diff --git a/zh.search-data.min.91e7f22db4c23c8aa5c240e671abe31713d24c3f0f93c5c62c6fd85567973880.json b/zh.search-data.min.5ae7646166a6fa591581bcf3a1abb5f2964eadd7a4960ad955e166dcdd20567f.json similarity index 76% rename from zh.search-data.min.91e7f22db4c23c8aa5c240e671abe31713d24c3f0f93c5c62c6fd85567973880.json rename to zh.search-data.min.5ae7646166a6fa591581bcf3a1abb5f2964eadd7a4960ad955e166dcdd20567f.json index 02cf51ec49d..6ca8f058771 100644 --- a/zh.search-data.min.91e7f22db4c23c8aa5c240e671abe31713d24c3f0f93c5c62c6fd85567973880.json +++ b/zh.search-data.min.5ae7646166a6fa591581bcf3a1abb5f2964eadd7a4960ad955e166dcdd20567f.json @@ -1 +1 @@ -[{"id":0,"href":"/zh/docs/technology/","title":"技术","section":"Docs","content":"这里面都是放一些平常技术知识的学习,知识来源主要来自经典书籍,或是其他通俗易懂的系列视频。\n"},{"id":1,"href":"/zh/docs/problem/","title":"问题解决","section":"Docs","content":"主要是一些平常遇到的一些问题,或是经过一顿折腾后解决的,或是临时遇到的小问题。\n"},{"id":2,"href":"/zh/docs/life/","title":"生活","section":"Docs","content":"生活相关的一些随感而发\n"},{"id":3,"href":"/zh/docs/technology/Review/java_guide/","title":"JavaGuide","section":"面试","content":"基本全部转载自https://github.com/Snailclimb/JavaGuide(添加小部分笔记)感谢作者! 不过不是整篇拷贝,也是一句句理解后,一个字一个字打下来的。\n"},{"id":4,"href":"/zh/docs/technology/Review/","title":"面试","section":"技术","content":"面试用的,面经。但是从另一个角度也可以说是梳理知识框架,爱恨交加。\n"},{"id":5,"href":"/zh/docs/technology/Interview/","title":"Interview","section":"技术","content":" 必看 # 项目介绍 使用建议 贡献指南 常见问题\n面试准备 # 手把手教你如何准备Java面试(重要) 程序员简历编写指南(重要) Java面试重点总结(重要) 项目经验指南 优质面经汇总(付费) 常见面试题自测(付费)\nJava # Java基础常见面试题总结(上) Java基础常见面试题总结(中) Java基础常见面试题总结(下)\n重要知识点 # Java 值传递详解 Java 序列化详解 泛型\u0026amp;通配符详解\nJava 反射机制详解\nJava 代理模式详解\nBigDecimal 详解\nJava 魔法类 Unsafe 详解\nJava SPI 机制详解\nJava 语法糖详解\n集合 # Java集合常见面试题总结(上)\nJava集合常见面试题总结(下)\nJava集合使用注意事项总结\n源码分析 # ArrayList 源码分析\nLinkedList 源码分析\nHashMap 源码分析\nConcurrentHashMap 源码分析\nLinkedHashMap 源码分析\nCopyOnWriteArrayList 源码分析\nArrayBlockingQueue 源码分析\nPriorityQueue 源码分析(付费)\nDelayQueue 源码分析\n并发编程 # Java并发常见面试题总结(上)\nJava并发常见面试题总结(中)\nJava并发常见面试题总结(下)\n重要知识点 # 乐观锁和悲观锁详解CAS\n详解JMM(Java 内存模型)\n详解Java 线程池详解Java\n线程池最佳实践Java\n常见并发容器总结AQS\n详解Atomic 原子类总结\nThreadLocal\n详解CompletableFuture\n详解虚拟线程常见问题总结\nIO # Java IO 基础知识总结\nJava IO 设计模式总结\nJava IO 模型详解\nJava NIO 核心知识总结\nJVM # Java内存区域详解(重点)\nJVM垃圾回收详解(重点)\n类文件结构详解\n类加载过程详解\n类加载器详解(重点)\n最重要的JVM参数总结\nJDK监控和故障处理工具总结\nJVM线上问题排查和性能调优案例\n新特性 # Java8 新特性实战\n《Java8 指南》中文翻译\nJava 9 新特性概览\nJava 10 新特性概览\nJava 11 新特性概览\nJava 12 \u0026amp; 13 新特性概览\nJava 14 \u0026amp; 15 新特性概览\nJava 16 新特性概览\nJava 17 新特性概览(重要)\nJava 18 新特性概览\nJava 19 新特性概览\nJava 20 新特性概览\nJava 21 新特性概览(重要)\nJava 22 \u0026amp; 23 新特性概览\n计算机基础 # 网络 # 计算机网络常见面试题总结(上)计算机网络常见面试题总结(下)\n重要知识点 # OSI 和 TCP/IP 网络分层模型详解(基础)\n访问网页的全过程(知识串联)\n应用层常见协议总结(应用层)\nHTTP vs HTTPS(应用层)\nHTTP 1.0 vs HTTP 1.1(应用层)\nHTTP 常见状态码总结(应用层)\nDNS 域名系统详解(应用层)\nTCP 三次握手和四次挥手(传输层)\nTCP 传输可靠性保障(传输层)\nARP 协议详解(网络层)\nNAT 协议详解(网络层)\n网络攻击常见手段总结\n操作系统 # 操作系统常见面试题总结(上)\n操作系统常见面试题总结(下)\nLinux # Linux 基础知识总结Shell\n编程基础知识总结\n数据结构 # 线性数据结构\n图\n堆\n树\n红黑树\n布隆过滤器\n算法 # 经典算法思想总结(含LeetCode题目推荐)\n常见数据结构经典LeetCode题目推荐\n几道常见的字符串算法题\n几道常见的链表算法题\n剑指offer部分编程题\n十大经典排序算法总结\n数据库 # 基础 # 数据库基础知识总结\nNoSQL基础知识总结\n字符集详解\nSQL # SQL语法基础知识总结\nSQL常见面试题总结(1)\nSQL常见面试题总结(2)\nSQL常见面试题总结(3)\nSQL常见面试题总结(4)\nSQL常见面试题总结(5)\nMySQL # MySQL常见面试题总结\nMySQL高性能优化规范建议总结\n重要知识点 # MySQL索引详解\nMySQL三大日志详解\nMySQL事务隔离级别详解\nInnoDB存储引擎对MVCC的实现\nSQL语句在MySQL中的执行过程\nMySQL查询缓存详解\nMySQL执行计划分析\nMySQL自增主键一定是连续的吗\nMySQL日期类型选择建议\nMySQL隐式转换造成索引失效\nRedis # 缓存基础常见面试题总结(付费)\nRedis常见面试题总结(上)\nRedis常见面试题总结(下)\n重要知识点 # 如何基于Redis实现延时任务\n3种常用的缓存读写策略详解\nRedis 5 种基本数据类型详解\nRedis 3 种特殊数据类型详解\nRedis为什么用跳表实现有序集合\nRedis持久化机制详解\nRedis内存碎片详解\nRedis常见阻塞原因总结\nRedis集群详解(付费)\nElasticsearch # Elasticsearch常见面试题总结(付费)\nMongoDB # MongoDB常见面试题总结(上)\nMongoDB常见面试题总结(下)\n开发工具 # Maven # Maven核心概念总结\nMaven最佳实践\nGradle # Gradle核心概念总结\nGit # Git核心概念总结\nGithub实用小技巧总结\nDocker # Docker核心概念总结\nDocker实战\nIDEA # 常用框架 # Spring\u0026amp;SpringBoot # Spring常见面试题总结\nSpringBoot常见面试题总结(付费)\nSpring\u0026amp;SpringBoot常用注解总结\nSpring Boot核心源码解读(付费)\n重要知识点 # IoC \u0026amp; AOP详解(快速搞懂)\nSpring 事务详解\nSpring 中的设计模式详解\nSpringBoot 自动装配原理详解\nMyBatis常见面试题总结 # Netty常见面试题总结(付费) # 系统设计 # 基础知识 # RestFul API 简明教程\n软件工程简明教程\n代码命名指南\n代码重构指南\n单元测试指南\n认证授权 # 认证授权基础概念详解\nJWT 基础概念详解\nJWT 身份认证优缺点分析\nSSO 单点登录详解\n权限系统设计详解\n数据安全 # 常见加密算法总结\n敏感词过滤方案总结\n数据脱敏方案总结\n系统设计常见面试题总结(付费) # 设计模式常见面试题总结 # Java 定时任务详解 # Web 实时消息推送详解 # 分布式 # 理论\u0026amp;算法\u0026amp;协议 # CAP \u0026amp; BASE理论详解\nPaxos 算法详解\nRaft 算法详解\nGossip 协议详解\nAPI网关 # API网关基础知识总结\nSpring Cloud Gateway常见问题总结\n分布式ID # 分布式ID介绍\u0026amp;实现方案总结\n分布式ID设计指南\n分布式锁 # 分布式锁介绍\n分布式锁常见实现方案总结\n分布式事务 # 分布式事务常见解决方案总结(付费)\n分布式配置中心 # 分布式配置中心常见问题总结(付费)\nRPC\nZooKeeper\n高性能\n高可用\nRPC # RPC基础知识总结\nDubbo常见问题总结\nZooKeeper # ZooKeeper相关概念总结(入门)\nZooKeeper相关概念总结(进阶)\n高性能 # CDN # CDN工作原理详解\n负载均衡 # 负载均衡原理及算法详解\n数据库优化 # 读写分离和分库分表详解\n数据冷热分离详解\n常见SQL优化手段总结(付费)\n深度分页介绍及优化建议\n消息队列 # 消息队列基础知识总结\nDisruptor常见问题总结\nKafka常见问题总结\nRocketMQ常见问题总结\nRabbitMQ常见问题总结\n高可用 # 高可用系统设计指南\n冗余设计详解\n服务限流详解\n降级\u0026amp;熔断详解(付费)\n超时\u0026amp;重试详解\n性能测试入门\n"},{"id":6,"href":"/zh/docs/test/","title":"测试","section":"Docs","content":"没啥重要的,随便测试的一些东西,大部分跟博客架构相关的东西,之前用的hexo,现在改用hugo了,还有一些东西在摸索中。\n"},{"id":7,"href":"/zh/docs/technology/MySQL/MySQL%E6%98%AF%E6%80%8E%E6%A0%B7%E8%BF%90%E8%A1%8C%E7%9A%84/%E7%AC%AC26%E7%AB%A0_%E5%86%99%E4%BD%9C%E6%9C%AC%E4%B9%A6%E6%97%B6%E7%94%A8%E5%88%B0%E7%9A%84%E4%B8%80%E4%BA%9B%E9%87%8D%E8%A6%81%E7%9A%84%E5%8F%82%E8%80%83%E8%B5%84%E6%96%99/","title":"第26章_写作本书时用到的一些重要的参考资料","section":"My Sql是怎样运行的","content":"第26章 写作本书时用到的一些重要的参考资料\n感谢 # 我不生产知识,只是知识的搬运工。写作本小册的时间主要用在了两个方面:\n搞清楚事情的本质是什么。\n这个过程就是研究源码、书籍和资料。\n如何把我已经知道的知识表达出来。\n这个过程就是我不停的在地上走过来走过去,梳理知识结构,斟酌用词用句,不停的将已经写好的文章推倒重来,只是想给大家一个不错的用户体验。\n这两个方面用的时间基本上是一半一半吧,在搞清楚事情的本质是什么阶段,除了直接阅读MySQL的源码之外,查看参考资料也是一种比较偷懒的学习方式。本书只是MySQL进阶的一个入门,想了解更多关于MySQL的知识,大家可以从下面这些资料里找点灵感。\n一些链接 # MySQL官方文档: https://dev.mysql.com/doc/refman/5.7/en/\nMySQL官方文档是写作本书时参考最多的一个资料。说实话,文档写的非常通俗易懂,唯一的缺点就是太长了,导致大家看的时候无从下手。\nMySQL Internals Manual: https://dev.mysql.com/doc/internals/en/\n介绍MySQL如何实现各种功能的文档,写的比较好,但是太少了,有很多章节直接跳过了。\n何登成的github: https://github.com/hedengcheng/tech\n登博的博客非常好,对事务、优化这讨论的细节也非常多,不过由于大多是PPT结构,字太少,对上下文不清楚的同学可能会一脸懵逼。\norczhou的博客: http://www.orczhou.com/\nJeremy Cole的博客: https://blog.jcole.us/innodb/\nJeremy Cole大神不仅写作了innodb_ruby这个非常棒的解析InnoDB存储结构的工具,还对这些存储结构写了一系列的博客,在我几乎要放弃深入研究表空间结构的时候,是他老人家的博客把我又从深渊里拉了回来。\n那海蓝蓝(李海翔)的博客: https://blog.csdn.net/fly2nn\ntaobao月报: http://mysql.taobao.org/monthly/\n因为MySQL的源码非常多,经常让大家无从下手,而taobao月报就是一个非常好的源码阅读指南。\n吐槽一下,这个taobao月报也只能当作源码阅读指南看,如果真的不看源码光看月报,那只能当作天书看,十有八九被绕进去出不来了。\nMySQL Server Blog: http://mysqlserverteam.com/\nMySQL team的博客,一手资料,在我不知道看什么的时候给了很多启示。\nmysql_lover的博客: https://blog.csdn.net/mysql_lover/\nJorgen\u0026rsquo;s point of view: https://jorgenloland.blogspot.com/ mariadb的关于查询优化的文档: https://mariadb.com/kb/en/library/query-optimizations/\n不得不说mariadb的文档相比MySQL的来说就非常有艺术性了(里边儿有很多漂亮的插图),我很怀疑MySQL文档是程序员直接写的,mariadb的文档是产品经理写的。当我们想研究某个功能的原理,在MySQL文档干巴巴的说明中找不到头脑时,可以参考一下mariadb娓娓道来的风格。\nReconstructing Data Manipulation Queries from Redo Logs: https://www.sba-research.org/wp-content/uploads/publications/WSDF2012_InnoDB.pdf\n关于InnoDB事务的一个PPT: https://mariadb.org/wp-content/uploads/2018/02/Deep-Dive_-InnoDB-Transactions-and-Write-Paths.pdf 非官方优化文档: http://www.unofficialmysqlguide.com/optimizer-trace.html\n这个文档非常好,非常非常好~\nMySQL8.0的源码文档: https://dev.mysql.com/doc/dev/mysql-server\n一些书籍 # 《数据库查询优化器的艺术》李海翔著\n大家可以把这本书当作源码观看指南来看,不过讲的是5.6的源码,5.7里重构了一些,不过大体的思路还是可以参考的。\n《MySQL运维内参》周彦伟、王竹峰、强昌金著\n内参里有许多代码细节,是一个阅读源码的比较好的指南。\n《Effectiv MySQL:Optimizing SQL Statements》Ronald Bradford著\n小册子,可以一口气看完,对了解MySQL查询优化的大概内容还是有些好处滴。\n《高性能MySQL》瓦茨 (Baron Schwartz) / 扎伊采夫 (Peter Zaitsev) / 特卡琴科 (Vadim Tkachenko) 著\n经典,对于第三版的内容来说,如果把第2章和第3章的内容放到最后就更好了。不过作者更愿意把MySQL当作一个黑盒去讲述,主要是说明了如何更好的使用MySQL这个软件,这一点从第二版向第三版的转变上就可以看出来,第二版中涉及的许多的底层细节都在第三版中移除了。总而言之它是MySQL进阶的一个非常好的入门读物。\n《数据库事务处理的艺术》李海翔著\n同《数据库查询优化器的艺术》。\n《MySQL技术内幕 : InnoDB存储引擎 第2版》姜承尧著\n学习MySQL内核进阶阅读的第一本书。\n《MySQL技术内幕 第5版》 Paul DuBois 著\n这本书是对于MySQL使用层面的一个非常详细的介绍,也就是说它并不涉及MySQL的任何内核原理,甚至连索引结构都懒得讲。像是一个老妈子在给你不停的介绍吃饭怎么吃,喝水怎么喝,怎么上厕所的各种絮叨。整体风格比较像MySQL的官方文档,如果有想从使用层面从头了解MySQL的同学可以尝试的看看。\n《数据库系统概念》(美)Abraham Silberschatz / (美)Henry F.Korth / (美)S.Sudarshan 著\n这本书对于入门数据库原理来说非常好,不过看起来学术气味比较大一些,毕竟是一本正经的教科书,里边有不少的公式什么的。\n《事务处理 概念与技术》Jim Gray / Andreas Reuter 著\n这本书只是象征性的看了1~5章,说实话看不太懂,总是get不到作者要表达的点。不过听说业界非常推崇这本书,而恰巧我也看过一点,就写上了,有兴趣的同学可以去看看。\n说点不好的 # 上面尽说这些参考资料如何如何好了,主要是因为在我写作过程中的确参考到了,没有这些资料可能三五年都无法把小册写完。但是除了MySQL的文档以及《高性能MySQL》、《Effectiv MySQL:Optimizing SQL Statements》这两本书之外,其余的资料在大部分时间都是看的我头晕眼花,四肢乏力,不看个十遍八遍基本无法理清楚作者想要表达的点,这也是我写本小册的初衷\u0026mdash;让天下没有难学的知识。\n结语 # 希望这是各位2019年最爽的一次知识付费,如果各位因为阅读本小册而顺利通过面试,或者解决了工作中的很多技术问题,觉得29.9实在是太物超所值,希望各位能来给点打赏(本人很穷,靠救济生活~ 添加好友可以问关于小册的问题,不过希望不要扯犊子聊八卦了,我其实挺忙的~ 微信号:xiaohaizi4919)。\n小贴士:请允许我鄙视一下那些打着知识付费骗钱的人,除了不生产一点社会价值外,反而生产了数不清的焦虑,让人们连幸福感都丧失掉了。也请各位警惕那些说只要你交几百块钱,就能得到诸如境界上的提升、开阔了眼界、追赶上行业发展趋势之类的课程/知识付费,这类抽象而无法验证的主题都是骗人的。 "},{"id":8,"href":"/zh/docs/technology/MySQL/MySQL%E6%98%AF%E6%80%8E%E6%A0%B7%E8%BF%90%E8%A1%8C%E7%9A%84/%E7%AC%AC25%E7%AB%A0_%E5%B7%A5%E4%BD%9C%E9%9D%A2%E8%AF%95%E8%80%81%E5%A4%A7%E9%9A%BE-%E9%94%81/","title":"第25章_工作面试老大难-锁","section":"My Sql是怎样运行的","content":"第25章 工作面试老大难-锁\n解决并发事务带来问题的两种基本方式 # 上一章介绍了事务并发执行时可能带来的各种问题,并发事务访问相同记录的情况大致可以划分为3种:\n读-读情况:即并发事务相继读取相同的记录。\n读取操作本身不会对记录有一毛钱影响,并不会引起什么问题,所以允许这种情况的发生。\n写-写情况:即并发事务相继对相同的记录做出改动。\n我们前面说过,在这种情况下会发生脏写的问题,任何一种隔离级别都不允许这种问题的发生。所以在多个未提交事务相继对一条记录做改动时,需要让它们排队执行,这个排队的过程其实是通过锁来实现的。这个所谓的锁其实是一个内存中的结构,在事务执行前本来是没有锁的,也就是说一开始是没有锁结构和记录进行关联的,如图所示:\n当一个事务想对这条记录做改动时,首先会看看内存中有没有与这条记录关联的锁结构,当没有的时候就会在内存中生成一个锁结构与之关联。比方说事务T1要对这条记录做改动,就需要生成一个锁结构与之关联:\n其实在锁结构里有很多信息,不过为了简化理解,我们现在只把两个比较重要的属性拿了出来: - trx信息:代表这个锁结构是哪个事务生成的。 - is_waiting:代表当前事务是否在等待。\n如图所示,当事务T1改动了这条记录后,就生成了一个锁结构与该记录关联,因为之前没有别的事务为这条记录加锁,所以is_waiting属性就是false,我们把这个场景就称之为获取锁成功,或者加锁成功,然后就可以继续执行操作了。\n在事务T1提交之前,另一个事务T2也想对该记录做改动,那么先去看看有没有锁结构与这条记录关联,发现有一个锁结构与之关联后,然后也生成了一个锁结构与这条记录关联,不过锁结构的is_waiting属性值为true,表示当前事务需要等待,我们把这个场景就称之为获取锁失败,或者加锁失败,或者没有成功的获取到锁,画个图表示就是这样:\n在事务T1提交之后,就会把该事务生成的锁结构释放掉,然后看看还有没有别的事务在等待获取锁,发现了事务T2还在等待获取锁,所以把事务T2对应的锁结构的is_waiting属性设置为false,然后把该事务对应的线程唤醒,让它继续执行,此时事务T2就算获取到锁了。效果图就是这样:\n我们总结一下后续内容中可能用到的几种说法,以免大家混淆:\n+ 不加锁\n意思就是不需要在内存中生成对应的锁结构,可以直接执行操作。\n+ 获取锁成功,或者加锁成功\n意思就是在内存中生成了对应的锁结构,而且锁结构的is_waiting属性为false,也就是事务可以继续执行操作。\n+ 获取锁失败,或者加锁失败,或者没有获取到锁\n意思就是在内存中生成了对应的锁结构,不过锁结构的is_waiting属性为true,也就是事务需要等待,不可以继续执行操作。\n小贴士:这里只是对锁结构做了一个非常简单的描述,我们后边会详细介绍介绍锁结构的,稍安勿躁。\n读-写或写-读情况:也就是一个事务进行读取操作,另一个进行改动操作。\n我们前面说过,这种情况下可能发生脏读、不可重复读、幻读的问题。\n小贴士:幻读问题的产生是因为某个事务读了一个范围的记录,之后别的事务在该范围内插入了新记录,该事务再次读取该范围的记录时,可以读到新插入的记录,所以幻读问题准确的说并不是因为读取和写入一条相同记录而产生的,这一点要注意一下。\nSQL标准规定不同隔离级别下可能发生的问题不一样: - 在READ UNCOMMITTED隔离级别下,脏读、不可重复读、幻读都可能发生。 - 在READ COMMITTED隔离级别下,不可重复读、幻读可能发生,脏读不可以发生。 - 在REPEATABLE READ隔离级别下,幻读可能发生,脏读和不可重复读不可以发生。 - 在SERIALIZABLE隔离级别下,上述问题都不可以发生。\n不过各个数据库厂商对SQL标准的支持都可能不一样,与SQL标准不同的一点就是,MySQL在REPEATABLE READ隔离级别实际上就已经解决了幻读问题。\n怎么解决脏读、不可重复读、幻读这些问题呢?其实有两种可选的解决方案:\n+ 方案一:读操作利用多版本并发控制(MVCC),写操作进行加锁。\n所谓的MVCC我们在前一章有过详细的描述,就是通过生成一个ReadView,然后通过ReadView找到符合条件的记录版本(历史版本是由undo日志构建的),其实就像是在生成ReadView的那个时刻做了一次时间静止(就像用相机拍了一个快照),查询语句只能读到在生成ReadView之前已提交事务所做的更改,在生成ReadView之前未提交的事务或者之后才开启的事务所做的更改是看不到的。而写操作肯定针对的是最新版本的记录,读记录的历史版本和改动记录的最新版本本身并不冲突,也就是采用MVCC时,读-写操作并不冲突。\n小贴士:我们说过普通的SELECT语句在READ COMMITTED和REPEATABLE READ隔离级别下会使用到MVCC读取记录。在READ COMMITTED隔离级别下,一个事务在执行过程中每次执行SELECT操作时都会生成一个ReadView,ReadView的存在本身就保证了事务不可以读取到未提交的事务所做的更改,也就是避免了脏读现象;REPEATABLE READ隔离级别下,一个事务在执行过程中只有第一次执行SELECT操作才会生成一个ReadView,之后的SELECT操作都复用这个ReadView,这样也就避免了不可重复读和幻读的问题。\n+ 方案二:读、写操作都采用加锁的方式。\n如果我们的一些业务场景不允许读取记录的旧版本,而是每次都必须去读取记录的最新版本,比方在银行存款的事务中,你需要先把账户的余额读出来,然后将其加上本次存款的数额,最后再写到数据库中。在将账户余额读取出来后,就不想让别的事务再访问该余额,直到本次存款事务执行完成,其他事务才可以访问账户的余额。这样在读取记录的时候也就需要对其进行加锁操作,这样也就意味着读操作和写操作也像写-写操作那样排队执行。\n小贴士: 我们说脏读的产生是因为当前事务读取了另一个未提交事务写的一条记录,如果另一个事务在写记录的时候就给这条记录加锁,那么当前事务就无法继续读取该记录了,所以也就不会有脏读问题的产生了。不可重复读的产生是因为当前事务先读取一条记录,另外一个事务对该记录做了改动之后并提交之后,当前事务再次读取时会获得不同的值,如果在当前事务读取记录时就给该记录加锁,那么另一个事务就无法修改该记录,自然也不会发生不可重复读了。我们说幻读问题的产生是因为当前事务读取了一个范围的记录,然后另外的事务向该范围内插入了新记录,当前事务再次读取该范围的记录时发现了新插入的新记录,我们把新插入的那些记录称之为幻影记录。采用加锁的方式解决幻读问题就有那么一丢丢麻烦了,因为当前事务在第一次读取记录时那些幻影记录并不存在,所以读取的时候加锁就有点尴尬 —— 因为你并不知道给谁加锁,没关系,这难不倒设计InnoDB的大佬的,我们稍后揭晓答案,稍安勿躁。\n很明显,采用MVCC方式的话,读-写操作彼此并不冲突,性能更高,采用加锁方式的话,读-写操作彼此需要排队执行,影响性能。一般情况下我们当然愿意采用MVCC来解决读-写操作并发执行的问题,但是业务在某些特殊情况下,要求必须采用加锁的方式执行,那也是没有办法的事。\n一致性读(Consistent Reads) # 事务利用MVCC进行的读取操作称之为一致性读,或者一致性无锁读,有的地方也称之为快照读。所有普通的SELECT语句(plain SELECT)在READ COMMITTED、REPEATABLE READ隔离级别下都算是一致性读,比方说: SELECT * FROM t; SELECT * FROM t1 INNER JOIN t2 ON t1.col1 = t2.col2 一致性读并不会对表中的任何记录做加锁操作,其他事务可以自由的对表中的记录做改动。\n锁定读(Locking Reads) # 共享锁和独占锁 # 我们前面说过,并发事务的读-读情况并不会引起什么问题,不过对于写-写、读-写或写-读这些情况可能会引起一些问题,需要使用MVCC或者加锁的方式来解决它们。在使用加锁的方式解决问题时,由于既要允许读-读情况不受影响,又要使写-写、读-写或写-读情况中的操作相互阻塞,所以设计MySQL的大佬给锁分了个类: - 共享锁,英文名:Shared Locks,简称S锁。在事务要读取一条记录时,需要先获取该记录的S锁。 - 独占锁,也常称排他锁,英文名:Exclusive Locks,简称X锁。在事务要改动一条记录时,需要先获取该记录的X锁。\n假如事务T1首先获取了一条记录的S锁之后,事务T2接着也要访问这条记录: - 如果事务T2想要再获取一个记录的S锁,那么事务T2也会获得该锁,也就意味着事务T1和T2在该记录上同时持有S锁。 - 如果事务T2想要再获取一个记录的X锁,那么此操作会被阻塞,直到事务T1提交之后将S锁释放掉。\n如果事务T1首先获取了一条记录的X锁之后,那么不管事务T2接着想获取该记录的S锁还是X锁都会被阻塞,直到事务T1提交。\n所以我们说S锁和S锁是兼容的,S锁和X锁是不兼容的,X锁和X锁也是不兼容的,画个表表示一下就是这样: 兼容性 X S X 不兼容 不兼容 S 不兼容 兼容\n锁定读的语句 # 我们前面说在采用加锁方式解决脏读、不可重复读、幻读这些问题时,读取一条记录时需要获取一下该记录的S锁,其实这是不严谨的,有时候想在读取记录时就获取记录的X锁,来禁止别的事务读写该记录,为此设计MySQL的大佬提出了两种比较特殊的SELECT语句格式:\n对读取的记录加S锁:\nSELECT ... LOCK IN SHARE MODE;\n也就是在普通的SELECT语句后边加LOCK IN SHARE MODE,如果当前事务执行了该语句,那么它会为读取到的记录加S锁,这样允许别的事务继续获取这些记录的S锁(比方说别的事务也使用SELECT ... LOCK IN SHARE MODE语句来读取这些记录),但是不能获取这些记录的X锁(比方说使用SELECT ... FOR UPDATE语句来读取这些记录,或者直接修改这些记录)。如果别的事务想要获取这些记录的X锁,那么它们会阻塞,直到当前事务提交之后将这些记录上的S锁释放掉。\n对读取的记录加X锁:\nSELECT ... FOR UPDATE;\n也就是在普通的SELECT语句后边加FOR UPDATE,如果当前事务执行了该语句,那么它会为读取到的记录加X锁,这样既不允许别的事务获取这些记录的S锁(比方说别的事务使用SELECT ... LOCK IN SHARE MODE语句来读取这些记录),也不允许获取这些记录的X锁(比方也说使用SELECT ... FOR UPDATE语句来读取这些记录,或者直接修改这些记录)。如果别的事务想要获取这些记录的S锁或者X锁,那么它们会阻塞,直到当前事务提交之后将这些记录上的X锁释放掉。\n关于更多锁定读的加锁细节我们稍后会详细介绍,稍安勿躁。\n写操作 # 平常所用到的写操作无非是DELETE、UPDATE、INSERT这三种:\nDELETE:\n对一条记录做DELETE操作的过程其实是先在B+树中定位到这条记录的位置,然后获取一下这条记录的X锁,然后再执行delete mark操作。我们也可以把这个定位待删除记录在B+树中位置的过程看成是一个获取X锁的锁定读。\nUPDATE:\n在对一条记录做UPDATE操作时分为三种情况: - 如果未修改该记录的键值并且被更新的列占用的存储空间在修改前后未发生变化,则先在B+树中定位到这条记录的位置,然后再获取一下记录的X锁,最后在原记录的位置进行修改操作。其实我们也可以把这个定位待修改记录在B+树中位置的过程看成是一个获取X锁的锁定读。 - 如果未修改该记录的键值并且至少有一个被更新的列占用的存储空间在修改前后发生变化,则先在B+树中定位到这条记录的位置,然后获取一下记录的X锁,将该记录彻底删除掉(就是把记录彻底移入垃圾链表),最后再插入一条新记录。这个定位待修改记录在B+树中位置的过程看成是一个获取X锁的锁定读,新插入的记录由INSERT操作提供的隐式锁进行保护。 - 如果修改了该记录的键值,则相当于在原记录上做DELETE操作之后再来一次INSERT操作,加锁操作就需要按照DELETE和INSERT的规则进行了。\nINSERT:\n一般情况下,新插入一条记录的操作并不加锁,设计InnoDB的大佬通过一种称之为隐式锁的东东来保护这条新插入的记录在本事务提交前不被别的事务访问,更多细节我们后边看~\n小贴士:当然,在一些特殊情况下INSERT操作也是会获取锁的,具体情况我们后边介绍。\n多粒度锁 # 我们前面提到的锁都是针对记录的,也可以被称之为行级锁或者行锁,对一条记录加锁影响的也只是这条记录而已,我们就说这个锁的粒度比较细;其实一个事务也可以在表级别进行加锁,自然就被称之为表级锁或者表锁,对一个表加锁影响整个表中的记录,我们就说这个锁的粒度比较粗。给表加的锁也可以分为共享锁(S锁)和独占锁(X锁):\n给表加S锁:\n如果一个事务给表加了S锁,那么: - 别的事务可以继续获得该表的S锁 - 别的事务可以继续获得该表中的某些记录的S锁 - 别的事务不可以继续获得该表的X锁 - 别的事务不可以继续获得该表中的某些记录的X锁\n给表加X锁:\n如果一个事务给表加了X锁(意味着该事务要独占这个表),那么: - 别的事务不可以继续获得该表的S锁 - 别的事务不可以继续获得该表中的某些记录的S锁 - 别的事务不可以继续获得该表的X锁 - 别的事务不可以继续获得该表中的某些记录的X锁\n上面看着有点啰嗦,为了更好的理解这个表级别的S锁和X锁,我们举一个现实生活中的例子。不知道各位同学都上过大学没,我们以大学教学楼中的教室为例来分析一下加锁的情况: - 教室一般都是公用的,我们可以随便选教室进去上自习。当然,教室不是自家的,一间教室可以容纳很多同学同时上自习,每当一个人进去上自习,就相当于在教室门口挂了一把S锁,如果很多同学都进去上自习,相当于教室门口挂了很多把S锁(类似行级别的S锁)。 - 有的时候教室会进行检修,比方说换地板,换天花板,换灯管什么的,这些维修项目并不能同时开展。如果教室针对某个项目进行检修,就不允许别的同学来上自习,也不允许其他维修项目进行,此时相当于教室门口会挂一把X锁(类似行级别的X锁)。\n上面提到的这两种锁都是针对教室而言的,不过有时候我们会有一些特殊的需求: - 有领导要来参观教学楼的环境。\n校领导考虑并不想影响同学们上自习,但是此时不能有教室处于维修状态,所以可以在教学楼门口放置一把`S锁`(类似表级别的`S锁`)。此时: - 来上自习的学生们看到教学楼门口有`S锁`,可以继续进入教学楼上自习。 - 修理工看到教学楼门口有`S锁`,则先在教学楼门口等着,什么时候领导走了,把教学楼的`S锁`撤掉再进入教学楼维修。 学校要占用教学楼进行考试。\n此时不允许教学楼中有正在上自习的教室,也不允许对教室进行维修。所以可以在教学楼门口放置一把X锁(类似表级别的X锁)。此时: - 来上自习的学生们看到教学楼门口有X锁,则需要在教学楼门口等着,什么时候考试结束,把教学楼的X锁撤掉再进入教学楼上自习。 - 修理工看到教学楼门口有X锁,则先在教学楼门口等着,什么时候考试结束,把教学楼的X锁撤掉再进入教学楼维修。\n但是这里头有两个问题: - 如果我们想对教学楼整体上S锁,首先需要确保教学楼中的没有正在维修的教室,如果有正在维修的教室,需要等到维修结束才可以对教学楼整体上S锁。 - 如果我们想对教学楼整体上X锁,首先需要确保教学楼中的没有上自习的教室以及正在维修的教室,如果有上自习的教室或者正在维修的教室,需要等到全部上自习的同学都上完自习离开,以及维修工维修完教室离开后才可以对教学楼整体上X锁。\n我们在对教学楼整体上锁(表锁)时,怎么知道教学楼中有没有教室已经被上锁(行锁)了呢?依次检查每一间教室门口有没有上锁?那这效率也太慢了吧!遍历是不可能遍历的,这辈子也不可能遍历的,于是乎设计InnoDB的大佬们提出了一种称之为意向锁(英文名:Intention Locks)的东东: - 意向共享锁,英文名:Intention Shared Lock,简称IS锁。当事务准备在某条记录上加S锁时,需要先在表级别加一个IS锁。 - 意向独占锁,英文名:Intention Exclusive Lock,简称IX锁。当事务准备在某条记录上加X锁时,需要先在表级别加一个IX锁。\n视角回到教学楼和教室上来: - 如果有学生到教室中上自习,那么他先在整栋教学楼门口放一把IS锁(表级锁),然后再到教室门口放一把S锁(行锁)。 - 如果有维修工到教室中维修,那么它先在整栋教学楼门口放一把IX锁(表级锁),然后再到教室门口放一把X锁(行锁)。\n之后: - 如果有领导要参观教学楼,也就是想在教学楼门口前放S锁(表锁)时,首先要看一下教学楼门口有没有IX锁,如果有,意味着有教室在维修,需要等到维修结束把IX锁撤掉后才可以在整栋教学楼上加S锁。 - 如果有考试要占用教学楼,也就是想在教学楼门口前放X锁(表锁)时,首先要看一下教学楼门口有没有IS锁或IX锁,如果有,意味着有教室在上自习或者维修,需要等到学生们上完自习以及维修结束把IS锁和IX锁撤掉后才可以在整栋教学楼上加X锁。\n小贴士:学生在教学楼门口加IS锁时,是不关心教学楼门口是否有IX锁的,维修工在教学楼门口加IX锁时,是不关心教学楼门口是否有IS锁或者其他IX锁的。IS和IX锁只是为了判断当前时间教学楼里有没有被占用的教室用的,也就是在对教学楼加S锁或者X锁时才会用到。\n总结一下:IS、IX锁是表级锁,它们的提出仅仅为了在之后加表级别的S锁和X锁时可以快速判断表中的记录是否被上锁,以避免用遍历的方式来查看表中有没有上锁的记录,也就是说其实IS锁和IX锁是兼容的,IX锁和IX锁是兼容的。我们画个表来看一下表级别的各种锁的兼容性: 兼容性 X IX S IS X 不兼容 不兼容 不兼容 不兼容 IX 不兼容 兼容 不兼容 兼容 S 不兼容 不兼容 兼容 兼容 IS 不兼容 兼容 兼容 兼容\nMySQL中的行锁和表锁 # 上面说的都算是些理论知识,其实MySQL支持多种存储引擎,不同存储引擎对锁的支持也是不一样的。当然,我们重点还是讨论InnoDB存储引擎中的锁,其他的存储引擎只是稍微提一下~\n其他存储引擎中的锁 # 对于MyISAM、MEMORY、MERGE这些存储引擎来说,它们只支持表级锁,而且这些引擎并不支持事务,所以使用这些存储引擎的锁一般都是针对当前会话来说的。比方说在Session 1中对一个表执行SELECT操作,就相当于为这个表加了一个表级别的S锁,如果在SELECT操作未完成时,Session 2中对这个表执行UPDATE操作,相当于要获取表的X锁,此操作会被阻塞,直到Session 1中的SELECT操作完成,释放掉表级别的S锁后,Session 2中对这个表执行UPDATE操作才能继续获取X锁,然后执行具体的更新语句。\n小贴士:因为使用MyISAM、MEMORY、MERGE这些存储引擎的表在同一时刻只允许一个会话对表进行写操作,所以这些存储引擎实际上最好用在只读,或者大部分都是读操作,或者单用户的情景下。另外,在MyISAM存储引擎中有一个称之为Concurrent Inserts的特性,支持在对MyISAM表读取时同时插入记录,这样可以提升一些插入速度。关于更多Concurrent Inserts的细节,我们就不介绍了,详情可以参考文档。\nInnoDB存储引擎中的锁 # InnoDB存储引擎既支持表锁,也支持行锁。表锁实现简单,占用资源较少,不过粒度很粗,有时候你仅仅需要锁住几条记录,但使用表锁的话相当于为表中的所有记录都加锁,所以性能比较差。行锁粒度更细,可以实现更精准的并发控制。下面我们详细看一下。\nInnoDB中的表级锁 # 表级别的S锁、X锁\n在对某个表执行SELECT、INSERT、DELETE、UPDATE语句时,InnoDB存储引擎是不会为这个表添加表级别的S锁或者X锁的。\n另外,在对某个表执行一些诸如ALTER TABLE、DROP TABLE这类的DDL语句时,其他事务对这个表并发执行诸如SELECT、INSERT、DELETE、UPDATE的语句会发生阻塞,同理,某个事务中对某个表执行SELECT、INSERT、DELETE、UPDATE语句时,在其他会话中对这个表执行DDL语句也会发生阻塞。这个过程其实是通过在server层使用一种称之为元数据锁(英文名:Metadata Locks,简称MDL)东东来实现的,一般情况下也不会使用InnoDB存储引擎自己提供的表级别的S锁和X锁。\n小贴士:在事务简介的章节中我们说过,DDL语句执行时会隐式的提交当前会话中的事务,这主要是DDL语句的执行一般都会在若干个特殊事务中完成,在开启这些特殊事务前,需要将当前会话中的事务提交掉。另外,关于MDL锁并不是我们本章所要讨论的范围,大家可以参阅文档了解~\n其实这个InnoDB存储引擎提供的表级S锁或者X锁是相当鸡肋,只会在一些特殊情况下,比方说崩溃恢复过程中用到。不过我们还是可以手动获取一下的,比方说在系统变量autocommit=0,innodb_table_locks = 1时,手动获取InnoDB存储引擎提供的表t的S锁或者X锁可以这么写: - LOCK TABLES t READ:InnoDB存储引擎会对表t加表级别的S锁。 - LOCK TABLES t WRITE:InnoDB存储引擎会对表t加表级别的X锁。\n不过请尽量避免在使用InnoDB存储引擎的表上使用LOCK TABLES这样的手动锁表语句,它们并不会提供什么额外的保护,只是会降低并发能力而已。InnoDB的厉害之处还是实现了更细粒度的行锁,关于表级别的S锁和X锁大家了解一下就罢了。\n表级别的IS锁、IX锁\n当我们在对使用InnoDB存储引擎的表的某些记录加S锁之前,那就需要先在表级别加一个IS锁,当我们在对使用InnoDB存储引擎的表的某些记录加X锁之前,那就需要先在表级别加一个IX锁。IS锁和IX锁的使命只是为了后续在加表级别的S锁和X锁时判断表中是否有已经被加锁的记录,以避免用遍历的方式来查看表中有没有上锁的记录。更多关于IS锁和IX锁的解释我们上面都介绍过了,就不赘述了。\n表级别的AUTO-INC锁\n在使用MySQL过程中,我们可以为表的某个列添加AUTO_INCREMENT属性,之后在插入记录时,可以不指定该列的值,系统会自动为它赋上递增的值,比方说我们有一个表: CREATE TABLE t ( id INT NOT NULL AUTO_INCREMENT, c VARCHAR(100), PRIMARY KEY (id) ) Engine=InnoDB CHARSET=utf8; 由于这个表的id字段声明了AUTO_INCREMENT,也就意味着在书写插入语句时不需要为其赋值,比方说这样: INSERT INTO t(c) VALUES('aa'), ('bb'); 上面的插入语句并没有为id列显式赋值,所以系统会自动为它赋上递增的值,效果就是这样:\nmysql\u0026gt; SELECT * FROM t; +----+------+ | id | c | +----+------+ | 1 | aa | | 2 | bb | +----+------+ 2 rows in set (0.00 sec) 系统实现这种自动给AUTO_INCREMENT修饰的列递增赋值的原理主要是两个:\n+ 采用AUTO-INC锁,也就是在执行插入语句时就在表级别加一个AUTO-INC锁,然后为每条待插入记录的AUTO_INCREMENT修饰的列分配递增的值,在该语句执行结束后,再把AUTO-INC锁释放掉。这样一个事务在持有AUTO-INC锁的过程中,其他事务的插入语句都要被阻塞,可以保证一个语句中分配的递增值是连续的。\n如果我们的插入语句在执行前不可以确定具体要插入多少条记录(无法预计即将插入记录的数量),比方说使用INSERT ... SELECT、REPLACE ... SELECT或者LOAD DATA这种插入语句,一般是使用AUTO-INC锁为AUTO_INCREMENT修饰的列生成对应的值。\n小贴士:需要注意一下的是,这个AUTO-INC锁的作用范围只是单个插入语句,插入语句执行完成后,这个锁就被释放了,跟我们之前介绍的锁在事务结束时释放是不一样的。\n+ 采用一个轻量级的锁,在为插入语句生成AUTO_INCREMENT修饰的列的值时获取一下这个轻量级锁,然后生成本次插入语句需要用到的AUTO_INCREMENT列的值之后,就把该轻量级锁释放掉,并不需要等到整个插入语句执行完才释放锁。\n如果我们的插入语句在执行前就可以确定具体要插入多少条记录,比方说我们上面举的关于表t的例子中,在语句执行前就可以确定要插入2条记录,那么一般采用轻量级锁的方式对AUTO_INCREMENT修饰的列进行赋值。这种方式可以避免锁定表,可以提升插入性能。\n小贴士:设计InnoDB的大佬提供了一个称之为innodb_autoinc_lock_mode的系统变量来控制到底使用上述两种方式中的哪种来为AUTO_INCREMENT修饰的列进行赋值,当innodb_autoinc_lock_mode值为0时,一律采用AUTO-INC锁;当innodb_autoinc_lock_mode值为2时,一律采用轻量级锁;当innodb_autoinc_lock_mode值为1时,两种方式混着来(也就是在插入记录数量确定时采用轻量级锁,不确定时使用AUTO-INC锁)。不过当innodb_autoinc_lock_mode值为2时,可能会造成不同事务中的插入语句为AUTO_INCREMENT修饰的列生成的值是交叉的,在有主从复制的场景中是不安全的。\nInnoDB中的行级锁 # 很遗憾的通知大家一个不好的消息,上面讲的都是铺垫,本章真正的重点才刚刚开始[手动偷笑]。\n行锁,也称为记录锁,顾名思义就是在记录上加的锁。不过设计InnoDB的大佬很有才,一个行锁玩出了各种花样,也就是把行锁分成了各种类型。换句话说即使对同一条记录加行锁,如果类型不同,起到的功效也是不同的。为了故事的顺利发展,我们还是先将之前介绍MVCC时用到的表抄一遍: CREATE TABLE hero ( number INT, name VARCHAR(100), country varchar(100), PRIMARY KEY (number), KEY idx_name (name) ) Engine=InnoDB CHARSET=utf8; 我们主要是想用这个表存储三国时的英雄,然后向这个表里插入几条记录: INSERT INTO hero VALUES (1, 'l刘备', '蜀'), (3, 'z诸葛亮', '蜀'), (8, 'c曹操', '魏'), (15, 'x荀彧', '魏'), (20, 's孙权', '吴'); 现在表里的数据就是这样的: mysql\u0026gt; SELECT * FROM hero; +--------+------------+---------+ | number | name | country | +--------+------------+---------+ | 1 | l刘备 | 蜀 | | 3 | z诸葛亮 | 蜀 | | 8 | c曹操 | 魏 | | 15 | x荀彧 | 魏 | | 20 | s孙权 | 吴 | +--------+------------+---------+ 5 rows in set (0.01 sec) 小贴士:不是说好的存储三国时的英雄么,你在搞什么,为什么要在'刘备'、'曹操'、'孙权'前面加上'l'、'c'、's'这几个字母呀?这个主要是因为我们采用utf8字符集,该字符集并没有对应的按照汉语拼音进行排序的比较规则,也就是说'刘备'、'曹操'、'孙权'这几个字符串的排序并不是按照它们汉语拼音进行排序的,我怕大家懵逼,所以在汉字前面加上了汉字对应的拼音的第一个字母,这样在排序时就是按照汉语拼音进行排序,大家也不懵逼了。另外,我们故意把各条记录number列的值搞得很分散,后边会用到,稍安勿躁~ 我们把hero表中的聚簇索引的示意图画一下:\n当然,我们把B+树的索引结构做了一个超级简化,只把索引中的记录给拿了出来,我们这里只是想强调聚簇索引中的记录是按照主键大小排序的,并且省略掉了聚簇索引中的隐藏列,大家心里明白就好(不理解索引结构的同学可以去前面的文章中查看)。\n现在准备工作做完了,下面我们来看看都有哪些常用的行锁类型。\nRecord Locks:\n我们前面提到的记录锁就是这种类型,也就是仅仅把一条记录锁上,我决定给这种类型的锁起一个比较不正经的名字:正经记录锁(请允许我皮一下,我实在不知道该叫什么名好)。官方的类型名称为:LOCK_REC_NOT_GAP。比方说我们把number值为8的那条记录加一个正经记录锁的示意图如下:\n正经记录锁是有S锁和X锁之分的,让我们分别称之为S型正经记录锁和X型正经记录锁吧(听起来有点怪怪的),当一个事务获取了一条记录的S型正经记录锁后,其他事务也可以继续获取该记录的S型正经记录锁,但不可以继续获取X型正经记录锁;当一个事务获取了一条记录的X型正经记录锁后,其他事务既不可以继续获取该记录的S型正经记录锁,也不可以继续获取X型正经记录锁;\nGap Locks:\n我们说MySQL在REPEATABLE READ隔离级别下是可以解决幻读问题的,解决方案有两种,可以使用MVCC方案解决,也可以采用加锁方案解决。但是在使用加锁方案解决时有个大问题,就是事务在第一次执行读取操作时,那些幻影记录尚不存在,我们无法给这些幻影记录加上正经记录锁。不过这难不倒设计InnoDB的大佬,他们提出了一种称之为Gap Locks的锁,官方的类型名称为:LOCK_GAP,我们也可以简称为gap锁。比方说我们把number值为8的那条记录加一个gap锁的示意图如下:\n如图中为number值为8的记录加了gap锁,意味着不允许别的事务在number值为8的记录前面的间隙插入新记录,其实就是number列的值(3, 8)这个区间的新记录是不允许立即插入的。比方说有另外一个事务再想插入一条number值为4的新记录,它定位到该条新记录的下一条记录的number值为8,而这条记录上又有一个gap锁,所以就会阻塞插入操作,直到拥有这个gap锁的事务提交了之后,number列的值在区间(3, 8)中的新记录才可以被插入。\n这个gap锁的提出仅仅是为了防止插入幻影记录而提出的,虽然有共享gap锁和独占gap锁这样的说法,但是它们起到的作用都是相同的。而且如果你对一条记录加了gap锁(不论是共享gap锁还是独占gap锁),并不会限制其他事务对这条记录加正经记录锁或者继续加gap锁,再强调一遍,gap锁的作用仅仅是为了防止插入幻影记录的而已。\n不知道大家发现了一个问题没,给一条记录加了gap锁只是不允许其他事务往这条记录前面的间隙插入新记录,那对于最后一条记录之后的间隙,也就是hero表中number值为20的记录之后的间隙该咋办呢?也就是说给哪条记录加gap锁才能阻止其他事务插入number值在(20, +∞)这个区间的新记录呢?这时候应该想起我们在前面介绍数据页时介绍的两条伪记录了: - Infimum记录,表示该页面中最小的记录。 - Supremum记录,表示该页面中最大的记录。\n为了实现阻止其他事务插入number值在(20, +∞)这个区间的新记录,我们可以给索引中的最后一条记录,也就是number值为20的那条记录所在页面的Supremum记录加上一个gap锁,画个图就是这样:\n这样就可以阻止其他事务插入number值在(20, +∞)这个区间的新记录。为了大家理解方便,之后的索引示意图中都会把这个Supremum记录画出来。\nNext-Key Locks:\n有时候我们既想锁住某条记录,又想阻止其他事务在该记录前面的间隙插入新记录,所以设计InnoDB的大佬们就提出了一种称之为Next-Key Locks的锁,官方的类型名称为:LOCK_ORDINARY,我们也可以简称为next-key锁。比方说我们把number值为8的那条记录加一个next-key锁的示意图如下:\nnext-key锁的本质就是一个正经记录锁和一个gap锁的合体,它既能保护该条记录,又能阻止别的事务将新记录插入被保护记录前面的间隙。\nInsert Intention Locks:\n我们说一个事务在插入一条记录时需要判断一下插入位置是不是被别的事务加了所谓的gap锁(next-key锁也包含gap锁,后边就不强调了),如果有的话,插入操作需要等待,直到拥有gap锁的那个事务提交。但是设计InnoDB的大佬规定事务在等待的时候也需要在内存中生成一个锁结构,表明有事务想在某个间隙中插入新记录,但是现在在等待。设计InnoDB的大佬就把这种类型的锁命名为Insert Intention Locks,官方的类型名称为:LOCK_INSERT_INTENTION,我们也可以称为插入意向锁。\n比方说我们把number值为8的那条记录加一个插入意向锁的示意图如下:\n为了让大家彻底理解这个插入意向锁的功能,我们还是举个例子然后画个图表示一下。比方说现在T1为number值为8的记录加了一个gap锁,然后T2和T3分别想向hero表中插入number值分别为4、5的两条记录,所以现在为number值为8的记录加的锁的示意图就如下所示:\n小贴士:我们在锁结构中又新添了一个type属性,表明该锁的类型。稍后会全面介绍InnoDB存储引擎中的一个锁结构到底长什么样。 从图中可以看到,由于T1持有gap锁,所以T2和T3需要生成一个插入意向锁的锁结构并且处于等待状态。当T1提交后会把它获取到的锁都释放掉,这样T2和T3就能获取到对应的插入意向锁了(本质上就是把插入意向锁对应锁结构的is_waiting属性改为false),T2和T3之间也并不会相互阻塞,它们可以同时获取到number值为8的插入意向锁,然后执行插入操作。事实上插入意向锁并不会阻止别的事务继续获取该记录上任何类型的锁(插入意向锁就是这么鸡肋)。\n隐式锁\n我们前面说一个事务在执行INSERT操作时,如果即将插入的间隙已经被其他事务加了gap锁,那么本次INSERT操作会阻塞,并且当前事务会在该间隙上加一个插入意向锁,否则一般情况下INSERT操作是不加锁的。那如果一个事务首先插入了一条记录(此时并没有与该记录关联的锁结构),然后另一个事务: - 立即使用SELECT ... LOCK IN SHARE MODE语句读取这条事务,也就是在要获取这条记录的S锁,或者使用SELECT ... FOR UPDATE语句读取这条事务或者直接修改这条记录,也就是要获取这条记录的X锁,该咋办?\n如果允许这种情况的发生,那么可能产生`脏读`问题。 + 立即修改这条记录,也就是要获取这条记录的X锁,该咋办?\n如果允许这种情况的发生,那么可能产生脏写问题。\n这时候我们前面介绍了很多遍的事务id又要起作用了。我们把聚簇索引和二级索引中的记录分开看一下: - 情景一:对于聚簇索引记录来说,有一个trx_id隐藏列,该隐藏列记录着最后改动该记录的事务id。那么如果在当前事务中新插入一条聚簇索引记录后,该记录的trx_id隐藏列代表的的就是当前事务的事务id,如果其他事务此时想对该记录添加S锁或者X锁时,首先会看一下该记录的trx_id隐藏列代表的事务是否是当前的活跃事务,如果是的话,那么就帮助当前事务创建一个X锁(也就是为当前事务创建一个锁结构,is_waiting属性是false),然后自己进入等待状态(也就是为自己也创建一个锁结构,is_waiting属性是true)。 - 情景二:对于二级索引记录来说,本身并没有trx_id隐藏列,但是在二级索引页面的Page Header部分有一个PAGE_MAX_TRX_ID属性,该属性代表对该页面做改动的最大的事务id,如果PAGE_MAX_TRX_ID属性值小于当前最小的活跃事务id,那么说明对该页面做修改的事务都已经提交了,否则就需要在页面中定位到对应的二级索引记录,然后回表找到它对应的聚簇索引记录,然后再重复情景一的做法。\n通过上面的叙述我们知道,一个事务对新插入的记录可以不显式的加锁(生成一个锁结构),但是由于事务id这个牛逼的东东的存在,相当于加了一个隐式锁。别的事务在对这条记录加S锁或者X锁时,由于隐式锁的存在,会先帮助当前事务生成一个锁结构,然后自己再生成一个锁结构后进入等待状态。\n小贴士:除了插入意向锁,在一些特殊情况下INSERT还会获取一些锁,我们稍后介绍。\nInnoDB锁的内存结构 # 我们前面说对一条记录加锁的本质就是在内存中创建一个锁结构与之关联,那么是不是一个事务对多条记录加锁,就要创建多个锁结构呢?比方说事务T1要执行下面这个语句: ```\n事务T1\nSELECT * FROM hero LOCK IN SHARE MODE; ``` 很显然这条语句需要为hero表中的所有记录进行加锁,那是不是需要为每条记录都生成一个锁结构呢?其实理论上创建多个锁结构没问题,反而更容易理解,但是谁知道你在一个事务里想对多少记录加锁呢,如果一个事务要获取10000条记录的锁,要生成10000个这样的结构也太亏了吧!所以设计InnoDB的大佬本着勤俭节约的传统美德,决定在对不同记录加锁时,如果符合下面这些条件: - 在同一个事务中进行加锁操作 - 被加锁的记录在同一个页面中 - 加锁的类型是一样的 - 等待状态是一样的\n那么这些记录的锁就可以被放到一个锁结构中。当然,这么空口白牙的说有点儿抽象,我们还是画个图来看看InnoDB存储引擎中的锁结构具体长什么样吧:\n我们看看这个结构里边的各种信息都是干嘛的:\n锁所在的事务信息:\n不论是表锁还是行锁,都是在事务执行过程中生成的,哪个事务生成了这个锁结构,这里就记载着这个事务的信息。\n小贴士:实际上这个所谓的锁所在的事务信息在内存结构中只是一个指针而已,所以不会占用多大内存空间,通过指针可以找到内存中关于该事务的更多信息,比方说事务id是什么。下面介绍的所谓的索引信息其实也是一个指针。\n索引信息:\n对于行锁来说,需要记录一下加锁的记录是属于哪个索引的。\n表锁/行锁信息:\n表锁结构和行锁结构在这个位置的内容是不同的:\n+ 表锁:\n记载着这是对哪个表加的锁,还有其他的一些信息。\n+ 行锁:\n记载了三个重要的信息: - Space ID:记录所在表空间。 - Page Number:记录所在页号。 - n_bits:对于行锁来说,一条记录就对应着一个比特位,一个页面中包含很多记录,用不同的比特位来区分到底是哪一条记录加了锁。为此在行锁结构的末尾放置了一堆比特位,这个n_bits属性代表使用了多少比特位。\n小贴士:并不是该页面中有多少记录,n_bits属性的值就是多少。为了让之后在页面中插入了新记录后也不至于重新分配锁结构,所以n_bits的值一般都比页面中记录条数多一些。 - type_mode:\n这是一个32位的数,被分成了lock_mode、lock_type和rec_lock_type三个部分,如图所示:\n+ 锁的模式(lock_mode),占用低4位,可选的值如下:\n+ `LOCK_IS`(十进制的`0`):表示共享意向锁,也就是`IS锁`。 + `LOCK_IX`(十进制的`1`):表示独占意向锁,也就是`IX锁`。 + `LOCK_S`(十进制的`2`):表示共享锁,也就是`S锁`。 + `LOCK_X`(十进制的`3`):表示独占锁,也就是`X锁`。 + `LOCK_AUTO_INC`(十进制的`4`):表示`AUTO-INC锁`。 小贴士:在InnoDB存储引擎中,LOCK_IS,LOCK_IX,LOCK_AUTO_INC都算是表级锁的模式,LOCK_S和LOCK_X既可以算是表级锁的模式,也可以是行级锁的模式。\n+ 锁的类型(lock_type),占用第5~8位,不过现阶段只有第5位和第6位被使用:\n+ `LOCK_TABLE`(十进制的`16`),也就是当第5个比特位置为1时,表示表级锁。 + `LOCK_REC`(十进制的`32`),也就是当第6个比特位置为1时,表示行级锁。 + 行锁的具体类型(rec_lock_type),使用其余的位来表示。只有在lock_type的值为LOCK_REC时,也就是只有在该锁为行级锁时,才会被细分为更多的类型:\n+ `LOCK_ORDINARY`(十进制的`0`):表示`next-key锁`。 + `LOCK_GAP`(十进制的`512`):也就是当第10个比特位置为1时,表示`gap锁`。 + `LOCK_REC_NOT_GAP`(十进制的`1024`):也就是当第11个比特位置为1时,表示`正经记录锁`。 + `LOCK_INSERT_INTENTION`(十进制的`2048`):也就是当第12个比特位置为1时,表示插入意向锁。 + 其他的类型:还有一些不常用的类型我们就不多说了。 怎么还没看见is_waiting属性呢?这主要还是设计InnoDB的大佬太抠门了,一个比特位也不想浪费,所以他们把is_waiting属性也放到了type_mode这个32位的数字中: - LOCK_WAIT(十进制的256) :也就是当第9个比特位置为1时,表示is_waiting为true,也就是当前事务尚未获取到锁,处在等待状态;当这个比特位为0时,表示is_waiting为false,也就是当前事务获取锁成功。\n其他信息:\n为了更好的管理系统运行过程中生成的各种锁结构而设计了各种哈希表和链表,为了简化讨论,我们忽略这部分信息~\n一堆比特位:\n如果是行锁结构的话,在该结构末尾还放置了一堆比特位,比特位的数量是由上面提到的n_bits属性表示的。我们前面介绍InnoDB记录结构的时候说过,页面中的每条记录在记录头信息中都包含一个heap_no属性,伪记录Infimum的heap_no值为0,Supremum的heap_no值为1,之后每插入一条记录,heap_no值就增1。锁结构最后的一堆比特位就对应着一个页面中的记录,一个比特位映射一个heap_no,不过为了编码方便,映射方式有点怪:\n小贴士:这么怪的映射方式纯粹是为了敲代码方便,大家不要大惊小怪,只需要知道一个比特位映射到页内的一条记录就好了。\n可能上面的描述大家觉得还是有些抽象,我们还是举个例子说明一下。比方说现在有两个事务T1和T2想对hero表中的记录进行加锁,hero表中记录比较少,假设这些记录都存储在所在的表空间号为67,页号为3的页面上,那么如果:\nT1想对number值为15的这条记录加S型正常记录锁,在对记录加行锁之前,需要先加表级别的IS锁,也就是会生成一个表级锁的内存结构,不过我们这里不关心表级锁,所以就忽略掉了~ 接下来分析一下生成行锁结构的过程:\n+ 事务`T1`要进行加锁,所以锁结构的`锁所在事务信息`指的就是`T1`。 + 直接对聚簇索引进行加锁,所以索引信息指的其实就是`PRIMARY`索引。 + 由于是行锁,所以接下来需要记录的是三个重要信息:\n+ `Space ID`:表空间号为`67`。 + `Page Number`:页号为`3`。 + n_bits:我们的hero表中现在只插入了5条用户记录,但是在初始分配比特位时会多分配一些,这主要是为了在之后新增记录时不用频繁分配比特位。其实计算n_bits有一个公式:\nn_bits = (1 + ((n_recs + LOCK_PAGE_BITMAP_MARGIN) / 8)) * 8 其中n_recs指的是当前页面中一共有多少条记录(算上伪记录和在垃圾链表中的记录),比方说现在hero表一共有7条记录(5条用户记录和2条伪记录),所以n_recs的值就是7,LOCK_PAGE_BITMAP_MARGIN是一个固定的值64,所以本次加锁的n_bits值就是: n_bits = (1 + ((7 + 64) / 8)) * 8 = 72\n+ type_mode是由三部分组成的:\n+ `lock_mode`,这是对记录加`S锁`,它的值为`LOCK_S`。 + `lock_type`,这是对记录进行加锁,也就是行锁,所以它的值为`LOCK_REC`。 + `rec_lock_type`,这是对记录加`正经记录锁`,也就是类型为`LOCK_REC_NOT_GAP`的锁。另外,由于当前没有其他事务对该记录加锁,所以应当获取到锁,也就是`LOCK_WAIT`代表的二进制位应该是0。 综上所属,此次加锁的type_mode的值应该是:\ntype_mode = LOCK_S | LOCK_REC | LOCK_REC_NOT_GAP 也就是: type_mode = 2 | 32 | 1024 = 1058\n+ 其他信息\n略~\n+ 一堆比特位\n因为number值为15的记录heap_no值为5,根据上面列举的比特位和heap_no的映射图来看,应该是第一个字节从低位往高位数第6个比特位被置为1,就像这样:\n综上所述,事务T1为number值为5的记录加锁生成的锁结构就如下图所示:\nT2想对number值为3、8、15的这三条记录加X型的next-key锁,在对记录加行锁之前,需要先加表级别的IX锁,也就是会生成一个表级锁的内存结构,不过我们这里不关心表级锁,所以就忽略掉了~\n现在T2要为3条记录加锁,number为3、8的两条记录由于没有其他事务加锁,所以可以成功获取这条记录的X型next-key锁,也就是生成的锁结构的is_waiting属性为false;但是number为15的记录已经被T1加了S型正经记录锁,T2是不能获取到该记录的X型next-key锁的,也就是生成的锁结构的is_waiting属性为true。因为等待状态不相同,所以这时候会生成两个锁结构。这两个锁结构中相同的属性如下: - 事务T2要进行加锁,所以锁结构的锁所在事务信息指的就是T2。 - 直接对聚簇索引进行加锁,所以索引信息指的其实就是PRIMARY索引。 - 由于是行锁,所以接下来需要记录是三个重要信息: - Space ID:表空间号为67。 - Page Number:页号为3。 - n_bits:此属性生成策略同T1中一样,该属性的值为72。 - type_mode是由三部分组成的: - lock_mode,这是对记录加X锁,它的值为LOCK_X。 - lock_type,这是对记录进行加锁,也就是行锁,所以它的值为LOCK_REC。 - rec_lock_type,这是对记录加next-key锁,也就是类型为LOCK_ORDINARY的锁。\n+ 其他信息\n略~\n不同的属性如下:\n+ 为number为3、8的记录生成的锁结构:\n+ type_mode值。\n由于可以获取到锁,所以is_waiting属性为false,也就是LOCK_WAIT代表的二进制位被置0。所以: type_mode = LOCK_X | LOCK_REC |LOCK_ORDINARY 也就是 type_mode = 3 | 32 | 0 = 35\n+ 一堆比特位\n因为number值为3、8的记录heap_no值分别为3、4,根据上面列举的比特位和heap_no的映射图来看,应该是第一个字节从低位往高位数第4、5个比特位被置为1,就像这样:\n综上所述,事务T2为number值为3、8两条记录加锁生成的锁结构就如下图所示:\n+ 为number为15的记录生成的锁结构:\n+ type_mode值。\n由于可以获取到锁,所以is_waiting属性为true,也就是LOCK_WAIT代表的二进制位被置1。所以: type_mode = LOCK_X | LOCK_REC |LOCK_ORDINARY | LOCK_WAIT 也就是 type_mode = 3 | 32 | 0 | 256 = 291\n+ 一堆比特位\n因为number值为15的记录heap_no值为5,根据上面列举的比特位和heap_no的映射图来看,应该是第一个字节从低位往高位数第6个比特位被置为1,就像这样:\n综上所述,事务T2为number值为15的记录加锁生成的锁结构就如下图所示:\n综上所述,事务T1先获取number值为15的S型正经记录锁,然后事务T2获取number值为3、8、15的X型正经记录锁共需要生成3个锁结构。噗~ 关于锁结构我本来就想写一点点的,没想到一些起来就停不下了,大家乐呵乐呵看~\n小贴士:上面事务T2在对number值分别为3、8、15这三条记录加锁的情景中,是按照先对number值为3的记录加锁、再对number值为8的记录加锁,最后对number值为15的记录加锁的顺序进行的,如果我们一开始就对number值为15的记录加锁,那么该事务在为number值为15的记录生成一个锁结构后,直接就进入等待状态,就不为number值为3、8的两条记录生成锁结构了。在事务T1提交后会把在number值为15的记录上获取的锁释放掉,然后事务T2就可以获取该记录上的锁,这时再对number值为3、8的两条记录加锁时,就可以复用之前为number值为15的记录加锁时生成的锁结构了。\n更多内容 # 欢迎各位关注我的微信公众号「我们都是小青蛙」,那里有更多技术干货与特色扯犊子文章(后续会在公众号中发布各种不同的语句具体的加锁情况分析,敬请期待)。\n"},{"id":9,"href":"/zh/docs/technology/MySQL/MySQL%E6%98%AF%E6%80%8E%E6%A0%B7%E8%BF%90%E8%A1%8C%E7%9A%84/%E7%AC%AC24%E7%AB%A0_%E4%B8%80%E6%9D%A1%E8%AE%B0%E5%BD%95%E7%9A%84%E5%A4%9A%E5%B9%85%E9%9D%A2%E5%AD%94-%E4%BA%8B%E5%8A%A1%E7%9A%84%E9%9A%94%E7%A6%BB%E7%BA%A7%E5%88%AB%E4%B8%8EMVCC/","title":"第24章_一条记录的多幅面孔-事务的隔离级别与MVCC","section":"My Sql是怎样运行的","content":"第24章 一条记录的多幅面孔-事务的隔离级别与MVCC\n事前准备 # 为了故事的顺利发展,我们需要创建一个表: CREATE TABLE hero ( number INT, name VARCHAR(100), country varchar(100), PRIMARY KEY (number) ) Engine=InnoDB CHARSET=utf8; 小贴士:注意我们把这个hero表的主键命名为number,而不是id,主要是想和后边要用到的事务id做区别,大家不用大惊小怪~ 然后向这个表里插入一条数据: INSERT INTO hero VALUES(1, '刘备', '蜀'); 现在表里的数据就是这样的: mysql\u0026gt; SELECT * FROM hero; +--------+--------+---------+ | number | name | country | +--------+--------+---------+ | 1 | 刘备 | 蜀 | +--------+--------+---------+ 1 row in set (0.00 sec)\n事务隔离级别 # 我们知道MySQL是一个客户端/服务器架构的软件,对于同一个服务器来说,可以有若干个客户端与之连接,每个客户端与服务器连接上之后,就可以称之为一个会话(Session)。每个客户端都可以在自己的会话中向服务器发出请求语句,一个请求语句可能是某个事务的一部分,也就是对于服务器来说可能同时处理多个事务。在事务简介的章节中我们说过事务有一个称之为隔离性的特性,理论上在某个事务对某个数据进行访问时,其他事务应该进行排队,当该事务提交之后,其他事务才可以继续访问这个数据。但是这样子的话对性能影响太大,我们既想保持事务的隔离性,又想让服务器在处理访问同一数据的多个事务时性能尽量高些,鱼和熊掌不可得兼,舍一部分隔离性而取性能者也。\n事务并发执行遇到的问题 # 怎么个舍弃法呢?我们先得看一下访问相同数据的事务在不保证串行执行(也就是执行完一个再执行另一个)的情况下可能会出现哪些问题:\n脏写(Dirty Write)\n如果一个事务修改了另一个未提交事务修改过的数据,那就意味着发生了脏写,示意图如下:\n如上图,Session A和Session B各开启了一个事务,Session B中的事务先将number列为1的记录的name列更新为'关羽',然后Session A中的事务接着又把这条number列为1的记录的name列更新为张飞。如果之后Session B中的事务进行了回滚,那么Session A中的更新也将不复存在,这种现象就称之为脏写。这时Session A中的事务就很懵逼,我明明把数据更新了,最后也提交事务了,怎么到最后说自己什么也没干呢?\n脏读(Dirty Read)\n如果一个事务读到了另一个未提交事务修改过的数据,那就意味着发生了脏读,示意图如下:\n如上图,Session A和Session B各开启了一个事务,Session B中的事务先将number列为1的记录的name列更新为'关羽',然后Session A中的事务再去查询这条number为1的记录,如果du到列name的值为'关羽',而Session B中的事务稍后进行了回滚,那么Session A中的事务相当于读到了一个不存在的数据,这种现象就称之为脏读。\n不可重复读(Non-Repeatable Read)\n如果一个事务只能读到另一个已经提交的事务修改过的数据,并且其他事务每对该数据进行一次修改并提交后,该事务都能查询得到最新值,那就意味着发生了不可重复读,示意图如下:\n如上图,我们在Session B中提交了几个隐式事务(注意是隐式事务,意味着语句结束事务就提交了),这些事务都修改了number列为1的记录的列name的值,每次事务提交之后,如果Session A中的事务都可以查看到最新的值,这种现象也被称之为不可重复读。\n幻读(Phantom)\n如果一个事务先根据某些条件查询出一些记录,之后另一个事务又向表中插入了符合这些条件的记录,原先的事务再次按照该条件查询时,能把另一个事务插入的记录也读出来,那就意味着发生了幻读,示意图如下:\n如上图,Session A中的事务先根据条件number \u0026gt; 0这个条件查询表hero,得到了name列值为'刘备'的记录;之后Session B中提交了一个隐式事务,该事务向表hero中插入了一条新记录;之后Session A中的事务再根据相同的条件number \u0026gt; 0查询表hero,得到的结果集中包含Session B中的事务新插入的那条记录,这种现象也被称之为幻读。\n有的同学会有疑问,那如果Session B中是删除了一些符合number \u0026gt; 0的记录而不是插入新记录,那Session A中之后再根据number \u0026gt; 0的条件读取的记录变少了,这种现象算不算幻读呢?明确说一下,这种现象不属于幻读,幻读强调的是一个事务按照某个相同条件多次读取记录时,后读取时读到了之前没有读到的记录。\n小贴士:那对于先前已经读到的记录,之后又读取不到这种情况,算什么呢?其实这相当于对每一条记录都发生了不可重复读的现象。幻读只是重点强调了读取到了之前读取没有获取到的记录。\nSQL标准中的四种隔离级别 # 我们上面介绍了几种并发事务执行过程中可能遇到的一些问题,这些问题也有轻重缓急之分,我们给这些问题按照严重性来排一下序: 脏写 \u0026gt; 脏读 \u0026gt; 不可重复读 \u0026gt; 幻读 我们上面所说的舍弃一部分隔离性来换取一部分性能在这里就体现在:设立一些隔离级别,隔离级别越低,越严重的问题就越可能发生。有一帮人(并不是设计MySQL的大佬们)制定了一个所谓的SQL标准,在标准中设立了4个隔离级别: - READ UNCOMMITTED:未提交读。 - READ COMMITTED:已提交读。 - REPEATABLE READ:可重复读。 - SERIALIZABLE:可串行化。\nSQL标准中规定,针对不同的隔离级别,并发事务可以发生不同严重程度的问题,具体情况如下: 隔离级别 脏读 不可重复读 幻读 READ UNCOMMITTED Possible Possible Possible READ COMMITTED Not Possible Possible Possible REPEATABLE READ Not Possible Not Possible Possible SERIALIZABLE Not Possible Not Possible Not Possible 也就是说: - READ UNCOMMITTED隔离级别下,可能发生脏读、不可重复读和幻读问题。 - READ COMMITTED隔离级别下,可能发生不可重复读和幻读问题,但是不可以发生脏读问题。 - REPEATABLE READ隔离级别下,可能发生幻读问题,但是不可以发生脏读和不可重复读的问题。 - SERIALIZABLE隔离级别下,各种问题都不可以发生。\n脏写是怎么回事儿?怎么里边都没写呢?这是因为脏写这个问题太严重了,不论是哪种隔离级别,都不允许脏写的情况发生。\nMySQL中支持的四种隔离级别 # 不同的数据库厂商对SQL标准中规定的四种隔离级别支持不一样,比方说Oracle就只支持READ COMMITTED和SERIALIZABLE隔离级别。本书中所讨论的MySQL虽然支持4种隔离级别,但与SQL标准中所规定的各级隔离级别允许发生的问题却有些出入,MySQL在REPEATABLE READ隔离级别下,是可以禁止幻读问题的发生的(关于如何禁止我们之后会详细说明的)。\nMySQL的默认隔离级别为REPEATABLE READ,我们可以手动修改一下事务的隔离级别。\n如何设置事务的隔离级别 # 我们可以通过下面的语句修改事务的隔离级别: SET [GLOBAL|SESSION] TRANSACTION ISOLATION LEVEL level; 其中的level可选值有4个: level: { REPEATABLE READ | READ COMMITTED | READ UNCOMMITTED | SERIALIZABLE } 设置事务的隔离级别的语句中,在SET关键字后可以放置GLOBAL关键字、SESSION关键字或者什么都不放,这样会对不同范围的事务产生不同的影响,具体如下:\n使用GLOBAL关键字(在全局范围影响):\n比方说这样: SET GLOBAL TRANSACTION ISOLATION LEVEL SERIALIZABLE; 则: - 只对执行完该语句之后产生的会话起作用。 - 当前已经存在的会话无效。\n使用SESSION关键字(在会话范围影响):\n比方说这样: SET SESSION TRANSACTION ISOLATION LEVEL SERIALIZABLE; 则: - 对当前会话的所有后续的事务有效 - 该语句可以在已经开启的事务中间执行,但不会影响当前正在执行的事务。 - 如果在事务之间执行,则对后续的事务有效。\n上述两个关键字都不用(只对执行语句后的下一个事务产生影响):\n比方说这样: SET TRANSACTION ISOLATION LEVEL SERIALIZABLE; 则: - 只对当前会话中下一个即将开启的事务有效。 - 下一个事务执行完后,后续事务将恢复到之前的隔离级别。 - 该语句不能在已经开启的事务中间执行,会报错的。\n如果我们在服务器启动时想改变事务的默认隔离级别,可以修改启动参数transaction-isolation的值,比方说我们在启动服务器时指定了--transaction-isolation=SERIALIZABLE,那么事务的默认隔离级别就从原来的REPEATABLE READ变成了SERIALIZABLE。\n想要查看当前会话默认的隔离级别可以通过查看系统变量transaction_isolation的值来确定: mysql\u0026gt; SHOW VARIABLES LIKE 'transaction_isolation'; +-----------------------+-----------------+ | Variable_name | Value | +-----------------------+-----------------+ | transaction_isolation | REPEATABLE-READ | +-----------------------+-----------------+ 1 row in set (0.02 sec) 或者使用更简便的写法: mysql\u0026gt; SELECT @@transaction_isolation; +-------------------------+ | @@transaction_isolation | +-------------------------+ | REPEATABLE-READ | +-------------------------+ 1 row in set (0.00 sec)\n小贴士:我们也可以使用设置系统变量transaction_isolation的方式来设置事务的隔离级别,不过我们前面介绍过,一般系统变量只有GLOBAL和SESSION两个作用范围,而这个transaction_isolation却有3个(与上面 SET TRANSACTION ISOLATION LEVEL的语法相对应),设置语法上有些特殊,更多详情可以参见文档:https://dev.mysql.com/doc/refman/5.7/en/server-system-variables.html#sysvar_transaction_isolation。另外,transaction_isolation是在MySQL 5.7.20的版本中引入来替换tx_isolation的,如果你使用的是之前版本的MySQL,请将上述用到系统变量transaction_isolation的地方替换为tx_isolation。\nMVCC原理 # 版本链 # 我们前面说过,对于使用InnoDB存储引擎的表来说,它的聚簇索引记录中都包含两个必要的隐藏列(row_id并不是必要的,我们创建的表中有主键或者非NULL的UNIQUE键时都不会包含row_id列): - trx_id:每次一个事务对某条聚簇索引记录进行改动时,都会把该事务的事务id赋值给trx_id隐藏列。 - roll_pointer:每次对某条聚簇索引记录进行改动时,都会把旧的版本写入到undo日志中,然后这个隐藏列就相当于一个指针,可以通过它来找到该记录修改前的信息。\n比方说我们的表hero现在只包含一条记录: mysql\u0026gt; SELECT * FROM hero; +--------+--------+---------+ | number | name | country | +--------+--------+---------+ | 1 | 刘备 | 蜀 | +--------+--------+---------+ 1 row in set (0.07 sec) 假设插入该记录的事务id为80,那么此刻该条记录的示意图如下所示:\n小贴士:实际上insert undo只在事务回滚时起作用,当事务提交后,该类型的undo日志就没用了,它占用的Undo Log Segment也会被系统回收(也就是该undo日志占用的Undo页面链表要么被重用,要么被释放)。虽然真正的insert undo日志占用的存储空间被释放了,但是roll_pointer的值并不会被清除,roll_pointer属性占用7个字节,第一个比特位就标记着它指向的undo日志的类型,如果该比特位的值为1时,就代表着它zhi向的undo日志类型为insert undo。所以我们之后在画图时都会把insert undo给去掉,大家留意一下就好了。\n假设之后两个事务id分别为100、200的事务对这条记录进行UPDATE操作,操作流程如下:\n小贴士:能不能在两个事务中交叉更新同一条记录呢?这不就是一个事务修改了另一个未提交事务修改过的数据,沦为了脏写了么?InnoDB使用锁来保证不会有脏写情况的发生,也就是在第一个事务更新了某条记录后,就会给这条记录加锁,另一个事务再次更新时就需要等待第一个事务提交了,把锁释放之后才可以继续更新。关于锁的更多细节我们后续的文章中再介绍~ 每次对记录进行改动,都会记录一条undo日志,每条undo日志也都有一个roll_pointer属性(INSERT操作对应的undo日志没有该属性,因为该记录并没有更早的版本),可以将这些undo日志都连起来,串成一个链表,所以现在的情况就像下图一样:\n对该记录每次更新后,都会将旧值放到一条undo日志中,就算是该记录的一个旧版本,随着更新次数的增多,所有的版本都会被roll_pointer属性连接成一个链表,我们把这个链表称之为版本链,版本链的头节点就是当前记录最新的值。另外,每个版本中还包含生成该版本时对应的事务id,这个信息很重要,我们稍后就会用到。\nReadView # 对于使用READ UNCOMMITTED隔离级别的事务来说,由于可以读到未提交事务修改过的记录,所以直接读取记录的最新版本就好了;对于使用SERIALIZABLE隔离级别的事务来说,设计InnoDB的大佬规定使用加锁的方式来访问记录(加锁是什么我们后续文章中说);对于使用READ COMMITTED和REPEATABLE READ隔离级别的事务来说,都必须保证读到已经提交了的事务修改过的记录,也就是说假如另一个事务已经修改了记录但是尚未提交,是不能直接读取最新版本的记录的,核心问题就是:需要判断一下版本链中的哪个版本是当前事务可见的。为此,设计InnoDB的大佬提出了一个ReadView的概念,这个ReadView中主要包含4个比较重要的内容: - m_ids:表示在生成ReadView时当前系统中活跃的读写事务的事务id列表。 - min_trx_id:表示在生成ReadView时当前系统中活跃的读写事务中最小的事务id,也就是m_ids中的最小值。 - max_trx_id:表示生成ReadView时系统中应该分配给下一个事务的id值。\n``` 小贴士:注意max_trx_id并不是m_ids中的最大值,事务id是递增分配的。比方说现在有id为1,2,3这三个事务,之后id为3的事务提交了。那么一个新的读事务在生成ReadView时,m_ids就包括1和2,min_trx_id的值就是1,max_trx_id的值就是4。 ``` creator_trx_id:表示生成该ReadView的事务的事务id。\n小贴士:我们前面说过,只有在对表中的记录做改动时(执行INSERT、DELETE、UPDATE这些语句时)才会为事务分配事务id,否则在一个只读事务中的事务id值都默认为0。\n有了这个ReadView,这样在访问某条记录时,只需要按照下面的步骤判断记录的某个版本是否可见: - 如果被访问版本的trx_id属性值与ReadView中的creator_trx_id值相同,意味着当前事务在访问它自己修改过的记录,所以该版本可以被当前事务访问。 - 如果被访问版本的trx_id属性值小于ReadView中的min_trx_id值,表明生成该版本的事务在当前事务生成ReadView前已经提交,所以该版本可以被当前事务访问。 - 如果被访问版本的trx_id属性值大于ReadView中的max_trx_id值,表明生成该版本的事务在当前事务生成ReadView后才开启,所以该版本不可以被当前事务访问。 - 如果被访问版本的trx_id属性值在ReadView的min_trx_id和max_trx_id之间,那就需要判断一下trx_id属性值是不是在m_ids列表中,如果在,说明创建ReadView时生成该版本的事务还是活跃的,该版本不可以被访问;如果不在,说明创建ReadView时生成该版本的事务已经被提交,该版本可以被访问。\n如果某个版本的数据对当前事务不可见的话,那就顺着版本链找到下一个版本的数据,继续按照上面的步骤判断可见性,依此类推,直到版本链中的最后一个版本。如果最后一个版本也不可见的话,那么就意味着该条记录对该事务完全不可见,查询结果就不包含该记录。\n在MySQL中,READ COMMITTED和REPEATABLE READ隔离级别的的一个非常大的区别就是它们生成ReadView的时机不同。我们还是以表hero为例来,假设现在表hero中只有一条由事务id为80的事务插入的一条记录: mysql\u0026gt; SELECT * FROM hero; +--------+--------+---------+ | number | name | country | +--------+--------+---------+ | 1 | 刘备 | 蜀 | +--------+--------+---------+ 1 row in set (0.07 sec) 接下来看一下READ COMMITTED和REPEATABLE READ所谓的生成ReadView的时机不同到底不同在哪里。\nREAD COMMITTED —— 每次读取数据前都生成一个ReadView # 比方说现在系统里有两个事务id分别为100、200的事务在执行: ```\nTransaction 100\nBEGIN;\nUPDATE hero SET name = \u0026lsquo;关羽\u0026rsquo; WHERE number = 1;\nUPDATE hero SET name = \u0026lsquo;张飞\u0026rsquo; WHERE number = 1; ``\nTransaction 200\nBEGIN;\n更新了一些别的表的记录\n\u0026hellip; `` 小贴士:再次强调一遍,事务执行过程中,只有在第一次真正修改记录时(比如使用INSERT、DELETE、UPDATE语句),才会被分配一个单独的事务id,这个事务id是递增的。所以我们才在Transaction 200中更新一些别的表的记录,目的是让它分配事务id。 ``` 此刻,表hero中number为1的记录得到的版本链表如下所示:\n假设现在有一个使用READ COMMITTED隔离级别的事务开始执行: ```\n使用READ COMMITTED隔离级别的事务\nBEGIN;\nSELECT1:Transaction 100、200未提交\nSELECT * FROM hero WHERE number = 1; # 得到的列name的值为\u0026rsquo;刘备\u0026rsquo; ``` 这个SELECT1的执行过程如下: - 在执行SELECT语句时会先生成一个ReadView,ReadView的m_ids列表的内容就是[100, 200],min_trx_id为100,max_trx_id为201,creator_trx_id为0。 - 然后从版本链中挑选可见的记录,从图中可以看出,最新版本的列name的内容是'张飞',该版本的trx_id值为100,在m_ids列表内,所以不符合可见性要求,根据roll_pointer跳到下一个版本。 - 下一个版本的列name的内容是'关羽',该版本的trx_id值也为100,也在m_ids列表内,所以也不符合要求,继续跳到下一个版本。 - 下一个版本的列name的内容是'刘备',该版本的trx_id值为80,小于ReadView中的min_trx_id值100,所以这个版本是符合要求的,最后返回给用户的版本就是这条列name为'刘备'的记录。\n之后,我们把事务id为100的事务提交一下,就像这样:\nTransaction 100 BEGIN; UPDATE hero SET name = \u0026#39;关羽\u0026#39; WHERE number = 1; UPDATE hero SET name = \u0026#39;张飞\u0026#39; WHERE number = 1; COMMIT; `然后再到`事务id`为`200`的事务中更新一下表`hero`中`number`为`1`的记录:` Transaction 200 BEGIN; 更新了一些别的表的记录 ... UPDATE hero SET name = \u0026#39;赵云\u0026#39; WHERE number = 1; UPDATE hero SET name = \u0026#39;诸葛亮\u0026#39; WHERE number = 1; ``` 此刻,表`hero`中`number`为`1`的记录的版本链就长这样: ![](img/24-09.png) 然后再到刚才使用`READ COMMITTED`隔离级别的事务中继续查找这个`number`为`1`的记录,如下: ``` 使用READ COMMITTED隔离级别的事务 BEGIN; SELECT1:Transaction 100、200均未提交 SELECT * FROM hero WHERE number = 1; \\# 得到的列name的值为\u0026#39;刘备\u0026#39; SELECT2:Transaction 100提交,Transaction 200未提交 SELECT * FROM hero WHERE number = 1; \\# 得到的列name的值为\u0026#39;张飞\u0026#39; ``` 这个`SELECT2`的执行过程如下: - 在执行`SELECT`语句时会**又会单独生成**一个`ReadView`,该`ReadView`的`m_ids`列表的内容就是`[200]`(`事务id`为`100`的那个事务已经提交了,所以再次生成快照时就没有它了),`min_trx_id`为`200`,`max_trx_id`为`201`,`creator_trx_id`为`0`。 - 然后从版本链中挑选可见的记录,从图中可以看出,最新版本的列`name`的内容是`\u0026#39;诸葛亮\u0026#39;`,该版本的`trx_id`值为`200`,在`m_ids`列表内,所以不符合可见性要求,根据`roll_pointer`跳到下一个版本。 - 下一个版本的列`name`的内容是`\u0026#39;赵云\u0026#39;`,该版本的`trx_id`值为`200`,也在`m_ids`列表内,所以也不符合要求,继续跳到下一个版本。 - 下一个版本的列`name`的内容是`\u0026#39;张飞\u0026#39;`,该版本的`trx_id`值为`100`,小于`ReadView`中的`min_trx_id`值`200`,所以这个版本是符合要求的,最后返回给用户的版本就是这条列`name`为`\u0026#39;张飞\u0026#39;`的记录。 以此类推,如果之后`事务id`为`200`的记录也提交了,再此在使用`READ COMMITTED`隔离级别的事务中查询表`hero`中`number`值为`1`的记录时,得到的结果就是`\u0026#39;诸葛亮\u0026#39;`了,具体流程我们就不分析了。总结一下就是:**使用READ COMMITTED隔离级别的事务在每次查询开始时都会生成一个独立的ReadView**。 ### REPEATABLE READ —— 在第一次读取数据时生成一个ReadView 对于使用`REPEATABLE READ`隔离级别的事务来说,只会在第一次执行查询语句时生成一个`ReadView`,之后的查询就不会重复生成了。我们还是用例子看一下是什么效果。 比方说现在系统里有两个`事务id`分别为`100`、`200`的事务在执行: ``` Transaction 100 BEGIN; UPDATE hero SET name = \u0026#39;关羽\u0026#39; WHERE number = 1; UPDATE hero SET name = \u0026#39;张飞\u0026#39; WHERE number = 1; ``` Transaction 200\nBEGIN;\n更新了一些别的表的记录\n\u0026hellip; ```\n此刻,表hero中number为1的记录得到的版本链表如下所示:\n假设现在有一个使用REPEATABLE READ隔离级别的事务开始执行: ```\n使用REPEATABLE READ隔离级别的事务\nBEGIN;\nSELECT1:Transaction 100、200未提交\nSELECT * FROM hero WHERE number = 1; # 得到的列name的值为\u0026rsquo;刘备\u0026rsquo; ``` 这个SELECT1的执行过程如下:\n在执行SELECT语句时会先生成一个ReadView,ReadView的m_ids列表的内容就是[100, 200],min_trx_id为100,max_trx_id为201,creator_trx_id为0。 然后从版本链中挑选可见的记录,从图中可以看出,最新版本的列name的内容是'张飞',该版本的trx_id值为100,在m_ids列表内,所以不符合可见性要求,根据roll_pointer跳到下一个版本。 下一个版本的列name的内容是'关羽',该版本的trx_id值也为100,也在m_ids列表内,所以也不符合要求,继续跳到下一个版本。 下一个版本的列name的内容是'刘备',该版本的trx_id值为80,小于ReadView中的min_trx_id值100,所以这个版本是符合要求的,最后返回给用户的版本就是这条列name为'刘备'的记录。 之后,我们把事务id为100的事务提交一下,就像这样: ```\nTransaction 100\nBEGIN;\nUPDATE hero SET name = \u0026lsquo;关羽\u0026rsquo; WHERE number = 1;\nUPDATE hero SET name = \u0026lsquo;张飞\u0026rsquo; WHERE number = 1;\nCOMMIT; 然后再到事务id为200的事务中更新一下表hero中number为1的记录:\nTransaction 200\nBEGIN;\n更新了一些别的表的记录\n\u0026hellip;\nUPDATE hero SET name = \u0026lsquo;赵云\u0026rsquo; WHERE number = 1;\nUPDATE hero SET name = \u0026lsquo;诸葛亮\u0026rsquo; WHERE number = 1; ``` 此刻,表hero中number为1的记录的版本链就长这样:\n然后再到刚才使用REPEATABLE READ隔离级别的事务中继续查找这个number为1的记录,如下: ```\n使用REPEATABLE READ隔离级别的事务\nBEGIN;\nSELECT1:Transaction 100、200均未提交\nSELECT * FROM hero WHERE number = 1; # 得到的列name的值为\u0026rsquo;刘备\u0026rsquo;\nSELECT2:Transaction 100提交,Transaction 200未提交\nSELECT * FROM hero WHERE number = 1; # 得到的列name的值仍为\u0026rsquo;刘备\u0026rsquo; ``` 这个SELECT2的执行过程如下: - 因为当前事务的隔离级别为REPEATABLE READ,而之前在执行SELECT1时已经生成过ReadView了,所以此时直接复用之前的ReadView,之前的ReadView的m_ids列表的内容就是[100, 200],min_trx_id为100,max_trx_id为201,creator_trx_id为0。 - 然后从版本链中挑选可见的记录,从图中可以看出,最新版本的列name的内容是'诸葛亮',该版本的trx_id值为200,在m_ids列表内,所以不符合可见性要求,根据roll_pointer跳到下一个版本。 - 下一个版本的列name的内容是'赵云',该版本的trx_id值为200,也在m_ids列表内,所以也不符合要求,继续跳到下一个版本。 - 下一个版本的列name的内容是'张飞',该版本的trx_id值为100,而m_ids列表中是包含值为100的事务id的,所以该版本也不符合要求,同理下一个列name的内容是'关羽'的版本也不符合要求。继续跳到下一个版本。 - 下一个版本的列name的内容是'刘备',该版本的trx_id值为80,小于ReadView中的min_trx_id值100,所以这个版本是符合要求的,最后返回给用户的版本就是这条列c为'刘备'的记录。\n也就是说两次SELECT查询得到的结果是重复的,记录的列c值都是'刘备',这就是可重复读的含义。如果我们之后再把事务id为200的记录提交了,然后再到刚才使用REPEATABLE READ隔离级别的事务中继续查找这个number为1的记录,得到的结果还是'刘备',具体执行过程大家可以自己分析一下。\nMVCC小结 # 从上面的描述中我们可以看出来,所谓的MVCC(Multi-Version Concurrency Control ,多版本并发控制)指的就是在使用READ COMMITTD、REPEATABLE READ这两种隔离级别的事务在执行普通的SEELCT操作时访问记录的版本链的过程,这样子可以使不同事务的读-写、写-读操作并发执行,从而提升系统性能。READ COMMITTD、REPEATABLE READ这两个隔离级别的一个很大不同就是:生成ReadView的时机不同,READ COMMITTD在每一次进行普通SELECT操作前都会生成一个ReadView,而REPEATABLE READ只在第一次进行普通SELECT操作前生成一个ReadView,之后的查询操作都重复使用这个ReadView就好了。\n小贴士:我们之前说执行DELETE语句或者更新主键的UPDATE语句并不会立即把对应的记录完全从页面中删除,而是执行一个所谓的delete mark操作,相当于只是对记录打上了一个删除标志位,这主要就是为MVCC服务的,大家可以对比上面举的例子自己试想一下怎么使用。另外,所谓的MVCC只是在我们进行普通的SEELCT查询时才生效,截止到目前我们所见的所有SELECT语句都算是普通的查询,至于什么是个不普通的查询,我们稍后再说~\n关于purge # 大家有没有发现两件事儿: - 我们说insert undo在事务提交之后就可以被释放掉了,而update undo由于还需要支持MVCC,不能立即删除掉。 - 为了支持MVCC,对于delete mark操作来说,仅仅是在记录上打一个删除标记,并没有真正将它删除掉。\n随着系统的运行,在确定系统中包含最早产生的那个ReadView的事务不会再访问某些update undo日志以及被打了删除标记的记录后,有一个后台运行的purge线程会把它们真正的删除掉。关于更多的purge细节,我们将放到纸质书中进行详细介绍,不见不散~\n"},{"id":10,"href":"/zh/docs/technology/MySQL/MySQL%E6%98%AF%E6%80%8E%E6%A0%B7%E8%BF%90%E8%A1%8C%E7%9A%84/%E7%AC%AC23%E7%AB%A0_%E5%90%8E%E6%82%94%E4%BA%86%E6%80%8E%E4%B9%88%E5%8A%9E-undo%E6%97%A5%E5%BF%97%E4%B8%8B/","title":"第23章_后悔了怎么办-undo日志(下)","section":"My Sql是怎样运行的","content":"第23章 后悔了怎么办-undo日志(下)\n上一章我们主要介绍了为什么需要undo日志,以及INSERT、DELETE、UPDATE这些会对数据做改动的语句都会产生什么类型的undo日志,还有不同类型的undo日志的具体格式是什么。本章会继续介绍这些undo日志会被具体写到什么地方,以及在写入过程中需要注意的一些问题。\n通用链表结构 # 在写入undo日志的过程中会使用到多个链表,很多链表都有同样的节点结构,如图所示:\n在某个表空间内,我们可以通过一个页的页号和在页内的偏移量来唯一定位一个节点的位置,这两个信息也就相当于指向这个节点的一个指针。所以: - Pre Node Page Number和Pre Node Offset的组合就是指向前一个节点的指针 - Next Node Page Number和Next Node Offset的组合就是指向后一个节点的指针。\n整个List Node占用12个字节的存储空间。\n为了更好的管理链表,设计InnoDB的大佬还提出了一个基节点的结构,里边存储了这个链表的头节点、尾节点以及链表长度信息,基节点的结构示意图如下:\n其中: - List Length表明该链表一共有多少节点。 - First Node Page Number和First Node Offset的组合就是指向链表头节点的指针。 - Last Node Page Number和Last Node Offset的组合就是指向链表尾节点的指针。\n整个List Base Node占用16个字节的存储空间。\n所以使用List Base Node和List Node这两个结构组成的链表的示意图就是这样:\n小贴士:上述链表结构我们在前面的文章中频频提到,尤其是在表空间那一章重点描述过,不过我不敢奢求大家都记住了,所以在这里又强调一遍,希望大家不要嫌我烦,我只是怕大家忘了学习后续内容吃力而已~\nFIL_PAGE_UNDO_LOG页面 # 我们前面介绍表空间的时候说过,表空间其实是由许许多多的页面构成的,页面默认大小为16KB。这些页面有不同的类型,比如类型为FIL_PAGE_INDEX的页面用于存储聚簇索引以及二级索引,类型为FIL_PAGE_TYPE_FSP_HDR的页面用于存储表空间头部信息的,还有其他各种类型的页面,其中有一种称之为FIL_PAGE_UNDO_LOG类型的页面是专门用来存储undo日志的,这种类型的页面的通用结构如下图所示(以默认的16KB大小为例):\n“类型为FIL_PAGE_UNDO_LOG的页”这种说法太绕口,以后我们就简称为Undo页面了。上图中的File Header和File Trailer是各种页面都有的通用结构,我们前面介绍过很多遍了,这里就不赘述了(忘记了的可以到讲述数据页结构或者表空间的章节中查看)。Undo Page Header是Undo页面所特有的,我们来看一下它的结构:\n其中各个属性的意思如下:\nTRX_UNDO_PAGE_TYPE:本页面准备存储什么种类的undo日志。\n我们前面介绍了好几种类型的undo日志,它们可以被分为两个大类:\n+ `TRX_UNDO_INSERT`(使用十进制`1`表示):类型为`TRX_UNDO_INSERT_REC`的`undo日志`属于此大类,一般由`INSERT`语句产生,或者在`UPDATE`语句中有更新主键的情况也会产生此类型的`undo日志`。 + `TRX_UNDO_UPDATE`(使用十进制`2`表示),除了类型为`TRX_UNDO_INSERT_REC`的`undo日志`,其他类型的`undo日志`都属于这个大类,比如我们前面说的`TRX_UNDO_DEL_MARK_REC`、`TRX_UNDO_UPD_EXIST_REC`什么的,一般由`DELETE`、`UPDATE`语句产生的`undo日志`属于这个大类。 这个TRX_UNDO_PAGE_TYPE属性可选的值就是上面的两个,用来标记本页面用于存储哪个大类的undo日志,不同大类的undo日志不能混着存储,比如一个Undo页面的TRX_UNDO_PAGE_TYPE属性值为TRX_UNDO_INSERT,那么这个页面就只能存储类型为TRX_UNDO_INSERT_REC的undo日志,其他类型的undo日志就不能放到这个页面中了。\n小贴士:之所以把undo日志分成两个大类,是因为类型为TRX_UNDO_INSERT_REC的undo日志在事务提交后可以直接删除掉,而其他类型的undo日志还需要为所谓的MVCC服务,不能直接删除掉,对它们的处理需要区别对待。当然,如果你看这段话迷迷糊糊的话,那就不需要再看一遍了,现在只需要知道undo日志分为2个大类就好了,更详细的东西我们后边会仔细介绍的。\nTRX_UNDO_PAGE_START:表示在当前页面中是从什么位置开始存储undo日志的,或者说表示第一条undo日志在本页面中的起始偏移量。\nTRX_UNDO_PAGE_FREE:与上面的TRX_UNDO_PAGE_START对应,表示当前页面中存储的最后一条undo日志结束时的偏移量,或者说从这个位置开始,可以继续写入新的undo日志。\n假设现在向页面中写入了3条undo日志,那么TRX_UNDO_PAGE_START和TRX_UNDO_PAGE_FREE的示意图就是这样:\n当然,在最初一条undo日志也没写入的情况下,TRX_UNDO_PAGE_START和TRX_UNDO_PAGE_FREE的值是相同的。\nTRX_UNDO_PAGE_NODE:代表一个List Node结构(链表的普通节点,我们上面刚说的)。\n下面马上用到这个属性,稍安勿躁。\nUndo页面链表 # 单个事务中的Undo页面链表 # 因为一个事务可能包含多个语句,而且一个语句可能对若干条记录进行改动,而对每条记录进行改动前,都需要记录1条或2条的undo日志,所以在一个事务执行过程中可能产生很多undo日志,这些日志可能一个页面放不下,需要放到多个页面中,这些页面就通过我们上面介绍的TRX_UNDO_PAGE_NODE属性连成了链表:\n大家往上再瞅一瞅上面的图,我们特意把链表中的第一个Undo页面给标了出来,称它为first undo page,其余的Undo页面称之为normal undo page,这是因为在first undo page中除了记录Undo Page Header之外,还会记录其他的一些管理信息,这个我们稍后再说。\n在一个事务执行过程中,可能混着执行INSERT、DELETE、UPDATE语句,也就意味着会产生不同类型的undo日志。但是我们前面又强调过,同一个Undo页面要么只存储TRX_UNDO_INSERT大类的undo日志,要么只存储TRX_UNDO_UPDATE大类的undo日志,反正不能混着存,所以在一个事务执行过程中就可能需要2个Undo页面的链表,一个称之为insert undo链表,另一个称之为update undo链表,画个示意图就是这样:\n另外,设计InnoDB的大佬规定对普通表和临时表的记录改动时产生的undo日志要分别记录(我们稍后阐释为什么这么做),所以在一个事务中最多有4个以Undo页面为节点组成的链表:\n当然,并不是在事务一开始就会为这个事务分配这4个链表,具体分配策略如下: - 刚刚开启事务时,一个Undo页面链表也不分配。 - 当事务执行过程中向普通表中插入记录或者执行更新记录主键的操作之后,就会为其分配一个普通表的insert undo链表。 - 当事务执行过程中删除或者更新了普通表中的记录之后,就会为其分配一个普通表的update undo链表。 - 当事务执行过程中向临时表中插入记录或者执行更新记录主键的操作之后,就会为其分配一个临时表的insert undo链表。 - 当事务执行过程中删除或者更新了临时表中的记录之后,就会为其分配一个临时表的update undo链表。\n总结一句就是:按需分配,什么时候需要什么时候再分配,不需要就不分配。\n多个事务中的Undo页面链表 # 为了尽可能提高undo日志的写入效率,不同事务执行过程中产生的undo日志需要被写入到不同的Undo页面链表中。比方说现在有事务id分别为1、2的两个事务,我们分别称之为trx 1和trx 2,假设在这两个事务执行过程中:\ntrx 1对普通表做了DELETE操作,对临时表做了INSERT和UPDATE操作。\nInnoDB会为trx 1分配3个链表,分别是: - 针对普通表的update undo链表 - 针对临时表的insert undo链表 - 针对临时表的update undo链表。\ntrx 2对普通表做了INSERT、UPDATE和DELETE操作,没有对临时表做改动。\nInnoDB会为trx 2分配2个链表,分别是: - 针对普通表的insert undo链表 - 针对普通表的update undo链表。\n综上所述,在trx 1和trx 2执行过程中,InnoDB共需为这两个事务分配5个Undo页面链表,画个图就是这样:\n如果有更多的事务,那就意味着可能会产生更多的Undo页面链表。\nundo日志具体写入过程 # 段(Segment)的概念 # 如果你有认真看过表空间那一章的话,对这个段的概念应该印象深刻,我们当时花了非常大的篇幅来介绍这个概念。简单讲,这个段是一个逻辑上的概念,本质上是由若干个零散页面和若干个完整的区组成的。比如一个B+树索引被划分成两个段,一个叶子节点段,一个非叶子节点段,这样叶子节点就可以被尽可能的存到一起,非叶子节点被尽可能的存到一起。每一个段对应一个INODE Entry结构,这个INODE Entry结构描述了这个段的各种信息,比如段的ID,段内的各种链表基节点,零散页面的页号有哪些等信息(具体该结构中每个属性的意思大家可以到表空间那一章里再次重温一下)。我们前面也说过,为了定位一个INODE Entry,设计InnoDB的大佬设计了一个Segment Header的结构:\n整个Segment Header占用10个字节大小,各个属性的意思如下: - Space ID of the INODE Entry:INODE Entry结构所在的表空间ID。 - Page Number of the INODE Entry:INODE Entry结构所在的页面页号。 - Byte Offset of the INODE Ent:INODE Entry结构在该页面中的偏移量\n知道了表空间ID、页号、页内偏移量,不就可以唯一定位一个INODE Entry的地址了么~\n小贴士:这部分关于段的各种概念我们在表空间那一章中都有详细解释,在这里重提一下只是为了唤醒大家沉睡的记忆,如果有任何不清楚的地方可以再次跳回表空间的那一章仔细读一下。\nUndo Log Segment Header # 设计InnoDB的大佬规定,每一个Undo页面链表都对应着一个段,称之为Undo Log Segment。也就是说链表中的页面都是从这个段里边申请的,所以他们在Undo页面链表的第一个页面,也就是上面提到的first undo page中设计了一个称之为Undo Log Segment Header的部分,这个部分中包含了该链表对应的段的segment header信息以及其他的一些关于这个段的信息,所以Undo页面链表的第一个页面其实长这样:\n可以看到这个Undo链表的第一个页面比普通页面多了个Undo Log Segment Header,我们来看一下它的结构:\n其中各个属性的意思如下:\nTRX_UNDO_STATE:本Undo页面链表处在什么状态。\n一个Undo Log Segment可能处在的状态包括: - TRX_UNDO_ACTIVE:活跃状态,也就是一个活跃的事务正在往这个段里边写入undo日志。 - TRX_UNDO_CACHED:被缓存的状态。处在该状态的Undo页面链表等待着之后被其他事务重用。 - TRX_UNDO_TO_FREE:对于insert undo链表来说,如果在它对应的事务提交之后,该链表不能被重用,那么就会处于这种状态。 - TRX_UNDO_TO_PURGE:对于update undo链表来说,如果在它对应的事务提交之后,该链表不能被重用,那么就会处于这种状态。 - TRX_UNDO_PREPARED:包含处于PREPARE阶段的事务产生的undo日志。\n小贴士:Undo页面链表什么时候会被重用,怎么重用我们之后会详细说的。事务的PREPARE阶段是在所谓的分布式事务中才出现的,本书中不会介绍更多关于分布式事务的事情,所以大家目前忽略这个状态就好了。\nTRX_UNDO_LAST_LOG:本Undo页面链表中最后一个Undo Log Header的位置。\n小贴士:关于什么是Undo Log Header,我们稍后马上介绍。\nTRX_UNDO_FSEG_HEADER:本Undo页面链表对应的段的Segment Header信息(就是我们上一节介绍的那个10字节结构,通过这个信息可以找到该段对应的INODE Entry)。\nTRX_UNDO_PAGE_LIST:Undo页面链表的基节点。\n我们上面说Undo页面的Undo Page Header部分有一个12字节大小的TRX_UNDO_PAGE_NODE属性,这个属性代表一个List Node结构。每一个Undo页面都包含Undo Page Header结构,这些页面就可以通过这个属性连成一个链表。这个TRX_UNDO_PAGE_LIST属性代表着这个链表的基节点,当然这个基节点只存在于Undo页面链表的第一个页面,也就是first undo page中。\nUndo Log Header # 一个事务在向Undo页面中写入undo日志时的方式是十分简单暴力的,就是直接往里怼,写完一条紧接着写另一条,各条undo日志之间是亲密无间的。写完一个Undo页面后,再从段里申请一个新页面,然后把这个页面插入到Undo页面链表中,继续往这个新申请的页面中写。设计InnoDB的大佬认为同一个事务向一个Undo页面链表中写入的undo日志算是一个组,比方说我们上面介绍的trx 1由于会分配3个Undo页面链表,也就会写入3个组的undo日志;trx 2由于会分配2个Undo页面链表,也就会写入2个组的undo日志。在每写入一组undo日志时,都会在这组undo日志前先记录一下关于这个组的一些属性,设计InnoDB的大佬把存储这些属性的地方称之为Undo Log Header。所以Undo页面链表的第一个页面在真正写入undo日志前,其实都会被填充Undo Page Header、Undo Log Segment Header、Undo Log Header这3个部分,如图所示:\n这个Undo Log Header具体的结构如下:\n哇唔,映入眼帘的又是一大坨属性,我们先大致看一下它们都是什么意思: - TRX_UNDO_TRX_ID:生成本组undo日志的事务id。 - TRX_UNDO_TRX_NO:事务提交后生成的一个需要序号,使用此序号来标记事务的提交顺序(先提交的此序号小,后提交的此序号大)。 - TRX_UNDO_DEL_MARKS:标记本组undo日志中是否包含由于Delete mark操作产生的undo日志。 - TRX_UNDO_LOG_START:表示本组undo日志中第一条undo日志的在页面中的偏移量。 - TRX_UNDO_XID_EXISTS:本组undo日志是否包含XID信息。\n``` 小贴士:本书不会讲述更多关于XID是个什么东东,有兴趣的同学可以到搜索引擎或者文档中搜一搜。 ``` TRX_UNDO_DICT_TRANS:标记本组undo日志是不是由DDL语句产生的。 TRX_UNDO_TABLE_ID:如果TRX_UNDO_DICT_TRANS为真,那么本属性表示DDL语句操作的表的table id。 TRX_UNDO_NEXT_LOG:下一组的undo日志在页面中开始的偏移量。 TRX_UNDO_PREV_LOG:上一组的undo日志在页面中开始的偏移量。\n小贴士:一般来说一个Undo页面链表只存储一个事务执行过程中产生的一组undo日志,但是在某些情况下,可能会在一个事务提交之后,之后开启的事务重复利用这个Undo页面链表,这样就会导致一个Undo页面中可能存放多组Undo日志,TRX_UNDO_NEXT_LOG和TRX_UNDO_PREV_LOG就是用来标记下一组和上一组undo日志在页面中的偏移量的。关于什么时候重用Undo页面链表,怎么重用这个链表我们稍后会详细说明的,现在先理解TRX_UNDO_NEXT_LOG和TRX_UNDO_PREV_LOG这两个属性的意思就好了。\nTRX_UNDO_HISTORY_NODE:一个12字节的List Node结构,代表一个称之为History链表的节点。\n小贴士:关于History链表我们后边会格外详细的介绍,现在先不用管。\n小结 # 对于没有被重用的Undo页面链表来说,链表的第一个页面,也就是first undo page在真正写入undo日志前,会填充Undo Page Header、Undo Log Segment Header、Undo Log Header这3个部分,之后才开始正式写入undo日志。对于其他的页面来说,也就是normal undo page在真正写入undo日志前,只会填充Undo Page Header。链表的List Base Node存放到first undo page的Undo Log Segment Header部分,List Node信息存放到每一个Undo页面的undo Page Header部分,所以画一个Undo页面链表的示意图就是这样:\n重用Undo页面 # 我们前面说为了能提高并发执行的多个事务写入undo日志的性能,设计InnoDB的大佬决定为每个事务单独分配相应的Undo页面链表(最多可能单独分配4个链表)。但是这样也造成了一些问题,比如其实大部分事务执行过程中可能只修改了一条或几条记录,针对某个Undo页面链表只产生了非常少的undo日志,这些undo日志可能只占用一丢丢存储空间,每开启一个事务就新创建一个Undo页面链表(虽然这个链表中只有一个页面)来存储这么一丢丢undo日志岂不是太浪费了么?的确是挺浪费,于是设计InnoDB的大佬本着勤俭节约的优良传统,决定在事务提交后在某些情况下重用该事务的Undo页面链表。一个Undo页面链表是否可以被重用的条件很简单:\n该链表中只包含一个Undo页面。\n如果一个事务执行过程中产生了非常多的undo日志,那么它可能申请非常多的页面加入到Undo页面链表中。在该事物提交后,如果将整个链表中的页面都重用,那就意味着即使新的事务并没有向该Undo页面链表中写入很多undo日志,那该链表中也得维护非常多的页面,那些用不到的页面也不能被别的事务所使用,这样就造成了另一种浪费。所以设计InnoDB的大佬们规定,只有在Undo页面链表中只包含一个Undo页面时,该链表才可以被下一个事务所重用。\n该Undo页面已经使用的空间小于整个页面空间的3/4。\n我们前面说过,Undo页面链表按照存储的undo日志所属的大类可以被分为insert undo链表和update undo链表两种,这两种链表在被重用时的策略也是不同的,我们分别看一下:\ninsert undo链表\ninsert undo链表中只存储类型为TRX_UNDO_INSERT_REC的undo日志,这种类型的undo日志在事务提交之后就没用了,就可以被清除掉。所以在某个事务提交后,重用这个事务的insert undo链表(这个链表中只有一个页面)时,可以直接把之前事务写入的一组undo日志覆盖掉,从头开始写入新事务的一组undo日志,如下图所示:\n如图所示,假设有一个事务使用的insert undo链表,到事务提交时,只向insert undo链表中插入了3条undo日志,这个insert undo链表只申请了一个Undo页面。假设此刻该页面已使用的空间小于整个页面大小的3/4,那么下一个事务就可以重用这个insert undo链表(链表中只有一个页面)。假设此时有一个新事务重用了该insert undo链表,那么可以直接把旧的一组undo日志覆盖掉,写入一组新的undo日志。\n小贴士:当然,在重用Undo页面链表写入新的一组undo日志时,不仅会写入新的Undo Log Header,还会适当调整Undo Page Header、Undo Log Segment Header、Undo Log Header中的一些属性,比如TRX_UNDO_PAGE_START、TRX_UNDO_PAGE_FREE等等等等,这些我们就不具体介绍了。\nupdate undo链表\n在一个事务提交后,它的update undo链表中的undo日志也不能立即删除掉(这些日志用于MVCC,我们后边会说的)。所以如果之后的事务想重用update undo链表时,就不能覆盖之前事务写入的undo日志。这样就相当于在同一个Undo页面中写入了多组的undo日志,效果看起来就是这样:\n回滚段 # 回滚段的概念 # 我们现在知道一个事务在执行过程中最多可以分配4个Undo页面链表,在同一时刻不同事务拥有的Undo页面链表是不一样的,所以在同一时刻系统里其实可以有许许多多个Undo页面链表存在。为了更好的管理这些链表,设计InnoDB的大佬又设计了一个称之为Rollback Segment Header的页面,在这个页面中存放了各个Undo页面链表的frist undo page的页号,他们把这些页号称之为undo slot。我们可以这样理解,每个Undo页面链表都相当于是一个班,这个链表的first undo page就相当于这个班的班长,找到了这个班的班长,就可以找到班里的其他同学(其他同学相当于normal undo page)。有时候学校需要向这些班级传达一下精神,就需要把班长都召集在会议室,这个Rollback Segment Header就相当于是一个会议室。\n我们看一下这个称之为Rollback Segment Header的页面长什么样(以默认的16KB为例):\n设计InnoDB的大佬规定,每一个Rollback Segment Header页面都对应着一个段,这个段就称为Rollback Segment,翻译过来就是回滚段。与我们之前介绍的各种段不同的是,这个Rollback Segment里其实只有一个页面(这可能是设计InnoDB的大佬们的一种洁癖,他们可能觉得为了某个目的去分配页面的话都得先申请一个段,或者他们觉得虽然目前版本的MySQL里Rollback Segment里其实只有一个页面,但可能之后的版本里会增加页面也说不定)。\n了解了Rollback Segment的含义之后,我们再来看看这个称之为Rollback Segment Header的页面的各个部分的含义都是什么意思:\nTRX_RSEG_MAX_SIZE:本Rollback Segment中管理的所有Undo页面链表中的Undo页面数量之和的最大值。换句话说,本Rollback Segment中所有Undo页面链表中的Undo页面数量之和不能超过TRX_RSEG_MAX_SIZE代表的值。\n该属性的值默认为无限大,也就是我们想写多少Undo页面都可以。 小贴士:无限大其实也只是个夸张的说法,4个字节能表示最大的数也就是0xFFFFFFFF,但是我们之后会看到,0xFFFFFFFF这个数有特殊用途,所以实际上TRX_RSEG_MAX_SIZE的值为0xFFFFFFFE。\nTRX_RSEG_HISTORY_SIZE:History链表占用的页面数量。\nTRX_RSEG_HISTORY:History链表的基节点。\n小贴士:History链表后边讲,稍安勿躁。\nTRX_RSEG_FSEG_HEADER:本Rollback Segment对应的10字节大小的Segment Header结构,通过它可以找到本段对应的INODE Entry。\nTRX_RSEG_UNDO_SLOTS:各个Undo页面链表的first undo page的页号集合,也就是undo slot集合。\n一个页号占用4个字节,对于16KB大小的页面来说,这个TRX_RSEG_UNDO_SLOTS部分共存储了1024个undo slot,所以共需1024 × 4 = 4096个字节。\n从回滚段中申请Undo页面链表 # 初始情况下,由于未向任何事务分配任何Undo页面链表,所以对于一个Rollback Segment Header页面来说,它的各个undo slot都被设置成了一个特殊的值:FIL_NULL(对应的十六进制就是0xFFFFFFFF),表示该undo slot不指向任何页面。\n随着时间的流逝,开始有事务需要分配Undo页面链表了,就从回滚段的第一个undo slot开始,看看该undo slot的值是不是FIL_NULL:\n如果是FIL_NULL,那么在表空间中新创建一个段(也就是Undo Log Segment),然后从段里申请一个页面作为Undo页面链表的first undo page,然后把该undo slot的值设置为刚刚申请的这个页面的地址,这样也就意味着这个undo slot被分配给了这个事务。 如果不是FIL_NULL,说明该undo slot已经指向了一个undo链表,也就是说这个undo slot已经被别的事务占用了,那就跳到下一个undo slot,判断该undo slot的值是不是FIL_NULL,重复上面的步骤。 一个Rollback Segment Header页面中包含1024个undo slot,如果这1024个undo slot的值都不为FIL_NULL,这就意味着这1024个undo slot都已经名花有主(被分配给了某个事务),此时由于新事务无法再获得新的Undo页面链表,就会回滚这个事务并且给用户报错: Too many active concurrent transactions 用户看到这个错误,可以选择重新执行这个事务(可能重新执行时有别的事务提交了,该事务就可以被分配Undo页面链表了)。\n当一个事务提交时,它所占用的undo slot有两种命运:\n如果该undo slot指向的Undo页面链表符合被重用的条件(就是我们上面说的Undo页面链表只占用一个页面并且已使用空间小于整个页面的3/4)。\n该undo slot就处于被缓存的状态,设计InnoDB的大佬规定这时该Undo页面链表的TRX_UNDO_STATE属性(该属性在first undo page的Undo Log Segment Header部分)会被设置为TRX_UNDO_CACHED。\n被缓存的undo slot都会被加入到一个链表,根据对应的Undo页面链表的类型不同,也会被加入到不同的链表:\n+ 如果对应的`Undo页面`链表是`insert undo链表`,则该`undo slot`会被加入`insert undo cached链表`。 + 如果对应的`Undo页面`链表是`update undo链表`,则该`undo slot`会被加入`update undo cached链表`。 一个回滚段就对应着上述两个cached链表,如果有新事务要分配undo slot时,先从对应的cached链表中找。如果没有被缓存的undo slot,才会到回滚段的Rollback Segment Header页面中再去找。\n如果该undo slot指向的Undo页面链表不符合被重用的条件,那么针对该undo slot对应的Undo页面链表类型不同,也会有不同的处理:\n+ 如果对应的`Undo页面`链表是`insert undo链表`,则该`Undo页面`链表的`TRX_UNDO_STATE`属性会被设置为`TRX_UNDO_TO_FREE`,之后该`Undo页面`链表对应的段会被释放掉(也就意味着段中的页面可以被挪作他用),然后把该`undo slot`的值设置为`FIL_NULL`。 + 如果对应的`Undo页面`链表是`update undo链表`,则该`Undo页面`链表的`TRX_UNDO_STATE`属性会被设置为`TRX_UNDO_TO_PRUGE`,则会将该`undo slot`的值设置为`FIL_NULL`,然后将本次事务写入的一组`undo`日志放到所谓的`History链表`中(需要注意的是,这里并不会将`Undo页面`链表对应的段给释放掉,因为这些`undo`日志还有用呢~)。 小贴士:更多关于History链表的事我们稍后再说,稍安勿躁。\n多个回滚段 # 我们说一个事务执行过程中最多分配4个Undo页面链表,而一个回滚段里只有1024个undo slot,很显然undo slot的数量有点少啊。我们即使假设一个读写事务执行过程中只分配1个Undo页面链表,那1024个undo slot也只能支持1024个读写事务同时执行,再多了就崩溃了。这就相当于会议室只能容下1024个班长同时开会,如果有几千人同时到会议室开会的话,那后来的那些班长就没地方坐了,只能等待前面的人开完会自己再进去开。\n话说在InnoDB的早期发展阶段的确只有一个回滚段,但是设计InnoDB的大佬后来意识到了这个问题,咋解决这问题呢?会议室不够,多盖几个会议室不就得了。所以设计InnoDB的大佬一口气定义了128个回滚段,也就相当于有了128 × 1024 = 131072个undo slot。假设一个读写事务执行过程中只分配1个Undo页面链表,那么就可以同时支持131072个读写事务并发执行(这么多事务在一台机器上并发执行,还真没见过呢~)。 小贴士:只读事务并不需要分配Undo页面链表,MySQL 5.7中所有刚开启的事务默认都是只读事务,只有在事务执行过程中对记录做了某些改动时才会被升级为读写事务。 每个回滚段都对应着一个Rollback Segment Header页面,有128个回滚段,自然就要有128个Rollback Segment Header页面,这些页面的地址总得找个地方存一下吧!于是设计InnoDB的大佬在系统表空间的第5号页面的某个区域包含了128个8字节大小的格子:\n每个8字节的格子的构造就像这样:\n如果所示,每个8字节的格子其实由两部分组成:\n4字节大小的Space ID,代表一个表空间的ID。 4字节大小的Page number,代表一个页号。 也就是说每个8字节大小的格子相当于一个指针,指向某个表空间中的某个页面,这些页面就是Rollback Segment Header。这里需要注意的一点事,要定位一个Rollback Segment Header还需要知道对应的表空间ID,这也就意味着不同的回滚段可能分布在不同的表空间中。\n所以通过上面的叙述我们可以大致清楚,在系统表空间的第5号页面中存储了128个Rollback Segment Header页面地址,每个Rollback Segment Header就相当于一个回滚段。在Rollback Segment Header页面中,又包含1024个undo slot,每个undo slot都对应一个Undo页面链表。我们画个示意图:\n把图一画出来就清爽多了。\n回滚段的分类 # 我们把这128个回滚段给编一下号,最开始的回滚段称之为第0号回滚段,之后依次递增,最后一个回滚段就称之为第127号回滚段。这128个回滚段可以被分成两大类:\n第0号、第33~127号回滚段属于一类。其中第0号回滚段必须在系统表空间中(就是说第0号回滚段对应的Rollback Segment Header页面必须在系统表空间中),第33~127号回滚段既可以在系统表空间中,也可以在自己配置的undo表空间中,关于怎么配置我们稍后再说。\n如果一个事务在执行过程中由于对普通表的记录做了改动需要分配Undo页面链表时,必须从这一类的段中分配相应的undo slot。\n第1~32号回滚段属于一类。这些回滚段必须在临时表空间(对应着数据目录中的ibtmp1文件)中。\n如果一个事务在执行过程中由于对临时表的记录做了改动需要分配Undo页面链表时,必须从这一类的段中分配相应的undo slot。\n也就是说如果一个事务在执行过程中既对普通表的记录做了改动,又对临时表的记录做了改动,那么需要为这个记录分配2个回滚段,再分别到这两个回滚段中分配对应的undo slot。\n不知道大家有没有疑惑,为什么要把针对普通表和临时表来划分不同种类的回滚段呢?这个还得从Undo页面本身说起,我们说Undo页面其实是类型为FIL_PAGE_UNDO_LOG的页面的简称,说到底它也是一个普通的页面。我们前面说过,在修改页面之前一定要先把对应的redo日志写上,这样在系统奔溃重启时才能恢复到奔溃前的状态。我们向Undo页面写入undo日志本身也是一个写页面的过程,设计InnoDB的大佬为此还设计了许多种redo日志的类型,比方说MLOG_UNDO_HDR_CREATE、MLOG_UNDO_INSERT、MLOG_UNDO_INIT等等等等,也就是说我们对Undo页面做的任何改动都会记录相应类型的redo日志。但是对于临时表来说,因为修改临时表而产生的undo日志只需要在系统运行过程中有效,如果系统奔溃了,那么在重启时也不需要恢复这些undo日志所在的页面,所以在写针对临时表的Undo页面时,并不需要记录相应的redo日志。总结一下针对普通表和临时表划分不同种类的回滚段的原因:在修改针对普通表的回滚段中的Undo页面时,需要记录对应的redo日志,而修改针对临时表的回滚段中的Undo页面时,不需要记录对应的redo日志。\n小贴士:实际上在MySQL 5.7.21这个版本中,如果我们仅仅对普通表的记录做了改动,那么只会为该事务分配针对普通表的回滚段,不分配针对临时表的回滚段。但是如果我们仅仅对临时表的记录做了改动,那么既会为该事务分配针对普通表的回滚段,又会为其分配针对临时表的回滚段(不过分配了回滚段并不会立即分配undo slot,只有在真正需要Undo页面链表时才会去分配回滚段中的undo slot)。\n为事务分配Undo页面链表详细过程 # 上面说了一大堆的概念,大家应该有一点点的小晕,接下来我们以事务对普通表的记录做改动为例,给大家梳理一下事务执行过程中分配Undo页面链表时的完整过程,\n事务在执行过程中对普通表的记录首次做改动之前,首先会到系统表空间的第5号页面中分配一个回滚段(其实就是获取一个Rollback Segment Header页面的地址)。一旦某个回滚段被分配给了这个事务,那么之后该事务中再对普通表的记录做改动时,就不会重复分配了。\n使用传说中的round-robin(循环使用)方式来分配回滚段。比如当前事务分配了第0号回滚段,那么下一个事务就要分配第33号回滚段,下下个事务就要分配第34号回滚段,简单一点的说就是这些回滚段被轮着分配给不同的事务(就是这么简单粗暴,没什么好说的)。\n在分配到回滚段后,首先看一下这个回滚段的两个cached链表有没有已经缓存了的undo slot,比如如果事务做的是INSERT操作,就去回滚段对应的insert undo cached链表中看看有没有缓存的undo slot;如果事务做的是DELETE操作,就去回滚段对应的update undo cached链表中看看有没有缓存的undo slot。如果有缓存的undo slot,那么就把这个缓存的undo slot分配给该事务。\n如果没有缓存的undo slot可供分配,那么就要到Rollback Segment Header页面中找一个可用的undo slot分配给当前事务。\n从Rollback Segment Header页面中分配可用的undo slot的方式我们上面也说过了,就是从第0个undo slot开始,如果该undo slot的值为FIL_NULL,意味着这个undo slot是空闲的,就把这个undo slot分配给当前事务,否则查看第1个undo slot是否满足条件,依次类推,直到最后一个undo slot。如果这1024个undo slot都没有值为FIL_NULL的情况,就直接报错喽(一般不会出现这种情况)~\n找到可用的undo slot后,如果该undo slot是从cached链表中获取的,那么它对应的Undo Log Segment已经分配了,否则的话需要重新分配一个Undo Log Segment,然后从该Undo Log Segment中申请一个页面作为Undo页面链表的first undo page。\n然后事务就可以把undo日志写入到上面申请的Undo页面链表了! 对临时表的记录做改动的步骤和上述的一样,就不赘述了。不错需要再次强调一次,如果一个事务在执行过程中既对普通表的记录做了改动,又对临时表的记录做了改动,那么需要为这个记录分配2个回滚段。并发执行的不同事务其实也可以被分配相同的回滚段,只要分配不同的undo slot就可以了。\n回滚段相关配置 # 配置回滚段数量 # 我们前面说系统中一共有128个回滚段,其实这只是默认值,我们可以通过启动参数innodb_rollback_segments来配置回滚段的数量,可配置的范围是1~128。但是这个参数并不会影响针对临时表的回滚段数量,针对临时表的回滚段数量一直是32,也就是说: - 如果我们把innodb_rollback_segments的值设置为1,那么只会有1个针对普通表的可用回滚段,但是仍然有32个针对临时表的可用回滚段。 - 如果我们把innodb_rollback_segments的值设置为2~33之间的数,效果和将其设置为1是一样的。 - 如果我们把innodb_rollback_segments设置为大于33的数,那么针对普通表的可用回滚段数量就是该值减去32。\n配置undo表空间 # 默认情况下,针对普通表设立的回滚段(第0号以及第33~127号回滚段)都是被分配到系统表空间的。其中的第第0号回滚段是一直在系统表空间的,但是第33~127号回滚段可以通过配置放到自定义的undo表空间中。但是这种配置只能在系统初始化(创建数据目录时)的时候使用,一旦初始化完成,之后就不能再次更改了。我们看一下相关启动参数: - 通过innodb_undo_directory指定undo表空间所在的目录,如果没有指定该参数,则默认undo表空间所在的目录就是数据目录。 - 通过innodb_undo_tablespaces定义undo表空间的数量。该参数的默认值为0,表明不创建任何undo表空间。\n第`33~127`号回滚段可以平均分布到不同的`undo表空间`中。 小贴士:如果我们在系统初始化的时候指定了创建了undo表空间,那么系统表空间中的第0号回滚段将处于不可用状态。 比如我们在系统初始化时指定的innodb_rollback_segments为35,innodb_undo_tablespaces为2,这样就会将第33、34号回滚段分别分布到一个undo表空间中。\n设立undo表空间的一个好处就是在undo表空间中的文件大到一定程度时,可以自动的将该undo表空间截断(truncate)成一个小文件。而系统表空间的大小只能不断的增大,却不能截断。\n"},{"id":11,"href":"/zh/docs/technology/MySQL/MySQL%E6%98%AF%E6%80%8E%E6%A0%B7%E8%BF%90%E8%A1%8C%E7%9A%84/%E7%AC%AC22%E7%AB%A0_%E5%90%8E%E6%82%94%E4%BA%86%E6%80%8E%E4%B9%88%E5%8A%9E-undo%E6%97%A5%E5%BF%97%E4%B8%8A/","title":"第22章_后悔了怎么办-undo日志(上)","section":"My Sql是怎样运行的","content":"第22章 后悔了怎么办-undo日志(上)\n事务回滚的需求 # 我们说过事务需要保证原子性,也就是事务中的操作要么全部完成,要么什么也不做。但是偏偏有时候事务执行到一半会出现一些情况,比如: - 情况一:事务执行过程中可能遇到各种错误,比如服务器本身的错误,操作系统错误,甚至是突然断电导致的错误。 - 情况二:程序员可以在事务执行过程中手动输入ROLLBACK语句结束当前的事务的执行。\n这两种情况都会导致事务执行到一半就结束,但是事务执行过程中可能已经修改了很多东西,为了保证事务的原子性,我们需要把东西改回原先的样子,这个过程就称之为回滚(英文名:rollback),这样就可以造成一个假象:这个事务看起来什么都没做,所以符合原子性要求。\n小时候我非常痴迷于象棋,总是想找厉害的大人下棋,赢棋是不可能赢棋的,这辈子都不可能赢棋的,又不想认输,只能偷偷的悔棋才能勉强玩的下去。悔棋就是一种非常典型的回滚操作,比如棋子往前走两步,悔棋对应的操作就是向后走两步;比如棋子往左走一步,悔棋对应的操作就是向右走一步。数据库中的回滚跟悔棋差不多,你插入了一条记录,回滚操作对应的就是把这条记录删除掉;你更新了一条记录,回滚操作对应的就是把该记录更新为旧值;你删除了一条记录,回滚操作对应的自然就是把该记录再插进去。说的貌似很简单的样子[手动偷笑😏]。\n从上面的描述中我们已经能隐约感觉到,每当我们要对一条记录做改动时(这里的改动可以指INSERT、DELETE、UPDATE),都需要留一手 —— 把回滚时所需的东西都给记下来。比方说: - 你插入一条记录时,至少要把这条记录的主键值记下来,之后回滚的时候只需要把这个主键值对应的记录删掉就好了。 - 你删除了一条记录,至少要把这条记录中的内容都记下来,这样之后回滚时再把由这些内容组成的记录插入到表中就好了。 - 你修改了一条记录,至少要把修改这条记录前的旧值都记录下来,这样之后回滚时再把这条记录更新为旧值就好了。\n设计数据库的大佬把这些为了回滚而记录的这些东东称之为撤销日志,英文名为undo log,我们也可以土洋结合,称之为undo日志。这里需要注意的一点是,由于查询操作(SELECT)并不会修改任何用户记录,所以在查询操作执行时,并不需要记录相应的undo日志。在真实的InnoDB中,undo日志其实并不像我们上面所说的那么简单,不同类型的操作产生的undo日志的格式也是不同的,不过先暂时把这些容易让人脑子糊的具体细节放一放,我们先回过头来看看事务id是个神马玩意儿。\n事务id # 给事务分配id的时机 # 我们前面在介绍事务简介时说过,一个事务可以是一个只读事务,或者是一个读写事务:\n我们可以通过START TRANSACTION READ ONLY语句开启一个只读事务。\n在只读事务中不可以对普通的表(其他事务也能访问到的表)进行增、删、改操作,但可以对临时表做增、删、改操作。\n我们可以通过START TRANSACTION READ WRITE语句开启一个读写事务,或者使用BEGIN、START TRANSACTION语句开启的事务默认也算是读写事务。\n在读写事务中可以对表执行增删改查操作。\n如果某个事务执行过程中对某个表执行了增、删、改操作,那么InnoDB存储引擎就会给它分配一个独一无二的事务id,分配方式如下:\n对于只读事务来说,只有在它第一次对某个用户创建的临时表执行增、删、改操作时才会为这个事务分配一个事务id,否则的话是不分配事务id的。\n小贴士:我们前面说过对某个查询语句执行EXPLAIN分析它的查询计划时,有时候在Extra列会看到Using temporary的提示,这个表明在执行该查询语句时会用到内部临时表。这个所谓的内部临时表和我们手动用CREATE TEMPORARY TABLE创建的用户临时表并不一样,在事务回滚时并不需要把执行SELECT语句过程中用到的内部临时表也回滚,在执行SELECT语句用到内部临时表时并不会为它分配事务id。 - 对于读写事务来说,只有在它第一次对某个表(包括用户创建的临时表)执行增、删、改操作时才会为这个事务分配一个事务id,否则的话也是不分配事务id的。\n有的时候虽然我们开启了一个读写事务,但是在这个事务中全是查询语句,并没有执行增、删、改的语句,那也就意味着这个事务并不会被分配一个事务id。\n说了半天,事务id有什么子用?这个先保密,后边会一步步的详细介绍。现在只要知道只有在事务对表中的记录做改动时才会为这个事务分配一个唯一的事务id。 小贴士:上面描述的事务id分配策略是针对MySQL 5.7来说的,前面的版本的分配方式可能不同~\n事务id是怎么生成的 # 这个事务id本质上就是一个数字,它的分配策略和我们前面提到的对隐藏列row_id(当用户没有为表创建主键和UNIQUE键时InnoDB自动创建的列)的分配策略大抵相同,具体策略如下: - 服务器会在内存中维护一个全局变量,每当需要为某个事务分配一个事务id时,就会把该变量的值当作事务id分配给该事务,并且把该变量自增1。 - 每当这个变量的值为256的倍数时,就会将该变量的值刷新到系统表空间的页号为5的页面中一个称之为Max Trx ID的属性处,这个属性占用8个字节的存储空间。 - 当系统下一次重新启动时,会将上面提到的Max Trx ID属性加载到内存中,将该值加上256之后赋值给我们前面提到的全局变量(因为在上次关机时该全局变量的值可能大于Max Trx ID属性值)。\n这样就可以保证整个系统中分配的事务id值是一个递增的数字。先被分配id的事务得到的是较小的事务id,后被分配id的事务得到的是较大的事务id。\ntrx_id隐藏列 # 我们前面介绍InnoDB记录行格式的时候重点强调过:聚簇索引的记录除了会保存完整的用户数据以外,而且还会自动添加名为trx_id、roll_pointer的隐藏列,如果用户没有在表中定义主键以及UNIQUE键,还会自动添加一个名为row_id的隐藏列。所以一条记录在页面中的真实结构看起来就是这样的:\n其中的trx_id列其实还蛮好理解的,就是某个对这个聚簇索引记录做改动的语句所在的事务对应的事务id而已(此处的改动可以是INSERT、DELETE、UPDATE操作)。至于roll_pointer隐藏列我们后边分析~\nundo日志的格式 # 为了实现事务的原子性,InnoDB存储引擎在实际进行增、删、改一条记录时,都需要先把对应的undo日志记下来。一般每对一条记录做一次改动,就对应着一条undo日志,但在某些更新记录的操作中,也可能会对应着2条undo日志,这个我们后边会仔细介绍。一个事务在执行过程中可能新增、删除、更新若干条记录,也就是说需要记录很多条对应的undo日志,这些undo日志会被从0开始编号,也就是说根据生成的顺序分别被称为第0号undo日志、第1号undo日志、\u0026hellip;、第n号undo日志等,这个编号也被称之为undo no。\n这些undo日志是被记录到类型为FIL_PAGE_UNDO_LOG(对应的十六进制是0x0002,忘记了页面类型是什么的同学需要回过头再看看前面的章节)的页面中。这些页面可以从系统表空间中分配,也可以从一种专门存放undo日志的表空间,也就是所谓的undo tablespace中分配。不过关于如何分配存储undo日志的页面这个事情我们稍后再说,现在先来看看不同操作都会产生什么样子的undo日志吧~ 为了故事的顺利发展,我们先来创建一个名为undo_demo的表: CREATE TABLE undo_demo ( id INT NOT NULL, key1 VARCHAR(100), col VARCHAR(100), PRIMARY KEY (id), KEY idx_key1 (key1) )Engine=InnoDB CHARSET=utf8; 这个表中有3个列,其中id列是主键,我们为key1列建立了一个二级索引,col列是一个普通的列。我们前面介绍InnoDB的数据字典时说过,每个表都会被分配一个唯一的table id,我们可以通过系统数据库information_schema中的innodb_sys_tables表来查看某个表对应的table id是什么,现在我们查看一下undo_demo对应的table id是多少: mysql\u0026gt; SELECT * FROM information_schema.innodb_sys_tables WHERE name = 'xiaohaizi/undo_demo'; +----------+---------------------+------+--------+-------+-------------+------------+---------------+------------+ | TABLE_ID | NAME | FLAG | N_COLS | SPACE | FILE_FORMAT | ROW_FORMAT | ZIP_PAGE_SIZE | SPACE_TYPE | +----------+---------------------+------+--------+-------+-------------+------------+---------------+------------+ | 138 | xiaohaizi/undo_demo | 33 | 6 | 482 | Barracuda | Dynamic | 0 | Single | +----------+---------------------+------+--------+-------+-------------+------------+---------------+------------+ 1 row in set (0.01 sec) 从查询结果可以看出,undo_demo表对应的table id为138,先把这个值记住,我们后边有用。\nINSERT操作对应的undo日志 # 我们前面说过,当我们向表中插入一条记录时会有乐观插入和悲观插入的区分,但是不管怎么插入,最终导致的结果就是这条记录被放到了一个数据页中。如果希望回滚这个插入操作,那么把这条记录删除就好了,也就是说在写对应的undo日志时,主要是把这条记录的主键信息记上。所以设计InnoDB的大佬设计了一个类型为TRX_UNDO_INSERT_REC的undo日志,它的完整结构如下图所示:\n根据示意图我们强调几点: - undo no在一个事务中是从0开始递增的,也就是说只要事务没提交,每生成一条undo日志,那么该条日志的undo no就增1。 - 如果记录中的主键只包含一个列,那么在类型为TRX_UNDO_INSERT_REC的undo日志中只需要把该列占用的存储空间大小和真实值记录下来,如果记录中的主键包含多个列,那么每个列占用的存储空间大小和对应的真实值都需要记录下来(图中的len就代表列占用的存储空间大小,value就代表列的真实值)。\n小贴士:当我们向某个表中插入一条记录时,实际上需要向聚簇索引和所有的二级索引都插入一条记录。不过记录undo日志时,我们只需要考虑向聚簇索引插入记录时的情况就好了,因为其实聚簇索引记录和二级索引记录是一一对应的,我们在回滚插入操作时,只需要知道这条记录的主键信息,然后根据主键信息做对应的删除操作,做删除操作时就会顺带着把所有二级索引中相应的记录也删除掉。后边说到的DELETE操作和UPDATE操作对应的undo日志也都是针对聚簇索引记录而言的,我们之后就不强调了。 现在我们向undo_demo中插入两条记录: ``` BEGIN; # 显式开启一个事务,假设该事务的id为100\n插入两条记录\nINSERT INTO undo_demo(id, key1, col) VALUES (1, \u0026lsquo;AWM\u0026rsquo;, \u0026lsquo;狙击枪\u0026rsquo;), (2, \u0026lsquo;M416\u0026rsquo;, \u0026lsquo;步枪\u0026rsquo;); ``` 因为记录的主键只包含一个id列,所以我们在对应的undo日志中只需要将待插入记录的id列占用的存储空间长度(id列的类型为INT,INT类型占用的存储空间长度为4个字节)和真实值记录下来。本例中插入了两条记录,所以会产生两条类型为TRX_UNDO_INSERT_REC的undo日志:\n第一条undo日志的undo no为0,记录主键占用的存储空间长度为4,真实值为1。画一个示意图就是这样:\n第二条undo日志的undo no为1,记录主键占用的存储空间长度为4,真实值为2。画一个示意图就是这样(与第一条undo日志对比,undo no和主键各列信息有不同):\n小贴士:为了最大限度的节省undo日志占用的存储空间,和我们前面说过的redo日志类似,设计InnoDB的大佬会给undo日志中的某些属性进行压缩处理,具体的压缩细节我们就不介绍了。\nroll_pointer隐藏列的含义 # 是时候揭开roll_pointer的真实面纱了,这个占用7个字节的字段其实一点都不神秘,本质上就是一个指向记录对应的undo日志的一个指针。比方说我们上面向undo_demo表里插入了2条记录,每条记录都有与其对应的一条undo日志。记录被存储到了类型为FIL_PAGE_INDEX的页面中(就是我们前面一直所说的数据页),undo日志被存放到了类型为FIL_PAGE_UNDO_LOG的页面中。效果如图所示:\n从图中也可以更直观的看出来,roll_pointer本质就是一个指针,指向记录对应的undo日志。不过这7个字节的roll_pointer的每一个字节具体的含义我们后边介绍完如何分配存储undo日志的页面之后再具体说~\nDELETE操作对应的undo日志 # 我们知道插入到页面中的记录会根据记录头信息中的next_record属性组成一个单向链表,我们把这个链表称之为正常记录链表;我们在前面介绍数据页结构的时候说过,被删除的记录其实也会根据记录头信息中的next_record属性组成一个链表,只不过这个链表中的记录占用的存储空间可以被重新利用,所以也称这个链表为垃圾链表。Page Header部分有一个称之为PAGE_FREE的属性,它指向由被删除记录组成的垃圾链表中的头节点。为了故事的顺利发展,我们先画一个图,假设此刻某个页面中的记录分布情况是这样的(这个不是undo_demo表中的记录,只是我们随便举的一个例子):\n为了突出主题,在这个简化版的示意图中,我们只把记录的delete_mask标志位展示了出来。从图中可以看出,正常记录链表中包含了3条正常记录,垃圾链表里包含了2条已删除记录,在垃圾链表中的这些记录占用的存储空间可以被重新利用。页面的Page Header部分的PAGE_FREE属性的值代表指向垃圾链表头节点的指针。假设现在我们准备使用DELETE语句把正常记录链表中的最后一条记录给删除掉,其实这个删除的过程需要经历两个阶段:\n阶段一:仅仅将记录的delete_mask标识位设置为1,其他的不做修改(其实会修改记录的trx_id、roll_pointer这些隐藏列的值)。设计InnoDB的大佬把这个阶段称之为delete mark。\n把这个过程画下来就是这样:\n可以看到,正常记录链表中的最后一条记录的delete_mask值被设置为1,但是并没有被加入到垃圾链表。也就是此时记录处于一个中间状态,跟猪八戒照镜子——里外不是人似的。在删除语句所在的事务提交之前,被删除的记录一直都处于这种所谓的中间状态。\n小贴士:为什么会有这种奇怪的中间状态呢?其实主要是为了实现一个称之为MVCC的功能,稍后再介绍。\n阶段二:当该删除语句所在的事务提交之后,会有专门的线程后来真正的把记录删除掉。所谓真正的删除就是把该记录从正常记录链表中移除,并且加入到垃圾链表中,然后还要调整一些页面的其他信息,比如页面中的用户记录数量PAGE_N_RECS、上次插入记录的位置PAGE_LAST_INSERT、垃圾链表头节点的指针PAGE_FREE、页面中可重用的字节数量PAGE_GARBAGE、还有页目录的一些信息等等。设计InnoDB的大佬把这个阶段称之为purge。\n把阶段二执行完了,这条记录就算是真正的被删除掉了。这条已删除记录占用的存储空间也可以被重新利用了。画下来就是这样:\n对照着图我们还要注意一点,将被删除记录加入到垃圾链表时,实际上加入到链表的头节点处,会跟着修改PAGE_FREE属性的值。\n小贴士:页面的Page Header部分有一个PAGE_GARBAGE属性,该属性记录着当前页面中可重用存储空间占用的总字节数。每当有已删除记录被加入到垃圾链表后,都会把这个PAGE_GARBAGE属性的值加上该已删除记录占用的存储空间大小。PAGE_FREE指向垃圾链表的头节点,之后每当新插入记录时,首先判断PAGE_FREE指向的头节点代表的已删除记录占用的存储空间是否足够容纳这条新插入的记录,如果不可以容纳,就直接向页面中申请新的空间来存储这条记录(是的,你没看错,并不会尝试遍历整个垃圾链表,找到一个可以容纳新记录的节点)。如果可以容纳,那么直接重用这条已删除记录的存储空间,并且把PAGE_FREE指向垃圾链表中的下一条已删除记录。但是这里有一个问题,如果新插入的那条记录占用的存储空间大小小于垃圾链表的头节点占用的存储空间大小,那就意味头节点对应的记录占用的存储空间里有一部分空间用不到,这部分空间就被称之为碎片空间。那这些碎片空间岂不是永远都用不到了么?其实也不是,这些碎片空间占用的存储空间大小会被统计到PAGE_GARBAGE属性中,这些碎片空间在整个页面快使用完前并不会被重新利用,不过当页面快满时,如果再插入一条记录,此时页面中并不能分配一条完整记录的空间,这时候会首先看一看PAGE_GARBAGE的空间和剩余可利用的空间加起来是不是可以容纳下这条记录,如果可以的话,InnoDB会尝试重新组织页内的记录,重新组织的过程就是先开辟一个临时页面,把页面内的记录依次插入一遍,因为依次插入时并不会产生碎片,之后再把临时页面的内容复制到本页面,这样就可以把那些碎片空间都解放出来(很显然重新组织页面内的记录比较耗费性能)。\n从上面的描述中我们也可以看出来,在删除语句所在的事务提交之前,只会经历阶段一,也就是delete mark阶段(提交之后我们就不用回滚了,所以只需考虑对删除操作的阶段一做的影响进行回滚)。设计InnoDB的大佬为此设计了一种称之为TRX_UNDO_DEL_MARK_REC类型的undo日志,它的完整结构如下图所示:\n这个里边的属性也太多了点儿吧~(其实大部分属性的意思我们上面已经介绍过了) 是的,的确有点多,不过大家千万不要在意,如果记不住千万不要勉强自己,我这里把它们都列出来让大家混个脸熟而已。劳烦大家先克服一下密集恐急症,再抬头大致看一遍上面的这个类型为TRX_UNDO_DEL_MARK_REC的undo日志中的属性,特别注意一下这几点:\n在对一条记录进行delete mark操作前,需要把该记录的旧的trx_id和roll_pointer隐藏列的值都给记到对应的undo日志中来,就是我们图中显示的old trx_id和old roll_pointer属性。这样有一个好处,那就是可以通过undo日志的old roll_pointer找到记录在修改之前对应的undo日志。比方说在一个事务中,我们先插入了一条记录,然后又执行对该记录的删除操作,这个过程的示意图就是这样:\n从图中可以看出来,执行完delete mark操作后,它对应的undo日志和INSERT操作对应的undo日志就串成了一个链表。这个很有意思啊,这个链表就称之为版本链,现在貌似看不出这个版本链有什么用,等我们再往后看看,讲完UPDATE操作对应的undo日志后,这个所谓的版本链就慢慢的展现出它的牛逼之处了。\n与类型为TRX_UNDO_INSERT_REC的undo日志不同,类型为TRX_UNDO_DEL_MARK_REC的undo日志还多了一个索引列各列信息的内容,也就是说如果某个列被包含在某个索引中,那么它的相关信息就应该被记录到这个索引列各列信息部分,所谓的相关信息包括该列在记录中的位置(用pos表示),该列占用的存储空间大小(用len表示),该列实际值(用value表示)。所以索引列各列信息存储的内容实质上就是\u0026lt;pos, len, value\u0026gt;的一个列表。这部分信息主要是用在事务提交后,对该中间状态记录做真正删除的阶段二,也就是purge阶段中使用的,具体如何使用现在我们可以忽略~\n该介绍的我们介绍完了,现在继续在上面那个事务id为100的事务中删除一条记录,比如我们把id为1的那条记录删除掉: ``` BEGIN; # 显式开启一个事务,假设该事务的id为100\n插入两条记录\nINSERT INTO undo_demo(id, key1, col) VALUES (1, \u0026lsquo;AWM\u0026rsquo;, \u0026lsquo;狙击枪\u0026rsquo;), (2, \u0026lsquo;M416\u0026rsquo;, \u0026lsquo;步枪\u0026rsquo;);\n删除一条记录\nDELETE FROM undo_demo WHERE id = 1; ``` 这个delete mark操作对应的undo日志的结构就是这样:\n对照着这个图,我们得注意下面几点: - 因为这条undo日志是id为100的事务中产生的第3条undo日志,所以它对应的undo no就是2。 - 在对记录做delete mark操作时,记录的trx_id隐藏列的值是100(也就是说对该记录最近的一次修改就发生在本事务中),所以把100填入old trx_id属性中。然后把记录的roll_pointer隐藏列的值取出来,填入old roll_pointer属性中,这样就可以通过old roll_pointer属性值找到最近一次对该记录做改动时产生的undo日志。 - 由于undo_demo表中有2个索引:一个是聚簇索引,一个是二级索引idx_key1。只要是包含在索引中的列,那么这个列在记录中的位置(pos),占用存储空间大小(len)和实际值(value)就需要存储到undo日志中。\n- 对于主键来说,只包含一个`id`列,存储到`undo日志`中的相关信息分别是: - `pos`:`id`列是主键,也就是在记录的第一个列,它对应的`pos`值为`0`。`pos`占用1个字节来存储。 - `len`:`id`列的类型为`INT`,占用4个字节,所以`len`的值为`4`。`len`占用1个字节来存储。 - `value`:在被删除的记录中`id`列的值为`1`,也就是`value`的值为`1`。`value`占用4个字节来存储。 画一个图演示一下就是这样: ![][22-12] 所以对于`id`列来说,最终存储的结果就是`\u0026lt;0, 4, 1\u0026gt;`,存储这些信息占用的存储空间大小为`1 + 1 + 4 = 6`个字节。 - 对于`idx_key1`来说,只包含一个`key1`列,存储到`undo日志`中的相关信息分别是: - `pos`:`key1`列是排在`id`列、`trx_id`列、`roll_pointer`列之后的,它对应的`pos`值为`3`。`pos`占用1个字节来存储。 - `len`:`key1`列的类型为`VARCHAR(100)`,使用`utf8`字符集,被删除的记录实际存储的内容是`AWM`,所以一共占用3个字节,也就是所以`len`的值为`3`。`len`占用1个字节来存储。 - `value`:在被删除的记录中`key1`列的值为`AWM`,也就是`value`的值为`AWM`。`value`占用3个字节来存储。 画一个图演示一下就是这样: ![][22-13] 所以对于`key1`列来说,最终存储的结果就是`\u0026lt;3, 3, 'AWM'\u0026gt;`,存储这些信息占用的存储空间大小为`1 + 1 + 3 = 5`个字节。 从上面的叙述中可以看到,`\u0026lt;0, 4, 1\u0026gt;`和`\u0026lt;3, 3, 'AWM'\u0026gt;`共占用`11`个字节。然后`index_col_info len`本身占用`2`个字节,所以加起来一共占用`13`个字节,把数字`13`就填到了`index_col_info len`的属性中。 UPDATE操作对应的undo日志 # 在执行UPDATE语句时,InnoDB对更新主键和不更新主键这两种情况有截然不同的处理方案。\n不更新主键的情况 # 在不更新主键的情况下,又可以细分为被更新的列占用的存储空间不发生变化和发生变化的情况。\n就地更新(in-place update)\n更新记录时,对于被更新的每个列来说,如果更新后的列和更新前的列占用的存储空间都一样大,那么就可以进行就地更新,也就是直接在原记录的基础上修改对应列的值。再次强调一边,是每个列在更新前后占用的存储空间一样大,有任何一个被更新的列更新前比更新后占用的存储空间大,或者更新前比更新后占用的存储空间小都不能进行就地更新。比方说现在undo_demo表里还有一条id值为2的记录,它的各个列占用的大小如图所示(因为采用utf8字符集,所以'步枪'这两个字符占用6个字节):\n假如我们有这样的UPDATE语句: UPDATE undo_demo SET key1 = 'P92', col = '手枪' WHERE id = 2; 在这个UPDATE语句中,col列从步枪被更新为手枪,前后都占用6个字节,也就是占用的存储空间大小未改变;key1列从M416被更新为P92,也就是从4个字节被更新为3个字节,这就不满足就地更新需要的条件了,所以不能进行就地更新。但是如果UPDATE语句长这样:\nUPDATE undo_demo SET key1 = 'M249', col = '机枪' WHERE id = 2; 由于各个被更新的列在更新前后占用的存储空间是一样大的,所以这样的语句可以执行就地更新。\n先删除掉旧记录,再插入新记录\n在不更新主键的情况下,如果有任何一个被更新的列更新前和更新后占用的存储空间大小不一致,那么就需要先把这条旧的记录从聚簇索引页面中删除掉,然后再根据更新后列的值创建一条新的记录插入到页面中。\n请注意一下,我们这里所说的删除并不是delete mark操作,而是真正的删除掉,也就是把这条记录从正常记录链表中移除并加入到垃圾链表中,并且修改页面中相应的统计信息(比如PAGE_FREE、PAGE_GARBAGE等这些信息)。不过这里做真正删除操作的线程并不是在介绍DELETE语句中做purge操作时使用的另外专门的线程,而是由用户线程同步执行真正的删除操作,真正删除之后紧接着就要根据各个列更新后的值创建的新记录插入。\n这里如果新创建的记录占用的存储空间大小不超过旧记录占用的空间,那么可以直接重用被加入到垃圾链表中的旧记录所占用的存储空间,否则的话需要在页面中新申请一段空间以供新记录使用,如果本页面内已经没有可用的空间的话,那就需要进行页面分裂操作,然后再插入新记录。\n针对UPDATE不更新主键的情况(包括上面所说的就地更新和先删除旧记录再插入新记录),设计InnoDB的大佬们设计了一种类型为TRX_UNDO_UPD_EXIST_REC的undo日志,它的完整结构如下:\n其实大部分属性和我们介绍过的TRX_UNDO_DEL_MARK_REC类型的undo日志是类似的,不过还是要注意这么几点:\nn_updated属性表示本条UPDATE语句执行后将有几个列被更新,后边跟着的\u0026lt;pos, old_len, old_value\u0026gt;分别表示被更新列在记录中的位置、更新前该列占用的存储空间大小、更新前该列的真实值。\n如果在UPDATE语句中更新的列包含索引列,那么也会添加索引列各列信息这个部分,否则的话是不会添加这个部分的。\n现在继续在上面那个事务id为100的事务中更新一条记录,比如我们把id为2的那条记录更新一下: ``` BEGIN; # 显式开启一个事务,假设该事务的id为100\n插入两条记录\nINSERT INTO undo_demo(id, key1, col) VALUES (1, \u0026lsquo;AWM\u0026rsquo;, \u0026lsquo;狙击枪\u0026rsquo;), (2, \u0026lsquo;M416\u0026rsquo;, \u0026lsquo;步枪\u0026rsquo;);\n删除一条记录\nDELETE FROM undo_demo WHERE id = 1;\n更新一条记录\nUPDATE undo_demo SET key1 = \u0026lsquo;M249\u0026rsquo;, col = \u0026lsquo;机枪\u0026rsquo; WHERE id = 2; ``` 这个UPDATE语句更新的列大小都没有改动,所以可以采用就地更新的方式来执行,在真正改动页面记录时,会先记录一条类型为TRX_UNDO_UPD_EXIST_REC的undo日志,长这样:\n对照着这个图我们注意一下这几个地方: - 因为这条undo日志是id为100的事务中产生的第4条undo日志,所以它对应的undo no就是3。 - 这条日志的roll_pointer指向undo no为1的那条日志,也就是插入主键值为2的记录时产生的那条undo日志,也就是最近一次对该记录做改动时产生的undo日志。 - 由于本条UPDATE语句中更新了索引列key1的值,所以需要记录一下索引列各列信息部分,也就是把主键和key1列更新前的信息填入。\n更新主键的情况 # 在聚簇索引中,记录是按照主键值的大小连成了一个单向链表的,如果我们更新了某条记录的主键值,意味着这条记录在聚簇索引中的位置将会发生改变,比如你将记录的主键值从1更新为10000,如果还有非常多的记录的主键值分布在1 ~ 10000之间的话,那么这两条记录在聚簇索引中就有可能离得非常远,甚至中间隔了好多个页面。针对UPDATE语句中更新了记录主键值的这种情况,InnoDB在聚簇索引中分了两步处理:\n将旧记录进行delete mark操作\n高能注意:这里是delete mark操作!这里是delete mark操作!这里是delete mark操作!也就是说在UPDATE语句所在的事务提交前,对旧记录只做一个delete mark操作,在事务提交后才由专门的线程做purge操作,把它加入到垃圾链表中。这里一定要和我们上面所说的在不更新记录主键值时,先真正删除旧记录,再插入新记录的方式区分开!\n小贴士:之所以只对旧记录做delete mark操作,是因为别的事务同时也可能访问这条记录,如果把它真正的删除加入到垃圾链表后,别的事务就访问不到了。这个功能就是所谓的MVCC,我们后边的章节中会详细介绍什么是个MVCC。\n根据更新后各列的值创建一条新记录,并将其插入到聚簇索引中(需重新定位插入的位置)。\n由于更新后的记录主键值发生了改变,所以需要重新从聚簇索引中定位这条记录所在的位置,然后把它插进去。\n针对UPDATE语句更新记录主键值的这种情况,在对该记录进行delete mark操作前,会记录一条类型为TRX_UNDO_DEL_MARK_REC的undo日志;之后插入新记录时,会记录一条类型为TRX_UNDO_INSERT_REC的undo日志,也就是说每对一条记录的主键值做改动时,会记录2条undo日志。这些日志的格式我们上面都介绍过了,就不赘述了。\n小贴士:其实还有一种称为TRX_UNDO_UPD_DEL_REC的undo日志的类型我们没有介绍,主要是想避免引入过多的复杂度,如果大家对这种类型的undo日志的使用感兴趣的话,可以额外查一下别的资料。\n"},{"id":12,"href":"/zh/docs/technology/MySQL/MySQL%E6%98%AF%E6%80%8E%E6%A0%B7%E8%BF%90%E8%A1%8C%E7%9A%84/%E7%AC%AC21%E7%AB%A0_%E8%AF%B4%E8%BF%87%E7%9A%84%E8%AF%9D%E5%B0%B1%E4%B8%80%E5%AE%9A%E8%A6%81%E5%8A%9E%E5%88%B0-redo%E6%97%A5%E5%BF%97%E4%B8%8B/","title":"第21章_说过的话就一定要办到-redo日志(下)","section":"My Sql是怎样运行的","content":"第21章 说过的话就一定要办到-redo日志(下)\nredo日志文件 # redo日志刷盘时机 # 我们前面说mtr运行过程中产生的一组redo日志在mtr结束时会被复制到log buffer中,可是这些日志总在内存里呆着也不是个办法,在一些情况下它们会被刷新到磁盘里,比如:\nlog buffer空间不足时\nlog buffer的大小是有限的(通过系统变量innodb_log_buffer_size指定),如果不停的往这个有限大小的log buffer里塞入日志,很快它就会被填满。设计InnoDB的大佬认为如果当前写入log buffer的redo日志量已经占满了log buffer总容量的大约一半左右,就需要把这些日志刷新到磁盘上。\n事务提交时\n我们前面说过之所以使用redo日志主要是因为它占用的空间少,还是顺序写,在事务提交时可以不把修改过的Buffer Pool页面刷新到磁盘,但是为了保证持久性,必须要把修改这些页面对应的redo日志刷新到磁盘。\n后台线程不停的刷刷刷\n后台有一个线程,大约每秒都会刷新一次log buffer中的redo日志到磁盘。\n正常关闭服务器时\n做所谓的checkpoint时(我们现在没介绍过checkpoint的概念,稍后会仔细介绍,稍安勿躁) 其他的一些情况\u0026hellip; redo日志文件组 # MySQL的数据目录(使用SHOW VARIABLES LIKE 'datadir'查看)下默认有两个名为ib_logfile0和ib_logfile1的文件,log buffer中的日志默认情况下就是刷新到这两个磁盘文件中。如果我们对默认的redo日志文件不满意,可以通过下面几个启动参数来调节:\ninnodb_log_group_home_dir\n该参数指定了redo日志文件所在的目录,默认值就是当前的数据目录。\ninnodb_log_file_size\n该参数指定了每个redo日志文件的大小,在MySQL 5.7.21这个版本中的默认值为48MB,\ninnodb_log_files_in_group\n该参数指定redo日志文件的个数,默认值为2,最大值为100。\n从上面的描述中可以看到,磁盘上的redo日志文件不只一个,而是以一个日志文件组的形式出现的。这些文件以ib_logfile[数字](数字可以是0、1、2\u0026hellip;)的形式进行命名。在将redo日志写入日志文件组时,是从ib_logfile0开始写,如果ib_logfile0写满了,就接着ib_logfile1写,同理,ib_logfile1写满了就去写ib_logfile2,依此类推。如果写到最后一个文件该咋办?那就重新转到ib_logfile0继续写,所以整个过程如下图所示:\n总共的redo日志文件大小其实就是:innodb_log_file_size × innodb_log_files_in_group。 小贴士:如果采用循环使用的方式向redo日志文件组里写数据的话,那岂不是要追尾,也就是后写入的redo日志覆盖掉前面写的redo日志?当然可能了!所以设计InnoDB的大佬提出了checkpoint的概念,稍后我们重点介绍~\nredo日志文件格式 # 我们前面说过log buffer本质上是一片连续的内存空间,被划分成了若干个512字节大小的block。将log buffer中的redo日志刷新到磁盘的本质就是把block的镜像写入日志文件中,所以redo日志文件其实也是由若干个512字节大小的block组成。\nredo日志文件组中的每个文件大小都一样,格式也一样,都是由两部分组成:\n前2048个字节,也就是前4个block是用来存储一些管理信息的。 从第2048字节往后是用来存储log buffer中的block镜像的。 所以我们前面所说的循环使用redo日志文件,其实是从每个日志文件的第2048个字节开始算,画个示意图就是这样:\n普通block的格式我们在介绍log buffer的时候都说过了,就是log block header、log block body、log block trialer这三个部分,就不重复介绍了。这里需要介绍一下每个redo日志文件前2048个字节,也就是前4个特殊block的格式都是干嘛的,废话少说,先看图:\n从图中可以看出来,这4个block分别是:\nlog file header:描述该redo日志文件的一些整体属性,看一下它的结构:\n各个属性的具体释义如下: 属性名 长度(单位:字节) 描述 LOG_HEADER_FORMAT 4 redo日志的版本,在MySQL 5.7.21中该值永远为1 LOG_HEADER_PAD1 4 做字节填充用的,没什么实际意义,忽略~ LOG_HEADER_START_LSN 8 标记本redo日志文件开始的LSN值,也就是文件偏移量为2048字节初对应的LSN值(关于什么是LSN我们稍后再看,看不懂的先忽略)。 LOG_HEADER_CREATOR 32 一个字符串,标记本redo日志文件的创建者是谁。正常运行时该值为MySQL的版本号,比如:\u0026quot;MySQL 5.7.21\u0026quot;,使用mysqlbackup命令创建的redo日志文件的该值为\u0026quot;ibbackup\u0026quot;和创建时间。 LOG_BLOCK_CHECKSUM 4 本block的校验值,所有block都有,我们不关心 小贴士:设计InnoDB的大佬对redo日志的block格式做了很多次修改,如果你阅读的其他书籍中发现上述的属性和你阅读书籍中的属性有些出入,不要慌,正常现象,忘记以前的版本吧。另外,LSN值我们后边才会介绍,现在千万别纠结LSN是什么。\ncheckpoint1:记录关于checkpoint的一些属性,看一下它的结构:\n各个属性的具体释义如下: 属性名 长度(单位:字节) 描述 LOG_CHECKPOINT_NO 8 服务器做checkpoint的编号,每做一次checkpoint,该值就加1。 LOG_CHECKPOINT_LSN 8 服务器做checkpoint结束时对应的LSN值,系统奔溃恢复时将从该值开始。 LOG_CHECKPOINT_OFFSET 8 上个属性中的LSN值在redo日志文件组中的偏移量 LOG_CHECKPOINT_LOG_BUF_SIZE 8 服务器在做checkpoint操作时对应的log buffer的大小 LOG_BLOCK_CHECKSUM 4 本block的校验值,所有block都有,我们不关心 小贴士:现在看不懂上面这些关于checkpoint和LSN的属性的释义是很正常的,我就是想让大家对上面这些属性混个脸熟,后边我们后详细介绍的。\n第三个block未使用,忽略~\ncheckpoint2:结构和checkpoint1一样。\nLog Sequeue Number # 自系统开始运行,就不断的在修改页面,也就意味着会不断的生成redo日志。redo日志的量在不断的递增,就像人的年龄一样,自打出生起就不断递增,永远不可能缩减了。设计InnoDB的大佬为记录已经写入的redo日志量,设计了一个称之为Log Sequeue Number的全局变量,翻译过来就是:日志序列号,简称lsn。不过不像人一出生的年龄是0岁,设计InnoDB的大佬规定初始的lsn值为8704(也就是一条redo日志也没写入时,lsn的值为8704)。\n我们知道在向log buffer中写入redo日志时不是一条一条写入的,而是以一个mtr生成的一组redo日志为单位进行写入的。而且实际上是把日志内容写在了log block body处。但是在统计lsn的增长量时,是按照实际写入的日志量加上占用的log block header和log block trailer来计算的。我们来看一个例子:\n系统第一次启动后初始化log buffer时,buf_free(就是标记下一条redo日志应该写入到log buffer的位置的变量)就会指向第一个block的偏移量为12字节(log block header的大小)的地方,那么lsn值也会跟着增加12:\n如果某个mtr产生的一组redo日志占用的存储空间比较小,也就是待插入的block剩余空闲空间能容纳这个mtr提交的日志时,lsn增长的量就是该mtr生成的redo日志占用的字节数,就像这样:\n我们假设上图中mtr_1产生的redo日志量为200字节,那么lsn就要在8716的基础上增加200,变为8916。\n如果某个mtr产生的一组redo日志占用的存储空间比较大,也就是待插入的block剩余空闲空间不足以容纳这个mtr提交的日志时,lsn增长的量就是该mtr生成的redo日志占用的字节数加上额外占用的log block header和log block trailer的字节数,就像这样:\n我们假设上图中mtr_2产生的redo日志量为1000字节,为了将mtr_2产生的redo日志写入log buffer,我们不得不额外多分配两个block,所以lsn的值需要在8916的基础上增加1000 + 12×2 + 4 × 2 = 1032。\n小贴士:为什么初始的lsn值为8704呢?我也不太清楚,人家就这么规定的。其实你也可以规定你一生下来算1岁,只要保证随着时间的流逝,你的年龄不断增长就好了。 从上面的描述中可以看出来,每一组由mtr生成的redo日志都有一个唯一的LSN值与其对应,LSN值越小,说明redo日志产生的越早。\nflushed_to_disk_lsn # redo日志是首先写到log buffer中,之后才会被刷新到磁盘上的redo日志文件。所以设计InnoDB的大佬提出了一个称之为buf_next_to_write的全局变量,标记当前log buffer中已经有哪些日志被刷新到磁盘中了。画个图表示就是这样:\n我们前面说lsn是表示当前系统中写入的redo日志量,这包括了写到log buffer而没有刷新到磁盘的日志,相应的,设计InnoDB的大佬提出了一个表示刷新到磁盘中的redo日志量的全局变量,称之为flushed_to_disk_lsn。系统第一次启动时,该变量的值和初始的lsn值是相同的,都是8704。随着系统的运行,redo日志被不断写入log buffer,但是并不会立即刷新到磁盘,lsn的值就和flushed_to_disk_lsn的值拉开了差距。我们演示一下:\n系统第一次启动后,向log buffer中写入了mtr_1、mtr_2、mtr_3这三个mtr产生的redo日志,假设这三个mtr开始和结束时对应的lsn值分别是:\n+ `mtr_1`:8716 ~ 8916 + `mtr_2`:8916 ~ 9948 + `mtr_3`:9948 ~ 10000 此时的lsn已经增长到了10000,但是由于没有刷新操作,所以此时flushed_to_disk_lsn的值仍为8704,如图:\n随后进行将log buffer中的block刷新到redo日志文件的操作,假设将mtr_1和mtr_2的日志刷新到磁盘,那么flushed_to_disk_lsn就应该增长mtr_1和mtr_2写入的日志量,所以flushed_to_disk_lsn的值增长到了9948,如图:\n综上所述,当有新的redo日志写入到log buffer时,首先lsn的值会增长,但flushed_to_disk_lsn不变,随后随着不断有log buffer中的日志被刷新到磁盘上,flushed_to_disk_lsn的值也跟着增长。如果两者的值相同时,说明log buffer中的所有redo日志都已经刷新到磁盘中了。\n小贴士:应用程序向磁盘写入文件时其实是先写到操作系统的缓冲区中去,如果某个写入操作要等到操作系统确认已经写到磁盘时才返回,那需要调用一下操作系统提供的fsync函数。其实只有当系统执行了fsync函数后,flushed_to_disk_lsn的值才会跟着增长,当仅仅把log buffer中的日志写入到操作系统缓冲区却没有显式的刷新到磁盘时,另外的一个称之为write_lsn的值跟着增长。不过为了大家理解上的方便,我们在讲述时把flushed_to_disk_lsn和write_lsn的概念混淆了起来。\nlsn值和redo日志文件偏移量的对应关系 # 因为lsn的值是代表系统写入的redo日志量的一个总和,一个mtr中产生多少日志,lsn的值就增加多少(当然有时候要加上log block header和log block trailer的大小),这样mtr产生的日志写到磁盘中时,很容易计算某一个lsn值在redo日志文件组中的偏移量,如图:\n初始时的LSN值是8704,对应文件偏移量2048,之后每个mtr向磁盘中写入多少字节日志,lsn的值就增长多少。\nflush链表中的LSN # 我们知道一个mtr代表一次对底层页面的原子访问,在访问过程中可能会产生一组不可分割的redo日志,在mtr结束时,会把这一组redo日志写入到log buffer中。除此之外,在mtr结束时还有一件非常重要的事情要做,就是把在mtr执行过程中可能修改过的页面加入到Buffer Pool的flush链表。为了防止大家早已忘记flush链表是什么,我们再看一下图:\n当第一次修改某个缓存在Buffer Pool中的页面时,就会把这个页面对应的控制块插入到flush链表的头部,之后再修改该页面时由于它已经在flush链表中了,就不再次插入了。也就是说flush链表中的脏页是按照页面的第一次修改时间从大到小进行排序的。在这个过程中会在缓存页对应的控制块中记录两个关于页面何时修改的属性:\noldest_modification:如果某个页面被加载到Buffer Pool后进行第一次修改,那么就将修改该页面的mtr开始时对应的lsn值写入这个属性。\nnewest_modification:每修改一次页面,都会将修改该页面的mtr结束时对应的lsn值写入这个属性。也就是说该属性表示页面最近一次修改后对应的系统lsn值。\n我们接着上面介绍flushed_to_disk_lsn的例子看一下:\n假设mtr_1执行过程中修改了页a,那么在mtr_1执行结束时,就会将页a对应的控制块加入到flush链表的头部。并且将mtr_1开始时对应的lsn,也就是8716写入页a对应的控制块的oldest_modification属性中,把mtr_1结束时对应的lsn,也就是8916写入页a对应的控制块的newest_modification属性中。画个图表示一下(为了让图片美观一些,我们把oldest_modification缩写成了o_m,把newest_modification缩写成了n_m):\n接着假设mtr_2执行过程中又修改了页b和页c两个页面,那么在mtr_2执行结束时,就会将页b和页c对应的控制块都加入到flush链表的头部。并且将mtr_2开始时对应的lsn,也就是8916写入页b和页c对应的控制块的oldest_modification属性中,把mtr_2结束时对应的lsn,也就是9948写入页b和页c对应的控制块的newest_modification属性中。画个图表示一下:\n从图中可以看出来,每次新插入到flush链表中的节点都是被放在了头部,也就是说flush链表中前面的脏页修改的时间比较晚,后边的脏页修改时间比较早。\n接着假设mtr_3执行过程中修改了页b和页d,不过页b之前已经被修改过了,所以它对应的控制块已经被插入到了flush链表,所以在mtr_3执行结束时,只需要将页d对应的控制块都加入到flush链表的头部即可。所以需要将mtr_3开始时对应的lsn,也就是9948写入页d对应的控制块的oldest_modification属性中,把mtr_3结束时对应的lsn,也就是10000写入页d对应的控制块的newest_modification属性中。另外,由于页b在mtr_3执行过程中又发生了一次修改,所以需要更新页b对应的控制块中newest_modification的值为10000。画个图表示一下:\n总结一下上面说的,就是:flush链表中的脏页按照修改发生的时间顺序进行排序,也就是按照oldest_modification代表的LSN值进行排序,被多次更新的页面不会重复插入到flush链表中,但是会更新newest_modification属性的值。\ncheckpoint # 有一个很不幸的事实就是我们的redo日志文件组容量是有限的,我们不得不选择循环使用redo日志文件组中的文件,但是这会造成最后写的redo日志与最开始写的redo日志追尾,这时应该想到:redo日志只是为了系统奔溃后恢复脏页用的,如果对应的脏页已经刷新到了磁盘,也就是说即使现在系统奔溃,那么在重启后也用不着使用redo日志恢复该页面了,所以该redo日志也就没有存在的必要了,那么它占用的磁盘空间就可以被后续的redo日志所重用。也就是说:判断某些redo日志占用的磁盘空间是否可以覆盖的依据就是它对应的脏页是否已经刷新到磁盘里。我们看一下前面一直介绍的那个例子:\n如图,虽然mtr_1和mtr_2生成的redo日志都已经被写到了磁盘上,但是它们修改的脏页仍然留在Buffer Pool中,所以它们生成的redo日志在磁盘上的空间是不可以被覆盖的。之后随着系统的运行,如果页a被刷新到了磁盘,那么它对应的控制块就会从flush链表中移除,就像这样子:\n这样mtr_1生成的redo日志就没有用了,它们占用的磁盘空间就可以被覆盖掉了。设计InnoDB的大佬提出了一个全局变量checkpoint_lsn来代表当前系统中可以被覆盖的redo日志总量是多少,这个变量初始值也是8704。\n比方说现在页a被刷新到了磁盘,mtr_1生成的redo日志就可以被覆盖了,所以我们可以进行一个增加checkpoint_lsn的操作,我们把这个过程称之为做一次checkpoint。做一次checkpoint其实可以分为两个步骤:\n步骤一:计算一下当前系统中可以被覆盖的redo日志对应的lsn值最大是多少。\nredo日志可以被覆盖,意味着它对应的脏页被刷到了磁盘,只要我们计算出当前系统中被最早修改的脏页对应的oldest_modification值,那凡是在系统lsn值小于该节点的oldest_modification值时产生的redo日志都是可以被覆盖掉的,我们就把该脏页的oldest_modification赋值给checkpoint_lsn。\n比方说当前系统中页a已经被刷新到磁盘,那么flush链表的尾节点就是页c,该节点就是当前系统中最早修改的脏页了,它的oldest_modification值为8916,我们就把8916赋值给checkpoint_lsn(也就是说在redo日志对应的lsn值小于8916时就可以被覆盖掉)。\n步骤二:将checkpoint_lsn和对应的redo日志文件组偏移量以及此次checkpint的编号写到日志文件的管理信息(就是checkpoint1或者checkpoint2)中。\n设计InnoDB的大佬维护了一个目前系统做了多少次checkpoint的变量checkpoint_no,每做一次checkpoint,该变量的值就加1。我们前面说过计算一个lsn值对应的redo日志文件组偏移量是很容易的,所以可以计算得到该checkpoint_lsn在redo日志文件组中对应的偏移量checkpoint_offset,然后把这三个值都写到redo日志文件组的管理信息中。\n我们说过,每一个redo日志文件都有2048个字节的管理信息,但是上述关于checkpoint的信息只会被写到日志文件组的第一个日志文件的管理信息中。不过我们是存储到checkpoint1中还是checkpoint2中呢?设计InnoDB的大佬规定,当checkpoint_no的值是偶数时,就写到checkpoint1中,是奇数时,就写到checkpoint2中。\n记录完checkpoint的信息之后,redo日志文件组中各个lsn值的关系就像这样:\n批量从flush链表中刷出脏页 # 我们在介绍Buffer Pool的时候说过,一般情况下都是后台的线程在对LRU链表和flush链表进行刷脏操作,这主要因为刷脏操作比较慢,不想影响用户线程处理请求。但是如果当前系统修改页面的操作十分频繁,这样就导致写日志操作十分频繁,系统lsn值增长过快。如果后台的刷脏操作不能将脏页刷出,那么系统无法及时做checkpoint,可能就需要用户线程同步的从flush链表中把那些最早修改的脏页(oldest_modification最小的脏页)刷新到磁盘,这样这些脏页对应的redo日志就没用了,然后就可以去做checkpoint了。\n查看系统中的各种LSN值 # 我们可以使用SHOW ENGINE INNODB STATUS命令查看当前InnoDB存储引擎中的各种LSN值的情况,比如:\n(...省略前面的许多状态) LOG * * * Log sequence number 124476971 Log flushed up to 124099769 Pages flushed up to 124052503 Last checkpoint at 124052494 0 pending log flushes, 0 pending chkp writes 24 log i/o\u0026#39;s done, 2.00 log i/o\u0026#39;s/second * * * (...省略后边的许多状态) ``` 其中: -`Log sequence number`:代表系统中的`lsn`值,也就是当前系统已经写入的`redo`日志量,包括写入`log buffer`中的日志。 -`Log flushed up to`:代表`flushed_to_disk_lsn`的值,也就是当前系统已经写入磁盘的`redo`日志量。 -`Pages flushed up to`:代表`flush链表`中被最早修改的那个页面对应的`oldest_modification`属性值。 -`Last checkpoint at`:当前系统的`checkpoint_lsn`值。 # innodb_flush_log_at_trx_commit的用法 我们前面说为了保证事务的`持久性`,用户线程在事务提交时需要将该事务执行过程中产生的所有`redo`日志都刷新到磁盘上。这一条要求太狠了,会很明显的降低数据库性能。如果有的同学对事务的`持久性`要求不是那么强烈的话,可以选择修改一个称为`innodb_flush_log_at_trx_commit`的系统变量的值,该变量有3个可选的值: + `0`:当该系统变量值为0时,表示在事务提交时不立即向磁盘中同步`redo`日志,这个任务是交给后台线程做的。 这样很明显会加快请求处理速度,但是如果事务提交后服务器挂了,后台线程没有及时将`redo`日志刷新到磁盘,那么该事务对页面的修改会丢失。 + `1`:当该系统变量值为1时,表示在事务提交时需要将`redo`日志同步到磁盘,可以保证事务的`持久性`。`1`也是`innodb_flush_log_at_trx_commit`的默认值。 + `2`:当该系统变量值为2时,表示在事务提交时需要将`redo`日志写到操作系统的缓冲区中,但并不需要保证将日志真正的刷新到磁盘。 这种情况下如果数据库挂了,操作系统没挂的话,事务的`持久性`还是可以保证的,但是操作系统也挂了的话,那就不能保证`持久性`了。 # 崩溃恢复 在服务器不挂的情况下,`redo`日志简直就是个大累赘,不仅没用,反而让性能变得更差。但是万一,我说万一啊,万一数据库挂了,那`redo`日志可是个宝了,我们就可以在重启时根据`redo`日志中的记录就可以将页面恢复到系统奔溃前的状态。我们接下来大致看一下恢复过程是什么样。 ## 确定恢复的起点 我们前面说过,`checkpoint_lsn`之前的`redo`日志都可以被覆盖,也就是说这些`redo`日志对应的脏页都已经被刷新到磁盘中了,既然它们已经被刷盘,我们就没必要恢复它们了。对于`checkpoint_lsn`之后的`redo`日志,它们对应的脏页可能没被刷盘,也可能被刷盘了,我们不能确定,所以需要从`checkpoint_lsn`开始读取`redo`日志来恢复页面。 当然,`redo`日志文件组的第一个文件的管理信息中有两个block都存储了`checkpoint_lsn`的信息,我们当然是要选取最近发生的那次checkpoint的信息。衡量`checkpoint`发生时间早晚的信息就是所谓的`checkpoint_no`,我们只要把`checkpoint1`和`checkpoint2`这两个block中的`checkpoint_no`值读出来比一下大小,哪个的`checkpoint_no`值更大,说明哪个block存储的就是最近的一次`checkpoint`信息。这样我们就能拿到最近发生的`checkpoint`对应的`checkpoint_lsn`值以及它在`redo`日志文件组中的偏移量`checkpoint_offset`。 ## 确定恢复的终点 `redo`日志恢复的起点确定了,那终点是哪个呢?这个还得从block的结构说起。我们说在写`redo`日志的时候都是顺序写的,写满了一个block之后会再往下一个block中写: ![](img/21-20.png) 普通block的`log block header`部分有一个称之为`LOG_BLOCK_HDR_DATA_LEN`的属性,该属性值记录了当前block里使用了多少字节的空间。对于被填满的block来说,该值永远为`512`。如果该属性的值不为`512`,那么就是它了,它就是此次奔溃恢复中需要扫描的最后一个block。 ## 怎么恢复 确定了需要扫描哪些`redo`日志进行奔溃恢复之后,接下来就是怎么进行恢复了。假设现在的`redo`日志文件中有5条`redo`日志,如图: ![](img/21-21.png) 由于`redo 0`在`checkpoint_lsn`后边,恢复时可以不管它。我们现在可以按照`redo`日志的顺序依次扫描`checkpoint_lsn`之后的各条redo日志,按照日志中记载的内容将对应的页面恢复出来。这样没什么问题,不过设计`InnoDB`的大佬还是想了一些办法加快这个恢复的过程: + 使用哈希表 根据`redo`日志的`space ID`和`page number`属性计算出散列值,把`space ID`和`page number`相同的`redo`日志放到哈希表的同一个槽里,如果有多个`space ID`和`page number`都相同的`redo`日志,那么它们之间使用链表连接起来,按照生成的先后顺序链接起来的,如图所示: ![](img/21-22.png) 之后就可以遍历哈希表,因为对同一个页面进行修改的`redo`日志都放在了一个槽里,所以可以一次性将一个页面修复好(避免了很多读取页面的随机IO),这样可以加快恢复速度。另外需要注意一点的是,同一个页面的`redo`日志是按照生成时间顺序进行排序的,所以恢复的时候也是按照这个顺序进行恢复,如果不按照生成时间顺序进行排序的话,那么可能出现错误。比如原先的修改操作是先插入一条记录,再删除该条记录,如果恢复时不按照这个顺序来,就可能变成先删除一条记录,再插入一条记录,这显然是错误的。 + 跳过已经刷新到磁盘的页面 我们前面说过,`checkpoint_lsn`之前的`redo`日志对应的脏页确定都已经刷到磁盘了,但是`checkpoint_lsn`之后的`redo`日志我们不能确定是否已经刷到磁盘,主要是因为在最近做的一次`checkpoint`后,可能后台线程又不断的从`LRU链表`和`flush链表`中将一些脏页刷出`Buffer Pool`。这些在`checkpoint_lsn`之后的`redo`日志,如果它们对应的脏页在奔溃发生时已经刷新到磁盘,那在恢复时也就没有必要根据`redo`日志的内容修改该页面了。 那在恢复时怎么知道某个`redo`日志对应的脏页是否在奔溃发生时已经刷新到磁盘了呢?这还得从页面的结构说起,我们前面说过每个页面都有一个称之为`File Header`的部分,在`File Header`里有一个称之为`FIL_PAGE_LSN`的属性,该属性记载了最近一次修改页面时对应的`lsn`值(其实就是页面控制块中的`newest_modification`值)。如果在做了某次`checkpoint`之后有脏页被刷新到磁盘中,那么该页对应的`FIL_PAGE_LSN`代表的`lsn`值肯定大于`checkpoint_lsn`的值,凡是符合这种情况的页面就不需要重复执行lsn值小于`FIL_PAGE_LSN`的redo日志了,所以更进一步提升了奔溃恢复的速度。 # 遗漏的问题:LOG_BLOCK_HDR_NO是如何计算的 我们前面说过,对于实际存储`redo`日志的普通的`log block`来说,在`log block header`处有一个称之为`LOG_BLOCK_HDR_NO`的属性(忘记了的话回头再看看),我们说这个属性代表一个唯一的标号。这个属性是初次使用该block时分配的,跟当时的系统`lsn`值有关。使用下面的公式计算该block的`LOG_BLOCK_HDR_NO`值: `((lsn / 512) \u0026amp; 0x3FFFFFFFUL) + 1` 这个公式里的`0x3FFFFFFFUL`可能让大家有点困惑,其实它的二进制表示可能更亲切一点: ![](img/21-23.png) 从图中可以看出,`0x3FFFFFFFUL`对应的二进制数的前2位为0,后30位的值都为`1`。我们刚开始学计算机的时候就学过,一个二进制位与0做与运算(`\u0026amp;`)的结果肯定是0,一个二进制位与1做与运算(`\u0026amp;`)的结果就是原值。让一个数和`0x3FFFFFFFUL`做与运算的意思就是要将该值的前2个比特位的值置为0,这样该值就肯定小于或等于`0x3FFFFFFFUL`了。这也就说明了,不论lsn多大,`((lsn / 512) \u0026amp; 0x3FFFFFFFUL)`的值肯定在`0`\\\\~`0x3FFFFFFFUL`之间,再加1的话肯定在`1`\\\\~`0x40000000UL`之间。而`0x40000000UL`这个值大家应该很熟悉,这个值就代表着`1GB`。也就是说系统最多能产生不重复的`LOG_BLOCK_HDR_NO`值只有`1GB`个。设计InnoDB的大佬规定`redo`日志文件组中包含的所有文件大小总和不得超过512GB,一个block大小是512字节,也就是说redo日志文件组中包含的block块最多为1GB个,所以有1GB个不重复的编号值也就够用了。 另外,`LOG_BLOCK_HDR_NO`值的第一个比特位比较特殊,称之为`flush bit`,如果该值为1,代表着本block是在某次将`log buffer`中的block刷新到磁盘的操作中的第一个被刷入的block。 "},{"id":13,"href":"/zh/docs/technology/MySQL/MySQL%E6%98%AF%E6%80%8E%E6%A0%B7%E8%BF%90%E8%A1%8C%E7%9A%84/%E7%AC%AC20%E7%AB%A0_%E8%AF%B4%E8%BF%87%E7%9A%84%E8%AF%9D%E5%B0%B1%E4%B8%80%E5%AE%9A%E8%A6%81%E5%8A%9E%E5%88%B0-redo%E6%97%A5%E5%BF%97%E4%B8%8A/","title":"第20章_说过的话就一定要办到-redo日志(上)","section":"My Sql是怎样运行的","content":"第20章 说过的话就一定要办到-redo日志(上)\n事先说明 # 本文以及接下来的几篇文章将会频繁的使用到我们前面介绍的InnoDB记录行格式、页面格式、索引原理、表空间的组成等各种基础知识,如果大家对这些东西理解的不透彻,那么阅读下面的文字可能会有些吃力,为保证您的阅读体验,请确保自己已经掌握了我前面介绍的这些知识。\nredo日志是什么 # 我们知道InnoDB存储引擎是以页为单位来管理存储空间的,我们进行的增删改查操作其实本质上都是在访问页面(包括读页面、写页面、创建新页面等操作)。我们前面介绍Buffer Pool的时候说过,在真正访问页面之前,需要把在磁盘上的页缓存到内存中的Buffer Pool之后才可以访问。但是在介绍事务的时候又强调过一个称之为持久性的特性,就是说对于一个已经提交的事务,在事务提交后即使系统发生了崩溃,这个事务对数据库中所做的更改也不能丢失。但是如果我们只在内存的Buffer Pool中修改了页面,假设在事务提交后突然发生了某个故障,导致内存中的数据都失效了,那么这个已经提交了的事务对数据库中所做的更改也就跟着丢失了,这是我们所不能忍受的(想想ATM机已经提示狗哥转账成功,但之后由于服务器出现故障,重启之后猫爷发现自己没收到钱,猫爷就被砍死了)。那么如何保证这个持久性呢?一个很简单的做法就是在事务提交完成之前把该事务所修改的所有页面都刷新到磁盘,但是这个简单粗暴的做法有些问题:\n刷新一个完整的数据页太浪费了\n有时候我们仅仅修改了某个页面中的一个字节,但是我们知道在InnoDB中是以页为单位来进行磁盘IO的,也就是说我们在该事务提交时不得不将一个完整的页面从内存中刷新到磁盘,我们又知道一个页面默认是16KB大小,只修改一个字节就要刷新16KB的数据到磁盘上显然是太浪费了。\n随机IO刷起来比较慢\n一个事务可能包含很多语句,即使是一条语句也可能修改许多页面,倒霉催的是该事务修改的这些页面可能并不相邻,这就意味着在将某个事务修改的Buffer Pool中的页面刷新到磁盘时,需要进行很多的随机IO,随机IO比顺序IO要慢,尤其对于传统的机械硬盘来说。\n咋办呢?再次回到我们的初心:我们只是想让已经提交了的事务对数据库中数据所做的修改永久生效,即使后来系统崩溃,在重启后也能把这种修改恢复出来。所以我们其实没有必要在每次事务提交时就把该事务在内存中修改过的全部页面刷新到磁盘,只需要把修改了哪些东西记录一下就好,比方说某个事务将系统表空间中的第100号页面中偏移量为1000处的那个字节的值1改成2我们只需要记录一下:\n将第0号表空间的100号页面的偏移量为1000处的值更新为2。\n这样我们在事务提交时,把上述内容刷新到磁盘中,即使之后系统崩溃了,重启之后只要按照上述内容所记录的步骤重新更新一下数据页,那么该事务对数据库中所做的修改又可以被恢复出来,也就意味着满足持久性的要求。因为在系统奔溃重启时需要按照上述内容所记录的步骤重新更新数据页,所以上述内容也被称之为重做日志,英文名为redo log,我们也可以土洋结合,称之为redo日志。与在事务提交时将所有修改过的内存中的页面刷新到磁盘中相比,只将该事务执行过程中产生的redo日志刷新到磁盘的好处如下:\nredo日志占用的空间非常小\n存储表空间ID、页号、偏移量以及需要更新的值所需的存储空间是很小的,关于redo日志的格式我们稍后会详细介绍,现在只要知道一条redo日志占用的空间不是很大就好了。\nredo日志是顺序写入磁盘的\n在执行事务的过程中,每执行一条语句,就可能产生若干条redo日志,这些日志是按照产生的顺序写入磁盘的,也就是使用顺序IO。\nredo日志格式 # 通过上面的内容我们知道,redo日志本质上只是记录了一下事务对数据库做了哪些修改。 设计InnoDB的大佬们针对事务对数据库的不同修改场景定义了多种类型的redo日志,但是绝大部分类型的redo日志都有下面这种通用的结构:\n各个部分的详细释义如下:\ntype:该条redo日志的类型。\n在MySQL 5.7.21这个版本中,设计InnoDB的大佬一共为redo日志设计了53种不同的类型,稍后会详细介绍不同类型的redo日志。\nspace ID:表空间ID。\npage number:页号。 data:该条redo日志的具体内容。 简单的redo日志类型 # 我们前面介绍InnoDB的记录行格式的时候说过,如果我们没有为某个表显式的定义主键,并且表中也没有定义Unique键,那么InnoDB会自动的为表添加一个称之为row_id的隐藏列作为主键。为这个row_id隐藏列赋值的方式如下: - 服务器会在内存中维护一个全局变量,每当向某个包含隐藏的row_id列的表中插入一条记录时,就会把该变量的值当作新记录的row_id列的值,并且把该变量自增1。 - 每当这个变量的值为256的倍数时,就会将该变量的值刷新到系统表空间的页号为7的页面中一个称之为Max Row ID的属性处(我们前面介绍表空间结构时详细说过)。 - 当系统启动时,会将上面提到的Max Row ID属性加载到内存中,将该值加上256之后赋值给我们前面提到的全局变量(因为在上次关机时该全局变量的值可能大于Max Row ID属性值)。\n这个Max Row ID属性占用的存储空间是8个字节,当某个事务向某个包含row_id隐藏列的表插入一条记录,并且为该记录分配的row_id值为256的倍数时,就会向系统表空间页号为7的页面的相应偏移量处写入8个字节的值。但是我们要知道,这个写入实际上是在Buffer Pool中完成的,我们需要为这个页面的修改记录一条redo日志,以便在系统奔溃后能将已经提交的该事务对该页面所做的修改恢复出来。这种情况下对页面的修改是极其简单的,redo日志中只需要记录一下在某个页面的某个偏移量处修改了几个字节的值,具体被修改的内容是什么就好了,设计InnoDB的大佬把这种极其简单的redo日志称之为物理日志,并且根据在页面中写入数据的多少划分了几种不同的redo日志类型: - MLOG_1BYTE(type字段对应的十进制数字为1):表示在页面的某个偏移量处写入1个字节的redo日志类型。 - MLOG_2BYTE(type字段对应的十进制数字为2):表示在页面的某个偏移量处写入2个字节的redo日志类型。 - MLOG_4BYTE(type字段对应的十进制数字为4):表示在页面的某个偏移量处写入4个字节的redo日志类型。 - MLOG_8BYTE(type字段对应的十进制数字为8):表示在页面的某个偏移量处写入8个字节的redo日志类型。 - MLOG_WRITE_STRING(type字段对应的十进制数字为30):表示在页面的某个偏移量处写入一串数据。\n我们上面提到的Max Row ID属性实际占用8个字节的存储空间,所以在修改页面中的该属性时,会记录一条类型为MLOG_8BYTE的redo日志,MLOG_8BYTE的redo日志结构如下所示:\n其余MLOG_1BYTE、MLOG_2BYTE、MLOG_4BYTE类型的redo日志结构和MLOG_8BYTE的类似,只不过具体数据中包含对应个字节的数据罢了。MLOG_WRITE_STRING类型的redo日志表示写入一串数据,但是因为不能确定写入的具体数据占用多少字节,所以需要在日志结构中添加一个len字段:\n小贴士:只要将MLOG_WRITE_STRING类型的redo日志的len字段填充上1、2、4、8这些数字,就可以分别替代MLOG_1BYTE、MLOG_2BYTE、MLOG_4BYTE、MLOG_8BYTE这些类型的redo日志,为什么还要多此一举设计这么多类型呢?还不是因为省空间啊,能不写len字段就不写len字段,省一个字节算一个字节。\n复杂一些的redo日志类型 # 有时候执行一条语句会修改非常多的页面,包括系统数据页面和用户数据页面(用户数据指的就是聚簇索引和二级索引对应的B+树)。以一条INSERT语句为例,它除了要向B+树的页面中插入数据,也可能更新系统数据Max Row ID的值,不过对于我们用户来说,平时更关心的是语句对B+树所做更新:\n表中包含多少个索引,一条INSERT语句就可能更新多少棵B+树。 针对某一棵B+树来说,既可能更新叶子节点页面,也可能更新内节点页面,也可能创建新的页面(在该记录插入的叶子节点的剩余空间比较少,不足以存放该记录时,会进行页面的分裂,在内节点页面中添加目录项记录)。 在语句执行过程中,INSERT语句对所有页面的修改都得保存到redo日志中去。这句话说的比较轻巧,做起来可就比较麻烦了,比方说将记录插入到聚簇索引中时,如果定位到的叶子节点的剩余空间足够存储该记录时,那么只更新该叶子节点页面就好,那么只记录一条MLOG_WRITE_STRING类型的redo日志,表明在页面的某个偏移量处增加了哪些数据就好了么?那就too young too naive了~ 别忘了一个数据页中除了存储实际的记录之后,还有什么File Header、Page Header、Page Directory等等部分(在介绍数据页的章节有详细讲解),所以每往叶子节点代表的数据页里插入一条记录时,还有其他很多地方会跟着更新,比如说:\n可能更新Page Directory中的槽信息。 Page Header中的各种页面统计信息,比如PAGE_N_DIR_SLOTS表示的槽数量可能会更改,PAGE_HEAP_TOP代表的还未使用的空间最小地址可能会更改,PAGE_N_HEAP代表的本页面中的记录数量可能会更改,等等,各种信息都可能会被修改。 我们知道在数据页里的记录是按照索引列从小到大的顺序组成一个单向链表的,每插入一条记录,还需要更新上一条记录的记录头信息中的next_record属性来维护这个单向链表。 还有别的等等的更新的地方,就不一一介绍了\u0026hellip; 画一个简易的示意图就像是这样:\n说了这么多,就是想表达:把一条记录插入到一个页面时需要更改的地方非常多。这时我们如果使用上面介绍的简单的物理redo日志来记录这些修改时,可以有两种解决方案:\n方案一:在每个修改的地方都记录一条redo日志。\n也就是如上图所示,有多少个加粗的块,就写多少条物理redo日志。这样子记录redo日志的缺点是显而易见的,因为被修改的地方是在太多了,可能记录的redo日志占用的空间都比整个页面占用的空间都多了~\n方案二:将整个页面的第一个被修改的字节到最后一个修改的字节之间所有的数据当成是一条物理redo日志中的具体数据。\n从图中也可以看出来,第一个被修改的字节到最后一个修改的字节之间仍然有许多没有修改过的数据,我们把这些没有修改的数据也加入到redo日志中去岂不是太浪费了~\n正因为上述两种使用物理redo日志的方式来记录某个页面中做了哪些修改比较浪费,设计InnoDB的大佬本着勤俭节约的初心,提出了一些新的redo日志类型,比如:\nMLOG_REC_INSERT(对应的十进制数字为9):表示插入一条使用非紧凑行格式的记录时的redo日志类型。 MLOG_COMP_REC_INSERT(对应的十进制数字为38):表示插入一条使用紧凑行格式的记录时的redo日志类型。 小贴士:Redundant是一种比较原始的行格式,它就是非紧凑的。而Compact、Dynamic以及Compressed行格式是较新的行格式,它们是紧凑的(占用更小的存储空间)。\nMLOG_COMP_PAGE_CREATE(type字段对应的十进制数字为58):表示创建一个存储紧凑行格式记录的页面的redo日志类型。 MLOG_COMP_REC_DELETE(type字段对应的十进制数字为42):表示删除一条使用紧凑行格式记录的redo日志类型。 MLOG_COMP_LIST_START_DELETE(type字段对应的十进制数字为44):表示从某条给定记录开始删除页面中的一系列使用紧凑行格式记录的redo日志类型。 MLOG_COMP_LIST_END_DELETE(type字段对应的十进制数字为43):与MLOG_COMP_LIST_START_DELETE类型的redo日志呼应,表示删除一系列记录直到MLOG_COMP_LIST_END_DELETE类型的redo日志对应的记录为止。 小贴士:我们前面介绍InnoDB数据页格式的时候重点强调过,数据页中的记录是按照索引列大小的顺序组成单向链表的。有时候我们会有删除索引列的值在某个区间范围内的所有记录的需求,这时候如果我们每删除一条记录就写一条redo日志的话,效率可能有点低,所以提出MLOG_COMP_LIST_START_DELETE和MLOG_COMP_LIST_END_DELETE类型的redo日志,可以很大程度上减少redo日志的条数。\nMLOG_ZIP_PAGE_COMPRESS(type字段对应的十进制数字为51):表示压缩一个数据页的redo日志类型。 ······还有很多很多种类型,这就不列举了,等用到再说~ 这些类型的redo日志既包含物理层面的意思,也包含逻辑层面的意思,具体指: - 物理层面看,这些日志都指明了对哪个表空间的哪个页进行了修改。 - 逻辑层面看,在系统奔溃重启时,并不能直接根据这些日志里的记载,将页面内的某个偏移量处恢复成某个数据,而是需要调用一些事先准备好的函数,执行完这些函数后才可以将页面恢复成系统奔溃前的样子。\n大家看到这可能有些懵逼,我们还是以类型为MLOG_COMP_REC_INSERT这个代表插入一条使用紧凑行格式的记录时的redo日志为例来理解一下我们上面所说的物理层面和逻辑层面到底是什么意思。废话少说,直接看一下这个类型为MLOG_COMP_REC_INSERT的redo日志的结构(由于字段太多了,我们把它们竖着看效果好些):\n这个类型为MLOG_COMP_REC_INSERT的redo日志结构有几个地方需要大家注意: - 我们前面在介绍索引的时候说过,在一个数据页里,不论是叶子节点还是非叶子节点,记录都是按照索引列从小到大的顺序排序的。对于二级索引来说,当索引列的值相同时,记录还需要按照主键值进行排序。图中n_uniques的值的含义是在一条记录中,需要几个字段的值才能确保记录的唯一性,这样当插入一条记录时就可以按照记录的前n_uniques个字段进行排序。对于聚簇索引来说,n_uniques的值为主键的列数,对于其他二级索引来说,该值为索引列数+主键列数。这里需要注意的是,唯一二级索引的值可能为NULL,所以该值仍然为索引列数+主键列数。 - field1_len ~ fieldn_len代表着该记录若干个字段占用存储空间的大小,需要注意的是,这里不管该字段的类型是固定长度大小的(比如INT),还是可变长度大小(比如VARCHAR(M))的,该字段占用的大小始终要写入redo日志中。 - offset代表的是该记录的前一条记录在页面中的地址。为什么要记录前一条记录的地址呢?这是因为每向数据页插入一条记录,都需要修改该页面中维护的记录链表,每条记录的记录头信息中都包含一个称为next_record的属性,所以在插入新记录时,需要修改前一条记录的next_record属性。 - 我们知道一条记录其实由额外信息和真实数据这两部分组成,这两个部分的总大小就是一条记录占用存储空间的总大小。通过end_seg_len的值可以间接的计算出一条记录占用存储空间的总大小,为什么不直接存储一条记录占用存储空间的总大小呢?这是因为写redo日志是一个非常频繁的操作,设计InnoDB的大佬想方设法想减小redo日志本身占用的存储空间大小,所以想了一些弯弯绕的算法来实现这个目标,end_seg_len这个字段就是为了节省redo日志存储空间而提出来的。至于具体设计InnoDB的大佬到底是用了什么神奇魔法减小redo日志大小的,我们这就不多介绍了,因为的确有那么一丢丢小复杂,说清楚还是有一点点麻烦的,而且说明白了也没什么用。 - mismatch_index的值也是为了节省redo日志的大小而设立的,大家可以忽略。\n很显然这个类型为MLOG_COMP_REC_INSERT的redo日志并没有记录PAGE_N_DIR_SLOTS的值修改为了什么,PAGE_HEAP_TOP的值修改为了什么,PAGE_N_HEAP的值修改为了什么等等这些信息,而只是把在本页面中插入一条记录所有必备的要素记了下来,之后系统奔溃重启时,服务器会调用相关向某个页面插入一条记录的那个函数,而redo日志中的那些数据就可以被当成是调用这个函数所需的参数,在调用完该函数后,页面中的PAGE_N_DIR_SLOTS、PAGE_HEAP_TOP、PAGE_N_HEAP等等的值也就都被恢复到系统奔溃前的样子了。这就是所谓的逻辑日志的意思。\nredo日志格式小结 # 虽然上面说了一大堆关于redo日志格式的内容,但是如果你不是为了写一个解析redo日志的工具或者自己开发一套redo日志系统的话,那就没必要把InnoDB中的各种类型的redo日志格式都研究的透透的,没那个必要。上面我只是象征性的介绍了几种类型的redo日志格式,目的还是想让大家明白:redo日志会把事务在执行过程中对数据库所做的所有修改都记录下来,在之后系统奔溃重启后可以把事务所做的任何修改都恢复出来。\n小贴士:为了节省redo日志占用的存储空间大小,设计InnoDB的大佬对redo日志中的某些数据还可能进行压缩处理,比方说spacd ID和page number一般占用4个字节来存储,但是经过压缩后,可能使用更小的空间来存储。具体压缩算法就不介绍了。\nMini-Transaction # 以组的形式写入redo日志 # 语句在执行过程中可能修改若干个页面。比如我们前面说的一条INSERT语句可能修改系统表空间页号为7的页面的Max Row ID属性(当然也可能更新别的系统页面,只不过我们没有都列举出来而已),还会更新聚簇索引和二级索引对应B+树中的页面。由于对这些页面的更改都发生在Buffer Pool中,所以在修改完页面之后,需要记录一下相应的redo日志。在执行语句的过程中产生的redo日志被设计InnoDB的大佬人为的划分成了若干个不可分割的组,比如: - 更新Max Row ID属性时产生的redo日志是不可分割的。 - 向聚簇索引对应B+树的页面中插入一条记录时产生的redo日志是不可分割的。 - 向某个二级索引对应B+树的页面中插入一条记录时产生的redo日志是不可分割的。 - 还有其他的一些对页面的访问操作时产生的redo日志是不可分割的。。。\n怎么理解这个不可分割的意思呢?我们以向某个索引对应的B+树插入一条记录为例,在向B+树中插入这条记录之前,需要先定位到这条记录应该被插入到哪个叶子节点代表的数据页中,定位到具体的数据页之后,有两种可能的情况:\n情况一:该数据页的剩余的空闲空间充足,足够容纳这一条待插入记录,那么事情很简单,直接把记录插入到这个数据页中,记录一条类型为MLOG_COMP_REC_INSERT的redo日志就好了,我们把这种情况称之为乐观插入。假如某个索引对应的B+树长这样:\n现在我们要插入一条键值为10的记录,很显然需要被插入到页b中,由于页b现在有足够的空间容纳一条记录,所以直接将该记录插入到页b中就好了,就像这样:\n情况二:该数据页剩余的空闲空间不足,那么事情就悲剧了,我们前面说过,遇到这种情况要进行所谓的页分裂操作,也就是新建一个叶子节点,然后把原先数据页中的一部分记录复制到这个新的数据页中,然后再把记录插入进去,把这个叶子节点插入到叶子节点链表中,最后还要在内节点中添加一条目录项记录指向这个新创建的页面。很显然,这个过程要对多个页面进行修改,也就意味着会产生多条redo日志,我们把这种情况称之为悲观插入。假如某个索引对应的B+树长这样:\n现在我们要插入一条键值为10的记录,很显然需要被插入到页b中,但是从图中也可以看出来,此时页b已经塞满了记录,没有更多的空闲空间来容纳这条新记录了,所以我们需要进行页面的分裂操作,就像这样:\n如果作为内节点的页a的剩余空闲空间也不足以容纳增加一条目录项记录,那需要继续做内节点页a的分裂操作,也就意味着会修改更多的页面,从而产生更多的redo日志。另外,对于悲观插入来说,由于需要新申请数据页,还需要改动一些系统页面,比方说要修改各种段、区的统计信息信息,各种链表的统计信息(比如什么FREE链表、FSP_FREE_FRAG链表等等我们在介绍表空间那一章中介绍过的各种东东)等等等等,反正总共需要记录的redo日志有二、三十条。\n小贴士:其实不光是悲观插入一条记录会生成许多条redo日志,设计InnoDB的大佬为了其他的一些功能,在乐观插入时也可能产生多条redo日志(具体是为了什么功能我们就不多说了,要不篇幅就受不了了~)。 设计InnoDB的大佬们认为向某个索引对应的B+树中插入一条记录的这个过程必须是原子的,不能说插了一半之后就停止了。比方说在悲观插入过程中,新的页面已经分配好了,数据也复制过去了,新的记录也插入到页面中了,可是没有向内节点中插入一条目录项记录,这个插入过程就是不完整的,这样会形成一棵不正确的B+树。我们知道redo日志是为了在系统奔溃重启时恢复崩溃前的状态,如果在悲观插入的过程中只记录了一部分redo日志,那么在系统奔溃重启时会将索引对应的B+树恢复成一种不正确的状态,这是设计InnoDB的大佬们所不能忍受的。所以他们规定在执行这些需要保证原子性的操作时必须以组的形式来记录的redo日志,在进行系统奔溃重启恢复时,针对某个组中的redo日志,要么把全部的日志都恢复掉,要么一条也不恢复。怎么做到的呢?这得分情况讨论:\n有的需要保证原子性的操作会生成多条redo日志,比如向某个索引对应的B+树中进行一次悲观插入就需要生成许多条redo日志。\n如何把这些redo日志划分到一个组里边儿呢?设计InnoDB的大佬做了一个很简单的小把戏,就是在该组中的最后一条redo日志后边加上一条特殊类型的redo日志,该类型名称为MLOG_MULTI_REC_END,type字段对应的十进制数字为31,该类型的redo日志结构很简单,只有一个type字段:\n所以某个需要保证原子性的操作产生的一系列redo日志必须要以一个类型为MLOG_MULTI_REC_END结尾,就像这样:\n这样在系统奔溃重启进行恢复时,只有当解析到类型为MLOG_MULTI_REC_END的redo日志,才认为解析到了一组完整的redo日志,才会进行恢复。否则的话直接放弃前面解析到的redo日志。\n有的需要保证原子性的操作只生成一条redo日志,比如更新Max Row ID属性的操作就只会生成一条redo日志。\n其实在一条日志后边跟一个类型为MLOG_MULTI_REC_END的redo日志也是可以的,不过设计InnoDB的大佬比较勤俭节约,他们不想浪费一个比特位。别忘了虽然redo日志的类型比较多,但撑死了也就是几十种,是小于127这个数字的,也就是说我们用7个比特位就足以包括所有的redo日志类型,而type字段其实是占用1个字节的,也就是说我们可以省出来一个比特位用来表示该需要保证原子性的操作只产生单一的一条redo日志,示意图如下:\n如果type字段的第一个比特位为1,代表该需要保证原子性的操作只产生了单一的一条redo日志,否则表示该需要保证原子性的操作产生了一系列的redo日志。\nMini-Transaction的概念 # 设计MySQL的大佬把对底层页面中的一次原子访问的过程称之为一个Mini-Transaction,简称mtr,比如上面所说的修改一次Max Row ID的值算是一个Mini-Transaction,向某个索引对应的B+树中插入一条记录的过程也算是一个Mini-Transaction。通过上面的叙述我们也知道,一个所谓的mtr可以包含一组redo日志,在进行奔溃恢复时这一组redo日志作为一个不可分割的整体。\n一个事务可以包含若干条语句,每一条语句其实是由若干个mtr组成,每一个mtr又可以包含若干条redo日志,画个图表示它们的关系就是这样:\nredo日志的写入过程 # redo log block # 设计InnoDB的大佬为了更好的进行系统奔溃恢复,他们把通过mtr生成的redo日志都放在了大小为512字节的页中。为了和我们前面提到的表空间中的页做区别,我们这里把用来存储redo日志的页称为block(你心里清楚页和block的意思其实差不多就行了)。一个redo log block的示意图如下:\n真正的redo日志都是存储到占用496字节大小的log block body中,图中的log block header和log block trailer存储的是一些管理信息。我们来看看这些所谓的管理信息都是什么:\n其中log block header的几个属性的意思分别如下: - LOG_BLOCK_HDR_NO:每一个block都有一个大于0的唯一标号,本属性就表示该标号值。 - LOG_BLOCK_HDR_DATA_LEN:表示block中已经使用了多少字节,初始值为12(因为log block body从第12个字节处开始)。随着往block中写入的redo日志越来也多,本属性值也跟着增长。如果log block body已经被全部写满,那么本属性的值被设置为512。 - LOG_BLOCK_FIRST_REC_GROUP:一条redo日志也可以称之为一条redo日志记录(redo log record),一个mtr会生产多条redo日志记录,这些redo日志记录被称之为一个redo日志记录组(redo log record group)。LOG_BLOCK_FIRST_REC_GROUP就代表该block中第一个mtr生成的redo日志记录组的偏移量(其实也就是这个block里第一个mtr生成的第一条redo日志的偏移量)。 - LOG_BLOCK_CHECKPOINT_NO:表示所谓的checkpoint的序号,checkpoint是我们后续内容的重点,现在先不用清楚它的意思,稍安勿躁。\nlog block trailer中属性的意思如下:\nLOG_BLOCK_CHECKSUM:表示block的校验值,用于正确性校验,我们暂时不关心它。 redo日志缓冲区 # 我们前面说过,设计InnoDB的大佬为了解决磁盘速度过慢的问题而引入了Buffer Pool。同理,写入redo日志时也不能直接直接写到磁盘上,实际上在服务器启动时就向操作系统申请了一大片称之为redo log buffer的连续内存空间,翻译成中文就是redo日志缓冲区,我们也可以简称为log buffer。这片内存空间被划分成若干个连续的redo log block,就像这样:\n我们可以通过启动参数innodb_log_buffer_size来指定log buffer的大小,在MySQL 5.7.21这个版本中,该启动参数的默认值为16MB。\nredo日志写入log buffer # 向log buffer中写入redo日志的过程是顺序的,也就是先往前面的block中写,当该block的空闲空间用完之后再往下一个block中写。当我们想往log buffer中写入redo日志时,第一个遇到的问题就是应该写在哪个block的哪个偏移量处,所以设计InnoDB的大佬特意提供了一个称之为buf_free的全局变量,该变量指明后续写入的redo日志应该写入到log buffer中的哪个位置,如图所示:\n我们前面说过一个mtr执行过程中可能产生若干条redo日志,这些redo日志是一个不可分割的组,所以其实并不是每生成一条redo日志,就将其插入到log buffer中,而是每个mtr运行过程中产生的日志先暂时存到一个地方,当该mtr结束的时候,将过程中产生的一组redo日志再全部复制到log buffer中。我们现在假设有两个名为T1、T2的事务,每个事务都包含2个mtr,我们给这几个mtr命名一下: - 事务T1的两个mtr分别称为mtr_T1_1和mtr_T1_2。 - 事务T2的两个mtr分别称为mtr_T2_1和mtr_T2_2。\n每个mtr都会产生一组redo日志,用示意图来描述一下这些mtr产生的日志情况:\n不同的事务可能是并发执行的,所以T1、T2之间的mtr可能是交替执行的。每当一个mtr执行完成时,伴随该mtr生成的一组redo日志就需要被复制到log buffer中,也就是说不同事务的mtr可能是交替写入log buffer的,我们画个示意图(为了美观,我们把一个mtr中产生的所有的redo日志当作一个整体来画):\n从示意图中我们可以看出来,不同的mtr产生的一组redo日志占用的存储空间可能不一样,有的mtr产生的redo日志量很少,比如mtr_t1_1、mtr_t2_1就被放到同一个block中存储,有的mtr产生的redo日志量非常大,比如mtr_t1_2产生的redo日志甚至占用了3个block来存储。 小贴士:对照着上图,自己分析一下每个block的LOG_BLOCK_HDR_DATA_LEN、LOG_BLOCK_FIRST_REC_GROUP属性值都是什么~\n"},{"id":14,"href":"/zh/docs/technology/MySQL/MySQL%E6%98%AF%E6%80%8E%E6%A0%B7%E8%BF%90%E8%A1%8C%E7%9A%84/%E7%AC%AC19%E7%AB%A0_%E4%BB%8E%E7%8C%AB%E7%88%B7%E8%A2%AB%E6%9D%80%E8%AF%B4%E8%B5%B7-%E4%BA%8B%E5%8A%A1%E7%AE%80%E4%BB%8B/","title":"第19章_从猫爷被杀说起-事务简介","section":"My Sql是怎样运行的","content":"第19章 从猫爷被杀说起-事务简介\n事务的起源 # 对于大部分程序员来说,他们的任务就是把现实世界的业务场景映射到数据库世界。比如银行为了存储人们的账户信息会建立一个account表: CREATE TABLE account ( id INT NOT NULL AUTO_INCREMENT COMMENT '自增id', name VARCHAR(100) COMMENT '客户名称', balance INT COMMENT '余额', PRIMARY KEY (id) ) Engine=InnoDB CHARSET=utf8; 狗哥和猫爷是一对好基友,他们都到银行开一个账户,他们在现实世界中拥有的资产就会体现在数据库世界的account表中。比如现在狗哥有11元,猫爷只有2元,那么现实中的这个情况映射到数据库的account表就是这样: +----+--------+---------+ | id | name | balance | +----+--------+---------+ | 1 | 狗哥 | 11 | | 2 | 猫爷 | 2 | +----+--------+---------+ 在某个特定的时刻,狗哥猫爷这些家伙在银行所拥有的资产是一个特定的值,这些特定的值也可以被描述为账户在这个特定的时刻现实世界的一个状态。随着时间的流逝,狗哥和猫爷可能陆续进行向账户中存钱、取钱或者向别人转账等操作,这样他们账户中的余额就可能发生变动,每一个操作都相当于现实世界中账户的一次状态转换。数据库世界作为现实世界的一个映射,自然也要进行相应的变动。不变不知道,一变吓一跳,现实世界中一些看似很简单的状态转换,映射到数据库世界却不是那么容易的。比方说有一次猫爷在赌场赌博输了钱,急忙打电话给狗哥要借10块钱,不然那些看场子的就会把自己剁了。现实世界中的狗哥走向了ATM机,输入了猫爷的账号以及10元的转账金额,然后按下确认,狗哥就拔卡走人了。对于数据库世界来说,相当于执行了下面这两条语句:\nUPDATE account SET balance = balance - 10 WHERE id = 1; UPDATE account SET balance = balance + 10 WHERE id = 2;\n但是这里头有个问题,上述两条语句只执行了一条时忽然服务器断电了咋办?把狗哥的钱扣了,但是没给猫爷转过去,那猫爷还是逃脱不了被砍死的噩运~ 即使对于单独的一条语句,我们前面介绍Buffer Pool时也说过,在对某个页面进行读写访问时,都会先把这个页面加载到Buffer Pool中,之后如果修改了某个页面,也不会立即把修改同步到磁盘,而只是把这个修改了的页面加到Buffer Pool的flush链表中,在之后的某个时间点才会刷新到磁盘。如果在将修改过的页刷新到磁盘之前系统崩溃了那岂不是猫爷还是要被砍死?或者在刷新磁盘的过程中(只刷新部分数据到磁盘上)系统奔溃了猫爷也会被砍死?\n怎么才能保证让可怜的猫爷不被砍死呢?其实再仔细想想,我们只是想让某些数据库操作符合现实世界中状态转换的规则而已,设计数据库的大佬们仔细盘算了盘算,现实世界中状态转换的规则有好几条,待我们慢慢道来。\n原子性(Atomicity) # 现实世界中转账操作是一个不可分割的操作,也就是说要么压根儿就没转,要么转账成功,不能存在中间的状态,也就是转了一半的这种情况。设计数据库的大佬们把这种要么全做,要么全不做的规则称之为原子性。但是在现实世界中的一个不可分割的操作却可能对应着数据库世界若干条不同的操作,数据库中的一条操作也可能被分解成若干个步骤(比如先修改缓存页,之后再刷新到磁盘等),最要命的是在任何一个可能的时间都可能发生意想不到的错误(可能是数据库本身的错误,或者是操作系统错误,甚至是直接断电之类的)而使操作执行不下去,所以猫爷可能会被砍死。为了保证在数据库世界中某些操作的原子性,设计数据库的大佬需要费一些心机来保证如果在执行操作的过程中发生了错误,把已经做了的操作恢复成没执行之前的样子,这也是我们后边章节要仔细介绍的内容。\n隔离性(Isolation) # 现实世界中的两次状态转换应该是互不影响的,比如说狗哥向猫爷同时进行的两次金额为5元的转账(假设可以在两个ATM机上同时操作)。那么最后狗哥的账户里肯定会少10元,猫爷的账户里肯定多了10元。但是到对应的数据库世界中,事情又变的复杂了一些。为了简化问题,我们粗略的假设狗哥向猫爷转账5元的过程是由下面几个步骤组成的:\n步骤一:读取狗哥账户的余额到变量A中,这一步骤简写为read(A)。 步骤二:将狗哥账户的余额减去转账金额,这一步骤简写为A = A - 5。 步骤三:将狗哥账户修改过的余额写到磁盘里,这一步骤简写为write(A)。 步骤四:读取猫爷账户的余额到变量B,这一步骤简写为read(B)。 步骤五:将猫爷账户的余额加上转账金额,这一步骤简写为B = B + 5。 步骤六:将猫爷账户修改过的余额写到磁盘里,这一步骤简写为write(B)。 我们将狗哥向猫爷同时进行的两次转账操作分别称为T1和T2,在现实世界中T1和T2是应该没有关系的,可以先执行完T1,再执行T2,或者先执行完T2,再执行T1,对应的数据库操作就像这样:\n但是很不幸,真实的数据库中T1和T2的操作可能交替执行,比如这样:\n如果按照上图中的执行顺序来进行两次转账的话,最终狗哥的账户里还剩6元钱,相当于只扣了5元钱,但是猫爷的账户里却成了12元钱,相当于多了10元钱,这银行岂不是要亏死了?\n所以对于现实世界中状态转换对应的某些数据库操作来说,不仅要保证这些操作以原子性的方式执行完成,而且要保证其它的状态转换不会影响到本次状态转换,这个规则被称之为隔离性。这时设计数据库的大佬们就需要采取一些措施来让访问相同数据(上例中的A账户和B账户)的不同状态转换(上例中的T1和T2)对应的数据库操作的执行顺序有一定规律,这也是我们后边章节要仔细介绍的内容。\n一致性(Consistency) # 我们生活的这个世界存在着形形色色的约束,比如身份证号不能重复,性别只能是男或者女,高考的分数只能在0~750之间,人民币面值最大只能是100(现在是2019年),红绿灯只有3种颜色,房价不能为负的,学生要听老师话,等等有点儿扯远了~ 只有符合这些约束的数据才是有效的,比如有个小孩儿跟你说他高考考了1000分,你一听就知道他胡扯呢。数据库世界只是现实世界的一个映射,现实世界中存在的约束当然也要在数据库世界中有所体现。如果数据库中的数据全部符合现实世界中的约束(all defined rules),我们说这些数据就是一致的,或者说符合一致性的。\n如何保证数据库中数据的一致性(就是符合所有现实世界的约束)呢?这其实靠两方面的努力:\n数据库本身能为我们保证一部分一致性需求(就是数据库自身可以保证一部分现实世界的约束永远有效)。\n我们知道MySQL数据库可以为表建立主键、唯一索引、外键、声明某个列为NOT NULL来拒绝NULL值的插入。比如说当我们对某个列建立唯一索引时,如果插入某条记录时该列的值重复了,那么MySQL就会报错并且拒绝插入。除了这些我们已经非常熟悉的保证一致性的功能,MySQL还支持CHECK语法来自定义约束,比如这样:\nCREATE TABLE account ( id INT NOT NULL AUTO_INCREMENT COMMENT '自增id', name VARCHAR(100) COMMENT '客户名称', balance INT COMMENT '余额', PRIMARY KEY (id), CHECK (balance \u0026gt;= 0) );\n上述例子中的CHECK语句本意是想规定balance列不能存储小于0的数字,对应的现实世界的意思就是银行账户余额不能小于0。但是很遗憾,MySQL仅仅支持CHECK语法,但实际上并没有一点卵用,也就是说即使我们使用上述带有CHECK子句的建表语句来创建account表,那么在后续插入或更新记录时,MySQL并不会去检查CHECK子句中的约束是否成立。\n小贴士:其它的一些数据库,比如SQL Server或者Oracle支持的CHECK语法是有实实在在的作用的,每次进行插入或更新记录之前都会检查一下数据是否符合CHECK子句中指定的约束条件是否成立,如果不成立的话就会拒绝插入或更新。 虽然CHECK子句对一致性检查没什么卵用,但是我们还是可以通过定义触发器的方式来自定义一些约束条件以保证数据库中数据的一致性。 小贴士:触发器是MySQL基础内容中的知识,本书是一本MySQL进阶的书籍,如果你不了解触发器,那恐怕要找本基础内容的书籍来看看了。\n更多的一致性需求需要靠写业务代码的程序员自己保证。\n为建立现实世界和数据库世界的对应关系,理论上应该把现实世界中的所有约束都反应到数据库世界中,但是很不幸,在更改数据库数据时进行一致性检查是一个耗费性能的工作,比方说我们为account表建立了一个触发器,每当插入或者更新记录时都会校验一下balance列的值是不是大于0,这就会影响到插入或更新的速度。仅仅是校验一行记录符不符合一致性需求倒也不是什么大问题,有的一致性需求简直变态,比方说银行会建立一张代表账单的表,里边儿记录了每个账户的每笔交易,每一笔交易完成后,都需要保证整个系统的余额等于所有账户的收入减去所有账户的支出。如果在数据库层面实现这个一致性需求的话,每次发生交易时,都需要将所有的收入加起来减去所有的支出,再将所有的账户余额加起来,看看两个值相不相等。这不是搞笑呢么,如果账单表里有几亿条记录,光是这个校验的过程可能就要跑好几个小时,也就是说你在煎饼摊买个煎饼,使用银行卡付款之后要等好几个小时才能提示付款成功,这样的性能代价是完全承受不起的。\n现实生活中复杂的一致性需求比比皆是,而由于性能问题把一致性需求交给数据库去解决这是不现实的,所以这个锅就甩给了业务端程序员。比方说我们的account表,我们也可以不建立触发器,只要编写业务的程序员在自己的业务代码里判断一下,当某个操作会将balance列的值更新为小于0的值时,就不执行该操作就好了嘛!\n我们前面介绍的原子性和隔离性都会对一致性产生影响,比如我们现实世界中转账操作完成后,有一个一致性需求就是参与转账的账户的总的余额是不变的。如果数据库不遵循原子性要求,也就是转了一半就不转了,也就是说给狗哥扣了钱而没给猫爷转过去,那最后就是不符合一致性需求的;类似的,如果数据库不遵循隔离性要求,就像我们前面介绍隔离性时举的例子中所说的,最终狗哥账户中扣的钱和猫爷账户中涨的钱可能就不一样了,也就是说不符合一致性需求了。所以说,数据库某些操作的原子性和隔离性都是保证一致性的一种手段,在操作执行完成后保证符合所有既定的约束则是一种结果。那满足原子性和隔离性的操作一定就满足一致性么?那倒也不一定,比如说狗哥要转账20元给猫爷,虽然在满足原子性和隔离性,但转账完成了之后狗哥的账户的余额就成负的了,这显然是不满足一致性的。那不满足原子性和隔离性的操作就一定不满足一致性么?这也不一定,只要最后的结果符合所有现实世界中的约束,那么就是符合一致性的。\n持久性(Durability) # 当现实世界的一个状态转换完成后,这个转换的结果将永久的保留,这个规则被设计数据库的大佬们称为持久性。比方说狗哥向猫爷转账,当ATM机提示转账成功了,就意味着这次账户的状态转换完成了,狗哥就可以拔卡走人了。如果当狗哥走掉之后,银行又把这次转账操作给撤销掉,恢复到没转账之前的样子,那猫爷不就惨了,又得被砍死了,所以这个持久性是非常重要的。\n当把现实世界的状态转换映射到数据库世界时,持久性意味着该转换对应的数据库操作所修改的数据都应该在磁盘上保留下来,不论之后发生了什么事故,本次转换造成的影响都不应该被丢失掉(要不然猫爷还是会被砍死)。\n事务的概念 # 为了方便大家记住我们上面介绍的现实世界状态转换过程中需要遵守的4个特性,我们把原子性(Atomicity)、隔离性(Isolation)、一致性(Consistency)和持久性(Durability)这四个词对应的英文单词首字母提取出来就是A、I、C、D,稍微变换一下顺序可以组成一个完整的英文单词:ACID。想必大家都是学过初高中英语的,ACID是英文酸的意思,以后我们提到ACID这个词儿,大家就应该想到原子性、一致性、隔离性、持久性这几个规则。另外,设计数据库的大佬为了方便起见,把需要保证原子性、隔离性、一致性和持久性的一个或多个数据库操作称之为一个事务(英文名是:transaction)。\n我们现在知道事务是一个抽象的概念,它其实对应着一个或多个数据库操作,设计数据库的大佬根据这些操作所执行的不同阶段把事务大致上划分成了这么几个状态:\n活动的(active)\n事务对应的数据库操作正在执行过程中时,我们就说该事务处在活动的状态。\n部分提交的(partially committed)\n当事务中的最后一个操作执行完成,但由于操作都在内存中执行,所造成的影响并没有刷新到磁盘时,我们就说该事务处在部分提交的状态。\n失败的(failed)\n当事务处在活动的或者部分提交的状态时,可能遇到了某些错误(数据库自身的错误、操作系统错误或者直接断电等)而无法继续执行,或者人为的停止当前事务的执行,我们就说该事务处在失败的状态。\n中止的(aborted)\n如果事务执行了半截而变为失败的状态,比如我们前面介绍的狗哥向猫爷转账的事务,当狗哥账户的钱被扣除,但是猫爷账户的钱没有增加时遇到了错误,从而当前事务处在了失败的状态,那么就需要把已经修改的狗哥账户余额调整为未转账之前的金额,换句话说,就是要撤销失败事务对当前数据库造成的影响。书面一点的话,我们把这个撤销的过程称之为回滚。当回滚操作执行完毕时,也就是数据库恢复到了执行事务之前的状态,我们就说该事务处在了中止的状态。\n提交的(committed)\n当一个处在部分提交的状态的事务将修改过的数据都同步到磁盘上之后,我们就可以说该事务处在了提交的状态。\n随着事务对应的数据库操作执行到不同阶段,事务的状态也在不断变化,一个基本的状态转换图如下所示:\n从图中大家也可以看出了,只有当事务处于提交的或者中止的状态时,一个事务的生命周期才算是结束了。对于已经提交的事务来说,该事务对数据库所做的修改将永久生效,对于处于中止状态的事务,该事务对数据库所做的所有修改都会被回滚到没执行该事务之前的状态。\n小贴士:贴士处纯属扯犊子,与正文没什么关系,纯属吐槽。大家知道我们的计算机术语基本上全是从英文翻译成中文的,事务的英文是transaction,英文直译就是交易,买卖的意思,交易就是买的人付钱,卖的人交货,不能付了钱不交货,交了货不付钱把,所以交易本身就是一种不可分割的操作。不知道是哪位大神把transaction翻译成了事务(我想估计是他们也想不出什么更好的词儿,只能随便找一个了),事务这个词儿完全没有交易、买卖的意思,所以大家理解起来也会比较困难,外国人理解transaction可能更好理解一点吧~\nMySQL中事务的语法 # 我们说事务的本质其实只是一系列数据库操作,只不过这些数据库操作符合ACID特性而已,那么MySQL中如何将某些操作放到一个事务里去执行的呢?我们下面就来重点介绍介绍。\n开启事务 # 我们可以使用下面两种语句之一来开启一个事务:\nBEGIN [WORK];\nBEGIN语句代表开启一个事务,后边的单词WORK可有可无。开启事务后,就可以继续写若干条语句,这些语句都属于刚刚开启的这个事务。 ``` mysql\u0026gt; BEGIN; Query OK, 0 rows affected (0.00 sec)\nmysql\u0026gt; 加入事务的语句\u0026hellip; ```\nSTART TRANSACTION;\nSTART TRANSACTION语句和BEGIN语句有着相同的功效,都标志着开启一个事务,比如这样: ``` mysql\u0026gt; START TRANSACTION; Query OK, 0 rows affected (0.00 sec)\nmysql\u0026gt; 加入事务的语句\u0026hellip; ```\n不过比BEGIN语句牛逼一点儿的是,可以在START TRANSACTION语句后边跟随几个修饰符,就是它们几个:\n+ READ ONLY:标识当前事务是一个只读事务,也就是属于该事务的数据库操作只能读取数据,而不能修改数据。\n小贴士:其实只读事务中只是不允许修改那些其他事务也能访问到的表中的数据,对于临时表来说(我们使用CREATE TMEPORARY TABLE创建的表),由于它们只能在当前会话中可见,所以只读事务其实也是可以对临时表进行增、删、改操作的。\n+ READ WRITE:标识当前事务是一个读写事务,也就是属于该事务的数据库操作既可以读取数据,也可以修改数据。\n+ WITH CONSISTENT SNAPSHOT:启动一致性读(先不用关心什么是个一致性读,后边的章节才会介绍)。\n比如我们想开启一个只读事务的话,直接把READ ONLY这个修饰符加在START TRANSACTION语句后边就好,比如这样: START TRANSACTION READ ONLY; 如果我们想在START TRANSACTION后边跟随多个修饰符的话,可以使用逗号将修饰符分开,比如开启一个只读事务和一致性读,就可以这样写:\nSTART TRANSACTION READ ONLY, WITH CONSISTENT SNAPSHOT; 或者开启一个读写事务和一致性读,就可以这样写: START TRANSACTION READ WRITE, WITH CONSISTENT SNAPSHOT 不过这里需要大家注意的一点是,READ ONLY和READ WRITE是用来设置所谓的事务访问模式的,就是以只读还是读写的方式来访问数据库中的数据,一个事务的访问模式不能同时既设置为只读的也设置为读写的,所以我们不能同时把READ ONLY和READ WRITE放到START TRANSACTION语句后边。另外,如果我们不显式指定事务的访问模式,那么该事务的访问模式就是读写模式。\n提交事务 # 开启事务之后就可以继续写需要放到该事务中的语句了,当最后一条语句写完了之后,我们就可以提交该事务了,提交的语句也很简单: COMMIT [WORK] COMMIT语句就代表提交一个事务,后边的WORK可有可无。比如我们上面说狗哥给猫爷转10元钱其实对应MySQL中的两条语句,我们就可以把这两条语句放到一个事务中,完整的过程就是这样: ``` mysql\u0026gt; BEGIN; Query OK, 0 rows affected (0.00 sec)\nmysql\u0026gt; UPDATE account SET balance = balance - 10 WHERE id = 1; Query OK, 1 row affected (0.02 sec) Rows matched: 1 Changed: 1 Warnings: 0\nmysql\u0026gt; UPDATE account SET balance = balance + 10 WHERE id = 2; Query OK, 1 row affected (0.00 sec) Rows matched: 1 Changed: 1 Warnings: 0\nmysql\u0026gt; COMMIT; Query OK, 0 rows affected (0.00 sec) ```\n手动中止事务 # 如果我们写了几条语句之后发现上面的某条语句写错了,我们可以手动的使用下面这个语句来将数据库恢复到事务执行之前的样子: ROLLBACK [WORK] ROLLBACK语句就代表中止并回滚一个事务,后边的WORK可有可无类似的。比如我们在写狗哥给猫爷转账10元钱对应的MySQL语句时,先给狗哥扣了10元,然后一时大意只给猫爷账户上增加了1元,此时就可以使用ROLLBACK语句进行回滚,完整的过程就是这样: ``` mysql\u0026gt; BEGIN; Query OK, 0 rows affected (0.00 sec)\nmysql\u0026gt; UPDATE account SET balance = balance - 10 WHERE id = 1; Query OK, 1 row affected (0.00 sec) Rows matched: 1 Changed: 1 Warnings: 0\nmysql\u0026gt; UPDATE account SET balance = balance + 1 WHERE id = 2; Query OK, 1 row affected (0.00 sec) Rows matched: 1 Changed: 1 Warnings: 0\nmysql\u0026gt; ROLLBACK; Query OK, 0 rows affected (0.00 sec) 这里需要强调一下,ROLLBACK语句是我们程序员手动的去回滚事务时才去使用的,如果事务在执行过程中遇到了某些错误而无法继续执行的话,事务自身会自动的回滚。 小贴士:我们这里所说的开启、提交、中止事务的语法只是针对使用黑框框时通过mysql客户端程序与服务器进行交互时控制事务的语法,如果大家使用的是别的客户端程序,比如JDBC之类的,那需要参考相应的文档来看看如何控制事务。 ```\n支持事务的存储引擎 # MySQL中并不是所有存储引擎都支持事务的功能,目前只有InnoDB和NDB存储引擎支持(NDB存储引擎不是我们的重点),如果某个事务中包含了修改使用不支持事务的存储引擎的表,那么对该使用不支持事务的存储引擎的表所做的修改将无法进行回滚。比方说我们有两个表,tbl1使用支持事务的存储引擎InnoDB,tbl2使用不支持事务的存储引擎MyISAM,它们的建表语句如下所示: ``` CREATE TABLE tbl1 ( i int ) engine=InnoDB;\nCREATE TABLE tbl2 ( i int ) ENGINE=MyISAM; 我们看看先开启一个事务,写一条插入语句后再回滚该事务,tbl1和tbl2的表现有什么不同: mysql\u0026gt; SELECT * FROM tbl1; Empty set (0.00 sec)\nmysql\u0026gt; BEGIN; Query OK, 0 rows affected (0.00 sec)\nmysql\u0026gt; INSERT INTO tbl1 VALUES(1); Query OK, 1 row affected (0.00 sec)\nmysql\u0026gt; ROLLBACK; Query OK, 0 rows affected (0.00 sec)\nmysql\u0026gt; SELECT * FROM tbl1; Empty set (0.00 sec) 可以看到,对于使用支持事务的存储引擎的tbl1表来说,我们在插入一条记录再回滚后,tbl1就恢复到没有插入记录时的状态了。再看看tbl2表的表现: mysql\u0026gt; SELECT * FROM tbl2; Empty set (0.00 sec)\nmysql\u0026gt; BEGIN; Query OK, 0 rows affected (0.00 sec)\nmysql\u0026gt; INSERT INTO tbl2 VALUES(1); Query OK, 1 row affected (0.00 sec)\nmysql\u0026gt; ROLLBACK; Query OK, 0 rows affected, 1 warning (0.01 sec)\nmysql\u0026gt; SELECT * FROM tbl2; +\u0026mdash;\u0026mdash;+ | i | +\u0026mdash;\u0026mdash;+ | 1 | +\u0026mdash;\u0026mdash;+ 1 row in set (0.00 sec) ``` 可以看到,虽然我们使用了ROLLBACK语句来回滚事务,但是插入的那条记录还是留在了tbl2表中。\n自动提交 # MySQL中有一个系统变量autocommit: mysql\u0026gt; SHOW VARIABLES LIKE 'autocommit'; +---------------+-------+ | Variable_name | Value | +---------------+-------+ | autocommit | ON | +---------------+-------+ 1 row in set (0.01 sec) 可以看到它的默认值为ON,也就是说默认情况下,如果我们不显式的使用START TRANSACTION或者BEGIN语句开启一个事务,那么每一条语句都算是一个独立的事务,这种特性称之为事务的自动提交。假如我们在狗哥向猫爷转账10元时不以START TRANSACTION或者BEGIN语句显式的开启一个事务,那么下面这两条语句就相当于放到两个独立的事务中去执行: UPDATE account SET balance = balance - 10 WHERE id = 1; UPDATE account SET balance = balance + 10 WHERE id = 2; 当然,如果我们想关闭这种自动提交的功能,可以使用下面两种方法之一:\n显式的的使用START TRANSACTION或者BEGIN语句开启一个事务。\n这样在本次事务提交或者回滚前会暂时关闭掉自动提交的功能。\n把系统变量autocommit的值设置为OFF,就像这样:\nSET autocommit = OFF; 这样的话,我们写入的多条语句就算是属于同一个事务了,直到我们显式的写出COMMIT语句来把这个事务提交掉,或者显式的写出ROLLBACK语句来把这个事务回滚掉。\n隐式提交 # 当我们使用START TRANSACTION或者BEGIN语句开启了一个事务,或者把系统变量autocommit的值设置为OFF时,事务就不会进行自动提交,但是如果我们输入了某些语句之后就会悄悄的提交掉,就像我们输入了COMMIT语句了一样,这种因为某些特殊的语句而导致事务提交的情况称为隐式提交,这些会导致事务隐式提交的语句包括:\n定义或修改数据库对象的数据定义语言(Data definition language,缩写为:DDL)。\n所谓的数据库对象,指的就是数据库、表、视图、存储过程等等这些东西。当我们使用CREATE、ALTER、DROP等语句去修改这些所谓的数据库对象时,就会隐式的提交前面语句所属于的事务,就像这样:\nSELECT ... \\# 事务中的一条语句 UPDATE ... \\# 事务中的一条语句 ... \\# 事务中的其它语句 CREATE TABLE ... \\# 此语句会隐式的提交前面语句所属于的事务 ``` + 隐式使用或修改`mysql`数据库中的表 当我们使用`ALTER USER`、`CREATE USER`、`DROP USER`、`GRANT`、`RENAME USER`、`REVOKE`、`SET PASSWORD`等语句时也会隐式的提交前面语句所属于的事务。 + 事务控制或关于锁定的语句 当我们在一个事务还没提交或者回滚时就又使用`START TRANSACTION`或者`BEGIN`语句开启了另一个事务时,会隐式的提交上一个事务,比如这样: ``` BEGIN; SELECT ... \\# 事务中的一条语句 UPDATE ... \\# 事务中的一条语句 ... \\# 事务中的其它语句 BEGIN; \\# 此语句会隐式的提交前面语句所属于的事务 ``` 或者当前的`autocommit`系统变量的值为`OFF`,我们手动把它调为`ON`时,也会隐式的提交前面语句所属的事务。 或者使用`LOCK TABLES`、`UNLOCK TABLES`等关于锁定的语句也会隐式的提交前面语句所属的事务。 + 加载数据的语句 比如我们使用`LOAD DATA`语句来批量往数据库中导入数据时,也会隐式的提交前面语句所属的事务。 + 关于`MySQL`复制的一些语句 使用`START SLAVE`、`STOP SLAVE`、`RESET SLAVE`、`CHANGE MASTER TO`等语句时也会隐式的提交前面语句所属的事务。 + 其它的一些语句 使用`ANALYZE TABLE`、`CACHE INDEX`、`CHECK TABLE`、`FLUSH`、 `LOAD INDEX INTO CACHE`、`OPTIMIZE TABLE`、`REPAIR TABLE`、`RESET`等语句也会隐式的提交前面语句所属的事务。 `小贴士:上面提到的一些语句,如果你都认识并且知道是干嘛用的那再好不过了,不认识也不要气馁,这里写出来只是为了内容的完整性,把可能会导致事务隐式提交的情况都列举一下,具体每个语句都是干嘛用的等我们遇到了再说。` ## 保存点 如果你开启了一个事务,并且已经敲了很多语句,忽然发现上一条语句有点问题,你只好使用`ROLLBACK`语句来让数据库状态恢复到事务执行之前的样子,然后一切从头再来,总有一种一夜回到解放前的感觉。所以设计数据库的大佬们提出了一个`保存点`(英文:`savepoint`)的概念,就是在事务对应的数据库语句中打几个点,我们在调用`ROLLBACK`语句时可以指定会滚到哪个点,而不是回到最初的原点。定义保存点的语法如下: `SAVEPOINT 保存点名称;` 当我们想回滚到某个保存点时,可以使用下面这个语句(下面语句中的单词`WORK`和`SAVEPOINT`是可有可无的): `ROLLBACK [WORK] TO [SAVEPOINT] 保存点名称;` 不过如果`ROLLBACK`语句后边不跟随保存点名称的话,会直接回滚到事务执行之前的状态。 如果我们想删除某个保存点,可以使用这个语句: `RELEASE SAVEPOINT 保存点名称;` 下面还是以狗哥向猫爷转账10元的例子展示一下`保存点`的用法,在执行完扣除狗哥账户的钱`10`元的语句之后打一个`保存点`: ``` mysql\u0026gt; SELECT * FROM account; \\+----\\+--------\\+---------\\+ | id | name | balance | \\+----\\+--------\\+---------\\+ | 1 | 狗哥 | 11 | | 2 | 猫爷 | 2 | \\+----\\+--------\\+---------\\+ 2 rows in set (0.00 sec) mysql\u0026gt; BEGIN; Query OK, 0 rows affected (0.00 sec) mysql\u0026gt; UPDATE account SET balance = balance - 10 WHERE id = 1; Query OK, 1 row affected (0.01 sec) Rows matched: 1 Changed: 1 Warnings: 0 mysql\u0026gt; SAVEPOINT s1; \\# 一个保存点 Query OK, 0 rows affected (0.00 sec) mysql\u0026gt; SELECT * FROM account; \\+----\\+--------\\+---------\\+ | id | name | balance | \\+----\\+--------\\+---------\\+ | 1 | 狗哥 | 1 | | 2 | 猫爷 | 2 | \\+----\\+--------\\+---------\\+ 2 rows in set (0.00 sec) mysql\u0026gt; UPDATE account SET balance = balance \\+ 1 WHERE id = 2; \\# 更新错了 Query OK, 1 row affected (0.00 sec) Rows matched: 1 Changed: 1 Warnings: 0 mysql\u0026gt; ROLLBACK TO s1; \\# 回滚到保存点s1处 Query OK, 0 rows affected (0.00 sec) mysql\u0026gt; SELECT * FROM account; \\+----\\+--------\\+---------\\+ | id | name | balance | \\+----\\+--------\\+---------\\+ | 1 | 狗哥 | 1 | | 2 | 猫爷 | 2 | \\+----\\+--------\\+---------\\+ 2 rows in set (0.00 sec) ``` "},{"id":15,"href":"/zh/docs/technology/MySQL/MySQL%E6%98%AF%E6%80%8E%E6%A0%B7%E8%BF%90%E8%A1%8C%E7%9A%84/%E7%AC%AC18%E7%AB%A0_%E8%B0%83%E8%8A%82%E7%A3%81%E7%9B%98%E5%92%8CCPU%E7%9A%84%E7%9F%9B%E7%9B%BE-InnoDB%E7%9A%84BufferPool/","title":"第18章_调节磁盘和CPU的矛盾-InnoDB的BufferPool","section":"My Sql是怎样运行的","content":"第18章 调节磁盘和CPU的矛盾-InnoDB的Buffer Pool\n缓存的重要性 # 通过前面的介绍我们知道,对于使用InnoDB作为存储引擎的表来说,不管是用于存储用户数据的索引(包括聚簇索引和二级索引),还是各种系统数据,都是以页的形式存放在表空间中的,而所谓的表空间只不过是InnoDB对文件系统上一个或几个实际文件的抽象,也就是说我们的数据说到底还是存储在磁盘上的。但是各位也都知道,磁盘的速度慢的跟乌龟一样,怎么能配得上“快如风,疾如电”的CPU呢?所以InnoDB存储引擎在处理客户端的请求时,当需要访问某个页的数据时,就会把完整的页的数据全部加载到内存中,也就是说即使我们只需要访问一个页的一条记录,那也需要先把整个页的数据加载到内存中。将整个页加载到内存中后就可以进行读写访问了,在进行完读写访问之后并不着急把该页对应的内存空间释放掉,而是将其缓存起来,这样将来有请求再次访问该页面时,就可以省去磁盘IO的开销了。\nInnoDB的Buffer Pool # 什么是个Buffer Pool # 设计InnoDB的大佬为了缓存磁盘中的页,在MySQL服务器启动的时候就向操作系统申请了一片连续的内存,他们给这片内存起了个名,叫做Buffer Pool(中文名是缓冲池)。那它有多大呢?这个其实看我们机器的配置,如果你是土豪,你有512G内存,你分配个几百G作为Buffer Pool也可以啊,当然你要是没那么有钱,设置小点也行呀~ 默认情况下Buffer Pool只有128M大小。当然如果你嫌弃这个128M太大或者太小,可以在启动服务器的时候配置innodb_buffer_pool_size参数的值,它表示Buffer Pool的大小,就像这样: [server] innodb_buffer_pool_size = 268435456 其中,268435456的单位是字节,也就是我指定Buffer Pool的大小为256M。需要注意的是,Buffer Pool也不能太小,最小值为5M(当小于该值时会自动设置成5M)。\nBuffer Pool内部组成 # Buffer Pool中默认的缓存页大小和在磁盘上默认的页大小是一样的,都是16KB。为了更好的管理这些在Buffer Pool中的缓存页,设计InnoDB的大佬为每一个缓存页都创建了一些所谓的控制信息,这些控制信息包括该页所属的表空间编号、页号、缓存页在Buffer Pool中的地址、链表节点信息、一些锁信息以及LSN信息(锁和LSN我们之后会具体介绍,现在可以先忽略),当然还有一些别的控制信息,我们这就不全介绍一遍了,挑重要的说嘛~\n每个缓存页对应的控制信息占用的内存大小是相同的,我们就把每个页对应的控制信息占用的一块内存称为一个控制块吧,控制块和缓存页是一一对应的,它们都被存放到 Buffer Pool 中,其中控制块被存放到 Buffer Pool 的前面,缓存页被存放到 Buffer Pool 后边,所以整个Buffer Pool对应的内存空间看起来就是这样的:\n咦?控制块和缓存页之间的那个碎片是个什么玩意儿?你想想啊,每一个控制块都对应一个缓存页,那在分配足够多的控制块和缓存页后,可能剩余的那点儿空间不够一对控制块和缓存页的大小,自然就用不到喽,这个用不到的那点儿内存空间就被称为碎片了。当然,如果你把Buffer Pool的大小设置的刚刚好的话,也可能不会产生碎片~ 小贴士:每个控制块大约占用缓存页大小的5%,在MySQL5.7.21这个版本中,每个控制块占用的大小是808字节。而我们设置的innodb_buffer_pool_size并不包含这部分控制块占用的内存空间大小,也就是说InnoDB在为Buffer Pool向操作系统申请连续的内存空间时,这片连续的内存空间一般会比innodb_buffer_pool_size的值大5%左右。\nfree链表的管理 # 当我们最初启动MySQL服务器的时候,需要完成对Buffer Pool的初始化过程,就是先向操作系统申请Buffer Pool的内存空间,然后把它划分成若干对控制块和缓存页。但是此时并没有真实的磁盘页被缓存到Buffer Pool中(因为还没有用到),之后随着程序的运行,会不断的有磁盘上的页被缓存到Buffer Pool中。那么问题来了,从磁盘上读取一个页到Buffer Pool中的时候该放到哪个缓存页的位置呢?或者说怎么区分Buffer Pool中哪些缓存页是空闲的,哪些已经被使用了呢?我们最好在某个地方记录一下Buffer Pool中哪些缓存页是可用的,这个时候缓存页对应的控制块就派上大用场了,我们可以把所有空闲的缓存页对应的控制块作为一个节点放到一个链表中,这个链表也可以被称作free链表(或者说空闲链表)。刚刚完成初始化的Buffer Pool中所有的缓存页都是空闲的,所以每一个缓存页对应的控制块都会被加入到free链表中,假设该Buffer Pool中可容纳的缓存页数量为n,那增加了free链表的效果图就是这样的:\n从图中可以看出,我们为了管理好这个free链表,特意为这个链表定义了一个基节点,里边儿包含着链表的头节点地址,尾节点地址,以及当前链表中节点的数量等信息。这里需要注意的是,链表的基节点占用的内存空间并不包含在为Buffer Pool申请的一大片连续内存空间之内,而是单独申请的一块内存空间。 小贴士:链表基节点占用的内存空间并不大,在MySQL5.7.21这个版本里,每个基节点只占用40字节大小。后边我们即将介绍许多不同的链表,它们的基节点和free链表的基节点的内存分配方式是一样一样的,都是单独申请的一块40字节大小的内存空间,并不包含在为Buffer Pool申请的一大片连续内存空间之内。\n有了这个free链表之后事儿就好办了,每当需要从磁盘中加载一个页到Buffer Pool中时,就从free链表中取一个空闲的缓存页,并且把该缓存页对应的控制块的信息填上(就是该页所在的表空间、页号之类的信息),然后把该缓存页对应的free链表节点从链表中移除,表示该缓存页已经被使用了~\n缓存页的哈希处理 # 我们前面说过,当我们需要访问某个页中的数据时,就会把该页从磁盘加载到Buffer Pool中,如果该页已经在Buffer Pool中的话直接使用就可以了。那么问题也就来了,我们怎么知道该页在不在Buffer Pool中呢?难不成需要依次遍历Buffer Pool中各个缓存页么?一个Buffer Pool中的缓存页这么多都遍历完岂不是要累死?\n再回头想想,我们其实是根据表空间号 + 页号来定位一个页的,也就相当于表空间号 + 页号是一个key,缓存页就是对应的value,怎么通过一个key来快速找着一个value呢?那肯定是希表喽~ 小贴士:什么?你别告诉我你不知道哈希表是什么?我们这个文章不是讲哈希表的,如果你不会那就去找本数据结构的书看看吧~ 什么?外头的书看不懂?别急,等我~ 所以我们可以用表空间号 + 页号作为key,缓存页作为value创建一个哈希表,在需要访问某个页的数据时,先从哈希表中根据表空间号 + 页号看看有没有对应的缓存页,如果有,直接使用该缓存页就好,如果没有,那就从free链表中选一个空闲的缓存页,然后把磁盘中对应的页加载到该缓存页的位置。\nflush链表的管理 # 如果我们修改了Buffer Pool中某个缓存页的数据,那它就和磁盘上的页不一致了,这样的缓存页也被称为脏页(英文名:dirty page)。当然,最简单的做法就是每发生一次修改就立即同步到磁盘上对应的页上,但是频繁的往磁盘中写数据会严重的影响程序的性能(毕竟磁盘慢的像乌龟一样)。所以每次修改缓存页后,我们并不着急立即把修改同步到磁盘上,而是在未来的某个时间点进行同步,至于这个同步的时间点我们后边会作说明说明的,现在先不用管~\n但是如果不立即同步到磁盘的话,那之后再同步的时候我们怎么知道Buffer Pool中哪些页是脏页,哪些页从来没被修改过呢?总不能把所有的缓存页都同步到磁盘上吧,假如Buffer Pool被设置的很大,比方说300G,那一次性同步这么多数据岂不是要慢死!所以,我们不得不再创建一个存储脏页的链表,凡是修改过的缓存页对应的控制块都会作为一个节点加入到一个链表中,因为这个链表节点对应的缓存页都是需要被刷新到磁盘上的,所以也叫flush链表。链表的构造和free链表差不多,假设某个时间点Buffer Pool中的脏页数量为n,那么对应的flush链表就长这样:\nLRU链表的管理 # 缓存不够的窘境 # Buffer Pool对应的内存大小毕竟是有限的,如果需要缓存的页占用的内存大小超过了Buffer Pool大小,也就是free链表中已经没有多余的空闲缓存页的时候岂不是很尴尬,发生了这样的事儿该咋办?当然是把某些旧的缓存页从Buffer Pool中移除,然后再把新的页放进来喽~ 那么问题来了,移除哪些缓存页呢?\n为了回答这个问题,我们还需要回到我们设立Buffer Pool的初衷,我们就是想减少和磁盘的IO交互,最好每次在访问某个页的时候它都已经被缓存到Buffer Pool中了。假设我们一共访问了n次页,那么被访问的页已经在缓存中的次数除以n就是所谓的缓存命中率,我们的期望就是让缓存命中率越高越好~ 从这个角度出发,回想一下我们的微信聊天列表,排在前面的都是最近很频繁使用的,排在后边的自然就是最近很少使用的,假如列表能容纳下的联系人有限,你是会把最近很频繁使用的留下还是最近很少使用的留下呢?废话,当然是留下最近很频繁使用的了~\n简单的LRU链表 # 管理Buffer Pool的缓存页其实也是这个道理,当Buffer Pool中不再有空闲的缓存页时,就需要淘汰掉部分最近很少使用的缓存页。不过,我们怎么知道哪些缓存页最近频繁使用,哪些最近很少使用呢?呵呵,神奇的链表再一次派上了用场,我们可以再创建一个链表,由于这个链表是为了按照最近最少使用的原则去淘汰缓存页的,所以这个链表可以被称为LRU链表(LRU的英文全称:Least Recently Used)。当我们需要访问某个页时,可以这样处理LRU链表:\n如果该页不在Buffer Pool中,在把该页从磁盘加载到Buffer Pool中的缓存页时,就把该缓存页对应的控制块作为节点塞到链表的头部。\n如果该页已经缓存在Buffer Pool中,则直接把该页对应的控制块移动到LRU链表的头部。\n也就是说:只要我们使用到某个缓存页,就把该缓存页调整到LRU链表的头部,这样LRU链表尾部就是最近最少使用的缓存页喽~ 所以当Buffer Pool中的空闲缓存页使用完时,到LRU链表的尾部找些缓存页淘汰就OK啦,真简单,啧啧\u0026hellip;\n划分区域的LRU链表 # 高兴的太早了,上面的这个简单的LRU链表用了没多长时间就发现问题了,因为存在这两种比较尴尬的情况:\n情况一:InnoDB提供了一个看起来比较贴心的服务——预读(英文名:read ahead)。所谓预读,就是InnoDB认为执行当前的请求可能之后会读取某些页面,就预先把它们加载到Buffer Pool中。根据触发方式的不同,预读又可以细分为下面两种:\n+ 线性预读\n设计InnoDB的大佬提供了一个系统变量innodb_read_ahead_threshold,如果顺序访问了某个区(extent)的页面超过这个系统变量的值,就会触发一次异步读取下一个区中全部的页面到Buffer Pool的请求,注意异步读取意味着从磁盘中加载这些被预读的页面并不会影响到当前工作线程的正常执行。这个innodb_read_ahead_threshold系统变量的值默认是56,我们可以在服务器启动时通过启动参数或者服务器运行过程中直接调整该系统变量的值,不过它是一个全局变量,注意使用SET GLOBAL命令来修改哦。 小贴士:InnoDB是怎么实现异步读取的呢?在Windows或者Linux平台上,可能是直接调用操作系统内核提供的AIO接口,在其它类Unix操作系统中,使用了一种模拟AIO接口的方式来实现异步读取,其实就是让别的线程去读取需要预读的页面。如果你读不懂上面这段话,那也就没必要懂了,和我们主题其实没太多关系,你只需要知道异步读取并不会影响到当前工作线程的正常执行就好了。其实这个过程涉及到操作系统如何处理IO以及多线程的问题,找本操作系统的书看看吧,什么?操作系统的书写的都很难懂?没关系,等我~\n+ 随机预读\n如果Buffer Pool中已经缓存了某个区的13个连续的页面,不论这些页面是不是顺序读取的,都会触发一次异步读取本区中所有其的页面到Buffer Pool的请求。设计InnoDB的大佬同时提供了innodb_random_read_ahead系统变量,它的默认值为OFF,也就意味着InnoDB并不会默认开启随机预读的功能,如果我们想开启该功能,可以通过修改启动参数或者直接使用SET GLOBAL命令把该变量的值设置为ON。\n预读本来是个好事儿,如果预读到Buffer Pool中的页成功的被使用到,那就可以极大的提高语句执行的效率。可是如果用不到呢?这些预读的页都会放到LRU链表的头部,但是如果此时Buffer Pool的容量不太大而且很多预读的页面都没有用到的话,这就会导致处在LRU链表尾部的一些缓存页会很快的被淘汰掉,也就是所谓的劣币驱逐良币,会大大降低缓存命中率。\n情况二:有的小伙伴可能会写一些需要扫描全表的查询语句(比如没有建立合适的索引或者压根儿没有WHERE子句的查询)。\n扫描全表意味着什么?意味着将访问到该表所在的所有页!假设这个表中记录非常多的话,那该表会占用特别多的页,当需要访问这些页时,会把它们统统都加载到Buffer Pool中,这也就意味着吧唧一下,Buffer Pool中的所有页都被换了一次血,其他查询语句在执行时又得执行一次从磁盘加载到Buffer Pool的操作。而这种全表扫描的语句执行的频率也不高,每次执行都要把Buffer Pool中的缓存页换一次血,这严重的影响到其他查询对 Buffer Pool的使用,从而大大降低了缓存命中率。\n总结一下上面说的可能降低Buffer Pool的两种情况: - 加载到Buffer Pool中的页不一定被用到。 - 如果非常多的使用频率偏低的页被同时加载到Buffer Pool时,可能会把那些使用频率非常高的页从Buffer Pool中淘汰掉。\n因为有这两种情况的存在,所以设计InnoDB的大佬把这个LRU链表按照一定比例分成两截,分别是: - 一部分存储使用频率非常高的缓存页,所以这一部分链表也叫做热数据,或者称young区域。 - 另一部分存储使用频率不是很高的缓存页,所以这一部分链表也叫做冷数据,或者称old区域。\n为了方便大家理解,我们把示意图做了简化,各位领会精神就好:\n大家要特别注意一个事儿:我们是按照某个比例将LRU链表分成两半的,不是某些节点固定是young区域的,某些节点固定是old区域的,随着程序的运行,某个节点所属的区域也可能发生变化。那这个划分成两截的比例怎么确定呢?对于InnoDB存储引擎来说,我们可以通过查看系统变量innodb_old_blocks_pct的值来确定old区域在LRU链表中所占的比例,比方说这样: mysql\u0026gt; SHOW VARIABLES LIKE 'innodb_old_blocks_pct'; +-----------------------+-------+ | Variable_name | Value | +-----------------------+-------+ | innodb_old_blocks_pct | 37 | +-----------------------+-------+ 1 row in set (0.01 sec) 从结果可以看出来,默认情况下,old区域在LRU链表中所占的比例是37%,也就是说old区域大约占LRU链表的3/8。这个比例我们是可以设置的,我们可以在启动时修改innodb_old_blocks_pct参数来控制old区域在LRU链表中所占的比例,比方说这样修改配置文件: [server] innodb_old_blocks_pct = 40 这样我们在启动服务器后,old区域占LRU链表的比例就是40%。当然,如果在服务器运行期间,我们也可以修改这个系统变量的值,不过需要注意的是,这个系统变量属于全局变量,一经修改,会对所有客户端生效,所以我们只能这样修改: SET GLOBAL innodb_old_blocks_pct = 40;\n有了这个被划分成young和old区域的LRU链表之后,设计InnoDB的大佬就可以针对我们上面提到的两种可能降低缓存命中率的情况进行优化了:\n针对预读的页面可能不进行后续访情况的优化\n设计InnoDB的大佬规定,当磁盘上的某个页面在初次加载到Buffer Pool中的某个缓存页时,该缓存页对应的控制块会被放到old区域的头部。这样针对预读到Buffer Pool却不进行后续访问的页面就会被逐渐从old区域逐出,而不会影响young区域中被使用比较频繁的缓存页。\n针对全表扫描时,短时间内访问大量使用频率非常低的页面情况的优化\n在进行全表扫描时,虽然首次被加载到Buffer Pool的页被放到了old区域的头部,但是后续会被马上访问到,每次进行访问的时候又会把该页放到young区域的头部,这样仍然会把那些使用频率比较高的页面给顶下去。有同学会想:可不可以在第一次访问该页面时不将其从old区域移动到young区域的头部,后续访问时再将其移动到young区域的头部。回答是:行不通!因为设计InnoDB的大佬规定每次去页面中读取一条记录时,都算是访问一次页面,而一个页面中可能会包含很多条记录,也就是说读取完某个页面的记录就相当于访问了这个页面好多次。\n咋办?全表扫描有一个特点,那就是它的执行频率非常低,谁也不会没事儿老在那写全表扫描的语句玩,而且在执行全表扫描的过程中,即使某个页面中有很多条记录,也就是去多次访问这个页面所花费的时间也是非常少的。所以我们只需要规定,在对某个处在old区域的缓存页进行第一次访问时就在它对应的控制块中记录下来这个访问时间,如果后续的访问时间与第一次访问的时间在某个时间间隔内,那么该页面就不会被从old区域移动到young区域的头部,否则将它移动到young区域的头部。上述的这个间隔时间是由系统变量innodb_old_blocks_time控制的,你看:\nmysql\u0026gt; SHOW VARIABLES LIKE 'innodb_old_blocks_time'; +------------------------+-------+ | Variable_name | Value | +------------------------+-------+ | innodb_old_blocks_time | 1000 | +------------------------+-------+ 1 row in set (0.01 sec) 这个innodb_old_blocks_time的默认值是1000,它的单位是毫秒,也就意味着对于从磁盘上被加载到LRU链表的old区域的某个页来说,如果第一次和最后一次访问该页面的时间间隔小于1s(很明显在一次全表扫描的过程中,多次访问一个页面中的时间不会超过1s),那么该页是不会被加入到young区域的~ 当然,像innodb_old_blocks_pct一样,我们也可以在服务器启动或运行时设置innodb_old_blocks_time的值,这里就不赘述了,你自己试试吧~ 这里需要注意的是,如果我们把innodb_old_blocks_time的值设置为0,那么每次我们访问一个页面时就会把该页面放到young区域的头部。\n综上所述,正是因为将LRU链表划分为young和old区域这两个部分,又添加了innodb_old_blocks_time这个系统变量,才使得预读机制和全表扫描造成的缓存命中率降低的问题得到了遏制,因为用不到的预读页面以及全表扫描的页面都只会被放到old区域,而不影响young区域中的缓存页。\n更进一步优化LRU链表 # LRU链表这就说完了么?没有,早着呢~ 对于young区域的缓存页来说,我们每次访问一个缓存页就要把它移动到LRU链表的头部,这样开销是不是太大啦,毕竟在young区域的缓存页都是热点数据,也就是可能被经常访问的,这样频繁的对LRU链表进行节点移动操作是不是不太好啊?是的,为了解决这个问题其实我们还可以提出一些优化策略,比如只有被访问的缓存页位于young区域的1/4的后边,才会被移动到LRU链表头部,这样就可以降低调整LRU链表的频率,从而提升性能(也就是说如果某个缓存页对应的节点在young区域的1/4中,再次访问该缓存页时也不会将其移动到LRU链表头部)。\n小贴士:我们之前介绍随机预读的时候曾说,如果Buffer Pool中有某个区的13个连续页面就会触发随机预读,这其实是不严谨的(不幸的是MySQL文档就是这么说的[摊手]),其实还要求这13个页面是非常热的页面,所谓的非常热,指的是这些页面在整个young区域的头1/4处。\n还有没有什么别的针对LRU链表的优化措施呢?当然有啊,你要是好好学,写篇论文,写本书都不是问题,可是这毕竟是一个介绍MySQL基础知识的文章,再说多了篇幅就受不了了,也影响大家的阅读体验,所以适可而止,想了解更多的优化知识,自己去看源码或者更多关于LRU链表的知识喽~ 但是不论怎么优化,千万别忘了我们的初心:尽量高效的提高 Buffer Pool 的缓存命中率。\n其他的一些链表 # 为了更好的管理Buffer Pool中的缓存页,除了我们上面提到的一些措施,设计InnoDB的大佬们还引进了其他的一些链表,比如unzip LRU链表用于管理解压页,zip clean链表用于管理没有被解压的压缩页,zip free数组中每一个元素都代表一个链表,它们组成所谓的伙伴系统来为压缩页提供内存空间等等,反正是为了更好的管理这个Buffer Pool引入了各种链表或其他数据结构,具体的使用方式就不啰嗦了,大家有兴趣深究的再去找些更深的书或者直接看源代码吧,也可以直接来找我~ 小贴士:我们压根儿没有深入介绍过InnoDB中的压缩页,对上面的这些链表也只是为了完整性顺便提一下,如果你看不懂千万不要抑郁,因为我压根儿就没打算向大家介绍它们。\n刷新脏页到磁盘 # 后台有专门的线程每隔一段时间负责把脏页刷新到磁盘,这样可以不影响用户线程处理正常的请求。主要有两种刷新路径:\n从LRU链表的冷数据中刷新一部分页面到磁盘。\n后台线程会定时从LRU链表尾部开始扫描一些页面,扫描的页面数量可以通过系统变量innodb_lru_scan_depth来指定,如果从里边儿发现脏页,会把它们刷新到磁盘。这种刷新页面的方式被称之为BUF_FLUSH_LRU。\n从flush链表中刷新一部分页面到磁盘。\n后台线程也会定时从flush链表中刷新一部分页面到磁盘,刷新的速率取决于当时系统是不是很繁忙。这种刷新页面的方式被称之为BUF_FLUSH_LIST。\n有时候后台线程刷新脏页的进度比较慢,导致用户线程在准备加载一个磁盘页到Buffer Pool时没有可用的缓存页,这时就会尝试看看LRU链表尾部有没有可以直接释放掉的未修改页面,如果没有的话会不得不将LRU链表尾部的一个脏页同步刷新到磁盘(和磁盘交互是很慢的,这会降低处理用户请求的速度)。这种刷新单个页面到磁盘中的刷新方式被称之为BUF_FLUSH_SINGLE_PAGE。\n当然,有时候系统特别繁忙时,也可能出现用户线程批量的从flush链表中刷新脏页的情况,很显然在处理用户请求过程中去刷新脏页是一种严重降低处理速度的行为(毕竟磁盘的速度满的要死),这属于一种迫不得已的情况,不过这得放在后边介绍redo日志的checkpoint时说了。\n多个Buffer Pool实例 # 我们上面说过,Buffer Pool本质是InnoDB向操作系统申请的一块连续的内存空间,在多线程环境下,访问Buffer Pool中的各种链表都需要加锁处理什么的,在Buffer Pool特别大而且多线程并发访问特别高的情况下,单一的Buffer Pool可能会影响请求的处理速度。所以在Buffer Pool特别大的时候,我们可以把它们拆分成若干个小的Buffer Pool,每个Buffer Pool都称为一个实例,它们都是独立的,独立的去申请内存空间,独立的管理各种链表,独立的等等,所以在多线程并发访问时并不会相互影响,从而提高并发处理能力。我们可以在服务器启动的时候通过设置innodb_buffer_pool_instances的值来修改Buffer Pool实例的个数,比方说这样: [server] innodb_buffer_pool_instances = 2 这样就表明我们要创建2个Buffer Pool实例,示意图就是这样:\n小贴士:为了简便,我只把各个链表的基节点画出来了,大家应该心里清楚这些链表的节点其实就是每个缓存页对应的控制块!\n那每个Buffer Pool实例实际占多少内存空间呢?其实使用这个公式算出来的: innodb_buffer_pool_size/innodb_buffer_pool_instances 也就是总共的大小除以实例的个数,结果就是每个Buffer Pool实例占用的大小。\n不过也不是说Buffer Pool实例创建的越多越好,分别管理各个Buffer Pool也是需要性能开销的,设计InnoDB的大佬们规定:当innodb_buffer_pool_size的值小于1G的时候设置多个实例是无效的,InnoDB会默认把innodb_buffer_pool_instances 的值修改为1。而我们鼓励在Buffer Pool大小或等于1G的时候设置多个Buffer Pool实例。\ninnodb_buffer_pool_chunk_size # 在MySQL 5.7.5之前,Buffer Pool的大小只能在服务器启动时通过配置innodb_buffer_pool_size启动参数来调整大小,在服务器运行过程中是不允许调整该值的。不过设计MySQL的大佬在5.7.5以及之后的版本中支持了在服务器运行过程中调整Buffer Pool大小的功能,但是有一个问题,就是每次当我们要重新调整Buffer Pool大小时,都需要重新向操作系统申请一块连续的内存空间,然后将旧的Buffer Pool中的内容复制到这一块新空间,这是极其耗时的。所以设计MySQL的大佬们决定不再一次性为某个Buffer Pool实例向操作系统申请一大片连续的内存空间,而是以一个所谓的chunk为单位向操作系统申请空间。也就是说一个Buffer Pool实例其实是由若干个chunk组成的,一个chunk就代表一片连续的内存空间,里边儿包含了若干缓存页与其对应的控制块,画个图表示就是这样:\n上图代表的Buffer Pool就是由2个实例组成的,每个实例中又包含2个chunk。\n正是因为发明了这个chunk的概念,我们在服务器运行期间调整Buffer Pool的大小时就是以chunk为单位增加或者删除内存空间,而不需要重新向操作系统申请一片大的内存,然后进行缓存页的复制。这个所谓的chunk的大小是我们在启动操作MySQL服务器时通过innodb_buffer_pool_chunk_size启动参数指定的,它的默认值是134217728,也就是128M。不过需要注意的是,innodb_buffer_pool_chunk_size的值只能在服务器启动时指定,在服务器运行过程中是不可以修改的。 小贴士:为什么不允许在服务器运行过程中修改innodb_buffer_pool_chunk_size的值?还不是因为innodb_buffer_pool_chunk_size的值代表InnoDB向操作系统申请的一片连续的内存空间的大小,如果你在服务器运行过程中修改了该值,就意味着要重新向操作系统申请连续的内存空间并且将原先的缓存页和它们对应的控制块复制到这个新的内存空间中,这是十分耗时的操作!另外,这个innodb_buffer_pool_chunk_size的值并不包含缓存页对应的控制块的内存空间大小,所以实际上InnoDB向操作系统申请连续内存空间时,每个chunk的大小要比innodb_buffer_pool_chunk_size的值大一些,约5%。\n配置Buffer Pool时的注意事项 # innodb_buffer_pool_size必须是innodb_buffer_pool_chunk_size × innodb_buffer_pool_instances的倍数(这主要是想保证每一个Buffer Pool实例中包含的chunk数量相同)。\n假设我们指定的innodb_buffer_pool_chunk_size的值是128M,innodb_buffer_pool_instances的值是16,那么这两个值的乘积就是2G,也就是说innodb_buffer_pool_size的值必须是2G或者2G的整数倍。比方说我们在启动MySQL服务器是这样指定启动参数的:\nmysqld --innodb-buffer-pool-size=8G --innodb-buffer-pool-instances=16 默认的innodb_buffer_pool_chunk_size值是128M,指定的innodb_buffer_pool_instances的值是16,所以innodb_buffer_pool_size的值必须是2G或者2G的整数倍,上面例子中指定的innodb_buffer_pool_size的值是8G,符合规定,所以在服务器启动完成之后我们查看一下该变量的值就是我们指定的8G(8589934592字节):\nmysql\u0026gt; show variables like 'innodb_buffer_pool_size'; +-------------------------+------------+ | Variable_name | Value | +-------------------------+------------+ | innodb_buffer_pool_size | 8589934592 | +-------------------------+------------+ 1 row in set (0.00 sec)\n如果我们指定的innodb_buffer_pool_size大于2G并且不是2G的整数倍,那么服务器会自动的把innodb_buffer_pool_size的值调整为2G的整数倍,比方说我们在启动服务器时指定的innodb_buffer_pool_size的值是9G:\nmysqld --innodb-buffer-pool-size=9G --innodb-buffer-pool-instances=16 那么服务器会自动把innodb_buffer_pool_size的值调整为10G(10737418240字节),不信你看: mysql\u0026gt; show variables like 'innodb_buffer_pool_size'; +-------------------------+-------------+ | Variable_name | Value | +-------------------------+-------------+ | innodb_buffer_pool_size | 10737418240 | +-------------------------+-------------+ 1 row in set (0.01 sec)\n如果在服务器启动时,innodb_buffer_pool_chunk_size × innodb_buffer_pool_instances的值已经大于innodb_buffer_pool_size的值,那么innodb_buffer_pool_chunk_size的值会被服务器自动设置为innodb_buffer_pool_size/innodb_buffer_pool_instances的值。\n比方说我们在启动服务器时指定的innodb_buffer_pool_size的值为2G,innodb_buffer_pool_instances的值为16,innodb_buffer_pool_chunk_size的值为256M:\nmysqld --innodb-buffer-pool-size=2G --innodb-buffer-pool-instances=16 --innodb-buffer-pool-chunk-size=256M 由于256M × 16 = 4G,而4G \u0026gt; 2G,所以innodb_buffer_pool_chunk_size值会被服务器改写为innodb_buffer_pool_size/innodb_buffer_pool_instances的值,也就是:2G/16 = 128M(134217728字节),不信你看:\nmysql\u0026gt; show variables like \u0026#39;innodb_buffer_pool_chunk_size\u0026#39;; \\+-------------------------------\\+-----------\\+ | Variable_name | Value | \\+-------------------------------\\+-----------\\+ | innodb_buffer_pool_chunk_size | 134217728 | \\+-------------------------------\\+-----------\\+ 1 row in set (0.00 sec) ``` ## Buffer Pool中存储的其它信息 `Buffer Pool`的缓存页除了用来缓存磁盘上的页面以外,还可以存储锁信息、自适应哈希索引等信息,这些内容等我们之后遇到了再详细讨论~ ## 查看Buffer Pool的状态信息 设计`MySQL`的大佬贴心的给我们提供了`SHOW ENGINE INNODB STATUS`语句来查看关于`InnoDB`存储引擎运行过程中的一些状态信息,其中就包括`Buffer Pool`的一些信息,我们看一下(为了突出重点,我们只把输出中关于`Buffer Pool`的部分提取了出来): ``` mysql\u0026gt; SHOW ENGINE INNODB STATUS\\\\G # (...省略前面的许多状态) # BUFFER POOL AND MEMORY Total memory allocated 13218349056; Dictionary memory allocated 4014231 Buffer pool size 786432 Free buffers 8174 Database pages 710576 Old database pages 262143 Modified db pages 124941 Pending reads 0 Pending writes: LRU 0, flush list 0, single page 0 Pages made young 6195930012, not young 78247510485 108.18 youngs/s, 226.15 non-youngs/s Pages read 2748866728, created 29217873, written 4845680877 160.77 reads/s, 3.80 creates/s, 190.16 writes/s Buffer pool hit rate 956 / 1000, young-making rate 30 / 1000 not 605 / 1000 Pages read ahead 0.00/s, evicted without access 0.00/s, Random read ahead 0.00/s LRU len: 710576, unzip_LRU len: 118 I/O sum[134264]:cur[144], unzip sum[16]:cur[0] * * * (...省略后边的许多状态) mysql\u0026gt; ``` 我们来详细看一下这里边的每个值都代表什么意思: -`Total memory allocated`:代表`Buffer Pool`向操作系统申请的连续内存空间大小,包括全部控制块、缓存页、以及碎片的大小。 -`Dictionary memory allocated`:为数据字典信息分配的内存空间大小,注意这个内存空间和`Buffer Pool`没什么关系,不包括在`Total memory allocated`中。 -`Buffer pool size`:代表该`Buffer Pool`可以容纳多少缓存`页`,注意,单位是`页`! -`Free buffers`:代表当前`Buffer Pool`还有多少空闲缓存页,也就是`free链表`中还有多少个节点。 -`Database pages`:代表`LRU`链表中的页的数量,包含`young`和`old`两个区域的节点数量。 -`Old database pages`:代表`LRU`链表`old`区域的节点数量。 -`Modified db pages`:代表脏页数量,也就是`flush链表`中节点的数量。 -`Pending reads`:正在等待从磁盘上加载到`Buffer Pool`中的页面数量。 当准备从磁盘中加载某个页面时,会先为这个页面在`Buffer Pool`中分配一个缓存页以及它对应的控制块,然后把这个控制块添加到`LRU`的`old`区域的头部,但是这个时候真正的磁盘页并没有被加载进来,`Pending reads`的值会跟着加1。 + `Pending writes LRU`:即将从`LRU`链表中刷新到磁盘中的页面数量。 + `Pending writes flush list`:即将从`flush`链表中刷新到磁盘中的页面数量。 + `Pending writes single page`:即将以单个页面的形式刷新到磁盘中的页面数量。 + `Pages made young`:代表`LRU`链表中曾经从`old`区域移动到`young`区域头部的节点数量。 这里需要注意,一个节点每次只有从`old`区域移动到`young`区域头部时才会将`Pages made young`的值加1,也就是说如果该节点本来就在`young`区域,由于它符合在`young`区域1/4后边的要求,下一次访问这个页面时也会将它移动到`young`区域头部,但这个过程并不会导致`Pages made young`的值加1。 + `Page made not young`:在将`innodb_old_blocks_time`设置的值大于0时,首次访问或者后续访问某个处在`old`区域的节点时由于不符合时间间隔的限制而不能将其移动到`young`区域头部时,`Page made not young`的值会加1。 这里需要注意,对于处在`young`区域的节点,如果由于它在`young`区域的1/4处而导致它没有被移动到`young`区域头部,这样的访问并不会将`Page made not young`的值加1。 + `youngs/s`:代表每秒从`old`区域被移动到`young`区域头部的节点数量。 + `non-youngs/s`:代表每秒由于不满足时间限制而不能从`old`区域移动到`young`区域头部的节点数量。 + `Pages read`、`created`、`written`:代表读取,创建,写入了多少页。后边跟着读取、创建、写入的速率。 + `Buffer pool hit rate`:表示在过去某段时间,平均访问1000次页面,有多少次该页面已经被缓存到`Buffer Pool`了。 + `young-making rate`:表示在过去某段时间,平均访问1000次页面,有多少次访问使页面移动到`young`区域的头部了。 需要大家注意的一点是,这里统计的将页面移动到`young`区域的头部次数不仅仅包含从`old`区域移动到`young`区域头部的次数,还包括从`young`区域移动到`young`区域头部的次数(访问某个`young`区域的节点,只要该节点在`young`区域的1/4处往后,就会把它移动到`young`区域的头部)。 + `not (young-making rate)`:表示在过去某段时间,平均访问1000次页面,有多少次访问没有使页面移动到`young`区域的头部。 需要大家注意的一点是,这里统计的没有将页面移动到`young`区域的头部次数不仅仅包含因为设置了`innodb_old_blocks_time`系统变量而导致访问了`old`区域中的节点但没把它们移动到`young`区域的次数,还包含因为该节点在`young`区域的前1/4处而没有被移动到`young`区域头部的次数。 + `LRU len`:代表`LRU链表`中节点的数量。 + `unzip_LRU`:代表`unzip_LRU链表`中节点的数量(由于我们没有具体介绍过这个链表,现在可以忽略它的值)。 + `I/O sum`:最近50s读取磁盘页的总数。 + `I/O cur`:现在正在读取的磁盘页数量。 + `I/O unzip sum`:最近50s解压的页面数量。 + `I/O unzip cur`:正在解压的页面数量。 # 总结 1. 磁盘太慢,用内存作为缓存很有必要。 2. `Buffer Pool`本质上是`InnoDB`向操作系统申请的一段连续的内存空间,可以通过`innodb_buffer_pool_size`来调整它的大小。 3. `Buffer Pool`向操作系统申请的连续内存由控制块和缓存页组成,每个控制块和缓存页都是一一对应的,在填充足够多的控制块和缓存页的组合后,`Buffer Pool`剩余的空间可能产生不够填充一组控制块和缓存页,这部分空间不能被使用,也被称为`碎片`。 4. `InnoDB`使用了许多`链表`来管理`Buffer Pool`。 5. `free链表`中每一个节点都代表一个空闲的缓存页,在将磁盘中的页加载到`Buffer Pool`时,会从`free链表`中寻找空闲的缓存页。 6. 为了快速定位某个页是否被加载到`Buffer Pool`,使用`表空间号 + 页号`作为`key`,缓存页作为`value`,建立哈希表。 7. 在`Buffer Pool`中被修改的页称为`脏页`,脏页并不是立即刷新,而是被加入到`flush链表`中,待之后的某个时刻同步到磁盘上。 8. `LRU链表`分为`young`和`old`两个区域,可以通过`innodb_old_blocks_pct`来调节`old`区域所占的比例。首次从磁盘上加载到`Buffer Pool`的页会被放到`old`区域的头部,在`innodb_old_blocks_time`间隔时间内访问该页不会把它移动到`young`区域头部。在`Buffer Pool`没有可用的空闲缓存页时,会首先淘汰掉`old`区域的一些页。 9. 我们可以通过指定`innodb_buffer_pool_instances`来控制`Buffer Pool`实例的个数,每个`Buffer Pool`实例中都有各自独立的链表,互不干扰。 10. 自`MySQL 5.7.5`版本之后,可以在服务器运行过程中调整`Buffer Pool`大小。每个`Buffer Pool`实例由若干个`chunk`组成,每个`chunk`的大小可以在服务器启动时通过启动参数调整。 11. 可以用下面的命令查看`Buffer Pool`的状态信息: `SHOW ENGINE INNODB STATUS\\G` "},{"id":16,"href":"/zh/docs/technology/MySQL/MySQL%E6%98%AF%E6%80%8E%E6%A0%B7%E8%BF%90%E8%A1%8C%E7%9A%84/%E7%AC%AC17%E7%AB%A0_%E7%A5%9E%E5%85%B5%E5%88%A9%E5%99%A8-optimizer_trace%E8%A1%A8%E7%9A%84%E7%A5%9E%E5%99%A8%E5%8A%9F%E6%95%88/","title":"第17章_神兵利器-optimizer_trace表的神器功效","section":"My Sql是怎样运行的","content":"第17章 神兵利器-optimizer trace表的神器功效\n对于MySQL 5.6以及之前的版本来说,查询优化器就像是一个黑盒子一样,你只能通过EXPLAIN语句查看到最后优化器决定使用的执行计划,却无法知道它为什么做这个决策。这对于一部分喜欢刨根问底的小伙伴来说简直是灾难:“我就觉得使用其他的执行方案比EXPLAIN输出的这种方案强,凭什么优化器做的决定和我想的不一样呢?”\n在MySQL 5.6以及之后的版本中,设计MySQL的大佬贴心的为这部分小伙伴提出了一个optimizer trace的功能,这个功能可以让我们方便的查看优化器生成执行计划的整个过程,这个功能的开启与关闭由系统变量optimizer_trace决定,我们看一下: mysql\u0026gt; SHOW VARIABLES LIKE 'optimizer_trace'; +-----------------+--------------------------+ | Variable_name | Value | +-----------------+--------------------------+ | optimizer_trace | enabled=off,one_line=off | +-----------------+--------------------------+ 1 row in set (0.02 sec) 可以看到enabled值为off,表明这个功能默认是关闭的。 小贴士:one_line的值是控制输出格式的,如果为on那么所有输出都将在一行中展示,不适合人阅读,所以我们就保持其默认值为off吧。 如果想打开这个功能,必须首先把enabled的值改为on,就像这样: mysql\u0026gt; SET optimizer_trace=\u0026quot;enabled=on\u0026quot;; Query OK, 0 rows affected (0.00 sec) 然后我们就可以输入我们想要查看优化过程的查询语句,当该查询语句执行完成后,就可以到information_schema数据库下的OPTIMIZER_TRACE表中查看完整的优化过程。这个OPTIMIZER_TRACE表有4个列,分别是: - QUERY:表示我们的查询语句。 - TRACE:表示优化过程的JSON格式文本。 - MISSING_BYTES_BEYOND_MAX_MEM_SIZE:由于优化过程可能会输出很多,如果超过某个限制时,多余的文本将不会被显示,这个字段展示了被忽略的文本字节数。 - INSUFFICIENT_PRIVILEGES:表示是否没有权限查看优化过程,默认值是0,只有某些特殊情况下才会是1,我们暂时不关心这个字段的值。\n完整的使用optimizer trace功能的步骤总结如下: ```\n打开optimizer trace功能 (默认情况下它是关闭的): SET optimizer_trace=\u0026ldquo;enabled=on\u0026rdquo;;\n这里输入你自己的查询语句 SELECT \u0026hellip;;\n从OPTIMIZER_TRACE表中查看上一个查询的优化过程 SELECT * FROM information_schema.OPTIMIZER_TRACE;\n可能你还要观察其他语句执行的优化过程,重复上面的第2、3步 \u0026hellip;\n当你停止查看语句的优化过程时,把optimizer trace功能关闭 SET optimizer_trace=\u0026ldquo;enabled=off\u0026rdquo;; 现在我们有一个搜索条件比较多的查询语句,它的执行计划如下: mysql\u0026gt; EXPLAIN SELECT * FROM s1 WHERE -\u0026gt; key1 \u0026gt; \u0026lsquo;z\u0026rsquo; AND -\u0026gt; key2 \u0026lt; 1000000 AND -\u0026gt; key3 IN (\u0026lsquo;a\u0026rsquo;, \u0026lsquo;b\u0026rsquo;, \u0026lsquo;c\u0026rsquo;) AND -\u0026gt; common_field = \u0026lsquo;abc\u0026rsquo;; +\u0026mdash;-+\u0026mdash;\u0026mdash;\u0026mdash;\u0026mdash;-+\u0026mdash;\u0026mdash;-+\u0026mdash;\u0026mdash;\u0026mdash;\u0026mdash;+\u0026mdash;\u0026mdash;-+\u0026mdash;\u0026mdash;\u0026mdash;\u0026mdash;\u0026mdash;\u0026mdash;\u0026mdash;\u0026mdash;\u0026mdash;-+\u0026mdash;\u0026mdash;\u0026mdash;-+\u0026mdash;\u0026mdash;\u0026mdash;+\u0026mdash;\u0026mdash;+\u0026mdash;\u0026mdash;+\u0026mdash;\u0026mdash;\u0026mdash;-+\u0026mdash;\u0026mdash;\u0026mdash;\u0026mdash;\u0026mdash;\u0026mdash;\u0026mdash;\u0026mdash;\u0026mdash;\u0026mdash;\u0026mdash;\u0026mdash;+ | id | select_type | table | partitions | type | possible_keys | key | key_len | ref | rows | filtered | Extra | +\u0026mdash;-+\u0026mdash;\u0026mdash;\u0026mdash;\u0026mdash;-+\u0026mdash;\u0026mdash;-+\u0026mdash;\u0026mdash;\u0026mdash;\u0026mdash;+\u0026mdash;\u0026mdash;-+\u0026mdash;\u0026mdash;\u0026mdash;\u0026mdash;\u0026mdash;\u0026mdash;\u0026mdash;\u0026mdash;\u0026mdash;-+\u0026mdash;\u0026mdash;\u0026mdash;-+\u0026mdash;\u0026mdash;\u0026mdash;+\u0026mdash;\u0026mdash;+\u0026mdash;\u0026mdash;+\u0026mdash;\u0026mdash;\u0026mdash;-+\u0026mdash;\u0026mdash;\u0026mdash;\u0026mdash;\u0026mdash;\u0026mdash;\u0026mdash;\u0026mdash;\u0026mdash;\u0026mdash;\u0026mdash;\u0026mdash;+ | 1 | SIMPLE | s1 | NULL | range | idx_key2,idx_key1,idx_key3 | idx_key2 | 5 | NULL | 12 | 0.42 | Using index condition; Using where | +\u0026mdash;-+\u0026mdash;\u0026mdash;\u0026mdash;\u0026mdash;-+\u0026mdash;\u0026mdash;-+\u0026mdash;\u0026mdash;\u0026mdash;\u0026mdash;+\u0026mdash;\u0026mdash;-+\u0026mdash;\u0026mdash;\u0026mdash;\u0026mdash;\u0026mdash;\u0026mdash;\u0026mdash;\u0026mdash;\u0026mdash;-+\u0026mdash;\u0026mdash;\u0026mdash;-+\u0026mdash;\u0026mdash;\u0026mdash;+\u0026mdash;\u0026mdash;+\u0026mdash;\u0026mdash;+\u0026mdash;\u0026mdash;\u0026mdash;-+\u0026mdash;\u0026mdash;\u0026mdash;\u0026mdash;\u0026mdash;\u0026mdash;\u0026mdash;\u0026mdash;\u0026mdash;\u0026mdash;\u0026mdash;\u0026mdash;+ 1 row in set, 1 warning (0.00 sec) 可以看到该查询可能使用到的索引有3个,那么为什么优化器最终选择了idx_key2而不选择其他的索引或者直接全表扫描呢?这时候就可以通过otpimzer trace功能来查看优化器的具体工作过程: SET optimizer_trace=\u0026ldquo;enabled=on\u0026rdquo;;\nSELECT * FROM s1 WHERE key1 \u0026gt; \u0026lsquo;z\u0026rsquo; AND key2 \u0026lt; 1000000 AND key3 IN (\u0026lsquo;a\u0026rsquo;, \u0026lsquo;b\u0026rsquo;, \u0026lsquo;c\u0026rsquo;) AND common_field = \u0026lsquo;abc\u0026rsquo;;\nSELECT * FROM information_schema.OPTIMIZER_TRACE\\G\n``` 我们直接看一下通过查询OPTIMIZER_TRACE表得到的输出(我使用\\#后跟随注释的形式为大家解释了优化过程中的一些比较重要的点,大家重点关注一下):\n分析的查询语句是什么 QUERY: SELECT * FROM s1 WHERE key1 \u0026gt; \u0026#39;z\u0026#39; AND key2 \u0026lt; 1000000 AND key3 IN (\u0026#39;a\u0026#39;, \u0026#39;b\u0026#39;, \u0026#39;c\u0026#39;) AND common_field = \u0026#39;abc\u0026#39; 优化的具体过程 TRACE: \\{ \u0026#34;steps\u0026#34;: [ \\{ \u0026#34;join_preparation\u0026#34;: \\{ \\# prepare阶段 \u0026#34;select\\#\u0026#34;: 1, \u0026#34;steps\u0026#34;: \\[ \\{ \u0026#34;IN_uses_bisection\u0026#34;: true \\}, \\{ \u0026#34;expanded_query\u0026#34;: \u0026#34;/* select\\#1 */ select `s1`.`id` AS `id`,`s1`.`key1` AS `key1`,`s1`.`key2` AS `key2`,`s1`.`key3` AS `key3`,`s1`.`key_part1` AS `key_part1`,`s1`.`key_part2` AS `key_part2`,`s1`.`key_part3` AS `key_part3`,`s1`.`common_field` AS `common_field` from `s1` where (\\(`s1`.`key1` \u0026gt; \u0026#39;z\u0026#39;) and (`s1`.`key2` \u0026lt; 1000000) and (`s1`.`key3` in \\(\u0026#39;a\u0026#39;,\u0026#39;b\u0026#39;,\u0026#39;c\u0026#39;)\\) and (`s1`.`common_field` = \u0026#39;abc\u0026#39;)\\)\u0026#34; \\} ] /* steps */ \\} /* join_preparation */ \\}, \\{ \u0026#34;join_optimization\u0026#34;: \\{ \\# optimize阶段 \u0026#34;select\\#\u0026#34;: 1, \u0026#34;steps\u0026#34;: \\[ \\{ \u0026#34;condition_processing\u0026#34;: \\{ \\# 处理搜索条件 \u0026#34;condition\u0026#34;: \u0026#34;WHERE\u0026#34;, \\# 原始搜索条件 \u0026#34;original_condition\u0026#34;: \u0026#34;(\\(`s1`.`key1` \u0026gt; \u0026#39;z\u0026#39;) and (`s1`.`key2` \u0026lt; 1000000) and (`s1`.`key3` in \\(\u0026#39;a\u0026#39;,\u0026#39;b\u0026#39;,\u0026#39;c\u0026#39;)\\) and (`s1`.`common_field` = \u0026#39;abc\u0026#39;)\\)\u0026#34;, \u0026#34;steps\u0026#34;: \\[ \\{ \\# 等值传递转换 \u0026#34;transformation\u0026#34;: \u0026#34;equality_propagation\u0026#34;, \u0026#34;resulting_condition\u0026#34;: \u0026#34;(\\(`s1`.`key1` \u0026gt; \u0026#39;z\u0026#39;) and (`s1`.`key2` \u0026lt; 1000000) and (`s1`.`key3` in \\(\u0026#39;a\u0026#39;,\u0026#39;b\u0026#39;,\u0026#39;c\u0026#39;)\\) and (`s1`.`common_field` = \u0026#39;abc\u0026#39;)\\)\u0026#34; \\}, \\{ \\# 常量传递转换 \u0026#34;transformation\u0026#34;: \u0026#34;constant_propagation\u0026#34;, \u0026#34;resulting_condition\u0026#34;: \u0026#34;(\\(`s1`.`key1` \u0026gt; \u0026#39;z\u0026#39;) and (`s1`.`key2` \u0026lt; 1000000) and (`s1`.`key3` in \\(\u0026#39;a\u0026#39;,\u0026#39;b\u0026#39;,\u0026#39;c\u0026#39;)\\) and (`s1`.`common_field` = \u0026#39;abc\u0026#39;)\\)\u0026#34; \\}, \\{ \\# 去除没用的条件 \u0026#34;transformation\u0026#34;: \u0026#34;trivial_condition_removal\u0026#34;, \u0026#34;resulting_condition\u0026#34;: \u0026#34;(\\(`s1`.`key1` \u0026gt; \u0026#39;z\u0026#39;) and (`s1`.`key2` \u0026lt; 1000000) and (`s1`.`key3` in \\(\u0026#39;a\u0026#39;,\u0026#39;b\u0026#39;,\u0026#39;c\u0026#39;)\\) and (`s1`.`common_field` = \u0026#39;abc\u0026#39;)\\)\u0026#34; \\} \\] /* steps */ \\} /* condition_processing */ \\}, \\{ \\# 替换虚拟生成列 \u0026#34;substitute_generated_columns\u0026#34;: \\{ \\} /* substitute_generated_columns */ \\}, \\{ \\# 表的依赖信息 \u0026#34;table_dependencies\u0026#34;: [ \\{ \u0026#34;table\u0026#34;: \u0026#34;`s1`\u0026#34;, \u0026#34;row_may_be_null\u0026#34;: false, \u0026#34;map_bit\u0026#34;: 0, \u0026#34;depends_on_map_bits\u0026#34;: \\[ ] /* depends_on_map_bits */ \\} \\] /* table_dependencies */ \\}, \\{ \u0026#34;ref_optimizer_key_uses\u0026#34;: [ ] /* ref_optimizer_key_uses */ \\}, \\{ # 预估不同单表访问方法的访问成本 \u0026#34;rows_estimation\u0026#34;: [ { \u0026#34;table\u0026#34;: \u0026#34;`s1`\u0026#34;, \u0026#34;range_analysis\u0026#34;: { \u0026#34;table_scan\u0026#34;: { # 全表扫描的行数以及成本 \u0026#34;rows\u0026#34;: 9688, \u0026#34;cost\u0026#34;: 2036.7 } /* table_scan */, # 分析可能使用的索引 \u0026#34;potential_range_indexes\u0026#34;: [ { \u0026#34;index\u0026#34;: \u0026#34;PRIMARY\u0026#34;, # 主键不可用 \u0026#34;usable\u0026#34;: false, \u0026#34;cause\u0026#34;: \u0026#34;not_applicable\u0026#34; }, { \u0026#34;index\u0026#34;: \u0026#34;idx_key2\u0026#34;, # idx_key2可能被使用 \u0026#34;usable\u0026#34;: true, \u0026#34;key_parts\u0026#34;: [ \u0026#34;key2\u0026#34; ] /* key_parts */ }, { \u0026#34;index\u0026#34;: \u0026#34;idx_key1\u0026#34;, # idx_key1可能被使用 \u0026#34;usable\u0026#34;: true, \u0026#34;key_parts\u0026#34;: [ \u0026#34;key1\u0026#34;, \u0026#34;id\u0026#34; ] /* key_parts */ }, { \u0026#34;index\u0026#34;: \u0026#34;idx_key3\u0026#34;, # idx_key3可能被使用 \u0026#34;usable\u0026#34;: true, \u0026#34;key_parts\u0026#34;: [ \u0026#34;key3\u0026#34;, \u0026#34;id\u0026#34; ] /* key_parts */ }, { \u0026#34;index\u0026#34;: \u0026#34;idx_key_part\u0026#34;, # idx_keypart不可用 \u0026#34;usable\u0026#34;: false, \u0026#34;cause\u0026#34;: \u0026#34;not_applicable\u0026#34; } ] /* potential_range_indexes */, \u0026#34;setup_range_conditions\u0026#34;: [ ] /* setup_range_conditions */, \u0026#34;group_index_range\u0026#34;: { \u0026#34;chosen\u0026#34;: false, \u0026#34;cause\u0026#34;: \u0026#34;not_group_by_or_distinct\u0026#34; } /* group_index_range */, # 分析各种可能使用的索引的成本 \u0026#34;analyzing_range_alternatives\u0026#34;: { \u0026#34;range_scan_alternatives\u0026#34;: [ { # 使用idx_key2的成本分析 \u0026#34;index\u0026#34;: \u0026#34;idx_key2\u0026#34;, # 使用idx_key2的范围区间 \u0026#34;ranges\u0026#34;: [ \u0026#34;NULL \u0026lt; key2 \u0026lt; 1000000\u0026#34; ] /* ranges */, \u0026#34;index_dives_for_eq_ranges\u0026#34;: true, # 是否使用index dive \u0026#34;rowid_ordered\u0026#34;: false, # 使用该索引获取的记录是否按照主键排序 \u0026#34;using_mrr\u0026#34;: false, # 是否使用mrr \u0026#34;index_only\u0026#34;: false, # 是否是索引覆盖访问 \u0026#34;rows\u0026#34;: 12, # 使用该索引获取的记录条数 \u0026#34;cost\u0026#34;: 15.41, # 使用该索引的成本 \u0026#34;chosen\u0026#34;: true # 是否选择该索引 }, { # 使用idx_key1的成本分析 \u0026#34;index\u0026#34;: \u0026#34;idx_key1\u0026#34;, # 使用idx_key1的范围区间 \u0026#34;ranges\u0026#34;: [ \u0026#34;z \u0026lt; key1\u0026#34; ] /* ranges */, \u0026#34;index_dives_for_eq_ranges\u0026#34;: true, # 同上 \u0026#34;rowid_ordered\u0026#34;: false, # 同上 \u0026#34;using_mrr\u0026#34;: false, # 同上 \u0026#34;index_only\u0026#34;: false, # 同上 \u0026#34;rows\u0026#34;: 266, # 同上 \u0026#34;cost\u0026#34;: 320.21, # 同上 \u0026#34;chosen\u0026#34;: false, # 同上 \u0026#34;cause\u0026#34;: \u0026#34;cost\u0026#34; # 因为成本太大所以不选择该索引 }, { # 使用idx_key3的成本分析 \u0026#34;index\u0026#34;: \u0026#34;idx_key3\u0026#34;, # 使用idx_key3的范围区间 \u0026#34;ranges\u0026#34;: [ \u0026#34;a \u0026lt;= key3 \u0026lt;= a\u0026#34;, \u0026#34;b \u0026lt;= key3 \u0026lt;= b\u0026#34;, \u0026#34;c \u0026lt;= key3 \u0026lt;= c\u0026#34; ] /* ranges */, \u0026#34;index_dives_for_eq_ranges\u0026#34;: true, # 同上 \u0026#34;rowid_ordered\u0026#34;: false, # 同上 \u0026#34;using_mrr\u0026#34;: false, # 同上 \u0026#34;index_only\u0026#34;: false, # 同上 \u0026#34;rows\u0026#34;: 21, # 同上 \u0026#34;cost\u0026#34;: 28.21, # 同上 \u0026#34;chosen\u0026#34;: false, # 同上 \u0026#34;cause\u0026#34;: \u0026#34;cost\u0026#34; # 同上 } ] /* range_scan_alternatives */, # 分析使用索引合并的成本 \u0026#34;analyzing_roworder_intersect\u0026#34;: { \u0026#34;usable\u0026#34;: false, \u0026#34;cause\u0026#34;: \u0026#34;too_few_roworder_scans\u0026#34; } /* analyzing_roworder_intersect */ } /* analyzing_range_alternatives */, # 对于上述单表查询s1最优的访问方法 \u0026#34;chosen_range_access_summary\u0026#34;: { \u0026#34;range_access_plan\u0026#34;: { \u0026#34;type\u0026#34;: \u0026#34;range_scan\u0026#34;, \u0026#34;index\u0026#34;: \u0026#34;idx_key2\u0026#34;, \u0026#34;rows\u0026#34;: 12, \u0026#34;ranges\u0026#34;: [ \u0026#34;NULL \u0026lt; key2 \u0026lt; 1000000\u0026#34; ] /* ranges */ } /* range_access_plan */, \u0026#34;rows_for_plan\u0026#34;: 12, \u0026#34;cost_for_plan\u0026#34;: 15.41, \u0026#34;chosen\u0026#34;: true } /* chosen_range_access_summary */ } /* range_analysis */ } ] /* rows_estimation */ }, { # 分析各种可能的执行计划 #(对多表查询这可能有很多种不同的方案,单表查询的方案上面已经分析过了,直接选取idx_key2就好) \u0026#34;considered_execution_plans\u0026#34;: [ { \u0026#34;plan_prefix\u0026#34;: [ ] /* plan_prefix */, \u0026#34;table\u0026#34;: \u0026#34;`s1`\u0026#34;, \u0026#34;best_access_path\u0026#34;: { \u0026#34;considered_access_paths\u0026#34;: [ { \u0026#34;rows_to_scan\u0026#34;: 12, \u0026#34;access_type\u0026#34;: \u0026#34;range\u0026#34;, \u0026#34;range_details\u0026#34;: { \u0026#34;used_index\u0026#34;: \u0026#34;idx_key2\u0026#34; } /* range_details */, \u0026#34;resulting_rows\u0026#34;: 12, \u0026#34;cost\u0026#34;: 17.81, \u0026#34;chosen\u0026#34;: true } ] /* considered_access_paths */ } /* best_access_path */, \u0026#34;condition_filtering_pct\u0026#34;: 100, \u0026#34;rows_for_plan\u0026#34;: 12, \u0026#34;cost_for_plan\u0026#34;: 17.81, \u0026#34;chosen\u0026#34;: true } ] /* considered_execution_plans */ }, { # 尝试给查询添加一些其他的查询条件 \u0026#34;attaching_conditions_to_tables\u0026#34;: { \u0026#34;original_condition\u0026#34;: \u0026#34;((`s1`.`key1` \u0026gt; \u0026#39;z\u0026#39;) and (`s1`.`key2` \u0026lt; 1000000) and (`s1`.`key3` in (\u0026#39;a\u0026#39;,\u0026#39;b\u0026#39;,\u0026#39;c\u0026#39;)) and (`s1`.`common_field` = \u0026#39;abc\u0026#39;))\u0026#34;, \u0026#34;attached_conditions_computation\u0026#34;: [ ] /* attached_conditions_computation */, \u0026#34;attached_conditions_summary\u0026#34;: [ { \u0026#34;table\u0026#34;: \u0026#34;`s1`\u0026#34;, \u0026#34;attached\u0026#34;: \u0026#34;((`s1`.`key1` \u0026gt; \u0026#39;z\u0026#39;) and (`s1`.`key2` \u0026lt; 1000000) and (`s1`.`key3` in (\u0026#39;a\u0026#39;,\u0026#39;b\u0026#39;,\u0026#39;c\u0026#39;)) and (`s1`.`common_field` = \u0026#39;abc\u0026#39;))\u0026#34; } ] /* attached_conditions_summary */ } /* attaching_conditions_to_tables */ }, { # 再稍稍的改进一下执行计划 \u0026#34;refine_plan\u0026#34;: [ { \u0026#34;table\u0026#34;: \u0026#34;`s1`\u0026#34;, \u0026#34;pushed_index_condition\u0026#34;: \u0026#34;(`s1`.`key2` \u0026lt; 1000000)\u0026#34;, \u0026#34;table_condition_attached\u0026#34;: \u0026#34;((`s1`.`key1` \u0026gt; \u0026#39;z\u0026#39;) and (`s1`.`key3` in (\u0026#39;a\u0026#39;,\u0026#39;b\u0026#39;,\u0026#39;c\u0026#39;)) and (`s1`.`common_field` = \u0026#39;abc\u0026#39;))\u0026#34; } ] /* refine_plan */ } ] /* steps */ } /* join_optimization */ }, { \u0026#34;join_execution\u0026#34;: { # execute阶段 \u0026#34;select#\u0026#34;: 1, \u0026#34;steps\u0026#34;: [ ] /* steps */ } /* join_execution */ } \\] /* steps */ \\} 因优化过程文本太多而丢弃的文本字节大小,值为0时表示并没有丢弃 MISSING_BYTES_BEYOND_MAX_MEM_SIZE: 0 权限字段 INSUFFICIENT_PRIVILEGES: 0 1 row in set (0.00 sec) ``` 大家看到这个输出的第一感觉就是这文本也太多了点儿吧,其实这只是优化器执行过程中的一小部分,设计`MySQL`的大佬可能会在之后的版本中添加更多的优化过程信息。不过杂乱之中其实还是蛮有规律的,优化过程大致分为了三个阶段: + `prepare`阶段 + `optimize`阶段 + `execute`阶段 我们所说的基于成本的优化主要集中在`optimize`阶段,对于单表查询来说,我们主要关注`optimize`阶段的`\u0026#34;rows_estimation\u0026#34;`这个过程,这个过程深入分析了对单表查询的各种执行方案的成本;对于多表连接查询来说,我们更多需要关注`\u0026#34;considered_execution_plans\u0026#34;`这个过程,这个过程里会写明各种不同的连接方式所对应的成本。反正优化器最终会选择成本最低的那种方案来作为最终的执行计划,也就是我们使用`EXPLAIN`语句所展现出的那种方案。 如果有小伙伴对使用`EXPLAIN`语句展示出的对某个查询的执行计划很不理解,大家可以尝试使用`optimizer trace`功能来详细了解每一种执行方案对应的成本,相信这个功能能让大家更深入的了解`MySQL`查询优化器。 "},{"id":17,"href":"/zh/docs/technology/MySQL/MySQL%E6%98%AF%E6%80%8E%E6%A0%B7%E8%BF%90%E8%A1%8C%E7%9A%84/%E7%AC%AC16%E7%AB%A0_%E6%9F%A5%E8%AF%A2%E4%BC%98%E5%8C%96%E7%9A%84%E7%99%BE%E7%A7%91%E5%85%A8%E4%B9%A6-Explain%E8%AF%A6%E8%A7%A3%E4%B8%8B/","title":"第16章_查询优化的百科全书-Explain详解(下)","section":"My Sql是怎样运行的","content":"第16章 查询优化的百科全书-Explain详解(下)\n执行计划输出中各列详解 # 本章紧接着上一节的内容,继续介绍EXPLAIN语句输出的各个列的意思。\nExtra # 顾名思义,Extra列是用来说明一些额外信息的,我们可以通过这些额外信息来更准确的理解MySQL到底将如何执行给定的查询语句。MySQL提供的额外信息有好几十个,我们就不一个一个介绍了(都介绍了感觉我们的文章就跟文档差不多了~),所以我们只挑一些平时常见的或者比较重要的额外信息介绍给大家。\nNo tables used\n当查询语句的没有FROM子句时将会提示该额外信息,比如: mysql\u0026gt; EXPLAIN SELECT 1; +----+-------------+-------+------------+------+---------------+------+---------+------+------+----------+----------------+ | id | select_type | table | partitions | type | possible_keys | key | key_len | ref | rows | filtered | Extra | +----+-------------+-------+------------+------+---------------+------+---------+------+------+----------+----------------+ | 1 | SIMPLE | NULL | NULL | NULL | NULL | NULL | NULL | NULL | NULL | NULL | No tables used | +----+-------------+-------+------------+------+---------------+------+---------+------+------+----------+----------------+ 1 row in set, 1 warning (0.00 sec)\nImpossible WHERE\n查询语句的WHERE子句永远为FALSE时将会提示该额外信息,比方说: mysql\u0026gt; EXPLAIN SELECT * FROM s1 WHERE 1 != 1; +----+-------------+-------+------------+------+---------------+------+---------+------+------+----------+------------------+ | id | select_type | table | partitions | type | possible_keys | key | key_len | ref | rows | filtered | Extra | +----+-------------+-------+------------+------+---------------+------+---------+------+------+----------+------------------+ | 1 | SIMPLE | NULL | NULL | NULL | NULL | NULL | NULL | NULL | NULL | NULL | Impossible WHERE | +----+-------------+-------+------------+------+---------------+------+---------+------+------+----------+------------------+ 1 row in set, 1 warning (0.01 sec)\nNo matching min/max row\n当查询列表处有MIN或者MAX聚集函数,但是并没有符合WHERE子句中的搜索条件的记录时,将会提示该额外信息,比方说:\nmysql\u0026gt; EXPLAIN SELECT MIN(key1) FROM s1 WHERE key1 = 'abcdefg'; +----+-------------+-------+------------+------+---------------+------+---------+------+------+----------+-------------------------+ | id | select_type | table | partitions | type | possible_keys | key | key_len | ref | rows | filtered | Extra | +----+-------------+-------+------------+------+---------------+------+---------+------+------+----------+-------------------------+ | 1 | SIMPLE | NULL | NULL | NULL | NULL | NULL | NULL | NULL | NULL | NULL | No matching min/max row | +----+-------------+-------+------------+------+---------------+------+---------+------+------+----------+-------------------------+ 1 row in set, 1 warning (0.00 sec)\nUsing index\n当我们的查询列表以及搜索条件中只包含属于某个索引的列,也就是在可以使用索引覆盖的情况下,在Extra列将会提示该额外信息。比方说下面这个查询中只需要用到idx_key1而不需要回表操作: mysql\u0026gt; EXPLAIN SELECT key1 FROM s1 WHERE key1 = 'a'; +----+-------------+-------+------------+------+---------------+----------+---------+-------+------+----------+-------------+ | id | select_type | table | partitions | type | possible_keys | key | key_len | ref | rows | filtered | Extra | +----+-------------+-------+------------+------+---------------+----------+---------+-------+------+----------+-------------+ | 1 | SIMPLE | s1 | NULL | ref | idx_key1 | idx_key1 | 303 | const | 8 | 100.00 | Using index | +----+-------------+-------+------------+------+---------------+----------+---------+-------+------+----------+-------------+ 1 row in set, 1 warning (0.00 sec)\nUsing index condition\n有些搜索条件中虽然出现了索引列,但却不能使用到索引,比如下面这个查询:\nSELECT * FROM s1 WHERE key1 \u0026gt; 'z' AND key1 LIKE '%a'; 其中的key1 \u0026gt; 'z'可以使用到索引,但是key1 LIKE '%a'却无法使用到索引,在以前版本的MySQL中,是按照下面步骤来执行这个查询的: - 先根据key1 \u0026gt; 'z'这个条件,从二级索引idx_key1中获取到对应的二级索引记录。 - 根据上一步骤得到的二级索引记录中的主键值进行回表,找到完整的用户记录再检测该记录是否符合key1 LIKE '%a'这个条件,将符合条件的记录加入到最后的结果集。\n但是虽然key1 LIKE '%a'不能组成范围区间参与range访问方法的执行,但这个条件毕竟只涉及到了key1列,所以设计MySQL的大佬把上面的步骤改进了一下: - 先根据key1 \u0026gt; 'z'这个条件,定位到二级索引idx_key1中对应的二级索引记录。 - 对于指定的二级索引记录,先不着急回表,而是先检测一下该记录是否满足key1 LIKE '%a'这个条件,如果这个条件不满足,则该二级索引记录压根儿就没必要回表。 - 对于满足key1 LIKE '%a'这个条件的二级索引记录执行回表操作。\n我们说回表操作其实是一个随机IO,比较耗时,所以上述修改虽然只改进了一点点,但是可以省去好多回表操作的成本。设计MySQL的大佬们把他们的这个改进称之为索引条件下推(英文名:Index Condition Pushdown)。\n如果在查询语句的执行过程中将要使用索引条件下推这个特性,在Extra列中将会显示Using index condition,比如这样:\nmysql\u0026gt; EXPLAIN SELECT * FROM s1 WHERE key1 \u0026gt; 'z' AND key1 LIKE '%b'; +----+-------------+-------+------------+-------+---------------+----------+---------+------+------+----------+-----------------------+ | id | select_type | table | partitions | type | possible_keys | key | key_len | ref | rows | filtered | Extra | +----+-------------+-------+------------+-------+---------------+----------+---------+------+------+----------+-----------------------+ | 1 | SIMPLE | s1 | NULL | range | idx_key1 | idx_key1 | 303 | NULL | 266 | 100.00 | Using index condition | +----+-------------+-------+------------+-------+---------------+----------+---------+------+------+----------+-----------------------+ 1 row in set, 1 warning (0.01 sec)\nUsing where\n当我们使用全表扫描来执行对某个表的查询,并且该语句的WHERE子句中有针对该表的搜索条件时,在Extra列中会提示上述额外信息。比如下面这个查询:\nmysql\u0026gt; EXPLAIN SELECT * FROM s1 WHERE common_field = 'a'; +----+-------------+-------+------------+------+---------------+------+---------+------+------+----------+-------------+ | id | select_type | table | partitions | type | possible_keys | key | key_len | ref | rows | filtered | Extra | +----+-------------+-------+------------+------+---------------+------+---------+------+------+----------+-------------+ | 1 | SIMPLE | s1 | NULL | ALL | NULL | NULL | NULL | NULL | 9688 | 10.00 | Using where | +----+-------------+-------+------------+------+---------------+------+---------+------+------+----------+-------------+ 1 row in set, 1 warning (0.01 sec) 当使用索引访问来执行对某个表的查询,并且该语句的WHERE子句中有除了该索引包含的列之外的其他搜索条件时,在Extra列中也会提示上述额外信息。比如下面这个查询虽然使用idx_key1索引执行查询,但是搜索条件中除了包含key1的搜索条件key1 = 'a',还有包含common_field的搜索条件,所以Extra列会显示Using where的提示:\nmysql\u0026gt; EXPLAIN SELECT * FROM s1 WHERE key1 = 'a' AND common_field = 'a'; +----+-------------+-------+------------+------+---------------+----------+---------+-------+------+----------+-------------+ | id | select_type | table | partitions | type | possible_keys | key | key_len | ref | rows | filtered | Extra | +----+-------------+-------+------------+------+---------------+----------+---------+-------+------+----------+-------------+ | 1 | SIMPLE | s1 | NULL | ref | idx_key1 | idx_key1 | 303 | const | 8 | 10.00 | Using where | +----+-------------+-------+------------+------+---------------+----------+---------+-------+------+----------+-------------+ 1 row in set, 1 warning (0.00 sec)\nUsing join buffer (Block Nested Loop)\n在连接查询执行过程中,当被驱动表不能有效的利用索引加快访问速度,MySQL一般会为其分配一块名叫join buffer的内存块来加快查询速度,也就是我们所讲的基于块的嵌套循环算法,比如下面这个查询语句:\nmysql\u0026gt; EXPLAIN SELECT * FROM s1 INNER JOIN s2 ON s1.common_field = s2.common_field; +----+-------------+-------+------------+------+---------------+------+---------+------+------+----------+----------------------------------------------------+ | id | select_type | table | partitions | type | possible_keys | key | key_len | ref | rows | filtered | Extra | +----+-------------+-------+------------+------+---------------+------+---------+------+------+----------+----------------------------------------------------+ | 1 | SIMPLE | s1 | NULL | ALL | NULL | NULL | NULL | NULL | 9688 | 100.00 | NULL | | 1 | SIMPLE | s2 | NULL | ALL | NULL | NULL | NULL | NULL | 9954 | 10.00 | Using where; Using join buffer (Block Nested Loop) | +----+-------------+-------+------------+------+---------------+------+---------+------+------+----------+----------------------------------------------------+ 2 rows in set, 1 warning (0.03 sec)\n可以在对s2表的执行计划的Extra列显示了两个提示:\n+ Using join buffer (Block Nested Loop):这是因为对表s2的访问不能有效利用索引,只好退而求其次,使用join buffer来减少对s2表的访问次数,从而提高性能。\n+ Using where:可以看到查询语句中有一个s1.common_field = s2.common_field条件,因为s1是驱动表,s2是被驱动表,所以在访问s2表时,s1.common_field的值已经确定下来了,所以实际上查询s2表的条件就是s2.common_field = 一个常数,所以提示了Using where额外信息。\nNot exists\n当我们使用左(外)连接时,如果WHERE子句中包含要求被驱动表的某个列等于NULL值的搜索条件,而且那个列又是不允许存储NULL值的,那么在该表的执行计划的Extra列就会提示Not exists额外信息,比如这样:\nmysql\u0026gt; EXPLAIN SELECT * FROM s1 LEFT JOIN s2 ON s1.key1 = s2.key1 WHERE s2.id IS NULL; +----+-------------+-------+------------+------+---------------+----------+---------+-------------------+------+----------+-------------------------+ | id | select_type | table | partitions | type | possible_keys | key | key_len | ref | rows | filtered | Extra | +----+-------------+-------+------------+------+---------------+----------+---------+-------------------+------+----------+-------------------------+ | 1 | SIMPLE | s1 | NULL | ALL | NULL | NULL | NULL | NULL | 9688 | 100.00 | NULL | | 1 | SIMPLE | s2 | NULL | ref | idx_key1 | idx_key1 | 303 | xiaohaizi.s1.key1 | 1 | 10.00 | Using where; Not exists | +----+-------------+-------+------------+------+---------------+----------+---------+-------------------+------+----------+-------------------------+ 2 rows in set, 1 warning (0.00 sec) 上述查询中s1表是驱动表,s2表是被驱动表,s2.id列是不允许存储NULL值的,而WHERE子句中又包含s2.id IS NULL的搜索条件,这意味着必定是驱动表的记录在被驱动表中找不到匹配ON子句条件的记录才会把该驱动表的记录加入到最终的结果集,所以对于某条驱动表中的记录来说,如果能在被驱动表中找到1条符合ON子句条件的记录,那么该驱动表的记录就不会被加入到最终的结果集,也就是说我们没有必要到被驱动表中找到全部符合ON子句条件的记录,这样可以稍微节省一点性能。 小贴士:右(外)连接可以被转换为左(外)连接,所以就不提右(外)连接的情况了。\nUsing intersect(...)、Using union(...)和Using sort_union(...)\n如果执行计划的Extra列出现了Using intersect(...)提示,说明准备使用Intersect索引合并的方式执行查询,括号中的...表示需要进行索引合并的索引名称;如果出现了Using union(...)提示,说明准备使用Union索引合并的方式执行查询;出现了Using sort_union(...)提示,说明准备使用Sort-Union索引合并的方式执行查询。比如这个查询的执行计划:\nmysql\u0026gt; EXPLAIN SELECT * FROM s1 WHERE key1 = 'a' AND key3 = 'a'; +----+-------------+-------+------------+-------------+-------------------+-------------------+---------+------+------+----------+-------------------------------------------------+ | id | select_type | table | partitions | type | possible_keys | key | key_len | ref | rows | filtered | Extra | +----+-------------+-------+------------+-------------+-------------------+-------------------+---------+------+------+----------+-------------------------------------------------+ | 1 | SIMPLE | s1 | NULL | index_merge | idx_key1,idx_key3 | idx_key3,idx_key1 | 303,303 | NULL | 1 | 100.00 | Using intersect(idx_key3,idx_key1); Using where | +----+-------------+-------+------------+-------------+-------------------+-------------------+---------+------+------+----------+-------------------------------------------------+ 1 row in set, 1 warning (0.01 sec) 其中Extra列就显示了Using intersect(idx_key3,idx_key1),表明MySQL即将使用idx_key3和idx_key1这两个索引进行Intersect索引合并的方式执行查询。\n小贴士:剩下两种类型的索引合并的Extra列信息就不一一举例子了,自己写个查询看看呗~\nZero limit\n当我们的LIMIT子句的参数为0时,表示压根儿不打算从表中读出任何记录,将会提示该额外信息,比如这样:\nmysql\u0026gt; EXPLAIN SELECT * FROM s1 LIMIT 0; +----+-------------+-------+------------+------+---------------+------+---------+------+------+----------+------------+ | id | select_type | table | partitions | type | possible_keys | key | key_len | ref | rows | filtered | Extra | +----+-------------+-------+------------+------+---------------+------+---------+------+------+----------+------------+ | 1 | SIMPLE | NULL | NULL | NULL | NULL | NULL | NULL | NULL | NULL | NULL | Zero limit | +----+-------------+-------+------------+------+---------------+------+---------+------+------+----------+------------+ 1 row in set, 1 warning (0.00 sec)\nUsing filesort\n有一些情况下对结果集中的记录进行排序是可以使用到索引的,比如下面这个查询: mysql\u0026gt; EXPLAIN SELECT * FROM s1 ORDER BY key1 LIMIT 10; +----+-------------+-------+------------+-------+---------------+----------+---------+------+------+----------+-------+ | id | select_type | table | partitions | type | possible_keys | key | key_len | ref | rows | filtered | Extra | +----+-------------+-------+------------+-------+---------------+----------+---------+------+------+----------+-------+ | 1 | SIMPLE | s1 | NULL | index | NULL | idx_key1 | 303 | NULL | 10 | 100.00 | NULL | +----+-------------+-------+------------+-------+---------------+----------+---------+------+------+----------+-------+ 1 row in set, 1 warning (0.03 sec) 这个查询语句可以利用idx_key1索引直接取出key1列的10条记录,然后再进行回表操作就好了。但是很多情况下排序操作无法使用到索引,只能在内存中(记录较少的时候)或者磁盘中(记录较多的时候)进行排序,设计MySQL的大佬把这种在内存中或者磁盘上进行排序的方式统称为文件排序(英文名:filesort)。如果某个查询需要使用文件排序的方式执行查询,就会在执行计划的Extra列中显示Using filesort提示,比如这样:\nmysql\u0026gt; EXPLAIN SELECT * FROM s1 ORDER BY common_field LIMIT 10; +----+-------------+-------+------------+------+---------------+------+---------+------+------+----------+----------------+ | id | select_type | table | partitions | type | possible_keys | key | key_len | ref | rows | filtered | Extra | +----+-------------+-------+------------+------+---------------+------+---------+------+------+----------+----------------+ | 1 | SIMPLE | s1 | NULL | ALL | NULL | NULL | NULL | NULL | 9688 | 100.00 | Using filesort | +----+-------------+-------+------------+------+---------------+------+---------+------+------+----------+----------------+ 1 row in set, 1 warning (0.00 sec) 需要注意的是,如果查询中需要使用filesort的方式进行排序的记录非常多,那么这个过程是很耗费性能的,我们最好想办法将使用文件排序的执行方式改为使用索引进行排序。\nUsing temporary\n在许多查询的执行过程中,MySQL可能会借助临时表来完成一些功能,比如去重、排序之类的,比如我们在执行许多包含DISTINCT、GROUP BY、UNION等子句的查询过程中,如果不能有效利用索引来完成查询,MySQL很有可能寻求通过建立内部的临时表来执行查询。如果查询中使用到了内部的临时表,在执行计划的Extra列将会显示Using temporary提示,比方说这样:\nmysql\u0026gt; EXPLAIN SELECT DISTINCT common_field FROM s1; +----+-------------+-------+------------+------+---------------+------+---------+------+------+----------+-----------------+ | id | select_type | table | partitions | type | possible_keys | key | key_len | ref | rows | filtered | Extra | +----+-------------+-------+------------+------+---------------+------+---------+------+------+----------+-----------------+ | 1 | SIMPLE | s1 | NULL | ALL | NULL | NULL | NULL | NULL | 9688 | 100.00 | Using temporary | +----+-------------+-------+------------+------+---------------+------+---------+------+------+----------+-----------------+ 1 row in set, 1 warning (0.00 sec) 再比如: mysql\u0026gt; EXPLAIN SELECT common_field, COUNT(*) AS amount FROM s1 GROUP BY common_field; +----+-------------+-------+------------+------+---------------+------+---------+------+------+----------+---------------------------------+ | id | select_type | table | partitions | type | possible_keys | key | key_len | ref | rows | filtered | Extra | +----+-------------+-------+------------+------+---------------+------+---------+------+------+----------+---------------------------------+ | 1 | SIMPLE | s1 | NULL | ALL | NULL | NULL | NULL | NULL | 9688 | 100.00 | Using temporary; Using filesort | +----+-------------+-------+------------+------+---------------+------+---------+------+------+----------+---------------------------------+ 1 row in set, 1 warning (0.00 sec) 不知道大家注意到没有,上述执行计划的Extra列不仅仅包含Using temporary提示,还包含Using filesort提示,可是我们的查询语句中明明没有写ORDER BY子句呀?这是因为MySQL会在包含GROUP BY子句的查询中默认添加上ORDER BY子句,也就是说上述查询其实和下面这个查询等价:\nEXPLAIN SELECT common_field, COUNT(*) AS amount FROM s1 GROUP BY common_field ORDER BY common_field; 如果我们并不想为包含GROUP BY子句的查询进行排序,需要我们显式的写上ORDER BY NULL,就像这样: mysql\u0026gt; EXPLAIN SELECT common_field, COUNT(*) AS amount FROM s1 GROUP BY common_field ORDER BY NULL; +----+-------------+-------+------------+------+---------------+------+---------+------+------+----------+-----------------+ | id | select_type | table | partitions | type | possible_keys | key | key_len | ref | rows | filtered | Extra | +----+-------------+-------+------------+------+---------------+------+---------+------+------+----------+-----------------+ | 1 | SIMPLE | s1 | NULL | ALL | NULL | NULL | NULL | NULL | 9688 | 100.00 | Using temporary | +----+-------------+-------+------------+------+---------------+------+---------+------+------+----------+-----------------+ 1 row in set, 1 warning (0.00 sec) 这回执行计划中就没有Using filesort的提示了,也就意味着执行查询时可以省去对记录进行文件排序的成本了。\n另外,执行计划中出现Using temporary并不是一个好的征兆,因为建立与维护临时表要付出很大成本的,所以我们最好能使用索引来替代掉使用临时表,比方说下面这个包含GROUP BY子句的查询就不需要使用临时表: mysql\u0026gt; EXPLAIN SELECT key1, COUNT(*) AS amount FROM s1 GROUP BY key1; +----+-------------+-------+------------+-------+---------------+----------+---------+------+------+----------+-------------+ | id | select_type | table | partitions | type | possible_keys | key | key_len | ref | rows | filtered | Extra | +----+-------------+-------+------------+-------+---------------+----------+---------+------+------+----------+-------------+ | 1 | SIMPLE | s1 | NULL | index | idx_key1 | idx_key1 | 303 | NULL | 9688 | 100.00 | Using index | +----+-------------+-------+------------+-------+---------------+----------+---------+------+------+----------+-------------+ 1 row in set, 1 warning (0.00 sec) 从Extra的Using index的提示里我们可以看出,上述查询只需要扫描idx_key1索引就可以搞定了,不再需要临时表了。\nStart temporary, End temporary\n我们前面介绍子查询的时候说过,查询优化器会优先尝试将IN子查询转换成semi-join,而semi-join又有好多种执行策略,当执行策略为DuplicateWeedout时,也就是通过建立临时表来实现为外层查询中的记录进行去重操作时,驱动表查询执行计划的Extra列将显示Start temporary提示,被驱动表查询执行计划的Extra列将显示End temporary提示,就是这样:\nmysql\u0026gt; EXPLAIN SELECT * FROM s1 WHERE key1 IN (SELECT key3 FROM s2 WHERE common_field = 'a'); +----+-------------+-------+------------+------+---------------+----------+---------+-------------------+------+----------+------------------------------+ | id | select_type | table | partitions | type | possible_keys | key | key_len | ref | rows | filtered | Extra | +----+-------------+-------+------------+------+---------------+----------+---------+-------------------+------+----------+------------------------------+ | 1 | SIMPLE | s2 | NULL | ALL | idx_key3 | NULL | NULL | NULL | 9954 | 10.00 | Using where; Start temporary | | 1 | SIMPLE | s1 | NULL | ref | idx_key1 | idx_key1 | 303 | xiaohaizi.s2.key3 | 1 | 100.00 | End temporary | +----+-------------+-------+------------+------+---------------+----------+---------+-------------------+------+----------+------------------------------+ 2 rows in set, 1 warning (0.00 sec)\nLooseScan\n在将In子查询转为semi-join时,如果采用的是LooseScan执行策略,则在驱动表执行计划的Extra列就是显示LooseScan提示,比如这样:\nmysql\u0026gt; EXPLAIN SELECT * FROM s1 WHERE key3 IN (SELECT key1 FROM s2 WHERE key1 \u0026gt; 'z'); +----+-------------+-------+------------+-------+---------------+----------+---------+-------------------+------+----------+-------------------------------------+ | id | select_type | table | partitions | type | possible_keys | key | key_len | ref | rows | filtered | Extra | +----+-------------+-------+------------+-------+---------------+----------+---------+-------------------+------+----------+-------------------------------------+ | 1 | SIMPLE | s2 | NULL | range | idx_key1 | idx_key1 | 303 | NULL | 270 | 100.00 | Using where; Using index; LooseScan | | 1 | SIMPLE | s1 | NULL | ref | idx_key3 | idx_key3 | 303 | xiaohaizi.s2.key1 | 1 | 100.00 | NULL | +----+-------------+-------+------------+-------+---------------+----------+---------+-------------------+------+----------+-------------------------------------+ 2 rows in set, 1 warning (0.01 sec)\nFirstMatch(tbl_name)\n在将In子查询转为semi-join时,如果采用的是FirstMatch执行策略,则在被驱动表执行计划的Extra列就是显示FirstMatch(tbl_name)提示,比如这样: mysql\u0026gt; EXPLAIN SELECT * FROM s1 WHERE common_field IN (SELECT key1 FROM s2 where s1.key3 = s2.key3); +----+-------------+-------+------------+------+-------------------+----------+---------+-------------------+------+----------+-----------------------------+ | id | select_type | table | partitions | type | possible_keys | key | key_len | ref | rows | filtered | Extra | +----+-------------+-------+------------+------+-------------------+----------+---------+-------------------+------+----------+-----------------------------+ | 1 | SIMPLE | s1 | NULL | ALL | idx_key3 | NULL | NULL | NULL | 9688 | 100.00 | Using where | | 1 | SIMPLE | s2 | NULL | ref | idx_key1,idx_key3 | idx_key3 | 303 | xiaohaizi.s1.key3 | 1 | 4.87 | Using where; FirstMatch(s1) | +----+-------------+-------+------------+------+-------------------+----------+---------+-------------------+------+----------+-----------------------------+ 2 rows in set, 2 warnings (0.00 sec)\nJson格式的执行计划 # 我们上面介绍的EXPLAIN语句输出中缺少了一个衡量执行计划好坏的重要属性 —— 成本。不过设计MySQL的大佬贴心的为我们提供了一种查看某个执行计划花费的成本的方式:\n在EXPLAIN单词和真正的查询语句中间加上FORMAT=JSON。 这样我们就可以得到一个json格式的执行计划,里边儿包含该计划花费的成本,比如这样: ``` mysql\u0026gt; EXPLAIN FORMAT=JSON SELECT * FROM s1 INNER JOIN s2 ON s1.key1 = s2.key2 WHERE s1.common_field = \u0026lsquo;a\u0026rsquo;\\G *************************** 1. row ***************************\nEXPLAIN: { \u0026ldquo;query_block\u0026rdquo;: { \u0026ldquo;select_id\u0026rdquo;: 1, # 整个查询语句只有1个SELECT关键字,该关键字对应的id号为1 \u0026ldquo;cost_info\u0026rdquo;: { \u0026ldquo;query_cost\u0026rdquo;: \u0026ldquo;3197.16\u0026rdquo; # 整个查询的执行成本预计为3197.16 }, \u0026ldquo;nested_loop\u0026rdquo;: [ # 几个表之间采用嵌套循环连接算法执行\n# 以下是参与嵌套循环连接算法的各个表的信息 { \u0026quot;table\u0026quot;: { \u0026quot;table_name\u0026quot;: \u0026quot;s1\u0026quot;, # s1表是驱动表 \u0026quot;access_type\u0026quot;: \u0026quot;ALL\u0026quot;, # 访问方法为ALL,意味着使用全表扫描访问 \u0026quot;possible_keys\u0026quot;: [ # 可能使用的索引 \u0026quot;idx_key1\u0026quot; ], \u0026quot;rows_examined_per_scan\u0026quot;: 9688, # 查询一次s1表大致需要扫描9688条记录 \u0026quot;rows_produced_per_join\u0026quot;: 968, # 驱动表s1的扇出是968 \u0026quot;filtered\u0026quot;: \u0026quot;10.00\u0026quot;, # condition filtering代表的百分比 \u0026quot;cost_info\u0026quot;: { \u0026quot;read_cost\u0026quot;: \u0026quot;1840.84\u0026quot;, # 稍后解释 \u0026quot;eval_cost\u0026quot;: \u0026quot;193.76\u0026quot;, # 稍后解释 \u0026quot;prefix_cost\u0026quot;: \u0026quot;2034.60\u0026quot;, # 单次查询s1表总共的成本 \u0026quot;data_read_per_join\u0026quot;: \u0026quot;1M\u0026quot; # 读取的数据量 }, \u0026quot;used_columns\u0026quot;: [ # 执行查询中涉及到的列 \u0026quot;id\u0026quot;, \u0026quot;key1\u0026quot;, \u0026quot;key2\u0026quot;, \u0026quot;key3\u0026quot;, \u0026quot;key_part1\u0026quot;, \u0026quot;key_part2\u0026quot;, \u0026quot;key_part3\u0026quot;, \u0026quot;common_field\u0026quot; ], # 对s1表访问时针对单表查询的条件 \u0026quot;attached_condition\u0026quot;: \u0026quot;((`xiaohaizi`.`s1`.`common_field` = 'a') and (`xiaohaizi`.`s1`.`key1` is not null))\u0026quot; } }, { \u0026quot;table\u0026quot;: { \u0026quot;table_name\u0026quot;: \u0026quot;s2\u0026quot;, # s2表是被驱动表 \u0026quot;access_type\u0026quot;: \u0026quot;ref\u0026quot;, # 访问方法为ref,意味着使用索引等值匹配的方式访问 \u0026quot;possible_keys\u0026quot;: [ # 可能使用的索引 \u0026quot;idx_key2\u0026quot; ], \u0026quot;key\u0026quot;: \u0026quot;idx_key2\u0026quot;, # 实际使用的索引 \u0026quot;used_key_parts\u0026quot;: [ # 使用到的索引列 \u0026quot;key2\u0026quot; ], \u0026quot;key_length\u0026quot;: \u0026quot;5\u0026quot;, # key_len \u0026quot;ref\u0026quot;: [ # 与key2列进行等值匹配的对象 \u0026quot;xiaohaizi.s1.key1\u0026quot; ], \u0026quot;rows_examined_per_scan\u0026quot;: 1, # 查询一次s2表大致需要扫描1条记录 \u0026quot;rows_produced_per_join\u0026quot;: 968, # 被驱动表s2的扇出是968(由于后边没有多余的表进行连接,所以这个值也没什么用) \u0026quot;filtered\u0026quot;: \u0026quot;100.00\u0026quot;, # condition filtering代表的百分比 # s2表使用索引进行查询的搜索条件 \u0026quot;index_condition\u0026quot;: \u0026quot;(`xiaohaizi`.`s1`.`key1` = `xiaohaizi`.`s2`.`key2`)\u0026quot;, \u0026quot;cost_info\u0026quot;: { \u0026quot;read_cost\u0026quot;: \u0026quot;968.80\u0026quot;, # 稍后解释 \u0026quot;eval_cost\u0026quot;: \u0026quot;193.76\u0026quot;, # 稍后解释 \u0026quot;prefix_cost\u0026quot;: \u0026quot;3197.16\u0026quot;, # 单次查询s1、多次查询s2表总共的成本 \u0026quot;data_read_per_join\u0026quot;: \u0026quot;1M\u0026quot; # 读取的数据量 }, \u0026quot;used_columns\u0026quot;: [ # 执行查询中涉及到的列 \u0026quot;id\u0026quot;, \u0026quot;key1\u0026quot;, \u0026quot;key2\u0026quot;, \u0026quot;key3\u0026quot;, \u0026quot;key_part1\u0026quot;, \u0026quot;key_part2\u0026quot;, \u0026quot;key_part3\u0026quot;, \u0026quot;common_field\u0026quot; ] } } ] } } 1 row in set, 2 warnings (0.00 sec) 我们使用#后边跟随注释的形式为大家解释了EXPLAIN FORMAT=JSON语句的输出内容,但是大家可能有疑问\u0026ldquo;cost_info\u0026rdquo;里边的成本看着怪怪的,它们是怎么计算出来的?先看s1表的\u0026ldquo;cost_info\u0026rdquo;部分: \u0026ldquo;cost_info\u0026rdquo;: { \u0026ldquo;read_cost\u0026rdquo;: \u0026ldquo;1840.84\u0026rdquo;, \u0026ldquo;eval_cost\u0026rdquo;: \u0026ldquo;193.76\u0026rdquo;, \u0026ldquo;prefix_cost\u0026rdquo;: \u0026ldquo;2034.60\u0026rdquo;, \u0026ldquo;data_read_per_join\u0026rdquo;: \u0026ldquo;1M\u0026rdquo; } ```\nread_cost是由下面这两部分组成的:\n+ `IO`成本 + 检测`rows × (1 - filter)`条记录的`CPU`成本 小贴士:rows和filter都是我们前面介绍执行计划的输出列,在JSON格式的执行计划中,rows相当于rows_examined_per_scan,filtered名称不变。\neval_cost是这样计算的:\n检测 rows × filter条记录的成本。\nprefix_cost就是单独查询s1表的成本,也就是:\nread_cost + eval_cost\ndata_read_per_join表示在此次查询中需要读取的数据量,我们就不多介绍这个了。\n小贴士:大家其实没必要关注MySQL为什么使用这么古怪的方式计算出read_cost和eval_cost,关注prefix_cost是查询s1表的成本就好了。 对于s2表的\u0026quot;cost_info\u0026quot;部分是这样的: \u0026quot;cost_info\u0026quot;: { \u0026quot;read_cost\u0026quot;: \u0026quot;968.80\u0026quot;, \u0026quot;eval_cost\u0026quot;: \u0026quot;193.76\u0026quot;, \u0026quot;prefix_cost\u0026quot;: \u0026quot;3197.16\u0026quot;, \u0026quot;data_read_per_join\u0026quot;: \u0026quot;1M\u0026quot; } 由于s2表是被驱动表,所以可能被读取多次,这里的read_cost和eval_cost是访问多次s2表后累加起来的值,大家主要关注里边儿的prefix_cost的值代表的是整个连接查询预计的成本,也就是单次查询s1表和多次查询s2表后的成本的和,也就是: 968.80 + 193.76 + 2034.60 = 3197.16\nExtented EXPLAIN # 最后,设计MySQL的大佬还为我们留了个彩蛋,在我们使用EXPLAIN语句查看了某个查询的执行计划后,紧接着还可以使用SHOW WARNINGS语句查看与这个查询的执行计划有关的一些扩展信息,比如这样: ``` mysql\u0026gt; EXPLAIN SELECT s1.key1, s2.key1 FROM s1 LEFT JOIN s2 ON s1.key1 = s2.key1 WHERE s2.common_field IS NOT NULL; +\u0026mdash;-+\u0026mdash;\u0026mdash;\u0026mdash;\u0026mdash;-+\u0026mdash;\u0026mdash;-+\u0026mdash;\u0026mdash;\u0026mdash;\u0026mdash;+\u0026mdash;\u0026mdash;+\u0026mdash;\u0026mdash;\u0026mdash;\u0026mdash;\u0026mdash;+\u0026mdash;\u0026mdash;\u0026mdash;-+\u0026mdash;\u0026mdash;\u0026mdash;+\u0026mdash;\u0026mdash;\u0026mdash;\u0026mdash;\u0026mdash;\u0026mdash;-+\u0026mdash;\u0026mdash;+\u0026mdash;\u0026mdash;\u0026mdash;-+\u0026mdash;\u0026mdash;\u0026mdash;\u0026mdash;-+ | id | select_type | table | partitions | type | possible_keys | key | key_len | ref | rows | filtered | Extra | +\u0026mdash;-+\u0026mdash;\u0026mdash;\u0026mdash;\u0026mdash;-+\u0026mdash;\u0026mdash;-+\u0026mdash;\u0026mdash;\u0026mdash;\u0026mdash;+\u0026mdash;\u0026mdash;+\u0026mdash;\u0026mdash;\u0026mdash;\u0026mdash;\u0026mdash;+\u0026mdash;\u0026mdash;\u0026mdash;-+\u0026mdash;\u0026mdash;\u0026mdash;+\u0026mdash;\u0026mdash;\u0026mdash;\u0026mdash;\u0026mdash;\u0026mdash;-+\u0026mdash;\u0026mdash;+\u0026mdash;\u0026mdash;\u0026mdash;-+\u0026mdash;\u0026mdash;\u0026mdash;\u0026mdash;-+ | 1 | SIMPLE | s2 | NULL | ALL | idx_key1 | NULL | NULL | NULL | 9954 | 90.00 | Using where | | 1 | SIMPLE | s1 | NULL | ref | idx_key1 | idx_key1 | 303 | xiaohaizi.s2.key1 | 1 | 100.00 | Using index | +\u0026mdash;-+\u0026mdash;\u0026mdash;\u0026mdash;\u0026mdash;-+\u0026mdash;\u0026mdash;-+\u0026mdash;\u0026mdash;\u0026mdash;\u0026mdash;+\u0026mdash;\u0026mdash;+\u0026mdash;\u0026mdash;\u0026mdash;\u0026mdash;\u0026mdash;+\u0026mdash;\u0026mdash;\u0026mdash;-+\u0026mdash;\u0026mdash;\u0026mdash;+\u0026mdash;\u0026mdash;\u0026mdash;\u0026mdash;\u0026mdash;\u0026mdash;-+\u0026mdash;\u0026mdash;+\u0026mdash;\u0026mdash;\u0026mdash;-+\u0026mdash;\u0026mdash;\u0026mdash;\u0026mdash;-+ 2 rows in set, 1 warning (0.00 sec)\nmysql\u0026gt; SHOW WARNINGS\\G *************************** 1. row ************************** Level: Note Code: 1003 Message: / select#1 */ select xiaohaizi.s1.key1 AS key1,xiaohaizi.s2.key1 AS key1 from xiaohaizi.s1 join xiaohaizi.s2 where ( ‘xiaohaizi‘.‘s1‘.‘key1‘=‘xiaohaizi‘.‘s2‘.‘key1‘)and(‘xiaohaizi‘.‘s2‘.‘commonfield‘isnotnull)`xiaohaizi`.`s1`.`key1` = `xiaohaizi`.`s2`.`key1`) and (`xiaohaizi`.`s2`.`common_field` is not null) 1 row in set (0.00 sec) ``` 大家可以看到SHOW WARNINGS展示出来的信息有三个字段,分别是Level、Code、Message。我们最常见的就是Code为1003的信息,当Code值为1003时,Message字段展示的信息类似于查询优化器将我们的查询语句重写后的语句。比如我们上面的查询本来是一个左(外)连接查询,但是有一个s2.common_field IS NOT NULL的条件,着就会导致查询优化器把左(外)连接查询优化为内连接查询,从SHOW WARNINGS的Message字段也可以看出来,原本的LEFT JOIN已经变成了JOIN。\n但是大家一定要注意,我们说Message字段展示的信息类似于查询优化器将我们的查询语句重写后的语句,并不是等价于,也就是说Message字段展示的信息并不是标准的查询语句,在很多情况下并不能直接拿到黑框框中运行,它只能作为帮助我们理解查MySQL将如何执行查询语句的一个参考依据而已。\n"},{"id":18,"href":"/zh/docs/technology/MySQL/MySQL%E6%98%AF%E6%80%8E%E6%A0%B7%E8%BF%90%E8%A1%8C%E7%9A%84/%E7%AC%AC15%E7%AB%A0_%E6%9F%A5%E8%AF%A2%E4%BC%98%E5%8C%96%E7%9A%84%E7%99%BE%E7%A7%91%E5%85%A8%E4%B9%A6-Explain%E8%AF%A6%E8%A7%A3%E4%B8%8A/","title":"第15章_查询优化的百科全书-Explain详解(上)","section":"My Sql是怎样运行的","content":"第15章 查询优化的百科全书-Explain详解(上)\n一条查询语句在经过MySQL查询优化器的各种基于成本和规则的优化会后生成一个所谓的执行计划,这个执行计划展示了接下来具体执行查询的方式,比如多表连接的顺序是什么,对于每个表采用什么访问方法来具体执行查询等等。设计MySQL的大佬贴心的为我们提供了EXPLAIN语句来帮助我们查看某个查询语句的具体执行计划,本章的内容就是为了帮助大家看懂EXPLAIN语句的各个输出项都是干嘛使的,从而可以有针对性的提升我们查询语句的性能。\n如果我们想看看某个查询的执行计划的话,可以在具体的查询语句前面加一个EXPLAIN,就像这样:\nmysql\u0026gt; EXPLAIN SELECT 1; +----+-------------+-------+------------+------+---------------+------+---------+------+------+----------+----------------+ | id | select_type | table | partitions | type | possible_keys | key | key_len | ref | rows | filtered | Extra | +----+-------------+-------+------------+------+---------------+------+---------+------+------+----------+----------------+ | 1 | SIMPLE | NULL | NULL | NULL | NULL | NULL | NULL | NULL | NULL | NULL | No tables used | +----+-------------+-------+------------+------+---------------+------+---------+------+------+----------+----------------+ 1 row in set, 1 warning (0.01 sec) 然后这输出的一大坨东西就是所谓的执行计划,我的任务就是带领大家看懂这一大坨东西里边的每个列都是干什么用的,以及在这个执行计划的辅助下,我们应该怎样改进自己的查询语句以使查询执行起来更高效。其实除了以SELECT开头的查询语句,其余的DELETE、INSERT、REPLACE以及UPDATE语句前面都可以加上EXPLAIN这个词儿,用来查看这些语句的执行计划,不过我们这里对SELECT语句更感兴趣,所以后边只会以SELECT语句为例来描述EXPLAIN语句的用法。为了让大家先有一个感性的认识,我们把EXPLAIN语句输出的各个列的作用先大致罗列一下: 列名 描述 id 在一个大的查询语句中每个SELECT关键字都对应一个唯一的id select_type SELECT关键字对应的那个查询的类型 table 表名 partitions 匹配的分区信息 type 针对单表的访问方法 possible_keys 可能用到的索引 key 实际上使用的索引 key_len 实际使用到的索引长度 ref 当使用索引列等值查询时,与索引列进行等值匹配的对象信息 rows 预估的需要读取的记录条数 filtered 某个表经过搜索条件过滤后剩余记录条数的百分比 Extra 一些额外的信息 需要注意的是,大家如果看不懂上面输出列含义,那是正常的,千万不要纠结~。我在这里把它们都列出来只是为了描述一个轮廓,让大家有一个大致的印象,下面会细细道来,等会儿说完了不信你不会~ 为了故事的顺利发展,我们还是要请出我们前面已经用了n遍的single_table表,为了防止大家忘了,再把它的结构描述一遍: CREATE TABLE single_table ( id INT NOT NULL AUTO_INCREMENT, key1 VARCHAR(100), key2 INT, key3 VARCHAR(100), key_part1 VARCHAR(100), key_part2 VARCHAR(100), key_part3 VARCHAR(100), common_field VARCHAR(100), PRIMARY KEY (id), KEY idx_key1 (key1), UNIQUE KEY idx_key2 (key2), KEY idx_key3 (key3), KEY idx_key_part(key_part1, key_part2, key_part3) ) Engine=InnoDB CHARSET=utf8; 我们仍然假设有两个和single_table表构造一模一样的s1、s2表,而且这两个表里边儿有10000条记录,除id列外其余的列都插入随机值。为了让大家有比较好的阅读体验,我们下面并不准备严格按照EXPLAIN输出列的顺序来介绍这些列分别是干嘛的,大家注意一下就好了。\n执行计划输出中各列详解 # table # 不论我们的查询语句有多复杂,里边儿包含了多少个表,到最后也是需要对每个表进行单表访问的,所以设计MySQL的大佬规定EXPLAIN语句输出的每条记录都对应着某个单表的访问方法,该条记录的table列代表着该表的表名。所以我们看一条比较简单的查询语句: mysql\u0026gt; EXPLAIN SELECT * FROM s1; +----+-------------+-------+------------+------+---------------+------+---------+------+------+----------+-------+ | id | select_type | table | partitions | type | possible_keys | key | key_len | ref | rows | filtered | Extra | +----+-------------+-------+------------+------+---------------+------+---------+------+------+----------+-------+ | 1 | SIMPLE | s1 | NULL | ALL | NULL | NULL | NULL | NULL | 9688 | 100.00 | NULL | +----+-------------+-------+------------+------+---------------+------+---------+------+------+----------+-------+ 1 row in set, 1 warning (0.00 sec) 这个查询语句只涉及对s1表的单表查询,所以EXPLAIN输出中只有一条记录,其中的table列的值是s1,表明这条记录是用来说明对s1表的单表访问方法的。\n下面我们看一下一个连接查询的执行计划:\nmysql\u0026gt; EXPLAIN SELECT * FROM s1 INNER JOIN s2; +----+-------------+-------+------------+------+---------------+------+---------+------+------+----------+---------------------------------------+ | id | select_type | table | partitions | type | possible_keys | key | key_len | ref | rows | filtered | Extra | +----+-------------+-------+------------+------+---------------+------+---------+------+------+----------+---------------------------------------+ | 1 | SIMPLE | s1 | NULL | ALL | NULL | NULL | NULL | NULL | 9688 | 100.00 | NULL | | 1 | SIMPLE | s2 | NULL | ALL | NULL | NULL | NULL | NULL | 9954 | 100.00 | Using join buffer (Block Nested Loop) | +----+-------------+-------+------------+------+---------------+------+---------+------+------+----------+---------------------------------------+ 2 rows in set, 1 warning (0.01 sec) 可以看到这个连接查询的执行计划中有两条记录,这两条记录的table列分别是s1和s2,这两条记录用来分别说明对s1表和s2表的访问方法是什么。\nid # 我们知道我们写的查询语句一般都以SELECT关键字开头,比较简单的查询语句里只有一个SELECT关键字,比如下面这个查询语句: SELECT * FROM s1 WHERE key1 = 'a'; 稍微复杂一点的连接查询中也只有一个SELECT关键字,比如: SELECT * FROM s1 INNER JOIN s2 ON s1.key1 = s2.key1 WHERE s1.common_field = 'a'; 但是下面两种情况下在一条查询语句中会出现多个SELECT关键字:\n查询中包含子查询的情况\n比如下面这个查询语句中就包含2个SELECT关键字: SELECT * FROM s1 WHERE key1 IN (SELECT * FROM s2);\n查询中包含UNION语句的情况\n比如下面这个查询语句中也包含2个SELECT关键字: SELECT * FROM s1 UNION SELECT * FROM s2;\n查询语句中每出现一个SELECT关键字,设计MySQL的大佬就会为它分配一个唯一的id值。这个id值就是EXPLAIN语句的第一个列,比如下面这个查询中只有一个SELECT关键字,所以EXPLAIN的结果中也就只有一条id列为1的记录:\nmysql\u0026gt; EXPLAIN SELECT * FROM s1 WHERE key1 = 'a'; +----+-------------+-------+------------+------+---------------+----------+---------+-------+------+----------+-------+ | id | select_type | table | partitions | type | possible_keys | key | key_len | ref | rows | filtered | Extra | +----+-------------+-------+------------+------+---------------+----------+---------+-------+------+----------+-------+ | 1 | SIMPLE | s1 | NULL | ref | idx_key1 | idx_key1 | 303 | const | 8 | 100.00 | NULL | +----+-------------+-------+------------+------+---------------+----------+---------+-------+------+----------+-------+ 1 row in set, 1 warning (0.03 sec) 对于连接查询来说,一个SELECT关键字后边的FROM子句中可以跟随多个表,所以在连接查询的执行计划中,每个表都会对应一条记录,但是这些记录的id值都是相同的,比如: mysql\u0026gt; EXPLAIN SELECT * FROM s1 INNER JOIN s2; +----+-------------+-------+------------+------+---------------+------+---------+------+------+----------+---------------------------------------+ | id | select_type | table | partitions | type | possible_keys | key | key_len | ref | rows | filtered | Extra | +----+-------------+-------+------------+------+---------------+------+---------+------+------+----------+---------------------------------------+ | 1 | SIMPLE | s1 | NULL | ALL | NULL | NULL | NULL | NULL | 9688 | 100.00 | NULL | | 1 | SIMPLE | s2 | NULL | ALL | NULL | NULL | NULL | NULL | 9954 | 100.00 | Using join buffer (Block Nested Loop) | +----+-------------+-------+------------+------+---------------+------+---------+------+------+----------+---------------------------------------+ 2 rows in set, 1 warning (0.01 sec) 可以看到,上述连接查询中参与连接的s1和s2表分别对应一条记录,但是这两条记录对应的id值都是1。这里需要大家记住的是,在连接查询的执行计划中,每个表都会对应一条记录,这些记录的id列的值是相同的,出现在前面的表表示驱动表,出现在后边的表表示被驱动表。所以从上面的EXPLAIN输出中我们可以看出,查询优化器准备让s1表作为驱动表,让s2表作为被驱动表来执行查询。\n对于包含子查询的查询语句来说,就可能涉及多个SELECT关键字,所以在包含子查询的查询语句的执行计划中,每个SELECT关键字都会对应一个唯一的id值,比如这样: mysql\u0026gt; EXPLAIN SELECT * FROM s1 WHERE key1 IN (SELECT key1 FROM s2) OR key3 = 'a'; +----+-------------+-------+------------+-------+---------------+----------+---------+------+------+----------+-------------+ | id | select_type | table | partitions | type | possible_keys | key | key_len | ref | rows | filtered | Extra | +----+-------------+-------+------------+-------+---------------+----------+---------+------+------+----------+-------------+ | 1 | PRIMARY | s1 | NULL | ALL | idx_key3 | NULL | NULL | NULL | 9688 | 100.00 | Using where | | 2 | SUBQUERY | s2 | NULL | index | idx_key1 | idx_key1 | 303 | NULL | 9954 | 100.00 | Using index | +----+-------------+-------+------------+-------+---------------+----------+---------+------+------+----------+-------------+ 2 rows in set, 1 warning (0.02 sec) 从输出结果中我们可以看到,s1表在外层查询中,外层查询有一个独立的SELECT关键字,所以第一条记录的id值就是1,s2表在子查询中,子查询有一个独立的SELECT关键字,所以第二条记录的id值就是2。\n但是这里大家需要特别注意,查询优化器可能对涉及子查询的查询语句进行重写,从而转换为连接查询。所以如果我们想知道查询优化器对某个包含子查询的语句是否进行了重写,直接查看执行计划就好了,比如说: mysql\u0026gt; EXPLAIN SELECT * FROM s1 WHERE key1 IN (SELECT key3 FROM s2 WHERE common_field = 'a'); +----+-------------+-------+------------+------+---------------+----------+---------+-------------------+------+----------+------------------------------+ | id | select_type | table | partitions | type | possible_keys | key | key_len | ref | rows | filtered | Extra | +----+-------------+-------+------------+------+---------------+----------+---------+-------------------+------+----------+------------------------------+ | 1 | SIMPLE | s2 | NULL | ALL | idx_key3 | NULL | NULL | NULL | 9954 | 10.00 | Using where; Start temporary | | 1 | SIMPLE | s1 | NULL | ref | idx_key1 | idx_key1 | 303 | xiaohaizi.s2.key3 | 1 | 100.00 | End temporary | +----+-------------+-------+------------+------+---------------+----------+---------+-------------------+------+----------+------------------------------+ 2 rows in set, 1 warning (0.00 sec) 可以看到,虽然我们的查询语句是一个子查询,但是执行计划中s1和s2表对应的记录的id值全部是1,这就表明了查询优化器将子查询转换为了连接查询。\n对于包含UNION子句的查询语句来说,每个SELECT关键字对应一个id值也是没错的,不过还是有点儿特别的东西,比方说下面这个查询: mysql\u0026gt; EXPLAIN SELECT * FROM s1 UNION SELECT * FROM s2; +----+--------------+------------+------------+------+---------------+------+---------+------+------+----------+-----------------+ | id | select_type | table | partitions | type | possible_keys | key | key_len | ref | rows | filtered | Extra | +----+--------------+------------+------------+------+---------------+------+---------+------+------+----------+-----------------+ | 1 | PRIMARY | s1 | NULL | ALL | NULL | NULL | NULL | NULL | 9688 | 100.00 | NULL | | 2 | UNION | s2 | NULL | ALL | NULL | NULL | NULL | NULL | 9954 | 100.00 | NULL | | NULL | UNION RESULT | \u0026lt;union1,2\u0026gt; | NULL | ALL | NULL | NULL | NULL | NULL | NULL | NULL | Using temporary | +----+--------------+------------+------------+------+---------------+------+---------+------+------+----------+-----------------+ 3 rows in set, 1 warning (0.00 sec) 这个语句的执行计划的第三条记录是个什么鬼?为毛id值是NULL,而且table列长的也怪怪的?大家别忘了UNION子句是干嘛用的,它会把多个查询的结果集合并起来并对结果集中的记录进行去重,怎么去重呢?MySQL使用的是内部的临时表。正如上面的查询计划中所示,UNION子句是为了把id为1的查询和id为2的查询的结果集合并起来并去重,所以在内部创建了一个名为\u0026lt;union1, 2\u0026gt;的临时表(就是执行计划第三条记录的table列的名称),id为NULL表明这个临时表是为了合并两个查询的结果集而创建的。\n跟UNION对比起来,UNION ALL就不需要为最终的结果集进行去重,它只是单纯的把多个查询的结果集中的记录合并成一个并返回给用户,所以也就不需要使用临时表。所以在包含UNION ALL子句的查询的执行计划中,就没有那个id为NULL的记录,如下所示: mysql\u0026gt; EXPLAIN SELECT * FROM s1 UNION ALL SELECT * FROM s2; +----+-------------+-------+------------+------+---------------+------+---------+------+------+----------+-------+ | id | select_type | table | partitions | type | possible_keys | key | key_len | ref | rows | filtered | Extra | +----+-------------+-------+------------+------+---------------+------+---------+------+------+----------+-------+ | 1 | PRIMARY | s1 | NULL | ALL | NULL | NULL | NULL | NULL | 9688 | 100.00 | NULL | | 2 | UNION | s2 | NULL | ALL | NULL | NULL | NULL | NULL | 9954 | 100.00 | NULL | +----+-------------+-------+------------+------+---------------+------+---------+------+------+----------+-------+ 2 rows in set, 1 warning (0.01 sec)\nselect_type # 通过上面的内容我们知道,一条大的查询语句里边可以包含若干个SELECT关键字,每个SELECT关键字代表着一个小的查询语句,而每个SELECT关键字的FROM子句中都可以包含若干张表(这些表用来做连接查询),每一张表都对应着执行计划输出中的一条记录,对于在同一个SELECT关键字中的表来说,它们的id值是相同的。\n设计MySQL的大佬为每一个SELECT关键字代表的小查询都定义了一个称之为select_type的属性,意思是我们只要知道了某个小查询的select_type属性,就知道了这个小查询在整个大查询中扮演了一个什么角色,口说无凭,我们还是先来见识见识这个select_type都能取哪些值(为了精确起见,我们直接使用文档中的英文做简要描述,随后会进行详细解释的): 名称 描述 SIMPLE Simple SELECT (not using UNION or subqueries) PRIMARY Outermost SELECT UNION Second or later SELECT statement in a UNION UNION RESULT Result of a UNION SUBQUERY First SELECT in subquery DEPENDENT SUBQUERY First SELECT in subquery, dependent on outer query DEPENDENT UNION Second or later SELECT statement in a UNION, dependent on outer query DERIVED Derived table MATERIALIZED Materialized subquery UNCACHEABLE SUBQUERY A subquery for which the result cannot be cached and must be re-evaluated for each row of the outer query UNCACHEABLE UNION The second or later select in a UNION that belongs to an uncacheable subquery (see UNCACHEABLE SUBQUERY) 英文描述太简单,不知道说了什么?来详细看看里边儿的每个值都是干什么吃的:\nSIMPLE\n查询语句中不包含UNION或者子查询的查询都算作是SIMPLE类型,比方说下面这个单表查询的select_type的值就是SIMPLE:\nmysql\u0026gt; EXPLAIN SELECT * FROM s1; +----+-------------+-------+------------+------+---------------+------+---------+------+------+----------+-------+ | id | select_type | table | partitions | type | possible_keys | key | key_len | ref | rows | filtered | Extra | +----+-------------+-------+------------+------+---------------+------+---------+------+------+----------+-------+ | 1 | SIMPLE | s1 | NULL | ALL | NULL | NULL | NULL | NULL | 9688 | 100.00 | NULL | +----+-------------+-------+------------+------+---------------+------+---------+------+------+----------+-------+ 1 row in set, 1 warning (0.00 sec) 当然,连接查询也算是SIMPLE类型,比如: mysql\u0026gt; EXPLAIN SELECT * FROM s1 INNER JOIN s2; +----+-------------+-------+------------+------+---------------+------+---------+------+------+----------+---------------------------------------+ | id | select_type | table | partitions | type | possible_keys | key | key_len | ref | rows | filtered | Extra | +----+-------------+-------+------------+------+---------------+------+---------+------+------+----------+---------------------------------------+ | 1 | SIMPLE | s1 | NULL | ALL | NULL | NULL | NULL | NULL | 9688 | 100.00 | NULL | | 1 | SIMPLE | s2 | NULL | ALL | NULL | NULL | NULL | NULL | 9954 | 100.00 | Using join buffer (Block Nested Loop) | +----+-------------+-------+------------+------+---------------+------+---------+------+------+----------+---------------------------------------+ 2 rows in set, 1 warning (0.01 sec)\nPRIMARY\n对于包含UNION、UNION ALL或者子查询的大查询来说,它是由几个小查询组成的,其中最左边的那个查询的select_type值就是PRIMARY,比方说: mysql\u0026gt; EXPLAIN SELECT * FROM s1 UNION SELECT * FROM s2; +----+--------------+------------+------------+------+---------------+------+---------+------+------+----------+-----------------+ | id | select_type | table | partitions | type | possible_keys | key | key_len | ref | rows | filtered | Extra | +----+--------------+------------+------------+------+---------------+------+---------+------+------+----------+-----------------+ | 1 | PRIMARY | s1 | NULL | ALL | NULL | NULL | NULL | NULL | 9688 | 100.00 | NULL | | 2 | UNION | s2 | NULL | ALL | NULL | NULL | NULL | NULL | 9954 | 100.00 | NULL | | NULL | UNION RESULT | \u0026lt;union1,2\u0026gt; | NULL | ALL | NULL | NULL | NULL | NULL | NULL | NULL | Using temporary | +----+--------------+------------+------------+------+---------------+------+---------+------+------+----------+-----------------+ 3 rows in set, 1 warning (0.00 sec) 从结果中可以看到,最左边的小查询SELECT * FROM s1对应的是执行计划中的第一条记录,它的select_type值就是PRIMARY。\nUNION\n对于包含UNION或者UNION ALL的大查询来说,它是由几个小查询组成的,其中除了最左边的那个小查询以外,其余的小查询的select_type值就是UNION,可以对比上一个例子的效果,这就不多举例子了。\nUNION RESULT\nMySQL选择使用临时表来完成UNION查询的去重工作,针对该临时表的查询的select_type就是UNION RESULT,例子上面有,就不赘述了。\nSUBQUERY\n如果包含子查询的查询语句不能够转为对应的semi-join的形式,并且该子查询是不相关子查询,并且查询优化器决定采用将该子查询物化的方案来执行该子查询时,该子查询的第一个SELECT关键字代表的那个查询的select_type就是SUBQUERY,比如下面这个查询:\nmysql\u0026gt; EXPLAIN SELECT * FROM s1 WHERE key1 IN (SELECT key1 FROM s2) OR key3 = 'a'; +----+-------------+-------+------------+-------+---------------+----------+---------+------+------+----------+-------------+ | id | select_type | table | partitions | type | possible_keys | key | key_len | ref | rows | filtered | Extra | +----+-------------+-------+------------+-------+---------------+----------+---------+------+------+----------+-------------+ | 1 | PRIMARY | s1 | NULL | ALL | idx_key3 | NULL | NULL | NULL | 9688 | 100.00 | Using where | | 2 | SUBQUERY | s2 | NULL | index | idx_key1 | idx_key1 | 303 | NULL | 9954 | 100.00 | Using index | +----+-------------+-------+------------+-------+---------------+----------+---------+------+------+----------+-------------+ 2 rows in set, 1 warning (0.00 sec) 可以看到,外层查询的select_type就是PRIMARY,子查询的select_type就是SUBQUERY。需要大家注意的是,由于select_type为SUBQUERY的子查询由于会被物化,所以只需要执行一遍。\nDEPENDENT SUBQUERY\n如果包含子查询的查询语句不能够转为对应的semi-join的形式,并且该子查询是相关子查询,则该子查询的第一个SELECT关键字代表的那个查询的select_type就是DEPENDENT SUBQUERY,比如下面这个查询: mysql\u0026gt; EXPLAIN SELECT * FROM s1 WHERE key1 IN (SELECT key1 FROM s2 WHERE s1.key2 = s2.key2) OR key3 = 'a'; +----+--------------------+-------+------------+------+-------------------+----------+---------+-------------------+------+----------+-------------+ | id | select_type | table | partitions | type | possible_keys | key | key_len | ref | rows | filtered | Extra | +----+--------------------+-------+------------+------+-------------------+----------+---------+-------------------+------+----------+-------------+ | 1 | PRIMARY | s1 | NULL | ALL | idx_key3 | NULL | NULL | NULL | 9688 | 100.00 | Using where | | 2 | DEPENDENT SUBQUERY | s2 | NULL | ref | idx_key2,idx_key1 | idx_key2 | 5 | xiaohaizi.s1.key2 | 1 | 10.00 | Using where | +----+--------------------+-------+------------+------+-------------------+----------+---------+-------------------+------+----------+-------------+ 2 rows in set, 2 warnings (0.00 sec) 需要大家注意的是,select_type为DEPENDENT SUBQUERY的查询可能会被执行多次。\nDEPENDENT UNION\n在包含UNION或者UNION ALL的大查询中,如果各个小查询都依赖于外层查询的话,那除了最左边的那个小查询之外,其余的小查询的select_type的值就是DEPENDENT UNION。说的有些绕,比方说下面这个查询:\nmysql\u0026gt; EXPLAIN SELECT * FROM s1 WHERE key1 IN (SELECT key1 FROM s2 WHERE key1 = 'a' UNION SELECT key1 FROM s1 WHERE key1 = 'b'); +----+--------------------+------------+------------+------+---------------+----------+---------+-------+------+----------+--------------------------+ | id | select_type | table | partitions | type | possible_keys | key | key_len | ref | rows | filtered | Extra | +----+--------------------+------------+------------+------+---------------+----------+---------+-------+------+----------+--------------------------+ | 1 | PRIMARY | s1 | NULL | ALL | NULL | NULL | NULL | NULL | 9688 | 100.00 | Using where | | 2 | DEPENDENT SUBQUERY | s2 | NULL | ref | idx_key1 | idx_key1 | 303 | const | 12 | 100.00 | Using where; Using index | | 3 | DEPENDENT UNION | s1 | NULL | ref | idx_key1 | idx_key1 | 303 | const | 8 | 100.00 | Using where; Using index | | NULL | UNION RESULT | \u0026lt;union2,3\u0026gt; | NULL | ALL | NULL | NULL | NULL | NULL | NULL | NULL | Using temporary | +----+--------------------+------------+------------+------+---------------+----------+---------+-------+------+----------+--------------------------+ 4 rows in set, 1 warning (0.03 sec) 这个查询比较复杂啊,大查询里包含了一个子查询,子查询里又是由UNION连起来的两个小查询。从执行计划中可以看出来,SELECT key1 FROM s2 WHERE key1 = 'a'这个小查询由于是子查询中第一个查询,所以它的select_type是DEPENDENT SUBQUERY,而SELECT key1 FROM s1 WHERE key1 = 'b'这个查询的select_type就是DEPENDENT UNION。\nDERIVED\n对于采用物化的方式执行的包含派生表的查询,该派生表对应的子查询的select_type就是DERIVED,比方说下面这个查询: mysql\u0026gt; EXPLAIN SELECT * FROM (SELECT key1, count(*) as c FROM s1 GROUP BY key1) AS derived_s1 where c \u0026gt; 1; +----+-------------+------------+------------+-------+---------------+----------+---------+------+------+----------+-------------+ | id | select_type | table | partitions | type | possible_keys | key | key_len | ref | rows | filtered | Extra | +----+-------------+------------+------------+-------+---------------+----------+---------+------+------+----------+-------------+ | 1 | PRIMARY | \u0026lt;derived2\u0026gt; | NULL | ALL | NULL | NULL | NULL | NULL | 9688 | 33.33 | Using where | | 2 | DERIVED | s1 | NULL | index | idx_key1 | idx_key1 | 303 | NULL | 9688 | 100.00 | Using index | +----+-------------+------------+------------+-------+---------------+----------+---------+------+------+----------+-------------+ 2 rows in set, 1 warning (0.00 sec) 从执行计划中可以看出,id为2的记录就代表子查询的执行方式,它的select_type是DERIVED,说明该子查询是以物化的方式执行的。id为1的记录代表外层查询,大家注意看它的table列显示的是\u0026lt;derived2\u0026gt;,表示该查询是针对将派生表物化之后的表进行查询的。\n小贴士:如果派生表可以通过和外层查询合并的方式执行的话,执行计划又是另一番景象,大家可以试试~ - MATERIALIZED\n当查询优化器在执行包含子查询的语句时,选择将子查询物化之后与外层查询进行连接查询时,该子查询对应的select_type属性就是MATERIALIZED,比如下面这个查询:\nmysql\u0026gt; EXPLAIN SELECT * FROM s1 WHERE key1 IN (SELECT key1 FROM s2); +----+--------------+-------------+------------+--------+---------------+------------+---------+-------------------+------+----------+-------------+ | id | select_type | table | partitions | type | possible_keys | key | key_len | ref | rows | filtered | Extra | +----+--------------+-------------+------------+--------+---------------+------------+---------+-------------------+------+----------+-------------+ | 1 | SIMPLE | s1 | NULL | ALL | idx_key1 | NULL | NULL | NULL | 9688 | 100.00 | Using where | | 1 | SIMPLE | \u0026lt;subquery2\u0026gt; | NULL | eq_ref | \u0026lt;auto_key\u0026gt; | \u0026lt;auto_key\u0026gt; | 303 | xiaohaizi.s1.key1 | 1 | 100.00 | NULL | | 2 | MATERIALIZED | s2 | NULL | index | idx_key1 | idx_key1 | 303 | NULL | 9954 | 100.00 | Using index | +----+--------------+-------------+------------+--------+---------------+------------+---------+-------------------+------+----------+-------------+ 3 rows in set, 1 warning (0.01 sec) 执行计划的第三条记录的id值为2,说明该条记录对应的是一个单表查询,从它的select_type值为MATERIALIZED可以看出,查询优化器是要把子查询先转换成物化表。然后看执行计划的前两条记录的id值都为1,说明这两条记录对应的表进行连接查询,需要注意的是第二条记录的table列的值是\u0026lt;subquery2\u0026gt;,说明该表其实就是id为2对应的子查询执行之后产生的物化表,然后将s1和该物化表进行连接查询。\nUNCACHEABLE SUBQUERY\n不常用,就不多介绍了。 - UNCACHEABLE UNION\n不常用,就不多介绍了。\npartitions # 由于我们压根儿就没介绍过分区是什么,所以这个输出列我们也就不说了,一般情况下我们的查询语句的执行计划的partitions列的值都是NULL。\ntype # 我们前面说过执行计划的一条记录就代表着MySQL对某个表的执行查询时的访问方法,其中的type列就表明了这个访问方法是什么,比方说下面这个查询: mysql\u0026gt; EXPLAIN SELECT * FROM s1 WHERE key1 = 'a'; +----+-------------+-------+------------+------+---------------+----------+---------+-------+------+----------+-------+ | id | select_type | table | partitions | type | possible_keys | key | key_len | ref | rows | filtered | Extra | +----+-------------+-------+------------+------+---------------+----------+---------+-------+------+----------+-------+ | 1 | SIMPLE | s1 | NULL | ref | idx_key1 | idx_key1 | 303 | const | 8 | 100.00 | NULL | +----+-------------+-------+------------+------+---------------+----------+---------+-------+------+----------+-------+ 1 row in set, 1 warning (0.04 sec) 可以看到type列的值是ref,表明MySQL即将使用ref访问方法来执行对s1表的查询。但是我们之前只介绍过对使用InnoDB存储引擎的表进行单表访问的一些访问方法,完整的访问方法如下:system,const,eq_ref,ref,fulltext,ref_or_null,index_merge,unique_subquery,index_subquery,range,index,ALL。当然我们还要详细介绍一下:\nsystem\n当表中只有一条记录并且该表使用的存储引擎的统计数据是精确的,比如MyISAM、Memory,那么对该表的访问方法就是system。比方说我们新建一个MyISAM表,并为其插入一条记录:\nmysql\u0026gt; INSERT INTO t VALUES(1); Query OK, 1 row affected (0.01 sec) `然后我们看一下查询这个表的执行计划:` mysql\u0026gt; EXPLAIN SELECT * FROM t; \\+----\\+-------------\\+-------\\+------------\\+--------\\+---------------\\+------\\+---------\\+------\\+------\\+----------\\+-------\\+ | id | select_type | table | partitions | type | possible_keys | key | key_len | ref | rows | filtered | Extra | \\+----\\+-------------\\+-------\\+------------\\+--------\\+---------------\\+------\\+---------\\+------\\+------\\+----------\\+-------\\+ | 1 | SIMPLE | t | NULL | system | NULL | NULL | NULL | NULL | 1 | 100.00 | NULL | \\+----\\+-------------\\+-------\\+------------\\+--------\\+---------------\\+------\\+---------\\+------\\+------\\+----------\\+-------\\+ 1 row in set, 1 warning (0.00 sec) `可以看到`type`列的值就是`system`了。` 小贴士:你可以把表改成使用InnoDB存储引擎,试试看执行计划的type列是什么。 ``` + `const` 这个我们前面介绍过,就是当我们根据主键或者唯一二级索引列与常数进行等值匹配时,对单表的访问方法就是`const`,比如: `mysql\u0026gt; EXPLAIN SELECT * FROM s1 WHERE id = 5; +----+-------------+-------+------------+-------+---------------+---------+---------+-------+------+----------+-------+ | id | select_type | table | partitions | type | possible_keys | key | key_len | ref | rows | filtered | Extra | +----+-------------+-------+------------+-------+---------------+---------+---------+-------+------+----------+-------+ | 1 | SIMPLE | s1 | NULL | const | PRIMARY | PRIMARY | 4 | const | 1 | 100.00 | NULL | +----+-------------+-------+------------+-------+---------------+---------+---------+-------+------+----------+-------+ 1 row in set, 1 warning (0.01 sec)` + `eq_ref` 在连接查询时,如果被驱动表是通过主键或者唯一二级索引列等值匹配的方式进行访问的(如果该主键或者唯一二级索引是联合索引的话,所有的索引列都必须进行等值比较),则对该被驱动表的访问方法就是`eq_ref`,比方说: `mysql\u0026gt; EXPLAIN SELECT * FROM s1 INNER JOIN s2 ON s1.id = s2.id; +----+-------------+-------+------------+--------+---------------+---------+---------+-----------------+------+----------+-------+ | id | select_type | table | partitions | type | possible_keys | key | key_len | ref | rows | filtered | Extra | +----+-------------+-------+------------+--------+---------------+---------+---------+-----------------+------+----------+-------+ | 1 | SIMPLE | s1 | NULL | ALL | PRIMARY | NULL | NULL | NULL | 9688 | 100.00 | NULL | | 1 | SIMPLE | s2 | NULL | eq_ref | PRIMARY | PRIMARY | 4 | xiaohaizi.s1.id | 1 | 100.00 | NULL | +----+-------------+-------+------------+--------+---------------+---------+---------+-----------------+------+----------+-------+ 2 rows in set, 1 warning (0.01 sec)` 从执行计划的结果中可以看出,`MySQL`打算将`s1`作为驱动表,`s2`作为被驱动表,重点关注`s2`的访问方法是`eq_ref`,表明在访问`s2`表的时候可以通过主键的等值匹配来进行访问。 + `ref` 当通过普通的二级索引列与常量进行等值匹配时来查询某个表,那么对该表的访问方法就**可能**是`ref`,最开始举过例子了,就不重复举例了。 + `fulltext` 全文索引,我们没有细讲过,跳过~ + `ref_or_null` 当对普通二级索引进行等值匹配查询,该索引列的值也可以是`NULL`值时,那么对该表的访问方法就**可能**是`ref_or_null`,比如说: `mysql\u0026gt; EXPLAIN SELECT * FROM s1 WHERE key1 = \u0026#39;a\u0026#39; OR key1 IS NULL; +----+-------------+-------+------------+-------------+---------------+----------+---------+-------+------+----------+-----------------------+ | id | select_type | table | partitions | type | possible_keys | key | key_len | ref | rows | filtered | Extra | +----+-------------+-------+------------+-------------+---------------+----------+---------+-------+------+----------+-----------------------+ | 1 | SIMPLE | s1 | NULL | ref_or_null | idx_key1 | idx_key1 | 303 | const | 9 | 100.00 | Using index condition | +----+-------------+-------+------------+-------------+---------------+----------+---------+-------+------+----------+-----------------------+ 1 row in set, 1 warning (0.01 sec)` + `index_merge` 一般情况下对于某个表的查询只能使用到一个索引,但我们介绍单表访问方法时特意强调了在某些场景下可以使用`Intersection`、`Union`、`Sort-Union`这三种索引合并的方式来执行查询,忘掉的回去补一下,我们看一下执行计划中是怎么体现`MySQL`使用索引合并的方式来对某个表执行查询的: `mysql\u0026gt; EXPLAIN SELECT * FROM s1 WHERE key1 = \u0026#39;a\u0026#39; OR key3 = \u0026#39;a\u0026#39;; +----+-------------+-------+------------+-------------+-------------------+-------------------+---------+------+------+----------+---------------------------------------------+ | id | select_type | table | partitions | type | possible_keys | key | key_len | ref | rows | filtered | Extra | +----+-------------+-------+------------+-------------+-------------------+-------------------+---------+------+------+----------+---------------------------------------------+ | 1 | SIMPLE | s1 | NULL | index_merge | idx_key1,idx_key3 | idx_key1,idx_key3 | 303,303 | NULL | 14 | 100.00 | Using union(idx_key1,idx_key3); Using where | +----+-------------+-------+------------+-------------+-------------------+-------------------+---------+------+------+----------+---------------------------------------------+ 1 row in set, 1 warning (0.01 sec)` 从执行计划的`type`列的值是`index_merge`就可以看出,`MySQL`打算使用索引合并的方式来执行对`s1`表的查询。 + `unique_subquery` 类似于两表连接中被驱动表的`eq_ref`访问方法,`unique_subquery`是针对在一些包含`IN`子查询的查询语句中,如果查询优化器决定将`IN`子查询转换为`EXISTS`子查询,而且子查询可以使用到主键进行等值匹配的话,那么该子查询执行计划的`type`列的值就是`unique_subquery`,比如下面的这个查询语句: `mysql\u0026gt; EXPLAIN SELECT * FROM s1 WHERE key2 IN (SELECT id FROM s2 where s1.key1 = s2.key1) OR key3 = \u0026#39;a\u0026#39;; +----+--------------------+-------+------------+-----------------+------------------+---------+---------+------+------+----------+-------------+ | id | select_type | table | partitions | type | possible_keys | key | key_len | ref | rows | filtered | Extra | +----+--------------------+-------+------------+-----------------+------------------+---------+---------+------+------+----------+-------------+ | 1 | PRIMARY | s1 | NULL | ALL | idx_key3 | NULL | NULL | NULL | 9688 | 100.00 | Using where | | 2 | DEPENDENT SUBQUERY | s2 | NULL | unique_subquery | PRIMARY,idx_key1 | PRIMARY | 4 | func | 1 | 10.00 | Using where | +----+--------------------+-------+------------+-----------------+------------------+---------+---------+------+------+----------+-------------+ 2 rows in set, 2 warnings (0.00 sec)` 可以看到执行计划的第二条记录的`type`值就是`unique_subquery`,说明在执行子查询时会使用到`id`列的索引。 + `index_subquery` `index_subquery`与`unique_subquery`类似,只不过访问子查询中的表时使用的是普通的索引,比如这样: `mysql\u0026gt; EXPLAIN SELECT * FROM s1 WHERE common_field IN (SELECT key3 FROM s2 where s1.key1 = s2.key1) OR key3 = \u0026#39;a\u0026#39;; +----+--------------------+-------+------------+----------------+-------------------+----------+---------+------+------+----------+-------------+ | id | select_type | table | partitions | type | possible_keys | key | key_len | ref | rows | filtered | Extra | +----+--------------------+-------+------------+----------------+-------------------+----------+---------+------+------+----------+-------------+ | 1 | PRIMARY | s1 | NULL | ALL | idx_key3 | NULL | NULL | NULL | 9688 | 100.00 | Using where | | 2 | DEPENDENT SUBQUERY | s2 | NULL | index_subquery | idx_key1,idx_key3 | idx_key3 | 303 | func | 1 | 10.00 | Using where | +----+--------------------+-------+------------+----------------+-------------------+----------+---------+------+------+----------+-------------+ 2 rows in set, 2 warnings (0.01 sec)` + `range` 如果使用索引获取某些`范围区间`的记录,那么就**可能**使用到`range`访问方法,比如下面的这个查询: `mysql\u0026gt; EXPLAIN SELECT * FROM s1 WHERE key1 IN (\u0026#39;a\u0026#39;, \u0026#39;b\u0026#39;, \u0026#39;c\u0026#39;); +----+-------------+-------+------------+-------+---------------+----------+---------+------+------+----------+-----------------------+ | id | select_type | table | partitions | type | possible_keys | key | key_len | ref | rows | filtered | Extra | +----+-------------+-------+------------+-------+---------------+----------+---------+------+------+----------+-----------------------+ | 1 | SIMPLE | s1 | NULL | range | idx_key1 | idx_key1 | 303 | NULL | 27 | 100.00 | Using index condition | +----+-------------+-------+------------+-------+---------------+----------+---------+------+------+----------+-----------------------+ 1 row in set, 1 warning (0.01 sec)` 或者: ``` mysql\u0026gt; EXPLAIN SELECT * FROM s1 WHERE key1 \u0026gt; \u0026#39;a\u0026#39; AND key1 \u0026lt; \u0026#39;b\u0026#39;; \\+----\\+-------------\\+-------\\+------------\\+-------\\+---------------\\+----------\\+---------\\+------\\+------\\+----------\\+-----------------------\\+ | id | select_type | table | partitions | type | possible_keys | key | key_len | ref | rows | filtered | Extra | \\+----\\+-------------\\+-------\\+------------\\+-------\\+---------------\\+----------\\+---------\\+------\\+------\\+----------\\+-----------------------\\+ | 1 | SIMPLE | s1 | NULL | range | idx_key1 | idx_key1 | 303 | NULL | 294 | 100.00 | Using index condition | \\+----\\+-------------\\+-------\\+------------\\+-------\\+---------------\\+----------\\+---------\\+------\\+------\\+----------\\+-----------------------\\+ 1 row in set, 1 warning (0.00 sec) ``` + `index` 当我们可以使用索引覆盖,但需要扫描全部的索引记录时,该表的访问方法就是`index`,比如这样: `mysql\u0026gt; EXPLAIN SELECT key_part2 FROM s1 WHERE key_part3 = \u0026#39;a\u0026#39;; +----+-------------+-------+------------+-------+---------------+--------------+---------+------+------+----------+--------------------------+ | id | select_type | table | partitions | type | possible_keys | key | key_len | ref | rows | filtered | Extra | +----+-------------+-------+------------+-------+---------------+--------------+---------+------+------+----------+--------------------------+ | 1 | SIMPLE | s1 | NULL | index | NULL | idx_key_part | 909 | NULL | 9688 | 10.00 | Using where; Using index | +----+-------------+-------+------------+-------+---------------+--------------+---------+------+------+----------+--------------------------+ 1 row in set, 1 warning (0.00 sec)` 上述查询中的搜索列表中只有`key_part2`一个列,而且搜索条件中也只有`key_part3`一个列,这两个列又恰好包含在`idx_key_part`这个索引中,可是搜索条件`key_part3`不能直接使用该索引进行`ref`或者`range`方式的访问,只能扫描整个`idx_key_part`索引的记录,所以查询计划的`type`列的值就是`index`。 `小贴士:再一次强调,对于使用InnoDB存储引擎的表来说,二级索引的记录只包含索引列和主键列的值,而聚簇索引中包含用户定义的全部列以及一些隐藏列,所以扫描二级索引的代价比直接全表扫描,也就是扫描聚簇索引的代价更低一些。` + `ALL` 最熟悉的全表扫描,就不多介绍了,直接看例子: `mysql\u0026gt; EXPLAIN SELECT * FROM s1; +----+-------------+-------+------------+------+---------------+------+---------+------+------+----------+-------+ | id | select_type | table | partitions | type | possible_keys | key | key_len | ref | rows | filtered | Extra | +----+-------------+-------+------------+------+---------------+------+---------+------+------+----------+-------+ | 1 | SIMPLE | s1 | NULL | ALL | NULL | NULL | NULL | NULL | 9688 | 100.00 | NULL | +----+-------------+-------+------------+------+---------------+------+---------+------+------+----------+-------+ 1 row in set, 1 warning (0.00 sec)` 一般来说,这些访问方法按照我们介绍它们的顺序性能依次变差。其中除了`All`这个访问方法外,其余的访问方法都能用到索引,除了`index_merge`访问方法外,其余的访问方法都最多只能用到一个索引。 ## possible_keys和key 在`EXPLAIN`语句输出的执行计划中,`possible_keys`列表示在某个查询语句中,对某个表执行单表查询时可能用到的索引有哪些,`key`列表示实际用到的索引有哪些,比方说下面这个查询: `mysql\u0026gt; EXPLAIN SELECT * FROM s1 WHERE key1 \u0026gt; \u0026#39;z\u0026#39; AND key3 = \u0026#39;a\u0026#39;; +----+-------------+-------+------------+------+-------------------+----------+---------+-------+------+----------+-------------+ | id | select_type | table | partitions | type | possible_keys | key | key_len | ref | rows | filtered | Extra | +----+-------------+-------+------------+------+-------------------+----------+---------+-------+------+----------+-------------+ | 1 | SIMPLE | s1 | NULL | ref | idx_key1,idx_key3 | idx_key3 | 303 | const | 6 | 2.75 | Using where | +----+-------------+-------+------------+------+-------------------+----------+---------+-------+------+----------+-------------+ 1 row in set, 1 warning (0.01 sec)` 上述执行计划的`possible_keys`列的值是`idx_key1,idx_key3`,表示该查询可能使用到`idx_key1,idx_key3`两个索引,然后`key`列的值是`idx_key3`,表示经过查询优化器计算使用不同索引的成本后,最后决定使用`idx_key3`来执行查询比较划算。 不过有一点比较特别,就是在使用`index`访问方法来查询某个表时,`possible_keys`列是空的,而`key`列展示的是实际使用到的索引,比如这样: `mysql\u0026gt; EXPLAIN SELECT key_part2 FROM s1 WHERE key_part3 = \u0026#39;a\u0026#39;; +----+-------------+-------+------------+-------+---------------+--------------+---------+------+------+----------+--------------------------+ | id | select_type | table | partitions | type | possible_keys | key | key_len | ref | rows | filtered | Extra | +----+-------------+-------+------------+-------+---------------+--------------+---------+------+------+----------+--------------------------+ | 1 | SIMPLE | s1 | NULL | index | NULL | idx_key_part | 909 | NULL | 9688 | 10.00 | Using where; Using index | +----+-------------+-------+------------+-------+---------------+--------------+---------+------+------+----------+--------------------------+ 1 row in set, 1 warning (0.00 sec)` 另外需要注意的一点是,**possible_keys列中的值并不是越多越好,可能使用的索引越多,查询优化器计算查询成本时就得花费更长时间,所以如果可以的话,尽量删除那些用不到的索引**。 ## key_len `key_len`列表示当优化器决定使用某个索引执行查询时,该索引记录的最大长度,它是由这三个部分构成的: - 对于使用固定长度类型的索引列来说,它实际占用的存储空间的最大长度就是该固定值,对于指定字符集的变长类型的索引列来说,比如某个索引列的类型是`VARCHAR(100)`,使用的字符集是`utf8`,那么该列实际占用的最大存储空间就是`100 × 3 = 300`个字节。 - 如果该索引列可以存储`NULL`值,则`key_len`比不可以存储`NULL`值时多1个字节。 - 对于变长字段来说,都会有2个字节的空间来存储该变长列的实际长度。 比如下面这个查询: `mysql\u0026gt; EXPLAIN SELECT * FROM s1 WHERE id = 5; +----+-------------+-------+------------+-------+---------------+---------+---------+-------+------+----------+-------+ | id | select_type | table | partitions | type | possible_keys | key | key_len | ref | rows | filtered | Extra | +----+-------------+-------+------------+-------+---------------+---------+---------+-------+------+----------+-------+ | 1 | SIMPLE | s1 | NULL | const | PRIMARY | PRIMARY | 4 | const | 1 | 100.00 | NULL | +----+-------------+-------+------------+-------+---------------+---------+---------+-------+------+----------+-------+ 1 row in set, 1 warning (0.01 sec)` 由于`id`列的类型是`INT`,并且不可以存储`NULL`值,所以在使用该列的索引时`key_len`大小就是`4`。当索引列可以存储`NULL`值时,比如: `mysql\u0026gt; EXPLAIN SELECT * FROM s1 WHERE key2 = 5; +----+-------------+-------+------------+-------+---------------+----------+---------+-------+------+----------+-------+ | id | select_type | table | partitions | type | possible_keys | key | key_len | ref | rows | filtered | Extra | +----+-------------+-------+------------+-------+---------------+----------+---------+-------+------+----------+-------+ | 1 | SIMPLE | s1 | NULL | const | idx_key2 | idx_key2 | 5 | const | 1 | 100.00 | NULL | +----+-------------+-------+------------+-------+---------------+----------+---------+-------+------+----------+-------+ 1 row in set, 1 warning (0.00 sec)` 可以看到`key_len`列就变成了`5`,比使用`id`列的索引时多了`1`。 对于可变长度的索引列来说,比如下面这个查询: `mysql\u0026gt; EXPLAIN SELECT * FROM s1 WHERE key1 = \u0026#39;a\u0026#39;; +----+-------------+-------+------------+------+---------------+----------+---------+-------+------+----------+-------+ | id | select_type | table | partitions | type | possible_keys | key | key_len | ref | rows | filtered | Extra | +----+-------------+-------+------------+------+---------------+----------+---------+-------+------+----------+-------+ | 1 | SIMPLE | s1 | NULL | ref | idx_key1 | idx_key1 | 303 | const | 8 | 100.00 | NULL | +----+-------------+-------+------------+------+---------------+----------+---------+-------+------+----------+-------+ 1 row in set, 1 warning (0.00 sec)` 由于`key1`列的类型是`VARCHAR(100)`,所以该列实际最多占用的存储空间就是`300`字节,又因为该列允许存储`NULL`值,所以`key_len`需要加`1`,又因为该列是可变长度列,所以`key_len`需要加`2`,所以最后`ken_len`的值就是`303`。 有的同学可能有疑问:你在前面介绍`InnoDB`行格式的时候不是说,存储变长字段的实际长度不是可能占用1个字节或者2个字节么?为什么现在不管三七二十一都用了`2`个字节?这里需要强调的一点是,执行计划的生成是在`MySQL server`层中的功能,并不是针对具体某个存储引擎的功能,设计`MySQL`的大佬在执行计划中输出`key_len`列主要是为了让我们区分某个使用联合索引的查询具体用了几个索引列,而不是为了准确的说明针对某个具体存储引擎存储变长字段的实际长度占用的空间到底是占用1个字节还是2个字节。比方说下面这个使用到联合索引`idx_key_part`的查询: `mysql\u0026gt; EXPLAIN SELECT * FROM s1 WHERE key_part1 = \u0026#39;a\u0026#39;; +----+-------------+-------+------------+------+---------------+--------------+---------+-------+------+----------+-------+ | id | select_type | table | partitions | type | possible_keys | key | key_len | ref | rows | filtered | Extra | +----+-------------+-------+------------+------+---------------+--------------+---------+-------+------+----------+-------+ | 1 | SIMPLE | s1 | NULL | ref | idx_key_part | idx_key_part | 303 | const | 12 | 100.00 | NULL | +----+-------------+-------+------------+------+---------------+--------------+---------+-------+------+----------+-------+ 1 row in set, 1 warning (0.00 sec)` 我们可以从执行计划的`key_len`列中看到值是`303`,这意味着`MySQL`在执行上述查询中只能用到`idx_key_part`索引的一个索引列,而下面这个查询: `mysql\u0026gt; EXPLAIN SELECT * FROM s1 WHERE key_part1 = \u0026#39;a\u0026#39; AND key_part2 = \u0026#39;b\u0026#39;; +----+-------------+-------+------------+------+---------------+--------------+---------+-------------+------+----------+-------+ | id | select_type | table | partitions | type | possible_keys | key | key_len | ref | rows | filtered | Extra | +----+-------------+-------+------------+------+---------------+--------------+---------+-------------+------+----------+-------+ | 1 | SIMPLE | s1 | NULL | ref | idx_key_part | idx_key_part | 606 | const,const | 1 | 100.00 | NULL | +----+-------------+-------+------------+------+---------------+--------------+---------+-------------+------+----------+-------+ 1 row in set, 1 warning (0.01 sec)` 这个查询的执行计划的`ken_len`列的值是`606`,说明执行这个查询的时候可以用到联合索引`idx_key_part`的两个索引列。 ## ref 当使用索引列等值匹配的条件去执行查询时,也就是在访问方法是`const`、`eq_ref`、`ref`、`ref_or_null`、`unique_subquery`、`index_subquery`其中之一时,`ref`列展示的就是与索引列作等值匹配的东东是什么,比如只是一个常数或者是某个列。大家看下面这个查询: `mysql\u0026gt; EXPLAIN SELECT * FROM s1 WHERE key1 = \u0026#39;a\u0026#39;; +----+-------------+-------+------------+------+---------------+----------+---------+-------+------+----------+-------+ | id | select_type | table | partitions | type | possible_keys | key | key_len | ref | rows | filtered | Extra | +----+-------------+-------+------------+------+---------------+----------+---------+-------+------+----------+-------+ | 1 | SIMPLE | s1 | NULL | ref | idx_key1 | idx_key1 | 303 | const | 8 | 100.00 | NULL | +----+-------------+-------+------------+------+---------------+----------+---------+-------+------+----------+-------+ 1 row in set, 1 warning (0.01 sec)` 可以看到`ref`列的值是`const`,表明在使用`idx_key1`索引执行查询时,与`key1`列作等值匹配的对象是一个常数,当然有时候更复杂一点: `mysql\u0026gt; EXPLAIN SELECT * FROM s1 INNER JOIN s2 ON s1.id = s2.id; +----+-------------+-------+------------+--------+---------------+---------+---------+-----------------+------+----------+-------+ | id | select_type | table | partitions | type | possible_keys | key | key_len | ref | rows | filtered | Extra | +----+-------------+-------+------------+--------+---------------+---------+---------+-----------------+------+----------+-------+ | 1 | SIMPLE | s1 | NULL | ALL | PRIMARY | NULL | NULL | NULL | 9688 | 100.00 | NULL | | 1 | SIMPLE | s2 | NULL | eq_ref | PRIMARY | PRIMARY | 4 | xiaohaizi.s1.id | 1 | 100.00 | NULL | +----+-------------+-------+------------+--------+---------------+---------+---------+-----------------+------+----------+-------+ 2 rows in set, 1 warning (0.00 sec)` 可以看到对被驱动表`s2`的访问方法是`eq_ref`,而对应的`ref`列的值是`xiaohaizi.s1.id`,这说明在对被驱动表进行访问时会用到`PRIMARY`索引,也就是聚簇索引与一个列进行等值匹配的条件,于`s2`表的`id`作等值匹配的对象就是`xiaohaizi.s1.id`列(注意这里把数据库名也写出来了)。 有的时候与索引列进行等值匹配的对象是一个函数,比方说下面这个查询: `mysql\u0026gt; EXPLAIN SELECT * FROM s1 INNER JOIN s2 ON s2.key1 = UPPER(s1.key1); +----+-------------+-------+------------+------+---------------+----------+---------+------+------+----------+-----------------------+ | id | select_type | table | partitions | type | possible_keys | key | key_len | ref | rows | filtered | Extra | +----+-------------+-------+------------+------+---------------+----------+---------+------+------+----------+-----------------------+ | 1 | SIMPLE | s1 | NULL | ALL | NULL | NULL | NULL | NULL | 9688 | 100.00 | NULL | | 1 | SIMPLE | s2 | NULL | ref | idx_key1 | idx_key1 | 303 | func | 1 | 100.00 | Using index condition | +----+-------------+-------+------------+------+---------------+----------+---------+------+------+----------+-----------------------+ 2 rows in set, 1 warning (0.00 sec)` 我们看执行计划的第二条记录,可以看到对`s2`表采用`ref`访问方法执行查询,然后在查询计划的`ref`列里输出的是`func`,说明与`s2`表的`key1`列进行等值匹配的对象是一个函数。 ## rows 如果查询优化器决定使用全表扫描的方式对某个表执行查询时,执行计划的`rows`列就代表预计需要扫描的行数,如果使用索引来执行查询时,执行计划的`rows`列就代表预计扫描的索引记录行数。比如下面这个查询: `mysql\u0026gt; EXPLAIN SELECT * FROM s1 WHERE key1 \u0026gt; \u0026#39;z\u0026#39;; +----+-------------+-------+------------+-------+---------------+----------+---------+------+------+----------+-----------------------+ | id | select_type | table | partitions | type | possible_keys | key | key_len | ref | rows | filtered | Extra | +----+-------------+-------+------------+-------+---------------+----------+---------+------+------+----------+-----------------------+ | 1 | SIMPLE | s1 | NULL | range | idx_key1 | idx_key1 | 303 | NULL | 266 | 100.00 | Using index condition | +----+-------------+-------+------------+-------+---------------+----------+---------+------+------+----------+-----------------------+ 1 row in set, 1 warning (0.00 sec)` 我们看到执行计划的`rows`列的值是`266`,这意味着查询优化器在经过分析使用`idx_key1`进行查询的成本之后,觉得满足`key1 \u0026gt; \u0026#39;z\u0026#39;`这个条件的记录只有`266`条。 ## filtered 之前在分析连接查询的成本时提出过一个`condition filtering`的概念,就是`MySQL`在计算驱动表扇出时采用的一个策略: - 如果使用的是全表扫描的方式执行的单表查询,那么计算驱动表扇出时需要估计出满足搜索条件的记录到底有多少条。 - 如果使用的是索引执行的单表扫描,那么计算驱动表扇出的时候需要估计出满足除使用到对应索引的搜索条件外的其他搜索条件的记录有多少条。 比方说下面这个查询: `mysql\u0026gt; EXPLAIN SELECT * FROM s1 WHERE key1 \u0026gt; \u0026#39;z\u0026#39; AND common_field = \u0026#39;a\u0026#39;; +----+-------------+-------+------------+-------+---------------+----------+---------+------+------+----------+------------------------------------+ | id | select_type | table | partitions | type | possible_keys | key | key_len | ref | rows | filtered | Extra | +----+-------------+-------+------------+-------+---------------+----------+---------+------+------+----------+------------------------------------+ | 1 | SIMPLE | s1 | NULL | range | idx_key1 | idx_key1 | 303 | NULL | 266 | 10.00 | Using index condition; Using where | +----+-------------+-------+------------+-------+---------------+----------+---------+------+------+----------+------------------------------------+ 1 row in set, 1 warning (0.00 sec)` 从执行计划的`key`列中可以看出来,该查询使用`idx_key1`索引来执行查询,从`rows`列可以看出满足`key1 \u0026gt; \u0026#39;z\u0026#39;`的记录有`266`条。执行计划的`filtered`列就代表查询优化器预测在这`266`条记录中,有多少条记录满足其余的搜索条件,也就是`common_field = \u0026#39;a\u0026#39;`这个条件的百分比。此处`filtered`列的值是`10.00`,说明查询优化器预测在`266`条记录中有`10.00%`的记录满足`common_field = \u0026#39;a\u0026#39;`这个条件。 对于单表查询来说,这个`filtered`列的值没什么意义,我们更关注在连接查询中驱动表对应的执行计划记录的`filtered`值,比方说下面这个查询: `mysql\u0026gt; EXPLAIN SELECT * FROM s1 INNER JOIN s2 ON s1.key1 = s2.key1 WHERE s1.common_field = \u0026#39;a\u0026#39;; +----+-------------+-------+------------+------+---------------+----------+---------+-------------------+------+----------+-------------+ | id | select_type | table | partitions | type | possible_keys | key | key_len | ref | rows | filtered | Extra | +----+-------------+-------+------------+------+---------------+----------+---------+-------------------+------+----------+-------------+ | 1 | SIMPLE | s1 | NULL | ALL | idx_key1 | NULL | NULL | NULL | 9688 | 10.00 | Using where | | 1 | SIMPLE | s2 | NULL | ref | idx_key1 | idx_key1 | 303 | xiaohaizi.s1.key1 | 1 | 100.00 | NULL | +----+-------------+-------+------------+------+---------------+----------+---------+-------------------+------+----------+-------------+ 2 rows in set, 1 warning (0.00 sec)` 从执行计划中可以看出来,查询优化器打算把`s1`当作驱动表,`s2`当作被驱动表。我们可以看到驱动表`s1`表的执行计划的`rows`列为`9688`, `filtered`列为`10.00`,这意味着驱动表`s1`的扇出值就是`9688 × 10.00% = 968.8`,这说明还要对被驱动表执行大约`968`次查询。 "},{"id":19,"href":"/zh/docs/technology/MySQL/MySQL%E6%98%AF%E6%80%8E%E6%A0%B7%E8%BF%90%E8%A1%8C%E7%9A%84/%E7%AC%AC14%E7%AB%A0_%E4%B8%8D%E5%A5%BD%E7%9C%8B%E5%B0%B1%E8%A6%81%E5%A4%9A%E6%95%B4%E5%AE%B9-MySQL%E5%9F%BA%E4%BA%8E%E8%A7%84%E5%88%99%E7%9A%84%E4%BC%98%E5%8C%96%E5%86%85%E5%90%AB%E5%85%B3%E4%BA%8E%E5%AD%90%E6%9F%A5%E8%AF%A2%E4%BC%98%E5%8C%96%E4%BA%8C%E4%B8%89%E4%BA%8B%E5%84%BF/","title":"第14章_不好看就要多整容-MySQL基于规则的优化(内含关于子查询优化二三事儿)","section":"My Sql是怎样运行的","content":"第14章 不好看就要多整容-MySQL基于规则的优化(内含关于子查询优化二三事儿)\n大家别忘了MySQL本质上是一个软件,设计MySQL的大佬并不能要求使用这个软件的人个个都是数据库高高手,就像我写这本书的时候并不能要求各位在学之前就会了里边儿的知识。 吐槽一下:都会了的人谁还看呢,难道是为了精神上受感化? 也就是说我们无法避免某些同学写一些执行起来十分耗费性能的语句。即使是这样,设计MySQL的大佬还是依据一些规则,竭尽全力的把这个很糟糕的语句转换成某种可以比较高效执行的形式,这个过程也可以被称作查询重写(就是人家觉得你写的语句不好,自己再重写一遍)。本章详细介绍一下一些比较重要的重写规则。\n条件化简 # 我们编写的查询语句的搜索条件本质上是一个表达式,这些表达式可能比较繁杂,或者不能高效的执行,MySQL的查询优化器会为我们简化这些表达式。为了方便大家理解,我们后边举例子的时候都使用诸如a、b、c之类的简单字母代表某个表的列名。\n移除不必要的括号 # 有时候表达式里有许多无用的括号,比如这样: ((a = 5 AND b = c) OR ((a \u0026gt; c) AND (c \u0026lt; 5))) 看着就很烦,优化器会把那些用不到的括号给干掉,就是这样: (a = 5 and b = c) OR (a \u0026gt; c AND c \u0026lt; 5)\n常量传递(constant_propagation) # 有时候某个表达式是某个列和某个常量做等值匹配,比如这样: a = 5 当这个表达式和其他涉及列a的表达式使用AND连接起来时,可以将其他表达式中的a的值替换为5,比如这样: a = 5 AND b \u0026gt; a 就可以被转换为: a = 5 AND b \u0026gt; 5 小贴士:为什么用OR连接起来的表达式就不能进行常量传递呢?自己想想~\n等值传递(equality_propagation) # 有时候多个列之间存在等值匹配的关系,比如这样: a = b and b = c and c = 5 这个表达式可以被简化为: a = 5 and b = 5 and c = 5\n移除没用的条件(trivial_condition_removal) # 对于一些明显永远为TRUE或者FALSE的表达式,优化器会移除掉它们,比如这个表达式: (a \u0026lt; 1 and b = b) OR (a = 6 OR 5 != 5) 很明显,b = b这个表达式永远为TRUE,5 != 5这个表达式永远为FALSE,所以简化后的表达式就是这样的: (a \u0026lt; 1 and TRUE) OR (a = 6 OR FALSE) 可以继续被简化为: a \u0026lt; 1 OR a = 6\n表达式计算 # 在查询开始执行之前,如果表达式中只包含常量的话,它的值会被先计算出来,比如这个: a = 5 + 1 因为5 + 1这个表达式只包含常量,所以就会被化简成: a = 6 但是这里需要注意的是,如果某个列并不是以单独的形式作为表达式的操作数时,比如出现在函数中,出现在某个更复杂表达式中,就像这样: ABS(a) \u0026gt; 5 或者: -a \u0026lt; -8 优化器是不会尝试对这些表达式进行化简的。我们前面说过只有搜索条件中索引列和常数使用某些运算符连接起来才可能使用到索引,所以如果可以的话,最好让索引列以单独的形式出现在表达式中。\nHAVING子句和WHERE子句的合并 # 如果查询语句中没有出现诸如SUM、MAX等等的聚集函数以及GROUP BY子句,优化器就把HAVING子句和WHERE子句合并起来。\n常量表检测 # 设计MySQL的大佬觉得下面这两种查询运行的特别快:\n查询的表中一条记录没有,或者只有一条记录。\n小贴士:大家有没有觉得这一条有点儿不对劲,我还没开始查表呢咋就知道这表里边有几条记录呢?这个其实依靠的是统计数据。不过我们说过InnoDB的统计数据数据不准确,所以这一条不能用于使用InnoDB作为存储引擎的表,只能适用于使用Memory或者MyISAM存储引擎的表。\n使用主键等值匹配或者唯一二级索引列等值匹配作为搜索条件来查询某个表。\n设计MySQL的大佬觉得这两种查询花费的时间特别少,少到可以忽略,所以也把通过这两种方式查询的表称之为常量表(英文名:constant tables)。优化器在分析一个查询语句时,先首先执行常量表查询,然后把查询中涉及到该表的条件全部替换成常数,最后再分析其余表的查询成本,比方说这个查询语句: SELECT * FROM table1 INNER JOIN table2 ON table1.column1 = table2.column2 WHERE table1.primary_key = 1; 很明显,这个查询可以使用主键和常量值的等值匹配来查询table1表,也就是在这个查询中table1表相当于常量表,在分析对table2表的查询成本之前,就会执行对table1表的查询,并把查询中涉及table1表的条件都替换掉,也就是上面的语句会被转换成这样: SELECT table1表记录的各个字段的常量值, table2.* FROM table1 INNER JOIN table2 ON table1表column1列的常量值 = table2.column2;\n外连接消除 # 我们前面说过,内连接的驱动表和被驱动表的位置可以相互转换,而左(外)连接和右(外)连接的驱动表和被驱动表是固定的。这就导致内连接可能通过优化表的连接顺序来降低整体的查询成本,而外连接却无法优化表的连接顺序。为了故事的顺利发展,我们还是把之前介绍连接原理时用过的t1和t2表请出来,为了防止大家早就忘掉了,我们再看一下这两个表的结构: ``` CREATE TABLE t1 ( m1 int, n1 char 1)1) Engine=InnoDB, CHARSET=utf8;\nCREATE TABLE t2 ( m2 int, n2 char 1)1) Engine=InnoDB, CHARSET=utf8; 为了唤醒大家的记忆,我们再把这两个表中的数据给展示一下: mysql\u0026gt; SELECT * FROM t1; +\u0026mdash;\u0026mdash;+\u0026mdash;\u0026mdash;+ | m1 | n1 | +\u0026mdash;\u0026mdash;+\u0026mdash;\u0026mdash;+ | 1 | a | | 2 | b | | 3 | c | +\u0026mdash;\u0026mdash;+\u0026mdash;\u0026mdash;+ 3 rows in set (0.00 sec)\nmysql\u0026gt; SELECT * FROM t2; +\u0026mdash;\u0026mdash;+\u0026mdash;\u0026mdash;+ | m2 | n2 | +\u0026mdash;\u0026mdash;+\u0026mdash;\u0026mdash;+ | 2 | b | | 3 | c | | 4 | d | +\u0026mdash;\u0026mdash;+\u0026mdash;\u0026mdash;+ 3 rows in set (0.00 sec) 我们之前说过,**外连接和内连接的本质区别就是:对于外连接的驱动表的记录来说,如果无法在被驱动表中找到匹配ON子句中的过滤条件的记录,那么该记录仍然会被加入到结果集中,对应的被驱动表记录的各个字段使用NULL值填充;而内连接的驱动表的记录如果无法在被驱动表中找到匹配ON子句中的过滤条件的记录,那么该记录会被舍弃**。查询效果就是这样: mysql\u0026gt; SELECT * FROM t1 INNER JOIN t2 ON t1.m1 = t2.m2; +\u0026mdash;\u0026mdash;+\u0026mdash;\u0026mdash;+\u0026mdash;\u0026mdash;+\u0026mdash;\u0026mdash;+ | m1 | n1 | m2 | n2 | +\u0026mdash;\u0026mdash;+\u0026mdash;\u0026mdash;+\u0026mdash;\u0026mdash;+\u0026mdash;\u0026mdash;+ | 2 | b | 2 | b | | 3 | c | 3 | c | +\u0026mdash;\u0026mdash;+\u0026mdash;\u0026mdash;+\u0026mdash;\u0026mdash;+\u0026mdash;\u0026mdash;+ 2 rows in set (0.00 sec)\nmysql\u0026gt; SELECT * FROM t1 LEFT JOIN t2 ON t1.m1 = t2.m2; +\u0026mdash;\u0026mdash;+\u0026mdash;\u0026mdash;+\u0026mdash;\u0026mdash;+\u0026mdash;\u0026mdash;+ | m1 | n1 | m2 | n2 | +\u0026mdash;\u0026mdash;+\u0026mdash;\u0026mdash;+\u0026mdash;\u0026mdash;+\u0026mdash;\u0026mdash;+ | 2 | b | 2 | b | | 3 | c | 3 | c | | 1 | a | NULL | NULL | +\u0026mdash;\u0026mdash;+\u0026mdash;\u0026mdash;+\u0026mdash;\u0026mdash;+\u0026mdash;\u0026mdash;+ 3 rows in set (0.00 sec) ``` 对于上面例子中的(左)外连接来说,由于驱动表t1中m1=1, n1='a'的记录无法在被驱动表t2中找到符合ON子句条件t1.m1 = t2.m2的记录,所以就直接把这条记录加入到结果集,对应的t2表的m2和n2列的值都设置为NULL。\n小贴士:右(外)连接和左(外)连接其实只在驱动表的选取方式上是不同的,其余方面都是一样的,所以优化器会首先把右(外)连接查询转换成左(外)连接查询。我们后边就不再介绍右(外)连接了。 我们知道WHERE子句的杀伤力比较大,凡是不符合WHERE子句中条件的记录都不会参与连接。只要我们在搜索条件中指定关于被驱动表相关列的值不为NULL,那么外连接中在被驱动表中找不到符合ON子句条件的驱动表记录也就被排除出最后的结果集了,也就是说:在这种情况下:外连接和内连接也就没有什么区别了!比方说这个查询: mysql\u0026gt; SELECT * FROM t1 LEFT JOIN t2 ON t1.m1 = t2.m2 WHERE t2.n2 IS NOT NULL; +------+------+------+------+ | m1 | n1 | m2 | n2 | +------+------+------+------+ | 2 | b | 2 | b | | 3 | c | 3 | c | +------+------+------+------+ 2 rows in set (0.01 sec) 由于指定了被驱动表t2的n2列不允许为NULL,所以上面的t1和t2表的左(外)连接查询和内连接查询是一样一样的。当然,我们也可以不用显式的指定被驱动表的某个列IS NOT NULL,只要隐含的有这个意思就行了,比方说这样: mysql\u0026gt; SELECT * FROM t1 LEFT JOIN t2 ON t1.m1 = t2.m2 WHERE t2.m2 = 2; +------+------+------+------+ | m1 | n1 | m2 | n2 | +------+------+------+------+ | 2 | b | 2 | b | +------+------+------+------+ 1 row in set (0.00 sec) 在这个例子中,我们在WHERE子句中指定了被驱动表t2的m2列等于2,也就相当于间接的指定了m2列不为NULL值,所以上面的这个左(外)连接查询其实和下面这个内连接查询是等价的: mysql\u0026gt; SELECT * FROM t1 INNER JOIN t2 ON t1.m1 = t2.m2 WHERE t2.m2 = 2; +------+------+------+------+ | m1 | n1 | m2 | n2 | +------+------+------+------+ | 2 | b | 2 | b | +------+------+------+------+ 1 row in set (0.00 sec) 我们把这种在外连接查询中,指定的WHERE子句中包含被驱动表中的列不为NULL值的条件称之为空值拒绝(英文名:reject-NULL)。在被驱动表的WHERE子句符合空值拒绝的条件后,外连接和内连接可以相互转换。这种转换带来的好处就是查询优化器可以通过评估表的不同连接顺序的成本,选出成本最低的那种连接顺序来执行查询。\n子查询优化 # 我们的主题本来是介绍MySQL查询优化器是如何处理子查询的,但是我还是有一万个担心好多同学连子查询的语法都没掌握全,所以我们就先介绍介绍什么是个子查询(当然不会面面俱到啦,只是说个大概),然后再介绍关于子查询优化的事儿。\n子查询语法 # 想必大家都是妈妈生下来的吧,连孙猴子都有妈妈——石头人。怀孕妈妈肚子里的那个东东就是她的孩子,类似的,在一个查询语句里的某个位置也可以有另一个查询语句,这个出现在某个查询语句的某个位置中的查询就被称为子查询(我们也可以称它为宝宝查询),那个充当“妈妈”角色的查询也被称之为外层查询。不像人们怀孕时宝宝们都只在肚子里,子查询可以在一个外层查询的各种位置出现,比如:\nSELECT子句中\n也就是我们平时说的查询列表中,比如这样:\nmysql\u0026gt; SELECT (SELECT m1 FROM t1 LIMIT 1); +-----------------------------+ | (SELECT m1 FROM t1 LIMIT 1) | +-----------------------------+ | 1 | +-----------------------------+ 1 row in set (0.00 sec) 其中的(SELECT m1 FROM t1 LIMIT 1)就是我们介绍的所谓的子查询。\nFROM子句中\n比如: SELECT m, n FROM (SELECT m2 + 1 AS m, n2 AS n FROM t2 WHERE m2 \u0026gt; 2) AS t; +------+------+ | m | n | +------+------+ | 4 | c | | 5 | d | +------+------+ 2 rows in set (0.00 sec) 这个例子中的子查询是:(SELECT m2 + 1 AS m, n2 AS n FROM t2 WHERE m2 \u0026gt; 2),很特别的地方是它出现在了FROM子句中。FROM子句里边儿不是存放我们要查询的表的名称么,这里放进来一个子查询是个什么鬼?其实这里我们可以把子查询的查询结果当作是一个表,子查询后边的AS t表明这个子查询的结果就相当于一个名称为t的表,这个名叫t的表的列就是子查询结果中的列,比如例子中表t就有两个列:m列和n列。这个放在FROM子句中的子查询本质上相当于一个表,但又和我们平常使用的表有点儿不一样,设计MySQL的大佬把这种由子查询结果集组成的表称之为派生表。\nWHERE或ON子句中\n把子查询放在外层查询的WHERE子句或者ON子句中可能是我们最常用的一种使用子查询的方式了,比如这样:\nmysql\u0026gt; SELECT * FROM t1 WHERE m1 IN (SELECT m2 FROM t2); +------+------+ | m1 | n1 | +------+------+ | 2 | b | | 3 | c | +------+------+ 2 rows in set (0.00 sec) 这个查询表明我们想要将(SELECT m2 FROM t2)这个子查询的结果作为外层查询的IN语句参数,整个查询语句的意思就是我们想找t1表中的某些记录,这些记录的m1列的值能在t2表的m2列找到匹配的值。\nORDER BY子句中\n虽然语法支持,但没什么子意义,不介绍这种情况了。\nGROUP BY子句中\n同上~\n按返回的结果集区分子查询 # 因为子查询本身也算是一个查询,所以可以按照它们返回的不同结果集类型而把这些子查询分为不同的类型:\n标量子查询\n那些只返回一个单一值的子查询称之为标量子查询,比如这样:\nSELECT (SELECT m1 FROM t1 LIMIT 1); 或者这样: SELECT * FROM t1 WHERE m1 = (SELECT MIN(m2) FROM t2);\n这两个查询语句中的子查询都返回一个单一的值,也就是一个标量。这些标量子查询可以作为一个单一值或者表达式的一部分出现在查询语句的各个地方。\n行子查询\n顾名思义,就是返回一条记录的子查询,不过这条记录需要包含多个列(只包含一个列就成了标量子查询了)。比如这样:\nSELECT * FROM t1 WHERE (m1, n1) = (SELECT m2, n2 FROM t2 LIMIT 1); 其中的(SELECT m2, n2 FROM t2 LIMIT 1)就是一个行子查询,整条语句的含义就是要从t1表中找一些记录,这些记录的m1和n2列分别等于子查询结果中的m2和n2列。\n列子查询\n列子查询自然就是查询出一个列的数据喽,不过这个列的数据需要包含多条记录(只包含一条记录就成了标量子查询了)。比如这样:\nSELECT * FROM t1 WHERE m1 IN (SELECT m2 FROM t2); 其中的(SELECT m2 FROM t2)就是一个列子查询,表明查询出t2表的m2列的值作为外层查询IN语句的参数。\n表子查询\n顾名思义,就是子查询的结果既包含很多条记录,又包含很多个列,比如这样:\nSELECT * FROM t1 WHERE (m1, n1) IN (SELECT m2, n2 FROM t2); 其中的(SELECT m2, n2 FROM t2)就是一个表子查询,这里需要和行子查询对比一下,行子查询中我们用了LIMIT 1来保证子查询的结果只有一条记录,表子查询中不需要这个限制。\n按与外层查询关系来区分子查询 # 不相关子查询\n如果子查询可以单独运行出结果,而不依赖于外层查询的值,我们就可以把这个子查询称之为不相关子查询。我们前面介绍的那些子查询全部都可以看作不相关子查询,所以也就不举例子了。\n相关子查询\n如果子查询的执行需要依赖于外层查询的值,我们就可以把这个子查询称之为相关子查询。比如:\nSELECT * FROM t1 WHERE m1 IN (SELECT m2 FROM t2 WHERE n1 = n2); 例子中的子查询是(SELECT m2 FROM t2 WHERE n1 = n2),可是这个查询中有一个搜索条件是n1 = n2,别忘了n1是表t1的列,也就是外层查询的列,也就是说子查询的执行需要依赖于外层查询的值,所以这个子查询就是一个相关子查询。\n子查询在布尔表达式中的使用 # 你说写下面这样的子查询有什么意义: SELECT (SELECT m1 FROM t1 LIMIT 1); 貌似没什么意义~ 我们平时用子查询最多的地方就是把它作为布尔表达式的一部分来作为搜索条件用在WHERE子句或者ON子句里。所以我们这里来总结一下子查询在布尔表达式中的使用场景。\n使用=、\u0026gt;、\u0026lt;、\u0026gt;=、\u0026lt;=、\u0026lt;\u0026gt;、!=、\u0026lt;=\u0026gt;作为布尔表达式的操作符\n这些操作符具体是什么意思就不用我多介绍了吧,如果你不知道的话,那我真的很佩服你是靠着什么勇气一口气看到这里的~ 为了方便,我们就把这些操作符称为comparison_operator吧,所以子查询组成的布尔表达式就长这样:\n操作数 comparison_operator (子查询)\n这里的操作数可以是某个列名,或者是一个常量,或者是一个更复杂的表达式,甚至可以是另一个子查询。但是需要注意的是,这里的子查询只能是标量子查询或者行子查询,也就是子查询的结果只能返回一个单一的值或者只能是一条记录。比如这样(标量子查询):\nSELECT * FROM t1 WHERE m1 \u0026lt; (SELECT MIN(m2) FROM t2); 或者这样(行子查询): SELECT * FROM t1 WHERE (m1, n1) = (SELECT m2, n2 FROM t2 LIMIT 1);\n[NOT] IN/ANY/SOME/ALL子查询\n对于列子查询和表子查询来说,它们的结果集中包含很多条记录,这些记录相当于是一个集合,所以就不能单纯的和另外一个操作数使用comparison_operator来组成布尔表达式了,MySQL通过下面的语法来支持某个操作数和一个集合组成一个布尔表达式:\n+ IN或者NOT IN\n具体的语法形式如下:\n操作数 [NOT] IN (子查询)\n这个布尔表达式的意思是用来判断某个操作数在不在由子查询结果集组成的集合中,比如下面的查询的意思是找出t1表中的某些记录,这些记录存在于子查询的结果集中:\nSELECT * FROM t1 WHERE (m1, n2) IN (SELECT m2, n2 FROM t2);\n+ ANY/SOME(ANY和SOME是同义词)\n具体的语法形式如下:\n操作数 comparison_operator ANY/SOME(子查询)\n这个布尔表达式的意思是只要子查询结果集中存在某个值和给定的操作数做comparison_operator比较结果为TRUE,那么整个表达式的结果就为TRUE,否则整个表达式的结果就为FALSE。比方说下面这个查询:\nSELECT * FROM t1 WHERE m1 \u0026gt; ANY(SELECT m2 FROM t2); 这个查询的意思就是对于t1表的某条记录的m1列的值来说,如果子查询(SELECT m2 FROM t2)的结果集中存在一个小于m1列的值,那么整个布尔表达式的值就是TRUE,否则为FALSE,也就是说只要m1列的值大于子查询结果集中最小的值,整个表达式的结果就是TRUE,所以上面的查询本质上等价于这个查询:\nSELECT * FROM t1 WHERE m1 \u0026gt; (SELECT MIN(m2) FROM t2);\n另外,=ANY相当于判断子查询结果集中是否存在某个值和给定的操作数相等,它的含义和IN是相同的。\n+ ALL\n具体的语法形式如下: 操作数 comparison_operator ALL(子查询)\n这个布尔表达式的意思是子查询结果集中所有的值和给定的操作数做comparison_operator比较结果为TRUE,那么整个表达式的结果就为TRUE,否则整个表达式的结果就为FALSE。比方说下面这个查询:\nSELECT * FROM t1 WHERE m1 \u0026gt; ALL(SELECT m2 FROM t2); 这个查询的意思就是对于t1表的某条记录的m1列的值来说,如果子查询(SELECT m2 FROM t2)的结果集中的所有值都小于m1列的值,那么整个布尔表达式的值就是TRUE,否则为FALSE,也就是说只要m1列的值大于子查询结果集中最大的值,整个表达式的结果就是TRUE,所以上面的查询本质上等价于这个查询:\n``小贴士:觉得ANY和ALL有点晕的同学多看两遍。 ``` + EXISTS子查询 有的时候我们仅仅需要判断子查询的结果集中是否有记录,而不在乎它的记录具体是什么,可以使用把`EXISTS`或者`NOT EXISTS`放在子查询语句前面,就像这样: `[NOT] EXISTS (子查询)` 我们举一个例子啊: `SELECT * FROM t1 WHERE EXISTS (SELECT 1 FROM t2);` 对于子查询`(SELECT 1 FROM t2)`来说,我们并不关心这个子查询最后到底查询出的结果是什么,所以查询列表里填`*`、某个列名,或者其他什么东西都无所谓,我们真正关心的是子查询的结果集中是否存在记录。也就是说只要`(SELECT 1 FROM t2)`这个查询中有记录,那么整个`EXISTS`表达式的结果就为`TRUE`。 ### 子查询语法注意事项 + 子查询必须用小括号扩起来。 不扩起来的子查询是非法的,比如这样: ``` mysql\u0026gt; SELECT SELECT m1 FROM t1; ERROR 1064 (42000): You have an error in your SQL syntax; check the manual that corresponds to your MySQL server version for the right syntax to use near \u0026#39;SELECT m1 FROM t1\u0026#39; at line 1 ``` + 在`SELECT`子句中的子查询必须是标量子查询。 如果子查询结果集中有多个列或者多个行,都不允许放在`SELECT`子句中,也就是查询列表中,比如这样就是非法的: ``` mysql\u0026gt; SELECT (SELECT m1, n1 FROM t1); ERROR 1241 (21000): Operand should contain 1 column(s) ``` - 在想要得到标量子查询或者行子查询,但又不能保证子查询的结果集只有一条记录时,应该使用`LIMIT 1`语句来限制记录数量。 + 对于`[NOT] IN/ANY/SOME/ALL`子查询来说,子查询中不允许有`LIMIT`语句。 比如这样是非法的: ``` mysql\u0026gt; SELECT * FROM t1 WHERE m1 IN (SELECT * FROM t2 LIMIT 2); ERROR 1235 (42000): This version of MySQL doesn\u0026#39;t yet support \u0026#39;LIMIT \u0026amp; IN/ALL/ANY/SOME subquery\u0026#39; ``` 为什么不合法?人家就这么规定的,不解释~ 可能以后的版本会支持吧。正因为`[NOT] IN/ANY/SOME/ALL`子查询不支持`LIMIT`语句,所以子查询中的这些语句也就是多余的了: + `ORDER BY`子句 子查询的结果其实就相当于一个集合,集合里的值排不排序一点儿都不重要,比如下面这个语句中的`ORDER BY`子句简直就是画蛇添足: `SELECT * FROM t1 WHERE m1 IN (SELECT m2 FROM t2 ORDER BY m2);` + `DISTINCT`语句 集合里的值去不去重也没什么意义,比如这样: `SELECT * FROM t1 WHERE m1 IN (SELECT DISTINCT m2 FROM t2);` + 没有聚集函数以及`HAVING`子句的`GROUP BY`子句。 在没有聚集函数以及`HAVING`子句时,`GROUP BY`子句就是个摆设,比如这样: `SELECT * FROM t1 WHERE m1 IN (SELECT m2 FROM t2 GROUP BY m2);` 对于这些冗余的语句,**查询优化器在一开始就把它们给干掉了**。 + 不允许在一条语句中增删改某个表的记录时同时还对该表进行子查询。 比方说这样: ``` mysql\u0026gt; DELETE FROM t1 WHERE m1 \u0026lt; (SELECT MAX\\(m1) FROM t1\\); ERROR 1093 (HY000): You can\u0026#39;t specify target table \u0026#39;t1\u0026#39; for update in FROM clause ``` ## 子查询在MySQL中是怎么执行的 好了,关于子查询的基础语法我们用最快的速度温习了一遍,如果想了解更多语法细节,大家可以去查看一下`MySQL`的文档,现在我们就假设各位都懂了什么是个子查询了喔,接下来就要介绍具体某种类型的子查询在`MySQL`中是怎么执行的了,想想就有点儿小激动呢~ 当然,为了故事的顺利发展,我们的例子也需要跟随形势鸟枪换炮,还是要祭出我们用了n遍的`single_table`表: `CREATE TABLE single_table ( id INT NOT NULL AUTO_INCREMENT, key1 VARCHAR(100), key2 INT, key3 VARCHAR(100), key_part1 VARCHAR(100), key_part2 VARCHAR(100), key_part3 VARCHAR(100), common_field VARCHAR(100), PRIMARY KEY (id), KEY idx_key1 (key1), UNIQUE KEY idx_key2 (key2), KEY idx_key3 (key3), KEY idx_key_part(key_part1, key_part2, key_part3) ) Engine=InnoDB CHARSET=utf8;` 为了方便,我们假设有两个表`s1`、`s2`与这个`single_table`表的构造是相同的,而且这两个表里边儿有10000条记录,除id列外其余的列都插入随机值。下面正式开始我们的表演。 ### 小白们眼中子查询的执行方式 在我还是一个单纯无知的少年时,觉得子查询的执行方式是这样的: + 如果该子查询是不相关子查询,比如下面这个查询: `SELECT * FROM s1 WHERE key1 IN (SELECT common_field FROM s2);` 我年少时觉得这个查询是的执行方式是这样的: + 先单独执行`(SELECT common_field FROM s2)`这个子查询。 + 然后在将上一步子查询得到的结果当作外层查询的参数再执行外层查询`SELECT * FROM s1 WHERE key1 IN (...)`。 + 如果该子查询是相关子查询,比如下面这个查询: `SELECT * FROM s1 WHERE key1 IN (SELECT common_field FROM s2 WHERE s1.key2 = s2.key2);` 这个查询中的子查询中出现了`s1.key2 = s2.key2`这样的条件,意味着该子查询的执行依赖着外层查询的值,所以我年少时觉得这个查询的执行方式是这样的: + 先从外层查询中获取一条记录,本例中也就是先从`s1`表中获取一条记录。 + 然后从上一步骤中获取的那条记录中找出子查询中涉及到的值,本例中就是从`s1`表中获取的那条记录中找出`s1.key2`列的值,然后执行子查询。 + 最后根据子查询的查询结果来检测外层查询`WHERE`子句的条件是否成立,如果成立,就把外层查询的那条记录加入到结果集,否则就丢弃。 + 再次执行第一步,获取第二条外层查询中的记录,依次类推~ 告诉我不只是我一个人是这样认为的,这样认为的同学请举起你们的双手~~~ 哇唔,还真不少~ 其实设计`MySQL`的大佬想了一系列的办法来优化子查询的执行,大部分情况下这些优化措施其实挺有效的,但是保不齐有的时候马失前蹄,下面我们详细介绍各种不同类型的子查询具体是怎么执行的。 `小贴士:我们下面即将介绍的关于MySQL优化子查询的执行方式的事儿都是基于MySQL5.7这个版本的,以后版本可能有更新的优化策略!` ### 标量子查询、行子查询的执行方式 我们经常在下面两个场景中使用到标量子查询或者行子查询: + `SELECT`子句中,我们前面说过的在查询列表中的子查询必须是标量子查询。 + 子查询使用`=`、`\u0026gt;`、`\u0026lt;`、`\u0026gt;=`、`\u0026lt;=`、`\u0026lt;\u0026gt;`、`!=`、`\u0026lt;=\u0026gt;`等操作符和某个操作数组成一个布尔表达式,这样的子查询必须是标量子查询或者行子查询。 对于上述两种场景中的**不相关**标量子查询或者行子查询来说,它们的执行方式是简单的,比方说下面这个查询语句: `SELECT * FROM s1 WHERE key1 = (SELECT common_field FROM s2 WHERE key3 = \u0026#39;a\u0026#39; LIMIT 1);` 它的执行方式和年少的我想的一样: + 先单独执行`(SELECT common_field FROM s2 WHERE key3 = \u0026#39;a\u0026#39; LIMIT 1)`这个子查询。 + 然后在将上一步子查询得到的结果当作外层查询的参数再执行外层查询`SELECT * FROM s1 WHERE key1 = ...`。 也就是说,**对于包含不相关的标量子查询或者行子查询的查询语句来说,MySQL会分别独立的执行外层查询和子查询,就当作两个单表查询就好了**。 对于**相关**的标量子查询或者行子查询来说,比如下面这个查询: `SELECT * FROM s1 WHERE key1 = (SELECT common_field FROM s2 WHERE s1.key3 = s2.key3 LIMIT 1);` 事情也和年少的我想的一样,它的执行方式就是这样的: + 先从外层查询中获取一条记录,本例中也就是先从`s1`表中获取一条记录。 + 然后从上一步骤中获取的那条记录中找出子查询中涉及到的值,本例中就是从`s1`表中获取的那条记录中找出`s1.key3`列的值,然后执行子查询。 + 最后根据子查询的查询结果来检测外层查询`WHERE`子句的条件是否成立,如果成立,就把外层查询的那条记录加入到结果集,否则就丢弃。 + 再次执行第一步,获取第二条外层查询中的记录,依次类推~ 也就是说对于一开始介绍的两种使用标量子查询以及行子查询的场景中,`MySQL`优化器的执行方式并没有什么新鲜的。 ### IN子查询优化 #### 物化表的提出 对于不相关的`IN`子查询,比如这样: `SELECT * FROM s1 WHERE key1 IN (SELECT common_field FROM s2 WHERE key3 = \u0026#39;a\u0026#39;);` 我们最开始的感觉就是这种不相关的`IN`子查询和不相关的标量子查询或者行子查询是一样一样的,都是把外层查询和子查询当作两个独立的单表查询来对待,可是很遗憾的是设计`MySQL`的大佬为了优化`IN`子查询倾注了太多心血(毕竟`IN`子查询是我们日常生活中最常用的子查询类型),所以整个执行过程并不像我们想象的那么简单(\u0026gt;_\u0026lt;)。 其实说句老实话,对于不相关的`IN`子查询来说,如果子查询的结果集中的记录条数很少,那么把子查询和外层查询分别看成两个单独的单表查询效率还是蛮高的,但是如果单独执行子查询后的结果集太多的话,就会导致这些问题: + 结果集太多,可能内存中都放不下~ + 对于外层查询来说,如果子查询的结果集太多,那就意味着`IN`子句中的参数特别多,这就导致: + 无法有效的使用索引,只能对外层查询进行全表扫描。 + 在对外层查询执行全表扫描时,由于`IN`子句中的参数太多,这会导致检测一条记录是否符合和`IN`子句中的参数匹配花费的时间太长。 比如说`IN`子句中的参数只有两个: `SELECT * FROM tbl_name WHERE column IN (a, b);` 这样相当于需要对`tbl_name`表中的每条记录判断一下它的`column`列是否符合`column = a OR column = b`。在`IN`子句中的参数比较少时这并不是什么问题,如果`IN`子句中的参数比较多时,比如这样: `SELECT * FROM tbl_name WHERE column IN (a, b, c ..., ...);` 那么这样每条记录需要判断一下它的`column`列是否符合`column = a OR column = b OR column = c OR ...`,这样性能耗费可就多了。 于是乎设计`MySQL`的大佬想了一个招:**不直接将不相关子查询的结果集当作外层查询的参数,而是将该结果集写入一个临时表里**。写入临时表的过程是这样的: + 该临时表的列就是子查询结果集中的列。 + 写入临时表的记录会被去重。 我们说`IN`语句是判断某个操作数在不在某个集合中,集合中的值重不重复对整个`IN`语句的结果并没有什么子关系,所以我们在将结果集写入临时表时对记录进行去重可以让临时表变得更小,更省地方~ `小贴士:临时表如何对记录进行去重?这不是小意思嘛,临时表也是个表,只要为表中记录的所有列建立主键或者唯一索引就好了嘛~` + 一般情况下子查询结果集不会大的离谱,所以会为它建立基于内存的使用`Memory`存储引擎的临时表,而且会为该表建立希索引。 `小贴士:IN语句的本质就是判断某个操作数在不在某个集合里,如果集合中的数据建立了哈希索引,那么这个匹配的过程就是超级快的。 有同学不知道哈希索引是什么?我这里就不展开了,自己上网找找吧,不会了再来问我~` 如果子查询的结果集非常大,超过了系统变量`tmp_table_size`或者`max_heap_table_size`,临时表会转而使用基于磁盘的存储引擎来保存结果集中的记录,索引类型也对应转变为`B+`树索引。 设计`MySQL`的大佬把这个将子查询结果集中的记录保存到临时表的过程称之为`物化`(英文名:`Materialize`)。为了方便起见,我们就把那个存储子查询结果集的临时表称之为`物化表`。正因为物化表中的记录都建立了索引(基于内存的物化表有哈希索引,基于磁盘的有B\\+树索引),通过索引执行`IN`语句判断某个操作数在不在子查询结果集中变得非常快,从而提升了子查询语句的性能。 #### 物化表转连接 事情到这就完了?我们还得重新审视一下最开始的那个查询语句: `SELECT * FROM s1 WHERE key1 IN (SELECT common_field FROM s2 WHERE key3 = \u0026#39;a\u0026#39;);` 当我们把子查询进行物化之后,假设子查询物化表的名称为`materialized_table`,该物化表存储的子查询结果集的列为`m_val`,那么这个查询其实可以从下面两种角度来看待: + 从表`s1`的角度来看待,整个查询的意思其实是:对于`s1`表中的每条记录来说,如果该记录的`key1`列的值在子查询对应的物化表中,则该记录会被加入最终的结果集。画个图表示一下就是这样: ![](img/14-01.png) + 从子查询物化表的角度来看待,整个查询的意思其实是:对于子查询物化表的每个值来说,如果能在`s1`表中找到对应的`key1`列的值与该值相等的记录,那么就把这些记录加入到最终的结果集。画个图表示一下就是这样: ![](img/14-02.png) 也就是说其实上面的查询就相当于表`s1`和子查询物化表`materialized_table`进行内连接: `SELECT s1.* FROM s1 INNER JOIN materialized_table ON key1 = m_val;` 转化成内连接之后就有意思了,查询优化器可以评估不同连接顺序需要的成本是多少,选取成本最低的那种查询方式执行查询。我们分析一下上述查询中使用外层查询的表`s1`和物化表`materialized_table`进行内连接的成本都是由哪几部分组成的: + 如果使用`s1`表作为驱动表的话,总查询成本由下面几个部分组成: + 物化子查询时需要的成本 + 扫描`s1`表时的成本 + s1表中的记录数量 × 通过`m_val = xxx`对`materialized_table`表进行单表访问的成本(我们前面说过物化表中的记录是不重复的,并且为物化表中的列建立了索引,所以这个步骤显然是非常快的)。 + 如果使用`materialized_table`表作为驱动表的话,总查询成本由下面几个部分组成: + 物化子查询时需要的成本 + 扫描物化表时的成本 + 物化表中的记录数量 × 通过`key1 = xxx`对`s1`表进行单表访问的成本(非常庆幸`key1`列上建立了索引,所以这个步骤是非常快的)。 `MySQL`查询优化器会通过运算来选择上述成本更低的方案来执行查询。 #### 将子查询转换为semi-join 虽然将子查询进行物化之后再执行查询都会有建立临时表的成本,但是不管怎么说,我们见识到了将子查询转换为连接的强大作用,设计`MySQL`的大佬继续开脑洞:能不能不进行物化操作直接把子查询转换为连接呢?让我们重新审视一下上面的查询语句: `SELECT * FROM s1 WHERE key1 IN (SELECT common_field FROM s2 WHERE key3 = \u0026#39;a\u0026#39;);` 我们可以把这个查询理解成:对于`s1`表中的某条记录,如果我们能在`s2`表(准确的说是执行完`WHERE s2.key3 = \u0026#39;a\u0026#39;`之后的结果集)中找到一条或多条记录,这些记录的`common_field`的值等于`s1`表记录的`key1`列的值,那么该条`s1`表的记录就会被加入到最终的结果集。这个过程其实和把`s1`和`s2`两个表连接起来的效果很像: `SELECT s1.* FROM s1 INNER JOIN s2 ON s1.key1 = s2.common_field WHERE s2.key3 = \u0026#39;a\u0026#39;;` 只不过我们不能保证对于`s1`表的某条记录来说,在`s2`表(准确的说是执行完`WHERE s2.key3 = \u0026#39;a\u0026#39;`之后的结果集)中有多少条记录满足`s1.key1 = s2.common_field`这个条件,不过我们可以分三种情况讨论: + 情况一:对于`s1`表的某条记录来说,`s2`表中**没有**任何记录满足`s1.key1 = s2.common_field`这个条件,那么该记录自然也不会加入到最后的结果集。 + 情况二:对于`s1`表的某条记录来说,`s2`表中**有且只有**记录满足`s1.key1 = s2.common_field`这个条件,那么该记录会被加入最终的结果集。 + 情况三:对于`s1`表的某条记录来说,`s2`表中**至少有2条**记录满足`s1.key1 = s2.common_field`这个条件,那么该记录会被**多次**加入最终的结果集。 对于`s1`表的某条记录来说,由于我们只关心`s2`表中**是否存在**记录满足`s1.key1 = s2.common_field`这个条件,而**不关心具体有多少条记录与之匹配**,又因为有`情况三`的存在,我们上面所说的`IN`子查询和两表连接之间并不完全等价。但是将子查询转换为连接又真的可以充分发挥优化器的作用,所以设计`MySQL`的大佬在这里提出了一个新概念 --- `半连接`(英文名:`semi-join`)。将`s1`表和`s2`表进行半连接的意思就是:**对于`s1`表的某条记录来说,我们只关心在`s2`表中是否存在与之匹配的记录是否存在,而不关心具体有多少条记录与之匹配,最终的结果集中只保留`s1`表的记录**。为了让大家有更直观的感受,我们假设MySQL内部是这么改写上面的子查询的: `SELECT s1.* FROM s1 SEMI JOIN s2 ON s1.key1 = s2.common_field WHERE key3 = \u0026#39;a\u0026#39;;` `小贴士:semi-join只是在MySQL内部采用的一种执行子查询的方式,MySQL并没有提供面向用户的semi-join语法,所以我们不需要,也不能尝试把上面这个语句放到黑框框里运行,我只是想说明一下上面的子查询在MySQL内部会被转换为类似上面语句的半连接~` 概念是有了,怎么实现这种所谓的`半连接`呢?设计`MySQL`的大佬准备了好几种办法。 + Table pullout (子查询中的表上拉) 当**子查询的查询列表处只有主键或者唯一索引列**时,可以直接把子查询中的表`上拉`到外层查询的`FROM`子句中,并把子查询中的搜索条件合并到外层查询的搜索条件中,比如这个 `SELECT * FROM s1 WHERE key2 IN (SELECT key2 FROM s2 WHERE key3 = \u0026#39;a\u0026#39;);` 由于`key2`列是`s2`表的唯一二级索引列,所以我们可以直接把`s2`表上拉到外层查询的`FROM`子句中,并且把子查询中的搜索条件合并到外层查询的搜索条件中,上拉之后的查询就是这样的: `SELECT s1.* FROM s1 INNER JOIN s2 ON s1.key2 = s2.key2 WHERE s2.key3 = \u0026#39;a\u0026#39;;` 为什么当子查询的查询列表处只有主键或者唯一索引列时,就可以直接将子查询转换为连接查询呢?哎呀,主键或者唯一索引列中的数据本身就是不重复的嘛!所以对于同一条`s1`表中的记录,你不可能找到两条以上的符合`s1.key2 = s2.key2`的记录呀~ + DuplicateWeedout execution strategy (重复值消除) 对于这个查询来说: `SELECT * FROM s1 WHERE key1 IN (SELECT common_field FROM s2 WHERE key3 = \u0026#39;a\u0026#39;);` 转换为半连接查询后,`s1`表中的某条记录可能在`s2`表中有多条匹配的记录,所以该条记录可能多次被添加到最后的结果集中,为了消除重复,我们可以建立一个临时表,比方说这个临时表长这样: `CREATE TABLE tmp ( id PRIMARY KEY );` 这样在执行连接查询的过程中,每当某条`s1`表中的记录要加入结果集时,就首先把这条记录的`id`值加入到这个临时表里,如果添加成功,说明之前这条`s1`表中的记录并没有加入最终的结果集,现在把该记录添加到最终的结果集;如果添加失败,说明这条之前这条`s1`表中的记录已经加入过最终的结果集,这里直接把它丢弃就好了,这种使用临时表消除`semi-join`结果集中的重复值的方式称之为`DuplicateWeedout`。 + LooseScan execution strategy (松散索引扫描) 大家看这个查询: `SELECT * FROM s1 WHERE key3 IN (SELECT key1 FROM s2 WHERE key1 \u0026gt; \u0026#39;a\u0026#39; AND key1 \u0026lt; \u0026#39;b\u0026#39;);` 在子查询中,对于`s2`表的访问可以使用到`key1`列的索引,而恰好子查询的查询列表处就是`key1`列,这样在将该查询转换为半连接查询后,如果将`s2`作为驱动表执行查询的话,那么执行过程就是这样: ![](img/14-03.png) 如图所示,在`s2`表的`idx_key1`索引中,值为`\u0026#39;aa\u0026#39;`的二级索引记录一共有3条,那么只需要取第一条的值到`s1`表中查找`s1.key3 = \u0026#39;aa\u0026#39;`的记录,如果能在`s1`表中找到对应的记录,那么就把对应的记录加入到结果集。依此类推,其他值相同的二级索引记录,也只需要取第一条记录的值到`s1`表中找匹配的记录,这种虽然是扫描索引,但只取值相同的记录的第一条去做匹配操作的方式称之为`松散索引扫描`。 + Semi-join Materialization execution strategy 我们之前介绍的先把外层查询的`IN`子句中的不相关子查询进行物化,然后再进行外层查询的表和物化表的连接本质上也算是一种`semi-join`,只不过由于物化表中没有重复的记录,所以可以直接将子查询转为连接查询。 + FirstMatch execution strategy (首次匹配) `FirstMatch`是一种最原始的半连接执行方式,跟我们年少时认为的相关子查询的执行方式是一样一样的,就是说先取一条外层查询的中的记录,然后到子查询的表中寻找符合匹配条件的记录,如果能找到一条,则将该外层查询的记录放入最终的结果集并且停止查找更多匹配的记录,如果找不到则把该外层查询的记录丢弃掉;然后再开始取下一条外层查询中的记录,重复上面这个过程。 对于某些使用`IN`语句的**相关**子查询,比方这个查询: `SELECT * FROM s1 WHERE key1 IN (SELECT common_field FROM s2 WHERE s1.key3 = s2.key3);` 它也可以很方便的转为半连接,转换后的语句类似这样: `SELECT s1.* FROM s1 SEMI JOIN s2 ON s1.key1 = s2.common_field AND s1.key3 = s2.key3;` 然后就可以使用我们上面介绍过的`DuplicateWeedout`、`LooseScan`、`FirstMatch`等半连接执行策略来执行查询,当然,如果子查询的查询列表处只有主键或者唯一二级索引列,还可以直接使用`table pullout`的策略来执行查询,但是需要大家注意的是,**由于相关子查询并不是一个独立的查询,所以不能转换为物化表来执行查询**。 #### semi-join的适用条件 当然,并不是所有包含`IN`子查询的查询语句都可以转换为`semi-join`,只有形如这样的查询才可以被转换为`semi-join`: ``` SELECT ... FROM outer_tables WHERE expr IN (SELECT ... FROM inner_tables ...) AND ... `或者这样的形式也可以:` SELECT ... FROM outer_tables WHERE (oe1, oe2, ...) IN (SELECT ie1, ie2, ... FROM inner_tables ...) AND ... ``` 用文字总结一下,只有符合下面这些条件的子查询才可以被转换为`semi-join`: + 该子查询必须是和`IN`语句组成的布尔表达式,并且在外层查询的`WHERE`或者`ON`子句中出现。 + 外层查询也可以有其他的搜索条件,只不过和`IN`子查询的搜索条件必须使用`AND`连接起来。 + 该子查询必须是一个单一的查询,不能是由若干查询由`UNION`连接起来的形式。 + 该子查询不能包含`GROUP BY`或者`HAVING`语句或者聚集函数。 + ... 还有一些条件比较少见,就不介绍啦~ #### 不适用于semi-join的情况 对于一些不能将子查询转位`semi-join`的情况,典型的比如下面这几种: + 外层查询的WHERE条件中有其他搜索条件与IN子查询组成的布尔表达式使用`OR`连接起来 `SELECT * FROM s1 WHERE key1 IN (SELECT common_field FROM s2 WHERE key3 = \u0026#39;a\u0026#39;) OR key2 \u0026gt; 100;` + 使用`NOT IN`而不是`IN`的情况 `SELECT * FROM s1 WHERE key1 NOT IN (SELECT common_field FROM s2 WHERE key3 = \u0026#39;a\u0026#39;)` + 在`SELECT`子句中的IN子查询的情况 `SELECT key1 IN (SELECT common_field FROM s2 WHERE key3 = \u0026#39;a\u0026#39;) FROM s1 ;` + 子查询中包含`GROUP BY`、`HAVING`或者聚集函数的情况 `SELECT * FROM s1 WHERE key2 IN (SELECT COUNT(*) FROM s2 GROUP BY key1);` + 子查询中包含`UNION`的情况 `SELECT * FROM s1 WHERE key1 IN ( SELECT common_field FROM s2 WHERE key3 = \u0026#39;a\u0026#39; UNION SELECT common_field FROM s2 WHERE key3 = \u0026#39;b\u0026#39; );` `MySQL`仍然留了两手绝活来优化不能转为`semi-join`查询的子查询,那就是: + 对于不相关子查询来说,可以尝试把它们物化之后再参与查询 比如我们上面提到的这个查询: `SELECT * FROM s1 WHERE key1 NOT IN (SELECT common_field FROM s2 WHERE key3 = \u0026#39;a\u0026#39;)` 先将子查询物化,然后再判断`key1`是否在物化表的结果集中可以加快查询执行的速度。 `小贴士:请注意这里将子查询物化之后不能转为和外层查询的表的连接,只能是先扫描s1表,然后对s1表的某条记录来说,判断该记录的key1值在不在物化表中。` + 不管子查询是相关的还是不相关的,都可以把`IN`子查询尝试专为`EXISTS`子查询 其实对于任意一个IN子查询来说,都可以被转为`EXISTS`子查询,通用的例子如下: `outer_expr IN (SELECT inner_expr FROM ... WHERE subquery_where)` 可以被转换为: `EXISTS (SELECT inner_expr FROM ... WHERE subquery_where AND outer_expr=inner_expr)` 当然这个过程中有一些特殊情况,比如在`outer_expr`或者`inner_expr`值为`NULL`的情况下就比较特殊。因为有`NULL`值作为操作数的表达式结果往往是`NULL`,比方说: ``` mysql\u0026gt; SELECT NULL IN (1, 2, 3); \\+-------------------\\+ | NULL IN (1, 2, 3) | \\+-------------------\\+ | NULL | \\+-------------------\\+ 1 row in set (0.00 sec) mysql\u0026gt; SELECT 1 IN (1, 2, 3); \\+----------------\\+ | 1 IN (1, 2, 3) | \\+----------------\\+ | 1 | \\+----------------\\+ 1 row in set (0.00 sec) mysql\u0026gt; SELECT NULL IN (NULL); \\+----------------\\+ | NULL IN (NULL) | \\+----------------\\+ | NULL | \\+----------------\\+ 1 row in set (0.00 sec) `而`EXISTS`子查询的结果肯定是`TRUE`或者`FASLE`:` mysql\u0026gt; SELECT EXISTS (SELECT 1 FROM s1 WHERE NULL = 1); \\+------------------------------------------\\+ | EXISTS (SELECT 1 FROM s1 WHERE NULL = 1) | \\+------------------------------------------\\+ | 0 | \\+------------------------------------------\\+ 1 row in set (0.01 sec) mysql\u0026gt; SELECT EXISTS (SELECT 1 FROM s1 WHERE 1 = NULL); \\+------------------------------------------\\+ | EXISTS (SELECT 1 FROM s1 WHERE 1 = NULL) | \\+------------------------------------------\\+ | 0 | \\+------------------------------------------\\+ 1 row in set (0.00 sec) mysql\u0026gt; SELECT EXISTS (SELECT 1 FROM s1 WHERE NULL = NULL); \\+---------------------------------------------\\+ | EXISTS (SELECT 1 FROM s1 WHERE NULL = NULL) | \\+---------------------------------------------\\+ | 0 | \\+---------------------------------------------\\+ 1 row in set (0.00 sec) ``` 但是幸运的是,我们大部分使用`IN`子查询的场景是把它放在`WHERE`或者`ON`子句中,而`WHERE`或者`ON`子句是不区分`NULL`和`FALSE`的,比方说: ``` mysql\u0026gt; SELECT 1 FROM s1 WHERE NULL; Empty set (0.00 sec) mysql\u0026gt; SELECT 1 FROM s1 WHERE FALSE; Empty set (0.00 sec) `所以只要我们的`IN`子查询是放在`WHERE`或者`ON`子句中的,那么`IN -\u0026gt; EXISTS`的转换就是没问题的。说了这么多,为什么要转换呢?这是因为不转换的话可能用不到索引,比方说下面这个查询:` SELECT * FROM s1 WHERE key1 IN (SELECT key3 FROM s2 where s1.common_field = s2.common_field) OR key2 \u0026gt; 1000; ``` 这个查询中的子查询是一个相关子查询,而且子查询执行的时候不能使用到索引,但是将它转为`EXISTS`子查询后却可以使用到索引: `SELECT * FROM s1 WHERE EXISTS (SELECT 1 FROM s2 where s1.common_field = s2.common_field AND s2.key3 = s1.key1) OR key2 \u0026gt; 1000;` 转为`EXISTS`子查询时便可以使用到`s2`表的`idx_key3`索引了。 需要注意的是,如果`IN`子查询不满足转换为`semi-join`的条件,又不能转换为物化表或者转换为物化表的成本太大,那么它就会被转换为`EXISTS`查询。 `小贴士:在MySQL5.5以及之前的版本没有引进semi-join和物化的方式优化子查询时,优化器都会把IN子查询转换为EXISTS子查询,好多同学就惊呼我明明写的是一个不相关子查询,为什么要按照执行相关子查询的方式来执行呢?所以当时好多声音都是建议大家把子查询转为连接,不过随着MySQL的发展,最近的版本中引入了非常多的子查询优化策略,大家可以稍微放心的使用子查询了,内部的转换工作优化器会为大家自动实现。` #### 小结一下 + 如果`IN`子查询符合转换为`semi-join`的条件,查询优化器会优先把该子查询为`semi-join`,然后再考虑下面5种执行半连接的策略中哪个成本最低: + Table pullout + DuplicateWeedout + LooseScan + Materialization + FirstMatch 选择成本最低的那种执行策略来执行子查询。 + 如果`IN`子查询不符合转换为`semi-join`的条件,那么查询优化器会从下面两种策略中找出一种成本更低的方式执行子查询: + 先将子查询物化之后再执行查询 + 执行`IN to EXISTS`转换。 ### ANY/ALL子查询优化 如果ANY/ALL子查询是不相关子查询的话,它们在很多场合都能转换成我们熟悉的方式去执行,比方说: 原始表达式 转换为 \u0026lt; ANY (SELECT inner_expr ...) \u0026lt; (SELECT MAX\\(inner_expr) ...\\) \u0026gt; ANY (SELECT inner_expr ...) \u0026gt; (SELECT MIN\\(inner_expr) ...\\) \u0026lt; ALL (SELECT inner_expr ...) \u0026lt; (SELECT MIN\\(inner_expr) ...\\) \u0026gt; ALL (SELECT inner_expr ...) \u0026gt; (SELECT MAX\\(inner_expr) ...\\) ### [NOT] EXISTS子查询的执行 如果`[NOT] EXISTS`子查询是不相关子查询,可以先执行子查询,得出该`[NOT] EXISTS`子查询的结果是`TRUE`还是`FALSE`,并重写原先的查询语句,比如对这个查询来说: `SELECT * FROM s1 WHERE EXISTS (SELECT 1 FROM s2 WHERE key1 = \u0026#39;a\u0026#39;) OR key2 \u0026gt; 100;` 因为这个语句里的子查询是不相关子查询,所以优化器会首先执行该子查询,假设该EXISTS子查询的结果为`TRUE`,那么接着优化器会重写查询为: `SELECT * FROM s1 WHERE TRUE OR key2 \u0026gt; 100;` 进一步简化后就变成了: `SELECT * FROM s1 WHERE TRUE;` 对于相关的`[NOT] EXISTS`子查询来说,比如这个查询: `SELECT * FROM s1 WHERE EXISTS (SELECT 1 FROM s2 WHERE s1.common_field = s2.common_field);` 很不幸,这个查询只能按照我们年少时的那种执行相关子查询的方式来执行。不过如果`[NOT] EXISTS`子查询中如果可以使用索引的话,那查询速度也会加快不少,比如: `SELECT * FROM s1 WHERE EXISTS (SELECT 1 FROM s2 WHERE s1.common_field = s2.key1);` 上面这个`EXISTS`子查询中可以使用`idx_key1`来加快查询速度。 ### 对于派生表的优化 我们前面说过把子查询放在外层查询的`FROM`子句后,那么这个子查询的结果相当于一个`派生表`,比如下面这个查询: `SELECT * FROM ( SELECT id AS d_id, key3 AS d_key3 FROM s2 WHERE key1 = \u0026#39;a\u0026#39; ) AS derived_s1 WHERE d_key3 = \u0026#39;a\u0026#39;;` 子查询`( SELECT id AS d_id, key3 AS d_key3 FROM s2 WHERE key1 = \u0026#39;a\u0026#39;)`的结果就相当于一个派生表,这个表的名称是`derived_s1`,该表有两个列,分别是`d_id`和`d_key3`。 对于含有`派生表`的查询,`MySQL`提供了两种执行策略: + 最容易想到的就是把派生表物化。 我们可以将派生表的结果集写到一个内部的临时表中,然后就把这个物化表当作普通表一样参与查询。当然,在对派生表进行物化时,设计`MySQL`的大佬使用了一种称为`延迟物化`的策略,也就是在查询中真正使用到派生表时才回去尝试物化派生表,而不是还没开始执行查询呢就把派生表物化掉。比方说对于下面这个含有派生表的查询来说: `SELECT * FROM ( SELECT * FROM s1 WHERE key1 = \u0026#39;a\u0026#39; ) AS derived_s1 INNER JOIN s2 ON derived_s1.key1 = s2.key1 WHERE s2.key2 = 1;` 如果采用物化派生表的方式来执行这个查询的话,那么执行时首先会到`s1`表中找出满足`s1.key2 = 1`的记录,如果压根儿找不到,说明参与连接的`s1`表记录就是空的,所以整个查询的结果集就是空的,所以也就没有必要去物化查询中的派生表了。 + 将派生表和外层的表合并,也就是将查询重写为没有派生表的形式 我们来看这个贼简单的包含派生表的查询: `SELECT * FROM (SELECT * FROM s1 WHERE key1 = \u0026#39;a\u0026#39;) AS derived_s1;` 这个查询本质上就是想查看`s1`表中满足`key1 = \u0026#39;a\u0026#39;`条件的的全部记录,所以和下面这个语句是等价的: `SELECT * FROM s1 WHERE key1 = \u0026#39;a\u0026#39;;` 对于一些稍微复杂的包含派生表的语句,比如我们上面提到的那个: `SELECT * FROM ( SELECT * FROM s1 WHERE key1 = \u0026#39;a\u0026#39; ) AS derived_s1 INNER JOIN s2 ON derived_s1.key1 = s2.key1 WHERE s2.key2 = 1;` 我们可以将派生表与外层查询的表合并,然后将派生表中的搜索条件放到外层查询的搜索条件中,就像这样: `SELECT * FROM s1 INNER JOIN s2 ON s1.key1 = s2.key1 WHERE s1.key1 = \u0026#39;a\u0026#39; AND s2.key2 = 1;` 这样通过将外层查询和派生表合并的方式成功的消除了派生表,也就意味着我们没必要再付出创建和访问临时表的成本了。可是并不是所有带有派生表的查询都能被成功的和外层查询合并,当派生表中有这些语句就不可以和外层查询合并: + 聚集函数,比如MAX()、MIN()、SUM()什么的 + DISTINCT + GROUP BY + HAVING + LIMIT + UNION 或者 UNION ALL + 派生表对应的子查询的`SELECT`子句中含有另一个子查询 + ... 还有些不常用的情况就不多说了~ 所以`MySQL`在执行带有派生表的时候,优先尝试把派生表和外层查询合并掉,如果不行的话,再把派生表物化掉执行查询。 "},{"id":20,"href":"/zh/docs/technology/MySQL/MySQL%E6%98%AF%E6%80%8E%E6%A0%B7%E8%BF%90%E8%A1%8C%E7%9A%84/%E7%AC%AC13%E7%AB%A0_%E5%85%B5%E9%A9%AC%E6%9C%AA%E5%8A%A8%E7%B2%AE%E8%8D%89%E5%85%88%E8%A1%8C-InnoDB%E7%BB%9F%E8%AE%A1%E6%95%B0%E6%8D%AE%E6%98%AF%E5%A6%82%E4%BD%95%E6%94%B6%E9%9B%86%E7%9A%84/","title":"第13章_兵马未动粮草先行-InnoDB统计数据是如何收集的","section":"My Sql是怎样运行的","content":"第13章 兵马未动,粮草先行-InnoDB统计数据是如何收集的\n我们前面介绍查询成本的时候经常用到一些统计数据,比如通过SHOW TABLE STATUS可以看到关于表的统计数据,通过SHOW INDEX可以看到关于索引的统计数据,那么这些统计数据是怎么来的呢?它们是以什么方式收集的呢?本章将聚焦于InnoDB存储引擎的统计数据收集策略,看完本章大家就会明白为什么前面老说InnoDB的统计信息是不精确的估计值了(言下之意就是我们不打算介绍MyISAM存储引擎统计数据的收集和存储方式,有想了解的同学自己个儿看看文档)。\n两种不同的统计数据存储方式 # InnoDB提供了两种存储统计数据的方式:\n永久性的统计数据\n这种统计数据存储在磁盘上,也就是服务器重启之后这些统计数据还在。\n非永久性的统计数据\n这种统计数据存储在内存中,当服务器关闭时这些这些统计数据就都被清除掉了,等到服务器重启之后,在某些适当的场景下才会重新收集这些统计数据。\n设计MySQL的大佬们给我们提供了系统变量innodb_stats_persistent来控制到底采用哪种方式去存储统计数据。在MySQL 5.6.6之前,innodb_stats_persistent的值默认是OFF,也就是说InnoDB的统计数据默认是存储到内存的,之后的版本中innodb_stats_persistent的值默认是ON,也就是统计数据默认被存储到磁盘中。\n不过InnoDB默认是以表为单位来收集和存储统计数据的,也就是说我们可以把某些表的统计数据(以及该表的索引统计数据)存储在磁盘上,把另一些表的统计数据存储在内存中。怎么做到的呢?我们可以在创建和修改表的时候通过指定STATS_PERSISTENT属性来指明该表的统计数据存储方式: CREATE TABLE 表名 (...) Engine=InnoDB, STATS_PERSISTENT = (1|0); ALTER TABLE 表名 Engine=InnoDB, STATS_PERSISTENT = (1|0); 当STATS_PERSISTENT=1时,表明我们想把该表的统计数据永久的存储到磁盘上,当STATS_PERSISTENT=0时,表明我们想把该表的统计数据临时的存储到内存中。如果我们在创建表时未指定STATS_PERSISTENT属性,那默认采用系统变量innodb_stats_persistent的值作为该属性的值。\n基于磁盘的永久性统计数据 # 当我们选择把某个表以及该表索引的统计数据存放到磁盘上时,实际上是把这些统计数据存储到了两个表里:\nmysql\u0026gt; SHOW TABLES FROM mysql LIKE 'innodb%'; +---------------------------+ | Tables_in_mysql (innodb%) | +---------------------------+ | innodb_index_stats | | innodb_table_stats | +---------------------------+ 2 rows in set (0.01 sec) 可以看到,这两个表都位于mysql系统数据库下面,其中: - innodb_table_stats存储了关于表的统计数据,每一条记录对应着一个表的统计数据。 - innodb_index_stats存储了关于索引的统计数据,每一条记录对应着一个索引的一个统计项的统计数据。\n我们下面的任务就是看一下这两个表里边都有什么以及表里的数据是如何生成的。\ninnodb_table_stats # 直接看一下这个innodb_table_stats表中的各个列都是干嘛的: 字段名 描述 database_name 数据库名 table_name 表名 last_update 本条记录最后更新时间 n_rows 表中记录的条数 clustered_index_size 表的聚簇索引占用的页面数量 sum_of_other_index_sizes 表的其他索引占用的页面数量 注意这个表的主键是(database_name,table_name),也就是innodb_table_stats表的每条记录代表着一个表的统计信息。我们直接看一下这个表里的内容: mysql\u0026gt; SELECT * FROM mysql.innodb_table_stats; +---------------+---------------+---------------------+--------+----------------------+--------------------------+ | database_name | table_name | last_update | n_rows | clustered_index_size | sum_of_other_index_sizes | +---------------+---------------+---------------------+--------+----------------------+--------------------------+ | mysql | gtid_executed | 2018-07-10 23:51:36 | 0 | 1 | 0 | | sys | sys_config | 2018-07-10 23:51:38 | 5 | 1 | 0 | | xiaohaizi | single_table | 2018-12-10 17:03:13 | 9693 | 97 | 175 | +---------------+---------------+---------------------+--------+----------------------+--------------------------+ 3 rows in set (0.01 sec) 可以看到我们熟悉的single_table表的统计信息就对应着mysql.innodb_table_stats的第三条记录。几个重要统计信息项的值如下: - n_rows的值是9693,表明single_table表中大约有9693条记录,注意这个数据是估计值。 - clustered_index_size的值是97,表明single_table表的聚簇索引占用97个页面,这个值是也是一个估计值。 - sum_of_other_index_sizes的值是175,表明single_table表的其他索引一共占用175个页面,这个值是也是一个估计值。\nn_rows统计项的收集 # 为什么老强调n_rows这个统计项的值是估计值呢?现在就来揭晓答案。InnoDB统计一个表中有多少行记录的套路是这样的:\n按照一定算法(并不是纯粹随机的)选取几个叶子节点页面,计算每个页面中主键值记录数量,然后计算平均一个页面中主键值的记录数量乘以全部叶子节点的数量就算是该表的n_rows值。\n小贴士:真实的计算过程比这个稍微复杂一些,不过大致上就是这样的啦~\n可以看出来这个n_rows值精确与否取决于统计时采样的页面数量,设计MySQL的大佬很贴心的为我们准备了一个名为innodb_stats_persistent_sample_pages的系统变量来控制使用永久性的统计数据时,计算统计数据时采样的页面数量。该值设置的越大,统计出的n_rows值越精确,但是统计耗时也就最久;该值设置的越小,统计出的n_rows值越不精确,但是统计耗时特别少。所以在实际使用是需要我们去权衡利弊,该系统变量的默认值是20。\n我们前面说过,不过InnoDB默认是以表为单位来收集和存储统计数据的,我们也可以单独设置某个表的采样页面的数量,设置方式就是在创建或修改表的时候通过指定STATS_SAMPLE_PAGES属性来指明该表的统计数据存储方式:\nALTER TABLE 表名 Engine=InnoDB, STATS_SAMPLE_PAGES = 具体的采样页面数量; ``` 如果我们在创建表的语句中并没有指定`STATS_SAMPLE_PAGES`属性的话,将默认使用系统变量`innodb_stats_persistent_sample_pages`的值作为该属性的值。 ### clustered_index_size和sum_of_other_index_sizes统计项的收集 统计这两个数据需要大量用到我们之前介绍的`InnoDB`表空间的知识,**如果大家压根儿没有看那一章,那下面的计算过程大家还是不要看了(看也看不懂)**;如果看过了,那大家就会发现`InnoDB`表空间的知识真是有用啊啊啊!!! 这两个统计项的收集过程如下: + 从数据字典里找到表的各个索引对应的根页面位置。 系统表`SYS_INDEXES`里存储了各个索引对应的根页面信息。 + 从根页面的`Page Header`里找到叶子节点段和非叶子节点段对应的`Segment Header`。 在每个索引的根页面的`Page Header`部分都有两个字段: + `PAGE_BTR_SEG_LEAF`:表示B\\+树叶子段的`Segment Header`信息。 + `PAGE_BTR_SEG_TOP`:表示B\\+树非叶子段的`Segment Header`信息。 + 从叶子节点段和非叶子节点段的`Segment Header`中找到这两个段对应的`INODE Entry`结构。 这个是`Segment Header`结构: ![](img/13-01.png) + 从对应的`INODE Entry`结构中可以找到该段对应所有零散的页面地址以及`FREE`、`NOT_FULL`、`FULL`链表的基节点。 这个是`INODE Entry`结构: ![](img/13-02.png) + 直接统计零散的页面有多少个,然后从那三个链表的`List Length`字段中读出该段占用的区的大小,每个区占用`64`个页,所以就可以统计出整个段占用的页面。 这个是链表基节点的示意图: ![](img/13-03.png) + 分别计算聚簇索引的叶子结点段和非叶子节点段占用的页面数,它们的和就是`clustered_index_size`的值,按照同样的套路把其余索引占用的页面数都算出来,加起来之后就是`sum_of_other_index_sizes`的值。 这里需要大家注意一个问题,我们说一个段的数据在非常多时(超过32个页面),会以`区`为单位来申请空间,这里头的问题是**以区为单位申请空间中有一些页可能并没有使用**,但是在统计`clustered_index_size`和`sum_of_other_index_sizes`时都把它们算进去了,所以说聚簇索引和其他的索引占用的页面数可能比这两个值要小一些。 ## innodb_index_stats 直接看一下这个`innodb_index_stats`表中的各个列都是干嘛的: 字段名 描述 `database_name` 数据库名 `table_name` 表名 `index_name` 索引名 `last_update` 本条记录最后更新时间 `stat_name` 统计项的名称 `stat_value` 对应的统计项的值 `sample_size` 为生成统计数据而采样的页面数量 `stat_description` 对应的统计项的描述 注意这个表的主键是`(database_name,table_name,index_name,stat_name)`,其中的`stat_name`是指统计项的名称,也就是说**innodb_index_stats表的每条记录代表着一个索引的一个统计项**。可能这会大家有些懵逼这个统计项到底指什么,别着急,我们直接看一下关于`single_table`表的索引统计数据都有些什么: `mysql\u0026gt; SELECT * FROM mysql.innodb_index_stats WHERE table_name = \u0026#39;single_table\u0026#39;; +---------------+--------------+--------------+---------------------+--------------+------------+-------------+-----------------------------------+ | database_name | table_name | index_name | last_update | stat_name | stat_value | sample_size | stat_description | +---------------+--------------+--------------+---------------------+--------------+------------+-------------+-----------------------------------+ | xiaohaizi | single_table | PRIMARY | 2018-12-14 14:24:46 | n_diff_pfx01 | 9693 | 20 | id | | xiaohaizi | single_table | PRIMARY | 2018-12-14 14:24:46 | n_leaf_pages | 91 | NULL | Number of leaf pages in the index | | xiaohaizi | single_table | PRIMARY | 2018-12-14 14:24:46 | size | 97 | NULL | Number of pages in the index | | xiaohaizi | single_table | idx_key1 | 2018-12-14 14:24:46 | n_diff_pfx01 | 968 | 28 | key1 | | xiaohaizi | single_table | idx_key1 | 2018-12-14 14:24:46 | n_diff_pfx02 | 10000 | 28 | key1,id | | xiaohaizi | single_table | idx_key1 | 2018-12-14 14:24:46 | n_leaf_pages | 28 | NULL | Number of leaf pages in the index | | xiaohaizi | single_table | idx_key1 | 2018-12-14 14:24:46 | size | 29 | NULL | Number of pages in the index | | xiaohaizi | single_table | idx_key2 | 2018-12-14 14:24:46 | n_diff_pfx01 | 10000 | 16 | key2 | | xiaohaizi | single_table | idx_key2 | 2018-12-14 14:24:46 | n_leaf_pages | 16 | NULL | Number of leaf pages in the index | | xiaohaizi | single_table | idx_key2 | 2018-12-14 14:24:46 | size | 17 | NULL | Number of pages in the index | | xiaohaizi | single_table | idx_key3 | 2018-12-14 14:24:46 | n_diff_pfx01 | 799 | 31 | key3 | | xiaohaizi | single_table | idx_key3 | 2018-12-14 14:24:46 | n_diff_pfx02 | 10000 | 31 | key3,id | | xiaohaizi | single_table | idx_key3 | 2018-12-14 14:24:46 | n_leaf_pages | 31 | NULL | Number of leaf pages in the index | | xiaohaizi | single_table | idx_key3 | 2018-12-14 14:24:46 | size | 32 | NULL | Number of pages in the index | | xiaohaizi | single_table | idx_key_part | 2018-12-14 14:24:46 | n_diff_pfx01 | 9673 | 64 | key_part1 | | xiaohaizi | single_table | idx_key_part | 2018-12-14 14:24:46 | n_diff_pfx02 | 9999 | 64 | key_part1,key_part2 | | xiaohaizi | single_table | idx_key_part | 2018-12-14 14:24:46 | n_diff_pfx03 | 10000 | 64 | key_part1,key_part2,key_part3 | | xiaohaizi | single_table | idx_key_part | 2018-12-14 14:24:46 | n_diff_pfx04 | 10000 | 64 | key_part1,key_part2,key_part3,id | | xiaohaizi | single_table | idx_key_part | 2018-12-14 14:24:46 | n_leaf_pages | 64 | NULL | Number of leaf pages in the index | | xiaohaizi | single_table | idx_key_part | 2018-12-14 14:24:46 | size | 97 | NULL | Number of pages in the index | +---------------+--------------+--------------+---------------------+--------------+------------+-------------+-----------------------------------+ 20 rows in set (0.03 sec)` 这个结果有点儿多,正确查看这个结果的方式是这样的: + 先查看`index_name`列,这个列说明该记录是哪个索引的统计信息,从结果中我们可以看出来,`PRIMARY`索引(也就是主键)占了3条记录,`idx_key_part`索引占了6条记录。 + 针对`index_name`列相同的记录,`stat_name`表示针对该索引的统计项名称,`stat_value`展示的是该索引在该统计项上的值,`stat_description`指的是来描述该统计项的含义的。我们来具体看一下一个索引都有哪些统计项: + `n_leaf_pages`:表示该索引的叶子节点占用多少页面。 + `size`:表示该索引共占用多少页面。 + `n_diff_pfx**NN**`:表示对应的索引列不重复的值有多少。其中的`NN`长得有点儿怪呀,什么意思呢? 其实`NN`可以被替换为`01`、`02`、`03`... 这样的数字。比如对于`idx_key_part`来说: + `n_diff_pfx01`表示的是统计`key_part1`这单单一个列不重复的值有多少。 + `n_diff_pfx02`表示的是统计`key_part1、key_part2`这两个列组合起来不重复的值有多少。 + `n_diff_pfx03`表示的是统计`key_part1、key_part2、key_part3`这三个列组合起来不重复的值有多少。 + `n_diff_pfx04`表示的是统计`key_part1、key_part2、key_part3、id`这四个列组合起来不重复的值有多少。 `小贴士:这里需要注意的是,对于普通的二级索引,并不能保证它的索引列值是唯一的,比如对于idx_key1来说,key1列就可能有很多值重复的记录。此时只有在索引列上加上主键值才可以区分两条索引列值都一样的二级索引记录。对于主键和唯一二级索引则没有这个问题,它们本身就可以保证索引列值的不重复,所以也不需要再统计一遍在索引列后加上主键值的不重复值有多少。比如上面的idx_key1有n_diff_pfx01、n_diff_pfx02两个统计项,而idx_key2却只有n_diff_pfx01一个统计项。` + 在计算某些索引列中包含多少不重复值时,需要对一些叶子节点页面进行采样,`size`列就表明了采样的页面数量是多少。 `小贴士:对于有多个列的联合索引来说,采样的页面数量是:innodb_stats_persistent_sample_pages × 索引列的个数。当需要采样的页面数量大于该索引的叶子节点数量的话,就直接采用全表扫描来统计索引列的不重复值数量了。所以大家可以在查询结果中看到不同索引对应的size列的值可能是不同的。` ## 定期更新统计数据 随着我们不断的对表进行增删改操作,表中的数据也一直在变化,`innodb_table_stats`和`innodb_index_stats`表里的统计数据是不是也应该跟着变一变了?当然要变了,不变的话`MySQL`查询优化器计算的成本可就差老鼻子远了。设计`MySQL`的大佬提供了如下两种更新统计数据的方式: + 开启`innodb_stats_auto_recalc`。 系统变量`innodb_stats_auto_recalc`决定着服务器是否自动重新计算统计数据,它的默认值是`ON`,也就是该功能默认是开启的。每个表都维护了一个变量,该变量记录着对该表进行增删改的记录条数,如果发生变动的记录数量超过了表大小的`10%`,并且自动重新计算统计数据的功能是打开的,那么服务器会重新进行一次统计数据的计算,并且更新`innodb_table_stats`和`innodb_index_stats`表。不过**自动重新计算统计数据的过程是异步发生的**,也就是即使表中变动的记录数超过了`10%`,自动重新计算统计数据也不会立即发生,可能会延迟几秒才会进行计算。 再一次强调,`InnoDB`默认是**以表为单位来收集和存储统计数据的**,我们也可以单独为某个表设置是否自动重新计算统计数的属性,设置方式就是在创建或修改表的时候通过指定`STATS_AUTO_RECALC`属性来指明该表的统计数据存储方式: ``` CREATE TABLE 表名 (...) Engine=InnoDB, STATS_AUTO_RECALC = (1|0); ALTER TABLE 表名 Engine=InnoDB, STATS_AUTO_RECALC = (1|0); ``` 当`STATS_AUTO_RECALC=1`时,表明我们想让该表自动重新计算统计数据,当`STATS_PERSISTENT=0`时,表明不想让该表自动重新计算统计数据。如果我们在创建表时未指定`STATS_AUTO_RECALC`属性,那默认采用系统变量`innodb_stats_auto_recalc`的值作为该属性的值。 + 手动调用`ANALYZE TABLE`语句来更新统计信息 如果`innodb_stats_auto_recalc`系统变量的值为`OFF`的话,我们也可以手动调用`ANALYZE TABLE`语句来重新计算统计数据,比如我们可以这样更新关于`single_table`表的统计数据: `mysql\u0026gt; ANALYZE TABLE single_table; +------------------------+---------+----------+----------+ | Table | Op | Msg_type | Msg_text | +------------------------+---------+----------+----------+ | xiaohaizi.single_table | analyze | status | OK | +------------------------+---------+----------+----------+ 1 row in set (0.08 sec)` 需要注意的是,**ANALYZE TABLE语句会立即重新计算统计数据,也就是这个过程是同步的**,在表中索引多或者采样页面特别多时这个过程可能会特别慢,请不要没事儿就运行一下`ANALYZE TABLE`语句,最好在业务不是很繁忙的时候再运行。 ## 手动更新**`innodb_table_stats`**和**`innodb_index_stats`**表 其实`innodb_table_stats`和`innodb_index_stats`表就相当于一个普通的表一样,我们能对它们做增删改查操作。这也就意味着我们可以**手动更新某个表或者索引的统计数据**。比如说我们想把`single_table`表关于行数的统计数据更改一下可以这么做: + 步骤一:更新`innodb_table_stats`表。 `UPDATE innodb_table_stats SET n_rows = 1 WHERE table_name = \u0026#39;single_table\u0026#39;;` + 步骤二:让`MySQL`查询优化器重新加载我们更改过的数据。 更新完`innodb_table_stats`只是单纯的修改了一个表的数据,需要让`MySQL`查询优化器重新加载我们更改过的数据,运行下面的命令就可以了: `FLUSH TABLE single_table;` 之后我们使用`SHOW TABLE STATUS`语句查看表的统计数据时就看到`Rows`行变为了`1`。 # 基于内存的非永久性统计数据 当我们把系统变量`innodb_stats_persistent`的值设置为`OFF`时,之后创建的表的统计数据默认就都是非永久性的了,或者我们直接在创建表或修改表时设置`STATS_PERSISTENT`属性的值为`0`,那么该表的统计数据就是非永久性的了。 与永久性的统计数据不同,非永久性的统计数据采样的页面数量是由`innodb_stats_transient_sample_pages`控制的,这个系统变量的默认值是`8`。 另外,由于非永久性的统计数据经常更新,所以导致`MySQL`查询优化器计算查询成本的时候依赖的是经常变化的统计数据,也就会**生成经常变化的执行计划**,这个可能让大家有些懵逼。不过最近的`MySQL`版本都不咋用这种基于内存的非永久性统计数据了,所以我们也就不深入介绍它了。 # innodb_stats_method的使用 我们知道`索引列不重复的值的数量`这个统计数据对于`MySQL`查询优化器十分重要,因为通过它可以计算出在索引列中平均一个值重复多少行,它的应用场景主要有两个: + 单表查询中单点区间太多,比方说这样: `SELECT * FROM tbl_name WHERE key IN (\u0026#39;xx1\u0026#39;, \u0026#39;xx2\u0026#39;, ..., \u0026#39;xxn\u0026#39;);` 当`IN`里的参数数量过多时,采用`index dive`的方式直接访问`B+`树索引去统计每个单点区间对应的记录的数量就太耗费性能了,所以直接依赖统计数据中的平均一个值重复多少行来计算单点区间对应的记录数量。 + 连接查询时,如果有涉及两个表的等值匹配连接条件,该连接条件对应的被驱动表中的列又拥有索引时,则可以使用`ref`访问方法来对被驱动表进行查询,比方说这样: `SELECT * FROM t1 JOIN t2 ON t1.column = t2.key WHERE ...;` 在真正执行对`t2`表的查询前,`t1.comumn`的值是不确定的,所以我们也不能通过`index dive`的方式直接访问`B+`树索引去统计每个单点区间对应的记录的数量,所以也只能依赖统计数据中的平均一个值重复多少行来计算单点区间对应的记录数量。 在统计索引列不重复的值的数量时,有一个比较烦的问题就是索引列中出现`NULL`值怎么办,比方说某个索引列的内容是这样: `+------+ | col | +------+ | 1 | | 2 | | NULL | | NULL | +------+` 此时计算这个`col`列中不重复的值的数量就有下面的分歧: + 有的人认为`NULL`值代表一个未确定的值,所以设计`MySQL`的大佬才认为任何和`NULL`值做比较的表达式的值都为`NULL`,就是这样: ``` mysql\u0026gt; SELECT 1 = NULL; \\+----------\\+ | 1 = NULL | \\+----------\\+ | NULL | \\+----------\\+ 1 row in set (0.00 sec) mysql\u0026gt; SELECT 1 \\!= NULL; \\+-----------\\+ | 1 \\!= NULL | \\+-----------\\+ | NULL | \\+-----------\\+ 1 row in set (0.00 sec) mysql\u0026gt; SELECT NULL = NULL; \\+-------------\\+ | NULL = NULL | \\+-------------\\+ | NULL | \\+-------------\\+ 1 row in set (0.00 sec) mysql\u0026gt; SELECT NULL \\!= NULL; \\+--------------\\+ | NULL \\!= NULL | \\+--------------\\+ | NULL | \\+--------------\\+ 1 row in set (0.00 sec) ``` 所以每一个`NULL`值都是独一无二的,也就是说统计索引列不重复的值的数量时,应该把`NULL`值当作一个独立的值,所以`col`列的不重复的值的数量就是:`4`(分别是1、2、NULL、NULL这四个值)。 + 有的人认为其实`NULL`值在业务上就是代表没有,所有的`NULL`值代表的意义是一样的,所以`col`列不重复的值的数量就是:`3`(分别是1、2、NULL这三个值)。 + 有的人认为这`NULL`完全没有意义嘛,所以在统计索引列不重复的值的数量时压根儿不能把它们算进来,所以`col`列不重复的值的数量就是:`2`(分别是1、2这两个值)。 设计`MySQL`的大佬蛮贴心的,他们提供了一个名为`innodb_stats_method`的系统变量,相当于在计算某个索引列不重复值的数量时如何对待`NULL`值这个锅甩给了用户,这个系统变量有三个候选值: + `nulls_equal`:认为所有`NULL`值都是相等的。这个值也是`innodb_stats_method`的默认值。 如果某个索引列中`NULL`值特别多的话,这种统计方式会让优化器认为某个列中平均一个值重复次数特别多,所以倾向于不使用索引进行访问。 + `nulls_unequal`:认为所有`NULL`值都是不相等的。 如果某个索引列中`NULL`值特别多的话,这种统计方式会让优化器认为某个列中平均一个值重复次数特别少,所以倾向于使用索引进行访问。 + `nulls_ignored`:直接把`NULL`值忽略掉。 反正这个锅是甩给用户了,当你选定了`innodb_stats_method`值之后,优化器即使选择了不是最优的执行计划,那也跟设计`MySQL`的大佬们没关系了~ 当然对于用户的我们来说,**最好不在索引列中存放NULL值才是正解**。 # 总结 + `InnoDB`以表为单位来收集统计数据,这些统计数据可以是基于磁盘的永久性统计数据,也可以是基于内存的非永久性统计数据。 + `innodb_stats_persistent`控制着使用永久性统计数据还是非永久性统计数据;`innodb_stats_persistent_sample_pages`控制着永久性统计数据的采样页面数量;`innodb_stats_transient_sample_pages`控制着非永久性统计数据的采样页面数量;`innodb_stats_auto_recalc`控制着是否自动重新计算统计数据。 + 我们可以针对某个具体的表,在创建和修改表时通过指定`STATS_PERSISTENT`、`STATS_AUTO_RECALC`、`STATS_SAMPLE_PAGES`的值来控制相关统计数据属性。 + `innodb_stats_method`决定着在统计某个索引列不重复值的数量时如何对待`NULL`值。 "},{"id":21,"href":"/zh/docs/technology/MySQL/MySQL%E6%98%AF%E6%80%8E%E6%A0%B7%E8%BF%90%E8%A1%8C%E7%9A%84/%E7%AC%AC12%E7%AB%A0_%E8%B0%81%E6%9C%80%E4%BE%BF%E5%AE%9C%E5%B0%B1%E9%80%89%E8%B0%81-MySQL%E5%9F%BA%E4%BA%8E%E6%88%90%E6%9C%AC%E7%9A%84%E4%BC%98%E5%8C%96/","title":"第12章_谁最便宜就选谁-MySQL基于成本的优化","section":"My Sql是怎样运行的","content":"第12章 谁最便宜就选谁-MySQL基于成本的优化\n什么是成本 # 我们之前老说MySQL执行一个查询可以有不同的执行方案,它会选择其中成本最低,或者说代价最低的那种方案去真正的执行查询。不过我们之前对成本的描述是非常模糊的,其实在MySQL中一条查询语句的执行成本是由下面这两个方面组成的:\nI/O成本\n我们的表经常使用的MyISAM、InnoDB存储引擎都是将数据和索引都存储到磁盘上的,当我们想查询表中的记录时,需要先把数据或者索引加载到内存中然后再操作。这个从磁盘到内存这个加载的过程损耗的时间称之为I/O成本。\nCPU成本\n读取以及检测记录是否满足对应的搜索条件、对结果集进行排序等这些操作损耗的时间称之为CPU成本。\n对于InnoDB存储引擎来说,页是磁盘和内存之间交互的基本单位,设计MySQL的大佬规定读取一个页面花费的成本默认是1.0,读取以及检测一条记录是否符合搜索条件的成本默认是0.2。1.0、0.2这些数字称之为成本常数,这两个成本常数我们最常用到,其余的成本常数我们后边再说。 小贴士:需要注意的是,不管读取记录时需不需要检测是否满足搜索条件,其成本都算是0.2。\n单表查询的成本 # 准备工作 # 为了故事的顺利发展,我们还得把之前用到的single_table表搬来,怕大家忘了这个表长什么样,再给大家抄一遍: CREATE TABLE single_table ( id INT NOT NULL AUTO_INCREMENT, key1 VARCHAR(100), key2 INT, key3 VARCHAR(100), key_part1 VARCHAR(100), key_part2 VARCHAR(100), key_part3 VARCHAR(100), common_field VARCHAR(100), PRIMARY KEY (id), KEY idx_key1 (key1), UNIQUE KEY idx_key2 (key2), KEY idx_key3 (key3), KEY idx_key_part(key_part1, key_part2, key_part3) ) Engine=InnoDB CHARSET=utf8; 还是假设这个表里边儿有10000条记录,除id列外其余的列都插入随机值。下面正式开始我们的表演。\n基于成本的优化步骤 # 在一条单表查询语句真正执行之前,MySQL的查询优化器会找出执行该语句所有可能使用的方案,对比之后找出成本最低的方案,这个成本最低的方案就是所谓的执行计划,之后才会调用存储引擎提供的接口真正的执行查询,这个过程总结一下就是这样: 1. 根据搜索条件,找出所有可能使用的索引 2. 计算全表扫描的代价 3. 计算使用不同索引执行查询的代价 4. 对比各种执行方案的代价,找出成本最低的那一个\n下面我们就以一个实例来分析一下这些步骤,单表查询语句如下: SELECT * FROM single_table WHERE key1 IN ('a', 'b', 'c') AND key2 \u0026gt; 10 AND key2 \u0026lt; 1000 AND key3 \u0026gt; key2 AND key_part1 LIKE '%hello%' AND common_field = '123'; 乍看上去有点儿复杂,我们一步一步分析一下。\n根据搜索条件,找出所有可能使用的索引 # 我们前面说过,对于B+树索引来说,只要索引列和常数使用=、\u0026lt;=\u0026gt;、IN、NOT IN、IS NULL、IS NOT NULL、\u0026gt;、\u0026lt;、\u0026gt;=、\u0026lt;=、BETWEEN、!=(不等于也可以写成\u0026lt;\u0026gt;)或者LIKE操作符连接起来,就可以产生一个所谓的范围区间(LIKE匹配字符串前缀也行),也就是说这些搜索条件都可能使用到索引,设计MySQL的大佬把一个查询中可能使用到的索引称之为possible keys。\n我们分析一下上面查询中涉及到的几个搜索条件: - key1 IN ('a', 'b', 'c'),这个搜索条件可以使用二级索引idx_key1。 - key2 \u0026gt; 10 AND key2 \u0026lt; 1000,这个搜索条件可以使用二级索引idx_key2。 - key3 \u0026gt; key2,这个搜索条件的索引列由于没有和常数比较,所以并不能使用到索引。 - key_part1 LIKE '%hello%',key_part1通过LIKE操作符和以通配符开头的字符串做比较,不可以适用索引。 - common_field = '123',由于该列上压根儿没有索引,所以不会用到索引。\n综上所述,上面的查询语句可能用到的索引,也就是possible keys只有idx_key1和idx_key2。\n计算全表扫描的代价 # 对于InnoDB存储引擎来说,全表扫描的意思就是把聚簇索引中的记录都依次和给定的搜索条件做一下比较,把符合搜索条件的记录加入到结果集,所以需要将聚簇索引对应的页面加载到内存中,然后再检测记录是否符合搜索条件。由于查询成本=I/O成本+CPU成本,所以计算全表扫描的代价需要两个信息: - 聚簇索引占用的页面数 - 该表中的记录数\n这两个信息从哪来呢?设计MySQL的大佬为每个表维护了一系列的统计信息,关于这些统计信息是如何收集起来的我们放在本章后边详细介绍,现在看看怎么查看这些统计信息。设计MySQL的大佬给我们提供了SHOW TABLE STATUS语句来查看表的统计信息,如果要看指定的某个表的统计信息,在该语句后加对应的LIKE语句就好了,比方说我们要查看single_table这个表的统计信息可以这么写:\nmysql\u0026gt; SHOW TABLE STATUS LIKE \u0026#39;single_table\u0026#39;\\\\G *************************** 1. row *************************** Name: single_table Engine: InnoDB Version: 10 Row_format: Dynamic Rows: 9693 Avg_row_length: 163 Data_length: 1589248 Max_data_length: 0 Index_length: 2752512 Data_free: 4194304 Auto_increment: 10001 Create_time: 2018-12-10 13:37:23 Update_time: 2018-12-10 13:38:03 Check_time: NULL Collation: utf8_general_ci Checksum: NULL Create_options: Comment: 1 row in set (0.01 sec) ``` 虽然出现了很多统计选项,但我们目前只关心两个: + `Rows` 本选项表示表中的记录条数。对于使用`MyISAM`存储引擎的表来说,该值是准确的,对于使用`InnoDB`存储引擎的表来说,该值是一个估计值。从查询结果我们也可以看出来,由于我们的`single_table`表是使用`InnoDB`存储引擎的,所以虽然实际上表中有10000条记录,但是`SHOW TABLE STATUS`显示的`Rows`值只有9693条记录。 + `Data_length` 本选项表示表占用的存储空间字节数。使用`MyISAM`存储引擎的表来说,该值就是数据文件的大小,对于使用`InnoDB`存储引擎的表来说,该值就相当于聚簇索引占用的存储空间大小,也就是说可以这样计算该值的大小: `Data_length = 聚簇索引的页面数量 x 每个页面的大小` 我们的`single_table`使用默认`16KB`的页面大小,而上面查询结果显示`Data_length`的值是`1589248`,所以我们可以反向来推导出`聚簇索引的页面数量`: `聚簇索引的页面数量 = 1589248 ÷ 16 ÷ 1024 = 97` 我们现在已经得到了聚簇索引占用的页面数量以及该表记录数的估计值,所以就可以计算全表扫描成本了,但是设计`MySQL`的大佬在真实计算成本时会进行一些`微调`,这些微调的值是直接硬编码到代码里的,由于没有注释,我也不知道这些微调值是什么子意思,但是由于这些微调的值十分的小,并不影响我们分析,所以我们也没有必要在这些微调值上纠结了。现在可以看一下全表扫描成本的计算过程: + `I/O`成本 `97 x 1.0 + 1.1 = 98.1` `97`指的是聚簇索引占用的页面数,`1.0`指的是加载一个页面的成本常数,后边的`1.1`是一个微调值,我们不用在意。 + `CPU`成本: `9693 x 0.2 + 1.0 = 1939.6` `9693`指的是统计数据中表的记录数,对于`InnoDB`存储引擎来说是一个估计值,`0.2`指的是访问一条记录所需的成本常数,后边的`1.0`是一个微调值,我们不用在意。 + 总成本: `98.1 + 1939.6 = 2037.7` 综上所述,对于`single_table`的全表扫描所需的总成本就是`2037.7`。 `小贴士:我们前面说过表中的记录其实都存储在聚簇索引对应B+树的叶子节点中,所以只要我们通过根节点获得了最左边的叶子节点,就可以沿着叶子节点组成的双向链表把所有记录都查看一遍。也就是说全表扫描这个过程其实有的B+树内节点是不需要访问的,但是设计MySQL的大佬们在计算全表扫描成本时直接使用聚簇索引占用的页面数作为计算I/O成本的依据,是不区分内节点和叶子节点的,有点儿简单暴力,大家注意一下就好了。` ### 计算使用不同索引执行查询的代价 从第1步分析我们得到,上述查询可能使用到`idx_key1`和`idx_key2`这两个索引,我们需要分别分析单独使用这些索引执行查询的成本,最后还要分析是否可能使用到索引合并。这里需要提一点的是,`MySQL`查询优化器先分析使用唯一二级索引的成本,再分析使用普通索引的成本,所以我们也先分析`idx_key2`的成本,然后再看使用`idx_key1`的成本。 #### 使用idx_key2执行查询的成本分析 `idx_key2`对应的搜索条件是:`key2 \u0026gt; 10 AND key2 \u0026lt; 1000`,也就是说对应的范围区间就是:`(10, 1000)`,使用`idx_key2`搜索的示意图就是这样子: ![](img/12-01.png) 对于使用`二级索引 + 回表`方式的查询,设计`MySQL`的大佬计算这种查询的成本依赖两个方面的数据: + 范围区间数量 不论某个范围区间的二级索引到底占用了多少页面,查询优化器粗暴的认为读取索引的一个范围区间的`I/O`成本和读取一个页面是相同的。本例中使用`idx_key2`的范围区间只有一个:`(10, 1000)`,所以相当于访问这个范围区间的二级索引付出的`I/O`成本就是: `1 x 1.0 = 1.0` + 需要回表的记录数 优化器需要计算二级索引的某个范围区间到底包含多少条记录,对于本例来说就是要计算`idx_key2`在`(10, 1000)`这个范围区间中包含多少二级索引记录,计算过程是这样的: + 步骤1:先根据`key2 \u0026gt; 10`这个条件访问一下`idx_key2`对应的`B+`树索引,找到满足`key2 \u0026gt; 10`这个条件的第一条记录,我们把这条记录称之为`区间最左记录`。我们前头说过在`B+`数树中定位一条记录的过程是贼快的,是常数级别的,所以这个过程的性能消耗是可以忽略不计的。 + 步骤2:然后再根据`key2 \u0026lt; 1000`这个条件继续从`idx_key2`对应的`B+`树索引中找出第一条满足这个条件的记录,我们把这条记录称之为`区间最右记录`,这个过程的性能消耗也可以忽略不计的。 + 步骤3:如果`区间最左记录`和`区间最右记录`相隔不太远(在`MySQL 5.7.21`这个版本里,只要相隔不大于10个页面即可),那就可以精确统计出满足`key2 \u0026gt; 10 AND key2 \u0026lt; 1000`条件的二级索引记录条数。否则只沿着`区间最左记录`向右读10个页面,计算平均每个页面中包含多少记录,然后用这个平均值乘以`区间最左记录`和`区间最右记录`之间的页面数量就可以了。那么问题又来了,怎么估计`区间最左记录`和`区间最右记录`之间有多少个页面呢?解决这个问题还得回到`B+`树索引的结构中来: ![](img/12-02.png) 如图,我们假设`区间最左记录`在`页b`中,`区间最右记录`在`页c`中,那么我们想计算`区间最左记录`和`区间最右记录`之间的页面数量就相当于计算`页b`和`页c`之间有多少页面,而每一条`目录项记录`都对应一个数据页,所以计算`页b`和`页c`之间有多少页面就相当于**计算它们父节点(也就是页a)中对应的目录项记录之间隔着几条记录**。在一个页面中统计两条记录之间有几条记录的成本就贼小了。 不过还有问题,如果`页b`和`页c`之间的页面实在太多,以至于`页b`和`页c`对应的目录项记录都不在一个页面中该咋办?继续递归啊,也就是再统计`页b`和`页c`对应的目录项记录所在页之间有多少个页面。之前我们说过一个`B+`树有4层高已经很了不得了,所以这个统计过程也不是很耗费性能。 知道了如何统计二级索引某个范围区间的记录数之后,就需要回到现实问题中来,根据上述算法测得`idx_key2`在区间`(10, 1000)`之间大约有`95`条记录。读取这`95`条二级索引记录需要付出的`CPU`成本就是: `95 x 0.2 + 0.01 = 19.01` 其中`95`是需要读取的二级索引记录条数,`0.2`是读取一条记录成本常数,`0.01`是微调。 在通过二级索引获取到记录之后,还需要干两件事儿: + 根据这些记录里的主键值到聚簇索引中做回表操作 这里需要大家使劲儿睁大自己滴溜溜的大眼睛仔细瞧,设计`MySQL`的大佬评估回表操作的`I/O`成本依旧很豪放,他们认为每次回表操作都相当于访问一个页面,也就是说二级索引范围区间有多少记录,就需要进行多少次回表操作,也就是需要进行多少次页面`I/O`。我们上面统计了使用`idx_key2`二级索引执行查询时,预计有`95`条二级索引记录需要进行回表操作,所以回表操作带来的`I/O`成本就是: `95 x 1.0 = 95.0` 其中`95`是预计的二级索引记录数,`1.0`是一个页面的`I/O`成本常数。 + 回表操作后得到的完整用户记录,然后再检测其他搜索条件是否成立 回表操作的本质就是通过二级索引记录的主键值到聚簇索引中找到完整的用户记录,然后再检测除`key2 \u0026gt; 10 AND key2 \u0026lt; 1000`这个搜索条件以外的搜索条件是否成立。因为我们通过范围区间获取到二级索引记录共`95`条,也就对应着聚簇索引中`95`条完整的用户记录,读取并检测这些完整的用户记录是否符合其余的搜索条件的`CPU`成本如下: 设计`MySQL`的大佬只计算这个查找过程所需的`I/O`成本,也就是我们上一步骤中得到的`95.0`,在内存中的定位完整用户记录的过程的成本是忽略不计的。在定位到这些完整的用户记录后,需要检测除`key2 \u0026gt; 10 AND key2 \u0026lt; 1000`这个搜索条件以外的搜索条件是否成立,这个比较过程花费的`CPU`成本就是: `95 x 0.2 = 19.0` 其中`95`是待检测记录的条数,`0.2`是检测一条记录是否符合给定的搜索条件的成本常数。 所以本例中使用`idx_key2`执行查询的成本就如下所示: + `I/O`成本: `1.0 + 95 x 1.0 = 96.0 (范围区间的数量 + 预估的二级索引记录条数)` + `CPU`成本: `95 x 0.2 + 0.01 + 95 x 0.2 = 38.01 (读取二级索引记录的成本 + 读取并检测回表后聚簇索引记录的成本)` 综上所述,使用`idx_key2`执行查询的总成本就是: `96.0 + 38.01 = 134.01` #### 使用idx_key1执行查询的成本分析 `idx_key1`对应的搜索条件是:`key1 IN (\u0026#39;a\u0026#39;, \u0026#39;b\u0026#39;, \u0026#39;c\u0026#39;)`,也就是说相当于3个单点区间: + `[\u0026#39;a\u0026#39;, \u0026#39;a\u0026#39;]` + `[\u0026#39;b\u0026#39;, \u0026#39;b\u0026#39;]` + `[\u0026#39;c\u0026#39;, \u0026#39;c\u0026#39;]` 使用`idx_key1`搜索的示意图如下: ![](img/12-03.png) 与使用`idx_key2`的情况类似,我们也需要计算使用`idx_key1`时需要访问的范围区间数量以及需要回表的记录数: + 范围区间数量 使用`idx_key1`执行查询时很显然有3个单点区间,所以访问这3个范围区间的二级索引付出的I/O成本就是: `3 x 1.0 = 3.0` + 需要回表的记录数 由于使用`idx_key1`时有3个单点区间,所以每个单点区间都需要查找一遍对应的二级索引记录数: + 查找单点区间`[\u0026#39;a\u0026#39;, \u0026#39;a\u0026#39;]`对应的二级索引记录数 计算单点区间对应的二级索引记录数和计算连续范围区间对应的二级索引记录数是一样的,都是先计算`区间最左记录`和`区间最右记录`,然后再计算它们之间的记录数,具体算法上面都介绍过了,就不赘述了。最后计算得到单点区间`[\u0026#39;a\u0026#39;, \u0026#39;a\u0026#39;]`对应的二级索引记录数是:`35`。 + 查找单点区间`[\u0026#39;b\u0026#39;, \u0026#39;b\u0026#39;]`对应的二级索引记录数 与上同理,计算得到本单点区间对应的记录数是:`44`。 + 查找单点区间`[\u0026#39;c\u0026#39;, \u0026#39;c\u0026#39;]`对应的二级索引记录数 与上同理,计算得到本单点区间对应的记录数是:`39`。 所以,这三个单点区间总共需要回表的记录数就是: `35 + 44 + 39 = 118` 读取这些二级索引记录的`CPU`成本就是: `118 x 0.2 + 0.01 = 23.61` 得到总共需要回表的记录数之后,就要考虑: + 根据这些记录里的主键值到聚簇索引中做回表操作 所需的`I/O`成本就是: `118 x 1.0 = 118.0` + 回表操作后得到的完整用户记录,然后再比较其他搜索条件是否成立 此步骤对应的`CPU`成本就是: `118 x 0.2 = 23.6` 所以本例中使用`idx_key1`执行查询的成本就如下所示: + `I/O`成本: `3.0 + 118 x 1.0 = 121.0 (范围区间的数量 + 预估的二级索引记录条数)` + `CPU`成本: `118 x 0.2 + 0.01 + 118 x 0.2 = 47.21 (读取二级索引记录的成本 + 读取并检测回表后聚簇索引记录的成本)` 综上所述,使用`idx_key1`执行查询的总成本就是: `121.0 + 47.21 = 168.21` #### 是否有可能使用索引合并(Index Merge) 本例中有关`key1`和`key2`的搜索条件是使用`AND`连接起来的,而对于`idx_key1`和`idx_key2`都是范围查询,也就是说查找到的二级索引记录并不是按照主键值进行排序的,并不满足使用`Intersection`索引合并的条件,所以并不会使用索引合并。 `小贴士:MySQL查询优化器计算索引合并成本的算法也比较麻烦,所以我们这也就不展开介绍了。` ### 4. 对比各种执行方案的代价,找出成本最低的那一个 下面把执行本例中的查询的各种可执行方案以及它们对应的成本列出来: - 全表扫描的成本:`2037.7` - 使用`idx_key2`的成本:`134.01` - 使用`idx_key1`的成本:`168.21` 很显然,使用`idx_key2`的成本最低,所以当然选择`idx_key2`来执行查询喽。 `小贴士:考虑到大家的阅读体验,为了最大限度的减少大家在理解优化器工作原理的过程中遇到的懵逼情况,这里对优化器在单表查询中对比各种执行方案的代价的方式稍稍的做了简化,不过毕竟大部分同学不需要去看MySQL的源码,把大致的精神传递正确就好了。` ## 基于索引统计数据的成本计算 有时候使用索引执行查询时会有许多单点区间,比如使用`IN`语句就很容易产生非常多的单点区间,比如下面这个查询(下面查询语句中的`...`表示还有很多参数): `SELECT * FROM single_table WHERE key1 IN (\u0026#39;aa1\u0026#39;, \u0026#39;aa2\u0026#39;, \u0026#39;aa3\u0026#39;, ... , \u0026#39;zzz\u0026#39;);` 很显然,这个查询可能使用到的索引就是`idx_key1`,由于这个索引并不是唯一二级索引,所以并不能确定一个单点区间对应的二级索引记录的条数有多少,需要我们去计算。计算方式我们上面已经介绍过了,就是先获取索引对应的`B+`树的`区间最左记录`和`区间最右记录`,然后再计算这两条记录之间有多少记录(记录条数少的时候可以做到精确计算,多的时候只能估算)。设计`MySQL`的大佬把这种通过直接访问索引对应的`B+`树来计算某个范围区间对应的索引记录条数的方式称之为`index dive`。 `小贴士:dive直译为中文的意思是跳水、俯冲的意思,原谅我的英文水平捉急,我实在不知道怎么翻译 index dive,索引跳水?索引俯冲?好像都不太合适,所以压根儿就不翻译了。不过大家要意会index dive就是直接利用索引对应的B+树来计算某个范围区间对应的记录条数。` 有零星几个单点区间的话,使用`index dive`的方式去计算这些单点区间对应的记录数也不是什么问题,可是你架不住有的孩子憋足了劲往`IN`语句里塞东西呀,我就见过有的同学写的`IN`语句里有20000个参数的🤣🤣,这就意味着`MySQL`的查询优化器为了计算这些单点区间对应的索引记录条数,要进行20000次`index dive`操作,这性能损耗可就大了,搞不好计算这些单点区间对应的索引记录条数的成本比直接全表扫描的成本都大了。设计`MySQL`的大佬们多聪明啊,他们当然考虑到了这种情况,所以提供了一个系统变量`eq_range_index_dive_limit`,我们看一下在`MySQL 5.7.21`中这个系统变量的默认值: `mysql\u0026gt; SHOW VARIABLES LIKE \u0026#39;%dive%\u0026#39;; +---------------------------+-------+ | Variable_name | Value | +---------------------------+-------+ | eq_range_index_dive_limit | 200 | +---------------------------+-------+ 1 row in set (0.08 sec)` 也就是说如果我们的`IN`语句中的参数个数小于200个的话,将使用`index dive`的方式计算各个单点区间对应的记录条数,如果大于或等于200个的话,可就不能使用`index dive`了,要使用所谓的索引统计数据来进行估算。怎么个估算法?继续往下看。 像会为每个表维护一份统计数据一样,`MySQL`也会为表中的每一个索引维护一份统计数据,查看某个表中索引的统计数据可以使用`SHOW INDEX FROM 表名`的语法,比如我们查看一下`single_table`的各个索引的统计数据可以这么写: `mysql\u0026gt; SHOW INDEX FROM single_table; +--------------+------------+--------------+--------------+-------------+-----------+-------------+----------+--------+------+------------+---------+---------------+ | Table | Non_unique | Key_name | Seq_in_index | Column_name | Collation | Cardinality | Sub_part | Packed | Null | Index_type | Comment | Index_comment | +--------------+------------+--------------+--------------+-------------+-----------+-------------+----------+--------+------+------------+---------+---------------+ | single_table | 0 | PRIMARY | 1 | id | A | 9693 | NULL | NULL | | BTREE | | | | single_table | 0 | idx_key2 | 1 | key2 | A | 9693 | NULL | NULL | YES | BTREE | | | | single_table | 1 | idx_key1 | 1 | key1 | A | 968 | NULL | NULL | YES | BTREE | | | | single_table | 1 | idx_key3 | 1 | key3 | A | 799 | NULL | NULL | YES | BTREE | | | | single_table | 1 | idx_key_part | 1 | key_part1 | A | 9673 | NULL | NULL | YES | BTREE | | | | single_table | 1 | idx_key_part | 2 | key_part2 | A | 9999 | NULL | NULL | YES | BTREE | | | | single_table | 1 | idx_key_part | 3 | key_part3 | A | 10000 | NULL | NULL | YES | BTREE | | | +--------------+------------+--------------+--------------+-------------+-----------+-------------+----------+--------+------+------------+---------+---------------+ 7 rows in set (0.01 sec)` 哇唔,竟然有这么多属性,不过好在这些属性都不难理解,我们就都介绍一遍吧: 属性名 描述 `Table` 索引所属表的名称。 `Non_unique` 索引列的值是否是唯一的,聚簇索引和唯一二级索引的该列值为`0`,普通二级索引该列值为`1`。 `Key_name` 索引的名称。 `Seq_in_index` 索引列在索引中的位置,从1开始计数。比如对于联合索引`idx_key_part`,来说,`key_part1`、`key_part2`和`key_part3`对应的位置分别是1、2、3。 `Column_name` 索引列的名称。 `Collation` 索引列中的值是按照何种排序方式存放的,值为`A`时代表升序存放,为`NULL`时代表降序存放。 `Cardinality` 索引列中不重复值的数量。后边我们会重点看这个属性的。 `Sub_part` 对于存储字符串或者字节串的列来说,有时候我们只想对这些串的前`n`个字符或字节建立索引,这个属性表示的就是那个`n`值。如果对完整的列建立索引的话,该属性的值就是`NULL`。 `Packed` 索引列如何被压缩,`NULL`值表示未被压缩。这个属性我们暂时不了解,可以先忽略掉。 `Null` 该索引列是否允许存储`NULL`值。 `Index_type` 使用索引的类型,我们最常见的就是`BTREE`,其实也就是`B+`树索引。 `Comment` 索引列注释信息。 `Index_comment` 索引注释信息。 上述属性除了`Packed`大家可能看不懂以外,应该没有什么看不懂的了,如果有的话肯定是大家看前面文章的时候跳过了什么东西。其实我们现在最在意的是`Cardinality`属性,`Cardinality`直译过来就是`基数`的意思,表示索引列中不重复值的个数。比如对于一个一万行记录的表来说,某个索引列的`Cardinality`属性是`10000`,那意味着该列中没有重复的值,如果`Cardinality`属性是`1`的话,就意味着该列的值全部是重复的。不过需要注意的是,**对于InnoDB存储引擎来说,使用SHOW INDEX语句展示出来的某个索引列的Cardinality属性是一个估计值,并不是精确的**。关于这个`Cardinality`属性的值是如何被计算出来的我们后边再说,先看看它有什么用途。 前面说道,当`IN`语句中的参数个数大于或等于系统变量`eq_range_index_dive_limit`的值的话,就不会使用`index dive`的方式计算各个单点区间对应的索引记录条数,而是使用索引统计数据,这里所指的`索引统计数据`指的是这两个值: + 使用`SHOW TABLE STATUS`展示出的`Rows`值,也就是一个表中有多少条记录。 这个统计数据我们在前面介绍全表扫描成本的时候说过很多遍了,就不赘述了。 + 使用`SHOW INDEX`语句展示出的`Cardinality`属性。 结合上一个`Rows`统计数据,我们可以针对索引列,计算出平均一个值重复多少次。 `一个值的重复次数 ≈ Rows ÷ Cardinality` 以`single_table`表的`idx_key1`索引为例,它的`Rows`值是`9693`,它对应索引列`key1`的`Cardinality`值是`968`,所以我们可以计算`key1`列平均单个值的重复次数就是: `9693 ÷ 968 ≈ 10(条)` 此时再看上面那条查询语句: `SELECT * FROM single_table WHERE key1 IN (\u0026#39;aa1\u0026#39;, \u0026#39;aa2\u0026#39;, \u0026#39;aa3\u0026#39;, ... , \u0026#39;zzz\u0026#39;);` 假设`IN`语句中有20000个参数的话,就直接使用统计数据来估算这些参数需要单点区间对应的记录条数了,每个参数大约对应`10`条记录,所以总共需要回表的记录数就是: `20000 x 10 = 200000` 使用统计数据来计算单点区间对应的索引记录条数可比`index dive`的方式简单多了,但是它的致命弱点就是:**不精确!**。使用统计数据算出来的查询成本与实际所需的成本可能相差非常大。 `小贴士:大家需要注意一下,在MySQL 5.7.3以及之前的版本中,eq_range_index_dive_limit的默认值为10,之后的版本默认值为200。所以如果大家采用的是5.7.3以及之前的版本的话,很容易采用索引统计数据而不是index dive的方式来计算查询成本。当你的查询中使用到了IN查询,但是却实际没有用到索引,就应该考虑一下是不是由于 eq_range_index_dive_limit 值太小导致的。` # 连接查询的成本 ## 准备工作 连接查询至少是要有两个表的,只有一个`single_table`表是不够的,所以为了故事的顺利发展,我们直接构造一个和`single_table`表一模一样的`single_table2`表。为了简便起见,我们把`single_table`表称为`s1`表,把`single_table2`表称为`s2`表。 ## Condition filtering介绍 我们前面说过,`MySQL`中连接查询采用的是嵌套循环连接算法,驱动表会被访问一次,被驱动表可能会被访问多次,所以对于两表连接查询来说,它的查询成本由下面两个部分构成: - 单次查询驱动表的成本 - 多次查询被驱动表的成本(**具体查询多少次取决于对驱动表查询的结果集中有多少条记录**) 我们把对驱动表进行查询后得到的记录条数称之为驱动表的`扇出`(英文名:`fanout`)。很显然驱动表的扇出值越小,对被驱动表的查询次数也就越少,连接查询的总成本也就越低。当查询优化器想计算整个连接查询所使用的成本时,就需要计算出驱动表的扇出值,有的时候扇出值的计算是很容易的,比如下面这两个查询: + 查询一: `SELECT * FROM single_table AS s1 INNER JOIN single_table2 AS s2;` 假设使用`s1`表作为驱动表,很显然对驱动表的单表查询只能使用全表扫描的方式执行,驱动表的扇出值也很明确,那就是驱动表中有多少记录,扇出值就是多少。我们前面说过,统计数据中`s1`表的记录行数是`9693`,也就是说优化器就直接会把`9693`当作在`s1`表的扇出值。 + 查询二: `SELECT * FROM single_table AS s1 INNER JOIN single_table2 AS s2 WHERE s1.key2 \u0026gt;10 AND s1.key2 \u0026lt; 1000;` 仍然假设`s1`表是驱动表的话,很显然对驱动表的单表查询可以使用`idx_key2`索引执行查询。此时`idx_key2`的范围区间`(10, 1000)`中有多少条记录,那么扇出值就是多少。我们前面计算过,满足`idx_key2`的范围区间`(10, 1000)`的记录数是95条,也就是说本查询中优化器会把`95`当作驱动表`s1`的扇出值。 事情当然不会总是一帆风顺的,要不然剧情就太平淡了。有的时候扇出值的计算就变得很棘手,比方说下面几个查询: + 查询三: `SELECT * FROM single_table AS s1 INNER JOIN single_table2 AS s2 WHERE s1.common_field \u0026gt; \u0026#39;xyz\u0026#39;;` 本查询和`查询一`类似,只不过对于驱动表`s1`多了一个`common_field \u0026gt; \u0026#39;xyz\u0026#39;`的搜索条件。查询优化器又不会真正的去执行查询,所以它只能`猜`这`9693`记录里有多少条记录满足`common_field \u0026gt; \u0026#39;xyz\u0026#39;`条件。 + 查询四: `SELECT * FROM single_table AS s1 INNER JOIN single_table2 AS s2 WHERE s1.key2 \u0026gt; 10 AND s1.key2 \u0026lt; 1000 AND s1.common_field \u0026gt; \u0026#39;xyz\u0026#39;;` 本查询和`查询二`类似,只不过对于驱动表`s1`也多了一个`common_field \u0026gt; \u0026#39;xyz\u0026#39;`的搜索条件。不过因为本查询可以使用`idx_key2`索引,所以只需要从符合二级索引范围区间的记录中猜有多少条记录符合`common_field \u0026gt; \u0026#39;xyz\u0026#39;`条件,也就是只需要猜在`95`条记录中有多少符合`common_field \u0026gt; \u0026#39;xyz\u0026#39;`条件。 + 查询五: `SELECT * FROM single_table AS s1 INNER JOIN single_table2 AS s2 WHERE s1.key2 \u0026gt; 10 AND s1.key2 \u0026lt; 1000 AND s1.key1 IN (\u0026#39;a\u0026#39;, \u0026#39;b\u0026#39;, \u0026#39;c\u0026#39;) AND s1.common_field \u0026gt; \u0026#39;xyz\u0026#39;;` 本查询和`查询二`类似,不过在驱动表`s1`选取`idx_key2`索引执行查询后,优化器需要从符合二级索引范围区间的记录中猜有多少条记录符合下面两个条件: + `key1 IN (\u0026#39;a\u0026#39;, \u0026#39;b\u0026#39;, \u0026#39;c\u0026#39;)` + `common_field \u0026gt; \u0026#39;xyz\u0026#39;` 也就是优化器需要猜在`95`条记录中有多少符合上述两个条件的。 说了这么多,其实就是想表达在这两种情况下计算驱动表扇出值时需要靠`猜`: - 如果使用的是全表扫描的方式执行的单表查询,那么计算驱动表扇出时需要猜满足搜索条件的记录到底有多少条。 - 如果使用的是索引执行的单表扫描,那么计算驱动表扇出的时候需要猜满足除使用到对应索引的搜索条件外的其他搜索条件的记录有多少条。 设计`MySQL`的大佬把这个`猜`的过程称之为`condition filtering`。当然,这个过程可能会使用到索引,也可能使用到统计数据,也可能就是设计`MySQL`的大佬单纯的瞎猜,整个评估过程挺复杂的,再仔细的介绍一遍可能引起大家的生理不适,所以我们就跳过了。 `小贴士:在MySQL 5.7之前的版本中,查询优化器在计算驱动表扇出时,如果是使用全表扫描的话,就直接使用表中记录的数量作为扇出值,如果使用索引的话,就直接使用满足范围条件的索引记录条数作为扇出值。在MySQL 5.7中,设计MySQL的大佬引入了这个condition filtering的功能,就是还要猜一猜剩余的那些搜索条件能把驱动表中的记录再过滤多少条,其实本质上就是为了让成本估算更精确。我们所说的纯粹瞎猜其实是很不严谨的,设计MySQL的大佬们称之为启发式规则(heuristic),大家有兴趣的可以再深入了解一下。` ## 两表连接的成本分析 连接查询的成本计算公式是这样的: `连接查询总成本 = 单次访问驱动表的成本 + 驱动表扇出数 x 单次访问被驱动表的成本` 对于左(外)连接和右(外)连接查询来说,它们的驱动表是固定的,所以想要得到最优的查询方案只需要: + 分别为驱动表和被驱动表选择成本最低的访问方法。 可是对于内连接来说,驱动表和被驱动表的位置是可以互换的,所以需要考虑两个方面的问题: + 不同的表作为驱动表最终的查询成本可能是不同的,也就是需要考虑最优的表连接顺序。 + 然后分别为驱动表和被驱动表选择成本最低的访问方法。 很显然,计算内连接查询成本的方式更麻烦一些,下面我们就以内连接为例来看看如何计算出最优的连接查询方案。 `小贴士:左(外)连接和右(外)连接查询在某些特殊情况下可以被优化为内连接查询,我们在之后的章节中会仔细介绍的,稍安勿躁。` 比如对于下面这个查询来说: `SELECT * FROM single_table AS s1 INNER JOIN single_table2 AS s2 ON s1.key1 = s2.common_field WHERE s1.key2 \u0026gt; 10 AND s1.key2 \u0026lt; 1000 AND s2.key2 \u0026gt; 1000 AND s2.key2 \u0026lt; 2000;` 可以选择的连接顺序有两种: - `s1`连接`s2`,也就是`s1`作为驱动表,`s2`作为被驱动表。 - `s2`连接`s1`,也就是`s2`作为驱动表,`s1`作为被驱动表。 查询优化器需要**分别考虑这两种情况下的最优查询成本,然后选取那个成本更低的连接顺序以及该连接顺序下各个表的最优访问方法作为最终的查询计划**。我们分别来看一下(定性的分析一下,不像分析单表查询那样定量的分析了): + 使用`s1`作为驱动表的情况 + 分析对于驱动表的成本最低的执行方案 首先看一下涉及`s1`表单表的搜索条件有哪些: + `s1.key2 \u0026gt; 10 AND s1.key2 \u0026lt; 1000` 所以这个查询可能使用到`idx_key2`索引,从全表扫描和使用`idx_key2`这两个方案中选出成本最低的那个,这个过程我们上面都介绍过了,很显然使用`idx_key2`执行查询的成本更低些。 + 然后分析对于被驱动表的成本最低的执行方案 此时涉及被驱动表`idx_key2`的搜索条件就是: + `s2.common_field = 常数`(这是因为对驱动表`s1`结果集中的每一条记录,都需要进行一次被驱动表`s2`的访问,此时那些涉及两表的条件现在相当于只涉及被驱动表`s2`了。) + `s2.key2 \u0026gt; 1000 AND s2.key2 \u0026lt; 2000` 很显然,第一个条件由于`common_field`没有用到索引,所以并没有什么卵用,此时访问`single_table2`表时可用的方案也是全表扫描和使用`idx_key2`两种,很显然使用`idx_key2`的成本更小。 所以此时使用`single_table`作为驱动表时的总成本就是(暂时不考虑使用`join buffer`对成本的影响): `使用idx_key2访问s1的成本 + s1的扇出 × 使用idx_key2访问s2的成本` + 使用`s2`作为驱动表的情况 + 分析对于驱动表的成本最低的执行方案 首先看一下涉及`s2`表单表的搜索条件有哪些: + `s2.key2 \u0026gt; 10 AND s2.key2 \u0026lt; 1000` 所以这个查询可能使用到`idx_key2`索引,从全表扫描和使用`idx_key2`这两个方案中选出成本最低的那个,这个过程我们上面都介绍过了,很显然使用`idx_key2`执行查询的成本更低些。 + 然后分析对于被驱动表的成本最低的执行方案 此时涉及被驱动表`idx_key2`的搜索条件就是: + `s1.key1 = 常数` + `s1.key2 \u0026gt; 1000 AND s1.key2 \u0026lt; 2000` 这时就很有趣了,使用`idx_key1`可以进行`ref`方式的访问,使用`idx_key2`可以使用`range`方式的访问。这是优化器需要从全表扫描、使用`idx_key1`、使用`idx_key2`这几个方案里选出一个成本最低的方案。这里有个问题啊,因为`idx_key2`的范围区间是确定的:`(10, 1000)`,怎么计算使用`idx_key2`的成本我们上面已经说过了,可是在没有真正执行查询前,`s1.key1 = 常数`中的`常数`值我们是不知道的,怎么衡量使用`idx_key1`执行查询的成本呢?其实很简单,直接使用索引统计数据就好了(就是索引列平均一个值重复多少次)。一般情况下,`ref`的访问方式要比`range`成本最低,这里假设使用`idx_key1`进行对`s2`的访问。 所以此时使用`single_table`作为驱动表时的总成本就是: `使用idx_key2访问s2的成本 + s2的扇出 × 使用idx_key1访问s1的成本` 最后优化器会比较这两种方式的最优访问成本,选取那个成本更低的连接顺序去真正的执行查询。从上面的计算过程也可以看出来,连接查询成本占大头的其实是`驱动表扇出数 x 单次访问被驱动表的成本`,所以我们的优化重点其实是下面这两个部分: + 尽量减少驱动表的扇出 + 对被驱动表的访问成本尽量低 这一点对于我们实际书写连接查询语句时十分有用,我们需要**尽量在被驱动表的连接列上建立索引**,这样就可以使用`ref`访问方法来降低访问被驱动表的成本了。如果可以,被驱动表的连接列最好是该表的主键或者唯一二级索引列,这样就可以把访问被驱动表的成本降到更低了。 ## 多表连接的成本分析 首先要考虑一下多表连接时可能产生出多少种连接顺序: + 对于两表连接,比如表A和表B连接 只有 AB、BA这两种连接顺序。其实相当于`2 × 1 = 2`种连接顺序。 + 对于三表连接,比如表A、表B、表C进行连接 有ABC、ACB、BAC、BCA、CAB、CBA这么6种连接顺序。其实相当于`3 × 2 × 1 = 6`种连接顺序。 + 对于四表连接的话,则会有`4 × 3 × 2 × 1 = 24`种连接顺序。 + 对于`n`表连接的话,则有 `n × (n-1) × (n-2) × ··· × 1`种连接顺序,就是n的阶乘种连接顺序,也就是`n!`。 有`n`个表进行连接,`MySQL`查询优化器要每一种连接顺序的成本都计算一遍么?那可是`n!`种连接顺序呀。其实真的是要都算一遍,不过设计`MySQL`的大佬们想了很多办法减少计算非常多种连接顺序的成本的方法: + 提前结束某种顺序的成本评估 `MySQL`在计算各种链接顺序的成本之前,会维护一个全局的变量,这个变量表示当前最小的连接查询成本。如果在分析某个连接顺序的成本时,该成本已经超过当前最小的连接查询成本,那就压根儿不对该连接顺序继续往下分析了。比方说A、B、C三个表进行连接,已经得到连接顺序`ABC`是当前的最小连接成本,比方说`10.0`,在计算连接顺序`BCA`时,发现`B`和`C`的连接成本就已经大于`10.0`时,就不再继续往后分析`BCA`这个连接顺序的成本了。 + 系统变量`optimizer_search_depth` 为了防止无穷无尽的分析各种连接顺序的成本,设计`MySQL`的大佬们提出了`optimizer_search_depth`系统变量,如果连接表的个数小于该值,那么就继续穷举分析每一种连接顺序的成本,否则只对与`optimizer_search_depth`值相同数量的表进行穷举分析。很显然,该值越大,成本分析的越精确,越容易得到好的执行计划,但是消耗的时间也就越长,否则得到不是很好的执行计划,但可以省掉很多分析连接成本的时间。 + 根据某些规则压根儿就不考虑某些连接顺序 即使是有上面两条规则的限制,但是分析多个表不同连接顺序成本花费的时间还是会很长,所以设计`MySQL`的大佬干脆提出了一些所谓的`启发式规则`(就是根据以往经验指定的一些规则),凡是不满足这些规则的连接顺序压根儿就不分析,这样可以极大的减少需要分析的连接顺序的数量,但是也可能造成错失最优的执行计划。他们提供了一个系统变量`optimizer_prune_level`来控制到底是不是用这些启发式规则。 # 调节成本常数 我们前面之介绍了两个`成本常数`: + 读取一个页面花费的成本默认是`1.0` + 检测一条记录是否符合搜索条件的成本默认是`0.2` 其实除了这两个成本常数,`MySQL`还支持好多呢,它们被存储到了`mysql`数据库(这是一个系统数据库,我们之前介绍过)的两个表中: `mysql\u0026gt; SHOW TABLES FROM mysql LIKE \u0026#39;%cost%\u0026#39;; +--------------------------+ | Tables_in_mysql (%cost%) | +--------------------------+ | engine_cost | | server_cost | +--------------------------+ 2 rows in set (0.00 sec)` 我们在第一章中就说过,一条语句的执行其实是分为两层的: + `server`层 + 存储引擎层 在`server`层进行连接管理、查询缓存、语法解析、查询优化等操作,在存储引擎层执行具体的数据存取操作。也就是说一条语句在`server`层中执行的成本是和它操作的表使用的存储引擎是没关系的,所以关于这些操作对应的`成本常数`就存储在了`server_cost`表中,而依赖于存储引擎的一些操作对应的`成本常数`就存储在了`engine_cost`表中。 ## mysql.server_cost表 `server_cost`表中在`server`层进行的一些操作对应的`成本常数`,具体内容如下: `mysql\u0026gt; SELECT * FROM mysql.server_cost; +------------------------------+------------+---------------------+---------+ | cost_name | cost_value | last_update | comment | +------------------------------+------------+---------------------+---------+ | disk_temptable_create_cost | NULL | 2018-01-20 12:03:21 | NULL | | disk_temptable_row_cost | NULL | 2018-01-20 12:03:21 | NULL | | key_compare_cost | NULL | 2018-01-20 12:03:21 | NULL | | memory_temptable_create_cost | NULL | 2018-01-20 12:03:21 | NULL | | memory_temptable_row_cost | NULL | 2018-01-20 12:03:21 | NULL | | row_evaluate_cost | NULL | 2018-01-20 12:03:21 | NULL | +------------------------------+------------+---------------------+---------+ 6 rows in set (0.05 sec)` 我们先看一下`server_cost`各个列都分别是什么意思: - `cost_name`:表示成本常数的名称。 - `cost_value`:表示成本常数对应的值。如果该列的值为`NULL`的话,意味着对应的成本常数会采用默认值。 - `last_update`:表示最后更新记录的时间。 - `comment`:注释。 从`server_cost`中的内容可以看出来,目前在`server`层的一些操作对应的`成本常数`有以下几种: 成本常数名称 默认值 描述 `disk_temptable_create_cost` `40.0` 创建基于磁盘的临时表的成本,如果增大这个值的话会让优化器尽量少的创建基于磁盘的临时表。 `disk_temptable_row_cost` `1.0` 向基于磁盘的临时表写入或读取一条记录的成本,如果增大这个值的话会让优化器尽量少的创建基于磁盘的临时表。 `key_compare_cost` `0.1` 两条记录做比较操作的成本,多用在排序操作上,如果增大这个值的话会提升`filesort`的成本,让优化器可能更倾向于使用索引完成排序而不是`filesort`。 `memory_temptable_create_cost` `2.0` 创建基于内存的临时表的成本,如果增大这个值的话会让优化器尽量少的创建基于内存的临时表。 `memory_temptable_row_cost` `0.2` 向基于内存的临时表写入或读取一条记录的成本,如果增大这个值的话会让优化器尽量少的创建基于内存的临时表。 `row_evaluate_cost` `0.2` 这个就是我们之前一直使用的检测一条记录是否符合搜索条件的成本,增大这个值可能让优化器更倾向于使用索引而不是直接全表扫描。 `小贴士:MySQL在执行诸如DISTINCT查询、分组查询、Union查询以及某些特殊条件下的排序查询都可能在内部先创建一个临时表,使用这个临时表来辅助完成查询(比如对于DISTINCT查询可以建一个带有UNIQUE索引的临时表,直接把需要去重的记录插入到这个临时表中,插入完成之后的记录就是结果集了)。在数据量大的情况下可能创建基于磁盘的临时表,也就是为该临时表使用MyISAM、InnoDB等存储引擎,在数据量不大时可能创建基于内存的临时表,也就是使用Memory存储引擎。关于更多临时表的细节我们并不打算展开介绍,因为展开可能又需要好几万字了,大家知道创建临时表和对这个临时表进行写入和读取的操作代价还是很高的就行了。` 这些成本常数在`server_cost`中的初始值都是`NULL`,意味着优化器会使用它们的默认值来计算某个操作的成本,如果我们想修改某个成本常数的值的话,需要做两个步骤: + 对我们感兴趣的成本常数做更新操作 比方说我们想把检测一条记录是否符合搜索条件的成本增大到`0.4`,那么就可以这样写更新语句: `UPDATE mysql.server_cost SET cost_value = 0.4 WHERE cost_name = \u0026#39;row_evaluate_cost\u0026#39;;` + 让系统重新加载这个表的值。 使用下面语句即可: `FLUSH OPTIMIZER_COSTS;` 当然,在你修改完某个成本常数后想把它们再改回默认值的话,可以直接把`cost_value`的值设置为`NULL`,再使用`FLUSH OPTIMIZER_COSTS`语句让系统重新加载它就好了。 ## mysql.engine_cost表 `engine_cost表`表中在存储引擎层进行的一些操作对应的`成本常数`,具体内容如下: `mysql\u0026gt; SELECT * FROM mysql.engine_cost; +-------------+-------------+------------------------+------------+---------------------+---------+ | engine_name | device_type | cost_name | cost_value | last_update | comment | +-------------+-------------+------------------------+------------+---------------------+---------+ | default | 0 | io_block_read_cost | NULL | 2018-01-20 12:03:21 | NULL | | default | 0 | memory_block_read_cost | NULL | 2018-01-20 12:03:21 | NULL | +-------------+-------------+------------------------+------------+---------------------+---------+ 2 rows in set (0.05 sec)` 与`server_cost`相比,`engine_cost`多了两个列: + `engine_name`列:指成本常数适用的存储引擎名称。如果该值为`default`,意味着对应的成本常数适用于所有的存储引擎。 + `device_type`列:指存储引擎使用的设备类型,这主要是为了区分常规的机械硬盘和固态硬盘,不过在`MySQL 5.7.21`这个版本中并没有对机械硬盘的成本和固态硬盘的成本作区分,所以该值默认是`0`。 我们从`engine_cost`表中的内容可以看出来,目前支持的存储引擎成本常数只有两个: 成本常数名称 默认值 描述 `io_block_read_cost` `1.0` 从磁盘上读取一个块对应的成本。请注意我使用的是`块`,而不是`页`这个词儿。对于`InnoDB`存储引擎来说,一个`页`就是一个块,不过对于`MyISAM`存储引擎来说,默认是以`4096`字节作为一个块的。增大这个值会加重`I/O`成本,可能让优化器更倾向于选择使用索引执行查询而不是执行全表扫描。 `memory_block_read_cost` `1.0` 与上一个参数类似,只不过衡量的是从内存中读取一个块对应的成本。 大家看完这两个成本常数的默认值是不是有些疑惑,怎么从内存中和从磁盘上读取一个块的默认成本是一样的,脑子瓦特了?这主要是因为在`MySQL`目前的实现中,并不能准确预测某个查询需要访问的块中有哪些块已经加载到内存中,有哪些块还停留在磁盘上,所以设计`MySQL`的大佬们很粗暴的认为不管这个块有没有加载到内存中,使用的成本都是`1.0`,不过随着`MySQL`的发展,等到可以准确预测哪些块在磁盘上,那些块在内存中的那一天,这两个成本常数的默认值可能会改一改吧。 与更新`server_cost`表中的记录一样,我们也可以通过更新`engine_cost`表中的记录来更改关于存储引擎的成本常数,我们也可以通过为`engine_cost`表插入新记录的方式来添加只针对某种存储引擎的成本常数: + 插入针对某个存储引擎的成本常数 比如我们想增大`InnoDB`存储引擎页面`I/O`的成本,书写正常的插入语句即可: `INSERT INTO mysql.engine_cost VALUES (\u0026#39;InnoDB\u0026#39;, 0, \u0026#39;io_block_read_cost\u0026#39;, 2.0, CURRENT_TIMESTAMP, \u0026#39;increase Innodb I/O cost\u0026#39;);` - 让系统重新加载这个表的值。 使用下面语句即可: `FLUSH OPTIMIZER_COSTS;` "},{"id":22,"href":"/zh/docs/technology/MySQL/MySQL%E6%98%AF%E6%80%8E%E6%A0%B7%E8%BF%90%E8%A1%8C%E7%9A%84/%E7%AC%AC10%E7%AB%A0_%E6%9D%A1%E6%9D%A1%E5%A4%A7%E8%B7%AF%E9%80%9A%E7%BD%97%E9%A9%AC-%E5%8D%95%E8%A1%A8%E8%AE%BF%E9%97%AE%E6%96%B9%E6%B3%95/","title":"第10章_条条大路通罗马-单表访问方法","section":"My Sql是怎样运行的","content":"第10章 条条大路通罗马-单表访问方法\n对于我们这些MySQL的使用者来说,MySQL其实就是一个软件,平时用的最多的就是查询功能。DBA时不时丢过来一些慢查询语句让优化,我们如果连查询是怎么执行的都不清楚还优化个毛线,所以是时候掌握真正的技术了。我们在第一章的时候就曾说过,MySQL Server有一个称为查询优化器的模块,一条查询语句进行语法解析之后就会被交给查询优化器来进行优化,优化的结果就是生成一个所谓的执行计划,这个执行计划表明了应该使用哪些索引进行查询,表之间的连接顺序是什么样的,最后会按照执行计划中的步骤调用存储引擎提供的方法来真正的执行查询,并将查询结果返回给用户。不过查询优化这个主题有点儿大,在学会跑之前还得先学会走,所以本章先来看看MySQL怎么执行单表查询(就是FROM子句后边只有一个表,最简单的那种查询~)。不过需要强调的一点是,在学习本章前务必看过前面关于记录结构、数据页结构以及索引的部分,如果你不能保证这些东西已经完全掌握,那么本章不适合你。\n为了故事的顺利发展,我们先得有个表: CREATE TABLE single_table ( id INT NOT NULL AUTO_INCREMENT, key1 VARCHAR(100), key2 INT, key3 VARCHAR(100), key_part1 VARCHAR(100), key_part2 VARCHAR(100), key_part3 VARCHAR(100), common_field VARCHAR(100), PRIMARY KEY (id), KEY idx_key1 (key1), UNIQUE KEY idx_key2 (key2), KEY idx_key3 (key3), KEY idx_key_part(key_part1, key_part2, key_part3) ) Engine=InnoDB CHARSET=utf8; 我们为这个single_table表建立了1个聚簇索引和4个二级索引,分别是:\n为id列建立的聚簇索引。\n为key1列建立的idx_key1二级索引。\n为key2列建立的idx_key2二级索引,而且该索引是唯一二级索引。\n为key3列建立的idx_key3二级索引。\n为key_part1、key_part2、key_part3列建立的idx_key_part二级索引,这也是一个联合索引。\n然后我们需要为这个表插入10000行记录,除id列外其余的列都插入随机值就好了,具体的插入语句我就不写了,自己写个程序插入吧(id列是自增主键列,不需要我们手动插入)。\n访问方法(access method)的概念 # 想必各位都用过高德地图来查找到某个地方的路线吧(此处没有为高德地图打广告的意思,他们没给我钱,大家用百度地图也可以啊),如果我们搜西安钟楼到大雁塔之间的路线的话,地图软件会给出n种路线供我们选择,如果我们实在闲的没事儿干并且足够有钱的话,还可以用南辕北辙的方式绕地球一圈到达目的地。也就是说,不论采用哪一种方式,我们最终的目标就是到达大雁塔这个地方。回到MySQL中来,我们平时所写的那些查询语句本质上只是一种声明式的语法,只是告诉MySQL我们要获取的数据符合哪些规则,至于MySQL背地里是怎么把查询结果搞出来的那是MySQL自己的事儿。对于单个表的查询来说,设计MySQL的大佬把查询的执行方式大致分为下面两种:\n使用全表扫描进行查询\n这种执行方式很好理解,就是把表的每一行记录都扫一遍嘛,把符合搜索条件的记录加入到结果集就完了。不管是什么查询都可以使用这种方式执行,当然,这种也是最笨的执行方式。\n使用索引进行查询\n因为直接使用全表扫描的方式执行查询要遍历好多记录,所以代价可能太大了。如果查询语句中的搜索条件可以使用到某个索引,那直接使用索引来执行查询可能会加快查询执行的时间。使用索引来执行查询的方式五花八门,又可以细分为许多种类:\n+ 针对主键或唯一二级索引的等值查询\n+ 针对普通二级索引的等值查询\n+ 针对索引列的范围查询\n+ 直接扫描整个索引\n设计MySQL的大佬把MySQL执行查询语句的方式称之为访问方法或者访问类型。同一个查询语句可能可以使用多种不同的访问方法来执行,虽然最后的查询结果都是一样的,但是执行的时间可能差老鼻子远了,就像是从钟楼到大雁塔,你可以坐火箭去,也可以坐飞机去,当然也可以坐乌龟去。下面细细道来各种访问方法的具体内容。\nconst # 有的时候我们可以通过主键列来定位一条记录,比方说这个查询: SELECT * FROM single_table WHERE id = 1438; MySQL会直接利用主键值在聚簇索引中定位对应的用户记录,就像这样:\n原谅我把聚簇索引对应的复杂的B+树结构搞了一个极度精简版,为了突出重点,我们忽略掉了页的结构,直接把所有的叶子节点的记录都放在一起展示,而且记录中只展示我们关心的索引列,对于single_table表的聚簇索引来说,展示的就是id列。我们想突出的重点就是:B+树叶子节点中的记录是按照索引列排序的,对于的聚簇索引来说,它对应的B+树叶子节点中的记录就是按照id列排序的。B+树本来就是一个矮矮的大胖子,所以这样根据主键值定位一条记录的速度贼快。类似的,我们根据唯一二级索引列来定位一条记录的速度也是贼快的,比如下面这个查询: SELECT * FROM single_table WHERE key2 = 3841; 这个查询的执行过程的示意图就是这样:\n可以看到这个查询的执行分两步,第一步先从idx_key2对应的B+树索引中根据key2列与常数的等值比较条件定位到一条二级索引记录,然后再根据该记录的id值到聚簇索引中获取到完整的用户记录。\n设计MySQL的大佬认为通过主键或者唯一二级索引列与常数的等值比较来定位一条记录是像坐火箭一样快的,所以他们把这种通过主键或者唯一二级索引列来定位一条记录的访问方法定义为:const,意思是常数级别的,代价是可以忽略不计的。不过这种const访问方法只能在主键列或者唯一二级索引列和一个常数进行等值比较时才有效,如果主键或者唯一二级索引是由多个列构成的话,索引中的每一个列都需要与常数进行等值比较,这个const访问方法才有效(这是因为只有该索引中全部列都采用等值比较才可以定位唯一的一条记录)。\n对于唯一二级索引来说,查询该列为NULL值的情况比较特殊,比如这样: SELECT * FROM single_table WHERE key2 IS NULL; 因为唯一二级索引列并不限制 NULL 值的数量,所以上述语句可能访问到多条记录,也就是说 上面这个语句不可以使用const访问方法来执行(至于是什么访问方法我们下面马上说)。\nref # 有时候我们对某个普通的二级索引列与常数进行等值比较,比如这样: SELECT * FROM single_table WHERE key1 = 'abc'; 对于这个查询,我们当然可以选择全表扫描来逐一对比搜索条件是否满足要求,我们也可以先使用二级索引找到对应记录的id值,然后再回表到聚簇索引中查找完整的用户记录。由于普通二级索引并不限制索引列值的唯一性,所以可能找到多条对应的记录,也就是说使用二级索引来执行查询的代价取决于等值匹配到的二级索引记录条数。如果匹配的记录较少,则回表的代价还是比较低的,所以MySQL可能选择使用索引而不是全表扫描的方式来执行查询。设计MySQL的大佬就把这种搜索条件为二级索引列与常数等值比较,采用二级索引来执行查询的访问方法称为:ref。我们看一下采用ref访问方法执行查询的图示:\n从图示中可以看出,对于普通的二级索引来说,通过索引列进行等值比较后可能匹配到多条连续的记录,而不是像主键或者唯一二级索引那样最多只能匹配1条记录,所以这种ref访问方法比const差了那么一丢丢,但是在二级索引等值比较时匹配的记录数较少时的效率还是很高的(如果匹配的二级索引记录太多那么回表的成本就太大了),跟坐高铁差不多。不过需要注意下面两种情况:\n二级索引列值为NULL的情况\n不论是普通的二级索引,还是唯一二级索引,它们的索引列对包含NULL值的数量并不限制,所以我们采用key IS NULL这种形式的搜索条件最多只能使用ref的访问方法,而不是const的访问方法。\n对于某个包含多个索引列的二级索引来说,只要是最左边的连续索引列是与常数的等值比较就可能采用ref的访问方法,比方说下面这几个查询:\nSELECT * FROM single_table WHERE key_part1 = \u0026#39;god like\u0026#39; AND key_part2 = \u0026#39;legendary\u0026#39;; SELECT * FROM single_table WHERE key_part1 = \u0026#39;god like\u0026#39; AND key_part2 = \u0026#39;legendary\u0026#39; AND key_part3 = \u0026#39;penta kill\u0026#39;; `但是如果最左边的连续索引列并不全部是等值比较的话,它的访问方法就不能称为`ref`了,比方说这样:` SELECT * FROM single_table WHERE key_part1 = \u0026#39;god like\u0026#39; AND key_part2 \u0026gt; \u0026#39;legendary\u0026#39;; ``` # ref_or_null 有时候我们不仅想找出某个二级索引列的值等于某个常数的记录,还想把该列的值为`NULL`的记录也找出来,就像下面这个查询: `SELECT * FROM single_demo WHERE key1 = \u0026#39;abc\u0026#39; OR key1 IS NULL;` 当使用二级索引而不是全表扫描的方式执行该查询时,这种类型的查询使用的访问方法就称为`ref_or_null`,这个`ref_or_null`访问方法的执行过程如下: ![](img/10-04.png) 可以看到,上面的查询相当于先分别从`idx_key1`索引对应的`B+`树中找出`key1 IS NULL`和`key1 = \u0026#39;abc\u0026#39;`的两个连续的记录范围,然后根据这些二级索引记录中的`id`值再回表查找完整的用户记录。 # range 我们之前介绍的几种访问方法都是在对索引列与某一个常数进行等值比较的时候才可能使用到(`ref_or_null`比较奇特,还计算了值为`NULL`的情况),但是有时候我们面对的搜索条件更复杂,比如下面这个查询: `SELECT * FROM single_table WHERE key2 IN (1438, 6328) OR (key2 \u0026gt;= 38 AND key2 \u0026lt;= 79);` 我们当然还可以使用全表扫描的方式来执行这个查询,不过也可以使用`二级索引 + 回表`的方式执行,如果采用`二级索引 + 回表`的方式来执行的话,那么此时的搜索条件就不只是要求索引列与常数的等值匹配了,而是索引列需要匹配某个或某些范围的值,在本查询中`key2`列的值只要匹配下列3个范围中的任何一个就算是匹配成功了: + `key2`的值是`1438` + `key2`的值是`6328` + `key2`的值在`38`和`79`之间。 设计`MySQL`的大佬把这种利用索引进行范围匹配的访问方法称之为:`range`。 `小贴士:此处所说的使用索引进行范围匹配中的 `索引` 可以是聚簇索引,也可以是二级索引。` 如果把这几个所谓的`key2`列的值需要满足的`范围`在数轴上体现出来的话,那应该是这个样子: ![](img/10-05.png) 也就是从数学的角度看,每一个所谓的范围都是数轴上的一个`区间`,3个范围也就对应着3个区间: + 范围1:`key2 = 1438` + 范围2:`key2 = 6328` + 范围3:`key2 ∈ [38, 79]`,注意这里是闭区间。 我们可以把那种索引列等值匹配的情况称之为`单点区间`,上面所说的`范围1`和`范围2`都可以被称为单点区间,像`范围3`这种的我们可以称为连续范围区间。 # index 看下面这个查询: `SELECT key_part1, key_part2, key_part3 FROM single_table WHERE key_part2 = \u0026#39;abc\u0026#39;;` 由于`key_part2`并不是联合索引`idx_key_part`最左索引列,所以我们无法使用`ref`或者`range`访问方法来执行这个语句。但是这个查询符合下面这两个条件: + 它的查询列表只有3个列:`key_part1`, `key_part2`, `key_part3`,而索引`idx_key_part`又包含这三个列。 + 搜索条件中只有`key_part2`列。这个列也包含在索引`idx_key_part`中。 也就是说我们可以直接通过遍历`idx_key_part`索引的叶子节点的记录来比较`key_part2 = \u0026#39;abc\u0026#39;`这个条件是否成立,把匹配成功的二级索引记录的`key_part1`, `key_part2`, `key_part3`列的值直接加到结果集中就行了。由于二级索引记录比聚簇索记录小的多(聚簇索引记录要存储所有用户定义的列以及所谓的隐藏列,而二级索引记录只需要存放索引列和主键),而且这个过程也不用进行回表操作,所以直接遍历二级索引比直接遍历聚簇索引的成本要小很多,设计`MySQL`的大佬就把这种采用遍历二级索引记录的执行方式称之为:`index`。 # all 最直接的查询执行方式就是我们已经提了无数遍的全表扫描,对于`InnoDB`表来说也就是直接扫描聚簇索引,设计`MySQL`的大佬把这种使用全表扫描执行查询的方式称之为:`all`。 # 注意事项 ## 重温 二级索引 \\+ 回表 **一般情况下**只能利用单个二级索引执行查询,比方说下面的这个查询: `SELECT * FROM single_table WHERE key1 = \u0026#39;abc\u0026#39; AND key2 \u0026gt; 1000;` 查询优化器会识别到这个查询中的两个搜索条件: + `key1 = \u0026#39;abc\u0026#39;` + `key2 \u0026gt; 1000` 优化器一般会根据`single_table`表的统计数据来判断到底使用哪个条件到对应的二级索引中查询扫描的行数会更少,选择那个扫描行数较少的条件到对应的二级索引中查询(关于如何比较的细节我们后边的章节中会介绍)。然后将从该二级索引中查询到的结果经过回表得到完整的用户记录后再根据其余的`WHERE`条件过滤记录。一般来说,等值查找比范围查找需要扫描的行数更少(也就是`ref`的访问方法一般比`range`好,但这也不总是一定的,也可能采用`ref`访问方法的那个索引列的值为特定值的行数特别多),所以这里假设优化器决定使用`idx_key1`索引进行查询,那么整个查询过程可以分为两个步骤: + 步骤1:使用二级索引定位记录的阶段,也就是根据条件`key1 = \u0026#39;abc\u0026#39;`从`idx_key1`索引代表的`B+`树中找到对应的二级索引记录。 + 步骤2:回表阶段,也就是根据上一步骤中找到的记录的主键值进行`回表`操作,也就是到聚簇索引中找到对应的完整的用户记录,再根据条件`key2 \u0026gt; 1000`到完整的用户记录继续过滤。将最终符合过滤条件的记录返回给用户。 这里需要特别提醒大家的一点是,**因为二级索引的节点中的记录只包含索引列和主键,所以在步骤1中使用`idx_key1`索引进行查询时只会用到与`key1`列有关的搜索条件,其余条件,比如`key2 \u0026gt; 1000`这个条件在步骤1中是用不到的,只有在步骤2完成回表操作后才能继续针对完整的用户记录中继续过滤**。 `小贴士:需要注意的是,我们说一般情况下执行一个查询只会用到单个二级索引,不过还是有特殊情况的,我们后边会详细介绍的。` ## 明确range访问方法使用的范围区间 其实对于`B+`树索引来说,只要索引列和常数使用`=`、`\u0026lt;=\u0026gt;`、`IN`、`NOT IN`、`IS NULL`、`IS NOT NULL`、`\u0026gt;`、`\u0026lt;`、`\u0026gt;=`、`\u0026lt;=`、`BETWEEN`、`!=`(不等于也可以写成`\u0026lt;\u0026gt;`)或者`LIKE`操作符连接起来,就可以产生一个所谓的`区间`。 `小贴士:LIKE操作符比较特殊,只有在匹配完整字符串或者匹配字符串前缀时才可以利用索引,具体原因我们在前面的章节中介绍过了,这里就不赘述了。 IN操作符的效果和若干个等值匹配操作符`=`之间用`OR`连接起来是一样的,也就是说会产生多个单点区间,比如下面这两个语句的效果是一样的: SELECT * FROM single_table WHERE key2 IN (1438, 6328); SELECT * FROM single_table WHERE key2 = 1438 OR key2 = 6328;` 不过在日常的工作中,一个查询的`WHERE`子句可能有很多个小的搜索条件,这些搜索条件需要使用`AND`或者`OR`操作符连接起来,虽然大家都知道这两个操作符的作用,但我还是要再说一遍: + `cond1 AND cond2` :只有当`cond1`和`cond2`都为`TRUE`时整个表达式才为`TRUE`。 + `cond1 OR cond2`:只要`cond1`或者`cond2`中有一个为`TRUE`整个表达式就为`TRUE`。 当我们想使用`range`访问方法来执行一个查询语句时,重点就是找出该查询可用的索引以及这些索引对应的范围区间。下面分两种情况看一下怎么从由`AND`或`OR`组成的复杂搜索条件中提取出正确的范围区间。 ### 所有搜索条件都可以使用某个索引的情况 有时候每个搜索条件都可以使用到某个索引,比如下面这个查询语句: `SELECT * FROM single_table WHERE key2 \u0026gt; 100 AND key2 \u0026gt; 200;` 这个查询中的搜索条件都可以使用到`key2`,也就是说每个搜索条件都对应着一个`idx_key2`的范围区间。这两个小的搜索条件使用`AND`连接起来,也就是要取两个范围区间的交集,在我们使用`range`访问方法执行查询时,使用的`idx_key2`索引的范围区间的确定过程就如下图所示: ![](img/10-06.png) `key2 \u0026gt; 100`和`key2 \u0026gt; 200`交集当然就是`key2 \u0026gt; 200`了,也就是说上面这个查询使用`idx_key2`的范围区间就是`(200, +∞)`。这东西小学都学过吧,再不济初中肯定都学过。我们再看一下使用`OR`将多个搜索条件连接在一起的情况: `SELECT * FROM single_table WHERE key2 \u0026gt; 100 OR key2 \u0026gt; 200;` `OR`意味着需要取各个范围区间的并集,所以上面这个查询在我们使用`range`访问方法执行查询时,使用的`idx_key2`索引的范围区间的确定过程就如下图所示: ![](img/10-07.png) 也就是说上面这个查询使用`idx_key2`的范围区间就是`(100, +∞)`。 ### 有的搜索条件无法使用索引的情况 比如下面这个查询: `SELECT * FROM single_table WHERE key2 \u0026gt; 100 AND common_field = \u0026#39;abc\u0026#39;;` 请注意,这个查询语句中能利用的索引只有`idx_key2`一个,而`idx_key2`这个二级索引的记录中又不包含`common_field`这个字段,所以在使用二级索引`idx_key2`定位记录的阶段用不到`common_field = \u0026#39;abc\u0026#39;`这个条件,这个条件是在回表获取了完整的用户记录后才使用的,而`范围区间`是为了到索引中取记录中提出的概念,所以在确定`范围区间`的时候不需要考虑`common_field = \u0026#39;abc\u0026#39;`这个条件,我们在为某个索引确定范围区间的时候只需要把用不到相关索引的搜索条件替换为`TRUE`就好了。 `小贴士:之所以把用不到索引的搜索条件替换为TRUE,是因为我们不打算使用这些条件进行在该索引上进行过滤,所以不管索引的记录满不满足这些条件,我们都把它们选取出来,待到之后回表的时候再使用它们过滤。` 我们把上面的查询中用不到`idx_key2`的搜索条件替换后就是这样: `SELECT * FROM single_table WHERE key2 \u0026gt; 100 AND TRUE;` 化简之后就是这样: `SELECT * FROM single_table WHERE key2 \u0026gt; 100;` 也就是说最上面那个查询使用`idx_key2`的范围区间就是:`(100, +∞)`。 再来看一下使用`OR`的情况: `SELECT * FROM single_table WHERE key2 \u0026gt; 100 OR common_field = \u0026#39;abc\u0026#39;;` 同理,我们把使用不到`idx_key2`索引的搜索条件替换为`TRUE`: `SELECT * FROM single_table WHERE key2 \u0026gt; 100 OR TRUE;` 接着化简: `SELECT * FROM single_table WHERE TRUE;` 额,这也就说说明如果我们强制使用`idx_key2`执行查询的话,对应的范围区间就是`(-∞, +∞)`,也就是需要将全部二级索引的记录进行回表,这个代价肯定比直接全表扫描都大了。也就是说一个使用到索引的搜索条件和没有使用该索引的搜索条件使用`OR`连接起来后是无法使用该索引的。 ### 复杂搜索条件下找出范围匹配的区间 有的查询的搜索条件可能特别复杂,光是找出范围匹配的各个区间就挺烦的,比方说下面这个: `SELECT * FROM single_table WHERE (key1 \u0026gt; \u0026#39;xyz\u0026#39; AND key2 = 748 ) OR (key1 \u0026lt; \u0026#39;abc\u0026#39; AND key1 \u0026gt; \u0026#39;lmn\u0026#39;) OR (key1 LIKE \u0026#39;%suf\u0026#39; AND key1 \u0026gt; \u0026#39;zzz\u0026#39; AND (key2 \u0026lt; 8000 OR common_field = \u0026#39;abc\u0026#39;)) ;` 我滴个神,这个搜索条件真是绝了,不过大家不要被复杂的表象迷住了双眼,按着下面这个套路分析一下: + 首先查看`WHERE`子句中的搜索条件都涉及到了哪些列,哪些列可能使用到索引。 这个查询的搜索条件涉及到了`key1`、`key2`、`common_field`这3个列,然后`key1`列有普通的二级索引`idx_key1`,`key2`列有唯一二级索引`idx_key2`。 + 对于那些可能用到的索引,分析它们的范围区间。 + 假设我们使用`idx_key1`执行查询 + 我们需要把那些用不到该索引的搜索条件暂时移除掉,移除方法也简单,直接把它们替换为`TRUE`就好了。上面的查询中除了有关`key2`和`common_field`列不能使用到`idx_key1`索引外,`key1 LIKE \u0026#39;%suf\u0026#39;`也使用不到索引,所以把这些搜索条件替换为`TRUE`之后的样子就是这样: `(key1 \u0026gt; \u0026#39;xyz\u0026#39; AND TRUE ) OR (key1 \u0026lt; \u0026#39;abc\u0026#39; AND key1 \u0026gt; \u0026#39;lmn\u0026#39;) OR (TRUE AND key1 \u0026gt; \u0026#39;zzz\u0026#39; AND (TRUE OR TRUE))` 化简一下上面的搜索条件就是下面这样: `(key1 \u0026gt; \u0026#39;xyz\u0026#39;) OR (key1 \u0026lt; \u0026#39;abc\u0026#39; AND key1 \u0026gt; \u0026#39;lmn\u0026#39;) OR (key1 \u0026gt; \u0026#39;zzz\u0026#39;)` - 替换掉永远为`TRUE`或`FALSE`的条件 因为符合`key1 \u0026lt; \u0026#39;abc\u0026#39; AND key1 \u0026gt; \u0026#39;lmn\u0026#39;`永远为`FALSE`,所以上面的搜索条件可以被写成这样: `(key1 \u0026gt; \u0026#39;xyz\u0026#39;) OR (key1 \u0026gt; \u0026#39;zzz\u0026#39;)` + 继续化简区间 `key1 \u0026gt; \u0026#39;xyz\u0026#39;`和`key1 \u0026gt; \u0026#39;zzz\u0026#39;`之间使用`OR`操作符连接起来的,意味着要取并集,所以最终的结果化简的到的区间就是:`key1 \u0026gt; xyz`。也就是说:**上面那个有一坨搜索条件的查询语句如果使用 idx_key1 索引执行查询的话,需要把满足`key1 \u0026gt; xyz`的二级索引记录都取出来,然后拿着这些记录的id再进行回表,得到完整的用户记录之后再使用其他的搜索条件进行过滤**。 + 假设我们使用`idx_key2`执行查询 + 我们需要把那些用不到该索引的搜索条件暂时使用`TRUE`条件替换掉,其中有关`key1`和`common_field`的搜索条件都需要被替换掉,替换结果就是: `(TRUE AND key2 = 748 ) OR (TRUE AND TRUE) OR (TRUE AND TRUE AND (key2 \u0026lt; 8000 OR TRUE))` 哎呀呀,`key2 \u0026lt; 8000 OR TRUE`的结果肯定是`TRUE`呀,也就是说化简之后的搜索条件成这样了: `key2 = 748 OR TRUE` 这个化简之后的结果就更简单了: `TRUE` 这个结果也就意味着如果我们要使用`idx_key2`索引执行查询语句的话,需要扫描`idx_key2`二级索引的所有记录,然后再回表,这不是得不偿失么,所以这种情况下不会使用`idx_key2`索引的。 ## 索引合并 我们前面说过`MySQL`在一般情况下执行一个查询时最多只会用到单个二级索引,但不是还有特殊情况么,在这些特殊情况下也可能在一个查询中使用到多个二级索引,设计`MySQL`的大佬把这种使用到多个索引来完成一次查询的执行方法称之为:`index merge`,具体的索引合并算法有下面三种。 ### Intersection合并 `Intersection`翻译过来的意思是`交集`。这里是说某个查询可以使用多个二级索引,将从多个二级索引中查询到的结果取交集,比方说下面这个查询: `SELECT * FROM single_table WHERE key1 = \u0026#39;a\u0026#39; AND key3 = \u0026#39;b\u0026#39;;` 假设这个查询使用`Intersection`合并的方式执行的话,那这个过程就是这样的: + 从`idx_key1`二级索引对应的`B+`树中取出`key1 = \u0026#39;a\u0026#39;`的相关记录。 + 从`idx_key3`二级索引对应的`B+`树中取出`key3 = \u0026#39;b\u0026#39;`的相关记录。 + 二级索引的记录都是由`索引列 + 主键`构成的,所以我们可以计算出这两个结果集中`id`值的交集。 + 按照上一步生成的`id`值列表进行回表操作,也就是从聚簇索引中把指定`id`值的完整用户记录取出来,返回给用户。 这里有同学会思考:为什么不直接使用`idx_key1`或者`idx_key3`只根据某个搜索条件去读取一个二级索引,然后回表后再过滤另外一个搜索条件呢?这里要分析一下两种查询执行方式之间需要的成本代价。 只读取一个二级索引的成本: + 按照某个搜索条件读取一个二级索引 + 根据从该二级索引得到的主键值进行回表操作,然后再过滤其他的搜索条件 读取多个二级索引之后取交集成本: + 按照不同的搜索条件分别读取不同的二级索引 + 将从多个二级索引得到的主键值取交集,然后进行回表操作 虽然读取多个二级索引比读取一个二级索引消耗性能,但是读取二级索引的操作是`顺序I/O`,而回表操作是`随机I/O`,所以如果只读取一个二级索引时需要回表的记录数特别多,而读取多个二级索引之后取交集的记录数非常少,当节省的因为`回表`而造成的性能损耗比访问多个二级索引带来的性能损耗更高时,读取多个二级索引后取交集比只读取一个二级索引的成本更低。 `MySQL`在某些特定的情况下才可能会使用到`Intersection`索引合并: + 情况一:二级索引列是等值匹配的情况,对于联合索引来说,在联合索引中的每个列都必须等值匹配,不能出现只匹配部分列的情况。 比方说下面这个查询可能用到`idx_key1`和`idx_key_part`这两个二级索引进行`Intersection`索引合并的操作: `SELECT * FROM single_table WHERE key1 = \u0026#39;a\u0026#39; AND key_part1 = \u0026#39;a\u0026#39; AND key_part2 = \u0026#39;b\u0026#39; AND key_part3 = \u0026#39;c\u0026#39;;` 而下面这两个查询就不能进行`Intersection`索引合并: ``` SELECT * FROM single_table WHERE key1 \u0026gt; \u0026#39;a\u0026#39; AND key_part1 = \u0026#39;a\u0026#39; AND key_part2 = \u0026#39;b\u0026#39; AND key_part3 = \u0026#39;c\u0026#39;; SELECT * FROM single_table WHERE key1 = \u0026#39;a\u0026#39; AND key_part1 = \u0026#39;a\u0026#39;; ``` 第一个查询是因为对`key1`进行了范围匹配,第二个查询是因为联合索引`idx_key_part`中的`key_part2`列并没有出现在搜索条件中,所以这两个查询不能进行`Intersection`索引合并。 + 情况二:主键列可以是范围匹配 比方说下面这个查询可能用到主键和`idx_key1`进行`Intersection`索引合并的操作: `SELECT * FROM single_table WHERE id \u0026gt; 100 AND key1 = \u0026#39;a\u0026#39;;` 为什么呢?凭什么呀?突然冒出这么两个规定让大家一脸懵逼,下面我们慢慢品一品这里头的玄机。这话还得从`InnoDB`的索引结构说起,你要是记不清麻烦再回头看看。对于`InnoDB`的二级索引来说,记录先是按照索引列进行排序,如果该二级索引是一个联合索引,那么会按照联合索引中的各个列依次排序。而二级索引的用户记录是由`索引列 + 主键`构成的,二级索引列的值相同的记录可能会有好多条,这些索引列的值相同的记录又是按照`主键`的值进行排序的。所以重点来了,之所以在二级索引列都是等值匹配的情况下才可能使用`Intersection`索引合并,是因为**只有在这种情况下根据二级索引查询出的结果集是按照主键值排序的**。 so?还是没看懂根据二级索引查询出的结果集是按照主键值排序的对使用`Intersection`索引合并有什么好处?小伙子,别忘了`Intersection`索引合并会把从多个二级索引中查询出的主键值求交集,如果从各个二级索引中查询的到的结果集本身就是已经按照主键排好序的,那么求交集的过程就很easy啦。假设某个查询使用`Intersection`索引合并的方式从`idx_key1`和`idx_key2`这两个二级索引中获取到的主键值分别是: + 从`idx_key1`中获取到已经排好序的主键值:1、3、5 + 从`idx_key2`中获取到已经排好序的主键值:2、3、4 那么求交集的过程就是这样:逐个取出这两个结果集中最小的主键值,如果两个值相等,则加入最后的交集结果中,否则丢弃当前较小的主键值,再取该丢弃的主键值所在结果集的后一个主键值来比较,直到某个结果集中的主键值用完了,如果还是觉得不太明白那继续往下看: + 先取出这两个结果集中较小的主键值做比较,因为`1 \u0026lt; 2`,所以把`idx_key1`的结果集的主键值`1`丢弃,取出后边的`3`来比较。 + 因为`3 \u0026gt; 2`,所以把`idx_key2`的结果集的主键值`2`丢弃,取出后边的`3`来比较。 + 因为`3 = 3`,所以把`3`加入到最后的交集结果中,继续两个结果集后边的主键值来比较。 + 后边的主键值也不相等,所以最后的交集结果中只包含主键值`3`。 别看我们写的啰嗦,这个过程其实可快了,时间复杂度是`O(n)`,但是如果从各个二级索引中查询出的结果集并不是按照主键排序的话,那就要先把结果集中的主键值排序完再来做上面的那个过程,就比较耗时了。 `小贴士:按照有序的主键值去回表取记录有个专有名词儿,叫:Rowid Ordered Retrieval,简称ROR,以后大家在某些地方见到这个名词儿就眼熟了。` 另外,不仅是多个二级索引之间可以采用`Intersection`索引合并,索引合并也可以有聚簇索引参加,也就是我们上面写的`情况二`:在搜索条件中有主键的范围匹配的情况下也可以使用`Intersection`索引合并索引合并。为什么主键这就可以范围匹配了?还是得回到应用场景里,比如看下面这个查询: `SELECT * FROM single_table WHERE key1 = \u0026#39;a\u0026#39; AND id \u0026gt; 100;` 假设这个查询可以采用`Intersection`索引合并,我们理所当然的以为这个查询会分别按照`id \u0026gt; 100`这个条件从聚簇索引中获取一些记录,在通过`key1 = \u0026#39;a\u0026#39;`这个条件从`idx_key1`二级索引中获取一些记录,然后再求交集,其实这样就把问题复杂化了,没必要从聚簇索引中获取一次记录。别忘了二级索引的记录中都带有主键值的,所以可以在从`idx_key1`中获取到的主键值上直接运用条件`id \u0026gt; 100`过滤就行了,这样多简单。所以涉及主键的搜索条件只不过是为了从别的二级索引得到的结果集中过滤记录罢了,是不是等值匹配不重要。 当然,上面说的`情况一`和`情况二`只是发生`Intersection`索引合并的必要条件,不是充分条件。也就是说即使情况一、情况二成立,也不一定发生`Intersection`索引合并,这得看优化器的心情。优化器只有在单独根据搜索条件从某个二级索引中获取的记录数太多,导致回表开销太大,而通过`Intersection`索引合并后需要回表的记录数大大减少时才会使用`Intersection`索引合并。 ### Union合并 我们在写查询语句时经常想把既符合某个搜索条件的记录取出来,也把符合另外的某个搜索条件的记录取出来,我们说这些不同的搜索条件之间是`OR`关系。有时候`OR`关系的不同搜索条件会使用到不同的索引,比方说这样: `SELECT * FROM single_table WHERE key1 = \u0026#39;a\u0026#39; OR key3 = \u0026#39;b\u0026#39;` `Intersection`是交集的意思,这适用于使用不同索引的搜索条件之间使用`AND`连接起来的情况;`Union`是并集的意思,适用于使用不同索引的搜索条件之间使用`OR`连接起来的情况。与`Intersection`索引合并类似,`MySQL`在某些特定的情况下才可能会使用到`Union`索引合并: + 情况一:二级索引列是等值匹配的情况,对于联合索引来说,在联合索引中的每个列都必须等值匹配,不能出现只出现匹配部分列的情况。 比方说下面这个查询可能用到`idx_key1`和`idx_key_part`这两个二级索引进行`Union`索引合并的操作: `SELECT * FROM single_table WHERE key1 = \u0026#39;a\u0026#39; OR ( key_part1 = \u0026#39;a\u0026#39; AND key_part2 = \u0026#39;b\u0026#39; AND key_part3 = \u0026#39;c\u0026#39;);` 而下面这两个查询就不能进行`Union`索引合并: ``` SELECT * FROM single_table WHERE key1 \u0026gt; \u0026#39;a\u0026#39; OR (key_part1 = \u0026#39;a\u0026#39; AND key_part2 = \u0026#39;b\u0026#39; AND key_part3 = \u0026#39;c\u0026#39;); SELECT * FROM single_table WHERE key1 = \u0026#39;a\u0026#39; OR key_part1 = \u0026#39;a\u0026#39;; ``` 第一个查询是因为对`key1`进行了范围匹配,第二个查询是因为联合索引`idx_key_part`中的`key_part2`列并没有出现在搜索条件中,所以这两个查询不能进行`Union`索引合并。 + 情况二:主键列可以是范围匹配 + 情况三:使用`Intersection`索引合并的搜索条件 这种情况其实也挺好理解,就是搜索条件的某些部分使用`Intersection`索引合并的方式得到的主键集合和其他方式得到的主键集合取交集,比方说这个查询: `SELECT * FROM single_table WHERE key_part1 = \u0026#39;a\u0026#39; AND key_part2 = \u0026#39;b\u0026#39; AND key_part3 = \u0026#39;c\u0026#39; OR (key1 = \u0026#39;a\u0026#39; AND key3 = \u0026#39;b\u0026#39;);` 优化器可能采用这样的方式来执行这个查询: + 先按照搜索条件`key1 = \u0026#39;a\u0026#39; AND key3 = \u0026#39;b\u0026#39;`从索引`idx_key1`和`idx_key3`中使用`Intersection`索引合并的方式得到一个主键集合。 + 再按照搜索条件`key_part1 = \u0026#39;a\u0026#39; AND key_part2 = \u0026#39;b\u0026#39; AND key_part3 = \u0026#39;c\u0026#39;`从联合索引`idx_key_part`中得到另一个主键集合。 + 采用`Union`索引合并的方式把上述两个主键集合取并集,然后进行回表操作,将结果返回给用户。 当然,查询条件符合了这些情况也不一定就会采用`Union`索引合并,也得看优化器的心情。优化器只有在单独根据搜索条件从某个二级索引中获取的记录数比较少,通过`Union`索引合并后进行访问的代价比全表扫描更小时才会使用`Union`索引合并。 ### Sort-Union合并 `Union`索引合并的使用条件太苛刻,必须保证各个二级索引列在进行等值匹配的条件下才可能被用到,比方说下面这个查询就无法使用到`Union`索引合并: `SELECT * FROM single_table WHERE key1 \u0026lt; \u0026#39;a\u0026#39; OR key3 \u0026gt; \u0026#39;z\u0026#39;` 这是因为根据`key1 \u0026lt; \u0026#39;a\u0026#39;`从`idx_key1`索引中获取的二级索引记录的主键值不是排好序的,根据`key3 \u0026gt; \u0026#39;z\u0026#39;`从`idx_key3`索引中获取的二级索引记录的主键值也不是排好序的,但是`key1 \u0026lt; \u0026#39;a\u0026#39;`和`key3 \u0026gt; \u0026#39;z\u0026#39;`这两个条件又特别让我们动心,所以我们可以这样: + 先根据`key1 \u0026lt; \u0026#39;a\u0026#39;`条件从`idx_key1`二级索引总获取记录,并按照记录的主键值进行排序 + 再根据`key3 \u0026gt; \u0026#39;z\u0026#39;`条件从`idx_key3`二级索引总获取记录,并按照记录的主键值进行排序 + 因为上述的两个二级索引主键值都是排好序的,剩下的操作和`Union`索引合并方式就一样了。 我们把上述这种先按照二级索引记录的主键值进行排序,之后按照`Union`索引合并方式执行的方式称之为`Sort-Union`索引合并,很显然,这种`Sort-Union`索引合并比单纯的`Union`索引合并多了一步对二级索引记录的主键值排序的过程。 `小贴士:为什么有Sort-Union索引合并,就没有Sort-Intersection索引合并么?是的,的确没有Sort-Intersection索引合并这么一说,Sort-Union的适用场景是单独根据搜索条件从某个二级索引中获取的记录数比较少,这样即使对这些二级索引记录按照主键值进行排序的成本也不会太高,而Intersection索引合并的适用场景是单独根据搜索条件从某个二级索引中获取的记录数太多,导致回表开销太大,合并后可以明显降低回表开销,但是如果加入Sort-Intersection后,就需要为大量的二级索引记录按照主键值进行排序,这个成本可能比回表查询都高了,所以也就没有引入Sort-Intersection这个玩意儿。` ### 索引合并注意事项 ### 联合索引替代Intersection索引合并 `SELECT * FROM single_table WHERE key1 = \u0026#39;a\u0026#39; AND key3 = \u0026#39;b\u0026#39;;` 这个查询之所以可能使用`Intersection`索引合并的方式执行,还不是因为`idx_key1`和`idx_key3`是两个单独的`B+`树索引,你要是把这两个列搞一个联合索引,那直接使用这个联合索引就把事情搞定了,何必用什么索引合并呢,就像这样: `ALTER TABLE single_table drop index idx_key1, idx_key3, add index idx_key1_key3(key1, key3);` 这样我们把没用的`idx_key1`、`idx_key3`都干掉,再添加一个联合索引`idx_key1_key3`,使用这个联合索引进行查询简直是又快又好,既不用多读一棵`B+`树,也不用合并结果,何乐而不为? `小贴士:不过小心有单独对key3列进行查询的业务场景,这样子不得不再把key3列的单独索引给加上。` "},{"id":23,"href":"/zh/docs/technology/MySQL/MySQL%E6%98%AF%E6%80%8E%E6%A0%B7%E8%BF%90%E8%A1%8C%E7%9A%84/%E7%AC%AC9%E7%AB%A0_%E5%AD%98%E6%94%BE%E9%A1%B5%E7%9A%84%E5%A4%A7%E6%B1%A0%E5%AD%90-InnoDB%E7%9A%84%E8%A1%A8%E7%A9%BA%E9%97%B4/","title":"第9章_存放页的大池子-InnoDB的表空间","section":"My Sql是怎样运行的","content":"第9章 存放页的大池子-InnoDB的表空间\n通过前面儿的内容大家知道,表空间是一个抽象的概念,对于系统表空间来说,对应着文件系统中一个或多个实际文件;对于每个独立表空间来说,对应着文件系统中一个名为表名.ibd的实际文件。大家可以把表空间想象成被切分为许许多多个页的池子,当我们想为某个表插入一条记录的时候,就从池子中捞出一个对应的页来把数据写进去。本章内容会深入到表空间的各个细节中,带领大家在InnoDB存储结构的池子中畅游。由于本章中将会涉及比较多的概念,虽然这些概念都不难,但是却相互依赖,所以奉劝大家在看的时候:\n不要跳着看!\n不要跳着看!\n不要跳着看!\n回忆一些旧知识 # 页类型 # 再一次强调,InnoDB是以页为单位管理存储空间的,我们的聚簇索引(也就是完整的表数据)和其他的二级索引都是以B+树的形式保存到表空间的,而B+树的节点就是数据页。我们前面说过,这个数据页的类型名其实是:FIL_PAGE_INDEX,除了这种存放索引数据的页类型之外,InnoDB也为了不同的目的设计了若干种不同类型的页,为了唤醒大家的记忆,我们再一次把各种常用的页类型提出来: 类型名称 十六进制 描述 FIL_PAGE_TYPE_ALLOCATED 0x0000 最新分配,还没使用 FIL_PAGE_UNDO_LOG 0x0002 Undo日志页 FIL_PAGE_INODE 0x0003 段信息节点 FIL_PAGE_IBUF_FREE_LIST 0x0004 Insert Buffer空闲列表 FIL_PAGE_IBUF_BITMAP 0x0005 Insert Buffer位图 FIL_PAGE_TYPE_SYS 0x0006 系统页 FIL_PAGE_TYPE_TRX_SYS 0x0007 事务系统数据 FIL_PAGE_TYPE_FSP_HDR 0x0008 表空间头部信息 FIL_PAGE_TYPE_XDES 0x0009 扩展描述页 FIL_PAGE_TYPE_BLOB 0x000A BLOB页 FIL_PAGE_INDEX 0x45BF 索引页,也就是我们所说的数据页 因为页类型前面都有个FIL_PAGE或者FIL_PAGE_TYPE的前缀,为简便起见我们后边介绍页类型的时候就把这些前缀省略掉了,比方说FIL_PAGE_TYPE_ALLOCATED类型称为ALLOCATED类型,FIL_PAGE_INDEX类型称为INDEX类型。\n页通用部分 # 我们前面说过数据页,也就是INDEX类型的页由7个部分组成,其中的两个部分是所有类型的页都通用的。当然我不能寄希望于你把我说的话都记住,所以在这里重新强调一遍,任何类型的页都有下面这种通用的结构:\n从上图中可以看出,任何类型的页都会包含这两个部分:\nFile Header:记录页的一些通用信息\nFile Trailer:校验页是否完整,保证从内存到磁盘刷新时内容的一致性。\n对于File Trailer我们不再做过多强调,全部忘记了的话可以到将数据页的那一章回顾一下。我们这里再强调一遍File Header的各个组成部分: 名称 占用空间大小 描述 FIL_PAGE_SPACE_OR_CHKSUM 4字节 页的校验和(checksum值) FIL_PAGE_OFFSET 4字节 页号 FIL_PAGE_PREV 4字节 上一个页的页号 FIL_PAGE_NEXT 4字节 下一个页的页号 FIL_PAGE_LSN 8字节 页被最后修改时对应的日志序列位置(英文名是:Log Sequence Number) FIL_PAGE_TYPE 2字节 该页的类型 FIL_PAGE_FILE_FLUSH_LSN 8字节 仅在系统表空间的一个页中定义,代表文件至少被刷新到了对应的LSN值 FIL_PAGE_ARCH_LOG_NO_OR_SPACE_ID 4字节 页属于哪个表空间 现在除了名称里边儿带有LSN的两个字段大家可能看不懂以外,其他的字段肯定都是倍儿熟了,不过我们仍要强调这么几点:\n表空间中的每一个页都对应着一个页号,也就是FIL_PAGE_OFFSET,这个页号由4个字节组成,也就是32个比特位,所以一个表空间最多可以拥有2³²个页,如果按照页的默认大小16KB来算,一个表空间最多支持64TB的数据。表空间的第一个页的页号为0,之后的页号分别是1,2,3\u0026hellip;依此类推\n某些类型的页可以组成链表,链表中的页可以不按照物理顺序存储,而是根据FIL_PAGE_PREV和FIL_PAGE_NEXT来存储上一个页和下一个页的页号。需要注意的是,这两个字段主要是为了INDEX类型的页,也就是我们之前一直说的数据页建立B+树后,为每层节点建立双向链表用的,一般类型的页是不使用这两个字段的。\n每个页的类型由FIL_PAGE_TYPE表示,比如像数据页的该字段的值就是0x45BF,我们后边会介绍各种不同类型的页,不同类型的页在该字段上的值是不同的。\n独立表空间结构 # 我们知道InnoDB支持许多种类型的表空间,本章重点关注独立表空间和系统表空间的结构。它们的结构比较相似,但是由于系统表空间中额外包含了一些关于整个系统的信息,所以我们先挑简单一点的独立表空间来介绍,稍后再说系统表空间的结构。\n区(extent)的概念 # 表空间中的页实在是太多了,为了更好的管理这些页,设计InnoDB的大佬们提出了区(英文名:extent)的概念。对于16KB的页来说,连续的64个页就是一个区,也就是说一个区默认占用1MB空间大小。不论是系统表空间还是独立表空间,都可以看成是由若干个区组成的,每256个区被划分成一组。画个图表示就是这样:\n其中extent 0 ~ extent 255这256个区算是第一个组,extent 256 ~ extent 511这256个区算是第二个组,extent 512 ~ extent 767这256个区算是第三个组(上图中并未画全第三个组全部的区,请自行脑补),依此类推可以划分更多的组。这些组的头几个页的类型都是类似的,就像这样:\n从上图中我们能得到如下信息:\n第一个组最开始的3个页的类型是固定的,也就是说extent 0这个区最开始的3个页的类型是固定的,分别是:\n+ FSP_HDR类型:这个类型的页是用来登记整个表空间的一些整体属性以及本组所有的区,也就是extent 0 ~ extent 255这256个区的属性,稍后详细介绍。需要注意的一点是,整个表空间只有一个FSP_HDR类型的页。\n+ IBUF_BITMAP类型:这个类型的页是存储本组所有的区的所有页关于INSERT BUFFER的信息。当然,你现在不用知道什么是个INSERT BUFFER,后边会详细说到你吐。\n+ INODE类型:这个类型的页存储了许多称为INODE的数据结构,还是那句话,现在你不需要知道什么是个INODE,后边儿会说到你吐。\n其余各组最开始的2个页的类型是固定的,也就是说extent 256、extent 512这些区最开始的2个页的类型是固定的,分别是:\n+ XDES类型:全称是extent descriptor,用来登记本组256个区的属性,也就是说对于在extent 256区中的该类型页存储的就是extent 256 ~ extent 511这些区的属性,对于在extent 512区中的该类型页存储的就是extent 512 ~ extent 767这些区的属性。上面介绍的FSP_HDR类型的页其实和XDES类型的页的作用类似,只不过FSP_HDR类型的页还会额外存储一些表空间的属性。\n+ IBUF_BITMAP类型:上面介绍过了。\n好了,宏观的结构介绍完了,里边儿的名词大家也不用记清楚,只要大致记得:表空间被划分为许多连续的区,每个区默认由64个页组成,每256个区划分为一组,每个组的最开始的几个页类型是固定的就好了。\n段(segment)的概念 # 为什么好端端的提出一个区(extent)的概念呢?我们以前分析问题的套路都是这样的:表中的记录存储到页里边儿,然后页作为节点组成B+树,这个B+树就是索引,然后等等一堆聚簇索引和二级索引的区别。这套路也没什么不妥的呀~\n是的,如果我们表中数据量很少的话,比如说你的表中只有几十条、几百条数据的话,的确用不到区的概念,因为简单的几个页就能把对应的数据存储起来,但是你架不住表里的记录越来越多呀。\n什么??表里的记录多了又怎样?B+树的每一层中的页都会形成一个双向链表呀,File Header中的FIL_PAGE_PREV和FIL_PAGE_NEXT字段不就是为了形成双向链表设置的么?\n是的是的,您说的都对,从理论上说,不引入区的概念只使用页的概念对存储引擎的运行并没什么影响,但是我们来考虑一下下面这个场景:\n我们每向表中插入一条记录,本质上就是向该表的聚簇索引以及所有二级索引代表的B+树的节点中插入数据。而B+树的每一层中的页都会形成一个双向链表,如果是以页为单位来分配存储空间的话,双向链表相邻的两个页之间的物理位置可能离得非常远。我们介绍B+树索引的适用场景的时候特别提到范围查询只需要定位到最左边的记录和最右边的记录,然后沿着双向链表一直扫描就可以了,而如果链表中相邻的两个页物理位置离得非常远,就是所谓的随机I/O。再一次强调,磁盘的速度和内存的速度差了好几个数量级,随机I/O是非常慢的,所以我们应该尽量让链表中相邻的页的物理位置也相邻,这样进行范围查询的时候才可以使用所谓的顺序I/O。 所以,所以,所以才引入了区(extent)的概念,一个区就是在物理位置上连续的64个页。在表中数据量大的时候,为某个索引分配空间的时候就不再按照页为单位分配了,而是按照区为单位分配,甚至在表中的数据十分非常特别多的时候,可以一次性分配多个连续的区。虽然可能造成一点点空间的浪费(数据不足填充满整个区),但是从性能角度看,可以消除很多的随机I/O,功大于过嘛!\n事情到这里就结束了么?太天真了,我们提到的范围查询,其实是对B+树叶子节点中的记录进行顺序扫描,而如果不区分叶子节点和非叶子节点,统统把节点代表的页放到申请到的区中的话,进行范围扫描的效果就大打折扣了。所以设计InnoDB的大佬们对B+树的叶子节点和非叶子节点进行了区别对待,也就是说叶子节点有自己独有的区,非叶子节点也有自己独有的区。存放叶子节点的区的集合就算是一个段(segment),存放非叶子节点的区的集合也算是一个段。也就是说一个索引会生成2个段,一个叶子节点段,一个非叶子节点段。\n默认情况下一个使用InnoDB存储引擎的表只有一个聚簇索引,一个索引会生成2个段,而段是以区为单位申请存储空间的,一个区默认占用1M存储空间,所以默认情况下一个只存了几条记录的小表也需要2M的存储空间么?以后每次添加一个索引都要多申请2M的存储空间么?这对于存储记录比较少的表简直是天大的浪费。设计InnoDB的大佬们都挺节俭的,当然也考虑到了这种情况。这个问题的症结在于到现在为止我们介绍的区都是非常纯粹的,也就是一个区被整个分配给某一个段,或者说区中的所有页都是为了存储同一个段的数据而存在的,即使段的数据填不满区中所有的页,那余下的页也不能挪作他用。现在为了考虑以完整的区为单位分配给某个段对于数据量较小的表太浪费存储空间的这种情况,设计InnoDB的大佬们提出了一个碎片(fragment)区的概念,也就是在一个碎片区中,并不是所有的页都是为了存储同一个段的数据而存在的,而是碎片区中的页可以用于不同的目的,比如有些页用于段A,有些页用于段B,有些页甚至哪个段都不属于。碎片区直属于表空间,并不属于任何一个段。所以此后为某个段分配存储空间的策略是这样的:\n在刚开始向表中插入数据的时候,段是从某个碎片区以单个页为单位来分配存储空间的。\n当某个段已经占用了32个碎片区页之后,就会以完整的区为单位来分配存储空间。\n所以现在段不能仅定义为是某些区的集合,更精确的应该是某些零散的页以及一些完整的区的集合。除了索引的叶子节点段和非叶子节点段之外,InnoDB中还有为存储一些特殊的数据而定义的段,比如回滚段,当然我们现在并不关心别的类型的段,现在只需要知道段是一些零散的页以及一些完整的区的集合就好了。\n区的分类 # 通过上面一通介绍,大家知道了表空间的是由若干个区组成的,这些区大体上可以分为4种类型:\n空闲的区:现在还没有用到这个区中的任何页。\n有剩余空间的碎片区:表示碎片区中还有可用的页。\n没有剩余空间的碎片区:表示碎片区中的所有页都被使用,没有空闲页。\n附属于某个段的区。每一个索引都可以分为叶子节点段和非叶子节点段,除此之外InnoDB还会另外定义一些特殊作用的段,在这些段中的数据量很大时将使用区来作为基本的分配单位。\n这4种类型的区也可以被称为区的4种状态(State),设计InnoDB的大佬们为这4种状态的区定义了特定的名词儿: 状态名 含义 FREE 空闲的区 FREE_FRAG 有剩余空间的碎片区 FULL_FRAG 没有剩余空间的碎片区 FSEG 附属于某个段的区 需要再次强调一遍的是,处于FREE、FREE_FRAG以及FULL_FRAG这三种状态的区都是独立的,算是直属于表空间;而处于FSEG状态的区是附属于某个段的。 小贴士:如果把表空间比作是一个集团军,段就相当于师,区就相当于团。一般的团都是隶属于某个师的,就像是处于FSEG的区全都隶属于某个段,而处于FREE、FREE_FRAG以及FULL_FRAG这三种状态的区却直接隶属于表空间,就像独立团直接听命于军部一样。\n为了方便管理这些区,设计InnoDB的大佬设计了一个称为XDES Entry的结构(全称就是Extent Descriptor Entry),每一个区都对应着一个XDES Entry结构,这个结构记录了对应的区的一些属性。我们先看图来对这个结构有个大致的了解:\n从图中我们可以看出,XDES Entry是一个40个字节的结构,大致分为4个部分,各个部分的释义如下:\nSegment ID(8字节)\n每一个段都有一个唯一的编号,用ID表示,此处的Segment ID字段表示就是该区所在的段。当然前提是该区已经被分配给某个段了,不然的话该字段的值没什么意义。\nList Node(12字节)\n这个部分可以将若干个XDES Entry结构串联成一个链表,大家看一下这个List Node的结构:\n如果我们想定位表空间内的某一个位置的话,只需指定页号以及该位置在指定页号中的页内偏移量即可。所以:\n+ Pre Node Page Number和Pre Node Offset的组合就是指向前一个XDES Entry的指针\n+ Next Node Page Number和Next Node Offset的组合就是指向后一个XDES Entry的指针。\n把一些XDES Entry结构连成一个链表有什么用?稍安勿躁,我们稍后介绍XDES Entry结构组成的链表问题。\nState(4字节)\n这个字段表明区的状态。可选的值就是我们前面说过的那4个,分别是:FREE、FREE_FRAG、FULL_FRAG和FSEG。具体释义就不多介绍了,前面说的够仔细了。\nPage State Bitmap(16字节)\n这个部分共占用16个字节,也就是128个比特位。我们说一个区默认有64个页,这128个比特位被划分为64个部分,每个部分2个比特位,对应区中的一个页。比如Page State Bitmap部分的第1和第2个比特位对应着区中的第1个页,第3和第4个比特位对应着区中的第2个页,依此类推,Page State Bitmap部分的第127和128个比特位对应着区中的第64个页。这两个比特位的第一个位表示对应的页是否是空闲的,第二个比特位还没有用。\nXDES Entry链表 # 到现在为止,我们已经提出了五花八门的概念,什么区、段、碎片区、附属于段的区、XDES Entry结构等等的概念,走远了千万别忘了自己为什么出发,我们把事情搞这么麻烦的初心,仅仅是想提高向表插入数据的效率,又不至于数据量少的表浪费空间。现在我们知道向表中插入数据本质上就是向表中各个索引的叶子节点段、非叶子节点段插入数据,也知道了不同的区有不同的状态,再回到最初的起点,捋一捋向某个段中插入数据的过程:\n当段中数据较少的时候,首先会查看表空间中是否有状态为FREE_FRAG的区,也就是找还有空闲空间的碎片区,如果找到了,那么从该区中取一些零碎的页把数据插进去;否则到表空间下申请一个状态为FREE的区,也就是空闲的区,把该区的状态变为FREE_FRAG,然后从该新申请的区中取一些零碎的页把数据插进去。之后不同的段使用零碎页的时候都会从该区中取,直到该区中没有空闲空间,然后该区的状态就变成了FULL_FRAG。\n现在的问题是你怎么知道表空间里的哪些区是FREE的,哪些区的状态是FREE_FRAG的,哪些区是FULL_FRAG的?要知道表空间的大小是可以不断增大的,当增长到GB级别的时候,区的数量也就上千了,我们总不能每次都遍历这些区对应的XDES Entry结构吧?这时候就是XDES Entry中的List Node部分发挥奇效的时候了,我们可以通过List Node中的指针,做这么三件事:\n+ 把状态为FREE的区对应的XDES Entry结构通过List Node来连接成一个链表,这个链表我们就称之为FREE链表。\n+ 把状态为FREE_FRAG的区对应的XDES Entry结构通过List Node来连接成一个链表,这个链表我们就称之为FREE_FRAG链表。\n+ 把状态为FULL_FRAG的区对应的XDES Entry结构通过List Node来连接成一个链表,这个链表我们就称之为FULL_FRAG链表。\n这样每当我们想找一个FREE_FRAG状态的区时,就直接把FREE_FRAG链表的头节点拿出来,从这个节点中取一些零碎的页来插入数据,当这个节点对应的区用完时,就修改一下这个节点的State字段的值,然后从FREE_FRAG链表中移到FULL_FRAG链表中。同理,如果FREE_FRAG链表中一个节点都没有,那么就直接从FREE链表中取一个节点移动到FREE_FRAG链表的状态,并修改该节点的STATE字段值为FREE_FRAG,然后从这个节点对应的区中获取零碎的页就好了。\n当段中数据已经占满了32个零散的页后,就直接申请完整的区来插入数据了。\n还是那个问题,我们怎么知道哪些区属于哪个段的呢?再遍历各个XDES Entry结构?遍历是不可能遍历的,这辈子都不可能遍历的,有链表还遍历个毛线啊。所以我们把状态为FSEG的区对应的XDES Entry结构都加入到一个链表喽?傻呀,不同的段哪能共用一个区呢?你想把索引a的叶子节点段和索引b的叶子节点段都存储到一个区中么?显然我们想要每个段都有它独立的链表,所以可以根据段号(也就是Segment ID)来建立链表,有多少个段就建多少个链表?好像也有点问题,因为一个段中可以有好多个区,有的区是完全空闲的,有的区还有一些页可以用,有的区已经没有空闲页可以用了,所以我们有必要继续细分,设计InnoDB的大佬们为每个段中的区对应的XDES Entry结构建立了三个链表:\n+ FREE链表:同一个段中,所有页都是空闲的区对应的XDES Entry结构会被加入到这个链表。注意和直属于表空间的FREE链表区别开了,此处的FREE链表是附属于某个段的。\n+ NOT_FULL链表:同一个段中,仍有空闲空间的区对应的XDES Entry结构会被加入到这个链表。\n+ FULL链表:同一个段中,已经没有空闲空间的区对应的XDES Entry结构会被加入到这个链表。\n再次强调一遍,每一个索引都对应两个段,每个段都会维护上述的3个链表,比如下面这个表:\nCREATE TABLE t ( c1 INT NOT NULL AUTO_INCREMENT, c2 VARCHAR(100), c3 VARCHAR(100), PRIMARY KEY (c1), KEY idx_c2 (c2) )ENGINE=InnoDB;\n这个表t共有两个索引,一个聚簇索引,一个二级索引idx_c2,所以这个表共有4个段,每个段都会维护上述3个链表,总共是12个链表,加上我们上面说过的直属于表空间的3个链表,整个独立表空间共需要维护15个链表。所以段在数据量比较大时插入数据的话,会先获取NOT_FULL链表的头节点,直接把数据插入这个头节点对应的区中即可,如果该区的空间已经被用完,就把该节点移到FULL链表中。\n链表基节点 # 上面光是介绍了一堆链表,可我们怎么找到这些链表呢,或者说怎么找到某个链表的头节点或者尾节点在表空间中的位置呢?设计InnoDB的大佬当然考虑了这个问题,他们设计了一个叫List Base Node的结构,翻译成中文就是链表的基节点。这个结构中包含了链表的头节点和尾节点的指针以及这个链表中包含了多少节点的信息,我们画图看一下这个结构的示意图:\n我们上面介绍的每个链表都对应这么一个List Base Node结构,其中:\nList Length表明该链表一共有多少节点,\nFirst Node Page Number和First Node Offset表明该链表的头节点在表空间中的位置。\nLast Node Page Number和Last Node Offset表明该链表的尾节点在表空间中的位置。\n一般我们把某个链表对应的List Base Node结构放置在表空间中固定的位置,这样想找定位某个链表就变得so easy啦。\n链表小结 # 综上所述,表空间是由若干个区组成的,每个区都对应一个XDES Entry的结构,直属于表空间的区对应的XDES Entry结构可以分成FREE、FREE_FRAG和FULL_FRAG这3个链表;每个段可以附属若干个区,每个段中的区对应的XDES Entry结构可以分成FREE、NOT_FULL和FULL这3个链表。每个链表都对应一个List Base Node的结构,这个结构里记录了链表的头、尾节点的位置以及该链表中包含的节点数。正是因为这些链表的存在,管理这些区才变成了一件so easy的事情。\n段的结构 # 我们前面说过,段其实不对应表空间中某一个连续的物理区域,而是一个逻辑上的概念,由若干个零散的页以及一些完整的区组成。像每个区都有对应的XDES Entry来记录这个区中的属性一样,设计InnoDB的大佬为每个段都定义了一个INODE Entry结构来记录一下段中的属性。大家看一下示意图:\n它的各个部分释义如下:\nSegment ID\n就是指这个INODE Entry结构对应的段的编号(ID)。\nNOT_FULL_N_USED\n这个字段指的是在NOT_FULL链表中已经使用了多少个页。下次从NOT_FULL链表分配空闲页时可以直接根据这个字段的值定位到。而不用从链表中的第一个页开始遍历着寻找空闲页。\n3个List Base Node\n分别为段的FREE链表、NOT_FULL链表、FULL链表定义了List Base Node,这样我们想查找某个段的某个链表的头节点和尾节点的时候,就可以直接到这个部分找到对应链表的List Base Node。so easy!\nMagic Number:\n这个值是用来标记这个INODE Entry是否已经被初始化了(初始化的意思就是把各个字段的值都填进去了)。如果这个数字是值的97937874,表明该INODE Entry已经初始化,否则没有被初始化。(不用纠结这个值有什么特殊含义,人家规定的)。\nFragment Array Entry\n我们前面强调过无数次:段是一些零散页和一些完整的区的集合,每个Fragment Array Entry结构都对应着一个零散的页,这个结构一共4个字节,表示一个零散页的页号。\n结合着这个INODE Entry结构,大家可能对段是一些零散页和一些完整的区的集合的理解再次深刻一些。\n各类型页详细情况 # 到现在为止我们已经大概清楚了表空间、段、区、XDES Entry、INODE Entry、各种以XDES Enty为节点的链表的基本概念了,可是总有一种飞在天上不踏实的感觉,每个区对应的XDES Entry结构到底存储在表空间的什么地方?直属于表空间的FREE、FREE_FRAG、FULL_FRAG链表的基节点到底存储在表空间的什么地方?每个段对应的INODE Entry结构到底存在表空间的什么地方?我们前面介绍了每256个连续的区算是一个组,想解决刚才提出来的这些个疑问还得从每个组开头的一些类型相同的页说起,接下来我们一个页一个页的分析,真相马上就要浮出水面了。\n**FSP_HDR**类型 # 首先看第一个组的第一个页,当然也是表空间的第一个页,页号为0。这个页的类型是FSP_HDR,它存储了表空间的一些整体属性以及第一个组内256个区的对应的XDES Entry结构,直接看这个类型的页的示意图:\n从图中可以看出,一个完整的FSP_HDR类型的页大致由5个部分组成,各个部分的具体释义如下表: 名称 中文名 占用空间大小 简单描述 File Header 文件头部 38字节 页的一些通用信息 File Space Header 表空间头部 112字节 表空间的一些整体属性信息 XDES Entry 区描述信息 10240字节 存储本组256个区对应的属性信息 Empty Space 尚未使用空间 5986字节 用于页结构的填充,没什么实际意义 File Trailer 文件尾部 8字节 校验页是否完整 File Header和File Trailer就不再强调了,另外的几个部分中,Empty Space是尚未使用的空间,我们不用管它,重点来看看File Space Header和XDES Entry这两个部分。\nFile Space Header部分 # 从名字就可以看出来,这个部分是用来存储表空间的一些整体属性的,废话少说,看图:\n哇唔,字段有点儿多哦,不急一个一个慢慢看。下面是各个属性的简单描述: 名称 占用空间大小 描述 Space ID 4字节 表空间的ID Not Used 4字节 这4个字节未被使用,可以忽略 Size 4字节 当前表空间占有的页数 FREE Limit 4字节 尚未被初始化的最小页号,大于或等于这个页号的区对应的XDES Entry结构都没有被加入FREE链表 Space Flags 4字节 表空间的一些占用存储空间比较小的属性 FRAG_N_USED 4字节 FREE_FRAG链表中已使用的页数量 List Base Node for FREE List 16字节 FREE链表的基节点 List Base Node for FREE_FRAG List 16字节 FREE_FREG链表的基节点 List Base Node for FULL_FRAG List 16字节 FULL_FREG链表的基节点 Next Unused Segment ID 8字节 当前表空间中下一个未使用的 Segment ID List Base Node for SEG_INODES_FULL List 16字节 SEG_INODES_FULL链表的基节点 List Base Node for SEG_INODES_FREE List 16字节 SEG_INODES_FREE链表的基节点 这里头的Space ID、Not Used、Size这三个字段大家肯定一看就懂,其他的字段我们再详细看看,为了大家的阅读体验,我就不严格按照实际的字段顺序来解释各个字段了。\nList Base Node for FREE List、List Base Node for FREE_FRAG List、List Base Node for FULL_FRAG List。\n这三个大家看着太亲切了,分别是直属于表空间的FREE链表的基节点、FREE_FRAG链表的基节点、FULL_FRAG链表的基节点,这三个链表的基节点在表空间的位置是固定的,就是在表空间的第一个页(也就是FSP_HDR类型的页)的File Space Header部分。所以之后定位这几个链表就so easy啦。\nFRAG_N_USED\n这个字段表明在FREE_FRAG链表中已经使用的页数量,方便之后在链表中查找空闲的页。\nFREE Limit\n我们知道表空间都对应着具体的磁盘文件,一开始我们创建表空间的时候对应的磁盘文件中都没有数据,所以我们需要对表空间完成一个初始化操作,包括为表空间中的区建立XDES Entry结构,为各个段建立INODE Entry结构,建立各种链表等等的各种操作。我们可以一开始就为表空间申请一个特别大的空间,但是实际上有绝大部分的区是空闲的,我们可以选择把所有的这些空闲区对应的XDES Entry结构加入FREE链表,也可以选择只把一部分的空闲区加入FREE链表,等什么时候空闲链表中的XDES Entry结构对应的区不够使了,再把之前没有加入FREE链表的空闲区对应的XDES Entry结构加入FREE链表,中心思想就是什么时候用到什么时候初始化,设计InnoDB的大佬采用的就是后者,他们为表空间定义了FREE Limit这个字段,在该字段表示的页号之前的区都被初始化了,之后的区尚未被初始化。\nNext Unused Segment ID\n表中每个索引都对应2个段,每个段都有一个唯一的ID,那当我们为某个表新创建一个索引的时候,就意味着要创建两个新的段。那怎么为这个新创建的段找一个唯一的ID呢?去遍历现在表空间中所有的段么?我们说过,遍历是不可能遍历的,这辈子都不可能遍历,所以设计InnoDB的大佬们提出了这个名叫Next Unused Segment ID的字段,该字段表明当前表空间中最大的段ID的下一个ID,这样在创建新段的时候赋予新段一个唯一的ID值就so easy啦,直接使用这个字段的值就好了。\nSpace Flags\n表空间对于一些布尔类型的属性,或者只需要寥寥几个比特位搞定的属性都放在了这个Space Flags中存储,虽然它只有4个字节,32个比特位大小,却存储了好多表空间的属性,详细情况如下表: 标志名称 占用的空间(单位:bit) 描述 POST_ANTELOPE 1 表示文件格式是否大于ANTELOPE ZIP_SSIZE 4 表示压缩页的大小 ATOMIC_BLOBS 1 表示是否自动把值非常长的字段放到BLOB页里 PAGE_SSIZE 4 页大小 DATA_DIR 1 表示表空间是否是从默认的数据目录中获取的 SHARED 1 是否为共享表空间 TEMPORARY 1 是否为临时表空间 ENCRYPTION 1 表空间是否加密 UNUSED 18 没有使用到的比特位 小贴士:不同MySQL版本里 SPACE_FLAGS 代表的属性可能有些差异,我们这里列举的是5.7.21版本的。不过大家现在不必深究它们的意思,因为我们一旦把这些概念 展开,就需要非常大的篇幅,主要怕大家受不了。我们还是先挑重要的看,把主要的表空间结构了解完,这些 SPACE_FLAGS 里的属性的细节就暂时不深究了。\nList Base Node for SEG_INODES_FULL List和List Base Node for SEG_INODES_FREE List\n每个段对应的INODE Entry结构会集中存放到一个类型位INODE的页中,如果表空间中的段特别多,则会有多个INODE Entry结构,可能一个页放不下,这些INODE类型的页会组成两种列表:\n+ SEG_INODES_FULL链表,该链表中的INODE类型的页都已经被INODE Entry结构填充满了,没空闲空间存放额外的INODE Entry了。\n+ SEG_INODES_FREE链表,该链表中的INODE类型的页都已经仍有空闲空间来存放INODE Entry结构。\n由于我们现在还没有详细介绍INODE类型页,所以等会说过INODE类型的页之后再回过头来看着两个链表。\nXDES Entry部分 # 紧接着File Space Header部分的就是XDES Entry部分了,我们嘴上介绍过无数次,却从没见过真身的XDES Entry就是在表空间的第一个页中保存的。我们知道一个XDES Entry结构的大小是40字节,但是一个页的大小有限,只能存放有限个XDES Entry结构,所以我们才把256个区划分成一组,在每组的第一个页中存放256个XDES Entry结构。大家回看那个FSP_HDR类型页的示意图,XDES Entry 0就对应着extent 0,XDES Entry 1就对应着extent 1\u0026hellip; 依此类推,XDES Entry255就对应着extent 255。\n因为每个区对应的XDES Entry结构的地址是固定的,所以我们访问这些结构就so easy啦,至于该结构的详细使用情况我们已经介绍的够明白了,在这就不赘述了。\n**XDES**类型 # 我们说过,每一个XDES Entry结构对应表空间的一个区,虽然一个XDES Entry结构只占用40字节,但你抵不住表空间的区的数量也多啊。在区的数量非常多时,一个单独的页可能就不够存放足够多的XDES Entry结构,所以我们把表空间的区分为了若干个组,每组开头的一个页记录着本组内所有的区对应的XDES Entry结构。由于第一个组的第一个页有些特殊,因为它也是整个表空间的第一个页,所以除了记录本组中的所有区对应的XDES Entry结构以外,还记录着表空间的一些整体属性,这个页的类型就是我们刚刚说完的FSP_HDR类型,整个表空间里只有一个这个类型的页。除去第一个分组以外,之后的每个分组的第一个页只需要记录本组内所有的区对应的XDES Entry结构即可,不需要再记录表空间的属性了,为了和FSP_HDR类型做区别,我们把之后每个分组的第一个页的类型定义为XDES,它的结构和FSP_HDR类型是非常相似的:\n与FSP_HDR类型的页对比,除了少了File Space Header部分之外,也就是除了少了记录表空间整体属性的部分之外,其余的部分是一样一样的。由于我们上面介绍的已经够仔细了,对于XDES类型的页也就不重复介绍了。\n**IBUF_BITMAP**类型 # 对比前面介绍表空间的图,每个分组的第二个页的类型都是IBUF_BITMAP,这种类型的页里边记录了一些有关Change Buffer的东东,由于这个Change Buffer里又包含了贼多的概念,考虑到大家在一章中接受这么多新概念有点呼吸不适,怕大家心脏病犯了所以就把Change Buffer的相关知识放到后边的章节中,大家稍安勿躁。\n**INODE**类型 # 再次对比前面介绍表空间的图,第一个分组的第三个页的类型是INODE。我们前面说过设计InnoDB的大佬为每个索引定义了两个段,而且为某些特殊功能定义了些特殊的段。为了方便管理,他们又为每个段设计了一个INODE Entry结构,这个结构中记录了关于这个段的相关属性。而我们这会儿要介绍的这个INODE类型的页就是为了存储INODE Entry结构而存在的。好了,废话少说,直接看图:\n从图中可以看出,一个INODE类型的页是由这几部分构成的: 名称 中文名 占用空间大小 简单描述 File Header 文件头部 38字节 页的一些通用信息 List Node for INODE Page List 通用链表节点 12字节 存储上一个INODE页和下一个INODE页的指针 INODE Entry 段描述信息 16128字节 Empty Space 尚未使用空间 6字节 用于页结构的填充,没什么实际意义 File Trailer 文件尾部 8字节 校验页是否完整 除了File Header、Empty Space、File Trailer这几个老朋友外,我们重点关注List Node for INODE Page List和INODE Entry这两个部分。\n首先看INODE Entry部分,我们前面已经详细介绍过这个结构的组成了,主要包括对应的段内零散页的地址以及附属于该段的FREE、NOT_FULL和FULL链表的基节点。每个INODE Entry结构占用192字节,一个页里可以存储85个这样的结构。\n重点看一下List Node for INODE Page List这个玩意儿,因为一个表空间中可能存在超过85个段,所以可能一个INODE类型的页不足以存储所有的段对应的INODE Entry结构,所以就需要额外的INODE类型的页来存储这些结构。还是为了方便管理这些INODE类型的页,设计InnoDB的大佬们将这些INODE类型的页串联成两个不同的链表:\nSEG_INODES_FULL链表:该链表中的INODE类型的页中已经没有空闲空间来存储额外的INODE Entry结构了。\nSEG_INODES_FREE链表:该链表中的INODE类型的页中还有空闲空间来存储额外的INODE Entry结构了。\n想必大家已经认出这两个链表了,我们前面提到过这两个链表的基节点就存储在File Space Header里边,也就是说这两个链表的基节点的位置是固定的,所以我们可以很轻松的访问到这两个链表。以后每当我们新创建一个段(创建索引时就会创建段)时,都会创建一个INODE Entry结构与之对应,存储INODE Entry的大致过程就是这样的:\n先看看SEG_INODES_FREE链表是否为空,如果不为空,直接从该链表中获取一个节点,也就相当于获取到一个仍有空闲空间的INODE类型的页,然后把该INODE Entry结构防到该页中。当该页中无剩余空间时,就把该页放到SEG_INODES_FULL链表中。\n如果SEG_INODES_FREE链表为空,则需要从表空间的FREE_FRAG链表中申请一个页,修改该页的类型为INODE,把该页放到SEG_INODES_FREE链表中,与此同时把该INODE Entry结构放入该页。\nSegment Header 结构的运用 # 我们知道一个索引会产生两个段,分别是叶子节点段和非叶子节点段,而每个段都会对应一个INODE Entry结构,那我们怎么知道某个段对应哪个INODE Entry结构呢?所以得找个地方记下来这个对应关系。希望你还记得我们在介绍数据页,也就是INDEX类型的页时有一个Page Header部分,当然我不能指望你记住,所以把Page Header部分再抄一遍给你看:\nPage Header部分(为突出重点,省略了好多属性) 名称 占用空间大小 描述 \u0026hellip; \u0026hellip; \u0026hellip; PAGE_BTR_SEG_LEAF 10字节 B+树叶子段的头部信息,仅在B+树的根页定义 PAGE_BTR_SEG_TOP 10字节 B+树非叶子段的头部信息,仅在B+树的根页定义 其中的PAGE_BTR_SEG_LEAF和PAGE_BTR_SEG_TOP都占用10个字节,它们其实对应一个叫Segment Header的结构,该结构图示如下:\n各个部分的具体释义如下: 名称 占用字节数 描述 Space ID of the INODE Entry 4 INODE Entry结构所在的表空间ID Page Number of the INODE Entry 4 INODE Entry结构所在的页页号 Byte Offset of the INODE Ent 2 INODE Entry结构在该页中的偏移量 这样子就很清晰了,PAGE_BTR_SEG_LEAF记录着叶子节点段对应的INODE Entry结构的地址是哪个表空间的哪个页的哪个偏移量,PAGE_BTR_SEG_TOP记录着非叶子节点段对应的INODE Entry结构的地址是哪个表空间的哪个页的哪个偏移量。这样子索引和其对应的段的关系就建立起来了。不过需要注意的一点是,因为一个索引只对应两个段,所以只需要在索引的根页中记录这两个结构即可。\n真实表空间对应的文件大小 # 等会儿等会儿,上面的这些概念已经压的快喘不过气了。不过独立表空间有那么大么?我到数据目录里看了,一个新建的表对应的.ibd文件只占用了96K,才6个页大小,上面的内容该不是扯犊子吧?\n一开始表空间占用的空间自然是很小,因为表里边都没有数据嘛!不过别忘了这些.ibd文件是自扩展的,随着表中数据的增多,表空间对应的文件也逐渐增大。\n系统表空间 # 了解完了独立表空间的基本结构,系统表空间的结构也就好理解多了,系统表空间的结构和独立表空间基本类似,只不过由于整个MySQL进程只有一个系统表空间,在系统表空间中会额外记录一些有关整个系统信息的页,所以会比独立表空间多出一些记录这些信息的页。因为这个系统表空间最牛逼,相当于是表空间之首,所以它的表空间 ID(Space ID)是0。\n系统表空间的整体结构 # 系统表空间与独立表空间的一个非常明显的不同之处就是在表空间开头有许多记录整个系统属性的页,如图:\n可以看到,系统表空间和独立表空间的前三个页(页号分别为0、1、2,类型分别是FSP_HDR、IBUF_BITMAP、INODE)的类型是一致的,只是页号为3~7的页是系统表空间特有的,我们来看一下这些多出来的页都是干什么使的: 页号 页类型 英文描述 描述 3 SYS Insert Buffer Header 存储Insert Buffer的头部信息 4 INDEX Insert Buffer Root 存储Insert Buffer的根页 5 TRX_SYS Transction System 事务系统的相关信息 6 SYS First Rollback Segment 第一个回滚段的页 7 SYS Data Dictionary Header 数据字典头部信息 除了这几个记录系统属性的页之外,系统表空间的extent 1和extent 2这两个区,也就是页号从64~191这128个页被称为Doublewrite buffer,也就是双写缓冲区。不过上述的大部分知识都涉及到了事务和多版本控制的问题,这些问题我们会放在后边的章节集中介绍,现在讲述太影响用户体验,所以现在我们只介绍一下有关InnoDB数据字典的知识,其余的概念在后边再看。\nInnoDB数据字典 # 我们平时使用INSERT语句向表中插入的那些记录称之为用户数据,MySQL只是作为一个软件来为我们来保管这些数据,提供方便的增删改查接口而已。但是每当我们向一个表中插入一条记录的时候,MySQL先要校验一下插入语句对应的表存不存在,插入的列和表中的列是否符合,如果语法没有问题的话,还需要知道该表的聚簇索引和所有二级索引对应的根页是哪个表空间的哪个页,然后把记录插入对应索引的B+树中。所以说,MySQL除了保存着我们插入的用户数据之外,还需要保存许多额外的信息,比方说:\n某个表属于哪个表空间,表里边有多少列\n表对应的每一个列的类型是什么\n该表有多少索引,每个索引对应哪几个字段,该索引对应的根页在哪个表空间的哪个页\n该表有哪些外键,外键对应哪个表的哪些列\n某个表空间对应文件系统上文件路径是什么\nbalabala \u0026hellip; 还有好多,不一一列举了\n上述这些数据并不是我们使用INSERT语句插入的用户数据,实际上是为了更好的管理我们这些用户数据而不得已引入的一些额外数据,这些数据也称为元数据。InnoDB存储引擎特意定义了一些列的内部系统表(internal system table)来记录这些这些元数据: 表名 描述 SYS_TABLES 整个InnoDB存储引擎中所有的表的信息 SYS_COLUMNS 整个InnoDB存储引擎中所有的列的信息 SYS_INDEXES 整个InnoDB存储引擎中所有的索引的信息 SYS_FIELDS 整个InnoDB存储引擎中所有的索引对应的列的信息 SYS_FOREIGN 整个InnoDB存储引擎中所有的外键的信息 SYS_FOREIGN_COLS 整个InnoDB存储引擎中所有的外键对应列的信息 SYS_TABLESPACES 整个InnoDB存储引擎中所有的表空间信息 SYS_DATAFILES 整个InnoDB存储引擎中所有的表空间对应文件系统的文件路径信息 SYS_VIRTUAL 整个InnoDB存储引擎中所有的虚拟生成列的信息 这些系统表也被称为数据字典,它们都是以B+树的形式保存在系统表空间的某些页中,其中SYS_TABLES、SYS_COLUMNS、SYS_INDEXES、SYS_FIELDS这四个表尤其重要,称之为基本系统表(basic system tables),我们先看看这4个表的结构:\nSYS_TABLES表 # SYS_TABLES表的列 列名 描述 NAME 表的名称 ID InnoDB存储引擎中每个表都有一个唯一的ID N_COLS 该表拥有列的个数 TYPE 表的类型,记录了一些文件格式、行格式、压缩等信息 MIX_ID 已过时,忽略 MIX_LEN 表的一些额外的属性 CLUSTER_ID 未使用,忽略 SPACE 该表所属表空间的ID 这个SYS_TABLES表有两个索引:\n以NAME列为主键的聚簇索引\n以ID列建立的二级索引\nSYS_COLUMNS表 # SYS_COLUMNS表的列 列名 描述 TABLE_ID 该列所属表对应的ID POS 该列在表中是第几列 NAME 该列的名称 MTYPE main data type,主数据类型,就是那堆INT、CHAR、VARCHAR、FLOAT、DOUBLE之类的东东 PRTYPE precise type,精确数据类型,就是修饰主数据类型的那堆东东,比如是否允许NULL值,是否允许负数什么的 LEN 该列最多占用存储空间的字节数 PREC 该列的精度,不过这列貌似都没有使用,默认值都是0 这个SYS_COLUMNS表只有一个聚集索引:\n以(TABLE_ID, POS)列为主键的聚簇索引 SYS_INDEXES表 # SYS_INDEXES表的列 列名 描述 TABLE_ID 该索引所属表对应的ID ID InnoDB存储引擎中每个索引都有一个唯一的ID NAME 该索引的名称 N_FIELDS 该索引包含列的个数 TYPE 该索引的类型,比如聚簇索引、唯一索引、更改缓冲区的索引、全文索引、普通的二级索引等等各种类型 SPACE 该索引根页所在的表空间ID PAGE_NO 该索引根页所在的页号 MERGE_THRESHOLD 如果页中的记录被删除到某个比例,就把该页和相邻页合并,这个值就是这个比例 这个SYS_INEXES表只有一个聚集索引:\n以(TABLE_ID, ID)列为主键的聚簇索引 SYS_FIELDS表 # SYS_FIELDS表的列 列名 描述 INDEX_ID 该索引列所属的索引的ID POS 该索引列在某个索引中是第几列 COL_NAME 该索引列的名称 这个SYS_INEXES表只有一个聚集索引:\n以(INDEX_ID, POS)列为主键的聚簇索引 Data Dictionary Header页 # 只要有了上述4个基本系统表,也就意味着可以获取其他系统表以及用户定义的表的所有元数据。比方说我们想看看SYS_TABLESPACES这个系统表里存储了哪些表空间以及表空间对应的属性,那就可以:\n到SYS_TABLES表中根据表名定位到具体的记录,就可以获取到SYS_TABLESPACES表的TABLE_ID\n使用这个TABLE_ID到SYS_COLUMNS表中就可以获取到属于该表的所有列的信息。\n使用这个TABLE_ID还可以到SYS_INDEXES表中获取所有的索引的信息,索引的信息中包括对应的INDEX_ID,还记录着该索引对应的B+数根页是哪个表空间的哪个页。\n使用INDEX_ID就可以到SYS_FIELDS表中获取所有索引列的信息。\n也就是说这4个表是表中之表,那这4个表的元数据去哪里获取呢?没法搞了,只能把这4个表的元数据,就是它们有哪些列、哪些索引等信息硬编码到代码中,然后设计InnoDB的大佬又拿出一个固定的页来记录这4个表的聚簇索引和二级索引对应的B+树位置,这个页就是页号为7的页,类型为SYS,记录了Data Dictionary Header,也就是数据字典的头部信息。除了这4个表的5个索引的根页信息外,这个页号为7的页还记录了整个InnoDB存储引擎的一些全局属性,说话太啰嗦,直接看这个页的示意图:\n可以看到这个页由下面几个部分组成: 名称 中文名 占用空间大小 简单描述 File Header 文件头部 38字节 页的一些通用信息 Data Dictionary Header 数据字典头部信息 56字节 记录一些基本系统表的根页位置以及InnoDB存储引擎的一些全局信息 Segment Header 段头部信息 10字节 记录本页所在段对应的INODE Entry位置信息 Empty Space 尚未使用空间 16272字节 用于页结构的填充,没什么实际意义 File Trailer 文件尾部 8字节 校验页是否完整 可以看到这个页里竟然有Segment Header部分,意味着设计InnoDB的大佬把这些有关数据字典的信息当成一个段来分配存储空间,我们就姑且称之为数据字典段吧。由于目前我们需要记录的数据字典信息非常少(可以看到Data Dictionary Header部分仅占用了56字节),所以该段只有一个碎片页,也就是页号为7的这个页。\n接下来我们需要细细介绍一下Data Dictionary Header部分的各个字段:\nMax Row ID:我们说过如果我们不显式的为表定义主键,而且表中也没有UNIQUE索引,那么InnoDB存储引擎会默认为我们生成一个名为row_id的列作为主键。因为它是主键,所以每条记录的row_id列的值不能重复。原则上只要一个表中的row_id列不重复就可以了,也就是说表a和表b拥有一样的row_id列也没什么关系,不过设计InnoDB的大佬只提供了这个Max Row ID字段,不论哪个拥有row_id列的表插入一条记录时,该记录的row_id列的值就是Max Row ID对应的值,然后再把Max Row ID对应的值加1,也就是说这个Max Row ID是全局共享的。\nMax Table ID:InnoDB存储引擎中的所有的表都对应一个唯一的ID,每次新建一个表时,就会把本字段的值作为该表的ID,然后自增本字段的值。\nMax Index ID:InnoDB存储引擎中的所有的索引都对应一个唯一的ID,每次新建一个索引时,就会把本字段的值作为该索引的ID,然后自增本字段的值。\nMax Space ID:InnoDB存储引擎中的所有的表空间都对应一个唯一的ID,每次新建一个表空间时,就会把本字段的值作为该表空间的ID,然后自增本字段的值。\nMix ID Low(Unused):这个字段没什么用,跳过。\nRoot of SYS_TABLES clust index:本字段代表SYS_TABLES表聚簇索引的根页的页号。\nRoot of SYS_TABLE_IDS sec index:本字段代表SYS_TABLES表为ID列建立的二级索引的根页的页号。\nRoot of SYS_COLUMNS clust index:本字段代表SYS_COLUMNS表聚簇索引的根页的页号。\nRoot of SYS_INDEXES clust index本字段代表SYS_INDEXES表聚簇索引的根页的页号。\nRoot of SYS_FIELDS clust index:本字段代表SYS_FIELDS表聚簇索引的根页的页号。\nUnused:这4个字节没用,跳过。\n以上就是页号为7的页的全部内容,初次看可能会懵逼(因为有点儿绕),大家多瞅几次。\ninformation_schema系统数据库 # 需要注意一点的是,用户是不能直接访问InnoDB的这些内部系统表的,除非你直接去解析系统表空间对应文件系统上的文件。不过设计InnoDB的大佬考虑到查看这些表的内容可能有助于大家分析问题,所以在系统数据库information_schema中提供了一些以innodb_sys开头的表: ``` mysql\u0026gt; USE information_schema; Database changed\nmysql\u0026gt; SHOW TABLES LIKE \u0026lsquo;innodb_sys%\u0026rsquo;; +\u0026mdash;\u0026mdash;\u0026mdash;\u0026mdash;\u0026mdash;\u0026mdash;\u0026mdash;\u0026mdash;\u0026mdash;\u0026mdash;\u0026mdash;\u0026mdash;\u0026mdash;\u0026mdash;\u0026ndash;+ | Tables_in_information_schema (innodb_sys%) | +\u0026mdash;\u0026mdash;\u0026mdash;\u0026mdash;\u0026mdash;\u0026mdash;\u0026mdash;\u0026mdash;\u0026mdash;\u0026mdash;\u0026mdash;\u0026mdash;\u0026mdash;\u0026mdash;\u0026ndash;+ | INNODB_SYS_DATAFILES | | INNODB_SYS_VIRTUAL | | INNODB_SYS_INDEXES | | INNODB_SYS_TABLES | | INNODB_SYS_FIELDS | | INNODB_SYS_TABLESPACES | | INNODB_SYS_FOREIGN_COLS | | INNODB_SYS_COLUMNS | | INNODB_SYS_FOREIGN | | INNODB_SYS_TABLESTATS | +\u0026mdash;\u0026mdash;\u0026mdash;\u0026mdash;\u0026mdash;\u0026mdash;\u0026mdash;\u0026mdash;\u0026mdash;\u0026mdash;\u0026mdash;\u0026mdash;\u0026mdash;\u0026mdash;\u0026ndash;+ 10 rows in set (0.00 sec) ``` 在information_schema数据库中的这些以INNODB_SYS开头的表并不是真正的内部系统表(内部系统表就是我们上面介绍的以SYS开头的那些表),而是在存储引擎启动时读取这些以SYS开头的系统表,然后填充到这些以INNODB_SYS开头的表中。以INNODB_SYS开头的表和以SYS开头的表中的字段并不完全一样,但供大家参考已经足矣。这些表太多了,我就不介绍了,大家自个儿动手试着查一查这些表中的数据吧~\n总结图 # 小册微信交流群2群中一个昵称为think同学非常有心的为表空间画了一个全局图,希望能对各位有帮助(这种学习态度实在让我感动😹):\n"},{"id":24,"href":"/zh/docs/technology/MySQL/MySQL%E6%98%AF%E6%80%8E%E6%A0%B7%E8%BF%90%E8%A1%8C%E7%9A%84/%E7%AC%AC8%E7%AB%A0_%E6%95%B0%E6%8D%AE%E7%9A%84%E5%AE%B6-MySQL%E7%9A%84%E6%95%B0%E6%8D%AE%E7%9B%AE%E5%BD%95/","title":"第8章_数据的家-MySQL的数据目录","section":"My Sql是怎样运行的","content":"第8章 数据的家-MySQL的数据目录\n数据库和文件系统的关系 # 我们知道像InnoDB、MyISAM这样的存储引擎都是把表存储在磁盘上的,而操作系统用来管理磁盘的那个东东又被称为文件系统,所以用专业一点的话来表述就是:像 InnoDB 、 MyISAM 这样的存储引擎都是把表存储在文件系统上的。当我们想读取数据的时候,这些存储引擎会从文件系统中把数据读出来返回给我们,当我们想写入数据的时候,这些存储引擎会把这些数据又写回文件系统。本章就是要介绍一下InnoDB和MyISAM这两个存储引擎的数据如何在文件系统中存储的。\nMySQL数据目录 # MySQL服务器程序在启动时会到文件系统的某个目录下加载一些文件,之后在运行过程中产生的数据也都会存储到这个目录下的某些文件中,这个目录就称为数据目录,我们下面就要详细唠唠这个目录下具体都有哪些重要的东西。\n数据目录和安装目录的区别 # 我们之前只接触过MySQL的安装目录(在安装MySQL的时候我们可以自己指定),我们重点强调过这个安装目录下非常重要的bin目录,它里边存储了许多关于控制客户端程序和服务器程序的命令(许多可执行文件,比如mysql,mysqld,mysqld_safe等等等等好几十个)。而数据目录是用来存储MySQL在运行过程中产生的数据,一定要和本章要讨论的安装目录区别开!一定要区分开!一定要区分开!一定要区分开!\n如何确定MySQL中的数据目录 # 那说了半天,到底MySQL把数据都存到哪个路径下呢?其实数据目录对应着一个系统变量datadir,我们在使用客户端与服务器建立连接之后查看这个系统变量的值就可以了: mysql\u0026gt; SHOW VARIABLES LIKE 'datadir'; +---------------+-----------------------+ | Variable_name | Value | +---------------+-----------------------+ | datadir | /usr/local/var/mysql/ | +---------------+-----------------------+ 1 row in set (0.00 sec) 从结果中可以看出,在我的计算机上MySQL的数据目录就是/usr/local/var/mysql/,你用你的计算机试试呗~\n数据目录的结构 # MySQL在运行过程中都会产生哪些数据呢?当然会包含我们创建的数据库、表、视图和触发器等等的用户数据,除了这些用户数据,为了程序更好的运行,MySQL也会创建一些其他的额外数据,我们接下来细细的品味一下这个数据目录下的内容。\n数据库在文件系统中的表示 # 每当我们使用CREATE DATABASE 数据库名语句创建一个数据库的时候,在文件系统上实际发生了什么呢?其实很简单,每个数据库都对应数据目录下的一个子目录,或者说对应一个文件夹,我们每当我们新建一个数据库时,MySQL会帮我们做这两件事儿:\n在数据目录下创建一个和数据库名同名的子目录(或者说是文件夹)。\n在该与数据库名同名的子目录下创建一个名为db.opt的文件,这个文件中包含了该数据库的各种属性,比方说该数据库的字符集和比较规则是什么。\n比方说我们查看一下在我的计算机上当前有哪些数据库: mysql\u0026gt; SHOW DATABASES; +--------------------+ | Database | +--------------------+ | information_schema | | charset_demo_db | | dahaizi | | mysql | | performance_schema | | sys | | xiaohaizi | +--------------------+ 7 rows in set (0.00 sec) 可以看到在我的计算机上当前有7个数据库,其中charset_demo_db、dahaizi和xiaohaizi数据库是我们自定义的,其余4个数据库是属于MySQL自带的系统数据库。我们再看一下我的计算机上的数据目录下的内容: ``` . ├── auto.cnf ├── ca-key.pem ├── ca.pem ├── charset_demo_db ├── client-cert.pem ├── client-key.pem ├── dahaizi ├── ib_buffer_pool ├── ib_logfile0 ├── ib_logfile1 ├── ibdata1 ├── ibtmp1 ├── mysql ├── performance_schema ├── private_key.pem ├── public_key.pem ├── server-cert.pem ├── server-key.pem ├── sys ├── xiaohaizideMacBook-Pro.local.err ├── xiaohaizideMacBook-Pro.local.pid └── xiaohaizi\n6 directories, 16 files ``` 当然这个数据目录下的文件和子目录比较多,但是如果仔细看的话,除了information_schema这个系统数据库外,其他的数据库在数据目录下都有对应的子目录。这个information_schema比较特殊,设计MySQL的大佬们对它的实现进行了特殊对待,没有使用相应的数据库目录,我们忽略它的存在就好了。\n表在文件系统中的表示 # 我们的数据其实都是以记录的形式插入到表中的,每个表的信息其实可以分为两种:\n表结构的定义\n表中的数据\n表结构就是该表的名称是什么,表里边有多少列,每个列的数据类型是什么,有什么约束条件和索引,用的是什么字符集和比较规则等等的各种信息,这些信息都体现在我们的建表语句中了。为了保存这些信息,InnoDB和MyISAM这两种存储引擎都在数据目录下对应的数据库子目录下创建了一个专门用于描述表结构的文件,文件名是这样: 表名.frm 比方说我们在dahaizi数据库下创建一个名为test的表: ``` mysql\u0026gt; USE dahaizi; Database changed\nmysql\u0026gt; CREATE TABLE test ( -\u0026gt; c1 INT -\u0026gt; ); Query OK, 0 rows affected (0.03 sec) ``` 那在数据库dahaizi对应的子目录下就会创建一个名为test.frm的用于描述表结构的文件。值得注意的是,这个后缀名为.frm是以二进制格式存储的,我们直接打开会是乱码的~ 你还不赶紧在你的计算机上创建个表试试~\n描述表结构的文件我们知道怎么存储了,那表中的数据存到什么文件中了呢?在这个问题上,不同的存储引擎就产生了分歧了,下面我们分别看一下InnoDB和MyISAM是用什么文件来保存表中数据的。\nInnoDB是如何存储表数据的 # 我们前面重点介绍过InnoDB的一些实现原理,到现在为止我们应该熟悉下面这些东东:\nInnoDB其实是使用页为基本单位来管理存储空间的,默认的页大小为16KB。\n对于InnoDB存储引擎来说,每个索引都对应着一棵B+树,该B+树的每个节点都是一个数据页,数据页之间不必要是物理连续的,因为数据页之间有双向链表来维护着这些页的顺序。\nInnoDB的聚簇索引的叶子节点存储了完整的用户记录,也就是所谓的索引即数据,数据即索引。\n为了更好的管理这些页,设计InnoDB的大佬们提出了一个表空间或者文件空间(英文名:table space或者file space)的概念,这个表空间是一个抽象的概念,它可以对应文件系统上一个或多个真实文件(不同表空间对应的文件数量可能不同)。每一个表空间可以被划分为很多很多很多个页,我们的表数据就存放在某个表空间下的某些页里。设计InnoDB的大佬将表空间划分为几种不同的类型,我们一个一个看一下。\n系统表空间(system tablespace) # 这个所谓的系统表空间可以对应文件系统上一个或多个实际的文件,默认情况下,InnoDB会在数据目录下创建一个名为ibdata1(在你的数据目录下找找看有木有)、大小为12M的文件,这个文件就是对应的系统表空间在文件系统上的表示。怎么才12M?这么点儿还没插多少数据就用完了,那是因为这个文件是所谓的自扩展文件,也就是当不够用的时候它会自己增加文件大小~\n当然,如果你想让系统表空间对应文件系统上多个实际文件,或者仅仅觉得原来的ibdata1这个文件名难听,那可以在MySQL启动时配置对应的文件路径以及它们的大小,比如我们这样修改一下配置文件: [server] innodb_data_file_path=data1:512M;data2:512M:autoextend 这样在MySQL启动之后就会创建这两个512M大小的文件作为系统表空间,其中的autoextend表明这两个文件如果不够用会自动扩展data2文件的大小。\n我们也可以把系统表空间对应的文件路径不配置到数据目录下,甚至可以配置到单独的磁盘分区上,涉及到的启动参数就是innodb_data_file_path和innodb_data_home_dir,具体的配置逻辑挺绕的,我们这就不多介绍了,知道改哪个参数可以修改系统表空间对应的文件,有需要的时候到官方文档里一查就好了。\n需要注意的一点是,在一个MySQL服务器中,系统表空间只有一份。从MySQL5.5.7到MySQL5.6.6之间的各个版本中,我们表中的数据都会被默认存储到这个 系统表空间。\n独立表空间(file-per-table tablespace) # 在MySQL5.6.6以及之后的版本中,InnoDB并不会默认的把各个表的数据存储到系统表空间中,而是为每一个表建立一个独立表空间,也就是说我们创建了多少个表,就有多少个独立表空间。使用独立表空间来存储表数据的话,会在该表所属数据库对应的子目录下创建一个表示该独立表空间的文件,文件名和表名相同,只不过添加了一个.ibd的扩展名而已,所以完整的文件名称长这样: 表名.ibd 比方说假如我们使用了独立表空间去存储xiaohaizi数据库下的test表的话,那么在该表所在数据库对应的xiaohaizi目录下会为test表创建这两个文件: test.frm test.ibd 其中test.ibd文件就用来存储test表中的数据和索引。当然我们也可以自己指定使用系统表空间还是独立表空间来存储数据,这个功能由启动参数innodb_file_per_table控制,比如说我们想刻意将表数据都存储到系统表空间时,可以在启动MySQL服务器的时候这样配置: [server] innodb_file_per_table=0 当innodb_file_per_table的值为0时,代表使用系统表空间;当innodb_file_per_table的值为1时,代表使用独立表空间。不过innodb_file_per_table参数只对新建的表起作用,对于已经分配了表空间的表并不起作用。如果我们想把已经存在系统表空间中的表转移到独立表空间,可以使用下面的语法: ALTER TABLE 表名 TABLESPACE [=] innodb_file_per_table; 或者把已经存在独立表空间的表转移到系统表空间,可以使用下面的语法: ALTER TABLE 表名 TABLESPACE [=] innodb_system; 其中中括号扩起来的=可有可无,比方说我们想把test表从独立表空间移动到系统表空间,可以这么写: ALTER TABLE test TABLESPACE innodb_system;\n其他类型的表空间 # 随着MySQL的发展,除了上述两种老牌表空间之外,现在还新提出了一些不同类型的表空间,比如通用表空间(general tablespace)、undo表空间(undo tablespace)、临时表空间(temporary tablespace)等等的,具体情况我们就不细介绍了,等用到的时候再提。\nMyISAM是如何存储表数据的 # 好了,介绍完了InnoDB的系统表空间和独立表空间,现在轮到MyISAM了。我们知道不像InnoDB的索引和数据是一个东东,在MyISAM中的索引全部都是二级索引,该存储引擎的数据和索引是分开存放的。所以在文件系统中也是使用不同的文件来存储数据文件和索引文件。而且和InnoDB不同的是,MyISAM并没有什么所谓的表空间一说,表数据都存放到对应的数据库子目录下。假如test表使用MyISAM存储引擎的话,那么在它所在数据库对应的xiaohaizi目录下会为test表创建这三个文件: test.frm test.MYD test.MYI 其中test.MYD代表表的数据文件,也就是我们插入的用户记录;test.MYI代表表的索引文件,我们为该表创建的索引都会放到这个文件中。\n视图在文件系统中的表示 # 我们知道MySQL中的视图其实是虚拟的表,也就是某个查询语句的一个别名而已,所以在存储视图的时候是不需要存储真实的数据的,只需要把它的结构存储起来就行了。和表一样,描述视图结构的文件也会被存储到所属数据库对应的子目录下面,只会存储一个视图名.frm的文件。\n其他的文件 # 除了我们上面说的这些用户自己存储的数据以外,数据目录下还包括为了更好运行程序的一些额外文件,主要包括这几种类型的文件:\n服务器进程文件。\n我们知道每运行一个MySQL服务器程序,都意味着启动一个进程。MySQL服务器会把自己的进程ID写入到一个文件中。\n服务器日志文件。\n在服务器运行过程中,会产生各种各样的日志,比如常规的查询日志、错误日志、二进制日志、redo日志等等各种日志,这些日志各有各的用途,我们之后会重点介绍各种日志的用途,现在先了解一下就可以了。\n默认/自动生成的SSL和RSA证书和密钥文件。\n主要是为了客户端和服务器安全通信而创建的一些文件, 大家看不懂可以忽略~\n文件系统对数据库的影响 # 因为MySQL的数据都是存在文件系统中的,就不得不受到文件系统的一些制约,这在数据库和表的命名、表的大小和性能方面体现的比较明显,比如下面这些方面:\n数据库名称和表名称不得超过文件系统所允许的最大长度。\n每个数据库都对应数据目录的一个子目录,数据库名称就是这个子目录的名称;每个表都会在数据库子目录下产生一个和表名同名的.frm文件,如果是InnoDB的独立表空间或者使用MyISAM引擎还会有别的文件的名称与表名一致。这些目录或文件名的长度都受限于文件系统所允许的长度~\n特殊字符的问题\n为了避免因为数据库名和表名出现某些特殊字符而造成文件系统不支持的情况,MySQL会把数据库名和表名中所有除数字和拉丁字母以外的所有字符在文件名里都映射成 @+编码值的形式作为文件名。比方说我们创建的表的名称为'test?',由于?不属于数字或者拉丁字母,所以会被映射成编码值,所以这个表对应的.frm文件的名称就变成了test@003f.frm。\n文件长度受文件系统最大长度限制\n对于InnoDB的独立表空间来说,每个表的数据都会被存储到一个与表名同名的.ibd文件中;对于MyISAM存储引擎来说,数据和索引会分别存放到与表同名的.MYD和.MYI文件中。这些文件会随着表中记录的增加而增大,它们的大小受限于文件系统支持的最大文件大小。\nMySQL系统数据库简介 # 我们前面提到了MySQL的几个系统数据库,这几个数据库包含了MySQL服务器运行过程中所需的一些信息以及一些运行状态信息,我们现在稍微了解一下。\nmysql\n这个数据库贼核心,它存储了MySQL的用户账户和权限信息,一些存储过程、事件的定义信息,一些运行过程中产生的日志信息,一些帮助信息以及时区信息等。\ninformation_schema\n这个数据库保存着MySQL服务器维护的所有其他数据库的信息,比如有哪些表、哪些视图、哪些触发器、哪些列、哪些索引等等。这些信息并不是真实的用户数据,而是一些描述性信息,有时候也称之为元数据。\nperformance_schema\n这个数据库里主要保存MySQL服务器运行过程中的一些状态信息,算是对MySQL服务器的一个性能监控。包括统计最近执行了哪些语句,在执行过程的每个阶段都花费了多长时间,内存的使用情况等等信息。\nsys\n这个数据库主要是通过视图的形式把information_schema和performance_schema结合起来,让程序员可以更方便的了解MySQL服务器的一些性能信息。\n什么?这四个系统数据库这就介绍完了?是的,我们的标题写的就是简介嘛!如果真的要介绍一下这几个系统库的使用,那怕是又要写一本书了\u0026hellip; 这里只是因为介绍数据目录里遇到了,为了内容的完整性跟大家提一下,具体如何使用还是要参照文档~\n"},{"id":25,"href":"/zh/docs/technology/MySQL/MySQL%E6%98%AF%E6%80%8E%E6%A0%B7%E8%BF%90%E8%A1%8C%E7%9A%84/%E7%AC%AC7%E7%AB%A0_%E5%A5%BD%E4%B8%9C%E8%A5%BF%E4%B9%9F%E5%BE%97%E5%85%88%E5%AD%A6%E4%BC%9A%E6%80%8E%E4%B9%88%E7%94%A8-B+%E6%A0%91%E7%B4%A2%E5%BC%95%E7%9A%84%E4%BD%BF%E7%94%A8/","title":"第7章_好东西也得先学会怎么用-B+树索引的使用","section":"My Sql是怎样运行的","content":"第7章 好东西也得先学会怎么用-B+树索引的使用\n我们前面详细、详细又详细的介绍了InnoDB存储引擎的B+树索引,我们必须熟悉下面这些结论:\n每个索引都对应一棵B+树,B+树分为好多层,最下面一层是叶子节点,其余的是内节点。所有用户记录都存储在B+树的叶子节点,所有目录项记录都存储在内节点。\nInnoDB存储引擎会自动为主键(如果没有它会自动帮我们添加)建立聚簇索引,聚簇索引的叶子节点包含完整的用户记录。\n我们可以为自己感兴趣的列建立二级索引,二级索引的叶子节点包含的用户记录由索引列 + 主键组成,所以如果想通过二级索引来查找完整的用户记录的话,需要通过回表操作,也就是在通过二级索引找到主键值之后再到聚簇索引中查找完整的用户记录。\nB+树中每层节点都是按照索引列值从小到大的顺序排序而组成了双向链表,而且每个页内的记录(不论是用户记录还是目录项记录)都是按照索引列的值从小到大的顺序而形成了一个单链表。如果是联合索引的话,则页面和记录先按照联合索引前面的列排序,如果该列值相同,再按照联合索引后边的列排序。\n通过索引查找记录是从B+树的根节点开始,一层一层向下搜索。由于每个页面都按照索引列的值建立了Page Directory(页目录),所以在这些页面中的查找非常快。\n如果你读上面的几点结论有些任何一点点疑惑的话,那下面的内容不适合你,回过头先去看前面的内容去。\n索引的代价 # 在熟悉了B+树索引原理之后,本篇文章的主题是介绍如何更好的使用索引,虽然索引是个好东西,可不能乱建,在介绍如何更好的使用索引之前先要了解一下使用这玩意儿的代价,它在空间和时间上都会拖后腿:\n空间上的代价\n这个是显而易见的,每建立一个索引都要为它建立一棵B+树,每一棵B+树的每一个节点都是一个数据页,一个页默认会占用16KB的存储空间,一棵很大的B+树由许多数据页组成,那可是很大的一片存储空间呢。\n时间上的代价\n每次对表中的数据进行增、删、改操作时,都需要去修改各个B+树索引。而且我们讲过,B+树每层节点都是按照索引列的值从小到大的顺序排序而组成了双向链表。不论是叶子节点中的记录,还是内节点中的记录(也就是不论是用户记录还是目录项记录)都是按照索引列的值从小到大的顺序而形成了一个单向链表。而增、删、改操作可能会对节点和记录的排序造成破坏,所以存储引擎需要额外的时间进行一些记录移位,页面分裂、页面回收什么的操作来维护好节点和记录的排序。如果我们建了许多索引,每个索引对应的B+树都要进行相关的维护操作,这还能不给性能拖后腿么?\n所以说,一个表上索引建的越多,就会占用越多的存储空间,在增删改记录的时候性能就越差。为了能建立又好又少的索引,我们先得学学这些索引在哪些条件下起作用的。\nB+树索引适用的条件 # 下面我们将介绍许多种让B+树索引发挥最大效能的技巧和注意事项,不过大家要清楚,所有的技巧都是源自你对B+树索引本质的理解,所以如果你还不能保证对B+树索引充分的理解,那么再次建议回过头把前面的内容看完了再来,要不然读文章对你来说是一种折磨。首先,B+树索引并不是万能的,并不是所有的查询语句都能用到我们建立的索引。下面介绍几个我们可能使用B+树索引来进行查询的情况。为了故事的顺利发展,我们需要先创建一个表,这个表是用来存储人的一些基本信息的: CREATE TABLE person_info( id INT NOT NULL auto_increment, name VARCHAR(100) NOT NULL, birthday DATE NOT NULL, phone_number CHAR(11) NOT NULL, country varchar(100) NOT NULL, PRIMARY KEY (id), KEY idx_name_birthday_phone_number (name, birthday, phone_number) ); 对于这个person_info表我们需要注意两点:\n表中的主键是id列,它存储一个自动递增的整数。所以InnoDB存储引擎会自动为id列建立聚簇索引。\n我们额外定义了一个二级索引idx_name_birthday_phone_number,它是由3个列组成的联合索引。所以在这个索引对应的B+树的叶子节点处存储的用户记录只保留name、birthday、phone_number这三个列的值以及主键id的值,并不会保存country列的值。\n从这两点注意中我们可以再次看到,一个表中有多少索引就会建立多少棵B+树,person_info表会为聚簇索引和idx_name_birthday_phone_number索引建立2棵B+树。下面我们画一下索引idx_name_birthday_phone_number的示意图,不过既然我们已经掌握了InnoDB的B+树索引原理,那我们在画图的时候为了让图更加清晰,所以在省略一些不必要的部分,比如记录的额外信息,各页面的页号等等,其中内节点中目录项记录的页号信息我们用箭头来代替,在记录结构中只保留name、birthday、phone_number、id这四个列的真实数据值,所以示意图就长这样(留心的同学看出来了,这其实和《高性能MySQL》里举的例子的图差不多,我觉得这个例子特别好,所以就借鉴了一下):\n为了方便大家理解,我们特意标明了哪些是内节点,哪些是叶子节点。再次强调一下,内节点中存储的是目录项记录,叶子节点中存储的是用户记录(由于不是聚簇索引,所以用户记录是不完整的,缺少country列的值)。从图中可以看出,这个idx_name_birthday_phone_number索引对应的B+树中页面和记录的排序方式就是这样的:\n先按照name列的值进行排序。 如果name列的值相同,则按照birthday列的值进行排序。 如果birthday列的值也相同,则按照phone_number的值进行排序。 这个排序方式十分、特别、非常、巨、very very very重要,因为只要页面和记录是排好序的,我们就可以通过二分法来快速定位查找。下面的内容都仰仗这个图了,大家对照着图理解。\n全值匹配 # 如果我们的搜索条件中的列和索引列一致的话,这种情况就称为全值匹配,比方说下面这个查找语句: SELECT * FROM person_info WHERE name = 'Ashburn' AND birthday = '1990-09-27' AND phone_number = '15123983239'; 我们建立的idx_name_birthday_phone_number索引包含的3个列在这个查询语句中都展现出来了。大家可以想象一下这个查询过程:\n因为B+树的数据页和记录先是按照name列的值进行排序的,所以先可以很快定位name列的值是Ashburn的记录位置。\n在name列相同的记录里又是按照birthday列的值进行排序的,所以在name列的值是Ashburn的记录里又可以快速定位birthday列的值是'1990-09-27'的记录。\n如果很不幸,name和birthday列的值都是相同的,那记录是按照phone_number列的值排序的,所以联合索引中的三个列都可能被用到。\n有的同学也许有个疑问,WHERE子句中的几个搜索条件的顺序对查询结果有什么影响么?也就是说如果我们调换name、birthday、phone_number这几个搜索列的顺序对查询的执行过程有影响么?比方说写成下面这样: SELECT * FROM person_info WHERE birthday = '1990-09-27' AND phone_number = '15123983239' AND name = 'Ashburn'; 答案是:没影响。MySQL有一个叫查询优化器的东东,会分析这些搜索条件并且按照可以使用的索引中列的顺序来决定先使用哪个搜索条件,后使用哪个搜索条件。我们后边儿会有专门的章节来介绍查询优化器,敬请期待。\n匹配左边的列 # 其实在我们的搜索语句中也可以不用包含全部联合索引中的列,只包含左边的就行,比方说下面的查询语句: SELECT * FROM person_info WHERE name = 'Ashburn'; 或者包含多个左边的列也行: SELECT * FROM person_info WHERE name = 'Ashburn' AND birthday = '1990-09-27'; 那为什么搜索条件中必须出现左边的列才可以使用到这个B+树索引呢?比如下面的语句就用不到这个B+树索引么? SELECT * FROM person_info WHERE birthday = '1990-09-27'; 是的,的确用不到,因为B+树的数据页和记录先是按照name列的值排序的,在name列的值相同的情况下才使用birthday列进行排序,也就是说name列的值不同的记录中birthday的值可能是无序的。而现在你跳过name列直接根据birthday的值去查找,臣妾做不到呀~ 那如果我就想在只使用birthday的值去通过B+树索引进行查找咋办呢?这好办,你再对birthday列建一个B+树索引就行了,创建索引的语法不用我介绍了吧。\n但是需要特别注意的一点是,如果我们想使用联合索引中尽可能多的列,搜索条件中的各个列必须是联合索引中从最左边连续的列。比方说联合索引idx_name_birthday_phone_number中列的定义顺序是name、birthday、phone_number,如果我们的搜索条件中只有name和phone_number,而没有中间的birthday,比方说这样: SELECT * FROM person_info WHERE name = 'Ashburn' AND phone_number = '15123983239'; 这样只能用到name列的索引,birthday和phone_number的索引就用不上了,因为name值相同的记录先按照birthday的值进行排序,birthday值相同的记录才按照phone_number值进行排序。\n匹配列前缀 # 我们前面说过为某个列建立索引的意思其实就是在对应的B+树的记录中使用该列的值进行排序,比方说person_info表上建立的联合索引idx_name_birthday_phone_number会先用name列的值进行排序,所以这个联合索引对应的B+树中的记录的name列的排列就是这样的: Aaron Aaron ... Aaron Asa Ashburn ... Ashburn Baird Barlow ... Barlow 字符串排序的本质就是比较哪个字符串大一点儿,哪个字符串小一点,比较字符串大小就用到了该列的字符集和比较规则,这个我们前面儿介绍过,就不多介绍了。这里需要注意的是,一般的比较规则都是逐个比较字符的大小,也就是说我们比较两个字符串的大小的过程其实是这样的:\n先比较字符串的第一个字符,第一个字符小的那个字符串就比较小。\n如果两个字符串的第一个字符相同,那就再比较第二个字符,第二个字符比较小的那个字符串就比较小。\n如果两个字符串的第二个字符也相同,那就接着比较第三个字符,依此类推。\n所以一个排好序的字符串列其实有这样的特点:\n先按照字符串的第一个字符进行排序。\n如果第一个字符相同再按照第二个字符进行排序。\n如果第二个字符相同再按照第三个字符进行排序,依此类推。\n也就是说这些字符串的前n个字符,也就是前缀都是排好序的,所以对于字符串类型的索引列来说,我们只匹配它的前缀也是可以快速定位记录的,比方说我们想查询名字以'As'开头的记录,那就可以这么写查询语句: SELECT * FROM person_info WHERE name LIKE 'As%'; 但是需要注意的是,如果只给出后缀或者中间的某个字符串,比如这样: SELECT * FROM person_info WHERE name LIKE '%As%'; MySQL就无法快速定位记录位置了,因为字符串中间有'As'的字符串并没有排好序,所以只能全表扫描了。有时候我们有一些匹配某些字符串后缀的需求,比方说某个表有一个url列,该列中存储了许多url: +----------------+ | url | +----------------+ | www.baidu.com | | www.google.com | | www.gov.cn | | ... | | www.wto.org | +----------------+ 假设已经对该url列创建了索引,如果我们想查询以com为后缀的网址的话可以这样写查询条件:WHERE url LIKE '%com',但是这样的话无法使用该url列的索引。为了在查询时用到这个索引而不至于全表扫描,我们可以把后缀查询改写成前缀查询,不过我们就得把表中的数据全部逆序存储一下,也就是说我们可以这样保存url列中的数据: +----------------+ | url | +----------------+ | moc.udiab.www | | moc.elgoog.www | | nc.vog.www | | ... | | gro.otw.www | +----------------+ 这样再查找以com为后缀的网址时搜索条件便可以这么写:WHERE url LIKE 'moc%',这样就可以用到索引了。\n匹配范围值 # 回头看我们idx_name_birthday_phone_number索引的B+树示意图,所有记录都是按照索引列的值从小到大的顺序排好序的,所以这极大的方便我们查找索引列的值在某个范围内的记录。比方说下面这个查询语句: SELECT * FROM person_info WHERE name \u0026gt; 'Asa' AND name \u0026lt; 'Barlow'; 由于B+树中的数据页和记录是先按name列排序的,所以我们上面的查询过程其实是这样的:\n找到name值为Asa的记录。 找到name值为Barlow的记录。 哦啦,由于所有记录都是由链表连起来的(记录之间用单链表,数据页之间用双链表),所以他们之间的记录都可以很容易的取出来喽~ 找到这些记录的主键值,再到聚簇索引中回表查找完整的记录。 不过在使用联合进行范围查找的时候需要注意,如果对多个列同时进行范围查找的话,只有对索引最左边的那个列进行范围查找的时候才能用到B+树索引,比方说这样: SELECT * FROM person_info WHERE name \u0026gt; 'Asa' AND name \u0026lt; 'Barlow' AND birthday \u0026gt; '1980-01-01'; 上面这个查询可以分成两个部分:\n通过条件name \u0026gt; 'Asa' AND name \u0026lt; 'Barlow'来对name进行范围,查找的结果可能有多条name值不同的记录,\n对这些name值不同的记录继续通过birthday \u0026gt; '1980-01-01'条件继续过滤。\n这样子对于联合索引idx_name_birthday_phone_number来说,只能用到name列的部分,而用不到birthday列的部分,因为只有name值相同的情况下才能用birthday列的值进行排序,而这个查询中通过name进行范围查找的记录中可能并不是按照birthday列进行排序的,所以在搜索条件中继续以birthday列进行查找时是用不到这个B+树索引的。\n精确匹配某一列并范围匹配另外一列 # 对于同一个联合索引来说,虽然对多个列都进行范围查找时只能用到最左边那个索引列,但是如果左边的列是精确查找,则右边的列可以进行范围查找,比方说这样: SELECT * FROM person_info WHERE name = 'Ashburn' AND birthday \u0026gt; '1980-01-01' AND birthday \u0026lt; '2000-12-31' AND phone_number \u0026gt; '15100000000'; 这个查询的条件可以分为3个部分:\nname = 'Ashburn',对name列进行精确查找,当然可以使用B+树索引了。\nbirthday \u0026gt; '1980-01-01' AND birthday \u0026lt; '2000-12-31',由于name列是精确查找,所以通过name = 'Ashburn'条件查找后得到的结果的name值都是相同的,它们会再按照birthday的值进行排序。所以此时对birthday列进行范围查找是可以用到B+树索引的。\nphone_number \u0026gt; '15100000000',通过birthday的范围查找的记录的birthday的值可能不同,所以这个条件无法再利用B+树索引了,只能遍历上一步查询得到的记录。\n同理,下面的查询也是可能用到这个idx_name_birthday_phone_number联合索引的: SELECT * FROM person_info WHERE name = 'Ashburn' AND birthday = '1980-01-01' AND phone_number \u0026gt; '15100000000';\n用于排序 # 我们在写查询语句的时候经常需要对查询出来的记录通过ORDER BY子句按照某种规则进行排序。一般情况下,我们只能把记录都加载到内存中,再用一些排序算法,比如快速排序、归并排序、等等排序等等在内存中对这些记录进行排序,有的时候可能查询的结果集太大以至于不能在内存中进行排序的话,还可能暂时借助磁盘的空间来存放中间结果,排序操作完成后再把排好序的结果集返回到客户端。在MySQL中,把这种在内存中或者磁盘上进行排序的方式统称为文件排序(英文名:filesort),跟文件这个词儿一沾边儿,就显得这些排序操作非常慢了(磁盘和内存的速度比起来,就像是飞机和蜗牛的对比)。但是如果ORDER BY子句里使用到了我们的索引列,就有可能省去在内存或文件中排序的步骤,比如下面这个简单的查询语句: SELECT * FROM person_info ORDER BY name, birthday, phone_number LIMIT 10; 这个查询的结果集需要先按照name值排序,如果记录的name值相同,则需要按照birthday来排序,如果birthday的值相同,则需要按照phone_number排序。大家可以回过头去看我们建立的idx_name_birthday_phone_number索引的示意图,因为这个B+树索引本身就是按照上述规则排好序的,所以直接从索引中提取数据,然后进行回表操作取出该索引中不包含的列就好了。简单吧?是的,索引就是这么牛逼。\n使用联合索引进行排序注意事项 # 对于联合索引有个问题需要注意,ORDER BY的子句后边的列的顺序也必须按照索引列的顺序给出,如果给出ORDER BY phone_number, birthday, name的顺序,那也是用不了B+树索引,这种颠倒顺序就不能使用索引的原因我们上面详细说过了,这就不赘述了。\n同理,ORDER BY name、ORDER BY name, birthday这种匹配索引左边的列的形式可以使用部分的B+树索引。当联合索引左边列的值为常量,也可以使用后边的列进行排序,比如这样: SELECT * FROM person_info WHERE name = 'A' ORDER BY birthday, phone_number LIMIT 10; 这个查询能使用联合索引进行排序是因为name列的值相同的记录是按照birthday, phone_number排序的,说了好多遍了都。\n不可以使用索引进行排序的几种情况 # ASC、DESC混用 # 对于使用联合索引进行排序的场景,我们要求各个排序列的排序顺序是一致的,也就是要么各个列都是ASC规则排序,要么都是DESC规则排序。 小贴士: ORDER BY子句后的列如果不加ASC或者DESC默认是按照ASC排序规则排序的,也就是升序排序的。 为什么会有这种奇葩规定呢?这个还得回头想想这个idx_name_birthday_phone_number联合索引中记录的结构:\n先按照记录的name列的值进行升序排列。\n如果记录的name列的值相同,再按照birthday列的值进行升序排列。\n如果记录的birthday列的值相同,再按照phone_number列的值进行升序排列。\n如果查询中的各个排序列的排序顺序是一致的,比方说下面这两种情况:\nORDER BY name, birthday LIMIT 10\n这种情况直接从索引的最左边开始往右读10行记录就可以了。\nORDER BY name DESC, birthday DESC LIMIT 10\n这种情况直接从索引的最右边开始往左读10行记录就可以了。\n但是如果我们查询的需求是先按照name列进行升序排列,再按照birthday列进行降序排列的话,比如说这样的查询语句: SELECT * FROM person_info ORDER BY name, birthday DESC LIMIT 10; 这样如果使用索引排序的话过程就是这样的:\n先从索引的最左边确定name列最小的值,然后找到name列等于该值的所有记录,然后从name列等于该值的最右边的那条记录开始往左找10条记录。\n如果name列等于最小的值的记录不足10条,再继续往右找name值第二小的记录,重复上面那个过程,直到找到10条记录为止。\n累不累?累!重点是这样不能高效使用索引,而要采取更复杂的算法去从索引中取数据,设计MySQL的大佬觉得这样还不如直接文件排序来的快,所以就规定使用联合索引的各个排序列的排序顺序必须是一致的。\nWHERE子句中出现非排序使用到的索引列 # 如果WHERE子句中出现了非排序使用到的索引列,那么排序依然是使用不到索引的,比方说这样: SELECT * FROM person_info WHERE country = 'China' ORDER BY name LIMIT 10; 这个查询只能先把符合搜索条件country = 'China'的记录提取出来后再进行排序,是使用不到索引。注意和下面这个查询作区别: SELECT * FROM person_info WHERE name = 'A' ORDER BY birthday, phone_number LIMIT 10; 虽然这个查询也有搜索条件,但是name = 'A'可以使用到索引idx_name_birthday_phone_number,而且过滤剩下的记录还是按照birthday、phone_number列排序的,所以还是可以使用索引进行排序的。\n排序列包含非同一个索引的列 # 有时候用来排序的多个列不是一个索引里的,这种情况也不能使用索引进行排序,比方说: SELECT * FROM person_info ORDER BY name, country LIMIT 10; name和country并不属于一个联合索引中的列,所以无法使用索引进行排序,至于为什么我就不想再介绍了,自己用前面的理论自己捋一捋把~\n排序列使用了复杂的表达式 # 要想使用索引进行排序操作,必须保证索引列是以单独列的形式出现,而不是修饰过的形式,比方说这样: SELECT * FROM person_info ORDER BY UPPER(name) LIMIT 10; 使用了UPPER函数修饰过的列就不是单独的列啦,这样就无法使用索引进行排序啦。\n用于分组 # 有时候我们为了方便统计表中的一些信息,会把表中的记录按照某些列进行分组。比如下面这个分组查询: SELECT name, birthday, phone_number, COUNT(*) FROM person_info GROUP BY name, birthday, phone_number 这个查询语句相当于做了3次分组操作:\n先把记录按照name值进行分组,所有name值相同的记录划分为一组。\n将每个name值相同的分组里的记录再按照birthday的值进行分组,将birthday值相同的记录放到一个小分组里,所以看起来就像在一个大分组里又化分了好多小分组。\n再将上一步中产生的小分组按照phone_number的值分成更小的分组,所以整体上看起来就像是先把记录分成一个大分组,然后把大分组分成若干个小分组,然后把若干个小分组再细分成更多的小小分组。\n然后针对那些小小分组进行统计,比如在我们这个查询语句中就是统计每个小小分组包含的记录条数。如果没有索引的话,这个分组过程全部需要在内存里实现,而如果有了索引的话,恰巧这个分组顺序又和我们的B+树中的索引列的顺序是一致的,而我们的B+树索引又是按照索引列排好序的,这不正好么,所以可以直接使用B+树索引进行分组。\n和使用B+树索引进行排序是一个道理,分组列的顺序也需要和索引列的顺序一致,也可以只使用索引列中左边的列进行分组,等等的~\n回表的代价 # 上面的讨论对回表这个词儿多是一带而过,可能大家没什么深刻的体会,下面我们详细介绍下。还是用idx_name_birthday_phone_number索引为例,看下面这个查询: SELECT * FROM person_info WHERE name \u0026gt; 'Asa' AND name \u0026lt; 'Barlow'; 在使用idx_name_birthday_phone_number索引进行查询时大致可以分为这两个步骤:\n从索引idx_name_birthday_phone_number对应的B+树中取出name值在Asa~Barlow之间的用户记录。\n由于索引idx_name_birthday_phone_number对应的B+树用户记录中只包含name、birthday、phone_number、id这4个字段,而查询列表是*,意味着要查询表中所有字段,也就是还要包括country字段。这时需要把从上一步中获取到的每一条记录的id字段都到聚簇索引对应的B+树中找到完整的用户记录,也就是我们通常所说的回表,然后把完整的用户记录返回给查询用户。\n由于索引idx_name_birthday_phone_number对应的B+树中的记录首先会按照name列的值进行排序,所以值在Asa~Barlow之间的记录在磁盘中的存储是相连的,集中分布在一个或几个数据页中,我们可以很快的把这些连着的记录从磁盘中读出来,这种读取方式我们也可以称为顺序I/O。根据第1步中获取到的记录的id字段的值可能并不相连,而在聚簇索引中记录是根据id(也就是主键)的顺序排列的,所以根据这些并不连续的id值到聚簇索引中访问完整的用户记录可能分布在不同的数据页中,这样读取完整的用户记录可能要访问更多的数据页,这种读取方式我们也可以称为随机I/O。一般情况下,顺序I/O比随机I/O的性能高很多,所以步骤1的执行可能很快,而步骤2就慢一些。所以这个使用索引idx_name_birthday_phone_number的查询有这么两个特点:\n会使用到两个B+树索引,一个二级索引,一个聚簇索引。\n访问二级索引使用顺序I/O,访问聚簇索引使用随机I/O。\n需要回表的记录越多,使用二级索引的性能就越低,甚至让某些查询宁愿使用全表扫描也不使用二级索引。比方说name值在Asa~Barlow之间的用户记录数量占全部记录数量90%以上,那么如果使用idx_name_birthday_phone_number索引的话,有90%多的id值需要回表,这不是吃力不讨好么,还不如直接去扫描聚簇索引(也就是全表扫描)。\n那什么时候采用全表扫描的方式,什么时候使用采用二级索引 + 回表的方式去执行查询呢?这个就是传说中的查询优化器做的工作,查询优化器会事先对表中的记录计算一些统计数据,然后再利用这些统计数据根据查询的条件来计算一下需要回表的记录数,需要回表的记录数越多,就越倾向于使用全表扫描,反之倾向于使用二级索引 + 回表的方式。当然优化器做的分析工作不仅仅是这么简单,但是大致上是个这个过程。一般情况下,限制查询获取较少的记录数会让优化器更倾向于选择使用二级索引 + 回表的方式进行查询,因为回表的记录越少,性能提升就越高,比方说上面的查询可以改写成这样: SELECT * FROM person_info WHERE name \u0026gt; 'Asa' AND name \u0026lt; 'Barlow' LIMIT 10; 添加了LIMIT 10的查询更容易让优化器采用二级索引 + 回表的方式进行查询。\n对于有排序需求的查询,上面讨论的采用全表扫描还是二级索引 + 回表的方式进行查询的条件也是成立的,比方说下面这个查询: SELECT * FROM person_info ORDER BY name, birthday, phone_number; 由于查询列表是*,所以如果使用二级索引进行排序的话,需要把排序完的二级索引记录全部进行回表操作,这样操作的成本还不如直接遍历聚簇索引然后再进行文件排序(filesort)低,所以优化器会倾向于使用全表扫描的方式执行查询。如果我们加了LIMIT子句,比如这样: SELECT * FROM person_info ORDER BY name, birthday, phone_number LIMIT 10; 这样需要回表的记录特别少,优化器就会倾向于使用二级索引 + 回表的方式执行查询。\n覆盖索引 # 为了彻底告别回表操作带来的性能损耗,我们建议:最好在查询列表里只包含索引列,比如这样: SELECT name, birthday, phone_number FROM person_info WHERE name \u0026gt; 'Asa' AND name \u0026lt; 'Barlow'; 因为我们只查询name, birthday, phone_number这三个索引列的值,所以在通过idx_name_birthday_phone_number索引得到结果后就不必到聚簇索引中再查找记录的剩余列,也就是country列的值了,这样就省去了回表操作带来的性能损耗。我们把这种只需要用到索引的查询方式称为索引覆盖。排序操作也优先使用覆盖索引的方式进行查询,比方说这个查询: SELECT name, birthday, phone_number FROM person_info ORDER BY name, birthday, phone_number; 虽然这个查询中没有LIMIT子句,但是采用了覆盖索引,所以查询优化器就会直接使用idx_name_birthday_phone_number索引进行排序而不需要回表操作了。\n当然,如果业务需要查询出索引以外的列,那还是以保证业务需求为重。但是我们很不鼓励用*号作为查询列表,最好把我们需要查询的列依次标明。\n如何挑选索引 # 上面我们以idx_name_birthday_phone_number索引为例对索引的适用条件进行了详细的介绍,下面看一下我们在建立索引时或者编写查询语句时就应该注意的一些事项。\n只为用于搜索、排序或分组的列创建索引 # 也就是说,只为出现在WHERE子句中的列、连接子句中的连接列,或者出现在ORDER BY或GROUP BY子句中的列创建索引。而出现在查询列表中的列就没必要建立索引了: SELECT birthday, country FROM person_name WHERE name = 'Ashburn'; 像查询列表中的birthday、country这两个列就不需要建立索引,我们只需要为出现在WHERE子句中的name列创建索引就可以了。\n考虑列的基数 # 列的基数指的是某一列中不重复数据的个数,比方说某个列包含值2, 5, 8, 2, 5, 8, 2, 5, 8,虽然有9条记录,但该列的基数却是3。也就是说,在记录行数一定的情况下,列的基数越大,该列中的值越分散,列的基数越小,该列中的值越集中。这个列的基数指标非常重要,直接影响我们是否能有效的利用索引。假设某个列的基数为1,也就是所有记录在该列中的值都一样,那为该列建立索引是没有用的,因为所有值都一样就无法排序,无法进行快速查找了~ 而且如果某个建立了二级索引的列的重复值特别多,那么使用这个二级索引查出的记录还可能要做回表操作,这样性能损耗就更大了。所以结论就是:最好为那些列的基数大的列建立索引,为基数太小列的建立索引效果可能不好。\n索引列的类型尽量小 # 我们在定义表结构的时候要显式的指定列的类型,以整数类型为例,有TINYINT、MEDIUMINT、INT、BIGINT这么几种,它们占用的存储空间依次递增,我们这里所说的类型大小指的就是该类型表示的数据范围的大小。能表示的整数范围当然也是依次递增,如果我们想要对某个整数列建立索引的话,在表示的整数范围允许的情况下,尽量让索引列使用较小的类型,比如我们能使用INT就不要使用BIGINT,能使用MEDIUMINT就不要使用INT~ 这是因为:\n数据类型越小,在查询时进行的比较操作越快(这是CPU层次的东东)\n数据类型越小,索引占用的存储空间就越少,在一个数据页内就可以放下更多的记录,从而减少磁盘I/O带来的性能损耗,也就意味着可以把更多的数据页缓存在内存中,从而加快读写效率。\n这个建议对于表的主键来说更加适用,因为不仅是聚簇索引中会存储主键值,其他所有的二级索引的节点处都会存储一份记录的主键值,如果主键适用更小的数据类型,也就意味着节省更多的存储空间和更高效的I/O。\n索引字符串值的前缀 # 我们知道一个字符串其实是由若干个字符组成,如果我们在MySQL中使用utf8字符集去存储字符串的话,编码一个字符需要占用1~3个字节。假设我们的字符串很长,那存储一个字符串就需要占用很大的存储空间。在我们需要为这个字符串列建立索引时,那就意味着在对应的B+树中有这么两个问题:\nB+树索引中的记录需要把该列的完整字符串存储起来,而且字符串越长,在索引中占用的存储空间越大。\n如果B+树索引中索引列存储的字符串很长,那在做字符串比较时会占用更多的时间。\n我们前面儿说过索引列的字符串前缀其实也是排好序的,所以索引的设计者提出了个方案 \u0026mdash; 只对字符串的前几个字符进行索引也就是说在二级索引的记录中只保留字符串前几个字符。这样在查找记录时虽然不能精确的定位到记录的位置,但是能定位到相应前缀所在的位置,然后根据前缀相同的记录的主键值回表查询完整的字符串值,再对比就好了。这样只在B+树中存储字符串的前几个字符的编码,既节约空间,又减少了字符串的比较时间,还大概能解决排序的问题,何乐而不为,比方说我们在建表语句中只对name列的前10个字符进行索引可以这么写: CREATE TABLE person_info( name VARCHAR(100) NOT NULL, birthday DATE NOT NULL, phone_number CHAR(11) NOT NULL, country varchar(100) NOT NULL, KEY idx_name_birthday_phone_number (name(10), birthday, phone_number) ); name(10)就表示在建立的B+树索引中只保留记录的前10个字符的编码,这种只索引字符串值的前缀的策略是我们非常鼓励的,尤其是在字符串类型能存储的字符比较多的时候。\n索引列前缀对排序的影响 # 如果使用了索引列前缀,比方说前面只把name列的前10个字符放到了二级索引中,下面这个查询可能就有点儿尴尬了: SELECT * FROM person_info ORDER BY name LIMIT 10; 因为二级索引中不包含完整的name列信息,所以无法对前十个字符相同,后边的字符不同的记录进行排序,也就是使用索引列前缀的方式无法支持使用索引排序,只好乖乖的用文件排序喽。\n让索引列在比较表达式中单独出现 # 假设表中有一个整数列my_col,我们为这个列建立了索引。下面的两个WHERE子句虽然语义是一致的,但是在效率上却有差别:\nWHERE my_col * 2 \u0026lt; 4\nWHERE my_col \u0026lt; 4/2\n第1个WHERE子句中my_col列并不是以单独列的形式出现的,而是以my_col * 2这样的表达式的形式出现的,存储引擎会依次遍历所有的记录,计算这个表达式的值是不是小于4,所以这种情况下是使用不到为my_col列建立的B+树索引的。而第2个WHERE子句中my_col列并是以单独列的形式出现的,这样的情况可以直接使用B+树索引。\n所以结论就是:如果索引列在比较表达式中不是以单独列的形式出现,而是以某个表达式,或者函数调用形式出现的话,是用不到索引的。\n主键插入顺序 # 我们知道,对于一个使用InnoDB存储引擎的表来说,在我们没有显式的创建索引时,表中的数据实际上都是存储在聚簇索引的叶子节点的。而记录又是存储在数据页中的,数据页和记录又是按照记录主键值从小到大的顺序进行排序,所以如果我们插入的记录的主键值是依次增大的话,那我们每插满一个数据页就换到下一个数据页继续插,而如果我们插入的主键值忽大忽小的话,这就比较麻烦了,假设某个数据页存储的记录已经满了,它存储的主键值在1~100之间:\n如果此时再插入一条主键值为9的记录,那它插入的位置就如下图:\n可这个数据页已经满了啊,再插进来咋办呢?我们需要把当前页面分裂成两个页面,把本页中的一些记录移动到新创建的这个页中。页面分裂和记录移位意味着什么?意味着:性能损耗!所以如果我们想尽量避免这样无谓的性能损耗,最好让插入的记录的主键值依次递增,这样就不会发生这样的性能损耗了。所以我们建议:**让主键具有AUTO_INCREMENT,让存储引擎自己为表生成主键,而不是我们手动插入 **,比方说我们可以这样定义person_info表: CREATE TABLE person_info( id INT UNSIGNED NOT NULL AUTO_INCREMENT, name VARCHAR(100) NOT NULL, birthday DATE NOT NULL, phone_number CHAR(11) NOT NULL, country varchar(100) NOT NULL, PRIMARY KEY (id), KEY idx_name_birthday_phone_number (name(10), birthday, phone_number) ); 我们自定义的主键列id拥有AUTO_INCREMENT属性,在插入记录时存储引擎会自动为我们填入自增的主键值。\n冗余和重复索引 # 有时候有的同学有意或者无意的就对同一个列创建了多个索引,比方说这样写建表语句: CREATE TABLE person_info( id INT UNSIGNED NOT NULL AUTO_INCREMENT, name VARCHAR(100) NOT NULL, birthday DATE NOT NULL, phone_number CHAR(11) NOT NULL, country varchar(100) NOT NULL, PRIMARY KEY (id), KEY idx_name_birthday_phone_number (name(10), birthday, phone_number), KEY idx_name (name(10)) ); 我们知道,通过idx_name_birthday_phone_number索引就可以对name列进行快速搜索,再创建一个专门针对name列的索引就算是一个冗余索引,维护这个索引只会增加维护的成本,并不会对搜索有什么好处。\n另一种情况,我们可能会对某个列重复建立索引,比方说这样: CREATE TABLE repeat_index_demo ( c1 INT PRIMARY KEY, c2 INT, UNIQUE uidx_c1 (c1), INDEX idx_c1 (c1) ); 我们看到,c1既是主键、又给它定义为一个唯一索引,还给它定义了一个普通索引,可是主键本身就会生成聚簇索引,所以定义的唯一索引和普通索引是重复的,这种情况要避免。\n总结 # 上面只是我们在创建和使用B+树索引的过程中需要注意的一些点,后边我们还会陆续介绍更多的优化方法和注意事项,敬请期待。本集内容总结如下:\nB+树索引在空间和时间上都有代价,所以没事儿别瞎建索引。\nB+树索引适用于下面这些情况:\n+ 全值匹配 + 匹配左边的列 + 匹配范围值 + 精确匹配某一列并范围匹配另外一列 + 用于排序 + 用于分组 在使用索引时需要注意下面这些事项:\n+ 只为用于搜索、排序或分组的列创建索引 + 为列的基数大的列创建索引 + 索引列的类型尽量小 + 可以只对字符串值的前缀建立索引 + 只有索引列在比较表达式中单独出现才可以适用索引 + 为了尽可能少的让`聚簇索引`发生页面分裂和记录移位的情况,建议让主键拥有`AUTO_INCREMENT`属性。 + 定位并删除表中的重复和冗余索引 + 尽量使用`覆盖索引`进行查询,避免`回表`带来的性能损耗。 "},{"id":26,"href":"/zh/docs/technology/MySQL/MySQL%E6%98%AF%E6%80%8E%E6%A0%B7%E8%BF%90%E8%A1%8C%E7%9A%84/%E7%AC%AC6%E7%AB%A0_%E5%BF%AB%E9%80%9F%E6%9F%A5%E8%AF%A2%E7%9A%84%E7%A7%98%E7%B1%8D-B+%E6%A0%91%E7%B4%A2%E5%BC%95/","title":"第6章_快速查询的秘籍-B+树索引","section":"My Sql是怎样运行的","content":"第6章 快速查询的秘籍-B+树索引\n前面我们详细介绍了InnoDB数据页的7个组成部分,知道了各个数据页可以组成一个双向链表,而每个数据页中的记录会按照主键值从小到大的顺序组成一个单向链表,每个数据页都会为存储在它里边儿的记录生成一个页目录,在通过主键查找某条记录的时候可以在页目录中使用二分法快速定位到对应的槽,然后再遍历该槽对应分组中的记录即可快速找到指定的记录(如果你对这段话有一丁点儿疑惑,那么接下来的部分不适合你,返回去看一下数据页结构吧)。页和记录的关系示意图如下:\n其中页a、页b、页c \u0026hellip; 页n 这些页可以不在物理结构上相连,只要通过双向链表相关联即可。\n没有索引的查找 # 本集的主题是索引,在正式介绍索引之前,我们需要了解一下没有索引的时候是怎么查找记录的。为了方便大家理解,我们下面先只介绍搜索条件为对某个列精确匹配的情况,所谓精确匹配,就是搜索条件中用等于=连接起的表达式,比如这样: SELECT [列名列表] FROM 表名 WHERE 列名 = xxx;\n在一个页中的查找 # 假设目前表中的记录比较少,所有的记录都可以被存放到一个页中,在查找记录的时候可以根据搜索条件的不同分为两种情况:\n以主键为搜索条件\n这个查找过程我们已经很熟悉了,可以在页目录中使用二分法快速定位到对应的槽,然后再遍历该槽对应分组中的记录即可快速找到指定的记录。\n以其他列作为搜索条件\n对非主键列的查找的过程可就不这么幸运了,因为在数据页中并没有对非主键列建立所谓的页目录,所以我们无法通过二分法快速定位相应的槽。这种情况下只能从最小记录开始依次遍历单链表中的每条记录,然后对比每条记录是不是符合搜索条件。很显然,这种查找的效率是非常低的。\n在很多页中查找 # 大部分情况下我们表中存放的记录都是非常多的,需要好多的数据页来存储这些记录。在很多页中查找记录的话可以分为两个步骤:\n定位到记录所在的页。 从所在的页内中查找相应的记录。 在没有索引的情况下,不论是根据主键列或者其他列的值进行查找,由于我们并不能快速的定位到记录所在的页,所以只能从第一个页沿着双向链表一直往下找,在每一个页中根据我们刚刚介绍过的查找方式去查找指定的记录。因为要遍历所有的数据页,所以这种方式显然是超级耗时的,如果一个表有一亿条记录,使用这种方式去查找记录那要等到猴年马月才能等到查找结果。所以祖国和人民都在期盼一种能高效完成搜索的方法,索引同志就要亮相登台了。\n索引 # 为了故事的顺利发展,我们先建一个表: mysql\u0026gt; CREATE TABLE index_demo( -\u0026gt; c1 INT, -\u0026gt; c2 INT, -\u0026gt; c3 CHAR(1), -\u0026gt; PRIMARY KEY(c1) -\u0026gt; ) ROW_FORMAT = Compact; Query OK, 0 rows affected (0.03 sec) 这个新建的index_demo表中有2个INT类型的列,1个CHAR(1)类型的列,而且我们规定了c1列为主键,这个表使用Compact行格式来实际存储记录的。为了我们理解上的方便,我们简化了一下index_demo表的行格式示意图:\n我们只在示意图里展示记录的这几个部分:\nrecord_type:记录头信息的一项属性,表示记录的类型,0表示普通记录、2表示最小记录、3表示最大记录、1我们还没用过,等会再说~\nnext_record:记录头信息的一项属性,表示下一条地址相对于本条记录的地址偏移量,为了方便大家理解,我们都会用箭头来表明下一条记录是谁。\n各个列的值:这里只记录在index_demo表中的三个列,分别是c1、c2和c3。\n其他信息:除了上述3种信息以外的所有信息,包括其他隐藏列的值以及记录的额外信息。\n为了节省篇幅,我们之后的示意图中会把记录的其他信息这个部分省略掉,因为它占地方并且不会有什么观赏效果。另外,为了方便理解,我们觉得把记录竖着放看起来感觉更好,所以将记录格式示意图的其他信息去掉并把它竖起来的效果就是这样:\n把一些记录放到页里边的示意图就是:\n一个简单的索引方案 # 回到正题,我们在根据某个搜索条件查找一些记录时为什么要遍历所有的数据页呢?因为各个页中的记录并没有规律,我们并不知道我们的搜索条件匹配哪些页中的记录,所以 不得不 依次遍历所有的数据页。所以如果我们想快速的定位到需要查找的记录在哪些数据页中该咋办?还记得我们为根据主键值快速定位一条记录在页中的位置而设立的页目录么?我们也可以想办法为快速定位记录所在的数据页而建立一个别的目录,建这个目录必须完成下面这些事儿:\n下一个数据页中用户记录的主键值必须大于上一个页中用户记录的主键值。\n为了故事的顺利发展,我们这里需要做一个假设:假设我们的每个数据页最多能存放3条记录(实际上一个数据页非常大,可以存放下好多记录)。有了这个假设之后我们向index_demo表插入3条记录: mysql\u0026gt; INSERT INTO index_demo VALUES(1, 4, 'u'), (3, 9, 'd'), (5, 3, 'y'); Query OK, 3 rows affected (0.01 sec) Records: 3 Duplicates: 0 Warnings: 0 那么这些记录已经按照主键值的大小串联成一个单向链表了,如图所示:\n从图中可以看出来,index_demo表中的3条记录都被插入到了编号为10的数据页中了。此时我们再来插入一条记录: mysql\u0026gt; INSERT INTO index_demo VALUES(4, 4, 'a'); Query OK, 1 row affected (0.00 sec) 因为页10最多只能放3条记录,所以我们不得不再分配一个新页:\n咦?怎么分配的页号是28呀,不应该是11么?再次强调一遍,新分配的数据页编号可能并不是连续的,也就是说我们使用的这些页在存储空间里可能并不挨着。它们只是通过维护着上一个页和下一个页的编号而建立了链表关系。另外,页10中用户记录最大的主键值是5,而页28中有一条记录的主键值是4,因为5 \u0026gt; 4,所以这就不符合下一个数据页中用户记录的主键值必须大于上一个页中用户记录的主键值的要求,所以在插入主键值为4的记录的时候需要伴随着一次记录移动,也就是把主键值为5的记录移动到页28中,然后再把主键值为4的记录插入到页10中,这个过程的示意图如下:\n这个过程表明了在对页中的记录进行增删改操作的过程中,我们必须通过一些诸如记录移动的操作来始终保证这个状态一直成立:下一个数据页中用户记录的主键值必须大于上一个页中用户记录的主键值。这个过程我们也可以称为页分裂。\n给所有的页建立一个目录项。\n由于数据页的编号可能并不是连续的,所以在向index_demo表中插入许多条记录后,可能是这样的效果:\n因为这些16KB的页在物理存储上可能并不挨着,所以如果想从这么多页中根据主键值快速定位某些记录所在的页,我们需要给它们做个目录,每个页对应一个目录项,每个目录项包括下面两个部分:\n+ 页的用户记录中最小的主键值,我们用`key`来表示。 + 页号,我们用`page_no`表示。 所以我们为上面几个页做好的目录就像这样子:\n以页28为例,它对应目录项2,这个目录项中包含着该页的页号28以及该页中用户记录的最小主键值5。我们只需要把几个目录项在物理存储器上连续存储,比如把他们放到一个数组里,就可以实现根据主键值快速查找某条记录的功能了。比方说我们想找主键值为20的记录,具体查找过程分两步:\n1.先从目录项中根据二分法快速确定出主键值为20的记录在目录项3中(因为 12 \u0026lt; 20 \u0026lt; 209),它对应的页是页9。\n2.再根据前面说的在页中查找记录的方式去页9中定位具体的记录。\n至此,针对数据页做的简易目录就搞定了。不过忘了说了,这个目录有一个别名,称为索引。\nInnoDB中的索引方案 # 上面之所以称为一个简易的索引方案,是因为我们为了在根据主键值进行查找时使用二分法快速定位具体的目录项而假设所有目录项都可以在物理存储器上连续存储,但是这样做有几个问题:\nInnoDB是使用页来作为管理存储空间的基本单位,也就是最多能保证16KB的连续存储空间,而随着表中记录数量的增多,需要非常大的连续的存储空间才能把所有的目录项都放下,这对记录数量非常多的表是不现实的。\n我们时常会对记录进行增删,假设我们把页28中的记录都删除了,页28也就没有存在的必要了,那意味着目录项2也就没有存在的必要了,这就需要把目录项2后的目录项都向前移动一下,这种牵一发而动全身的设计不是什么好主意~\n所以,设计InnoDB的大佬们需要一种可以灵活管理所有目录项的方式。他们灵光乍现,忽然发现这些目录项其实长得跟我们的用户记录差不多,只不过目录项中的两个列是主键和页号而已,所以他们复用了之前存储用户记录的数据页来存储目录项,为了和用户记录做一下区分,我们把这些用来表示目录项的记录称为目录项记录。那InnoDB怎么区分一条记录是普通的用户记录还是目录项记录呢?别忘了记录头信息里的record_type属性,它的各个取值代表的意思如下:\n0:普通的用户记录 1:目录项记录 2:最小记录 3:最大记录 原来这个值为1的record_type是这个意思呀,我们把前面使用到的目录项放到数据页中的样子就是这样:\n从图中可以看出来,我们新分配了一个编号为30的页来专门存储目录项记录。这里再次强调一遍目录项记录和普通的用户记录的不同点:\n目录项记录的record_type值是1,而普通用户记录的record_type值是0。\n目录项记录只有主键值和页的编号两个列,而普通的用户记录的列是用户自己定义的,可能包含很多列,另外还有InnoDB自己添加的隐藏列。\n还记得我们之前在介绍记录头信息的时候说过一个叫min_rec_mask的属性么,只有在存储目录项记录的页中的主键值最小的目录项记录的min_rec_mask值为1,其他别的记录的min_rec_mask值都是0。\n除了上述几点外,这两者就没什么差别了,它们用的是一样的数据页(页面类型都是0x45BF,这个属性在File Header中,忘了的话可以翻到前面的文章看),页的组成结构也是一样一样的(就是我们前面介绍过的7个部分),都会为主键值生成Page Directory(页目录),从而在按照主键值进行查找时可以使用二分法来加快查询速度。现在以查找主键为20的记录为例,根据某个主键值去查找记录的步骤就可以大致拆分成下面两步:\n先到存储目录项记录的页,也就是页30中通过二分法快速定位到对应目录项,因为12 \u0026lt; 20 \u0026lt; 209,所以定位到对应的记录所在的页就是页9。\n再到存储用户记录的页9中根据二分法快速定位到主键值为20的用户记录。\n虽然说目录项记录中只存储主键值和对应的页号,比用户记录需要的存储空间小多了,但是不论怎么说一个页只有16KB大小,能存放的目录项记录也是有限的,那如果表中的数据太多,以至于一个数据页不足以存放所有的目录项记录,该咋办呢?\n当然是再多整一个存储目录项记录的页喽~ 为了大家更好的理解新分配一个目录项记录页的过程,我们假设一个存储目录项记录的页最多只能存放4条目录项记录(请注意是假设哦,真实情况下可以存放好多条的),所以如果此时我们再向上图中插入一条主键值为320的用户记录的话,那就需要分配一个新的存储目录项记录的页喽:\n从图中可以看出,我们插入了一条主键值为320的用户记录之后需要两个新的数据页:\n为存储该用户记录而新生成了页31。\n因为原先存储目录项记录的页30的容量已满(我们前面假设只能存储4条目录项记录),所以不得不需要一个新的页32来存放页31对应的目录项。\n现在因为存储目录项记录的页不止一个,所以如果我们想根据主键值查找一条用户记录大致需要3个步骤,以查找主键值为20的记录为例:\n确定目录项记录页\n我们现在的存储目录项记录的页有两个,即页30和页32,又因为页30表示的目录项的主键值的范围是[1, 320),页32表示的目录项的主键值不小于320,所以主键值为20的记录对应的目录项记录在页30中。\n通过目录项记录页确定用户记录真实所在的页。\n在一个存储目录项记录的页中通过主键值定位一条目录项记录的方式说过了,不赘述了~\n在真实存储用户记录的页中定位到具体的记录。\n在一个存储用户记录的页中通过主键值定位一条用户记录的方式已经说过200遍了,你再不会我就,我就,我就求你到上一篇介绍数据页结构的文章中多看几遍,求你了~\n那么问题来了,在这个查询步骤的第1步中我们需要定位存储目录项记录的页,但是这些页在存储空间中也可能不挨着,如果我们表中的数据非常多则会产生很多存储目录项记录的页,那我们怎么根据主键值快速定位一个存储目录项记录的页呢?其实也简单,为这些存储目录项记录的页再生成一个更高级的目录,就像是一个多级目录一样,大目录里嵌套小目录,小目录里才是实际的数据,所以现在各个页的示意图就是这样子:\n如图,我们生成了一个存储更高级目录项的页33,这个页中的两条记录分别代表页30和页32,如果用户记录的主键值在[1, 320)之间,则到页30中查找更详细的目录项记录,如果主键值不小于320的话,就到页32中查找更详细的目录项记录。不过这张图好漂亮喔,随着表中记录的增加,这个目录的层级会继续增加,如果简化一下,那么我们可以用下面这个图来描述它:\n这玩意儿像不像一个倒过来的树呀,上头是树根,下头是树叶!其实这是一种组织数据的形式,或者说是一种数据结构,它的名称是B+树。 小贴士:为什么叫B+呢,B树是什么?喔对不起,这不是我们讨论的范围,你可以去找一本数据结构或算法的书来看。什么?数据结构的书看不懂?等我~ 不论是存放用户记录的数据页,还是存放目录项记录的数据页,我们都把它们存放到B+树这个数据结构中了,所以我们也称这些数据页为节点。从图中可以看出来,我们的实际用户记录其实都存放在B+树的最底层的节点上,这些节点也被称为叶子节点或叶节点,其余用来存放目录项的节点称为非叶子节点或者内节点,其中B+树最上面的那个节点也称为根节点。\n从图中可以看出来,一个B+树的节点其实可以分成好多层,设计InnoDB的大佬们为了讨论方便,规定最下面的那层,也就是存放我们用户记录的那层为第0层,之后依次往上加。之前的讨论我们做了一个非常极端的假设:存放用户记录的页最多存放3条记录,存放目录项记录的页最多存放4条记录。其实真实环境中一个页存放的记录数量是非常大的,假设,假设,假设所有存放用户记录的叶子节点代表的数据页可以存放100条用户记录,所有存放目录项记录的内节点代表的数据页可以存放1000条目录项记录,那么:\n如果B+树只有1层,也就是只有1个用于存放用户记录的节点,最多能存放100条记录。\n如果B+树有2层,最多能存放1000×100=100000条记录。\n如果B+树有3层,最多能存放1000×1000×100=100000000条记录。\n如果B+树有4层,最多能存放1000×1000×1000×100=100000000000条记录。哇咔咔~这么多的记录!!!\n你的表里能存放100000000000条记录么?所以一般情况下,我们用到的B+树都不会超过4层,那我们通过主键值去查找某条记录最多只需要做4个页面内的查找(查找3个目录项页和一个用户记录页),又因为在每个页面内有所谓的Page Directory(页目录),所以在页面内也可以通过二分法实现快速定位记录,这不是很牛么!\n聚簇索引 # 我们上面介绍的B+树本身就是一个目录,或者说本身就是一个索引。它有两个特点:\n使用记录主键值的大小进行记录和页的排序,这包括三个方面的含义:\n+ 页内的记录是按照主键的大小顺序排成一个单向链表。\n+ 各个存放用户记录的页也是根据页中用户记录的主键大小顺序排成一个双向链表。\n+ 存放目录项记录的页分为不同的层次,在同一层次中的页也是根据页中目录项记录的主键大小顺序排成一个双向链表。\nB+树的叶子节点存储的是完整的用户记录。\n所谓完整的用户记录,就是指这个记录中存储了所有列的值(包括隐藏列)。\n我们把具有这两种特性的B+树称为聚簇索引,所有完整的用户记录都存放在这个聚簇索引的叶子节点处。这种聚簇索引并不需要我们在MySQL语句中显式的使用INDEX语句去创建(后边会介绍索引相关的语句),InnoDB存储引擎会自动的为我们创建聚簇索引。另外有趣的一点是,在InnoDB存储引擎中,聚簇索引就是数据的存储方式(所有的用户记录都存储在了叶子节点),也就是所谓的索引即数据,数据即索引。\n二级索引 # 大家有木有发现,上面介绍的聚簇索引只能在搜索条件是主键值时才能发挥作用,因为B+树中的数据都是按照主键进行排序的。那如果我们想以别的列作为搜索条件该咋办呢?难道只能从头到尾沿着链表依次遍历记录么?\n不,我们可以多建几棵B+树,不同的B+树中的数据采用不同的排序规则。比方说我们用c2列的大小作为数据页、页中记录的排序规则,再建一棵B+树,效果如下图所示:\n这个B+树与上面介绍的聚簇索引有几处不同:\n使用记录c2列的大小进行记录和页的排序,这包括三个方面的含义:\n+ 页内的记录是按照c2列的大小顺序排成一个单向链表。\n+ 各个存放用户记录的页也是根据页中记录的c2列大小顺序排成一个双向链表。\n+ 存放目录项记录的页分为不同的层次,在同一层次中的页也是根据页中目录项记录的c2列大小顺序排成一个双向链表。\nB+树的叶子节点存储的并不是完整的用户记录,而只是c2列+主键这两个列的值。\n目录项记录中不再是主键+页号的搭配,而变成了c2列+页号的搭配。\n所以如果我们现在想通过c2列的值查找某些记录的话就可以使用我们刚刚建好的这个B+树了。以查找c2列的值为4的记录为例,查找过程如下:\n确定目录项记录页\n根据根页面,也就是页44,可以快速定位到目录项记录所在的页为页42(因为2 \u0026lt; 4 \u0026lt; 9)。\n通过目录项记录页确定用户记录真实所在的页。\n在页42中可以快速定位到实际存储用户记录的页,但是由于c2列并没有唯一性约束,所以c2列值为4的记录可能分布在多个数据页中,又因为2 \u0026lt; 4 ≤ 4,所以确定实际存储用户记录的页在页34和页35中。\n在真实存储用户记录的页中定位到具体的记录。\n到页34和页35中定位到具体的记录。\n但是这个B+树的叶子节点中的记录只存储了c2和c1(也就是主键)两个列,所以我们必须再根据主键值去聚簇索引中再查找一遍完整的用户记录。\n各位各位,看到步骤4的操作了么?我们根据这个以c2列大小排序的B+树只能确定我们要查找记录的主键值,所以如果我们想根据c2列的值查找到完整的用户记录的话,仍然需要到聚簇索引中再查一遍,这个过程也被称为回表。也就是根据c2列的值查询一条完整的用户记录需要使用到2棵B+树!!!\n为什么我们还需要一次回表操作呢?直接把完整的用户记录放到叶子节点不就好了么?你说的对,如果把完整的用户记录放到叶子节点是可以不用回表,但是太占地方了呀~相当于每建立一棵B+树都需要把所有的用户记录再都拷贝一遍,这就有点太浪费存储空间了。因为这种按照非主键列建立的B+树需要一次回表操作才可以定位到完整的用户记录,所以这种B+树也被称为二级索引(英文名secondary index),或者辅助索引。由于我们使用的是c2列的大小作为B+树的排序规则,所以我们也称这个B+树为为c2列建立的索引。\n联合索引 # 我们也可以同时以多个列的大小作为排序规则,也就是同时为多个列建立索引,比方说我们想让B+树按照c2和c3列的大小进行排序,这个包含两层含义:\n先把各个记录和页按照c2列进行排序。 在记录的c2列相同的情况下,采用c3列进行排序 为c2和c3列建立的索引的示意图如下:\n如图所示,我们需要注意一下几点:\n每条目录项记录都由c2、c3、页号这三个部分组成,各条记录先按照c2列的值进行排序,如果记录的c2列相同,则按照c3列的值进行排序。\nB+树叶子节点处的用户记录由c2、c3和主键c1列组成。\n千万要注意一点,以c2和c3列的大小为排序规则建立的B+树称为联合索引,本质上也是一个二级索引。它的意思与分别为c2和c3列分别建立索引的表述是不同的,不同点如下:\n建立联合索引只会建立如上图一样的1棵B+树。\n为c2和c3列分别建立索引会分别以c2和c3列的大小为排序规则建立2棵B+树。\nInnoDB的B+树索引的注意事项 # 根页面万年不动窝 # 我们前面介绍B+树索引的时候,为了大家理解上的方便,先把存储用户记录的叶子节点都画出来,然后接着画存储目录项记录的内节点,实际上B+树的形成过程是这样的:\n每当为某个表创建一个B+树索引(聚簇索引不是人为创建的,默认就有)的时候,都会为这个索引创建一个根节点页面。最开始表中没有数据的时候,每个B+树索引对应的根节点中既没有用户记录,也没有目录项记录。\n随后向表中插入用户记录时,先把用户记录存储到这个根节点中。\n当根节点中的可用空间用完时继续插入记录,此时会将根节点中的所有记录复制到一个新分配的页,比如页a中,然后对这个新页进行页分裂的操作,得到另一个新页,比如页b。这时新插入的记录根据键值(也就是聚簇索引中的主键值,二级索引中对应的索引列的值)的大小就会被分配到页a或者页b中,而根节点便升级为存储目录项记录的页。\n这个过程需要大家特别注意的是:一个B+树索引的根节点自诞生之日起,便不会再移动。这样只要我们对某个表建立一个索引,那么它的根节点的页号便会被记录到某个地方,然后凡是InnoDB存储引擎需要用到这个索引的时候,都会从那个固定的地方取出根节点的页号,从而来访问这个索引。 小贴士:跟大家剧透一下,这个存储某个索引的根节点在哪个页面中的信息就是传说中的数据字典中的一项信息,关于更多数据字典的内容,后边会详细介绍,别着急。\n内节点中目录项记录的唯一性 # 我们知道B+树索引的内节点中目录项记录的内容是索引列 + 页号的搭配,但是这个搭配对于二级索引来说有点儿不严谨。还拿index_demo表为例,假设这个表中的数据是这样的: c1 c2 c3 1 1 \u0026lsquo;u\u0026rsquo; 3 1 \u0026rsquo;d\u0026rsquo; 5 1 \u0026lsquo;y\u0026rsquo; 7 1 \u0026lsquo;a\u0026rsquo; 如果二级索引中目录项记录的内容只是索引列 + 页号的搭配的话,那么为c2列建立索引后的B+树应该长这样:\n如果我们想新插入一行记录,其中c1、c2、c3的值分别是:9、1、'c',那么在修改这个为c2列建立的二级索引对应的B+树时便碰到了个大问题:由于页3中存储的目录项记录是由c2列 + 页号的值构成的,页3中的两条目录项记录对应的c2列的值都是1,而我们新插入的这条记录的c2列的值也是1,那我们这条新插入的记录到底应该放到页4中,还是应该放到页5中啊?答案是:对不起,懵逼了。\n为了让新插入记录能找到自己在那个页里,我们需要保证在B+树的同一层内节点的目录项记录除页号这个字段以外是唯一的。所以对于二级索引的内节点的目录项记录的内容实际上是由三个部分构成的:\n索引列的值 主键值 页号 也就是我们把主键值也添加到二级索引内节点中的目录项记录了,这样就能保证B+树每一层节点中各条目录项记录除页号这个字段外是唯一的,所以我们为c2列建立二级索引后的示意图实际上应该是这样子的:\n这样我们再插入记录(9, 1, 'c')时,由于页3中存储的目录项记录是由c2列 + 主键 + 页号的值构成的,可以先把新记录的c2列的值和页3中各目录项记录的c2列的值作比较,如果c2列的值相同的话,可以接着比较主键值,因为B+树同一层中不同目录项记录的c2列 + 主键的值肯定是不一样的,所以最后肯定能定位唯一的一条目录项记录,在本例中最后确定新记录应该被插入到页5中。\n一个页面最少存储2条记录 # 我们前面说过一个B+树只需要很少的层级就可以轻松存储数亿条记录,查询速度杠杠的!这是因为B+树本质上就是一个大的多层级目录,每经过一个目录时都会过滤掉许多无效的子目录,直到最后访问到存储真实数据的目录。那如果一个大的目录中只存放一个子目录是什么效果呢?那就是目录层级非常非常非常多,而且最后的那个存放真实数据的目录中只能存放一条记录。费了半天劲只能存放一条真实的用户记录?逗我呢?所以InnoDB的一个数据页至少可以存放两条记录,这也是我们之前介绍记录行格式的时候说过一个结论(我们当时依据这个结论推导了表中只有一个列时该列在不发生行溢出的情况下最多能存储多少字节,忘了的话回去看看吧)。\nMyISAM中的索引方案简单介绍 # 至此,我们介绍的都是InnoDB存储引擎中的索引方案,为了内容的完整性,以及各位可能在面试的时候遇到这类的问题,我们有必要再简单介绍一下MyISAM存储引擎中的索引方案。我们知道InnoDB中索引即数据,也就是聚簇索引的那棵B+树的叶子节点中已经把所有完整的用户记录都包含了,而MyISAM的索引方案虽然也使用树形结构,但是却将索引和数据分开存储:\n将表中的记录按照记录的插入顺序单独存储在一个文件中,称之为数据文件。这个文件并不划分为若干个数据页,有多少记录就往这个文件中塞多少记录就成了。我们可以通过行号而快速访问到一条记录。\nMyISAM记录也需要记录头信息来存储一些额外数据,我们以上面介绍过的index_demo表为例,看一下这个表中的记录使用MyISAM作为存储引擎在存储空间中的表示:\n由于在插入数据的时候并没有刻意按照主键大小排序,所以我们并不能在这些数据上使用二分法进行查找。\n使用MyISAM存储引擎的表会把索引信息另外存储到一个称为索引文件的另一个文件中。MyISAM会单独为表的主键创建一个索引,只不过在索引的叶子节点中存储的不是完整的用户记录,而是主键值 + 行号的组合。也就是先通过索引找到对应的行号,再通过行号去找对应的记录!\n这一点和InnoDB是完全不相同的,在InnoDB存储引擎中,我们只需要根据主键值对聚簇索引进行一次查找就能找到对应的记录,而在MyISAM中却需要进行一次回表操作,意味着**MyISAM中建立的索引相当于全部都是二级索引**!\n如果有需要的话,我们也可以对其它的列分别建立索引或者建立联合索引,原理和InnoDB中的索引差不多,不过在叶子节点处存储的是相应的列 + 行号。这些索引也全部都是二级索引。 小贴士:MyISAM的行格式有定长记录格式(Static)、变长记录格式(Dynamic)、压缩记录格式(Compressed)。上面用到的index_demo表采用定长记录格式,也就是一条记录占用存储空间的大小是固定的,这样就可以轻松算出某条记录在数据文件中的地址偏移量。但是变长记录格式就不行了,MyISAM会直接在索引叶子节点处存储该条记录在数据文件中的地址偏移量。通过这个可以看出,MyISAM的回表操作是十分快速的,因为是拿着地址偏移量直接到文件中取数据的,反观InnoDB是通过获取主键之后再去聚簇索引里边儿找记录,虽然说也不慢,但还是比不上直接用地址去访问。 此处我们只是非常简要的介绍了一下MyISAM的索引,具体细节全拿出来又可以写一篇文章了。这里只是希望大家理解InnoDB中的索引即数据,数据即索引,而MyISAM中却是索引是索引、数据是数据。\nMySQL中创建和删除索引的语句 # 光顾着介绍索引的原理了,那我们如何使用MySQL语句去建立这种索引呢?InnoDB和MyISAM会自动为主键或者声明为UNIQUE的列去自动建立B+树索引,但是如果我们想为其他的列建立索引就需要我们显式的去指明。为什么不自动为每个列都建立个索引呢?别忘了,每建立一个索引都会建立一棵B+树,每插入一条记录都要维护各个记录、数据页的排序关系,这是很费性能和存储空间的。\n我们可以在创建表的时候指定需要建立索引的单个列或者建立联合索引的多个列: CREATE TALBE 表名 ( 各种列的信息 ··· , [KEY|INDEX] 索引名 (需要被索引的单个列或多个列) ) 其中的KEY和INDEX是同义词,任意选用一个就可以。我们也可以在修改表结构的时候添加索引: ALTER TABLE 表名 ADD [INDEX|KEY] 索引名 (需要被索引的单个列或多个列); 也可以在修改表结构的时候删除索引: ALTER TABLE 表名 DROP [INDEX|KEY] 索引名; 比方说我们想在创建index_demo表的时候就为c2和c3列添加一个联合索引,可以这么写建表语句: CREATE TABLE index_demo( c1 INT, c2 INT, c3 CHAR(1), PRIMARY KEY(c1), INDEX idx_c2_c3 (c2, c3) ); 在这个建表语句中我们创建的索引名是idx_c2_c3,这个名称可以随便起,不过我们还是建议以idx_为前缀,后边跟着需要建立索引的列名,多个列名之间用下划线_分隔开。\n如果我们想删除这个索引,可以这么写: ALTER TABLE index_demo DROP INDEX idx_c2_c3;\n"},{"id":27,"href":"/zh/docs/technology/MySQL/MySQL%E6%98%AF%E6%80%8E%E6%A0%B7%E8%BF%90%E8%A1%8C%E7%9A%84/%E7%AC%AC5%E7%AB%A0-%E7%9B%9B%E6%94%BE%E8%AE%B0%E5%BD%95%E7%9A%84%E5%A4%A7%E7%9B%92%E5%AD%90-InnoDB%E6%95%B0%E6%8D%AE%E9%A1%B5%E7%BB%93%E6%9E%84/","title":"第5章 盛放记录的大盒子-InnoDB数据页结构","section":"My Sql是怎样运行的","content":"第5章 盛放记录的大盒子-InnoDB数据页结构\n不同类型的页简介 # 前面我们简单提了一下页的概念,它是InnoDB管理存储空间的基本单位,一个页的大小一般是16KB。InnoDB为了不同的目的而设计了许多种不同类型的页,比如存放表空间头部信息的页,存放Insert Buffer信息的页,存放INODE信息的页,存放undo日志信息的页等等等等。当然了,如果我说的这些名词你一个都没有听过,就当我放了个屁吧~ 不过这没有一毛钱关系,我们今儿个也不准备说这些类型的页,我们聚焦的是那些存放我们表中记录的那种类型的页,官方称这种存放记录的页为索引(INDEX)页,鉴于我们还没有了解过索引是个什么东西,而这些表中的记录就是我们日常口中所称的数据,所以目前还是叫这种存放记录的页为数据页吧。\n数据页结构的快速浏览 # 数据页代表的这块16KB大小的存储空间可以被划分为多个部分,不同部分有不同的功能,各个部分如图所示:\n从图中可以看出,一个InnoDB数据页的存储空间大致被划分成了7个部分,有的部分占用的字节数是确定的,有的部分占用的字节数是不确定的。下面我们用表格的方式来大致描述一下这7个部分都存储一些什么内容(快速的瞅一眼就行了,后边会详细介绍的): 名称 中文名 占用空间大小 简单描述 File Header 文件头部 38字节 页的一些通用信息 Page Header 页面头部 56字节 数据页专有的一些信息 Infimum + Supremum 最小记录和最大记录 26字节 两个虚拟的行记录 User Records 用户记录 不确定 实际存储的行记录内容 Free Space 空闲空间 不确定 页中尚未使用的空间 Page Directory 页面目录 不确定 页中的某些记录的相对位置 File Trailer 文件尾部 8字节 校验页是否完整 小贴士:我们接下来并不打算按照页中各个部分的出现顺序来依次介绍它们,因为各个部分中会出现很多大家目前不理解的概念,这会打击各位读文章的信心与兴趣,希望各位能接受这种拍摄手法~\n记录在页中的存储 # 在页的7个组成部分中,我们自己存储的记录会按照我们指定的行格式存储到User Records部分。但是在一开始生成页的时候,其实并没有User Records这个部分,每当我们插入一条记录,都会从Free Space部分,也就是尚未使用的存储空间中申请一个记录大小的空间划分到User Records部分,当Free Space部分的空间全部被User Records部分替代掉之后,也就意味着这个页使用完了,如果还有新的记录插入的话,就需要去申请新的页了,这个过程的图示如下:\n为了更好的管理在User Records中的这些记录,InnoDB可费了一番力气呢,在哪费力气了呢?不就是把记录按照指定的行格式一条一条摆在User Records部分么?其实这话还得从记录行格式的记录头信息中说起。\n记录头信息的秘密 # 为了故事的顺利发展,我们先创建一个表: mysql\u0026gt; CREATE TABLE page_demo( -\u0026gt; c1 INT, -\u0026gt; c2 INT, -\u0026gt; c3 VARCHAR(10000), -\u0026gt; PRIMARY KEY (c1) -\u0026gt; ) CHARSET=ascii ROW_FORMAT=Compact; Query OK, 0 rows affected (0.03 sec) 这个新创建的page_demo表有3个列,其中c1和c2列是用来存储整数的,c3列是用来存储字符串的。需要注意的是,我们把 c1 列指定为主键,所以在具体的行格式中InnoDB就没必要为我们去创建那个所谓的 row_id 隐藏列了。而且我们为这个表指定了ascii字符集以及Compact的行格式。所以这个表中记录的行格式示意图就是这样的:\n从图中可以看到,我们特意把记录头信息的5个字节的数据给标出来了,说明它很重要,我们再次先把这些记录头信息中各个属性的大体意思浏览一下(我们目前使用Compact行格式进行演示): 名称 大小(单位:bit) 描述 预留位1 1 没有使用 预留位2 1 没有使用 delete_mask 1 标记该记录是否被删除 min_rec_mask 1 B+树的每层非叶子节点中的最小记录都会添加该标记 n_owned 4 表示当前记录拥有的记录数 heap_no 13 表示当前记录在记录堆的位置信息 record_type 3 表示当前记录的类型,0表示普通记录,1表示B+树非叶节点记录,2表示最小记录,3表示最大记录 next_record 16 表示下一条记录的相对位置 由于我们现在主要在介绍记录头信息的作用,所以为了大家理解上的方便,我们只在page_demo表的行格式演示图中画出有关的头信息属性以及c1、c2、c3列的信息(其他信息没画不代表它们不存在啊,只是为了理解上的方便在图中省略了~),简化后的行格式示意图就是这样:\n下面我们试着向page_demo表中插入几条记录: mysql\u0026gt; INSERT INTO page_demo VALUES(1, 100, 'aaaa'), (2, 200, 'bbbb'), (3, 300, 'cccc'), (4, 400, 'dddd'); Query OK, 4 rows affected (0.00 sec) Records: 4 Duplicates: 0 Warnings: 0 为了方便大家分析这些记录在页的User Records部分中是怎么表示的,我把记录中头信息和实际的列数据都用十进制表示出来了(其实是一堆二进制位),所以这些记录的示意图就是:\n看这个图的时候需要注意一下,各条记录在User Records中存储的时候并没有空隙,这里只是为了大家观看方便才把每条记录单独画在一行中。我们对照着这个图来看看记录头信息中的各个属性是什么意思:\ndelete_mask\n这个属性标记着当前记录是否被删除,占用1个二进制位,值为0的时候代表记录并没有被删除,为1的时候代表记录被删除掉了。\n什么?被删除的记录还在页中么?是的,摆在台面上的和背地里做的可能大相径庭,你以为它删除了,可它还在真实的磁盘上[摊手](忽然想起冠希~)。这些被删除的记录之所以不立即从磁盘上移除,是因为移除它们之后把其他的记录在磁盘上重新排列需要性能消耗,所以只是打一个删除标记而已,所有被删除掉的记录都会组成一个所谓的垃圾链表,在这个链表中的记录占用的空间称之为所谓的可重用空间,之后如果有新记录插入到表中的话,可能把这些被删除的记录占用的存储空间覆盖掉。\n小贴士:将这个delete_mask位设置为1和将被删除的记录加入到垃圾链表中其实是两个阶段,我们后边在介绍事务的时候会详细介绍删除操作的详细过程,稍安勿躁。\nmin_rec_mask\nB+树的每层非叶子节点中的最小记录都会添加该标记,什么是个B+树?什么是个非叶子节点?好吧,等会再聊这个问题。反正我们自己插入的四条记录的min_rec_mask值都是0,意味着它们都不是B+树的非叶子节点中的最小记录。\nn_owned\n这个暂时保密,稍后它是主角~\nheap_no\n这个属性表示当前记录在本页中的位置,从图中可以看出来,我们插入的4条记录在本页中的位置分别是:2、3、4、5。是不是少了点什么?是的,怎么不见heap_no值为0和1的记录呢?\n这其实是设计InnoDB的大佬们玩的一个小把戏,他们自动给每个页里边儿加了两个记录,由于这两个记录并不是我们自己插入的,所以有时候也称为伪记录或者虚拟记录。这两个伪记录一个代表最小记录,一个代表最大记录,等一下~,记录可以比大小么?\n是的,记录也可以比大小,对于一条完整的记录来说,比较记录的大小就是比较主键的大小。比方说我们插入的4行记录的主键值分别是:1、2、3、4,这也就意味着这4条记录的大小从小到大依次递增。\n小贴士:请注意我强调了对于一条完整的记录来说,比较记录的大小就相当于比的是主键的大小。后边我们还会介绍只存储一条记录的部分列的情况,敬请期待~\n但是不管我们向页中插入了多少自己的记录,设计InnoDB的大佬们都规定他们定义的两条伪记录分别为最小记录与最大记录。这两条记录的构造十分简单,都是由5字节大小的记录头信息和8字节大小的一个固定的部分组成的,如图所示\n由于这两条记录不是我们自己定义的记录,所以它们并不存放在页的User Records部分,他们被单独放在一个称为Infimum + Supremum的部分,如图所示:\n从图中我们可以看出来,最小记录和最大记录的heap_no值分别是0和1,也就是说它们的位置最靠前。\nrecord_type\n这个属性表示当前记录的类型,一共有4种类型的记录,0表示普通记录,1表示B+树非叶节点记录,2表示最小记录,3表示最大记录。从图中我们也可以看出来,我们自己插入的记录就是普通记录,它们的record_type值都是0,而最小记录和最大记录的record_type值分别为2和3。\n至于record_type为1的情况,我们之后在说索引的时候会重点强调的。\nnext_record\n这玩意儿非常重要,它表示从当前记录的真实数据到下一条记录的真实数据的地址偏移量。比方说第一条记录的next_record值为32,意味着从第一条记录的真实数据的地址处向后找32个字节便是下一条记录的真实数据。如果你熟悉数据结构的话,就立即明白了,这其实是个链表,可以通过一条记录找到它的下一条记录。但是需要注意注意再注意的一点是,下一条记录指得并不是按照我们插入顺序的下一条记录,而是按照主键值由小到大的顺序的下一条记录。而且规定** Infimum记录(也就是最小记录) 的下一条记录就是本页中主键值最小的用户记录,而本页中主键值最大的用户记录的下一条记录就是 Supremum记录(也就是最大记录) **,为了更形象的表示一下这个next_record起到的作用,我们用箭头来替代一下next_record中的地址偏移量:\n从图中可以看出来,我们的记录按照主键从小到大的顺序形成了一个单链表。最大记录的next_record的值为0,这也就是说最大记录是没有下一条记录了,它是这个单链表中的最后一个节点。如果从中删除掉一条记录,这个链表也是会跟着变化的,比如我们把第2条记录删掉: mysql\u0026gt; DELETE FROM page_demo WHERE c1 = 2; Query OK, 1 row affected (0.02 sec) 删掉第2条记录后的示意图就是:\n从图中可以看出来,删除第2条记录前后主要发生了这些变化:\n+ 第2条记录并没有从存储空间中移除,而是把该条记录的`delete_mask`值设置为`1`。 + 第2条记录的`next_record`值变为了0,意味着该记录没有下一条记录了。 + 第1条记录的`next_record`指向了第3条记录。 + 还有一点你可能忽略了,就是`最大记录`的`n_owned`值从`5`变成了`4`,关于这一点的变化我们稍后会详细说明的。 所以,不论我们怎么对页中的记录做增删改操作,InnoDB始终会维护一条记录的单链表,链表中的各个节点是按照主键值由小到大的顺序连接起来的。\n小贴士:你会不会觉得next_record这个指针有点儿怪,为什么要指向记录头信息和真实数据之间的位置呢?为什么不干脆指向整条记录的开头位置,也就是记录的额外信息开头的位置呢?因为这个位置刚刚好,向左读取就是记录头信息,向右读取就是真实数据。我们前面还说过变长字段长度列表、NULL值列表中的信息都是逆序存放,这样可以使记录中位置靠前的字段和它们对应的字段长度信息在内存中的距离更近,可能会提高高速缓存的命中率。当然如果你看不懂这句话的话就不要勉强了,果断跳过~\n再来看一个有意思的事情,因为主键值为2的记录被我们删掉了,但是存储空间却没有回收,如果我们再次把这条记录插入到表中,会发生什么事呢? mysql\u0026gt; INSERT INTO page_demo VALUES(2, 200, 'bbbb'); Query OK, 1 row affected (0.00 sec) 我们看一下记录的存储情况:\n从图中可以看到,InnoDB并没有因为新记录的插入而为它申请新的存储空间,而是直接复用了原来被删除记录的存储空间。 小贴士:当数据页中存在多条被删除掉的记录时,这些记录的next_record属性将会把这些被删除掉的记录组成一个垃圾链表,以备之后重用这部分存储空间。\nPage Directory(页目录) # 现在我们了解了记录在页中按照主键值由小到大顺序串联成一个单链表,那如果我们想根据主键值查找页中的某条记录该咋办呢?比如说这样的查询语句: SELECT * FROM page_demo WHERE c1 = 3; 最笨的办法:从Infimum记录(最小记录)开始,沿着链表一直往后找,总有一天会找到(或者找不到[摊手]),在找的时候还能投机取巧,因为链表中各个记录的值是按照从小到大顺序排列的,所以当链表的某个节点代表的记录的主键值大于你想要查找的主键值时,你就可以停止查找了,因为该节点后边的节点的主键值依次递增。\n这个方法在页中存储的记录数量比较少的情况用起来也没什么问题,比方说现在我们的表里只有4条自己插入的记录,所以最多找4次就可以把所有记录都遍历一遍,但是如果一个页中存储了非常多的记录,这么查找对性能来说还是有损耗的,所以我们说这种遍历查找这是一个笨办法。但是设计InnoDB的大佬们是什么人,他们能用这么笨的办法么,当然是要设计一种更6的查找方式喽,他们从书的目录中找到了灵感。\n我们平常想从一本书中查找某个内容的时候,一般会先看目录,找到需要查找的内容对应的书的页码,然后到对应的页码查看内容。设计InnoDB的大佬们为我们的记录也制作了一个类似的目录,他们的制作过程是这样的:\n将所有正常的记录(包括最大和最小记录,不包括标记为已删除的记录)划分为几个组。\n每个组的最后一条记录(也就是组内最大的那条记录)的头信息中的n_owned属性表示该记录拥有多少条记录,也就是该组内共有几条记录。\n将每个组的最后一条记录的地址偏移量单独提取出来按顺序存储到靠近页的尾部的地方,这个地方就是所谓的Page Directory,也就是页目录(此时应该返回头看看页面各个部分的图)。页面目录中的这些地址偏移量被称为槽(英文名:Slot),所以这个页面目录就是由槽组成的。\n比方说现在的page_demo表中正常的记录共有6条,InnoDB会把它们分成两组,第一组中只有一个最小记录,第二组中是剩余的5条记录,看下面的示意图:\n从这个图中我们需要注意这么几点:\n现在页目录部分中有两个槽,也就意味着我们的记录被分成了两个组,槽1中的值是112,代表最大记录的地址偏移量(就是从页面的0字节开始数,数112个字节);槽0中的值是99,代表最小记录的地址偏移量。\n注意最小和最大记录的头信息中的n_owned属性\n+ 最小记录的`n_owned`值为`1`,这就代表着以最小记录结尾的这个分组中只有`1`条记录,也就是最小记录本身。 + 最大记录的`n_owned`值为`5`,这就代表着以最大记录结尾的这个分组中只有`5`条记录,包括最大记录本身还有我们自己插入的`4`条记录。 99和112这样的地址偏移量很不直观,我们用箭头指向的方式替代数字,这样更易于我们理解,所以修改后的示意图就是这样:\n哎呀,咋看上去怪怪的,这么乱的图对于我这个强迫症真是不能忍,那我们就暂时不管各条记录在存储设备上的排列方式了,单纯从逻辑上看一下这些记录和页目录的关系:\n这样看就顺眼多了嘛!为什么最小记录的n_owned值为1,而最大记录的n_owned值为5呢,这里头有什么猫腻么?\n是的,设计InnoDB的大佬们对每个分组中的记录条数是有规定的:对于最小记录所在的分组只能有 1 条记录,最大记录所在的分组拥有的记录条数只能在 1~8 条之间,剩下的分组中记录的条数范围只能在是 4~8 条之间。所以分组是按照下面的步骤进行的:\n初始情况下一个数据页里只有最小记录和最大记录两条记录,它们分属于两个分组。\n之后每插入一条记录,都会从页目录中找到主键值比本记录的主键值大并且差值最小的槽,然后把该槽对应的记录的n_owned值加1,表示本组内又添加了一条记录,直到该组中的记录数等于8个。\n在一个组中的记录数等于8个后再插入一条记录时,会将组中的记录拆分成两个组,一个组中4条记录,另一个5条记录。这个过程会在页目录中新增一个槽来记录这个新增分组中最大的那条记录的偏移量。\n由于现在page_demo表中的记录太少,无法演示添加了页目录之后加快查找速度的过程,所以再往page_demo表中添加一些记录: mysql\u0026gt; INSERT INTO page_demo VALUES(5, 500, 'eeee'), (6, 600, 'ffff'), (7, 700, 'gggg'), (8, 800, 'hhhh'), (9, 900, 'iiii'), (10, 1000, 'jjjj'), (11, 1100, 'kkkk'), (12, 1200, 'llll'), (13, 1300, 'mmmm'), (14, 1400, 'nnnn'), (15, 1500, 'oooo'), (16, 1600, 'pppp'); Query OK, 12 rows affected (0.00 sec) Records: 12 Duplicates: 0 Warnings: 0 我们一口气又往表中添加了12条记录,现在页里边就一共有18条记录了(包括最小和最大记录),这些记录被分成了5个组,如图所示:\n因为把16条记录的全部信息都画在一张图里太占地方,让人眼花缭乱的,所以只保留了用户记录头信息中的n_owned和next_record属性,也省略了各个记录之间的箭头,我没画不等于没有啊!现在看怎么从这个页目录中查找记录。因为各个槽代表的记录的主键值都是从小到大排序的,所以我们可以使用所谓的二分法来进行快速查找。4个槽的编号分别是:0、1、2、3、4,所以初始情况下最低的槽就是low=0,最高的槽就是high=4。比方说我们想找主键值为6的记录,过程是这样的:\n计算中间槽的位置:(0+4)/2=2,所以查看槽2对应记录的主键值为8,又因为8 \u0026gt; 6,所以设置high=2,low保持不变。\n重新计算中间槽的位置:(0+2)/2=1,所以查看槽1对应的主键值为4,又因为4 \u0026lt; 6,所以设置low=1,high保持不变。\n因为high - low的值为1,所以确定主键值为5的记录在槽2对应的组中。此刻我们需要找到槽2中主键值最小的那条记录,然后沿着单向链表遍历槽2中的记录。但是我们前面又说过,每个槽对应的记录都是该组中主键值最大的记录,这里槽2对应的记录是主键值为8的记录,怎么定位一个组中最小的记录呢?别忘了各个槽都是挨着的,我们可以很轻易的拿到槽1对应的记录(主键值为4),该条记录的下一条记录就是槽2中主键值最小的记录,该记录的主键值为5。所以我们可以从这条主键值为5的记录出发,遍历槽2中的各条记录,直到找到主键值为6的那条记录即可。由于一个组中包含的记录条数只能是1~8条,所以遍历一个组中的记录的代价是很小的。\n所以在一个数据页中查找指定主键值的记录的过程分为两步:\n通过二分法确定该记录所在的槽,并找到该槽中主键值最小的那条记录。\n通过记录的next_record属性遍历该槽所在的组中的各个记录。\n小贴士:如果你不知道二分法是个什么东西,找个基础算法书看看吧。什么?算法书写的看不懂?等我~\nPage Header(页面头部) # 设计InnoDB的大佬们为了能得到一个数据页中存储的记录的状态信息,比如本页中已经存储了多少条记录,第一条记录的地址是什么,页目录中存储了多少个槽等等,特意在页中定义了一个叫Page Header的部分,它是页结构的第二部分,这个部分占用固定的56个字节,专门存储各种状态信息,具体各个字节都是干嘛的看下表: 名称 占用空间大小 描述 PAGE_N_DIR_SLOTS 2字节 在页目录中的槽数量 PAGE_HEAP_TOP 2字节 还未使用的空间最小地址,也就是说从该地址之后就是Free Space PAGE_N_HEAP 2字节 本页中的记录的数量(包括最小和最大记录以及标记为删除的记录) PAGE_FREE 2字节 第一个已经标记为删除的记录地址(各个已删除的记录通过next_record也会组成一个单链表,这个单链表中的记录可以被重新利用) PAGE_GARBAGE 2字节 已删除记录占用的字节数 PAGE_LAST_INSERT 2字节 最后插入记录的位置 PAGE_DIRECTION 2字节 记录插入的方向 PAGE_N_DIRECTION 2字节 一个方向连续插入的记录数量 PAGE_N_RECS 2字节 该页中记录的数量(不包括最小和最大记录以及被标记为删除的记录) PAGE_MAX_TRX_ID 8字节 修改当前页的最大事务ID,该值仅在二级索引中定义 PAGE_LEVEL 2字节 当前页在B+树中所处的层级 PAGE_INDEX_ID 8字节 索引ID,表示当前页属于哪个索引 PAGE_BTR_SEG_LEAF 10字节 B+树叶子段的头部信息,仅在B+树的Root页定义 PAGE_BTR_SEG_TOP 10字节 B+树非叶子段的头部信息,仅在B+树的Root页定义 如果大家认真看过前面的文章,从PAGE_N_DIR_SLOTS到PAGE_LAST_INSERT以及PAGE_N_RECS的意思大家一定是清楚的,如果不清楚,对不起,你应该回头再看一遍前面的文章。剩下的状态信息看不明白不要着急,饭要一口一口吃,东西要一点一点学(一定要稍安勿躁哦,不要被这些名词吓到)。在这里我们先介绍一下PAGE_DIRECTION和PAGE_N_DIRECTION的意思:\nPAGE_DIRECTION\n假如新插入的一条记录的主键值比上一条记录的主键值大,我们说这条记录的插入方向是右边,反之则是左边。用来表示最后一条记录插入方向的状态就是PAGE_DIRECTION。\nPAGE_N_DIRECTION\n假设连续几次插入新记录的方向都是一致的,InnoDB会把沿着同一个方向插入记录的条数记下来,这个条数就用PAGE_N_DIRECTION这个状态表示。当然,如果最后一条记录的插入方向改变了的话,这个状态的值会被清零重新统计。\n至于我们没提到的那些属性,我没说是因为现在不需要大家知道。不要着急,当我们学完了后边的内容,你再回头看,一切都是那么清晰。\n小贴士:说到这个有些东西后边我们学过后回头看就很清晰的事儿不禁让我想到了乔布斯在斯坦福大学的演讲,摆一下原文: “You can't connect the dots looking forward; you can only connect them looking backwards. So you have to trust that the dots will somehow connect in your future.You have to trust in something - your gut, destiny, life, karma, whatever. This approach has never let me down, and it has made all the difference in my life.” 上面这段话纯属心血来潮写的,大意是坚持做自己喜欢的事儿,你在做的时候可能并不能搞清楚这些事儿对自己之后的人生有什么影响,但当你一路走来回头看时,一切都是那么清晰,就像是命中注定的一样。上述内容跟MySQL毫无干系,请忽略~\nFile Header(文件头部) # 上面介绍的Page Header是专门针对数据页记录的各种状态信息,比方说页里头有多少个记录了呀,有多少个槽了呀。我们现在描述的File Header针对各种类型的页都通用,也就是说不同类型的页都会以File Header作为第一个组成部分,它描述了一些针对各种页都通用的一些信息,比方说这个页的编号是多少,它的上一个页、下一个页是谁啦等等~ 这个部分占用固定的38个字节,是由下面这些内容组成的: 名称 占用空间大小 描述 FIL_PAGE_SPACE_OR_CHKSUM 4字节 页的校验和(checksum值) FIL_PAGE_OFFSET 4字节 页号 FIL_PAGE_PREV 4字节 上一个页的页号 FIL_PAGE_NEXT 4字节 下一个页的页号 FIL_PAGE_LSN 8字节 页面被最后修改时对应的日志序列位置(英文名是:Log Sequence Number) FIL_PAGE_TYPE 2字节 该页的类型 FIL_PAGE_FILE_FLUSH_LSN 8字节 仅在系统表空间的一个页中定义,代表文件至少被刷新到了对应的LSN值 FIL_PAGE_ARCH_LOG_NO_OR_SPACE_ID 4字节 页属于哪个表空间 对照着这个表格,我们看几个目前比较重要的部分:\nFIL_PAGE_SPACE_OR_CHKSUM\n这个代表当前页面的校验和(checksum)。什么是个校验和?就是对于一个很长很长的字节串来说,我们会通过某种算法来计算一个比较短的值来代表这个很长的字节串,这个比较短的值就称为校验和。这样在比较两个很长的字节串之前先比较这两个长字节串的校验和,如果校验和都不一样两个长字节串肯定是不同的,所以省去了直接比较两个比较长的字节串的时间损耗。\nFIL_PAGE_OFFSET\n每一个页都有一个单独的页号,就跟你的身份证号码一样,InnoDB通过页号来可以唯一定位一个页。\nFIL_PAGE_TYPE\n这个代表当前页的类型,我们前面说过,InnoDB为了不同的目的而把页分为不同的类型,我们上面介绍的其实都是存储记录的数据页,其实还有很多别的类型的页,具体如下表:\n类型名称 十六进制 描述 `FIL_PAGE_TYPE_ALLOCATED` 0x0000 最新分配,还没使用 `FIL_PAGE_UNDO_LOG` 0x0002 Undo日志页 `FIL_PAGE_INODE` 0x0003 段信息节点 `FIL_PAGE_IBUF_FREE_LIST` 0x0004 Insert Buffer空闲列表 `FIL_PAGE_IBUF_BITMAP` 0x0005 Insert Buffer位图 `FIL_PAGE_TYPE_SYS` 0x0006 系统页 `FIL_PAGE_TYPE_TRX_SYS` 0x0007 事务系统数据 `FIL_PAGE_TYPE_FSP_HDR` 0x0008 表空间头部信息 `FIL_PAGE_TYPE_XDES` 0x0009 扩展描述页 `FIL_PAGE_TYPE_BLOB` 0x000A BLOB页 `FIL_PAGE_INDEX` 0x45BF 索引页,也就是我们所说的`数据页` 我们存放记录的数据页的类型其实是`FIL_PAGE_INDEX`,也就是所谓的`索引页`。至于什么是个索引,且听下回分解~ FIL_PAGE_PREV和FIL_PAGE_NEXT\n我们前面强调过,InnoDB都是以页为单位存放数据的,有时候我们存放某种类型的数据占用的空间非常大(比方说一张表中可以有成千上万条记录),InnoDB可能不可以一次性为这么多数据分配一个非常大的存储空间,如果分散到多个不连续的页中存储的话需要把这些页关联起来,FIL_PAGE_PREV和FIL_PAGE_NEXT就分别代表本页的上一个和下一个页的页号。这样通过建立一个双向链表把许许多多的页就都串联起来了,而无需这些页在物理上真正连着。需要注意的是,并不是所有类型的页都有上一个和下一个页的属性,不过我们本集中介绍的数据页(也就是类型为FIL_PAGE_INDEX的页)是有这两个属性的,所以所有的数据页其实是一个双链表,就像这样:\n关于File Header的其他属性我们暂时用不到,等用到的时候再提~\nFile Trailer # 我们知道InnoDB存储引擎会把数据存储到磁盘上,但是磁盘速度太慢,需要以页为单位把数据加载到内存中处理,如果该页中的数据在内存中被修改了,那么在修改后的某个时间需要把数据同步到磁盘中。但是在同步了一半的时候中断电了咋办,这不是莫名尴尬么?为了检测一个页是否完整(也就是在同步的时候有没有发生只同步一半的尴尬情况),设计InnoDB的大佬们在每个页的尾部都加了一个File Trailer部分,这个部分由8个字节组成,可以分成2个小部分:\n前4个字节代表页的校验和\n这个部分是和File Header中的校验和相对应的。每当一个页面在内存中修改了,在同步之前就要把它的校验和算出来,因为File Header在页面的前面,所以校验和会被首先同步到磁盘,当完全写完时,校验和也会被写到页的尾部,如果完全同步成功,则页的首部和尾部的校验和应该是一致的。如果写了一半儿断电了,那么在File Header中的校验和就代表着已经修改过的页,而在File Trialer中的校验和代表着原先的页,二者不同则意味着同步中间出了错。\n后4个字节代表页面被最后修改时对应的日志序列位置(LSN)\n这个部分也是为了校验页的完整性的,只不过我们目前还没说LSN是个什么意思,所以大家可以先不用管这个属性。\n这个File Trailer与File Header类似,都是所有类型的页通用的。\n总结 # InnoDB为了不同的目的而设计了不同类型的页,我们把用于存放记录的页叫做数据页。\n一个数据页可以被大致划分为7个部分,分别是\n+ `File Header`,表示页的一些通用信息,占固定的38字节。 + `Page Header`,表示数据页专有的一些信息,占固定的56个字节。 + `Infimum + Supremum`,两个虚拟的伪记录,分别表示页中的最小和最大记录,占固定的`26`个字节。 + `User Records`:真实存储我们插入的记录的部分,大小不固定。 + `Free Space`:页中尚未使用的部分,大小不确定。 + `Page Directory`:页中的某些记录相对位置,也就是各个槽在页面中的地址偏移量,大小不固定,插入的记录越多,这个部分占用的空间越多。 + `File Trailer`:用于检验页是否完整的部分,占用固定的8个字节。 每个记录的头信息中都有一个next_record属性,从而使页中的所有记录串联成一个单链表。\nInnoDB会为把页中的记录划分为若干个组,每个组的最后一个记录的地址偏移量作为一个槽,存放在Page Directory中,所以在一个页中根据主键查找记录是非常快的,分为两步:\n+ 通过二分法确定该记录所在的槽。\n+ 通过记录的next_record属性遍历该槽所在的组中的各个记录。\n每个数据页的File Header部分都有上一个和下一个页的编号,所以所有的数据页会组成一个双链表。\n为保证从内存中同步到磁盘的页的完整性,在页的首部和尾部都会存储页中数据的校验和和页面最后修改时对应的LSN值,如果首部和尾部的校验和和LSN值校验不成功的话,就说明同步过程出现了问题。\n"},{"id":28,"href":"/zh/docs/technology/MySQL/MySQL%E6%98%AF%E6%80%8E%E6%A0%B7%E8%BF%90%E8%A1%8C%E7%9A%84/%E7%AC%AC4%E7%AB%A0_%E4%BB%8E%E4%B8%80%E6%9D%A1%E8%AE%B0%E5%BD%95%E8%AF%B4%E8%B5%B7-InnoDB%E8%AE%B0%E5%BD%95%E7%BB%93%E6%9E%84/","title":"第4章_从一条记录说起-InnoDB记录结构","section":"My Sql是怎样运行的","content":"第4章 从一条记录说起-InnoDB记录结构\n准备工作 # 到现在为止,MySQL对于我们来说还是一个黑盒,我们只负责使用客户端发送请求并等待服务器返回结果,表中的数据到底存到了哪里?以什么格式存放的?MySQL是以什么方式来访问的这些数据?这些问题我们统统不知道,对于未知领域的探索向来就是社会主义核心价值观中的一部分,作为新一代社会主义接班人,不把它们搞懂怎么支援祖国建设呢?\n我们前面介绍请求处理过程的时候提到过,MySQL服务器上负责对表中数据的读取和写入工作的部分是存储引擎,而服务器又支持不同类型的存储引擎,比如InnoDB、MyISAM、Memory什么的,不同的存储引擎一般是由不同的人为实现不同的特性而开发的,真实数据在不同存储引擎中存放的格式一般是不同的,甚至有的存储引擎比如Memory都不用磁盘来存储数据,也就是说关闭服务器后表中的数据就消失了。由于InnoDB是MySQL默认的存储引擎,也是我们最常用到的存储引擎,我们也没有那么多时间去把各个存储引擎的内部实现都看一遍,所以本集要介绍的是使用InnoDB作为存储引擎的数据存储结构,了解了一个存储引擎的数据存储结构之后,其他的存储引擎都是依葫芦画瓢,等我们用到了再说。\nInnoDB页简介 # InnoDB是一个将表中的数据存储到磁盘上的存储引擎,所以即使关机后重启我们的数据还是存在的。而真正处理数据的过程是发生在内存中的,所以需要把磁盘中的数据加载到内存中,如果是处理写入或修改请求的话,还需要把内存中的内容刷新到磁盘上。而我们知道读写磁盘的速度非常慢,和内存读写差了几个数量级,所以当我们想从表中获取某些记录时,InnoDB存储引擎需要一条一条的把记录从磁盘上读出来么?不,那样会慢死,InnoDB采取的方式是:将数据划分为若干个页,以页作为磁盘和内存之间交互的基本单位,InnoDB中页的大小一般为 16 KB。也就是在一般情况下,一次最少从磁盘中读取16KB的内容到内存中,一次最少把内存中的16KB内容刷新到磁盘中。\nInnoDB行格式 # 我们平时是以记录为单位来向表中插入数据的,这些记录在磁盘上的存放方式也被称为行格式或者记录格式。设计InnoDB存储引擎的大佬们到现在为止设计了4种不同类型的行格式,分别是Compact、Redundant、Dynamic和Compressed行格式,随着时间的推移,他们可能会设计出更多的行格式,但是不管怎么变,在原理上大体都是相同的。\n指定行格式的语法 # 我们可以在创建或修改表的语句中指定行格式: ``` CREATE TABLE 表名 (列的信息) ROW_FORMAT=行格式名称\nALTER TABLE 表名 ROW_FORMAT=行格式名称 比如我们在xiaohaizi数据库里创建一个演示用的表record_format_demo,可以这样指定它的行格式: mysql\u0026gt; USE xiaohaizi; Database changed\nmysql\u0026gt; CREATE TABLE record_format_demo ( -\u0026gt; c1 VARCHAR 10),−\u0026gt;c2VARCHAR(10)NOTNULL,−\u0026gt;c3CHAR(10),−\u0026gt;c4VARCHAR(10)−\u0026gt;10), -\u0026gt; c2 VARCHAR(10) NOT NULL, -\u0026gt; c3 CHAR(10), -\u0026gt; c4 VARCHAR(10) -\u0026gt; CHARSET=ascii ROW_FORMAT=COMPACT; Query OK, 0 rows affected (0.03 sec) 可以看到我们刚刚创建的这个表的行格式就是Compact,另外,我们还显式指定了这个表的字符集为ascii,因为ascii字符集只包括空格、标点符号、数字、大小写字母和一些不可见字符,所以我们的汉字是不能存到这个表里的。我们现在向这个表中插入两条记录: mysql\u0026gt; INSERT INTO record_format_demo(c1, c2, c3, c4) VALUES(\u0026lsquo;aaaa\u0026rsquo;, \u0026lsquo;bbb\u0026rsquo;, \u0026lsquo;cc\u0026rsquo;, \u0026rsquo;d\u0026rsquo;), (\u0026rsquo;eeee\u0026rsquo;, \u0026lsquo;fff\u0026rsquo;, NULL, NULL); Query OK, 2 rows affected (0.02 sec) Records: 2 Duplicates: 0 Warnings: 0 现在表中的记录就是这个样子的: mysql\u0026gt; SELECT * FROM record_format_demo; +\u0026mdash;\u0026mdash;+\u0026mdash;\u0026ndash;+\u0026mdash;\u0026mdash;+\u0026mdash;\u0026mdash;+ | c1 | c2 | c3 | c4 | +\u0026mdash;\u0026mdash;+\u0026mdash;\u0026ndash;+\u0026mdash;\u0026mdash;+\u0026mdash;\u0026mdash;+ | aaaa | bbb | cc | d | | eeee | fff | NULL | NULL | +\u0026mdash;\u0026mdash;+\u0026mdash;\u0026ndash;+\u0026mdash;\u0026mdash;+\u0026mdash;\u0026mdash;+ 2 rows in set (0.00 sec)\nmysql\u0026gt; ``` 演示表的内容也填充好了,现在我们就来看看各个行格式下的存储方式到底有什么不同吧~\nCOMPACT行格式 # 废话不多说,直接看图:\n大家从图中可以看出来,一条完整的记录其实可以被分为记录的额外信息和记录的真实数据两大部分,下面我们详细看一下这两部分的组成。\n记录的额外信息 # 这部分信息是服务器为了描述这条记录而不得不额外添加的一些信息,这些额外信息分为3类,分别是变长字段长度列表、NULL值列表和记录头信息,我们分别看一下。\n变长字段长度列表 # 我们知道MySQL支持一些变长的数据类型,比如VARCHAR(M)、VARBINARY(M)、各种TEXT类型,各种BLOB类型,我们也可以把拥有这些数据类型的列称为变长字段,变长字段中存储多少字节的数据是不固定的,所以我们在存储真实数据的时候需要顺便把这些数据占用的字节数也存起来,这样才不至于把MySQL服务器搞懵,所以这些变长字段占用的存储空间分为两部分:\n真正的数据内容 占用的字节数 在Compact行格式中,把所有变长字段的真实数据占用的字节长度都存放在记录的开头部位,从而形成一个变长字段长度列表,各变长字段数据占用的字节数按照列的顺序逆序存放,我们再次强调一遍,是逆序存放!\n我们拿record_format_demo表中的第一条记录来举个例子。因为record_format_demo表的c1、c2、c4列都是VARCHAR(10)类型的,也就是变长的数据类型,所以这三个列的值的长度都需要保存在记录开头处,因为record_format_demo表中的各个列都使用的是ascii字符集,所以每个字符只需要1个字节来进行编码,来看一下第一条记录各变长字段内容的长度: 列名 存储内容 内容长度(十进制表示) 内容长度(十六进制表示) c1 'aaaa' 4 0x04 c2 'bbb' 3 0x03 c4 'd' 1 0x01 又因为这些长度值需要按照列的逆序存放,所以最后变长字段长度列表的字节串用十六进制表示的效果就是(各个字节之间实际上没有空格,用空格隔开只是方便理解): 01 03 04 把这个字节串组成的变长字段长度列表填入上面的示意图中的效果就是:\n由于第一行记录中c1、c2、c4列中的字符串都比较短,也就是说内容占用的字节数比较小,用1个字节就可以表示,但是如果变长列的内容占用的字节数比较多,可能就需要用2个字节来表示。具体用1个还是2个字节来表示真实数据占用的字节数,InnoDB有它的一套规则,我们首先声明一下W、M和L的意思:\n假设某个字符集中表示一个字符最多需要使用的字节数为W,也就是使用SHOW CHARSET语句的结果中的Maxlen列,比方说utf8字符集中的W就是3,gbk字符集中的W就是2,ascii字符集中的W就是1。\n对于变长类型VARCHAR(M)来说,这种类型表示能存储最多M个字符(注意是字符不是字节),所以这个类型能表示的字符串最多占用的字节数就是M×W。\n假设它实际存储的字符串占用的字节数是L。\n所以确定使用1个字节还是2个字节表示真正字符串占用的字节数的规则就是这样:\n如果M×W \u0026lt;= 255,那么使用1个字节来表示真正字符串占用的字节数。 就是说InnoDB在读记录的变长字段长度列表时先查看表结构,如果某个变长字段允许存储的最大字节数不大于255时,可以认为只使用1个字节来表示真正字符串占用的字节数。\n如果M×W \u0026gt; 255,则分为两种情况: 如果L \u0026lt;= 127,则用1个字节来表示真正字符串占用的字节数。 如果L \u0026gt; 127,则用2个字节来表示真正字符串占用的字节数。 InnoDB在读记录的变长字段长度列表时先查看表结构,如果某个变长字段允许存储的最大字节数大于255时,该怎么区分它正在读的某个字节是一个单独的字段长度还是半个字段长度呢?设计InnoDB的大佬使用该字节的第一个二进制位作为标志位:如果该字节的第一个位为0,那该字节就是一个单独的字段长度(使用一个字节表示不大于127的二进制的第一个位都为0),如果该字节的第一个位为1,那该字节就是半个字段长度。 对于一些占用字节数非常多的字段,比方说某个字段长度大于了16KB,那么如果该记录在单个页面中无法存储时,InnoDB会把一部分数据存放到所谓的溢出页中(我们后边会介绍),在变长字段长度列表处只存储留在本页面中的长度,所以使用两个字节也可以存放下来。\n总结一下就是说:如果该可变字段允许存储的最大字节数(M×W)超过255字节并且真实存储的字节数(L)超过127字节,则使用2个字节,否则使用1个字节。\n另外需要注意的一点是,**变长字段长度列表中只存储值为 非NULL 的列内容占用的长度,值为 NULL 的列的长度是不储存的 **。也就是说对于第二条记录来说,因为c4列的值为NULL,所以第二条记录的变长字段长度列表只需要存储c1和c2列的长度即可。其中c1列存储的值为'eeee',占用的字节数为4,c2列存储的值为'fff',占用的字节数为3。数字4可以用1个字节表示,3也可以用1个字节表示,所以整个变长字段长度列表共需2个字节。填充完变长字段长度列表的两条记录的对比图如下:\n小贴士:并不是所有记录都有这个 变长字段长度列表 部分,比方说表中所有的列都不是变长的数据类型的话,这一部分就不需要有。\nNULL值列表 # 我们知道表中的某些列可能存储NULL值,如果把这些NULL值都放到记录的真实数据中存储会很占地方,所以Compact行格式把这些值为NULL的列统一管理起来,存储到NULL值列表中,它的处理过程是这样的:\n首先统计表中允许存储NULL的列有哪些。\n我们前面说过,主键列、被NOT NULL修饰的列都是不可以存储NULL值的,所以在统计的时候不会把这些列算进去。比方说表record_format_demo的3个列c1、c3、c4都是允许存储NULL值的,而c2列是被NOT NULL修饰,不允许存储NULL值。\n如果表中没有允许存储 NULL 的列,则 NULL值列表 也不存在了,否则将每个允许存储NULL的列对应一个二进制位,二进制位按照列的顺序逆序排列,二进制位表示的意义如下:\n+ 二进制位的值为`1`时,代表该列的值为`NULL`。 + 二进制位的值为`0`时,代表该列的值不为`NULL`。 因为表record_format_demo有3个值允许为NULL的列,所以这3个列和二进制位的对应关系就是这样:\n再一次强调,二进制位按照列的顺序逆序排列,所以第一个列c1和最后一个二进制位对应。\nMySQL规定NULL值列表必须用整数个字节的位表示,如果使用的二进制位个数不是整数个字节,则在字节的高位补0。\n表record_format_demo只有3个值允许为NULL的列,对应3个二进制位,不足一个字节,所以在字节的高位补0,效果就是这样:\n以此类推,如果一个表中有9个允许为NULL,那这个记录的NULL值列表部分就需要2个字节来表示了。\n知道了规则之后,我们再返回头看表record_format_demo中的两条记录中的NULL值列表应该怎么储存。因为只有c1、c3、c4这3个列允许存储NULL值,所以所有记录的NULL值列表只需要一个字节。\n对于第一条记录来说,c1、c3、c4这3个列的值都不为NULL,所以它们对应的二进制位都是0,画个图就是这样:\n所以第一条记录的NULL值列表用十六进制表示就是:0x00。\n对于第二条记录来说,c1、c3、c4这3个列中c3和c4的值都为NULL,所以这3个列对应的二进制位的情况就是:\n所以第二条记录的NULL值列表用十六进制表示就是:0x06。\n所以这两条记录在填充了NULL值列表后的示意图就是这样:\n记录头信息 # 除了变长字段长度列表、NULL值列表之外,还有一个用于描述记录的记录头信息,它是由固定的5个字节组成。5个字节也就是40个二进制位,不同的位代表不同的意思,如图:\n这些二进制位代表的详细信息如下表: 名称 大小(单位:bit) 描述 预留位1 1 没有使用 预留位2 1 没有使用 delete_mask 1 标记该记录是否被删除 min_rec_mask 1 B+树的每层非叶子节点中的最小记录都会添加该标记 n_owned 4 表示当前记录拥有的记录数 heap_no 13 表示当前记录在记录堆的位置信息 record_type 3 表示当前记录的类型,0表示普通记录,1表示B+树非叶子节点记录,2表示最小记录,3表示最大记录 next_record 16 表示下一条记录的相对位置 大家不要被这么多的属性和陌生的概念给吓着,我这里只是为了内容的完整性把这些位代表的意思都写了出来,现在没必要把它们的意思都记住,记住也没什么用,现在只需要看一遍混个脸熟,等之后用到这些属性的时候我们再回过头来看。\n因为我们并不清楚这些属性详细的用法,所以这里就不分析各个属性值是怎么产生的了,之后我们遇到会详细看的。所以我们现在直接看一下record_format_demo中的两条记录的头信息分别是什么:\n小贴士:再一次强调,大家如果看不懂记录头信息里各个位代表的概念千万别纠结,我们后边会说的~\n记录的真实数据 # 对于record_format_demo表来说,记录的真实数据除了c1、c2、c3、c4这几个我们自己定义的列的数据以外,MySQL会为每个记录默认的添加一些列(也称为隐藏列),具体的列如下: 列名 是否必须 占用空间 描述 row_id 否 6字节 行ID,唯一标识一条记录 transaction_id 是 6字节 事务ID roll_pointer 是 7字节 回滚指针 小贴士:实际上这几个列的真正名称其实是:DB_ROW_ID、DB_TRX_ID、DB_ROLL_PTR,我们为了美观才写成了row_id、transaction_id和roll_pointer。\n这里需要提一下InnoDB表对主键的生成策略:优先使用用户自定义主键作为主键,如果用户没有定义主键,则选取一个Unique键作为主键,如果表中连Unique键都没有定义的话,则InnoDB会为表默认添加一个名为row_id的隐藏列作为主键。所以我们从上表中可以看出:InnoDB存储引擎会为每条记录都添加 transaction_id 和 roll_pointer 这两个列,但是 row_id 是可选的(在没有自定义主键以及Unique键的情况下才会添加该列)。这些隐藏列的值不用我们操心,InnoDB存储引擎会自己帮我们生成的。\n因为表record_format_demo并没有定义主键,所以MySQL服务器会为每条记录增加上述的3个列。现在看一下加上记录的真实数据的两个记录长什么样吧:\n看这个图的时候我们需要注意几点:\n表record_format_demo使用的是ascii字符集,所以0x61616161就表示字符串'aaaa',0x626262就表示字符串'bbb',以此类推。\n注意第1条记录中c3列的值,它是CHAR(10)类型的,它实际存储的字符串是:'cc',而ascii字符集中的字节表示是'0x6363',虽然表示这个字符串只占用了2个字节,但整个c3列仍然占用了10个字节的空间,除真实数据以外的8个字节的统统都用空格字符填充,空格字符在ascii字符集的表示就是0x20。\n注意第2条记录中c3和c4列的值都为NULL,它们被存储在了前面的NULL值列表处,在记录的真实数据处就不再冗余存储,从而节省存储空间。\nCHAR(M)列的存储格式 # record_format_demo表的c1、c2、c4列的类型是VARCHAR(10),而c3列的类型是CHAR(10),我们说在Compact行格式下只会把变长类型的列的长度逆序存到变长字段长度列表中,就像这样:\n但是这只是因为我们的record_format_demo表采用的是ascii字符集,这个字符集是一个定长字符集,也就是说表示一个字符采用固定的一个字节,如果采用变长的字符集(也就是表示一个字符需要的字节数不确定,比如gbk表示一个字符要1\\~2个字节、utf8表示一个字符要1\\~3个字节等)的话,c3列的长度也会被存储到变长字段长度列表中,比如我们修改一下record_format_demo表的字符集: mysql\u0026gt; ALTER TABLE record_format_demo MODIFY COLUMN c3 CHAR(10) CHARACTER SET utf8; Query OK, 2 rows affected (0.02 sec) Records: 2 Duplicates: 0 Warnings: 0 修改该列字符集后记录的变长字段长度列表也发生了变化,如图:\n这就意味着:对于 CHAR(M) 类型的列来说,当列采用的是定长字符集时,该列占用的字节数不会被加到变长字段长度列表,而如果采用变长字符集时,该列占用的字节数也会被加到变长字段长度列表。\n另外有一点还需要注意,变长字符集的CHAR(M)类型的列要求至少占用M个字节,而VARCHAR(M)却没有这个要求。比方说对于使用utf8字符集的CHAR(10)的列来说,该列存储的数据字节长度的范围是10~30个字节。即使我们向该列中存储一个空字符串也会占用10个字节,这是怕将来更新该列的值的字节长度大于原有值的字节长度而小于10个字节时,可以在该记录处直接更新,而不是在存储空间中重新分配一个新的记录空间,导致原有的记录空间成为所谓的碎片。(这里你感受到设计Compact行格式的大佬既想节省存储空间,又不想更新CHAR(M)类型的列产生碎片时的纠结心情了吧。)\nRedundant行格式 # 其实知道了Compact行格式之后,其他的行格式就是依葫芦画瓢了。我们现在要介绍的Redundant行格式是MySQL5.0之前用的一种行格式,也就是说它已经非常老了,但是本着知识完整性的角度还是要提一下,大家乐呵乐呵的看就好。\n画个图展示一下Redundant行格式的全貌:\n现在我们把表record_format_demo的行格式修改为Redundant: mysql\u0026gt; ALTER TABLE record_format_demo ROW_FORMAT=Redundant; Query OK, 0 rows affected (0.05 sec) Records: 0 Duplicates: 0 Warnings: 0 为了方便大家理解和节省篇幅,我们直接把表record_format_demo在Redundant行格式下的两条记录的真实存储数据提供出来,之后我们着重分析两种行格式的不同即可。\n下面我们从各个方面看一下Redundant行格式有什么不同的地方:\n字段长度偏移列表\n注意Compact行格式的开头是变长字段长度列表,而Redundant行格式的开头是字段长度偏移列表,与变长字段长度列表有两处不同:\n+ 没有了变长两个字,意味着Redundant行格式会把该条记录中所有列(包括隐藏列)的长度信息都按照逆序存储到字段长度偏移列表。\n+ 多了个偏移两个字,这意味着计算列值长度的方式不像Compact行格式那么直观,它是采用两个相邻数值的差值来计算各个列值的长度。\n比如第一条记录的字段长度偏移列表就是: 25 24 1A 17 13 0C 06 因为它是逆序排放的,所以按照列的顺序排列就是: 06 0C 13 17 1A 24 25 按照两个相邻数值的差值来计算各个列值的长度的意思就是: 第一列(row_id)的长度就是 0x06个字节,也就是6个字节。 第二列(transaction_id)的长度就是 (0x0C - 0x06)个字节,也就是6个字节。 第三列(roll_pointer)的长度就是 (0x13 - 0x0C)个字节,也就是7个字节。 第四列(c1)的长度就是 (0x17 - 0x13)个字节,也就是4个字节。 第五列(c2)的长度就是 (0x1A - 0x17)个字节,也就是3个字节。 第六列(c3)的长度就是 (0x24 - 0x1A)个字节,也就是10个字节。 第七列(c4)的长度就是 (0x25 - 0x24)个字节,也就是1个字节。 - 记录头信息\nRedundant行格式的记录头信息占用6字节,48个二进制位,这些二进制位代表的意思如下:\n名称 大小(单位:bit) 描述 `预留位1` `1` 没有使用 `预留位2` `1` 没有使用 `delete_mask` `1` 标记该记录是否被删除 `min_rec_mask` `1` B\\+树的每层非叶子节点中的最小记录都会添加该标记 `n_owned` `4` 表示当前记录拥有的记录数 `heap_no` `13` 表示当前记录在页面堆的位置信息 `n_field` `10` 表示记录中列的数量 `1byte_offs_flag` `1` 标记字段长度偏移列表中每个列对应的偏移量是使用1字节还是2字节表示的 `next_record` `16` 表示下一条记录的相对位置 第一条记录中的头信息是: 00 00 10 0F 00 BC 根据这六个字节可以计算出各个属性的值,如下: 预留位1:0x00 预留位2:0x00 delete_mask: 0x00 min_rec_mask: 0x00 n_owned: 0x00 heap_no: 0x02 n_field: 0x07 1byte_offs_flag: 0x01 next_record:0xBC 与Compact行格式的记录头信息对比来看,有两处不同: 1. Redundant 行格式多了 n_field 和 1byte_offs_flag 这两个属性。 2. Redundant 行格式没有 record_type 这个属性。\n1byte_offs_flag的值是怎么选择的\n字段长度偏移列表实质上是存储每个列中的值占用的空间在记录的真实数据处结束的位置,还是拿record_format_demo第一条记录为例,0x06代表第一个列在记录的真实数据第6个字节处结束,0x0C代表第二个列在记录的真实数据第12个字节处结束,0x13代表第三个列在记录的真实数据第19个字节处结束,等等等等,最后一个列对应的偏移量值为0x25,也就意味着最后一个列在记录的真实数据第37个字节处结束,也就意味着整条记录的真实数据实际上占用37个字节。\n我们前面说过每个列对应的偏移量可以占用1个字节或者2个字节来存储,那到底什么时候用1个字节,什么时候用2个字节呢?其实是根据该条Redundant行格式记录的真实数据占用的总大小来判断的:\n+ 当记录的真实数据占用的字节数不大于127(十六进制`0x7F`,二进制`01111111`)时,每个列对应的偏移量占用1个字节。 小贴士:如果整个记录的真实数据占用的存储空间都不大于127个字节,那么每个列对应的偏移量值肯定也就不大于127,也就可以使用1个字节来表示喽。\n+ 当记录的真实数据占用的字节数大于127,但不大于32767(十六进制0x7FFF,二进制0111111111111111)时,每个列对应的偏移量占用2个字节。\n+ 有没有记录的真实数据大于32767的情况呢?有,不过此时的记录已经存放到了溢出页中,在本页中只保留前768个字节和20个字节的溢出页面地址(当然这20个字节中还记录了一些别的信息)。因为字段长度偏移列表处只需要记录每个列在本页面中的偏移就好了,所以每个列使用2个字节来存储偏移量就够了。\n大家可以看出来,设计Redundant行格式的大佬还是比较简单粗暴的,直接使用整个记录的真实数据长度来决定使用1个字节还是2个字节存储列对应的偏移量。只要整条记录的真实数据占用的存储空间大小大于127,即使第一个列的值占用存储空间小于127,那对不起,也需要使用2个字节来表示该列对应的偏移量。简单粗暴,就是这么简单粗暴(所以这种行格式有些过时了~)。\n小贴士:大家有没有疑惑,一个字节能表示的范围是0~255,为什么在记录的真实数据占用的存储空间大于127时就采用2个字节表示各个列的偏移量呢?稍安勿躁,后边马上揭晓。\n为了在解析记录时知道每个列的偏移量是使用1个字节还是2个字节表示的,设计Redundant行格式的大佬特意在记录头信息里放置了一个称之为1byte_offs_flag的属性:\n当它的值为1时,表明使用1个字节存储。 - 当它的值为0时,表明使用2个字节存储。 Redundant行格式中NULL值的处理\n因为Redundant行格式并没有NULL值列表,所以设计Redundant行格式的大佬在字段长度偏移列表中的各个列对应的偏移量处做了一些特殊处理 —— 将列对应的偏移量值的第一个比特位作为是否为NULL的依据,该比特位也可以被称之为NULL比特位。也就是说在解析一条记录的某个列时,首先看一下该列对应的偏移量的NULL比特位是不是为1,如果为1,那么该列的值就是NULL,否则不是NULL。\n这也就解释了上面介绍为什么只要记录的真实数据大于127(十六进制0x7F,二进制01111111)时,就采用2个字节来表示一个列对应的偏移量,主要是第一个比特位是所谓的NULL比特位,用来标记该列的值是否为NULL。\n但是还有一点要注意,对于值为NULL的列来说,该列的类型是否为定长类型决定了NULL值的实际存储方式,我们接下来分析一下record_format_demo表的第二条记录,它对应的字段长度偏移列表如下:\nA4 A4 1A 17 13 0C 06 按照列的顺序排放就是: 06 0C 13 17 1A A4 A4\n我们分情况看一下:\n+ 如果存储NULL值的字段是定长类型的,比方说CHAR(M)数据类型的,则NULL值也将占用记录的真实数据部分,并把该字段对应的数据使用0x00字节填充。\n如图第二条记录的c3列的值是NULL,而c3列的类型是CHAR(10),占用记录的真实数据部分10字节,所以我们看到在Redundant行格式中使用0x00000000000000000000来表示NULL值。\n另外,c3列对应的偏移量为0xA4,它对应的二进制实际是:10100100,可以看到最高位为1,意味着该列的值是NULL。将最高位去掉后的值变成了0100100,对应的十进制值为36,而c2列对应的偏移量为0x1A,也就是十进制的26。36 - 26 = 10,也就是说最终c3列占用的存储空间为10个字节。\n+ 如果该存储NULL值的字段是变长数据类型的,则不在记录的真实数据处占用任何存储空间。\n比如record_format_demo表的c4列是VARCHAR(10)类型的,VARCHAR(10)是一个变长数据类型,c4列对应的偏移量为0xA4,与c3列对应的偏移量相同,这也就意味着它的值也为NULL,将0xA4的最高位去掉后对应的十进制值也是36,36 - 36 = 0,也就意味着c4列本身不占用任何记录的实际数据处的空间。\n除了以上的几点之外,Redundant行格式和Compact行格式还是大致相同的。\nCHAR(M)列的存储格式 # 我们知道Compact行格式在CHAR(M)类型的列中存储数据的时候还挺麻烦,分变长字符集和定长字符集的情况,而在Redundant行格式中十分干脆,不管该列使用的字符集是什么,只要是使用CHAR(M)类型,占用的真实数据空间就是该字符集表示一个字符最多需要的字节数和M的乘积。比方说使用utf8字符集的CHAR(10)类型的列占用的真实数据空间始终为30个字节,使用gbk字符集的CHAR(10)类型的列占用的真实数据空间始终为20个字节。由此可以看出来,使用Redundant行格式的CHAR(M)类型的列是不会产生碎片的。\n行溢出数据 # VARCHAR(M)最多能存储的数据 # 我们知道对于VARCHAR(M)类型的列最多可以占用65535个字节。其中的M代表该类型最多存储的字符数量,如果我们使用ascii字符集的话,一个字符就代表一个字节,我们看看VARCHAR(65535)是否可用:\nmysql\u0026gt; CREATE TABLE varchar_size_demo( -\u0026gt; c VARCHAR(65535) -\u0026gt; ) CHARSET=ascii ROW_FORMAT=Compact; ERROR 1118 (42000): Row size too large. The maximum row size for the used table type, not counting BLOBs, is 65535. This includes storage overhead, check the manual. You have to change some columns to TEXT or BLOBs mysql\u0026gt; 从报错信息里可以看出,MySQL对一条记录占用的最大存储空间是有限制的,除了BLOB或者TEXT类型的列之外,其他所有的列(不包括隐藏列和记录头信息)占用的字节长度加起来不能超过65535个字节。所以MySQL服务器建议我们把存储类型改为TEXT或者BLOB的类型。这个65535个字节除了列本身的数据之外,还包括一些其他的数据(storage overhead),比如说我们为了存储一个VARCHAR(M)类型的列,其实需要占用3部分存储空间:\n真实数据 真实数据占用字节的长度 NULL值标识,如果该列有NOT NULL属性则可以没有这部分存储空间 如果该VARCHAR类型的列没有NOT NULL属性,那最多只能存储65532个字节的数据,因为真实数据的长度可能占用2个字节,NULL值标识需要占用1个字节: mysql\u0026gt; CREATE TABLE varchar_size_demo( -\u0026gt; c VARCHAR(65532) -\u0026gt; ) CHARSET=ascii ROW_FORMAT=Compact; Query OK, 0 rows affected (0.02 sec) 如果VARCHAR类型的列有NOT NULL属性,那最多只能存储65533个字节的数据,因为真实数据的长度可能占用2个字节,不需要NULL值标识: ``` mysql\u0026gt; DROP TABLE varchar_size_demo; Query OK, 0 rows affected (0.01 sec)\nmysql\u0026gt; CREATE TABLE varchar_size_demo( -\u0026gt; c VARCHAR 65533)NOTNULL−\u0026gt;65533) NOT NULL -\u0026gt; CHARSET=ascii ROW_FORMAT=Compact; Query OK, 0 rows affected (0.02 sec) 如果VARCHAR(M)类型的列使用的不是ascii字符集,那会怎么样呢?来看一下: mysql\u0026gt; DROP TABLE varchar_size_demo; Query OK, 0 rows affected (0.00 sec)\nmysql\u0026gt; CREATE TABLE varchar_size_demo( -\u0026gt; c VARCHAR 65532)−\u0026gt;65532) -\u0026gt; CHARSET=gbk ROW_FORMAT=Compact; ERROR 1074 (42000): Column length too big for column \u0026lsquo;c\u0026rsquo; (max = 32767); use BLOB or TEXT instead\nmysql\u0026gt; CREATE TABLE varchar_size_demo( -\u0026gt; c VARCHAR 65532)−\u0026gt;65532) -\u0026gt; CHARSET=utf8 ROW_FORMAT=Compact; ERROR 1074 (42000): Column length too big for column \u0026lsquo;c\u0026rsquo; (max = 21845); use BLOB or TEXT instead 从执行结果中可以看出,如果VARCHAR(M)类型的列使用的不是ascii字符集,那M的最大取值取决于该字符集表示一个字符最多需要的字节数。在列的值允许为NULL的情况下,gbk字符集表示一个字符最多需要2个字节,那在该字符集下,M的最大取值就是32766(也就是:65532/2),也就是说最多能存储32766个字符;utf8字符集表示一个字符最多需要3个字节,那在该字符集下,M的最大取值就是21844,就是说最多能存储21844(也就是:65532/3)个字符。 小贴士:上述所言在列的值允许为NULL的情况下,gbk字符集下M的最大取值就是32766,utf8字符集下M的最大取值就是21844,这都是在表中只有一个字段的情况下说的,一定要记住一个行中的所有列(不包括隐藏列和记录头信息)占用的字节长度加起来不能超过65535个字节! ```\n记录中的数据太多产生的溢出 # 我们以ascii字符集下的varchar_size_demo表为例,插入一条记录: ``` mysql\u0026gt; CREATE TABLE varchar_size_demo( -\u0026gt; c VARCHAR 65532)−\u0026gt;65532) -\u0026gt; CHARSET=ascii ROW_FORMAT=Compact; Query OK, 0 rows affected (0.01 sec)\nmysql\u0026gt; INSERT INTO varchar_size_demo(c) VALUES(REPEAT ′a′,65532)\u0026#x27;a\u0026#x27;, 65532) ; Query OK, 1 row affected (0.00 sec) ``` 其中的REPEAT('a', 65532)是一个函数调用,它表示生成一个把字符'a'重复65532次的字符串。前面说过,MySQL中磁盘和内存交互的基本单位是页,也就是说MySQL是以页为基本单位来管理存储空间的,我们的记录都会被分配到某个页中存储。而一个页的大小一般是16KB,也就是16384字节,而一个VARCHAR(M)类型的列就最多可以存储65532个字节,这样就可能造成一个页存放不了一条记录的尴尬情况。\n在Compact和Reduntant行格式中,对于占用存储空间非常大的列,在记录的真实数据处只会存储该列的一部分数据,把剩余的数据分散存储在几个其他的页中,然后记录的真实数据处用20个字节存储指向这些页的地址(当然这20个字节中还包括这些分散在其他页面中的数据的占用的字节数),从而可以找到剩余数据所在的页,如图所示:\n从图中可以看出来,对于Compact和Reduntant行格式来说,如果某一列中的数据非常多的话,在本记录的真实数据处只会存储该列的前768个字节的数据和一个指向其他页的地址,然后把剩下的数据存放到其他页中,这个过程也叫做行溢出,存储超出768字节的那些页面也被称为溢出页。画一个简图就是这样:\n最后需要注意的是,不只是 VARCHAR(M) 类型的列,其他的 TEXT、BLOB 类型的列在存储数据非常多的时候也会发生行溢出。\n行溢出的临界点 # 那发生行溢出的临界点是什么呢?也就是说在列存储多少字节的数据时就会发生行溢出?\nMySQL中规定一个页中至少存放两行记录,至于为什么这么规定我们之后再说,现在看一下这个规定造成的影响。以上面的varchar_size_demo表为例,它只有一个列c,我们往这个表中插入两条记录,每条记录最少插入多少字节的数据才会行溢出的现象呢?这得分析一下页中的空间都是如何利用的。\n每个页除了存放我们的记录以外,也需要存储一些额外的信息,乱七八糟的额外信息加起来需要136个字节的空间(现在只要知道这个数字就好了),其他的空间都可以被用来存储记录。\n每个记录需要的额外信息是27字节。\n这27个字节包括下面这些部分: - 2个字节用于存储真实数据的长度 - 1个字节用于存储列是否是NULL值 - 5个字节大小的头信息 - 6个字节的row_id列 - 6个字节的transaction_id列 - 7个字节的roll_pointer列\n假设一个列中存储的数据字节数为n,那么发生行溢出现象时需要满足这个式子: 136 + 2×(27 + n) \u0026gt; 16384 求解这个式子得出的解是:n \u0026gt; 8098。也就是说如果一个列中存储的数据不大于8098个字节,那就不会发生行溢出,否则就会发生行溢出。不过这个8098个字节的结论只是针对只有一个列的varchar_size_demo表来说的,如果表中有多个列,那上面的式子和结论都需要改一改了,所以重点就是:你不用关注这个临界点是什么,只要知道如果我们向一个行中存储了很大的数据时,可能发生行溢出的现象。\nDynamic和Compressed行格式 # 下面要介绍另外两个行格式,Dynamic和Compressed行格式,我现在使用的MySQL版本是5.7,它的默认行格式就是Dynamic,这俩行格式和Compact行格式挺像,只不过在处理行溢出数据时有点儿分歧,它们不会在记录的真实数据处存储字段真实数据的前768个字节,而是把所有的字节都存储到其他页面中,只在记录的真实数据处存储其他页面的地址,就像这样:\nCompressed行格式和Dynamic不同的一点是,Compressed行格式会采用压缩算法对页面进行压缩,以节省空间。\n总结 # 页是MySQL中磁盘和内存交互的基本单位,也是MySQL是管理存储空间的基本单位。\n指定和修改行格式的语法如下:\nALTER TABLE 表名 ROW_FORMAT=行格式名称 ``` 3.`InnoDB`目前定义了4种行格式 + COMPACT行格式 具体组成如图: ![](img/04-19.png) + Redundant行格式 具体组成如图: ![](img/04-20.png) + Dynamic和Compressed行格式 这两种行格式类似于`COMPACT行格式`,只不过在处理行溢出数据时有点儿分歧,它们不会在记录的真实数据处存储字符串的前768个字节,而是把所有的字节都存储到其他页面中,只在记录的真实数据处存储其他页面的地址。 另外,`Compressed`行格式会采用压缩算法对页面进行压缩。 3. 一个页一般是`16KB`,当记录中的数据太多,当前页放不下的时候,会把多余的数据存储到其他页中,这种现象称为`行溢出`。 "},{"id":29,"href":"/zh/docs/technology/MySQL/MySQL%E6%98%AF%E6%80%8E%E6%A0%B7%E8%BF%90%E8%A1%8C%E7%9A%84/%E7%AC%AC3%E7%AB%A0_%E4%B9%B1%E7%A0%81%E7%9A%84%E5%89%8D%E4%B8%96%E4%BB%8A%E7%94%9F-%E5%AD%97%E7%AC%A6%E9%9B%86%E5%92%8C%E6%AF%94%E8%BE%83%E8%A7%84%E5%88%99/","title":"第3章_乱码的前世今生-字符集和比较规则","section":"My Sql是怎样运行的","content":"第3章 乱码的前世今生-字符集和比较规则\n字符集和比较规则简介 # 字符集简介 # 我们知道在计算机中只能存储二进制数据,那该怎么存储字符串呢?当然是建立字符与二进制数据的映射关系了,建立这个关系最起码要搞清楚两件事儿:\n你要把哪些字符映射成二进制数据? 也就是界定清楚字符范围。 怎么映射? 将一个字符映射成一个二进制数据的过程也叫做编码,将一个二进制数据映射到一个字符的过程叫做解码。 人们抽象出一个字符集的概念来描述某个字符范围的编码规则。比方说我们来自定义一个名称为xiaohaizi的字符集,它包含的字符范围和编码规则如下:\n包含字符'a'、'b'、'A'、'B'。\n编码规则如下:\n采用1个字节编码一个字符的形式,字符和字节的映射关系如下: 'a' -\u0026gt; 00000001 (十六进制:0x01) 'b' -\u0026gt; 00000010 (十六进制:0x02) 'A' -\u0026gt; 00000011 (十六进制:0x03) 'B' -\u0026gt; 00000100 (十六进制:0x04)\n有了xiaohaizi字符集,我们就可以用二进制形式表示一些字符串了,下面是一些字符串用xiaohaizi字符集编码后的二进制表示: 'bA' -\u0026gt; 0000001000000011 (十六进制:0x0203) 'baB' -\u0026gt; 000000100000000100000100 (十六进制:0x020104) 'cd' -\u0026gt; 无法表示,字符集xiaohaizi不包含字符'c'和'd'\n比较规则简介 # 在我们确定了xiaohaizi字符集表示字符的范围以及编码规则后,怎么比较两个字符的大小呢?最容易想到的就是直接比较这两个字符对应的二进制编码的大小,比方说字符'a'的编码为0x01,字符'b'的编码为0x02,所以'a'小于'b',这种简单的比较规则也可以被称为二进制比较规则,英文名为binary collation。\n二进制比较规则是简单,但有时候并不符合现实需求,比如在很多场合对于英文字符我们都是不区分大小写的,也就是说'a'和'A'是相等的,在这种场合下就不能简单粗暴的使用二进制比较规则了,这时候我们可以这样指定比较规则:\n将两个大小写不同的字符全都转为大写或者小写。 再比较这两个字符对应的二进制数据。 这是一种稍微复杂一点点的比较规则,但是实际生活中的字符不止英文字符一种,比如我们的汉字有几万之多,对于某一种字符集来说,比较两个字符大小的规则可以制定出很多种,也就是说同一种字符集可以有多种比较规则,我们稍后就要介绍各种现实生活中用的字符集以及它们的一些比较规则。\n一些重要的字符集 # 不幸的是,这个世界太大了,不同的人制定出了好多种字符集,它们表示的字符范围和用到的编码规则可能都不一样。我们看一下一些常用字符集的情况:\nASCII字符集\n共收录128个字符,包括空格、标点符号、数字、大小写字母和一些不可见字符。由于总共才128个字符,所以可以使用1个字节来进行编码,我们看一些字符的编码方式: 'L' -\u0026gt; 01001100(十六进制:0x4C,十进制:76) 'M' -\u0026gt; 01001101(十六进制:0x4D,十进制:77)\nISO 8859-1字符集\n共收录256个字符,是在ASCII字符集的基础上又扩充了128个西欧常用字符(包括德法两国的字母),也可以使用1个字节来进行编码。这个字符集也有一个别名latin1。\nGB2312字符集\n收录了汉字以及拉丁字母、希腊字母、日文平假名及片假名字母、俄语西里尔字母。其中收录汉字6763个,其他文字符号682个。同时这种字符集又兼容ASCII字符集,所以在编码方式上显得有些奇怪:\n如果该字符在ASCII字符集中,则采用1字节编码。\n否则采用2字节编码。 这种表示一个字符需要的字节数可能不同的编码方式称为变长编码方式。比方说字符串'爱u',其中'爱'需要用2个字节进行编码,编码后的十六进制表示为0xB0AE,'u'需要用1个字节进行编码,编码后的十六进制表示为0x75,所以拼合起来就是0xB0AE75。\n小贴士:我们怎么区分某个字节代表一个单独的字符还是代表某个字符的一部分呢?别忘了ASCII字符集只收录128个字符,使用0~127就可以表示全部字符,所以如果某个字节是在0~127之内的,就意味着一个字节代表一个单独的字符,否则就是两个字节代表一个单独的字符。 - GBK字符集\nGBK字符集只是在收录字符范围上对GB2312字符集作了扩充,编码方式上兼容GB2312。\nutf8字符集 收录地球上能想到的所有字符,而且还在不断扩充。这种字符集兼容ASCII字符集,采用变长编码方式,编码一个字符需要使用1~4个字节,比方说这样: 'L' -\u0026gt; 01001100(十六进制:0x4C) '啊' -\u0026gt; 111001011001010110001010(十六进制:0xE5958A) 小贴士:其实准确的说,utf8只是Unicode字符集的一种编码方案,Unicode字符集可以采用utf8、utf16、utf32这几种编码方案,utf8使用1~4个字节编码一个字符,utf16使用2个或4个字节编码一个字符,utf32使用4个字节编码一个字符。更详细的Unicode和其编码方案的知识不是本书的重点,大家上网查查。MySQL中并不区分字符集和编码方案的概念,所以后边介绍的时候把utf8、utf16、utf32都当作一种字符集对待。\n对于同一个字符,不同字符集也可能有不同的编码方式。比如对于汉字'我'来说,ASCII字符集中根本没有收录这个字符,utf8和gb2312字符集对汉字我的编码方式如下: utf8编码:111001101000100010010001 (3个字节,十六进制表示是:0xE68891) gb2312编码:1100111011010010 (2个字节,十六进制表示是:0xCED2)\nMySQL中支持的字符集和排序规则 # MySQL中的utf8和utf8mb4 # 我们上面说utf8字符集表示一个字符需要使用1~4个字节,但是我们常用的一些字符使用1~3个字节就可以表示了。而在MySQL中字符集表示一个字符所用最大字节长度在某些方面会影响系统的存储和性能,所以设计MySQL的大佬偷偷的定义了两个概念:\nutf8mb3:阉割过的utf8字符集,只使用1~3个字节表示字符。\nutf8mb4:正宗的utf8字符集,使用1~4个字节表示字符。\n有一点需要大家十分的注意,在MySQL中utf8是utf8mb3的别名,所以之后在MySQL中提到utf8就意味着使用1~3个字节来表示一个字符,如果大家有使用4字节编码一个字符的情况,比如存储一些emoji表情什么的,那请使用utf8mb4。\n字符集的查看 # MySQL支持好多好多种字符集,查看当前MySQL中支持的字符集可以用下面这个语句: SHOW (CHARACTER SET|CHARSET) [LIKE 匹配的模式]; 其中CHARACTER SET和CHARSET是同义词,用任意一个都可以。我们查询一下(支持的字符集太多了,我们省略了一些): mysql\u0026gt; SHOW CHARSET; +----------+---------------------------------+---------------------+--------+ | Charset | Description | Default collation | Maxlen | +----------+---------------------------------+---------------------+--------+ | big5 | Big5 Traditional Chinese | big5_chinese_ci | 2 | ... | latin1 | cp1252 West European | latin1_swedish_ci | 1 | | latin2 | ISO 8859-2 Central European | latin2_general_ci | 1 | ... | ascii | US ASCII | ascii_general_ci | 1 | ... | gb2312 | GB2312 Simplified Chinese | gb2312_chinese_ci | 2 | ... | gbk | GBK Simplified Chinese | gbk_chinese_ci | 2 | | latin5 | ISO 8859-9 Turkish | latin5_turkish_ci | 1 | ... | utf8 | UTF-8 Unicode | utf8_general_ci | 3 | | ucs2 | UCS-2 Unicode | ucs2_general_ci | 2 | ... | latin7 | ISO 8859-13 Baltic | latin7_general_ci | 1 | | utf8mb4 | UTF-8 Unicode | utf8mb4_general_ci | 4 | | utf16 | UTF-16 Unicode | utf16_general_ci | 4 | | utf16le | UTF-16LE Unicode | utf16le_general_ci | 4 | ... | utf32 | UTF-32 Unicode | utf32_general_ci | 4 | | binary | Binary pseudo charset | binary | 1 | ... | gb18030 | China National Standard GB18030 | gb18030_chinese_ci | 4 | +----------+---------------------------------+---------------------+--------+ 41 rows in set (0.01 sec) 可以看到,我使用的这个MySQL版本一共支持41种字符集,其中的Default collation列表示这种字符集中一种默认的比较规则。大家注意返回结果中的最后一列Maxlen,它代表该种字符集表示一个字符最多需要几个字节。为了让大家的印象更深刻,我把几个常用到的字符集的Maxlen列摘抄下来,大家务必记住: 字符集名称 Maxlen ascii 1 latin1 1 gb2312 2 gbk 2 utf8 3 utf8mb4 4\n比较规则的查看 # 查看MySQL中支持的比较规则的命令如下: SHOW COLLATION [LIKE 匹配的模式]; 我们前面说过一种字符集可能对应着若干种比较规则,MySQL支持的字符集就已经非常多了,所以支持的比较规则更多,我们先只查看一下utf8字符集下的比较规则: mysql\u0026gt; SHOW COLLATION LIKE 'utf8_%'; +--------------------------+---------+-----+---------+----------+---------+ | Collation | Charset | Id | Default | Compiled | Sortlen | +--------------------------+---------+-----+---------+----------+---------+ | utf8_general_ci | utf8 | 33 | Yes | Yes | 1 | | utf8_bin | utf8 | 83 | | Yes | 1 | | utf8_unicode_ci | utf8 | 192 | | Yes | 8 | | utf8_icelandic_ci | utf8 | 193 | | Yes | 8 | | utf8_latvian_ci | utf8 | 194 | | Yes | 8 | | utf8_romanian_ci | utf8 | 195 | | Yes | 8 | | utf8_slovenian_ci | utf8 | 196 | | Yes | 8 | | utf8_polish_ci | utf8 | 197 | | Yes | 8 | | utf8_estonian_ci | utf8 | 198 | | Yes | 8 | | utf8_spanish_ci | utf8 | 199 | | Yes | 8 | | utf8_swedish_ci | utf8 | 200 | | Yes | 8 | | utf8_turkish_ci | utf8 | 201 | | Yes | 8 | | utf8_czech_ci | utf8 | 202 | | Yes | 8 | | utf8_danish_ci | utf8 | 203 | | Yes | 8 | | utf8_lithuanian_ci | utf8 | 204 | | Yes | 8 | | utf8_slovak_ci | utf8 | 205 | | Yes | 8 | | utf8_spanish2_ci | utf8 | 206 | | Yes | 8 | | utf8_roman_ci | utf8 | 207 | | Yes | 8 | | utf8_persian_ci | utf8 | 208 | | Yes | 8 | | utf8_esperanto_ci | utf8 | 209 | | Yes | 8 | | utf8_hungarian_ci | utf8 | 210 | | Yes | 8 | | utf8_sinhala_ci | utf8 | 211 | | Yes | 8 | | utf8_german2_ci | utf8 | 212 | | Yes | 8 | | utf8_croatian_ci | utf8 | 213 | | Yes | 8 | | utf8_unicode_520_ci | utf8 | 214 | | Yes | 8 | | utf8_vietnamese_ci | utf8 | 215 | | Yes | 8 | | utf8_general_mysql500_ci | utf8 | 223 | | Yes | 1 | +--------------------------+---------+-----+---------+----------+---------+ 27 rows in set (0.00 sec) 这些比较规则的命名还挺有规律的,具体规律如下:\n比较规则名称以与其关联的字符集的名称开头。如上图的查询结果的比较规则名称都是以utf8开头的。\n后边紧跟着该比较规则主要作用于哪种语言,比如utf8_polish_ci表示以波兰语的规则比较,utf8_spanish_ci是以西班牙语的规则比较,utf8_general_ci是一种通用的比较规则。\n名称后缀意味着该比较规则是否区分语言中的重音、大小写什么的,具体可以用的值如下:\n后缀 英文释义 描述 `_ai` `accent insensitive` 不区分重音 `_as` `accent sensitive` 区分重音 `_ci` `case insensitive` 不区分大小写 `_cs` `case sensitive` 区分大小写 `_bin` `binary` 以二进制方式比较 比如`utf8_general_ci`这个比较规则是以`ci`结尾的,说明不区分大小写。 每种字符集对应若干种比较规则,每种字符集都有一种默认的比较规则,SHOW COLLATION的返回结果中的Default列的值为YES的就是该字符集的默认比较规则,比方说utf8字符集默认的比较规则就是utf8_general_ci。\n字符集和比较规则的应用 # 各级别的字符集和比较规则 # MySQL有4个级别的字符集和比较规则,分别是:\n服务器级别 数据库级别 表级别 列级别 我们接下来仔细看一下怎么设置和查看这几个级别的字符集和比较规则。\n服务器级别 # MySQL提供了两个系统变量来表示服务器级别的字符集和比较规则: 系统变量 描述 character_set_server 服务器级别的字符集 collation_server 服务器级别的比较规则 我们看一下这两个系统变量的值: ``` mysql\u0026gt; SHOW VARIABLES LIKE \u0026lsquo;character_set_server\u0026rsquo;; +\u0026mdash;\u0026mdash;\u0026mdash;\u0026mdash;\u0026mdash;\u0026mdash;\u0026mdash;-+\u0026mdash;\u0026mdash;-+ | Variable_name | Value | +\u0026mdash;\u0026mdash;\u0026mdash;\u0026mdash;\u0026mdash;\u0026mdash;\u0026mdash;-+\u0026mdash;\u0026mdash;-+ | character_set_server | utf8 | +\u0026mdash;\u0026mdash;\u0026mdash;\u0026mdash;\u0026mdash;\u0026mdash;\u0026mdash;-+\u0026mdash;\u0026mdash;-+ 1 row in set (0.00 sec)\nmysql\u0026gt; SHOW VARIABLES LIKE \u0026lsquo;collation_server\u0026rsquo;; +\u0026mdash;\u0026mdash;\u0026mdash;\u0026mdash;\u0026mdash;\u0026mdash;+\u0026mdash;\u0026mdash;\u0026mdash;\u0026mdash;\u0026mdash;\u0026ndash;+ | Variable_name | Value | +\u0026mdash;\u0026mdash;\u0026mdash;\u0026mdash;\u0026mdash;\u0026mdash;+\u0026mdash;\u0026mdash;\u0026mdash;\u0026mdash;\u0026mdash;\u0026ndash;+ | collation_server | utf8_general_ci | +\u0026mdash;\u0026mdash;\u0026mdash;\u0026mdash;\u0026mdash;\u0026mdash;+\u0026mdash;\u0026mdash;\u0026mdash;\u0026mdash;\u0026mdash;\u0026ndash;+ 1 row in set (0.00 sec)\n``` 可以看到在我的计算机中服务器级别默认的字符集是utf8,默认的比较规则是utf8_general_ci。\n我们可以在启动服务器程序时通过启动选项或者在服务器程序运行过程中使用SET语句修改这两个变量的值。比如我们可以在配置文件中这样写: [server] character_set_server=gbk collation_server=gbk_chinese_ci 当服务器启动的时候读取这个配置文件后这两个系统变量的值便修改了。\n数据库级别 # \\[DEFAULT] CHARACTER SET 字符集名称\\]\\[DEFAULT] COLLATE 比较规则名称\\];\n\\[DEFAULT] CHARACTER SET 字符集名称\\]\\[DEFAULT] COLLATE 比较规则名称\\]; 其中的DEFAULT可以省略,并不影响语句的语义。比方说我们新创建一个名叫charset_demo_db的数据库,在创建的时候指定它使用的字符集为gb2312,比较规则为gb2312_chinese_ci: mysql\u0026gt; CREATE DATABASE charset_demo_db -\u0026gt; CHARACTER SET gb2312 -\u0026gt; COLLATE gb2312_chinese_ci; Query OK, 1 row affected (0.01 sec) 如果想查看当前数据库使用的字符集和比较规则,可以查看下面两个系统变量的值(前提是使用`USE`语句选择当前默认数据库,如果没有默认数据库,则变量与相应的服务器级系统变量具有相同的值): 系统变量 描述 `character_set_database` 当前数据库的字符集 `collation_database` 当前数据库的比较规则 我们来查看一下刚刚创建的`charset_demo_db`数据库的字符集和比较规则: mysql\u0026gt; USE charset_demo_db; Database changed\nmysql\u0026gt; SHOW VARIABLES LIKE \u0026lsquo;character_set_database\u0026rsquo;; +\u0026mdash;\u0026mdash;\u0026mdash;\u0026mdash;\u0026mdash;\u0026mdash;\u0026mdash;\u0026mdash;+\u0026mdash;\u0026mdash;\u0026ndash;+ | Variable_name | Value | +\u0026mdash;\u0026mdash;\u0026mdash;\u0026mdash;\u0026mdash;\u0026mdash;\u0026mdash;\u0026mdash;+\u0026mdash;\u0026mdash;\u0026ndash;+ | character_set_database | gb2312 | +\u0026mdash;\u0026mdash;\u0026mdash;\u0026mdash;\u0026mdash;\u0026mdash;\u0026mdash;\u0026mdash;+\u0026mdash;\u0026mdash;\u0026ndash;+ 1 row in set (0.00 sec)\nmysql\u0026gt; SHOW VARIABLES LIKE \u0026lsquo;collation_database\u0026rsquo;; +\u0026mdash;\u0026mdash;\u0026mdash;\u0026mdash;\u0026mdash;\u0026mdash;\u0026ndash;+\u0026mdash;\u0026mdash;\u0026mdash;\u0026mdash;\u0026mdash;\u0026mdash;-+ | Variable_name | Value | +\u0026mdash;\u0026mdash;\u0026mdash;\u0026mdash;\u0026mdash;\u0026mdash;\u0026ndash;+\u0026mdash;\u0026mdash;\u0026mdash;\u0026mdash;\u0026mdash;\u0026mdash;-+ | collation_database | gb2312_chinese_ci | +\u0026mdash;\u0026mdash;\u0026mdash;\u0026mdash;\u0026mdash;\u0026mdash;\u0026ndash;+\u0026mdash;\u0026mdash;\u0026mdash;\u0026mdash;\u0026mdash;\u0026mdash;-+ 1 row in set (0.00 sec)\nmysql\u0026gt; ``` 可以看到这个charset_demo_db数据库的字符集和比较规则就是我们在创建语句中指定的。需要注意的一点是:** character_set_database 和 collation_database 这两个系统变量是只读的,我们不能通过修改这两个变量的值而改变当前数据库的字符集和比较规则**。\n数据库的创建语句中也可以不指定字符集和比较规则,比如这样: CREATE DATABASE 数据库名; 这样的话,将使用服务器级别的字符集和比较规则作为数据库的字符集和比较规则。\n表级别 # \\[DEFAULT] CHARACTER SET 字符集名称\\] [COLLATE 比较规则名称]]\n\\[DEFAULT] CHARACTER SET 字符集名称\\] [COLLATE 比较规则名称] 比方说我们在刚刚创建的charset_demo_db数据库中创建一个名为t的表,并指定这个表的字符集和比较规则: mysql\u0026gt; CREATE TABLE t( -\u0026gt; col VARCHAR 10)−\u0026gt;10) -\u0026gt; CHARACTER SET utf8 COLLATE utf8_general_ci; Query OK, 0 rows affected (0.03 sec) 如果创建和修改表的语句中没有指明字符集和比较规则,**将使用该表所在数据库的字符集和比较规则作为该表的字符集和比较规则**。假设我们的创建表t的语句是这么写的: CREATE TABLE t( col VARCHAR 10)10) ; ``` 因为表t的建表语句中并没有明确指定字符集和比较规则,则表t的字符集和比较规则将继承所在数据库charset_demo_db的字符集和比较规则,也就是gb2312和gb2312_chinese_ci。\n列级别 # 需要注意的是,对于存储字符串的列,同一个表中的不同的列也可以有不同的字符集和比较规则。我们在创建和修改列定义的时候可以指定该列的字符集和比较规则,语法如下: ``` CREATE TABLE 表名( 列名 字符串类型 [CHARACTER SET 字符集名称] [COLLATE 比较规则名称], 其他列\u0026hellip; );\nALTER TABLE 表名 MODIFY 列名 字符串类型 [CHARACTER SET 字符集名称] [COLLATE 比较规则名称]; 比如我们修改一下表t中列col的字符集和比较规则可以这么写: mysql\u0026gt; ALTER TABLE t MODIFY col VARCHAR(10) CHARACTER SET gbk COLLATE gbk_chinese_ci; Query OK, 0 rows affected (0.04 sec) Records: 0 Duplicates: 0 Warnings: 0\nmysql\u0026gt; 对于某个列来说,如果在创建和修改的语句中没有指明字符集和比较规则,**将使用该列所在表的字符集和比较规则作为该列的字符集和比较规则**。比方说表t的字符集是utf8,比较规则是utf8_general_ci,修改列col的语句是这么写的: ALTER TABLE t MODIFY col VARCHAR(10); 那列col的字符集和编码将使用表t的字符集和比较规则,也就是utf8和utf8_general_ci。 小贴士:在转换列的字符集时需要注意,如果转换前列中存储的数据不能用转换后的字符集进行表示,就会发生错误。比方说原先列使用的字符集是utf8,列中存储了一些汉字,现在把列的字符集转换为ascii的话就会出错,因为ascii字符集并不能表示汉字字符。 ```\n仅修改字符集或仅修改比较规则 # 由于字符集和比较规则是互相有联系的,如果我们只修改了字符集,比较规则也会跟着变化,如果只修改了比较规则,字符集也会跟着变化,具体规则如下:\n只修改字符集,则比较规则将变为修改后的字符集默认的比较规则。 只修改比较规则,则字符集将变为修改后的比较规则对应的字符集。 不论哪个级别的字符集和比较规则,这两条规则都适用,我们以服务器级别的字符集和比较规则为例来看一下详细过程:\n只修改字符集,则比较规则将变为修改后的字符集默认的比较规则。 ``` mysql\u0026gt; SET character_set_server = gb2312; Query OK, 0 rows affected (0.00 sec)\nmysql\u0026gt; SHOW VARIABLES LIKE \u0026lsquo;character_set_server\u0026rsquo;; +\u0026mdash;\u0026mdash;\u0026mdash;\u0026mdash;\u0026mdash;\u0026mdash;\u0026mdash;-+\u0026mdash;\u0026mdash;\u0026ndash;+ | Variable_name | Value | +\u0026mdash;\u0026mdash;\u0026mdash;\u0026mdash;\u0026mdash;\u0026mdash;\u0026mdash;-+\u0026mdash;\u0026mdash;\u0026ndash;+ | character_set_server | gb2312 | +\u0026mdash;\u0026mdash;\u0026mdash;\u0026mdash;\u0026mdash;\u0026mdash;\u0026mdash;-+\u0026mdash;\u0026mdash;\u0026ndash;+ 1 row in set (0.00 sec)\nmysql\u0026gt; SHOW VARIABLES LIKE \u0026lsquo;collation_server\u0026rsquo;; +\u0026mdash;\u0026mdash;\u0026mdash;\u0026mdash;\u0026mdash;\u0026mdash;+\u0026mdash;\u0026mdash;\u0026mdash;\u0026mdash;\u0026mdash;\u0026mdash;-+ | Variable_name | Value | +\u0026mdash;\u0026mdash;\u0026mdash;\u0026mdash;\u0026mdash;\u0026mdash;+\u0026mdash;\u0026mdash;\u0026mdash;\u0026mdash;\u0026mdash;\u0026mdash;-+ | collation_server | gb2312_chinese_ci | +\u0026mdash;\u0026mdash;\u0026mdash;\u0026mdash;\u0026mdash;\u0026mdash;+\u0026mdash;\u0026mdash;\u0026mdash;\u0026mdash;\u0026mdash;\u0026mdash;-+ 1 row in set (0.00 sec) ``` 我们只修改了character_set_server的值为gb2312,collation_server的值自动变为了gb2312_chinese_ci。\n只修改比较规则,则字符集将变为修改后的比较规则对应的字符集。\nmysql\u0026gt; SHOW VARIABLES LIKE \u0026#39;character_set_server\u0026#39;; \\+----------------------\\+-------\\+ | Variable_name | Value | \\+----------------------\\+-------\\+ | character_set_server | utf8 | \\+----------------------\\+-------\\+ 1 row in set (0.00 sec) mysql\u0026gt; SHOW VARIABLES LIKE \u0026#39;collation_server\u0026#39;; \\+------------------\\+-----------------\\+ | Variable_name | Value | \\+------------------\\+-----------------\\+ | collation_server | utf8_general_ci | \\+------------------\\+-----------------\\+ 1 row in set (0.00 sec) mysql\u0026gt; ``` 我们只修改了`collation_server`的值为`utf8_general_ci`,`character_set_server`的值自动变为了`utf8`。 ### 各级别字符集和比较规则小结 我们介绍的这4个级别字符集和比较规则的联系如下: + 如果创建或修改列时,没有显式的指定字符集和比较规则,则该列默认用表的字符集和比较规则 + 如果创建或修改表时,没有显式的指定字符集和比较规则,则该表默认用数据库的字符集和比较规则 + 如果创建或修改数据库时,没有显式的指定字符集和比较规则,则该数据库默认用服务器的字符集和比较规则 知道了这些规则之后,对于给定的表,我们应该知道它的各个列的字符集和比较规则是什么,从而根据这个列的类型来确定存储数据时每个列的实际数据占用的存储空间大小了。比方说我们向表`t`中插入一条记录: ``` mysql\u0026gt; INSERT INTO t(col) VALUES(\u0026#39;我我\u0026#39;); Query OK, 1 row affected (0.00 sec) mysql\u0026gt; SELECT * FROM t; \\+--------\\+ | s | \\+--------\\+ | 我我 | \\+--------\\+ 1 row in set (0.00 sec) ``` 首先列`col`使用的字符集是`gbk`,一个字符`\u0026#39;我\u0026#39;`在`gbk`中的编码为`0xCED2`,占用两个字节,两个字符的实际数据就占用4个字节。如果把该列的字符集修改为`utf8`的话,这两个字符就实际占用6个字节。 ## 客户端和服务器通信中的字符集 ### 编码和解码使用的字符集不一致的后果 说到底,字符串在计算机上的体现就是一个字节串,如果你使用不同字符集去解码这个字节串,最后得到的结果可能让你挠头。 我们知道字符`\u0026#39;我\u0026#39;`在`utf8`字符集编码下的字节串长这样:`0xE68891`,如果一个程序把这个字节串发送到另一个程序里,另一个程序用不同的字符集去解码这个字节串,假设使用的是`gbk`字符集来解释这串字节,解码过程就是这样的: 1. 首先看第一个字节`0xE6`,它的值大于`0x7F`(十进制:127),说明是两字节编码,继续读一字节后是`0xE688`,然后从`gbk`编码表中查找字节为`0xE688`对应的字符,发现是字符`\u0026#39;鎴\u0026#39;` 2. 继续读一个字节`0x91`,它的值也大于`0x7F`,再往后读一个字节发现木有了,所以这是半个字符。 3. 所以`0xE68891`被`gbk`字符集解释成一个字符`\u0026#39;鎴\u0026#39;`和半个字符。 假设用`iso-8859-1`,也就是`latin1`字符集去解释这串字节,解码过程如下: 1. 先读第一个字节`0xE6`,它对应的`latin1`字符为`æ`。 2. 再读第二个字节`0x88`,它对应的`latin1`字符为`ˆ`。 3. 再读第二个字节`0x91`,它对应的`latin1`字符为`‘`。 4. 所以整串字节`0xE68891`被`latin1`字符集解释后的字符串就是`\u0026#39;我\u0026#39;` 可见,**如果对于同一个字符串编码和解码使用的字符集不一样,会产生意想不到的结果**,作为人类的我们看上去就像是产生了乱码一样。 ### 字符集转换的概念 如果接收`0xE68891`这个字节串的程序按照`utf8`字符集进行解码,然后又把它按照`gbk`字符集进行编码,最后编码后的字节串就是`0xCED2`,我们把这个过程称为`字符集的转换`,也就是字符串`\u0026#39;我\u0026#39;`从`utf8`字符集转换为`gbk`字符集。 ### MySQL中字符集的转换 我们知道从客户端发往服务器的请求本质上就是一个字符串,服务器向客户端返回的结果本质上也是一个字符串,而字符串其实是使用某种字符集编码的二进制数据。这个字符串可不是使用一种字符集的编码方式一条道走到黑的,从发送请求到返回结果这个过程中伴随着多次字符集的转换,在这个过程中会用到3个系统变量,我们先把它们写出来看一下: 系统变量 描述 `character_set_client` 服务器解码请求时使用的字符集 `character_set_connection` 服务器处理请求时会把请求字符串从`character_set_client`转为`character_set_connection` `character_set_results` 服务器向客户端返回数据时使用的字符集 这几个系统变量在我的计算机上的默认值如下(不同操作系统的默认值可能不同): ``` mysql\u0026gt; SHOW VARIABLES LIKE \u0026#39;character_set_client\u0026#39;; \\+----------------------\\+-------\\+ | Variable_name | Value | \\+----------------------\\+-------\\+ | character_set_client | utf8 | \\+----------------------\\+-------\\+ 1 row in set (0.00 sec) mysql\u0026gt; SHOW VARIABLES LIKE \u0026#39;character_set_connection\u0026#39;; \\+--------------------------\\+-------\\+ | Variable_name | Value | \\+--------------------------\\+-------\\+ | character_set_connection | utf8 | \\+--------------------------\\+-------\\+ 1 row in set (0.01 sec) mysql\u0026gt; SHOW VARIABLES LIKE \u0026#39;character_set_results\u0026#39;; \\+-----------------------\\+-------\\+ | Variable_name | Value | \\+-----------------------\\+-------\\+ | character_set_results | utf8 | \\+-----------------------\\+-------\\+ 1 row in set (0.00 sec) `大家可以看到这几个系统变量的值都是`utf8`,为了体现出字符集在请求处理过程中的变化,我们这里特意修改一个系统变量的值:` mysql\u0026gt; set character_set_connection = gbk; Query OK, 0 rows affected (0.00 sec) `所以现在系统变量`character_set_client`和`character_set_results`的值还是`utf8`,而`character_set_connection`的值为`gbk`。现在假设我们客户端发送的请求是下面这个字符串:` SELECT * FROM t WHERE s = \u0026#39;我\u0026#39;; ``` 为了方便大家理解这个过程,我们只分析字符`\u0026#39;我\u0026#39;`在这个过程中字符集的转换。 现在看一下在请求从发送到结果返回过程中字符集的变化: 1. 客户端发送请求所使用的字符集 一般情况下客户端所使用的字符集和当前操作系统一致,不同操作系统使用的字符集可能不一样,如下: + 类`Unix`系统使用的是`utf8` + `Windows`使用的是`gbk` 例如我在使用的`macOS`操作系统时,客户端使用的就是`utf8`字符集。所以字符`\u0026#39;我\u0026#39;`在发送给服务器的请求中的字节形式就是:`0xE68891` `小贴士:如果你使用的是可视化工具,比如navicat之类的,这些工具可能会使用自定义的字符集来编码发送到服务器的字符串,而不采用操作系统默认的字符集(所以在学习的时候还是尽量用黑框框)。` 1. 服务器接收到客户端发送来的请求其实是一串二进制的字节,它会认为这串字节采用的字符集是`character_set_client`,然后把这串字节转换为`character_set_connection`字符集编码的字符。 由于我的计算机上`character_set_client`的值是`utf8`,首先会按照`utf8`字符集对字节串`0xE68891`进行解码,得到的字符串就是`\u0026#39;我\u0026#39;`,然后按照`character_set_connection`代表的字符集,也就是`gbk`进行编码,得到的结果就是字节串`0xCED2`。 2. 因为表`t`的列`col`采用的是`gbk`字符集,与`character_set_connection`一致,所以直接到列中找字节值为`0xCED2`的记录,最后找到了一条记录。 `小贴士:如果某个列使用的字符集和character_set_connection代表的字符集不一致的话,还需要进行一次字符集转换。` 1. 上一步骤找到的记录中的`col`列其实是一个字节串`0xCED2`,`col`列是采用`gbk`进行编码的,所以首先会将这个字节串使用`gbk`进行解码,得到字符串`\u0026#39;我\u0026#39;`,然后再把这个字符串使用`character_set_results`代表的字符集,也就是`utf8`进行编码,得到了新的字节串:`0xE68891`,然后发送给客户端。 2. 由于客户端是用的字符集是`utf8`,所以可以顺利的将`0xE68891`解释成字符`我`,从而显示到我们的显示器上,所以我们人类也读懂了返回的结果。 如果你读上面的文字有点晕,可以参照这个图来仔细分析一下这几个步骤: ![](img/03-01.png) 从这个分析中我们可以得出这么几点需要注意的地方: + 服务器认为客户端发送过来的请求是用`character_set_client`编码的。 **假设你的客户端采用的字符集和 ***character_set_client*** 不一样的话,这就会出现意想不到的情况**。比如我的客户端使用的是`utf8`字符集,如果把系统变量`character_set_client`的值设置为`ascii`的话,服务器可能无法理解我们发送的请求,更别谈处理这个请求了。 + 服务器将把得到的结果集使用`character_set_results`编码后发送给客户端。 **假设你的客户端采用的字符集和 ***character_set_results*** 不一样的话,这就可能会出现客户端无法解码结果集的情况**,结果就是在你的屏幕上出现乱码。比如我的客户端使用的是`utf8`字符集,如果把系统变量`character_set_results`的值设置为`ascii`的话,可能会产生乱码。 + `character_set_connection`只是服务器在将请求的字节串从`character_set_client`转换为`character_set_connection`时使用,它是什么其实没多重要,但是一定要注意,**该字符集包含的字符范围一定涵盖请求中的字符**,要不然会导致有的字符无法使用`character_set_connection`代表的字符集进行编码。比如你把`character_set_client`设置为`utf8`,把`character_set_connection`设置成`ascii`,那么此时你如果从客户端发送一个汉字到服务器,那么服务器无法使用`ascii`字符集来编码这个汉字,就会向用户发出一个警告。 知道了在`MySQL`中从发送请求到返回结果过程里发生的各种字符集转换,但是为什么要转来转去的呢?不晕么? 答:是的,很头晕,所以**我们通常都把 ***character_set_client*** 、***character_set_connection***、***character_set_results*** 这三个系统变量设置成和客户端使用的字符集一致的情况,这样减少了很多无谓的字符集转换**。为了方便我们设置,`MySQL`提供了一条非常简便的语句: `SET NAMES 字符集名;` 这一条语句产生的效果和我们执行这3条的效果是一样的: `SET character_set_client = 字符集名; SET character_set_connection = 字符集名; SET character_set_results = 字符集名;` 比方说我的客户端使用的是`utf8`字符集,所以需要把这几个系统变量的值都设置为`utf8`: ``` mysql\u0026gt; SET NAMES utf8; Query OK, 0 rows affected (0.00 sec) mysql\u0026gt; SHOW VARIABLES LIKE \u0026#39;character_set_client\u0026#39;; \\+----------------------\\+-------\\+ | Variable_name | Value | \\+----------------------\\+-------\\+ | character_set_client | utf8 | \\+----------------------\\+-------\\+ 1 row in set (0.00 sec) mysql\u0026gt; SHOW VARIABLES LIKE \u0026#39;character_set_connection\u0026#39;; \\+--------------------------\\+-------\\+ | Variable_name | Value | \\+--------------------------\\+-------\\+ | character_set_connection | utf8 | \\+--------------------------\\+-------\\+ 1 row in set (0.00 sec) mysql\u0026gt; SHOW VARIABLES LIKE \u0026#39;character_set_results\u0026#39;; \\+-----------------------\\+-------\\+ | Variable_name | Value | \\+-----------------------\\+-------\\+ | character_set_results | utf8 | \\+-----------------------\\+-------\\+ 1 row in set (0.00 sec) mysql\u0026gt; `` 小贴士:如果你使用的是Windows系统,那应该设置成gbk。 `另外,如果你想在启动客户端的时候就把`character_set_client`、`character_set_connection`、`character_set_results`这三个系统变量的值设置成一样的,那我们可以在启动客户端的时候指定一个叫`default-character-set`的启动选项,比如在配置文件里可以这么写:` [client] default-character-set=utf8 ``` 它起到的效果和执行一遍`SET NAMES utf8`是一样一样的,都会将那三个系统变量的值设置成`utf8`。 ## 比较规则的应用 结束了字符集的漫游,我们把视角再次聚焦到`比较规则`,`比较规则`的作用通常体现比较字符串大小的表达式以及对某个字符串列进行排序中,所以有时候也称为`排序规则`。比方说表`t`的列`col`使用的字符集是`gbk`,使用的比较规则是`gbk_chinese_ci`,我们向里边插入几条记录: ``` mysql\u0026gt; INSERT INTO t(col) VALUES(\u0026#39;a\u0026#39;), (\u0026#39;b\u0026#39;), (\u0026#39;A\u0026#39;), (\u0026#39;B\u0026#39;); Query OK, 4 rows affected (0.00 sec) Records: 4 Duplicates: 0 Warnings: 0 mysql\u0026gt; `我们查询的时候按照`t`列排序一下:` mysql\u0026gt; SELECT * FROM t ORDER BY col; \\+------\\+ | col | \\+------\\+ | a | | A | | b | | B | | 我 | \\+------\\+ 5 rows in set (0.00 sec) `可以看到在默认的比较规则`gbk_chinese_ci`中是不区分大小写的,我们现在把列`col`的比较规则修改为`gbk_bin`:` mysql\u0026gt; ALTER TABLE t MODIFY col VARCHAR(10) COLLATE gbk_bin; Query OK, 5 rows affected (0.02 sec) Records: 5 Duplicates: 0 Warnings: 0 `由于`gbk_bin`是直接比较字符的编码,所以是区分大小写的,我们再看一下排序后的查询结果:` mysql\u0026gt; SELECT * FROM t ORDER BY s; \\+------\\+ | s | \\+------\\+ | A | | B | | a | | b | | 我 | \\+------\\+ 5 rows in set (0.00 sec) mysql\u0026gt; `所以,如果以后大家在对字符串做比较或者对某个字符串列做排序操作时,没有得到想象中的结果,需要思考一下是不是`比较规则`的问题。` 小贴士: 列col中各个字符在使用gbk字符集编码后对应的数字如下: \u0026#39;A\u0026#39; -\u0026gt; 65 (十进制) \u0026#39;B\u0026#39; -\u0026gt; 66 (十进制) \u0026#39;a\u0026#39; -\u0026gt; 97 (十进制) \u0026#39;b\u0026#39; -\u0026gt; 98 (十进制) \u0026#39;我\u0026#39; -\u0026gt; 25105 (十进制) ``` # 总结 1. `字符集`指的是某个字符范围的编码规则。 2. `比较规则`是针对某个字符集中的字符比较大小的一种规则。 3. 在`MySQL`中,一个字符集可以有若干种比较规则,其中有一个默认的比较规则,一个比较规则必须对应一个字符集。 4. 查看`MySQL`中查看支持的字符集和比较规则的语句如下: `SHOW (CHARACTER SET|CHARSET) [LIKE 匹配的模式]; SHOW COLLATION [LIKE 匹配的模式];` 5. MySQL有四个级别的字符集和比较规则 6. 服务器级别 `character_set_server`表示服务器级别的字符集,`collation_server`表示服务器级别的比较规则。 7. 数据库级别 创建和修改数据库时可以指定字符集和比较规则: ``` CREATE DATABASE 数据库名 [\\[DEFAULT] CHARACTER SET 字符集名称\\] [\\[DEFAULT] COLLATE 比较规则名称\\]; ALTER DATABASE 数据库名 [\\[DEFAULT] CHARACTER SET 字符集名称\\] [\\[DEFAULT] COLLATE 比较规则名称\\]; ````character_set_database`表示当前数据库的字符集,`collation_database`表示当前默认数据库的比较规则,这两个系统变量是只读的,不能修改。如果没有指定当前默认数据库,则变量与相应的服务器级系统变量具有相同的值。 8. 表级别 创建和修改表的时候指定表的字符集和比较规则: ``` CREATE TABLE 表名 (列的信息) [\\[DEFAULT] CHARACTER SET 字符集名称\\] [COLLATE 比较规则名称]\\]; ALTER TABLE 表名 [\\[DEFAULT] CHARACTER SET 字符集名称\\] [COLLATE 比较规则名称]; ``` 9. 列级别 创建和修改列定义的时候可以指定该列的字符集和比较规则: ``` CREATE TABLE 表名( 列名 字符串类型 [CHARACTER SET 字符集名称] [COLLATE 比较规则名称], 其他列... ); ALTER TABLE 表名 MODIFY 列名 字符串类型 [CHARACTER SET 字符集名称] [COLLATE 比较规则名称]; ``` 10. 从发送请求到接收结果过程中发生的字符集转换: + 客户端使用操作系统的字符集编码请求字符串,向服务器发送的是经过编码的一个字节串。 + 服务器将客户端发送来的字节串采用`character_set_client`代表的字符集进行解码,将解码后的字符串再按照`character_set_connection`代表的字符集进行编码。 + 如果`character_set_connection`代表的字符集和具体操作的列使用的字符集一致,则直接进行相应操作,否则的话需要将请求中的字符串从`character_set_connection`代表的字符集转换为具体操作的列使用的字符集之后再进行操作。 + 将从某个列获取到的字节串从该列使用的字符集转换为`character_set_results`代表的字符集后发送到客户端。 + 客户端使用操作系统的字符集解析收到的结果集字节串。 在这个过程中各个系统变量的含义如下: 系统变量 描述 `character_set_client` 服务器解码请求时使用的字符集 `character_set_connection` 服务器处理请求时会把请求字符串从`character_set_client`转为`character_set_connection` `character_set_results` 服务器向客户端返回数据时使用的字符集 一般情况下要使用保持这三个变量的值和客户端使用的字符集相同。 1. 比较规则的作用通常体现比较字符串大小的表达式以及对某个字符串列进行排序中。 "},{"id":30,"href":"/zh/docs/technology/MySQL/MySQL%E6%98%AF%E6%80%8E%E6%A0%B7%E8%BF%90%E8%A1%8C%E7%9A%84/%E7%AC%AC2%E7%AB%A0_MySQL%E7%9A%84%E8%B0%83%E6%8E%A7%E6%8C%89%E9%92%AE-%E5%90%AF%E5%8A%A8%E9%80%89%E9%A1%B9%E5%92%8C%E7%B3%BB%E7%BB%9F%E5%8F%98%E9%87%8F/","title":"第2章_MySQL的调控按钮-启动选项和系统变量","section":"My Sql是怎样运行的","content":"第2章 MySQL的调控按钮-启动选项和系统变量\n如果你用过手机,你的手机上一定有一个设置的功能,你可以选择设置手机的来电铃声、设置音量大小、设置解锁密码等等。假如没有这些设置功能,我们的生活将置于尴尬的境地,比如在图书馆里无法把手机设置为静音,无法把流量开关关掉以节省流量,在别人得知解锁密码后无法更改密码~ MySQL的服务器程序和客户端程序也有很多设置项,比如对于MySQL服务器程序,我们可以指定诸如允许同时连入的客户端数量、客户端和服务器通信方式、表的默认存储引擎、查询缓存的大小等设置项。对于MySQL客户端程序,我们之前已经见识过了,可以指定需要连接的服务器程序所在主机的主机名或IP地址、用户名及密码等信息。\n这些设置项一般都有各自的默认值,比方说服务器允许同时连入的客户端的默认数量是151,表的默认存储引擎是InnoDB,我们可以在程序启动的时候去修改这些默认值,对于这种在程序启动时指定的设置项也称之为启动选项(startup options),这些选项控制着程序启动后的行为。在MySQL安装目录下的bin目录中的各种可执行文件,不论是服务器相关的程序(比如mysqld、mysqld_safe)还是客户端相关的程序(比如mysql、mysqladmin),在启动的时候基本都可以指定启动参数。这些启动参数可以放在命令行中指定,也可以把它们放在配置文件中指定。下面我们以mysqld为例,来详细介绍指定启动选项的格式。需要注意的一点是,我们现在要介绍的是设置启动选项的方式,下面出现的启动选项不论大家认不认识,先不用去纠结每个选项具体的作用是什么,之后我们会对一些重要的启动选项详细介绍。\n在命令行上使用选项 # 如果我们在启动客户端程序时在-h参数后边紧跟服务器的IP地址,这就意味着客户端和服务器之间需要通过TCP/IP网络进行通信。因为我的客户端程序和服务器程序都装在一台计算机上,所以在使用客户端程序连接服务器程序时指定的主机名是127.0.0.1的情况下,客户端进程和服务器进程之间会使用TCP/IP网络进行通信。如果我们在启动服务器程序的时候就禁止各客户端使用TCP/IP网络进行通信,可以在启动服务器程序的命令行里添加skip-networking启动选项,就像这样: mysqld --skip-networking 可以看到,我们在命令行中指定启动选项时需要在选项名前加上--前缀。另外,如果选项名是由多个单词构成的,它们之间可以由短划线-连接起来,也可以使用下划线_连接起来,也就是说skip-networking和skip_networking表示的含义是相同的。所以上面的写法与下面的写法是等价的: mysqld --skip_networking 在按照上述命令启动服务器程序后,如果我们再使用mysql来启动客户端程序时,再把服务器主机名指定为127.0.0.1(IP地址的形式)的话会显示连接失败: ``` mysql -h127.0.0.1 -uroot -p Enter password:\nERROR 2003 (HY000): Can\u0026rsquo;t connect to MySQL server on \u0026lsquo;127.0.0.1\u0026rsquo; (61) ``` 这就意味着我们指定的启动选项skip-networking生效了!\n再举一个例子,我们前面说过如果在创建表的语句中没有显式指定表的存储引擎的话,那就会默认使用InnoDB作为表的存储引擎。如果我们想改变表的默认存储引擎的话,可以这样写启动服务器的命令行: mysqld --default-storage-engine=MyISAM 我们现在就已经把表的默认存储引擎改为MyISAM了,在客户端程序连接到服务器程序后试着创建一个表: mysql\u0026gt; CREATE TABLE sys_var_demo( -\u0026gt; i INT -\u0026gt; ); Query OK, 0 rows affected (0.02 sec) 这个定义语句中我们并没有明确指定表的存储引擎,创建成功后再看一下这个表的结构: mysql\u0026gt; SHOW CREATE TABLE sys_var_demo\\G *************************** 1. row *************************** Table: sys_var_demo Create Table: CREATE TABLE sys_var_demo(i int(11) DEFAULT NULL ) ENGINE=MyISAM DEFAULT CHARSET=utf8 1 row in set (0.01 sec) 可以看到该表的存储引擎已经是MyISAM了,说明启动选项default-storage-engine生效了。\n所以在启动服务器程序的命令行后边指定启动选项的通用格式就是这样的: --启动选项1[=值1] --启动选项2[=值2] ... --启动选项n[=值n] 也就是说我们可以将各个启动选项写到一行中,各个启动选项之间使用空白字符隔开,在每一个启动选项名称前面添加--。对于不需要值的启动选项,比方说skip-networking,它们就不需要指定对应的值。对于需要指定值的启动选项,比如default-storage-engine我们在指定这个设置项的时候需要显式的指定它的值:InnoDB、MyISAM等。在命令行上指定有值的启动选项时需要注意,选项名、=、选项值之间不可以有空白字符,比如写成下面这样就是不正确的: ``` mysqld \u0026ndash;default-storage-engine = MyISAM\n``` 每个MySQL程序都有许多不同的选项。大多数程序提供了一个--help选项,你可以查看该程序支持的全部启动选项以及它们的默认值。例如,使用mysql --help可以看到mysql程序支持的启动选项,mysqld_safe --help可以看到mysqld_safe程序支持的启动选项。查看mysqld支持的启动选项有些特别,需要使用mysqld --verbose --help。\n选项的长形式和短形式 # 我们前面提到的skip-networking、default-storage-engine称之为长形式的选项(因为它们很长),设计MySQL的大佬为了我们使用的方便,对于一些常用的选项提供了短形式,我们列举一些具有短形式的启动选项来看看(MySQL支持的短形式选项太多了,全列出来会刷屏的): 长形式 短形式 含义 --host -h 主机名 --user -u 用户名 --password -p 密码 --port -P 端口 --version -V 版本信息 短形式的选项名只有一个字母,与使用长形式选项时需要在选项名前加两个短划线--不同的是,使用短形式选项时在选项名前只加一个短划线-前缀。有一些短形式的选项我们之前已经接触过了,比方说我们在启动服务器程序时指定监听的端口号: mysqld -P3307 使用短形式指定启动选项时,选项名和选项值之间可以没有间隙,或者用空白字符隔开(-p选项有些特殊,-p和密码值之间不能有空白字符),也就是说上面的命令形式和下面的是等价的: mysqld -P 3307 另外,选项名是区分大小写的,比如-p和-P选项拥有完全不同的含义,大家需要注意一下。\n配置文件中使用选项 # 在命令行中设置启动选项只对当次启动生效,也就是说如果下一次重启程序的时候我们还想保留这些启动选项的话,还得重复把这些选项写到启动命令行中,这样真的很烦呀!于是设计MySQL的大佬们提出一种配置文件(也称为选项文件)的概念,我们把需要设置的启动选项都写在这个配置文件中,每次启动服务器的时候都从这个文件里加载相应的启动选项。由于这个配置文件可以长久的保存在计算机的硬盘里,所以只需我们配置一次,以后就都不用显式的把启动选项都写在启动命令行中了,所以我们推荐使用配置文件的方式来设置启动选项。\n配置文件的路径 # MySQL程序在启动时会寻找多个路径下的配置文件,这些路径有的是固定的,有的是可以在命令行指定的。根据操作系统的不同,配置文件的路径也有所不同,我们分开看一下。\nWindows操作系统的配置文件 # 在Windows操作系统中,MySQL会按照下列路径来寻找配置文件: 路径名 备注 %WINDIR%\\my.ini, %WINDIR%\\my.cnf C:\\my.ini, C:\\my.cnf BASEDIR\\my.ini, BASEDIR\\my.cnf defaults-extra-file 命令行指定的额外配置文件路径 %APPDATA%\\MySQL\\.mylogin.cnf 登录路径选项(仅限客户端) 在阅读这些Windows操作系统下配置文件路径的时候需要注意一些事情:\n在给定的前三个路径中,配置文件可以使用.ini的扩展名,也可以使用.cnf的扩展名。\n%WINDIR%指的是你机器上Windows目录的位置,通常是C:\\WINDOWS,如果你不确定,可以使用这个命令来查看:\necho %WINDIR%\nBASEDIR指的是MySQL安装目录的路径,在我的Windows机器上的BASEDIR的值是:\nC:\\Program Files\\MySQL\\MySQL Server 5.7\n第四个路径指的是我们在启动程序时可以通过指定defaults-extra-file参数的值来添加额外的配置文件路径,比方说我们在命令行上可以这么写:\nmysqld --defaults-extra-file=C:\\Users\\xiaohaizi\\my_extra_file.txt 这样MySQL服务器启动时就可以额外在C:\\Users\\xiaohaizi\\my_extra_file.txt这个路径下查找配置文件。\n%APPDATA%表示Windows应用程序数据目录的值,可以使用下列命令查看:\necho %APPDATA%\n列表中最后一个名为.mylogin.cnf配置文件有点儿特殊,它不是一个纯文本文件(其他的配置文件都是纯文本文件),而是使用mysql_config_editor实用程序创建的加密文件。文件中只能包含一些用于启动客户端软件时连接服务器的一些选项,包括 host、user、password、port和 socket。而且它只能被客户端程序所使用。\n小贴士:mysql_config_editor实用程序其实是MySQL安装目录下的bin目录下的一个可执行文件,这个实用程序有专用的语法来生成或修改 .mylogin.cnf 文件中的内容,如何使用这个程序不是我们讨论的主题,可以到MySQL的官方文档中查看。\n类Unix操作系统中的配置文件 # 在类UNIX操作系统中,MySQL会按照下列路径来寻找配置文件: 路径名 备注 /etc/my.cnf /etc/mysql/my.cnf SYSCONFDIR/my.cnf $MYSQL_HOME/my.cnf 特定于服务器的选项(仅限服务器) defaults-extra-file 命令行指定的额外配置文件路径 ~/.my.cnf 用户特定选项 ~/.mylogin.cnf 用户特定的登录路径选项(仅限客户端) 在阅读这些UNIX操作系统下配置文件路径的时候需要注意一些事情:\nSYSCONFDIR表示在使用CMake构建MySQL时使用SYSCONFDIR选项指定的目录。默认情况下,这是位于编译安装目录下的etc目录。 小贴士:如果你不懂什么是个CMAKE,什么是个编译,那就跳过吧,对我们后续的文章没什么影响。 - MYSQL_HOME是一个环境变量,该变量的值是我们自己设置的,我们想设置就设置,不想设置就不设置。该变量的值代表一个路径,我们可以在该路径下创建一个my.cnf配置文件,那么这个配置文件中只能放置关于启动服务器程序相关的选项(言外之意就是其他的配置文件既能存放服务器相关的选项也能存放客户端相关的选项,.mylogin.cnf除外,它只能存放客户端相关的一些选项)。\n小贴士:如果大家使用mysqld_safe启动服务器程序,而且我们也没有主动设置这个MySQL_HOME环境变量的值,那这个环境变量的值将自动被设置为MySQL的安装目录,也就是MySQL服务器将会在安装目录下查找名为my.cnf配置文件(别忘了mysql.server会调用mysqld_safe,所以使用mysql.server启动服务器时也会在安装目录下查找配置文件)。\n列表中的最后两个以~开头的路径是用户相关的,类UNIX系统中都有一个当前登陆用户的概念,每个用户都可以有一个用户目录,~就代表这个用户目录,大家可以查看HOME环境变量的值来确定一下当前用户的用户目录,比方说我的macOS机器上的用户目录就是/Users/xiaohaizi。之所以说列表中最后两个配置文件是用户相关的,是因为不同的类UNIX系统的用户都可以在自己的用户目录下创建.my.cnf或者.mylogin.cnf,换句话说,不同登录用户使用的.my.cnf或者.mylogin.cnf配置文件是不同的。\ndefaults-extra-file的含义与Windows中的一样。\n.mylogin.cnf的含义也同Windows中的一样,再次强调一遍,它不是纯文本文件,只能使用mysql_config_editor实用程序去创建或修改,用于存放客户端登陆服务器时的相关选项。\n这也就是说,在我的计算机中这几个路径中的任意一个都可以当作配置文件来使用,如果它们不存在,你可以手动创建一个,比方说我手动在~/.my.cnf这个路径下创建一个配置文件。\n另外,我们在介绍如何启动MySQL服务器程序的时候说过,使用mysqld_safe程序启动服务器时,会间接调用mysqld,所以对于传递给mysqld_safe的启动选项来说,如果mysqld_safe程序不处理,会接着传递给mysqld程序处理。比方说skip-networking选项是由mysqld处理的,mysqld_safe并不处理,但是如果我们我们在命令行上这样执行: mysqld_safe --skip-networking 则在mysqld_safe调用mysqld时,会把它处理不了的这个skip-networking选项交给mysqld处理。\n配置文件的内容 # 与在命令行中指定启动选项不同的是,配置文件中的启动选项被划分为若干个组,每个组有一个组名,用中括号[]扩起来,像这样: ``` [server] (具体的启动选项\u0026hellip;)\n[mysqld] (具体的启动选项\u0026hellip;)\n[mysqld_safe] (具体的启动选项\u0026hellip;)\n[client] (具体的启动选项\u0026hellip;)\n[mysql] (具体的启动选项\u0026hellip;)\n[mysqladmin] (具体的启动选项\u0026hellip;) ``` 像这个配置文件里就定义了许多个组,组名分别是server、mysqld、mysqld_safe、client、mysql、mysqladmin。每个组下面可以定义若干个启动选项,我们以[server]组为例来看一下填写启动选项的形式(其他组中启动选项的形式是一样的):\n[server] option1 #这是option1,该选项不需要选项值 option2 = value2 #这是option2,该选项需要选项值 ... 在配置文件中指定启动选项的语法类似于命令行语法,但是配置文件中只能使用长形式的选项。在配置文件中指定的启动选项不允许加--前缀,并且每行只指定一个选项,而且=周围可以有空白字符(命令行中选项名、=、选项值之间不允许有空白字符)。另外,在配置文件中,我们可以使用#来添加注释,从#出现直到行尾的内容都属于注释内容,读取配置文件时会忽略这些注释内容。为了大家更容易对比启动选项在命令行和配置文件中指定的区别,我们再把命令行中指定option1和option2两个选项的格式写一遍看看: --option1 --option2=value2 配置文件中不同的选项组是给不同的启动命令使用的,如果选项组名称与程序名称相同,则组中的选项将专门应用于该程序。例如,[mysqld]和[mysql]组分别应用于mysqld服务器程序和mysql客户端程序。不过有两个选项组比较特别:\n[server]组下面的启动选项将作用于所有的服务器程序。\n[client]组下面的启动选项将作用于所有的客户端程序。\n需要注意的一点是,mysqld_safe和mysql.server这两个程序在启动时都会读取[mysqld]选项组中的内容。为了直观感受一下,我们挑一些启动命令来看一下它们能读取的选项组都有哪些: 启动命令 类别 能读取的组 mysqld 启动服务器 [mysqld]、[server] mysqld_safe 启动服务器 [mysqld]、[server]、[mysqld_safe] mysql.server 启动服务器 [mysqld]、[server]、[mysql.server] mysql 启动客户端 [mysql]、[client] mysqladmin 启动客户端 [mysqladmin]、[client] mysqldump 启动客户端 [mysqldump]、[client] 现在我们以macOS操作系统为例,在/etc/mysql/my.cnf这个配置文件中添加一些内容(Windows系统参考上面提到的配置文件路径): [server] skip-networking default-storage-engine=MyISAM 然后直接用mysqld启动服务器程序: mysqld 虽然在命令行没有添加启动选项,但是在程序启动的时候,就会默认的到我们上面提到的配置文件路径下查找配置文件,其中就包括/etc/mysql/my.cnf。又由于mysqld命令可以读取[server]选项组的内容,所以skip-networking和default-storage-engine=MyISAM这两个选项是生效的。你可以把这些启动选项放在[client]组里再试试用mysqld启动服务器程序,看一下里边的启动选项生效不(剧透一下,不生效)。 小贴士:如果我们想指定mysql.server程序的启动参数,则必须将它们放在配置文件中,而不是放在命令行中。mysql.server仅支持start和stop作为命令行参数。\n特定MySQL版本的专用选项组 # 我们可以在选项组的名称后加上特定的MySQL版本号,比如对于[mysqld]选项组来说,我们可以定义一个[mysqld-5.7]的选项组,它的含义和[mysqld]一样,只不过只有版本号为5.7的mysqld程序才能使用这个选项组中的选项。\n配置文件的优先级 # 我们前面介绍过MySQL将在某些固定的路径下搜索配置文件,我们也可以通过在命令行上指定defaults-extra-file启动选项来指定额外的配置文件路径。MySQL将按照我们在上表中给定的顺序依次读取各个配置文件,如果该文件不存在则忽略。值得注意的是,如果我们在多个配置文件中设置了相同的启动选项,那以最后一个配置文件中的为准。比方说/etc/my.cnf文件的内容是这样的: [server] default-storage-engine=InnoDB 而~/.my.cnf文件中的内容是这样的: [server] default-storage-engine=MyISAM 又因为~/.my.cnf比/etc/my.cnf顺序靠后,所以如果两个配置文件中出现相同的启动选项,以~/.my.cnf中的为准,所以MySQL服务器程序启动之后,default-storage-engine的值就是MyISAM。\n同一个配置文件中多个组的优先级 # 我们说同一个命令可以访问配置文件中的多个组,比如mysqld可以访问[mysqld]、[server]组,如果在同一个配置文件中,比如~/.my.cnf,在这些组里出现了同样的配置项,比如这样: ``` [server] default-storage-engine=InnoDB\n[mysqld] default-storage-engine=MyISAM ``` 那么,将以最后一个出现的组中的启动选项为准,比方说例子中default-storage-engine既出现在[mysqld]组也出现在[server]组,因为[mysqld]组在[server]组后边,就以[mysqld]组中的配置项为准。\ndefaults-file的使用 # 如果我们不想让MySQL到默认的路径下搜索配置文件(就是上表中列出的那些),可以在命令行指定defaults-file选项,比如这样(以UNIX系统为例): mysqld --defaults-file=/tmp/myconfig.txt 这样,在程序启动的时候将只在/tmp/myconfig.txt路径下搜索配置文件。如果文件不存在或无法访问,则会发生错误。 小贴士:注意defaults-extra-file和defaults-file的区别,使用defaults-extra-file可以指定额外的配置文件搜索路径(也就是说那些固定的配置文件路径也会被搜索)。\n命令行和配置文件中启动选项的区别 # 在命令行上指定的绝大部分启动选项都可以放到配置文件中,但是有一些选项是专门为命令行设计的,比方说defaults-extra-file、defaults-file这样的选项本身就是为了指定配置文件路径的,再放在配置文件中使用就没什么意义了。剩下的一些只能用在命令行上而不能用到配置文件中的启动选项就不一一列举了,用到的时候再提(本书中基本用不到,有兴趣的到官方文档看)。\n另外有一点需要特别注意,如果同一个启动选项既出现在命令行中,又出现在配置文件中,那么以命令行中的启动选项为准!比如我们在配置文件中写了: [server] default-storage-engine=InnoDB 而我们的启动命令是: mysql.server start --default-storage-engine=MyISAM 那最后default-storage-engine的值就是MyISAM!\n系统变量 # 系统变量简介 # MySQL服务器程序运行过程中会用到许多影响程序行为的变量,它们被称为MySQL系统变量,比如允许同时连入的客户端数量用系统变量max_connections表示,表的默认存储引擎用系统变量default_storage_engine表示,查询缓存的大小用系统变量query_cache_size表示,MySQL服务器程序的系统变量有好几百条,我们就不一一列举了。每个系统变量都有一个默认值,我们可以使用命令行或者配置文件中的选项在启动服务器时改变一些系统变量的值。大多数的系统变量的值也可以在程序运行过程中修改,而无需停止并重新启动它。\n查看系统变量 # 我们可以使用下列命令查看MySQL服务器程序支持的系统变量以及它们的当前值: SHOW VARIABLES [LIKE 匹配的模式]; 由于系统变量实在太多了,如果我们直接使用SHOW VARIABLES查看的话就直接刷屏了,所以通常都会带一个LIKE过滤条件来查看我们需要的系统变量的值,比方说这么写: ``` mysql\u0026gt; SHOW VARIABLES LIKE \u0026lsquo;default_storage_engine\u0026rsquo;; +\u0026mdash;\u0026mdash;\u0026mdash;\u0026mdash;\u0026mdash;\u0026mdash;\u0026mdash;\u0026mdash;+\u0026mdash;\u0026mdash;\u0026ndash;+ | Variable_name | Value | +\u0026mdash;\u0026mdash;\u0026mdash;\u0026mdash;\u0026mdash;\u0026mdash;\u0026mdash;\u0026mdash;+\u0026mdash;\u0026mdash;\u0026ndash;+ | default_storage_engine | InnoDB | +\u0026mdash;\u0026mdash;\u0026mdash;\u0026mdash;\u0026mdash;\u0026mdash;\u0026mdash;\u0026mdash;+\u0026mdash;\u0026mdash;\u0026ndash;+ 1 row in set (0.01 sec)\nmysql\u0026gt; SHOW VARIABLES like \u0026lsquo;max_connections\u0026rsquo;; +\u0026mdash;\u0026mdash;\u0026mdash;\u0026mdash;\u0026mdash;\u0026ndash;+\u0026mdash;\u0026mdash;-+ | Variable_name | Value | +\u0026mdash;\u0026mdash;\u0026mdash;\u0026mdash;\u0026mdash;\u0026ndash;+\u0026mdash;\u0026mdash;-+ | max_connections | 151 | +\u0026mdash;\u0026mdash;\u0026mdash;\u0026mdash;\u0026mdash;\u0026ndash;+\u0026mdash;\u0026mdash;-+ 1 row in set (0.00 sec) 可以看到,现在服务器程序使用的默认存储引擎就是InnoDB,允许同时连接的客户端数量最多为151。别忘了LIKE表达式后边可以跟通配符来进行模糊查询,也就是说我们可以这么写: mysql\u0026gt; SHOW VARIABLES LIKE \u0026lsquo;default%\u0026rsquo;; +\u0026mdash;\u0026mdash;\u0026mdash;\u0026mdash;\u0026mdash;\u0026mdash;\u0026mdash;\u0026mdash;\u0026mdash;\u0026mdash;-+\u0026mdash;\u0026mdash;\u0026mdash;\u0026mdash;\u0026mdash;\u0026mdash;\u0026mdash;\u0026ndash;+ | Variable_name | Value | +\u0026mdash;\u0026mdash;\u0026mdash;\u0026mdash;\u0026mdash;\u0026mdash;\u0026mdash;\u0026mdash;\u0026mdash;\u0026mdash;-+\u0026mdash;\u0026mdash;\u0026mdash;\u0026mdash;\u0026mdash;\u0026mdash;\u0026mdash;\u0026ndash;+ | default_authentication_plugin | mysql_native_password | | default_password_lifetime | 0 | | default_storage_engine | InnoDB | | default_tmp_storage_engine | InnoDB | | default_week_format | 0 | +\u0026mdash;\u0026mdash;\u0026mdash;\u0026mdash;\u0026mdash;\u0026mdash;\u0026mdash;\u0026mdash;\u0026mdash;\u0026mdash;-+\u0026mdash;\u0026mdash;\u0026mdash;\u0026mdash;\u0026mdash;\u0026mdash;\u0026mdash;\u0026ndash;+ 5 rows in set (0.01 sec)\nmysql\u0026gt; ``` 这样就查出了所有以default开头的系统变量的值。\n设置系统变量 # 通过启动选项设置 # 大部分的系统变量都可以通过启动服务器时传送启动选项的方式来进行设置。如何填写启动选项我们上面已经花了大篇幅来介绍了,就是下面两种方式:\n通过命令行添加启动选项。\n比方说我们在启动服务器程序时用这个命令: mysqld --default-storage-engine=MyISAM --max-connections=10 - 通过配置文件添加启动选项。\n我们可以这样填写配置文件: [server] default-storage-engine=MyISAM max-connections=10\n当使用上面两种方式中的任意一种启动服务器程序后,我们再来查看一下系统变量的值: ``` mysql\u0026gt; SHOW VARIABLES LIKE \u0026lsquo;default_storage_engine\u0026rsquo;; +\u0026mdash;\u0026mdash;\u0026mdash;\u0026mdash;\u0026mdash;\u0026mdash;\u0026mdash;\u0026mdash;+\u0026mdash;\u0026mdash;\u0026ndash;+ | Variable_name | Value | +\u0026mdash;\u0026mdash;\u0026mdash;\u0026mdash;\u0026mdash;\u0026mdash;\u0026mdash;\u0026mdash;+\u0026mdash;\u0026mdash;\u0026ndash;+ | default_storage_engine | MyISAM | +\u0026mdash;\u0026mdash;\u0026mdash;\u0026mdash;\u0026mdash;\u0026mdash;\u0026mdash;\u0026mdash;+\u0026mdash;\u0026mdash;\u0026ndash;+ 1 row in set (0.00 sec)\nmysql\u0026gt; SHOW VARIABLES LIKE \u0026lsquo;max_connections\u0026rsquo;; +\u0026mdash;\u0026mdash;\u0026mdash;\u0026mdash;\u0026mdash;\u0026ndash;+\u0026mdash;\u0026mdash;-+ | Variable_name | Value | +\u0026mdash;\u0026mdash;\u0026mdash;\u0026mdash;\u0026mdash;\u0026ndash;+\u0026mdash;\u0026mdash;-+ | max_connections | 10 | +\u0026mdash;\u0026mdash;\u0026mdash;\u0026mdash;\u0026mdash;\u0026ndash;+\u0026mdash;\u0026mdash;-+ 1 row in set (0.00 sec)\nmysql\u0026gt; ``` 可以看到default_storage_engine和max_connections这两个系统变量的值已经被修改了。有一点需要注意的是,对于启动选项来说,如果启动选项名由多个单词组成,各个单词之间用短划线-或者下划线*连接起来都可以,但是对应的系统变量之间必须使用下划线*连接起来。\n服务器程序运行过程中设置 # 系统变量比较牛逼的一点就是,对于大部分系统变量来说,它们的值可以在服务器程序运行过程中,进行动态修改而无需停止并重启服务器。不过系统变量有作用范围之分,下面详细介绍下。\n设置不同作用范围的系统变量 # 我们前面说过,多个客户端程序可以同时连接到一个服务器程序。对于同一个系统变量,我们有时想让不同的客户端有不同的值。比方说狗哥使用客户端A,他想让当前客户端对应的默认存储引擎为InnoDB,所以他可以把系统变量default_storage_engine的值设置为InnoDB;猫爷使用客户端B,他想让当前客户端对应的默认存储引擎为MyISAM,所以他可以把系统变量default_storage_engine的值设置为MyISAM。这样可以使狗哥和猫爷的的客户端拥有不同的默认存储引擎,使用时互不影响,十分方便。但是这样各个客户端都私有一份系统变量会产生这么两个问题:\n有一些系统变量并不是针对单个客户端的,比如允许同时连接到服务器的客户端数量max_connections,查询缓存的大小query_cache_size,这些公有的系统变量让某个客户端私有显然不合适。\n一个新连接到服务器的客户端对应的系统变量的值该怎么设置?\n为了解决这两个问题,设计MySQL的大佬提出了系统变量的作用范围的概念,具体来说作用范围分为这两种:\nGLOBAL:全局变量,影响服务器的整体操作。\nSESSION:会话变量,影响某个客户端连接的操作。(注:SESSION有个别名叫LOCAL)\n在服务器启动时,会将每个全局变量初始化为其默认值(可以通过命令行或选项文件中指定的选项更改这些默认值)。然后服务器还为每个连接的客户端维护一组会话变量,客户端的会话变量在连接时使用相应全局变量的当前值初始化。\n这话有点儿绕,还是以default_storage_engine举例,在服务器启动时会初始化一个名为default_storage_engine,作用范围为GLOBAL的系统变量。之后每当有一个客户端连接到该服务器时,服务器都会单独为该客户端分配一个名为default_storage_engine,作用范围为SESSION的系统变量,该作用范围为SESSION的系统变量值按照当前作用范围为GLOBAL的同名系统变量值进行初始化。\n很显然,通过启动选项设置的系统变量的作用范围都是GLOBAL的,也就是对所有客户端都有效的,因为在系统启动的时候还没有客户端程序连接进来呢。了解了系统变量的GLOBAL和SESSION作用范围之后,我们再看一下在服务器程序运行期间通过客户端程序设置系统变量的语法: SET [GLOBAL|SESSION] 系统变量名 = 值; 或者写成这样也行: SET [@@(GLOBAL|SESSION).]var_name = XXX; 比如我们想在服务器运行过程中把作用范围为GLOBAL的系统变量default_storage_engine的值修改为MyISAM,也就是想让后面新连接到服务器的客户端都用MyISAM作为默认的存储引擎,那我们可以选择下面两条语句中的任意一条来进行设置: 语句一:SET GLOBAL default_storage_engine = MyISAM; 语句二:SET @@GLOBAL.default_storage_engine = MyISAM; 如果只想对本客户端生效,也可以选择下面三条语句中的任意一条来进行设置: 语句一:SET SESSION default_storage_engine = MyISAM; 语句二:SET @@SESSION.default_storage_engine = MyISAM; 语句三:SET default_storage_engine = MyISAM; 从上面的语句三也可以看出,如果在设置系统变量的语句中省略了作用范围,默认的作用范围就是SESSION。也就是说SET 系统变量名 = 值和SET SESSION 系统变量名 = 值是等价的。\n查看不同作用范围的系统变量 # 既然系统变量有作用范围之分,那我们的SHOW VARIABLES语句查看的是什么作用范围的系统变量呢?\n答:默认查看的是SESSION作用范围的系统变量。\n当然我们也可以在查看系统变量的语句上加上要查看哪个作用范围的系统变量,就像这样: SHOW [GLOBAL|SESSION] VARIABLES [LIKE 匹配的模式]; 下面我们演示一下完整的设置并查看系统变量的过程: ``` mysql\u0026gt; SHOW SESSION VARIABLES LIKE \u0026lsquo;default_storage_engine\u0026rsquo;; +\u0026mdash;\u0026mdash;\u0026mdash;\u0026mdash;\u0026mdash;\u0026mdash;\u0026mdash;\u0026mdash;+\u0026mdash;\u0026mdash;\u0026ndash;+ | Variable_name | Value | +\u0026mdash;\u0026mdash;\u0026mdash;\u0026mdash;\u0026mdash;\u0026mdash;\u0026mdash;\u0026mdash;+\u0026mdash;\u0026mdash;\u0026ndash;+ | default_storage_engine | InnoDB | +\u0026mdash;\u0026mdash;\u0026mdash;\u0026mdash;\u0026mdash;\u0026mdash;\u0026mdash;\u0026mdash;+\u0026mdash;\u0026mdash;\u0026ndash;+ 1 row in set (0.00 sec)\nmysql\u0026gt; SHOW GLOBAL VARIABLES LIKE \u0026lsquo;default_storage_engine\u0026rsquo;; +\u0026mdash;\u0026mdash;\u0026mdash;\u0026mdash;\u0026mdash;\u0026mdash;\u0026mdash;\u0026mdash;+\u0026mdash;\u0026mdash;\u0026ndash;+ | Variable_name | Value | +\u0026mdash;\u0026mdash;\u0026mdash;\u0026mdash;\u0026mdash;\u0026mdash;\u0026mdash;\u0026mdash;+\u0026mdash;\u0026mdash;\u0026ndash;+ | default_storage_engine | InnoDB | +\u0026mdash;\u0026mdash;\u0026mdash;\u0026mdash;\u0026mdash;\u0026mdash;\u0026mdash;\u0026mdash;+\u0026mdash;\u0026mdash;\u0026ndash;+ 1 row in set (0.00 sec)\nmysql\u0026gt; SET SESSION default_storage_engine = MyISAM; Query OK, 0 rows affected (0.00 sec)\nmysql\u0026gt; SHOW SESSION VARIABLES LIKE \u0026lsquo;default_storage_engine\u0026rsquo;; +\u0026mdash;\u0026mdash;\u0026mdash;\u0026mdash;\u0026mdash;\u0026mdash;\u0026mdash;\u0026mdash;+\u0026mdash;\u0026mdash;\u0026ndash;+ | Variable_name | Value | +\u0026mdash;\u0026mdash;\u0026mdash;\u0026mdash;\u0026mdash;\u0026mdash;\u0026mdash;\u0026mdash;+\u0026mdash;\u0026mdash;\u0026ndash;+ | default_storage_engine | MyISAM | +\u0026mdash;\u0026mdash;\u0026mdash;\u0026mdash;\u0026mdash;\u0026mdash;\u0026mdash;\u0026mdash;+\u0026mdash;\u0026mdash;\u0026ndash;+ 1 row in set (0.00 sec)\nmysql\u0026gt; SHOW GLOBAL VARIABLES LIKE \u0026lsquo;default_storage_engine\u0026rsquo;; +\u0026mdash;\u0026mdash;\u0026mdash;\u0026mdash;\u0026mdash;\u0026mdash;\u0026mdash;\u0026mdash;+\u0026mdash;\u0026mdash;\u0026ndash;+ | Variable_name | Value | +\u0026mdash;\u0026mdash;\u0026mdash;\u0026mdash;\u0026mdash;\u0026mdash;\u0026mdash;\u0026mdash;+\u0026mdash;\u0026mdash;\u0026ndash;+ | default_storage_engine | InnoDB | +\u0026mdash;\u0026mdash;\u0026mdash;\u0026mdash;\u0026mdash;\u0026mdash;\u0026mdash;\u0026mdash;+\u0026mdash;\u0026mdash;\u0026ndash;+ 1 row in set (0.00 sec)\nmysql\u0026gt; 可以看到,最初default_storage_engine的系统变量无论是在GLOBAL作用范围上还是在SESSION作用范围上的值都是InnoDB,我们在SESSION作用范围把它的值设置为MyISAM之后,可以看到GLOBAL作用范围的值并没有改变。 小贴士:如果某个客户端改变了某个系统变量在GLOBAL作用范围的值,并不会影响该系统变量在当前已经连接的客户端作用范围为SESSION的值,只会影响后续连入的客户端在作用范围为SESSION的值。 ```\n注意事项 # 并不是所有系统变量都具有GLOBAL和SESSION的作用范围。\n+ 有一些系统变量只具有GLOBAL作用范围,比方说max_connections,表示服务器程序支持同时最多有多少个客户端程序进行连接。\n+ 有一些系统变量只具有SESSION作用范围,比如insert_id,表示在对某个包含AUTO_INCREMENT列的表进行插入时,该列初始的值。\n+ 有一些系统变量的值既具有GLOBAL作用范围,也具有SESSION作用范围,比如我们前面用到的default_storage_engine,而且其实大部分的系统变量都是这样的,\n有些系统变量是只读的,并不能设置值。\n比方说version,表示当前MySQL的版本,我们客户端是不能设置它的值的,只能在SHOW VARIABLES语句里查看。\n启动选项和系统变量的区别 # 启动选项是在程序启动时我们程序员传递的一些参数,而系统变量是影响服务器程序运行行为的变量,它们之间的关系如下:\n大部分的系统变量都可以被当作启动选项传入。\n有些系统变量是在程序运行过程中自动生成的,是不可以当作启动选项来设置,比如auto_increment_offset、character_set_client等。\n有些启动选项也不是系统变量,比如defaults-file。\n状态变量 # 为了让我们更好的了解服务器程序的运行情况,MySQL服务器程序中维护了很多关于程序运行状态的变量,它们被称为状态变量。比方说Threads_connected表示当前有多少客户端与服务器建立了连接,Handler_update表示已经更新了多少行记录等,像这样显示服务器程序状态信息的状态变量还有好几百个,我们就不一一介绍了,等遇到了会详细说它们的作用的。\n由于状态变量是用来显示服务器程序运行状况的,所以它们的值只能由服务器程序自己来设置,我们程序员是不能设置的。与系统变量类似,状态变量也有GLOBAL和SESSION两个作用范围的,所以查看状态变量的语句可以这么写: SHOW [GLOBAL|SESSION] STATUS [LIKE 匹配的模式]; 类似的,如果我们不写明作用范围,默认的作用范围是SESSION,比方说这样: ``` mysql\u0026gt; SHOW STATUS LIKE \u0026rsquo;thread%\u0026rsquo;; +\u0026mdash;\u0026mdash;\u0026mdash;\u0026mdash;\u0026mdash;\u0026mdash;-+\u0026mdash;\u0026mdash;-+ | Variable_name | Value | +\u0026mdash;\u0026mdash;\u0026mdash;\u0026mdash;\u0026mdash;\u0026mdash;-+\u0026mdash;\u0026mdash;-+ | Threads_cached | 0 | | Threads_connected | 1 | | Threads_created | 1 | | Threads_running | 1 | +\u0026mdash;\u0026mdash;\u0026mdash;\u0026mdash;\u0026mdash;\u0026mdash;-+\u0026mdash;\u0026mdash;-+ 4 rows in set (0.00 sec)\nmysql\u0026gt; ``` 所有以Thread开头的SESSION作用范围的状态变量就都被展示出来了。\n"},{"id":31,"href":"/zh/docs/technology/MySQL/MySQL%E6%98%AF%E6%80%8E%E6%A0%B7%E8%BF%90%E8%A1%8C%E7%9A%84/%E7%AC%AC1%E7%AB%A0_%E8%A3%85%E4%BD%9C%E8%87%AA%E5%B7%B1%E6%98%AF%E4%B8%AA%E5%B0%8F%E7%99%BD-%E9%87%8D%E6%96%B0%E8%AE%A4%E8%AF%86MySQL/","title":"第1章_装作自己是个小白-重新认识MySQL","section":"My Sql是怎样运行的","content":"第1章 装作自己是个小白-重新认识MySQL\nMySQL的客户端/服务器架构 # 以我们平时使用的微信为例,它其实是由两部分组成的,一部分是客户端程序,一部分是服务器程序。客户端可能有很多种形式,比如手机APP,电脑软件或者是网页版微信,每个客户端都有一个唯一的用户名,就是你的微信号,另一方面,腾讯公司在他们的机房里运行着一个服务器软件,我们平时操作微信其实都是用客户端来和这个服务器来打交道。比如狗哥用微信给猫爷发了一条消息的过程其实是这样的:\n消息被客户端包装了一下,添加了发送者和接收者信息,然后从狗哥的微信客户端传送给微信服务器; 微信服务器从消息里获取到它的发送者和接收者,根据消息的接收者信息把这条消息送达到猫爷的微信客户端,猫爷的微信客户端里就显示出狗哥给他发了一条消息。 MySQL的使用过程跟这个是一样的,它的服务器程序直接和我们存储的数据打交道,然后可以有好多客户端程序连接到这个服务器程序,发送增删改查的请求,然后服务器就响应这些请求,从而操作它维护的数据。和微信一样,MySQL的每个客户端都需要提供用户名密码才能登录,登录之后才能给服务器发请求来操作某些数据。我们日常使用MySQL的情景一般是这样的:\n启动MySQL服务器程序。 启动MySQL客户端程序并连接到服务器程序。 在客户端程序中输入一些命令语句作为请求发送到服务器程序,服务器程序收到这些请求后,会根据请求的内容来操作具体的数据并向客户端返回操作结果。 我们知道计算机很牛逼,在一台计算机上可以同时运行多个程序,比如微信、QQ、音乐播放器、文本编辑器等,每一个运行着的程序也被称为一个进程。我们的MySQL服务器程序和客户端程序本质上都算是计算机上的一个进程,这个代表着MySQL服务器程序的进程也被称为MySQL数据库实例,简称数据库实例。\n每个进程都有一个唯一的编号,称为进程ID,英文名叫PID,这个编号是在我们启动程序的时候由操作系统随机分配的,操作系统会保证在某一时刻同一台机器上的进程号不重复。比如你打开了计算机中的QQ程序,那么操作系统会为它分配一个唯一的进程号,如果你把这个程序关掉了,那操作系统就会把这个进程号回收,之后可能会重新分配给别的进程。当我们下一次再启动 QQ程序的时候分配的就可能是另一个编号。每个进程都有一个名称,这个名称是编写程序的人自己定义的,比如我们启动的MySQL服务器进程的默认名称为mysqld, 而我们常用的MySQL客户端进程的默认名称为mysql。\nMySQL的安装 # 不论我们通过下载源代码自行编译安装的方式,还是直接使用官方提供的安装包进行安装之后,MySQL的服务器程序和客户端程序都会被安装到我们的机器上。不论使用上述两者的哪种安装方式,一定一定一定(重要的话说三遍)要记住你把MySQL安装到哪了,换句话说,一定要记住MySQL的安装目录。 小贴士:MySQL的大部分安装包都包含了服务器程序和客户端程序,不过在Linux下使用RPM包时会有单独的服务器RPM包和客户端RPM包,需要分别安装。\n另外,MySQL可以运行在各种各样的操作系统上,我们后边会讨论在类UNIX操作系统和Windows操作系统上使用的一些差别。为了方便大家理解,我在macOS 操作系统(苹果电脑使用的操作系统)和Windows操作系统上都安装了MySQL,它们的安装目录分别是:\nmacOS操作系统上的安装目录: /usr/local/mysql/\nWindows操作系统上的安装目录: C:\\Program Files\\MySQL\\MySQL Server 5.7\n下面我会以这两个安装目录为例来进一步扯出更多的概念,不过一定要注意,这两个安装目录是我的运行不同操作系统的机器上的安装目录,一定要记着把下面示例中用到安装目录的地方替换为你自己机器上的安装目录。 小贴士:类UNIX操作系统非常多,比如FreeBSD、Linux、macOS、Solaris等都属于UNIX操作系统的范畴,我们这里使用macOS操作系统代表类UNIX操作系统来运行MySQL。\nbin目录下的可执行文件 # 在MySQL的安装目录下有一个特别特别重要的bin目录,这个目录下存放着许多可执行文件,以macOS系统为例,这个bin目录的绝对路径就是(在我的机器上): /usr/local/mysql/bin 我们列出一些在macOS中这个bin目录下的一部分可执行文件来看一下(文件太多,全列出来会刷屏的): . ├── mysql ├── mysql.server -\u0026gt; ../support-files/mysql.server ├── mysqladmin ├── mysqlbinlog ├── mysqlcheck ├── mysqld ├── mysqld_multi ├── mysqld_safe ├── mysqldump ├── mysqlimport ├── mysqlpump ... (省略其他文件) 0 directories, 40 files Windows中的可执行文件与macOS中的类似,不过都是以.exe为扩展名的。这些可执行文件都是与服务器程序和客户端程序相关的,后边我们会详细介绍一些比较重要的可执行文件,现在先看看执行这些文件的方式。\n对于有可视化界面的操作系统来说,我们拿着鼠标点点点就可以执行某个可执行文件,不过现在我们更关注在命令行环境下如何执行这些可执行文件,命令行通俗的说就是那些黑框框,这里的指的是类UNIX系统中的Shell或者Windows系统中的cmd.exe,如果你现在还不知道怎么启动这些命令行工具,网上搜搜吧~ 下面我们以macOS系统为例来看看如何启动这些可执行文件(Windows中的操作是类似的,依葫芦画瓢就好了)\n使用可执行文件的相对/绝对路径\n假设我们现在所处的工作目录是MySQL的安装目录,也就是/usr/local/mysql,我们想启动bin目录下的mysqld这个可执行文件,可以使用相对路径来启动: ./bin/mysqld 或者直接输入mysqld的绝对路径也可以: /usr/local/mysql/bin/mysqld\n将该bin目录的路径加入到环境变量PATH中\n如果我们觉得每次执行一个文件都要输入一串长长的路径名贼麻烦的话,可以把该bin目录所在的路径添加到环境变量PATH中。环境变量PATH是一系列路径的集合,各个路径之间使用冒号:隔离开,比方说我的机器上的环境变量PATH的值就是: /usr/local/bin:/usr/bin:/bin:/usr/sbin:/sbin 我的系统中这个环境变量PATH的值表明:当我在输入一个命令时,系统便会在/usr/local/bin、/usr/bin:、/bin:、/usr/sbin、/sbin这些目录下依次寻找是否存在我们输入的那个命令,如果寻找成功,则执行该目录下对应的可执行文件。所以我们现在可以修改一下这个环境变量PATH,把MySQL安装目录下的bin目录的路径也加入到PATH中,在我的机器上修改后的环境变量PATH的值为: /usr/local/bin:/usr/bin:/bin:/usr/sbin:/sbin:/usr/local/mysql/bin 这样现在不论我们所处的工作目录是什么,我们都可以直接输入可执行文件的名字就可以启动它,比如这样: mysqld 方便多了~\n小贴士:关于什么是环境变量以及如何在当前系统中添加或修改系统变量不是我们介绍的范围,大家找本相关的书或者上网查一查~\n启动MySQL服务器程序 # UNIX里启动服务器程序 # 在类UNIX系统中用来启动MySQL服务器程序的可执行文件有很多,大多在MySQL安装目录的bin目录下,我们一起来看看。\nmysqld # mysqld这个可执行文件就代表着MySQL服务器程序,运行这个可执行文件就可以直接启动一个服务器进程。但这个命令不常用,我们继续往下看更牛逼的启动命令。\nmysqld_safe # mysqld_safe是一个启动脚本,它会间接的调用mysqld,而且还顺便启动了另外一个监控进程,这个监控进程在服务器进程挂了的时候,可以帮助重启它。另外,使用mysqld_safe启动服务器程序时,它会将服务器程序的出错信息和其他诊断信息重定向到某个文件中,产生出错日志,这样可以方便我们找出发生错误的原因。\nmysql.server # mysql.server也是一个启动脚本,它会间接的调用mysqld_safe,在调用mysql.server时在后边指定start参数就可以启动服务器程序了,就像这样: mysql.server start 需要注意的是,这个 mysql.server 文件其实是一个链接文件,它的实际文件是 ../support-files/mysql.server。我使用的macOS操作系统会在bin目录下自动创建一个指向实际文件的链接文件,如果你的操作系统没有帮你自动创建这个链接文件,那就自己创建一个呗~ 别告诉我你不会创建链接文件,上网搜搜呗~\n另外,我们还可以使用mysql.server命令来关闭正在运行的服务器程序,只要把start参数换成stop就好了: mysql.server stop\nmysqld_multi # 其实我们一台计算机上也可以运行多个服务器实例,也就是运行多个MySQL服务器进程。mysql_multi可执行文件可以对每一个服务器进程的启动或停止进行监控。这个命令的使用比较复杂,本书主要是为了讲清楚MySQL服务器和客户端运行的过程,不会对启动多个服务器程序进行过多介绍。\nWindows里启动服务器程序 # Windows里没有像类UNIX系统中那么多的启动脚本,但是也提供了手动启动和以服务的形式启动这两种方式,下面我们详细看。\nmysqld # 同样的,在MySQL安装目录下的bin目录下有一个mysqld可执行文件,在命令行里输入mysqld,或者直接双击运行它就算启动了MySQL服务器程序了。\n以服务的方式运行服务器程序 # 首先看看什么是个Windows 服务?如果无论是谁正在使用这台计算机,我们都需要长时间的运行某个程序,而且需要在计算机启动的时候便启动它,一般我们都会把它注册为一个Windows 服务,操作系统会帮我们管理它。把某个程序注册为Windows服务的方式挺简单,如下: \u0026quot;完整的可执行文件路径\u0026quot; --install [-manual] [服务名] 其中的-manual可以省略,加上它的话,表示在Windows系统启动的时候不自动启动该服务,否则会自动启动。服务名也可以省略,默认的服务名就是MySQL。比如我的Windows计算机上mysqld的完整路径是: C:\\Program Files\\MySQL\\MySQL Server 5.7\\bin\\mysqld 所以如果我们想把它注册为服务的话可以在命令行里这么写: \u0026quot;C:\\Program Files\\MySQL\\MySQL Server 5.7\\bin\\mysqld\u0026quot; --install 在把mysqld注册为Windows服务之后,我们就可以通过下面这个命令来启动MySQL服务器程序了: net start MySQL 当然,如果你喜欢图形界面的话,你可以通过Windows的服务管理器通过用鼠标点点点的方式来启动和停止服务(作为一个程序猿,还是用黑框框吧~)。\n关闭这个服务也非常简单,只要把上面的start换成stop就行了,就像这样: net stop MySQL\n启动MySQL客户端程序 # 在我们成功启动MySQL服务器程序后,就可以接着启动客户端程序来连接到这个服务器喽,bin目录下有许多客户端程序,比方说mysqladmin、mysqldump、mysqlcheck等等(好多呢,就不一一列举了)。这里我们重点要关注的是可执行文件mysql,通过这个可执行文件可以让我们和服务器程序进程交互,也就是发送请求,接收服务器的处理结果。启动这个可执行文件时一般需要一些参数,格式如下: mysql -h主机名 -u用户名 -p密码\n各个参数的意义如下: 参数名 含义 -h 表示服务器进程所在计算机的域名或者IP地址,如果服务器进程就运行在本机的话,可以省略这个参数,或者填localhost或者127.0.0.1。也可以写作 --host=主机名的形式。 -u 表示用户名。也可以写作 --user=用户名的形式。 -p 表示密码。也可以写作 --password=密码的形式。 小贴士:像 h、u、p 这样名称只有一个英文字母的参数称为短形式的参数,使用时前面需要加单短划线,像 host、user、password 这样大于一个英文字母的参数称为长形式的参数,使用时前面需要加双短划线。后边会详细讨论这些参数的使用方式的,稍安勿躁~ 比如我这样执行下面这个可执行文件(用户名密码按你的实际情况填写),就可以启动MySQL客户端,并且连接到服务器了。 mysql -hlocalhost -uroot -p123456 我们看一下连接成功后的界面: ``` Welcome to the MySQL monitor. Commands end with ; or \\g. Your MySQL connection id is 2 Server version: 5.7.21 Homebrew\nCopyright (c) 2000, 2018, Oracle and/or its affiliates. All rights reserved.\nOracle is a registered trademark of Oracle Corporation and/or its affiliates. Other names may be trademarks of their respective owners. Type \u0026lsquo;help;\u0026rsquo; or \u0026lsquo;\\h\u0026rsquo; for help. Type \u0026lsquo;\\c\u0026rsquo; to clear the current input statement.\nmysql\u0026gt; ``` 最后一行的mysql\u0026gt;是一个客户端的提示符,之后客户端发送给服务器的命令都需要写在这个提示符后边。\n如果我们想断开客户端与服务器的连接并且关闭客户端的话,可以在mysql\u0026gt;提示符后输入下面任意一个命令:\n1. `quit` 2. `exit` 3. `\\q` 比如我们输入quit试试: mysql\u0026gt; quit Bye 输出了Bye说明客户端程序已经关掉了。值得注意的是,这是关闭客户端程序的方式,不是关闭服务器程序的方式,怎么关闭服务器程序上一节里介绍过了。\n如果你愿意,你可以多打开几个黑框框,每个黑框框都使用mysql -hlocahhost -uroot -p123456来运行多个客户端程序,每个客户端程序都是互不影响的。如果你有多个电脑,也可以试试把它们用局域网连起来,在一个电脑上启动MySQL服务器程序,在另一个电脑上执行mysql命令时使用IP地址作为主机名来连接到服务器。\n连接注意事项 # 最好不要在一行命令中输入密码。\n我们直接在黑框框里输入密码很可能被别人看到,这和你当着别人的面输入银行卡密码没什么区别,所以我们在执行mysql连接服务器的时候可以不显式的写出密码,就像这样: mysql -hlocahhost -uroot -p 点击回车之后才会提示你输入密码: Enter password: 不过这回你输入的密码不会被显示出来,心怀不轨的人也就看不到了,输入完成点击回车就成功连接到了服务器。\n如果你非要在一行命令中显式的把密码输出来,那-p和密码值之间不能有空白字符(其他参数名之间可以有空白字符),就像这样:\nmysql -h localhost -u root -p123456 如果加上了空白字符就是错误的,比如这样: mysql -h localhost -u root -p 123456\nmysql的各个参数的摆放顺序没有硬性规定,也就是说你也可以这么写:\nmysql -p -u root -h localhost\n如果你的服务器和客户端安装在同一台机器上,-h参数可以省略,就像这样:\nmysql -u root -p\n如果你使用的是类UNIX系统,并且省略-u参数后,会把你登陆操作系统的用户名当作MySQL的用户名去处理。\n比方说我用登录操作系统的用户名是xiaohaizi,那么在我的机器上下面这两条命令是等价的: mysql -u xiaohaizi -p mysql -p 对于Windows系统来说,默认的用户名是ODBC,你可以通过设置环境变量USER来添加一个默认用户名。\n客户端与服务器连接的过程 # 我们现在已经知道如何启动MySQL的服务器程序,以及如何启动客户端程序来连接到这个服务器程序。运行着的服务器程序和客户端程序本质上都是计算机上的一个进程,所以客户端进程向服务器进程发送请求并得到回复的过程本质上是一个进程间通信的过程!MySQL支持下面三种客户端进程和服务器进程的通信方式。\nTCP/IP # 真实环境中,数据库服务器进程和客户端进程可能运行在不同的主机中,它们之间必须通过网络来进行通讯。MySQL采用TCP作为服务器和客户端之间的网络通信协议。在网络环境下,每台计算机都有一个唯一的IP地址,如果某个进程有需要采用TCP协议进行网络通信方面的需求,可以向操作系统申请一个端口号,这是一个整数值,它的取值范围是0~65535。这样在网络中的其他进程就可以通过IP地址 + 端口号的方式来与这个进程连接,这样进程之间就可以通过网络进行通信了。\nMySQL服务器启动的时候会默认申请3306端口号,之后就在这个端口号上等待客户端进程进行连接,用书面一点的话来说,MySQL服务器会默认监听3306端口。 小贴士:TCP/IP 网络体系结构是现在通用的一种网络体系结构,其中的 TCP 和 IP 是体系结构中两个非常重要的网络协议,如果你并不知道协议是什么,或者并不知道网络是什么,那恐怕兄弟你来错地方了,找本计算机网络的书去看看吧! 如果3306端口号已经被别的进程占用了或者我们单纯的想自定义该数据库实例监听的端口号,那可以在启动服务器程序的命令行里添加-P参数来明确指定一下端口号,比如这样: mysqld -P3307 这样MySQL服务器在启动时就会去监听我们指定的端口号3307。\n如果客户端进程想要使用TCP/IP网络来连接到服务器进程,比如我们在使用mysql来启动客户端程序时,在-h参数后必须跟随IP地址来作为需要连接的服务器进程所在主机的主机名,如果客户端进程和服务器进程在一台计算机中的话,我们可以使用127.0.0.1来代表本机的IP地址。另外,如果服务器进程监听的端口号不是默认的3306,我们也可以在使用mysql启动客户端程序时使用-P参数(大写的P,小写的p是用来指定密码的)来指定需要连接到的端口号。比如我们现在已经在本机启动了服务器进程,监听的端口号为3307,那我们启动客户端程序时可以这样写: mysql -h127.0.0.1 -uroot -P3307 -p 不知大家发现了没有,我们在启动服务器程序的命令mysqld和启动客户端程序的命令mysql后边都可以使用-P参数,关于如何在命令后边指定参数,指定哪些参数我们稍后会详细介绍的,稍微等等~\n命名管道和共享内存 # 如果你是一个Windows用户,那么客户端进程和服务器进程之间可以考虑使用命名管道或共享内存进行通信。不过启用这些通信方式的时候需要在启动服务器程序和客户端程序时添加一些参数:\n使用命名管道来进行进程间通信\n需要在启动服务器程序的命令中加上--enable-named-pipe参数,然后在启动客户端程序的命令中加入--pipe或者--protocol=pipe参数。\n使用共享内存来进行进程间通信\n需要在启动服务器程序的命令中加上--shared-memory参数,在成功启动服务器后,共享内存便成为本地客户端程序的默认连接方式,不过我们也可以在启动客户端程序的命令中加入--protocol=memory参数来显式的指定使用共享内存进行通信。\n不过需要注意的是,使用共享内存的方式进行通信的服务器进程和客户端进程必须在同一台Windows主机中。 小贴士:命名管道和共享内存是Windows操作系统中的两种进程间通信方式,如果你没听过的话也不用纠结,并不妨碍我们介绍MySQL的知识~\nUnix域套接字文件 # 如果我们的服务器进程和客户端进程都运行在同一台操作系统为类Unix的机器上的话,我们可以使用Unix域套接字文件来进行进程间通信。如果我们在启动客户端程序的时候指定的主机名为localhost,或者指定了--protocol=socket的启动参数,那服务器程序和客户端程序之间就可以通过Unix域套接字文件来进行通信了。MySQL服务器程序默认监听的Unix域套接字文件路径为/tmp/mysql.sock,客户端程序也默认连接到这个Unix域套接字文件。如果我们想改变这个默认路径,可以在启动服务器程序时指定socket参数,就像这样: mysqld --socket=/tmp/a.txt 这样服务器启动后便会监听/tmp/a.txt。在服务器改变了默认的UNIX域套接字文件后,如果客户端程序想通过UNIX域套接字文件进行通信的话,也需要显式的指定连接到的UNIX域套接字文件路径,就像这样: mysql -hlocalhost -uroot --socket=/tmp/a.txt -p 这样该客户端进程和服务器进程就可以通过路径为/tmp/a.txt的Unix域套接字文件进行通信了。\n服务器处理客户端请求 # 其实不论客户端进程和服务器进程是采用哪种方式进行通信,最后实现的效果都是:客户端进程向服务器进程发送一段文本(MySQL语句),服务器进程处理后再向客户端进程发送一段文本(处理结果)。那服务器进程对客户端进程发送的请求做了什么处理,才能产生最后的处理结果呢?客户端可以向服务器发送增删改查各类请求,我们这里以比较复杂的查询请求为例来画个图展示一下大致的过程:\n从图中我们可以看出,服务器程序处理来自客户端的查询请求大致需要经过三个部分,分别是连接管理、解析与优化、存储引擎。下面我们来详细看一下这三个部分都干了什么。\n连接管理 # 客户端进程可以采用我们上面介绍的TCP/IP、命名管道或共享内存、Unix域套接字这几种方式之一来与服务器进程建立连接,每当有一个客户端进程连接到服务器进程时,服务器进程都会创建一个线程来专门处理与这个客户端的交互,当该客户端退出时会与服务器断开连接,服务器并不会立即把与该客户端交互的线程销毁掉,而是把它缓存起来,在另一个新的客户端再进行连接时,把这个缓存的线程分配给该新客户端。这样就起到了不频繁创建和销毁线程的效果,从而节省开销。从这一点大家也能看出,MySQL服务器会为每一个连接进来的客户端分配一个线程,但是线程分配的太多了会严重影响系统性能,所以我们也需要限制一下可以同时连接到服务器的客户端数量,至于怎么限制我们后边再说~\n在客户端程序发起连接的时候,需要携带主机信息、用户名、密码,服务器程序会对客户端程序提供的这些信息进行认证,如果认证失败,服务器程序会拒绝连接。另外,如果客户端程序和服务器程序不运行在一台计算机上,我们还可以采用使用了SSL(安全套接字)的网络连接进行通信,来保证数据传输的安全性。\n当连接建立后,与该客户端关联的服务器线程会一直等待客户端发送过来的请求,MySQL服务器接收到的请求只是一个文本消息,该文本消息还要经过各种处理,预知后事如何,继续往下看~\n解析与优化 # 到现在为止,MySQL服务器已经获得了文本形式的请求,接着还要经过九九八十一难的处理,其中的几个比较重要的部分分别是查询缓存、语法解析和查询优化,下面我们详细来看。\n查询缓存 # 如果我问你9+8×16-3×2×17的值是多少,你可能会用计算器去算一下,或者牛逼一点用心算,最终得到了结果35,如果我再问你一遍9+8×16-3×2×17的值是多少,你还会再傻呵呵的算一遍么?我们刚刚已经算过了,直接说答案就好了。MySQL服务器程序处理查询请求的过程也是这样,会把刚刚处理过的查询请求和结果缓存起来,如果下一次有一模一样的请求过来,直接从缓存中查找结果就好了,就不用再傻呵呵的去底层的表中查找了。这个查询缓存可以在不同客户端之间共享,也就是说如果客户端A刚刚查询了一个语句,而客户端B之后发送了同样的查询请求,那么客户端B的这次查询就可以直接使用查询缓存中的数据。\n当然,MySQL服务器并没有人聪明,如果两个查询请求在任何字符上的不同(例如:空格、注释、大小写),都会导致缓存不会命中。另外,如果查询请求中包含某些系统函数、用户自定义变量和函数、一些系统表,如 mysql 、information_schema、 performance_schema 数据库中的表,那这个请求就不会被缓存。以某些系统函数举例,可能同样的函数的两次调用会产生不一样的结果,比如函数NOW,每次调用都会产生最新的当前时间,如果在一个查询请求中调用了这个函数,那即使查询请求的文本信息都一样,那不同时间的两次查询也应该得到不同的结果,如果在第一次查询时就缓存了,那第二次查询的时候直接使用第一次查询的结果就是错误的!\n不过既然是缓存,那就有它缓存失效的时候。MySQL的缓存系统会监测涉及到的每张表,只要该表的结构或者数据被修改,如对该表使用了INSERT、 UPDATE、DELETE、TRUNCATE TABLE、ALTER TABLE、DROP TABLE或 DROP DATABASE语句,那使用该表的所有高速缓存查询都将变为无效并从高速缓存中删除! 小贴士:虽然查询缓存有时可以提升系统性能,但也不得不因维护这块缓存而造成一些开销,比如每次都要去查询缓存中检索,查询请求处理完需要更新查询缓存,维护该查询缓存对应的内存区域。从MySQL 5.7.20开始,不推荐使用查询缓存,并在MySQL 8.0中删除。\n语法解析 # 如果查询缓存没有命中,接下来就需要进入正式的查询阶段了。因为客户端程序发送过来的请求只是一段文本而已,所以MySQL服务器程序首先要对这段文本做分析,判断请求的语法是否正确,然后从文本中将要查询的表、各种查询条件都提取出来放到MySQL服务器内部使用的一些数据结构上来。\n小贴士:这个从指定的文本中提取出我们需要的信息本质上算是一个编译过程,涉及词法解析、语法分析、语义分析等阶段,这些问题不属于我们讨论的范畴,大家只要了解在处理请求的过程中需要这个步骤就好了。\n查询优化 # 语法解析之后,服务器程序获得到了需要的信息,比如要查询的列是哪些,表是哪个,搜索条件是什么等等,但光有这些是不够的,因为我们写的MySQL语句执行起来效率可能并不是很高,MySQL的优化程序会对我们的语句做一些优化,如外连接转换为内连接、表达式简化、子查询转为连接等等的一堆东西。优化的结果就是生成一个执行计划,这个执行计划表明了应该使用哪些索引进行查询,表之间的连接顺序是什么样的。我们可以使用EXPLAIN语句来查看某个语句的执行计划,关于查询优化这部分的详细内容我们后边会仔细介绍,现在你只需要知道在MySQL服务器程序处理请求的过程中有这么一个步骤就好了。\n存储引擎 # 截止到服务器程序完成了查询优化为止,还没有真正的去访问真实的数据表,MySQL服务器把数据的存储和提取操作都封装到了一个叫存储引擎的模块里。我们知道表是由一行一行的记录组成的,但这只是一个逻辑上的概念,物理上如何表示记录,怎么从表中读取数据,怎么把数据写入具体的物理存储器上,这都是存储引擎负责的事情。为了实现不同的功能,MySQL提供了各式各样的存储引擎,不同存储引擎管理的表具体的存储结构可能不同,采用的存取算法也可能不同。 小贴士:为什么叫引擎呢?因为这个名字更拉风~ 其实这个存储引擎以前叫做表处理器,后来可能人们觉得太土,就改成了存储引擎的叫法,它的功能就是接收上层传下来的指令,然后对表中的数据进行提取或写入操作。 为了管理方便,人们把连接管理、查询缓存、语法解析、查询优化这些并不涉及真实数据存储的功能划分为MySQL server的功能,把真实存取数据的功能划分为存储引擎的功能。各种不同的存储引擎向上面的MySQL server层提供统一的调用接口(也就是存储引擎API),包含了几十个底层函数,像\u0026quot;读取索引第一条内容\u0026quot;、\u0026ldquo;读取索引下一条内容\u0026rdquo;、\u0026ldquo;插入记录\u0026quot;等等。\n所以在MySQL server完成了查询优化后,只需按照生成的执行计划调用底层存储引擎提供的API,获取到数据后返回给客户端就好了。\n常用存储引擎 # MySQL支持非常多种存储引擎,我这先列举一些: 存储引擎 描述 ARCHIVE 用于数据存档(行被插入后不能再修改) BLACKHOLE 丢弃写操作,读操作会返回空内容 CSV 在存储数据时,以逗号分隔各个数据项 FEDERATED 用来访问远程表 InnoDB 具备外键支持功能的事务存储引擎 MEMORY 置于内存的表 MERGE 用来管理多个MyISAM表构成的表集合 MyISAM 主要的非事务处理存储引擎 NDB MySQL集群专用存储引擎 这么多我们怎么挑啊,你多虑了,其实我们最常用的就是InnoDB和MyISAM,有时会提一下Memory。其中InnoDB是MySQL默认的存储引擎,我们之后会详细介绍这个存储引擎的各种功能,现在先看一下一些存储引擎对于某些功能的支持情况: Feature MyISAM Memory InnoDB Archive NDB B-tree indexes yes yes yes no no Backup/point-in-time recovery yes yes yes yes yes Cluster database support no no no no yes Clustered indexes no no yes no no Compressed data yes no yes yes no Data caches no N/A yes no yes Encrypted data yes yes yes yes yes Foreign key support no no yes no yes Full-text search indexes yes no yes no no Geospatial data type support yes no yes yes yes Geospatial indexing support yes no yes no no Hash indexes no yes no no yes Index caches yes N/A yes no yes Locking granularity Table Table Row Row Row MVCC no no yes no no Query cache support yes yes yes yes yes Replication support yes Limited yes yes yes Storage limits 256TB RAM 64TB None 384EB T-tree indexes no no no no yes Transactions no no yes no yes Update statistics for data dictionary yes yes yes yes yes 密密麻麻列了这么多,看的头皮都发麻了,达到的效果就是告诉你:这玩意儿很复杂。其实这些东西大家没必要立即就给记住,我列出来的目的就是想让大家明白不同的存储引擎支持不同的功能,有些重要的功能我们会在后边的介绍中慢慢让大家理解的~\n关于存储引擎的一些操作 # 查看当前服务器程序支持的存储引擎 # 我们可以用下面这个命令来查看当前服务器程序支持的存储引擎: SHOW ENGINES; 来看一下调用效果: ``` mysql\u0026gt; SHOW ENGINES; +\u0026mdash;\u0026mdash;\u0026mdash;\u0026mdash;\u0026mdash;\u0026mdash;\u0026ndash;+\u0026mdash;\u0026mdash;\u0026mdash;+\u0026mdash;\u0026mdash;\u0026mdash;\u0026mdash;\u0026mdash;\u0026mdash;\u0026mdash;\u0026mdash;\u0026mdash;\u0026mdash;\u0026mdash;\u0026mdash;\u0026mdash;\u0026mdash;\u0026mdash;\u0026mdash;\u0026mdash;\u0026mdash;\u0026mdash;\u0026mdash;\u0026mdash;-+\u0026mdash;\u0026mdash;\u0026mdash;\u0026mdash;\u0026ndash;+\u0026mdash;\u0026mdash;+\u0026mdash;\u0026mdash;\u0026mdash;\u0026mdash;+ | Engine | Support | Comment | Transactions | XA | Savepoints | +\u0026mdash;\u0026mdash;\u0026mdash;\u0026mdash;\u0026mdash;\u0026mdash;\u0026ndash;+\u0026mdash;\u0026mdash;\u0026mdash;+\u0026mdash;\u0026mdash;\u0026mdash;\u0026mdash;\u0026mdash;\u0026mdash;\u0026mdash;\u0026mdash;\u0026mdash;\u0026mdash;\u0026mdash;\u0026mdash;\u0026mdash;\u0026mdash;\u0026mdash;\u0026mdash;\u0026mdash;\u0026mdash;\u0026mdash;\u0026mdash;\u0026mdash;-+\u0026mdash;\u0026mdash;\u0026mdash;\u0026mdash;\u0026ndash;+\u0026mdash;\u0026mdash;+\u0026mdash;\u0026mdash;\u0026mdash;\u0026mdash;+ | InnoDB | DEFAULT | Supports transactions, row-level locking, and foreign keys | YES | YES | YES | | MRG_MYISAM | YES | Collection of identical MyISAM tables | NO | NO | NO | | MEMORY | YES | Hash based, stored in memory, useful for temporary tables | NO | NO | NO | | BLACKHOLE | YES | /dev/null storage engine (anything you write to it disappears) | NO | NO | NO | | MyISAM | YES | MyISAM storage engine | NO | NO | NO | | CSV | YES | CSV storage engine | NO | NO | NO | | ARCHIVE | YES | Archive storage engine | NO | NO | NO | | PERFORMANCE_SCHEMA | YES | Performance Schema | NO | NO | NO | | FEDERATED | NO | Federated MySQL storage engine | NULL | NULL | NULL | +\u0026mdash;\u0026mdash;\u0026mdash;\u0026mdash;\u0026mdash;\u0026mdash;\u0026ndash;+\u0026mdash;\u0026mdash;\u0026mdash;+\u0026mdash;\u0026mdash;\u0026mdash;\u0026mdash;\u0026mdash;\u0026mdash;\u0026mdash;\u0026mdash;\u0026mdash;\u0026mdash;\u0026mdash;\u0026mdash;\u0026mdash;\u0026mdash;\u0026mdash;\u0026mdash;\u0026mdash;\u0026mdash;\u0026mdash;\u0026mdash;\u0026mdash;-+\u0026mdash;\u0026mdash;\u0026mdash;\u0026mdash;\u0026ndash;+\u0026mdash;\u0026mdash;+\u0026mdash;\u0026mdash;\u0026mdash;\u0026mdash;+ 9 rows in set (0.00 sec)\nmysql\u0026gt; ```\n其中的Support列表示该存储引擎是否可用,DEFAULT值代表是当前服务器程序的默认存储引擎。Comment列是对存储引擎的一个描述,英文的,将就着看吧。Transactions列代表该存储引擎是否支持事务处理。XA列代表该存储引擎是否支持分布式事务。Savepoints代表该列是否支持部分事务回滚。 小贴士:好吧,也许你并不知道什么是个事务、更别提分布式事务了,这些内容我们在后边的章节会详细介绍,现在瞅一眼看个新鲜就行。\n设置表的存储引擎 # 我们前面说过,存储引擎是负责对表中的数据进行提取和写入的,我们可以为不同的表设置不同的存储引擎,也就是说不同的表可以有不同的物理存储结构,不同的提取和写入方式。\n创建表时指定存储引擎 # 我们之前创建表的语句都没有指定表的存储引擎,那就会使用默认的存储引擎InnoDB(当然这个默认的存储引擎也是可以修改的,我们在后边的章节中再说怎么改)。如果我们想显式的指定一下表的存储引擎,那可以这么写: CREATE TABLE 表名( 建表语句; ) ENGINE = 存储引擎名称; 比如我们想创建一个存储引擎为MyISAM的表可以这么写: ``` mysql\u0026gt; CREATE TABLE engine_demo_table( -\u0026gt; i int -\u0026gt; ) ENGINE = MyISAM; Query OK, 0 rows affected (0.02 sec)\nmysql\u0026gt; ```\n修改表的存储引擎 # 如果表已经建好了,我们也可以使用下面这个语句来修改表的存储引擎: ALTER TABLE 表名 ENGINE = 存储引擎名称; 比如我们修改一下engine_demo_table表的存储引擎: ``` mysql\u0026gt; ALTER TABLE engine_demo_table ENGINE = InnoDB; Query OK, 0 rows affected (0.05 sec) Records: 0 Duplicates: 0 Warnings: 0\nmysql\u0026gt; 这时我们再查看一下engine_demo_table的表结构: mysql\u0026gt; SHOW CREATE TABLE engine_demo_table\\G *************************** 1. row *************************** Table: engine_demo_table Create Table: CREATE TABLE engine_demo_table ( i int 11)DEFAULTNULL11) DEFAULT NULL ENGINE=InnoDB DEFAULT CHARSET=utf8 1 row in set (0.01 sec)\nmysql\u0026gt; ``` 可以看到该表的存储引擎已经改为InnoDB了。\n"},{"id":32,"href":"/zh/docs/technology/MySQL/MySQL%E6%98%AF%E6%80%8E%E6%A0%B7%E8%BF%90%E8%A1%8C%E7%9A%84/%E7%AC%AC0%E7%AB%A0_%E4%B8%87%E9%87%8C%E9%95%BF%E5%BE%81%E7%AC%AC%E4%B8%80%E6%AD%A5%E9%9D%9E%E5%B8%B8%E9%87%8D%E8%A6%81-%E5%A6%82%E4%BD%95%E6%84%89%E5%BF%AB%E7%9A%84%E9%98%85%E8%AF%BB%E6%9C%AC%E5%B0%8F%E5%86%8C/","title":"第0章_万里长征第一步(非常重要)-如何愉快的阅读本小册","section":"My Sql是怎样运行的","content":"第0章 万里长征第一步(非常重要)-如何愉快的阅读本小册\n购买前警告⚠️ # 此小册并非数据库入门书籍,需要各位知道增删改查是什么意思,并且能用 SQL 语言写出来,当然并不要求各位知道的太多,你甚至可以不知道连接的语法都可以。不过如果你连SELECT、INSERT这些单词都没听说过那本小册并不适合你。 此小册非正经科学专著,亦非十二五国家级规划教材,也没有大段代码和详细论证,有的全是图,喜欢正经论述的同学请避免购买本小册。 此小册作者乃一无业游民,非专业大佬,没有任何职称,只是单单喜欢把复杂问题讲清楚的那种快感,所以喜欢作者有 Google、Facebook 高级开发工程师,二百年工作经验等 Title 的同学请谨慎购买。 此小册是用于介绍 MySQL 的工作原理以及对我们程序猿的影响,并不是介绍概念设计、逻辑设计、物理设计、范式化之类的数据库设计方面的知识,希望了解上述这些知识的同学来错地方了。 文章标题中的“从根儿上理解MySQL”**其实是专门雇了 UC 震惊部小编起的,纯属为了吸引大家眼球。严格意义上说,本书只是介绍MySQL内核的一些核心概念的小白进阶书籍。大家读完本小册也不会一下子晋升业界大佬,当上 CTO,迎娶白富美,走上人生巅峰。希望本小册能够帮助大家解决一些工作、面试过程中的问题,逐渐成为一个更好的工程师,有兴趣的小伙伴可以再深入研究一下 MySQL,说不定你就是下一个数据库泰斗啦。 购买并阅读本小册的建议 # 本小册是一本待出版的纸质书籍,并非一些杂碎文章的集合,是非常有结构和套路的,所以大家阅读时千万不能当作厕所蹲坑、吃饭看手机时的所谓碎片化读物。碎片化阅读只适合听听矮大紧、罗胖子他们扯扯犊子,开阔一下视野用的。对于专业的技术知识来说,大家必须付出一个完整的时间段进行体系化学习,这样尊重知识,工资才能尊重你。 顺便说一句,我已经好久都不听罗胖子扯犊子了,刚开始办罗辑思维的时候觉得他扯的还可以,越往后越觉得都钻钱眼儿里了,天天在鼓吹焦虑,让大家去买他们的鸡汤课。不过听听矮大紧就挺好啊,不累~ 本小册是由 Markdown 写成,在电脑端阅读体验十分舒服,当然你非要用小手机看我也不拦着你,但是效果打了折扣是你的损失。 为了保证最好的阅读体验,不用一个没学过的概念去介绍另一个新概念,本小册的章节有严重的依赖性,比如你在没读InnoDB数据页结构前千万不要就去读B+树索引,所以大家最好从前看到尾,不要跳着看!不要跳着看!不要跳着看!,当然,不听劝告我也不能说什么,祝你好运。 大家可能买过别的小册,有的小册一篇文章可能用5分钟、10分钟读完,不过我的小册子每一篇文章都比较长,因为我把高耦合的部分都集中在一篇文章中了。文章中埋着各种伏笔,所以大家看的时候可能不会觉察出来很突兀的转变,所以在阅读一篇文章的时候千万不要跳着看!不要跳着看!不要跳着看! 大家在看本小册之前应该断断续续看过一些与本小册内容相关的知识,只是不成体系,细节学习的不够。对于这部分读者来说,希望大家像倚天屠龙记里的张无忌一样,在学张三丰的太极剑法时先忘记之前的武功,忘的越干净,学的越得真传。这样才能跟着我的套路走下去。 如果你真的是个小白的话,那这里头的数字都是假的: 一篇文章能用2个小时左右的时间掌握就很不错了。说句扫大家兴的话,虽然我已经很努力的想让大家的学习效率提升n倍,但是不幸的是想掌握一门核心技术仍然需要大家多看几遍(不然工资那么好涨啊~)。\n关于工具 # 本小册中会涉及很多 InnoDB 的存储结构的知识,比如记录结构、页结构、索引结构、表空间结构等等,这些知识是所有后续知识的基础,所以是重中之重,需要大家认真对待。Jeremy Cole 已经使用 Ruby 开发了一个简易的解析这些基础结构的工具,github地址是: innodb_ruby的github地址,大家可以按照说明安装上这个工具,可以更好的理解 InnoDB 中的一些存储结构(此工具虽然是针对MySQL 5.6的,但是幸好MySQL的基础存储结构基本没多大变化,所以大部分场景下这个innodb_ruby工具还是可以使用的)。\n关于盗版 # 在写这本小册之前,我天真的以为只需要找几本参考书,看看 MySQL 的官方文档,遇到不会的地方百度谷歌一下就可以在 3 个月内解决这本书,后来的现实证明我真的想的太美了。不仅花了大量的时间阅读各种书籍和源码,而且有的时候知识耦合太厉害,为了更加模块化的把知识表述清楚,我又花了大量的时间来思考如何写作才能符合用户认知习惯,还花了非常多的时间来画各种图表,总之就是心累啊~ 我希望的是:各位同学可以用很低的成本来更快速学会一些看起来生涩难懂的知识,但是毕竟我不是马云,不能一心一意做公益,希望各位通过正规渠道获得小册,尊重一下版权。 还有各位写博客的同学,引用的少了叫借鉴,引用的多了就,就有点那个了。希望各位不要大段大段的复制粘贴,用自己的话写出来的知识才是自己的东西。 我知道不论我们怎样强调版权意识,总是有一部分小伙伴喜欢不劳而获,总是喜欢想尽各种渠道来弄一份盗版的看,希望这部分同学看完别忘了关注公众号【我们都是小青蛙】,给我填个粉儿也算是赞助一下我(下面是二维码,觉得有帮助的话希望可以打赏一下,毕竟本人很穷。另外,公众号中有若干篇小册的补充文章,包括三篇极其重要的语句加锁分析):\n小贴士:我一直有个想法,就是如何降低教育成本。现在教育的盈利收费模式都太单一,就是直接跟学生收上课费,导致课程成为一种2C的商品,价格高低其实和内容质量并不是很相关,所以课程提供商会投入更大的精力做他们的渠道营销。所以现在的在线教育市场就是渠道为王,招生为王。我们其实可以换一种思路,在线教育的优势其实是传播费用更低,一个人上课和一千万人上课的费用区别其实就是服务器使用的多少罢了,所以我们可能并不需要那么多语文老师、数学老师,我们用专业的导演、专业的声优、专业的动画制作、专业的后期、专业的剪辑、专业的编剧组成的团队为某个科目制作一个专业的课程就好了嘛(顺便说一句,我就可以转行做课程编剧了)!把课程当作电影、电视剧来卖,只要在课程中植入广告,或者在播放平台上加广告就好了嘛,我们也可以在课程里培养偶像,来做一波粉丝经济。这样课程生产方也赚钱,学生们也省钱,最主要的是可以更大层度上促进教育公平,多好。\n关于错误 # 准确性问题 # 我不是神,并不是书中的所有内容我都一一对照源码来验证准确性(阅读的大部分源码是关于查询优化和事务处理的),如果各位发现了文中有准确性问题请直接联系我,我会加入 Bug 列表中修正的。\n阅读体验问题 # 大家知道大部分人在长大之后就忘记了自己小时候的样子,我写本书的初衷就是有很多资料我看不懂,看的我脑壳疼,之后才决定从小白的角度出发来写一本小白都能看懂的技术书籍。但是由于后来自己学的东西越来越多,可能有些地方我已经忘掉了小白的想法是怎么样的,所以大家在阅读过程中有任何阅读不畅快的地方都可以给我提,我也会加入bug列表中逐一优化。\n关于转发 # 如果你从本小册中获取到了自己想要的知识,并且这个过程是比较轻松愉快的,希望各位能帮助转发本小册,解放一下学不懂这些知识的童鞋们,多节省一下他们的学习时间以及让学习过程不再那么痛苦。大家的技术都长进了,咱国家的技术也就慢慢强起来了。\n关于疑惑 # 虽然我觉得文章写的已经很清晰了,但毕竟只是“我觉得”,不是大家觉得。传道授业解惑,解惑很重要。在学习一门知识时,我们最容易让一些问题绊住脚步,大家在阅读小册时如果发现了任何你觉得让你很困惑的问题,都可以直接加微信 xiaohaizi4919 问我,或者到群里提问题(最好到群里提,这样大家都能看到,也省的重复提问),我在力所能及的范围内尽力帮大家解答。 闲话 # 如果有的同学购买本小册后觉得并不是自己的菜,那很遗憾,我不能给你退款,钱是掘金这个平台收的。不过我还是觉得绝大部分同学读过后肯定有物超所值的感受,面试一般的数据库问题再也难不倒各位了,工作中一般的数据库问题也都是小菜一碟了,想继续研究 MySQL 源码的同学也找到方向了,如果你觉得 29.9 元不能表达你淘到宝的喜悦之情,那这好说,给我发红包就好了。\n"},{"id":33,"href":"/zh/docs/culture/%E6%B1%89%E5%AD%97%E5%B0%B1%E6%98%AF%E8%BF%99%E4%B9%88%E6%9D%A5%E7%9A%84/01%E8%B5%B0%E8%BF%9B%E6%B1%89%E5%AD%97%E4%B8%96%E7%95%8C/","title":"01走进汉字世界","section":"汉字就是这么来的","content":"01走进汉字世界\n封面 # 版权 # 版权信息\n书名:汉字就是这么来的·走进汉字世界\n作者:孟琢\n出版社:湖南少年儿童出版社\n出版时间:2020-08-01\nISBN:9787556251919\n本书由天津博集新媒科技有限公司授权亚马逊发行\n版权所有 侵权必究\n汉字的起源 # 汉字是中国文化的根源,蕴含着中华文明悠久古老的基因。当我们走进汉字世界的时候,先要思考一个重要的问题:汉字的源头是什么?\n对这个问题的回答,真是众说纷纭。在历史上,有三种比较重要的说法:八卦造字、结绳造字和仓颉造字。\n汉字与八卦 # 八卦,听起来蛮神奇的!中华民族的始祖伏羲氏[1]创造了八卦。他在大自然中看到了天空、大地、沼泽、大湖,还有清澈的水流、熊熊的烈火和浩浩荡荡的长风,听到了响彻苍穹的雷鸣。在伏羲看来,万事万物都分为阴阳,他用一个长横()表示阳爻,用两个短横()表示阴爻,再用阴阳进行组合,就拼出了八卦,分别代表天、地、山、泽、水、火、风、雷。\n在中国文化中,八卦相当重要。有一部听起来有些神秘的古老经典——《周易》,就是以八卦的道理为根本的。于是,有人认为汉字也起源于八卦。他们举了一个很有意思的例子:在八卦中,有一个坎卦,表示水的意思。你看,坎卦是这个样子的:。主张汉字起源于八卦的人们说,把坎卦旋转九十度竖起来,和古文字中的(水)非常像。\n你别说,对比一下还真像!那汉字真的起源于八卦吗?不是的,八卦中的另外七个卦和汉字一点儿关系也没有,样子也不相似。坎卦和水的相似,应该只是一种偶然。\n结绳造字 # 还有一种说法,认为汉字起源于结绳。\n什么叫作结绳呢?这是古人提醒自己别忘事的一种办法。他们随身携带一根绳子,有大事,就系一个大疙瘩,有小事,就系一个小疙瘩。这种风俗见于世界各地,有些比较原始的部落,现在还在使用结绳记事。\n结绳能够帮助我们记事,汉字有同样的功能,于是有人认为,汉字起源于结绳。我们仔细想一想,就会发现这种说法并不靠谱——绳子上的疙瘩能提醒我们有一件重要的事,但它无法告诉我们,到底是什么事。你是要去上学呢?还是要去吃火锅呢?还是去游乐场玩耍呢?但汉字就不一样了,汉字能够传达准确而具体的信息。\n仓颉:汉字的始祖 # 在造字的传说中,仓颉造字的说法流传最广。\n仓颉是谁?相传他生活在公元前2500多年,距今有4500多年。仓颉是黄帝时期的史官,掌管着历史、文化与文献,这个身份和汉字的关系最为密切,古人认为是他发明了汉字。这个时间和我们今天发现的早期文字符号的时期,也是相当接近的。\n根据一些古书中的记载,仓颉的样子很奇怪,“龙颜侈侈,四目灵光,实有睿德,生而能书”。这句话说的是,仓颉的额头很大,像龙一样,他有四个眼睛,绽放出闪闪的灵光,这个人非常聪明,生下来就能写字——哇,仓颉好厉害!\n仓颉真长这个样子吗?不见得。古人习惯于将一些厉害的人物神化,来凸显他们的与众不同。他们往往出生时伴有奇怪的天象,长相也异于常人。伏羲是人的脑袋蛇的身子;蚩尤和兄弟们都是铜头铁额,真是够酷。没这么夸张,稍微低调一点儿的也有。三国时期的刘备双耳垂肩,双手过膝,耳垂和手的长度远非常人能比;明朝开国皇帝朱元璋脚上有七颗痣,有个厉害的说法叫作“脚踏七星”。所以,仓颉的长相也是经过了艺术的夸张。\n仓颉是如何造字的呢?古代有一位大思想家叫韩非子,他记载了仓颉造字的一个思路,他说:“仓颉之作书也,自环者谓之私,背私者谓之公。”仓颉要给公开的“公”和自私的“私”造字。\n先造“私”字,这个字的意思有点儿抽象,用什么样的字形来表达呢?想来想去,他找到了一个造字的好办法:画一个封闭的圆圈,圈里的东西都是我的,也就是“厶”。这个字是今天“私”字的右半边。在古人心中,什么东西最能表示私有呢?莫过于粮食!于是,聪明的古人在“厶”旁边再添加一个“禾”,创造出“私”,强调这是我私有的财物。\n“私”造出来了,“公”又该如何造字呢?这个问题难不住聪明的仓颉!他说,“公”和“私”的意思是相反的,把私有的财物分给大家,不就是“公”嘛。因此,他在“厶”的上面加上了一个“八”字。在数字中,一分为二,二分为四,四分为八,都是一个不断分离的过程。因此“八”表示分的意思,把“私”用“八”不断分开,这就是“公”!\n仓颉造字,体现出高超的智慧!事实上,汉字不可能是某一个人造出来的,中华民族宝贵的文化遗产,是人民大众的智慧结晶。战国的大思想家荀子点明了仓颉造字的实质。《荀子》中说:“好书者众矣,而仓颉独传者,壹也。”喜欢写字、善于造字的人很多,但只有仓颉造字的名声千古流传,这是为什么呢?因为仓颉“专壹”!\n请注意,大写的“壹”字,既表示专一,仓颉一心一意地创造汉字;也表示规范、整理与统一的意思。仓颉是汉字的整理者、规范者与统一者,他是古人发明汉字的伟大的历史运动中的代表。\n正因如此,仓颉被后人尊称为“字圣”——发明汉字的圣人。\n随着汉字的发明,我们的历史文化大大地向前迈了一步。相传汉字发明之后,“天雨粟,鬼夜哭”——天上落下粮食雨,晚上有鬼在哭泣,听上去是不是有点儿恐怖呢?事实上,“天雨粟,鬼夜哭”是个吉兆呢!上天降下粮食雨,奖励人们发明了汉字;鬼也不是可怕的妖怪,所谓“鬼者,归也”,他们是“回归”故乡的祖先灵魂。那些去世的祖先觉得,有了汉字,他们的生平事迹就会被记录在文字里,再也不会被后人遗忘了,因此纷纷喜极而泣。\n汉字源自图画 # 传说听起来很热闹,但如果根据考古来看,汉字应当起源于早期的原始图画。大家跟我看大汶口文化的图画型陶符,以及半坡文化中的彩陶符号。在这些刻画在陶器上的符号中,有圆圆的太阳,有巍峨的高山,有抽象的小草……和汉字中的象形文字,真是如出一辙。\n在原始图画和汉字的对比中,这样的例子还有很多。你看,自由自在的鱼儿、展翅欲飞的小鸟、姿态优雅的小鹿,这些原始绘画摇身一变,就成了最早的汉字。\n图画是形象的,最早的汉字也是如此。古人造字的基本思路是“近取诸身,远取诸物”,从自己熟悉的身体和丰富多彩的大自然中选取形象,造出汉字。取象,是汉字造字的基本思路,也是我们给大家讲解汉字的基本视角。\n[1] 在神话传说中,伏羲和女娲是兄妹,是上古时代人类的始祖。在《伏羲女娲图》中能看到他们俩蛇身人首的样子,充满神话特有的浪漫和想象色彩。传说他发明了八卦,教会人们结网捕鱼,还发明了乐器——琴瑟。\n甲骨文-传奇的开始 # 甲骨文,是我们今天能见到的最早的汉字。\n甲骨文是汉字历史上的一个传奇,它是如何被发现的呢?它是什么时代的文字?甲骨文都写了些什么?又有怎样的特点呢?让我们带着疑问,一起走进甲骨文的世界吧。\n神奇的发现 # 甲骨文,为什么叫这个名字?\n我们一起来看两幅图。上面这幅是乌龟的腹甲,也就是肚子上的甲壳,下面这幅是牛的肩胛骨,两个骨片上都刻着密密麻麻的文字。那我们就清楚了,甲骨文的命名来源于它的载体,这是一种刻在龟甲和兽骨上的文字。\nVCG21gic3609468-©View Stock/VSI美好景象(Creative)/视觉中国\nVCG21gic2728673-©UGI/UGI优谷/视觉中国\n甲骨文的发现很传奇,发现甲骨文的人叫王懿(yì)荣,他是清朝的大臣,也是一位金石学[1]家,也就是研究古文字的人。大概是1899年的时候,王懿荣生病了,要吃中药。他一时兴起,想看看这次中药的成色如何,就拿了一包药打开翻检。没承想,在一味叫作“龙骨”的药材上,他看到了类似文字的刻痕。\n什么是龙骨?相传是龙的遗骨,吃下去能够补肾,听上去是不是有点儿高大上?其实,龙骨就是古代动物的骨骼化石。我们说过,王懿荣熟悉古文字,一般人看到龙骨上的刻痕,也许不会注意,但王懿荣一眼扫过去,却是大惊失色!\n“啊!这龙骨上面刻的东西,好像是文字!来人,快去把药店里的龙骨都买回来,我要好好研究一下。”\n这一研究不要紧,王懿荣就此发现了甲骨文。这对中国的文字学和历史学来说,都是一件开天辟地的大事。甲骨文让我们见到了最早的文字,也让我们看到了尘封已久的殷商王朝的历史。\n占卜的秘密 # 甲骨文所处的时代,是历史上的殷商时期。如果你看过《封神演义》的话,会知道有个残暴的商纣王[2],他的名字也曾出现在甲骨文中。甲骨文里都写了些什么呢?它主要记录了古代占卜的相关内容,因此,甲骨文也叫作卜辞文字。\n什么是占卜?这个事说来话长。殷商是一个非常迷信的时代,那个时候大大小小的事情,都要通过占卜来进行预测。用什么来占卜呢?选来选去,古人盯上了乌龟的壳。也许,他们觉得乌龟长寿,见多识广,因此能够预测未来吧。\n占卜的流程很复杂,古人先要把乌龟的腹甲,或者牛的肩胛骨锯下来,然后磨平。磨平之后,再用铜钻在上面钻出又深又圆的孔,再在圆孔旁边凿出枣核形的槽。又钻又凿,折腾了一大通,甲骨的表面就不再平整了。接下来,古人会用火去烧这个甲骨。不平整的东西受热不均匀,就容易爆裂。在爆裂的时候,发出“bu bu”的声音,这就是“卜”字的声音来源。爆裂之后,甲骨上会出现裂纹,这些裂纹的样子,就是“卜”字的形体来源。\n翻看前文牛的肩胛骨的图片,它上面的裂纹与甲骨文中的(卜)字,是不是很像呢?\n在我们看来,这些裂纹是随机的、偶然的,但在古人眼中,它们却有一种神奇的力量,能够揭示未来的吉凶祸福!古代有专门负责占卜的巫师家族,那些大巫头上插着炫目的羽毛,穿着与众不同的衣裳,神情肃穆地看着甲骨上的裂纹,为商王判断吉凶。有时候,尊贵的商王甚至会亲自进行占卜。\n这段历史也烙印在汉字里。在古文字中,“占”字上面是一个“卜”,下面是一个“口”。对“卜”(裂纹)的解释与预测,就是占卜。占卜之后,古人会把这次占卜的前因后果记录在甲骨上,记录使用的文字就是甲骨文!在殷商时期,无论是天时、祭祀、战争、农业、生活,各种各样的事情都要占卜,因此,甲骨文中也就记载了那个时代的方方面面。\n想学占卜吗?在考试之前预测吉凶,看看自己的分数如何?\n还是算了吧。第一,小乌龟蛮可怜的,不忍心!第二,占卜的方法早就失传了,学不到!\n最早的记叙文 # 古人占卜完后,在甲骨上用古老的文字记录占卜的相关信息。记录的体例和格式是什么样子的呢?\n在回答这个问题之前,我要先问你一个问题:你会写作文吗?\n这有什么难的!一篇记叙文,把时间、地点、人物、事件,清清楚楚地写下来就是了。嗯,这个回答很不错。但你知道吗,记叙文的规则与体裁,在甲骨文中就已经基本定型了。\n这么早啊!\n甲骨文有固定的记事模式,分为前辞、命辞、占辞、验辞四种。听起来,会不会觉得这些名字有点儿“玄”?其实,它们的内容和格式,与我们平时所写的记叙文非常像。\n“前辞”说了些什么?它告诉我们,古人在哪一天占卜,由谁来负责占卜,这其实就是时间和人物。一般来说,负责占卜的是专职的大巫师,他们被称为“贞人”——在古代汉语中,“贞”也有占卜的意思。占卜意味着与神灵沟通的能力,这是一种特殊而重要的文化权力,因此“贞人”往往是世袭的,形成了代代相传的“贞人集团”。\n“命辞”介绍了为什么事情而占卜,在甲骨文的“命辞”中,我们能看到殷商古人最关心的事情。比如有的甲骨文里说:“帝及四月令雨?帝弗其及今四月令雨?”你能看懂这句话吗?在这里,“帝”是天帝,“雨”是下雨,古人问的问题是:今年四月份,上天会不会下雨?\n“占辞”是巫师或者商王对占卜结果的判断。“验辞”是对占卜结果的验证,无论准确与否,都如实记录下来。有意思的是,甲骨文中有相当多的占卜“不灵”的记载,古人尽管有些迷信,但还是很“实在”的,把握住了实事求是的记录标准。\n你看,甲骨文是不是很像一篇记叙文,时间、人物、事件、结果,都非常清楚。\n千姿百态的甲骨文 # 介绍了甲骨文的发现、制作、内容与格式,最后,我们看一看这种文字的特点。\n甲骨文的第一个特点,是象形性强。什么叫象形性呢?就是文字和它所记录的事物特别像,惟妙惟肖。汉字来源于图画,早期汉字就像一幅幅生动的图案。不信你看上面的这张图片。\n这是甲骨文组成的一张面孔。圆圆的是脸的轮廓,中间是眼睛、鼻子、嘴巴,还有两旁的耳朵。其实,这些都是甲骨文。\n眼睛的象形,是“目”字,你把这个字掉转九十度看,和今天的“目”还颇为相似。鼻子的象形,是“自”字。古人为什么用鼻子来表示“自己”呢?很简单,自我介绍的时候,我们指着自己的鼻子说:“这是我!”你肯定不会指着自己的屁股,告诉别人说“这是我”,对不对?“自”下面的这个字,看起来很好笑。一张嘴,里面缺了好多颗牙,这是甲骨文中的“齿”字——古人没有牙刷牙膏,茹毛饮血对牙齿的磨损又很严重,在很年轻的时候,牙齿往往就损毁得十分严重了,这种健康状况,也大大地拉低了古人的平均寿命。在脸的两旁,是甲骨文中的“耳”字,你看,这个耳朵还不小呢!\n眼耳口鼻,这些字形取象于我们的面孔,非常形象。把它们拼到一起,就形成了一个殷商时期美男子的模样。\n天啊!这个样子能叫美男子吗?\n甲骨文的第二个特点,是文字瘦硬坚实、挺拔爽利,很有一种“骨感”之美。特别是把甲骨文和金文对比,前者骨感,后者肉感,堪称“环肥燕瘦”。在下一章关于“金文”的讲解中,你会看到非常鲜明的对比。\n这种骨感之美,形成了甲骨文独特的审美风貌。要知道,甲骨文多是用刀刻在龟甲、兽骨上的,刀和笔不同,很难做到圆转如意。因此,甲骨文中有很多直来直去的笔画,也就形成了硬朗、骨感的书写风格。\n甲骨文的第三个特点,是字形结构多变,同一个汉字往往有不同的写法。在汉字学中,这个现象叫作异体字。我们看甲骨文中的“龟”字:\n第一个字是侧面的小乌龟,头尾四肢都很完整;第二个字也是侧面的小乌龟,但似乎受了伤,缺胳膊断腿的,看上去很可怜;第三个字是从上往下俯视的乌龟。同一个龟字,有不同的写法,甲骨文中的异体字非常多,这也体现出它是一种不完全成形、不够规范的文字。\n[1] 研究古代钟鼎彝器碑碣石刻﹑考辨今古文字的一种专门学问。\n[2] 商朝的最后一任国君。我们在名著《封神演义》里也会见到他。\n金文-金灿灿的文字 # 走过殷商,迈入周朝,在经过了像画一样的甲骨文之后,我们步入了金文的鼎盛时代。需要注意的是,甲骨文、金文,以及我后面要讲的小篆(zhuàn)、隶书等,它们有大致的先后顺序,但没有特别清晰的时间界限。金文诞生于商朝中期,兴盛于西周,基本消亡于秦灭六国。\n什么是金文呢?\n和甲骨文一样,金文名字的由来也和它的载体有关。\n你见过博物馆里的青铜器吗?那些斑驳陆离的古老器物,是历史无言的见证者。由于氧化作用,我们见到的青铜器大多数是青黑色的,但它刚刚铸造出来的时候,则是耀眼夺目的金黄色。[1]要知道,周天子也喜欢“土豪金”的颜色呢。\n金文,就是铸造在金色的青铜器上的文字。在古代,有两种青铜器最为常用:一种是鼎,用作礼器;一种是钟,用作乐器。所以,金文也叫作“钟鼎文”。\n问鼎中原 # 古人为什么要把金文刻在鼎和钟这样的青铜器上呢?因为,以钟鼎为代表的青铜器在历史上的地位非常崇高。它们被称为“国之重器”,是国家政权的象征。在周代,不是谁都有资格铸造青铜器的。你说我家里特别有钱,铸个鼎玩玩,怎么样?那可是绝对不允许的。必须要由天子赏赐给诸侯大夫“金”,也就是青铜,然后诸侯大夫才有资格铸造钟鼎。\n钟和鼎如此重要,它们到底是干什么用的呢?\n让我们先从“鼎”说起,说出来你可能不信,地位这么尊崇的鼎,最初是一口接地气的大锅。中国第一部系统分析汉字字形和考究字源的字典《说文解字》[2]说:“鼎,三足两耳,和五味之宝器也。”“五味”是酸、咸、苦、甘、辛五种味道,泛指美味的食物,鼎最早是一种用于烹饪的器具。\n“鼎”长什么样呢?所谓“三足两耳”,我们看一看它的样子就知道了。鼎有三足,稳稳放在地上;上面又有两耳,可以把木棍穿进去,用来扛鼎。这个造型还真的蛮适合做锅的,下面的“三足”之间很适合放柴火,上面的“两耳”很适合把锅抬起来。\n作为一口接地气的大锅,鼎为什么备受推崇,地位倍增呢?\n因为一个我们熟悉的人——大禹。\n大禹治理了水灾,安定九州,广大人民都很爱戴他。舜便禅让了自己的位置,让大禹当王。大禹建立了夏朝,成为夏朝的开国天子。他掌管天下后,做了一件特别有权力象征意义的事情——收来了九州的金(青铜)铸造了九个大鼎,以此象征当时的九州[3]大地。正因如此,鼎在古代也象征着国家政权,是大国国力的体现。人们铸鼎,将它作为重要的礼器,用于祭祀等重大事宜。\n鼎作为礼器,是一个国家政权的象征,所以古代有“问鼎中原”这个成语。故事的主角是春秋时期的楚庄王,这是一个英明勇敢、野心勃勃的国君。有一次,他问周天子的使臣王孙满:“听说天子有九鼎,不知道这鼎有多沉啊?”你要知道,楚庄王并不是真想知道鼎有多沉,而是因为鼎象征国家政权,他想借此掂量一下周天子的分量!\n春秋时期周室衰微,诸侯蠢蠢欲动,天子已经没什么分量了,谁都敢欺负一下。但能言善辩的王孙满非同小可,他一句话就把楚庄王顶了回去——“在德不在鼎”!天子统治万国,在于德行,而不在于是否拥有九鼎。言下之意是,你楚庄王只关心鼎的轻重,不过是个有勇无德的莽夫罢了。\n楚庄王反驳道:“我们楚国长戟(jǐ)[4]上的钩尖儿加起来,就能够铸成九鼎。”这句话说的是铸鼎,其实暗含的意思是,我们楚国兵强马壮,靠军队就能推翻周王室,一统天下。\n王孙满丝毫不惧:“当初夏强盛的时候,即使是远方的诸侯也会赶来朝见大禹。九州的长官们贡献金,铸成九鼎,九鼎上刻着九州各地的出产以及奇特的东西。天地之间的事物都被容纳在九鼎之中,人们从鼎上能识别一切神圣与邪恶的东西。后来,夏桀[5]道德败坏,夏朝被殷商取代,九鼎也随之迁到了殷都[6]。殷商的国运有六百年,可惜纣王暴虐,于是殷商被周取代,九鼎便也归了周。可见,如果一个君王有德行,国家治理得美好清明,那么鼎即便很小,也重得难以移动。如果一个君王无德昏聩(kuì),那么鼎即便很大也会轻易失去。当初周天子把九鼎安放在都城时,占卜得知周会传国三十代,国运会持续七百年,这是天命。现在周虽然在衰败,但天命还没有改变。”\n楚庄王碰了软钉子,这才收敛了自己的傲气。\n在历史上,觊觎九鼎的霸主不止楚庄王一个。秦国的秦武王热衷举鼎,楚霸王项羽力能扛鼎。他们举起沉重的鼎,不仅是展现自己力气过人,也是要通过举鼎的行为,寄托征服天下的决心。\n价值连城的编钟 # 除了鼎,钟上也经常刻着金文。钟和鼎一样,都是价值连城的宝贝。\n钟是做什么用的呢?它是古代典礼上常备的乐器,一般都是一套,称为“编钟”。大大小小不同的钟,敲击起来音色不同,演奏出复杂的旋律。殷商的编钟多为3枚一组,春秋战国时期多为9枚一组。现存最华丽的编钟,是在湖北省博物馆里藏着的一套曾侯乙编钟。这套编钟共64枚,分三层悬挂,全部重量在2500公斤以上,美轮美奂,真可谓先秦青铜器中的珍品,你们有机会一定要去参观一下。\n在中国古代,人们把那些尊贵的家族称作“钟鸣鼎食之家”。什么是富贵的标志?不是黄金美酒、宝马香车,而是用钟奏乐、用鼎吃饭的人家——这幅古香古色的生活画面,不仅是古人心中财富的象征,也是社会地位的标志。\nVCG211262913655-©王萌/视觉中国\n国家大事与家族荣耀 # 青铜器地位崇高,铸刻在上面的文字也不是一般内容,或是国家的军国大事,或是一个家族代代相传的荣耀和辉煌。\n在金文中,我们能看到一些历史上的“重大新闻”。你看下面这个青铜器,古朴大方,它的名字叫利簋(guǐ)。簋和鼎一样,也是古代的一种食器,主要用于盛饭,也是一种礼器。北京有个饮食一条街,以麻辣小龙虾著称,就叫作“簋街”。“利”是这个簋的主人。大家跟我一起看右上角的利簋铭文:\n第一个字右边是戈,下面是止,所谓“止戈为武”,这是一个“武”字。旁边加上一个“王”,这是周武王的专用字。哇!鼎鼎大名的周武王,大人物出现了!武王下面的一个字不难认,这是征伐的“征”。第三个字,是商纣王的“商”。“珷(武)征商”——看到这几个字,有没有一点儿心潮澎湃的感觉。在古老的青铜器上,记录了中国历史上翻天覆地的一场大战。在利簋接下来的文字中,记录了武王伐纣的时间,“唯甲子朝”——在甲子那一天的清晨。这个时间和司马迁在《史记》中的记载是完全吻合的——“二月甲子昧爽,武王朝至于商郊牧野”。\n在利簋上,铭刻着“利”带领家族参与武王伐纣的光荣历史。这类青铜器是不折不扣的传家宝!因此,很多金文中有这样的句子:“子子孙孙永保用”,“用作宝尊彝(yí)”——周代的贵族们,希望这些尊贵典雅的青铜器,成为子孙代代相传的宝贝。当然,那些古老的权贵家族,在历史的长河中早已湮(yān)没无闻,时至今日,金文已不再是某个家族的荣耀,而是中华民族共同的瑰宝。\n古朴雄浑的金文 # 说完金文的载体和内容,再来看看金文字形本身的美。\n金文是一种古朴雄浑的文字。说它古朴,是因为金文保存了一些非常古老的字形。就拿我们熟悉的“王”字来说,“王”是国家的统治者,这个字最开始是什么样子的呢?在甲骨文中,“王”写成、、等样子,有人说,这是祭祀上天的架子,用来焚烧祭品,王者拥有祭祀上天的神圣权力!也有人不同意,哼!画个烤肉架,就能代表高高在上的王吗?\n争来争去,直到看到了金文中的一个字形。\n你看这个字像什么?没错,斧头!它是斧头的象形。那帝王和斧头之间,又有什么关联呢?\n在古代,有一种威猛夸张的大斧头,名字叫“钺”,它标志着征战、杀伐的权力,是王权的象征。前面说过,青铜刚刚铸造出来时是金黄色的,在古书中,这种青铜大斧有个专门的名字——黄钺!\n《尚书》中记载,武王伐纣的时候,“左杖黄钺,右秉白旄(máo)以麾(huī)”——左手拿着青铜大斧,右手挥舞雪白的旗帜,来指挥部队。除了史书,考古发现也印证了黄钺的存在。在殷商时期,商王武丁有一位英勇善战的妻子,她是中国历史上第一个女英雄,名字叫妇好。在河南安阳发现的妇好墓中,出土了非常霸气的青铜钺。而且,不止一把,而是两把!这说明妇好拥有领兵出征的权力。根据甲骨文记载,这位女英雄最多指挥过13000人,真是赫赫威风!不过,妇好出征,手拿两把大斧,听起来怎么有点儿像李逵……\n从妇好墓出土的黄钺,结合史料和金文,我们可以得出一个结论:什么是“王”?王者拥有钺所代表的征伐大权,这是一种至高无上的政治地位!\n至于雄浑,金文地位崇高,在当时都是由最好的书法家来写的。而且,金文不是用刀契刻,而是铸造而成,相比甲骨文而言,有着更多的书法展示空间。若你去博物馆游览或者看字帖,会发现金文的书法性非常高,有一种雄浑丰满、古朴天然的美感。吴昌硕、齐白石等大书法家都喜欢用这种字体进行创作。\n[1] 汉代以前所说的“金”往往指的是青铜,这是一种铜和锡、铅等的合金,刚制成时是耀眼的金色。博物馆里的青铜器,大都在地下经过了千年的水浸土埋,金属被腐蚀,颜色变为庄重古朴的青黑色,内敛了许多。\n[2] 《说文解字》是中国历史上第一部系统分析汉字字形、讲解汉字字理的字典,简称《说文》,作者是东汉的许慎。在体例上,它开创了部首编排法;在内容上,它用六书系统地解说汉字,并保存了部分早期古文字的写法,为我们研究甲骨文、金文等提供了依据。六书是六种汉字造字法,包括象形、会意、指事、形声、转注和假借。\n[3] 上古时代,天下被划分为九州,即九个地理区域。后来,人们用九州泛指天下。\n[4] 戟是一种兵器,主要由青铜制成。\n[5] 夏朝的最后一任国君。\n[6] 殷商的国都,在今天的河南安阳。\n小篆-一统天下 # 金文,是周代的主要文字,小篆,是秦国的文字。从周到秦,在这两个伟大的王朝之间,经历了春秋战国数百年的风云变幻。在这波澜壮阔的历史进程中,我们的汉字又经历了怎样的命运呢?\n战国文字:我的地盘我做主 # 典雅肃穆、古朴雄浑的金文是周代的官方文字。但随着周幽王[1]烽火戏诸侯的闹剧,还有周平王无奈而仓促的东迁[2],从西周到东周,天子的权威与礼乐一落千丈!在诸侯并起的春秋战国时代,金文这种整齐规矩的文字,也受到了极大的冲击——周天子都可以不放在眼里,何况区区文字呢?\n从周代到战国,汉字的面貌发生了巨大变化。我们还是先浏览一下战国的文字吧,你看,这页下面是秦国文字、三晋[3]文字、楚国文字和齐国文字。每个国家的文字各不相同,差异极大,战国文字最基本的特点就是复杂纷乱、各国不同。\n为什么会这样呢?\n这和当时的政治形势密不可分。当时天下分裂,战国七雄——秦、楚、燕、齐、赵、魏、韩,谁都想统一天下,成为天下共主,谁都要谋求自己的霸业和野心。在这样的心态下,各国的政治、文化、军事、社会不断走向差异——我的地盘我做主,就要和你不一样!\n天下分裂,各国制度不同,汉字也是如此。许慎在《说文解字叙》中说:“诸侯力政,不统于王,恶礼乐之害己,而皆去其典籍,分为七国,田畴异亩,车途异轨,律令异法,衣冠异制,言语异声,文字异形。”\n诸侯用暴力争夺天下,打来打去,要不怎么叫“战国”呢?在一个你咬我、我咬你的时代里,天子的礼乐就像耳边整天的唠叨一样,真烦人!赶紧扔掉。各国纷纷推出自己的货币政策、土地政策、交通法规、法令政策、衣冠风俗,连语言文字也不例外——嘴里说的是方言土语,写出的文字也各不相同。\nVCG21gic5474401-©View Stock/VSI美好景象(Creative)/视觉中国\nVCG21gic5474144-©View Stock/VSI美好景象(Creative)/视觉中国\nVCG21gic5474421-©View Stock/VSI美好景象(Creative)/视觉中国\nVCG2124a9e3381-©View Stock/VSI美好景象(Creative)/视觉中国\nVCG21gic5474248-©View Stock/VSI美好景象(Creative)/视觉中国\n战国时期的不同货币\n秦始皇与文字统一 # 天下大势,分久必合,合久必分,经过了近300年的春秋时期和200多年的战国纷争,中国历史终于又从分裂走向了统一。兼并六国,结束战国纷争的丰功伟业,是由秦始皇完成的。\n秦始皇雄才大略,他十三岁即位,二十二岁亲政,在三十九岁时就已经统一了中国,结束了春秋战国数百年的纷争。大诗人李白写道:“秦王扫六合,虎视何雄哉!”如果你读过《史记》中的《秦始皇本纪》,再去陕西的兵马俑实地感受一下,一定会为他的赫赫功业震撼不已。当然,在统一中国的过程中,秦国多次打败东方六国的大军,杀伤无数,也让秦始皇在历史上背负了残忍、暴虐的骂名。要知道,这样一个重要的历史人物,对他的评价始终都是相当复杂的。\n统一天下之后,秦始皇需要治理天下,这个时候,他发现自己遇到了一个大麻烦——各国的制度、风俗、语言、文字都不一样,想要推行秦朝的政令,下一道诏书,六国的人看不懂,怎么办?汉字不同,也影响大家之间的文化交流,想看其他六国的书,相当于要重新掌握好几门语言文字,简直太麻烦了。\n秦始皇正在头疼,他的丞相李斯来了。“陛下别急,六国的文字乱七八糟的,干脆用咱们大秦的文字,把它们统一起来!”秦始皇一听,“好办法!丞相所言,甚合朕心。这个任务就交给你了!”\n于是,李斯带领着那个指鹿为马的太监赵高(这个人品质很坏,但能耐不小,也懂得文字学呢),还有太史令胡毋敬,一起写出了《仓颉篇》《爰历篇》和《博学篇》。在这三篇字书中,包括了3300个字,他们根据传统的秦国文字,进一步加以简化、调整,同时废除了那些形状各异的六国文字,最后推出了小篆!\n这段历史,就是《说文解字叙》中所记载的:“秦始皇帝初兼天下,丞相李斯乃奏同之,罢其不与秦文合者。斯作《仓颉篇》,中车府令赵高作《爰历篇》,太史令胡毋敬作《博学篇》。皆取史籀(zhòu)[4]大篆,或颇省改,所谓小篆者也。”所谓“史籀大篆”,指的是多取自周代大篆的秦国文字,“省改”则是省简与改易的意思。\n你可能会问,“篆”这个字好陌生,是什么意思?\n“篆”虽然难认,但意思很清楚。“篆”是个形声字,下面的“彖”是用来表示“篆”的读音的,而上面的竹字头说明这个字和竹子有关。“篆”的本义就是用笔写在竹简上的意思。《说文解字》中说:“篆,引书也。”这是一种笔画拖长、线条流畅的文字,就好像用笔“牵引”着书写出来的一样。\n小篆作为秦始皇统一天下之后面向全国推行的标准字体,被大秦帝国不断地推广、普及。对秦始皇来说,统一文字是维护国家统一的重要政策。对中华民族来说,统一的汉字使得我们能够更加团结、凝聚,不要小看一个个小小的汉字,是它们铸成了我们几千年来未曾动摇的文化根基。\n李斯的马屁 # 小篆一统天下,听上去就很酷!\n小篆长什么样子呢?作为秦始皇推广至全国的规范文字,小篆字形整齐,结构规范,不像甲骨文那样,同一个字有各种各样的写法;小篆充分保留了造字的文化内涵,在它的字形中,蕴含着汉语和中华文化的奥秘。这一点,我们后面在具体讲解造字法时会讲到很多案例。此外,从字形来讲,小篆也是一种比甲骨文、战国文字更为漂亮的汉字,它线条细腻,工整大方,十分优美。\n遗憾的是,无论是《仓颉篇》《爰历篇》还是《博学篇》,李斯的标准小篆样板都已经失传了,我们今天只能在秦始皇的一些碑刻上,看到李斯小篆的残迹。\n秦始皇是一个精力旺盛的人,他不甘心“宅”在咸阳的宫殿里,而是要巡游天下,亲眼看一看自己统治的山河大地。哪里有造反闹事的,顺手再给平定了。他东到琅玡(láng yá),南到会稽(jī),登泰山,登峄(yì)山,顺道让大臣们歌颂自己的功德,然后刻在石碑上,秀给千秋万代。\n这就是历史上著名的秦刻石!\n这些碑刻,是大秦书法第一人——李斯所写,你看,这是《泰山刻石》上的残字,斑斑点点,充满了沧桑之感。古人读书写字,都是从右往左、自上向下的顺序,大家跟我一起看:\n第一个字是臣子的“臣”,第二个字是请求的“请”,第三个字是“具”,表示详尽,第四个字是“刻”,最后两个字是“诏书”。“臣请具刻诏书”,我请求把您的诏书详尽地刻下来。这六个字,应当是李斯的传世真迹,古朴苍劲、端庄大方,是中国书法史上难得的精品!\n李斯书法很好,但作为臣子,在内容上难免要拍一拍秦始皇的马屁。我们再一起看下面的《峄山碑》。\n这块碑是宋代人的仿刻,现在还保存在西安碑林中。它的内容很完整,我们看前四个字,“皇帝立国”,从秦始皇统一天下说起。在《峄山碑》里,有不少盛赞秦始皇的话,什么“威动四极,武义直方”啊,什么“孝道显明”“乃降专惠”啊,秦始皇太厉害了,他征讨逆贼,威震四方,都是正义的战争。他尊崇孝道,一直践行先祖统一天下的愿望,又是那么仁爱,把自己的恩泽惠及天下所有的人啊,听上去都有点儿小肉麻。\n最讽刺的是,《峄山碑》里面说秦始皇“灭六暴强”——六国是暴君,是强权,是我们伟大的皇帝把他们灭掉了。可在六国人眼中,发动战争的是“暴秦”啊,你是“暴强”,你全家都是“暴强”呢!\n看来,书写历史的权力,往往掌握在胜利者手中。\n[1] 西周末代国君,也是历史上有名的昏君。古代帝王诸侯贵族或高级官员死后,朝廷会根据其生平事迹,给他一个具有褒贬意义的称号,这种称号叫作谥号。周幽王谥号里的“幽”字,和商纣王的“纣”字一样,都不是好的谥号,能看出人们对这两位昏君的负面评价。\n[2] 周平王继位时,周王室已然衰微,国都丰镐(今陕西长安西北)被犬戎洗劫一空。为了避免再被犬戎侵袭,平王将国都东迁到洛邑(今河南洛阳),史称“平王东迁”。东周王朝由此开始。\n[3] 战国时代,赵国、魏国、韩国的合称。由于三个诸侯国都出自原晋国,所以统称“三晋”。\n[4] 相传史籀是西周的大书法家,他书写的字体叫做“大篆”。\n隶书-大刀阔斧的简化 # 小篆有种种优点,但为什么我们今天不用小篆来写字呢?这种字体,有没有致命的缺点呢?\n解答这个问题之前,建议你先写一写小篆,动动手你就明白了。\n写到手断的小篆 # 写什么呢?给你一个小篆样本好了——“一只忧郁的台湾乌龟”。\n有没有觉得字形好复杂?写呀写,有一种要崩溃的感觉!\n小篆字形规整、内涵丰富、端庄大方,很棒!但它有一个致命的问题,那就是不好写——写这种文字,需要耐心细致、凝神定气,手中的毛笔又慢又稳,很有修身养性的感觉,但就是效率特低!写上个千八百字,恨不得手都要累断了。\n历史上,秦始皇是一个暴君,但不可否认的是,他也是一个非常勤政的国君。相传他每天要看的奏折,有一百多斤重!秦始皇看奏折累,那些用小篆写奏折的人呢?累不累?秦国以法治国,政令繁多,很多事情都要写成公文去交流。如果用小篆来写的话,效率太低下了,大小官吏们实在辛苦。\n于是,在小篆作为规范文字向全国推广的同时,秦国的官吏们率先放弃了小篆,他们自觉地简化汉字,用一种简便的字体书写了大量的公文。这种字体,就是隶书。“隶”就是小吏的意思。\n隶书与简化 # 秦朝的官吏们都做了哪些简化呢?\n把隶书和小篆进行对比,你会发现,隶书的特点十分鲜明。比如“者”字,除去“日”,如果用小篆写,要六七笔,而且有很多弧形的笔画,写起来弯弯绕绕的。但在隶书中,复杂的笔画被连起来了,弯曲的笔画被拉直了,直来直去,唰唰唰唰,四笔就能写完!快得多!\n再看裤腰带的“带”。在小篆中,“带”上边的一横是腰带的象征。古代的“带”不仅是用来提裤子的腰带,还要挂上各种各样的饰物,“象系佩之形”,横下面的笔画就表示古人腰带上佩戴的一大串玉佩、香囊。小篆中的“带”弯弯曲曲,很好看,但也很复杂。到了隶书中,笔画被拉直简化,上半部分一横三竖,就写完了。\nVCG21gic20065841-©许嘉星/视觉中国\n从小篆到隶书,汉字的这个变化过程叫作“隶变”。在隶变中,汉字的笔画变得横平竖直,基本结构越发趋向现代汉字。经过大刀阔斧的简化过程,汉字书写的效率大大提高。但另一方面,汉字中的意义信息也丢失了不少。比如,“鸟”的小篆字体是,还能看出是一只小鸟朝上张嘴啼鸣,右下方是扇动的翅膀,左下方是小鸟的爪子,但到了隶书,已经是我们熟悉的繁体字“鳥”了,看不出小鸟的模样。\nVCG21gic20065840-©许嘉星/视觉中国\n在汉字的发展中,有一组重要的矛盾——字义丰富和书写效率。想要在一个字里体现更多的字义,就要用更多的笔画来表示。但如果笔画太多了,这个字又很难写,不利于传播,就要加以简化。\n举个有趣的例子,陕西有种面条叫“biang biang面”,这个“biang”写成这样:\n一个字,包含了好多好多内容。为了记住这个极度复杂的字,陕西有个歌谣:“一点撩上天,黄河两道湾,八字大张口,言字往里走。你一扭,我一扭;你一长,我一长;当中夹个马大王。心字底,月字旁,一个小钩挂麻糖,坐个车子回咸阳。”说的就是这个字。\n哇,好厉害的面!一碗面条,写出了这么多的讲究。但你不觉得这个字特别难写吗?让你写一百个面,我估计你这辈子都不想再碰它。\n因此,汉字势必要进行简化。为什么呢?因为我们懒啊,谁也不想写笔画多的字。从甲骨文、金文、小篆再到隶书,这是一个发展了几千年的简化过程。哪怕会丧失一些字义信息,但人们还是坚定不移地选择了简化。\n简化,是汉字发展的整体规律。\n两汉名碑 # 隶书在秦代萌芽后,在社会上越来越流行,在汉代大为兴盛。从西汉到东汉,出现了一批经典的隶书名碑,让我们得以窥见当时的隶书之美。\n在西安碑林,你能看到秀美端庄的《曹全碑》;在曲阜孔庙,你能看到大方古朴的《乙瑛碑》;在泰安岱庙,你能看到雄强大气的《张迁碑》;在陕西汉中,你能看到气势磅礴的《石门颂》……\n这些碑刻上的隶书精彩极了,在斑驳的石刻上,我们看到了汉字古朴浑厚的文化气质!尽管我们已经不知道那些写字者的具体姓名,但他们气象万千的隶书书法,早已成为中国书法史上光彩照人的篇章。\n《曹全碑》\n《乙瑛碑》\n从汉碑,我们能看出隶书的基本特点。第一,隶书字形在整体上是扁方形的。第二,隶书讲究“蚕头燕尾”。在起笔的时候顿笔,就像蚕宝宝的脑袋一样;在收笔的时候要翘起来,就像天上飞的小燕子的尾巴一样。\n这是隶书独特的审美风味。\n隶书是古今汉字的分水岭,之前的甲骨文、金文和小篆都是古文字,从隶书开始就是书写规范的今文字了。从古文字到今文字的演化过程中,汉字遭遇过一场史无前例的危机——秦始皇“焚书坑儒”。在漫天的大火里,中华古籍遭到了毁灭性的破坏,古文几乎废绝。在这样的年代里,人们离古文字渐行渐远,很多人不明白汉字发展的过程,便开始根据当时通行的隶书来随意讲解汉字。比如说,什么是“长”呢?当时的“长”写作繁体字“長”,“長”和“馬”(马)的上半部分很像,于是有人就说“马头人为长”——长像马,马脸长,人脸要是像马脸一样,自然就是“长”啦。\n听起来是不是有点儿荒唐?当时有一个叫许慎的人看不下去了!\n找一把解开古文字的钥匙 # 许慎是谁呢?许慎,字叔重,他熟读经典,是个大儒,被人们称赞为“五经无双”,意思是说许慎熟读五经,对古籍的理解天下无双。\n具有研究精神、严肃认真的许慎心想,怎样才能准确地讲解汉字呢?隶书已经经过简化和笔画的调整,不能准确地传达出汉字本身的造字含义,应该找一种更古老的字体,作为说文解字的基础。在许慎生活的年代,甲骨文尚未发现,金文数量极少且过于久远,于是,他将目光转向了古书中的小篆。比如说,如果看隶书中的“光”字,根本看不出它的造字思路,但如果看向“光”的小篆字体,便能看出上面是个“火”——“光”与“火”相关,简直再清晰明白不过了!\n就是它了!小篆承前启后,连接甲骨文、金文等古文字,保存了古文字中丰富的意义和文化,是打开古文字宝库的钥匙。凭着这把钥匙,许慎写出了汉字史上最重要的一部书——《说文解字》。\n许慎把小篆作为《说文解字》的基础,在古籍中遍寻汉字的小篆字形,广泛搜集。有的字找不到小篆字形,他便按小篆的书写规则加以复原。最终成书一共收录了9353个汉字(不包括异体字,也不包括后继学者为《说文解字》增加的新附字)。\n在字头下面,许慎会详细讲解汉字:这个字是怎么造出来的,它的形体有何特点,这个字的字义是什么,读音又是什么,这个字的背后有怎样的文化内涵。比如“示”字:\n示,天垂象,见吉凶,所以示人也。从二(二,古文上字)。三垂,日月星也。观乎天文,以察时变。示,神事也。凡示之属皆从示。\n这是《说文解字》对“示”的解释,我们知道,“示”有“展示”的意思,古人的理解则要更为复杂一些,具有鲜明的历史特点。在他们看来,什么东西能给人“展示”“揭示”出最根本的道理呢?那就是我们头上的苍天。“天垂象,见吉凶,所以示人也”是解释“示”的字义:上天有各种各样的“天象”,日月风云、星辰月相,都提示着人间的吉凶祸福。所以,人要善于观察天象,及时把握天意,得到启示。“从二。(二,古文上字。)三垂,日月星也”是拆分说解字形,你看小篆中的(示),上面是“二”,许慎先生告诉我们,这个“二”在古文字中是“上”,它在古汉语中有上天的意思,天象给人们带来启示。说起来,甲骨文的(上)就写作一长一短两横。下面一横长,表示大地;上面一横短,标注着大地上方,表示地平线以上的位置,也就是“上”的意思。这是一个标准的指事字,后面我们还会讲到。\n下面的“三垂”呢?也不是随便的三笔,而是象征着天上的日月星辰,也就是所谓的“三光”。无论是中国文化中的星宿,还是西方文化中的星座,在古人眼中,日月星辰都与人类世界密不可分,具有“示”的功能。“观乎天文,以察时变”,这是来自《周易·贲·彖辞》中的话,意思是古代圣人观察天文、获取启示,从而来把握现实中的种种变化。\n发现部首的奥秘 # 除了用小篆厘清汉字含义,《说文解字》的另一个重要意义和价值是:许慎发现了汉字部首的奥秘。\n在《说文解字》中,上万个汉字是由540个部首统摄起来的,掌握了部首,也就掌握了汉字的秘诀。比如,“示”就是一个重要的部首,这也就是现在楷书中的“礻”。所谓“凡示之属皆从示”,“礼、祭、祀、神、福、祸”,这些和神灵祭祀、祸福祈祷有关的字,都是从“示”的。\n许慎用部首把具有相同偏旁的字归纳起来,让汉字不再零散,呈现出汉字字义的基本规律,这是汉字历史上特别重要的一个发现。我们今天的《新华字典》里还有“部首检字法”,通过部首来查找汉字,这个重要的发明,是许慎先生的功劳!\n《说文解字》凝聚着许慎一生的心血,它是中国历史上第一部字典,也是中国历史上第一部汉字文化巨著,书中蕴藏着汉字的全部奥秘。因此,许慎也被尊称为仓颉之后的第二位“字圣”。\n我为大家讲解汉字,正是以《说文解字》为基础的。\n楷书-汉字的楷模 # 在隶书之后,还有草书、行书等不同字体,但影响最为深远的,还是楷书。\n楷书诞生的时间并不晚,1800多年前的汉魏之际就已经形成了,之后又过了200多年,到南北朝时期渐渐成为主流字体,进而一举成为汉字的标准字体。我们今天学写字,都是从楷书开始。\n从甲骨文到楷书,汉字经历了几千年的岁月,从历史走到今天,跌宕起伏,实在不易。\n什么叫楷书? # “楷”有楷模、标准的意思,也就是正体书法。这种字体端正大方,能够成为汉字的标准,因此有了“楷书”这样的好名字。楷书也叫作“真书”“正书”——一种“真真正正”的字体,带有标准的内涵。\n一般来说,和行书、草书一样,楷书也是由隶书发展而来的字体。和隶书相比,楷书发生了几个明显的变化。对比隶书和楷书,你能看出有哪些不同吗?\n很明显!楷书的字形结构由扁平变成了正方,所谓“方块汉字”,指的就是楷书。此外,楷书中的笔画没有明显的蚕头燕尾了,比隶书更为平直,写起来也就更加便捷。最后,楷书的字形比隶书更加简化了。\n楷书四大家 # 在汉字的历史上,曾出现过一大批善写楷书的大书法家。其中,最有名的是楷书四大家。\n他们是谁呢?\n他们是唐代的欧阳询、颜真卿、柳公权,还有元代的赵孟頫(fǔ),分别为欧体、颜体、柳体、赵体的创始者。在四大家中,有三个都是唐朝人,足见唐朝是楷书发展的高峰。四大家的书法各有特色,欧体端庄刚劲,颜体雄浑厚重,柳体瘦硬遒劲,赵体圆润俊逸,真是各有千秋、自得风流。\n在楷书四大家中,我最喜欢颜体,那种雄浑苍劲、大气非凡的感觉,看起来仿佛千军万马、长枪大戟一般。\n为什么颜体这么有力量呢?\n传说,颜真卿曾经告诉别人:“写千斤之字必先有千斤之力!”人家不解,“敢问,什么叫千斤之力呢?”\n颜真卿哈哈大笑:“你看着啊!”他弯下腰,握住椅子腿,将一把太师椅举过头顶,一、二、三……一口气举了几百下,再轻悄悄放回原处,大气不喘,面不改色。“看好了,这就是千斤之力!”\n好家伙,颜体是这样练出来的啊!\n这只是一段趣谈,其实颜体可谓字如其人,我给你讲讲颜真卿的故事吧。\n读《新唐书》中的颜真卿传记,会让我们对颜体字的理解又深了一层。颜真卿一门忠烈,他堂兄叫颜杲(gǎo)卿,是安禄山亲手提拔的常山太守。安史之乱时,颜杲卿举兵讨贼,把安禄山弄得狼狈不堪。后来城破被俘,安禄山恨透了他,把他捆在天津桥的柱子上,凌迟处死!\n哥哥壮烈殉国,这件事对颜真卿影响很大。到了颜真卿的晚年,李希烈在汝州造反,朝中奸臣陷害颜真卿,让他前往招抚。这是要把他送到狼嘴里去啊,不少人劝他别去,颜真卿却毫不犹豫地出发了。\n到了汝州,有上千名士兵拔刀冲上来,李希烈的将领们纷纷谩骂,“将食之”——“把这老东西吃了”!颜真卿面不改色,坦然宣读朝廷旨意。\n到后来,李希烈派人劝降,颜真卿正色说道:“你没听说过常山颜杲卿吗?那是我的兄长啊!我一个年近八十的老头子,眼看要死的人了,还会改变自己的操守吗?”\n李希烈没办法,便把颜真卿关了起来,挖了个坑要活埋他。颜真卿淡然一笑,说道:“生死有命,你以为挖个坑就能吓到我吗?”最后,颜真卿被李希烈派人绞死,临死前骂贼不止,和兄长一样壮烈殉国。\n颜真卿去世时是七十六岁,已是风烛残年,却爆发出刚强的气概。我们今天讲他的故事,仿佛还能感受到那股堂堂正气。北宋大文学家欧阳修评价颜体书法说:“斯人忠义出于天性,故其字画刚劲独立,不袭前迹,挺然奇伟,有似其为人。”意思是说,颜真卿天性忠义,他的字画就像他的为人一样,遒劲有力,不承袭前人的印记,挺拔奇伟。这也正是我最喜欢颜体的原因,读颜真卿的传记,再看他雄浑刚劲的书法,心中不禁会生出一种由衷的敬意。\n繁体字与简体字 # 楷书诞生后也不是一成不变的。\n从历史到今天,楷书经过了一个不断简化的过程。最大的变化,便是繁体转变为我们熟悉的简体。\n在楷书中,有繁体字和简体字的区别。你看右页,“凤、兴、爱、论”的繁体与简体,是不是差别蛮大?\n汉字从古至今,不断简化,新中国成立以来,国家更大力推行简化字。因此,我们如果读古书的话,会看到很多繁体字,但在今天的图书中,则是以简体字为主。\n也许你要问,繁体字和简体字,哪个更好?\n哎呀,回答这个问题真麻烦!我先问你一个问题吧,世界上有没有绝对的“好”,和绝对的“不好”呢?\n战国时代有一位哲学家,叫庄子。他说:“彼亦一是非,此亦一是非。”什么意思呢?是非对错,从来不是绝对的,要看在什么样的角度上理解。\n就书写方便来说,当然是简体字好!不承认的话,罚你抄一百遍“壹隻憂鬱的臺灣烏龜”(繁体字版“一只忧郁的台湾乌龟”)!\n为什么要追求书写效率呢?\n文字,是为社会应用服务的,一种好写、好认的汉字,当然更受欢迎。\n简化,是贯穿汉字发展的整体规律。从甲骨文开始,汉字就在不断简化了。在新中国简化汉字的过程中,汉字专家们在简化字时尽量做到有所依据。很多人都不知道,在今天的简体字里,只有0.26%是新中国成立之后完全新造的,其他简化字都有历史来源。比如说繁体字中的“爲”在简体字中写作“为”,但实际上,“为”的写法在王羲之的书法中就已经出现了。\n中国古人在写字的时候,会进行自发的简化。因此,我们千万不要以为,简体字是今人随随便便造出来的,它们其实是历代古人追求书写便利的智慧结晶。\n新中国推行简化字后,汉字好学好认,其一大好处便是扫盲。\n你听说过扫盲这个词吗?\n扫盲,就是扫除文盲。在古代,知识是奢侈的,只有少数的读书人才会写字,大多数老百姓根本没机会接受教育,更不认字,这样的人被叫作“文盲”。你能想象新中国刚成立的时候,中国大约80%的人都是文盲吗?你能想象文盲的生活吗?我有一次出国旅游,就体会了文盲的痛苦。吃饭点菜,菜单上都是不认识的洋文,随手叫了几个菜,那叫一个难吃……\n点菜还是小事,不认识字,老百姓就会被人欺负,被人骗,整个社会更毫无平等可言。中华人民共和国成立后,国家下了很大的力气扫盲,教那些不认字的老爷爷、老奶奶识字。扫盲,用的就是简体字,因为它好学啊!\n有的汉字简化得非常精妙,比如我们讲过的“龟”字。它在甲骨文中的写法就相当烦琐,繁体字写作“龜”,来自小乌龟的侧面象形,也很难写,一不小心就会写错。但简化成“龟”之后,笔画清晰,一目了然,而且和小乌龟的样子还是很像!\n介绍了简体字的好处,那繁体字呢?\n如果你想要了解汉字的历史、汉字的文化,就需要掌握繁体字的知识。在繁体字中,我们能够更清晰地看到汉字的历史脉络。讲《汉字就是这么来的》,也会给大家介绍繁体字的相关知识。要知道,简化字也是有副作用的,那就是会导致汉字字义丢失、汉字的历史源流断绝。在很长一段时间里,我们教学生掌握汉字的道理,都忽视了汉字源流的讲解,对汉字背后的文化内涵也不怎么清楚,导致我们只能机械地记忆汉字。今天,我们越来越重视汉字的历史,还有它背后的道理了,在学汉字的时候,也会注重汉字历史和汉字字理的讲解,这也是我写这套书,想和你聊聊汉字的原因。\n总之,简体字方便使用,繁体字保存历史,二者各有所长。对我们来说,踏踏实实地用简体字,再了解一下繁体字的知识,加深对汉字的理解,这就足够了。\n象形字-用图画来造字 # 汉字,从遥远的古代走到今天,从古老神秘的甲骨文,走到我们笔下整齐端正的楷书。汉字的岁月,是中国文化的生命历程;汉字的未来,也与中国文化相伴始终。\n懂得了汉字的历史,接下来,我们需要了解造字的方法,这是我们走进汉字世界的重要角度。汉字是怎么创造出来的?在造字中,凝聚了古人怎样的智慧与灵感?古人造字遵循了哪几种造字逻辑和规律呢?\n这些问题,都等待着我们探寻!\n什么是“六书”? # 中国古人在很早以前,就开始思考造字的规律了。他们把这些规律总结为“六书”——“书”是书写,写的是文字,六书就是六种造字、构字的规律。这是我们了解汉字的基本知识。\n在周代,古人就已经提到六书了,但历史上最重要的六书专家,把六书讲解得明明白白的,还是《说文解字》的作者许慎。在《说文解字》中,许慎先生给六书下了清楚的定义,用具体的例子说解六书,还用六书分析了全部的汉字。可以说,有了《说文解字》,六书才成为一门重要的学问。\n说来说去,“六书”是什么呢?\n它们包括象形、指事、会意、形声、转注、假借。一般来说,六书的重点在于前四书,也就是象形、指事、会意、形声。\n这些名称都是啥意思啊?听上去好难!别急,这正是我们接下来要重点讲解的内容。\n象形:汉字是画出来的 # 象形,是用画画的方式造字。\n象形字,和事物的“形”体一定要“像”。在《说文解字》中,许慎先生是这样讲解“象形”的——“象形者,画成其物,随体诘诎,日月是也”。\n什么意思呢?“画成其物”,象形是用绘画的手段来造字,“物”是汉字所要表达的事物。“随体诘诎”,象形字要惟妙惟肖地画出事物的形体,“体”是事物的形体,“诘诎”是弯弯曲曲的笔画,用来描绘事物的轮廓。最后,许慎先生举了两个例子:日和月。\n试想一下,如果你是一个小原始人的话,怎么给太阳和月亮造字呢?\n太阳圆圆的,非常明亮,仿佛有无尽的光明洒向大地。因此,古人造日的时候,画一个圆圈,来描绘太阳的形体;中间加上一点,表示太阳的光芒。《说文解字》说:“日,实也”,太阳的光芒是充实饱满的。\n月亮呢?十五的满月是圆的,但也有弯弯的月牙,像女子的眉毛一样。月有阴晴圆缺,和太阳相比,“缺”的状态是月亮独有的特点。因此,古人造月的时候,画一个弯弯的月牙,来描绘月亮的形体。\n你看,这就是古文字中的日与月,它们是最典型的象形字。\n画出自己,画出世界 # 参考日月的形象,造出、的过程,叫作“取象”。取象有两个最重要的来源,“近取诸身,远取诸物”——低下头,看看自己,从我们的身体中选取图像来造字;抬起头,眺望世界,从广阔无垠的大自然中选取图像来造字。简单来说,就是画出自己,画出世界。\n在象形字中,我们能看到从头到脚的人体形象。还记得在讲甲骨文的时候,我们拼出的古代人脸吗?那个让你难忘的美男子,就是由象形字组成的。再给你举几个例子吧,你看,这个字是什么?\n我们不难看出,这是一个脑袋的象形。无论是头发、面孔,还是眼睛,都清晰可辨。这个字是甲骨文中的“首”,也就是脑袋的意思。不过,这个脑袋看起来好丑……\n再看下一个字!,这是周代金文中的形体,猜猜看,这是什么字?\n猜对了!有手臂,有张开的五指,这个字是“手”的象形。\n有了手,也一定有脚。我们再来看第三个字:。\n上面是小腿,下面是脚,脚趾还向外张开着,这个字又是什么呢?\n它是甲骨文中的“足”。\n在象形字中,古人非常细致地观察了自己的身体,从头到脚,选取了各种各样的形体,造出大量和人体有关的汉字。对于这些字,我在《字里字外的人体世界》还会给大家详细讲解。\n在近取诸身以外,古人还远取诸物。\n他们仰观天文,俯察地理,把自己好奇的目光,投向了丰富多彩的大自然。在象形字中,有天文地理,有花草树木,有日用器皿,有饮食服饰,还有各种活泼可爱的小动物。\n你看,这里有几个汉字:\n第一个字,是甲骨文中的“山”,画出了高耸入云的山峰。\n第二个字,是甲骨文中的“水”,画出了蜿蜒曲折的流水,汉字中的小点,就像流水中飞溅起的水花一样。\n第三个字,是战国文字中的“艸”。“艸”是小草的意思,也是草字头的来源,春风吹拂大地,小草生出了嫩芽。\n第四个字,是甲骨文中的“木”,树根牢牢地扎在地里,树枝在微风中轻轻摇曳。\n这四个字放在一起,展现出大自然中的动人风光。\n我们再看下面的几个字:\n第一个字,画出了长长的腿,轻盈的四蹄,还有长长的角。这是什么动物呢?也许你一眼就能看出,这是甲骨文中的“鹿”字。\n第二个字要侧过来看,也是一种动物,脸长长的,后背上还长着鬃毛,似乎在随风飘荡。它是什么呢?这是甲骨文中的“马”。\n第三个字是动物的局部,凸显出下弯的双角,这是甲骨文中的“羊”。\n第四个字最好认了,太象形了,这是“牛”。\n这样的汉字还有好多,足够组成一个动物园了。\n认识了这些象形字,我要问你一个问题了。在你看来,象形字最主要的特点是什么?\n“像!太像了!”\n没错!象形字就是要把事物形体惟妙惟肖、淋漓尽致地描绘出来。可以说,象形字用图画的方法,记录了丰富而美丽的世界。需要注意的是,甲骨文在描绘物象的时候,非常注重展现它们的特点,从而进行有效地区别,比如说,牛角向上,羊角向下,就是凸显了牛羊的特点。\n图画与背景 # 不是所有的事物画成字时都容易识别。有些东西,即使你画出了它的样子,别人也不认识。\n什么?你说你不信?好吧,我画一个给你看。\n这个方块,你说它是什么?有人说,这是一块面包。有人说,这是一扇窗户。还有人说,这是一个手机。到底是什么,得不出一个明确的答案。\n因此,光描绘事物本身不行,有时还需要把它的背景画出来。比如这个方块,它的背景其实是一个房子——。\n墙上面方方正正的一个框,一定是窗户。这个字,其实是甲骨文中的“向”。《说文解字》说:“向,北出牖(yǒu)也。”“牖”是窗户的意思,“向”最初的意思是朝北开的窗户,由此引申出“方向、朝向”的意思。\n画完了一个方块,再画一个椭圆。猜猜看,这是什么字?\n有人说,这是一个鸡蛋。有人说,这是一块圆面包。还有人说,这是我们的圆滚滚的脑袋!\n都不是,这是一个瓜!\n凭什么说它一定是个瓜?我才不信呢!\n所以,在造字的时候,要把和瓜有关的背景凸显出来——。\n瓜长在藤上,甲骨文中画出了蔓延下垂的藤条,藤条下面圆滚滚的东西,自然是甜甜的西瓜啦。你看这个字,和今天的“瓜”字很像。\n刚学写字的时候,我们经常分不清“瓜”和“爪”。记住了,“瓜”字多出一点,这一点,正是瓜的象形。而“爪”的甲骨文是,看着很像爪子,或是人手垂下来的样子。\n再画一个:一个圆圈,里面是一个十字,十字里面有小点。有人说,这是一个鬼脸。有人说,这是一块田地。还有人说,这是一个熊掌。\n其实,这是一个果实的象形。果实里面长满了果肉,结满了籽。但如果有人觉得不像,怎么办呢?在造字的时候,古人在果实下面添加了一个“木”,变为——长在树上,肯定不是熊掌,不是田地,不是鬼脸,而是味美可口的水果。古文字中的“果”和今天的“果”还很像,记住,果上面不是“田”地,而是果实的象形。\n这些象形字有一个共同的特点,单独画出它们的形体,认不出来,必须把这个字的背景也画出来,才能确定是什么字。图画加背景,构成了另一种象形字。\n总结一下,“山、水、草、木、鹿、马、羊、牛”,这些只需要描绘事物的象形字,叫作“独体象形”;“向、瓜、果”,这些既需要描绘事物,也需要画出背景的象形字,叫作“合体象形”。\n特殊的象形字 # 无论刚才讲到的独体象形字的例子,还是合体象形字的例子,它们都是描绘具体的事物,都是名词。有没有一种表示抽象的字义、表示形容词的象形字呢?\n有,但比较少,这是一种特殊的象形字。\n你看这个字,像什么?下面是房屋的地基,上面是一间高高的房子,还有一个尖顶。它是房屋的象形,但却不是“房”字,而是甲骨文中的“高”。\n为什么高大、高矮的“高”,要用房子来表示呢?\n说来话长,高和低、矮相对,意思上比较抽象,不易造字。所以,古人就用生活中最常见的、高高的事物来造字。\n在远古时代,古人是穴居的,或者在山侧挖一个窑洞,或者在地上挖个坑,上面再搭一个棚子——“穴”是洞穴的意思,穴居就是住在洞穴里。在今天陕西半坡遗址中,还能看到先民穴居的遗迹。\n穴居生活相当辛苦,潮湿阴冷不说,还总受野兽和毒虫的侵扰。随着社会生产的发展,古人开始搭建一种二层的木制小楼——下面一层防潮,顺便还能养猪;上面一层干燥舒适,用来住人。这种建筑比以前的穴居屋舍,高出来很多,所以古人就用房屋的形状来表示抽象的“高”的概念。\n讲完了“高”,它还有一个近义词—“大”。\n怎么给“大”造字呢?画一头大象?画一座大山?画出无边无垠的天空?都不是好办法!大象、大山容易和“象”“山”混淆,无边的天空则很难描画。想来想去,聪明的古人想出一个好办法:在我们的人体中取象!\n人的身体在什么时候最“大”呢?当然是正面站立、四肢伸展的时候,很有一种顶天立地的大丈夫感。就这么办!古人画出了一个正面站立的人形,表示“大”,在今天的楷书中,我们还能看出,“大”是一个人伸展四肢的样子。\n不过,这种表示抽象概念的象形字,数量很少。\n讲完了象形字,我们一起思考一个问题。在你看来,这种造字方法有什么优点呢?形象,逼真,可爱,惟妙惟肖,一看就懂,趣味盎然……\n说得都不错!\n那么,象形字又有哪些不足呢?\n指事字-给象形字加上标签 # 象形字是一种经典的造字方法,古人细致地观察世界,选取各种精彩的画面,纳入汉字之中。象形字里充满了古人的智慧、灵感与用心。\n但是象形字也有它的不足。世界丰富多彩,并不是所有的事情都可以用图画来表达,一旦事情复杂了,象形字也难免笔画繁多,写起来太麻烦。而且象形字适合表达名词,特别是那些基础、常见的事物。但语言中的抽象内容,该怎么造字呢?\n这些不足,都给古人提出了新的挑战——要有更多的造字方法!\n什么是指事字? # 先问个小问题吧。你在家给爸爸妈妈帮厨吗?会切菜吗?我的问题是,如何造一个字,来表示菜刀的锋利呢?\n这个字真难造!我可以画出一把刀,但怎么能画出“锋利”呢?画一把快刀,旁边是斩断的树木,甚至连大山、大河都能砍断,这样行吗?意思是有了,但写起来太麻烦——别忘了,我们是很懒的哦。\n对此,聪明的古人想出来一个简单又好用的造字新方法。先画出一把刀,然后为了偷懒只保留了刀的一些线条。再在刀刃上添加一点,标示出刀刃的地方,创造出、等不同的写法——这就是甲骨文中的“刃”,表示刀的锋利之处,又好写,又好认!\n这种造字的新方法,人们叫作指事。“事”是想要表达的字义,“指”是表达字义的方法——在象形字上添加一个小标签,“指”明所要表达的“事”物。\n在《说文解字》中,许慎先生给出了指事字的辨别方法。“视而可识,察而见意,上下是也。”\n“视而可识”是针对象形字说的,“视”是看,“识”是认识,指事字我们往往觉得很熟悉,因为它是在象形字的基础上造出来的,一看就认识。“察而见意”说的是象形字上的小标签,用汉字学的科学术语来说,它叫作“指事符号”。“察”是仔细看,用心观察,“意”是字义——仔细看看指事字,认真研究象形字上面多出的小标签,你就能明了这个指事字的字义。\n总结一下,指事字是在象形的基础上,添加一个标示性的指事符号,来表达新的字义。\n许慎给指事举了两个例子,“上”与“下”。“上”和“下”太抽象了,怎么给它们造字呢?\n这两个就是“上”“下”的甲骨文,乍一看很像“二”。这两个甲骨文里,都有一个长横。在汉字中,长横经常用于表示大地,因为远眺地平线时,大地就是一道直线。在大地之上,加一个表示位置的小标签,就是甲骨文中的“上”;在大地之下,加上标签,就是甲骨文中的“下”。\n指事字不常见 # 指事字是对象形造字的重要补充,但在汉字中,指事字的数量是很少的。\n少到什么程度呢?常见的指事字,也许不超过二十个。正因如此,指事字在汉字中格外宝贵,物以稀为贵嘛!就这么点儿指事字,要不要好好了解一下呢?我现在就给你介绍几个有趣的指事字吧。\n先看这个字,它的基础是一个“又”字——。在古文字中,“又”是右手的象形,伸出你的右手,自然放松,大拇指和食指分开,中指、无名指、小指轻轻地合拢在一起,从侧面看,和甲骨文中的“又”像不像?在汉字中,“又”经常表示手的意思,我们在后面还会讲到。\n是在(又)的下面多了一点,作为指事符号。在手腕的部位,加上一个小标签,猜猜看,这是什么字呢?\n猜不出来吧,嘿嘿,我来告诉你,这是古文字中的“寸”。\n咦,“寸”是个长度单位啊,它和我们的手腕有什么关系?\n要知道,古代最早的丈量单位,很多都来自人体。人是万物的尺度,在尺子还未诞生的时代,我们用自己的身体丈量世界。我们的手腕上有一条横纹,在中医里叫作腕横纹。在你的腕横纹下面,用三个手指头一比,就是一寸,也就是中医所谓的“同身寸”——你的一寸,只有用自己的手指头才能量准。因此古人用一个小点标识出一寸的位置,造出了“寸”字。\n这个位置,有一个穴道,叫作内关穴。在腕横纹下面一寸,再找到两根筋,中间的位置就是内关穴。用力摁一摁,酸酸疼疼的,这是身体上非常重要的一个穴位。找找看,能不能按准自己的内关穴?\n你看,汉字包罗万象,它和古老的中医文化也是相通的。\n说完“寸”,再看这个字。\n里面是一只眼睛,也就是“目”,外面一个框,包围着目字。这是什么字呢?是眼眶的“眶”吗?眼睛的外围又是什么呢?\n其实,这个字是脸面、要面子的“面”,包围着“目”的框,是一种特殊的指事方式。在楷书中,“面”这个字里面也有个“目”,这是古文字的存留。明眸善睐的眼睛,给人留下的印象太深了,它不仅是心灵的窗口,也是颜面的象征。\n一个“木”变出三个字 # 再看这三个字,它们分别是什么呢?\n这三个字,有一个共同的基础,那就是中间有个(木)。是一个典型的象形字,惟妙惟肖地画出了一棵小树的样子。\n在木的下面,树根的地方,加一个小横作为标签,这就是“本”。“本”最开始是树根的意思,后来引申出“根本、基础”的意思。根扎牢了,树木才能茁壮成长,成为参天大树。做人之道也是如此,《论语》中说:“君子务本,本立而道生。”把握住人生的根本,才能不断地生发出君子之道。\n在木的上面,树梢的地方,加一个小横,这个字是什么呢?这个字和“本”相对,是“末”,表示树枝的末梢,因此引申出最后的意思。所谓“末日”,就是最后一天。\n在木的中间,树干的地方,加上一个小横,这个字又是什么呢?不好猜吧。这个字是“朱”。朱是红色的意思,它和树干有什么关系?我们看一看《说文解字》的解释,许慎先生说:“朱,赤心木也。”在大森林中,有一种特殊的树,它的中间是红色的。所以,在树干上加一个小标签,表示这是一种红心的木头,也就是“朱”。\n我为你介绍了一些最常见的指事字,在汉字中,还有哪些指事字呢?\n想想许慎先生说的“视而可识,察而见意”,快去找找看吧!\n会意字-拼积木造字法 # 在发明指事字的时候,古人明白了一个道理——光用象形造字是不够的,必须要有更多的造字方法。他们的脑子里很快又蹦出一个新的想法,如果把不同的象形字拼起来呢?下面我要介绍的这种造字法,就是一种像拼积木一样的造字法——会意。\n“文”与“字”可不一样 # 在讲解会意字之前,我们需要先分清楚“文”和“字”。\n什么?“文字”不是一个词吗?“文”和“字”不是一个意思吗?\n当然不是,听我慢慢讲来。\n象形字和指事字有一个共同的特点:拆不开。\n回想一下,象形字是一个独立的字,拆开了,就不成字了,指事字可以拆分成一个字和一个标签,但标签——指事符号也不是字,因此指事字里也只有一个字。这样的汉字,我们叫作“独体字”,它们由图画演变而来,是一个整体,切分不开,都是些独来独往,单独成字的“独行侠”。\n和独体字相对的,是“合体字”,它们喜欢热闹,喜欢抱团,是由两个或更多的独体字组成的新字,就像动画片里的合体变身一样酷炫。\n合体字有两种,一种是这一讲要介绍的会意字,还有一种,是下一章要讲的形声字。\n关于独体字与合体字,古人有专门的称谓——“文”与“字”。“文字”在今天是一个词,但在古人眼中,它们的意思是不一样的。\n什么是“文”呢?\n在古文字中,(文)是一个正面站立的人形,身上画着五彩斑斓的纹样,它的本义是文身。漂亮的甲骨文和指事字,都像是美丽的图画,仿佛精美的文身一样,因此,古人用“文”来指那些用图画的办法造出来的独体字。\n什么是“字”呢?\n你看金文中,(字)上面是一个“宀”,这是房屋的象形,下面是“子”,就像一个挥舞着两只手的小宝宝一样——在房屋中养育小孩子,所以,“字”有养育的意思,引申为不断生长。因此,“字”指那些在独体字的基础上,通过合体变身,不断滋生出来的合体字。\n“独体为文,合体为字”,这是汉字最基本的分类。\n由“文”到“字”,由独体到合体,汉字经历了一个不断生长的过程。在这个过程中,独体的“文”不断地合体、变化,造出各种各样的新字,像生命一样繁衍生息、昂扬生长,帮助古人更加准确地记录丰富多彩的世界。\n什么是会意字? # 讲完了合体字,现在我为你介绍合体字中的第一类——会意字。\n什么是会意字呢?\n我们知道,一个字是有它的字义的,这就是“意”。“会”呢,有集合的意思,开班会就是一群学生聚集在一块儿。因此,会意字是把不同的文字像拼积木一样拼合在一起,“会”聚不同汉字的字“意”,从而造出新字,表达新义。\n在《说文解字》中,许慎先生这样讲解会意字:“会意者,比类合谊,以见指㧑(huī)。”这句话看上去好难懂啊!简而言之,许慎的意思是,会意字是把两个或者多个形体的意义结合起来,来表达新的字义。\n许慎先生给会意字举了个例子——武。\n你喜欢看武侠电影吗?在荧屏上,侠客们飞来飞去、行侠仗义,是不是很潇洒?但你有没有想过,汉字“武”是怎么造出来的呢?它又具有怎样的文化内涵呢?\n你看,是甲骨文中的“武”。下面是一个停止的“止”,上半边是一个“戈”。在先秦时期,戈是最常用的一种武器,我们在博物馆里,还能见到各式各样的青铜戈。“止”加上“戈”,两个字拼在一起,创造出一个新的字义“止戈为武”——停止干戈、阻止战争,这就是“武”的意蕴所在。\n原来,我们一直理解错了“武”字,它不是武力,不是暴虐,不是厮杀和血腥,在它诞生之初,它其实寄托着一个美丽而远大的理想——缔造和平。\n古人对“武”的解释真是极具丰富哲理!而且,这个解释不是许慎的独创,它的创造者是那位“问鼎中原”的楚庄王。\n楚庄王是一位传奇的国君。根据《韩非子》记载,他继位的时候,年岁很小,朝廷中由权臣把握朝政。这位年轻的楚庄王怎么办呢?继位三年,他从来不发号施令,一天到晚游山玩水、饮酒作乐,潇洒极了。\n国君很颓废,一些忠心耿耿的大臣看不下去了。有位大臣想向他进谏,但楚庄王说过:“寡人烦着呢!谁也别给我提意见!”这可怎么办呢?\n这位大臣想来想去,突然灵机一动,有主意了!他找到楚庄王,一脸神秘地说:“启禀大王!咱们楚国出了一件怪事!”\n“什么怪事?”楚庄王正在喝酒,一下子来了精神。\n“楚国郊区来了一只奇怪的大鸟,三年了,它一声也不叫,也没有飞,不知道是怎么回事?”\n大鸟?三年?这明摆着是讽谏楚庄王无所作为。楚庄王又不傻,他放下酒杯,眯着眼睛看着大臣,缓缓地说:“爱卿,你是不懂这只大鸟的心啊!它不飞,是要让羽翼丰满;不鸣,是要观察人心。你看着吧,这只鸟三年不飞,一飞冲天,三年不鸣,一鸣惊人!”\n于是,楚庄王开始飞翔了。他励精图治,提拔贤才,惩处昏庸无能的大臣,让楚国迅速强大起来。\n楚国强大了,就要北上争霸中原。在春秋时期,北方强大的晋国和南方兴盛的楚国,在中原地带多次争霸。在楚庄王的带领下,楚国和晋国之间发生了一场大战!\n根据《左传》记载,当时晋国派出了一支敢死队,跑到楚国大营前耀武扬威,进行挑战。按照常规,敌人来挑战,派出一支小分队迎击就好了。但是这位英武勇敢的楚庄王竟然亲自出马,带领队伍追杀过去。\n楚庄王冲杀在队伍的最前方,王的军旗在风中猎猎飞扬。楚国大臣一看,“坏了!小心大王有危险!”怎么办?全军出阵!跟着大王往前冲杀!\n当时,楚国军队“车驰卒奔”——战车奔驰,士兵喊叫着拼命冲杀,一时间烟尘四起,杀气冲天。晋国大将哪儿见过这阵势,好家伙,楚庄王这是要拼命啊!慌乱之中,他犯下了一个致命的错误:“先济者有赏!”\n“济”是过河的意思,当时黄河在晋国军队的后方。“谁先跑过黄河,我就赏赐谁!”这个命令,等于鼓励临阵脱逃。于是,晋国军心大乱,纷纷溃逃,被楚国杀得落花流水。\n大胜之后,有人向楚庄王建议:“把敌人的尸体都堆积起来,展示我们的军威!”没想到,勇猛善战的楚庄王拒绝了这个建议。他说:“止戈为武!”真正的“武”,是要能够停止干戈,战争的终极目的是奠定和平。我们现在做不到,但至少不要炫耀杀了多少人吧。\n这个说法真精彩!\n楚庄王对“武”的解释,反映出中国古人向往和平、期待和平的愿望。\n一个“止”,一个“戈”,把两个字合起来造出新字,表达全新的意思,这就是会意字!\n人丁兴旺的会意字 # 在汉字中,会意字的数量比象形字、指事字加在一起还要多,真是人丁兴旺。\n先看几个和“手”有关的会意字吧。在讲指事字的时候,我们介绍过“又”字,这是古文字中手的象形。接下来的几个字,都和“又”有关。\n你看,是什么字?\n从甲骨文到小篆,这个字的形体一脉相承,上面一个“又”,下面一个“又”,两只手,表达怎样的含义呢?\n握手?拉手?掰手腕?\n其实,这个字是古文字中的“友”。\n你看,今天“友”上面的一横一撇,不就是(又)拉直之后的样子吗?\n《说文解字》说:“同志为友。”什么是真正的朋友呢?\n一方面,两个人要有共同的理想志向,有着共同的人生方向;另一方面,他们就像紧紧拉在一起的两只手一样,互相帮助、互相关怀、互相扶持。\n两只紧紧握在一起的手,把“友”的内涵淋漓尽致地展现出来了。\n再看这个字,下面是一只手,上面是一个人,手牢牢地把人抓住,这是什么字呢?\n这个字是古文字中的“及”。\n什么是“及”呢?\n《说文解字》说:“及,逮也,从又从人。”逮是逮捕的意思,也就是赶上,“及”的本义就是赶上、追上前面的人。在《宋史》中,有一家人在逃亡的过程中,“为金人所及”,就是被金兵追上,被抓住了。\n了解了“及”的字义,我们也就明白了“及格”的意思。在这个词里,“格”是标准的意思,“及格”就是刚刚赶上某种标准。考试的标准是60分,及格就是考试成绩达到了这个标准。\n还有《水浒传》中的好汉宋江,外号叫“及时雨”——赶在大旱时节,下了一场恰到好处的大雨,这就是“及时”的内涵。\n“友”和“及”是简单的会意字,我们再看一个形体复杂的会意字。这个字笔画好多!看上去蛮有趣的,它是什么字呢?\n这个字是小篆中的“爨”,读作cuàn,是烧火做饭的意思。在《说文解字》中,许慎先生详细地分析了它的形体:“臼象持甑(zèng),冂为灶口,廾推林内火。”上面是两只手,拿着一个蒸锅,放到灶口之上。做什么呢?当然是要烧饭了。光有锅不行,还要点火。古人没有燃气灶、微波炉,做饭时要烧柴火,这个字的下面是灶口,里面点燃了熊熊炉火,还有两只手,不断地把木柴添进去……\n“爨”是一个内涵丰富的会意字,把古人烧火做饭的画面,活灵活现地展现在我们面前。\n总之,会意字把不同的汉字拼在一起,造一个新字,这种造字方式适合表达复杂的字义和丰富的文化内涵。\n介绍完栩栩如生的象形字、数量稀少的指事字、姿态万千的会意字,这一章,我来介绍合体字中的第二类字——形声字。\n这是一种超厉害的造字法。\n好用的形声字 # 形声字厉害在哪里呢?\n好用!特别好用!\n在汉字中,形声字是最常用的造字方法。根据统计,汉字中有将近90%的形声字——十个字里面,有八九个是形声字,你说它重要不重要?\n什么是形声字?\n许慎先生说:“形声者,以事为名,取譬(pì)相成。”形声字可以一分为二,一半是形符,一半是声符,形符表达汉字的意义,声符提示汉字的读音。\n什么是形符呢?请看“溪、江、河、湖、海、洋”这几个字,它们表示大大小小不同的水系,都和水有关,因此都用“水”作为形符。“以事为名”,“事”是事物,也就是形符所表示的内容,“名”指汉字,“以事为名”指形声字以形符为基础来造字。\n知道了形符的含义,声符也就不难理解了。顾名思义,声符表示汉字的读音。我们看“取譬相成”这四个字:什么是“譬”呢?就是打比方。形声字的声符和这个字的读音很像,但不是完全相同,就像给读音打个比方一样。还以“溪、江、河、湖、海、洋”这几个字为例,“溪、湖、洋”的声符是什么?分别是“奚、胡、羊”,声符和形声字的读音一样;“江、河、海”的声符是什么?分别是“工、可、每”,声符的读音和形声字不太一样。\n为什么会有声符和形声字读音不同的现象呢?原因很复杂!有的是因为声符和形声字在古代读音一致,随着汉语的发展,今天的读音已经不同了,比如“工”和“江”;还有的是在造字之初,古人就选择了读音相近的字作为声符,比如“可”和“河”。无论如何,声符都能起到提示字音的作用。\n注意!声符提示字音,而不是完全等于字音。在我们识字的时候,有一个很容易犯的毛病,那就是根据声符的读音来念形声字。所谓“秀才识字念半边”,不假思索地把声符当作形声字来读,是要犯错误的。\n形声字从哪儿来? # 形声字是一种很好用的造字方法,它的来源是什么呢?\n一开始,古人发明形声字,是为了强化字义。什么是强化呢?我们看这个字,三个小方块,这是什么字呢?\n这个字不是品德的“品”,而是甲骨文中的“星”。古人仰观天象,看见天空中密布的小星星,于是画了三个小方块,代表众多星辰。可问题在于,有人非要说这是三块石头,怎么办呢?\n这难不住聪明的古人,他们想了一个好办法。在几颗小星星下边加上一个“生”,“生”和“星”的读音非常像,古人想通过一个相似的读音,来强化“星”字的所指——别胡思乱想了,这不是小石头,也不是小点心,而是星星的“星”!\n除了强化字义,形声字也能够分化一个汉字的不同意义。什么是分化呢?我再给大家举个例子:\n甲骨文是“止”,它是小脚丫的象形。下面是脚掌,上面是脚趾。你低头看看,像不像?\n也许你会说:“咦!我明明有五个脚指头,怎么到了甲骨文中,就剩下三个了?”要知道,“三”在古代表示众多的意思,我们前面讲过的“又”“星”都是如此——三根手指,表示众多手指;三颗星星,代表星罗棋布。三根脚指头,足够代表你所有的脚指头了。\n人的双脚站立在大地上,它们是人体的根基之处,因此,“止”引申出基址、地址的意思。可“止”又表示“脚趾”,又表示“地址”,还能表示“停止”的意思……这么多意思,会不会发生混淆呢?聪明的古人想了个好办法,给“止”加上不同的形符,表示不同的意义。\n如果要表示脚指头,那就和“足”有关,于是古人添加“足”字作为形符,创造出脚趾的“趾”;如果要表示地址,建筑物和土地有关,于是古人添加“土”字作为形符,创造出地址的“址”;至于“停止”“静止”的意思,就写作“止”。\n通过添加不同的形符,古人把“止”的字义清晰地区分开来,这就是形声字的分化功能。通过分化,汉字表达的字义更加清晰、准确。\n在强化和分化之外,还有很多形声字来自类化。什么是类化呢?有些相关的字,没有形符,古人为了汉字的规整,便给这一类字统一添加了形符。\n类化产生的形声字往往是批量的。比如说,和金属有关的字,一般都加上了金字旁,于是有了“铜、钢、锡、铠”等形声字。和人有关的字,一般都加上了“单人旁”,于是有了“傍、他、佛、伯”等形声字。和小草有关的字,一般都加上了“草字头”,于是有了“芽、花、芙、莉”等形声字。和树木有关的字,一般都加上了“木字旁”,于是有了“枝、橘、棉、棚”等形声字。\n这种一批一批出现的形声字,就是类化。\n无论是强化、分化,还是类化,古人在不断地使用自己的聪明智慧,让汉字表意更清晰、分类更明确、汉字系统更完整。在这种追求中,形声字成了最常用、最好用的造字方法!\n形声字≠会意字 # 在学习六书的时候,要特别注意,不要把形声字和会意字弄混了。特别是有些形声字的声符是古代的读音,今天读起来已经不像了,很容易被误解为会意字。据说,赫赫有名的王安石也犯过这样的错误。\n宋代的大政治家、文学家王安石,也是一位说文解字的爱好者。王安石研究汉字,写了一本书叫《字说》。有一次,大文豪苏东坡向他请教:“听说您研究汉字很有成果,给我讲讲‘波’这个字是什么意思吧?”\n王安石一听,可高兴了。想不到才高八斗的东坡先生,也有向我请教的时候,我来告诉你吧:“波者,水之皮也!”\n“波”应该怎么讲?它实际上是个形声字,从水皮声,“皮”的古音和“波”是一样的。但王安石不懂,他把“波”讲成会意字了,所谓波浪,就是水面上一层光滑的皮肤。听上去也蛮有道理的,对不对?\n但聪明的苏东坡不以为然,他脑筋一转,反问王安石:“照您这么说,那‘滑’字,难道还是水的骨头吗?”王安石一听,默然无语,无法回答。\n王安石的错误,是把形声字讲成了会意字。汉字是有系统的,我们可以像苏东坡一样举一反三——如果“波”是“水之皮”,那“滑”能是“水之骨”吗?“江”能是“水之工”吗?“河”能是“水之可”吗?完全说不通!\n在学习汉字时,我们一定要避免这样的问题,要有一种汉字系统的观念,触类旁通、举一反三地去学习汉字。\n在前面的内容中,我给大家介绍了汉字的历史和造字的方法。这些知识,是解读汉字的基础。\n可了解了这些知识,你可能还不太会运用。在这一章,我就给你介绍分析汉字,熟练运用六书解读汉字的实操方法——因形求义。顾名思义,因形求义就是利用汉字的字形来解读汉字的字义。自古以来,这都是一种重要的解释汉字的方法,有些因形求义的过程,就像福尔摩斯探案一样有趣呢。\n方法1:找到源头,看图识字 # 使用因形求义法,第一步要注意找寻汉字的源头。汉字是不断发展演变的,在时间的长河中,字形会发生变化,古人造字的用意也会不断丢失。因此,因形求义必须要找到比较古老的字形,追溯古人造字的真意。\n比如,我们都知道的一位古代圣贤——周公。他的名字叫姬旦,姬旦,听上去有点儿像“鸡蛋”!他怎么起了这么一个名字?“旦”又有什么含义呢?\n周公,是中国历史上的大政治家,他是周文王的儿子,周武王的弟弟。周武王打败商朝之后,很快就去世了,他的儿子周成王年纪还小,商朝的残余势力趁机作乱,周朝形势一下子岌岌可危。这个时候,周公挺身而出,摄持朝政,讨伐叛徒,建立起周礼的文化体系,奠定了周朝数百年的天下。在孔子看来,周公是古代制作礼乐的大圣人,他在学习礼乐的时候,还经常会梦到周公呢。\n周公这么了不起,他的名字应该不会搞笑。就让我们化身福尔摩斯,运用因形求义法,去找到周公名字的真相吧!\n简体的“旦”字看起来很抽象,让我们先追本溯源,看看“旦”在古文字中的写法。在甲骨文和金文中,“旦”上边的部分是“日”,下面像是土疙瘩,是大地的象形。在小篆中,“旦”的下面是一横,表示大地。之前我们讲“上”“下”时,它们都是用一横来表示大地的。由此可见,“旦”这个字,就是一轮朝阳从大地之上冉冉升起的样子,它有“朝阳”的意思。周文王给儿子起名字叫姬旦,寄托着一个非常好的寓意——这个孩子,将来是我周王朝的一轮朝阳,是希望的所在!\n通过“旦”的古文字字形,我们猜出并理解了“旦”的内涵。这个过程是不是很像“看图猜字”游戏呢?汉字是表意文字,在造字的时候,古人根据字义来设计字形,字形和字义高度统一。因此,我们可以通过分析汉字的字形,来理解汉语的词义,这就是“因形求义”的方法。\n再举个例子,比如老虎的“虎”字,在楷书、隶书中,都看不出这个字的造字逻辑,小篆也不行,那就要一直上溯,找到金文和甲骨文。\n在甲骨文中,“虎”是一个典型的象形字,惟妙惟肖地画出了老虎的獠牙、利爪,还有身上一道一道的斑纹。在甲骨文中因形求义,我们才能看到古人心目中,作为百兽之王的老虎,既凶猛,又威风!\n再举一个难点儿的例子——“执”。这个字有“抓住、拿着”的意思,手执红旗,迎风招展。在汉字中,“执”应该如何解释呢?它的造字逻辑是什么呢?\n如果根据简化字,左边一个“手”,右边一个“丸”,手里面拿着一个热腾腾的大丸子,这就是“执”吗?不像话!接着上溯,我们看繁体字“執”——“执”成了一个“幸”福的小“丸”子,也不像话呀!\n直到我们看到了甲骨文,才能弄清“执”的含义。在甲骨文中,“执”的左边是一只手铐的样子,右边是一个跪着的人,两只手伸出去,被刑具锁得死死的。《说文解字》说:“执,捕罪人也。”“执”最初的意思是抓住坏蛋,把他们牢牢地锁起来。\n中国古代有一部著名的史书——《左传》,里面记载说“执邾(zhū)悼公,以其伐我故”,这里的“我”指的是鲁国,也就是孔子的故乡。邾悼公是鲁国附近小国的君主,他去攻打鲁国,结果反倒被人家抓起来了,这就是“执邾悼公”。由此,我们梳理清楚了“执”的发展脉络,它的本义是牢牢地抓住,后来引申出“掌握、掌控”的意思,今天我们说“执行”“执掌”“执政”,都是从抓坏蛋的意思引申而来。\n你看,找到古老的形体,就开启了分析汉字秘密的大门。\n方法2:使用“六书”,举一反三 # 使用因形求义法,除了找源头,还要注意的是,千万别把六书的知识忘掉了。一个字是象形字、指事字、会意字,还是形声字,对于准确理解它的意义至关重要。比如“执”字,一个罪人,手穿到刑具中去,这是典型的会意字。如果从形声字的角度去理解,就完全说不通了。\n再比如说,“鹅”这个字怎么解释呢?\n在分析六书的时候,我们说过,一定不要把会意字和形声字弄混了。犯这种错误的人还真不少,有人说,鹅和人的关系很亲密,左边是一个“我”,右边是一个“鸟”,鹅就是“我的鸟”,指和我们关系最亲密的一种鸟。这么讲似乎有点儿道理,但问题在于,其他家禽呢?如果我们以此类推,举一反三,就会发现按照这个逻辑,会闹出一连串的笑话:“鸡”是“又一只鸟”吗?“鸭”是“甲的鸟”吗?“鸿”是“江上的鸟”吗?这样讲解汉字,你说好笑不好笑?\n会意字解释不通,我们可以试试形声字的思路。如果“鹅”是个形声字,那么“鸟”就是它的形符,而“我”可能是个读音不那么准确的声符。再举一反三一下,“鸭”的声符是“甲”,读音很像。至于“鸡”,它的繁体字是“鷄”,“奚”读作“xī”,和“鸡”的读音很像,作为声符也解释得通。这么看来,它们应该都是形声字。\n方法3:核对文献,找准“物证” # 找到了汉字的源头,准确地分析六书,在因形求义法中,最后一个重要的步骤,是要核对古代的典籍文献,确定汉字的本义。解谜汉字,就像侦探一样,要找到语言使用的蛛丝马迹,相互印证。这些古籍,就像解开汉字谜团的“物证”一样。\n举一个例子,你看“行”字。\n现在的字形中,我们已经看不出“行”字为什么这么造字了,它怎么看都和双人旁、和“亍”没什么关系。让我们先上溯到汉字的源头,看看在甲骨文中,“行”是什么样子,又是什么意思吧。猜猜看!\n甲骨文的“行”写作,从六书来分析,这是一个很典型的象形字,古人画出一个十字路口,指的是大路大道,又引申出行列的意思。\n现在,让我们去古文里找到物证,印证一下。\n大诗人杜甫有句很有名的诗:“两个黄鹂鸣翠柳,一行白鹭上青天”,用的就是“行列”的意思。在《诗经·小雅·鹿鸣》篇中,有一句“人之好我,示我周行”,这里的“行”是“道”的意思。诗人说的是,怎么叫作对我好呢?不是给我零花钱,也不是给我糖吃,而是要给我一个“周行”——周全的、完整无缺的大道。\n经过验证,“行”确实表示道路。它现在的样子,是汉字发展演化中,字形发生变化的结果。\n现在让我们反观“银行”一词,什么是银行呢?“银”是古人用的货币,“行”是大路,银行其实就是金银财富往来之所,是货币流通的地方,是财富来来往往的大道。这么解释,是不是非常清晰呢?\n最后总结一下,因形求义三部曲——找古字、辨六书、查古书,一个都不能少。\n通过前面的讲解,我们基本掌握了汉字的知识和应用。而掌握汉字的目的,是为了更好地理解祖先留给我们的精神财富——古代典籍。在阅读古代经典的时候,如果从古老的汉字出发,来解读那些历史悠久的文本,往往能有很多意想不到的收获。\n汉字是一把奇特的钥匙,能够为我们开启古书中的奥秘。让我们以最熟悉的一句经典为例,那就是《论语》中的第一句话:\n“学而时习之,不亦说(yuè)乎?”\n“说”是“悦”的假借字,这句话是说,学到知识后时常温习,不是一件令人心生喜悦的事吗?这句话我们太熟悉了!从上小学的时候,老师就会教这句话。问题是,虽然熟悉,但未必能解读透彻。理解这句话,需要思考一个非常真实的问题:学习是快乐的吗?\n回味一下我们的人生经历,从幼儿园到小学,从小学到中学,将来还要参加高考、进入大学,你是否能拍着胸脯说,我的学习是快乐的!还真不一定。那么,孔子为什么要说他的学习很快乐,他又“乐”在何处呢?想要理解这些问题,关键要读懂四个汉字——学、习、时、说。\n孔子的课程表 # 在讲解“学、时、习、说”之前,我们先要了解一下孔子的课程表。孔子当时要学习“六艺”,这是先秦时期重要的教学内容。什么是“六艺”呢?根据《周礼》(一部讲解古代礼乐、政治的经典)中的说法,“六艺”是礼、乐、射、御、书、数。它们体现为三个层次的教育内容:\n第一个层次是礼乐教育,主要用于涵养一个人的道德品质。“礼”是周代以来的文化传统,它是一整套待人接物、处世为人的法则。对礼的学习,就是在一个人的行为规范中,让他掌握做人的道理。古代礼乐相配,行礼的场合往往需要奏乐。如果说,“礼”主要是培养一个人的行为规范的话,“乐”则是对一个人的心灵、性情的陶冶。在古人看来,一个沉浸在雅乐中的人,自然会呈现出一种温和文雅的品质。\n第二个层次是“射”和“御”的教育。“射”是射箭,“御”是驾车,这是古人投身沙场、保家卫国的必备本领。先秦时期,战争以车战为主,大诗人屈原在《国殇》中写道:“霾(mái)两轮兮絷(zhí)四马。”[1]一车四马,战士在飞奔的马车上弯弓搭箭,射杀敌人。“射”和“御”之间需要整体配合,这是古代君子的能力素养,也是他们保家卫国的社会责任。\n第三个层次是“书”和“数”的教育。“书”是识字,“数”是计算,这是一个人要掌握的文化知识。\n“礼乐”教育是涵养品德,“射御”教育是掌握本领,“书数”教育是获取知识。我们看到,古代教育有三个层次——学品德、学本领、学知识,它们指向一套完整的人才培养体系。这种教育是“动手动脚”的,特别注重实践。要知道,我们的古人可不是端坐在课堂上,老老实实地听讲,“礼”和“乐”需要演练,“射”和“御”需要实操,这是一种全方位的、使人活动起来的学习。和今天的学习内容相比,“六艺”教育丰富多彩,一点儿也不枯燥。\n学:开启生命的觉悟 # 仅仅是学习内容的丰富,就会让人在学习中“不亦说乎”吗?也不尽然。了解了先秦的课程表之后,我们要进一步理解“学”的精神实质。什么是“学”?先秦古人眼中的“学”有着怎样的精神内涵?让我们从古老的汉字说起。\n早在甲骨文中,就已经有“学”了。到了金文和小篆,基本已具备了“学”的字形结构。\n你看,“学”的上面有两只手,手里拿的是“爻”。有人说,“爻”与算卦有关,指的是《周易》中卦爻的符号;也有人说,“爻”就是算筹,是一种算算数的教学工具。无论如何,它都是古人学习的内容。\n在的下面是一个“子”,谁来学习?当然是小朋友了!在“子”的上面,还有一个类似房屋建筑的形状,一般认为它代表了古代的教室。\n不过,《说文解字》对这个形体有另外一种有趣的解释。许慎先生认为,“子”上面是“冖”(mì)。\n什么是“冖”呢?\n在介绍金文的时候,我们说过,古代用鼎烹饪。鼎的个头不小,煮的饭经常一顿吃不完。如果食物没有吃完,古人就会用一个布做的盖子罩在鼎上,这个罩子就叫“冖”。\n我们可以想象一下,如果一个人被罩住了会怎样?《说文解字》中解释说:“冖,尚矇也。”一个人的心灵被遮蔽起来,就像被罩子罩住了一样,长期处在蒙昧之中,缺乏生命的自觉。而“学”的实质,就是要通过不断地教与学,来打破心灵的蒙昧状态,开启混沌的罩子,给心灵迎来真知的光明。\n因此,“学”的本质是一种精神觉悟,这种觉悟不仅是指掌握了多少知识,更是要找到自己的人生方向,找到生命的意义与价值。\n习:勤劳不懈的实践 # 和“学”相配的“习”字,它的含义又是什么呢?我们今天常说“学习”,在古人看来,“学”和“习”还不太一样。它们的区别是什么呢?还是要从汉字说起。\n你看,是甲骨文中的“习”。这个字上面是“羽”,下面是“日”。到了小篆,下面变成了“白”,这个字形是繁体字“習”的来源。《说文解字》根据小篆字形解释道:“习,数(shuò)飞也。”所谓“数飞”,就是多次飞行。无论是“白”还是“日”,“習”的字义都清晰明白,是小鸟在白日里学习飞翔。小鸟学飞,总是“扑棱扑棱”呼扇着翅膀,跟着大鸟从一个枝头飞到另一个枝头上,如此反复多次,才能单飞。既然是反复学飞,“习”一定有实践的含义!\n通过汉字的讲解,我们看到,“学”是精神觉悟,“习”是行为实践,它们是一个人成长学习过程中,不可或缺的两个方面。在“学而时习之”中,体现出心灵觉悟和行为实践的统一,这也是儒家传统中的“知行合一”。\n时:把握自然的节奏 # 觉悟和实践的统一,建立在“时”的节奏之中。对于“时”,可以有两种理解,第一种是“按时”,“学而时习之”指要按时学习。该上课了,该读书了,你不能还在睡懒觉。第二种是“适时”,要在合适的时间做合适的事。两种理解并不矛盾,按时学习很好懂,那什么是适时学习呢?\n在魏晋南北朝时期,有一位学问家叫皇侃(kǎn)[2],他在《论语义疏》中,对于“时”与“学”的关系,有着非常精彩的说解:\n夫学随时气则受业易入,故《王制》云“春夏学《诗》《乐》,秋冬学《书》《礼》”是也。\n所谓“时气”,指的是一年四季各有特点。古人认为,学习时要根据四季的特点,安排不同的学习内容。《王制》是周代天子的学习制度,里面说,“春夏学《诗》《乐》,秋冬学《书》《礼》”。\n什么意思呢?\n春天万物苏生,意气洋洋,每个人都活泼欢快。这个时候要学《诗》。要知道,“诗者,志之所之也”,《诗》是性情的抒发,因此要在春天学习,让我们的心灵与大自然一起生发、一起昂扬。\n到了夏天,万物繁盛,因此要学习气象宏大的雅乐,共同振奋。\n再到秋天,万物肃杀,这是一个收敛沉潜的季节,应该学《尚书》。在《尚书》中,有古代的历史,也有尧舜禹等上古先王的教导,在一个严肃的季节里,正好可以走进先王的历史传统。\n最后是冬天,大地冰封,安静肃穆。因此可以学习礼仪,培养做人的规矩。\n在一年四季中,大自然的节律与学习内容形成了完整的统一体。“学而时习之”,就是要根据大自然的节奏展开学习,把内心的精神觉悟落实到行为实践之中,落实到每个人具体的生命过程中。通过这样的学习过程,人不仅收获了知识,更能收获一种生命的充实与拓展。也正是这种成长的收获,给人带来发自内心的喜悦。\n说:心开意解的快乐 # 说到“说”,我们知道,“说”是“悦”的假借字,这究竟是怎样的一种快乐呢?\n也许你会说,“悦是喜悦,是属于内心的快乐”。没错,《孟子》中记载,孔子的弟子对孔子是“中心悦而诚服也”,也就是“心悦诚服”。\n但我接着问你,这份内心的快乐,又是怎样的快乐呢?\n在语言发展的浩瀚长河里,“悦”和“脱”“蜕”来自同一个源头。在古代,它们的读音是相同的,声符都是“兑”,在意义上也相近。“脱”有“解”的意思,“解脱”嘛。而“蜕”则是动物蜕皮,是把外面的一层硬皮“脱”下来。\n这几个字的共同特点,是在意义上都有一种“开解”的感觉。由此可知,“悦”不只是简单的快乐,而是一种“心开意解”的欢喜。遇到一道难题,百思而不得其解,突然灵光一现,思路来了,这就是“悦”;有件事死活想不通,心里打了个结,有人一句话点醒了你,这也是“悦”。\n我们读过陶渊明的《桃花源记》,里面有一段很著名的描写:“便舍船,从口入。初极狭,才通人,复行数十步,豁然开朗。”可以说,“悦”就是生命中的豁然开朗,这是一种彻底的开解与觉悟。\n解读了“悦”字,我们看到,在“学而时习之”和“不亦说乎”之间,存在着极为紧密的搭配关系——在“学”和“习”的互动中,在知行合一的收获中,我们才能收获由衷的“悦”,收获这种心开意解的精神至乐。可以说,“学而时习之”代表着孔子根本的人生方向,那就是一个平凡的生命,如何通过不断地觉悟与实践,不断达到无限的高度与可能。\n汉字的根本特点是“形义统一”,在汉字的形体中,蕴含着丰富的汉语词义的信息,也承载着历史悠久的中华文化。通过对“学、习、时、说”四个汉字的讲解,你是不是懂得了更多中华文化,对“学而时习之,不亦说乎”也有了更深刻的理解呢?\n经过这十三章的分享,我们对汉字有了新的认识——汉字,是通向中国文化的桥梁。走过这座桥,你会走进一个多姿多彩的世界。我们探寻汉字的奥秘,也是在探索中华文化的美丽画卷。希望这套小书可以给你一些有益的启示,让你看到一个不一样的汉字世界,从而掌握推开中华文化大门的能力。\n[1] 出自屈原《九歌·国殇》,整句为“霾两轮兮絷四马,援玉枹兮击鸣鼓”。\n[2] 皇侃(488—545),南朝梁经学家,吴郡(今江苏苏州)人,通《三礼》《论语》《孝经》。著有《论语义疏》《礼记讲疏》《礼记义疏》《孝经义疏》等,仅《论语义疏》存世。\n后记\n这套小书,由我在“博雅小学堂”为小朋友们讲授《说文解字》的讲稿整理而成。\n带领孩子们走进汉字世界,探寻汉字的前世今生,并非易事。如何把握汉字知识的“度”,哪些适合讲,哪些不适合讲?如何做到知识性与趣味性的结合?如何面对汉字的历史来源与释义发展之间的张力?如何通过汉字更为深入地理解我们的语言?如何从汉字出发走进中国文化的广阔天地?对这些问题,我们进行了积极的思考与尝试,是否得当,还请亲爱的读者们多多批评!\n成书之际,我要感谢赵凌女士、任小平女士,她们策划了这门课程;感谢楚怡兄在课程讲授过程中的高水平工作;感谢《青年文摘》的周玲女士,刊发了这套书的部分内容;感谢责任编辑李炜兄和张苗苗女士,对醉心学术而不断拖稿的笔者,给予了充分宽容;“小博集”做书的水平,亦远超我的预期。感谢尹梦、方伟杰、张燕语、王鹤凝、高铭婉、陈子昊、张祎昀等同学的协助整理,特别是张燕语君,帮我搜集了清晰的古文字字形。恍然之间,有的同学已离开了我们,自今视昔,伤逝何如!\n当然,最应该感谢的,是那些听我课程的、读我作品的小朋友!希望这套小书,能让你们喜欢上古老而神奇的汉字——它,是中国文化的根壤所在,也是每一个中国人心灵深处的文化基因。\n庚子仲夏于北京师范大学晓韵楼\n"},{"id":34,"href":"/zh/docs/culture/%E5%A4%A9%E7%BA%AA/%E4%BA%BA%E9%97%B4%E9%81%93/0%E5%BA%8F/","title":"序","section":"人间道","content":"人間道\n校 堪 序 # 本《人间道》乃由民间的中医爱好者整理与校对。中华民族文化博大精深、渊远流长,中医更是中华民族的瑰宝,几千年来一直守护着炎黄子孙的健康。继承和发扬中医,本是我们爱好中医人士与生俱来的使命。可是由于种种原因,使得中医的许多典籍在流传上产生问题;或已出版而校对欠佳,或印量稀少而极难购得,甚或已经绝版而失传。使得这份本应属于整个中华民族所共有共享的珍贵智慧,无法让更多需要的人学习它,大非往圣之本意!\n我们在校正的过程中,仅对明显的错别字给予了修改,对于无法确定者则保留原样。我们力图提供正确无误的电子书,但限于能力,自知错误在所难免。\n本书的原始材料来自于网络,为网络中善心人士传播之电子版本。本书的校正, 由民间中医爱好朋友们自发组织,属于无偿的自发行为,在此首先对他们表示感谢! 本电子版不收取任何费用,亦不得将本书内容用于商业行为。\n根据相关法律,本电子书仅供网络测试,不收取任何费用。请您下载后 24 小时内删除,如果您喜欢本书,请购买原版。任何人不得将本书用于商业行为,否则由此直接或间接引发的任何法律问题,我们不承担责任。\n民间中医爱好者 敬校\n2010-10-22\n自 序\n本書之著作,是為了發揚我國千年來,易經的神髓而作的,目的是希望後代子孫能以中國文化為榮,以身為炎黃子孫為傲,切不可數典忘袓,唯有真正的發揚固有文化,才可以讓中國人領導二十一世紀,獨領風騷。\n本書之書名,是由我父親倪志凌先生親手寫的,我父親飽讀詩書,現旅居美國, 一手好字,為同仁所公認,他一直以中國人為榮,時時教诲他的孫子,不可忘本, 從小我就受他影響很大,萬般皆下品,惟有讀書高的觀念,三日不讀書,面目可憎, 真是如此。現在社會形態一直轉變,我從事算命有十幾年,看盡人間百態有感而發, 希望藉由此著作,能寓教於人,不可再茫然度日,只知吃喝玩樂,不知長進。做長輩的要求子女多讀書,而不以身作則,人言:言教不如身教。我愛讀書,喜歡研究, 實身受我父親影響至大,故特別請我父親為我的書名立文,以茲感念。\n梵宇龍八十三年九月十日\n自 序\n易,變易也,隨時變易以從道也。\n不易也,事物之外象皆變,而其理不變也。簡易也,萬法皆不同,然其神只有一也。\n故易廣大皆備,順性命之理,通陰符,盡事物之情,而示吾人開物成務之道。得此易道之神,則天下萬事皆能化繁為簡矣。\n前賢失其義而只能傳其言,後學者誦言而忘其味。自秦以後,無傳矣。前有天官,姜太公、范蠡、鬼谷子、張良、諸葛亮、李淳風、程頤、邵康節、劉伯温之用易,其用易之神,後學者瞠乎其後,而實無來者再傳其神矣。\n易中聖人之道有四:以言者,尚其辭。以動者,尚其變。制器者,尚其象。卜筮者,尚其占。其間藴含吉凶消長之理,進退存亡之道。\n君子靜則觀象以悟其辭,動則觀變以玩其占。\n今世之人,大都得其辭,而未達其意,此著作以悟象之角度申其義,此其目的也。\n祈與同道共勉之!\n作者倪海廈甲戍年正月廿四日\n郭 序\n易經一書,相傳始於伏羲,成於文王。爲古人仰觀天象,俯察地理,累積日常生活資料所得之經驗。稱之為易,蓋取其〔簡易、變易、不易〕。因其簡易,當為常人所了解;因其變易,當可用於事事,復因不易,當能垂教萬世。唯以書成年代久遠,用語艱澀隱晦,不易暸解,師徒口語相傳,又失其微言大義,加以今人多不識此書,或竟視爲算命工具,或直斥爲迷信,殊為可惜。\n畏友 海廈兄,自幼隨明師習醫,於吾國固有之〔山、醫、命、相、卜〕諸術, 無不精通,尤擅於易經,除熟讀探究易經精義,更已將易經融入日常生活中,經由具體之實驗,驗證先哲思想之正確性。而余生也駑鈍,自倖入法界服務以來,對案件之偵査,雖期能究明實情,勿枉勿縱,但驗諸實際,每感實質正義追求之不易與人道之難爲,挫折感屢生,適因緣際會得識 海廈兄,每以易經象術相教,既解迷惑於一時,復啟對問題思考之另一模式,助益可謂大矣。\n今兄將研習易經多年之心得與課堂講授之經驗,集數月之力,爲十餘萬言,著成此書,以簡淺文字,發前人之所未言,闡明易經之微言大義;又博採史例,廣爲佐引,論證古今,詳實可信。於書成之際,邀余爲序,爰不揣淺陋做之序,以示慶賀之意,更盼巨著隨出,以享讀者。\n中 華 民 國 八十三年十一月九日\n郭學廉 序於台灣板橋地方法院檢察署\n徐 序\n人生於天地之間,秉天地之氣而有形,受天地之養以爲生。未有能離於天地之\n間而生者。是倪師海廈畢數十年之心力,上窮天道,下探地脈,中明人事,終底於成,而作「天紀」。\n余不敏,早歲亦嘗涉獵易理,惟不得法鬥。自從倪師游,方知易者「易」也, 何「難」之有?亦知吾國先人之智慧至倪師而能昭顯。「天紀」一書,以易經爲軸, 以天文、地理及人間道爲輔;發前人之所未發,言前人之所未言。復道盡天、人、地三才之關係,良以三才能分,能合;名異而實爲一體也。又豈天人合一之境所能比擬?\n吾國易經博大精深,漢、唐以前,重象、數而輕辭,自宋以降,則重辭而輕象、 數。倪師則並重之。使「天紀」一書不僅成爲集古代易經之大成,更有所發明。例如,,倪師之陽宅學,以九宮八卦圖爲内卦,居於其位之人爲外卦;卦既成,則觀該卦之象以斷其人之吉凶禍福。此實深合易之道也。\n君子静以觀象,退而演易,動則問卜,以果決行。「天紀」一書實爲倪師智慧之結晶,若以卜筮之流者視之,吾不與焉!\n徐光佑甲戍年孟冬 序於 台北景美\n前 言\n本書的內容,共分二部份。〔一〕從圖來談易經,即古人所謂「無字天書」,占卜、問卦,也可以應用於易經推命來批流年行運,也可以運用於陽宅來推易,這是學如何演易。〔二 〕從易辭部份來研究「人間道」。從第一卦「乾為天」開始,天地定位後,人的一生即進入此一輪迴,舉凡天地間所有的任何事物,全部包羅在內, 不但是醒世哲言,更可將世間所有的學問理論簡化之。今人讀易越讀越複雜,或是講易經講半天,而無人懂,這都是未得易之道,所謂易者,易也,不然何名「易經」?\n讀易的心理準備\n易經本身並不難,故讀者先要有一概念,在讀易之前,平心静氣,捨去一切雜念,千萬不可在讀易之前,先對易經有所認定,那尚未開始,您已經錯了 , 一步錯,步歩錯,一旦離開了易經的神,運氣好的要「十年乃字」〔十年乃數之終,有倦怠的象〕,運氣不好,又自以為是的話,可能終其一世,仍未窺得易之全貌。\n孟子學而篇:學而不思則罔,思而不學則殆。\n此二句銘言,示吾人正確的讀書方法為:學而後思則悟。\n現今教育的失敗即在此,莘莘學子們苦背書籍,為考試而讀書,加之考試內容問題的設計只著重死背,造成人們都學而不思則罔。罔者,迷惘也。故現今社會, 人心失落,不知孰是孰非,謡言滿天,大家所從何事,所為何事,只一昧的湊熱鬧, 一昧的為自己利益著想,別人都是不好,只有自己最好,大家一昧的修飾外在,而無人著重內實,繁不勝舉的例子,都是教育失敗造成。更有人成天胡思亂想,從不學習任何學問,亳無正確求知的方法,造成自以為是,殊不知危險就將來臨,結果自己害自己,絶大多數自以為是的人,往往事情發生前都認定是不可能的,事情發生以後都在後悔,更有人連後悔都不,還死不認錯,從不悔改,這是無知至極的, 相信夜闌人靜時一個人會害怕的無法入眠。\n吾人的邏輯〔求學的方法〕如下:假設→驗證→結果,凡天下任何事都無法離開這個科學精神,是非應辨,真理即現,舉例説:有人說:「我不相信命,命都是自己努力來的。」聽起來好像是對的,但諸君深入一想,真偽立判,首先此人犯了第一錯誤,他從未研究過命,也就是完全不懂命學,卻從一不知的角度,來認定古聖先賢智慧的結晶,就是「不知而説」,這是一無知的人。第二錯誤,吾人假設他的話是對的話,既然命完全靠努力可得,那農業博士必是第一志願,因為如拿到農業博士 , 一定可以做到總統的。試問這可能嗎?大家都知道,可是卻從不深思,這就犯了大錯。所以諸君在看易經之前,應如一張白紙,心中無任何擔心會看不懂,或看了無益,或是迷信等等任何想法,都不存於心中。切記「學而後思則悟」的真言,悟得後方屬於您自己的,這才是求知的方法。吾同道共勉之!\n易經中的「人間道」\n易經中共分六十四卦,每一卦體代表事,代表一狀況每一卦有六爻,每個爻意味事之時機也。每一卦的爻是由下而上,從第一爻到第六爻,共六階段,一般只知其為事之演進,吾人在何階段,做何動作,都可以預知其未來之果。如更進一步的推論,則是您在處於何階段,應如何做,如果做錯結果會如何,易經上早已明示, 更進一步提示讀者,這是一部終極的書,它可涵蓋一切,千萬不可被自已對易的觀念鎖定,要爭脱易的束縛,吸收為己用,此時它將領著您,使您有顆平常心,無欲則剛,智慧打開如海一樣能容納百川不增減,這才是讀易的至高階段。\n吾人先定位,六爻可區分為六個層面,如左:\n水雷屯: 定位\n※註:易經原文中,凡九代表陽,六代表陰,例如:九三,即代表第三爻為陽爻,六五即代表第五爻為陰爻。餘類推。\n吾人须先明屯之義,屯為物之初始,如胎在母體中,故萬物之始生謂 屯,此卦示吾人事之始生,如何戒之於初,即「慎始」之精神。\n例如,你是一位新任銀行經理,剛到任時,即物之始生因為經理如一方之將, 故你須看屯卦,第二爻位,為將位應有之態度,於始之時,〔爻為時也。〕。如你剛考上金融特考而分發到一陌生之單位,無人記得,那你就是居於第一爻的位置,易經告訴你如何處理才是正確的,又如你是大企業的第二代須接君位,你就必須看第五爻君位處屯時之方法,成敗就看你如何做了 。唐朝名將李靖,有一銘言:「古今勝敗,一誤而已,」示人一念之間,就已決定勝敗,故開始時是最重要的。此例諸君要依此類推,任何事情都不出易之法則。吾定書名為「天紀」就是説明易經,本來就是一部闡述天地間永不變的紀律,循此法則則不論上下,不論何行,皆不脱離此範圍。\n此後始論易經之人間道,將由六十四卦之順序演變,一 一為各位説明,許多天\n機,已被古之天官將易卦之順序調整,為防止天機外洩,故陰符經云:「天發殺機, 龍蛇起陸,人發殺機天地反覆。」此處恕無法多言,自古能通陰符經之人不多,連姜太公,亦只有一半通悟,故此留於諸賢來研究,俗云「師父領進门,修行在個人。」 現在讓我們來看一看「人間道」。\n目录\n| 乾爲天 | \u0026hellip;\u0026hellip;\u0026hellip;\u0026hellip;\u0026hellip;\u0026hellip;\u0026hellip;\u0026hellip;\u0026hellip;\u0026hellip;\u0026hellip;\u0026hellip;\u0026hellip;. 1 | 地火明夷 | \u0026hellip;\u0026hellip;\u0026hellip;\u0026hellip;\u0026hellip;\u0026hellip;\u0026hellip;\u0026hellip;\u0026hellip;\u0026hellip;\u0026hellip;\u0026hellip;\u0026hellip; 79 |\n|\u0026mdash;\u0026mdash;\u0026mdash;\u0026mdash;\u0026mdash;\u0026mdash;\u0026mdash;\u0026mdash;-|\u0026mdash;\u0026mdash;\u0026mdash;\u0026mdash;\u0026mdash;\u0026mdash;\u0026mdash;\u0026mdash;\u0026mdash;\u0026mdash;\u0026mdash;\u0026mdash;\u0026mdash;\u0026mdash;\u0026mdash;\u0026mdash;\u0026mdash;\u0026mdash;\u0026mdash;\u0026ndash;|\u0026mdash;\u0026mdash;\u0026mdash;\u0026mdash;\u0026mdash;\u0026mdash;\u0026mdash;\u0026mdash;-|\u0026mdash;\u0026mdash;\u0026mdash;\u0026mdash;\u0026mdash;\u0026mdash;\u0026mdash;\u0026mdash;\u0026mdash;\u0026mdash;\u0026mdash;\u0026mdash;\u0026mdash;\u0026mdash;\u0026mdash;\u0026mdash;\u0026mdash;\u0026mdash;\u0026mdash;\u0026ndash;|\n| 坤爲地 | \u0026hellip;\u0026hellip;\u0026hellip;\u0026hellip;\u0026hellip;\u0026hellip;\u0026hellip;\u0026hellip;\u0026hellip;\u0026hellip;\u0026hellip;\u0026hellip;\u0026hellip;.. 7 | 風火家人 | \u0026hellip;\u0026hellip;\u0026hellip;\u0026hellip;\u0026hellip;\u0026hellip;\u0026hellip;\u0026hellip;\u0026hellip;\u0026hellip;\u0026hellip;\u0026hellip;.. 81 |\n| 水雷屯 | \u0026hellip;\u0026hellip;\u0026hellip;\u0026hellip;\u0026hellip;\u0026hellip;\u0026hellip;\u0026hellip;\u0026hellip;\u0026hellip;\u0026hellip;\u0026hellip;.. 11 | 火澤睽 | \u0026hellip;\u0026hellip;\u0026hellip;\u0026hellip;\u0026hellip;\u0026hellip;\u0026hellip;\u0026hellip;\u0026hellip;\u0026hellip;\u0026hellip;\u0026hellip;\u0026hellip; 83 |\n| 山水蒙 | \u0026hellip;\u0026hellip;\u0026hellip;\u0026hellip;\u0026hellip;\u0026hellip;\u0026hellip;\u0026hellip;\u0026hellip;\u0026hellip;\u0026hellip;\u0026hellip;.. 13 | 水山蹇 | \u0026hellip;\u0026hellip;\u0026hellip;\u0026hellip;\u0026hellip;\u0026hellip;\u0026hellip;\u0026hellip;\u0026hellip;\u0026hellip;\u0026hellip;\u0026hellip;\u0026hellip; 85 |\n| 水天需 | \u0026hellip;\u0026hellip;\u0026hellip;\u0026hellip;\u0026hellip;\u0026hellip;\u0026hellip;\u0026hellip;\u0026hellip;\u0026hellip;\u0026hellip;\u0026hellip;.. 15 | 雷水解 | \u0026hellip;\u0026hellip;\u0026hellip;\u0026hellip;\u0026hellip;\u0026hellip;\u0026hellip;\u0026hellip;\u0026hellip;\u0026hellip;\u0026hellip;\u0026hellip;\u0026hellip;87 |\n| 天水讼 | \u0026hellip;\u0026hellip;\u0026hellip;\u0026hellip;\u0026hellip;\u0026hellip;\u0026hellip;\u0026hellip;\u0026hellip;\u0026hellip;\u0026hellip;\u0026hellip;.. 17 | 山澤損 | \u0026hellip;\u0026hellip;\u0026hellip;\u0026hellip;\u0026hellip;\u0026hellip;\u0026hellip;\u0026hellip;\u0026hellip;\u0026hellip;\u0026hellip;\u0026hellip;\u0026hellip; 89 |\n| 地水師 | \u0026hellip;\u0026hellip;\u0026hellip;\u0026hellip;\u0026hellip;\u0026hellip;\u0026hellip;\u0026hellip;\u0026hellip;\u0026hellip;\u0026hellip;\u0026hellip;.. 19 | 風雷益 | \u0026hellip;\u0026hellip;\u0026hellip;\u0026hellip;\u0026hellip;\u0026hellip;\u0026hellip;\u0026hellip;\u0026hellip;\u0026hellip;\u0026hellip;\u0026hellip;.. 91 |\n| 水地比 | \u0026hellip;\u0026hellip;\u0026hellip;\u0026hellip;\u0026hellip;\u0026hellip;\u0026hellip;\u0026hellip;\u0026hellip;\u0026hellip;\u0026hellip;\u0026hellip;.. 21 | 澤天夬 | \u0026hellip;\u0026hellip;\u0026hellip;\u0026hellip;\u0026hellip;\u0026hellip;\u0026hellip;\u0026hellip;\u0026hellip;\u0026hellip;\u0026hellip;\u0026hellip;\u0026hellip; 93 |\n| 風天小畜 | \u0026hellip;\u0026hellip;\u0026hellip;\u0026hellip;\u0026hellip;\u0026hellip;\u0026hellip;\u0026hellip;\u0026hellip;\u0026hellip;\u0026hellip;\u0026hellip;\u0026hellip; 25 | 天風姤 | \u0026hellip;\u0026hellip;\u0026hellip;\u0026hellip;\u0026hellip;\u0026hellip;\u0026hellip;\u0026hellip;\u0026hellip;\u0026hellip;\u0026hellip;\u0026hellip;\u0026hellip; 95 |\n| 天澤履 | \u0026hellip;\u0026hellip;\u0026hellip;\u0026hellip;\u0026hellip;\u0026hellip;\u0026hellip;\u0026hellip;\u0026hellip;\u0026hellip;\u0026hellip;\u0026hellip;\u0026hellip; 27 | 澤地萃 | \u0026hellip;\u0026hellip;\u0026hellip;\u0026hellip;\u0026hellip;\u0026hellip;\u0026hellip;\u0026hellip;\u0026hellip;\u0026hellip;\u0026hellip;\u0026hellip;\u0026hellip; 97 |\n| 地天泰 | \u0026hellip;\u0026hellip;\u0026hellip;\u0026hellip;\u0026hellip;\u0026hellip;\u0026hellip;\u0026hellip;\u0026hellip;\u0026hellip;\u0026hellip;\u0026hellip;\u0026hellip; 29 | 地風升 | \u0026hellip;\u0026hellip;\u0026hellip;\u0026hellip;\u0026hellip;\u0026hellip;\u0026hellip;\u0026hellip;\u0026hellip;\u0026hellip;\u0026hellip;\u0026hellip;\u0026hellip; 99 |\n| 天地否 | \u0026hellip;\u0026hellip;\u0026hellip;\u0026hellip;\u0026hellip;\u0026hellip;\u0026hellip;\u0026hellip;\u0026hellip;\u0026hellip;\u0026hellip;\u0026hellip;.. 31 | 澤水困 | \u0026hellip;\u0026hellip;\u0026hellip;\u0026hellip;\u0026hellip;\u0026hellip;\u0026hellip;\u0026hellip;\u0026hellip;\u0026hellip;\u0026hellip;\u0026hellip; 101 |\n| 天火同人 | \u0026hellip;\u0026hellip;\u0026hellip;\u0026hellip;\u0026hellip;\u0026hellip;\u0026hellip;\u0026hellip;\u0026hellip;\u0026hellip;\u0026hellip;\u0026hellip;\u0026hellip; 33 | 水風井 | \u0026hellip;\u0026hellip;\u0026hellip;\u0026hellip;\u0026hellip;\u0026hellip;\u0026hellip;\u0026hellip;\u0026hellip;\u0026hellip;\u0026hellip;\u0026hellip; 103 |\n| 火天大有 | \u0026hellip;\u0026hellip;\u0026hellip;\u0026hellip;\u0026hellip;\u0026hellip;\u0026hellip;\u0026hellip;\u0026hellip;\u0026hellip;\u0026hellip;\u0026hellip;\u0026hellip; 35 | 澤火革 | \u0026hellip;\u0026hellip;\u0026hellip;\u0026hellip;\u0026hellip;\u0026hellip;\u0026hellip;\u0026hellip;\u0026hellip;\u0026hellip;\u0026hellip;\u0026hellip;.105 |\n| 地山謙 | \u0026hellip;\u0026hellip;\u0026hellip;\u0026hellip;\u0026hellip;\u0026hellip;\u0026hellip;\u0026hellip;\u0026hellip;\u0026hellip;\u0026hellip;\u0026hellip;\u0026hellip; 37 | 火風鼎 | \u0026hellip;\u0026hellip;\u0026hellip;\u0026hellip;\u0026hellip;\u0026hellip;\u0026hellip;\u0026hellip;\u0026hellip;\u0026hellip;\u0026hellip;\u0026hellip; 107 |\n| 雷地豫 | \u0026hellip;\u0026hellip;\u0026hellip;\u0026hellip;\u0026hellip;\u0026hellip;\u0026hellip;\u0026hellip;\u0026hellip;\u0026hellip;\u0026hellip;\u0026hellip;\u0026hellip; 39 | 震爲雷 | \u0026hellip;\u0026hellip;\u0026hellip;\u0026hellip;\u0026hellip;\u0026hellip;\u0026hellip;\u0026hellip;\u0026hellip;\u0026hellip;\u0026hellip;\u0026hellip;.109 |\n| 澤雷隨 | \u0026hellip;\u0026hellip;\u0026hellip;\u0026hellip;\u0026hellip;\u0026hellip;\u0026hellip;\u0026hellip;\u0026hellip;\u0026hellip;\u0026hellip;\u0026hellip;.. 41 | 艮為山 | \u0026hellip;\u0026hellip;\u0026hellip;\u0026hellip;\u0026hellip;\u0026hellip;\u0026hellip;\u0026hellip;\u0026hellip;\u0026hellip;\u0026hellip;.. 111 |\n| 山風蠱 | \u0026hellip;\u0026hellip;\u0026hellip;\u0026hellip;\u0026hellip;\u0026hellip;\u0026hellip;\u0026hellip;\u0026hellip;\u0026hellip;\u0026hellip;\u0026hellip;\u0026hellip;43 | 風山漸 | \u0026hellip;\u0026hellip;\u0026hellip;\u0026hellip;\u0026hellip;\u0026hellip;\u0026hellip;\u0026hellip;\u0026hellip;\u0026hellip;\u0026hellip;\u0026hellip; 113 |\n| 地澤臨 | \u0026hellip;\u0026hellip;\u0026hellip;\u0026hellip;\u0026hellip;\u0026hellip;\u0026hellip;\u0026hellip;\u0026hellip;\u0026hellip;\u0026hellip;\u0026hellip;\u0026hellip; 45 | 雷澤歸妹 | \u0026hellip;\u0026hellip;\u0026hellip;\u0026hellip;\u0026hellip;\u0026hellip;\u0026hellip;\u0026hellip;\u0026hellip;\u0026hellip;\u0026hellip;\u0026hellip; 115 |\n| 風地觀 | \u0026hellip;\u0026hellip;\u0026hellip;\u0026hellip;\u0026hellip;\u0026hellip;\u0026hellip;\u0026hellip;\u0026hellip;\u0026hellip;\u0026hellip;\u0026hellip;\u0026hellip;47 | 雷火豐 | \u0026hellip;\u0026hellip;\u0026hellip;\u0026hellip;\u0026hellip;\u0026hellip;\u0026hellip;\u0026hellip;\u0026hellip;\u0026hellip;\u0026hellip;\u0026hellip; 117 |\n| 火雷噬嗑 | \u0026hellip;\u0026hellip;\u0026hellip;\u0026hellip;\u0026hellip;\u0026hellip;\u0026hellip;\u0026hellip;\u0026hellip;\u0026hellip;\u0026hellip;\u0026hellip;\u0026hellip; 49 | 火山旅 | \u0026hellip;\u0026hellip;\u0026hellip;\u0026hellip;\u0026hellip;\u0026hellip;\u0026hellip;\u0026hellip;\u0026hellip;\u0026hellip;\u0026hellip;\u0026hellip; 119 |\n| 山火賁 | \u0026hellip;\u0026hellip;\u0026hellip;\u0026hellip;\u0026hellip;\u0026hellip;\u0026hellip;\u0026hellip;\u0026hellip;\u0026hellip;\u0026hellip;\u0026hellip;.. 51 | 巽為風 | \u0026hellip;\u0026hellip;\u0026hellip;\u0026hellip;\u0026hellip;\u0026hellip;\u0026hellip;\u0026hellip;\u0026hellip;\u0026hellip;\u0026hellip;\u0026hellip; 121 |\n| 山地剝 | \u0026hellip;\u0026hellip;\u0026hellip;\u0026hellip;\u0026hellip;\u0026hellip;\u0026hellip;\u0026hellip;\u0026hellip;\u0026hellip;\u0026hellip;\u0026hellip;\u0026hellip; 53 | 兌爲澤 | \u0026hellip;\u0026hellip;\u0026hellip;\u0026hellip;\u0026hellip;\u0026hellip;\u0026hellip;\u0026hellip;\u0026hellip;\u0026hellip;\u0026hellip;\u0026hellip; 123 |\n| 地雷復 | \u0026hellip;\u0026hellip;\u0026hellip;\u0026hellip;\u0026hellip;\u0026hellip;\u0026hellip;\u0026hellip;\u0026hellip;\u0026hellip;\u0026hellip;\u0026hellip;\u0026hellip; 55 | 風水渙 | \u0026hellip;\u0026hellip;\u0026hellip;\u0026hellip;\u0026hellip;\u0026hellip;\u0026hellip;\u0026hellip;\u0026hellip;\u0026hellip;\u0026hellip;\u0026hellip;.125 |\n| 天雷无妄 | \u0026hellip;\u0026hellip;\u0026hellip;\u0026hellip;\u0026hellip;\u0026hellip;\u0026hellip;\u0026hellip;\u0026hellip;\u0026hellip;\u0026hellip;\u0026hellip;\u0026hellip; 57 | 水澤節 | \u0026hellip;\u0026hellip;\u0026hellip;\u0026hellip;\u0026hellip;\u0026hellip;\u0026hellip;\u0026hellip;\u0026hellip;\u0026hellip;\u0026hellip;\u0026hellip; 127 |\n| 山天大畜 | \u0026hellip;\u0026hellip;\u0026hellip;\u0026hellip;\u0026hellip;\u0026hellip;\u0026hellip;\u0026hellip;\u0026hellip;\u0026hellip;\u0026hellip;\u0026hellip;\u0026hellip; 59 | 風澤中孚 | \u0026hellip;\u0026hellip;\u0026hellip;\u0026hellip;\u0026hellip;\u0026hellip;\u0026hellip;\u0026hellip;\u0026hellip;\u0026hellip;\u0026hellip;\u0026hellip;.129 |\n| 山雷頤 | \u0026hellip;\u0026hellip;\u0026hellip;\u0026hellip;\u0026hellip;\u0026hellip;\u0026hellip;\u0026hellip;\u0026hellip;\u0026hellip;\u0026hellip;\u0026hellip;.. 61 | 雷山小過 | \u0026hellip;\u0026hellip;\u0026hellip;\u0026hellip;\u0026hellip;\u0026hellip;\u0026hellip;\u0026hellip;\u0026hellip;\u0026hellip;\u0026hellip;\u0026hellip; 131 |\n| 澤風大過 | \u0026hellip;\u0026hellip;\u0026hellip;\u0026hellip;\u0026hellip;\u0026hellip;\u0026hellip;\u0026hellip;\u0026hellip;\u0026hellip;\u0026hellip;\u0026hellip;\u0026hellip; 63 | 水火既濟 | \u0026hellip;\u0026hellip;\u0026hellip;\u0026hellip;\u0026hellip;\u0026hellip;\u0026hellip;\u0026hellip;\u0026hellip;\u0026hellip;\u0026hellip;\u0026hellip; 133 |\n| 坎爲水 | \u0026hellip;\u0026hellip;\u0026hellip;\u0026hellip;\u0026hellip;\u0026hellip;\u0026hellip;\u0026hellip;\u0026hellip;\u0026hellip;\u0026hellip;\u0026hellip;\u0026hellip; 65 | 火水未濟 | \u0026hellip;\u0026hellip;\u0026hellip;\u0026hellip;\u0026hellip;\u0026hellip;\u0026hellip;\u0026hellip;\u0026hellip;\u0026hellip;\u0026hellip;\u0026hellip; 135 |\n| 離為火 | \u0026hellip;\u0026hellip;\u0026hellip;\u0026hellip;\u0026hellip;\u0026hellip;\u0026hellip;\u0026hellip;\u0026hellip;\u0026hellip;\u0026hellip;\u0026hellip;\u0026hellip; 67 | | |\n| 澤山咸 | \u0026hellip;\u0026hellip;\u0026hellip;\u0026hellip;\u0026hellip;\u0026hellip;\u0026hellip;\u0026hellip;\u0026hellip;\u0026hellip;\u0026hellip;\u0026hellip;\u0026hellip; 69 | | |\n| 雷風恆 | \u0026hellip;\u0026hellip;\u0026hellip;\u0026hellip;\u0026hellip;\u0026hellip;\u0026hellip;\u0026hellip;\u0026hellip;\u0026hellip;\u0026hellip;\u0026hellip;.. 71 | | |\n| 天山遯 | \u0026hellip;\u0026hellip;\u0026hellip;\u0026hellip;\u0026hellip;\u0026hellip;\u0026hellip;\u0026hellip;\u0026hellip;\u0026hellip;\u0026hellip;\u0026hellip;\u0026hellip; 73 | | |\n| 雷天大壯 | \u0026hellip;\u0026hellip;\u0026hellip;\u0026hellip;\u0026hellip;\u0026hellip;\u0026hellip;\u0026hellip;\u0026hellip;\u0026hellip;\u0026hellip;\u0026hellip;\u0026hellip; 75 | | |\n| 火地晉 | \u0026hellip;\u0026hellip;\u0026hellip;\u0026hellip;\u0026hellip;\u0026hellip;\u0026hellip;\u0026hellip;\u0026hellip;\u0026hellip;\u0026hellip;\u0026hellip;\u0026hellip; 77 | | |\n"},{"id":35,"href":"/zh/docs/culture/%E5%A4%A9%E7%BA%AA/%E4%BA%BA%E9%97%B4%E9%81%93/%E4%B8%8B%E7%BB%8F/64%E7%81%AB%E6%B0%B4%E6%9C%AA%E6%BF%9F/","title":"64火水未濟","section":"下经","content":" 64火水未濟 # 此卦孔子穿九曲明珠未徹,卜得之,乃遇二女方始得穿也。\n圖中:\n一人提刀斧,一虎坐,一旗卓在山上, 一人取旗,梯子有到字。\n碣火求珠之課 憂中望喜之象\n物必不會終盡,故以未濟為易卦之終。既濟為物之終,易為變易而不盡,有生生之意,故未濟為其序。未濟為未窮盡也,此卦,上離下坎,火在水上,互不為用,為未濟之時義。\n卦圖象解\n一、一人提刀斧:劉姓,武官,手有生殺之權。二、 一虎坐:肖虎人,王姓,虎坐乃無威之象。三、一旗卓在山上:揭竿而起,正義之師。\n四、一人取旗:代替也,高姓,宋姓,其人取代之象。\n五、梯子有至字:無刀日至,桎梏受困象,六、(附註:峪:谷姓。旗揚:楊姓。)有谷、楊合乃招凶象。\n人間道\n未濟:亨。小狐汔濟,濡其尾,无攸利。\n未濟之時,亦有亨通之道。假如小狐不知慎,逞其壯勇,渡河不知慎,其終必不能濟,故無所利矣。\n彖曰:未濟亨,柔得中也。小狐汔濟,未出中也。 # 未濟之時,能亨通,乃因其柔居中得其時,慎處之於未濟時,可以亨通也。如小狐之果敢於渡河,如不憂其尾濕,必不可脱險也。\n濡其尾,无攸利,不續終也。雖不當位,剛柔應也。 # 其進雖勇,因尾濡而不能進,故不利往也。若能慎重處之,剛柔並濟則雖不當位,亦有可濟之理處。\n象曰:火在水上,未濟,君子以愼辨物居方。 # 火在水上,互不相交,此為未濟,君子觀象知,乃處不當之象,應慎處事物,辨其所當, 各居其位,止於其所。\n初六:濡其尾,吝。 # 陰柔居下位,居未濟之時,求力進猶獸之渡河,必揭其尾,方可渡,此言人不度量其才而進,終必不濟,終招吝鄙也。\n九二 :曳其輪,貞吉。 # 陽剛居將位,有欲動之象,今居未濟之時, 有以剛凌柔水來勝火之象,故須知止,如曳車之輪使其止進,如此可吉。戒之在剛過,如此則犯上,其終必凶。\n六三:未濟,征凶,利涉大川。 # 居未濟之時,柔居相位,在下卦之上,有領導之象,但才不足濟,居險而無才足以出險, 如此而行,其必招凶致。惟俟時至,俟上位之才相應,再涉險而出,乃可出險。\n九四:貞吉 ,悔亡,震用伐鬼方, 三年有賞于大國。 # 陽剛之賢才,居於君側,上為中虛明順之君,故能濟天下之艱困,能伐鬼方示其力之大, 三年後乃有大功受國封賞。\n六五:貞吉 ,无悔,君子之光, 有孚,吉。 # 中虛而明之才居君之尊位,能虛其心任剛賢之相為輔佐,且信任之而不悔,此處之至善, 即令己才不足,但亦由中心孚誠,終必大吉。\n象曰:君子之光,其暉吉也。 # 上九:有孚于飲酒,无咎,濡其首, 有孚失是。\n陽剛居極,在中虛卦之上,乃示其剛且明, 能如此,則必不生燥,決之以明,明能洞察事理,剛能行事,然居未濟之時,因無可濟之理, 故成樂天順命而已,可以無災。如過於禮法, 濡其首,亂如是,其必不知反省,則示不安於所居也。\n象曰:濡其尾,亦不知極也。 # 不度其才而力進,乃不知至極點也。\n象曰:九二貞吉,中以行正也。 # 九二能吉,乃因其能得中道,行之正,不過剛,犯上,故也。\n象曰:未濟征凶,位不當也。 # 陰柔無能之才居領導之相位,處未濟時, 動必有凶,仍因其位不適。\n象曰:貞吉悔亡,志行也。 # 賢相之才,能與時合,且貞固心志,必能吉而不慮悔亡也。\n君子之能如是,則其功之光必明且亮,其光盛而吉也。\n象曰:飲酒濡首,亦不知節也。 # 飲酒而至於弄濕其首,乃過樂也,必不能安於命,此不知命節制,必失其常理,人能安處劣勢,必能守其心志。此理之至然。\n"},{"id":36,"href":"/zh/docs/culture/%E5%A4%A9%E7%BA%AA/%E4%BA%BA%E9%97%B4%E9%81%93/%E4%B8%8B%E7%BB%8F/63%E6%B0%B4%E7%81%AB%E6%97%A2%E6%BF%9F/","title":"63水火既濟","section":"下经","content":" 63水火既濟 # 此卦季布在周家潛藏,卜得之,遂遇高皇\n帝\n圖中:\n人在岸上一船來,一堆錢,雲中雨下, 二小兒在雨中行,文書一策。\n舟楫濟川之課 陰陽配合之象\n小過乃過於物,能過於物,必能相濟,故受之以既濟。此卦,水在上,火在下,火水相交, 則能為用,能互為用,即為既濟,此卦言,天下萬事,己互濟之時也。\n卦圖象解\n一、人在岸上:泣也,等待也,無目的也。二、 一船來:接引至也。\n三、一堆錢:財祿無用也,憂心忡忡也,有竊象,抄家之象。四、雲中雨下:陰中有果也,夜間至凌晨時。\n五、二小兒:主喜也,二人也,二人雨中行也。六、文書一策:證件,命令也,資料也。\n人間道\n既濟:亨小,利貞,初吉,終亂。\n既濟之時大事必能亨通,然須知小事亦须亨通,方可為吉,必須所有事皆吉,否則濟至極時,終亂。\n彖曰:既濟亨,小者亨也。利貞,剛柔正而位當也。 # 陰陽能皆得位,故為既濟,但須無所不亨,乃可也,且貞固守之。\n初吉,柔得中也。終止則亂,其道窮也。 # 其以柔順文明之中道,故可成既濟之功,至既濟之極,若不知變通,必生亂,其亂之生乃因道至盡窮。\n象曰:水在火上,既濟,君子以思患而豫防之。 # 水火即交,各有其用,為互濟,時當既濟,君子觀象知於既濟之時,必先思慮患害之生, 使不至成禍患也。自古以來,天下由治而亂,乃皆因居治不思亂時之戒。\n初九:曳其輪,濡其尾,无咎。 # 倒曳輪,使不再進。獸之涉水,必舉高其尾,使尾不濕方可進,無災至。\n象曰:曳其輪,義无咎也。 # 於既濟大吉之時,能知止進,則必不至極時,故必無咎也。\n六二:婦喪其茀,勿逐,七日得。 # 陰居正位,得五君位應,其志必得遂行, 但中正之道,不可廢也,猶婦人出門用茀遮己今喪其茀,則不行,能自守不失,道必復也。待時之至。\n九三:高宗伐鬼方,三年克之,小人勿用。 # 以剛居剛位,居既濟時,其君主威武必令民心服。但若專肆威武,必令民心忿而不服, 殘害人民,貪人民之富,故有小人勿用之戒, 因惟小人其威武必為滿足其私欲,君子戒之。\n六四:繻有衣袽,終日戒。 # 四近君位,陰柔居之,乃適其任,當既濟之時,須如行舟,戒之滲漏,始漏則塞以衣物, 且終日戒懼不怠,則必可免患。\n九五:柬鄰殺牛,不如西鄰之禴祭,實受其福。 # 九五至尊君位,陽剛人居此,當既濟之時, 必以至誠信孚如祭祀之誠,則即令薄禮之祭, 也可勝於豐厚之祭,由其至誠之心使然也。易之不重形,而重神,此明而顯矣,故論易須知\n上六:濡其首,厲。 # 既濟至極時,必不安而危,今陰柔居之, 居坎險之上,即既濟之終,小人居之,其敗壞必立可見矣。\n象曰:七日得,以中道也。 # 因中正之道非不可用,乃時之未至,於此時自守其中,俟時至必能行矣。\n象曰:三年克之,憊也。 # 既濟之時,必濟天下,於高宗可也,乃因其心為民,道必正,如為己之私欲,則三年民必疲憊而終忿矣。\n象曰:終日戒,有所疑也。 # 當既濟之時,必疑其患之將至,其戒懼之心如此,謹慎如此。\n時,方可得易之神明矣。\n象曰:東鄰殺牛,不如西鄰之時 也,實受其福,吉大來也。 # 既濟之時,能得時者,即令薄祭,亦可有大吉之福至,此言時之可貴也。\n象曰:濡其首厲,何可久也。 # 藭盡之須以水淋其首,其必不可長久也。\n"},{"id":37,"href":"/zh/docs/culture/%E5%A4%A9%E7%BA%AA/%E4%BA%BA%E9%97%B4%E9%81%93/%E4%B8%8B%E7%BB%8F/62%E9%9B%B7%E5%B1%B1%E5%B0%8F%E9%81%8E/","title":"62雷山小過","section":"下经","content":" 62雷山小過 # 此卦漢君有難,卜得之,後果脱險也。\n圖中:\n明月當空,林下一人彈冠,人在網中。一人割網,猴子在山頭出。\n飛鳥遺音之課 上逆下順之象\n人之有信後必行,行則生過,故小過所以為中孚序也。此卦山上有雷,雷震於高處,其聲必過,故為小過。小過者,可說為小事過,亦可説為過之小也。\n卦圖象解\n一、明月當空:政清狀,入法庭,刑堂。二、林下一人:林姓人。\n三、人陷網中:不得脱身,受束縛。\n四、一人彈冠:求去之象,但求去為形,束縛為神,合則有表裡不一狀。五、一人割網:貴人解救,脱困狀。\n六、猴子在山頭:從新開始之課(候再出山)。\n人間道\n小過:亨,利,貞。\n過之意在過其正常也。如矯枉過正,此過為就正意。時不正,用過之道使正,此過之吉處, 吾人須知過之道,知用過之時機方如是。\n可小事,不可大事,飛鳥遺之音,不宜上,宜下,大吉。 # 用過之道,乃為求中正。其過之限,可用小事,於大事不可用過。故曰不宜上,宜下,順之則吉,乃過能順理,吉反更大也。\n彖曰:小過,小者過而亨也。過以利貞,與時行也。 # 小過之道,在過之小,有時當過,過而能致亨,過之能吉矣。但用過之道,須配合時機, 乃意當過則過,此非真過矣,此為以過養正也。\n柔得中,是以小事吉也,剛失位而不中,是以不可大事也,有飛鳥之象焉。 # 小過之道,處小事之過,可得吉。但不可做大事,因剛失位又不得中,故不可以用在大事上,故有飛鳥之象,鳥飛之事小,過而餘音,但無災也。\n飛鳥遺之音,不宜上宜下,大吉,上逆而下順也。 # 事之有過,當從其宜。如人之過恭,過哀,過儉,其太過,則不可。其過當如飛鳥之遺音,\n其聲出,而身己過,事之過當如是,則吉宜。此順道之過,故也。\n象曰:山上有雷,小過。君子以行過乎恭,喪過乎哀,用過乎儉。 # 雷震於山上,其聲至,而雷已過,故為小過,君子觀象,知天下事,有時當過,但不可過甚。故為小過,當過而過,乃其宜也。\n初六:飛鳥以凶。 # 陰柔居下位,為小人之象,小人本躁易動, 今逢小過時,乃得理不饒人,其行為之過。必速且遠,救之莫及也,故凶。\n六二:過其祖,遇其妣,不及其君, 遇其臣,无咎。 # 二與五爻位,其猶祖之象,今陰柔居其位, 越過三,四位,直與五相應,有越位之戒,今逢該過之時,如過越位,仍不失臣道,亦可無咎。於其他之卦,越過本位凶,但今於小過, 乃意當過之時,可過,無災,切忌失君臣之道,\n九三:弗過防之,從或戕之,凶。 # 陽居正位,於小過之時,意味手下無能, 又為陰之小人蒙蔽。此時須過防之。如不加強防範,必招害。君子防小人之道,以正己為先, 堅守正道也。\n九四:无咎,弗過遇之,往厲必戒, 勿用永貞。 # 剛居柔位,以剛而用柔,其剛必不過也。故無災咎。剛陽之道居小過時,須戒之隨宜, 不可固守不變,故君子隨時順處,不固守其常也。\n六五:密雲不雨,自我西郊,公弋取彼在穴。 # 此陰柔居君位,居當過之時,乃不夠陽剛, 故雖欲過,但不能成功。故猶如密雲而不成雨, 其不成功,即令越過其位向二爻相應,三、四爻位不應,亦如同密雲集中,而無法成雨\n上六:弗遇過之,飛鳥離之,凶, 是謂災眚。 # 六居小過之極,居過之極,不顧正理,必違常道,遠矣,故凶,必招人為災害也,此於天理人事皆同。\n象曰:飛鳥以凶,不可如何也。 # 過之疾,如鳥之速,必救之不及,無有作為也。\n及過之太過,反凶。\n象曰:不及其君,臣不可過也。 # 下臣遇须過之時,可過越其上位,但不可至君位,至君前,則太過,過一爻可無咎。\n象曰:從或戕之,凶如何也。 # 小人道盛,必害於君子。當防而不防,必傷矣。\n象曰:弗過遇之,位不當也,往厲必戒,終不可長也。 # 陽剛居柔位,不當位時,於當過之時,能不過剛反柔,乃得其宜,於自保可也,但終不可成長至盛,故往則有危,須戒之。\n象曰:密雲不雨,已上也。 # 陽下陰上,合則成雨,今陰已在上,即令雲再密,無陽而不成雨,終無功也。\n象曰:弗遇過之,已亢也。 # 居過之終,失正理之甚,乃過至亢極,故招凶致。\n"},{"id":38,"href":"/zh/docs/culture/%E5%A4%A9%E7%BA%AA/%E4%BA%BA%E9%97%B4%E9%81%93/%E4%B8%8B%E7%BB%8F/61%E9%A2%A8%E6%BE%A4%E4%B8%AD%E5%AD%9A/","title":"61風澤中孚","section":"下经","content":" 61風澤中孚 # 此卦辛君屯邊,卜得之,遂果得梅妃之信也。\n圖中:\n望子上文書,人擊柝,貴人用繩牽鹿雁啣書。\n鶴鳴子和之課 事有定期之象\n人有節,而後有信,故中孚次之。節本有節制,不可過越之意,人能信而後行之,上位知信守,下位知信從。此卦澤上有風,風行于澤上能感於水為中孚之象,此因一 、二爻位,五六爻位,皆實陽,而三、四爻位,為中虛之象,全卦有外實中虛之義,此中孚之象。\n卦圖象解\n一、望子上文書:科甲高中,刑法更吉。二 、人擊柝:預警也。\n三、貴人用繩牽鹿:守成,不可攻也。四、雁啣書:喜事將至,有南徙之象。\n五、人立庭中:蒞庭也,防官司或打官司保身也。\n人間道\n中孚:豚魚吉,利涉大川,利貞。\n中孚之道,其中心之誠信,能使豚魚都有感,則無所不至矣。故利涉大川,週行無限也\n彖曰:中孚,柔在内而剛得中。 # 中孚之中虛乃至誠之象。此示意剛之道能得中正,故吉。\n説而巽,孚乃化邦也。豚魚吉,信及豚魚也。 # 上位以至誠而順從於下,下位以至誠以求上悦,由其中孚之至誠,必教化邦國,此信能令豚魚皆感,此道之至善也。\n利涉大川,乘木舟虛也。中孚以利貞,乃應乎天也。 # 中孚道之吉,猶乘舟渡川,內無實物,不虞覆船,即處艱困,能以中孚則必可亨通,能堅守中孚之道,此天道之極至也。\n象曰:澤上有風,中孚,君子以議獄緩死。 # 風在澤上,澤有感于風,因水體本虛,故風能入。君子觀之,知人心虛,則物必感之,此中孚之象,君子于議獄,本為盡忠而已。於決死之際,但求緩之,寬之。\n初九:虞吉。有它,不燕。 # 陽剛居始進,於中孚之道並非了解,人初志必不定,故如能以愚誠信之,且專一之志, 若生異志,必不得安矣。\n九二:鳴鶴在陰,其子和之,我有好爵,吾與爾靡之。 # 內剛外柔,陽居陰位,中孚之感,因外柔必能感通,猶鶴鳴於幽谷,人不聞也,而其子必應和,乃心感通也。故能中心孚誠,千里能\n六三:得敵,或鼓或罷,或泣或歌。 # 柔居陽剛之位,居位高但才不能濟,故勢必唯所信而從,其外在之鼓張,罷廢悲泣,歡歌皆因導於內心之所信,因才不足,故只有信,\n六四:月幾望,馬匹亡,无咎。 # 近君之位,以柔順得信,其盛必如月之滿盈。即同類同屬亡失,亦可無咎。\n九五:有孚孿如,无咎。 # 君位之人,以中孚至誠之道,感通天下, 民心之信服,固結如抽掣也。\n上九:翰音登于天,貞凶。 # 中誠孚信致於極,有信終則衰,華美其外, 內無忠篤,猶翰音登天,不知止之,貞固如此, 不知變,乃自招凶。孔子曰:好信不好學,此敝也贼。意即固守而不通也。\n象曰:初九虞吉,志未變也。 # 始信之時,志未能從,但能愚誠專一至信, 則吉矣,故吾人初始,必求能為己所信之道, 方以愚誠,如此方不致迷。\n感之,能化邦國。\n其子和之,中心願也。 # 子能與合,乃中心誠意能通故也。\n而不知吉凶,此非長才之君。\n象曰:鼓鼓或罷,位不當也。 # 居不適位,無所心主,惟能從於所信而已。\n象曰:馬匹亡,絕類上也。 # 求上之孚信,即令同類亡,亦不顧而求, 此所以吉也。\n象曰:有孚孿如,位正當也。 # 五居君位以陽剛,用中正之道,天下民心固結信服,其稱位如此,君之道乃能致極矣。\n象曰:翰音登于天,何可長也? # 守誠信至窮極而不知變,必不能長久?此固守不知變之戒,招凶至矣。\n"},{"id":39,"href":"/zh/docs/culture/%E5%A4%A9%E7%BA%AA/%E4%BA%BA%E9%97%B4%E9%81%93/%E4%B8%8B%E7%BB%8F/60%E6%B0%B4%E6%BE%A4%E7%AF%80/","title":"60水澤節","section":"下经","content":" 60水澤節 # 此卦是孟姜女送寒衣,卜得之,知夫落亡不吉之兆。\n圖中:\n大雨下,火中魚躍出,雞在屋上,犬在井中,屋門開著。\n船行風黃之課 寒暑有節之象\n渙者散也,物不可以終散,必當節止,故渙之後以節卦為序。此卦「澤」上有「水」,澤為有限之水地,如再置水,當滿不容,故有節制之象。\n卦圖象解\n一、大雨下:陰暗不明,凶象。\n二、火中魚躍:飛騰而徒勞無功,又餐飲業。三、雞在屋上:酉年肖雞人,解救之時與人。\n四、犬在井中:招陷,動彈不得,不知節制而招如此。五、屋門開著:仍可開張,须雞人來助大吉。\n人間道\n節:亨,苦節,不可貞。\n節之道,在有制,萬事如知節,必可亨。節之貴在中庸,如限制太過則苦,如節至於苦, 必不可常久也,人事如此。\n彖曰:節亨,剛柔分,而剛得中。 # 節之道能致亨通,乃因知剛柔並濟,能剛又且能適中,此節之能亨。\n苦節不可貞,其道窮也。 # 如節之致於苦極,必無法堅固守志,則節道必招困窮也。\n説以行險,當位以節,中正以通。 # 此以卦才言,外險內悦,以能悦且又知止,此為節之大義,常人於悦時不知止,遇艱險方思止。當位居尊之人如明節之道,必能中正且亨通矣。今人之權力欲望無盡,居尊不思止,悦而無限,終必至凶,乃不知節之道,明矣。\n天地節而四時成,節以制度,不傷財,不害民。 # 天地之間有節道故四季分明,聖人觀之知立制度,以為節道,所以必不傷財害民,此法治觀念之始,聖人立此道,即因知人之欲望無限,故以節制之,免流於人治,必因私欲,而終致勞民傷財。\n象曰:澤上有水,節,君子以制數度,議德行。 # 澤之容水有限,故節。君子觀之,知節以制度來限制,下定義來區分君子小人之行為。\n初九:不出户庭,无咎。 # 陽剛之性居節之初,必不能節,如居門庭之內,則可無咎。\n九二:不出門庭,凶。 # 剛陽之性,居陰柔之位,爻義為,處陰且不正之人居當節之時,不知節必合於中道,過與不及皆非節,如此即令不出門庭,亦會招凶。\n六三:不節若,則嗟若,无咎。 # 三爻本剛,今陰柔居之,須剛斷而柔性之人居其位,因其柔順,且知自節乃可順於義行, 亦可以無過。\n六四:安節,亨。 # 陰居陰位,其正位,於節時,即居高位且有節之象,能安於此,則亨通。\n九五:節,吉,往有尚。 # 九五尊位,乃居節時之主位,能甘之如飴, 盡節之道,必吉,功大矣。\n上六:苦節,貞凶,悔亡。 # 上六乃居節之極,其必因節致苦,如堅守不改則必凶,終致亡而悔,易之節卦悔亡,與他卦之悔亡,辭同但意不同也。\n象曰:不出户庭,知通塞也。 # 不出户庭可以無咎,但須知外之言與行, 必以時來定進退之機。\n象曰:不出門庭凶,失時極也。 # 時之義在易中最為重要,不知節之義,又居節之時,失其時,因不合中道之節,過與不及皆不對,即令不出門庭,亦有凶矣。\n象曰:不節之嗟,又誰咎也? # 知節可以免過失,故不知自節而招禍,又能怪誰呢?\n象曰:安節之亨,承上道也。 # 能行節之中道,且安居於此,必亨通義。\n象曰:甘節之吉,居位中也。 # 君王能知節道,用節且甘如飴,必成大功, 因其居尊位故也。\n象曰:苦節貞凶,其道窮也。 # 因節致苦,不知變,失易之神,其凶乃因道之窮盡。\n"},{"id":40,"href":"/zh/docs/culture/%E5%A4%A9%E7%BA%AA/%E4%BA%BA%E9%97%B4%E9%81%93/%E4%B8%8B%E7%BB%8F/59%E9%A2%A8%E6%B0%B4%E6%B8%99/","title":"59風水渙","section":"下经","content":" 59風水渙 # 此卦漢武帝,卜得之,乃知李夫還魂也。\n圖中:\n山上有寺,一僧,一人隨後,一鬼在後,金甲人。\n順水行舟之課 大風吹物之象\n兌,悦也,人悦時,則必舒散,渙、散也,故渙為兌之序。人之性憂則結聚,悦則舒散。此卦上巽下水,乃風行水上,水遇風則渙散,故渙為散也。\n卦圖象解\n一、山上有寺:出家狀,對峙狀,寸土寸金象。二、僧:化外之人,光頭人,曾姓人。\n三、一人隨後:逃避,求助化外之人。\n四、一鬼在後:為禍追緝之象,內心有鬼,處事不明象。五、金甲人:正義之師,得民心也。\n六、寺,土頭人作對。等待時機也。\n人間道\n渙:亨。王假有廟,利涉大川,利貞。\n渙,散也。人會離散,本於中心一念,心離則散矣。故能治散,必從中入手,有能收拾人心,散可聚矣,故散之道論如何用散,故可以亨。君王能知立宗廟收人心,則必可前進無阻, 故須堅心到底。\n彖曰:渙,亨。剛來而不窮,柔得位乎外而上同。 # 渙之道所以可致亨,以卦來言,用陽剛之法,不可致極剛,以居下位,又得中道,柔位而得五君位之相應,故居渙時,能守其中,必不至於離散,所以能亨。\n王假有廟,王乃在中也。利涉大川,乘木有功也。 # 君王能利用宗廟收拾人心,乃知用中道之妙,能如此可往天下,無處不阻也,自古以來, 能得民者,必得其心,方可謂得民矣。\n象曰:風行水上,渙。先王以享于帝立廟。 # 風行水上,渙散之象。先王觀渙之象知,救天下之散,惟有祭祀宗廟,收合人心,合心之道,莫大於此。\n初六:用拯馬壯,吉。 # 初爻,為渙之始,陰柔居不正,又處卑下, 故於始時即察知而拯,只須託於陽剛之人,即可整渙,故吉。\n九二:渙奔其機,悔亡。 # 外飾順,內實險憂,心已散。九二居坎險中位,乃意於渙散時,又居險中,其險可知, 如能知機而奔往不猶豫,方可不慮亡也。\n六三:渙其躬,无悔。 # 六三相位,今才為陰質,不適居位,於渙散之時,必無法拯救他人,只能止於其身,可以無悔矣。\n六四:渙其群,元吉。渙有丘,匪夷所思。 # 六四乃大臣之位,今有九五君來同應,有君臣合力,以濟天下之渙散,能如此則有大功。方渙散之時,用剛則必不能使之懷附,用柔又不足使其依歸,故如能使大聚,此事必難,用必非常法,能成此大功,非聖賢何能如是乎?\n九五:渙汗其大號,渙王居,无咎。 # 九五君位,居渙之時,以陽剛正德又得巽之外順,此深得處渙之道,必能號令人民,民心信服而從矣,如汗之於體外,息息相關,民與君之關係能如此,則必能居王位而無咎。\n上九:渙其血,去逖出,无咎。 # 渙至極時,仍能守巽順之道,即令有傷, 亦仍可出險,遠離災害而無災矣。\n象曰:初六之吉,順也。 # 初六之能吉,乃因於渙始即察知,始而拯, 此得時也故吉。\n象曰:渙本汁其機,得願也。 # 渙之時居險,必以知機而親近之,乃可得願矣。\n象曰:渙其躬,志在外也。 # 渙時,以躬順求上同。可免己之災。\n象曰:渙其群,元吉,光大也。 # 元吉謂大功德也,君臣合力於渙時,能群聚民眾,其功可光大也。\n象曰:王居无咎,正位也。 # 君位尊,而其才德又適其位,稱王可無咎, 因合於正位。\n象曰:渙其血,遠害也。 # 渙散至極,即令血出傷害。以堅守巽順之道,必遠離禍害也。\n"},{"id":41,"href":"/zh/docs/culture/%E5%A4%A9%E7%BA%AA/%E4%BA%BA%E9%97%B4%E9%81%93/%E4%B8%8B%E7%BB%8F/58%E5%85%8C%E7%88%B2%E6%BE%A4/","title":"58兌爲澤","section":"下经","content":" 58兌爲澤 # 此卦唐三藏去西天取經,卜得之,乃知必歸唐國。\n圖中:\n人坐看一堆擔,月在天邊,秀才登梯, 一女在合邊立,文字上箭。\n江湖養物之課 天峰雨澤之象\n巽者,入也,物能相入,必有相悦方成,故兌為巽之序。\n卦圖象解\n一、人坐看一擔:任務完成狀,有人相助象,助人為樂之貴人。二、月在天邊:清明之治。\n三、秀才登梯:金榜登科象。\n四、一女在合邊立:女人介入,先成後破。五、文字上箭:得機而發,射:發象。\n人間道\n兌:亨,利、貞。\n兌之道,可以致亨。人能悦於物,物亦相悦,必足以亨。然兌之道在貞正,求悦不以正道, 必成邪吝,終致悔咎。\n彖曰:兌,説也,剛中而柔外,説以利貞,是以順乎天而應乎人,説以先民,民忘其勞,説以犯難,民忘其死,説之大,民勸矣哉。 # 兑,悦也。外柔內剛,中心誠實之象,悦之道可亨,乃因其能貞正。上順天理,下應人心, 能使民以悦,則民忘勞苦,且欣然為國犯難。忘其生死,此悦之道至極矣,民莫不信之。\n象曰:麗澤,兌,君子以朋友講習。 # 兩兌相重,即兩澤互麗,交相浸潤,互有滋益之象,君子觀之乃知朋友講習,互相增益, 為天下之大悦,有互相明益之象。\n初九:和兌,吉。 # 初雖陽剛,但因居卑下,乃知卑下和順, 此悦必無所偏私,此兌之正道,故吉。\n九二:孚兌,吉,悔亡。 # 二位有剛中之德,內實孚信,雖近小人, 但不失君子之道,悦而不失剛中之德,所以能吉而不慮亡矣。\n象曰:和兌之吉,行未疑也。 # 初位必隨時順處,心無所欲,惟求能和而已,是以吉也。其行必未有可疑。\n象曰:孚兌之吉,信志也。 # 君子之悦,自心中之至誠,故必不失道, 小人之悦,必忘形而自失不知。\n六三:來兌,凶。 # 陰棄居陽剛之位,不適也,比得兌之道不以中正,為悦而求悦,人之有求必因私欲,己離正道,故凶。\n九四:商兌未寧,介疾有喜。 # 陽剛處陰位,居不中正,故有未決,未能定也。居兑之時,不為悦所惑,能知命守剛正, 疾惡去仇,必能得君之信,而有喜也。\n九五:孚于剝,有厲。 # 九五君位得中正之道,居悦時,乃可盡善, 但聖人復設戒於有厲,即雖中正聖賢在上,天下仍有小人,為免惑於悦,小人之入而不自知,\n上六:引兌 # 悦之至極則愈悦,故言引兌。天下萬事過之皆不宜,有凶至。\n象曰:來兌之凶,位不當也。 # 柔居陽剛,自處必不中正,和欲而求悦, 必招凶。\n象曰:九四之喜,有慶也。 # 九四君側之人能有喜慶,必来自君之信孚,得遂行其陽剛之志也。\n故於此設戒。\n象曰:孚于剝,位正當也。 # 居悦知剝之戒,人於事始知戒,可以無災咎也。\n象曰:上六引兌,未光也。 # 悦之至極,已太過於事理,其果必不能更光也。\n"},{"id":42,"href":"/zh/docs/culture/%E5%A4%A9%E7%BA%AA/%E4%BA%BA%E9%97%B4%E9%81%93/%E4%B8%8B%E7%BB%8F/57%E5%B7%BD%E7%82%BA%E9%A2%A8/","title":"57巽為風","section":"下经","content":" 57巽為風 # 此卦范蠡辭官入湖,卜得之,乃知越國不久也。\n圖中:\n貴人賜衣,一人跪受,雲中雁傳書, 人在虎下坐。一人射虎中箭,虎走。\n風行草偃之課 上行下放之象\n巽者,入也。處旅時,親人不在,惟能巽順,方可平安,無所不入。故巽次旅也。此卦一陰居二陽之下,乃有陰順於陽象。\n一、貴人賜衣:先破後成象。\n卦圖象解\n二、 一人跪受:貴綬也。如占疾病,則主壽衣,凶象。三、雲中雁傳書:意外喜訊。\n四、人在虎下坐:身處險而不知。五、一人射虎中箭:貴人相救。 六、虎走:脱險,寅年也。\n又虎乃有威望之人,利武官,主人在虎邊進退不得之時。亦言虎將須知功成身退,否則暗箭難防,以明哲保身為吉。\n人間道\n巽:小亨。利有攸往,利見大人。\n巽順之道,得之可以小亨。故論柔之順性,能如此,利往進,可見貴人,必助。\n彖曰:重巽以申命。剛巽乎中正而志行,柔皆順乎剛,是以小亨。利有攸往,利見大人。\n巽之重卦,有上順下亦順象,苟能上順中道以出命,下順命而服從,則必吉。故君子申復命令,乃知巽也。能知順乎剛且中正,即令才不足亦可以小亨。能得巽順之道,必無往不入可出世見大人也。\n象曰:隨風,巽。君子以申命行事。 # 物之隨風而動,巽之道也,君子觀巽象,乃知重復申令之重要,能有政令,上隨下以順服, 上下皆順,即重巽之象,為始切實,故重申命令。\n初六:進退,利武人之貞。 # 居巽順之時,又處卑下,以陰柔之質,必畏而不安,無所適從,故此時惟利於武官,從武職之人,其巽赈必吉也。\n九二:巽在床下,用史巫紛若,吉, 无咎。 # 九二乃示剛居陰位,外又有巽順之象,意即人有過於卑順之時,不是恐懼就是諂媚,皆非正也。但其雖非正禮,亦可遠恥辱,去\n怨隙,亦可為吉,就如同用誠意來通於神明之史巫,其誠意能通,亦可無過。\n九三:頻巽,吝。 # 九三為下卦之上極,剛居之,有剛亢之質, 居巽順之時,非真能順,乃出於勉強為之,必有所失,失後又頻順,頻順又再失,亦可卑吝也。\n六四:悔亡,田獲三品。 # 六四僅乃陰柔居陰,此位居下之上,乃居上位而知順下,人能如此善處,必可不慮亡。猶田之收獲能遍及上下,能如此,何慮悔亡更且有功。\n九五:貞吉,悔亡无不利,无初有終,先庚三日,後庚三日,吉。 # 陽居陽位又處君位,此為巽之主,其命令, 必合於中正之道,天下黎民莫不順從。能始終如此,必無往不利,如命令之出,有須變更時, 改始之不善,成終之善,則可變更,其中道,\n上九:巽在床下,喪其資斧,貞凶。 # 床,人之所安處也。今在床下,有過於安之義,九為巽極,乃過於柔順,乃喪失剛斷, 必失居所,乃自失也,對正道而言為凶。\n象曰:進退志疑也,利武人之貞, 志治也。 # 進退不知所安,其志必疑且懼,故堅心服從,利於武職之人,不生貳心。\n象曰:紛若之吉,得中也。 # 人能得中道,亦以至誠,則人必信之,吉而無咎。\n象曰:頻巽之吝,志窮也。 # 陽剛之才,本非能順巽,今處重巽之時, 因勢不能遂志,又必須以順,故必有所失,其志必困窮。\n象曰:田獲三品,有功也。 # 巽能通於上下,如田之獲,上下受惠,此巽順之功。\n必始終如一方可。\n象曰:九五之吉,位正中也。 # 九五之尊,能處之吉,乃因能得中正之道, 過與不及皆不善。\n象曰:巽在床下,上窮也,喪其資斧,正乎凶也。 # 居上之極本為陽剛,今卻過於巽順,必失正道,為凶道。\n"},{"id":43,"href":"/zh/docs/culture/%E5%A4%A9%E7%BA%AA/%E4%BA%BA%E9%97%B4%E9%81%93/%E4%B8%8B%E7%BB%8F/56%E7%81%AB%E5%B1%B1%E6%97%85/","title":"56火山旅","section":"下经","content":" 56火山旅 # 此卦陳後主得張麗華,卜得之,乃知先喜後悲。\n圖中:\n三星者,貴人台上垂釣牽水畔人,一猴一羊,大溪者。\n始鳥焚巢之課 歡極哀生之象\n豐至極時,不知戒盛,乃失其居,故序卦為旅。此卦「離」上「艮」下乃外明內止之象, 山為止而不動,火行而不居,有不與同流之意。故為旅象,又人之旅,必外麗,亦旅象。\n卦圖象解\n一、三星者:三台貴人,相位之人。\n二、貴人台上垂釣:君主相求,老闆相求人才。\n三、牽水畔人:賢能之人,離野入朝封候。或慕權勢而變志。四、一猴一羊:肖猴,肖羊,候、楊姓人,未申年應之。\n五、大溪:脱險也,港口也,水側也。\n人間道\n旅:小亨、旅貞吉。\n旅之時,此因不同流而旅,故有小亨,且得之堅心,必吉。切不可假旅之道而有私欲其中。\n彖曰:旅,小亨,柔得中乎外而順乎剛,止而麗乎明,是以小亨,旅貞吉也。旅之時義大矣哉! # 旅之時至而旅,乃有亨,人能知所進退,內有中道外又知順於剛,止之在能外麗而內明, 是故有小亨,旅而堅貞其志,是故吉也。當旅不旅乃自求咎。故天下之事,當有時宜,因時而動,其義大也。\n象曰:山上有火,旅。君子以明慎用刑,而不留獄。 # 火在山高處,其明及遠,旅之道也。君子觀此明照之象,知明慎以用刑,絶不依持己之明, 必有戒慎恐懼之心而施於用刑。因火行不留,故有不留獄之志,獄乃不得已而設,故不求留獄, 此觀火之行而體悟之。\n初六:旅瑣瑣,斯其所取災。 # 陰柔無才之人,居卑下,居旅之時,因才質如此,故必畏畏瑣瑣,其必終自取其辱。\n象曰:旅瑣瑣,志窮災也。 # 意志因困窮時,而生變,乃自取其災也。\n六二:旅即次,懷其資,得童僕貞。 # 六二乃得適位之將才居旅時,因知中正處不失當,必能懷蓄財資,又能得僕人之忠心, 故吉。\n九三:旅焚其次,喪其童僕,貞厲。 # 旅之時,必以柔順謙下為吉,如今自處過剛,又居高,乃招致災困。必失僕人之忠,終有危厲來臨。\n九四:旅于處,得其資斧,我心不快。 # 陽剛又居柔位,有用柔之象。人居旅時, 能柔,得旅之道,必吉。此法居旅時可得財货之資助,但不能伸其大志,其心必不快。\n六五:射雉,一矢亡,終以譽命。 # 人於旅時,不可有錯,一但犯之,災禍立至。就如射雉,能一箭而中,不虛發即無過失, 能如此,則於旅時,必可立名揚譽。\n上九:鳥焚其巢,旅人先笑後號 # 咷,喪牛于易,凶。\n旅時以過陽剛自處以高,不知謙卑,就如鳥高飛而自焚其巢,故終居無所安處,自負過剛,居旅時,始則可快意盡情,故生笑。繼而失安無居所,故後號咷。牛為順性之物,但也\n象曰:得童僕貞,終无尤也。 # 旅行之人,所賴者為童僕,能得其忠心, 必可無災。\n象曰:旅焚其次,亦以傷矣,以旅與下,其義喪也。 # 居旅時以過剛自高,手下必喪失忠心,危之至矣。\n象曰:旅于處,未得位也,得其資斧,必未快也。 # 以剛居柔,合於旅之道,故旅時為善,但欲得行其志,卻不能也。心必不快。\n象曰:終以譽命,上逮也。 # 旅之時,能令上下皆應,則可吉。其因旅之時乃困而未得其安之時為旅。\n易於喪失,只一疏忽而己。故凶。\n象曰:以旅在上,其義焚也。喪牛于易,終莫之聞也。 # 旅時以居高自處,必不能保安,猶鳥之焚巢,喪失順德極易,待凶至,乃不知自聞知也。\n"},{"id":44,"href":"/zh/docs/culture/%E5%A4%A9%E7%BA%AA/%E4%BA%BA%E9%97%B4%E9%81%93/%E4%B8%8B%E7%BB%8F/55%E9%9B%B7%E7%81%AB%E8%B1%90/","title":"55雷火豐","section":"下经","content":" 55雷火豐 # 此卦莊周説劍,臨行卜得之,果得劍也。\n圖中:\n竹简灰起,龍蛇交錯者,官人著衣裳立,一合子,人吹笙竿,腳踏虎。\n日麗中天之課 藏暗向明之象\n萬物與人能適得所歸,必能成大,故歸妹之後,受之以豐,豐有盛大之義,此卦上震下離, 有內明外動之象,能以明而動,動而能明,此為致豐之道,是故明而能照,動而能亨,可以致豐大也。\n卦圖象解\n一、竹简灰起:簡姓,竹有順象,堅節不變之象,灰起,中空之象。二、龍蛇交錯:辰巳之年,正邪相爭,蛇虺乃毒謀暗算也。\n三、官人著衣裳立:官人、倌人、丈夫也,藏於內也。四、一合子:先成後破。\n五、人吹笙竿:閒情逸志,主喜事臨門。六、腳踏虎:臨危不亂也,險在下也。 七、旱沼之旁:心力交瘁也。\n八、池中無水:旱象,破財也。九、珠落盤中:先聚後散也。\n人間道\n豐:亨,王假之,勿憂,宣日中。\n豐之道,在能致亨通,能令天下百姓致豐,唯王者能。豐之道在令人民繁盛,事物豐發, 有如日中之明,無所不照,則可以無憂。\n彖曰:豐,大也,明以動,故豐。王假之,尚大也。 # 豐之道,在論如何盛大,能明而動,必能成豐盛且大也。王者所追求之,為能及天下之大, 致人民以豐,必可永保其治世之道。\n勿憂宜日中,宜照天下也。 # 致豐之道,能如日中之盛明,普照天下,無所不及,如此則可無憂,而能保其豐大及永久也。\n日中則昃,月盈則食,天地盈虛,與時消息,而況於人乎?況於鬼神乎? # 日正當中,也有暗時,月滿之後亦有缺虧,天地之盈虛,靠時為消息,更何况乎人?何沉\n乎鬼神?此君子戒之在盛,知盛時须戒,方可長久。\n象曰:雷電皆至,豐,君子以折獄致刑。 # 電雷並行,乃明動相濟,成豐之象,君子觀明動之象,知用刑制法,在求明與威並行,君子能明則無折獄,慎於用刑。能立威則民服無怨。\n初九:遇其配主,雖旬无咎,往有尚。 # 初進之陽剛,居豐之時,知往有豐,雖非有君之對應,但有其左右高位之人能與同志, 此往可吉也,所謂「同舟能共濟也」,此於豐之時,可得無咎也。\n六二:豐其蘚,日中見斗,往得疑疾,有孚發若,吉 。 # 六二乃柔將其位,又居明卦之中,為至明之才,但因所遇之君不明,而無法下求於己, 若居豐時,意往而求其君能明己才,必招致猜妒疑惑。惟有盡己之至誠,以求其感而能發,\n九三:豐其沛,日中見沫,折其右肱,无咎。 # 九三乃居相位有賢才之人,於豐時,卻不見明主之相應,其日之晦更甚於六二將位,故有如人之折肱無法於行,賢能之人有才,但無法發揮乃因君之不明,故無可歸咎也。\n九四:豐其薜,日中見斗,遇其夷主,吉。 # 九四為陽剛之人居君側大臣之位,遇君不明,其賢能陽剛受圍,如日中有缺晦,不得其用,亦無用也,故君子之才須得遇明主,方可有用能成濟世之功。\n六五:來章,有慶譽,吉。 # 陰柔居君之尊位,己之才不足,但知用下位章美之才,必有福慶,且有美譽故吉。\n上六:豐其屋,蔀其家,窺其户, 闋其無人,三歲不覿,凶。 # 上六居丰極之時,因處動之終,必燥動, 人於丰盛之時,必假謙虛方吉。如不知戒盛, 外丰其居,內不明,暗藏其家,又目中無人, 三年又不知變,一意孤行,终自招凶。\n象曰:雖旬无咎,過旬災也。 # 聖人知時之所至,順時而為,能知降己以相求,若懷己之私意,必招患至。\n則君之昏蒙可開,如此則吉矣。\n象曰:有孚發君,信以發志也。 # 用己之至誠孚實,以求發上之知信,如可成,則吉必至矣。\n象曰:豐其沛,不可大事也,折其右肱,終不可用也。 # 豐之時,不見上用,無法成大事,猶人折其肱,終無法受重用。\n象曰:豐其蔀,位不當也。日中見斗,幽不明也。遇其夷主,吉行也。 # 有才能卻受圍困,無法致豐,乃位不適當也。豐之時,又有幽暗之處必因君不明,臣位又不適當而造成。惟求遇明主,吉乃可行。\n象曰:六五之吉,有慶也。 # 君位能吉,於豐之時,必可有吉慶及於天下。此因君能用才也。\n象曰:豐其屋,天際翔也,窺其户, 闃其无人,自藏也。 # 人處豐之極,不知謙卑,目中無人,猶鳥之翔於天際,不知己才之不足,居高自傲,必招人棄絶,其致如此,乃不知自藏也。\n"},{"id":45,"href":"/zh/docs/culture/%E5%A4%A9%E7%BA%AA/%E4%BA%BA%E9%97%B4%E9%81%93/%E4%B8%8B%E7%BB%8F/54%E9%9B%B7%E6%BE%A4%E6%AD%B8%E5%A6%B9/","title":"54雷澤歸妹","section":"下经","content":" 54雷澤歸妹 # 此卦舜娶堯二女,卜得之,乃知卑幼不寧也。\n圖中:\n官人騎鹿指雲,小鹿子在後,望竿上有文字,人落刺中,一人拔出。\n浮雲蔽日之課 陰陽不交之象\n渐乃進也,人進必有所歸,所以歸妹次渐也,少女,人所悦也,雷為動,今如人之動以悦, 此因悦而動,必有所不正,此卦澤上有雷,雷震而澤動,相從之象,男動在外而女從之在內, 有女嫁歸男之象。人之動须以明,明而後動,方不失正。\n卦圖象解\n一、官人騎鹿指雲:浮雲蔽日,乘亂而欲進。二、小鹿子在後:生意人,隨從狀。\n三、望竿上有文字:旦夕而亡,揭竿而起,正名也。四、人落刺中:如鲠在喉,去之不得。\n五、一人拔出:一人相救示寵狀。六、此卦問病凶,問財吉,問政凶。\n人間道\n歸妹,征凶,无攸利。\n此動之以悦,必有不當,如動反招凶,所往必不利。\n彖曰:歸妹,天地之大義也。 # 此言陰陽之道,男女之配,乃天地間之常理也。有男居上女在下,陰從陽動。\n天地不交而萬物不興,歸妹,人之終始也。 # 天地如不交則萬物必不生,女之從男,為生生之道,人類從男女之交而生,其終必不窮\n説以動,所歸妹也,征凶,位不當也。无攸利,柔乘剛也。 # 此動之因喜悦對方為少女,人如只因悦而動,必失明之道,動必凶至。此不以正道,而以悦道,故位不當也。男女尊卑,夫唱婦隨,人之常理,如不以常道,惟私欲作與,柔勝於剛, 所以凶也,往必不利矣。\n象曰:澤上有雷,歸妹,君子以永終知敝。 # 君子觀雷震於澤上,澤隨雷而動,猶男女相配,生生不息之象,其有永終之戒,因物久後必生敝壞,君子於始初乃知戒敝壞之理,故凡事之長久而吉,必於初始即生戒故謂永終之戒。\n初九:歸妹以娣,跛能履,征吉。 # 娣,有賢良正德之義,女之嫁歸能如此, 又知處卑順,然因其位卑,即令有賢才亦只能助夫而已,獨善其身,猶跛之能行,必無法及遠,但往乃得吉。\n九二:眇能視,利幽人之貞。 # 九二乃陽剛之賢居正位,於歸妹之時,乃意女之賢能所配不良之人,猶如目眇之人,其視必不能及遠,此時惟隱藏其賢,且堅心以正禮,可利也。\n六三:歸妹以須,反歸以娣。 # 六三乃相位居陰柔之人,於歸妹時,以悦而求上應,不以正道,故必不得其歸,無所適從。必當反歸求處卑下,則可吉矣。\n九四:歸妹愆期,遲歸有時。 # 四柔陽剛居之,即意處柔乃婦人之常道, 但內有剛明之賢,處歸妹之時,因賢明又居高位,人所願娶,但卻有遲嫁之象,非不嫁也, 乃待時而動,有佳配而後行也,此遲歸有時。\n六五:帝乙歸妹,其君之袂,不如其娣之袂良,月幾望,吉 。 # 六五居尊位之人,柔性象女,示女之高貴者,今居歸妹之期,雖為帝女,於歸時,乃得屈而謙降,故女之歸以能謙降為美德,但求於禮,不求於外飾,故猶月陰之盛,而不至於盈滿,此乃能為吉。女子尊貴之道即此。\n上六:女承筐无實,士刲羊无血, 无攸利。 # 女歸之終至極時,乃承繼祖先祭祀之職, 但至極則無實可祭,猶士人之割取羊血以祭禮,如割羊無血無以祭,必不利繼往也。\n象曰:歸妹以娣,以恆也。跛能履吉,相承也。 # 女嫁歸男,能自處卑順,且悦於內,持之以恆,即令跛者行路,亦可以有吉,其因乃其能相承相助也。\n象曰:利幽人之貞,未變常也。 # 守其隱於內之才,堅心不變,此不失夫婦常久之道也。\n象曰:歸妹以須,未當也。 # 女歸嫁男,如求己之须,無法求外應合, 必不當也。\n象曰:愆期之志,有待而行也。 # 此之延期所由皆己,不由他人,因己之有賢,故能如是。\n象曰:帝乙歸妹,不如其娣之袂良也,其位在中,以貴行也。 # 此尚禮而不尚外飾,為帝乙歸妹之道,五為柔而居尊位,乃有尊貴而又知能行中道之人也。\n象曰:上六无實,承虛筐也。 # 女歸嫁之極處,以柔居之,猶空筐之無實, 必不可以承祭祀,女終不可承繼也,必主人人離絶也。\n"},{"id":46,"href":"/zh/docs/culture/%E5%A4%A9%E7%BA%AA/%E4%BA%BA%E9%97%B4%E9%81%93/%E4%B8%8B%E7%BB%8F/53%E9%A2%A8%E5%B1%B1%E6%BC%B8/","title":"53風山漸","section":"下经","content":" 53風山漸 # 此卦齊晏子應舉,卜得之,後果為丞相也。\n圖中:\n一望竽在爽高處,一藥爐在地,一官人登梯,一枝花在地上。\n高山植木之課 積小成大之象\n艮止之後,必有所生,其生乃因前之有止,故此生必渐進,故渐為艮之序。渐,有渐進緩進之意,其進能緩,必有序,而不踰越。此卦上「巽」下「艮」,巽為木,而生於山上,此木之高乃因在山之上,故渐之道在知高之因,在知進退消息之理據。\n卦圖象解\n一、一望竿在爽高處:家人望歸象。二、 一藥爐在地:平安,無災也。\n三、一官人登梯:升官之象,衣錦還鄉也,棺也。四、一枝花在地:落第象,不久之象。\n人間道\n漸:女歸吉,利貞。\n渐之道,其有序,且緩進,吾人可於女之出嫁而見之,女歸時,以渐進有序,必吉,且始終如一,更吉。猶臣之入朝侍奉天子亦同,必當有序,如失序則為欺陵主上,必生禍害。\n彖曰:漸之進也,女歸吉也。進得位,往有功也。 # 渐乃論進之道,知進必有序,猶如女之歸夫,必吉。進時能剛柔並濟,適得其位,此進必有功也。進以正,可以正邦也。其位,剛得中也。止而巽,動不窮也。以正道而進,可以興邦正德也。故凡進皆須稟持以正道則吉。正得其位之定義,必以剛中而得,方可謂得位。內止外順,其人之進如此,方可得吉,如進之以欲,乃生燥,必失渐道,阻力乃生,故能做到內無欲, 而外和順,此進方可無窮無盡矣。\n象曰:山上有木,漸.,君子以居賢德善俗。 # 木在山上,其高有自,故君子觀渐象,乃知居賢能正德,美化風俗,其能成功,皆歸之在渐,人之錯習,遽改生反,教化之於人,勢必以渐進方可入於人心,此渐之道也。\n初六:鴻漸于干,小子厲,有言,无咎。\n鴻鳥之以時而遷動,又群聚而生且有序, 此渐也。初陰居卑下,上又無援助,君子知時, 故處之不疑。但小人及無知之人只能見眼前之事,不知患於未來,洞察事理,唯以剛而求進, 失渐矣,進則生咎,不進無災。\n象曰:小子之厲,義无咎也。 # 初始時,雖有小人之危懼不安之心,但因於義理仍合,故有無咎。\n六二:鴻漸于磐,飲食衎衎,吉。 # 柔居柔位,得中位得當,居渐之時,進必不速,穩若磐石狀,其能安居如此,故可飲食和樂,其吉必然。\n九三:鴻漸于陸,夫征不復,婦孕不育,凶,利禦寇。 # 九三陽剛居下卦之上,即將進入上卦,意言,人之將上進之時,理應守正道以得時至, 萬不可以欲而進,以遂私志,此己失渐道,猶為求進而不顧於道之夫,婦人受孕不以正,亦不可育同義,此凶即致矣。惟可堅守正道,摒除邪念,可得吉。\n六四:鴻漸于木,或得其桷,无咎。 # 此陰柔在高位,下有陽剛欲進之人,必不得安處,猶鴻鳥近於木,立木高處,必不安也, 如能立在横枝上,自處安寧,則可無災。\n九五:鴻漸于陵,婦三婦不孕,終莫之勝,吉。 # 鴻鳥所止於至高之地,象君之尊位,此渐之時至,乃知惟下二爻位,可與同心同德,居中之三,四位相隔,猶即令婦人三年不孕,亦無用於事,相隔正道之不正,其終必消,正道必合,惟時較久而已。終吉。\n上九:鴻漸于陸,其羽可用爲儀, 吉。 # 鴻能飛於天上,毫無阻礙,人之賢能如此, 進至終極,又不失渐之道,其賢能通達之能可以為表率,猶鴻之羽,可以用禮儀一樣,必吉。\n象曰:飲食衎衎,不素飽也。\n中正之人,得正位,適居之狀,能飲食安樂,心志愉快,不光是飽食而已。\n象曰:夫征不復,離群醜也,婦孕不育,失其道也,利用禦寇,順相保也。 # 夫但知征而不知復,乃因欲而失正道,叛離群類,可醜也。婦人之孕乃不正,故不可育也。皆因失其正道,唯利在堅守正道,棄絶邪惡,可因順正道而保平安也。\n象曰:或得其桷,順以巽也。 # 此意求平安自寧之道,惟有順於義,行乎正,能如此者,何處會不安呢?\n象曰:終莫之勝吉,得所願也。 # 君臣以中正之道相交,其終必合,所願必遂也。\n象曰:其羽可用爲儀,吉,不可亂也。 # 君子之進,必有以渐,有序渐循之,乃可以為吉,此進因不失序,故吉,序亂招凶。故可以為禮法而遵循之。\n"},{"id":47,"href":"/zh/docs/culture/%E5%A4%A9%E7%BA%AA/%E4%BA%BA%E9%97%B4%E9%81%93/%E4%B8%8B%E7%BB%8F/52%E8%89%AE%E7%82%BA%E5%B1%B1/","title":"52艮為山","section":"下经","content":" 52艮為山 # 此卦是漢高祖困榮陽時,卜得知,只宜守舊。\n圖中:\n猴上東北字,猴執文書,官吏執鏡,三人繩相繫縛\n游魚避網之課 積小成高之象\n震乃動也,物之動其終必止,艮,止也,故為震之序卦。此卦上下皆艮,艮為山,故有安重堅實之象,外內皆止,皆靜,此止於其所也。人能知安於止,則得艮之道也。\n卦圖象解\n一、堠上東北字:候也,停滯等候也,陳姓。二、猴執文書:候其時,待公文命令至也。三、官吏執鏡:清明之法官,政治清明。\n四、三人繩相繫縛:三人相牽連招訟事也。\n人間道\n艮其背,不獲其身,行其庭,不見其人,无咎。\n艮為止之道,人之不知止,乃私欲作為,背為不見之處,如不能見,則無欲以亂其心志人能忘我,則止矣,今人見利則欲據為己有,失止之道。知止之道,則可無咎也。\n彖曰:艮,止也,時止則止,時行則行,動靜不失其時,其道光明。 # 艮之道,言止也,動靜之間不以時,失止之義也,君子貴乎時,時可行則行,時之義於君子,則重視之,人能不失時,知所進退,道乃光明。\n艮其止,止其所也。上下敵應,不相與也。 # 艮止之道,其功在能止於適止之所,此唯聖人能成之。此卦上下二體皆為山,有因同而相敵應也,互相背而不相與也。\n是以不獲其身,行其庭不見其人,无咎也。 # 相背而不見,行不見其人,則欲無處生則能止,能止則無咎也。\n象曰:兼山,艮,君子以思不出其位。 # 上下皆山,故為兼山,乃重艮之象,君子觀艮止之道,知思安於所止,不越其本位。\n初六:艮其趾,无咎,利永貞。 # 初六居最下,乃足趾之象,趾乃人動之最先,於始動即知止,必可無咎,且須患己之性陰柔,故戒之在堅心守道。\n象曰:艮其趾 ,未失正也。 # 能止於始初,必易,乃不失正也。\n六二:艮其腓,不拯其隨,其心不快。 # 陰居陰位,得止之正道,然居二位為猶人之腿肌位,股動則肌動,二雖正道,然受三爻之影響,即中正之道,無法救助於上位時,唯勉而隨之,因言不聽道不隨,故其心不快。\n九三:艮其限,列其夤,厲薰心。 # 九三陽剛居陽位,乃意其剛極而使上下分隔,不復進退,其堅強如此,則必造成獨限一隅,而世人不相與,故無法安定其心。故止之道,貴在時宜,行止之間必以時,則吉。\n六四:艮其身,无咎。 # 居君側之人,位高權重,然於艮止之時, 當止而不能止,乃因君位過陽剛,不能信孚於臣位之人,居此際,惟自止其身,可無咎,但如臨施政,則生咎矣。意即在上位之人只能獨\n六五:艮其輔,言有序,悔亡。 # 人之所當止者,唯言與行,今陰柔居君位, 於止之時,其才不足任此位,如此則須言行一致,不可脱序,古言:君無戲言,即此,故雖己之才不足任此位,但謹言慎行,亦可無慮於敗亡。\n上九:敦艮,吉。 # 以剛陽之性居艮之終極,可見其止之道, 性之堅實如此。常人於止之時,難於持久,晚節不保等事常見,人能敦厚知止,如此堅心, 此所以吉也。\n象曰:不拯其隨,未退聽也。 # 上位之人,未能從下意,不須救助之,唯隨之可也。\n象曰:艮其限,危薰心也。 # 堅固己之剛性,不能因時知進退,必有危懼生其心內也。\n善其身,必無可取也。\n象曰:艮其身,止諸躬也。 # 居大臣之位,卻只能獨善其身,不能行止之道,乃不適其位也。\n象曰:艮其輔,以中正也。 # 君位之人能止於中道,言行不脱序於中正之道,即令無足之才能,亦可免禍故易示人之言行之重要如是。\n象曰:敦艮之吉 ,以厚終也。 # 人常於始能行止之道,至終則無法堅持到底,始終如一是易之神,艮致终能吉,乃因其能終守不變。\n"},{"id":48,"href":"/zh/docs/culture/%E5%A4%A9%E7%BA%AA/%E4%BA%BA%E9%97%B4%E9%81%93/%E4%B8%8B%E7%BB%8F/51%E9%9C%87%E7%88%B2%E9%9B%B7/","title":"51震爲雷","section":"下经","content":" 51震爲雷 # 此卦是李靜天師遇龍母借宿,替龍行雨, 卜得之,官至僕射。\n圖中:\n人在岩上立,一樹開花,一文書,一人推車上有文字,一堆錢財者。\n震驚百里之課 有聲無形之象\n鼎,器也,能主器者,必賴長子,震為長男,取其主器之義,故次鼎為震也,此卦二震相重,有奮發震驚之象,二雷相繼,重雷也。長子繼位為君也。\n卦圖象解\n一、人立岩上:特立獨行,摇摇欲墜也。危險也。\n二、一樹開花:有果也,惜乎過於短暫,少部份之人。三、一文書:消息至,公文至。\n四、一人推車:轉變之象,執行命令象。五、上有文字:得官令也。\n六、一堆錢財者:錢財散於地狀,憂心也,喪事也,求財不利,占疾病凶。\n人間道\n震:亨。震來虩虩,笑言啞啞。震驚百里,不喪匕鬯。\n震之道,能奮發,動而進,知懼而進修,皆足以亨。當震動之來,恐懼不寧不安狀,知所戒懼,則可保安,故笑言和適貌。震聲遠及百里,人無不懼而喪失,唯執宗廟祭祀之器者,不喪失不懼也。人間之至誠,莫如於祭祀時之堅心守正。\n彖曰:震亨,震來虩虩,恐致福也。笑言啞啞,後有則也。 # 震來時能知恐懼,則無患而能亨矣。此因能有戒懼反為福也,必恐而知自修,不敢違於常理法規,因震而生法度,必能致福安,故可笑言無憂。\n震驚百里,驚遠而懼邇也。出可以守宗廟社稷,以爲祭主也。 # 雷之嚮震百里,遠受驚而近受懼,人能知戒懼不妄為,則出可以長守宗廟社稷,能如是, 則必可守承國家。\n象曰:洊雷,震。君子以恐懼修省。 # 洊為重意,上下同卦故為洊雷,形容威震之大也,君子觀之乃知恐懼自修,畏天威省思已過而改之,唯君子能如是。\n初九:震來虩虩,後笑言啞啞,吉。 # 初九:居震之始,知震之來,能於始即知戒懼恐慎,不敢掉以輕心,終必安吉。\n六二:震來厲,億喪貝,躋于九陵, 勿逐,七日得。 # 六ニ乃柔居正位,善處震時之道也,雷震乃剛動而上,無人能禦其威猛,度量知其必喪全數之資,故能遠避以自守其中正,以不追隨而得,雖不能立,即防禦,但時過後,必可得\n六三:震蘇蘇,震行无眚。 # 三位為陽位,陰居之,乃不正位也。不正位之人於平日,尚且不安,更何況處於震時居\n九四:震遂泥。 # 以剛居柔,不適位而失去剛健之道,無法震奮也,如泥之滯也。\n六五:震往來厲,億无喪有事。 # 君位之人居震動之時,必不失中道,即令危亦不為凶矣。中道勝於正道,有中道之人必不違於正道,正道卻不必一定為中道。此其差異所在也。\n象曰:震來虩虩,恐致福也,笑言啞啞,後有則也。 # 雷震之來知懼進修,此因知恐而終有福吉。不違於常法,則可保平安也。\n象曰:震來厲,乘剛也。 # 震之至剛而來,如欲駕乘,乃自招危厄。\n此如能知己力之不足而求去,亦可無咎。\n象曰:震蘇蘇,位不當也。 # 處不當位,即震來而不知戒,不知恐懼也。\n象曰:震遂泥,未光也。 # 震之動必以剛為本,今動如滯泥,已失剛道,震之義,必無法光大也。\n象曰:震往來厲,危行也,其事在中,大无喪也。 # 震之動不以中,往来皆有危,其危之生, 乃因失中之道,居此時,以無喪失為至善。\n上六:震索索,視矍矍,征凶。震不于其躬,于其鄰,无咎,婚媾有言。 # 陰柔之人居震之極,驚懼至甚,無志抖索狀,其視瞻亦不能安定而明,其於此時,若動, 必招凶。乃不明而動之戒。震之戒在不及身而及於鄰時己生戒心,則終無咎,如婚事之初有爭言,則己生戒心,莫俟及於身,已不及矣。\n"},{"id":49,"href":"/zh/docs/culture/%E5%A4%A9%E7%BA%AA/%E4%BA%BA%E9%97%B4%E9%81%93/%E4%B8%8B%E7%BB%8F/50%E7%81%AB%E9%A2%A8%E9%BC%8E/","title":"50火風鼎","section":"下经","content":" 50火風鼎 # 此卦秦君卜得之,乃知得九鼎以象九州也。\n圖中:\n雲中月現,鵲南飛,一子裹席帽,一人執刀,貴人端坐無畏,一鼠。\n調和鼎鼐之課 去故取新之象\n能使物分隔,為鼎,故鼎之用在能革物,能使水火不同之物相合,易生為熟,易堅為柔, 使相合而不相害,此革物之功也,故鼎為革之序卦。此卦上火下風,乃木入火中,烹飪之象, 鼎之象即此。為器之大用。\n卦圖象解\n一、雲中月現:顯象,撥雲見月之象,清明也。二、鵲南飛:冬至來臨,北人執政。\n三九一\n三、一子裹席帽,不明之人,無知之人。果喜也。四、一人執刀:武官也,護衛將軍。\n五、貴人端坐無畏:君王,老闆等人,至公無私。 六、一鼠:子年,暗謀之人,內有陰謀,肖鼠人也。\n人間道\n鼎:元吉、亨。\n鼎之道,能使不合之物相合,故必吉亨。\n彖曰:鼎,象也。 # 鼎之為器,法自象也。古人以方代表實且正之象。兩耳對峙在上,三足分峙於下,周圓內外,皆有法而正,成安重之象。\n以木巽火,烹飪也。聖人亨以享上帝,而大亨以養聖賢。 # 用木就火,烹飪之象,鼎器人賴以生,故聖人以鼎享上帝,用大亨以享聖人。\n巽而耳目聰明,柔進而上行,得中而應乎剛,是以元亨。 # 人能如卦才,外明內順,上之頭面能中虛為明,乃耳聰目明之象,柔乃在下之物,能進上位,以明居尊,得乎中道,而陽剛之道相應,此乃元亨之因也。\n象曰:木上有火,鼎,君子以正位凝命。 # 木之上有火,生火烹飪之象為鼎,君子觀象,知法象器,形體端正且安重,以正其位也\n初六:鼎顚趾,利出否。得妾以其子,无咎。 # 初六乃最下之位,與四爻相應,今居鼎時, 有如趾之向上,顛也。鼎覆則趾顛,此非順道, 反道而行之理,利於天下敗壞之時,乃可為也。居卑下而從陽如妾,妾之從夫,則可無咎。\n九二:鼎有實,我仇有疾,不我能即,吉。 # 二位為中位,以剛居中,乃鼎中有物之實象,但於陰位,有能才而密比於陰之意,居此當求己之守正,不求於人,使之能求於我,則不正亦终就之,此吉也。\n九三:鼎耳革,其行塞,雉膏不食, 方雨虧悔,終吉。 # 九三相位,陽剛居之,乃正位,然五位之柔君不能與合,不信用之,其行必阻塞,道必不行。有才而不能得君祿命任用之時,君子必內其德,守其正道,終必吉。\n九四:鼎折足,覆公餗,其形渥, 凶。 # 四為君側之位,與初下爻相應,乃示初陰柔之小人不可用;其不勝任而敗事,猶鼎足折也。居大臣之位,所用非人,至於覆敗,此不勝任,其凶可知。成因在於任不適之才,必來\n六五:鼎黃耳,金鉉,利貞。 # 鼎之主在耳,執耳之意,有陽剛之體,又得其位,才必充實,乃能為大用,至善矣。\n上九:鼎玉鉉,大吉,无不利。 # 鼎之終,功成也,玉為剛而温,居成功之項,唯小心善處,剛柔適用,動靜不可過,乃生大吉。無所不利也。\n象曰:鼎顚趾,未悖也。利出否, 以從貴也。 # 鼎顛覆趾在上時,背道而行,只利天下敗壞之際,棄惡而從貴也。\n象曰:鼎有實,慎所之也。我仇有疾,終无尤也。 # 鼎之有實,猶人之有才,必慎擇趨向,如往之不慎,必有對立之事,於此能自求守正, 則彼必不能影響我,亦可無過矣。\n象曰:鼎耳革,失其義也。 # 有才居相位,與君之念不合,乃失其義也, 但上能明時,下之才必有能用,故吉。\n自私心作用。\n象曰:覆公餗,信如何也。 # 當大臣之人,須成天下之治,如任人之不當,以招不信,乃誤也。\n象曰:鼎黃耳,中以爲實也。 # 能執鼎耳之才,乃由其得中道也。\n象曰:玉鉉在上,剛柔節也。 # 鼎以能終為功成,致成功之地,以剛柔之並用,有法度之,無不利也。\n"},{"id":50,"href":"/zh/docs/culture/%E5%A4%A9%E7%BA%AA/%E4%BA%BA%E9%97%B4%E9%81%93/%E4%B8%8B%E7%BB%8F/49%E6%BE%A4%E7%81%AB%E9%9D%A9/","title":"49澤火革","section":"下经","content":" 49澤火革 # 此卦彭越戰項王絶糧時,卜得之,遂能承恩改革也。\n圖中:\n一人把柿全,一人把柿不全,一兔虎, 官人推車,車上一印,在大路上。\n豹變爲虎之課 改舊從新之象\n井之為用,在甘潔寒洌,如中有穢物,任其存之必敗,清之則必潔,此乃革之義,故井後, 革為其序。此卦澤在上,火在下,乃水能滅火,火亦能涸水之象,人能外悦而內明,乃革之真義。\n卦圖象解\n一、一人把柿全,乃人肺腑之言。二、一人把柿不全,一人半真言也。三、一兔:躍進之象。\n四、一虎:威權之象,大人之象,肖虎也。\n五、官人推車有一印:如為武官,則訟至。今為文官帶印,主得掛印封帥,須力求變革, 果必成。丈夫推車,乃求婚也,帶印必成。\n六、虎兔向山行:賢良大人退,後繼有人躍進同行。七、山頭四點:上位之人,黑暗不明之象。\n八、大路:轉變為新且順之象。\n人間道\n革:巳日乃孚,元亨利貞,悔亡。\n革之道,在變其故舊,始初人必未能遽信,必俟至巳日乃人心信從。革之道在求變故後能亨通,但須誠正利於天下,乃能去故從新,不因有大變而生悔。\n彖曰:革,水火相息,二女同居,其志不相得,曰革。 # 水火互息互滅,次女少女同居一室,其志不同,其所歸各異,如革也,有生變異之象。\n巳日乃孚,革而信之。文明以説,大亨以正,革而當,其悔乃亡。 # 革之雖變,始初不令人信,但如以正道,日久乃信。能明且悦,則能盡事之理,無事不察, 人心和順,必致大亨,此革之至當,必不生悔。\n天地革而四時成,湯武革命,順乎天而應乎人,革之時大矣哉! # 天地有變革後,乃能生成四時寒暑,萬物因其時節而生。王者之與,能上順天命,下應人心,改舊去新,得易之神,此革之時義大也。\n象曰:澤中有火。革,君子以治歷明時。 # 水火相消滅,為革道,除舊佈新,君子觀革之象,乃知推演歷數,知日月星辰之變,以明四時之序,則終能合於天地。\n初九:鞏用黃牛之革。 # 變革之事為大事,必有時機,有人才,有其地位,謀慮而後動,則可無侮矣。猶如用黃牛之皮來侷束,但求自固而守,不求任意妄動之義。\n六二:巳日乃革之,征吉 ,无咎。 # 陰居陰位,乃得才適用之時,此際足以去天下之弊乃上輔於君,行其正道而革之,吉而無災也。\n九三:征凶,貞厲。革言三就,有孚。 # 九三相位,居下卦之上,上有君,於此以陽剛之勢而力革時,乃過於燥動,行之必有凶。但如慎戒敬懼,守貞正之道,知順從公論,行革不為人疑出於私利,則吉,因眾必孚服而順。\n九四:悔亡,有孚,改命,吉。 # 此即以剛處柔位,近君側之人,剛柔互濟, 於革之時,行以至誠,致令上信下順,其吉必矣。\n九五:大人虎變,未占有孚。 # 九五至尊之君,能以中正之道,居革之時, 乃能力革之,其必昭著天下,不須占決,知其行為之至當,天下必信。\n上六:君子豹變,小人革面,征凶, 居貞吉。 # 革之終時,君子必從革後而大變,於至善, 小人昏愚難變,即令心智未化,但其面已改, 從上之教導。天下之事於始革必艱難,至革成, 則患無法自守,故於此須戒之守貞堅志,則果必 吉 。\n象曰:鞏用黃牛,不可以有爲也。 # 初革之時,但求能自固守其位,不可妄求有功。\n象曰:己日革之,行有嘉也。 # 時至人信乃革之,動則有喜慶。如時至而不動,則必無濟世之心,必因失時而生悔矣。\n象曰:革言三就,又何之矣。 # 革之道經再三察合,知順乎天理,乃事之至當,何須再往呢?\n象曰:改命之吉 ,信志也。 # 能改命致吉,必上下能順從信服方成,因至誠感召也。\n象曰:大人虎變,其文炳也。 # 事理之明如虎紋之明盛,則天下無不信服。\n象曰:君子豹變,其文蔚也。小人革面,順以從君也。 # 君子變革後,其更精進,如文采之蔚盛。小人因於革,不敢為惡,唯外飾臣服,以從君道,此革道成功矣。\n"},{"id":51,"href":"/zh/docs/culture/%E5%A4%A9%E7%BA%AA/%E4%BA%BA%E9%97%B4%E9%81%93/%E4%B8%8B%E7%BB%8F/48%E6%B0%B4%E9%A2%A8%E4%BA%95/","title":"48水風井","section":"下经","content":" 48水風井 # 此卦楊貴妃,私與安祿山為事,卜得之,反受其害也。\n圖中:\n金甲神執符,女子抱合,錢寶有光起, 人落井中,官人用繩引出。\n珠藏深淵之課 守靜安常之象\n困之後,如至極,則纏身動彈不得,猶陷井中,故困之後為井。物之居下者,莫如井,此卦坎上巽下,坎為水,巽為木,木在水下,必上乎水,故有汲井之象。\n卦圖象解\n一、金甲神執符:貴人在空中,祥瑞之兆也。二、女子抱合:好也,但先成後破。\n三、錢寶有光起:財不失,不露之象,不久有喪也。四、人落井中:病重象,官司沈陷象,招陷害也。 五、官人用繩引出:貴人為官人,欲相救也。\n六、符:符乃策略有傍人作對。\n人間道\n井:改邑不改井,无喪无得,往來井井。\n井,為不常改之處,常見都巿已改,但井無法遷動,井之性,汲之不竭,存之不盈,故曰無喪無得,凡人至井側,皆受其用,此不變之常德也,此為井之道。\n汔至,亦未繘井,贏其瓶,凶。 # 井之道在乎供人用為善,如人至而未用井,仍飲泉,則有亦若無。君子之道,以有成為貴, 如道即令正,但不為所用,亦等於無也。徒自招凶。\n彖曰:巽乎水而上水,井,井養而不窮也,改邑不改井,乃以剛中也。 # 木入水中,而又上於水,此井也,井能養物,不盡不竭,此井德之常也。城巿可改,而井不能遷,猶君子剛中之德,即令再艱險,卻永不改變其志。\n汔至亦未繘井,未有功也,羸其瓶,是以凶也。 # 井以供人使用為功,今人至井側,而不用井,則有井亦同於無井,無法發揮井之功用。瓶須盛水,今破壞,是以無用而致凶也。\n象曰:木上有水,井,君子以勞民勸相。 # 君子觀井象,效法井之性,為助民而任勞,且教其互助之功,此效法井之佈施無私也。\n初六:井泥不食,舊井无禽。 # 井之道,始為井之時,仍不適用也,井內初無水,其底為泥,不可食,井之德能養人, 乃其有水也。今若為廢棄之舊井,其無水,故必無禽至,猶人之無才無法濟物,必為時代淘汰。今之師即須有舊井之戒,故孟子曰:人之\n九二:井谷射鮒,甕敝漏。 # 陽剛居將位,居井之時,上不對應,因而趨下,失井之道,降下而趨泥,而成微不足道之物,如破漏之甕也,終必無功。\n九三:井渫不食,爲我心惻,可用汲,王明並受其福。 # 九三之才居相位,陽剛又居高位,才必能濟世,如井之清水升而上,可助人之力大也, 今不見人食,乃謂君明其賢不用其才,賢才之心必憂,如君能用其才,使其才能濟民,乃上下之共福也。\n六四:井甃,无咎。 # 陰柔之人居君王身側,才不足而任其位, 若但求修治井口之磚砌,自守之,亦可無咎。\n九五:井洌,寒泉食。 # 九五君位之人,能如井中之泉,甘寒潔淨, 為民所喜用,此井道之至善也。\n上六:井收勿幕,有孚元吉。 # 井以水能上出為人所用為居功,使人汲取而不竭,其利必無窮無盡,其間之性為常此不變,始終如一,廣施其德於萬民,此乃井道之成也。\n患,莫過於為師,即有舊井之戒。\n象曰:井泥不食,下也,舊井无禽, 時舍也。 # 初為陰柔,而居井之底,乃泥象,無水之泥,人必不食,無水之井亦無禽聚,此必為時所捨之也。\n象曰:井谷射鮒,无與也。 # 井,能出水助人為功用,故以能出為成功, 今陽剛之才,本可濟用於世,因上不用故成無用。\n象曰:井渫不食,行惻也,求王明, 受福也。 # 井中水清澈而不食用,乃有才智之人,不見用,其無法遂行其志而憂懷,但求得遇明君而天下受其福澤。\n象曰:井甃無咎,脩井也。 # 修治井口 ,即令無功於天下,亦因其能守而不致招廢棄,亦可為功矣。\n象曰:寒泉之食,中正也。 # 寒洌甘潔之泉水受人喜用,乃中正之道得用亦同。\n象曰:元吉在上,大成也。 # 能以至善而居卦終,乃大成也。\n"},{"id":52,"href":"/zh/docs/culture/%E5%A4%A9%E7%BA%AA/%E4%BA%BA%E9%97%B4%E9%81%93/%E4%B8%8B%E7%BB%8F/47%E6%BE%A4%E6%B0%B4%E5%9B%B0/","title":"47澤水困","section":"下经","content":" 47澤水困 # 此卦李德裕罷相時,卜得之,乃知身命無氣也。\n圖中:\n一輪獨在地上,一人臥病,葯爐, 貴人倾水救旱池魚,池中青草。\n河中無水之課 守己待時之象\n升至極,不知進退,私欲使然,終必至困,故困次升也。此卦「兌」上「坎」下,水在澤上乃有水也,今水在澤下乃枯涸無水之象,此示人之困乏象,本卦論人紀中,君子受小人掩蔽, 居窮困之時也。\n卦圖象解\n一、一輪獨在地下:獨行也無依也,方向不明也。二、一人臥病,招危難也。\n三、藥爐:有人來救,待時緩進也。\n四、貴人傾水救旱池魚:旱池,無財也,有貴人資援。五、池中青草:仍有生氣狀。\n人間道\n困:亨,貞,大人士口,无咎,有言口不信。\n困之時,如何用困之道,乃易之精神,大人居困時,以樂天安命,隨時善處,自得其樂, 必吉。有愚頑之人,居困而力求言,人必不信也。\n彖曰:困,剛揜也。險以説,困而不失其所亨,其唯君子乎? # 困之生乃因剛為柔所掩蔽,專言君子之道為小人所掩蔽,而困窮之時君子知困之道,外悦內險,處困而能以悦之度量居之,不失正道,則其道必自然亨通,能如此之人,必為君子貞, 大人吉,以剛中也。有言不信,尚口乃窮也。\n處困時,君子能吉,乃因堅守剛中之道,不變節。於困之時,所言人必不信,如反求己之口才以脱困,必更致困,此即為尚口之戒。\n象曰:澤无水,困。君子以致命遂志。 # 澤中無水,困之象。居此時君子以力盡防患之法,如仍難免,則歸之於命,泰然處之,絶不因困窮而變其志節。小人遇困,志節必變,但求附於他人,以求脱困,其終必凶。\n初六:臀困于株木,入于幽谷,三歲不覿。 # 陰柔之人位居卑下,又無上位救助,猶如無葉之木,無法蔭庇下物,因無庇蔭,故不能安居於此,如進入幽暗之谷,無法自出,有三年的時間無法入亨。\n九二:困于酒食,朱紱方來,利用享祀:征凶,无咎。 # 常人無不為酒食所困,不知酒食之施,乃生於人有所欲,常言:宴無好宴。君子之飲與小人不同,能困君子的,必是其剛正之道不足以濟天下之困,此其困擾也。居此時君子必求至誠以守,等待時機,可利用祭祀以示至誠, 俟貴人至,求之方吉。若不安居困,自往求之,\n六三:困于石,據于蒺藜,入于其宮,不見其妻,凶。 # 處困可羞之時,卻用外剛來飾內險,益增自困,必堅重如石,而不安之情如手握蒺藜之物,因刺多而不能握,進退不得狀,必失所居, 凶。\n九四:來徐徐,困于金車,吝,有終。 # 居君側之人,不以中正之道處困時,而才能又不足濟困,其來動必遲遲,困於金車之內, 必不見容於世,可羞也,如知濟困得中正之道, 乃有歸也。\n九五:劓刖,困于赤紱,乃徐有説, 利用祭祀。 # 劓,乃上有傷,刖,乃下有傷,人君之困, 乃上下無應,天下之民不來也,必起用剛正之賢才,以至誠待之,並利用祭祀,示道之至誠於天,則民必徐來。\n上六:困于葛藟,于臲卼,曰動悔。有悔,征吉。 # 困至極,必生變,如物之纏束於身,動則有悔,無所不困,即動靜皆困,必求進,方有吉處。\n象曰:入于幽谷,幽不明也。 # 自陷於深谷,乃出自己之不明也。\n其招凶皆自取也。\n象曰:困于酒食,中有慶也。 # 居困時,雖未能施惠於人,如能守其剛中之德,必能亨。而有吉慶也。\n象曰:據于蒺藜,乘剛也。入于其宮,不見其妻,不祥也。 # 此言內之不安,欲求外剛來飾,乃用剛之不正,必終失所安,果必不祥也\n象曰:來徐徐,志在下也,雖不當位,有與也。 # 居不適位,志在求下,其雖徐徐而來,即令未善,但因與正相應,必有果矣。\n象曰:劓刖,志未得也,乃徐有説, 以中直也,利用祭祀,受福也。 # 上下不應,君王之困,其志不遂,於此必用祭祀以至誠,求得天下之賢,必能濟天下之困,終享其福慶也。\n象曰:困于葛藟,未當也,動悔有悔,吉行也。 # 為困纏身,無法求變,乃未知困之道,知動有悔,求悔而去,必可出困,其行必吉矣。\n"},{"id":53,"href":"/zh/docs/culture/%E5%A4%A9%E7%BA%AA/%E4%BA%BA%E9%97%B4%E9%81%93/%E4%B8%8B%E7%BB%8F/46%E5%9C%B0%E9%A2%A8%E5%8D%87/","title":"46地風升","section":"下经","content":" 46地風升 # 此卦房玄齡去蓬萊採葯未回,卜得知,主不在也。\n圖中:\n雲中雨點下,木匠下墨解木,一人磨鏡,一架子有鏡\n高山植木之課 積小成大之象\n萃者聚也,聚之後能上,即升也,萬物咸因能聚方可升高,故升次萃也。此卦坤上巽下, 乃木在地下生於地中,長而愈高此升之象也。\n卦圖象解\n一、雲中雨點下:沾衣恩澤之象,昏暗之時將過也。\n二、木匠下墨解木:有尺度法則,有依據之象。匠—將也。以武力去災也有改朝換代之象也。\n三、一人磨鏡:可成始得之象。\n四、一架子負大木:有不小之財也。\n五、有鏡:指明鏡高懸,於商事指有競爭也。六、藍:難也,百廢待與之象。\n人間道\n升:元亨,用見大人,勿恤,南征吉。\n升之道,在於亨通,用此道来見大人,不靠体恤而能進,必吉。\n彖曰:柔以時升,巽而順,剛中而應,是以大亨。 # 柔即順也,順時而升,以柔而進,此升之得時矣,二剛陽之位,得五柔順之君應則能大亨也。\n用見大人,勿恤,有慶也。南征吉,志行也。 # 用巽順剛陽中正之道來見大人,必遂心志得升,從不去憂升之不遂,此吉慶也,前進則必升,必能遂行其志也。\n象曰:地中生木,升;君子以順德,積小以高大。 # 地中生木,木長而高,此升也。君子觀升象,乃知修身之德,由積累微小,乃至高大,故積不善,則不足以成名,凡學問道德之高,皆由累積而成,升之義也。\n初六:允升,大吉。 # 初進時柔順於九二之剛,因得信而升,此升吉也。\n象曰:允升大吉,上合志也。 # 與上位之志合,得信而升,此因信於剛中之賢而能大吉也。\n九二:孚乃利用禴,无咎。 # 九二陽剛之臣,處升之時,因上位君柔, 絶不可矯求外飾,必求以中心之至誠來感通於上,如此則可無咎。\n九三:升虛邑。 # 九三陽剛之相,能正而順上,如此而升, 就如入無人之巿,無人抵禦。\n六四:王用享于岐山,吉 ,无咎。 # 居諸候之位,能上柔順於君王,下又順天下之賢,舉之升進,於已則柔順謙恭,不求離本位,有德如是,必吉,且無災矣。人之戒, 切記在不可無事而升,升又必量力而進,方合君子之道。今人已不復如此,無功而升,比比\n六五:貞,吉,升階。 # 君位之人性柔,必得貞正且固守之德,則人皆信而賢至,能知人善用,必因用賢而能再升,澤及天下也。\n上六:冥升,利于不息之貞。 # 升之極乃致昏昧於升,只知進不知止,此為不明,君子之人終日乾乾,無時不警惕自己, 知所進退。小人貪求私慾之心盛,乃生不知進退之行為,凶也。\n象曰:九二之孚,有喜也。 # 君臣之道,臣之事君能中心至誠,不但為臣無禍,又可遂行其大志,此可喜也。\n象曰:升虛邑,无所疑也。 # 升而如入無人之邑,則此進,必無可疑慮也。\n皆是,升之道,如出私欲,必令國致窮困矣。\n象曰:王用享于岐山,順事也。 # 居君側之人,因知順時順事,能順處之, 得坤之性,可以無咎也。\n象曰:貞吉升階,大得志也。 # 君能任用賢才,如此可致天下大治,故君王升之道在能用賢耳,必遂其志。\n象曰:冥升在上,消不富也。 # 昏昧之人已至極上,猶求升之不已,其果唯消亡而已,無復增益也。\n"},{"id":54,"href":"/zh/docs/culture/%E5%A4%A9%E7%BA%AA/%E4%BA%BA%E9%97%B4%E9%81%93/%E4%B8%8B%E7%BB%8F/45%E6%BE%A4%E5%9C%B0%E8%90%83/","title":"45澤地萃","section":"下经","content":" 45澤地萃 # 此卦韓信被呂后疑惑,卜得知,果被其戮也。\n圖中:\n貴人磨玉,一僧指小兒山路,一人救火,一魚在火上,一鳳啣書。\n魚龍會聚之課 如水就下之象\n物相遇而後,同氣相求,則生聚象,萃,聚也。物相聚成群,為萃,故次姤卦之後也此卦, 澤上地下,水之聚象,水之在地上,乃方聚之時也。\n卦圖象解\n一、貴人磨玉:進修也,專心一致也,免破損也。\n二、一僧指:指引也,局外人也,光頭人也,曾姓人也。 三、小兒山路:退引山林,以退為進象,示上坡也。倪姓。四、一人救火:救災禍也;猶行醫濟世救人之道也。\n五、一魚在火上:得救狀,或餐飲業。六、一鳳啣書:喜訊至也。\n人間道\n萃:亨,王假有廟。利見大人,亨,利貞。用大牲吉,利有攸往。 # 人之至誠專一莫過於宗廟祭祀之時,王者能令天下之人其心專一,莫過於利用宗廟,故聖人制祭祀之禮以養民之德。此聚之義大也。民聚之時,能得賢才,則必聚而正,如不正,治之不以正理,則亂之生亦由人之聚,故聚必以道,非正之道,即令有聚,亦不能安處也。於聚時祭禮用大牲厚禮,乃示慎重且富是天下必同之,今人聚而人不能亨其豐盛,則必散也。是故古今皆然,凡能與大功立大業之人,第一必得其時,第二必聚而後能用,此動必吉,天理莫及此也。\n彖曰:萃,聚也,順以説,剛中而應,故聚也。 # 萃卦,乃上悦下順之象,上位以中道用民,合於民心,下又能順從於上之政令且又剛陽相應,堅心赤誠如此,必能聚天下之人才。\n王假有廟,致孝享也。 # 王者要有聚民心之道,必立宗廟以示孝順之至誠,能順天下之人心,必以孝方成。\n利見大人亨,聚以正也。 # 聚之以正道,必能得人才治之,乃因其正也。\n用大牲吉,利有攸往,順天命也。 # 祭祀時用厚禮祭天,必可有為,此順天命也。\n視其所聚,而天地萬物之情可見矣。 # 天地萬物間皆有聚,有動,有散等,聖人觀象,可見萬物之情性也。\n象曰:澤上於地,萃,君子以除戎器,戒不虞。 # 澤上於地,為集聚之象,君子知始戒之慎,故眾民相聚之初,就也生戒,因眾聚必有爭, 私心所成,故先除兵械,乃能戒而無虞。\n初六:有孚不終,乃亂乃萃,若號, 一握爲笑,勿恤,往无咎。 # 聚之始,柔居之,陰柔之人,必無法堅守正節,為求與人同而捨其正道,但為求同,此不終之戒。人心必亂,同氣相求而生聚,或哀號作苦以求相應,其果必為眾人之笑柄,若能堅心往從陽剛正道,必無過咎,否則成小人矣。\n六二:引吉,无咎,孚乃利用禴。 # 二之位與五相對應,位雖有差距,此時乃當聚而未及合之時,如能相引聚則可無咎,此因其中正之德未變,如德變,則必不相吸引。故於群小人聚時能獨立其間,且與上位之德同,必能合,此其意也。即令不重外飾,專以至誠,則終必合矣。\n六三:萃如磋如,无攸利,往无咎, 小吝。 # 不正之人,求能與人聚合,但人皆不應, 為人棄絶,上下皆不應,唯退求事外之賢與之相應,如此則可無咎。人之動有求,即令得之, 亦可羞也。\n九四:大吉无咎。 # 九四之位,能以陽剛,與君位對應,得君臣之萃,下又得群陰之聚,此上下能因其陽剛而聚,此至善也。\n九五:萃有位,无咎。匪孚,元永貞,悔亡。 # 九五居天下之尊,有萃聚天下之力,則無災,此時必自修其德,正其位,處不以私,為得其位也。如有人不信而未能歸從,當自省是否不正,堅固中正不變之德性,則無不歸矣。\n上六:齎咨涕洟,无咎。 # 象曰:乃亂乃萃,其志亂也。\n若人之心志受人惑而亂,必不能固守節志,乃失其正道也。\n象曰:引吉无咎,中未變也。 # 萃之時,以能聚為吉,同志相求,平安, 其位有差距,因中正誠心同德必能無咎。\n象曰:往无咎,上巽也。 # 上六居柔之極,故能往而無災。\n象曰:大吉无咎,位不當也。 # 能有吉且無災之果,即令位不當,因上下與已合德,故也。\n象曰:萃有位,志未光也。 # 為王者,必以誠信以示天下,如此方有感於民,則人莫不歸矣。如仍有不從,乃已之志未能光顯也\n小人居高位,求人與其聚,而人皆不與,此可嘆也,人之棄絶,實出於已,怎可歸咎他人\n如再不知自省,反嗟嘆而致涕泣,此小人狀也。\n象曰:齎咨涕洟,未安上也。 # 小人之對人皆因貪而失所宜,以致终窮困,此起因於不能安居其位也\n"},{"id":55,"href":"/zh/docs/culture/%E5%A4%A9%E7%BA%AA/%E4%BA%BA%E9%97%B4%E9%81%93/%E4%B8%8B%E7%BB%8F/44%E5%A4%A9%E9%A2%A8%E5%A7%A4/","title":"44天風姤","section":"下经","content":" 44天風姤 # 此卦漢呂后擬立呂氏謀漢社稷,卜得之果不利也。\n圖中:\n官人射鹿,文書有喜字,二人執索, 綠衣人指路。\n風雲相濟之課 或聚或散之象\n夬,決也,潰決之後,必有遇,姤,遇也,故次夬卦。此卦,乾上巽下,即風行天下,風行時必觸及萬物,故為遇之象。\n卦圖象解\n一、官人射鹿:丈夫也,取祿狀,野外之財,射,發也。二、文書上有喜字:摇摇欲墜,根只一線,夫妻凶。\n三、二人執索:互相牽累。\n四、綠衣人指路:掌生死簿之人,信差(今日)。五、二重山:重阻也。出也。\n人間道\n姤:女壯,勿用取女。\n姤之道在戒陰壯之始,娶女本欲其柔且順從,但初進為陰,而渐進長盛,且有壯於陽者, 古来女壯必失男女之道,家道必敗。戒之在初始。\n彖曰:姤,遇也,柔遇剛也。勿用取女,不可與長也。 # 姤,乃柔之遇剛,小人之道始遇君子之陽剛,須戒之在始,勿取如是之女,不可使小人之道長。\n天地相遇,品物咸章也。剛遇中正,天下大行也。姤之時義大矣哉。 # 萬物之生始於天陽地陰之交遇,不遇則不生。但须剛遇中正之道,猶人君之遇賢臣,剛與中正合德,其道必及於天下,姤之道即在遇之始,遇之正則吉,遇之不正須始戒。\n象曰:天下有風,姤,后以施命誥四方。 # 君之道,能如風之及於萬物,方可施政令而及於四方,故遇之道能行,天下必因中正之遇, 上下配合,而政令能如風之遍及萬物。\n初六:繫于金柅,貞吉,有攸往, 見凶,羸豕孚蹢躅。 # 柅,止車之物,金,堅固意,因姤乃陰始於初而將長盛之卦,小人之道,於始初即應如止車之柅,且繫之,使不進也。此吉。如令其渐盛,不知始戒,有凶,猶羸弱之豕其雖不強, 然為陰物,故知其能跳躍之時,必消之,故小人之道於始即消除,則必不能成大,而無有作為矣。\n九二:包有魚,无咎,不利賓。 # 姤相遇之道在於專一,陽剛將位之人與五君位應,其志必專一,遇則無災,如又遇於旁人,變其志,則有悔,其不利在不能專一。陰\n九三:臀无膚,其行次且,厲,无大咎。 # 時義乃謂初進之人與上位相遇而恰合,此時居上位之人同志於初始進之人,如為求其助,而親密於下,此凶,乃行且困難,必令居不安也,如臀之無膚狀,居此時如懷危懼不妄\n九四:包无魚,起凶。 # 君側之人始遇初進同志之相應,但因其已先遇九二位之人,此遇已失,此意味臣位之人不中正,必失其民,故凶。故遇之道,必下不離散,今如下位之人散,上必失道也。\n九五:以杞包瓜,含章,有損自天。 # 九五乃至尊之位,上位下求賢才,瓜乃美實但居於下,今上位之人能屈而求下,此乃包含之美德,人君能如此,則必有遇所求之人才, 故能屈尊求下內心至誠,如有隕石自天而下, 此遇之善道也。\n上九:姤其角,吝,无咎。 # 至剛而居上,莫過於角,人之遇,必由降屈相求,巽順相應,此合之由來,如居高而剛極,持才自傲,人何將與之,此人之遠離,乃肇因於己之過亢,非他人之罪也。\n象曰:繫于金柅,柔道牽也。 # 陰柔小人之道,始進則如車之受金柅而止,不使其進,正道必存,吉也。\n柔之人志易變,必不能專。\n象曰:包有魚,義不及賓也。 # 二位乃初遇志同之時,因不事二主,當如苞苴之魚,只能及於主人,無法及於客賓一樣。動,可無咎也。\n象曰:其行次且,行未牽也。 # 相位之人求姤遇於初進之人,中有將位阻隔,故行次且困頓,知危立改,必可未至大殃也。\n象曰:无魚之凶,遠民也。 # 下民遠離,乃起因於已之不中正,或已中正,而下民不相應也。\n象曰:九五含章,中正也。有隕自天,志不合,命也。 # 九五至尊,能含有中正之德,求賢而屈下, 此存志乃合於天理,必如隕石般,天助而得良才,遇之大道。\n象曰:姤其角,上窮吝也。 # 遇之如角之亢極,以剛而遇,必致窮極末路,人必散之。\n"},{"id":56,"href":"/zh/docs/culture/%E5%A4%A9%E7%BA%AA/%E4%BA%BA%E9%97%B4%E9%81%93/%E4%B8%8B%E7%BB%8F/43%E6%BE%A4%E5%A4%A9%E5%A4%AC/","title":"43澤天夬","section":"下经","content":" 43澤天夬 # 此卦漢高祖欲拜韓信為將,卜得之,知有王佐才也。\n圖中:\n二人同行,前水後火,虎蛇當道,一人斬蛇,竿上有文字,竿下有錢。\n神劍斬蛟之課 先損後益之象\n益之至極,猶水之滿溢,必決出之,夬者,決也,必決而後止。以卦体而言,兌上乾下, 有水之聚高至頂,則潰決也。如以爻而言,五陽在下,陽之長而陰將退,有眾陽決去一陰之象, 故於人間道,則此為君子道長,小人道消之時也。\n卦圖象解\n一、二人同行:相輔相成,可成事也。\n二、前水後火:前險而後明,宜進取象,土生金象。\n三、虎蛇當道:虎,有權威之人。蛇—險奸之小人阻道。四、一人斬蛇:勇士也,得名將也。\n五、竿上有文字:揚竿而起,正名出師。六、竿下有錢:行動有利也。\n七、錢下有火:燒錢狀,主喪服。\n人間道\n夬:揚于王庭,孚號,有厲。\n夬之道,在小人式微,君子道長,必顯於公堂,使人明善惡,故揚于王庭。於此時敬戒之以至誠以命下民,使民戒之,居安思危,隨時生戒懼之心,必無後患。\n告自邑,不利即戎,利有攸往。 # 夬之道能於至善,必不尚武力,從修已開始,齊家後能治國,以道服眾,如此則可進而往。\n彖曰:夬,決也,剛決柔也。健而説,決而和。 # 五陽去一陰,下健而上悦,乃因健而能悦,能和,此決道之至善。\n揚于王庭,柔乘五剛也。孚號有厲,其危乃光也。 # 陰居陽上,此不正也,君子去之,當揚其罪於公堂,使民眾知善惡也。以衷心至誠帶領民眾,使知戒懼,防危之至,君子之道方可顯其力大。\n告自邑,不利即戎,所尚乃窮也。利有攸往,剛長乃終也。 # 夬之時,不可崇武以力取,必須從修治本身之地起,方可有利前進光大君子陽剛之道,待正道至剛時,決不可留一邪吝之道,此剛進至終可吉也。\n象曰:澤上於天,夬,君子以施祿及下,居德則忌。 # 水之聚而上於天,為夬象,其終必決於上,而灌溉於下,君子觀象法天,故知必施祿於天下,則民皆向之,於安處之時,知須防禁,則無潰散之虞。\n初九:壯于前趾,往不勝爲咎。 # 在下位剛健之人,於決之時,必強進執行, 如執行中受阻而失敗,必決之過,故凡事欲動之前,必戒之在燥,做最壞之打算,方可行進。\n九二:惕號,莫夜有戎,勿恤。 # 君子去小人之時,必思懷戒備,不鬆懈一時,如此即令夜有兵戎,亦不驚也。\n九三:壯于頄,有凶。君子夬夬, 獨行遇雨,若濡有慍,无咎。 # 九三居相位,上有君王,卻剛於果決,此決乃自任之決,必非合君意,果必招凶。故君子居夬之時,知己道之長,小人之道將消,必不獨與小人合,且面現愠色,必可無咎。今人皆自以為重要,見人之招凶,不論其因為何, 一律給予支持,此愚善之表現,徒令小人得逞, 不知反悔,反更盛其勢,此禍之端,乃肇因於人之愚善。\n九四:臀无膚,其行次且,牽羊悔亡,聞言不信。 # 陽居陰位,乃示剛決不足之人所犯之過, 陽剛正理已明極時,如己之果決不足,必招居不安,行進又難之狀,若能法羊群之群行特性, 可無悔,但剛決不足之人,令其以柔進,必不能也,故即令告之,本性如此,必不能信用。自古以來,知過能改,知善能用,克己之欲能\n九五:莧陸夬夬,中行无咎。 # 君為決定之主,居夬之時,必立決,以去小人之道,稟持中正之道,必無災。\n上六:无號,終有凶。 # 此陰將盡之時,君子道顯,小人道必消之際,惟比附於君子之道方吉,否則即令不號咷畏懼,亦終獲凶。\n象曰:不勝而往,咎也。 # 君子之行,必度量而進,知不可而力求, 必招咎也。小人無此之戒,不量力而進,其果自取其辱。\n象曰:有戎勿恤,得中道也。 # 衣有兵戎,不驚憂,乃因自處之善,能行中道,知所戒懼也,故學易之人必知時識勢, 不知如此,惶論精易。\n象曰:君子夬夬,終无咎也。 # 決必合於正理,君子於當決之時果決之, 終不至咎。常人须戒,在不明狀況之前,不做決定就是良策,切不可憑己之所好而蒙決,必害及他人。\n如是之人,必剛明者方可做到。\n象曰:其行次且,位不當也;聞言不信,聰不明也。 # 此行為受阻,受難,乃居位之人不適其職位。正理之言不信,因其聰聽不明也。\n象曰:中行无咎,中未光也。 # 人能中心至誠,必決之無過也,但人心皆有私慾,一旦涉及私欲,必不能光大中道。\n象曰:无號之凶,終不可長也。 # 如示人號咷畏懼,仍招凶,此道必不久也, 故君子思去小人之道,非斬盡小人也。\n"},{"id":57,"href":"/zh/docs/culture/%E5%A4%A9%E7%BA%AA/%E4%BA%BA%E9%97%B4%E9%81%93/%E4%B8%8B%E7%BB%8F/42%E9%A2%A8%E9%9B%B7%E7%9B%8A/","title":"42風雷益","section":"下经","content":" 42風雷益 # 此卦冉伯牛有疾,卜得之,乃知謾師之過也。\n圖中:\n官人抱合子,一人推車,一鹿一錢。\n鴻鵠遇風之課 河水溢出之象\n損而不己必生益,自古盛,衰,損,益,天理循環,損至極生益,所以繼損也。此卦巽上震下,風雷二物互相增益,雷受風則迅,風受雷激則烈,互相助威,所以成益象。損上益下, 故為益,前卦乃損下益上故損,以其義而推,吾人可知,民厚則國安。\n卦圖象解\n一、官人抱合子:倌人,丈夫也,事必先成後破。二、一人推車:因時而動之象,空坐相請。\n三、一鹿一錢:才祿俱備,有回祿之災,財有破損,憂心忡忡。\n四、不為財货,不畏時尚之變,明知先成後破,仍走馬上任,以損上益下之道,天下或事業亦能平治。\n人間道\n益:利有攸往,利涉大川。\n益,乃有益於天下之道,故可濟險難,利涉大川。\n彖曰:益,損上益下,民説无疆,自上下下,其道大光。 # 益之本義,其道在損於上而益於下,民必悦之且無盡無窮,從上而降,下之下亦受之,益道乃光顯而大也。\n利有攸往,中正有慶。利涉大川,木道乃行。 # 以中正之道助益天下,天下受福,故往吉。萬物中唯木助益人類最大,舉凡葯材,車船, 築室等皆不離木,木於平時尚未顯其要,但於艱危困頓時,則助益乃大,益道亦如是。\n益動而巽,日進无疆。天施地生,其益無方。凡益之道,與時偕行。 # 益道之動乃在乎順,動不順乎理,必不成益。天道滋始,地道成物,而化育萬物,各正性命,使益之道廣大無邊。聖人體天地之道,知能利乎天下之道,必須順乎時應乎理,因時制宜, 乃益之大道。\n象曰:風雷,益,君子以見善則遷,有過則改。 # 風與雷二物相互助益,君子觀其象,而知益於己之道,乃在見善則遷,則可盡天下之善, 有過能改,則無過矣。\n初九:利用爲大作,元吉,無咎。 # 初九為陽剛之賢,如遇六四上位之順時, 亦即居下位而有能力之賢才,能得上位之順從支持,必做大益於天下之事,此為大作,如不能持此原則,不但悔咎來臨,且又累上位,此是上位之過也。如益眾人,則必無過。今人多志得意滿,狐假虎威,欺上瞞下,唯圖利自己而已。\n六二:或益之,十朋之龜,弗克違, 永貞吉,王用享于帝,吉。 # 人能處於中正之道,又知中虛來求益,且能順從無私,天下何不能受?必不相違,日久更吉。如此則必能通上位,獲吉,人皆相從矣。\n六三:益之用凶事,无咎。有孚中行,告公用圭。 # 六三乃相位,居下民之上統管民事,如能居民上而剛決,其果為益民而決,即令是凶事, 亦可無咎。但仍須以誠意通於上,使上能信任, 如所為不合中道,即令上信亦不可為也。\n六四:中行,告公從,利用爲依遷國。 # 居君側,以柔順從君,對下又順應於初陽之剛賢,如此則能令民順而從行。\n九五:有孚惠心,勿問元吉,有孚惠我德。 # 陽剛君王,居尊心中又誠正,必能益於天下,不問可知,天下之人必至誠愛戴,因君之能施恩德也。\n上九:莫益之,或擊之,立心勿恒, 凶。 # 以剛處益之極,此專欲利己,不與眾同欲, 必招怨,或受攻撃,聖人戒之在人欲,堅心不可有私欲,否則招凶。有則須速改。\n象曰:元吉无咎,下不厚事也。 # 下位之人,本就不可擔重大之事,今得上位信賴而出任大事,必须能濟世助人,方可使上位有知人善任之譽,如只會壞事,民怨四起, 則上下皆有過失也。\n象曰:或益之,自外來也。 # 能知益天下之道,且堅守此道,必令眾人自外而來歸矣。\n象曰:益用凶事,固有之也。 # 居下凡事當稟明於上,乃得誠信。但如遇危難救災之事,可立以剛決,必能無咎,此其大義也。\n象曰:告公從,以益志也。 # 其動之志在益天下,上必信從,因其為公不為私,故古人不患上之不從,患己之不正。\n象曰:有孚惠心,勿問之矣,惠我德,大得志也。 # 人君有至誠增益天下之心,不须問,天下之民,必懷德承恩,益道大行,人君志必行。\n象曰:莫益之,偏辭也,或擊之, 自外來也。 # 若以私利增益自己,人必與爭之,必不肯之。或言辭偏,或攻擊之,皆自外來。起因皆因己心不合正理,私欲專利而成。\n"},{"id":58,"href":"/zh/docs/culture/%E5%A4%A9%E7%BA%AA/%E4%BA%BA%E9%97%B4%E9%81%93/%E4%B8%8B%E7%BB%8F/41%E5%B1%B1%E6%BE%A4%E6%90%8D/","title":"41山澤損","section":"下经","content":" 41山澤損 # 此卦薛仁貴將收燕,卜得之,大破燕軍。\n圖中:\n二人對酌,酒瓶倒案上,毬在地上, 文書二策,有再告二字。\n鑿石見玉之課 握土爲山之象\n解,散也,斥去也,其後必有所失,故損為序。此卦「艮」上「兑」下,山體高在上而澤體深在下乃有損下益上之義。如損上而益下,為益卦,取下而求益上,則為損,故居人上而施恩澤於下則益,如取下而求自足,乃損。\n卦圖象解\n一、二人對酌:二人夾木,來也。二、酒瓶倒案之:目前無指望也。三、毬在地上:所求不成狀。\n四、文書二策有再告二字:再次求,方成。\n居此之時唯求有益於人,乃得損之真道也,損其多餘,同志乃至。\n人間道\n損:有孚,元吉,无咎,可貞,利有攸往。\n損之道須持之正理,來自誠正,則損因順理而至大善,此可吉也。堅固常行必利有所往。常人之損,有過或不及或不常皆不合乎正理。\n曷之用? 二簋可用享。\n人間祭祀之禮,文繁節褥,常人多備祭品,聖人以儉為禮之本,示以至誠之心敬天,如求祭物過多,乃求飾其誠,實示人偽矣。故知凡人之慾望若有過者,其始初皆起於奉養,常久以後則成宮樓峻牆,酒池肉林,聖人有見於此,故先制其本以儉,故損之精義在損滅人欲以近乎天理而已。\n彖曰:損,損下益上,其道上行。 # 損之成,乃損下而益於上,故道乃上行。\n損而有孚,元吉,无咎,可貞,利有攸往。 # 損之道在至誠,堅心如此,乃可盡損之道也。\n曷之用?二簋可用享,二簋應有時,損剛益柔有時。 # 吾人應損去外在浮飾,使至誠見,萬事之始,必有長幼尊卑,然於事之末,往往已流於形式,損之時乃知何時須損剛益柔,此損之道必以時乃吉。\n損益盈虛,與時偕行。 # 須損須益,求盈求虛,只隨時之一念而已,易之於時義於此可見。\n象曰:山下有澤,損,君子以懲忿窒欲。 # 君子觀損之象,以損己為上,修己之道在忿與欲上,能知損己之忿與欲,乃得真損之時義。\n初九:已事遄往,无咎,酌損之。 # 損之道,在損剛益柔也。今居下之人為益上,當以功與之,不求己居,事畢則速去,不求居功,乃能無過。若求享其功之美,此離損下益上之道,不可也。\n九二:利貞,征凶,弗損益之。 # 剛中之人與君位柔性相應,居損時,用柔悦態度求應於上,則有失剛中之德,其動必凶, 乃因不知堅心於陽剛也。此適足以損也。\n六三:三人行,則損一人。一人行, 則得其友。 # 天下沒有獨一之人,必有二者,如男女之往,由其精一,故能生也。一陰一陽,無法加入故三人因志不專一,必生損一人,一人獨行, 由其志為專,故必有一友能同其志,故天地之間損義之明且大哉,莫過於此。\n六四:換其疾,使遄有喜,无咎。 # 陰柔之人居損時,須自損以從陽剛中正之道,即損不善而從善,必有喜而無災。人之損過失,唯患不速,如速則此過必不至深,為可喜也。\n六五:或益之,十朋之龜,弗克違, 元吉。 # 君王能柔順居尊位,處損之時,知虛中自損,順從在下陽剛之賢人,人君能如此,則天下人必從其德,咸以損己為美,故有眾民之公論助之,因合正理,即龜筮亦不相違,可謂大善之吉也。古云:「謀能從眾,則合天心。」\n上九:弗損益之,无咎,貞吉,利有攸往,得臣无家。 # 損道之終極,能居上而不損其下又益增在下之人,則天下莫不服從,人心歸順,無遠近內外之區限。\n象曰:已事遄往,尚合志也。 # 事畢後退居不居功,能與上合志也。\n象曰:九二利貞,中以爲志也。 # 九二陽剛之人能守中正之道,則何有不吉。志存乎中道,則必自正也。\n象曰:一人行,三則疑也。 # 一人行而能得友,三人行則易生疑,於此時須明損之道,損去一人,乃損多餘。\n象曰:損其疾,亦可喜也。 # 知損之道,得損之時,速去其不善,此可喜也。\n象曰:六五元吉,自上祐也。 # 人君能盡眾人之見,合於天地之理,天必降福祐也。\n象曰:弗損益之,大得志也。 # 居上而不損下,又能增益之,君子必能遂行其志,故簡言之,君子之志,唯在如何益於人而已。\n"},{"id":59,"href":"/zh/docs/culture/%E5%A4%A9%E7%BA%AA/%E4%BA%BA%E9%97%B4%E9%81%93/%E4%B8%8B%E7%BB%8F/40%E9%9B%B7%E6%B0%B4%E8%A7%A3/","title":"40雷水解","section":"下经","content":" 40雷水解 # 此卦項羽受困垓下,卜得之,後果士卒潰散也。\n圖中:\n旗上提字,一刀插地,一兔走,貴人雲中,一雞在邊鳴,道士手指門,道人獻書,小堠在側。\n雷行風止之課 患難解散之象\n解者散也,萬物無終難之理,難至極而散,故解為蹇之序。此卦震上坎下,外動內險,即動於險外,有出險之象,故為患難解散之時也。\n卦圖象解\n一、旗上提字:指名提凶,不利。有遠走他鄉之象。二、一刀插地:求快也,劉姓之人也。\n三、一兔走:卯年,劉姓。\n四、一雞在邊鳴:有競爭象,酉年適逢貴人救。五、貴人雲中:不及救也。\n六、道士手指門:入空門也,逃亡方向,出家也。七、道人獻書:裝飾外表,示誠象。\n人間道\n解:利西南,無所往,其來復吉,有攸往,夙吉。\n西南即坤方,坤之体本含弘光大,於天下之難方解時,人始離困苦,當以宽大簡易之法待之,切不可使煩於苛政,人心必安,再求重修冶道,立綱紀,建法治,且宜早動,如俟難復則不及矣。\n象曰:解,險以動,動而免乎險,解。 # 險則示難也,不險必不難,遇解時,如不動必不能出險,故此動是免於招險也。\n解利西南,往得眾也。 # 此即解難之道,须廣大且平易之法以待民,則民心必歸。\n其來復吉,乃得中也。 # 治世之道,必待解之時來臨方可往用,此為得宜。\n有攸往夙吉,往有功也。 # 欲動而往,則必利速,愈早前往,功愈大,遲則害已生矣。\n天地解而雷雨作,雷雨作而百果草木皆甲坼,解之時大矣哉! # 天地之氣開而和暢生雷雨,雷雨生而萬物甲坼,故解能成天地之功,故聖人知解之時,大\n也。讀易須体時之義,方成得易之道。易之道與天地合而同德。\n象曰:雷雨作,解;君子以赦過宥罪。 # 天地解散而生雷雨,君子觀雷雨之象,体會發育之功,而知施恩仁,行宽法也。\n初六:无咎。 # 初解之時,柔居陽剛,乃柔而能剛象,知剛柔之合宜,使患難解散。\n九二:田獲三狐,得黃矢,貞吉。 # 二與五陰位相應,此言陰柔之君在上,陽剛之臣在之時也,古來如君柔則小人易蔽,威必受損,又不果斷且易受小人之惑,小人一近則心動矣,處此時又逢災難初解,聖人必以能去小人來正君之心,行其剛中之道,方可吉,\n六三:負且乘,致寇至,貞吝。 # 小人本非在上之物,今居下之上位,猶負重而乘車,乃招寇至,終必悔矣。\n九四:解而姆,朋至斯孚。 # 陽剛之人居陰柔君之側時,如居上位親近小人,必使賢士遠矣,須斥去小人則君子必進, 能得人之信孚。\n六五:君子維有解,吉,有孚于小人。 # 君主之人必去小人,則君子必進,正道乃行,如不去小人,則天下不治,此可反看,即天下不治,乃小人不去也。\n上六:公用射隼于高墉之上,獲之无不利。 # 解之至極如仍未解,必有堅強之害也,故於此時可強用弓矢如射鷹隼於高處,時至而發,如無良器又動不以時,射未成,必反生害, 故此言器之重要與動之時機,獲之必吉,不成\n象曰:剛柔之際,義无咎也。 # 剛柔相接又得其時宜,必無災也。\n狐為邪媚之獸,黄乃直中也。\n象曰:九二貞吉,得中道也。 # 能得中正之道,除去邪惡乃正且吉也。\n象曰:負且乘,亦可醜也。自我致戎,又誰咎也。 # 負重乘車,招寇致其之醜乃咎由自取,又可怪誰呢。小人不量力勉居君子之位處上,必終自招盜而奪之,自取其辱也。\n象曰:解而姆,未當位也。 # 如與君子至誠以交,則必無擋,故小人如能介乎中,則示人其交必不誠至也。\n象曰:君子有解,小人退也。 # 君子能解退小人,則君子之道方行,故能行君子之道,乃因能去小人也。\n反招凶。\n象曰:公用射隼以解悖也 # 至解之終仍未解,必害之大且堅,故須強硬如射隼以解悖亂,天下可治也。\n"},{"id":60,"href":"/zh/docs/culture/%E5%A4%A9%E7%BA%AA/%E4%BA%BA%E9%97%B4%E9%81%93/%E4%B8%8B%E7%BB%8F/39%E6%B0%B4%E5%B1%B1%E8%B9%87/","title":"39水山蹇","section":"下经","content":" 39水山蹇 # 此卦鍾離末將收楚,卜得之,乃知身不王矣。\n圖中:\n日當天,旗一面上有使字,鼓五面中有一鹿、堠上千里字。\n飛雁啣草之課 背明向暗之象\n睽,乖違也,乖離必生難,蹇即難也,因時之乖違,必有蹇難,故蹇次睽為序卦。此卦坎上艮下,坎,險也,艮,止也,前有險而止不進,為蹇之意也。\n卦圖象解\n一、日當天:君明之象,吳姓。\n二、旗一面只有使字:出使,和平之象,使—二人一口,示單人也。\n三、鼓五面者:時為五更天,佔五面地,王為君王,一方之主,有多國或多島之象。四、中有一鹿:攜財福至,旅客帶財來狀,商人至也。\n五、堆子千里字:行西南,千里封候,候姓也。六、千里為重:有第二次方吉。\n人間道\n蹇:利西南,不利東北,利見大人,貞吉。\n西南為坤,坤体順,東北為艮,艮体險止,此言於蹇難之時,利處平易之地,不利止於危險,今人遇險而止,不知因險而止,乃更險也。\n彖曰:蹇,難也,險在前也。見險而能止,知矣哉! # 蹇有險阻之難也,因險在前,止而不進狀。處蹇之道,乃能見險而止,此知蹇難也,此止非真止也,乃不犯險而進之意。\n蹇:利西南,往得中也:不利東北,其道窮也。 # 蹇之時,唯利處平易之地,乃得中正之位,東北為山,險阻在前,犯險而進,道必窮。\n利見大人,往有功也,當位貞吉,以正邦也。蹇之時用大矣哉! # 蹇難之時,唯聖賢能濟天下之蹇,故用賢人能往求而成功,在位之人堅心執意如此,必能正家邦,此即知蹇之時任用賢人,適時而動,量險而行,使至正之大道能濟天下之蹇。今之為政者,不知蹇之時難,乃因不知古聖先哲之智慧也。\n象曰:山上有水,蹇;君子以反身修德。 # 山已為險阻,上又有水,故有上下險阻狀,為蹇,君子觀蹇象,知此時乃反求於己修身進德,故必省自身,以待時之至也。\n初六:往蹇,來譽。 # 此蹇之初,以陰柔無助而求進,蹇之甚也, 是知止而不進,乃因知進退而成譽也。\n六二:王臣蹇蹇,匪躬之故。 # 二爻為陰位,陰居之為正位,乃意為中正之臣,又與上五君同德而受信任,此即王臣, 今處蹇難時,即令中正之人,以陰柔之質,不能勝任,故有蹇中之蹇狀,因為中正之人,其所為必忠於君,非為私己,故即令不勝,亦因\n九三:往蹇,來反。 # 以剛居下卦之上,處蹇時,下位之人皆柔, 依附於上狀。欲再上進,又逄陰柔,無法相濟, 故有來反,即還歸也,反回其所。\n六四:往蹇,來連。 # 蹇之時以陰柔而往入坎險之深處為往蹇, 如能與同志之人,使合眾而附之,此即来連, 乃真得處蹇之道也。\n九五:大蹇,朋來。 # 君處蹇難之時,必天下處難也,故名大蹇, 此時如能得朋來助,且須為陽剛中正之才來相輔方有效。\n上六:往蹇,來碩,吉,利見大人。 # 六以陰柔居蹇之極處,蹇之極有將離蹇之態,如以陰柔必不得出,須有陽剛之助,以寛裕之大量,見有德之人,方可為吉。\n象曰:往蹇來譽,宜待也。 # 於蹇之初,進必愈蹇,宜見幾而止,待時而進,不可燥進。\n其能忠而可嘉勉。\n象曰:王臣蹇蹇,終无尤也。 # 中正之臣所為必為其君,不為私,故於蹇難時,雖未成功,但終無過也。\n象曰:往蹇來反,内喜之也。 # 下之陰柔無法獨立,必附於九三之陽剛。故吾人知,於處蹇之時,必得下之心可以求安, 此求內而喜之也。\n象曰:往蹇來連,當位實也。 # 處蹇,居於上位,能不與下往而眾來,乃得眾志也,能得眾志之人,必始之於誠實待下方可得也。\n象曰:大蹇朋來,以中節也。 # 大蹇之時,須陽剛中正之才,如臣之才不足濟蹇,只守於節義,不能濟世也,今之政客, 多屬此類,不知己之才不濟,而力進,徒顯其無能也。\n象曰:往蹇來碩,志在内也,利見大人,以從貴也。 # 六位陰柔又處蹇之極,能近陽剛中正之君,來求自濟,此以從貴之義,吉也。\n"},{"id":61,"href":"/zh/docs/culture/%E5%A4%A9%E7%BA%AA/%E4%BA%BA%E9%97%B4%E9%81%93/%E4%B8%8B%E7%BB%8F/38%E7%81%AB%E6%BE%A4%E7%9D%BD/","title":"38火澤睽","section":"下经","content":" 38火澤睽 # 此卦武則天聘尚賈至精魅成,卜此除之。\n圖中:\n人執斧在手,文書半破,牛鼠,桃開鬥掩,雁飛鳴。\n猛虎陷井之課 二女同舟之象\n家之道到窮途,則生乖違離散,故睽卦為之序。此卦上火下澤,二體相違,此睽之義,二女三女同居一室,但所歸各異,乃因志之不同也。\n卦圖象解\n一、人執斧在手:求快也,行刑之人,執法之人,有權柄也。二、文書半破:無望象,毁約也。\n三、牛鼠:秋冬吉,春夏凶。四、桃開:逃開也。\n五、門掩:牢獄之災,閉也,隔也。\n六、雁飛鳴:悲也,孤單也,報凶訊也。\n人間道\n睽:小事吉。\n睽違之道於小之事,可言吉。如與不良之人乖離,與不正之事違背也。\n象曰:睽,火動而上,澤動而下,二女同居,其志不同行。 # 睽,以卦才言火在上而動,澤在下位,此二物性反,其義猶二女三女其本志同而共處,及長所歸各異,其志不同也。\n説而麗乎明,柔進而上行,得中而應乎剛,是以小事吉。 # 內悦順而又應乎外明,麗明居上,以柔求進,又能得中正之道相應,處睽之時雖無法去天下之睽,但於小處則吉矣。\n天地睽而其事同也,男女睽而其志通也,萬物睽而其事類也,睽之時用大矣哉! # 天上地下雖睽然天陽下降地陰上升而成萬物,其事本同也。男女之質不同,但互相求之志卻同也。天下萬物不同是暌,然皆稟天地之氣而成,則相同也。是故物雖異而其本同,聖人用睽之道,能令天下不同之群眾,合而為一,故其事大矣哉。\n象曰:上火下澤,睽,君子以同而異。 # 火在上,澤在下,二物之性相違,為睽象,君子觀睽而知於世俗所同者,有時須獨異於世人,中庸曰和而不流,即義此也。\n初九:悔亡,喪馬勿逐,自復;見惡人,无咎。 # 卦之初,即睽之始時,於睽時又剛動於下, 其悔可知,於此時因不能行而喪其馬如勿逐則馬自復回。因睽時小人必眾,若棄之,則必天下而仇己乎?就會喪失坤德之含弘功能,乃致凶地。不但無法使之化善,更遑論能合之,故有必見惡人之議,所以自古以來能化姦邪為善良,去仇敵而為臣民者,皆因不拒絶見之。\n九二:遇主于巷,無咎。 # 睽違之時乃陰陽不合,且剛戾相對,即令為陽剛之中臣,當以委曲而婉轉之善道將就於主,期使之合,絶非求附君主而屈道相向也。\n六三:見輿曳,其牛掣,其人天且劓,无初有終。 # 三爻位為下卦之上,本離下卦而求進於上,今逢四之剛為阻,而下二爻之陽剛於後, 處此之時又逢乖違不和,猶牛車之行具,牛受掣阻於前,後有車輿在後,如以此狀況,又求力進,則必重傷於上,宜剛守中正之道與其對\n九四:睽孤,遇元夫,交孚,厲无咎。\n四爻近君位,君不與應,乃睽乖之時,故為孤,唯求與同德之人相合,且至誠相與交往, 能如此,雖處危,但仍可無咎也。\n六五:悔亡,厥宗噬膚,往何咎。 # 陰柔又居尊位,於睽時,其危可知,然有九二陽剛之賢相輔,可以悔亡,且陽剛之賢又能成黨,且深入之,則往而有慶也,如劉襌之庸,有孔明之輔,乃成中興之態勢。\n上九:暌孤,見豕負塗,載鬼一車, 先張之弧,後説之弧,匪寇婚媾,往遇雨則吉。 # 睽極之時,必反復正道,猶人見污濁之豕, 且全身爛泥,又車載一群惡鬼,心厭惡之乃欲張弓射之,後思之如動必反,而弗射,化仇寇為婚媾,陰陽交合而成雨,故始因疑而睽,睽至極因不疑而合,猶天地陰陽上為雨,往而遇之,則吉也。\n象曰:見惡人,以辟咎也? # 於睽時人情乖違,為求和合,若因其惡人而避之,則生眾仇於君子,故見惡人乃為避免怨咎之悔也。\n象曰:遇主于巷,未失道也。 # 此意即當以至誠以動君心,使明義理而能求相合,不可屈道迎逢,乃真未失道。今之小人明知不正,然於乖違,之時,只知迎合奉承, 為求己利,枉顧正道,故因時局而戀志之人乃真小人也。\n應,待睽終極之時必合,方為善處之道。\n象曰:見輿曳,位不當也,无初有終,遇剛也。 # 見車牛之負掣後拖曳而行,知位所不當, 聖人知理順行,知時而守,待遇剛而能合。\n象曰:交孚无咎,志行也。 # 睽乖之時,上下至誠相交,不止無咎,且其志必可行,故救睽之道,唯君子以陽剛之才, 至誠相輔,何所不能濟也。\n象曰:厥宗噬膚,往有慶也。 # 人君之才不足,如知信任賢輔,使其道能深入於己,亦可為也。今人不知己之才不足, 以為博士就是好的,不但不能成事,且造成乖違之勢愈烈,果必凶。\n象曰:遇雨之吉,群疑亡也。 # 雨之成,乃因陰陽之和,故所以合和能成, 乃因疑慮盡消亡也。\n"},{"id":62,"href":"/zh/docs/culture/%E5%A4%A9%E7%BA%AA/%E4%BA%BA%E9%97%B4%E9%81%93/%E4%B8%8B%E7%BB%8F/37%E9%A2%A8%E7%81%AB%E5%AE%B6%E4%BA%BA/","title":"37風火家人","section":"下经","content":" 37風火家人 # 此卦董永喪父賣身,卜之。後感得仙女為妻。\n圖中:\n一人張弓,一帶在水邊,雲中文書, 貴人拜綬,婦人攜手。三點。\n入海求珠之課 開花結子之象\n人之傷於外而必後返於內,故家人次明夷為序卦,此卦專論家內之道,父子、夫婦、長幼尊卑之倫理與恩義的家人之道。外風內火,乃風自火出,火愈熾則風生,有自內而出之象,故處家之道,有內明外順象。聖人之謂人能立於家,則必能施於國,以至天下大治。故治天下猶治家也。\n卦圖象解\n一、一人張弓:暗算,破壞也。\n二、一帶在水邊:事滯也,棄官也。三、雲中文書:突而來之喜訊也。四、貴人拜受:綬命也。\n五、婦人攜手:家和之象,此卦卜婚姻大吉。\n人間道\n家人:利女貞。\n家人之道,利在女正且堅,女能正,則家道正矣。\n彖曰:家人,女正位乎内,男正位乎外,男女正,天地之大義也。 # 家人之道,正位且堅心,男主外,女主內,其有尊卑有道,乃合乎天地陰陽之道。\n家人有嚴君焉,父母之謂也。 # 父母為一家之君長,無尊嚴則無人孝,無大小,則倫理廢,故君嚴則家正。\n父父子子,兄兄弟弟,夫夫婦婦,而家道正,正家而天下定矣。 # 父母兄弟夫妻各適其位,則家道正矣。能治家道,其道亦可推而治天下。\n象曰:風自火出,家人。君子以言有物,而行有恆。 # 君子觀風自火出之象,知正身之道,皆由內出,故言必有物,行必有恆,身正而能治家。\n初九:閑有家,悔亡。 # 家人道之始為初九,须以陽剛立家道,如無法則度量,則終至人情流放,必悔。\n象曰:閑有家,志未變也。 # 閑即預防之法則也,閑必於始初而立。在正志未流散之前,變動而法規之,因始立規之時,必不傷恩,不失義,如於志變後再思治, 則多有傷,必有悔。\n六二:无攸遂,在中饋,貞吉。 # 常人處家,唯骨肉親情,故常以情勝禮, 以恩去義,偏私之人多矣。獨有能剛之人不以私愛而不顧理。今以陰柔之人治家,必無可為, 唯婦人之道可以柔順,如能處正,居中協調, 則必能吉。\n九三:家人嗃嗃,悔厲吉,婦子嘻嘻,終吝。 # 嗃嗃乃嚴厲約束意,家人之道如約束過嚴,必有悔,然能令人心敬畏,也比讓婦人之放肆,喜樂無節,終至家敗而來得好。同理, 人能剛立,必知忠義,喜愛玩樂必不能正倫理, 知恩義也。\n六四:富家,大吉。 # 能有其富,於家道大吉,保有富之人,而能保其家,此吉之大也。\n九五:王假有家,勿恤,吉。 # 能剛又處君位,位尊而中正,又能順應內, 此治家之至正至善。是故修身來齊家,家家能正,則天下大治,此不須憂勞而天下自治矣。\n上九:有孚威如,終吉。 # 此言治家之道,非至誠之心不能也。己能守其规如常則人自化,如己不能守,況於他人乎?家之患必在禮法不足,下犯上,上凌下。如能守正禮法,又有威嚴,則必能吉。\n象曰:六二之吉,順以巽也。 # 今以陰柔居中,能順從而動其心,此為婦人之正道。\n象曰:家人嗃嗃,未失也;婦子嘻嘻,失家節也。 # 雖過嚴厲傷恩,但猶未失家人之道。但令婦人喜節無度,失家之道,必亂矣。\n象曰:富家大吉,順在位也。 # 以巽順之道而能保其家富,此家之大吉也。\n象曰:王假有家,交相愛也。 # 尊位又能有家之道,不但能使其順從而已,必使其能從內心而化合。丈夫能愛內助, 婦能愛其威治於家,此交相愛,為家之至道。\n象曰:威如之吉,反身之謂也。 # 此即言治家之道,以正身為本,當先嚴其身,後有威望於人,則人能服。此反身之意。\n"},{"id":63,"href":"/zh/docs/culture/%E5%A4%A9%E7%BA%AA/%E4%BA%BA%E9%97%B4%E9%81%93/%E4%B8%8B%E7%BB%8F/36%E5%9C%B0%E7%81%AB%E6%98%8E%E5%A4%B7/","title":"36地火明夷","section":"下经","content":" 36地火明夷 # 此卦是文王囚姜里,見子不至,卜得知,果子歿也。\n圖中:\n婦人在井中,虎在井上,錢破,人逐鹿, 堠上有工尺,珠聚。\n鳳凰重翼之課 出明入暗之象\n物進不已,必有所傷,故明夷為序卦,夷者,傷也,此卦坤上離下,乃明入地中之象,為晉之反卦,故義亦反,晉為明且盛,上明君下群賢並進之時。明夷乃暗君在上,明者見傷之時, 日入於地中象。\n卦圖象解\n一、婦人在井中:女人招災,或頸部災,家內有災。二、虎在外:外人虎視眈眈。\n三、錢缺:財傷也。\n四、人逐鹿:財失也,爭相為祿,逐鹿中原。五、堠在人鹿中:猴作梗也。\n六、「:尺也,工匠獨有,匠,將也,武官人。七、堠:不動也,尊位也,姓侯,肖猴。\n八、鹿回頭:回祿也,火災。九、人手上木枝:休象。\n見此課,必傷且凶矣。\n人間道\n明夷:利艱貞。\n君子處明夷之時,須知時之艱難,必求不失正道,方為君子。小人則阿諛奉承,唯利是圖, 順附勢而為,不顧正道。\n彖曰:明入地中,明夷,内文明而外柔順,以蒙大難,文王以之。\n君子處明夷之時,知明者必見傷,故求內明外順之態勢,應對大難之時,如此可內不失明, 外又可遠禍患。易之妙即在此,卦之德本亦卦之解也。\n利艱貞,晦其明也,内難而能正其志.箕子以之。 # 處艱難時,能知晦藏其明,不知藏晦,必生禍患,不能守正,必非賢人。\n象曰:明入地中,明夷,君子以莅眾,用晦而明。 # 君子觀明入地中,明夷之道,乃知不極其明察而用晦之道,於莅眾之時,如此必能容物和眾,眾親而安,猶水至清則無魚,人至明則無徒亦然。\n初九:明夷于飛,垂其翼。君子于行,三日不食,有攸往,主人有言。 # 明夷之初,乃見傷之始,暗君在上,陽剛之人不得上進,猶鳥飛而傷翼,小人害君子皆害其所以行。君子之人能見事於微末,始見傷, 則避之,即令困窮亦退避之,取適之位而往, 絶不以世俗之見而遲疑所行也。常人無法見傷於始,不知退避,至傷於己時,已不能退也。\n象曰:君子于行,義不食也。 # 義之所至,即令困窮,亦不所為不正,故君子能安之於苦悶。\n六二:明夷,夷于左股,用拯馬壯, 吉。 # 六二乃陰居陰位,得中正之德,順時知處, 乃至善也。但處明夷之時,亦不免有所損傷, 但須如馬之壯,可獲免傷而吉,如自處之道不壯,則傷之深矣。\n九三:明夷於南狩,得其大首,不可疾貞。 # 因上卦暗極,而又正居明之極,明即將與暗衝突之時也。居此,君子必前進除害,以獲之魁首,為要務,至於舊疾敗俗,必不能立刻改變,須知渐進之道,教其渐改,如求劇革必令不安,非上策也。\n象曰:六二之吉,順以則也。 # 順處且有原則,合於中正之道,故必能處明傷之時,而能保吉也。\n象曰:南狩之志,乃大得也。 # 用下位之明,來除上位之暗,唯求目的在除害而已,如不然則為背亂之事也。\n六四:入于左腹,獲明夷之心,于出門庭。 # 此爻意陰邪之小人,處近君之位,乃以柔順附於君主時,為保其固交,乃以柔邪之法, 隱敝之交而結於上。而君本陰邪之人,必受蠱惑其心,而行之於外。故君必求明,守正道, 而不為小人所煽動,所利用也,即此。\n六五:箕子之明夷,利貞。 # 此爻言暗傷至極,乃謂明見傷之主,若於此時顯示其明,必見傷害。要如箕子之知晦藏, 則可免於難,若不知晦藏其明,則害大矣。\n上六:不明晦,初登于天,後入于地。 # 明之至高而又傷於此時,本可遠照,今傷於明,必不明反昏悔,反失其道也。\n象曰:入于左腹,獲心意也。 # 此以邪惡之陰道惑於君心,而逞其私慾, 君為小人,必始終不悟也。\n象曰:箕子之貞,明不可息也。 # 箕子知晦藏其明於暗傷之時,且能不失意志,同與陰暗之人合污,其明必能不滅,小人若受禍患所逼,必失其節守也。\n象曰:初登於天,照四國也,後入于地,失則也。 # 始登於天,能明照四方,今傷於昏暗,反失其道。\n"},{"id":64,"href":"/zh/docs/culture/%E5%A4%A9%E7%BA%AA/%E4%BA%BA%E9%97%B4%E9%81%93/%E4%B8%8B%E7%BB%8F/35%E7%81%AB%E5%9C%B0%E6%99%89/","title":"35火地晉","section":"下经","content":" 35火地晉 # 此卦昔司馬進策,卜得之,後果為丞相。\n圖中:\n文字破,官人掩面,毬在泥上,雞啣秤, 枯木生花,鹿啣書,一堆金寶。\n龍劍入匣之課 以臣遇君之象\n繼壯盛之後,則必進,晉,進也,明出於地,升而明,故晉,此論進之道,離,中虛為明, 乃明出於地上也。\n卦圖象解\n一、文字破:官事不成,退位象。二、官人掩面:無顏見人,悲象。三、毬在泥上:所求必陷險也。\n四、雞啣秤:雞,酉也,肖雞人也,落日之象。秤:示公平,稱心也。五、枯木生花:晚發也。\n六、鹿啣書:祿與命到也。\n七、一堆金寶:財祿豐收之象,占疾厄,則有焚金紙之象。主喪服,二人去一也。八、人立水邊:恐泣之象,主喪服。\n九、三點在石上:心象,示人心在石上,不動念,堅心也。\n人間道\n晉:康侯用錫馬蕃庶,晝日三接。\n有能治安之諸侯,名康侯,其因能明,下體順附,承王之象,則見眾多之馬駕,有重賜, 乃寵遇之至也,此上明下順,乃進盛之時。\n彖曰:晉,進也。明出地上,順而麗乎大明,柔進而上行,是以康侯用錫馬蕃庶,晝日三接也。 # 晉乃明進而盛,此外明內順,故能包明至大明,如順德之良臣,附麗於明主也。柔居君位, 能明而順,上附於君,下順於民,故為康侯,此為諸侯之道也。\n象曰:明出地上,晉,君子以自昭明德。 # 君子觀明出地上,近明為晉,故知惟昭明德於已,求己之明且有德,為晉之道。\n初六:晉如摧如,貞吉,罔孚,裕无咎。 # 初爻乃晉之始時,始進而求快升,必不遂其意,因上位未能遽信,必當自守安位,寬裕不求,以待上位之信方可。小人始進而求上信, 則必傷於義,皆招咎悔。此君子方知進退之道。\n六二:晉如愁如,貞吉。受茲介福, 于其王母。 # 進退之間守正道,當吉,二爻之位雖無援, 因能守正,日久彰顯,自得上六五君位之人相應,上位自當求之,因德同也。\n六三:眾允,悔亡。 # 柔居剛位,又不中正,此乃居不適位時, 必有悔咎,惟居此時,能明謀從眾,順應天心, 吉。\n九四:晉如鼫鼠,貞厲。 # 陽剛居陰位,非適其位而居之,乃貪據其位,貪而畏人者,範鼠也,近君之位又不適, 下位又求上進,居此時生畏忌之心,必生危矣。\n六五:悔亡,失得勿恤,往吉,无不利。 # 柔居尊位,乃不正位,本凶。但用大明之德而上下皆附順,故反吉。因下之順附,必能盡誠委任眾人之才,以濟天下。明君不患其不能明照,患其用明之過於察察,有失委任之道, 自任其明而以私意偏任,天下必無法為公。\n象曰:晉如摧如,獨行正也。裕无咎,未受命也。 # 進退之間,惟君子能獨行正道,唯義之所趨,方可出仕,未受命時,皆寬裕無求。若能信於上,忠職守,卻反失去職務,則君子一日不居,必退而遠之。\n象曰:受茲介福,以中正也。 # 雖居下位之人,能永持中正之道,久必有亨。更加有明君在上,雖位遠,但必因同德受福。\n象曰:眾允之志,上行也。 # 能順從眾志,居下位之上,又上從明君, 合於眾志。\n象曰:鼫鼠貞厲,位不當也。 # 不正又居高位,時懷貪而懼失之心,堅守此不放,招危險也。\n象曰:失得勿恤,往有慶也。 # 有明明之君,下又上附順,能推誠委任, 必能成天下之大功,往必有福也。\n上九:晉其角,維用伐邑,厲吉, 无咎,貞咎。 # 陽剛又居最上,如角之象,此言進之極, 過剛則易失於躁急,本剛又急於求進,必有失也。此惟有用於治內(伐邑〕可以無患。但如用其治外,反凶。觀人之自治,能剛之人守道必固,求進燥急之人必遷善益速,唯須合中正之道,可無咎。\n象曰:維用伐邑,道未光也。 # 盡善之道,能安內攘外,今維用伐邑,能治內不能治外,其道未光大也。\n"},{"id":65,"href":"/zh/docs/culture/%E5%A4%A9%E7%BA%AA/%E4%BA%BA%E9%97%B4%E9%81%93/%E4%B8%8B%E7%BB%8F/34%E9%9B%B7%E5%A4%A9%E5%A4%A7%E5%A3%AF/","title":"34雷天大壯","section":"下经","content":" 34雷天大壯 # 此卦唐玄宗避安祿山亂,卜得之,乃知不久亨通也。\n圖中:\n北斗星,天神執劍,一官人燒香拜, 一猴,一兔,一犬。\n羝羊觸藩之課 先曲後順之象\n君子之退而伸道,多數君子之退且聚合,而成大壯之勢,壯有進且盛義,陽剛而震動此以剛而動,乃大壯之象,猶雷威震在天上也。\n卦圖象解\n一、北斗星:帝君,老闆,主事之人。二、天神執劍:民意歸向,人心也。\n三、一官人燒香拜:拜求於天,而不求人,凶。\n四、一猴、一兔、一犬:侯,劉,狄姓,在野之人。五、猴回頭:回吼。\n六、兔回頭:回吐。七、羊回頭:回陽。\n此卦示意過剛必招凶,而猴、羊、兔為三貴人,和中近易為處之道。\n人間道\n大壯:利,貞。\n大壯之道利在正,理正而勢正,如不得正而壯,只強猛無理而已,非君子之壯。\n彖曰:大壯,大者壯也,剛以動,故壯。\n大且壯為大壯,乾之至剛而動,外動內剛狀,名壯。\n大壯利貞,大者正也。正大而天地之情可見矣。 # 天地之道可運行而長久,乃至大至正始然也,此道之至大能如是。\n象曰:雷在天上,大壯,君子以非禮弗履。 # 君子觀雷震威在天,此之壯大,而行大壯之道,知唯克己復禮最大。能自勝之人為天地之最壯者,能赴湯蹈火,此唯武夫之勇也。君子能和而不流,大立而不倚。\n初九:壯於趾,征凶,有孚。 # 初爻在下,而陽剛居之,乃初進而用壯, 必凶,吾人居上用壯,猶須正方可行,初進而剛壯,必招凶辱。\n象曰:壯於趾,其孚窮也。 # 在下位而用壯,即令行可信,亦必招窮困阻滯也。\n九二:貞吉。 # 二位屬陰位,陽剛居之,乃示人剛柔得中也,人能識得時義之輕重,則必吉。\n九三:小人用壯,君子用罔。貞厲, 羝羊觸藩,羸其角。 # 小人尚力,只用其壯勇,而君子用罔,因其志剛,無視於非正之事,無所忌憚也。萬物莫不用其壯,猶羊之壯於首角,一遇藩籬,亦受困也,故用壯,不可強力進,須持之正道, 無忌憚於邪吝,方為真罔。\n九四:貞吉,悔亡,藩決不羸,壯于大輿之複。 # 時方道之長盛時,如有小失,則必害進之勢,猶大車其輪軸必壯,乃利於行,如輪軸不強,以致大車之不行也,有如藩籬之決開而不復其困也。\n六五:喪羊于易,无悔。 # 羊性喜群行又互觸其角,猶諸陽並行,如以力制,則必難勝有悔,唯其平和得之,則無所用其剛,使其壯失於和易之道,可以無悔。\n上六:羝羊觸藩,不能退,不能遂, 无攸利,艱則吉。 # 陰柔又處壯極,猶羊角之觸藩,進退不得, 必無所往也,陰柔處壯時,一遇艱困,必不能守,往即凶也。吾人須居艱困時而處柔,必吉。此壯之極,須變義也。\n象曰:不能退,不能遂,不詳也。 # 象曰:九二貞吉,以中也。\n能得中正,而堅持守之,也吉乎,此意中而不失正。今人中立而為己,已失正道,猶牆頭之草,可嘆也。\n象曰:小人用壯,君子罔也。 # 小人用其強壯之武力,終困,君子志氣剛強,無視邪惡,無所忌憚。\n象曰:藩決不羸,尚往也。 # 四位未至極,以當盛之陽,又用壯以進, 無能阻之,即令藩籬隔而不困其力也。\n象曰:喪羊于易,位不當也。 # 柔居尊位,位不當也,猶人君之勢不足, 下又壯進,才有所謂『治壯之道』,治壯之道不可用剛,須以平易近人待之,可化壯也。\n艱則吉,咎不長也。 # 此因自處不詳不慎,以致進退不能也。以柔處艱時,即有咎,亦不長久。\n"},{"id":66,"href":"/zh/docs/culture/%E5%A4%A9%E7%BA%AA/%E4%BA%BA%E9%97%B4%E9%81%93/%E4%B8%8B%E7%BB%8F/33%E5%A4%A9%E5%B1%B1%E9%81%AF/","title":"33天山遯","section":"下经","content":" 33天山遯 # 此孟嘗君進白狐裘,夜渡函谷關,卜得知, 果脱身也。\n圖中:\n一山,一水,酒旗上文字,官人踏龜,月半雲中,幞頭上樹,樹下人獨酌。\n豹隱南山之課 守道去惡之象\n恆者,久也,久不動則有去意,遯乃退也,故遯所以繼恆也,此卦天下有山,天陽向上, 物雖上陵但止而不進,以陰陽之道論,二陰居下有上進之勢,陽將消退,故此時乃論小人渐盛, 君子退避之時,此遯之義也。\n卦圖象解 # 一、一山:阻也。二、一水:險也。\n三、酒旗上文字:酒為忘憂之物,文在旗上,受人提攜也。四、官人踏龜:歸,鬼也;玄武自來,水上求財。\n五、幞頭上樹:求去辭官也。\n六、月在雲中:主半清半濁象,事未明晰。七、人在水邊:等待平安狀,蒞臨也。\n八、樹下人獨酌:休也,無事也,萌生退意狀。\n人間道\n遞:亨,小利貞。\n遯,乃君子潛藏之時,君子退而伸其道,人退道不屈,則可亨,雖無大力,但仍有小吉, 因知時避退,此善之道也。\n彖曰:遯亨,遯而亨也。剛當位而應,與時行也。 # 君子處遯之道,知退生吉,退所以為伸道。故雖退之時,君子處之,未必真遯,當隨時消息,有可以致力,當不避之,以扶持其道,並非於遯時,藏而不為也。\n小利貞,浸而長也,遯之時義大矣哉! # 小人之道長時,不可求大功,但利於小功,君子之人能察於其微,始即深以戒之,退而伸道,不使道消,此處遯之時意義之大處也。\n象曰:天下有山,遯;君子以遠小人,不惡而嚴。 # 山在天下,由下起而上止,天上進分之相違,此遯之象,因違和而須遯。君子遠小人之道, 若以惡聲厲色,只增其怨忿而已,唯用莊嚴威色,使其敬畏,小人自然遠矣。\n初六:遯尾,厲,勿用有攸往。 # 因遯本即退去,故初爻為往退之初,反為其尾,此時以柔處遯,不可往,往則有災。\n六二:執之用黃牛之革,莫之勝説。 # 二與五位相應,居正而相親合,其交之堅, 若牛革之繋,不可言語。\n九三:係遯,有疾厲,畜臣妾,吉。 # 遯退之道,其貴在速及遠,如有事累,則不速,於遯時則有危也。此爻即言,人之親, 若以女子小人而言,其皆懷恩而不知義,親愛之則忠,犯錯而嚴厲時,則生暗害,此小人之道。然君子之道非如是,待之以正,即令有拖累亦不有災,如君王有危而不棄黎民,雖危亦\n九四:好遯,君子吉,小人否。 # 君子之人亦有所好,處退之時,必去而不疑,以道來制欲,故為吉,而小人反之,其不能以義處之,唯私慾之所好,即令身陷亦不能自制,故於小人則否。故君子必易出難進,小人則易進難出。\n九五:嘉遯,貞吉。 # 君王處得中道,動靜間必以時,故嘉美, 終吉。今人卻動靜皆以私慾,表裡不一,不以中正之道處,知進不知退,此凶也。\n上九:肥遯,无不利。 # 此退之極致,退求遠且無累於他人,此退之有餘,無往不利矣。今人即令退,亦追求有所牽累他人,其動不正,唯自苦而已,小人自招之累也。\n象曰:遯尾之厲,不往何災也。 # 退處之時,居於尾者,此危也,唯晦藏於下或末端,可得吉,因往有晦不若不往,故晦藏可免災也。\n象曰:執用黃牛,固志也。 # 二與五位用中正之道相結,且堅心其志, 猶牛革之堅也。\n可無咎,古之前例多矣。\n象曰:係遯之厲,有疾憊也,畜臣妾吉,不可大事也。 # 係遯之道,於養臣妾親暱人時可吉,但不可以擔當大事,待人取中正之道,不以私心而生偏,取諸於公,則可行大事矣。\n象曰:君子好遯,小人否也。 # 君子知時而退不疑,小人則無法克制私慾,以至於凶也。\n象曰:嘉遯貞吉,以正志也。 # 內心正則動必正,此退之美也,今人不以退為美,只動之以欲,因志不正也。\n象曰:肥遯无不利,无所疑也。 # 退之求其遠,且無所拖累,此退之道至極, 必無不利。且剛決而不疑,君子之遯道,雖退, 因得道,卻能無往不利。\n"},{"id":67,"href":"/zh/docs/culture/%E5%A4%A9%E7%BA%AA/%E4%BA%BA%E9%97%B4%E9%81%93/%E4%B8%8B%E7%BB%8F/32%E9%9B%B7%E9%A2%A8%E6%81%86/","title":"32雷風恆","section":"下经","content":" 32雷風恆 # 此卦宋王奪韓朋妻,卜,得之也。\n圖中:\n日在雲,鳳啣書,官人行路,道士手指門,鼠下兩口,牆擋路。\n日月常明之課 四時不沒之象\n恆,乃論夫妻之道,終身不變者,前『咸』為少女少男之感,今『恆』乃長男長女之往, 男尊在上,女卑在下,此夫婦居室之道,男動於外,女順於內,此人倫之常,此恆久之道。\n卦圖象解\n一、日在雲中:昏暗之象,君主不明象。二 、鳳啣書:喜訊至,金榜题名也。\n三、官人行路:將至也,貴人至。官人,倌人,丈夫也,人傍為倌也。四、道士手指鬥:入空門也,閃也。\n五、鼠下兩口:陰謀在後之象;兩口,回也,呂姓也。六、時機:在春末,日為春之末尾也。\n此即持之以恆,則有撥雲見日之喜也。 # 人間道\n恆:亨,无咎,利貞,利有攸往。\n恆為常久之道,人能恆之於善,此道可恆,君子之道。小人之道,乃恆於惡,已失可恆之道,故恆之道能常久,乃因所恆為正,恆不正,招凶也。\n彖曰:恆,久也。剛上而柔下,雷風相與,巽而動,剛柔皆應,恆。 # 恆久之道,在於上剛下柔,君剛臣柔,夫剛婦柔,雷震風興,風中有雷,相互交應,助長其勢,天地之間能恆久不已,乃因能知順動而已。\n恆,亨,无咎,利貞,久於其道也。\n恆之道,可以令亨通,且無災,但須注意所恆為正,吉也,所恆不正,則反招辱。\n天地之道,恆久而不已也。 # 人如能恆持可恆之道,則合於天地之理。必可長久。\n利有攸往,終則有始也。 # 萬物之動,必有始終,今動而能恆,其間變化必多,能知隨時變易其外,神在內不變,此得易之神也。\n日月得天而能久照,四時變化而能久成,聖人久於其道而天下化成, 觀其所恆,而天地萬物之情可見矣。 # 日月得天之道,能久照不已,四時往来生成萬物,也因得天,故常久不已,聖人用恆之道, 行之不已,能化成天下,成其美俗。觀天地之恆常,得天下常久之理,此聖人之道所以能常久也。常人孰能知。\n象曰:雷風,恆;君子以立不易方。 # 人才君子觀恆之象,以恆久之德性,持立於中道,永不易其立足之道。\n初六:浚恆,貞凶,无攸利。 # 初六以柔暗之人初進,只知守常卻不度勢而為,但求上之眷顧,又堅守此向,致凶之道也。因其心志在求上眷顧,必不能安居其位, 故凶也。\n九二:悔亡。 # 恆能居其正位,常道也。今陽居陰位,處非其位,雖中正之才,不知輕重之勢,造成有\n九三:不恆其德,或承之羞,貞吝。 # 得位乃可久,但於須持久時不知持之以恆,朝令夕改,人無所適從,反招羞辱矣。\n九四:田无禽。 # 田獵又無獲禽,徙勞無功也,九四陰位而陽居之,處非其所,恆又何益?人之動必有道, 持之以恆則有功,不得道,即久亦無功也。\n六五:恆其德,貞,婦人吉 ,夫子凶。 # 柔居剛位,又得九二之陽剛助應,此即居中正又有應乎中正,恆此以久,吉之道。婦人之道以順從為恆,此順從於婦人則吉,丈夫為剛陽,如以順從之道持恆,反致凶也。更何沉人君之道,君道切不以柔順為恆也。\n上六:振恆,凶。 # 此恆之極位,乃振恆,即守之不常也,居上而動無節宜,再以此為恆,凶至也。故吾人動之必以正理,不逾矩,動如失正,又不及復, 反持恆下去,果致凶矣。\n象曰:浚恆之凶,始求深也。 # 但求上之眷顧,不知度勢而行,安居其位, 失恆之道於始也。\n悔。人能知所輕重,易之義即此。\n象曰:九二悔亡,能久中也。 # 人能知恆久於中道,知所輕重,必可常久。\n象曰:不恆其德,无所容也。 # 不知恆之道的人,終至無處容身也。\n象曰:久非其位,安得禽也。 # 處不適位,雖久何益,猶徙獵而無功。\n象曰:婦人貞吉,從一而終也,夫子制義,從婦凶也。 # 柔而順從於婦人則吉,乃從一而終也,君王之義,從婦人之順從,招凶。\n象曰:振恆在上,大无功也。 # 居上之道,躁動不安,必無所成,此言持恆,知恆,用恆之重要性,人不知此即,令再大之勞亦無功也。\n"},{"id":68,"href":"/zh/docs/culture/%E5%A4%A9%E7%BA%AA/%E4%BA%BA%E9%97%B4%E9%81%93/%E4%B8%8B%E7%BB%8F/31%E6%BE%A4%E5%B1%B1%E5%92%B8/","title":"31澤山咸","section":"下经","content":" 31澤山咸 # 此卦漢王昭君,卜得之,知和番不回也。\n圖中:\n空中有一拳,錢寶一堆,貴人在山頂, 女人上山,一合子。\n山澤通氣之課 至誠感神之象\n易經上經乃論天地萬物之本,有天地後有萬物,聖人觀其間象,以巨視之角度來體察出天道。咸卦之始,為易經下經,其論夫婦人倫之始,較重於人道。咸之為卦,兌上艮下,乃少女少男之意,表示世上男女相感之深,莫過於少男少女,童稚之間無隔閡,人人皆以平常心處之, 男女相悦又無所求,互相以誠及悦相處對應,此時之感,即咸之深意。\n卦圖象解\n一、空中有一拳:貴人提拔,憑空而得。二、錢寶一堆:求財得財。\n三、貴人在山頂:孤立狀,即將下坡,退休狀。四、女人上山:夫妻吉,陰人卜之知走馬上任。五、合子:先成後破,因誠感方吉,物感必凶。\n人間道\n咸:亨,利,貞,取女吉。\n咸,感也,舉凡君臣上下,夫婦、父子等皆有感之道,人人能以少男少女之童悦相感以誠悦,即以平常心相待,則必有亨通之理。今人皆以媚説,上下之位處以邪僻,皆感之不正也。艮為止,澤為悦,乃外悦內止之意,以此之意近女,乃得正而吉也。\n彖曰:咸,感也,柔上而剛下,二氣感應以相與,止而説,男下女,是以亨利貞,取女吉也。 # 陰陽相交,乃男女相感之義同,男女之交,男以篤實至誠之心,女以喜悦之心相應,此男女之感於正道,故能亨通得正。\n天地感而萬物化生,聖人感人心而天下和平,觀其所感,而天地萬物之情可見矣。 # 感之道到極限時,聖人能以至誠之心感億兆人之心,使天下和平,其情之偉大可見,但須知感通之理,須以默而觀之,不可道破,此感方可久矣。\n象曰:山上有澤,咸;君子以虛受人。 # 澤在山上有渐潤通澈狀,此感之道。君子觀山澤通氣知須虛其中方能受人,凡人之心能中虛必可受,實則不能入矣。\n初六:咸其拇。 # 初與四相感,初之時感必未深,不能動人, 如拇指之動,不可進也,須識勢而為。\n六二:咸其腓,凶,居吉。 # 腓,足肚也,人之動,足必先行,足肚自動也。二位無法守道而妄求於上,故凶。進退之道乃安居其位,以待上求,此吉也。\n九三:咸其股,執其隨,往吝。 # 股在身之下,足之上,不能自主,隨身而動。九三陽剛之才,感於所動而隨其動,如此已失其正道,其動必羞也。\n九四:貞吉,悔亡,憧憧往來,朋從爾思。 # 人之所動皆因感也,九四在中,乃當心臓之位,為心感之主,感之正則吉,不正則凶。今人以私心感人,所感狹小也。天地萬物各有不同,事亦變化萬千,能殊途同歸,此感之極至,必以公正無私方可行。人之向往,必有屈求,人之來至,必因有信,此屈信相感,利之\n九五:咸其脢,无悔。 # 居尊位之人,必以至誠感於天下,脢,背肉也,其與心相背,目所不見狀,此意能背其私心,感於非所見而能悦者,以人君可以無悔。今人不知,皆感於所見而悦,所不見而仇視之, 此感不正,招自凶亡也。\n上六:咸其輔,頰,舌。 # 上為感之極位,陰柔居之,此即不能以至誠感物,唯求於口舌之間感人,乃小人女子之常態,必不能動於人,故云頰。\n象曰:咸其拇,志在外也。 # 感應之志雖動,但未大,不足以進。初志之感與四相應,故曰在外。\n象曰:雖凶,居吉,順不害也。 # 陰居二位,其得位中正,在咸之時,因質本柔,必戒以先動,求之必凶,守居不動吉。\n象曰:咸其股,亦不處也,志在隨人,所執下也。 # 人有陽剛之質,卻又不能自主,反隨於人而動,此所執者必卑賤甚也。\n所生處。\n象曰:貞吉悔亡,未感害也。憧憧往來,未光大也。 # 人能正則必吉,事皆以私,則生害也,互相往來以私心相感,此感之道狹,必未光大也。\n象曰:咸其脢,志未也。 # 人感於所不見而生戒,其必淺也,此私心之用也。\n象曰:咸其輔頰舌,滕口説也。 # 世間唯至誠能感人,如靠口舌説話之才, 感人必不能長久。\n"},{"id":69,"href":"/zh/docs/culture/%E5%A4%A9%E7%BA%AA/%E4%BA%BA%E9%97%B4%E9%81%93/%E4%B8%8A%E7%BB%8F/30%E9%9B%A2%E7%82%BA%E7%81%AB/","title":"30離為火","section":"上经","content":" 30離為火 # 此卦朱買臣被妻棄時,卜得之,知身必貴也。\n圖中:\n人在虎背上立,官人執箭在岸上立,水中有船,二人分立兩岸。\n飛禽在網之課 大明當天之象\n陷於險難之中,必有所附麗,理之自然,離者麗也。離為火,火體中虛而能明照,故能麗於物而明也。人之明,必經險難,大風大浪過後,其經驗之累積,而後能明,此猶可人。\n今人雖歷災險,但仍不能明,吾人教授易經,其目的即希望世人,不須付出血的代價,亦能明,此中虛之廣義。\n卦圖象解\n一、人在虎背上立:行危不懼;難下也。\n二、 一船在江心:口舌相爭,無所適從也。三、官人執箭岸上立:在位之人,欲殺之象。此卦夫妻破散成仇,兄弟分家各自為王之課。\n人間道\n離:利貞,亨,畜牝牛,吉。\n天下萬物莫不有所麗,其形即麗。人之所麗,必得貞正,得正可以亨通,人能附麗於正, 必能順於正道,從善如流為人之性,如牝牛之吉,牛之順道,由養而成,人之順德亦同,既附麗於正道,當有養以成其德也。\n彖曰:離,麗也。日月麗乎天,百穀草木麗乎土 。 # 附麗狀,如日月麗於天,百穀草木麗於土 ,天下萬物必有所麗,無麗之物,人必視何麗方正,則能亨通也。\n重明以麗乎正,乃化成天下。柔麗乎中正,故亨。是以畜牝牛吉也。 # 上下皆離故曰重明,乃意君臣皆有明之能,又處事中正,故可以教化天下成文明之俗也。人又能柔順於中正之明德如牝牛之順吉,乃更能將明德發乎光大,襯托明德之麗。\n象曰:明兩作離,大人以繼明照于四方。 # 此卦若云兩離,則只見明,而不知繼明之德。君子觀離明相繼之象,知本身之明德能濟天下,為使以明德中正永傳後世,則須有進一步繼明之能,能明於選擇何人能承繼,乃繼明之義。如堯之擇舜,漢之蕭規曹隨等即是繼明之用。\n初九:履錯然,敬之,无咎。 # 初爻陽居之,乃言初進有陽剛之才而進又躁動,卻因動之非宜,已失居下位之分而得咎, 但因為人才,只須知守義而戒慎之,則不至於有咎。\n六二:黃離,元吉。 # 以柔居中乃得正位,黃為中色,人能文明而中正,則美之至極也。又有六五文明之君以順其德,其能明如此,乃大善之吉也。\n九三:日昃之離,不鼓缶而歌,則大耋之嗟,凶。 # 易有八純卦,唯離為火於人事之義陳述最細。今九三吾人居下卦之終乃意前明將盡,後明當繼之時,人之有始,必有其終,此常道也。智慮通達之人,以順理為樂且常持之,如不能如此,則有將盡之悲,而日夜恐於死亡,故君子樂天,小人終日恐懼,此處易論生死之道也。\n象曰:履錯之敬,以辟咎也。 # 能知履動之躁進為錯而敬慎之,因居下位,但求避之可也,不致有災。吾人有陽剛之才,仍須有明之能,能剛不能明,則因妄動生災也。\n象曰:黃離元吉,得中道也。 # 能成文明乃由中之虛明,故元吉之因,是由其得中之道也。\n象曰:日昃之離,何可久也。 # 日已倾,不可久也,能明之人,能求人以繼其事,知退而修其身,則可長久。\n九四:突如、其來如、焚如、死如、棄如。 # 九四位,乃言繼明之初,承繼之意,但陽爻居之,有剛躁但不失中正之意,此非善繼者, 能知善繼之人,始繼必有謙讓之誠,知順承前賢之道。今突如其來,已失善繼,又剛勢凌主, 氣焰高張故焚如,必招禍害致死,故死如,以眾所棄之,故果為棄如。\n六五:出涕沱若,戚嗟若,吉。 # 出涕戚嗟,皆形容人憂懼至深。君位以陰柔之才居之,又受於陽剛之在左右,此居尊位之明,能知憂且畏,故能吉。今若自持己才之不足,全然不知懼,必不能得其吉也。\n上九:王用出征,有嘉。折首,獲匪其醜,无咎。 # 陽剛之才居離之終,有剛明至極之狀。此方式乃適用之於君王出征時,明則足以察邪惡,剛則能斷事,施以威行,如此則能去天下之邪惡,此有功也。世上事至刑不足以阻時, 必用征伐方法解決,此之時也。去天下之邪惡, 如明斷之極,則必無所宽宥,此時必取其魁首, 始作俑者方是,趕盡誅滅,必有暴殘之後悔也。\n象曰:突如其來如,无所容也。 # 諸君試想,人之凌其君,而不順所承繼之前賢,必眾棄,天下不容也。\n象曰:六五之士,離王公也。 # 居上位之吉,乃在明察事理,且以畏懼憂慮之心以持之,可以為吉也。\n象曰:王用出征,以正邦也。 # 君王用此德,則能治其邦國,此剛明至上之道也。\n"},{"id":70,"href":"/zh/docs/culture/%E5%A4%A9%E7%BA%AA/%E4%BA%BA%E9%97%B4%E9%81%93/%E4%B8%8A%E7%BB%8F/29%E5%9D%8E%E7%88%B2%E6%B0%B4/","title":"29坎爲水","section":"上经","content":" 29坎爲水 # 此卦唐玄宗避祿山,卜得之,果身出九重也。\n圖中:\n人在井中,人用繩引出,一牛一鼠,人身虎頭。\n船涉重灘之課 外虛中實之象\n大過之後,過至極必陷,坎者,陷也。重坎即重險,險中有險,此卦專論處險之道,如何用險等。\n卦圖象解 # 一、人在井中:官司,牢獄,陷入險地象。\n二、人用繩引出:責人出手相救狀。\n三、一牛:勞苦也,春夏凶,秋冬吉,亦肖牛人。\n四、一鼠:肖鼠人,陰謀暗害者,暗藏也。\n五、人身虎頭:有權威之人,貴人也。\n人間道 # 習坎:有孚,維心亨,行有尚。\n陽實在中,乃中有孚信,惟心誠正一,則能亨通,俗謂:至誠可以通金石,入水火,有何險難不可亨?故出險之道在於誠一而行,否則常居險中也。\n彖曰:習坎,重險也,水流而不盈,行險而不失其信。\n習有重覆之意,上下皆坎,即險中有險,水流而不盈,即動於險中仍未出險之時,吾人行險须不失其信,乃可吉。\n維心亨,乃以剛中也。行有尚,往有功也。\n以剛中之道,抱持至誠之內實,天下何所不通,若止而不行,則常居險中也,故坎險以能行為功。\n天險不可升也,地險山川丘陵也,王公設險以守其國。險之時用大矣哉!\n天之險乃因高不可升,地之險為山川丘陵,諸王公觀坎之象,知險不可陵越,故設高牆深地為險,以守其國,保其人民,此用險之時也。人間亦然,凡知人之學,分尊卑,分貴賤,區分小人與君子,皆為用於杜絶災殃,限隔上下,此都是體悟了險之用也。\n象曰:水洊至,習坎,君子以常德行,習敎事。\n水勢就下,由小而大,歷久不變,君子觀坎水之象,取其有常,不變其德性為真。如人之德行常變,則偽也。政治上發令行教,必使民熟於聞,人人皆從之,故有謂三令五申,即此意。\n初六:習坎,入于坎窞,凶。\n初以陰柔居坎險之下,因無援,處不當, 不能出險,唯益陷深而已,故凶。\n象曰:習坎入坎,失道凶也。\n由險中而加陷入險,乃失正道,加凶。\n九二:坎有險,求小得。\n二爻位,乃陽陷二陰之中,即至險而有險狀,但其本為剛中之才,故雖未出險,亦可自救,不至於愈險,故君子處艱險而能自得,皆因剛中而已。能剛則有才,能執中則動不失所宜。\n象曰:求小得,未出中也。\n此即雖有小得,雖不致於陷險,然亦未能出險狀。\n六三:來之坎坎,險且枕,入于坎富,勿用。\n六三位在坎險之時,以陰柔居而處不正, 走下則入險,向上則重險,故有進與退皆遇險, 居又有險,枕乃處不安狀,此時唯戒勿用方是。\n象曰:來之坎坎,終无功也。\n以陰柔又處不正,動則唯益險,人出險须有道,如失道,但為能求去,只益增困窮而己。\n六四:樽酒,簋貳,用缶,納約自牖,終无咎。\n此言大臣當險難之時,唯有至誠於君,不可停交往,而又能打開使君能明之心,則可保无災。人欲求上之篤信,只有求質實而已,如多禮儀而尚服飾,反凶。一樽之酒,二簋之飯, 以瓦缶為器,乃求質實之意。再處明之地以明君之心,如此方能入君心,即令艱險,亦可無咎。自古以來,只求剛直攻訐直言不諱之人, 多忤逆君心,而屬温厚明辨者,反而能入君心, 改變君主。\n象曰:樽酒簋貳,剛柔際也。\n但求實質,又剛中有柔,必平安。君臣之交能長久,不外乎誠實而已。\n九五:坎不盈,衹。既平,无咎。\n此即九五之尊,有剛中之才,可以濟險, 但下無助,獨依君之力無法濟天下之險,則有咎,必待盈平,方可無咎。\n象曰:坎不盈,中未大也。\n剛中之才得居君位,時天下險難,但未至全險,其未能平險,乃因君臣無法協力相濟之故,故此君道未能救濟天下,乃因無臣,故曰不稱其位。\n上六:係用徽纆,寘于叢棘,三歲不得,凶。\n此意陰柔而深陷險中,聖人以牢獄比喻, 縛以徽纏,囚置於叢棘之中,其不能出,至少三年不可免,其凶如此。\n象曰:上六失道,凶三歲也。\n上六以陰柔而處險地,乃因其失道而成。其災延三年不得免,意久也。\n"},{"id":71,"href":"/zh/docs/culture/%E5%A4%A9%E7%BA%AA/%E4%BA%BA%E9%97%B4%E9%81%93/%E4%B8%8A%E7%BB%8F/28%E6%BE%A4%E9%A2%A8%E5%A4%A7%E9%81%8E/","title":"28澤風大過","section":"上经","content":" 28澤風大過 # 此卦姜太公釣渭水,卜之,至八十方遇文王。\n圖中:\n官人乘車上,插兩旗,旗上有喜字, 入朱門,門外貴人立,文書在地,一合子。\n寒木生花之課 本末俱弱之象\n頤者,養也,萬物養而後能成,成而後動,動則有過,故大過次頤也。大過乃論過失之大與過人之大事也。舉凡世間事之大過於常者,皆是也,古聖人盡人道非過於理之正,如今之行過乎恭,喪過乎哀,用過乎儉,皆過之也。過人之大事,本不常見,故非常人能為之,如堯舜之襌讓,湯武之放伐,即立不世之功,乃過人之大事。\n卦圖象解\n一、官人乘車上插兩旗:車至官來,必有官司訴訟,軍人象。二、旗上有喜字且分開,乃喜有破損象,捨去婚姻象。\n三、入朱鬥:乃豪門世家相請,或朱姓人。四,、門外貴人立:被棄於外象。\n五、文書在地:合約,契約,不成之象。六、一合子:先成後破之象。\n人間道\n大過:棟橈,利有攸往,亨。\n小過與大過之分,乃小過為陰過於上下,大過乃陽過於中,上下皆弱,故有棟橈之象。此卦四陽居中,乃棟象,能任重也。即君子盛而小人衰,故利有攸往可亨。\n彖曰:大過,大者過也。 # 犯過之大與成大過於人之事。\n棟橈,本末弱也。 # 本末為陰而中為重陽,故為楝橈。\n剛過而中,巽而説行,利有攸往,乃亨。 # 雖因陽剛有過,但處不失中道,巽順和悦,無以私心,必利攸往,所以亨。\n大過之時大矣哉! # 人能立非常之大事,立不世之大功,乃真大過,處之時得宜乃大哉。\n象曰:澤滅木,大過。君子以獨立不懼,遯世无悶。 # 澤本潤養於木,今至滅沒於木,此過之甚矣,為天地之象。君子(人才)觀大過之象,以立其大過於人之行,其所以能大過於人者,乃其能獨立不懼,天下誹之亦無動其心,舉世不見\n知而不悔,隱世而不憂悶,如此方能自守,此為其能大過於人也。\n初六:藉用白茅,无咎。 # 此以陰柔而處卑下,易藉用茅為至薄之物,但用之亦很慎重,此為敬慎之至也,故可以無災。\n九二:枯陽生稊,老夫得其女妻, 无不利。 # 二為柔位,陽剛居柔,是過剛之人而能以中自處,與柔相濟,其功如老夫之得女妻,則\n九三:棟橈,凶。 # 九三以太過之陽剛自居,又不得中道,此剛過之甚,其動必違和於中,拂逆眾心,故即以棟樑之才,而無人自輔,安能當大過之任乎?故凶。君子居大過之時,立大過之功,非剛柔適中,得人之輔,則不能也。\n九四:棟隆,吉 ,有它吝。 # 此近君之位,又當大過之任,以剛處柔則能相濟,是吉也。但如有異志,則剛必失中, 此加累於陽剛,雖不致有大害,但亦可吝也。\n九五:枯楊生華,老婦得其士夫, 无咎无譽。 # 君位處大過之時,下無助援,就如枯楊不生根而生外表,其表秀雖可見,但卻無益於枯也。猶如老婦之與士夫,此雖无咎,但非美也, 其可醜乎。\n上六:過涉滅頂,凶,无咎。 # 此陰柔又處過之極者,乃小人過之極也, 小人之大過,並非能做大過於人之事也,而是過越常理,不體恤危亡,犯險闖禍而已,如涉水而至滅頂,必凶。此自取其辱,无所怨尤。\n象曰:藉用白茅,柔在下也。 # 此示意陰柔而居卑下,過於敬慎之意,此至慎之道。\n亦能成生育之功。\n象曰:老夫女妻,過以相與也。 # 老夫少妻之配,雖過於常份,但老夫能悦少女,而少女能順老夫,亦無災。\n象曰:棟橈之凶,不可以有輔也。 # 此即剛強之過,必不能取於人,人亦無法輔之,此其凶也。\n象曰:棟隆之吉,不橈乎下也。 # 棟能隆起則吉,不可曲橈就下。\n象曰:枯楊生華,何可久也?老婦士夫,亦可醜也。 # 枯陽不生根而生華秀之外表,必不能長久。老婦得士夫,必不能成生育之功也。\n象曰:過涉之凶,不可咎也。 # 過涉水以至於溺水,皆咎由自取,無可抱怨也。\n"},{"id":72,"href":"/zh/docs/culture/%E5%A4%A9%E7%BA%AA/%E4%BA%BA%E9%97%B4%E9%81%93/%E4%B8%8A%E7%BB%8F/27%E5%B1%B1%E9%9B%B7%E9%A0%A4/","title":"27山雷頤","section":"上经","content":" 27山雷頤 # 此卦張騫尋黄河源,卜得之,乃知登天位也。\n圖中:\n雨下,三小兒,日當天,香案,金紫官人引一人。\n龍隱清潭之課 遷善遠惡之象\n萬物畜聚之後,必有所養,無養不成,故頤者,養也,次大畜之序卦。上艮下震,乃上止而下動,外實中虛象,如人頷頤物於口中象。人之口所以飲食,乃能養人之身,此為頤。此論頤養之道,大至於天地養萬物,聖人養賢,人之養生,養形,養德,養人,養才,皆屬頤養之道。\n一、雨下:受恩澤也。\n卦圖象解\n二、三小兒:乃合心之象,有德之國也。三、日當天:君明之象。父全。\n四、香案:求取之象。\n五、金紫官人:天子身側之人。六、引一人:提攜,推薦也。\n人間道\n頤:貞吉,觀頤,自求口實。\n頤之養人,君子觀之,自求口實之道,乃養君子。口不實又不知養,小人。\n彖曰:頤,貞吉,養正則吉也,觀頤,觀其所養也,自求口實,觀其自養也。天地養萬物,聖人養賢以及萬民,頤之時大矣哉! # 吾人所養之人及養生之道,皆不外自求口實,皆以正則吉。聖人養賢,使之為天位,使之食天祿,為求賢才施惠於天下。此養賢即養萬民也。\n象曰:山下有雷,頤。君子以慎言語,節飲食。 # 以象言,雷震於山下,萬物根動而萌芽,此天地養之象。君子知口所以養身,故慎言語以養其德,節制飲食以養身。\n初九:舍爾靈龜,觀我朵頤,凶。 # 龜之性,能咽息不食,可以不求養於外, 故為靈,即明智,而人口腹之慾既動,則必失所養,故凶。\n象曰:觀我朵頤,亦不足貴也。 # 人之動為慾,即使有剛健之才,再明智, 終亦必失,故不足貴也。\n六二:顚頤,拂經,于丘頤,征凶。 # 養之道正,在以上養下,天子養天下,養臣子,臣食君祿,皆以上養下。今女不能自處必從男,陰不能獨立必從於陽,此二爻不能自養反求下之陽剛,故為顛倒,拂違常理,故不可行。丘即上九,為在外而高之物,故往而必凶,此即意示人之才不足以自養,見在上位勢足養人,即非同類之君子,妄往求之,取辱必矣。\n六三:拂頤,貞凶,十年勿用,无攸利。 # 邪柔又動之不正,違反頤之正道,故凶。\n六四:顚頤,吉,虎視眈眈,其欲逐逐,无咎。 # 陰柔居大臣之位,陰柔不足以自養,如何養天下?今如與初陽剛相應,而柔順於初陽之賢正,吉。故如己之才不足以養天下,但知求在下之賢而順從之,亦可吉也。但須養其威嚴, 眈眈如虎視,以免因己才之陰柔不足養,而受人輕視,造成犯上。但亦須注意所須用者,必逐逐相繼而不缺,若唯取於人又不能繼,困窮\n六五:拂經,居貞吉,不可涉大川。 # 君位乃養人者,但其質如陰柔,即才不足以養天下,而能順從於陽剛(師)之賢,賴其以濟天下,必須有居安篤信之心,全般信賴, 則可輔其身,德澤天下,故吉。但此種狀況可依賴賢能之人於平時,不可於有困艱生變故之時,否則招凶。\n上九:由頤,厲吉,利涉大川。 # 此以陽剛之德,任師之職,君位從而篤信, 天下得養也,但須夕惕若厲,戒慎恐懼,方能濟天下之危,故利涉大川。\n象曰:六二征凶,行失類也。 # 征而從上,又非其類,故人求非同類,必得凶矣。\n十為數之終,故終不可用,無所往而利。\n象曰:十年勿用,道大悖也。 # 此戒之終不可用,凡人邪而柔,又欲動, 反正道大也,此戒之。\n至矣。\n象曰:顚頤之吉,上施光也。 # 吾人能顛倒求養而又吉者,皆得自陽剛以濟應其事,故能施光明于天下。\n象曰:居貞之吉,順以從上也。 # 居上位之吉處,乃因能篤信委任剛賢之人,來養天下。\n象曰:由頤厲吉,大有慶也。 # 若上九之大任能謹言慎行,安居已位,不妄圖權利,則必福慶至矣。\n"},{"id":73,"href":"/zh/docs/culture/%E5%A4%A9%E7%BA%AA/%E4%BA%BA%E9%97%B4%E9%81%93/%E4%B8%8A%E7%BB%8F/26%E5%B1%B1%E5%A4%A9%E5%A4%A7%E7%95%9C/","title":"26山天大畜","section":"上经","content":" 26山天大畜 # 此卦昔神堯,卜得之,果登天位也。\n圖中:\n一鹿一馬,月下有文書,官人憑欄, 盆內蒼發茂盛。\n龍潛大壑之課 積小成大之象\n無妄則至善,可以積畜,故大畜為序卦。畜為止,又為聚,止而後聚,此卦天至大,而在山中,有所畜至大之象。外止內健為畜象,吾人視萬物外則無欲,即止也。內則運行不斷,必有大積畜於其中。\n卦圖象解\n一、一鹿一馬:祿馬交馳,大富之象。\n二、月下有文書:明而有往,中秋時節,文書在空中尚未到手。\n三、官人憑欄:無事狀,遥望所失之國度。又欄為木,人靠木,休也。棺也。四、花在盆中:有限也。\n此卦求官不利,求財大利。 # 人間道\n大畜:利貞,不家食,吉,利涉大川。\n大畜於人亦同,人之畜在學術道德充積於內,乃至大之畜,若為邪端異説畜又何用?今人重大畜於錢財,不重視學術修養,所畜何用?不為大畜。\n彖曰:大畜剛健,篤實輝光,日新其德。 # 人须能剛健篤實,其畜乃大,又因充實而光辉,德必日新。\n剛上而尚賢,能止健,大正也。 # 居尊位能剛明用賢,又能持之永恆,此大正之道。今之君王剛而不明,用人乃因私慾,而不以賢能為基準,故非大正之道。\n不家食吉,養賢也;利涉大川,應乎天也。 # 大畜學術道德之人,施其所畜,利於天下,因而不在家食,乃吉。能涉大川而無險,乃應乎天地也。\n象曰:天在山中,大畜,君子以多識前言往行,以畜其德。 # 君子觀天之至大而畜山中,故知人之能畜大,乃由學而大,學之道在多聞古聖賢之言行, 取長為己用,察言以求其內心,乃大畜之真義。\n初九:有厲,利己。 # 初爻為陽剛,必上進不已,則有危厲,此時,利在己之畜而不進,待與上合志乃動。\n九二:輿説輹。 # 二為六五位所對應,雖陽剛但亦不可力進,進則如車輪脱軸,不得行也。\n九三:良馬逐,利艱貞,曰閑輿衛, 利有攸往。 # 三為相位,以剛健之才,能與上位合志而同進,就如良馬之馳,速也。唯雖速,但須切\n六四:童牛之牿,元吉。 # 此言近君位之人,须上畜止人君之邪心, 下畜止天下之惡。凡人之惡能止於初始,則易如童牛加桎梏。俟惡盛而後禁,必相隔而難勝。故聖人止惡於初。\n六五:豶豕之牙,吉。 # 豬之去勢,則雖有牙,而剛躁必自止。君子體之,知天下之惡,不可力制之,須査其根本,不靠刑法嚴峻而能止惡。聖人教修政教, 使人人足用,知廉恥之心,方為至善。\n上九:何天之衢,亨。 # 大畜之道,能如行空中,橫行無阻,必散之天下,吉也。\n象曰:有厲利己,不犯災也。 # 此即不度局勢而進,不利己,乃災至。戒不可犯危而行。\n象曰:輿説輹,中无尤也。 # 至善為剛中,柔不可過柔,因位居下,仍不足以進。易之道,乃知所進退也。\n忌不可持才之健,而忘戒備與謹慎也。\n象曰:利有攸往,上合志也。 # 因能與上合志,故往而利也。\n象曰:六四元吉,有喜也。 # 大畜之道在止惡於初,此大善而吉,必能不勞而易治。\n象曰:六五之吉,有慶也。 # 在上者止惡有道,乃天下福慶也。反用強力嚴刑與民為敵,傷又無功矣。\n象曰:何天之衢,道大行也。 # 道路空曠,而能大行,乃大畜之極也。\n"},{"id":74,"href":"/zh/docs/culture/%E5%A4%A9%E7%BA%AA/%E4%BA%BA%E9%97%B4%E9%81%93/%E4%B8%8A%E7%BB%8F/25%E5%A4%A9%E9%9B%B7%E6%97%A0%E5%A6%84/","title":"25天雷无妄","section":"上经","content":" 25天雷无妄 # 此卦李廣,卜得之,後凡為事皆不利也。\n圖中:\n官人射鹿,鹿啣文書,錢一堆在水中,一鼠一豬。\n石中蘊玉之課 守舊安常之象\n因復於道,合於正理,則无妄,故復之後受以无妄。卦乾上震下,此言動以天則為无妄, 如動以人慾則生妄矣。故无妄之義大也。\n卦圖象解\n一、官人射鹿:自亂陣腳。二、鹿啣文書:財訊至也。\n三、錢一堆在水中:得財遇險之象。險中求財,憂心忡忡也。四、一鼠一豬:遇子、亥年吉,肖鼠人陰謀,肖豬人黑心。\n又可以子、亥為孩,亦即孩無子為亥,缺子象。又六字不全,亥月凶,無尾凶, 不足六歲,為五歲凶死。\n天機象:犬、豬、牛、羊:叱之即去。雞、魚、鵝、鴨:欲用則不生。\n人間道\n无妄:元,亨,利,貞,其匪正有眚,不利有攸往。\n无妄即至誠也。至誠為天道。天能化育萬物,生生不息,且各正其性,此无妄也。人如能合於无妄之道,則能與天地合德,故大吉,君子行無妄之道,能致大吉,須戒在堅心,如失貞, 則致災。今人本無邪心,但如不合正理,則妄亦邪心也。\n彖曰:无妄,剛自外來而爲主於内。動而健,剛中而應,大亨以正,天之命也。\n動以天則无妄,今人動以人慾。剛自外而入主於內,乃以正去妄之象。上健而下動,內動而外健,其動乃剛,理正氣壯,道乃大亨,此天道也。今人動之以慾,而無法示誠於外,不夠剛明,乃因內不實,此致妄矣。\n其匪正有眚,不利有攸往,无妄之往,何之矣。天命不祐,行矣哉? # 无妄,即正也,小失於正,乃妄。入於妄,不知復,乃更往,則必悖天理,為天道所不容, 不可行也。\n象曰:天下雷行,物與无妄,先王以茂對時育萬物。 # 雷行天下,陰陽和而成聲,生發萬物,各正性命,其發而无妄,先王觀此象,體天之道, 養育人民,使各正性命,乃對時育物之道也。\n初九:无妄,往吉。 # 初進陽剛在內,以无妄之天理行之於外, 故往吉。\n六二:不耕穫,不菑畬,則利有攸往。 # 人之欲即妄,理之自然者,非妄,今以耕穫喻之,農耕後之穫,乃理之自然也。田一歲為『菑』,三歲曰『畬』,有耕而有穫,有菑而有畬,聖人順時而作,乃為聖也,讀易之人, 能知時順勢,得易之道矣。\n六三:无妄之災,或繫之牛,行人之得,邑人之災。 # 人之妄動,由己之欲也,妄動有得,亦必有失,陰柔居相位,動乃因妄,失之大在欲, 故妄得之福,災亦隨之,妄得之得,失亦來之, 皆非真得也。\n九四:可貞,无咎。 # 四位剛而居之,剛則必無私,永不生妄。\n九五:无亡之疾,勿藥有喜。 # 九五為尊位,以陽剛應之,下又以陽剛中正順應,則致善也。即令有小疵,不須去治而自癒,如持之以恆,則平安無災,戒之在動, 一動生妄,故人君之无妄道即在此。\n上九:无妄,行有眚,无攸利。 # 此位為卦之極,无妄已至善,人至無妄, 又復行,理必過也。過於理,則生妄。\n象曰:无妄之往,得志也。 # 无妄乃誠也,人之至誠,物無不動,君子以之修身則身正,以之治事則得理,以之待人則人化,无往不利也。\n象曰:不耕穫,未富也。 # 心念於始耕菑之時,乃為求畬,此所以為富。心有欲而為,則妄。故易言人之行但求合於自然,不為人欲所動,故能勝己之欲為大勇也。\n象曰:行人得牛,邑人災也。 # 有得有失,不足言得。聖人之得,乃無欲而得,不言有失。\n象曰:可貞无咎,固有之也。 # 堅心守无妄之道,剛而無欲,則无災。\n象曰:无妄之藥,不可試也。 # 人有妄必改,則致无妄,今反以藥治之, 反成妄矣。故不可試。\n象曰:无妄之行,窮之災也。 # 无妄已為極點,今又復加進,乃生妄矣, 故窮極而生災也。\n"},{"id":75,"href":"/zh/docs/culture/%E5%A4%A9%E7%BA%AA/%E4%BA%BA%E9%97%B4%E9%81%93/%E4%B8%8A%E7%BB%8F/24%E5%9C%B0%E9%9B%B7%E5%BE%A9/","title":"24地雷復","section":"上经","content":" 24地雷復 # 此卦唐太宗歸天,卜得之,七日復還魂也。\n圖中:\n官人乘車,上兩隻旗,堠上東字,一將持刀立,一兔一虎。\n淘沙見金之課 反復往來之象\n萬物無剝盡之理,故剝極必復來。復為之序卦。此卦一陽生於五陰之下,有陰極生陽象, 如同冬至極寒,則必生陽,亦同。雷在地下,有復生之象,外順內動,此為復之始。\n卦圖象解\n一、官人乘車:使節也;車姓、連姓也;軍隊也;官大也;調職也。二、上兩隻旗:鬥也;將、帥也;一方之主也。\n三、堠上東字:猴發木形人。東:有心約束,十八日至。十日來人封侯象。四、一將持刀立:武威之人,司法執行人。\n五、一兔:肖兔之人,劉姓之人。六、一虎:肖虎之人,王姓之人。\n此卦云:君子之道,既消而復,始復必不能力勝小人,必待其朋類渐盛,協力致勝可也。\n人間道\n復:亨,出入无疾,朋來无咎。\n萬物既復則必亨,如君子之道將復,雖微,但同類將渐進,故亨且盛矣。故復之時至,並無法立勝小人,必待其同朋相繼,則協力能勝也。\n反復其道,七日來復,利有攸往。 # 此為消長之道,易之論數,以立準則,小有七日乃復,大有七年方復,此復之道也。\n彖曰:復,亨。剛反,動而以順行,是以出入无疾,朋來无咎。 # 此以卦才來論,復之道,陽剛因消弱至極而反來,其法動而取順行法,是故出入皆無災, 朋之來,亦因順而動來也。\n反復其道,七日來復,天行也。利有攸往,剛長也;復其見天地之心乎! # 復之返來,順天之運行,故言七日。因君子道長,故利前往,陽復生於下,乃是天地生物之心,以為動之始也,故外見動,而始生於內也。\n象曰:雷在地中,復。先王以至日閉關,商旅不行,后不省方。 # 以卦象而言,如雷乃陰陽相搏而成聲,時陽之微,未能發也。如聖人順天之道,於日陽之\n始生,安靜養之以閉關,使商旅不得行,人君不視四方,此順天也,於人亦以此安養其陽為復原之道。\n初九:不遠復,无祇悔,元吉。 # 有失而後有復,此復之始時,因失之不遠, 故復必不至於悔,故吉。\n六二:休復,吉。 # 二為切近初陽之位,君子道始生於下,若二位志向於陽,今陽在下從之,故能下之道為仁。吉。\n六三:頻復,厲,无咎。 # 從復之道,又頻復履失,致危之道也。故須厲,即戒此可无咎,頻失致危,其過乃在失而不復也。今人迷途不知返,比之皆是,又知返而不戒又入迷,凶也。\n六四:中行獨復。 # 四於眾陰之間,能自處於正,但因力弱, 不足克濟,此言獨復,不一定無災。\n六五:敦復,无悔。 # 君王於復之道,能敦篤而行之,但臣下均陰柔之輩,因無助,但能無悔而不能無災,君子戒之。\n上六:迷復,凶,有災眚,用行師, 終有大敗,以其國君凶,至于十年不克征。 # 陰柔居復之終,乃意終迷不復者,大凶。招凶,皆因己之迷而不返,動則必有過失,以之出師必大敗,以之為國君,大凶。迷於道,\n象曰:不遠之復,以脩身也。 # 此即知不善而速改,為君子修身之道。學問之道即在此,知不對立改,為君子也。\n象曰:休復之吉,以下仁也。 # 仁者天下之公,善之本源,能體會而近於下之仁,乃休復之美。\n象曰:頻復之厲,義无咎也。 # 戒之在屢復,復而又失,失之過,故堅心於復不失,無災殃也。\n象曰:中行獨復,以從道也。 # 以獨復於小人間,如從陽剛,君子之道也。\n象曰:敦復无悔,中以自考也。 # 五以陰居尊位,能敦篤善道,以中道自成, 此可無悔也。\n而終不可行也。\n象曰:迷復之凶,反君道也。 # 人君居上而治天下,當從天下之善,如迷於復,反復無常,皆招凶也。\n"},{"id":76,"href":"/zh/docs/culture/%E5%A4%A9%E7%BA%AA/%E4%BA%BA%E9%97%B4%E9%81%93/%E4%B8%8A%E7%BB%8F/23%E5%B1%B1%E5%9C%B0%E5%89%9D/","title":"23山地剝","section":"上经","content":" 23山地剝 # 此卦尉遲將軍與金牙鬥爭,卜得之,不利男子。\n圖中:\n婦人床上坐,風中燭,葫蘆在地,山下官人坐,冠巾掛木上,一結亂絲。\n去舊生新之課 群陰剝盡之象\n盡賁飾之道,而後可亨。但飾之終,不戒,則必剝,故為之序。卦體為五陰一陽,乃意陰始自下生,渐長至極盛,群陰皆受剝於陽也。天地之間,山乃高大,但仍附於地上,此為頹剝之象也。外止內順,招剝之象也。而順且止,為處剝之道。\n卦圖象解\n一、婦人床上坐:必有暗災,陰人得勢,陽道衰弱,小人為主官。二、燭在風中:險也。\n三、葫蘆:醫藥無救也;有暗計也。四、繅絲:事務繁冗。\n五、山下官人坐:有靠山,但陰人為禍。 六、冠巾掛木上:求去之象;又有樂之象。\n人間道\n剝:不利有攸往。\n剝乃群陰長盛,剝削陽時,同眾小人剝喪君子,故君子不利往。\n彖曰:剝,剝也,柔變剛也。不利有攸往,小人長也。 # 剝,落也。柔盛而剛剝,陰盛陽必退,此陰陽之道,小人道長盛,君子消退也。\n順而止之,觀象也;君子尚消息盈虛,天行也。 # 君子處剝之時,知不可往,順時而止,乃因能觀剝也。故此卦有順止之象,為處剝之道。君子之人見事於始,凡事之始皆有理,君子視其盈虧消息,而知處之道。順時則吉,逆時則凶, 故君子事天。小人反之,不見事始之作為如何,不論事理之盈虧如何,一昧迎隨奉承,故小人事人。\n象曰:山附於地,剝,上以厚下安宅。 # 山再高大仍依附於地,聖人體此,知人君與上位者,視剝而知厚固其下位之人,以安居也, 下者為上之本,從未有基本固而能剝者也。故上如有剝,必自下。故安養人民以厚國本。書曰\n『民惟邦本,本固邦寧』。\n初六:剝床以足,蔑貞,凶。 # 因剝之始皆自下,故曰剝足,陰自下進, 渐消亡正道,凶之至也。\n六二:剝床以辨,蔑貞凶。 # 此陰漸進,為將者已受剝也,凶益甚也。\n六三:剝之,无咎。 # 眾陰在下剝陽之時,三之相位獨剛應之, 則可無災,但因勢孤弱,雖可無災,但未必能大亨。\n六四:剝床以膚,凶。 # 剝之及身時也,其凶可知。乃因不知始剝於下,以致進盛至此。\n六五:貫魚,以宮人寵,无不利。 # 剝本及於君,凶可知,但聖人於此,知人性之從善如流,以示寵方式,使群陰如魚之貫序,則可無所不利。此寵如對妻妾之同態也。\n上九:碩果不食,君子得輿,小人剝廬。 # 果之大者,如不食,則有復生之理。陰道盛極,天下大亂,但亂極則必思治,故眾人之心,必願載君子,故如得輿。此陰極生陽也。若為小人於此,則必無安身之地。\n象曰:剝床以足,以滅下也。 # 正道之侵滅,必由下而上渐進。用剝之道即在此。\n象曰:剝床以辨,未有與也。 # 小人之侵剝君子,若君子有對應,則小人不能為害,如無對應,則有被滅之凶。\n象曰:剝之,无咎,失上下也。 # 為相之人居剝之時,无災之因乃同類而相失,不與同道,故也。\n象曰:剝床以膚,切近災也。 # 象為剝及皮膚,因四為君側之人,如君之膚,示災禍之近身也。\n象曰:以宮人寵,終无尤也。 # 於剝之及身時,能以寵妻妾之方式對待, 則終無災。\n象曰:君子得輿,民所載也;小人剝盧,終不可用也。 # 因正道消剝至極,則人必思治,上九為陽剛之君子,為民所載也,小人處剝之極,終不可用也。\n"},{"id":77,"href":"/zh/docs/culture/%E5%A4%A9%E7%BA%AA/%E4%BA%BA%E9%97%B4%E9%81%93/%E4%B8%8A%E7%BB%8F/22%E5%B1%B1%E7%81%AB%E8%B3%81/","title":"22山火賁","section":"上经","content":" 22山火賁 # 此卦管鮑卜得之,果獲全,彼此相遜,終顯名義也。\n圖中:\n雨下,車行路,舟張帆江中,官人著公服登梯,仙女雲中執珪。\n猛虎靠岩之課 光明通泰之象\n物之合後則必立文,文乃飾之意。賁者,飾也,故為之序。人因合聚則有威儀上下,物之合聚則必有次序行列。此卦山下有火,草木百物聚於山處,下有火,則能照見草木百物,且增益其光彩,有賁飾之象。\n卦圖象解\n一、雨下:滿也,潤澤也,成也。 # 二、車行路:前往任官象,行走四方也。三、舟張帆在江中:此行順風大利也。\n四、官人著公服登梯:升遷在外地象。任公職主官外調象。丈夫從公職升官也。五、仙女雲中執珪:受陰人提攜象。\n人間道\n賁:亨,小利有攸往。\n物有外飾而後有立,但须有內實而加外飾,則可以亨。因文飾之道可以增加光彩,故必利於小進。\n彖曰:賁亨,柔來而文剛,故亨。分剛上而文柔,故小利有攸往,天文也;文明以止,人文也。\n賁之道能亨,實由飾而來,飾之道,須來往以柔,求文飾以剛,名須正,故利小吉。賁飾之道,並非能增其內實,只外加文彩而已。今人但求外飾,不求內實,富侈表面,內無實才, 比比皆是,上位者所用皆富有之人,此敗亡之初始象也。\n觀乎天文以察時變,觀乎人文以化成天下。 # 天文,為日月星辰之錯列,四時寒暑之變化。人文為人理之倫序,觀人文以教化天下,此聖人用賁之道也。\n象曰:山下有火,賁。君子以明庶政,无敢折獄。 # 山下有火,草木為其明照而有光彩,此賁飾也。聖人觀此明照之象,以修明庶政,成文明之治,無敢於用文飾,掩飾實情,枉法不顧,故君子以用明為戒。\n初九:賁其趾,舍車而徒。 # 初九乃君子有剛明之德,居於下也。因其無位,無法施濟天下,唯求賁飾自己而已。君子修飾之道,在正其所行,守節處義,凡不正則寧不與同行。\n六二:賁其須。 # 外飾於物,無法改變其本質,須因其本質如何加飾,乃賁之道。\n九三:賁如濡如,永貞,吉。 # 賁之盛,須戒永正,則吉,因永正之心及行,很難維持。\n六四:賁如皤如,白馬翰如,匪寇婚媾。 # 賁外飾為白色,白馬亦為白色,但志不同, 由外飾同,而終有婚遂相應也。今人之求門當户對也。\n六五:賁于丘園,束帛戔戔,吝, 終吉。 # 此雖君位,但本質陰柔,不足自守,但求外賁於設險守國,田園近城,山丘在外,據險而守,即令陰柔之質,受人裁制其外,雖君子吝之,但終平安。\n上九:白賁,无咎。 # 賁飾之極,則失於外偽,唯能質白於賁, 則無過飾之災。故尚質素,不失其本真,千萬不可外飾過於華美而失其本質,此戒賁也。\n象曰:舍車而徒,義弗乘也。 # 於義不可同行,寧舍車而步行。\n象曰:賁其須,與上興也。 # 須賁之道,須與上隨,隨上而動,動靜歸依於所主。\n象曰:永貞之吉,終莫之陵也。 # 如飾之變化多且非正,人所陵侮也,故戒之永正則吉。\n象曰:六四當位,疑也;匪寇婚媾, 終无尤也。 # 四與一為應爻,六四當位時,中離間隔二三位,故疑也,但因一與四為正應,理直義勝, 終必無災也。\n象曰:六五之吉,有喜也。 # 君能從人以成賁之功,此吉有喜也。\n象曰:白賁无咎,上得志也。 # 處賁之極,則有過華偽失實之慮,故戒之, 以質素則可無災,飾不可過也。\n"},{"id":78,"href":"/zh/docs/culture/%E5%A4%A9%E7%BA%AA/%E4%BA%BA%E9%97%B4%E9%81%93/%E4%B8%8A%E7%BB%8F/21%E7%81%AB%E9%9B%B7%E5%99%AC%E5%97%91/","title":"21火雷噬嗑","section":"上经","content":" 21火雷噬嗑 # 此卦蘇秦説六國,卜得之,後為六國丞相。\n圖中:\n北斗星,婦人燒香拜,憂字不全,喜字全,一雁食稻,一錢財,一鹿。\n日中爲喜之課 順中有物之象\n觀之後而有來合者,所合者,噬嗑也。故為觀之序。卦才中有一剛居,意為口中有物隔其上下,不得嗑狀,必咬之則得嗑,故稱噬嗑,以此理推而天下事,如天下有強梗或讒邪阻隔其間,使天下事不得合,當用刑法。小則懲戒,大則誅戮以除去,使天下得治。古今萬物皆合而後遂成,如未合必有間阻,若君臣、父子、親人、朋友有怨隙者,因讒言在其間也,除之則合, 是故間隔為天下之大害。聖人體噬嗑之道,知此為天下之大用,能去天下之阻間,唯刑與罰。此卦二體明照而威鎮,乃用刑之象。\n卦圖象解\n一、北斗星:君王也,夫君也,公職也。\n二、婦人燒香拜:妻於人事無助,乃求助於天。三、憂字不全喜字全:乃無又喜象。\n四、一雞食稻:果被食,無果也。\n五、一錢財、一鹿者:於財祿事憂心忡忡也。六、禽:字解為『離多會少』之象。\n人間道\n噬嗑:亨,利用獄。\n此即天下之事不能亨通,乃因有間阻,非刑獄何以除之。\n彖曰:頤中有物,曰噬嗑,噬嗑而亨。 # 如口中有物阻,必用力嗑之,乃能亨通象。用獄之道,所以察情偽,得其情,則知為間之道,而後可以設防與致刑。知噬嗑之道,乃知去間之道,而天下亨通。\n剛柔分,動而明,雷電合而章。 # 此卦才有剛有柔,分而不雜,為明辨之象。察獄之本即明辨,以明而動,上震下離,其動因明也。照與威並行,為用獄之道。人能照,則無隱情,有威則無人不畏,故照與明並用也。\n柔得中而上行,雖不當位,利用獄也。 # 此言治獄之道,如全剛則傷於嚴暴,過柔則失於宽縱。以仁而居剛得中正,則得用獄之道。\n象曰:雷電,噬嗑,先王以明罰敕法。 # 雷威而電明,聖人觀雷電之象,效法其明與威,而立刑罰法令。法之精神乃在教明事理而設防也。\n初九:屨校滅趾,无咎。 # 最下位,下民之象,為受刑之人,用刑之始,罪小刑輕,以木械傷其趾,則不敢進惡矣。此即因小懲而有大戒。小人之福,即在此。\n六二:噬膚滅鼻,无咎也。 # 二居中而得正,是用刑得中正象。如此罪惡易服,然遇剛強之人,刑之必须深痛,故致滅鼻而无災。\n六三:噬臘肉遇毒,小吝,无咎。 # 本身不正又用刑於人,則人不服而怨對反駁,如食臘肉遇毒,反傷於口。此雖有小議, 但無大災也。\n九四:噬乾胏,得金矢,利艱貞, 吉。 # 九四乃居近君之位,乃意愈大,則用刑必重。至此得剛直之道,可克艱其事,又因貞固本身操守,且執意行之,則无咎。\n六五:噬乾肉,得黃金,貞厲,无咎。 # 因五乃居君位,至尊故勢大,以此刑下, 較易如吃乾肉,故為君之道,時天下之間阻物大,須得中而剛,心懷危懼,持續下去,方吉。\n上九:何校滅耳,凶。 # 受刑之極,乃起因於惡之積也,其凶可知。小惡不懲,必成大惡。\n象曰:履校滅趾,不行也。 # 因小罪而小用木械傷其趾,則因懲而不再進為惡也。\n象曰:噬膚滅鼻,乘剛也。 # 此用刑於剛強之人,不得不深痛,乃有戒也。\n象曰:遇毒,位不當也。 # 受刑之人難服,乃因其用刑之人位不當也。今之他國人在本國犯罪,即見之。\n象曰:利艱貞吉,未光也。 # 此時,乃有不足,故雖堅心,但仍未光大也。此貞亦艱也。\n象曰:貞厲无咎,得當也。 # 當正位,以剛而能守正又慮危,故無災。\n象曰:何校滅耳,聰不明也。 # 人之為惡不悟,積其惡以致極大,至此何教之?今之人聰而不明,不知止惡於初,教正以刑,乃至大惡。\n"},{"id":79,"href":"/zh/docs/culture/%E5%A4%A9%E7%BA%AA/%E4%BA%BA%E9%97%B4%E9%81%93/%E4%B8%8A%E7%BB%8F/20%E9%A2%A8%E5%9C%B0%E8%A7%80/","title":"20風地觀","section":"上经","content":" 20風地觀 # 此卦唐明皇與葉靜遊月宫,卜得之,雖有好事,必違也。\n圖中:\n日月當天,官人香案邊立,鹿在山上, 金甲人執印秤。\n雲捲晴空之課 春花競發之象\n臨者,大也,萬物之成大,乃有可觀,故觀所以次臨也。凡觀視於物,即為觀也。人君上觀天道,下觀民俗,為觀之道。風行地上,遍觸萬物,乃周觀之象。以陽剛在上,為群下所觀, 故觀亦有仰之義。\n卦圖象解\n一、日月當天:政治清明,普照大地,父母雙全。\n二、官人香案邊立:倌人,丈夫也。蔡姓人士,神人共明之象。三、鹿在山上:得高祿之象。\n四、金甲人:君側之人也,護衛也。五、執印秤:提印信而來授權也。\n人間道\n觀:盥而不薦,有孚顒若。\n人於祭祀之始,求神之時,心念最專精之時也。居上位者,正其儀表,以為下民之觀,當\n始與終,皆能令民之觀如初始之專精,則盡觀之道也。今人至君位後,禮數繁多,致令人心散, 精一不如初始也。\n彖曰:大觀在上,順而巽,中正以觀天下。 # 五居尊位之人,能以陽剛中正之德,始終如一,為下所觀見,則必能精一也。\n觀,盥而不薦,有孚顒若,下觀而化也。 # 處觀之道須嚴守如始之精一,則下民必至誠瞻仰而從教化,不薦為不使誠意少散。\n觀天之神道,而四時不忒,聖人以神道設敎,而天下服矣。 # 天之道至神,故曰神道。聖人觀天之運行四時,從無差錯,其神妙如此。聖人體神之道而以設教,故天下服也。\n象曰:風行地上,觀。先王以省方、觀民、設敎。 # 風行地上,能觸萬物,聖人體之,知巡省四方,觀視民俗,設為政教,例如見奢則約之以儉,見奸則矯之以正,則民將觀之。\n初六:童觀,小人无咎,君子吝。 # 處最下之位,又為陰柔之本質,離陽剛過遠,是故其觀淺如童稚,故曰童觀。小人觀陽剛之道,不識君子之道,於觀之初,反而無災, 如見君子亦昏淺如是,則可羞也。\n六二:闚觀,利女貞。 # 象曰:初六童觀,小人道也。\n此所觀不明如童稚,此實小人也,故言小人之道。\n觀,乃少見而無甚明也。二居將位,而不能明見陽剛中正之道,則如女子之道,即見之不甚明,又能順從者。將位之人,能如女子之順從,則不失其正,故利。\n象曰:闚觀女貞,亦可醜也。 # 此即君子之人不能觀見陽剛中正之大德,雖能順從,乃同女子也。亦可羞也。\n六三:觀我生,進退。 # 柔質居相位,處觀之時,其進退之道,乃為己者,雖非正,.亦不為過也。\n六四:觀國之光,利用賓于王。 # 四雖陰柔居君側之位,能觀見國之光辉盛大,可見人君之道德也。此因九五君位為聖明之人,致令才德之士 ,皆願進朝廷輔佐,以濟天下。反之聖君因能用敬才德之人,則人才聚之。\n九五:觀我生,君子无咎。 # 九五君位之人,體觀之道,其視天下之俗而知己,若天下之俗,不合乎君子之道,則是己之所為,政治必不佳,不可不怪自己。\n上九:觀其生,君子无咎。 # 上九為不當位之人,以陽剛處之,如同賢人君子不在其位,而道德為天下所觀仰,能如此,皆君子也。\n象曰:觀我生進退,未失道也。 # 觀己知生存之道,而以之進退,此未失道也。\n象曰:觀國之光,尚賓也。 # 君子懷才自守,乃因無明主也。既觀見國之盛德光華,則志願登進王朝,以行其道。\n象曰:觀我生,觀民也。 # 人君欲觀己之施政良否,當觀於民,民俗善,則政化必善。此意即王弼云『觀民以察己之道』。\n象曰:觀其生,志未平也。 # 吾人觀其生存之道,常不失於君子,雖不在位,則亦不失人所望之教化也。\n"},{"id":80,"href":"/zh/docs/culture/%E5%A4%A9%E7%BA%AA/%E4%BA%BA%E9%97%B4%E9%81%93/%E4%B8%8A%E7%BB%8F/19%E5%9C%B0%E6%BE%A4%E8%87%A8/","title":"19地澤臨","section":"上经","content":" 19地澤臨 # 此卦蔡琰去和番,卜得知,必還故國也。\n圖中:\n婦人乘風,一車上有使旗,人在山頂, 虎在山下坐,一合子,人射弓。\n鳳入雞群之課 以上臨下之象\n有事之後乃有可大之心,臨者,大也,故序卦為臨。為卦澤上有地,乃岸也,因與水相近故曰臨,天下之事最近臨者,唯水與地,故地上有水,為此卦,澤上有地,為臨;臨,即臨民臨事,凡所面臨的,皆有臨之道。\n卦圖象解\n一、婦人乘風:外援為陰女,美人也。\n二、一車上有使旗:出使之象,以謀略可解災,謀之對象為女人也。連姓、車姓人。三、人在山頂頭:盛極將衰,招困也,即將下坡也。\n四、虎在山下坐:人受虎困之象。五、一合:先成後破象。\n六、人射弓:張姓人士,夷狄之人,以弓射獵,故外族入侵象。\n故自古天下安治,未有久而不亂者,皆因不能戒盛而已。 # 人間道\n臨:元,亨,利,貞。\n言臨之道,可致大亨也。\n至于八月有凶。 # 二陽才剛生於下,聖人戒之於方盛,方盛而慮衰,則可以防自滿,圖其永久,衰而後戒, 無及也,此乃戒盛之道。今人富而多驕,為安樂而壞綱紀,忘禍亂而任小人奸邪,皆因不知戒盛。\n彖曰:臨,剛浸而長,説而順,剛中而應,大亨以正,天之道也。 # 剛中得正道,而有應助,故能大亨。天道,能化育生萬物,皆因其剛正而和順而已。以此道來臨事,臨人,臨天下,莫不大亨通也。\n至於八月有凶,消不久也。 # 此乃聖人知戒於盛時,不以自滿,則凶不至也。\n象曰:澤上有地,臨。君子以敎思无窮,容保民无疆。 # 地與水之臨近為至近,君子以此知臨之道乃親民如此,才能教導人民。有寬容保民之心, 才能廣大無窮的包容。\n初九:咸臨,貞吉。 # 初為卑下之位,而以正道與四位感應,為所信任而得行其志,故吉。\n九二:咸臨,吉无不利。 # 九二居將位,能以陽剛之心臨人,臨事, 不愧於人,吉無不利。\n六三:甘臨,无攸利,既憂之,无咎。 # 三居相位,柔陰居之,此居上位又不以中正臨下,此失德也,不利。如能憂於此而立改之,則無災也。\n六四:至臨,无咎。 # 此臨之至也,臨道乃由近而生,此位居上之下,又切臨於下,乃處近君之臣位,守正任賢,如親臨於下,則无咎。\n六五:知臨,大君之宜,吉。 # 以一人之身,君臨天下之廣,若皆以自任, 必不能周於萬事,如自認為皆知者,正明示其不知也。唯有能得天下之善,用天下之智者, 則無所不周,這就是不以自認皆知,故其知乃可大。\n上六:敦臨,吉,无咎。 # 此位乃臨之極位,以坤順而居此位,乃意尊而應卑,高而從下,尊賢取善,此為敦厚之極也。皆因其能敦厚於順剛,是以吉而無災。\n象曰:咸臨貞吉,志行正也。 # 陽剛之志在於行正,志正也。\n象曰:咸臨,吉无不利,未順命也。 # 九二乃應於五之尊位,因以剛德之長,又得中道,此至誠相感,而得吉,並非因順上之命而得吉也。\n象曰:甘臨,位不當也,既憂之, 咎不長也。 # 陰柔之人,處臨人事不以中正,又居下之上,乃不適其位,如能知懼而憂之,則必自改其災不長也。\n象曰:至臨无咎,位當也。 # 居近君之臣,陰柔對之,為得正道,與下之剛賢相應,故能無咎。\n象曰:大君之宜,行中之謂也。 # 君臣合道,皆因同氣相求,君能倚任剛中之賢,則能成臨之功,此知臨之意。\n象曰:敦臨之吉,志在内也。 # 內心敦厚篤實,志順於陽剛,其吉可知。\n"},{"id":81,"href":"/zh/docs/culture/%E5%A4%A9%E7%BA%AA/%E4%BA%BA%E9%97%B4%E9%81%93/%E4%B8%8A%E7%BB%8F/18%E5%B1%B1%E9%A2%A8%E8%A0%B1/","title":"18山風蠱","section":"上经","content":" 18山風蠱 # 此卦伯樂療馬,卜得知馬難治,見三蠱同器皿也。\n圖中:\n孩兒在雲中,雁啣書,一鹿、一錢,男女相拜。\n三蠱食血之課 以惡害義之象\n因喜而隨人者,必有事,無事何喜何隨?故蠱以次隨也。山下有風,風在山下,遇山而回, 使物亂,是為蠱象,蠱者,壞亂也。長女下於少男,長幼無序也,其亂可知。此卦象言成蠱之因,卦才專言,治蠱之道。\n卦圖象解\n一、孩兒在雲中:有天官促成也。不求名利之人。二、雁啣書:有消息至。\n三、一鹿:有大利也。\n四、錢在空中:此心中圖利之象也。\n五、男女相拜:男女相合,此為利而結合,非正婚也。\n人間道\n蠱:元亨,利涉大川。\n蠱之大者,乃時之艱難險阻,能有治蠱之才,則必亨通。\n先甲三日,後甲三日。 # 甲為十干之首,意言事之始也。先甲三日,乃言治蠱之道,應先思其事之原,再思其事之後,乃知救弊之道。亦強調慮之深,推之遠也。能善於救者,則前之弊可革,能善親備者,則後利長久。此聖人之治蠱道。今人不明聖人先甲後甲之誡,慮淺而事近,故勞於救亂,亂不革, 功未及成,弊已生矣。\n彖曰:蠱,剛上而柔下,巽而止,蠱。 # 以卦而言,男雖少但居上,女雖長而在下,尊卑得正,上下顺理,治蠱之道也,故以巽順之道治蠱,是以元亨。\n蠱,元亨,而天下治也。利涉大川,往有事也。 # 自古治亂者,能使尊卑上下之義正,在下為巽順,在上有才能安定萬事,如此則天下大治。如正遇天下壞亂之時,持治蠱之道而往濟之,利也。\n先甲三日。後甲三日,終則有始,天行也。 # 有始必有終,終之後必有始,此天道也。聖人體始終之道,故能於事之始而探究其所以然, 能於事之終而備其將然,此先甲後甲之慮,故能治蠱而天下亨。\n象曰:山下有風,蠱,君子以振民育德。 # 山下有風,風遇山而回,致物皆散亂,故為有事之象。君子觀有事之象,在己則養德,在天下則濟民,君子所事此二者為最大。\n初六:幹父之盎,有子,考无咎, 厲終吉。 # 庶民之道,即子幹父蠱之道。子能居其位而知正,則無災,不然,則為父之累,故必惕厲為戒,終吉。初六為最下之道,故言子與父之關係。\n九二:幹母之蠱,不可貞。 # 婦人本陰柔,若子只顧強伸己陽剛之道, 反逆之,則傷恩,所害大也,唯有屈下己意柔順將成,使之身正而事乃治,故曰不可貞。如同陽剛之道輔柔弱之君,須盡誠竭忠致使其於中道即可,不會有大做為的。\n象曰:幹父之蠱,意承考也。 # 子幹父蠱之道,意在能承擔父事,常懷惕厲,只敬其事,盡誠於父,則終吉。\n象曰:幹母之蠱,得中道也。 # 居臣位,不過剛,乃得幹母蠱之道完整者。\n九三:幹父之蠱,小有悔,无大咎。 # 九三乃以陽剛之才居相位,克盡所事,雖以剛過而小有悔,但終無大災,但如以事親之道,則並非善事親之人也。\n六四:裕父之蠱,往見吝。 # 此柔順之才而以正道處事,只能自守其道而已,若遇非正之事,因柔順之本質,而無法矯正,故不能而招罷斥。\n六五:幹父之蠱,用譽。 # 此君位之人但質柔,但下應九二陽剛之位,意其能用陽剛之才為臣,但因本實陰柔, 故可為承其舊業,而不能為開創事業之才。自古創業之事,非剛明之才不足以成事。不能用剛賢之人,只可以守舊袓業而已。\n上九:不事王侯,高尚其事。 # 上九乃居蠱之終,乃無事之地,處事之外。此應賢人君子不遇於時,應高潔自守,不可曲道以遁時,此進退合於道者也。\n象曰:幹父之蠱,終无咎也。 # 此因剛而明斷之才,雖剛因不失正,故終無災。\n象曰:裕父之蠱,往未得也。 # 柔順之才,守於寛裕之時可以,如要前行, 則不可得也,再加其任務,必不能勝任之。聖人知柔順之才可以守成,但不可用於發展,委之重任,必不成也。\n象曰:幹父用譽,承以德也。 # 陰柔之君主,能承下陽剛之才,以在下位之賢而能用之,乃因此而聲譽顯。\n象曰:不事王侯,志可則也。 # 不臣事於王侯,因知無法施其志,此乃賢能之人,此可以為法則也。小人皆可曲道而順行,背己逆天,只圖權力名位,此不可法也。\n"},{"id":82,"href":"/zh/docs/culture/%E5%A4%A9%E7%BA%AA/%E4%BA%BA%E9%97%B4%E9%81%93/%E4%B8%8A%E7%BB%8F/17%E6%BE%A4%E9%9B%B7%E9%9A%A8/","title":"17澤雷隨","section":"上经","content":" 17澤雷隨 # 此卦孫臏破秦,卜得之,便知必勝也。\n圖中:\n雲中雁傳書,一堆錢,朱門內有人坐, 一人在門外立,地上一堆珠。\n良士琢玉之課 如水推車之象\n豫悦之道,人必皆隨之,故豫之後有隨。震為雷,兌為澤,雷震於澤中,澤隨雷而動,此隨之義。以陽而下陰,陰乃隨陽,此陰陽不易之理。『外悦內動象』『下動上悦象』。\n卦圖象解\n一、雲中雁傳書:飛來喜訊象。二、一堆錢:財聚也。\n三、朱門內有人坐:君王之象,必見君面也。\n四、一人在鬥外立地:乃士人求仕,受尊重之象。五、串錢在地:有用利不行之象。\n六、亂珠聚盆:先聚後散之象。\n全卦:禮儀得體,有見速喜之象。 # 人間道\n隨:元,亨,利,貞,无咎。\n隨之道,可以致大亨通。君子之道,眾人所隨,又遇事擇所隨,隨能得道,必致大亨。今\n人君之從民意,臣下之從君意,人人隨從義,利在堅守隨義,隨因能正,其道必亨。小人群聚, 此隨失正,豈不招凶乎。\n彖曰:隨,剛來而下柔,動而説,隨。大亨,貞,无咎,而天下隨時。 # 雷為剛今居柔下,是以尊貴而對下隨,為隨之義。臣君關係,以上悦下動,以動可悦,為隨之道,如是則亨通而無災。\n隨時之義大矣哉。 # 君子之道,隨時而變,因時制宜。今世昏蒙不明,凡事皆表明裡暗,是故民之所隨,未能適中,常受謡言、外飾所惑,隨而必枉,受奸人利用。\n象曰:澤中有雷,隨,君子以嚮晦入宴息。 # 澤隨雷震而動,君子晝則自強不息,夜則入居內安其身,起居作息隨天時,乃宜也。禮云: 君子晝不居內,夜不居外,此隨時之道。\n初九:官有渝,貞吉,出門交有功。 # 官為守也,因所從隨為正,故吉。出門交往多所親愛者,因人心所從隨也。常人之情, 愛之所見皆對,惡之所見皆非,小人皆以私情而隨從之,不合正理,君子出鬥交不以私,故所隨皆有功也。\n六二:係小子,失丈夫。 # 六二為陰柔位,陰乃順從,柔順之意,但如順隨的對象為小人,則必失卻君子之人,此因不能固守,聖人戒之。\n六三:係丈夫,失小子,隨有求得, 利居貞。 # 六三居相位,所固守本身之正,求上之隨, 有求必得,乃上上隨也。如背是求非,舍君子從小人,為下下之隨也。故君子之隨,首先固守本身之正。\n九四:隨有獲,貞凶,有孚在道以明,何咎? # 因四位乃臣之極位,如隨而有獲,即正亦易招凶,皆因天下之心隨於己。為臣之道,應使恩威出於上,眾心能隨於君,如讓人心從己, 致凶之道。故君子居此,唯誠正於心,所動皆合於道,則可無悔。小人位極,用君王權,遂行其志,得民心又不歸功於上,位極而逼上, 勢強而專權,隨之過大矣。君子雖功大震主, 但由於其正而心誠,故主隨從而信之,何有災?\n九五:孚於嘉,吉。 # 此君位之人,得中正而固守,動隨於善, 大吉也。故此,從人君到平民,隨道之吉,唯隨善耳。\n上六:拘係之,乃從維之,王用亨于西山。 # 象曰:官有渝,從正吉也。出門交有功,不失也。\n所隨從之道,須從正則吉。隨從不正,招悔咎也。因交非因本身之私,其交必正,故不失。\n象曰:係小子,弗兼與也。 # 人因所順隨為正道,邪惡必遠也,但須戒, 人若從於正,須專一,不可兼與。\n象曰:係丈夫,志舍下也。 # 隨之至善即在此,舍小人而從君子,人人如此,小人無所遁也,常自省而人之性乃從善如流,故將無小人矣。\n象曰:隨有獲,其義凶也,有孚在道,明功也。 # 近君之位,因追隨而有得,雖有凶戒,但有中正之誠,則無災,明哲保身也。\n象曰:孚于嘉,吉,位正中也。 # 處中正之位,又中誠所隨,必吉矣。\n此柔順而居隨之極也,柔順之隨至極雖有過之失,但如人心皆隨,因有得民之隨,仍有王道王業之成也,故能得民心者,得天下也。\n象曰:拘係之,上窮也。 # 隨之道至此,乃窮盡也。\n"},{"id":83,"href":"/zh/docs/culture/%E5%A4%A9%E7%BA%AA/%E4%BA%BA%E9%97%B4%E9%81%93/%E4%B8%8A%E7%BB%8F/16%E9%9B%B7%E5%9C%B0%E8%B1%AB/","title":"16雷地豫","section":"上经","content":" 16雷地豫 # 此卦諸葛孔明討南蠻,卜得之,便知必勝。\n圖中:\n兩重山,一官人,一祿一馬,金銀數錠,錢一堆者。\n鳳凰生雛之課 萬物發生之象\n人有大量而又能謙,必豫,豫者悦也,豫卦承大有及謙之序也。上動而下又順,以象言, 陽之始,潛伏地下,及出於地,乃因動,即為雷,故雷出地上,有通暢和順象。\n卦圖象解\n一、兩重山:出象,千里阻隔也。\n二、官人在中:外出不任官象;丈夫外出從商。三、一祿一馬:從商之人,財祿豐盛。\n四、金銀數錠:紙錢錠,主喪服。五、錢一堆者:內有憂心忡忡象。\n全卦:此卦內有憂,外有阻,乃順勢而動,祿馬來會,可去官從商矣。 # 人間道\n豫:利建侯,行師。\n聖人體豫悦之道,知兵師之興,諸侯之封,必眾心和悦方成,故能君臨萬邦,能群聚大眾, 唯和悦可也,此豫之時也。\n彖曰:豫,剛應而志行,順以動,豫。 # 動之順理,眾順而回應,其志乃行,故豫乃動而眾順。\n豫順以動,故天地如之,而況建侯行師乎。 # 順理而動,天地之道亦如是,更何況立建諸侯,興兵之師。\n天地以順動,故日月不爲過,而四時不忒;聖人以順動,則刑罰清, 而民服。 # 天地之順而動,吾人可視四季之行,日月運轉永不為過,聖人稟此,而知因正而民相爭於行善,刑清罰簡,萬民皆服。\n豫之時義大矣哉。 # 聖人知豫而體用豫之道,故於時機之掌握大矣,此卦之下有十一卦,豫、遯、姤、旅,皆言時之義。坎、睽、蹇,皆言時之用。頤、大過、解、革,皆言時之大也。\n象曰:雷出地奮,豫。先王以作樂崇德,殷薦之上帝,以配祖考。 # 雷動出於地而奮震,悦之象也。先王作聲樂以褒揚功德,歸之上帝、祖先,此言,豫之道, 能知,能體,則盛而至大也。\n初六:鳴豫,凶。 # 初六乃下位,陰柔處之,是意不中正之小人,處豫時,為上所寵,志得意滿,乃至於發聲,小人之輕淺如是,必至凶也。\n六二:介于石,不終日,貞吉。 # 豫悦之道,易流放縱,過則失正道,乃不合於時。此六二本陰位,今陰爻居位,乃處中正,為自守之象,君子處豫之時,獨其能以中正自守,示出其節介如石之堅也,故吉。能見事於幾微者,謂之神妙。君子之人上交不諂, 下交不瀆,因能知微,吉凶之始,可先見於此也。守堅如石,則能不惑而明。\n六三:盱豫,悔。遲有悔。 # 六三乃以陰而居陽位,為不中不正之人居相位也。如此動則有悔,盱為上視也,其動竟上視君側之人,故不中不正,悔之始也。是故君子明處身不正,進退皆有悔矣。處已之道, 在正本身而已,以禮制心,即處豫卻不失中正, 則無悔矣。\n九四:由豫,大有得,勿疑,朋盍簪。 # 九四乃君側之位,動則眾陰悦順,此豫之義。豫悦之由,以陽剛而任此位,大行其志, 而天下皆悦。人能盡誠則無疑,上下因至誠而信,合而聚之,簪,乃聚髪之物。\n六五:貞疾,恆不死。 # 六五為君位,當豫悦之時,陰柔有沉溺之象,故乃柔弱不能自立且耽於酒色之豫道,受制於專權之臣,因受制於下,故有疾苦,古今人君致危之道很多,但以縱情於樂居多,豈有不死乎?\n上六:冥豫,成有渝,无咎。 # 豫之極,而以陰柔居之,乃聖人示意,凡人之失,苟能自變,則亦可以无咎,此乃為君子。如昏迷不知反省,必招凶。\n象曰:初六鳴豫,志窮凶也。 # 初六處下而驕鳴,雖外飾喜悦,實乃窮極而凶。\n象曰:不終日貞吉,以中正也。 # 人能中正,且守堅,故能辨之於早,此君子處豫之道。\n象曰:盱豫有悔,位不當也。 # 因自處不當,失卻中正,造成進退有悔, 處不當位也。\n象曰:由豫,大有得,志大行也。 # 志得大行於天下,乃皆由聚所悦也。\n象曰:六五貞疾,乘剛也;恆不死, 中未亡也。 # 君位有疾,乃因側位專權之人壓制也,如能不死,乃因側位之剛為中正,方不致於亡。\n象曰:冥豫在上,何可長也。 # 昏冥於豫悦,乃至於終極,災難至矣,不可長久,君子當速變。\n"},{"id":84,"href":"/zh/docs/culture/%E5%A4%A9%E7%BA%AA/%E4%BA%BA%E9%97%B4%E9%81%93/%E4%B8%8A%E7%BB%8F/15%E5%9C%B0%E5%B1%B1%E8%AC%99/","title":"15地山謙","section":"上经","content":" 15地山謙 # 此卦唐玄宗因祿山之亂,卜得之,乃知干戈必息也。\n圖中:\n月當天,一人騎鹿,三人腳下亂絲, 貴人捧鏡,文字上有公字。\n地中有山之課 仰高就下之象\n大有至終必不可盈滿,故聖人受之序卦為謙。天之道盈滿,則必損地道,此卦為亦謙之義, 以自然界喻之,外卦為地,內卦為山,即山居地下,山乃至高大之物,而今居地下,此處卑乃謙之象,亦示人有崇高之至德,而處卑下,乃盡謙之義。\n卦圖象解\n一、月當天:清明之政,中秋時也。二、一人骑鹿:才祿俱備。\n三、三人腳下亂絲,隱於山後:小人不敢正面示人,惟隱山謙之後(即外飾謙為幌子)實則亂如麻,計無所出。\n四、貴人捧鏡:明鏡高懸,執法公正光大。五、文字上有公字:處事以公,得理也。\n全卦:不知謙下自晦,果必招訟也。 # 人間道\n謙:亨,君子有終。\n有德而不居,為謙,人能以謙遜自處,無往不利。君子之志在達理,樂天命而無競之心, 但求內充,退讓而不衿持。能安於謙,終身不易。小人則有慾必競,此種人見有德之人必攻之, 即令有人告之謙遜,但終不能宽行固守。故小人,必不能有終也。\n彖曰:謙,亨,天道下濟而光明,地道卑而上行。 # 君子明謙之道而能亨。天之道因其氣能下濟,故能生育萬物,故道乃光明,故君子知謙而能使其道濟天下萬民,地之道亦同天,因自處卑地,故能上交於天,是故天地二者,皆因能卑降而終亨通也。\n天道虧盈而益謙。 # 天之道有日月五星,其隨時盈晦,故能降氣於地,生養萬物。\n地道變盈而流謙。 # 地勢如盈滿,則必倾變而下陷。\n鬼神害盈而福謙。 # 天地之道乃形而上之學,吾人可推理求之。鬼神人道乃形而下之學,吾人可親而見之,世間萬物必有其用,凡盈滿者,必有禍害,能謙損者,必有福祐之,猶今之西醫,其但知能如何除瘤用毒,不知損害之大,故後遗症之多,而自不見之。\n人道惡盈而好謙。 # 人情必惡人之盈滿,好人之謙損,故聖人戒盈而勸人為謙也。\n謙尊而光,卑而不可喻,君子之終也。 # 君子之道因謙卑而光大,至誠之念如是而終。\n象曰:地中有山,謙。君子以裒多益寡,稱物平施。 # 高大之山在地下,故示人外卑下,內實高大乃謙象。君子有過則損之,不足則益之,以之用於事,使萬物皆平衡也。\n初六:謙謙君子,用涉大川,吉。 # 最卑下之位以柔態處之,又謙讓也。能如是,君子也。即涉險難,必無凶也。\n六三:嗚謙,貞吉。 # 二位以柔順居中,謙之德至此,則須明倡於外,見諸聲色,堅心如此,吉也。\n九三:勞謙,君子有終,吉。 # 三為相位,以陽剛居之,上為君所任,下為民所從,能知勞而不居功,謙下待人,君子能行之至終,故吉。今上位掌權之人,盡皆示民其功勳為何,不知勞謙之美,此為小人之道長之時也。反之,能盡勞謙之道,必為君子。\n六四:无不利,撝謙。 # 四體居近君主之位,此時因六五之君,以謙柔自處,九三之相位,又因大功德為上所用, 此當恭畏侍君,卑謙以讓勞謙之臣,所有布施, 均為謙也,切不可不利於謙。\n六五:不富以其鄰,利用侵伐,无不利。 # 富貴,眾人之所嚮往,以財來聚人。今五以居尊之君王,不以富而能得人親,皆因知謙順,乃能得天下。然須戒君道,切不可專尚謙順,必須威柔並濟,才能服天下,故易曰:「利用侵伐」。威德並重,方為君道。\n象曰:謙謙君子,卑以自牧也。 # 君子以謙卑之道,於初入世間而自處之。\n象曰:鳴謙貞吉,中心得也。 # 因中心能自得,山至大又能知謙,故吾人須有高厚實力,乃真謙非勉力為之也。\n象曰:勞謙君子,萬民服也。 # 此勞謙君子,能服萬民也。\n象曰:无不利,撝謙,不達則也。 # 六四因近君之位,又居勞謙臣之上,絶不可不利謙道,此得宜之法也。\n象曰:利用侵伐,征不服也。 # 君王之道,須用征伐,其用謙德所不能服者,否則不能治天下,則為謙之過也。\n上六:鳴謙,利用行師,征邑國。 # 上六處謙之極也,因極謙反居高位,為太過於謙,為倡明其謙,只有用剛武之道,於已之私有地,征之。此因謙之太過而致如此。\n象曰:鳴謙,志未得也。可用行師, 征邑國也。 # 因極謙又居上,其不適謙,故志不得伸, 則鳴,唯宜以剛武自治其國內。\n"},{"id":85,"href":"/zh/docs/culture/%E5%A4%A9%E7%BA%AA/%E4%BA%BA%E9%97%B4%E9%81%93/%E4%B8%8A%E7%BB%8F/14%E7%81%AB%E5%A4%A9%E5%A4%A7%E6%9C%89/","title":"14火天大有","section":"上经","content":" 14火天大有 # 此卦藺相如送趙壁往秦,卜得之,果還壁歸趙也。\n圖中:\n婦人腹中一道氣,氣中二小兒,一藥王,藥有光,女人受藥,一犬。\n金玉滿堂之課 日麗中天之象\n同人之序卦,與人同者,物必歸,此天紀也。故大有為同人之序,物歸之後,乃大有也此卦,火在天上,火之處高,其明及遠,能照萬物,此為大有之象,一柔居尊位,眾陽應和,此居尊執柔,物乃所歸,上下相應,為天子之道。\n卦圖象解\n一、婦人腹中一道氣:妊娠之事;事萌於內也。二、氣中二子:主雙喜臨鬥。伯仲之間象。\n三、一藥王:平安之象。得病有救,遇良醫也。四、藥有光:藥有名也,災中有救也。\n五、女人受藥:女人為陰,示表面不信,但暗中仍聽之。\n六、一犬:乃狗年,戌月,戌日,此論時機也;或狄姓人士。七、犬:哭笑皆有象。\n人間道\n大有:元亨。\n此乃以卦才而言,不言元亨利貞四德,乃恐與乾坤二卦相混淆,只言元亨乃盡元之義,元乃物之始,萬物生成之初皆為大善,因必有功用。此為大善之初,易經唯四卦,大有、蠱、升、鼎有元亨。其不與乾坤同,在乾為首出萬物,元始之象,在此四卦唯至善為大而已。\n彖曰:大有,柔得尊位,大中而上下應之,曰大有。其德剛健而文明, 應乎天而時行,是以元亨。 # 此卦之所以為大有,乃因五以陰柔居尊位,又處以中庸大中之道,為諸陽所依歸,上下相應,此居尊執柔,有虛中之象,卦德內剛健而外明理,處事順乎天應乎人,此所以元亨。事於成後,方可見成敗,敗非先現於事前也。故有得後乃有所失,非得則何言有失乎?\n象曰:火在天上,大有,君子以遏惡揚善,順天休命。 # 火因在天上,故能照見萬物,君子觀大有之象,故知治眾之道,唯遏惡揚善,此能順天命而安眾民心,天子之道即如是。\n初九:无交害,匪咎,艱則无咎。 # 以陽剛之性處卑下之位,因未至於盛,故不有驕盈之慮,因無交往,故無害。人之富有於財或才,鮮不有害,富有本無罪,但人卻皆因富有而招害,乃因處富有而不知思艱戒盛, 則生驕侈之心,此所以生害也。\n九二:大車以載,有攸往,无咎。 # 此位以剛健之才,居於陰柔之位,而為六五君位所信任,故無災。就如大車之材,強壯能载重物,可以任重行遠,往而無災。\n九三:公用亨于天子,小人弗克。 # 九三居下卦之上位,為諸侯之位,諸侯雖享有土地之富,人民之眾,但仍屬天子所有, 此人臣之義。但小人居此位則專其富有以為私,不知奉上以公之道。\n九四:匪其彭,无咎。 # 九四陽剛居大有之時,因近君位,如處之太盛,則致凶,彭為盛大之義,須謙損,方為吉道。\n六五:厥孚交如,威如,吉。 # 人君之位,以陰柔守之,以誠信待於下, 下亦誠信待上,上下相交,此人心易安,但若專以柔順,則必生悔慢之心,故以威信,故君子柔中有威,使下屬有畏,其吉可知也。\n上九:自天祐之,吉,无不利。 # 此位乃大有卦之終極,仍明之極意。人唯至明,故不居於已有,有極大之位,而不為已有,則無盈滿之災,君能至此,則合於天道, 得天之祐,則無往不吉。大同之世來矣。\n象曰:大有初九,无交害也。 # 此即在大有之初始,即生戒心,須時思念艱難之時,此居安思危之來源,故能如此,則交亦無害也。故與人交往,持富有而驕,必生害也。\n象曰:大車以載,積中不敗也。 # 大車能載重物,重物集中而不損敗,言九二才力之強,能勝大有之重任。\n象曰:公用亨于天子,小人害也。 # 諸侯能守臣節,忠順奉上,慎養其眾,為王之徵用。若小人處此位,則不知奉上之道, 以所有為私有,故小人居此位,則有大害也。\n象曰:匪其彭,无咎,明辯皙也。 # 明智之人,處此大有盛大之時,戒在咎之將至,以謙損態度應之,不敢至於極滿招凶之道也。故無災。\n象曰:厥孚交如,信以發志也;威如之吉,易而无備也。 # 上下相交以誠信,互相呼應,上位如無威嚴,則下屬易生毁謾,故君須戒無威。\n象曰:大有上吉,自天祐也。 # 大有至極處,本有變化,但由所為皆順天合道,此君子滿而不溢,故天祐之。尊尚賢人, 崇尚信義,故為上吉。\n"},{"id":86,"href":"/zh/docs/culture/%E5%A4%A9%E7%BA%AA/%E4%BA%BA%E9%97%B4%E9%81%93/%E4%B8%8A%E7%BB%8F/13%E5%A4%A9%E7%81%AB%E5%90%8C%E4%BA%BA/","title":"13天火同人","section":"上经","content":" 13天火同人 # 此卦劉文龍在外求官,卜得之,果衣錦還鄉。\n圖中:\n人捧文書上有心字,人張弓,射山上, 一鹿飲水,一溪。\n游魚從水之課 二人分金之象\n天地不交則否,上下相同則同。遇否之世,必與人同力方能渡過。故次否也。以象言,天之性在上,火之性炎上與天同,故為同人。以卦體言,五為君位乾主,二為離位,陰柔居二爻。上下相同之義,天性剛健,火性明耀,即外健內明之象,吾人外剛健,內明道,則成天火同人, 此為大同之道。\n卦圖象解\n一、人捧文書上有心字:得民心之象,同心協力之象。寧姓。二、人張弓射山上:高中金榜。張姓人氏。\n三、一鹿飲水:财祿滾滾而來。亦在野之賢人。四、一溪:平和安靜狀。\n五、鹿之性,動則敏,靜則順,此剛健人之性,世間但聞虎食人,未聞鹿食人。\n人間道\n同人:于野,亨。利涉大川,利君子貞。\n野即遠與外之意,以天下大同之道,聖賢大公之心,使之無遠不同。常人之同,以私意所合,此暱親之情。故必于野,方可謂大同。天下皆同時,何險阻能阻礙?何艱困能致危?故利涉大川,君子須堅心於此。小人唯用私意,所親比者亦為小人,朋黨之始,其心不正也。\n象曰:同人,柔得位,得中而應乎乾,曰同人。 # 二爻以陰居陰位,此為正位,得中正之德,應乎乾上,五為君位,以剛健中正,二以柔順對應,上下同心同德,故曰:同人。\n同人于野,亨,利涉大川,乾行也。 # 乾之行,必至誠無私,故可以涉險難,而吉。無私,天德也。\n文明以健,中正而應,君子正也。唯君子爲能通天下之志。 # 下體為有文明之德,上卦為剛健之性,此君子之正道。天下之志萬殊且異,但理則一也。君子因明理,故能通天下之志。聖人視億萬人之心為一心,因只一理而已。文明能使人更明理, 故能明大同之真義,剛健必能克已,則必能致大同之道。\n象曰:天與火,同人,君子以類族辨物。 # 天在上,火之性炎上,此為同人,君子觀同人之象,以分類群族各以其性同來區分,如君子小人,善惡是非,物之外形固異,但事理皆同,君子能辨明。\n初九:同人于門,无咎。 # 初進為剛健,此因無所偏私,出門在外因無私,而無咎也。\n象曰:出門同人,又誰咎也。 # 出門在外,因同人之道而無私,同此道之人又廣,無厚此薄彼之異,則無人歸咎也。\n六二:同人于宗,吝! # 宗為宗黨也,同於所派系,此有所偏,故可吝。故只用宗黨之人則有偏私,此為鄙吝之人。\n九三:伏戎于莽,升其高陵,三歲不興。 # 二以中正之道與君五位相同志,三以剛暴之人居二五之間,欲奪,然理不直,義不勝, 不敢發動,故只能藏兵戎於莽林間,時升高陵顧望,至三年之久,仍無機可乘,此小人之情狀。\n九四:乘其墉,弗克攻,吉。 # 九四乃陰位,陽剛居之,故以剛居柔位, 其近君位,知義不直而能復返正道,亦吉也。\n三以剛居剛,故終其強而不可能反。此示人畏義能改則終吉。\n九五:同人,先號咷而後笑,大師克相遇。 # 人君當與天下大同,如獨私一人,則非君道也。其同志為二爻,間隔有三四之剛,故未遇之前憤怒而號咷,即遇則大笑,故二人同心, 其利斷金,中誠所同,無所不同,天下莫能離間,則無所不入,故聖人赞之曰:『同心之言, 其臭如蘭』。\n上九:同人于郊,无悔。 # 求大同之道,必相親相與,即在外又遠之地不同,但亦永無悔矣。\n象曰:同人于宗,吝道也。 # 同人之道用於宗親,乃有所私,因私比, 非人君之道,故此為吝道。今人皆如是,莫若太宗之用人。\n象曰:伏戎于莽,敵剛也;三歲不興,安行也。 # 因三爻乃剛暴之人,其五君位為剛且正, 故畏忌而不敢與,即三年亦不與。\n象曰:乘其墉,義弗克也;其吉, 則困而反則也。 # 因同人乃一陰,而眾陽所同欲也。今獨三四爻有爭奪之意,乃邪不勝義,其困窮乃因反於法則,故吉。\n象曰:同人之先,以中直也;大師相遇,言相克也。 # 同人之先以中誠理直,故同心之力大,雖敵剛強,如大師相遇,由其義直理勝,終能克之也。\n象曰:同人于郊,志未得也。 # 此申明同人之道,在外及遠之地求同之志不成,即無悔,但非最善之道。\n"},{"id":87,"href":"/zh/docs/culture/%E5%A4%A9%E7%BA%AA/%E4%BA%BA%E9%97%B4%E9%81%93/%E4%B8%8A%E7%BB%8F/12%E5%A4%A9%E5%9C%B0%E5%90%A6/","title":"12天地否","section":"上经","content":" 12天地否 # 此卦蘇秦將遊説六國,卜得之,果為相矣。\n圖中:\n男人臥病,鏡破,人路上坐,張弓箭頭落地,人拍掌笑,口舌。\n天地不交之課 人口不實之象\n上泰之終,六爻以陰柔(小人)處之,則為否之初。天地相交,陰陽相通,則為泰,今天在上,地在下,是天地隔絶,不相往來,故成否卦。\n卦圖象解\n一、男子臥病:男子為陽,陽同佯,裝病之象也。二、镜破:不可赴也。破鏡難圓之象。\n三、人路上坐:小人阻路,不讓進也。四、張弓箭頭落地:鳥盡弓藏也。\n五、人拍掌笑:幸災樂禍之小人,做假攻訐兩人感情得逞狀,夫妻凶。六、舌:主口舌,官司,謡言生害。\n七、口上有四點:四點為黑,暗中誣陷之謡言,古人謂三口成虎,即意此。\n人間道\n否之匪人。不利君子貞,大往小來。\n舉凡天地之間皆為人道,今天地不交,不生萬物,是無人道,故曰匪人。處此之時,君子不可堅心,因正道不行,小人道長之時。\n彖曰:否之匪人,不利君子貞,大往小來,則是天地不交,而萬物不通也。上下不交,而天下无邦也。内陰而外陽,内柔而外剛,内小人而外君子,小人道長,君子道消也。 # 天地不交,萬物不生。上下不和,則天下無治國之道。治世乃上位施政來治民,人民擁戴君王而願從命,上下相交,此治國之道。今君子居於外野,小人處於朝內,故為小人道長,君子道消之時也。\n象曰:天地不交,否。君子以儉德辟難,不可榮以祿。 # 君子觀否塞之象為外健內顺(柔)。處否之時,損儉自己,避開禍亂。千萬不可榮居祿位, 戀棧不去。因小人得志之時,君子仍居顯位,則禍必及己身。反之,仍居顯位不放,乃真小人也。即內小人,外君子之意。\n初六:拔茅如,以其彙,貞吉,亨。 # 否之在最下者,為君子也。因否而不能進者,君子也。處否而能進者,小人也。因在下位,又貞固其節志,同類而聚,雖不進升,但亦吉矣。\n六二:包承,小人吉,大人否,亨。 # 六二本陰爻位,今陰柔居之,以小人而言, 其心所包容的,皆承顺上位之意,以求本身之利,此小人之吉。大人處否,則以道之陽剛自勉,絶不枉屈正道,奉承上位,雖身處否,但道仍亨也。\n六三:包羞。 # 不中不正之人居否之時,位居相位,不能守道安命,極盡小人之能事,每日謀慮邪事, 無所不包,此羞恥之大也。\n九四:有命,无咎,疇離祉。 # 居君側之人,最忌有居功招忌之事,因否時君道不正,即令有濟世之大才,亦不堪用。如能使事事出於君令,威柄皆歸於上,則\n無災而可實現大志。當君子道行,同類必同進同出為天下黎民福祉著想。小人亦同進退也。\n九五:休否,大人吉,其亡,其亡, # 繋于苞桑。\n處否之時,惟有陽剛中正之人居君位,能去天下之否,即大人吉。如循環至泰,亦須戒盛警惕否之復来,此其亡,其亡之意。此戒须如同苞桑,桑為根深蒂固之物,苞乃叢生之物, 其固更強,此為安固之道。此為聖人之深戒也。 繫辭:『危者安其位者也,亡者保其存者也, 亂者有其治者也。是故君子安而不忘危,存而不忘亡,治而不忘亂,是以身安而國家可保也。』\n上九:傾否,先否後喜。 # 物極必反,泰極則否,否極則泰,故否之極,即否道將覆,則泰矣,故曰後喜。\n象曰:拔茅貞吉,志在君也。 # 君子處否時而居下位,冀得同類而進,如遇小人,則堅守其節,但心仍在天下。\n象曰:大人否亨,不亂群也。 # 處否而守其正節,乃為大人,不與小人同亂為群體,形雖否,但其道吉,此道必大。\n象曰:包羞,位不當也。 # 居否時,所為邪吝,羞於公正,居此相位而不適,此不可以為正道。\n象曰:有命,无咎,志行也。 # 凡事皆由君命而出,則無災,且大志得行。\n象曰:大人之吉,位正當也。 # 因有陽剛中正之德才,又居君子之位,能去天下之否,故吉也。\n象曰:否終則傾,何可長也。 # 否之終必傾危,絶無永否之理,但反危為安,易亂而治,必有陽剛之才,乃能做也,故否卦之上九能傾否,如屯之上六則不能去屯同意也。\n"},{"id":88,"href":"/zh/docs/culture/%E5%A4%A9%E7%BA%AA/%E4%BA%BA%E9%97%B4%E9%81%93/%E4%B8%8A%E7%BB%8F/11%E5%9C%B0%E5%A4%A9%E6%B3%B0/","title":"11地天泰","section":"上经","content":" 11地天泰 # 此卦堯帝將襌位,卜得之,乃得舜而遜位。\n圖中:\n月中桂開,官人登梯,鹿銜書,小兒在雲中,羊回頭,地下亂點。\n天地交泰之課 小往大來之象\n履之後(有禮而始終如一)則有泰,即履得其所,則舒泰而安也。故泰所以次履也,坤柔在上,乾陽居下,乃天地陰陽之氣相交合,萬物生成,故表通泰狀。卦象外柔內健,此為致泰之道。\n卦圖象解\n一、月中桂開:清明政治之時。\n二、官人登梯:升遷順遂之意,官人亦倌人,即丈夫也。三、鹿銜書:受天子恩賜祿位。\n四、小兒在雲中:少年得志象。得天官貴人助也。 五、羊回頭:回陽間也。亦楊姓人,發肖羊人成格。\n人間道\n泰,小往大來,吉亨。\n陽氣下降,陰氣上升,陰陽和暢,萬物生焉,此天地之泰。人間之泰,大則居上,小則臣下,君王推誠任下,臣盡誠以事君,上下同志,朝廷之泰。君子,小人亦如是,君子處君位, 小人居下位,天下之泰也。\n彖曰:泰,小往大來,吉亨。則是天地交而萬物通也,上下交而其志同也。 # 陰往而陽來,天地氣相交,萬物通泰。人則上下之志相同,互相交通,人之泰也。「內陽而外陰,內健而外順,內君子而外小人,君子道長,小人道消也。」陽進陰退之時,故內健外順,君子之道也,君子在內,小人在外,此所以為泰。\n象曰:天地交,泰。后以財成天地之道,輔相天地之宜,以左右民。 # 此示人君須體會交泰之道而為法制,使民用天時之宜,輔助教育人民之功,使民有財,輔助於民,民則必賴君上之法制,因法治之宜也,得其生養。\n初九:拔茅茹,以其彙,征吉。 # 剛明之才,居於下位,遇泰之時,志而上\n進,遇同志而行同道,因同類而進,吉。凡君子小人都須賴同類以助,未有人能獨立而不須朋類之助。\n象曰:拔茅征吉,志在外也。 # 同類相聚,如拔茅之根相牽連,同欲上進。\n九二:包荒,用馮河,不遐遺,朋亡,得尚于中行。 # 九二為將位,以剛明之才,五為柔順而得君位,上下之專信建立,此位乃治泰者,故治泰之道,主將位而能包荒,如人情放肆所為, 則政令緩,法度廢弛,治此之道,必有包含荒穢之量,詳密施政,去其弊端,則人安之。處泰之道,人之常情習於久安,惰於因循,不敢變更,用馮河,乃奮發改革之意,雖至小至微之事亦不可遺漏,自古立法治事,牽於人情, 卒不能行者多矣。如禁奢侈則害近戚,限田宅, 則防礙貴族之家,此治泰之難。遇治泰,須稟持公正之態度,即中行意。\n九三:无平不陂,无往不復,艱貞无咎,勿恤其孚,于食有福。 # 三居諸陽之上,乃泰之盛時。聖人為之戒, 在下者必升,居上者必降,泰久而必否,故戒之。故當此時,不敢安逸,居安思危,則無災。故處泰之道須能堅貞,可常保泰。自古以來隆盛皆因內失道而喪敗下來。\n六四:翩翩不富,以其鄰,不戒以孚。 # 翩翩,疾飛之貌,人富而從其類,為利也。不富而從其者;志同也。上三爻皆為陰,陰在上而失其中實之道,皆欲下從,故為不富而從, 此誠意相合也。如至六四位方戒已晚,居三為適中,知戒可保,四位則已過,必生變化。\n六五:帝乙歸妹,以祉元吉。 # 為居君位,古之帝女皆向下嫁,帝乙制禮法,须降其尊貴,以順從其夫也。今六五以陰柔居君位,對應之九二為陽剛,乃能信之,而任用其賢且順從之,猶帝女之下嫁亦然,此成治泰之功也。\n上六:城復于隍,勿用師,自邑告命,貞吝。 # 此致泰之終也,小人處之,行必否矣。掘隍土累積成城,如治道累積以成泰,今城土頹圮,又復返於隍也。勿用師,夫用師之道,必得民心,今民心不從,用師必亂,此時即自有天命任之,亦逄羞而凶矣。\n象曰:包荒,得尚於中行,以光大也。 # 有包含荒穢之量,又配合中行之德,其道則明顯光大。\n象曰:无不復,天地際也。 # 陽降於下,必復上,陰升於上,必復下, 此示人明天地交泰之道不常存之理也,聖人戒之。\n象曰:翩翩不富,皆失實也;不戒以孚,中心願也。 # 因三陰在上,失其實才,欲行往下,不待其人富而鄰從,皆因失實故也。此已失乃方知戒之意,此時不待告誡而誠意相待,乃出於中心所願也。\n象曰:以祉元吉,中以行願也。 # 其能任用剛中之賢,乃因已有中道與之合,志同相交也。\n象曰:城復於隍,其命亂也。 # 此意城傾圮,又回歸隍土 ,即令命為正, 但亂已不可止矣。\n"},{"id":89,"href":"/zh/docs/culture/%E5%A4%A9%E7%BA%AA/%E4%BA%BA%E9%97%B4%E9%81%93/%E4%B8%8A%E7%BB%8F/10%E5%A4%A9%E6%B3%BD%E5%B1%A5/","title":"10天澤履","section":"上经","content":" 10天澤履 # 此卦子路出行,卜得之,遇虎拔其尾也。\n圖中:\n笠子下一女,文書破,手中有傘,軍旗官人邊坐,堠上有千里字,地上兩點足印。\n如履虎尾之課 安中防危之象\n天地間萬物由畜道而止之後,有禮生焉,履者,禮也,故畜之後為履。所有物之聚大小不同,高下不等,有美惡之分,是故物聚後必有禮。人到何處,必有禮也。卦體天在上,澤在下, 上下區分,尊卑立見,此理所當然也。此以柔藉剛,謙卑而順,説禮之道也。澤有卑順之意, 卦象『外健內卑』,禮之本也。\n一、女人在笠下:妾也。\n卦圖象解\n二、千里堠:遠離外出封侯象。\n三、文書破在地:憑證承諾無效象。四、傘下:有庇蔭也。\n五、軍旗下官人坐:主有官司、訴訟、牢獄之災象。\n六、二點在地:踐踏之足印也,示人處危之時,有禮之道,乃能行危而無殃也。\n人間道\n履虎尾,不咥人,亨。\n有禮之道即入險中,如踐虎尾,不見其反食人,乃因有禮也,所以亨也。故常言伸手不打笑臉人,同此。\n彖曰:履,柔履剛也。説而應乎乾,是以履虎尾,不咥人,亨。 # 兌為澤,為悦,兌以柔順之藉乾剛,與乾剛相乎應,又有禮於下,故踐虎尾而不相害也。\n剛中正,履帝位而不疚,光明也。 # 九五以陽剛又居君位,又得履道之善,此不病之因,乃得光明也。\n象曰:上天下澤,履。君子以辯上下,定民志。 # 天在上,澤在下,尊卑有分,此天下之正理也。人之禮常如是,有禮乃行也。君子觀履象, 以辨上下之區分,來立民志,民因上下區分明顯而志定,自此乃可言治。立法複雜,民無所從, 治世將不再。是故人各安其位,此得其分內也,如佔位又不進德,除之,由君子進任,士人進修學識,到一定程度而君子求之。士農工商各行業之人,因所享有限,而能有定志,則天下一\n心也。今人自下至公卿,日所志為尊榮,農工商人,日思於富侈,億兆之心交相為利,天下皆如此,心如何一致?要它不亂也難矣!皆因上下無定其志也。君子觀履,分區上下,使各當其位,用此以定民心之向也。\n初九:素履往,无咎。 # 初處於下,陽剛之才可以進,但外則表現其卑下之位的素養,無咎。\n九二:履道坦坦,幽人貞吉。 # 二為陰位,陽居,即意其待人之禮坦蕩蕩, 平易之道也。因為陽進,故須有防人嗤之禮數之誹,是以安幽清靜之心情處此時,則吉。\n六三:眇能視,跛能履,履虎尾, 咥人凶,武人爲于大君。 # 三為陽位,柔居之,乃志欲剛而體本柔, 不能堅心為履道。就如盲人之視而不見,跛人行路而不遠,意乃才能不足,又處時之不順遂, 則禮道非正,乃履於危地,因不善履道,入危地召凶,禍患立至,故咥人凶,就如武暴之人卻居人上,又任意為之,不知禮,乃凶之道。\n象曰:素履之往,獨行願也。 # 安於有禮之往,因非為利也,乃各有其志也。如人有行道之心,又有名利之心,交戰於中,豈能安履。\n象曰:幽人貞吉,中不自亂也。 # 履之道(禮之道)在於安靜,其因正,則所履安也,心中躁動,則不能安於禮道,此即有禮於人,必以心中安靜,如以利欲交爭於心中, 必自亂。\n象曰:眇能視,不足以明也,跛能履,不足以與行也。 # 陰柔乃才不足之人,視不明,行又不遠, 今又居剛之上,災難來矣。\n咥人之凶,位不當也,武人爲于大君,志剛也。 # 此凶之致,乃因才不足,以武人為喻,其居陽剛之位,但才不足,又強出之,則所履不由本道,屬於志剛又妄動。\n九四:履虎尾,愬愬終吉。 # 在近君之側,知伴君如伴虎,愬愬,畏懼之貌,意如能畏懼,則終必吉。上位之陽剛, 雖近處,能敬慎畏懼,即入危地終亦必吉也。\n九五:夬履貞厲。 # 夬乃剛決之意,九五雖示君位之人,居此位,任意剛決而行之,即得正,仍危厲也。古之聖人,居天下之尊,仍納眾言,明足以照, 剛足以決,必以明而動,動則志剛,此之所以為聖也,若自以為剛明,決行不顧,即使行正, 亦屬危道也。\n上九:視履考祥,其旋元吉。 # 人之禮,須視其終,若始終完備,善之至也。今人淺視人之表面禮遇,而不考證其始終, 禮之至善,其『始終如一』方是。\n象曰:愬愬,終吉,志行也。\n畏懼之貌,入危而終吉,因本心在於能行己之志願,故須居此位,故為行其志,而示畏懼之態,終得吉,其志遂行。\n象曰:夬履,貞厲,位正當也。 # 居正當之尊位,須戒剛決自任,否則招凶。\n象曰:元吉在上,大有慶也。 # 人之所以履善終能吉,貴乎有終,始終如一,禮之至極也。\n"},{"id":90,"href":"/zh/docs/culture/%E5%A4%A9%E7%BA%AA/%E4%BA%BA%E9%97%B4%E9%81%93/%E4%B8%8A%E7%BB%8F/09%E9%A2%A8%E5%A4%A9%E5%B0%8F%E7%95%9C/","title":"09風天小畜","section":"上经","content":" 09風天小畜 # 此卦韓信擊取散關不破,卜得之後,再撃之果破也。\n圖中:\n兩重山,一人在山頂,舟横岸上,望竿在草中,羊馬過河。\n匣藏寶劍之課 密雲不雨之象\n比乃親和,親和之後必有所畜,因物相比親則附於一處,為畜,此畜者,聚之來源也。同相親和之人,其志必同畜。畜亦止也,止然後有聚。此體本乾為天,乃在上之體,今居風之下, 示意吾人如要聚止剛健之人,唯有巽順為大,故世間再剛健之事與人,必為巽順所止也。本卦一陰居第四位,為五陽所包,此得位乃因柔巽之道也。卦象『外柔內剛』,乃能以小畜大。\n卦圖象解\n一、兩重山:知有險阻於前,不妄動之象。亦為出字象。二、一人山頂:孤立獨行象。已至顛峰,將走下坡之意。三、舟横岸上:準備出發,水未至而不行也。\n四、望竿在草裡:等待訊息象。草頭姓人為貴人。望,亡也。\n五、上有羊馬:馬引羊過河,馬為貴人,肖馬人,姓馬人也,馬亦為午時。\n全卦:一人在頂顛,但有疑,前有二山所阻,只有等待消息,午年或午月、午日、午時或馬人引羊至,不由水路而來也。 # 人間道\n小畜,亨。密雲不雨,自我西郊。\n此言雲之畜聚雖密,卻不能成雨,猶人雖聚,卻不能和之義。\n彖曰:小畜,柔得位而上下應之,曰小畜。 # 小畜之卦,因第四爻陰,居近君主之位,以巽之柔順本性,使上下之陽剛相互溝通,但只能維繫,卻不能強固,故曰小畜。\n健而巽,剛中而志行,乃亨。 # 以卦象而言,內剛健而外能柔順,雖為小畜,但能亨也。\n密雲不雨,尚往也。自我西郊,施未行也。 # 此言密雲不能成雨,猶小畜無法成大也,其因下陽往上,上陽往下,二氣不和。\n象曰:風行天上,小畜,君子以懿文德。 # 以乾之剛健,風仍能在上而行,故剛健之性,唯柔順能畜止之,君子以小則文章才藝,大則道德經綸之聖才,以此二道為所畜之才義。\n初九:復自道,何其咎,吉。 # 初入之地為初爻,以其剛健之性,又得上位之同性,進必上,何來災也。故初之陽剛, 乃因上位之陽剛性同,故無災也。\n九二:牽復,吉。 # 九二居將位,因與第五爻陽爻相對應,同為剛健,雖中有四爻,但因同為陽剛,自古『同患相憂』〔同慾相憎〕,吉之道,此時為將時。\n九三:輿説輻,夫妻反目。(説即脱) # 九三為下卦之上爻,最親密於四爻陰位, 故乃陰陽之情相處也。猶如夫妻。陰本受制於陽,今居四位居陽之上,即反制陽,如夫妻之反目,故如車輿脱去輪軸,不能行也。妻為夫所惑,反制於夫,乃因夫不正道。未有夫不失道,而妻能制之也。\n六四:有孚,血去惕出,无咎。 # 六四乃處近君之位,其能畜君,使五位君之威嚴能因之而止其欲,皆因六四有孚信(孚, 乃內有誠信也〕而受其感也。\n九五:有孚攣如,富以其鄰。 # 小畜之時,乃眾陽皆為其中一陰所畜之時。猶如一國之君,與臣下皆剛而不容(密雲不雨〕,但近君側之人,適得柔順之人,而此人為上下溝通之管道,此時乃即小畜之時也。九五為君位,如以中正居至尊之位,又內有誠信,則所有皆附應之。就好像富人出其財力與鄰共享之也。從此爻則知當君子為小人所困, 正人為艰邪所逼,此時如無上下正陽之互援, 則獨力難助於此時,須有互助方可去小人也。\n上九:既雨既處,尚德載,婦貞厲。月幾望,君子征凶。 # 上九為此卦之終極,乃處畜之終。和而能止乃畜之道,陰柔之能畜剛,由累積而成,非朝夕可得,此戒之在平時,如專任以柔制剛, 不知止,如婦之堅守此道則乃凶矣,天下無婦制其夫,臣制其君,尚能安穩者乎?月滿之時為月望,與日相敵狀。若君子待婦將成敵時,\n動之必凶,不須戒也,故须戒之於月未滿時。\n象曰:復自道,其義吉也。 # 初爻與第四爻本為對應之位,故在畜時, 陰在四,陽在初,陰陽相應,故吉。\n象曰:牽復在中,亦不自失也。 # 初爻為陽剛,復二爻亦陽剛,其勢乃強, 但因此時乃居中位,將位,故即強亦不會過剛, 過剛乃失,此天道。故吾人須知居中而陽剛之美,因理正,故能強剛,君子持之以理,雖剛亦不為過矣。\n象曰:夫妻反目,不能正室也。 # 因陽剛位之夫,過剛而失自處之道,不能顧家室,以致夫妻反目。故為夫之道,須正室其家,方為正道。\n象曰:有孚惕出,上合志也。 # 因四位有誠信且外柔,使五之君位信任之,乃合同志。五因四位之人能與之合,眾陽從之,故惕出則血去,不見兵禍矣。\n象曰:有孚攣如,不獨富也。 # 是故君子處之艱危,唯其至誠,能得眾力之助,則平安矣。(攣如即牽連使相從之。)\n象曰:既雨既處,德積載也;君子征凶,有所疑也。 # 此小畜之道積滿而成,其象為陰將盛樣, 君子動則有凶也。小人抗君子,必有害於君子, 制陰極盛之道,則為君子有疑而知警惕,不妄動,求其所以制君子之因,則不至凶也。\n"},{"id":91,"href":"/zh/docs/culture/%E5%A4%A9%E7%BA%AA/%E4%BA%BA%E9%97%B4%E9%81%93/%E4%B8%8A%E7%BB%8F/08%E6%B0%B4%E5%9C%B0%E6%AF%94/","title":"08水地比","section":"上经","content":" 08水地比 # 此卦陸賈將説蠻,卜得之,果勝蠻王歸也。\n圖中:\n月圓當空,秀才望月飲酒,自酌自斟, 藥爐在高處,枯樹開花。\n眾星拱比之課 水行地上之象\n師之後,師者眾也,眾聚有相比親也。比,相親也,有眾則必有比,故次師也。物之相近莫如水與地,永遠相比附,故卦體為水在地上,地上有水象。水為險,地為順,故外險內順為比卦。險猶戰戰兢兢,恐得罪人象,內心又持順之地道,有坤厚之德性,此比之道也。\n卦圖象解\n一、月圓當空:政治清明象。\n二、三星拱照:得賢能剛直之人輔助。\n三、秀才望月飲酒:示才智之人無憂也。(亦示:作秀之人,於政治清明時,無法出頭〕四、自酌自斟:無慾則剛象,孤獨之象。\n五、藥爐在高處:無病無災,故不需用藥也。\n六、枯樹開花:晚發也。示心有誠,制度立,事必成。七、酒:忘憂之物。\n全卦:上位清明,三台輔佐,人將無憂,即使枯樹亦能開花。 # 人間道\n比:吉,原筮,元永貞,无咎。不寧方來,後夫凶。\n比為吉之道。人相親比,必有其道,須視可比而決定比之,不可比而比,凶。如果等到不能自保安寧之時,方開始求親比,幸運的得所親比,可得平安。但如仍獨立自持,求親比之想法不前反後,即使是丈夫之親人,亦招凶矣。\n是故君王亦懷柔天下,下位之人亦和親其上,未能獨立也,平日即須有此志,天地之間, 沒有不相親比而能獨立生存的。\n相比之道,须兩志相求,如互不相求則為睽卦,故戰國策〈中山策〕有同慾相憎,同憂相親之理。所以好的制度,能使人有所依從,故能互相親和也。\n彖曰:比,吉也。比,輔也,下順從也。 # 因相親比為吉之道,有相輔相成之意。但求比之道,須如臣下順從君上一樣,即使地位相同,或上下有別,決不可因持於己之高位,而忘順從之道,此乃真比之道。\n原筮,元永貞,无咎,以剛中也。 # 元、永、貞為相比之道。元為君長之道,永為可以長久,貞謂得正道且堅其心志,人有此三德性,再以陽剛當君主之位,此為君之德也。如此之君主可以無災矣。\n不寧方來,上下應也。 # 上位之念,應知君不能獨立,须保民以此為安。下民知己不能自保,須擁君以求寧,此上下相應之理。如以王之私而行為之,不求下民之附和,凶危立至矣。\n後夫凶,其道窮矣。 # 若眾人之志相和親,則無有不遂,此天地之道,若人之和親不行,則雖夫亦凶矣。\n象曰:地上有水,比。先王以建萬國,親諸侯。 # 天地之間,物相親比,莫如水在地上,聖人觀比之象,以此建萬國,近諸侯,親近人民, 此比之極道。\n初六:有孚,比之无咎。有孚盈缶, 終來有它,吉。 # 此為比之始。孚為中信,故相比之道,以誠信為本,表裡不一致,人誰近之。誠信充實於內,如於缶中之滿,且外不加修飾(它,外也〕,至誠以待之,則無不信。\n六二:比之自内,貞吉。 # 自內言主之在己也。得正道,而與君道相合,此吉也。即以中正之道,合於上位之所求, 乃曰自內。今人汲汲以求比者,非君子自重之道,乃自失之道也。\n六三:比之匪人。 # 相親比之人,如為不正當之人,即為匪人, 招凶也。\n六四:外比之,貞吉。 # 六四之位,為近君之位。居此位之人,以柔性坤德向君主親比,且堅心順從,吉也。\n象曰:比之初六,有它吉也。 # 此即比之道在乎始也。始即能中實誠信, 終吉,始無誠信,終凶。\n象曰:比之自内,不自失也。 # 堅守己中正之道,以待上求,乃不自失也。此易之戒。故士人修己,方求上進之道。降志辱身此絶非自重之道。吾人救天下之心,並非不急切,乃因須待禮遇之至方出也。\n象曰:比之匪人,不亦傷乎。 # 相親於匪人,必將得悔咎,傷之大矣,君子須深戒所親比之人。\n象曰:外比於賢,以從上也。 # 六四之位從於五君之位,為外比。因五位剛明之賢為中正,故附從之,此從上之道,絶不可盲從。\n九五:顯比,王用三驅,失前禽,邑人不誡,吉。 # 人君親比天下之道,须誠意以待物,恕已以及人,發政施仁,民之所好好之,民之所惡惡之,為民圖利,使天下蒙其澤,此盡善也。則天下亦盡親於上也。如表彰己之小功,展示自己的權力,違反道德人心,強力施為,致人心不信,社會浮華,人偷敗亡,如此而求天下親比是不可能的。就好比君王出獵,只圍三面開一面,禽獸前去者,免矣,此曰失前禽,只取來者, 此王道也。邑者,居邑,誡,期約也。此意臣之效於君,竭盡忠誠,用盡才能,以彰顯親君之道,此為正道。至於用之與否,決定在君,萬不可阿諛奉承,妄求君之親比也。朋友亦然,須修身誠意待人,至於是否願意與己相親,在人而己,千萬不可巧言令色,曲從苟合,以求人之相比。『此顯比之道』。\n象曰:顯比之吉,位正中也。舍逆取順,失前禽也。邑人不誡,上使中也。 # 故顯比之所以吉,以其所在之位,得適中之法也。且比之道,须舍逆取順,孟子收徒之道: 往者不追,來者不拒也。三面圍網,所失唯前禽,即言此來者撫之,去者不追之理。\n上六:比之无首,凶。 # 上六乃比之終極處。凡比之首,其始善, 終亦必善矣。多數人皆有其始,而無其終,但從未有人沒有開始,而有終善也。故此言,比之無首(首,始也〕,至終必凶矣。今天下之人,始親和比之時不以正道,至終必有隔閡, 此類人居多數。\n象曰:比之无首,无所終也。 # 此即始不以道,則至終必也凶矣。是故交友之道,你來我往,你不來,我何往。如我往, 必有求,須以正,否則終將隔隙,互相仇視也。\n"},{"id":92,"href":"/zh/docs/culture/%E5%A4%A9%E7%BA%AA/%E4%BA%BA%E9%97%B4%E9%81%93/%E4%B8%8A%E7%BB%8F/07%E5%9C%B0%E6%B0%B4%E5%B8%AB/","title":"07地水師","section":"上经","content":" 07地水師 # 此卦周亞夫將欲排陣,卜得之,果獲勝。\n圖中:\n虎馬羊待命,將軍臺上立,一文一武綬印,人跪台上受印,棋盤在下。\n天馬出群之課 以寡服眾之象\n師之興,因有爭議也。此卦本體上順下險之象,亦即地下有水,內外分即內險外順,有行險之道如何順行之義。水在地下必聚,如部隊之集結,如九五為一陽,餘為諸陰,則為天子之象,今九二為將位,統帥諸陰,則為將帥之象。此卦申明處師之時,內須戒慎警惕,不可掉以輕心,外須統一專權,命令如一,方能行順之義。\n卦圖象解\n一、肖虎、馬、羊之人三合:可戰。二、文武二官綬印:得萬人服之帥。\n三、人立棋盤上:圍棋愈下愈多,但須有謀略方可成事。 四、將軍臺上立:示有兵權也。生殺之專權也。武職大利。\n五、羊回首:回陽也,病危之人卜之,有自陰間回陽間之象。六、虎在馬後:為馬之靠山,馬因虎威而動。故馬回首視虎。\n人間道\n師:貞,丈人吉,无咎。\n此言,師之道必有正名,因師之與,天下萬民必傷,如不以正,則民心不從,致凶之道。丈人,即尊貴有眾望之人,故出師必立帥將,此人必民所聽從順同,不能服眾人,安得民心。如此則無災。故興師必有二道:正名與主將。\n彖曰:師,眾也;貞,正也,能以眾正,可以王矣。 # 師為帶眾之道,必以正,如能使眾人皆正,即可王天下。此用師之正道。\n剛中而應,行險而順。 # 出師之將帥,須得君王之專信也。此王者之帥,民心從之,雖有險必順矣。\n以此毒天下,而民從之,吉又何咎矣。 # 如從前面所論之師道下手,則雖因出師傷下之百姓,民仍從之,故吉而無災。\n象曰:地中有水,師,君子以容民畜眾。 # 水在地下相聚,為眾聚之象,君子之人須以包容民眾,為使民順從之法。\n初六:師出以律,否臧凶。 # 師之出,必以誅亂制暴而動,行師之道, 须以律法,合於義理,如不按此法,即致勝亦凶。\n九二:在師中,吉,无咎,王三錫命。 # 此言九二之道,為為將之道,君之事為人臣絶無敢專權,但出師作戰之時,則將在外君命有所不從,君王須順從其命,以專信任之。\n六三: 師或輿尸,凶。 # 輿尸,眾人為主也。此言師旅之事,任當專一,須以剛中之才居上為信,乃得成功。如不這樣,而以眾之意見為主,凶之道也。\n六四:師左次,无咎。 # 左次,乃知不可進而退。此言,師之常法, 見可而進,知難而退,進退有據,平安無災。六四為陰爻,主陰柔,而師必以強勇,中有陰柔,即兵家之風、林、火、山同義。\n六五:田有禽,利執言,无咎。長子帥師,弟子輿尸,貞凶。 # 五為君之位,此為興師之主,此君主興師任將之道。師之興,必以生民受災,蠻夷賊寇, 此正名以誅之。如禽獸之入於田中,害五穀, 於義宜獵取,則獵取之,如此之動,則吉。如無禽獸入田,則出不因禽,凶。此有禽之義。\n上六:大君有命,開國成家,小人勿用。 # 師之終極,言功之成也。此時君主以爵位財祿賞賜有功之人,並任用之。但於軍旅征戰中,查覺出之小人,不能因其有功而任用之, 致凶之道。\n象曰:師出以律,失律凶也。 # 師出必有律法,失律法,致凶之道。\n象曰:在師中吉,承天寵也。王三錫命,懷萬邦也。 # 人臣如無君之專寵,則不得專制之權,更何論成功。\n象曰:師或輿尸,大无功也。 # 如倚二三人以上,必誤之時也,不但無功, 凶禍至矣。\n象曰:左次,无咎,未失常也。 # 言師之道,退亦未必為失道,退須得宜, 無災。\n象曰:長子帥師,以中行也,弟子輿尸,使不當也 # 任將授師之道,必以長子帥師。此長子義, 非定為真長子,而示意為任用可信任之如長子者,可以為帥,以其專任後之權必大,故必有信:方可。若以眾人主之,無主將帥之師,雖名正出師於義,亦凶道也。\n象曰:大君有命,以正功也。小人勿用,必亂邦也。 # 君王有持恩賞之權柄,来表揚軍旅之功, 小人雖亦有賞,用之必亂邦,史上小人持功而亂邦,有太多案例了 。\n"},{"id":93,"href":"/zh/docs/culture/%E5%A4%A9%E7%BA%AA/%E4%BA%BA%E9%97%B4%E9%81%93/%E4%B8%8A%E7%BB%8F/06%E5%A4%A9%E6%B0%B4%E8%AE%BC/","title":"06天水讼","section":"上经","content":" 06天水讼 # 此卦漢高祖斬丁公,疑惑,卜得之後,果遭戮也。\n圖中:\n口舌二字,山下有睡虎,文書在雲中, 人立虎下,望口舌,柳樹在旁。\n蒼鷹逐兔之課 天水相違之象\n人之所需飲食也,因有所須,爭訟之所由起。天陽上行,水性就下,其行相違,所以成訟也。卦義:外健內險之象,必有訟。\n卦圖象解\n一、口舌二字:官司,糾紛,災也。二、山下有睡虎:入危地而不知象。三、虎:王姓,肖虎之人。\n四、文書在雲中:心想事不成,幻想也。 五、人立虎下:近險也,近險為脱險之道。\n六、柳樹:隨風而動,雖大風而不斷,此柳之性也。〔韓信之辱,即柳性〕\n人间道\n訟:有孚,窒惕,中吉,終凶。\n訟之道,必中實,如中無有實,乃誣妄,凶之道也。訟,乃與人爭辩,而待決於他人,雖有孚,仍會窒塞。故有得中實則吉,但終極其事則凶也。\n利見大人,不利涉大川。 # 因訟者求辩曲直,故利見大人,如大人能以剛明中正決其訟,吉。因訟非吉事,須擇安地而居,不可陷於險難,故不利涉大川也。\n彖曰:訟,上剛下險,險而健,訟。 # 此內險外健,訟之所起。若健而不險,不生訟也。險而不健,不能訟也。猶如一人,只重外飾,內無真實材料,此為訟之源也。\n訟,有孚,窒惕,中吉,剛來而得中也。 # 訟之,有中實剛健,但處訟之時,雖有中實,仍有阻礙而須惕懼,則吉。\n終凶,訟不可成也。 # 因訟本非善事,乃不得已也,終極其事,則凶矣。\n利見大人,尚中正也。不利涉大川,入于淵也。 # 如見中正之大人,吉。與人訟必先居於平安之地,任意行險,則身陷。\n象曰:天與水違行,訟,君子以作事謀始。 # 此因天上水下,二卦體相違,訟之由也。君子觀象,知人有爭訟之道,故行事必「慎始」, 絶訟端於事之始,則訟不生矣。\n初六:不永所事,小有言,終吉。 # 此陰柔居下位,必不可終極其訟也。若不終極其訟,雖小有言傷,而終得吉。\n九二:不克訟,歸而逋,其邑人三百户,无眚。 # 二爻與五爻為相應之地,九二乃將位,以剛處險,與五之君位為敵,知不可敵,歸而避之,儉樸自處,則無過矣。三百户,乃邑之至小者,如處強大,此競也,則必過也。\n六三:食舊德,貞厲終吉。或從王事,无成。 # 陰爻居三陽剛位,乃陰柔居二剛之間,须知雖處危地,能知危懼,終必獲吉。守原之本分而無所求,則不生訟矣。或從上而成,因從王事,故不在己也。訟乃剛健之事,故始則不永,三則從之,皆可使善也。\n九四:不克訟,復即命,渝安貞, 吉。 # 此陽剛居乾下,因不得中正,本必訟。故四爻剛位陽居之,雖剛健欲訟,而無與對敵, 則訟無由而興。此即若能克制剛忿欲訟之心, 就於命,革其心,平其氣,而變為安貞,吉矣。孟子云:「方命虐民,夫剛健而不中正,則躁動,故不安,處非中正,故不貞,不安貞,所以好訟也。」方,不順也。\n九五:訟,元吉。 # 九五居尊位,治訟得中正,則大吉而盡善。\n上九:或錫之鞶帶,終朝三褫之。 # 剛健之極,處訟之終極,人因其剛強,窮極於訟,取禍喪身,即使善訟能勝,即賞,亦來自與人仇爭所得,其能保乎。終一日而見三次褫奪也。\n象曰:不永所事,訟不可長也。雖小有言,其辯明也。 # 此即於訟之初,即戒訟,因柔弱而居下, 不可長久。因既訟,必有小災,應辩理之明, 終得其吉。\n象曰:不克訟,歸逋竄也。自下訟上,患至掇也。 # 因義不敵,故不能訟,須避去其所。自下訟上,義不正,且氣勢不足,此招禍患之至易也。\n象曰:食舊德,從上吉也。 # 守其本分,順從上之所為,非由己意,終也吉。\n象曰:復即命,渝安貞,不失也。 # 能如上,則無失,吉也。\n象曰:訟元吉,以中正也。 # 此云中正之道,施必吉也。\n象曰:以訟受服,亦不足敬也。 # 窮極訟事,即有受命之寵,亦不足以敬, 而可賤惡,禍患隨至也。\n"},{"id":94,"href":"/zh/docs/culture/%E5%A4%A9%E7%BA%AA/%E4%BA%BA%E9%97%B4%E9%81%93/%E4%B8%8A%E7%BB%8F/05%E6%B0%B4%E5%A4%A9%E9%9C%80/","title":"05水天需","section":"上经","content":" 05水天需 # 此卦蔡順遇赤眉贼,卜得知,乃知必脱大難也。\n圖中:\n月當天,一門,一人攀龍尾,一僧接引,一墓。\n雲靄中天之課 密雲不雨之象\n蒙之後須養,此需之時也,飲食之道也。雲之上於天有蒸潤之象,就如同飲食之於人一樣, 此養之時,乃待也。乾之性健,必採進法,仍處坎險之下,故須待而後進,即外險內健,此卦之象,乃真養之義。\n卦圖象解\n一、月當天:陰人居上位象,無擔當也;清明無災。二、 一門:豪鬥也,官府也。\n三、一人攀龍尾:附於貴人,想進據其位也。\n四、一僧接引:光頭之人,柔而無慾之象。入空門可解。五、墓:藏棺之地,有官與財象;置之死地而後生之象。\n全意,即使如龍之力大,但動不以時,則有入墓之險,此動為求官與財。佛理亦同:『生死一線,無死焉大生』。 # 人间道\n需:有孚,光亨贞吉,利涉大川。\n以二卦體而言,乾以剛健,如上進而遇險,此未可進也。需待之。以卦之爻言,五爻陽居陽位,乃陽剛中正之德人,居君位,而誠信充實於內(內卦為乾),則光明而可進,必亨,故利涉大川。\n彖曰:需,須也,險在前也,剛健而不陷,其義不困窮矣。 # 因險在前,未可遽進,须待而進,以乾之剛健,能待而不輕動,則不陷險中,則必不至困窮。時下剛健之人頗多,其動必躁,如能待時而動,則為至善之道。\n需:有孚,光亨貞吉,位乎天位以正中也。 # 孚,中實之義。五位因剛實居中,此孚之象,此居天位而能正中,光明而亨通,且須貞正\n(堅心),吉也。\n利涉大川,往有功也。 # 因中實(內之學問、操守)而貞正(堅心向道),即涉險阻,亦可有功也。故需之道在以乾剛之性而知待之道,何所不利。\n象曰:雲上於天,需,君子以飲食宴樂。 # 此自然之象,水上於天未成雨,為雲。猶君子積蓄其才德,而未施於用也,懷其大才,安以待時,飲食以養身體,宴樂和其心志也。\n初九:需于郊,利用恆,无咎。 # 初爻因最遠於險,故於郊(曠遠之地), 故君子處於曠遠之地,仍安守其常,則無咎災也。如躁進犯難,則必災至矣。\n九二:需于沙,小有言,終吉。 # 坎為水,水近則有沙,此二爻之位近險, 故需于沙。君子知渐近於險難,雖未至於害, 已小有言矣。此示言語之傷,至小者也。二爻以陽爻居之,示人陽剛之才居陰柔位,守中正之德,雖小有言語之傷,而无大害,終也吉。\n九三:需于泥,致寇至。 # 泥,逼近於水也。因逼近於險,而致寇難之至,此居健體之上,有進動之象,苟非謹慎, 終致喪敗也。\n六四:需于血,出自穴。 # 第四爻位以陰柔之質居於險,下又有三陽之進,必傷於險難。因傷於險難,必不可安居, 而失其居所,故出自穴。此順時以從,不競於險難,則不至凶也。又無中正之德,只以剛競於險,此適足以致凶之道也。\n九五:需於酒食,貞吉。 # 此五君位,陽剛之人居中,只需宴安酒食以待之,所需必得也。堅心,必吉。\n上六:入于穴,有不速之客三人來, 敬之終吉。 # 陰柔於六位,乃需之極限,須安其處,此入於穴之義。安而止居,則下之三陽必來。不速,不促之而自來也。此時須以至誠盡敬之心以待之,雖再剛暴,必無欺凌之理也,此因六位陰位,非三陽乾體之人,志在必奪,故敬之則吉。\n象曰:需于郊,不犯難行也,利用恆,无咎,未失常也。 # 君子處下野,不冒犯險難而行,復宜安處, 不失其常,保持恆靜心亦不動,如雖不進而志動,則不能安其常也。\n象曰:需于沙,衍在中也;雖小有言,以吉終也。 # 此寓,二位雖近險,而如以寬裕居中,即小有言語之傷,及終得吉。\n象曰:需于泥,災在外也。自我致寇,敬愼不敗也。 # 三位居上險之最近,故云災在外。此寇致之因,實乃己之逼也。須敬慎小心,量宜而進, 則无喪敗。義在相時機而動,戒之盛也。即盛時須戒之象。\n象曰:需于血,順以聽也。 # 意為陰柔之性居近險之位,不能處,則退, 以順從而聽於時,不至凶也。\n象曰:酒食貞吉,以中正也。 # 陽剛中正居五之君位,只需酒食,即可盡其道也。\n象曰:不速之客來,敬之終吉;雖不當位,未大失也。 # 此不當位,意為以险而在上也。聖人明示陰宜居下而今居上,此不當位也。但如能謹慎自處,則陽再剛亦不能欺,終得其吉。\n"},{"id":95,"href":"/zh/docs/culture/%E5%A4%A9%E7%BA%AA/%E4%BA%BA%E9%97%B4%E9%81%93/%E4%B8%8A%E7%BB%8F/04%E5%B1%B1%E6%B0%B4%E8%92%99/","title":"04山水蒙","section":"上经","content":" 04山水蒙 # 此卦王莽篡漢社稷卜得知,乃知漢家必有中與之主。\n圖中:\n一鹿一堆錢,一合子,李樹一枝子折, 二人江中撑船,珍寳填塞。\n人藏祿寶之課 萬物发生之象\n艮為山,為止,坎為水,為險。山下有險,遇險而止,莫知所之,蒙之象也。\n卦圖象解\n蒙卦有示吾人去蒙之道。反觀之,如人以蒙蔽之法示人,勢必有所圖也。圖中:\n一、滿船珍寳:暗示人圖財之象。二、船在水上:乃離國他去象。\n三、鹿在地上,示仍有小祿於內,鹿—祿也。\n四、兩串錢:乃憂心忡忡象,即意雖有祿,但仍令人憂心。\n五、圖中一碗:示此蒙蔽手段,必先成後破,因其乃不正之財,必不久遠也。六、李樹子折:示人李姓人氏,且有子夭折之象,則成格。\n人间道\n蒙:亨,匪我求童蒙,童蒙求我。初筮告,再三瀆,瀆則不告,利貞。 # 兒童之蒙,其未發蒙,而志則專一,此為童蒙,因有童蒙,則告之。因其童蒙,故必至誠一意以求必中。而發蒙之道,必以貞正方吉。\n象曰:蒙,山下有險,險而止,蒙。蒙亨,以亨行,時中也。匪我求童蒙,童蒙求我,志應也。 # 此因剛賢之才居九二爻位,處於下位。六五之童蒙,處於君位。九二之賢臣,必以時中也。時之義,必待其至誠一心之童蒙求問,方以告之,乃中與。如賢能之人處下,而自求為進,主動告以君,則必無被信用之理。故如方法正確,非二求於王君位,實為五之志應於下二也。此為「時中」也。\n初筮告,以剛中也,再三瀆,漬則不告,瀆蒙也。 # 此言,如誠一而來求決其蒙,當以剛中之道開發之,如煩數不能誠一,則乃瀆蒙,此時, 不當告。因告之必不能信受,徒以煩瀆,無益矣。\n蒙以養正,聖功也。 # 此申利貞之義,養蒙之法,必以正道,此時乃純一未發之童蒙時,故養正於蒙,乃學之至善也。現今人類皆「教而後禁」,故難以教勝,故時風日下。\n象曰:山下出泉,蒙,君子以果行育德。 # 此蒙之象也,如人蒙蔽,未知所適從狀。君子此時,必以果決其行,使通行無阻。如始生而方法不對,則以養育其明德為教法。\n初六:發蒙,利用刑人,用説桎梏, 以往吝。 # 初六之爻屬最下位,此言,發下民之蒙, 须明刑禁以示之,使之知懼,從而教之。是故為政之始,立法居先,治蒙之初,威以刑者, 是以使民去其昏蒙之桎梏。不設法去其蒙之桎梏,即善教亦無法改變其蒙,故聖人使下民畏威以從,不敢任意其昏蒙之欲,然後才能漸知善道,此為移風易俗之唯一法門。但只有初爻之始可用之,如專用刑以為治,則蒙雖畏,終不能发。\n九二:包蒙吉,納婦吉,子克家。 # 九二有剛明之才,與六五之君相對應,其志又一同,當其時,必廣其包容,老人婦孺之見,皆包容,則能啓天下之蒙,功大矣。今人專持其明,漫用自任,致凶之道。是故古之堯舜,其聖功天下莫及,尚能請教下民,取合理之言,天下之民歸之,就如兒子能治其家一樣\n六三:勿用,取女見金夫,不有躬, 无攸利。 # 三爻之位陰居之,此時機正應上位不能遠從近見,九二為群蒙所蔽,居此之時,無所往則利矣。猶女之嫁夫,當由正禮,如見人多金, 悦而相從,不可取也。\n六四:困蒙,吝。 # 因六四之陰爻,離剛賢最遠,無由來發其蒙,終困於昏蒙也,其永不足矣。\n六五:童蒙,吉。 # 此示柔順之人居君位,下應於二,乃示柔中之德,任剛明之才,如此能治天下之蒙,吉也。為人君者,如至誠用賢,以成功惠於百姓,此功亦猶如己出一樣,何須顧忌手下,功高震主。\n象曰:利用刑人,以正法也。 # 此即用立法制刑,以教人之意,萬不可不教而誅。後世之論刑者,不復知教化孕其中, 只一昧的論刑。\n象曰:子克家,剛柔接也。 # 兒子之能治家,其因父之信任專一也。是故九二能成啓蒙之功,乃由五之信任專一也。此剛柔相接之義也。\n象曰:勿用取女,行不順也。 # 此女不可取,因行不順故也。\n象曰:困蒙之吝,獨遠實也。 # 此義昏蒙之人,不能親賢以致困,終不得明矣。實,陽剛也。\n象曰:童蒙之吉,順以巽也。 # 從人之言,只要合理,都能接受,順也。降尊位下求,巽也。君能如此,天下治矣。\n上九:擊蒙,不利爲寇,利禦寇。 # 陽剛居蒙之極,爻之終,示吾人知,人之愚蒙至極,而為宼為亂者,當擊伐之,故戒不利為寇。\n象曰:利用禦寇,上下順也。 # 禦寇之限度,上不過暴,下得撃去其蒙, 則上下皆得順矣。\n"},{"id":96,"href":"/zh/docs/culture/%E5%A4%A9%E7%BA%AA/%E4%BA%BA%E9%97%B4%E9%81%93/%E4%B8%8A%E7%BB%8F/03%E6%B0%B4%E9%9B%B7%E5%B1%AF/","title":"03水雷屯","section":"上经","content":" 03水雷屯 # 此卦季布遇難,卜得之漢推其忠,乃赦其罪。\n圖中:\n人在望竿頭立,車在泥中,犬頭上回字, 人射文書,刀在牛上,一合子。\n龍困淺水之課 萬物如生之象\n雲雷之興,陰陽始交,而未成澤,故為屯,如成澤則為解,此卦動於險中,乃屯之義,如陰陽不交則否,此時機乃天下屯難,未亨泰之時也。\n卦圖象解\n一、人在望竿頭上立:此不明局勢之屯難時也,不可妄動也。二、犬上回字:乃肖狗之人,狗年,狄姓之人,果為哭之象。三、車在泥中:進退兩難之象。刀在牛上:解也,牛為貴人象。四、人射文書:張姓,小人阻礙也。射:同音,色也,緋聞。五、牛不順道:背道而馳,反其道而為之,吉。\n六、人望:人亡也。七、人立:在位也。八、盒:先成後破。\n九、牛回頭視犬:計無所出,待也。\n卜得此卦:動於險中,先成後破也。 # 人間道\n屯:元、亨、利、貞,勿用有攸往,利建侯。\n此義處屯之時必貞固(堅心),須明非獨力能成,必廣資助,此利見侯之義。不可往,唯利建侯。\n象曰:屯,剛柔始交而難生,動乎險中。 # 剛柔始交而未通暢,則難屯,難生,於此之時,如動即往,乃取險之道。\n大亨貞,雷雨之動滿盈。 # 此言屯有大亨之道,得陰陽交而和洽,則成雷雨,充滿天地之間,此因貞固才能出屯。\n天造草昧,宜建侯而不寧。 # 天時造出地上之草,亂而無序,此暗昧不明時,須建立輔助,可以有助矣,此聖人之戒。\n象曰:雲雷,屯。君子以經綸。 # 水未能成雨為雲象,君子觀屯象,知須立法規範來經營此時。\n初九:盤桓,利居貞,利建侯。 # 初而陽爻居下,意乃剛明之才,當屯難之時,而居下位。此時未便往濟,故盤桓。如遽進,則犯險遇難。此即示人處屯難時,須守正方是,現今之人,鮮少人於屯難之時守正。聖人戒之於屯時。\n以貴下賤,大得民也。 # 象曰:雖盤桓,志行正也。\n聖人處屯時,雖有濟屯之志,仍盤桓不動, 因時未至也。此居下位之念。\n初陽之剛健,居陰之下,此易以貴下賤之象。即處屯之時,陰柔不能存,唯一陽剛之才, 則眾所歸從也。更加此人能自處卑下,所以大得民心。\n六二 :屯如邅如,乘馬班如,匪寇,婚媾,女子貞不字,十年乃字。 # 此陰爻之柔居屯世。受逼於初剛健之人, 故柔處屯時,無法自濟,又受下之陽剛所逼, 為難為也。非理而至者為寇,柔守中正不苟合於初剛之意,須十年久久乃通。\n象曰:六二之難,乘剛也。十年乃字,反常也。 # 此六二患難,因柔,又居屯時,又有下之陽剛所逼,此患難乃因柔而生,須十年,難久必過。如反其柔之常性,而與陽剛合,則省十年,十為數之終也。\n六四:乘馬班如,求婚媾,往吉, 无不利。 # 此以柔順居近於君位,此得之於上者,而才不足以濟屯時,須求賢自輔,則往而利矣。此即居公卿之位,己之才不足以濟屯難之時, 若能求在下之賢人,親而用之,則必平安有利。\n九五:屯其膏,小貞吉,大貞凶。 # 五居尊位得正,當屯時,若有剛明之才為位,則利。如無,則屯其膏,此因無良臣,而施為有所不下於民,民未得其德澤,乃因威權不在己之故也。此屯因威權已去而妄想瞬間正回,此求凶之道。须以渐正之,方吉,如不為會因當屯時不改,以至於亡矣。\n上六:乘馬班如,泣血漣如。 # 六此以陰柔居屯之終,此險之極而無援, 居之不安,動無所向,此窮厄之\n極,若陽剛而有助,即屯至極,亦可平安互濟矣。\n六三:即鹿无虞,惟入于林中,君子幾,不如舍,往吝。 # 此陰爻居三之陽位,此意柔居剛而不中正,則必妄動,貪於所求,必不足自濟,此不安之源。如入山林射鹿,又無嚮導,此不安也。君子見事於微,捨而勿逐,如往取,徒取其咎也。\n象曰:即鹿无虞,以從禽也,君子舍之,往吝窮也。 # 事因欲而妄動,此貪禽也。處屯之時,不可動而動,凶也。君子捨之不從,則無咎,如往必困窮也。\n象曰:求而往、明也。 # 知己才之不足,往而求賢,以濟己之不足、此乃真明也。\n象曰:屯其膏,施未光也。 # 膏澤不下及,此人君之屯也,乃因威權不在之象。\n象曰:泣血漣如,何可長也。 # 如於屯難窮極,而莫知所為,則至泣血顛沛,則必不長久。\n"},{"id":97,"href":"/zh/docs/culture/%E5%A4%A9%E7%BA%AA/%E4%BA%BA%E9%97%B4%E9%81%93/%E4%B8%8A%E7%BB%8F/02%E5%9D%A4%E4%B8%BA%E5%9C%B0/","title":"02坤爲地","section":"上经","content":" 02坤爲地 # 此卦漢高袓與項羽交爭卜得之,乃知身霸天下。\n圖中:\n十一箇口,一官人,坐看二串錢。一官人立受命,一馬,金甲神人在台上乘雲下, 綬文書與官,土堆上有四點。\n生載萬物之課 君倡臣和之象\n一、十一口:吉也,陳姓人也。\n卦圖象解\n二、一官人:倌人,丈夫也。公務員也。 三、坐看二串錢:欲拿不得,憂心忡忡狀。四、一馬者:肖馬人;指官貴;指調動也。五、金甲神:示民心也。\n六、授文書與官:授官封侯。\n七、土堆上有四點:黑也,背有陰謀也。\n人間道\n坤:元、亨、利牝馬之貞。\n坤乃地之厚德,示人效地之性,如牝馬之柔順而健行也,貞示堅心,但與乾不同,乃堅心於柔順之念也。\n君子有攸往。 # 君子因行柔順,且表裡一致,合乎坤地之德性。\n先迷後得,主利。 # 陰從陽也,即不了解但知須從陽剛之中正,如不知而欲進,則迷錯,損失在己,須居於後, 則利,如君臣之道,柔順乃臣之職也。\n西南得朋,東北喪朋,安貞吉。 # 西南為陰位,東北為陽位,陰必從於陽,陽之正而陰亦正,由陽剛之中正,去蕪存菁,故能成化育之功也。\n彖曰:至哉坤元!萬物資生,乃順承天,坤厚載物,德合无疆。 # 坤之道大也,萬物因乾而始,因坤而生,猶父母之道,坤之德厚,故能持載萬物。\n含弘光大,品物咸亨,牝馬地類,行地無彊,柔順利貞,君子攸行。 # 含弘光大此四者,用来形容坤道,含,包容也,弘,寬裕也,光,昭明也,大,厚且博廣。聖人有此四德,故能成天之功,牝馬因柔順而能行,故健。\n先迷失道,後順得常。西南得朋,乃與類行;東北喪朋,乃終有慶, 安貞之吉,應地无疆。 # 因先迷而從陽剛之德,後必得理,西南為陰,同類而行,故得朋,東北陽方,離其類而喪朋,因離其類而從陽,故能成物之功也。終必吉,又因能安心堅守此道,則無所不可往矣。\n象曰:地勢坤,君子以厚德載物。 # 坤地之道至大,聖人體之,君子以地之柔順且厚能載物之德處事。\n初六:履霜,堅冰至。 # 聖人於陰之始生,知其將長,此時戒之, 猶人之履及霜,則知後必有堅冰,故小人始雖甚微,但絶不可使其長,長盛則凶也。\n六二:直方大,不習,无不利。 # 聖人以地之直、方、大三德以盡地之道。直者,直來直往,不迂迴作假。方者,原則不因局勢而改變,且正。大者,容載萬物之胸襟乃大也。不習,謂順其自然,則無往不利。亦可云,因又直且方,氣勢乃大也。\n六三:含章可貞,或從王事,无成有終。 # 六三居相位,為臣之道,如有含章之美, 即不居其功,將善歸於君王,乃可因王無忌惡之心,終必吉。\n或從王事,知光大也。 # 象曰:履霜堅冰,陰始凝也,馴致其道,至堅冰也。\n陰始凝為霜,渐盛則至堅冰,故聖人戒小人於初,如任小人因循不阻止,則終至凶。故吾人須戒之於初,小人表面常有可憐,博人同情,示弱以求憫恤,實則內心有所圖,今人不知濟弱扶倾與姑息養奸乃一線之差,一昧以濟弱扶倾為自傲,殊不知其姑息養奸,而至今小人才盛。\n象曰:六二之動,直以方也,不習, 无不利,地道光也。 # 地道之光顯,由其直且方大,人人順其自然,則奸人何所遁形,無往不利。\n象曰:含章可貞,以時發也。 # 處臣下之道,不當有功,免招君忌,但義之當為時,須立行之,此因不有其功,不失其宜,皆因時也。今人含而不為,皆不忠之人。\n聖人知含章之光大,故能養晦,小人有善唯恐人不知,此君子小人之分野。\n六四:括囊,无咎,无譽。 # 六四為居近君之側位,藏口而不露,則平安無災,此沈默之功也。\n六五:黄裳,元吉。 # 坤本論為臣之道,今臣居尊位,或婦人居尊位,须有黄裳之戒,應守中而居下,不可揚溢無節制,否則必有非常之變。\n象曰:括囊无咎,慎不害也。 # 能隨時謹言,不妄言,此無害之因慎也。\n象曰:黃裳元吉 ,文在中也。 # 因黃裳之美,乃內積至美,執中不過,位高但謙居下,故吉。\n上六:龍戰於野,其血玄黃。 # 六爻為極盛之位,陰至極則陽至,故有抗爭,因六又再進不已,故必戰,戰必有傷,故血色現,天之血色為玄,地之血色為黃。\n用六:利永貞。 # 此為用陰之道,如乾有(用九)之道同。陰道柔而難常,有朝令夕改之憂。故利在常而堅固。\n象曰:龍戰于野,其道窮也。 # 道因陰至極而無道,故有龍戰於野之象現。\n象曰:用六永貞,以大終也。 # 聖人自此悟之,始終如一,堅心不變,必利,其終極必大。今人朝夕所思不同,自視甚重,角色不能認清,小人有太過之行為,常人從俗只見愚善,致小人極盛,積非成是,故積重難改。\n文言曰:坤至柔而動也剛,至靜而德方。後得主而有常,含萬物而化光, 坤道其順乎?承天而時行。 # 坤之道雖柔,但如動亦須剛,坤之體至靜,但其德也须方正。動而不違方正。再得同道之呼應,成萬物光大之功,故坤道之順大,皆因王時之至也。\n積善之家,必有餘慶,積不善之家,必有餘殃。臣弑其君,子弑其父,非一朝一夕之故;其所由來者漸矣,由辨之不早辨也。易曰:履霜堅冰至,蓋言順也。 # 學者讀此段,必可自悟矣。故聖人戒小人於初,其道大也。\n直其正也,方其義也;君子敬以直内,義以方外,敬義立而德不孤, 直方大,不習无不利,則不疑其所行也。 # 君子外敬內直,堅守義以方其外,故內敬直,外義方,其德之盛,順其自然,不须裝飾, 無人會疑其所行也。\n陰雖有美含之,以從王事,弗敢成也。地道也,妻道也,臣道也, 地道无成,而代有終也。 # 臣下之道,不表其功,含晦以為王行事,至終而不敢有其功,如地道代天終萬物,而成功又歸之於天,妻之道同此。今之妻如何,吾人試自問之?\n天地變化,草木蕃。天地閉,賢人隱。易曰:括囊,無咎無譽,蓋言謹也。 # 天地相交,則能興發草木,天下因君臣相往而道亨。今天地隔絶,萬物不成,猶君臣無道, 賢者隱退,此時易道為閉口含藏,雖無聲譽,但無災,皆因言論謹慎也。\n君子黃中通理,正位居體,美在其中,而暢於四支,發於事業,美之至也。 # 君子知謙守下之道而通於事理,居君王位故美積其中,暢於四體,現於事業,此美之極也。此段乃示柔中有剛,乃至大至美,今人有柔善而無剛,或過剛而無仁,皆不足之美也。\n陰疑於陽必戰,爲其嫌於无陽也,故稱龍焉; 猶未離其類也,故稱血焉。夫玄黃者,天地之雜也,天玄而地黃。 # 中醫之至道在此,陽大陰小,陰乃從陽,故經方派乃用陽藥,如人間亦然。如令陰到極盛, 陽受陰盛而外走,此不相從必戰,傷而見血,天地之色變,亦言傷之大也。\n第 9页\n"},{"id":98,"href":"/zh/docs/culture/%E5%A4%A9%E7%BA%AA/%E4%BA%BA%E9%97%B4%E9%81%93/%E4%B8%8A%E7%BB%8F/01%E4%B9%BE%E4%B8%BA%E5%A4%A9/","title":"01乾爲天","section":"上经","content":" 01乾爲天 # 此卦高祖與呂后走在芒碭山卜得之,餘人難壓也。\n圖中:\n鹿在雲中,石上玉,有光明,人琢玉。月當空,官人登雲梯,望月。\n六龍御天之課 廣大包容之象\n萬物皆成於天地之間,故天之高,地之厚德,皆為吾人所效法。聖人觀天而知天之運行恆古不變,且永不止息,此天之道。乾為萬物之始,以象言其為天,為陽,為父,為君。\n卦圖象解\n一、官人在梯上:平步青雲象。\n二、鹿在雲中:作為行事心中不可求祿,則祿至矣。三、石上玉有光明:示人才如玉之含光,吉也。\n四、人磨玉:示人堅心不移,夕惕若厲,隨時戒之。五、工匠:有重立門户之象。\n六、梯上官人:棺也。卜疾厄凶也。\n七、官人望磨玉人:示人即平步青雲須戒盛,且望下看,念中無妄,則升遷平安,如爭位則旦夕而亡也。\n人間道\n乾:元、亨、利、貞。\n元為萬物之始生,亨為萬物之成長,利為萬物之遂成,貞為萬物之堅心,此為乾之性,恒古不變,示人须有堅定不移之心志,萬事乃成於此。\n初九:潛龍勿用。 # 初爻為陽位,且陽居此,因爻理本無形, 聖人假借龍之物象來示人乾之初象如聖人始萌,如物之始端,若龍之潛隱,由於始生,未為可用之時機,當韜光養晦,以俟時之至。\n九二:見龍在田,利見大人。 # 以個人而言,此時如舜之田漁,其德已著, 乃時之至,故利見大德之君,以行其道。以君王言,此時亦利見大德之人臣,以共成其功。以天下百姓言,亦利見大德之人才,以受其恩澤。\n九三:君子終日乾乾,夕惕若厲,无咎。 # 三爻位是在下卦之最上,雖為臣位,但未離下位,此時君子知朝夕不懈,隨時警惕,即處危地,但因戒惕而無災至。時今在下位之人,因德已顯彰,民心將至,天下將歸於他,試想其危懼之程度,故聖人設戒於此時,此易之論時也。\n九四:或躍在淵,无咎。 # 或乃進與不進之時,聖人示戒進之時機,如時機不對,亦可不進,或之意也,無災。\n九五:飛龍在天,利見大人。 # 此乃進乎天位君位之時,天下利見有大德之人,來共成天下之大事,聖人居此位,乃知時之至矣。廣納天下大德之人共成之。\n用九:見群龍無首,吉。 # 上九:亢龍有悔。\n上九為位之極點,過此時太過也。聖人知此為時之極限,乃知進退存亡而終至無過,就不至於後悔也。\n用九之道,即用鲍陽剛之道,須剛柔相濟方為中道,如過乎剛,以至剛為天下之優先,此凶之道也。\n彖曰:大哉乾元!萬物資始,乃統天。雲行雨施,品物流形。大明终始。六位時成,時乘六龍以御天。乾道變化,各正性命,保合大和, 乃利貞。首出庶務,萬國咸寧。 # 彖為一卦之義,吾人觀讀彖,有助於對卦之了解。開始即讃乾道之浩大,萬物由此而生, 乾道因統天,故即為天道,萬物生後,雲行雨施,品物流形,此物之長也。六爻之卦位,各因時而成,此為天之運行,因天道之剛健而自強不息的運作,生育出萬物,且各有分類,各有所性,各有所命。吾人言『天所賦為命,物所受為性。』自此常存此道,因天道生成之\n剛正,利於吾人堅心向道,果必吉。天之道首出萬物而皆亨吉,人君之道遵循天道,以致四海顺从。\n象曰:天行健,君子以自彊不息。 # 象為解一卦之神,每卦皆有象,每爻皆有象,所有的卦皆以象為法則。此乾之卦象言,始終如一,永恆不變,人之行為能如此則至健,可以見天道也。吾人之自強不息,乃法天之則也。此為天紀。\n潛龍勿用,陽在下也。 # 陽居最下始生之位,猶植物之生於土中, 未出頭,始萌之狀。故君子如處此時,乃應進德修業,未為可用之時機也。\n終日乾乾,反復道也。 # 此時居下卦之最上,要再進前,但又未脱下位之關係,進退動靜,必依有道,且旦夕進修勵德,此時之法則也。\n飛龍在天,大人造也。 # 君子能進君位,而行其正道,天下大治矣。\n用九,天德不可爲首也。 # 用九,意為用剛之道也,天道陽剛,又用剛而為先,此之過矣。\n見龍在田,德施普也。 # 此時君子之德已普及各地,人民已感受其教化之力量也。\n或躍在淵,進无咎也。 # 或乃量可而進,待之以時,時至而動,動必以明,無災。\n亢龍有悔,盈不可久也。 # 滿則溢,此天之道也,故易示人滿必不可持久,太過必生悔矣。\n文言曰:元者,善之長也;亨者,嘉之會也;利者,義之和也;貞者, 事之幹也。 # 易中唯乾坤二卦有文言,此乃補強乾坤天地之道。元,萬物之始皆為善,天之生必有其用, 此為造化之跡也。亨者,乃其成長之嘉且美也。利者,任何行為必合於義也。貞者,萬物之用, 必以堅心不移方可成矣。\n君子體仁,足以長人。 # 分均,仁也,此仁之道大矣,君子體之,以仁普及教化,以所以長人也。\n嘉會,足以合禮。 # 不合理則為非禮,合於理之生長,為美之至嘉也。故萬般事物皆有一定之法則,吾人須體理之治,則發展合於天道自然。\n利物,足以合義。 # 合於義,則能利於萬物之遂行。後出,義也。能斷後,則為義也。而今人類製造之物,無法完全回收,造成自然界之失衡,此不合於義之道也,不合於利物之理。\n貞固,足以幹事。 # 因堅定不移之意志,則定以成事。\n君子行此四德者,故曰:乾,元,亨,利,貞。\n能行此四德,則合乎乾天之道。\n初九曰:潛龍勿用,何謂也?子曰: # 龍德而隱者也,不易乎世,不成乎名,遁世无悶,不見是而无悶,樂則行之,憂則違之,確乎其不可拔, 潛龍也。\n乾卦之用,即用九之道。初九為陽剛之微\n現,如龍之潛隱,聖人居之陋側,堅守其道不因世之變而改變,時不至,隱其行,不求為人知也,自信自樂,見可而動,知災而避,守正道之心堅而不移,此真潛龍也。\n九三曰:君子終日乾乾,夕惕若厲, 无咎,何謂也?子曰:君子進德脩業,忠信所以盡德也;脩辭立其誠, 所以居業也。知至至之,可與幾也; 知終終之,可與存義也。是故居上位而不驕,在下位而不憂,故乾乾因其時而惕,雖危无咎矣。 # 位居下卦之上者,已近君位,又因有君之\n德顯,為免招凶,故君子居此時,必求進德修業,終日謹慎言行,知進退之機,此義之道。不以居上位或處下位而驕傲或憂慮,不予人有野心之戒,故雖處危地但無災至也。\n九二曰:見龍在田,利見大人,何 # 謂也?子曰:龍德而正中者也。庸言之信,庸行之谨,閑邪存其誠, 善世而不伐,德博而化。易曰:見龍在田,利見大人,君德也。\n因非為君位,但有中正之德,謹言慎行,\n但求處於無過之地,遇邪道,但求存誠,德施普及,但不伐其惡,以誠純一,雖不為君,但為君之德也。\n九四曰:或躍在淵,无咎,何謂也? 子曰:上下无常,非爲邪也;進退无恆,非離群也;君子進德脩業。欲及時也,故无咎。 # 或即進退須待時之意,時行時止,不可恆久不變。君子之順時而動,猶影之隨形,並非欲為邪吝之人,亦非欲離同類也。此位乃近君惻之人也。\n九五曰:飛龍在天,利見大人,何謂也?子曰:同聲相應,同氣相求, 水流濕,火就燥,雲從龍,風從虎,聖人作而萬物睹。本乎天者親上, 本乎地者親下,則各從其類也。 # 此有中正之德的人,居於君王之位,天下人皆歸仰其德,上應於下,下從於上,故聖人做而萬人皆明,因同德相感,故賢人出,各有其類,本為君位之人則親和於君王,本為位低之賢, 則近於下位之人,此乃自然之水流濕,火就燥,雲從龍,風從虎之義。故利見大人。\n上九曰:亢龍有悔,何謂也?子曰:貴而无位,高而无民,賢人在下位而无輔,是以動而有悔也。 # 有陽剛之正德而居不適位,則動而必悔,因无民无輔,獨力難成也。\n潛龍勿用,下也。 # 居下位之時,即有中正之德,亦宜潛隱, 不可就用也。\n終日乾乾,行事也。 # 君子行事之法則,不居君位,則終日警惕, 進德修業,不求有大功。\n飛龍在天,上治也。 # 有君王之位人,又有中正之德者,如龍在天,乃上上之治,其意如此。\n乾元用九,天下治也。 # 用九之道,因天與聖人同知且同用,天下因治。\n見龍在田,天下文明。 # 龍之陽剛德性,見於地上,如此天下則見其文明也。\n或躍在淵,乾道乃革。 # 因知進退之得宜,故能離下卦,而躍居上位,故曰上下之革。\n亢龍有悔,與時偕極。 # 時之過,再居此時,則人與時皆過也。\n乾元用九,乃見天則。 # 用九,乃天之則也,天之法則即為天道, 聖人稟道而行,得失吉凶立知於事之初,此為天則。\n見龍在田,時舍也。 # 因時而止,此利普施其德於一地,範圍有限。\n或躍在淵,自試也。 # 處君側,可自試時機為何,動靜隨消息而進或止,不可躁進,求凶之道。\n亢龍有悔,窮之災也。 # 因窮困之極,乃有太過之舉動,此災之至也。\n潛龍勿用,陽氣潛藏。 # 方陽衰而潛藏之時,君子效此,亦晦隱, 不可用也。\n終日乾乾,與時偕行。 # 待時而動,不可分秒懈怠。\n飛龍在天,乃位乎天德。 # 正於君位,天德乃現,如龍升天。\n乾元者,始而亨者也。 # 用陽剛之天則,初始即可亨通也。\n利貞者,性情也。 # 陽剛之性既亨通,須始終如一,則能生生不息。\n乾始能以美利利天下,不言所利,大矣哉。 # 乾始之道,能使天下皆感其美,而天下因之而利,又不歌功頌德,將功歸己,此則大矣。\n大哉乾乎!剛健中正,純粹精也;六爻發揮,旁通情也;時乘六龍, 以御天也;雲行雨施,天下平也。 # 此言乾道之偉大,因純之剛健中正,按步就班,順時順位而行之,則可以統御天下,陰陽和暢,天下則進入和平之道也。\n君子以成德爲行,日可見之行也。潛之爲言也,隱而未見,行而未成,是以君子弗用也。 # 君子以行為而示人其德性,隨時可見。初因方潛未為見用,故君子弗用也。\n君子學以聚之,問以辯之,寬以居之,仁以行之,易曰:見龍在田, 利見大人,君德也。 # 聖人居下位,德已顯,卻未得位時,必求進德修身,不怪時之不與,但求身體力行此雖不為君,卻是為君之德才。\n九三:重剛而不中,上不在天,下不在田,故乾乾因其時而惕,雖危无咎矣。 # 因九三位為過中而又居下卦之上,屬相位,上又未至君位,又為陽剛之性,故屬危懼之地,聖人知戒,競競惕惕以防危,故雖處此時而无咎也。\n九四:重剛而不中,上不在天,下不在田,中不在人,故或之;或之者,疑之也,故无咎。 # 九四為近君位,亦屬危地,或進或退,但求平安耳,故可无咎。君子能曲能伸。\n夫大人者,與天地合其德,與日月合其明,與四時合其序,與鬼神合其吉凶,先天而天弗爲,後天而奉天時,天且弗違,而況於人乎?况於鬼神乎? # 聖人先知於天故天與其同,後於天又順於天,此合乎天道,故人與鬼神皆不能違,天為至大,存天地之間,道也。鬼神,造化之跡也。\n亢之爲言也,知進而不知退,知存而不知亡,知得而不知喪,其唯聖人乎!知進退存亡,而不失其正者,其唯聖人乎! # 亢之義乃不知進退存亡之機也,聖人知亢而處此,凡事皆不失正道,故不為亢,唯聖人可為也。\n"},{"id":99,"href":"/zh/docs/test/test2/","title":"test2","section":"测试","content":" index\n,,,啊四道口附近看喀什地方就开始角度看开始卡斯克使得开发商开具收款附件啊可是结果卡就十分就凯撒记得付款就看四道口附近凯撒记得付款就开始大家可接受的看就是接口设计开具收款的 就框架 看 看技术的开发就开始酒店开房间奥数开始讲课 伺机待发开具收款即可就开始的JFK两节课\n撒旦发射点 撒旦发射点 s地方\n这里我做一个测试,,写字的测试哦σ假装,○这里宀~不很好用。句号不太好画好来,\n"},{"id":100,"href":"/zh/docs/culture/%E8%AE%BA%E8%AF%AD/%E8%AE%BA%E8%AF%AD%E8%AF%91%E6%B3%A8_%E6%9D%A8%E4%BC%AF%E5%B3%BB/19%E5%AD%90%E5%BC%A0%E7%AF%87%E7%AC%AC%E5%8D%81%E4%B9%9D/","title":"19子张篇第十九","section":"论语译注 杨伯峻","content":" 子张篇第十九 # (共二十五章)\n19.1子张曰:“士见危致命,见得思义,祭思敬,丧思哀,其可已矣。”\n【译文】子张说:“读书人看见危险便肯豁出生命,看见有所得便考虑是否该得,祭祀时候考虑严肃恭敬,居丧时候考虑悲痛哀伤,那也就可以了。”\n19.2子张曰:“执德不弘⑴,信道不笃,焉能为有?焉能为亡⑵?”\n【译文】子张说:“对于道德,行为不坚强,信仰不忠实,[这种人,]有他不为多,没他不为少。”\n【注释】⑴弘——此“弘”字就是今之“强”字,说见章炳麟《广论语骈枝》。⑵焉能为有,焉能为亡——这两句疑是当日成语。何晏《论语集解》云:“言无所轻重”,所以译文也用今日俗语来表达此意。\n19.3子夏之门人问交于子张。子张曰:“子夏云何?”\n对曰:“子夏曰:‘可者与之,其不可者拒之。’”\n子张曰:“异乎吾所闻:君子尊贤而容众,嘉善而矜不能。我之大贤与,于人何所不容?我之不贤与,人将拒我,如之何其拒人也?”\n【译文】子夏的学生向子张问怎样去交朋友。子张道:“子夏说了些什么?”\n答道:“子夏说,可以交的去交他,不可以交的拒绝他。”\n子张道:“我所听到的与此不同:君子尊敬贤人,也接纳普通人;鼓励好人,可怜无能的人。我是非常好的人吗,对什么人不能容纳呢?我是坏人吗,别人会拒绝我,我怎能去拒绝别人呢?”\n19.4子夏曰:“虽小道,必有可观者焉;致远恐泥,是以君子不为也。”\n【译文】子夏说道:“就是小技艺,一定有可取的地方;恐怕它妨碍远大事业,所以君子不从事于它。”\n19.5子夏曰:“日知其所亡,月无忘其所能,可谓好学也已矣。”\n【译文】子夏说:“每天知道所未知的,每月复习所已能的,可以说是好学了。”\n19.6子夏曰:“博学而笃志⑴,切问而近思,仁在其中矣。”\n【译文】子夏说:“广泛地学习,坚守自己志趣;恳切地发问,多考虑当前的问题,仁德就在这中间了。”\n【注释】⑴志——孔注以为“志”与“识”同,那么,“博学笃志”便是“博闻强记”之意,说虽可通,但不及译文所解恰切。\n19.7子夏曰:“百工居肆以成其事,君子学以致其道。”\n【译文】子夏说:“各种工人居住于其制造场所完成他们的工作,君子则用学习获得那个道。”\n19.8子夏曰:“小人之过也必文。”\n【译文】子夏说:“小人对于错误一定加以掩饰。”\n19.9子夏曰:“君子有三变:望之俨然,卽之也温,听其言也厉。”\n【译文】子夏说:“君子有三变:远远望着,庄严可畏;向他靠拢,温和可亲;听他的话,严厉不苟。”\n19.10子夏曰:“君子信而后劳其民;未信,则以为厉己也。信而后谏;未信,则以为谤己也。”\n【译文】子夏说:“君子必须得到信仰以后才去动员百姓;否则百姓会以为你在折磨他们。必须得到信任以后才去进谏,否则君上会以为你在毁谤他。”\n19.11子夏曰:“大德不踰闲,小德出入可也。”\n【译文】子夏说:“人的重大节操不能踰越界限,作风上的小节稍稍放松一点是可以的。”\n19.12子游曰:“子夏之门人小子,当洒扫应对进退,则可矣,抑末也。本之则无,如之何?”\n子夏闻之,曰:“噫!言游过矣!君子之道,孰先传焉?孰后倦焉?譬诸草木,区以别矣。君子之道,焉可诬也?有始有卒者,其惟圣人乎!”\n【译文】子游道:“子夏的学生,叫他们做做打扫、接待客人、应对进退的工作,那是可以的;不过这只是末节罢了。探讨他们的学术基础却没有,怎样可以呢?”\n子夏听了这话,便道:“咳!言游说错了!君子的学术,哪一项先传授呢?哪一项最后讲述呢?学术犹如草木,是要区别为各种各类的。君子的学术,如何可以歪曲?[依照一定的次序去传授而]有始有终的,大概只有圣人罢!”\n19.13子夏曰:“仕而优则学,学而优则仕。”\n【译文】子夏说:“做官了,有余力便去学习;学习了,有余力便去做官。”\n19.14子游曰:“丧致乎哀而止。”\n【译文】子游说:“居丧,充分表现了他的悲哀也就够了。”\n19.15子游曰:“吾友张也为难能也,然而未仁。”\n【译文】子游说:“我的朋友子张是难能可贵的了,然而还不能做到仁。”\n19.16曾子曰:“堂堂⑴乎张也,难与并为仁矣。”\n【译文】曾子说:“子张的为人高得不可攀了,难以携带别人一同进入仁德。”\n【注释】⑴堂堂——这是迭两字而成的形容词,其具体意义如何,古今解释纷纭。《荀子·非十二子篇》云:“弟佗其冠,神禫其辞,禹行而舜趋,是子张氏之贱儒也。”这是对子张学派的具体描写,因此我把“堂堂”译为“高不可攀”。根据《论语》和后代儒家诸书,可以证明曾子的学问重在“正心诚意”,而子张则重在言语形貌,所以子游也批评子张“然而未仁”。\n19.17曾子曰:“吾闻诸夫子:人未有自致者也,必也亲丧乎!”\n【译文】曾子说:“我听老师说过,平常时候,人不可能来自动地充分发挥感情,[如果有,]一定在父母死亡的时候罢!”\n19.18曾子曰:“吾闻诸夫子:孟庄子⑴之孝也,其它可能也;其不改父之臣与父之政,是难能也。”\n【译文】曾子说:“我听老师说过:孟庄子的孝,别的都容易做到;而留用他父亲的僚属,保持他父亲的政治设施,是难以做到的。”\n【注释】⑴孟庄子——鲁大夫孟献子仲孙蔑之子,名速。其父死于鲁襄公十九年,本人死于二十三年,相距仅四年。这一章可以和“三年无改于父之道可谓孝矣”(1.11)结合来看。\n19.19孟氏使阳肤⑴为士师,问于曾子。曾子曰:“上失其道,民散⑵久矣。如得其情,则哀矜而勿喜!”\n【译文】孟氏任命阳肤做法官,阳肤向曾子求教。曾子道:“现今在上位的人不依规矩行事,百姓早就离心离德了。你假若能够审出罪犯的真情,便应该同情他,可怜他,切不要自鸣得意!”\n【注释】⑴阳肤——旧注说他是曾子弟子。⑵散——黄家岱《嬹艺轩杂着·论语多齐鲁方言述》云:“散训犯法,与上下文义方接。扬氏《方言》:‘虔散,杀也。东齐曰散,青徐淮楚之间曰虔。’虔散为贼杀义。曰民散久矣,用齐语也。”译文未取此说,録之以备参考。\n19.20子贡曰:“纣⑴之不善,不如是之甚也。是以君子恶居下流,天下之恶皆归焉。”\n【译文】子贡说:“商纣的坏,不像现在传说的这么厉害。所以君子憎恨居于下流,一居下流,天下的什么坏名声都会集中在他身上了。”\n【注释】⑴纣——殷商最末之君,为周武王所伐,自焚而死。\n19.21子贡曰:“君子之过也,如日月之食焉:过也,人皆见之;更也,人皆仰之。”\n【译文】子贡说:“君子的过失好比日蚀月蚀:错误的时候,每个人都看得见;更改的时候,每个人都仰望着。”\n19.22卫公孙朝⑴问于子贡曰:“仲尼焉学?”子贡曰:“文武之道,未坠于地,在人。贤者识其大者,不贤者识其小者。莫不有文武之道焉。夫子焉不学?而亦何常师之有?”\n【译文】卫国的公孙朝向子贡问道:“孔仲尼的学问是从哪里学来的?”子贡道:“周文王武王之道,并没有失传,散在人间。贤能的人便抓住大处,不贤能的人只抓些末节。没有地方没有文王武王之道。我的老师何处不学,又为什么要有一定的老师,专门的传授呢?”\n【注释】⑴卫公孙朝——翟灏《四书考异》云:“春秋时鲁有成大夫公孙朝,见昭二十六年传;楚有武城尹公孙朝,见哀十七年传;郑子产有弟曰公孙朝,见列子。记者故系‘卫’以别之。”\n19.23叔孙武叔⑴语大夫于朝曰:“子贡贤于仲尼。”\n子服景伯以告子贡。\n子贡曰:“譬之宫墙⑵,赐之墙也及肩,窥见室家之好。夫子之墙数仞⑶,不得其门而入,不见宗庙之美,百官⑷之富。得其门者或寡矣。夫子之云,不亦宜乎!”\n【译文】叔孙武叔在朝廷中对官员们说:“子贡比他老师仲尼要强些。”\n子服景伯便把这话告诉子贡。\n子贡道:“拿房屋的围墙作比喻罢:我家的围墙只有肩膀那么高,谁都可以探望到房屋的美好。我老师的围墙却有几丈高,找不到大门走进去,就看不到他那宗庙的雄伟,房舍的多种多样。能够找着大门的人或许不多罢,那么,武叔他老人家的这话,不也是自然的吗?”\n【注释】⑴叔孙武叔——鲁大夫,名州仇。⑵宫墙——“宫”有围障的意义,如《礼记·丧大记》:“君为庐宫之”。“宫墙”当系一词,犹如今天的“围墙”。⑶仞——七尺曰仞(此从程瑶田《通艺録·释仞》之说)。⑷官——“官”字的本义是房舍,其后才引申为官职之义,说见俞樾《羣经平议》卷三及遇夫先生《积微居小学金石论丛》卷一。这里也是指房舍而言。\n19.24叔孙武叔毁仲尼。子贡曰:“无以⑴为也!仲尼不可毁也。他人之贤者,丘陵也,犹可踰也;仲尼,日月也,无得而踰焉。人虽欲自绝,其何伤于日月乎?多⑵见其不知量也⑶。”\n【译文】叔孙武叔毁谤仲尼。子贡道:“不要这样做,仲尼是毁谤不了的。别人的贤能,好比山邱,还可以超越过去;仲尼,简直是太阳和月亮,不可能超越它。人家纵是要自绝于太阳月亮,那对太阳月亮有什么损害呢?祗是表示他不自量罢了。”\n【注释】⑴以——此也,这里作副词用。⑵多——副词,祗也,适也。⑶不知量也——皇侃《义疏》解此句为“不知圣人之度量”,译文从朱熹《集注》。“也”,用法同“耳”。\n19.25陈子禽谓子贡曰:“子为恭也,仲尼岂贤于子乎?”\n子贡曰:“君子一言以为知,一言以为不知,言不可不慎也。夫子之不可及也,犹天之不可阶而升也。夫子之得邦家者,所谓立之斯立,道之斯行,绥之斯来,动之斯和。其生也荣,其死也哀,如之何其可及也?”\n【译文】陈子禽对子贡道:“您对仲尼是客气罢,是谦让罢,难道他真比您还强吗?”\n子贡道:“高贵人物由一句话表现他的有知,也由一句话表现他的无知,所以说话不可不谨慎。他老人家的不可以赶得上,犹如青天的不可以用阶梯爬上去。他老人家如果得国而为诸侯,或者得到采邑而为卿大夫,那正如我们所说的一叫百姓人人能立足于社会,百姓自会人人能立足于社会;一引导百姓,百姓自会前进;一安抚百姓,百姓自会从远方来投靠;一动员百姓,百姓自会同心协力。他老人家,生得光荣,死得可惜,怎么样能够赶得上呢?”\n"},{"id":101,"href":"/zh/docs/culture/%E8%AE%BA%E8%AF%AD/%E8%AE%BA%E8%AF%AD%E8%AF%91%E6%B3%A8_%E6%9D%A8%E4%BC%AF%E5%B3%BB/13%E5%AD%90%E8%B7%AF%E7%AF%87%E7%AC%AC%E5%8D%81%E4%B8%89/","title":"13子路篇第十三","section":"论语译注 杨伯峻","content":" 子路篇第十三 # (共三十章)\n13.1子路问政。子曰:“先之⑴劳之。”请益。曰:“无倦⑵。”\n【译文】子路问政治。孔子道:“自己给百姓带头,然后让他们勤劳地工作。”子路请求多讲一点。孔子又道:“永远不要懈怠。”\n【注释】⑴先之——就是下一章“先有司”之意。⑵无倦——也就是“居之无倦”(12.14)之意。\n13.2仲弓为季氏宰,问政。子曰:“先有司,赦小过,举贤才。”\n曰:“焉知贤才而举之?”子曰:“举尔所知;尔所不知,人其舍诸?”\n【译文】仲弓做了季氏的总管,向孔子问政治。孔子道:“给工作人员带头,不计较人家的小错误,提拔优秀人才。”\n仲弓道:“怎样去识别优秀人才把他们提拔出来呢?”孔子道:“提拔你所知道的;那些你所不知道的,别人难道会埋没他吗?”\n13.3子路曰:“卫君⑴待子而为政,子将奚先?”\n子曰:“必也正名⑵乎!”\n子路曰:“有是哉,子之迂也!奚其正?”\n子曰:“野哉,由也!君子于其所不知,盖阙如也。名不正,则言不顺;言不顺,则事不成;事不成,则礼乐不兴;礼乐不兴,则刑罚不中;刑罚不中,则民无所错⑶手足。故君子名之必可言也,言之必可行也。君子于其言,无所苟而已矣。”\n【译文】子路对孔子说:“卫君等着您去治理国政,您准备首先干什么?”\n孔子道:“那一定是纠正名分上的用词不当罢!”\n子路道:“您的迂腐竟到如此地步吗!这又何必纠正?”\n孔子道:“你怎么这样卤莽!君子对于他所不懂的,大概采取保留态度,[你怎么能乱说呢?]用词不当,言语就不能顺理成章;言语不顺理成章,工作就不可能搞好;工作搞不好,国家的礼乐制度也就举办不起来;礼乐制度举办不起来,刑罚也就不会得当;刑罚不得当,百姓就会[惶惶不安,]连手脚都不晓得摆在哪里才好。所以君子用一个词,一定[有它一定的理由,]可以说得出来;而顺理成章的话也一定行得通。君子对于措词说话要没有一点马虎的地方才罢了。”\n【注释】⑴卫君——历来的注释家都说是卫出公辄。⑵正名——关于这两个字的解释,从汉以来便异说纷纭。皇侃《义疏》引郑玄的注云:“正名谓正书字也,古者曰名,今世曰字。”这说恐不合孔子原意。《左传》成公二年曾经载有孔子的话,说:“唯器(礼器)与名(名义、名分)不可以假人。”《论语》这一“名”字应该和《左传》的这一“名”字相同。《论语》中有孔子“觚不觚”之叹。“觚”而不像“觚”,有其名,无其实,就是名不正。孔子对齐景公之问,说,“君君,臣臣,父父,子子”,也就是正名。《韩诗外传》卷五记载着孔子的一段故事,说,“孔子侍坐于季孙,季孙之宰通曰:‘君使人假马,其与之乎?’孔子曰:‘吾闻:君取于臣曰取,不曰假。’季孙悟,告宰通曰:‘今以往,君有取谓之取,无曰假。’孔子曰:‘正假马之言而君臣之义定矣。’”更可以说明孔子正名的实际意义。我这里用“名分上的用词不当”来解释“名不正”,似乎较为接近孔子原意。但孔子所要纠正的,只是有关古代礼制、名分上的用词不当的现象,而不是一般的用词不当的现象。一般的用词不当的现象,是语法修辞范畴中的问题;礼制上、名分上用词不当的现象,依孔子的意见,是有关伦理和政治的问题,这两点必须区别开来。⑶错——同“措”,安置也。\n13.4樊迟请学稼。子曰:“吾不如老农。”请学为圃。曰:“吾不如老圃。”\n樊迟出。子曰:“小人哉,樊须也!上好礼,则民莫敢不敬;上好义,则民莫敢不服;上好信,则民莫敢不用情。夫如是,则四方之民襁负其子而至矣,焉用稼?”\n【译文】樊迟请求学种庄稼。孔子道:“我不如老农民。”又请求学种菜蔬。孔子道:“我不如老菜农。”\n樊迟退了出来。孔子道:“樊迟真是小人,统治者讲究礼节,百姓就没有人敢不尊敬;统治者行为正当,百姓就没有人敢不服从;统治者诚恳信实,百姓就没有人敢不说真话。做到这样,四方的百姓都会背负着小儿女来投奔,为什么要自己种庄稼呢?”\n13.5子曰:“诵《诗》三百,授之以政,不达;使于四方,不能专对⑴;虽多,亦奚以为⑵?”\n【译文】孔子说:“熟读《诗经》三百篇,交给他以政治任务,却办不通;叫他出使外国,又不能独立地去谈判酬酢;纵是读得多,有什么用处呢?”\n【注释】⑴不能专对——古代的使节,只接受使命,至于如何去交涉应对,只能随机应变,独立行事,更不能事事请示或者早就在国内一切安排好,这便叫做“受命不受辞”,也就是这里的“专对”。同时春秋时代的外交酬酢和谈判,多半背诵诗篇来代替语言(《左传》里充满了这种记载),所以诗是外交人才的必读书。⑵亦奚以为——“以”,动词,用也。“为”,表疑问的语气词,但只跟“奚”、“何”诸字连用,如“何以文为”、“何以伐为”。\n13.6子曰:“其身正,不令而行;其身不正,虽令不从。”\n【译文】孔子说:“统治者本身行为正当,不发命令,事情也行得通。他本身行为不正当,纵三令五申,百姓也不会信从。”\n13.7子曰:“鲁、卫之政,兄弟也。”\n【译文】孔子说:“鲁国的政治和卫国的政治,像兄弟一般[地相差不远]。”\n13.8子谓卫公子荆⑴,“善居室⑵,始有,曰:‘苟合⑶矣。’少有,曰:‘苟完矣。’富有,曰:‘茍美矣。’”\n【译文】孔子谈到卫国的公子荆,说:“他善于居家过日子,刚有一点,便说道:‘差不多够了。’增加了一点,又说道:‘差不多完备了。’多有一点,便说道:‘差不多富丽堂皇了。”\n【注释】⑴卫公子荆——卫国的公子,吴季札曾把他列为卫国的君子,见《左传》襄公二十九年。有人说:“此取荆之善居室以风有位者也。”因为当时的卿大夫,不但贪污,而且奢侈成风,所以孔子“以廉风贪,以俭风侈。”似可备一说。⑵居室——这一词组意义甚多:(甲)居住房舍,《礼记·曲礼》“君子将营宫室,宗庙为先,廐库为次,居室为后。”(乙)夫妇同居,《孟子·万章》:“男女居室,人之大伦也。”(丙)汉代又以为狱名,《史记·卫青传》:“青尝从入甘泉居室。”(丁)此则为积蓄家业居家度日之义。“居”读为“奇货可居”之“居”。⑶合——给也,足也。此依俞樾《羣经平议》说。\n13.9子适卫,冉有仆⑴。子曰:“庶矣哉!”\n冉有曰:“既庶矣,又何加焉?”曰:“富之。”\n曰:“既富矣,又何加焉?”曰:“教之⑵。”\n【译文】孔子到卫国,冉有替他驾车子。孔子道:“好稠密的人口!”\n冉有道:“人口已经众多了,又该怎么办呢?”孔子道:“使他们富裕起来。”\n冉有道:“已经富裕了,又该怎么办呢?”孔子道:“教育他们”\n【注释】⑴仆——动词,驾御车马。其人则谓之仆夫,《诗·小雅·出车》“仆夫况瘁”可证。仆亦作名词,驾车者,《诗·小雅·正月》“屡顾尔仆”是也。⑵既富……教之——孔子主张“先富后教”,孟子、荀子也都继续发挥了这一主张。所以孟子说“乐岁终身苦,凶年不免于死亡。此惟救死而恐不赡,奚暇治礼义哉?”(《梁惠王上》)也和《管子·治国篇》的“凡治国之道,必先富民”主张相同。\n13.10子曰:“苟有用我者,期月⑴而已可也,三年有成。”\n【译文】孔子说:“假若有用我主持国家政事的,一年便差不多了,三年便会很有成绩。”\n【注释】⑴期月——期同“朞”,有些本子卽作“朞”,音姬,jī。期月,一年。\n13.11子曰:“‘善人为邦百年,亦可以胜⑴残去⑵杀矣⑶。’诚哉是言也!”\n【译文】孔子说:“‘善人治理国政连续到一百年,也可以克服残暴免除虐杀了。’这句话真说得对呀!”\n【注释】⑴胜——旧读平声。⑵去——旧读上声。⑶善人……去杀矣——依文意是孔子引别人的话。\n13.12子曰:“如有王者,必世而后仁。”\n【译文】孔子说:“假若有王者兴起,一定需要三十年才能使仁政大行。”\n13.13子曰:“苟正其身矣,于从政乎何有?不能正其身,如正人何?”\n【译文】孔子说:“假若端正了自己,治理国政有什么困难呢?连本身都不能端正,怎么端正别人呢?”\n13.14冉子退朝。子曰:“何晏也?”对曰:“有政。”子曰:“其事也。如有政,虽不吾以,吾其与闻之⑴。”\n【译文】冉有从办公的地方回来。孔子道:“为什么今天回得这样晚呢?”答道:“有政务。”孔子道:“那只是事务罢了。若是有政务,虽然不用我了,我也会知道的。”\n【注释】⑴与闻之——与,去声,参预之意。《左传》哀公十一年曾有记载,季氏以用田赋的事征求孔子意见,并且说,“子为国老,待子而行。”可见孔子“如有政,吾其与闻之”这话是有根据的。只是冉有不明白“政”和“事”的分别,一时用词不当罢了。依我看,这章并无其它意义,前人有故求深解的,未必对。\n13.15定公问:“一言而可以兴邦,有诸?”\n孔子对曰:“言不可以若是其几也。人之言曰:‘为君难,为臣不易。’如知为君之难也,不几乎一言而兴邦乎?”\n曰:“一言而丧邦,有诸?”\n孔子对曰:“言不可以若是其几也。人之言曰:‘予无乐乎为君,唯其言而莫予违也。’如其善而莫之违也,不亦善乎?如不善而莫之违也,不几乎一言而丧邦乎?”\n【译文】鲁定公问:“一句话兴盛国家,有这事么?”\n孔子答道:“说话不可以像这样地简单机械。不过,人家都说:‘做君上很难,做臣子不容易。’假若知道做君上的艰难,[自然会谨慎认真地干去,]不近于一句话便兴盛国家么?”\n定公又道:“一句话丧失国家,有这事么?”\n孔子答道:“说话不可以像这样地简单机械。不过,大家都说:‘我做国君没有别的快乐,只是我说什么话都没有人违抗我。’假若说的话正确而没有人违抗,不也好么?假若说的话不正确而也没有人违抗,不近于一句话便丧失国家么?”\n13.16叶公问政。子曰:“近者悦,远者来。”\n【译文】叶公问政治。孔子道:“境内的人使他高兴,境外的人使他来投奔。”\n13.17子夏为莒父⑴宰,问政。子曰:“无欲速,无见小利。欲速,则不达;见小利,则大事不成。”\n【译文】子夏做了莒父的县长,问政治。孔子道:“不要图快,不要顾小利。图快,反而不能达到目的;顾小利,就办不成大事。”\n【注释】⑴莒父——鲁国之一邑,现在已经不能确知其所在。山东通志认为在今山东高密县东南。\n13.18叶公语孔子曰:“吾党有直躬者,其父攘羊,而子证⑴之。”孔子曰:“吾党之直者异于是:父为子隐,子为父隐。——直在其中⑵矣。”\n【译文】叶公告诉孔子道:“我那里有个坦白直率的人,他父亲偷了羊,他便告发。”孔子道:“我们那里坦白直率的人和你们的不同:父亲替儿子隐瞒,儿子替父亲隐瞒——直率就在这里面。”\n【注释】⑴证——《说文》云:“证,告也。”正是此义。相当今日的“检举”“揭发”,《韩非子·五蠹篇》述此事作“谒之吏”,《吕氏春秋·当务篇》述此事作“谒之上”,都可以说明正是其子去告发他父亲。“证明”的“证”,古书一般用“征”字为之。⑵直在其中——孔子伦理哲学的基础就在于“孝”和“慈”因之说父子相隐,直在其中。\n13.19樊迟问仁。子曰:“居处恭,执事敬,与人忠。虽之⑴夷狄,不可弃也。”\n【译文】樊迟问仁。孔子道:“平日容貌态度端正庄严,工作严肃认真,为别人忠心诚意。这几种品德,纵到外国去,也是不能废弃的。”\n【注释】⑴之——动词,到也。\n13.20子贡问曰:“何如斯可谓之士矣?”子曰:“行己有耻,使于四方,不辱君命,可谓士矣。”\n曰:“敢问其次。”曰:“宗族称孝焉,乡党称弟焉。”\n曰:“敢问其次。”曰:“言必信,行必果,硁硁然小人哉!——抑亦可以为次矣。”\n曰:“今之从政者何如?”子曰:“噫!斗筲之人⑴,何足算也?”\n【译文】子贡问道:“怎样才可以叫做‘士’?”孔子道:“自己行为保持羞耻之心,出使外国,很好地完成君主的使命,可以叫做‘士’了。”\n子贡道:“请问次一等的。”孔子道:“宗族称赞他孝顺父母,乡里称赞他恭敬尊长。”\n子贡又道:“请问再次一等的。”孔子道:“言语一定信实,行为一定坚决,这是不问是非黑白而只管自己贯彻言行的小人呀,但也可以说是再次一等的‘士’了。”\n子贡道:“现在的执政诸公怎么样?”孔子道:“咳!这班器识狭小的人算得什么?”\n【注释】⑴斗筲之人——斗是古代的量名,筲音梢,shāo,古代的饭筐(《说文》作𥳓),能容五升。斗筲譬如度量和见识的狭小。有人说,“斗筲之人”也可以译为“车载斗量之人”,言其不足为奇。\n13.21子曰:“不得中行而与之,必也狂狷⑴乎!狂者进取,狷者有所不为也。”\n【译文】孔子说:“得不到言行合乎中庸的人和他相交,那一定要交到激进的人和狷介的人罢,激进者一意向前,狷介者也不肯做坏事。”\n【注释】⑴狂狷——《孟子·尽心篇下》有一段话可以为本文的解释,録之于下:“孟子曰:‘孔子不得中道而与之,必也狂獧(同“狷”)乎!狂者进取,獧者有所不为也。孔子岂不欲中道哉?不可必得,故思其次也。’‘敢问何如斯可谓狂矣?’(此万章问词,下同。)曰:‘如琴张、曾晳、牧皮者,孔子之所谓狂矣。’何以谓之狂也?’曰:‘其志嘐嘐然,曰:古之人!古之人!夷考其行而不掩焉者也。狂者又不可得,欲得不屑不洁之士而与之,是獧也,是又其次也。’”孟轲这话未必尽合孔子本意,但可备参考。\n13.22子曰:“南人有言曰:‘人而无恒,不可以作巫医⑴。’善夫!”\n“不恒其德⑵,或承之羞。”子曰:“不占而已矣。”\n【译文】孔子说:“南方人有句话说,‘人假若没有恒心,连巫医都做不了。’这句话很好呀!”\n《易经·恒卦》的爻辞说:“三心二意,翻云覆雨,总有人招致羞耻。”孔子又说:“这话的意思是叫无恒心的人不必去占卦罢了。”\n【注释】⑴巫医——巫医是一词,不应分为卜筮的巫和治病的医两种。古代常以禳祷之术替人治疗,这种人便叫巫医。⑵不恒其德——这有两种意义:(甲)不能持久,时作时辍;(乙)没有一定的操守。译文用“三心二意”表示“不能持久”,用“翻云覆雨”表示“没有操守”。\n13.23子曰:“君子和而不同,小人同而不和⑴。”\n【译文】孔子说:“君子用自己的正确意见来纠正别人的错误意见,使一切都做到恰到好处,却不肯盲从附和。小人只是盲从附和,却不肯表示自己的不同意见。”\n【注释】⑴和,同——“和”与“同”是春秋时代的两个常用术语,《左传》昭公二十年所载晏子对齐景公批评梁丘据的话,和《国语·郑语》所载史伯的话都解说得非常详细。“和”如五味的调和,八音的和谐,一定要有水、火、酱、醋各种不同的材料才能调和滋味,一定要有高下、长短、疾徐各种不同的声调才能使乐曲和谐。晏子说:“君臣亦然。君所谓可,而有否焉,臣献其否以成其可;君所谓否,而有可焉,臣献其可以去其否。”因此史伯也说,“以他平他谓之和”。“同”就不如此,用晏子的话说:“君所谓可,据亦曰可;君所谓否,据亦曰否;若以水济水,谁能食之?若琴瑟之专一,谁能听之?‘同’之不可也如是。”我又认为这个“和”字与“礼之用和为贵”的“和”有相通之处。因此译文也出现了“恰到好处”的字眼。\n13.24子贡问曰:“乡人皆好之,何如?”子曰:“未可也⑴。”\n“乡人皆恶之,何如?”子曰:“未可也;不如乡人之善者好之,其不善者恶之。”\n【译文】子贡问道:“满乡村的人都喜欢他,这个人怎么样?”孔子道:“还不行。”\n子贡便又道:“满乡村的人都厌恶他,这个人怎么样?”孔子道:“还不行。最好是满乡村的好人都喜欢他,满乡村的坏人都厌恶他。”\n【注释】⑴未可也——如果一乡之人皆好之,便近乎所谓好好先生,孔、孟叫他为“乡愿。”因之孔子便说:“众好之,必察焉;众恶之,必察焉。”(15.28)又说,“唯仁者能好人,能恶人。”(4.3)这可以为“善者好之,不善者恶之”的解释。\n13.25子曰:“君子易事⑴而难说也。说之不以道,不说也;及其使人也,器之。小人难事而易说也。说之虽不以道,说也;及其使人也,求备焉。”\n【译文】孔子说:“在君子底下工作很容易,讨他的欢喜却难。不用正当的方式去讨他的欢喜,他不会欢喜的;等到他使用人的时候,却衡量各人的才德去分配任务。在小人底下工作很难,讨他的欢喜却容易。用不正当的方式去讨他的欢喜,他会欢喜的;等到他使用人的时候,便会百般挑剔,求全责备。”\n【注释】⑴易事——《说苑·雅言篇》说:“曾子曰,‘夫子见人之一善而忘其百非,是夫子之易事也’。”这话可以作“君子易事”的一个说明。\n13.26子曰:“君子泰而不骄⑴,小人骄而不泰。”\n【译文】孔子说:“君子安详舒泰,却不骄傲凌人;小人骄傲凌人,却不安详舒泰。”\n【注释】⑴泰,骄——皇侃《义疏》云:“君子坦荡荡,心貌怡平,是泰而不为骄慢也;小人性好轻凌,而心恒戚戚,是骄而不泰也。”李塨《论语传注》云:“君子无众寡,无小大,无敢慢(按:见20.2),何其舒泰!小人矜己傲物,惟恐失尊,何其骄侈,而安得泰?”译文正取此义。\n13.27子曰:“刚、毅、木、讷近仁。”\n【译文】孔子说:“刚强、果决、朴质,而言语不轻易出口,有这四种品德的人近于仁德。”\n13.28子路问曰:“何如斯可谓之士矣?”子曰:“切切偲偲⑴,怡怡⑵如也,可谓士矣。朋友切切偲偲,兄弟怡怡。”\n【译文】子路问道:“怎么样才可以叫做‘士’了呢?”孔子道:“互相批评,和睦共处,可以叫做‘士’了。朋友之间,互相批评;兄弟之间,和睦共处。”\n【注释】⑴切切偲偲——偲音思,sī。切切偲偲,互相责善的样子。⑵怡怡——和顺的样子。\n13.29子曰:“善人教民七年,亦可以卽戎⑴矣。”\n【译文】孔子说:“善人教导人民达七年之久,也能够叫他们作战了。”\n【注释】⑴卽戎——“卽”是“卽位”的“卽”,就也,往那里去的意思。“戎”是“兵戎”的意思。\n13.30子曰:“以不教民⑴战,是谓弃之。”\n【译文】孔子道:“用未经受过训练的人民去作战,这等于糟踏生命。”\n【注释】⑴不教民——“不教民”三字构成一个名词语,意思就是“不教之民”,正如《诗经·邶风·柏舟》“心之忧矣,如匪澣衣”的“匪澣衣”一样,意思就是“匪澣之衣”(不曾洗涤过的衣服)。\n"},{"id":102,"href":"/zh/docs/culture/%E8%AE%BA%E8%AF%AD/%E8%AE%BA%E8%AF%AD%E8%AF%91%E6%B3%A8_%E6%9D%A8%E4%BC%AF%E5%B3%BB/09%E5%AD%90%E7%BD%95%E7%AF%87%E7%AC%AC%E4%B9%9D/","title":"09子罕篇第九","section":"论语译注 杨伯峻","content":" 子罕篇第九 # (共三十一章(朱熹《集注》把第六、第七两章合并为一章,所以作三十章。))\n9.1子罕⑴言利与命与仁。\n【译文】孔子很少[主动]谈到功利、命运和仁德。\n【注释】⑴罕——副词,少也,只表示动作频率。而《论语》一书,讲“利”的六次,讲“命”的八、九次,若以孔子全部语言比较起来,可能还算少的。因之子贡也说过,“夫子之言性与天道,不可得而闻也。”(公冶长篇第五)至于“仁”,在《论语》中讲得最多,为什么还说“孔子罕言”呢?于是对这一句话便生出别的解释了。金人王若虚(《误谬杂辨》)、清人史绳祖(《学斋占毕》)都以为造句应如此读:“子罕言利,与命,与仁。”“与”,许也。意思是“孔子很少谈到利,却赞成命,赞成仁”。黄式三(《论语后案》)则认为“罕”读为“轩”,显也。意思是“孔子很明显地谈到利、命和仁”。遇夫先生(《论语疏证》)又以为“所谓罕言仁者,乃不轻许人以仁之意,与罕言利命之义似不同。试以圣人评论仲弓、子路、冉有、公西华、令尹子文、陈文子之为人及克伐怨欲不行之德,皆云不知其仁,更参之以儒行之说,可以证明矣”。我则以为《论语》中讲“仁”虽多,但是一方面多半是和别人问答之词,另一方面,“仁”又是孔门的最高道德标准,正因为少谈,孔子偶一谈到,便有记载。不能以记载的多便推论孔子谈得也多。孔子平生所言,自然千万倍于《论语》所记载的,《论语》出现孔子论“仁”之处若用来和所有孔子平生之言相比,可能还是少的。诸家之说未免对于《论语》一书过于拘泥,恐怕不与当时事实相符,所以不取。于省吾读“仁”为“𡰥”,卽“夷狄”之“夷”,未必确。\n9.2达巷党⑴人曰:“大哉孔子!博学而无所成名。”子闻之,谓门弟子曰:“吾何执?执御乎?执射乎?吾执御矣。”\n【译文】达街的一个人说:“孔子真伟大!学问广博,可惜没有足以树立名声的专长。”孔子听了这话,就对学生们说:“我干什么呢?赶马车呢?做射击手呢?我赶马车好了。”\n【注释】⑴达巷党——《礼记·杂记》有“余从老聃助葬于巷党”的话,可见“巷党”两字为一词,“里巷”的意思。\n9.3子曰:“麻冕⑴,礼也;今也纯⑵,俭⑶,吾从众。拜下⑷,礼也;今拜乎上,泰也。虽违众,吾从下。”\n【译文】孔子说:“礼帽用麻料来织,这是合于传统的礼的;今天大家都用丝料,这样省俭些,我同意大家的做法。臣见君,先在堂下磕头,然后升堂又磕头,这是合于传统的礼的。今天大家都免除了堂下的磕头,只升堂后磕头,这是倨傲的表现。虽然违反大家,我仍然主张要先在堂下磕头。”\n【注释】⑴麻冕——一种礼帽,有人说就是缁布冠(古人一到二十岁,便举行加帽子的仪式,叫“冠礼”。第一次加的便是缁布冠),未必可信。⑵纯——黑色的丝。⑶俭——绩麻做礼帽,依照规定,要用二千四百缕经线。麻质较粗,必须织得非常细密,这很费工。若用丝,丝质细,容易织成,因而省俭些。⑷拜下——指臣子对君主的行礼,先在堂下磕头,然后升堂再磕头。《左传》僖公九年和《国语·齐语》都记述齐桓公不听从周襄王的辞让,终于下拜的事。到孔子时,下拜的礼似乎废弃了。\n9.4子絶四——毋意,毋必,毋固,毋我。\n【译文】孔子一点也没有四种毛病——不悬空揣测,不绝对肯定,不拘泥固执,不唯我独是。\n9.5子畏于匡⑴,曰:“文王既没,文不在兹乎?天之将丧斯文也,后死者⑵不得与⑶于斯文也;天之未丧斯文也,匡人其如予何?”\n【译文】孔子被匡地的羣众所拘禁,便道:“周文王死了以后,一切文化遗产不都在我这里吗?天若是要消灭这种文化,那我也不会掌握这些文化了;天若是不要消灭这一文化,那匡人将把我怎么样呢?”\n【注释】⑴子畏于匡——《史记·孔子世家》说,孔子离开卫国,准备到陈国去,经过匡。匡人曾经遭受过鲁国阳货的掠夺和残杀,而孔子的相貌很像阳货,便以为孔子就是过去曾经残害过匡地的人,于是囚禁了孔子。“畏”是拘囚的意思,《荀子·赋篇》云:“比干见刳,孔子拘匡。”《史记·孔子世家》作“拘焉五日”,可见这一“畏”字和《礼记·檀弓》“死而不吊者三,畏、厌、溺”的“畏”相同,说见俞樾《羣经平议》。今河南省长垣县西南十五里有匡城,可能就是当日孔子被囚之地。⑵后死者——孔子自谓。⑶与——音预。\n9.6太宰⑴问于子贡曰:“夫子圣者与?何其多能也?”子贡曰:“固天纵之将圣,又多能也。”\n子闻之,曰:“太宰知我乎!吾少也贱,故多能鄙事。君子多乎哉?不多也。”\n【译文】太宰向子贡问道:“孔老先生是位圣人吗?为什么这样多才多艺呢?”子贡道:“这本是上天让他成为圣人,又使他多才多艺。”\n孔子听到,便道:“太宰知道我呀!我小时候穷苦,所以学会了不少鄙贱的技艺。真正的君子会有这样多的技巧吗?是不会的。”\n【注释】⑴太宰——官名。这位太宰已经不知是哪一国人以及姓甚名谁了。\n9.7牢⑴曰:“子云,‘吾不试⑵,故艺。’”\n【译文】牢说:“孔子说过,我不曾被国家所用,所以学得一些技艺。”\n【注释】⑴牢——郑玄说是孔子学生,但《史记·仲尼弟子列传》无此人。王肃伪撰之《孔子家语》说“琴张,一名牢,字子开,亦字子张,卫人也”,尤其不可信。说本王引之,详王念孙《读书杂志》卷四之三。⑵试——《论衡·正说篇》云:“尧曰:‘我其试哉!’说《尚书》曰:‘试者用也。’”这“试”字也应当“用”字解。\n9.8子曰:“吾有知乎哉?无知也。有鄙夫问于我,空空如也。我叩其两端而竭焉。”\n【译文】孔子说:“我有知识吗?没有哩。有一个庄稼汉问我,我本是一点也不知道的;我从他那个问题的首尾两头去盘问,[才得到很多意思,]然后尽量地告诉他。”\n9.9子曰:“凤鸟不至,河不出图⑴,吾已矣夫!”\n【译文】孔子说:“凤凰不飞来了,黄河也没有图画出来了,我这一生恐怕是完了吧!”\n【注释】⑴凤鸟河图——古代传说,凤凰是一种神鸟,祥瑞的象征,出现就是表示天下太平。又说,圣人受命,黄河就出现图画。孔子说这几句话,不过藉此比喻当时天下无清明之望罢了。\n9.10子见齐衰⑴者、冕衣裳者⑵与瞽者,见之,虽少,必作⑶;过之,必趋⑶。\n【译文】孔子看见穿丧服的人、穿戴着礼帽礼服的人以及瞎了眼睛的人,相见的时候,他们虽然年轻,孔子一定站起来;走过的时候,一定快走几步。\n【注释】⑴齐衰——齐音咨,zī;衰音崔,cuī。齐衰,古代丧服,用熟麻布做的,其下边缝齐(斩衰则用粗而生的麻布,左右及下边也都不缝)。齐衰又有齐衰三年、齐衰期(一年)、齐衰五月、齐衰三月几等;看死了什么人,便服多长日子的孝。这里讲齐衰,自然也包括斩衰而言。斩衰是最重的孝服,儿子对父亲,臣下对君上才斩衰三年。⑵冕衣裳者——卽衣冠整齐的贵族。冕是高等贵族所戴的礼帽,后来只有皇帝所戴才称冕。衣是上衣,裳是下衣,相当现代的帬。古代男子上穿衣,下着帬。⑶作,趋——作,起;趋,疾行。这都是一种敬意的表示。\n9.11颜渊喟然叹曰:“仰之弥高,钻之弥坚。瞻之在前,忽焉在后。夫子循循然善诱人,博我以文,约我以礼,欲罢不能。既竭吾才,如有所立卓尔。虽欲从之,末由也已。”\n【译文】颜渊感叹着说:“老师之道,越抬头看,越觉得高;越用力钻研,越觉得深。看看,似乎在前面,忽然又到后面去了。[虽然这样高深和不容易捉摸,可是]老师善于有步骤地诱导我们,用各种文献来丰富我的知识,又用一定的礼节来约束我的行为,使我想停止学习都不可能。我已经用尽我的才力,似乎能够独立地工作。要想再向前迈进一步,又不知怎样着手了。”\n9.12子疾病,子路使门人为臣⑴。病间,曰:“久矣哉,由之行诈也!无臣而为有臣。吾谁欺?欺天乎!且予与其死于臣之手也,无宁⑵死于二三子之手乎!且予纵不得大葬,予死于道路乎?”\n【译文】孔子病得厉害,子路便命孔子的学生组织治丧处。很久以后,孔子的病渐渐好了,就道:“仲由干这种欺假的勾当竟太长久了呀!我本不该有治丧的组织,却一定要使人组织治丧处。我欺哄谁呢?欺哄上天吗?我与其死在治丧的人的手里,宁肯死在你们学生们的手里,不还好些吗?卽使不能热热闹闹地办理丧葬,我会死在路上吗?”\n【注释】⑴为臣——和今天的组织治丧处有相似之处,所以译文用来比傅。但也有不同之处。相似之处是死者有一定的社会地位才给他组织治丧处。古代,诸侯之死才能有“臣”;孔子当时,可能有许多卿大夫也“僭”行此礼。不同之处是治丧处人死以后才组织,才开始工作。“臣”却不然,死前便工作,死者的衣衾手足的安排以及翦须诸事都由“臣”去处理。所以孔子这里也说“死于臣之手”的话。⑵无宁——“无”为发语词,无义。《左传》隐公十一年云:“无宁兹许公复奉其社稷。”杜预的注说:“无宁,宁也。”\n9.13子贡曰:“有美玉于斯,韫椟而藏诸?求善贾⑴而沽诸?”子曰:“沽之哉!沽之哉!我待贾者也。”\n【译文】子贡道:“这里有一块美玉,把它放在柜子里藏起来呢?还是找一个识货的商人卖掉呢?”孔子道:“卖掉,卖掉,我是在等待识货者哩。”\n【注释】⑴贾——音古,gǔ,商人。又同“价”,价钱。如果取后一义,“善贾”便是“好价钱”,“待贾”便是“等好价钱”。不过与其说孔子是等价钱的人,不如说他是等识货者的人。\n9.14子欲居九夷⑴。或曰:“陋,如之何?”子曰:“君子居之,何陋之有⑵?”\n【译文】孔子想搬到九夷去住。有人说:“那地方非常简陋,怎么好住?,”孔子道:“有君子去住,就不简陋了。”\n【注释】⑴九夷——九夷就是淮夷。《韩非子·说林上篇》云:“周公旦攻九夷而商盖伏。”商盖就是商奄,则九夷本居鲁国之地,周公曾用武力降服他们。春秋以后,盖臣属楚、吴、越三国,战国时又专属楚。以《说苑·君道篇》、《淮南子·齐俗训》、《战国策·秦策》与《魏策》、李斯〈上秦始皇书〉诸说九夷者考之,九夷实散居于淮、泗之间,北与齐、鲁接壤(说本孙诒让《墨子闲诂·非攻篇》)。⑵何陋之有——直译是“有什么简陋呢”,此用意译。\n9.15子曰:“吾自卫反鲁⑴,然后乐正,《雅》、《颂》各得其所⑵。”\n【译文】孔子说:“我从卫国回到鲁国,才把音乐[的篇章]整理出来,使《雅》归《雅》,《颂》归《颂》,各有适当的安置。”\n【注释】⑴自卫反鲁——根据《左传》,事在鲁哀公十一年冬。⑵雅颂各得其所——“雅”和“颂”一方面是《诗经》内容分类的类名,一方面也是乐曲分类的类名。篇章内容的分类,可以由今日的《诗经》考见;乐曲的分类,因为古乐早已失传,便无可考证了。孔子的正雅颂,究竟是正其篇章呢?还是正其乐曲呢?或者两者都正呢?《史记·孔子世家》和《汉书·礼乐志》则以为主要的是正其篇章,因为我们已经得不到别的材料,只得依从此说。孔子只“正乐”,调整《诗经》篇章的次序,太史公在孔子世家中因而说孔子曾把三千余篇的古诗删为三百余篇,是不可信的。\n9.16子曰:“出则事公卿,入则事父兄⑴,丧事不敢不勉,不为酒困,何有于我哉⑵?”\n【译文】孔子说:“出外便服事公卿,入门便服事父兄,有丧事不敢不尽礼,不被酒所困扰,这些事我做到了哪些呢?”\n【注释】⑴父兄——孔子父亲早死,说这话时候,或者他哥孟皮还在,“父兄”二字,只“兄”字有义,古人常有这用法。“父兄”或者在此引伸为长者之义。⑵何有于我哉——如果把“何有”看为“不难之词”,那这一句便当译为“这些事对我有什么困难呢”。全文由自谦之词变为自述之词了。\n9.17子在川上,曰:“逝者如斯夫!不舍⑴昼夜。”\n【译文】孔子在河边,叹道:“消逝的时光像河水一样呀!日夜不停地流去。”\n【注释】⑴舍——上、去两声都可以读。上声,同舍;去声,也作动词,居住,停留。孔子这话不过感叹光阴之奔驶而不复返吧了,未必有其它深刻的意义。《孟子·离娄下》、《荀子·宥坐篇》、《春秋繁露·山川颂》对此都各有阐发,很难说是孔子本意。\n9.18子曰:“吾未见好德如好色者也。”\n【译文】孔子说:“我没有看见过这样的人,喜爱道德赛过喜爱美貌。”\n9.19子曰:“譬如为山,未成一篑,止,吾止也。譬如平地,虽覆一篑,进,吾往也⑴。”\n【译文】孔子说:“好比堆土成山,只要再加一筐土便成山了,如果懒得做下去,这是我自己停止的。又好比在平地上堆土成山,纵是刚刚倒下一筐土,如果决心努力前进,还是要自己坚持呵!”\n【注释】⑴子曰……往也——这一章也可以这样讲解:“好比堆土成山,只差一筐土了,如果[应该]停止,我便停止。好比平地堆土成山,纵是刚刚倒下一筐土,如果[应该]前进,我便前进。”依照前一讲解,便是“为仁由己”的意思;依照后一讲解,便是“唯义与比”的意思。\n9.20子曰:“语之而不惰者,其回也与!”\n【译文】孔子说:“听我说话始终不懈怠的,大概只有颜回一个人吧!”\n9.21子谓颜渊,曰:“惜乎!吾见其进也,未见其止也。”\n【译文】孔子谈到颜渊,说道:“可惜呀[他死了]!我只看见他不断地进步,从没看见他停留。”\n9.22子曰:“苗而不秀⑴者有矣夫!秀而不实者有矣夫!”\n【译文】孔子说:“庄稼生长了,却不吐穗开花的,有过的罢!吐穗开花了,却不凝浆结实的,有过的罢!”\n【注释】⑴秀——“秀”字从禾,则只是指禾黍的吐花。《诗经·大雅·生民》云:“实发实秀,实坚实好。”“发”和“秀”是指庄稼的生长和吐穗开花;“坚”和“好”是指谷粒的坚实和壮大。这都是“秀”的本义。现在还把庄稼的吐穗开花叫做“秀穗”。因此译文点明是指庄稼而言。汉人唐人多以为孔子这话是为颜回短命而发。但颜回只是“秀而不实”(祢衡〈颜子碑〉如此说),则“苗而不秀”又指谁呢?孔子此言必有为而发,但究竟何所指,则不必妄测。\n9.23子曰:“后生可畏,焉知来者之不如今也?四十、五十而无闻焉,斯亦不足畏也已。”\n【译文】孔子说:“年少的人是可怕的,怎能断定他的将来赶不上现在的人呢,一个人到了四、五十岁还没有什么名望,也就值不得惧怕了。”\n9.24子曰:“法语之言,能无从乎?改之为贵。巽与之言,能无说乎?绎之为贵。说而不绎,从而不改,吾末如之何也已矣。”\n【译文】孔子说:“严肃而合乎原则的话,能够不接受吗?改正错误才可贵。顺从己意的话,能够不高兴吗?分析一下才可贵。盲目高兴,不加分析;表面接受,实际不改,这种人我是没有办法对付他的了。”\n9.25子曰:“主忠信,毋友不如己者,过则勿惮改⑴。”\n【注释】⑴见卷一学而篇。\n9.26子曰:“三军⑴可夺帅也,匹夫不可夺志也。”\n【译文】孔子说:“一国军队,可以使它丧失主帅;一个男子汉,却不能强迫他放弃主张。”\n【注释】⑴三军——周朝的制度,诸侯中的大国可以拥有军队三军。因此便用“三军”作军队的通称。\n9.27子曰:“衣⑴敝缊⑵袍,与衣⑴狐貉者立,而不耻者,其由也与?‘不忮不求,何用不臧⑶?’”子路终身诵之。子曰:“是道也,何足以臧?”\n【译文】孔子说道:“穿着破烂的旧丝绵袍子和穿着狐貉裘的人一道站着,不觉得惭愧的,恐怕只有仲由罢!《诗经》上说:‘不嫉妒,不贪求,为什么不会好?’”子路听了,便老念着这两句诗。孔子又道:“仅仅这个样子,怎样能够好得起来?”\n【注释】⑴衣——去声,动词,当“穿”字解。⑵缊——音运,yùn,旧絮。古代没有草棉,所有“絮”字都是指丝绵。一曰,乱麻也。⑶不忮不求,何用不臧——两句见于《诗经·邶风·雄雉篇》。\n9.28子曰:“岁寒,然后知松柏之后雕⑴也。”\n【译文】孔子说:“天冷了,才晓得松柏树是最后落叶的。”\n【注释】⑴雕——同凋、凋零,零落。\n9.29子曰:“知者不惑,仁者不忧,勇者不惧。”\n【译文】孔子说:“聪明人不致疑惑,仁德的人经常乐观,勇敢的人无所畏惧。”\n9.30子曰:“可与共学,未可与适道;可与适道,未可与立⑴;可与立,未可与权。”\n【译文】孔子说:“可以同他一道学习的人,未必可以同他一道取得某种成就;可以同他一道取得某种成就的人,未必可以同他一道事事依体而行;可以同他一道事事依体而行的人,未必可以同他一道通权达变。”\n【注释】⑴立——《论语》的“立”经常包含着“立于礼”的意思,所以这里译为“事事依礼而行”。\n9.31“唐棣⑴之华,偏其反而。岂不尔思?室是远而。”子曰:“未之思也,夫何远之有?”\n【译文】古代有几句这样的诗:“唐棣树的花,翩翩地摇摆。难道我不想念你?因为家住得太遥远。”孔子道:“他是不去想念哩,真的想念,有什么遥远呢?”\n【注释】⑴唐棣……何远之有——唐棣,一种植物,陆玑《毛诗草木鸟兽虫鱼疏》以为就是郁李(蔷薇科,落叶乔木),李时珍《本草纲目》却以为是扶栘(蔷薇科,落叶乔木)。“唐棣之华,偏其反而”似是捉摸不定的意思,或者和颜回讲孔子之道“瞻之在前,忽焉在后”(9.11)意思差不多。“夫何远之有”可能是“仁远乎哉?我欲仁,斯仁至矣”(7.30)的意思。或者当时有人引此诗(这是“逸诗”,不在今《诗经》中),意在证明道之远而不可捉摸,孔子则说,你不曾努力罢了,其实是一呼卽至的。\n"},{"id":103,"href":"/zh/docs/culture/%E8%AE%BA%E8%AF%AD/%E8%AE%BA%E8%AF%AD%E8%AF%91%E6%B3%A8_%E6%9D%A8%E4%BC%AF%E5%B3%BB/06%E9%9B%8D%E4%B9%9F%E7%AF%87%E7%AC%AC%E5%85%AD/","title":"06雍也篇第六","section":"论语译注 杨伯峻","content":" 雍也篇第六 # (共三十章(朱熹《集注》把第一、第二和第四、第五各并为一章,故作二十八章。))\n6.1子曰:“雍也可使南面⑴。”\n【译文】孔子说:“冉雍这个人,可以让他做一部门或一地方的长官。”\n【注释】⑴南面——古代早就知道坐北朝南的方向是最好的,因此也以这个方向的位置最为尊贵,无论天子、诸侯、卿大夫,当他作为长官出现的时候,总是南面而坐的。说见王引之《经义述闻》和凌廷堪《礼经释义》。\n6.2仲弓问子桑伯子⑴。子曰:“可也简⑵。”仲弓曰:“居敬而行简,以临其民,不亦可乎?居简而行简,无乃⑶大⑷简乎?”子曰:“雍之言然。”\n【译文】仲弓问到子桑伯子这个人。孔子道:“他简单得好。”仲弓道:“若存心严肃认真,而以简单行之,[抓大体,不烦琐,]来治理百姓,不也可以吗?若存心简单,又以简单行之,不是太简单了吗?”孔子道:“你这番话正确。”\n【注释】⑴子桑伯子——此人已经无可考。有人以为就是《庄子》的子桑户,又有人以为就是秦穆公时的子桑(公孙枝),都未必可靠。既然称“伯子”,很大可能是卿大夫。仲弓说“以临其民”。也要是卿大夫才能临民。⑵简——《说苑》有子桑伯子的一段故事,说他“不衣冠而处”,孔子却认为他“质美而无文”,因之有人认为这一“简”字是指其“无文”而言。但此处明明说他“可也简”,而《说苑》孔子却说,“吾将说而文之”,似乎不能如此解释。朱熹以为“简”之所以“可”,在于“事不烦而民不扰”,颇有道理,故译文加了两句。⑶无乃——相当于“不是”,但只用于反问句。⑷大——同“太”。\n6.3哀公问:“弟子孰为好学?”孔子对曰:“有颜回者好学,不迁怒,不贰过。不幸短命⑴死矣,今也则亡,未闻好学者也。”\n【译文】鲁哀公问:“你的学生中,哪个好学?”孔子答道:“有一个叫颜回的人好学,不拿别人出气;也不再犯同样的过失。不幸短命死了,现在再没有这样的人了,再也没听过好学的人了。”\n【注释】短命——《公羊传》把颜渊的死列在鲁哀公十四年(公元前481年),其时孔子年七十一,依《史记·仲尼弟子列传》,颜渊少于孔子三十岁,则死时年四十一。但据《孔子家语》等书,颜回卒时年仅三十一,因此毛奇龄(《论语稽求篇》)谓《史记》“少孔子三十岁,原是四十之误”。\n6.4子华⑴使⑵于齐,冉子⑶为其母请粟⑷。子曰:“与之釜⑸。”\n请益。曰:“与之庾⑹。”\n冉子与之粟五秉⑺。\n子曰:“赤之适齐也,乘肥马⑻,衣⑼轻裘。吾闻之也:君子周⑽急不继富。”\n【译文】公西华被派到齐国去作使者,冉有替他母亲向孔子请求小米。孔子道:“给他六斗四升。”\n冉有请求增加。孔子道:“再给他二斗四升。”\n冉有却给了他八十石。\n孔子道:“公西赤到齐国去,坐着由肥马驾的车辆,穿着又轻又暖的皮袍。我听说过:君子只是雪里送炭,不去锦上添花。”\n【注释】⑴子华——孔子学生,姓公西,名赤,字子华,比孔子小四十二岁。⑵使——旧读去声,出使。⑶冉子——《论语》中,孔子弟子称“子”的不过曾参、有若、闵子骞和冉有几个人,因之这冉子当然就是冉有。⑷粟——小米(详新建设杂志1954年12月号胡静〈我国古代农艺史上的几个问题〉)。一般的说法,粟是指未去壳的谷粒,去了壳就叫做米。但在古书中也有把米唤做粟的。见沈彤《周官禄田考》。⑸釜——fǔ,古代量名,容当时的量器六斗四升,约合今天的容量一斗二升八合。⑹庾——yǔ,古代量名,容当日的二斗四升,约合今日的四升八合。⑺秉——音丙,bǐng,古代量名,十六斛。五秉则是八十斛。古代以十斗为斛,所以译为八十石。南宋的贾似道才改为五斗一斛,一石两斛,沿用到民国初年,现今已经废除这一量名了。周秦的八十斛合今天的十六石。⑻乘肥马——不能解释为“骑肥马”,因为孔子时穿着大袖子宽腰身的衣裳,是不便于骑马的。直到战国时的赵武灵王才改穿少数民族服装,学习少数民族的骑着马射箭,以便利于作战。在所有“经书”中找不到骑马的文字,只有《曲礼》有“前有车骑”一语,但《曲礼》的成书在战国以后。⑼衣——去声,动词,当“穿”字解。⑽周——后人写作“赒”,救济。\n6.5原思⑴为之⑵宰,与之粟九百⑶,辞。子曰:“毋!以与尔邻里乡党⑷乎!”\n【译文】原思任孔子家的总管,孔子给他小米九百,他不肯受。孔子道:“别辞,有多的,给你地方上[的穷人]吧!”\n【注释】⑴原思——孔子弟子原宪,字子思。⑵之——用法同“其”,他的,指孔子而言。⑵九百——下无量名,不知是斛是斗,还是别的。习惯上常把最通用的度、量、衡的单位省畧不说,古今大致相同。不过这一省畧,可把我们迷胡了。⑷邻里乡党——都是古代地方单位的名称,五家为邻,二十五家为里,万二千五百家为乡,五百家为党。\n6.6子谓仲弓,曰:“犂牛⑴之子骍⑵且角⑶;虽欲勿用⑷,山川其⑸舍诸⑹?”\n【译文】孔子谈到冉雍,说:“耕牛的儿子长着赤色的毛,整齐的角,虽然不想用它作牺牲来祭祀,山川之神难道会舍弃它吗?”\n【注释】⑴犂牛——耕牛。古人的名和字,意义一定互相照应。从孔子学生冉耕字伯牛、司马耕字子牛的现象看来,足以知道生牛犂田的方法当时已经普遍实行。从前人说,耕牛制度开始于汉武帝时的赵过,那是由于误解《汉书·食货志》的缘故。⑵骍——赤色。周朝以赤色为贵,所以祭祀的时候也用赤色的牲畜。⑶角——意思是两角长得周正。这是古人用词的简略处。⑷用——义同《左传》“用牲于社”之“用”,杀之以祭也。据《史记·仲尼弟子列传》说,仲弓的父亲是贱人,仲弓却是“可使南面”的人才,因此孔子说了这番话。古代供祭祀的牺牲不用耕牛,而且认为耕牛之子也不配作牺牲。孔子的意思是,耕牛所产之子如果够得上作牺牲的条件,山川之神一定会接受这种祭享。那么,仲弓这样的人才,为什么因为他父亲“下贱”而舍弃不用呢?⑸其——意义同“岂”。⑹诸——“之乎”两字的合音字。\n6.7子曰:“回也,其心三月⑴不违仁,其余则日月⑵至焉而已矣。”\n【译文】孔子说:“颜回呀,他的心长久地不离开仁德,别的学生么,只是短时期偶然想起一下罢了。”\n【注释】⑴三月,日月——这种词语必须活看,不要被字面所拘束,因此译文用“长久地”译“三月”,用“短时期”“偶然”来译“日月”。\n6.8季康子问:“仲由可使从政也与?”子曰:“由也果,于从政乎何有?”\n曰:“赐也可使从政也与?”曰:“赐也达,于从政乎何有?”\n曰:“求也可使从政也与?”曰:“求也艺,于从政乎何有?”\n【译文】季康子问孔子:“仲由这人,可以使用他治理政事么?”孔子道:“仲由果敢决断,让他治理政事有什么困难呢?”\n又问:“端木赐可以使用他治理政事么?”孔子道:“端木赐通情达理,让他治理政事有什么困难呢?”\n又问:“冉求可以使用他治理政事么?”孔子道:“冉求多才多艺,让他治理政事有什么困难呢?”\n6.9季氏使闵子骞⑴为费⑵宰。闵子骞曰:“善为我辞焉!如有复我者,则吾必在汶上⑶矣。”\n【译文】季氏叫闵子骞作他采邑费地的县长。闵子骞对来人说道:“好好地替我辞掉吧!若是再来找我的话,那我一定会逃到汶水之北去了。”\n【注释】⑴闵子骞——孔子学生闵损,字子骞,比孔子小十五岁。(公元前515——?)⑵费——旧音秘,故城在今山东费县西北二十里。⑶汶上——汶音问,wèn,水名,就是山东的大汶河。桂馥《札朴》云:“水以阳为北,凡言某水上者,皆谓水北。”“汶上”暗指齐国之地。\n6.10伯牛⑴有疾,子问之,自牖执其手,曰:“亡之⑵,命矣夫!斯人也而有斯疾也!斯人也而有斯疾也!”\n【译文】伯牛生了病,孔子去探问他,从窗户里握着他的手,道:“难得活了,这是命呀,这样的人竟有这样的病!这样的人竟有这样的病!”\n【注释】⑴伯牛——孔子学生冉耕字伯牛。⑵亡之——这“之”字不是代词,不是“亡”(死亡之意)的宾语,因为“亡”字在这里不应该有宾语,只是凑成一个音节罢了。古代常有这种形似宾语而实非宾语的“之”字,详拙著《文言语法》。\n6.11子曰:“贤哉,回也!一箪⑴食,一瓢饮,在陋巷,人不堪其忧,回也不改其乐。贤哉,回也!”\n【译文】孔子说:“颜回多么有修养呀,一竹筐饭,一瓜瓢水,住在小巷子里,别人都受不了那穷苦的忧愁,颜回却不改变他自有的快乐。颜回多么有修养呀!”\n【注释】⑴箪——音单,dān,古代盛饭的竹器,圆形。\n6.12冉求曰:“非不说子之道,力不足也。”子曰:“力不足者⑴,中道而废。今女画⑵。”\n【译文】冉求道:“不是我不喜欢您的学说,是我力量不够。”孔子道:“如果真是力量不够,走到半道会再走不动了。现在你却没有开步走。”\n【注释】⑴力不足者——“者”这一表示停顿的语气词,有时兼表假设语气,详《文言语法》。⑵画——停止。\n6.13子谓子夏曰:“女为君子儒!无为小人儒!”\n【译文】孔子对子夏道:“你要去做个君子式的儒者,不要去做那小人式的儒者!”\n6.14子游为武城⑴宰。子曰:“女得人焉耳⑵乎?”曰:“有澹台灭明者⑶,行不由径,非公事,未尝至于偃之室也。”\n【译文】子游做武城县县长。孔子道:“你在这儿得到什么人才没有?”他道:“有一个叫澹台灭明的人,走路不插小道,不是公事,从不到我屋里来。”\n【注释】⑴武城——鲁国的城邑,在今山东费县西南。⑵耳——通行本作“尔”,兹依《唐石经》、《宋石经》、皇侃《义疏》本作“耳”。⑶有澹台灭明者——澹台灭明字子羽,《史记·仲尼弟子列传》也把他列入弟子。但从这里子游的答话语气来看,说这话时还没有向孔子受业。因为“有……者”的提法,是表示这人是听者以前所不知道的。若果如《史记》所记,澹台灭明在此以前便已经是孔子学生,那子游这时的语气应该与此不同。\n6.15子曰:“孟之反⑴不伐,奔而殿,将入门,策其马,曰:‘非敢后也,马不进也。’”\n【译文】孔子说:“孟之反不夸耀自己,[在抵御齐国的战役中,右翼的军队溃退了,]他走在最后,掩护全军,将进城门,便鞭打着马匹,一面说道:‘不是我敢于殿后,是马匹不肯快走的缘故。’”\n【注释】⑴孟之反——《左传》哀公十一年作“孟之侧”,译文参照《左传》所叙述的事实有所增加。\n6.16子曰:“不有⑴祝鮀⑵之佞,而⑶有宋朝⑷之美,难乎免于今之世矣。”\n【译文】孔子说:“假使没有祝鮀的口才,而仅有宋朝的美丽,在今天的社会里怕不易避免祸害了。”\n【注释】⑴不有——这里用以表示假设语气,“假若没有”的意思。⑵祝鮀——卫国的大夫,字子鱼,《左传》定公四年曾记载着他的外交词令。⑶而——王引之《经义述闻》云:“而犹与也,言有祝鮀之佞与有宋朝之美也。”很多人同意这种讲法,但我终嫌“不有祝鮀之佞,与有宋朝之美”为语句不顺,王氏此说恐非原意。⑷宋朝——宋国的公子朝,《左传》昭公二十年和定公十四年都曾记载着他因为美丽而惹起乱子的事情。\n6.17子曰:“谁能出不由户?何莫由斯道也?”\n【译文】孔子说:“谁能够走出屋外不从房门经过?为什么没有人从我这条路行走呢?”\n6.18子曰:“质胜文则野,文胜质则史。文质彬彬⑴,然后君子。”\n【译文】孔子说:“朴实多于文采,就未免粗野;文采多于朴实,又未免虚浮。文采和朴实,配合适当,这才是个君子。”\n【注释】⑴文质彬彬——此处形容人既文雅又朴实,后来多用来指人文雅有礼貌。\n6.19子曰:“人之生也⑴直,罔⑵之生也幸而免。”\n【译文】孔子说:“人的生存由于正直,不正直的人也可以生存,那是他侥幸地免于祸害。”\n【注释】⑴也——语气词,表“人之生”是一词组作主语,这里无妨作一停顿,下文“直”是谓语。⑵罔——诬罔的人,不直的人。\n6.20子曰:“知之者不如好之者,好之者不如乐之者。”\n【译文】孔子说:“[对于任何学问和事业,]懂得它的人不如喜爱它的人,喜爱它的人又不如以它为乐的人。”\n6.21子曰:“中人以上,可以语上也;中人以下,不可以语上也。”\n【译文】孔子说:“中等水平以上的人,可以告诉他高深学问;中等水平以下的人,不可以告诉他高深学问。”\n6.22樊迟问知。子曰:“务民之义,敬鬼神而远之⑴,可谓知矣。”\n问仁。曰:“仁者先难⑵而后获,可谓仁矣。”\n【译文】樊迟问怎么样才算聪明。孔子道:“把心力专一地放在使人民走向‘义’上,严肃地对待鬼神,但并不打算接近他,可以说是聪明了。”\n又问怎么样才叫做有仁德。孔子道:“仁德的人付出一定的力量,然后收获果实,可以说是仁德了。”\n【注释】⑴远之——远作及物动词,去声,yuàn。疏远,不去接近的意思。譬如祈祷、淫祀,在孔子看来都不是“远之”。⑵先难——颜渊篇第十二又有一段答樊迟的话,其中有两句道:“先事后得,非崇德与?”和这里“先难后获可谓仁矣”是一个意思,所以我把“难”字译为“付出一定的力量”。孔子对樊迟两次说这样的话,是不是樊迟有坐享其成的想法,那就不得而知了。\n6.23子曰:“知者乐水,仁者乐山。知者动,仁者静。知者乐,仁者寿。”\n【译文】孔子说:“聪明人乐于水,仁人乐于山。聪明人活动,仁人沉静。聪明人快乐,仁人长寿。”\n6.24子曰:“齐一变,至于鲁;鲁一变,至于道。”\n【译文】孔子说:“齐国[的政治和教育]一有改革,便达到鲁国的样子;鲁国[的政治和教育]一有改革,便进而合于大道了。”\n6.25子曰:“觚⑴不觚,觚哉!觚哉!”\n【译文】孔子说:“觚不像个觚,这是觚吗!这是觚吗!”\n【注释】⑴觚——音孤,gū,古代盛酒的器皿,腹部作四条棱角,足部也作四条棱角。每器容当时容量二升(或曰三升)。孔子为什么说这话,后人有两种较为近于情理的猜想:(甲)觚有棱角,才能叫做觚。可是做出棱角比做圆的难,孔子所见的觚可能只是一个圆形的酒器,而不是上圆下方(有四条棱角)的了。但也名为棱,因之孔子慨叹当日事物名实不符,如“君不君,臣不臣,父不父,子不子”之类。(乙)觚和孤同音,寡少的意思。只能容酒两升(或者三升)的叫觚,是叫人少饮不要沉湎之意。可能当时的觚实际容量已经大大不止此数,由此孔子发出感慨。(古代酿酒,不懂得蒸酒的技术,因之酒精成份很低,而升又小,两三升酒是微不足道的。《史记·滑稽列传》载淳于髡的话,最多能够饮一石,可以想见了。)\n6.26宰我问曰:“仁者,虽告之曰,‘井有仁⑴焉。’其从之也?”子曰:“何为其然也?君子可逝⑵也,不可陷也;可欺⑶也,不可罔⑷也。”\n【译文】宰我问道:“有仁德的人,就是告诉他,‘井里掉下一位仁人啦。’他是不是会跟着下去呢?”孔子道:“为什么你要这样做呢?君子可以叫他远远走开不再回来,却不可以陷害他;可以欺骗他,却不可以愚弄他。”\n【注释】⑴仁——卽“仁人”的意思,和学而篇第一“泛爱众而亲仁”的“仁”用法相同。⑵逝——古代“逝”字的意义和“往”字有所不同,“往”而不复返才用“逝”字。译文卽用此义。俞樾《羣经平议》读“逝”为“折”说:“逝与折古通用。君子杀身成仁则有之矣,故可得而摧折,然不可以非理陷害之,故可折而不可陷。”亦通。⑶欺、罔——《孟子·万章上》有这样一段话,和这一段结合,正好说明“欺”和“罔”的区别。那段的原文是:“昔者有馈生鱼于郑子产,子产使校人畜之池。校人烹之,反命曰:‘始舍之,圉圉焉;少则洋洋焉;攸然而逝。’子产曰:‘得其所哉!得其所哉!’校人出,曰:‘孰谓子产知?予既烹而食之,曰,得其所哉,得其所哉。’故君子可欺以其方,难罔以非其道。”那么,校人的欺骗子产,是“欺以其方”,而宰我的假设便是“罔以非其道”了。\n6.27子曰:“君子博学于文,约之以礼⑴,亦可以弗畔⑵矣夫!”\n【译文】孔子说:“君子广泛地学习文献,再用礼节来加以约束,也就可以不致于离经叛道了。”\n【注释】⑴博学于文,约之以礼——子罕篇第九云:“颜渊喟然叹曰:‘夫子循循然善诱人,博我以文,约我以礼。’”这里的“博学于文,约之以礼”和子罕篇的“博我以文,约我以礼”是不是完全相同呢?如果完全相同,则“约之以礼”的“之”是指代“君子”而言。这是一般人的说法。但毛奇龄的《论语稽求篇》却说:“博约是两事,文礼是两物,然与‘博我以文,约我以礼’不同。何也?彼之博约是以文礼博约回;此之博约是以礼约文,以约约博也。博在文,约文又在礼也。”毛氏认为“约之以礼”的“之”是指代“文”,正是我们平常所说的“由博返约”的意思。⑵畔——同“叛”。\n6.28子见南子⑴,子路不说。夫子矢之曰:“予所⑵否者,天厌之!天厌之!”\n【译文】孔子去和南子相见,子路不高兴。孔子发誓道:“我假若不对的话,天厌弃我罢!天厌弃我罢!”\n【注释】⑴南子——卫灵公夫人,把持着当日卫国的政治,而且有不正当的行为,名声不好。《史记·孔子世家》对“子见南子”的情况有生动的描述。⑵所——如果,假若。假设连词,但只用于誓词中。详阎若璩《四书释地》。\n6.29子曰:“中庸⑴之为德也,其至矣乎!民⑵鲜久矣。”\n【译文】孔子说:“中庸这种道德,该是最高的了,大家已经是长久地缺乏它了。”\n【注释】⑴中庸——这是孔子的最高道德标准。“中”,折中,无过,也无不及,调和;“庸”,平常。孔子拈出这两个字,就表示他的最高道德标准,其实就是折中的和平常的东西。后代的儒家又根据这两个字作了一篇题为“中庸”的文章,西汉人戴圣收入《礼记》,南宋人朱熹又取入《四书》。司马迁说是子思所作,未必可靠。从其文字和内容看,可能是战国至秦的作品,难免不和孔子的“中庸”有相当距离。⑵民——这“民”字不完全指老百姓,因以“大家”译之。\n6.30子贡曰:“如有博施⑴于民而能济众,何如?可谓仁乎?”子曰:“何事于仁!必也圣乎!尧舜⑵其犹病诸!夫⑶仁者,己欲立而立人,己欲达而达人。能近取譬,可谓仁之方也已。”\n【译文】子贡道:“假若有这么一个人,广泛地给人民以好处,又能帮助大家生活得很好,怎么样?可以说是仁道了吗?”孔子道:“哪里仅是仁道!那一定是圣德了!尧舜或者都难以做到哩!仁是甚么呢?自己要站得住,同时也使别人站得住;自己要事事行得通,同时也使别人事事行得通。能够就眼下的事实选择例子一步步去做,可以说是实践仁道的方法了。”\n【注释】⑴施——旧读去声。⑵尧舜——传说中的上古两位帝王,也是孔子心目中的榜样。⑶夫——音扶,fú,文言中的提挈词。\n"},{"id":104,"href":"/zh/docs/culture/%E8%AE%BA%E8%AF%AD/%E8%AE%BA%E8%AF%AD%E8%AF%91%E6%B3%A8_%E6%9D%A8%E4%BC%AF%E5%B3%BB/20%E5%B0%A7%E6%9B%B0%E7%AF%87%E7%AC%AC%E4%BA%8C%E5%8D%81/","title":"20尧曰篇第二十","section":"论语译注 杨伯峻","content":" 尧曰篇第二十 # (共三章)\n20.1尧曰:“咨!尔舜!天之历数在尔躬,允执其中。四海困穷,天禄永终。”\n舜亦以命禹⑴。\n【译文】尧[让位给舜的时候,]说道:“啧啧!你这位舜!上天的大命已经落到你的身上了,诚实地保持着那正确罢!假若天下的百姓都陷于困苦贫穷,上天给你的禄位也会永远地终止了。”\n舜[让位给禹的时候,]也说了这一番话。\n【注释】⑴这一章的文字前后不相连贯,从宋朝苏轼以来便有许多人疑心它有脱落。我只得把它分为若干段落,逐段译注,以便观览。\n曰:“予小子履⑴敢用玄牡,敢昭告于皇皇后帝:有罪不敢赦。帝臣不蔽⑵,简在帝心。朕躬有罪,无以万方;万方有罪,罪在朕躬。”\n【译文】[汤]说:“我履谨用黑色牡牛作牺牲,明明白白地告于光明而伟大的天帝:有罪的人[我]不敢擅自去赦免他。您的臣仆[的善恶]我也不隐瞒掩盖,您心里也是早就晓得的。我本人若有罪,就不要牵连天下万方;天下万方若有罪,都归我一个人来承担。”\n【注释】⑴予小子履——“予小子”和“予一人”都是上古帝王自称之词。从《史记·殷本记》中知道汤名天乙,甲骨卜辞作“大乙”,相传汤又名履。⑵帝臣不蔽——《墨子·兼爱下篇》此句作“有善不敢蔽”,但郑玄注此句云:“言天简阅其善恶也。”译文从郑。《墨子·兼爱下篇》和《吕氏春秋·顺民篇》都说这是成汤战胜夏桀以后,遭逢大旱,向上天祈祷求雨之词。《国语·周语上》引汤誓“余一人有罪,无以万夫”,和这“朕躬有罪,无以万方”义近。\n周有大赉,善人是富。“虽有周亲,不如仁人。百姓有过,在予一人⑴。”\n【译文】周朝大封诸侯,使善人都富贵起来。“我虽然有至亲,却不如有仁德之人。百姓如果有罪过,应该由我来担承。”\n【注释】⑴虽有周亲……一人——刘宝楠《论语正义》引宋翔凤说,“虽有周亲”四句是周武王封诸侯之辞,尤其像封姜太公于齐之辞。\n谨权量,审法度⑴,修废官⑵,四方之政行焉。兴灭国,继绝世,举逸民,天下之民归心焉。\n【译文】检验并审定度量衡,修复已废弃的机关工作,全国的政令就都会通行了。恢复被灭亡的国家,承续已断绝的后代,提拔被遗落的人才,天下的百姓就都会心悦诚服了。\n【注释】⑴谨权量,审法度——权就是量轻重的衡量,量就是容量,度就是长度。“法度”不是法律制度之意。《史记·秦始皇本纪》和秦权、秦量的刻辞中都有“法度”一词,都是指长度的分、寸、尺、丈、引而言。所以“谨权量,审法度”两句只是“齐一度量衡”一个意思。这一说法,清初阎若璩的《四书释地》又续已发其端。⑵废官——赵佑《四书温故録》云:“或有职而无其官,或有官而不举其职,皆曰废。”这以下都是孔子的话。从文章的风格来看,也和尧告舜、成汤求雨、武王封诸侯的文诰体不同。历代注释家多以为是孔子的话,大致可信。但是刘宝楠《正义》引《汉书·律历志》“孔子陈后王之法曰,谨权量,审法度,修废官,举逸民,四方之政行矣”说:“据《志》此文,是‘谨权量’以下皆孔子语,故何休《公羊》昭三十二年注引此节文冠以孔子曰”云云,则不足为证。因为汉人引《论语》,不论是否孔子之言,多称“孔子曰”。《困学纪闻》曾举出《汉书·艺文志》引“小道可观”(19.4),《后汉书·蔡邕传》引“致远恐泥”(同上)皆以子夏之言为孔子,其实不止于此,如后汉章帝长水校尉樊鯈奏言引“博学而笃志”三句(19.6),也以子夏之言为孔子之言,《史记·田叔传》赞曰“孔子称居是国必闻其政”,又以子禽之问(1.10)为孔子之言;刘向《说苑》引“孔子曰,君子务本”,又引“孔子曰,恭近于礼”,则以有子之言为孔子之言。甚至郑玄注《曲礼》、《玉藻》,以及王充着《论衡》,引乡党篇之文,都冠以“孔子曰”。则可见《论语》之书当时似别称“孔子”,如“孟子书”之称孟子者然。翟灏《四书考异》据《尸子·广泽篇》、“墨子贵兼,孔子贵公,皇子贵衷”云云,以为先儒以孔子杂诸子中;又据《论衡·率性篇》云“孔子道德之祖,诸子中最卓者也”谓当时等孔子于诸子,其言不为无据(说本《诂经精舍三集》吴承志〈汉人引孔门诸子言皆称孔子说〉)。若此,则刘氏所举不足为证矣。\n所重:民、食、丧、祭。\n【译文】所重视的:人民、粮食、丧礼、祭祀。\n宽则得众,信则民任焉此五字衍文⑴,敏则有功,公则说。\n【译文】宽厚就会得到羣众的拥护,勤敏就会有功绩,公平就会使百姓高兴。\n【注释】⑴信则民任焉——《汉石经》无此五字,《天文本校勘记》云:“皇本、唐本、津藩本、正平本均无此句。”足见这一句是因阳货篇“信则人任焉”而误增的。阳货篇作“人”,“人”是领导。此处误作“民”。“民”指百姓。有信实,就会被百姓任命,这种思想绝非孔子所能有,尤其可见此句不是原文。\n20.2子张问于孔子曰:“何如斯可以从政矣?”\n子曰:“尊五美,屏⑴四恶,斯可以从政矣。”\n子张曰:“何谓五美?”\n子曰:“君子惠而不费,劳而不怨,欲而不贪⑵,泰而不骄,威而不猛。”\n子张曰:“何谓惠而不费?”\n子曰:“因民之所利而利之,斯不亦惠而不费乎?择可劳而劳之,又谁怨?欲仁而得仁,又焉贪?君子无众寡,无小大,无敢慢,斯不亦泰而不骄乎?君子正其衣冠,尊其瞻视,俨然人望而畏之,斯不亦威而不猛乎?”\n子张曰:“何谓四恶?”\n子曰:“不教而杀谓之虐;不戒视成谓之暴;慢令致期谓之贼;犹之⑶与人也,出纳⑷之吝谓之有司⑸。”\n【译文】子张向孔子问道:“怎样就可以治理政事呢?”\n孔子道:“尊贵五种美德,排除四种恶政,这就可以治理政事了。”\n子张道:“五种美德是些什么?”\n孔子道:“君子给人民以好处,而自己却无所耗费;劳动百姓,百姓却不怨恨;自己欲仁欲义,却不能叫做贪;安泰矜持却不骄傲;威严却不凶猛。”\n子张道:“给人民以好处,自己却无所耗费,这应该怎么办呢?”\n孔子道:“就着人民能得利益之处因而使他们有利,这也不是给人民以好处而自己却无所耗费吗?选择可以劳动的[时间、情况和人民]再去劳动他们,又有谁来怨恨呢?自己需要仁德便得到了仁德,又贪求什么呢?无论人多人少,无论势力大小,君子都不敢怠慢他们,这不也是安泰矜持却不骄傲吗?君子衣冠整齐,目不邪视,庄严地使人望而有所畏惧,这也不是威严却不凶猛吗?”\n子张道:“四种恶政又是些什么呢?”\n孔子道:“不加教育便加杀戮叫做虐;不加申诫便要成绩叫做暴;起先懈怠,突然限期叫做贼;同是给人以财物,出手悭吝,叫做小家子气。”\n【注释】⑴屏——音丙,又去声音并,bíng,屏除。⑵欲而不贪——下文云:“欲仁而得仁,又焉贪?”可见此“欲”字是指欲仁欲义而言,因之皇侃《义疏》云:“欲仁义者为廉,欲财色者为贪。”译文本此。⑶犹之——王引之《释词》云:“犹之与人,均之与人也。”⑷出纳——出和纳(入)是两个意义相反的词,这里虽然在一起连用,却只有“出”的意义,没有“纳”的意义。说本俞樾《羣经平议》。⑸有司——古代管事者之称,职务卑微,这里意译为“小家子气”。\n20.3孔子曰:“不知命,无以为君子也;不知礼,无以立也;不知言⑴,无以知人也。”\n【译文】孔子说:“不懂得命运,没有可能作为君子;不懂得礼,没有可能立足于社会;不懂得分辨人家的言语,没有可能认识人。”\n【注释】⑴知言——这里“知言”的意义和《孟子·公孙丑上》的“我知言”的“知言”相同,善于分析别人的言语,辨其是非善恶的意思。\n"},{"id":105,"href":"/zh/docs/culture/%E8%AE%BA%E8%AF%AD/%E8%AE%BA%E8%AF%AD%E8%AF%91%E6%B3%A8_%E6%9D%A8%E4%BC%AF%E5%B3%BB/17%E9%98%B3%E8%B4%A7%E7%AF%87%E7%AC%AC%E5%8D%81%E4%B8%83/","title":"17阳货篇第十七","section":"论语译注 杨伯峻","content":" 阳货篇第十七 # (共二十六章(《汉石经》同。何晏《集解》把第二、第三两章以及第九、第十两章各并为一章,所以只二十四章。))\n17.1阳货⑴欲见孔子,孔子不见,归孔子豚⑵。\n孔子时其亡也,而往拜之。\n遇诸涂。\n谓孔子曰:“来!予与尔言。”曰⑶:“怀其宝而迷其邦,可谓仁乎?”曰:“不可。——好从事而亟⑷失时,可谓知乎?”曰:“不可。——日月逝矣,岁不我与。”\n孔子曰:“诺;吾将仕矣⑸。”\n【译文】阳货想要孔子来拜会他,孔子不去,他便送孔子一个[蒸熟了的]小猪,[使孔子到他家来道谢。]\n孔子探听他不在家的时候,去拜谢。\n两人在路上碰着了。\n他叫着孔子道:“来,我同你说话。”[孔子走了过去。]他又道:“自己有一身的本领,却听任着国家的事情糊里胡涂,可以叫做仁爱吗?”[孔子没吭声。]他便自己接口道:“不可以;——一个人喜欢做官,却屡屡错过机会,可以叫做聪明吗?”[孔子仍然没吭声。]他又自己接口道:“不可以;——时光一去,就不再回来了呀。”\n孔子这才说道:“好吧;我打算做官了。”\n【注释】⑴阳贷——又叫阳虎,季氏的家臣。季氏几代以来把持鲁国的政治,阳货这时正又把持季氏的权柄。最后因企图削除三桓而未成,逃往晋国。⑵归孔子豚——“归”同“馈”,赠送也。《孟子·滕文公下》对这事有一段说明,他说,当时,“大夫有赐于士,不得受于其家,则往拜其门。”阳货便利用这一礼俗,趁孔子不在家,送一个蒸熟了的小猪去。孔子也就趁阳货不在家才去登门拜谢。⑵曰——自此以下的几个“曰”字,都是阳贷的自为问答。说本毛奇龄《论语稽求篇》引明人郝敬之说。俞樾《古书疑义举例》卷二有“一人之辞而加曰字例”,对这种修辞方式更有详细引证。⑷亟——去声,音气,qì,屡也。⑸吾将仕矣——孔子于阳虎当权之时,并未仕于阳虎。可参《左传》定公八、九年传。\n17.2子曰:“性相近也,习相远也。”\n【译文】孔子说:“人性情本相近,因为习染不同,便相距悬远。”\n17.3子曰:“唯上知与下愚⑴不移。”\n【译文】孔子说:“只有上等的智者和下等的愚人是改变不了的。”\n【注释】⑴上知下愚——关于“上知”下愚”的解释,古今颇有异说。《汉书·古今人表》说:“可与为善,不可与为恶,是谓上智。可与为恶,不可与为善,是谓下愚。”则是以其品质言。孙星衍《问字堂集》说:“上知谓生而知之,下愚谓困而不学。”则是兼以其知识与质量而言。译文仅就字面译出。但孔子说过“生而知之者上也”(16.9),这里的“上知”可能就是“生而知之”的人。当然这种人是不会有的。可是当时的人却以为一定有,甚至孔子都曾否认地说过“我非生而知之者”(7.20)。\n17.4子之武城,闻弦歌之声。夫子莞尔而笑,曰:“割鸡焉用牛刀?”\n子游对曰:“昔者偃也闻诸夫子曰:‘君子学道则爱人,小人学道则易使也。’”\n子曰:“二三子!偃之言是也。前言戏之耳。”\n【译文】孔子到了[子游作县长]的武城,听到了弹琴瑟唱诗歌的声音。孔子微微笑着,说道:“宰鸡,何必用宰牛的刀?[治理这个小地方,用得着教育吗?]”\n子游答道:“以前我听老师说过,做官的学习了,就会有仁爱之心;老百姓学习了,就容易听指挥,听使唤。[教育总是有用的。]”\n孔子便向学生们道:“二三子!言偃的这话是正确的。我刚才那句话不过同他开顽笑吧了。”\n17.5公山弗扰⑴以费畔⑵,召,子欲往。\n子路不说,曰:“末之也,已⑶,何必公山氏之之⑷也?”\n子曰:“夫召我者,而岂徒哉⑸?如有用我者,吾其为东周乎?”\n【译文】公山弗扰盘踞在费邑图谋造反,叫孔子去,孔子准备去。子路很不高兴,说道:“没有地方去便算了,为什么一定要去公山氏那里呢?”\n孔子道:“那个叫我去的人,难道是白白召我吗?假若有人用我,我将使周文王武王之道在东方复兴。”\n【注释】⑴公山弗扰——疑卽《左传》定公五年、八年、十二年及哀公八年之公山不狃(唯陈天祥的《四书辨疑》认为是两人)。不过《论语》所叙之事不见于《左传》,而《左传》定公十二年所叙的公山不狃反叛鲁国的事,不但没有叫孔子去,而且孔子当时正为司寇,命人打败了他。因此赵翼的《陔余丛考》、崔述的《洙泗考信録》都疑心这段文字不可信。但是其后又有一些人,如刘宝楠《论语正义》,则说赵、崔不该信《左传》而疑《论语》。我们于此等处只能存疑。⑵畔——毛奇龄说,“畔是谋逆”,译文取这一义。⑶末之也已——旧作一句读,此依武亿《经读考异》作两句读。“末”,没有地方的意思;“之”,动词,往也;“已”,止也。⑷何必公山氏之之也——“何必之公山氏也”的倒装。“之之”的第一个“之”字只是帮助倒装用的结构助词,第二个“之”字是动词。⑸而岂徒哉——“徒”下省略动宾结构,说完全是“而岂徒召我哉”。\n17.6子张问仁于孔子。孔子曰:“能行五者于天下为仁矣。”\n“请问之。”曰:“恭,宽,信,敏,惠。恭则不侮,宽则得众,信则人任焉,敏则有功,惠则足以使人。”\n【译文】子张向孔子问仁。孔子道:“能够处处实行五种品德,便是仁人了。”\n子张道:“请问哪五种。”孔子道:“庄重,宽厚,诚实,勤敏,慈惠。庄重就不致遭受侮辱,宽厚就会得到大众的拥护,诚实就会得到别人的任用,勤敏就会工作效率高、贡献大,慈惠就能够使唤人。”\n17.7佛肸⑴召,子欲往。\n子路曰:“昔者由也闻诸夫子曰:‘亲于其身为不善者,君子不入也。’佛肸以中牟⑵畔,子之往也,如之何?”\n子曰:“然,有是言也。不曰坚乎,磨而不磷⑶;不曰白乎,湼⑷而不缁。吾岂匏瓜⑸也哉?焉能系而不食?”\n【译文】佛肸叫孔子,孔子打算去。\n子路道:“从前我听老师说过,‘亲自做坏事的人那里,君子不去的。’如今佛肸盘踞中牟谋反,您却要去,怎么说得过去呢?”\n孔子道:“对,我有过这话。但是,你不知道吗?最坚固的东西,磨也磨不薄;最白的东西,染也染不黑。我难道是匏瓜吗?哪里能够只是被悬挂着而不给人吃食呢?”\n【注释】⑴佛肸——晋国赵简子攻打范中行,佛肸是范中行的家臣,为中牟的县长,因此依据中牟来抗拒赵简子。⑵中牟——春秋时晋邑,故址当在今日河北省邢台和邯郸之间,跟河南的中牟了不相涉。⑶磷——音吝,lìn,薄也。⑷湼——niè,本是一种矿物,古人用作黑色染料,这里作动词,染黑之意。⑹匏瓜——卽匏子,古有甘、苦两种,苦的不能吃,但因它比水轻,可以系于腰,用以泅渡。《国语·鲁语》“苦瓠不材,于人共济而已。”《庄子·逍遥游》:“今子有五石之匏,何不虑以为大樽,而浮乎江湖。”皆可以为证。\n17.8子曰:“由也!女闻六言⑴六蔽矣乎?”对曰:“未也。”\n“居!吾语女。好仁不好学⑵,其蔽也愚⑶;好知不好学,其蔽也荡⑷;好信不好学,其蔽也贼⑸;好直不好学,共蔽也绞;好勇不好学,其蔽也乱;好刚不好学,其蔽也狂。”\n【译文】孔子说:“仲由,你听过有六种品德便会有六种弊病吗?”子路答道:“没有。”\n孔子道:“坐下!我告诉你。爱仁德,却不爱学问,那种弊病就是容易被人愚弄;爱耍聪明,却不爱学问,那种弊病就是放荡而无基础;爱诚实,却不爱学问,那种弊病就是[容易被人利用,反而]害了自己;爱直率,却不爱学问,那种弊病就是说话尖刻,刺痛人心;爱勇敢,却不爱学问,那种弊病就是捣乱闯祸;爱刚强,却不爱学问,那种弊病就是胆大妄为。”\n【注释】⑴言——这个“言”字和“有一言而可以终身行之”(15.24)的“言”相同,名曰“言”,实是指“德”。“一言”,孔子拈出“恕”字;“六言”,孔子拈出“仁”、“知”、“信”、“直”、“勇”、“刚”六宇。后代“五言诗”、“七言诗”以一字为“言”之义盖本于此。⑵不好学——不学则不能明其理。⑶愚——朱熹《集注》云:“愚若可陷可罔之类。”译文取之。⑷荡——孔安国云:“荡,无所适守也。”译文取之。⑹贼——管同《四书纪闻》云:“大人之所以不必信者,惟其为学而知义之所在也。苟好信不好学,则惟知重然诺而不明事理之是非,谨厚者则硁硁为小人;苟又挟以刚勇之气,必如周汉刺客游侠,轻身殉人,扞文网而犯公义,自圣贤观之,非贼而何?”这是根据春秋侠勇之士的事实,又根据儒家明哲保身的理论所发的议论,似乎近于孔子本意。\n17.9子曰:“小子何莫学夫诗?诗,可以兴,可以观,可以羣,可以怨。迩之事父,远之事君;多识于鸟兽草木之名。”\n【译文】孔子说:“学生们为什么没有人研究诗?读诗,可以培养联想力,可以提高观察力,可以锻炼合羣性,可以学得讽刺方法。近呢,可以运用其中道理来事奉父母;远呢,可以用来服事君上;而且多多认识鸟兽草木的名称。”\n17.10子谓伯鱼曰:“女为《周南》、《召南》⑴矣乎?人而不为《周南》、《召南》,其犹正墙面而立⑵也与?”\n【译文】孔子对伯鱼说道:“你研究过《周南》和《召南》了吗?人假若不研究《周南》和《召南》,那会像面正对着墙壁而站着罢!”\n【注释】⑴《周南》、《召南》——现存《诗经·国风》中。但沈括《梦溪笔谈》卷三说:“《周南》、《召南》,乐名也。……有乐有舞焉,学者之事。……所谓为《周南》、《召南》者,不独诵其诗而已。”⑵正墙面而立——朱熹云:“言卽其至近之地,而一物无所见,一步不可行。”\n17.11子曰:“礼云礼云,玉帛云乎哉?乐云乐云,钟鼓云乎哉?”\n【译文】孔子说:“礼呀礼呀,仅是指玉帛等等礼物而说的吗?乐呀乐呀,仅是指钟鼓等等乐器而说的吗?”\n17.12子曰:“色厉而内荏,譬诸小人,其犹穿窬之盗也与?”\n【译文】孔子说:“颜色严厉,内心怯弱,若用坏人作比喻,怕像个挖洞跳墙的小偷罢!”\n17.13子曰:“乡愿⑴,德之贼也。”\n【译文】孔子说:“没有真是非的好好先生是足以败坏道德的小人。”\n【注释】⑴乡愿——愿音愿,yuàn,孟子作“原”。《孟子·尽心下》对“乡愿”有一段最具体的解释:“何以是嘐嘐也?言不顾行,行不顾言,则曰:‘古之人,古之人,行何为踽踽凉凉?生斯世也,为斯世也,善斯可矣。’阉然媚于世也者,是乡原也。”又说;“非之无举也,刺之无刺也。同乎流俗,合乎污世。居之似忠信,行之似廉洁。众皆悦之,自以为是,而不可与入尧舜之道。故曰‘德之贼’也。”\n17.14子曰:“道听而涂说,德之弃也。”\n【译文】孔子说:“听到道路传言就四处传播,这是应该革除的作风。”\n17.15子曰:“鄙夫可与⑴事君也与哉?其未得之也,患得之当作患不得之⑵。既得之,患失之。苟患失之,无所不至矣。”\n【译文】孔子说:“鄙夫,难道能同他共事吗?当他没有得到职位的时候,生怕得不着;已经得着了,又怕失去。假若生怕失去,会无所不用其极了。”\n【注释】⑴可与——王引之《释词》谓卽“可以”,今不取。⑵患得之——王符《潜夫论·爱日篇》云:“孔子疾夫未之得也,患不得之,既得之,患失之者。”可见东汉人所据的本子有“不”字。《荀子·子道篇》说:“孔子曰,……小人者,其未得也,则忧不得;既已得之,又恐失之。”(《说苑·杂言篇》同)此虽是述意,“得”上也有“不”字。宋人沈作喆寓简云:“东坡解云,‘患得之’当作‘患不得之’”,可见宋人所见的本子已脱此“不”字。\n17.16子曰:“古者民有三疾,今也或是之亡也。古之狂也肆,今之狂也荡;古之矜也廉⑴,今之矜也忿戾;古之愚也直,今之愚也诈而已矣。”\n【译文】孔子说:“古代的人民还有三种[可贵的]毛病,现在呢,或许都没有了。古代的狂人肆意直言,现在的狂人便放荡无羁了;古代自己矜持的人还有些不能触犯的地方,现在自己矜持的人却只是一味老羞成怒,无理取闹罢了;古代的愚人还直率,现在的愚人却只是欺诈耍手段罢了。”\n【注释】⑴廉——“廉隅”的“廉”,本义是器物的棱角,人的行为方正有威也叫“廉”。\n17.17子曰:“巧言令色,鲜矣仁⑴。”\n【注释】⑴见学而篇(1.3)。\n17.18子曰:“恶紫之夺朱⑴也,恶郑声之乱雅乐也,恶利口之覆邦家者。”\n【译文】孔子说:“紫色夺去了大红色的光彩和地位,可憎恶;郑国的乐曲破坏了典雅的乐曲,可憎恶;强嘴利舌颠覆国家,可憎恶。”\n【注释】⑴紫之夺朱——春秋时候,鲁桓公和齐桓公都喜欢穿紫色衣服。从《左传》哀公十七年卫浑良夫“紫衣狐裘”而被罪的事情看来,那时的紫色可能已代替了朱色而变为诸侯衣服的正色了。\n17.19子曰:“予欲无言。”子贡曰:“子如不言,则小子何述焉?”子曰:“天何言哉?四时行焉,百物生焉,天何言哉?”\n【译文】孔子说:“我想不说话了。”子贡道:“您假若不说话,那我们传述什么呢?”孔子道:“天说了什么呢?四季照样运行,百物照样生长,天说了什么呢?”\n17.20孺悲⑴欲见孔子,孔子辞以疾⑵。将命者出户,取瑟而歌,使之闻之。\n【译文】孺悲来,要会晤孔子,孔子托言有病,拒绝接待。传命的人刚出房门,孔子便把瑟拿下来弹,并且唱着歌,故意使孺悲听到。\n【注释】⑴孺悲——鲁国人。《礼记·杂记》云:“恤由之丧,哀公使孺悲之孔子学士丧礼,《士丧礼》于是乎书。”⑵辞以疾——《孟子·告子下》说:“教亦多术矣。予不屑之教诲也者,是亦教诲之而已矣。”孔子故意不接见孺悲,并且使他知道,是不是也是如此的呢?\n17.21宰我问:“三年之丧,期已久矣。君子三年不为礼,礼必坏;三年不为乐,乐必崩。旧谷既没,新谷既升,钻燧改火⑴,期⑵可已矣。”\n子曰:“食夫稻⑶,衣夫锦,于女安乎?”\n曰:“安。”\n“女安,则为之!。夫君子之居丧,食旨不甘,闻乐不乐,居处不安⑷,故不为也。今女安,则为之!”\n宰我出,子曰:“予之不仁也!子生三年,然后免于父母之怀。夫三年之丧,天下之通丧也,予也有三年之爱于其父母乎!”\n【译文】宰我间道:“父母死了,守孝三年,为期也太久了。君子有三年不去习礼仪,礼仪一定会废弃掉;三年不去奏音乐,音乐一定会失传。陈谷既已吃完了,新谷又已登场;打火用的燧木又经过了一个轮回,一年也就可以了。”\n孔子道:“[父母死了,不到三年,]你便吃那个白米饭,穿那个花缎衣,你心里安不安呢?”\n宰我道:“安。”\n孔子便抢着道:“你安,你就去干吧,君子的守孝,吃美味不晓得甜,听音乐不觉得快乐,住在家里不以为舒适,才不这样干。如今你既然觉得心安,便去干好了。”\n宰我退了出来。孔子道:“宰予真不仁呀,儿女生下地来,三年以后才能完全脱离父母的怀抱。替父母守孝三年,天下都是如此的。宰予难道就没有从他父母那里得着三年怀抱的爱护吗?”\n【注释】钻燧改火——古代用的是钻木取火的方法,被钻的木,四季不同,所谓“春取榆柳之火,夏取枣杏之火,季夏取桑柘之火,秋取柞楢之火,冬取槐檀之火”(马融引《周书·月令篇》文),一年一轮回。⑵期——同朞,音基。jī,一年。⑶稻——古代北方以稷(小米)为主要粮食,水稻和粱(精细的小米)是珍品,而稻的耕种面积更小,所以这里特别提出它来和“锦”为对文。⑷居处不安——古代孝子要“居倚庐,寝苫枕块”,就是住临时用草料木料搭成的凶庐,睡在用草编成的藁垫上,用土块做枕头。这里的“居处”是指平日的居住生活而言。\n17.22子曰:“饱食终日,无所用心,难矣哉!不有博⑴弈者乎?为之,犹贤乎已⑵。”\n【译文】孔子说:“整天吃饱了饭,什么事也不做,不行的呀!不是有掷采下弈的游戏吗?干干也比闲着好。”\n【注释】⑴博——古代的一种棊局。焦循的《孟子正义》说:“盖弈但行棊,博以掷采(骰子)而后行棊。”又说:“后人不行棊而专掷采,遂称掷采为博(赌博),博与弈益远矣。”⑵犹贤乎已——句法与意义和《墨子·法仪篇》的“犹逾(同愈)已”,《孟子·尽心上》的“犹愈于已”全同。“已”是不动作的意思。\n17.23子路曰:“君子尚⑴勇乎?”子曰:“君子义以为上⑴,君子有勇而无义为乱,小人有勇而无义为盗。”\n【译文】子路问道:“君子尊贵勇敢不?”孔子道:“君子认为义是最可尊贵的,君子只有勇,没有义,就会捣乱造反;小人只有勇,没有义,就会做土匪强盗。”\n【注释】⑴尚,上——“尚勇”的“尚”和“上”相同。不过用作动词。\n17.24子贡曰:“君子亦有恶乎?”子曰:“有恶:恶称人之恶者,恶居下流流字衍文⑴而讪上者,恶勇而无礼者,恶果敢而窒者。”\n曰:“赐也亦有恶乎?”“恶徼以为知者,恶不孙以为勇者,恶讦以为直者。”\n【译文】子贡道:“君子也有憎恨的事吗?”孔子道:“有憎恨的事:憎恨一味传播别人的坏处的人,憎恨在下位而毁谤上级的人,憎恨勇敢却不懂礼节的人,憎恨勇于贯彻自己的主张,却顽固不通、执抝到底的人。”\n孔子又道:“赐,你也有憎恶的事吗?”子贡随卽答道:“我憎恨偷袭别人的成绩却作为自己的聪明的人,憎恨毫不谦虚却自以为勇敢的人,憎恨揭发别人阴私却自以为直率的人。”\n【注释】⑴下流——根据惠栋的《九经古义》和冯登府的《论语异文考证》,证明了晚唐以前的本子没有这个“流”字。案文义,这个“流”字也是不应该有的。但苏轼〈上韩太尉书〉引此文时已有“流”字,可见北宋时已经误衍。\n17.25子曰:“唯女子与小人为难养也,近之则不孙,远之则怨。”\n【译文】孔子道:“只有女子和小人是难得同他们共处的,亲近了,他会无礼;疏远了,他会怨恨。”\n17.26子曰:“年四十而见恶焉,其终也已⑴。”\n【译文】孔子说:“到了四十岁还被厌恶,他这一生也就完了。”\n【注释】⑴其终也已——“已”是动词,和“末之也已”(17.4)“斯害也已”(2.16)的“已”字相同,句法更和“斯害也已”一致。“其终也”“斯害也”为主语;“已”为动词,谓语。如在“其终也”下作一停顿,文意便显豁了。\n"},{"id":106,"href":"/zh/docs/culture/%E8%AE%BA%E8%AF%AD/%E8%AE%BA%E8%AF%AD%E8%AF%91%E6%B3%A8_%E6%9D%A8%E4%BC%AF%E5%B3%BB/12%E9%A2%9C%E6%B8%8A%E7%AF%87%E7%AC%AC%E5%8D%81%E4%BA%8C/","title":"12颜渊篇第十二","section":"论语译注 杨伯峻","content":" 颜渊篇第十二 # (共二十四章)\n12.1颜渊问仁。子曰:“克己复礼为仁⑴。一日克己复礼,天下归仁⑵焉。为仁由己,而由人乎哉?”\n颜渊曰:“请问其目。”子曰:“非礼勿视,非礼勿听,非礼勿言,非礼勿动。”\n颜渊曰:“回虽不敏,请事斯语矣。”\n【译文】颜渊问仁德。孔子道:“抑制自己,使言语行动都合于礼,就是仁。一旦这样做到了,天下的人都会称许你是仁人。实践仁德,全凭自己,还凭别人吗?”\n颜渊道:“请问行动的纲领。”孔子道:“不合礼的事不看,不合礼的话不听,不合礼的话不说,不合礼的事不做。”\n颜渊道:“我虽然迟钝,也要实行您这话。”\n【注释】⑴克己复礼——《左传》昭公十二年说:“仲尼曰:‘古也有志:克己复礼,仁也。’”那么,“克己复礼为仁”是孔子用前人的话赋予新的含义。⑵归仁——“称仁”的意思,说见毛奇龄《论语稽求篇》。朱熹《集注》谓“归,犹与也”,也是此意。\n12.2仲弓问仁。子曰:“出门如见大宾,使民如承大祭。己所不欲,勿施于人。在邦无怨,在家⑴无怨。”\n仲弓曰:“雍虽不敏,请事斯语矣。”\n【译文】仲弓问仁德。孔子道:“出门[工作]好像去接待贵宾,役使百姓好像去承当大祀典,[都得严肃认真,小心谨慎。]自己所不喜欢的事物,就不强加于别人。在工作岗位上不对工作有怨恨,就是不在工作岗位上也没有怨恨。”\n仲弓道:“我虽然迟钝,也要实行您这话。”\n【注释】⑴在家——刘宝楠《论语正义》说:“在邦谓仕于诸侯之邦,在家谓仕于卿大夫之家也。”把“家”字拘泥于“大夫曰家”的一个意义,不妥当。\n12.3司马牛⑴问仁。子曰:“仁者,其言也讱。”\n曰:“其言也讱,斯谓之仁已乎?”子曰:“为之难,言之得无讱乎?”\n【译文】司马牛问仁德。孔子道:“仁人,他的言语迟钝。”\n司马牛道:“言语迟钝,造就叫做仁了吗?”孔子道:“做起来不容易,说话能够不迟钝吗?”\n【注释】⑴司马牛——《史记·仲尼弟子列传》云:“司马耕,字子牛。牛多言而躁,问仁于孔子。孔子曰:‘仁者其言也讱。’”根据司马迁的这一说法,孔子的答语是针对问者“多言而躁”的缺点而说的。\n12.4司马牛问君子。子曰:“君子不忧不惧。”\n曰:“不忧不惧,斯谓之君子已乎?”子曰:“内省不疚,夫何忧何惧?”\n【译文】司马牛问怎样去做一个君子。孔子道:“君子不忧愁,不恐惧。”司马牛道:“不忧愁,不恐惧,这样就可以叫做君子了吗?”孔子道:“自己问心无愧,那有什么可以忧愁和恐惧的呢?”\n12.5司马牛忧曰:“人皆有兄弟,我独亡⑴。”子夏曰:“商闻之矣:死生有命,富贵在天。君子敬而无失,与人恭而有礼。四海之内,皆兄弟也——君子何患乎无兄弟也?”\n【译文】司马牛忧愁地说道:“别人都有好兄弟,单单我没有。”子夏道:“我听说过:死生听之命运,富贵由天安排。君子只是对待工作严肃认真,不出差错,对待别人词色恭谨,合乎礼节,天下之大,到处都是好兄弟——君子又何必着急没有好兄弟呢?”\n【注释】⑴人皆有兄弟,我独亡——自来的注释家都说这个司马牛就是宋国桓魋的兄弟。桓魋为人很坏,结果是谋反失败,他的几个兄弟也都跟着失败了。其中只有司马牛不赞同他这些兄弟的行为。但结果也是逃亡在外,死于道路(事见《左传》哀公十四年)。译文姑且根据这种说法。但我却认为,孔子的学生司马牛和宋国桓魋的弟弟司马牛可能是两个不同的人,难于混为一谈。第一,《史记·仲尼弟子列传》既不说这一个司马牛是宋人,更没有把《左传》上司马牛的事情记载上去,太史公如果看到了这类史料而不采取,可见他是把两个司马牛作不同的人看待的。第二,说《论语》的司马牛就是《左传》的司马牛者始于孔安国。孔安国又说司马牛名犂,又和《史记·仲尼弟子列传》说司马牛名耕的不同。如果孔安国之言有所本,那么,原本就有两个司马牛,一个名耕,孔子弟子;一个名犂,桓魋之弟。但自孔安国以后的若干人却误把名犂的也当作孔子学生了。姑识于此,以供参考。\n12.6子张问明。子曰:“浸润之谮,肤受之愬,不行焉,可谓明也已矣。浸润之谮,肤受之愬,不行焉,可谓远也已矣。”\n【译文】子张问怎样才叫做见事明白。孔子道:“点滴而来,日积月累的谗言和肌肤所受、急迫切身的诬告都在你这里行不通,那你可以说是看得明白的了。点滴而来,日积月累的谗言和肌肤所受、急迫切身的诬告也都在你这里行不通,那你可以说是看得远的了。”\n12.7子贡问政。子曰:“足食,足兵⑴,民信之矣。”\n子贡曰:“必不得已而去,于斯三者何先?”曰:“去兵。”\n子贡曰:“必不得已而去,于斯二者何先?”曰:“去食。自古皆有死,民无信不立。”\n【译文】子贡问怎样去治理政事。孔子道:“充足粮食,充足军备,百姓对政府就有信心了。”\n子贡道:“如果迫于不得已,在粮食、军备和人民的信心三者之中一定要去掉一项,先去掉哪一项?”孔子道:“去掉军备。”\n子贡道:“如果迫于不得已,在粮食和人民的信心两者之中一定要去掉一项,先去掉哪一项?”孔子道:“去掉粮食。[没有粮食,不过死亡,但]自古以来谁都免不了死亡。如果人民对政府缺乏信心,国家是站不起来的。”\n【注释】⑴兵——在五经和《论语》、《孟子》中,“兵”字多指兵器而言,但也偶有解作兵士的。如《左传》隐公四年“诸侯之师败郑徒兵”,襄公元年“败其徒兵于洧上”。顾炎武、阎若璩都以为五经中的“兵”字无作士兵解者,恐未谛(刘宝楠说)。但此“兵”字仍以解为军器为宜,故以军备译之。\n12.8棘子成⑴曰:“君子质而已矣,何以文为?”子贡曰:“惜乎,夫子之说君子也⑵!驷不及舌。文犹质也,质犹文也。虎豹之鞟犹犬羊之鞟。”\n【译文】棘子成道:“君子只要有好的本质便够了,要那些文彩[那些仪节、那些形式]干什么?”子贡道:“先生这样地谈论君子,可惜说错了。一言既出,驷马难追。本质和文彩,是同等重要的。假若把虎豹和犬羊两类兽皮拔去有文彩的毛,那这两类皮革就很少区别了。”\n【注释】⑴棘子成——卫国大夫。古代大夫都可以被尊称为“夫子”,所以子贡这样称呼他。⑵惜乎夫子之说君子也——朱熹《集注》把它作两句读:“惜乎!夫子之说,君子也。”便应该这样翻译:“先生的话,是出自君子之口,可惜说错了。”我则以为“夫子之说君子也”为主语,“惜乎”为谓语,此为倒装句。\n12.9哀公问于有若曰:“年饥,用不足,如之何?”\n有若对曰:“盍彻乎?”\n曰:“二,吾犹不足,如之何其彻也?”\n对曰:“百姓足,君孰与不足?百姓不足,君孰与足?”\n【译文】鲁哀公向有若问道:“年成不好,国家用度不够,应该怎么办?”\n有若答道:“为什么不实行十分抽一的税率呢?”\n哀公道:“十分抽二,我还不够,怎么能十分抽一呢?”\n答道:“如果百姓的用度够,您怎么会不够?如果百姓的用度不够,您又怎么会够?”\n12.10子张问崇德辨惑。子曰:“主忠信,徙义,崇德也。爱之欲其生,恶之欲其死。既欲其生,又欲其死,是惑也。‘诚不以富,亦祗以异⑴。’”\n【译文】子张问如何去提高品德,辨别迷惑。孔子道:“以忠诚信实为主,唯义是从,这就可以提高品德。爱一个人,希望他长寿;厌恶起来,恨不得他马上死去。既要他长寿,又要他短命,这便是迷惑。这样,的确对自己毫无好处,只是使人奇怪罢了。”\n【注释】⑴诚不以富,亦祗以异——《诗经·小雅·我行其野篇》诗句,引在这里,很难解释。程颐说是“错简”(别章的文句,因为书页次序错了,误在此处),但无证据。我这里姑且依朱熹《集注》的解释而意译之。\n12.11齐景公问政于孔子。孔子对曰:“君君,臣臣,父父,子子。”公曰:“善哉!信如君不君,臣不臣,父不父,子不子,虽有粟,吾得而食诸?”\n【译文】齐景公向孔子问政治。孔子答道:“君要像个君,臣要像个臣,父亲要像父亲,儿子要像儿子。”景公道:“对呀!若是君不像君,臣不像臣,父不像父,子不像子,卽使粮食很多,我能吃得着吗?”\n12.12子曰:“片言可以折狱⑴者,其由也与?”\n子路无宿诺⑵。\n【译文】孔子说:“根据一方面的语言就可以判决案件的,大概只有仲由吧!”\n子路从不拖延诺言。\n【注释】⑴片言折狱——“片言”古人也叫做“单辞”。打官司一定有原告和被告两方面的人,叫做两造。自古迄今从没有只根据一造的言辞来判决案件的(除掉被告缺席裁判)。孔子说子路“片言可以折狱”,不过表示他的为人诚实直率,别人不愿欺他罢了。⑵子路无宿诺——这句话与上文有什么逻辑关系,从来没有人说得明白(焦循《论语补疏》的解释也不可信)。唐陆德明《经典释文》云:“或分此为别章。”\n12.13子曰:“听讼⑴,吾犹人也。必也使无讼乎!”\n【译文】孔子说:“审理诉讼,我同别人差不多。一定要使诉讼的事件完全消灭才好。”\n【注释】⑴听讼——据《史记·孔子世家》,孔子在鲁定公时,曾为大司寇,司寇为治理刑事的官,孔子这话或许是刚作司寇时所说。\n12.14子张问政。子曰:“居之无倦,行之以忠。”\n【译文】子张问政治。孔子道:“在位不要疲倦懈怠,执行政令要忠心。”\n12.15子曰:“博学于文,约之以礼,亦可以弗畔矣夫!”\n【注释】⑴见雍也篇第六。\n12.16子曰:“君子成人之美,不成人之恶。小人反是。”\n【译文】孔子说:“君子成全别人的好事,不促成别人的坏事。小人却和这相反。”\n12.17季康子问政于孔子。孔子对曰:”政者,正也。子帅以正,孰敢不正?”\n【译文】季康子向孔子问政治。孔子答道:“政字的意思就是端正。您自己带头端正,谁敢不端正呢?”\n12.18季康子患盗,问于孔子。孔子对曰:“苟子之不欲,虽赏之不窃。”\n【译文】季康子苦于盗贼太多,向孔子求教。孔子答道:“假若您不贪求太多的财货,就是奖励偷抢,他们也不会干。”\n12.19季康子⑴问政于孔子曰:“如杀无道,以就有道何如?”孔子对曰:“子为政,焉用杀?子欲善而民善矣。君子之德风,小人之德草。草上之风,必偃。”\n【译文】季康子向孔子请教政治,说道:“假若杀掉坏人来亲近好人,怎么样?”孔子答道:“您治理政治,为什么要杀戮?您想把国家搞好,百姓就会好起来。领导人的作风好比风,老百姓的作风好比草,风向哪边吹,草向哪边倒。”\n【注释】⑴季康子——根据《春秋》以及《左传》,季孙斯(桓子)死于哀公三年秋七月,季孙肥(康子)随卽袭位。则以上三章季康子之问,当在鲁哀公三年七月以后。\n12.20子张问:“士何如斯可谓之达矣?”子曰:“何哉,尔所谓达者?”子张对曰:“在邦必闻,在家必闻。”子曰:“是闻也,非达也。夫达也者,质直而好义,察言而观色,虑以下人。在邦必达,在家必达。夫闻也者,色取仁而行违,居之不疑。在邦必闻,在家必闻。”\n【译文】子张问:“读书人要怎样做才可以叫达?”孔子道:“你所说的达是什么意思?”子张答道:“做国家的官时一定有名望,在大夫家工作时一定有名望。”孔子道:“这个叫闻,不叫达。怎样才是达呢?质量正直,遇事讲理,善于分析别人的言语,观察别人的颜色,从思想上愿意对别人退让。这种人,做国家的官时固然事事行得通,在大夫家一定事事行得通。至于闻,表面上似乎爱好仁德,实际行为却不如此,可是自己竟以仁人自居而不加疑惑。这种人,做官的时候一定会骗取名望,居家的时候也一定会骗取名望。”\n12.21樊迟从游于舞雩之下,曰:“敢问崇德,修慝,辨惑。”子曰:“善哉问!先事后得,非崇德与?攻其恶,无攻人之恶,非修慝与?一朝之忿,忘其身,以及其亲,非惑与?”\n【译文】樊迟陪侍孔子在舞雩台下游逛,说道:“请问怎样提高自己的品德,怎样消除别人对自己不露面的怨恨,怎样辨别出哪种是胡涂事。”孔子道:“问得好!首先付出劳动,然后收获,不是提高品德了吗?批判自己的坏处,不去批判别人的坏处,不就消除无形的怨恨了吗?因为偶然的忿怒,便忘记自己,甚至也忘记了爹娘,不是胡涂吗?”\n12.22樊迟问仁。子曰:“爱人。”问知。子曰:“知人。”\n樊迟未达。子曰:“举直错诸枉,能使枉者直。”\n樊迟退,见子夏曰:“乡⑴也吾见于夫子而问知,子曰,‘举直错诸枉,能使枉者直’,何谓也?”\n子夏曰:“富哉言乎!舜有天下,选于众,举皋陶⑵,不仁者远⑶矣。汤⑷有天下,选于众,举伊尹⑸,不仁者远矣⑹。”\n【译文】樊迟问仁。孔子道:“爱人。”又问智。孔子道:“善于鉴别人物。”\n樊迟还不透澈了解。孔子道:“把正直人提拔出来,位置在邪恶人之上,能够使邪恶人正直。”\n樊迟退了出来,找着子夏,说道:“刚纔我去见老师向他问智,他说,‘把正直人提拔出来,位置在邪恶人之上’,这是什么意思?”\n子夏道:“意义多么丰富的话呀!舜有了天下,在众人之中挑选,把皋陶提拔出来,坏人就难以存在了。汤有了天下,在众人之中挑选,把伊尹提拔出来,坏人也就难以存在了。”\n【注释】⑴乡——去声,同“向”。⑵皋陶——音高摇,gāoyáo,舜的臣子。⑶远——本是“离开”“逋逃”之意,但人是可以转变的,何必非逃离不可。译文用“难以存在”来表达,比之拘泥字面或者还符合子夏的本意些。⑷汤——卜辞作“唐”,罗振玉云:“唐殆太乙之谥。”(《增订殷虚书契考释》)商朝开国之君,名履(卜辞作“大乙”,而无“履”字),伐夏桀而得天下。⑸伊尹——汤的辅相。⑹“举直”而“使枉者直”,属于“仁”;知道谁是直人而举他,属于“智”,所以“举直错诸枉”是仁智之事,而孔子屡言之(参2.19)。\n12.23子贡问友。子曰:“忠告⑴而善道之,不可则止,毋自辱焉。”\n【译文】子贡问对待朋友的方法。孔子道:“忠心地劝告他,好好地引导他,他不听从,也就罢了,不要自找侮辱。”\n【注释】⑴告——旧读梏,gù。\n12.24曾子曰:“君子以文会友,以友辅仁。”\n【译文】曾子说:“君子用文章学问来聚会朋友,用朋友来帮助我培养仁德。”\n"},{"id":107,"href":"/zh/docs/culture/%E8%AE%BA%E8%AF%AD/%E8%AE%BA%E8%AF%AD%E8%AF%91%E6%B3%A8_%E6%9D%A8%E4%BC%AF%E5%B3%BB/01%E5%AD%A6%E8%80%8C%E7%AF%87%E7%AC%AC%E4%B8%80/","title":"01学而篇第一","section":"论语译注 杨伯峻","content":" 学而篇第一 # (共十六章)\n1.1子⑴曰:“学而时⑵习⑶之,不亦说⑷乎?有朋⑸自远方来,不亦乐乎?人不知⑹,而不愠⑺,不亦君子⑻乎?”\n【译文】孔子说:“学了,然后按一定的时间去实习它,不也高兴吗?有志同道合的人从远处来,不也快乐吗?人家不了解我,我却不怨恨,不也是君子吗?”\n【注释】⑴子——《论语》“子曰”的“子”都是指孔子而言。⑵时——“时”字在周秦时候若作副词用,等于《孟子·梁惠王上》“斧斤以时入山林”的“以时”,“在一定的时候”或者“在适当的时候”的意思。王肃的《论语注》正是这样解释的。朱熹的《论语集注》把它解为“时常”,是用后代的词义解释古书。⑶习——一般人把习解为“温习”,但在古书中,它还有“实习”、“演习”的意义,如《礼记·射义》的“习礼乐”、“习射”。《史记·孔子世家》:“孔子去曹适宋,与弟子习礼大树下。”这一“习”字,更是演习的意思。孔子所讲的功课,一般都和当时的社会生活和政治生活密切结合。像礼(包括各种仪节)、乐(音乐)、射(射箭)、御(驾车)这些,尤其非演习、实习不可。所以这“习”字以讲为实习为好。⑷说——音读和意义跟“悦”字相同,高兴、愉快的意思。⑸有朋——古本有作“友朋”的。旧注说:“同门曰朋。”宋翔凤《朴学斋札记》说,这里的“朋”字卽指“弟子”,就是《史记·孔子世家》的“故孔子不仕,退而修诗、书、礼乐,弟子弥众,至自远方”译文用“志同道合之人”卽本此义。⑹人不知——这一句,“知”下没有宾语,人家不知道什么呢?当时因为有说话的实际环境,不需要说出便可以了解,所以未给说出。这却给后人留下一个谜。有人说,这一句是接上一句说的,从远方来的朋友向我求教,我告诉他,他还不懂,我却不怨恨。这样,“人不知”是“人家不知道我所讲述的”了。这种说法我嫌牵强,所以仍照一般的解释。这一句和宪问篇的“君子病无能焉,不病人之不己知也”的精神相同。⑺愠——yùn,怨恨。⑻君子——《论语》的“君子”,有时指“有德者”,有时指“有位者”,这里是指“有德者”。\n1.2有子⑴曰:“其为人也孝弟⑵,而好犯⑶上者,鲜⑷矣;不好犯上,而好作乱者,未之有也⑸。君子务本,本立而道生。孝弟也者,其为仁之本⑹与⑺!”\n【译文】有子说:“他的为人,孝顺爹娘,敬爱兄长,却喜欢触犯上级,这种人是很少的;不喜欢触犯上级,却喜欢造反,这种人从来没有过。君子专心致力于基础工作,基础树立了,‘道’就会产生。孝顺爹娘,敬爱兄长,这就是‘仁’的基础吧!”\n【注释】⑴有子——孔子学生,姓有,名若,比孔子小十三岁,一说小三十三岁,以小三十三岁之说较可信。《论语》记载孔子的学生一般称字,独曾参和有若称“子”(另外,冉有和闵子骞偶一称子,又当别论),因此很多人疑心《论语》就是由他们两人的学生所纂述的。但是有若称子,可能是由于他在孔子死后曾一度为孔门弟子所尊重的缘故(这一史实可参阅《礼记·檀弓上》、《孟子·滕文公上》和《史记·仲尼弟子列传》)。至于《左传》哀公八年说有若是一个“国士”,还未必是足以使他被尊称为“子”的原因。⑵孝弟——孝,奴隶社会时期所认为的子女对待父母的正确态度;弟,音读和意义跟“悌”相同,音替,tì,弟弟对待兄长的正确态度。封建时代也把“孝弟”作为维持它那时候的社会制度、社会秩序的一种基本道德力量。⑶犯——抵触,违反,冒犯。⑷鲜——音显,xiǎn,少。《论语》的“鲜”都是如此用法。⑸未之有也——“未有之也”的倒装形式。古代句法有一条这样的规律:否定句,宾语若是指代词,这指代词的宾语一般放在动词前。⑹孝弟为仁之本——“仁”是孔子的一种最高道德的名称。也有人说(宋人陈善的《扪虱新语》开始如此说,后人赞同者很多),这“仁”字就是“人”字,古书“仁”“人”两字本有很多写混了的。这里是说“孝悌是做人的根本”。这一说虽然也讲得通,但不能和“本立而道生”一句相呼应,未必符合有子的原意。《管子·戒篇》说,“孝弟者,仁之祖也”,也是这意。⑺与——音读和意义跟“欤”字一样,《论语》的“欤”字都写作“与”。\n1.3子曰:“巧言令色⑴,鲜矣仁!”\n【译文】孔子说:“花言巧语,伪善的面貌,这种人,‘仁德’是不会多的。”\n【注释】⑴巧言令色——朱注云:“好其言,善其色,致饰于外,务以说人。”所以译文以“花言巧语”译巧言,“伪善的面貌”译令色。\n1.4曾子⑴曰:“吾日三省⑵吾身——为人谋而不忠乎?与朋友交而不信⑶乎?传⑷不习⑸乎?”\n【译文】曾子说:“我每天多次自己反省:替别人办事是否尽心竭力了呢?同朋友往来是否诚实呢?老师传授我的学业是否复习了呢?”\n【注释】⑴曾子——孔子学生,名参(音森,sēn),字子舆,南武城(故城在今天的山东枣庄市附近)人,比孔子小四十六岁(公元前505—435)。⑵三省——“三”字有读去声的,其实不破读也可以。“省”音醒,xǐng,自我检查,反省,内省。“三省”的“三”表示多次的意思。古代在有动作性的动词上加数字,这数字一般表示动作频率。而“三”“九”等字,又一般表示次数的多,不要着实地去看待。说详汪中《述学·释三九》。这里所反省的是三件事,和“三省”的“三”只是巧合。如果这“三”字是指以下三件事而言,依《论语》的句法便应该这样说:“吾日省者三。”和宪问篇的“君子道者三”一样。⑶信——诚也。⑷传——平声,chuán,动词作名词用,老师的传授。⑸习——这“习”字和“学而时习之”的“习”一样,包括温习、实习、演习而言,这里概括地译为“复习”。\n1.5子曰:“道⑴千乘之国⑵,敬事⑶而信,节用而爱人⑷,使民以时⑸。”\n【译文】孔子说:“治理具有一千辆兵车的国家,就要严肃认真地对待工作,信实无欺,节约费用,爱护官吏,役使老百姓要在农闲时间。”\n【注释】⑴道——动词,治理的意思。⑵千乘之国——乘音剩,shèng,古代用四匹马拉着的兵车。春秋时代,打仗用车子,所以国家的强弱都用车辆的数目来计算。春秋初期,大国都没有千辆兵车。像《左传》僖公二十八年所记载的城濮之战,晋文公还只七百乘。但是在那时代,战争频繁,无论侵略者和被侵略者都必须扩充军备。侵略者更因为兼并的结果,兵车的发展速度更快;譬如晋国到平丘之会,据叔向的话,已有四千乘了(见《左传》昭公十三年)。千乘之国,在孔子之时已经不是大国,因此子路也说“千乘之国摄乎大国之间”(11.26)的话了。⑶敬事——“敬”字一般用于表示工作态度,因之常和“事”字连用,如卫灵公篇的“事君敬其事而后其食”。⑷爱人——古代“人”字有广狭两义。广义的“人”指一切人羣,狭义的人只指士大夫以上各阶层的人。这里和“民”(使“民”以时)对言,用的是狭义。⑸使民以时——古代以农业为主,“使民以时”卽是《孟子·梁惠王上》的“不违农时”,因此用意译。\n1.6子曰:“弟子⑴入⑵则孝,出⑵则悌,谨⑶而信,泛爱众,而亲仁⑷。行有余力,则以学文。”\n【译文】孔子说:“后生小子,在父母跟前,就孝顺父母;离开自己房子,便敬爱兄长;寡言少语,说则诚实可信,博爱大众,亲近有仁德的人。这样躬行实践之后,有剩余力量,就再去学习文献。”\n【注释】⑴弟子——一般有两种意义:(甲)年纪幼小的人,(乙)学生。这里用的是第一种意义。⑵入、出——《礼记·内则》:“由命士以上,父子皆异宫”,则知这里的“弟子”是指“命士”以上的人物而言。“入”是“入父宫”,“出”是“出己宫”。⑶谨——寡言叫做谨。详见杨遇夫先生的《积微居小学金石论丛》卷一。⑷仁——“仁”卽“仁人”,和雍也篇第六的“井有仁焉”的“仁”一样。古代的词汇经常运用这样一种规律:用某一具体人和事物的性质、特征甚至原料来代表那一具体的人和事物。\n1.7子夏⑴曰:“贤贤易色⑵;事父母,能竭其力;事君,能致⑶其身;与朋友交,言而有信。虽曰未学,吾必谓之学矣。”\n【译文】子夏说:“对妻子,重品德,不重容貌;侍奉爹娘,能尽心竭力;服事君上,能豁出生命;同朋友交往,说话诚实守信。这种人,虽说没学习过,我一定说他已经学习过了。”\n【注释】⑴子夏——孔子学生,姓卜,名商,字子夏,比孔子小四十四岁。(公元前507—?)⑵贤贤易色——这句话,一般的解释是:“用尊贵优秀品德的心来交换(或者改变)爱好美色的心。”照这种解释,这句话的意义就比较空泛。陈祖范的《经咫》、宋翔凤的《朴学斋札记》等书却说,以下三句,事父母、事君、交朋友,各指一定的人事关系;那么,“贤贤易色”也应该是指某一种人事关系而言,不能是一般的泛指。奴隶社会和封建社会把夫妻间关系看得极重,认为是“人伦之始”和“王化之基”,这里开始便谈到它,是不足为奇的。我认为这话很有道理。“易”有交换、改变的意义,也有轻视(如言“轻易”)、简慢的意义。因之我便用《汉书》卷七十五《李寻传》颜师古注的说法,把“易色”解为“不重容貌”。⑵致——有“委弃”、“献纳”等意义,所以用“豁出生命”来译它。\n1.8子曰:“君子⑴不重,则不威;学则不固。主忠信⑵。无友不如己者⑶。过,则勿惮改。”\n【译文】孔子说:“君子,如果不庄重,就没有威严;卽使读书,所学的也不会巩固。要以忠和信两种道德为主。不要跟不如自己的人交朋友。有了过错,就不要怕改正。”\n【注释】⑴君子——这一词一直贯串到末尾,因此译文将这两字作一停顿。⑵主忠信——颜渊篇(12.10)也说,“主忠信,徙义,崇德也”,可见“忠信”是道德。⑶无友不如己者——古今人对这一句发生不少怀疑,因而有一些不同的解释。译文只就字面译出。\n1.9曾子曰:“慎终⑴,追远⑵,民德归厚矣。”\n【译文】曾子说:“谨慎地对待父母的死亡,追念远代祖先,自然会导致老百姓归于忠厚老实了。”\n【注释】⑴慎终——郑玄的注:“老死曰终。”可见这“终”字是指父母的死亡。慎终的内容,刘宝楠《论语正义》引《檀弓》曾子的话是指附身(装殓)、附棺(埋葬)的事必诚必信,不要有后悔。⑵追远——具体地说是指“祭祀尽其敬”。两者译文都只就字面译出。\n1.10子禽⑴问于子贡⑵曰:“夫子⑶至于是邦也,必闻其政,求之与?抑与之与?”子贡曰:“夫子温、良、恭、俭、让以得之。夫子之求之也,其诸⑷异乎人之求之与?”\n【译文】子禽向子贡问道:“他老人家一到哪个国家,必然听得到那个国家的政事,求来的呢?还是别人自动告诉他的呢?”子贡道:“他老人家是靠温和、善良、严肃、节俭、谦逊来取得的。他老人家获得的方法,和别人获得的方法,不相同吧?”\n【注释】⑴子禽——陈亢(kàng)字子禽。从子张篇所载的事看来,恐怕不是孔子的学生。《史记·仲尼弟子列传》也不载此人。但郑玄注《论语》和《檀弓》都说他是孔子学生,不晓得有什么根据。(臧庸的《拜经日记》说子禽就是仲尼弟子列传的原亢禽,简朝亮的《论语集注补疏》曾加以辩驳。)⑵子贡——孔子学生,姓端木,名赐,字子贡,卫人,比孔子小三十一岁。(公元前520—?)⑶夫子——这是古代的一种敬称,凡是做过大夫的人,都可以取得这一敬称。孔子曾为鲁国的司寇,所以他的学生称他为夫子,后来因此沿袭以称呼老师。在一定的场合下,也用以特指孔子。⑷其诸——洪颐煊《读书丛録》云:“公羊桓六年传,‘其诸以病桓与?’闵元年传,‘其诸吾仲孙与?’僖二十四年传,‘其诸此之谓与?’宣五年传,‘其诸为其双双而俱至者与?’十五年传,‘其诸则宜于此焉变矣。’‘其诸’是齐鲁间语。”案,总上诸例,皆用来表示不肯定的语气。黄家岱《嬹艺轩杂着》说“其诸”意为“或者”,大致得之。\n1.11子曰:“父在,观其⑴志;父没,观其⑴行⑵;三年⑶无改于父之道⑷,可谓孝矣。”\n【译文】孔子说:“当他父亲活着,[因为他无权独立行动,]要观察他的志向;他父亲死了,要考察他的行为;若是他对他父亲的合理部分,长期地不加改变,可以说做到孝了。”\n【注释】⑴其——指儿子,不是指父亲。⑵行——去声,xìng。⑶三年——古人这种数字,有时不要看得太机械。它经常只表示一种很长的期间。⑷道——有时候是一般意义的名词,无论好坏、善恶都可以叫做道。但更多时候是积极意义的名词,表示善的好的东西。这里应该这样看,所以译为“合理部分”。\n1.12有子曰:“礼之用,和⑴为贵。先王之道,斯为美;小大由之。有所不行⑵,知和而和,不以礼节之,亦不可行也。”\n【译文】有子说:“礼的作用,以遇事都做得恰当为可贵。过去圣明君王的治理国家,可宝贵的地方就在这里;他们小事大事都做得恰当。但是,如有行不通的地方,便为恰当而求恰当,不用一定的规矩制度来加以节制,也是不可行的。”\n【注释】⑴和——《礼记·中庸》:“喜怒哀乐之未发谓之中,发而皆中节谓之和。”杨遇夫先生《论语疏证》说:“事之中节者皆谓之和,不独喜怒哀乐之发一事也。说文云:‘龢,调也。’‘盉,调味也。’乐调谓之龢,味调谓之盉,事之调适者谓之和,其义一也。和今言适合,言恰当,言恰到好处。”⑵有所不行——皇侃《义疏》把这句属上,全文便如此读:“礼之用,和为贵。先王之道,斯为美。小大由之,有所不行。……”他把“和”解为音乐,说:“此以下明人君行化必礼乐相须。……变乐言和,见乐功也。……小大由之有所不行者,言每事小大皆用礼,而不以乐和之,则其政有所不行也。”这种句读法值得考虑,但把“和”解释为音乐,而且认为“小大由之”的“之”是指“礼”而言,都觉牵强。特为注出,以供大家考虑。\n1.13有子曰:“信近于义,言可复⑴也。恭近于礼,远⑵耻辱也。因⑶不失其亲,亦可宗⑷也。”\n【译文】有子说:“所守的约言符合义,说的话就能兑现。态度容貌的庄矜合于礼,就不致遭受侮辱。依靠关系深的人,也就可靠了。”\n【注释】⑴复——《左传》僖公九年荀息说:“吾与先君言矣,不可以贰,能欲复言而爱身乎?”又哀公十六年叶公说:“吾闻胜也好复言,……复言非信也。”这“复言”都是实践诺言之义。《论语》此义当同于此。朱熹《集注》云:“复,践言也。”但未举论证,因之后代训诂家多有疑之者。童第德先生为我举出《左传》为证,足补古今字书之所未及。⑵远——去声,音院,yuàn,动词,使动用法,使之远离的意思。此处亦可以译为避免。⑶因——依靠,凭借。有人读为“姻”字,那“因不失其亲”便当译为“所与婚姻的人都是可亲的”,恐未必如此。⑷宗——主,可靠。一般解释为“尊敬”,不妥。\n1.14子曰:“君子⑴食无求饱,居无求安,敏于事而慎于言,就有道而正⑵焉,可谓好学也已。”\n【译文】孔子说:“君子,吃食不要求饱足,居住不要求舒适,对工作勤劳敏捷,说话却谨慎,到有道的人那里去匡正自己,这样,可以说是好学了。”\n【注释】⑴君子——《论语》的“君子”有时指“有位之人”,有时指“有德之人”。但有的地方究竟是指有位者,还是指有德者,很难分别。此处大概是指有德者。⑵正——《论语》“正”字用了很多次。当动词的,都作“匡正”或“端正”讲,这里不必例外。一般把“正”字解为“正其是非”、“判其得失”,我所不取。\n1.15子贡曰:“贫而无谄,富而无骄,何如⑴?”子曰:“可也;未若贫而乐⑵,富而好礼者也。”子贡曰:“《诗》云:‘如切如磋,如琢如磨⑶’其斯之谓与?”子曰:“赐⑷也,始可与言诗已矣,告诸往而知来者⑸。”\n【译文】子贡说:“贫穷却不巴结奉承,有钱却不骄傲自大,怎么样?”孔子说:“可以了;但是还不如虽贫穷却乐于道,纵有钱却谦虚好礼哩。”子贡说:“《诗经》上说:‘要像对待骨、角、象牙、玉石一样,先开料,再糙锉,细刻,然后磨光。’那就是这样的意思吧?”孔子道:“赐呀,现在可以同你讨论《诗经》了,告诉你一件,你能有所发挥,举一反三了。”\n【注释】⑴何如——《论语》中的“何如”,都可以译为“怎么样”。⑵贫而乐——皇侃本“乐”下有“道”字。郑玄注云:“乐谓志于道,不以贫为忧苦。”所以译文增“于道”两字。⑶如切如磋,如琢如磨——两语见于《诗经·卫风·淇奥篇》。⑷赐——子贡名。孔子对学生都称名。⑸告诸往而知来者——“诸”,在这里用法同“之”一样。“往”,过去的事,这里譬为已知的事;“来者”,未来的事,这里譬为未知的事。译文用意译法。孔子赞美子贡能运用《诗经》作譬,表示学问道德都要提高一步看。\n1.16子曰:“不患人之不己知,患不知人也。”\n【译文】孔子说:“别人不了解我,我不急;我急的是自己不了解别人。”\n"},{"id":108,"href":"/zh/docs/culture/%E8%AE%BA%E8%AF%AD/%E8%AE%BA%E8%AF%AD%E8%AF%91%E6%B3%A8_%E6%9D%A8%E4%BC%AF%E5%B3%BB/10%E4%B9%A1%E5%85%9A%E7%AF%87%E7%AC%AC%E5%8D%81/","title":"10乡党篇第十","section":"论语译注 杨伯峻","content":" 乡党篇第十 # (本是一章,今分为二十七节。)\n10.1孔子于乡党,恂恂⑴如也,似不能言者。其在宗庙朝廷,便便⑵言,唯谨尔。\n【译文】孔子在本乡的地方上非常恭顺,好像不能说话的样子。他在宗庙里、朝廷上,有话便明白而流畅地说出,只是说得很少。\n【注释】⑴恂恂——恂音旬,xún,恭顺貌。⑵便便——便旧读骈,pián。\n10.2朝,与下大夫言,侃侃如也;与上大夫言,誾誾⑴如也。君在,踧踖如也,与与如也。\n【译文】上朝的时候,[君主还没有到来,]同下大夫说话,温和而快乐的样子;同上大夫说话,正直而恭敬的样子。君主已经来了,恭敬而心中不安的样子,行步安祥的样子。\n【注释】⑴誾——音银,yín。\n10.3君召使摈,色勃如也,足躩⑴如也。揖所与立,左右手,衣前后⑵,襜⑶如也。趋进⑷,翼如也。宾退,必复命曰:“宾不顾矣。”\n【译文】鲁君召他去接待外国的贵宾,面色矜持庄重,脚步也快起来。向两旁的人作揖,或者向左拱手,或者向右拱手,衣裳一俯一仰,却很整齐。快步向前,好像鸟儿舒展了翅膀。贵宾辞别后一定向君主回报说:“客人已经不回头了。”\n【注释】⑴躩——音矍,jué,皇侃《义疏》引江熙云:“不暇闲步,躩,速貌也。”⑵前后——俯仰的意思。⑵襜——音幨,chān,整齐之貌。⑷趋进——在行步时一种表示敬意的行动。\n10.4入公门,鞠躬如⑴也,如不容。\n立不中门,行不履阈。\n过位⑵,色勃如也,足躩如也,其言似不足者。\n摄齐⑶升堂,鞠躬如也,屏气⑷似不息者。\n出,降一等,逞颜色,怡怡如也。\n没阶,趋进⑸,翼如也。\n复其位,踧踖如也。\n【译文】孔子走进朝廷的门,害怕而谨慎的样子,好像没有容身之地。\n站,不站在门的中间;走,不踩门坎。\n经过国君的坐位,面色便矜庄,脚步也快,言语也好像中气不足。\n提起下襬向堂上走,恭敬谨慎的样子,憋住气好像不呼吸一般。\n走出来,降下台阶一级,面色便放松,怡然自得。\n走完了台阶,快快地向前走几步,好像鸟儿舒展翅膀。\n回到自己的位置,恭敬而内心不安的样子。\n【注释】⑴鞠躬如——这“鞠躬”两字不能当“曲身”讲。这是双声字,用以形容谨慎恭敬的样子。《论语》所有“□□如”的区别词(区别词是形容词、副词的合称),都不用动词结构。清人卢文弨《龙城札记》说:“……且曲身乃实事,而云曲身如,更无此文法。”⑵过位——过旧音戈,平声。位是人君的坐位,经过之时,人君并不在,坐位是空的。⑶摄齐——齐音咨,zī,衣裳缝了边的下襬;摄,提起。⑷屏——音丙,又音并,bìng,屏气卽屏息,压抑呼吸。⑸趋进——有些本子无“进”字,不对。自汉以来所有引《论语》此文的都有“进”字,《唐石经》也有“进”字,《太平御览》居处部、人事部引文,张子《正蒙》引文也都有“进”字。\n10.5执圭⑴,鞠躬如也,如不胜⑵。上如揖,下如授。勃如战色,足蹜蹜如有循⑶。\n享礼⑷,有容色⑸。\n私觌⑹,愉愉如也。\n【译文】[孔子出使到外国,举行典礼,]拿着圭,恭敬谨慎地,好像举不起来。向上举好像在作揖,向下拿好像在交给别人。面色矜庄好像在作战。脚步也紧凑狭窄,好像在沿着[一条线]走过。\n献礼物的时候,满脸和气。\n用私人身分和外国君臣会见,显得轻松愉快。\n【注释】⑴圭——一种玉器,上圆,或者作剑头形,下方,举行典礼的时候,君臣都拿着。⑵胜——音升,shēng,能担负得了。⑶足蹜蹜如有循——蹜音缩,“蹜蹜”,举脚密而狭的样子。“如有循”,所沿循的应当是很窄狭的东西,所以译文加了“一条线”诸字以示意。⑷享礼——古代出使外国,初到所聘问的国家,便行聘问礼。“执圭”一段所写的正是行聘问礼时孔子的情貌。聘问之后,便行享献之礼。“享礼”就是享献礼,使臣把所带来的各种礼物罗列满庭。⑸有容色——《仪礼·聘礼》:“及享,发气焉盈容。”“有容色”就是“发气焉盈容”。⑹觌——音狄,dí,相见。\n10.6君子不以绀緅饰⑴,红紫不以为亵服⑵。\n当暑,袗絺绤⑶,必表而出之。\n缁衣,羔裘;素衣,麑裘;黄衣,狐裘⑷。\n亵裘长⑸,短右袂⑹。\n必有寝衣⑺,长一身有半。\n狐貉之厚以居。\n去丧,无所不佩。\n非帷裳⑻,必杀之⑼。\n羔裘玄冠不以吊⑽。\n吉月⑾,必朝服而朝。\n【译文】君子不用[近乎黑色的]天青色和铁灰色作镶边,[近乎赤色的]浅红色和紫色不用来作平常居家的衣服。\n暑天,穿着粗的或者细的葛布单衣,但一定裹着衬衫,使它露在外面。\n黑色的衣配紫羔,白色的衣配麑裘,黄色的衣配狐裘。\n居家的皮袄身材较长,可是右边的袖子要做得短些。\n睡觉一定有小被,长度合本人身长的一又二分之一。\n用狐貉皮的厚毛作坐垫。\n丧服满了以后,什么东西都可以佩带。\n不是[上朝和祭祀穿的]用整幅布做的裙子,一定裁去一些布。\n紫羔和黑色礼帽都不穿戴着去吊丧。\n大年初一,一定穿着上朝的礼服去朝贺。\n【注释】⑴绀緅饰——绀音赣,gàn;緅音邹,zōu;都是表示颜色的名称。“绀”是深青中透红的颜色,相当今天的“天青”;“緅”是青多红少,比绀更暗的颜色,这里用“铁灰色”来表明它。“饰”是滚边,镶边,缘边。古代,黑色是正式礼服的颜色,而这两种颜色都近于黑色,所以不用来镶边,为别的颜色作装饰。⑵红紫不以为亵服——古代大红色叫“朱”,这是很贵重的颜色。“红”和“紫”都属此类,也连带地被重视,不用为平常家居衣服的颜色。⑶袗絺绤——袗音轸,zhěn,单也。此处用为动词。絺音痴,chī,细葛布;绤音隙,xì,粗葛布。⑷缁衣羔裘等三句——这三句表示衣服里外的颜色应该相称。古代穿皮衣,毛向外,因之外面一定要用罩衣,这罩衣就叫做裼(音锡)衣。这里“缁衣”、“素衣”、“黄衣”的“衣”指的正是裼衣。缁,黑色。古代所谓“羔裘”都是黑色的羊毛,就是今天的紫羔。麑音倪,ní,小鹿,它的毛是白色。⑸亵裘长——亵裘长为着保暖。古代男子上面穿衣,下面穿裳(裙),衣裳不相连。因之孔子在家的皮袄就做得比较长。⑹短右袂——袂,mèi,袖子。右袖较短,为着做事方便。有人认为衣袖一长一短,不大好看,孔子不会如此,于是对这一句别生解释,我认为那些解释都不可信。⑺寝衣——卽被。古代大被叫“衾”,小被叫“被”。⑻帷裳——礼服,上朝和祭祀时穿,用整幅布做,不加翦裁,多余的布作褶迭(褶迭古代叫做襞积),犹如今天的百褶裙。古代男子上衣下裙。⑼杀——去声,shài,减少,裁去。“杀之”就是缝制之先裁去多余的布,不用褶迭,省工省料。⑽羔裘玄冠不以吊——玄冠,一种礼帽。“羔裘玄冠”都是黑色的,古代都用作吉服。丧事是凶事,因之不能穿戴着去吊丧。⑾吉月——这两个字有各种解释:(甲)每月初一(旧注都如此);(乙)“吉”字误,应该作“告”。“告月”就是每月月底,司历者以下月初一告之于君(王引之《经义述闻》、俞樾《羣经平议》);两说都不可信。今从程树德《论语集释》之说。\n10.7齐,必有明衣,布⑴。\n齐必变食⑵,居必迁坐⑶。\n【译文】斋戒沐浴的时候,一定有浴衣,用布做的。\n斋戒的时候,一定改变平常的饮食;居住也一定搬移地方[不和妻妾同房]。\n【注释】⑴布——现在的布一般是用草棉(棉花)纺织的,但古代没有草棉,布的质料,王夫之《四书稗疏》说:“古之言布者,兼丝麻枲葛而言之。练丝为帛,未练为布,盖今之生丝绢也。清商曲有云:‘丝布涩难缝’,则晋宋间犹有丝布之名。唯孔丛子谓麻苎葛曰布,当亦一隅之论。”赵翼《陔余丛考》说:“古时未有棉布,凡布皆麻为之。记曰:‘治其丝麻,以为布帛’是也。”⑵变食——变食的内容,古人有三种说法:(甲)《庄子·人间世篇》说:“颜回曰:‘回之家贫,惟不饮酒不茹荤者数月矣。如此,则可以为齐乎?’曰:‘是祭祀之齐,非心齐也。’”有人据此,便把“不饮酒,不茹荤(荤是有浓厚气味的蔬菜,如蒜、韭、葱之属)”来解释“变食”。(乙)《周礼·天官·膳夫》:“王日一举……王齐,日三举。”这意思是王每天虽然吃饭三顿,却只在第一顿饭时杀牲,其余两顿,只把第一顿的剩菜回锅罢了。天子如此,其它的人更不会顿顿吃新鲜的。若在斋戒之时那就顿顿吃新鲜的,不吃回锅的剩菜,取其洁净,这便是“变食”。(丙)金鹗《求古録·礼说补遗》说,变食不但不饮酒、不食葱蒜等,也不食鱼肉。⑶迁坐——等于说改变卧室。古代的上层人物平常和妻室居于“燕寝”;斋戒之时则居于“外寝”(也叫“正寝”),和妻室不同房。唐朝的法律还规定着举行大祭,在斋戒之时官吏不宿于正寝的,每一晚打五十竹板。这或者犹是古代风俗的残余。\n10.8食不厌精,脍不厌细。\n食饐而餲⑴,鱼馁而肉败⑵,不食。色恶,不食。臭恶,不食。失饪,不食。不时⑶,不食。割不正⑷,不食。不得其酱,不食。\n肉虽多,不使胜食气⑸。\n唯酒无量,不及乱⑹。\n沽酒市脯不食。\n不撤姜食,不多食。\n【译文】粮食不嫌舂得精,鱼和肉不嫌切得细。粮食霉烂发臭,鱼和肉腐烂,都不吃。食物颜色难看,不吃。气味难闻,不吃。烹调不当,不吃。不到该当吃食时候,不吃。不是按一定方法砍割的肉,不吃。没有一定调味的酱醋,不吃。\n席上肉虽然多,吃它不超过主食。\n只有酒不限量,却不至醉。\n买来的酒和肉干不吃。\n吃完了,姜不撤除,但吃得不多。\n【注释】⑴饐而餲——饐音懿,yì;餲,ài;饮食经久而腐臭。⑵馁,败——馁音“内”的上声,něi,鱼腐烂叫“馁”,肉腐烂叫“败”。⑶不时——有两说:(甲)过早的食物,冬天在温室种菜蔬,在《汉书·循吏·召信臣传》和桓宽《盐铁论·散不足篇》里便称为“不时之物”。但在汉朝,也只有“太官园”和其它少数园圃才能供奉,也只有皇上和极为富贵之家才能享受,而在孔子时,不但不必有温室种菜的技术,卽有,孔子也未必能够享受。(乙)不是该当吃食的时候。《吕氏春秋·尽数篇》:“食能以时,身必无灾。”卽此意。⑷割不正——“割”和“切”不同。“割”指宰杀猪牛羊时肢体的分解。古人有一定的分解方法,不按那方法分解的,便叫“割不正”。说本王夫之《四书稗疏》。⑸食气——食音嗣,“气”,说文引作“既”。“既”、“气”、“饩”三字古书通用。“食气”,饭料。⑹乱——高亨《周易古经今注》云:“乱者神志昏乱也。《左传》宣公十五年传:‘疾病则乱’。《论语·乡党篇》:‘唯酒无量不及乱’。易象传曰:‘乃乱乃萃,其志乱也。’得其恉矣。”\n10.9祭于公,不宿肉⑴。祭肉⑵不出三日。出三日,不食之矣。\n【译文】参与国家祭祀典礼,不把祭肉留到第二天。别的祭肉留存不超过三天。若是存放过了三天,便不吃了。\n【注释】⑴不宿肉——古代的大夫、士都有助君祭祀之礼。天子诸侯的祭礼,当天清早宰杀牲畜,然后举行祭典。第二天又祭,叫做“绎祭”。绎祭之后才令各人拿自己带来助祭的肉回去,或者又依贵贱等级分别颁赐祭肉。这样,祭于公的肉,在未颁下来以前,至少是放了一两宵了,因之不能再存放一夜。⑵祭肉——这一祭肉或者指自己家中的,或者指朋友送来的,都可以。\n10.10食不语,寝不言。\n【译文】吃饭的时候不交谈,睡觉的时候不说话。\n10.11虽疏食菜羹,瓜祭⑴,必齐如也。\n【译文】虽然是糙米饭小菜汤,也一定得先祭一祭,而且祭的时候还一定恭恭敬敬,好像斋戒了的一样。\n【注释】⑴瓜祭——有些本子作“必祭”,“瓜”恐怕是错字。这是食前将席上各种食品拿出少许,放在食器之间,祭最初发明饮食的人,《左传》叫泛祭。\n10.12席⑴不正,不坐。\n【译文】坐席摆的方向不合礼制,不坐。\n【注释】⑴席——古代没有椅和櫈,都是在地面上铺席子,坐在席子上。席子一般是用蒲苇、蒯草、竹篾以至禾穰为质料。现在日本人还保留着席地而坐的习惯。《墨子·非儒篇》说:“哀公迎孔子,席不端,不坐。”以“端”解“正”,则“席不正”,是坐席不端正之意。然而《汉书·王尊传》说,“[匡]衡与中二千石大鸿胪赏等会坐殿门下,衡南乡,赏等西乡。衡更为赏布束乡席,起立延赏坐……而设不正之席,使下坐上”云云,那么,“席不正”是布席不合礼制之意。\n10.13乡人饮酒⑴,杖者出,斯出矣。\n【译文】行乡饮酒礼后,要等老年人都出去了,自己这纔出去。\n【注释】⑴乡人饮酒——卽行乡饮酒礼,据《礼记·乡饮酒义》“少长以齿”。《王制》也说:“习乡尚齿”。既论年龄大小,所以孔子必须让杖者先出。\n10.14乡人傩⑴,朝服而立于阼阶⑵。\n【译文】本地方人迎神驱鬼,穿着朝服站在东边的台阶上。\n【注释】⑴滩——音挪,nuó,古代的一种风俗,迎神以驱逐疫鬼。解放前的湖南,如果家中有病人,还有雇请巫师以驱逐疫鬼的迷信,叫做“冲傩”,可能是这种风俗的残余。⑵阼阶——阼音祚,zuò,东面的台阶,主人所立之地。\n10.15问⑴人于他邦,再拜⑵而送之。\n【译文】托人给在外国的朋友问好送礼,便向受托者拜两次送行。\n【注释】⑴问——问讯,问好。不过古代问好,也致送礼物以表示情意,如《诗经·郑风·女曰鸡鸣》“杂佩以问之”,《左传》成公十六年“楚子使工尹襄问之以弓”,哀公十一年“使问弦多以琴”,因此译文加了“送礼”两字。⑵拜——拱手并弯腰。\n10.16康子馈药,拜而受之。曰:“丘未达,不敢尝。”\n【译文】季康子给孔子送药,孔子拜而接受,却说道:“我对这药性不很了解,不敢试服。”\n10.17厩焚。子退朝,曰:“伤人乎?”不问马。\n【译文】孔子的马棚失了火。孔子从朝廷回来,道:“伤了人吗?”,不问到马。\n10.18君赐食,必正席先尝之。君赐腥,必熟而荐⑴之。君赐生,必畜之。\n侍食于君,君祭,先饭。\n【译文】国君赐以熟食,孔子一定摆正坐位先尝一尝。国君赐以生肉,一定煮熟了,先[给祖宗]进供。国君赐以活物,一定养着它。\n同国君一道吃饭,当他举行饭前祭礼的时候,自己先吃饭,[不吃菜。]\n【注释】⑴荐——进奉。这里进奉的对象是自己的祖先,但不能看为祭祀。\n10.19疾,君视之,东首⑴,加朝服,拖绅⑵。\n【译文】孔子病了,国君来探问,他便脑袋朝东,把上朝的礼服披在身上,拖着大带。\n【注释】⑴东首——指孔子病中仍旧卧床而言。古人卧榻一般设在南窗的西面。国君来,从东边台阶走上来(东阶就是阼阶,原是主人的位向,但国君自以为是全国的主人,就是到其臣下家中,仍从阼阶上下),所以孔子面朝东来迎接他。⑵加朝服,拖绅——孔子卧病在床,自不能穿朝服,只能盖在身上。绅是束在腰间的大带。束了以后,仍有一节垂下来。\n10.20君命召,不俟驾行矣。\n【译文】国君呼唤,孔子不等待车辆驾好马,立卽先步行。\n10.21入太庙,每事问⑴。\n【注释】⑴见八佾篇。\n10.22朋友死,无所归,曰:“于我殡⑴。”\n【译文】朋友死亡,没有负责收敛的人,孔子便道:“丧葬由我来料理。”\n【注释】⑴殡——停放灵柩叫殡,埋葬也可以叫殡,这里当指一切丧葬事务而言。\n10.23朋友之馈,虽车马,非祭肉,不拜。\n【译文】朋友的赠品,卽使是车马,只要不是祭肉,孔子在接受的时候,不行礼。\n10.24寝不尸,居不客⑴。\n【译文】孔子睡觉不像死尸一样[直躺着],平日坐着,也不像接见客人或者自己做客人一样,[跪着两膝在席上。]\n【注释】⑴居不客——“客”本作“容”,今从《释文》和《唐石经》校订作“客”。居,坐;客,宾客。古人的坐法有几种,恭敬的是屈着两膝,膝盖着地,而足跟承着臀部。作客和见客时必须如此。不过这样难以持久,居家不必如此。省力的坐法是脚板着地,两膝耸起,臀部向下而不贴地,和蹲一样。所以《说文》说:“居,蹲也。”(这几个字是依从段玉裁的校本。)最不恭敬的坐法是臀部贴地,两腿张开,平放而直伸,像箕一样,叫做“箕踞”。孔子平日的坐式可能像蹲。说见段玉裁《说文解字注》。\n10.25见齐衰者,虽狎,必变。见冕者与瞽者,虽亵,必以貌。\n凶服者式⑴之。式负版⑵者。\n有盛馔,必变色而作。\n迅雷风烈⑶必变。\n【译文】孔子看见穿齐衰孝服的人,就是极亲密的,也一定改变态度,[表示同情。]看见戴着礼帽和瞎了眼睛的人,卽使常相见,也一定有礼貌。\n在车中遇着拿了送死人衣物的人,便把身体微微地向前一俯,手伏着车前的横木,[表示同情。]遇见背负国家图籍的人,也手伏车前横木。\n一有丰富的菜肴,一定神色变动,站立起来。\n遇见疾雷、大风,一定改变态度。\n【注释】⑴式——同“轼”,古代车辆前的横木叫“轼”,这里作动词用,用手伏轼的意思。⑵版——国家图籍。⑶迅雷风烈——就是“迅雷烈风”的意思。\n10.26升车,必正立,执绥。\n车中,不内顾,不疾言,不亲指。\n【译文】孔子上车,一定先端正地站好,拉着扶手带[登车]。在车中,不向内回顾,不很快地说话,不用手指指画画。\n10.27色斯举矣,翔而后集。曰:“山梁雌雉,时哉时哉!”子路共⑴之,三嗅⑵而作⑶。\n【译文】[孔子在山谷中行走,看见几只野鸡。]孔子的脸色一动,野鸡便飞向天空,盘旋一阵,又都停在一处。孔子道:“这些山梁上雌雉,得其时呀,得其时呀,”子路向它们拱拱手,它们又振一振翅膀飞去了。\n【注释】⑴共——同“拱”。⑵嗅——当作狊,jù,张两翅之貌。⑶这段文字很费解,自古以来就没有满意的解释,很多人疑它有脱误,我只能取前人的解释之较为平易者翻译出来。\n"},{"id":109,"href":"/zh/docs/culture/%E8%AE%BA%E8%AF%AD/%E8%AE%BA%E8%AF%AD%E8%AF%91%E6%B3%A8_%E6%9D%A8%E4%BC%AF%E5%B3%BB/14%E5%AE%AA%E9%97%AE%E7%AF%87%E7%AC%AC%E5%8D%81%E5%9B%9B/","title":"14宪问篇第十四","section":"论语译注 杨伯峻","content":" 宪问篇第十四 # (共四十四章(朱熹《集注》把第一章自“克、伐、怨、欲”以下别为一章,把第二十章自“曾子曰”以下别为一章,又把第三十七章自“子曰作者”以下别为一章,所以题为四十七章。))\n14.1宪问耻。子曰:“邦有道,谷;邦无道,谷,耻也。”\n“克、伐、怨、欲不行焉,可以为仁矣?⑴”子曰:“可以为难矣,仁则吾不知也。”\n【译文】原宪问如何叫耻辱。孔子道:“国家政治清明,做官领薪俸;国家政治黑暗,做官领薪俸,这就是耻辱。”\n原宪又道:“好胜、自夸、怨恨和贪心四种毛病都不曾表现过,这可以说是仁人了吗?”孔子道:“可以说是难能可贵的了,若说是仁人,那我不能同意。”\n【注释】⑴可以为仁矣——这句话从形式上看应是肯定句,但从上下文看,实际应是疑问句,不过疑问只从说话者的语势来表示,不藉助于别的表达形式而已。这一段可以和“邦有道,贫且贱焉,耻也;邦无道,富且贵焉,耻也。”(8.13)互相发明。\n14.2子曰:“士而怀居⑴,不足以为士矣。”\n【译文】孔子说:“读书人而留恋安逸,便不配做读书人了。”\n【注释】⑴怀居——怀,怀思,留恋;居,安居。《左传》僖公二十三年记载着晋文公的流亡故事,说他在齐国安居下来,有妻妾,有家财,便不肯再移动了。他老婆姜氏便对他说:“行也!怀与安,实败名。”便和此意相近。\n14.3子曰:“邦有道,危⑴言危行;邦无道,危行言孙⑵。”\n【译文】孔子说:“政治清明,言语正直,行为正直;政治黑暗,行为正直,言语谦顺。”\n【注释】⑴危——《礼记·缁衣》注:“危,高峻也。”意谓高于俗,朱熹《集注》用之,固然可通。但《广雅》云:“危,正也。”王念孙《疏证》卽引《论语》此文来作证,更为恰当,译文卽用此解。⑵孙——同逊。\n14.4子曰:“有德者必有言,有言者不必有德。仁者必有勇,勇者不必有仁。”\n【译文】孔子说:“有道德的人一定有名言,但有名言的人不一定有道德。仁人一定勇敢,但勇敢的人不一定仁。”\n14.5南宫适⑴问于孔子曰:“羿⑵善射,奡⑶荡舟⑷,俱不得其死然。禹稷躬稼而有天下。”夫子不答。\n南宫适出,子曰:“君子哉若人!尚德哉若人⑸!”\n【译文】南宫适向孔子问道:“羿擅长射箭,奡擅长水战,都没有得到好死。禹和稷自己下地种田,却得到了天下。[怎样解释这些历史?]”孔子没有答复。\n南宫适退了出来。孔子道:“这个人,好一个君子!这个人,多么尊尚道德!”\n【注释】⑴南宫适——孔子学生南容。⑵羿——音诣,yì。在古代传说中有三个羿,都是射箭能手。一为帝喾的射师,见于说文;二为唐尧时人,传说当时十个太阳同时出现,羿射落了九个,见《淮南子·本经训》;三为夏代有穷国的君主,见《左传》襄公四年。这里所指的和《孟子·离娄篇》所载的“逢蒙学射于羿”的羿,据说都是夏代的羿。⑶奡——音傲,aò。也是古代传说中的人物,夏代寒浞的儿子。字又作“浇”。⑷荡舟——顾炎武《日知録》云:“古人以左右冲杀为荡。陈其锐卒,谓之跳荡;别帅谓之荡主。荡舟盖兼此义。”译成现代汉语,就是用舟师冲锋陷阵。⑸君子……尚德哉若人——南宫适托古代的事来问孔子,中心思想是当今尚力不尚德,但按之历史,尚力者不得善终,尚德者终有天下。因之孔子称赞他。\n14.6子曰:“君子⑴而不仁者有矣夫,未有小人⑵而仁者也。”\n【译文】孔子说:“君子之中不仁的人有的罢,小人之中却不会有仁人。”\n【注释】⑴君子,小人——这个“君子”“小人”的含义不大清楚。“君子”“小人”若指有德者无德者而言,则第二句可以不说;看来,这里似乎是指在位者和老百姓而言。\n14.7子曰:“爱之,能勿劳乎⑴?忠焉,能勿诲乎?”\n【译文】孔子说:“爱他,能不叫他劳苦吗?忠于他,能够不教诲他吗?”\n【注释】⑴能勿劳乎——《国语·鲁语下》说:“夫民劳则思,思则善心生;逸则淫,淫则忘善,忘善则恶心生。”可以为“能勿劳乎”的注脚。\n14.8子曰:“为命⑴,裨谌⑵草创之,世叔⑶讨论⑷之,行人子羽⑸修饰之,东里子产⑹润色之。”\n【译文】孔子说:“郑国外交辞令的创制,裨谌拟稿,世叔提意见,外交官子羽修改,子产作文词上的加工。”\n【注释】⑴为命——《左传》襄公三十一年云:“郑国将有诸侯之事,子产乃问四国之为于子羽,且使多为辞令,与裨谌乘以适野,使谋可否,而告冯简子使断之。事成,乃授子太叔使行之,以应对宾客,是以鲜有败事。”可与《论语》此文相参校。《左传》所讲的过程和《论语》此文虽然有些出入,但主题是相同的,因此我把“命”译为“外交辞令”,不作一般的政令讲。⑵裨谌——音庇臣,bìchén,郑国大夫,见《左传》。⑶世叔——卽《左传》的子太叔(古代,“太”和“世”两字通用),名游吉。⑷讨论——意义和今天的“讨论”不同,这是一个人去研究而后提意见的意思。⑸行人子羽——行人,官名,卽古代的外交官。子羽,公孙挥的字。⑹东里子产——东里,地名,今在郑州市,子产所居。\n14.9或问子产。子曰:“惠人也。”\n问子西⑴。曰:“彼哉!彼哉⑵!”\n问管仲。曰:“人也。夺伯氏⑶骈邑⑷三百,饭疏食,没齿无怨言。”\n【译文】有人向孔子问子产是怎样的人物。孔子道:“是宽厚慈惠的人。”\n又问到子西。孔子道:“他呀,他呀!”\n又问到管仲。孔子道:“他是人才。剥夺了伯氏骈邑三百户的采地,使伯氏只能吃粗粮,到死没有怨恨的话。”\n【注释】⑴子西——春秋时有三个子西,一是郑国的公孙夏,生当鲁襄公之世,为子产的同宗兄弟,子产便是继他而主持郑国政治的。二是楚国的鬬宜申,生当鲁僖公、文公之世。三是楚国的公子申,和孔子同时。鬬宜申去孔子太远,公子申又太近,这人所问的当是公孙夏。⑵彼哉彼哉——《公羊传》定公八年记载阳虎谋杀季孙的事,说阳虎谋杀未成,在郊外休息,忽然望见公敛处父领着追兵而来,便道:“彼哉彼哉!”毛奇龄《论语稽求篇》因云:“此必古成语,而夫子引以作答者。”案:这是当时表示轻视的习惯语。⑶伯氏——齐国的大夫,皇侃《义疏》云:“伯氏名偃。”不知何据。⑷骈邑——地名。阮元曾得伯爵彝,说是乾隆五十六年出土于山东临朐县柳山寨。他在《积古斋锺鼎彝器款识》里说,柳山寨有古城的城基,卽春秋的骈邑。用《水经·巨洋水注》证之,阮氏之言很可信。\n14.10子曰:“贫而无怨难,富而无骄易。”\n【译文】孔子说:“贫穷却没有怨恨,很难;富贵却不骄傲,倒容易做到”\n14.11子曰:“孟公绰⑴为赵魏老⑵则优⑶,不可以为滕、薛⑷大夫。”\n【译文】孔子说:“孟公绰,若是叫他做晋国诸卿赵氏、魏氏的家臣,那是力有余裕的;却没有才能来做滕、薛这样小国的大夫。”\n【注释】⑴孟公绰——鲁国大夫,《左传》襄公二十五年记载着他的一段事。《史记·仲尼弟子列传》说他是孔子所尊敬的人。⑵老——古代,大夫的家臣称老,也称室老。⑶优——本意是“优裕”,所以用“力有余裕”来译它。⑷滕、薛——当时的小国,都在鲁国附近。滕的故城在今山东滕县西南十五里,薛的故城在今滕县南四十四里官桥公社处。\n14.12子路问成人。子曰:“若臧武仲⑴之知,公绰之不欲,卞庄子⑵之勇,冉求之艺,文之以礼乐,亦可以为成人矣。”曰:“今之成人者何必然?见利思义,见危授命,久要⑶不忘平生之言,亦可以为成人矣。”\n【译文】子路问怎样才是全人。孔子道:“智慧像臧武仲,清心寡欲像孟公绰,勇敢像卞庄子,多才多艺像冉求,再用礼乐来成就他的文采,也可以说是全人了。”等了一会,又道:“现在的全人哪里一定要这样?看见利益便能想起该得不该得,遇到危险便肯付出生命,经过长久的穷困日子都不忘记平日的诺言,也可以说是全人了。”\n【注释】⑴臧武仲——鲁大夫臧孙纥。他很聪明,逃到齐国之后,能预见齐庄公的被杀而设法辞去庄公给他的田。事见《左传》襄公二十三年。⑵卞庄子——鲁国的勇士。《荀子·大略篇》和《韩诗外传》卷十都载有他的勇敢故事。⑵久要——“要”为“约”的借字,“约”,穷困之意。说见杨遇夫先生的《积微居小学述林》。\n14.13子问公叔文子⑴于公明贾⑵曰:“信乎,夫子不言,不笑,不取乎?”\n公明贾对曰:“以⑶告者过也。夫子时然后言,人不厌其言;乐然后笑,人不厌其笑;义然后取,人不厌其取。”\n子曰:“其然?岂其然乎?”\n【译文】孔子向公明贾问到公叔文子,说:“他老人家不言语,不笑,不取,是真的吗?”\n公明贾答道:“这是传话的人说错了。他老人家到应说话的时候才说话,别人不厌恶他的话;高兴了才笑,别人不厌恶他的笑;应该取才取,别人不厌恶他的取。”\n孔子道:“如此的吗?难道真是如此的吗?”\n【注释】⑴公叔文子——卫国大夫,《檀弓》载有他的故事。⑵公明贾——卫人,姓公明,名贾。贾音假,jiǎ。《左传》哀公十四年楚有蔿贾也音假。⑶以——代词,此也。例证可参考杨遇夫先生的《词诠》。\n14.14子曰:“臧武仲以防求为后于鲁⑴,虽曰不要⑵君,吾不信也。”\n【译文】孔子说:“臧武仲[逃到齐国之前,]凭借着他的采邑防城请求立其子弟嗣为鲁国卿大夫,纵然有人说他不是要挟,我是不相信的。”\n【注释】⑴臧武仲以防求为后于鲁——事见《左传》襄公二十三年。防,臧武仲的封邑,在今山东费县东北六十里之华城,离齐国边境很近。⑵要——平声,音腰,yāo。\n14.15子曰:“晋文公⑴谲⑵而不正,齐桓公⑴正而不谲。”\n【译文】孔子说:“晋文公诡诈好耍手段,作风不正派;齐桓公作风正派,不用诡诈,不耍手段。”\n【注释】⑴晋文公、齐桓公——晋文公名重耳,齐桓公名小白。齐桓、晋文是春秋时五霸中最有名声的两个霸主。⑵谲——音决,jué,欺诈,玩弄权术阴谋。\n14.16子路曰:“桓公杀公子纠,召忽死之,管仲不死⑴。”曰:“未仁乎?”子曰:“桓公九合⑵诸侯,不以兵车,管仲之力也。如其仁,如其仁⑶。”\n【译文】子路道:“齐桓公杀了他哥哥公子纠,[公子纠的师傅]召忽因此自杀,[但是他的另一师傅]管仲却活着。”接着又道:“管仲该不是有仁德的罢?”孔子道:“齐桓公多次地主持诸侯间的盟会,停止了战争,都是管仲的力量。这就是管仲的仁德,这就是管仲的仁德。”\n【注释】⑴管仲不死——齐桓公和公子纠都是齐襄公的弟弟。齐襄公无道,两人都怕牵累,桓公便由鲍叔牙侍奉逃往莒国,公子纠也由管仲和召忽侍奉逃往鲁国。襄公被杀以后,桓公先入齐国,立为君,便兴兵伐鲁,逼迫鲁国杀了公子纠,召忽自杀以殉,管仲却做了桓公的宰相。这段历史可看《左传》庄公八年和九年。⑵九合——齐桓公纠合诸侯共计十一次。这一“九”字实是虚数,不过表示其多罢了。⑶如其仁——王引之《经传释词》云:“如犹乃也。”扬雄《法言》三次仿用这种句法,义同。\n14.17子贡曰:“管仲非仁者与?桓公杀公子纠,不能死,又相之。”子曰:“管仲相桓公,霸诸侯,一匡天下,民到于今受其赐。微⑴管仲,吾其被⑵发左衽矣。岂若匹夫匹妇之为谅也,自经⑶于沟渎⑷而莫之知也?”\n【译文】子贡道:“管仲不是仁人罢?桓公杀掉了公子纠,他不但不以身殉难,还去辅相他。”孔子道:“管仲辅相桓公,称霸诸侯,使天下一切得到匡正,人民到今天还受到他的好处。假若没有管仲,我们都会披散着头发,衣襟向左边开,[沦为落后民族]了。他难道要像普通老百姓一样守着小节小信,在山沟中自杀,还没有人知道的吗?”\n【注释】⑴微——假若没有的意思,只用于和既成事实相反的假设句之首。⑵被——同“披”。⑶自经——自缢。⑷沟渎——犹《孟子·梁惠王》的“沟壑”。王夫之《四书稗疏》认为它是地名,就是《左传》的“句渎”,《史记》的“笙渎”,那么,孔子的匹夫匹妇就是指召忽而言,恐不可信。\n14.18公叔文子之臣大夫⑴僎与文子同升诸⑵公。子闻之,曰:“可以为‘文’⑶矣。”\n【译文】公叔文子的家臣大夫僎,[由于文子的推荐,]和文子一道做了国家的大臣。孔子知道这事,便道:“这便可以谥为‘文’了。”\n【注释】⑴毛奇龄《四书剩言》云:“臣大夫卽家大夫也。”把“臣大夫”三字不分,今不取。《后汉书·吴良传》李贤注说:“文子家臣名僎”云云,也可见唐初人不以“臣大夫”为一词。⑵诸——用法同“于”。⑶据《礼记·檀弓》,公叔文子实谥为贞惠文子。郑玄《礼记》注说:“不言‘贞惠’者?‘文’足以兼之。”\n14.19子言卫灵公之无道也,康子曰:“夫如是,奚而⑴不丧?”孔子曰:“仲叔圉⑵治宾客,祝鮀治宗庙,王孙贾治军旅。夫如是,奚其丧?”\n【译文】孔子讲到卫灵公的昏乱,康子道:“既然这样,为什么不败亡?”孔子道:“他有仲叔圉接待宾客,祝鮀管理祭祀,王孙贾统率军队,像这样,怎么会败亡?”\n【注释】⑴奚而——俞樾《羣经平议》云:“奚而犹奚为也。”⑵仲叔圉——就是孔文子。\n14.20子曰:“其言之不怍,则为之也难。”\n【译文】孔子说:“那个人大言不惭,他实行就不容易。”\n14.21陈成子⑴弑简公⑵。孔子沐浴而朝⑶,告于哀公曰:“陈恒弑其君,请讨之⑷。”公曰:“告夫三子!”\n孔子曰⑸:“以吾从大夫之后,不敢不告也。君曰‘告夫三子’者!”\n之三子告,不可。孔子曰:“以吾从大夫之后,不敢不告也。”\n【译文】陈恒杀了齐简公。孔子斋戒沐浴而后朝见鲁哀公,报告道:“陈恒杀了他的君主,请你出兵讨伐他。”哀公道:“你向季孙、仲孙、孟孙三人去报告罢!”\n孔子[退了出来],道:“因为我曾忝为大夫,不敢不来报告,但是君上却对我说,‘给那三人报告吧’!”\n孔子又去报告三位大臣,不肯出兵。孔子道:“因为我曾忝为大夫,不敢不报告。”\n【注释】⑴陈成子——就是陈恒。⑵简公——齐简公,名壬。⑶孔子沐浴而朝——这时孔子已经告老还家,特为这事来朝见鲁君。⑷请讨之——孔子请讨陈恒,主要地由于陈恒以臣杀君,依孔子的学说,非讨不可。同时孔子也估计了战争的胜负。《左传》记载着孔子的话道:“陈恒弒其君,民之不与者半。以鲁之众加齐之半,可克也。”但这事仍可讨论。⑸孔子曰——这是孔子退朝后的话,参校《左传》哀公十四年的记载便可以知道。\n14.22子路问事君。子曰:“勿欺也,而犯之。”\n【译文】子路问怎样服侍人君。孔子道:“不要[阳奉阴违地]欺骗他,却可以[当面]触犯他。”\n14.23子曰:“君子上达⑴,小人下达⑴。”\n【译文】孔子说:“君子通达于仁义,小人通达于财利。”\n【注释】⑴上达下达——古今学人各有解释,译文采取了皇侃《义疏》的说法。\n14.24子曰:“古之学者为己⑴,今之学者为人⑴。”\n【译文】孔子说:“古代学者的目的在修养自己的学问道德,现代学者的目的却在装饰自己,给别人看。”\n【注释】为己为人——如何叫做“为己”和“为人”,译文采用了《荀子·劝学篇》、《北堂书钞》所引《新序》和《后汉书·桓荣传论》(俱见杨遇夫先生《论语疏证》)的解释。\n14.25蘧伯玉⑴使人于孔子。孔子与之坐而问焉,曰:“夫子何为?”对曰:“夫子欲寡其过⑵而未能也。”\n使者出。子曰“使乎!使乎!”\n【译文】蘧伯玉派一位使者访问孔子。孔子给他坐位,而后问道:“他老人家干些什么?”使者答道:“他老人家想减少过错却还没能做到。”\n使者辞了出来。孔子道:“好一位使者!好一位使者!”\n【注释】⑴蘧伯玉——卫国的大夫,名瑗。孔子在卫国之时,曾经住过他家。⑵寡其过——《庄子·则阳篇》说:“蘧伯玉行年六十而六十化,未尝不始于是之,而卒诎之以非也;或未知今之所谓是之非五十九非也(六十之是或为五十九之非)。”《淮南子·原道篇》也说:“蘧伯玉年五十而知四十九年非。”大概这人是位求进甚急善于改过的人。使者之言既得其实,又不卑不亢,所以孔子连声称赞。\n14.26子曰:“不在其位,不谋其政⑴。”\n曾子曰:“君子思不出其位。”\n【译文】曾子说:“君子所思虑的不超出自己的工作岗位。”\n【注释】⑴见泰伯篇。(8.14)\n14.27子曰:“君子耻其言而⑴过其行。”\n【译文】孔子说:“说得多,做得少,君子以为耻。”\n【注释】⑴而——用法同“之”,说详《词诠》。皇侃所据本,日本足利本,这一“而”字都作“之”。\n14.28子曰:“君子道者三,我无能焉:仁者不忧,知者不惑,勇者不惧。”子贡曰:“夫子自道也。”\n【译文】孔子说:“君子所行的三件事,我一件也没能做到:仁德的人不忧虑,智慧的人不迷惑,勇敢的人不惧怕。”子贡道:“这正是他老人家对自己的叙述哩。”\n14.29子贡方人⑴。子曰:“赐也贤乎哉?夫我则不暇。”\n【译文】子贡讥评别人。孔子对他道:“你就够好了吗?我却没有这闲工夫。”\n【注释】⑴方人——《经典释文》说,郑玄注的《论语》作“谤人”,又引郑注云“谓言人之遇恶”。因此译文译为“讥评”。《世说新语·容止篇》:“或以方谢仁祖不乃重者。”这“方”字作品评解,其用法可能出于此。\n14.30子曰:“不患人之不己知,患其不能也。”\n【译文】孔子说:“不着急别人不知道我,只着急自己没有能力。”\n14.31子曰:“不逆诈,不亿不信,抑亦先觉者,是贤乎!”\n【译文】孔子说:“不预先怀疑别人的欺诈,也不无根据地猜测别人的不老实,却能及早发觉,这样的人是一位贤者罢!”\n14.32微生亩⑴谓孔子曰:“丘何为是⑵栖栖者与?无乃为佞乎?”孔子曰:“非敢为佞也,疾固也。”\n【译文】微生亩对孔子道:“你为什么这样忙忙碌碌的呢?不是要逞你的口才吗?”孔子道:“我不是敢逞口才,而是讨厌那种顽固不通的人。”\n【注释】⑴微生亩——“微生是姓,“亩”是名。⑵是——这里作副词用,当“如此”解。\n14.33子曰:“骥不称其力,称其德也。”\n【译文】孔子说:“称千里马叫做骥,并不是赞美它的气力,而是赞美他的质量。”\n14.34或曰:“以德报怨⑴,何如?”子曰:“何以报德?以直报怨,以德报德。”\n【译文】有人对孔子道:“拿恩惠来回答怨恨,怎么样?”孔子道:“拿什么来酬答恩惠呢?拿公平正直来回答怨恨,拿恩惠来酬答恩惠。”\n【注释】⑴以德报怨——《老子》也说:“大小多少,报怨以德。”可能当日流行此语。\n14.35子曰:“莫我知也夫!”子贡曰:“何为其莫知子也?”子曰:“不怨天,不尤人,下学而上达⑴。知我者其天乎!”\n【译文】孔子叹道:“没有人知道我呀!”子贡道:“为什么没有人知道您呢?”孔子道:“不怨恨天,不责备人,学习一些平常的知识,却透澈了解很高的道理。知道我的,只是天罢!”\n【注释】⑴下学而上达——这句话具体的意义是什么,古今颇有不同解释,译文所言只能备参考。皇侃《义疏》云:“下学,学人事;上达,达天命。我既学人事,人事有否有泰,故不尤人。上达天命,天命有穷有通,故我不怨天也。”全部意思都贯通了,虽不敢说合于孔子本意,无妨録供参考。\n14.36公伯寮⑴愬⑵子路于季孙。子服景伯⑶以告,曰:“夫子固有惑志于公伯寮,吾力犹能肆诸市朝⑷。”\n子曰:“道之将行也与,命也;道之将废也与,命也。公伯寮其如命何!”\n【译文】公伯寮向季孙毁谤子路。子服景伯告诉孔子,并且说:“他老人家已经被公伯寮所迷惑了,可是我的力量还能把他的尸首在街头示众。”\n孔子道:“我的主张将实现吗,听之于命运;我的主张将永不实现吗,也听之于命运。公伯寮能把我的命运怎样呢!”\n【注释】⑴公伯寮——《史记·仲尼弟子列传》作“公伯僚”云“字子周”。⑵愬——同“诉”。⑶子服景伯——鲁大夫,名何。⑷市朝——古人把罪人之尸示众,或者于朝廷,或者于市集。\n14.37子曰:“贤者辟⑴世,其次辟地,其次辟色,其次辟言。”\n子曰:“作者七人矣。”\n【译文】孔子说:“有些贤者逃避恶浊社会而隐居,次一等的择地而处,再次一等的避免不好的脸色,再次一等的迥避恶言。”\n孔子又说:“像这样的人已经有七位了。”\n【注释】⑴辟——同“避”。\n14.38子路宿于石门⑴。晨门曰:“奚自?”子路曰:“自孔氏。”曰:“是知其不可而为之者与?”\n【译文】子路在石门住了一宵,[第二天清早进城,]司门者道:“从哪儿来?”子路道:“从孔家来。”司门者道:“就是那位知道做不到却定要去做的人吗?”\n【注释】⑴石门——《后汉书·张皓王龚传论》注引郑玄《论语注》云:“石门,鲁城外门也。”\n14.39子击磬于卫,有荷蒉而过孔氏之门者,曰:“有心哉,击磬乎!”既而曰:“鄙哉,踁踁乎!莫己知也,斯己而已矣。深则厉,浅则揭⑴。”\n子曰:“果哉!末之难矣。”\n【译文】孔子在卫国,一天正敲着磬,有一个挑着草筐子的汉子恰在门前走过,便说道:“这个敲磬是有深意的呀!”等一会又说道:“磬声踁踁的,可鄙呀,[它好像在说,没有人知道我呀!]没有人知道自己,这就罢休好了。水深,索性连衣裳走过去;水浅,无妨撩起衣裳走过去。”\n孔子道:“好坚决!没有办法说服他了。”\n【注释】⑴深厉浅揭——两句见于《诗经·邶风·匏有苦叶》。这是比喻。水深比喻社会非常黑暗,只得听之任之;水浅比喻黑暗的程度不深,还可以使自己不受沾染,便无妨撩起衣裳,免得濡湿。\n14.40子张曰:“《书》云:‘高宗谅阴⑴,三年不言。’何谓也?”子曰:“何必高宗,古之人皆然。君薨,百官总己以听于冢宰三年。”\n【译文】子张道:“《尚书》说:‘殷高宗守孝,住在凶庐,三年不言语。’这是什么意思?”孔子道:“不仅仅高宗,古人都是这样:国君死了,继承的君王三年不问政治,各部门的官员听命于宰相。”\n【注释】⑴谅阴——居丧时所住的房子,又叫“凶庐”。两语见《无逸篇》。\n14.41子曰:“上好礼,则民易使也。”\n【译文】孔子说:“在上位的人若遇事依礼而行,就容易使百姓听从指挥。”\n14.42子路问君子。子曰:“修己以敬。”\n曰:“如斯而已乎?”曰:“修己以安人⑴。”\n曰:“如斯而已乎?”曰:“修己以安百姓。修己以安百姓⑵,尧舜其犹病诸?”\n【译文】子路问怎样才能算是一个君子。孔子道:“修养自己来严肃认真地对待工作。”\n子路道:“这样就够了吗?”孔子道:“修养自己来使上层人物安乐。”\n子路道:“这样就够了吗?”孔子道:“修养自己来使所有老百姓安乐。修养自己来使所有老百姓安乐,尧舜大概还没有完全做到哩!”\n【注释】⑴人——这个“人”字显然是狭义的“人”(参见1.5注四),没有把“百姓”包括在内。⑵修己以安百姓——雍也篇说:“博施于民……尧舜其犹病诸。”(6.30)这里说:“修己以安百姓,尧舜其犹病诸。”可见这里的“修己以安百姓”就是“博施于民”。\n14.43原壤⑴夷俟⑵。子曰:“幼而不孙弟⑶,长而无述焉,老而不死,是为贼。”以杖叩其胫。\n【译文】原壤两腿像八字一样张开坐在地上,等着孔子。孔子骂道:“你幼小时候不懂礼节,长大了毫无贡献,老了还白吃粮食,真是个害人精。”说完,用拐杖敲了敲他的小腿。\n【注释】⑴原壤——孔子的老朋友,《礼记·檀弓》记载他一段故事,说他母亲死了,孔子去帮助他治丧,他却站在棺材上唱起歌来了,孔子也只好装做没听见。大概这人是一位另有主张而立意反对孔子的人。⑵夷俟——夷,箕踞;俟,等待。⑵孙弟——同逊悌。\n14.44阙党⑴童子将命。或问之曰:“益者与?”子曰:“吾见其居于位⑵也。见其与先生并行⑶也。非求益者也,欲速成者也。”\n【译文】阙党的一个童子来向孔子传达信息。有人问孔子道:“这小孩是肯求上进的人吗?孔子道:“我看见他[大模大样地]坐在位上,又看见他同长辈并肩而行。这不是个肯求上进的人,只是一个急于求成的人。”\n【注释】⑴阙党——顾炎武的《日知録》说:“《史记·鲁世家》‘炀公筑茅阙门’,盖阙门之下,其里卽名阙里,夫子之宅在焉。亦谓之阙党。”案顾氏此说很对(阎若璩《四书释地》的驳论不对),《荀子·儒效篇》也有孔子“居于阙党”的记载,可见阙党为孔子所居之地名。⑵居于位——根据《礼记·玉藻》的记载,“童子无事则立主人之北,南面。”则“居于位”是不合当日礼节的。⑶与先生并行——《礼记·曲礼》上篇说:“五年以长,则肩随之”(“肩随”就是与之并行而稍后),而童子的年龄相差甚远,依当日礼节,不能和成人并行。\n"},{"id":110,"href":"/zh/docs/culture/%E8%AE%BA%E8%AF%AD/%E8%AE%BA%E8%AF%AD%E8%AF%91%E6%B3%A8_%E6%9D%A8%E4%BC%AF%E5%B3%BB/11%E5%85%88%E8%BF%9B%E7%AF%87%E7%AC%AC%E5%8D%81%E4%B8%80/","title":"11先进篇第十一","section":"论语译注 杨伯峻","content":" 先进篇第十一 # (共二十六章(朱熹《集注》把第二、第三两章合并为一章。刘宝楠正义则把第十八、第十九和第二十、第二十一各并为一章。))\n11.1子曰:“先进⑴于礼乐,野人也;后进⑴于礼乐,君子也。如用之,则吾从先进。”\n【译文】孔子说:“先学习礼乐而后做官的是未曾有过爵禄的一般人,先有了官位而后学习礼乐的是卿大夫的子弟。如果要我选用人才,我主张选用先学习礼乐的人。”\n【注释】⑴先进,后进——这两个术语的解释很多,都不恰当。译文本刘宝楠《论语正义》之说而略有取舍。孔子是主张“学而优则仕”的人,对于当时的卿大夫子弟,承袭父兄的庇荫,在做官中去学习的情况可能不满意。《孟子·告子下》引葵丘之会盟约说,“士无世官”,又说,“取士必得”,那么,孔子所谓“先进”一般指“士”。\n11.2子曰:“从我于陈、蔡⑴者,皆不及门⑵也。”\n【译文】孔子说:“跟着我在陈国、蔡国之间忍饥受饿的人,都不在我这里了。”\n【注释】⑴从我于陈、蔡——“从”读去声,zòng。《史记·孔子世家》云:“吴伐陈,楚救陈,军于城父。闻孔子在陈、蔡之间,楚使人聘孔子,孔子将往拜礼。陈、蔡大夫谋曰:‘孔子贤者,所刺讥皆中诸侯之疾,今者久留陈、蔡之间,诸大夫所设行皆非仲尼之意。今楚,大国也,来聘孔子。孔子用于楚,则陈、蔡用事大夫危矣。’乃相与发徒役围孔子于野。不得已,绝粮。从者病,莫能兴。……于是使子贡至楚。楚昭王兴师迎孔子,然后得免。”⑵不及门——汉唐旧解“不及门”为“不及仕进之门”或“不仕于卿大夫之门”,刘宝楠因而傅会孟子的“无上下之交”,解为“孔子弟子无仕陈蔡者”,我则终嫌与文意不甚密合,故不取,而用朱熹之说。郑珍《巢经巢文集》卷二〈驳朱竹垞孔子门人考〉有云:“古之教者家有塾,塾在门堂之左右,施教受业者居焉。所谓‘皆不及门’,及此门也。‘奚为于丘(原作某,由于避讳故,今改)之门’,于此门也。滕更之‘在门’,在此门也,故曰‘愿留而受业于门’(按上两句俱见《孟子》)。”亦见朱熹此说之有据。\n11.3德行:颜渊,闵子骞,冉伯牛,仲弓。言语:宰我,子贡。政事:冉有,季路。文学⑴:子游,子夏。\n【译文】[孔子的学生各有所长。]德行好的:颜渊,闵子骞,冉伯牛,仲弓。会说话的:宰我,子贡。能办理政事的:冉有,季路。熟悉古代文献的:子游,子夏。\n【注释】⑴文学——指古代文献,卽孔子所传的《诗》、《书》、《易》等。皇侃《义疏》引范宁说如此。《后汉书·徐防传》说:“防上疏云:‘经书礼乐,定自孔子;发明章句,始于子夏”。似亦可为证。又这一章和上一章“从我于陈蔡者”不相连。朱熹《四书集注》说这十人卽当在陈、蔡之时随行的人,是错误的。根据《左传》,冉有其时在鲁国为季氏之臣,未必随行。根据《史记·仲尼弟子列传》,当时随行的还有子张,何以这里不说及?根据各种史料,确知孔子在陈绝粮之时为鲁哀公四年,时孔子六十一岁。又据《史记·仲尼弟子列传》,子游小于孔子四十五岁,子夏小于孔子四十四岁,那么,孔子在陈、蔡受困时,子游不过十六岁,子夏不过十七岁,都不算成人。这么年幼的人卽使已经在孔子门下受业,也未必都跟去了。可见这几句话不过是孔子对这十个学生的一时的叙述,由弟子转述下来的记载而已。\n11.4子曰:“回也非助我者也,于吾言无所不说。”\n【译文】孔子说:“颜回不是对我有所帮助的人,他对我的话没有不喜欢的。”\n11.5子曰:“孝哉闵子骞!人不间于其父母昆弟之言。”\n【译文】孔子说:“闵子骞真是孝顺呀,别人对于他爹娘兄弟称赞他的言语并无异议。”\n11.6南容三复白圭⑴,孔子以其兄之子妻之。\n【译文】南容把“白圭之玷,尚可磨也;斯言之玷,不可为也”的几句诗读了又读,孔子便把自己的侄女嫁给他。\n【注释】⑴白圭——白圭的诗四句见于《诗经·大雅·抑篇》,意思是白圭的污点还可以磨掉;我们言语中的污点便没有办法去掉。大概南容是一个谨小慎微的人,所以能做到“邦有道,不废;邦无道,免于刑戮”。(5.2)\n11.7季康子问⑴:“弟子孰为好学?”孔子对曰:“有颜回者好学,不幸短命死矣,今也则亡。”\n【译文】季康子问道:“你学生中谁用功?”孔子答道:“有一个叫颜回的用功,不幸短命死了,现在就再没有这样的人了。”\n【注释】⑴季康子问——鲁哀公曾经也有此问(6.3),孔子的回答较为详细。有人说,从此可见孔子与鲁君的问答和与季氏的问答有繁简之不同。\n11.8颜渊死,颜路⑴请子之车以为之⑵椁⑶。子曰:“才不才,亦各言其子也。鲤⑷也死,有棺而无椁。吾不徒行以为之椁。以吾从大夫之后⑸,不可徒行也。”\n【译文】颜渊死了,他父亲颜路请求孔子卖掉车子来替颜渊办外椁。孔子道:“不管有才能或者没有才能,但总是自己的儿子。我的儿子鲤死了,也只有内棺,没有外椁。我不能[卖掉车子]步行来替他买椁。因为我也曾做过大夫,是不可以步行的。”\n【注释】⑴颜路——颜回的父亲,据《史记·仲尼弟子列传》,名无繇,字路,也是孔子学生。⑵之——用法同“其”。⑶椁——也作“椁”,音果,guǒ。古代大官棺木至少用两重,里面的一重叫棺,外面又一重大的叫椁,平常我们说“内棺外椁”就是这个意思。⑷鲤也死——鲤,字伯鱼,年五十死,那时孔子年七十。⑹从大夫之后——孔子在鲁国曾经做过司寇的官,是大夫之位。不过此时孔子已经去位多年。他不说“我曾为大夫”,而说“吾从大夫之后”(在大夫行列之后随行的意思)只是一种谦逊的口气罢了。\n11.9颜渊死。子曰:“噫!天丧予!天丧予⑴!”\n【译文】颜渊死了,孔子道:“咳!天老爷要我的命呀!天老爷要我的命呀!”\n【注释】⑴天丧予——译文只就字面译出。\n11.10颜渊死,子哭之恸⑴。从者曰:“子恸矣!”曰:“有恸乎?非夫人之为恸⑵而谁为?”\n【译文】颜渊死了,孔子哭得很伤心。跟着孔子的人道:“您太伤心了!”孔子道:“真的太伤心了吗?我不为这样的人伤心,还为什么人伤心呢!”\n【注释】⑴恸——郑注:“恸,变动容貌”。马融注:“恸,哀过也”。译文从马。⑵非夫人之为恸而谁为——“非夫人之为恸”是“非为夫人恸”的倒装形式。“夫人”的“夫”读阳平,音扶,指示形容词,“那”的意思。“之为”的“之”是专作帮助倒装用的,无实际意义。这一整句下文的“谁为”,依现代汉语的格式说也是倒装,不过在古代,如果介词或者动词的宾语是疑问代词,一般都放在介词或者动词之上。\n11.11颜渊死,门人欲厚葬⑴之。子曰:“不可。”门人厚葬之。子曰:“回也视予犹父也,予不得视犹子也。非我也,夫二三子也。”\n【译文】颜渊死了,孔子的学生们想要很丰厚地埋葬他。孔子道:“不可以。”学生们仍然很丰厚地埋葬了他。孔子道:“颜回呀,你看待我好像看待父亲,我却不能够像对待儿子一般地看待你。这不是我的主意呀,是你那班同学干的呀。”\n【注释】⑴厚葬——根据《檀弓》所记载孔子的话,丧葬应该“称家之有亡,有,毋过礼。苟亡矣,敛首足形,还葬,县棺而封。”颜子家中本穷,而用厚葬,从孔子看来,是不应该的。孔子的叹,实是责备那些主持厚葬的学生。\n11.12季路问事鬼神。子曰:“未能事人,焉能事鬼?”\n曰:“敢⑴问死。”曰:“未知生,焉知死?”\n【译文】子路问服事鬼神的方法。孔子道:“活人还不能服事,怎么能去服事死人?”\n子路又道:“我大胆地请问死是怎么回事。”孔子道:“生的道理还没有弄明白,怎么能够懂得死?”\n【注释】⑴敢——表敬副词,无实际意义。《仪礼·士虞礼》郑玄注云:“敢,冒昧之词。”贾公彦疏云:“凡言‘敢’者,皆是以卑触尊不自明之意。”\n11.13闵子侍侧,誾誾如也;子路,行行⑴如也;冉有、子贡,侃侃如也。子乐。“若由也,不得其死然⑵。”\n【译文】闵子骞站在孔子身旁,恭敬而正直的样子;子路很刚强的样子;冉有、子贡温和而快乐的样子。孔子高兴起来了。[不过,又道:]“像仲由吧,怕得不到好死。”\n【注释】⑴行行——旧读去声,hàng。⑵不得其死然——得死,当时俗语,谓得善终。《左传》僖公十九年“得死为幸”;哀公十六年“得死,乃非我”。然,语气词,用法同“焉”。\n11.14鲁人⑴为长府。闵子骞曰:“仍旧贯,如之何?何必改作?”子曰:“夫人不言,言必有中。”\n【译文】鲁国翻修叫长府的金库。闵子骞道:“照着老样子下去怎么样?为什么一定要翻造呢?”孔子道:“这个人平日不大开口,一开口一定中肯。”\n【注释】⑴鲁人——“鲁人”的“人”指其国的执政大臣而言。此“人”和“民”的区别。\n11.15子曰:“由之瑟⑴奚为于丘之门?”门人不敬子路。子曰:“由也升堂矣,未入于室⑵也。”\n【译文】孔子道:“仲由弹瑟,为什么在我这里来弹呢?”因此孔子的学生们瞧不起子路。孔子道:“由么,学问已经不错了,只是还不够精深罢了。”\n【注释】⑴瑟——音涩,sè,古代的乐器,和琴同类。这里孔子不是不高兴子路弹瑟,而是不高兴他所弹的音调。《说苑·修文篇》对这段文字曾有所发挥。⑵升堂入室——这是比喻话。“堂”是正厅,“室”是内室。先入门,次升堂,最后入室,表示做学问的几个阶段。“入室”犹如今天的俗语“到家”。我们说,“这个人的学问到家了”,正是表示他的学问极好。\n11.16子贡问:“师与商也孰贤?”子曰:“师也过,商也不及。”\n曰:“然则师愈与?”子曰:“过犹不及。”\n【译文】子贡问孔子:“颛孙师(子张)和卜商(子夏)两个人,谁强一些?”孔子道:“师呢,有些过分;商呢,有些赶不上。”子贡道:“那么,师强一些吗?”孔子道:“过分和赶不上同样不好。”\n11.17季氏富于周公⑴,而求也为之聚敛而附益之⑵。子曰:“非吾徒也。小子鸣鼓而攻之,可也。”\n【译文】季氏比周公还有钱,冉求却又替他搜括,增加更多的财富。孔子道:“冉求不是我们的人,你们学生很可以大张旗鼓地来攻击他。”\n【注释】⑴周公——有两说:(甲)周公旦;(乙)泛指在周天子左右作卿士的人,如周公黑肩、周公阅之类。⑵聚敛而附益之——事实可参阅《左传》哀公十一年和十二年文。季氏要用田赋制度,增加赋税,使冉求征求孔子的意见,孔子则主张“施取其厚,事举其中,敛从其薄”。结果冉求仍旧听从季氏,实行田赋制度。聚敛,《礼记·大学》说:“百乘之家,不畜聚敛之臣。与其有聚敛之臣,宁有盗臣。”可见儒家为了维护统治,反对对人民的过分剥削。其思想渊源或者本于此章。\n11.18柴⑴也愚,参也鲁,师也辟⑵,由也喭。\n【译文】高柴愚笨,曾参迟钝,颛孙师偏激,仲由卤莽。\n【注释】⑴柴——高柴,字子羔,孔子学生,比孔子小三十岁(公元前521—?)。⑵辟——音辟,pì。黄式三《论语后案》云:“辟读若《左传》‘阙西辟’之辟,偏也。以其志过高而流于一偏也”。\n11.19子曰:“回也其庶⑴乎,屡空⑵。赐不受命⑶,而货殖焉,亿则屡中。”\n【译文】孔子说:“颜回的学问道德差不多了罢,可是常常穷得没有办法。端木赐不安本分,去囤积投机,猜测行情,竟每每猜对了。”\n【注释】⑴庶——庶几,差不多。一般用在称赞的场合。⑵空——世俗把“空”字读去声,不但无根据,也无此必要。“贫”和“穷”两字在古代有时有些区别,财货的缺少叫贫,生活无着落,前途无出路叫穷。“空”字却兼有这两方面的意思,所以用“穷得没有办法”来译它。⑶赐不受命——此语古今颇有不同解释,关键在于“命”字的涵义。有把“命”解为“教命”的,则“不受命”为“不率教”,其为错误甚明显。王弼、江熙把“命”解为“爵命”“禄命”,则“不受命”为“不做官”,自然很讲得通,可是子贡并不是不曾做官。《史记·仲尼弟子列传》说他“常相鲁卫”,《货殖列传》又说他“既学于仲尼,退而仕于卫,废着鬻财于曹鲁之间”,则子贡的经商和做官是不相先后的。那么,这一说既不合事实,也就不合孔子原意了。又有人把“命”讲为“天命”(皇《疏》引或说,朱熹《集注》),俞樾《羣经平议》则以为古之经商皆受命于官,“若夫不受命于官而自以其财市贱鬻贵,逐什一之利,是谓不受命而货殖。”两说皆言之成理,而未知孰是,故译文仅以“不安本分”言之。\n11.20子张问善人之道。子曰:“不践迹,亦不入于室⑴。”\n【译文】子张问怎样才是善人。孔子道:“善人不踩着别人的脚印走,学问道德也难以到家。”\n【注释】⑴善人——孔子曾三次论到“善人”,这章可和(7.26)(13.11)两章合看。\n11.21子曰:“论笃是与⑴,君子者乎?色庄者乎?”\n【译文】孔子说:“总是推许言论笃实的人,这种笃实的人是真正的君子呢?还是神情上伪装庄重的人呢?”\n【注释】⑴论笃是与——这是“与论笃”的倒装形式,“是”是帮助倒装之用的词,和“唯你是问”的“是”用法相同。“与”,许也。“论笃”就是“论笃者”的意思。\n11.22子路问:“闻斯行诸?”子曰:“有父兄在,如之何其闻斯行之?”\n冉有问:“闻斯行诸?”子曰:“闻斯行之。”\n公西华曰:“由也问闻斯行诸,子曰,‘有父兄在’,求也问闻斯行诸,子曰,‘闻斯行之’。赤也惑,敢问。”子曰:“求也退,故进之;由也兼人⑴,故退之。”\n【译文】子路问:“听到就干起来吗?”孔子道:“有爸爸哥哥活着,怎么能听到就干起来?”\n冉有问:“听到就干起来吗?”孔子道:“听到就干起来。”\n公西华道:“仲由问听到就干起来吗,您说‘有爸爸哥哥活着,[不能这样做;]’冉求问听到就干起来吗,您说‘听到就干起来。’[两个人问题相同,而您的答复相反,]我有些胡涂,大胆地来问问。”\n孔子道:“冉求平日做事退缩,所以我给他壮胆;仲由的胆量却有两个人的大,勇于作为,所以我要压压他。”\n【注释】⑴兼人——孔安国和朱熹都把“兼人”解为“胜人”,但子路虽勇,未必“务在胜尚人”;反不如张敬夫把“兼人”解为“勇为”为适当。\n11.23子畏于匡,颜渊后。子曰:“吾以女为死矣。”曰:“子在,回何敢死?”\n【译文】孔子在匡被囚禁了之后,颜渊最后才来。孔子道:“我以为你是死了。”颜渊道:“您还活着,我怎么敢死呢?”\n11.24季子然⑴问:“仲由、冉求可谓大臣与?”子曰:“吾以子为异之问,曾由与求之问。所谓大臣者,以道事君,不可则止。今由与求也,可谓具臣矣⑵。”\n曰:“然则从之者与?”子曰:“弑父与君,亦不从也。”\n【译文】季子然问:“仲由和冉求可以说是大臣吗”孔子道:“我以为你是问别的人,竟问由和求呀。我们所说的大臣,他用最合于仁义的内容和方式来对待君主,如果这样行不通,宁肯辞职不干。如今由和求这两个人,可以说是具有相当才能的臣属了。”\n季子然又道:“那么,他们会一切顺从上级吗?”孔子道:“杀父亲、杀君主的事情,他们也不会顺从的。”\n【注释】⑴季子然——当为季氏的同族之人,《史记·仲尼弟子列传》作“季孙问曰:子路可谓大臣与”,与《论语》稍异。⑵这一章可以和孔子不以仁来许他们的一章(5.8)以及季氏旅泰山冉有不救章(3.6)、季氏伐颛臾冉有子路为他解脱章(16.1)合看。\n11.25子路使子羔为费宰。子曰:“贼夫人之子。”\n子路曰:“有民人焉,有社禝焉,何必读书,然后为学?”\n子曰:“是故恶夫佞者。”\n【译文】子路叫子羔去做费县县长。孔子道:“这是害了别人的儿子!”\n子路道:“那地方有老百姓,有土地和五谷,为什么定要读书才叫做学问呢?”\n孔子道:“所以我讨厌强嘴利舌的人。”\n11.26子路、曾晳⑴、冉有、公西华侍坐。\n子曰:“以吾一日长乎尔,毋吾以也。居⑵则曰:‘不吾知也!’如或知尔,则何以哉?”\n子路率尔而对曰“千乘之国,摄乎大国之间,加之以师旅,因之以饥馑;由也为之,比⑶及三年,可使有勇,且知方也。”\n夫子哂之。\n“求!尔何如?”\n对曰:“方六七十⑷,如⑸五六十,求也为之,比⑶及三年,可使足民。如其礼乐,以俟君子。”\n“赤!尔何如?”\n对曰:“非曰能之,愿学焉。宗庙之事,如会同,端章甫⑹,愿为小相⑺焉。”\n“点!尔何如?”\n鼓瑟希,铿尔,舍瑟而作⑻,对曰:“异乎三子者之撰。”\n子曰:“何伤乎?亦各言其志也。”\n曰:“莫⑼春者,春服既成⑽,冠者五六人,童子六七人,浴乎沂⑾,风乎舞雩⑿,咏而归。”\n夫子喟然叹曰:“吾与点也!”\n三子者出,曾晳后。曾晳曰:“夫三子者之言何如?”\n子曰:“亦各言其志也已矣。”\n曰:“夫子何哂由也?”\n曰:“为国以礼,其言不让,是故哂之。”\n“唯⒀求则非邦也与?”\n“安见方六七十如五六十而非邦也者?”\n“唯赤则非邦也与?”\n“宗庙会同,非诸侯而何?赤也为之⒁小,孰能为之⒁大?”\n【译文】子路、曾晳、冉有、公西华四个人陪着孔子坐着。孔子说道:“因为我比你们年纪都大,[老了,]没有人用我了。你们平日说:‘人家不了解我呀!’假若有人了解你们,[打算请你们出去,]那你们怎么办呢?”\n子路不加思索地答道:“一千辆兵车的国家,局促地处于几个大国的中间,外面有军队侵犯它,国内又加以灾荒。我去治理,等到三年光景,可以使人人有勇气,而且懂得大道理。”\n孔子微微一笑。\n又问:“冉求,你怎么样?”\n答道:“国土纵横各六七十里或者五六十里的小国家,我去治理,等到三年光景,可以使人人富足。至于修明礼乐,那只有等待贤人君子了。”\n又问:“公西赤!你怎么样?”\n答道:“不是说我已经很有本领了,我愿意这样学习:祭祀的工作或者同外国盟会,我愿意穿着礼服,戴着礼帽,做一个小司仪者。”\n又问:“曾点!你怎么样?”\n他弹瑟正近尾声,铿的一声把瑟放下,站了起来答道:“我的志向和他们三位所讲的不同。”\n孔子道:“那有什么妨碍呢?正是要各人说出自己的志向呵!”\n曾晳便道:“暮春三月,春天衣服都穿定了,我陪同五六位成年人,六七个小孩,在沂水旁边洗洗澡,在舞雩台上吹吹风,一路唱歌,一路走回来。”\n孔子长叹一声道:“我同意曾点的主张呀!”子路、冉有、公西华三人都出来了,曾晳后走。曾晳问道:“那三位同学的话怎样?”\n孔子道:“也不过各人说说自己的志向罢了。”\n曾晳又道:“您为什么对仲由微笑呢?”\n孔子道:“治理国家应该讲求礼让,可是他的话却一点不谦虚,所以笑笑他。”\n“难道冉求所讲的就不是国家吗?”\n孔子道:“怎样见得横纵各六七十里或者五六十里的土地就不够是一个国家呢?”\n“公西赤所讲的不是国家吗?”\n孔子道:“有宗庙,有国际间的盟会,不是国家是什么?[我笑仲由的不是说他不能治理国家,关键不在是不是国家,而是笑他说话的内容和态度不够谦虚。譬如公西赤,他是个十分懂得礼仪的人,但他只说愿意学着做一个小司仪者。]如果他只做一小司仪者,又有谁来做大司仪者呢?”\n【注释】⑴曾晳——名点,曾参的父亲,也是孔子的学生。⑵居——义与唐、宋人口语“平居”同,平日、平常的意思。⑶比——去声,bì,等到的意思。⑷方六七十——这是古代的土地面积计算方式,“方六七十”不等于“六七十方里”,而是每边长六七十里的意思。⑸如——或者的意思。⑹端章甫——端,古代礼服之名;章甫,古代礼帽之名。“端章甫”为修饰句,在古代可以不用动词。⑺相——去声,名词,赞礼之人。⑻舍瑟而作——作,站起来的意思。曾点答孔子之问站了起来,其它学生也同样站了起来可以推知,不过上文未曾明说罢了。⑼莫——同“暮”。⑽成——定也。《国语·吴语》:“吴晋争长未成”,就是争为盟主而未定的意思。⑾沂——水名,但和大沂河以及流入于大沂河的小沂河都不同。这沂水源出山东邹县东北,西流经曲阜与洙水合,入于泗水。也就是《左传》昭公二十五年“季平子请待于沂上”的“沂”。⑿舞雩——《水经注》:“沂水北对稷门,一名高门,一名雩门。南隔水有雩坛,坛高三丈。卽曾点所欲风处也。”当在今曲阜县南。⒀唯——语首词,无义。⒁之——用法同“其”。\n"},{"id":111,"href":"/zh/docs/culture/%E8%AE%BA%E8%AF%AD/%E8%AE%BA%E8%AF%AD%E8%AF%91%E6%B3%A8_%E6%9D%A8%E4%BC%AF%E5%B3%BB/18%E5%BE%AE%E5%AD%90%E7%AF%87%E7%AC%AC%E5%8D%81%E5%85%AB/","title":"18微子篇第十八","section":"论语译注 杨伯峻","content":" 微子篇第十八 # (共十一章)\n18.1微子⑴去之,箕子为之奴⑵,比干谏而死⑶。孔子曰:“殷有三仁焉。”\n【译文】[纣王昏乱残暴,]微子便离开了他,箕子做了他的奴隶,比干谏劝而被杀。孔子说:“殷商末年有三位仁人。”\n【注释】⑴微子——名启,纣王的同母兄,不过当他出生时,他的母亲尚为帝乙之妾,其后才立为妻,然后生了纣,所以帝乙死后,纣得嗣立,而微子不得立。事见《吕氏春秋·仲冬纪》。古书中唯《孟子·告子篇》认为微子是纣的叔父。⑵箕子为之奴——箕子,纣王的叔父。纣王无道,他曾进谏而不听,便披发佯狂,降为奴隶。⑶比干谏而死——比干也是纣的叔父,力谏纣王,纣王说,我听说圣人的心有七个孔,便剖开他的心而死。\n18.2柳下惠为士师,三黜。人曰:“子未可以去乎?”曰:“直道而事人,焉往而不三黜?枉道而事人,何必去父母之邦?”\n【译文】柳下惠做法官,多次地被撤职。有人对他说:“您不可以离开鲁国吗?”他道:“正直地工作,到哪里去不多次被撤职?不正直地工作,为什么一定要离开祖国呢?”\n18.3齐景公待孔子曰:“若季氏,则吾不能;以季孟之间待之。”曰:“吾老矣,不能用也。”孔子行。\n【译文】齐景公讲到对待孔子的打算时说:“用鲁君对待季氏的模样对待孔子,那我做不到;我要用次于季氏而高于孟氏的待遇来对待他。”不久,又说道:“我老了,没有什么作为了。”孔子离开了齐国。\n18.4齐人归女乐⑴,季桓子⑵受之,三日不朝,孔子行。\n【译文】齐国送了许多歌姬舞女给鲁国,季桓子接受了,三天不问政事,孔子就离职走了。\n【注释】⑴齐人归女乐——“归”同“馈”。此事可参阅《史记·孔子世家》和《韩非子·内储说》。⑵季桓子——季孙斯,鲁国定公以至哀公初年时的执政上卿,死于哀公三年。\n18.5楚狂接舆⑴歌而过孔子曰:“凤兮凤兮!何德之衰?往者不可谏,来者犹可追⑵。已而,已而!今之从政者殆而!”\n孔子下,欲与之言。趋而辟之,不得与之言。\n【译文】楚国的狂人接舆一面走过孔子的车子,一面唱着歌,道:“凤凰呀,凤凰呀!为什么这么倒霉?过去的不能再挽回,未来的还可不再着迷。算了吧,算了吧!现在的执政诸公危乎其危!”\n孔子下车,想同他谈谈,他却赶快避开,孔子没法同他谈。\n【注释】⑴接舆——曹之升《四书摭余说》云:“《论语》所记隐士皆以其事名之。门者谓之‘晨门’,杖者谓之‘丈人’,津者谓之‘沮’、‘溺’,接孔子之舆者谓之‘接舆’,非名亦非字也。”⑵犹可追——赶得上、来得及的意思,译文因图押韵,故用意译法。\n18.6长沮、桀溺耦而耕⑴,孔子过之,使子路问津焉。\n长沮曰:“夫执舆⑵者为谁?”\n子路曰:“为孔丘。”\n曰:“是鲁孔丘与?”\n曰:“是也。”\n曰:“是知津矣。”\n问于桀溺。\n桀溺曰:“子为谁?”\n曰:“为仲由。”\n曰:“是鲁孔丘之徒与?”\n对曰:“然。”\n曰:“滔滔者天下皆是也,而谁以⑶易之?且而⑷与其从辟⑸人之士也,岂若从辟世之士哉?”耰⑹而不辍。\n子路行以告。\n夫子怃⑺然曰:“鸟兽不可与同羣,吾非斯人之徒与而谁与?天下有道,丘不与易也。”\n【译文】长沮、桀溺两人一同耕田,孔子在那儿经过,叫子路去问渡口。\n长沮问子路道:“那位驾车子的是谁?”\n子路道:“是孔丘。”\n他又道:“是鲁国的那位孔丘吗?”\n子路道:“是的。”\n他便道:“他么,早晓得渡口在哪儿了。”\n去问桀溺。\n桀溺道:“您是谁?”\n子路道:“我是仲由。”\n桀溺道:“您是鲁国孔丘的门徒吗?”\n答道:“对的。”\n他便道:“像洪水一样的坏东西到处都是,你们同谁去改革它呢?你与其跟着[孔丘那种]逃避坏人的人,为什么不跟着[我们这些]逃避整个社会的人呢?”说完,仍旧不停地做田里工夫。\n子路回来报告给孔子。\n孔子很失望地道:“我们既然不可以同飞禽走兽合羣共处,若不同人羣打交道,又同什么去打交道呢?如果天下太平,我就不会同你们一道来从事改革了。”\n【注释】⑴长沮、桀溺耦而耕——“长溺”“桀溺”不是真姓名。其姓名当时已经不暇询问,后世更无由知道了。耦耕是古代耕田的一种方法。春秋时代已经用牛耕田,不但由冉耕字伯牛、司马耕字子牛的现象可以看出,《国语·晋语》云:“其子孙将耕于齐,宗庙之牺为畎亩之勤”,尤为确证。耦耕的方法说法不少,都难说很精确。下文又说“耰而不辍”,则这耦耕未必是执耒,像夏炘学《礼管释·释二耜为耦》所说的。估计这个耦耕不过说二人做庄稼活罢了。1959年科学出版社《农史研究集刊》万国钧〈耦耕考〉对此有解释。上海中华书局《中华文史论丛》第三辑何兹全〈谈耦耕〉对万说有补充,也只能作参考。⑵执舆——就是执辔(拉马的缰绳)。本是子路做的,因子路已下车,所以孔子代为驾御。⑶以——与也,和下文“不可与同羣”,“斯人之徒与而谁与”,“丘不与易也”诸“与”字同义。⑷而——同“尔”。⑸辟——同“避”。⑹耰——音忧,yōu,播种之后,再以土覆之,摩而平之,使种入土,鸟不能啄,这便叫耰。⑺怃——音舞。wǔ,怃然,怅惘失意之貌。\n18.7子路从而后,遇丈人,以杖荷筱⑴。\n子路问曰:“子见夫子乎?”\n丈人曰:“四体不勤,五谷不分⑵。孰为夫子?”植其杖而芸。\n子路拱而立。\n止子路宿,杀鸡为黍⑶而食之,见其二子焉。\n明日,子路行以告。\n子曰:“隐者也。”使子路反见之。至,则行矣。\n子路曰:“不仕无义。长幼之节,不可废也;君臣之义,如之何其废之?欲洁其身,而乱大伦。君子之仕也,行其义也。道之不行,已知之矣。”\n【译文】子路跟随着孔子,却远落在后面,碰到一个老头,用拐杖挑着除草用的工具。\n子路问道:“您看见我的老师吗?”\n老头道:“你这人,四肢不劳动,五谷不认识,谁晓得你的老师是什么人?”说完,便扶着拐杖去锄草。\n子路拱着手恭敬地站着。\n他便留子路到他家住宿,杀鸡、作饭给子路吃,又叫他两个儿子出来相见。\n第二天,子路赶上了孔子,报告了这件事。\n孔子道:“这是位隐士。”叫子路回去再看看他。子路到了那里,他却走开了。\n子路便道:“不做官是不对的。长幼间的关系,是不可能废弃的;君臣间的关系,怎么能不管呢?你原想不沾污自身,却不知道这样隐居便是忽视了君臣间的必要关系。君子出来做官,只是尽应尽之责。至于我们的政治主张行不通,早就知道了。”\n【注释】⑴筱——音掉,diào,古代除田中草所用的工具。说文作“莜”。⑵四体不勤,五谷不分——这二句,宋吕本中《紫微杂说》以至清朱彬《经传考证》、宋翔凤《论语发微》都说是丈人说自己。其余更多人主张说是丈人责子路。译文从后说。⑶为黍——黍就是现在的黍子,也叫黄米。它比当时的主要食粮稷(小米)的收获量小,因此在一般人中也算是比较珍贵的主食。杀鸡做菜,为黍做饭,这在当时是很好的招待了。\n18.8逸⑴民:伯夷、叔齐、虞仲、夷逸、朱张、柳下惠、少连⑵。子曰:“不降其志,不辱其身,伯夷、叔齐与!”谓“柳下惠、少连,降志辱身矣,言中伦,行中虑,其斯而已矣。”谓“虞仲、夷逸,隐居放言,身中清,废中权。我则异于是,无可无不可。”\n【译文】古今被遗落的人才有伯夷、叔齐、虞仲、夷逸、朱张、柳下惠、少连。孔子道:“不动摇自己意志,不辱没自己身份,是伯夷、叔齐罢!”又说,“柳下惠、少连降低自己意志,屈辱自己身份了,可是言语合乎法度,行为经过思虑,那也不过如此罢了。”又说:“虞仲、夷逸逃世隐居,放肆直言。行为廉洁,被废弃也是他的权术。我就和他们这些人不同,没有什么可以,也没有什么不可以。”\n【注释】⑴逸——同“佚”,《论语》两用“逸民”,义都如此。《孟子·公孙丑上》云:“柳下惠……遗佚而不怨,阨穷而不闵。”这一“逸”正是《孟子》“遗佚”之义。说本黄式三《论语后案》。⑵虞仲、夷逸、朱张、少连——四人言行多已不可考。虞仲前人认为就是吴太伯之弟仲雍,不可信。夷逸曾见《尸子》,有人劝他做官,他不肯。少连曾见《礼记·杂记》,孔子说他善于守孝。夏炘《景紫堂文集》卷三有〈逸民虞仲、夷逸、朱张皆无考说〉,于若干附会之说有所驳正。\n18.9大师挚⑴适齐,亚饭干适楚,三饭缭适蔡,四饭缺适秦⑵,鼓方叔入于河,播鼗武入于汉,少师阳、击磬襄入于海。\n【译文】太师挚逃到了齐国,二饭乐师干逃到了楚国,三饭乐师缭逃到了蔡国,四饭乐师缺逃到了秦国,打鼓的方叔入居黄河之滨,摇小鼓的武入居汉水之涯,少师阳和击磬的襄入居海边。\n【注释】⑴大师挚——泰伯篇第八有“师挚之始”,不知是不是此人。⑵亚饭——古代天子诸侯用饭都得奏乐,所以乐官有“亚饭”、“三饭”、“四饭”之名。这些人究竟是何时人,已经无法肯定。\n18.10周公谓鲁公⑴曰:“君子不施⑵其亲,不使大臣怨乎不以。故旧无大故,则不弃也。无求备于一人!”\n【译文】周公对鲁公说道:“君子不怠慢他的亲族,不让大臣抱怨没被信用。老臣故人没有发生严重过失,就不要抛弃他。不要对某一人求全责备!”\n【注释】⑴周公、鲁公——周公,周公旦,孔子心目中的圣人。鲁公是他的儿子伯禽。⑵施——同“弛”,有些本子卽作“弛”。\n18.11周有八士:伯达、伯适、仲突、仲忽、叔夜、叔夏、季随、季騧⑴。\n【译文】周朝有八个有教养的人:伯达、伯适、仲突、仲忽、叔夜、叔夏、季随、季騧。\n【注释】⑴伯达等八人——此八人已经无可考。前人看见此八人两人一列,依伯、仲、叔、季排列,而且各自押韵(达适一韵,突忽一韵,夜夏一韵,随騧一韵),便说这是四对双生子。\n"},{"id":112,"href":"/zh/docs/culture/%E8%AE%BA%E8%AF%AD/%E8%AE%BA%E8%AF%AD%E8%AF%91%E6%B3%A8_%E6%9D%A8%E4%BC%AF%E5%B3%BB/02%E4%B8%BA%E6%94%BF%E7%AF%87%E7%AC%AC%E4%BA%8C/","title":"02为政篇第二","section":"论语译注 杨伯峻","content":" 为政篇第二 # (共二十四章)\n2.1子曰:“为政以德,譬如北辰⑴居其所而众星共⑵之。”\n【译文】孔子说:“用道德来治理国政,自己便会像北极星一般,在一定的位置上,别的星辰都环绕着它。”\n【注释】⑴北辰——由于地球自转轴正对天球北极,在地球自转和公转所反映出来的恒星周日和周年视运动中,天球北极是不动的,其它恒星则绕之旋转。我国黄河中、下游流域,约为北纬36度,因之天球北极也高出北方地平线上36度。孔子所说的北辰,不是指天球北极,而是指北极星。天球北极虽然不动,其它星辰都环绕着它动,但北极星也是动的,而且转动非常快。祗是因为它距离地球太远,约782光年,人们不觉得它移动罢了。距今四千年前北极在右枢(天龙座α)附近,今年则在勾陈一(小熊座α)。⑵共——同拱,与《左传》僖公三十二年“尔墓之木拱矣”的“拱”意义相近,环抱、环绕之意。\n2.2子曰:“诗三百⑴,一言以蔽之,曰:‘思无邪⑵’。”\n【译文】孔子说:“《诗经》三百篇,用一句话来概括它,就是‘思想纯正’。”\n【注释】⑴诗三百——《诗经》实有三百五篇,“三百”只是举其整数。⑵思无邪——“思无邪”一语本是《诗经·鲁颂·駉篇》之文,孔子借它来评论所有诗篇。思字在《駉篇》本是无义的语首词,孔子引用它却当思想解,自是断章取义。俞樾《曲园杂纂》说项说这也是语辞,恐不合孔子原意。\n2.3子曰:“道⑴之以政,齐之以刑,民免⑵而无耻;道之以德,齐之以礼,有耻且格⑶。”\n【译文】孔子说:“用政法来诱导他们,使用刑罚来整顿他们,人民只是暂时地免于罪过,却没有廉耻之心。如果用道德来诱导他们,使用礼教来整顿他们,人民不但有廉耻之心,而且人心归服。”\n【注释】⑴道——有人把它看成“道千乘之国”的“道”一样,治理的意思。也有人把它看成“导”字,引导的意思,我取后一说。⑵免——先秦古书若单用一个“免”字,一般都是“免罪”、“免刑”、“免祸”的意思。⑶格——这个字的意义本来很多,在这里有把它解为“来”的,也有解为“至”的,还有解为“正”的,更有写作“恪”,解为“敬”的。这些不同的讲解都未必符合孔子原意。《礼记·缁衣篇》:“夫民,教之以德,齐之以礼,则民有格心;教之以政,齐之以刑,则民有遯心。”这话可以看作孔子此言的最早注释,较为可信。此处“格心”和“遯心”相对成文,“遯”卽“遁”字,逃避的意思。逃避的反面应该是亲近、归服、向往,所以用“人心归服”来译它。\n2.4子曰:“吾十有⑴五而志于学,三十而立⑵,四十而不惑⑶,五十而知天命⑷,六十而耳顺⑸,七十而从心所欲,不踰矩⑹。”\n【译文】孔子说:“我十五岁,有志于学问;三十岁,[懂礼仪,]说话做事都有把握;四十岁,[掌握了各种知识,]不致迷惑;五十岁,得知天命;六十岁,一听别人言语,便可以分别真假,判明是非;到了七十岁,便随心所欲,任何念头不越出规矩。”\n【注释】⑴有——同又。古人在整数和小一位的数字之间多用“有”字,不用“又”字。⑵立——泰伯篇说:“立于礼。”季氏篇又说:“不学礼,无以立。”因之译文添了“懂得礼仪”几个字。“立”是站立的意思,这里是“站得住”的意思,为求上下文的流畅,意译为遇事“都有把握”。⑶不惑——子罕篇和宪问篇都有“知者不惑”的话,所以译文用“掌握了知识”来说明“不惑”。⑷天命——孔子不是宿命论者,但也讲天命。孔子的天命,我已有文探讨。后来的人虽然谈得很多,未必符合孔子本意。因此,这两个字暂不译出。⑸耳顺——这两个字很难讲,企图把它讲通的也有很多人,但都觉牵强。译者姑且作如此讲解。⑷从心所欲不踰矩——“从”字有作“纵”字的,皇侃《义疏》也读为“纵”,解为放纵。柳宗元〈与杨晦之书〉说“孔子七十而纵心”,不但“从”字写作“纵”,而且以“心”字绝句,“所欲”属下读。“七十而纵心,所欲不踰矩”。但“纵”字古人多用于贬义,如《左传》昭公十年“我实纵欲”,柳读难从。\n2.5孟懿子⑴问孝。子曰:“无违⑵。”\n樊迟⑶御,子告之曰:“孟孙问孝于我,我对曰,无违。”樊迟曰:“何谓也?”子曰:“生,事之以礼⑷;死,葬之以礼,祭之以礼。”\n【译文】孟懿子向孔子问孝道。孔子说:“不要违背礼节。”不久,樊迟替孔子赶车子,孔子便告诉他说:“孟孙向我问孝道,我答复说,不要违背礼节。”樊迟道:“这是什么意思?”孔子道:“父母活着,依规定的礼节侍奉他们;死了,依规定的礼节埋葬他们,祭祀他们。”\n【注释】⑴孟懿子——鲁国的大夫,三家之一,姓仲孙,名何忌,“懿”是谥号。他父亲是孟僖子仲孙貜。《左传》昭公七年说,孟僖子将死,遗嘱要他向孔子学礼。⑵无违——黄式三《论语后案》说:“《左传》桓公二年云,‘昭德塞违’,‘灭德立违’,‘君违,不忘谏之以德’;六年传云:‘有嘉德而无违心’,襄公二十六年传云,‘正其违而治其烦’……古人凡背礼者谓之违。”因此,我把“违”译为“违礼”。王充《论衡·问孔篇》曾经质问孔子,为什么不讲“无违礼”,而故意省略讲为“无违”,难道不怕人误会为“毋违志”吗?由此可见“违”字的这一含义在后汉时已经不被人所了解了。⑶樊迟——孔子学生,名须,字子迟,比孔子小四十六岁。[《史记·仲尼弟子列传》作小三十六岁,《孔子家语》作小四十六岁。若从《左传》哀公十一年所记载的樊迟的事考之,可能《史记》的“三”系“亖”(古四字)之误。]⑷生,事之以礼——“生”和下句“死”都是表示时间的节缩语,所以自成一逗。古代的礼仪有一定的差等,天子、诸侯、大夫、士、庶人各不相同。鲁国的三家是大夫,不但有时用鲁公(诸侯)之礼,甚至有时用天子之礼。这种行为当时叫做“僭”,是孔子所最痛心的。孔子这几句答语,或者是针对这一现象发出的。\n2.6孟武伯⑴问孝。子曰:“父母唯其⑵疾之忧。”\n【译文】孟武伯向孔子请教孝道。孔子道:“做爹娘的只是为孝子的疾病发愁。”\n【注释】⑴孟武伯——仲孙彘,孟懿子的儿子,“武”是谥号。⑵其——第三人称表示领位的代名词,相当于“他的”、“他们的”。但这里所指代的是父母呢,还是儿女呢?便有两说。王充《论衡·问孔篇》说:“武伯善忧父母,故曰,唯其疾之忧。”《淮南子·说林训》说:“忧父之疾者子,治之者医。”高诱注云:“父母唯其疾之忧,故曰忧之者子。”可见王充、高诱都以为“其”字是指代父母而言。马融却说:“言孝子不妄为非,唯疾病然后使父母忧。”把“其”字代孝子。两说都可通,而译文采取马融之说。\n2.7子游⑴问孝。子曰:“今之孝者,是谓能养⑵。至于⑶犬马,皆能有养⑷;不敬,何以别乎?”\n【译文】子游问孝道。孔子说:“现在的所谓孝,就是说能够养活爹娘便行了。对于狗马都能够得到饲养;若不存心严肃地孝顺父母,那养活爹娘和饲养狗马怎样去分别呢?”\n【注释】⑴子游——孔子学生,姓言,名偃,字子游,吴人,小于孔子四十五岁。⑵养——“养父母”的“养”从前人都读去声,音漾,yàng。⑶至于——张相的《诗词曲语词汇释》把“至于”解作“卽使”、“就是”。在这一段中固然能够讲得文从字顺,可是“至于”的这一种用法,在先秦古书中仅此一见,还难于据以肯定。我认为这一“至于”和《孟子·告子上》的“惟耳亦然。至于声,天下期于师旷,是天下之耳相似也。惟目亦然。至于子都,天下莫不知其姣也。”的“至于”用法相似。都可用“谈到”、“讲到”来译它。不译也可。⑷至于犬马皆能有养——这一句很有些不同的讲法。一说是犬马也能养活人,人养活人,若不加以敬,便和犬马的养活人无所分别。这一说也通。还有一说是犬马也能养活它自己的爹娘(李光地《论语剳记》、翟灏《四书考异》),可是犬马在事实上是不能够养活自己爹娘的,所以这说不可信。还有人说,犬马是比喻小人之词(刘宝楠《论语正义》引刘宝树说),可是用这种比喻的修辞法,在《论语》中找不出第二个相似的例子,和《论语》的文章风格不相侔,更不足信。\n2.8子夏问孝。子曰:“色难⑴。有事,弟子⑵服其劳;有酒食⑶,先生馔⑷,曾⑸是以为孝乎?”\n【译文】子夏问孝道。孔子道:“儿子在父母前经常有愉悦的容色,是件难事。有事情,年轻人效劳;有酒有肴,年长的人吃喝,难道这竟可认为是孝么?”\n【注释】⑴色难——这句话有两说,一说是儿子侍奉父母时的容色。《礼记·祭义篇》说:“孝子之有深爱者必有和气,有和气者必有愉色,有愉色者必有婉容。”可以做这两个字的注脚。另一说是侍奉父母的容色,后汉的经学家包咸、马融都如此说。但是,若原意果如此的话,应该说为“侍色为难”,不该简单地说为“色难”,因之我不采取。⑵弟子、先生——刘台拱《论语骈枝》云:“《论语》言‘弟子’者七,其二皆年幼者,其五谓门人。言‘先生’者二、皆谓年长者。”马融说:“先生谓父兄也。”亦通。⑶食——旧读去声,音嗣,sì,食物。不过现在仍如字读shí,如“主食”、“副食”、“面食”。⑷馔——zhuàn,吃喝。《鲁论》作“馂”。馂,食余也。那么这句便当如此读:“有酒,食先生馂”,而如此翻译:“有酒,幼辈吃其剩余。”⑸曾——音层,céng,副词,竟也。\n2.9子曰:“吾与回⑴言终日,不违,如愚。退而省其私⑵,亦足以发,回也不愚。”\n【译文】孔子说:“我整天和颜回讲学,他从不提反对意见和疑问,像个蠢人。等他退回去自己研究,却也能发挥,可见颜回并不愚蠢。”\n【注释】⑴回——颜回,孔子最得意的学生,鲁国人,字子渊,小孔子三十岁(《史记·仲尼弟子列传》如此。但根据毛奇龄《论语稽求篇》和崔适《论语足征记》的考证,《史记》的“三十”应为“四十”之误,颜渊实比孔子小四十岁,公元前511—480)。⑵退而省其私——朱熹的《集注》以为孔子退而省颜回的私,“则见其日用动静语默之间皆足以发明夫子之道。”用颜回的实践来证明他能发挥孔子之道,说也可通。\n2.10子曰:“视其所以⑴,观其所由⑵,察其所安⑶。人焉廋哉⑷?人焉廋哉?”\n【译文】孔子说:“考查一个人所结交的朋友;观察他为达到一定目的所采用的方式方法;了解他的心情,安于什么,不安于什么。那么,这个人怎样隐藏得住呢?这个人怎样隐藏得住呢?”\n【注释】⑴所以——“以”字可以当“用”讲,也可以当“与”讲。如果解释为“用”,便和下句“所由”的意思重复,因此我把它解释为“与”,和微子篇第十八“而谁以易之”的“以”同义。有人说“以犹为也”。“视其所以”卽《大戴礼·文王官人篇》的“考其所为”,也通。⑵所由——“由”,“由此行”的意思。学而篇第一的“小大由之”,雍也篇第六的“行不由径”,泰伯篇第八的“民可使由之”的“由”都如此解。“所由”是指所从由的道路,因此我用方式方法来译述。⑶所安——“安”就是阳货篇第十七孔子对宰予说的“女安,则为之”的“安”。一个人未尝不错做一两件坏事,如果因此而心不安,仍不失为好人。因之译文多说了几句。⑷人焉廋哉——焉,何处;廋,音搜,sōu,隐藏,藏匿。这句话机械地翻译,便是:“这个人到哪里去隐藏呢。”《史记·魏世家》述说李克的观人方法是“居视其所亲,富视其所与,达视其所举,穷视其所不为,贫视其所不取”。虽较具体,却无此深刻。\n2.11子曰:“温故而知新⑴,可以为师矣。”\n【译文】孔子说:“在温习旧知识时,能有新体会、新发现,就可以做老师了。”\n【注释】⑴温故而知新——皇侃《义疏》说,“温故”就是“月无忘其所能”,“知新”就是“日知其所亡”(19.5),也通。\n2.12子曰:“君子不器⑴。”\n【译文】孔子说:“君子不像器皿一般,[只有一定的用途。]”\n【注释】⑴古代知识范围狭窄,孔子认为应该无所不通。后人还曾说,一事之不知,儒者之耻。虽然有人批评孔子“博学而无所成名”(9.2),但孔子仍说“君子不器”。\n2.13子贡问君子。子曰:“先行其言而后从之。”\n【译文】子贡问怎样才能做一个君子。孔子道:“对于你要说的话,先实行了,再说出来[这就够说是一个君子了]。”\n2.14子曰:“君子周而不比⑴,小人比而不周。”\n【译文】孔子说:“君子是团结,而不是勾结;小人是勾结,而不是团结。”\n【注释】⑴周、比——“周”是以当时所谓道义来团结人,“比”则是以暂时共同利害互相勾结。“比”旧读去声bì。\n2.15子曰:“学而不思则罔⑴,思而不学则殆⑵。”\n【译文】孔子说:“只是读书,却不思考,就会受骗;只是空想,却不读书,就会缺乏信心。”\n【注释】⑴罔——诬罔的意思。“学而不思”则受欺,似乎是《孟子·尽心下》“尽信书,不如无书”的意思。⑵殆——《论语》的“殆”(dài)有两个意义。下文第十八章“多见阙殆”的“殆”当“疑惑”解(说本王引之《经义述闻》),微子篇“今之从政者殆而”的“殆”当危险解。这里两个意义都讲得过去,译文取前一义。古人常以“罔”“殆”对文,如《诗经·小雅·节南山》云:“弗问弗仕,勿罔君子,式夷式己,无小人殆。”(“无小人殆”卽“无殆小人”,因韵脚而倒装。)旧注有以“罔然无所得”释“罔”,以“精神疲殆”释“殆”的,似乎难以圆通。\n2.16子曰:“攻⑴乎异端⑵,斯⑶害也已⑷。”\n【译文】孔子说:“批判那些不正确的议论,祸害就可以消灭了。”\n【注释】⑴攻——《论语》共享四次“攻”字,像先进篇的“小子鸣鼓而攻之”,颜渊篇的“攻其恶,无攻人之恶”的三个“攻”字都当“攻击”解,这里也不应例外。很多人却把它解为“治学”的“治”。⑵异端——孔子之时,自然还没有诸子百家,因之很难译为“不同的学说”,但和孔子相异的主张、言论未必没有,所以译为“不正确的议论”。⑶斯——连词,“这就”的意思。⑷已——应该看为动词,止也。因之我译为“消灭”。如果把“攻”字解为“治”,那么“斯”字得看作指代词,“这”的意思;“也已”得看作语气词。全文便如此译:“从事于不正确的学术研究,这是祸害哩。”一般的讲法是如此的,虽能文从字顺,但和《论语》词法和句法都不合。\n2.17子曰:“由⑴!诲女知之乎!知之为知之,不知为不知,是知也⑵。”\n【译文】孔子说:“由!教给你对待知或不知的正确态度吧!知道就是知道,不知道就是不知道,这就是聪明智慧。”\n【注释】⑴由——孔子学生,仲由,字子路,卞(故城在今山东泗水县东五十里)人,小于孔子九岁。(公元前542—480)⑵是知也——《荀子·子道篇》也载了这一段话,但比这详细。其中有两句道:“言要则知,行至则仁。”因之读“知”为“智”。如果“知”如字读,便该这样翻译:这就是对待知或不知的正确态度。\n2.18子张⑴学干禄⑵。子曰:“多闻阙疑,慎言其余,则寡尤;多见阙殆⑶,慎行其余,则寡悔。言寡尤,行⑷寡悔,禄在其中矣。”\n【译文】子张向孔子学求官职得俸禄的方法。孔子说:“多听,有怀疑的地方,加以保留;其余足以自信的部分,谨慎地说出,就能减少错误。多看,有怀疑的地方,加以保留;其余足以自信的部分,谨慎地实行,就能减少懊悔。言语的错误少,行动的懊悔少,官职俸禄就在这里面了。”\n【注释】⑴子张——孔子学生颛孙师,字子张,陈人,小于孔子四十八岁。(公元前503—?)⑵干禄——干,求也,禄,旧时官吏的俸给。⑶阙殆——和“阙疑”同意。上文作“阙疑”,这里作“阙殆”。“疑”和“殆”是同义词,所谓“互文”见义。⑷行——名词,去声,xìng。\n2.19哀公⑴问曰:“何为则民服?”孔子对曰⑵:“举直错诸枉⑶,则民服;举枉错诸直,则民不服。”\n【译文】鲁哀公问道:“要做些甚么事才能使百姓服从呢?”,孔子答道:“把正直的人提拔出来,放在邪曲的人之上,百姓就服从了;若是把邪曲的人提拔出来,放在正直的人之上,百姓就会不服从。”\n【注释】⑴哀公——鲁君,姓姬,名蒋,定公之子,继定公而卽位,在位二十七年。(公元前494—466)“哀”是谥号。⑵孔子对曰——《论语》的行文体例是,臣下对答君上的询问一定用“对曰”,这里孔子答复鲁君之问,所以用“孔子对曰”。⑶错诸枉——“错”有放置的意思,也有废置的意思。一般人把它解为废置,说是“废置那些邪恶的人”(把“诸”字解为“众”)。这种解法和古汉语语法规律不相合。因为“枉”、“直”是以虚代实的名词,古文中的“众”、“诸”这类数量形容词,一般只放在真正的实体词之上,不放在这种以虚代实的词之上。这一规律,南宋人孙季和(名应时)便已明白。王应麟《困学纪闻》曾引他的话说:“若诸家解,何用二‘诸’字?”这二“诸”字只能看做“之于”的合音,“错”当“放置”解。“置之于枉”等于说“置之于枉人之上”,古代汉语“于”字之后的方位词有时可以省略。朱亦栋《论语札记》解此句不误。\n2.20季康子⑴问:“使民敬、忠以⑵劝,如之何?”子曰:“临之以庄,则敬;孝慈,则忠;举善而教不能,则劝。”\n【译文】季康子问道:“要使人民严肃认真,尽心竭力和互相勉励,应该怎么办呢?”孔子说:“你对待人民的事情严肃认真,他们对待你的政令也会严肃认真了;你孝顺父母,慈爱幼小,他们也就会对你尽心竭力了;你提拔好人,教育能力弱的人,他们也就会劝勉了。”\n【注释】⑴季康子——季孙肥,鲁哀公时正卿,当时政治上最有权力的人。“康”是谥号。⑵以——连词,与“和”同。\n2.21或谓孔子曰:“子奚不为政?”子曰:“《书》⑴云:‘孝乎惟孝,友于兄弟,施⑵于有政⑶。’是亦为政,奚其为为政?”\n【译文】有人对孔子道:“你为什么不参与政治?”孔子道:“《尚书》上说,‘孝呀,只有孝顺父母,友爱兄弟,把这种风气影响到政治上去。’这也就是参与政治了呀,为什么定要做官才算参与政治呢?”\n【注释】⑴书云——以下三句是《尚书》的逸文,作《伪古文尚书》的便从这里采入《君陈篇》。⑵施——这里应该当“延及”讲,从前人解为“施行”,不妥。⑶施于有政——“有”字无义,加于名词之前,这是古代构词法的一种形态,详拙著《文言语法》。杨遇夫先生说:“政谓卿相大臣,以职言,不以事言。”(说详增订《积微居小学金石论丛·〈论语〉子奚不为政解》)那么。这句话便当译为“把这种风气影响到卿相大臣上去”。\n2.22子曰:“人而无信⑴,不知其可也。大车无輗,小车无軏⑵,其何以行之哉?”\n【译文】孔子说:“做为一个人,却不讲信誉,不知那怎么可以。譬如大车子没有安横木的輗,小车子没有安横木的軏,如何能走呢?”\n【注释】⑴人而无信——这“而”字不能当“如果”讲。不说“人无信”,而说“人而无信”者,表示“人”字要作一读。古书多有这种句法,译文似能表达其意。⑵輗、軏——輗音倪,ní;軏音月,yuè。古代用牛力的车叫大车,用马力的车叫小车。两者都要把牲口套在车辕上。车辕前面有一道横木,就是驾牲口的地方。那横木,大车上的叫做鬲,小车上的叫做衡。鬲、衡两头都有关键(活销),輗就是鬲的关键,軏就是衡的关键。车子没有它,自然无法套住牲口,那怎么能走呢?\n2.23子张问:“十世可知也⑴?”子曰:“殷因于夏礼,所损益,可知也;周因于殷礼,所损益,可知也。其或继周者,虽百世,可知也。”\n【译文】子张问:“今后十代[的礼仪制度]可以预先知道吗?”孔子说:“殷朝沿袭夏朝的礼仪制度,所废除的,所增加的,是可以知道的;周朝沿袭殷朝的礼仪制度,所废除的,所增加的,也是可以知道的。那么,假定有继承周朝而当政的人,就是以后一百代,也是可以预先知道的。”\n【注释】⑴十世可知也——从下文孔子的答语看来,便足以断定子张是问今后十代的礼仪制度,而不是泛问,所以译文加了几个字。这“也”字同“耶”,表疑问。\n2.24子曰:“非其鬼⑴而祭⑵之,谄⑶也。见义不为,无勇也。”\n【译文】孔子说:“不是自己应该祭祀的鬼神,却去祭祀他,这是献媚。眼见应该挺身而出的事情,却袖手旁观,这是怯懦。”\n【注释】⑴鬼——古代人死都叫“鬼”,一般指已死的祖先而言,但也偶有泛指的。⑵祭——祭是吉祭,和凶祭的奠不同(人初死,陈设饮食以安其灵魂,叫做奠)。祭鬼的目的一般是祈福。⑶谄——chǎn,谄媚,阿谀。\n"},{"id":113,"href":"/zh/docs/culture/%E8%AE%BA%E8%AF%AD/%E8%AE%BA%E8%AF%AD%E8%AF%91%E6%B3%A8_%E6%9D%A8%E4%BC%AF%E5%B3%BB/15%E5%8D%AB%E7%81%B5%E5%85%AC%E7%AF%87%E7%AC%AC%E5%8D%81%E4%BA%94/","title":"15卫灵公篇第十五","section":"论语译注 杨伯峻","content":" 卫灵公篇第十五 # (共四十二章(朱熹《集注》把第一、第二两章并为一章,所以说“凡四十一章”。))\n15.1卫灵公问陈⑴于孔子。孔子对曰:“俎豆⑵之事,则尝闻之矣;军旅之事,未之学也。”明日遂行。\n【译文】卫灵公向孔子问军队陈列之法。孔子答道:“礼仪的事情,我曾经听到过;军队的事情,从来没学习过。”第二天便离开卫国。\n【注释】⑴陈——就是今天的“阵”字。⑵俎豆之事——俎和豆都是古代盛肉食的器皿,行礼时用它,因之藉以表示礼仪之事。这种用法和泰伯篇第八的“笾豆之事”相同。\n15.2在陈绝粮,从者病,莫能兴。子路愠见曰:“君子亦有穷乎?”子曰:“君子固穷,小人穷斯滥矣。”\n【译文】孔子在陈国断绝了粮食,跟随的人都饿病了,爬不起床来。子路很不高兴地来见孔子,说道:“君子也有穷得毫无办法的时候吗?”孔子道:“君子虽然穷,还是坚持着;小人一穷便无所不为了。”\n15.3子曰:“赐也,女以予为多学而识之者与?”对曰:“然,非与?”曰:“非也,予一以贯之⑴。”\n【译文】孔子道:“赐!你以为我是多多地学习又能够记得住的吗?”子贡答道:“对呀,难道不是这样吗?”孔子道:“不是的,我有一个基本观念来贯串它。”\n【注释】⑴一以贯之——这和里仁篇的“夫子之道,忠恕而已矣”(4.15)的“一贯”相同。从这里可以看出,子贡他们所重视的,是孔子的博学多才,因之认为他是“多学而识之”;而孔子自己所重视的,则在于他的以忠恕之道贯穿于其整个学行之中。\n15.4子曰:“由!知德者鲜矣。”\n【译文】孔子对子路道:“由!懂得‘德’的人可少啦。”\n15.5子曰:“无为而治⑴者其舜也与?夫何为哉?恭己正南面而已矣。”\n【译文】孔子说:“自己从容安静而使天下太平的人大概只有舜罢?他干了什么呢?庄严端正地坐朝廷罢了。”\n【注释】⑴无为而治——舜何以能如此?一般儒者都以为他能“所任得其人,故优游而自逸也。”(《三国志·吴志·楼玄传》)如《大戴礼·主言篇》云:“昔者舜左禹而右皋陶,不下席而天下治。”《新序·杂事三》云:“故王者劳于求人,佚于得贤。舜举众贤在位,垂衣裳恭己无为而天下治。”赵岐《孟子注》也说:“言任官得其人,故无为而治”。\n15.6子张问行。子曰:“言忠信,行笃敬,虽蛮貊之邦,行矣。言不忠信,行不笃敬,虽州里,行乎哉?立则见其参于前也,在舆则见其倚于衡也,夫然后行。”子张书诸绅。\n【译文】子张问如何才能使自己到处行得通。孔子道:“言语忠诚老实,行为忠厚严肃,纵到了别的部族国家,也行得通。言语欺诈无信,行为刻薄轻浮,就是在本乡本土,能行得通吗?站立的时候,就[彷佛]看见“忠诚老实忠厚严肃”几个字在我们面前;在车箱里,也[彷佛]看见它刻在前面的横木上;[时时刻刻记着它,]这才能使自己到处行得通。”子张把这些话写在大带上。\n15.7子曰:“直哉史鱼⑴!邦有道,如矢;邦无道,如矢。君子哉蘧伯玉⑵!邦有道,则仕;邦无道,则可卷而怀之。”\n【译文】孔子说:“好一个刚直不屈的史鱼!政治清明也像箭一样直,政治黑暗也像箭一样直。好一个君子蘧伯玉!政治清明就出来做官,政治黑暗就可以把自己的本领收藏起来。”\n【注释】⑴史鱼——卫国的大夫史鳅,字子鱼。他临死时嘱咐他的儿子,不要“治丧正室”,以此劝告卫灵公进用蘧伯玉,斥退弥子瑕,古人叫为“尸谏”,事见《韩诗外传》卷七。⑵蘧伯玉——事可参见《左传》襄公十四年和二十六年。\n15.8子曰:“可与言而不与之言,失人;不可与言而与之言,失言。知者不失人,亦不失言。”\n【译文】孔子说:“可以同他谈,却不同他谈,这是错过人才;不可以同他谈,却同他谈,这是浪费言语。聪明人既不错过人才,也不浪费言语。”\n15.9子曰:“志士仁人,无求生以害仁,有杀身以成仁。”\n【译文】孔子说:“志士仁人,不贪生怕死因而损害仁德,只勇于牺牲来成全仁德。”\n15.10子贡问为仁。子曰:“工欲善其事,必先利其器。居是邦也,事其大夫之贤者,友其士⑴之仁者。”\n【译文】子贡问怎样去培养仁德。孔子道:“工人要搞好他的工作,一定先要搞好他的工具。我们住在一个国家,就要敬奉那些大官中的贤人,结交那些士人中的仁人。”\n【注释】⑴士——《论语》中的“士”,有时指有一定修养的人,如“士志于道”(4.9)的“士”。有时指有一定社会地位的人。如“使于四方,不辱君命,可调士矣”的“士”(13.20)。此处和“大夫”并言,可能是“士、大夫”之“士”,卽已做官而位置下于大夫的人。\n15.11颜渊问为邦。子曰:“行夏之时⑴,乘殷之辂⑵,服周之冕⑶,乐则韶、舞⑷。放郑声⑸,远佞人。郑声淫,佞人殆。”\n【译文】颜渊问怎样去治理国家。孔子道:“用夏朝的历法,坐殷朝的车子,戴周朝的礼帽,音乐就用韶和武。舍弃郑国的乐曲,斥退小人。郑国的乐曲靡曼淫秽,小人危险。”\n【注释】⑴行夏之时——据古史记载,夏朝用的自然历,以建寅之月(旧历正月)为每年的第一月,春、夏、秋、冬合乎自然现象。周朝则以建子之月(旧历十一月)为每年的第一月,而且以冬至日为元日。这个虽然在观测天象方面比较以前进步,但实用起来却不及夏历方便于农业生产。就是在周朝,也有很多国家是仍旧用夏朝历法。⑵乘殷之辂——辂音路,商代的车子,比周代的车子自然朴质些。所以《左传》桓公二年也说:“大辂、越席,昭其俭也。”⑵服周之冕——周代的礼帽自然又比以前的华美,孔子是不反对礼服的华美的,赞美禹“致美乎黻冕”可见。⑷韶、舞——韶是舜时的音乐,“舞”同“武”,周武王时的音乐。⑸放郑声——“郑声”和“郑诗”不同。郑诗指其文辞,郑声指其乐曲。说本明人杨慎《丹铅总録》。清人陈启源《毛诗稽古篇》。\n15.12子曰:“人无远虑,必有近忧。”\n【译文】孔子说:“一个人没有长远的考虑,一定会有眼前的忧患。”\n15.13子曰:“已矣乎!吾未见好德如好色⑴者也。”\n【译文】孔子说:“完了吧!我从没见过像喜欢美貌一般地喜欢美德的人哩。”\n【注释】⑴好色——据《史记·孔子世家》,孔子“居卫月余,灵公与夫人(南子)同车,宦者雍渠参乘出,使孔子为次乘,招摇市过之。”孔子因发这一感叹。\n15.14子曰:“藏文仲⑴其窃位者与!知柳下惠⑵之贤而不与立⑶也。”\n【译文】孔子说:“臧文仲大概是个做官不管事的人,他明知柳下惠贤良,却不给他官位。”\n【注释】⑴臧文仲——鲁国的大夫臧孙辰,历仕庄、闵、僖、文四朝。⑵柳下惠——鲁国贤者,本名展获,字禽,又叫展季。“柳下”可能是其所居,因以为号;据《列女传》,“惠”是由他的妻子的倡议给他的私谥(不由国家授予的谥号叫私谥)。⑶立——同“位”,说详俞樾《羣经平议》。\n15.15子曰:“躬自厚⑴而薄责于人,则远怨矣。”\n【译文】孔子说:“多责备自己,而少责备别人,怨恨自然不会来了。”\n【注释】⑴躬自厚——本当作“躬自厚责”,“责”字探下文“薄责”之“责”而省略。说详拙著《文言语法》。“躬自”是一双音节的副词,和《诗经·卫风·氓》的“静言思之,躬自悼矣”的“躬自”用法一样。\n15.16子曰:“不曰‘如之何⑴,如之何’者,吾末如之何也已矣。”\n【译文】孔子说:“[一个人]不想想‘怎么办,怎么办’的,对这种人,我也不知道怎么办了。”\n【注释】⑴如之何——“不曰如之何”意思就是不动脑筋。《荀子·大略篇》说:“天子卽位,上卿进曰,如之何,忧之长也。”则说如之何的,便是深忧远虑的人。\n15.17子曰:“羣居终日,言不及义,好行小慧,难矣哉!”\n【译文】孔子说:“同大家整天在一块,不说一句有道理的话,只喜欢卖弄小聪明,这种人真难教导!”\n15.18子曰:“君子义以为质,礼以行之,孙以出之⑴,信以成之。君子哉!”\n【译文】孔子说:“君子[对于事业],以合宜为原则,依礼节实行它,用谦逊的言语说出它,用诚实的态度完成它。真个是位君子呀!”\n【注释】⑴孙以出之——“出”谓出言。何晏《论语集解》引郑玄注云:“孙以出之谓言语。”\n15.19子曰:“君子病无能焉,不病人之不己知也。”\n【译文】孔子说:“君子只惭愧自己没有能力,不怨恨别人不知道自己。”\n15.20子曰:“君子疾没世而名不称焉。”\n【译文】孔子说:“到死而名声不被人家称述,君子引以为恨。”\n15.21子曰:“君子求诸己,小人求诸人。”\n【译文】孔子说:“君子要求自己,小人要求别人。”\n15.22子曰:“君子矜而不争,羣而不党⑴。”\n【译文】孔子说:“君子庄矜而不争执,合羣而不闹宗派。”\n【注释】⑴羣而不党——“羣而不党”可能包含着“周而不比”(2.14)以及“和而不同”(13.23)两个意思。\n15.23子曰:“君子不以言举人,不以人废言。”\n【译文】孔子说:“君子不因为人家一句话[说得好]便提拔他,不因为他是坏人而鄙弃他的好话。”\n15.24子贡问曰:“有一言而可以终身行之者乎?”子曰:“其恕⑴乎!己所不欲,勿施于人。”\n【译文】子贡问道:“有没有一句可以终身奉行的话呢?”孔子道:“大概是‘恕’罢!自己所不想要的任何事物,就不要加给别人。”\n【注释】⑴恕——“忠”(己欲立而立人,己欲达而达人)是有积极意义的道德,未必每个人都有条件来实行。“恕”只是“己所不欲,勿施于人”,则谁都可以这样做,因之孔子在这里言“恕”不言“忠”。《礼记·大学》篇的“絜矩之道”就是“恕”道。可是在阶级社会里,也只能是幻想。\n15.25子曰:“吾之于人也,谁毁谁誉?如有所誉者,其有所试矣。斯民也,三代之所以直道而行也。”\n【译文】孔子说:“我对于别人,诋毁了谁?称赞了谁?假若我有所称赞,必然是曾经考验过他的。夏、商、周三代的人都如此,所以三代能直道而行。”\n15.26子曰:“吾犹及史之阙文也。有马者借人乘之,今亡矣夫!”\n【译文】孔子说:“我还能够看到史书存疑的地方。有马的人[自己不会训练,]先给别人使用,这种精神,今天也没有了罢,”\n【注释】“史之阙文”和“有马借人乘之”,其间有什么关连,很难理解。包咸的《论语章句》和皇侃的《义疏》都把它们看成两件不相关的事。宋叶梦得《石林燕语》却根据《汉书·艺文志》的引文无“有马”等七个字,因疑这七个字是衍文。其它穿凿的解释很多,依我看来,还是把它看为两件事较妥当。又有人说这七字当作“有焉者晋人之乘”(见《诂经精舍六集》卷九〈方赞尧有马者借人乘之解〉),更是毫无凭据的臆测。\n15.27子曰:“巧言乱德。小不忍⑴,则乱大谋。”\n【译文】孔子说:“花言巧语足以败坏道德。小事情不忍耐,便会败坏大事情。”\n【注释】⑴小不忍——“小不忍”不仅是不忍小忿怒,也包括不忍小仁小恩,没有“蝮蛇螫手,壮士断腕”的勇气,也包括吝财不忍舍,以及见小利而贪。\n15.28子曰:“众恶之,必察焉⑴;众好之,必察焉。”\n【译文】孔子说:“大家厌恶他,一定要去考察;大家喜爱他,也一定要去考察。”\n【注释】⑴必察焉——子路篇有这样一段:“子贡问曰:‘乡人皆好之,何如?’子曰:‘未可也。’‘乡人皆恶之,何如?’子曰:‘未可也。不如乡人之善者好之,其不善者恶之。’”(13.24)可以和这段话互相发明。\n15.29子曰:“人能弘道,非道弘人⑴。”\n【译文】孔子说:“人能够把道廓大,不是用道来廓大人。”\n【注释】⑴这一章只能就字面来翻译,孔子的真意何在,又如何叫做“非道弘人”,很难体会。朱熹曾经强为解释,而郑皓的《论语集注述要》却说,“此章最不烦解而最可疑”,则我们也只好不加臆测。《汉书·董仲舒传》所载董仲舒的对策和《礼乐志》所载的平当对策都引此二句,都以为是治乱兴废在于人的意思,但细加思考,仍未必相合。\n15.30子曰:“过而不改,是谓过矣⑴。”\n【译文】孔子说:“有错误而不改正,那个错误便真叫做错误了。”\n【注释】⑴是谓过矣——《韩诗外传》卷三曾引孔子的话说:“过而改之,是不过也。”\n15.31子曰:“吾尝终日不食,终夜不寝,以思,无益,不如学也。”\n【译文】孔子说:“我曾经整天不吃,整晚不睡,去想,没有益处,不如去学习。”\n15.32子曰:“君子谋道不谋食。耕也,馁在其中矣;学也,禄在其中⑴矣。君子忧道不忧贫。”\n【译文】孔子说:“君子用心力于学术,不用心力于衣食。耕田,也常常饿着肚皮;学习,常常得到俸禄。君子只着急得不到道,不着急得不到财。”\n【注释】⑴禄在其中——这一章可以和“樊迟请学稼”章(13.4)结合着看。\n15.33子曰:“知及之⑴,仁不能守之;虽得之,必失之。知及之,仁能守之。不庄以莅之,则民不敬。知及之,仁能守之,庄以莅之,动之不以礼,未善也。”\n【译文】孔子说:“聪明才智足以得到它,仁德不能保持它;就是得到,一定会丧失。聪明才智足以得到它,仁德能保持它,不用严肃态度来治理百姓,百姓也不会认真[地生活和工作]。聪明才智足以得到它,仁德能保持它,能用严肃的态度来治理百姓,假若不合理合法地动员百姓,是不够好的。”\n【注释】⑴知及之——“知及之”诸“之”字究竟何指,原文未曾说出。以“不庄以莅之”“动之不以礼”诸句来看,似是小则指卿大夫士的禄位,大则指天下国家。不然,不会涉及临民和动员人民的。\n15.34子曰:“君子不可小知而可大受也,小人不可大受而可小知也。”\n【译文】孔子道:“君子不可以用小事情考验他,却可以接受重大任务;小人不可以接受重大任务,却可以用小事情考验他。”\n15.35子曰:“民之于仁也,甚于水火⑴。水火,吾见蹈而死者矣,未见蹈仁而死者也。”\n【译文】孔子说:“百姓需要仁德,更急于需要水火。往水火里去,我看见因而死了的,却从没有看见践履仁德因而死了的。”\n【注释】⑴甚于水火——《孟子·尽心上》说:“民非水火不生活”,译文摘取此意,故加“需要”两字。\n15.36子曰:“当仁,不让于师。”\n【译文】孔子说:“面临着仁德,就是老师,也不同他谦让。”\n15.37子曰:“君子贞⑴而不谅⑵。”\n【译文】孔子说:“君子讲大信,却不讲小信。”\n【注释】⑴贞——《贾子·道术篇》云:“言行抱一谓之贞。”所以译文以“大信”译之。⑵谅——朱骏声《说文通训定声说》这“谅”字假借为“勍”,犹固执也。则他把这“贞”字解为《伪古文尚书·太甲》“万邦以贞”的“贞”,正也。似不妥。\n15.38子曰:“事君,敬其事而后其食⑴。”\n【译文】孔子说:“对待君上,认真工作,把拿俸禄的事放在后面。”\n【注释】⑴而后其食——据宋晁公武《郡斋读书志》的记载,蜀石经作“而后食其禄”。\n15.39子曰:“有教无类⑴。”\n【译文】孔子说:“人人我都教育,没有[贫富、地域等等]区别。”\n【注释】⑴无类——“自行束修以上,吾未尝无诲焉”(7.7),便是“有教无类。”\n15.40子曰:“道不同,不相为谋。”\n【译文】孔子说:“主张不同,不互相商议。”\n15.41子曰:“辞达⑴而已矣。”\n【译文】孔子说:“言辞,足以达意便罢了。”\n【注释】⑴辞达——可以和“文胜质则史”(6.18)参看。过于浮华的词藻,是孔子所不同意的。\n15.42师冕⑴见,及阶,子曰:“阶也。”及席,子曰:“席也。”皆坐,子告之曰:“某在斯,某在斯。”\n师冕出。子张问曰:“与师言之道与?”子曰:“然;固相师之道也。”\n【译文】师冕来见孔子,走到阶沿,孔子道:“这是阶沿啦。”走到坐席旁,孔子道:“这是坐席啦。”都坐定了,孔子告诉他说:“某人在这里,某人在这里。”\n师冕辞了出来。子张问道:“这是同瞎子讲话的方式吗?”孔子道:“对的;这本来是帮助瞎子的方式。”\n【注释】⑴师冕——师,乐师,冕,这人之名。古代乐官一般用瞎子充当。\n"},{"id":114,"href":"/zh/docs/culture/%E8%AE%BA%E8%AF%AD/%E8%AE%BA%E8%AF%AD%E8%AF%91%E6%B3%A8_%E6%9D%A8%E4%BC%AF%E5%B3%BB/08%E6%B3%B0%E4%BC%AF%E7%AF%87%E7%AC%AC%E5%85%AB/","title":"08泰伯篇第八","section":"论语译注 杨伯峻","content":" 泰伯篇第八 # (共二十一章)\n8.1子曰:“泰伯⑴,其可谓至德也已矣。三以天下⑵让,民无得而称焉。”\n【译文】孔子说:“泰伯,那可以说是品德极崇高了。屡次地把天下让给季历,老百姓简直找不出恰当的词语来称赞他。”\n【注释】】⑴泰伯——亦作“太伯”,周朝祖先古公亶父的长子。古公有三子,太伯、仲雍、季历。季历的儿子就是姬昌(周文王)。据传说,古公预见到昌的圣德,因此想打破惯例,把君位不传长子太伯,而传给幼子季历,从而传给昌。太伯为着实现他父亲的意愿,便偕同仲雍出走至勾吴(为吴国的始祖),终于把君位传给季历和昌。昌后来扩张国势,竟有天下的三分之二,到他儿子姬发(周武王),便灭了殷商,统一天下。⑵天下——当古公、泰伯之时,周室仅是一个小的部落,谈不上“天下”。这“天下”两字可能卽指其当时的部落而言。也有人说,是预指以后的周部落统一了中原的天下而言。\n8.2子曰:“恭而无礼则劳⑴,慎而无礼则葸⑵,勇而无礼则乱,直而无礼则绞⑶。君子笃于亲,则民兴于仁;故旧不遗,则民不偷⑷。”\n【译文】孔子说:“注重容貌态度的端庄,却不知礼,就未免劳倦;只知谨慎,却不知礼,就流于畏葸懦弱;专凭敢作敢为的胆量,却不知礼,就会盲动闯祸;心直口快,却不知礼,就会尖刻刺人。在上位的人能用深厚感情对待亲族,老百姓就会走向仁德;在上位的人不遗弃他的老同事、老朋友,那老百姓就不致对人冷淡无情。\n【注释】⑴礼——这里指的是礼的本质。⑵葸——xǐ,胆怯,害怕。⑶绞——尖刻刺人。⑷偷——淡薄,这里指人与人的感情而言。\n8.3曾子有疾,召门弟子曰:“启⑴予足!启予手!《诗》云⑵,‘战战兢兢,如临深渊,如履⑶薄冰。’而今而后,吾知免夫!小子!”\n【译文】曾参病了,把他的学生召集拢来,说道:“看看我的脚!看看我的手!《诗经》上说:‘小心呀!谨慎呀!好像面临深深水坑之旁,好像行走薄薄冰层之上。’从今以后,我才晓得自己是可以免于祸害刑戮的了!学生们!”\n【注释】⑴启——说文有“”字,云:“视也。”王念孙《广雅疏证》(《释诂》)说,《论语》的这“启”字就是说文的“”字。⑵《诗》云——三句诗见《诗经·小雅·小旻篇》。⑶履——《易·履卦》爻辞:“眇能视,跛能履。”履,步行也。\n8.4曾子有疾,孟敬子⑴问之。曾子言曰:“鸟之将死,其鸣也哀;人之将死,其言也善。君子所贵乎道者三:动容貌,斯远暴慢⑵矣;正颜色,斯近信矣;出辞气,斯远鄙倍⑶矣。笾豆之事⑷,则有司⑸存。”\n【译文】曾参病了,孟敬子探问他。曾子说:“鸟要死了,鸣声是悲哀的;人要死了,说出的话是善意的。在上位的人待人接物有三方面应该注重:严肃自己的容貌,就可以避免别人的粗暴和懈怠;端正自己的脸色,就容易使人相信;说话的时候,多考虑言辞和声调,就可以避免鄙陋粗野和错误。至于礼仪的细节,自有主管人员。”\n【注释】⑴孟敬子——鲁国大夫仲孙捷。⑵暴慢——暴是粗暴无礼,慢是懈怠不敬。⑶鄙倍——鄙是粗野鄙陋;倍同“背”,不合理,错误。⑷笾豆之事——笾音边,古代的一种竹器,高脚,上面圆口,有些像碗,祭祀时用以盛果实等食品。豆也是古代一种像笾一般的器皿,木料做的,有盖,用以盛有汁的食物,祭祀时也用它。这里“笾豆之事”系代表礼仪中的一切具体细节。⑸有司——主管其事的小吏。\n8.5曾子曰:“以能问于不能,以多问于寡;有若无,实若虚,犯而不校——昔者吾友⑴尝从事于斯矣。”\n【译文】曾子说:“有能力却向无能力的人请教,知识丰富却向知识缺少的人请教;有学问像没学问一样,满腹知识像空无所有一样;纵被欺侮,也不计较——从前我的一位朋友便曾这样做了。”\n【注释】⑴吾友——历来的注释家都以为是指颜回。\n8.6曾子曰:“可以托六尺⑴之孤,可以寄百里之命,临大节而不可夺也——君子人与?君子人也。”\n【译文】曾子说:“可以把幼小的孤儿和国家的命脉都交付给他,面临安危存亡的紧要关头,却不动摇屈服——这种人,是君子人吗?是君子人哩。”\n【注释】⑴六尺——古代尺短,六尺约合今日一百三十八厘米,市尺四尺一寸四分。身长六尺的人还是小孩,一般指十五岁以下的人。\n8.7曾子曰:“士不可以不弘毅⑴,任重而道远。仁以为己任,不亦重乎?死而后已,不亦远乎?”\n【译文】曾子说:“读书人不可以不刚强而有毅力,因为他负担沉重,路程遥远。以实现仁德于天下为己任,不也沉重吗?到死方休,不也遥远吗?”\n【注释】⑴弘毅——就是“强毅”。章太炎(炳麟)先生《广论语骈枝》说:“说文:‘弘,弓声也。’后人借‘强’为之,用为‘强’义。此‘弘’字卽今之‘强’字也。说文:‘毅,有决也。’任重须强,不强则力绌;致远须决,不决则志渝。”\n8.8子曰:“兴于《诗》,立于礼,成于乐⑴。”\n【译文】孔子说:“诗篇使我振奋,礼使我能在社会上站得住,音乐使我的所学得以完成。”\n【注释】⑴成于乐——孔子所谓“乐”的内容和本质都离不开“礼”,因此常常“礼乐”连言。他本人也很懂音乐,因此把音乐作为他的教学工作的一个最后阶段。\n8.9子曰:“民可使由之,不可使知之⑴。”\n【译文】孔子说:“老百姓,可以使他们照着我们的道路走去,不可以使他们知道那是为什么。”\n【注释】⑴子曰……知之——这两句与“民可以乐成,不可与虑始”(《史记·滑稽列传》补所载西门豹之言,《商君列传》作“民不可与虑始,而可与乐成”)意思大致相同,不必深求。后来有些人觉得这种说法不很妥当,于是别生解释,意在为孔子这位“圣人”回护,虽煞费苦心,反失孔子本意。如刘宝楠《正义》以为“上章是夫子教弟子之法,此‘民’字亦指弟子”。不知上章“兴于诗”三句与此章旨意各别,自古以来亦曾未有以“民”代“弟子”者。宦懋庸《论语稽》则云:“对于民,其可者使其自由之,而所不可者亦使知之。或曰,舆论所可者则使共由之,其不可者亦使共知之。”则原文当读为“民可,使由之;不可,使知之”。恐怕古人无此语法。若是古人果是此意,必用“则”字,甚至“使”下再用“之”字以重指“民”,作“民可,则使(之)由之,不可,则使(之)知之”,方不致晦涩而误解。\n8.10子曰:“好勇疾贫,乱也。人而不仁,疾之已甚,乱也。”\n【译文】孔子说:“以勇敢自喜却厌恶贫困,是一种祸害。对于不仁的人,痛恨太甚,也是一种祸害。”\n8.11子曰:“如有周公之才之美,使骄且吝,其余不足观也已。”\n【译文】孔子说:“假如才能的美妙真比得上周公,只要骄傲而吝啬,别的方面也就不值得一看了。”\n8.12子曰:“三年学,不至⑴于谷⑵,不易得也。”\n【译文】孔子说:“读书三年并不存做官的念头,这是难得的。”\n【注释】⑴至——这“至”字和雍也篇第六“回也其心三月不违仁,其余则日月至焉而已矣”的“至”用法相同,指意念之所至。⑵谷——古代以谷米为俸禄(作用相当于今日的工资),所以“谷”有“禄”的意义。宪问篇第十四的“邦有道,谷;邦无道,谷”的“谷”正与此同。\n8.13子曰:“笃信⑴好学,守死善道。危邦不入,乱邦不居⑵。天下有道则见⑶,无道则隐。邦有道,贫且贱焉,耻也;邦无道,富且贵焉,耻也。”\n【译文】孔子说:“坚定地相信我们的道,努力学习它,誓死保全它。不进入危险的国家,不居住祸乱的国家。天下太平,就出来工作;不太平,就隐居。政治清明,自己贫贱,是耻辱;政治黑暗,自己富贵,也是耻辱。”\n【注释】⑴笃信——子张篇:“执德不弘,信道不笃,焉能为有?焉能为亡?”这一“笃信”应该和“信道不笃”的意思一样。⑵危邦乱邦——包咸云“臣弒君,子弑父,乱也;危者,将乱之兆也。”⑶见——同“现”。\n8.14子曰:“不在其位,不谋其政。”\n【译文】孔子说:“不居于那个职位,便不考虑它的政务。”\n8.15子曰:“师挚之始⑴,《关雎》之乱⑵,洋洋乎盈耳哉!”\n【译文】孔子说:“当太师挚开始演奏的时候,当结尾演奏《关雎》之曲的时候,满耳朵都是音乐呀!”\n【注释】⑴师挚之始——“始”是乐曲的开端,古代奏乐,开始叫做“升歌”,一般由太师演奏。师挚是鲁国的太师,名挚,由他演奏,所以说“师挚之始”。⑵《关雎》之乱——“始”是乐的开端,“乱”是乐的结束。由“始”到“乱”,叫做“一成”。“乱”是“合乐”,犹如今日的合唱。当合奏之时,奏《关雎》的乐章,所以说“《关雎》之乱”。\n8.16子曰:“狂而不直,侗而不愿,悾悾而不信,吾不知之矣。”\n【译文】孔子说:“狂妄而不直率,幼稚而不老实,无能而不讲信用,这种人我是不知道其所以然的。”\n8.17子曰:“学如不及,犹恐失之。”\n【译文】孔子说:“做学问好像[追逐什么似的,]生怕赶不上;[赶上了,]还生怕丢掉了。”\n8.18子曰:“巍巍乎,舜禹⑴之有天下也而不与⑵焉!”\n【译文】孔子说:“舜和禹真是崇高得很呀!贵为天子,富有四海,[却整年地为百姓勤劳,]一点也不为自己。”\n【注释】⑴禹——夏朝开国之君。据传说,受虞舜的禅让而卽帝位。又是中国主持水利工程最早的有着功勋的人物。⑵与——音预yù,参与,关连。这里含着“私有”、“享受”的意思。\n8.19子曰:“大哉尧之为君也!巍巍乎!唯天为大,唯尧则之。荡荡乎,民无能名焉。巍巍乎其有成功也,焕乎其有文章!”\n【译文】孔子说:“尧真是了不得呀!真高大得很呀!只有天最高最大,只有尧能够学习天。他的恩惠真是广博呀!老百姓简直不知道怎样称赞他。他的功绩实在太崇高了,他的礼仪制度也真够美好了!”\n8.20舜有臣五人而天下治。武王曰:“予有乱臣⑴十人。”孔子曰:“才难,不其然乎!唐虞之际,于斯为盛。有妇人焉,九人而已。三分天下有其二⑵,以服事殷。周之德,其可谓至德也已矣。”\n【译文】舜有五位贤臣,天下便太平。武王也说过,“我有十位能治理天下的臣子。”孔子因此说道:“[常言道:]‘人才不易得。’不是这样吗?唐尧和虞舜之间以及周武王说那话的时候,人才最兴盛。然而武王十位人才之中还有一位妇女,实际上只是九位罢了。周文王得了天下的三分之二,仍然向商纣称臣,周朝的道德,可以说是最高的了。”\n【注释】⑴乱臣——说文:“乱,治也。”《尔雅·释诂》同。《左传》昭公二十四年引《大誓》说:“余有乱臣十人,同心同德。”则“乱臣”就是“治国之臣”。近人周谷城(《古史零证》)认为“乱”有“亲近”的意义,则“乱臣”相当于《孟子·粱惠王下》“王无亲臣矣”的“亲臣”,虽然言之亦能成理,但和下文“才难”之意不吻合,恐非孔子原意。⑵三分天下有其二——《逸周书·程典篇》说:“文王合九州岛之侯,奉勤于商”。相传当时分九州岛,文王得六州,是有三分之二。\n8.21子曰:“禹,吾无间然矣。菲饮食而致孝乎鬼神,恶衣服而致美乎黻冕⑴,卑宫室而尽力乎沟洫⑵。禹,吾无间然矣。”\n【译文】孔子说:“禹,我对他没有批评了。他自己吃得很坏,却把祭品办得极丰盛;穿得很坏,却把祭服做得极华美;住得很坏,却把力量完全用于沟渠水利。禹,我对他没有批评了。”\n【注释】⑴黻冕——黻音弗,fú,祭祀时穿的礼服;冕音免,miǎn,古代大夫以上的人的帽子都叫冕,后来只有帝王的帽子才叫冕。这里指祭祀时的礼帽。⑵沟洫——就是沟渠,这里指农田水利而言。\n"},{"id":115,"href":"/zh/docs/culture/%E8%AE%BA%E8%AF%AD/%E8%AE%BA%E8%AF%AD%E8%AF%91%E6%B3%A8_%E6%9D%A8%E4%BC%AF%E5%B3%BB/07%E8%BF%B0%E8%80%8C%E7%AF%87%E7%AC%AC%E4%B8%83/","title":"07述而篇第七","section":"论语译注 杨伯峻","content":" 述而篇第七 # (共三十八章(朱熹《集注》把第九、第十两章并作一章,所以题为三十七章。))\n7.1子曰:“述而不作,信而好古⑴,窃比于我老彭⑵。”\n【译文】孔子说:“阐述而不创作,以相信的态度喜爱古代文化,我私自和我那老彭相比。”\n【注释】⑴作,好古——下文第二十八章说:“盖有不知而作之者,我无是也。”这个“作”,大概也是“不知而作”的涵义,很难说孔子的学说中没有创造性。又第二十章说:“好古敏以求之”,也可为这个“好古”的证明。⑵老彭——人名。有人说是老子和彭祖两人,有人说是殷商时代的彭祖一人,又有人说孔子说“我的老彭”,其人一定和孔子相当亲密,未必是古人。《大戴礼·虞戴德篇》有“商老彭”,不知卽此人不。\n7.2子曰:“默而识⑴之,学而不厌,诲人不倦,何有于我哉⑵?”\n【译文】孔子说:“[把所见所闻的]默默地记在心里,努力学习而不厌弃,教导别人而不疲倦,这些事情我做到了哪些呢?”\n【注释】⑴识——音志,zhì,记住。⑵何有于我哉——“何有”在古代是一常用语,在不同场合表示不同意义。像《诗·邶风·谷风》“何有何亡?黾勉求之”的“何有”便是“有什么”的意思,译文就是用的这一意义。也有人说,《论语》的“何有”都是“不难之辞”,那么,这句话便该译为“这些事情对我有什么困难呢”。这种译法便不是孔子谦虚之词,而和下文第二十八章的“多闻,择其善者而从之,多见而识之”以及“抑为之不厌,诲人不倦”的态度相同了。\n7.3子曰:“德之不修,学之不讲,闻义不能徙,不善不能改,是吾忧也。”\n【译文】孔子说:“品德不培养;学问不讲习;听到义在那里,却不能亲身赴之;有缺点不能改正,这些都是我的忧虑哩!”\n7.4子之燕居,申申⑴如也,夭夭⑵如也。\n【译文】孔子在家闲居,很整齐的,很和乐而舒展的。\n【注释】⑴申申——整敕之貌。⑵夭夭——和舒之貌。\n7.5子曰:“甚矣吾衰也!久矣吾不复梦见周公⑴!”\n【译文】孔子说:“我衰老得多么厉害呀!我好长时间没再梦见周公了!”\n【注释】⑴周公——姓姬,名旦,周文王的儿子,武王的弟弟,成王的叔父,鲁国的始祖,又是孔子心目中最敬服的古代圣人之一。\n7.6子曰:“志于道,据于德,依于仁,游于艺⑴。”\n【译文】孔子说:“目标在‘道’,根据在‘德’,依靠在‘仁’,而游憩于礼、乐、射、御、书、数六艺之中。”\n【注释】⑴游于艺——《礼记·学记》曾说:“不兴其艺,不能乐学。故君子之于学也,藏焉,修焉,息焉,游焉。夫然,故安其学而亲其师,乐其友而信其道,是以虽离师辅而不反也。”可以阐明这里的“游于艺”。\n7.7子曰:“自行束修⑴以上,吾未尝无诲焉。”\n【译文】孔子说:“只要是主动地给我一点见面薄礼,我从没有不教诲的。”\n【注释】⑴束修——修是干肉,又叫脯。每条脯叫一脡(挺),十脡为一束。束修就是十条干肉,古代用来作初次拜见的礼物。但这一礼物是菲薄的。\n7.8子曰:“不愤⑴不启,不悱⑵不发⑶。举一隅不以三隅反,则不复也。”\n【译文】孔子说:“教导学生,不到他想求明白而不得的时候,不去开导他;不到他想说出来却说不出的时候,不去启发他。教给他东方,他却不能由此推知西、南、北三方,便不再教他了。”\n【注释】⑴愤——心求通而未得之意。⑵悱音斐,fěi,口欲言而未能之貌。⑶不启,不发——这是孔子自述其教学方法,必须受教者先发生困难,有求知的动机,然后去启发他。这样,教学效果自然好些。\n7.9子食于有丧者之侧,未尝饱也。\n【译文】孔子在死了亲属的人旁边吃饭,不曾吃饱过。\n7.10子于是日哭,则不歌。\n【译文】孔子在这一天哭泣过,就不再唱歌。\n7.11子谓颜渊曰:“用之则行,舍之则藏,惟我与尔有是夫!”\n子路曰:“子行三军,则谁与⑴?”\n子曰:“暴虎冯河⑵,死而无悔者,吾不与也。必也临事而惧,好谋而成者也。”\n【译文】孔子对颜渊道:“用我呢,就干起来;不用呢,就藏起来。只有我和你才能这样吧!”\n子路道:“您若率领军队,找谁共事?”\n孔子道:“赤手空拳和老虎搏斗,不用船只去渡河,这样死了都不后悔的人,我是不和他共事的。[我所找他共事的,]一定是面临任务便恐惧谨慎,善于谋略而能完成的人哩!”\n【注释】⑴子行三军,则谁与——“行”字古人用得很活,行军犹言行师。《易经·谦卦·上六》云:“利用行师征邑国”,又《复卦·上六》:“用行师终有大败”,行师似有出兵之意。这种活用,一直到中古都如此。如“子夜歌”的“欢行白日心,朝东暮还西。”“与”,动词,偕同的意思。子路好勇,看见孔子夸奖颜渊,便发此问。⑵暴虎冯河——冯音凭,píng。徒手搏虎曰暴虎,徒足涉河曰冯河。“冯河”两字最初见于《易·泰卦·爻辞》,又见于《诗·小雅·小旻》。“暴虎”也见于《诗经·郑风·大叔于田》和《小雅·小旻》,可见都是很早就有的俗语。“河”不一定是专指黄河,古代也有用作通名,泛指江河的。\n7.12子曰:“富而⑴可求也,虽执鞭之士⑵,吾亦为之。如不可求,从吾所好。”\n【译文】孔子说:“财富如果可以求得的话,就是做市场的守门卒我也干。如果求它不到,还是我干我的罢。”\n【注释】⑴而——用法同“如”,假设连词。但是用在句中的多,卽有用在句首的,那句也多半和上一句有密切的关连,独立地用在句首的极少见。⑵执鞭之士——根据《周礼》,有两种人拿着皮鞭,一种是古代天子以及诸侯出入之时,有二至八人拿着皮鞭使行路之人让道。一种是市场的守门人,手执皮鞭来维持秩序。这里讲的是求财,市场是财富所聚集之处,因此译为“市场守门卒”。\n7.13子之所慎:齐⑴,战,疾⑵。\n【译文】孔子所小心慎重的事有三样:斋戒,战争,疾病。\n【注释】⑴齐——同“斋”。古代于祭祀之前,一定先要做一番身心的整洁工作,这一工作便叫做‘斋’或者“斋戒”。乡党篇第十说孔子“斋必变食,居必迁坐”。⑵战,疾——上文说到孔子作战必求“临事而惧好谋而成”的人,因为它关系国家的存亡安危;乡党篇又描写孔子病了,不敢随便吃药,因为它关系个人的生死。这都是孔子不能不谨慎的地方。\n7.14子在齐闻韶,三月不知肉味,曰:“不图为乐之至于斯也。”\n【译文】孔子在齐国听到韶的乐章,很长时间尝不出肉味,于是道:“想不到欣赏音乐竟到了这种境界。”\n7.15冉有曰:“夫子为⑴卫君⑵乎?”子贡曰:“诺;吾将问之。”\n入,曰:“伯夷、叔齐何人也?”曰:“古之贤人也。”曰:“怨乎?”曰:“求仁而得仁,又何怨?”\n出,曰:“夫子不为也。”\n【译文】冉有道:“老师赞成卫君吗?”子贡道:“好罢;我去问问他。”\n子贡进到孔子屋里,道:“伯夷、叔齐是什么样的人?”孔子道:“是古代的贤人。”子贡道:“[他们两人互相推让,都不肯做孤竹国的国君,结果都跑到国外,]是不是后来又怨悔呢?”孔子道:“他们求仁德,便得到了仁德,又怨悔什么呢?”\n子贡走出,答复冉有道:“老师不赞成卫君。”\n【注释】⑴为——动词,去声,本意是帮助,这里译为“赞成”,似乎更合原意。⑵卫君——指卫出公辄。辄是卫灵公之孙,太子蒯聩之子。太子蒯聩得罪了卫灵公的夫人南子,逃在晋国。灵公死,立辄为君。晋国的赵简子又把蒯聩送回,藉以侵略卫国。卫国抵御晋兵,自然也拒绝了蒯聩的回国。从蒯聩和辄是父子关系的一点看来,似乎是两父子争夺卫君的位置,和伯夷、叔齐两兄弟的互相推让,终于都抛弃了君位相比,恰恰成一对照。因之下文子贡引以发问,藉以试探孔子对出公辄的态度。孔子赞美伯夷、叔齐,自然就是不赞成出公辄了。\n7.16子曰:“饭疏食⑴饮水⑵,曲肱⑶而枕⑷之,乐亦在其中矣。不义而富且贵,于我如浮云。”\n【译文】孔子说:“吃粗粮,喝冷水,弯着胳膊做枕头,也有着乐趣。干不正当的事而得来的富贵,我看来好像浮云。”\n【注释】⑴疏食——有两个解释:(甲)粗粮。古代以稻梁为细粮,以稷为粗粮。见程瑶田《通艺録·九谷考》。(乙)糙米。⑵水——古代常以“汤”和“水”对言,“汤”的意义是热水,“水”就是冷水。⑶肱——音宫,gōng,胳膊。⑷枕——这里用作动词,旧读去声。\n7.17子曰:“加我数年,五十以学《易》⑴,可以无大过矣。”\n【译文】孔子说:“让我多活几年,到五十岁时候去学习《易经》,便可以没有大过错了。”\n【注释】⑴易——古代一部用以占筮的书,其中的卦辞和爻辞是孔子以前的作品。\n7.18子所雅言⑴,《诗》、《书》、执礼,皆雅言也。\n【译文】孔子有用普通话的时候,读《诗》,读《书》,行礼,都用普通话。\n【注释】⑴雅言——当时中国所通行的语言。春秋时代各国语言不能统一,不但可以想象得到,卽从古书中也可以找到证明。当时较为通行的语言便是“雅言”。\n7.19叶公⑴问孔子于子路,子路不对。子曰:“女奚不曰,其为人也,发愤忘食,乐以忘忧,不知老之将至云尔⑵。”\n【译文】叶公向子路问孔子为人怎么样,子路不回答。孔子对子路道:“你为什么不这样说:他的为人,用功便忘记吃饭,快乐便忘记忧愁,不晓得衰老会要到来,如此罢了。”\n【注释】⑴叶——旧音摄,shè,地名,当时属楚,今河南叶县南三十里有古叶城。叶公是叶地方的县长,楚君称王,那县长便称公。此人叫沈诸梁,字子高,《左传》定公、哀公之间有一些关于他的记载,在楚国当时还算是一位贤者。⑵云尔——云,如此;尔同“耳”,而已,罢了。\n7.20子曰:“我非生而知之者,好古,敏以求之者也。”\n【译文】孔子说:“我不是生来就有知识的人,而是爱好古代文化,勤奋敏捷去求得来的人。”\n7.21子不语怪,力,乱,神。\n【译文】孔子不谈怪异、勇力、叛乱和鬼神。\n7.22子曰:“三人行,必有我师焉:择其善者而从之,其不善者而改之⑴。”\n【译文】孔子说:“几个人一块走路,其中便一定有可以为我所取法的人:我选取那些优点而学习,看出那些缺点而改正。”\n【注释】⑴子曰……改之——子贡说孔子没有特定的老师(见19.22),意思就是随处都有老师,和这章可以以互相证明,老子说:“善人,不善人之师;不善人,善人之资。”未尝不是这个道理。\n7.23子曰:“天生德于予,桓魋⑴其如予何⑵?”\n【译文】孔子说:“天在我身上生了这样的品德,那桓魋将把我怎样?”\n【注释】⑴桓魋——“魋”音颓,tuí。桓魋,宋国的司马向魋,因为是宋桓公的后代,所以又叫桓魋。⑵桓魋其如予何——《史记·孔子世家》有一段这样的记载:“孔子去曹,适宋,与弟子习礼大树下。宋司马桓魋欲杀孔子,拔其树。孔子去,弟子曰‘可以速矣!’孔子曰:‘天生德于予,桓魋其如予何?’”\n7.24子曰:“二三子以我为隐乎?吾无隐乎尔。吾无行而不与二三子者,是丘也。”\n【译文】孔子说:“你们这些学生以为我有所隐瞒吗?我对你们是没有隐瞒的。我没有一点不向你们公开,这就是我孔丘的为人”\n7.25子以四教:文,行⑴,忠,信。\n【译文】孔子用四种内容教育学生:历代文献,社会生活的实践,对待别人的忠心,与人交际的信实。\n【注释】⑴行——作名词用,旧读去声。\n7.26子曰:“圣人,吾不得而见之矣;得见君子者,斯可矣。”\n子曰:“善人,吾不得而见之矣;得见有恒⑴者,斯可矣。亡而为有,虚而为盈,约而为泰⑵,难乎有恒矣。”\n【译文】孔子说:“圣人,我不能看见了;能看见君子,就可以了。”又说:“善人,我不能看见了,能看见有一定操守的人,就可以了。本来没有,却装做有;本来空虚,却装做充足;本来穷困,却要豪华,这样的人便难于保持一定操守了。”\n【注释】⑴有恒——这个“恒”字和《孟子·梁惠王上》的“无恒产而有恒心”的“恒”是一个意义。⑵泰——这“泰”字和《国语·晋语》的“恃其富宠,以泰于国”,《荀子·议兵篇》的“用财欲泰”的“泰”同义,用度豪华而不吝惜的意思。\n7.27子钓而不纲⑴,弋⑵不射宿⑶。\n【译文】孔子钓鱼,不用大绳横断流水来取鱼;用带生丝的箭射鸟,不射归巢的鸟。\n【注释】⑴纲——网上的大绳叫纲,用它来横断水流,再用生丝系钓,着于纲上来取鱼,这也叫纲。“不纲”的“纲”是动词。⑵弋——音亦,yì,用带生丝的矢来射。⑶宿——歇宿了的鸟。\n7.28子曰:“盖有不知而作之者,我无是也。多闻,择其善者而从之;多见而识之;知之次也⑴。”\n【译文】孔子说:“大概有一种自己不懂却凭空造作的人,我没有这种毛病。多多地听,选择其中好的加以接受;多多地看,全记在心里。这样的知,是仅次于‘生而知之’的。”\n【注释】⑴次——《论语》的“次”一共享了八次,都是当“差一等”、“次一等”讲。季氏篇云:“孔子曰:‘生而知之者,上也;学而知之者,次也。’”这里的“知之次也”正是“学而知之者,次也”的意思。孔子自己也说他是学而知之(好古敏以求之)的人,所以译文加了几个字。\n7.29互乡⑴难与言,童子见,门人惑。子曰:“与其进也,不与其退也,唯何甚?人洁己以进,与其洁也,不保⑵其往也。”\n【译文】互乡这地方的人难于交谈,一个童子得到孔子的接见,弟子们疑惑。孔子道:“我们赞成他的进步,不赞成他的退步,何必做得太过?别人把自己弄得干干净净而来,便应当赞成他的干净,不要死记住他那过去。”\n【注释】⑴互乡——地名,现在已不详其所在。⑵保——守也,所以译为“死记住”。\n7.30子曰:“仁远乎哉?我欲仁,斯仁至矣。”\n【译文】孔子道:“仁德难道离我们很远吗?我要它,它就来了。”\n7.31陈司败⑴问昭公⑵知礼乎,孔子曰:“知礼。”\n孔子退,揖巫马期⑶而进之,曰:“吾闻君子不党,君子亦党乎?君取于吴⑷,为同姓⑸,谓之吴孟子⑹。君而知礼,孰不知礼?”\n巫马期以告。子曰:“丘也幸,苟有过⑺,人必知之。”\n【译文】陈司败向孔子问鲁昭公懂不懂礼,孔子道:“懂礼。”\n孔子走了出来,陈司败便向巫马期作了个揖。请他走近自己,然后说道:“我听说君子无所偏袒,难道孔子竟偏袒吗?鲁君从吴国娶了位夫人,吴和鲁是同姓国家,[不便叫她做吴姬,]于是叫她做吴孟子。鲁君若是懂得礼,谁不懂得礼呢?”\n巫马期把这话转告给孔子。孔子道:“我真幸运,假若有错误,人家一定给指出来,”\n【注释】⑴陈司败——人名。有人说“司败”是官名,也有人说是人名,究竟是什么样的人,今天已经无法知道。⑵昭公——鲁昭公,名裯,襄公庶子,继襄公而为君。“昭”是谥号,陈司败之问若在昭公死后,则“昭公知礼乎”可能是原来语言。如果他这次发问尚在昭公生时,那“昭公”字眼当是后人的记述。我们已无从判断,所以这句不加引号。⑶巫马期——孔子学生,姓巫马,名施,字子期,小于孔子三十岁。⑷君取于吴——“取”这里用作“娶”字。吴,当时的国名,拥有今天淮水、泗水以南以及浙江的嘉兴、湖州等地。哀公时,为越王勾践所灭。⑸为同姓——鲁为周公之后,姬姓;吴为太伯之后,也是姬姓。⑹吴孟子——春秋时代,国君夫人的称号一般是所生长之国名加她的本姓。鲁娶于吴,这位夫人便应该称为吴姬。但“同姓不婚”是周朝的礼法,鲁君夫人的称号而把“姬”字标明出来,便是很显明地表示出鲁君的违背了“同姓不婚”的礼制,因之改称为“吴孟子”。“孟子”可能是这位夫人的字。《左传》哀公十二年亦书曰:“昭夫人孟子卒”。⑺苟有过——根据《荀子·子道篇》关于孔子的另一段故事,和《史记·仲尼弟子列传》对这一事“臣不可言君亲之恶,为讳者礼也”的解释,则孔子对鲁昭公所谓不合礼的行为不是不知,而是不说,最后只得归过于自己。\n7.32子与人歌而善,必使反之,而后和之。\n【译文】孔子同别人一道唱歌,如果唱得好,一定请他再唱一遍,然后自己又和他。\n7.33子曰:“文,莫⑴吾犹人也。躬行君子,则吾未之有得。”\n【译文】孔子说:“书本上的学问,大约我同别人差不多。在生活实践中做一个君子,那我还没有成功。”\n【注释】⑴文莫——以前人都把“文莫”两字连读,看成一个双音词,但又不能得出恰当的解释。吴检斋(承仕)先生在〈亡莫无虑同词说〉(载于前北京中国大学《国学丛编》第一期第一册)中以为“文”是一词,指孔子所谓的“文章”,“莫”是一词,“大约”的意思。关于“莫”字的说法在先秦古籍中虽然缺乏坚强的论证,但解释本文却比所有各家来得较为满意,因之为译者所采用。朱熹《集注》亦云,“莫,疑辞”,或为吴说所本。\n7.34子曰:“若圣⑴与仁,则吾岂敢?抑为之不厌,诲人不倦,则可谓云尔已矣。”公西华曰:“正唯弟子不能学也。”\n【译文】孔子说道:“讲到圣和仁,我怎么敢当?不过是学习和工作总不厌倦,教导别人总不疲劳,就是如此如此罢了。”公西华道:“这正是我们学不到的。”\n【注释】⑴圣——《孟子·公孙丑上》载子贡对这事的看法说:“学不厌,智也;教不倦,仁也。仁且智,夫子既圣矣。”可见当时的学生就已把孔子看成圣人。\n7.35子疾病⑴,子路请祷。子曰:“有诸?”子路对曰:“有之;〈诔〉⑵曰:‘祷尔于上下神祇⑶。’”子曰:“丘之祷久矣。”\n【译文】孔子病重,子路请求祈祷。孔子道:“有这回事吗?”子路答道:“有的;〈诔文〉说过:‘替你向天神地祇祈祷。’”孔子道:“我早就祈祷过了。”\n【注释】⑴疾病——“疾病”连言,是重病。⑵诔——音耒,lèi,本应作讄,祈祷文。和哀悼死者的“诔”不同。⑶祇——音祁,qí,地神。\n7.36子曰:“奢则不孙⑴,俭则固⑵。与其不孙也,宁固。”\n【译文】孔子说:“奢侈豪华就显得骄傲,省俭朴素就显得寒伧。与其骄傲,宁可寒伧。”\n【注释】⑴孙——同“逊”。⑵固——固陋,寒伧。\n7.37子曰:“君子坦荡荡,小人长戚戚。”\n【译文】孔子说:“君子心地平坦宽广,小人却经常局促忧愁。”\n7.38子温而厉,威而不猛,恭而安。\n【译文】孔子温和而严厉,有威仪而不凶猛,庄严而安详。\n"},{"id":116,"href":"/zh/docs/culture/%E8%AE%BA%E8%AF%AD/%E8%AE%BA%E8%AF%AD%E8%AF%91%E6%B3%A8_%E6%9D%A8%E4%BC%AF%E5%B3%BB/%E8%AF%95%E8%AE%BA-%E5%AF%BC%E8%A8%80-%E4%BE%8B%E8%A8%80/","title":"试论-导言-例言","section":"论语译注 杨伯峻","content":"论语译注\n杨伯峻译注\n中华书局\n1980年 北京\n试论孔子 # (一)孔子身世 # 孔子名丘,字仲尼,一说生于鲁襄公二十一年(《公羊传》和《谷梁传》,卽公元前五五一年),一说生于鲁襄公二十二年(《史记·孔子世家》),相差仅一年。前人为此打了许多笔墨官司,实在不必。死于鲁哀公十六年,卽公元前四七九年。终年实七十二岁。\n孔子自己说“而丘也,殷人也”(《礼记·檀弓上》),就是说他是殷商的苗裔。周武王灭了殷商,封殷商的微子启于宋。孔子的先祖孔父嘉是宋国宗室,因为距离宋国始祖已经超过五代,便改为孔氏。孔父嘉无辜被华父督杀害(见《左传》桓公元年和二年)。据《史记·孔子世家·索隐》,孔父嘉的后代防叔畏惧华氏的逼迫而出奔到鲁国,防叔生伯夏,伯夏生叔梁纥,叔梁纥就是孔子的父亲,因此孔子便成为鲁国人。\n殷商是奴隶社会,《礼记·表记》说:“殷人尚神”,这些都能从卜辞中得到证明。孔子也说:“殷礼,吾能言之。”(3.9)孔子所处的时代正是奴隶社会衰亡、新兴封建制逐渐兴起的交替时期。孔子本人,便看到这些迹象。譬如微子篇(18.6)耦耕的长沮、桀溺,不但知道孔子,讥讽孔子,而且知道子路是“鲁孔丘之徒”。这种农民,有文化,通风气,有自己的思想,绝对不是农业奴隶。在孔子生前,鲁宣公十五年,卽公元前五九四年,鲁国实行“初税亩”制。卽依各人所拥有的田地亩数抽收赋税,这表明了承认土地私有的合法性。《诗经·小雅·北山》说:“溥天之下,莫非王土。率土之滨,莫非王臣。”这是奴隶社会的情况。天下的土地全是天子的土地,天子再分封一些给他的宗族、亲戚、功臣和古代延续下来的旧国,或者成为国家,或者成为采邑。土地的收入,大部为被封者所享有,一部分还得向天子纳贡。土地的所有权,在天子权力强大时,还是为天子所有。他可以收回,可以另行给予别人。这种情况固然在封建社会完全确立以后还曾出现,如汉代初年,然而实质上却有不同。在汉代以后,基本上已经消灭了农业奴隶,而且土地可以自由买卖。而在奴隶社会,从事农业的基本上是奴隶,土地既是“王土”,当然不得自由买卖。鲁国的“初税亩”,至少打破了“莫非王土”的传统,承认土地为某一宗族所有,甚至为某一个人所有。一部《春秋左传》和其它春秋史料,虽然不曾明显地记载着土地自由买卖的情况,但出现有下列几种情况。已经有自耕农,长沮、桀溺便是。《左传》记载着鲁襄公二十七年(孔子出生后五年或六年),申鲜虞“仆赁于野”,这就是说产生了雇农。《左传》昭公二十五年说鲁国的季氏“隐民多取食焉”,隐民就是游民。游民来自各方,也很有可能来自农村。游民必然是自由身份,才能向各大氏族投靠。春秋时,商业很发达,商人有时参与政治。《左传》僖公三十三年记载着郑国商人弦高的事。他偶然碰着秦国来侵的军队,便假借郑国国君名义去犒劳秦军,示意郑国早有准备。昭公十六年,郑国当政者子产宁肯得罪晋国执政大臣韩起,不肯向无名商人施加小小压力逼他出卖玉环。到春秋晚期,孔子学生子贡一面做官,一面做买卖。越国的大功臣范蠡帮助越王勾践灭亡吴国后,便抛弃官位而去做商人,大发其财。这些现象应该能说明两点:一是社会购买力已有一定发展,而购买力的发展是伴随生产力,尤其农业生产力的发展而来的。没有土地所有制的改革,农业生产力是不容有较快较大发展的。于是乎又可以说明,田地可能自由买卖了,兼并现象也发生了,不仅雇农和游民大量出现,而且商人也可以经营皮毛玉贝等货物,经营田地和农产品。\n至于“率土之滨,莫非王臣”这一传统,更容易地被打破。周天子自平王东迁以后,王仅仅享有虚名,因之一般士大夫,不仅不是“王臣”,而且各有其主。春秋初期,齐国内乱,便有公子纠和公子小白争夺齐国君位之战。管仲和召忽本是公子纠之臣,鲍叔牙则是小白(齐桓公)之臣。小白得胜,召忽因之而死,管仲却转而辅佐齐桓公。晋献公死后,荀息是忠于献公遗嘱拥护奚齐的,但另外很多人,却分别为公子重耳(晋文公)、公子夷吾(晋惠公)之臣。有的甚至由本国出去做别国的官,《左传》襄公二十六年便述说若干楚国人才为晋国所用的情事。卽以孔子而言,从来不曾做过“王臣”。他从很卑微的小吏,如“委吏”(仓库管理员),如“乘田”(主持畜牧者——俱见《孟子·万章下》),进而受到鲁国权臣季氏的赏识,才进入“大夫”的行列。鲁国不用他,他又臣仕于自己讥评为“无道”的卫灵公。甚至晋国范氏、中行氏的党羽佛佾盘踞中牟(在今河北省邢台市和邯郸市之间),来叫孔子去,孔子也打算去。(17.7)这些事例,说明所谓“莫非王土”、“莫非王臣”的传统观念早已随着时间的流逝,形势的变迁,被人轻视,甚至完全抛弃了。\n孔子所处的社会,是动荡的社会;所处的时代,是变革的时代。公元前五四六年,卽孔子出生后五、六年,晋、楚两大国在宋国召开了弭兵大会。自此以后,诸侯间的兼并战争少了,而各国内部,尤其是大国内部,权臣间或者强大氏族间的你吞我杀,却多起来了。鲁国呢,三大氏族(季氏、孟氏、仲氏)互相兼并现象不严重,但和鲁国公室冲突日益扩大。甚至迫使鲁昭公寄居齐国和晋国,死在晋国边邑干侯,鲁哀公出亡在越国,死在越国。\n这种动荡和变革,我认为是由奴隶社会崩溃而逐渐转化为封建社会引起的。根据《左传》,在孔子出生前十年或十一年,卽鲁襄公十年,鲁国三大家族便曾“三分公室而各有其一”。这就是把鲁君的“三郊三遂”(《尚书·费誓》)的军赋所出的土地人口全部瓜分为三,三家各有其一,而且把私家军队也并入,各帅一军。但三家所采取的军赋办法不同。季氏采取封建社会的办法,所分得的人口全部解放为自由民。孟氏采取半封建半奴隶的办法,年轻力壮的仍旧是奴隶。叔孙氏则依旧全用奴隶制。过了二十五年,又把公室再瓜分一次,分为四份,季氏得一半,孟氏和叔孙氏各得四分之一,都废除奴隶制。这正是孔子所耳闻目见的国家的大变化。在这种变革动荡时代中,自然有许多人提出不同主张。当时还谈不上“百家争鸣”,但主张不同则是自然的。孔子作为救世者,也有他的主张。他因而把和自己意见不同的主张称为“异端”。还说:“攻乎异端,斯害也已。”(2.16)\n孔子的志向很大,要做到“老者安之,朋友信之,少者怀之”。(5.26)在鲁国行不通,到齐国也碰壁,到陈蔡等小国,更不必说了。在卫国,被卫灵公供养,住了较长时间,晚年终于回到鲁国。大半辈子精力用于教育和整理古代文献。他对后代的最大贡献也就在这里。\n(二)孔子思想体系的渊源 # 孔子的世界观,留在下面再谈。我们先讨论孔子思想体系卽他的世界观形成的渊源。我认为从有关孔子的历史资料中选择那些最为可信的,来论定孔子的阶级地位、经历、学术以及所受的影响等等,这就可以确定孔子的思想体系形成的渊源。\n第一,孔子纵然是殷商的苗裔,但早巳从贵族下降到一般平民。他自己说:“吾少也贱。”足以说明他的身世。他父亲,《史记》称做叔梁纥,这是字和名的合称,春秋以前有这种称法,字在前,名在后。“叔梁”是字,“纥”是名。《左传》称做郰人纥(襄公十年),这是官和名的合称。春秋时代一些国家,习惯把一些地方长官叫“人”,孔子父亲曾经做过郰地的宰(卽长官),所以叫他做郰人纥。郰人纥在孔子出生后不久死去,只留得孔子的寡母存在。相传寡母名征在。寡母抚养孔子,孔子也得赡养寡母,因之,他不能不干些杂活。他自己说:“吾少也贱,故多能鄙事。”(9.6)鄙事就是杂活。委吏、乘田或许还是高级的“鄙事”。由此可以说,孔子的祖先出身贵族,到他自己,相隔太久了,失去了贵族的地位。他做委吏也好,做乘田也好,干其它“鄙事”也好,自必有一些共事的同伴。那些人自然都贫贱。难道自少小和他共事的贫贱者,不给孔子一点点影响么?孔子也能够完全摆脱那些人的影响么?这是不可能的。\n第二,孔子是鲁国人。在孔子生前,鲁国政权已在季、孟、仲孙三家之手,而季氏权柄势力又最大。以季氏而论,似乎有些自相矛盾的做法。当奴隶制度衰落时,他分得“公室”三分之一,便采用封建的军赋制度;到昭公五年,再“四分公室”,其它二家都学习他的榜样,全都采用封建军赋制度。这是他的进步处。但鲁昭公自二十五年出外居于齐国,到三十二年死在干侯,鲁国几乎七年没有国君,国内照常安定自不必说,因为政权早巳不在鲁昭公手里。但季氏,卽叫季孙意如的,却一点也没有夺取君位的意图,还曾想把鲁昭公迎接回国;鲁昭公死了,又立昭公之弟定公为君。这不能说是倒退的,也不能说是奇怪的,自然有它的原由。第一,正是这个时候,齐国的陈氏(《史记》作田氏)有夺取姜齐政柄的趋向,鲁昭公三年晏婴曾经向晋国的叔向作了这种预言,叔向也向晏婴透露了他对晋国公室削弱卑微的看法。然而,当时还没有一个国家由权臣取代君位的,季氏还没有胆量开这一先例。何况鲁国是弱小国家,齐、秦、晋、楚这些强大之国,能不以此为借口而攻伐季氏么?第二,鲁国是为西周奴隶社会制作礼乐典章法度的周公旦后代的国家,当时还有人说:“周礼尽在鲁矣。”(《左传》昭公二年)还说:鲁“犹秉周礼”(闵公元年)。周礼的内容究竟怎样,现在流传的周礼不足为凭。但周公姬旦制作它,其本意在于巩固奴隶主阶级的统治,是可以肯定的。这种传统在鲁国还有不小力量,季氏也就难以取鲁君之位而代之了。孔子对于季氏对待昭公和哀公的态度,是目见耳闻的,却不曾有一言半语评论它,是孔子没有评论呢?还是没有传下来呢?弄不清楚。这里我只想说明一点,卽孔子作为一个鲁国人,他的思想也不能不受鲁国的特定环境卽鲁国当时的国情的影响。当时的鲁国,正处于新、旧交替之中,卽有改革,而改革又不彻底,这种情况,也反映在孔子的思想上。\n第三,孔子自己说“信而好古”。(7.1)他的学子子贡说他老师“夫子焉不学?而亦何常师之有?”(19.22)孔子自己又说:“三人行,必有我师焉:择其善者而从之,其不善者而改之。”(7.22)可见孔子的学习,不但读书,而且还在于观察别人,尤其在“每事问”。(3.15)卽以古代文献而论,孔子是非常认真看待的。他能讲夏代的礼,更能讲述殷代的礼,却因为缺乏文献,无法证实,以至于感叹言之。(3.9)那么,他爱护古代文献和书籍的心情可想而知。由《论语》一书来考察,他整理过《诗经》的《雅》和《颂》,(9.15)命令儿子学诗学礼。(16.3)自己又说:“五十以学《易》。”(7.17)《易》本来是用来占筮的书,而孔子不用来占筮,却当作人生哲理书读,因此才说:“五十以学《易》,可以无大过矣。”他引用《易》“不恒其德,或承之羞”二句,结论是“不占而已矣”。(13.22)他征引过《尚书》。他也从许多早已亡佚的古书中学习很多东西。举一个例子,他的思想核心是仁。他曾为仁作一定义“克己复礼”。(12.1)然而这不是孔子自己创造的,根据《左传》昭公十二年孔子自己的话,在古代一种“志”书中,早有“克己复礼,仁也”的话。那么,孔子答对颜回“克己复礼为仁”,不过是孔子的“古为今用”罢了。孔子对他儿子伯鱼说:“不学礼,无以立。”(16.13)这本是孟僖子的话,见于《左传》昭公七年。孟僖子说这话时,孔子还不过十七、八岁,自然又是孔子借用孟僖子的话。足见孔子读了当时存在的许多书,吸取了他认为可用的东西,加以利用。古代书籍和古人对孔子都有不小影响。\n第四,古人,尤其春秋时人,有各种政治家、思想家,自然有进步的,有改良主义的,也有保守和倒退的。孔子对他们都很熟知,有的作好评,有的作恶评,有的不加评论。由这些地方,可以看出孔子对他们的看法和取舍,反过来也可从中看出他们对孔子的影响。子产是一位唯物主义者,又是郑国最有名、最有政绩的政治家和外交家。孔子对他极为赞扬。郑国有个“乡校”,平日一般士大夫聚集在那里议论朝廷政治,于是有人主张毁掉它。子产不肯,并且说:“其所善者,吾则行之;其所恶者,吾则改之,是吾师也,若之何毁之?”这时孔子至多十一岁,而后来评论说:“以是观之,人谓子产不仁,吾不信也。”(《左传》襄公三十一年)孔子以“仁”来赞扬子产的极有限的民主作风,足见他对待当时政治的态度。他讥评鲁国早年的执政臧文仲“三不仁”、“三不知(智)”。其中有压抑贤良展禽(柳下惠)一事(《左传》文公二年),而又赞许公叔文子大力提拔大夫僎升居卿位。用人唯贤,不准许压抑贤良,这也是孔子品评人物标准之一。又譬如晋国有位叔向(羊舌佾),当时贤良之士都表扬他,喜爱他。他也和吴季札、齐晏婴、郑子产友好,孔子对他没有什么议论,可能因为他政治态度过于倾向保守罢。春秋时代二三百年,著名而有影响的人物不少,他们的言行,或多或少地影响孔子。这自是孔子思想体系渊源之一。\n以上几点说明,孔子的思想渊源是复杂的,所受的影响是多方面的。我们今天研究孔子,不应当只抓住某一方面,片面地加以夸大,肯定一切或否定一切。\n(三)孔子论天、命、鬼神和卜筮 # 孔子是殷商苗裔,又是鲁国人,这两个国家比其它各国更为迷信。以宋国而论,宇宙有陨星,这是自然现象,也是常见之事,宋襄公是个图霸之君,却还向周内史过问吉凶,使得内史过不敢不诡辞答复。宋景公逝世,有二个养子,宋昭公——养子之一,名“得”,《史记》作“特”——因为作了个好梦,就自信能继承君位。这表示宋国极迷信,认为天象或梦境预示着未来的吉凶。至于鲁国也一样,穆姜搬家,先要用《周易》占筮(《左传》襄公九年);叔孙穆子刚出生,也用《周易》卜筮(《左传》昭公五年);成季尚未出生,鲁桓公既用龟甲卜,又用蓍草筮(《左传》闵公二年),而且听信多年以前的童谣,用这童谣来断定鲁国政治前途。这类事情,在今天看来,都很荒谬。其它各国无不信天、信命、信鬼神。这是奴隶社会以及封建社会的必然现象,唯有真正的唯物主义者而又有勇气的,才不如此。以周太史过而论,他认为“陨星”是“阴阳”之事,而“吉凶由人”,因为不敢得罪宋襄公,才以自己观察所得假“陨星”以答。以子产而论,能说“天道远,人道迩,非所及也”(《左传》昭公十八年),却对伯有作为鬼魂出现这种谣传和惊乱,不敢作勇敢的否定,恐怕一则不愿得罪晋国执政大臣赵景子,二则也不敢过于作违俗之论罢!\n孔子是不迷信的。我认为只有庄子懂得孔子,庄子说:“六合之外,圣人存而不论。”(《庄子·齐物论篇》)庄子所说的“圣人”无疑是孔子,由下文“春秋经世先王之志,圣人议而不辩”可以肯定。“天”、“命”、“鬼神”都是“六合之外,圣人存而不论”的东西。所谓“存而不论”,用现代话说,就是保留它而不置可否,不论其有或无。实际上也就是不大相信有。\n孔子为什么没有迷信思想,这和他治学态度的严谨很有关系。他说过,“多闻阙疑”,“多见阙殆”。(2.18)还说:“学而不思则罔,思而不学则殆。”(2.15)足见他主张多闻、多见和学思结合。“思”什么呢?其中至少包括思考某事某物的道理。虽然当时绝大多数人相信卜筮,相信鬼神,孔子却想不出它们存在的道理。所以他不讲“怪、力、乱、神”。(7.21)“力”和“乱”,或者是孔子不愿谈,“怪”和“神”很大可能是孔子根本采取“阙疑”、“存而不论”的态度。臧文仲相信占卜,畜养一个大乌龟,并且给它极为华丽的地方住,孔子便批评他不聪明,或者说是愚蠢。(5.18)一个乌龟壳怎能预先知道一切事情呢?这是孔子所想不通的。由于孔子这种治学态度,所以能够超出当时一般人,包括宋、鲁二国人之上。“知之为知之,不知为不知”,(2.17)不但于“六合之外”存而不论,卽六合之内,也有存而不论的。\n我们现在来谈谈孔子有关天、命、卜筮和鬼神的一些具体说法和看法。我只用《论语》和《左传》的资料。其它古书的数据,很多是靠不住的,需要更多地审查和选择,不能轻易使用。\n先讨论“天”。\n在《论语》中,除复音词如“天下”、“天子”、“天道”之类外,单言“天”字的,一共十八次。在十八次中,除掉别人说的,孔子自己说了十二次半。在这十二次半中,“天”有三个意义:一是自然之“天”,一是主宰或命运之天,一是义理之天。自然之天仅出现三次,而且二句是重复句:\n天何言哉!四时行焉,百物生焉,天何言哉,(7.19)巍巍乎唯天为大。(8.19)\n义理之天,仅有一次:\n获罪于天,无所祷也。(3.13)\n命运之天或主宰之天就比较多,依出现先后次序録述它:\n予所否者,天厌之!天厌之!(6.28)\n天生德于予,桓魋其如予何?(7.23)\n天之将丧斯文也,后死者不得与于斯文也;天之未丧斯文也,匡人其如予何?(9.5)\n吾谁欺,欺天乎!(9.12)\n不怨天,不尤人。下学而上达,知我者,其天乎!(14.35)\n另外一次是子夏说的。他说:“商闻之矣:死生有命,富贵在天。”但这话子夏是听别人说的。听谁说的呢?很大可能是听孔子说的,所以算它半次。\n若从孔子讲“天”的具体语言环境来说,不过三、四种。一种是发誓,“天厌之”就是当时赌咒的语言。一种是孔子处于困境或险境中,如在匡被围或者桓魋想谋害他,他无以自慰,只好听天。因为孔子很自负,不但自认有“德”,而且自认有“文”,所以把自己的生死都归之于天。一种是发怒,对子路的弄虚作假,违犯礼节大为不满,便骂“欺天乎”。在不得意而又被学生引起牢骚时,只得说“知我者其天乎”。古人也说过,疾病则呼天,创痛则呼父母。孔子这样称天,并不一定认为天真是主宰,天真有意志,不过藉天以自慰、或发泄感情罢了。至于“获罪于天”的“天”,意思就是行为不合天理。\n再讨论“命”,《论语》中孔子讲“命”五次半,讲“天命”三次。也罗列于下:\n亡之!命矣夫!斯人也而有斯疾也!(6.10)\n道之将行也与,命也;道之将废也与,命也。公伯寮其如命何?(14.36)\n不知命,无以为君子也,(20.3)\n同“富贵在天”一样,子夏还听他说过“死生有命”。关于“天命”的有下列一些语句:\n五十而知天命。(2.4)\n君子有三畏:畏天命,……小人不知天命而不畏也。(16.8)\n从文句表面看,孤立地看,似乎孔子是宿命论者,或者如《墨子·天志篇》所主张的一样是天有意志能行令论者。其实不如此。古代人之所以成为宿命论者或者天志论者,是因为他们对于宇宙以至社会现象不能很好理解的缘故。孔子于“六合之外,存而不论”,他认为对宇宙现象不可能有所知,因此也不谈,所以他讲“命”,都是关于人事。依一般人看,在社会上,应该有个“理”。无论各家各派的“理”怎样,各家各派自然认为他们的“理”是正确的,善的,美的。而且他们还要认为依他的“理”而行,必然会得到“善报”;违背他们的“理”而行,必然会有“凶恶”的结果。然而世间事不完全或者大大地不如他们的意料,这就是常人所说善人得不到好报,恶人反而能够荣华富贵以及长寿。伯牛是好人,却害着治不好的病,当孔子时自然无所谓病理学和生理学,无以归之,只得归之于“命”。如果说,孔子是天志论者,认为天便是人间的主宰,自会“赏善而罚淫”,那伯牛有疾,孔子不会说“命矣夫”,而会怨天瞎了眼,怎么孔子自己又说“不怨天”呢?(14.35)如果孔子是天命论者,那一切早已由天安排妥当,什么都不必干,听其自然就可以了,孔子又何必栖栖遑遑“知其不可而为之”呢?人世间事,有必然,有偶然。越是文化落后的社会,偶然性越大越多,在不少人看来,不合“理”的事越多。古人自然不懂得偶然性和必然性以及这两者的关系,由一般有知识者看来,上天似乎有意志,又似乎没有意志,这是谜,又是个不可解的谜,孟子因之说:“莫之为而为者,天也;莫之致而至者,命也。”(《万章上》)这就是把一切偶然性,甚至某些必然性,都归之于“天”和“命”。这就是孔、孟的天命观。\n孔子是怀疑鬼神的存在的。他说:“祭如在,祭神如神在。”(3.12)祭祖先(鬼)好像祖先真在那里,祭神好像神真在那里。所谓“如在”“如神在”,实际上是说并不在。孔子病危,子路请求祈祷,并且征引古书作证,孔子就婉言拒绝。(7.35)楚昭王病重,拒绝祭神,孔子赞美他“知大道”(《左传》哀公六年)。假使孔子真认为天地有神灵,祈祷能去灾得福,为什么拒绝祈祷呢?为什么赞美楚昭王“知大道”呢?子路曾问孔子如何服事鬼神。孔子答说:“活人还不能服事,怎么能去服事死人?”子路又问死是怎么回事。孔子答说:“生的道理还没有弄明白,怎么能够懂得死?”(11.12)足见孔子只讲现实的事,不讲虚无渺茫的事。孔子说:“君子于其所不知,盖阙如也。”(13.3)孔子对死和鬼的问题,回避答复,也是这种表现。那么为什么孔子要讲究祭祀,讲孝道,讲三年之丧呢?我认为,这是孔子利用所谓古礼来为现实服务。殷人最重祭祀,最重鬼神。孔子虽然不大相信鬼神的实有,却不去公开否定它,而是利用它,用曾参的话说:“慎终追远,民德归厚矣。”(1.9)很显然,孔子的这些主张不过企图藉此维持剥削者的统治而已。\n至于卜筮,孔子曾经引《易经》“不恒其德,或承之羞”,结论是不必占卜了。这正如王充《论衡·卜筮篇》所说,“枯骨死草,何能知吉凶乎”(依刘盼遂《集解》本校正)。\n(四)孔子的政治观和人生观 # 在春秋时代,除郑国子产等几位世卿有心救世以外,本人原在下层地位,而有心救世的,像战国时许多人物一般,或许不见得没有,但却没有一人能和孔子相比,这从所有流传下来的历史数据可以肯定。在《论语》一书中反映孔子热心救世,碰到不少隐士泼以冰凉的水。除长沮、桀溺外,还有楚狂接舆、(18.5)荷筱丈人、(18.7)石门司门者(14.38)和微生亩(14.32)等等。孔子自己说:“天下有道,丘不与易也。”(18.6)石门司门者则评孔子为“知其不可而为之”。“知其不可而为之”,可以说是“不识时务”,但也可以说是坚韧不拔。孔子的热心救世,当时未见成效,有客观原因,也有主观原因,这里不谈。但这种“席不暇暖”(韩愈:〈争臣论〉,盖本于《文选·班固答宾戏》),“三月无君则吊”(《孟子·滕文公下》)的精神,不能不说是极难得的,也是可敬佩的。\n孔子的时代,周王室已经无法恢复权力和威信,这是当时人都知道的,难道孔子不清楚?就是齐桓公、晋文公这样的霸主,也已经成为陈迹。中原各国,不是政权落于卿大夫,就是“陪臣执国命”。如晋国先有六卿相争,后来只剩四卿——韩、赵、魏和知伯。《左传》最后载知伯被灭,孔子早“寿终正寝”了。齐国陈恒杀了齐简公,这也是孔子所亲见的。(14.21)在鲁国,情况更不好,“禄之去公室五世(宣、成、襄、昭、定五公)矣,政逮于大夫四世(季文子、武子、平子、桓子四代)矣,故夫三桓之子孙微矣”,(16.3)而处于“陪臣执国命”(16.2)时代。在这种情况下,中原诸国,如卫、陈、蔡等,国小力微,不能有所作为。秦国僻在西方,自秦穆公、康公以后已无力再过问中原的事。楚国又被吴国打得精疲力尽,孔子仅仅到了楚国边境,和叶公相见。(13.16,又7.19)纵然有极少数小官,如仪封人之辈赞许孔子,(3.24)但在二千多年以前,要对当时政治实行较大改变,没有适当力量的凭借是不可能做到的。孔子徒抱大志,感叹以死罢了。\n孔子的政治思想,从尧曰篇可以看出。我认为尧曰篇“谨权量,审法度”以下都是孔子的政治主张。然而度、量、衡的统一直到孔子死后二百五十八年,秦始皇二十六年统一中国后才实行。孔子又说,治理国家要重视三件事,粮食充足,军备无缺,人民信任,而人民信任是极为重要的。(12.7)甚至批评晋文公伐原取信(见《左传》僖公二十六年)为“谲而不正”。(14.15)孔子主张“正名”,(13.3)正名就是“君君,臣臣,父父,子子”;(12.11)而当时正是“君不君,臣不臣,父不父,子不子”。孔子的政绩表现于当时的,一是定公十年和齐景公在夹谷相会,在外交上取得重大胜利;一是子路毁坏季氏的费城,叔孙氏自己毁坏了他们的郈城,唯独孟氏不肯毁坏成城(《左传》定公十二年)。假使三家的老巢城池都被毁了,孔子继续在鲁国做官,他的“君君,臣臣”的主张有可能逐渐实现。但齐国的“女乐”送来,孔子只得离开鲁国了。(18.4)孔子其它政治主张,仅仅托之空言。\n孔子还说:“如有用我者,吾其为东周乎!”(17.5)孔子所谓“东周”究竟是些什么内容,虽然难以完全考定,但从上文所述以及联系孔子其它言行考察,可以肯定绝不是把周公旦所制定的礼乐制度恢复原状。孔子知道时代不同,礼要有“损益”。(2.23)他主张“行夏之时”,(15.11)便是对周礼的改变。夏的历法是以立春之月为一年的第一月,周的历法是以冬至之月为一年的第一月。夏历便于农业生产,周历不便于农业生产。从《左传》或者《诗经》看,尽管某些国家用周历,但民间还用夏历。晋国上下全用夏历。所谓周礼,在春秋以前,很被人重视。孔子不能抛弃这面旗帜,因为它有号召力,何况孔子本来景仰周公?周礼是上层建筑,在阶级社会,封建地主阶级无妨利用奴隶主阶级某些礼制加以改造,来巩固自己的统治。不能说孔子要“复礼”,要“为东周”,便是倒退。他在夹谷会上,不惜用武力对待齐景公的无礼,恐怕未必合乎周礼。由此看来,孔子的政治主张,尽管难免有些保守处,如“兴灭国,继绝世”,(20.1)但基本倾向是进步的,和时代的步伐合拍的。\n至于他的人生观,更是积极的。他“发愤忘食,乐以忘忧,不知老之将至”。(7.19)他能够过穷苦生活,而对于不义的富贵,视同浮云。(7.16)这些地方还不失他原为平民的本色。\n(五)关于忠恕和仁 # 春秋时代重视“礼”,“礼”包括礼仪、礼制、礼器等,却很少讲“仁”。我把《左传》“礼”字统计一下,一共讲了462次:另外还有“礼食”一次,“礼书”、“礼经”各一次,“礼秩”一次,“礼义”三次。但讲“仁”不过33次,少于讲“礼”的至429次之多。并且把礼提到最高地位。《左传》昭公二十六年晏婴对齐景公说:“礼之可以为国也久矣,与天地并。”还有一个现象,《左传》没有“仁义”并言的。《论语》讲“礼”75次,包括“礼乐”并言的;讲“仁”却109次。由此看来,孔子批判地继承春秋时代的思潮,不以礼为核心,而以仁为核心。而且认为没有仁,也就谈不上礼,所以说:“人而不仁,如礼何?”(3.3)\n一部《论语》,对“仁”有许多解释,或者说“克己复礼为仁”,(12.1)或者说“仁者先难而后获”,(6.22)或者说“能行五者(恭、宽、信、敏、惠)于天下为仁”,(17.6)或者说“爱人”就是“仁”,(12.22)还有很多歧异的说法。究竟“仁”的内涵是什么呢?我认为从孔子对曾参一段话可以推知“仁”的真谛。孔子对曾参说:“吾道一以贯之。”曾参告诉其它同学说:“夫子之道,忠恕而已矣。”(4.15)“吾道”就是孔子自己的整个思想体系,而贯穿这个思想体系的,必然是它的核心。分别讲是“忠恕”,概括讲是“仁”。\n孔子自己曾给“恕”下了定义:“己所不欲,勿施于人。”(15.24)这是“仁”的消极面。另一面是积极面:“己欲立而立人,己欲达而达人。”(6.30)而“仁”并不是孔子所认为的最高境界,“圣”才是最高境界。“圣”的目标是:“博施于民而能济众”,(6.30)“修己以安百姓”。(14.41)这个目标,孔子认为尧、舜都未必能达到。\n用具体人物来作证。\n孔子不轻许人以“仁”。有人说:“雍也仁而不佞。”孔子的答复是,“不知其仁(意卽雍不为仁),焉用佞”。(5.5)又答复孟武伯说,子路、冉有、公西华,都“不知其仁”。(5.8)孔子对所有学生,仅仅说“回也其心三月不违仁”,(6.7)这也未必是说颜渊是仁人。对于令尹子文和陈文子,说他们“忠”或“清”,却不同意他们是仁。(5.19)但有一件似乎不无矛盾的事,孔子说管仲不俭,不知礼,(3.22)却说:“桓公九合诸侯,不以兵车,管仲之力也!如其仁!如其仁!”(14.16)由这点看来,孔子认为管仲纵是“有反坫”“有三归”,却帮助齐桓公使天下有一个较长期的(齐桓公在位四十三年)、较安定的局面,这是大有益于大众的事,而这就是仁德,《孟子·告子下》曾载齐桓公葵丘之会的盟约,其中有几条,如“尊贤育才”“无曲防,无遏籴”。并且说:“凡我同盟之人,既盟之后,言归于好。”孟子还说当孟子时的诸侯,都触犯了葵丘的禁令。由此可见,依孔子意见,谁能够使天下安定,保护大多数人的生命,就可以许他为仁。\n孔子是爱惜人的生命的。殷商是奴隶社会,但那时以活奴隶殉葬的风气孔子未必知道。自从生产力有所发展,奴隶对奴隶主多少还有些用处、有些利益以后,奴隶主便舍不得把他们活埋,而用木偶人、土俑代替殉葬的活人了。在春秋,也有用活人殉葬的事。秦穆公便用活人殉葬,殉葬的不仅是奴隶,还有闻名的贤良的三兄弟,秦国人叫他们做“三良”。秦国人谴责这一举动,《诗经·秦风》里《黄鸟》一诗就是哀恸三良、讥刺秦穆公的。《左传》宣公十五年记载晋国魏犨有个爱妾,魏犨曾经告诉他儿子说,我死了,一定嫁了她。等到魏犨病危,却命令儿子,一定要她殉葬,在黄泉中陪侍自己。结果是他儿子魏颗把她嫁出去。足见春秋时代一般人不以用活人殉葬为然。孟子曾经引孔子的话说:“始作俑者,其无后乎!”(《孟子·梁惠王上》)在别处,孔子从来不曾这样狠毒地咒骂人。骂人“绝子灭孙”,“断绝后代”,在过去社会里是谁都忍受不了的。用孟子的话说,“不孝有三,无后为大。”(《孟子·离娄上》)孔子对最初发明用木俑土俑殉葬的人都这样狠毒地骂,对于用活人殉葬的态度又该怎样呢?由此足以明白,在孔子的仁德中,包括着重视人的生命。\n孔子说仁就是“爱人”。后代,尤其现代,有些人说“人”不包括“民”。“民”是奴隶,“人”是士以上的人物。“人”和“民”二字,有时有区别,有时没有区别。以《论语》而论,“节用而爱人,使民以时”,(1.5)“人”和“民”对言,就有区别。“逸民”(18.8)的“民”,便不是奴隶,因为孔子所举的伯夷、叔齐、柳下惠等都是上层人物,甚至是大奴隶主,“人”和“民”便没有区别。纵然在孔子心目中,“士”以下的庶民是不足道的,“民斯为下矣”,(16.9)但他对于“修己以安百姓”(14.42)“博施于民而能济众”(6.30)的人,简直捧得比尧和舜还高。从这里又可以看到,孔子的重视人的性命,也包括一切阶级、阶层的人在内。\n要做到“修己以安人”,至少做到“不以兵车”“一匡天下”,没有相当地位、力量和时间是不行的。但是做到“己所不欲,勿施于人”,孔子以为比较容易。子贡问“有一言而可以终身行之者乎”,孔子便拈出一个“恕”字。实际上在阶级社会中,要做到“己所不欲,勿施于人”,不但极难,甚至不可能,只能是一种幻想,孔子却认为可以“终身行之”,而且这是“仁”的一个方面。于是乎说能“为仁由己”(12.1)了。\n“四人帮”的论客们捉住“克己复礼为仁”(12.1)一句不放,武断地说孔子所要“复”的“礼”是周礼,是奴隶制的礼,而撇开孔子其它论“仁”的话不加讨论,甚至不予参考,这是有意歪曲,妄图借此达到他们政治上的罪恶目的。《论语》“礼”字出现七十四次,其中不见孔子对礼下任何较有概括性的定义。孔子只是说:“人而不仁,如礼何?人而不仁,如乐何?”(3.3)还说:“礼云礼云,玉帛云乎哉?乐云乐云,钟鼓云乎哉?”(17.11)可见孔子认为礼乐不在形式,不在器物,而在于其本质。其本质就是仁。没有仁,也就没有真的礼乐。春秋以及春秋以上的时代,没有仁的礼乐,不过徒然有其仪节和器物罢了。孔子也并不是完全固执不变的人。他主张臣对君要行“拜下”之礼,但对“麻冕”却赞同实行变通,(9.3)以求省俭。他不主张用周代历法,上文已经说过。由此看来,有什么凭据能肯定孔子在复周礼呢?孔子曾经说自己,“我则异于是,无可无不可”,(18.8)孟子说孔子为“圣之时”(万章下),我认为适才是真正的孔子!\n(六)孔子对后代的贡献 # 孔子以前有不少文献,孔子一方面学习它,一方面加以整理,同时向弟子传授。《论语》所涉及的有《易》,有《书》,有《诗》。虽然有“礼”,却不是简册(书籍)。据《礼记·杂记下》“恤由之丧,哀公使孺悲之孔子学士丧礼,士丧礼于是乎书”,那么,《仪礼》诸篇虽出在孔子以后,却由孔子传出。孺悲这人也见于《论语》,他曾求见孔子,孔子不但以有病为辞不接见,还故意弹瑟使他知道是托病拒绝,其实并没有病。(17.20)但孺悲若是受哀公之命而来学,孔子就难以拒绝。《论语》没有谈到《春秋》,然而自《左传》作者以来都说孔子修《春秋》,孟子甚至说孔子作《春秋》。《公羊春秋》和《谷梁春秋》记载孔子出生的年、月、日,《左氏春秋》也记载孔子逝世的年、月、日;而且《公羊春秋》、《谷梁春秋》止于哀公十四年“西狩获麟”,《左氏春秋》则止于哀公十六年“夏四月己丑孔丘卒”。三种《春秋》,二种记载孔子生,一种记载孔子卒,能说《春秋》和孔子没有关系么?我不认为孔子修过《春秋》,更不相信孔子作过《春秋》,而认为目前所传的《春秋》还是鲁史的原文。尽管王安石诋毁《春秋》为“断烂朝报”(初见于苏辙《春秋集解》自序,其后周麟之、陆佃以及《宋史·王安石传》都曾记载这话),但《春秋》二百四十二年的史事大纲却赖此以传。更为重要的事是假若没有《春秋》,就不会有人作《左传》。《春秋》二百多年的史料,我们就只能靠地下挖掘。总而言之,古代文献和孔子以及孔门弟子有关系的,至少有《诗》、《书》、《易》、《仪礼》、《春秋》五种。\n孔子弟子不过七十多人,《史记·孔子世家》说“弟子盖三千焉”,用一“盖”字,就表明太史公说这话时自己也不太相信。根据《左传》昭公二十年记载,琴张往吊宗鲁之死,孔子阻止他。琴张是孔子弟子,这时孔子三十岁。其后又不断地招收门徒,所以孔子弟子有若干批;年龄相差也很大。依《史记·仲尼弟子列传》所载,子路小于孔子九岁,可能是年纪最大的学生。(《史记·索隐》引《孔子家语》说颜无繇只小于孔子六岁,不知可靠否,因不计数。)可能以颛孙师卽子张为最小,小于孔子四十八岁,孔子四十八岁时他才出生。假定他十八岁从孔子受业,孔子已是六十六岁的老人。孔子前半生,有志于安定天下,弟子也跟随他奔走,所以孔子前一批学生从事政治的多,故《左传》多载子路、冉有、子贡的言行。后辈学生可能以子游、子夏、曾参为著名,他们不做官,多半从事教学。子夏曾居于西河,为魏文侯所礼遇,曾参曾责备他“退而老于西河之上,使西河之民疑女于夫子”(《礼记·檀弓上》),可见他在当时名声之大。孔门四科,文学有子游、子夏,(11.3)而子张也在后辈之列,自成一派,当然也设帐教书,所以《荀子·非十二子篇》有“子张氏之贱儒”、“子夏氏之贱儒”和“子游氏之贱儒”。姑不论他们是不是“贱儒”,但他们传授文献,使中国古代文化不致绝灭,而且有发展、有变化,这种贡献开自孔子,行于孔门。若依韩非子显学篇所说,儒家又分为八派。战国初期魏文侯礼待儒生,任用能人;礼待者,卽所谓“君皆师之”(《史记·魏世家》,亦见《韩诗外传》和《说苑》)的,有卜子夏、田子方(《吕氏春秋·当染篇》说他是子贡学生)、段干木(《吕氏春秋·尊贤篇》说他是子夏学生)三人。信用的能人有魏成子,卽推荐子夏等三人之人;有翟璜,卽推荐吴起、乐羊、西门豹、李克、屈侯鲋(《韩诗外传》作“赵苍”)的人。吴起本是儒家,其后成为法家和军事家。李克本是子夏学生,但为魏文侯“务尽地力”,卽努力于开垦并提高农业生产力,而且着有《法经》(《晋书·刑法志》),也变成法家。守孔子学说而不加变通的,新兴地主阶级的头目,只尊重他们,却不任用他们。接受孔门所传的文化教育,而适应形势,由儒变法的,新兴地主阶级的头目却任用他们,使他们竭尽心力,为自己国家争取富强。魏文侯礼贤之后,又有齐国的稷下。齐都(今山东临淄镇)西面城门叫稷门,在稷门外建筑不少学舍,优厚供养四方来的学者,让他们辩论和著书,当时称这班被供养者为稷下先生。稷下可能开始于田齐桓公,而盛于威王、宣王,经历愍王、襄王,垂及王建,历时一百多年。荀子重礼,他的礼近于法家的法,而且韩非、李斯都出自他门下,但纵在稷下“三为祭酒”(《史记·孟荀列传》),却仍然得不到任用,这是由于他仍然很大程度地固守孔子学说而变通不大。但他的讲学和著作,却极大地影响后代。韩非是荀卿学生,也大不以他老师为然。《显学篇》的“孙氏之儒”就是“荀氏之儒”。然而没有孔子和孔门弟子以及其后的儒学,尤其是荀卿,不但不可能有战国的百家争鸣,更不可能有商鞅帮助秦孝公变法(《晋书·刑法志》说:“李悝[卽李克]着法经六篇,商鞅受之以相秦。”),奠定秦始皇统一的基础;尤其不可能有李斯帮助秦始皇统一天下。溯源数典,孔子在学术上、文化上的贡献以及对后代的影响是不可磨灭的。\n孔子的学习态度和教学方法,也有可取之处。孔子虽说“生而知之者上也”,(16.9)自己却说:“我非生而知之者,好古,敏以求之者也。”(7.20)似乎孔子并不真正承认有“生而知之者”。孔子到了周公庙,事事都向人请教,有人讥笑他不知礼。孔子答复是,不懂得就问,正是礼。(3.15)孔子还说:“三人行,必有我师焉:择其善者而从之,其不善者而改之。”(7.22)就是说,在交往的人中,总有我的正面老师,也有我的反面教员。子贡说,孔子没有一定的老师,哪里都去学习。(19.22)我们现在说“活到老,学到老”。依孔子自述的话,“发愤忘食,乐以忘忧,不知老之将至”,(7.19)就是说学习不晓得老。不管时代怎么不同,如何发展,这种学习精神是值得敬佩而采取的。\n孔子自己说“诲人不倦”,(7.2,又34)而且毫无隐瞒。(7.24)元好问〈论诗〉诗说:“鸳鸯绣了从教看,莫把金针度与人。”过去不少工艺和拳术教师,对学生总留一手,不愿意把全部本领尤其最紧要处,最关键处,俗话说的“最后一手”“看家本领”传授下来。孔子则对学生无所隐瞒,因而才赢得学生对他的无限尊敬和景仰。孔子死了,学生如同死了父母一般,在孔子墓旁结庐而居,三年而后去,子贡还继续居住墓旁三年(《孟子·滕文公上》)。有这种“诲人不倦”的老师,才能有这种守庐三年、六年的学生。我们当然反对什么守庐,但能做到师生关系比父子还亲密,总有值得学习的地方。\n孔子对每个学生非常了解,对有些学生作了评论。在解答学生的疑问时,纵然同一问题,因问者不同,答复也不同。颜渊篇记载颜渊、仲弓、司马牛三人“问仁”,孔子有三种答案。甚至子路和冉有都问“闻斯行诸”,孔子的答复竟完全相反,引起公西华的疑问。(11.22)因材施教,在今天的教育中是不是还用得着?我以为还是可以用的,只看如何适应今天的情况而已。时代不同,具体要求和做法必然也不同。然而孔子对待学生的态度和某些教学方法如“不愤不启,不悱不发”,(7.8)就是在今天,也还有可取之处。\n孔子以前,学在官府。《左传》载郑国有乡校,那也只有大夫以上的人及他们的子弟才能入学。私人设立学校,开门招生,学费又非常低廉,只是十条肉干,(7.7)自古以至春秋,恐怕孔子是第一人。有人说同时有少正卯也招收学徒,这事未必可信。纵有这事,但少正卯之学和他的学生对后代毫无影响。\n孔子所招收的学生,除鲁的南宫敬叔以外,如果司马牛果然是桓魋兄弟,仅他们两人出身高门,其余多出身贫贱。据《史记·仲尼弟子列传》,子路“冠雄鸡,佩豭豚”,简直像个流氓。据《史记·游侠列传》,原宪“终身空室蓬户,褐衣疏食”,更为穷困。《论语》说公冶长无罪被囚,假设他家有地位,有罪还未必被囚,何况无罪?足见也是下贱门第。据《弟子列传·正义》引《韩诗外传》,曾参曾经做小吏,能谋斗升之粟来养亲,就很满足,可见曾点、曾参父子都很穷。据《吕氏春秋·尊师篇》,子张是“鲁之鄙家”。颜回居住在陋巷,箪食瓢饮,死后有棺无椁,都见于《论语》。由此推论,孔子学生,出身贫贱的多,出身富贵的可知者只有二人。那么,孔子向下层传播文化的功劳,何能抹杀?《淮南子·要略篇》说:“墨子学儒者之业,受孔子之术。”这不是说墨子出自儒,而是说,在当时,要学习文化和文献,离开孔门不行。韩非子说“今之显学,儒、墨也”,由儒家墨家而后有诸子百家,所以我说,中国文化的流传和发达与孔子的整理古代文献和设立私塾是分不开的。\n导言 # (一)“论语”命名的意义和来由 # 《论语》是这样一部书,它记载着孔子的言语行事,也记载着孔子的若干学生的言语行事。班固的《汉书·艺文志》说:\n“《论语》者,孔子应答弟子、时人及弟子相与言而接闻于夫子之语也。当时弟子各有所记,夫子既卒,门人相与辑而论纂,故谓之《论语》。”\n《文选·辩命论》注引《傅子》也说:\n“昔仲尼既殁,仲弓之徒追论夫子之言,谓之《论语》。”\n从这两段话裹,我们得到两点概念:(1)“论语”的“论”是“论纂”的意思,“论语”的“语”是“语言”的意思,“论语”就是把“接闻于夫子之语”“论纂”起来的意思。(2)“论语”的名字是当时就有的,不是后来别人给它的。\n关于“论语”命名的意义,后来还有些不同的说法,譬如刘熙在《释名·释典艺》中说:“《论语》,记孔子与弟子所语之言也。论,伦也,有伦理也。语,叙也,叙己所欲说也。”那么,“论语”的意义便是“有条理地叙述自己的话”。说到这里,谁都不免会问一句:难道除孔子和他的弟子以外,别人的说话都不是“有条理的叙述”吗?如果不是这样,那“论语”这样命名有什么意义呢?可见刘熙这一解释是很牵强的。(《释名》的训诂名物,以音训为主,其中不少牵强傅会的地方。)还有把“论”解释为“讨论”的,说“论语”是“讨论文义”的书,何异孙的《十一经问对》便如是主张,更是后出的主观的看法了。\n关于《论语》命名的来由,也有不同的说法。王充在《论衡·正说篇》便说:“初,孔子孙孔安国以教鲁人扶卿,官至荆州刺史,始曰《论语》。”似乎是《论语》之名要到汉武帝时才由孔安国、扶卿给它的。这一说法不但和刘歆、班固的说法不同,而且也未必与事实相合,《礼记·坊记》中有这样一段话:\n“子云:君子弛其亲之过而敬其美。《论语》曰:‘三年无改于父之道,可谓孝矣。’”\n坊记的著作年代我们目前虽然还不能确定,但不会在汉武帝以后,是可以断言的①。因之,《论衡》的这一说法也未必可靠。\n由此可以得出结论:“论语”这一书名是当日的编纂者给它命名的,意义是语言的论纂。\n①吴骞《经说》因《坊记》有“论语”之称,便认它是汉人所记,固属武断;而沈约却说《坊记》是子思所作,也欠缺有力论证。\n(二)“论语”的作者和编着年代 # 《论语》又是若干断片的篇章集合体。这些篇章的排列不一定有什么道理;就是前后两章间,也不一定有什么关连。而且这些断片的篇章绝不是一个人的手笔。《论语》一书,篇幅不多,却出现了不少次的重复的章节。其中有字句完全相同的,如“巧言令色鲜矣仁”一章,先见于学而篇第一,又重出于阳货篇第十七:“博学于文”一章先见于雍也篇第六,又重出于颜渊篇第十二。又有基本上是重复只是详略不同的,如“君子不重,”学而篇第一多出十一个字,子罕篇第九只载“主忠信”以下的十四个字;“父在观其志”章,学而篇第一多出十字,里仁篇第四只载“三年”以下的十二字。还有一个意思,却有各种记载的,如里仁篇第四说:“不患莫己知,求为可知也,”宪问篇第十四又说:“不患人之不己知,患其不能也。”卫灵公篇第十五又说:“君子病无能焉,不病人之不己知也。”如果加上学而篇第一的“人不知而不愠,不亦君子乎”,便是重复四次。这种现象只能作一个合理的推论:孔子的言论,当时弟子各有记载,后来才汇集成书。所以《论语》一书绝不能看成某一个人的著作。\n那么,《论语》的作者是一些什么人呢?其中当然有孔子的学生。今天可以窥测得到的有两章。一章在子罕篇第九:\n“牢曰:‘子云:吾不试,故艺。’”\n“牢”是人名,相传他姓琴,字子开,又字子张(这一说法最初见于王肃的伪《孔子家语》,因此王引之的《经义述闻》和刘宝楠的《论语正义》都对它怀疑,认为琴牢和琴张是不同的两个人)。不论这一传说是否可靠,但这里不称姓氏只称名,这种记述方式和《论语》的一般体例是不相吻合的。因此,便可以作这样的推论,这一章是琴牢的本人的记载,编辑《论语》的人,“直取其所记而载之耳”(日本学者安井息轩《论语集说》中语)。另一章就是宪问篇第十四的第一章:\n“宪问耻。子曰:‘邦有道,谷;邦无道,谷,耻也。”\n“宪”是原宪,字子思,也就是雍也篇第六的“原思为之宰”的原思。这里也去姓称名,不称字,显然和《论语》的一般体例不合,因此也可以推论,这是原宪自己的笔墨。\n《论语》的篇章不但出自孔子不同学生之手,而且还出自他不同的再传弟子之手。这里面不少是曾参的学生的记载。像泰伯篇第八的第一章:\n“曾子有疾,召门弟子曰:‘启予足!启予手!《诗》云,战战兢兢,如临深渊,如履薄冰。而今而后,吾知免夫!小子!’”\n不能不说是曾参的门弟子的记载。又如子张篇第十九:\n“子夏之门人问交于子张。子张曰:‘子夏云何?’对曰:‘子夏曰:可者与之,其不可者拒之。’子张曰:‘异乎吾所闻:君子尊贤而容众,嘉善而矜不能。我之大贤与,于人何所不容?我之不贤与,人将拒我,如之何其拒人也?”\n这一段又像子张或者子夏的学生的记载。又如先进篇第十一的第五章和第十三章:\n“子曰:‘孝哉闵子骞,人不间于其父母昆弟之言。”\n“闵子侍侧,誾誾如也;子路,行行如也;冉有、子贡,侃侃如也。子乐。”\n孔子称学生从来直呼其名,独独这里对闵损称字,不能不启人疑窦。有人说,这是“孔子述时人之言”,从上下文意来看,这一解释不可凭信,崔述在《论语余说》中加以驳斥是正确的。我认为这一章可能就是闵损的学生所追记的,因而有这一不经意的失实。至于闵子侍侧一章,不但闵子骞称“子”,而且列在子路、冉有、子贡三人之前,都是难以理解的。以年龄而论,子路最长;以仕宦而论,闵子更赶不上这三人。他凭什么能在这一段记载上居于首位而且得着“子”的尊称呢?合理的推论是,这也是闵子骞的学生把平日闻于老师之言追记下来而成的。\n《论语》一书有孔子弟子的笔墨,也有孔子再传弟子的笔墨,那么,著作年代便有先有后了。这一点,从词义的运用上也适当地反映了出来。譬如“夫子”一词,在较早的年代一般指第三者,相当于“他老人家”,直到战国,才普遍用为第二人称的表敬代词,相当于“你老人家”。《论语》的一般用法都是相当于“他老人家”的,孔子学生当面称孔子为“子”,背面才称“夫子”,别人对孔子也是背面才称“夫子”,孔子称别人也是背面才称“夫子”。只是在阳货篇第十七中有两处例外,言偃对孔子说,“昔者偃也闻诸夫子”;子路对孔子也说,“昔者由也闻诸夫子”,都是当面称“夫子”,“夫子”用如“你老人家”,开战国时运用“夫子”一词的词义之端。崔述在《洙泗考信録》据此来断定《论语》的少数篇章的“驳杂”,固然未免武断;但《论语》的着笔有先有后,其间相距或者不止于三、五十年,似乎可以由此窥测得到。\n《论语》一书,既然成于很多人之手,而且这些作者的年代相去或者不止于三、五十年,那么,这最后编定者是谁呢?自唐人柳宗元以来,很多学者都疑心是由曾参的学生所编定的,我看很有道理。第一,《论语》不但对曾参无一处不称“子”,而且记载他的言行和孔子其它弟子比较起来为最多。除开和孔子问答之词以外,单独记载曾参言行的,还有学而篇两章,泰伯篇五章,颜渊篇一章,宪问篇和孔子的话合并的一章,子张篇四章,总共十三章。第二,在孔子弟子中,不但曾参最年轻,而且有一章还记载着曾参将死之前对孟敬子的一段话。孟敬子是鲁大夫孟武伯的儿子仲孙捷的谥号①。假定曾参死在鲁元公元年(周考王五年,纪元前四三六年。这是依《阙里文献考》“曾子年七十而卒”一语而推定的),则孟敬子之死更在其后,那么,这一事的记述者一定是在孟敬子死后才着笔的。孟敬子的年岁我们已难考定,但《檀弓》记载着当鲁悼公死时,孟敬子对答季昭子的一番话,可见当曾子年近七十之时,孟敬子已是鲁国执政大臣之一了。则这一段记载之为曾子弟子所记,毫无可疑。《论语》所叙的人物和事迹,再没有比这更晚的,那么,《论语》的编定者或者就是这班曾参的学生。因此,我们说《论语》的着笔当开始于春秋末期,而编辑成书则在战国初期,大概是接近于历史事实的②。\n①谥法在什么时候才兴起的,古今说法不同。历代学者相信《逸周书·谥法解》的说法,说起于周初。自王国维发表了〈遹敦跋〉(《观堂集林》卷十八)以后,这一说法才告动摇。王氏的结论说:“周初诸王若文、武、成、康、昭、穆,皆号而非谥也。”又说:“则谥法之作其在宗周共、懿诸王以后乎?”这一说法较可信赖。郭沫若先生则说“当在春秋中叶以后”(《金文丛考·谥法之起源》,又《两周金文辞大系·初序》),这结论则尚待研究。至于疑心“谥法之兴当在战国时代”(〈谥法之起源〉),甚至说“起于战国中叶以后”(《文学遗产》一一七期〈读了关于《周颂·噫嘻篇》的解释)〉,那未免更使人怀疑了。郭先生的后一种结论,不但在其文中缺乏坚强的论证,而且太与古代的文献材料相矛盾。即从《论语》看(如“孔文子何以谓之文也”),从《左传》看(如文公元年、宣公十一年、襄公十三年死后议谥的记载),这些史料,都不能以“托古作伪”四字轻轻了之。因而我对旧说仍作适当保留。唐人陆淳说:“《史记》、《世本》,厉王以前,诸人有谥者少,其后乃皆有谥。”似亦可属余说之佐证。\n②日本学者山下寅次有《论语编纂年代考》(附于其所著《史记编述年代考》内)。谓《论语》编纂年代为纪元前479年(孔子卒年)至400年(子思卒年)之间。虽然其论证与我不同。但结论却基本一致。\n(三)“论语”的版本和真伪 # 《论语》传到汉朝,有三种不同的本子:(1)《鲁论语》二十篇;(2)《齐论语》二十二篇,其中二十篇的章句很多和《鲁论语》相同,但是多出问王和知道两篇;(3)《古文论语》二十一篇,也没有问王和知道两篇,但是把尧曰篇的“子张问”另分为一篇,于是有了两个子张篇。篇次也和《齐论》、《鲁论》不一样,文字不同的计四百多字。《鲁论》和《齐论》最初各有师传,到西汉末年,安昌侯张禹先学习了《鲁论》,后来又讲习《齐论》,于是把两个本子融合为一,但是篇目以《鲁论》为根据,“采获所安”,号为张侯论。张禹是汉成帝的师傅,其时极为尊贵,所以他的这一个本子便为当时一般儒生所尊奉,后汉灵帝时所刻的《熹平石经》就是用的张侯论。《古文论语》是在汉景帝时由鲁恭王刘余在孔子旧宅壁中发现的,当时并没有传授。何晏《论语集解》序说:“《古论》,唯博士孔安国为之训解,而世不传。”《论语集解》并经常引用了孔安国的注。但孔安国是否曾为《论语》作训解,集解中的孔安国说是否伪作,陈鳣的《论语古训》自序已有怀疑,沈涛的《论语孔注辨伪》认为就是何晏自己的伪造品,丁晏的《论语孔注证伪》则认为出于王肃之手。这一官司我们且不去管它。直到东汉末年,大学者郑玄以张侯论为依据,参照《齐论》、《古论》,作了《论语注》。在残存的郑玄《论语注》中我们还可以略略窥见鲁、齐、古三种《论语》本子的异同。然而,我们今天所用的《论语》本子,基本上就是张侯论。于是怀疑《论语》的人便在这里抓住它作话柄。张禹这个人实际上够不上说是一位“经师”,只是一个无耻的政客,附会王氏,保全富贵,当时便被斥为“佞臣”,所以崔述在《论语源流附考》中竟说:“公山、佛肸两章安知非其有意采之以入《鲁论》为己解嘲地(也)乎?”但是,崔述的话纵然不为无理,而《论语》的篇章仍然不能说有后人所杜撰的东西在内,顶多只是说掺杂着孔门弟子以及再传弟子之中的不同传说而已。如果我们要研究孔子,仍然只能以《论语》为最可信赖的材料。无论如何,《论语》的成书要在《左传》之前,我很同意刘宝楠在《论语正义》(公山章)的主张,我们应该相信《论语》来补充《左传》,不应该根据《左传》来怀疑《论语》。至于崔述用后代的封建道德作为标准,以此来范围孔子,来测量《论语》的真伪、纯驳,更是不公平和不客观的。\n(四)略谈古今“论语”注释书籍 # 《论语》自汉代以来,便有不少人注解它,《论语》和孝经是汉朝初学者必读书,一定要先读这两部书,才进而学习“五经”,“五经”就是今天的《诗经》、《尚书》(除去伪古文)、《易经》、《仪礼》和春秋。看来,《论语》是汉人启蒙书的一种。汉朝人所注释的《论语》,基本上全部亡佚,今日所残存的,以郑玄(127—200,《后汉书》有传)注为较多,因为敦煌和日本发现了一些唐写本残卷,估计十存六七;其它各家,在何晏(190—249)《论语集解》以后,就多半只存于《论语集解》中。现在十三经注疏《论语注疏》就用何晏《集解》,宋人邢昺(932—1010,《宋史》有传)的疏。至于何晏、邢昺前后还有不少专注《论语》的书,可以参看清人朱彝尊(1629—1709,清史稿有传)《经义考》,纪昀(1724—1805)等的《四库全书总目提要》以及唐陆德明(550左右—630左右。新、旧《唐书》对他的生卒年并没有明确记载,此由《册府元龟》卷九十七推而估计之)《经典释文·序録》和吴检斋(承仕)师的疏证。\n我曾经说过,关于《论语》的书,真是汗牛充栋,举不胜举。读者如果认为看了《论语译注》还有进一步研究的必要,可以再看下列几种书:\n(1)《论语注疏》——即何晏《集解》、邢昺疏,在十三经注疏中,除武英殿本外,其它各本多沿袭阮元南昌刻本,因它有校勘记,可以参考。其本文文字出现于校勘记的,便在那文字句右侧用小圈作标帜,便于查考。\n(2)《论语集注》——宋朱熹(1130—1200)从《礼记》中抽出《大学》和《中庸》,合《论语》、《孟子》为四书,自己用很大功力做集注。固然有很多封建道德迂腐之论,朱熹本人也是个客观唯心主义者。但一则自明朝以至清末,科举考试,题目都从《四书》中出;所做文章的义理,也不能违背朱熹的见解,这叫做“代圣人立言”,影响很大。二则朱熹对于《论语》,不但讲“义理”,也注意训诂。所以这书无妨参看。\n(3)刘宝楠(1791—1855)《论语正义》——清代儒生大多不满意于唐、宋人的注疏,所以陈奂(1786—1863)作毛诗传疏,焦循(1763一1820)作《孟子正义》。刘宝楠便依焦循作《孟子正义》之法,作《论语正义》,因病而停笔,由他的儿子刘恭冕(1821—1880)继续写定。所以这书实是刘宝楠父子二人所共着。征引广博,折中大体恰当。只因学问日益进展,当日的好书,今天便可以指出不少缺点,但参考价值仍然不小。\n(4)程树德《论语集释》。此书在例言中已有论述,不再重复。\n(5)杨树达(1885—1956)《论语疏证》。这书把三国以前所有征引《论语》或者和《论语》的有关资料都依《论语》原文疏列,有时出己意,加案语。值得参考。\n例言 # 一、在本书中,著者的企图是:帮助一般读者比较容易而正确地读懂《论语》,并给有志深入研究的人提供若干线索。同时,有许多读者想藉自学的方式提高阅读古书的能力,本书也能起一些阶梯作用。\n二、《论语》章节的分合,历代版本和各家注解本互相间稍有出入,著者在斟酌取舍之后,依照旧有体例,在各篇篇名之下,简略地记述各重要注解本的异同。\n三、《论语》的本文,古今学者作了极为详尽的校勘,但本书所择取的只是必须对通行本的文字加以订正的那一部分。而在这一部分中,其有刊本足为依据的,便直接用那一刊本的文字;不然,仍用通行本的文字印出,只是在应加订正的原文之下用较小字体注出来。\n四、译文在尽可能不走失原意并保持原来风格下力求流畅明白。但古人言辞简略,有时不得不加些词句。这些在原文涵义之外的词句,外用方括号[]作标志。\n五、在注释中,著者所注意的是字音词义、语法规律、修辞方式、历史知识、地理沿革、名物制度和风俗习惯的考证等等,依出现先后以阿拉伯数字为标记。\n六、本书虽然不纠缠于考证,但一切结论都是从细致深入的考证中提炼出来的。其中绝大多数为古今学者的研究成果,也间有著者个人千虑之一得。结论固很简单,得来却不容易。为便于读者查究,有时注明出处,有时略举参考书籍,有时也稍加论证。\n七、字音词义的注释只限于生僻字、破读和易生歧义的地方,而且一般只在第一次出现时加注。注音一般用汉语拼音,有时兼用直音法,而以北京语音为标准。直音法力求避免古今音和土语方言的歧异。但以各地方言的纷歧庞杂,恐难完全避免,所以希望读者依照汉语拼音所拼成的音去读。\n八、注释以及词典中所用的语法术语以及其所根据的理论,可参考我的另一本着作《文言语法》(北京出版社出版)。\n九、《论语》中某地在今日何处,有时发生不同说法,著者只选择其较为可信的,其它说法不再征引。今日的地名暂依中华人民共和国行政区划简册,这本书是依据1975年底由公安部编成的。\n十、朱熹的《论语集注》,虽然他自己也说,“至于训诂皆仔细者”(《朱子语类大全》卷十一),但是,他究竟是个唯心主义者,也有意地利用《论语》的注释来阐述自己的哲学思想,因之不少主观片面的说法;同时,他那时的考据之学、训诂之学的水平远不及后代,所以必须纠正的地方很多。而他这本书给后代的影响特别大,至今还有许多人“积非成是”,深信不疑。因之,在某些关节处,著者对其错误说法,不能不稍加驳正。\n十一、《论语》的词句,几乎每一章节都有两三种以至十多种不同的讲解。一方面,是由于古今人物引用《论语》者“断章取义”的结果。我们不必去反对“断章取义”的做法(这实在是难以避免的),但是不要认为其断章之义就是《论语》的本义。另一方面,更有许多是由于解释《论语》者“立意求高”的结果。金人王若虚在其所著《滹南遗老集》卷五中说:\n“‘子曰,十室之邑必有忠信如丘者焉,不如丘之好学也。’或训‘焉’为‘何’,而属之下句。‘厩焚,子退朝,曰,伤人乎,不问马。’或读‘不’为‘否’而属之上句(著者案:当云另成一读)。意圣人至谦,必不肯言人之莫己若;圣人至仁,必不贱畜而无所恤也。义理之是非姑置勿论,且道世之为文者有如此语法乎?故凡解经,其论虽高,而于文势语法不顺者亦未可遽从,况未高乎?”\n我非常同意这种意见。因之,著者的方针是不炫博,不矜奇,像这样的讲解,一概不加论列。但也不自是,不遗美。有些讲解虽然和“译文”有所不同,却仍然值得考虑,或者可以两存,便也在注释中加以征引。也有时对某些流行的似是而非的讲解加以论辨。\n十二、本书引用诸家,除仲父及师友称字并称“先生”外,余皆称名。\n十三、本书初稿曾经我叔父遇夫(树达)先生逐字审读,直接加以批改,改正了不少错误。其后又承王了一(力)教授审阅,第二次稿又承冯芝生(友兰)教授审阅,两位先生都给提了宝贵意见。最后又承古籍出版社童第德先生提了许多意见。著者因此作了适当的增改。对冯、王、童三位先生,著者在此表示感谢;但很伤心的是遇夫先生已经不及看到本书的出版了。\n十四、著者在撰述“译注”之先,曾经对《论语》的每一字、每一词作过研究,编着有“《论语》词典”一稿。其意在尽可能地弄清《论语》本文每字每词的涵义,译注才有把握。“得鱼忘筌”,译注完稿,“词典”便被弃置。最近吕叔湘先生向我建议,可以仿效苏联《普希金词典》的体例,标注每词每义的出现次数,另行出版。我接受了这一建议,把“词典”未定稿加以整理。但以为另行出版,不如附于“译注”之后,以收相辅相成的效用。详于“注释”者,“词典”仅略言之;“注释”未备者,“词典”便补充之,对读者或者有些好处。在这里,自不能不对吕先生的这一可宝贵的提议表示感谢。\n十五、古今中外关于《论语》的著作真是“汗牛充栋”。仅日本学者林泰辅博士在《论语年谱》中所著録的便达三千种之多,此外还有他所不曾着録和不及着録的,又还有散见于别的书籍的大量零星考证材料。程树德的《论语集释》,征引书籍六百八十种,可说是繁富的了,然而还未免有疏略和可以商量的地方。著者以前人已有的成果为基础,着手虽然比较容易,但仍限于学力和见解,一定还有不妥以至错误之处,诚恳地希望读者指正。\n著者\n一九五六年七月十六日写讫,一九五七年\n三月廿六日增改。一九七九年十二月修订\n"},{"id":117,"href":"/zh/docs/culture/%E8%AE%BA%E8%AF%AD/%E8%AE%BA%E8%AF%AD%E8%AF%91%E6%B3%A8_%E6%9D%A8%E4%BC%AF%E5%B3%BB/04%E9%87%8C%E4%BB%81%E7%AF%87%E7%AC%AC%E5%9B%9B/","title":"04里仁篇第四","section":"论语译注 杨伯峻","content":" 里仁篇第四 # (共二十六章)\n4.1子曰:“里⑴仁为美。择不处⑵仁,焉得知⑶?”\n【译文】孔子说:“住的地方,要有仁德这才好。选择住处,没有仁德,怎么能是聪明呢?”\n【注释】⑴里——这里可以看为动词。居住也。⑵处——上声,音杵,chǔ,居住也。⑶知——《论语》的“智”字都如此写。这一段话,究竟孔子是单纯地指“择居”而言呢,还是泛指,“择邻”、“择业”、“择友”等等都包括在内呢?我们已经不敢肯定。《孟子·公孙丑上》云:“孟子曰:‘矢人岂不仁于函人哉?矢人惟恐不伤人,函人惟恐伤人。巫、匠亦然。故术不可不慎也。孔子曰,里仁为美。择不处仁,焉得智?’”便是指择业。因此译文于“仁”字仅照字面翻译,不实指为仁人。\n4.2子曰:“不仁者不可以久处约,不可以长处乐。仁者安仁,知者利仁。”\n【译文】孔子说:“不仁的人不可以长久地居于穷困中,也不可以长久地居于安乐中。有仁德的人安于仁[实行仁德便心安,不实行仁德心便不安];聪明人利用仁[他认识到仁德对他长远而巨大的利益,他便实行仁德]。”\n4.3子曰:“唯仁者能好人,能恶人⑴。”\n【译文】孔子说:“只有仁人才能够喜爱某人,厌恶某人。”\n【注释】⑴唯仁者能好人,能恶人——《后汉书·孝明八王传》注引《东观汉记》说:和帝赐彭城王恭诏曰:“孔子曰,‘惟仁者能好人,能恶人’。——贵仁者所好恶得其中也。”我认为“贵仁者所好恶得其中”,正可说明这句。\n4.4子曰:“苟志于仁矣,无恶也。”\n【译文】孔子说:“假如立定志向实行仁德,总没有坏处。”\n4.5子曰:“富与贵,是人之所欲也;不以其道得之,不处也。贫与贱,是人之所恶也;不以其道得之⑴,不去也。君子去仁,恶乎⑵成名?君子无终食之间违⑶仁,造次必于是,颠沛必于是。”\n【译文】孔子说:“发大财,做大官,这是人人所盼望的;不用正当的方法去得到它,君子不接受。穷困和下贱,这是人人所厌恶的;不用正当的方法去抛掉它,君子不摆脱。君子抛弃了仁德,怎样去成就他的声名呢?君子没有吃完一餐饭的时间离开仁德,就是在仓卒匆忙的时候一定和仁德同在,就是在颠沛流离的时候一定和仁德同在。”\n【注释】⑴贫与贱……不以其道得之——“富与贵”可以说“得之”,“贫与贱”却不是人人想“得之”的。这里也讲“不以其道得之”,“得之”应该改为“去之”。译文只就这一整段的精神加以诠释,这里为什么也讲“得之”可能是古人的不经意处,我们不必再在这上面做文章了。⑵恶乎——恶音乌,wū,何处。“恶乎”卽“于何处”,译文意译为“怎样”。⑶违——离开,和公冶长篇第五的“弃而违之”的“违”同义。\n4.6子曰:“我未见好仁者,恶不仁者。好仁者,无以尚⑴之;恶不仁者,其为仁矣⑵,不使不仁者加乎其身。有能一日用其力于仁矣乎?我未见力不足者。盖⑶有之矣,我未之见也。”\n【译文】孔子说:“我不曾见到过爱好仁德的人和厌恶不仁德的人。爱好仁德的人,那是再好也没有的了;厌恶不仁德的人,他行仁德只是不使不仁德的东西加在自己身上。有谁能在某一天使用他的力量于仁德呢?我没见过力量不够的。大概这样人还是有的,我不曾见到罢了。”\n【注释】⑴尚——动词,超过之意。⑵矣——这个“矣”字用法同“也”,表示停顿。⑶盖——副词,大概之意。\n4.7子曰:“人之过也,各于其党。观过,斯知仁⑴矣。”\n【译文】孔子说:“[人是各种各样的,人的错误也是各种各样的。]什么样的错误就是由什么样的人犯的。仔细考察某人所犯的错误,就可以知道他是什么样式的人了。”\n【注释】⑴仁——同“人”。《后汉书·吴佑传》引此文正作“人”(武英殿本却又改作“仁”,不可为据)。\n4.8子曰:“朝闻道,夕死可矣。”\n【译文】孔子说:“早晨得知真理,要我当晚死去,都可以。”\n4.9子曰:“士志于道,而耻恶衣恶食者,未足与议也。”\n【译文】孔子说:“读书人有志于真理,但又以自己吃粗粮穿破衣为耻辱,这种人,不值得同他商议了。”\n4.10子曰:“君子之于天下也,无适⑴也,无莫⑴也,义之与比⑵。”\n【译文】孔子说:“君子对于天下的事情,没规定要怎样干,也没规定不要怎样干,只要怎样干合理恰当,便怎样干。”\n【注释】⑴适,莫——这两字讲法很多,有的解为“亲疏厚薄”,“无适无莫”便是“情无亲疏厚薄”。有的解为“敌对与羡慕”,“无适(读为敌)无莫(读为慕)”便是“无所为仇,无所欣羡”。我则用朱熹《集注》的说法。⑵比——去声,bì,挨着,靠拢,为邻。从孟子和以后的一些儒家看来,孔子“无必无固”(9.4),通权达变,“可以仕则仕,可以止则止,可以久则久,可以速则速”(《孟子·公孙丑上》),唯义是从,叫做“圣之时”,或者可以做这章的解释。\n4.11子曰:“君子怀德,小人怀土⑴;君子怀刑⑵,小人怀惠。”\n【译文】孔子说:“君子怀念道德,小人怀念乡土;君子关心法度,小人关心恩惠。”\n【注释】⑴土——如果解为田土,亦通。⑵刑——古代法律制度的“刑”作“刑”,刑罚的“刑”作“㓝”,从刀井,后来都写作“刑”了。这“刑”字应该解释为法度。\n4.12子曰:“放⑴于利而行,多怨。”\n【译文】孔子说:“依据个人利益而行动,会招致很多的怨恨。”\n【注释】⑴放——旧读上声,音仿,fǎng,依据。\n4.13子曰:“能以礼让为国乎?何有⑴?不能以礼让为国,如礼何⑵?”\n【译文】孔子说:“能够用礼让来治理国家吗?这有什么困难呢?如果不能用礼让来治理国家,又怎样来对待礼仪呢?”\n【注释】⑴何有——这是春秋时代的常用语,在这里是“有何困难”的意思。黄式三《论语后案》、刘宝楠《论语正义》都说:“何有,不难之词。”⑵如礼何——依孔子的意见,国家的礼仪必有其“以礼让为国”的本质,它是内容和形式的统一体。如果舍弃它的内容,徒拘守那些仪节上的形式,孔子说,是没有什么作用的。\n4.14子曰:“不患无位,患所以立⑴。不患莫己知,求为可知也。”\n【译文】孔子说:“不发愁没有职位,只发愁没有任职的本领;不怕没有人知道自己,去追求足以使别人知道自己的本领好了。”\n【注释】⑴患所以立——“立”和“位”古通用,这“立”字便是“不患无位”的“位”字。《春秋》桓公二年“公卽位”,石经作“公卽立”可以为证。\n4.15子曰:“参乎!吾道一以贯⑴之。”曾子曰:“唯。”子出,门人问曰:“何谓也?”曾子曰:“夫子之道,忠恕⑵而已矣。”\n【译文】孔子说:“参呀!我的学说贯穿着一个基本观念。”曾子说:“是。”孔子走出去以后,别的学生便问曾子道:“这是什么意思”曾子道:“他老人家的学说,只是忠和恕罢了。”\n【注释】⑴贯——贯穿、统贯。阮元《揅经室集》有〈一贯说〉,认为《论语》的“贯”字都是“行”、“事”的意义,未必可信。⑵忠、恕——“恕”,孔子自己下了定义:“己所不欲,勿施于人。”“忠”则是“恕”的积极一面,用孔子自己的话,便应该是:“己欲立而立人,己欲达而达人。”\n4.16子曰:“君子⑴喻于义,小人⑴喻于利。”\n【译文】孔子说:“君子懂得的是义,小人懂得的是利。”\n【注释】⑴君子、小人——这里的“君子”、“小人”是指在位者,还是指有德者,还是两者兼指,孔子原意不得而知。《汉书·杨恽传·报孙会宗书》曾引董仲舒的话说:“明明求仁义常恐不能化民者,卿大夫之意也;明明求财利常恐困乏者,庶人之事也。”只能看作这一语的汉代经师的注解,不必过信。\n4.17子曰:“见贤思齐焉,见不贤而内自省也。”\n【译文】孔子说:“看见贤人,便应该想向他看齐;看见不贤的人,便应该自己反省,[有没有同他类似的毛病。]”\n4.18子曰:“事父母几⑴谏,见志不从,又敬不违⑵,劳⑶而不怨。”\n【译文】孔子说:“侍奉父母,[如果他们有不对的地方,]得轻微婉转地劝止,看到自己的心意没有被听从,仍然恭敬地不触犯他们,虽然忧愁,但不怨恨。”\n【注释】⑴几——平声,音机,jī,轻微,婉转。⑵违——触忤,冒犯。⑶劳——忧愁。说见王引之《经义述闻》。\n4.19子曰:“父母在,不远游,游必有方。”\n【译文】孔子说:“父母在世,不出远门,如果要出远门,必须有一定的去处。”\n4.20子曰:“三年无改于父之道,可谓孝矣⑴。”\n【注释】⑴见学而篇。(1.11)\n4.21子曰:“父母之年,不可不知也。一则以喜,一则以惧。”\n【译文】孔子说:“父母的年纪不能不时时记在心里:一方面因[其高寿]而喜欢,另一方面又因[其寿高]而有所恐惧。”\n4.22子曰:“古者言之不出,耻⑴躬之不逮⑵也。”\n【译文】孔子说:“古时候言语不轻易出口,就是怕自己的行动赶不上。”\n【注释】⑴耻——动词的意动用法,以为可耻的意思。⑵逮——音代,dài,及,赶上。\n4.23子曰:“以约⑴失之者鲜矣。”\n【译文】孔子说:“因为对自己节制、约束而犯过失的,这种事情总不会多。”\n【注释】⑴约——《论语》的“约”字不外两个意义:(甲)穷困,(乙)约束。至于节俭的意义,虽然已见于《荀子》,却未必适用于这里。\n4.24子曰:“君子欲讷⑴于言而敏于行⑵。”\n【译文】孔子说:“君子言语要谨慎迟钝,工作要勤劳敏捷。”\n【注释】⑴讷——读nà,语言迟钝。⑵讷于言敏于行——造句和学而篇的“敏于事而慎于言”意思一样,所以译文加“谨慎”两字,同时也把“行”字译为“工作”。\n4.25子曰:“德不孤,必有邻⑴。”\n【译文】孔子说:“有道德的人不会孤单,一定会有[志同道合的人来和他做]伙伴。”\n【注释】⑴德不孤必有邻——《易·系辞上》说:“方以类聚,物以羣分。”又《干·文言》说:“子曰:同声相应,同气相求。”这都可以作为“德不孤”的解释。\n4.26子游曰:“事君数⑴,斯辱矣;朋友数⑴,斯疏矣。”\n【译文】子游说:“对待君主过于烦琐,就会招致侮辱;对待朋友过于烦琐,就会反被疏远。”\n【注释】⑴数——音朔,shuò,密,屡屡。这里依上下文意译为“烦琐”。颜渊篇第十二说:“子贡问友。子曰:‘忠告而善道之,不可则止,无自辱焉。’”也正是这个意思。\n"},{"id":118,"href":"/zh/docs/culture/%E8%AE%BA%E8%AF%AD/%E8%AE%BA%E8%AF%AD%E8%AF%91%E6%B3%A8_%E6%9D%A8%E4%BC%AF%E5%B3%BB/16%E5%AD%A3%E6%B0%8F%E7%AF%87%E7%AC%AC%E5%8D%81%E5%85%AD/","title":"16季氏篇第十六","section":"论语译注 杨伯峻","content":" 季氏篇第十六 # (共十四章)\n16.1季氏将伐颛臾⑴。冉有、季路见于孔子曰:“季氏将有事⑵于颛臾。”\n孔子曰:“求!无乃尔是过⑶与?夫颛臾,昔者先王以为东蒙⑷主,且在邦域之中矣,是社稷之臣也。何以伐为?”\n冉有曰:“夫子欲之,吾二臣者皆不欲也。”\n孔子曰:“求!周任⑸有言曰:‘陈力就列,不能者止。’危而不持,颠而不扶,则将焉用彼相矣?且尔言过矣,虎兕出于柙,龟玉毁于椟中,是谁之过与?”\n冉有曰:“今夫颛臾,固而近于费⑹。今不取,后世必为子孙忧。”\n孔子曰:“求!君子疾夫舍⑺曰欲之而必为之辞。丘也闻有国有家者,不患寡当作贫而患不均,不患贫当作寡而患不安⑻。盖均无贫,和无寡,安无倾。夫如是,故远人不服,则修文德以来之。既来之,则安之。今由与求也,相夫子,远人不服,而不能来也;邦分崩离析,而不能守也;而谋动干戈于邦内。吾恐季孙之忧,不在颛臾,而在萧墙之内⑼也。”\n【译文】季氏准备攻打颛臾。冉有、子路两人谒见孔子,说道:“季氏准备对颛臾使用兵力。”\n孔子道:“冉求,这难道不应该责备你吗?颛臾,上代的君王曾经授权他主持东蒙山的祭祀,而且它的国境早在我们最初被封时的疆土之中,这正是和鲁国共安危存亡的藩属,为什么要去攻打它呢?”\n冉有道:“季孙要这么干,我们两人本来都是不同意的。”\n孔子道:“冉求!周任有句话说:‘能够贡献自己的力量,这再任职;如果不行,就该辞职。’譬如瞎子遇到危险,不去扶持;将要摔倒了,不去搀扶,那又何必用助手呢?你的话是错了。老虎犀牛从槛里逃了出来,龟壳美玉在匣子里毁坏了,这是谁的责任呢?”\n冉有道:“颛臾,城墙既然坚牢,而且离季孙的采邑费地很近。现今不把它占领,日子久了,一定会给子孙留下祸害。”\n孔子道:“冉求!君子就讨厌[那种态度,]不说自己贪心无厌,却一定另找借口。我听说过:无论是诸侯或者大夫,不必着急财富不多,只须着急财富不均;不必着急人民太少,只须着急境内不安。若是财富平均,便无所谓贫穷;境内和平团结,便不会觉得人少;境内平安,便不会倾危。做到这样,远方的人还不归服,便再修仁义礼乐的政教来招致他们。他们来了,就得使他们安心。如今仲由和冉求两人辅相季孙,远方之人不归服,却不能招致;国家支离破碎,却不能保全;反而想在国境以内使用兵力。我恐怕季孙的忧愁不在颛臾,却在鲁君哩。”\n【注释】⑴颛臾——鲁国的附庸国家,现在山东省费县西北八十里有颛臾村,当是古颛臾之地。⑵有事——《左传》成公十三年,“国之大事,在祀与戎。”这“有事”卽指用兵。⑶尔是过——不能解作“尔之过”,因为古代人称代词表示领位极少再加别的虚词的(像《尚书·康诰》“朕其弟小子封”只是极个别的例子)。这里“过”字可看作动词,“是”字是表示倒装之用的词,顺装便是“过尔”,“责备你”、“归罪于你”的意思。⑷东蒙——卽蒙山,在今山东蒙阴县南,接费县界。⑸周任——古代的一位史官。⑹费——音秘,bèi,鲁国季氏采邑,今山东费县西南七十里有费城。⑺舍——同“舍”。⑻不患寡而患不均,不患贫而患不安——当作“不患贫而患不均,不患寡而患不安”,“贫”和“均”是从财富着眼,下文“均无贫”可以为证;“寡”和“安”是从人民着眼,下文“和无寡”可以为证。说详俞樾《羣经平议》。⑼萧墙之内——“萧墙”是鲁君所用的屏风。人臣至此屏风,便会肃然起敬,所以叫做萧墙(萧字从肃得声)。“萧墙之内”指鲁君。当时季孙把持鲁国政治,和鲁君矛盾很大,也知道鲁君想收拾他以收回主权,因此怕颛臾凭借有利的地势起而帮助鲁国,于是要先下手为强,攻打颛臾。孔子这句话,深深地刺中了季孙的内心。\n16.2孔子曰:“天下有道,则礼乐征伐自天子出;天下无道,则礼乐征伐自诸侯出。自诸侯出,盖十世希不失矣;自大夫出,五世希不失矣;陪臣执国命,三世希不失矣⑴。天下有道,则政不在大夫。天下有道,则庶人不议。”\n【译文】孔子说:“天下太平,制礼作乐以及出兵都决定于天子;天下昏乱,制礼作乐以及出兵便决定于诸侯。决定于诸侯,大概传到十代,很少还能继续的;决定于大夫,传到五代,很少还能继续的;若是大夫的家臣把持国家政权,传到三代很少还能继续的。天下太平,国家的最高政治权力就不会掌握在大夫之手。天下太平,老百姓就不会议论纷纷。”\n【注释】⑴孔子这一段话可能是从考察历史,尤其是当日时事所得出的结论。“自天子出”,孔子认为尧、舜、禹、汤以及西周都如此的;“天下无道”则自齐桓公以后,周天子已无发号施令的力量了。齐自桓公称霸,历孝公、昭公、懿公、惠公、顷公、灵公、庄公、景公、悼公、简公十公,至简公而为陈恒所杀,孔子亲身见之;晋自文公称霸,历襄公、灵公、成公、景公、厉公、平公、昭公、顷公九公,六卿专权,也是孔子所亲见的。所以说:“十世希不失”。鲁自季友专政,历文子、武子、平子、桓子而为阳虎所执,更是孔子所亲见的。所以说“五世希不失”。至于鲁季氏家臣南蒯、公山弗扰、阳虎之流都当身而败,不曾到过三世。当时各国家臣有专政的,孔子言“三世希不失”,盖宽言之。这也是历史演变的必然,愈近变动时代,权力再分配的鬬争,一定愈加激烈。这却是孔子所不明白的。\n16.3孔子曰:“禄之去公室五世⑴矣,政逮于大夫四世⑴矣,故夫三桓⑵之子孙微矣。”\n【译文】孔子说:“国家政权离开了鲁君,[从鲁君来说,]已经五代了;政权到了大夫之手,[从季氏来说,]已经四代了,所以桓公的三房子孙现在也衰微了。”\n【注释】⑴五世四世——自鲁君丧失政治权力到孔子说这段话的时候,经历了宣公、成公、襄公、昭公、定公五代;自季氏最初把持鲁国政治到孔子说这段话时,经历了文子、武子、平子、桓子四代。说本毛奇龄《论语稽求篇》。⑵三桓——鲁国的三卿,仲孙(卽孟孙)、叔孙、季孙都出于鲁桓公,故称“三桓”。\n16.4孔子曰:“益者三友,损者三友。友直,友谅⑴,友多闻,益矣。友便辟,友善柔,友便佞,损矣。”\n【译文】孔子说:“有益的朋友三种,有害的朋友三种。同正直的人交友,同信实的人交友,同见闻广博的人交友,便有益了。同谄媚奉承的人交友,同当面恭维背面毁谤的人交友,同夸夸其谈的人交友,便有害了。”\n【注释】⑴谅——《说文》:“谅,信也。”“谅”和“信”有时意义相同,这里便如此。有时意义有别。如宪问篇第十四“岂若匹夫匹妇之为谅也”的“谅”只是“小信”的意思。\n16.5孔子曰:“益者三乐,损者三乐。乐节礼乐,乐道人之善,乐多贤友,益矣。乐骄乐,乐佚游,乐晏乐,损矣。”\n【译文】孔子说:“有益的快乐三种,有害的快乐三种。以得到礼乐的调节为快乐,以宣扬别人的好处为快乐,以交了不少有益的朋友为快乐,便有益了。以骄傲为快乐,以游荡忘返为快乐,以饮食荒淫为快乐,便有害了。”\n16.6孔子曰:“侍于君子有三愆:言未及之而言谓之躁,言及之而不言谓之隐,未见颜色而言谓之瞽。”\n【译文】孔子说:“陪着君子说话容易犯三种过失:没轮到他说话,却先说,叫做急躁;该说话了,却不说,叫做隐瞒;不看看君子的脸色便贸然开口,叫做瞎眼睛。”\n16.7孔子曰:“君子有三戒:少之时,血气未定,戒之在色;及其壮也,血气方刚,戒之在鬬;及其老也,血气既衰,戒之在得⑴。”\n【译文】孔子说:“君子有三件事情应该警惕戒备:年轻的时候,血气未定,便要警戒,莫迷恋女色;等到壮大了,血气正旺盛,便要警戒,莫好胜喜鬬;等到年老了,血气已经衰弱,便要警戒,莫贪求无厌。”\n【注释】⑴孔安国注云:“得,贪得。”所贪者可能包括名誉、地位、财货在内。《淮南子·诠言训》:“凡人之性,少则猖狂,壮则强暴,老则好利。”意本于此章,而以“好利”释得,可能涵义太狭。\n16.8孔子曰:“君子有三畏:畏天命,畏大人⑴,畏圣人之言。小人不知天命而不畏也,狎大人,侮圣人之言。”\n【译文】孔子说:“君子害怕的有三件事:怕天命,怕王公大人,怕圣人的言语。小人不懂得天命,因而不怕它;轻视王公大人,轻侮圣人的言语。”\n【注释】⑴大人——古代对于在高位的人叫“大人”,如《易·干卦》“利见大人”,《礼记·礼运》“大人世及以为礼”,《孟子·尽心下》“说大人,则藐之”。对于有道德的人也可以叫“大人”,如《孟子·告子上》“从其大体为大人”。这里的“大人”是指在高位的人,而“圣人”则是指有道德的人。\n16.9孔子曰:“生而知之者上也,学而知之者次也;困而学之,又其次也;困而不学,民斯为下矣。”\n【译文】孔子说:“生来就知道的是上等,学习然后知道的是次一等;实践中遇见困难,再去学它,又是再次一等;遇见困难而不学,老百姓就是这种最下等的了。”\n16.10孔子曰:“君子有九思:视思明,听思聪,色思温,貌思恭,言思忠,事思敬,疑思问,忿思难,见得思义。”\n【译文】孔子说:“君子有九种考虑:看的时候,考虑看明白了没有;听的时候,考虑听清楚了没有;脸上的颜色,考虑温和么;容貌态度,考虑庄矜么;说的言语,考虑忠诚老实么;对待工作,考虑严肃认真么;遇到疑问,考虑怎样向人家请教;将发怒了,考虑有什么后患;看见可得的,考虑我是否应该得。”\n16.11孔子曰:“见善如不及,见不善如探汤。吾见其人矣,吾闻其语矣。隐居以求其志,行义以达其道。吾闻其语矣,未见其人也。”\n【译文】孔子说:“看见善良,努力追求,好像赶不上似的;遇见邪恶,使劲避开,好像将伸手到沸水里。我看见这样的人,也听过这样的话。避世隐居求保全他的意志,依义而行来贯彻他的主张。我听过这样的话,却没有见过这样的人。”\n16.12齐景公有马千驷⑴,死之日,民无德而称焉。伯夷、叔齐饿于首阳⑵之下,民到于今称之。其斯之谓与⑶?\n【译文】齐景公有马四千匹,死了以后,谁都不觉得他有什么好行为可以称述。伯夷、叔齐两人饿死在首阳山下,大家到现在还称颂他。那就是这个意思吧!\n【注释】⑴千驷——古代一般用四匹马驾一辆车,所以一驷就是四匹马。《左传》哀公八年:“鲍牧谓羣公子曰:‘使女有马千乘乎?’”这“千乘”就是景公所遗留的“千驷”。鲍牧用此来诱劝羣公子争夺君位,可见“千乘”是一笔相当富厚的私产。⑵首阳——山名,现在何地,古今传说纷歧,总之,已经难于确指。⑶其斯之谓与——这一章既然没有“子曰”字样,而且“其斯之谓与”的上面无所承受,程颐以为颜渊篇第十二的“诚不以富,亦祗以异”两句引文应该放在此处“其斯之谓与”之上,但无证据。朱熹〈答江德功书〉云:“此章文势或有断续,或有阙文,或非一章,皆不可考。”\n16.13陈亢⑴问于伯鱼曰:“子亦有异闻乎?”\n对曰:“未也。尝独立,鲤趋而过庭。曰:‘学诗乎?’对曰:‘未也。’‘不学诗,无以言。’鲤退而学诗。他日,又独立,鲤趋而过庭。曰:‘学礼乎?’对曰:‘未也。’‘不学礼,无以立。’鲤退而学礼。闻斯二者。”\n陈亢退而喜曰:“问一得三,闻诗,闻礼,又闻君子之远其子也。”\n【译文】陈亢向孔子的儿子伯鱼问道:“您在老师那儿,也得着与众不同的传授吗?”\n答道:“没有。他曾经一个人站在庭中,我恭敬地走过。他问我道:‘学诗没有?’我道:‘没有。’他便道:‘不学诗就不会说话。’我退回便学诗。过了几天,他又一个人站在庭中,我又恭敬地走过。他问道:‘学礼没有?’我答:‘没有。’他道:‘不学礼,便没有立足社会的依据。’我退回便学礼。只听到这两件。”\n陈亢回去非常高兴地道:“我问一件事,知道了三件事。知道诗,知道礼,又知道君子对他儿子的态度。”\n【注释】⑴陈亢——亢音刚,gāng,就是陈子禽。\n16.14邦君之妻,君称之曰夫人,夫人自称曰小童;邦人称之曰君夫人,称诸异邦曰寡小君;异邦人称之亦曰君夫人⑴。\n【译文】国君的妻子,国君称她为夫人,她自称为小童;国内的人称她为君夫人,但对外国人便称她为寡小君;外国人称她也为君夫人。\n【注释】⑴这章可能也是孔子所言,却遗落了“子曰”两字。有人疑心这是后人见竹简有空白处,任意附记的。殊不知书写《论语》的竹简不过八寸,短者每章一简,长者一章数简,断断没有多大空白能书写这四十多字。而且这一章既见于《古论》,又见于《鲁论》(《鲁论》作“固君之妻”),尤其可见各种古本都有之,决非后人所搀入。\n"},{"id":119,"href":"/zh/docs/culture/%E8%AE%BA%E8%AF%AD/%E8%AE%BA%E8%AF%AD%E8%AF%91%E6%B3%A8_%E6%9D%A8%E4%BC%AF%E5%B3%BB/05%E5%85%AC%E5%86%B6%E9%95%BF%E7%AF%87%E7%AC%AC%E4%BA%94/","title":"05公冶长篇第五","section":"论语译注 杨伯峻","content":" 公冶长篇第五 # (共二十八章(何晏《集解》把第十章“子曰,始吾于人也”以下又分一章,故题为二十九章;朱熹《集注》把第一、第二两章并为一章,故题为二十七章。))\n5.1子谓公冶长⑴,“可妻⑵也。虽在缧绁⑶之中,非其罪也。”以其子⑷妻之。\n【译文】孔子说公冶长,“可以把女儿嫁给他。他虽然曾被关在监狱之中,但不是他的罪过。”便把自己的女儿嫁给他。\n【注释】⑴公冶长——孔子学生,齐人。⑵妻——动词,去声,qì。⑶缧绁——缧同“累”,léi;绁音泄,xiè。缧绁,拴罪人的绳索,这里指代监狱。⑷子——儿女,此处指的是女儿。\n5.2子谓南容⑴,“邦有道,不废;邦无道,免于刑戮。”以其兄之子妻之⑵。\n【译文】孔子说南容,“国家政治清明,[总有官做,]不被废弃;国家政治黑暗,也不致被刑罚。”于是把自己的侄女嫁给他。\n【注释】⑴南容——孔子学生南宫适,字子容。⑵兄之子——孔子之兄叫孟皮,见《史记·孔子世家·索隐》引《家语》。这时孟皮可能已死,所以孔子替他女儿主婚。\n5.3子谓子贱⑴,“君子哉若人!鲁无君子者,斯焉取斯?”\n【译文】孔子评论宓子贱,说:“这人是君子呀!假若鲁国没有君子,这种人从哪里取来这种好品德呢?”\n【注释】⑴子贱——孔子学生宓不齐,字子贱,少于孔子四十九岁。(公元前521—?)\n5.4子贡问曰:“赐也何如?”子曰:“女,器也。”曰:“何器也?”曰:“瑚琏⑴也。”\n【译文】子贡问道:“我是一个怎样的人?”孔子道:“你好比是一个器皿。”子贡道:“什么器皿?”孔子道:“宗庙里盛黍稷的瑚琏。”\n【注释】⑴瑚琏——音胡连,又音胡hú辇niǎn,卽簠簋,古代祭祀时盛粮食的器皿,方形的叫簠,圆形的叫簋,是相当尊贵的。\n5.5或曰:“雍⑴也仁而不佞⑵。”子曰:“焉用佞?御人以口给⑶,屡憎于人。不知其仁⑷,焉用佞?”\n【译文】有人说:“冉雍这个人有仁德,却没有口才。”孔子道:“何必要口才呢?强嘴利舌地同人家辩驳,常常被人讨厌。冉雍未必仁,但为什么要有口才呢?”\n【注释】⑴雍——孔子学生冉雍,字仲弓。⑵佞——音泞,nìng,能言善说,有口才。⑶口给——给,足也。“口给”犹如后来所说“言词不穷”、“辩才无碍”。⑷不知其仁——孔子说不知,不是真的不知,只是否定的另一方式,实际上说冉雍还不能达到“仁”的水平。下文第八章“孟武伯问子路仁乎,子曰,不知也”,这“不知”也是如此。\n5.6子使漆雕开⑴仕。对曰:“吾斯之未能信⑵。”子说。\n【译文】孔子叫漆雕开去做官。他答道:“我对这个还没有信心。”孔子听了很欢喜。\n【注释】⑴漆雕开——“漆雕”是姓,“开”是名,孔子学生,字子开。⑵吾斯之未能信——这句是“吾未能信斯”的倒装形式,“之”是用来倒装的词。\n5.7子曰:“道不行,乘桴⑴浮于海。从⑵我者,其由与?”子路闻之喜。子曰:“由也好勇过我,无所取材⑶。”\n【译文】孔子道:“主张行不通了,我想坐个木簰到海外去,跟随我的恐怕只有仲由吧!”子路听到这话,高兴得很。孔子说:“仲由这个人太好勇敢了,好勇的精神大大超过了我,这就没有什么可取的呀!”\n【注释】桴——音孚,fú,古代把竹子或者木头编成簰,以当船用,大的叫筏,小的叫桴,也就是现在的木簰。⑵从——动词,旧读去声,跟随。⑶材——同“哉”,古字有时通用。有人解做木材,说是孔子以为子路真要到海外去,便说,“没地方去取得木材”。这种解释一定不符合孔子原意。也有人把“材”看做“翦裁”的“裁’,说是“子路太好勇了,不知道节制、检点”,这种解释不知把“取”字置于何地,因之也不采用。\n5.8孟武伯问子路仁乎?子曰:“不知也。”又问。子曰:“由也,千乘之国,可使治其赋⑴也,不知其仁也。”\n“求也何如?”子曰:“求也,千室之邑⑵,百乘之家⑶,可使为之⑷宰⑸也,不知其仁也。”\n“赤也何如?”子曰:“赤也,束带立于朝,可使与宾客⑹言也,不知其仁也。”\n【译文】孟武伯向孔子问子路有没有仁德。孔子道:“不晓得。”他又问。孔子道:“仲由啦,如果有一千辆兵车的国家,可以叫他负责兵役和军政的工作。至于他有没有仁德,我不晓得。”\n孟武伯继续问:“冉求又怎么样呢?”,孔子道:“求啦,千户人口的私邑,可以叫他当县长;百辆兵车的大夫封地,可以叫他当总管。至于他有没有仁德,我不晓得。”。\n“公西赤又怎么样呢?”。孔子道:“赤啦,穿着礼服,立于朝廷之中,可以叫他接待外宾,办理交涉。至于他有没有仁德,我不晓得。”\n【注释】⑴赋——兵赋,古代的兵役制度。这里自也包括军政工作而言。⑵邑——《左传》庄公二十八年云:“凡邑,有宗庙先王之主曰都,无曰邑。”又《公羊传》桓公元年云:“田多邑少称田,邑多田少称邑。”可见“邑”就是古代庶民聚居之所,不过有一些田地罢了。⑶家——古代的卿大夫由国家封以一定的地方,由他派人治理,并且收用当地的租税,这地方便叫采地或者采邑。“家”便是指这种采邑而言。⑷之——用法同“其”,他的。⑸宰——古代一县的县长叫做“宰”,大夫家的总管也叫做“宰”。所以“原思为之宰”(6.5)的宰为“总管”,而“季氏使闵子骞为费宰”(6.9)的“宰”是“县长”。⑹宾客——“宾”“客”两字散文则通,对文有异。一般是贵客叫宾,因之天子诸侯的客人叫宾,一般客人叫客,《易经·需卦·爻辞》“有不速之客三人来”的“客”正是此意。这里则把“宾客”合为一词了。\n5.9子谓子贡曰:“女与回也孰愈?”对曰:“赐也何敢望回?回也闻一以知十,赐也闻一以知二。”子曰:“弗如也;吾与⑴女弗如也。”\n【译文】孔子对子贡道:“你和颜回,哪一个强些?”子贡答道:“我么,怎敢和回相比?他啦,听到一件事,可以推演知道十件事;我咧,听到一件事,只能推知两件事。”孔子道:“赶不上他;我同意你的话,是赶不上他。”\n【注释】⑴与——动词,同意,赞同。这里不应该看作连词。\n5.10宰予昼寝。子曰:“朽木不可雕也;粪土之墙不可杇⑴也;于予与何诛⑵”子曰⑶:“始吾于人也,听其言而信其行;今吾于人也,听其言而观其行。于予与改是。”\n【译文】宰予在白天睡觉。孔子说:“腐烂了的木头雕刻不得,粪土似的墙壁粉刷不得;对于宰予么,不值得责备呀。”又说:“最初,我对人家,听到他的话,便相信他的行为;今天,我对人家,听到他的话,却要考察他的行为。从宰予的事件以后,我改变了态度。”\n【注释】⑴杇——音乌,wū,泥工抹墙的工具叫杇,把墙壁抹平也叫杇。这里依上文的意思译为“粉刷”。⑵何诛——机械地翻译是“责备什么呢”,这里是意译。⑶子曰——以下的话虽然也是针对“宰予昼寝”而发出,却是孔子另一个时候的言语,所以又加“子曰”两字以示区别。古人有这种修辞条例,俞樾《古书疑义举例》卷二“一人之辞而加曰字例”曾有所阐述(但未引证此条),可参阅。\n5.11子曰:“吾未见刚者。”或对曰:“申枨⑴。”子曰:“枨也欲,焉得刚?”\n【译文】孔子道:“我没见过刚毅不屈的人。”有人答道:“申枨是这样的人。”孔子道:“申枨啦,他欲望太多,哪里能够刚毅不屈?”\n【注释】⑴申枨——枨音橙,chéng。《史记·仲尼弟子列传》有申党,古音“党”和“枨”相近,那么“申枨”就是“申党”。\n5.12子贡曰:“我不欲人之加⑴诸我也,吾亦欲无加诸人。”子曰:“赐也,非尔所及也。”\n【译文】子贡道:“我不想别人欺侮我,我也不想欺侮别人。”孔子说:“赐,这不是你能做到的。”\n【注释】⑴加——驾凌,凌辱。\n5.13子贡曰:“夫子之文章⑴,可得而闻也;夫子之言性⑵与天道⑶,不可得而闻也。”\n【译文】子贡说:“老师关于文献方面的学问,我们听得到;老师辟于天性和天道的言论,我们听不到。”\n【注释】⑴文章——孔子是古代文化的整理者和传播者,这里的“文章”该是指有关古代文献的学问而言。在《论语》中可以考见的有诗、书、史、礼等等。⑵性——人的本性。古代不可能有阶级观点,因之不知道人的阶级性。而对人的自然的性,孟子、荀子都有所主张,孔子却只说过“性相近也,习相远也”(17.2)一句话。⑶天道——古代所讲的天道一般是指自然和人类社会吉凶祸福的关系。但《左传》昭公十八年郑国子产的话说:“天道远,人道迩,非所及也。”却是对自然和人类社会的吉凶有必然关系的否认。《左传》昭公二十六年又有晏婴的话:“天道不謟。”虽然是用人类的美德来衡量自然之神,反对禳灾,也是对当时迷信习惯的破除。这两人都与孔子同时而年龄较大,而且为孔子所称道。孔子不讲天道,对自然和人类社会的关系取存而不论的态度,不知道是否受这种思想的影响。\n5.14子路有闻,未之能行,唯恐有⑴闻。\n【译文】子路有所闻,还没有能够去做,只怕又有所闻。\n【注释】⑴有——同“又”。\n5.15子贡问曰:“孔文子⑴何以谓之‘文’也?”子曰:“敏而好学,不耻下问,是以谓之‘文’也。”\n【译文】子贡问道:“孔文子凭什么谥他为‘文’?”孔子道:“他聪敏灵活,爱好学问,又谦虚下问,不以为耻,所以用‘文’字做他的谥号。”\n【注释】⑴孔文子——卫国的大夫孔圉。考孔文子死于鲁哀公十五年,或者在此稍前,孔子卒于十六年夏四月,那么,这次问答一定在鲁哀公十五年到十六年初的一段时间内。\n5.16子谓子产⑴,“有君子之道四焉:其行己也恭,其事上也敬,其养民也惠,其使民也义。”\n【译文】孔子评论子产,说:“他有四种行为合于君子之道:他自己的容颜态度庄严恭敬,他对待君上负责认真,他教养人民有恩惠,他役使人民合于道理。\n【注释】⑴子产——公孙侨,字子产,郑穆公之孙,为春秋时郑国的贤相,在郑简公、郑定公之时执政二十二年。其时,于晋国当悼公、平公、昭公、顷公、定公五世,于楚国当共王、康王、郏敖、灵王、平王五世,正是两国争强、战争不息的时候。郑国地位冲要,而周旋于这两大强国之间,子产却能不低声下气,也不妄自尊大,使国家得到尊敬和安全,的确是古代中国的一位杰出的政治家和外交家。\n5.17子曰:“晏平仲⑴善与人交,久而敬之⑵。”\n【译文】孔子说:“晏平仲善于和别人交朋友,相交越久,别人越发恭敬他。”\n【注释】晏平仲——齐国的贤大夫,名婴。《史记》卷六十二有他的传记。现在所传的《晏子春秋》,当然不是晏婴自己的作品,但亦是西汉以前的书。⑵久而敬之——〈魏著作郎韩显宗墓志〉,“善与人交,人亦久而敬焉”,卽本《论语》,义与别本《论语》作“久而人敬之”者相合。故我以“之”字指晏平仲自己。若以为是指相交之人,译文便当这样:“相交越久,越发恭敬别人”。\n5.18子曰:“臧文仲⑴居蔡⑵,山节藻梲⑵,何如其知⑷也?”\n【译文】孔子说:“臧文仲替一种叫蔡的大乌龟盖了一间屋,有雕刻着像山一样的斗栱和画着藻草的梁上短柱,这个人的聪明怎么这样呢?”\n【注释】⑴臧文仲——鲁国的大夫臧孙辰。(?——公元前617年)⑵居蔡——古代人把大乌龟叫作“蔡”。《淮南子·说山训》说:“大蔡神龟,出于沟壑。”高诱注说:“大蔡,元龟之所出地名,因名其龟为大蔡,臧文仲所居蔡是也。”古代人迷信卜筮,卜卦用龟,筮用蓍草。用龟,认为越大越灵。蔡便是这种大龟。臧文仲宝藏着它,使它住在讲究的地方。居,作及物动词用,使动用法,使之居住的意思。⑶山节藻梲——节,柱上斗栱;“梲”音啄,zhuō,梁上短柱。⑷知——同“智”。\n5.19子张问曰:“令尹子文⑴三仕⑵为令尹,无喜色;三已⑵之,无愠色。旧令尹之政,必以告新令尹。何如?”子曰:“忠矣。”曰:“仁矣乎?”曰:“未知⑶;——焉得仁?”\n“崔子弒齐君⑷,陈文子⑸有马十乘,弃而违之。至于他邦,则曰,‘犹吾大夫崔子也。’违之。之一邦,则又曰:‘犹吾大夫崔子也。’违之。何如?”子曰:“清矣。”曰:“仁矣乎?”曰:“未知⑶;——焉得仁?”\n【译文】子张问道:“楚国的令尹子文三次做令尹的官,没有高兴的颜色;三次被罢免,没有怨恨的颜色。[每次交代,]一定把自己的一切政令全部告诉接位的人。这个人怎么样?”孔子道:“可算尽忠于国家了。”子张道:“算不算仁呢?”孔子道:“不晓得;——这怎么能算是仁呢?”\n子张又问:“崔杼无理地杀掉齐庄公,陈文子有四十匹马,舍弃不要,离开齐国。到了另一个国家,说道:‘这里的执政者同我们的崔子差不多。’又离开。又到了一国,又说道:‘这里的执政者同我们的崔子差不多。’于是又离开。这个人怎么样?”孔子道:“清白得很。”子张道:“算不算仁呢?”孔子道:“不晓得;——这怎么能算是仁呢?”\n【注释】⑴令尹子文——楚国的宰相叫做令尹。子文卽鬬谷(谷音构)于菟(音乌徒)。根据《左传》,子文于鲁庄公三十年开始做令尹,到僖公二十三年让位给子玉,其中相距二十八年。在这二十八年中可能有几次被罢免又被任命,《国语·楚语下》说:“昔子文三舍令尹,无一日之积”,也就可以证明。⑵三仕——“三仕”和“三已”的“三”不一定是实数,可能只是表示那事情的次数之多。⑶未知——和上文第五章“不知其仁”,第八章“不知也”的“不知”相同,不是真的“不知”,只是否定的另一方式,孔子停了一下,又说“焉得仁”,因此用破折号表示。⑷崔子弒齐君——崔子,齐国的大夫崔杼;齐君,齐庄公,名光。弑,古代在下的人杀掉在上的人叫做弑。“崔子弑齐君”的事见《左传》襄公二十五年。⑸陈文子——也是齐国的大夫,名须无。可是《左传》没有记载他离开的事,却记载了他以后在齐国的行为很多,可能是一度离开,终于回到本国了。\n5.20季文子⑴三思⑵而后行。子闻之,曰:“再⑶,斯可矣。”\n【译文】季文子每件事考虑多次才行动。孔子听到了,说:“想两次也就可以了。”\n【注释)⑴季文子——鲁国的大夫季孙行父,历仕鲁国文公、宣公、成公、襄公诸代。孔子生于襄公二十二年,文子死在襄公五年。(?——公元前568年)孔子说这话的时候,文子死了很久了。⑵三思——这一“三”字更其不是实实在在的“三”。⑶再——“再”在古文中一般只当副词用,其下承上文省去了动词“思”字。《唐石经》作“再思”,“思”字不省。凡事三思,一般总是利多弊少,为什么孔子却不同意季文子这样做呢?宦懋庸《论语稽说》,“文子生平盖祸福利害之计太明,故其美恶两不相掩,皆三思之病也。其思之至三者,特以世故太深,过为谨慎;然其流弊将至利害徇一己之私矣”云云。若以《左传》所载文子先后行事证明,此话不为无理。\n5.21子曰:“宁武子⑴,邦有道,则知;邦无道,则愚⑵。其知可及也,其愚不可及也。”\n【译文】孔子说:“宁武子在国家太平时节,便聪明;在国家昏暗时节,便装儍。他那聪明,别人赶得上;那装儍,别人就赶不上了。”\n【注释】⑴宁武子——卫国的大夫,姓宁,名俞。⑵愚——孔安国以为这“愚”是“佯愚似实”,故译为“装儍”。\n5.22子在陈⑴,曰:“归与!归与!吾党之小子狂简,斐然成章,不知所以裁之⑵。”\n【译文】孔子在陈国,说:“回去吧!回去吧!我们那里的学生们志向高大得很,文彩又都斐然可观,我不知道怎样去指导他们。”\n【注释】⑴陈——国名,姓妫。周武王灭殷以后,求得舜的后代叫妫满的封于陈。春秋时拥有现在河南开封以东,安徽亳县以北一带地方。都于宛丘,卽今天的河南淮阳县。春秋末为楚所灭。⑵不知所以裁之——《史记·孔子世家》作“吾不知所以裁之”。译文也认为这一句的主语不是承上文“吾党之小子”而省略,而是省略了自称代词。“裁”,翦裁。布要翦裁才能成衣,人要教育才能成才,所以译为“指导”。\n5.23子曰:“伯夷、叔齐⑴不念旧恶⑵,怨是用希。”\n【译文】孔子说:“伯夷、叔齐这两兄弟不记念过去的仇恨,别人对他们的怨恨也就很少。”\n【注释】⑴伯夷、叔齐——孤竹君的两个儿子,父亲死了,互相让位,而都逃到周文王那里。周武王起兵讨伐商纣,他们拦住车马劝阻。周朝统一天下,他们以吃食周朝的粮食为可耻,饿死于首阳山。《史记》卷六十一有传。⑵恶——嫌隙,仇恨。\n5.24子曰:“孰谓微生高⑴直?或乞酰⑵焉,乞诸其邻而与之。”\n【译文】孔子说:“谁说微生高这个人直爽?有人向他讨点醋,[他不说自己没有,]却到邻人那里转讨一点给人。”\n【注释】⑴微生高——《庄子》、《战国策》诸书载有尾生高守信的故事,说这人和一位女子相约,在桥梁之下见面。到时候,女子不来,他却老等,水涨了都不走,终于淹死。“微”、“尾”古音相近,字通,因此很多人认为微生高就是尾生高。⑵酰——xī,醋。\n5.25子曰:“巧言、令色、足⑴恭,左丘明⑵耻之,丘亦耻之。匿怨而友其人,左丘明耻之,丘亦耻之。”\n【译文】孔子说:“花言巧语,伪善的容貌,十足的恭顺,这种态度,左丘明认为可耻,我也认为可耻。内心藏着怨恨,表面上却同他要好,这种行为,左丘明认为可耻,我也认为可耻。”\n【注释】⑴足恭——“足”字旧读去声,zù。⑵左丘明——历来相传左丘明为《左传》的作者,又因为司马迁在报任安书中说遇:“左丘失明,厥有《国语》。”又说他是《国语》的作者。这一问题,经过很多人的研究,我则以为下面的两点结论是可以肯定的:(甲)《国语》和《左传》的作者不是一人,(乙)两书都不可能是和孔子同时甚或较早于孔子(因为孔子这段言语把左丘明放在自己之前,而且引以自重)的左丘明所作。\n5.26颜渊季路侍⑴。子曰:“盍⑵各言尔志?”子路曰:“愿车马衣轻轻字当删裘与朋友共敝之而无憾。⑶”\n颜渊曰:“愿无伐善,无施⑷劳。”\n子路曰:“愿闻子之志。”\n子曰:“老者安之,朋友信之,少者怀之⑸。”\n【译文】孔子坐着,颜渊、季路两人站在孔子身边。孔子道:“何不各人说说自己的志向?”\n子路道:“愿意把我的车马衣服同朋友共同使用坏了也没有什么不满”\n颜渊道:“愿意不夸耀自己的好处,不表白自己的功劳。”子路向孔子道:“希望听到您的志向。”\n孔子道:“[我的志向是,]老者使他安逸,朋友使他信任我,年青人使他怀念我。”\n【注释】⑴侍——《论语》有时用一“侍”字,有时用“侍侧”两字,有时用“侍坐”两字。若单用“侍”字,便是孔子坐着,弟子站着。若用“侍坐”,便是孔子和弟子都坐着。至于“侍侧”,则或坐或立,不加肯定。⑵盍——“何不”的合音字。⑶愿车马衣轻裘与朋友共敝之而无憾——这句的“轻”字是后人加上去的,有很多证据可以证明唐以前的本子并没有这一“轻”字。详见刘宝楠《论语正义》。这一句有两种读法。一种从“共”字断句,把“共”字作谓词。一种作一句读,“共”字看作副词,修饰“敝”字。这两种读法所表现的意义并无显明的区别。⑷施——《淮南子·诠言训》“功盖天下,不施其美。”这两个“施”字意义相同,《礼记·祭统》注云:“施犹着也。”卽表白的意思。⑸信之、怀之——译文把“信”和“怀”同“安”一样看做动词的使动用法。如果把它看做一般用法,那这两句便应该如此翻译:对“朋友有信任,年青人便关心他”。\n5.27子曰:“已矣乎,吾未见能见其过而内自讼者也。”\n【译文】孔子说:“算了吧,我没有看见过能够看到自己的错误便自我责备的哩。”\n5.28子曰:“十室之邑,必有忠信如丘者焉,不如丘之好学也。”\n【译文】孔子说:“就是十户人家的地方,一定有像我这样又忠心又信实的人,祗是赶不上我的喜欢学问罢了。”\n"},{"id":120,"href":"/zh/docs/culture/%E8%AE%BA%E8%AF%AD/%E8%AE%BA%E8%AF%AD%E8%AF%91%E6%B3%A8_%E6%9D%A8%E4%BC%AF%E5%B3%BB/03%E5%85%AB%E4%BD%BE%E7%AF%87%E7%AC%AC%E4%B8%89/","title":"03八佾篇第三","section":"论语译注 杨伯峻","content":" 八佾篇第三 # (共二十六章)\n3.1孔子谓季氏,⑴“八佾⑵舞于庭,是可忍⑶也,孰不可忍也?”\n【译文】孔子谈到季氏,说:“他用六十四人在庭院中奏乐舞蹈,这都可以狠心做出来,甚么事不可以狠心做出来呢?”\n【注释】⑴季氏——根据《左传》昭公二十五年的记载和《汉书·刘向传》,这季氏可能是指季平子,卽季孙意如。据《韩诗外传》,似以为季康子,马融注则以为季桓子,恐皆不足信。⑵八佾——佾音逸,yì。古代舞蹈奏乐,八个人为一行,这一行叫一佾。八佾是八行,八八六十四人,只有天子才能用。诸侯用六佾,卽六行,四十八人。大夫用四佾,三十二人。四佾才是季氏所应该用的。⑶忍——一般人把它解为“容忍”、“忍耐”,不好;因为孔子当时并没有讨伐季氏的条件和意志,而且季平子削弱鲁公室,鲁昭公不能忍,出走到齐,又到晋,终于死在晋国之干侯。这可能就是孔子所“孰不可忍”的事。《贾子·道术篇》:“恻隐怜人谓之慈,反慈为忍。”这“忍”字正是此意。\n3.2三家⑴者以《雍》⑵彻。子曰:“‘相⑶维辟公,天子穆穆’,奚取于三家之堂?”\n【译文】仲孙、叔孙、季孙三家,当他们祭祀祖先时候,[也用天子的礼,]唱着雍这篇诗来撤除祭品。孔子说:“[《雍》诗上有这样的话]‘助祭的是诸侯,天子严肃静穆地在那儿主祭。’这两句话,用在三家祭祖的大厅上在意义上取它哪一点呢?”\n【注释】⑴三家——鲁国当政的三卿。⑵《雍》——也写作“雝”,《诗经·周颂》的一篇。⑶相——去声,音向,xiàng助祭者。\n3.3子曰:“人而不仁,如礼何?人而不仁,如乐何?”\n【译文】孔子说:“做了人,却不仁,怎样来对待礼仪制度呢?做了人,却不仁,怎样来对待音乐呢?”\n3.4林放⑴问礼之本。子曰:“大哉问!礼,与其奢也,宁俭;丧,与其易⑵也,宁戚。”\n【译文】林放问礼的本质。孔子说:“你的问题意义重大呀,就一般礼仪说,与其铺张浪费,宁可朴素俭约;就丧礼说,与其仪文周到,宁可过度悲哀。”\n【注释】⑴林放——鲁人。⑵易——《礼记·檀弓上》云:“子路曰,‘吾闻诸夫子:丧礼,与其哀不足而礼有余也,不若礼不足而哀有余也。”可以看做“与其易也,宁戚”的最早的解释。“易”有把事情办妥的意思,如《孟子·尽心上》“易其田畴”,因此这里译为“仪文周到”。\n3.5子曰:“夷狄之有君,不如⑴诸夏之亡⑵也。”\n【译文】孔子说:“文化落后国家虽然有个君主,还不如中国没有君主哩。”\n【注释】⑴夷狄有君……亡也——杨遇夫先生《论语疏证》说,夷狄有君指楚庄王、吴王阖庐等。君是贤明之君。句意是夷狄还有贤明之君,不像中原诸国却没有。说亦可通。⑵亡——同“无”。在《论语》中,“亡”下不用宾语,“无”下必有宾语。\n3.6季氏旅⑴于泰山。子谓冉有⑵曰:“女弗能救与?”对曰:“不能。”子曰:“呜呼!曾谓泰山不如林放乎?”\n【译文】季氏要去祭祀泰山。孔子对冉有说道:“你不能阻止吗?”冉有答道:“不能。”孔子道:“哎呀!竟可以说泰山之神还不及林放[懂礼,居然接受这不合规矩的祭祀]吗?”\n【注释】⑴旅——动词,祭山。在当时,只有天子和诸侯才有祭祀“名山大川”的资格。季氏只是鲁国的大夫,竟去祭祀泰山,因之孔子认为是“僭礼”。⑵冉有——孔子学生冉求,字子有,小于孔子二十九岁。(公元前522—?)当时在季氏之下做事,所以孔子责备他。\n3.7子曰:“君子无所争。必也射乎!揖让而升,下而饮。其争也君子⑴。”\n【译文】孔子说:“君子没有什么可争的事情。如果有所争,一定是比箭吧,[但是当射箭的时候,]相互作揖然后登堂;[射箭完毕,]走下堂来,然后[作揖]喝酒。那一种竞赛是很有礼貌的。”\n【注释】⑴其争也君子——这是讲古代射礼,详见《仪礼·乡射礼》和《大射仪》。登堂而射,射后计算谁中靶多,中靶少的被罚饮酒。\n3.8子夏问曰:“‘巧笑倩⑴兮,美目盼⑵兮,素以为绚⑶兮。’何谓也?”子曰:“绘事后素。”\n曰:“礼后⑷乎?”子曰:“起⑸予者商也!始可与言诗已矣。”\n【译文】子夏问道:“‘有酒涡的脸笑得美呀,黑白分明的眼流转得媚呀,洁白的底子上画着花卉呀。’这几句诗是什么意思?”孔子道:“先有白色底子,然后画花。”\n子夏道:“那么,是不是礼乐的产生在[仁义]以后呢?”孔子道:“卜商呀,你真是能启发我的人。现在可以同你讨论《诗经》了。”\n【注释】⑴倩——音欠,qiàn,面颊长得好。⑵盼——黑白分明。⑶绚xuàn,有文采,译文为着协韵,故用“画着花卉”以代之。这三句诗,第一句第二句见于《诗经·卫风·硕人》。第三句可能是逸句,王先谦《三家诗义集疏》以为《鲁诗》有此一句。⑷礼后——“礼”在什么之后呢,原文没说出。根据儒家的若干文献,译文加了“仁义”两字。⑸起——友人孙子书(楷第)先生云:“凡人病困而愈谓之起,义有滞碍隐蔽,通达之,亦谓之起。”说见杨遇夫先生《汉书窥管》卷九引文。\n3.9子曰:“夏礼,吾能言之,杞⑴不足征也;殷礼,吾能言之,宋⑵不足征也。文献⑶不足故也。足,则吾能征之矣。”\n【译文】孔子说:“夏代的礼,我能说出来,它的后代杞国不足以作证;殷代的礼,我能说出来,它的后代宋国不足以作证。这是他们的历史文件和贤者不够的缘故。若有足够的文件和贤者,我就可以引来作证了。”\n【注释】⑴杞——国名,夏禹的后代。周武王时候的故城卽今日河南的杞县。其后因为国家弱小,依赖别国的力量来延长国命,屡经迁移。⑵宋——国名,商汤的后代,故城在今日河南商邱县南。国土最大的时候,有现在河南商邱以东,江苏徐州以西之地。战国时为齐、魏、楚三国所共灭。⑵文献——《论语》的“文献”和今天所用的“文献”一词的概念有不同之处。《论语》的“文献”包括历代的历史文件和当时的贤者两项(朱注云:“文,典籍也;献,贤也。”)。今日“文献”一词只指历史文件而言。\n3.10子曰:“禘⑴自既灌⑵而往者,吾不欲观之矣。”\n【译文】孔子说:“禘祭的礼,从第一次献酒以后,我就不想看了。”\n【注释】⑴禘——这一禘礼是指古代一种极为隆重的大祭之礼,只有天子才能举行。不过周成王曾因为周公旦对周朝有过莫大的功勋,特许他举行禘祭。以后鲁国之君都沿此惯例,“僭”用这一禘礼,因此孔子不想看。⑵灌——本作“裸”,祭祀中的一个节目。古代祭祀,用活人以代受祭者,这活人便叫“尸”。尸一般用幼小的男女。第一次献酒给尸,使他(她)闻到“郁鬯”(一种配合香料煮成的酒)的香气,叫做裸。\n3.11或问禘之说。子曰:“不知也⑴;知其说者之于天下也,其如示⑵诸斯乎!”指其掌。\n【译文】有人向孔子请教关于禘祭的理论。孔子说:“我不知道;知道的人对于治理天下,会好像把东西摆在这里一样容易罢!”一面说,一面指着手掌。\n【注释】⑴不知也——禘是天子之礼,鲁国举行,在孔子看来,是完全不应该的。但孔子又不想明白指出,只得说“不欲观”,“不知也”,甚至说“如果有懂得的人,他对于治理天下是好像把东西放在手掌上一样的容易。”⑵示——假借字,同“置”,摆、放的意义。或曰同“视”,犹言“了如指掌”。\n3.12祭如在,祭神如神在。子曰:“吾不与祭,如不祭⑴。”\n【译文】孔子祭祀祖先的时候,便好像祖先真在那里;祭神的时候,便好像神真在那里。孔子又说:“我若是不能亲自参加祭祀,是不请别人代理的。”\n【注释】⑴吾不与祭,如不祭——这是一般的句读法。“与”读去声,音预,yù,参预的意思。“如不祭”译文是意译。另外有人主张“与”字仍读上声,赞同的意思,而且在这里一读,便是“吾不与,祭如不祭”。译文便应改为:“若是我所不同意的祭礼,祭了同没祭一般。”我不同意此义,因为孔丘素来不赞成不合所谓礼的祭祀,如“非其鬼而祭之,谄也”,(2.24)孔丘自不会参加他所不赞同的祭祀。\n3.13王孙贾⑴问曰:“与其媚于奥,宁媚于灶⑵,何谓也?”子曰:“不然;获罪于天,无所祷也⑶。”\n【译文】王孙贾问道:“‘与其巴结房屋里西南角的神,宁可巴结灶君司命,’这两句话是什么意思?”孔子道:“不对;若是得罪了上天,祈祷也没用。”\n【注释】⑴王孙贾——卫灵公的大臣。⑵与其媚于奥,宁媚如灶——这两句疑是当时俗语。屋内西南角叫奥,弄饭的设备叫灶,古代都以为那里有神,因而祭它。⑶王孙贾和孔子的问答都用的比喻,他们的正意何在,我们只能揣想。有人说,奥是一室之主,比喻卫君,又在室内,也可以比喻卫灵公的宠姬南子;灶则是王孙贾自比。这是王孙贾暗示孔子,“你与其巴结卫公或者南子,不如巴结我。”因此孔子答复他:“我若做了坏事,巴结也没有用处,我若不做坏事,谁都不巴结。”又有人说,这不是王孙贾暗示孔子的话,而是请教孔子的话。奥指卫君,灶指南子、弥子瑕,位职虽低,却有权有势。意思是说,“有人告诉我,与其巴结国君,不如巴结有势力的左右像南子、弥子瑕。你以为怎样?”孔子却告诉他:“这话不对;得罪了上天,那无所用其祈祷,巴结谁都不行。”我以为后一说比较近情理。\n3.14子曰:“周监于二代⑴,郁郁乎文哉!吾从周。”\n【译文】孔子说:“周朝的礼仪制度是以夏商两代为根据,然后制定的,多么丰富多彩呀,我主张周朝的。”\n【注释】⑴二代——夏、商两朝。\n3.15子入太庙⑴,每事问。或曰:“孰谓鄹人之子⑵知礼乎?入太庙,每事问。”子闻之,曰:“是礼也。”\n【译文】孔子到了周公庙,每件事情都发问。有人便说:“谁说叔梁纥的这个儿子懂得礼呢?他到了太庙,每件事都要向别人请教。”孔子听到了这话,便道:“这正是礼呀。”\n【注释】⑴太庙——古代开国之君叫太祖,太祖之庙便叫做太庙,周公旦是鲁国最初受封之君,因之这太庙就是周公的庙。⑵鄹人之子——鄹音邹,zōu,又作郰,地名。《史记·孔子世家》:“孔子生鲁昌平乡郰邑。”有人说,这地就是今天的山东省曲阜县东南十里的西邹集。“鄹人”指孔子父亲叔梁纥。叔梁纥曾经作过鄹大夫,古代经常把某地的大夫称为某人,因之这里也把鄹大夫叔梁纥称为“鄹人”。\n3.16子曰:“射不主皮⑴,为⑵力不同科⑶,古之道也。”\n【译文】孔子说:“比箭,不一定要穿破箭靶子,因为各人的气力大小不一样,这是古时的规矩。”\n【注释】⑴射不主皮——“皮”代表箭靶子。古代箭靶子叫“侯”,有用布做的,也有用皮做的。当中画着各种猛兽或者别的东西,最中心的又叫做“正”或者“鹄”。孔子在这里所讲的射应该是演习礼乐的射,而不是军中的武射,因此以中不中为主,不以穿破皮侯与否为主。《仪礼·乡射礼》云,“礼射不主皮”,盖本此。⑵为——去声,wèi,因为。⑶同科——同等。\n3.17子贡欲去⑴告朔之饩羊⑵。子曰:“赐也!尔爱⑶其羊,我爱其礼。”\n【译文】子贡要把鲁国每月初一告祭祖庙的那只活羊去而不用。孔子道:“赐呀,你可惜那只羊,我可惜那种礼。”\n【注释】⑴去——从前读为上声,因为它在这里作为及物动词,而且和“来去”的“去”意义不同。⑵告朔饩羊——“告”,从前人读梏,gù,入声。“朔”,每月的第一天,初一。“饩”,xì。“告朔饩羊”,古代的一种制度。每年秋冬之交,周天子把第二年的历书颁给诸侯。这历书包括那年有无闰月,每月初一是哪一天,因之叫“颁告朔”。诸侯接受了这一历书,藏于祖庙。每逢初一,便杀一只活羊祭于庙,然后回到朝廷听政。这祭庙叫做“告朔”,听政叫做“视朔”,或者“听朔”。到子贡的时候,每月初一,鲁君不但不亲临祖庙,而且也不听政,只是杀一只活羊“虚应故事”罢了。所以子贡认为不必留此形式,不如干脆连羊也不杀。孔子却认为尽管这是残存的形式,也比什么也不留好。⑶爱——可惜的意思。\n3.18子曰:“事君尽礼,人以为谄也。”\n【译文】孔子说:“服事君主,一切依照做臣子的礼节做去,别人却以为他在谄媚哩。”\n3.19定公⑴问:“君使臣,臣事君,如之何?”孔子对曰:“君使臣以礼,臣事君以忠。”\n【译文】鲁定公问:“君主使用臣子,臣子服事君主,各应该怎么样?”孔子答道:“君主应该依礼来使用臣子,臣子应该忠心地服事君主。”\n【注释】⑴定公——鲁君,名宋,昭公之弟,继昭公而立,在位十五年。(公元前509—495)“定”是谥号。\n3.20子曰:“《关雎》⑴,乐而不淫⑵,哀而不伤。”\n【译文】孔子说:“《关雎》这诗,快乐而不放荡,悲哀而不痛苦。”\n【注释】⑴《关雎》——《诗经》的第一篇。但这篇诗并没有悲哀的情调,因此刘台拱的《论语骈枝》说:“诗有《关雎》,乐亦有《关雎》,此章据乐言之。古之乐章皆三篇为一。……乐而不淫者,《关雎》、《葛覃》也;哀而不伤者,卷耳也。”⑵淫——古人凡过分以至于到失当的地步叫淫,如言“淫祀”(不应该祭祀而去祭祀的祭礼)、“淫雨”(过久的雨水)。\n3.21哀公问社⑴于宰我⑵。宰我对曰:“夏后氏以松,殷人以柏,周人以栗,曰,使民战栗。”子闻之,曰:“成事不说,遂事不谏,既往不咎。”\n【译文】鲁哀公向宰我问,作社主用什么木。宰我答道:“夏代用松木,殷代用柏木,周代用栗木,意思是使人民战战栗栗。”孔子听到了这话,[责备宰我]说:“已经做了的事不便再解释了,已经完成的事不便再挽救了,已经过去的事不便再追究了。”\n【注释】⑴社——土神叫社,不过哀公所问的社,从宰我的答话中可以推知是指社主而言。古代祭祀土神,要替他立一个木制的牌位,这牌位叫主,而认为这一木主,便是神灵之所凭依。如果国家有对外战争,还必需载这一木主而行。详见俞正燮《癸巳类稾》。有人说“社”是指立社所栽的树,未必可信。⑵宰我——孔子学生,名予,字子我。\n3.22子曰:“管仲⑴之器小哉!”\n或曰:“管仲俭乎?”曰:“管氏有三归⑵,官事不摄⑶,焉得俭?”\n“然则管仲知礼乎?”曰:“邦君树塞门⑷,管氏亦树塞门。邦君为两君之好⑸,有反坫⑹,管氏亦有反坫。管氏而⑺知礼,孰不知礼?”\n【译文】孔子说:“管仲的器量狭小得很呀!”\n有人便问:“他是不是很节俭呢?”孔子道:“他收取了人民的大量的市租,他手下的人员,[一人一职,]从不兼差,如何能说是节俭呢?”\n那人又问:“那末,他懂得礼节么?”孔子又道:“国君宫殿门前,立了一个塞门;管氏也立了个塞门;国君设燕招待外国的君主,在堂上有放置酒杯的设备,管氏也有这样的设备。假若说他懂得礼节,那谁不懂得礼节呢?”\n【注释】⑴管仲——春秋时齐国人,名夷吾,做了齐桓公的宰相,使他称霸诸侯。⑵三归——“三归”的解释还有:(甲)国君一娶三女,管仲也娶了三国之女(《集解》引包咸说,皇侃《义疏》等);(乙)三处家庭(俞樾《羣经平议》);(丙)地名,管仲的采邑(梁玉绳《瞥记》);(丁)藏泉币的府库(武亿《羣经义证》)。我认为这些解释都不正确。郭嵩焘《养知书屋文集》卷一释三归云:“此盖《管子》九府轻重之法,当就《管子》书求之。〈山至数篇〉曰。‘则民之三有归于上矣。’三归之名,实本于此。是所谓三归者,市租之常例之归之公者也。桓公既霸,遂以赏管仲。《汉书·地理志》、《食货志》并云,桓公用管仲设轻重以富民,身在陪臣,而取三归。其言较然明显。《韩非子》云,‘使子有三归之家’,《说苑》作‘赏之市租’。三归之为市租,汉世儒者犹能明之,此一证也。《晏子春秋》辞三归之赏,而云厚受赏以伤国民之义,其取之民无疑也,此又一证也。”这一说法很有道理。我还再举两个间接证据。(甲)《战国策》一说:“齐桓公宫中七市,女闾七百,国人非之。管仲故为三归之家以掩桓公,非自伤于民也。”似亦以三归为市租。(乙)《三国志·魏志·武帝纪》建安十五年令曰:“若必廉士而后可用,则齐桓其何以霸?”亦以管仲不是清廉之士,当指三归。⑶摄——兼职。⑷树塞门——树,动词,立也。塞门,用以间隔内外视线的一种东西,形式和作用可以同今天的照壁相比。⑸好——古读去声,友好。⑹反坫——坫音店,diàn,用以放置器物的设备,用土筑成的,形似土堆,筑于两楹(厅堂前部东西各有一柱)之间。详全祖望《经史问答》。⑺而——假设连词,假如,假若。\n3.23子语⑴鲁大师⑵乐,曰:“乐其可知也:始作,翕⑶如也;从⑷之,纯如也,皦⑸如也,绎如也,以成。”\n【译文】孔子把演奏音乐的道理告给鲁国的太师,说道:“音乐,那是可以晓得的:开始演奏,翕翕地热烈;继续下去,纯纯地和谐,皦皦地清晰,绎绎地不绝,这样,然后完成。”\n【注释】⑴语——去声,yù,告诉。⑵大师——大音泰,tài,乐官之长。⑶翕——xī。⑷从——去声,zòng。⑸皦——音皎,jiǎo。\n3.24仪封人⑴请见⑵,曰:“君子之至于斯也,吾未尝不得见也。”从者⑶见之⑵。出曰:“二三子何患于丧⑷乎?天下之无道也久矣,天将以夫子为木铎⑸。”\n【译文】仪这个地方的边防官请求孔子接见他,说道:“所有到了这个地方的有道德学问的人,我从没有不和他见面的。”孔子的随行学生请求孔子接见了他。他辞出以后,对孔子的学生们说:“你们这些人为什么着急没有官位呢?天下黑暗日子也长久了,[圣人也该有得意的时候了,]上天会要把他老人家做人民的导师哩。”\n【注释】⑴仪封人——仪,地名。有人说当在今日的开封市内,未必可靠。封人,官名。《左传》有颖谷封人、祭封人、萧封人、吕封人,大概是典守边疆的官。说本方观旭《论语偶记》。⑵请见、见之——两个“见”字从前都读去声,音现,xiàn。“请见”是请求接见的意思,“见之”是使孔子接见了他的意思。何焯《义门读书记》云:“古者相见必由绍介,逆旅之中无可因缘,故称平日未尝见绝于贤者,见气类之同,致词以代绍介,故从者因而通之。夫子亦不拒其请,与不见孺悲异也。”⑶从者——“从”去声,zòng。⑷丧——去声,sàng,失掉官位。⑸木铎——铜质木舌的铃子。古代公家有什么事要宣布,便摇这铃,召集大家来听。\n3.25子谓韶⑴,“尽美⑵矣,又尽善⑵也。”谓武⑶,“尽美矣,未尽善也。”\n【译文】孔子论到韶,说:“美极了,而且好极了。”论到武,说:“美极了,却还不够好。”\n【注释】⑴韶——舜时的乐曲名。⑵美、善——“美”可能指声音言,“善”可能指内容言。舜的天子之位是由尧“禅让”而来,故孔子认为“尽善”。周武王的天子之位是由讨伐商纣而来,尽管是正义战,依孔子意,却认为“未尽善”。⑶武——周武王时乐曲名。\n3.26子曰:“居上不宽,为礼不敬,临丧不哀,吾何以观之哉?”\n【译文】孔子说:“居于统治地位不宽宏大量,行礼的时候不严肃认真,参加丧礼的时候不悲哀,这种样子我怎么看得下去呢?”\n"},{"id":121,"href":"/zh/docs/technology/MySQL/MySQL%E6%98%AF%E6%80%8E%E6%A0%B7%E8%BF%90%E8%A1%8C%E7%9A%84/%E7%AC%AC11%E7%AB%A0_%E4%B8%A4%E4%B8%AA%E8%A1%A8%E7%9A%84%E4%BA%B2%E5%AF%86%E6%8E%A5%E8%A7%A6-%E8%BF%9E%E6%8E%A5%E7%9A%84%E5%8E%9F%E7%90%86/","title":"第11章_两个表的亲密接触-连接的原理","section":"My Sql是怎样运行的","content":"第11章 两个表的亲密接触-连接的原理\n搞数据库一个避不开的概念就是Join,翻译成中文就是连接。相信很多小伙伴在初学连接的时候有些一脸懵逼,理解了连接的语义之后又可能不明白各个表中的记录到底是怎么连起来的,以至于在使用的时候常常陷入下面两种误区: - 误区一:业务至上,管他三七二十一,再复杂的查询也用在一个连接语句中搞定。 - 误区二:敬而远之,上次 DBA 那给报过来的慢查询就是因为使用了连接导致的,以后再也不敢用了。\n所以本章就来扒一扒连接的原理。考虑到一部分小伙伴可能忘了连接是什么或者压根儿就不知道,为了节省他们百度或者看其他书的宝贵时间以及为了我的书凑字数,我们先来介绍一下 MySQL 中支持的一些连接语法。\n连接简介 # 连接的本质 # 为了故事的顺利发展,我们先建立两个简单的表并给它们填充一点数据:\nmysql\u0026gt; CREATE TABLE t2 (m2 int, n2 char\\(1)\\); Query OK, 0 rows affected (0.02 sec) mysql\u0026gt; INSERT INTO t1 VALUES(1, \u0026#39;a\u0026#39;), (2, \u0026#39;b\u0026#39;), (3, \u0026#39;c\u0026#39;); Query OK, 3 rows affected (0.00 sec) Records: 3 Duplicates: 0 Warnings: 0 mysql\u0026gt; INSERT INTO t2 VALUES(2, \u0026#39;b\u0026#39;), (3, \u0026#39;c\u0026#39;), (4, \u0026#39;d\u0026#39;); Query OK, 3 rows affected (0.00 sec) Records: 3 Duplicates: 0 Warnings: 0 我们成功建立了t1、t2两个表,这两个表都有两个列,一个是INT类型的,一个是CHAR(1)`类型的,填充好数据的两个表长这样:\nmysql\u0026gt; SELECT * FROM t1; \\+------\\+------\\+ | m1 | n1 | \\+------\\+------\\+ | 1 | a | | 2 | b | | 3 | c | \\+------\\+------\\+ 3 rows in set (0.00 sec) mysql\u0026gt; SELECT * FROM t2; \\+------\\+------\\+ | m2 | n2 | \\+------\\+------\\+ | 2 | b | | 3 | c | | 4 | d | \\+------\\+------\\+ 3 rows in set (0.00 sec) 连接的本质就是把各个连接表中的记录都取出来依次匹配的组合加入结果集并返回给用户。所以我们把t1和t2两个表连接起来的过程如下图所示:\n这个过程看起来就是把t1表的记录和t2的记录连起来组成新的更大的记录,所以这个查询过程称之为连接查询。连接查询的结果集中包含一个表中的每一条记录与另一个表中的每一条记录相互匹配的组合,像这样的结果集就可以称之为笛卡尔积。因为表t1中有3条记录,表t2中也有3条记录,所以这两个表连接之后的笛卡尔积就有3×3=9行记录。在MySQL中,连接查询的语法也很随意,只要在FROM语句后边跟多个表名就好了,比如我们把t1表和t2表连接起来的查询语句可以写成这样: mysql\u0026gt; SELECT * FROM t1, t2; +------+------+------+------+ | m1 | n1 | m2 | n2 | +------+------+------+------+ | 1 | a | 2 | b | | 2 | b | 2 | b | | 3 | c | 2 | b | | 1 | a | 3 | c | | 2 | b | 3 | c | | 3 | c | 3 | c | | 1 | a | 4 | d | | 2 | b | 4 | d | | 3 | c | 4 | d | +------+------+------+------+ 9 rows in set (0.00 sec)\n连接过程简介 # 如果我们乐意,我们可以连接任意数量张表,但是如果没有任何限制条件的话,这些表连接起来产生的笛卡尔积可能是非常巨大的。比方说3个100行记录的表连接起来产生的笛卡尔积就有100×100×100=1000000行数据!所以在连接的时候过滤掉特定记录组合是有必要的,在连接查询中的过滤条件可以分成两种:\n涉及单表的条件\n这种只设计单表的过滤条件我们之前都提到过一万遍了,我们之前也一直称为搜索条件,比如t1.m1 \u0026gt; 1是只针对t1表的过滤条件,t2.n2 \u0026lt; 'd'是只针对t2表的过滤条件。\n涉及两表的条件\n这种过滤条件我们之前没见过,比如t1.m1 = t2.m2、t1.n1 \u0026gt; t2.n2等,这些条件中涉及到了两个表,我们稍后会仔细分析这种过滤条件是如何使用的。\n下面我们就要看一下携带过滤条件的连接查询的大致执行过程了,比方说下面这个查询语句: SELECT * FROM t1, t2 WHERE t1.m1 \u0026gt; 1 AND t1.m1 = t2.m2 AND t2.n2 \u0026lt; 'd'; 在这个查询中我们指明了这三个过滤条件:\nt1.m1 \u0026gt; 1\nt1.m1 = t2.m2\nt2.n2 \u0026lt; 'd'\n那么这个连接查询的大致执行过程如下:\n首先确定第一个需要查询的表,这个表称之为驱动表。怎样在单表中执行查询语句我们在前一章都介绍过了,只需要选取代价最小的那种访问方法去执行单表查询语句就好了(就是说从const、ref、ref_or_null、range、index、all这些执行方法中选取代价最小的去执行查询)。此处假设使用t1作为驱动表,那么就需要到t1表中找满足t1.m1 \u0026gt; 1的记录,因为表中的数据太少,我们也没在表上建立二级索引,所以此处查询t1表的访问方法就设定为all吧,也就是采用全表扫描的方式执行单表查询。关于如何提升连接查询的性能我们之后再说,现在先把基本概念捋清楚。所以查询过程就如下图所示:\n我们可以看到,t1表中符合t1.m1 \u0026gt; 1的记录有两条。\n针对上一步骤中从驱动表产生的结果集中的每一条记录,分别需要到t2表中查找匹配的记录,所谓匹配的记录,指的是符合过滤条件的记录。因为是根据t1表中的记录去找t2表中的记录,所以t2表也可以被称之为被驱动表。上一步骤从驱动表中得到了2条记录,所以需要查询2次t2表。此时涉及两个表的列的过滤条件t1.m1 = t2.m2就派上用场了:\n+ 当`t1.m1 = 2`时,过滤条件`t1.m1 = t2.m2`就相当于`t2.m2 = 2`,所以此时`t2`表相当于有了`t2.m2 = 2`、`t2.n2 \u0026lt; 'd'`这两个过滤条件,然后到`t2`表中执行单表查询。 + 当`t1.m1 = 3`时,过滤条件`t1.m1 = t2.m2`就相当于`t2.m2 = 3`,所以此时`t2`表相当于有了`t2.m2 = 3`、`t2.n2 \u0026lt; 'd'`这两个过滤条件,然后到`t2`表中执行单表查询。 所以整个连接查询的执行过程就如下图所示:\n也就是说整个连接查询最后的结果只有两条符合过滤条件的记录:\n+------+------+------+------+ | m1 | n1 | m2 | n2 | +------+------+------+------+ | 2 | b | 2 | b | | 3 | c | 3 | c | +------+------+------+------+\n从上面两个步骤可以看出来,我们上面介绍的这个两表连接查询共需要查询1次t1表,2次t2表。当然这是在特定的过滤条件下的结果,如果我们把t1.m1 \u0026gt; 1这个条件去掉,那么从t1表中查出的记录就有3条,就需要查询3次t2表了。也就是说在两表连接查询中,驱动表只需要访问一次,被驱动表可能被访问多次。\n内连接和外连接 # 为了大家更好理解后边内容,我们先创建两个有现实意义的表,\nCREATE TABLE score ( number INT COMMENT \u0026#39;学号\u0026#39;, subject VARCHAR\\(30) COMMENT \u0026#39;科目\u0026#39;, score TINYINT COMMENT \u0026#39;成绩\u0026#39;, PRIMARY KEY (number, score) \\) Engine=InnoDB CHARSET=utf8 COMMENT \u0026#39;学生成绩表\u0026#39;; 我们新建了一个学生信息表,一个学生成绩表,然后我们向上述两个表中插入一些数据,为节省篇幅,具体插入过程就不介绍了,插入后两表中的数据如下: ` mysql\u0026gt; SELECT * FROM student; +\u0026mdash;\u0026mdash;\u0026mdash;-+\u0026mdash;\u0026mdash;\u0026mdash;\u0026ndash;+\u0026mdash;\u0026mdash;\u0026mdash;\u0026mdash;\u0026mdash;\u0026mdash;\u0026mdash;\u0026mdash;\u0026ndash;+ | number | name | major | +\u0026mdash;\u0026mdash;\u0026mdash;-+\u0026mdash;\u0026mdash;\u0026mdash;\u0026ndash;+\u0026mdash;\u0026mdash;\u0026mdash;\u0026mdash;\u0026mdash;\u0026mdash;\u0026mdash;\u0026mdash;\u0026ndash;+ | 20180101 | 杜子腾 | 软件学院 | | 20180102 | 范统 | 计算机科学与工程 | | 20180103 | 史珍香 | 计算机科学与工程 | +\u0026mdash;\u0026mdash;\u0026mdash;-+\u0026mdash;\u0026mdash;\u0026mdash;\u0026ndash;+\u0026mdash;\u0026mdash;\u0026mdash;\u0026mdash;\u0026mdash;\u0026mdash;\u0026mdash;\u0026mdash;\u0026ndash;+ 3 rows in set (0.00 sec)\nmysql\u0026gt; SELECT * FROM score; +\u0026mdash;\u0026mdash;\u0026mdash;-+\u0026mdash;\u0026mdash;\u0026mdash;\u0026mdash;\u0026mdash;\u0026mdash;\u0026mdash;\u0026mdash;\u0026mdash;\u0026ndash;+\u0026mdash;\u0026mdash;-+ | number | subject | score | +\u0026mdash;\u0026mdash;\u0026mdash;-+\u0026mdash;\u0026mdash;\u0026mdash;\u0026mdash;\u0026mdash;\u0026mdash;\u0026mdash;\u0026mdash;\u0026mdash;\u0026ndash;+\u0026mdash;\u0026mdash;-+ | 20180101 | 母猪的产后护理 | 78 | | 20180101 | 论萨达姆的战争准备 | 88 | | 20180102 | 论萨达姆的战争准备 | 98 | | 20180102 | 母猪的产后护理 | 100 | +\u0026mdash;\u0026mdash;\u0026mdash;-+\u0026mdash;\u0026mdash;\u0026mdash;\u0026mdash;\u0026mdash;\u0026mdash;\u0026mdash;\u0026mdash;\u0026mdash;\u0026ndash;+\u0026mdash;\u0026mdash;-+ 4 rows in set (0.00 sec) 现在我们想把每个学生的考试成绩都查询出来就需要进行两表连接了(因为score中没有姓名信息,所以不能单纯只查询score表)。连接过程就是从student表中取出记录,在score表中查找number相同的成绩记录,所以过滤条件就是student.number = socre.number,整个查询语句就是这样: mysql\u0026gt; SELECT * FROM student, score WHERE student.number = score.number; +\u0026mdash;\u0026mdash;\u0026mdash;-+\u0026mdash;\u0026mdash;\u0026mdash;\u0026ndash;+\u0026mdash;\u0026mdash;\u0026mdash;\u0026mdash;\u0026mdash;\u0026mdash;\u0026mdash;\u0026mdash;\u0026ndash;+\u0026mdash;\u0026mdash;\u0026mdash;-+\u0026mdash;\u0026mdash;\u0026mdash;\u0026mdash;\u0026mdash;\u0026mdash;\u0026mdash;\u0026mdash;\u0026mdash;\u0026ndash;+\u0026mdash;\u0026mdash;-+ | number | name | major | number | subject | score | +\u0026mdash;\u0026mdash;\u0026mdash;-+\u0026mdash;\u0026mdash;\u0026mdash;\u0026ndash;+\u0026mdash;\u0026mdash;\u0026mdash;\u0026mdash;\u0026mdash;\u0026mdash;\u0026mdash;\u0026mdash;\u0026ndash;+\u0026mdash;\u0026mdash;\u0026mdash;-+\u0026mdash;\u0026mdash;\u0026mdash;\u0026mdash;\u0026mdash;\u0026mdash;\u0026mdash;\u0026mdash;\u0026mdash;\u0026ndash;+\u0026mdash;\u0026mdash;-+ | 20180101 | 杜子腾 | 软件学院 | 20180101 | 母猪的产后护理 | 78 | | 20180101 | 杜子腾 | 软件学院 | 20180101 | 论萨达姆的战争准备 | 88 | | 20180102 | 范统 | 计算机科学与工程 | 20180102 | 论萨达姆的战争准备 | 98 | | 20180102 | 范统 | 计算机科学与工程 | 20180102 | 母猪的产后护理 | 100 | +\u0026mdash;\u0026mdash;\u0026mdash;-+\u0026mdash;\u0026mdash;\u0026mdash;\u0026ndash;+\u0026mdash;\u0026mdash;\u0026mdash;\u0026mdash;\u0026mdash;\u0026mdash;\u0026mdash;\u0026mdash;\u0026ndash;+\u0026mdash;\u0026mdash;\u0026mdash;-+\u0026mdash;\u0026mdash;\u0026mdash;\u0026mdash;\u0026mdash;\u0026mdash;\u0026mdash;\u0026mdash;\u0026mdash;\u0026ndash;+\u0026mdash;\u0026mdash;-+ 4 rows in set (0.00 sec) ```\n字段有点多哦,我们少查询几个字段: mysql\u0026gt; SELECT s1.number, s1.name, s2.subject, s2.score FROM student AS s1, score AS s2 WHERE s1.number = s2.number; +----------+-----------+-----------------------------+-------+ | number | name | subject | score | +----------+-----------+-----------------------------+-------+ | 20180101 | 杜子腾 | 母猪的产后护理 | 78 | | 20180101 | 杜子腾 | 论萨达姆的战争准备 | 88 | | 20180102 | 范统 | 论萨达姆的战争准备 | 98 | | 20180102 | 范统 | 母猪的产后护理 | 100 | +----------+-----------+-----------------------------+-------+ 4 rows in set (0.00 sec) 从上述查询结果中我们可以看到,各个同学对应的各科成绩就都被查出来了,可是有个问题,史珍香同学,也就是学号为20180103的同学因为某些原因没有参加考试,所以在score表中没有对应的成绩记录。那如果老师想查看所有同学的考试成绩,即使是缺考的同学也应该展示出来,但是到目前为止我们介绍的连接查询是无法完成这样的需求的。我们稍微思考一下这个需求,其本质是想:驱动表中的记录即使在被驱动表中没有匹配的记录,也仍然需要加入到结果集。为了解决这个问题,就有了内连接和外连接的概念:\n对于内连接的两个表,驱动表中的记录在被驱动表中找不到匹配的记录,该记录不会加入到最后的结果集,我们上面提到的连接都是所谓的内连接。\n对于外连接的两个表,驱动表中的记录即使在被驱动表中没有匹配的记录,也仍然需要加入到结果集。\n在MySQL中,根据选取驱动表的不同,外连接仍然可以细分为2种:\n+ 左外连接\n选取左侧的表为驱动表。\n+ 右外连接\n选取右侧的表为驱动表。\n可是这样仍然存在问题,即使对于外连接来说,有时候我们也并不想把驱动表的全部记录都加入到最后的结果集。这就犯难了,有时候匹配失败要加入结果集,有时候又不要加入结果集,这咋办,有点儿愁啊。。。噫,把过滤条件分为两种不就解决了这个问题了么,所以放在不同地方的过滤条件是有不同语义的:\nWHERE子句中的过滤条件\nWHERE子句中的过滤条件就是我们平时见的那种,不论是内连接还是外连接,凡是不符合WHERE子句中的过滤条件的记录都不会被加入最后的结果集。\nON子句中的过滤条件\n对于外连接的驱动表的记录来说,如果无法在被驱动表中找到匹配ON子句中的过滤条件的记录,那么该记录仍然会被加入到结果集中,对应的被驱动表记录的各个字段使用NULL值填充。\n需要注意的是,这个ON子句是专门为外连接驱动表中的记录在被驱动表找不到匹配记录时应不应该把该记录加入结果集这个场景下提出的,所以如果把ON子句放到内连接中,MySQL会把它和WHERE子句一样对待,也就是说:内连接中的WHERE子句和ON子句是等价的。\n一般情况下,我们都把只涉及单表的过滤条件放到WHERE子句中,把涉及两表的过滤条件都放到ON子句中,我们也一般把放到ON子句中的过滤条件也称之为连接条件。\n小贴士:左外连接和右外连接简称左连接和右连接,所以下面提到的左外连接和右外连接中的外字都用括号扩起来,以表示这个字儿可有可无。\n左(外)连接的语法 # 左(外)连接的语法还是挺简单的,比如我们要把t1表和t2表进行左外连接查询可以这么写: SELECT * FROM t1 LEFT [OUTER] JOIN t2 ON 连接条件 [WHERE 普通过滤条件]; 其中,中括号里的OUTER单词是可以省略的。对于LEFT JOIN类型的连接来说,我们把放在左边的表称之为外表或者驱动表,右边的表称之为内表或者被驱动表。所以上述例子中t1就是外表或者驱动表,t2就是内表或者被驱动表。需要注意的是,对于左(外)连接和右(外)连接来说,必须使用ON子句来指出连接条件。了解了左(外)连接的基本语法之后,再次回到我们上面那个现实问题中来,看看怎样写查询语句才能把所有的学生的成绩信息都查询出来,即使是缺考的考生也应该被放到结果集中:\nmysql\u0026gt; SELECT s1.number, s1.name, s2.subject, s2.score FROM student AS s1 LEFT JOIN score AS s2 ON s1.number = s2.number; +----------+-----------+-----------------------------+-------+ | number | name | subject | score | +----------+-----------+-----------------------------+-------+ | 20180101 | 杜子腾 | 母猪的产后护理 | 78 | | 20180101 | 杜子腾 | 论萨达姆的战争准备 | 88 | | 20180102 | 范统 | 论萨达姆的战争准备 | 98 | | 20180102 | 范统 | 母猪的产后护理 | 100 | | 20180103 | 史珍香 | NULL | NULL | +----------+-----------+-----------------------------+-------+ 5 rows in set (0.04 sec) 从结果集中可以看出来,虽然史珍香并没有对应的成绩记录,但是由于采用的是连接类型为左(外)连接,所以仍然把她放到了结果集中,只不过在对应的成绩记录的各列使用NULL值填充而已。\n右(外)连接的语法 # 右(外)连接和左(外)连接的原理是一样一样的,语法也只是把LEFT换成RIGHT而已: SELECT * FROM t1 RIGHT [OUTER] JOIN t2 ON 连接条件 [WHERE 普通过滤条件]; 只不过驱动表是右边的表,被驱动表是左边的表,具体就不介绍了。\n内连接的语法 # 内连接和外连接的根本区别就是在驱动表中的记录不符合ON子句中的连接条件时不会把该记录加入到最后的结果集,我们最开始介绍的那些连接查询的类型都是内连接。不过之前仅仅提到了一种最简单的内连接语法,就是直接把需要连接的多个表都放到FROM子句后边。其实针对内连接,MySQL提供了好多不同的语法,我们以t1和t2表为例看看: SELECT * FROM t1 [INNER | CROSS] JOIN t2 [ON 连接条件] [WHERE 普通过滤条件]; 也就是说在MySQL中,下面这几种内连接的写法都是等价的: - SELECT * FROM t1 JOIN t2; - SELECT * FROM t1 INNER JOIN t2; - SELECT * FROM t1 CROSS JOIN t2;\n上面的这些写法和直接把需要连接的表名放到FROM语句之后,用逗号,分隔开的写法是等价的: SELECT * FROM t1, t2; 现在我们虽然介绍了很多种内连接的书写方式,不过熟悉一种就好了,这里我们推荐INNER JOIN的形式书写内连接(因为INNER JOIN语义很明确嘛,可以和LEFT JOIN和RIGHT JOIN很轻松的区分开)。这里需要注意的是,由于在内连接中ON子句和WHERE子句是等价的,所以内连接中不要求强制写明ON子句。\n我们前面说过,连接的本质就是把各个连接表中的记录都取出来依次匹配的组合加入结果集并返回给用户。不论哪个表作为驱动表,两表连接产生的笛卡尔积肯定是一样的。而对于内连接来说,由于凡是不符合ON子句或WHERE子句中的条件的记录都会被过滤掉,其实也就相当于从两表连接的笛卡尔积中把不符合过滤条件的记录给踢出去,所以对于内连接来说,驱动表和被驱动表是可以互换的,并不会影响最后的查询结果。但是对于外连接来说,由于驱动表中的记录即使在被驱动表中找不到符合ON子句连接条件的记录,所以此时驱动表和被驱动表的关系就很重要了,也就是说左外连接和右外连接的驱动表和被驱动表不能轻易互换。\n小结 # 上面说了很多,给大家的感觉不是很直观,我们直接把表t1和t2的三种连接方式写在一起,这样大家理解起来就很easy了: ``` mysql\u0026gt; SELECT * FROM t1 INNER JOIN t2 ON t1.m1 = t2.m2; +\u0026mdash;\u0026mdash;+\u0026mdash;\u0026mdash;+\u0026mdash;\u0026mdash;+\u0026mdash;\u0026mdash;+ | m1 | n1 | m2 | n2 | +\u0026mdash;\u0026mdash;+\u0026mdash;\u0026mdash;+\u0026mdash;\u0026mdash;+\u0026mdash;\u0026mdash;+ | 2 | b | 2 | b | | 3 | c | 3 | c | +\u0026mdash;\u0026mdash;+\u0026mdash;\u0026mdash;+\u0026mdash;\u0026mdash;+\u0026mdash;\u0026mdash;+ 2 rows in set (0.00 sec)\nmysql\u0026gt; SELECT * FROM t1 LEFT JOIN t2 ON t1.m1 = t2.m2; +\u0026mdash;\u0026mdash;+\u0026mdash;\u0026mdash;+\u0026mdash;\u0026mdash;+\u0026mdash;\u0026mdash;+ | m1 | n1 | m2 | n2 | +\u0026mdash;\u0026mdash;+\u0026mdash;\u0026mdash;+\u0026mdash;\u0026mdash;+\u0026mdash;\u0026mdash;+ | 2 | b | 2 | b | | 3 | c | 3 | c | | 1 | a | NULL | NULL | +\u0026mdash;\u0026mdash;+\u0026mdash;\u0026mdash;+\u0026mdash;\u0026mdash;+\u0026mdash;\u0026mdash;+ 3 rows in set (0.00 sec)\nmysql\u0026gt; SELECT * FROM t1 RIGHT JOIN t2 ON t1.m1 = t2.m2; +\u0026mdash;\u0026mdash;+\u0026mdash;\u0026mdash;+\u0026mdash;\u0026mdash;+\u0026mdash;\u0026mdash;+ | m1 | n1 | m2 | n2 | +\u0026mdash;\u0026mdash;+\u0026mdash;\u0026mdash;+\u0026mdash;\u0026mdash;+\u0026mdash;\u0026mdash;+ | 2 | b | 2 | b | | 3 | c | 3 | c | | NULL | NULL | 4 | d | +\u0026mdash;\u0026mdash;+\u0026mdash;\u0026mdash;+\u0026mdash;\u0026mdash;+\u0026mdash;\u0026mdash;+ 3 rows in set (0.00 sec) ```\n连接的原理 # 上面的介绍都只是为了唤醒大家对连接、内连接、外连接这些概念的记忆,这些基本概念是为了真正进入本章主题做的铺垫。真正的重点是MySQL采用了什么样的算法来进行表与表之间的连接,了解了这个之后,大家才能明白为什么有的连接查询运行的快如闪电,有的却慢如蜗牛。\n嵌套循环连接(Nested-Loop Join) # 我们前面说过,对于两表连接来说,驱动表只会被访问一遍,但被驱动表却要被访问到好多遍,具体访问几遍取决于对驱动表执行单表查询后的结果集中的记录条数。对于内连接来说,选取哪个表为驱动表都没关系,而外连接的驱动表是固定的,也就是说左(外)连接的驱动表就是左边的那个表,右(外)连接的驱动表就是右边的那个表。我们上面已经大致介绍过t1表和t2表执行内连接查询的大致过程,我们温习一下: - 步骤1:选取驱动表,使用与驱动表相关的过滤条件,选取代价最低的单表访问方法来执行对驱动表的单表查询。 - 步骤2:对上一步骤中查询驱动表得到的结果集中每一条记录,都分别到被驱动表中查找匹配的记录。\n通用的两表连接过程如下图所示:\n如果有3个表进行连接的话,那么步骤2中得到的结果集就像是新的驱动表,然后第三个表就成为了被驱动表,重复上面过程,也就是步骤2中得到的结果集中的每一条记录都需要到t3表中找一找有没有匹配的记录,用伪代码表示一下这个过程就是这样: ``` for each row in t1 { #此处表示遍历满足对t1单表查询结果集中的每一条记录\nfor each row in t2 { #此处表示对于某条t1表的记录来说,遍历满足对t2单表查询结果集中的每一条记录 for each row in t3 { #此处表示对于某条t1和t2表的记录组合来说,对t3表进行单表查询 if row satisfies join conditions, send to client } } } ``` 这个过程就像是一个嵌套的循环,所以这种驱动表只访问一次,但被驱动表却可能被多次访问,访问次数取决于对驱动表执行单表查询后的结果集中的记录条数的连接执行方式称之为嵌套循环连接(Nested-Loop Join),这是最简单,也是最笨拙的一种连接查询算法。\n使用索引加快连接速度 # 我们知道在嵌套循环连接的步骤2中可能需要访问多次被驱动表,如果访问被驱动表的方式都是全表扫描的话,妈呀,那得要扫描好多次呀~~~ 但是别忘了,查询t2表其实就相当于一次单表扫描,我们可以利用索引来加快查询速度哦。回顾一下最开始介绍的t1表和t2表进行内连接的例子: SELECT * FROM t1, t2 WHERE t1.m1 \u0026gt; 1 AND t1.m1 = t2.m2 AND t2.n2 \u0026lt; 'd'; 我们使用的其实是嵌套循环连接算法执行的连接查询,再把上面那个查询执行过程表拉下来给大家看一下:\n查询驱动表t1后的结果集中有两条记录,嵌套循环连接算法需要对被驱动表查询2次:\n当t1.m1 = 2时,去查询一遍t2表,对t2表的查询语句相当于:\nSELECT * FROM t2 WHERE t2.m2 = 2 AND t2.n2 \u0026lt; 'd';\n当t1.m1 = 3时,再去查询一遍t2表,此时对t2表的查询语句相当于:\nSELECT * FROM t2 WHERE t2.m2 = 3 AND t2.n2 \u0026lt; 'd';\n可以看到,原来的t1.m1 = t2.m2这个涉及两个表的过滤条件在针对t2表做查询时关于t1表的条件就已经确定了,所以我们只需要单单优化对t2表的查询了,上述两个对t2表的查询语句中利用到的列是m2和n2列,我们可以:\n在m2列上建立索引,因为对m2列的条件是等值查找,比如t2.m2 = 2、t2.m2 = 3等,所以可能使用到ref的访问方法,假设使用ref的访问方法去执行对t2表的查询的话,需要回表之后再判断t2.n2 \u0026lt; d这个条件是否成立。\n这里有一个比较特殊的情况,就是假设m2列是t2表的主键或者唯一二级索引列,那么使用t2.m2 = 常数值这样的条件从t2表中查找记录的过程的代价就是常数级别的。我们知道在单表中使用主键值或者唯一二级索引列的值进行等值查找的方式称之为const,而设计MySQL的大佬把在连接查询中对被驱动表使用主键值或者唯一二级索引列的值进行等值查找的查询执行方式称之为:eq_ref。\n在n2列上建立索引,涉及到的条件是t2.n2 \u0026lt; 'd',可能用到range的访问方法,假设使用range的访问方法对t2表的查询的话,需要回表之后再判断在m2列上的条件是否成立。\n假设m2和n2列上都存在索引的话,那么就需要从这两个里边儿挑一个代价更低的去执行对t2表的查询。当然,建立了索引不一定使用索引,只有在二级索引 + 回表的代价比全表扫描的代价更低时才会使用索引。\n另外,有时候连接查询的查询列表和过滤条件中可能只涉及被驱动表的部分列,而这些列都是某个索引的一部分,这种情况下即使不能使用eq_ref、ref、ref_or_null或者range这些访问方法执行对被驱动表的查询的话,也可以使用索引扫描,也就是index的访问方法来查询被驱动表。所以我们建议在真实工作中最好不要使用*作为查询列表,最好把真实用到的列作为查询列表。\n基于块的嵌套循环连接(Block Nested-Loop Join) # 扫描一个表的过程其实是先把这个表从磁盘上加载到内存中,然后从内存中比较匹配条件是否满足。现实生活中的表可不像t1、t2这种只有3条记录,成千上万条记录都是少的,几百万、几千万甚至几亿条记录的表到处都是。内存里可能并不能完全存放的下表中所有的记录,所以在扫描表前面记录的时候后边的记录可能还在磁盘上,等扫描到后边记录的时候可能内存不足,所以需要把前面的记录从内存中释放掉。我们前面又说过,采用嵌套循环连接算法的两表连接过程中,被驱动表可是要被访问好多次的,如果这个被驱动表中的数据特别多而且不能使用索引进行访问,那就相当于要从磁盘上读好几次这个表,这个I/O代价就非常大了,所以我们得想办法:尽量减少访问被驱动表的次数。\n当被驱动表中的数据非常多时,每次访问被驱动表,被驱动表的记录会被加载到内存中,在内存中的每一条记录只会和驱动表结果集的一条记录做匹配,之后就会被从内存中清除掉。然后再从驱动表结果集中拿出另一条记录,再一次把被驱动表的记录加载到内存中一遍,周而复始,驱动表结果集中有多少条记录,就得把被驱动表从磁盘上加载到内存中多少次。所以我们可不可以在把被驱动表的记录加载到内存的时候,一次性和多条驱动表中的记录做匹配,这样就可以大大减少重复从磁盘上加载被驱动表的代价了。所以设计MySQL的大佬提出了一个join buffer的概念,join buffer就是执行连接查询前申请的一块固定大小的内存,先把若干条驱动表结果集中的记录装在这个join buffer中,然后开始扫描被驱动表,每一条被驱动表的记录一次性和join buffer中的多条驱动表记录做匹配,因为匹配的过程都是在内存中完成的,所以这样可以显著减少被驱动表的I/O代价。使用join buffer的过程如下图所示:\n最好的情况是join buffer足够大,能容纳驱动表结果集中的所有记录,这样只需要访问一次被驱动表就可以完成连接操作了。设计MySQL的大佬把这种加入了join buffer的嵌套循环连接算法称之为基于块的嵌套连接(Block Nested-Loop Join)算法。\n这个join buffer的大小是可以通过启动参数或者系统变量join_buffer_size进行配置,默认大小为262144字节(也就是256KB),最小可以设置为128字节。当然,对于优化被驱动表的查询来说,最好是为被驱动表加上效率高的索引,如果实在不能使用索引,并且自己的机器的内存也比较大可以尝试调大join_buffer_size的值来对连接查询进行优化。\n另外需要注意的是,驱动表的记录并不是所有列都会被放到join buffer中,只有查询列表中的列和过滤条件中的列才会被放到join buffer中,所以再次提醒我们,最好不要把*作为查询列表,只需要把我们关心的列放到查询列表就好了,这样还可以在join buffer中放置更多的记录呢。\n"},{"id":122,"href":"/zh/docs/culture/%E6%81%B0%E5%90%8C%E5%AD%A6%E5%B0%91%E5%B9%B4/%E7%AE%80%E4%BB%8B-%E4%BD%9C%E8%80%85/","title":"简介-作者","section":"恰同学少年","content":"\n请支持正版 # 书名:恰同学少年\n作者:黄辉\n排版:墨茗\n邮箱:1052805965@QQ.COM\n本书仅供个人学习之用,请勿用于商业用途。如果觉得好,请购买正版书籍,书中若有错误,望反馈给我,谢谢。\n所用字体:方正博雅方刊宋_GBK、方正黑体_GBK\n简 介 # 小说根据同名电视剧改编,以毛泽东在湖南第一师范五年半的读书生活为主要表现背景,描绘了1913~1918年以毛泽东、蔡和森、向警予、杨开慧、陶斯咏等为代表的一批优秀青年积极进取的学习生活和他们之间纯真美丽的爱情故事,同时塑造了杨昌济、孔昭绶等一批优秀教师形象。深刻揭示了“学生应该怎样读书,教师应该怎样育人”这个与当今社会紧密相关的现实主题,很好展现了毛泽东为代表的一群风华正茂的青年以天下为己任的抱负与情怀。这对社会主义核心价值体系的构建、现行教育理念的完善、当代青年树立正确的理想追求有重大的现实意义。\n作 者 # 黄晖,第26届电视“飞天奖”优秀编剧获得者,现居长沙。2007年凭借《恰同学少年》荣获中国电视剧艺术成就最高奖——飞天奖优秀编剧,年末编剧创作的“传奇大戏”《血色湘西》在湖南卫视引起收视狂潮。\n2007年,作品《恰同学少年》红遍大江南北,不仅得到普通观众的追捧,同时也受到了国家领导高层的高度关注,黄晖凭此剧一举拿下今年电视“飞天奖”优秀编剧奖。年末,湖南卫视推出他编剧的“传奇大片”《血色湘西》,收视率节节升高。\n"},{"id":123,"href":"/zh/docs/culture/%E6%81%B0%E5%90%8C%E5%AD%A6%E5%B0%91%E5%B9%B4/%E7%BB%8F%E5%85%B8%E8%AF%AD%E5%BD%95/","title":"经典语录","section":"恰同学少年","content":" 经典语录 # 衡山西,岳麓东,城南讲学峙其中。人可铸,金可熔,丽泽绍高风。多材自昔夸熊封。男儿努力蔚为万夫雄。\n天欲使其灭亡,必先使其疯狂 。\n人,之所以为人,正是因为人有理想,有信念,懂得崇高与纯洁的意义。假如眼中只有利益与私欲,那人和只会满足于物欲的动物,又有何分别呢?林文忠公有言:壁立千仞,无欲则刚。我若相信崇高,崇高自与我同在!而区区人言冷暖,物欲得失,与之相比,又渺小得何值一题.\n耻辱啊!耻辱!\n我泱泱大国,巍巍中华,竟成了诸般列强眼中的蛮荒未开化之地。\n耻辱啊!我四万万同胞竟成了任其宰割的鱼肉。\n人,不可不知耻。耻,有个人之耻,国家之耻。德守不坚,学识愚昧,身体衰弱,遭人白眼,乃个人之耻。纲纪扫地,主权外移,疆土日蹙,奴颜卑膝,乃国家之耻。我四万万同胞,如果人人为人所耻,则国家必为人所耻,一个国家被人耻笑,那么个人也将成为被别人耻笑的把柄。支那之耻,无有个人与国家之分,此乃我中华全体之奇耻大辱!\n今日之日本,处心积虑,虎视眈眈,视我中华为其囊中之物,大有灭我而朝食之想,已远非一日。今次,二十一条的强加于我,是欲将我中华亡国灭种的野心赤裸裸地表现。而袁世凯政府呢,曲意承欢,卑躬屈膝,卖国求荣,他直欲将我大好河山,拱手让于日寇,此等卖国行径如我国人仍浑浑噩噩,仍然任其为之,中华灭亡,迫在眉睫!!!\n夷狄虎视,国之将亡,多少国人痛心疾首,多少国人惶惶不安呢!是啊,大难来临了,国家要亡了,这样的灾难什么时候才是尽头,老天爷为什么不开开眼,劈死这些贪婪的强盗。这些抱怨,这些呼号,我们听过无数回,也说过无数回,可抱怨有什么用呢?我们恨这些强盗,恨得牙痒痒的。可是恨,救不了中国!\n大家都知道,南满铁路,东蒙铁路,都归于日本人之手,山东权益也归于日本人之手。要旅顺,要大连,整个长江流域所有的矿产要归日本来开采,一国之政治军事财经各项都要请日本人担任顾问,所有的武器要跟日本去买,就连我中国的警察都要跟日本来合作,这还能算是一个主权国家吗?这究竟是为什么?为什么局势会这样?国家为什么会落到了如此的地步?\n有人说,是因为国势积弱,无力维护自己的利益;有人说,是因为袁世凯政府太腐败,在列强面前,只知一味退让;还有人说,是因为国人太冷漠,仁人志士的呼号像一道道警钟,却难以唤醒他们麻木的心灵。我们坐在这里,痛斥列强,痛斥一切让中国落后挨打受欺负的人和事的时候,你的心中有没有想过,我们每一个中国人应该为国家的落后承担些什么样的责任?应该为这个民族的强大和兴盛担负起什么样的义务?天下兴亡,匹夫有责。这个匹夫不是指除你之外的别人,而是首先应该包括你自己。我们都希望国家强大,但是我要在这里告戒大家一句:不能光有恨!我们要学会将仇恨埋在心底,把悲愤化为动力,我们要拿出十倍的精神,百倍的努力,卧薪尝胆,发奋图强,振兴中华,做得比任何人更好,更出色,这才是每一个中国人应尽的职责。国家之广设学校,所为何事?我们青年置身于学校,又所为何来?正因为一国之希望,在于青年;一国之未来,要由青年来担当。当此国难之际,我青年学子,责有悠归,更肩负着为国家储备实力的重任。\nTable of Contents\n封 面 版 权 简 介 作 者 第一章 我叫毛泽东 第二章 免费招生 第三章 论小学教育 第四章 经世致用 第五章 欲栽大木柱长天 第六章 嘤其鸣矣 第七章 修学储能 第八章 俭朴为修身之本 第九章 袁门立雨 第十章 世间大才少通才 第十一章 过年 第十二章 二十八画生征友启事 第十三章 可怜天下父母心 第十四章 纳于大麓 烈风骤雨弗迷 第十五章 五月七日 民国奇耻 第十六章 感国家之多难 誓九死以不移 第十七章 新任校长 第十八章 易永畦之死 第十九章 驱张事件 第二十章 君子有所不为亦必有所为 第二十一章 逆书大案 第二十二章 文明其精神 野蛮其体魄 第二十三章 到中流击水 第二十四章 书生练兵 第二十五章 学生人物互选 第二十六章 汗漫九垓 第二十七章 工人夜学 第二十八章 梦醒时分 第二十九章 男儿蔚为万夫雄 经典语录 "},{"id":124,"href":"/zh/docs/culture/%E6%81%B0%E5%90%8C%E5%AD%A6%E5%B0%91%E5%B9%B4/%E7%AC%AC1%E7%AB%A0-%E7%AC%AC5%E7%AB%A0/","title":"第1章-第5章","section":"恰同学少年","content":" 第一章 我叫毛泽东 # “人可铸,\n金可熔,\n丽泽绍高风\u0026hellip;\u0026hellip;\n多才自昔夸熊封,\n男儿努力蔚为万夫雄!”\n一 # 1913年3月,这一天清晨,长沙城里一阵微雨才过,空气中便荡满了新叶抽芽的清香和浓烈的花香,透亮的阳光掠进湖南省公立第一师范的院子里,照得几树梧桐新发的鹅黄色嫩叶上的雨滴晶莹剔透,院墙外一树桃花含满雨水次第绽放,红如胭脂,艳如流霞。\n方维夏匆匆穿过梧桐的绿阴,步子轻快有力,清新的空气令他精神不由一振。这位第一师范的学监主任已然年近四十,背微有些曲,一直性情内敛,举止平和。但经历了1911年那一场旷日持久的血雨腥风之后,他和大多数狂热的年轻人一样没有了分别,都为新生的中华民国所激励和鼓舞,就像这春天一样忽然从寒冬里迸发出了无限生机,充满了无穷活力。\n今天是长沙市商会陶会长到校捐资的日子 ,这位陶会长是长沙首富,向来乐善好施,尤其看重教育,被称作湖南教育界的财神,每到捐资的时候长沙各校都是争相逢迎,其恭敬不下于湖南的都督谭延?莅临。这一次一师数日前才新换了位校长,方维夏唯恐这位新校长不懂其中的干系,冷落了财神,因此急忙赶来提醒。\n他一脚跨进校长室,却见新校长孔昭绶在办公桌后正襟危坐,这位才从日本留学回来的法学学士约摸三十多岁年纪,剃得颇短的头发根根直立,脸上棱角分明,目光锐利,颇有行伍之气,他正端正地在一封聘书上写着字。方维夏见他戴了一顶黑呢礼帽,穿着苏绸的长衫马褂,脚下是老泰鑫的圆口新布鞋,胸前挂一块古铜怀表。在他印象里,这位新校长似乎只在上任的那天,才穿得这样正式,不觉暗自点头,看来孔昭绶对这位财神还是极重视的,他对孔昭绶说:“校长,商会的陶会长半个小时后到。”\n孔昭绶起身将聘书放进口袋,微笑道:“维夏,今天我有要事要出门,客人来了,你就代为接待吧。” 方维夏不觉一愣,忙说道:“商会陶翁每次来,历任校长都是亲自接待的……”但孔昭绶却摆了摆手说:“我今天的事,比钱重要。”说话间径直出了门,扔下方维夏在那里发呆:什么事比财神上门还重要?\n出了校门,孔昭绶租了一顶“三人抬”的小轿,只吩咐一句:“浏城桥,板仓杨宅。”便微眯上眼睛养神。沿街一线是高高低低的青砖鳞瓦小楼,深黑色的飞檐和素白色的粉壁在阳光里清亮而又明净。各色的招牌和旗幌迎风轻荡,石板街面上微雨渐干,一尘不染,空中天高云淡,往来行人安闲自在。\n孔昭绶打量着街头的悠闲,不觉想起一年多前长沙街头的那种惊惶。1911年10月(宣统三年八月)武昌起义爆发,随后焦达峰和陈作新在湖南起义,同时倾力增援武昌。但就在焦、陈抽空身边兵力增援武昌时,从邵阳赶到长沙的新军第50协(团)第二营管带梅馨乘机发动兵变,杀了焦、陈二人。因梅资历不足,派士兵一顶小轿将谭延?拥上了湖南都督的位置。其时的长沙可谓是一夜数惊,到处在杀人,到处在抢掠。同时袁世凯的军队已经攻占了汉口,大炮的火力隔江控制着革命军占领的汉阳与武昌,近在咫尺的长沙更是谣言不断,人心惶惶,连谭延?也有朝不保夕之感。随即忽然南北议和,1912年1月1日中华民国成立。\n民国建立后,谭延?开始真心实意地裁撤军队,发展经济。其时湖南建立了省议会,颁布了新刑法;兴办了大量的民营及省办的实业,修筑了第一条湖南的公路——长沙至湘潭公路;废除了清朝的田赋制度,减轻了农民的负担;还拿出经费大办教育,选派公费留学生,为湖南的建设培养人才。不到一年,湖南各业都迸发出勃勃生机。\n孔昭绶从日本政法大学留学一回来就得到了谭延?的聘任,就任第一师范校长。这些天来,他感到长沙这个千年古城一夜之间便从寒冬跨进了暖春,人们从新民国看到了民族复兴、国家强盛的希望,都以一种前所未有的热情进行建设。孔昭绶不由热血沸腾,他等这一天等得太久了,当真有一种时不我待之感。\n轿夫们穿着草鞋的脚拐进一条青石板的小巷。这时忽然传来一阵喧闹的鼓乐声,前方的小巷被挤得水泄不通。孔昭绶怔了一怔,看时,前方不远处一支仪仗队,开路的24人全套西洋军乐队奏着军乐,鼓乐嘹亮,后面紧跟着48名法式盛装、绶带肩章、刺刀闪亮的仪仗兵,军容耀眼,步伐整齐,吸引一路的行人纷纷围观,小孩子们更是跑前跑后。领队的那人孔昭绶再熟不过,正是省教育司的督学纪墨鸿。孔昭绶不觉发呆,这分明是湖南都督府专门迎奉贵客的仪仗队,怎么到了这里?又是什么人要教育司的督学亲自出马?\n小巷太窄,围观的人却越聚越多,孔昭绶的轿子只得跟着仪仗队后慢慢地走。一时大队人马迤逦行来,终于在一间大宅子前停下,看着墙上挂着的“板仓杨宅”的牌子,孔昭绶不由脸色一变,暗想:不会这么巧吧?\n这时纪墨鸿翻身下马,轻轻地扣了扣大门,只听大门“吱呀”一声开了,走出个中年男子来,穿长衫,中等身材,面容丰润,目光柔和,举止沉稳。背后却藏着一个十二三岁的少女,梳两个小辫子,脸如满月,睁大了一双漆黑的眼睛伸出头好奇地打量着。\n“立——正!”随着一声威严的军令骤然在门口响起,几十双锃亮的军靴轰然踩得地上尘土飞扬,一声令下,仪仗队的士兵同时枪下肩,向那中年男子行了一个标准的军礼,随即八面军鼓震耳欲聋地响起来。\n纪墨鸿把手一抬,军鼓便戛然而止,他向那中年男子深深鞠了一躬,朗声道:“卑职省教育司督学纪墨鸿,奉湖南都督谭延?大帅令,特来拜访板仓先生。”没等那人开口,纪墨鸿已经向后一招手:“呈上来!”\n一时鼓声和军乐又骤然大作。两名仪仗兵托着一只锦缎衬底的盘子正步上前,盘中是一封大红烫金、足有一尺见方的聘书。纪墨鸿双手捧起聘书,呈到那人面前:“谭大帅素仰先生风格高古,学贯中西,今林泉隐逸,是为我湘省厥才之失。兹特命卑职率都督府仪仗队,礼聘先生俯就湖南省教育司司长。这是都督大人的亲笔聘书,伏请先生屈尊。”四周人群中顿时发出惊叹之声,目光齐齐投在那张聘书上。\n孔昭绶见状,不由下意识地摸了摸自己怀里的聘书,他显然有些措手不及,只睁大了眼看着那中年男子。面对如此排场,那中年人却像是一个偶尔经过的过客。他并不去接聘书,只是淡淡说道:“杨某久居国外,于国内情形素无了解,更兼毫无行政才能,实在不是做官的料子。烦纪先生转告谭帅,就说他的好意我领了,请他见谅。”\n那人的态度让众人都吃了一惊,纪墨鸿尴尬地捧着那份聘书,看着他笑道:“大帅思贤若渴,一片赤诚,几次三番求到先生门下,先生总得给大帅一个面子吧!”\n“好了,该说的话,我也说过了。杨某区区闲云野鹤一书生,只想关起门来教几个学生读几句书,谭帅也是三湘名儒,想必能体会杨某这点书呆子想法。不送了。”说完这番话,这人转身牵着那少女进了院子,反手掩上了院门。\n纪墨鸿不觉呆在那里,仿佛泥塑木雕,半晌才沮丧上马而去,一路偃旗息鼓。孔昭绶不觉脸上露出一丝微笑。\n孔昭绶下了轿,走到大门前,正要伸手叩门,却见那门是虚掩的。他轻轻推开,里面是一个小院落,三面房间,一面院墙大门,正中一个小天井到处植满花木,阳光透进来,一片葱茏,花架子上十数盆兰花才经新雨,长长短短的绿叶舒展开来,几朵素白的春兰悄然绽放,清香满院。\n只见那中年男子手里拿着个洒水壶,悠闲地在那里浇水,少女也提起一个水壶,边学着父亲的样子洒水,边歪着脖子问:“爸爸,他们是来请你去当官的吧?为什么你不当官,当官不好吗?”\n这人看看女儿,又看看眼前的兰花,说:“当官嘛,倒也没什么不好,不过是有人合适当官,有人不合适。就好像花吧,一种跟另一种也不一样啊,你比方牡丹,是富贵花,像爸爸和开慧种的兰花呢……”\n少女抢过话头说:“我知道,我知道,是君子花。”“对喽。你想若兰花变得像牡丹一样一身富贵气,那兰花还是兰花吗?”那人笑了起来。不等少女答话,院门口忽然传来了一个声音,“恐怕不是。”\n那人诧异地回头,看到孔昭绶正站在门前,一时间,他简直有些不敢相信自己的眼睛,“昭绶兄?” 孔昭绶也是快步上前:“昌济兄!”\n“哈哈哈哈,真是没想到,没想到啊……”这人惊喜地说着,迎上去握住孔昭绶的手,二人相视大笑。这人名叫杨昌济,长沙人。又名怀中,字华生,是个虔诚的佛教徒。早年就读城南、岳麓书院,研究宋明理学。1903年春到1913年,先后在日本弘文学院、东京高等师范学校及英国爱伯汀大学留学,并赴德国考察。对西方教育、哲学和伦理学之历史与现状、理论与实践均有深入研究,乃是湖南有名的大学者。方才回国不久。那少女是他的小女儿,名叫杨开慧,今年刚刚12岁。\n二人一同到书房就坐,杨昌济兀自还在久别的激动中:“东京一别,一晃这都几年了,好几回做梦,我还梦见昭绶兄在法政大学演讲的情景呢——‘当今之中国,唯有驱除满清鞑虏,建立共和之民国,方为民族生存之唯一方法!’那是何等的慷慨激昂!言犹在耳,言犹在耳啊!”\n“我也一直记挂着昌济兄啊。从日本回来以后,我还托人打听过你的消息,听说你去了英国留学,后来又去了德国和瑞士……”尽管久别重逢,想说的话很多,但孔昭绶是个急性子,略略寒暄,便开门见山:“哎,闲话少叙,今天我可是无事不登三宝殿哦。”说着,从口袋里掏出那份聘书,递到杨昌济面前。\n杨昌济不禁有些疑惑,打开聘书,只见写着:“今敦请怀中杨老先生为本校修身及伦理教员,每周授课四时,月敬送修金大洋叁拾圆正。此约湖南省公立第一师范学校校长孔昭绶。”\n“怎么,奇怪啊?当此民国初创、百废待兴之际,什么是强国之本?什么是当务之急?教育是强国之本,教育是当务之急!”迎着杨昌济的目光,孔昭绶站起身,声音大了起来,“一个国家,一个民族,不把教育二字放在首位,何谈国家之发展,何谈民族之未来?开民智,兴教育,提高全体国民的素质,这,才是民族生存之根本,中华强盛之源泉啊!”\n杨昌济连连点头:“嗯,这一点,你我在日本的时候就有共识。”孔昭绶继续说道:“而教育要办好,首先就得办好师范,得有好的老师,才有好的教育啊。这回谭畏公招我任一师校长,我也想过了,头一步就得聘请一批德才兼备的优秀教员,扫除旧学校那股酸腐之气,为我湖湘之教育开出一个崭新局面。昌济兄,你的学问,三湘学界谁不景仰,我又怎能放过你这位板仓先生?”\n迎着孔昭绶殷切的目光,杨昌济却明显地露出了为难的神情。孔昭绶不禁笑了:“怎么,谭畏公的官你不做,我那儿的庙你也嫌小了?”\n“昭绶兄,你开了口,我本应该义不容辞,不过这一次,只怕你是来晚了。”杨昌济从书桌抽屉里取出一封聘书,递给孔昭绶:“这是周南女中昨天送来的聘书,聘我去教国文,我已经答应了。”\n这个变故显然大出孔昭绶的意料,看看聘书上的日期,还真是昨天的落款,失望之中,他只得起身告辞,却仍不甘心:“‘得天下英才而教之!’昌济兄,我记得这可是你毕生的理想啊。”\n杨昌济道:“只可惜英才难求啊。”\n“你怎么知道我那儿就没有英才?我第一师范自宋代城南书院发祥,千年以降,哪一代不是人才济济?且不说张南轩、曾国藩这些历史人物,就是眼下,缔造共和的民国第一人黄克强先生,那不也是我一师的毕业生吗?”\n“可是周南那边……”\n孔昭绶赶紧趁热打铁:“不就是一点国文吗?我只要你来兼课,耽误不了你多少时间的。昌济兄,以你的学问,只要肯来屈尊,未必不能在一师学子之中,造就一批栋梁之材!怎么样,还是答应我吧?”\n迎着孔昭绶期待的目光,杨昌济沉吟了片刻,只好说道:“这样吧,你给我几天时间,我想办法安排一下,要是安排得过来,我就来给你兼这份差。”\n得了他这句话,孔昭绶才算是放心出了杨宅。临上轿,还回头郑重叮嘱了一句:“昌济兄,可别敷衍我哦。”\n送走孔昭绶,父女二人回了书房,开慧一路还在问:“爸爸,孔叔叔他们学校的学生真的很好吗?”杨昌济道:“现在在校的学生嘛,倒没听说什么特别出类拔萃的,新学生呢,又还没招,好不好现在怎么知道?”\n“可是孔叔叔不是说他们学校出了好多人才吗?还有个缔造民国的黄克强先生,那是谁呀?”\n杨昌济告诉女儿:“黄克强,就是黄兴,也是爸爸在日本的时候的同学。”\n“黄兴大元帅?他也是孔叔叔他们学校的学生?”开慧听得几乎跳了起来,拉住父亲的手臂,“哇!爸爸,那你赶紧去呀,你也去教几个黄兴那样的大英雄出来,到时候,民国的大总统、大元帅都是你的学生,那多带劲!”\n“还几个?哈哈……”杨昌济不禁一笑,“真要遇上一个,就已经是佛祖显灵了。可惜爸爸善缘还修得不够,遇不上哦。”开慧嘟着小嘴问:“为什么?”\n杨昌济拍了拍女儿的头,笑着回答:“你还小,不明白这个道理。这个世上,最难求的,就是人才,且不说黄兴那样惊天动地的英雄人物,但凡能遇上一个可造之才,能教出一个于国于民还有些作用的学生,像爸爸这样的教书匠,一辈子,也就知足了。”\n开慧甩开父亲的手臂,偏着头,很认真地对父亲说:“我就不信!爸爸,你以后一定会教出一个比黄兴元帅还厉害、还有本事的学生!”杨昌济笑道:“你算得这么准?”开慧起劲地点点头:“不信我们打赌。”\n杨昌济笑了,望着书桌上的地球仪和那尊他朝夕敬奉的白玉观音像,脸上的笑容却渐渐凝结了起来,心里想:如此人才,却不知锥藏何处?\n二 # 陶会长那辆镶着银色花纹的豪华马车才停在一师门口,一个十六七岁的少女便跳下车来。这少女面目清秀,身材高挑,穿一身淡雅学生裙,虽然看上去像个内秀的古典美女,但她纤细而灵巧的双脚,流光溢彩的双眼却泄露了充满渴望的少女情怀。\n“斯咏!不要乱跑。” 陶会长在车上叫道。“爸,我去看看,这个学校好漂亮。”少女说话间直进了校门。陶会长尴尬地向前来迎接的方维夏一笑,说:“小女陶斯咏,小孩子不懂规矩,让先生见笑了。” 方维夏也一笑说:“不要紧。”然后迎着陶会长进了校长室。\n陶斯咏一个人在学校里缓缓而行。第一师范前身为南宋绍兴三十一年(公元1161年)张浚、张拭父子创建的城南书院。乾道三年,朱熹来访时,住此两月。书院遂因朱张会讲而名传天下,与岳麓书院齐名。书院建在妙高峰上。妙高峰为长沙城区的最高峰,号称长沙城南“第一名胜”。学院前临湘江,与岳麓书院隔水相望。清末书院被毁,一师便在原址上重建,建筑风格仿照日本青山师范学校,以黑白线条为主,等角三角形的深黑色瓦顶,映衬素白的拱形顶百叶窗,墨蓝色方形墙面,整个建筑群是典型欧式风格,典雅庄重。但连接建筑的回廊迂回曲折,开出一个独立的庭院,或有小亭,或有古井,独具东方韵味。\n此时阳光越发明净,院子里几株老槐抽出新条,一树垂柳如烟一般,满院草色苍然,学生们都在上课,回廊里静寂无声,暖风轻拂,一只蝴蝶翩然而飞。斯咏穿过回廊,在一间一间的教室窗外探过头去,看里面都是男生,不觉撇了撇薄薄的嘴唇。\n“衡山西,岳麓东,城南讲学峙其中……”一阵悠扬的歌声和钢琴声忽然传来,斯咏不自觉地寻声走过一个回廊,却见不远处繁花绿树之中,一个穿中式长衫、金发碧眼的老师在那里弹着钢琴,当他那双白种人修长的手滑过键盘时,就有音符如行云流水般从他灵巧的指端泻落,这声音,穿透了斯咏的身心。\n几十个一师的学生一色的白色校服,朝气蓬勃,手里捧着歌谱,嘴里唱着新学的校歌,眼睛却被回廊前斯咏那双灵动的大眼睛所牵引,不能收回到歌谱上,歌声也没有刚才响亮了。斯咏迎着满院男生们诧异的目光,调皮地一笑。\n“斯咏!”陶会长不知什么时候已经站在走廊一头的楼梯口,皱着眉头,尽量压低嗓门叫自己的宝贝女儿,“像什么样子?还不过来?”\n陶斯咏又回看了两眼,才跑了开去。陶会长责怪说:“这是男校!女孩家东跑西跑,成何体统?”看看身边的方维夏,又道:“小女失礼,让方先生见笑了。”\n方维夏倒不在意: “哪里。陶翁代表商会慷慨解囊,捐资助学,我们欢迎还来不及呢,小姐参观一下有什么关系?倒是孔校长有事外出,未能亲迎陶翁,失礼之处,还望见谅。”\n办完了捐款的事,陶会长辞别方维夏,出了校门,正要上车,却不见斯咏跟上来,回头一看,斯咏还站在教学楼的台阶下,一副恋恋不舍的样子。陶会长催道:“斯咏,你到底走不走?”\n“急什么嘛?爸,你看这儿好美啊,那么大的树,还有那么多花,教室也那么漂亮……”斯咏一面走一面回头说,“爸,要是我能到这儿来读书该多好?”\n陶会长被女儿的话逗笑了:“胡说八道!哪有女孩子读男校的道理?”\n“可女的为什么就不能读嘛?不公平!”\n“不是给你办好了上周南女中吗?”\n“可是这儿比周南漂亮嘛!”\n陶会长望着这个被他娇宠惯了的女儿,忍不住摇了摇头:“你个小脑瓜子一天到晚想些什么?一点正经都没有!还不走?”斯咏噘着嘴,恋恋不舍地又回头望了一眼,这才上了车。\n车行到南门口,斯咏素来爱逛开在这里的观止轩书店,便先下了车。\n她来到书店前,习惯性地看了看门口推介新书的广告牌,却见上面最醒目的一行写着:“板仓杨昌济先生新作《达化斋读书录》,每册大洋一元二角”,当即抬脚进了书店。\n书店柜台前的店伙计一只手撑着下巴,一只手百无聊赖地拨着面前的算盘珠子,眼睛却时不时地盯住书柜下露出的一双破布鞋——这个家伙从一大早就来了,蹲在那里看书,一动不动,已经白看了一上午了。店伙计心中早已有些不耐烦,斯咏正好走了进来:“请问有杨昌济先生的《达化斋读书录》吗?”\n“有,还剩最后一本。”伙计满脸堆笑, “小姐,您算来巧了。我这就给您拿去?”一时在书架上四处乱翻,却没有找到,正纳闷时,一眼瞟见破布鞋上遮着的正是那本《达化斋读书录》,叫道:“这位先生,对不起,打搅一下。”\n那人全没有听见他的话,只顾埋头看书,伙计拍拍他的肩膀,大声说“先生!这位先生!”“啊?”那人吓了一跳,问道:“干什么?”伙计指指外面,说:“对不起,您这本书有人要买。”\n“哦,你另外拿本给他吧。”这人又埋头继续看书。伙计忍无可忍,伸手盖住了书,说:“哎哎哎哎,别看了别看了。” “怎么了?”这人站了起来。\n店伙计瞪了他一眼, “这是最后一本,别人买了!”声音惊动了柜台前的斯咏,她向这边望过来,只见一个青年高大的背影,肩上打了一大块补丁,说一口略带湘潭腔的长沙话,“你等我看完嘛……”“我等,人家顾客不能等,你这不让我为难吗?”\n那青年忙说好话:“那……那我看完这一章,就两页了,看完这两页就给他……”“哎呀,拿来吧,你!”店伙计实在懒得跟他纠缠下去,一把将书夺了过来,白了他一眼,换上笑脸走向斯咏,“小姐,对不起对不起,劳您久等了。”\n那青年悻悻地走了出来,斯咏这才发现他身材极是高大,头发剃成短短的板寸,眉目清秀,目光却炯然有神,身上的短衫满是补丁,一双布鞋破开了个大口。他淡淡地扫了斯咏一眼,向门外走去。斯咏怔了一怔,没有接书说:“没关系,人家在看嘛。”店伙计指了指那青年:“您说那位呀?嗨,都蹲那儿半天了,从早上一直到现在,光知道白看!买不起就买不起吧,他还霸着不让别人买,真是!”\n这青年听见这话,猛然转到柜台前,一把将书从伙计手里抢了过来,重重拍在柜台上:“这本书我买了!”斯咏不禁一愣,却正碰上他示威似的目光。店伙计也愣住了,抱怨道:“人家都买了,你这不是抬杠吗?”\n那青年也不含糊,他看看书后的定价,回敬道:“先来后到嘛。我先来,凭什么不让我买?不就一块二吗?”他一手按着书,一手伸进口袋,颇有一副谁怕谁的傲气。然而那伸进口袋的手却慢慢僵住了,脸上的表情也跟着僵住了:他左掏右掏,掏来掏去,不过掏出了两三个铜板,一腔气势顿时化作尴尬。\n伙计脸上浮起了一丝嘲笑:“哟,您不是没带钱吧?”感受到身边斯咏的目光,青年的脸顿时涨红了。伙计却还在继续奚落他:“要是您手头不方便,那我只好卖给这位小姐了。”他说着话,使劲从青年的手掌下抽出书,放在了斯咏面前。\n青年愣了一愣,转身出了书店。斯咏付了钱,拿着书缓缓沿街而行,这时她突然忍不住笑了:那位青年人就走在前面不远处的街边上,似乎脚被什么东西磕了一下,他发泄地一脚踢去,却将鞋踢飞了,他赶紧单脚跳着去捡那只飞出老远的鞋。\n这个样子真是太滑稽了。斯咏看着他跳着移到一棵树旁,正扶住树穿鞋,那只鞋鞋帮被踢开了个更大的口子。斯咏的心隐隐地动了一下,她忽然加快了脚步,走到青年身后,将那本《达化斋读书录》递到了他面前,说:“这本书送给你。”\n青年顿时愣住了,看了看斯咏,一时接也不是,不接也不是。斯咏把书往他手里一塞:“你不是没看完吗?拿着吧。”青年这才反应过来,赶紧边手忙脚乱地掏口袋边对斯咏说:“那,我……我给你钱。”手一伸进口袋,才想起自己根本没有钱,不由得越发尴尬了。\n斯咏道:“我说了送给你。哎,这可是大街上啊,你不会拒绝一位女士的好意吧?”青年只得赶紧接过书,喃喃地回应:“那,算我借你的,我回头还给你。”\n斯咏一笑,转身就走。青年一手举着书,一手提着破布鞋,高声问:“哎,你叫什么?我怎么找你啊?”斯咏回头说:“不用了,书你留着吧。再见。”叫了一辆过路的黄包车,径直上了车。青年想追,但少了一只鞋,无法迈开步子,他单脚跳着,冲斯咏的背影叫道:“哎,哎——那你有空来找我吧,我就住前面湘乡会馆,我叫——”这时黄包车已经跑出老远,显然听不到他的喊声了。青年看看那本书,再看看破布鞋,突然冲着那只破布鞋裂开的大洞喊道:“我叫毛、泽、东!”\n三 # 拿着那本《达化斋读书录》,毛泽东用兜里剩的铜板买了个烧饼,边啃边向湘乡会馆方向走来。\n湘乡会馆所在的巷子口,照例摆了个小小的臭豆腐摊子,摆摊的老人虽然不过五十来岁年纪,看上去却苍老得像六十好几的老头,这老头叫刘三爹,毛泽东一向喜欢吃臭豆腐,早和他混得烂熟。\n毛泽东一路走来,远远便闻到了那股臭味,摸摸口袋,却只能叹了口气。刚要走进巷子,忽见那小摊的破木桌旁坐着两个年轻人,一个十七八岁,长衫笔挺,容貌雅俊,收拾得一丝不苟;一个十五六岁,对襟短衫,还是个愣头小子。这时刘三爹正把臭豆腐端到二人面前,那穿长衫的顿时皱起眉头,掩着鼻子:“端过去端过去,他的。” 刘三爹赶紧把臭豆腐移到那愣头小子面前,侧头问那长衫少年:“这位少爷,您不来碗?”\n长衫少年掩着鼻子使劲地摇头。毛泽东见了他的模样,当时便笑了,悄悄走了过来。这边那愣头小子把脸凑近热气腾腾的臭豆腐,深吸一口气,盯着长衫少年问:“哥,你真不吃?”\n长衫少年头一摇:“臭烘烘的,吃什么不好?吃这个。”“闻着臭,吃着香!你就不懂。”愣头小子说着从筷笼里抄起一双筷子就要动手,长衫少年赶紧拦住他,从衣兜里掏出了一块雪白的手帕。愣头小子看他擦着筷子,摇头说:“就你讲究多!”长衫少年瞪了他一眼,反反复复狠擦了几遍,看看手帕上并无污渍,这才把筷子塞给了弟弟。\n毛泽东走到二人身后,忽然一拍那长衫少年,那少年吃了一惊,却听对面的弟弟早抬起头来,惊喜地叫道:“润之哥。”这二人正是毛泽东的好友,长衫少年名叫萧子升,愣头小子名叫萧三,两人是两兄弟,都是毛泽东两年前在湘乡东山学堂时的同窗。看着二人,毛泽东还没开口,肚子就先发出一阵“咕噜噜”的声音。萧子升忙拉毛泽东坐下,要了一碗臭豆腐。毛泽东风卷残云吃得干干净净,放下空碗,用手背一擦嘴,这才长长地透了一口气。\n萧子升打趣道:“一箪食,一瓢饮,润之兄饱乎?不饱乎?”“饱也,饱也。还不饱我不成饭桶了?”毛泽东拍着肚皮,有些不好意思地解释,“不瞒子升兄,我呀,五天没吃过一餐饱饭了,天天一个烧饼打发,那烧饼做得又小,吃下去跟没吃一样。”\n“怎么,口袋又布贴布了?”萧子升说着,掏出钱袋,“哗啦”一声,把钱通通倒在桌上,里面是几块银元和一堆铜板,他把钱分成三堆,也不数,将其中一堆推到毛泽东面前:“拿着吧。”\n毛泽东也不客气,收了钱:“等我家寄了钱,我再连以前的一起还给你。”\n“等你家寄钱?等你家寄钱你还不饿死七八回了?我说润之,你这样下去也不是个办法,总不能老跟家里犟下去,还是要跟伯父说清楚才行……”萧子升还在说着,毛泽东打断了他的话,说:“哎呀,你不明白的。我们家老倌子,什么都好商量,就读书两个字提不得!”\n三个人离开臭豆腐摊,回了湘乡会馆,进了萧家兄弟租住的房间,萧子升说道:“我说润之,你这样下去不行,才到长沙一两年,学校读了无数个,没一个满意的,也怨不得伯父生气。你到底打算上哪所学校?”\n这个话题正触到了毛泽东的难处,他呆了一呆,摸摸后脑勺说:“那些学校是不行嘛,读不下去,我有什么办法?正好,最近有没有什么新消息呀?”萧三笑说:“润之哥,我们今天正想去跟你说这件事情的,干脆,跟我们一起考北大算了。”“北大?”毛泽东眼睛一亮,“北大今年对湖南招生了?”萧三手舞足蹈地说:“对呀,招生广告都出来了,全国都可以报名,我和我哥都打算去考呢。”\n“真的?哎呀那太好了!我去年就想考北大,兵荒马乱的没去成。哎,它什么时候招生?能不能在长沙考?”毛泽东大喜过望,一口气提了一连串的问题。萧子升笑道:“哪有在长沙考的道理?当然得去北京,就下个月。我和萧三正在想办法筹钱呢。润之,一起去吧。三个人一块,到北京还能省点住宿费呢。”\n一提起钱,毛泽东口气便虚了:“那,大概要好多钱啊?”\n“一个人总要150块大洋吧。”\n毛泽东听得眼睛都瞪圆了,叫了起来:“150?!”\n“你瞪着我干嘛?”萧子升看到毛泽东的这副样子,索性扳起手指给他算账,“你想呀,这么远的路,食宿、路费,两个月备考,再加上头一年的学费、杂费、生活费各项,150块已经是紧打紧算了。”\n毛泽东这下傻了眼:“我的个天,150!剁了我这身肉,不晓得卖得到15块钱不?”\n“我现在也是天天愁钱。两兄弟这一下就是三四百块,家父这一段身体又不好,家境也不如从前,可除了跟家里伸手呢,我又实在想不出别的办法。”萧子升话锋一转,对毛泽东说,“其实说起来,你比我们强多了。”\n“我比你们强?我都穷得饿饭了!”\n“好歹你家里并不穷嘛,真要想办法,这个钱未必拿不出来。”萧子升道。萧三也点头:“是啊,润之哥,你就跟你爸说说好话嘛,你要去北大,肯定能考取,这么好的机会,错过就可惜了。”\n“机会我当然不想放过,可我们家老倌子,哎呀……”毛泽东想想还是摇了摇头。\n“你没试过怎么知道他一定不答应你?以前你读书,他不是也供过你吗?你跟他说清楚,全中国就一个北大,最好的大学。父望子成龙嘛,他也盼着你前途无量。”\n“对对对,你把读北大的好处说他个天花乱坠,万一说动了伯父,不就解决了吗?”\n听着萧氏兄弟的劝说,毛泽东嘴里沉默不语,心底里也不觉有些动了。\n第二章 免费招生 # 湖南省公立第一师范的招生广告,\n末尾 “ 免收学费,\n免费膳宿,\n另发津贴 ”\n一行字极为醒目\n一 # 残阳缓缓从韶山的峰峦间隐去,一山的苍翠都被抹上了胭脂。沿山而下,掩映的绿树翠竹中是一栋十三间的泥砖青瓦房,房前一口池塘,塘边春草初生,塘内小荷露出尖角。远处的山野间油菜花开得正旺,一片金黄,夹杂着绿树和新放的桃花梨花,四处炊烟,袅袅而起。\n屋场上,一个中年妇女正拿着一个小竹簸在撒谷喂鸡,随着她“啰啰啰”的叫声,十几只鸡争先恐后地抢着谷粒。不远处,一个戴着瓜皮帽穿着短褂的老人坐在板凳上,闷头敲打着一张犁。这时忽然一个惊喜的声音传来,“娘!娘!大哥回来了,大哥回来了!” 一个十三四岁的少年打着赤脚,边喊边直跑过来。\n中年妇女诧异地抬起头来,正看见少年身后,毛泽东背着蓝布行李包,拿着雨伞,大步奔来,老远便喊道:“娘——娘——”这妇女正是毛泽东的母亲文七妹,两行泪珠立时从她的眼中夺眶而出,她喃喃说道:“石三伢子?我的石三伢子啊……”手一抖,小竹簸顿时掉在了地上。鸡群蜂拥了上来,争抢着谷粒。\n“去去去,去去去……”正在修犁的老人赶紧抢上前,手忙脚乱赶开鸡,捡起竹簸,放到了一旁的竹架子上。这时毛泽东放下手里的行李,向他叫道:“爹。”毛贻昌看了他一眼,说:“你也晓得回来了。”仍自顾去修犁。文七妹急忙擦去眼泪,说道:“快,泽民,帮你大哥把行李拿进去。”那少年答应着,毛泽东忙说:“不用了。”拿起行李便进了屋。\n一家人吃过晚饭,文七妹把两个小孩子毛泽覃、毛泽建打发去睡觉了,和毛泽东坐在灶房门口。一个缝补着毛泽东那只破了的布鞋,一个剥着豆,都不时地悄悄偷窥着毛贻昌的表情。\n房里“噼啪”燃烧着的火塘上,吊着一口老式铜吊壶。毛贻昌就挨近火塘坐在条凳上,把旱烟锅子凑近火苗,点着了烟丝,跳动的火苗照亮了他满是皱纹的脸,他长长地喷出一口烟,紧锁的眉头下,目光固执。半晌终于开口问:“你讲的那个什么什么大学?”\n毛泽东小心翼翼地补充说:“北京大学,就是以前的京师大学堂。”毛贻昌猛地把烟锅子往条凳上一磕,“我不管你什么金师大学堂、银师大学堂,一句话,什么学堂你都莫打主意! 150块大洋?亏你讲得出口!你当这个家里有座金山,容得你一顿败家子败哒!”\n毛泽东低头看着父亲,说:“我是读书,又不是浪费。”毛贻昌一听更是火冒三丈,用烟锅子指着儿子说:“你还好意思提读书!你读的什么鬼书?哼!”文七妹忙说:“哎呀,你好点讲嘛,一开口就发脾气,三伢子这才进门……”\n毛贻昌瞪了她一眼,“你少啰嗦!都是你把他惯坏了!”文七妹赶紧不做声了,埋头继续补手里的鞋。\n毛贻昌却越说越生气,“早听了我的他不会是这个样子,你自己看看你自己看看,二十岁的人了,文文不得武武不得,一天到晚东游西逛,只晓得花钱就不晓得赚钱!都是你这个做娘的从小惯的……”\n毛泽东抬起了头:“爹!你骂我就骂我,骂我娘干什么?”毛贻昌眼睛一瞪:“这个家还是老子当家,老子骂不得啊?还顶嘴!你自己算一下,这些年你读书读书都读出了什么名堂?东山学堂你呆不住要去省城,老子让你去了,你呢?读不得几天你退学,什么不好当你去当兵!”\n毛泽东嘟囔道:“那你以前不也当过兵……”毛贻昌却一句话把儿子堵了回去:“我当兵是没饭吃!你也没饭吃啊?你有吃有喝有老子供祖宗一样供起你,你去当兵!好铁不打钉,好男不当兵,这句话你没听过啊?”\n“我现在不是没当兵了吗?” 毛泽东缓了口气。毛贻昌不理他,又装了一锅烟丝,凑近火塘点燃,咂了一口,坐回条凳上去,这才说:“那倒是!兵你不当了,你讲要读书,结果呢?今天讲要进商业学校学做生意,我还蛮高兴,答应你,给你钱报名,你读两天讲听不懂什么英文,你要退学;明天讲你要进肥皂学校学做肥皂,我又答应你,又给你钱报名;后天你要进警察学校学当警察;大后天你要进什么法政学校学法律,当法官;再过两天一封信来你又到了省一中……你自己算算,半年不到,你换了好多学堂?有哪个学堂你呆满过一个月?你读书?你读什么鬼书?你把老子当鬼哄才是真的!”\n毛泽东似乎没发现父亲的忍耐已经快到极限了,插嘴说:“那些学校是不好嘛。”毛贻昌眯起眼睛反问道:“那些都不好,这个就好了?”毛泽东忙道:“这次这个不一样,这是北京大学,中国最好的大学……”\n毛贻昌劈头打断他:“你少跟我乱弹琴!哪一个学校你开始不讲好?哪一个学校你又读得下去?长沙读遍了,不好玩了,你又想起去北京,换个大地方玩是吧?你啊,老子是看透了,从今往后,再莫跟我提什么读书的事!”\n一直埋头补鞋的文七妹忍不住又抬起头说:“顺生,三伢子想读书,又不是什么坏事……”毛贻昌转头厉声说:“我求哒你闭起嘴巴好不!”文七妹只得又不做声了,打量着补好的鞋,收拾着顶针、针线。\n毛贻昌回头对毛泽东说:“我告诉你,你今天回来了就莫想再走了。银田市那边天和成米店,是我的老主顾,人家给了天大的面子,愿意收你去当学徒,明天你就跟我一起去,以后老老实实在那里拜师学徒,三年学成,接老子的脚!”\n毛泽东头一扭:“我不去!”\n“你敢!我告诉你,以前我都由着你的性子,才搞得你这么没出息,这一次的事,板上钉钉,你去也得去,不去也得去!”毛贻昌用烟杆敲打着条凳,“还有,罗家的媳妇你14岁上我就给你定好了,你一拖拖到现在,你拖得起,人家女方拖不起。等你到天和成拜完师,就给我回来办喜事圆房,以后老老实实成家立业种田做生意,也省得你一天到晚胡思乱想,一世人在外面吊儿郎当!”\n毛泽东闻言,腾地站起身来,毛贻昌瞪眼喝道:“你干什么?”\n“我不要钱了,我明天就回长沙!”\n“你再讲一遍!”\n“我明天就回长沙,以后再也不回来了!”\n“反了你了?”毛贻昌抡起旱烟杆就劈了过去,毛泽东一闪,旱烟杆打在板凳上,断成了两截。毛贻昌顺手又抄起火塘边的火钳,扑了上来,骂道:“还敢顶嘴?还顶嘴?我打死你个忤逆不孝的东西……”\n他抡着火钳便是一顿乱打,毛泽东虽然东躲西闪,身上还是挨了两下。文七妹和毛泽民吓得赶紧冲上来,死死拦住毛贻昌。文七妹叫道:“哎呀,你干什么你?你放下!这是铁做的,你晓不晓得……”\n混乱中,隔壁的泽覃、泽建也被惊醒了,揉着惺忪的睡眼站到了灶房门外。恰在这时,哗啦一声,把大家都吓了一跳,原来是文七妹装针线的小竹匾被毛贻昌一火钳打翻,将里面的顶针早砸扁了。才六岁的泽建吓得“哇”的一声哭了出来,叫道:“爹——”\n毛贻昌喘着粗气,直指着儿子,“你给老子听着,滚回房去蒙起脑壳好好想清白!你要敢跑,我打脱你的腿!” 毛泽东哼了一声,却被母亲连推带劝进了卧室。毛贻昌找了把锁来,只等文七妹出来,便“咔嚓”一声锁住了房门。\n那天夜里,毛泽东躺在床上,翻来覆去,却始终无法平静下来。\n也不知过了多久,一阵轻微的响声突然从窗台传了过来,毛泽东腾地弹起,扑到窗前,看见泽民正在窗外撬着窗户。兄弟俩心有灵犀,一里一外,小心翼翼地一起用力,窗子被撬开了。毛泽民向他做了个手势,低声说:“大哥,爹睡着了,你小心点。”\n毛泽东点点头,敏捷地爬上窗户,刚把头探出窗外,却看见母亲站在窗外等着,忙叫道:“娘?”\n母子三人轻手轻脚离了家,到了村口,文七妹这才把一个蓝布包裹递到了毛泽东手中,从怀里小心地摸出一方手帕包,拿出里面的几块银元,塞了过来:“你娘也没有几个钱,这是瞒着你爹攒的,就这么多。娘这一世也没什么用,你想读那个大学,娘也帮不上你。要读书,你就找个便宜点的学堂吧。”\n毛泽东呆了一呆,接过银元,喉咙里不觉一阵哽咽,也不知说什么好。文七妹抚着儿子的脸,柔声说:“一个人在外面,要自己多保重,饭要吃饱,冷了要记得加衣服,莫太苦自己,有什么难处,就写信回来,娘帮你想办法。你爹爹也是为你好,就是性子急,你不要怪他,等过一阵子他气消了,你再写封信回来跟他认个错,就没事了,啊。”毛泽东怔怔地听着,点头说:“哎,我记住了。”\n“好了,快走吧,晚了你爹爹醒来了,又走不成了。走吧走吧。”文七妹推着儿子,眼里却红了。\n毛泽东好不容易忍住了眼泪,长吸一口气,对身旁的泽民说:“二弟,我走了,你在家里多照顾娘。”\n他刚转身走出几步,身后又传来了文七妹的叮嘱声:“三伢子,记得走大路,莫走山上的小路,晚上山上有狼。”\n毛泽东再也忍不住了,转身扑向母亲,一下子跪倒在地,哽咽着说:“娘,儿子不孝,不能守在您身边,对不起您了……”眼泪从他的眼中狂涌而出。\n文七妹搂住儿子,拍拍他的后背,催促道:“好了好了,莫哭了莫哭了,娘晓得你孝顺。我石三伢子是有出息的人,要干大事的,娘不要你守着。不哭了啊,快点走吧,听话,走吧。”毛泽东用力给母亲磕了个头,狠狠擦了一把泪,站起身就走。\n刚走出几步,他突然愣住了,前方不远处的大树下,父亲毛贻昌居然正站在大路中央。\n毛泽民和母亲都呆住了,一时都不知道怎么才好。毛泽东和父亲对视着,沉默中,两个人似乎在比试谁比谁更倔强。终于,毛贻昌低下头、背着双手,缓缓走了过来。看到父亲脸色铁青地从自己身边走过,却看也不看自己,毛泽东的鼻子忽然有些酸楚,这时一个小包裹直落在了他脚边,随即地上一阵丁当乱响,月光下洒了一地的银元,闪闪发亮。\n母子三个人面面相觑。只听见毛贻昌冷冷地说:“你娘老子的话你都听到了,那种少爷公子读的什么大学,莫怪家里不供你,自己去找个便宜学堂,再要读不进,就老实给我滚回来!”说话间他头也不回,径直向家里走去。\n看着父亲消失的方向,毛泽东蓦然心里一热。他蹲下去,伸出被父亲用火钳打得满是淤青的手,一块一块地捡着地上的银元。摸索中,他突然停住了——父亲扔给他的包裹里除了银元,居然还有一瓶跌打油!他猛然站起来,大声叫道:\n“爹,我记住了,我会读出个名堂的!”\n二 # 从湘潭韶山到长沙,约摸一百五十里水路。毛泽东坐船回到长沙,已经是第二天傍晚时分,他下船便向萧氏兄弟的住地而来。方才坐下,萧子升正想开口,萧三却抢着问道:“润之哥,上次我们说一起考北大,你决定没有?”\n“我这次来就是要跟你们说这件事情。唉,我回家去一说上北大要150块大洋,我们家老倌子就火冒三丈。哦,差点忘记了,我这次是来还钱的。不好意思,有借有还,再借不难。”毛泽东边拿出钱来,边把自己挨打,连夜逃跑的事说了一遍,末了说:“老倌子给的钱不够啊,我现在也不知怎么办好。”\n萧三哈哈大笑,说:“你们家老倌子真有意思。”萧子升却静静地听两人说话,一言不发。毛泽东问道:“你们两个怎么样了,有办法了没有?” 萧子升闻言叹了口气,从怀里拿出两样东西,一封信、一张报纸。把信递给毛泽东,神色凝重,说:“子暲也看看吧。” 萧三呆了一呆,伏在毛泽东背上看时,却是一封家书,写道:“子升、子暲吾儿,汝父昨日为汝学费一事,外出筹借款项,突发晕眩旧疾,至跌伤右足。家中近年生计本已颇不如前,岂料又生此变故?来信所言报考北大之学杂各费,恐已难以为备…… ”萧三脸色顿时变了,说:“娘什么时候来的信,哥你怎么早不说。”\n子升不理他,说:“家父都病成这样了,我们做儿子的,不能为家里分忧也就罢了,还提什么考北大?按说家里出了这样的事,我们兄弟就应该回家尽孝,不该再想什么读书的事。可家父这些年辛辛苦苦,盼的就是我们有个像样的出息,现在说不读书的话,他老人家是断不会答应的。”\n萧三张大了嘴说:“那怎么办?”子升将那张报纸推到二人面前,上面赫然是一则湖南省公立第一师范的招生广告,末尾“免收学费,免费膳宿,另发津贴”一行字极为醒目。\n毛泽东顿时明白过来,大笑说:“你想去读不要钱的师范?”\n“除此还有什么两全之策?”子升苦笑了一下,“其实师范也不错啊,又不要钱,出来又不愁没事做。再说,一师这次除了五年制的本科,还开了两年制的讲习科,我正想早点毕业做事,读两年,就能出来帮着供子暲,也不错呀。”\n萧三沉默一时,说:“哥,你读书比我强,还是我去考讲习科,你读本科吧。”“我是大哥还是你是大哥?这件事不要提了。”子升回头发现毛泽东仍拿着那份报纸出神,便问:“润之,你的学费也没凑足,有没有想过下一步怎么办?”\n毛泽东仿佛没有听见他的话,突然没头没脑冒出一句:“‘毛老师’?哎,你们觉得,‘毛老师’三个字,喊起来顺不顺口啊?”看看萧氏兄弟一副摸不着头脑的样子,毛泽东接着说:“我是说我要是去教书,往讲台上这么一站,那些学生不得喊‘毛老师好’吗?”\n“你也想考?” 子升、萧三这才明白过来,三人顿时相视大笑起来。\n三 # 湘江自南向北迤逦而来,在大西门穿城而过,将长沙城分作东西两部。自光绪三十年(1904年)长沙开埠,客货云集,大西门渡口便成了长沙最繁华的渡口。\n这一天清晨,在渡口长长的石阶旁,衣着一丝不苟的纪墨鸿坐在椅子上,一面跷着脚让人给他擦皮鞋,一面看报纸,报纸背面是醒目的“湖南省公立第一师范学校招生启事”。给他擦皮鞋的是一个十七八岁的少年,身材高挑、体形单薄。他专注地看着报纸,却忘了手里的活计。纪墨鸿显然感觉到了,他突然移开了报纸,对着小伙子吼道:“喂,你还擦不擦?”这个少年名叫蔡和森,湖南湘乡人。\n蔡和森吃了一惊,手脚麻利地忙碌着:“擦,擦,马上就好……先生,擦好了。”纪墨鸿看看擦得锃亮的鞋,站起身,掏了两个铜板递出去。蔡和森红着脸,轻声说:“我不要您的钱。先生,能不能把这份报纸给我?”\n纪墨鸿愣了一下,看着眼前这个小伙子,穿的一身学生装,虽然打着补丁却很整洁,问道:“擦鞋的还看报?你认识字吗?”蔡和森点了点头。\n纪墨鸿严肃的脸顿时笑了起来,他把报纸递给蔡和森,“拿去吧。贫而好学,穷且益坚,我最喜欢这样的年轻人了。”蔡和森连忙谢过,蹲在地上,认真地看着报纸,全没听到码头上响起的高亢的汽笛声,轮船靠岸,旅客纷纷涌了出来。\n向警予在保姆仆人的簇拥下慢慢下了船,她老远便见一乘轿子候在路边,一个管事模样的人领着大堆仆人站在那里,不觉皱了皱眉头,向管事问道:“斯咏没来?”管事笑说:“小姐临时有事,她临走时托付小人,千万要照顾好向小姐。”这管事正是陶会长的管家,向警予的父亲则是溆浦商会的会长,陶向两家是世交,常有往来,向警予与陶斯咏自小就是好朋友,此次向警予前来长沙就读周南中学,向父便托陶会长代为照看。\n管事说道:“向小姐,这是我们老爷给您准备的轿子。”向警予手一挥:“谢了,我用不着。”\n管事忙道:“那哪行啊?您是千金小姐,哪有自己走路的道理?”\n“我就喜欢自己走路。” 向警予理也不理他,直上了台阶。管事还想劝,保姆拦住他说:“您就别客气了,我们小姐就这习惯,从来不坐轿,劝也没用。”\n管事愣住了,后面跟上来的仆人嘀咕了一句:“这什么小姐呀?”管事瞪了仆人一眼,仆人赶紧不做声了。管事只得向轿夫一挥手:“跟上跟上。”前面向警予走得飞快。\n警予忽然停在了蔡和森的身后,她被蔡和森手里的报纸吸引住了。蔡和森诧异地一回头,却看见一位美貌少女正探头看自己手里报纸上的广告,正不知所措,还没反应过来发生了什么事情,这少女已经大大方方地也蹲了下来,对他说:“哎,报纸能不能借我看一下。”\n蔡和森还没遇到过这样大胆的女子呢,他实在不清楚对方要做什么,只得赶紧把报纸递给她。管事的跟了上来,很是抱歉地说:“向小姐,您要看报啊?我这就给您买去。”\n“不用了不用了,我不正在看吗?” 警予头也没抬,小声读着广告:“‘……于见报次日,即开始报名。’哎,这是哪天的报纸?”一时乱翻,去看报头的日期,也不等蔡和森回答,又自言自语道:“今天的?太好了!”\n看到蔡和森正茫然地望着自己,她笑了笑,问:“哎,你想去考啊?到时候咱们一块儿考。”“一师不是女校,你怎么可以去考呀?”蔡和森真想不明白,怎么这个女生连长沙的一般学校不招女生都不知道?\n“广告上也没说只招男生啊?怎么这样啊?太没道理了!我还以为省城会比小地方强呢,也这么落后!”警予站起身,把报纸还给蔡和森,冲管事大声说,“走,去第一师范!”管事又是一愣,这位小姐让他已经有些昏头昏脑,“不回陶府吗?”\n“我现在不去陶老爷府上,我要去第一师范!”向警予一字一顿地说着,走出几步,又回头对蔡和森说,“哎,你等着看,我肯定跟你一起考。”\n向警予直奔第一师范而来,一脚踏进教务室,叫道:“老师,报名处是这里吗?我要报考。” 教务室此时只有国文教师袁吉六一个人,这位前清的举人花白大胡子,体态肥胖,留着剪过辫子后半长不长的披肩发。他半天才弄明白眼前这个风风火火的女子居然要考一师,几乎有些不敢相信,“你个女娃娃考一师?”\n向警予挺起腰杆,大声道:“我是女人,不是什么女娃娃!”袁吉六把眼镜往鼻梁上一推,看也不看警予一眼,“女人更不能考!男女之大防都不要了,成何体统!去去去!”看到向警予的脸都被气白了,旁边的管事赶紧插话:“这位先生,说话客气一点嘛。这可是向会长家的千金……”\n“我管你什么千金万金,赶紧领回家去,少在这里捣乱!” 袁吉六扬扬下巴说。这时一名校役进门,“袁先生,校长请您去开会呢。” “知道了。”袁吉六慢条斯理地起身,端起水烟,边走边对管事说:“赶紧走赶紧走,也不看看地方——这是学校,学校是女人家来的场合吗?搞得没名堂!”\n“你才没名堂呢,老封建!”向警予冲袁吉六的背影跺跺脚骂完了,又冲着管事说,“走,这种地方,请我我都不来!”她冲将出去,从袁吉六背后挤过,扬长而去。袁吉六被挤得一个踉跄,连水烟壶都差点掉了,气得他吹胡子瞪眼,半天才憋出一句:“简直……简直……世风日下!世风日下!哼!”\n向警予憋着气到了陶家,一进陶家就大声嚷嚷:“气死我了,真是气死我了!简直不把女人当人!真是气死我了!”这时门外一个声音传来,“什么事把我们的向大小姐气成这样?”陶斯咏站在了门前。\n“斯咏,你个死丫头,也不到码头接我。” 警予叫道,两人一把抱住了,笑闹成一团。这时一个佣人上前来说道:“小姐,刚才姨太太来电话了,她和表少爷一会就到。” 斯咏闻言怔了一怔,顿时不耐烦起来,说:“知道了。”却对警予说:“走,去看看你的房间。”\n二人上楼来,警予一面看,一面把报考一师被拒的事说了,斯咏却有些心不在焉,趴在床上说:“谁要你跑到男校去报名的?其实读周南还不是一回事?反正都是师范。我现在才是真的遇到麻烦了。”\n警予却没有听出她的言外之意,在房间里不停地走来走去,说道:“那也不能把我轰出来吧?还城南书院,千年学府?都是老封建!况且,我也没说非得进一师,我就是咽不下这口气!谁比谁差呀?”\n斯咏点点头,应和道:“那倒是,进了考场,说不定那些男生还考不过我们呢。”\n警予一听这话,突然停下脚步,偏着头想了想,然后一脸坏笑地看着斯咏,凑近她说: “如果有两个女生,悄悄去参加了一场只准男生参加的考试,而且考了第一名,然后她们再去告诉那些老封建考官,你们录取的头名状元,乃巾帼英雄陶斯咏、向警予是也,那时候你会是一种什么感觉?”\n斯咏推开她说:“去去去,异想天开!我可不跟你发神经!”“哎,我可不是跟你开玩笑,我们这回,就是要让他们看看,女人比男人强!来,拉钩!”警予一本正经地凑拢来,向斯咏伸出手来。斯咏犹豫着,警予用目光鼓励着她,斯咏显然经不起这番怂恿,终于按捺不住,两只手的小指勾在了一起。\n这时陶家的丫环进来,讲王家的老爷、太太和表少爷已经来了。斯咏闻言呆了一呆,不情愿地站了起来,苦着脸看着警予,警予笑说:“快去,别让人等急了,我累了,要睡觉,就不打扰你们约会了。” 说话间又暧昧地一笑,斯咏瞪了她一眼,半晌才缓缓下楼来。\n陶家的客厅里,西装革履、戴着近视眼镜的王子鹏坐在沙发上,一双纤弱的手下意识地绞在一起。他长得颇为清秀,从头到脚收拾得一丝不苟,只是脸色略有些苍白,身后站着他的丫环秀秀。\n斯咏进了客厅,看到父母和姨夫姨母都不在,有些意外,只好很不自然地招呼子鹏:“表哥,你来了?”\n子鹏站起身,同样的不自然,紧张地挤了个笑容。倒是秀秀乖巧地叫了一声表小姐。\n“哟,斯咏,”王老板、王夫人与陶会长这才从里面出来,王夫人先咋咋呼呼叫了起来,“子鹏今天专门来邀你出去玩的,都等你半天了。我们大人要商量点事情,你们小孩子出去玩吧。”\n斯咏看看三位长辈,再看看局促不安的王子鹏,一脸的不情愿,向外走去。子鹏赶紧跟了出去。秀秀几乎是条件反射地拿起子鹏的围巾,跟在子鹏身后。王夫人眼睛一瞪,呵斥道:“阿秀,少爷陪表小姐散步,你跟着算怎么回事?一边去。”\n秀秀收住脚步,回到夫人身边,目送着子鹏出门。只见子鹏跟在斯咏身后走出大门,悄悄窥视着斯咏的表情,正好斯咏回过头来,他又赶紧低下头。\n斯咏问他:“你到底想上哪儿去?”“我……随便。”斯咏说道:“王子鹏,你什么时候能有一回主见?哪怕就说一个具体的地点,这不是很难吧?”\n子鹏紧张地绞着双手,不敢看斯咏。斯咏移开目光,摇了摇头。这时远处忽然传来教堂悠扬的钟声,子鹏似乎想起了什么,兴奋地说道:“我们去教堂!”\n“听说……你要上周南去读书?”子鹏终于找着了一个话题。斯咏点头说:“周南女中师范科。还有一个朋友跟我一起。”“谁呀?”子鹏无话找话。\n“溆浦商会向会长的女儿,叫向警予。我们约好了一起读师范,以后毕业了,一起当老师。对了,你呢?” 斯咏说道。“我什么?”子鹏呆了一呆。\n“你的打算啊?打算上哪所学校?学什么?打算以后干什么?”\n“我,我还没想好。” 子鹏半晌才说道。\n“就是说,姨父姨母还没给你安排好,是吗?”\n子鹏不禁有些窘迫。这时教堂的钟声再次响起。子鹏突然想起了什么,忙不迭地掏起口袋来,他掏出一大把零钱数着,兀自不足,“斯咏,你——有没有零钱?”\n斯咏看得莫名其妙:“你要那么多零钱干什么?”子鹏吞吞吐吐地说:“我……我借一下。”\n“王少爷,哎,王少爷来了……!”这时一大群小乞丐看见子鹏,呼啦一下围了上来,一只只黑黑的小手伸了过来,子鹏忙不迭地把手中大把零钱分发给每一个孩子。在孩子们的一声声“谢谢”里,斯咏温柔地望着子鹏分发零钱时那灿烂的笑容。\n孩子们一阵风似的来,又一阵风似的散去。待最后一个孩子跑开,子鹏回过头,正碰上斯咏的目光,这目光他很陌生。在教堂外的椅子上坐下,斯咏问:“你好像跟他们很熟?”\n隔着一个人的位置,子鹏坐在斯咏左边:“也谈不上……我经常来这儿,他们习惯了。”斯咏望着子鹏,子鹏被她的目光弄得一阵紧张,低下头。\n斯咏沉吟说道:“表哥,有句话我想跟你说。其实,你是个很好、很善良的人,可你想没想过,一个人光心地善良是不够的。你可以发善心,给这些孩子施舍,可这能改变什么呢?你能改变他们的前途,能改变他们的命运吗?”\n子鹏愣住了,他显然没认真想过这些问题。斯咏又说:“中国到处都是这样的孩子,如果光是施舍,而不为他们去做点什么,那他们今天是这样,明天还会是这样,甚至他们的孩子,他们孩子的孩子仍然会是这样。这就是我为什么要去读师范,要去当老师的原因。”\n她抓起子鹏那只略有些苍白的右手:“你有没有想过,你的这双手,能为这些孩子,能为这个社会做些什么有用的事?能让你自己觉得,你是一个对别人有用的人?表哥,这些问题,我们都好好想想,好吗?我先走了。”\n斯咏走了,子鹏呆在那儿抬起自己的手,仿佛不认识一样端详着,直到钟声又一次响着,惊得一群鸽子扑啦啦从他面前飞起,才站起身来。\n中午子鹏闷闷不乐回到家,他在心里反复咀嚼着斯咏的话,呆坐在阳台上,随手翻看当天的报纸,当他看到一师的招生广告时,沉默了一时,忽然忍不住问正给他端茶来的秀秀:“秀秀,你说,我,王子鹏,是不是一个有用的人?”\n秀秀放下茶杯,站在少爷身后,说:“少爷读过那么多书,还会洋文,心又那么好……您是少爷,怎么会没用呢?”\n子鹏把手里的报纸放在桌子上,撑着下巴说:“我有用?我是能文,能武?还是能做工,能种田,能教书,能医病,我能干什么?我对别人有什么用?除了当少爷,我连一杯茶都不会泡,还得你泡好了给我端过来!”\n秀秀以为是自己说错了话惹少爷不高兴了,赶紧摆着手说:“少爷,好端端的您这是怎么了?您哪能跟我这种下人比呢?少爷……”\n“我应该跟你比,跟你比了我才会知道,我就是个废物,一个废物!”长长地叹了一口气,子鹏拿起那份报纸,读着上面的广告,说:“我不要做废物,我去考一师范,当教师,教孩子!”\n秀秀看了看报纸,忽然说道:“少爷,您这张报纸能给我吗?”\n四 # 湘乡会馆巷子口卖臭豆腐的刘三爹今天收了摊,儿子刘俊卿考上了法政学堂,眼看着就要报到了,可是家里哪能拿得出30块大洋的学费呀?实在没有办法,刘三爹只好领着儿子去了三堂会。\n堂里的大哥马疤子斜在榻上抽着大烟,手下的亲信老六带着好几名打手凶神恶煞地侍立在旁边。马疤子喷了口烟圈,懒洋洋地说:“嘿,有意思。借钱交学费?我说刘老三,你不是真老糊涂了吧?”\n刘三爹把腰快弯成一张弓了,低声恳求:“实在是想不出法子了,这才求到马爷这儿。就30块大洋,多少利息我都认,求求您了。”\n“你认?”马疤子坐了起来,长长地伸了个懒腰,盯着刘三爹问:“你拿什么认?啊?就凭你那清汤寡水的臭豆腐摊?”他说着下了烟榻,过来拍拍刘三爹的肩膀,又说:“老刘啊,听我马疤子一句劝,死了这条心吧。就为你这傻儿子读书,这些年你都过的什么日子?能典的典能当的当,三更半夜起早贪黑,连闺女都押给人家当了丫环,你值吗你?”\n“俊卿他会读书,他真的会读书,他以前在学堂年年考第一的。”刘三爹赶紧拉过刘俊卿,“俊卿,来,你把学堂的成绩单给马爷看,你拿出来呀。”\n这种卑躬屈膝的屈辱令清秀俊朗的刘俊卿很是难堪,他沉着脸,甩开了父亲的手。\n“好了好了,谁看那破玩意?”马疤子看到刘俊卿这副样子,“哼”了一声,“我就不明白,这书有什么好读的?还当法官?马爷我一天书没读过,连法官还得让我三分呢!告诉你,没钱就别做那个白日梦,麻雀变凤凰,还轮不到你那臭豆腐种!”\n“我求求您,马爷,只要俊卿进了学堂,我给您做牛做马……”刘三爹还不死心,刘俊卿却实在受不了了,他转身就走,刘三爹赶紧拉他,“俊卿,你回来,快求求马大爷……”\n刘俊卿甩掉父亲的手,说:“要求你求,我不求!”\n马疤子在身后叫道:“哟嘿,还蛮有骨气?我说小子,真有骨气,就别把你家老头往死里逼,自己给自己寻条活路是正经。马爷我为人义字当先,最是个爱帮人的,要不,上爷这儿来?爷手底下能写会算的还真不多,包管有你一碗饱饭吃。”\n父子俩回到家已经是黄昏了,棚屋里已经简陋得没有任何一样值钱的东西。一道布帘将本来就狭窄的房子一分为二,靠外面杂乱地堆满了石磨、竹匾等做臭豆腐的工具,只有一床窄小破旧的铺盖挤在墙角,这是父亲住的地方。布帘另一侧桌椅床铺虽然简单,却还干净整洁,那就是刘俊卿的书房了。刘俊卿气愤地在床头坐下,点亮油灯,看起书来。\n忽然门外轻响,秀秀走了进来,她见刘俊卿在那里读书,也不惊动他,只在布帘外悄悄拉了父亲一把,掏出一个布帕递给父亲,小声说:“爸,这是我的工钱。”一时又看布帘里的刘俊卿一眼,说:“那个法政学堂那么贵,一年学费好几十块,我们上哪弄得到这么多钱?”\n刘三爹无奈地说:“我想,实在不行,我明天再去求一求三堂会……”秀秀急了,打断父亲的话说:“爸,那种钱借不得,利滚利,要人命的!”\n“我怎么这么没用?我怎么这么没用?就这么一个儿,我都供不起他读书……”刘三爹抬手猛捶着自己的脑袋,哭着说。\n刘俊卿在屋里坐不下去了,他掀开布帘子走出来,紧紧地抱住已是老泪纵横的父亲,叫道:“爸,你别这样,大不了……大不了我不读了。”\n“怎么能不读呢?你这么会读书,你要读了才有出息,你要当法官的,不读怎么行呢……”刘三爹一把捂住了脸,“都怪我这个当爹的没用,害了我的儿啊……” 刘俊卿兄妹相互看了一眼,不说话,秀秀眼泪止不住地流了下来,半晌掏出了那张报纸,递给刘俊卿说:“哥,这是我找我们少爷要来的,可能,你有用。”\n五 # 湘江对岸的岳麓山。山下溁湾镇刘家台子的一个小巷子里,用竹篱笆围成一个小院落,院内一间阴暗的小房子里,桌上、地上堆满了火柴盒子和糨糊,斜阳照进来,一个妇人和一个十多岁的小女孩正低头在那里糊着火柴盒。\n这个妇人梳着一个大髻,乌黑的头发总挽在脑后,穿一件深蓝色衣衫,虽已极是破旧,但破口处都用花饰掩盖,整洁异常。她面容清瘦,眉角间满是风霜之色,然而举止从容娴静。\n“第……八十五页。” 妇人一边报数字,一边手不停地忙碌着。\n小女孩手边赫然是一本翻旧了的《西哲诗选》,她看了一眼标题,盖住书,拿起刷子,一面在火柴盒上刷糨糊,口里背诵:“‘假如生活欺骗了你,不要愤慨,也不要忧郁。’”她背了这句,停下来,看着那妇人。\n“不顺心时暂且克制自己,相信吧,快乐的日子就要来临。” 妇人立时续道,然后看着女孩。女孩也续着,“现实总是令人悲哀,我们的心却憧憬未来。”又停下来。妇人又接道,“一切都是暂时的,它将转瞬即逝。”\n两个人你一句我一句,从普希金到雪莱,从哥德到席勒,背个不停。这时一个少年走进了院子,正是蔡和森,他轻手轻脚掀开墙边的破草席,把一个擦鞋的工具箱藏进去盖好,换出自己的书包背在背上,然后擦了擦手上的黑渍,整理好衣服,这才推门进了屋,问:“妈,小妹,今天谁赢了?”\n“打平!”小女孩放下手里的刷子。她正是蔡和森的小妹蔡畅,那妇人是他的母亲葛健豪。蔡和森放下书包,坐在妹妹身边帮着糊火柴盒,低着头说:“不可能,你能跟妈打平?” 蔡畅得意地说:“今天我发挥得好,不信你问妈。”\n葛健豪看着儿子,问:“又这么晚才放学啊?” 蔡和森答应着,不动声色地避开了母亲的目光,对妹妹说:“来来,再比,我也来一个。小妹,你来翻书。”\n“书待会儿再背吧。” 葛健豪拍拍手,站起身,叫着儿子的小名,“彬彬,你来一下,我有话问你。”少年蔡和森犹豫了一下,立即微笑着站起来跟母亲出了房间。等儿子出来,葛健豪关严了房门,站到破草席旁问儿子:“这些天学校里还好吧?”\n蔡和森故作轻松地回答:“就那样。”“就那样是哪样啊?”葛健豪的语调平静。蔡和森说:“还不就是上课,也没什么可说的。”\n葛健豪的眼睛还看着儿子,一只手却掀开了草席,指着露出来的擦鞋箱:“就用这个上课吗?如果不是你们学校今天寄通知过来,妈到现在还被你瞒着呢。你自己看看,学校说你一直欠着学费没交,最近一段干脆连课也不去上了。彬彬,要学费为什么不跟妈说呢?”\n“咱家现在哪交得起这么多学费啊”!蔡和森低下了头,小声说,“小妹又要读中学了,我是想……”\n“不管怎么想,总不能不去读书!”葛健豪打断儿子的话,平静了一下,伸手按在儿子的肩上,很坚决地对儿子说: “彬彬,你是个好孩子,你心里想什么妈也知道,可不管怎么苦,不管怎么难,妈不能看着你们两兄妹失学。连妈都在读书,何况是你们?不怕穷了家业,只怕蠢了儿女啊,你懂不懂?”\n“可这个铁路学堂,我实在是读不下去了,一年学费这么多,我不能看着妈你白天晚上糊火柴盒子供我上学,再说也供不起啊!”蔡和森叹了口气。\n葛健豪眼眶不由红了,说:“妈明白,妈不是那种不切实际的人。学校太贵,咱们可以换,好学校也不是个个都贵的。关键是你得读下去。”\n蔡和森这时从口袋里掏出了那份叠好的报纸,打开递给母亲:“我想过了,妈,我想退学考一师。”\n第三章 论小学教育 # “ 民国教育,提倡的是平民化,\n一般平民看得懂的,倒正是这些大白话。\n如果我们还守着子曰诗云那些几千年的圣人经典,\n又何谈普及国民教育?再说,师范学校,\n本来招收的就主要是贫家子弟,以后他们要做的,\n也是最基础的小学教育\u0026hellip;\u0026hellip;”\n一 # “传、不、习、乎?” 一师的校长室里,碧眼黄发的美籍英语教师饶伯斯眯缝着眼睛,读着纸上的考题,操着一口颇流利的中文不解地问,“这是什么意思?”\n二十出头的历史教师兼庶务主任黎锦熙,一身笔挺的西装,留着当时少见的漂亮发型,用颇为流利的英语回答饶伯斯:“这是孔夫子的学生曾参的话,意思是说,作为教师应该经常反思,教授给学生的知识和道理,自己是不是经常体验、学习,是不是身体力行地掌握好了。”他说完这段话,看到饶伯斯呆呆地望着自己,一时不明白他是什么意思:没听明白?还是听明白了在思考?\n饶伯斯却把一直微微张着的嘴合拢,咂巴了两下,才问:“这么长的一句话,四个字就讲完了?”\n满屋子的中国教师都情不自禁地笑出了声。方维夏给他解释说:“中国的古文就是这样,字很少,意思却很深,一般人不容易理解。”\n一直坐在旁边没吭声的德籍音乐教师费尔廉,忽然问道:“既然不容易理解,为什么要出这样的考题?”\n大家一时都不知怎么回答他,全把目光投向了出这个题目的国文老师袁吉六。袁吉六吧嗒吧嗒地吸着水烟,慢条斯理地理了理烟楣子,这才说:“微言大义,自古考题都是如此,袁某这种老古董也变不来什么新花样,既然列位都觉得酸腐,合不上民国新教育的要求,那就照列位的意思来吧。”\n“既然仲老都这么说了。”孔昭绶其实等的就是这句话,正好接住话茬往下说,“大家有什么提议,就尽管说吧。”\n一阵沉默之后,黎锦熙看到孔昭绶正微笑着对自己点头,心领神会地轻轻咳嗽了一声,开口说:“我们不是培养小学教师的吗?以‘论小学教育’为题,既简单又明了,怎么样?”\n大家都还没表态,袁吉六先皱起了眉头:“论小学教育?这不成了大白话吗?”\n费尔廉直抒胸臆:“我觉得大白话好啊,意思很明白,容易懂,这个题目很好很好。”\n袁吉六白了这个老外一眼,“哼”了一声,说:“只怕上不了台面吧?”\n方维夏站起来说道: “我看倒也不见得,民国教育,提倡的是平民化,一般平民看得懂的,倒正是这些大白话。如果我们还守着子曰诗云那些几千年的圣人经典,又何谈普及国民教育?再说,师范学校,本来招收的就主要是贫家子弟,以后他们要做的,也是最基础的小学教育。论小学教育,这个题目应该不错。”\n看到其他几位老师也纷纷表示首肯,孔昭绶询问的目光投向袁吉六。袁吉六显然还是有些不以为然,他喷了一大口烟圈,说:“既然大家都觉得好,那——论就论吧。”\n孔昭绶听到袁吉六这样说,一颗悬着的心才彻底落了下来,总结说:“那这个题目就定下来了。依我看,还可以再放宽一步,只要以‘论小学教育’为中心议题,具体的作文题目可以由考生自行拟定,文体、篇幅一概不限。我们就是要让考生自由发挥!”\n二 # “……凡长沙本市及湖南中路各县考生,具高小毕业及同等学力者,均可报名……报名之次日,将入学考试作文送交本校教务室……录取结果将于五日后张榜公布……”\n当蔡和森从溁湾镇坐船过了湘江,赶到一师时,一师操场的公示栏前,已经密不透风地围了一大群年轻人,都伸长了脖子在看《招生报名须知》,有的还边看边断断续续地念着。蔡和森站在后面干着急,想挤挤不进去,踮起脚来也看不全公示栏上的内容,正没办法,看到前面站了个特别高的大个子,便拍了拍那人说:“这位老兄,老兄!”\n身穿半旧长衫的大个子回过头来问:“什么事?”\n“你能不能帮我看看考题是什么?”\n大个子看了看蔡和森,说:“‘论小学教育’,以此为内容,题目自拟,篇幅不限。哎,你也是来报名的?”\n蔡和森点点头,看着眼前密密麻麻的脑袋,叹息道:“没想到会有这么多人啊。”\n大个子朗声笑了:“就是。才招80个,来报名的倒有好几百!”\n蔡和森正想接着问,却见大个子伸手拍拍他前面的一个清瘦小伙子,说:“哎,萧菩萨,想不想对个对子?上联是——叫花子开粥厂。”那位“萧菩萨”才回过头,还没来得及答话,大个子却自行接了下去:“眼前就是绝妙的下联——穷师范招学生。”\n“萧菩萨”似乎和大个子很熟,习惯了他这样说话,很默契地问:“横批?”大个子一字一顿地说:“挤、破、脑、壳。”\n周围的人都大笑起来,蔡和森也被逗乐了,他不禁仔细地多看了这个乐天达观的大个子几眼。只有紧挨在前面的刘俊卿皱起了眉头:竞争者之多已经令他不安,偏偏还有人拿这个开玩笑……他移开了几步,躲开了这笑声。\n这时候,在不远处的操场大门前,一字排开的几张方案上,立着“报名处”的牌子,旁边还摆好了笔墨、报名表格等。黎锦熙站上台阶大声说:“请各位考生注意了,凡愿意报名者,到报名处来领取报名表,操场上摆了桌子供大家填写。填写后,交到这边来,换取考号。”\n蔡和森随着人流呼啦一下都围了过去,抢到一张表格,他左右张望着,想找个位子坐下来填写表格,却看到那位“萧菩萨”在和一个同学打招呼,“哎,易礼容?”易礼容看时,惊叫道:“子升兄?你这湘乡第一才子也来考?你看看你看看,你这一跑来,我们还有什么指望啊?干脆直接回家得了。”\n众人都回过头了,想看看这位名叫萧子升的湘乡第一才子长得是什么模样。蔡和森这时却瞅到了一个空位子,忙坐下提起毛笔填写。等他再去蘸墨的时候,发现身边坐的人也正好伸过笔来,顺着一双大手看上去,呵,这不正是刚才帮自己的那位大个子吗?大个子显然也认出了他,率先对他说:“你好!”\n蔡和森回应着,把面前的砚台给他推近了些。大个子说着“谢谢”,无意间,却正好看见蔡和森表格上填好的姓名,一下子惊叫起来:“蔡和森!你就是蔡和森?铁路学堂那个蔡和森?”\n蔡和森有些奇怪:“你怎么知道呀?”大个子依然大着嗓门说:“嗨,长沙的学生,哪个不晓得有个蔡和森,去年考铁路学堂,作文考了105分。满分不够,还另加5分,天下奇闻啊!原来就是你呀。哎,你不是在读铁路学堂吗?怎么又跑到这里来了?”\n蔡和森很坦率地回答:“那边!学费太贵,实在读不起,我已经退学了。”“哦!彼此彼此。穷师范招学生,还是咱们穷兄弟多。”大个子说道。\n二人一面填表,一面聊着。蔡和森问道:“对了,还没请教老兄贵姓啊?”“贵什么贵?”大个子把报名表递了过来,“我姓毛,毛泽东。”蔡和森的目光停留在表格的履历一栏上,那上面除了“工”一项外,农兵学商都打上了勾,他颇为惊奇:“嘿,毛兄干过那么多行当?农兵学商都全了!”\n毛泽东得意地说:“我呀,是家在农村种过地,老爹贩米帮过忙,出了私塾进学堂,辛亥革命又扛枪。五花八门,反正都试了一下。”\n“毛兄不过比我大一两岁,阅历却如此丰富,令人佩服。”蔡和森说道。“我们就不要你佩服我,我佩服你了。”毛泽东向蔡和森伸出手,爽快地说,“来,交个朋友。”\n两个人的手握在了一起。毛泽东说:“以后,你我可就是同学了。”蔡和森笑道:“还不知道考不考得上呢。”毛泽东手一挥:“怎么会考不上?肯定考得上!”\n“……李维汉,255号;周世钊,256号;邹彝鼎,257号;罗学瓒,258号……” 黎锦熙依次收着考生交来的报名表,一面读出考生姓名,一面往表上编定考号:“……萧子升,401号;刘俊卿,402号;这,这是怎么填的嘛?乱七八糟的,向——胜男,403号。”\n这个“向胜男”年龄也不小了,来考师范,想必应该是读过书的,但却连自己的名字都写得歪歪斜斜,像是才提笔写字的学童一样。不仅写字,走路的样子也很奇怪,像是跑堂的小二进了文庙,埋着头弯着腰,全身紧张。更可笑的是,他领了考号,竟像是做贼一样,飞快地跑了出去,看得所有人都目瞪口呆。排着长队的学生里有人起哄道:“哈哈,这样的人还想胜男?”\n这时又一张表格递了过来,收表格的同学抬起头一看,当即愣住了——面前是一个矮矮壮壮、留着粗粗的八字胡、戴着眼镜的中年人,那张脸上都已经有了皱纹,忙道:“这位老伯,对不起,学校规定要由考生本人报名,不能由家长代报。”\n中年人笑着说:“我就是考生啊。”这话把旁边的人都吓了一跳。中年人很温和地问:“年纪大是吗?可招生不是没限年龄吗?”\n“年龄是不限,可是……您真的来报名?”这个同学有些疑惑地念着表格,“何叔衡?哟,您还是位秀才啊?”\n黎锦熙听到何叔衡的名字,忙过来接过表格,看了看,猜疑地问道:“您不是宁乡的何琥璜先生吧?”“正是鄙人。”何叔衡笑说。\n“何先生,您好,我是一师的历史教师黎锦熙。您这是开什么玩笑?您可是长沙教育界的老前辈了,怎么能到我们这儿来报名呢?”\n何叔衡赶紧解释说:“我真的不是开玩笑。何某虽说已经37岁了,在宁乡办过几年学,教过几年书,可过去学的,都是些老掉牙的八股文章,穷乡僻壤,风气不开,如果不多学些新知识、新文化,再教下去,只怕就要误人子弟了。所以,我是真心实意来贵校报名,想从头学起,做个民国合格的老师。怎么,不会嫌我这个学生太老了吧?”\n“哪里的话?琥璜先生这么看得起一师,是我们一师的光荣。”黎锦熙对那个高年级的同学说,“陈章甫,来来来,大家都来,为何先生鼓鼓掌,欢迎何先生!”围观的报名考生都鼓起了掌,掌声顿时响成了一片。\n忙了一上午,黎锦熙才把报名表格汇总交到教务室,老师们顿时都围了上来,竞相关心着新生报名的情况。\n“连琥璜先生这等人物都来报名了?”袁吉六拿着何叔衡的那份报名表,笑逐颜开,“一师这回,真是人才济济啊!”\n黎锦熙清理着桌上厚厚的报名表格,说:“不光何先生,还有这个——蔡和森,去年考铁路学堂,作文考了105分,全长沙都出了名了!”他的手停在了下一份报名表上:“哎,这个也挺有意思,才19岁,务过农,经过商,做过学生,还当过兵,什么都干全了。”\n“哦,还有这种全才?我看看。”孔昭绶刚要接过那张毛泽东的报名表,同在清理表格的方维夏突然一拍桌子:“漂亮!太漂亮了!哎,你们来看你们来看。”\n几个人都围了上来,那是萧子升的报名表,表上的字简直是一幅书法作品。方维夏啧啧有声地夸着:“看看,看看,这是18岁的后生写出来的字!不是亲眼所见,谁敢信啊?”\n黎锦熙看得也呆了:“哇,这手字,咱们在座的只怕是没谁能写得出来哦。”袁吉六捏着胡子,左右端详:“嗯,飘逸灵秀,有几分大家神韵,了不起!”\n孔昭绶接过报名表,同样爱不释手,不住地颔首。他踱到窗前,望着碧空万里,校旗飘扬,他长长舒了一口气,似乎是在对几位同事说,又更像是在踌躇满志地自言自语:“咱们一师,有希望,大有希望啊。”\n突然他转过身问:“对了,杨昌济先生还没有消息过来吗?”\n众人都摇了摇头。\n三 # 那位在一师闹了笑话的“向胜男”,一路跑回了陶府,跑进了陶家小姐陶斯咏的房间。擦着冷汗把领取考号的过程给一直等在这里的两位小姐做了详细的汇报。斯咏递给他一块钱,并吩咐他不许泄漏一个字。“是,小姐。”他答应着欢喜地接了钱,关上门出去了。\n“向胜男先生,动手吧。” 等仆人一离开,斯咏就立刻兴奋地和警予一起开始合谋答卷了。向警予正要落笔,心里突然猛跳了一下,想:不知道那个擦皮鞋的家伙现在是不是也在答卷?\n蔡和森这个时候的确正在答卷。在他身边,葛健豪与蔡畅正静悄悄地糊着火柴盒。蔡和森写完最后一个字,从头到尾看了一遍,然后收起笔墨,对葛健豪说:“妈,您休息一下,我来吧。”\n而在萧家兄弟的租屋里,子升也正提笔凝神思考。萧三已经写了一小半,看到哥哥不慌不忙的样子,着急地催道:“哥,快写呀,我们还要去赶船呢!润之哥一会就要去刘三爹那里等我们了。”\n刘俊卿的文章却很快就写好了,想到父亲为了自己能上学受的苦,他心里酸酸的,放好了卷子就到父亲这里来帮忙。刘三爹看到儿子站在他面前,忙问:“俊卿,有事啊?”\n“没什么事。我,文章写完了。”刘俊卿说着,操起炸臭豆腐的长筷子。“哎呀,这哪是你做的事?”刘三爹吓得赶紧拦住儿子,“又是油又是火的,你快些站开,莫烫着了。”\n“那,我帮你擦擦桌子。”刘俊卿伸手去拿抹布。刘三爹赶紧又抢了过来:“不用不用,俊卿啊,你这双手是写字的,怎么能做这些粗活?莫做坏了手!你饿不饿啊?要不要吃碗臭豆腐?”\n“爸,我不饿。”“写了一下午文章,怎么会不饿呢?先吃一碗。”刘三爹装起一碗臭豆腐,放到了他面前,“吃啊,吃。”\n眼看什么也插不上手,刘俊卿只得坐在父亲摊子旁边,吃了起来。\n这时萧家兄弟提着行李来到摊前,萧三坐下看子升的文章,子升叫道:“老板,来碗臭豆腐。”读着文章的萧三忍不住挑起了大拇指:“哇,湘乡第一才子到底是湘乡第一才子!哥,我什么时候才能写出你这么好的文章?”\n听见这句话,刘俊卿抬起头来,往这边看了两眼。他认出了子升,下意识地侧了侧身子,背向二人。\n“行了,子暲,自家兄弟,还吹个什么劲?”子升全没有看见他。“哥,你是写得好嘛,就凭这篇文章,这回考一师,准是你的头名状元!”刘俊卿听到这话,脸色越来越难看,甚至微微扯着嘴角。\n“子暲,状不状元先别管,也不知爹怎么样了。我先去买船票,你看好行李,润之那边我已经约好了,让他帮我们代交文章。他一会儿就到这儿来碰头,你把文章给他,赶快到码头,六点的船,别耽误了。”子升站起来说道。\n“哥,知道了,都交代一百遍了,也不烦。”萧三答应着,看子升匆匆离去后,这时刘三爹端了臭豆腐过来,他随手将文章往摆在长凳上的包袱下一压,吃了起来。压在包袱下的文章的一角露在外面,随风轻轻抖动。刘俊卿一口一口,慢慢地嚼着臭豆腐,眼睛却始终盯着把被风吹动的文章。\n“子暲,子暲,萧三少爷!” 也不知道过了多久,正倚着桌子养神的萧三被一阵叫声惊醒了,他睁开惺忪的眼睛一看,正是毛泽东来了。\n“怎么,梦见周公了?”毛泽东问他,“让我代交的大作呢?”“写了半天文章,跟着就收拾行李,一口气都没喘,我刚眯了一下眼睛。”萧三解释着,转身去拿起凳上的包袱,顿时傻眼了——压在下面的文章已不翼而飞!\n“咦,我的文章呢?我明明放在这里的,怎么不见了?哎,这真是怪了,出鬼了?”两个人四下到处搜寻,哪里有文章的影子?\n毛泽东问他:“你不会记错吧?是不是放在别的地方了?”“我就放在这儿,肯定没错!”萧三着急地问刘三爹,“老板,你看到有谁动过我的东西吗?”\n刘三爹想了想,摇摇头,说:“哟,这我可没注意。怎么,丢东西了?”“两篇文章!我和我哥考第一师范的作文!没它就考不了!”\n“文章?那东西谁会拿呢?挺要紧的?可是,这儿也没来过别人啊。”刘三爹说着,目光下意识地向儿子刚才坐的地方看去:刘俊卿早已不见了,摊子上留了一只空碗。\n万般无奈,萧三只得听从了毛泽东的建议,把卷子的事情交给毛泽东来解决,然后赶紧去码头和哥哥会合。暮色初现的码头趸船上,看到萧三提着行李,气喘吁吁地跑来,子升已经急得不知道该怎么责备他了,只是催促着:“你怎么搞的?再晚来几分钟,船就开了。走走走,快点!\n上了船,子升站在踏板上,将箱子放上行李架,回头来接另一个包袱,萧三却抱着包袱走了神。\n“子暲!你怎么魂不守舍的?”子升从弟弟手里拿过包袱放好,在弟弟身边坐下来,问:“那两篇文章呢?我问你给润之没有?”\n“已经……已经给了……”萧三回答的时候,躲避着哥哥的目光。子升望着他的样子,皱起了眉头:“你今天这是怎么了?有什么事瞒着我?”\n“没有哇。”“你一说谎就不停地眨眼,我还看不出来?说,到底怎么回事?说呀!”\n萧三只得把丢卷子的经过一一告诉子升。伴着他的讲述,传来一声长鸣的汽笛,有人在喊“开船啰!”随即,船离开了岸边。\n“什么,文章丢了?”子升听了弟弟的话,腾地站了起来,爬上踏板就搬行李,但眼看着窗外已是江水一片,子升一屁股坐下,重重叹了口气:“你把我害死了!”\n四 # 转眼便到了张榜的日子,这一天一大早,一师的教务室里,气氛轻松。袁吉六是这次考试的总阅卷,录取依照科举考试的惯例,考生上榜的名次由后往前,分批公布。费尔廉饶有兴致地说:“这种方式也很好啊,能制造悬念,更加刺激。用中国的俗话来说,叫做——对了,叫‘卖、关、子’。”逗得大家都笑了。\n一师校门口的公告栏前,看榜的考生围得水泄不通,通红的考榜上是“招生录取名次”几个大字。校门对面的角落里,刘三爹心不在焉地用长筷拨拉着臭豆腐,眼睛却望着拥挤的考生。这时刘俊卿走了过来,他四下瞄了一眼,忙低声埋怨说:“你怎么把摊子摆到这里来了?”\n“我,我想来看看你考上没有。”刘三爹半晌才说道。刘俊卿撇撇嘴,说:“你又不认识字,来凑什么热闹?现在有什么好看的?都是些后面的名次。等出前三名的时候再说吧。”\n“俊卿,看到了赶紧来告诉我一声,记住啊。”刘三爹在他背后喊。“知道了。”刘俊卿头也没回,生怕被人看见。\n这时有人叫道:“出来了出来了。”只见两个老师手里拿张红纸直出了教务室,考生们呼啦一下都涌了上来。一张名单贴上了公示栏,这是第一批后四十名,考生们都寻找着自己的名字,罗学瓒高兴地拉着易礼容:“考上了,我们都考上了!太好了,走走走。”\n易礼容却站在原地,说:“走什么?再看看,看看谁能考第一嘛。”人群中,毛泽东一拍蔡和森:“蔡和森,你上榜了吗?”蔡和森摇摇头,反问道:“你呢?”\n“我也没有。嗨,急什么,后面还有嘛。”毛泽东很轻松地回答。他背后王子鹏也扶着眼镜,焦急地寻找自己的名字,名单上,却连一个姓王的也找不到。\n又一张红榜贴上了公示栏,这是第十一到第四十名的名单。子鹏摘下眼镜擦了擦,仔细搜寻着,他的名字还是没有出现。随后公示栏上,第十至第四名公布了:何叔衡榜上有名,刘俊卿排在第六,萧植蕃第五,第四名是“向胜男”。刘俊卿的脸一下子沉了下来——第六的名次显然大大出乎他的意料。他阴沉着脸,挤开人群就走。\n看看上面还没有名字,毛泽东问:“蔡和森,你不会着急了吧?”“就剩三个了,怎么会不急呢?” 蔡和森不觉有些担心了。\n“就凭你,左手都考进前三名去,你急什么?”毛泽东与蔡和森说着话,眼睛却看到萧氏兄弟挤进了人群,大叫道: “哎哟,萧大少,萧三少,你们回来了?”\n萧三擦着汗走过来,说:“今天看榜嘛。紧赶慢赶,行李都还没放呢。哎,我们上榜了吗?”毛泽东扬扬下巴:“你抬头看呀。”“第五名,萧植蕃!我上榜了,哎,我上榜了!”\n看到弟弟得意忘形,子升目光严厉地看了他一眼:“这值得你高兴吗?”萧三赶紧不做声,躲到一边去了。\n毛泽东却还在说着笑话:“萧菩萨,莫着急,我的名字也没看见。还有前三名,好戏在后头。”“我怕的就是你的好戏。”子升沉着脸回答。\n这边刘三爹看刘俊卿沉着脸一言不发走过来,忙赶紧叫他,问:“考上没有。”刘俊卿只当没有听见,直走过去,刘三爹愣在了那儿,自言自语:“没考上?不会吧?”\n而在一师大门对面的茶楼包厢里,向警予听了仆人的报告也腾地站了起来:“第四名?那前三名是谁?”她有些不敢相信地看看斯咏,斯咏显然也颇为意外,只听警予说道:“斯咏,走!去一师!我倒要看看,把我们俩比下去的,到底是何方神圣!”\n“哎呀,不行的!我表哥也在那儿看榜。”“你表哥就你表哥,有什么好怕的?”警予看看斯咏的神情,突然笑了,“哦,就是那个跟你订了娃娃亲的表哥是吧?好好好,陶大小姐脸皮薄,不去就不去。”\n斯咏拧了警予一把,对仆人说:“我们就在楼上等着,你去把前三名的名字记清楚,回来告诉我们。”\n一师布告栏的红榜上,前三名仍然空着。还没上榜的考生们都等得着急了,人人脸上已按捺不住紧张的表情。子鹏更是心虚,他当然不敢奢望自己能考进前三名。只有毛泽东全无紧张之色,“早晓得要等这么久,不记得带本书来看。”他不耐烦地来回走了几步,突然吸了吸鼻子,“哎,什么那么香啊?”\n子升也闻到了,却皱起了眉头,“是臭吧?”“香!臭豆腐香!”毛泽东吸着鼻子,踮起脚向人群外张望,远远看见了刘三爹的臭豆腐摊,顿时兴奋起来,“哎哎哎,那边在炸臭豆腐,你们饿不饿?”\n几个人望着他,简直不敢相信他这时候居然还有这种胃口。“老板。”他们来到臭豆腐摊,毛泽东一眼认出了刘三爹,“哎,是你老先生啊,今天摊子摆到这里来了?”\n“几位老主顾,一人来几块?”刘三爹点着头赔着笑。“一人来八块,炸老点,莫舍不得放辣椒啊!”毛泽东拉开一条板凳,蔡和森、萧子升、萧三分别坐下。\n“好吃!”毛泽东满头大汗,辣得直咂嘴巴,他夹起碗里最后一块臭豆腐塞进嘴里,“辣得过瘾啊!”\n萧三一边吃着一边看看毛泽东,又看看萧子升。子升面前的一碗臭豆腐却动也没动。\n“怎么,萧菩萨,还讲客气啊?”毛泽东有意没话找话说。子升闷声回答:“这东西有什么吃头?”\n“你这个人啊,天下第一美味的臭豆腐,你都不晓得品尝,你活着有什么意思啰?”他把那碗臭豆腐端到自己面前,大方地说:“你不吃我吃,免得浪费。”\n蔡和森看着毛泽东的样子,问:“毛兄倒真是豁达之人啊,你真一点都不担心?”“你说那边的考试啊?哎呀,是你的自然是你的,它又飞不掉。想还不是白想了。来来来,先吃。”毛泽东将钱递给刘三爹,“老板,付账。”\n刘三爹听着他们的对话,半晌才迟疑地问:“几位老板,你们也是来看榜的吗?”“对呀。”“能不能跟你们打听个事,有一个叫刘俊卿的,不晓得上了榜没有?”\n“刘俊卿?有啊,第六名。”萧三回答, “我第五,他第六,我记得清清楚楚,刘俊卿,肯定没错!”\n“考上了?考上了?哎呀,太好了,俊卿考上了!俊卿考上了!”刘三爹激动得把钱又塞回毛泽东手里,说,“今天的钱不收了,我请客,我请客!”\n“那怎么行,钱还是要收的。你请客,我出钱,好吧?”毛泽东觉得这个老爹很是投缘,他把钱又拍回桌上。\n“那……那就谢谢了。”刘三爹压抑不住兴奋,不住地自言自语:“太好了,俊卿考上了,这就放心了,放心了……”四个年轻人起身朝一师走去,毛泽东边走边说刘三爹: “考了个第六都高兴成那样,要是像你蔡和森考个第一,那不要飞上天了?”\n蔡和森反问说:“你怎么知道我考第一?”“除了你还有谁?总不会是我吧?”“怎么就不会是你呢?”“我那个文章自己还不晓得?糙得很!”\n众人伸长了颈,眼见着时间到了中午,前三名却仍不见出来。大多数学生都等得不耐烦了,陆续散去,却不知这时的教务室中,老师们正吵成了一团。\n黎锦熙皱着眉头读着两份试卷,弥封的卷子上头,标着第一名、第二名的字样。发现黎锦熙满脸的严肃,孔昭绶不由问道:“怎么,卷子有问题?”\n“袁先生署定的第一名这篇,文章的确很好,这我也不否认。但第二名这篇,论述气势磅礴,文笔纵横驰骋,观点新颖,颇有其独到之处。一个学生,能写出这样的文章,锦熙生平之所未见。我不明白,为什么它倒成了第二名?”\n袁吉六闻言,转身说:“黎先生读过多少文章,就能断言好坏?”“锦熙年轻,自然当不得袁老先生,但这两篇文章不仅我一个人看过,还有好几位先生与我的看法也一致,这又怎么说呢?” 黎锦熙却是寸步不让。\n袁吉六的目光从眼镜上方射出来,环视着众人,问:“未必都一致吧?”方维夏沉吟了一下:“仲老,恕我直言,以文章的气势而言,这篇文章我也觉得略胜第一名那篇。”\n“我看不惯的,正是它那个气势!上下五千年,纵横八万里,中国一直扯到外国,咿哩哇啦一顿扯过去,一副老子天下第一的口气,张扬过甚!它败就败在这一点上。哪像第一名这篇,娓娓道来,平稳含蓄,颇有古之大家之风。文重平实嘛,反正我喜欢这篇。” 袁吉六一顿手里的水烟壶,说道。\n看到气氛紧张,易培基忙打圆场说:“其实第一名也好,第二名也好,反正都是录取,就不必太计较吧?”袁吉六脸一板:“话不是这么说,好就是好,差就是差,既然排名次,当然要排得人家心服口服。”\n“没错,考卷以后是要公布的,我们评出来的结果,总要经得起大家的评价。”黎锦熙反火上浇油。\n袁吉六冷冷看了他一眼,反问:“你的意思是,袁某评的结果经不起悠悠之口?”\n黎锦熙毫不示弱:“不敢,晚辈只是平心而论而已。”两个人针锋相对,谁也不肯退一步,局面一时僵在了那儿。\n方维夏轻轻拉了一下孔昭绶,二人来到办公楼走廊上,站在窗户前,远远望着公示栏前仍然等待着的成群考生,方维夏显然着急了:“校长,这样拖着可不是个办法。得赶紧拿个主意才行,总不能让考生们一直这么等下去啊。”\n“我何尝不知道?可仲老和锦熙这回算是拗上了,仲老的脾气你不是不知道,锦熙呢,偏偏也是个爱较真的性子,不好办啊。”孔昭绶左右为难。\n“好歹您是校长,实在不行,就由您下个定论算了。”\n“那怎么行?文章好坏,又不是当校长就说了算,如果草率定论,总会有一方心里不服。”\n“要是现在能有个让他们都服气的人开句口就好了。”\n听到方维夏这样说,孔昭绶摇摇头:“仲老和锦熙何等人物,想开这两把硬锁,那得什么样的钥匙?”\n第四章 经世致用 # 何谓经世?\n致力于国家,\n致力于社会谓之经世;\n何谓致用?\n以我之所学,化我之所用谓之致用。\n一 # 杨宅的书桌上,那封聘书正静静地躺着。开慧把一杯茶轻轻放在了杨昌济手边,问:“爸爸,你真的不去孔叔叔那儿教书了?”正对着聘书提笔沉思的杨昌济放下笔,抚了抚开慧的头:“爸爸的事情实在太多了,除了周南那边的课,还想多留些时间,好好写两本书出来。”\n开慧想了想问:“可孔叔叔不是说,一个礼拜只要去上几节课吗?”杨昌济耐心地给女儿解释:“教书的事,你还不懂。台上一分钟,台下十年功,要上好一堂课,先得花十堂、二十堂课的精神备好课。爸爸总不能像那种照本宣科的懒先生,误人子弟不是?”\n开慧点点头,但又觉得不妥:“可是孔叔叔上次那样求你——”杨昌济看看乖巧的小女儿,笑了:“我为难的也就是这个。爸爸跟孔叔叔不是一般的交情,这个话确实是不大好说出口啊。算了,还是上一师去一趟,当面跟他赔个罪吧。”\n杨昌济还没有到一师大门口,便远远听到一片嘈杂声。看见有人在张贴红榜,这才明白是怎么回事,不由微微一笑,向办公楼走去。正见了一个校役,便烦他前去通报。校役匆匆来到了教务室外,将门推开一条缝,向里一瞄,只见里面坐满了老师,个个神色严肃,正在为什么事情忙着呢,赶紧把门掩上,回来看杨昌济坐在长廊的长椅上,不住地掏出怀表来看,回话说:“先生,实在对不起,孔校长现在真的忙着,还得麻烦您再等等。”\n杨昌济收起怀表,站起身来,掏出那份聘书,对校役说:“不好意思,麻烦你一件事好吗?今天我来,本来是为了退还孔校长这份聘书,既然他忙着,我就先不打搅了。麻烦你代我转交一下,告诉他恕我无法分身,不能从命,改日再登门向他谢罪。”说完,转身向外走去。\n校役答应着边走边打开了那份聘书:“杨……杨怀中先生?哎哟妈呀!”他照自己脸上就劈了一巴掌,忙不迭地快步跑到孔昭绶面前,把聘书递给他,结结巴巴地想说明白事情的来龙去脉。\n“你搞什么名堂?连杨先生的驾也敢挡?他人呢?”“刚刚走,这会儿只怕还没出大门呢……”孔昭绶当即一拍方维夏:“维夏,走,开锁的钥匙来了!”\n“昌济兄,昌济兄!”孔昭绶、方维夏从楼梯口匆匆追上正走出办公楼的杨昌济,声音大得几乎像是在喊了,“对不住对不住,不知道是你大驾光临,劳你久等了。走走走,先到教务室坐坐。”\n“坐就不必坐了。你不是正忙着吗?不用耽误时间陪我。要不,我就在这儿几句话讲完,还是为了上次的事……”孔昭绶打断了杨昌济的话,不由分说拉住他就往教务室走:“什么事我们回头再说,先跟我上楼去。我呀,正有一件事要请你帮个忙。走走走。”\n二人引着杨昌济走进教务室,孔昭绶一进门就说:“各位先生,跟大家介绍一下,这位就是板仓杨昌济先生。”\n“杨先生……板仓先生……”满座教师呼啦一下都站了起来,连向来倨傲的袁吉六都抢着迎上前来,问候道:“原来是大名鼎鼎的板仓先生,失敬了。”\n杨昌济拱着手回礼:“哪里哪里。”寒暄完毕,孔昭绶将两篇文章摆在了杨昌济面前: “孰优孰劣,请昌济兄法眼一辨。”\n杨昌济指着弥封上标的名次问:“可这不是已经定了名次吗?”不等孔昭绶解释,袁吉六先表了态:“原来那个不算数,初评而已,板仓先生不必放在心上,只管照您的看法来。”\n杨昌济有些疑惑了:“这到底是怎么回事啊?”孔昭绶笑说:“没怎么回事,就是请你看看文章,发表一下看法,真的没什么别的意思。”\n“那——我就先看看,要是有什么说得不对的,还请各位方家指正。”杨昌济拿起标着第一名的那篇看了起来:“《小学教育改良管窥》,标题倒也平实。”教务室里安静下来,所有的人都静静地等待着。\n刚刚看完开头,杨昌济已忍不住点头不止:“好!这个头开得好!”他接着往下看,越看越喜欢,不住地点着头:“嗯,精辟……好……不错不错,有见地……”\n袁吉六忍不住露出了微笑,颇有觅得了知音的得意。看完文章,杨昌济忍不住拍案叫绝:“写得太好了!昭绶兄,这是你的考生写的?”\n孔昭绶点点头。“哎呀,难得难得。文笔流畅,逻辑严密,于平实之中娓娓道来,虽然以全篇而言稍欠些起伏,但一个学生写得出这样的文章,已经是难能可贵了。昭绶兄,你这里有人才啊!”\n“先别着急夸奖,这儿还有一篇呢。” 孔昭绶又推过一篇文章来。 “哦,对对对。我都给忘了。”杨昌济拿起第二名的文章,《普胜法,毛奇谓当归功于小学教师,其故安在?》,不禁微微皱了皱眉:“这么长的标题?写小学教育还写到普鲁士打法兰西去了?倒也新鲜。”\n他接着看了下去,这一回却不像看上一篇,脸上原有的笑容渐渐凝结了起来,也没有了不住口的评价,反而越看越严肃,越看眉头皱得越紧。大家都专注地看着他,教务室里的气氛也不禁凝重起来。\n杨昌济很快看完了一遍,抬起头,仿佛要开口,大家正等着听他的评价,不料他沉吟了一下,却一言不发,又从头开始看起第二遍来,这回看得反而慢得多。\n凝重的气氛似乎都有些紧张了,教务室里安静得只剩了文章翻动发出的纸声。杨昌济终于缓缓地放下了文章。一片寂静中,孔昭绶试探着:“怎么样?”\n杨昌济说:“单以文笔而言,倒是粗糙了一些。”黎锦熙等不禁露出了失望之色,袁吉六则微笑起来。\n杨昌济接着说:“文章结构、论理之严密,尤其遣词用字这些细微之处,应该说是不及前一篇的。”\n孔昭绶点点头:“既然昌济兄也这么说,那……”\n杨昌济一抬手:“我还没有说完。单以这些作文的技巧来看,这篇文章确实略逊于前一篇,然则此文之中,越看越有一股压不住的勃勃生气,以小学教育之优劣,见战争之成败,国家之兴衰,纵横驰骋间豪气冲天,立意高远而胆识惊人。没错,胆识惊人,豪气冲天,就是这八个字!”\n激动中,他不禁站了起来,连声音都大了,“文采华章,固属难能,而气势与胆识,才是天纵奇才之征兆!此子笔下虽粗糙,胸中有丘壑,如璞中美玉,似待磨精钢,假以时日,当成非凡大器,非凡大器!”\n一片惊讶的肃静中,袁吉六缓缓站起身,走上前,提笔划去了两份卷子上已经标好的名次。他拆开前三名试卷的弥封,读出姓名:“第三名,萧子升;第二名,蔡和森;第一名,毛泽东。”\n这时孔昭绶也笑了起来,取出了那份聘书,“对了,昌济兄,今天找我什么事?不会真要把这封聘书退给我吧?” “恰好相反,”杨昌济转过身来,带着微笑说,“我是专程来告诉你,我接受你的聘请。”\n二 # 第三天便是开学的日子,灿烂的阳光里,一师大门口那幅“第一师范欢迎你”的崭新横幅分外耀眼。横幅下,入校的新生肩扛手提扁担挑,带着各色行李铺盖,布鞋、草鞋、长衫、短褂……汇集在一起,方维夏正带着陈章甫等一批老生在负责接待,偌大的前坪上,一片热闹。\n“蔡和森!”一只大手拍在蔡和森肩头,蔡和森一回头,毛泽东一手提着行李,正站在他身后,忙答应:“嘿,你好。”\n“哎,你分在哪个班?”毛泽东问。\n“本科第六班。你呢?”\n“我第八班。这么说我们不在一个班?搞什么名堂,我还想跟你同班呢。”毛泽东遗憾地说。\n“反正是一个年级,还不一样?”蔡和森嘴里这样说,心里却很感动,他没想到毛泽东会这么看重自己。\n正在这时,大门口传来了一个妇人不耐烦的叫声:“让开让开,怎么回事?还让不让人过路啊?”\n所有新生的目光都被这突兀的声音吸引了过去——大门前,王家的三乘轿子被人流挡住了去路,王夫人正掀开轿帘呵斥着挡路的新生们:“听见没有?都让开!你没看见他们挡着路啊?一群乡下土包子,连轿子都不知道让!”\n新生们人人侧目,但还是让开了一条路。可轿夫正要起步,方维夏走了过来,背着双手站在轿子前面,绷着脸说:“对不起,请下轿。”\n王夫人冲着他吼道:“下什么轿?我是来送我儿子读书的!”\n“本校规定,从这条线起,家长一律止步。”方维夏说着,指了指脚下齐着大门的一条白线,线后标着“家长止步”四个大字。\n王夫人摆足了阔太太架势,盛气凌人地冲着方维夏说:“我儿子来读书,我当妈的还不能进门了?你知道这是谁家的轿子吗?这可是王议员家……”\n“行了!”\n“妈,你嚷嚷什么?”\n王老板和王子鹏这时候已经从后面两顶轿子里下来,穿过拥挤的人群,来到了王夫人的轿子旁,异口同声地责怪着王太太。\n王夫人还想嚷嚷,看到丈夫的样子,又生生地把嘴边的话咽了回去。王老板黑着脸瞪了老婆一眼,又很快换了副笑容转向方维夏,说:“鄙人王万源,请教先生……”\n“本校学监主任,方维夏。”\n王老板拱手说道:“是方主任啊。犬子刚刚考上贵校,我们这是送儿子来报到的,还请行个方便。”\n“学生入校,一切自理,家长不得代劳,这是本校的规定。王先生,请将贵公子的所携用品交与他本人,学校自会安排他入住,你们父母就不必操心了。”\n王夫人看方维夏一点面子都不给,很是生气,嘟囔道:“那么多东西,他一个人怎么拿?”\n大家听她这样说,才注意到轿子后面堆积的东西简直都成了山。毛泽东一捅蔡和森:“我说,他们不是在搬家吧?”\n这话听着幽默,仔细一想却意味深长,学生们都大笑起来。王子鹏看了看周围的同学,不知道该说什么,红着脸回头看了一眼妈妈。他原本以为自己没有机会读一师:这次,一师只收80个学生,他偏偏考了个81名。可让人万万想不到的是,那个叫“向胜男”的第四名临时转学,他幸运地补缺被录取了。更让他想不到的是,当他去陶家报喜的时候,表妹斯咏居然眉开眼笑地恭喜他。这让他很开心,一直以来,他都不知道该怎样做才能让表妹满意。可到了准备来学校报名时,他的心情又烦躁起来了,因为妈妈对十来个人挤在一间破旧的宿舍里很不满意,接连几天都把家里的丫环、仆人使唤得团团转,说是收拾子鹏上学的行李,把箱笼、铺盖、各种日用品堆得到处都是,整得像是要大搬家似的。临了,还让秀秀一件一件地清查了好几遍,连一瓶雪花膏都不许漏掉。子鹏也觉得妈妈这样做很过分, 可他能怎么办呢?\n子鹏不知道怎么办,方维夏却知道,他果断地对王老板、王夫人说:“学生寝室,十人一间,你们带来的东西,两间房都装不下,就不必全带进去了,还是选些必要之物,其他的原样带回吧。”\n王老板和王夫人还在面面相觑,子鹏已经沉着脸,冲到行李堆前,乒乒乓乓地打开箱笼,王夫人和秀秀见了,赶紧上去帮忙。子鹏也不理睬她们,独自沉着脸,提着匆匆收拾起的箱子就往里走。王夫人捡起一瓶雪花膏,望着儿子的背影尖声叫道:“子鹏,子鹏,你的雪花膏!”看到儿子头也不回,她把雪花膏塞给抱了一满怀东西的秀秀,呵斥道:“还不跟着少爷!”\n雪花膏这样的东西,当时只有少数女人才用,很难得听说有男人用的。在同学们异样的眼光和笑声里,子鹏尴尬地埋着头冲进了学校。秀秀拿着雪花膏想跟去,却又被方维夏拦住了:“对不起,本校学生,毋需仆人侍候。”\n王夫人跟在后面问:“丫鬟都不能去?那谁给我儿子铺床啊?”\n不仅用雪花膏,还要丫环铺床!这次,连蔡和森都被逗笑了,更不用说毛泽东。校园里一时似乎变成了看杂耍的街头,哄笑声此起彼伏。\n子鹏终于忍不住了,停下来回头朝母亲吼了一句:“你够了没有?还不走!”说着,提着东西就想逃离这个让他很是尴尬的现场。可这人啊,越急越容易出事情,子鹏才一抬脚,“哗啦”一声,刚才仓促间没收拾好的箱子打开了,里面的东西撒了一地。秀秀赶紧上来帮他捡,子鹏恼火地一把扒开她的手:“你走开,我不要你动,我自己能行!你走啊!”\n王老板沉着脸扶住被儿子吓得直往后退的秀秀,对仆人们吼道:“都回去,听到没有,赶紧走!”\n众目睽睽下,子鹏涨红了脸,狠狠地收拾着满地的东西。众人嘲弄的目光压得他几乎抬不起头来,他觉得好孤单。但出乎他意料,一只手突然出现在他面前,捡起脸盆递给他。他一抬头,蔡和森正蹲在他身边,向他露出微笑。又一只手帮他捡起了东西,那是易永畦,紧接着是何叔衡、罗学瓒等,毛泽东却不屑地摇了摇头,对这种少爷他显然不愿意帮忙。他上去提起蔡和森的行李往背上一甩,扬长而去。\n三 # 刘三爹今天没有出去卖臭豆腐,因为他要送儿子去一师报名。刘家简陋的棚屋里,床头、地上,摆放着崭新的铺盖、脸盆等用品,刘俊卿的身上,更是一袭全新笔挺的长衫,与房里的寒酸破败形成了鲜明的对比。\n“到了学校,不比在家里。”刘三爹一面收拾箱子,一面唠叨叮嘱儿子,“你从小也没受过这个苦,这一去吧,我又照顾你不到,也不晓得你吃不吃得饱饭,穿不穿得暖衣,只能靠你自己凡事小心。”\n“知道了。”刘俊卿正对着镜子梳理着自己几乎是一丝不乱的头发。\n“你睡觉的时候,最听不得有人打呼噜,万一寝室里有那种打呼噜的人,你莫跟他讲客气,告诉校长,要他换寝室。还有,饭碗、脸盆这些东西,莫让其他人随便用,不干净。”\n他正想合上箱子,刘俊卿却皱起眉头从箱子里拎出一条旧短裤:“这么旧的还带?”\n刘三爹看看那条旧短裤比自己身上补丁摞补丁的衣服明显好得多了,想说反正是短裤,穿在里面别人又不知道。可看看刘俊卿的神情,这话却怎么都说不出口,赶紧将短裤拿了出来,还负疚似的不停地说:“不带,不带不带。”\n父子俩收拾停当,一前一后出了门:刘俊卿两手空空地走前面,刘三爹挑着满满一担行李,跟在后面。看看离学校已近,刘俊卿站住了,回头说:“爸,你送就到这儿吧。”\n“不是还没到吗?”\n“你把东西给我,我自己拿进去就行了。”\n“你哪会挑担子啊?”刘三爹挑着担子继续向前走去。\n“爸,爸!”刘俊卿追上去要拉父亲,看到旁边走过来两个拿着行李的新生,刘俊卿赶紧收住口,摆出一副泰然自若的样子,悄悄与他一身皱巴巴的父亲拉开了距离。\n刘三爹挑着行李,挤了到了校门口,迎头碰上了秀秀刚送走子鹏要回王家。父女俩正想说话,方维夏挡在了刘三爹前面,轻声说:“对不起,你不能进来。”\n“我是来送行李的。”刘三爹忙解释。\n“学校规定,行李一律由学生本人拿。”方维夏抬头,提高了声音,“这是谁的行李?”\n“这是……”\n“是我的。”本来与父亲拉开了距离的刘俊卿抢上前来,打断了父亲的话,伸手就来解行李。\n“哎呀,你哪里挑得担子?”刘三爹急了,抓着行李,对方维夏说,“这位先生,还是让我挑进去吧,他从来没挑过担子的。”\n“那就从今天开始挑!”方维夏的口气一下子变得严厉了。\n“可是……东西这么重,他会拿不动的。”\n“年轻人,这点东西怎么会拿不动?”方维夏看看刘俊卿,发觉他的穿着打扮与刘三爹实在不像一路人,又问,“这是你什么人啊?”\n“这是……”刘俊卿看看四周到处是人,憋了憋,居然说,“是……是……我雇的挑夫。”\n刘俊卿的这句话仿佛一记重锤,击得刘三爹全身一震,击得秀秀目瞪口呆!而刘俊卿似乎也被自己口中说出的话吓了一跳,慌乱中,他埋下头,伸手来解行李,却碰到了死死抓着绳子的父亲的手。儿子的手一伸过来,刘三爹就如触电般一抖,松开了手里的绳子。秀秀仿佛这才反应过来,她刚要开口,衣角也被父亲使劲地揪住了。\n刘三爹用力挤出一丝笑容,对方维夏说:“是,是挑夫,我是挑夫。”\n终于把俊卿的行李送进去了,秀秀和刘三爹一起出了一师,她可以陪父亲走到南门口,再分路回王家。秀秀一路上都不说话,只是不住地流泪。\n“你哭什么嘛?又没什么事。本来嘛,我这样子,多不像样,学校是个体面场合,你哥他也是没办法。” 刘三爹知道女儿在想什么,他劝慰着女儿,可劝慰来劝慰去,他越劝慰越觉得这种解释没有道理,叹了口气,在路边蹲了下来,自言自语似的说:“他不会是有心的,肯定不是有心的,只是一句话,不会是有心的。”\n秀秀站在父亲身后,看着父亲花白的头发,使劲擦了一把泪。\n四 # 对于任何一所学校来说,最热闹的时候,都莫过于新生入学那几天。面对浪潮般涌入的一张张满是渴望的、朝气勃勃的青春笑脸,有谁不会热血澎湃呢?看到他们,就等于是看到了一个无限广阔的美好未来呀!\n八班寝室里,新生们收拾着床铺及生活用品。王子鹏正试图把“第一师范”的领章钉上校服领子,却左弄右弄也钉不好。\n在子鹏对面的床上,刘俊卿正木然地扣着新校服的扣子。屋子里,就数毛泽东的动静最大,他收拾好床铺,捧起母亲那枚因为自己而被父亲砸瘪的顶针看了看,轻轻放到枕头下面,然后换上了新校服。他伸伸胳膊伸伸腿,好像总感觉校服小了一点。\n刘俊卿钉好扣子,穿上新校服,木然出了寝室,远远看见萧子升闷头坐在走廊栏杆上。他心里一紧:我不是把他的考卷给……为什么他还能考那么好的成绩?真是奇怪!正想着,只见萧三抱着两套新校服匆匆跑来,他装不认识,继续往前走了几步,躲到走廊的圆柱后面。身后传来萧家兄弟的对话:\n“哥,校服我领来了,你试试。”萧三说,萧子升却没有回答。“哥,来都来了,就别再东想西想了。那件事,都怪我和润之哥,不关你的事。”\n“怎么能说不关我的事呢?”“是我弄丢的文章,是润之哥要帮这个忙,你又不知道,哥,别坐在这儿了,回寝室吧。”随着一声轻轻的叹息,刘俊卿听到了萧家兄弟的脚步声,他从圆柱后面探出头来,望着萧氏兄弟离去的背影,他脸上的木然早已一扫而空,只剩了一脸阴沉沉的疑惑——“润之帮忙?”\n“送电了……送电了……”天黑了,随着校役摇动的铜铃声和喊声,一只手拉动电灯拉绳,室内电灯陡然亮起,照亮了全寝室的十个穿着崭新校服的青年。\n“各位各位,”周世钊拍了拍巴掌,示意安静,“从今天起,我们十个人就是同寝室的室友了,今天呢,也算是个室友见面会,借这个机会,大家互相认识一下,就从我这个寝室长开始,我姓周,周世钊,宁乡人。”\n同学们次第举手示意,介绍着自己:\n“罗学瓒,株洲人。”\n“易礼容,湘乡人。”\n“邹蕴真,湘乡人。”\n“易永畦,浏阳人。”\n“刘俊卿,长沙人。”\n“我叫王子鹏,也是长沙人。”\n“毛泽东,湘潭的。”\n周世钊笑说:“你就不用介绍了,状元嘛,谁不知道?”\n大家都笑了起来,只有刘俊卿冷着脸,望了毛泽东一眼。“那以后就这样排定了——润之兄就是我们寝室的老大,我老二。”周世钊一个个指点着,“老三王子鹏……”\n罗学瓒忙道:“不对不对,我比王子鹏大三天。”周世钊点头说:“哦,对,罗学瓒老三,王子鹏老四……”\n这时外面走廊上孔昭绶与方维夏并肩走来,听到笑声,孔昭绶不由得停住了脚步,走进门来,笑说:“嘿,好热闹啊。”学生们一时都站了起来问好。方维夏说道:“各位同学,给大家介绍一下,这位就是本校的孔昭绶校长,孔校长今天是专门来看望新同学的。”\n孔昭绶和蔼地摆摆手,示意大家都坐下,说:“大家不用客气,都坐吧。我和大家一起聊聊天,好不好?”\n“好的好的,我正有一个问题要问,那就是我们为什么要读师范?”毛泽东倒有些考这个校长的意思,要知道这个题目说大不大,说小不小,一般的老师大可以拿一番套话来敷衍。\n这时刘俊卿忙不迭地给孔昭绶与方维夏倒来了水。孔昭绶沉吟一时,说道:“诸位今日走入师范之门,习教育之法,今后也要致力于民国之国民教育。如果不解决读书的目的这个问题,则必学而不得其旨,思而不知其意。到头来,不明白自己五年的大好青春,一番工夫都下在了哪里。”\n孔昭绶喝了一口水,停了一停:“要回答这个问题,我想,要从我们第一师范的办学宗旨讲起。”他缓缓口气,“大家都知道,一师素称千年学府,自南宋理学大儒张栻张南轩先生在此地创办城南书院发祥,800余年间,虽天灾,虽战祸,虽朝代变迁,帝王更迭,而绵绵不息直垂于今日……”\n孔昭绶侃侃而谈,一双双脚步悄悄停在了门外,一个个经过的学生静静地站在了门口,“如孙鼎臣、何绍基,如曾国藩、李元度,如谭嗣同、黄兴,历代人才辈出而灿若星辰,成为湖湘学派生生不息之重要一支,为什么?我想,一句话可以概括:经世致用。\n“何谓经世?致力于国家,致力于社会谓之经世;何谓致用,以我之所学,化我之所用谓之致用。经世致用者,就是说我们不是为了读书而读书,我们读书的目的,我们求学的动力,是为了学得知识,以求改变我们的国家,改变我们的社会。那种关进书斋里,埋头故纸堆中做些于国于民无关痛痒的所谓之学问,不是我湖湘学派的特点,湖南人读书,向来只为了两个字:做事!做什么事呢?做于国于民有用之事!”\n毛泽东迫不及待地插嘴道:“那——什么事于国于民最有用呢?”孔昭绶看了他一眼,沉默一时说:“乱以尚武平天下,治以修文化人心。以今时今日论,我以为首要大事,当推教育。我中华百年积弱,正因为民智未开,只有大兴教育,才能以新知识、新文化扫除全民族的愚昧落后,教育人人,则人人得治,人人自治,则社会必良,社会改良,则人才必盛,真才既出,则国势必张……”\n孔昭绶又喝了口水说:“以此而推论,当今之中国,有什么事比教育还大?欲救国强种,有什么手段比教育还强?所以,读师范,学教育,他日学成,以我之所学,为民智之开启而效绵薄,为中华之振兴而尽一己之力,这,不正是诸位经世致用的最佳途径吗?”\n一片沉思的寂静中,孔昭绶的身后,突然响起了掌声。孔昭绶一回头,发现身后居然密不透风地挤满了学生。\n第五章 欲栽大木柱长天 # “ 自闭桃源称太古,欲栽大木柱长天。 ”\n无为官之念,无发财之想,\n悄然遁世,不问炎凉,愿与诸君之中,\n得一二良材,栽得参天之大木,\n为我百年积弱之中撑起一片自强自立的天空。\n一 # 在这样一个特殊的夜晚,一师的校园里却有一个人和这喧闹的气氛格格不入。他就是萧子升。在一师的草坪上,子升一人缓缓地踱着步子。微风轻袭,掠动着他整洁的长衫,却似乎吹不走他心头的烦闷。他仰起头,凝视着夜空中那纯净无瑕的月亮,深深地吸了一口气,像是终于下定了决心,毅然转身往八班寝室走去。\n此刻的八班寝室里,毛泽东、 周世钊、 罗学瓒、 易礼容、邹蕴真、 易永畦、刘俊卿、 王子鹏他们依然一个个情绪高昂,他们在带头为孔校长的演讲鼓掌之后,又七嘴八舌地恳请方维夏也给大家说点什么。\n方维夏看了看众人,一时盛情难却:“那我就说两句。孔校长刚才给大家讲了为什么读书的大道理,我不会讲什么道理,就跟诸位提个小小的要求吧:有书读时,莫闲了光阴。年轻人最怕没有定力,无书读时盼书读,有书读时,却总不免有一些耽于游玩而疏于用功的人,总觉得明日复明日,明日何其多。其实这世上最易逝的,便是光阴。岳武穆云:”莫等闲,白了少年头,空悲切。‘青春只有那么几年啊,过去了,是追不回来的。所以,我只希望各位在校期间,多读书,读好书,今后,回想起你在一师的生活时,你能毫无遗憾地对自己说,我这五年,真正用在了读书上,真正学了该学的东西,我没有虚度光阴。如果能做到这一点,那就是你这五年师范生活的成功,就是第一师范教育的成功!试问诸君可能做到?“\n同学们沉默着,似乎都在思考这个问题。刘俊卿却翻开新课本写一行字,放下毛笔率先打破沉默说:“校长和学监的教诲,俊卿与诸位同学一定牢记在心,决不辜负学校的期望。”\n孔昭绶拿过刘俊卿的课本,看见扉页上是他刚刚写下的“书山有路,学海无涯”八个字,字迹工整,颇见功力,含笑点头说:“嗯,字写得不错嘛。”\n刘俊卿一脸诚恳地望着校长回答:“这是校长和学监的教诲,俊卿自当视为座右铭。”孔昭绶赞许地看着他,正要开口,身后却传来一个声音:“请问,毛泽东在吗?”\n毛泽东一回头,原来是子升挤进了寝室,忙站起身说:“子升兄?哎,来来来,快来快来。”\n“润之,我有点事找你……”\n“你先进来再说。”毛泽东上前一把将他拉了过来,“跟你介绍,孔校长,方学监。”\n子升一直沉浸在自己的心事里,显然没有去想这外面围了那么多学生的原因。他被吓了一跳,立即恭敬地向二人问好。\n毛泽东说道:“这是我的老同学,萧子升,这回刚考进讲习科的。”孔昭绶上上下下地看着眼前这个文弱清秀的青年,赞叹道:“萧子升?哦——我记得你,第三名嘛。你还有个弟弟,一起考上的,叫萧植蕃,你第三,他第五,两兄弟一起名列前茅,不简单啊。”\n方维夏在旁边提醒道:“不光是考得好,校长,你还记不记得他那手字?”孔昭绶恍然大悟,脸上越发的惊喜了,笑道:“怎么会不记得?飘逸灵秀,有几分大家神韵嘛。这评语,还是在看你填的报名表的时候,袁仲谦老先生给你下的呢。当时黎锦熙先生也说,就凭你的字,我们全校的先生都找不出一个有那份功力的。”\n子升看看毛泽东,却心不在焉,“校长谬赞,子升愧不敢当。”毛泽东不管子升的脸色好看不好看,生怕人家不知道他的朋友有多厉害,继续做他的宣传工作:“校长,子升可不光字好,他还有个绝活,天下无双。”\n孔昭绶感兴趣地问:“哦,什么绝活?说说看。”子升拉了一把毛泽东,毛泽东大声说:“你扯我干什么?本来就是天下无双嘛。他呀,不光右手写得,左手也写得,两只手一起,他还写得。”\n孔昭绶有些不相信:“两只手一起?”毛泽东把子升推到桌子前面,边摆纸笔边说:“就是左手右手一边一支笔,同时写字,而且是写不一样的字,写出来就跟一只手写的一样。”\n这招功夫显然让大家都来了兴趣。孔昭绶说:“还有这种绝招?这倒是见所未见啊。”方维夏也说:“萧同学,就这个机会,给大家表演一个,让我们也开开眼界,好不好?”\n子升还想谦虚:“一点微末之技,岂敢贻笑大方。”孔昭绶鼓励他:“你那个字要还是微末之技,别人还用写字吗?来,表演一个,表演一个。”\n毛泽东拍拍他的肩膀,说:“你就莫端起个架子了,都等着你呢。”子升实在没办法,只得说:“那,我就献个丑。”\n“这就对了嘛。有本事就要拿出来。”毛泽东说着话,将两张桌子拼在了一起,铺开雪白的纸,并随手把刘俊卿那本刚题好字的书丢到旁边。\n刘俊卿看到毛泽东这个动作,脸沉了下来,子升显示出的吸引力已经令他感到了冷落,这时更是平添一股被忽视的难堪,他悄悄收起了那本书。\n子升提着笔,犹豫着:“写点什么呢?”孔昭绶想了想,说:“嗯——就以读书为题,写副对联吧。”\n子升点点头,略一思索,两支笔同时落在纸上,但见他左右开弓,笔走龙蛇,却是互不干扰,一副对联顷刻已一挥而就:“旧书常读出新意,俗见尽弃做雅人”,整副对联完美无缺,竟完全看不出左右手同时书写的迹象。\n“好,字好,意思更好!”孔昭绶向子升一竖大拇指:“萧子升,奇才啊!”毛泽东搂住了子升的肩膀,兴奋地打了他一拳。一片啧啧称奇之声中,刘俊卿阴沉着脸,狠狠合上了自己那本书,眼睛眯了起来。\n“电灯公司拉闸了……各室赶快关灯……油灯注意……小心火烛。”吊在铁钩架子上的油灯叮叮当当地撞击着,值夜的校役敲着竹梆,在校园里边走边喊。\n随即整个校园里的电灯一下子熄灭了,同学们纷纷回了各自寝室。孔昭绶卷着那副对联,意犹未尽地说:“萧同学,这幅字就当送我的见面礼了,回去我就把它挂到校长室去。”\n子升有些难为情,“信手涂鸦,岂敢登大雅之堂。”“我不光要挂起来,以后其他学校来了人,我还要逢人就说,这是我一师学生萧子升的手笔,也让别人好好羡慕羡慕我这个当校长的!”孔昭绶收起笑容,环顾着学生们,“但愿各位同学更加努力,人人都成为我一师的骄傲!”\n同学们齐声答应,一时就散了,子升看着毛泽东,叹息一声,朝六班萧三的寝室走去。只有刘俊卿在那里没动,他咬了咬嘴唇,忽然快步赶上两位先生,说:“校长,我,刚才看到萧子升同学的书法,实在是很佩服,很想多学习学习。可手边又没有他的字……”\n“嗯,见贤思齐,这是求上进嘛。是不是想要这幅字啊?”孔昭绶问。“这是校长喜欢的,学生怎么敢要?”“没关系,你想学,我可以先借给你。”\n刘俊卿猛地挺了挺腰杆,语调很快地说:“不不,这幅字就不必了,我是想萧子升不是也参加了入学考试吗?那是整篇文章,字数更多,既然出自他的手,想必也是书法精品,所以……”\n方维夏听了这话,眉心突然一跳,仿佛想起了什么,他轻轻一拉孔昭绶,打断了他的话:“刘同学,今天太晚了,你先回去休息,文章的事,以后再说吧。”\n“是,方老师。那,我先回去了。”刘俊卿如释重负地转身离去。孔昭绶疑惑地问道:“维夏,你这是怎么了?”方维夏没有答话,脸上的神情却很是严肃。\n回到教务室,方维夏点亮了油灯,一把拉开柜子,急匆匆地搬出厚厚的入学考试作文,放在桌上,把前两名的文章放在一起,拿出第三名萧子升的文章,对孔昭绶说:“校长,您把萧子升那幅字打开看看。”\n孔昭绶疑惑地摊开了那副对联。方维夏将子升的文章摆在对联旁,拨亮油灯。油灯下,文章的字迹与对联上的字分明完全两样。\n孔昭绶的眉头皱了起来:“这字不对呀!怎么会这样?”方维夏说:“其实上次在教务室看到这批考卷的时候,我就曾经有过一种不对劲的感觉,可又说不出是为什么,正好仲老和锦熙为了一二名的次序争起来,这一打搅,我也就未加深思。可是刚才,那个刘俊卿的话提醒了我,萧子升这篇入学考试作文,绝不可能出自他的笔下!”\n“不是他,那会是谁?”两个人的目光同时停在了旁边的一篇作文上——那是被方维夏放在旁边的头两名的作文,上面一篇正是毛泽东的。两篇字迹一模一样的作文被移到了一起,孔昭绶几乎不敢相信自己的眼睛,“毛泽东?”他脸色一变,转身就要下楼。\n方维夏提着油灯跟在他后面:“校长,今天,是不是太晚了?”“不管有多晚,这件事必须马上处理,不能过夜。”\n“可是,他们都是您赏识的人才……”“人才?有德才有才!若是有才无德,将来只会成为更大的祸害!连基本的诚实都没有,代考舞弊这种事也敢做,不处理还了得?走!”\n子升回到寝室,萧三已经上床了,他迷迷糊糊地被哥哥拉到草坪上,一听哥哥唧唧歪歪地说起卷子的事情,火了:“哥,代考的事,怎么能怪润之呢?”他的声音不小,把正从走廊那头急急忙忙走过来的孔昭绶和方维夏吓了一跳。拐角处,孔昭绶收住了脚步,抬手示意方维夏放轻脚步。\n“文章我们都写了,它不是突然丢了吗?润之哥是怕我们耽误了船,才帮我们代写的。人家是一番好心,要怪,就怪我不该丢了文章。”\n“你说的这些我都知道。可这毕竟是作弊,用这样的手段考进学校,岂是君子之所为?”“哥,道理我都知道,我也后悔,可事情已经这样了,还能怎么办呢?”\n孔昭绶与方维夏贴墙而立,方维夏悄悄调小了油灯的光芒。子升的声音清晰地传来:“其实这两天,我一直在犹豫,想把这件事跟学校坦白出来。刚才我甚至都到了润之的寝室,想告诉他这个想法,可是……当时的情形你也知道了,校长、学监都在,润之呢,情绪又那么高,我是实在说不出口啊!再怎么说,润之也是为了我们兄弟好,虽然事情做错了,可要因此害得他读不成书,我总觉得……”\n“哥,其实要我说呢,凭你的文章,又不是真的考不起。真要考,第一名还未见得是润之呢,你又何必这么想不开?”\n“这不是考不考得起的问题!我当然知道我们考得起。可做人不能暗藏欺心,不能光讲结果,不论手段,你明不明白?我已经想过了,这件事,只有一个办法解决。”\n“什么办法?”\n“退学。明天就退,我们一起退。学校,我们可以再考,但良心上的安宁丢了,你我这一辈子都不会安心的。子暲,君子坦荡荡,这是做人最基本的原则啊!”\n“那,润之哥那边……”“润之那边,明天我会去跟他解释,我想,他会明白的。”\n孔昭绶略一沉吟,转过身,示意方维夏跟他退后,悄声说:“去找毛泽东。”\n毛泽东和蔡和森这时候也还没有睡觉,两人在学生寝室外的走廊上头碰着头,借着烛光,正在读课本上一师的校歌歌词。\n“……人可铸,金可熔,丽泽绍高风……多材自昔夸熊封,男儿努力蔚为万夫雄!”毛泽东压着声音朗诵着,声音里却透着压不住的激动,“写得多好啊!我一读到这歌词,心里头就像烧起一团火一样!”\n“是啊,男儿努力蔚为万夫雄!”看来蔡和森也一样激动。\n“哎,不瞒你说,其实一开始,我根本没打算考一师。”毛泽东说,“我那个时候,一门心思就想考北大,哪想过什么第一师范啊?可我们家不给我钱,人穷志就短,这里又不要钱,没办法,只好考到这儿来了。”\n“那现在还后悔吗?”蔡和森问。毛泽东笑道:“后悔?后悔没早考进来!今天我才知道,我们的先生是什么样的先生,我们的学校是什么样的学校!一句话,来这里,来对了,来得太对了!对我的胃口!”\n毛泽东的声音越来越大,却不知道孔昭绶与方维夏正静静地站在他们身后不远处。“我毛泽东没别的本事,就一条,认准了的事,我一条路走到黑,就在这里,就在这所第一师范,我死活要读出个名堂来!”\n蔡和森压低嗓门劝他:“润之,你声音小点。”孔昭绶一言未发,向方维夏摆了摆手,两个人顺着来路悄悄退了回去。望着沐浴在月光下的一师主楼,孔昭绶长长舒了一口气,对方维夏说:“学校是干什么的?不就是教育人的吗?人孰能无过,无过岂不成了圣人?那还要我们教什么?他们都还是孩子嘛,不论犯过什么错,都是进校以前的事了,只要知错能改,诚心上进,我不信在我们一师,在你我手上,教不出堂堂正正的君子来。明天……给他们一个主动的机会,等到明天。”\n方维夏郑重地点了点头。\n二 # 第二天一大早校长的办公桌上,一方刻着“知耻”字样的镇纸压着两份退学申请。孔昭绶的目光,从萧子升移到萧三,由萧三再移到毛泽东的身上。三个人垂手站在办公桌前,紧张中,都带着不安。\n“毛泽东同学,”孔昭绶终于开口了,“你的两个朋友已经决定以退学来承担良心上的责任,并没有牵连到你,你为什么还要主动来承认代考的事?”\n毛泽东笔直地站着,说:“因为代考是我主动提出来的,文章也是我写的,我的错,我承担。校长,无论您怎么惩罚我,我都接受,可他们真的没有主动舞弊,请您给他们一个机会吧……”\n“不!”子升打断了他,“这不关你的事!校长,事情由我们而起,是我们没有经过考试进了学校,责任应该由我们负……”\n“好了,你们也不要争了。责任由谁来负,该由我这个校长来决定吧?”孔昭绶说着,把两张雪白的纸和笔墨文具摆在了萧氏兄弟面前。\n子升一时没明白:“校长……”\n“怎么,刚刚考过的试,就不记得考题了?行,我提醒你们一句:论小学教育,标题自拟,篇幅不限——隔壁办公室已经给你们准备好了,由方主任给你们监考,补考时限两小时,够不够?”\n子升、萧三与毛泽东面面相觑,愣了一阵,这才反应过来,子升:“够!用不着两个小时,20分钟就够。”\n孔昭绶皱皱眉头:“20分钟?”“我自己写过的文章,每一个字我都记得。”子升喜出望外。\n孔昭绶问萧三:“你呢?”“我也一样,没问题!”萧三欢喜得只差没跳起来了。孔昭绶点头说:“好,那就20分钟。”拿起纸笔文具,子升与萧三激动得同时向孔昭绶深深一鞠躬:“谢谢校长!”\n两个人匆匆出门,子升又站住了:“校长,那润之……您能原谅他吗?”\n“这不是你现在该关心的问题。”看看面无表情的孔昭绶,再看看正用目光催他快走的毛泽东,子升也明白自己多说无益,只得退出了房间。\n“校长,谢谢您原谅他们。”毛泽东也向孔昭绶鞠了一躬。“可我没说过要原谅你哦。”\n“我明白。”正视着孔昭绶的眼睛,毛泽东目光坦然,“这件事的错误本源在我,一切责任本该归我承担。”\n“那,说说你错在哪儿?”“我……我不该随便帮朋友。”\n孔昭绶摇了摇头:“错。友道以义字为先,你帮朋友,我并不怪你。但君子立身,以诚信为本,义气是小道,诚信为大节。你的行为,耽于小义而乱大节,是谓本末倒置,本末倒置,则既伤己身,又害朋友。这才是你的错误之所在。”\n毛泽东沉默半晌,默然点头说:“我明白了,校长。”“真的明白了?” 孔昭绶肃然看着他。\n“真的明白了。校长,请您现在就处罚我吧。” 毛泽东低着头说。孔昭绶点了点头,“你能主动走进这间办公室,坦白自己的错误,我相信你是有诚意的。当然,绝不等于我不处罚你。”他拉开抽屉——抽屉里,是一面折得整整齐齐的旗帜。旗帜展开了,那是一面深蓝为底,正中印着庄重的“师”字白徽的一师校旗。\n“这是我一师的校旗,也是我一师光荣的象征,它有蓝色的坚强沉稳,更有白色的纯洁无瑕。它的洁净,不容沾上一点尘埃,它的诚实、理想、信念、光荣,更不容任何玷污!”孔昭绶将校旗递到了毛泽东面前:“把它接过去。”\n待毛泽东接过了校旗,孔昭绶又说:“一会儿就要开新生入学典礼了,我希望由你在典礼上升起这面校旗,我也希望从今以后,每当你看到这面校旗,都能想起今天犯过的错,都能告诉自己:从今天开始,从现在开始,我毛泽东,将光明磊落,无愧于这面校旗!这就是我对你的处理,能接受吗?”\n捧着校旗,仿佛捧着巨大的重托和承诺,毛泽东用力地一点头。\n三 # “何谓修身?修养一己之道德情操,勉以躬行实践,谓之修身。古人云:修身齐家治国平天下。也就是说,修身是一个人,一个读书人,一个想成为堂堂君子之人成材的第一道门坎。己身之道德不修养,情操不陶冶,私欲不约束,你就做不了一个纯粹的人,一个高尚的人,一个精神完美的人,齐家治国平天下这些作为就更无从谈起。那么,什么是修身的第一要务呢?两个字:立志!”\n这一天上午,本科八班教室里,杨昌济正在给学生上第一节修身课。\n他在黑板上用力写下“立志”二字,转过身来继续讲:“孔子曰:三军可夺帅也,匹夫不可夺志也。人无志,则没有目标,没有目标,修身就成了无源之水。所以,凡修身,必先立志,志存高远而心自纯洁!”\n讲到这里,他沉吟一时,然后走下讲台,来到学生中间,说道:“下面,我想请在座的各位同学谈一谈你的志向是什么。”他看看身边课桌上贴着的学生姓名:“周世钊同学,就从你开始吧。”\n周世钊笔直地站起来,朗声答道:“我的理想,是当一个学校的校长。”杨昌济颇感兴趣地问:“哦,为什么?”\n“我小时候每天早上都看到学校的门口,所有的学生向校长敬礼。我想我长大了,也要像他一样,那么威严,那么受人尊敬。我考入师范,就是为了实现这个理想。”\n“很好。”杨昌济微微一笑,说:“下一位,罗学瓒同学。”“为国为民,舍生取义,做一个像戊戌君子中的谭嗣同那样的人。如国家有事,则奋不顾身,死而后已。”\n杨昌济点点头,说:“舍身成仁,高洁之至,很好。易永畦同学。”易永畦有些紧张地站起:“我……我不知道该怎么说……”\n杨昌济鼓励他说:“不要紧张。你从小到大,总有过这样那样的梦想吧?就算是天真得不切实际,或者平凡得不值一提,都不妨一说,姑且言之嘛。”\n“我……我想当三国里的关云长大将军。”易永畦话音才落,教室里就有不少同学小声笑了起来,易永畦那副单薄如纸的身材实在不能让人把他跟武圣人关云长联系起来。\n“嗯,纵横沙场,精忠为国。虽然是童真稚趣,却存英雄之气,好!下一个,刘俊卿同学。”\n刘俊卿显然早已准备好答案了,他站起来,很自负地回答:“学生的理想,就是要好好读书,将来做一个学识渊博、为世人所景仰、为政府所器重的社会精英,凭自己的学问和才能,傲立于天地之间。”\n“傲立于天地之间?因为学问而傲吗?”杨昌济问。“是,老师。只有学识出众之人,才能为人所敬重,学生就是要做这样的精英。”\n杨昌济似乎想说什么,想想又收住了口:“你坐下吧。”他看看桌上的姓名,认真打量了毛泽东一眼,问,“你的志向是什么?”\n毛泽东站了起来,犹豫了一下,茫然地回答:“我不知道。”“不知道?”在全班同学的窃窃私语中,杨昌济皱起眉头,问:“一个人对自己的未来怎么会没有一点想法呢?难道你从来就没有想过?”\n“我想过,经常想。可是,我找不到答案。”毛泽东望着老师,他的目光清澈如水,他的话显然出自真心。“嗯,路漫漫其修远兮,吾将上下而求索。毛君亦在求索之中么?”“求学即求索。”\n杨昌济若有所思地点点头,对毛泽东说:“你坐下吧。”“老师,”毛泽东刚坐下,却又突然像是想起了什么,站起来问:“能不能问您一个问题?您的志向是什么?”\n毛泽东的大胆实在有些出乎教室里所有人的意料。同学们不禁一愣,杨昌济也有些意外地回过身来。他望着毛泽东的眼睛,那双眼睛平静却隐隐地含着让人必须面对的刚毅。一片静默中,杨昌济走上讲台,拿起粉笔,刷刷地在黑板上写了两行苍劲有力的大字:\n自闭桃源称太古\n欲栽大木柱长天\n一片肃穆中,杨昌济用极为平和但却坚定的语调说:“昌济平生,无为官之念,无发财之想,悄然遁世,不问炎凉,愿于诸君之中,得一二良材,栽得参天之大木,为我百年积弱之中华撑起一片自强自立的天空,则吾愿足矣。”\n一片寂静之中,周世钊、刘俊卿带头鼓起掌来,掌声立即响成了一片。只有毛泽东仍站在那里,望着老师,没有鼓掌。杨昌济挥手止住掌声:“毛泽东同学,今天你没有回答我的问题,我也不要求你马上回答,但有一件事我希望你能答应我。五年后,当你迈出一师校门时,我想听到你回答我,你的志向是什么。能答应我吗?”\n毛泽东还在揣度着老师写在黑板上的“志向”,想着能说出眼前这十四个字的人会是一个什么样的人、什么样的老师,想着什么是他眼里的桃源、太古、大木、长天?时至今日,他辗转上过好几所学校,见过数十位老师,却没有谁说过如此让他深思的话。毛泽东看着老师正凝望着自己的眼睛,郑重地点了点头,说:“我答应您,老师。”\n四 # 下午,杨家小院内里,杨开慧正在送爸爸出门去周南。她一边翻看着《普胜法,毛奇谓当归功于小学教师,其故安在?》,一边问爸爸:“他真的就什么也没说?文章写得这么好,怎么会没有理想呢?这个学生真怪啊。”\n“是的,他什么也没说。”杨昌济指着小院里花台上洒水的“壶”,风趣地解释道,“当然他没说并不意味着他没有,而是不肯轻言——有时候,鸿鹄,也要岁月磨炼方成的。”\n“爸,你怎么知道他就有鸿鹄之志?说不定是燕雀之志呢?”开慧还没见过爸爸这样评价一个学生,和爸爸开起了玩笑。\n“不会的。”杨昌济肯定地回答女儿。\n“为什么,就因为文章写得好吗?”\n杨昌济已经出了院门,听到女儿这样问,回过身来意味深长地说:“不光是文章。还有那双眼睛,明亮、有神——坚定!那不是一般年轻人能有的目光。由目可视其心,那样的目光,必定心存高远。”\n开慧对爸爸的话似懂非懂,但对爸爸的心思却是完全明白的。她把拿着文章的手背到身后,站在爸爸面前,注视着他的脸,调皮地问:“爸,你什么时候变成看相先生了?”\n“爸爸可不会看相,”杨昌济微微一笑,表情反倒严肃了,“爸爸看的,是那股精气神。”\n杨昌济来到周南女中,一片绿树苍翠之中是一副“周礼盍在,南化流行”的对联。他进到教室,一节课上完,说道:“今天给大家下发两篇范文,是第一师范本次入学考试中头两名的文章,也是我很欣赏的两篇文章。当然,作文之人年识尚浅,文章自非十全十美,但第一名这篇的气势和胆识,与第二名这篇的平实稳重,确有值得效仿之处。且文章为各位同学的同龄人所作,更有其借鉴意义。今天发给大家,希望大家课后细细品味,找一找自己的作文与这两篇文章之间的差距。”\n油印的文章在学生们手中依次向后传递着,学生们认真看着,相互悄声交流着。斯咏与警予同时捧起了文章,入神地看着。过了一会,放下了那篇蔡和森的文章,警予把手一摊,吐着舌头,眼睛瞪着天花板,说:“这么好的文章,让人还怎么活嘛?”\n“哟,今天太阳从西边出来了,我们向女侠居然也有服人的一天?”斯咏看看左右,悄声打趣警予。\n“人家是比我们强,比我们强我们当然得服。”警予一副梁山好汉的样子。\n斯咏拿着文章翻来覆去地看着,问警予:“哎,你觉得这两篇里头,哪篇更好?”“当然是这篇,蔡和森的。”警予毫不犹豫地说。\n“怎么会是这篇呢?你看看,从头到尾,唧唧歪歪,除了板着个脸讲道理,还是板着个脸讲道理,文似看山不喜平嘛,一篇文章作得这么四平八稳软绵绵的,有什么意思?”\n“这叫平中见奇,什么软绵绵的?”\n“反正啊,我还是喜欢这篇,多有气势。”斯咏坚持着自己的观点。\n“毛泽东这篇啊?去,你自己看看,从头到尾,咿里哇啦,除了扯着个嗓子大喊大叫,还是扯着个嗓子大喊大叫,文章就是要平实稳重嘛,有必要搞得这么气势汹汹的吗?”\n“你平时不就气势汹汹的,怎么,倒看不上气势汹汹的文章了?”斯咏看看警予,突然觉得她今天变得有些怪怪的。\n“谁平时气势汹汹的了?我对你凶过吗?算了算了,不跟你争。”警予转头问旁边的一个秀秀气气的女生,“一贞,你说说,这两篇文章哪篇好?”\n“都好。”赵一贞一笑两个酒窝,甜甜的。“我是说哪篇更好?”警予才不给她和稀泥的机会。\n“反正……都好嘛!”\n“什么都是好好好,你啊,整个一个好好小姐!”警予不和她说,又转头朝着斯咏,见她正爱不释手地读着毛泽东的文章,便故意拿起蔡和森那篇在斯咏面前晃着说:“我要把这篇文章贴在寝室的床头,每天看三遍!”见斯咏不理睬自己,她又盯着蔡和森的文章,凶巴巴地悄声说:“姓蔡的,你等着,总有一天,我要超过你!”\n"},{"id":125,"href":"/zh/docs/culture/%E6%81%B0%E5%90%8C%E5%AD%A6%E5%B0%91%E5%B9%B4/%E7%AC%AC11%E7%AB%A0-%E7%AC%AC15%E7%AB%A0/","title":"第11章-第15章","section":"恰同学少年","content":" 第十一章 过年 # 到了新年,毛家院子里,毛贻昌一身半旧的长袍马褂,正在端正自己的瓜皮小帽;泽建一身新花衣,扎着红头绳,蹦过来跳过去;毛泽东站在凳子上,正在泽覃泽建的指挥下贴着自己刚刚写好的对联。\n一 # 放假了,过年了,刘俊卿的心情特别好。虽然他只考了第三名,但在放假的前一天,纪督学特地把他叫到了督学办公室,拉着他的手说:“俊卿,老师心里闷,闷得很!老师难啊,大好的一所学校,怎么就搞成了这个样子?这是怎么回事嘛?这所学校,老师是彻底死心了!老师现在就剩了一个念头——你,可不要上那些乌七八糟的什么新教育观念的当,一定要踏踏实实,好好读书,考出好分数,给老师争口气。只要你好好学出个样子来,到时候,你的前程,包在老师身上!”\n“你的前程,包在老师身上!”这话像天上的福音一样,让刘俊卿振奋,他从这句话里似乎已经看到了自己辉煌的前程。迫不及待地,他想让心爱的人来分享自己的好心情。\n在离茶叶店不远的小街拐角处,刘俊卿与赵一贞依偎在淡淡的月光下说着知心话: “其实一二三名不都差不多,你何必对自己要求那么高呢?”\n“可我答应过你,我要考第一的。”\n“不管你考第几,我都不在乎。”\n“可我在乎。”刘俊卿叹了口气,“你知道吗?师范生就一条出路,当小学老师,小学老师啊!除非我有出类拔萃的成绩,否则,我就改变不了这个命运。”\n“可小学老师也不错呀。”\n刘俊卿不禁苦笑,“一辈子站讲台,吃粉笔灰,拿一点紧巴巴的薪水,跟一帮拖鼻涕的娃娃打交道,这就算不错吗?就算我能受得了,可我总不能让你跟着我这样过一辈子啊!”\n一贞捧住刘俊卿的脸,摇摇头:“我不在乎,俊卿,我真的不在乎,不管有没有人成绩比你好,不管你是不是教一辈子书,在我心里,你永远是最优秀的,永远。”\n端详着一贞清纯的脸,刘俊卿禁不住轻轻吻在她的面颊上:“一贞……”一贞将头埋进了他怀中。\n“我不会辜负你的!”仰望着月光,刘俊卿喃喃自语,仿佛是在向一贞立誓,又仿佛是在说给自己听。突然,一贞惊得弹了起来:“爸?”刘俊卿猛一回头——赵老板面如严霜,正站在拐角处!\n自那天赵老板把一贞拉走后,刘俊卿便再没有见过一贞了。他虽然无时无刻不在想着一贞,但却没有胆子去赵家的茶叶铺。转眼就到年三十了,简陋的棚屋门口,刘俊卿一身崭新的长衫,正拿着一副春联,在往土坯墙上比着贴的位置——春联上是他工整的字体。\n“俊卿,你饿不饿?要不,我先给你做点吃的。”刘三爹心疼地招呼儿子。\n刘俊卿懂事地说:“不用了,还是等阿秀回来,一起团年吧。”\n“也好。过年嘛,他王家准又得赏几样好菜,留着肚子,等你妹妹回来再吃也好。”\n就在这时,随着一阵急促的脚步声,传来一贞的声音:“俊卿。”\n“一贞?”刘俊卿大吃一惊:出现在他面前的,真的是跑得气喘吁吁的赵一贞,“你怎么来了?”\n带着喜悦,更带着几分羞涩,一贞使劲平静着过于激烈的呼吸:“我……我爸他说……请你上我们家去吃团年饭!”\n“你说什么?”刘俊卿简直不敢相信自己的耳朵。停了两秒钟,他这才反应过来:“这是真的?”\n忍着激动与呼吸,一贞用力点了点头。巨大的惊喜令刘俊卿张大了嘴,愣了一阵,喜极的笑容才绽放在他的脸上:“哎,我去,我……我换双鞋就去!”\n年夜饭吃过,一贞正在收拾着残羹冷炙。世故的赵老板剔着牙,点着了一支烟,吐出一口烟雾,这才盯着局促地坐在他面前,带着几分希望,忐忑不安地盯着自己的皮鞋尖的刘俊卿,和蔼地说:“吃好了吧?”\n刘俊卿赶紧点头。赵老板看了捧着碗筷还站一边的一贞一眼,一贞只得端着碗筷进了里屋。赵老板这才微笑着对刘俊卿:“吃好了,那我也不留你了,你走吧。”\n这话说得刘俊卿有点摸着不头脑。赵老板的下一句话却仿佛给了他当头一棒:“走了以后,就不要再来了。”刘俊卿不禁目瞪口呆!\n“怎么,听不明白?我是说今天踏出这个门,以后你就不用再来了,更不要再找一贞。”赵老板的口气冷酷,不容置疑。布帘里,端着碗筷、偷听着外面谈话的一贞顿时呆住了。\n“赵叔叔,可这……这是为什么?”刘俊卿还想问个明白。“为什么就不用再说了。总之一句话,今天我请你这顿年夜饭,就算是给你和一贞之间做个了断,只要以后你不再跟一贞来往,以前的事,我当没发生过。”\n“赵叔叔,我……我对一贞是真心的……我真的是真心的……”\n“怎么,你非要我点那么明?你当我是才知道你们的事?行,那我们就摊开来谈:刘俊卿,你一个父亲,一个妹妹,父亲摆小摊卖臭豆腐,妹妹典给人家当丫环,你读个不收钱的一师范,家里还欠了一屁股债——还用我说下去吗?”\n刘俊卿的脸色顿时一片惨白。布帘后,一贞同样面如死灰——这个突然的打击显然完全出乎她的预想。\n“我为什么送一贞去周南读书?因为那是长沙最好的女校,全长沙有身份的少爷娶的都是那儿的女学生!我赵家是小户人家,可小户人家也有个小户人家的盼头,我就一个女儿,我不想让她再过我这种紧巴巴的穷日子!我省吃俭用,我供她读书,就是要让她嫁个好人家!而不是你这种人!”\n一贞冲了出来:“爸!”赵老板腾地站起,指着女儿骂道:“滚回去!还嫌给我丢脸丢得不够啊?”\n一贞呆住了。瞟了一眼刘俊卿,赵老板站起身来,扔掉烟头,一脚踩灭:“要娶一贞,你还不够格。你走吧。以后不要来了。”\n仿佛自己的身体有千斤重,刘俊卿颤抖着腿,终于站了起来,咬了咬嘴唇,向门外走去。一贞叫了声“俊卿!”抬腿要追,赵老板一个耳光打得她一歪:“你敢!”\n捂着脸,一贞的眼泪滚了下来……\n二 # 在与长沙隔江相望的溁湾镇,蔡家母子三人也在温馨地准备着他们自己的新年。\n葛健豪对着镜子,披上一件老式大红女装——那是一件宽袍大袖,刺绣精致、衣料华美的旗式女装。她打开一只颇为精致但已陈旧的首饰盒,取出里面几件银首饰,往头上戴着。她的身后,蔡和森正举着一张通红的老虎剪纸窗花,在油灯前比划着问妹妹蔡畅像不像,他旁边的旧木桌子上,散乱着红纸和碎纸屑,摆着几张剪好的“春”、“福”字。\n“咦——不像不像,等我这个剪出来,你才知道什么叫过虎年!”蔡畅一面剪着自己手里的窗花,一面说,“想起以前在乡下,那些窗花才叫好看呢。一到过年,家里前前后后,那么大的院子,那么多间房子,门啊、窗户啊,到处都贴满了,我都看不过来。”\n蔡和森笑话妹妹:“那时候,你只记得缠着要压岁钱,还记得看窗花?”\n“谁只记得要压岁钱了?”\n“还不承认。那一年——就是爸从上海给你带了个那么大的洋娃娃的那一年,过年那天晚上,你跟族里头一帮孩子躲猫猫,藏到后花园花匠的屋里头,结果你一个人在那儿睡着了,吃年夜饭都找不到你。”\n“那是你们把我忘了。”\n“谁把你忘了?到处找。我还记得管家跑到我那里直嚷嚷:”少爷少爷,四小姐不见了,怎么办啊!‘弄得一家子仆人、丫环找你找出好几里地去,等把你找出来,你倒好,光记得问:“压岁钱给完了没有,我还没拿呢。’”\n蔡畅颇为得意:“哼,那年我拿的压岁钱最多,一年都没用完!”\n蔡和森说:“那是长辈们怕你哭,故意给你加了倍。”\n“你也不差呀,你这件西装,不就是那年爸从上海带回来的?老家那么多少爷,还没一个穿过呢。”\n兄妹二人越说越高兴的对话中,葛健豪照着镜子,戴着首饰,梳理着头发——本来,她还被儿女的高兴所打动,但渐渐地,她的笑容消失了,梳理着头发的手也渐渐停了下来。她的目光扫过简陋的房间,扫过一件件破旧的家具用品,扫过窗台上摆着的一碗红薯,扫过蔡和森明显有点小了、已经打了补丁的破旧西装,扫过蔡畅的粗布棉袄、鞋面补过的旧布鞋……\n房门轻轻的响动惊醒了兴致高昂的蔡和森,他一回头,才发现母亲已经出了门。镜子前,是几件摘下的银首饰,那件精致的旗式女装已经折好,放在了一旁。\n蔡畅并未注意到这一切,还在情绪高昂:“哎,对了,哥,你还记不记得我们门口挂过大灯笼,我们剪一个好不好?”\n“行,你先剪。”蔡和森不露声色地放下剪刀,“哥先出去帮妈做点事。好好剪啊。”\n蔡畅:“放心,肯定剪得像。”\n坐在墙边,葛健豪呆呆地望着夜空。她的面颊上,挂着两行眼泪。无声地,一只手轻轻拉住了她的手。\n“彬彬?”蓦然发现儿子站在身边,葛健豪赶紧擦了一把泪水。\n“妈,怎么了?”\n“没什么,没什么。”葛健豪掩饰着,但眼泪却又涌了出来,她极力想忍住,擦去泪,笑了一下,却不料眼泪越涌越多,她连擦了好几下,眼泪不曾擦尽,却猛然鼻子一酸,忍不住一下捂住了脸——那是一个坚强女人压抑不住的,突然感到疲惫、无助、软弱而内疚的抽泣声。\n“妈。”蔡和森蹲了下来,抓紧了母亲的手,“妈,您这是干什么?怎么了?”\n半晌,葛健豪才抬起头,望着儿子的眼睛:“小彬,你后悔过吗?跟着妈出来,跟着妈离开那个家,过上现在这样的穷日子,你后悔过吗?”\n“妈,您怎么会突然这样想?”\n“不是妈要这样想,是妈不能不想啊。妈这一辈子,做什么事都利落,都干脆,从来不想什么后果,也从来没有为自己的选择后悔过。只有把你们两兄妹带出来这件事,妈的心里,一直就不安稳。”她叹了口气,接着说,“离开家也好,受苦受穷也好,那都是妈自愿的,可你们不一样,你们都还是孩子,只要还呆在那个家里,你们就能吃好的,穿好的,过得无忧无虑。其实妈心里总是想啊,是不是妈害了你们,是不是妈太亏欠你们,是不是妈夺走了你们应该享受的幸福和快乐……”\n“妈。”蔡和森打断了母亲,“谁说我们现在过得不快乐了?”\n“可是……可是跟着妈,你们连个像样的年都过不上……”\n蔡和森突然站了起来,说:“妈,你真的不知道我们快不快乐?”\n葛健豪点了点头。“那您自己来看,来看看吧。”迟疑着,葛健豪站起身,顺着蔡和森的目光,向窗内望去。\n房里,蔡畅不知何时已经放下了剪刀,正站在母亲刚才照过的镜子前,披着母亲刚才穿过的那件大红旗装,学着母亲的样子,往头上戴着那几件银首饰。对着镜子,她比划着,欣赏着,做着各种天真的表情——大人不在身边,她那小女孩的天性这时展露得是那样一览无余。\n灿烂的、春天般的笑容充盈在她那还带着童稚的脸上。蔡和森问:“妈,您觉得,现在的小畅,不如过去的小畅快乐吗?”葛健豪不禁笑了。\n“要是没有妈妈在身边,做儿子、做女儿的,还能有真正的快乐吗?妈,跟着您出来,是我们这一辈子最正确的选择,您从来没有亏欠我们什么,正好相反,是您,给我们保留了这份幸福和快乐。”\n握着儿子的手,葛健豪点了点头。她突然把儿子的手贴到了脸上,紧紧地,紧紧地……\n三 # 炊烟袅袅,从毛家屋顶上升起。灶前,文七妹蹲在地上,眯着眼睛躲着柴草的烟,往灶膛吹火……\n有双脚步停在了她的身后。文七妹似乎这才感觉到了什么,她突然一回头——\n站在她身后的,正是背着包袱、一身长衫的毛泽东!\n“娘。”\n“哎……哎!”这一刹那,文七妹突然竟有些手足无措,她擦着沾满烟尘的双手,愣了好几秒钟,突然扯开了嗓子,喊,“顺生……回来了……顺生……回来了嘞!”\n毛贻昌板着脸出现在里屋门口:“鬼喊鬼叫什么?我又没聋!”\n他的目光移到了儿子身上。\n毛泽东:“爹。”\n毛贻昌鼻子里“嗯”了一声。\n“大哥……大哥……”年幼的弟妹欢叫着从里面钻了出来。\n“泽覃,泽建!”毛泽东一手一个,一把将两个年幼的弟妹抡了起来,在空中悠了一个圈。\n“大哥?”房门外,担着一担水进门的泽民愣了一下,放下担子就冲了上来,“大哥!”\n毛泽东放下泽覃,一把搂住了泽民。四兄妹欢声笑语,闹成了一团。\n望着自己的儿女们,文七妹搓着双手,喃喃道:“回来了,嘿嘿,回来了……”连毛贻昌的脸上,都闪过了一丝笑意。\n第二天便到了新年,毛家院子里,毛贻昌一身半旧的长袍马褂,正在端正自己的瓜皮小帽;泽建一身新花衣,扎着红头绳,蹦过来跳过去;毛泽东站在凳子上,正在泽覃泽建的指挥下贴着自己刚刚写好的对联。\n端着菜从厨房里面走出,文七妹笑融融地望着家人,快步把菜端进了厢房。抓着泽建的小手,毛泽东用香点燃了挂了树上的一段鞭炮。鞭炮声中,一家人进了厢房,丰盛的农家年夜饭摆满了一桌,父子五人围坐桌前,只有文七妹还戴着围裙,忙碌地上着菜。\n毛泽东从身后拿出了一个布包:“爹,我从省城也带了几件礼物回来,没花多少钱,都是些简单东西。”拿出一包麻糖,毛泽东说:“泽覃、泽建,这个是九如斋的麻糖,省城最有名的,又香又甜,我带了半斤给你们尝尝。”\n毛泽东又取出一本字帖和一叠描红纸:“泽民,你在家里,整天忙农活,认得那几个字我都怕你忘了,这是给你的,有空多练练,以后考学校,用得上。”泽民说道:“哎,谢谢大哥。”\n毛贻昌沉着脸,补了一句:“做完事再练,莫只记得几个字,当不得饭吃。”泽民点头笑说:“我会的,爹。”\n毛泽东又拿出了一盒香烟,送到了毛贻昌面前:“爹,这是给您的。”接过香烟,毛贻昌皱眉打量着——他显然不大认得这是什么东西:“什么家伙?”\n“洋烟,洋纸烟,听说比旱烟好抽。” 毛泽东说道。\n“贵吧?”毛贻昌仰头问。“不算贵,也就两毛钱。”\n毛贻昌掂量了一下轻飘飘的香烟,往桌上一甩:“两毛钱?买得斤多旱烟了,图这个新鲜!”\n“哎呀,三伢子还不是给你图个新鲜?”文七妹正好端上了最后一道菜,她推了丈夫一下,冲毛泽东,“买得好,蛮好,蛮好。”解着围裙,她也坐上了桌。\n毛泽东最后拿出了一只崭新的铜顶针:“娘,这是给您的。”“我?”文七妹有些不相信,“我要什么东西?不用的不用的。”\n“娘——我专门给您买的,您那个顶针不是断了吗?我跑了好多家店铺,才挑了这个最好的。您试试吧,试试合不合适。”接过顶针,文七妹的手居然有些发抖,她颤抖着把顶针戴上了手指。\n毛泽东问道:“娘,大小合适不?”顶针在文七妹的手指上明显大了,文七妹掩住了顶针,赶紧褪下:“合适,正合适,蛮合适的……”她忍不住擦了一把眼角的泪水,赶紧端起酒壶,给毛贻昌倒上酒:“吃饭吧,吃团年饭,一家人团团圆圆……”\n“你急什么?”毛贻昌打断了她,目光又投到了毛泽东身上,“就拿点麻糖、洋烟来交差啊?学堂的成绩单嘞?”\n毛泽东将成绩单递了过来。毛贻昌仔细地翻着成绩单,单子上一长串的各科成绩,都是满分或者九十几。\n他的神色缓和了,一丝笑意也浮了起来。翻过一页,他继续看着,眉头却突然一皱,眼睛凑近了成绩单,那是排在后面的数学等几科较差的成绩。\n“砰”的一声,毛贻昌将成绩单重重地拍在饭桌上,把妻子、儿女都吓了一跳!“数学61?”毛贻昌瞪着儿子,“你搞什么名堂,啊?”毛泽东低下了头。\n“乱七八糟的功课你倒是考一堆分子,算账的功课就乱弹琴!你数学课干什么去了?尽睡觉啊?”毛贻昌越说越火,一拍桌子,却正拍在那盒香烟上,他拿起香烟,“还买什么洋烟来糊弄老子,老子看到就碍眼睛!”一甩手,他将香烟扔到了地上!\n“哎呀你干什么你?”文七妹赶紧起身把烟捡了回来,“门把功课没考好,以后赶上来就是。大过年的,高高兴兴,你发什么脾气嘛?”她将那盒烟又塞进了毛贻昌的口袋。\n看看一家人一个个低头无语的样子,毛贻昌也感到气氛不对,他重重地哼了一声,移开了瞪着毛泽东的目光。\n文七妹忙笑说:“来,吃饭,团年饭——菜都冷了,都吃啊。”她用胳膊碰了毛贻昌一下,毛贻昌这才拿起筷子,挟了一筷鱼:“来,年年有余啊。”几个孩子总算松了一口气,大家都伸出了筷子:“年年有余。”\n转眼寒假过了,一家人都起了个大早,忙忙碌碌地为他准备着。厢房里,文七妹在收拾着毛泽东路上带的干粮等,毛泽民与泽覃在一旁捆扎着毛泽东的行李。\n泽建小心翼翼地把一碗热腾腾的熟鸡蛋端进了厢房,文七妹边往包袱里装着鸡蛋,边吩咐泽建,“去看看你大哥,怎么还在屋里头,莫耽误了船。”泽建推开大哥的门,喊道:“哥,娘在催你了。”\n锉刀声声,毛泽东正坐在桌前专注地干着什么,头也没抬,“晓得了,再等一下,我就好。”\n蹲在门口的一辆独轮车边,毛贻昌正拿着一支香烟,放在鼻子底下闻着。他手里,那包香烟拆了封,却一支也没抽过。\n似乎光闻闻已经过瘾,他又打算把烟装回烟盒,就在这时,两个乡邻正好经过,“顺生老倌,你三伢子要回省城读书去了吧?”\n毛贻昌点头:“哎哎哎,马上走,正在屋里收拾东西。”他一面回应,一面忙不迭地掏出火柴,点着香烟。\n“哟,顺生老倌,这是什么新鲜玩意啊?” 乡邻伸过头来。\n毛贻昌脸上一副不以为然的样子,挟着纸烟的手指高高翘起,展示着:“这个?洋烟,三伢子从省城买回来孝敬我的。细伢子,不懂事,只晓得花钱图新鲜。”\n乡邻凑得更近了,“洋烟是这个样子的哦?哎,顺生老倌,讨一根来我们也开开洋荤喽?”\n毛贻昌平时虽省吃俭用,可对乡亲们却不吝啬。儿子从长沙带来了盒洋烟,不正好让乡亲们尝尝鲜。他得意地将烟递给这两个乡邻,然后又将烟收进口袋,用手按着,这才又补充:“试试喽,看比旱烟强些不。”\n两个乡邻接过烟,点燃后细细地品味起来。\n毛贻昌也怡然自得地抽着烟,远望着两个乡邻走远。待乡邻身影消失不见,毛贻昌赶紧把手里还剩半截的烟掐灭,小心翼翼地,又将半截烟塞回了烟盒。\n这边泽民与泽覃把捆扎好的行李搬上了他身边的独轮推车,捆绑着。看着两个儿子的动作,毛贻昌一脸的不满,“一点东西都不晓得捆!站开站开,我来。”他干净利落,几下捆紧了行李。\n毛泽东却还在专注地干着。停下手,他拿起那根量过母亲手指大小的线,比照着,又拿起锉刀锉了起来。文七妹推开了房门:“三伢子,还在忙什么呢?”\n“就好了。”毛泽东最后锉了几下,转过头来,“娘,您再试试,应该合适了。”他的手中是那枚刚刚打磨过的顶针。\n望着崭新的顶针,和儿子那绽着细细汗珠的笑脸,文七妹一时竟愣住了。拿起母亲的手,毛泽东把顶针戴了上去——果然,不大不小,刚好合适:“娘,您看,刚好。”\n“这伢子……”抚摸着顶针,不知怎么,文七妹突然感到鼻子有些酸了……\n第十二章 二十八画生征友启事 # 二十八画生者,长沙布衣学子也。\n但有能耐艰苦劳顿,不惜己身而为国家者,\n修远求索,上下而欲觅同道者,皆吾之所求也。\n故曰:愿嘤鸣以求友,敢步将伯之呼。\n敬岂事二十八画生。\n一 # 三月,和暖的阳光从长沙街头梧桐新发的绿叶的叶尖,从街面青石板缝隙的新苔上,从湘江新涨的绿水之中滑过,便如一泓清泉,将整个长沙高高低低的建筑洗涤得干净而明亮。空气中弥漫了春天特有的气息,翠枝抽条,绿草萌芽的清新,纷纷绽放的杂花的浓香和新翻泥土的清香,都渗进了长沙街头熙熙攘攘的人流之中。\n何中秀在青石板的街道上快步而行,这位周南女中的教务长全没有在意春光的明媚,连路上的熟人打招呼也心不在焉。她的步子急促有力,颧骨高突的瘦脸上,拧成一体的细眉和紧咬着的薄薄的嘴唇,将她心中的恼怒都勾了出来。她努力控制自己的情绪,尽量想将步子放慢,然而呼吸却更为急促,紧裹在教会学校女学监式高领制服里的身子不由自主地颤抖,一张油印的纸帖在她手里皱成一团。当时在学校的大门前见到这张帖子,她几乎一把撕得粉碎,不过她很快冷静下来,她要留下来做证据。\n这位从英国留学回来的女教务长在周南一向以严厉著称,她下意识地捏了捏手里的帖子,这是一张所谓的《二十八画生征友启事》。不觉越想越恼火。早在英国留学时,她就见识过西方男学生追女生的胆大,令她这些“非礼勿视,非礼勿听”的中国女学生们大开眼界,但今日这个帖子,她发现中国的男学生们实在有青出于而胜于蓝之势,居然如此明目张胆地贴在学校的大门口来招揽女生的眼球,说什么“修远求索,上下而欲觅同道者……”什么“愿嘤鸣以求友,敢步将伯之呼”。 启事末尾写道,“来函请寄省立第一师范,黎锦熙转二十八画生……”一股怒火不自禁地从她脚底直窜到头顶。她倒要看看,这个黎锦熙到底是何方神圣,这个胆大妄为的“二十八画生”又是什么东西,一时脚下更快了起来。\n她折过几条街巷,远远便看见了第一师范那栋高大的暗红色教学楼,柔和的阳光如同蝉翼覆盖,越发显得雍容典雅。\n何中秀略略平缓了心情,这才走进一师那张深黑的镂花大门,学校开课已经几天了,学生们正在上课,回廊上静寂无声。何中秀径直穿过回廊,高挑着的头不动,但冷厉而恼怒地一眼便看见了教务处。何中秀推了推眼镜,抬起了手。\n“乓乓乓……”重重的敲门声吓了几个老师一跳。\n“谁呀?”一个老师打开房门,何中秀冷冷地直视着他。这个老师呆了一呆,右手握住门的把手,疑惑地问:“请问?”\n何中秀不理他,一脚跨进门去,语气生冷:“谁叫黎锦熙?”\n办公桌下不知在找什么的黎锦熙抬起头来,应声答道:“我就是。”他还没有回过神来,何中秀已经直奔过去,把一张纸向他桌上一拍!“这是你寄的?”\n黎锦熙拿起那张纸来,是一张油印的启事,他一眼瞟见那个兰亭体的标题——《二十八画生征友启事》,不觉笑了起来。慌忙说道:“小姐,您听我解释……”\n何中秀立刻打断他,说:“敝姓何,周南女中的教务长。”她的声音随即提高:“太不像话了!居然把这种东西发到我们周南女中来。你把我们周南当成什么地方了?”\n黎锦熙静静地等何中秀发泄完,才赔笑说:“何小姐,恐怕您误会了!”何中秀找了把椅子坐下,眼皮也不抬一抬,纠正说:“何教务长!”\n黎锦熙笑道:“何教务长,您听我解释,这是敝校一名学生写的,他只是托我代收来信……”他的话没有说完,何中秀更是怒气冲天,这是什么老师?一时声音更高了,尖锐的女声便如划过玻璃的钢丝,从教务处一直传到走廊,引得经过的几个老师纷纷侧目。“学生?学生你就更不应该!身为教师,眼看着学生发这种乌七八糟的东西出来勾三搭四,不但不阻止教育,你还帮他收信?是不是想助长他来蒙骗我的女学生啊?”\n黎锦熙这时脑子里已经乱成一团,张大了口说:“蒙骗女学生?”\n何中秀手指在那张启事上乱敲,厉声说:“把这种东西发到女校来,不是想蒙骗女学生还是干什么?还‘愿嘤鸣以求友,敢步将伯之呼’?想求什么友啊,女朋友吗?”\n满屋子的老师们都愣住了。黎锦熙一时真是不知从何解释起,看着何中秀苦笑起来,他深吸了一口气,才说:“何教务长,我想您真的误会了,我可以向您保证,这个学生绝对没有什么不轨的心思……”\n何中秀冷笑一声,说:“你向我保证?”她顿了一顿,尖声说道:“谁向我保证也不行!”\n“那我保证行吗?”\n忽然门被杨昌济推了开来。\n何中秀微微一怔,有学问的人何中秀也见过不少,但像杨昌济这样学贯中西又品行高洁的大学问家却极是少见,这也是她最敬重的。 杨昌济在周南兼课,她一直执以弟子之礼,这时赶紧站起身,神色恭谨:“杨先生?”然而心中疑惑,这件事怎么会和杨先生扯上关系,这个“二十八画生”究竟是何方神圣?\n杨昌济点头微笑,自桌上拿起那张启事,说:“何教务长,请您跟我来,我为您解释。如何?”\n何中秀不觉有些局促,忙说道:“您叫我小何吧。”\n杨昌济含笑说道:“好吧,小何,这边请。”一时领着何中秀出门去。 黎锦熙此时已经是满头大汗,看着两个人出门,长吁了口气,向几个老师自嘲说:“当真是唯小人与女子难养也,这位和苏格拉底的那位有得一拼。”几个老师都笑起来。\n何中秀随杨昌济慢慢穿过回廊,一时来到学校的公示栏前, 杨昌济指着上面贴着的一篇文章说:“你帮我看看,这篇文章怎么样?”\n何中秀一头雾水,但又不好多问,看那篇标题为《心之力》,署名“毛泽东”的文章上,密密麻麻被加上了一片圈点,圈到后来,竟已无从下笔。文章上方用红笔打上了“100”的分数。后面又重重地添上了“+5”。文章之下是杨昌济长长的批语。\n何中秀疑惑地慢慢读这篇文章,越读到后面,脸色越惊异,不自禁地扶住眼镜,又跨前一步,身子几乎已经贴住了公示栏。半晌才抬起头来,说道:“这是你们学生写的文章。”\n杨昌济点头一笑。何中秀半晌才吐了口气说:“一个学生,居然有这样深刻的思想,这样严密的逻辑?我也教了这么多年哲学,真是见所未见啊。”\n杨昌济手拍着公示栏,肃然说道:“不仅仅是才华。此生的人品、志趣,昌济是最了解的,别的不论,心底无私、光明磊落这八个字,我敢为他拍个胸脯。”\n何中秀怔了一怔,忽然回过神来,说:“等等,您是说, 这个毛泽东就是二十八画生?”\n杨昌济点头肯定。说:“是这样的,几天前刚开学,这位学生对我说, 他越来越觉得,所学到的东西,直接从书本上得来的少,倒是向各位先生质疑问难,和同侪学友相互交流中,得到的更多。”\n何中秀沉吟说:“嗯,从有字之书中搬学问,不如从无字之书中得真理。”\n杨昌济笑起来,说:“得真理也只是第一步,他对我说,修学也好,储能也好,归根结底,是为改造我们的社会,而改造社会,绝不是一个人的事,再大的本事,一个人也解决不了任何问题。所以他觉得应该扩大自己的交流范围,结交更多的有志青年,他日,方可形成于中国未来有所作用的新的力量。”\n何中秀闻言呆了一呆,忽然一击掌,说:“对,这就应该结交同志,公开征友。是不是?”\n杨昌济欣然大笑,打开那张启事,说:“您看,‘但求能耐艰苦劳顿,不惜己身而为国家者’,他既以家国天下为己任,自能想人之不敢想,行人之不敢行。区区世俗之见,又岂在他的眼中?”\n何中秀低头一笑说:“看来倒是我有俗见了,杨先生,今天是我冒昧了,请您原谅。”\n杨昌济微笑说:“这么说,何教务长不打算追究了?”\n何中秀含笑说:“我要追究的是心存不良的浪荡子,可不是有这等才华个性的好学生。”\n杨昌济会意一笑说:“那这份启事就还给我,就当这件事没有发生过,好不好?”\n何中秀缓缓摇了摇头,斩钉截铁地说:“那可不行。”\n杨昌济愣了一愣说:“怎么?”\n何中秀笑道:“启事还给您,我周南的学生,上哪儿去结交这样特立独行的人才呢?”\n杨昌济也笑起来,递过那张启事。何中秀接过来说道:“今天冒昧打扰了,麻烦您代我向黎先生致歉。”\n杨昌济笑着答应:“一定,一定。”\n何中秀告辞出来,已经是中午时分,阳光越发显得清亮了,便如透明的琥珀一般。何中秀不觉又将那张启事拿在手里细看,“二十八画生者,长沙布衣学子也……但有能耐艰苦劳顿,不惜己身而为国家者,修远求索,上下而欲觅同道者,皆吾之所求也。故曰:愿嘤鸣以求友,敢步将伯之呼。”脸上渐渐露出笑意,一抬头,却见不远处阳光下数株老槐都抽出碧绿的新条,如同清泉淌过的玉石一般。\n二 # 毛泽东这几天来一直都在一种激动和亢奋之中,周身仿佛有一团火在燃烧。他的征友启事在长沙各大中学贴出不过两天,便接到了长郡联合中学一位自号“纵宇一郎”的来信,这人名叫罗章龙,虽然只有19岁,但胆识气魄都超人一等,两个人一见之下,顿时有相见恨晚之感,从周日下午二时一直谈到天黑,还意犹未尽。罗章龙对经济学的领悟颇深,这是毛泽东尚未涉猎的新范畴,因此听得相当仔细,不觉暗自庆幸,如果不是有这次征友,在学校的课本上,他是无法学到这些新知识的。而从罗章龙的谈吐,他也情不自禁地感到,天下之大,无处不是英才,如果这些精英都能同心一力,中国的复兴只在指掌之间。\n这日一大早,毛泽东胡乱吃了早饭,便匆忙往爱晚亭赶,他与另一位来信应征的已经约好了在爱晚亭见面。一时过了湘江,直上岳麓山。这天正是周末,但天时还早,山上游人不多,天边一轮红日,自绵延的山岚之间浮出,便在满山碧绿的松涛中抹出一痕胭脂。松风振动,鸟雀相鸣。\n出岳麓书院后门,沿石道而上,山路盘折,越往里走,山路越窄,两山夹峙,行至山穷水尽之时,眼前忽然开朗,一个亭子金柱丹漆,四翼如飞,立在山麓之中, 正是号称天下四大名亭的爱晚亭。亭下两个大池塘,春水新涨,绿柳如丝。\n毛泽东在亭子里的一张石桌旁坐了下来,他来得太早,应征的人还没有到。但他此时心中却更为急切,在那亭子里坐立不安。\n终于听到有脚步声远远传来,毛泽东站了起来,看时,却是一男一女两个中年人,看着也不像。他又坐了下来,正失望时,忽然石道上闪出一个少年,只有十五六岁的年纪,短发,眉目清秀,但嘴唇丰厚。他步履谨慎,无声无息地上了亭子,略有些局促地看着毛泽东,张了张口,腼腆一笑试探道:“二十八画生?”\n毛泽东大笑一声,扬起手中的信来,两封信同时摆在了石桌上。\n“长郡联合中学,李隆郅。”这位少年报出名字。\n“第一师范,毛泽东。你好。”毛泽东热情地伸出手,李隆郅看了看这只手,才伸出手来,握了一下。\n毛泽东坐了下来,说:“你想先谈点什么?”\n李隆郅沉默一时,说:“毛兄主动征友,自然先听毛兄谈。”\n毛泽东全不推辞,顿时滔滔不绝:“嗯!那好,我就谈谈我为什么要征友。首先呢,我们都是民国新时代的青年,天下者,青年之天下也。青年要实现自己的理想和抱负,就要寻找更多志同道合的同志。古有高山流水,管鲍之谊,我们今天更应该与一切有志于救国的青年团结起来……”\n山风掠过,亭子四翼的松枝一阵颤动,便如触电一般,满山的松涛都荡开来,便如海波扬起,直向天空奔涌而去。毛泽东说得兴起,站了起来,在亭子里来回走动着,挥动手臂,声音也越来越大:“……正如梁启超先生言:今日之责任,全在我少年。少年强则中国强,少年进步则中国进步,少年雄于地球,则中国雄于地球……”\n李隆郅沉吟不语,目光落在了石桌上并排摆放的那两封信上。山风越发大起来,吹动信纸。\n“……以我万丈之雄心,蒸蒸向上,大呼无畏,大呼猛进,洗涤中国之旧,开发中国之新,何事不成……”\n毛泽东越说越兴奋,大开大阖,仿佛眼前的群山都是他的听众,正在受到他的鼓动感染!\n李隆郅默然无语,只是眼看着亭外的山景,沿池塘植满了垂柳,阳光透过来, 柳叶如眉,绿草如丝。\n“……莽莽乾坤,纵横八荒,谁堪与我青年匹敌?纵一人之力有限,合我进步青年之力,则必滔滔而成洪流,冲决一切,势不可挡,为我中华迎来一崭新世界!”毛泽东用力一挥手,声音戛然而止。一番演说带来的激动使得他额角都带上了微微的汗珠,眼里闪着炽热的光,等待李隆郅的回应。\n这时亭外一群飞鸟骤然从枝头惊起,正在打量着山景的李隆郅似乎也被惊醒,他看看毛泽东望向自己的眼神,半晌才说道:“毛兄——说完了?”\n毛泽东:“说完了。”\n李隆郅沉默一时,似乎下了很大的决心似的,站起身来,一言不发,向亭外走去。\n毛泽东呆了一呆,“哎,你上哪去?”\n李隆郅头也不回说:“你不是说完了吗?”\n毛泽东:“我讲完了,你还什么都没讲呢。”\n李隆郅却不理他,飞也似的跑下山去了。\n毛泽东不由哭笑不得,招手想叫他回来,但想一想却作罢了,只摇一摇头:“这个人,什么毛病?”\n不过毛泽东怎么也没有想到的是,不到十年,他和这个人成为了战友。1922年,李隆郅从法国留学回来,先到中共湘区委员会报到,书记正是当初寻友时结识的“润之兄”。毛泽东对他说:你的名字太难叫,工人们也不认识“隆郅”这两个字。这位性格豪爽的革命者马上同意改名,决定按谐音改成“能至”。再后李能至又更名李立三,成为早期中国共产党领导人之一,中国工人运动领袖,无产阶级革命家。只是毛泽东一直也没明白他当时为什么一言不发,这也成了一段谜。\n三 # 何中秀回到周南女中,当天就把这个启事张贴在了学校门口。放学后,一大群好奇的女生们叽叽喳喳地围在门口,有人读着,有人议论,也有人皱着眉头。\n“什么那么好看?让一下让一下。”警予拉着斯咏挤了进来。\n“《二十八画生征友启事》?嘿,这倒新鲜啊!”警予读着启事,“‘二十八画生者,长沙布衣学子也’——这是谁呀,这么酸溜溜的?”\n斯咏比较喜欢古文些,并不觉得这样写有什么不好,她蛮有兴趣地看着启事,说:“你管他谁,看看再说嘛。”\n“我才懒得看呢。”警予一点兴趣也没有。\n斯咏自顾自地读着启事:“……但有能耐艰苦劳顿,不惜己身而为国家者,修远求索,上下而欲觅同道者,皆吾所求也……”\n“切,好大的口气!”警予一把拉住斯咏,“走走走,牛皮哄哄的,有什么好看的?走!”\n两人刚转身,听到身后传来其他女生读启事的声音:“……故曰:愿嘤鸣以求友,敢步将伯之呼……”\n斯咏猛地站住了,她一把甩开警予的手,回过头来。启事的末尾,霍然是那句“愿嘤鸣以求友”!\n回到寝室。斯咏拿出那本《伦理学原理》,翻开了扉页,露出了那句“嘤其鸣矣,求其友声”。她几乎是下意识地把这一页翻过去,又翻回来,反反复复。\n“你说我们周南这是怎么了?平时连门都不让男生进,今天倒好,外校男生的征友启事,居然也让贴在大门口,真是怪了。”警予在趴在床边,摔打着一个旧布娃娃。\n一贞也轻轻应和着:“就是,我也觉得怪。”\n“哎,你们猜猜,会不会有人去应征啊?”警予看看斯咏,又看看一贞,问。\n只有一贞回答:“不会吧?”\n“你肯定?”\n“男生征友,女生谁会好意思去呀?那还不让人笑话死?”\n两个人聊着,却发现斯咏坐在一边出了神,警予把那布娃娃扔了过去,砸在斯咏头上:“哎!大小姐,今天怎么回事?一句话都不说。”\n斯咏没抬头,仍然盯着那句诗。\n“这丫头怎么了?丢魂了?”警予上前把那本书一把抢了过来,“想什么呢?”\n斯咏抬起头,忽然仿佛下定了决心似的,说:“我想去应征。”\n四 # 毛泽东接到陶斯咏的信已经是第三天,自和李隆郅见面之后,他一直也没有弄明白,李隆郅为什么一言不发便走了。而黎锦熙这回交给他的信,落款居然是“周南女中 悠然女士”,分明是个女生,他就更是犹豫,直到了约定的周日上午,他还拿不定主意,便来找蔡和森。\n“老蔡。”毛泽东把信放在蔡和森面前,“陪我走一趟好不好?”\n蔡和森看一看信上的落款,顿时笑起来:“想不到,润之兄天不怕地不怕,倒怕和女学生见面。”\n毛泽东哼一声,说:“我怕?我怕他个鬼!我就是觉得头回见面,一男一女,总不太好嘛。”\n蔡和森沉吟说:“人家肯来应征,足见思想开明,不是那种扭扭捏捏的传统女性。”\n毛泽东点头说:“这个我晓得。不过……我还是觉得不太好——再说,这么思想开明的女性,你也应该见识见识嘛。哎呀,走走走,走嘛。”\n来信约在岳麓山的半山亭,二人直出了校门,过湘江上山。\n半山亭在岳麓山的半山腰,此处原建有半云庵,后废弃,亭子是六方形,亭周苍松半隐,杂花乱放。松外半边晴日,半壁山石嵚嵌。\n“看样子还没到。”两个人上了亭子,毛泽东环顾四周。\n“还不到时间吧。” 蔡和森全不在意,看那亭子上“半山亭”三个字,说道:“润之,这半山亭还有个来历,你还记得那首诗么?”\n毛泽东正要说话,忽然背后一个女声传来“请问——”\n毛泽东和蔡和森同时回过头来,斯咏、警予、毛泽东、蔡和森都愣住了。\n“怎么是你?” 四个人几乎是不约而同。\n毛泽东大笑起来,一扬手中的信说:“两位谁是悠然女士?”\n警予一指斯咏笑说:“本人周南女侠,这位悠然女士。谁是二十八画生?”\n“敝人毛泽东,正好二十八画,这位第一师范蔡和森。”他向斯咏一笑:“两位女士好。”\n斯咏怔了一怔,这两个名字实在再熟悉不过了,想不到毛泽东就是他,立时伸出手来笑道:“你好,陶斯咏,向警予。”\n她话未说完,警予几乎跳了起来,“你就是蔡和森,你是毛泽东,去年一师入学考试的一二名?”指着蔡和森,“你还笑,你怎么骗我。”\n蔡和森尴尬一笑,毛陶二人奇道:“原来你们认识?”\n警予哼了一声,说道:“鬼才认识他。” 蔡和森却一抱拳笑道:“女侠气量如海,得罪之处,还请恕罪。”\n警予一摆手,撇嘴说:“也罢,本女侠肚里能撑船,暂时饶了你,下回再犯,定斩不饶。”\n一时四个人坐定,慢慢说起缘故,从向陶二人冒名考试,到蔡陶街头擦鞋,从毛陶二人书店偶遇,再到街头躲雨,原来都是对面相逢不识君。说到好笑处,都哈哈大笑起来。\n“这就叫无巧不成书啊。”毛泽东一捅蔡和森:“你看,你还不打算来,不来怎么碰上你这位崇拜者啊?”\n警予冷哼一声说:“还说!想起来就叫人生气,说什么‘我跟蔡和森是同学’——为什么骗我?”\n蔡和森笑说:“我可没骗你。”\n“还不承认!” 警予得理不饶人。\n蔡和森笑一笑说:“当时你只问我认不认识一师的蔡和森,我说认识也没错呀——我能不认识自己吗?”\n警予瞪了一眼,说道:“狡辩!”\n“好了,偶像也碰上了,还有什么不满意的?” 斯咏笑说,“再说刚才你怎么说的,‘本女侠肚里能撑船’。”\n警予一扭头反驳她:“谁说他是我偶像了?”\n“不是偶像?不是偶像你那床头贴的是什么?” 斯咏含笑说道。\n警予脸上微微发热,顿时反唇相讥:“不准说了啊。是谁又送书,又抄诗,还说我?”\n斯咏立时羞红了脸。\n“好了好了,以前的事都不提了,今天,就当我们正式交个朋友。来,握个手吧。”\n在毛泽东的提议下,四个人的手大大方方地握在了一起。\n五 # 读书会的周日活动时间很快就到了。这一天也正是斯咏、警予头回参加活动的日子,毛泽东一早便告诉了萧子升有两个新成员要加入,春色和暖中,读书会的人在一师门前陆续聚齐,萧子升一直留意,却不见有新人来。一时问:“润之,你说的两个新成员呢?”\n毛泽东笑说:“莫着急嘛,马上就到。”\n这时身后传来了警予的声音:“毛泽东。”萧子升看时,斯咏穿一件淡黄的连衣裙,一头乌青的长发如缎子一般飘动,高挑身材,眉如细月,目似澄波,神色从容,举止冷静。警予穿白色校服,短发,修眉俊目,文采精华,这两个人, 斯咏艳如霞映澄塘, 警予却是素若秋蕙披霜,一艳一素,看得萧子升不由怔住了。毛泽东大笑说:“你看,说曹操曹操就到吧。来来来,介绍一下,萧子升,我们读书会的负责人。这两位是周南女中的向警予、陶斯咏。”\n警予落落大方地伸出手来:“你好。”\n子升这才反应过来,赶紧掩饰着自己的失态:“你好。”\n斯咏也伸出了手,与子升相握:“你好。”\n毛泽东一拍巴掌说:“哎哎哎——人都到齐了,兵发湘江,走喽!”\n一行人浩浩荡荡过了湘江,向岳麓书院而来,一路上玩笑不断,向、陶很快和众人混熟了。\n岳麓书院始建于宋代开宝九年,书院前抵湘江西岸,背延至麓山之顶,占地数百亩。众人远远便见苍松老柏之间,院堂相接,楼阁勾连,自有一番气势,都不觉肃然起来。\n众人一时缓缓行到了桃李坪,却见正面是单檐硬山式的三间大门,额书“千年学府”。萧子升微微一笑,说道:“有人说一大段的时间,才凝聚出一点历史,一大段的历史,才凝聚成一点文化,文化之重,自古使然,这里是中国千年文化之地,虽然只有这简单的四个字,但其中的分量,实在有泰山之重。”\n蔡和森沉吟说道:“自来游名山大川,就有两种人:一种是明白人,积蕴深厚,胸中有丘壑,因此于简单处见文化,于平白处得性情;一种是糊涂人,只知道搜奇猎胜,更有人附庸风雅,不知所谓,实在糟蹋了这些名山胜景。”\n警予笑说:“你说我们是明白人还是糊涂人?”\n蔡和森笑一笑,不置可否。毛泽东却笑说:“他一向的难得糊涂,是大智若愚。”\n几个人说笑,已经进了那三间头门,这里就是正门了,只见五间出三山屏风墙,也是单檐硬山顶,门额“岳麓书院”,门联大书“惟楚有才,于斯为盛”。\n外檐石柱一幅楹联:“地结衡湘,大泽深山龙虎气,学宗邹鲁,礼门义路圣贤心”。\n警予念着门联,回过头来,手点着身后众人说:“哎,你们说,是不是于斯为盛呀?”\n斯咏笑道:“人家千年书院,才敢这么说,我们算老几?”\n警予哼一声:“那千年也过掉了嘛!以后呢,说不定就是我们。蔡和森,你说是不是?”\n蔡和森笑一笑说:“我可不敢做此奢望。”\n萧子升却沉声说:“为什么不?江山代有才人出,各领风骚数百年。焉知今后就不是你我之辈?”他的目光转向了斯咏,说道:“陶小姐,向小姐,请吧!”\n众人纷纷向里走去,斯咏却回头在找什么,只见毛泽东还站在原地,仰望着对联出神,招呼道:“毛泽东,走啊!”\n“哎!”毛泽东答应一声,又认真看了对联一眼,深吸了一口气,这才向里走去。\n向里便是书院的主体讲堂所在。自初创至今,讲堂堂序共有五间,前出轩廊七间,东西深三间,一体的青瓦歇山顶。讲堂明间正中设讲台,屏风正面刊着张栻撰、周昭怡书的《岳麓书院记》,背刊岳麓全景摹刻壁画。左右壁嵌石刻“忠、孝、廉、节”四字。轩廊后壁左右,分置石刻,为乾隆二十二年山长欧阳正焕书“整、齐、严、肃”四字。内壁四处都是木刻、石刻,刊满学箴、学规、题诗。\n蔡和森长吸一口气说:“这就是湖湘千年学术之滥觞啊。”\n萧子升点一点头,“站在这儿,想想当年,朱熹、张栻、王阳明、王船山这些先贤巨儒,就曾在那个讲台上传道授业,我们站的地方,就曾坐过曾国藩、左宗棠、谭嗣同、魏源这些学生……”\n警予扬起脸补充:“还有杨老师。”\n萧子升愣了一愣,笑了起来,“对,包括杨老师——身处圣贤故地,举目而思先哲,油然而生敬意啊!”\n警予突然一撩裙子,席地端坐了下来,招呼说:“来来来,都坐下,体会一下。”\n众青年纷纷学着古人听讲的样子,席地端坐下来。\n警予点头说:“嗯!感觉不错。可惜呀!就缺上面坐个老师了。”\n斯咏仰头说:“那上面谁敢坐?那可是朱熹、王阳明讲课的地方。”\n萧子升笑说:“是啊!我们没赶上好时候,不然,也能一睹圣贤风采了。”\n“我看老师还在。”这时身后传来了毛泽东的声音,众人回头一看,才发现只有他还站在大家后面。\n他走上前来,手一指:“那就是老师,真正的老师。”手指的方向,正是轩廊外檐明间匾额上“实事求是”四个大字。\n斯咏疑惑道:“实事求是?”\n“对,实事求是!据说朱熹在读《中庸》时,《中庸》里面关于心和性,他总是不得其解,就跟张栻讨论,张栻是胡宏的学生,认为‘未发就是性,已发就是心’,主张‘先察实,然后再持养’,这就是湖湘学派经世致用的发端。其后湖湘学派把这种心性的修炼和经世致用结合起来,像张栻的时候,他研究《孙子兵法》,而且认为《孙子兵法》是每个儒生必须要研究的。王船山还在这里办了一个社团,叫‘行社’,行动的行。曾国藩也专门解释过实事求是,说实事求是就是‘格物致知’,研究学问要格物,那个实事就是物,我们要格物就是要研究从实事中间来求得天理。朱夫子也好,王阳明也好,不管多少饱学先贤,也不过匆匆过客。只有从东汉就留下的这四个字,才是岳麓书院的精华,才是湖湘经世致用的根本所在。”毛泽东回过身来,“讲实话,做实事,不务虚,求真理,这才是值得我们记一辈子的原则!”\n他说到这里,也坐了下来,说:“我建议,今天我们就在这儿,对着这块匾,讨论一下,怎么做,才是真正的实事求是。”\n第十三章 可怜天下父母心 # 刘俊卿顺着孔昭绶手指的方向,走出房门,\n忽然,他愣住了,父亲竟然站在门口,\n全身都在颤抖,老泪纵横。\n刘俊卿无法面对父亲,\n更无法面对身份即将揭穿的难堪,\n他低头着,加快脚步,从父亲身旁逃也似的跑开。\n一 # 刘俊卿自过年那日被赵一贞的父亲羞辱赶出赵家之后,不敢再去赵家,每日里躲在巷口张望,心想哪怕是见上一面也好,至于真见了面要说些什么,或是答应赵一贞些什么,他是一点考虑也没有。故而每每看到赵一贞的身影出现在巷口,他反而躲得比赵一贞还快,唯恐被她发现。\n那一日,赵一贞还没放学,刘俊卿正躲闪着东张西望,冷不防被人横过来当胸一掌,推得他一个趔趄。刘俊卿大怒,挽了袖子正要上前据理力争,但一看原来是三堂会的老六,带着两个青衣打手直往赵记茶叶店去。刘俊卿忙把到了嘴边的话全部吞回肚里,远远憋在后面偷看。\n那三个人进到赵记茶叶店之后,一个青衣打手把一张印得三分像人,七分像鬼的关公像甩在柜台上。赵老板连忙拿了一块光洋恭恭敬敬放在关公像旁边。\n另一位青衣打手眼睛也不抬一下,说道:“马爷有话,从这个月起,你这种店面的香火钱一律两块。”\n赵老板赔着笑说:“两位爷,我这小店不一直是一块吗?”\n“怎么,不想给?”\n“不是这话。实在是生意难做啊,就一块我都是牙缝里挤着省呢……”赵老板只差点跪下了。\n“你这店是不是不想开了?”青衣打手把桌子一拍,赵老板吓得一哆嗦,老六看看这火候也差不多了,这才慢条斯理地故意问道:“这是怎么回事?”\n“这家不肯敬关二爷。”\n“不敬关二爷?嘿,我说你他妈的……”\n老六嘴里的三字经才刚出口,身穿周南校服的赵一贞正好进了门,顿时把老六看得呆了——女人他老六也见过不少,手下就打理着三堂会的两家妓院,但似一贞这般文静秀雅,既有良家女子的贤良,又有女学生的新潮的姑娘,他却着实是头回开眼,一时间看直了眼,目光追着一贞,直到一贞进了茶叶店的后院,放下帘子,这才回过神来。再回头看到手下正在又拍桌子又要拆店,他二话不说,“啪”地挥出一巴掌,打得手下晕头转向:“吵什么吵?你吵什么吵?老子在这儿,轮得到你来耍威风?从今天起,这间店的香火钱免了!谁敢再提拆店的事,我先把他给拆了!还不滚!”又转头对赵老板说,“没事了,没事了啊。老板,做生意,做生意。都是一家人,以后常来往,常来往啊。”说话间直出门去。\n看看老六一步一回头的样子,再回头瞄瞄里间的门帘,赵老板似乎猜到了其中的原因。\n这时赵一贞已经走过来,低声说: “爸,我出去一趟。”\n赵老板心不在焉地点点头。\n斑驳的夕阳,清清冷冷地洒在小巷陈旧的青石板上,一步,又一步,赵一贞在青石板来来回回地走着。她早就看到了躲在墙后的刘俊卿,或者说,她每天都看到了刘俊卿,她在等,等着刘俊卿自己出来,像个男人一样主动站出来。\n刘俊卿却仍然躲在墙后。\n赵一贞停下来:“你每天等在这儿,就是为了躲着我吗?如果你以为我和我爸想的一样,那你何必还等在这儿?你明明知道,有些事是我根本不会计较的,从认识你的第一天开始,我就没想过你是少爷公子还是穷学生,可要是连你都躲着我,连你自己都看不起自己,那你让我怎么办?这份感情,我需要有人跟我一起坚持啊!”\n刘俊卿脸贴在墙上,双唇紧闭。赵一贞也一动不动,她在等刘俊卿的回答,夕阳一点一点从她月白的衫子上退到墙角,直到巷子里都暗了下来,远处麓山寺的钟声隐隐传来,但刘俊卿仍然一言不发。赵一贞叹息一声,转过身来,缓缓离去。\n刘俊卿这时才从墙角转出身来,他张了张嘴,但最后还是忍住了,只看着赵一贞的背影,闭上了眼,抱头蹲下身来……\n二 # 一连几天,上课也好,读书也好,刘俊卿全无心思。这一天才放学,忽然见纪墨鸿向他招手,他一时进了纪墨鸿的办公室,只听纪墨鸿说道:“关上门。”\n“老师找我有事?” 刘俊卿掩上了门。\n“有个机会,你想不想抓住?” 纪墨鸿含笑着问他。\n“什么机会?”\n一时纪墨鸿说出一段话来,刘俊卿只觉得全身上下每一个毛孔都轻飘飘的,只要踮一踮脚,就可以飞起来。他告辞出来,飞快跑下楼梯,把迎面而来的同学手里拿着的试卷课本撞得满天飞扬。同学惊讶地看着他,他却看也不看一眼,抬起头继续向前飞奔,他心里只有一个念头,就是找到赵一贞,告诉她,他们的爱情有希望了,他们的生命,重新开始了。\n他冲进赵记茶叶店,把正在看店的赵一贞吓了一跳,又想父亲此时正好在家,生怕刘俊卿难堪,忙从柜台后面出来,打算拦住刘俊卿,却不料赵老板听到动静,马上从门帘后出来,把赵一贞往内屋推:“你给我进去,进去!”\n刘俊卿气喘吁吁:“一贞……赵叔叔,您听我说……有件事,我一定要告诉一声……”\n赵老板见刘俊卿不像来捣乱的,“什么事?”\n“我要当科长了,省教育司一司科长。”刘俊卿兴奋地说。\n“当科长?可你不是二年级还没读完吗?”赵老板冷眼看着他。\n刘俊卿解释说:“是这样,我有一个老师,是教育司的督学大人,刚代理了教育司长,他一直很欣赏我,这次那个科长的位置空出来了,他说,只要我能参加我们学校讲习科的毕业考试,考个第一名,他就推荐我接这个位子。到时候,我就是算是民国政府正式的文官,光薪水就比当老师高出好几倍,只要我再努力好好干,以后,还能升署长,升司长……”刘俊卿还欲滔滔不绝继续往下说,却被将信将疑的赵老板打断,“你说的——是真的?”\n刘俊卿一再保证,“是真的。赵叔叔,我一定会认真考,一定会争取到这次机会的。您就让我见见一贞,让我把这个消息告诉她,好吗?”\n刘俊卿知道赵一贞就在旁边,也听到他刚才所说的话,但是,他不管,他要亲口再对赵一贞说一遍,这是他对他们感情的保证。\n刘俊卿这番话,在赵老板听来,不可全信,也不可不信。在他的算盘里,与其让女儿跟三堂会老六那个大字不识,只会耍狠的流氓,还不如遂了女儿的心愿,许了眼前这位刘俊卿。这小子,穷是穷点,但好歹也是读书人,难免不保日后会有飞黄腾达的一天,说出去也体面。怕就怕这小子说话不尽不实,夸夸其谈,到头来,竹篮打水空欢喜一场事小,得罪了三堂会老六可就是身家性命不保的大事。\n赵老板脸上头一次有了笑容,对刘俊卿说:“我也没说过你们就不能见面了嘛!一贞,一贞。”等到一贞迫不及待从里屋出来,赵老板半步也不离身,挡在两个人中间,说:“俊卿呢,是来告诉你一个消息的,告诉完了他就会走,至于以后还来不来,就看他那个消息是不是能有结果了。”\n刘俊卿一心沉浸在爱情重燃希望的喜悦里,哪里想到就这短短三两分钟的工夫,赵老板这个生意人的脑子里,已转过了这许多念头。“您放心,赵叔叔!”刘俊卿口中喊着赵老板,目光却是迎着赵一贞,“这个第一名,我一定会考到手!”\n这些天,老六一天三趟地往茶叶店跑,赵老板唯恐他被撞见,忙说道:“话已经带到了,人也已经见到了,机会我已经给你了,至于晚饭我就不留你了,你好自为之。”\n赵老板一席话,倒勾出了刘俊卿的隐忧:转入讲习科参加毕业考试,这方面的手续问题,纪墨鸿既然开了口,自然不用他操心,但讲习科那边还有个天才萧子升,从入学作文开始,就一直压在他头上,压得他几乎喘不过气来。\n在刘俊卿看来,这世间的读书人也是分三六九等的,最差一等是王子鹏那样,读来读去都是倒数第几,自己对自己都没什么信心,幸亏有个好爹娘罩着,否则被人卖了还帮人数钱。再次就是毛泽东那种人,有一点小聪明就到处张扬,弄得天下皆知,不过一到考试就现真章,平均分也好,总分也好,加加减减下来也不过如此。最可怕的就是萧子升、蔡和森这种人,平时也没见他们如何熬夜加班加点努力用功,一到考试,却永远名列前茅。\n临近考试的那些日子,他找讲习科的同学借来听课笔记,拼了命地下苦功,心中已经定了目标,这回,一定要把萧子升远远抛在身后,把这个第一名考到手。\n然而事与愿违,他心中背了这个包袱,茶饭不思、没日没夜地熬下来,不知怎么,记性却反倒不如从前。这天王子鹏来帮他复习,几个问题考下来,刘俊卿竟答得一塌糊涂。\n“《独立宣言》是谁起草的?”\n“华盛顿。”\n“不是。”\n“那……富兰克林?”\n王子鹏又摇头:“是杰斐逊。”\n刘俊卿慌了手脚,本科要到明年才会正式开世界历史,讲习课却开得早,他原以为这段时间自己下了工夫,应该没问题了,不料越急越记不清,脑袋里全乱成了一锅粥。\n王子鹏看着刘俊卿面前堆得厚厚的笔记本,很为他担忧:“时间来得及吗?俊卿,不用太勉强了。”\n“没事,数学、英语这些基础科目我们都上过了,剩下的都是些要背诵的,无非是多花点时间背就是了。”刘俊卿只能自我安慰。\n“我看你把所有时间都用来背这些笔记了,别的老师都知道你报考讲习科一事,睁一眼闭一眼,但明天有袁老师的课,你小心点。”\n刘俊卿还是有些惧怕袁吉六的,大概就是所谓师道尊严吧。但另一方面,刘俊卿又觉得,袁吉六作为老师,过来人,更应该理解他,即便是抓到了他在课堂上背诵讲习科的笔记,也会放他一马。\n但刘俊卿失望了,袁吉六很生气,教鞭狠狠地抽在课桌上,两只眼睛瞪着他,一副要他把生吞活剥的样子。\n刘俊卿手忙脚乱:“袁老师,您听我解释……”\n“有什么好解释的?上课不认真听讲你还有理了!反了你了!”袁吉六抓起笔记,刷地扔到教室后面:“站到教室后面去给我好好反省,这堂课,你就站在那里听。”\n刘俊卿长这么大,一直是好学生,第一次被老师这样当面不留情面地批评。他不敢再说话,乖乖站到教室后面,笔记本就在他脚边,当着袁吉六的面,他不敢弯腰去捡。好不容易等到下了课,袁吉六离开教室之后,刘俊卿这才捡回笔记本,垂头丧气回了宿舍。\n宿舍里,易礼容和张昆弟把两张书桌拼起来,各拿着一只简陋的光板球拍,你来我往打起了乒乓球,引来了几十个六班和八班的同学过来看热闹,把宿舍挤得水泄不通。\n易礼容一招失手,被张昆弟抓住机会赢了一球,喝彩声之后,张昆弟大叫,“哈哈,六比五,你输定了!”\n易礼容不服气:“就一球,运气球,有什么了不起的,再来!”\n张昆弟洋洋得意:“这可不是运气问题,是水平问题,你就认输吧你,今天我吃定你了!”\n张昆弟这话言者无心,刘俊卿却是听者有意。这些天来,他所忌讳的,只有萧子升一人,刚才的课堂上,他出了这么大一个丑,张昆弟这些人无一不跟萧子升交好,当着他的面就敢这样指桑骂槐,背后说不定多么幸灾乐祸。刘俊卿想到这里,越发怒不可遏,仿佛疯了一样,扑上前去,一把夺过张昆弟手里的乒乓球拍:“吵吵吵!吵什么吵!还让不让人看书?这不是你一个人的寝室!你知不知道?”\n“啪”的一声,乒乓球拍被刘俊卿重重摔在地上。\n宿舍顿时安静下来,所有人都惊呆了。好半天易礼容才回过神来,悻悻地说:“走!我们都走!不要在这里耽误了某些人的远大前程!”\n待众人悻悻地出了寝室,刘俊卿猛地一把关上门,把所有声音关在门外,把自己一个关在宿舍里面。他知道,他已没有了退路,赵一贞那里没有了退路,他打开书本,呆呆地看着,但他的心却无法集中在书本上,而是迷失在某个无尽的空虚处。\n他现在,只剩了一个念头:只要能在这场考试中稳操胜券,无论什么办法,什么手段,他都将毫不犹豫……\n三 # 考试终于如期而至。\n这日刘三爹推着臭豆腐架子车来到一师附近,儿子不喜欢他在这里摆摊子,但今天是儿子参加讲习科毕业考试的关键时刻,他不放心,怎么也得来。前面是个陡坡,推上去就可以摆摊了。刘三爹竭力忍住咳嗽,这个动作他已经习惯了,在家时是为了不影响儿子学习,摆摊的时候又担心客人们不喜欢影响生意。\n坡很陡,刘三爹推了几次都没能推上去,他深吸一口气,准备做最大的努力,不料这口气堵在胸口,反而堵得他喘不过气来。\n他突然一头栽倒在地上。\n正要进校门的孔昭绶和王子鹏正好看见这一幕,赶紧上前同时扶住了刘三爹。孔昭绶连忙吩咐王子鹏:“快,去校医室。”\n校医检查的时候,刘三爹已经缓过劲来了,校医把孔昭绶拉到一边,低声说:“老人家暂时没什么大问题,不过,学校医疗条件有限,校长,这位老人家的病,还是上医院好好检查为好。”\n孔昭绶点点头,转向刘三爹:“老人家,要不要我通知您家里,送您上医院?”\n刘三爹看着孔昭绶,有些迷茫,王子鹏赶紧介绍说,“这位是我们一师的孔校长。”\n刘三爹吓了一跳:“不用了,不用了,校长大人,您是校长大人,那么忙,不用管我了,我没什么事,回头我自己去,自己去。”\n孔昭绶还是不放心,“那……您家里还有什么人?我叫人去通知一声,让他们来接您。”\n刘三爹吞吞吐吐:“我儿子……出去了,不在家,不在家的……要过些日子才能回来。”\n“哦。那……家里总还有别的人吧?”\n“倒是有个女儿,在大王家巷王议员家做丫头。”\n王子鹏一听,赶紧问道:“王议员家,她叫什么?”\n“阿秀?”\n王子鹏闻言不由一呆。\n正在这时,方维夏站在门口敲门,一脸严峻,背后有一个人在那里躲躲闪闪,正是刘俊卿。孔昭绶有些惊讶,方维夏找他找到校医室来,必是出了很严重的事。\n孔昭绶刚走到走廊,还没来得及关门,方维夏就说出了事情原委:“讲习科的毕业考试,有人作弊,被当场抓获。”\n“是谁?”孔昭绶怒不可遏。\n“原本科第八班转到讲习科的刘俊卿!”\n方维夏话音刚落,“啪”的一声,校医室传来一声巨响,孔昭绶和方维夏忙过头去,只见刘三爹定定地站在那里,看着地上摔碎的茶杯发呆,脸上是一片近乎死亡的灰白。一旁的王子鹏一连声地喊着:“刘老伯,您怎么啦,您哪里不舒服,您说话啊……”\n孔昭绶也急了,走到刘三爹身边:“老人家,老人家……”\n刘三爹仿佛这才从恶梦中惊醒过来,一把抓住孔昭绶的手,“校长大人,校长大人……”他正要把话说完,原本藏在方维夏身后的刘俊卿忽然听到父亲的声音,大吃一惊,抬头看了一眼,吓得赶紧又低了回去。\n“张校医,王子鹏,你们在这里照顾一下老人家,有什么事随时告诉我。”孔昭绶当着刘三爹的面,不方便处理刘俊卿的事,“维夏,我们去校长室再说。”\n来到校长室,方维夏把考场上从刘俊卿手里当场缴获的笔记本交给孔昭绶。孔昭绶猛然想起前些时候老师们纷纷反映,刘俊卿在上与考试内容无关的课时,总是背笔记。袁吉六那次闹得最凶,老先生回到教师办公室仍然气得吹胡子瞪眼,黎锦熙出来开解:“一师的记分方式改了没错,不再唯分数论,教育司录取公务员还是考考考分数是法宝,我们这些做老师的,不去体谅学生,反而责怪他们视分为命,于心何忍?”\n想到这里,孔昭绶的脸色稍稍缓和下来,对刘俊卿说:“通知你的家长,下午来学校。”孔昭绶觉得,刘俊卿这一次的作弊行为,错误性质非常严重,但也并非事出无因,应该跟家庭教育方式有很大关系,有必要进行沟通,再下处分决定。\n刘俊卿低头站在角落里,神经质地咬着嘴唇,一言不发。\n方维夏忍不住推了推他:“校长的话你听到没有?”\n刘俊卿似乎这才回过神来,慌慌张张地说:“我……我爸爸不在家。”\n“那就明天来。”\n刘俊卿不再出声了。\n孔昭绶提高声音,“怎么了,难道明天也不在吗?”\n“我……我爸爸在外地做生意,平时都不在家。”刘俊卿千方百计找理由搪塞。\n方维夏也看不过去了:“刘俊卿,到底是不在家还是你不愿意叫家长来?”\n刘俊卿又开始一言不发。\n孔昭绶涵养再好也忍不住了,他猛地打开房门,向门外一指:“刘俊卿,你必须找你的家长来,因为,按照校规,你将被开除!”\n几乎是下意识的,刘俊卿顺着孔昭绶手指的方向,走出房门,忽然,他愣住了,父亲竟然站在门口,全身都在颤抖,老泪纵横。刘俊卿无法面对父亲,更无法面对身份即将揭穿的难堪,他低头着,加快脚步,从父亲身旁逃也似的跑开。\n“孔校长,对不起,我……我拦不住刘老伯。”王子鹏急着跟孔昭绶解释。\n“老人家,您有什么事慢慢说,不用急……”孔昭绶一句话还没说完,“扑通”一声,刘三爹直挺挺跪倒在地。\n“老人家,您这是干什么?”孔昭绶、方维夏、王子鹏都大吃一惊,赶紧扶住刘三爹。\n刘三爹怎么也不肯起来:“我求求您,校长大人,您不要开除他好不好?我求求您,求求您放过他一回……”刘三爹一边说一边拼命地磕头,额头在地上碰得砰砰直响!\n“老人家,您先起来说话,先起来啊。”\n“我不能起来,您不答应我,我不能起来啊……”刘三爹此时只有一个念头,要求得孔昭绶答应为止。\n几个人一齐用力,总算把刘三爹架了起来,孔昭绶问他:“老人家,您这到底是为谁求情啊?”\n“刘俊卿啊,就是您那个学生刘俊卿啊。他还小,他不懂事,他不是有心要犯错的,您大人大量,就饶他这一回吧,我求求您了!”刘三爹的额头已经磕出血来,触目惊心。\n孔昭绶问:“您为什么替刘俊卿求情?他是你什么人啊?”\n“他……”刘三爹差点冲口说出他跟刘俊卿的关系,但刚才他又是磕头又是求情的闹,四周已围上不少看热闹的人,想起儿子是最要面子的人,不禁语塞,“他,他……不是我什么人,我不认识,不认识的……”\n“不认识您为什么来替他求情?”\n“我……我……我就是觉得他是个读书的料子,就想求您给他个机会,给他个机会……校长大人,求您了……”\n那一刻,孔昭绶与方维夏的心头,不禁全是疑云。\n四 # 那天夜里,孔昭绶约了方维夏,按照刘俊卿学籍单上的家庭住址,一起去做家访,却在刘家门外的小巷里,正好遇上了也来探望刘三爹的王子鹏。\n师生三人一同寻到刘家门口时,刘三爹也正倚在床头,苦口婆心劝儿子:“俊卿,算我求你,去认个错吧。我看你们校长是个好人,不会不给你机会的。俊卿,去求求他,明天就去,好不好?”\n刘俊卿背冲着父亲,却是死不开口。\n“你怎么就不听话呢?”刘三爹咳得喘不过气来,秀秀赶紧拼命地抚着他的后背,尽量帮他顺气:“爸,您别说了,说这些有什么用?”\n“家里现在这个情形,不读一师,俊卿还能上哪儿去读啊?”刘三爹心一急,牵动了病情,又开始不停地咳嗽,“好歹也读了两年了,总不能白读了不是?”\n秀秀一边帮父亲捶背顺气一边心疼地说:“爸,歇歇好吧,为了哥,您都熬成什么样了?”\n“我不怕,我怎么都熬得住,我只要俊卿有出息。”\n“可我心疼!我也想哥有出息,可出息也要自己把得住,不能拿您的命来换啊!”\n“够了!”刘俊卿听着父亲和妹妹的对话,一字一句,都像刀扎在心口上一样,“你们说够了没有,啊?说够了没有?是,我没出息,我自找的,我混蛋!可我愿意这样吗?你以为我不想好好读书?我也想!我也想出人头地,我也想光宗耀祖!我也梦想有一天,自己有大好前程,到那个时候,爸不用再卖臭豆腐,你也不用再给人当丫头,咱们刘家都能过上好日子,都能挺直腰杆做人!可做梦有什么用?有什么用啊?”\n刘三爹和秀秀都被吓呆了,秀秀扶着父亲,看着刘俊卿踢翻凳子,狂乱地挥舞着手臂想要抓住些什么,想要与虚空中的命运拼命,但最终,还是两手空空。\n刘俊卿越说越癫狂:“这个世道就是这样,卖臭豆腐的儿子就是卖臭豆腐的儿子,我不是你那个王少爷,天生的好命,要什么有什么,我只是个穷卖臭豆腐的儿子,穷买臭豆腐的儿子!没有人看得起我,没有人会给我机会,哪怕是给了,老天也抢走它——老天爷也知道,我就是个穷卖臭豆腐的儿子,我没有别的选择啊……”\n说到这里,刘俊卿已经撑不住了,颓然坐在地上,全身犹如散了架一样,什么也没有了,房间里安静得只听得到呼吸声。\n正在这时,吱呀一声房门声响,刘俊卿吓了一跳,抬头看时,孔昭绶、方维夏,还有王子鹏正站在门前!\n三人打量着整个房间,除了破败还是破败,唯一与这破败格格不入的,是刘俊卿脚上那双蹭亮的皮鞋。\n孔昭绶不禁长长叹了口气……\n从刘家回来后,孔校长一直在想该如何处理刘俊卿作弊、如何帮助刘三爹度过目前的难关。刘三爹自从生病后,身体大不如前,已经不能风里雨里外出摆摊了,但他如果不做事情,家里的生活就无以为继。经黎锦熙提议,孔校长决定请刘三爹来学校做校役,这样从吃到穿的问题就都解决了。刘三爹对孔校长又给他送医药费,又给他安排事做感念不已。当然最让他感动的,还是孔校长能让刘俊卿继续回学校读书。\n“学校嘛,也只是不想随便放弃一个学生,希望能给每一个年轻人一个改正错误的机会而已。刘俊卿,经过这次的事,我希望你能真正认识到自己的错误,不辜负学校,特别是不辜负你这位含辛茹苦的老父亲。可怜天下父母心啊,你要明白,要不是为了他这番苦心,学校是绝不会给你这次机会的。”\n孔校长的这番话刘俊卿是完全听明白了的,他在接受了开除学籍、留校察看的处分后,被安排回到了本科八班。\n一个星期后,刘俊卿重返校园,只见校园内外装饰一新,“第一师范讲习进修班毕业典礼”的横幅,高高悬挂在礼堂正中。通往礼堂的路上,八班的同学们身穿整齐的校服,一边走一边兴高采烈地讨论些什么。刘俊卿忙迎上前去,在脸上堆出笑容打算跟他们打个招呼,才走了不过两三步,同学们看到他,原本热闹的气氛一下子消失了,纷纷加快脚步,远远绕开他。\n刘俊卿不得不停下脚步,远远地站在一边,在那群人中寻找着熟悉的身影,终于,他看到了王子鹏,很显然,王子鹏也看到了他。\n刘俊卿欣喜若狂,踮起脚,挥起右手,刚要喊王子鹏的名字。就在此时,王子鹏一侧身,避开他的目光,抢在他开口之前叫道:“周世钊。”挽住周世钊的肩,很快融入人群。\n刘俊卿木然地继续走着,今天的毕业典礼,所有老师也来了,纪墨鸿走在最前头,满脸是笑。刘俊卿精神一振:“老师……”他才吐出这两个字,纪墨鸿却扭过了头,仿佛眼中没看见这个人,又仿佛从不认识他刘俊卿,迈着方步从他身边走了过去。\n进到礼堂,刘俊卿悄然在最后一排找了个位置坐下。讲习科的毕业生们都坐在第一排正中,老师们反而坐在了两旁。偌大的礼堂里,座无虚席,掌声如雷,毕业生正按孔昭绶读出的名字,次第走上讲台,领取毕业证。\n“……讲习科第二名毕业生:何叔衡!”掌声中,何叔衡上台,向孔昭绶鞠躬,接过毕业证,转身向台下师生鞠躬,最后面向校旗九十度鞠躬。\n孔昭绶拿起最后一份毕业证:“讲习科第一名毕业生:萧子升!”\n刘俊卿猛然抬头,主席台上,萧子升正从孔昭绶手里接过毕业证书,台下,杨昌济,徐特立,袁吉六,还有毛泽东,蔡和森,都在鼓掌。刘俊卿暗暗咬了咬嘴唇,低头悄然离开了礼堂。\n刘俊卿一个人在学校里漫无目的地乱走,他知道他现在只有忍,但他无法抑制自己心中的失落和恨意,礼堂内的掌声还在一阵接一阵,仿佛像一把刀,在一点一点的刺他的心,一种尖锐的疼痛瞬间传遍了全身。他握紧了拳头,一拳击在一棵老槐树上。\n这时忽然有脚步声传来,刘俊卿回过头,远远见何叔衡、萧子升、蔡和森和毛泽东四人一面说笑,向这边走来。他立时向树后一闪,只听毛泽东笑说:“我们同学终于有人有收入了,子升进了楚怡小学,叔翁你呢?”\n“修业小学。”何叔衡答道。\n“好好,都离长沙不远,以后没饭吃,就去吃你们的大户。” 毛泽东大笑说。\n“还是那句话,有我萧子升一口,就有你毛泽东一口。” 萧子升肃然说。\n蔡和森在一边沉吟一时,说:“虽然叔翁和子升兄毕业了,可我们读书会的活动还得继续,叔翁和子升兄,仍然是我们读书会的一员,每次活动,没有特别理由不得缺席。”\n何叔衡忙说:“求之不得。”\n四个人一路说话,全没有在意到刘俊卿,直走了过去,远远只听萧子升问,“润之兄,马上就放暑假了,你有什么计划没有?”\n“我跟张昆弟约好了,这个暑假留在长沙读书,至于住宿问题嘛——”毛泽东嘿嘿一笑,“当然是去蔡和森家打秋风啰。”\n刘俊卿从树后走了出来,他冷冷地看了四人的背影一眼,握紧了双拳。\n第十四章 纳于大麓 烈风骤雨弗迷 # 风,浴我之体,\n雨,浴我之身,\n烈风骤雨,\n浴我之魂!\n山川在我脚下!\n大地在我怀中!\n我就是这原野山川之主,\n我就是这天地万物之精灵!\n一 # 暑假的第一个星期天,陶斯咏满20周岁。因为是整生日,中国又素来有男做单女做双的规矩,陶会长决定为女儿大肆操办一番。\n多方打听之后,得知德国洋行那里新来了一个做西餐的西洋厨师,会做很精巧的叫什么生日蛋糕的西式点心。陶会长亲自把这人请到家里,忙碌好几天,做了一个一米多高的九层大蛋糕,每一层除了雕花奶油之外,还装饰了各式时令水果。陶斯咏和向警予也是第一次看到这么大的生日蛋糕,看了好半天之后,斯咏才说道:“爸,其实也就是个生日,用得着那么讲究吗?”\n陶会长呵呵笑着说:“我的女儿满20,怎么能不讲究呢?再说,你姨父姨母和你表哥也要来给你过生日,总还要给他们面子嘛!”\n“我过生日,关他们什么事?”\n“你以后总归是他王家的人嘛……”陶会长看见斯咏拉下了脸,赶紧收口,“不说了不说了,反正啊,这个生日,得给你过热闹了。”\n斯咏却突然想起了什么:“爸,我能不能另外请几个朋友来参加?”\n陶会长笑着说:“那有什么不行?人多热闹嘛!”\n“这可是你说的。”\n“只要你愿意,有多少请多少。你就是把全校同学都请来,我也给你开流水席。”\n“哪有那么夸张!就……”陶斯咏看看站在一旁的向警予,“就两个。”\n“是哪家的小姐?我这就叫人送帖子去。”\n“不用了,这两个人,我跟警予亲自去请。”\n陶斯咏拉了向警予就走,陶会长追在后面喊:“记得早点回来,晚上等你开席呢。”\n出了陶家大门口,警予问斯咏:“哎,你到底要请谁呀?”\n斯咏冲她一挤眼睛,悄悄说:“蔡和森和毛泽东。”\n“你疯了,你陶大小姐过生日,请两个外校男生到府,就算你爸不说,你那未来的公公、婆婆会怎么想啊?”\n“我偏要请,管他们怎么想。”\n“好,你请你请,可想请也得找得到人啊,现在都放暑假了,这么大个长沙,你上哪儿去找一个毛泽东?”\n斯咏却是一笑:“这我早就打听清楚了,这个暑假,毛泽东住在刘家台子蔡和森家读书。”\n二 # 毛泽东的确是和张昆弟早已约好了暑假留在长沙读书,两个人都没有租房的钱,只能相约借宿蔡和森家。可当萧三清早帮张昆弟送行李到蔡家,才知道蔡家已经连饭都没得吃了,原来租的三间房,也退掉了两间,连蔡和森自己都没地方住。萧三和张昆弟拿着行李,只得回到子升任教的楚怡小学。\n子升听他们解释了半天之后,问:“润之呢?”\n张昆弟说:“他说他下午动身,现在估计快到蔡家了吧?”\n子升沉吟了一下:“昆弟,你就先在我这儿住下。咱们分头出发,多找几个朋友,尽量凑点钱,到蔡家去。”\n这边子升在忙着想办法,那边毛泽东却还蒙在鼓里。在学校吃过午饭,他兴致勃勃地过了湘江,来到溁湾镇,找到了镇子最南边的蔡家。\n进门看时,却见蔡家正在搬家,狭小的房间里,中间搁了一张床,四周被家具书本杂物堆得满满当当,几乎连转身的地方都没有。葛健豪和蔡畅正在里面收拾。毛泽东连忙放下行李卷,一边帮着做搬运之类的重活,一边问道,“伯母,蔡和森呢?”葛健豪犹豫的工夫,蔡畅已经代为回答了,“我哥搬到爱晚亭去了。”毛泽东当即明白了,也不说话,只搬着东西。\n毛泽东帮完忙时间已近黄昏,他扛着行李卷,直奔岳麓山而来,沿石径而上。天气极是闷热,空中云层越积越厚,直从远处绵延的山峦之间纷涌过来,山道上蜻蜓四处乱飞,毛泽东忖度着要下大雨,不由加快了脚步。\n爱晚亭内,一座旧草席铺在正中地面上,亭栏上一竹篮子的书,旁边是叠得整整齐齐的几件简单衣物。两根亭柱间拉着一条麻绳,蔡和森正在将刚刚洗过的一师校服晾上绳子。大概是熟悉了的缘故,几只胆大的小鸟叽叽喳喳,在他不远处自在地觅食。毛泽东童心忽起,身子猛然向前一冲,鸟儿们拍起翅膀,扑啦啦飞上半空,他这才大声说道:“远上寒山石径斜,白云深处有人家——蔡隐士,好个首阳遗韵,夷齐之风啊!”\n两个人不禁相视一笑。\n山风之中,蔡和森帮着毛泽东铺开了行李:“让润之兄陪着我露宿山野,对不住了。”\n“天当房,地当床,清风伴我好乘凉。好得很嘛!”毛泽东往铺盖上一躺,双手往脑袋后面一背,“不到这山野中露宿一番,哪里享受得到这夏夜清凉,体会得到这天人一体的境界?”\n“你还别说,昨天在这儿住了一晚,仰头苍茫无尽,低头群山巍巍,着实是大开心胸啊。就是有一点不好。”\n他话音未落,两个人的肚子里咕噜噜响起一阵饥肠之声。\n两个人哈哈大笑起来。\n就在此时,一道闪电骤然划破长空,紧接着轰然一声,惊雷骤起,大雨不期而至,天色也刹那间暗了下来。顿时莽莽岳麓笼罩在一片倾盆大雨之中,雨水如帘,从亭檐直垂下来,被风一吹,一扫酷热烦闷。\n蔡和森手忙脚乱收拾着衣物书籍,毛泽东将双手伸在雨中,感受那份雨水冲刷的凉爽和快意,还是觉得不过瘾,遂回头叫道,“唉,老蔡,想不想去爬山?”\n“爬山?”\n“对啊,趁着这满山夜色归你我所独享,烈风骤雨中,凌其绝顶,一览众山,岂不快哉!”\n望了望亭外密密麻麻的雨点,再看看毛泽东跃跃欲试的眼神,蔡和森腾地站了起来:“去就去!”\n毛泽东一把握住了他的手:“走!”\n两个人一步冲出亭去,惊雷闪电中,大雨一下子浇了他们满身。\n“雨中的岳麓,我们来了!”\n忽然,一道闪电,似乎把前面的天空划开了一道口子,片刻之后,惊雷在他们身后响起,毛泽东大笑,“老蔡,我们快些跑,看是这闪电快,还是我们快。”二人顿时狂奔起来,只听毛泽东的声音在大叫“老蔡,我们来喊吧,看是这雷声大,还是我们的喊声大!”\n“啊……啊……”山道上,湿透的毛泽东和蔡和森长啸狂奔在雨中,喊声划破雨夜,直震长空,仿佛两个狂野的斗士,完全融入了雨中的自然。\n“润之!”“润之哥!”“蔡和森!”风雨中,隐隐有无数声音传来。\n蔡和森停下来,拉住毛泽东,“有人在叫我们?”“好像有很多人?”二人顺着喊声直奔回去,只见萧子升、萧三、张昆弟、陶斯咏、向警予,甚至蔡畅也来了,站在爱晚亭里焦急地张望,蔡畅急得直跺脚。向警予倒也罢了,平日里斯文含蓄的陶斯咏鞋袜、裙摆全已湿透,斑斑点点溅满了黄泥。看到他们二人从树林里钻出来,陶斯咏这才放下那颗一直悬着的心。\n“你们怎么来了?”毛泽东问。“来找你们啊,今天是……”向警予刚要说话,不料却被萧子升打断,“还问我们,这么大的雨,你们这是上哪里去了?”“爬山啊!”“爬山?”“对啊,刚从山顶下来。”毛泽东似乎意犹未尽。\n“大风大雨的,爬山?你们搞什么名堂?”萧子升问道。原来,毛泽东那边前脚离开蔡家,萧氏兄弟和张昆弟后脚也凑钱赶到了蔡家,待安顿好了蔡家断炊的事,却正撞见陶斯咏、向警予来找毛蔡二人去庆祝生日,得知他们二人在爱晚亭,便一齐找上山来。\n“大风怎么了?大雨怎么了?古人云:纳于大麓,烈风骤雨弗迷!今天,我和蔡和森算是好好体会了一回!老蔡,你说是不是?”毛泽东回过头问蔡和森。\n“没错!风,浴我之体,雨,浴我之身,烈风骤雨,浴我之魂!” 蔡和森一扫平日的沉稳。\n“说得好!”向警予情不自禁,放开嗓子大喊一声,“说得好!说得太好了!”\n毛泽东大踏步重新回到雨中,“来呀,还站在那里做什么?来体会一下,体会这风,体会这雨,享受这大自然的畅快淋漓!”蔡和森也在喊着,“来呀,都来呀!”\n向警予一阵面红耳热,第一个扔掉雨伞,大雨一下子浇在她身上,一阵畅快的清凉袭遍全身,她仰起头,迎接着雨水,纵情高呼:“舒服,真的很舒服!你们快来啊,都来试试!”\n萧三、张昆弟和蔡畅也深受感染,一个接着一个,扔掉雨伞,抛开一切束缚,冲进雨中大喊大叫。\n陶斯咏看着雨中兴奋不已的朋友们,千金大小姐的矜持正慢慢从她身体里远去,她迈出子升为她撑着的雨伞,冲进了雨中。大雨冲刷着她的身体,她仰起头,伸出双手迎接着雨水,似乎要把这20年来一直束缚着她的东西全部冲走,感受到那股从灵魂深处彻底解放出来的自由。她轻轻舔了舔嘴角的雨水,雨水竟然是咸的。不知何时,束缚的泪水、放纵的雨水已经混为一体,已分不清哪是泪水,哪是雨水。\n“山川在我脚下!大地在我怀中!我就是这原野山川之主,我就是这天地万物之精灵!”毛泽东大喊着,一手抓住斯咏的手,另一手握住了蔡和森,“来呀,一起来呀,跟我一起喊,风——雨——雷——电——”\n苍茫的原野上,青年们充满了自由力量的长啸狂呼声,应和着原始、野性的自然之力,刺破夜空,在电光飞闪中,如疾电破空、惊雷掠地!\n三 # 陶府上上下下寻了整整半夜,差点把雨中的长沙城翻了个遍,才从码头附近撑渡船的船夫那里打听到,天擦黑的时候,有两位小姐坐他们的船过了江,说是要去刘家台子,听衣着打扮,应该就是斯咏她们。\n陶会长领着家人、仆役,心急如焚地过江寻来,狂风渐弱,雷电渐息,刚过了溁湾镇,却听到一阵吟啸声直撼而来:\n我本楚狂人,凤歌笑孔丘。\n手持绿玉杖,朝别黄鹤楼。\n五岳寻仙不辞远,一生好入名山游。\n庐山秀出南斗傍,屏风九叠云锦张。\n影落明湖青黛光,金阙前开二峰长。\n银河倒挂三石梁,香炉瀑布遥相望……\n来的正是毛泽东等人,他们从岳麓山上一路狂呼长啸,吟诵而来,刚刚下了山,迎面忽然是一片火光通明,写着大大的“陶”字灯笼一排列开,众多仆役恭恭敬敬地齐声叫道:“小姐。”把大家都吓了一跳。\n萧子升抬头看见陶会长板着脸,站在众多仆役的最前面。他再回头找到陶斯咏,看到她正悄悄缩回一直被毛泽东拉着的手。\n陶会长深吸一口气,竭力压住心头的怒火,放缓了语气问:“斯咏,这几位是?”\n“我的……几个朋友。”陶斯咏忐忑不安,但有些不甘心,特地跟毛泽东介绍,“这是我爸。”\n陶会长打量着这群人,个个身上滴着水,鞋袜衣裙,到处溅着泥点。\n“斯咏,今天你生日,你姨父姨母一直在家等着给你过生日呢,先回家吧。”陶会长说。\n“今天你生日?”毛泽东有些意外。斯咏点头。“你看你怎么不早说?都没给庆祝一下……”“斯咏。”陶会长打断毛泽东,脸上的微笑快保持不住了,“走吧。”又说道,“谢谢你们几位送斯咏,我们先走一步了。”\n陶斯咏跟着父亲走了几步,忽然回过头来对毛泽东说道:“谢谢你,也谢谢大家,让我过了一个有生以来最有意义的生日!”\n四 # 斯咏的背影随着马车渐渐远了,大家怅然若失,兴奋过后疲倦袭来,打算各自散去,萧子升问道:“警予,你去哪里?周南好像现在关了门。”\n向警予笑笑说:“我现在无家可归了,你们谁收留我。”众人都呆了一呆,大家一群光棍,如何收留一个女孩子。蔡畅想了想笑着说:“去我家吧,只是太挤。我、我妈还有你三个人一张床。警予姐你习不习惯?”\n向警予笑着回答:“我无所谓,只怕太打扰了。” 毛泽东笑笑:“就这样定了,老蔡负责把两位女士送回家,我还是到爱晚亭当亭长去。”\n蔡和森、蔡畅陪警予一路回了蔡家,蔡畅一阵风似的蹦进屋来:“妈,我们回来了。”\n葛健豪正在看书,一抬头,却见神采飞扬的儿子身边竟然有一个明眸皓齿的少女,落落大方地望着她。\n“妈,这位是向警予小姐。”蔡和森倒是没有丝毫扭捏。警予甜甜地叫了声“伯母”,目不转睛地望着葛健豪。只见她虽然穿一件粗布上衣,眼角爬满皱纹,但一双眼如一泓深潭,深邃宁静,而举止之间,自然显出一种优雅沉静,仿佛天然生成的一般,全无半点的矫揉造作。\n蔡畅换好了衣服,笑嘻嘻地拿了一套自己的衣服递给警予,葛健豪笑了笑,“你的衣服能穿啊?”丢了套衣服给儿子,“把门关上,出去换了。”蔡和森再进屋顿时眼前一亮,松烟灯下,警予穿着一件衣料华美、刺绣精致的老式大红旗式女装,映红了她白净的脸蛋,越发衬得眉目如画,娇艳无比。葛健豪打量着警予,多年不穿的嫁衣倒也找到了个好衣架子,欣赏地笑了:“真像我年轻的时候啊。”蔡畅拍手叫道:“好漂亮,好漂亮,警予姐穿上妈的衣服,就像个刚出嫁的少奶奶。” 警予眼角瞟到呆子般的蔡和森,终于也羞涩起来,她有些慌乱地拿起了葛健豪放在破木桌上的书——那竟是一本雪莱的诗集!\n“伯母,您在看这本书?”警予惊讶地问,葛健豪微微一笑,算是承认,“跑了半晚上,都饿了吧?晚上就吃山芋煮野菜,家里没什么别的东西,委屈向小姐了。”\n“挺好啊,我正好尝尝鲜嘛。”\n吃饭时警予悄悄扫了一下四周,狭小的房里,家具杂物并不多,都已破旧,触目所及到处是书。葛健豪一边看书一边吃饭,夹到了一块山芋,顺手放进了蔡畅碗里,又夹起野菜送进嘴里。警予看得呆了,想起刘禹锡那老夫子的话:何陋之有啊?!\n吃过了饭,夏日雨后的夜空,清亮透明,清风过处,警予的心如微波浮动。她第一次安安静静地坐在蔡和森身边,听他娓娓道来。\n“我妈妈原来不叫葛健豪,叫葛兰英。我外公是曾国藩的一员部将,做过道台,所以我妈也算大户小姐出身。年轻的时候,她和鉴湖女侠秋瑾、同盟会的第一位女会员唐群英曾经是非常好的朋友,三个人还结拜过姐妹呢。”\n警予睁大眼睛望着他,秋瑾、唐群英?蔡和森微微一笑,继续说道:“16岁的时候,我妈嫁给了我爸,成了湘乡大财主蔡家的少奶奶,你身上穿的这件衣服,就是她出嫁的嫁衣。后来呢,她就生了我们。我小名叫彬彬,老家的人都叫我彬少爷。”\n警予疑惑地望着他,欲言又止。\n蔡和森看出她的疑惑,笑了笑:“你是想问现在怎么会这个样子?是吗?简单说起来,因为我妈跟我爸不是一路人。我妈妈爱读书,个性也强,她相信男女应该平等,相信社会一定会进步,相信女人也能成为社会的栋梁,所以我妈跟我爸的关系一直不好。后来,我爸到上海,学会了抽鸦片,还讨了小老婆,我妈就跟他彻底闹翻了。两年前,我爸做主,收了一个财主家500块光洋的聘礼,把我妹妹许给那家同样抽鸦片烟的儿子,我妈妈坚决不同意,就跟我爸离婚了。”\n警予简直不敢自己的耳朵:“离婚?”\n“不敢相信是吧?在那样的封建家庭里,一个女人,居然主动提出离婚!简直就是大逆不道,伤风败俗。我爸当然不答应,就提出条件,除非我妈妈放弃一切家产,一分钱也不带走。他肯定觉得,像我妈这样做了半辈子少奶奶的家庭妇女,一旦离开夫家,绝不可能生存下去,所以就用这样的条件挟胁我妈。”\n“但伯母偏偏就答应了。”警予慨然叹道。\n蔡和森笑了:“做了半辈子夫妻,我爸还不如你了解我妈,她一点也没有犹豫就答应了,带着我们兄妹,就这么空着两手,离开了那个家。”\n“所以你就从彬少爷,变成了现在的蔡和森?”\n“能够跟妈妈在一起,还有什么是不能放弃的呢?你知道吗?就靠那双手,妈妈养活了我们兄妹,供我们读书,她自己还半工半读,进了女子教员养成所,成了全长沙年龄最大的学生。就是在进校那天,她改成了现在的名字——葛健豪。”\n两个人幽幽地吸了口气,灯光从窗口透出,葛健豪的影子投在窗纸上,她正在补衣服,警予觉得她补衣服的影子都透着难以言表的高贵!\n警予突然握住了蔡和森的手:“你知道吗?以前,你一直是我的偶像。今天我才知道,为什么你会成为我的偶像。”\n“为什么?”\n“因为你有这样一个好妈妈。”\n五 # 一夜大雨之后,清晨柔和的阳光照在爱晚亭外垂柳的叶尖上,雨珠晶莹剔透,耀出七彩的光。池塘胀满,燕子直掠而过,歇在亭子的檐上呢喃。\n杨昌济一脚踏入爱晚亭,毛泽东兀自睡梦正酣,手脚袒露,被子也被踢到一边。杨昌济在他身边轻轻地站住,俯身下身来看着他,一年多以来,他对这个小伙子越来越欣赏,隐隐觉得在他的身上担负着自己一生中未竟的理想,他不敢说从他身上看到了国家的希望,但可以肯定地说,他看到了这个时代的希望。他是一块尚未琢磨的宝玉,而自己,是琢玉者。\n毛泽东隐隐感觉有个影子挡住了阳光,睁眼一看又惊又喜:“杨老师?”\n“要不是子升告诉我,你是不是打算在这亭子里当一假期野人啊?” 原来暑假来临,杨昌济嫌城市喧嚣,打算回到老家长沙县板仓乡下,临走时萧子升前去道别,才知道毛泽东在爱晚亭睡在“天地之间”,又好气又好笑,决定把这孩子带到乡下,也让他安心读书。\n毛泽东翻身起来,搔头笑了笑,赶紧手忙脚乱收拾东西。\n板仓离长沙不远,一上午工夫便到了。一路稻浪如海,随风而起,毛泽东随杨昌济转过一座小桥,远远便见一座大宅子隐于绿树之中,青砖鳞瓦,阳光照过来,屋后丘陵绵延起伏,四处寂静一片。\n先走进门来的杨昌济一边回头招呼着还站在门外的毛泽东:“愣着干嘛?进来吧。”一边给妻子向仲熙和儿子杨开智介绍道:“我的学生,毛润之,你们都听我提起过的。润之,这是你师母。”毛泽东扛着行李走进门来,赶紧鞠躬问好。向仲熙看着这个高大而羞怯的年轻人微笑着点点头。\n杨昌济随即问道:“对了,开慧呢?”\n向仲熙说道:“谁知道又上哪儿疯去了?这丫头,一天到晚也没个消停。”\n杨昌济也不以为意,向毛泽东一挥手说:“润之,跟我来。”他径直把毛泽东带到书房,“这个暑假,你就住这儿了,我这儿也没什么别的,就一样有你看不完的书。”毛泽东顿时眼睛都直了——偌大的书房里,重重叠叠,一架一架,一层一层,全是书,毛泽东上前抚着一层层的书本,贪婪地伸过头去,双眼圆睁,恨不能一下子把它们看个仔细。\n杨昌济笑说:“生活上需要什么,只管跟你师母说,她会给你准备的。”\n“不不不,什么都不要,” 毛泽东兴奋得有点语无伦次,“有这些就够了,什么都够了,都够了都够了。”他把行李卷随手往地上一扔,抽出一本书,往行李上一坐,迫不及待地翻了起来。\n杨昌济微笑带上门出来,只听他吩咐向仲熙:“仲熙,从今天起,多做两个人的饭。”\n“不是就一个客人吗?” 向仲熙一怔。杨昌济只一笑,说:“照我说的做,没错的。”\n毛泽东全不理会,在那里看书。不知道过了多久,眼前的书上,突然扫过一条辫梢,毛泽东一抬头,一双清澈见底的眼睛正盯着他。他眯了眯眼,一个扎着小辫的小姑娘,十四五岁,托着俏生生的圆脸,带着好奇和挑衅。\n毛泽东奇怪地问:“你看什么?”“看你呀。”“看我什么?”“看你的眼睛。”“我的眼睛?”\n“看看跟一般人的有什么不一样,看看我爸爸为什么会说有个学生眼睛怎么怎么明亮啊,有神啊,坚定啊,藏了好多好多远大理想在里头啊。”开慧夸张的表情把毛泽东逗笑了,他捏了捏她的鼻子,“哦,我知道你是谁了,杨开慧,我的小师妹。”开慧头一偏,也伸手一捏他的鼻子:“我也知道你是谁。毛泽东,我爸爸最喜欢的学生。”\n两个人同时说道,大笑起来,好像久别重逢的好朋友。开慧拉着他,“快走吧,我是来叫你吃饭的,你看书的时候爸爸不让我过来呢。”\n饭桌上毛泽东的表现让杨家人开了眼界,捧着一只大得吓人的海碗,狼吞虎咽,吃得啧啧有声。开慧惊奇地盯着毛泽东的吃相,他第一碗很快见底,到饭甑边抄起大饭勺,一连几下,他居然又堆了满满一海碗饭,饭桶一下子空了大半。开慧目瞪口呆,向仲熙却看着杨昌济会心一笑。\n毛泽东回头这才发现发现大家都看着自己,当下里端着大碗,有点不好意思起来。向仲熙连忙夹上一大筷子菜,放进了毛泽东的碗里,笑道:“快坐下吃,润之,我呀,就喜欢看你们年轻人吃得多,吃得多,身体才好嘛,就跟在自己家里一样,别客气啊。”\n一连十几天,毛泽东都呆在书房,也不管好歹,书架上的书,摸了一本就读,读罢便放在左手边,一时那里的书越堆越多。这一天他正看得出神,忽然一只纸折的蛤蟆放到了他的头上,回头见开慧笑嘻嘻地站在他身后。他哈哈一笑,抓下头上的纸蛤蟆:“没有天鹅肉吃,我可不愿意当癞蛤蟆。”\n开慧伸手给他,“来,咬一口啊。”\n毛泽东笑说:“哦,我把小师妹吃了,老师还不得找我算账?”\n开慧哼一声说:“谅你也不敢!”她靠在毛泽东身边坐下:“看什么呢?”伸手把书拿了过来,“《诸葛亮文集》?早就看过了。”\n“你才多大,就看《诸葛亮文集》?”\n“谁说我小啊?下学期我都上中学了,看这个算什么?”\n“好好好,十四岁的大姑娘。那我抽一段考考你。”\n开慧急了:“我只说看过,又没说都记得。难道你看一遍就都记得啊?”\n“差不多。”\n开慧噜着嘴:“吹牛皮,我不信!”随手翻开一页,“《诫子书》,背呀!”\n毛泽东张口就来:“夫君子之行,静以修身,俭以养德,非淡泊无以明志,非宁静无以致远。夫学须静也,才须学也,非学无以广才,非志无以成学,淫慢则不能励精,险躁则不能治性。年与时驰,竟与日去,遂成枯落……”\n“好了好了,《出师表》!”\n“臣亮言:先帝……”\n“前面不要背,从中间开始。嗯,‘可计日而待也’,从这里开始。”\n“臣本布衣,躬耕南阳,苟全性命于乱世,不求闻达于诸侯……”毛泽东又是一口气背了下来。\n开慧不服气,要毛泽东翻出所有看过的书,一心要考倒这位师兄,不厌其烦地提着问题,考到《五灯会元》十八卷时,毛泽东终于错了一句,开慧哈哈大笑,叫道:“我赢了我赢了,你背错了要罚!”\n毛泽东也让着她:“好吧好吧,你说怎么罚,杨先生。”\n开慧眼珠一转:“这样,罚你明天陪我去抓鱼,不许反悔。”\n第二天一大早开慧便来了,扯了毛泽东便走,毛泽东无奈,只得随她出来。两个人背着钓竿,提着鱼篓出了门,沿溪而行,那溪水曲折,直行出数里,在一座山下汇成一个港汊。一湾绿水沿山势环绕,直向东折去,岸边绿草如茵,两个人在草地上坐了下来,放眼一望,小山如黛,稻浪翻滚,远处三两间茅舍点缀。清风徐来,吹着开慧的发梢,她一身乡下姑娘打扮,更衬出她清水芙蓉的脸蛋,煞是可爱。\n“怎么样,我们乡下漂亮吧?”开慧卷起裤管,把白嫩嫩的小腿伸进溪水里拨弄着。\n“这算什么?一般般。我乡下长大的,我们家那边,比这儿还漂亮!那个山,那个水——你是没看见过,比画上画的都好看!”\n“不可能。”\n“你还不信?史书上都有记载,当年舜帝南巡,经过我们那里,见山水灵秀,叹为观止,乃为之制韶乐。韶乐你知不知道?就是‘子在齐闻韶,三月不知肉味’那个韶乐!那么美的音乐,就是看了我们那里的山水才作出来的,所以,我们那里就叫韶山,你说美不美?”\n“是吗?”听他这么一说,开慧都有点悠然神往了。\n“小时候,每年这个时候,我就在我们家对面的山坡上放牛,一边呢,就捡柴、捡粪,捡完了,往山坡上这么一躺。”说着就往草地上一躺, “太阳一照,风这么一吹,舒服啊!”\n开慧学着他的样子,也在他身边躺了下来。\n“有空啊,我就和邻居家的小孩一起,挖笋子,捉泥鳅,爬到树上摘樟树果果,下到水塘里去捞鱼,夏天就游泳,春天就放风筝,反正名堂搞尽。”\n“这些我也玩过,不新鲜。”\n“新鲜的也有呀,比方我们那里,最有意思的,就是唱山歌,这边山上唱,那边山上的人就和,一问一答,看谁比得谁赢。那些山歌真的有意思,我到现在还记得。”\n开慧来了兴趣,支起了身子:“那你唱一个我听听。”\n“我唱得太难听了。”\n“难听就难听喽,又没有别人,唱一个嘛。”\n毛泽东坐起身来:“好,给你唱一个《扯白歌》,就是专门扯谎的歌,比哪个扯谎扯得狠些,怎么不可能就怎么唱。你听啊。”\n“生下来我从不唱捏白的歌,风吹石头就滚上哒坡喽。出门就碰哒牛生个蛋,回来又看哒马长个角喽。四两棉花它沉哒水,咯大个石磨子它飘过哒河喽……”\n毛泽东五音不全的嗓子唱起山歌来,不知在念还是在喊。\n黄昏的路上,开慧握着一把浅紫色的野菊花,脚步十分的轻快,一路想起毛泽东的山歌,忍俊不禁,很快到了家。两人一进门,毛泽东不禁愣住了:“老蔡,子升,你们怎么跑来了?”\n来的正是蔡和森和萧子升,杨昌济神情凝重地放下手里的一份报纸:“他们俩,是来送这份报纸的。”\n“谭都督被撤职了?!”一旁倒好了茶的开慧趴了过来,看看报纸的大幅标题,奇怪地问,“谭都督是谁呀?”\n子升回答说:“就是我们一师的老校长,湖南都督,谭延闿.”\n“那,谁把他撤了?”\n“除了袁大总统,谁还能撤一省之都督?”蔡和森回答着开慧的问题,但脸却对着毛泽东,“江苏撤了,浙江撤了,四川撤了,广东撤了,如今,又轮到我们湖南了,看来,不把中国各省的都督都换成只服从他的人,这位袁大总统是不会罢休啊。”\n开慧还是不明白地问:“可大总统不是比都督官大吗?都督本来就应该服从他嘛。”\n“开慧,这些事,你还不懂。”蔡和森说,“都督也好,大总统也好,服从的,都应该是中华民国的法律,可如今北方各省,都是袁世凯北洋系的人,如果南方的都督也换成了他的人,那中国今后,就没有法律,只剩下他袁大总统了。”\n子升接着说:“刺杀宋教仁,解散国民党,把持国会,修改约法,这两年,他袁世凯这个大总统的权力已经扩大都得没边了,他难道还不满足?他到底想怎么样呢?”\n“独裁!”一片沉寂中,杨昌济开口了,“他要的,就是独裁!”\n毛泽东与蔡和森都微微点了点头。\n子升不禁叹了口气:“总统独不独裁,我们也操不上心,我只担心,谭都督在,湖南还算过了几天安稳日子,可谭都督这一走,我们湖南,只怕从此要不得安宁了。”\n一旁的开慧没有见过这么严肃的场面,一直紧张地听着,听到子升的话,急了:“真的?那,那学校呢?学校不会出什么事吧?我下学期还要上周南去读初中呢。”\n子升安慰开慧,也算自我安慰:“学校当然不会有事。教育乃立国之本嘛,不管哪个当权,也不管他独不独裁,总不至于拿教育开玩笑。”\n蔡和森分析道:“那可难说。民权他可以不顾,约法他可以乱改,区区教育,在独裁者眼里,又算得了什么?”\n“要我看,也好!”一直沉默着的毛泽东语出惊人。\n“好?”子升没听明白。\n“对,好!”毛泽东扬声说道,“上苍欲使人灭亡,必先令其疯狂,他爱蹦跶,让他蹦跶去,等蹦跶够了,他的日子,应该也就到头了!”\n“可他这一蹦跶,中国就得大乱啊!”\n“大乱就大乱,治乱更迭,本来就是天理循环,无一乱,不可得一治!三国怎么说的,‘天下大势,分久必合,合久必分’。”\n“可是……”子升还要说,杨昌济却抬手止住:“世事纷扰,国运多舛,中国是否会乱,乱中能否得治,确实令人担忧。作为你们的老师,今天,我只想提醒你们一句话,不管时局如何发展,不管变乱是否来临,读书求真理,才是你们现在最重要的事。非如此,不可为未来之中国积蓄力量。子升、和森、润之,记住我的话,好好用功,为了将来,做好准备吧。”\n三人点了点头。“还有我呢?我也算一个吧?”开慧突然插了一句。\n师生们都笑了,毛泽东一拍她的脑袋:“要得,你也好好用功,做好准备,到时候,国家有难,就靠你这个花木兰了。”\n第十五章 五月七日 民国奇耻 # 天下兴亡,匹夫有责。何以报仇,在我学子!国家之广设学校,所为何事?我们青年置身学校,所为何来?正因为一国之希望,全在青年,一国之未来,要由青年来担当!当此国难之际,我青年学子,责有悠归,更肩负着为我国家储备实力的重任……\n一 # 1915年5月,长沙的天气渐闷热起来,空中积满厚云,阳光似乎努力想从云层里挣扎出来,渗出淡淡的光,投在洒扫得没有一丝尘土的火车站月台。\n月台上每隔不到一米,便肃立着一个荷枪实弹的士兵,沿铁轨迤逦向北一字排开。警戒线外挤满了湖南各界的缙绅士商,官员贤达,西装革履,长袍马褂,各色不一,一面大横幅扯开,上书“三湘各界恭迎汤大将军莅临督湘”,阳光折过来,将这一行金字和众人举着的彩旗映得人眼花缭乱。\n一声汽笛长鸣,一列火车自北缓缓驶进站来。半晌车门方才开了,从里步出一个人来,这个人年纪不过30岁,白净的脸上架着一付精致的细金丝眼镜,削长脸儿,眉目清秀,穿一身细绸布长衫,手里习惯地把玩着一串晶莹透亮的玉质念珠。姿态优雅,气质沉静。除了剃得极短、极整齐的日本式板寸头外,他全身上下,几乎找不到一点能和军人联系起来的痕迹。\n这个人就是汤芗铭,字铸新。湖北浠水人,新任的湖南布政使,督理湖南军务将军。汤芗铭17岁中举。曾留学法国、英国学习海军知识,精通多国语言和梵文、藏文,乃是学贯中西的佛学大家。\n汤芗铭才一下车,军乐声,欢呼声顿时响成一团。汤芗铭不觉微微皱眉,他一向崇尚佛道的清静无为,极为厌弃这种繁文缛节。这时军乐声一停,一个长袍马褂、白须垂胸的老头子捧着本锦缎册子,颤巍巍地迎了上来:“三湘父老、官民代表恭迎汤大将军莅临督湘。”旋即打开册子,摇头晃脑,“伏惟国之盛世兮明公莅矣,民之雀跃兮如遇甘霖……”\n汤芗铭看也没看老头一眼,边走边对身后的副官说:“收了。”言语轻柔,轻得只有那副官才听得见。\n副官伸手便把老头捧着的册子抢了过来,老头迟钝,一时还没反应过来,直叫道:“哎,哎!”\n欢迎的人群呆了一呆,顿时冷了许多,大家都不免紧张起来,伸长了颈看着汤芗铭。他却向人群旁若无人地直走过来,人群只得赶紧让开了一条路。\n汤芗铭走不过两步,突然站住了,轻声说道:“省教育司有人来吗?”\n后排人群里的纪墨鸿一愣,赶紧挤上前:“卑职省教育司代理司长纪墨鸿,恭迎汤大将军。”\n汤芗铭的神情一下子和蔼了起来,居然伸出手,说道:“纪先生好。”\n纪墨鸿受宠若惊,忙小心地握住汤芗铭的手:“大帅好。”\n汤芗铭淡淡一笑说:“有个地方,想劳烦纪先生陪我走一趟,可否赏个面子啊?”\n纪墨鸿慌忙答道:“大帅差遣,墨鸿自当效劳。”\n这时一个军官小心地凑过来,说道:“大帅,省府各界已在玉楼东备了薄宴,大家都盼着一睹大帅的虎威……”\n汤芗铭扭过头,看了他一眼,目光虽平和,却自然透着股说不出的不耐烦,硬生生地把那军官的半截话逼了回去。\n但一转头,笑容重又到了他脸上,说道:“纪先生,请吧!”\n纪墨鸿低声问:“不知大帅要光临何处?”\n汤芗铭淡淡说道:“敝人生平最服左文襄公,就去他当年读书的城南书院吧。噢,现在应该叫做第一师范。千年学院,仰慕久矣!”\n一行人浩浩荡荡直出了火车站向一师而来。其时虽然南北大战,但湖南得到谭延闿周旋,未经大的兵火,长沙城里倒也繁华。不过沿街各省逃难而来的难民也是极多,汤芗铭到来之前,城中军警已经是倾尽全力驱赶,却也驱之不尽。\n汤芗铭坐在马车上,手里摩弄念珠,长沙街景在他身后一一退去,但他心思全不在这里。\n1905年汤芗铭在巴黎结识孙中山,并经孙中山介绍加入兴中会,事后汤芗铭知道孙中山曾是三点会帮会首领,汤芗铭认为三点会是黑社会组织,因而反悔道:“革命我们自己革,岂有拥戴三点会、 哥老会首领之理。”于是汤芗铭到孙中山居住的巴黎东郊横圣纳旅馆取走入会盟书,向清廷驻巴黎公使孙宝崎自首,自此为革命党人所不齿。后来虽然有起义援汉的功劳,孙中山又宽宏大量,不计前嫌,但汤芗铭心中始终存有芥蒂。\n而袁世凯因他曾助孙中山,也对他心存疑忌,虽发布命令任命他为湖南将军兼民政长,执掌湖南军政大权;但并不放心,先是派亲信沈金鉴至湘掣肘其权;继之任命爱将曹锟为长江上游警备司令,命其率第三师进驻岳州严密监视汤芗铭举动。\n汤芗铭不是谭延闿,深知南北对峙,湖南地处要冲,北方军队南下首攻湖南,南方军队北上,也是一样。谭延闿所谓的湘人治湘,在南北之间中立无异于痴人说梦。他汤芗铭现在两边都不讨好,唯有乘着这第一次成为一方诸侯的机会,明里向袁世凯纳诚效忠,暗里在湖南扩充军队,到时候有大军在手,他就谁也不惧。\n但要讨好袁世凯也不是一件容易的事,火车上他反复权衡。\n1914年以来,“袁世凯要做皇帝”的传说越来越多。1915年初,日本向中国政府提出企图把中国的领土、政治、军事及财政等都置于其控制之下的“二十一条”。消息一经传开,反日舆论沸腾。1915年2月2日中日两国开始正式谈判,日本以支持袁世凯称帝引诱于前,以武力威胁于后,企图迫使袁世凯政府全盘接受“二十一条”,但迫于舆论,一直拖到了现在。最近传来消息,据说日本打算以最后通牒的形式来逼迫袁世凯接受条件。\n汤芗铭揣摩袁世凯的意思,欧美列强虽然反对“二十一条”,但现在身陷欧战泥潭,也只能说说而已。中国无力独自对抗日本,只能极力维护和日本的关系。只是国内舆论喧嚣,现在要做的,就是要压制舆论,舆论都掌握在读书人手里。因此汤芗铭下车伊始,便是直奔长沙两大千年学院之一的城南书院。\n孔昭绶等人早已得到消息,当下里带着众位老师出迎到学校的大门,却见汤芗铭已抢先抱拳招呼:“晚生汤芗铭冒昧叨扰,列位先生,有礼了。”\n“汤大将军大驾光临,有失远迎,恕罪恕罪。”孔昭绶赶紧还礼。\n纪墨鸿赶紧介绍说:“这位就是一师的孔昭绶校长。”\n汤芗铭含笑又一抱拳说:“久仰久仰。”\n孔昭绶笑说:“岂敢岂敢,大帅客气了。”\n汤芗铭闻言说道:“孔校长,芗铭能否提个小小的要求?”\n孔昭绶说道:“请大帅指教。”\n汤芗铭沉声说道:“城南旧院,千年学府,本为先贤授业之道场,湖湘文华之滥觞,芗铭心向往之,已非一日。今日有幸瞻仰,可谓诚惶诚恐,又岂敢在先贤旧地,妄自尊大?所谓大帅、将军之类俗名,还是能免则免了吧,免得折了区区薄福。”\n孔昭绶呆了一呆,“这个?”\n汤芗铭微笑说:“就叫芗铭即可。”\n孔昭绶倒不好再客气了,说道:“铸新先生如此自谦,昭绶感佩不已。”\n汤芗铭目光微向孔昭绶身后移动,问道:“这几位是?”\n孔昭绶一让杨昌济:“这位是板仓杨昌济先生。”\n汤芗铭顿时肃然起敬:“原来是板仓先生?久仰大名了。”\n杨昌济笑一笑说:“哪里。昌济不过山野一书生,怎比得铸新先生海内学者,天下闻名?”\n纪墨鸿提醒着,“孔校长,此地可不是讲话之所,是不是先请大帅进去坐啊?”\n孔昭绶点点头一笑说:“对对对,倒是昭绶失礼了。就请铸新先生先到校长室喝杯茶吧。”\n汤芗铭略一沉吟,说道“校长室就不必了,不如教务室吧,芗铭就喜欢那种传道授业、教书育人的氛围。”\n孔昭绶微微一怔,说道“那……也好。铸新先生,请……”\n汤芗铭含笑说道:“列位先生请……”\n一行人进了大门,说话间来到了教务室。纪墨鸿说道:“早听说大帅学钟繇、张芝,得二王之精粹,可否为这千年书院赐一墨宝,也为后人添一佳话。”\n汤芗铭笑说:“岂敢岂敢,列位都是方家,芗铭哪里敢班门弄斧。”\n孔昭绶说道:“铸新先生客气了,先生学贯中西,名闻天下,若能得先生大笔一挥,我一师蓬荜生辉。”一时便叫人拿纸笔,汤芗铭也不推迟,当即写下“桃李成荫”四个字。\n“好字,有悬针垂露之异,又有临危据槁之形。可谓得钟王三昧。”袁吉六带头鼓起了掌,围成一圈的老师们掌声一片。\n汤芗铭放下了笔,“僭越了。其实,芗铭此生,一直在做一个梦,梦想像列位先生一样,做一个教书人,教得桃李满天下,可惜提笔的手,却偏偏拿了枪,可谓有辱斯文。”\n纪墨鸿忙道:“大帅太自谦了,论儒学,您是癸卯科年纪最轻的举人;论西学,您是留学法兰西、英吉利的高材生;论军事,您是中华民国海军的创建者。古今中外,文武之道,一以贯之,谁不佩服您的博学?”\n汤芗铭微摇了摇头,却转向了杨昌济:“板仓先生才真是学问通达之士。”\n杨昌济说道:“昌济好读书而已,岂敢称通达?”\n汤芗铭却长叹了一声:“芗铭毕生之夙愿,便是能如先生一般,潜心学问,只可惜俗务缠身,到底是放不下,惭愧惭愧。”\n大家都笑了起来,汤芗铭谦恭有礼,又兼才气过人,一时众人都渐渐与他亲近起来。\n只听汤芗铭说道:“孔校长,贵院学生的文章,芗铭可否有幸拜读?”\n孔昭绶说道:“先生说哪里话,还请先生指教。”一时便请袁吉六将毛、蔡等人的作文拿来。汤芗铭接过,第一眼便是毛泽东的,却见上面写着毛润之,微微一诧,笑说:“这里也有一位润之么?”\n杨昌济笑说:“这位学生心慕当年的胡润芝胡文忠公,便改表字为毛润之,让先生见笑了。”\n汤芗铭微微一笑说:“夫子云:”十五而志于学,古今有成就者,莫不少年便有大志‘。“他说到这里,指一指杨昌济,又指一指自己说道:”你我当年,恐怕也立过这样的志向吧。“\n他细看文章,点头笑说: “嗯,好文章,文理通达,深得韩文之三昧,气势更是不凡,当得润之这两个字。”抬起头向袁吉六说道:“袁老先生,能教学生写出这样的文章,果然名师高徒啊。”\n袁吉六大松了一口气,忙道:“总算能入方家之眼。”\n汤芗铭放下了文章,问道:“这个毛润之应该是一师学生中的翘楚了吧!”\n袁吉六点头说:“以作文而论,倒是名列前茅。”\n汤芗铭微一沉吟,说道:“哎!孔校长,芗铭能否借贵校学生的作文成绩单一睹啊?”\n孔昭绶忙答道:“那有什么不行?”\n接过作文成绩单,汤芗铭看了一眼,却转手交给了纪墨鸿。他站起身:“列位先生,今日芗铭不告而来,已是冒昧打搅,先贤之地既已瞻仰,就不多耽误各位的教务了。”\n大家也都站了起来,准备送客。\n汤芗铭却微笑说道:“差点忘了孔校长,芗铭此来,还有一件公事,想请您过将军府一叙。”\n孔昭绶不觉一愕,“我?”\n汤芗铭点头说:“对,非您不可。趁着车马就便,不妨与芗铭同行如何?”\n孔昭绶还来不及回过神来,汤芗铭已携了他的手,向外走去。众人方才行到一师门前,汤芗铭正待告辞,这时远处忽然一声枪响,随即传来一片喧闹,把众人都惊了一跳。护卫的军警顿时都忙乱起来,汤芗铭眉头微微一皱,副官只看了一眼他的眼色,立即会意,匆匆跑去。\n但笑容马上又重新回到汤芗铭脸上,拱手道:“叨扰列位的清静,芗铭就此告辞了。”一时众人纷纷回礼,看着汤芗铭携孔昭绶向一辆豪华马车行去。\n只见汤芗铭抢上一步,掀起了马车的帘子,说道:“孔校长,请!”\n孔昭绶怔了一怔,汤芗铭如此客气,倒叫他不好推辞,正要登车,这时那名副官引着一名军官匆匆跑来:“大帅。”\n汤芗铭扭过头来,那军官啪地一个立正,敬礼:“驻湘车震旅长沙城防营营副参见大帅!”\n汤芗铭只瞟了他一眼,便把头扭了回去,淡淡地说:“闹什么呢?”\n军官答道:“报告大帅,有一群要饭的饥民哄抢米铺的米,标下奉命率城防营前来弹压,闹事的22人已全部抓获。如何处置,请大帅示下。”\n未加思索,汤芗铭把玩着手串的食指在空中轻轻一划——这个动作他做得是那么习惯成自然。副官却早会过意来,转头对军官说道:“全部就地处决。”\n正要登车的孔昭绶全身猛地一震,连旁边的纪墨鸿都不禁嘴角一抽。\n那军官显然也吓了一跳,脸色发白说道:“处……处决?都是些女人孩子,二十多个呢……”\n汤芗铭的头扭了过来,看了他一眼,目光中,是一种极不耐烦的神色,目光森冷,直逼得那军官不由自主地低下了头:“……是!”转身跑步离去。\n孔昭绶这时才从震惊中反应过来,一把抓住了汤芗铭的胳膊,“大帅,罪不至死吧?”\n微笑着,汤芗铭轻轻将手按在了孔昭绶的手上:“孔校长,您执掌一师,不免有校规校纪,芗铭治理湖南,自然也有芗铭的规矩嘛。”\n“可是……”孔昭绶还想说什么。\n汤芗铭轻松笑一笑,说:“换作是一师,要是有谁敢乱了规矩,不一样要杀一儆百吗?说话间轻轻拿开了孔昭绶的手,扶着马车帘子,客气地说:”孔校长,请啊。“\n映着阳光,他的笑容和蔼,透着浓浓的书卷气。望着这张笑脸,孔昭绶脸上的肌肉不由自主的抽搐起来。\n枪声骤起!\n孔昭绶紧紧闭上了眼睛……\n二 # 到了将军府,汤芗铭便向孔昭绶合盘托出了这次请他前来的目的。\n“中日亲善征文?”端着茶碗的孔昭绶不由呆住了。一旁的纪墨鸿默然不语,他是在去一师的路上便早已知道这件事了。\n“说得完整点,应该是‘论袁大总统英明之中日亲善政策’。”汤芗铭坐在办公桌后,手里摩弄念珠,微笑说道。\n孔昭绶沉吟一时,放下了茶碗,缓缓说道:“中日关系,事关国策,一师不过一中等师范学校,学生素日所习,也不过是怎样做个教书匠,妄论国是,只怕不大合适吧?”\n汤芗铭依然慢条斯理:“孔校长何必过谦?贵校以湖湘学派之滥觞,上承城南遗风,这坐论国是,本来就是湖湘学人经世致用的传统嘛。刚才拜访贵校时,芗铭拜读的那篇学生作文,不就纵论家国,写得勃勃而有生气吗?”\n纪墨鸿笑说:“孔校长,大帅如此青睐,将这次全省征文活动交由一师发起,这是大帅对一师的信任,大言之,也是袁大总统对一师的信任,您就不必推脱了。”\n孔昭绶忍不住脱口道:“可日本对中国,狼子野心,早已是昭然……”他猛然碰上了汤芗铭笑吟吟的目光,那目光中的森森寒意硬生生将他的话堵了回去。掩饰着阵阵恐惧,他伸手端茶碗,但手却不由自主地在微微颤抖。\n许久,汤芗铭才收回目光:“看来孔校长还是深明大义,愿意配合我大总统英明决策的。征文的事,就这么定了,具体的做法,纪先生,你向孔校长介绍一下吧。”\n“是。”站起身来,纪墨鸿对孔昭绶说,“湖南将军汤大帅令,一、本次征文,以‘论袁大总统英明之中日亲善政策’为题;二、征文以一师为发起策源,首先在一师校内开展,除号召全校学生踊跃参加外,凡作文成绩名列前30名者,必须参加;三、征文结果,须送将军府审阅;四、征文结束后,以一师为范例,将征文比赛推广至省内各校,照例实行;五、凡征文优胜者,省教育司将颁以重奖。征文第一名除奖励外,省府还将特别简拔,实授科长以上职务,以示我民主政府求才若渴之心。”\n茶水突然溅在了孔昭绶的长衫上,他这才发现手里的茶碗不知不觉间端斜了,赶紧放下茶碗,擦着长衫上的水。一方雪白的手帕递到了他的面前,原来竟是汤芗铭起身给他递来了手帕:“征文之事,就由纪先生协助孔校长,即日实施,好吗?”\n孔昭绶回到学校,已经是下午了。他呆呆地坐在办公桌前,一动不动。那张“中日亲善征文”告示就摊在桌子上。\n纪墨鸿推开了房门,孔昭绶仍旧一动不动,仿佛充耳未闻。他拿起那张告示一看,顿时急了:“孔校长,您怎么还没用印啊?我可都等半天了。您到底要拖到什么时候啊?”\n孔昭绶依然不动。\n纪墨鸿叫道:“孔校长,昭绶兄。”凑到了孔昭绶眼前,口气也缓和了:“您心里想什么,墨鸿不是不知道。可咱们这些书生,管不了那么多国家大事,要咱们干什么,咱们就只能干什么,读书人,千古都是如此,生的就是这个命——谁叫咱们的手只会拿笔呢?”说到这里,他长叹了一口气,站直身子:“汤大帅的雷厉风行,您也是亲眼目睹了的,墨鸿还要赶回去交差,昭绶兄,就不要为难小弟了吧?”\n仿佛自己的手有千斤重,孔昭绶艰难地、一点一点地拉开了抽屉。校长的印信就躺在抽屉里。\n纪墨鸿半晌看他没有动手的样子,索性自己动手,手伸进抽屉,抓住了那方印。\n鲜红的校长大印盖上了告示。孔昭绶还是一动不动,仿佛一具失去了灵魂的躯壳。纪墨鸿叹息一声,摇一摇头,出了校长室,轻轻掩上门。\n走廊里,刘俊卿看到纪墨鸿急匆匆走来,怯生生地招呼了一声:“纪督学。”然后侧过身子,正要给纪墨鸿让路,却听见了纪墨鸿的声音:“俊卿。”\n刘俊卿不禁受宠若惊:“老师。”纪墨鸿把那份告示递了过来:“帮我个忙,把这个贴到公示栏上去。”\n“征文第一名将由省府特别简拔,实授科长以上职务……”\n刘俊卿正把告示往公示栏上贴,盯着上面征文奖励的条款,眼睛都直了:“老师,这是真的?”\n“大帅亲口说的,还能有假?”纪墨鸿拍了拍刘俊卿的肩膀,“俊卿,上次的事,你实在是让我太失望,太痛心了。可你毕竟还叫过我一声老师,我也不希望你这么个人才真的这么荒废了。现在机会摆在你面前,希望你可不要再错过了。”\n“老师,您放心,我不会错过的,我这就去写,我一定抓住这个机会。”\n激动中,刘俊卿全身都在颤抖,他又把公告仔细读了几遍,这才向寝室走来,一路寻思,这样的机会,大家都在那里抢,自己恐怕要竭尽全力,当下里拿定主意,请几天假,一心一意写好文章。\n这时学生们都已陆续上前来看告示,围成一团,议论纷纷。杨昌济走了过来,抬头看去,“中日亲善?”他简直都不敢相信自己的眼睛,一时细读,越读脸色越沉了下来,当即直奔校长室,连门也不敲,猛地推开,一步闯进去。\n三 # 《日本国发出最后通牒大总统袁世凯承认二十一条》。毛泽东拿着刚到的《大公报》,头版显著的大标题不觉令他发呆,一时怔在了校门口。\n此时心中的愤怒反使他冷静下来,他觉得自己应该要做点什么了,但到底怎么做?他第一个想到了杨昌济。\n“润芝,哪里都找你不到,原来你在这里?”迎面蔡和森和张昆弟满脸焦急,叫道。\n“怎么回事?”\n“出大事了。” 蔡和森说道,直将毛泽东拉到那公告栏前。\n毛泽东一看之下,也不由目瞪口呆,问道:“老蔡,这是什么时候的事?”\n“下午才贴的,现在杨老师已经去找孔校长了,我不相信孔校长会干这样的事,到处找你,我们一齐去问个清楚。怎么样?” 蔡和森说道。\n毛泽东不说话,将手里的报纸递给他说:“你看吧。”\n蔡和森接过,张昆弟也凑了过来,一见标题顿时双目圆睁,脸上一阵抽搐,一拳击在报纸上,喝道:“欺人太甚。”把周围的同学都吓了一跳。\n蔡和森细细将报纸看完,才问道:“润之,杨老师知道这件事么?”\n毛泽东沉吟说:“应该不知道,这是最新的报纸,刚到的。”他说到这里,一扯蔡张二人说:“走,我们去校长室。”\n三人匆匆向校长室赶来,只见房门大开,方维夏、黎锦熙、袁吉六……一个个老师都站在门前,大家的神情同样凝重,大家的表情同样难以置信。\n“全校征文?居然要我们的学生,要我们亲手教出来的学生为日本的狼子野心唱赞歌!这样的启事,竟贴进了一师的校园,我一师的传统何在?我一师的光荣何在?这座千年学府之浩然正气何在?” 杨昌济的声音越来越大,在回廊之间直震荡开来。\n三个人从窗子里看进去,只见孔昭绶一动不动背向众人,仿佛一尊泥雕一般。杨昌济激动得在那里走来走去。\n“耻辱啊,这件事,你到底知道不知道?你怎么不说话?难道你事先知道?你为什么不回答我,为什么不敢面对大家?你不是这种人啊,你到底是怎么了?你在怕什么?” 杨昌济敲着桌子说。\n孔昭绶依然没有任何动静。杨昌济再也忍不住了,他一把将孔昭绶的身子扳了过来,大叫道:“昭绶!”\n猛然,他愣住了。所有的老师也都愣住了。\n——两行泪水,正静静地滑出孔昭绶的眼眶,顺着他的面颊淌下!\n“你知道吗?他的手指这么一勾,就杀了二十二个人,因为他们没饭吃,他们抢了点米,他就这么一勾,二十二个人,二十二条命,就这么一勾……”孔昭绶喃喃地说着,整个人都笼罩在那种刻骨铭心的恐怖之中。忽然他猛地一拳重重砸在自己头上,声嘶力竭地叫道:“我是个胆小鬼啊!”\n所有的人都惊呆了。杨昌济扳在他肩上的手不自觉地滑落下来。\n毛泽东沉默一时,握着报纸,直闯进门去。\n“润之?”杨昌济不觉一怔。孔昭绶闻言也抬起头来。\n“校长,这是刚收到的报纸。” 毛泽东递过报纸。\n“原来这样!” 孔昭绶接过报纸看时,汤芗铭的种种企图刹那间都明白了。孔昭绶沉默片刻,将报纸递给了杨昌济,忽然一跃而起,冲出了校长室,直奔公告栏,这时栏前仍围满了学生。孔昭绶排开人群一把将告示撕了下来。面对满是惊愕的师生们,孔昭绶目光如炬,向追上来的方维夏说道:“维夏,马上起草一份征文启事——标题是:《就五·七国耻征文告全校师生书》!”\n方维夏闻言大声应道:“是。”在场的师生都轰然欢呼起来!\n四 # 整整三天,一师的师生都在忙乱之中,所有的文学老师连夜阅评,学生们自发的组织起来协助装订,整理,大家没有一句多余的话,仿佛形成了一种默契,把所有的耻辱和愤怒放在心里,用更多的行动去洗雪。到第二天上午,方维夏便将一本蓝色封皮、装帧简洁的《明耻篇》拿到了孔昭绶的办公室:“校长,国耻征文印出来了,这是样书。”\n孔昭绶接过来仔细翻看,点头说:“不错。”他沉吟一时,问道:“润之在哪里。”\n“他们在礼堂为明天的全校师生五·七明耻大会准备会场。我去叫他来。” 方维夏说道。\n孔昭绶摆摆手说:“不用了,我去找他,顺便看看会场。你去忙你的吧。”说话间站了起来,方维夏点点头,却眼看着孔昭绶,半晌站着不动。孔昭绶怔了一怔,说道:“维夏,你还有事?”\n方维夏摇一摇头,迟疑一时才缓缓说道:“校长,你没事吧。” 孔昭绶又是一愣,但瞬间他明白了方维夏的意思,微微一笑说:“维夏,谢谢你,我没事。” 方维夏沉吟一时,还想说些什么,但最后一句话也没有说,出了房门。\n孔昭绶看着他的背影,由不得心头一热,从昨天到现在,他从每个老师和学生的眼里都看到了一种关心,虽然没有一个人说出来,只是埋头做事,然而他可以明白的感受到,大家都在替他担心。他拿起那本《明耻篇》来,心中忽然感到一丝欣慰,随即关上门向礼堂而来。\n礼堂外露天摆放的桌子前,蔡和森正在写着大字。地上摊着长长的横幅,毛泽东、张昆弟等人正将他写好的大字拼贴在横幅上。孔昭绶站在蔡和森身后,也不说话,只看他写字。\n“校长。”毛泽东几个人抬起了头。孔昭绶笑笑说:“写得不错啊。”一时向毛泽东说:“润之,你那里先放一放,来给这本《明耻篇》题个引言吧。”说话间把书递了过来。\n毛泽东愣了一下:“我来题?”\n“对,你来题。” 孔昭绶拿起架在砚台旁的毛笔,递到了毛泽东面前: “如果不是你的提醒,就不会有这次国耻征文,所以,应该由你题。”盯着孔昭绶为他翻开的书的空白扉页,毛泽东沉吟了一会儿,接过了毛笔。大家都围了上来。\n毛泽东奋笔疾书,一挥而就,《明耻篇》的扉页上留下刚劲有力的十六个字。孔昭绶读出了声:“‘五月七日,民国奇耻。何以报仇,在我学子’写得好,写得好!”\n就在这时,只见刘俊卿慢慢挨了过来,叫道:“校长。”\n孔昭绶回过头来:“是你,什么事啊?”刘俊卿小心捧着手里的文章,恭恭敬敬递了上来:“我的征文写好了。”\n“征文?不是早就截止了吗,你怎么才送来?” 孔昭绶呆了一呆。“截止了?哎,不是有一个星期吗?” 刘俊卿急忙叫道。\n孔昭绶沉默一时,忽然好像想起什么,问道:“你写的什么征文?”“中日亲善征文啊。” 刘俊卿不觉奇怪,这有什么好问的。\n一刹那间,大家好像发现一只怪物,把刘俊卿看得莫名其妙。孔昭绶一把接过了刘俊卿的文章,打开看了一眼——文章的标题是《袁大总统中日亲善政策英明赋》。\n孔昭绶读了出来:“‘东邻有师,巍巍其皇。一衣带水,亲善之邦。’”他突然忍不住笑了,“一衣带水,亲善之邦!”他蓦然住口,两眼如刀一般盯着刘俊卿,握紧拳头,一种尖锐的痛楚从心底里直透出来,他都不知道自己该说什么。\n刘俊卿呆呆地看着孔昭绶,全不明白大家为什么会是这样的眼神,他只觉有无数的针从四面八方刺来,这时他看见孔昭绶缓缓地将他那篇文章一撕两半,不觉大惊,叫道:“校长,你……”\n孔昭绶冷冷地一点一点,将那篇文章撕得粉碎。纸屑洒落在地上。他拍打着双手,仿佛是要拍去什么不干净的东西,看也没看刘俊卿一眼,转身离去。\n刘俊卿仍旧呆在那里一动不动,毛泽东等人都不理他,自顾布置会场。张昆第却不耐烦了,叫道:“让一让。”从背后一推,将他推了个趔趄,他这才回过神来看清了地上那幅已经拼贴完工的横幅上,却是“第一师范师生五·七明耻大会”几个大字。\n五 # 第二天清晨,一师大礼堂的主席台上高悬出“第一师范五·七师生明耻大会”的横幅,左右两侧,是飞扬的行草,“五月七日,民国奇耻”、“何以报仇,在我学子”。台下全校数百师生聚集一堂,一片肃穆,过道间黎锦熙等人正在发放《明耻篇》,一本本书无声地由前至后传递着。\n当孔昭绶出现礼堂门口,刘俊卿死死地咬着嘴唇,坐在最后一排,木然接过那本《明耻篇》。这时雷鸣般的掌声响了起来,他有些怨恨地看着孔昭绶一步步走上了讲台。\n掌声骤然一停,全场一时鸦雀无声。孔昭绶环顾着台下,眼光从杨昌济、徐特立、方维夏等一位位老师身上,又从毛泽东、蔡和森、萧三等全场白衣胜雪的学子们身上掠过,他甚至看到了刘俊卿,仿佛有千言万语一时不知从何说起。\n终于,他深深地吸了一口气:“有一个词,大家一定都听过支那。这是日本人称呼我们中国人时用的词,在日本人嘴里,中国就是支那,我们这些在座的中国人就是支那人。那么支那是什么意思呢?过去我也并不清楚,只知道那是隋朝起从天竺语‘摩诃至那’中派生的一个对中国的称呼,本意并无褒贬。直到五年前,五年前,我在日本留学的时候,日本学校给我准备的学籍表上,填的就是‘支那人’孔昭绶。每次碰到日本人,他们也都会说:”哦,支那人来了。‘说这句话的时候,他们脸上的那种表情,我这一辈子也忘不了,那是一种看到了怪物,看到了异类,看到了某种不洁净的东西,看到了一头猪,混进了人的场合时才会有的蔑视和鄙夷!\n“于是我去查了一回字典,我不相信日本人的字典,我查的是荷兰人出的——1901年版《荷兰大百科通用辞典》,查到了:支那,中国的贬义称呼,常用于日本语,亦特指愚蠢的、精神有问题的中国人。这就是支那的解释!”\n“今日之日本,朝野上下,万众一心,视我中华为其囊中之物,大有灭我而朝食之想,已远非一日。今次,‘二十一条’的强加于我,即是欲将我中华亡国灭种的野心赤裸裸的表现!而袁世凯政府呢?曲意承欢,卑躬屈膝,卖国求荣,直欲将我大好河山拱手让于倭寇!此等卖国行径,如我国人仍浑浑噩噩,任其为之,则中华之亡,迫在眉睫矣!”孔昭绶痛心疾首,振臂而呼。\n“夷狄虎视,国之将亡,多少国人痛心疾首,多少国人惶惶不安?是,大难要临头了,中国要亡了,该死的日本人是多么可恨啊,老天爷怎么不开开眼劈死这帮贪婪的强盗?这些抱怨,这些呼号,我们都听过无数回,我们也讲过无数回。”端起杯子,孔昭绶似乎准备喝口水润润嗓子,但突然情绪激动起来,又把茶杯重重一放。“可是怨天尤人是没有用的!我们恨日本怎么样?恨得牙痒又怎么样?恨,救不了中国!\n“以日本之蕞尔小邦,40年来,励精图治,发愤图强,长足进步,已凛然与欧美之列强比肩,为什么?隋唐以降,一千多年,他日本代代臣服于我中华,衣我之衣冠,书我之文字,师我中华而亦步亦趋,而今,却凌我大国之上,肆意而为,视我中华如任其宰割之鱼肉,又是为什么?\n“因为日本人有优点,有许许多多我中国所没有的,也许过去有过,但今天却被丢弃了的优点!我在日本的时候,留学生们人人对日本人的歧视如针芒在背,可是呢,抱怨完了,却总有一些人,但不多,但总有那么几个逃学、旷课,他们干什么去了?打麻将!逛妓院!还要美其名曰,逛妓院是在日本女人身上雪我国耻,打麻将是在桌上修我中华永远不倒的长城!大家想一想,这还是在敌人的国土上,这还是当着敌人的面!他日本人又怎么会不歧视我们?怎么会不来灭亡这样一个庸碌昏聩的民族?\n“所以,我们都恨日本,可我却要在这里告诫大家,不要光记得恨!把我们的恨,且埋在心里,要恨而敬之,敬而学之,学而赶之,赶而胜之!要拿出十倍的精神、百倍的努力,比他日本人做得更好,更出色!这,才是每一个中国人的责任!”\n慷慨激昂的演说深深地震撼着全场的师生,不知何时,刘俊卿的座位悄悄空了……\n"},{"id":126,"href":"/zh/docs/culture/%E6%81%B0%E5%90%8C%E5%AD%A6%E5%B0%91%E5%B9%B4/%E7%AC%AC16%E7%AB%A0-%E7%AC%AC20%E7%AB%A0/","title":"第16章-第20章","section":"恰同学少年","content":" 第十六章 感国家之多难 誓九死以不移 # 感国家之多难,誓九死以不移,\n虽刀锯鼎镬又有何辞?\n人固有一死,死得其所,不亦快哉!\n鼓大勇,戡大乱,雪大耻,\n令我中华生存于竞争剧烈之中,\n大崛起于世界民族之林\n一 # 刘俊卿悄悄离开礼堂,埋头疾步朝校外跑去,忽然听到有人喊他的名字,吓了一跳,东张西望之后看清是父亲,这才松了一口气:“爸。”\n刘三爹本是提了开水瓶去礼堂倒茶的,却见儿子独自一人跑出来,很是奇怪:“不是开大会吗?你这是上哪去?”\n“我……有点急事……”\n“你能有什么急事啊?”\n“说了有急事,你就别管了。”刘俊卿走出几步,突然又回过身来:“爸……”看着父亲那饱经沧桑满是皱纹的脸,心头一热,似乎想说什么,却又不知从何说起,终于,他只是笑了笑:“爸,等着我,等我回来,也许你就不用给人倒开水了。”\n“那我倒什么?”刘三爹显然没听明白。\n“什么也不倒,以后,我要让别人给你倒。”\n扔下一头雾水的父亲,刘俊卿匆匆出了校门,一口气跑到省教育司纪墨鸿的办公室,边喘气边把“中日友善”变“明耻大会”的经过说了一遍。“学生按照老师要求,熬了一个通宵写的征文,被孔校长当着老师同学们的面撕得粉碎。”刘俊卿委屈地说。\n接过刘俊卿递来的《明耻篇》,纪墨鸿翻开封面,“五月七日,民国奇耻。何以报仇,在我学子”的引言赫然在目。“这还了得!这不是公然煽动学生造反吗?”纪墨鸿腾地站了起来,“走,马上跟我去将军府。”\n两人匆匆来到将军府,纪墨鸿吩咐刘俊卿等在外面,自己请陈副官赶紧通报,匆匆进了汤芗铭的办公室。刘俊卿本只想到纪墨鸿那里告个状就走人,万万没想到竟会被带到将军府来,看纪墨鸿的紧张模样,自己这一状真是告到了点子上,这一刻便觉得全身轻飘飘的,犹如踩着两团棉花,两只眼睛直勾勾地看着将军府内那颗桂花树。这时还只是初夏时节,他却仿佛闻到了一阵阵的桂花香,心中想:古人所云“蟾宫折桂”,大抵就是这个情形吧。\n正在胡思乱想之际,只听得一阵阵杂乱而紧张的脚步声,众多士兵涌了出来,刺刀闪亮,排列成行,刘俊卿哪见过这等阵仗,心中正发虚,却不料被人从后面拎住了衣领。回头一看,正是那个陈副官,脸上全无表情:“走,跟我去认人!”\n“认人,认什么人?”刘俊卿愣住了。\n“抓的是你们学校的校长,你不认人,谁认人?”陈副官眼睛一瞪,刘俊卿这才明白这帮士兵竟是要去捉孔昭绶的,顿时傻了,求援的目光投向一旁跟来的纪墨鸿,“可是……可是我……老师……”\n纪墨鸿似乎也有些歉然,躲开了他的目光:“俊卿,做人就要善始善终嘛。”刘俊卿急了:“不是啊,老师,我就是来报个信,这种事我怎么好去呀?”纪墨鸿拍着他的肩膀:“我知道,当着熟人,大庭广众的,脸上抹不开也是有的。可你不去,这些当兵的谁认识他孔昭绶啊?再说,大帅可有话,只要你肯尽心效力,绝不会亏待你,教育司一科科长的位子,可还空着呢。”\n“老师,我……我真的不行……”刘俊卿还在苦苦哀求,早已等得不耐烦的陈副官一挥手,两名士兵上来,一人一边,挟了刘俊卿就跑。纪墨鸿站在将军府门口,看着挣扎着的刘俊卿被士兵们带走,却是一言未发。\n二 # 这一刻,一师礼堂里,“明耻大会”仍在进行,孔昭绶还在慷慨陈词:\n“天下兴亡,匹夫有责。何以报仇,在我学子!国家之广设学校,所为何事?我们青年置身学校,所为何来?正因为一国之希望,全在青年,一国之未来,要由青年来担当!当此国难之际,我青年学子,责有悠归,更肩负着为我国家储备实力的重任……”\n忽然,砰的一声,礼堂门被撞开了,刘三爹气喘吁吁地冲了进来,把师生们吓了一大跳。原来,刘俊卿走后,刘三爹进到礼堂帮着老师们一一泡上热茶,又站着听了一会儿演讲,大道理他说不出来,就觉得孔昭绶说得有理,说出了中国人的骨气。他听了一半,想着儿子还在外面,开水瓶也空了,就出去换开水,顺便再把儿子喊进来。出了礼堂,却左找右找不见儿子身影,正在校门口东张西望之际,只见大批军队直朝一师而来,连忙锁了校门,跑来报信。\n“不好了,不好了,当兵的……全是当兵的……好多当兵的……”刘三爹话音未落,砰的一声,门口传来一声枪响,随即是校门被砸开的声音,士兵们整齐的脚步声,听得所有人心中一紧,几乎同时站了起来!\n“第一师范的师生人等,给我听清楚了,湖南将军汤大帅有令:文匪孔昭绶,目无国法,包藏祸心,蛊惑学生,对抗政府,着令立即逮捕。凡包庇孔犯昭绶,窝藏卷带者,与孔同罪。煽动闹事,阻碍搜捕者,格杀勿论!”\n门外的士兵们喊话声传来,礼堂里的学生们顿时一片大乱。\n“都不要乱,同学们,不要乱,听我把话讲完。”一片惊悚中,讲台上的孔昭绶却笑了,这一切原本就在他的预料之中,只不过提前了一点点罢了,“没有什么了不起的,不就是抓人吗?昭绶今日走上这个讲台,外面的情况,早就已在我意料之中。死算什么?感国家之多难,誓九死以不移,虽刀锯鼎镬又有何辞?人固有一死,死得其所,不亦快哉!”\n他戴上礼帽,正正衣襟:“同学们,我亲爱的同学们,昭绶今日虽去,一师未来犹存,但望我去后,诸位同学能不忘我今日所言,鼓大勇,戡大乱,雪大耻,令我中华生存于竞争剧烈之中,崛起于世界民族之林,则昭绶此去,如沐春风矣。”\n说罢,迈步便下了讲台。\n“校长!”前排的萧三再也忍不住了,双膝蓦然重重跪倒在地!一排排同学,一双双膝盖随着孔昭绶的经过,顿时跪倒了一片!一双双眼里,饱含着泪水,一双双手,伸向了即将生离死别的校长……\n满场黑压压的学生中,只剩了毛泽东、蔡和森还站着没动,两个人互相看着,却也不知如何是好。孔昭绶的眼睛也湿润了,他微笑着,坚定地排开一双双伸向他的手,向大门走去。杨昌济一把抓住了他的手:“昭绶!”\n“昌济兄,你我之约,望君铭记。”孔昭绶挡开杨昌济的手,就要来拉大门。猛地,站在门边的刘三爹一把靠住大门,堵住了孔昭绶的去路,冲毛泽东等人大喊:“你们还愣着干嘛?还不保护校长走?快啊!”\n毛泽东这才反应过来,一挥手,几个人上来一把抱住孔昭绶。孔昭绶挣扎着,“放开我,快放开我……”然而学生们人多势众,不容分说,架起他便往另一边的门跑去。\n孔昭绶这边刚被架走,枪托砸门的声音砰然大起!学生们赶紧冲上前,与刘三爹一起堵着大门。门外的士兵们蜂拥而上,枪托砸、肩膀撞,到底当兵的凶悍,轰然一声,礼堂的一边大门被撞断了门轴,倒了下来。数十把闪亮的刺刀一拥而入,逼得学生们纷纷后退。\n“带他认人!”副官和被士兵押着的刘俊卿走了上来。副官一挥手,士兵放开刘俊卿,顺手向前一推,刘俊卿一个踉跄,重重摔在地上。这一跤摔得很重,但刘俊卿也顾不得了,趴在地上,双手捂住脸,只希望这里的人认不出他来。\n“刘俊卿?”不知是谁首先喊出了这个名字,无数道惊愕的目光一齐射了过来。几乎是刹那之间,大家都明白了,目光一下子转成了无比的鄙夷。角落里,刘三爹更是惊得目瞪口呆,简直不敢相信自己的眼睛!\n一名士兵过来,揪着刘俊卿的衣领,把他拎了起来:“快认人!”\n看着曾经朝夕相处的同学们,刘俊卿躲闪着他们的眼光,最后,他的眼神落在易永畦——这位平日里最温顺和善的同学身上,“永畦,我……”他满怀希望地喊出了这个名字,他希望永畦能够明白他,原谅他今天所做的一切。\n易永畦猛地抬起头,抡起巴掌,狠狠扇在刘俊卿的脸上!\n一个士兵走过来,抡起枪托照着易永畦当胸狠狠砸去,易永畦一头摔翻在地,一口鲜血猛喷了出来!“永畦!”周世钊等好几名同学涌了上来,扶住了昏迷的他。\n“还有谁不老实?谁!”陈副官拔出手枪,黑洞洞的枪口对着同学们挥舞了一圈之后,停在刘俊卿的脑门上,“认人,你认不认!”\n脸上火辣辣的刘俊卿被冷冰冰的枪口指着,脑子一片空白,他不敢回头,后面全是黑洞洞的杀人的枪口。他也不敢向前,前面是张昆弟、周世钊他们仇恨的目光。如果他们手里也有枪,他们枪口第一个对准的,肯定也是他刘俊卿。站在人群中间,他重重咬着嘴唇,鲜血从唇角流下来。\n猛然,他疯一样地冲进人群,“我认,我认,我现在就认!”他一把推开了面前的同学,“孔昭绶,你给我出来!出来,孔昭绶!”\n他嘶吼着,寻找着,疯子般寻遍了整个礼堂,却不见孔昭绶。\n“走,走,再找!再找!我带你们找!”他领着士兵们冲了出去,这一刻,他已经只剩下一个念头:他已经不属于这所学校,他只想毁了这眼前的一切!\n此情此景,连刘俊卿的亲生父亲——刘三爹也看不下去了,他扶着墙壁,一步一步慢慢挪着离开了礼堂。路其实很平,他却摔了一跤,随即两腿发软,怎么也站不起来,嘴唇哆嗦着,上下牙齿咬得咔咔作响,终于,从齿缝里挤一句“兔崽子!”,禁不住泪如雨下。\n毛泽东、蔡和森一左一右夹着孔昭绶,一时之间也不知哪里安全,只好先带着老师们到宿舍再说。\n“昭绶兄,你怎么就不听劝呢?”杨昌济急得满头大汗,“白白牺牲一条性命,有必要吗?”徐特立、方维夏等人也纷纷劝道:“是啊,校长,赶紧走吧,迟了就来不及了。”\n孔昭绶早已抱了必死决心,只是微笑着说:“你们不用劝了,我不会走的。昌济兄、特立兄,你们都走吧。毛泽东、蔡和森,你们赶快把外面的同学都带走,千万别让他们出事。”\n毛泽东斩钉截铁地说:“您不走,谁也不会走的!”\n就在这时,外面传来了一阵嘈杂声。屋里的人不由得都紧张起来,只有孔昭绶反而更加平静了。蔡和森向杨昌济等点了一下头,打开门走了出去,迎头却愣住了……眼前,张昆弟、罗学瓒、萧三……几十个同学抄着棍棒、板凳、砖头等东西,正涌向门口。一张张年轻的脸上,都是视死如归的无畏。\n蔡和森问:“你们这是要干什么啊?”张昆弟扬着手里的木棒说:“和森兄,我们决定了,大家把校长围在中间,一起往外冲,拼出这条命,也要把校长送出去!”“对,冲出去……”众人纷纷点头。张昆弟一挥手,“说干就干!不怕死的,跟我来!”\n“都给我站住!”身后,传来了毛泽东的一声大吼,大家不由得都愣住了,“你们这是要干什么?都疯了?凭这几根木棍,就想跟刺刀、跟子弹、跟一支军队去拼命吗?”“那你说怎么办?总不能眼睁睁看着他们把校长抓走吧?”张昆弟说。\n“不管怎么样,也不能用血肉之躯,用这么多人命去冒这种险!这是无谓的牺牲,是匹夫之勇!”毛泽东一把抢下了张昆弟手中的棍子:“都把东西放下!都给我放下!”好几个同学被他震住了,放下了手里的东西,更多的人迟疑着,一时不知如何是好。张昆弟说:“不行,我不能看着他们把校长抓走,要命有一条!我不怕!”说完,就要往外冲,“昆弟……”蔡和森连忙一把拉住。\n“同学们!” 听到动静的孔昭绶与其他老师出现在门口,孔昭绶命令同学们,“把东西都放下来,放下!都放下!”\n一片静默中, 乒乓一阵,同学们手中的棍棒、砖头、板凳……通通落在了地上。忽然,一只手缓缓地,却是坚定地捡起了地上的一根木棒。所有人都愣住了——居然是蔡和森!\n孔昭绶急了:“蔡和森,你这是干什么?”蔡和森看着孔昭绶,一脸平静,仿佛什么也没发生过:“昆弟他们刚才要干什么,我现在就去干什么。”\n方维夏急了,站出来想要阻止,杨昌济却轻轻拉了他一把,他太了解蔡和森了,知道这个学生绝不会做出不理智的事来,尤其在这种危急时刻。\n蔡和森环顾着同学们:“怎么了?大家刚才不都还勇气十足吗?怎么现在都不敢了?”他又捡起一根球杆,递向毛泽东,“润之,拿着!”连毛泽东也被他搞糊涂了,一时间接也不是,不接也不是。孔昭绶上前,一把将那根球杆抢了过来:“蔡和森,你就别添乱了!你这不是去白白牺牲吗?”蔡和森说:“连校长都可以白白牺牲,我这个学生为什么不可以?连校长都不要命了,我这个学生还要什么命?”毛泽东这才醒悟过来,立刻率先抄起了板凳,其他学生也纷纷捡起刚才扔在地上的东西。杨昌济严厉地说:“昭绶,你还要以你的固执,去换取他们的生命吗?”\n所有人都在等着、期待着,终于,孔昭绶长长地吐了一口气:“好!我走。”\n大家刚刚松了一口气,萧三等人气喘吁吁地跑过来,“孔校长,快走,刘俊卿带人搜到宿舍来了。”跟在后面的李维汉接着说:“学校的前后门都被堵了,四面全是兵,一条出去的路都没有了!”\n“一个口子都没有了?”徐特立连忙问。“到处都是兵,围得跟铁桶一样,谁都不准出去啊。”罗学瓒说,“最可恨是那个刘俊卿,每个人他都要过目,比那些当兵的搜得还卖力!”\n孔昭绶心如死灰:“一师教出了这样的败类,也是天亡我了。”\n“校长。”身后突然传来了刘三爹的声音。孔昭绶一扭头,不知何时,刘三爹已来到人群外,提着一只油迹斑斑的竹匾,捧着一个蓝布包袱。他解开蓝布包袱,里面是一套皱巴巴、油腻腻的旧衣服,散发出难闻的臭味,那是他炸臭豆腐时经常穿的:“靠大椿桥那边的小侧门,只有几个当兵的守着,校长,您换上这身衣服,就说是来给学校食堂送臭豆腐的。学校里除了老师就是学生,没有这种打扮的人,他们肯定会相信。”\n“这行吗?”孔昭绶将信将疑。“换吧,校长,一定行,我打包票,一定行的。”刘三爹把旧衣服捧到了孔昭绶眼前,微笑着说:“换吧,校长。”\n一旁的杨昌济想了想:“不管怎么说,这个办法值得一试。不过,还得烦请徐副议长大驾。”“这话怎么说?”徐特立忙问。\n杨昌济低声说了几句,大家连连点头,孔昭绶也终于解开长衫扣子,开始换衣服。徐特立与杨昌济也赶紧动手,拿的拿衣服,取的取帽子帮他。换下的长衫被刘三爹随手接过,搭在自己臂弯里,忙乱中,谁也没留意。\n不一会儿,杨昌济、徐特立迈着方步,直朝大椿桥的小侧门而来。刘三爹说的没错,相比学生宿舍的喧闹混乱,这里显得安静很多。\n“站住!”两名持枪的士兵喝住了迎面走来的杨昌济和徐特立。杨昌济脸色一变,“你们干什么?知道这位是谁吗?省议会的徐副议长!连议长的驾都敢挡,好大的胆子!”一名军官上前来,嘴里骂骂咧咧,“少他妈啰嗦,老子是汤大帅的兵,不认得什么一长二长,都给我站住!”徐特立头一扬,端着架子就往外走,那名军官拔出手枪,迎头顶住了他:“往前一步,格杀勿论!”\n这里正僵持不下,身穿破衣、头戴毡帽的孔昭绶低着头从旁边走来,就要从他们身边出门,那名军官却眼尖,枪一抬:“哎——哪去哪去?”“我回家。”“回家?你干什么的?”“我,卖臭豆腐的,刚到学校食堂送完货。”“站这儿等着!”“长官,家里锅上还炸着豆腐呢,您行个方便吧。”“少啰嗦,人犯没抓到以前,谁都不准出这个门!”\n正在这时,身后不远处又传来一阵杂乱的脚步声,杨昌济一扭头——看到刘俊卿一马当先,带着一帮兵正向这边走来。刹那间,三个人的心猛地悬了起来。杨昌济与徐特立赶紧拦在了孔昭绶前面,眼睁睁看着刘俊卿一步步逼了上来,却是一点办法也没有。\n突然,哗啦一阵,一堆泥土从坡上滚了下来,正散在刘俊卿的脚边。他猛一扭头,坡上,一个穿长衫的背影一闪而过。\n刘俊卿的眼睛顿时亮了:“就是他,他在那儿!”陈副官也看见了,手一挥:“给我追!”士兵们与刘俊卿一窝蜂追了上去。那名负责看门的军官拔出手枪,一巴掌抽在一个士兵头上:“还愣着干嘛,还不快追!”他带着看门的两个兵也追了上去。\n喜欢恰同学少年,就登陆连城书盟踊跃投票吧。\n片刻之间,叫嚣呼喊声渐渐远去……门前的兵全空了。杨昌济与徐特立长长松了一口气,杨昌济催着:“昭绶兄,快走啊!”孔昭绶却是焦急地向士兵们追去的方向张望着:“我说,不会是谁被他们认错了吧?”徐特立说:“他们要抓的是你,肯定是看花了眼。”孔昭绶还是不放心:“可万一抓错了人……”\n杨昌济安慰他:“带头的是刘俊卿,真弄错了,他也能认得,不会连累别人的。昭绶,快走啊!”两个人拉着孔昭绶,硬把他推出了门。孔昭绶似乎还有些担心,但当此时刻,确也无力去核实,只得匆匆离去。\n刘俊卿一马当先,带着士兵们蜂拥追逐,穿长衫的背影跃山坡,过树丛,奔台阶……身后,枪声和士兵的叫喊响成了一片。\n背影冲过一条窄巷,骤然发现自己已拐进了死路——面前是横挡着的高墙。身后,跑过的刘俊卿一眼看到了僵立的背影,他大喊着,“他在这儿,他在这儿。”众多士兵哗啦将巷子口封了个水泄不通。\n“跑?”盯着无路可逃的背影,陈副官冷森森地笑了,“你往哪儿跑?再跑一步试试?”正在这时,犹豫了一下,本来僵立不动的背影突然纵身向墙头爬去。“妈的,活腻味了!”背影充耳不闻,半个身子已经骑上了墙头。陈副官抬起手枪,正对着后背开了一枪,“给我下来,听到没有?”\n一声枪响,背影全身一震,一头从墙上跌落下来。鲜血从他的后背、前胸同时涌了出来。\n“你跑啊,你跑啊!”刘俊卿一个箭步冲上前来,一把揪住了俯卧在地上人,“你不是要开除我吗?你不是撕我的文章吗?你不是不给我活路吗?你也有今天?”一把将地上的人翻转过来,熟悉的面孔映入眼帘,蓦然,刘俊卿呆住了:“爸?!”\n副官等人都是一愣,也纷纷围了上来。“爸,怎么会是你?爸,你撑住,你撑住啊……”刘俊卿拼命要把刘三爹抱起来,然而,刘三爹却死死按住了他的手。一口唾沫,和着鲜血,狠狠啐在他的脸上:“畜牲!”\n刘俊卿愣住了。\n陈副官的眼睛凶狠狠地眯了起来,抬起了手枪:“妈的,敢骗我!”\n刘俊卿大惊失色,拼命来挡:“不,不不!不要,他是我爸,他是我爸……”\n副官一把将他推翻在地,枪直顶在刘三爹头上。砰,枪响了!鲜血猛地溅了刘俊卿一脸,溅得他呆如雕塑……\n三 # 刘三爹的头七,雨下了整整一天。王子鹏一大早就来到秀秀家,帮着布置灵堂,安置灵位。秀秀倚在床上,从送完葬回来那天起,她整个人都垮了。子鹏端来一杯水送到嘴边:“阿秀……”秀秀呆呆地摇了摇头。也不知应该怎么劝她,子鹏黯然放下了杯子。\n房门突然开了,风夹着雨点,一下子洒进门来,全身上下滴着水的刘俊卿出现在门前。他站在门口,似乎想走进房,但望着父亲的灵位,看看妹妹的样子,却又有些鼓不起勇气,抬起的脚又缩了回去,“阿秀,我……我有话跟你说……”\n秀秀的目光移到了另一边,她宁可看墙壁也不愿看这个哥哥一眼,更不想跟他说话。\n刘俊卿上前一步,恳切地说,“阿秀……你听我说,我会去找事做,以后有了薪水,你也不用上王家当丫环了……”“滚。”秀秀从牙缝里挤出一个字来回答刘俊卿。\n“阿秀,我知道你恨我,我也在恨自己!我……我不知道该怎么跟你说……阿秀,你不用叫我哥,也不用理我,你就是别再去当丫环了,好不好?我求求你……”“滚!”秀秀还是只有这一个字。\n“我……”刘俊卿一阵冲动,抬脚迈进门来,看了一眼秀秀,但秀秀还是背对着他。他又把那只迈进了门的脚重新缩到了门槛外,对着子鹏递来了求援的目光:“子鹏兄,我知道,你是好人,你帮我劝劝阿秀吧,我求你,劝劝她吧。”\n“你走吧,阿秀不想见到你,我也不想见到你。”子鹏的回答出乎意料。刘俊卿不敢相信,“子鹏兄……”\n猛然间,从来是那么柔弱,从来不对人说一句重话的子鹏腾地站了起来,指着门外,一声怒吼:“你滚!”刘俊卿吓得倒退一步。\n王子鹏长到二十几岁,第一次冲人发这么大的火,发过之后,他反而有些手足无措,不知说什么好,轻轻叹了口气,避开了刘俊卿的目光,重新坐回到秀秀身边。\n屋外,雨越下越大,秀秀仍然一动不动,刘俊卿一步,又是一步,退出房门,轻轻把门关上。他不知道,他走之后,秀秀猛然回头,看着紧闭的房门,死死抱住子鹏的手臂,撕心裂肺地痛哭起来。子鹏搂住她,抚摸着她的头,眼泪同样淌过了面颊。\n刘俊卿跌跌撞撞走在雨中,他心中只有一个念头:他要拜祭父亲。秀秀不肯原谅他,不让他给父亲上香,他要找到父亲的坟墓,要去父亲的坟前磕头上香。\n“义士刘三根之墓”——七个血红的大字映入眼帘,全身透湿的刘俊卿呆若木鸡,一双膝盖再也支撑不住,猛地跪倒在坟前泥水里,任由雨水冲刷着他的全身。雨水顺着他的头发,淋过他的脸——他的脸上,早已分不清雨水与泪水。\n坟头新垒的泥土被雨水冲刷得滑落了下来。几乎是下意识的,刘俊卿伸手拦挡着滑落的泥土,要将泥重新敷上坟堆,但雨实在太大,泥浆四面滑落,他挡得这里挡不得那里,越来越手忙脚乱,到后来,他已是近乎疯狂地在与泥浆搏斗,整个人都变成一个泥人!“爸,爸……”他猛地全身扑在了坟堆上!压抑中爆发出的哭喊,是如此撕心裂肺,那是儿子痛彻心底的忏悔!\n一把雨伞悄无声息地遮住了他头上的雨。刘俊卿回过头,一贞打着雨伞,正站在他的身后。“一贞?”愣了一阵,刘俊卿突然吼了出来:“你还来干什么?你走,你走开!”手足并用,他连滚带爬地退缩着:“我不是人,我不是人!我这种狗屎都不如的东西,你还来干什么?你走,你走啊……”仿佛是耗尽了全身的力气,狂乱的喊叫变成了无力的呻吟,他一把抱住了头:“你走啊……”\n一贞默默地走上前,将遍身泥水的刘俊卿搂进了怀里。“一贞。”刘俊卿猛地一把紧紧抱住了一贞,哭得仿佛一个婴儿,“一贞,一贞,我该怎么办,我该怎么办?汤芗茗要我干侦缉队长,要我干那咬人的活,他恨不得我见人就咬一口,要咬得又准又狠,咬中那人的痛处。他要我拿枪,要我用拿笔的手拿枪杀人啊!”\n“俊卿,要不,咱们去找找纪老师,让他帮着求求情。”“纪老师?纪墨鸿?哈!一贞,你知道纪墨鸿是什么人吗?他让我去一师抓孔校长,让我欺师卖友,让我背黑锅!”大风大雨中,刘俊卿的嘶吼声仿佛受伤的野兽。\n“没关系,俊卿,没关系的,你不想做那个侦缉队长,咱们就不做。我们不拿枪,不杀人,你不是喜欢读书吗?我们回去读书。”赵一贞流着泪说。\n“回去?”刘俊卿冷笑,“回去?回去哪里?第一师范?他们恨不得挖我的心,喝我的血,又怎么会让我回去。退一万步讲,即使一师还要我!一贞,你怎么办?我能眼睁睁看着你嫁给老六那个流氓!”\n赵一贞慢慢松开刘俊卿,脸白如纸,“你都知道了?你怎么知道的?”“我怎么可能不知道!这七天来,我看着老六一趟一趟地往你家跑,看着他把扎红带彩的三牲六礼一趟一趟往你家抬,看着你爹收下老六的婚书,看着他跟老六赔笑脸,我是个男人,我是个男人啊!”\n“别说了!俊卿,别说了!”赵一贞再也听不下去,用尽全身力气喊了出来。她捂着脸,泪水从指间不断涌出来,“俊卿,求求你,别说了。”\n刘俊卿把她的双手从她脸上拿开,十指交叉,两个人四只手交叉在一起,这时的他,已经完全平静下来,“一贞,你放心,我不会让老六得逞的。”\n四 # 汤芗茗来到湖南之后,任命张树勋为警察长,以严刑峻法治理湖南,大开杀戒,仅这两个月被杀的就不下千余人。三堂会的娼嫽、烟馆、赌场也被封的封,关的关,生计越发艰难起来,不得不重操旧业,做起码头走私鸦片的活计。\n马疤子这趟货走得提心吊胆,满满30箱鸦片,几乎是三堂会的半副身家,这天夜里,货刚到长沙码头,没等他和押货的老六松口气,只听得“闪开!都他妈闪开……”一阵气势汹汹的吼声,荷枪实弹的侦缉队特务们一拥而上,拦住了一大帮正在卸货的三堂会打手。\n守在一旁的马疤子腾地站了起来,老六赶紧上前:“怎么回事?你们要干什么?”\n“没什么。”特务们一让,刘俊卿出现在面前,他一把推开了拦路的老六,举起一张纸向马疤子一晃,“奉上峰令,检查鸦片走私而已。”向特务们一挥手,“给我搜。”\n老六等人还想拦挡,马疤子却抬手制止住手下。\n特务们乒乒乓乓动起手来。\n很快,一个个特务跑了回来:“队长,没有。”\n马疤子笑了:“怎么样啊,刘队长?我马疤子可一向奉公守法,就靠这老实本分的名声混饭吃,今天这事,不能搜过就算吧?”\n打量着满地打开的货箱,刘俊卿一言不发,走上前来。翻翻箱子里的货,不过是些稻草裹鸡蛋,果然并无可疑之处。他的目光落在了用来当扁担抬货箱的一根根竹杠子上——那些杠子根根又粗又大。刘俊卿突然笑了:“马爷做生意,可真是小心啊,一箱鸡蛋才多重?也要用那么粗的竹杠子挑,太浪费喽。我看,这一根竹杠,劈开了至少能做四根扁担,要不,我帮帮马爷?”\n他抬腿就要踩脚边的竹杠。\n“刘队长、刘队长,有话好商量。”马疤子的脚抢先撂在了竹杠子上,“刘队长,给个面子,有话慢慢说。”\n两人进了码头附近一家茶馆的包间里,把手下都留在了门外。\n“这读书人就是读书人,脑筋就是转得快。不瞒刘队长,我马疤子吃这碗饭有年头了,能看出我这套把戏的,你算头一个。”马疤子满脸堆着笑,凑到了刘俊卿面前,说,“愿意的话,到我三堂会,有饭一起吃?”\n刘俊卿“哼”了一声,心里想:“敲竹杠”这样的手段,早就不是什么新鲜玩意了,你还敢在老子面前玩?\n“这侦缉队能挣几个钱?只要你进我三堂会,这二把交椅马上就是你的,凭你这脑袋瓜子,包咱们兄弟有发不完的财。”马疤子还想劝,看看刘俊卿一脸不屑,也便收了声,“刘队长还是看我们这行不上啊。那好吧,我也不勉强,一句话,你什么时候想通了,我什么时候欢迎你。我要的,就是你这种聪明人!”说完把手一拍,老六掀开帘子进来了,将一口小箱子摆到了刘俊卿面前。马疤子揭开箱子盖,露出了满满一箱子光洋,光洋的上面摆着那份婚书。\n刘俊卿拿起那张婚书便起了身:“别的就不必了,我只要这个。”\n五 # 因反袁而导致的第一师范孔昭绶事件,震惊了民国之初的全国教育界。因遭到袁世凯的全国通缉,孔昭绶被迫逃往上海,第二次赴日本留学。\n孔昭绶潜出长沙的那天,毛泽东也正在问自己老师和同学:“教育真的能救国吗?校长曾经告诉我,教育能救国,我也曾经以为,只有我们这些受教育的青年,才是中国未来的希望。可今天我才知道,搞教育的,连自己都救不了,那教育又怎么救别人,怎么救这么大的国家呢?”\n“我回答不了你的问题,润之,因为我也苦恼。”蔡和森沉吟了好一阵,又说,“但我还是相信,人会进步,社会会进步,国家也会进步。而进步,是离不开教育的。”\n“我也相信过,社会一定会进步,我也相信过,人,一定会越变越好,可为什么我们的身边并不是这样?有的人,有的事,真的能靠教育,真的能靠空洞的理想就改变过来吗?”\n“靠读书,也许是不能救国,靠教育,也许也不能改变一切。”杨昌济道:“但只有读书,我们才能悟出道理。只有读书,你今天的问题,才有可能在明天找到答案。除此以外,你还有什么更好的办法,破解你心中的疑团呢?”\n江水浑浊,无语北去。一团疑云也在毛泽东的心头渐渐升起,越来越大。\n第3部分\n[手机电子书网 Http://www.bookdown.com.cn]\n第十七章 新任校长 # 大幅 “第一师范增补校规条例”\n一张接一张,贴满了整个一师公示栏。\n章下有则,则下有款,款下有条,条再分一二三,\n蝇头小楷,密密麻麻,洋洋乎大观,\n最后落着校长张干的签名和大印。\n一 # 周末,天空中阴沉沉一片,大雨倾盆,打得人几乎睁不开眼睛。一师校园里,毛泽东光着膀子,在双杠间上下翻飞,雨水从他的头发、身体四处淋下,他全然不顾,任由大雨冲刷身体。萧三、罗学瓒匆匆从外面赶回来,直接找到毛泽东。\n萧三迫不及待地说,“润之哥,教育司给咱们一师派了个叫张干的新校长,听说是纪墨鸿推荐的。”罗学瓒也说,“你想想,纪墨鸿推荐的角色,能有什么好人?”“好人坏人要来了才知道,现在担心?太早了点吧?”毛泽东不经意笑笑,继续他的双杠动作。\n“那今晚读书会的活动还搞不搞?”看到毛泽东的笑容,萧三稍稍放心了些。毛泽东停下来,“搞!怎么不搞?”\n“可是——”萧三正想说点什么,一转眼,看着黎锦熙伞也没打扬着手匆匆跑过来,溅得长衫上又是泥又是水。“黎老师,您这是干什么,也来学润之雨中修身?”黎锦熙为人向来不拘小节,萧三这些学生最喜欢跟他开玩笑。\n黎锦熙一把拉住毛泽东,“那个……那个,张校长很关心你,要你以后下雨天不要出门,以免淋出病来。”\n“张校长?”毛泽东好半天才回过神来,“黎老师是说新来的校长,他来了?看来他还管得蛮宽的,刚来就管到我头上来了。”“润之,张校长这也是关心你。”黎锦熙说。\n毛泽东见黎锦熙一副左右为难的样子,遂从双杠上跳下来,抬头看了看对面楼上校长室紧闭的窗户,笑着说,“这张校长刚来,怎么也得尊重尊重,黎老师就不必为难了,大不了以后找个张校长看不见的地方修身。”\n当晚的读书会上,杨开慧听说此事,笑得直不起腰,用拳头捶着毛泽东结实的脊背,“毛大哥会被雨淋病?这校长长没长眼睛啊?”斯咏拉住开慧的手说:“人家也是关心润之的身体,应该也是出于好意。”\n毛泽东:“大概吧?就是管得也太宽了一点。哎呀,不管他,我们搞我们的。”他站起身来,将手里一本《青年杂志》创刊号往桌上一放:“大家安静一下,今天,我们讨论一个新的内容。《青年杂志》发刊词——陈独秀先生的《敬告青年》!国人而欲脱蒙昧时代,羞为浅化之民也,则急起直追,当以科学与人权并重。陈独秀先生的这番话,真正讲到点子上,中国的问题在哪里?就是不重科学,就是不讲……”\n“这是在干什么?”突然推开的门打断了毛泽东的慷慨激昂。一位身穿紧巴巴的日式文员制服的中年人出现在门口,扣子扣得一丝不苟,脸色苍白而瘦削,戴着一副略略有些老式的金丝眼镜。\n毛泽东放下了手里的杂志问,“你是谁?”“本校校长——张干!”所有的人都吓了一跳,大家纷纷站了起来,只有毛泽东还坐着。蔡和森解释说:“张校长,是这样,我们正在搞读书会的讨论活动……”\n张干打断他,“男男女女,半夜三更,讨论?——谁发起的?”毛泽东这才站起身:“我发起的。”听他喉咙还蛮粗,张干瞟了他一眼:“你哪个班的,叫什么?”“本科第八班,毛泽东。”\n张干打量着毛泽东,显然对这个名字还有印象:“马上解散!”“为什么?”毛泽东不服气。\n“学校是读书的地方,不是给你搞什么讨论的地方!”张干看了一眼斯咏、警予这几个女生:“你们几位是哪里的?”警予头一扭,没理他,还是斯咏主动回答:“周南女中。”“第一师范是男校,外校女生深夜滞留,多有不便。”张干向门口一指,“几位,请自重吧。”警予脸都气白了,开慧也是一脸忿忿,斯咏赶紧拉了她俩一把,几位女生都气呼呼地向外走去。\n张干又冲其他人呵斥:“还站在这里干什么?都给我回寝室!”众人无奈,纷纷散去。毛泽东气得把杂志往桌上一拍,一屁股坐下了。张干一眼瞥见,“毛泽东,你怎么还不走?”\n“我住这个寝室,走什么走?”毛泽东收拾起桌上的杂志和笔记本,气呼呼地起身往外走。“你不是住这个寝室吗?怎么又出去?”“不让讨论,我去阅览室看书可以了吧?”“你不看看现在几点了?阅览室早就关门了,你还去什么去?”“我有钥匙。”“学校阅览室,你一个学生哪来的钥匙?”“我看书看得晚,以前孔校长照顾我,特批的。”张干手一伸:“把钥匙交出来。”\n毛泽东愣住了:“这是孔校长给我的。”\n“我是张校长,不是孔校长!熄灯就寝,这是学校的校规,你不知道吗?交出来!”\n毛泽东万分不情愿地把钥匙放在了张干手上。张干顺手又把他手里那本《青年杂志》拿在手里:“这种跟课业无关的杂书,以后不要再看了!没收!”\n张干离开之后,毛泽东愣了半晌,一拳砸在墙上,满肚子火不知从何发起。\n二 # 第二天一大早,大幅“第一师范增补校规条例”一张接一张,贴满了整个一师公示栏。章下有则,则下有款,款下有条,条再分一二三,蝇头小楷,密密麻麻,洋洋乎大观,最后落着校长张干的签名和大印。众多学生围在公示栏前,眼前如此纷繁庞杂的条例规章把大家都给看呆了。\n“学生不得经营一切非关学术之事业,不得入一切非关学术之党社及教育会。润之兄,这是不是在说我们的读书会啊?”周世钊扶着眼镜读着校规。\n“不得散布谣言,发布传单,或匿名揭帖,鼓动同学,希图扰乱……虽盛暑严寒,必着制服,不得用妖冶华丽之时装,不得裸体、赤足……润之兄,这分明是在针对你嘛,你那天打赤膊雨中修身,他不是还让黎老师管你来着。”萧三也说道。\n“管天管地,管人拉屎放屁!”毛泽东懒得再看后面的内容,扔下一句话,排开人群就走。\n张昆弟继续读着条例,“不得干预外事,扰乱社会之秩序,不得有意破坏校内一切规则。不得停课罢学,不得私自开会演说,什么嘛,这分明在说孔校长反日反二十一条反得不对,不行,我得找他理论去!”张昆弟越读越觉得恼火,蛮劲上来,撸起袖子就要跑去校长室辩个究竟,周世钊、萧三等人也跟在后面跃跃欲试。\n“昆弟,不要冲动!”蔡和森一见情形不对,一把拉住张昆弟,想出了个折中的法子,“这样吧,我们先去问问黎老师,看这是怎么一回事再说。”\n众人来到黎锦熙的办公室,发现方维夏、陈章甫等几位老师居然都在,脸上还一副苦相。\n周士钊眼尖,一眼看到方维夏的办公桌上摆着一本小册子,封面上写着:《第一师范教职工工作条例》。他随手拿起,正要翻开看。方维夏要阻止,黎锦熙却拦住他,“让学生们看看也好。”\n周士钊翻开手册第一页,上面赫然写着——\n本条例计总则14条,下分各项职务细则14类,计校长11条,学监58条,庶务21条,会计11条,教员13条,事务员6条,文牍5条,管图书员5条,管仪器员6条,校医7条,实习主任8条,校园主任7条,工场主任6条,膳食主任5条。具体条例如下:校长,一,主持全校事务,聘请各职教员;二,督率全校职教员忠实尽职;三,规定本校一切规程,并执行政府官厅所颁布之法令;四,酌定学生入学、退学、升级、留级、毕业、休业及赏罚各事项;五,视察全校管教状况,审查教本,并核定学生操行、学业、身体各成绩……\n张昆弟摸了摸脑袋,惊呼:“天啊,方老师,你们的规矩定得比我们还多。”\n方维夏对同学们说,“这个条例我们也是刚刚拿到,这样吧,你们回去上课,这些事,让我们老师出面跟张校长好好谈谈。”\n劝走学生之后,黎锦熙、方维夏来到校长办公室,外面雨下得正大,黎锦熙把还在滴水的雨伞随手放在了墙角,水流在地板上。张干看了一眼,一言不发地拿起那把伞,小心移到门外,黎锦熙不禁有些尴尬,打量着这间熟悉而陌生的校长办公室。\n孔昭绶性格豪爽,喜欢结交朋友,畅谈交心。老师也好学生也罢,甚至一师的勤杂工人,他都能打成一片。他在的时候,校长办公室常常是人来人往,笔墨、书籍、报纸都放在触手可及的地方,略显零乱倒也不失方便。相比之下,张干则内敛严肃得多,公事之外少有谈笑的时候。即便是公事,也常常是三言两语命令了事。他来了之后,这间办公室被打扫得一尘不染,笔墨纸砚,书籍报纸都分门别类,各就各位。办公桌上,孔昭绶钟爱的那方刻着“知耻”二字的镇纸已不知去向,取而代之的那一方,上面刻着个“诚”字。\n方维夏一见这个情形,临时改变主意,条例的事还是不要开门见山的好,他正在考虑怎样委婉措辞之时,只听见张干说,“黎老师,方老师,你们来得真好,我这里有个通知,麻烦你马上下发全校。”\n黎锦熙接过通知一看,顿时愣住了,“月考?还每门都考?”\n黎锦熙激动起来,正要说话,一旁的方维夏对他使了个眼色,两个人一起出了校长办公室之后,方维夏说,“张校长刚来,不了解一师的情况,以月考的形式摸摸学生的底,履行一校之长的职责,这没什么不对吧。”\n方维夏一席话点醒了黎锦熙,“月考算什么,考就考,别的不敢说,一师的这些学生,我对他们有信心。等考试成绩一出来,我们再把校规条例的事摆一摆,张校长也是搞教育的,哪有不同意的道理。”\n“什么,月考?”学生宿舍里,听到消息的毛泽东瞪大了眼睛。\n罗学瓒等人都是一脸的不满,说:“刚宣布的,这个月开始,每月一次,门门功课都要考!”\n周世钊说:“这个张校长,期中期末还不够,是不是想把我们考死?”\n易礼容说:“难怪听说他是纪墨鸿推荐的,现在我才明白了,还是因为孔校长得罪了那个汤屠夫,他故意派这个张干来整我们一师的。”\n易永畦说:“话也不能这么说,张校长可能是想抓好学习……”张昆弟打断他,“只有你老实!抓学习?他张干来了才几天,你看看出了多少花样?加校规加校纪,取消读书会,增加晚自习,连润之兄出去搞锻炼他都不准。现在又是什么月考,不是整人是什么?”\n毛泽东说:“人善被人欺,马善被人骑,我看啊,不能让他把我们当软柿子。”\n罗学瓒眼前一亮:“润之,你有什么主意,我们听你的,你说,该怎么办吧?”张昆弟、周世钊等人都安静下来,竖着耳朵听毛泽东的主意。\n谁知毛泽东轻描淡写地说,“怎么办我不知道,反正他考他的,我就不理他那一套,他能怎么样?”说完,他拿起饭碗,“走,吃饭去。”\n张昆弟、罗学瓒相互交换了个眼神,很显然,他们从毛泽东这句话中受了启发,两个人脸上挂着会心的微笑,勾着肩膀出了宿舍。\n月考成绩很快就出来了,按照张干的吩咐,按分数排出名次贴在了公示栏,前十名写在红榜上,后十名写在白榜上,中间的名次写在绿榜上。与往常不同的是,同学们这一次关注的重点不是红榜,反而是白榜,在那里指指点点,高声大笑。\n一个同学说,“我原以为我够厉害的了,原来还有比我更猛的,唉,居然让你上了白榜。”\n另一个同学一拍大腿,“早知道我就干脆交白卷。”\n人群里,也在看榜的黎锦熙和方维夏两个已经惊得说不出话来了,两个人悄悄出来,低着头闷声朝办公室走去。果然,还没到门口,就听到张干嘶哑的声音,“这是怎么回事?这是怎么回事?一次月考,全校三分之二的学生不及格!这……这些题目并不难呀,怎么会考成这样?难道这就是一师的教学质量?这样下去,一师还能出几个合格的毕业生?不行,全校补课!马上补!方主任,黎老师,你们通知下去,从今天开始,每天晚上增加两节晚自习,星期天全天补课,还有,取消课间操,把做操的时间,并进上午第二节课。”\n方维夏和黎锦熙都愣住了:方维夏说,“校长,这样怕不妥吧?”张干说,“有什么不妥?学生成绩都成这样了,还不补怎么得了?”黎锦熙急了,“可补课也没有这样补法的。学生也是人,连课间操都不做了,哪有这样压着学的?”\n张干说,“读书就是要有压力!不压哪来的成绩?”黎锦熙反问,“可张校长压来压去,压出成绩了吗?”张干指着黎锦熙说,“你是说——倒是我害学生成绩变差了?”方维夏拉了黎锦熙一把,黎锦熙却把他的手一甩:“反正孔校长手上,一师的学生,从来没有这么差的成绩!”\n“你……”张干腾地站了起来,颤着声音说,“黎锦熙,我是第一师范的校长,第一师范的教学怎么进行,我说了算!”黎锦熙也站了起来:“那我也可以告诉张校长,这样的教学方式,我绝不赞成!”\n黎锦熙回到自己的办公室,心中犹如有一团火在烧,拿起笔,辞职信一挥而就。但完成之后,他又坐了下来,看着辞职信发呆。方维夏从后面追过来,推开门,看到黎锦熙手里的辞职信,脸色都变了,一把抓起,揉成一团,“锦熙,你……”\n正在这时,蔡和森、毛泽东、张昆弟、罗学瓒等几个同学也闻讯起来了,他们站在办公室门口,一个个像做错事的孩子,低头看着地面,不敢进来。\n黎锦熙笑笑,对同学们说,“都站在门口做什么,快进来。”说着,又从抽屉里拿出几封信,递给方维夏。方维夏打开一看,都是来自北京大学文学院的邀请信,最早的一封日期是半年前。方维夏抬起头,“锦熙,这……这是好事,你不是一直提倡言文一致,国语统一吗,这可是实现理想的大好机会,你怎么不早说?”\n黎锦熙笑着说,“我现在不正在说吗?”他转过头,对毛泽东说,“润之啊,老实说,这一次的事件,是不是你的主意?”毛泽东莫名其妙,“我的什么主意?”\n蔡和森忍不住质问,“润之,敢做就要敢当,这次月考的事,我听说是你发动同学,让大家通通不要考好成绩,给校长一个下马威,是不是?”\n毛泽东有点摸不着头脑,不知这矛头怎么一下子都朝着他来了,“这是怎么一回事,你不要冤枉我了啊,月考我是反对,但我也没有发动同学顶着干啊。”\n罗学瓒一见势头不对,忙上前解释:“黎老师,方老师,这件事真的不怪润之,他只是对月考有意见,说了几句,我们觉得他说得在理,所以,就悄悄联络同学顶着干了。”\n黎锦熙连连叹气,“你们怎么能这样呢?对月考有意见,你可以提嘛。哦,串联同学,故意考差,这就是你们的办法?为了目的,为了结果,也不能不讲方法,不讲手段吧?用这样的手段,只会适得其反,你们知不知道?”他又转向毛泽东:“你也是,他们这么干,你不可能事先不知道,大家平时都听你的,你要是劝阻一句,还会发生这种事吗?”\n毛泽东不服气,挺着脖子说,“张校长定的那些校规,是不合理嘛,我凭什么要劝阻啊?再说,顶他一下天也不会塌下来!”\n黎锦熙深深透了一口气,这才心平气和地对毛泽东说,“润之,不管怎样,我也要批评你几句。这次的事,张校长抓学习的方式可能是急了一点,但他终究还是为了你们的成绩,纵容大家串联同学,跟校长对着干,这算怎么一回事。你们对新出来的校规不满,本来我跟方老师商量,等你们月考成绩出来,跟校长坐下来好好谈,现在被你们这么一闹,唉……这样吧,杨老师出去讲学,也快要回来了。对学校的一些做法,你们就算有什么意见,也得等他回来,请他出面来解决。在此以前,不管张校长有什么要求,大家还是要服从,要记住自己是第一师范的学生,都记住了吗?”\n同学们依依不舍,一直把黎锦熙送出校门很远,眼见快要上晚自习了,这才返回学校。此时天色已暗,深秋的晚风颇有些刺骨的意思,吹到身上带着寒意。他们经过公示栏时,猛然发现那里又换了新花样,刚挂上的“距期末考试35天”的鲜红大幅警示即使在夜色中也赫然在目。罗学瓒几个对着警示撇了撇嘴,一脸的不满。张昆弟四处看了看,一个人影也没有,就走上前去,打算搞点破坏。他的手指还没碰到公示栏,另一只手比他动作更快,挡在了前面。张昆弟定睛一看,原来是毛泽东。\n“润之哥?”张昆弟不解。“黎老师刚才说的话,你就忘记了?”毛泽东说。张昆弟一听这话,脸色立刻变得沉重起来,不再说什么,把手收回来,跟着大家进了教室。\n教室里,手里抱着厚厚一堆资料的易永畦看见他们几个进来,连忙说,“你们来了,资料我都帮你们领了。”\n“什么资料?永畦,你病才好一点,这些事让我们来做就行了。”毛泽东连忙接过资料,拿在手里翻开一看,是厚厚一大本油印的《补充习题集》,再看课桌上,已经堆起了好几门课不同的补充习题、辅导资料等……毛泽东越看越心烦,“叭”地一声合上,正要发火,一旁的蔡和森推了推他,原来张昆弟他们几个,比他还冲动,一个一个正在用力把习题集砸在桌上,只差把它们撕成粉碎了,他赶紧大喊一声,“昆弟,你们几个做什么?!”\n“我撕了这些破玩意。”张昆弟话一出口,看到毛泽东、蔡和森等人一脸的不赞同,遂有些不好意思,摸摸脑门,“好了,好了,我做就是了。”\n张昆弟乖乖坐下之后,同学们也一个一个坐回位置,开始忙着那一本本习题集。做题的时间似乎过得特别慢,毛泽东忍不住想打哈欠,他本来还要忍,但见好几个同学也都疲倦得在打哈欠,也就不客气地伸起懒腰,大大打了个哈欠。\n另一张课桌上,易永畦咳嗽着,眼睛里全是血丝,好不容易做完了手中的一科功课,又伸手拿起一本作业来。就在这时,一阵咳嗽突然涌上,咳得他几乎喘不过气来,他用手帕捂着嘴,身子几乎弯成了一张弓。同学们都吓了一跳,纷纷围了上来:“永畦,怎么了……永畦……”\n毛泽东扶住易永畦,拍打着他的后背:“永畦,没事吧?”易永畦拼命忍着咳嗽,挤着尽量轻松的笑容:“没事,我没什么。”子鹏端来了一碗水:“永畦,喝点水吧。”\n“谢谢。”易永畦喝了口水之后,轻松多了:“好了好了,我没事了,谢谢你们了。”毛泽东还是不放心,“你真的没事?”易永畦说:“真的没事,只是刚才呛了一下,润之哥,还有功课呢,你去忙吧。”\n等大家纷纷散去,各自捧起了书本,易永畦才悄悄展开一直攥在手里的手帕,偷偷一看——手帕上竟然沾有血丝!他赶紧攥紧手帕,胡乱塞进口袋,生怕被同学们发现……\n好容易熬到晚自习下课,同学们总算松了口气,纷纷收拾东西准备离开,正在这时,教室门“吱呀”一声推开了,张干走了进来,径自走上讲台,“从今天开始,晚自习之后增加一堂课,今天补解析几何。”张干边说边在黑板上写下数学公式。\n同学们好半天才回过神来,只得强打精神继续听课。讲到一半的时候,电灯突然熄灭,教室外面传来校役的梆子声,“电灯公司拉闸了,各室点灯,小心火烛。”众人心中又升起隐约的希望,眼睁睁看着讲台上的张干。\n只见张干取出油灯,点燃之后,又拿出一个袋子,“前面的同学上来领蜡烛……我们继续上课……”\n三 # “子鹏,好一段没看见你上你姨父家了吧?”礼拜天子鹏一回家,王夫人就问起了儿子。\n子鹏这才想起来:“哦,我……忘了。”\n“怎么能忘了呢?你这孩子,斯咏是你未婚妻,你都不去看人家,人家还不当你没心没肺啊?下午就去,趁着礼拜天!”\n“我……还有功课呢。”\n王老板放下了报纸:“功课晚上做嘛。你跟斯咏,本来就走得不热乎,还不多来往,越发生疏了。按你妈说的,去!”\n吃过午饭,子鹏只得出门去陶家。秀秀的脚跟在子鹏的皮鞋后。但今天她却做不到往常的亦步亦趋,因为子鹏自己都心事重重,一副不知道要去什么地方的样子。前面,陶府的大门已遥遥在望。子鹏的脚步却停住了,犹豫了一下,他突然转身折回来路。\n秀秀紧跟上来问:“少爷,咱们不是上表小姐府上吗?”\n子鹏摇了摇头,看着秀秀,说:“我不想去那个府上,阿秀,找个清静点的地方,陪我坐坐吧。”\n两人漫无目的地打发着时间,不知不觉的,竟一前一后地来到了教堂前。子鹏站在教堂台阶下,凝视着教堂顶上的十字架,听庄严的教堂钟声在天际飘然回荡,看晴空下,鸽子群扑啦啦飞起,掠过教堂哥特式的拱顶和高悬的十字架。这静谧的宗教世界仿佛是一片世外桃源,隔断了世俗一切。子鹏在台阶前坐下了,拉了拉身边的秀秀,说:“阿秀,陪我坐坐吧,坐到我身边来。”\n“少爷,这……”\n“不要叫我少爷。这儿是教堂,在神的眼里,只有一个阿秀,一个王子鹏,没有少爷和丫环。”子鹏伸手握住了阿秀的手,“就让阿秀和王子鹏平等地一块儿坐坐,好吗?”\n望着子鹏坦诚的目光,秀秀犹豫了一下,在他的身边坐了下来。\n主仆二人在这个他们心里的世外桃源里,说着平常不容易说出口的知心话。却忘记了这里还是公共场所,不知道就在教堂旁的小街上,背着擦皮鞋的工具箱子,蔡和森与警予正并肩走来。\n蔡和森正在问警予,每个周末都来帮他擦皮鞋,会不会耽误警予的功课。\n警予白了他一眼,尖刻地问:“怎么,嫌我烦啊?”\n“我哪敢呀我?再说,有你帮忙,我挣的钱可多多了。”\n“那你还啰嗦什么?赶紧谢谢本小姐吧。哎!”警予突然一拉蔡和森,“那不是斯咏的表哥吗?”视线中,果然是子鹏与阿秀坐在教堂台阶上,正在说着话。警予向蔡和森一勾手指,“走,听听他们说什么。”\n“人家说话,你干嘛偷听?”蔡和森不想去。\n“那可是斯咏的未婚夫,瞒着斯咏在这儿拉拉扯扯的,我当然得听听。”警予一把拖着蔡和森就走,蔡和森又不敢出声,只得跟着警予,绕向教堂的一侧。\n台阶上,子鹏喃喃地,仿佛是在对阿秀倾诉,又更像是在自言自语:“过去,斯咏不愿意见我,我还不明白为什么,总觉得是我们来往太少,缺乏了解。现在我才明白,不想见一个人,却非要去见,是一种什么样的感觉。”\n“可您和表小姐定了亲的呀。”\n“定了亲又怎么样?定了亲就等于有感情吗?斯咏是那么热烈,那么奔放,她需要的,不是我这样性格柔弱的人,而我,每次跟她在一起,也总感觉是那么别扭,那么不自然,我和她根本不是一路人,又何必勉强在一起,互相破坏对方心里那份自然和宁静呢?\n墙角,警予偷听着,不住地点头。她身边的蔡和森显然觉得偷听别人的私语很不妥,想拉她走,却反而被警予用力按住。他哪里拗得过警予,只得陪着一起偷听。\n“我喜欢生活得简单,我喜欢宁静的日子。”台阶上,子鹏扭过头看着秀秀,说,“阿秀,倒是跟你在一起的时候,我会觉得非常非常的平静,非常非常的自然,这种感觉,根本不是跟斯咏在一起时能找到的。”\n秀秀有些慌乱地赶紧侧过身:“我只是个丫环,哪能跟表小姐比?”\n“不,在我心里,你比斯咏强得多。为了供你哥上学,为了照顾你生病的父亲,你吃了多少苦,受了多少罪,可你都一个人默默地扛着。如果说过去我还以为自己有多么善良的话,那么是你,告诉了我什么是真正的善良,什么是真正的坚强。尽管你很少开口,可我觉得,你,才是我最好、最知心的朋友。”子鹏说着话,一把握住了秀秀的手。\n眼泪湿润了秀秀的眼眶,望着子鹏,她似乎想说点什么,但又不会表达,只得看看被子鹏握着的手,轻轻垂下了头。\n“以后,我再也不去陶家了。爸爸妈妈非要我去,咱们就到这儿来,像现在这样,像一对最好的朋友,安安静静的,坐在神的脚下,让我们的心,更纯净,更安宁,好吗?”\n“我给你唱首歌吧,唱一首我们老师教我们的歌唱圣母的歌。”看到秀秀点了头、答应了自己,子鹏轻轻唱起了古诺的《圣母颂》:“圣母玛利亚,你是大地慈爱的母亲,你为我们受苦难……”\n宁静的歌声中,墙角的警予缩回了头。蔡和森还没发现她的情绪变化,正想探头往台阶那边看,警予一把将他揪了回来。他这才发现,警予的眼圈都红了。默默地沿着教堂后僻静的小街走出了老远,警予还在边走边擦着眼眶里的泪水。蔡和森忍了忍,还是问道:“怎么了你?”\n“受感动嘛。你不感动啊?”\n“你刚才还说他们拉拉扯扯的。”\n警予用胳膊肘一顶蔡和森:“你们男的怎么都这么没心没肺?人家说得多诚恳,多打动人啊?我都被他感动。你呢,死木头一个!”\n看到路边的石凳子,警予直着身子气哼哼地走过去坐下了。蔡和森也不知道她在生什么气、为谁生气,在她身边坐了下来,还傻乎乎地反问着:“可你不是说他是斯咏的未婚夫吗?”\n“他都说了,他们俩不合适嘛。我看也是,他呀,还是跟那个小丫环合适。”\n“人家把阿秀是当朋友,没你想的那么复杂。”\n“为什么不能复杂,为什么就不能复杂呀?我看他们俩就应该在一起。反正啊,今天的事,我绝不告诉斯咏,就要让他们发展下去。”\n“一个少爷,一个丫环,真要发展也难。身份地位差别那么大,真要发展下去,只怕也是个悲剧。”\n“要我说,阔少爷就应该配丫环,穷小子呢,就应该追求小姐,这样的爱情才是自由的爱情,什么身份地位,什么传统观念,通通见鬼去!”警予扬起拳头,威胁蔡和森,“赶紧赞成我一句。”\n蔡和森赶紧捂住了头,忙不迭地赞叹着:“你说得对,说得太对了。”\n“这还差不多。”警予仰头望着蓝天白云,长长舒了一口气,“要是人人都能有王子鹏那样的勇气,人人都能自由自在地追求心中的幸福,那该多好啊。”\n望着警予映着晚霞的脸,蔡和森的心里,突然涌起一阵激荡。悄悄地,他把手一寸一寸地向警予的手挪去,眼看手就要碰到警予的手,“当”的一声,教堂的钟声却在这时突然响起。蔡和森的手条件反射似的往后一缩,然而,不等他真缩回去,警予却一把抓住了他的手、一只手指着天空,兴奋莫名地叫道:“哎,哎,鸽子,鸽子!你看啊,你看啊!要是我能变成一只鸽子,那么自由,想飞就飞,该多好啊。”\n一大群鸽子刚刚被钟声所惊起,扑啦啦从教堂的顶上掠过,展翅飞翔在空中,但蔡和森的心思却不在这些鸽子身上……\n虽然明知警予只是情不自禁地握着自己的手,蔡和森还是情不自禁地脸热心跳。\n那天夜里,蔡和森的心情不能平静。躺在床上,他手枕着头,眼睛睁得大大的。辗转中,他索性一翻身爬了起来,悄悄跑到八班寝室,把毛泽东叫了出来。并排躺在草坪上仰望着夜空,蔡和森问毛泽东:“你说,这个世上,你最爱的人是谁呀?”\n“我娘。”毛泽东正睡得迷迷糊糊,被蔡和森强行拽到这里,头脑还是昏沉沉的。\n“妈妈不算。我是说除了亲人。”\n“那我倒没想过。你怎么问起这个来了?”这么古怪的问题,再加上外面凉爽的空气,终于让毛泽东清醒了。\n“随便问问嘛!哎,你就没有觉得哪个人跟你特别投缘、特别亲近吗?”\n“嗯,有的,杨老师。”\n“长辈不算。”\n“那,开慧,我跟她蛮亲近。”\n“太小的也不算。”\n毛泽东坐了起来,冲着蔡和森吆喝道:“我说,你到底想讲什么呢?东拉西扯的。”\n“没什么,我就是……你就没觉得有哪个同龄人特别让你觉得没有距离吗?”\n“你呀!”\n蔡和森瞪着一脸茫然的毛泽东,真不知道该怎么跟他继续说下去了。\n“毛泽东,蔡和森!”\n张干威严的声音骤然响起,那两个半夜起来谈心的人吓得赶紧爬了起来。\n“半夜三更,为什么夜不归宿?还不给我回寝室?”\n两个人哪敢作声,赶紧掉头就走,身后传来张干凶巴巴的吼叫声:“明天写检查,交到校长室!还有,打扫三天走廊!”\n第十八章 易永畦之死 # 去去思君深,思君君不来。\n愁杀芳年友,悲叹有余哀。\n衡阳雁声彻,湘滨春溜回。\n感物念所欢,踯躅城南隈。\n城南草萋萋,涔泪浸双题……\n一 # 此后的一师日程表上,便填满了一次又一次接踵而至的大考小考。整个学校像是一个大大的蒸笼,而学生们就像是蒸笼里的白薯,除了考试这个紧张的白色烟雾,什么都看不到。转眼间,在一师公示栏里,“距期末考试35天”的大幅警示已是赫然在目。学生们的课桌上已经堆起了几门课不同类型的补充习题、辅导资料,全把头埋在了高高的书堆里。白天如此,晚间补课也如此,停电以后,还要点起蜡烛继续奋战,身体好的同学已经吃不消了,像易永畦这样身体差的,更是顶不住,已经要端着药碗来上课了。但永畦尽管咳出血了,却还是悄悄忍着,一来不想让同学们担心,二来他也没钱治病。\n这样的状况却正是张干期待的。前任校长让他得到的教训,就是要把学生死死地拴在教室里,用繁重的功课压住他们,这样,他们就没有时间、没有精力、没有心思做那些会给他们带来危险的事情,也唯有这样,他们才会安全。\n这天,张干进了校长室,一如往常、不紧不慢地放下公文包时,看桌上有一封落着省教育司款的公函。他拿起来启开封皮,顿时愣住了。\n“砰”的一声,张干重重地关上校长室的门,沉着脸,脚步匆匆地赶到了教育司,把那份开了封的公函砰地拍在纪墨鸿办公室上!\n“老同学,你这是干什么?”纪墨鸿吓了一跳。\n“你还问我?你倒说说,你这是要干什么?”张干一把抽出了信封里的公文,读道,“‘从本学期起,在校学生一律补交十元学杂费,充作办学之资,原核定之公立学校拨款照此扣减’!我一师是全额拨款的公立师范学校,部颁有明令,办学经费概由国家拨款,怎么变成学生交钱了?”\n纪墨鸿叹了口气,无奈地说:“老弟,叫你收钱,你就收嘛。”\n“这个钱我不能收!公立师范实行免费教育,这是民国的规定!读师范的是些什么学生,他们的家境如何,你还不清楚?十块钱?家境差的学生,一年家里还给不了十块钱呢!你居然跟他们伸手,还一开口就是十块一个,你是想把学生们都逼走吗?”\n纪墨鸿一言不发,拉开抽屉,将一张将军手令推到了张干面前: “你也看到了,省里的教育经费,汤大帅一下就扣了一大半,要公立学校的学生交钱,也是他的手令,我能有什么办法?”\n“可教育经费专款专用,这是有法律规定的!”\n“老弟啊!枪杆子面前,谁跟你讲法律?孙中山正在广东反袁,他汤芗铭要为袁大总统出力,就得买枪买炮准备打仗。你去跟他说,钱是用来办学校、教学生的,不是用来买子弹、发军饷的,他会听你的吗?”纪墨鸿摇了摇头,起身来到张干身边,“老同学,我也是搞教育的,我何尝不知道办学校、教学生要用钱?我又何尝想逼得学生读不成书?可胳膊扭不过大腿,人在屋檐下,你就得低这个头啊!”\n长长地,张干无力地叹了口气。\n二 # 正如张干所言,一师的学生中,有几个家庭条件好的?比如毛泽东,要是家庭好,他怎么会来读一师呢?\n此时,还不知道要交钱的毛泽东正在校园里边走边读着一封母亲的来信: “三伢子,告诉你一个不好的事,你爹爹最近贩米,出了个大事,满满一船米,晚上被人抢光了……贩米的本钱,有一些还是借的。为这个事,你爹爹急得头发都白了一半。现在家里正在想办法还债,这一向只怕是没有办法给你寄钱了,只好让你跟家里一起吃点苦……”\n转过弯,突然传来一阵吵闹声,毛泽东放下手里的信看过去,只见公示栏前人头攒动,一片愤愤之声。毛泽东挤进人群一看,公示栏上,赫然是大幅的征收学杂费的通知。\n晚自习时,整个学校完全没有了往日的宁静,各个教室里,学生们都议论纷纷。\n“这次交学杂费,就是那个张干跟省里出的主意。”\n“上午好多人亲眼看见他喊轿子去教育司,中午一回来就出了这个通知,不是他是谁?”\n“他本来就是那个汤屠夫的人,汤屠夫赶走了孔校长,就派他来接班,汤屠夫要钱,他就想这种馊点子!”\n“什么鬼校长,就知道要钱!”\n不知情的学生们把所有的怨恨都发泄到了张干身上,但迎着学生们怀疑的、不满的、鄙视的目光,张干的脸上,居然平静得毫无表情。他能做什么?除了继续上课、保持学校的正常秩序,他还能干什么?他心里最清楚,唯有这样,他才能保住这些学生。但表情可以硬撑着,钱袋子却迅速地瘪了下去。这么大一所学校,每天有多少开支呀?只出不进,能够维持多久呢?张干正想着这一点,方维夏推开校长室的门进来,说:“张校长,食堂都快断粮了,经费怎么还不发下来?学生们还要吃饭啊!”\n张干沉着脸,一言不发。方维夏以为校长没听清楚说什么,就又重复了一遍刚才的话。张干还是没作声,只是缓缓地拉开抽屉,取出一叠钱来,又搜了搜口袋,摸出几块零散光洋,统统放在方维夏面前。想了想,他又摘下了胸前的怀表,也放在了钱上面:“先拿这些顶一顶吧,菜就算了,都买成米,至少保证学生一天一顿干饭吧。”\n望着面前的钱和怀表,方维夏犹豫了一下,问:“校长,您要是有什么苦衷,您就说出来……”\n“我没有什么要说的。经费的事,我会想办法,就不用你们操心了。你去办事吧。”张干挥了挥手,他所谓的想办法,就是直接去找汤芗铭。\n在汤芗铭的办公室外面,张干紧张地坐着。副官已经进去替他禀报了,可很长时间没有出来。他很希望能见到汤芗铭,当面把一师的情况向他汇报一下,他怎么都不能相信,教育经费真的会被挪用去充当军费,以为这里面一定有什么误会。\n副官终于出来了,对赶忙站起来的张干说:“张校长,大帅有公务在身,现在没空见你,请回吧!”\n“可是,我真的有急事。”张干想的,是一师几百师生的吃饭问题。\n“大帅的事不比你多?”\n张干无话可说了,他只得重新坐了下来:“那,我在这儿等,我等。”\n“张校长爱等,那随你的便喽!”副官不管张干在想什么,说话的口气比铁板还硬。\n呆坐在椅子上,张干看见有文官进了汤芗铭的办公室、有军官敲门进了汤芗铭的办公室、副官引着两个面团团富商模样的人进了办公室……张干挪了挪身子,活动一下酸疼的腰,习惯性地伸手去摸怀表,摸了个空,这才想起怀表已经没有了,不禁无声地叹了口气。恰在这时,门却开了,汤芗铭与那两名富商模样的人谈笑风生走了出来。张干赶紧迎上前去:“汤大帅,大帅!”\n汤芗铭颇为意外地看了他一眼,显然是不认识。\n张干赶紧说:“我是第一师范的校长张干,为学校经费一事,特来求见大帅。”\n汤芗铭挺和蔼地说:“哦,是张校长啊!哎呀,真是不巧,芗铭公务繁多,现在正要出门,要不您下次……”\n“大帅,学校现在万分艰难,实在是拖不下去了。大帅有事,我也不多耽误您,我这里写了一个呈文,有关的情况都已经写进去了,请大帅务必抽时间看一看。”\n“也好。张校长,您放心,贵校的事,芗铭一定尽快处理。不好意思,先失陪了。”汤芗铭接过呈文,客客气气地向张干抱拳告辞,与两名客人下了楼。\n张干这才长长舒了一口气,收拾起椅子上自己的皮包,张干也跟着走下楼来。前方不远,汤芗铭陪着客人正走出大门,谈笑风生间,他看也不看,顺手轻轻巧巧地将那份呈文扔进了门边的垃圾桶里。\n仿佛猝遭雷击,张干呆住了。\n三 # 因为张干的干涉,这个周末的上午,读书会的成员们不得不把活动地点改在距离市区比较偏远的楚怡小学子升小小的房间里。\n没有了往常的笑声,今天的气氛一片沉闷,大家都在谈论一师交学杂费的事情,蔡和森的意思,是希望大家冷静一点,有什么事,等杨老师回来再说。但毛泽东却扬言,不管杨老师回不回来,反正这个学杂费,他是不会交的。他还鼓动大家都莫交。看来,他已经把黎老师走的时候嘱咐他的话全忘记了。斯咏想到钱不多,希望毛泽东不要为了十块钱得罪校长。开慧却认为话不是这么说,即使是校长的话,好的大家可以听,歪门邪道就不能听。子升站起来支持斯咏的观点,大家争辩起来,很不愉快。\n“你们呀,都不用说了,谁爱交谁交,反正我不交,我也没钱,要交也交不起,他张干不是有汤芗铭撑腰吗?让他把我抓去卖钱好了。”任大家怎么说,毛泽东似乎已经铁了心。\n中午活动结束后,斯咏主动请毛泽东送她回家。一路上,两人并肩走着,毛泽东的脸色不好看,斯咏也不好多说什么,只是背在身后的手反复捏着一方手帕包成的小包,仿佛在酝酿着什么事,却又不知如何开口。到了陶府大门前,毛泽东完成任务,要准备回学校了。斯咏叫住他,伸出了背后的手,将手帕包成的小包递向毛泽东。毛泽东不明所以,接过来打开一看,居然是十来块光洋,问道:“你这是干什么?”\n“你不是没钱交学杂费吗?”\n毛泽东抓过斯咏的手,把钱硬塞回了她手里,坚决地说:“我不要!”\n“润之,你这又何必呢?为了十块钱,跟校长对着干,到时候,吃亏的还是你。你把钱交了,不就没事了吗?”\n“可这不是我一个人的事,全校还有几百同学呢!这种头,我不能带!”\n“润之……”\n两人正推推搡搡,陶会长板着脸站到了他们面前……\n目送毛泽东走远,父女俩回到家里。陶会长阴沉着脸盯着缩在沙发里的斯咏: “你跟那个毛泽东到底什么关系?”\n斯咏脸色苍白,情绪十分低落,她换了个坐姿,避开了父亲的目光,没有吭声。\n“我问你呢,那个毛泽东,到底跟你什么关系?”\n斯咏没好气地回答:“没什么关系。”\n“没什么关系?没什么关系你老跟他来往,你还给他钱?这像没什么关系吗?”\n一提到给钱的事,斯咏反而被刺痛了,她腾地站了起来:“我给钱怎么了?人家都不肯要,你高兴了吧?你还要怎么样嘛?”眼泪突然从她的脸上滑落了下来,仿佛是受了莫大的委屈,她竟伤心地抽泣起来。一转身,她哭着跑上楼去。\n陶会长呆了一呆,才回过味来:女儿的火,显然根本不是冲他发的。\n四 # 一路回来,因为刚才斯咏非要借钱给他的事情,毛泽东的心情很不好。他闷着头,匆匆走进校门,正遇到方维夏迎面跑过来,却不是在和他说话,而是越过他,和他身后的人说:“教育司纪司长来了,已经等您好半天,说是一师的学杂费至今还没上交,他专门来催款的。看他的样子,不太高兴。”\n毛泽东这才知道张干在自己身后,也不回头,径直朝教学楼走去。\n一师教学楼前厅的墙上,挂着“距期末考试只剩一天”的警示。纪墨鸿正在前厅里来回走动着,紧紧慢慢的脚步暴露了此时的心情。易永畦边咳嗽边捧着书本拐过弯,一不留神,正撞在纪墨鸿身上,吓得他把公文包失手掉到了地上。纪墨鸿正没处发火,逮住易永畦就是一顿训斥:“怎么回事?走路不长眼啊?”\n易永畦也被吓得不轻,连声说:“对不起,纪先生!对不起,纪先生!”\n“给我捡起来!”\n易永畦赶紧捡起公文包,双手递给纪墨鸿。纪墨鸿拍打着公文包上的灰尘,还不依不饶地训斥着:“这么宽的走廊,还要往人身上撞,搞什么名堂?”\n好几个经过的学生都远远躲开了,易永畦更是吓得不敢作声。毛泽东正从前厅走廊那头过来,远远地看到了事情发生的经过,几步跨过来,不满地对纪墨鸿说:“人家又不是故意的,凶什么凶?”\n纪墨鸿转向毛泽东,涨红着脸,问:“毛泽东,你说什么?”\n“我说大家都是人,用不着那么凶!”\n“还敢顶嘴?你……简直目无师长!”\n“我又没有开口就骂人,哪里目无师长了?”\n易永畦看看情形不对,赶紧一边鞠躬一边急切地说:“对不起,纪先生,都是我的错,对不起了,纪先生,都是我的错。”\n“不关你的事!毛泽东,我命令你,马上向我道歉,听到没有?”纪墨鸿看也不看易永畦,对毛泽东说。\n“对不起了,纪先生!”毛泽东硬邦邦地丢下一句,一拉易永畦,“永畦,走。”\n两个人转过身,却停下了,因为张干正板着脸站在前厅门口,冷冷地说:“你们两个,上操场,立正,罚站!”\n毛泽东拧着脖子问:“凭什么?”\n“新校规第十二条,学生侮慢师长,罚站半天。不记得了吗?”张干瞪着毛泽东说。\n“我们什么地方侮慢师长了……”\n“第十三条,怙过强辩,罚站半天。合起来,罚站一天。”\n可是……要罚罚我一个,易永畦又没开口,不关他的事。“\n“我说一起罚就一起罚!还不马上给我去?”\n夏日的阳光下,毛泽东与易永畦并排站在操场上。树上,蝉鸣声此起彼伏,仿佛它们也正热得难受。毛泽东胸前的衣服被汗浸湿了一大片。汗珠从易永畦苍白的脸上滚落,他轻轻咳嗽着,略显憔悴。\n校长室,张干呆呆地闷坐在办公桌后,任凭纪墨鸿将那份征收学杂费的公函拍在自己面前,敲打着。终于,纪墨鸿不能再忍受张干的沉默,转身出了校长室。张干一个人对着那份公函发着呆,一只手漫无目的地抚摸着那方“诚”字镇纸。已经黄昏了,他起身来到窗前,望着渐渐袭来的夜色里,那两个仍然在罚站的学生的身影,长长叹了口气,心里暗暗打定了一个主意。\n校役提着油灯来到毛泽东与易永畦面前,说:“毛泽东,易永畦,校长让我通知你们,可以回寝室了。”\n“永畦,走吧。”毛泽东吐了口气,活动活动站僵了的脚,走出两步,却不见易永畦跟上来,一回头,正看见易永畦顺着篮球架子,歪歪地滑了下去。\n毛泽东把脸色苍白如纸的易永畦背回寝室,扶到了床上。罗学瓒看子鹏端着杯水,在易永畦的床头怎么也找不到药,说:“别找了,永畦早就没药了。还不是那个破校长,天天逼着人交学杂费,永畦的家境本来就不好,他上哪去弄钱?还不是能省一分就省一分!”\n一句话弄得大家都沉默了,子鹏一跺脚,要马上出去买药,周世钊拉住了他说,半夜三更的,上哪去买?要买也得等明天呀。看看大家都在为自己担心,易永畦强打精神说:“其实,我也没什么事,休息一下,明天就好了。真的,明天还要期末考试,大家不要为我耽误复习了。”\n毛泽东听了这话,重重地叹了口气,给易永畦垫好了枕头。\n五 # 张干打定的主意,就是去找人筹钱。找谁呢?自然是长沙商会陶会长。在去的路上,张干想过陶会长不会很爽快地答应自己,也想过无数条他难为自己的理由。但当他面对陶会长,尴尬地把一师的难处说起来,并提出了自己的请求时,陶会长的条件却让他非常意外。\n“现在一师不单教师的修金,便是学生的口粮都已无钱购置,眼看就要难以为继。陶翁乐善好施,过去也曾多次慷慨解囊,捐资助学,故而张干老着脸皮,求到陶翁门外,还望陶翁体谅。”\n“那——张校长估计大致需要多少钱呢?”\n“这个——三……两千大洋吧。万一不行,暂借一千大洋,也可解一师燃眉之急。”\n陶会长沉吟着,终于开口了,说:“钱嘛,陶某倒还能想些办法——这样吧,我出五千大洋。不过,我有一个条件。我想让张校长答应我,开除一个名叫毛泽东的学生。至于什么原因,张校长就不必问了,总之,只要您把这个毛泽东开除出校,五千大洋,我马上送到贵校,就当是我的捐助,不必还的。”\n张干吃惊之余,腾地站了起来:“陶翁的条件,恕张干无法接受。张干今天冒昧登门,打搅陶翁了。”\n看他转身就要走,陶会长提醒道:“张校长,您这是干什么?毛泽东不就一个学生吗,您现在要救的是全校几百学生,孰轻孰重,您得考虑清楚啊。”\n“不必考虑了,再怎么样,我也不会拿一个学生的前途去换金钱的。”\n“张校长,”陶会长硬把张干拦住了,叹了口气说,“张校长,且听我把话说完好吗?本来吧,家丑不可外扬,但今天不把话讲清楚,张校长也不会明白这里头的原委,我也就只好直说了。事情是这样,贵校有个毛泽东,他组织些男男女女在校内外搞些什么活动,搞乱了学校秩序和风气,也有伤风化。我有个独生女儿,已经定了亲,她却受毛泽东的影响,追随他。哎!”\n张干目瞪口呆:“有这种事?”\n“说起来吧,也怪我这个父亲管教不严,未能及时发现。可我女儿好歹是定了亲的人,如再给毛泽东他们活动的机会,这要任其下去,万一闹出什么事来?不光我陶家,于贵校的脸上也不好看嘛。只要开除了毛泽东,这事也就过去了不是?”\n张干想了想,答应道:“事情若果真如陶翁所言,这样的行为,敝校也是绝不会允许的。”\n“千真万确!张校长,我也是没办法,才请您帮这个忙。这样吧,只要张校长点这个头,我捐一万大洋,明天就送到。怎么样?”\n张干坚决地说:“不,这是两回事。毛泽东如果并无此事,不管多少钱,我都不会开除他,否则,陶翁就算一分钱不出,我也一样会严肃处理。”\n出了陶宅,张干一路想着陶会长的话,坐车回了学校。他迈着沉重的步子,心事重重地上了教学楼,经过教务室时,听到虚掩的门里正传来一阵说笑声:\n“哈哈,有意思,有意思……”\n“……还真是又有大海又有太阳啊!”王立庵拿着毛泽东那张图画考卷,哈哈大笑。\n“你别说,两笔一幅画,还套上了李白的名句,这种绝招,也只有润之想得出来。”\n“反正我呀,拿他毛泽东,是哭不出也笑不出。”\n张干听到是在说毛泽东,推门进去问:“在说什么呢?这么热闹。”\n费尔廉说:“我们在看一个学生画的画,画得太有意思了,很有我们德国现代抽象派的风格。”\n“哦?我看看。”张干拿过毛泽东那幅画,愣住了,“这……这什么玩意?”\n陈章甫笑道:“半壁见海日啊,您看,一笔是海面,一笔是太阳,又简单又明了……”\n“什么简单明了?这也叫画?黄老师,这怎么回事?”张干严厉的口气使刚才轻松的气氛一扫而光,老师们不禁面面相觑,赶紧汇报说,不仅仅是图画课,还有那么几门课,毛泽东不是很感兴趣,成绩不是很理想……\n张干打断他们的话:“那你们就由着他想学就学,想考就考?就由着他拿这种鬼画符把考试当儿戏?”\n黄澍涛说:“这是孔校长以前特许的,说毛泽东是个特殊人才,他不感兴趣的课,不必硬逼着他拿高分,就当是一种因材施教的教育试验。”\n“简直乱弹琴!”张干把那张“半壁见海日”一拍,越想越气,“一个学生,不好好学习,视功课如儿戏,还能得到特许?这、这不是纵容学生乱来吗?”\n大家谁都不敢接腔,一时间,教务室里气氛紧张。就在这时,却传来了轻轻的敲门声,斯咏从虚掩的门后探出身来:“请问一师收学杂费,是在这儿交吗? 我来给毛泽东代交学杂费。”\n陈章甫惊讶地问:“给毛泽东代交?你是?”\n不等斯咏答话,一旁,张干扫了一眼斯咏,冷冷地说:“小姐是姓陶吗?毛泽东的学杂费,不必旁人代交。你走吧。”\n“可是……”斯咏的话还没说完,张干就毫不客气地一把将门贴着她的鼻子关上了。\n转过身,张干脸色阴沉得吓人:“陈老师,通知毛泽东,马上到校长室报到!”\n“毛泽东同学,叫你来之前,说实话,我对你身上暴露的问题是很有看法,甚至是有很大意见的。不过冷静下来一想,其实你身上这些缺点、毛病,也不能全怪你,应该说学校过去的教育方法也出现了偏差。既然是你有缺点,学校也有偏差,那就让我们共同来努力,改正这些存在的问题,你说好不好?”看着对面的毛泽东,张干坐在校长室自己的椅子上,字斟句酌地说。\n“我又存在什么问题了?”\n“你的问题,你自己还看不到吗?”张干不禁有些不快,但还是尽量平和地拿起那份考卷,“你说说,这叫怎么回事?一横一圈,这就叫半壁见海日?一个学生,怎么能这样对待学习,怎么能这样对待校规校纪呢?昨天才罚过你,今天你又是这样!屡教不改啊你!学校不是你家,不是菜市场,由不得你想怎样就怎样!你知不知道?”\n仿佛是发觉自己过于激动了,违背了初衷,他尽量平静了一下,接着说:“当然了,孔昭绶校长在这个问题上也有很大的责任,身为一校之长,不但不维护校规校纪,居然还对你放任自流,如此教育方式,怎么会不误人子弟?”\n毛泽东腾地站了起来:“张校长,你讲我就讲我,讲孔校长干什么?”\n“我是在帮你分析原因!”\n“那我也可以告诉你,孔校长是我见过的最好的、最称职的校长!比不上人家,就莫在背后讲人家坏话!”\n张干也腾地站了起来:“毛泽东!”\n“我在这儿!”\n张干指着毛泽东,气得连手指都在发抖:“好,好,好啊!我还说对你教育方法有问题,错!我看你是天性顽劣,不可救药!每次犯纪律的都是你,动不动就顶撞老师,难怪有人说上次是你在背后怂恿同学故意考差,别人家长在背后说你的空话……”\n毛泽东的眼睛猛地瞪圆了:“张校长,你把话讲清楚,我干了什么?”\n“你干了什么你自己知道。”\n“你……你瞎讲!”\n“怎么,心虚了?商会陶会长家的女儿,你跟她什么关系?人家家里早就看你不惯了,你居然还好意思去纠缠人家。”\n毛泽东的脸一下子涨得通红,他“砰”地一拍桌子:“你……你胡说八道!”\n张干简直都不敢相信自己的眼睛,一个学生,居然敢对校长拍桌子!一时间,两个人互相瞪着,房间里,只听见毛泽东呼呼喘粗气的声音!缓缓地,张干强压着全身的颤抖,扶着桌子坐下了。一指门口,他声音不大,却是一字一句:“出去。”\n毛泽东还愣着。\n猛地,张干几乎是声嘶力竭:“出去!”\n毛泽东转身冲出了校长室。“砰”的一声,房门被他重重摔上,声音之大,连桌上那方镇纸都被震得几乎跳了起来!\n几乎是大步跑回了寝室,乒乓一阵,毛泽东扫开桌上的东西,摊开纸笔砚台就写下了四个字: 退学申请。\n“润之!”蔡和森一把抓住了他的笔,“什么事都有个商量,犯得着那么冲动,挨了一回训就要退学吗?就算张校长讲错了,你也可以解释嘛。”\n易永畦咳嗽着,也挤上来说:“润之兄,我们都知道你不是那样的人,张校长是不了解你,你就别太计较了。”\n“润之,这件事都怪我。”斯咏走上前,“本来我只是想帮你,才来给你交钱的,没想到会给你惹出这些误会。要不我去跟你们校长解释清楚,好不好?”\n“我不要你们管!”毛泽东猛地一甩,把笔抢了过来,但纸上已被画了大大的一道,飞溅的墨水倒把蔡和森手上、身上都弄脏了, “丑话没讲到你们头上,你们当然讲得轻松!人家现在是在怀疑我的人格,是在讲我……反正我受不了这种侮辱!”\n斯咏说:“我说了我去解释……”\n“你算了!你不跑过来还好得多!”\n一句话令斯咏呆在了那儿!一刹那,眼泪猛地涌出了她的眼眶,她转身冲出了寝室。\n“斯咏,斯咏,”蔡和森追了两步,回过头,说,“毛泽东!你太不像话了!你要搞得人人都看你不顺眼吗?”\n“我就这样!看不顺眼莫看!”\n“好,好,你爱怎么办怎么办吧。谁都别管他,走!”蔡和森冲出了寝室,几个同学跟在他身后,也出了寝室。\n毛泽东越想越窝火,他一把将那张画坏了的纸揉成一团,扔到地上,又抓起一张空白纸,重重地拍在桌上。\n六 # 冲出校门,斯咏抽泣着一路跑去。蔡和森等追到学校门口时,斯咏已哭着跑远了。\n停住脚步,蔡和森重重地叹了口气,却看到杨昌济提着行李从停在校门口的人力车上走下来,忙把刚才发生的事情一五一十地全给老师讲了。杨昌济意识到了事态的严重,找到在校的徐特立、方维夏等老师,先看了毛泽东的《退学申请》,告诉他在老师们没有结束和校长的谈话之前,不要轻举妄动。然后,几位老师一起去了校长办公室。\n油灯下,张干的办公桌上堆满了试卷、教学资料等等,几乎要把他埋在其中。他正在一笔一画,十分专注地写着一篇文章,标题是《第一师范教学改良计划》。门被轻轻敲响,张干有些疑惑地抬起头,先看了一眼墙上的钟,这才说了声请进。杨昌济等三人推门走了进来。“杨先生?”张干不由得站了起来,“您回来了?”\n油灯映照下,张干埋着头,房间里气氛沉闷。\n徐特立和方维夏都将目光投向了杨昌济。杨昌济斟酌着:“张校长,你我都是搞教育的人,尽管对教育的理解,每一个人不尽相同,但我们都相信,您和过去的孔校长,和全校的每一位老师一样,都是想把一师办好。我也听说,自您到校以来,从来没有在晚上12点以前离开过学校,可以说,为了一师,您是在兢兢业业工作。可您有些做法,学校的老师、学生也确有看法,究竟是为什么要这样做,这一段,学校又到底碰上了什么让您为难的问题,您就不能跟大家解释一下吗?”\n张干抬头看了看杨昌济,似乎想说什么,却又一言不发地把头低下了。\n方维夏说:“我们知道您重视教学,希望把学生的成绩抓上来,可像现在这样,没日没夜,除了补课就是考试,学生的一切社会活动全部禁止,这是不是也过头了一点?学生也是人,他们不是读书的机器啊。还有,学校的经费为什么会这么紧张?到底出了什么事?我们都在着急啊。”\n徐特立也很着急:“张校长,大家都是同事,为什么您就不能把心里想的,跟我们谈出来呢?”\n张干依然一言不发。\n三个人互相看看,都有些不知该怎么谈下去了。沉默中,他们突然听到从学生寝室那边传来了一声撕心裂肺的惊呼……发生了什么事情?老师们迅速出了校长室,朝学生寝室方向跑去。\n当他们来到八班寝室时,只看到易永畦的被子、蚊帐上到处溅满了喷射状的鲜血。得知毛泽东已经把易永畦送往学校医务室了,他们又急忙撵了上去。但一切都迟了,医务室外长长的走廊上,鸦雀无声,挤满了第一师范的学生,所有的人都沉默着,所有的人眼中都含着泪水。一种不祥的感觉顿时攫住了张干的心,仿佛是为了印证他的不祥预感,盖着白布的易永畦的遗体被缓缓推了过来。仿佛猝遭雷击,张干一把扶住了墙,紧跟而来的杨昌济等人也都惊呆了……\n礼堂里,黑纱环绕,易永畦遗像挂在台上正中,上面悬着“易永畦同学千古”的横幅。台下,数百同学穿着整齐的校服,静静地肃立,萧三、子鹏等人正在裁剪纸张、黑布,制作白花、黑纱。在一片哀痛与泪光中,只有白花、黑纱在无声地传递着。蔡和森将白花、黑纱递到了毛泽东面前。默默地戴上白花、黑纱,毛泽东走到了易永畦的灵前。\n桌上,是折得整整齐齐的校服,抬头,是易永畦微笑着的相片,毛泽东将永畦沾满鲜血的课本轻轻放在校服上。身后的子鹏再也忍不住了,一把捂住了泪流满面的脸:“都怪我,我怎么……怎么就忘了给他买药回来……都怪我呀……”几个同寝室的同学搂住了子鹏,安慰着他。\n一支毛笔递到了毛泽东面前,蔡和森说:“润之,永畦平时最喜欢跟你在一起,他最佩服的,也是你,为他写点什么吧。”\n雪白的纸在毛泽东面前铺了开来。握着笔,抬头凝视着易永畦微笑的脸,眼泪轻轻从毛泽东眼眶中滑了下来,眼泪和着墨迹,落在纸上写下了挽诗的题目:《挽易永畦君》:“去去思君深,思君君不来。愁杀芳年友,悲叹有余哀。衡阳雁声彻,湘滨春溜回。感物念所欢,踯躅城南隈。城南草萋萋,涔泪浸双题……”\n毛泽东写着,一幕幕往事如此鲜活地重现在他的眼前:进校第一天,易永畦帮着不会钉校服领章的子鹏钉着领章;球场旁,不擅运动的易永畦在帮着打球的毛泽东等人看守衣服;杨老师的课上,易永畦讲述着自己想当将军的理想;灯光下,易永畦将补好的鞋悄悄放在毛泽东的脚边;操场上,易永畦与毛泽东一起罚站;礼堂里,面对成排的刺刀,易永畦狠狠地打向刘俊卿的脸,士兵的枪托狠击在他的胸口……\n“……我怀郁如焚,放歌倚列嶂。列嶂青且倩,愿言试长剑。东海有岛夷,北山尽仇怨。荡涤谁氏子,安得辞浮贱。子期竟早亡,牙琴从此绝。琴绝最伤情,朱华春不荣……”\n笔走龙蛇,字迹由行而草,饱含悲愤。肃立的同学们同样含着悲愤的泪水。毛泽东边写边擦着眼泪,但眼泪越涌越多,已将他的双颊完全湿透……\n蔡和森将毛泽东写好的《挽易永畦君》诗被放在了灵前。\n张昆弟情绪激动地高声呼喊道:“各位同窗,我们为什么会失去一位好同学?一师为什么会出这样的悲剧?大家心里都清楚,就因为那个张干!”\n罗学瓒也呼应着:“没错!就是他,一天到晚考考考,逼着永畦带病熬夜,永畦的身体本来就有伤,他是给活生生熬垮的呀!”\n萧三更是火上加油:“还有,为了什么学杂费,逼得永畦连药都舍不得买,前天,他还罚永畦在大太阳底下站了一整天……”\n“就是他……”悲痛中,学生的情绪都上来了,现场一片群情激愤。\n第十九章 驱张事件 # 第一师范不是一台机器,学生也不是木偶,他们有主见,他们敢想敢做,他们不需要我这样一个逃避现实的校长。一个跟不上学生要求的校长,只能是一个失败的校长,他所推行的教育,也只能是失败的教育。而我,就是这个失败者。\n一 # 墙上的挂钟单调而沉闷地晃动着钟摆。张干呆呆地坐在办公桌后,整个人如同一尊雕塑。\n“张校长,张校长。”\n校长室外,方维夏敲着房门。校长室内,张干充耳不闻,呆若木鸡。\n方维夏又敲了几下,却仍然听不见反应,他看看身边的徐特立,两个人都叹了口气。\n张干的姿势一动没动,只有挂钟还在单调地走,一下一下,沉闷得让人心烦。\n方维夏又敲了几下,无奈地停手。杨昌济看了看紧闭的校长室的门,说:“维夏,你是学监主任,应该有备用钥匙吧?”\n方维夏:“有是有,可是,这是校长室……”\n杨昌济不容置疑地:“打开!”\n门开了,杨昌济出现在门口。\n“张校长。”\n张干的背影一动不动。\n杨昌济一步来到他的面前,声音发生了变化:“张干先生!”\n仿佛是被突然震醒,张干的身子微微一动。\n“现在什么时候了?全校学生都集中在礼堂,他们有情绪!现在不是你闭门思过的时候,你的沉默,只会让事情越来越糟,你知不知道?”激动中,杨昌济走动两步,又一步折了回来:“从你进校开始,老师、学生,每一个人都不明白,每一个人都在等待你这个校长的解释,可你,就没有向大家说明过哪怕一次!第一师范不是一台机器,这里的师生也不是木偶,他们需要理解校长的教育理念,他们不能糊里糊涂地任人支配,你明不明白?你说话呀!”\n缓缓地,张干转过身来——杨昌济不禁一愣:张干的脸上,居然流着两行泪水!\n“校长,”方维夏走上前来:“全校学生正在为易永畦准备追悼会,您作为校长,应该去参加,到那儿,也算是给学生们一个交代,一个安慰,让他们也明白,您是关心学生的,您说是不是?”\n杨昌济:“我们都在等你,张校长!”\n缓缓地,张干终于点了点头。\n就在一行老师赶去礼堂的途中,学生们激动的情绪已经达到了顶点。 一片激愤中,萧三、罗学瓒、李维汉等一帮人围着毛泽东,问他接下来怎么办?毛泽东说:“我只知道,永畦不能就这么白死了,不管大家怎么办,我都支持!”\n“有你这句话就行!”张昆弟转身就往台上冲,“大家听我说,同学们,我们第一师范原来怎么样,现在怎么样,大家都是看在眼里的。”张昆弟一把举起那本沾血的课本,“大家说,是谁让这本书上喷满了易永畦同学的血?是谁造成了眼前这一切?”\n台下的学生异口同声地回答:“是张干!”\n台上,张昆弟情绪激动地继续问:“像张干这样的校长,我们要不要?”\n台下雷鸣般地回应:“不要!”\n“那么大家说,怎么办?”\n“把他赶出去……赶走张干……”\n“好!不想要张干这种校长的,跟我来!”张昆弟一步跳下讲台,学生们纷纷涌上,跟着他就往外涌去。\n人流在礼堂门口戛然站住了,因为站在礼堂门口的,是脸色铁青的张干。\n一个校长,数百学生,静静地对峙着。一刹那,数百人的礼堂里居然鸦雀无声。猛地,张昆弟振臂一呼:“张干滚出一师!”数百个声音仿如雷鸣:“张干滚出一师!”\n嘴唇剧烈地颤抖着,张干猛地转身就走。校长室内,张干怒不可遏地写着《开除通告》,名列被开除学生榜首的,赫然是毛泽东的名字!\n而在灵堂内,毛泽东也正奋笔疾书,白纸上的《驱张干书》尤为醒目。教室里,张昆弟等众多同学或写标语,或抄着《驱张干书》。不多久,一师的教室门口、走廊上到处都贴着“张干滚出一师”之类的标语和《驱张干书》。学生们在做了这些之后,还集中到了操场,开始罢课了!无论老师们怎么劝说,罢课的学生都无动于衷,杨昌济看了看眼前的学生,发现毛泽东和蔡和森等人不在其中,便对其他老师说:“你们先把学生看好,这件事,交给我来处理吧。”\n空荡荡的礼堂里,只有毛泽东与蔡和森静静地坐在易永畦的遗像前,吱呀一声,身后传来了大门推开的声音。蔡和森微微一愣,沉浸在悲痛中的毛泽东也被惊醒,回头看见杨昌济,不由得站了起来。轻轻掩上门,一步一步,杨昌济走到了遗像前。拿起桌上的一朵白花,他认真地戴好,然后郑重地向遗像深深鞠了一躬。\n“润之,和森,你们现在的心情,我都明白。永畦是你们的好同学,也是我的好学生,他走了,我这个老师,跟你们一样悲痛,也跟你们一样,无法接受这个现实。”杨昌济抚摸着那本带着鲜血的课本,眼泪渗出了眼眶,“我们一师,不该发生这样的悲剧啊!可是,不该发生的悲剧,已经发生了。我们是该从悲剧中吸取教训,还是让悲痛和情绪左右我们的理智,让悲剧愈演愈烈呢?我知道,你们对张校长的一些做法不满,永畦的不幸,更影响了大家的情绪。可无论张校长在治理学校方面有多少值得商榷的地方,作为学生,也不能采取如此极端的手段,不该用整个学校的正常秩序作为代价,来与校长争个高低啊!润之、和森,外面现在在发生什么,我想你们都知道,一所学校,连课都不上了,这是在干什么?这是在毁掉一所学校最基本的秩序!外面的同学都听你们两个的,我希望你们出去,现在就出去,制止大家,让一师恢复正常的秩序。”\n毛泽东与蔡和森互相看了一眼,两个人显然还难以接受这个要求。\n毛泽东问:“可是,永畦就这么白死了吗?”\n杨昌济说:“可永畦的死,真的就应该归结到张校长身上吗?永畦身上的伤哪来的?那是被汤芗铭的兵打的!永畦的身体,本来一直就不好,加上这么重的伤,这,能怪张校长吗?当然了,张校长来校时间短,没能及时了解永畦的身体情况,他有疏忽,可并不等于是他造成了永畦的悲剧啊!永畦走了,大家都很悲痛,可要是永畦还在,他会愿意看到大家为了他,连课都不上,连书都不读,会愿意看到一师变成现在这个样子吗?如果任由同学们这样下去,永畦在九泉之下,也会去得无法安心啊!”\n一番话,说得毛泽东与蔡和森都不禁低下了头。\n“老师,对不起,是我们不对,我们现在就出去,跟大家说……”蔡和森话未说完,“砰”的一声,虚掩的大门猛地开了,方维夏急匆匆地闯进门来:“杨先生!出事了,您赶紧来看看吧!”\n二 # 杨昌济来到一师公示栏前, 看到一纸《开除通告》赫然张贴在公示栏上,上面以毛泽东为首,赫然开列着17个因带头驱张而被开除的学生名字,下面是张干的落款和鲜红的校长大印!\n“开除?”\n惊讶中,杨昌济转过头来,老师们都面面相觑,然后一起急忙往校长办公室走去。毛泽东则快步冲回寝室,拿出《退学申请》,叫道:“我毛泽东用不着他张干来赶,此处不留人,天地大得很!”说着,推开想要拉住他的蔡和森,冲了出去。\n校长室里,满屋子的老师都望着张干,张干避开了大家的目光。\n徐特立说:“张校长,学生们的做法,也许是过于冲动了一些,可再怎么说,也是事出有因。一师已经出了易永畦这样的悲剧,难道还要一下子开除17个学生,让这悲剧继续下去,甚至是愈演愈烈吗?”\n张干低着头,一言不发。\n方维夏说:“就算学生们违反了校规,可校规校纪是死的,人是活的呀。孔校长过去就常说,学校是干什么的,就是教育人的,学生有问题,我们应该教育他们,而不是往门外一赶了之啊。”\n“这么说,列位是不是都不同意?”张干问。\n“我不同意……我不同意。”王立庵、陈章甫、饶伯斯等好几名老师纷纷摇头。\n费尔廉甚至说:“这件事,我觉得责任不全在学生身上。张校长,你的做法,比学生更冲动。”\n长长叹了口气,张干闭上了眼睛:“好吧,也许我是太冲动了,我可以收回这份开除通告。但是,其他16个人我可以放过,毛泽东,必须开除!”\n校长室外的走廊上,毛泽东拿着那份《退学申请》,三步两步跨上楼梯,匆匆走向校长室。校长室里,正传出张干激动的声音:“怎么,难道我身为校长,连开除毛泽东这么一个学生的权力都没有了吗?”\n杨昌济的声音:“这不是权力大小的问题!”\n毛泽东不由得站住了,听到杨昌济继续说:“年轻人,一时冲动总归难免,犯了错误,批评教育甚至处分我都不反对,可要是动辄拿出开除这样简单粗暴的手段,那我们这些先生是干什么的呢?”\n方维夏的声音:“张校长,这么多老师,没有一个赞成开除毛泽东,难道您就不考虑一下大家的意见吗?”\n张干的声音:“我就不信,像毛泽东这样无法无天的学生,各位先生都会站在他这一边?在座的各位,难道就没有一个认为毛泽东的行为已经足够开除处理了吗?”\n费尔廉说:“毛泽东的行为,也许是够开除,但开除他是不对的。”\n“又够开除又不开除,这叫什么道理?”\n“道理我讲不过张校长,我只知道就是不能开除。”\n听着里面老师们的争执,望着手中的《退学申请》,毛泽东一时真不知心里是什么滋味。一个身影突然出现在他的身后,毛泽东一回头,却是板着脸、端着水烟壶走来的袁吉六。\n“袁老师。”\n看也没看他,袁吉六口气淡淡地:“外面那篇赶校长的檄文,是你写的?”\n毛泽东点了点头。\n“混账东西!”袁吉六横眉立目,劈头一声暴喝,吓得毛泽东一抖!他的咆哮声从走廊上传了开去,“一看就知道是你!身为学生,驱赶校长,你好大的胆子!”\n老师们都愣住了,校长室的门开了,张干、杨昌济等人都探出头来。\n走廊上,袁吉六气势汹汹,劈头盖脸,训斥着毛泽东:“天地君亲师,人之五伦,师道尊严都敢丢到脑后,你眼里还有没有人伦纲常?教会你那几笔臭文章,就是用来干这个的吗?”\n毛泽东被训得一句话也不敢说。\n杨昌济叫了一声:“袁先生!”\n袁吉六又瞪了毛泽东一眼,狠狠扔下一句:“反了你了!”这才大咧咧地向校长室内走去。\n门“砰”的一声关上了,毛泽东一个人站在了走廊上。\n袁吉六走到张干的桌前,坐下了。老师们互相看着,袁吉六方才的态度,显然有些影响了方才一边倒的气氛。一片宁静中,张干仿佛打定了什么主意:“袁先生,您来得正好,有件事,我正想听听您的意见。”\n袁吉六问:“开除学生的事吗?”\n“是这样,这次开除学生,张干确有考虑不周之处,经各位先生提醒,现已决定,收回对其中16人的开除决定。可是为首的毛泽东,目无师长,扰乱校纪到了如此程度,再加姑息,学校还成什么学校?袁先生,您是一师任教的先生中年纪最大的前辈,既然列位先生不赞同我的想法,我也无法接受列位先生的纵容,开除毛泽东的事如何决断,就由您来定吧。”\n所有的目光顿时都集中到了袁吉六的身上。\n“张校长真的要老夫决定?”\n“但凭先生一言定夺。”\n众目睽睽中,袁吉六慢条斯理地抽了两口烟,吐出烟雾,将水烟壶放下,这才:“定夺不敢,袁某的意见就一句话;张校长若是开除毛泽东,袁某,现在就辞职。”\n说完,他起身就走。\n张干不禁呆住了。\n校长室外的毛泽东同样意外得几乎不敢相信自己的耳朵。\n猛然看见袁吉六走出,他几乎是下意识地:“袁老师……”\n袁吉六仍然没有看他一眼,仍然是那样硬冷,“别挡路!”大咧咧地踱着方步,消失在走廊尽头。\n毛泽东用手一摸,才发现泪水已滑出了自己的眼眶。那份《退学申请》被缓缓地,撕成了两半……\n三 # 一张盖着省教育司大印的对张干的免职令张贴到了一师的公告栏里。学生们欢呼一片,仿佛迎来了一场大胜利。\n隐隐的欢呼声中,校长室里,校长的大印、一本校长工作日志和第一师范校志被小心地推到了杨昌济、方维夏与徐特立面前。\n“张校长……”\n“我已经不是校长了。”张干轻轻一抬手,默默地收拾着桌上其他的东西。\n杨昌济按住了他的手,问:“次仑兄,就算是临走前一个交代吧,你就不能跟我们说说,这一切到底是为什么吗?”\n带着一丝苦涩,张干微笑了一下,笑容却转为无声的叹息 :“其实我不是不知道,学生们不喜欢我,因为我专横,我压制。我不准这样不准那样,我把学生关起来,让他们两耳不闻窗外事,恨不得他们一个个变成读书的机器。可这是我愿意的吗?这是这个世道逼的啊!”\n张干一把推开了窗户:“杨先生、徐先生、方先生,你们睁眼看看,眼前是个什么世道?民权写在法律里,法律高悬于庙堂上,可那庙堂之上的一纸空文,有谁当过一回事?拿枪的说话才是硬道理,掌权的是像汤芗铭那样杀人不眨眼的屠夫啊!就拿孔校长来说吧,学生们怀念他,怀念他开明,有胆气,关心国事,视天下兴亡为我一师师生之己任。可是结果怎么样?他不单自己被通缉,还险些给一师惹来灭顶之灾!还有徐先生,您为什么辞了省议会副议长的职务,您不就是不想同流合污吗?可您一个人可以辞职,我要面对的,却是好几百学生的第一师范啊。区区一个一师,在汤屠夫眼里,还比不上一只随手能捏死的蚂蚱,我还能怎么样?当此乱世,我只能压着学生老老实实,压着他们别惹事,我是一校之长,我要顾全大局,我不能让他们再往枪口上撞啊!”\n那份收学杂费的公文被摆在了桌上。\n“方先生,你一再问我,学校的经费究竟哪去了。现在你该明白了,是汤芗铭断了一师的经费,逼着学校收学生的钱。可我能告诉大家真相吗?我不能!因为那等于挑起学生们对政府不满,万一学生们冲动惹出事来,吃亏的是他们啊!所以我只能让大家骂我,把所有的气,都出在我身上,骂完我,出完气,他们就不会出去闹事了!退一万步来说,学生以学为本,严格校纪,发愤读书,这也是我这个校长的本职工作,让大家认真读书,这总没有错吧?可现在我才明白,我还是错了,杨先生说得对,第一师范不是一台机器,学生也不是木偶,他们有主见,他们敢想敢做,他们不需要我这样一个逃避现实的校长。一个跟不上学生要求的校长,只能是一个失败的校长,他所推行的教育,也只能是失败的教育。而我,就是这个失败者。”\n喃喃的,张干仿佛是在向三位同事解释,更像是在自我反思。平静地、小心地、如往常一样仔细地,张干一样一样收拾好了自己的备课资料、笔墨、雨伞……张干默默地将桌上那方“诚”字镇纸放进了包里。那方孔昭绶的“知耻”镇纸,被重新放回了原位。收拾得整整齐齐的办公室里,一切都恢复成了张干到来前的模样,只有办公桌上,端正地摆着那份已经起草好却还未来得及实施的《第一师范教学改良计划》。\n张干穿过走廊,走下楼梯,穿过教学楼前坪,经过他所熟悉的一处又一处。他的脚步停在了校门口的公示栏前,那上面,还贴着对他的免职令。回头最后望了一眼一师的校牌,张干的眼中,也流露出了依依不舍的伤感。人力车启动,车轮转动着,一块块青石街面被抛在了后面。\n这个时候,寝室走廊,欢庆胜利的学生蹦跳着走来,驱张的骨干们兴高采烈地簇拥着毛泽东,欢声笑语,洒满一路。学生们的声音突然停住了——面前,杨昌济、方维夏、徐特立正静静地站在他们面前。\n毛泽东:“老师……”\n望着这些让自己又深爱又头痛的学生们,几位先生相互交换了一个眼神,一时似乎都不知是什么心情。\n“校长没能开除学生,倒是学生赶走了校长,这确实是一件奇闻,也确乎值得大家庆祝一番。可当大家欢庆胜利的时候,你们有没有认真地想过,你们赶走的,究竟是怎样一个人?你们对他,又了解多少呢?”众多同学围成了一圈,静静地听杨昌济讲述着:“张校长的教育理念、治校方式,也许我们大家并不非常赞同,但当大家抱怨功课压力太重的时候,有谁注意到了张校长办公室里每天亮到深夜的灯光?当同学们为催交学杂费而意见纷纷的时候,有谁想过,张校长在教育司、在将军府据理力争却毫无结果时的痛苦?当一项又一项新校规压得大家喘不过气来的时候,有谁明白张校长千方百计保护学生的一片苦心?当同学们抱怨食堂伙食太差、吃不饱肚子的时候,又有谁知道,为了让大家还能吃个半饱,张校长甚至卖掉了自己的怀表……”\n围上来的同学越来越多,走廊、走廊旁的草地,渐渐都站满了。\n杨昌济讲得平心静气,毛泽东等人却越听越不安,老师讲述的话,显然是大家过去完全没有想到过的……\n“古语云:将心比心。然而真要做到这一点,真要从别人的角度去考虑问题,却不是一件容易的事。通过这一次,我只希望大家今后遇上别的事情的时候,不要光凭个人的好恶,不要以一时的冲动,不要单从自己的眼光、自己的角度来看待一件事、一个人,因为那样做出的判断,常常是有失公允,常常是会伤害别人,最终也令自己后悔莫及的。这,不仅是我们这些老师的希望,我想,当张校长走出一师的校门时,这,也一定是他心中对大家保留的最后一份期望……”\n脚步纷纷,学生们涌出教学楼。校门口,追出的学生们张望着:人海茫茫的街道上,早已消失了人力车的影子。毛泽东、蔡和森、张昆弟、罗学瓒、萧三等一个个同学的脸上,是歉疚、失望,是追悔、惆怅。\n天高云淡,第一师范的校旗随风轻扬,仿佛也在惋惜这场不应发生的离别。\n离开第一师范后,张干长期固守清贫,任教于长沙各中学。中华人民共和国成立后,毛泽东专门将老病失业的张干接到北京,为当年的驱张行动向这位老校长正式道歉。此后,他长期负担张干的生活与医疗费用,直至1967年张干病逝。这位学生用自己的行动,与当年被他赶走的校长修复了这段曾被破坏的师生关系。\n第二十章 君子有所不为亦必有所为 # “ 历史之车轮滚滚向前,\n欲以人力变其轨而倒行,\n只怕是无人指望得上。 ”\n一 # 我去帮他交钱,还不是不想他跟学校起冲突吗?他怎么这样对待我啊?!从一师回来后,斯咏越想越想不通,抱着枕头哭了一晚上,任警予怎么劝都不听。她以为自己这辈子可能都不想再见那头犟牛了,可几天后,当她和警予走出周南中学的校门,正看到毛泽东迎面走过来的时候,她的心还是和以前一样狂跳着,甚至跳得更厉害了。警予看了看他们俩,借故要去和开慧打排球,转身回了学校。走出几步,她心里暗想:还好,蔡和森不像他那么倔。前几天她将一方手帕包着的十来块光洋递到了蔡和森面前的时候,蔡和森可没有像毛泽东那样不领情,他只是开玩笑说不一定还得起。警予乘机唬着脸要挟他,不还也行,毕业后给她做十年长工,就算两清了。蔡和森算着账,问:“那,这十年长工都包括干哪些活?做牛啊,做马啊,还是做点别的什么?得有个具体内容吧?”当时是怎么回答的?“到时候叫你干什么你就干什么,哪那么多废话?”警予想着,脸一下子绯红。回头看看并排渐渐走远的毛泽东和陶斯咏,警予又想:不过,毛泽东要是不倔,还是毛泽东吗?\n毛泽东当然很倔,不过当他意识到自己确实误会了别人的时候,态度转变起来,还是蛮快的。所以,站在江风轻拂、竹影摇曳的湘江边,毛泽东坦诚地为那天的事情向斯咏道了歉。\n“事情过都过去了,你还专程来道什么歉?”斯咏低头走着,嘴里虽然这样说,脸上却荡漾着开心的笑意。\n“话不是这么说,本来是我不对嘛。我这个人,一急起来,就不分好歹,狗咬吕洞宾。你不计较就好。”他把手往斯咏面前一伸,说,“我们还是朋友。”\n“只要……只要你把我当朋友,我是永远不会变的。”斯咏握着毛泽东的手,有些忘情了,遥望着大江、岳麓山,轻轻地说:“但愿山无棱,天地绝……”\n“哎,你怎么学的古诗?那是讲两口子,讲朋友叫高山流水,知音长存。”毛泽东手一挥,指着眼前的山河,慷慨地说:“就好像这大江、岳麓山,历千古之风雨,永恒不变,那才叫真朋友。是不是?”\n黄昏的夕阳下,江水粼粼,金光万点。斯咏犹豫着,想说什么,可突然感到有水点落在头上。\n“哟,下雨了,走走走。”毛泽东拉起斯咏就走。\n雨越下越大,黄昏的街道显得比往常这个时候黯淡得多。顶着外衣遮雨,毛泽东与斯咏一头冲进了街边的小吃棚里。\n小吃摊的锅里,正煮着元宵。毛泽东闻到了香味:“嗯,好香啊!哎,斯咏,你饿不饿?今天我请客,来。”他拉开凳子让斯咏坐,高声喊道:“老板,元宵两大碗。”\n“嘘!”摊主被这话吓得脸都变色了,手指竖在嘴边,说,“小点声,小点声!”\n毛泽东和斯咏都愣住了:“怎么了?你那锅里不是元宵吗……”\n摊主一把捂住了毛泽东的嘴:“讲不得,讲不得啊!”他掀过摊前的牌子,指着上面的“汤圆”二字,压着声音,“姓袁的都被消灭了,还怎么当皇上啊?有圣旨,从今往后,这元宵,都得叫汤圆,叫错了就是大逆。嚓!”说着,手一挥,做了个杀头的动作。\n“砰”的一声,毛泽东把筷子重重拍在桌上。可很快,本来一脸怒容的他不知怎么,却突然笑了:“哈,哈哈,哈哈……”\n他越笑越开心,几乎是乐不可支,倒把斯咏笑糊涂了:“你还觉得好笑啊?”\n“为什么不好笑?千古奇闻嘛。心虚到如此地步,还梦想翻天,哈哈……”\n棚外,天色昏黑,雨,愈发大了。只有毛泽东的大笑声绵绵不绝,仿佛要冲破这无边的阴雨夜幕。\n斯咏和毛泽东吃了元宵回来,心情才好了些,欢欢喜喜地进了大门,却发现家里的气氛和往常很不一样。仆人们走路都是轻手轻脚的,连大气都不敢出。家里出了什么事情?斯咏心里一紧,在门厅里拉住管家就问,管家战战兢兢地小声说,老爷吩咐了,他今天生病不见任何客人,可进了客厅,正看到父亲闷声不响地窝在沙发里,一张平日里和蔼可亲的白白胖胖的脸,现在眉毛胡子全皱到一块了。\n斯咏走过去,在父亲身边坐下,还没开口,就看见父亲面前的茶几上摆了一本大红锦缎、富丽堂皇的聘书。她迟疑着看了父亲一眼,拿过聘书,打开,看到里面写着:“今敦请 长沙商会会长陶老夫子大人出任 湖南省各界拥戴 中华帝国洪宪大皇帝 登基大会筹办主任 晚生汤芗铭敬启百拜”。\n斯咏看父亲闷头不做声,腾地站起来,就要将那本聘书往壁炉里扔。\n“斯咏,你干什么你?你放下!”陶会长吓得赶紧一把将聘书抢了过来。\n“爸!”斯咏急了,“你难道真要跟他们一起遗臭万年吗?”\n“你知道什么你?”他将那本聘书往沙发上一甩,手拍着额头,又是长长一声叹息,他这时的苦恼无奈,真是无法用言语表达。\n二 # 蔡和森、警予陪着情绪低落的斯咏一起往楚怡小学走,要去参加本周的读书会。一路上,斯咏已经给他们讲了自己家发生的事情,言辞之间对父亲很不谅解。\n“算了,斯咏,伯父也不是自愿的,谁不知道那个汤屠夫杀人不眨眼?”蔡和森安慰着她。\n警予却率直地反驳:“话虽然这么说,可这是做人的原则,要是我,死也不干!”\n迎面,毛泽东与罗章龙、张昆弟等人也正好来到了门口。众人打着招呼,一齐向校内走去。毛泽东见斯咏一副长吁短叹的样子,就问她刚才在聊什么呢?斯咏说:“除了那个袁大皇帝,还能有什么?”\n房里已经聚集的五六个读书会成员,看到他们进来,开慧蹦起来大叫道:“毛大哥,你来你来,看子升大哥写的绝妙好联。”她左手一松,先垂下了上联“袁世凯千古”。右手再一松,露出了下联“中华民国万岁”。\n罗章龙疑惑地:“‘袁世凯’对不上‘中华民国’啊。”\n其他人也同样没弄明白,都搞不懂萧大才子为什么会写出这样一副不合规矩的对联。\n毛泽东第一个反应了过来,猛地一击掌:“好!写得好,写得绝!你们看你们看,‘袁世凯’对不起‘中华民国’,以错对成绝对!萧菩萨,干得漂亮啊,你!”\n因为汤芗铭把反袁的报纸、杂志都禁光了,整个湖南别说中文报纸、连英文报纸都收不到了,一时间通行的都是筹安会办的《大中报》,翻来覆去,全是“圣德汪洋”、“万寿无疆”,为袁世凯歌功颂德的。还好,读书会能辗转收到黎老师从北京偷偷寄来的报刊,虽然是迟到的新闻,但总比没有要好得多。今天他们读的,是《申报》上梁启超的《辟复辟论》:“……复辟果见诸事实,吾敢悬眼国门,以睹相续不断之革命!”\n“写得太好了。梁先生的文章,真是扎到了那帮复辟派的痛处,一针见血啊!”毛泽东忍不住击节而叹,站起身来说,“大家想想,启超先生他们这些文章,把复辟的问题分析得这么透彻,我们读了都明白了,可光我们十几个人读了又有什么用?全长沙还有好几千学生,他们整天看到的,全是汤芗铭塞下来的筹安会放的狗屁。如果我们能把这些报刊上的资料编印成一本书,散发出去,大家不就都明白他袁世凯是个什么东西了吗?”\n“好主意,这才是我们应该干的。”何叔衡头一个赞成,大家也强烈支持。可萧子升说:“这些资料都是违禁的,我们悄悄看看,还得躲着藏着。编印散发,这要被人发现了怎么办?”\n毛泽东说:“你就是一世人胆子小!要我说啊,只要做得巧,不怕别人搞。书印出来,又不是搬到大街上去发,我们分头行动,一个人传一个人,不是可靠的同学我们不传。长沙的学生,一百个至少有九十九个跟我们站在一边吧?我认识你,你再认识他,传不得几天,保证就传开了。到时候,就算汤芗铭真发现了,这本书已经到处都是,他未必还查得到始作俑者?大家说是不是?”\n众人还在考虑,又是何叔衡头一个点头:“润之的主意不错。要我看,不单是不见得真有风险,就算有,君子有所为有所不为,这件事我们也应该当仁不让!”\n何叔衡的一句话给这个主意定了性,几乎所有的人都点了点头。只有萧子升还有些犹豫:“主意呢,也许可行,可关键是前提,我们上哪儿去找一家肯印这种东西的印刷厂?还有,印书的钱又从哪来呢?”\n“这个大家就不用担心了,我们家就开着印刷厂,印书的事,包在我身上。”\n斯咏说得前所未有的坚定,话虽不是冲着萧子升来的,萧子升的脸却一下子红了。他看看斯咏,腾地站了起来:“好,说干就干!编书的事,我萧子升负责!”\n很快,一本本书名是 《最新阴阳黄历》而内容却是《梁启超等先生论时局的主张》的新书,就在长沙各个学校流传开了。\n三 # 书,很快就落到了汤芗铭的手里。拿着书,这个面如书生却杀人不眨眼的将军,决定要亲自出马,再验证一次他的“人格魅力”。他曾经凭借手中的权势和武器,让很多貌似高贵的头颅低垂在他面前,任他践踏。但这一次,他却没有十足的把握,只是想做最后一次努力。\n夜幕中,一只白净的手文雅地敲响了芋园杨宅的大门。杨昌济一介寒儒,平常往来的,除了亲戚朋友,便是学生同事,杨昌济一如平常把门打开,却没料到这次站在门口的,竟是汤芗铭,一身雪白的对襟短衫,似一名晚间散步的书生。\n“不速之客遑夜叨扰,板仓先生,打搅了。”\n杨昌济不由得往汤芗铭身后望了望,汤芗铭倒像是没明白他望什么,停了一下才反应过来:“哦,芗铭是来拜访朋友,就一个人来的。怎么,鸿儒雅居,芗铭无缘一入么?”\n“汤帅请。”\n汤芗铭环顾打量着书房:满满一排哲学经典排列在书架上,汤芗铭却看见一本《大乘金刚般若波罗密经》,他抽出书:“板仓先生也好《金刚经》吗?”\n“略也读过。”\n“芗铭平素最喜欢鸠摩大师的《金刚经》。”汤芗铭笑着在杨昌济对面坐下了,“这金刚经千言万语,妙谛莲花,据芗铭之陋见,倒是两个字可一以概之。”\n“哪两个字?”\n“一曰忍,二曰施,忍己而成佛,施爱于众生。忍得万般苦,方能布施众生啊。由此而论,倒是这个忍字,更是根本。先生以为如何?”\n“忍己是为施众,以昌济将来,倒是施才是目的。”\n“不忍何来施嘛?所谓世间万事,不如意者八九,人生于世,原是要忍的。”汤芗铭拍拍那本《金刚经》,“就譬如鸠摩罗什大师自己,一代大德,为后凉王吕光所逼,不也只好忍着与龟(音丘)兹公主成婚,一过15年吗?若是不忍,一味要杀身成仁,又何来后来如此煌煌佛学经典?所以中国人说,民不与官争,忍是根本哪!”\n他凑近了杨昌济:“鸠摩大师如果当时不忍,脑袋就掉了不是?”\n杨昌济不禁笑了。汤芗铭也笑了。\n一时间,两个人仿佛比着赛一样,但杨昌济越笑越开心,终于,汤芗铭有些尴尬地收住了笑容,房间里,只剩了杨昌济的大笑阵阵不绝!\n汤芗铭的眼睛眯起来了:“很好笑么?”\n“杭州灵隐寺弥勒佛前,有一联,下联尤其好:开口常笑笑世间可笑之人。”\n“却不知可笑之人,是先生,亦或芗铭?”\n“‘民不畏死,奈何以死惧之’,则汤帅以为孰人可笑呢?”\n汤芗铭腾地站了起来,仿佛是意识到自己的失态,他又搬出了惯有的、矜持的微笑:“长沙学界以先生为尊,而以先生之尊,自是无人敢让先生去死的。当此乱世,芗铭只希望先生为湖湘千年文华之气运存续考虑,不致任由湖湘学界生出什么变乱吧?”\n“汤帅谬赞了。昌济一介寒儒,哪里谈得上领导湖湘学界?至于变乱二字,当今世上,最大的变乱,恐怕并非来自学界,而是来自某些窃国之贼吧?”\n“看来,芗铭是指望不上先生了?”\n“历史之车轮滚滚向前,欲以人力变其轨而倒行,只怕是无人指望得上。”\n盯着杨昌济,足足有七八秒钟,汤芗铭这才放下《金刚经》,轻轻吐出一句:“打搅了。”\n他转身就走。身后,杨昌济站在原地说:“不送。”\n汤芗铭出了杨宅,吩咐带着卫兵埋伏在门外巷子里的副官:“传令,严查逆书来源,破案者,升三级。还有,通令长沙各校,一律组织学生,参加拥戴洪宪皇帝登基大会,不得有一人缺席。通知商会,赶印《洪宪皇帝圣谕》,到会师生,人手一册,作为各校正式教材,列入考试范围。”\n四 # 汤芗铭能封锁外面的报刊入湘,但却封锁不了私人信件往来,近期《申报》的头条消息《唐继尧蔡锷通电讨袁护国军进军川南湘西》还是悄悄地在长沙传开了。稍微有些政治头脑的人都开始观望,猜度汤芗铭下一步会走什么棋。而汤芗铭在这个时候,依然要印制《洪宪皇帝圣谕》、组织大规模“拥护袁大总统当皇帝”游行活动、清剿《梁启超等先生论时局的主张》,彻底暴露了他要跟随袁世凯走到底的决心。所以,尽管反袁的呐喊声已经响彻大半个中国,在湖南这片“敢为天下先”的沸腾土地上,汤芗铭的那些走狗还在为了讨主子欢心而绞尽脑汁,这其中,就包括那位因为出卖老师同学当上了侦缉队队长的刘俊卿。\n刘俊卿这几天因为忙着张罗 “拥护袁大总统当皇帝”游行,和三会堂的马疤子走得很近,两个人称兄道弟,酒馆同进茶馆同出,这让一贞心里很不痛快。刘俊卿便允诺,要继续努力,争取尽早转去教育司或者其他体面的部门,到时候,只要是一贞不喜欢的人,他保证再也不理,一贞不喜欢的事,他保证再也不干了。当然,他并没有给一贞说,以前是马疤子差人来他们赵家茶叶铺子收保护费,而现在,不仅那笔钱免了,马疤子为了码头那些见不得光的生意反而要按月给他刘俊卿分红利。虽然如此,刘俊卿给一贞说的话还是真心的,他一直都把自己当读书人,而对于一个读书人来说,“侦缉队队长”这个职务毕竟不是那么体面。\n从这个意义上说,汤芗铭确实没有看走眼,“刘俊卿这种人,你越不满足他,他就越会拼命干,因为他有一肚子火要发出来,火越大,他就越恨不得见人就咬一口,而且咬得又准又狠,一定咬中那人的痛处。”当初汤芗铭安排刘俊卿去干侦缉队是这个原因、现在要把清剿《梁启超等先生论时局的主张》的任务交给他也是这个原因。刘俊卿当然不会以为他是在被人利用,相反他觉得自己是在被重视、觉得这就是他迫切需要的、一直在等待的机会。拿着一本样书,别人不知道如何着手去查,他却知道,因为每一个巴心巴肝的走狗都是凭借鼻子来完成主人交代的任务的,所以,现在刘俊卿就认定了每本书有每本书的味道,他要寻着这味道去把主人想要的东西搜出来。于是,他带着下属直奔一师。\n在一师的凉水亭里,读书会的会员们还不知道汤芗铭已经指派了刘俊卿在严查他们散发“逆书”的事情,大家聚在一起正讨论斯咏爸爸的印刷厂是不是该为汤芗铭印《洪宪皇帝圣谕》。按照斯咏自己的意思,就算倾家荡产,陶家也不能给袁世凯当走狗。大家同仇敌忾,都是这个意思。可一向坚决反袁的毛泽东却一拍石桌:“印!为什么不印?汤芗铭印这个圣谕是想在庆祝大会上发给全长沙的学生,正好,借他这套锣鼓,唱我们的戏……”\n这边十几个脑袋凑成了一团,在听毛泽东的妙计,却没料到凉水亭虽然僻静,但毕竟也是一师的公共场合,难免会有喜欢清静的学生光顾。王子鹏在收到同学悄悄给的《梁启超等先生论时局的主张》后,胆小的他想来想去,就觉得来这里看比较安全,一路走来,上了后院的石阶,左右看看没人,他便迫不及待地翻开书,边走边看。走着走着从君子亭那边传来的声音把他吓了一跳:“对。拿这本《梁启超等先生论时局的主张》,换掉他汤芗铭的《洪宪皇帝圣谕》。到时候,大会一开,他把书一发,嘿嘿,拥护袁世凯登基马上就变成庆祝袁世凯垮台,看他汤芗铭还怎么收场!”\n这不是毛泽东的声音吗?王子鹏抬起头,看到他们班的张昆弟,六班的萧三、蔡和森,去年就已经毕业的萧子升,还有几个外校的学生……斯咏也在,她似乎和这些人非常熟悉,正附和着毛泽东的提议,说:“好主意!书都在我爸的厂里印,我来安排,应该可以做到。”有人劝斯咏不要这样做,会连累陶家的,斯咏说这是大是大非,孰轻孰重她还分得清,即使真出点什么事,以她爸在长沙的身份,汤芗铭也未见得真敢拿他怎么样。\n大是大非?子鹏听得惊呆了,看看手里的书,意识到他们的谈话肯定和这本书有关,便想转身离开这个是非之地,却不想慌乱中正好踢到一块石头,石头顺着台阶乒乒乓乓滚了下去。\n“谁呀?”子鹏吓得站住了,一回头,只见亭子里的人都警惕地站了起来,正望着自己,其他人的目光里只有猜忌,唯有斯咏,她的目光里有惊讶、也有惶恐……毕竟是一起长大的表兄妹、毕竟有了婚约、毕竟是自己有负于子鹏暗恋上了别人,斯咏猛一看到子鹏,骨子里的传统立刻就把她的愧疚从眼神里表达了出来。子鹏也不知道该怎么解释自己这个时候站在这里的原因,他低着头,逃也似的赶紧沿着台阶向下跑去。\n可他急促地跑下台阶,还没从刚才无意间听到的大胆计划里清醒过来,竟远远地看到刘俊卿正带着手下迎面走来。\n看着刘俊卿敞着衣衫、斜挎手枪、满脸杀气疾步走来的样子,回想起刚才在君子亭听到的那番话,子鹏的心狂跳起来,仿佛看到 “血染一师” 的场面就在眼前。他一时紧张得整个人都僵住了,只是下意识地将书藏到了身后。刘俊卿也看到了子鹏,正要打招呼,但看看子鹏的目光里没有一丝同学情谊,也倔强地扬起了头,摆出一副不在乎的样子,想要从子鹏身边走过。\n从这里到凉水亭,不过短短的二三十米远的距离!紧张中,子鹏想起了斯咏的目光、想起了她和毛泽东的对话、想起了易永畦在枪托下捂着胸口摔倒、想起了刘俊卿带着士兵在校园里疯狂搜查……子鹏手一松,把那紧紧攥着的书掉在了地上。\n刘俊卿捡起书,用冷冷的目光似笑非笑地望着子鹏,然后示意两个手下将子鹏推到了墙角。\n“说,哪来的?”刘俊卿扬起了那本书,在子鹏面前晃着,“子鹏兄,朋友一场,我这可是给你机会,别不识好歹。在这里问话,就是给你留面子,不想让别人看见。只要你说了,我保证,不告诉任何人是你说的,够可以吧?”\n子鹏瞟了他一眼:“书是我的,你爱怎么办就怎么办吧。”\n“还跟我耍少爷脾气?你当这是咱们在学校,吵两句嘴回头又好?这是掉脑袋的事!我再给你最后一次机会,赶紧说!”\n“我……我什么都不知道。”\n刘俊卿的眼睛眯起来了:“给你个好地方你不说,是不是想进侦缉队作回客?真到了那儿,子鹏兄,别说你这身细皮嫩肉,就是神仙我也包你脱三层皮!”\n子鹏听得全身都禁不住发抖了,但却死咬着牙关,保持着沉默。\n刘俊卿再也忍不住了,扔掉书,一把揪住子鹏,将子鹏按到墙上,拔出枪顶住他的头:“你到底说不说?!”\n“少爷?”秀秀是来给子鹏送换洗衣服的,在寝室里没有看到人,才找到这里来。看到哥哥正用枪顶着子鹏,她惊叫着扔掉手里抱着的那几件衣服,猛扑了上来,一把推开刘俊卿,拦在了子鹏前边:“你干什么你!”\n“阿秀,哥在办案,你别来多事,赶紧让开。”\n“办案你抓少爷干什么?少爷又不是坏人!”\n“他收藏逆书,够杀头的罪,你知不知道?你让开,我要带他去侦缉队。”\n“我不让!”秀秀死死拦着子鹏,又气又急之间,眼泪已经流了一脸, “我就不让,谁都不准抓你,都不准!”\n刘俊卿拎着枪冲了上来,想推开秀秀。秀秀一把抓住了手枪的枪管,按在了自己胸前!\n“阿秀,你……你这是干什么你?快放手,枪里有子弹的!”\n“你开枪吧,开枪啊!反正你不打死我,我是不会让你抓少爷的。”\n“你想为他送命啊?”\n“我愿意。”秀秀转过头,看了一眼子鹏,平静地说,“我愿意为他死,死多少遍都愿意。”\n秀秀看着子鹏,子鹏看着秀秀,两个人在对方的注视下,彼此都感受到了从未有的巨大冲击和心灵震撼。这冲击和震撼也把刘俊卿惊醒了,他的目光在秀秀、子鹏的脸上睃了好一阵,长长地吐了一口气,拨开秀秀的手,收起手枪,转身走了。\n仿佛是一下子耗尽了全身的力量,秀秀看到哥哥走了,突然脚一软,眼看着就要倒下,子鹏赶紧搂住了她。埋头抱着子鹏的胳膊,秀秀一时泣不成声。在这个安静的角落里,他们依偎在一起,共同感受着劫后余生的惊恐。终于平静下来了,秀秀这才想起问子鹏刚才出了什么事情。子鹏捡起被刘俊卿扔在地上的书,悄悄地翻给秀秀看,给她讲起了袁世凯复辟。\n复辟?秀秀对这个词一无所知,更不知道这个“复辟”和哥哥有什么关系,更不知道为什么他会因为袁世凯“复辟”而打少爷。她怔怔地看着子鹏,相信他做的事情总是对的。子鹏在秀秀清澈而惊恐的目光中想起秀秀刚才挺身救自己,心里涌起一股说不出的战栗,忍不住捧起了秀秀的脸……\n五 # 斯咏突然关心起《圣谕》印刷的事情,让陶会长非常诧异,女儿的理由既无奈也充分:“我不同意有什么用?长沙又不是我们一家印刷厂,反正他们也会印出来的。再说,胳膊也扭不过大腿,真不印,还不是咱们家倒霉?”\n看到女儿能体谅爸的难处了,陶会长心里好受多了,至于斯咏提出的要带同学们来参观印刷厂的事情,也一口答应了:自己家的厂子,参观参观有什么关系?不过,陶会长只是给印刷厂的厂长打招呼说,斯咏要带同学参观厂子,搞现代工业生产调查,却并没有说具体的时间。根据他们读书会在凉水亭的商议结果,这样的活动当然只能在晚上进行。机灵的斯咏便钻了这个空子,对厂长说,他们要等晚上不上课的时候才能来参观,而那时候工厂没人上班,只需要把工厂的钥匙给他们就可以了。\n计划于是在一个月白风清的夜晚得以实施。\n印刷厂堆满货箱的仓库里,一个贴着“洪宪圣谕”标签的箱子被打开了,几双手飞快地取出里面一本本《圣谕》,将旁边一个箱子里的《梁启超等先生论时局的主张》换了进去。\n毛泽东与蔡和森合力将一箱书码上了货堆,回头打量着仓库里:一箱箱书都已收拾码好,只剩了萧三、李维汉还在更换最后一箱书。蔡和森叫道:“子暲,你们俩快点。好了,大家赶紧走吧。”\n看到众人纷纷向外走来,在仓库外把风的女生们这才松了口气,刚要向门外走,恰在这时,门“吱呀”一声开了,陶会长推门进来说:“哟,这么多人啊?”\n这声音把紧紧靠在仓库墙角、正要盖上箱子的萧三与李维汉吓得往门后一缩,一时紧张得大气也不敢出。\n看到眼前这么多男女学生,陶会长也愣住了。他只是想女儿晚上参观,那能看见什么?因此特地前来看看,却不想竟看到了毛泽东他们。\n“斯咏,不是说带你同学来参观吗?怎么……”\n斯咏一时无言以对,用求援的目光看着毛泽东,毛泽东一时却也不知该如何回答,众人的心一下子悬了起来。就在这时,后面的何叔衡满面笑容地迎了上来:“这位就是陶翁吧?”\n“您是?”\n“在下姓何,在周南女中和第一师范任社会实习老师,今天借着贵府小姐提供机会,专门带了一师和周南的这些学生,前来参观。”\n“是这样啊。”何叔衡的年纪和气度令陶会长一下子放了心,他握住了何叔衡伸过来的手,“辛苦何先生了。斯咏,你看你,请何先生和同学们参观,也不选白天来,这半夜三更的,工人都走了,能看到什么?”\n斯咏一时还不知如何作答,何叔衡道:“我们也就是看个大概,了解一下现代工厂是个什么样子,再说学生们白天有课,晚上参观,既不影响学习也不影响工厂生产嘛。”\n“那倒也是。哦,我就不打搅各位参观了。斯咏,你出来一下,我有几句话跟你说。”\n斯咏跟着陶会长出去后,大家都微微松了口气。萧三与李维汉,这才敢活动一下因紧张而僵直的身子。无声无息中,李维汉胸前的校徽被萧三的手臂擦落,滑落在那只尚未盖上的书箱里,两人却浑然不觉,赶紧把箱子盖好,会同大家一起离开了这个让他们提心吊胆的地方。\n跟在父亲身后,斯咏在解释着方才的情景:“是何老师叫他们来的,我事先又不知道。”\n“好了好了,今天的事就不说了,反正你以后注意一点,少跟那个毛泽东来往,还有那帮第一师范的。”\n“知道了。爸,找我什么事啊?”\n一句话,勾起了陶会长满肚子的心事,抬头望着夜空中被乌云遮去了大半的月亮,陶会长一时仿佛不知该如何启齿:“怎么说呢?斯咏啊,我知道你很难理解,可有些事……人在屋檐下……”\n他摇了摇头。\n斯咏:“爸,有什么你就说吧。”\n陶会长犹豫了一下,这才说:“明天的拥戴洪宪皇帝登基大会,汤芗铭已经指定了,要我来主持。登这样的台,别人会怎么看我,我心里不是不清楚。可不登这个台吧?汤屠夫又点了我的名。斯咏,爸昨天一晚没睡着,今天又想了一天,可就是想不出个推脱的办法。我知道你绝不会同意我干这种事,可现在这种情况,爸实在是……”\n“去就去嘛。”斯咏很干脆地说。\n陶会长简直不敢相信自己的耳朵:“你是说让我去?”\n“爸,我知道你不是有意站在他们那边,这就够了。再说,不就是个大会吗?有什么大不了的?”\n“可这是公开……这可不是什么光彩的事,你真的不介意?”\n斯咏居然带着微笑:“爸,您放心,我不会介意的。”\n陶会长长长地松了口气:“你能理解爸,爸心里就轻松多了,爸怎么出丑都不要紧,就是怕你心里不舒服。”\n斯咏:“这回的事,还不知道是谁出丑呢。”\n心事重重的陶会长显然并没听懂她的一语双关。\n"},{"id":127,"href":"/zh/docs/culture/%E6%81%B0%E5%90%8C%E5%AD%A6%E5%B0%91%E5%B9%B4/%E7%AC%AC6%E7%AB%A0-%E7%AC%AC10%E7%AB%A0/","title":"第6章-第10章","section":"恰同学少年","content":" 第六章 嘤其鸣矣 # 伐木丁丁,鸟鸣嘤嘤。\n出自幽谷,迁于乔木。\n嘤其鸣矣,求其友声。\n一 # 光阴似水,渐渐到了四月,正是长沙的多雨时节,这一次一连下了三四天的雨,略略放晴,但天还是阴阴的。一师的综合大教室里,袁吉六正给六班、八班、讲习班全体学生上大课,评讲他们最近的作文。\n“第六班,蔡和森,95分。”袁吉六扬着手里蔡和森的作文本子,仿佛展览样品一般环视了教室里众学生一眼,这才笑吟吟地把作文本递给蔡和森。 “讲习班,萧子升,90分。”“第八班,刘俊卿,85分。”……\n接过作文本,不甘屈居人后的刘俊卿,脸色阴得像下暴雨前的天色。他瞄了蔡和森一眼,这一瞄,不是普通的瞄,而是带了钩子的,想要剜出什么来的样子。\n“第八班,毛泽东,”袁吉六又拿起了一个本子,声音却一下子沉了下来,“70分。”\n蔡和森、萧子升、萧三等人都吃了一惊,毛泽东也不禁一愣。他望着台上,正碰到袁吉六斜了自己一眼,然后硬冷冷地说:“锋芒太甚,须重含蓄!”本子被“砰”的扔在毛泽东的桌上,70分的分数旁边,果然是鲜红的评语“锋芒太甚,须重含蓄”。望着这八字评语,毛泽东显然有些摸不着头脑。\n下课后,欧式教学楼又热闹起来。川流的学生中,方维夏叫住了刘俊卿,说:“上次你不是说,想借讲习科萧子升同学的入学作文,学习他的书法吗?”说着,将一叠文章递了过来:“这是他补考的作文,还有他最近的两篇国文课作业,我都帮你借过来了。看完了,你直接还给他就可以了。”\n望着方维夏离去的背影,刘俊卿捏住那本作文,阴沉着脸,走回寝室。他伸手刚要推门,门却正好从里面被拉开,一个足球迎面飞出,随即毛泽东光着膀子,与周世钊他们冲了出来。\n“刘俊卿,”毛泽东看看侧着身子生怕被球碰到的刘俊卿,一边颠着足球一边招呼他,“走,踢足球去?”\n“不了,你们去吧。”刘俊卿说着,换了一副笑脸。\n“你也要动一动嘛。哎呀,随便你了。”毛泽东也不再勉强他,与周世钊等同学边传着球边往操场跑去。\n刘俊卿保持着笑容,走进了寝室。几乎在门关上的同时,他脸上的笑容一扫而光。看看手里萧子升的那本作文,再看看毛泽东的床位,他发泄似的将作文本扔到了桌上。他在自己床沿坐下,满寝室漫无目的地张望着,想这次的作文。想着看着,看着想着,猛然间,他的目光被子鹏鲜亮的床铺吸引住了。不由自主地,刘俊卿走到了子鹏床前,他有些忐忑地撩开蚊帐,窥视着里面的一切:崭新的、高档的、齐全的……总之是他刘俊卿没有见过却梦寐以求的,他把手伸了出来,却又有些心虚地望了望紧闭的门口,但最终还是抵抗不了诱惑,他在子鹏的床上坐了下来,怯生生地抚过绣花床单,抚过缎面被子,抚过柔软的枕头……他打开了一瓶雪花膏,闻了闻,又赶紧盖上,仿佛意识到了这一切并不属于自己,他有些慌乱地站起,放下了蚊帐。但地上那双擦得雪亮的皮鞋却令他怎么也无法迈开脚步,他看了看门口,咽了口唾沫,把手伸了过去……\n门突然开了,走进来的竟是子鹏!正在系着皮鞋鞋带的刘俊卿顿时愣在了那儿,脸一下子涨得通红,边手忙脚乱地脱鞋,边喃喃地说:“子鹏兄,你回来了?”\n子鹏看到刘俊卿的样子,一时间弄不明白他到底想做什么,愣了一下,随口说道:“没关系,你穿吧,没关系的。”\n“不是……我就是试试……试试这双和我那双是不是一样大小。”刘俊卿涨红着脸,换上自己的布鞋,逃也似的走出两步,又回头解释:“我那双放家里了,没带过来。”\n子鹏也不计较,跟在刘俊卿后面,一起往食堂走去。\n热闹喧天的一师食堂里,墙上的小黑板挂着菜谱——南瓜、茄子、包菜……都是些简单的素菜。学生们拿着各式各样的大碗,排着长长的队伍。终于排到他们了,子鹏和刘俊卿端着盛满饭菜的碗,找了个位子坐了下来。刘俊卿看见子鹏对着面前的茄子米饭,没有动筷子的意思,以为他在想刚才的事情,有些难为情。子鹏不想让同学难堪,解释说他不太习惯吃学校的饭菜,已经另外叫了点心。\n刘俊卿这才把心放回肚子里,低头吃饭,假装不经意地问:“哎,子鹏,问你个事,你那双皮鞋是在哪间店买的?要多少钱啊?”\n“南门口的大昌。也就七八块钱吧,怎么了?”\n“哦,没什么,我看看跟我那双是不是一家店的,我那双放家里了。”刘俊卿这时候说起谎来,已经脸不红心不跳了。\n这时候,一名跑堂的把子鹏的点心送来了。子鹏给了钱,跑堂要把零头还给他,子鹏手一挥,懒懒地说:“不用了,你留着吧。”跑堂满脸堆笑,说着感激的话走了。子鹏推开饭碗,吃起点心来,那些点心的样子很精美,可以想像,味道也一定很好。看看子鹏吃的,再看自己碗里的饭菜,刘俊卿顿时感到口里的食物有些难以下咽了。\n子鹏留意到了他的神情,赶紧把点心挪了过来,请他一起吃。\n刘俊卿客气了几句,还是没能抵抗住美食的诱惑,但又好面子地说:“那,下次我请你。”\n从食堂出来,刘俊卿直接出了学校,正要转弯,却看到父亲的臭豆腐摊子摆在对面的街角。他走过去,左右飞快地瞟了一眼,压低了声音说:“爸,你怎么又把摊子摆到这儿来了?南门口那边摆得好好的,怎么我一进一师,你就非天天摆到校门口来?”\n“俊卿啊,哦,我这就走,这就搬到南门口去。”看着儿子,刘三爹满脸歉然,赶紧收拾摊子。\n“爸,不是……我那个……我有件事……”犹豫了一会儿,刘俊卿终于还是开了口,“爸,你……你有钱吗?”\n刘三爹最怕听见的就是这句话,但他还是把秀秀的工钱全部拿出来给了儿子。\n刘俊卿揣着钱,飞快地跑到南门口的大昌鞋店,他看到中央柜台里,展示着一行皮鞋,当中最亮的一双与子鹏那双正好完全一样。\n看到刘俊卿的目光落在了那双皮鞋上,擅长察言观色的伙计忙凑过来说:“识货!瞧瞧,这位少爷就是识货。这是上海新款,英国老板的鞋厂做的,全省城的少爷都抢着买呢。要不,您拿双试试?”\n刘俊卿努力端着矜持,微一点头:“那就试试吧。”\n“好嘞。”伙计边拿鞋边冲旁边的小学徒,“给少爷上茶。”\n试好了鞋,伙计接过刘俊卿递来的一叠银元,忙不迭地收拾起刘俊卿换下来的布鞋,装进皮鞋盒:“多谢少爷。换下来的鞋,我叫人给您送府上去?”\n“不必了,我自己拿就可以了。”刘俊卿赶紧回绝,他的家哪里称得上是府呢?但接过鞋盒,他却站着没动。伙计问:“少爷,还有事啊?”\n“那个……”刘俊卿憋了一下,这才说,“好像还要找钱吧?”\n“哎哟,您瞧我这记性!”伙计抬手给了自己一巴掌,“对不起,对不起,忘了忘了。”他赶紧找出几枚铜元和一枚铜板递了过去。刘俊卿接过钱,犹豫了一下,又把那一枚铜板放回到伙计手中。学着子鹏的样子,他尽量自然地一挥手,说:“这是赏你的。”\n迈着方步,刘俊卿穿着崭新的皮鞋跨出了鞋店。店内,打量着手里那枚轻飘飘的铜板,伙计职业化的笑容一扫而空,瘪着嘴随手把铜板扔给一旁的小学徒,不屑地说:“去,什么他妈破少爷,伺候了半天,就他妈一个铜子!给,归你了!”\n一道闪电,划过乌云翻滚的天空,轰然一声,惊雷骤起,大雨滂沱。刘俊卿穿着崭新的皮鞋踏过雨点四溅的街道,顶着雨飞跑到一间茶叶店的屋檐下。大雨倾盆,雨点打在地上,水滴不断溅到他崭新的皮鞋上,他有些心痛,想了想,蹲下,准备解开鞋带把新皮鞋换下来。恰在这时,赵一贞背着书包,顶着雨,顺着屋檐跑了过来。刘俊卿突然蹲下,挡住了她的路,两个人一下子险些撞上,都吓了一跳。\n“哟,对不起。”刘俊卿赶紧站起,就在这一刹那间,他的心怦然而动,眼前明亮如彩虹高挂,那是湿淋淋的赵一贞,清秀而水灵。一贞读出了刘俊卿眼里的炽热,娇羞地躲开了刘俊卿的目光。\n店里的赵老板看见了女儿,叫道:“一贞,还不快回来?哎呀呀,你看看你这一身水,快擦擦,快擦擦。”一贞进了屋接过毛巾后,他又把一张货单递给一贞,说:“我先进去吃饭了,你看着店。这上面的几样货,都是客人订好了的,下午就会来拿,你赶紧包一下。弄漂亮点啊,人家要送礼的。”\n赵老板走后,一贞对着货单,收拾着包装茶叶的东西。几个竹编礼品盒放在货架最上面,一贞搬来凳子,脱鞋站上去,尽量伸手够着。她的脚用力踮起,打湿的衣裙贴着努力伸展的身体,露出了雪白的小腿,把屋檐下的刘俊卿看得都痴了。似乎是感觉到了某种异样,一贞一侧头,正碰上了刘俊卿痴痴的目光,慌乱中,哗啦一声,货架顶上的礼品茶叶盒摔了一地!\n“怎么回事?”里屋的布帘一掀,赵老板端着饭碗冲了出来,一看,火气腾地上来了,把饭碗往柜台上“砰”地一搁,对着女儿骂道,“你搞什么名堂?一点小事都做不好!这盒子一个多少钱你知不知道?”\n“养你吃,养你穿,供你念书还不够,还一回家就摔东西!你以为这点小生意供你供得容易啊?”女儿已经在道歉了,赵老板还是不依不饶,端起饭碗,吼了一声,“还不赶紧收拾?”\n赵老板重新进了屋后,一贞忍着眼泪,默默地收拾着地上的礼品盒。刘俊卿捡起掉在店门口的盒子,递到她面前。迎着刘俊卿满是安慰与同情的目光,一贞接过盒子,慌乱地低下了头,怯怯地招呼他进来躲雨。刘俊卿喜出望外地退进店里,坐在一贞递过来的凳子上。一贞躲开了刘俊卿的目光,背着他包扎茶叶礼品盒。刘俊卿的目光,却始终没有离开过一贞灵巧的双手。\n赵老板出来换赵一贞进去吃饭。赵一贞的身影已经看不见了,刘俊卿的目光还停留在通往里间的晃悠悠的门帘上。直到赵老板挡住了他的视线,提醒他说雨停了,他才起身不好意思地告辞。\n二 # “毛泽东。”捧着大堆信件和报纸的校役叫住了正趿着一双破布鞋,端着饭碗边走边吃的毛泽东,“你的报纸,还有你的一封信。”\n毛泽东接过校役递来的报纸和信,看到信封上是毛泽民那稚嫩的字体,落款却标着“母字”,一看就知道是母亲口述、弟弟抄写的,忙把饭碗随手往旁边的窗台上一放,赶紧拆开信读起来:“三伢子,收到你的信,晓得你考了个好学堂,碰上了好先生,妈妈真是好高兴……你爹爹白天还硬起脸,不肯看你的信,其实晚上一个人偷偷起来躲着看,还生怕被我看见了……你在学堂里要好好念书,不要记挂家里,家里爹爹、妈妈、弟弟、妹妹都好……读书辛苦,要注意身体。有什么难处就写信回来,妈妈给你想办法。没有时间,就不要想着回来看我,妈妈不要你看,只要你把书读好,就是对妈妈最大的孝顺……”\n缓缓地收起家信,毛泽东将信放进了贴身的口袋,拿起报纸和饭碗,刚一转身,却发现杨昌济与黎锦熙正站在他面前。两位老师打量着他,目光都落在了他那双打眼的破布鞋上。\n黎锦熙笑道:“润之,报纸呢,是越订越多,这双鞋呢上个月就说换,怎么到现在都还没换呀?也该换换了吧?”\n毛泽东不好意思地摸了摸脑袋说: “上个月……后来忘记了。杨老师,黎老师,我先走了。”\n“等一下。”他刚走出两步,杨昌济叫住他,把一块大洋递到了他面前,说: “书要读,报要看,鞋也不能不穿吧?趁中午,赶紧去买一双。”看毛泽东站着不动,黎锦熙拉了他一下,说:“拿着吧,还讲客气?”\n接过钱,毛泽东一时也不知该说什么好。站在原地看两位老师走远了,他赶紧收拾好报纸和碗筷,跑出去买鞋。\n大昌鞋店,伙计一听毛泽东连四毛一双的布鞋都还嫌贵,满脸不乐意地抱怨:“我这儿可是大昌,不卖便宜货。再要少,路边摊上买去。”毛泽东悻悻地向店外走去,在熙熙攘攘的叫卖声中,拖着一双破布鞋走在青石板街面上。这时街边,一个妇人正叫卖着:“布鞋,上好的布鞋,一毛五一双。”毛泽东径直向鞋摊方向走去。但他的脚步却没停在鞋摊前,而抢前几步,停在了一块招牌前。那正是观止轩书店的广告牌,上面开列着一系列新书消息。“《西洋伦理史论》?”毛泽东的眼睛亮了,转身进了观止轩书店。\n书架的两边,各有一双手正从相反的方向对准了相邻的两本书:一只纤纤小手放在了《伦理学原理》上,一双粗壮的大手放在了《西洋伦理史论》上。两个人在抽出书的同时,都发现了对方,毛泽东先惊呼了一声:“哎,是你啊?”斯咏暂时却还没把毛泽东认出来,她只是有些疑惑地望着这个似曾相识的人。\n“不记得了?上次,就在这里,那本书——你后来还送给我了。”毛泽东提醒她说。“哦——对对。”斯咏打量着毛泽东,目光落在那双鞋上,“你这双鞋修修补补的还在穿啊?”\n“上次那本书我已经看完了,你看什么时候还给你?”毛泽东看了看自己的鞋,不好意思地笑笑,边翻着手里的书边问。“我不是送给你了吗,还还什么?”“还是要还喽,哪有白拿你的道理?”毛泽东不好意思地说。\n“那——下次有机会再说啰。”“也好。哎,你买什么书呢?”斯咏把手里的书一亮,毛泽东看了看封面,说,“《伦理学原理》?哦,德国泡尔生的。我们发过课本,课还没开,不过我已经看完了。”\n斯咏看看他,吃惊地问:“你在读书啊?”“第一师范。你呢?”“我在周南。”斯咏犹豫了一下,问道,“哎,你是第一师范的?你贵姓啊?”\n“姓毛,毛润之。”斯咏顿时心里一热,试探问道:“你们第一师范有几个姓毛的?”\n“好几百学生,我怎么知道?哎,你叫什么?”看看斯咏翻开的课本露出的姓,毛泽东叹道,“陶斯咏?好名字啊,喜斯陶,陶斯咏,取得喜庆。”\n“你也知道这个典故?”斯咏惊疑说。“出自《礼记·檀弓上》嘛,‘喜则斯陶,陶斯咏,咏斯犹,犹斯舞。’你这个人,一辈子都会开心得连唱带跳喽!”\n说着话,毛泽东拿着书,来到柜台前,用杨昌济给他的那块大洋付了书钱。正要出门,才发现二人说话的时候,外面下起了大雨,雨顺着瓦当落下来,仿佛给大门挂上了一道水帘。毛泽东一展胳膊,满不在乎地说:“哈哈,人不留客天留客啊!”\n斯咏没料到他会这样想得开,很意外地问:“你还蛮高兴啊?”\n“天要下雨,你又挡不住,还不由得它下?”毛泽东回头叫道,“老板,拿条凳子来坐好不?”伙计提来了一条凳子,毛泽东接过就要坐,看看斯咏,觉得还是不妥,把凳子递过来请斯咏坐下,然后又问老板要。老板回答只有那一条,毛泽东只得在斯咏身边蹲了下来。\n雨如珠帘,洒在屋檐前。斯咏忍不住伸出手,任雨打在手上,感受着那份清凉。毛泽东学着她的样子,也把手伸进雨中。两个人看看自己,再看看对方,突然都笑了起来。这一笑,彼此之间便没有了生疏的感觉,说起话来也轻松多了。\n“要说写下雨,苏东坡那首《定风波》绝对天下无双!你听啊:莫听穿林打叶声,何妨吟啸且徐行。竹杖芒鞋轻胜马,谁怕,一蓑烟雨任平生……”指点雨景,吟起苏词,毛泽东兴致盎然。\n斯咏揭短道:“人家那是下小雨。”\n“大雨小雨还不是一回事,反正是写下雨的。”\n“那怎么会一样?下大雨不可能这么悠闲。”\n“倒也是啊。真要下这么大的雨,苏东坡还会‘徐行’?他肯定跑得比兔子还快。”\n毛泽东这句话把斯咏逗乐了,她嗔怪道:“正说也是你,反说也是你。”\n“不服气你来一首,得跟下雨有关啊。”\n明明知道毛泽东在激将她,斯咏还是大方地说:“来就来,李清照的《如梦令》,昨夜雨疏风骤,浓睡不消残酒。试问卷帘人,却道海棠依旧。知否知否,应是绿肥红瘦。怎么样,比你的有意境吧?”\n“光有意境,内容软绵绵的,还是没劲。你听这首,杜甫的《春夜喜雨》,好雨知时节,当春乃发生。随风潜入夜,润物细无声——由雨而遍及世间万物,比你那个意境开阔得多吧?”\n“诗词嘛,讲的是内心的感受,未必非要遍及世间万物才好。”斯咏争辩道。\n雨声潺潺,两个人对吟相和的声音一来一往,仿佛融入这纯净的雨中,成了其中的一部分。\n“伐木丁丁,鸟鸣嘤嘤。出自幽谷,迁于乔木。嘤其鸣矣,求其友声。”毛泽东得意洋洋,“我又赢一盘!怎么样,三打三胜了啊。”\n斯咏说不过毛泽东,耍着小性子:“你厉害,行了吧?不跟你比了。什么嘤其鸣矣,没意思。”\n“怎么会没意思呢?《诗经》里头,我最喜欢的就是这一句了。你看啊,空谷幽幽,一只寂寞的嘤鸟在徘徊吟唱,啊,天地之大,谁,能成为我的知音?谁,能成为我的朋友?谁,能懂得我的心,能跟我相应相和?”吟到高兴处,他拖着破布鞋,手为之舞,足为之蹈,完全陷入了诗的意境中。\n望着毛泽东,斯咏突然扑哧笑了出来。\n毛泽东问:“哎,你笑什么?这首诗未必好笑啊?”\n“诗倒是不好笑。我就是在想,你那个空谷,是不是在非洲啊?”\n“中国的诗,怎么又扯到非洲去了?”\n“要不是在非洲,”斯咏上下打量着毛泽东,“哪来那么大的一只鸟,你以为中国也产鸵鸟啊?”\n毛泽东的诗兴一下子被打断了,无奈地说:“你看你这个人,一点都不配合别人的情绪。真是对牛弹琴。”看到斯咏不高兴了,毛泽东赶紧弥补道:“开句玩笑嘛,这也当真?这世上哪有你这种身材的牛嘛?”\n“没错,蠢牛都是那些又高又大的家伙!”斯咏扭开头,过了一会儿,没听见毛泽东的声音,又扭头看去,却见毛泽东正笑嘻嘻地看着她。佯嗔着的斯咏也忍不住笑了,对着毛泽东又说了一句,“蠢牛!”\n“雨小了,该走了。我下午还有课,等不得了。再说这点雨,无所谓了。”打量着雨,毛泽东卷起了裤管,又把那双破布鞋脱了下来,拎在手里,转身,把刚脱过鞋的手伸向斯咏,“很高兴认识你。”\n看到斯咏盯着自己的手不动,毛泽东这才反应过来,赶紧把沾有污水的手往衣服上擦了几把,再次伸来,说,“对不起呀,没注意。”\n两个人握了握手,毛泽东说:“下次有空,我们再聊,到时候我把书还给你。再见了。”说完便冲进了雨中。\n望着毛泽东远去,斯咏不禁自言自语,“下次?一没时间二没地点,哪来的下次啊?这个人!”\n三 # 刘俊卿不舍地往前走去。地上,到处是积水,他找了个靠墙的地方,脱下皮鞋,换上了布鞋,小心地选着水少的地方落脚,向一师走来。眼看快到校门口了,他犹豫了一下,又躲到墙边,取出了皮鞋,掏出手帕,仔细地擦了擦,才穿上。崭新的皮鞋踏在地上,和穿布鞋的感觉就是不一样。刘俊卿昂着头,迈着方步,向学校走来。\n随着一声“落轿”,袁吉六一抖长衫,气派十足地下了轿。一旁,黄澍涛等人的轿子刚好也到了。二人互相抱着拳,走进校门。放眼看去,接送老师的轿子成了堆,众先生个个衣冠楚楚,一看就都是有身份的人。\n毛泽东光着双脚,提着那双破布鞋,正好也在这个时候跑了进来。刘俊卿心情很好,主动招呼道:“润之兄。”毛泽东随口答应着,看都没看刘俊卿的新皮鞋,自顾自地跑上台阶,抖着衣服放裤管。刘俊卿不禁有些失望,还好子鹏与蔡和森也正走来,他又来了精神,改变方向走向子鹏,不料此时身后正好有个中年人边抖蓑衣边走来,与他撞在了一起,中年人沾满泥水的草鞋踩在了刘俊卿闪亮的皮鞋上。\n“哎哟,对不起,对不起,一下没注意,对不起了。”中年人不好意思地说。\n看到崭新的皮鞋被踩上了几道泥水印,刘俊卿的眉头顿时皱了起来,他打量了一眼中年人,一身陈旧的土布短褂,卷着裤管,穿着草鞋,提着蓑衣,身上到处是水,脸上赔着憨厚的笑,一看就是个老实巴交的农民,顿时很不高兴地吼道:“搞什么名堂你?长没长眼啊?我这可是新鞋,上海货,弄坏了你赔得起吗?”\n“真是对不起,你多原谅……”中年人憨厚地继续道歉。\n刘俊卿却还是得理不饶人:“我不管,你给我弄干净!”\n“刘俊卿,你至于吗?人家又不是故意的。”毛泽东看不下去了,回头来为中年人打抱不平。\n刘俊卿对他怒目相向:“不是你的鞋,你当然不心疼。”\n“也不过就是双鞋,又不是你的命!”\n“你以为这是你那双破鞋啊?穿不起就别在这儿摆大方!”刘俊卿挖苦毛泽东,又冲中年人吼道,“你到底擦不擦?”\n蔡和森也看不过了,劝说道:“刘俊卿,何必呢?回去自己擦一下算了嘛。”\n“关你什么事?要你多嘴!”\n子鹏也来打圆场:“算了算了,我借你手帕……”\n毛泽东一把拉过子鹏:,说“莫借给他,让他自己擦!还不得了啦!”\n“毛泽东,我可没想惹你啊!”刘俊卿觉得毛泽东真是多事。\n毛泽东偏偏就是个不怕事的主,把腰一挺,冲着刘俊卿嚷嚷道:“那又怎么样?”\n眼看几个学生要吵起架来了,中年人赶紧插话说:“算了算了,都是我惹出来的事,我擦干净,好不好?”他蹲下去,抓着衣袖来给刘俊卿擦鞋。\n“哎,我说,你何必……”毛泽东还想阻止,中年人却带着一脸息事宁人的笑,温和地说,“算了,不就是擦一下吗?擦干净就什么事都没有了。”\n他用衣袖擦着皮鞋上的污水,刘俊卿伸着脚,一动不动。毛泽东实在看不下去,向刘俊卿重重地哼了一声,转身就走。\n中年人直起身问刘俊卿:“你看看擦好了吗?”\n众目睽睽下,刘俊卿似乎也感到了自己有些过分,他放缓了口气:“算了吧,下次小心点。”\n走进大楼的毛泽东又回头瞪了外面的刘俊卿一眼,他刚往里走,迎面,却站着杨昌济。看看老师的目光停留在自己拎在手里的破鞋和另一只手上的书上,毛泽东不由得不好意思起来,低下了头。\n杨昌济问他:“又买了什么书?”\n“《西洋伦理学史论》。”\n“哦。”杨昌济接过书,翻了翻定价:“不便宜嘛!”\n“本来我是去买鞋的,路上经过书店,没注意就……”他解释不下去了,摸了摸脑袋。望着他,好一阵,杨昌济才把书递了回来,不动声色地说:“要上课了,别耽误了。”望着毛泽东光脚跑去的背影,杨昌济微微地点了点头。\n毛泽东进了综合大教室里,才坐好,就看见方维夏进来了。他上了讲台,扫视了一眼台下的全体新生,说:“各位同学,从今天起,大家将开始一门新的课程——教育学的学习。教育学是我们师范生的专业主课,也是学校非常重视的一门课程,为了开好这门课,学校专门聘请了长沙教育界著名的教育学权威——徐特立先生为大家授课。”\n台下的学生们精神一振,不少人小声议论了起来,徐特立的名字显然大家都听说过。方维夏继续说:“徐先生是长沙师范学校的校长,也是省议会副议长,能于教务与政务之百忙中接受聘请,为大家来授课,是我们第一师范的光荣,也是各位新同学的荣幸。下面,让我们用热烈的掌声,欢迎徐特立老师!”\n掌声如雷,人群中,刘俊卿更是从听到“副议长”的头衔起就激动得两眼放光。他鼓掌的手突然僵住了。从门外进来的,竟是方才在大门口为他擦鞋的那个中年“农民”,他的袖口上,还带着擦鞋留下的污印。\n“同学们,”徐特立走上讲台,声音洪亮,“你们都是师范生,以后呢,都将成为小学教师,教育学就是教大家怎么做一个合格的教师。今天,我不打算给大家讲课,课本上的知识,留待今后。现在我们一起去参观一次小学教育,以便大家对今后要从事的职业有一个直观的认识。参观之后,回校分组讨论,各写一份参观心得,这就是我们的第一课。好,全体起立,跟我出发。”\n他干净利落地说完,大步就往外走。学生们纷纷跟了上来,这样的教学方法显然让大家颇觉新鲜,毛泽东拍拍蔡和森:“哎,这老先生有点意思啊。”蔡和森微笑着点点头,落在最后的刘俊卿却脸色惨白。\n四 # 足球场上,一场球正踢得热火朝天。学生们的球技显然大都不怎么样,却吆喝喧天,一个个大汗淋漓,只有易永畦一个人坐在场边,看守着大家堆放在一起的衣服、鞋子。\n简易的木框球门前,毛泽东大张双手,正在守门。萧三一脚劲射,毛泽东腾空跃起,一脚将球踢开,他身手虽快,动作姿势却并不漂亮,摔了个仰面朝天。那只修补过的布鞋唰的又撕裂了,随着球一道飞出了场外。一片笑声中,易永畦赶着给毛泽东捡回了鞋,毛泽东却示意不必,他索性脱了另一只鞋,光脚投入了比赛。拿着毛泽东的鞋,易永畦仔细地端详起那个破口子。\n黄昏的余光透过八班寝室的窗户,照在一双单瘦苍白的手上,这双手正吃力地用针线缝补着毛泽东那只裂了口子的布鞋。透过厚厚的近视眼镜,易永畦的神情是那样专注。\n“砰砰砰”,平和的敲门声传来。“请进。”易永畦抬起头,突然一愣,赶紧站起身来。走进门来的,正是杨昌济,他打量了一眼空荡荡的寝室,问:“怎么,毛泽东不在吗?”\n“您找润之啊,他这会儿肯定在图书馆阅览室,他每天这个时候都去看书,不到关门不回来的。”\n“是吗?”杨昌济点了点头,目光落在了毛泽东床头那张已经泛了白的姓名条上,“这是他的床吧?”\n“对。”\n杨昌济审视着毛泽东的床和桌子,床上,是简单的蓝色土布被褥,靠墙架着的一块木板上重重叠叠堆着好几层书,把木板压成了深深的弓形,还有不少书凌乱地堆在床头床尾,整张床只剩了勉强可容身的一小半地方。桌子上,同样层层叠叠堆满了书和笔记本,到处是残留的蜡烛痕迹和斑斑墨迹。一张摆在桌面上的报纸吸引住了杨昌济的目光,这是一张《大公报》,报纸却显得特别小了一号,杨昌济拿起来一看,才发现报纸被齐着有字的部分裁过,天头地脚都不见了。\n“这是怎么回事?”杨昌济显然有些不解,“怎么把报纸裁成这样?”\n“哦,这是润之自己裁的。”\n杨昌济这才注意到床边的另一叠报纸,这些报纸同样裁去了天头地脚,每张报纸上却都钉着一叠写了字的小纸条,可以看出正是用报纸的天头地脚裁成的。\n易永畦解释着:“润之读报有个习惯,特别仔细,不管看到什么不懂的,哪怕是一个地名,一个词,只要以前不知道的,他都要马上查资料,记到这些裁下来的纸条上。所以呀,我们都叫他‘时事通’,反正不管什么时事问题,只要问他,没有不知道的。”\n翻着钉在报纸上的一张张小纸条,杨昌济问:“可是,裁报纸多麻烦!为什么不另外用纸记呢?”\n“那个,”易永畦犹豫了一下,“白纸六张就要一分钱……”\n“哦。”杨昌济明白了,他点了点头,目光落在了易永畦手中那只正在补的布鞋上,问,“这是他的鞋吧?”\n“对,润之他就这双鞋,早就不能穿了,他又不会补,我反正以前补过鞋……就是鞋太旧了,补好了只怕也穿不了几天。”\n拿过那只补了一半的鞋,杨昌济伸手大致量了一下长短,突然笑了:“嗬,这双脚可够大。”\n易永畦憨厚地笑着,他自己的脚上,那双布鞋同样打着补丁,旧得不成样了。\n杨昌济一路想着易永畦说毛泽东的话,来到一师阅览室时,天色已经暗下来了,他进去时却发现一枝蜡烛摆在桌上,并没有点燃,毛泽东正借着窗前残余的微弱光线在看书。他的面前,是摊开的辞典和笔墨文具,他不时停下来,翻阅资料,核对着书上的内容。\n杨昌济划燃了火柴,微笑着点燃了那支蜡烛,对毛泽东说:“光线这么差,不怕坏眼睛啊?”\n毛泽东一看是杨老师,想站起来,杨昌济拍着他的肩膀,示意他坐下继续看书。毛泽东看看老师刚刚给自己点燃的蜡烛,说:“我觉得还看得清,再说天真黑了,学校也会来电。”\n杨昌济拿起毛泽东面前那本书,看了一眼,正是《西洋伦理学史论》,问道: “你好像对伦理学很感兴趣?来,说说看。”\n毛泽东大胆地说:“世间万事,以伦理而始,家国天下,以伦理为系,我觉得要研究历史、政治及社会各门学科,首先就要掌握伦理学。”\n杨昌济翻着书,又问:“那,你对泡尔生说的这个二元论怎么看?”\n“泡尔生说,精神不灭,物质不灭。我觉得很有道理,精神和物质,本来就一回事,一而二,二而一,正如王阳明所言,心即理也。”\n“你再具体说说你的感想。”\n“对。世界之历史文明,本来就都存在于人的观念里头,没有人的观念,就没有这个世界。孟子的仁义内在,王阳明的心即理,和德国康德的心物一体,讲的都是这个道理。可谓古今中外,万理一源。”\n“你是在想问题,带着思索读书方能有收获。”杨昌济笑了,放下书,站起身来,说:“好了,你先看书吧,我不打搅你了。”走出两步,他又转头:“对了,明天下了课,记得到我办公室来一趟。”\n走出阅览室,杨昌济的脚步停在了门外。静静地凝视着里面那个专心致志的身影。秋风掠过,杨昌济拉紧了西服的前襟。他的目光落在了毛泽东的凳子下,那双光着的大脚上,只穿着一双草鞋,却似乎全未感觉到寒冷的存在。\n从阅览室回到寝室,毛泽东洗脚准备休息了,可他的大脚从洗脚的木盆里提了出来,擦着脚上的水,眼睛却始终没有离开面前的书。一双手无声地移开了木盆旁的草鞋,将那双补好了的布鞋摆在了原处。毛泽东的脚落在鞋上,才发现感觉不对,一抬头,眼前是易永畦憨厚的笑容。毛泽东拿起鞋一看,愣住了。易永畦微笑着,向他点了点头,轻轻退回了自己的铺位。烛光下,凝视着重新补好的鞋,毛泽东一时间也不知是什么心情。\n第二天,在办公室里,杨昌济把厚厚的一大本手稿放在毛泽东面前,对他说:“昨天我看见你读那本《西洋伦理学史论》,那本是德文原著,蔡元培先生由日文转译而来,一则提纲挈领,比较简单;二则屡经转译,原意总不免打了折扣。我这里正好也译了一本《西洋伦理学史》,是由德文直接译过来的,你如果有兴趣,可以借给你看看。”\n毛泽东喜出望外:“真的?那……那太谢谢老师了!”\n“这可是手稿,只此一份,上海那边还等着凭此出书,你可要小心保管,要是丢了,我的书可就出不成了。”\n“您放心,弄丢一页,您砍我的脑壳!”\n毛泽东抱着书稿站起身,正要出门,却又听到杨昌济在喊他:“等一下。”然后,把两双崭新的布鞋递到了毛泽东面前。\n“我可不知道你的脚到底多大,只是估摸着买的。你这个个子,这鞋还真不好买。”\n拿着鞋,毛泽东一时真不知说什么好。他突然深深给杨昌济鞠了一躬:“谢谢老师!”\n第七章 修学储能 # 一个年轻人走进学校的目的是什么?是学习知识,更是储备能力。孔子曰:‘ 质胜文则野,文胜质则史。’ 就是说,一个人如果光是能力素质强,而学问修养不够,则必无法约束自己,本身的能力反而成了一种野性破坏之力;反过来,光是注重书本学问,却缺乏实际能力的培养,那知识也就成了死知识,学问也就成了伪学问,其人必死板呆滞,毫无价值。修学与储能,必须平衡发展,这是求学之路上不可或缺的两个方面。\n一 # “空山新雨后,天气晚来秋。”站在一师大门口,一身日本式的文官装束的纪墨鸿,打量着一师院内还带着雨水的参天大树、翠绿草坪,感慨颇多,“城南旧院,果然千年文华凝聚之地,气度不凡啊。”\n孔昭绶和方维夏、黎锦熙等人陪在他的身边,听到他的这番感慨,礼貌地说:“纪督学客气了。督学大人代表省府,莅临视察,故我一师蓬荜生辉。”三人一路寒暄,向校内走来。\n综合大教室里,人声鼎沸,一片热闹,学生们各自扎成一堆,热烈地讨论着,许多凳子都被抽乱,组成了一个个小组,连徐特立也挤在毛泽东这组学生中,和学生争辩着。教室门口,纪墨鸿望着眼前乱糟糟的样子,眉头拧得紧紧的,一副看不下去的样子。孔昭绶感觉到了他的不满,走上讲台,提高了嗓子大声说:“各位同学,请安静。特立先生,介绍一下,这位是省教育司派来的督学纪墨鸿先生,今天前来视察一师。”\n学生们这才发现校长等人来了,赶紧安静下来,各自坐好。不等徐特立开口,纪墨鸿抢先拱手作揖:“哎哟,是徐议长啊,久仰久仰。”\n徐特立淡淡地说:“纪督学客气了,这里没有什么徐议长,只有教书匠老徐。”孔昭绶问:“纪督学,既然来了,是不是给学生们训个话?”\n纪墨鸿赶紧摇手:“有徐议长在,哪容得卑职开口?”徐特立说:“在这里,我是老师,你是督学,督学训话,职责所在嘛!”\n“纪督学,您就不用客气了。”孔校长宣布:“各位同学,今天,省教育司督学纪墨鸿先生光临本校视察,下面,我们欢迎纪督学为大家训话。”\n他带头鼓起掌,掌声中,纪墨鸿一脸的迫不得已,向徐特立赔了个谦恭笑脸,这才整整衣冠,上了讲台。\n“各位青年才俊,在下纪墨鸿,墨者,翰墨飘香之墨,鸿者,鸿飞九天之鸿。墨鸿今日能与诸位才俊共聚一堂,深感荣幸。所谓训话二字,愧不敢当,不过借此机会,与诸位做个读书人之间的交流而已。这个读书二字,是世间最最可贵的了,何以这么说啊?书,它不是人人读得的,蠢人就读不得,只有聪明人才读得书进。所以这世上的读书人,都是聪明人,列位就是聪明人嘛……”\n台下,萧三忍不住跟毛泽东嘀咕了一句:“他不如照直讲,他这个人最聪明。”毛泽东一笑,他显然对这番话也极不以为然。\n“古人云:书中自有颜如玉,书中自有黄金屋。读了书,人自然就有大好前程,不然还读什么书呢?”纪墨鸿说得兴致勃勃,“所以,孔子曰:学而优则仕。就是说书读好了,政府才会请你去做官,你也才能出人头地,做个人上人啊!当然了,我不是说只有当官才有前途,打个比方,打个比方而已,但道理就是这个道理。”\n一下午无精打采的刘俊卿这时听得聚精会神,眼睛都望直了。蔡和森、萧子升等人却都露出了听不下去的神情,毛泽东则索性抽出一本书,翻了起来。\n“总之一句话,学生就要以学为本,好好读书,认真读书,不要去关心那些不该你关心的事,不要去浪费时间空口扯白话,多抽些时间读点书是正经。以后,你就会晓得,那才是你的前途,那才是你的饭碗。纪某是过来人,这番话,句句是肺腑之言,不知各位听到心里去没有?”\n台下,鸦雀无声中,突然传来了很清晰的一声翻书声——毛泽东哗啦翻过一页书,看得旁若无人。纪墨鸿不禁一阵尴尬,面露愠色。孔昭绶也愣了一下,一时又不好提醒毛泽东,不知如何是好,场面一时尴尬起来。安静中,刘俊卿突然带头鼓起掌来,这一下总算带起了一些掌声。纪墨鸿的尴尬总算有了下台的机会,僵住的笑容渐渐绽开。“嘿嘿,多谢,多谢多谢。”他团团抱拳,留意地看了为他解围的刘俊卿一眼。\n送走纪墨鸿,黎锦熙来到校长室,仰头喝了一大口水,长吐了一口气:“唉呀,总算是走了。”“总算?”方维夏苦笑了一下,“人家可没说以后不来了。”\n办公桌后,孔昭绶神情疲惫,他揉着自己的眉心,强打精神说:“维夏、锦熙,你们两个安排一下,尽快把这间校长室腾出来,再买几件像样的家具。还有,做一块督学办公室的牌子,记住,比校长室的这块要大。”\n黎锦熙愣住了:“校长,您还真给他腾办公室?”“全校就我这间大一点嘛。我无所谓,随便换间小的就是。”\n方维夏不解地问:“校长,他纪墨鸿不过是个督学,帮办督察而已,又不算什么真正的上司,不至于吧?”\n“这不是官大官小的问题,有的人哪,只要还能管到你一点……”孔昭绶没有继续往下说,只摆了摆手,“就这么办吧。”方维夏、黎锦熙无奈地看了一眼。\n二 # 因为上次“鼓掌解危”时,纪墨鸿曾刻意用嘉许的目光多看了刘俊卿几眼, 所以,几天后,一听说纪墨鸿搬进了督学办公室,敏锐的刘俊卿立即将自己精心写的一篇心得呈交了上去。\n纪墨鸿看了文章,微笑着说:“嗯,文章写得不错嘛。你怎么会想起写这篇心得给我呀?”\n刘俊卿毕恭毕敬地回答:“上次听了督学大人的教诲,学生激动得一晚上都没睡着觉。只有好好读书,才有大好前程,这个道理,从来没有人像大人说得那么透彻,真是句句说到学生的心里去了,学生有感而发,故此写了这篇心得,聊表对大人的高山仰止之意。”\n纪墨鸿满意地点了点头,亲切地说:“好了好了,你也别张口大人闭口大人的,这里是学校,纪某也是读书人,没有那么多官架子,你以后,就叫我老师吧。以后有空,多到我这儿坐坐。我呀,就喜欢跟你这样聪明上进的学生打交道。”\n刘俊卿低声唱着歌激动地从督学室内出来,一下子觉得整个身心从没有这样轻松过,头顶的天空也从来没有这样辽阔过。他一扫往日的沉郁,中午放学后,与子鹏有说有笑地结伴去食堂。食堂里,人流来往,喧闹非常,墙上木牌上仍然是老几样:茄子、南瓜、白菜……最好的不过是骨头汤。他俩一进去,就看见徐特立一身布衫草鞋,端着个大碗,排在一列学生队伍的最后面。刘俊卿一捅子鹏,夸张地说:“哎,看看看,徐大叫花又来了。”\n子鹏拉了拉他,低声说:“你怎么这么叫老师?”“都这么叫,又不是我一个人。本来嘛,教员食堂一餐才一毛钱,他都舍不得去,天天到这里吃不要钱的,不是叫花是什么?” 俊卿哼一哼说。\n两人打了饭菜坐下来。刘俊卿用筷子拨着碗里的饭菜,一脸不满地抱怨:“搞什么?天天就这点萝卜白菜!”子鹏苦笑着说:“味道是差了点。”\n“差了点?简直就是猪食!”刘俊卿说着把筷子一撂,抬眼看其他同学:食堂里,年轻人的胃口个个好得惊人,一桌桌学生都大口大口吃得正带劲。与学生们一桌吃饭的徐特立刮尽了碗里的饭,起身到开水桶前,接了半碗开水,涮涮碗,一仰脖喝下去,抹抹嘴,一副心满意足的样子。刘俊卿咽了一口唾液,站起身来说,“我去打两杯水过来。”\n这时秀秀忽然提着食盒进来了。她站在门口满食堂四处张望,一时见到王子鹏了,快步走过来,打开食盒,边取出里面的菜边对少爷说:“太太怕您吃不惯学校的伙食,叫我做了几样您爱吃的菜送过来。”\n“哇!阿秀,谢谢你了。”子鹏一看几乎要流口水了。\n端着两杯开水的刘俊卿猛然看见妹妹,手一抖,滚烫的开水抖了出来,烫得他一弹。子鹏赶紧接过开水,捧着俊卿的手吹气。“没事没事……水不烫。”紧张中,刘俊卿目光闪烁,瞟了一眼秀秀,又赶紧躲开她的目光。一个“哥”字都到了嘴边的秀秀硬生生地收住了口,她从哥哥的表情上看出,他不希望自己在这样的场合招呼他。\n子鹏掏手帕擦净了俊卿手上的水,说:“阿秀,这是我同学,刘俊卿,跟你同姓呢。俊卿,这是阿秀,在我家做事的。”\n迎着秀秀的目光,刘俊卿挤了个笑容,低下头。子鹏却请刘俊卿和他一起分享家里带来的美食,刘俊卿答应着,仿佛为着躲开妹妹,他端起桌上那两碗学校供应的饭菜,逃也似的向潲水桶走去,哗啦一下,两碗饭菜被他倒进了潲水桶。\n几个同学看见,诧异地看着刘俊卿,蔡和森一皱眉,忍不住站起,但想想又坐下了。秀秀的身子不禁微微一颤,跟子鹏说了一声送晚饭的时候再来收碗,就转身出去了。食堂外,回头远远地望着哥哥正和少爷一起吃饭的背影,哥哥脚上闪亮得刺眼的新皮鞋,两行眼泪从秀秀的脸上滑了下来。\n吃过了饭,学生们纷纷回教室,杨昌济正在那里准备教案,这时毛泽东捧着那本手稿,送到了他面前。杨昌济看看面前的手稿,再看看毛泽东,没有伸手接,却微微皱起了眉头。他沉吟了一下,说道:“润之,有句话,看来我得提醒你才行,读书切忌粗枝大叶,囫囵吞枣,这么厚的书,这么几天时间,你就看完了?这书中的精义,你难道都掌握了?”\n“老师,您误会了,这本书我还没来得及认真看呢。”\n杨昌济有点不高兴了,失望地说:“还没认真看?那你就还给我?这本书不值得你看吗?”\n“不是,书太好了,我才看了几页,就觉得太短的时间根本读不透书里面的内容,老师这部手稿又等着出书要用,所以……所以我抄了一份,打算留着慢慢消化。”\n“你抄了一份?”杨昌济眼都直了,“十几万字,一个礼拜,你抄了一份?”\n毛泽东点了点头。原来,就在杨昌济借书给毛泽东的那天下午放学后,毛泽东便跑去文具店花了他仅有的四毛八分钱,买回一大堆白纸和一块没有包装的低档墨,利用晚上寝室熄灯后,借着烛光往白纸订成的本子上抄录杨昌济的手稿。\n杨昌济显然还有些难以相信:“把你抄的给我看看。”\n厚厚几大本手抄本摆上了毛泽东的课桌,杨昌济翻阅着抄本,整整七本用白纸简单装订的手抄本上,字迹虽有些潦草,却是密密麻麻,一字不漏。他看看毛泽东,眼前的学生带着黑眼圈,精神却看不出一点疲倦。杨昌济又翻开了摆在旁边的“讲堂录”,看到笔记本上,同样是密密麻麻的潦草的字迹,上面还加着圆圈、三角、横线等各种不同的符号,旁边见缝插针,批满了蝇头小楷的批语。他惊讶地问:“这是你的课堂笔记?所有的课都记得这么详细?”\n毛泽东回答说:“一般社会学科的课我都记。”\n“怎么还分大字小字,还有那么多符号?”\n“大字是上课记的,小字是下课以后重新读笔记的心得,那些符号有的是重点,有的是疑义,有的是表示要进一步查阅……反正各有各的意思。”\n杨昌济点了点头:“你很舍得动笔啊。”\n“徐老师说过,不动笔墨不看书嘛,我习惯了,看书不记笔记,我总觉得好像没看一样。”\n杨昌济放下了讲堂录,看着毛泽东,似乎想说什么,却又没说出来。他抱起手稿和自己的备课资料,走出一步,又回头:“对了,礼拜六下午你好像只有一节课吧?如果你愿意,以后礼拜六下了课,可以到我这儿来,只要是你感兴趣的内容,我给你做课外辅导。”\n毛泽东问:“礼拜六您不是没有一师的课吗?”杨昌济笑着说:“以后有了,你的课。”\n三 # 一样的周末,因为不一样的心境,这些同学少年各自品味着属于他们的青春滋味。\n下午上完最后一节课,蔡和森归心似箭,回到了湘江西畔的溁湾镇刘家台子:“妈,我回来了。”\n正在吃饭的蔡畅蹦了起来:“哥。”\n葛健豪几乎是下意识地想盖住破木桌上的东西,然而蔡和森已经来到桌前,葛健豪的手又缩了回来。桌子上,是两碗几乎看不见米的稀粥,和两块黑糊糊的饼子。看看母亲和哥哥的神情,蔡畅也反应过来,拿着半块黑饼子的手藏向身后,但蔡和森已抓住她的手,将饼子拿了过来。他掰开饼子,碎糠渣子洒落在桌上。把那半块糠饼捏在手里,蔡和森坐在门边的石阶上,他慢慢地掰着,一口口细细地嚼着,嚼着。蔡畅蹲在他的身边,有些不安地观察着他的表情:“哥,其实——糠饼子也挺好吃的,嚼久了,还有一股米饭比不上的清香呢。”\n蔡和森没吭声,又掰了一块糠饼,放进口中。\n“哥,你别这样了。火柴厂关门了,我和妈会找别的事做,我们不会总吃这个的。”懂事的蔡畅抱住了哥哥的膝盖,安慰哥哥说,似乎整天吃这饼的是蔡和森而不是她和妈妈。\n“我知道。我只是想尝尝,尝尝这股清香而已。”蔡和森微笑着,抚了抚妹妹的头,“进屋睡吧,哥想一个人坐一会儿。”\n蔡畅犹豫着站起身,看看哥哥,悄悄回房间去了。\n残月当空,从乌云中探出,洒下浅浅的月光。蔡和森仰望着月亮,长长地吸了一口气,站起身来,走到墙角,掀开破草席。那只擦鞋的工具箱还静静躺在里面,蔡和森抹去箱子上的灰尘,清理着一件件擦鞋的工具。他抖了抖那块抛光的绒布,仿佛是在试探自己的手艺是否还熟练。\n一只手无声地按在他的肩膀上,蔡和森猛回头,看到妈妈温暖而平静的目光正直视着自己。沉默中,葛健豪蹲下身子,接过绒布,抹去了剩下两件工具上的灰尘。 “周末,其他时间不行。”关上鞋箱,站起身,葛健豪看着儿子的笑脸,理了理儿子的头发,说,“没有什么坎是人迈不过去的,只要我们一家人在一起,再难,天塌不下来。”\n蔡和森用力点了点头。月光下,葛健豪抚着儿子的头,突然抱住儿子,在他额上亲了一下。\n第二天上午,蔡和森背着擦皮鞋的箱子出了门。\n而在周南中学的寝室里,斯咏正专心致志地在一本书扉页上题字。警予轻手轻脚地从后面摸上来,摸到斯咏身后,大喝一声:“写什么呢?”\n“吓死我了,干什么你?”斯咏吓了一跳,一把盖住书。\n“看你写得那么认真,过来参观一下啰。写什么好东西,还遮着盖着?”\n斯咏把书推了过来,警予一看,那是一本《伦理学原理》,书的扉页上写的是“嘤其鸣矣,求其友声”。\n“嘤其鸣矣,求其友声?哎,你平时不是最烦《诗经》吗,怎么还抄这个?不就是有只鸟在叽叽喳、叽叽喳,想找只笨鸟跟它一块叫吗?很平常啊。呵呵,不会是有谁想跟你一块叫吧?”\n斯咏不再理睬警予,把头埋在书里了。警予看看她,三下两下、干净利落地收拾起自己的书包,蹬蹬蹬一个人出了门。\n“擦鞋吗,先生?又快又好……”蔡和森坐在街边擦鞋摊前,招揽着生意。远远的,一个正好经过的靓丽身影听到这熟悉的声音,走过来,停在了他的身边。蔡和森一抬头,站在面前的,居然是笑嘻嘻的向警予。\n蔡和森愣了一下,才认出她来:“嗨,是你啊。”\n“老远就看到是你。又在摆摊呢?哎,对了,上次你去考了一师吗?”\n蔡和森笑了笑,说:“考了。”\n“没考上?”\n“考上了。”\n“考上了?那你怎么还……”\n“擦皮鞋是吧?没钱就来擦啰。”\n“哦!勤工俭学。佩服佩服。”\n“这有什么好佩服的?人要吃饭嘛。”\n“话不是这么说,现在哪个学生拉得下面子干这个?只要考进个学校,一个个都好像上了天,恨不得把自己当文曲星供起来。像你这样的,我还真是第一次看见呢。”她在蔡和森身边蹲了下来,撑着下巴,盯着蔡和森:“嗯,我呢,今天出来给家里寄信。现在信也寄了,回去呢,也没别的事。所以呢……”\n蔡和森见她吞吞吐吐的样子,问:“你到底想说什么?”\n警予不容他回绝地说:“你教我擦皮鞋!”\n“哎!擦鞋擦鞋,擦皮鞋啰……”\n警予敲打着鞋刷子,扯开嗓子吆喝着。路人们纷纷侧目——这么漂亮而穿着高档的小姐居然吆喝这个,着实令人吃惊。连蔡和森都觉得有点不自然了,他推了推警予让她小声点,提醒她说别人都在看她呢。警予却敲得更起劲了,声称做生意嘛,就是要招人看呀。继续用更大的声音吆喝着:“来来来,哪位擦皮鞋?”\n一个男人挤了上来问:“哎,你们俩谁擦皮鞋啊?”\n警予:“他是师傅,我是徒弟,你想要师傅擦还是要徒弟擦?”\n“徒弟,就徒弟。”\n“那请坐吧!”\n男人兴高采烈地坐了下来,警予抄起工具就要动手,又抬头看看客人,说:“我刚学的,擦得不好别怪我啊!”\n男人忙不迭地答道:“不怪不怪。”\n看到警予的功夫还不错,人群一阵议论纷纷,好几个男人也挤了上来:“我也擦……我也擦……”\n一拨客人过后,两人哗啦哗啦地数着铜钱,才发现自己真是“发财”了。趁着没有客人,两个人坐在街边,说起上次报考一师的事情,警予问:“你们第一师范跟你一批考进去的,有个叫蔡和森的,你认识吗?”\n蔡和森不禁一愣:“你打听他干嘛?”\n“我看过他的入学作文,我们老师当范文发给我们的。怎么写得那么好,真是气死我了。”\n“他写得好你也生气啊?”蔡和森简直哭笑不得,“有那么严重吗?我看他很一般呀!”\n“写得也太好了一点嘛!我一直觉得自己作文好,跟他一比,人生都一片黑暗了。”警予容不得人家说蔡和森一般,“去,不识货!就他的文章,全长沙的学生,没人比得上,包括我。我想不通的就是这一点,我怎么就比不上他呢?未必他三头六臂啊?”\n蔡和森暗自笑了,随口说:“三头六臂?肯定没有,他嘛,也跟我差不多,一副穷样。”\n“我现在呀,把他那篇文章贴在我床头,每天起来第一件事,就是冲着那篇文章大喊一声:”姓蔡的,你等着瞧,我向警予总有一天要强过你!到时候,我就拿我的文章去找你,让你挖个地缝自己钻进去!‘“想想,她又叹了口气,说:”唉,也就是说说而已,真想赶上他,不知猴年马月喽!“\n“我看没问题,凭你这股倔劲,那姓蔡的肯定兔子尾巴长不了。”\n“对,总会有那一天。”警予看看天,突然想起斯咏,转头对蔡和森说,“哎,我得走了,再见……喂,我说的话,你可别告诉那个蔡和森啊!,”\n“你放心,我是肯定不会告诉第二个人的。”蔡和森望着警予风风火火离去的背影,笑着自言自语了一句,“向警予。”\n四 # 茶叶店里,赵一贞正捧着一本英文小说在读。阳光斜照,映着她柔美而清纯的脸。她眉头轻蹙,读得很入神,也显然很吃力。柜台前,传来了刘俊卿轻微的咳嗽声,赵一贞一抬头,正碰上刘俊卿的目光,一阵紧张,她有些慌乱地低下了头。\n刘俊卿同样也很紧张,他用有些干涩的声音说:“我,买点茶叶。”\n赵一贞低着头问:“要什么茶?”\n“嗯,”刘俊卿的心思当然并不在茶叶上,他随手一指,说:“就这个吧。”\n“您要多少?”\n“半斤吧。”\n赵一贞放下书,取茶叶,过秤。刘俊卿的目光追随着她,见一贞回头,他又掩饰着侧开头,装着在看那本放在柜台上的书,那是一本英文版的《少年维特之烦恼》。\n“你在看这本书啊?”\n赵一贞笑了笑,小声说:“看不太懂。”\n“什么地方看不懂?”\n赵一贞:“我英文差,一开始就看不太懂。”\n刘俊卿打开扉页,指着《卷首诗》问:“是这儿吗?”\n赵一贞点了一下头。\n“这是卷首诗,标题是《绿蒂与维特》。这两句是说:哪个少年不多情,哪个少女不怀春。”\n“哎呀!”一贞的手一抖,茶叶哗啦撒了一柜台,吓了她一跳。刘俊卿赶紧帮忙挡着,却正好抓住了一贞的手。一贞的脸绯红了,她赶紧把手抽了回来,小声说,“对不起啊,我……我给你另外换半斤。”\n“不用了,收拾起来是一样的。这样吧,你来扫,我接着。”\n他双手合拢,靠住柜台。一贞涨红了脸,扫拢茶叶,茶叶落在了刘俊卿手上,这一刻,两个人凑得那么近,几乎都能感觉到对方的呼吸。一贞的眼睛,头一次没有躲避刘俊卿火热的目光。\n比较起蔡家,刘家的日子却要好多了。摆在刘俊卿面前的除了一碗盛好的饭,还有几样菜,分量虽少,却既有肉,也有鱼。按刘三爹的意思,儿子吃了一个礼拜学校食堂,回了家还不吃点好的?\n看儿子有滋有味地吃着自己亲手做的菜,刘三爹打开儿子带回来的布包袱,将里面乱皱皱塞成一团的脏衣服、脏袜子倒进了木盆,吃力地端起木盆,走出布帘,伴着剧烈的咳嗽声,给儿子洗衣服。\n吃过饭,在父亲的咳嗽声中,刘俊卿不耐烦地挑亮了油灯,开始写字。他的面前是摊开的一本英文版《少年维特之烦恼》,和一张精致的描红信笺,信笺上是那首即将写完的《绿蒂与维特》的译文,字迹工整清秀,一丝不苟。听听门外总算安静了,他又提笔开始往下写,然而,刚写了一个字,更猛烈的咳嗽声又响了起来。刘俊卿烦得把笔一摔,拉开了门。月光下,刘三爹拼命抑制着咳嗽,提着一条洗好的裤子站起身来,腰却一阵发僵,他艰难地扶着腰站起,往绳子上晾衣服。本来一脸脾气的刘俊卿不由得站住了,他正想退回房里,却又站住了,轻声说:“爸,你不舒服,就早点休息吧,别太累着了。”\n一刹那,刘三爹张大了嘴,儿子少有的关怀令他整个人都呆了,他激动得嘴角直抖。两行老泪从刘三爹的脸上滑了下来,巨大的激动和喜悦几乎令他难以自持,提着衣服的手都在抖个不停。他用力擦去眼泪,一抖衣服,晾上了绳子。\n第二天一早,赵家茶叶店里,一贞送走了一名买茶的顾客,拿起抹布擦着柜台,突然看到一双熟悉的皮鞋站到了柜台前。一瞬间,一贞一阵紧张,涨红着脸,不敢抬头。刘俊卿把一张折得方方正正的描红信笺从柜台上推了过去。一贞犹豫着,伸出手正要去接,赵老板端着一盘茶叶,一掀门帘,走了出来。赵一贞吓得手一缩,赶紧转身叫了声“爸爸”。\n柜台上,那张信笺刷的一下被刘俊卿收了回去。\n赵老板吩咐女儿把指定的货分一下,回头看到刘俊卿,问:“这位先生,买茶吗?”\n刘俊卿一时间不知道说什么,逃也似地跑开了。\n“这小伙子,慌什么张啊?”赵老板看着刘俊卿,他突然回头看了一眼女儿,赵一贞干着活,头也没抬。\n趁着父亲背过身清理着钱箱里的钱,一贞抬起头,看到远处的拐角,刘俊卿正躲躲闪闪地探着头,向她打着手势。一贞一时不明白他的意思,顺着他手指的方向找了一阵,才发现算盘下正压着那张信笺。\n深夜,如水的月光透过窗楹,洒在那张描红信笺上。\n赵一贞痴痴地端详着信笺,信笺上,是那首卷首诗,下面写着“省立第一师范 刘俊卿赠”。\n五 # 毛泽东在当天下午放学后,如约到了杨昌济家。\n杨宅门前,“板仓杨”的门牌静静地挂在大门一侧,杨宅院内,兰花青翠,藤蔓攀墙,点点阳光透过树阴,洒在落叶片片的地上。探头打量着这宁静雅致的小院,毛泽东长长呼吸了一口清新的口气。\n“进来吧。”杨昌济推开了书房的门。\n带着几分崇敬,毛泽东跟在他身后,向里走去。书桌上,铺着一张雪白的纸,写着苍劲有力的四个大字:修学储能。\n“修学储能,这就是今天的第一课,也是我这个老师对你这个弟子提出的学习目标。”杨昌济放下笔,面对毛泽东坐了下来,说,“润之,一个年轻人走进学校的目的是什么?是学习知识,更是储备能力。孔子曰:”质胜文则野,文胜质则史。‘就是说,一个人如果光是能力素质强,而学问修养不够,则必无法约束自己,本身的能力反而成了一种野性破坏之力;反过来,光是注重书本学问,却缺乏实际能力的培养,那知识也就成了死知识,学问也就成了伪学问,其人必死板呆滞,毫无价值。所以,我今天送给你这四个字,就是要让你牢牢记住,修学与储能,必须平衡发展,这是你求学之路上不可或缺的两个方面。“\n毛泽东问:“那,以今日之我而言,应当以修什么学问,储哪种能力为先呢?”\n“什么学问?哪种能力?润之,你这种想法首先就是错的。今时今日之毛润之是什么人?一个师范学校一年级学生而已。你喜欢哲学伦理,也关心时事社会,这是兴趣,也是天赋,但我同时也担心你走入另一个误区,那就是于学问能力的涉猎之面太窄!润之,你的求学之路才刚刚起步,你才掌握了多少知识?才拥有多少能力?过早地框死了自己修学储能的范围,而不广泛学习,多方涉猎,于你的今后是有百弊而无一利的。所以,你现在的修学储能后面,还应该加上四个字:先博后渊。”\n毛泽东思索着,认真地点了点头:“我明白了,博采众长才能相互印证,固步自封则必粗陋浅薄。”\n杨昌济笑了,他为毛泽东有这样的悟性而感到非常欣慰。在谈到儒家三纲之说时,杨昌济喝了口茶,说:“儒家三纲之说,确属陈腐之论,船山先生的‘忠孝非以奉君亲,而但自践其身心之则’之说,于此即为明论。”\n记着笔记的毛泽东停下笔,插话道:“我觉得这种说法,其实是在提倡个人独立精神。”\n“对,个人独立。你看过谭嗣同的《仁学》吗?《仁学》对此就作了进一步阐发,它认为个人独立奋斗,是一个人成功的关键,即父子兄弟,亦无可依赖。而我以为,个人奋斗的宗旨,就在于两条原则。”他接过毛泽东手中的笔,在两张纸上各写了一个字:坚、忍。“坚者如磐石,虽岁月交替而不变,忍者如柔练,虽困苦艰辛而不摧。坚忍者,刚柔并济,百折不回,持之以恒也……”\n“口当……口当……”墙上挂钟恰在这时响了,毛泽东看看窗外的夜色,赶紧站起身:“哎哟!都这么晚了?老师,真是对不起,打搅您到这个时候,要不,我先回去了。”\n杨昌济伸展了一下胳膊,看来也是有些疲倦了,却意犹未尽地对毛泽东说:“清谈不觉迟,恍然过三更啊。算了,这么晚了,学校也早锁门了,我看,你就住这儿吧,反正我的家眷都回了乡下,房子空着也是空着。明天早上再走吧。”\n第二天早上,晨曦一缕,悄然抹亮了天际。 “板仓杨”的门牌映着初起的晨光,散发着古拙质朴。清晨的宁静中,一阵水流声传进了杨宅客房。毛泽东迷迷糊糊地睁开了眼,披着外衣,揉着惺忪的睡眼推开了门。他突然愣住了:就在眼前,小院的井边,杨昌济裸着身体,只穿着短裤和一双日本式的木屐,正在用冷水进行晨浴。光洁强健的脊背上,清水纵横,水流顺着身体,直淌到地上。一只木勺从木桶里舀起满满一勺水,冰凉的井水兜头浇下……他的神情肃穆,动作庄严,一吐一纳,仿佛正在进行某项庄严的仪式。似乎是感觉到了身后有人,杨昌济回过头来,看到毛泽东疑惑的眼神,他拿起井栏边的浴巾,擦着身上的水,说:“我在晨浴。几十年的老习惯了,清晨即起,以井水浴我肉体,然后晨诵半小时,以圣贤之言浴我精神,是以精神肉体,清清爽爽,方得全新之我,迎接新的一天嘛!”\n毛泽东伸手探了探水桶中残余的水,深秋之晨冰凉的井水,刺得他手一缩,问道: “老师,您不冷吗?”\n“一个人的修学之路上,比冷水更难熬、更严酷者不知有多少,若是连一点寒冷都受不了,还谈什么坚忍不拔?再说,读书人静坐过多,缺乏运动,这也是强健体魄的最好方式嘛!”杨昌济将浴巾往肩上一搭,在院中树下一块石头上盘腿坐下,拿起了手边的一本书,“哦,对了,我没有吃早饭的习惯,就不管你的饭了,你自便。我要晨诵了。”\n仿佛是在净化自己的心灵,杨昌济闭目长长呼吸了一口气,这才朗声:“杨昌济,光阴易逝,汝当惜之。先贤至理,汝当常忆……”随后,他打开书,端坐凝神,大声诵读起来,“子曰:学而时习之,不亦说乎?有朋自远方来,不亦乐乎?人不知而不愠,不亦君子乎……”\n渐渐明朗的晨光中,杨昌济读得如此旁若无人,那琅琅书声,仿佛天籁般充满了这雅致的小院。望着井边的木桶,望着晨光中静若雕塑的老师,听着那清澈得犹如回旋在天地之间的读书声,毛泽东几乎都痴了。\n随即他回到客房,一张“自订作息表”上,从清晨直到半夜,一个个时段,一项项安排,密密麻麻,开列详细。从此,这张作息表贴在毛泽东寝室的床头,一直伴随他读完一师。\n第八章 俭朴为修身之本 # 人,之所以为人,正是因为人有理想,有信念,懂得崇高与纯洁的意义,假如眼中只有利益与私欲,那人与只会满足于物欲的动物又有何分别?林文忠公有言:壁立千仞,无欲则刚。我若相信崇高,崇高自与我同在。而区区人言冷暖,物欲得失,与之相比,又渺小得何值一提呢?\n一 # 微弱的晨曦,刚刚将夜的天际稍稍染淡。一师的欧式教学楼还笼罩在一片黎明之前的深邃寂静之中。黑暗宁静的寝室里,交织着同学们不同的鼾声。毛泽东一个人轻手轻脚地下了床,他来到一师水井边,将满满一桶井水提出了井沿,脱掉衣服,全身只剩了一条短裤。深秋的晨风袭来,吹得高大的樟树哗哗作响,赤裸的毛泽东忍不住打了个寒战。他探了探冰凉冰凉的水温,用力深深呼吸了几口,仿佛是为自己壮胆,他狠狠一拍胸膛,撩起桶里的水,浇在胸膛上。顿时,他冷得全身一缩,倒吸了一口凉气。但咬咬牙,他一下接一下撩起水,浇在身上。然后用毛巾起劲地在透湿的身体上狠狠擦着……由慢而快,由冷而热,他体会着,他渴求着,他的呼吸交织着水花,他的脸上渐渐展开了笑容……猛地,他举起木桶,将半桶水兜头浇下。\n“爽快啊!”微起的晨曦中,他压抑不住的兴奋的声音回荡在树梢林间、秋风深处。\n接下来,他学着老师,大声诵道:“……呜呼!我中国其果老大矣乎?立乎今日,以指畴昔,唐虞三代,若何之郅治;秦皇汉武,若何之雄杰……”晨曦之中,宁静的一师校园里,毛泽东捧着一本《饮冰室文集》,正聚精会神地读着梁启超的《少年中国说》:“……梁启超曰,造成今日之老大中国者,则中国老朽之冤业也;制出将来之少年中国者,则中国少年之责任也……”\n“当啷、当啷……”校役摇晃着铜铃,起床铃声清脆地响满了寝室走廊。一间间寝室里,一顶顶蚊帐中,一个个学生打着哈欠,爬起床来。远远地,毛泽东的晨诵声正清晰地传来:“……故今日之责任,不在他人,而全在我少年。少年智则国智,少年富则国富,少年强则国强……”子升、萧三、张昆弟、罗学瓒……一个个同学奇怪地打开了房门,他们看到毛泽东端坐草坪的身影映着初升的朝阳,他的晨诵声如此清朗,盖过了一切铃声与起床的喧闹。\n“……少年独立则国独立,少年自由则国自由,少年进步则国进步,少年胜于欧洲则国胜于欧洲,少年雄于地球则国雄于地球……”晨诵声中,两个年轻的身影停在了毛泽东的身后,两个声音与他的声音汇成了一体。毛泽东一回头,原来是蔡和森和子升来到了他的身边,正加入他的诵读。三个人目光相对,会心一笑。毛泽东提高了声音,“……红日初升,其道大光;河出伏流,一泻汪洋;潜龙腾渊,鳞爪飞扬;乳虎啸谷,百兽震惶……”\n一双双脚步悄悄汇集,张昆弟、罗学瓒、萧三、李维汉、周世钊……一个个同学犹如被巨大的磁铁所吸引,不断聚集到毛泽东的身后,晨诵之声,越汇越响。那充满朝气、青春昂扬的晨诵声汇成了巨大的声浪,回荡在整个一师的上空,仿佛正呼唤一个崭新的开始,仿佛正向整个世界宣布着同学少年们青春的誓言。\n二 # 上午的课在综合大教室上。黑板上板书着“教师之职责与地位”的标题,台下,学生们不像往常面向讲台,而是面对面坐成了两个阵营,中间空出一片,相对摆了两把空椅子,整个教室布置得好像一个辩论场。\n徐特立草鞋布衫,一如往常,“这次的课堂心得,有一位同学表现不俗,不但论述详尽,有理有据,而且由此而阐发,对教师的职责与地位怎样确立,提出了自己独到的看法,那就是本科八班的刘俊卿同学。有趣的是,另有一位同学,这次的心得同样出类拔萃,而且观点正好与刘同学的相反,那就是本科六班的蔡和森同学。那么,两位同学的观点,究竟谁更有道理,作为师范生,我们又应该怎样认识教师的职责与地位问题呢?今天,我们的课换换花样,就请两位同学上台来,各自阐述自己的观点,交由大家来评判。”一指那两张空椅子,“刘同学,蔡同学,请上坐。”\n两位辩手上前坐了下来。望了对面那张平静的脸一眼,仿佛是为自己暗暗鼓劲,刘俊卿深吸了一口气,站了起来开始阐述自己的观点:“……教师要为社会奉献那么多,要还像现在这样,生活清苦,地位低下,那怎么吸引优秀的人才从事教育?”\n“我不同意。”蔡和森接过了话,“教师者,传道授业,教书育人者也。要是教师都一门心思追求更好的待遇,更高的地位去了,那还有什么心思培养学生?用这样的心态去教书,又怎么教得出愿意为社会、为大众奉献自己的学生呢?”\n“说得好!”学生中,毛泽东带头喊了出来,一时间,教室里响起嗡嗡一片赞同的议论,学生们大都站在了蔡和森一边。\n刘俊卿急了,争辩道:“大道理谁不会说?可真要让你低人一等,吃一辈子粉笔灰,你蔡和森也未必愿意吧?”\n此言一出,教室顿时静了,学生们一个个面面相觑,他们真的没有想到刘俊卿竟会说出这种话来。\n“对不起,我从来不觉得吃粉笔灰有什么地方低人一等,相反,我倒坚信,教书育人,是这个世界上最崇高的职业之一。”\n听到蔡和森以这样的方式这样回答自己,刘俊卿的脸涨红了,他心虚地说:“我……我也没有说就不崇高嘛,只不过、只不过别人都把老师看成穷教书匠,光你自己以为崇高,有什么用嘛……”\n他的声音不由自主地低了下去,在满教室鄙夷的目光注视下,他已然明白在这里讲出心里话是多么的不合时宜。\n平静地,蔡和森站了起来,一字一句地说:“人,之所以为人,正是因为人有理想,有信念,懂得崇高与纯洁的意义,假如眼中只有利益与私欲,那人与只会满足于物欲的动物又有何分别?林文忠公有言:壁立千仞,无欲则刚。我若相信崇高,崇高自与我同在。而区区人言冷暖,物欲得失,与之相比,又渺小得何值一提呢?”\n教室里,一片宁静,蔡和森的话,仿佛让所有的人都陷入了思考。宁静中,一个掌声突然响起,那是徐特立。掌声顿时响成了一片!热烈的掌声中,刘俊卿埋着头,满脸只剩了尴尬。带着屈辱与恼怒,他的目光扫过了蔡和森仍然平静的脸……\n三 # 最后一堂课,是评讲作文。\n“第二名,刘俊卿,90分。”袁吉六在发作文本,他把本子递给刘俊卿,微笑着,“有进步啊。”\n他又拿起一个本子,声音提高了八度:“第一名,蔡和森,98分。”冲着蔡和森,眼睛笑得都眯成了一条线,“如此文章,当上公示栏公示全校,展览完了再发还给你。”\n“毛泽东,”砰的一声,作文本甩在毛泽东的面前,袁吉六看也不看他一眼,从嗓子里挤出一个变了调的声音,“65分!”\n本子上,“65”分的分数旁,是大大的三字批语“老毛病!”看着自己的作文,再看看袁吉六,毛泽东都有些懵了……\n下课铃声中,众多学生纷纷拿着碗筷,涌出了教室。\n“俊卿兄。”走廊上,易礼容追上了正拿着碗筷走向食堂的刘俊卿, “是这样,你的文章最近进步那么快,我呢,就老是原地踏步,所以特别佩服你。不知道能不能耽误你一点时间,跟你讨教讨教,怎么才能提高作文水平。”\n“这个嘛……”刘俊卿露着笑容,口气却是不冷不热,“我现在功课也忙,要不改日吧。”他撇下易礼容,径直走去。身后,易礼容愣住了,张昆弟一拉他:“你也是,问他干什么?人家蔡和森文章比他强得多,又肯帮人,你不会去问蔡和森啊?”\n“我知道他不如蔡和森,可蔡和森是一直就强,他是慢慢进步的,所以我想问问他……”“那也得人家肯帮忙,你什么时候看到他帮过别人?”听着身后传来的话,刘俊卿的手捏紧了筷子,直捏得指节都发了白。\n人声鼎沸的食堂一角,蔡畅咬着窝头,面前是稀饭、咸菜、咸鸭蛋,蔡和森正微笑着看妹妹吃饭。学校今天发津贴,蔡和森特地带信让蔡畅来取回去,给家里买点米。望着妹妹吃得那样香甜,蔡和森的目光中充满了怜爱,告诉她自己已经吃过了,要她多吃点。\n人群中,毛泽东打好了饭,夹着书本,匆匆走出食堂,来到八班教室里,把稀饭、窝头摆在了桌上,咬着个窝头,急匆匆地打开课桌抽屉,把一本梁启超的《饮冰室文集》与他的作文本并排放在桌上。“我写不好,梁启超总写得好吧?”毛泽东把窝头往碗里一搁,一把翻开了梁启超的文集,说,“摆本梁启超在面前还学不像,我还不信了!”\n在距离蔡家兄妹不远处的食堂另一侧;王太太又吩咐秀秀来给王子鹏送饭菜了。子鹏推开食盒,表示不再吃外面送来的饭菜了,自己要和同学们吃一样的饭菜。秀秀正劝着他,刘俊卿端着饭走过,发现妹妹,赶紧扭开头,准备躲开,身后传来秀秀委屈的恳求声:“您不吃,太太那儿,我怎么交代得过去?太太说了,您要是不吃,就、就不准我回去……”\n“哟,子鹏兄,又有好吃的了?送都送了,这又何必呢?我做主,吃!”刘俊卿打开食盒,端出里面的饭菜,并不抬头,对秀秀:“好了,没事了,你回去吧。”\n看了为自己解围的哥哥一眼,秀秀转身离去。将丰盛的饭菜摆开,刘俊卿抄起了筷子,子鹏却犹豫着,看了看四周,有不少同学的目光都在望着这边。刘俊卿也感觉到了,扫了周围一眼,却见众目睽睽中,蔡和森一双平静的目光正在注视自己。咬了咬嘴唇,他端起饭碗,示威似的把几个窝头往桌上一扣!窝头在桌子上摇晃了几下,以不同的姿势乱七八糟地躺在了残汤剩水中间。\n正在吃饭的蔡畅不禁皱起了眉头:“哥,这个人怎么这样啊?”\n蔡和森想了想,站起身来,走过去,站在王子鹏和刘俊卿面前,尽量放着和缓的口气说:“子鹏兄,俊卿兄,你们两个如果不吃学校的饭,能不能不要这么浪费?这也太可惜了。”\n“哎哟,对不起啊!”子鹏赶紧起身,不好意思地道歉,“我们……不是故意的。”\n刘俊卿却沉下脸,一把拉开子鹏,说:“你跟他说什么对不起,又不是他的饭!”\n“不管是谁的,总归是粮食嘛……”蔡和森还想说服他。\n“粮食也是我和子鹏兄的粮食!怎么,看我们吃得好,看不过眼啊?”\n“俊卿,你别说了。”子鹏拉住刘俊卿,对蔡和森说,“蔡兄,是我们不对,我以后不倒了,再也不倒了。”\n子鹏说着,伸手来收拾桌上的窝头,刘俊卿却拦住了他,冲着蔡和森吼道:“我今天就倒了,怎么样吧?”\n蔡和森看看他,说:“你这个人怎么不讲道理呢?”\n“跟你我还偏不讲!当自己有什么了不起,我怕你呀?哼!”\n蔡和森盯着他,摇了摇头,一言不发地走回了座位。\n所有人的目光都在盯着刘俊卿和王子鹏,刘俊卿赌气似地坐下,提起筷子吃了起来。子鹏在一旁整个慌了手脚,满脸都是尴尬。刘俊卿却一副得胜的样子,用筷子一敲碗,大声说:“子鹏兄,吃呀!”\n回到自己的桌前,蔡和森坐下了,微笑对妹妹说:“吃饭吧,别理他。”等妹妹吃完饭后,又将她送出食堂门口。目送妹妹离去,蔡和森回到食堂饭桌前,收拾起自己的碗。他刚一转身,却看见方才子鹏和刘俊卿坐过的桌子下,躺着一串钥匙。蔡和森走过来,捡起钥匙,目光却不自觉地盯住了饭桌上那几个窝头。\n饭给了妹妹,他自己到现在还饿着呢。犹豫了一下,蔡和森咽了口口水,看了看四周,食堂里其他同学坐得离他还算远,没人注意他,于是,他把桌子上的窝头装进了自己碗里,大口大口地吃着。\n这时子鹏和刘俊卿来找钥匙,刘俊卿斜睨着蔡和森,脸上全是压不住的幸灾乐祸:“我说蔡兄哪来的力气教训人,原来吃饱了饭,还没忘了加餐,难怪难怪哟。”\n蔡和森手一抖,手中那半块窝头掉在了桌上。食堂里还在吃饭的学生涌了过来。子鹏拉了刘俊卿一把,希望他不要再说。\n“哎!都来瞧都来看,有好戏看了啊!从来只有叫花子捡人的剩饭,今天让大家开开眼,蔡和森蔡大才子也捡我刘俊卿的剩饭吃了。”刘俊卿把子鹏的手一甩,冲着四周里三层外三层的学生,兴致勃勃地喊道,“你说你还装什么样子嘛?还不让我和子鹏兄倒饭,不倒你上哪儿捡啊?”\n子鹏恳求着:“俊卿,我求求你,别说了!”\n“我偏要说!平日里他多威风啊?教训张三教训李四,今天也轮到他了!”刘俊卿捡起那半块窝头,伸向蔡和森,“蔡大才子,来呀来呀,别客气,不吃多浪费呀?”\n众目睽睽下,蔡和森脸色刷白,这番羞辱已经让他真是无地自容了,但更让他难堪的是,他听到有人在身后叫了一声。\n“哥!”\n“小妹?”蔡和森一回头,不禁全身一震,他看到的是早已泪流满面的蔡畅。因为想起了妈妈说要给哥哥留点钱买纸和墨,蔡畅跑到半路又回来了。蔡和森呆了一呆,他一把拉住妹妹,就往外走。\n“哎!别走哇!不还没吃完吗?蔡大才子,接着吃啊,要不要我来喂你?来呀来呀,别客气,来呀。”刘俊卿一步拦在蔡和森前头,举着那半块窝头,伸到蔡和森的鼻子底下,仿佛举着一面胜利的旗帜。\n突然,一只手从旁边伸了过来,一把抓住了那半块窝头。刘俊卿回头一看,愣住了。只见徐特立面无表情地将那半块窝头拿了过来,不紧不慢地将窝头塞进了自己嘴里!所有的同学都呆住了,子鹏一时手足无措,看看刘俊卿,刘俊卿更是尴尬万分。徐特立一言不发,在桌前坐了下来,把自己的空碗往桌上一放,又拿起一块窝头,旁若无人地吃了起来。一时间,全场静得连徐特立的咀嚼声都清晰可闻。\n“嗯,很香嘛!”徐特立一面大口吃着,“蔡和森,你要是不吃,我可就全吃了。”眼泪骤然滑出了蔡和森的眼眶,他拉开凳子坐下,也抓起一块窝头。两个人好像比赛一样,大口地吃着。两只手同时伸向了碗里最后一个窝头,还是徐特立拿了起来,他一掰两半:“来,二一添作五。”接过半块窝头,迎着徐特立温暖的目光,蔡和森笑了。\n就在这时,人群一阵躁动,孔昭绶与方维夏排开人群,出现在大家面前。孔昭绶的脸上没有任何表情,他看看四周的学生,拿过蔡和森手里的半块窝头,咬了一口,仿佛是回味起了某种久违的甜美,孔昭绶笑了,说:“小时候,我家里很穷,吃不起什么好东西。记得有一年过年,我母亲借了半袋玉米,磨成面,蒸了一锅窝头。窝头刚出锅,我饿极了,拿了一块就吃,结果烫了嘴,窝头掉在地上,母亲捡起来,把弄脏的那一半掰下来,自己吃了,干净的那一半,给了我吃。香啊!今天吃这半块窝头,又让我想起了小时候那半块,真的很香!”\n泪水蓦然湿润了他的眼眶,他擦了一把,昂起头继续说:“同学们,各位第一师范的同学们!一粥一饭,来之不易啊!你们的父亲,你们的母亲,在家里是何等的节俭,何等的惜粮惜物,你们从小都是看在眼里的!还记不记得,你们那种田的父亲,冒着三伏天的大太阳,在田里一整天、一整天地割稻?还记不记得,你们的母亲,把自己碗里的饭扒到你的碗里,告诉你她光半碗饭已经吃得好饱好饱?”\n他终于又忍不住,声音哽咽起来。不少学生都已是热泪盈眶!孔昭绶略平静了情绪,说:“刘俊卿同学,有一位老师,我想应该重新跟你介绍一次,也跟我们全体同学介绍一次,那就是被你称为徐大叫花的徐特立老师。我听说,不光你一个人,还有不少同学背后也这样叫他徐大叫花。是啊,徐大叫花。你们这位徐老师还真是像个叫花,身上补丁衣服,脚下是草鞋,坐不起轿子,吃学生食堂,连一把油纸伞都买不起,下雨天穿件蓑衣!很寒碜啊,长沙城的教书先生里头都找不出第二个这么寒碜的了。可我也要告诉你们,就是这位徐大叫花,光一项省议会副议长的职务,就是两百大洋的月薪!更不用说他还同时担任长沙师范学校的校长,兼着三所学校的课,他的收入,在我们长沙城所有的教书先生中无人能比,比我这个校长高出不止三倍!那徐老师的钱到哪里去了呢?如果大家有空,去一趟徐老师的家乡,长沙县五美乡,就会看到有一所小学,一所免费招收贫困农家子弟的五美小学,那里的学生读书不要钱,一分钱都不要!因为那是徐老师创办的学校,所有的钱,都是他一个人掏!而我们的徐老师,徐大叫花,连自己的家人都全部留在乡下务农,因为长沙城里生活费太高,因为多省一块钱,就能让一个穷人家的孩子多读一个月书!”\n已经不光是学生,所有的教师都深深地震撼了。\n“什么是贫困,什么是富有?穿草鞋、打补丁、吃粗茶淡饭就是贫困,穿皮鞋、坐轿子、吃山珍海味就是富有吗?不,孩子们,贫困与富有,不在于这些表面的东西。今天的你们,都还年轻,将来走入社会,你们都要经历金钱与名利的诱惑,都要面临理想与现实的选择。等到了那个时候,你将会真切地感受到,当你不计个人得失,尽己所能,使尽可能多的人得到幸福时,你的精神将是那样的富有和快乐。反过来,如果一天到晚只记得自己那一点私利,只盘算自己那一点得失,就算你坐拥万贯家财,就算你白天锦衣玉食,荣华富贵,等到了晚上,等到你一个人安静下来,你就会发现,你并不快乐,你所拥有的,只是无尽的空虚,因为在精神上,你只是个一文不名的穷光蛋!这半块窝头,我留下了,这半块窝头,我也希望从此留在每一位同学的心里!使我们牢牢记住,俭朴为修身之本!”\n猎猎秋风中,他的声音振聋发聩,回荡在整个学校的上空!\n四 # 任何事情都不可能只有一种绝对的评价,窝头事件也一样。当刘俊卿在会后委屈地垂头坐在督学办公室纪墨鸿的对面时,虽然纪墨鸿并没有明显地袒护他,但他还是看到了一线希望。\n纪墨鸿说:“挨了批评就挨了批评,垂头丧气的干什么?校长和先生们批评你,也是为了你好。你做学生的,难道还要到我这儿讨回个什么公道不成?当然了,有些观念,我也并不赞同,这读书人总还有个读书人的颜面,都弄得像个乞丐一样……算了,这些话,不是该跟你说的。你只要记住,学生,就得服从学校的规矩,不管听不听得进去,老师的话,总要服从,才是好学生。你先去吧。以后有什么事,还是可以来找我的。”\n刘俊卿毕恭毕敬地离开督学办公室之后,纪墨鸿也随即出门,到了杨昌济的办公室,在杨昌济对面坐下,端着茶杯字斟句酌地说:“有些事情,我这个督学本来不便开口,可不开口吧,这心里又堵得慌。杨先生,您是长沙学界之翘楚,与孔校长又有同窗之谊,我想,您的话他想必听得进去一些。”\n“纪先生有话,就尽管说吧。”除了上次来送聘书,杨昌济一向和纪墨鸿没什么交往,所以,他实在猜不透这位督学大人今天来找自己,到底是为了什么事情。\n“那我就直说了。你我都是致力于教育之人,学生应该教成什么样的人,不应该教成什么样的人,这是学校教育的大本大源,是万不可出一点纰漏的。这一次,孔校长在学校搞这场所谓俭朴教育,您就不觉得过分了吗?教学生俭朴做人,这墨鸿也是不反对的,可凡事过犹不及,俭朴要俭到捡人的剩饭吃吗?那剩饭是什么人吃的?那是叫花子!难道我们培养学生,就是要培养一群叫花子出来吗?”\n纪墨鸿说的是他的心里话,但道不同不相为谋。他的话显然在杨昌济这里得不到共鸣,相反,还让杨昌济非常反感。杨昌济反问道:“纪先生的意思,学校是培养上等人的地方,对吗?”\n“本来就是嘛,难道还培养下等人?”纪墨鸿端起茶碗要喝,但越想越生气,又把茶杯放下了,“这俗话说得好,水往低处流,人往高处走。学生家长辛辛苦苦,把孩子送到学校里来,为的什么?不就是为了他们有个好出息,他日有出人头地的那一天吗?咱们做先生的,也当时时想着身上担着的那份责任,总须培养学生谋个好前程,让那农家的孩子不必再扛锄头,做工人家的孩子不必再卖苦力,走出去一个个有头有脸,斯斯文文,做个人上人,才对得起学子们一番求学之意,家长们这番含辛茹苦啊。这下倒好,吃剩饭!学生吃了不纠正,老师还要带头吃,一个老师糊涂不算,校长还要吃!这、这、这是要干什么嘛?这样培养出来的学生,岂不是连高低贵贱都分不清?斯文扫地,真是斯文扫地!”\n说到情绪激动处,砰的一声,纪墨鸿把茶碗又一放。\n杨昌济实在听不下去了,但他还是尽力克制着,问:“扛锄头、卖苦力的,都是下等人,是贱民,只有读书人才是上等人,‘劳心者治人,劳力者治于人’,纪先生就是这个意思,对吗?”\n“话当然不能这么讲,一讲就是封建等级,糟粕之论。可这世道它就是这么个世道,道理也就是这么个道理嘛。”纪墨鸿的口气明显地软了些。\n“是吗?”杨昌济站了起来,他的口气却明显地硬了:“纪先生,如果事先不知道,我会以为今天当我的面讲这番话的,是哪位前清的学政大人。可你不是封建王朝的学政,你是民国的公务员!中华民国临时约法中明文规定,国民一律平等,哪来的高低贵贱之分?不错,今天的中国,还没有做到真正的人人平等,还有诸多不合理的现象,可我们这些从事教育的人要做的,不正是要抹平这种不合理的等级,让学生去除旧观念,做一个民国的新人,为人人平等之大同世界而努力才对吗?先生倒好,满口高低贵贱,恨不得把学生都教成蝇营狗苟,但求一己之富贵前程,不思国家、民族、社会之未来的自私自利之徒。我倒要请问纪先生,你,这是要干什么?”\n“大道理谁不会说,可大道理当不得饭吃!”纪墨鸿满脸涨得通红,腾地站了起来,拉开门便往外冲,一只脚已跨出了门,又回过头,狠狠地说:“我倒要看看,你板仓先生用这番大道理,教得出什么样的好学生!”\n纪墨鸿砰的一声关上了门走出办公室,迎面却正碰上毛泽东、蔡和森、萧子升三人站在他面前。三名学生显然看到纪墨鸿摔门而出的情景,都有些不自然。还是子升先恢复了常态,喊了一声:“纪督学。”\n纪墨鸿迅速平静了表情,和蔼地:“有事啊?”\n子升说:“我们来找杨老师。”\n纪墨鸿像没发生过任何事情一样,微笑着说:“杨先生在里面,进去吧。”走了几步,纪墨鸿又在楼梯口停了下来,回头看着三名学生进了杨昌济的办公室,轻轻摇了摇头。\n三个学生今天来找杨老师,是想请老师担任他们的指导老师。因为蔡和森提出想成立一个哲学读书会,基本成员除了他们三个,还有周世钊、张昆弟、罗学瓒、萧植蕃、李维汉、陈章甫、易礼容、熊光楚他们,一共十多个人,都是对哲学、社会学比较感兴趣的同学。他们商量着,打算定期开展读书活动,互相交换学习笔记,比赛学习进度,以促进提高自己。当然,根据毛泽东的建议,还要一起锻炼身体。这样的好事情,杨昌济怎么会不答应呢?只不过,无论是作为发起者的蔡和森、萧子升和毛泽东,还是哲学读书会的导师杨昌济,恐怕都没有想到,就是这个松散的、以强烈的求知欲望为纽带组织起来的学生兴趣小团体,后来竟会一步步壮大起来,一步步走向政治上的成熟。\n第九章 袁门立雨 # 寒风和着秋雨,刹那间笼罩了整个院落。房檐下,雨水如根根丝带,在风的吹动下,摇摆着。不平的地面上,很快形成了许多的小水潭。全身透湿的毛泽东平静而倔强,他垂手而立,一动不动,仿佛雨中一尊雕像。他那被雨水浸透了的头发一绺绺沾在他的前额上,雨,正顺着发梢不断地滴落。他的衣裳已经湿透,一双布鞋全部被从身上滑落下的雨水浸湿……\n一 # 上课铃响了,袁吉六绷着脸进了综合大教室,边报着分数,边把本子发给学生。\n“毛泽东,40分!”作文本“砰”的被扔在毛泽东课桌上,鲜红的“屡教不改”四个大字和40分的得分把毛泽东看得目瞪口呆!教室里的学生们也都愣住了:毛泽东居然只得到这样的分数?!\n“王子鹏,75;刘俊卿,90分……”袁吉六继续慢条斯理地给学生发放着作文本。他的身后,传来了“砰”的一声,不回头,他也知道这是毛泽东把作文本拍在桌上发出的声音。“怎么回事?”袁吉六环视着教室里的学生,瞪着眼睛问,“课堂之上,谁在喧哗?”\n毛泽东“呼”地站了起来,气呼呼地回答:“我!”\n“毛泽东?你要干什么?”袁吉六厉声问。\n“我不明白。”\n“什么不明白?”\n“我的作文,为什么只得40分?”\n“你还问我?”\n“袁老师打的分,我不问袁老师问谁?”\n这一来一往的针锋相对让所有的同学都吃了一惊,谁也没想到毛泽东居然敢这样跟袁吉六讲话!坐在旁边的几个好朋友拼命向毛泽东使眼色,示意他坐下,毛泽东却越发挺直了身子。\n“好,既然你问我,那我就告诉你!你这个作文,就只值40分!”袁吉六气愤地指着毛泽东的鼻子说。\n“我的作文有哪点不好了?”毛泽东质问老师的时候,完全忘记了自己是个学生,是在教室里。\n“哪点不好?哪点都不好!提醒你多少回了,要平实稳重,要锋芒内敛,不要有三分主意就喊得十七八分响,你听进去一回没有?你变本加厉!你越来越没边了!”袁吉六抓起那本作文,摇晃着说,“你这也叫文章?你这整个就是梁启超的新闻报道,只晓得喊口号!”\n“梁启超的文章怎么了?我就是学的他的文章。”\n“你还好意思讲!好的不学,学那些乌七八糟的半桶水!什么是温柔敦厚,什么是微言大义,什么是韩章柳句欧骨苏风,他梁启超懂吗?他屁都不懂!还跟他学?”\n“梁启超倒是屁都不懂,袁老师估计是懂了。”\n毛泽东这句话,把袁吉六气得大胡子直抖,他指着教室门吼道:“你……你混账!你给我滚出去,滚!”\n毛泽东愣住了,随即转身就往外冲,砰的一声,他的凳子被脚带倒在地!\n“你……”袁吉六大概也没想到毛泽东真敢冲离教室,怒气冲冲地朝着毛泽东的背影说,“好,你走,走了就再不准踏进我袁仲谦的教室!”\n“你放心,我不稀罕!”毛泽东头也不回地答应着,身影消失在了教室门外。\n袁吉六把手上剩下的作文本狠狠一摔,涨红着脸骂道:“混账东西!反了他了!”\n毛泽东气壮山河般地冲出教室,回到寝室里,坐也不是、站也不是,干脆躺在床上看书,可书也看不进去。正当他在床上翻烙饼的时候,方维夏、黎锦熙一脸严肃地进来了。方维夏沉着脸对他说:“出来一下,有话跟你谈。”毛泽东昂着脑袋,跟两位老师进了教务室,把刚才在综合教室发生的事情一五一十地讲了一遍,却一点没有认识错误的样子。\n黎锦熙敲边鼓说:“这件事情很严重,袁老师、孔校长、纪督学现在正在校长室研究对你的处理方案。”\n毛泽东像头小水牛一样,拧着脖子说:“处理什么?我本来没错。”\n“你没错,难道是老师错了不成?”\n看着方维夏满脸的恨铁不成钢,毛泽东一言不发。\n“润之,不管怎么说,袁老师都是为了你好,课堂之上,你当着那么多同学顶撞他,难道你还做对了?”黎锦熙的劝导还是很温和。\n毛泽东小声嘀咕道:“又不是我先骂人。”\n“这么说是袁老师先骂人?”黎锦熙问。\n“本来就是嘛。”\n“他骂谁了?”\n“梁启超。”\n方维夏和黎锦熙都愣住了,一时真是哭笑不得,异口同声地说: “他骂梁启超你较什么劲啊?”\n“那是我作文的偶像,我……我就是不让他骂。”\n“你……”方维夏简直不知该怎么跟他说下去了,“你这个人怎么这么犟呢?”\n两位老师是受孔校长的委托来找毛泽东谈话的,此时只好实事求是地回去向孔校长汇报。孔昭绶一听毛泽东死不认错,脾气也上来了,决定非要严肃处理他不可。但黎锦熙却认为,照毛泽东现在的情绪,处分只怕是火上浇油。站在两人中间,方维夏提议说:“校长,依我看,能不能先缓一缓?处分的目的,也是为了教育学生。可现在处分,不但达不到教育的效果,还会适得其反。毛泽东这个人,个性的确是有问题,太张扬,太冲动,倔强有余而不善自制。可我觉得,学生倔强也不见得都是坏事,如果能让一个倔强的学生认识到他的错误,那他一辈子可能都不会再犯同样的错误了。”\n孔昭绶冷静下来,也觉得这个办法可行,但谁能说服毛泽东这个倔强学生让他认识到自己的错误呢?他们三个人你看看我,我看看你,不约而同地齐声叫出了一个人的名字:杨昌济!\n杨昌济听了孔校长的一番话,也着实吃了一惊,但他想也没想,就接受了孔校长安排的任务。他也明白,就现在这种状况,除了他没有第二个合适的人选。姑且不说袁老那里学校不好交代,单说毛泽东,他也不能撒手不管呀。于是,当天晚上,他把毛泽东约到了君子亭。\n晚风中,杨昌济背着双手,仰望着星空,突然背起了一篇脍炙人口的文章:“‘世有伯乐,然后有千里马。千里马常有,而伯乐不常有。’润之,这篇文章你读过吗?”\n毛泽东在老师身后忐忑不安地坐着,小声回答:“读过,是韩愈的《马说》。”\n“对,《马说》。这个世上,真人才易得,识才者难求啊。为什么呢?”杨昌济在毛泽东身边坐下来,看着毛泽东,说:“因为人都有个毛病,自以为是。凡事总觉得自己是对的,看不到别人的优点,总之别人说的一概不认账。你比方……”\n他看到毛泽东微微侧开了头,那表情显然已经在等着自己的批评,忙话锋一转:“比方袁仲谦袁老先生,这方面的毛病就不小。”\n这一招很是高明,让毛泽东愣住了。\n杨昌济问:“怎么,你不同意我的看法?”\n“不是,老师怎么突然批评起袁先生来了?”毛泽东不好意思地说。\n“他做得不对我当然要批评他。你看啊,像你这样的学生,作文写得那么好,他居然看不上眼,这像话吗?不就是文章锋芒过甚,不太注重含蓄吗?又不是什么大不了的毛病,值得这么抓住不放?就算是有毛病吧,你毛润之改不改,关他什么事嘛?他要这么一而再再而三跟你过不去,真是吃饱了饭没事做!你说对不对?”\n毛泽东太尴尬了,尴尬得不知道怎么回答。\n杨昌济接着说:“还有还有,动不动就搬出什么韩柳欧苏,要人学什么古之大家,那韩柳欧苏有什么了不起?不就是几百上千年人人都觉得写得好嘛?难道你毛润之就非得跟一千年来的读书人看法一样?说不定你比这一千年来所有的读书人都要高明得多呢?他袁仲谦怎么就想不到这一层?这不是自以为是是什么?”\n这番话让毛泽东越发不安了,但杨昌济还在说:“最可气的是,他居然看不上梁启超的文章。梁启超的文章有什么不好,就算是比不得韩柳欧苏那么有名气,就算是许多人觉得过于直白,只适合打笔仗,上不得大台面,那又怎么样?你做学生的偏要喜欢,偏要当他十全十美,他这个老师管得着吗?还要因此在课堂上,当着那么多同学教训你,跟你争个面红耳赤,哪里有一点虚心的样子,哪里有一点容人的气度嘛?”\n“老师,我……”毛泽东垂下了头,擦了一把头上的汗。\n杨昌济不再继续说了,只是盯着毛泽东,直盯得他深深埋下了头。许久,杨昌济才站起身,向亭外走去。走出几步,他又站住了,回头说:“润之,道理呢,我就不跟你多说了,你自己慢慢去体会。不过有件事我想告诉你,你入学的作文,大家都知道,是我敲定为第一名的。可你不知道的是,那次阅卷其实是袁仲谦先生负责,当时他把你定为第二名。仲老是长沙国学界公认的权威,能在他的眼中得到第二名的成绩,足可见他有多么赏识你的才华,之所以定为第二名,也是因为你的文章还有明显的缺陷。他一次次指出这些缺陷,一次次降低你的作文分数,乃至降到40分,为什么?他看中的第二名写出的文章在他眼中真的只值40分吗?一个老师,当他碰上自己非常欣赏的有才华的学生,却又总也看不到学生改正缺点的时候,他会是什么心情?我告诉你,五个字——恨铁不成钢!”\n他说完,转身就走,只把夜空中的星光闪闪留给了正在发愣的毛泽东。\n二 # 那天夜里,毛泽东一口气跑到了袁吉六的宅第,“砰砰砰……”用力拍打着门环。\n“谁呀,这么晚了?”一名老仆人提着油灯,揉着睡眼打开了一道门缝。\n毛泽东喘着粗气对他说:“我是第一师范的学生毛泽东,来求见袁仲谦老师的。”\n“学生?也不看看几点了,有事不能明天说吗?”\n“我真的有事,我想马上见到袁老师。”\n“可先生已经睡了……”\n两人正说着,袁吉六的妻子戴长贞从里屋出来,站在走廊上问:“长顺,谁来呀?”仆人转头回答:“是老爷的学生。”\n戴长贞赶紧说:“哦。大冷的天,先让人家孩子进来嘛!”“是,太太。”仆人拉开大门,对毛泽东,“你进来吧!”\n毛泽东进到院子里,垂手立在天井里,听到里屋戴长贞正对袁吉六说:“说是来跟你道歉的,人在院子里等着呢。”袁吉六气冲冲的嗓门从房间里传出:“他爱等等去!谁也没请他来!睡觉!”\n话音一落,窗内的灯光骤然黑了,整个院落归入了一片宁静与黑暗,只剩了毛泽东一个人静静地站在院子里。\n夜空沉沉,星月无光,上半夜的满天星斗早已不知踪影。寒风骤起,在树梢、枝叶间呜咽,也卷起满地秋叶,掠过毛泽东一动不动的双脚。风是雨的脚,风吹雨就落。紧跟着,雨点落在了静静地伫立着的毛泽东的脸上。寒风和着秋雨,刹那间笼罩了整个院落。房檐下,雨水如根根丝带,在风的吹动下,摇摆着。不平的地面上,很快形成了许多的小水潭。全身透湿的毛泽东平静而倔强,他垂手而立,一动不动,仿佛雨中一尊雕像。他那被雨水浸透了的头发一绺绺沾在他的前额上,雨,正顺着发梢不断地滴落。他的衣裳已经湿透,一双布鞋全部被从身上滑落下的雨水浸湿……\n晨曦初露时,雨终于停了。渐渐的,东方的天际,一片火红。晨光中,雨水冲刷过的大自然,是那么干净、耀眼。\n袁吉六伸展着胳膊一走出卧室门,就听到毛泽东的声音:“老师。”\n袁吉六扣着扣子,扫了仍然站在原地的毛泽东一眼,一言不发。\n毛泽东往前走了几步,抬头正视着袁吉六逼人的目光,一字一顿地再次说:“老师,我错了,请您原谅我。”然后,深深地向袁吉六鞠了一躬。\n在毛泽东身后,残留的雨水悄然灌进了两个深深的脚印里,袁吉六心里一动,威严的目光从那两个脚印移到了毛泽东身上,看到眼前的学生静静地伫立着,浑身上下都湿淋淋的,脸上却平静谦和,全无半分疲色。\n良久,袁吉六接过妻子递过来的水烟壶,口气硬冷地说了声“跟我来”,便转身沿着走廊走去。\n望着这一对师徒离去的背影,戴长贞笑着招呼着仆人:“去,把我昨天晚上准备好的干净衣服拿来,还有,叫厨房烧碗姜汤。”\n师生俩进了袁家古色古香、四壁皆书的书房。袁吉六将水烟壶往毛泽东手上一塞,说:“拿着。”然后他踮起脚,小心翼翼地从书架上端取下了厚厚的一整套线装古书——那是一套足足二十多本的《韩昌黎全集》。\n“古文之兴,盛于唐宋,唐宋八大家,又以昌黎先生开千古文风之滥觞,读通了韩文,就读通了古文,也就懂得了什么是真文章。你的文章,缺的就是古之大家的凝练、平稳、含蓄、从容,如满弦之弓,只张不弛,令人全无回味。这是作文的大忌!这套韩昌黎全集是先父留给我的,里面有我几十年读此书留下的笔记心得,今天我借给你,希望你认真读,用心读,读懂什么是真正的千古文章!”\n“是,老师。”\n“遇到问题,只管来找我,我袁吉六家的门,你随时可以进,这间书房里所有的书,你也随时可以看,但有一条,毛病不改正,文章不进步,小心我对你不客气!”\n袁吉六炯炯的目光注视下,毛泽东用力点着头:“放心吧,老师!”\n三 # 袁老师的课,毛泽东这段时间是突飞猛进,可其他课,毛泽东就没这么幸运了。\n饶伯斯的英语课毛泽东还勉强过得去,美术课上他看其他科目的书,黄澎涛老师也能容忍,但在费尔廉老师的音乐课上,他那五音不全的大嗓门可就让他出尽了风头:他一跑调,隔壁几个班的同学全能听到,引来一片又一片哄笑,常常打断隔壁班老师的讲课。当然,这些还不是问题,最重要的是,他的数学和理化成绩不理想。没有办法,每次完成数学和理化作业,他都必须请教蔡和森跟萧三他们。\n这天晚上,他又抱着课本到了六班寝室。蔡和森去教室自习了,只有萧三在。两人约定,萧三先给他讲,讲了之后,毛泽东先自己做题,实在做不出来,再问萧三。萧三也不离开,就在旁边看书陪着他。\n“X加2Y等于X的平方,Y减X又等于……”毛泽东眉头紧锁,一副绞尽脑汁的苦相,一边做题还一边念念有词。\n萧三把手里的书一放:“你做就做,一晚上老念什么念?”\n“好好好,不念不念。”毛泽东苦着脸,继续做着题目。过了好半天,他终于把笔一放,长出了一口气,说:“哎呀呀呀,总算搞完了。哎,你看看,这回应该搞对了吧?”\n萧三接过作业本,逐一检查着。这个严厉的小老师看着看着,眉头皱起来了,脑袋一摇,把本子往毛泽东面前一塞,说:“润之哥,怎么回事啊你?”\n“怎么,还有错的?是哪一道?”毛泽东嬉皮笑脸地问。\n“哪一道?七道题搞错五道!总共两个公式,一晚上都跟你讲三遍了,第一遍你错七道,第二遍你错六道,第三遍你还要错五道,你说你怎么得了哟!”\n“怎么得了怎么得了,我还烦得死咧!什么鸡兔同笼,和尚分饼,一元二次,二元一次,鬼搞得它清?”毛泽东把作业本一摔,长叹一声,他显然也烦得够呛。\n“那你老是搞不清,考试的时候怎么办呢?”萧三问。\n毛泽东摇了摇头,仰头倒在了萧三床上。\n萧三又翻开了数学课本,没奈何地说:“算了算了,我再跟你讲最后一遍。”\n毛泽东强打着精神,支撑起身体,却无意间看见了萧三床头的一本《读史方舆纪要》。他的眼睛突然亮了:“《读史方舆纪要》?哎呀,这可是好书啊!”\n萧三几乎是条件反射地一把把书抢了过来:“哎!不行不行,这书不能给你。”\n“我看看怕什么?”\n“我还不知道你啊?看着看着就看到你手上去了。不准动啊。”\n“我看一下,就借三两天,两天可以了吧?”毛泽东哀求着。\n“一天都不行。”萧三护着书。\n“子暲,你不是那么小器的人吧?”\n“不是我小器。这是我哥的书,我刚拿过来的,他专门叮嘱了,不能借给你。”\n毛泽东:“怎么就不能借给我呢?哦,我借他的书什么时候不还了?”\n“你倒是还,还回来还是书吗?”他随手抓起床上两本书,翻动着,书上天头地脚到处都是墨迹:“你看看你看看,这都是你还回来的书,结果呢?上面写的字比书上的字还多,搞得我们哥俩都不晓得该看书上的字还是你写的字了。”\n“读书嘛,还不总要做点笔记?”\n“那你不会找个本子写啊?非要往书上写?我不管,反正我哥说了,什么都可以借给你,就是书不行。”\n“你哥讲了是你哥讲了,你可以通融一下嘛,我们两个还不好讲话——这回我保证不往书上写了,悄悄借,悄悄还,不让那个菩萨晓得,这总可以了吧?”\n“你会不写?我才不信呢。”\n“我保证!我,向袁大总统保证!”\n毛泽东把手伸到了萧三面前,脸上全是讨好的笑容。望着他,萧三满是无奈:“你到底是来补数学的,还是补历史的?”\n四 # 毛泽东的作文终于让袁吉六满意了,最近的一篇作文,袁吉六居然给他打了满分,还批了大大的两个字:“传阅”。\n这篇带着鲜红的“传阅”与满分成绩的作文,豁然张贴在一师公示栏的正中央。吸引着众多学生挤在公示栏前,争相阅读。何叔衡也挤在人群中,扶着眼镜仔细地读着,边读还边忍不住直点头。\n何叔衡读了毛泽东的满分作文,满脑子装的都是毛泽东,心里对这个比自己小了近20岁的年轻人钦佩不已。却不想从公示栏回来,一踏进讲习科寝室 ,正听到有人在说毛泽东。\n“我说了,什么都可以借,就是不能借书给他!你怎么就记不住呢?你看看你看看,这又成什么样子了?他保证不写,他毛泽东的保证你也信?他那身毛病,一看得激动起来,管他谁的书,反正是一顿乱抒发感慨,你又不是不知道!”\n何叔衡笑说:“子升兄,是什么书啊?能不能借我看看?”子升把书往他手里一递,“送给你了!”\n何叔衡接过来一看,是本《读史方舆纪要》,随手翻开,上面天头地脚又到处是墨迹,不觉好笑。这时子升拉开抽屉,取出几张空白描红纸,气冲冲地提笔在纸上的示范格写起偏旁来,感觉有些莫名其妙:难道他还需要练字吗?便好奇地问:“子升兄,你写这个干什么?”\n子升没做声。萧三赶紧解释:“是这样,润之哥正在练字,我哥每天都给他示范几张,好让他照着练。”望着子升一面带着气,一面一笔一画,精雕细刻,何叔衡忍不住笑了。\n子升看了何叔衡和萧三一眼,自己也不禁笑了,无可奈何地说:“交错了朋友,算我倒霉,行了吧?”\n何叔衡在子升一边坐下,读那本《读史方舆纪要》。他眯缝着眼睛,仔细地分辨着天头地脚上毛泽东潦草的字迹,与书上的内容作着对照。翻过一页时,他又寻找着上一页毛泽东未写完的评语,再翻回来对照着,不住地点头。不一会他便向毛泽东的寝室走来。\n“烦死了!” 何叔衡远远便听见一个声音。看见寝室里的桌子上,摊着课本、作业本,一个人正用圆规、直尺照着书画几何图形。左量右量,怎么画都跟书上对不上,烦得把尺一扔,却又碰掉了铅笔,铅笔滚到了床下。他嘟哝了一句,俯下身来捡铅笔,但铅笔滚到了床底,他只得尽量趴下去,使劲探着手臂。\n何叔衡不觉疑惑,问道:“请问毛泽东同学在吗?”“我就是,等一下啊。”那人探着手使劲地够着,总算够到了那支铅笔,从床底下钻了出来,拍打着满头满手的灰尘。\n毛泽东看着眼前这个手里还拿着那本书的老大哥,只觉得面熟,一时却想不起名字了,喃喃地问:“你不是那个?”“何叔衡,讲习科的。”\n“哦,对对对,何兄找我有事?”“我刚才看了毛兄公示的范文,还有这本书上的笔记,毛兄的知识之广,见解之深,立言之大胆,思索之缜密,令我非常佩服,真的,佩服之至。我有一个冒昧的想法,希望今后能多多来向毛兄求教。不知毛兄能不能给我这个机会?”\n毛泽东有点不好意思了,拍拍后脑勺说:“你看你这是怎么说的?你是老大哥嘛,我那点本事算什么?”\n“学问、见识,不以年龄论短长,我虽虚长几岁,却是远不及毛兄。今天,我确实是诚心诚意,来向毛兄讨教的。”何叔衡的态度非常恳切。\n毛泽东不喜欢客套,很爽快地向何叔衡伸出手来,说:“都是同学,有什么讨教不讨教?这样吧,我们交个朋友,以后,多多交流。”一老一少,两个人的手握在了一起。\n五 # 这周,杨昌济在周南师范科教室里,也在讲作文。\n“本次作文测验,又是陶斯咏同学第一名,向警予同学第二名,她们两个的作文水平,的确值得全班同学认真学习。”周南国文课上,杨昌济说道。\n女生们羡慕的目光都投到了斯咏和警予的身上,斯咏有点腼腆,警予却表情泰然。\n“当然了,陶同学和向同学的文章并非十全十美,这里呢,我也带来另外两篇范文,还是第一师范与你们同年级的毛泽东和蔡和森两位学生的,尤其是毛泽东这篇满分作文,可以说进步神速,克服了他过去作文中某些明显的弱点。今天我也把这两篇范文发给大家,以便大家学习体会别人是怎么改进提高的。”说着,杨昌济拿出一大叠油印稿发给学生。斯咏与警予不由得对了个眼神,脸色古怪。\n放学后,斯咏和警予肩并着肩走出校门。警予怎么都弄不明白,她每天都要喊三遍“我要超过你”的,可怎么越赶差得还越远呢?于是决定从今以后每天要喊六遍了。斯咏却说她现在是没那个志气了,既然打马扬鞭也追不上,不如不追。\n两人说着话,转身进小巷。警予突然问斯咏:“哎!你说这两个家伙会是个什么样啊?”\n“什么样?我怎么知道什么样?八只眼睛六条腿喽。”斯咏还没想过这个问题。\n“不行,我非得去看一眼不可,倒看看他们跟一般人长得有什么不同。”\n斯咏看警予那蛮横横的样子,打趣她说:“这好办啊,明天你直接往第一师范门口一站,两手往腰上一插,‘毛泽东,蔡和森,给姑奶奶我站出来!’包你马上看到。”\n“去!以为我神经病啊?”\n“你也知道啊?人家男校学生,我们跑去看,被看的还不知道是谁呢。”\n她话音未落,突然,被警予拉了一把。斯咏顺着警予的手指看过去,惊得嘴巴张得老大,半天合不拢!\n就在前面不远的,小巷的拐角处,赵一贞与刘俊卿正依偎在一起,两个人的唇正在悄悄接近。这时,斯咏和警予身后忽然传来了蹬蹬蹬的脚步声。斯咏回头一看,愣住了:穿得好像教会学校女学监模样的何教务长目不斜视,正向这边走来。\n“教务长好!”警予首先反应了过来,扯开嗓子喊了一声。斯咏也跟着问好:“教务长好。”\n“嗯。”何教务长答应着,对警予皱起了眉头,很严肃地说,“向警予同学,说话切忌高声,一个淑女,就得像陶斯咏同学这样,时刻保持温文尔雅,记住了?”\n何教务长说完又向前走。警予急了,一把拦在前面:“哎,教务长!”\n何教务长脸一板,问:“怎么又这么大声?温文尔雅,淑女风范!什么事啊?”\n“那个,明天照常上课吧?”“明天又不是礼拜天,当然上课!”“啊?哦!对对对,我那个、那个太糊涂了。”\n“没头没脑。”何教务长,说着,向前走了。警予、斯咏转身一看,大树下,一贞与刘俊卿早已躲得没了人影。两个人这才长出了一口气。\n回到寝室,“砰”的一声,警予的巴掌拍在桌上,喝道:“招!给我从实招!”赵一贞坐在自己床边,埋着头,声音细如蚊鸣:“他叫刘俊卿,第一师范的。”\n“刘俊卿,第一师范,这就算完了?”警予低头看看一贞的脸,“哟哟哟哟,还知道脸红呢!”\n一贞羞得捂住了脸。\n斯咏拉了一把警予:“你呀,算了,问那么多。”警予哼了一声,“不行,要没我们俩,今天什么后果?赶紧赶紧,怎么报答我们,说吧!”\n“随……随便你们喽!”\n“随我们说是吧?嗯——这倒是要好好想想。”警予突然眉毛一挑,想起了什么,“哎,对了,你是说,他是第一师范的?这样吧……”一贞听着警予的话,不停地点着头。\n周末,一贞一出周南女中的大门,就看到对面大树下,有一双锃亮的皮鞋,知道是刘俊卿在那里等自己,左右看看没人,便埋着头,紧张地走了过去,红着脸站在刘俊卿面前,却盯着自己的鞋尖,不敢看刘俊卿一眼。\n刘俊卿将一直背在身后的手伸了出来,在他的手里,是一个漂亮的小本子。一贞小声问:“是什么?”\n“《少年维特之烦恼》第一章,我翻译的——译得不太好,要是你觉得还能看下去,我再给你译后面的。”\n一贞红着脸,接过了本子,转过身,走上了回家的路。刘俊卿迟疑了一下,赶紧跟了上去。\n僻静的小巷,夕阳斜照,树影斑驳。抱着那个精巧的小本子,一贞与刘俊卿并肩默默地走着。秋风轻拂,一贞的辫角扫过俊卿的面颊。看着一贞含羞的脸,刘俊卿几乎都痴了。\n夕阳下,两个人的影子投在青石板路面上,一只手的影子悄悄伸向了另一只手,那只手微微挣了一下,两只手的影子还是合在了一起。夕阳将两个人的影子拉得老长老长。\n临到分手,一贞低声问:“俊卿,你能不能把毛泽东和蔡和森约到一师对面的茶馆里去。”刘俊卿停住脚步问,“你见他们干什么?”\n“不是我,是警予和斯咏。她们俩都是我最好的朋友,就是特别佩服你那两个同学的文章,所以想见见本人。怎么,是不是不好约啊?”\n刘俊卿犹豫一时说:“那倒不是……要不,我试试吧。”一贞打量着他的神情,说:“要是不好约,你也别勉强。”“怎么会呢?”刘俊卿赶紧换上轻松的笑容,“你交代的事,我怎么都会办好的,你就放心吧,让你两个同学等着见人就是。”\n六 # 南门口,车轿往来,行人穿梭,商贩叫卖,喧哗热闹的南门口的街道,今天却多了一个突兀而格格不入的声音——“Ill be back in a few days‘ time……”\n黄包车拉着斯咏,停在了街对面。斯咏下车付钱,听到读英语的声音,便掉头看去,就在嘈杂的街道边,毛泽东坐在大树下,正捧着英语课本,大声朗读着。阳光洒在他的身上、他的英语书上,形成美丽的剪影。而他竟读得如此专注,旁若无人,仿佛全未感觉到周围的吵闹和目光。\n斯咏悄悄停在了毛泽东的身后。“嗨!”毛泽东一回头,身后站着的,居然是斯咏:“嗨,是你呀,这么巧?”\n斯咏说:“我有点事,约了朋友在这儿碰头。你怎么……在这儿读书啊?”\n“哦,我英语成绩不太好,所以抽时间多练一练喽!”毛泽东看斯咏一副茫然的样子,又说, “是这样,我呀,有个毛病,性子太浮,读书也好,做事也好,旁边稍微一吵我就容易分心。古人不是说‘闹中取静’吗?南门口这里,最吵最闹人最多,所以我专门选了这个地方,每天来读一阵书。”\n“哦,身在烈火,如遇清凉境界?”斯咏和他开玩笑。\n“那是佛祖,我有那个本事还得了?只不过选个闹地方,练点静功夫,也算磨一磨自己的性子吧。”毛泽东说完,又捧起了书。\n望着毛泽东泰然自若的样子,斯咏不由地笑了。她索性在毛泽东身边坐了下来,问道: “你在读课文啊?”\n“我最差的就是口语,老是发音不准,只好多练习了。哎,你的英语怎么样?”毛泽东看斯咏自得的表情就知道她的英语一定不错,于是赶紧书捧到了两人中间,说,“那正好啊,我把这一段读一读,你帮我挑挑毛病。 It will be covered with some soil by me……”\n“等一下。”斯咏指着书上的单词:“这个词读得不准,应该是covered.”\n“covered.”毛泽东的发音仍然有点不地道。\n斯咏:“你看我的口形——covered.”\n毛泽东:“covered.”\n斯咏点点头。\n毛泽东:“我多练两遍:covered,covered,It will be covered with some soil by me……”\n碧空如洗,阳光轻柔。一教一学,斯咏与毛泽东的声音交替着。闹市的尘嚣似乎都已被拒之二人之外,只有清澈的英语诵读声,仿佛要融入这冬日的阳光之中……\n“斯咏,斯咏……”街对面,警予站在黄包车旁,正向这边招手叫着。\n“哎。”斯咏答应着起身,“对不起,我约的朋友来了。”\n毛泽东笑说:“哦,没关系,我也约了人,一会儿还有事。”\n斯咏跑到跟前,警予问:“谁呀那是?”“一个熟人,以前认识的,正好碰上。” 斯咏说道。\n这一天中午,警予、斯咏和一贞都等在一师对面的茶馆里,可来的却只有刘俊卿一个人。一贞忙问:“俊卿,到底是怎么回事,你不是说你那两个同学都答应了吗?”\n刘俊卿低着头,显然他没有兑现他的承诺,只得回避着她们的目光,吞吞吐吐地回答:“他们说……哎呀,我怎么说呢?”“是什么就说什么。”警予催促道。\n“他们……他们两个就这样,平时在学校里就那副嘴脸,一天到晚趾高气扬,把谁放在眼里过?一说是你们两位外校女生来找他们请教,那眼珠子,都快翻到天上了。还说我是没事找事,跟你们一样,吃饱了撑的。”刘俊卿编瞎话的本领可真是一流,一点破绽都让人看不出来。\n“对不起,给你添麻烦了,谢谢你。”警予腾地站了起来,脸涨得通红,一拉斯咏:“斯咏,我们走!”\n两人蹬蹬蹬蹬冲下了楼。一贞想追又不好追,一时满脸尴尬。\n刘俊卿拉住一贞的手说:“对不起啊,一贞,都是我没用,弄得你的朋友不高兴。”\n一贞回头对他笑了笑,说:“这怎么能怪你呢?你已经尽力了,是你那两个同学太不通情理了。”\n“什么不通情理?他们就是看不起人,自高自大,哼!”刘俊卿总算是出了一口气,说这话的时候,心情说不出有多爽快。但他却不知道,他的谎话最终会伤害到谁。\n警予回到寝室,径直冲到自己床前,一把将床头贴的蔡和森的文章撕了下来,团成一团,砸进了字纸篓!\n斯咏跟在她身后:“警予,算了,何必生那么大气?”\n“谁说我生气了?”警予回过头来,她脸上居然露出了笑容,“跟这种目中无人的家伙,我犯得着吗我?”\n斯咏:“其实,那个蔡和森和毛泽东又不认识我们,可能……可能只是一时……”\n警予:“斯咏,不用说了,你放心,我现在呀,反倒还轻松了。”\n她仔细地撕着床头残留的文章碎片:“原来呢,我还一直以为我们比别人差多远,现在我知道了,原来也不过如此。不就是文章写得好吗?那又有什么?德才德才,德永远在才的前面,像这样有才无德、狂妄自大的人,幸亏我们没去认识,要不然,更恶心!”\n她“呼”地一口气,将撕下的几片碎纸片轻轻吹落,拍了拍手。\n第十章 世间大才少通才 # 什么是真正的因材施教?\n怎样的教育,才是科学的、先进的、\n更利于培养真人才的呢?\n是一场考试定结果,还是别的什么?\n这确实是一个值得深深思考的问题。\n一 # 在南门口闹市区的大树下读英语的毛泽东约了什么人呢?\n日近黄昏了,几个下工的苦力和学徒、小贩,拿着扁担、麻绳之类的东西来到正在读英语的毛泽东身边。\n“哟,都来了?”毛泽东把书一收,“好,大家围拢,马上开课!今天我们学的这个字,上面一个自,自己的自,下面一个大,大小的大……”\n架子车旁,刘三爹等七八个市井百姓或蹲或坐,围坐成一圈,他们中间,毛泽东拿着一根树枝,正往地上写着一个“臭”字。\n“这不是我喜欢吃的那个臭豆腐的臭字吗?” 一个小贩认出了这个字,看来他也和毛泽东有一样的嗜好。\n毛泽东点点头,问:“你再仔细看看,这个字比那个臭字还少了什么没有?”\n小贩仔细分辨着,旁边一个码头苦力伸出手指着那个字说:“好像少了一点吧?”\n毛泽东加上了那一点,说:“什么气味讨人嫌啊?臭气,什么样的人讨人嫌呢?那些自高自大,以为自己了不起的人,看了就让人讨嫌。所以大家以后记住,”毛泽东用树枝指点着臭字的各个部分,“自、大、一点,惹人讨嫌。怎么样,这个臭字,都记住了吧?好,那我再讲一个字。”\n他先往地上写了一个“日”字,这个字大家显然学过,好几个人读了出来。\n“对,日头的日。”毛泽东又往地上写了个“禾”字,“这个字我也教过大家,还记得吗?”\n又有几个人读道:“禾,禾苗的禾。”\n“对,禾苗的禾。有了好太阳,禾苗会怎么样呢?”\n“长成谷啰。”\n“对了,万物生长靠太阳,日头一照,禾苗就能长成谷,到时候煮成饭,你一闻,嗯,怎么样啊?”\n“香。”\n“对了,就是一个香字!”毛泽东先日后禾,把香字写了出来,“日头照得禾苗长,这就是香喷喷的香。大家都记住了吗?”\n“原来这就是香字啊……记住了……”\n毛泽东扔掉树枝,拍打着手:“好了,今天的课就上到这里。放学了。”\n“谢谢您了,毛先生。”\n“讲什么客气?明天再来,我再给你们教五个字。”\n人群散去,毛泽东一抬头,孔昭绶迎面向他微笑着说:“毛老师,课上得不错啊,有板有眼的。”孔昭绶常常在这条路往来于学校和家之间,他已经不是第一次在这里看到毛泽东教人识字了,以往还以为只是碰巧有人请教,这次才知道,原来毛泽东是有计划地在这么做。\n毛泽东和校长并肩往学校走着,边走边给他解释说:“我这是在遵循徐老师的日行一事呀。他说,一个人,不必老想着去做什么惊天动地的大事,而应该着眼于每天做好一件小事,日积月累,才能真正成就大事。我们读书会专门讨论了这个原则,都觉得徐老师说得好。所以我们约好了,每人每天找一件实事来做。”\n孔昭绶赞许道:“你们这个读书会倒还搞得有声有色嘛。”\n“大家都谈得来,还不就凑到一起了。”\n“你怎么会想起教人认字呢?”\n“读师范嘛,以后反正要教书的,就算实习嘛。校长也说过,民国教育,就是要注重平民化,如今谁最需要教育,还不是那些一个字都不识的老百姓?”\n孔昭绶站住了,笑容也渐渐化为了严肃:“润之,你说的没错。师范的责任,就是要普及教育。学校应该想应该做却还没有想到、做到的事,你先想到、先做到了,谢谢你。”\n毛泽东有点不好意思:“我可没想那么多,我只是觉得,凡事光嘴上讲个道理没有用,只有自己去做,才算是真道理。”\n望着毛泽东,孔昭绶认真地点了点头。\n二 # 第二学期就要结束了,一师公示栏里,已经贴出了大幅的“期末预备测验考程表”,上面是各年级各科考程安排。大考前的紧张气氛扑面而来,学生们正端着饭从走廊上经过,不少人边吃手里还边捧着书。\n八班寝室里,个个同学正在聚精会神地复习。为了不影响他们,易永畦在寝室外的走廊上给毛泽东讲理化:“……质量,是物体所含的物质多少;重量,是地球对物体产生的引力大小。”\n毛泽东听得满头雾水:“可两个数字都一样啊。”\n“数字上看起来是一样,其实是两个概念。”\n“数字一样,又是两个概念……哎呀,我还是分不清。”\n“没关系,我再跟你从头讲一遍。”\n“润之哥,”萧三跑过来,把两份报纸递给毛泽东,“你的报纸,我帮你领回来了。”\n“谢谢啊。”拿到新报纸,毛泽东精神来了,“永畦,这些物理啊,化学啊,把我脑袋都搞晕了,要不我们休息一下,我先看看报纸。”\n“行,那你先看吧。”易永畦起身回了寝室。\n打开报纸,毛泽东浏览着标题,一篇有关欧战中巴黎保卫战况的报道首先吸引了他。读着报道,他的眉头突然皱了起来:“林木金阿皮耶?这是什么意思?”毛泽东立刻就跑去图书馆查,可查来查去没查到眉目,干脆又拿了报纸去找杨昌济,“我就是纳闷,这到底是个什么东西,怎么法国人用了它,一晚上就把军队运了那么远?”\n望着报纸的这行字,杨昌济的眉头也皱了起来:“这个词,我还真没见过,估计是从法语音译过来的吧?”沉吟了一下,杨昌济站起身:“要不,去请教一下其他先生吧。”\n杨昌济带着毛泽东,询问着一个个老师。易培基、黎锦熙……一个个老师看着报纸,都回答不上来。方维夏说:“林木金阿皮耶?哎哟,这个我还真不知道,我也没去过法国,对法语……对了,我想起了,纪督学是法国留学回来的。”\n杨昌济带着毛泽东来到督学办公室。纪墨鸿看了报纸,很轻松地说:“哦,这是法语中的一个词,通常见于上流社会很高雅的用法,翻译成汉语的话,可以叫做——出租汽车。”\n“出租汽车?”毛泽东没听明白。纪墨鸿笑了,他认真地说:“汽车你知道吗?”毛泽东点头,“听说过,是德国人发明的一种交通机器,我在报上见过照片。”\n纪墨鸿和蔼地说:“对喽。林木金阿皮耶指的是在大街上出租,付钱就可以坐的那种汽车,你付了钱,开车的司机就送你去要去的地方,好像我们这儿的黄包车,所以叫出租汽车。”\n“哦,就是英语里的TAXI嘛。” 杨昌济也明白过来。纪墨鸿笑说:“就是它。这篇报道是说德国军队进攻巴黎,法国人临时征用了全巴黎的七百辆出租汽车,一晚上把后方的军队运上了前线,所以保住了巴黎城。怎么样,你明白了吗?”\n毛泽东一拍手说:“明白了。难怪报纸上说这是人类有史以来调动军队最快的一次,兵贵神速,就是这个道理。”他兴奋地向纪墨鸿鞠了一躬:“谢谢您了,纪先生。”\n“谢什么?解惑答疑,本是我们做先生的责任嘛。”纪墨鸿端起茶杯,不经意地说:“哎,杨先生,一师什么时候增加军事课程了,我在教育大纲上没见过啊。”\n杨昌济诧异道:“军事课程倒没有,这只是毛泽东的个人兴趣而已。”“个人兴趣?”\n纪墨鸿眉头一皱,都举到了嘴边的茶杯又停下了:“这么说,毛同学,这不是你的课业?”毛泽东摇摇头:“不是。我对时事和军事平常就感兴趣,看到不懂的,所以才来请教先生。”\n“砰”的一声,纪墨鸿把茶杯重重往桌上一放:“乱弹琴!”毛泽东与杨昌济都吓了一跳。\n仿佛是意识到了自己失态,纪墨鸿赶紧控制了一下自己的语气:“毛泽东同学,你身为学生,不把精力用在自己的课业上,搞这些不着边际的玩意干什么?欧洲打仗,跟你有关系吗?没有嘛!搞懂一个兵贵神速,你的哪科分数能提高?不行嘛!——对了对了,还有十几天就要期末考试了,明后天你们全部科目还要摸底测验,你还不抓紧时间好好复习,是不是科科都能打一百分啊?”\n毛泽东被他一顿训斥,都懵了。杨昌济摇头说道:“纪先生,话也不能这么说,碰上问题,及时求教,这也是润之的优点嘛!”\n“那也得跟课业有关!这是什么?不务正业嘛!”纪墨鸿摇着脑袋,“早知道是这种问题,我才不会回答你呢!”他瞪了一眼毛泽东:“还站在这儿干什么?还不回去复习功课?”\n“纪先生再见。”毛泽东窝了一肚子气,转身就走。盯着纪墨鸿,杨昌济似乎有话说,但停了一停,只是道:“打扰纪先生,告辞了。”等房门一关上,纪墨鸿抓起那份报纸,便往字纸篓里一扔,“什么板仓先生,学生不懂事,他还助长劣习,如此为人师表,太不负责任了!”\n三 # 纪墨鸿对杨昌济教育学生的方式有意见,杨昌济对纪墨鸿教育学生的方式又何尝不是意见大大的!他非常担心纪墨鸿这样对待毛泽东,会打击毛泽东的求知欲,便把自己的担心告诉了老朋友孔昭绶。\n“毛泽东那个倔脾气,哪那么容易受打击?”孔昭绶满不在乎地安慰杨昌济,“哎,说起他,我又想起那天看到他教人认字的事。你看好的苗子,确实不错啊。说得天花乱坠,做起来眼高手低,这是读书人的通病,我最怕的,就是我们的学生也变成这样。这个毛润之倒确实不同凡响,不但能想能说,最难得的是,他想到什么,就去做什么,他愿意化为行动,这才是务实啊。”\n杨昌济很高兴孔校长能这样赏识他钟爱的学生:“润之的优点也就在这里。其实,论天资,他也并不见得有什么特别惊人之处,但别人坐而论道,他总是亲力亲为,所以长进得就是比一般人快……”\n杨昌济的话还没说完,校长室的门“砰”的一声被猛地推开了,黄澍涛挟着一叠考卷,一脸怒气冲冲地冲了进来,把孔昭绶和杨昌济都吓了一跳。\n“澍涛,这是怎么了?谁把你惹成这样?” 孔昭绶问道。\n“还能有谁?毛泽东!”黄澍涛把手里的考卷往桌上一拍。原来图画测验,黄澍涛监考。考试内容是:日常实物素描,请大家各自画出一件日常生活常见的实物。结果白纸发下去不到一分钟,毛泽东就交了卷子。\n“就画了这么个圈圈!”黄澍涛敲着孔昭绶手里的一张考卷,“你猜猜他说什么?他说这是他画的鸡蛋!”\n那张纸上,孤零零地还真就是一笔画了个椭圆形的圈。孔昭绶疑惑地问道:“怎么会这样?”\n“怎么不会这样?就这个毛泽东,每次上我的课,从来就没有认真过!——你有本事,功课学得好,你就是一节课都不上我也不怪你,可这是学得好吗?这不是胡扯蛋吗?” 黄澍涛越说越气。\n这时方维夏正好推门进来,说道:“校长,这次期末预备测验的数学和国文成绩单已经出来了。”他缓了一缓,看了杨昌济一眼,说:“有一个学生成绩比较怪——国文第一名,顺数;数学也是第一,可惜是倒数。”孔昭绶怔了一怔,问:“是谁?”\n“本科八班的毛泽东。” 方维夏答道。孔昭绶与杨昌济不觉面面相觑。信步来到教务室,却见老师们都在,正议论毛泽东的奇怪成绩。\n孔昭绶放下了手里的成绩单,说:“从这次摸底测验的成绩单上来看,毛泽东的确存在一定的偏科现象。各位都是第八班的任课老师,到底是什么原因?我想听听各位的意见。”\n费尔廉第一个开了口说:“从我的音乐课来看,毛泽东这个学生在音乐方面缺乏天赋。别的学生一遍就能学会的音乐,他五遍、十遍还要跑调。”他指指脑袋,“我觉得,他这里有问题,他太迟钝了,真的,这个学生不是不用功,他是非常非常的迟钝。”\n袁吉六一听,脸板起来了,当即回敬道:“毛泽东迟钝?他都迟钝了,一师范还有聪明学生吗?袁某教过的学生也不算少了,我敢断言,长沙城里最聪明,也最肯用功的学生就是毛泽东!”\n“不会吧?”数学老师王立庵情绪上来了,“毛泽东还用功?我教六个班的数学,还没见过他这么不用功的学生呢,上课上课老走神,作业作业不完成,我看他脑子是没有一点问题,就是不肯用功!”\n“你们说的毛泽东是我认识的毛泽东吗?”饶伯斯显然被搞糊涂了,“毛泽东上我的英语课是很认真很认真的,比一般学生刻苦得多,他就是基础差,所以成绩只是一般。我觉得,他是一个天分一般,但很用功的学生啊。”\n黄澍涛冷哼道:“依我看啊,聪明勤奋,他是哪一条都不占!”\n孔昭绶点头说:“嗯,又聪明又勤奋,聪明但不勤奋,勤奋却不聪明,又不聪明又不勤奋,这个毛泽东怕是个孙行者,七十二变啊!”\n评价如此悬殊,大家一时都不知该说什么好了。\n“我说两句吧。”一片沉静中,杨昌济开口了,“毛泽东的成绩单,我刚才也看了,总的来说,凡社会学科的课,他是门门全优,非社会学科的课呢,成绩确实不理想。你可以说他是一个偏科的学生,也可以说他是一个独擅专长的怪才。但我以为,他的身上,首先体现了一种难能可贵的东西,那就是个性!\n“的确,学生应该学好功课,偏科也证明了这名学生发展不全面。但学生为什么会偏科呢?原因就都在学生身上吗?” 杨昌济看了看大家一眼,顿了顿说,“我觉得不尽然。我国之教育,向来就有贪大求全之弊!以我校为例,部颁教育大纲规定的这些课程,可谓面面俱到,一个师范生,从国文、历史,到法制、经济,乃至农业、手工,文理工农商,无所不包。假如是小学、中学,那是打基础,全面培养学生最基本的知识,确实是必要的。可我们是小学、中学吗?不是,我们是高等专科学校啊。如此驳杂而主次不分的功课设计,这科学吗?这种恨不得将每个学生都培养成全才、通才的教育模式,本来就为教育界诸多有识之士所诟病,我本人也向来是不赞同的。\n“更令人担忧的是,把考试分数视为评价学生的唯一标准。学生的素质如何,能力怎样,没有人关心,每日里功课如山,作业如海,但以应试为唯一目的,把学生通通变成了考试的奴隶——须知一人之精力有限,面面俱到则面面不到,门门全优则门门不优,许多才质甚佳之优秀学生的个性,常常就湮没在这功课之山,作业之海里,便有峥嵘头角,也被磨得棱角全无了!”\n他说得不禁激动起来,站起身来:“以毛泽东为例吧!这个学生我接触较多,还是比较了解的。各位如果看过他的读书笔记,听过他讨论时的发言,就会发现,他是一个非常肯思考、也非常善于思考的学生。他的着眼点,从来不仅仅局限于个人之修身成才,而是把自己的学业,自己的前途、未来,与社会之发展,国家之兴衰,民族之未来紧紧联系在一起。身无半文而胸怀天下,砥砺寒窗而志在鸿鹄,这样的学生,你怎么可能用僵化呆板的应试教育来框死他,怎么可能要求他面面俱到、门门全优?\n“我们的教育应该提倡学生全面发展,但是如果出现某些个案就如临大敌,实在大可不必。因此,我们这些教书育人的先生,又何必为苛求某几门功课的成绩,硬要扼杀一个个性如此鲜明的学生的天性呢?”\n“杨先生这话,太不负责任了吧?”\n这个时候,纪墨鸿走了进来,“论见识,纪某是少了点,及不上杨先生。”纪墨鸿剔着指甲,慢条斯理、有意扭曲事实地说:“所以是怎么想,也想不明白,这上课不专心,读书不用功,校规校纪视若儿戏,考试成绩一塌糊涂,怎么他就成了个大才?要是这样就是大才,哈,那就好办了,学生通通不用上课了,考试通通取消掉,满山跑马遍地放羊,到时候,第一师范人人都成了大才。孔校长,是不是明天开始咱们就这么办啊?”\n杨昌济解释道:“纪先生不要误会我的话。昌济也没有说什么规矩都不要了,我说的只是毛泽东这个特例,他也并非上课不专心,读书不用功。”\n“特例?校规校纪就不许特例,部颁大纲更不容特例!”纪墨鸿毫无余地地回答。\n杨昌济继续说:“毛泽东的成绩,并非一塌糊涂,至少三分之二以上的科目,他堪称出类拔萃,虽然三四门功课还要加强,何必非得强求尽善尽美?”\n纪墨鸿敲着桌子:“三条腿的桌子站不稳!学生进校,学的是安身立命的本事。杨先生如此放任,他日这个毛泽东走出校门,万一就因为这几门功课不行砸了饭碗,只怕不会感激杨先生吧?”\n杨昌济摇摇头说:“纪先生是不了解毛泽东,此生读书,绝不是为了有碗饭吃。”\n“饭碗都不要了,他还想要什么?想上天啊?好,就算他可以不要饭碗,他去做他的旷世大才,其他学生呢?开出这么个先河,立起这么个榜样,岂不是要让其他学生都学他那样随心所欲,到时候,还有学生肯用功吗?”\n黎锦熙冷冷地说:“我想这倒不至于吧?毛泽东的用功,那是全校闻名的。我是事务长,我知道,每天晚上全校睡得最晚,也起得最早的,总是毛泽东,每天熄灯以后,他还要跑到茶炉房,借值班校役的灯光看好几个钟头的书。许多学生现在开夜车学习,还是受他的影响呢。”\n“又是一条,听听,又是一条!”纪墨鸿桌子敲得更响了,“熄灯就寝,这也是学校的规矩!熄了灯不睡觉,还要带着其他学生跟着他违反校规,果然是害群之马!不严惩何以正校纪?”\n黎锦熙不禁张口结舌。杨昌济笑说:“这真是正说也是纪先生,反说也是纪先生。”\n纪墨鸿冷笑说:“我没有什么正说反说,我只有一条:学校不是菜市场,一句话,不能没了规矩!”\n杨昌济肃然说:“我也只有一条,不能为了规矩扼杀了人才!”\n教务室里,一片宁静,一时间,气氛仿佛能点得燃火一般。坐在角落里的王立庵咳嗽了一声,却发觉自己的一声咳嗽在这一片剑拔弩张的安静中显得格外惹耳,赶紧强压住了声音。一片压抑的寂静中,连正在给老师们添茶的校役也小心翼翼地放轻了动作。\n“各位先生,我认为毛泽东的偏科,既不是他的能力缺陷,也不是学习态度有问题;广而言之,我们的教育,究竟应该以学生的考试分数为唯一标准,还是应该舍弃应试观念,尊重学生的个性,因材施教,我看,坐在这里讨论,也出不了结果,还是要从学生本人身上,去找真正的原因。”孔昭绶站了起来,说,“我建议,讨论先到这里。几位对毛泽东偏科有看法的先生,今天晚上,我们一起去找毛泽东谈一谈,再作定论,好不好?”\n四 # 老师们在教务室争论不休的同时,子升与蔡和森也在君子亭里就偏科的事情围攻毛泽东。\n“润之,我们是朋友,是朋友才会跟你说真心话。你这个偏科的毛病,我们是有看法的。读书不能光凭兴趣嘛,你我都是学生,学校规定的功课,怎么能想学什么学什么,不想学的就不学呢?”萧子升苦口婆心地劝毛泽东。\n“你以为我愿意啊?我也想通通学好,可是有些功课,我真的学不进去嘛。”毛泽东为自己辩解着。\n“你就是喜欢找借口。国文你学得好,历史、修身、伦理、教育那么多功课你都学得好,为什么数理化、音乐、美术就学不好呢?明明就是没用心嘛。”\n“我用了。”\n“你用了?用了怎么会学不通呢?”\n蔡和森看子升的话毛泽东听不进去,也开了口:“润之,我相信你说的是真心话,可是,偏科终归不是什么好事,你也不能总这样下去吧?”\n“我也烦咧。我就不想门门全优啊?可是,有些功课,我一拿起书就想打瞌睡,逼起自己看都看不进——有时候想想,也是想不通,那些个烂东西学起有什么用嘛?”毛泽东边说边叹了口气。\n子升问:“怎么能说没用呢?数学没用啊还是美术没用啊?你以后毕了业,要你教数学你怎么办?”\n毛泽东扯歪理:“我未必非要教数学啊?我可以教别的嘛。照你这么讲,我什么都要教,什么都要学,那读书不成了填鸭子?给你什么就往肚子里塞什么,以后一个个掏出来,都成了虎牌万金油,什么病都治,什么病都治不好,你就高兴了?”\n子升瞪着毛泽东,说:“什么叫我高兴了?学校有规矩,部颁有条例,这规矩、条例定出来,就是给人守的嘛。”\n“有时候,规矩定出来,也是给人破的。”\n“好好好,你破你破,反正跟你讲道理,永远也讲不清。”\n“你们俩呀,也不要争了。”\n旁边听着二人的唇枪舌剑,蔡和森仿佛思考清楚了什么,若有所思地说:“子升,其实仔细想想,润之说的,也不是全没道理。学习的目的,总不能光为了考试分数,数学不好,他以后可以不教数学,他教别的科目就是。所谓尺有所短,寸有所长嘛。”\n两个人说好了是来劝毛泽东的,这个时候见蔡和森这样说,子升火了:“你呀,和稀泥!”\n“我还没说完呢。话又说回来,润之,民国的教育才刚起步,学校的功课设计,的确不尽合理,但改变现实需要一个过程,规矩、条例也是客观存在,如果光凭热情和兴趣就想超越这个过程,什么规矩都不顾,我行我素,那也不现实啊。我知道,你的个性不是那种能被规矩框死了的人,可我们退一万步来想,分数毕竟还是决定升学和毕业的标准,你的成绩单,也要带回家去,给伯父、伯母过目。润之,你难道就忍心拿着一份几科不及格的成绩单回家,告诉你母亲,不是你学不好,是学校的规矩不对,所以你就是及不了格。那时候,你的母亲会怎么想?就算她不怪你,可她的心里,对你的学业,对你的前途又会产生多大的担忧?你就忍心让她为你着急吗?”\n毛泽东顿时沉默了。三个人坐在亭子里,各自想着心思。\n一直到晚饭后,毛泽东还在想着蔡和森最后说的那番话,他从枕头底下拿出那半片断裂的顶针,放在手心里。盯着母亲的顶针,毛泽东的目光中,有一丝内疚,有一丝思索,有一份牵挂,更有一份责任。不知不觉中,他收紧了拳头,顶针被他紧紧握在了手心。看看周围因为考完了试正在放松的同学,他拿了几本书,悄悄走了出去。因为有心事,在教室走廊上和王子鹏迎面错过的时候,连子鹏和他打招呼都没听到。\n子鹏盯着毛泽东的背影,直到毛泽东进了教室,知道他是去学习了,心里暗暗有些佩服。回到寝室里,子鹏看到周世钊他们四五个同学都围在桌子旁下象棋,参战的旁观的,正玩得来劲,不由得又想起了毛泽东,就在床头坐下,拿出书来看。过了一会,孔校长带着王立庵、费尔廉、黄澍涛、饶伯斯几位老师进来了。下棋的同学立刻就散开,站直了,向老师们问好,子鹏也赶紧从床上跳了下来。看到孔校长的目光落到了棋盘上,周世钊不好意思地解释:“今天刚考完,大家想轻松一下。”\n孔昭绶点点头,微笑着说:“哎,毛泽东呢?他不在寝室吗?”\n其他同学你看我我看你,这时候才发现毛泽东今天没和大家同乐。王子鹏一向不多言语,但此时见没人吭声,只好告诉校长,他刚才看见毛泽东往教室那边去了。\n孔昭绶点点头,一边叫学生们继续“战斗”,一边带着老师们去了八班教室。透过窗子,却见烛光下,毛泽东坐在课桌前,正在用圆规、尺子画着什么。他显然遇上了困难,左比右算,绞尽脑汁,冥思苦想。\n在半开的教室门口,孔昭绶与老师们交换了一个目光——他做了个噤声的手势,轻轻地、无声地把门推开了一些。\n聚精会神的毛泽东全未察觉,仍然埋头运算着。他的面前,是摊开的数学课本,还有零乱的、写满了运算过程的、画满了几何图形的草稿纸。\n一只手轻轻拿起了桌边的一张草稿纸。毛泽东一抬头,不由得一愣,赶紧起身:“校长,各位老师,找我有事吗?”\n老师们谁也没作声,只是相互交换了一下目光。数学老师王立庵突然拉过一张凳子,在他旁边坐了下来,“我找你来补习数学呀。有哪些地方不懂?说吧。”\n毛泽东一时还没反应过来。孔昭绶一拍他的肩膀:“老师都坐你身边了,还傻站着干什么?先补习。”\n他说完,转身就向门外走,似乎是突然想起什么,孔昭绶又回头说:“对了,润之,明天下了课,记得到我办公室来一趟。”\n五 # 第二天下了课,毛泽东到了校长室,忐忑不安地看着校长递过来的那叠成绩单。“那么紧张干什么?”孔昭绶突然笑了,“我说过要怪你了吗?十根手指没有一般长短,人不会十全十美,这个道理我也知道。”孔昭绶收起了笑容:“但是话要讲回来,润之,一个学生,对待功课过于随心所欲,绝不是什么好事。同样,一个学校,因材施教固然重要,但也绝不等于放任自流。”\n毛泽东感激地看着校长,认真地听着。“你的长处与短处,我相信你自己已经有所认识。我可以不强求你门门全优,好比音乐、美术这些需要特定天赋的功课,要你马上突飞猛进,本身也不现实。但有些功课,特别是数学、理化这些基础主科,是一个学生必须要掌握好的。就算你在这些功课上缺乏兴趣,也不可以轻言放弃。你明白吗?”\n“我明白,校长。”“那,愿不愿意跟你的校长达成一个约定?还有两周就要正式期末考试了,我不知道你能考出多少分,我也不要求你一定要考到多少分。我只要求一条:尽力——对你所欠缺的功课,你的确尽了全力,这就够了。能答应我吗?”\n“我答应您,校长。”“那我也答应你,只要你尽了力,你将得到一个意外的奖励。”孔昭绶他站起身,伸出手:“我们一言为定。”犹豫了一下,毛泽东伸出了手。校长与学生的手紧握在了一起。\n第二天的全校师生大会上,孔昭绶严肃地发表了他的《第一师范考评修正条例》:“各位先生:经过多方征求各科任课老师的意见,及报请省教育司批准,校务会决定,第一师范将改变过去单纯以考试评定学生优劣的做法。即日起,学生各科成绩,将由以下三部分组成后综合评定:其一,日常课堂问答、课外作业及实习能力占40%;其二,各科课内外笔记心得占20%;其三,考试成绩占40%,合计100%.做出这一修正,就是要改变以往一考定优劣、一考定前程的僵化体制,摆脱只讲形式的应试教育,将学生的考核融入整个学习过程中,全面地、科学地认识和评定我们的学生!”\n当晚,孔昭绶在《第一师范校长日志》上写道:“什么是真正的因材施教?怎样的教育,才是科学的、先进的、更利于培养真人才的呢?是一场考试定结果,还是别的什么?这确实是一个值得深深思考的问题。民国的新式教育刚刚起步,僵化守旧,唯分是举之弊,积淀甚深,从毛泽东这样有个性的学生身上,我们又能否探索出一种全新的人才观,使第一师范真正成为未来人才之摇篮,科学教育之殿堂呢?”\n随后两周所有同学都在忙碌之中。终于到了期末考试结束。这一天当成绩单汇总到校长办公室时,孔昭绶的心情一点也不亚于坐在他面前的毛泽东。他拿起成绩单,看了毛泽东一眼,肃然说:“你的理化成绩是——”毛泽东瞪大了眼看着他,孔昭绶的脸上露出微笑,“67分,及格了。”\n毛泽东顿时松了一口气。这时孔昭绶的笑容却突然又没了,“不过数学,可不如理化。”\n毛泽东一下子又紧张起来。“才只得了,”孔昭绶盯着毛泽东,脸上突然浮起笑容,“61,也及格了!”\n猛地一挥拳头,毛泽东往椅背上一仰,他长长地舒了一口气。“好了,现在,该我兑现承诺了。”孔昭绶放下成绩单,“我听昌济先生说过,你对船山学派的理论很感兴趣,是吗?”\n“是,校长。”\n孔昭绶笑道:“有个消息告诉你:湖南学界已经决定在小吴门重开船山学社,专门研讨王船山先生的学术思想和湖湘学派的经世之论。后天,学社就会开讲,以后,它将成为湖湘学术交流的中心。我看一师现在的课程好像也满足不了你这方面的需要,想不想要我帮你办一张听讲的入场证?”\n毛泽东喜出望外,“要,当然要!校长,能不能多办几张?子升和蔡和森他们对这方面也很感兴趣的。”\n孔昭绶笑道:“我试试看,应该不会有问题——怎么样,这,算不算我给了你个意外惊喜啊?”\n“算!算!我现在就去告诉他们!”毛泽东高兴得起身就要走。孔昭绶却叫住他,“等一下。”他从抽屉里取出一片钥匙,放在桌上。\n“这是?”毛泽东疑惑地看着他。\n“校阅览室的房门钥匙。我已经通知了管理员熊光楚,以后,他每天下班的时候会把灯加满油。你呢,就不要再在寝室里点蜡烛或者是跑到茶炉房去借光了,那里光线不好,坏眼睛。” 孔昭绶说道。\n望着面前的钥匙和孔昭绶和蔼的笑容,毛泽东一时真是无以言表,只说:“谢谢您了,校长。”\n"},{"id":128,"href":"/zh/docs/culture/%E6%81%B0%E5%90%8C%E5%AD%A6%E5%B0%91%E5%B9%B4/%E7%AC%AC21%E7%AB%A0-%E7%AC%AC25%E7%AB%A0/","title":"第21章-第25章","section":"恰同学少年","content":" 第二十一章 逆书大案 # 《护国浪潮席卷全国 袁逆世凯穷途末路》、《北洋将领全线倒戈 窃国大盗众叛亲离》、《袁世凯宣布取消帝制 恢复共和》、《袁逆心腹汤芗铭仓皇逃离湖南》、《湘军元老谭延闿再度督湘》……\n一 # 汤芗铭正要去参加拥戴洪宪皇帝登基大会,副官推门进了办公室,啪地立正,递上一份刚收到的广西、贵州通电。\n“说什么?”正展开手让卫兵扣扣子的汤芗铭显然不方便接电文,他今天穿上了肩章绶带、白旄高耸的华丽将军制服,两名卫兵正侍候着他扣上扣子,戴上雪白的手套。\n“贵州将军刘显世、广西将军陆荣廷通电全国,宣布反对帝制,支持护国军。”\n汤芗铭的手微微一震,抬手挡住了正要给他戴上帽子的卫兵。他伸手似乎是要来接那份电文,手伸到一半,却僵了一僵,又收回去了。拿起军帽,汤芗铭端正地戴上了,冷静地说:“去会场。”\n露天会场上,整齐的军乐队卖力地演奏着进行曲。鼓乐喧天中,“洪宪登基,三湘同庆”的横幅下,是披红挂彩的主席台。台下,一排排刺刀闪闪发亮、荷枪实弹的城防营士兵前后左右,几乎是包围了整个会场。刘俊卿带着几十个游动的侦缉队便衣,正监视着来自长沙各学校的数千师生入场。\n台上的欢天喜地与台下的一片冷漠、四周的如临大敌,构成了整个会场古怪的气氛。整齐的城防营士兵队列前,城防营营长张自忠穿一双锃亮的军靴正缓缓地踱着步子,冷漠地打量着眼前的一切。\n“一贞,”人丛中,刘俊卿看见了正在入场的一贞,兴奋地打着招呼, “我在当班,开完会等着我,我送你回去。”\n“哎。”一贞向他点了点头,答应着,追上了本校的队伍。\n纪墨鸿拿着白铁皮的喇叭,出现在台前:“各校注意了,庆祝大会马上开始,请各校代表速来领取洪宪大皇帝圣谕……”\n一师的队伍中,张昆弟悄悄接过了毛泽东递来的两卷红绸,与罗学瓒等人站了起来。\n看看主席台,张自忠随口往地上吐了口唾沫。\n主席台的一侧,成堆贴着“洪宪圣谕”标签的书箱堆放着,侦缉队的便衣正在纪墨鸿的指挥下向各学校领书的代表发放“圣谕”。书堆旁边,摆着两大捆鞭炮,和两卷卷好的红绸。靠着罗学瓒等人的身体掩护,张昆弟悄悄挨了过去,背着身子,取出了自己怀里暗藏的两卷红绸,调换了原来的两卷红绸。\n“让开让开。”两名便衣排开领书的人挤了过来,扒开张昆弟,一个抱起鞭炮,一个提起了红绸卷轴。在纪墨鸿的指挥下,两捆鞭炮与红绸对联在主席台两侧升了起来。\n台下,正走回一师学生方阵的张昆弟向毛泽东使了个成功的眼色。\n一箱箱“圣谕”搬到了一个个学校的师生们面前。\n一个个负责发书的老师带着压不住的厌恶和无奈,打开了一箱箱书,里面都是装得整整齐齐的《洪宪大皇帝圣谕》。\n一师学生方阵前,负责发书的陈章甫也打开了一箱书。\n“第六班、第七班……”他带着厌恶的神情,机械地取出成捆的书发给各班领书的代表。\n“第八班。”陈章甫又提起一捆书,正要交给来领书的周世钊和毛泽东,这捆书却没捆牢,哗啦散了一地\n陈章甫愣住了,散在地上的书,除了最上面一本“圣谕”,下面的居然全变成了《梁启超等先生论时局的主张》。\n看看他发愣的样子,毛泽东催促道:“章甫兄,发呀!”\n“发,继续发!第九班的谁来领?”陈章甫突然回过神来,懒洋洋的声音变得精神十足,拿书的动作也干净利落起来。\n一捆捆书打开了、一个个发书的老师都露出了惊讶的神情、一本本《梁启超等先生论时局的主张》传到了不同的学校、不同的学生手里,一张张意外、惊诧的脸很快都转成了兴奋,一个个发书的老师、学生都突然来了精神,游动监视的侦缉队便衣们看见这前后巨大的变化,都有些糊涂了。\n主席台上一阵骚动,原来是文武官员、各界代表们簇拥着汤芗铭到会了。汤芗铭殷勤地给陶会长抽出了椅子:“陶翁,今天可就辛苦你了。”似乎是想回应一个笑容,陶会长脸上却实在是掩饰不住的苦涩。\n台下的会场,嘈杂声却越来越大,人群兴奋,一片嗡嗡之声。台上的官员都有些糊涂了。汤芗铭也不由得皱起了眉头。副官看了一眼他的表情,连忙跑下台去。汤芗铭随即换上了笑脸,一手如往常一样轻松地把玩着玉手串:“陶翁,我看,可以开始了吧?”\n陶会长答应着站起身来,动作却犹犹豫豫,仿佛就要上刑场一样。\n台下一片混乱中,学生们的声音越来越大,到处是兴奋莫名的表情,几乎所有的人都在迫不及待地打开手里的书。刘俊卿奇怪地皱紧了眉头。他突然走上前去,拦住了一个正在发书的老师,抢过一本书来——他不由得呆住了,猛地把箱子里剩下的书往地上一倒,他一阵乱翻:所有的书都是《梁启超等先生论时局的主张》!\n副官正好跑到他面前,问他会场的秩序为什么这么乱,在学生的嘈杂声中,刘俊卿把书递到他面前。副官翻了翻书,转身往台上跑去。\n台上,陶会长终于艰难地站到了台前,开始主持大会。“拥戴……”刚说了两个字,他就觉得自己的嗓子很是干涩,使劲咳嗽了两声,这才又重新说,“拥戴洪宪皇帝登基庆祝大会,现在开始。”\n台下,两串鞭炮噼噼啪啪响了起来,与此同时,军乐队的鼓乐骤然大作。悬在鞭炮旁的对联同时放了下来。轰然一声,台下突然一片惊讶的声音,紧接着,惊讶声变成了一片笑声!台上,所有的官员们都愣住了。陶会长也被弄糊涂了,他不由得转过头来,往两边一看,放下的对联居然不是预先准备好的,而是一幅他从没见过的新联: “袁世凯千古,中华民国万岁”。纪墨鸿和大家一起在看,学者习惯,他没想那么多,只从字面分析着:“这‘袁世凯’对不起‘中华民国’呀?!”话才说完,他猛然反应过来,吓得一把捂住了自己的嘴。\n汤芗铭腾地站了起来,正要说什么,副官跑到了面前,将一本《梁启超等先生论时局的主张》双手呈送给他:“大帅,发给学生的圣谕被人换了,全部变成了这本逆书。”一手接过书,一手紧攥着那串手串,汤芗铭眼睛微微一眯四下扫视着:台下哄笑声、呼应声响成了一片,有学生正扯开嗓子喊“袁世凯对不起中华民国喽!”台两旁,长长的鞭炮还在起劲地炸着,仿佛是在给起哄嘲笑的学生们加油鼓劲。鞭炮燃到了尽头,最末那枚最大的鞭炮猛然炸响,“砰!”汤芗铭一向平和的脸色一阵发青,手骤然一紧,那串玉手串突然断了,一颗颗晶莹的珠子散落一地!他紧绷着脸,转身就走,台上的官员们也赶紧纷纷起身。\n台上,除了还忙着满地捡那串散珠子的纪墨鸿和副官,只剩了陶会长还呆呆站着。望着人群中闹得最起劲的毛泽东,再看看周南学生方阵中欢呼雀跃的斯咏、警予,他仿佛这才明白了什么,心里一下子轻松了,暗想这事情是谁带头做的呢?还没想出个头绪,更大的不安却又朝他袭来,他不敢想像,汤芗铭会如何处理这件事情。\n刘俊卿的想法却简单得多,他只想讨好汤芗铭。所以,一看到汤芗铭拂袖而去,他就立刻气急败坏地带着侦缉队的便衣们一拥而上,去抢夺那些让汤芗铭极度恼火的逆书。特务们把抢回来的书扔回书箱,其中一本落在了张自忠锃亮的军靴旁。张自忠弯腰捡起了那本书,仿佛无意识地随手翻着,转过身,悄悄把书塞进了口袋。\n人群中,赵一贞一动不动地站着,她眼前的喧嚣突然化成了一片无声的世界,只剩下了一支支挥舞的手枪、一张张特务凶恶的脸、无数双争来抢去的手、无数学生仇恨的目光……而这一切的中心,就是人群当中疯狂叫嚣着的刘俊卿。一贞的目光中,充满了恐惧和犹豫。\n成堆的书箱被搬回了侦缉队。乱成一堆的院子里,特务在一本本检查。一只未开封的书箱被撕开了,一箱子书哗啦倒在地上,“丁”的一声,一枚小小的校徽随着书跌落在地上。不等开箱的特务弯下腰,刘俊卿已经把校徽捡了起来。\n“第——一——师——范!”眯着眼睛盯着校徽,刘俊卿突然笑了,“我的老同学们,你们还真没让我猜错啊。”\n他把校徽往手心里一握,转身就往外走。迎面,一贞正站在门口。迎着一贞的目光,刘俊卿下意识地将握着校徽的手藏到了身后。\n犹豫过后的一贞,决定要用自己的办法阻止刘俊卿继续做那些让她感到恐惧的事情。她板着脸冲进队长室、冲到书架前,搬着架上的书。她的意思很明显,就是要刘俊卿马上离开这个肮脏的地方。刘俊卿明白她的意思,却在旁边说:“一贞,你这是干什么?不想让我干,也不用急着这一下吧?你这冲进来就收拾东西,我……我总还要个准备不是?”\n“准备?准备什么?准备去告密?去领赏?如果不是我正好来找你,你现在都已经到汤屠夫面前了,对不对?”\n“怎么能说是告密呢?我是管这个的,查到线索,我当然应该去报告。”\n“你还觉得当然?”\n“一贞,你听我说嘛。这个逆书案大帅非常重视,谁能破案,谁就马上连升三级。升三级啊!我知道你不想让我干这个破侦缉队长,抓住了这次机会,我不就可以不干了吗?”\n一贞望着刘俊卿,仿佛不认识眼前这个人一般:“我一直还以为,你以前做的那些事,都是被逼的,都是为了我,为了我们那份感情。今天我才知道,其实你全是为了自己,为了自己升官,为了自己发财!”\n“不是这样的……”\n“不是这样是什么样?为了升官,你连母校、过去的同学都打算出卖,你还有什么好说的?”\n“一贞!你怎么就不明白呢?我是个读书人,是个读书人啊,不找机会谋个体面的差使,难道我还真的拿把枪混一辈子吗?再说,我想换差使,也是为了好向你家求亲嘛?这回的事办完了,我进了教育司,就可以马上到你家去提亲,到时候,咱们不也风风光光……”\n“我不要这样的风光!我不要你与马疤子那样的流氓混在一起!我不要你出卖自己的同学,我不要你再干这些伤天害理的事!”眼泪蓦然滑出了一贞的眼眶,她颤抖着手,擦了一把泪,“俊卿,你知道吗?以前你干侦缉队,我还并没有觉得什么,我只当成那是你的差事,一个饭碗而已。可今天,我亲眼看到了,我看到你像疯了一样,带着那些特务抢学生的书,周围是那么多学生,那么多反抗,那么多人跟你们作对,那么多仇恨你们的眼睛,我当时好害怕,我真的好害怕呀!”流着泪,她一把抓住了刘俊卿的手:“俊卿,一个人,不能那么遭人恨,不能跟那么多人作对,不能啊!那么多双眼睛,那样仇恨地看着一个人,这个人一定不会有好下场,一定会有报应的,俊卿!我不想你遭报应,我不想啊!”\n刘俊卿呆住了。\n“答应我,俊卿,不要再干了,我不求你升官发财,我只要你平平安安,不再遭人恨,不再有那么多恨不得杀了你的眼睛盯着你,我就放心了。俊卿,你答应我呀!”\n望着一贞迫切的目光,刘俊卿轻轻为她擦去了眼泪,终于点了点头:“我答应你。”\n“你不会去告发了?”\n刘俊卿摇了摇头。\n“这个队长你也愿意辞掉?”\n刘俊卿点了点头。\n一贞盯着刘俊卿的眼睛:“你向我保证,你不会骗我。”\n“我保证,我保证可以了吧?”刘俊卿将一贞送出门来,“一贞,我还在当班,就不送你了。”\n刘俊卿望着一贞的背影消失在街拐角,久久地站立着,掏出口袋里那枚校徽,他犹豫着,总算下了决心,将校徽扔进了墙角。他转身走向办公室,刚走出几步,却又站住了。墙角里,那枚校徽映着阳光,闪闪发亮,亮得是那么充满诱惑。\n二 # “果然是这个毛泽东!”会后的陶家,陶会长颓然跌坐在沙发上,正在确证他的猜测。\n斯咏怯怯地在旁边说:“我们也只是不想看着汤芗铭倒行逆施,才想了这个主意。爸,对不起了。”\n“算了,事情不出也已经出了,你们本来也没做错什么。可有一句话我得告诉你,斯咏,毛泽东这个人,你是千万千万不能跟他来往了,我们陶家惹不起他这种祸害,你知不知道?”陶会长长长地叹了口气,心里还是最疼女儿。\n“谁说他是祸害?我觉得他是英雄!”\n“英雄我更惹不起!还是个学生,就敢把靖武将军、一等侯不放在眼里,以后他还了得?照这样下去,迟早连天都要被他捅出个窟窿来!斯咏,咱们是本分人家,咱们招惹不起这种惹是生非的祖宗,你明不明白?你不用说了,反正这个毛泽东,你绝不能再跟他有任何来往!他要翻天他去翻,他要找死他去死,我就是不能看着你被他连累进去!”\n他话音尚未落下,管家慌里慌张地跑进门来:“老爷,老爷,不好了……”\n不等管家的话说完,副官锃亮的皮靴已一步跨进了院门,后面是好几名枪兵!\n“汤大帅有令,传陶先生到将军府问话!”\n陶会长不想也知道,汤芗铭这是冲着印刷厂承印的书来的。但他能怎么说?他的确事先什么也不知道呀,可汤芗铭相信吗?\n“陶翁厂里印的书,陶翁居然不知道?”果然,汤芗铭听到陶会长这样解释,走到陶会长面前,弯下身子,说,“书是在陶翁厂里印的,直接从陶翁厂里运来的,一打开箱子就变成了逆书……这,可是要杀头的罪啊!”\n陶会长头上的冷汗已经历历可见,汤芗铭说得心平气和,似乎说的不是“杀头的罪”,而是在和陶会长讨论去什么地方出游。但说话的人和听话的人额头上,却都直冒冷汗。 汤芗铭掏出一方雪白的手帕,擦了一把自己额头上的汗,然后递给了陶会长。\n“哈哈……”看到陶会长接手帕的手微微有些发抖,汤芗铭笑了,“何必那么紧张呢?事情不是不可以商量嘛!”\n陶会长一听这话,知道还有生机,赶紧回答:“只要大帅为陶某做主,有什么条件,陶某任凭差遣。”\n汤芗铭又看了陶会长一眼,这才微笑着返回了自己的座位:“差遣不敢,可要说麻烦呢,眼下芗铭确实也不少啊。云南蔡锷的叛军已经打到湘西,南边吧,逆贼谭延闿、程潜的兵马也在蠢蠢欲动,芗铭为皇上坐镇一方,自当平逆报国,可我这手上,是要枪没枪,要饷没饷。兵马未动,粮草先行,这军火粮饷不济,还怎么打仗?陶翁,你说我难不难?”\n“大帅的意思是?”\n“50万大洋,这事就算了了。”\n陶会长惊得嘴都张大了:“50万?杀我的头我也拿不出啊,大帅!”\n汤芗铭用小刀修剪着指甲,看也不看陶会长,轻声细语:“陶翁长沙首富,后面还有那么大个长沙商会,这点钱真有这么难?”\n“商会力量薄弱,这些年生意也不好做。大帅,我是真的拿不出啊。”\n“40万。”\n“大帅,确实是难啊……”\n“30万。”“砰”的一声,汤芗铭把刀撂下了,抬起头来,“你当这是在买小菜啊,还要讨价还价?”\n“陶某不敢讨价还价,实在是数字太大,无力承担,求大帅再减减,无论如何再减减。”\n“那你觉得多少合适啊?”\n陶会长:“嗯,五万大洋,陶某还可勉力承担。”\n汤芗铭一言不发,盯得陶会长一阵阵发寒:“要不……要不……十万?”\n两个人在那里讨价还价,副官推开了门,纪墨鸿带着刘俊卿出现在门前,说:“卑职不敢惊扰大帅,确实是有紧急公务,那个逆书案有线索了。”\n刘俊卿唯唯诺诺地进来,把那枚校徽递给了汤芗铭之后,先看了看汤芗铭的表情,然后才咽了口唾沫,说:“以卑职所知,第一师范能干出这件事,也敢干出这件事的,就一个人。”\n“谁?”\n“本科第八班学生毛泽东!”\n“毛泽东?”汤芗铭看了刘俊卿一眼,“你那么肯定?”\n“这个人一向胆大妄为,目无王法,第一师范那些不老实、爱闹事的学生从来就以他为首。卑职保证,除了他,绝不会有别人。”\n汤芗铭微微点了点头:“来人哪!传令,逮捕第一师范学生毛泽东。”\n“大帅,”纪墨鸿却突然插了进来,“据卑职所知,这个毛泽东虽然只是一名学生,但在长沙各大学校中名气不小,颇有学生领袖的号召力,贸然抓这样一个学生,万一激起学潮……”\n“一个学生,至于吗?”\n“墨鸿也是为大帅考虑。上次抓一师孔昭绶之事,国内教育界至今仍沸沸扬扬,何况此次逆书案,并无证据证明与毛泽东有关。长沙学界目前正是人心不安之时,当此多事之秋,还是稳妥些,先抓住证据再动手的好。”\n仿佛是想起了什么,汤芗铭微微点了点头:“纪先生的话,也有道理,万一不是这个毛泽东,而是别的什么人背后捣鬼,岂不是放跑了真凶?”他转头吩咐副官,“传令城防营,协同侦缉队,搜查第一师范,务必查出逆书源头。一经查证,所有涉案叛逆,一律逮捕严办。”\n等副官、刘俊卿、纪墨鸿出了办公室。汤芗铭转过头来,微笑着叫了声:“陶翁。”\n陶会长仿佛突然被惊醒:“啊?哦,大帅。”\n“20万大洋就把陶翁吓成这样,不至于吧?要不,咱们再商量商量?”\n陶会长的目光微微向门口瞄了一下,似乎突然下了什么决心:“既然大帅开了口,20万就20万,陶某认了。”\n“哦?”汤芗铭倒没想到他突然爽快了,一拍桌子,“爽快!那我们就一言为定了。”\n陶会长站起身来:“陶某就先告辞了。”\n“哎,着什么急嘛?陶翁为皇上的千秋大业慷慨解囊,忠义可嘉,芗铭总要感谢一下,我这就叫人准备,晚上我做东,怎么样?”\n“不不不,陶某还要马上赶回去,召集商会成员,共商筹款大计,就不多耽误了。大帅吩咐的事,当然要马上办,要马上办。”\n陶会长一面说着,一面赔着笑,向门口退去。出了将军府,他火急火燎,一边上马车,一边不停地催促马夫赶紧走!马车飞驰在街道上。陶会长的手杖敲打着车沿,口里不住地催促着车夫再快点!长鞭脆响,马车拼命地跑着,但马车的速度还是令陶会长极为不满。正巧车子经过一条窄巷口,他敲打着车沿,喊道:“停下停下停下,快停车!怎么不走那边的近路?”\n“那边巷子太窄,车进不去啊。”\n“哎呀!”陶会长把手杖一甩,跳下车,撒腿就往小巷里跑。\n小巷那头,斯咏、警予、开慧正并肩走过来,斯咏的脸上,满是忧色。\n开慧正对斯咏说:“斯咏姐,你就放心吧,陶伯伯也是在气头上,你怕他还真能把你关起来,不让你和毛大哥见面啊?瞒着他不行了?咱们现在去一师范,马上就能见到毛大哥,陶伯伯不一样的不知道?”\n警予拍了开慧的脑袋一下:“你懂什么呀?斯咏担心的,不是这个。”\n三个人刚拐过街角,斯咏猛然一愣,正看到陶会长气喘吁吁迎面飞奔而来。三个人都被陶会长惊慌狼狈的样子吓了一跳,赶紧迎了上去。\n“快……快……”陶会长捂着胸口,身体摇晃着说,“第一师范……”\n斯咏和警予赶来报警的时候,毛泽东、蔡和森、张昆弟、罗学瓒、李维汉等正在寝室里清理剩下的179本书,打算明天后天加把劲,通通都发出去。听到斯咏带来的消息,他们还没来得及想对策,一阵凄厉的哨子声已经划破了校园的平静,侦缉队和城防营的人来了。\n一师门前,散乱的侦缉队与整齐划一的城防营正在会合。城防营整齐的小跑步在营副的号令下变为原地踏步。刘俊卿挥着手枪,冲着营副,心急火燎:“快快快,派一队人往左,一队往右,后面还有个侧门,赶紧包围!”\n营副根本没理他,继续整着队,士兵们的脚步戛然而止。\n“哎,你们怎么回事?”刘俊卿急了,“赶紧上啊!”\n挺立如林的城防营士兵们一个个充耳不闻,标准地执行着长官的口令。刘俊卿还在叫嚷着,营副看也没看他一眼,转身向另一边:“报告营长,城防营全体弟兄集合完毕!”\n刘俊卿发现张自忠骑在战马上正冷冷地望着他,赶紧换上了笑脸。张自忠盯着这张笑脸,直看得刘俊卿尴尬地低下了头,这才收回了目光,慢条斯理地下了马,打量着眼前第一师范的校牌,把手轻轻一挥:“围起来。”\n“是!”如炸雷一般的声音响过之后,两列士兵随即队列整齐、脚步划一、左右包抄而去。张自忠的治军之严,令刘俊卿望而生畏。\n三 # 教务室办公桌上,茶杯里的茶水突然荡起一阵阵涟漪,隐隐而来的脚步声是那样的震撼,仿佛正要吞噬这书香世界的平静。杨昌济、方维夏、袁吉六、饶伯斯、黄树涛、陈章甫……一个个老师疑惑地站起身来,推开了窗户:校门外,无数把刺刀反射着阳光,刺得老师们眼前一花!他们身后,门嘭地被推开,刚刚和斯咏、警予兵分两路的开慧飞快地跑进来,气喘吁吁地问:“我爸呢?”\n八班寝室里,大家还在商量着怎么样才能不让士兵们发现那些书。他们想扛出去,可来不及了,学校已经被包围!他们想藏在学校里,可一听说刘俊卿也来了,便知道藏也是白藏,还会连累全校同学。\n“那……那怎么办呢?”\n一时间,大家面面相觑,都已不知如何是好。\n一咬牙,毛泽东重新抱起箱子,哗啦一下,将满满一箱书倒在了自己床上:“大家都把书往我床上堆,堆不了的塞床下,都记住,这件事是我毛泽东一个人干的,你们谁也不知道!”\n“不!”\n斯咏急得扑了上来,一把抓住了毛泽东的胳膊:“润之,不能这样啊!”\n情急之下,她的声音都急得变了调!\n“不就是命一条,什么大不了的?大家都赶紧走,这里的事,我来对付。”\n“你真的要一个人留下?”斯咏使劲擦了一把泪,猛地抱起了一迭书,说,“那好,我跟你一起留。要死,我陪你一起死!”\n望着斯咏坚定的眼睛,毛泽东不由得愣住了:头一次,他在斯咏的目光中,仿佛读出某种从未感受到的东西。正当所有人的目光都集中在他们两人的身上时,一阵急促而杂乱的脚步声由远而近,开慧带着老师们出现在了寝室门口……\n一师门口,张自忠看着夕阳下一师古朴凝重的校牌和典雅庄重的教学楼,如同在欣赏一幅名家笔下的油画。\n“张营长,你到底要走到什么时候?咱们得赶紧动手呀!我可告诉你,我就是这所学校出来的,里面的校园大得很,再不动手,他们把证据一藏,要搜就难了!”\n张自忠转过身,看了看身后与眼前这书香世界格格不入的刀枪,淡淡地说:“搜查母校这种事,还是刘队长自己来干吧。我城防营接到的命令,是协同侦缉队办差,既是协同,当然以刘队长为主。这校门以外的包围警戒,我城防营还是会协同好的。”\n“好,这可是你说的,张营长,别怪我没提醒你,这校门外要是漏了口子,可得你担着!”刘俊卿狠狠点了点头,转身他一马当先,带领侦缉队特务们就往学校里冲去。\n“干什么?怎么走路的?眼睛长到屁股上了?”在教学楼的转弯处,一个熟悉的声音劈头盖脸地在刘俊卿面前响了起来。刘俊卿一抬头,发现正恶狠狠地瞪着他的,是袁吉六那双鼓凸的金鱼眼睛。\n仿佛又回到了过去的学生时代、仿佛又变成了过去那个胆怯的一师学生,面对自己向来最恐惧的老师,刘俊卿不由自主地倒退了一步。\n“混账东西,一边去!”袁吉六挟着包,昂着头,带着身后的老师就要走出教学楼。\n“上哪去上哪去?都站住都站住……”一名便衣拔出了手枪,一把将走上前的费尔廉推了个踉跄,“你给我站住!”\n“你居然动手打我!”费尔廉迎着枪口逼了上来,“我要向贵国政府抗议,抗议你们无故殴打一名德国公民,你要为你的行为付出代价!”\n“对不起对不起,我……我没看出来……对不起对不起。”推过之后,便衣这才发现这个穿着长衫、布鞋,戴着瓜皮小帽的,居然是个金发碧眼的洋人,一时手足无措,吓得直往后缩。\n饶伯斯也嚷嚷着帮腔:“我是美国侨民,我不准你们妨碍我的自由,赶紧让开!”\n“刘俊卿,这是怎么回事?”方维夏问。\n“我、我奉大帅之命,前来搜查违禁逆书。”\n“搜查?搜谁?搜老夫吗?”袁吉六恶狠狠地逼了上来,“你是想搜我袁某人的包,还是搜我袁某人的身啊?”\n刘俊卿被他逼得直往后退,他似乎自己也不明白为什么还是害怕这个老师,但这害怕却习惯成自然,令他怎么也无法鼓起勇气。\n“刘俊卿,你是不是想把我们这些老师都当成窝藏逆书的犯人啊?”\n“连老师都不认了,你眼里还有没有人伦纲常?”\n“还不给我滚开!”\n在老师们的质问声中,刘俊卿禁不住倒退出几步,便衣们一时没了主心骨。袁吉六、费尔廉、饶伯斯一马当先,其他老师纷纷跟着,拥出校门。\n“外头不有城防营吗?出了门就是他们的事。都傻站着干嘛?跟我上学生寝室!”刘俊卿眼睛一瞪,拼命提高嗓门掩饰着自己的尴尬,便衣们跟着他匆匆向校园内走去。\n校门外,是林立的刺刀,老师们各自挟着包,从刺刀丛里走过。落在后面的黄澍涛紧张得满头冷汗,眼前的阵势令他连头也不敢稍抬一下,只有拼命保持着镇定,但挟着包的手臂却还是止不住在微微发抖。就在这时,一匹战马突然嘶鸣了一声,黄澍涛吓得一抖,臂弯间的包失手落在地上,“砰”的一声,包裂开了,一本《梁启超等先生论时局的主张》滑出了包,正落在张自忠锃亮的军靴旁。\n黄澍涛整个人都僵住了,身边的老师们也目瞪口呆。空气紧张得似乎要凝固了,张自忠却慢慢弯下腰,不紧不慢地捡起了地上的包和书,微笑着将包递给黄澍涛:“这位先生,您的东西。”\n黄澍涛赶紧接过,连声说着谢谢。\n张自忠又翻了翻手里的书:“哟,这是什么书啊?”\n“是……教材,是教材。”\n“哦,这人不识字还真麻烦啊,这么好的教材,我这大老粗偏偏连个书名都不认识。”张自忠将书递向黄澍涛,扫了眼还站在原处的老师们,又说,“教书呢,就得教给学生这样的好书,可千万别教什么逆书、反书啊。各位先生,都别站在这儿了妨碍我们执行公务了,请吧请吧。”\n老师们这才松了口气,大家纷纷离去。走出几步,黄澍涛回过头来,一字一句地、很书生气地说:“这位长官,谢谢您了。”\n“不客气。好走了您。”张自忠转过身,又慢条斯理地踱起了步子。这位张自忠,后任国民政府天津、北平市长,第三十三集团军总司令等职,抗战爆发后,率部浴血奋战,屡挫日寇,参加了台儿庄大捷等一系列重大战役。1940年5月,在枣宜战役中,因率部阻击数十倍于己的日寇,壮烈牺牲于抗日战场,被国民政府追授为陆军一级上将。\n四 # 汤芗铭办公室,副官和刘俊卿正排着队向汤芗铭汇报请示事情。香烟袅袅,跳动的烛光映得汤芗铭脸上阴晴不定,他正一颗一颗、聚精会神地穿那串散了的玉手串。\n“大帅,前线急报,护国军程潜部已攻占湘西,逼近常德府。”\n“大帅,广东将军龙济光,江西将军李纯,山东将军靳云鹏,浙江将军朱瑞,长江巡阅使张勋五将军通电全国,反对帝制。”\n“大帅,日本国公使宣布,日本国不再支持中国实行帝制。”\n“大帅,四川将军陈宦刚刚发来通电,敦促洪宪皇帝退位。”\n“大帅,衡阳急电,我军防线已被谭延闿部击溃,谭部人马正进逼耒阳。”\n汤芗铭似乎没有听到副官的报告,只是专心穿着珠子,脸上全无半分表情。\n房间里好安静,安静得可以听到丝线从珠子中间穿过去的声音。刘俊卿看了副官一眼,怯生生地说:“大帅,卑职在第一师范未能搜查到逆书,估计是被毛泽东他们藏起来了,卑职建议,马上把他们抓起来,严加审讯,一定能问出逆书的下落……”\n轻轻地,汤芗铭打断了他的报告:“滚。”\n刘俊卿一愣。\n猛然间,汤芗铭站起,转过头来,声嘶力竭地吼道:“滚!”\n旁边副官被吓得浑身一抖。刘俊卿更是吓得魂飞魄散,撒腿便往外跑。\n汤芗铭颓然跌坐在椅子上,两手死死撑着桌沿,仿佛一只斗败的公鸡:“通电全国,湖南布政使、督办湖南军务将军汤芗铭宣布支持护国,反对帝制,声讨逆贼袁世凯。要快!”他的拳头气急败坏地砸在桌上。那串尚未穿好的玉手串又一次四散而飞。\n1916年3月的黄历一天一天翻过,长沙街头卖报的小童每天手里的报纸上都有爆炸性的新闻:《护国浪潮席卷全国 袁逆世凯穷途末路》、《北洋将领全线倒戈 窃国大盗众叛亲离》、《袁世凯宣布取消帝制 恢复共和》、《袁逆心腹汤芗铭仓皇逃离湖南》、《湘军元老谭延闿再度督湘》……\n第二十二章 文明其精神 野蛮其体魄 # 我最佩服的,是古希腊的斯巴达人,\n人数那么少,却能称霸希腊。\n为什么?\n因为他们不仅重视精神之文明,\n更崇尚野蛮之体魄!\n一 # 自古秋后都是处决犯人的季节。赵一贞看过很多话本、看过很多故事书,却从来没有一个时候比现在更让她害怕其中的那句“秋后问斩”。夏天不过刚刚过去,她走在街上还穿着单衫的行人中间,却感到了说不出的凉意。头顶的树叶绿中已经泛了黄、路边的小草青中已经带了焦,一切的生命、一切的情感,似乎都已经走过了它最旺盛的季节,正在渐渐地枯萎。但赵一贞不甘心,她不甘心眼睁睁地看着比生命还珍贵的东西枯萎。走在去监狱的路上,她有满腔的不甘心,但却不知道自己能怎么做;她不知道自己能怎么做,但却愈发地不甘心。\n袁世凯被推翻了、汤芗铭被赶跑了,新一轮的清算又开始了。长沙城里每天都有汤党余逆被抓,曾经威风八面的侦缉队队长,怎么可能漏网呢?自刘俊卿被抓以后,一贞就疯狂地四处打听刘俊卿的下落:刘俊卿被关到了哪座监狱、刘俊卿会被怎么处罚、刘俊卿已经知道错了吗……终于打听到了刘俊卿的确切消息,她又拿出自己所有的私房钱买通了狱警,只想着无论如何要见刘俊卿一面。\n隔着粗大的铁栏杆,一贞看到一个头发蓬乱、胡子拉碴、目光呆滞、浑身上下满是伤痕的人蜷缩在破草席上,她走过去,轻轻叫了一声:“俊卿!”\n似乎已经被打傻了的刘俊卿没有想到还有人会来这里看望自己,更没有想到一贞会来这里看望自己,他睁开血肉模糊的眼睛,抬头望着、望着……突然扑了过来,死死地抓住栏杆,砰砰有声地用头撞着,声嘶力竭地大叫:“一贞,一贞,我当初为什么不听你的?为什么不听你的啊?我怎么那么蠢,那么蠢啊!”\n看着眼前这个人,一贞的心疼着,心疼得甚至让她忘记了恐惧:这就是那个头发一丝不乱、一袭月白长衫、皮鞋锃亮的刘俊卿吗?这就是那个给她翻译“哪个少女不怀春、哪个少年不多情”的刘俊卿吗?这就是那个发誓要找一个体面的工作让她过上快乐日子的刘俊卿吗?是的,是的,这就是那个刘俊卿,是她的刘俊卿。尽管他面目全非,尽管在世人的眼里他早已经不是当年那个以第六名的成绩考进一师的学生,但他在一贞的眼里和心里,却永远不会改变。一贞抓住刘俊卿的手,紧紧地抓着,急促地说:“俊卿,不要这样,你会没事的,你一定要挺住,要挺住啊。”\n“没用的,一贞,我完了,我没指望了。你知道吗?这儿天天都在杀人,天天在杀,杀汤党余逆,拖出去就是一枪,就是一枪……”\n仿佛是为了印证刘俊卿的恐惧,走廊的尽头猝然响起一阵惊恐万状的狂叫声:“不,不要,我不是汤党,我不是汤党,我支持民国,民国万岁,民国万岁,我支持民国啊……”绝望的呼号迅速远去,紧接着,随着枪声,那个声音戛然而止!枪声中,一贞感觉到刘俊卿的手松了、随着他如烂泥一样的身体滑落下去,落到了血迹斑斑的枯草上。\n“一贞,以前,我答应过你许多事,答应过到你家提亲,答应过给你一个幸福的将来,这一切,我都做不到了,是我对不起你。你走吧,就当从来没认识过我,就当这世上从来没有过刘俊卿,你走吧,不要再来了。”\n望着刘俊卿,听着他绝望的声音,一贞苍白的脸色变得铁青,她用从未有过的坚定语调对刘俊卿说:“不,你不会死的,我一定会想办法,救你出去。你等着。”\n刘俊卿看着一贞毅然决然地转身离去,不相信她真的能救自己:一个无钱也无势的纤弱女子,她能有什么办法来搭救一个几乎是判了死刑的人呢?\n是的,一贞是一个无钱也无势的纤弱女子,她现在唯一可以自己支配的,只有她的生命。\n从监狱回来后,一贞突然说她同意嫁给老六了,这让赵老板喜出望外,他相信女儿也是想过富足日子的、相信女儿已经对那个没有出息的刘俊卿死心了。望着满桌子的绸缎、光洋和那封大红的婚书,他殷勤地给坐在一旁的老六递着烟:“这孩子吧,就是糊涂,你说要真跟了那个刘俊卿,这会儿受罪的还不是自己?现在好了,她也算是明白过来了,还是跟着六哥好。”\n一贞呆呆地坐在一旁,看到老六一直嘿嘿傻笑着、眼睛一眨不眨盯着自己,她木然地说:“六哥,这门亲事我有个条件。”\n“你说你说,不管什么条件,我都答应你。我要是办不到,还有我马大哥,有他在,长沙城就没有办不成的事!”\n“那就好。”\n老六同意了她的条件之后,一贞回学校去默默地收拾着东西:课本、笔记、作业、心爱的小饰物、周南女中的校徽……所有的书都已收拾好了,一贞最后拿起了那本《少年维特之烦恼》。书的扉页中,夹的是刘俊卿译得工工整整的那首卷首诗。一贞看着,眼泪忍不住滑出了眼眶,她悄悄擦了擦,将这本书单独收了起来。离开学校,一贞直接乘人力车来到王子鹏家,把那本《少年维特之烦恼》转给了秀秀。\n几天后,刘俊卿突然被释放了。\n一步迈进久违的阳光中,刘俊卿被刺得直眯眼睛。好一阵,他终于适应了光线,却看到秀秀和子鹏就站在前面不远处。\n回到他们虽然简陋但却还能遮风挡雨的家里,刘俊卿换下那身肮脏的破衣裳,吃着妹妹给他煮的面条。子鹏把一叠银元放在了刘俊卿面前,说:“我也帮不上你什么,俊卿,这些钱你拿着,找个事做也好,做点小生意也行……”\n刘俊卿把钱推了回来:“我不要。”\n“那你还想干什么?你还想去折腾?你说你折腾来折腾去,结果又怎么样……”\n“阿秀!”子鹏示意秀秀别再往下说,回过头来说,“俊卿,我们只是不想看着你像原来那样过下去,经过这么多事,我想你也应该明白了,一个人,就得老老实实过日子,踏踏实实做人。只要你想清楚了,现在重新开始,也不算晚,你说是吗?”\n刘俊卿长长地叹了口气:“我还能重新开始?”\n秀秀把那本《少年维特之烦恼》递到了他面前,刘俊卿呆了一呆,猛地一把抢过书:“这是哪来的?阿秀,你快说,这是哪来的?”\n“是一位赵小姐让我转交给你的。”\n“她跟你还说了什么?她还说了什么?你快说呀!”\n“她只说了一句,希望你出来以后,把她忘了,重新开始。”\n死死地握着书,刘俊卿一时还不曾反应过来。\n“哥,我听说,那位赵小姐今天出嫁。”\n刘俊卿惊得目瞪口呆!听着远处传来的隐隐的鞭炮声,他放下碗筷,撒腿就往外跑。\n那条他和一贞曾经手拉手走过的街道上,正鞭炮齐鸣,彩纸纷飞,唢呐、喇叭滴滴答答,迎亲的队伍浩浩荡荡,一派喜庆热闹。老六披红挂彩,骑在打头的马上,笑得嘴都合不拢。八抬大花轿旁,陈四姑屁颠屁颠地跟着。喧天鼓乐中,纷飞的彩纸飘飘洒洒,落在花轿上。轿帘偶尔掀动,但没有人注意到红彤彤的轿内,新娘凤冠霞帔,一身大红嫁衣,头上盖着同样鲜红的盖头。轻轻拉掉了盖头,露出的却是一张苍白绝望的脸。喜庆的鼓乐声中,新娘的手悄悄从怀里抽出,手里,是一把锋利的剪刀。剪刀挥动之后,一滴滴眼泪无声地滑过了她的面颊,一滴滴鲜血无声地浸透了她的大红嫁衣,落在花轿经过的路面上……\n但没有人留意。\n吹鼓手鼓着腮帮子,卖力地吹着喇叭;老六露着缺了两颗门牙的嘴,一路抱拳,嘿嘿傻笑;纷纷扬扬的彩纸在空中没有目的地飞扬、飞扬、落下来,落在了殷红的鲜血上,让人分不清那红色到底是纸的颜色还是鲜血的颜色。\n花轿已经远去了,飞奔而来的刘俊卿突然失足,摔倒在地,手里的书也脱了手。\n“一贞!一贞!”\n仿佛感觉到了什么不祥,他捡起书,书上,竟沾满了鲜血。他这才发现,地上是一路鲜血和带血的杂乱脚印!\n“一贞!”\n风卷着花花绿绿的纸屑,和着刘俊卿声嘶力竭的狂呼久久地在小街上空回旋。\n拖着麻木的双脚回到家,刘俊卿坐在火盆前,机械地撕扯着手里的书。跳动的火光映照着刘俊卿呆若木鸡的脸,那张脸上,没有泪光,甚至没有任何表情。火光熊熊,吞噬着一本本他珍藏的课本、书籍,仿佛也正吞噬着他久久珍藏的理想与梦幻。最后,他拿起了那本《少年维特之烦恼》,轻轻地抚摸着、轻轻地吻着,他的手一松,书落进了熊熊烈焰之中。\n那首承载着他与一贞所有情感的诗,在火焰中扭曲着,熊熊火焰映照着刘俊卿死灰一样的脸。纸灰飘逝,他一颗一颗扣好长衫的扣子,将头发梳理得整整齐齐, 走进了三堂会。\n二 # 酷暑过去,长沙城里渐渐飘起了越来越浓的桂花香,走街串巷的手艺人卖着担子里似乎几百年都没有什么变化的小玩意,也附带炫耀着他们从父辈那里复制而来的嘹亮嗓音。此起彼伏的吆喝声如湘江亘古不变的水声韵味悠长,让长沙人在梦醒的一瞬间、在回头的一刹那、在捧着茶碗拿起烟袋打开窗户的那一刻,想起自己经过的事和经过的人。\n因为反袁而不得不二度留学日本的一师原校长孔昭绶,归来时依然是一乘三人轿、依然是一身马褂长衫布鞋。穿过这最能撩起人心底乡愁的声音,他回到了一师,在校门口,轻轻地抚着一师的校牌,他的手指竟禁不住有些微微颤抖,那是久违后难以抑制的激动。有学生远远地看见了他,开始还不敢相信自己的眼睛,仔细看了、确认了,随即兴奋地奔跑着呼叫着:“校长回来了!校长回来了!”\n喊声回荡在楼道、走廊,回荡在整个学校的上空。钟声响起,惊喜的一师师生涌向了礼堂,他们要在校长当年离开的地方,接校长回来。\n“同学们,风风雨雨,我们,又在一起了!”\n百感交集的孔昭绶又站在了讲台上,他才一开口,台下便掌声雷动。孔昭绶的眼睛湿润了,他摘下眼镜,擦了擦眼角,梳理着离开一师的这些日子他的所有感想,然后重新戴好眼镜:\n“这一年多来,我们经历了许许多多,也思考了许许多多,过往的一切,千言万语,都不必多说。如果要说,我们就说一件事,那就是,第一师范的未来,我们应该怎样开创!是啊,一个学生应该怎样学习,一个老师应该怎样教书,一所学校应该怎样办好,一个民族、一个国家应该怎样振兴,这些问题,我们都曾经一而再,再而三的思考过、讨论过。中国的读书人,从来就不缺少坐而论道的能力,哪怕是天大的难事,我们也个个可以讲出一火车的道理来。可这一年多的思考,却让我明白了一个道理,这个道理就是:光讲道理是没有用的。天下兴亡,匹夫有责,这个匹夫,不是除你以外的别的中国人,而首先就是你自己!中国的事,盼着别人来做是不行的,从我开始,从现在开始,实实在在做实事,这才是我们这一代读书人的责任,才是我们这一代读书人崭新的精神!我宣布,从今天开始,第一师范将实行一项新的治校原则,一项新的教育观念:学生自治!”\n按部就班地机械灌输,连传统的“师”都算不上。一个优秀的教育家和一个普通校长的区别,就在于他是不是能让一所学校充满欣欣向荣的生机。经过了“驱张”、“反袁”之后的一师,如同一潭蓄势的山水,急需一条冲出峡谷的水道。而孔昭绶的“学生自治”来的恰是时候,它如清风般吹来,驱走了一师先前的沉闷和困惑,所到之处,叶为之舒展、花为之绽放、水为之流畅、生命为之鲜活。\n既然要搞学生自治,就要成立学友会事务室,就要选举产生学友会的“领导”。于是张贴《第一师范学友会竞选公告》、 开展学友会竞选演讲、全校同学排队投票……一师学子们青春的旗帜在这个金色的秋天,如同一师的校旗一样,迎风招展。\n学友会正式成立了,在专门的学友会事务室里,兼职会长孔昭绶和新当选的学友会全体成员围坐一堂,畅谈学友会将如何具体开展活动。周世钊、李维汉、萧三、张昆弟、罗学瓒、易礼容、毛泽东……环顾一张张意气风发写满希望的笑脸,孔昭绶微笑着说:“在座各位都是全校同学投票选举出来的学友会成员,第一师范的学生自治,应该怎样开展,就请大家谈谈想法吧。”\n“我觉得,学友会的工作,首要的是提高同学们的学习兴趣。我建议,根据现有的各科教学,成立相应的学生兴趣小组。比方说,有很多同学对文学就很感兴趣,如果成立一个文学兴趣小组,肯定会有不少同学参加。”\n“不光文学,手工、音乐、图画都可以成立嘛,这些内容,大家都会感兴趣的。”\n“我觉得外语更重要,如果成立一个英语兴趣组,一个日语兴趣组,对同学们提高外语水平,肯定有很大的帮助。”\n“我还有一个建议:办一个学友会资料室。学校现有的阅览室,有关时事、社会的报纸、杂志太少,像《新青年》、《东方红》、《太平洋》、《科学》、《旅欧杂志》、《教育周报》这些思想和观点新潮、激进的杂志,如果我们利用学友会的活动经费订齐全,一定能方便大家阅读。”\n“不光是订外面的,本校同学在学术和学业方面取得的优秀成绩,也可以在这个资料室公开陈列、展览,作为我们的成果,永久保留嘛。”\n“还有还有,一师的许多毕业生对母校感情都深得很,学友会可以定期组织老校友联谊活动,发动毕业校友支持在校学生的课外活动嘛。”\n……\n孔昭绶发现,在同学们热烈的讨论发言中,新当选的总务毛泽东却静静地坐在一旁。这个平素总是唱主角的毛泽东,今天为什么还没开过口呢?孔昭绶等待着他的爆发。\n果然,在大家都谈了自己的想法之后,仿佛才从回忆里走出来,毛泽东用与会场的热烈不那么协调的声音说:“大家刚才的提议,都非常好,我也很赞同。可刚才坐在这儿,听着大家的讨论,不晓得怎么,我却突然想起了一个人,一个同学,一个已经离我们而去了的同学。那就是永畦。真的,我经常想起永畦,早上起床,看见他空着的床,走进教室,看见他空着的座位,还有经过食堂,经过操场……好多次睡觉,我都梦见他,那么……那么腼腆地对我笑着,好像就要跟我说什么话,可又听不见他的声音,就是听不见……”\n他的声音哽咽了。\n“永畦的为人,是那么善良,永畦的成绩,也那么优秀,可他就有一个毛病,身体太差,稍微有点风雨,第一个感冒的,肯定是他。我记得那时候,我们打球、跑步、游泳、爬山,我也经常叫他一块去,可他……现在想起来,当初要是逼着他多锻炼锻炼身体,也许就不会发生这样的悲剧了。不仅仅是一个永畦,自古以来,中国的教育,可谓从来就没把体育放在眼里,颜回、贾谊、王勃、卢照邻,这些古人的才华还不惊人吗?可他们短命啊!于是只给历史留下一页页遗憾。没有健康的身体,你学得再多,学问再大,命都保不住,又有什么用呢?”\n满屋的同学,包括孔昭绶,都被毛泽东的话深深打动了,静静地看着他、听他说。\n“我最佩服的,是古希腊的斯巴达人,人数那么少,却能称霸希腊。为什么?因为他们不仅重视精神之文明,更崇尚野蛮之体魄!反观我今日之中国,身体羸弱者比比皆是,学校里,学生啃书本,老师教书本,家长更是一双眼睛只盯着孩子的书本,一国之青年都病怏怏的,这样下去,别人凭什么不把我们当成东亚病夫?国家的强大、武力的振兴又靠什么来保证?中国的未来,需要我们青年,青年的未来,需要野蛮强健的身体。所以,我的考虑是,学友会第一步的工作,当以全校的体育锻炼为中心,要让我们的同学,文明其精神,野蛮其体魄!”\n一片静默中,孔昭绶突然带头鼓起掌来。掌声随即响成了一片。\n“文明其精神,野蛮其体魄”,一师学友会把学校里的各项活动搞得有声有色,“武术组”、“架梁组”、“庭球组”、“竞技组”……都是同学们参加的热门,不过最热闹的,还要数毛泽东当守门员的蹴球队,他们聘请了年轻的德籍音乐教师费尔廉来做教练。有对手才有提高,经过一段时间的厉兵秣马,经学友会出面联系,一师的蹴球队和长郡中学的蹴球队在一个周末来了一场友谊赛。\n比赛是在长郡中学的简易蹴球场里进行的。长郡中学由罗章龙领队,一师由萧三、张昆弟领头。虽说是长郡中学的主场,可一师来的人比长郡本校来看球的还多,费尔廉这位教练就不说了,他正忙着布兵排阵呢,其他的,不仅校长孔昭绶带着杨昌济等老师来了,蔡和森带着拉拉队来了,萧子升从楚怡小学赶来了,斯咏、警予、蔡畅和开慧她们也来了。\n这次比赛的前两天,毛泽东去过一趟斯咏家。因为之前父亲曾经以五千元的代价要求张干校长开除毛泽东,所以斯咏怎么也没想到父亲会在千钧一发的时候跑去一师报信救毛泽东,更没想到事后毛泽东会来她家表示感谢。这一次在陶家的见面,让她陡然觉得自己和毛泽东之间的距离是那么近、也觉得父亲其实并不像她想像的那样讨厌毛泽东。“我救人,凭的只是良心,我觉得他了不起,也不等于认可你跟他交往。就算你可以不考虑我的看法,你也不应该忘记,你是定了亲的人,一个订了亲的女孩,跟别的男人,是不可能有将来的,这一点,不用我再提醒你了吧?”不过,想起在毛泽东走后父亲说的这番话,斯咏的情绪又一落千丈了。\n“笛!”随着裁判一声哨响,足球被一脚开出,场上的运动员跑起来了,看台上,孔昭绶、杨昌济等老师紧张地观看着比赛,旁边两个学校的拉拉队开始敲锣打鼓、呐喊助威。开慧冲在拉拉队最前面,扯起嗓子喊着“一师,加油!一师,加油!”小脸兴奋得通红,指挥着一师的男生们喊着号子。\n斯咏和子升、警予、蔡畅坐在一师的拉拉队前看球,但她的目光总也离不开一师队的球门,那里担任守门员的毛泽东张着双手,正全神贯注守着门。经过几个回合的无功拼抢,实力胜过一师队的长郡队此时正攻势更猛,猛然间,罗章龙突破防线,一脚劲射,球直飞网角——呐喊声骤然静了下来,所有人的心都悬起来了。说时迟,那时快,毛泽东一个飞身鱼跃,漂亮地扑住了这个球!叫好声惊雷般响了起来。看台上的孔昭绶与杨昌济长出了一口气,孔昭绶不禁擦了一把冷汗。斯咏同样松了一口气,手一抹,才发现自己也给吓出了一头的冷汗。子升把一块雪白的手帕递过来,斯咏擦了汗,把手帕递还子升,目光却又投向了毛泽东。\n球场上,一师队趁机反击,攻入一球。失球的长郡中学队攻势如潮,连续射门,毛泽东左腾右扑,一个个险球被他奇迹般地接连扑住!开慧高兴得都快疯了,冲到场边带着拉拉队狂喊:“毛泽东,加油!毛泽东,加油!”\n一场激烈的比赛最终因为一师队有一个无敌的守门员而以弱胜强。得胜归来的一师球队捧着锦旗,兴高采烈地班师回朝。孔昭绶拍着毛泽东的肩膀,兴奋得合不拢嘴:“那么多次射门,一个也没让他们射进去,润之,你好样的!”\n“那还用说?我们润之大哥,长沙有名的铁大门!”开慧攀着毛泽东的肩膀,一脸的得意。\n斯咏看到毛泽东满头的汗,接过张昆弟手里的毛巾,赶上两步,可没等她把毛巾递到毛泽东面前,开慧已经顺手用衣袖给毛泽东擦起了汗。斯咏收回毛巾,突然发现警予正看着她,不由得悄悄扭开了头。\n和一师的师生分路之后,斯咏跟警予不约而同地说起去看一贞。一贞的所有作为都是为了爱,她们又何尝不是?残阳如血,映红了黄昏的天际,血色余晖洒在一贞的新坟上,使这座新坟看起来像燃烧着的火焰。\n爱情真的比生命更重要吗?\n两人正想着各自的心思,身后传来一阵细碎的脚步声,回头去看,却是子鹏陪着秀秀来上坟。\n斯咏看了看子鹏,子鹏也正巧看了看斯咏,面对着刚刚被一门不情愿的婚事夺去了生命的一贞,这两个同样身不由己的人虽然相顾无言,目光中却已经交换了千言万语。\n三 # 上次一师和长郡的比赛结束后,杨昌济在带着开慧回家之前,给毛泽东布置了一个任务:考虑到毛泽东这段时间在一师推广体育锻炼搞得很好,杨昌济鼓励他写篇论文,把对体育运动的看法、心得总结一下。毛泽东兴奋不已,当时就保证两天交卷。\n礼拜二下午放学后,开慧出了周南女中就坐着黄包车一路飞快地到了一师,在学友会事务室找到了毛泽东,要先睹他的新文章为快。进了屋,却发现毛泽东并没有写文章,而是一边数着 “一、二、三、四,五、六、七、八”,一边手脚并用、蹦蹦跳跳,便问毛泽东在做什么。毛泽东告诉她,这是他发明的“毛氏六段操” ,这套体操,综合了手、足、头、躯干、拳击、跳跃六种运动,而且融合了体操、武术、西洋拳击各种运动形式,绝对是目前中国最先进的。她立刻对这个新鲜玩法有兴趣了,放下书包缠着毛泽东一定要学。\n毛泽东很高兴自己才发明的“毛氏六段操”有人喜欢,便把一篇画着“六段操动作图解”的文章翻开摆在桌上,开始手把手地教开慧: “一二三四,五六七八,二二三四,五六七八!这是手部运动;一二三四,五六七八,二二三四,五六七八,这是腿部运动…………”\n等到开慧练出了汗,休息的时候,毛泽东给开慧讲体育锻炼的好处时,说起自己小时候身体很差……一听毛大哥要讲小时候的故事,开慧可来劲了,催着他赶紧讲。\n“我小时候,身体一塌糊涂,三天两头生病,我上面还有两个哥哥,小小年纪都夭折了,我娘生怕我也养不大,求神拜佛,香都不晓得烧了好多。我们乡里有块石头,天生就像个观音,乡里人把那块石头当观音菩萨拜。有个算命先生告诉我娘,我要拜那个石头观音做干娘,以后才不会生病,我娘老子迷信,真的要我拜了那块石头做干娘,好保佑我不生病。所以,我有个小名,就叫石三伢子。”\n“那拜了有用吗?”开慧双手撑着下巴,仰着脸问。\n“一块石头,能有什么用?还不是哄鬼的。12岁那年,我一场大病,差一点就完蛋了。好不容易病好了,我也明白了,自己的身体,靠天靠菩萨都是假的,一句话,搞锻炼,坚持运动,自然百病不侵。从那个时候到现在,我就不晓得病字怎么写的。所以说,身体、精神、意志,那都是磨炼出来的。我在那一篇《体育之研究》里头,还专门总结了三条理论,讲人的精神、意志和身体之间的关系……”\n“毛大哥,你的‘毛氏六段操’也是这篇文章里的吗?哎呀,都忘记我是来做什么的了。”开慧跳起来,拿过摆在桌上的文章,翻到第一页,“体育二字,听起来是小事,其实关系一个国家的兴衰。一个人不爱运动,哪来的蓬勃之气?同样,一个民族不爱运动,哪来的尚武精神?到时候,国家有难,打仗都没有人扛得起枪,这个国家还有什么希望?你再看我们现在的学校教育,说是说德、智、体三育并重,其实呢……这么多字,我还是赶快回家,和爸爸一起看吧。”\n开慧把文章拿回来,还没忘记把刚学来的六段操表演给爸爸妈妈看。向仲熙自女儿来周南后就从老家搬来长沙杨宅照顾父女俩的起居了,她看到女儿大汗淋漓的样子,心疼地赶紧为女儿擦汗。\n“毛大哥说了,做运动嘛,就是要出汗。”开慧擦着汗,看到父亲缓缓地合上了手里那篇《体育之研究》,忙急不可待地问,“爸,怎么样怎么样?”\n杨昌济:“这么说吧,到目前为止,这是我看过的对体育运动论述得最好,也最全面的文章。润之这篇文章,应该说,对全国的体育教育改良都很有意义,我看,应该拿出去发表。而且要发在最好的杂志上。我打算将这篇文章推荐给《新青年》的陈独秀先生,他一定感兴趣的。”\n听到爸爸说要把毛大哥的文章推荐给《新青年》,开慧兴奋得眼睛都瞪圆了。\n第二十三章 到中流击水 # 总有一天,我要写一首诗,写出我们中流击水的风华正茂,写出我们指点江山的壮志豪情!\n一 # 转眼又是新的一年、新的一学期,1917年3月的最后一个周末,读书会的会员们除了开慧都来齐了,他们正在君子亭商议一个重要举措,那就是以他们现在的哲学读书会为基础,成立一个正式的、有组织、有纪律的青年团体。\n这件事情,毛泽东和蔡和森之前已经交流过很多次,只是还没有和大家讨论。按照毛泽东的想法,他们这个读书会,原本是因为共同的学习兴趣集合在一起。但读书学习毕竟不是他们的最终目的,而是为了改造社会。而且,虽然他们现在有一帮子人,但是人再多,一盘散沙子,也搞不成事。所以他才提议成立一个正式的青年团体,这个团体,不搞虚的,专门做能改造国家、能推动社会发展的实事,他坚信,只要按着这个目标做,他们的团体就完全可以成为湖南进步青年的中坚,成为改造中国一支不可忽视的力量!\n“成立一个正式团体,这我同意,不过,改造整个中国,这个目标,定得也太高了吧?”看到毛泽东说得慷慨激昂,萧子升第一个出来泼凉水,觉得做人还是要脚踏实地,不能好高骛远。\n“这怎么叫好高骛远呢?理想就应该定得高嘛。自己先把自己框死了,还成个什么气候?”毛泽东想要说服萧子升。\n“那也不能一口吃成个胖子吧?就你那口气,好像中国缺了我们几个都不行了,至于吗?”\n“缺了谁地球照样转。只不过,都照你那样想,世上就没有英雄豪杰了。”\n“我本来就没想过当什么英雄豪杰。改良社会,必须是个积跬步而至千里的过程,我们的任务,就是集中精力做好眼前的跬步之始,一天到晚只想着万里宏图,那反而会变成空中楼阁。”\n“胸中若无万里宏图,眼前的事岂不是没了方向?”\n众人正看着他俩面红耳赤、争得不可开交,猛听到亭外开慧兴奋的叫声,忙回头去看。毛泽东看到开慧手里拿着一本杂志气喘吁吁地跑来,便高声喊:“开慧,莫跑这么急,摔一跤不得了!”\n开慧根本没慢,反而一步冲进亭子,喘着气,双手抓起杂志,给大家看封面:是一本崭新的1917年四月号的《新青年》杂志。然后才翻到中间,一把递到毛泽东面前。\n“《体育之研究》?”毛泽东猛地一把抢过了杂志,“我的文章?我的文章发表了?哎!我的文章发表了。”\n大家一下子都围了上来,争先恐后地看着杂志。\n“哎,《新青年》?毛泽东,你可以啊!”。\n“润之,恭喜你。”\n“咱们长沙城,还没哪个学生能在《新青年》上面发表文章呢。”\n“老师也没几个啊!润之哥,这么大的喜事,要请客啊!”\n“对对对,请客请客!”\n“要得要得,请客请客。”众人纷纷向毛泽东道喜,毛泽东也高兴得嘴都合不拢,可他摸摸自己干瘪的口袋,不好意思地说,“请客我倒是愿意,可就是没钱。”\n“那不行,这么大的喜事,总要庆祝一下吧?”众人不依。\n“我看这样吧,客呢,就不要润之请了,他除了请大家喝开水,别的反正也请不起。不如我们搞个活动,现在不是春天吗?春暖花开,趁着明天礼拜天,我们出去春游,也算是庆祝润之的文章发表,大家说好不好?”蔡和森想了个两全其美的好办法,看大家都赞成,他又说,“润之,本来是给你庆祝,就由你定个地方吧。”\n“橘子洲头,怎么样?春江水暖,岸芷汀兰,长沙春色,尽收眼底……干脆我们搞回痛快的,游泳过去!”\n看了看斯咏为难的样子,他又补充说,“女生坐船,男生游泳。何胡子年纪大,你例外,其他人一律下水!”\n二 # 珠沉渊而水媚,青翠的橘子洲便是湘江的一颗绿宝石,湘江因为这颗宝石的光芒而柔媚,这颗宝石又因为湘江如兰的春水而熠熠生辉。\n湘江东边的沙滩上,读书会的同学们今天就要到江中的橘子洲上去庆贺毛泽东的《体育之研究》发表在《新青年》杂志上。蔡畅、何叔衡、开慧、斯咏都上了船,警予却还混在一群正脱了衣服做热身运动的男孩子堆里,像个大姐姐一样帮蔡和森收拾脱下来的衣服,叽里咕噜地吩咐蔡和森注意这样注意那样。毛泽东一边打趣他们的肉麻举动,一边把所有人的衣服卷成团一下子扔上了船,让开慧照顾着。\n开慧看到萧子升背着画架、居然和往常一样地一丝不苟地穿着长衫布鞋,也跟在斯咏身后上了船,问他:“萧大哥,你怎么也坐船啊?毛大哥说男生都要游泳过河的。”\n不等子升答话,岸边先传来了毛泽东的声音:“萧菩萨怕冷咧!还游泳?他呀,恨不得一天到晚把自己当个活菩萨供起哟!”\n看到毛泽东先将一只足球用力甩入江中,随后一个纵身鱼跃,身体在空中划出漂亮的弧线,一个猛子扎进了冰冷的江水,子升打了一个寒战:“你以为我是你啊?早春二月下河游泳!人不可违天时,你那是逆天而行。”他说着话放下画架,挨在斯咏身边坐下了。\n警予抱着蔡和森的衣服上船后,船就开了。船橹摇荡,渡船在水面划出长长的波纹。船的前方,男生们正劈浪前行,打打闹闹地玩着那只足球。毛泽东钻出了水面,踩着水,向船上挥着手:“萧菩萨,下来啊下来啊,水里舒服得很呢!”\n子升没理他,假装看着远处的橘子洲,余光却全在斯咏身上。江风吹来,斯咏裹紧了身上的衣裳,伸手试了试江水,江水冰冷,她的手才一伸下去,就猛地缩了回来。子升正想掏出手帕递出去,却听到斯咏对着击水的人群高声问:“润之,你们真的不冷啊?”子升黯然把手放在口袋里停了一会,然后空着手伸出来,抓住了身边的画架。\n“到了水里还冷什么冷,一身都发热,哎,玩几个花样给你们看啊!”毛泽东一跃老高,玩起了花样,侧身、平躺,倒立、翻筋斗……涌动的江水中,他似乎比鱼还自由。\n何叔衡看得呆了,说:“这个润之,到了水里,简直是条龙。”\n水里的和船上的都正看着毛泽东表演,毛泽东一个猛子却不见了。大家都知道他水性好,开始还想着他会从什么地方突然冒出来,给大家一个惊喜。可等了好一阵,还不见他浮出水面,大家禁不住都焦急起来,本来看得很开心的斯咏和警予竟吓得在船上大呼小叫。慌乱中,在船的另一边突然间水花涌起,毛泽东从斯咏的背后一头钻出了水面,攀住船舷挥手弹了开慧一脸的水,大叫:“我在这儿!”\n“哎哟,你吓死人了!”斯咏惊魂未定地拍着胸口。\n“你怕我淹死啊?一条湘江,再过50年我都能随便游。”\n“再过50年?再过50年你70多了,活不活得到那时候还难说呢?还游湘江?”萧子升说。\n“自信人生二百年,会当击水三千里!萧菩萨,你还莫不信,五十年以后,我游给你看看!”\n开慧擦着脸上的水问:“毛大哥,水里真的不冷啊?”\n“这个水啊,是下来前冷,下来反而不冷了,越游越热乎。不信你下来试试啊。”\n开慧把脱下的鞋和外衣往斯咏手里一塞,捏住自己的鼻子,扑通一声,真跳进了水里,水花溅了斯咏他们一身。水中的开慧游了几下,兴奋得直冲船上喊:“好舒服啊,还有谁要下来啊?”她边游边与毛泽东等在水里玩起了足球,球在青年们当中飞来飞去,一时间江中水花四溅,开慧的欢笑声响成了一片。蔡畅和开慧年龄相当,看到开慧在水里玩得那样开心,也依傍着船舷,乐得手舞足蹈。而警予的眼光却始终没有离开过蔡和森。\n三 # 过了江、上了岸、进了橘子林,换上干衣裳,大家就开始分工:一拨人去找当地的农民买红薯、一拨人去拣干柴。不用谁吩咐,蔡和森很自然地就跟在了警予身后,俩人一个捡柴,一个抱柴,动作蛮协调的。走出很长一段路了,警予看看身边一声不吭只顾着抱柴的蔡和森,突然“扑哧”一声笑了。蔡和森前后左右张望着,实在没发现什么异常情况,就问警予笑什么。警予抬起自己脚上的皮鞋,借着手里的柴棍摆了个俏皮的姿势,说:“我一直以为咱们只有在擦皮鞋的时候能配合默契,却不想,捡柴的时候也挺默契的。”蔡和森抱着柴就往回走,边走边说:“ 我倒觉得我们默契的时候还很多呢。”警予愣了一下,脸微微地红了,赶紧撵了上去。\n他们回来的时候,其他人早已经把柴和红薯堆在一起了,何叔衡和毛泽东正熟练地把一堆红薯埋进了挖好的土坑里,然后在上面搭着柴架子。看样子这两个人在家都是做活的好手,几弄几弄,一股青烟冒过,火苗“噌”地就起来了。\n等待红薯烤熟的这段时间,毛泽东、张昆弟、罗学瓒、萧三他们又在沙滩上踢起了足球,开慧套着毛泽东的长衫,袖子长得连手都伸不出来了,却还在沙滩上蹦着跳着给得了球的人加油。沙滩旁,子升架起画架写生,他的背后斯咏和蔡畅津津有味地看着浩浩湘江、连绵岳麓从子升的笔下流淌出来。警予和蔡和森却哪里都没有去,坐在火堆旁边添柴、守着红薯不要被烤糊了。通红的火苗窜出老高,映照着两人的脸。他们谈了最近读的书、谈了学校的新活动、又谈了些朋友间的趣闻,警予看着眼前的火堆、橘子林和远处同学们的身影,深深吸了口气,换了个话题:“真美啊!”\n“是啊,要是能天天这样,静静的,就这么坐着,那真是人生最大的幸福。”蔡和森犹豫了一下,“我是说,要是……要是两个人的话。”\n警予没有想到蔡和森会有这样的表白,心里猛然间说不出有多紧张、甜蜜和羞涩,竟不由自主地低下了头。一片小树叶在微风中飘下来,晃晃悠悠地正好落在警予的头发上,警予正伸手想去摘下来,蔡和森也已经伸出手了,两只手在警予的耳朵旁碰在了一起……\n“你们搞什么鬼?说好闻到香味就来叫我们的嘛!香味都飘过湘江了,你们居然还在这里只顾说话。”毛泽东像龙卷风一样横扫过来,凑在火堆旁仔细地嗅着红薯的香味,急急地用树枝扒出了一个个烤得黑糊糊的还在冒烟的红薯。他的叫喊声把所有人的馋虫都钓了起来,踢球的、画画的、喝彩的全欢呼着拥了过来。警予和蔡和森对视了一眼,心领神会地笑了笑,都扎进了抢红薯的人堆里。\n毛泽东把第一块红薯掰成两半,一半递给左边的开慧,一半递给右边的斯咏。斯咏文雅地小口咬着,开慧被红薯烫得直啧嘴,却偏要狼吞虎咽,吃得连鼻子尖都沾了红薯,旁边的人看到都大笑起来。\n简单的午餐过后,蔡和森宣布稍微休息一会,就开始今天的主题读书活动。张昆弟、萧三他们一听这话,抱起足球就往沙滩上跑,毛泽东跑了几步,又回来,把所有燃到一半的柴全部退了出来,埋进土堆里。他在做这些的时候,其余的人也已经各自找到了好玩的去处,跑掉了,只有斯咏静静地站在不远处看着他,等他把事情做完,走到自己身边。\n斯咏和毛泽东并肩走出橘子林,走到了江边。远远地看到萧三他们踢得起劲,毛泽东也想过去,可看看斯咏慢吞吞一副欲言又止的样子,他又不好意思把人家一个女孩子丢下。还好,斯咏终于开口了:“润之,还记不记得上次,我们也是这样,走在江边。”\n“哪次?”\n“就是上次,当时还下雨了。”\n“哦,你说那次啊,那不是在江那边吗?”\n“只要是我们俩,江哪边还不都一样?”\n斯咏看着毛泽东,似乎要把下面的话用眼睛说出来,可正当毛泽东看她时,她却又用长长的睫毛把眼睛覆盖住了,把一头青丝留给了毛泽东。两个人于是又沉默了,依然并肩慢慢地走着,在沉默中揣测着对方的心思,直到蔡和森在前面高声喊他们快开会了。\n今天的主持人是萧子升,大家都围坐在了沙滩上后,活动就正式开始了。\n“今天的议题,是改造读书会。这个想法,是润之和蔡和森提出来的,上次我们曾经讨论过,不过没有定论。今天呢,我们就继续讨论这个问题。”子升转向蔡和森,“和森,你的建议,你先说说吧。”\n“改造读书会,形成一个正式的进步青年团体,应该说是大家的共识,关键在于,我们新成立的这个团体,应该有着怎样的宗旨,应该朝哪个方向努力,应该定一个怎样的目标,只有这些方面形成了共识,这个想法才有可能实现。”\n蔡和森才停下来,萧三就回答:“上次润之哥不是说了吗?改造中国,改造世界啊。”\n“改造二字,未免言之过大,我看,这个团体,应该以致力于个人及人类生活向上为目标,首先是严格个人的生活,然后是周围的人,推而广之至全人类,只要我们这个团体,对此能有所贡献,使社会能受其影响,有所改良和进步,也就算是相当成功了。”子升一向不喜欢毛泽东的好高骛远。\n“个人及全人类生活向上?嗯,说得好。”\n“积跬步而至千里,千里我们也许做不到,能脚踏实地积跬步,也是不错的。”\n周世钊和何叔衡表示支持子升的观点,但开慧、张昆弟和罗学瓒却觉得还是毛泽东的改造世界来得过瘾,斯咏似乎还没从刚才的状态里走出来,一副心不在焉的样子。蔡和森于是把目光转向了毛泽东。\n“跬步也好,千里也好,现在言之,不免过早。我倒是觉得,有一条我们应该先定下来:团体的范围。我们这个团体,就应该是个最先进、最团结、最强有力的团体,所以范围不宜搞得太宽,我们要寻求的,必须是那些胸怀大志,能砥砺自身,严于律己,愿意为理想而奉献生命的真同志,”毛泽东突然往斯咏脸上看了一下,却又马上把目光收了回来,缓缓地站起来说,“时光这么宝贵,中国的事还有这么多等着我们去做,我们这些要担负大责任的青年,就应该想大事,做大事,没时间去考虑那些个人的小事情。所以我觉得,我们这个新团体,应该定一个‘三不’原则。”\n“三不原则?哪三不?”大家异口同声地问。\n“第一,不谈那些鸡毛蒜皮、杂七杂八的琐事。”\n“同意。”\n“第二,不谈个人的私事。”\n“同意。”\n“第三,不谈男女之情。”\n没有人注意到,斯咏的目光蓦然间黯淡了。警予正在给蔡和森整理着弄皱了的衣服,她抬起头,发现众人的目光都在看着自己,眼睛一瞪:“谁谈男女之情了?”\n“毛大哥没讲哪个具体的人啊?他只是说,时光宝贵,我们有志青年没时间谈那些不着边的事嘛!大家说是不是啊?”开慧一面说,一面挤眉弄眼,大家都知道她在说谁,萧三、张昆弟等几个人首先起哄吆喝起来:“开慧说得对,我同意!”\n“都同意是吧?都同意是吧?同意的握手。”开慧第一个伸出手来,其他人纷纷起身,七八只手一下子叠在了一起。众目睽睽下,蔡和森也只得伸出了手,那只手却犹豫着,伸了一半,僵在了半空中。他目光望向了警予,似乎准备把手往回缩,却又不好意思。望着他已经伸出去的手,警予的脸沉下来了,她赌气似的一伸手,往众人手上一叠:“我同意!”这下蔡和森的手不好缩回了,他也只得加入其中。\n在场的所有人中,只剩下斯咏与子升没有伸手。\n开慧问:“斯咏姐,子升大哥,你们两个呢?”\n望着毛泽东坦然的眼神,斯咏慢慢地伸出手,与大家握在一起,子升犹豫了一下,也伸手盖在了斯咏的手上。\n此时,夕阳正把一天中最美好的瞬间定格在湘江上。毛泽东看到今天的活动已经达到完满的效果了,就提议说:“这个时候,洲头的风景最漂亮,一起去看看,好不好?”\n“好哇好哇,大家比赛,看谁第一个到。”\n一群年轻人甩开膀子就跑,直往橘子洲头冲。猎猎晚风中,他们涌上洲头临江的高处,放眼望去,湘江浩荡,滚滚向前,天边,夕阳残照,晚霞满天,映照得一江春水,波光粼粼,苍翠的岳麓,大自然的壮观之美,震人心魄!迎着猎猎江风,方才的紧张与沉闷仿佛随风而去,毛泽东纵身跳上了一块突起的岩石展开双臂,仰天一声长啸:“啊!江山如画,一时多少豪杰!”\n子升笑道:“怎么?毛大诗人,发思古之幽情啊!”\n“思什么古嘛?难道只有古代才有豪杰?当年万户侯,皆已成粪土,我同学少年,才风华正茂,何须古人开我心胸?哎,你们也来,都上来,上来看看,来呀!”\n毛泽东一把将斯咏拎上了岩石,其他人也纷纷跳了上来。凌空而立、俯瞰山川,他们每个人的心里都豁然升腾起一种居高临下、超乎于自然之上的壮美:“问苍茫大地,谁……主……沉……浮……”\n“总有一天,我要写一首诗,写出我们中流击水的风华正茂,写出我们指点江山的壮志豪情!”夕阳下,毛泽东的声音如龙吟虎啸,回荡在天地山川间。\n第二十四章 书生练兵 # 窃昭绶忝再任第一师范学校校长……佥以人格教育、军国民教育、实用教育为实现救国强种唯一之教旨……我国国民,身体孱弱……历年外交失败,由无战斗实力以为折冲后盾……世界唯有铁血可以购公理,唯有武装可以企和平……故学校提倡尚武精神,诚为今日之要义,此学生志愿军倡办之必要也。\n一 # 去年冬天,斯咏看到子鹏带着秀秀又在教堂外,一边喊着“圣诞快乐”一边给一群小叫花子撒零钱,曾和子鹏开玩笑说:“这些小孩子未见得就知道耶稣、读过《圣经》,怎么会在乎圣诞节呢?”她没想到,一向在她面前说话唯唯诺诺的子朋居然想也没想就回答说:“我们小时候也不知道屈原、没读过《离骚》,不是一样过端午节吗?”这话说过彼此就都忘记了,但端午节真的快到了,斯咏却毫无来由地突然想起了这件事情,觉得子鹏偶尔说句话,还是蛮有道理的。很多时候,斯咏都在想,如果不是因为那桩莫名其妙的娃娃亲,她和子鹏的关系一定不会像现在这么尴尬。\n要过端午节了,今年家里会做些什么样的粽子、爸爸今年会不会请龙舟去参加一年一度在湘江举行的龙舟大赛?放学后,斯咏这样想着、哼着小调回到家,进了客厅,却看到陶会长已经回来了,正在仔细地打量一匹白纱,纱的旁边还堆着各色绸缎、果品和一大摞重叠的精致礼品盒子。\n“斯咏你回来了?快来快来,”陶会长拿起手里雪白的纱往斯咏身上比划着,“端午节快到了,这些都是你姨妈姨父送来的节礼。你和子鹏明年不就毕业了吗?你姨妈他们的意思呢,到时候,给你们弄回新鲜,办个西洋婚礼,这个,是人家专门托人从法国买回来的,最好的婚纱面料,你看喜不喜欢?”\n他兴奋地唠叨着,却没注意到斯咏的脸已经沉下来了,一手把婚纱面料扒开。陶会长赶忙问:“怎么了,不好看啊?”\n“好不好看我都不要!”\n“你要不喜欢,那我们还办中式婚礼,我跟王家说一声就是。”\n“我什么式都不要!”\n斯咏转身就走,甩手碰倒了摞得高高的礼品盒子,里面大大小小的饰物滚落出来,一下子把整洁的客厅弄得乱七八糟。\n“斯咏!”陶会长叫住怒气冲冲的女儿, “斯咏,我知道,有些话你不爱听,可你如今也不是孩子了,不能什么事都依着性子来。你和子鹏,那是你爷爷、外公手上就定好了的婚事,哪能你说不干就不干?”\n“我不喜欢表哥,我凭什么嫁给他?爷爷、外公他们都过世多少年了,我的事,凭什么还要他们说了算?”斯咏背对着爸爸,头也不回。\n“婚姻大事,长辈做主,天经地义嘛。”\n“爸,那我能问你一个问题吗?”斯咏腾地转过身,“你和妈也是长辈包办的婚姻,你觉得幸福吗?”\n陶会长没想到女儿会如此提及父母,不由得愣住了,好半天才喃喃地说:“我……我和你妈也不错啊,我们那么多年,一直相互尊重,相敬如宾……”\n“是,你们是相敬如宾,可夫妻之间,光有尊敬就够了吗?我一直还记得,妈过世以前,你们两个每天都是那样客客气气的,见面,打招呼,一起吃饭,然后呢,你做你的生意,她看她的小说,你们一天连话都说不上几句。哪怕你们吵一次架也好啊,可你们架也不吵,就这样十几年,就这样半辈子。爸,你真的觉得和妈在一起是幸福的?你对那样的婚姻,真的从没后悔过?你能回答我吗?”\n斯咏如竹筒倒豆子一般地把心里话说完以后,转身上楼,跑进自己的卧室,把陶会长一个人晾在客厅里。这次,陶会长没有叫住女儿。女儿的话,深深触动了他心底的痛处,他扶着沙发的靠背,抬头看墙上挂的那张他与妻子当年的一张合影。模糊的黑白照片上,长袍马褂的他与旗式装束的妻子隔着茶几,正襟危坐,面无表情。\n为了女儿的幸福,他决定当晚就去一趟王家。\n王老板和王夫人见陶会长一个人来了,有些失望。但随即就热情地请姐夫入座、吩咐仆人沏茶,还特意说子鹏出去散步了,马上就回来了。与斯咏妈妈的个性相反,这个姨妹能说会道、泼辣能干,陶会长一向对她敬而远之。这些年来,即使妻子在世的时候,也多是王家去陶家走动,妻子过世之后,两家走得不那么勤了,但也仍然只是王家上陶家的门。说来,陶家是女方,矜持一些也是应该的,况且自己的姐姐姐夫,从个性到家业都知根知底,王家夫妇也就没往别处想,他们早就把斯咏当成了王家的儿媳妇,而斯咏是陶家唯一的女儿,陶家的一切迟早都是王家的,还计较什么呢?\n陶会长当然明白王家夫妇的心思,其实这些年他又何尝不是这样想的呢?但问题的关键是,他也这样想的前提,是斯咏要嫁进王家和子鹏白头偕老,是女儿的幸福有保障。但现在女儿不想嫁给子鹏,这一切打算就毫无意义了。他想着,端起茶,拂着茶叶,斟酌着该如何开口。王老板看出陶会长的神色有些异样,便问他是不是有什么心事。\n“哦,也说不上有事。这几天,我……一直在想子鹏和斯咏的事,他们俩吧,原来小,长辈给作了主,也就那么定了。可现在呢,孩子都大了,都二十出头了嘛,也是自己有主意的年纪了,时间过得快呀……”\n陶会长说到这里,王夫人自以为听明白了姐夫的意思,拍着巴掌说:“这话说到点子上了,姐夫,您着急,我们比您还急呢。你看看人家,十七八的,孩子都能叫爹妈了,哪像他们俩,二十几了,还拖,早该办了。”\n“是啊,姐夫,原来呢,你一直不做声,我们是着急也不好催。难得你今天说起这事,我看啊,是得给他们好好准备准备了。这喜事嘛,宜早不宜迟。要照我的意思,也别等什么毕业不毕业,挑个好日子,尽早办。”\n本来想委婉地提出退婚的陶会长,听到这夫妻俩的话,也不敢确定他们是不是真的没听出自己的真实意图,又说: “我倒不是这个意思,我是说,时代不同了,年轻人有年轻人自己的想法,咱们这些长辈作的主,他们也不见得就一定愿意……”\n“这种事还能由得他们?还不得我们当父母的来操心?”\n王老板瞪了夫人一眼:“姐夫这话说得也在理。斯咏到底还在读书,真要成了亲,总不好再出去抛头露面吧?还是照咱们原来商量的,等他们毕业,毕业就办。算起来也就一年工夫了,咱们两家早点做准备,到时候办得风风光光的,孩子们也高兴嘛。姐夫,你看怎么样?”\n“这个……”陶会长看着这个精明的连襟,只得含糊地应承,“也是,也是……”\n“姨夫?!”\n陶会长正不知道说什么,子鹏散步回来了。看到陶会长在座,他喊了一声,回头看了看跟在后面的秀秀,秀秀急忙低下头看着脚尖。刚才在路上,子鹏才和秀秀说起希望能永远不毕业、希望那些不该来的永远别来,那些值得珍惜的永远留在身边。他们心里都明白,值得珍惜的,就是他和秀秀之间的情谊,不该来的,就是他和斯咏的婚事。可话音还在耳朵边响着,就在家里看到陶会长,这让子鹏很有些尴尬。\n“什么姨父?以后别叫姨父了。你姨父今天,可是专门来商量你和斯咏的婚事的,日子咱们都定好了。所以,打今天起,你呀,就该直接叫岳父。”王夫人一推王老板,“万源,你说是不是?”\n“对对对,叫岳父!难为你岳父大人为你的事辛辛苦苦跑来,赶紧,现在就叫,让他老人家也高兴高兴,叫啊。”\n尽管自小就和斯咏定了亲,也知道迟早要叫陶会长“岳父”,但子鹏却从没有想过这一天来得这么突然。他看看父母、又看看秀秀,父母的脸上是期待,秀秀低着头,看不到她脸上的表情。陶会长也没料到王家夫妻会来这么一招,却又不知如何推辞,看着子鹏说不出话来。\n“迟早都要叫,还等什么?子鹏,你瞧这孩子,还不好意思了,你倒是叫啊。”\n“子鹏!”\n妈妈的话绵里藏针、爸爸的话简直就是在命令了,子鹏像是被逼到了角落里的猎物,无助到了极点。他的嘴唇哆嗦着,还是艰难地叫出了声:“岳……岳父。”\n王老板和王夫人开怀大笑,陶会长木然地站了起来,秀秀的头埋得更低了。\n二 # 湖南这块土地上,出过太多的敢为天下先的英雄,最著名的莫过于以武功盖世著称的文人曾国藩曾文正公。道光十八年曾国藩从湖南湘乡一个偏僻的小山村以一介书生入京赴考,中进士留京师后十年七迁,连升十级,37岁任礼部侍郎,官至二品。因母丧返回长沙,恰逢太平天国巨澜横扫湘湖大地,他因势在家乡拉起了一支特别的民团——湘军,历尽艰辛为清王朝平定了天下, 被封为一等勇毅侯,成为清代以文人而封武侯的第一人。曾国藩所处的时代,是清王朝由乾嘉盛世转为没落衰败、内忧外患接踵而来的动荡年代,由于曾国藩等人的力挽狂澜, 一度出现“同治中兴”的局面,曾国藩正是这一过渡时期的重心人物,在政治、军事、文化、经济等各个方面产生了令人瞩目的影响,一时间“尚武强兵,以壮国力,人人有责”成了湖南学人的传统。还在一师做学生的毛泽东于近代诸多豪杰中,就独服曾国藩,并坚信,关到书斋里读死书是行不通的,继曾国藩之后,左宗棠、黄兴、蔡锷,哪个不是战场上打出来的赫赫功业?所以,唯有文武兼修,方能成大器!而1917年的中国,对外已经宣布参加第一次世界大战,对内也是军阀混战狼烟四起,于是,还有一年就要毕业的毛泽东想在一师搞学生练兵。\n他的主意得到了孔昭绶、杨昌济的大力赞成,但这毕竟不是简单的组织学生做体操,为慎重起见,孔昭绶就此事奏请了当时的湖南督军谭延闿:“字呈湖南督军谭延闿大帅阁下:窃昭绶忝再任第一师范学校校长……佥以人格教育、军国民教育、实用教育为实现救国强种唯一之教旨……我国国民,身体孱弱……历年外交失败,由无战斗实力以为折冲后盾……世界唯有铁血可以购公理,唯有武装可以企和平……故学校提倡尚武精神,诚为今日之要义,此学生志愿军倡办之必要也……”\n王子鹏是在学校的公示栏里看到《课外学生志愿军报名启事》时才知道这件事情的。公示栏旁边一字排着两张课桌,被报名的学生围得水泄不通。子鹏仔细看了启事后,对里面说的什么懵懵懂懂,不过旁边的一幅简练的标语却让他心动。\n“铁血可以购公理,武装可以企和平。”\n长久地看着这幅标语,子鹏的心里也鼓荡起了一股男子汉的豪情,理解了为什么会有那么多同学去报名,也站到了队伍后面,准备报名。可望着一个个同学领了崭新的学生军军装,兴高采烈地挤出人群,子鹏虽然好羡慕,却又想到自己平时在同学们眼里是个吃不得苦的大少爷,即使报名也未必能被录取,才沸腾起来的心又凉了,踌躇不敢上前。不过,这也正是改变不好印象的好机会呀,试一试有什么关系呢?也许自己不会比别人做得差呢……翻来覆去犹豫了很久,子鹏终于给自己鼓足了劲,抬脚向前挤去。\n“让一下、让一下。”恰在这时,毛泽东和张昆弟抱着两大捆新军装过来,子鹏只顾着看前面,没让路,毛泽东颇不耐烦地蹭了他一下,口里叫着王少爷,说人山人海的你挤到这来干什么?莫挡路。子鹏一惊,赶紧让到了一边,毛泽东他们刚一过去,后面的同学一下子挤了上去,又把他给挤到了最外面。\n子鹏想想毛泽东的话,看看挤成一团的同学,叹息一声,刚刚鼓起的勇气又消散了。\n学生军每天下午课后训练,八班寝室里,除了子鹏都去参加了。子鹏像只离群的雁,呆在哪里都不自在,干脆跑到操场旁边去看他们训练。“一二一,一二一,一、二、三、四!”震天的吼声中,同学们穿着仿制军服、戴着“第一师范学生军” 袖标、肩头扛着木头假枪,正在烈日下操练队列。子鹏目不转睛地盯着带队喊操的毛泽东,他走在队伍最前面,动作看起来好英武。四年前刚开学的时候,子鹏就听毛泽东给易永畦讲过,全校同学里头,就只有毛泽东一个人真正当过兵、扛过枪,而且还是正规军,湖南革命新军第二十五协五十标左队列兵。虽说只当了半年,可他们那时候的训练总长是日本讲武堂的高材生程潜,对他们进行的是一整套日本陆军正规操练。今天看来,果然是真的呢,难怪毛泽东只要一说起那段经历,就自豪得不得了。\n看看毛泽东健壮的身板,再看看自己单薄的身材,子鹏真恨不得能马上跑进操场里去,跟在毛泽东的身后,随着他的喊声和其他同学一起训练。可想想毛泽东看自己的眼光,却不由自主地转身想回寝室去。不过刚一抬脚,竟看到蔡和森陪着孔校长和杨老师边说着话边过来参观,只好又转回身靠在树干上,做出一副正在看训练的样子。\n“昭绶,你这个第一师范学生军,搞得还有声有色的啊!”\n“也算难为润之他们了。我原来还答应过他,跟督军府要真枪实弹呢,可到头来,一支真枪也没能给他们弄来。”\n“秀才练兵嘛,谭督军还能真把这些孩子当回事?能发几支木头枪,已经是给面子了。”\n“怕就怕这假枪练不出真本事来啊!”\n“又不是真上前线打仗。学生们要练的,也就是军人的那股尚武精神,只要能练出那股精气神,真枪假枪,有什么关系?”\n说话的是两位先生,蔡和森一直没开口。他们走过之后,子鹏看着他们的背影、咀嚼着他们刚才的对话,长出一口气,暗暗地下定了决心。\n训练结束了,同学们提着抢叫着累出了操场,毛泽东还是精神百倍,嚷着:“这就喊练惨了?我跟你们讲,才开始!也就是队列、卧倒,接下来,越野、格斗、拼刺、障碍,你们才晓得什么叫军训!”他的话音才落,就有人说道:\n“你放心,毛长官,你以前军营里怎么练的,我们也一样,撑不住的,不算好汉。”\n看到大家有说有笑地就要从自己面前走过去了,子鹏怯生生的叫了毛泽东两声。毛泽东见是子鹏,有些意外,站住了问:“叫我啊?什么事?”\n“我……我那个……”子鹏紧张地绞着有些苍白的手指。\n“有事讲啊!”\n“我……我想报名参加学生军。”\n已经走到前面去了的同学都愣住了,停下来看看王子鹏、又看看毛泽东。毛泽东上下打量着单瘦苍白的子鹏,仿佛是不敢相信自己的耳朵:“你?”说完,长笑三声,紧跑几步撵上了前面的同学,扬长而去。\n子鹏知道毛泽东一向讨厌自己是少爷出身,可也没想到他居然会这样对待自己。在旁边的低年级同学的指指点点中,子鹏恹恹地走在操场边的小路上。蔡和森刚把两位先生送走,转回来,看到子鹏和周围学弟的样子,忙问子鹏发生了什么事情。了解了经过,蔡和森拉上子鹏就往八班寝室走。\n毛泽东刚换下仿制军服、穿上自己的土布衣裳,正扣着扣子,一听蔡和森是来给王子鹏讲情的,看看子鹏说:“他还学生军?他少爷军就差不多。”\n子鹏被毛泽东盯得退后一步,蔡和森拉住他,对毛泽东说:“子鹏也是一师范的学生,一师范学生军,他为什么就不能参加呢?”\n“你自己看他那个样子,糯米团子一样,搞这么个人来,我的学生军还搞得成器?”\n“润之,这话就是你的不对了。第一师范学生志愿军,什么时候成你的了?子鹏平常性格是比较柔弱一点,可他既然想报名,就证明他想改变,想让自己坚强起来嘛。你那个报名启事上也说了,凡我同学,均可报名,怎么到了子鹏这儿,就要分个三六九等呢?”\n“他是个少爷啊!”\n“少爷就不是人了?我知道你对他印象不好,可你连个机会都不给他,又怎么知道他一定不行呢?”\n“你不信我跟你打赌,他那个少爷,搞不成器!”毛泽东的口气软了,算是答应了蔡和森,给子鹏一个机会。\n三 # 第二天课后,子鹏领到了学生军军装,衣裳虽然大了些,穿在身上松垮垮的,但子鹏还是很兴奋地扛着木头枪排在了整个队伍的末尾,在毛泽东的指挥下,进行着齐步跑训练。子鹏平时的体育课成绩就只是勉强过得去,又拉了课,跟在队伍里很吃力,不是立定的时候差一点没收住脚步,就是在行进中慢别人半拍,最让他难受的是卧倒。\n毛泽东一声令下,自己头一个结结实实扑在地上。身后,学生军一齐扑倒在地,排在末尾的子鹏痛得直咬牙。随着接连几声“卧倒”、“起立”、“卧倒”、“起立”,子鹏痛得嘴角都抽变了形,但他还是满头大汗地拼命地支撑着……\n晚上子鹏回到家,才一进屋,王夫人就尖叫起来,以为儿子遭劫了。子鹏解释了半天,才让妈妈明白自己是在参加军训。“你说你这孩子,什么不好玩,跟那帮不要命的玩打仗,你瞧瞧你瞧瞧,都成什么样子了?”王夫人把子鹏拉到沙发上坐下,检查着儿子身上一道道的红肿,招呼秀秀赶紧去拿碘酒。秀秀用蘸着碘酒的药棉轻轻擦在子鹏磨破了皮的手肘上,痛得他一抽,疼在子鹏身上,也疼在秀秀心里,秀秀的动作更加轻柔了。王夫人听儿子说这样的训练还要持续两个月,先是想说服子鹏不要去了,后来看看说服不了,就安排秀秀每天下午子鹏训练的时候,熬些解暑的汤送去。\n端午以后的太阳光,就跟火焰没什么区别了,烤得地面滚烫,照在皮肤上,让人有火辣辣的感觉,学生军的训练因此也更考验人了。训练期间,只要一休息,同学们就“哄”地全跑到树阴下去了,敞开衣襟扇着风,争先恐后地大口喝水,但子鹏因为拉下的训练太多,独自还留在操场上练着,前胸后背,都被汗水浸透了,满头满脸的汗水还在顺着脸淌着。毛泽东给子鹏指点了要领,也劝他去休息一会,子鹏倔强地要坚持要挤时间争取赶上同学们的进度。毛泽东赞赏地看看子鹏,说:“那你先练着,我喝水去,给你也端一碗来。”\n休息了一会儿,继续训练拼刺。一组组同学排着队,一支支木枪不断刺出,整个操场,杀声阵阵,一个个同学都练得异常兴奋。子鹏排在一队同学的最后,因为从没这么晒过,他的精神状态很不好。\n“下一个,王子鹏,王子鹏!”\n“哦。”子鹏猛然一惊,这才发现已经轮到了自己,赶紧端正架子,提枪刺出,这一枪却动作拙劣,连木桩的边也没挨到,刺了个空,他用力过猛,险些摔倒。旁边的同学都笑了起来。子鹏定了定神,再刺,还是差了一点,枪偏到了木桩外。他一连好几次,次次都偏了。他的动作实在是太滑稽了,旁边的同学已经笑成一团。\n大家又休息了,烈日下,子鹏咬紧牙关,用木枪刺着木桩。木桩震动着他的手,摩擦着他的手心,枪身握手的地方,已经沾上了血迹,他却仍然闷着头狠狠刺着。\n“少爷。”秀秀按照夫人的吩咐提酸梅汤来了,在子鹏的身后打开沙煲,将汤放在子鹏的旁边,又掏出手帕,来给子鹏擦汗。子鹏瞄了一眼不远处的同学们,想躲开,却又不好拒绝她的好意,只得伸手去拦。秀秀发现了少爷的手上的血,吓得一把抓住,慌忙用手帕去裹。\n“不要紧的,秀秀,真的不要……”\n“怎么不要紧?你的手这么嫩,哪受得了这个?你看还在出血呢!”\n远远看见这一幕,毛泽东不高兴了,虎着脸走了过来,“王子鹏,搞什么名堂?说过你多少次了,军训场上没有少爷!还丫环仆人跟着侍候,你以为这是你家?受不了苦你赶紧走,想当少爷回家当去,在这儿,就得像个男人,听到没有?”\n子鹏的脸腾地涨红了。秀秀还在拉着他的手包扎着:“少爷,您别动啊,还没包好呢!”\n“不要包了!”子鹏突然冲她吼了起来,“我不要你给我包,不要你送这样送那样,我不要人把我当孩子照顾,你不要再来烦我了好不好?!”\n他猛地一甩手,还未扎紧的手帕飞落在地。乒的一声,那只盛着汤的沙煲被他的脚碰翻,汤洒了一地!秀秀呆住了,眼泪涌了出来,她也不擦,转身就往操场外跑去。子鹏自己也被这突如其来的脾气吓住了,他愣了一下,把枪一扔,就去追秀秀。\n秀秀一路哭着跑过一家茶馆,刘俊卿带着几名三堂会手下正好优哉游哉地从茶馆里出来,他现在比当年当侦缉队长的时候还风光。不过一看到秀秀,脸上的表情立刻就柔和了很多,忙跟上去问,可怎么问,秀秀就是不吭声,只站在街角哭。刘俊卿不耐烦了:“到底出了什么事,你就不能跟哥说句实话吗?是不是在王家受气了?”\n秀秀一听这话,狠狠擦了一把眼泪。刘俊卿明白自己猜对了,顿时火冒三丈:“我去了王家几次,你都不见我。叫你别低三下四当丫环了,你偏不听。现在知道受气了?哥找你那么多回,求着你别干了,求着你出来当小姐,哥养着你,你偏不,你说你……你不犯贱吗?”\n一句话刺痛了秀秀的心,她转身就要走,刘俊卿赶紧拉住了她,尽量放软口气:“阿秀,哥不该跟你发火,是哥不对。我知道,我知道你看不起哥这种人渣,哥也知道自己就是个人渣子。可哥是真为你好,哥不想看到你再过那种穷日子啊!”\n说到伤心处,他自己先长叹了一声,颓然蹲下了。\n“哥这一辈子,反正是完了,混到哪天是哪天吧。可你不一样,哥亏欠你太多,这个世道它亏欠你太多了,哥没别的,就想你能过得好一点,就想你能开开心心,就算哥求求你好不好?你怎么……怎么就不肯给哥一点机会呢……”\n刘俊卿捂住了自己的脸,泪水从他的手指缝里流了出来。秀秀看着,想起几次看到哥哥在王家外面等自己、徘徊很久才离开,心里又有些感动,轻轻把手搭在了刘俊卿的肩上,叫了声:“哥!”\n这久违的声音令刘俊卿身子一抖,他站起来,正想说什么,突然传来子鹏的声音:“阿秀!”小巷口,满头大汗的子鹏正喘息着,望着秀秀。秀秀把手从哥哥肩膀上缩回来,低下了头。\n一时间,几个人谁也没说话。\n“你们谈吧!”看看子鹏,再看看妹妹,刘俊卿仿佛突然明白了什么,转身向巷子外走去,走出巷子口,又闪身往墙角一靠,偷听着妹妹和子鹏的谈话。\n“阿秀,对不起,我……我真的不是对你发火,我是心里烦,你别生气了。”\n“我只是个丫环,少爷骂我两句,我怎么敢生气?”\n“阿秀!我真的不是有心的,我知道你是关心我,可是……你知道吗?,我为什么参加军训?因为我不希望自己总是那么软弱,因为我一直很羡慕我的那些同学,毛泽东、蔡和森,还有好多好多我身边的同学,他们都活得那么自由,那么开心,那么敢做敢当。我只是想像他们一样生活,像他们一样坚强,我只是希望自己能勇敢起来,能保护我真正想保护的人!可我……可我却怎么也做不好,我是真的好烦好烦啊!”\n“少爷要保护的,应该是陶小姐才对。”\n“我不想保护什么陶小姐,我也不想别人塞给我一门什么婚事!”\n“可少爷跟陶小姐的婚事,已经定好了,老爷太太的话,少爷怎么能不听呢?陶小姐那么漂亮,那么知书识礼,少爷跟她,才是天生的一对。秀秀是个丫环,只希望少爷以后能和陶小姐过得开开心心的,秀秀就高兴了。”\n过了好一会,巷子外的墙边刘俊卿还没有听到声音,他探头出去,看到妹妹已经走了,子鹏还呆呆地站在原地,眯起眼睛想了想,心里已经开始酝酿一个计划了。\n四 # 陶府门外这几天突然多停了几辆马车:院墙边,有两辆人力车等着客人,车夫一个吸着旱烟,一个用草帽盖着头,倚在车上打着盹。旁边不远,还有两三辆车,车夫和几个闲人正围在一起下着象棋。不过,因为大门前是闹市区,常常车来车往,陶家也没有什么人在意。\n接连几天都没发生什么意外的事情,门外的车夫好像也不在乎生意的好坏,依然懒洋洋的。这天晚上,淡淡的月光照着,陶会长和女儿闲聊时,突然又说起了陶王两家的婚事:“感情呢,是可以慢慢培养的。要说子鹏,虽然是软弱一点,可这也是他的优点,人老实嘛!跟着他,至少让人放心不是?你们又是表兄妹,也不是完全不了解。我知道,现在说什么你可能也听不进去,可这门亲终究是定好的事,爸也不能随便跟王家反悔,你好好想想吧!”\n斯咏一听这事情就心烦,也不理睬父亲,沉着脸就出了大门,连管家叫她也不搭理。大门一侧的墙角边,那几辆人力车还停着没动,看到斯咏挥手,那个打着盹的车夫微微掀起草帽,向另一个车夫一勾手指,那个车夫便拉车迎了上去。\n“第一师范。”斯咏边说边上了车。\n斯咏坐的那辆车走后,打盹的车夫突然掀开草帽坐了起来,刘俊卿一张还算清秀的脸便暴露在了月光里。他手一挥,后面的一个车夫跑上前,拉起他就走。另外几辆人力车也同时跟了上去。\n陶会长看到女儿出了客厅,以为她只是去院子里转转就会回来,好半天没听到动静,便问管家小姐去了哪里?管家回答说不知道,叫她也没应,只是听她叫车,好像是去什么师范。陶会长眉头一皱,起身说:“备马车,去第一师范。”\n而此时,斯咏全然不知自己已进入了危险境地。入夜的街巷里,稀稀拉拉的只有几个行人、小贩,却有几辆相互跟着的人力车在青石街面上不紧不慢地跑着。最前面一辆车里坐着心事重重的斯咏,一路的街景晃过,她仿佛视而不见,甚至没有注意到车夫挽起袖子的胳膊上,赫然竟露着三堂会特有的刺青。他们身后的车上,刘俊卿眼睛微眯着,似乎在看前面的车、又似乎在看左右的行人。车子转进了一个巷子,里面很阴暗,连一个行人都没有。寂静中,只有人力车的车轮声吱呀呀地响着,刘俊卿腾地坐直了身子,手一挥,几辆人力车便同时加快了速度。\n斯咏听见了身后越来越近的脚步和车轮声,回头一看,僻静无人的街道上,好几辆人力车左右包抄,正向她围来,她不由得慌了,叫道:“车夫,快,快一点!”拉车的车夫不但没加快,反而停下了,他转过身,嘿嘿一笑:“对不起,陶小姐,跑不动了,休息一下吧。”\n斯咏一看这人咧开的大嘴缺了门牙,居然就是想强娶一贞的老六。斯咏还没来得及惊讶,几辆人力车已经从四面围了上来。暗夜中,寒光闪动,绳索、麻袋之外,好几个人手上还亮出了刀。斯咏吓呆了,尖叫道:“救命啊!救命啊!”\n几个人原以为计划万无一失,正要下手,后面却传来了马蹄声,一辆马车正朝这边疾驶而来。马车正是陶会长的,听见呼救,他猛地掀起车帘叫了声:“斯咏!”探身一把抢过了车夫的鞭子狠劲地抽着马。马车发疯般向前冲去,围上来的三堂会打手们猝不及防,吓得赶紧避让,马车撞翻了后头的人力车,直冲向前。\n“斯咏,快上车,快上车啊!”陶会长挥鞭抽打着欲上前阻拦的打手们,斯咏趁机冲过去,陶会长一把将她拉上了马车。\n刘俊卿已经回过了神,对着几个手下叫喊着拦住他、拦住他!前头的老六推起一辆人力车斜刺里冲上——马车“砰”地撞翻人力车,继续向前冲去,但站在车横梁上的陶会长被车子这一震,却摔下了车。\n“爸!爸!”\n“快跑,别管我,快带小姐跑!”\n在父女二人的喊叫声中,马车夫狂催车驾,马车狂奔而去。\n这一阵喧闹惊动了街两旁的居民,看到远远的有人嚷嚷着跑了出来,刘俊卿喝令手下把陶会长塞进麻袋里,赶紧撤退。\n但他们已经跑不掉了。\n斯咏乘着马车狂飙到一师找到毛泽东,说明了刚才发生的情况。尖锐的哨声骤然响起,划破了校园的宁静,正在休息的学生军马上投入了战斗,持着木枪,在毛泽东的带领下蜂拥来到刚刚出事的街面上,却只看到被撞得东倒西歪的那几辆人力车。\n斯咏急哭了,对着巷子两头大喊:“爸,爸!”\n毛泽东安慰她说:“你别着急,千万别着急,这帮家伙跑不远。大家听着,一连跟我走,二连往那边,连分排,排分班,每条街每条巷,分头去追!”\n“抓强盗啊!抓强盗啊……”一时间,四面呼应的喊叫声打破了黑夜的宁静,大街小巷,众多学生军分头追赶寻找劫匪。\n不远处的江边,正和秀秀闹着别扭的子鹏正抱着木枪心不在焉地练刺杀。木枪乒地刺在树上,却刺得太偏,向旁边一滑。子鹏咬着牙,盯着树干中间用粉笔画出的白色圆圈,再刺,枪又刺在了圈外。他定了定神,瞄了瞄,又一次刺出,却还是刺偏了。木枪单调而执著地击刺着,作为目标的大树已经被刺掉了不少树皮,露出了斑斑白印,但却几乎没有一处落在粉笔画成的白圈里。眼前的大树仿佛成了某个可恶的仇人,子鹏越刺越快,越刺越猛,直刺得喘着粗气还在拼命地刺着。猛地,木枪刺了个空,子鹏一个踉跄,撞在树上,枪失手跌落,他颓然跌坐在树下,仰头靠在了树上。\n“抓强盗啊!”学生军的呼喊隐隐传来。子鹏听见了喊声,站起身来,探出头打算看看发生了什么事情,却又吓得猛一缩头:他看到就在前面不远处,有几个人正抬着麻袋,朝这个方向跑来。\n一个抬麻袋的人实在累得不行,突然失足摔倒,麻袋一沉,其他几人也东倒西歪。\n“怎么回事,还不快点?”这分明就是刘俊卿的声音!\n“二爷,不行……实在是抬不动了……”\n“抬不动也得抬!给我起来,都起来,快!”\n在刘俊卿的吆喝声里,那几个人爬起来,拖着麻袋勉强向前走,渐渐地走近子鹏藏身的大树了。子鹏吓得紧紧靠在树身上,攥着木枪,紧张得牙齿都在不住地打战,全身上下,仿佛都僵硬了。打手们拖着麻袋,正从树旁经过,麻袋挣扎、扭动着,一阵阵绝望的闷哼正从里面传出。这绝望的声音让子鹏忘记了危险,他深深地吸了一口气,端着木枪突然跳了出去,拦在那群人前面!\n那一群人大吃一惊,扔掉麻袋,举起了雪亮的刀。但随即,他们就看出来了,拦在面前的,只有一个人。\n“王子鹏?”刘俊卿眉头一皱,“你也敢管闲事了?给我滚开!”\n子鹏喘着气,紧张得握枪的手都在不停地发抖,话也说不出来,只是使劲一摇头。\n刘俊卿盯着那抖动不止的枪头,笑了:“还逞英雄?王少爷,你怕是裤子都快尿湿了吧?赶紧滚!不然我不客气了!”\n“跟他废什么话?宰了他!”老六挥刀冲了上来。\n猛地,子鹏一声大吼:“杀!”\n木枪一记标准的刺杀,干净有力,正中老六胸口,老六仰面朝天,摔出老远!\n“快来人啊,强盗在这边!”这一枪准确的刺杀给了子鹏勇气,他终于声嘶力竭地大喊出来,而且一面呼救,一面挥舞木枪,与打手们拼命搏斗。\n寂静的夜里,子鹏的声音传出老远,斯咏和所有的学生军都听到了,一起朝江边拥了过来。\n势单力孤的子鹏终于抵挡不住,老六抢过木枪砸在子鹏头上,子鹏一头晕倒在地。四面,喊杀声、脚步声已然临近,打手们都慌了:“二爷,怎么办?”\n“还能怎么办?分头跑!跑出一个是一个!”\n打手们四散狂奔,老六捡起一把刀,想杀子鹏以报刚才刺杀之仇。可当他对准子鹏,举起刀时,有一柄匕首却已经从他的后背直穿过前胸!他回头一看,发现暗算他的,竟是刘俊卿。刘俊卿贴在他耳边,面上带着笑,口气却是狠狠地:“还记得被你逼死的赵一贞吗?我到三堂会,等的就是今天。”他手一松转身飞快地跑了,身后,老六一头栽倒在地。\n四面涌来的学生军围追堵截,一个个还没来得及跑掉的打手被当场生擒。解开麻袋,毛泽东和张昆弟扶起陶会长。斯咏一头扑进了父亲的怀里,陶会长反而拍着女儿的背安慰她不要哭,仿佛刚才装在麻袋里的是斯咏而不是自己。斯咏擦了一把眼泪,对父亲说:“爸,是……是润之他们救了您。”\n“陶伯伯,我们也是后来才到的。”毛泽东往旁边一让,指着蔡和森、萧三扶着的头上带伤的子鹏,“真正拼命救了您的,是这位王子鹏同学。”\n“子鹏?”\n“表哥?”\n“岳……”子鹏犹豫了一下,颇为艰难地叫了声,“岳父。”\n所有的人都愣住了,所有的目光,一下子都投在了子鹏和斯咏的身上。斯咏的脸,一下子涨得通红,恨不得地上有条缝能马上钻进去。\n五 # 因为涉嫌绑架和杀人,三会堂被查封了,三会堂的喽啰大都被警察抓住,只有马疤子和刘俊卿逃脱了,但各处交通要道都贴了通缉他们的告示。万般无奈,他们躲进塞满了鸡笼的船舱,打算逃离长沙。船到江心,马疤子和刘俊卿才战战兢兢地掀开笼盖,擦着满头满脸的鸡毛、鸡屎,探出头来透气,他们俩都穿着一身脏兮兮的破衣服,全没了往日的威风。\n打量着自己的狼狈样子,马疤子一肚子闷气实在是无处发泄,狠狠踹了刘俊卿一脚:“我操你个外婆的!我怎么就信了你这混账东西?几十年的基业,就他妈毁在你手里!我、我恨不得掐死你!”\n“行了,老大别怨老二,我还不一样,陪着你逃命?”刘俊卿看起来倒不像马疤子那样沉不住气。\n长叹了口气,马疤子悄悄向舱外探头,看到江水滔滔那一边,长沙城正渐渐远去,他恶狠狠地说:“长沙城啊长沙城,你等着,马爷我总有一天会回来的!”\n第4部分\n[手机电子书网 Http://www.bookdown.com.cn]\n第二十五章 学生人物互选 # 这两个学生,我必须了解。\n因为中国的未来,\n既不能缺少蔡和森的睿智,\n也不能缺少毛泽东的天才。\n一 # 1917年的6月,火热的夏天。绑架事件之后,所有人对王子鹏的印象都发生了转变,毛泽东也一样。\n一师操场,随着子鹏一声口令,十来个同学一齐立正。子鹏小跑到毛泽东面前,立正,敬礼:“一连下士王子鹏报告上士,本班全体集合完毕,请上士下令!”毛泽东没有马上作声,伸手给子鹏扣紧了军装最上面的扣子。四目相对,两个人都会心地微笑了。毛泽东拍了拍子鹏的肩膀:“开始巡逻吧。”\n“是!向右转,出发!”带着从未有过的自信与昂扬,子鹏带队向校门外走去。\n透过校长室的窗户,看到子鹏和学生军消失在大门外,孔昭绶将目光移向了飘扬的校旗,一时感慨顿生:“男儿何不带吴钩,收取关山五十州。”\n毛泽东组建学生军的建议能得到孔昭绶的首肯,是因为孔昭绶自己也是一个极度崇尚曾文正公的学人,想投笔从戎是多年的夙愿,并不是突生壮志豪情。他一直有个从军梦,常常和同事朋友谈起,这辈子如果不教书了,一定要去当个将军。现在看到毛泽东他们的学生军练得那么好,这样的心情更是迫切了。他一直都相信杨昌济的眼光,相信毛泽东是个非凡的学生,但也只是个学生而已。他却没想到,原本只是想让学生练就一点尚武精神,而毛泽东硬是把二百学生给练成了二百军人,连一师附近这一片的治安巡逻都让他们担起来了,还真是大出他的所料。\n正因为这个原因,他才把杨昌济请来,和他谈起了自己的新想法: “这一段,我们的学生自治运动开展得不错,趁着这个学期结束,我想用一种全新的形式总结一下。”\n当杨昌济听说这全新的形式就是学生人物互选时,立刻便表示支持,并就具体细节和孔昭绶进行了探讨。\n互选通知一公布,立刻就成了读书会成员们最关心的话题。周末到了君子亭,不需要谁提醒,大家就你一言我一语地议论起来了。\n“通知都出来了,明天公开投票,德智体三大项,下面分人品、自治、文学、言语、才具、胆识、体育等等十个小项。”萧三兴致勃勃,“每人限投两票,学生选学生,老师不参与。这可不是老师给谁打分呀,是全校同学投票,谁在同学里头最有威信,最得人心,最能服众,人心一杆秤,当场见分晓的。”\n毕业后已经到楚怡小学教了两年书的萧子升留意到今天斯咏没有来,心里说不出有多失落,对大家正在讨论的问题也很不以为然:“选上又怎么样?”\n但他的声音太微弱了,根本没引起大家的注意。性急的警予扯着大嗓门问:“哎,你们说,这次人物互选,你们一师范谁能得票第一?”\n开慧想也不想就回答:“那还用说,当然润之大哥。”\n何叔衡也点着头:“嗯,我也觉得应该是润之。”\n“不见得吧?就他那个成绩,数理化音乐美术,几门都不行,还优秀学生?”\n子升可不看好这个严重偏科的毛泽东,但他这句话却很让毛泽东不服气,毛泽东自信地反问:“又不光是选成绩,除了成绩差一点,未必我毛润之没有优点了?”\n子升看看他猴急的样子,故意激将道:“你这个意思,这个第一非你莫属喽?”\n“我没这么讲啊,谁得第一,大家选嘛。”毛泽东赶紧缴械,不敢再就这个话题和子升纠缠下去。\n警予的目光投向身边一直没作声的蔡和森,蔡和森却淡淡地微笑着,全无开口的意思,她耐不住了:“要我说,一师的优秀学生,这里也不止一个吧?”\n子升听出了警予话里的弦外之音,笑笑,明白地说:“对,要是我来投票,我就投给蔡和森。”\n一时间,李维汉等几个同学赞成子升的提议支持蔡和森,张昆弟、罗学瓒连声附和萧三支持毛泽东,局面基本成了一半对一半。\n子升温和地煽着风:“润之,和森,你们两个自己说,谁会得第一。”\n毛泽东立即表态:“选别人我不讲,选蔡和森,我毛润之一百个服气。”\n所有人都等着蔡和森开口。蔡和森平静地微笑着,摊开了手里的一本书:“我们今天不是讨论黑格尔的哲学原理吗?还是谈正题吧。”\n学生互选,别说在一师是首创,在全国的学校里也是首创呢。这么大的一个举措,学生们在讨论,老师们也在讨论。\n教务室,孔昭绶率先提出了这个大家都感兴趣的话题:“列位先生,你们猜猜,这次学生互选,谁的票数会第一?”\n正在忙着的老师们都放下了手里的工作,连老学究袁吉六也端起了水烟袋,自信地说:“要说谁能得第一,袁某心里,倒认准了一个。”\n费尔廉和老先生开玩笑:“我也觉得有个学生一定会得第一,不知道和袁先生想的是不是同一个人。”\n两人正要说,孔昭绶打断了他们:“哎,两位先生且慢,我有个建议,咱们在座的先生学一回瑜亮,各自把答案写出来,一起公布,再跟学生投票的结果作个对照,大家觉得怎么样?”\n看看老师评价学生,跟学生自己评价学生,结论是不是一样。这倒是个体察教育心理的好办法。这个建议显然更有挑战性,老师们互相看看,都点了点头。\n于是先生们各自提起了笔……六张写着答案的纸凑到一起,同时翻转过来:孔昭绶、徐特立、袁吉六写的是“毛泽东”,黄澍涛、饶伯斯、费尔廉写的是“蔡和森”,结果恰好三对三。\n费尔廉叫道:“怎么只有六票,刚才我们不是七个人吗?”\n大家回头一看,却发现杨昌济一个人还坐在原处,面前的白纸仍然空着。\n大家都等着板仓先生一票定乾坤,杨昌济反倒把笔放下了。\n“要说一师的人才,出类拔萃者,不外乎蔡、毛二人。蔡和森嘛,锋芒内敛,外柔内刚,那是表面平静却蕴藏着无穷力量的水,毛泽东呢,纵横恣肆,张扬不羁,就像一团熊熊燃烧光芒四射的火焰。如果单以个人喜好而言,倒是蔡和森的中正平和更对我的胃口。不过,这次学生互选,他们两个具体的得票率,却一定出乎大家的意料……这样吧,”杨昌济抽出钢笔,在白纸上刷刷写上两行字,折起来,说:“这里是我预测的选举结果,至于准不准确,等选举结果出来以后,我们再打开当场核对,好不好?”\n孔昭绶笑起来:“想不到昌济兄居然童心未泯,行,那我们就静候你的铁口神算了。”\n大家都笑了,暗自猜测着这个问题,最终会是什么结果。\n二 # 一师礼堂,一师全体学生聚集一堂,“第一师范学生人物互选”横幅悬挂在主席台上,主席台的桌上,一列排开的十来个票箱上,分别贴着“敦品”、“才具”、“自治”、“言语”、“胆识”等标签。\n台下,大家正在互相议论着。“各位同学,”主席台上的方维夏开始主持互选了,“今天,是我们第一师范学生人物互选的日子,也是第一师范第一次由全体同学投票决定,谁,是大家心目中最优秀的学生。在座的每一位同学,都可以凭自己心中的标准,按台上的分类,投出两票。下面,我不多说,给大家十分钟考虑,十分钟后,开始投票。”\n一片热烈的交头接耳声中,只有蔡和森静静地坐着,考虑着,仿佛是拿定了什么主意,他突然站起身来,找到主席台一侧的方维夏,说: “方老师。我有个请求,不知道行不行……”\n方维夏看着眼前这个优秀的学生,一时间不知道说什么好,只是拍拍他的肩膀,点点头。然后,他走向主席台,宣布:“ 各位同学,在投票开始以前,先跟大家说明一个情况,本科第六班蔡和森同学主动要求担任本次学生互选活动的计票人,为保证选举的公正,他本人提出,不再参加这次选举,也不接受大家对他的投票。如果有人已经填好了投给蔡和森同学的票,可以到台前来领取新的空白选票,另填其他同学。好,下面,开始投票。”\n台下的安静一下被打乱了,这个情况显然大出同学们的意外。\n萧三看看手里一张填好了蔡和森名字的选票:“这个蔡和森,怎么突然不参加了?”\n“就是嘛,昨天又不说,害得我也白填了。”李维汉也是满脸的不高兴。\n他们俩与不少填好蔡和森的票的同学纷纷揉掉了那张选票。人群中,毛泽东却坐着没动,带着责备,他的目光投向了台侧的蔡和森。蔡和森避开了他的目光,走上了主席台。毛泽东打开了自己填好的选票,他的两张票,写的正是他自己和蔡和森。\n“敦品:陈绍休,周世钊,邹彝鼎,毛泽东……”\n方维夏的唱票声中,蔡和森在小黑板上一笔笔计着票数。\n“文学:李维汉,毛泽东,萧植蕃,罗学瓒……”\n小黑板上,当选的同学名字渐多,毛泽东的名字不仅出现在已经唱到的每一项目下,而且几乎都是得票最高的。\n“言语:毛泽东。”方维夏唱过此项唯一一张选票,又打开下一个票箱,“胆识:毛泽东。”\n方维夏又打开一个票箱:“综合,蔡和森……”他愣住了。\n蔡和森也不由得一愣,转过身来看。选票上是毛泽东特有的飞扬张狂的字迹。台下,毛泽东的目光平静而坚定,直视着蔡和森投来的目光。目光交会间,蔡和森已然明白了这一票的来历。他轻轻抽掉了这张票,对方维夏:“这张是误投,方老师,念下面的吧。”\n互选很快结束了,方维夏回教务室,正打算向校长和各位老师报告结果。 “等一下,方先生,等一下宣布。”费尔廉打断了他,跑到杨昌济桌前,伸出手,带杨昌济将那张白纸放到了他手上,他这才说,“方先生,请宣布吧。”\n“得票总数,第三名,邹彝鼎;第二名,周世钊;第一名,毛泽东。”\n孔昭绶惊愕地:“那蔡和森呢?”\n“蔡和森主动要求担任计票,所以退出了选举。”\n打开白纸,费尔廉惊讶得嘴都张大了。袁吉六凑上前,念道:“毛泽东得票第一,蔡和森一票不得!……哎呀,板仓先生,神了您啊!”\n费尔廉连连摇头:“太神奇了,不可思议,简直不可思议!”\n众先生正在纷纷叹服,方维夏却道:“不,我虽然宣布了不用投蔡和森的票,但还是有人投了他一票。”\n“哦?”几个老师都愣住了。\n杨昌济只思考了几秒钟,就拍拍脑袋:“我明白了,是润之,一定是润之投的。以毛泽东的个性,但凡遇到这种胜负之争,他一定当仁不让;而蔡和森呢,却绝不会跟润之争这个胜负,所以一定会想一个既不损害这次学生互选的公正性,又能回避与润之一争高下的办法来退出选举,这些,我都算到了。可我却疏忽了一点,润之这个人,才不会理会什么投票的规则呢。他觉得谁优秀,这一票他就一定投给谁,蔡和森退不退出,都不可能改变他的想法。”\n一片敬佩的静默中,孔昭绶拍了拍杨昌济的肩:“知人之明,莫过于昌济兄啊。”\n静了好几秒钟,杨昌济来到窗前,推开了窗子。望着窗外飘扬的校旗,他仿佛是在回答孔昭绶,又更像是自言自语:“这两个学生,我必须了解。因为中国的未来,既不能缺少蔡和森的睿智,也不能缺少毛泽东的天才。”\n三 # 选举结束后,蔡和森约警予到湘江边散步。话题还是从那天的读书活动开始的,其实,那天并不是只有萧子升一个人留意到斯咏没来,只不过大家要么没多想,认为有事情耽误很正常;要么隐约知道些什么,却不好多问。这个时候,没有其他的人在场,蔡和森这才问起警予。\n“你是关心毛泽东还是关心斯咏呀?”警予明知故问,她怎么会不知道蔡和森是为了毛泽东才问起这件事情的呢。\n警予告诉蔡和森,别看斯咏有时候疯疯癫癫,有时候诗情画意,好像比谁都浪漫,其实,她骨子里是个特别传统的女孩。自从她和王子鹏的关系被大家知道以后,她就一直不肯去一师范,一直不肯见毛泽东,为什么?因为她害怕。可惜啊,她跟毛泽东相处那么久了,但对他的了解,还不如小丫头开慧。开慧都知道在毛泽东眼里,什么规矩、准则、三纲五常,根本就不值一提,这世上还有什么传统是他不敢蔑视的,更何况父母之命之类老掉牙的玩意儿?斯咏和毛泽东之间的问题,并不在于斯咏有没有订娃娃亲,而在于他们的个性是不是适合。毛泽东吸引斯咏的,是豪迈,是奔放,是那种一往无前,无所顾忌的个性,是蔑视一切,开创一切的勇气和信心。可这一切的背后是什么?是斯咏梦想的浪漫和温情吗?是斯咏渴望的平安和幸福吗?不,他所有的,是抱负,是理想,而不是只属于两个人的温情和浪漫。也就是说,斯咏所希望的,和毛泽东所追求的,其实并不是一回事。警予最后说:“我担心的,是斯咏一直陷在某种幻觉里,在用梦编织一种不切实际的期待。”\n蔡和森听着警予的分析,认真听着,一直没有打断她,直到警予把所有的话都说完了,才问:“你把你的这些想法都告诉她了吗?不知道她以后会怎么和润之相处。”\n“人家会怎么想呢,我就不知道了,不过,”警予停下脚步,侧过身,与蔡和森对面站着,说,“我现在是越来越觉得,你应该改个名字,叫毛和森算了。”\n蔡和森一愣,问道:“怎么,我放弃选举,你不高兴了?”\n“得了,我有那么小心眼吗?其实,我早应该想到你不会去争这个胜负的。如果你像润之那么好胜心切,那也不是蔡和森了。”\n“你也以为我是在让着润之?”\n“难道不是吗?凭你在同学里头的威信,真要选,我就不信选不过他。”\n蔡和森站住了,望着远山苍翠,湘江浩荡,他深深吸了一口气,说:“警予,你猜错了,我根本没想过要让着谁,我只是觉得,那些选票不配由我来得。”\n“你不配?”\n“对,我不配,因为有润之。不错,润之并不是完美无缺,他的能力,他的才华,更不是生来就超人一等,他甚至有很多这样那样的缺点和毛病,可这一切都不重要,重要的是,他有无比的热情,无比的斗志,有我和其他人都不具备的那种蔑视一切挫折、挑战一切困难的勇气和决心。他的身上,永远散发出那么强烈、让人无法抗拒的魅力,那是一种个性,一种什么也压不服、什么也挡不住的火一般的个性,跟他相处越久,我就越深刻地感到,他是那样的震撼人心,那么让我由衷地佩服。我相信,如果我们这些人当中有人能成就一番大事业的话,这个人绝不会是别人,一定是润之。”\n蔡和森说话的时候,表情异常严肃地望着远处的高天流云。警予顺着他的目光看出去,江水滔滔、远山横亘,似乎天地山川都正在回应着蔡和森的那番话……\n"},{"id":129,"href":"/zh/docs/culture/%E6%81%B0%E5%90%8C%E5%AD%A6%E5%B0%91%E5%B9%B4/%E7%AC%AC26%E7%AB%A0-%E7%AC%AC29%E7%AB%A0/","title":"第26章-第29章","section":"恰同学少年","content":" 第二十六章 汗漫九垓 # 欲从天下万物而学之,正当汗漫九垓,\n历游四宇,读无字之大书,方得真谛!\n览山川之胜,养大道于胸,以游为学。\n一 # 1917年的暑假到了,萧三回了老家,子升一个人待在楚怡小学自己的房间里正看书,毛泽东却拿着一张报纸进了门。\n他把那张《民报》摆在子升面前,手指敲打着一则报道的标题:“《两学生徒步漫游中国》,看看人家,一分钱不带,一双光脚杆,走遍全国,一直走到了西藏边境的打箭炉,厉害吧?”\n子升读着报道,不禁露出了佩服之色:“还真是的啊!嗯,值得佩服。”\n“莫光只顾得佩服喽,见贤要思齐嘛!人家走得,我们为什么走不得?当年太史公不是周游名山大川,遍访野叟隐老,哪来的煌煌《史记》?所以,还是顾炎武讲得对,欲从天下万物而学之,正当汗漫九垓,历游四宇,读无字之大书,方得真谛!”\n子升不禁点了点头:“嗯,览山川之胜,养大道于胸,以游为学,是个长见识的好办法。”\n“所以啊,趁着放暑假,我们也出去游,好不好?”\n“一个暑假,走不了那么远吧?”\n“远的去不了,我们去近的,中国游不完,我们游湖南嘛。我跟你讲啊,我都想好了,要学,我们就学个作古正经,跟他们一样,不准带一分钱,凭自己的本事,走多远算多远。”\n“那不成了讨饭当叫花子?”\n“讨饭怎么了?一不偷二不抢,讨得到也是你的本事,锻炼生存能力嘛。话又讲回来,你我总还读过几本书,写得几个字,两个读书人,未必还真的饿死在外面?那还不如一头撞死算了。”\n子升犹豫着。\n毛泽东激将他:“怎么,不敢去啊?”\n“游就游!谁怕谁啊?我就不信我会比你先饿死。干脆,叫上蔡和森,三个一起去。”\n“老蔡就算了,人家就靠暑假做事赚点钱,莫害得人家下个学期过不下去。你要是拿定了主意,我们明天就出发,好不好?”\n“好,我就陪你去当这回叫花子,一起走遍湖南!”\n第二天,俩人收拾停当准备开拔了,临出门才发现:准备还是不充分,子升与往常一样,一身笔挺的长衫,脚下布鞋整洁,上过油的头发一丝不苟,手里是结实的大皮箱;毛泽东却一身旧得不能再旧、还打了补丁的白色短布褂,一个瘪瘪的布包袱挑在油纸伞柄上,脚上穿着一双草鞋。\n毛泽东看着子升,大笑:“哈,你这是去走亲戚啊,还是去拜岳父老子?”\n子升看看毛泽东,再看看自己,也笑了:的确,自己这哪是去“叫花讨饭”呀,赶紧重新换上一身旧短布褂和草鞋,找了个师傅把头发理成极短的平头,背着油纸伞和简单的蓝布包袱。等他打扮得和毛泽东一样时,两人这才开始他们的正式行程。\n到了江边,正有船要离岸,毛泽东一拉子升:“走。上船喽,不坐船怎么过江?你又不肯游泳。”\n子升看了看船,说:“这是私人的渡船,要钱的,还是多走几里路,到那边搭免费的官渡吧。”\n“搭免费的船算什么本事?我们出来干什么,锻炼生存能力嘛,当然要舍易求难,怎么难搞就怎么搞。他的船要钱,我偏要不花钱去坐坐,那才是叫花子的搞法嘛。”看看子升还在犹豫,毛泽东拉起子升就走,“走喽,你还怕他把你丢到江里去啊?”\n江水如蓝,船篙轻点,渡船平稳地行驶在江心。“口当啷啷”,乘客们依次将铜板投进了收钱的小工手中的那面破铜锣里。挤在二十来个乘客当中,子升被越来越近的收钱声逼得忐忑不安。身边的毛泽东却大大咧咧,昂头打量着浩浩江水。铜锣伸到了二人面前,帮工等了一下,没见二人有反应:“哎,交钱啦!”\n子升瞄了毛泽东一眼,毛泽东仰着脸看着帮工,说:“对不起,没带钱。”\n“没带钱?”帮工眼睛瞪了起来,“没钱你坐什么船?”\n毛泽东笑嘻嘻地说:“那我坐都坐了,怎么办呢?”\n撑船的船夫火了:“嗨,没钱坐船你还坐出道理来了?我跟你讲,一人两个铜板,赶紧交钱!”\n毛泽东继续笑嘻嘻:“老板,我们两个是叫花子,半个铜板都没有,你就行个好,送我们过去算了嘛。”\n“我凭什么白送你们?没钱啊,”船夫看了看他们身上,说“没钱用雨伞顶!”\n“你就想得好啦,一把雨伞四毛钱,你船钱才两分,用雨伞顶,你也想得出!”\n子升有些不好意思了,劝毛泽东:“算了润之,要不,就给他这把雨伞?”\n“开什么玩笑?下雨怎么办,你不打伞啊?你愿意给,我还不愿意亏这个本呢!”\n船夫一听毛泽东这样说,脾气一下子上来了:“哎呀,你这个家伙是存心坐我的霸王船啊?!小五子,把船撑回去,让他们两个下去!”\n他真的调转船篙,要把船往回撑。船上的其他乘客顿时急了,纷纷嚷了起来:“哎哎哎,怎么回事,怎么往回开?我们怎么办?不行不行,我还有急事。”\n毛泽东乘机说:“看到了吧看到了吧?这里还有一船人,你不顾我们也要顾大家嘛。再说了,这船都走了一半了,你往回撑,湘江上又不是只你一条船,那边的生意不都让其他的船抢走了?为了个几文钱,划不来喽!”\n子升也帮着腔:“是啊,老板,你就当做回好事吧!”\n毛泽东:“你要是还想不通,我来帮你撑船,就当顶我们两个的船钱,这总可以了吧?”\n看看满船的人,再看看身后远远的江岸,船夫没辙了:“碰上你们这种人,算我倒霉!”\n二 # 下了船,走在乡间的小路上,回味着刚才坐船的经过,毛泽东开心的笑声把林间的小鸟都吓得四处乱飞。\n子升白了他一眼:“坐人家的霸王船,你还觉得蛮光彩啊?”\n“我们是叫花子,有什么光彩不光彩?再说了,他的船反正是过江,多我们两个不多,少我们两个不少,总共四文钱,他还发得财到?”\n“我看啊,你不是舍不得出钱,你是天生喜欢跟人对着干。”\n“这句话你还真讲对了。他不是犟吗?我比他还犟,看谁犟得过谁?人嘛,什么事都顺着来,那还活个什么劲?哎,这方面,上个礼拜我还在日记里头专门总结了三句话,叫作‘与天奋斗,其乐无穷;与地奋斗,其乐无穷;与人奋斗,其乐无穷’。”\n山野宁静,树影斑驳,毛泽东的声音在山冲里响起一阵回声。\n子升当然不赞成毛泽东这样说,反驳道:“你这种话不对!人,应该是一个世界和谐的组成部分,人与自然,应该和谐,人与人,更应该以和谐互补为目标,君子周而不比嘛,怎么能以互斗为乐呢?”\n“达尔文怎么说的?优胜劣汰!你说的清静无为,躲到山里当道士可以,在这个世上,它就行不通!”\n“反正我相信这个世界只有和谐才能发展,那些不和谐的互斗与纷争,终归没有前途。”\n“事实胜于雄辩,事实证明我斗赢了嘛,你还有什么话说?”\n“好好好,我不跟你争。”\n这天傍晚,两人便露宿江边。江水潺潺,一轮圆月亮如银盘,镶嵌在暗蓝暗蓝的夜空。月光映照下,宁静的夜空是那样纯净无瑕,那样深邃无边,仿佛要将一切人、一切事、一切烦忧融化在其中……\n“前不见古人,后不见来者。念天地之悠悠,独怆然而涕下。”子升枕着双手,躺在毛泽东身边,遥对夜空,吟起了陈子昂的诗。\n毛泽东最不耐烦子升来这一手,抗议道:“莫动不动就涕下涕下喽,清风明月,水秀山青,哪那么多眼泪鼻涕?”\n“那你想起什么?”\n“我想起啊?‘明月几时有,把酒问青天。不知天上宫阙,今夕是何年?’”\n“怎么,想当神仙了?”\n“神仙是修不成器了,不过,对着这么好的月亮,还真是想飞上去看看。看不到嫦娥,也可以看看吴刚砍桂花树嘛!”\n“那我宁愿看嫦娥。”子升突然转过了身子,撑着脑袋,问毛泽东,“哎,你说,我们在这儿看月亮,有没有人也在看着月亮想起我们?”\n毛泽东会心一笑:“谁会吃饱了没事,想你想我?不过,也难说,杨老师肯定会想我们的,我们到了前面镇子,给他寄封信吧?”\n三 # 他们的信很快就到了正在板仓老家过暑假的杨昌济的手上。油灯下,向仲熙正坐在杨昌济身边,与他看着一封信。开慧趴在一旁,急不可待问道:“爸,毛大哥信上都说了些什么?”\n“也没什么,说了一下路上大概的经历,再就是问候大家。”\n“有没有提到我?”\n“有哇,最后一句:代问师母及和森、斯咏、警予、子暲、叔衡、蔡畅、开慧小妹好。”\n“就一个名字啊?”\n看到女儿嘟起了小嘴,向仲熙开导她说:“总共一页纸,你还想他写多少?”\n“那萧大哥呢?”开慧想,毛大哥不记得我,萧大哥该记得吧?\n“子升倒是来了封长信,不过信里一大半内容是问候斯咏的,我已经叫人转给斯咏了。”\n爸爸的回答,让小开慧更失望:“一个个都不记得我,没劲!”\n开慧没有收到问候失望,斯咏收到了问候也一样很失望。在精致的台灯下,斯咏轻轻放下了子升的长信,目光却移到桌上那本《伦理学原理》上。她打开的扉页,看看是那句“嘤其鸣矣,求其友声”,叹息一声,轻轻把书合上了,又抬头望着窗外的月光\n在这样的夜晚,照耀着毛泽东的,不仅仅有月光,还有如空气一样存在着却看不见的母爱。在韶山冲毛家的厢房里,一盏调得小小的、微弱的油灯光闪动着,门口,半就着油灯光,半就着月光,文七妹正在纳着一只布鞋。她身边的小竹椅上,摆着已经做好了的两双崭新的布鞋。\n毛贻昌来到门口,在门槛上磕去了旱烟锅里的烟灰。拿起崭新的布鞋打量了一眼,他把布鞋扔回到竹椅上,想要关心妻子,但说出口的语言却是生硬的:“半晚三更,觉不睡觉,你怕是没累得?莫做哒。”\n文七妹头没抬,手没停,嘴里却答应着:“好了,就完了。”\n毛贻昌在她的身边蹲了下来,没头没尾地说:“一个暑假,人影子都没看见,做做做,做给鬼穿?”说是这么说,他却从口袋里摸出了半包皱巴巴的香烟,放在鼻子下闻——毛泽东进一师后第一次回家过年给他买的烟,他居然还没抽完!\n看到老婆微微地笑着看着自己,毛贻昌觉得有点尴尬,把烟往口袋里一塞,装起了一锅旱烟。看到老婆又埋头去纳鞋,他想了想,含着烟嘴,把油灯调亮了些。\n四 # 炽烈的正午骄阳下,毛泽东与子升到了安化县境,来拜访安化县劝学所所长、学者夏默安。\n安化县劝学所坐落在一片青翠宁静的山坡旁。门人进去通报了,毛泽东和萧子升扎在门外,看里面藤萝蔓绕,绿杨依依。院子一旁,池塘青青,荷叶田里,夏季盛开的荷花中,蛙声句句,更衬托出这书香之地的恬静清雅。\n正在看书的夏默安一身雪白的绸衫,戴着眼镜,摇着一把折扇,他六十来岁,表情古板,是个性格执拗沉闷的老先生。听了门人的通传,他继续看着书,头也不抬地说:“不见。”\n大门“咣口当”关上了。毛泽东与子升面面相觑。\n子升叹了口气:“唉,早听说夏老先生的大名,还想着当面求教一番,没想到却是闭门不纳啊!”\n“人家饱学先生,那么大的名气,你讲两个毛头学生来拜见,也难怪他没兴趣。”\n“也是啊,只好打道回府了。”\n“打道回府?开什么玩笑?来都来了,他不见就不见啊?”毛泽东沉吟了一会,说,“他不见,是不晓得我们有没有真本事,值不值得见,我们写个帖子递进去,让他也看看,我们不是个草包。”\n很快,两个人写的一首诗送进了劝学所内,送信的年轻门人给夏默安读了出来:“翻山渡水之名郡,竹杖草履谒学尊……”\n夏默安的头突然抬起来了,手一伸:“拿来我看。”\n诗递到了他的手上。纸上,子升漂亮的字体,首先已让夏默安眉心微微一挑,他继续读:“途见白云如晶海,沾衣晨露浸饿身。”\n他不禁轻轻吸了一口气,说:“请他们进来。”\n进了门,毛泽东与子升正襟危坐,有些局促地看着对面的夏默安。夏默安还是那样面无表情,眼睛盯着手里的书:“萧子升,毛泽东?”\n“是。素仰夏老先生大名,所以特来拜见。老先生的《默安诗》深得唐宋大家之意,遣词凝练,立意深远,《中华六族同胞考说》更是洋洋洒洒,考证古今,学生在长沙,就早已心向往之……”\n夏默安根本没理子升的赞誉,随口打断:“省城呆得好好的,为何出来游学啊?”\n讲了半截话就被打断了,子升被弄得一噎。\n毛泽东不像子升那样文绉绉的,他大声回答:“游学即求学。”\n“哦?有书不读,穷乡僻壤,山泽草野,有何可求?”\n毛泽东依然大声回答:“天下事,事皆有理,尽信书,不如无书。有字之书固然当读,然书中不过死道理,世事洞明皆学问。故学生二人,欲从山泽草野,世间百态中,读无字之大书,求无字之真理。”\n夏默安的头终于抬了起来,脸上,也现出了笑容:“上茶。”\n两杯清茶摆在了毛泽东与子升面前。\n窗外,绿杨轻拂,鸟鸣声声。\n夏默安收回了望向窗外的目光,突然提起笔来:“老夫有一联,请二位指教。”\n他挥笔写下,将上联移向毛萧二人这边,上联是“绿杨枝上鸟声声,春到也,春去也。”\n子升不禁与毛泽东交换了一个商量的目光。\n窗外,蛙声阵阵,毛泽东的高个子使他恰好能将一池碧水,夏日荷花,一览无余。\n“晚生斗胆一试。”毛泽东拿起笔,在纸的另一半上写了下去。\n一副对联顷刻已成,呈现在夏默安面前。\n“清水池中蛙句句,为公乎,为私乎?”夏默安读出下联,黯然半晌。\n移目窗外,鸟鸣蛙声,相映成趣,这上下联与眼前景象,当嵌合得天衣无缝,而下联的立意之深,也显然远超上联。\n他突然转头向外,提高了嗓门:“准备晚膳,收拾客房!”\n转向毛萧二人,一揖手,脸上已满是敬意:“两位小学弟,如蒙不弃,今晚便留宿寒舍,与默安畅论古今,对谈学问,谈他个痛快,意下如何啊?”\n五 # 拜别了夏默安,第二天,毛泽东与萧子升进了安化县城,这县城虽不大,却是街道古朴,店铺毗接,一派质朴的祥和。虽是一路同行,子升却仍然保持着清洁整齐,远不似身边的毛泽东,衣服皱巴巴的,脚下沾着泥点。\n“嗯!”毛泽东使劲地吸了吸鼻子,“好香啊,红烧肉,肯定是红烧肉!”\n子升顺着他的目光望去,对面恰好是一家饭馆,挂着“醉香楼”的招牌。\n子升问:“嘴馋了?”\n“二十几天嘴巴就没沾过油,未必你不馋?”\n“馋有什么用?还不是白馋?”\n毛泽东咽了一口唾液:“也是啊,再大方,也不会有人给叫花子打发红烧肉啊!哎呀,越闻越流口水,走!离它远点!”\n两人正往前走,只听“乒乒乓乓”,一家新开的店铺前,一串鞭炮正在热烈地炸响,门上是崭新的招牌,两旁是崭新的对联,店老板打躬作揖,正在接待到贺的街坊。\n“来去茶馆?”路过的毛泽东也看着热闹,“这是新开张啊。”\n子升眉头皱了起来:“哎,你看那副对联,平仄不对啊。”\n毛泽东一看,对联写的是“有茶有酒,香飘满楼”,不禁头一摇:“何止平仄?根本不是那回事嘛。”\n这话却让店老板听见了,他一拱手:“两位,我这副对联对得不好吗?”\n毛泽东:“你这个,不是对得不好,只怕连对联都算不上。”\n店老板:“哎哟,你看,我也没读过什么书,这副联是请别人写的,见笑了,两位既然是行家,就请赐一副联怎么样?”\n毛泽东一拍巴掌:“你算找对人了,我这位朋友对对联的本事,长沙城里都是有名的。”\n店老板一听,越发客气起来:“原来是省城来的秀才啊?那更要请你们留个墨宝了。”他一个劲地向子升拱着手,“这位先生,帮个忙帮个忙。”\n子升一时盛情难却,也便拿出了笔墨,店老板也赶紧裁来了红纸,子升仰头看看“来去茶馆”的招牌,略一沉吟,落下笔去,一副对联一挥而就:\n为名忙,为利忙,忙里偷闲,喝杯茶去\n劳心苦,劳力苦,苦中作乐,拿壶酒来\n旁边的观众们一片啧啧称奇声,就算看不出意思好坏,子升的一手字也已令大家叹为观止。\n店老板双手捧上了一个红包:“这位先生,多谢多谢,谢谢先生了。”\n子升赶紧推让:“这怎么好意思?”\n店老板:“些许心意,权作润笔,不成敬意,不成敬意。”\n子升还想推辞,毛泽东伸手把红包接了过来:“老板的心意,我们也莫讲客气了。”\n红包里倒出的,居然是两块光洋!站在街拐角,毛泽东和萧子升两个人你看看我,我看看你,再看看满街毗接不断的各种店铺,眼睛都亮了。\n两人当下就用这两块大洋买来红纸,租用了一个 “代写书信”的字摊,抄来一些比较像样的店铺的名称,开始“做生意”了,对联由子升写,讨钱的事情由毛泽东去做。\n他们的“生意”果然还不错,子升挥笔如云烟,毛泽东则一家家店铺跑去,一个下午,眼看着满街渐渐都换上了子升写的新对联,对联摊子前,看热闹的路人也越挤越多,子升的构思和书法成了当街最精彩的表演。\n便在这时,只听得一阵吆喝:“让开让开,都让开!”一个剽悍的家仆扒开了围观的人群挤了进来。\n子升停住了笔,抬起头,看到人群外停着一乘轿子,一个六七十岁、一身长袍马褂,翘着稀疏的山羊胡子的干瘪老头,正昂着脑袋大模大样地走了进来。\n这人显然来头不小,围观的人们都赶紧退让,几个士绅忙不迭地点头哈腰:“丁老爷……丁老爷好……”\n老头眼睛斜也没斜那些讨好打招呼的人一下,径自来到摊前,斜睨着写好的两副对联。看着看着,他昂得高高的脑袋突然低下了,神情一下子专注起来,拿起了一副对联,架起挂在胸前的眼镜,仔仔细细,从上看到下,再从下看到上,仿佛是有些不敢相信一般,目光转向了子升。\n子升问:“这位老先生,这对联有什么不妥吗?”\n老头没答他的话,却冒出一句:“你多大了?”\n“晚辈今年22岁。”\n“22岁?”老头又打量了子升一眼,问,“从哪里来?”\n“长沙。”\n“萧菩萨,写完了没有?”毛泽东风风火火,一步冲进人群,“那些我都送完了,收获不小啊!”\n他“哗啦”一声,把一大堆光洋、铜元堆在了桌上,忙不迭地收拾着剩下的对联:“剩下这两副赶紧送掉,我们好好吃一顿去!哎,不好意思啊!”\n他顺手把老头拿在手里的那半副联扯了过来。\n那名悍仆登时就要发作,老头却用目光制止住了仆人,他皱着眉头,打量了毛泽东一眼:一身皱巴巴,草鞋、裤脚上还沾着泥点的毛泽东,与文雅洁净的子升实在不像一路人。\n“在这儿等我啊。”毛泽东又急匆匆地冲出了人群。\n子升不禁有些不好意思:“老先生,我这位同学性子急,失礼了。”\n老头:“这是你同学?”\n子升:“是,我们一道游学,路经贵县,行囊拮据,故出此下策,让老先生见笑了。”\n老头瞄了桌上那堆钱一眼,再看看桌上笔墨与子升白净秀气的手,摇了摇头:“可惜了。”\n他大咧咧地出了人群。\n子升全然摸不着头脑:“这位是谁呀?”\n一名士绅对他说:“他你都不知道?丁德庵,我们安化有名的丁老爷,两榜进士,做过翰林的。”\n子升愣住了。直到毛泽东回来,他还没从刚才的惊讶中缓过来:“想不到是位进士翰林,这回我们真是班门弄斧了。”\n毛泽东只顾数着钱:“你管他翰林不翰林?他又不请你去做客。再说了,你那手字,未必会比翰林差。走走走,红烧肉兑现。”\n两人刚刚起身,身后突然传来了一个声音:“这位先生,请留步。”\n两个人回头一看,刚才那名跟着老头的仆人正恭恭敬敬地向子升拱着手,后面还跟着一乘小轿:“我家老爷看了先生的字,对先生的书法十分佩服,专程叫我来请先生过府做客,谈书论道,请先生务必赏光。”\n子升不禁有些惊喜:“丁老爷客气了,晚辈怎么敢当?”\n“先生就不必客气了,我家老爷最喜欢的,就是有本事的读书人。请先生赏个脸吧。”\n子升动心了:“润之,不如咱们去一趟?”\n不等毛泽东开口,那个仆人先抢着:“对不起,我家老爷只吩咐了请先生,没提别的人。”\n“这样啊……”子升不禁有些为难。\n毛泽东倒是无所谓:“哎呀,人家请你你就去嘛,反正我又不想见什么翰林。我吃我的红烧肉,饭馆里等你啊。”\n他径直向醉香楼走去。\n六 # 仆人将子升引入一扇朱漆大门,门上铜钉闪亮,门外镇府石狮威风凛凛,家丁排列,气势逼人。\n古色古香的丁府书房里,两壁皆书,精致的文房四宝,排列在檀木书桌上。正南墙上,挂着一个清朝官员的画像,提着“故中丞丁公树卿老大人遗像”,两旁挂着“诗礼传家”的中堂、“仁义乡里,忠烈遗泽”的对联,和“林隐乡居图”等等字画条幅,芝兰盆景,点缀其间,处处透着显赫的家世和归隐农田的文人雅致。\n“老先生原来是为国尽忠的丁中丞大人后人?”子升不由肃然起敬,“晚生真是失敬了。”\n“哪里哪里。”提到家世,丁德庵显然颇为自得,“丁某不肖,愧对先祖遗泽,倒是这诗礼传家的祖训,未敢轻忘,但求守几亩薄田,温几卷旧书,处江湖之远而独善其身而已。”\n他呷了一口茶,慢条斯理地说:“虽说隐居林下,老夫倒是最喜欢跟肚子里有真才的读书人交朋友,今天有幸一睹萧老弟的书法,颇有汉晋古雅风范,令人耳目一新啊!”\n“雕虫小技,贻笑方家了。”\n丁德庵却话锋一转:“只不过……”\n子升赶紧站起身:“老先生指教!”\n丁德庵挥手让他坐下:“以如此书法,竟当街卖字,不免有辱斯文了吧?”\n子升道:“晚辈倒是记得,昔时板桥先生亦曾将字画明码标价:大幅六两,中幅四两,小幅二两,扇子斗方五钱。可谓书生亦须作稻粱之谋,子升愚钝,困于行旅,只好斗胆学样而已。”\n丁德庵吃了一惊,倒笑了起来:“如此倒是老夫拘泥了。哎,萧老弟书倒是读得很杂呀,连这些野趣杂典也记得,不容易。”\n“当着老翰林之面,晚辈岂敢谈读书?”\n“哎,要谈要谈,读书人不谈读书,难道还谈种田挑粪那些下贱之事么?对了,老夫近日,正在重读老庄二经,不知萧老弟对这两本经熟吗?”\n子升道:“也略读过。”\n“以你之见,此二经,历代注解,谁的最好?”\n“晚辈浅见,注道德经,无过于王弼,注南华经,无过于郭象。”\n丁德庵满意地点了点头,对子升他显然又高看一眼了。\n“方才看老弟的对联,构思奇妙,老夫平时也好对句,正好拟了几副上联,还请指教一二如何?”丁德庵说着,起身踱了两步,手指室内花草盆景:“我这上联曰:室有余香谢草郑兰宝桂树。”\n子升几乎是张口就来:“晚辈对:身无长物唐诗晋字汉文章。”\n丁德庵不由得点头,他略一思索:“这句难一点:劝君更饮一杯酒。”\n子升思索了一阵:“晚辈对:与尔同销万古愁。”\n“嗯,以李白诗对王维诗,上下嵌合,天衣无缝,好,好,好!”丁德庵也颇有了知音之感,情绪上来了,“老夫还有一联,是三十年前翰林院的同仁出给我的,当时满朝翰林无人能对,一时而称绝对,萧老弟大才,今日老夫献丑,请教方家了。”他来到书桌前,铺纸提笔写下了上联,“出题之人,原是游戏文字,故意要弄出副绝对来,老弟若是为难,也不必放在心上。”\n“‘近世进士尽是近视’,四个词读音全同,词性各异,还是个全仄联?”子升思索着,这副联显然让他一时无从下手,沉吟中,他无意间又看见墙上那幅中丞遗像,突然灵机一动:“晚辈倒是可以斗胆一试,不过这下联要从老先生的先祖大人那儿来。”\n丁德庵扶着眼镜,读出子升的下联:“‘忠诚中丞终成忠臣’?对得好,对得好,对得太好了!”他猛然向子升一揖手,“萧先生大才,德庵佩服!”\n七 # “润之,”辞别了丁府,子升兴冲冲进了醉香楼,看见毛泽东,他一脸的兴奋莫名,“太可惜了,你没去真是太可惜了!这位丁翰林真是位雅人,学识过人,渊博风雅,不见一面真是可惜了。”他拉过长凳坐下,将一封光洋往毛泽东面前一放:“你看看,这是人家奉送的仪程,一出手,就是二十块光洋,大方吧?”见毛泽东只是“哼”了一声,没有接腔,子升不禁愣住了,这才发现气氛不对,毛泽东的身边,还站着互相扶持着默默抽泣的父女二人。\n看着子升不解的眼光,毛泽东义愤地告诉子升:“那位丁德庵的田,不管你丰年灾年,那是一粒租子都不能少。这几年,年景不好,这位老爹欠了他十担谷的租还不上,利滚利,驴打滚,就算成了一百多担的阎王债。这位老爹进城来求他姓丁的宽限宽限,他却看上了老爹的女儿芝妹子,逼他拿芝妹子抵债,芝妹子还不满十四岁,居然要去给他七十岁的人做第十三房,他也下得了这个手啊!”\n“爹……”芝妹子扑进父亲怀里,父女二人抱头痛哭。\n子升一脸的难以置信:“怎么会是这样?”\n酒楼的老板叹了口气,证实道:“你们两位是外乡人,不晓得底细,这位丁老爷,那是我们安化最大的一霸,家里的田,数都数不清,光佃户都有好几千。这种事算得什么?他家里逼租逼债,哪年不要逼出几条人命哦?”\n一位食客道:“丁德庵丁德庵,安化人人都喊他‘丁刮干’,不把你刮得干干净净,他从来就不会松手的。”\n其他围观的人或是面露不忍,或是默默点头,丁德庵的恶劣,显然为大家所公认。\n子升简直不敢相信:“满口礼义诗书,道德文章,居然……居然为人如此卑劣!”\n“他不在脑袋上贴个仁义道德,还贴个我是坏蛋啊?我告诉你,越是这种道貌岸然的读书人,越不是个东西!”毛泽东转向那父女俩,“我说,这个租,你们也不用交了,田是你种的,凭什么给他交粮?”\n老农却直摇头:“不行啊,丁老爷养了家丁,家里又有人做官,欠他的债不还,一家人活活打死的都有啊!”\n毛泽东火了:“他打你?你不晓得打他?他再养家丁,未必比你们几千佃户还多?你们几千人,一人一根扁担,冲到他家去,吃他的大户,你看他还耍什么威风?”\n子升急了:“润之!你这不是鼓动人家聚众闹事吗?”\n“聚众闹事怎么了?跟这种土豪劣绅,就是不能客气,大家一条心,谁怕谁呢!”\n“可你这不是搞暴动吗?真要惊动了上面,吃亏的还不是这些农民?”\n“那你说怎么办?”\n子升略一沉吟,起身,向围观的众人抱了个拳:“各位先生,这对父女的遭遇,大家也都看在眼里。我这儿呢,倒是有个主意,希望能帮他们一把,只是要有劳各位一起帮个忙,不知大家肯不肯?”\n八 # 丁府书房,丁德庵正在欣赏子升写的那副对联,仆人一把推开了房门:“老爷,大喜了!”\n丁德庵边扣马褂最上头一颗扣子,边匆匆迈出大门。门前的情景让他愣住了:黑压压一片都是县城里的商号老板和街坊们,簇拥着正中的一块匾,五六个吹鼓手还在起劲地吹吹打打。\n子升上前一步,手一抬,鞭炮、鼓乐齐止。\n子升朗声:“安化各界商民代表,为感本县世家丁氏诗礼教化,表率乡里,特向丁老夫子德庵先生献匾。”\n丁德庵一时乐得合不拢嘴:“哎哟哟哟……这怎么敢当……怎么敢当?”\n子升依旧大着嗓门:“老先生不必过谦,丁氏一门,既承忠烈遗泽,又秉仁义家风,道德廉耻,无所不备,高风亮节,泽被闾阎。晚辈受安化乡民之托,特书此匾,唯求略表全县乡亲敬慕仰仗之情于万一也。”\n他伸手掀去匾上蒙的红绸,露出了“造福桑梓”四个大字,与此同时,锣鼓、唢呐各色乐器同时大作。\n喜出望外之下,丁德庵只顾一个劲地抱拳拱手:“哎哟哟,这个这个……德庵何德何能,何德何能啊……”\n就在他伸手要接匾之际,人群中的毛泽东悄悄向旁边一让,一推躲在身后的那父女二人,父女二人一头扑了出来,扑通跪在丁德庵脚下,拼命地磕头:“丁老爷,您行行好,我求求你了,行行好啊,丁老爷……”\n丁德庵措手不及,吓得倒退出两步,两边的家丁一看不对,当场就要冲上来,毛泽东却抢先扶住了那老农,扯着嗓子:“哟,这位老伯,您这是干什么?有话慢慢说,丁老爷可是大善人,万事都有他老人家做主。”\n子升也上前来:“对对对,有丁老爷在,不管什么难处,您放心大胆地说。”\n看看四周人群,丁德庵赶紧用眼睛瞪住了家丁们。\n那老农抬头欲诉,看见丁德庵和身后气势汹汹的家丁,吓得又把头低下了,他女儿急了,头一扬:“我、我们是丁老爷家的佃户,年景不好,欠了老爷的租还不起,老爷他、他……”\n毛泽东:“老爷他怎么了?”\n女孩:“老爷……我爹说丁老爷要我去做小。”\n丁德庵的脸登时挂不住了。\n毛泽东:“胡说八道!丁老爷怎么会是那种人呢?”\n子升:“就是嘛,丁老爷是什么人?读书人,大善人,怎么会乘人之危呢?丁老爷,您说是不是?”\n当着众人,丁德庵的脸不禁涨得通红:“嗯,对呀,老夫什么时候说过那种话了?简直……简直一派胡言!”\n毛泽东:“听到了吧?人家丁老爷根本没有那么想。你这个当爹的也是,欠债还不起,可以来求丁老爷宽限嘛,就算免了你的债,那也是丁老爷一句话的事,怎么能拿女儿来抵债,这不是败坏丁老爷的名声吗?”\n子升:“这话说得是啊。丁老爷的为人,安化全县上下,谁不知道?你看看你看看,‘造、福、桑、梓’,你有难处,丁老爷还能不帮吗?”\n人群顿时一片附和之声。\n子升笑吟吟盯着丁德庵:“丁老先生,您的意思呢?”\n丁德庵的目光,从子升笑吟吟的脸,转到毛泽东,转到父女二人,再转到眼前黑压压的人群和那块崭新的匾上,他这才醒悟过来,眼前这一幕原来是专门给他下的圈套。\n“那个……啊,不是欠了点租吗?我丁某人怎么能逼佃户的租呢?那个那个……来人啦,把他家的借据找出来,还给人家。”\n他身边的仆人似乎还不敢相信:“老爷?”\n“快去!”\n“丁老先生的慷慨仗义,真令晚辈五体投地啊!”接过了仆人拿来的借据,子升转手将那块匾捧到了丁德庵眼前,“那,以后呢?”\n“以后……”丁德庵一咬牙,“以后的租子,也减半,一律减半。”\n毛泽东赶紧扯开了嗓门:“老人家,丁老爷的话你听见了?当着这么多人的面,他可是亲口答应,把你家的债全免了,还减了一半的租,丁老爷可是要面子的人,他说话,一定算话,你该放心了吧?”\n颤抖着手接过那张借据,父女二人抬起头来,已是泪流满面,两人同时重重磕下头去,泣不成声:“谢谢萧先生,谢谢毛先生……”\n子升与毛泽东赶紧拦住了父女二人:“怎么成了谢我们呢?谢谢丁老爷!”\n父女二人这才想起来,赶紧给丁德庵磕下头去:“谢谢丁老爷!”\n“免了,免了免了。”丁德庵捧着那块匾,笑得比哭还难看。\n九 # 离了安化县,那一路,毛泽东与萧子升还在为白天发生的事争执着,农民的疾苦,让两个人的心情都无法平静。\n“一个芝妹子,我们救得了,可还有成千上万个芝妹子,她们怎么办?”毛泽东思考着。\n“人力有时而穷,我们也只能救一个是一个。”子升也只能这样回答。\n“不,这是不负责任!你那一套仁义道德,你那一套温柔敦厚,解决不了农民的问题,也消灭不了这个社会的黑暗!”\n“可社会进步需要时间,完全的公正、完全的平等只能是不现实的空想。”\n“为什么?为什么就不能有完全的公正、完全的平等?”\n“人终归是有私欲的嘛。”\n“那我们就打破这个黑暗的现实,那我们就消灭这些无耻的私欲,把一切的不合理、一切的不公正、一切丑恶的人丑恶的事统统埋葬掉,这个世界自然会迎来大同。”\n“你那是理想主义,只会破坏社会的和谐。”\n“不公平不合理的所谓和谐,我宁可它统统被砸碎!”\n夕阳映在他们一样年轻的脸上,让他们彼此都看到了对方心里深深的疑惑。\n这满心的疑惑一路困扰着两个年轻人,直到五天后,他们来到了宁乡沩山寺。这沩山寺的住持证一和尚乃是佛门有名的大德,两人便专程登了门,想听听佛门中人对这俗世中的不平有何见解。\n进了证一的禅房,却见一床一几,此外便是四处堆积的书,把间禅房衬托得倒更像一间书房。那证一和尚年近七十,一身青衣短褂,如果不是光头上烫着戒疤,看上去简直就像一个和善的老农。\n听二人讲明来意,证一只是微微一笑,道:“佛门讲的是出世之理,二位施主的困惑,却是人间之事,只怕和尚是帮不上啊。”\n子升便道:“出世之理,亦由世上来,所谓万理同源,无分佛门与世俗,还请大师不吝指教。”\n证一没有答话,停了一停,端起茶壶,说:“先品新茶吧。”\n他将壶中茶水向子升面前原已倒好茶的杯中倒去,杯中水满,很快溢了出来。\n子升赶紧道:“大师,水溢了!”\n证一倒茶的手停住了:“水为什么会溢?”\n“这……因为杯中已经有茶了。”\n“是啊,旧茶不倾,新茶又如何倒得进去呢?我佛门禅宗,于此即有一佛理。”证一放下茶壶,铺开纸,提起笔,在纸上写下四个字,将纸转了个边,面向萧子升和毛泽东,写下了:不破不立。\n证一解释道:“所谓魔障所在,正见难存,旧念不除,无以证大道,不除旧,则无以布新,是当以霹雳手段,弃旧而图新也。”\n毛泽东一拍巴掌:“此言正合我意!佛门普度众生,与我辈欲拯救国家、民族,道理本来就一样,只有驱除腐恶,尽扫黑暗,彻底打破这个旧世界,才能迎来真正的光明,才能建立普遍的幸福,正如凤凰自烈火中涅槃,重得新生!”\n子升却不能接受:“可是新难道一定要从旧的废墟上才能建立吗?旧世界的问题,我们为什么不能徐图改良,为什么一定要毁灭旧的一切,这样的新,代价不是太大了吗?”\n证一想了一想,徐徐道:“两位所言,一则疾风骤雨,一则和风细雨,老衲以为,若无疾风骤雨,当头棒喝,则魔障难除,然先贤亦曰:飘风不终朝,暴雨不终夕,疾风骤雨,终难长久,破旧以骤雨,立新以和风,相辅相成,原是缺一不可的。取彼之长,补己之短,则新可立,道可成。”\n说罢又提起了笔:“老衲赠二位施主各一个字吧。”\n他先写下一个“动”字,转过来移到子升面前: “萧施主和风细雨,君子气节,独善己身足矣,但欲图进取,变世道,化人心,还须振作精神,勇于任事,以动辅静。”\n证一又写下一个“静”字,转过来推到毛泽东面前:“毛施主骤雨疾风,汹涌澎湃,以此雄心,天下无不可为之事。但世事无一蹴而就之理,施主于翻天覆地中,亦当常记,一动须有一静,一刚须有一柔,有些时候,是要静下来方好的。”\n子升和毛泽东互相看了一眼,都似乎有所领悟,但又似乎并未领悟得透彻,看看证一已然收了茶具,有起身送客之意,只得道了一声:“多谢大师!”\n第二十七章 工人夜学 # 要解决中国的问题,唤醒民众,肯定是件非搞不可的事。我们这些青年组成的团体,也只有眼睛向下,盯着最广大、最底层的国民,才能真正成就一点事情。\n一 # 暑假刚过,1917年秋,护法战争爆发了,护法军进军湖南,北洋系军阀傅良佐所率驻湘守军节节败退,安静了没几天的长沙城里,又是一日乱过一日。\n这天孔昭绶正在教务室召开全体教师会议,讨论一师为普及平民教育而办工人夜学的事,他这个想法由来已久,却一直腾不出空来,好不容易这回打出了招生广告,不料十多天过去,才七个人报名,眼看夜学就要成了泡影,无奈之下,只得找了老师们共同来分析原因。\n美术老师黄澍涛翻着办公室里的报纸,头一个便直摇头:“时局如此啊!校长,如今就连我们学校都朝不保夕,更何谈什么工人夜学?”\n的确,看看那堆报纸,哪张上面不是一个个触目惊心、火药味十足的大幅标题:《段祺瑞拒绝恢复约法,孙中山通电护法讨段》、《黔滇桂粤宣告独立 护法军誓师出征》、《湖南战事急报:湘南护法军兵进衡阳》、《衡山告急,傅部守军昨日增援耒衡前线》、《湖南督军傅良佐令:长沙即日实施宵禁》……\n方维夏也不禁叹了口气:“办夜学,普及平民教育,这件事本该是我们师范的责任。原指望谭督军在湖南,湖南还安稳一点,我们这些搞教育的,也可以做点实事,可这才安稳了几天?唉!”\n其他老师同样是七嘴八舌:\n“要说时局,确实是乱,可夜学办不起来,也不能说都是时局所致吧?”\n“可我们的招生广告打出去那么久了,才七个人报名,这样的学校,怎么办得起来呢?”\n“依我看,这帮出苦力做工的人,他就没那个读书上进的心思!你们看看,读书不要钱,课本全免费,连笔墨纸张都是免费送,这样的条件,上哪找去?这就是请他们来学嘛。你请他他都不来,还有什么好说的?”\n听着老师们的各抒己见,孔昭绶对自己当初的想法也有些动摇了:“这么说来,倒真是我们估计错了,这个工人夜学,工人们真的不感兴趣?”\n“我看,结论不必下得这么早吧?”始终没有开口的杨昌济突然说道, “要说夜学办不起来,是因为工人天生的不求上进,那有一件事我就想不明白了。我记得校长最初产生办工人夜学的想法,来源于看见毛泽东在街边教人认字。毛泽东是什么人?一个师范学生而已,为什么他教人认字,有人跟着学,而且学得认认真真,而我们,以一所师范学校的力量,提供比他那一根树枝当教鞭、一块泥地作黑板好得多的办学条件,反倒还招不来学生了,这能说得通吗?”\n所有的老师都静默了,这个问题显然极有说服力。\n“所以,招不来学生,我相信,责任不在工人,一定是我们的方法有不周之处。我的意思,还是先找润之谈谈。”\n毛泽东被孔昭绶叫进了教务室,一听来龙去脉,当即就拍了胸脯:这个工人夜学,一定会大受工人们的欢迎!校长要是不信,可以把工人夜学交给我们学友会来办。\n“你们来办?”一旁的袁吉六一脸的不信,“你们自己都还是些学生娃娃,还办学校?开玩笑!”\n看看老师们似乎都没有信心,毛泽东摆开了理由,说五年级的师范生上讲台当老师也就是没多远的事,现在接手工人夜学,等于给大家一个教育实习的场所,也给大家一个接触社会、锻炼自己的机会嘛。\n他的这个观点得到了老师们的一致认可。孔昭绶也当场点了头,答应把工人夜学交给学友会来办,但也有个条件:“十天内必须招到至少40个学生。”\n接了军令状,毛泽东回到学友会事务室,马上把学友会的成员统统召集起来,开了一个紧急会议。\n“这四年多来,我一直在思考,究竟什么才能改变我们这个千疮百孔的社会,才能拯救我们这个内忧外患的国家。过去,是读书、求学,可光靠书上道理有用吗?你读一万本书,不还是挡不住汤芗铭的那一营兵?我也想过,一个人不行,我们结交朋友,我们组成团体,可中国这么大,靠几个读书人来使劲,靠个把小团体,就能变过来?照样不行!这次暑假游学,给我的触动更大,一个土财主,就能骑在那么多佃户头上,为所欲为,为什么?因为读书人只有那么几个,因为中国真正多的,是农民工人老百姓,他们愚昧,他们老实,他们动不起来,你书读得再多,你是一帮学生,翻不了天。所以,要解决中国的问题,唤醒民众,肯定是件非搞不可的事。我们这些青年组成的团体,也只有眼睛向下,盯着最广大、最底层的国民,才能真正成就一点事情。”\n他这一番开场白,顿时赢得了大家的一片认可之声。\n毛泽东趁热打铁,在小黑板上画着一幅简单的地形示意图,把以第一师范为中心,往南到猴子石,往北到西湖桥,十里范围内集中的黑铅厂、印刷厂、纱厂、铸铁厂等大大小小十几家工厂全标了出来,这才宣布道:“这些工厂加起来,少说也有两千工人。而我们的任务,是十天内,从这两千工人里头,招到40个学生。大家说,敢不敢揽这个活?”\n“当然敢……没问题。”\n学友会的骨干们七嘴八舌,崭新的挑战令每一个人的眼中都闪着跃跃欲试的光芒。\n“好!说干就干!”毛泽东当即给大家分配了任务,“招生广告我来拟;子暲、昆弟,你们负责联系警察所,请他们帮忙,把广告尽可能贴遍每条街;李维汉、罗学瓒,你们负责准备报名表,接待报名;其他的人跟周世钊一起,收拾教室,准备课本、资料。”\n他伸出手来:“大家一起攒把劲,也让孔校长和全校的老师看看,我们这些师范生,不光会吃干饭!”\n学友会所有成员的手,紧握在了一起。\n二 # 毛泽东的笔头向来快,第二天,便拟好了一份大白话的《工人夜学招生广告》,那广告原文是这样的:\n列位大家来听我说几句白话:列位最不便益的是什么?大家晓得吗?就是俗话说的,讲了写不得,写了认不得,有数算不得。都是个人,照这样看起来,岂不是同木石一样?所以大家要求点知识,写得几个字,认得几个字,算得几笔数,方才是便益的。虽然如此,列位做工的人,又要劳动,又无人教授,如何能做到这样真是不易得的事。现今有个最好的法子,就是我们第一师范办了一个夜学。这个夜学专为列位工人设的,从礼拜一起至礼拜六止,每夜上课两点钟,教的是写信、算账,都是列位自己时刻要用的。讲义归我们发给,并不要钱。夜间上课又于列位工作并无妨碍。若是要来求学的,就赶快于一礼拜内到师范的号房来报名。\n广告拟好后,学友会的一部分同学立刻就拿去油印。很快,这份招生广告就在警察的帮助下,贴满了一师周围的街边墙上。其他同学搬桌椅,打扫卫生,很快把工人夜学的教室也布置好了。\n可是没想到,一晃眼过去了一周,总共才只有三个人报名!而街边的墙上,路人经过,也似乎都懒得多看那招生广告一眼。\n“按说广告也贴得够多了,怎么就没人来报名呢?”无奈之下,毛泽东也只能决定再请警察帮一次忙,多贴一些广告出去。\n但这次求上门去,却就没有上次那么好说话了。\n“什么,还贴?”警察所的警目把他们带来的招生广告往桌上一扔,“你当我们警察所是你第一师范开的?”\n毛泽东说着好话:“贴公益广告不是你们警察所的责任吗?”\n“你跟我讲责任?”警目眼睛一横,“弟兄们贴了一回就够对得起你们了,还一而再再而三?你以为我这帮弟兄专门给你当差的?”\n一旁有个年轻警察有点看不下去了,插嘴道:“长官,要我说,人家办夜学,也是做善事,能帮咱们还是帮帮吧。”\n“你是吃饱了撑着了,还是他发了你薪水给了你饷?”警目瞪着自己的手下,把那叠广告往毛泽东手里一塞,“给我拿回去,我这儿不侍候!”\n“你这个人怎么这样啊?当警察,就要为社会服务嘛……”\n萧三还想说点什么,毛泽东把他一拉,“子暲,跟这种人有什么好说的?求人不如求己。走!”\n几个人只得自己上街去贴广告,直贴到日头偏西,还剩了一半的街道不曾贴完,正在发愁时,那个年轻警察却与好几个同事赶了来,手里都还拿着糨糊桶、刷子之类。\n看看毛泽东他们一脸的诧异,那年轻警察笑了笑:“我们刚下差,反正没什么事,就来帮个手——哎,还剩几条街?”\n望着他和善的笑容,一股暖流蓦然涌上大家的心头,毛泽东用力点了点头:“东边南边我们都贴过了,这两边还剩几条街。”\n那年轻警察便抱起了一叠广告:“行,这边你们贴,那边归我们,动手吧。”说罢,带着警察们就走。\n毛泽东追了两步,问:“哎,你叫什么?”\n“郭亮。你呢?”\n“毛泽东。”\n郭亮和毛泽东就这样认识了,虽然只是匆匆一面,只是相互一挥手,虽然两个青年都不曾想到,他们今后的命运,会那样紧密联系在一起。\n三 # 第二次广告贴出去之后,从早等到晚,整整两天,还是没有人来报名。\n到底是怎么回事呢?毛泽东着实有些想不明白:明明是件好事情,可为什么就做不成呢?到底是哪个环节出了错?\n傍晚,他照例来到了水井旁,光着膀子开始了冷水浴,这一刻,他只想借冰凉的井水,来刺激一下自己,让自己的思路开启起来,他几乎是机械地一下一下往身上淋着水。一桶水很快见了底。\n刚打上一桶水,兜头淋了个从头到脚,杨开慧却气喘吁吁地跑来了:“润之大哥,我知道为什么没有工人报名了!”\n杨开慧是刚刚发现的原因。\n下午她和蔡畅一起放学回家,两人边走还边在帮哥哥们分析,为什么夜校招不到工人,却听到路边传来了一个声音:“搞什么名堂,怎么又把货送错了?”\n两个人转头一看,原来是一辆送货的板车停在布店门口,车上堆着标有“万源纱厂”的货箱,布庄的老板正敲着工人手中的一张单子直嚷嚷:“你看看这写的什么,再看看我的招牌——康和唐都分不清,你认不认识字?”\n开慧突然站住了,饶有兴趣地看着。\n“赶紧把我的货送来,我这儿客人等着要呢!”老板转身气呼呼进了店,剩下两个工人在那里大眼瞪小眼。\n开慧凑了上去问:“两位师傅,什么字弄错了?我们能不能看看啊?”\n工人的手里,是张送货单,上面写的是“唐记”布庄,布庄的招牌上却是“康记”。\n蔡畅说:“这是唐记,不是康记啊。”\n“看上去也差不多,我们哪分得那么清?”一个工人说,“唉!这眼看就要天黑戒严了,来回七八里,再送怎么来得及呀?”\n开慧问:“两位师傅,你们不认识这两个字吗?”\n一个工人说:“做工的,还不都是半个睁眼瞎子。”\n另一个工人也说:“真要识字,还能吃这种亏吗?”\n听了这话,开慧赶紧跟工人们说起了工人夜学的事,却不料两个工人一脸茫然,全不曾听说这回事,开慧问明了原因,恍然大悟,这才匆匆赶来,找到毛泽东。\n“你知道为什么没有工人报名吗?因为他们不识字、不认识广告上的字!”\n毛泽东这才醒悟过来:“你是说,工人根本不认识广告上的字?”\n“是的。我也是听那两个工人说了才知道,他们不光是看不懂,就算认识几个字的,也根本没敢去看广告。”\n“那又为什么?”\n“我们的广告不是请警察去贴的吗?警察在工人眼里,就是衙门抓人的差役,工人以为贴什么抓人的告示,怕惹麻烦,都躲着走,根本没人敢去看。就算有人看了,也不相信真有这种免费读书的好事。我碰上的那两个工人,就怎么都不肯相信,以为我跟他们开玩笑呢。”\n“原来这样。”毛泽东点了点头,略一思考,突然一挥拳头,“我有主意了!”\n四 # 第二天工人下工时分,万源纱厂的门口,两面铜锣当当直响,引得熙熙攘攘的路上,正在下班的工人们都奇怪地望了过来。一帮学生带着锣鼓唢呐,各种各样的乐器,在路边拉开了场子。原来毛泽东等连夜排了一个节目,想以这个方式来说服工人参加夜学。\n领头敲锣的,正是毛泽东和向警予,两人一边敲锣一边唱和:\n“哎,都来瞧都来看。”\n“看稀奇看古怪。”\n“看刘海砍樵出新段。”\n“胡大姐路边谈恋爱喽。”\n一旁,张昆弟、罗学瓒、萧三等一帮子锣鼓唢呐洋铁碗,滴滴答答伴奏声大作。\n这《刘海砍樵》本是长沙一带最受人欢迎的花鼓剧目,如今被弄出了这番新鲜举动,着实令人好奇,当下呼啦一下,众多工人顿时把他们围了个水泄不通。\n警予锣槌一抬,身后乐声止住,她团团一抱拳:“列位工友,有道是故事年年有,今年特别多。今天我们要讲的,便是这《刘海砍樵》的故事。”\n“列位要说了:《刘海砍樵》谁没看过?有什么新鲜的?”毛泽东接着和她一唱一和。\n“我告诉列位:有!”\n“怎么个有法?”\n“且看我们的小刘海进厂当工人!哎,刘海呢,刘海,刘海!”\n“(花鼓腔白)来哒咧……”但听得笛子、唢呐……花鼓调子的伴奏大起,音乐声中,蔡和森一身短褂、草鞋,背着雨伞、包袱,突然从观众群中钻了出来。\n蔡和森:“(唱)小刘海啊我别了娘亲,不上山来我不进林。(白)都说那做工比砍樵要好,我也到工厂(唱)来报个名哪咦呀哎嗨哟。”\n观众们的一片笑声中,警予手一背,挺胸腆肚,装起了工厂老板:“叫什么?”\n蔡和森:“(白)刘海。”\n警予:“哪个刘,哪个海?”\n蔡和森:“(白)刘海的刘,刘海的海。”\n毛泽东:“老板是问你名字怎么写的?”\n蔡和森:“(白)冒读过书,搞砣不清。”\n观众又是一片笑声。\n警予:“你自己的名字都不会写?”\n蔡和森:“唉,(唱)自幼家贫丧了父亲,上山砍樵养娘亲。我也有心把学堂进,无衣无食哪有读书的命。”\n毛泽东“当”的一声锣,冲观众:“可怜我们小刘海,论人才,原本也做得个纱厂的工头。”\n警予:“怎奈大字不识一个,只好先做了个送货的小学徒。”\n毛泽东:“一进工厂整三月。”\n警予:“家中急坏了胡秀英。”\n音乐声中,斯咏一身花红柳绿,袅袅婷婷出了场:“(唱)海哥哥进城三月挂零,秀英我在家中想夫君。不知他做工可做得好,为什么一去就无音讯?”\n她秀美的扮相与清脆的嗓音,一出场便博来了一片叫好声。\n“大姐,”开慧扎两根冲天辫子,打扮成个小丫头,从人群中挤了出来,“刘海哥来信了。”\n斯咏:“(唱)一块石头我落了地,小妹你快把书信念与我听。”\n开慧打开手上的信:“胡……胡圈圈?”\n斯咏凑上来一看:“哎哟,胡大姐喽。”\n开慧:“这明明是两个圈圈,哪里是大姐嘛?——还画都画不圆。”\n斯咏:“(白)海哥哥不会写大姐,画两颗脔心代替我啦。”\n四周观众哄堂大笑。\n开慧:“哦,(念)胡大姐,我在城里丢了命……”\n“啊?”斯咏、警予、毛泽东等齐声,“什么?”\n开慧:“(念)我在城里丢了命,一天到晚被雨淋,别人有命我无命,圈圈——哦不对——大姐有命送命来,若是大姐也无命,刘海我就不要命。”\n斯咏作焦急状:“(白)这可如何是好?”\n毛泽东、警予、开慧齐声:“赶快进城看看啊!”\n音乐声中,斯咏、开慧退场。这番新奇有趣的路边活报剧,一时间赢来了观众无比热烈的掌声与叫好。\n叫好声中,王老板夫妇也正好走出厂门,叫着仆人王福,让他备轿,那叫王福的仆人却正踮着脚挤在人群外看戏,听见老板叫他,赶紧跑来,一脸的兴奋地说:“老爷,好看啊,表小姐在那儿演戏,演得可好了。”\n“表小姐?”王老板夫妇眼都瞪圆了,夫妇俩走到人群后面,踮起脚来,果然正看到人群之中,斯咏与蔡和森一身戏装,一副久别重逢状,演得正来劲。\n开慧一拉斯咏:“大姐,刘海哥挺好的,没出事啊?”\n蔡和森:“(白)哪个讲我出哒事?”\n开慧:“你自己信里写的嘛。(拿出信来念)我在城里丢了命——这不是你写的?”\n场子一旁的警予举起一块大牌子,上面是一个老大的“命”字。\n蔡和森接过信:“(白)哎哟,我写的是‘我在城里丢了伞’嘞。”\n斯咏、开慧:“伞?”\n场子另一旁的毛泽东举起了另一块大牌子,上面是老大一个“伞”字,与警予举的牌子呼应着。\n蔡和森:“(白)对呀。(念信)‘我在城里丢了伞,一天到晚被雨淋,别个有伞我无伞,大姐有伞送伞来,若是大姐也无伞,刘海我就不要伞。’”\n斯咏:“(白)哎哟,海哥哥嘞,你硬把我脔心都吓跌哒咧。”\n开慧:“你看你这个刘海哥,(念板)我大姐,在家里,一天到晚想着你。听说你城里丢了命,大姐她心里好着急。”\n警予与毛泽东齐声:“嘿!胡大姐她心里好着急!”\n开慧:“她卖了鸭,卖了鸡,倒空了米缸卖了米。凑钱到城里把你看,原来你只是丢了伞。”\n警予、毛泽东:“嘿!原来他只是丢了伞!”\n这一段唱下来,有情节、又生动,把四周的围观的人全逗得大笑不止。\n斯咏:“(唱)海哥既然平安无事,秀英也算放哒心。三月工钱先把我,回家买米养娘亲。”\n蔡和森:“唉,(唱)提起工钱我眼泪汪汪,三个月辛苦我白忙一场。”\n斯咏:“(白)这又为何?”\n蔡和森:“(念板)上前天送货我出哒厂,要货的布老板他本姓唐。刘海我自幼读书少,一个唐字我看成哒康。跑出城外十几里,把货错送到康记布庄。等到我再往城里跑,太阳落山见月光。天一黑城里戒哒严,唐老板的生意塌哒场。厂里头怪我送错哒货,两个月的工钱全扣光。”\n毛泽东与警予一人一块牌子,上面写着大大的“康记布庄”和“唐记布庄”,生动地配合着他的念白,四周的工人们就算不认识这两个字,也看得明明白白,不由得又是一片哄笑。\n开慧:“这才两个月工钱,还有一个月的呢?”\n蔡和森:“(念板)昨天我送货又去结账,有个老板他冒得名堂。一共他要哒三次货,每回欠厂里八块光洋。三八是好多我又算不清账,只怪我细时候冒进学堂。他一看我算账不里手,硬讲三八是一十九。一下就少收哒五块钱,厂里头又要扣我工钱。学徒一个月才四块五,赔光哒下个月我还要补。认错一个字,算错一回账,三个月的工钱全泡汤啊全啊全泡汤。”\n斯咏:“(白)海哥哥,你明晓得冒读过书,厂里何式还喊你去送货喽,未必冒得别个哒?”\n蔡和森:“(白)大姐,你有所不知——(念板)厂里的工人有三百整,刘海我水平已经算蛮狠。斗大的字,还认得几箩筐,我就算厂里的状元郎。换哒别个更不得了,认字算数都摸风不到。写个一字他当扁担,写个二字以为筷子一双。”\n斯咏:“(白)那要是三字咧?”\n蔡和森:“(念板)写个三字他更眼生,还以为两双筷子跌哒一根。”\n这一段又让四周的观众笑得连气都喘不过来了。\n蔡和森:“(念板)讲得出口,写不得,别个写哒又认不得。厂里的规矩随老板讲,扣你的工钱冒商量。一世人做哒个睁眼瞎,人不读书就把亏吃。做工的生来命最苦,千苦万苦只因冒读得书。列位工友都在此,你们讲我讲得是不是?”\n满场的笑声戛然停了,一番话深深地引起工人们的共鸣,许多人都在默默地点头。\n斯咏:“(唱)听罢海哥话一场,秀英我心里好凄凉。人不读书遭白眼,夫受欺凌妻亦无光。千事万事先放下,海哥你今天就上学堂。”\n蔡和森:“(白)我也想读嘞,只是学堂这样贵,做工的哪里读得起喽?再说我只晚上有空,白天还要做事,大家讲,我这个书何式读得成器喽?”\n“谁说你读不成书?”一旁的警予与毛泽东突然插了上来,“我们给你指条路怎么样?”\n蔡和森、斯咏、开慧:“(白)哦?愿听端详。”\n警予:“眼下就有一个机会:第一师范新办了工人夜学,专门方便列位工友读书学知识。”\n毛泽东:“每天晚上两节课,不耽误你白天要做工。”\n警予:“你若担心晚上戒严,夜学还发听讲牌。”\n毛泽东:“凭牌就能畅通无阻,军警一概都放行。”\n蔡和森、斯咏、开慧:“当真?”\n警予、毛泽东:“当真。”\n蔡和森、斯咏、开慧:“果然?”\n警予、毛泽东:“果然。”\n蔡和森:“(白)但不知学费好多?”\n警予:“免费夜学,一文不收。”\n毛泽东:“课本笔墨,按人发放。”\n警予:“如今夜学正招生。”\n毛泽东:“要想报名你赶紧去。”\n众人:“对,要想报名你赶紧去!”\n“当当啷当,当当啷当……”观众的一片叫好与掌声中,伴奏的张昆弟、萧三等人打起了快板,走上前来,加入演员中,众人齐声:“嗨,嗨,这正是——\n“刘海砍樵的新故事,工人也要学知识。”\n“学写字,学算术,学了加减学乘除。”\n“能读书,能算账,我们和别人要一样。”\n“莫说人穷没人管,我们工友人穷志不短!”\n毛泽东扯开了嗓子:“列位工友,我们第一师范的工人夜学正在招生,过几日就正式开课,有愿意读书学知识的,现在就可以向我们报名!”\n“我报名……我也报……”\n呼啦一下,上百工人们争先恐后涌了上去,顿时将负责报名的张昆弟他们围了个水泄不通。\n望着那一张张渴盼的脸,一双双争抢着报名表的手,毛泽东兴奋得与蔡和森、开慧用力一击掌。\n转身,他又一把握住斯咏的手,用力一紧。\n“谢谢你,斯咏!”\n紧紧握着毛泽东的手,斯咏一时似乎也不知该如何表达成功的喜悦。就在这时,她蓦然一呆:涌上前来的观众群后面,露出了王老板夫妇,两口子脸色铁青,正狠狠地瞪着人群中的斯咏!迎着那两双气得简直要喷血的目光,斯咏不由得一慌,她下意识地正要放开毛泽东的手,想想,把头一扬,反而更紧地握住毛泽东的手。\n“润之,”蔡和森拿着一叠报名表挤过来,“不行呀,人太多了,昆弟他们忙不过来,你这边再开一个报名点吧。”\n“要得,交给我了。开慧,斯咏,来,一起帮个忙。”\n“好!”斯咏一把抓过毛泽东手中的报名表,仿佛示威般迎着王老板夫妇的目光,拉开了嗓子,“后面的工友们不要挤,这边也可以报名,请大家一个一个来,人人都能报……”\n几十名工人一下将她与毛泽东等围住了。\n一向很注意保持风度的王老板,这个时候都快要被未来儿媳给气疯了,他拉住同样目瞪口呆的王夫人,颤抖着嘴唇从牙缝中挤出一个字:“走!”\n1917年11月9日,第一师范工人夜学正式开学。\n因为是第一天上课,也因为毛泽东的军令状,孔昭绶带着杨昌济、方维夏、徐特立、袁吉六等先生特地来看教学效果。远远的,他们就看到了一块“工人夜学”的牌子挂在教室外面,不用说,大家都知道那是毛泽东的手笔。\n站在窗外,他们看到教室里讲台一侧挂着课程安排的粉牌:“今晚授课:第一节,国文,毛泽东;第二节,算术,陶斯咏”。毛泽东穿着一件灰白的长衫,颇有教师的风度,正在教工人读:“我是一个工人。”\n挤得密不透风的教室里,130多名工人捧着简单的油印课本,神情专注地跟着读:“我是一个工人。”\n“我为我们的中国做工。”\n“我为我们的中国做工。”\n……\n孔昭绶点点头,目光投向了袁吉六:“仲老,如何啊?”\n望着眼前的盛况,袁吉六彻底服气了:“袁某是老了,对他们年轻人,不服不行啊!”\n几个老师也纷纷断定,这个毛泽东,以后准是个好老师啊!\n那一刻,只有杨昌济却摇了摇头,心中暗想:老师是个好老师,就不知道这门教书的本事,他用不用得长了……\n第二十八章 梦醒时分 # “ 斯咏心中所藏的,也许只是一个浪漫的、不切实际的梦幻,我想,这也许是真的,因为如果她真的心中有我,在她的心中,所藏的那个人,也并不是真正的毛泽东,而只是一个被加工过的梦想而已。”\n一 # 陶家印刷厂门口,工人大多都已经下班了,陶会长看到还有几个工人没有回家,扎在一堆不知道在看着什么,就走了过去。工人们看到老板来了,赶紧起身问好。陶会长凑过来一看,地上是歪歪斜斜用树枝写的字:“我是一个工人。”\n工人把手里的识字课本递了过来 告诉他,这是工人夜学教的,第一师范办的工人夜学,教工人免费读书,好多工厂的工友都去了。陶会长翻看着识字课本,忍不住点着头,称赞这是件大善事,又问办学的是一师哪几位先生?一个工人说是一师的毛先生,还有周南的陶先生。陶会长锁着眉头重复了一句:“毛先生,陶先生?”\n一路疑惑地回到家,陶会长进了大门就问管家小姐在房里没有,管家回答说小姐出去了,姨老爷两口子来半天了,脸色不太高兴。陶会长“哦”了一声,进到客厅。果然看到王老板夫妇来了,不过王老板夫妇的脸色,说不高兴是太轻描淡写,那两张脸铁青,简直就是气急败坏!\n一看到姐夫进门,王夫人率先发难:“大街上!姐夫,那可是大街上啊!居然就跟人夫啊妻啊扭啊唱啊,让几百做工的围着看!成何体统啊?”\n“伤风败俗!”王老板也一巴掌重重地拍在桌子上,吼道:“伤风败俗!”\n这样有伤自尊的话任何一个女孩的父亲都接受不了,陶会长的脸色沉了下来。\n王夫人没看见姐夫的脸色,也或许她看见了却不在意,只顾自己发泄,而且越说口气越难听:“上次过生日,跟一帮男人疯到大街上,就够丢脸的了。现在倒好,干脆上大街,卖唱当戏子!真没个廉耻了!要让人知道这是我们王家的儿媳妇,我们还怎么做人哪。”\n“她二姨,你的意思,你王家的儿媳妇,我斯咏高攀不上?”\n看到陶会长脸拉得死长,王夫人这才发现话讲过了,赶紧放软了口气:“我……我不是这个意思,姐夫,我和你妹夫还不是为你着想,怕她丢了你的面子吗?好歹她是陶家的大小姐嘛。”\n王老板却仍然不松口:“她也是我王家的儿媳妇。姐夫,陶家、王家,都是有头有脸的人家,姐夫该管的还是得管,别闹出笑话来,大家脸上不好看。”\n陶会长把头一扭:“女儿怎么管,我自己有分寸!”\n“那就好,我们做公公婆婆的,可就拜托亲家翁了。”王老板拉起老婆,“告辞了。”\n“不送!”窝在沙发里,陶会长的胸膛一起一伏的,这番羞辱可当真把他气得够呛!\n这天晚上,工人夜学有斯咏的算术课,放学后已是夜里九点多钟了,斯咏一进门,迎头就碰上了陶会长铁青的脸。\n还没等她开口,陶会长砰地一拍桌子,劈头就是一顿骂,说的话居然和刚才王老板夫妇说的差不多。\n斯咏目瞪口呆地看着爸爸,长这么大,她可一直都是爸爸的心肝宝贝呢!听得父亲全没有收口的意思,她的小姐脾气上来了,登登登地上了楼,冲进卧室,反手便将门一关。\n“砰”的一声,门又被陶会长推开了:“怎么怎么,啊?还说不得你了?上大街丢人现眼的时候,你的面子哪去了?”\n“我那是为了办夜学,丢什么人?”\n“办夜学你可以去教书,我并没有反对嘛。可你、可你上什么大街?还夫啊妻啊跟人扭啊唱的,人家看到了会怎么想?啊?”\n“他爱怎么想怎么想,我就是做给他们看的!”\n“你……你怎么这么不懂事呢?那是你公公婆婆!你这个样子,以后怎么当人家儿媳妇?”\n“他看不惯是吧?看不惯正好,退婚喽。”\n“胡说!”陶会长这下真火了,“砰”地又是一拍桌子,“退婚是随便说的?我陶家多少代清清白白,无再嫁之女,无重婚之男!三媒六订许下的婚事,退婚?你不要脸我还要脸呢!”\n他一屁股在椅子上坐下,喘着粗气,强压了压火气,又把椅子往床边挪了挪,放缓了口气:“斯咏,不是爸想跟你发脾气,被人当着面这么说,爸心里窝火啊!要怪呢,只怪爸过去太娇惯你了,总想着你还小,什么事都还早,由着你玩就玩吧,到时候收心就是。可你一天一天,你越来越不像话。那个什么毛泽东,爸说过多少次,不要跟他来往不要跟他来往,你呢,听进去了吗?你非要跟他混在一起。他就那么好?就有那么大的吸引力?”\n“他就是好!就是好就是好!”\n“好,他好,他好上天又怎么样?不还是个穷师范生?爸不是说要嫌贫爱富,可凡事它总还有个门当户对,爸也得为你的将来考虑吧?退一万步,我们退一万步讲,你终归是定了亲的人,是有主的!你别忘了,还有半年你就得出嫁,不管什么毛泽东毛泽西,你跟他都没有将来!一个女孩子,名声要紧,不能乱来啊,你明不明白?”\n陶会长这番话,戳中了斯咏心里的痛处,她紧紧咬着牙,不知道该说什么了。\n那天晚上,斯咏躺在床上,翻来覆去地想着自己该怎么办。\n周末放学后,斯咏死乞白赖地要警予陪她去趟蔡家。开始怎么都不对警予说去蔡家的理由,后来警予威胁她不说就不陪她,她才说,蔡妈妈是反封建的典范、新女性楷模,所以想去找蔡妈妈给自己出个主意。警予白了她一眼,告诉她不用去蔡家,主意现成就有一个,退婚。\n“可是,我爸他就是不肯退啊!”\n“你爸不退,你自己退嘛!”\n“自己退?”这一层斯咏显然从未想到。\n“对呀,你的婚姻,退不退是你的权力,跟别人有什么关系?现在是民国,不是封建王朝!长辈的一句话,还能管你一辈子?不是我说你,斯咏,为了这一纸婚约,这些年你添了多少烦恼,多少无奈?想说的不敢说,想爱的不敢爱,都是因为它!其实不就是一张纸吗?撕了它,一身轻松!”\n看到斯咏还在犹豫,这次,警予主动提出去找蔡妈妈,因为她相信,蔡妈妈一定会赞成自己的想法。\n可是,当他们过江来到溁湾镇刘家台子蔡和森家,坐在蔡妈妈对面的时候,蔡妈妈的一番话,却叫他们大失所望。\n“我抛弃过一段老式婚姻,抛弃过一个封建家庭。斯咏,按理说,现在,最应该鼓励你,支持你,给你打气的,就是蔡伯母。可是,可是蔡伯母不能那样做。”\n葛健豪给孩子们续上茶水,又说:“你们还年轻啊,孩子们。有很多事,你们还没有经历过。只有经历了,你们才会明白,生活,并不像你们年轻人想的那样,只要迈过那一道坎,前头就会是一片阳光。正好相反,一个人,做出任何选择,都是有代价的,常常是,当你做出了选择,你却发现,你所面临的,反而是更大的、更长久的、更难以克服的障碍与压力。如果换作一个女人,要她去挑战旧婚姻、旧家庭、旧观念,甚至整个旧的社会,那更要付出巨大的,也许是你根本无法承受的代价。”\n“怎么,伯母,您后悔了?”警予几乎不敢相信这些话会出自葛健豪的口。\n“不,我没有后悔,我从来不为自己的选择后悔。可我是我,斯咏是斯咏。斯咏,你蔡伯母的性格,跟你不一样,蔡伯母的年龄,也比你大得多,我的选择,经过了深思熟虑,当我打算踏出那个家门时,我也自信已经做好了一切准备,预计好了一切困难。可当我真的离开那个家,我才发现,还有许许多多的白眼,许许多多的压力,许许多多旁人无法想像的困难,超出了我原来的预计。这些压力与困难,蔡伯母挺下来了,可是不是等于你也能挺下来,我不知道。真的,斯咏,我很愿意支持你,支持你挣脱枷锁,但作为一个过来人,我更要提醒你,做出一个人生的选择也许艰难,但承受一个选择所带来的终生的压力与代价,才是你今后真正要面对的现实。所以,当你打算做出选择的时候,我希望你认真地问一句自己:我,真的做好付出一切代价的准备了吗?”\n望着葛健豪坦诚而关切的眼睛,斯咏努力想弄明白蔡妈妈这番话背后的意思。\n回到家里,斯咏取出信笺,提笔写下了“姨父姨母大人台鉴”后,却又不知道该怎么写下去了,在桌子面前坐了半天,只是不知道怎么落笔。\n好不容易挨到天亮,她一路小跑到了一师,敲开了八班寝室的门。\n周世钊还睡眼惺忪的,一听斯咏说要找毛泽东,揉着眼睛说: “润之?他回家了。你不知道吗?他母亲病了。”\n二 # 毛泽东是头天离开的长沙,那时他正在寝室里整理“工人夜学记事簿”,四年来从没有到一师来过的毛泽民突然风尘仆仆地找来了,水都没有来得及喝一口,就拉住哥哥哽咽着说了母亲最近严重的病情。\n“你说什么?娘病了?”毛泽东大吃了一惊,问明母亲的情况,赶紧请了假,跟弟弟一起赶回了韶山。\n文七妹果然病得不轻,这一两年来,吃不下饭,睡不安觉,整个人已经骨瘦如柴,最近两个月,居然还连续晕倒了好几次,乡间本谈不上什么医疗条件,郎中也看不出是什么病来,她也就这么一日日挨着。毛顺生和毛泽民眼看她越来越严重了,这才下了决心,叫回了毛泽东,要送她去省城医病。\n文七妹却不想去长沙,只说跑那么远干什么,哪里不是一样的诊?毛泽东只能反复劝她省城可不像韶山这个小地方,有洋人开的大医院,什么病都诊得好。他一再宽妈妈的心,说不管什么病,等到了省城,就诊好了。好说歹说,文七妹终究拗不过丈夫和两个健壮的儿子,这才答应了下来。\n独轮车的车轮吱呀吱呀,辗过崎岖不平的羊肠山路。独轮车上,架着简单的木板,文七妹满脸病容,无力地斜靠在上面,盖着床被子。她的身后,毛泽东推着独轮车,额角绽着汗珠,汗水早已浸湿了前襟后背。泽民背着行李走在旁边,不时地对哥哥说:“大哥,你歇一会,我来推吧。”\n“不用不用,我来推。娘,您还没到过省城呢,等到了,诊好了病,我带您看省城,哪里热闹我们就看哪里,好不好?”\n“哎。看,看。只要娘走得动,就看。”\n“走得动的,诊好病就走得动了。不光省城,以后,北京、天津、上海、广州,好多地方您还要去看呢。”\n“娘哪里跑得那么远喽?”\n“跑得的。我先去嘛,这些地方,我都去,去了,就接娘去。娘,您还要活到九十九呢,哪里去不得?都去得。”\n“好,你去,三伢子去了,就等于娘去了。”\n山道弯弯,小车吱呀,母子之间的对话声,渐渐融入了秋日夕阳之中。\n车船劳顿,跑了整整一天的路,毛泽东总算把母亲送进了湘雅医院。\n给文七妹看病的是一个西洋医生,洋医生一番周折,检查完了,考虑了一下,才用还算清晰的中文对毛泽东说: “你是病人的大儿子?病人现在需要住院观察几天,先让你弟弟给她办住院手续。你留下来。”\n诊室内只剩了毛泽东和医生,毛泽东神情紧张地看着医生,听他说: “你母亲患的是淋巴腺结核,病情已经比较严重了。目前的医学,还没有治疗结核病的好办法,主要是保养,延缓病情的发展。但现在的问题,是你母亲的身体太差了。你说她只有50岁,可是从她的身体状况来看,就像一个70岁的人,我认为,她太过于劳累了,她在透支,透支自己的生命。如果再让这种情况发展下去,病情就会很难控制,你明白吗?”\n毛泽东沉重地点了点头,出了诊室,扶着楼梯一步一步走下楼来。眼看拐弯就要到病房了,毛泽东停住了脚步,深深吸了一口气,努力撑起一张笑脸,装出轻松的表情。他走到门口,正听到病房里传出一个冷冷的声音:“……怎么回事?你看不看得懂啊?”进门一看,一名护士一脸淡漠,看也不看毛泽民,边对着小镜子补妆边用鄙夷的口气说:“这是临时留观。什么是住院手续知道吗?”\n“我……我就是在门诊那边办的嘛。”毛泽民手足无措地看着护士。\n文七妹也看着护士小心翼翼地说:“那我们就留那个观嘛。”\n护士瞥了母子俩一眼冷冷地说:“你想留就留啊?真是!”\n毛泽东没听明白,进去之后,先看了看母亲,然后尽量和气地问:“护士,我们办错什么了?我是要给我娘办住院手续,如果错了,那我去补办一下。”\n护士自顾自地照着镜子:“你知道这儿住一天院多少钱吗?带了钱没有?”\n“请你给我娘安排病房,我现在就去补办手续。”\n因为长途跋涉,母子三人的身上和行李上,都满是尘土,护士看看人、又看看地上卷成一卷的行李、被子,毫无表情地说:“对不起,现在补办晚了,病房满了。”\n毛泽东真有些耐不住了,正想争辩,恰在这时,文七妹突然咳嗽起来,毛泽东赶紧扶住母亲,拍打着她的背:“娘,娘,您顺顺气,别着急,别着急啊。泽民,你扶着娘,我去打碗水来。”\n他刚转身,突然一愣,看到斯咏正站在面前的走廊上。\n斯咏是听说毛泽东接了母亲来看病,才专程赶来的,她看了看毛泽东,沉着脸,转向那个护士说:“我是这家医院陶董事的女儿,叫你们院长来!”\n病房的问题因为斯咏的到来而解决了。斯咏站在病房里,看毛泽东和弟弟小心翼翼地把妈妈扶到了病床上。\n毛泽东给母亲盖好被子,又端来一盆水,要给妈妈洗脸。文七妹拦着他,气喘吁吁地要儿子先招呼陶小姐,请陶小姐坐。站在一旁的斯咏赶紧摆着手说:“伯母,您不用客气,我和润之熟得很。”\n“对,我们是好朋友,不讲究这些。斯咏,你坐啊。娘,来,擦擦脸。”\n斯咏在一旁坐了下来,看毛泽东小心翼翼地给母亲擦着脸,他的动作是那样轻柔,那样仔细。洗了脸,他又捧着碗,小心地喂着母亲喝水,还用手帕轻轻擦去了母亲嘴角沾上的水。\n望着毛泽东在母亲面前温柔、仔细的一举一动,斯咏几乎都看呆了。\n妈妈睡下之后,毛泽东送斯咏出医院,很真诚地感激她今天为母亲做的一切。 斯咏问起文七妹的病情,毛泽东低下头,说:“我娘的病,其实都是累出来的。这几十年,整天整天,整夜整夜,田里,家里,大人,小孩,都是她一双手,就算是机器,它也要停一停啊,可我娘,就从来没停过。看看我这一身,哪样不是她一针一线熬夜熬出来的,可这些年,我这个做儿子的,也不在她老人家身边,什么事也没有为她分担,就连一点回报,也没有给过她老人家,反而让她牵挂我,想念我。”\n斯咏轻轻握住了他的手:“可你心里记着你母亲,有这一点,我想伯母也就满足了。”\n“是啊,中国最苦的,就是我娘这样的妇女,一辈子,什么都没有享受过,就这样一句话也不说,做啊,做啊,一直做到筋疲力尽,做出一身病痛,做到做不动为止。乡下呢,得了病,又没有地方看,只能这么拖,这么熬,结果小病拖成大病,大病拖成……好多人一辈子,连医院的门朝哪边开,连医生是个什么样子都不晓得啊!”\n“谁叫中国还这么落后,还这么贫穷呢?”\n“不,这一切都不合理,这一切都一定要改变!总有一天,我要让中国所有的人,不管是男人、女人,不管是城里、乡下,不管他有钱、没钱,都吃得起药,看得起病,我要让中国,再也不出现像我娘这样的悲剧!”毛泽东转过头,目光炯炯,“斯咏,你相信会有这么一天吗?”\n迎着他的目光,斯咏犹豫了一下。如此梦幻般的空想显然距现实太过遥远,但她又不忍否定:“也许吧,润之,你那么爱你的母亲,就凭这份爱,我相信你会做到。”\n三 # 晚上,忙了一天的陶会长进了门,伸展了一下的腰身,便倒在了沙发上。一杯茶轻轻端到了他面前,陶会长接过茶,却看到端茶给他的,居然是斯咏。\n“爸,忙了一天,累了吧?”斯咏转到沙发后,给陶会长按摩着肩膀。\n陶会长简直有些受宠若惊了,他扭头看着女儿。\n“怎么了,爸?”\n“没什么,没有什么。你今天……这么有空哦。”\n斯咏没回答他,她按着父亲的肩膀,突然趴到了父亲背后:“爸,我平时是不是很不听话?是不是老让您好烦好烦?老是惹您不高兴?”\n“你怎么……怎么突然说起这些来了?”\n“我只是想知道,想知道有我这样一个女儿,您后不后悔?”\n“后悔?这孩子!说什么傻话呢?”看着斯咏的眼睛,陶会长放下茶杯,也专注起来,“斯咏,不管是什么样的孩子,在父母眼里,永远都是最好最好的,你就是我最好的女儿,有你,爸这一辈子,都高兴,都幸福,都骄傲,你明白吗?”\n搂住了父亲的脖子,斯咏轻声叫着爸爸,心里却回想着毛泽东服侍他妈妈的样子……\n回到自己房间,斯咏铺开那张写着“姨父姨母大人台鉴”的信纸,深深吸了一口气,终于还是提笔写了下去。\n四 # 文七妹出院的前一天,葛健豪买了橘子来看文七妹。葛健豪听说文七妹明天就回去,很是意外。文七妹解释说:“家里事情放不下呀,鸡啊,猪啊,牛啊,都要喂,我老倌子和伢子、妹子又没人做饭。我呀,闲不得,闲了这几天,一身都痛,生就的贱命,没办法。”\n“可病总得看好呀。”\n“我这个病,洋郎中也讲了,就是自己保养,在医院,在家里,都差不多。还是回去好,回去习惯。”\n两位母亲亲切地聊着家常话,聊着他们都引以为自豪的儿子。从窗户看出去,她们正好可以看到毛泽东和蔡和森靠在病房外的走廊栏杆上,隐隐约约听到他们在说着工人夜学。\n“听说,我三伢子也常跑到你屋里去,又吃又住的,给你添好多麻烦吧?”\n“那有什么。润之这孩子,我喜欢。”\n文七妹说:“我听我三伢子讲过,你呀,知书达礼的,读过的书数都数不清,你有本事啊,所以教得那么好的儿子出,年年在学堂里拿前几名,不像我,字都不认得一个,一世人的睁眼瞎子,想教崽伢子,也不会教啊。”\n“不,毛妈妈,您才是最好的母亲。”葛健豪握住了文七妹的手,“过去我一直在想,是什么让润之这么出色,这么优秀,见到您,我才明白,是因为有您这样一个母亲。”\n文七妹憨笑着:“我哪有那个本事?哪有那个本事?”\n第二天,文七妹出院了,从长沙回韶山了,毛泽东在码头送别妈妈和弟弟,心里惦记着自己给妈妈许的那么多诺言,渴望着能有机会一一实现。但这一江秋水,却将母子二人永远隔开了……两年后,文七妹因患淋巴腺结核,病逝于韶山,终年52岁。\n五 # 斯咏的那封“姨父姨母大人亲启”的信在王家果然掀起了轩然大波。王老板怒不可遏地将那封信狠狠地拍在桌上,吩咐被吓得魂不附体的阿秀去把少爷叫回来,今天就去陶家,过彩礼,定日子,尽快完婚!\n子鹏回来后,却不愿意去陶家求亲,他告诉爸爸既然斯咏提出来要退婚就应该尊重人家。王老板回敬道:“尊重?她尊重你了吗?她尊重我们王家了吗?女孩家,居然敢擅自做主退婚,这不是往我们王家脸上抽嘴巴吗?这要放到从前,那就是沉潭浸猪笼的罪过!”\n子鹏又说,现在不是从前了,婚姻是要讲感情的。王夫人马上指着儿子的鼻子教训:“你们表兄表妹,怎么没感情了?就算现在淡一点,等她嫁过来,不自然有了吗?也不知道你这个脑子里天天想了些什么!”\n子鹏直接告诉父母,现在是人家根本就不愿意。王夫人一听这话,差点没跳起来:“那是你表妹一时糊涂!可她糊涂,你不能糊涂啊。陶家什么人家?长沙第一大户!家里又只有你表妹这一个女儿,只要娶过来,什么不是你的?这么简单的道理,还要妈教你?”\n子鹏这才明白,父母逼着自己和斯咏结婚,根本就没有考虑自己的幸福,而是记挂着陶家的家产。他更不想结婚了,又找理由说,也许退婚不仅仅是斯咏的意思,也是陶会长的想法。\n“不可能!”王老板斩钉截铁,“你姨父什么身份?定好的亲事,他敢悔婚?他还要不要这张脸?这就是斯咏整天在外头瞎混,被那些不三不四的学生给带坏了,所以我才要你赶紧求亲,趁早让她退学嫁过来,就什么事都没有了。好了好了,你也不要啰里啰嗦了,赶紧换衣服,上陶家!”\n“我不去!”子鹏看了一眼秀秀,涨红了脸,“这门亲事,我也不愿意,我也要退婚!”\n“混账东西!还敢顶嘴?”“啪”的一声,王老板一个耳光打得子鹏一歪,秀秀吓得赶紧扶住了子鹏。\n“你到底去不去?”\n“我偏不!”捂着被打红了的脸,子鹏猛然昂起头来冲出客厅,向大门跑去。身后,秀秀与王老板夫妇追了出来。\n秀秀在江边追上了子鹏,她走到了子鹏面前,抚摸着子鹏红肿的脸,劝他还是不要与父母作对,赶紧回去。子鹏摇了摇头,很坚决地表示绝不回去。\n“可老爷太太是真发脾气了,再说,您跟表小姐……其实真的很合适,您就听老爷的话吧。”\n“阿秀,你真的希望我跟表小姐结婚?”子鹏抓住了秀秀的手,盯着她的眼睛问。\n秀秀的头不由得低下了,犹豫了一下,还是点了点头:“少爷和表小姐,本来……就是天生的一对嘛。”\n子鹏的目光一下子黯淡了,机械地跟在秀秀的身后,往家里走。一阵江风吹过,子鹏停住了脚步。秀秀见他停住,伸手来拉子鹏的手。猛地,子鹏用力一拉,秀秀,猝不及防,一头扑在子鹏身上,子鹏一把将她紧紧抱住: “阿秀,我不会娶斯咏的,因为我早就在心里发过誓,这辈子除了你,我谁也不娶,不管她是小姐,是公主,是什么大富大贵,都比不上我的阿秀的万分之一!我现在只恨自己过去太胆小、太软弱,我早就应该像斯咏一样,勇敢地追求自己的幸福!”\n“少爷……”\n“不要叫我少爷,叫我子鹏。”\n“子……子鹏。”\n“答应我吧,阿秀,答应我,跟我一起走,走到一个新的,没有人认识我们,没有人能干涉我们的天地,我们结婚,我们永远在一起,快快乐乐的,永远不分开!只要有你跟我在一起,我会比过去活得快乐一千倍、一万倍。答应我,阿秀,答应我,跟我走吧?”\n两个人紧紧拥吻在一起,喜极而泣的眼泪混合着,流满了两张紧贴在一起的脸。\n这一幕被随后赶来的王老板看到,无疑是晴天霹雳。他一声怒呵,身后是那五六个粗壮的男仆马上扑了上来,从子鹏怀里拉走了秀秀,一路拖回王家,扔进了杂屋,用粗大的铜锁锁上了柴房门。\n子鹏经过一番挣扎,头发弄乱了、衣服撕破了、眼镜摔坏了,却最终被两个男仆按倒在了客厅的沙发里。余怒未消的王老板翻出秀秀的卖身契,在子鹏面前使劲地晃着: “看清楚了?啊?自愿卖身!我这可是有凭有据。她刘秀秀是卖给我王家的丫头,愿打愿卖都得由着我。你放心,打,我也懒得再打了,明天我就将她给卖了!”\n“不!”子鹏手脚并用地踢着、抓着,冲着父亲嚎叫。\n“近了我还不卖,上海、香港、南洋,能卖多远我卖多远,包身工也好,给人作妾也好,进窑子当婊子也好,反正这辈子我让你永远看不到她的影子!”\n“不!”子鹏猛地甩开了那两个男仆,一头扑到了一旁的王夫人脚下,“妈,我求求你,我求求你,不能这样,不关阿秀的事啊!”\n王夫人别过脸:“怎么不关她的事?就是这小狐狸精使的坏!看着老实巴交,我还当她是老实孩子呢,暗地里居然勾引我儿子,想当少奶奶了!这种狐媚子,留她干什么?”\n“妈,真的不怪阿秀,是我喜欢她,我喜欢她!是我硬要和她在一起的!”\n“你看看你看看,那小狐狸精使了什么招,把你迷得这么神经的?她是个丫头,是个丫头!你明不明白?”\n子鹏声泪俱下:“我真的喜欢她,妈,爸,我求求你们了,放过她吧!”\n“放过她?放过她你就听话了?”看到儿子不停地点头,王老板回到沙发上,坐下,不紧不慢地理了理衣服,“子鹏,你也别怪我和你妈逼你,你年轻,不懂事,我们也是没办法,以后你会明白,我和你妈这么做,都是为你好。你起来吧,起来呀。”\n王夫人忙不迭地把子鹏扶了起来。\n“你喜欢阿秀,我也没说一定不行,可你不能为了一个丫头耽误了正事。眼前就两条路,一条,你不娶斯咏,结果怎样我已经说过了。另一条,你老老实实,去陶家求亲,至于阿秀嘛,我可以留在家里,好好待她,等你把斯咏娶过了门,要她继续服侍也好,想把她收房做个小也行,我都不拦你。两条路,你自己选吧。”\n子鹏无力地跌坐在椅子上,默认了父亲的安排。\n王家父子俩带着丰厚的礼物衣冠楚楚地到了陶家,一进门就把斯咏的退婚信先给了陶会长。王老板挂着笑容,注意着姐夫的表情,子鹏一身西装革履,木然地坐在他身边。\n“死丫头,简直……简直想把我气死!”陶会长只看了一眼,就哆嗦着,猛地一把捏紧了手里的信。\n“别动气,别动气,姐夫,孩子们还年轻,犯犯糊涂总免不了。这也怪现在那些学校,什么自由啊,个性啊,解放啊,乌七八糟,教得学生不成个体统。斯咏都是受了那些所谓新思想的害,一时糊涂。要说呢,婚姻大事,那是开得玩笑的?斯咏这回,还真是太毛糙了。姐夫,我听说她跟一师范有些男学生常来常往,有些话,外面传起来,不大好听啊。当然了,我是不会往心里去的,可要万一真弄出什么事来,那可是孩子一辈子的事啊!咱们当长辈的,再来后悔不就晚了吗?”\n这话正说到陶会长的隐忧,他不由得点了点头。\n“所以,当断则断,只要马上把斯咏和子鹏的亲事一办,不就什么事都解决了?子鹏也是这个意思。子鹏,跟你岳父表个态啊!”\n子鹏木然地点了一下头。\n陶会长这一次,是在和王老板商量之后,把一切都安排好了,才突然对下午放学回来的斯咏说,要她退学结婚。而且说了这话之后,就吩咐了管家,在王家来接亲之前,不许小姐踏出家门一步!\n斯咏没有想到父亲这次做得如此决绝。但陶家一向宠惯了这个小姐,哪里能看得住她?趁着家里上上下下的丫环仆人都在贴喜字、挂灯笼,斯咏悄悄地跑了。聪明的姑娘直奔码头,问清楚当天晚上11点半就有最近一趟去武汉的船,她果断地掏出钱就要买一张船票。可就在递钱出去的那一瞬间,她犹豫了,突然改变主意,买了两张船票。\n斯咏紧紧攥着两张船票,坐上了黄包车。黄包车的车轮飞转在去一师的路上。\n六 # 黄昏的阳光透进学友会事务室里,给桌前正在看报纸的毛泽东涂上了一身的金黄。开慧蹦跳着进了门,叫了一声“毛大哥”,毛泽东似乎早已习惯了开慧这时候来,头也没怎么抬,只嗯了一声。开慧打量着摆了一桌子的记事本、杂志、球拍、笔墨等杂物,皱起了眉头,她拿起一个本子拍了一下毛泽东的脑袋,笑着骂他是个邋遢鬼,就一间办公室,还一天到晚乱七八糟。\n边说边麻利地把房间收拾整齐了。然后她趴到毛泽东坐着的椅子背上,顺手给他梳理着有些乱的头发,问他又有什么新闻啊?\n“护法军打傅良佐,傅良佐又反攻护法军。老调调,没什么新鲜的。”毛泽东笑笑,放下手里的《民报》,又拿起下面的《大公报》,浏览着主要的标题。猛然间,他腾地坐直了身子,把开慧吓了一跳!\n“出什么大事了?”\n“嘘,”毛泽东止住了开慧的打搅,一口气看完了报道,猛地一拍桌子,“好,好啊,太好了!开慧,你看……”\n开慧接过报纸,读出了下角一篇并不醒目的报道的标题:“《俄罗斯国爆发十月革命,工人武装推翻临时政府》?”\n“太好了!”毛泽东一挥拳头,仿佛指挥起义的是他一样,“你看看人家俄罗斯,工人起来了,武装暴动了,连政权都被他们夺到手了!我一直就在想,不破不立,可就是想不明白什么才是真正的不破不立?人家现在做出来了,打破旧世界,建立新世界,这就是不破不立,这就是新世界的希望!”\n他来回走了一圈,实在无法抑制心中的激动,猛地拉开房门:“开慧,我去找子升,你回去告诉老师,说我们回头就去你家,回头就去!”\n毛泽东和萧子升赶到杨宅书房,发现老师已经在等他们了。\n“这则报道我也看到了。”杨昌济待学生坐下了,也拍着报纸说,“惊世骇俗,的确是惊世骇俗啊!”\n毛泽东一拉身边的萧子升:“所以啊,我马上把子升拉来了。萧菩萨,你看,人民奋起,破旧立新,建立自己的政权,这才是推动世界进步的根本方法!”\n“有你说的这么厉害吗?”\n“你还不相信!你看啊,一个团体,布尔什维克,这是先进组织;广大民众,俄罗斯的工人,这是革命基础。上有团体组织,下有民众基础,所以人家搞成了事嘛!”毛泽东指着报纸,兴奋地阐述着自己的看法,又回头问杨昌济:“老师,您讲讲,像这样的革命,是不是代表了社会前进的方向,是不是给我们指明了打破旧中国、建立新中国的办法?”\n杨昌济沉吟着:“以霹雳手段,摧毁旧世界,看来人家确实是办到了。不过,破旧不等于立新,革命能不能真正成功,不光看革命能破坏什么,更要看它能建立什么。”\n“能破自然能立。工人起来了,民众起来了,还怕建不成人人幸福的大同世界?子升,你说对不对?”\n毛泽东推了子升一下,子升的眼睛却呆呆地望着报纸,兀自陷在震惊中,整个人一动不动。毛泽东察觉出了不对,伸过头来,这才发现就在有关十月革命的报道下面,刊登着一篇几乎同样大小的结婚广告:“王府公子子鹏先生,陶府千金斯咏小姐,定于民国六年十月初四(公历1917年11月18日礼拜天)借圣公理会大教堂举行结婚典礼。执手偕老,琴瑟永合,兹具此函,公之于众。”\n“王子鹏先生,陶府千金斯咏小姐……结婚?!”毛泽东也惊得目瞪口呆!\n就在这时,外面却传来了敲门声,毛泽东走出书房一看,竟然是警予和蔡和森,他们手里,居然也拿着那张报纸。蔡和森见面就说:“我猜你在这儿,果然没错。”\n进了书房,蔡和森先与警予对视了一眼,然后对杨昌济说:“老师,我们,想单独和润之谈谈,可以吗?”\n看看警予与蔡和森严肃的神色,再看看那张报纸,杨昌济站起了身向开慧、子升一挥手,示意二人跟自己出去。\n屋内,蔡和森、向警予直接告诉毛泽东,斯咏失踪了。毛泽东才看到斯咏的结婚启事,听到两人这样说,有点莫名其妙。向警予跟蔡和森轮番轰炸着毛泽东:\n“陶伯伯刚到周南找过斯咏,所以我们也是刚知道的消息。你知道斯咏为什么会失踪吗?斯咏和王子鹏,根本就没有感情,这种强加于人的婚姻,她当然无法接受。可更重要的是,她心里,一直装着另一个梦。”\n“斯咏的梦,也许不切实际,也许只是浪漫的幻觉,但是,就连我们,也常常能从她的目光中,感觉到一点什么,润之,难道你就从来没有感觉到过吗?”\n“我知道,事情往往是当局者迷,往往最后一个知道的人,才是自己。可是今天,斯咏为了抗拒她不需要的婚姻,也为了自己的梦,已经迈出了这一步。润之,不管过去你是不是有过感觉,现在也是你必须明白,必须给出一个答案的时候了。否则,就算找到斯咏,也没有任何意义。”\n一片静默中,毛泽东沉默着,犹豫着。他突然抬起头来,目光清澈,正视着自己的朋友:“不,毛泽东并不是一块木头,我也并不是从来没有过任何感觉的人。这些年来,朝夕相处,志同道合,我,和大家,和你们每一位朋友,也包括斯咏,有过那样多纯真而美好的过去。我记得我们的书生意气,指点江山,我记得我们的激扬文字,坦诚知心,还有我们的同生共死,患难与共。这其中,斯咏给过我许多,许多的友谊,许多的情感。当她不顾自己的生死,那样决然地跟我一起面对危险的时候,当我们并肩遥看湘江岳麓,她就站在我身边时,我不是没有那一刹那的感觉,也许她的心里,不仅仅是友谊那样简单……”\n院子里,杨昌济、向仲熙、子升、开慧面向书房,静静地听着。谁也没有察觉,他们的身后的小院的门口,竟多了一个人。那是提着行李箱的斯咏,她从码头赶到一师、又从一师赶到这里,满怀期待的心在这书房里传出的平静声音中渐渐被击碎了,而且在继续被击碎……\n“可是,我没有,从来没有过超出友谊的想法。在湘江边,在橘子洲头,在我们共同讨论一个属于我们的、更属于未来中国的青年团体的时候,我就提出来过,不谈男女私情。我是真心诚意说这句话的。也许,在别人眼里,这很幼稚,也很奇怪,可我真的是觉得,我们还年轻,我们还只是学生,我们有许多书要读,许多事要做,许多道理要明白,许多路要走。大言之,我们的社会,我们的中国,还有那么多需要改变的事情,而每一件,都值得我们倾注出全部的精力和热情。我不是一个天才,更不是什么超人,也许这一生,我成就不了什么事业,但我愿意倾我所能,为了理想而奋斗,为了中国而奋斗,为了更多的人,得到更多的光明,而牺牲我个人的一切。是,未来还很遥远,理想也只是梦幻,但它毕竟来自每一天,每一步的积累。作为一个学生,我相信,真心求学,实意做事,这才是今天的我们应该做的事,而不是那些只属于个人的卿卿我我,缠缠绵绵。也许正因为我太过理想化,也太过粗心,斯咏心里想的什么,我从来不曾真正去认真揣测过,哪怕偶尔的那一刹那,我也把它当成了我的多心,因为我们是这样风华正茂的一群人,因为我们这帮同学少年,都有着同样崇高的信念,决心以天下为己任,决心为真理而努力终生,我以为,友谊和信念,才是我们之间唯一的、值得信赖的桥梁,我不曾想过其他。”\n听着他真诚的袒露,警予与蔡和森都想弄明白:“可是,感情和理想,和信念,和事业,和你所追求的一切,真的就是矛盾的吗?”\n“不,感情和这一切,也许不矛盾。我虽然没有经历过这种感情,可我相信,感情是双方的,是共通的,是心有灵犀的,斯咏的感情,我体会得是那样肤浅,我对斯咏,更只有纯粹的友谊。那么我们之间,真的存在超出友谊的情感吗?蔡和森,你开始说,斯咏心中所藏的,也许只是一个浪漫的、不切实际的梦幻,我想,这也许是真的,因为如果她真的心中有我,在她的心中,所藏的那个人,也并不是真正的毛泽东,而只是一个被加工过的梦想而已。”\n斯咏似乎已经听到了自己心脏的最后一声破碎声,她微微闭上了眼睛,悄悄地俯下身,把那本《伦理学原理》轻轻放在了门槛边,出了杨宅。\n“爸?”\n她看到面前站着的竟然就是她的爸爸,更远处的巷子口,灯笼一片,马车、仆人们正静静地等候着。她不知道爸爸是怎么找到这里来的,但她知道爸爸一定找得很辛苦。\n“斯咏,回家吧。”\n斯咏呆立着。\n望着女儿的眼睛,陶会长和言细语:“明天是你大喜的日子,还有好多事要准备呢,别在这儿耽搁了,啊。”\n斯咏终于点了点头:“爸,回家吧。”\n她大步、决然地向前走去。\n杨宅院子里的人这时候听到声音都跑了出来,子升、开慧跑在最前面,后面是杨昌济夫妇。\n书房里,毛泽东头一个拉开房门就冲了出去,只看到远远的巷子口,斯咏与陶会长一道,正走向马车。\n“斯咏!”\n远远的,毛泽东的声音传来,正要上马车的斯咏脚步不禁一顿。只犹豫了一下,她继续向马车走去。随后追出的蔡和森与警予却看见了躺在门槛边的那本《伦理学原理》,警予捡了起来,书尚未递到毛泽东手上,夜风掠过,书的封面被吹开,露出了扉页上那句“嘤其鸣矣,求其友声”。\n接过书,毛泽东抬起头来。远远的巷口,斯咏已坐上了马车。马车驶动了,斯咏微微扭过头,但驶动的马车,将她的目光带出了巷口。两张纸片随着马车的背影,随着夜风,轻轻飘去。子升捡起来一看,那正是两张去武汉的船票。\n七 # 白天的小院已经丝毫没有了昨天晚上的喧嚣,但那喧嚣却留在了人的心里。杨昌济看到女儿手拂着兰花叶子,坐在花架前出着神,便静静地看着兰花,没有去打扰女儿的思绪。\n“爸,什么是爱情?”终于,女儿从沉思中醒了过来。\n杨昌济不禁微微愣了一下,回头看看妻子,妻子正站在屋檐下,静静地看着自己和女儿。\n“爱情,就是成年人之间,相互的倾心和爱慕。”\n“那,爱情和理想是矛盾的吗?”\n开慧看到父亲没有马上回答,似乎还在想什么,就自问自答: “你看啊,一个人的想法,其实分不了那么清的,理想、信念、抱负,和感情,不是一刀切开变成几回事,而是混在一起的,什么样的理想,什么样的信念,才会需要什么样的感情。如果两个人对人生、对别的大事追求、想法都不同,其实就不可能有一样的感情。对不对,爸?”\n“你能想到这一层,很不容易。”杨昌济不禁点了点头,“就比如润之吧,作为学生,润之是我见过的最优秀的一个,他的才华、他的倔强、他的冲天豪情、绝世抱负,都是我生平之所未见,能够教出这样一个学生,是爸爸一生最大的幸运。可是,可是他并不见得是一个能给人带来幸福的伴侣啊。他的个性太强了,他太执著、太任性,太像一团烈火,熊熊燃烧,不顾一切!他也许能成就惊天动地的事业,也许能赢得世人莫大的敬仰,但这样飞扬不羁的一个天才,能给爱他的人,带来一份属于自己的温馨、祥和,带来一份平平安安、无忧无虑的日子吗?”\n“那也不一定,爸,蜡烛燃得再久,有的人,也会宁愿选择惊天动地的闪电。”\n听到女儿这样说,杨昌济不禁与静静站在一旁的向仲熙对视了一眼。他理了理开慧额前的刘海,目光中充满了父亲的慈爱:“我的开慧长大了。可最迟发现女儿长大了的人,为什么永远是父亲呢?”\n开慧一笑:“我长大了?”\n“什么是真正的幸福,本来也只在于每个人自己内心的感受。能懂得这个道理的,就不是小孩子。”\n“我也长大了,哈哈。”开慧得意地站了起来,这一刹那,她的脸上挂着的又全是孩子般天真的笑,“这么深奥的道理,可不能只有我一个人知道。我现在就去教给那些应该知道的人。”\n望着她孩子气地蹦蹦跳跳出门,杨昌济不禁摇了摇头,对妻子说:“刚还说她长大了,结果……哈哈。”\n向仲熙看着女儿的背影,停了好几秒钟,这才说:“快了,不都16了吗?”\n江风吹拂,卷动着沙滩上那本《伦理学原理》,那句“嘤其鸣矣,求其友声”不断随着被风卷动的书页闪过。毛泽东的目光迷离在无尽的湘江天际,他的心里,同样如书页翻卷不休:观止轩书店里正选着书的斯咏、大大方方地把书递到了刚刚踢破了布鞋的毛泽东面前的斯咏、湘江边来应征笔友的斯咏、岳麓山上与毛泽东手拉着手忘情奔跑呼啸于大雨中的斯咏、乡村的草坡上与他一道枕着手仰望蓝天的斯咏、寝室里抱起了一堆《梁启超等先生论时局的主张》毅然地站在了他身边的斯咏、橘子洲头与毛泽东同立在岩石上面对壮丽山川展开双臂的斯咏……还有,杨宅院外,马车驶出巷口时留下那最后的令人如此神伤的一瞥的斯咏。\n无数的斯咏在毛泽东脑海里重叠着……突然,一阵脆生生的笑声响起,这笑声是那样突如其来,毫无关联,全无道理,却偏偏来得那么自然,一下子打破了斯咏眼神中无尽的哀怨。\n毛泽东用力地晃了晃头,以为是自己的幻觉,但随即笑声就已经到了他的背后,毛泽东一回头,站在身后的,正是开慧。\n“就知道你在这儿。”开慧蹦到了毛泽东面前,俯身盯着毛泽东的眼睛,“想不想听杨老师跟你讲个道理啊?”\n“怎么,老师也来了?”毛泽东四下看了看。\n开慧一指自己的鼻子:“这个杨老师。”\n双手托着小脸,开慧眼睛都不眨一眨地盯着毛泽东,把刚才讲给爸爸的和从爸爸那里听来的话,一股脑儿全给了毛泽东。\n看着这双清澈见底的眼睛,毛泽东点了点头:“我明白了。一个人的情感,和一个人追求,从来是一回事,斯咏与我走不到一起,只是因为我们是两种人,她梦想她的浪漫,我执著我的责任,我们之间,没有谁亏欠谁。”\n“所以啊,就算斯咏姐真的实现了她的梦,对她,也不见得是幸福。”\n毛泽东长长地舒了一口气,仿佛这一刻,他才终于感到了心中的解脱:“谢谢你,开慧。谢谢你帮我解开了这个心里的结。”\n开慧调皮地要求:“谢谢杨老师!”\n“要得,谢谢杨老师。”\n“哎,这还差不多。”能让毛泽东服一回气,开慧不禁开心得大笑,直笑得躺倒在了草地上。那清脆的、无拘无束的笑声,刹那间充盈在整个江岸边,整条湘江上。这天籁般的、纯真的笑声中,发自内心的、彻底轻松的笑容洋溢在毛泽东迎着阳光的脸上,他问开慧:“你说,那我毛泽东以后,是不是真的能碰上一个知我,懂我,和我一样的理想,一样的信念,也有一样的感情的人啊?”\n“你很难懂吗,我怎么不觉得?讲得自己好像好了不起,也不羞!”\n第二十九章 男儿蔚为万夫雄 # 夜幕下的一师,孔昭绶已经接到了猴子石传来的捷报,他打开《第一师范校志》,奋笔如飞地记载下了这次事件。意犹未尽,他又郑重地落下了这样一句话:“ 全校师生皆曰:毛泽东通身是胆。”\n一 # 陶家门口,大红灯笼高高挂起,鲜红的喜字对贴门上,忙碌的仆役披红戴彩,合府上下,一派喜气洋洋。斯咏房间里,画眉如烟,点唇似绛,换上了婚纱的斯咏面无表情地化着妆。那张秀美的脸,被描画得如此精致,偏偏却毫无生机,仿佛一张没有生命的假面。丫环推开了门,报告说该上教堂了。镜子前的新娘站起身来,捧起桌上一束鲜花,却突然看见了花下周南的校徽。她的手指轻轻一拨,校徽落进抽屉,抽屉关上了。\n王家,两个丫环为子鹏穿上了崭新的燕尾服。雪白的衬衣,精致的领结,闪亮的皮鞋,一丝不苟的头发……但子鹏却如同一具木偶。\n一尊巨大无比的豪华结婚蛋糕推到了客厅正中央,王老板夫妇打量着蛋糕,乐得嘴都合不上了。子鹏面无表情地走到了蛋糕前,看到了蛋糕旁的托盘里,有一柄扎着红丝带的餐刀。\n“瞧瞧,瞧瞧,满长沙城,谁家办喜事弄出过这么大的西洋蛋糕啊!子鹏,这回该满意了吧?”\n王太太还在唠叨,王老板看看时间,吩咐子鹏该上教堂了。子鹏却突然说他要见阿秀,不让他见她,他绝不去教堂。\n他转身就走,王老板夫妇慌了,赶紧追去。托盘里,红丝带还在,那柄刀却不见了。\n“哐啷”一声,杂屋的门开了。子鹏冲上前去,和蜷缩在墙角的秀秀紧紧拥抱在了一起。杂屋外王老板向看守的王福一使眼色,王福会意,咔嚓锁上房门,守住了门口。\n捧起秀秀带着伤痕的脸,子鹏已是泪流满面。秀秀同样流着泪,却努力露出了一丝微笑:“子鹏,别这样,我没事的,真的没事。”她擦了擦子鹏脸上的泪:“一会儿你还得去教堂,把眼泪擦擦吧,别让人看见了。”\n“我不会去教堂的,我不会跟别人结婚。”\n“子鹏,不要这样,我不怪你,真的。我没读过书,不会讲道理,我只知道,好久好久以来,子鹏少爷就是我心里的一个梦,我从没想到你会真的喜欢我,你会真心真意地爱过我,在梦里,我已经什么都得到了,我已经好满足好满足了。人,不能要得太多,有了梦里的,就不应该再想着真的了。”抚着子鹏的脸,秀秀含着微笑,“记着这个梦吧,子鹏,记着这个梦,就什么都够了。”\n“不,阿秀,它不是梦,我也不能把它当成梦。就算真的是梦,我也绝不让人毁了它!”子鹏缓缓地从袖子里,突然拔出了那柄餐刀。\n秀秀大惊:“少爷!”\n子鹏赶紧捂住了她的嘴:“阿秀,生不能相守,就让我们死在一起吧。”\n秀秀吓得慌了手脚:“不,子鹏,不,你不能这样,你不值得为我……”\n“值!值得!只要这一刀下去,那就谁也挡不住我们在一起,谁也不能阻止我们永远相依相伴。让我们永远在一起吧,阿秀。”\n两只几乎同样纤秀、白净的手腕紧靠在了一起。餐刀架在了两只手腕上。紧紧依偎在一起,两个决心殉情的人目光都是那样平静,充满了幸福的满足。刀微微一提,就要往下切……“砰!砰!砰!”突如其来的乱枪声惊得两个人同时抬起头来。\n二 # 1917年11月18日,北洋系军阀、湖南督军傅良佐在护法战争中被护法军程潜部(湘军)击溃,所部溃兵三千余人败往长沙,已经到了城南距离一师不远的猴子石。整个长沙城,陷入一片恐慌与混乱中,大街小巷,到处是拥挤不堪的骡马车轿,男男女女扶老携幼,扛着行李,争先恐后,夺路而逃。王陶两家正在进行的婚礼也被打断了。\n趁着父母在收拾细软,子鹏与秀秀趁机逃出后门,融进了逃难的人群。\n陶家,斯咏也脱掉了雪白的婚纱,却逆着逃难的人流艰难地往一师跑。\n第一师范校园里,此时铃声大作,学生们正跑向操场集合。孔昭绶见方维夏匆匆跑来,焦急地问:“维夏,怎么集合得那么慢?”\n“今天是礼拜天,老师们都放假了,人手不够啊!”\n“人手不够,也不能漏掉一个学生!”孔昭绶一咬牙:“我这边,你那边,一间一间寝室挨个喊!”\n两个人刚要出发,身后,传来了熟悉的声音:“昭绶兄。”\n孔昭绶、方维夏一回头,是气喘吁吁的杨昌济,他的身后,是满头大汗的袁吉六与徐特立。更后面,校门口,饶伯斯、费尔廉、黄澍涛、易培基、王立庵、雷明亮……一个个老师正匆匆跑来。望着老师们一张张脸,孔昭绶眼眶蓦然潮湿了,他用力一点头:“快,分头集合学生!”\n一师操场上,全体师生已集合完毕,子升、开慧、警予、蔡畅等读书会的会员因为在蔡和森寝室讨论,也一起都跑了过来。各班正在清点人数:“报告,本科十班集合完毕,全部到齐。”“报告,本科十五班集合完毕,全部到齐。”“讲习班全部到齐。”“本科六班全部到齐。”……“报告,”周世钊最后一个跑上前,“本科八班集合完毕,缺席二人。王子鹏和毛泽东。”\n孔昭绶看看杨昌济,对方维夏说:“先顾大家,赶紧宣布吧。”\n方维夏点点头,站上了中央的一张椅子,高声说:“同学们,目前的情况,大家可能都听说了,北洋军几千溃兵已经到了南面的猴子石,离我们只有一步之遥,长沙城将面临一场严重的兵祸!为了全校师生的安全,学校决定,全体师生马上撤离,集体到城东阿弥岭暂避兵祸。请大家迅速做好准备,保持秩序,五分钟后,全校出发……”\n“不,不能走!”这个时候,毛泽东风风火火,正跑进操场,全然忘记了自己只是一名学生,打断了方维夏的讲话,“老师,我们不能走!”\n杨昌济和孔昭绶看到毛泽东过来,焦急而责备地问道:“润之?你上哪去了?”\n“猴子石。”毛泽东喘着气,对两位老师说,“刚去的,我已经摸过了溃兵的情况,我认为,现在不是我们逃走的时候!唯今之计,只有主动进攻,方可保住学校,保住长沙城。”\n毛泽东面对老师和同学们,急切而大胆地提出了自己的分析、对策:虽然溃兵有几千人,但人多人少不是关键。傅良佐这个湖南督军,本来就是临时当上的,现在打了这么大的败仗,一路败逃,连傅良佐自己都跑得没影了,扔下这帮手下,群龙无首,完全是溃不成军,不要讲军队应有的士气,根据他刚到猴子石去看到的状况,那些溃兵已经连基本的建制都被打散了,完全就是帮散兵游勇,无头苍蝇,这样的军队,人再多,也不可能有什么战斗力。他们之所以敢来长沙城,就因为手里还有几千条枪。但仔细想想,他们跑来长沙干什么呢?不外乎想趁机抢一把,捞一笔。可是一两个钟头前他们就到了长沙城外,为什么到现在还没进城,还呆在猴子石不敢动?因为他们不知道城里的虚实!傅良佐的这支兵是被护法军里的湘军程潜部击溃后,从湘潭一线,由南往北败往长沙的,护法军的广西桂军呢?则是从湘西经常德,由西往东向长沙进攻,而且前天已经打过了益阳。也就是说,这支溃兵不可能知道从西而来的桂军现在的进展,他们之所以缩在城外不敢动,正是因为按时间来算,桂军完全有可能比他们先到长沙,他们怕的,也正是比他们多出好几倍的桂军在城里等着他们!这种兵败如山倒的军队,真打仗是绝对不敢打的,对他们来说,保命才是第一。但是,如果时间拖下去,城里没有动作,那就等于告诉他们,桂军还没到,长沙是一座空城。到那个时候,他们的胆子就会大起来,就会明白长沙城是他们面前的一盘菜,可以任他们宰割。这帮打了败仗的兵现在已经不是军队,而是强盗了!真要让他们一窝蜂拥进城,几十万人的长沙城,就会马上变成人间地狱!所以,现在最关键的是时间!只有抢在他们摸清虚实之前,打他们个措手不及,长沙城才能得救!\n所有的老师都不由得点了点头。溃兵进城会造成何其严重的后果,大家当然都估计得到。可长沙原来就是傅良佐的地盘,他自己跑了,又没有别的人马守城,这一时半会儿,上哪去找一支军队呢?\n毛泽东望着眼前的同学们,自信地说:“我们一师就有,一师学生志愿军!虽然一师只有两百学生,连一支真枪都没有,可猴子石四面是山,我们完全可以凭借地形,虚张声势,那帮吓破了胆的溃兵不可能摸清我们真正的实力。至于枪,也不是完全没有办法,警察所就有嘛,他们也是长沙的警察,为了长沙城,应该会跟我们一起干。”\n几个老师互相看了看,的确,这个主意虽然有理,但里头包藏的巨大风险实在令大家难以决断。\n毛泽东看出了老师们的犹豫,也看到了操场上同学们的群情激奋,他站到同学们前面,豪迈地说:“校长,诸位先生,我也知道,这样做有风险,可我们一师操练学生军是为了什么?不就是为了培养为国为民流血牺牲的尚武精神吗?事有轻重大小,君子有所不为亦必有所为,比起长沙城三十万老百姓,我们两百人算什么?当此全城民众安危之际,我们不挺身而出,谁挺身而出?各位老师,你们终生教授学生,想培养的,不正是敢于舍生取义、敢于临危向前的堂堂万夫之雄吗?”\n一番话震撼着每一位老师的心灵,也震撼着每一个同学的心灵。\n毛泽东的建议被采纳了,学生军的成员们换上了军装、扛起了木枪,大战将临,整个校园充满了紧张而有条不紊的气氛,每一张年轻的脸,都是那样无所畏惧,带着年轻人兴奋、紧张而又刻意保持的平静。\n几个学生军骨干正与毛泽东在一起,分派学生军的任务。一旁的子升走上前来,问有什么需要他帮忙。毛泽东笑了,他一拍子升的肩膀,刚要开口,却看到满头大汗、长发飘乱的斯咏气喘吁吁地跑来了。看到她这个时候跑来,毛泽东不由得想起了当刘俊卿带着特务来学校搜查“逆书”的时候,斯咏坚决地抱着书坐在他的床上、要和他同进退共存亡的举动,心里一热,对斯咏说:“你来得太好了,正有好多事情要你和警予、开慧做呢。”\n三 # 当蔡和森、张昆弟按照毛泽东的安排来到警察局救助时,最先响应他们的是那个曾经帮他们贴工人夜校招生广告的郭亮。但当郭亮带上枪要和学生们一起出门的时候,警目却一步拦在了众青年警察的前面,命令道:“都给我站住!想去干什么?你知道外头有多少兵?好几千!凭咱们这几十号人,十来条枪,想跟几千人对着干?你活腻了,弟兄们还没活腻呢!都给我把枪放下,听到没有?这是命令!”\n警察们无奈地将十来条步枪统统扔进了枪柜。咔嚓一声,警目把枪柜门锁上了,将钥匙往腰上一挂,拉过椅子,横坐在大门前。青年警察们互相看着,大家显然都窝了一肚子火,却是谁也不敢做声。郭亮又是气愤,又是羞愧,却毫无办法。\n看到蔡和森跟张昆弟无功而返,毛泽东似乎不意外,他沉着地说: “没枪就没枪!没枪,老子变也要变出一堆来!” 他吩咐萧三带人把七八个铁皮洋油桶和十几捆大大小小的鞭炮堆到学校门口,又吩咐罗学瓒收集起同学们扎的火把,准备运往猴子石。\n一旁,子升望着那堆鞭炮、洋油桶,不无担心:“润之,这些东西能管用吗?”\n“管不管用,试试就知道了。”毛泽东揪下一小截鞭炮,点燃,往洋油桶里一扔……\n猴子石的一片晒谷场上,一堆一堆熊熊燃烧的大火旁,军帽、绑腿散落一地,到处是乱糟糟的披着抢来的花花绿绿的被子、棉袄的北洋溃兵,他们正把砸坏的门板、桌椅、箱柜杂物纷纷扔进了燃着的火堆,火上架着瓦罐、铁锅,毛都没拔的死鸡被穿在刺刀上,直接伸进了火中……\n一身小军官打扮的马疤子从烧开的瓦罐里倒出一碗水,优哉游哉,哼着花鼓调子,边喝水边踱着步子,登上了晒谷场旁一块石头,眺望着远处对身边的刘俊卿说:“老二,我说过吧,总有一天,我马疤子还会杀回这长沙城的。”\n“回来又怎么样?都这副德性了,回来还不是丢人现眼?”看来虽然当了兵,但做了小文书的刘俊卿还是以前那副文弱的样子。\n“你错了,老弟。就是这样回来最好!天下大乱,越乱越好,越乱油水越多。”马疤子眺望长沙城,自言自语道,“长沙城啊长沙城,你就等着你马爷来慢慢收拾你吧。”\n晒谷场边的一家民居前,摆了几张桌椅,几个军官正大眼瞪着小眼地商议下一步怎么行动。因为不敢肯定长沙城里到底有没有桂军,在是否立即攻打长沙这个问题上,他们分成了势均力敌的两派,各不相让。其中军衔最高的一个团长最后决定,派两个人先去城里探个虚实。\n当然,这两个人最好就是长沙本地人。于是,马疤子和刘俊卿被毫无争议地选中了。团长掏出怀表看了一眼时间:“给你们两个钟头,快去快回,我们在这儿等消息。”\n马疤子和刘俊卿做梦都没想到,他们会以这样的方式回到长沙!换上从农民家里抢来的布衣短褂和破草帽,他们来到一片混乱的长沙街头时,他们也成了逃难人群里的一分子。正眯缝着眼满街乱串,突然,在一个小巷的岔路口,他们听到几声“枪”响,愣了瞬间之后,马疤子带着刘俊卿就往传来枪声的地点跑去。\n就在这附近,子鹏与秀秀也听到了这几声“枪”响。秀秀迟疑了一下,对子鹏说:“好像是你们学校那边……”子鹏二话不说,拉上秀秀就朝一师方向跑去。\n一师对面的小巷口墙角里,马疤子望着一师门口的学生、鞭炮和洋油桶,阴森森地笑了。马疤子一拍身边的刘俊卿说:“一帮学生崽子,还真他妈敢玩花样。老二,要不是亲眼看见,咱们说不定还真让他们给蒙了。”\n盯着一师熟悉的欧式教学楼,刘俊卿没有作声。马疤子站起身来:“还愣着干什么?回去搬兵吧。”\n刘俊卿心不在焉地问:“你真的想回去报告?”\n“那当然了,不然我们来干嘛?”\n“可是,这几千人真要进了长沙,长沙城就完了。他们是北方兵,咱们可都是长沙人啊。”刘俊卿还在迟疑着。\n“哟,看不出你还长出良心来了?本乡本土的,下不了手了?”马疤子挖苦刘俊卿道,“你他妈有病啊?你当你还是长沙人,长沙有谁把你当过人呀?你可别忘了,就是这座长沙城,就是这些长沙人,逼得你刘俊卿和我马疤子走投无路,才滚出城吃粮当的兵!你跟他们讲客气,谁跟你讲客气?啊?不信是吧?不信你摘了帽子走出去试试,你看看你那些老同学有哪一个会不把你当成一条狗?一条狗!”\n往昔的屈辱、仇恨蓦然充满了刘俊卿的眼睛,盯着一师,盯着门前的旧同学,他腾地站了起来:“走,回猴子石。”\n马疤子一拍他的肩膀:“这他妈才对了!等长沙城血流成了河,那才是你我的天下!”\n两个人转身向巷子里拐去,迎面,却正看到子鹏和秀秀贴着墙站在角落里。望着刘俊卿与马疤子,子鹏与秀秀带着巨大的、仿佛不敢相信的恐惧退缩着。显然他们刚才听见了马疤子和刘俊卿的对话。刷的一声,马疤子拔出了腰间的匕首,目光中杀气顿起!只犹豫了一秒钟,子鹏猛地将秀秀往身后一推,边拔出了那柄餐刀边高声喊叫:“快来人啊!抓坏人啊!”\n子鹏的呼救声在小巷子里回荡,一直传到了巷子对面……\n马疤子一手抓住了子鹏持刀的手腕,用力往墙上一撞,挥刀就刺。子鹏的餐刀落在地上,他拼命托住马疤子的手,但力不从心,马疤子的刀一点点向他的胸口压了下来。秀秀疯了似的扑上来,抱住了马疤子的手,拼命往上扳,合二人之力,马疤子的刀刺不下去了。\n“老二,你他妈愣着干嘛,还不快动手!”马疤子回头对刘俊卿叫道。\n秀秀不顾一切地用身体护着子鹏,也对刘俊卿叫道:“哥,不要啊!”\n刘俊卿几乎是下意识地捡起了那柄餐刀。然而,迎着子鹏与秀秀的目光,刘俊卿举着刀的手却剧烈地颤抖着,怎么也刺不下去。\n“不许动……别动……”随着一片怒吼,毛泽东等几十名学生军抄着木枪,冲进了小巷!\n当啷两声,马疤子与刘俊卿手中的刀颓然跌落在地……\n回到学校,子鹏换上学生军军装,想和毛泽东他们一起去打仗。可毛泽东却一指被反绑了双手蹲在地上的马疤子和刘俊卿,给了他一个同样艰巨的任务:“把这两个俘虏押到学友会办公室,由你负责看管。”\n看到子鹏失望的样子,蔡和森告诉他:“俘虏也要人看嘛,这也是重要任务,要让他们跑了,我们那边的戏可就没法唱了。”\n子鹏这才高兴地接受了任务。\n四 # 黄昏初起的薄暮中,猴子石的晒谷场上,散乱的满地溃兵东一支西一支点燃了火把,在火把忽闪忽闪的映照下,团长皱着眉头、吃力地辨认着怀表上的时间,嘴里不干不净地骂道:“妈拉个巴子,这两个混蛋,到现在还没消息,搞什么名堂?”\n一个上年纪的军官斜着眼睛说:“不会是给桂军抓去了吧?”\n另一个年轻军官骂道:“你他妈哪只眼睛看见桂军了?这么久了,鬼影子都没晃出来一个,要我说,现在就进城,冲进去,什么都有了!”\n“人不能拿命开玩笑。”上年纪的军官看来要稳重一些。\n“屁!真他妈有几万桂军在城里,能到现在一声枪响都听不见?你以为他桂军拿的是烧火棍啊?”年轻军官伸手拔出手枪,冲附近的散兵们吆喝着,“弟兄们,够胆儿的都给我起来,进城发财去!”\n不等四周的兵站直,仿佛是为了嘲笑他的嚣张,“砰”的一声“枪”响骤然传来,几个军官吓得全身一弹,随即便听到晒谷场三面的山坡上枪声阵阵。“我们被包围了!我们被包围了!”晒谷场上顿时风声鹤唳、乱成一团。\n此时在晒谷场东面的山头上,张昆弟、陈绍休正指挥着一部分学生军,将一串点燃的鞭炮扔进洋油桶;在晒谷场西面的山头上,罗学瓒、李维汉带领的学生军放的鞭炮声同样热烈;而在晒谷场北面的小山坡后,萧三点着了捆成一团的十来颗大雷鸣炮,倒转洋油桶盖住,一屁股坐在桶上,只听轰的一声闷响,一旁的蔡和森配合着他制造的“炮”声,点燃了从鞭炮里拆出的一堆火药,一大团硝烟腾空而起—— “枪”声骤停。\n毛泽东对着土喇叭喊道:“傅良佐部的官兵听着,我们是桂军。”\n张昆弟、陈绍休指挥着学生军喊道:“你们被包围了。”\n罗学瓒、李维汉等指挥的学生军喊道:“缴枪活命,赶紧投降!”\n喊声中, 斯咏、警予、开慧、蔡畅点燃了一支支火把,火把不断传递到男生们手上。一时间,漫山遍野,四面八方,喊杀四起,互相呼应。群山回荡,喊杀声与回音层层重叠,回旋不绝,四面看去,暮色中,但见点点火光逐渐亮成了一片,一时间,数千溃兵仿佛陷入了千军万马的重重包围之中!\n“团长,怎么办,怎么办啊……”趴在破墙后,几个军官慌成了一团。\n“奶奶的,还能怎么办?冲出去!”那个年轻军官拔出手枪。\n团长制止他说:“你他妈没长耳朵啊?听听,到处都是他们的人,往哪冲?想让弟兄们送死啊?”\n“不行……不行就投降吧?这好汉他不吃眼前亏嘛。”上年纪的军官提议说。\n团长觉得这主意不错,探出头来,扯着嗓子朝对面山上吆喝:“对面的桂军弟兄,别开枪,有事好商量。”\n对面山坡后,影影绰绰的火光、人影中,传来了毛泽东的声音:“只要你们缴枪投降,什么都好商量。”\n团长狡猾地要求:“口说无凭,你们得派代表过来谈判,当面答应我们的要求,不然弟兄们不放心。”\n两支火把熊熊燃烧,照亮了山坡前的路,也照亮了三个年轻的身影:两袭长衫是飘逸的毛泽东与萧子升,一身学生装,是沉静的蔡和森。一到晒谷场,几十支枪口呼啦一下,就对准了他们。毛泽东的脸上,浮起了一丝嘲讽的微笑,仿佛在耻笑对方的小题大做。\n“干什么干什么?都他妈把枪放下!”团长眼睛一瞪,换上笑容,抱拳迎了上来打招呼,“几位,有劳有劳。”\n毛泽东大咧咧地瞟了他一眼,问:“你是谁?”\n“兄弟傅良佐督军麾下王汝南师第三团团长,哦,这儿也就兄弟军衔最高,几位是……”\n子升一一介绍着:“这位是桂军谭浩明司令麾下毛副官,这位是长沙市政府蔡秘书,在下姓萧,是长沙商会的代表。受谭司令和长沙各界委托,我们三人负责今天的谈判。”\n“欢迎,欢迎。”团长一脸夸张的笑容,热烈地三人握着手,眼睛却不住地上下打量着三个实在过于年轻的对手。\n其他几个军官的目光也都集中毛泽东的身上,他的年轻与一身便装首先已令他们露出了一丝怀疑之色。那个五大三粗的年轻军官看了同伴们一眼,上前一步,一把握住毛泽东的手:“谭司令的副官好年轻啊。”\n毛泽东淡淡地:“傅督军的手下也不老嘛。”\n年轻军官笑了,手上却突然一紧,狠狠捏了下去。毛泽东微笑着,同样加了把劲。两张笑脸下,两只手无声地、却是狠狠地较开了劲,那个年轻军官的笑容突然僵住,他似乎想极力撑住,但手上巨大的疼痛却令他忍不住嘴角直抽,整张笑脸一阵扭曲,变得滑稽可笑。几个军官盯着二人的较量,原本带着的几分轻视不由得一扫而光。占尽了上风的毛泽东慢慢松开了手,那名年轻军官如蒙大赦,捂着手倒退出了好几步。\n团长赶紧打着圆场:“几位,别站在这儿啊,那边请,那边请。”\n坐在晒谷场的民居前,双方谈判正式开始。\n“局势摆在眼前,”方木桌前,蔡和森正侃侃而谈,“贵部如今已被团团包围,真要打,结局如何,团座及列位心里想必都有数。但不论战局如何,我长沙各界只有一个心愿,不希望看到仗在长沙城边打起来,殃及我千年古城之无辜官民等。因此,只要贵部能深明大义,化干戈为玉帛,护法军谭司令保证,决不伤贵部兄弟一人。毛副官,是这个意思吧。”\n毛泽东端起了桌上的粗瓷茶碗,看也不看对面的军官们:“缴枪活命,就是这句话。”\n蔡和森继续说:“只要贵部放下武器,尽可自由回乡,一切资遣靡费,均由我长沙商界承担。这一点,商会的萧代表也可以保证。”\n子升点点头,慢口答应:“只要不在长沙打仗,钱的事,都好说,都好说。”\n团长与几个军官互相看了看,回话道:“这其他的嘛,也还好说,就是这缴枪,没有必要吧?要不这样,桂军先撤围,放弟兄们一条活路,我们保证掉头就走,绝不回头再踏进长沙一步,好不好?”\n“双方各撤一步?亏团座想得出来啊。”毛泽东哈哈大笑。\n团长小心翼翼地问:“那毛副官的意思是?”\n子升赶紧向毛泽东使眼色,但毛泽东全不理睬,反而更加趾高气扬:“我军两万弟兄已将你们重重包围,占尽天时地利,能来跟你谈判,就是给你们面子。留枪不留人,留人不留枪,你们自己看着办!”\n那个年轻军官先嚷了起来:“要是我们不交枪呢?”\n“乒”的一声,毛泽东把喝干了水的瓷碗一放:“你试试!”\n上年纪的军官赶紧打圆场:“谈判嘛,谈判嘛,何必动怒,何必动怒呢?都坐,都坐。”\n他提起桌上的农家粗瓷茶壶,殷勤地给毛泽东续着水。毛泽东昂着头,四平八稳坐下了。团长向几个军官一使眼色,几个人随他退往一边角落。\n子升再也忍不住了,他站起身,笑着:“毛副官,蔡秘书,借一步说话好吗?”\n他们三人也起身,退向另一边角落。看看四周,子升压低了声音:“润之,你这干什么?他们能退出长沙,问题不就解决了吗?何必非逼他们缴枪?”\n“你怎么这么糊涂?两万人围住三千人,倒平白无故放他们把枪带走,这可能吗?傻瓜也不会信啊。这种时候,你就得压他一头。真要让他们带走枪,走不出几里路他们肯定会明白,我们这边是个空架子,所以不敢拿他们怎么样。到时候一个回马枪,那才是真的收不了场!”\n民居另一边角落里,那个年轻军官一脸的不服气:“要我说,这枪不能交,缴了枪,咱们弟兄还能剩什么?那不成了人家板上的肉?”\n“咱们现在已经是板上的肉了。换了是你是我,三千条枪摆在眼前,能不要吗?他要真不要,那倒是不对头了。”上年纪的军官分析得头头是道。\n团长一锤定音:“没错,他要真放咱们带枪走,就证明来的桂军不多,可能只是先头部队,他们是在吓唬人。要真是一步也不退,非全交枪不可,那才证明人家一口就能吞了咱们。真要那样,咱们也只有交枪保命了。”\n重新回到谈判桌前,火把依然通明,映照着相对而坐的两方代表。团长试探着问:“弟兄们商量的意思,留下三百条枪,就当给桂军的见面礼,你看怎么样?”\n毛泽东看也不看他,强硬地回答:“不行!”\n“那,五百条?”\n“不行!”\n“我再退一步,带一半,留一半,这总可以了吧?”\n“谭司令的命令,一颗子弹也不能留。这事,不用再商量了!”\n“毛副官这样说,那就不好谈了。”团长说着,向几个军官使了个眼色。\n年轻军官会意,头一个横起了眼睛:“带一半,留一半,最多这样!”\n上年纪的军官:“对对对,弟兄们总还要防防身吧?”\n另一个军官口气更凶:“老子本来就不想交,妈的,了不起一拍两散!”\n毛泽东:“是吗?”\n“没错,怎么样?”\n蔡和森向子升使了个眼色:“要是枪的事一时不好谈,那就先谈谈遣散费的事吧?”\n子升马上接口:“我们商会的意思,只要贵部弟兄离开长沙,路费嘛,士兵每人七块大洋,班排长以上,每人二十块,连长五十,各位觉得如何?”\n上了年纪的军官脱口问道:“那我们营长呢?”话刚出口,他已经意识到自己失言,看看周围几个军官责怪的眼神,他尴尬地坐了下去。\n蔡和森与子升换了个心照不宣的眼神,子升居高临下地回答:“营长一百,团长二百。”\n几个军官互相看了一眼,事到如今,大家显然都无心再强撑下去了。\n团长长叹了口气:“毛副官,弟兄们要真交了枪,谭司令可要保证绝不为难咱们啊。”\n带着胜利的姿态,毛泽东昂起了头,大模大样地命令道:“开始吧。”\n“是,是。”团长转向士兵们,高声宣布,“都给我听着,把枪放到这边,放完枪,退后一百米外,等候命令!第一排,出列。”\n第一排士兵乱糟糟上前,把几十支步枪和子弹、刺刀等扔在了地上。熊熊火堆,映照着一排排交枪的士兵脚步……\n悄悄地,子升舒了一口长气。毛泽东泰然自若,端起茶碗喝着水。\n五 # 长沙城里,入夜后的整条整条大街上,全是惊慌的人群,有人被挤倒,亲人拼命地拦着,仍挡不住混乱的脚步践踏。大人叫,孩子哭,乱作一团。混乱声、哭喊声传入警察所,几个青年警察面露愧色,郭亮更是来回焦躁地走动着。终于忍不住了,他一步冲到警目面前,叫道:“长官,长官!你听听,外面现在乱成什么样了?全长沙城的老百姓都在逃命,街上都乱成了一锅粥!多少人都在等着我们这些警察保护?可我们呢,还干坐在这儿!我们是警察,是警察啊!长官!民国的警察条例是怎么写的,你自己平时是怎么要求我们的?警察就要为民当差,警察就要保护民众!现在是谁在保护民众?不是我们,是第一师范的那些手无寸铁的学生!”\n“不要再说了!”警目一拳砸在桌子上!仿佛是为了平静或者是掩饰一下心情,他掏出一支烟,然而,划火柴的手却不住地颤抖着,接连几下,也没能划燃,此刻,警目的心情,显然也在受着剧烈的煎熬。响声中,警目刚刚划燃火柴的手一顿,反烫着了自己。\n郭亮似乎豁出去了,他猛地站了起来,三两下解开警服上面的扣子,一把将警服脱下、将警帽被狠狠摔在桌上。\n“长官,这个警察,我不干了!”郭亮转身冲着警察们,“弟兄们,外面,是我们长沙城的父老乡亲,是跟我们的父母、我们的兄弟姐妹一样的长沙老百姓!为了长沙城,为了我们的父老乡亲,是条汉子的,跟我走!”\n几个青年警察一齐站了起来,纷纷脱了警服,跟着郭亮就要往外冲。\n“都给我站住!”警目猛地站了起来,微微停了一停,把一串钥匙扔在桌上说,“想空着手去送死吗?枪柜里有十条枪,有枪的,上猴子石,去帮学生军,没枪的,全体上街,维持秩序!”\n“弟兄们,走!”脚步匆匆,郭亮带着九名扛枪的警察奔向猴子石。\n而在他们前面,还有一个人也正连滚带爬地往猴子石跑,这个人就是才从一师逃跑出来的马疤子。\n一个多小时之前,在学友会事务室里,子鹏原本很严密地监视着被反绑着双手的马疤子和刘俊卿,他们两个人正席地坐在墙角。\n秀秀看到刘俊卿又饥又渴的样子,就从外面端着一碗水拿了两只麦饼进来,看看刘俊卿反绑的双手,她犹豫了一下,还是蹲了下来,将水碗送到了刘俊卿嘴边。兄妹二人的目光微一接触,秀秀转开了目光。子鹏看到了,心里酸酸的,想起以前和刘俊卿同学的日子,动情地说:“俊卿,喝点吧。”\n犹豫了一下,刘俊卿凑上去,一口一口喝起了水。马疤子看见了,也想喝,被子鹏打了一枪托之后,才安分了。\n放下水碗,秀秀又拿起了一块麦饼,递到刘俊卿嘴边。刘俊卿却摇了摇头,目光一直盯着妹妹。秀秀站起身,正要走,身后突然传来了刘俊卿的声音:“阿秀,你脸上、手上是怎么了?是不是王家打你了,啊?”\n秀秀的身子猛地一震,她这才明白刘俊卿刚才不吃麦饼,是因为看见了她头上、手上那些早已褪得很淡的伤痕。一刹那,眼泪蓦然一下渗出了她的眼眶。她突然转身,在刘俊卿身边蹲下了:“哥,我没事,我……我有件事要告诉你。”\n她突然带起了羞涩的样子让刘俊卿有些奇怪:“什么事啊?”\n“我和子鹏……我和子鹏要在一起。”\n刘俊卿一下没听明白:“你和子鹏?”\n因为害羞、也因为不知道怎么才能说清楚,秀秀犹豫着、羞怯地看了看子鹏。子鹏对秀秀笑笑,鼓起勇气对刘俊卿说:“我要娶阿秀,我跟阿秀说好了,我们要结婚。”\n“哥,你……你同意吗?”秀秀望着刘俊卿,眼神里充满了渴望。\n“我同意吗?”刘俊卿愣了一下,妹妹的话让真切地感觉到他们兄妹间似乎又回到了很久很久之前,那时候多美好啊,妹妹无论遇到了什么事情都要哥哥拿主意。他醒悟过来,一直拒绝承认他的秀秀此刻是在把他当成唯一的亲人,当成家长,请求他对婚事的支持!巨大的激动、巨大的喜悦骤然冲击着刘俊卿的心,一刹那,他激动得全身都禁不住在发抖,狠狠地、狠狠地点着头,连声音都哽咽了:“阿秀,我同意,我同意,我同意!”\n刘俊卿说着,一头埋进了秀秀怀里,泣不成声。望着这一幕,子鹏的泪也忍不住了。而一旁的马疤子却狠狠地往地上啐了一口,唾沫落地的时候,马疤子看到了那只放在地上的瓷碗。\n“哥是个混蛋,是个人渣!”刘俊卿剧烈地抽泣着,“哥不配,不配你叫一声哥……”\n秀秀为刘俊卿擦着满脸的泪水:“哥,别这样,我知道,我知道你其实一直在后悔,知道你一直想为我好。哥,以前的事,都过去了,不管做过什么,你可以重新再来,我要你重新再来。”\n“不,我不行,我没有机会的……”刘俊卿使劲地摇着头。\n“俊卿,你有机会,我可以去求我姨父,求他原谅上次的事,求他不再追究你,他会答应我的。只要你肯改,就没有什么是不能弥补,没有什么是不能回头的。”\n“哥,子鹏说得对,你那么聪明,那么会读书,只要你肯改,有什么做不到?到时候,我和子鹏来想办法,想办法供你上学,供你重新读书,好不好?你以前不是说过吗,你天生就是读书的人,你还那么年轻,又那么聪明,会有好多好多学校抢着要你,你会读出出息的。”\n“阿秀……”刘俊卿再次泣不成声。\n“哭哭哭,哭什么哭?烦死了!”马疤子突然一脚扫来,“砰”的一声,那只放在地上的瓷碗被他踢得猛撞在墙上,四分五裂!\n“你干什么?”子鹏捅了他一枪:“老实点!往后退!”\n“不干什么,听他们哭得烦!”往后挪着的马疤子,一条大腿下,悄悄压住了一片尖锐的瓷片。\n秀秀将瓷片一片片捡了起来,捧着碎瓷片刚要走,子鹏突然想到了什么,拿起瓷片,拼凑起来,发现拼起的瓷碗缺了一片。看看地上,并没有其他瓷片的影子,子鹏的目光落在了马疤子身上:“你,手上拿了什么?”\n“啊?没什么呀?”正在用瓷片割绳子的马疤子吓得一愣。\n子鹏抄起了木枪边往马疤子面前走边说:“你转过来。”\n“我真没拿什么……”\n“我叫你转过来!”\n眼看马疤子的小动作就要无处可藏,门外突然传来了一阵喊叫声。子鹏与秀秀同时大吃一惊,回头去看,冲进门来的,果然是气喘吁吁的王老板夫妇!\n“哎哟子鹏啊,你可让妈好找啊你……”王夫人身子一软,差点没一屁股坐在地上,“我和你爸,是城东找到城西,可就没想到你这时候还敢往城南边跑,你怎么这么不要命啊我的傻儿子?子鹏,走吧,妈求你了,别管那么多,赶紧跟妈逃命啊。”\n子鹏很坚决地说:“我真的不能走!我们在保卫长沙城,你们知不知道?”\n“长沙城是你保得住的吗?”王老板火了,冲上来把子鹏手里的木枪一把抢下,往桌边一搁,拉着他就要走,“凭你们几个学生,就想挡住人家几千兵?你疯了你?跟我走!”\n“爸!”子鹏一把甩开了父亲,“我不!”\n“老爷,太太,你们就别劝了,子鹏真的不能走呀。”\n秀秀想帮子鹏解释,可王夫人一把推开她,骂道:“你少啰嗦!都是你这狐狸精!你给我滚开!”\n“妈!”看到妈妈一把将秀秀推得倒退了好几步,子鹏心痛了,他拦在秀秀前面,对王夫人说,“我不准你碰阿秀!”\n“好哇好哇,为了个狐狸精,你连妈都不要了?子鹏,妈和爸连命都不顾了,跑来找你,你怎么就不明白呢?”\n角落边,趁着子鹏他们争吵,马疤子的手,拼命地用瓷片割着绳子,绳子已经被割断大半了。\n王老板逼上前来,面如严霜地问子鹏:“你到底走不走!”\n子鹏一摇头。\n“啪。”一个耳光重重打在子鹏脸上,王老板又问:“走不走?”\n子鹏一个踉跄,那支靠在桌边的木枪被他身子一撞,向地上倒去,就在这时,马疤子猛地挣断了绳子,伸手接住了木枪,砸了过来。\n“子鹏!”秀秀吓得一声惊呼,猛扑上来护住子鹏,木枪重重砸在她肩头,将她打翻在地!\n“阿秀!”子鹏刚要去抱秀秀,木枪横扫,将他也打倒在地,他腰间那柄餐刀飞出,正好滑到马疤子面前,马疤子捡起刀,向王老板夫妇扑去。\n“救命啊……”\n呼救与打斗声猝然响起在一师的上空。\n马疤子用刀划伤了护着老婆的王老板,王老板抡着板凳倒退着,马疤子一脚踢飞了板凳,挥刀刺来!\n“爸!”\n“老爷!”\n子鹏和秀秀拼命扑上来,同时抱住了马疤子 “爸,妈,快跑啊,快跑啊!”\n马疤子一把掀翻秀秀,一刀扎在子鹏抱紧秀秀的胳膊上。\n“儿子!”王老板大叫着拼命扑上前要救子鹏,却被马疤子当胸一脚,踢得闷倒在地。\n抡起刀,马疤子就要刺向子鹏,秀秀惊叫着又扑上来,双手紧紧抓住了刀刃,血,一下子顺着刀流了下来。\n她一口咬在马疤子手上。\n“臭丫头,我宰了你!”负痛之下,马疤子暴怒地踢倒秀秀,他高举起刀,向秀秀扎下来。\n“阿秀!”\n说时迟,那时快,一声大吼中,一个身子猛地扑在秀秀身上,刀深深扎进了他的胸膛!\n那是还被反绑着双手的刘俊卿!狂吼着,刘俊卿疯了般向马疤子顶去,还握着刀柄的马疤子拼命向前刺,却被他用胸膛顶着踉跄倒退,直退出房门,一跤摔倒。带着那柄直没至柄的刀,刘俊卿屹立在门口,仿佛一尊浴血的门神!\n一阵脚步声中,众多老师远远向这边跑来,打头的饶伯斯等人还拎着西洋剑、球棍等。马疤子一看情形不妙,爬起来撒腿就跑,老师们围上前来,他已纵身翻过了围墙。\n直到这一刻,站在门口刘俊卿才仿佛耗尽了最后的生命力,突然仰面朝天,倒了下去!\n寂静的校园里顿时响起秀秀撕心裂肺的声音:“哥!”\n六 # 晒谷场,毛泽东他们正在顺利地收缴武器,突然,马疤子狂叫着向这边奔来:“他们是假的!别上当,他们是假的!”\n这个变故实在来得太突然,太致命了,子升的身子一震,蔡和森也禁不住眉心狂跳,山坡后,所有人的心更是猛然间悬了起来!\n“糟糕!”萧三提枪就要追,却被周世钊一把拉住了,他这才想起手里拿的是不能见人的假枪,不由得狠狠一跺脚。\n瞟了一眼跑进火把光照范围的马疤子,毛蔡萧三人显然都认出了他。子升禁不住与蔡和森紧张地对视了一眼。毛泽东的手,也不禁微微一颤,茶碗里的茶溅出少许。趁着众人的目光集中在马疤子身上,他手臂轻轻一带,用衣袖擦去了桌上的茶。\n“团座,他们……他们是假的!他们不是桂军!”狠狠擦了一把汗,马疤子气喘吁吁地叫道, “狗屁桂军!城里……城里他妈一个广西兵都没有,我亲眼看见的,全城的人都在逃难!长沙城根本就没兵!”\n团长将信将疑地指着毛泽东问:“那他们是什么人?”\n“他们是湖南第一师范的学生!”马疤子盯着毛泽东三人,恶狠狠地说, “团座,我说的是真的,他们真的是学生,就他妈一两百个人,我在他们学校门口亲眼看见的,他们连一支枪都没有啊!全他妈一堆洋油桶子里放鞭炮,吓唬人的!”\n“他奶奶的,玩老子?”年轻军官噌地拔出了手枪。\n团长一把按住了他的手:“先等会儿!”\n他看看马疤子,再看看毛蔡萧三人:马疤子的模样狼狈不堪,毛蔡萧三人的神情却都看不出一点慌乱。\n他当然不知道,保持着镇定的子升的脖子后,火光映照下,冷汗其实已经打湿了衣领,他下垂的衣袖正在不自觉地微微抖动着。但稳稳地,蔡和森悄悄握住了他的手。毛泽东却笑了,好像看到了一场无比有趣的滑稽戏,正等待着对方往下演。\n团长一时明显举棋不定:“马排长,刘文书呢?你不是和他一块儿去的吗?怎么没看见他人?”\n“那小子反了水了,我把他宰了!”\n“那你怎么去了这么久?”\n“我不是……不小心给这帮学生逮住了,我想了不少办法才逃出来的。团座,我真的没骗你,这四周真没有桂军,都是学生。团座,咱们三千弟兄,不能让他们二百来个学生给吓住了啊!”\n“哈哈……”毛泽东猛然爆发出一阵仰天大笑!\n马疤子:“你……你笑什么笑?你他妈就是学生,第一师范的,我见过你!”\n毛泽东抚掌大笑:“有意思,有意思有意思。团座,你这位弟兄是不是跟你有仇啊?要是没仇,怎么这么想害死这三千兄弟,啊?”\n几个军官的目光从毛泽东望到马疤子,再从马疤子望到毛泽东:按理马疤子不会骗他们,可毛泽东的样子又实在自信得几乎不容怀疑,让他们一时都糊涂了。\n看看几个军官迟疑的表情,马疤子狠狠地一咬牙:“好,团长,营长,你们都不信,都不肯信自家兄弟是吧?我有办法让你们信!”他一把从旁边一个兵手里抢过火把,转身冲前几步,面向小山坡,一拍胸脯:“对面第一师范的学生崽子们,给我马爷听着,你们他妈不是桂军吗?不是他妈机枪大炮吗?来呀,有种往这儿打!只要你们有一杆真枪,有一颗子弹,就往爷这儿打……”\n小山坡上,所有的人都急得不知如何是好,眼看马疤子如此嚣张,众人却偏是一点办法都没有。萧三急得一把扔掉了手里不顶事的假枪!斯咏、警予、开慧、蔡畅,四个女生紧张得紧紧拥抱在了一起。\n马疤子还在那里叫嚣:“你们打呀,打呀!怎么了,马爷送给你们打,怎么不敢打?刚才不是还机枪大炮满天响吗?这会儿怎么不响了?是没枪?还是没子弹?还是又没枪又没子弹?露馅了吧,一帮学生崽子们!”\n“毛副官?哼哼,戏演得不错嘛。”望着毛泽东,团长的眼睛狠狠地眯了起来,慢慢掏出了手枪,枪口猛地对准了毛泽东的脑门。枪口的准星里,毛泽东连眼睛都没往枪这边瞄一瞄,却不紧不慢地提起茶壶,给自己喝空了的茶碗里续起水来。\n子升、蔡和森的手猛然一紧。\n这时候,在山坡后,另一个枪口,另一个准星,瞄准的居然是马疤子。稍一犹豫,枪口突然又从马疤子身上移开,向团长的方向转去……砰!枪声骤响,夜空都仿佛为之一颤!随着枪声,团长头上帽子骤然飞起,吓得他猛一缩脖子!几个军官同样吓得一抖!连蔡和森和子升都是猛的一震!茶碗里的水刚好加满,一滴也没溢出——毛泽东稳稳地放下茶壶,一伸手,正好接住了团长落下的军帽,军帽上留着一个枪眼!\n军帽递到了面无人色的团长面前,毛泽东微笑着说:“兄弟治军不严,手下弟兄不小心走了火,让团座受惊了,真是对不住。”\n接过帽子,团长凶狠狠的目光突然转向了马疤子。马疤子显然也被弄糊涂了,但眼前的危险他却马上醒悟过来:“团座,不,我没骗你,我真的没骗你,不,团座,不要――”\n“砰。”一颗子弹正中马疤子脑门,他一头栽倒在地。\n“奶奶的,差点被你这狗娘养的害死!”团长又是砰砰几枪,打在早已毙命的马疤子身上。转过身,他擦了一把冷汗,将手枪捧到了毛泽东面前:“毛副官,我交枪!”\n哗啦一阵,溃兵们手中的枪纷纷落地。\n欢呼声中,众多学生军四面八方涌上前来。木枪被扔了一地,一双双手,抄起了地上堆放的真枪。胜利的欢呼声响成了一片!远远蹲成一片的溃兵们都给弄糊涂了,一双双眼睛都落在了学生军胳膊上“第一师范学生志愿军”的袖标和满地的木头假枪、洋油桶子、鞭炮碎屑上,几个军官全傻眼了。\n团长望着毛泽东,问:“你……你到底是什么人?”\n“第一师范本科八班学生,毛泽东。”\n团长双腿一软,蹲在了地上,狠狠一捶脑袋!\n毛泽东转过身,正与提枪而来的郭亮相遇在一起,两个人的手紧紧地握在了一起。\n夜幕下的一师,孔昭绶已经接到了猴子石传来的捷报,他打开《第一师范校志》,奋笔如飞地记载下了这次事件。意犹未尽,他又郑重地落下了这样一句话:“全校师生皆曰:毛泽东通身是胆。”\n七 # 王子鹏和阿秀结婚了,王家和陶家的婚书被如愿退给了斯咏,陶会长在经历了绑架案和猴子石一役之后,对毛泽东有了新的看法,不再认为那个穷师范生配不上他的女儿,反觉得他非同凡响,他的将来也绝非常人所能预测,希望女儿能跟这样惊世骇俗的人物共度一生。但斯咏却怅然地对父亲说: “那个曾经的、虚幻的梦,早已经醒来,我已经想得很清楚了:今生今世,我只会把毛泽东当成最好的朋友。”\n1918年4月14日,毛泽东还有他们共同的朋友蔡和森、何叔衡、萧子升、萧植蕃、罗章龙、张昆弟等在长沙溁湾镇的刘家台子蔡和森家里,发起成立了湖南近代史上最重要的进步青年团体——以“革新学术,砥砺品行,改良人心风俗”为宗旨的新民学会。\n1918年6月,毛泽东自湖南第一师范本科第八班毕业。\n同月,杨昌济赴北京大学担任伦理学教授。1920年1月,杨昌济因病逝世于北京,女儿杨开慧与学生毛泽东在病榻前陪伴他走过了人生最后的旅程。临终前,他还在向广州军政府秘书长章士钊写信推荐自己心爱的两名学生:毛泽东与蔡和森。信中说:“二子海内人才……君不言救国则已,言救中国,必先重此二子。”\n亦是同月,孔昭绶辞去第一师范校长职务,后投身军界,出任国民政府少将参议等职,1929年病逝于长沙。\n第一师范学监主任方维夏于1924年加入中国共产党,曾参加北伐战争与南昌起义,历任中华苏维埃共和国总务厅长,江西、湘赣省教育部长、裁判部长等职。红军长征后,奉命留守苏区。1936年,在艰苦卓绝的游击战中,遭叛徒出卖,牺牲于湖南桂东县。\n教育实习主任徐特立于1927年加入中国共产党,曾参加南昌起义,是长征中年龄最大的红军战士,历任中华苏维埃共和国、中华人民共和国教育部代部长、中共中央宣传部副部长等职,1968年病逝于北京。\n国文教师袁吉六20年代初曾出任湖南省教育司司长,并长期任教于长沙各学校,1936年病逝于湖南隆回县。中华人民共和国成立后,毛泽东曾长期照顾他的遗孀戴长贞女士。\n国文教师易培基20年代初曾出任第一师范校长,后曾担任国民政府农矿部长、故宫博物院院长等职。\n饶伯斯、费尔廉、黄澍涛、雷明亮、王立庵等第一师范其他教师后均长期从事教育工作。\n毛泽东于1920年起,被第一师范返聘为教师,先后担任一师附属小学主事(校长)、本科第二十二班班主任兼国文教师,任教一年半后辞去教职,成为职业革命家。\n杨开慧与毛泽东于1920年底在长沙结婚,育有三子。1921年,杨开慧加入中国共产党,成为中共历史上第二位女性党员。1930年,杨开慧在长沙被湖南军阀何键逮捕,遍历酷刑,坚贞不屈,因拒绝以宣布与毛泽东脱离关系为条件换取自由,同年10月14日,牺牲于长沙识字岭刑场,英年28岁。\n陶斯咏后长期致力于中国妇女教育,任教于长沙、上海等地,成为著名的女性教育家,曾培养了作家丁玲等大批优秀女性学生。1932年,陶斯咏因病早逝于长沙,享年37岁。终生未婚。\n蔡和森与向警予于1920年在法国结婚,二人后均成为早期中国共产党重要领袖。\n向警予曾任中共二、三、四大代表,中共中央妇女部长等职,是中国共产党历史上第一位女性中央委员。1928年,向警予在武汉组织工人运动时遭叛徒出卖被捕,5月1日,牺牲于汉口刑场,英年33岁。\n蔡和森曾是中国共产主义运动先驱者之一,历任中共二、三、四、五、六大代表,中央政治局委员,中共中央宣传部长等职,1931年,蔡和森在组织广东特委地下工作时,遭叛徒出卖被捕,坚贞不屈,约于6月中旬牺牲于广州军政监狱酷刑之下,英年36岁。\n何叔衡后成为中国共产党创始人之一,中共一大代表,历任中央苏维埃监察部长、内务部长、最高法庭主席等职。红军长征后,奉命留守苏区。1935年2月,在福建长汀的游击战中遇敌埋伏,突围失败,因不愿被俘,跳下悬崖壮烈牺牲,享年59岁。\n萧子升20世纪30年代曾出任国民政府农矿部次长、故宫博物院监守等职,后离职长期以学者身份旅居国外,任教于各大学。1979年病逝于南美乌拉圭。\n张昆弟于1922年加入中国共产党,曾任红五军团政治部主任,与贺龙等一道创建湘鄂西革命根据地。1930年在洪湖地区牺牲于“左”倾肃反运动中。\n罗学瓒于1922年加入中国共产党,曾任中共浙江省委书记,1930年牺牲于杭州。\n陈章甫于1921年加入中国共产党,曾任中共醴陵县委书记,1930年牺牲于长沙。\n郭亮于1921年加入中国共产党,成为著名工人运动领袖,曾任中共湖南、湖北省委书记,1928年3月牺牲于长沙。\n蔡畅后成为职业女革命家,长期担任党和国家领导职务,曾任中共中央委员,中华人民共和国全国人大副委员长,全国妇联主席,1990年病逝于北京。\n李维汉后成为职业革命家,长期担任党和国家领导职务,曾任中共中央政治局常委,中央组织部长、统战部长、中华人民共和国全国人大副委员长等职,1984年病逝于北京。\n萧三后成为著名作家、翻译家。\n罗章龙于1921年加入中国共产党,曾任早期中国共产党中央委员,后长期任教于各大学,成为著名经济学家。\n周世钊后曾长期担任第一师范校长,中华人民共和国成立后,曾担任湖南省副省长等职。后记后记\n记得四年前,我应湖南电视台的要求,创作电视连续剧《恰同学少年》剧本的时候,有不少熟人、朋友曾问起我在写什么,一听说是“毛泽东在第一师范上学的故事”,每一个人都摇头:“这能有什么意思?”——真的,没有一个人看好它。\n不用说别人,包括我自己,同样不敢看好这部剧的市场反响,因为剧本出自我笔下,我知道它太过严肃,没有搞笑、戏说、三角恋、婚外情、宫廷阴谋、凶杀大案……这些电视市场通行的娱乐元素,通篇至尾,除了教书育人,就是读书成才,一堆“国家、民族、理想、志向”的大道理,哪怕一样能跟“娱乐性”挂上钩的内容也找不出,所以,我是真不敢期望它的市场反响。\n而四年后的今天,这部剧在中央电视台、湖南电视台播出后,却连续创造了极高的收视率,在观众中、尤其是青少年观众中产生了空前强烈的反响,网络上好评如潮,称之为“《恰同学少年》现象”,甚至引起中央高层领导的关注,这样的成功,真是完全出乎我的意料。\n回想起来,《恰同学少年》的成功,首先源自湖南电视台欧阳常林台长独到的创意策划,是欧阳台长以高远的眼光和强烈的社会责任感,提出了创作一部以“老师怎样教书育人,学生怎样读书成才”为主题的电视剧作品,也是他最早提出,将选材放在杨昌济先生与毛泽东、蔡和森这一组历史最有名、最成功的师生组合上。可以说,欧阳台长的选题与创意,是《恰同学少年》得以成功的关键。\n另外,投资方之一长沙电视台对电视剧《恰同学少年》的大力投入,也是这部电视剧得以成功的不可或缺的因素,著名制片人罗浩先生的独具慧眼,至今令我由衷佩服。\n在剧本创作过程中,我的老师、著名编剧盛和煜先生全程参与策划、审稿、统稿,给予了我悉心的指导,另外,导演龚若飞先生作为项目负责人,也全程参与了剧本创作的讨论与执行,我的剧本能够创作成功,同样离不开他们的帮助和指导。\n《恰同学少年》播出以后,也得到湖南省委的高度重视,省委宣传部蒋建国部长专门指出要将《恰同学少年》的后续宣传与推广工作做好,并布置了六条具体措施,其中之一,就是及时出版同名小说作品。\n现在,这本电视剧同名小说已经面世,小说是在电视剧本的基础上加工完成的,由于时间仓促、水平有限,小说中还存在许多不足,敬请读者谅解。\n本书的出版单位湖南人民出版社对这个项目给予了高度重视,成立了专门的工作小组,组织改写、审稿、报批等等工作,这部小说能够这样快与读者见面,跟出版社有关领导与编辑人员加班加点的辛勤工作是分不开的。此外,何晓、张开宏、张雯轩、覃柳平等人也为这本书的改写,做了大量具体的工作,在此也一并表示感谢。\n黄晖\n2007年6月30日\n(全文完)\n"},{"id":130,"href":"/zh/docs/culture/%E5%A6%82%E4%BD%95%E9%98%85%E8%AF%BB%E4%B8%80%E6%9C%AC%E4%B9%A6/%E9%99%84%E5%BD%95/","title":"附录","section":"如何阅读一本书","content":" 附录一 建议阅读书目 # 下面所列举的书单,都是值得你花时间一读的书。我们说“值得你花时间”是很认真的。虽然这些书并不全都是一般人所认为的那种“伟大”,但只要你肯花时间努力,你就能得到回馈。所有这些书都超越了大多数的水平——超出许多。因而这些书会强迫大部分读者作心智上的成长,以了解并欣赏这样的书。当然,如果你想要增进自己的阅读技巧,这样的书就是你该找的书,同时你也会发现在我们文化传统中有过,哪些伟大的思想与说法。\n就我们在上一章所谈的特殊意义而言有些书特别了不起。每次你重读,都会发现许多新的想法。这些书是可以一读再读,永不会厌倦的。换句话说,这些书——我们不会正确地指出有多少这样的书,也不会指出是哪些书,因为这是由个人判断的——超越过所有读者的水平,就算最有技巧的读者也不能超越这样的书。我们在上一章说过,这些作品就是每个人都该特别努力去研读的书。这些书是真正的伟大作品,任何一个人要去荒岛,都该带着这些书一起去。\n这个书单很长,看起来有点难以消受。我们鼓励你不要因为这个书单而觉得为难。一开始,你可能会先要辨识大部分的作者是谁。这里面没什么是一般人难以了解,因而就该冷僻的道理。最重要的是,我们要提醒你,不论基于什么理由,最聪明的做法都是从你最感兴趣的书开始读。我们已经说过许多次,主要的目标是要读得好,而不是要读得广。如果一年当中你读不了几本书,其实不必觉得失望。书单上的书并不是要你在特定时间里读完的。这也不是非要读完所有的书才算完成的挑战。相反,这是一个你可以从容接受的邀请,只要你觉得很自在,任何时候都可以开始。\n作者名单是按时间前后顺序排出来的,以他们确实或大约的出生时期为准。一位作者有很多本书时,也是尽可能按作品时间顺序排列的。学者们对每一本书的最早出版时间可能不见得有一致的看法,但这对你来说没什么影响。要记得的重点是:这个书单就像是一个时代的演进表,当然,你用不着依时间先后的顺序来读。你甚至可以从最近出版的一本书来读,再回溯到荷马及《旧约》。\n我们并没有把每一位作者所有的书都列出来。通常我们都只挑选比较重要的作品,以论说性作品而言,我们挑选的根据是尽可能表现一位作者在不同学习领域里作了哪些贡献。在另外一些例子中,我们会列举一位作者的几部作品,然后把其中特别重要又有用的书用括号标示出来。\n要拟这份书单,最困难的总是跟当代作品有关的部分。作者越接近我们的年代,越难作很公正的评断。时间能证明一切是句好话,但我们不想等那么久。因此对现代的作者或作品,我们预留了一些不同观点的空间,因此在我们书单比较后面部分的书,我们不敢说前面那些书公认的地位。\n对前面部分的书,可能也有人有些不同的观点,因为我们没有列入某些作品,可能会认为我们在挑选时有偏见。在某些例子中,我们承认自己是有些偏见。这是我们开的书单,自然会跟别人开的书单有点不同。不过如果任何人想要认真地研拟一份值得一生阅读的好书书单,以增进阅读能力的话,其间的差别应该不会太大才对。当然,最后你还是要自己拟出一份书单,然后全力以赴。无论如何,在你列出自己的书单之前,先看一份被一致公认为好书的书单,是很聪明的做法。这份书单是一个可以开始的地方。\n我们还要提出一个疏漏之处,这可能会让一些不幸的读者觉得很受打击。这份书单只列出了西方的作品,不包括中国、日本或印度的作品。我们这么做有几个理由。其中一个是我们对西方传统文化以外的文化并不十分在行,我们建议的书单也不会有什么分量。另一个原因是东方并不像西方这样是单一的传统,我们必须要明白所有的东方文化传统之后,才能将这份书单拟好。而很少有学者能对所有的东方文化都有深刻的了解。第三,在你想要了解其他世界的文化之前,应该要先了解自己的文化。现代有许多人试着要读《易经》或《薄伽梵歌》(Bhagavad-Gita),都觉得很困难,不只是因为这样的书本身就很难懂,也因为他们并没有先利用自己文化中比较容易理解的书——他们比较容易接近的书把阅读技巧练习好。\n还有另外一个疏忽之处要提提。虽然是一份书单,其中主要以抒情诗诗人为人熟知的作者却没几位。当然,书单中另外有些作者也写抒情诗,但他们较为人知的是一些较长的其他著作。这方面不该当作是我们对抒情诗有偏见。读诗,我们认为从一本好的合选集开始阅读,会比从某一位作者的个人选集开始要好得多。帕尔格雷夫(Palgrave)编辑的《英诗金库》(The Golden reasury)及《牛津英诗选》(The Oxford Book of English Verse)是最好的入门书。这些老的诗选应该要有现代人做增补的工作——像塞尔登·罗德曼(Selden Rodman)的《现代诗一百首》(Owe Hundred Modern Poems),这本书用很有趣的概念,广泛收集了当代随手可得的英诗。因为阅读抒情诗需要特殊的技巧,我们也介绍了其他相关的指导书籍——像马克·范多伦的《诗歌入门》(Introduction to Poetry),是一本合选集,同时也包含了一些短论,谈到如何阅读许多有名的抒情诗。\n我们依照作者及书名将书单列出来,却没有列出出版者及特殊的版本。书单上几乎所有的书都可以在书店中找到,有许多出了不同的版本,平装或精装都有。不过,如果哪位作者或哪本作品已经收录进我们自己所编辑的两套书,那就会特别标示出来。其中出现在《西方世界的经典名著》(Great Books of the Western World)中的,打一个星号;出现在《名著入门》(Gateway to the Great Books)中的,打两个星号。\n1.Homer(9th century b.c.?)\n*Iliad\n*Odyssey\n2.The Old Testament\n3.Aeschylus(c.525-456 b.c.)\n*Tragedies\n4.Sophocles(c.495-406 b.c.)\n*Tragedies\n5.Herodotus(c.484-425 b.c.)\n*History(of the Persian Wars)\n6.Euripides(c.485-406 b.c.)\n*Tragedies\n(esp.Medea,Hippolytus,The Bacchae)\n7.Thucydides(c.460-400 b.c.)\n*History of the Peloponnesian War\n8.Hippocrates(c.460-377 b.c.)\n*Medical writings\n9.Airstophanes(c.448-380 b.c.)\n*Comedies\n(esp.The Clouds,The Birds,The Frogs)\n10.Plato(c.427-347 b.c.)\n*Dialogues\n(esp.The Republic,Symposium,Phaedo,Meno,Apology,Phaedrus,Protagoras,Gorgias, Sophist,Theaetetus)\n11.Aristotle(384-322 b.c.)\n*Works\n(esp. Organon,Physics,Metaphysics,On the Soul,The Nichomachean Ethics,Politics,Rhetoric,Poetics)\n12.**Epicurus(c.341-270 b.c.)\nLetter to Herodotus\nLetter to Menoeceus\n13.Euclid(fl.c.300 b.c.)\n*Elements(of Geometry)\n14.Archimedes(c.287-212 b.c.)\n*Works\n(esp.On the Equilibrium of Planes,On Floating Bodies,The Sand-Reckoner)\n15.Apollonius of Perga(fl.c.240 b.c.)\n*On Conic Sections\n16.**Cicero(106-43 b.c.)\nWorks\n(esp.Orations,On Friendship,On Old Age)\n17.Lucretius(c.95-55 b.c.)\n*On the Nature of Things IS.Virgil(70-19 b.c.)\n*Works\n19.Horace(65-8 b.c.)\nWorks\n(esp.Odes and Epodes,The Art of Poetry)\n20.Livy(59 b.c.-A.D.17)\nHistory of Rome\n21.Ovid(43 b.c.-A.D.17)\nWorks\n(esp.Metamorphoses)\n22.**Plutarch(c.45-120)\n*Lives of the Noble Grecians and Romans Moralia\n23.** Tacitus(c.55-117)\n*Histories\n*Annals\nAgricola\nGermania\n24.Nicomachus of Gerasa(fl. c.100 a.d.)\n*Introduction to Arithmetic\n25.* *Epictetus(c.60-120)\n*Discourses\nEncheiridion(Handbook)\n26.Ptolemy(c.100-178;fl.127-151)\n*Almagest\n27.** Lucian(c.120-c.190)\nWorks\n(esp.The Way to Write History,The True History t The Sale of Creeds)\n28.Marcus Aurelius(121-180)\n*Meditations\n29.Galen(c.130-200)\n*On the Natural Faculties\n30.The New Testament\n31.Plotinus(205-270)\n*The Enneads\n32.St.Augustine(354-430)\nWorks\n(esp.On the Teacher,*Confessions,The City of God,*Christian Doctrine)\n33.The Song of Roland(12th century?)\n34.The Nibelungenlied(13th century)\n(The Vlsunga Saga is the Scandinavian version of the same leg end.)\n35.The Saga of Burnt Njal\n36.St.Thomas Aquinas(c.1225-1274)\n*Summa Theologica\n37.**Dante Alighieri(1265-1321)\nWorks\n(esp.The New Life,On Monarchy,*The Divine Comedy)\n38.Geoffrey Chaucer(c.1340-1400)\nWorks\n(esp.*Troilus and Criseyde,* Canterbury Tales)\n39.Leonardo da Vinci(1452-1519)\nNotebooks\n40.Niccoló Machiavelli(1469-1527)\n*The Prince\nDiscourses on the First Ten Books of Livy\n41.Desiderius Erasmus(c.1469-1536)\nThe Praise of Folly\n42.Nicolaus Copernicus(1473-1543)\n*On the Revolutions of the Heavenly Spheres\n43.Sir Thomas More(c.1478-1535)\nUtopia\n44.Martin Luther(1483-1546)\nThree Treatises\nTable -Talk\n45.Francois Rabelais(c.1495-1553)\n*Gargantua and Pantagruel\n46.John Calvin(1509-1564)\nInstitutes of the Christian Religion\n47.Michel de Montaigne(1533-1592)\n*Essays\n48.William Gilbert(15401603)\n*On the Loadstone and Magnetic Bodies\n49.Miguel de Cervantes(1547-1616)\n*Don Quixote\n50.Edmund Spenser(c.1552-1599)\nProthalamion\nThe Faerie Queene\n51.**Francis Bacon(1561-1626)\nEssays\n*Advancement of Learning\n*Novum Organum\n*New Atlantis\n52.William Shakespeare(1564-1616)\n*Works\n53.**Galileo Galilei(1564-1642)\nThe Starry Messenger\n*Dialogues Concerning Two New Sciences\n54.Johannes Kepler(1571-1630)\n*Epitome of Copernican Astronomy\n*Concerning the Harmonies of the World\n55.William Harvey(1578-1657)\n*On the Motion of the Heart and Blood in Animals\n*On the Circulation of the Blood\n*On the Generation of Animals\n56.Thomas Hobbes(1588-1679)\n*The Leviathan\n57.Rene Descartes(1596-1650)\n*Rules for the Direction of the Mind\n*Discourse on Method\n*Geometry\n*Meditations on First Philosophy\n58.John Milton(1608-1674)\nWorks\n(esp.*the minor poems,*Areopagitica,*Paradise Lost,*Samson Agonistes)\n59.** Moliere(1622-1673)\nComedies\n(esp.The Miser,The School for Wives,The Misanthrope,The Doctor in Spite of Himself,Tartuffe)\n60.Blaise Pascal(1623-1662)\n*The Provincial Letters\n*Pensées\n*Scientific treatises\n61.Christiaan Huygens(1629-1695)\n*Treatise on Light\n62.Benedict de Spinoza(1632-1677)\n*Ethics\n63.John Locke(1632-1704)\n*Letter Concerning Toleration\n*“Of Civil Government ”( second treatise in Two Treatises on Government)\n*Essay Concerning Human Understanding Thoughts Concerning Education\n64.Jean Baptiste Racine(1639-1699)\nTragedies\n(esp.Andromache,Phaedra)\n65.Isaac Newton(1642-1727)\n*Mathematical Principles of Natural philosophy\n*Optics\n66.Gottfried Wilhelm von Leibniz(1646-1716)\nDiscourse on Metaphysics\nNew Essays Concerning Human Understanding Monadology\n67.**Daniel Defoe(1660-1731)\nRobinson Crusoe\n68.**Jonathan Swift(1667-1745)\nA Tale of a Tub\nJournal to Stella\n*Gulliver\u0026rsquo;s Travels\nA Modest Proposal\n69.William Congreve(1670-1729)\nThe Way of the World\n70.George Berkeley(1685-1753)\n*Principles of Human Knowledge\n71.Alexander Pope(1688-1744)\nEssay on Criticism\nRape of the Lock\nEssay on Man\n72.Charles de Secondat,Baron de Montesquieu(1689-1755)\nPersian Letters\n*Spirit of Laws\n73.**Voltaire(1694-1788)\nLetters on the English\nCandide\nPhilosophical Dictionary\n74.Henry Fielding(1707-1754)\nJoseph Andrews\n*Tom Jones\n75.**Samuel Johnson(1709-1784)\nThe Vanity of Human Wishes\nDictionary\nRasselas\nThe Lives of the Poets\n(esp.the essays on Milton and Pope)\n76.**David Hume(1711-1776)\nTreatise of Human Nature Essays Moral and Political\n*An Inquiry Concerning Human Understanding\n77.**Jean Jacques Rousseau(1712-1778)\n*On the Origin of Inequality\n*On Political Economy Emile\n*The Social Contract\n78.Laurence Sterne(1713-1768)\n*Tristram Shandy\nA Sentimental Journey Through France and Italy\n79.Adam Smith(1723-1790)\nThe Theory of the Moral Sentiments\n*Inquiry into the Nature and Causes of the Wealth of Nations\n80.**Immanuel Kant(1724-1804)\n*Critique of Pure Reason\n*Fundamental Principles of the Metaphysics of Morals\n*Critique of Practical Reason\n*The Science of Right\n*Critique of Judgment\nPerpetual Peace\n81.Edward Gibbon(1737-1794)\n*The Decline and Fall of the Roman Empire Autobiography\n82.James Boswell(17401795)\nJournal\n(esp.London Journal)\n*Life of Samuel Johnson Ll.D.\n83.Antoine Laurent Lavoisier(1743-1794)\n*Elements of Chemistry\n84.John Jay(1745-1829),James Madison(1751-1836),and Alexander Hamilton(1757-1804)\n*Federalist Papers\n(together with the *Articles of Confederation,the*Constitution of the United States,and the ^ Declaration of Independence)\n85.Jeremy Bentham(1748-1832)\nIntroduction to the Principles of Morals and Legislation Theory of Fictions\n86.Johann Wolfgang von Goethe(1749-1832)\n*Faust\nPoetry and Truth\n87.Jean Baptiste Joseph Fourier(1768-1830)\n*Analytical Theory of Heat\n88.Georg Wilhelm Friedrich Hegel(1770-1831)\nPhenomenology of Spirit\nPhilosophy of Right\nLectures on the Philosophy of History\n89.William Wordsworth(1770-1850)\nPoems\n(esp.Lyrical Ballads 9 Lucy poems,sonnets;The Prelude)\n90.Samuel Taylor Coleridge(1772-1834)\nPoems\n(esp.“Kubla Khan,”Rime of the Ancient Mariner)\nBiographia Literaria\n91. Jane Austen(1775-1817)\nPride and Prejudice\nEmma\n92.**Karl von Clausewitz(1780-1831)\nOn War\n93.Stendhal(1783-1842)\nThe Red and the Black\nThe Charterhouse of Parma\nOn Love\n94.George Gordon,Lord Byron(1788-1824)\nDon Juan\n95.**Arthur Schopenhauer(1788-1860)\nStudies in Pessimism\n96.**Michael Faraday(1791-1867)\nChemical History of a Candle\n*Experimental Researches in Electricity\n97.** Charles Lyell(1797-1875)\nPrinciples of Geology\n98.Auguste Comte(1798-1857)\nThe Positive Philosophy\n99.**Honore de Balzac(1799-1850)\nPère Goriot\nEugénie Grandet\n100.** Ralph Waldo Emerson(1803-1882)\nRepresentative Men\nEssays\nJournal\n101.** Nathaniel Hawthorne(1804-1864)\nThe Scarlet Letter\n102.**Alexis de Tocqueville(1805-1859)\nDemocracy in America\n103.**John Stuart Mill(1806-1873)\nA System of Logic\n*On Liberty\n*Representative Government\n*Utilitarianism\nThe Subjection of Women\nAutobiography\n104.**Charles Darwin(1809-1882)\n*The Origin of Species\n*The Descent of Man\nAutobiography\n105.**Charles Dickens(1812-1870)\nWorks\n(esp.Pickwick Papers,David Copper field,Hard Times)\n106.**Claude Bernard(1813-1878)\nIntroduction to the Study of Experimental Medicine\n107.**Henry David Thoreau(1817-1862)\nCivil Disobedience\nWalden\n108.Karl Marx(1818-1883)\n*Capital\n(together with the *Communist Manifesto)\n109.George Eliot(1819-1880)\nAdam Bede Middlemarch\n110.**Herman Melville(1819-1891)\n*Moby Dick\nBilly Budd\n111.**Fyodor Dostoevsky(1821-1881)\nCrime and Punishment\nThe Idiot\nThe Brothers Karamazov\n112.**Gustave Flaubert(1821-1880)\nMadame Bovary\nThree Stories\n113.**Henrik Ibsen(1828-1906)\nPlays\n(esp.Hedda Gabler,A Doll\u0026rsquo;s House,The Wild Duck)\n114.**Leo Tolstoy(1828-1910)\nWar and Peace\nAnna Karenina\nWhat Is Art?\nTwenty-three Tales\n115.**Mark Twain(1835-1910)\nThe Adventures of Huckleberry Finn\nThe Mysterious Stranger\n116.**William James(1842-1910)\n*The Principles of Psychology\nThe Varieties of Religious Experience Pragmatism\nEssays in Radical Empiricism\n117.**Henry James(1843-1916)\nThe American\nThe Ambassadors\n118.Friedrich Wilhelm Nietzsche(1844-1990)\nThus Spoke Zarathustra\nBeyond Good and Evil The Genealogy of Morals The Will to Power\n119.Jules Henri Poincare(1854-1912)\nScience and Hypothesis\nScience and Method\n120.Sigmund Freud(1856-1939)\n*The Interpretation of Dreams\n*Introductory Lectures on Psychoanalysis\n*Civilization and Its Discontents\n*New Introductory Lectures on Psychoanalysis\n121.**George Bernard Shaw(1856-1950)\nPlays(and Prefaces)\n(esp.Man and Superman,Major Barbara,Caesar and Cleopatra,Pygmalion,Saint Joan)\n122.** Max Planck(1858-1947)\nOrigin and Development of the Quantum Theory\nWhere Is Science Going?\nScientific Autobiography\n123.**Henri Bergson(1858-1941)\nTime and Free Will\nMatter and Memory\nCreative Evolution\nThe Two Sources of Morality and Religion\n124.“ John Dewey(1859-1952)\nHow We Think\nDemocracy and Education\nExperience and Nature\nLogic,the Theory of Inquiry\n125.**Alfred North Whitehead(1861-1947)\nAn Introduction to Mathematics\nScience and the Modern World\nThe Aims of Education and Other Essays\nAdventures of Ideas\n126.**George Santayana(1863-1952)\nThe Life of Reason\nSkepticism and Animal Faith\nPersons and Places\n127.Nikolai Lenin(1870-1924)\nThe State and Revolution\n128.Marcel Proust(1871-1922)\nRemembrance of Things Past\n129.**Bertrand Russell(1872-1970)\nThe Problems of Philosophy\nThe Analysis of Mind\nAn Inquiry into Meaning and Truth\nHuman Knowledge;Its Scope and Limits\n130.**Thomas Mann(1875-1955)\nThe Magic Mountain\nJoseph and His Brothers\n131.**Albert Einstein(1879-1955)\nThe Meaning of Relativity\nOn the Method of Theoretical Physics\nThe Evolution of Physics(with L.Infeld)\n132.**James Joyce(1882-1941)\n“The Dead”in Dubliners\nPortrait of the Artist as a Young Man Ulysses\n133.Jacques Maritain(1882-)\nArt and Scholasticism\nThe Degrees of Knowledge\nThe Rights of Man and Natural Law\nTrue Humanism\n134.Franz Kafka(1883-1924)\nThe Trial\nThe Castle\n135.Arnold Toynbee(1889-)\nA Study of History\nCivilization on Trial\n136.Jean Paul Sartre(1905-)\nNausea\nNo Exit\nBeing and Nothingness\n137.Aleksandr I.Solzhenitsyn(1918-)\nThe First Circle\nCancer Ward\n附录一 建议阅读书目(中文版)\n荷马——《伊利亚特》、《奥德赛》\n未知——《旧约》\n埃斯库罗斯——悲剧\n索福克勒斯——悲剧\n希罗多德——历史\n欧里庇得斯——悲剧 特别:《美狄亚》、《希波吕托斯》、《酒神的伴侣》\n修昔底德——《伯罗奔尼撒战争史》\n希波克拉底——《医学著作》\n阿里斯托芬——喜剧 特别:《云》、《鸟》、《青蛙》\n柏拉图——对话录 特别:《理想国》、《会饮》、《斐多》、《枚农》、《申辩篇》、《斐德罗》、《普罗太戈拉》、《高尔吉亚》、《智者》、《泰阿泰德》\n亚里士多德——全部作品 特别:《工具论》、《物理学》、《形而上学》、《论灵魂》、《尼各马科伦理学》、《政治学》、《修辞术》、《诗学》\n伊壁鸠鲁——《致希罗多德信》、《致梅瑙凯信》\n欧几里得——《几何原本》\n阿基米德——所有著作 特别:《论平板的平衡》、《论浮体》、《沙粒计算》\n阿波罗尼奥斯——《圆锥曲线论》\n西塞罗——《友谊篇》、《演说集》、《论老年》\n卢克莱修——《物性论》\n维吉尔——著作 英文版特别:《牧歌》、《农业的·田园诗 》、《埃涅阿斯纪》\n贺拉斯 ——《长短句》、《颂歌集》、《诗艺》\n李维——《罗马史》\n奥维德——著作 特别:《变形记》\n普鲁塔克——《希腊罗马名人比较列传》\n塔西佗——《历史》、《编年史》、《阿古利可拉传》、《日耳曼尼亚志》\n尼可马修斯——《算术入门》\n爱比克泰德——《金言录》、《手册》\n托密勒——《天文学大成》\n琉善(路吉阿诺斯)——著作 特别:《论撰史》、《真实的历史》、《待售的哲学》\n马库思·奥勒留——《沉思录》\n盖伦——《论自然力》\n未知——《新约》\n柏罗丁——《六部九章集》\n圣·奥古斯丁——著作 特别:《论教师》、《忏悔录》、《天主之城 》、《论基督教教义》\n未知——《罗兰之歌》\n未知——《尼伯龙根之歌》\n未知——《尼雅尔萨迦(尼雅尔传)》\n阿奎那——《神学大全》\n但丁——著作 特别:《新生活》、《君主国》、《神曲》\n乔叟——著作 特别:《特罗勒斯与克丽西德》、《坎特伯雷故事集》\n达芬奇——笔记\n马基维里——《君主论》、《李维罗马史论》\n伊拉斯谟——《愚人礼赞》\n哥白尼——《天体运行论(De Revolutionibus)》\n托马斯·摩尔——《乌托邦》\n马丁·路德——《三檄文》、《桌上谈》\n拉伯雷——《巨人传》\n加尔文——《基督教要义》\n蒙田——《随笔》\n威廉·吉尔伯特——《磁石论》\n塞万提斯——《堂吉诃德》\n爱德蒙·斯宾塞——《预祝婚礼曲》、《仙后》\n培根——《随笔集》、《学术的推进》、《新工具》、《新大西岛》\n莎士比亚——著作\n伽利略——《关于两门新科学的对话》、《星夜的差使》\n开布勒——《哥白尼天文学概要》、《世界的和谐》\n威廉·哈维——《动物心运动的解剖学研究》、《血液循环》、《论动物的生殖》\n托马斯·霍布斯——《利维坦》\n笛卡儿——《方法中的对话》、《方法论》、《几何学》、《第一哲学沉思》\n弥尔顿——著作 特别:《英文小诗歌》、《失乐园》、《力士参孙》、《论出版自由》\n莫里哀——喜剧 特别:《吝啬鬼》、《太太学校》、《恨世者》、《讨厌自己的医生》、《塔图弗》\n帕斯卡——《思想录》、《致外省人信札》、《科学论文集》\n惠更斯——《光论》\n斯宾诺莎——《伦理学》\n洛克——《论宽容》、《政府论》、《人类理解论》\n拉辛——悲剧 特别:《昂朵马格》、《费德儿》\n牛顿——《自然哲学的数学原理》、《光学》\n莱布尼兹——《形而上学序论》、《人类理智新论》、《单子论》\n笛福——《罗宾汉》\n斯威夫特——《致史黛拉书》、《格理弗游记》、《一个木桶的故事》、《一个小小的建议》\n康格里夫——《浮士道》\n柏克莱——《人类知识原理》\n蒲伯——《论批评》、《人论》、《鬈发遇劫记》\n孟德斯鸠——《波斯人信札》、《论法的精神》\n伏尔泰——《英国书简》、《憨第德》、《哲学词典》\n亨利·菲尔丁——《约瑟夫·安德鲁传》、《汤姆·琼斯》\n塞缪尔·约翰逊——《人类欲望的虚幻》、《英文辞典》、《拉塞勒斯》、《诗人传》\n休谟——《人性论》、《道德与政治文集》、《人类理智研究》\n让·雅各·卢梭——《论人类不平等的起源和基础》、《论政治经济学》、《爱弥尔》、《社会契约论》\n劳伦斯·斯特恩——《项狄传》、《在法国和意大利的伤感旅行》\n亚当·斯密——《道德情操论》、《国富论》\n康德——《纯粹理性批判》、《实践理性批判》、《法的形而上学原理-权利科学》、《判断力批判》、《论永久和平》\n爱德华·吉本——《罗马帝国的衰亡》\n包斯威尔——《伦敦日记》、《约翰逊传》\n拉瓦锡——《化学概要》\n多位——《联邦党人文集》\n边沁——《道德与立法原理导论》、《边沁的虚构理论(奥格登编撰)》\n歌德——《浮士德》、《诗与真相》\n傅立叶——《热的分析理论》\n黑格尔——《精神现象学》、《权利哲学》、《历史哲学》\n华兹华斯——诗 特别:《抒情歌谣集》、《露茜组诗》、《长诗(序曲)》\n萨缪尔·柯勒律——诗 特别:《忽必列汗》、《老水手行》\n奥斯汀——《傲慢与偏见》、《爱玛》\n克劳塞维茨——《战争论》\n司汤达——《红与黑》、《帕尔马修道院》、《爱情论》\n拜伦——《瑭璜》\n叔本华——《悲观主义的研究》\n法拉第——《蜡烛的化学历史》、《电学实验研究》\n莱伊尔——《地质学原理》\n孔德——《实证哲学教程》\n巴尔扎克——《高老头》、《欧也妮·葛朗台》\n爱默生——《代表人物》、《爱默生集:论文和讲演集》、《爱默生随笔》\n霍桑——《红字》\n托克威——《美国的民主政治》\n密尔——《理论学》、《论自由》、《代议制政府》、《功利主义》、《女性之卑屈》、《自传》\n查尔斯·达尔文——《物种起源》、《人类的由来》、《自传》\n查尔斯狄更斯——著作 特别:《匹克威克外传》、《大卫·科波维尔》、《艰难时世》\n克劳德·伯纳德——《实验医学研究导论》\n梭罗 ——《论公民的不服从》、《瓦尔登湖》\n马克思——《资本论》\n乔治·艾略特——《亚当·贝德》、《米德尔马契》\n赫尔曼·麦尔维尔——《莫比迪克(白鲸)》、《比理巴德》\n陀思妥耶夫斯基——《罪与罚》、《白痴》、《卡拉马佐夫兄弟》\nTable of Contents # 作者简介 序言 第一篇 阅读的层次 第一章 阅读的活力与艺术 1.主动的阅读 2.阅读的目标:为获得资讯而读,以及为求得理解而读 3.阅读就是学习:指导型的学习,以及自我发现型的学习之间的差异 4.老师的出席与缺席 第二章 阅读的层次 第三章 阅读的第一个层次:基础阅读 1.学习阅读的阶段 2.阅读的阶段与层次 3.更高层次的阅读与高等教育 4.阅读与民主教育的理念 第四章 阅读的第二个层次:检视阅读 1.检视阅读一:有系统的略读或粗读 2.检视阅读二:粗浅的阅读 3.阅读的速度 4.逗留与倒退 5.理解的问题 6.检视阅读的摘要 第五章 如何做一个自我要求的读者 1.主动的阅读基础:一个阅读者要提出的四个基本问题 2.如何让一本书真正属于你自己 3.三种做笔记的方法 4.培养阅读的习惯 5.由许多规则中养成一个习惯 第二篇 阅读的第三个层次:分析阅读 第六章 一本书的分类 1.书籍分类的重要性 2.从一本书的书名中你能学到什么 3.实用性VS.理论性作品 4.理论性作品的分类 第七章 透视一本书 1.结构与规划:叙述整本书的大意 2.驾驭复杂的内容:为一本书拟大纲的技巧 3.阅读与写作的互惠技巧 4.发现作者的意图 5.分析阅读的第一个阶段 第八章 与作者找出共通的词义 1.单字vs.词义 2.找出关键字 3.专门用语及特殊字汇 4.找出字义 第九章 判断作者的主旨 1.句子与主旨 2.找出关键句 3.找出主旨 4.找出论述 5.找出解答 6.分析阅读的第二个阶段 第十章 公正地评断一本书 1.受教是一种美德 2.修辞的作用 3.暂缓评论的重要性 4.避免争强好辩的重要性 5.化解争议 第十一章 赞同或反对作者 1.偏见与公正 2.判断作者的论点是否正确 3.判断作者论述的完整性 4.分析阅读的三阶段 第十二章 辅助阅读 1.相关经验的角色 2.其他的书可以当作阅读时的外在助力 3.如何运用导读与摘要 4.如何运用工具书 5.如何使用字典 6.如何使用百科全书 第三篇 阅读不同读物的方法 第十三章 如何阅读实用型的书 1.两种实用性的书 2.说服的角色 3.赞同实用书之后 第十四章 如何阅读想像文学 1.读想像文学的“不要” 2.阅读想像文学的一般规则 第十五章 阅读故事、戏剧与诗的一些建议 1.如何阅读故事书 2.关于史诗的重点 3.如何阅读戏剧 4.关于悲剧的重点 5.如何阅读抒情诗(Lyric Poetry) 第十六章 如何阅读历史书 1.难以捉摸的史实 2.历史的理论 3.历史中的普遍性 4.阅读历史书要提出的问题 5.如何阅读传记与自传 6.读关于当前的事件 7.摘的注意事项 第十七章 如何阅读科学与数学 1.了解科学这一门行业 2.阅读科学经典名著的建议 3.面对数学的问题 4.掌握科学作品中的数学问题 5.关于科普书的重点 第十八章 如何阅读哲学书 1.哲学家提出的问题 2.现代哲学与传承 3.哲学的方法 4.哲学的风格 5.阅读哲学的提示 6.厘清你的思绪 7.关于神学的重点 8.如何阅读“经书” 第十九章如何阅读社会科学 1.什么是社会科学? 2.阅读社会科学的容易处 3.阅读社会科学的困难处 4.阅读社会科学作品 第四篇 阅读的最终目标 第二十章 阅读的第四个层次:主题阅读 1.在主题阅读中,检视阅读所扮演的角色 2.主题阅读的五个步骤 3.客观的必要性 4.主题阅读的练习实例:进步论 5.如何应用主题工具书 6.构成主题阅读的原则 7.主题阅读精华摘要 第二十一章 阅读与心智的成长 1.好书能给我们什么帮助 2.书的金字塔 3.生命与心智的成长 附录一 建议阅读书目 附录一 建议阅读书目(中文版) "},{"id":131,"href":"/zh/docs/culture/%E5%A6%82%E4%BD%95%E9%98%85%E8%AF%BB%E4%B8%80%E6%9C%AC%E4%B9%A6/%E7%AE%80%E4%BB%8B-%E5%BA%8F%E8%A8%80/","title":"简介-序言","section":"如何阅读一本书","content":"如何阅读一本书\n[美]莫提默·J·艾德勒 查尔斯·范多伦\n作者简介 # 莫提默·J.艾德勒(1902-2001)以学者、教育家、编辑等多重面貌享有盛名。除了写作《如何阅读一本书》外,以主编《西方世界德经典》,并担任1974年第十五版《大英百科全书》的编辑指导而闻名于世。\n查尔斯·范多伦(1926- )\n先曾任美国哥伦比亚大学教授。后因故离任,和艾德勒一起工作。一方面襄助艾德勒编辑《大英百科全书》,一方面将本书1940年初版内容大幅度增补改写。因此,本书1970年新版由两个人共同署名。\n序言 # 《如何阅读一本书》的第一版是在1940年初出版的。很惊讶,我承认也很高兴的是,这本书立刻成为畅销书,高踞全美畅销书排行榜首有一年多时间。从1940年开始,这本书继续广泛的印刷发行,有精装本也有平装本,而且还被翻译成其他语言—法文、瑞典文、德文、西班牙文与意大利文。所以,为什么还要为目前这一代的读者再重新改写、编排呢?\n要这么做的原因,是近三十年来,我们的社会,与阅读这件事本身,都起了很大的变化。今天,完成高中教育及四年大学教育的年轻男女多了许多。尽管(或者说甚至因为)收音机及电视普及,识字的人也更多了。阅读的兴趣,有一种由小说类转移到非小说类的趋势。美国的教育人士都承认,教导年轻人阅读,以最基本的阅读概念来阅读,成了最重要的教育问题。曾经指出1970年代是阅读年代的现任健康、教育及福利部部长,提供了大笔大笔联邦政府经费,支持各式各样改进基本阅读技巧的努力,其中许多努力在启发儿童阅读的这种层次上也的确)有了些成果。此外,许多成人则着迷于速读课程亮丽的保证—增进他们阅读理解与阅读速度的保证。\n然而,过去三十年来,有些事情还是没有改变。其中一项是:要达到阅读的所有目的,就必须在阅读不同书籍的时候,运用适当的不同速度。不是所有的书都可以用最快的速度来阅读。法国学者巴斯卡(Pascal)在三百年前就说过:“读得太快或太慢,都一无所获。”现在既然速读已经形成全国性的狂热,新版的《如何阅读一本书》就针对这个问题,提出不同速度的阅读法才是解决之道。我们的目标是要读得更好,永远更好,不过,有时候要读得慢一点,有时候要读得快一点。\n很不幸的,另外有一件事也没有改变,那就是指导阅读的层次,仍然逗留在基本水平。我们教育体系里的人才、金钱与努力,大多花在小学六年的阅读指导上。超出这个范围,可以带引学生进人更高层次,需要不同阅读技巧的正式训练,则几乎少之又少。1939年,哥伦比亚大学教育学院的詹姆斯·墨塞尔(JamesMursell)教授在《大西洋月刊》上发表了一篇文章:《学校教育的失败》。现在我引述他当时所写的两段话,仍然十分贴切:\n学校是否有效地教导过学生如何阅读母语?可以说是,也可以说不是。到五六年级之前,整体来说,阅读是被有效地教导过,也学习过了。在这之前,我们发现阅读的学习曲线是稳定而普遍进步的,但是过了这一点之后,曲线就跌入死寂的水平。这不是说一个人到了六年级就达到个人学习能力的自然极限,因为证据一再显示,只要经过特殊的教导,成人及大一点的孩童,都能有显著的进步。同时,这也不表示大多数六年级学生在阅读各种实用书籍的时候,都已经有足够的理解能力。许许多多学生进入中学之后成绩很差,就是因为读不懂书中的意义。他们可以改进,他们也需要改进,但他们就不这么做。\n中学毕业的时候,学生都读过不少书了。但如果他要继续念大学,那就得还要念更多的书,不过这个时候他却很可能像是一个可怜而根本不懂得阅读的人(请注意:这里说的是一般学生,而不是受过特别娇正训练的学生)。他可以读一点简单的小说,享受一下。但是如果要他阅读结构严谨的细致作品,或是精简扼要的论文,或是需要运用严密思考的章节,他就没有办法了。举例来说,有人证明过,要一般中学生掌握一段文字的中心思想是什么,或是论述文的重点及次要重点在哪里,简直就是难上加难。不论就哪一方面来说,就算进了大学,他的阅读能力也都只会停留在小学六年级的程度。\n如果三十年前社会对《如何阅读一本书》有所需求,就像第一版所受到的欢迎的意义,那么今天就更需要这样的一本书了。但是,回应这些迫切的需求,并不是重写这本书的惟一动机,甚至也不是主要的动机。对于学习“如何阅读”这个问题的新观点;对于复杂的阅读艺术更深的理解与更完整的分析理念;对于如何弹性运用基本规则做不同形态的阅读(事实上可引伸到所有种类的读物上);对于新发明的阅读规则;对于读书应如金字塔—基础厚实,顶端尖锐等等概念,都是三十年前我写这本书时没有适当说明,或根本没提到的概念。所有这些,都在催促我加以阐述并重新彻底改写,呈现现在所完成,也出版的这个面貌。\n《如何阅读一本书》出版一年后,出现了博君一粲的模仿书《如何阅读两本书》(How to Read Two Books),而I. A.理查兹教授(I. A. Richards)则写了一篇严肃的论文《如何阅读一页书》(How to Read aPage)。提这些后续的事,是要指出这两部作品中所提到的一些阅读的问题,无论是好笑还是严肃的问题,都在我重写的书中谈到了,尤其是针对如何阅读一系列相关的书籍,并清楚掌握其针对同一主题相互补充与冲突的问题。\n在重写《如何阅读一本书》的种种理由当中,我特别强调了阅读的艺术,也指出对这种艺术更高水准的要求。这是第一版中我们没有谈到或详细说明的部分。任何人想要知道增补了些什么,只要比较新版与原版的目录,很快就会明白。在本书的四篇之中,只有第二篇,详述“分析阅读”(Analytical Reading)规则的那一篇,与原版很相近,但事实上也经过大幅度的改写。第一篇,介绍四种不同层次的阅读—基础阅读(elementaryreading)、检视阅读(inspectional reading)、分析阅读、主题阅读(syntopical reading)是本书在编排与内容上最基本也最决定性的改变。第三篇是全书增加最多的部分,详加说明了以不同阅读方法接触不同读物之道—如何阅读实用性与理论性作品、想像的文学(抒情诗、史诗、小说、戏剧)、历史、科学与数学、社会科学与哲学,以及参考书、报章杂志,甚至广告。最后,第四篇,主题阅读的讨论,则是全新的章节。\n在重新增订这本书时,我得到查尔斯·范多伦(Charles Van Doren)的帮助。他是我在哲学研究院(Institute for Philosophical Research)多年的同事。我们一起合写过其他的书,最为人知的是1969年由大英百科全书出版公司出版的二十册《美国编年史)(Annals\nofAmerica)。至于我们为什么要合作,共同挂名来改写本书,也许有个更相关的理由是:过去八年来,我和范多伦共同密切合作主持过许多经典著作(great books)的讨论会,以及在芝加哥、旧金山、科罗拉多州的阿斯本举行的许多研讨会。由于这些经验,我们获得了许多新观点来重写这本书。\n我很感激范多伦先生在我们合作中的贡献。对于建设性的批评与指导,他和我都想表达最深的谢意。也要谢谢我们的朋友,亚瑟·鲁宾(Arthur L.H.Rubin)的帮助—他说服我们在新版中提出许多重大的改变,使这本书得以与前一版有不同的生命,也成为我们所希望更好、更有用的一本书。\n莫提默·J·艾德勒\n1972年3月26日写于波卡格兰德(Boca Grande)\n"},{"id":132,"href":"/zh/docs/culture/%E5%A6%82%E4%BD%95%E9%98%85%E8%AF%BB%E4%B8%80%E6%9C%AC%E4%B9%A6/%E7%AC%AC%E4%B8%80%E7%AF%87_%E9%98%85%E8%AF%BB%E7%9A%84%E5%B1%82%E6%AC%A1/","title":"第一篇 阅读的层次","section":"如何阅读一本书","content":"第一篇 阅读的层次\n第一章 阅读的活力与艺术 # 这是一本为阅读的人,或是想要成为阅读的人而写的书。尤其是想要阅读书的人。说得更具体一点,这本书是为那些想把读书的主要目的当作是增进理解能力的人而写。\n这里所谓“阅读的人”(readers),是指那些今天仍然习惯于从书写文字中汲取大量资讯,以增进对世界了解的人,就和过去历史上每一个深有教养、智慧的人别无二致。当然,并不是每个人都能做到这一点。即使在收音机、电视没有出现以前,许多资讯与知识也是从口传或观察而得。但是对智能很高又充满好奇心的人来说,这样是不够的。他们知道他们还得阅读,而他们也真的身体力行。\n现代的人有一种感觉,读书这件事好像已经不再像以往那样必要了。收音机,特别是电视,取代了以往由书本所提供的部分功能,就像照片取代了图画或艺术设计的部分功能一样。我们不得不承认,电视有部分的功能确实很惊人,譬如对新闻事件的影像处理,就有极大的影响力。收音机最大的特点在于当我们手边正在做某件事(譬如开车)的时候,仍然能提供我们资讯,为我们节省不少的时间。但在这中间还是有一个严肃的议题:到底这些新时代的传播媒体是否真能增进我们对自己世界的了解?\n或许我们对这个世界的了解比以前的人多了,在某种范围内,知识(knowledge)也成了理解(understanding)的先决条件。这些都是好事。但是,“知识”是否那么必然是“理解”的先决条件,可能和一般人的以为有相当差距。我们为了“理解”(understand)一件事,并不需要“知道”(know)和这件事相关的所有事情。太多的资讯就如同太少的资讯一样,都是一种对理解力的阻碍。换句话说,现代的媒体正以压倒性的泛滥资讯阻碍了我们的理解力。\n会发生这个现象的一个原因是:我们所提到的这些媒体,经过太精心的设计,使得思想形同没有需要了(虽然只是表象如此)。如何将知识分子的态度与观点包装起来,是当今最有才智的人在做的最活跃的事业之一。电视观众、收音机听众、杂志读者所面对的是一种复杂的组成—从独创的华丽辞藻到经过审慎挑选的资料与统计—目的都在让人不需要面对困难或努力,很容易就整理出“自己”的思绪。但是这些精美包装的资讯效率实在太高了,让观众、听众或读者根本用不着自己做结论。相反的,他们直接将包装过后的观点装进自己的脑海中,就像录影机愿意接受录影带一样自然。他只要按一个“倒带”的钮,就能找到他所需要的适当言论。他根本不用思考就能表现得宜。\n1.主动的阅读 # 我们在一开始就说过,我们是针对发展阅读书的技巧而写的。但是如果你真的跟随并锻炼这些阅读的技巧,你便可以将这些技巧应用在任何印刷品的阅读上—报纸、杂志、小册子、文章、短讯,甚至广告。\n既然任何一种阅读都是一种活动,那就必须要有一些主动的活力。完全被动,就阅读不了—我们不可能在双眼停滞、头脑昏睡的状况下阅读。既然阅读有主动、被动之对比,那么我们的目标就是:第一提醒读者,阅读可以是一件多少主动的事。第二要指出的是,阅读越主动,效果越好。这个读者比另一个读者更主动一些,他在阅读世界里面的探索能力就更强一些,收获更多一些,因而也更高明一些。读者对他自己,以及自己面前的书籍,要求的越多,获得的就越多。\n虽然严格说来,不可能有完全被动阅读这回事,但还是有许多人认为,比起充满主动的写跟说,读与听完全是被动的事。写作者及演说者起码必须要花一点力气,听众或读者却什么也不必做。听众或读者被当作是一种沟通接收器,“接受”对方很卖力地在“给予”、“发送”的讯息。这种假设的谬误,在认为这种“接收”类同于被打了一拳,或得到一项遗产,或法院的判决。其实完全相反,听众或读者的“接收”,应该像是棒球赛中的捕手才对。\n捕手在接球时所发挥的主动是跟投手或打击手一样的。投手或打击手是负责“发送”的工作,他的行动概念就在让球动起来这件事上。捕手或外野手的责任是“接收”,他的行动就是要让球停下来。两者都是一种活动,只是方式有点不同。如果说有什么是被动的,就是那只球了。球是毫无感觉的,可以被投手投出去,也可以被捕手接住,完全看打球的人如何玩法。作者与读者之间的关系也很类似。写作与阅读的东西就像那只球一样,是被主动、有活力的双方所共有的,是由一方开始,另一方终结的。\n我们可以把这个类比的概念往前推。捕手的艺术就在能接住任何球的技巧—快速球、曲线球、变化球、慢速球等等。同样地,阅读的艺术也在尽可能掌握住每一种讯息的技巧。\n值得注意的是,只有当捕手与投手密切合作时,才会成功。作者与读者的关系也是如此。作者不会故意投对方接不到的球,尽管有时候看来如此。在任何案例中,成功的沟通都发生于作者想要传达给读者的讯息,刚好被读者掌握住了。作者的技巧与读者的技巧融合起来,便达到共同的终点。\n事实上,作者就很像是一位投手。有些作者完全知道如何“控球”:他们完全知道自己要传达的是什么,也精准正确地传达出去了。因此很公平地,比起一个毫无“控球”能力的“暴投”作家,他们是比较容易被读者所“接住”的。\n这个比喻有一点不恰当的是:球是一个单纯的个体,不是被完全接住,就是没接住。而一本作品,却是一个复杂的物件,可能被接受得多一点,可能少一点;从只接受到作者一点点概念到接受了整体意念,都有可能。读者想“接住”多少意念完全看他在阅读时多么主动,以及他投人不同心思来阅读的技巧如何。\n主动的阅读包含哪些条件?在这本书中我们会反复谈到这个问题。此刻我们只能说:拿同样的书给不同的人阅读,一个人却读得比另一个人好这件事,首先在于这人的阅读更主动,其次,在于他在阅读中的每一种活动都参与了更多的技巧。这两件事是息息相关的。阅读是一个复杂的活动,就跟写作一样,包含了大量不同的活动。要达成良好的阅读,这些活动都是不可或缺的。一个人越能运作这些活动,阅读的效果就越好。\n2.阅读的目标:为获得资讯而读,以及为求得理解而读 # 你有一个头脑。现在让我再假设你有一本想要读的书。这本书是某个人用文字书写的,想要与你沟通一些想法。你要能成功地阅读这本书,完全看你能接获多少作者想要传达的讯息。\n当然,这样说太简单了。因为在你的头脑与书本之间可能会产生两种关系,而不是一种。阅读的时候有两种不同的经验可以象征这两种不同的关系。\n这是书,那是你的头脑。你在阅读一页页的时候,对作者想要说的话不是很了解,就是不了解。如果很了解,你就获得了资讯(但你的理解不一定增强)。如果这本书从头到尾都是你明白的,那么这个作者跟你就是两个头脑却在同一个模子里铸造出来。这本书中的讯息只是将你还没读这本书之前,你们便共同了解的东西传达出来而已。\n让我们来谈谈第二种情况。你并不完全了解这本书。让我们假设—不幸的是并非经常如此—你对这本书的了解程度,刚好让你明白其实你并不了解这本书。你知道这本书要说的东西超过你所了解的,因此认为这本书包含了某些能增进你理解的东西。\n那你该怎么办?你可以把书拿给某个人,你认为他读得比你好的人,请他替你解释看不懂的地方。(“他”可能代表一个人,或是另一本书—导读的书或教科书。)或是你会决定,不值得为任何超越你头脑理解范围之外的书伤脑筋,你理解得已经够多了。不管是上述哪一种状况,你都不是本书所说的真正地在阅读。\n只有一种方式是真正地在阅读。没有任何外力的帮助,你就是要读这本书。你什么都没有,只凭着内心的力量,玩味着眼前的字句,慢慢地提升自己,从只有模糊的概念到更清楚地理解为止。这样的一种提升,是在阅读时的一种脑力活动,也是更高的阅读技巧。这种阅读就是让一本书向你既有的理解力做挑战。\n这样我们就可以粗略地为所谓的阅读艺术下个定义:这是一个凭借着头脑运作,除了玩味读物中的一些字句之外,不假任何外助,以一己之力来提升自我的过程。你的头脑会从粗浅的了解推进到深人的理解。而会产生这种结果的运作技巧,就是由许多不同活动所组合成的阅读的艺术。\n凭着你自己的心智活动努力阅读,从只有粗浅的了解推进到深人的体会,就像是自我的破茧而出。感觉上确实就是如此。这是最主要的作用。当然,这比你以前的阅读方式要多了很多活动,而且不只是有更多的活动,还有要完成这些多元化活动所需要的技巧。除此之外,当然,通常需要比较高难度阅读要求的读物,都有其相对应的价值,以及相对应水平的读者。\n为获得资讯而阅读,与为增进理解而阅读,其间的差异不能以道里计。我们再多谈一些。我们必须要考虑到两种阅读的目的。因为一种是读得懂的东西,另一种是必须要读的东西,二者之间的界限通常是很模糊的。在我们可以让这两种阅读目的区分开来的范围内,我们可以将“阅读”这个词,区分成两种不同的意义。\n第一种意义是我们自己在阅读报纸、杂志,或其他的东西时,凭我们的阅读技巧与聪明才智,一下子便能融会贯通了。这样的读物能增加我们‘的资讯,却不能增进我们的理解力,因为在开始阅读之前,我们的理解力就已经与他们完全相当了。否则,我们一路读下来早就应该被困住或吓住了—这是说如果我们够诚实、够敏感的话。\n第二种意义是一个人试着读某样他一开始并不怎么了解的东西。这个东西的水平就是比阅读的人高上一截。这个作者想要表达的东西,能增进阅读者的理解力。这种双方水准不齐之下的沟通,肯定是会发生的,否则,无论是透过演讲或书本,谁都永远不可能从别人身上学习到东西了。这里的“学习”指的是理解更多的事情,而不是记住更多的资讯—和你已经知道的资讯在同一水平的资讯。\n对一个知识分子来说,要从阅读中获得一些和他原先熟知的事物相类似的新资讯,并不是很困难的事。一个人对美国历史已经知道一些资料,也有一些理解的角度时,他只要用第一种意义上的阅读,就可以获得更多的类似资料,并且继续用原来的角度去理解。但是,假设他阅读的历史书不只是提供给他更多资讯,而且还在他已经知道的资讯当中,给他全新的或更高层次的启发。也就是说,他从中获得的理解超越了他原有的理解。如果他能试着掌握这种更深一层的理解,他就是在做第二种意义的阅读了。他透过阅读的活动间接地提升了自己,当然,不是作者有可以教他的东西也达不到这一点。\n在什么样的状况下,我们会为了增进理解而阅读?有两种状况:第一是一开始时不相等的理解程度。在对一本书的理解力上,作者一定要比读者来得“高杆”,写书时一定要用可读的形式来传达他有而读者所无的洞见。其次,阅读的人一定要把不相等的理解力克服到一定程度之内,虽然不能说全盘了解,但总是要达到与作者相当的程度。一旦达到相同的理解程度,就完成了清楚的沟通。\n简单来说,我们只能从比我们“更高杆”的人身上学习。我们一定要知道他们是谁,如何跟他们学习。有这种想法的人,就是能认知阅读艺术的人,就是我们这本书主要关心的对象。而任何一个可以阅读的人,都有能力用这样的方式来阅读。只要我们努力运用这样的技巧在有益的读物上,每个人都能读得更好,学得更多,毫无例外。\n我们并不想给予读者这样的印象:事实上,运用阅读以增加资讯与洞察力,与运用阅读增长理解力是很容易区分出来的。我们必须承认,有时候光是听别人转述一些讯息,也能增进很多的理解。这里我们想要强调的是:本书是关于阅读的艺术,是为了增强理解力而写的。幸运的是,只要你学会了这一点,为获取资讯而阅读的另一点也就不是问题了。\n当然,除了获取资讯与理解外,阅读还有一些其他的目标,就是娱乐。无论如何,本书不会谈论太多有关娱乐消遣的阅读。那是最没有要求,也不需要太多努力就能做到的事。而且那样的阅读也没有任何规则。任何人只要能阅读,想阅读,就能找一份读物来消遣。\n事实上,任何一本书能增进理解或增加资讯时,也就同时有了消遣的效果。就像一本能够增进我们理解力的书,也可以纯粹只读其中所包含的资讯一样。(这个情况并不是倒过来也成立:并不是每一种拿来消遣的书,都能当作增进我们的理解力来读。)我们也绝不是在鼓励你绝不要阅读任何消遣的书。重点在于,如果你想要读一本有助于增进理解力的好书,那我们是可以帮得上忙的。因此,如果增进理解力是你的目标,我们的主题就是阅读好书的艺术。\n3.阅读就是学习:指导型的学习,以及自我发现型的学习之间的差异 # 吸收资讯是一种学习,同样地,对你以前不了解的事开始理解了,也是一种学习。但是在这两种学习当中,却有很重要的差异。\n所谓吸收资讯,就只是知道某件事发生了。想要被启发,就是要去理解,搞清楚这到底是怎么回事:为什么会发生,与其他的事实有什么关联,有什么类似的情况,同类的差异在哪里等等。\n如果用你记得住什么事情,和你解释得了什么事情之间的差异来说明,就会比较容易明白。如果你记得某个作者所说的话,就是你在阅读中学到了东西。如果他说的都是真的,你甚至学到了有关这个世界的某种知识。但是不管你学到的是有关这本书的知识或有关世界的知识,如果你运用的只是你的记忆力,其实你除了那些讯息之外一无所获。你并没有被启发。要能被启发,除了知道作者所说的话之外,还要明白他的意思,懂得他为什么会这么说。\n当然,你可以同时记得作者所说的话,也能理解他话中的含义。吸收资讯是要被启发的前一个动作。无论如何,重点在不要止于吸收资讯而已。\n蒙田说:“初学者的无知在于未学,而学者的无知在于学后。”第一种的无知是连字母都没学过,当然无法阅读。第二种的无知却是读错了许多书。英国诗人亚历山大·蒲伯(Alexander Pope)称这种人是书呆子,无知的阅读者。总有一些书呆子读得太广,却读不通。希腊人给这种集阅读与愚蠢于一身的人一种特别称呼,这也可运用在任何年纪、好读书却读不懂的人身上。他们就叫“半瓶醋\u0026quot;(Sophomores)。\n要避免这样的错误—以为读得多就是读得好的错误—我们必须要区分出各种不同的阅读形态。这种区分对阅读的本身,以及阅读与一般教育的关系都有很重大的影响。\n在教育史上,人们总是将经由指导的学习,与自我发现的学习区别出来。一个人用言语或文字教导另一个人时,就是一种被引导的学习。当然,没有人教导,我们也可以学习。否则,如果每一位老师都必须要人教导过,才能去教导别人,就不会有求知的开始了。因此,自我发现的学习是必要的—这是经由研究、调查或在无人指导的状况下,自己深思熟虑的一种学习过程。\n自我发现的学习方式就是没有老师指导的方式,而被引导的学习就是要旁人的帮助。不论是哪一种方式,只有真正学习到的人才是主动的学习者。因此,如果说自我发现的学习是主动的,指导性的学习是被动的,很可能会造成谬误。其实,任何学习都不该没有活力,就像任何阅读都不该死气沉沉。\n这是非常真确的道理。事实上,要区分得更清楚一些的话,我们可以称指导型的学习是“辅助型的自我发现学习”。用不着像心理学家作深人的研究,我们也知道教育是非常特殊的艺术,与其他两种学术—农业与医学—一样,都有极为重要的特质。医生努力为病人做许多事,但最终的结论是这个病人必须自己好起来—变得健康起来。农夫为他的植物或动物做了许多事,结果是这些动植物必须长大,变得更好。同样地,老师可能用尽了方法来教学生,学生却必须自己能学习才行。当他学习到了,知识就会在他脑中生根发芽。\n指导型的学习与自我发现型的学习之间的差异—或是我们宁可说是在辅助型,及非辅助型的自我发现学习之间的差异—一个最基本的不同点就在学习者所使用的教材上。当他被指导时—在老师的帮助下自我发现时—学习者的行动立足于传达给他的讯息。他依照教导行事,无论是书写或口头的教导。他学习的方式就是阅读或倾听。在这里要注意阅读与倾听之间的密切关系。如果抛开这两种接收讯息方式之间的微小差异性,我们可以说阅读与倾听是同一种艺术—被教导的艺术。然而,当学习者在没有任何老师指导帮助下开始学习时,学习者则是立足于自然或世界,而不是教导来行动。这种学习的规范就构成了非辅助型的自我发现的学习。如果我们将“阅读”的含义放宽松一点,我们可以说自我发现型的学习—严格来说,非辅助型的自我发现学习—是阅读自我或世界的学习。就像指导型的学习(被教导,或辅助型的学习)是阅读一本书,包括倾听,从讲解中学习的一种艺术。\n那么思考呢?如果“思考”是指运用我们的头脑去增加知识或理解力,如果说自我发现型的学习与指导型的学习是增加知识的惟二法门时,那么思考一定是在这两种学习当中都会出现的东西。在阅读与倾听时我们必须要思考,就像我们在研究时一定要思考。当然,这些思考的方式都不相同—就像两种学习方式之不同。\n为什么许多人认为,比起辅助型学习,思考与非辅助型(或研究型)的自我发现学习更有关联,是因为他们假定阅读与倾听是丝毫不需要花力气的事。比起一个正在作研究发明的人,一个人在阅读资讯或消遣时,确实可能思考得较少一些。而这些都是比较被动的阅读方式。但对比较主动的阅读—努力追求理解力的阅读—来说,这个说法就不太正确了。没有一个这样阅读的人会说,那是丝毫不需要思考就能完成的工作。\n思考只是主动阅读的一部分。一个人还必须运用他的感觉与想像力。一个人必须观察,记忆,在看不到的地方运用想像力。我们要再提一次,这就是在非辅助型的学习中经常想要强调的任务,而在被教导型的阅读,或倾听学习中被遗忘或忽略的过程。譬如许多人会假设一位诗人在写诗的时候一定要运用他的想像力,而他们在读诗时却用不着。简单地说,阅读的艺术包括了所有非辅助型自我发现学习的技巧:敏锐的观察、灵敏可靠的记忆、想像的空间,再者当然就是训练有素的分析、省思能力。这么说的理由在于:阅读也就是一种发现—虽然那是经过帮助,而不是未经帮助的一个过程。\n4.老师的出席与缺席 # 一路谈来,我们似乎把阅读与倾听都当作是向老师学习的方式。在某种程度上,这确实是真的。两种方式都是在被指导,同样都需要被教导的技巧。譬如听一堂课就像读一本书一样,而听人念一首诗就跟亲自读到那首诗是一样的。在本书中所列举的规则跟这些经验都有关。但特别强调阅读的重要性,而将倾听当作第二顺位的考量,有很充分的理由。因为倾听是从一位出现在你眼前的老师学习—一位活生生的老师—而阅读却是跟一位缺席的老师学习。\n如果你问一位活生生的老师一个问题,他可能会回答你。如果你还是不懂他说的话,你可以再问他问题,省下自己思考的时间。然而,如果你问一本书一个问题,你就必须自己回答这个问题。在这样的情况下,这本书就跟自然或世界一样。当你提出间题时,只有等你自己作了思考与分析之后,才会在书本上找到答案。\n当然,这并不是说,如果有一位活生生的老师能回答你的问题,你就用不着再多做功课。如果你问的只是一件简单的事实的陈述,也许如此。但如果你追寻的是一种解释,你就必须去理解它,否则没有人能向你解释清楚。更进一步来说,一位活生生的老师出现在你眼前时,你从了解他所说的话,来提升理解力。而如果一本书就是你的老师的话,你就得一切靠自己了。\n在学校的学生通常会跟着老师或指导者阅读比较困难的书籍。但对我们这些已经不在学校的人来说,当我们试着要读一本既非主修也非选修的书籍时,也就是我们的成人教育要完全依赖书籍本身的时候,我们就不能再有老师的帮助了。因此,如果我们打算继续学习与发现,我们就要懂得如何让书本来教导我们。事实上,这就是本书最主要的目的。\n第二章 阅读的层次 # 在前一章里,我们说明了一些差异性的问题,这对接下来要说的事很重要。一位读者要追求的目标—为了消遣,获得资讯或增进理解力—会决定他阅读的方式。至于阅读的效果则取决于他在阅读上花了多少努力与技巧。一般来说,阅读的规则是:努力越多,效果越好。至少在阅读某些超越我们能力的书时,花一点力气就能让我们从不太了解进升到多一些了解的状态。最后,指导型与自我发现型学习(或辅助型与非辅助型自我发现学习)之间的区别之所以重要,因为我们大多数人在阅读时,都经常是没有人在旁边帮助的。阅读,就像是非辅助型的自我发现学习,是跟着一位缺席的老师在学习。只有当我们知道如何去读时,我们才可能真正读懂。\n虽然这些差异性很重要,但是这一章我们着墨不多。本章所谈的重点在阅读的层次问题。想要增进阅读的技巧之前,一定要先了解阅读层次的不同。\n一共有四种层次的阅读。我们称之为层次,而不称为种类的原因是,严格来说,种类是样样都不相同的,而层次却是再高的层次也包含了较低层次的特性。也就是说,阅读的层次是渐进的。第一层次的阅读并没有在第二层次的阅读中消失,第二层又包含在第三层中,第三层又在第四层中。事实上,第四层是最高的阅读层次,包括了所有的阅读层次,也超过了所有的层次。\n第一层次的阅读,我们称之为基础阅读(elementary reading)。也可以用其他的名称,如初级阅读、基本阅读或初步阅读。不管是哪一种名称,都指出一个人只要熟练这个层次的阅读,就摆脱了文盲的状态,至少已经开始认字了。在熟练这个层次的过程中,一个人可以学习到阅读的基本艺术,接受基础的阅读训练,获得初步的阅读技巧。我们之所以喜欢“基础阅读”这个名称,是因为这个阅读层次的学习通常是在小学时完成的。\n小孩子首先接触的就是这个层次的阅读。他的问题(也是我们开始阅读时的问题)是要如何认出一页中的一个个字。孩子看到的是白纸上的一堆黑色符号(或是黑板上的白色符号—如果他是从黑板上认字的话),而这些黑色符号代表着:“猫坐在帽子上。”一年级的孩子并不真的关心猫是不是坐在帽子上,或是这句话对猫、帽子或整个世界有什么意义。他关心的只是写这句话的人所用的语言。\n在这个层次的阅读中,要问读者的问题是:“这个句子在说什么?”当然,这个问题也有复杂与困难的一面,不过,我们在这里所说的只是最简单的那一面。\n对几乎所有阅读本书的读者来说,这个层次的阅读技巧应该在多年前就早已经学会了。但是,不论我们身为读者有多精通这样的阅读技巧,我们在阅读的时候还是一直会碰上这个层次的阅读问题。譬如,我们打开一本书想读的时候,书中写的却是我们不太熟悉的外国文字,这样的问题就发生了。这时我们要做的第一步努力就是去弄清楚这些字。只有当我们完全明白每个字的意思之后,我们才能试着去了解,努力去体会这些字到底要说的是什么。\n其实就算一本书是用本国语言写的,许多读者仍然会碰上这个阅读层次的各种不同的困难。大部分的困难都是技术性的问题,有些可以追溯到早期阅读教育的问题。克服了这些困难,通常能让我们读得更快一些。因此,大部分的速读课程都着眼在这个层次的阅读上。在下一章我们会详细讨论基础阅读,而速读会在第四章谈到。\n第二个层次的阅读我们称之为检视阅读(inspectional reading)。特点在强调时间。在这个阅读层次,学生必须在规定的时间内完成一项阅读的功课。譬如他可能要用十五分钟读完一本书,或是同样时间内念完两倍厚的书。\n因此,用另一种方式来形容这个层次的阅读,就是在一定的时间之内,抓出一本书的重点—通常是很短,而且总是(就定义上说)过短,很难掌握一本书所有重点。\n这个层次的阅读仍然可以用其他的称呼,譬如略读或预读。我们并不是说略读就是随便或随意浏览一本书。检视阅读是系统化略读(skimming systematically)的一门艺术。\n在这个层次的阅读上,你的目标是从表面去观察这本书,学习到光是书的表象所教给你的一切。这笔交易通常是很划得来的。\n如果第一层次的阅读所问的问题是:“这个句子在说什么?”那么在这个层次要问的典型问题就是:“这本书在谈什么?”这是个表象的问题。还有些类似的问题是:“这本书的架构如何?”或是:“这本书包含哪些部分?”\n用检视阅读读完一本书之后,无论你用了多短的时间,你都该回答得出这样的问题:“这是哪一类的书—小说、历史,还是科学论文?”\n第四章我们还会详细讨论这个层次的阅读,现在就不作进一步的说明了。我们想要强调的是,大多数人,即使是许多优秀的阅读者,都忽略了检视阅读的价值。他们打开一本书,从第一页开始读起,孜孜不倦,甚至连目录都不看一眼。因此,他们在只需要粗浅翻阅一本书的时候,却拿出了仔细阅读、理解一本书的时间。这就加重了阅读的困难。\n第三种层次的阅读,我们称之为分析阅读(analytical reading)。比起前面所说的两种阅读,这要更复杂,更系统化。随内文难读的程度有所不同,读者在使用这种阅读法的时候,多少会相当吃力。\n分析阅读就是全盘的阅读、完整的阅读,或是说优质的阅读—你能做到的最好的阅读方式。如果说检视阅读是在有限的时间内,最好也最完整的阅读,那么分析阅读就是在无限的时间里,最好也最完整的阅读。\n一个分析型的阅读者一定会对自己所读的东西提出许多有系统的问题。我们并不想在这里强调这个问题,因为本书主要就是在谈这个层次的阅读:本书的第二篇就是告诉你如何这么做的一些规则。我们要在这里强调的是,分析阅读永远是一种专注的活动。在这个层次的阅读中,读者会紧抓住一本书—这个比喻蛮恰当的—一直要读到这本书成为他自己为止。弗兰西斯·培根曾经说过:“有些书可以浅尝即止,有些书是要生吞活剥,只有少数的书是要咀嚼与消化的。”分析阅读就是要咀嚼与消化一本书。\n我们还要强调的是,如果你的目标只是获得资讯或消遣,就完全没有必要用到分析阅读。分析阅读就是特别在追寻理解的。相对的,除非你有相当程度的分析阅读的技巧,否则你也很难从对一本书不甚了解,进步到多一点的理解。\n第四种,也是最高层次的阅读,我们称之为主题阅读(syntopicalreading)。这是所有阅读中最复杂也最系统化的阅读。对阅读者来说,要求也非常多,就算他所阅读的是一本很简单、很容易懂的书也一样。\n也可以用另外的名称来形容这样的阅读,如比较阅读(comparative reading)。在做主题阅读时,阅读者会读很多书,而不是一本书,并列举出这些书之间相关之处,提出一个所有的书都谈到的主题。但只是书本字里行间的比较还不够。主题阅读涉及的远不止此。借助他所阅读的书籍,主题阅读者要能够架构出一个可能在哪一本书里都没提过的主题分析。因此,很显然的,主题阅读是最主动、也最花力气的一种阅读。\n我们会在第四篇讨论主题阅读。此刻我们只粗浅地说,主题阅读不是个轻松的阅读艺术,规则也并不广为人知。虽然如此,主题阅读却可能是所有阅读活动中最有收获的。就是因为你会获益良多,所以绝对值得你努力学习如何做到这样的阅读。\n第三章 阅读的第一个层次:基础阅读 # 我们生活在对阅读有很高的兴趣与关心的年代。官方宣称1970年代是“读书的年代”。畅销书告诉我们为什么强尼会念书或不会念书。在初步阅读的教学领域中,也有越来越多的人在作研究与实验。\n我们的年代会产生这样的狂热,是因为三个历史性的趋势或演变刚好聚合起来了。第一是美国在继续推行全民教育,这就是说,当然,最少要做到全国没有文盲。多年来美国一直在作这样的努力,甚至从国家草创时期就开始,成为民主生活的基石,而且也成果显著。美国比任何其他国家都更早达到接近全民教育,因而也帮助美国成为今天高度开发的现代工业化社会。但是其中也产生了许多问题。总括而言,要教育少数具有高度学习动机的孩子阅读(通常他们的父母都是知识分子),和教育一些不管动机有多微弱,或家庭有多贫困的孩子阅读,是完全不同的两码事—一百年前如此,今天依然如此。\n第二个历史趋向是阅读教育的本身起了变化。迟至1870年,大家所受的阅读教育,跟早期希腊或罗马学校没什么两样。在美国,至少所谓的ABC教学法仍然掌控了整个19世纪。孩子要学着分别以每一个字母来发音—这也是这个教学法名称的由来—然后再组合成音节,先是第一、二个字母,再来是三跟四,而不管这样拼出来的字是否有意义。因此,那些想要精通语言的人,就会勤练像是ab,ac,ad,ib,ic这样的音节。当一个孩子能记住所有组合的音节时,他就可以说是懂得ABC了。\n这样的阅读教学法在19世纪中叶受到严厉的批评,于是产生了两种变革。一种是ABC教学法的改变,变成了发音法(phonic method)。这样,认字不是由字母来认,而是由发音来辨识了。为了呈现某个字母所代表的各种发音,尤其是母音,得动用许多复杂又独创的印刷技术。如果你已经五十岁以上,在学校里所学的很可能就是这一类的发音法。\n另外有一种完全不同,着重分析,而非人为的教学法。起源于德国,由霍拉斯·曼(Horace Mann)与其他的教育专家在1840年所提倡。这个教学法强调在注意到每一个字母或发音之前,先以视觉认知整个单字。后来,这种所谓的视觉法(sight method)先看整个句子与其中的含义,然后才学习认识单字,最后才是字母。这种方法在19201930年间非常盛行,那段时期也正是强调从口语阅读转变成默读的转变时期。研究发现,口语阅读的能力在默读时并非必要,因此如果是以默读为目标的话,口语阅读的教学法也不一定适用了。因此,从1920-1925年,默读理解的阅读法几乎成为一家独尊的潮流。不过,后来潮流又转向了,发音法又受到了重视—事实上,发音法从来没有遭到过淘汰。\n所有这些不同的基础阅读教学法,对某些学生来说很有用,对另外一些学生却可能不管用。在过去的二三十年中,失败的案例总是引起更多的注意。结果第三次历史性的变动又兴起了。在美国,批判学校是一种传统。许多世纪以来,父母、自命专家的人与教育者都在攻击与控诉教育系统。在对学校所有的批评中,阅读教育受到最严厉的批评。现在所使用的教科书已经有长长的世系背景,而每次革新,都会带来一堆怀疑论者,与一些很难说服的观察者。\n这些批评可能对,也可能不对。但是,不论如何,随着全民教育进入新的一页,高中和大专学生日益增多,问题也呈现了新的尖锐面貌。一个不懂得如何阅读的年轻男子或年轻女子,在他追求美国梦的途中就会受到阻碍。如果他不在学校里,那主要是他个人的大问题。但如果他还在高中或大专求学,那就会成为他的老师和同学都关心的问题。\n因此,目前教育研究者非常活跃,他们的工作成果表现在许多新的阅读教学法上。在一些比较重要的新教学法中,包括了折衷教学法(eclectic approach),个别阅读教学法( individualized reading ap-proach)、语言经验教学法(language-experience approach),许多根据语言学原则而来的教学法,以及其他一些和某种特定教育计划多少挂钩的教学法。除此之外,一些新的媒介,如初期教学字母(InitialTeaching Alphabet)也被引进,有时候其中又包含了新的教学法。另外还有一些教学法如“全神贯注教学法,,(total immersion method)、“外国语言学校教法\u0026quot;(foreign-language-school method),以及众所周知的“看说\u0026quot;(see-say)、“看与说\u0026quot;(look-say)或“看到就说”(look-and-say)等等。毫无疑问,这些教学法都被实验证明各有巧妙之处。要判断哪一种方法才是解决所有阅读问题的万能妙药,可能还言之过早。\n1.学习阅读的阶段 # 最近有一项非常有用的研究,就是分析学习阅读的阶段。现在大家都广泛接受了这样的观念:在儿童具备纯熟的阅读能力之前,至少会经历大约四个截然不同的阶段。第一个阶段被称为“阅读准备阶段”(reading readiness)。专家指出,这一阶段从出生开始,直到六七岁为止。\n阅读准备阶段包括了几种不同的学习阅读的准备工作。身体方面的准备,包括良好的视力与听力。智力方面的准备是要有起码的认知能力,以便孩子能吸收与记住一个字,与组成这个字的字母。语言上的准备包括口齿清晰,能说出一些正确的句子。个人的准备,则包括能与其他孩童一起学习的能力,保持注意力,服从等等。\n阅读准备的总体是否成熟,要由测验来评定,也可以由一些经验丰富、眼光敏锐、很懂得判断小学生是否可以开始学习阅读的老师来作评估。最重要的是要记得,三级跳的做法通常会造成失败。一个孩子如果还没准备好就要教他阅读,他可能会不喜欢这样的学习经验,以后的学校教育甚至成人阶段都会受到影响。尽管有些父母会担心他们的孩子“反应迟钝”或“跟不上”同龄的孩子,超过阅读准备阶段,延后接受阅读指导,其实并不是太严重的事。\n在第二个阶段,孩子会学习读一些简单的读物。至少在美国,阅读的开始是一些看图识字。第一年结束时,基本上会认识三百到四百个字。这个时期会介绍一些基本的技巧,像字句的使用,词句的含意,字句的发音等等。这个阶段要结束时,小学生应该就能自己阅读简单的书,而且很喜欢阅读了。\n在这个阶段中,还有些附带的事情值得观察。那是在这个阶段发生的一些非常神秘,有点像是魔术一样的事情。在一个孩子发展过程中的某个时刻,面对着书本上一连串的符号,他会觉得毫无意义。但过不了多久—可能只是两三周之后—他却明白这些符号的意义了。他知道这是在说:“猫坐在帽子上。”不论哲学家与心理学家花了超过二千五百年的时间来研究这个奇迹,还是没有人真的知道这是怎么发生的。这些字的意义是从何而来的?法国的小孩是如何读懂“Le chatAasseyait sur le chapean”(猫坐在帽子上)的?事实上,懂得发现一些符号的意义,是人类所表现出的最惊人的聪明技巧,而大多数人在七岁以前就已经表现出来这样的智能了。\n第三个阶段的特征是快速建立字汇的能力,所用的方法是从上下文所提供的线索,“揭发”不熟悉的字眼。除此之外,孩子在这个阶段会学会不同目标与不同领域的阅读祛,像科学、社会学、语言艺术等等。他们学习到除了在学校之外,阅读还是一项可以自己来做的事—他们可以因为好玩、满足好奇心,或只是要“扩大视野”而阅读。\n最后,第四个阶段的特征是精练与增进前面所学的技巧。最重要的是,学生开始能消化他的阅读经验—从一本书所提出来的一个观点转化到另一个观点,在同一个主题上,对不同的作者所提出来的观点作比较。这是阅读的成熟阶段,应该是一个青少年就该达到的境界,也是终其一生都该持续下去的。\n但是对许多父母与教育者来说,显然孩子们并没有达到这样的目标。失败的原因很多,范围也很广,从被剥夺的家庭环境—经济、社会,或是智能(包括双亲是文盲)—到个人的各种问题(包括对整个“体制”的反抗)都有。但是其中有一个失败的原因却不常被注意到。过分强调阅读的准备阶段,过分注重教导孩子初步阅读的方法,往往意味着其他更高层次的阅读可能遭到忽视。这是很可以理解的,想想在第一个层次所可能碰到的各种紧急状况与问题的程度就会明白了。然而,除非我们在所有的阅读层次都投下努力,否则我们社会里有关阅读的整体问题是不可能有效地解决的。\n2.阅读的阶段与层次 # 我们已经形容过阅读的四个层次,也以很基础的方式列举了学习阅读的四个阶段。这些层次与阶段之间,到底有什么样的关联呢?\n最重要的是,这里所列举的四个阶段,都属于我们在前一章所谈的、第一个层次的阅读。这些阶段,都是基础阅读,对区分小学教育中的课程很有帮助。基础阅读的第一个阶段—阅读准备阶段—相当于学前教育或幼稚园的学习经验。第二阶段—认字—相当于一年级学生典型的学习经验(尽管相当多正常的孩子在某方面来说并非都很“典型,\u0026rsquo;)。这个阶段的成果是,孩子学会了我们称之为第二阶段的阅读技巧,或是一年级的阅读能力,或最初级的读写能力。基础阅读的第三个阶段—字汇的增长及对课文的运用—通常是(但非全面性,就算正常孩子也一样)在四年级结束时就学会的方法,这个阶段的成果可以称作是“四年级读写能力\u0026quot;(fourth grade literacy)或是“功能性读写能力\u0026quot;(functional literacy)也就是有能力很轻易地阅读交通号志,或图片说明,填写政府的有关简单表格等等。基础阅读的第四个阶段,也就是最后一个阶段,到这个时期,学生要从小学或初中毕业了。这个阶段有时候称之为八年级、九年级或十年级的读写能力。在某方面来说,这个孩子已经是一个“成熟”的阅读者,他几乎可以阅读所有的读物了,但是却还不够老练。简单来说,他的成熟度是可以上高中的课程了。\n无论如何,他还不是我们这本书中所说的“成熟的”阅读者。但他已经精通第一层次的阅读,如此而已。他可以自己阅读,也准备好要学习更多的阅读技巧。但是他还是不清楚要如何超越基础阅读,做更进一步的阅读。\n我们提到这些,是因为这跟本书要传达的讯息有密切的关系。我们假设,我们也必须假设你—我们的读者—已经有九年级的读写能力,也熟练了基础阅读,换句话说,你已经成功地通过我们所形容的四个阅读阶段。如果你想到这一点,就会了解我们的假设并不离谱。除非一个人能阅读,否则没有人能从一本教他如何如何的书中学到东西。特别就一本教人如何阅读的书来说,它的读者必须有某种程度的阅读能力才行。\n辅助型与非辅助型自我发现阅读的区别,在这里就有了关联。一般来说,基础阅读的四个阶段都有一位老师在旁指导。当然,每个孩子的能力并不相同,有些人需要比别人多一点的帮助。不过,在基础教育的几年当中,通常都会有一位老师出现在课堂,回答问题,消除在这个阶段会出现的难题。只有当一个孩子精通了基础阅读的四个阶段,才是他准备好往更高层次的阅读迈进的时候。只有当他能自己阅读时,才能够自己开始学习。也只有这样,他才能变成一个真正优秀的阅读者。\n3.更高层次的阅读与高等教育 # 传统上,美国的高中教育只为学生提供一点点阅读的指导,至于大学,更是一无所有。最近几年来,情况已经有点改变了。大约两个世代以前,高中登记人学的人数在短期内大量增加,教育者也开始觉察到,不能再假设所有的学生都能做到有效的阅读。矫正阅读的指导教育因此出现,不时有高达75%以上的学生需要矫正。在最近的十年当中,大学又发生同样的状况。譬如在1971年秋季,大约四万名新人进人纽约市立大学,却有高达一半,也就是超过二万名年轻人需要接受某种阅读训练的矫正课程。\n无论如何,这并不表示这些年来,许多美国大学都提供了基础阅读以上的教育指导课程。事实上,几乎可以说是完全没有。在更高层次的阅读中,矫正阅读的指导并不算指导。矫正阅读指导,只是要把学生带到一个他在小学毕业的时候所该具备的阅读能力程度。直到今天,大多数高等教育的指导者不是仍然不知道要如何指导学生超越基础阅读的层次,就是缺乏设备与人才来做这样的事。\n尽管最近一些四年大学或技术学院设立了速读,或“有效阅读法”,或“竞读”之类的课程,我们还是可以如此主张的。大体来说(虽然也有些例外),这些都是矫正阅读的课程。但这些课程都是为了克服初级教育的失败而设计的。这些课程不是为了要学生超越第一层次的阅读而设计的。也并不是在指导他们进人本书所主要强调的阅读层次与领域。\n当然,正常情况应该不是这样的。一个人文素养优良的高中,就算什么也没做,也该培养出能达到分析阅读的读者。一个优秀的大学,就算什么也没贡献,也该培育出能进行主题阅读的读者。大学的文凭应该代表着一般大学毕业生的阅读水平,不但能够阅读任何一种普通的资料,还能针对任何一种主题做个人的研究(这就是在所有阅读中,主题阅读能让你做到的事)。然而,通常大学生要在毕业以后,再读三四年的时间才能达到这样的程度,并且还不见得一定达到。\n一个人不应该花四年的时间留在研究所中只是为了学习如何阅读。四年研究所时间,再加上十二年的中、小学教育,四年的大学教育,总共加起来是整整二十年的学校教育。其实不该花这么长的时间来学习如何阅读。如果真是如此,这中间必然出了大问题。\n事情错了,可以改正。许多高中与大学可以依照本书所提供的方法来安排课程。我们所提供的方法并不神秘,甚至也并非新创。大多数只是普通常识而已。\n4.阅读与民主教育的理念 # 我们并不只想做个吹毛求疵的批评家。我们知道,不论我们要传达的讯息多么有道理,只要碰到成千上万的新人在学校的楼梯上踩得砰砰作响时,就什么也听不见了。看到这批新生当中有相当大的比率,或是大多数的人都无法达到有效阅读的基础水平时,我们应该警觉,当务之急是必须从最低层次的、最小公约数的阅读教起。\n甚至,此刻我们也不想提是否需要另一种教育方式了。我们的历史一直强调,无限制的受教育机会是一个社会能提供给人民最有价值的服务—或说得正确一点,只有当一个人的自我期许,能力与需要受限制时,教育机会才会受到限制。我们还没有办法提供这种机会之前,不表示我们就有理由要放弃尝试。-\n但是我们—包括学生、老师与门外汉等—也要明白:就算我们完成了眼前的任务,仍然还没有完成整个工作。我们一定要比一个人人识字的国家更进一步。我们的国人应该变成一个个真正“有能力”的阅读者,能够真正认知“有能力”这个字眼中的涵义。达不到这样的境界,我们就无法应付未来世界的需求。\n第四章 阅读的第二个层次:检视阅读 # 检视阅读,才算是真正进人阅读的层次。这和前一个层次(基础阅读)相当不同,也跟自然而来的下一个层次(分析阅读)大有差异。但是,就像我们在第二章所强调的,阅读的层次是渐进累积的。因此,基础阅读是包含在检视阅读中的,而事实上,检视阅读又包含在分析阅读中,分析阅读则包含在主题阅读中。\n事实上,除非你能精通基础阅读,否则你没法进人检视阅读的层次。你在阅读一位作者的作品时要相当顺手,用不着停下来检查许多生字的意思,也不会被文法或文章结构阻碍住。虽然不见得要每句每字都读得透彻,但你已经能掌握主要句子与章节的意义了。\n那么,检视阅读中究竟包含了些什么?你要怎样才能培养检视阅读的能力呢?\n首先要理解的是,检视阅读一共有两种。本来这是一体两面的事,但是对一个刚起步的阅读者来说,最好是将两者区别为不同的步骤与活动。有经验的阅读者已经学会同时运用两种步骤,不过此刻,我们还是将二者完全区分开来。\n1.检视阅读一:有系统的略读或粗读 # 让我们回到前面曾经提过的一些基本状态。这是一本书,或任何读物,而那是你的头脑。你会做的第一件事是什么?\n让我们再假设在这情况中还有两个相当常见的因素。第一,你并不知道自己想不想读这本书。你也不知道这本书是否值得做分析阅读。但你觉得,或只要你能挖掘出来,书中的资讯及观点就起码会对你有用处。\n其次,让我们假设—常会有这样的状况—你想要发掘所有的东西,但时间却很有限。\n在这样的情况下,你一定要做的就是“略读”(skim)整本书,或是有人说成是粗读(pre-read)一样。略读或粗读是检视阅读的第一个子层次。你脑中的目标是要发现这本书值不值得多花时间仔细阅读。其次,就算你决定了不再多花时间仔细阅读这本书,略读也能告诉你许多跟这本书有关的事。\n用这种快速浏览的方式来阅读一本书,就像是一个打谷的过程,能帮助你从糙糠中过滤出真正营养的谷核。当你浏览过后,你可能会发现这本书仅只是对你目前有用而已。这本书的价值不过如此而已。但至少你知道作者重要的主张是什么了,或是他到底写的是怎样的一本书。因此,你花在略读这本书上的时间绝没有浪费。\n略读的习惯应该用不着花太多时间。下面是要如何去做的一些建议:\n(1)先看书名页,然后如果有序就先看序。要很快地看过去。特别注意副标题,或其他的相关说明或宗旨,或是作者写作本书的特殊角度。在完成这个步骤之前,你对这本书的主题已经有概念了。如果你愿意,你会暂停一下,在你脑海中将这本书归类为某个特定的类型。而在那个类型中,已经包含了哪些书。\n(2)研究目录页,对这本书的基本架构做概括性的理解。这就像是在出发旅行之前,要先看一下地图一样。很惊讶的是,除非是真的要用到那本书了,许多人连目录页是看都不看一眼的。事实上,许多作者花了很多时间来创作目录页,想到这些努力往往都浪费了,不免让人伤心。\n通常,一本书,特别是一些论说性的书都会有目录,但是有时小说或诗集也会写上一整页的纲要目录,分卷分章之后再加许多小节的副标,以说明题旨。譬如写作《失乐园》(Paradise Lost)的时候,弥尔顿(John Milton)为每一章都写了很长的标题,或他所称的“要旨”(arguments)。吉朋(Edward Gibbon)出版的《罗马帝国衰亡史)) (Declineand Fall of the Roman Empire),为每一章都写了很长的分析性纲要。目前,虽然偶尔你还会看到一些分析性的纲要目录,但已经不普遍了。这种现象衰退的原因是,一般人似乎不再像以前一样喜欢阅读目录纲要了。同时,比起一本目录完全开诚布公的书,出版商也觉得越少揭露内容纲要,对读者越有吸引力。至于阅读者,他们觉得,一本书的章节标题有几分神秘性会更有吸引力—他们会想要阅读这本书以发现那些章节到底写了些什么。虽然如此,目录纲要还是很有价值的,在你开始阅读整本书之前,你应该先仔细阅读目录才对。\n谈到这里,如果你还没看过本书的目录页,你可能会想翻回去看一下了,我们尽可能地将目录页写得完整又说明清楚。检视一下这个目录页,你就会明白我们想要做的是什么了。\n(3)如果书中附有索引,也要检阅一下—大多数论说类的书籍都会有索引。快速评估一下这本书涵盖了哪些议题的范围,以及所提到的书籍种类与作者等等。如果你发现列举出来的哪一条词汇很重要,至少要看一下引用到这个词目的某几页内文。(我们会在第二部谈到词汇的重要问题。暂时你必须先依靠自己的常识,根据前面所提的第一及第二步骤,判别出一本书里你认为重要的词汇。)你所阅读的段落很可能就是个要点—这本书的关键点—或是关系到作者意图与态度的新方法。\n就跟目录页一样,现在你可能要检查一下本书的索引。你会辨认出一些我们已经讨论过的重要词目。那你能不能再找出其他一些也很重要的词目呢?—譬如说,参考一下词目底下所列被引用页数的多寡?\n(4)如果那是本包着书衣的新书,不妨读一下出版者的介绍。许多人对广告文案的印象无非是些吹牛夸张的文字。但这往往失之偏颇,尤其是一些论说性的作品更是如此,大致来说,许多书的宣传文案都是作者在出版公司企宣部门的协助下亲自写就的。这些作者尽力将书中的主旨正确地摘要出来,已经不是稀奇的事了。这些努力不应该被忽视。当然,如果宣传文案什么重点也没写到,只是在瞎吹牛,你也可以很容易看穿。不过,这也有助于你对这本书多一点了解,或许这本书根本没什么重要的东西可谈—而这也正是他们宣传文案一无可取的原因。\n完成这四个步骤,你对一本书已经有足够的资讯,让你判断是想要更仔细地读这本书,还是根本不想读下去了。不管是哪一种情况,现在你都可能会先将这本书放在一边一阵子。如果不是的话,现在你就准备好要真正地略读一本书了。\n(5)从你对一本书的目录很概略,甚至有点模糊的印象当中,开始挑几个看来跟主题息息相关的篇章来看。如果这些篇章在开头或结尾有摘要说明(很多会有),就要仔细地阅读这些说明。\n(6)最后一步,把书打开来,东翻翻西翻翻,念个一两段.有时候连续读几页,但不要太多。就用这样的方法把全书翻过一遍,随时寻找主要论点的讯号,留意主题的基本脉动。最重要的是,不要忽略最后的两三页。就算最后有后记,一本书最后结尾的两三页也还是不可忽视的。很少有作者能拒绝这样的诱惑,而不在结尾几页将自己认为既新又重要的观点重新整理一遍的。虽然有时候作者自己的看法不一定正确,但你不应该错过这个部分。\n现在你已经很有系统地略读过一本书了。你已经完成了第一种型态的检视阅读。现在,在花了几分钟,最多不过一小时的时间里,你对这本书已经了解很多了。尤其,你应该了解这本书是否包含你还想继续挖掘下去的内容,是否值得你再继续投下时间与注意?你也应该比以前更清楚,在脑海中这本书该归类为哪一个种类,以便将来有需要时好作参考。\n附带一提的是,这是一种非常主动的阅读。一个人如果不够灵活,不能够集中精神来阅读,就没法进行检视阅读。有多少次你在看一本好书的时候,翻了好几页,脑海却陷入了白日梦的状态中,等清醒过来,竟完全不明白自己刚看的那几页在说些什么?如果你跟随着我们提议的步骤来做,就绝不会发生这样的事—因为你始终有一个可以依循作者思路的系统了。\n你可以把自己想成是一个侦探,在找寻一本书的主题或思想的线索。随时保持敏感,就很容易让一切状况清楚。留意我们所提出的建议,会帮助你保持这样的态度。你会很惊讶地发现自己节省了更多时间,高兴自己掌握了更多重点,然后轻松地发现原来阅读是比想像中还更要简单的一件事。\n2.检视阅读二:粗浅的阅读 # 这一节的标题是故意要挑衅的。“粗浅”这两个字通常有负面的联想。但我们可是很认真在用这两个字。\n我们每个人都有这样的经验:对一本难读的书抱着高度的期望,以为它能启发我们,结果却只是在徒劳无益地挣扎而已。很自然的,我们会下个结论:一开始想读这本书就是个错误。但这并不是错误,而只是打从开始就对阅读一本难读的书期望过高。只要找到对的方向,不论是多难读的书,只要原来就是想写给大众读者看的,那就不该有望之却步的理由。\n什么叫对的方向?答案是一个很重要又有帮助的阅读规则,但却经常被忽略。这个规则很简单:头一次面对一本难读的书的时候,从头到尾先读完一遍,碰到不懂的地方不要停下来查询或思索。\n只注意你能理解的部分,不要为一些没法立即了解的东西而停顿。继续读下去,略过那些不懂的部分,很快你会读到你看得懂的地方。集中精神在这个部分。继续这样读下去。将全书读完,不要被一个看不懂的章节、注解、评论或参考资料阻挠或泄气。如果你让自己被困住了,如果你容许自己被某个顽固的段落绑住了,你就是被打败了。在大多数情况里,你一旦和它纠缠,就很难脱困而出。在读第二遍的时候,你对那个地方的了解可能会多一些,但是在那之前,你必须至少将这本书先从头到尾读一遍才行。\n你从头到尾读了一遍之后的了解—就算只有50%或更少—能帮助你在后来重读第一次略过的部分时,增进理解。就算你不重读,对一本难度很高的书了解了一半,也比什么都不了解来得要好些—如果你让自己在一碰上困难的地方就停住,最后就可能对这本书真的一无所知了。\n我们大多数人所受的教育,都说是要去注意那些我们不懂的地方。我们被教导说,碰到生字,就去查字典。我们被教导说,读到一些不明白的隐喻或论说,就去查百科全书或其他相关资料。我们被教导说,要去查注脚、学者的注释或其他的二手资料以获得帮助。但是如果时候不到就做这些事,却只会妨碍我们的阅读,而非帮助。\n譬如,阅读莎士比亚的戏剧,会获得极大的快乐。但是一代代的高中生被逼着要一幕一幕地念、一个生字接一个生字地查、一个学者注脚接一个注脚地读《裘利斯·凯撒)(Julius Caesar)、《皆大欢喜))(As YouLike It)或《哈姆雷特》(Hamlet),这种快乐就被破坏了。结果是他们从来没有真正读过莎士比亚的剧本。等他们读到最后的时候,已经忘了开始是什么,也无法洞察全剧的意义了。与其强迫他们接受这种装模作样的做学问的读法,不如鼓励他们一次读完全剧,然后讨论他们在第一次快速阅读中所获得的东西。只有这样,他们才算是做好接下来仔细又专心研究这个剧本的准备。因为他们已经有了相当的了解,可以准备再学一点新的东西了。\n这个规则也适用于论说性的作品。事实上,第一次看这样一本书的时候要粗浅阅读的这个规则,在你违反的时候正可以不证自明。拿一本经济学的基础书来说吧,譬如亚当·斯密(Adam Smith)的经典作品《国富论》(The Wealth of Nations)(我们会选这一本做例子,因为这不光只是一本教科书,或是为经济学家写的书,这也是一本为一般读者所写的书),如果你坚持要了解每一页的意义,才肯再往下读,那你一定读不了多少。在你努力去了解那些细微的重点时,就会错过斯密说得那么清楚的一些大原则:关于成本中包含的薪水、租金、利润与利息种种因素,市场在定价中的角色,垄断专卖的害处,自由贸易的理由等等。这样你在任何层次的阅读都不可能很好。\n3.阅读的速度 # 在第二章,我们谈过检视阅读是一种在有限的时间当中,充分了解一本书的艺术。本章我们要更进一步谈这件事,没有理由去改变这个定义。检视阅读的两个方式都需要快速地阅读。一个熟练的检视阅读者想要读一本书时,不论碰到多难读或多长的书,都能够很快地运用这两种方式读完。\n关于这个方式的定义,不可避免地一定会引起一个问题:那么速读又算什么呢?现在不论是商业界或学术界都有速读的课程,那么在阅读的层次与众多速读课程之间有什么关联呢?\n我们已经谈过那些课程基本上是为了矫正用的—因为他们所提供的就算不是全部,也主要都是基础阅读层次的指导。不过这要再多谈一点。\n首先我们要了解的是,我们都同意,大多数人应该有能力比他们现在读的速度还更快一点。更何况有很多东西根本不值得我们花那么多时间来读。如果我们不能读快一点,简直就是在浪费时间。的确没错,许多人阅读的速度太慢,应该要读快一点。但是,也有很多人读得太快了,应该要把速度放慢才行。一个很好的速读课程应该要教你不同的阅读速度,而不是一味求快,而忽略了你目前能掌握的程度。应该是依照读物的性质与复杂程度,而让你用不同的速度来阅读。\n我们的重点真的很简单。许多书其实是连略读都不值得的,另外一些书只需要快速读过就行了。有少数的书需要用某种速度,通常是相当慢的速度,才能完全理解。一本只需要快速阅读的书却用很慢的速度来读,就是在浪费时间,这时速读的技巧就能帮你解决问题。但这只是阅读问题中的一种而已。要了解一本难读的书,其间的障碍,非一般所谓生理或心理障碍所能比拟甚或涵盖。会有这些障碍,主要是因为阅读者在面对一本困难—值得读—的书时,完全不知道如何是好。他不知道阅读的规则,也不懂得运用心智的力量来做这件事。不论他读得多快,也不会获得更多,因为事实上,他根本不知道自己在寻找什么,就算找到了,也不清楚是不是自己想要的东西。\n所谓阅读速度,理想上来说,不只是要能读得快,还要能用不同的速度来阅读—要知道什么时候用什么样的速度是恰当的。检视阅读是一种训练有素的快速阅读,但这不只是因为你读的速度快—虽然你真的读得很快—而是因为在检视阅读时,你只读书中的一小部分,而且是用不同的方式来读,不一样的目标来读。分析阅读通常比检视阅读来得慢一些,但就算你拿到一本书要做分析阅读,也不该用同样的速度读完全书。每一本书,不论是多么难读的书,在无关紧要的间隙部分就可以读快一点。而一本好书,总会包含一些比较困难,应该慢慢阅读的内容。\n4.逗留与倒退 # 半个多世纪以来,速读的课程让我们有了一个最重大的发现:许多人会从最初学会阅读之后,多年一直使用“半出声”(sub-vocalize)的方式来阅读。此外,拍摄下来的眼睛在活动时的影片,显示年轻或未受过训练的阅读者,在阅读一行字的时候会在五六个地方发生“逗留”(fix-ate)现象。(眼睛在移动时看不见,只有停下来时才能看见。)因此,他们在读这一行字的时候,只能间隔着看到一个个单字或最多两三个字的组合。更糟的是,这些不熟练的阅读者在每看过两三行之后,眼睛就自然地“倒退”(regress)到原点—也就是说,他们又会倒退到先前读过的句子与那一行去了。\n所有这些习惯不但浪费而且显然降低了阅读的速度。之所以说是浪费,因为我们的头脑跟眼睛不一样,并不需要一次只“读”一个字或一个句子。我们的头脑是个惊人的工具,可以在“一瞥”之间掌握住一个句子或段落—只要眼睛能提供足够的资讯。因此,主要的课题—所有的速读课程都需要认知这一点—就是要矫正许多人在阅读时会“逗留”,会“倒退”,因而使他们的速度慢下来的习惯。幸运的是,要矫正这样的习惯还蛮容易的。一旦矫正过了,学生就能跟着脑部运作的快速度来阅读,而不是跟着眼部的慢动作来阅读了。\n要矫正眼睛逗留于一点的工具有很多种,有些很复杂又很昂贵。无论如何,任何复杂的工具其实都比不上你的一双手来得有用,你可以利用双手训练自己的眼睛,跟着章节段落移动得越来越快。你可以自己做这样的训练:将大拇指与食指、中指合并在一起,用这个“指针”顺着一行一行的字移动下去,速度要比你眼睛感觉的还要快一点。强迫自己的眼睛跟着手部的动作移动。一旦你的眼睛能跟着手移动时,你就能读到那些字句了。继续练习下去,继续增快手的动作,等到你发觉以前,你的速度已经可以比以前快两三倍了。\n5.理解的问题 # 不过,在你明显地增进了阅读的速度之后,你到底获得了什么呢?没错,你是省下了一些时间,但是理解力(comprehension)呢?同样地增进了,还是在这样的进展中一无所获?\n.就我们所知,没有一种速读课程不是声明在阅读速度加快时,理解力也同时增进。整体来说,这样的声明确实是有点根据的。我们的手(或其他工具)就像是个计时器,不只负责增进你的阅读速度,也能帮助你专注于你所阅读的东西上。一旦你能跟随自己的手指时,就很难打磕睡或做白日梦,胡思乱想。到目前为止,一切都很不错。专心一致也就是主动阅读的另一种称呼。一个优秀的阅读者就是读得很主动,很专心。\n但是专心并不一定等于理解力—如果大家对“理解力”并没有误解的话。理解力,是比回答书本内容一些简单问题还要多一点的东西。那种有限的理解力,不过是小学生回答“这是在说什么?\u0026lsquo;\u0026lsquo;之类问题的程度而已。一个读者要能够正确地回答许多更进一步的问题,才表示有更高一层的理解力,而这是速读课程所不要求的东西,也几乎没有人指导要如何回答这类的问题。\n为了说得更清楚一些,用一篇文章来做例子。我们用《独立宣言》为例。你手边可能有这篇文章,不妨拿出来看看。这篇文章印出来还不到三页的篇幅。你能多快读完全文?\n《独立宣言》的第二段结尾写着:“为了证明这一点,提供给这个公正的世界一些事实吧。”接下来的两页是一些“事实”。看起来有些部分似乎还蛮可疑的,不妨快一点读完。我们没有必要去深入了解杰佛逊所引述的事实到底是些什么,当然,除非你是个学者,非常在意他所写的历史环境背景如何,自然又另当别论。就算是最后一段,结尾是著名的公正的声明,几位歌者“互诵我们的生命,财富与神圣的荣耀。”这也可以快快地读过。那是一些修辞学上的华丽词藻,就只值得在修辞学上的注意力。但是,读《独立宣言》的前两段,需要的却绝不只是快速地阅读一遍。\n我们怀疑有人能以超过一分钟二十个字的速度来阅读前两段文字。的确,在著名的第二段里的一些字句,如“不可剥夺的”、“权利”、“自由”、“幸福”、“同意”、“正义的力量”,值得再三玩味、推敲、沉思。要完全了解《独立宣言》的前两段,正确的读法是需要花上几天,几星期,甚至好几年的时间。\n这么说来,速读的问题就出在理解力上。事实上,这里所谓的理解力是超越基础阅读层次以上的理解力,也是造成问题的根源。大多数的速读课程都没有包括这方面的指导。因此,有一点值得在这里强调的是,本书之所以想要改进的,正是这一种阅读的理解力。没有经过分析阅读,你就没法理解一本书。正如我们前面所言,分析阅读,是想要理解(或了解)一本书的基本要件。\n6.检视阅读的摘要 # 以下简短的几句话是本章的摘要。阅读的速度并非只有单一的一种,重点在如何读出不同的速度感,知道在阅读某种读物时该用什么样的速度。超快的速读法是引人怀疑的一种成就,那只是表现你在阅读一种根本不值得读的读物。更好的秘方是:在阅读一本书的时候,慢不该慢到不值得,快不该快到有损于满足与理解。不论怎么说,阅读的速度,不论是快还是慢,只不过是阅读问题一个微小的部分而已。\n略读或粗读一本书总是个好主意。尤其当你并不清楚手边的一本书是否值得细心阅读时(经常发生这种情况),必须先略读一下。略读过后,你就会很清楚了。一般来说,就算你想要仔细阅读的书也要先略读一下,从基本架构上先找到一些想法。\n最后,在第一次阅读一本难读的书时,不要企图了解每一个字句。这是最最重要的一个规则。这也是检视阅读的基本概念。不要害怕,或是担忧自己似乎读得很肤浅。就算是最难读的书也快快地读一遍。当你再读第二次时,你就已经准备好要读这本书了。\n我们已经完整地讨论过第二层次的阅读—检视阅读。我们会在第四篇时再讨论同一个主题,我们会提到检视阅读在主题阅读中占有多么重要的角色。主题阅读是第四层次,也是最高层次的阅读。\n无论如何,你应该记住,当我们在本书第二篇讨论第三层次的阅读—分析阅读时,检视阅读在那个层次中仍然有很重要的功能。检视阅读的两个步骤都可以当作是要开始做分析阅读之前的预备动作。第一阶段的检视阅读—我们称作有系统的略读或粗读—帮助阅读者分析在这个阶段一定要回答的问题。换句话说,有系统略读,就是准备要了解本书的架构。第二阶段的检视阅读—我们称之为粗浅的阅读—帮助阅读者在分析阅读中进人第二个阶段。粗浅的阅读,是阅读者想要了解全书内容的第一个必要步骤。\n在开始讨论分析阅读之前,我们要暂停一下,再想一下阅读的本质是一种活动。想要读得好,一个主动、自我要求的读者,就得采取一些行动。下一章,我们会谈。\n第五章 如何做一个自我要求的读者 # 在阅读的时候,让自己昏昏入睡比保持清醒要容易得多。爬上床,找个舒适的位置,让灯光有点昏暗,刚好能让你的眼睛觉得有点疲劳,然后选一本非常困难或极端无聊的书—可以是任何一个主题,是一本可读可不读的书—这样几分钟之后,你就会昏昏人睡了。\n不幸的是,要保持清醒并不是采取相反的行动就会奏效。就算你坐在舒适的椅子里,甚至躺在床上,仍然有可能保持清醒。我们已经知道许多人因为深夜还就着微弱的灯光阅读,而伤害了眼睛的事。到底是什么力量,能让那些秉烛夜读的人仍然保持清醒?起码有一点是可以确定的—他们有没有真正在阅读手中的那本书,造成了其间的差异,而且是极大的差异。\n在阅读的时候想要保持清醒,或昏昏入睡,主要看你的阅读目标是什么。如果你的阅读目标是获得利益—不论是心灵或精神上的成长—你就得保持清醒。这也意味着在阅读时要尽可能地保持主动,同时还要做一番努力—而这番努力是会有回馈的。\n好的书,小说或非小说,都值得这样用心阅读。把一本好书当作是镇静剂,完全是极度浪费。不论睡着,还是花了好几小时的时间想要从书中获得利益—主要想要理解这本书—最后却一路胡思乱想,都绝对无法达成你原来的目标。\n不过悲哀的是,许多人尽管可以区分出阅读的获益与取乐之不同—其中一方是理解力的增进,另一方则是娱乐或只是满足一点点的好奇心—最后仍然无法完成他们的阅读目标。就算他们知道那本书该用什么样的方式来阅读,还是失败。原因就在他们不知道如何做个自我要求的阅读者,如何将精神集中在他们所做的事情上,而不会一无所获。\n1.主动的阅读基础:一个阅读者要提出的四个基本问题 # 本书已经数度讨论过主动的阅读。我们说过,主动阅读是比较好的阅读,我们也强调过检视阅读永远是充满主动的。那是需要努力,而非毫不费力的阅读。但是我们还没有将主动阅读的核心作个简要的说明,那就是:你在阅读时要提出问题来—在阅读的过程中,你自己必须尝试去回答的问题。\n有问题吗?没有。只要是超越基础阅读的阅读层次,阅读的艺术就是要以适当的顺序提出适当的问题。关于一本书,你一定要提出四个主要的问题。\n(1)整体来说,这本书到底在谈些什么?你一定要想办法找出这本书的主题,作者如何依次发展这个主题,如何逐步从核心主题分解出从属的关键议题来。\n(2)作者细部说了什么,怎么说的?你一定要想办法找出主要的想法、声明与论点。这些组合成作者想要传达的特殊讯息。\n(3)这本书说得有道理吗?是全部有道理,还是部分有道理?除非你能回答前两个问题,否则你没法回答这个问题。在你判断这本书是否有道理之前,你必须先了解整本书在说些什么才行。然而,等你了解了一本书,如果你又读得很认真的话,你会觉得有责任为这本书做个自己的判断。光是知道作者的想法是不够的。\n(4)这本书跟你有什么关系?如果这本书给了你一些资讯,你一定要问问这些资讯有什么意义。为什么这位作者会认为知道这件事很重要?你真的有必要去了解吗?如果这本书不只提供了资讯,还启发了你,就更有必要找出其他相关的、更深的含意或建议,以获得更多的启示。\n在本书的其他篇章我们还会再回到这四个问题,做更深人的讨论。换句话说,这四个问题是阅读的基本规则,也是本书第二篇要讨论的主要议题。这四个重点以问题的方式出现在这里有一个很好的理由。任何一种超越基础阅读的阅读层次,核心就在你要努力提出问题(然后尽你可能地找出答案)。这是绝不可或忘的原则。这也是有自我要求的阅读者,与没有自我要求的阅读者之间,有天壤之别的原因。后者提不出问题—当然也得不到答案。\n前面说的四个问题,概括了一个阅读者的责任。这个原则适用于任何一种读物—一本书、一篇文章,甚至一个广告。检视阅读似乎对前两个问题要比对后两个更能提出正确的答案,但对后两个问题一样;会有帮助。而除非你能回答后面两个问题,否则即使用了分析阅读也不算功德圆满—你必须能够以自己的判断来掌握这本书的整体或部分道理与意义,才算真正完成了阅读。尤其最后一个问题—这本书跟你有什么关系?—可能是主题阅读中最重要的一个问题。当然,在想要回答最后一个问题之前,你得先回答前三个问题才行。\n光是知道这四个问题还不够。在阅读过程中,你要记得去提出这些问题。要养成这样的习惯,才能成为一个有自我要求的阅读者。除此之外,你还要知道如何精准、正确地回答问题。如此训练而来的能力,就是阅读的艺术。\n人们在读一本好书的时候会打磕睡,并不是他们不想努力,而是因为他们不知道要如何努力。你挂念着想读的好书太多了。(如果不是挂念着,也算不上是你觉得的好书。)而除非你能真正起身接触到它们,把自己提升到同样的层次,否则你所挂念的这些好书只会使你厌倦而已。并不是起身的本身在让你疲倦,而是因为你欠缺有效运用自我提升的技巧,在挫败中产生了沮丧,因而才感到厌倦。要保持主动的阅读,你不只是要有意愿这么做而已,还要有技巧—能战胜最初觉得自己能力不足部分,进而自我提升的艺术。\n2.如何让一本书真正属于你自己 # 如果你有读书时提出问题的习惯,那就要比没有这种习惯更能成为一个好的阅读者。但是,就像我们所强调的,仅仅提出问题还不够。你还要试着去回答问题。理论上来说,这样的过程可以在你脑海中完成,但如果你手中有一枝笔会更容易做到。在你阅读时,这枝笔会变成提醒你的一个讯号。\n俗话说:“你必须读出言外之意,才会有更大的收获。”而所谓阅读的规则,就是用一种比较正式的说法来说明这件事而已。此外,我们也鼓励你“写出言外之意”。不这么做,就难以达到最有效的阅读的境界。\n你买了一本书,就像是买了一项资产,和你付钱买衣服或家具是一样的。但是就一本书来说,付钱购买的动作却不过是真正拥有这本书的前奏而已。要真正完全拥有一本书,必须把这本书变成你自己的一部分才行,而要让你成为书的一部分最好的方法—书成为你的一部分和你成为书的一部分是同一件事—就是要去写下来。\n为什么对阅读来说,在书上做笔记是不可或缺的事?第一,那会让你保持清醒—不只是不昏睡,还是非常清醒。其次,阅读,如果是主动的,就是一种思考,而思考倾向于用语言表达出来—不管是用讲的还是写的。一个人如果说他知道他在想些什么,却说不出来,通常是他其实并不知道自己在想些什么。第三,将你的感想写下来,能帮助你记住作者的思想。\n阅读一本书应该像是你与作者之间的对话。有关这个主题,他知道的应该比你还多,否则你根本用不着去跟这本书打交道了。但是了解是一种双向沟通的过程,学生必须向自己提问题,也要向老师提问题。一旦他了解老师的说法后,还要能够跟老师争辩。在书上做笔记,其实就是在表达你跟作者之间相异或相同的观点。这是你对作者所能付出的最高的敬意。\n做笔记有各式各样,多彩多姿的方法。以下是几个可以采用的方法:\n(1)画底线—在主要的重点,或重要又有力量的句子下画线。\n(2)在画底线处的栏外再加画一道线—把你已经画线的部分再强调一遍,或是某一段很重要,但要画底线太长了,便在这一整段外加上一个记号。\n(3)在空白处做星号或其他符号—要慎用,只用来强调书中十来个最重要的声明或段落即可。你可能想要将做过这样记号的地方每页折一个角,或是夹一张书签,这样你随时从书架上拿起这本书,打开你做记号的地方,就能唤醒你的记忆。\n(4)在空白处编号—作者的某个论点发展出一连串的重要陈述时,可以做顺序编号。\n(5)在空白处记下其他的页码—强调作者在书中其他部分也有过同样的论点,或相关的要点,或是与此处观点不同的地方。这样做能让散布全书的想法统一集中起来。许多读者会用Cf这样的记号,表示比较或参照的意思。\n(6)将关键字或句子圈出来—这跟画底线是同样的功能。\n(7)在书页的空白处做笔记—在阅读某一章节时,你可能会有些问题(或答案),在空白处记下来,这样可以帮你回想起你的问题或答案。你也可以将复杂的论点简化说明在书页的空白处。或是记下全书所有主要论点的发展顺序。书中最后一页可以用来作为个人的索引页,将作者的主要观点依序记下来。\n对已经习惯做笔记的人来说,书本前面的空白页通常是非常重要的。有些人会保留这几页以盖上藏书印章。但是那不过表示了你在财务上对这本书的所有权而已。书前的空白页最好是用来记载你的思想。你读完一本书,在最后的空白页写下个人的索引后,再翻回前面的空白页,试着将全书的大纲写出来,用不着一页一页或一个重点一个重点地写(你已经在书后的空白页做过这件事了),试着将全书的整体架构写出来,列出基本的大纲与前后篇章秩序。这个大纲是在测量你是否了解了全书,这跟藏书印章不同,却能表现出你在智力上对这本书的所有权。\n3.三种做笔记的方法 # 在读一本书时,你可能会有三种不同的观点,因此做笔记时也会有三种不同的方式。你会用哪一种方式做笔记,完全依你阅读的层次而定。\n你用检视阅读来读一本书时,可能没有太多时间来做笔记。检视阅读,就像我们前面所说过的,所花的时间永远有限。虽然如此,你在这个层次阅读时,还是会提出一些重要的问题,而且最好是在你记忆犹新时,将答案也记下来—只是有时候不见得能做得到。\n在检视阅读中,要回答的问题是:第一,这是什么样的一本书?第二,整本书在谈的是什么?第三,作者是借着怎样的整体架构,来发展他的观点或陈述他对这个主题的理解?你应该做一下笔记,把这些问题的答案写下来。尤其如果你知道终有一天,或许是几天或几个月之后,你会重新拿起这本书做分析阅读时,就更该将问题与答案先写下来。要做这些笔记最好的地方是目录页,或是书名页,这些是我们前面所提的笔记方式中没有用到的页数。\n在这里要注意的是,这些笔记主要的重点是全书的架构,而不是内容—至少不是细节。因此我们称这样的笔记为结构(structuralnote-making)。\n在检视阅读的过程中,特别是又长又难读的书,你有可能掌握作者对这个主题所要表达的一些想法。但是通常你做不到这一点。而除非你真的再仔细读一遍全书,否则就不该对这本书立论的精确与否、有道理与否隧下结论。之后,等你做分析阅读时,关于这本书准确性与意义的问题,你就要提出答案了。在这个层次的阅读里,你做的笔记就不再是跟结构有关,而是跟概念有关了。这些概念是作者的观点,而当你读得越深越广时,便也会出现你自己的观点了。\n结构笔记与概念笔记(conceptual note-making)是截然不同的。而当你同时在读好几本书,在做主题阅读—就同一个主题,阅读许多不同的书时,你要做的又是什么样的笔记呢?同样的,这样的笔记也应该是概念性的。你在书中空白处所记下的页码不只是本书的页码,也会有其他几本书的页码。\n对一个已经熟练同时读好几本相同主题书籍的专业阅读者来说,还有一个更高层次的记笔记的方法。那就是针对一场讨论情境的笔记一这场讨论是由许多作者所共同参与的,而且他们可能根本没有常察自己的参与。在第四篇我们会详细讨论这一点,我们喜欢称这样的笔记为辩证笔记(dialectical note making)。因为这是从好多本书中摘要出来的,而不只是一本,因而通常需要用单独的一张纸来记载。这时,我们会再用上概念的结构—就一个单一主题,把所有相关的陈述和疑问顺序而列。我们会在第二十章时再回来讨论这样的笔记。\n4.培养阅读的习惯 # 所谓艺术或技巧,只属于那个能养成习惯,而且能依照规则来运作的人。这也是艺术家或任何领域的工匠与众不同之处。要养成习惯,除了不断地运作练习之外,别无他法。这也就是我们通常所说的,从实际去做中学习到如何去做的道理。在你养成习惯的前后,最大的差异就在于阅读能力与速度的不同。经过练习后,同一件事,你会做得比刚开始时要好很多。这也就是俗话说的熟能生巧。一开始你做不好的事,慢慢就会得心应手,像是自然天生一样。你好像生来就会做这件事,就跟你走路或吃饭一样自然。这也是为什么说习惯是第二天性的道理。\n知道一项艺术的规则,跟养成习惯是不同的。我们谈到一个有技术的人时,并不是在说他知道该如何去做那件事,而是他已经养成去做那件事的习惯了。当然,对于规则是否了解得够清楚,是能不能拥有技巧的关键。如果你不知道规则是什么,就根本不可能照规则来行事了。而你不能照规则来做,就不可能养成一种艺术,或任何技能的习惯。艺术就跟其他有规则可循的事一样,是可以学习、运作的。就跟养成其他事情的习惯一样,只要照着规则练习,就可以培养出习惯来。\n顺便一提,并不是每个人都清楚做一个艺术家是要照规则不断练习的。人们会指着一个具有高度原创性的画作或雕塑说:“他不按规矩来。他的作品原创性非常高,这是前人从没有做过的东西,根本没有规矩可循。”其实这些人是没有看出这个艺术家所遵循的规则而已。严格\u0026rsquo;来说,对艺术家或雕塑家而言,世上并没有最终的、不可打破的规则。但是准备画布,混合颜料,运用颜料,压模黏土或焊接钢铁,绝对是有规则要遵守的。画家或雕塑家一定要依循这些规则,否则他就没办法完成他想要做的作品了。不论他最后的作品如何有原创性,不论他淘汰了多少传统所知的“规则”,他都必须有做出这样成品的技巧。这就是我们在这里所要谈论的艺术—或是说技巧或手艺。\n5.由许多规则中养成一个习惯 # 阅读就像滑雪一样,做得很好的时候,像一个专家在做的时候,滑雪跟阅读一样都是很优美又和谐的一种活动。但如果是一个新手上路,两者都会是笨手笨脚、又慢又容易受挫的事。\n学习滑雪是一个成人最难堪的学习经验(这也是为什么要趁年轻时就要学会)。毕竟,一个成人习惯于走路E经很长一段时间。他知道如何落脚,如何一步一步往某个方向走。但是他一把雪橇架在脚上,就像他得重新学走路一样。他摔倒又滑倒,跌倒了还很难站起来。等好不容易站起来,雪橇又打横了,又跌倒了。他看起来—或感觉—自己就像个傻瓜。\n就算一个专业教练,对一个刚上路的新手也一筹莫展。滑雪教练滑出的优美动作是他口中所说的简单动作,而对一个新学者来说不只是天方夜谭,更近乎侮辱了。你要怎样才能记住教练所说的每一个动作?屈膝,眼睛往下面的山丘看,重心向下,保持背部挺直,还得学着身体往前倾。要求似乎没完没了—你怎能记住这么多事,同时还要滑雪呢?\n当然,滑雪的重点在不该将所有的动作分开来想,而是要连贯在一起,平滑而稳定地转动。你只要顾着往山下看,不管你会碰撞到什么,也不要理会其他同伴,享受冰凉的风吹在脸颊上,往山下滑行时身体流动的快感。换句话说,你一定要学会忘掉那些分开的步骤,才能表现出整体的动作,而每一个单一的步骤都还要确实表现得很好。但是,为了要忘掉这些单一的动作,一开始你必须先分别学会每一个单一的动作。只有这样,你才能将所有的动作连结起来,变成一个优秀的滑雪高手。\n这就踉阅读一样,或许你已经阅读了很长一段时间,现在却要一切重新开始,实在有点难堪。但是阅读就跟滑雪一样,除非你对每一个步骤都很熟练之后,你才能将所有不同的步骤连结起来,变成一个复杂却和谐的动作。你无法压缩其中不同的部分,好让不同的步骤立刻紧密连结起来。你在做这件事时,每一个分开来的步骤都需要你全神贯注地去做。在你分别练习过这些分开来的步骤后,你不但能放下你的注意力,很有效地将每个步骤做好,还能将所有的动作结合起来,表现出一个整体的顺畅行动。\n这是学习一种复杂技巧的基本知识。我们会这么说,仅仅是因为我们希望你知道学习阅读,至少跟学习滑雪、打字或打网球一样复杂。如果你能回想一下过去所学习的经验,就比较能忍受一位提出一大堆阅读规则的指导者了。\n一个人只要学习过一种复杂的技巧,就会知道要学习一项新技巧,一开始的复杂过程是不足为惧的。也知道他用不着担心这些个别的行动,因为只有当他精通这些个别的行动时,才能完成一个整体的行动。\n规则的多样化,意味着要养成一个习惯的复杂度,而非表示要形成许多个不同的习惯。在到达一个程度时,每个分开的动作自然会压缩、连结起来,变成一个完整的动作。当所有相关动作都能相当自然地做出来时,你就已经养成做这件事的习惯了。然后你就能想一下如何掌握一个专家的动作,滑出一个你从没滑过的动作,或是读一本以前你觉得对自己来说很困难的书。一开始时,学习者只会注意到自己与那些分开来的动作。等所有分开的动作不再分离,渐渐融为一体时,学习者便能将注意力转移到目标上,而他也具备了要达成目标的能力了。\n我们希望在这几页中所说的话能给你一些鼓励。要学习做一个很好的阅读者并不容易。而且不单单只是阅读,还是分析式的阅读。那是非常复杂的阅读技巧—比滑雪复杂多了。那更是一种心智的活动。一个初学滑雪的人必须先考虑到身体的动作,之后他才能放下这些注意力,做出自然的动作。相对来说,考虑到身体的动作还是比较容易做到的。考虑到心智上的活动却困难许多,尤其是在刚开始做分析阅读时更是如此,因为他总是在想着自己的想法。大多数人都不习惯这样的阅读。虽然如此,但仍然是可以训练出来的。而一旦学会了,你的阅读技巧就会越来越好。\n"},{"id":133,"href":"/zh/docs/culture/%E5%A6%82%E4%BD%95%E9%98%85%E8%AF%BB%E4%B8%80%E6%9C%AC%E4%B9%A6/%E7%AC%AC%E5%9B%9B%E7%AF%87_%E9%98%85%E8%AF%BB%E7%9A%84%E6%9C%80%E7%BB%88%E7%9B%AE%E6%A0%87/","title":"第四篇 阅读的最终目标","section":"如何阅读一本书","content":"第四篇 阅读的最终目标\n第二十章 阅读的第四个层次:主题阅读 # 到目前为止,我们还没有仔细谈过关于就同一个主题阅读两三本书的问题。我们在前面提到过,在讨论某个特定的主题时,牵涉到的往往不只是一本书。我们也一再非正式地提醒过,甚至其他领域中相关的作者与书籍,都与这个特定的主题有关。在作主题阅读时,第一个要求就是知道:对一个特定的问题来说,所牵涉的绝对不是一本书而已。第二个要求则是:要知道就总的来说,应该读的是哪些书?第二个要求比第一个要求还难做到。\n我们在检验这个句子:“与同一个主题相关两本以上的书”时,困难就出现了。我们所说的“同一个主题”是什么意思?如果这个主题是单一的历史时期或事件,就很清楚了,但是在其他的领域中,就很难作这样清楚的区分。《飘》与《战争与和平》都是关于伟大战争的小说—但是,两者相似之处也止于此了。司汤达的《帕玛修道院》(The Charterhouse of Parma)谈的拿破仑战争,也是托尔斯泰作品中谈的战争。但是这两本书当然都不是在谈这场战争,也不是与一般战争有关的书。在这两个故事中,战争只是提供了一个环境或背景,故事的本身所谈的是人类的生存与挣扎,战争不过是作者想吸引读者注意的手法。我们可能会了解有关这场战役的一些事情—事实上,托尔斯泰就说过,从司汤达所描述的滑铁卢之役中,他学到很多有关这场战役的事—但是如果我们的主题是要研究战争,就用不着拿这些小说来读了。\n你可能料到小说有这种情况。因为作品的特性,小说沟通问题的方法跟论说性作品不同。但是,论说性作品也有同样的问题。\n譬如说你对“爱”这个概念很感兴趣,想要阅读相关的读物。因为关于爱的作品很广泛,你要整理出一个相关书目来阅读是有点困难的。假设你向专家求教,到一个完备的图书馆中寻找书目,还对照一位优秀学者所写的论文,终于把书目弄出来了。再假设你进一步舍弃诗人和小说家谈的这个主题,只想从论说性的作品中找答案(在后面我们会说明为什么这样的做法是明智的)。现在你开始依照书目来阅读这些书了。你发现什么?\n即使只是匆匆的浏览,你也会找到一大堆相关的资料。人类的行为,几乎没有任何一种行为没有被称作是爱的行为—只是称呼的方式不同而已。而且爱并不只限于人类。如果你进一步往下阅读,你会发现宇宙中的万事万物皆有爱。也就是说,任何存在的事物都可能爱与被爱—或二者兼而有之。\n石头是爱,因为它是地球的中心。火焰会上扬,是因为爱的功能。铁刀会吸引磁铁,被形容为爱的结果。有些书专门研究变形虫、草履虫、蜗牛、蚂蚁的爱情生活。更别提一些较高等的动物,它们会爱它们的主人,也会彼此相爱。谈到人类的爱,我们发现作者谈到也写到他们对男人们、女人们、一个男人、一个女人、孩子、他们自己、人类、金钱、艺术、家庭生活、原则、原因、职业或专业、冒险、安全、想法、乡村生活、爱的本身、牛排或美酒之爱。在某些教材中,天体的运转被认为是受到爱的启发。而天使与魔鬼的不同就在爱的品质不同。至于上帝,当然是要来爱人的。\n面对如此庞大的相关资料,我们要如何决定我们要研究的主题是什么呢?我们能确定这中间只有一个单一的主题吗?当一个人说:“我爱起司。”另一个人说“我爱橄榄球。”而第三个人说“我爱人类”时,他们三个人所用的同样一个爱字,代表着同样的意义吗?毕竟,起司是可以吃的,橄榄球或人类是不能吃的。一个人可以玩橄榄球,却不能玩起司或其他的人。而不论“我爱人类”是什么意思,这个爱都与起司或橄榄球之爱不同。但是这三个人用的都是同样一个爱字。在这其中是否有深刻的理由?一些无法立即浮现的理由?就像这个问题本身的困难,在我们找到答案之前,我们能说我们已经确认了“同一个主题”吗?\n面对如此的混乱,你可能会决定把范围缩小到人类的爱上—人与人之间的爱,同性爱或异性爱,同年之爱或忘年之爱等等。其中的规则又跟我们前面说的三种爱法不同了。但是就算你只读了一小部分与主题相关的书,你仍然会找到一堆的相关资料。譬如你会发现某些作者说:爱只是一种占有的欲望,通常是性的欲望,也就是说,爱只是一种所有动物在面对异性时会产生的吸引力。但是你也会发现另一个作者所谈的爱是不包含占有的欲望,而是一种慈善。如果说占有的欲望总是暗示着想要为自己追求好东西,而慈善却暗示着要为别人追求好东西。那么占有的欲望与慈善之间,是否有相通之处?\n至少在占有的欲望与慈善之间,分享着一种共同的倾向,那就是渴望某种非常抽象的东西。但是你对这个主题的研究很快又让你发现:某些作者主张的爱是心灵的,而非肉欲的。这些作者认为爱是知性的行为,而非感性的行为。换句话说,知道某个人是值得仰慕的,总会引发渴望之心,不论是前面所说的哪一种渴望都行。这类作者并不否认有这样的渴望,但他们不承认那就是爱。\n让我们假设—事实上,我们认为可以做得到—在这么多有关人类之爱的构想中,你能找出一些共通的意义。就算是这样,你的问题还是没有解决。再想想看,在人际之间,爱所表现出来的方式其实是截然不同的。男女之间的爱在恋爱期间、结婚之后、二十多岁时、七十多岁时都相同吗?一个女人对丈夫的爱与对孩子的爱相同吗?当孩子长大时,母亲对他们的爱就改变了吗?一个兄弟对姊妹的爱,跟他对父亲的爱是一样的吗?一个孩子长大之后,对父母的爱会改变吗?男人对女人的爱—无论是妻子或其他的女人—跟他对朋友的爱是相同的吗?他和不同朋友之间的关系—像是某人跟他一起打保龄球,某人是一起工作的伙伴,某人是知性的伙伴等—是否各有不同?“爱情”与“友情”之所以不同,是因为其中牵涉到的情绪(如果这是它们被命名的原因)不同,才有不同的名称吗?两个不同年纪的人也能做朋友吗?两个在财富与知识水平上有明显差距的人,也能做朋友吗?女人之间真的有友谊吗?兄弟姊妹,或哥哥弟弟、姊姊妹妹之间真的能成为朋友吗?如果你向人借钱,或是借钱给人,你们之间的友谊能保持下去吗?如果不能·,为什么?一个男孩子能爱上自己的老师吗?而这个老师是男是女,会不会造成什么样的差别?如果真的有像人一样的机器人,人类会爱他们吗?如果我们在火星或其他星球上发现了有智慧的生物,我们会爱他们吗?我们会不会爱上一个素昧平生的人,像是电影明星或总统?如果我们觉得恨某个人,那是否其实是一种爱的表现?\n你只不过读了一小部分有关爱的论说性作品,这些问题就会浮现在你脑海中,其实还有更多其他的问题会出现。无论如何,我们已经说到重点了。在做主题阅读时,会出现一种很矛盾的现象。虽然这个层次的阅读被定义为就同一个主题,阅读两种以上的书,意思也是指在阅读开始之前,这个主题就已经被确认了,但是换个角度来说,这个主题也是跟着阅读走的,而不是事前就能定出来的。以爱这个例子来说,在你决定自己要读些什么之前,你可能已经读了好几百本相关的著作了。等你都读完之后,你会发现有一半的书其实跟主题根本无关。\n1.在主题阅读中,检视阅读所扮演的角色 # 我们已经说过很多次,阅读的层次是渐进累积的。较高层次的阅读中也包括了前面的,或较低层次的阅读。在主题阅读中,我们就要说明这一点。\n你可能还记得,在解说检视阅读与分析阅读的关系时,我们指出在检视阅读中的两个步骤—第一个是浏览,第二个是粗浅地阅读—也就是分析阅读的前两个步骤。浏览能帮助你准备做分析阅读的第一个步骤:你能确定自己在读的是什么主题,能说明这是什么样的书,并拟出大纲架构。粗浅的阅读对分析阅读的第一步骤也有帮助。基本上这是进人第二步骤的准备动作。在第二个步骤中,你要能够与作者达成共识,说明他的主旨,跟随他的论述,才能够诠释整本书的内容。\n同样的,检视阅读与分析阅读也可以当作是进人主题阅读的前置作业或准备动作。事实上,在这个阶段,检视阅读已经是读者在阅读时主要的工具或手段了。\n举例来说,你有上百本的参考书目,看起来全是与爱有关的主题。如果你全部用分析阅读来阅读,你不只会很清楚你在研究的主题是什么—主题阅读中的“同一主题”—你还会知道你所阅读的书中,那些跟主题无关,是你不需要的书。但是要用分析阅读将一百本书读完,.会花上你十年的时间。就算你能全心投注在这个研究上,仍然要花上好几个月的时间。再加上我们前面谈过的主题阅读中会出现的矛盾问题,显然必要有一些捷径。\n这个捷径是要靠你的检视阅读技巧来建立的。你收集好书目之后,要做的第一件事是检视书单上所有的书。在做检视阅读之前,绝不要用分析阅读来阅读。检视阅读不会让你明白有关主题的所有错综复杂的内容,或是作者所有的洞察力,但却具有两种基本的功能。第一,它会让你对自己想要研究的主题有个清晰的概念,这样接下来你针对某几本书做分析阅读时,会大有助益。其次,它会简化你的书目到一个合理的程度。\n对学生,尤其是研究生来说,我们很难想到还有比这更管用的方式。只要他们肯照着做,一定会有帮助。根据我们的经验,在研究生程度的学生中,确实有些人能做到主动的阅读与分析阅读。这对他们来说还不够,他们或许不是完美的读者,但是至少他们知道要如何掌握一本书的重点,能明确地说出书中的要点,并把这些观点纳人他们研究主题的一部分。但是他们的努力有一大半是浪费掉了,因为他们不知道要如何才能比别人读得快一点。他们阅读每一本书或每一篇文章都花上同样的时间与努力,结果他们该花精神好好阅读的书却没有读好,倒把时间花在那些不太值得注意的书上了。\n能够熟练检视阅读的读者,不但能在心中将书籍分类,而且能对内容有一个粗浅的了解。他也会用非常短的时间就发现,这本书谈的内容对他研究的主题到底重不重要。这时他可能还不清楚哪些资料才是最重要的—这可能要等到读下本书的时候才能发现。但是有两件事至少他已经知道其中之一。那就是他不是发现这本书必须回头再读一次,以获得启发,便是知道不论这本书多有趣又多丰富,却毫无启发性,因此不值得重新再读。\n这个忠告通常会被忽略是有原因的。我们说过,在分析阅读中,技巧熟练的阅读者可以同时用上许多技巧,而初学者却必须把步骤分开来。同样的,主题阅读的准备工作—先检视书目上所有的书,在开始做分析阅读之前先检视一遍—可以在做分析阅读时一并进行。但我们不相信任何读者能做到这一点,就算技巧再熟练也不行。这也是许多年轻研究生所犯的毛病。他们自以为两个步骤可以融合为一个,结果阅读任何书都用同样的速度,对某些特殊的作品来说不是太快就是太慢,但无论如何,对他们阅读的大部分书来说,这样的方法都是不对的。\n一旦你检视过,确定某些书跟你研究的主题相关后,你就可以开始做主题阅读了。要注意的是,我们并没有像你以为的说:“开始做分析阅读”。当然,你需要研读每一本书,再组合起跟你主题相关的资料,你在做分析阅读时就已经学会了这些技巧。但是绝不要忘了,分析阅读的技巧只适用于单一的作品,主要的目标是要了解这本书。而我们会看到,主题阅读的目标却大不相同。\n2.主题阅读的五个步骤 # 现在我们准备好要说明如何做主题阅读了。我们的假设是:你已经检视了相当多的书,你至少对其中一些书在谈些什么有点概念了,而且你也有想要研究的主题了。接下来你该怎么办?\n在主题阅读中一共有五个步骤。这些步骤我们不该称之为规则—虽然也许我们会—因为只要漏掉其中一个步骤,主题阅读就会变得很困难,甚至读不下去了。我们会简略地介绍一下这些步骤的顺序,不过这些步骤彼此之间还是可以互相取代的。·\n主题阅读步骤一:找到相关的章节。当然,我们假设你已经学会分析阅读了,如果你愿意,你能把所有相关的书都看透彻了。但是你可能会把阅读单本的书放在第一顺位,而把自己的主题放在其次。事实上,这个顺序应该颠倒过来,在主题阅读中,你及你关心的主题才是基本的重点,而不是你阅读的书。\n在你已经确定哪些书是相关的之后,主题阅读的第一个步骤就是把这些书整体检视阅读一遍。你的目标是找出书中与你的主题极为相关的章节。你选择的书不太可能全本都与你的主题或问题相关。就算是如此,也一定是少数,你应该很快地把这本书读完。你不该忘了,你的阅读是别有用心的—也就是说,你是为了要解决自己的问题才阅读—而不是为了这本书本身的目的而阅读。\n看起来,这个步骤似乎与前面所说的,为了发现这本书是否与你主题相关的检视阅读当同一件事来进行。许多状况的确可以这么做。但是如果你认为永远都可以这么做的话,可能就不太聪明了。记住,第一步的检视阅读是要集中焦点在你要进一步做主题阅读的主题上。我们说过,除非你已经检阅过书单上大部分的书,否则你无法完全理解这个问题。因此,在确认哪些是相关的书籍的同时,还要确认哪些是相关的章节,其实是很危险的做法。除非你的技巧已经很熟练,而且对你要研究的主题已经很清楚了,否则你最好是将两部分分开来做。\n在主题阅读中,能够把你所阅读的第一批书,与你后来针对这个主题阅读的许多本书的差别区分出来,是很重要的事。对后来的这些书来说,你可能对自己的主题已经有了很清楚的概念,这时就可以把两种检视阅读合并在一起。但是在一开始时,却要明显地区分出来,否则你在找相关章节时会犯下严重的错误,到后来要更正这些错误时又要花上很多的时间与精力。\n总之,要记得你最主要的工作不是理解整本书的内容,而是找出这本书对你的主题有什么帮助,而这可能与作者本身的写作目的相去甚远。在这个阶段的过程中,这并不重要。作者可能是在无意之间帮你解决了问题。我们已经说过,在主题阅读中,是书在服务你,而不是你在服务书。因此,主题阅读是最主动的一种阅读法。当然,分析阅读也需要主动的阅读方式。但是你在分析阅读一本书时,你就像是把书当作主人,供他使唤。而你在做主题阅读时,却一定要做书的主人。\n因此,在与作者达成共识这一点上,这个阶段有不同的做法。\n主题阅读步骤二:带引作者与你达成共识。在诠释阅读中(分析阅读的第二步骤),第一个规则是要你与作者达成共识,也就是要能找出关键字,发现他是如何使用这些字的。但是现在你面对的是许多不同的作者,他们不可能每个人都使用同样的字眼,或相同的共识。在这时候就是要由你来建立起共识,带引你的作者们与你达成共识,而不是你跟着他们走。\n在主题阅读中,这可能是最困难的一个步骤。真正的困难在于要强迫作者使用你的语言,而不是使用他的语言。这跟我们一般的阅读习惯都不相同。我们也指出过很多次,我们假设:我们想要用分析阅读来阅读的作者,是比我们优秀的人。尤其如果这是一本伟大的著作时,就更可能如此。无论我们在了解他的过程中花了多少力气,我们都会倾向于接受他的词义与他安排的主题结构。但在主题阅读中,如果我们接受任何一位作者所提出来的词汇(terminology),我们很快就会迷失。我们可能会了解他的书,却无法了解别人的书。我们也很难找到与自己感兴趣的主题的资料。\n我们不只要能够坚决拒绝接受任何一位作者的词汇,还得愿意面对可能没有任何一位作者的词汇对我们来说是有用的事实。换句话说,我们必须要接受一个事实:我们的词汇刚好与任何一位书目上的作者相同时,只是一种巧合。事实上,这样的巧合还满麻烦的。因为如果我们使用了某一位作者的一个或一组词义,我们就可能继续引用他书中其他的词义,而这只会带给我们麻烦,没有其他的帮助。\n简单来说,主题阅读是一种大量的翻译工作。我们并不是将一种语言翻成另一种语言,像法语翻成英语,但是我们要将一种共通的词汇加诸在许多作者身上,无论他们所使用的是不是相同的语言,或是不是关心我们想解决的问题,是否创造了理想的词汇供我们使用。\n这就是说,在进行主题阅读时,我们要建立一组词汇,首先帮助我们了解所有的作者,而不是其中一两个作者;其次帮助我们解决我们的问题。这一点认识会带我们进人第三个步骤。\n主题阅读步骤三:厘清问题。诠释阅读的第二个规则是要我们找出作者的关键句子。然后从中逐步了解作者的主旨。主旨是由词义组成的,在主题阅读中,当然我们也要做同样的工作。但是因为这时是由我们自己来建立词汇,因此,我们也得建立起一组不偏不倚的主旨。最好的方法是先列出一些可以把我们的问题说得比较明白的问题,然后让那些作者来回答这些问题。\n这也是很困难的工作,这些问题必须要以某种形式,某种秩序来说明,以帮助我们解决我们提出的问题,同时这些问题也要是大多数作者都能回答的问题。难就难在我们认为是问题的地方,作者也许并不认为是问题。他们对我们认定的主题可能有相当不同的看法。\n事实上,有时候我们必须接受作者可能一个问题也回答不了。在这样的状况中,我们必须要将他视为是对这个问题保持沉默,或是尚未作出决定。但是就算他并没有很清楚地讨论这个问题,有时我们也可以在他书中找到间接的回答。我们会得出这么一个结论:如果他考虑到这个问题的话,那就会如何如何回答这个问题。在这里需要一点自我约束。我们不能把思想强加在作者脑海中,也不能把话语放进他们的口中。但是我们也不能完全依赖他们对这个问题的解说。如果我们真的能靠其中任何一位作者来解释这个问题,或许我们根本就没有问题要解决。\n我们说过要把问题照秩序排列出来,好帮助我们在研究时使用。当然,这个秩序是跟主题有关的,不过还是有一般的方向可循。第一个问题通常跟我们在研究的概念或现象的存在或特质有关。如果一位作者说这种现象的确存在,或这种概念有一种特质,那么对于他的书我们就要提出更进一步的问题了。这个问题可能跟这个现象是如何被发现,或这个概念是如何表现出来的有关。最后一部分的问题则是与回答前面问题所产生的影响有关。\n我们不该期望所有的作者都用同一种方法来回答我们的问题。如果他们这么做了,我们就又没有问题要解决了。那个问题会被一致的意见解决了。正因为每个作者都不相同,因此我们要再面对主题阅读的下一个步骤。\n主题阅读步骤四:界定议题。如果一个问题很清楚,如果我们也确定各个作者会用不同的方式来回答—不论赞成或反对—那么这个议题就被定义出来了。这是介于用这种方法回答问题的作者,和用另外一种(可能是相反的)方法来回答问题的作者之间的议题。\n如果检验过后,所有的作者提供的答案只有正反两面的意见,那么这个问题算是简单的问题。通常,对一个问题会有超过两种以上的答案。在这种情况下,我们就要找出不同意见彼此之间的关联,再根据作者的观点来作分类。\n当两个作者对同一个问题有相当的了解,所作的回答却完全相反或矛盾时,这才是一个真正有参与的议题。但是这样的现象并不像我们希望的那样经常发生。通常,答案之不同固然来自于各人对这个主题有不同的观点,但也有很多情况是来自于对问题本身的认知不同。所以在做主题阅读的读者,要尽可能地确保议题是大家所共同参与的。有时候这会迫使他在列出问题的时候,小心不采取任何一位作者明白采用的方法。\n我们要处理的问题,可能会出现很多种不同的议题,不过通常都可以分门别类。譬如像考虑到某种概念的特质的问题,就会出现一堆相关的议题。许多议题绕着一组相互关联密切的问题打转,就会形成这个主题的争议。这样的争议可能很复杂,这时主题阅读的读者就要将所有争议的前后关系整理清楚—尽管没有任何作者做这件事。厘清争议,同时将相关议题整理出来之后,我们便要进入主题阅读的最后一个步骤。\n主题阅读步骤五:分析讨论。到目前为止,我们已经检验过作品,找出相关的章节,设定了一个不偏不倚的共识,适用于所有被检视过的作者,再设定出一整套的问题,其中大部分都能在作者的说明中找到答案。然后就不同的答案界定并安排出议题。接下来该怎么做呢?\n前面四个步骤与分析阅读的前两组规则是互相辉映的。这些规则应用在任何一本书中,都会要我们回答一个问题:这本书在说些什么?是如何说明的?在主题阅读中,对于与我们的问题相关的讨论,我们也要回答类似的问题。在只阅读一本书的分析阅读中,剩下还有两个问题要回答:这是真实的吗?这与我何干?而在主题阅读中,我们对于讨论也要准备回答同样的问题。\n让我们假设起头的那个阅读问题并不单纯,是个几世纪以来与许多思考者纷争不已的长久问题,许多人家不同意,并且会继续不同意的问题。在这个假设中,我们要认知的是,身为主题阅读的读者,我们的责任不只是要自己回答这些问题—这些问题是我们仔细整理出来,以便易于说明主题的本身与讨论的内容。有关这类问题的真理并不容易发现。如果我们期望真理就存在某一组问题的答案之中,那可能太轻率了。就算能找到答案,也是在一些相互矛盾的答案的冲突中找到令人信服的证据,而且有支持自己的确切理由。\n因此,就可以发现的真理而言,就我们可以找到的问题答案而言,与其说是立足于任何一组主旨或主张上,不如说是立足于顺序清楚的讨论的本身。因此,为了要让我们的头脑接受这样的真相—也让别人接受—我们要多做一点工作,不只是问问题与回答问题而已。我们要依照特定的顺序来提问题,也要能够辨认为什么是这个顺序。我们必须说明这些问题的不同答案,并说明原因。我们也一定要能够从我们检视过的书中找出支持我们把答案如此分类的根据。只有当我们做到这一切时,我们才能号称针对我们问题的讨论作了分析,也才能号称真正了解了问题。\n事实上,我们所做的可能超过这些。对一个问题完整地分析过后,将来其他人对同一个问题要作研究时,我们的分析讨论就会提供他一个很好的研究基础。那会清除一些障碍,理出一条路,让一个原创性的思考者能突破困境。如果没有这个分析的工作,就没法做到这一点,因为这个问题的各个层面就无法显现出来。\n3.客观的必要性 # 要完整地分析一个问题或某个主题,得指出这个讨论中的主要议题,或是一些基本的知性反对立场。这并不是说在所有的讨论中,反对的意见总是占主导的。相反,同意或反对的意见总是互相并存的。也就是说,在大多数的议题中,正反两面的意见总是有几个,甚至许多作者在支持。在一个争议性的立场上,我们很少看到一个孤零零的支持者或反对者。\n人类对任何领域某种事物的特质达成一致的观点,都建立一种假设,意味着他们共同拥有的意见代表着真理。而不同的观点则会建立起另一个相反的假设—无论你是否参与,这些争论中的观点可能没有一个是完全真实的。当然,在这些冲突的观点中,也可能有一个是完全真实的,而其他的则是虚假的。不过也可能双方面都只是表达了整体真理的一小部分。除了一些单调或孤立的争论之外(就我们在这里所读的问题,不太可能有这种形式的讨论),很可能正反双方的意见都是错的,一如所有的人可能都同意了一种错误的观点。而另一些没有表达出来的观点才可能是真实的,或接近真实的。\n换句话说,主题阅读的目的,并不是给阅读过程中发展出来的问题提供最终答案,也不是给这个计划开始时候的问题提供最终解答。当我们要给这样的主题阅读写一份读者报告的时候,这个道理特别清楚。如果这份报告就任何所界定并分析过的重要议题,想要主张或证明某一种观点的真实或虚假,都会太过教条,失去对话的意义。如果这么做,主题阅读就不再是主题阅读,而只是讨论过程中的另一个声音,失去了疏离与客观性。\n我们要说的,并不是我们认为对人类关心的重要议题多一个声音无足轻重。我们要说的是我们在追求理解的过程中,可以而且应该多贡献一种不同的形式。而这样的形式必须是绝对客观又公正的。主题阅读所追求的这种特质,可以用这句话来作总结:“辩证的客观。”\n简单来说,主题阅读就是要能面面俱到,而自己并不预设立场。当然,这是个严格的理想,一般人是没法做到的。而绝对的客观也不是人类所能做到的事。他可能可以做到不预设立场,毫无偏见地呈现出任何观点,对不同的意见也保持中立。但是采取中立比面面俱到要容易多了。在这一方面,主题阅读的读者注定会失败的。一个议题有各种不同的观点,不可能巨细靡遗地全都列出来。虽然如此,读者还是要努力一试。\n虽然我们说保持中立要比面面俱到容易一些,但还是没那么容易。主题阅读的读者必须抗拒一些诱惑,厘清自己的思绪。对于某些冲突性的观点避免作出明白的真伪判断,并不能保证就能做到完全的公正客观。偏见可能会以各种微妙的方式进人你的脑海中—可能是总结论述的方式,可能是因为强调与忽略的比重,可能是某个问题的语气或评论的色彩,甚至可能因为对某些关键问题的不同答案的排列顺序。\n要避免这样的危险,谨慎的主题阅读的读者可以采取一个明显的手段,尽量多加利用。那就是他要不断回头参阅诸多作者的原文,重新再阅读相关的章节。并且,当他要让更多的人能应用他的研究结果时,他必须照原作者的原文来引用他的观点或论述。虽然看起来有点矛盾,但这并不影响我们前面所说的,在分析问题时必须先建立一套中立的词汇。这样的中立语言还是必要的,而且在总结一个作者的论述时,一定要用这套中立的语言,而不是作者的语言。但是伴随着总结,一定要有仔细引用的作者原文,以免对文意有所扭曲,这样阅读者才能自己判断你对作者所作的诠释是否正确。\n主题阅读的读者必须能够坚决地避免这个问题,才不会偏离公正客观的立场。要达到这样的理想,必须要能不偏不倚地在各种相对立的问题中保持平衡,放下一切偏见,反省自己是否有过与不及的倾向。在最后的分析中,一份主题阅读的书面报告是否达到对话形式的客观,虽然也可以由读者来判断,但只有写这份报告的人才真正明白自己是否达到这些要求。\n4.主题阅读的练习实例:进步论 # 举个例子可以说明主题阅读是如何运作的。让我们以进步这个概念做例子。我们并不是随便找的这个例子。对这个问题我们做了相当多的研究。否则这个例子对你来说不会很有用。\n我们花了很长的时间研究这个重要的历史与哲学问题。第一个步骤是列出与研究主题相关的章节—也就是列出书目(最后出现的书单超过450本)。要完成这项工作,我们运用了一连串的检视阅读。针对许多书籍、文章与相关著作,做了许多次的检视阅读。对于讨论“进步”这个概念来说,这是非常重要的一个过程。同样的,对其他的重大研究来说这也是很重要的过程。许多最后被判定为相关的资料多少都是无意间发现的,或至少也是经过合理的猜测才找到的。许多近代的书籍都以“进步”为书名,因此要开始寻找资料并不困难。但是其他的书并没有标明进步这两个字,尤其是一些古书,内容虽然相关,却并没有运用这个词句。\n我们也读了一些小说或诗,但最后决定以论说性的作品为主。我们早说过,在主题阅读中,要包括小说、戏剧与诗是很困难的,原因有很多个。第一,故事的精髓在情节,而非对某个议题所秉持的立场。其次,就算是最能言善道的角色也很少对某个议题清楚表达出立场—譬如托马斯·曼的《魔山》(Magic Mountain)中,斯坦布林尼就对进步发表过一些见解—我们无法确定那是不是作者本人的观点。是作者在利用他的角色对这个议题作出反讽?还是他想要你看到这个观点的愚蠢,而非睿智?一般来说,要将小说作者的观点列人议题的某一方时,需要作很多很广泛的努力。要花的努力很多,得到的结果却可能是半信半疑的,因此通常最好放弃在这方面的努力。\n可以检验进步这个概念的其他许多作品,一如常见的情况,显得一片混乱。面对这样的问题,我们前面说过,就是要建立起一套中立的语言。这是一个很复杂的工作,下面的例子可以帮助我们说明这是如何进行的。\n所谓“进步”一词,不同的作者有许多不同的用法。这些不同的用法,大部分显示的只是意义的轻重不同,因而可以用分析的方法来处理。但是有些作者也用这个词来指出历史上某种特定的变化,而这种变化不是改善的变化。既然大多数作者都用“进步”来指出历史上某种为了促进人类朝向更美好生活的变化,并且既然往更改善的状态的变化是这个概念的基础,那么同样的字眼就不能适用于两种相反的概念了。因此,本例我们取大多数人的用法,那些主张历史上“非关改善的进展”(non meliorative advance)的作者,就只好划为少数派了。我们这么说的目的是,在讨论这些少数作者的观点时,就算他们自己运用了“进步”这样的字眼,我们也不能将他们纳入“进步”的概念中。\n我们前面说过,主题阅读的第三步是厘清问题。在“进步”的例子中,我们对这个问题一开始的直觉,经过检验之后,证明是正确的。第一个要问的问题,也是各个作者被认为提供各种不同答案的问题,是“历史上真的有‘进步\u0026rsquo;这回事吗?”说历史的演变整体是朝向改善人类的生存条件,的确是事实吗?基本上,对这个问题有三种不同的回答:(1)是;(2)否;(3)不知道。然而,回答“是”可以用许多不同的方式来表达,回答“否”也有好几种说法,而说“不知道”也至少有三种方式。\n对这个基本问题所产生的各式各样相互牵连的答案,构成我们所谓关于进步的一般性争议。所谓一般性,是因为我们研究的每个作者,只要对这个主题有话要说,就会在这个主题所界定的各个议题上选边站。但是对于进步还有一种特殊的争论,参与这种议题的,都是一些主张进步论的作者—这些作者主张进步确实发生。身为进步论的作者,他们全都强调进步是一种历史的事实,而所有的议题都应该和进步的本质或特质相关。这里的议题其实只有三种,只是个别讨论起来都很复杂。这三个议题我们可以用问题的形式来说明:(1)进步是必要的?还是要取决于其他事件?(2)进步会一直无止境地持续下去?还是会走到终点或高原期而消失?(3)进步是人类的天性,还是养成的习惯—来自人类动物的本能,或只是外在环境的影响?\n最后,就进步发生的面向而言,还有一些次要议题,不过,这些议题仍然只限于在主张进步论的作者之间。有六个面向是某些作者认为会发生,另外有些作者虽然多少会反对其中一两个的发生,但不会全部反 对(因为他们在定义上就是肯定进步发生的作者)。这六个面向是:(1)知识的进步;(2)技术的进步;(3)经济的进步;(4)政治的进步;(5)道德的进步;(6)艺术的进步。关于最后一项有些特殊的争议。因为在我们的观点里,没有一位作者坚信在这个面向中真的有进步,甚至有些作者否认这个面向有进步。\n我们列举出“进步”的分析架构,只是要让你明白,在这个主题中包含了多少的议题,与对这些讨论的分析—换句话说,这也是主题阅读的第四及第五个步骤。主题阅读的读者必须做类似的工作才行,当然,他用不着非得就自己的研究写一本厚厚的书不可。\n5.如何应用主题工具书 # 如果你仔细阅读过本章,你会注意到,虽然我们花了不少时间谈这件事,但我们并没有解决主题阅读中的矛盾问题。这个矛盾可以说明如下:除非你知道要读些什么书,你没法使用主题阅读。但是除非你能做主题阅读,否则你不知道该读些什么书。换句话说,这可以算是主题阅读中的根本问题。也就是说,如果你不知道从何开始,你就没法做主题阅读。就算你对如何开始有粗浅的概念,你花在寻找相关书籍与篇章的时间,远超过其他步骤所需时间的总和。\n当然,至少理论上有一种方法可以解决这个矛盾的问题。理论上来说,你可以对我们传统中的主要经典作品有一番完整的认识,对每本书所讨论的各种观念都有相当的认知。如果你是这样的人,就根本用不着任何人帮忙,我们在主题阅读上也没法再多教给你什么了。\n从另一个角度来看,就算你本身没有这样的知识,你还是可以找有这种知识的人帮忙。但你要认清一点,就算你能找到这样的人,他的建议最后对你来说,在帮助的同时,几乎也都会变成障碍。如果那个主题正好是他做过特殊研究的,对他来说就很难只告诉你哪些章节是重要相关的,而不告诉你该如何读这些书—而这一点很可能就造成你的阻碍。但是如果他并没有针对这个主题做过特殊的研究,他知道的也许还没有你多—尽管你们双方都觉得应该比你多。\n因此,你需要的是一本工具书,能告诉你在广泛的资料当中,到哪里去找与你感兴趣的主题相关的章节,而用不着花时间教你如何读这些章节—也就是对这些章节的意义与影响不抱持偏见。譬如,主题工具书(Syntopicon)就是这样的一种工具。出版于1940年,名为《西方世界的经典名著))(Great Books of the Western World)的这套书,包含了三千种话题或主题,就每一个讨论到的主题,你可以按照页码找到相关的参考资料。某些参考资料长达多页,某些则只是几段关键文字。你用不着花太多时间,只需取出其中的某本书,动手翻阅便行了。\n当然,主题工具书有一个主要的缺点。这仍然是一套书目的索引(尽管是很大的一套),至于这套书没有包含的其他作品里什么地方可以找到你要的东西,则只有一些粗略的指引。不过,不管你要做哪一类主题阅读,这套书至少总能帮助你知道从何处着手。同时,在这整套名著中的书,不论是关于哪个主题,也都是你真的想要阅读的书。因此,主题工具书能帮助成熟的学者,或刚开始研究特定问题的初学者节省许多基本的研究工具,能让他很快进人重点,开始做独立的思考。因为他已经知道前人的思想是什么了。\n主题工具书对这种研究型的读者很有帮助,而且对初学者更有助益。主题工具书能从三方面帮助刚开始做研究的人:启动阅读,建议阅读.指导阅读。\n在启动阅读方面,主题工具书能帮助我们在面对传统经典作品时,克服最初的困难。这些作品都有点吸引力,我们都很想读这些书,但往往做不到。我们听到很多建议,要我们从不同的角度来阅读这样的书,而且有不同的阅读进度,从简单的作品开始读,再进展到困难的作品。但是所有这类阅读计划都是要读完整本书,或是至少要读完其中的大部分内容。就一般的经验来说,这样的解决方案很少能达到预期的效果。\n对于这类经典巨著,使用主题阅读再加上主题工具书的帮助,就会产生完全不同的解决方案。主题工具书可以帮读者就他们感兴趣的主题,启动他对一些经典著作的阅读—在这些主题上,先阅读来自大量不同作者的一些比较短的章节。这可以帮助我们在读完这些经典著作之前,先读进去。\n使用主题阅读来阅读经典名著,再加上主题工具书的帮助,还能提供我们许多建议。读者一开始阅读是对某个主题特别感兴趣,但是会逐渐激发出对其他主题的兴趣。而一旦你开始研究某位作者,就很难不去探索他的上下文。就在你明白过来之前,这本书你已经读了一大半了。\n最后,主题阅读加上主题工具书,还能从三种不同的方向指导关系。事实上,这是这个层次的阅读最有利的地方。\n第一,读者阅读的章节所涉及的主题,能够给他一个诠释这些章节的方向。但这并不是告诉他这些章节是什么意思,因为一个章节可能从好几个或许多个方向与主题相关。而读者的责任就是要找出这个章节与主题真正相关的地方在哪里。要学习这一点,需要拥有很重要的阅读技巧。\n第二,针对同一个主题,从许多不同的作者与书籍中收集出来的章节,能帮助读者强化对各个章节的诠释能力。有时候我们从同一本书中依照顺序来阅读的章节,以及挑出来比对阅读的章节,相互对照之下可以让我们更了解其中的含意。有时候从不同书中摘出来的章节是互相冲突的,但是当你读到彼此冲突的论点时,就会更明白其中的意义了。有时候从一个作者的书中摘出来的章节,由另一个作者的书的某个章节作补充或评论,实际上可以帮助读者对第二位作者有更多的了解。\n第三,如果主题阅读运用在许多不同的主题上,当你发现同一个章节被主题工具书引述在许多不同主题之下的时候,这件事情本身就很有指导阅读的效果。随着读者针对不同的主题要对这些章节进行多少不同的诠释,他会发现这些章节含有丰富的意义。这种多重诠释的技巧,不只是阅读技巧中的基本练习,同时也会训练我们的头脑面对任何含义丰富的章节时,能习惯性地作出适当的调整。\n因为我们相信,对想做这个层次的阅读的读者来说,无论他是资深的学者或初学者,主题工具书都很有帮助,因此我们称这一阅读层次为主题阅读。我们希望读者能原谅我们一点点的自我耽溺。为了回报您的宽容,我们要指出很重要的一点。主题阅读可以说有两种,一种是单独使用的主题阅读,一种是与主题工具一起并用。后一种可以当作是构成前一种阅读计划的一部分,一开始由这里着手,是最聪明的做法。而前一种主题阅读所应用的范围要比后一种广义许多。\n6.构成主题阅读的原则 # 有些人说主题阅读(就上述广义的定义来说)是不可能做到的事。他们说在一个作者身上强加一套语言,即使是最“中立”的一套词汇(就算真有这回事的话),也是错的。作者本身的词汇是神圣不可侵犯的,因为阅读一本书时绝不能“脱离上下文”,而且将一组词汇转成另一种解释总是很危险的,因为文字并不像数学符号那么容易控制。此外,反对者认为主题阅读牵涉的作者太广,时空不同,基本的风格与性质也不 同,而主题阅读就像是将他们都聚在同一个时空,彼此一起讨论—这完全扭曲了事实的真相。每位作者都有自己的天地,虽然同一位作者在不同时空所写的作品之间可能有些联系(他们提醒说即使这样也很危险),但是在这位作者与另一位作者之间却没有明显的联系。最后, 他们坚持,作者所讨论的主题比不上讨论的方法重要。他们说风格代表一个人,如果我们忽略作者是如何谈一件事,却只顾他谈的是什么事,结果只会两头落空,什么也没了解到。\n当然,我们对所有这些指控都不同意,我们要依序回答这些指控。让我们一次谈一个。\n第一,是关于词汇的问题。否认一个概念可以用不同的词汇来说明,就像否认一种语言可以翻译成另一种语言。当然,这样的否认是刻意制造出来的。譬如最近我们阅读《古兰经》的一个新译本,前言一开始便说要翻译《古兰经》是不可能的事。但是因为译者接着又解释他是如何完成的,所以我们只能假设他的意思是:要翻译这样一本被众人视为神圣的典籍,是一件极为困难的事。我们也同意。不过困难并不代表做不到。\n事实上,所谓作者本身的词汇是神圣不可侵犯的说法,其实只是在说要将一种说法翻译成另一种说法是非常困难的。这一点我们也同意。但是,同样的,困难并非不可能做到。\n其次,谈到作者各自区隔与独立的特性。这就像说有一天亚里士多德走进我们办公室(当然穿着长袍),身边跟着一位又懂现代英语又懂古希腊语的翻译,而我们却无法听懂他讲什么,他也无法听懂我们讲什么一样。我们不相信有这回事。毫无疑问,亚里士多德对他看到的许多事一定觉得很讶异,但我们确信在十分钟之内,只要我们想,我们就能跟他一起讨论某个我们共同关心的问题。对于一些特定的概念一定会发生困难,但是只要我们能够发现,就能解决。\n如果这是可行的(我们不认为任何人会否认),那么让一本书经由翻译—也就是主题阅读的读者—与另一本书的作者“谈话”,并不是不可能的事。当然,这需要很谨慎,而且你要把双方的语言—也就是两本书的内容—了解得越透彻越好。这些问题并非不能克服,如果你觉得无法克服只是在自欺欺人。\n最后,谈到风格的问题。我们认为,这就像是说人与人之间无法作理性的沟通,而只能作情绪上的沟通—就像你跟宠物沟通的层次。如果你用很愤怒的腔调对你的狗说:“我爱你!”它会吓得缩成一团,并不知道你在说什么。有谁能说:人与人之间的语言沟通,除了语气与姿势外就没有其他的东西?说话的语气是很重要的—尤其当沟通的主要内容是情绪关系的时候;而当我们只能听(或者看?)的时候,肢体语言中可能就有些要告诉我们的事情。但是人类的沟通,不只这些东西。如果你问一个人出口在哪里?他告诉你沿着B走廊就会看到。这时他用的是什么语气并不重要。他可能对也可能错,可能说实话也可能撒谎,但是重点在你沿着B走廊走,很快就能找到出口了。你知道他说的是什么,也照着做了,这跟他如何说这句话一点关系也没有。\n只要相信翻译是可行的(因为人类一直在做这件事),书与书之间就能彼此对谈(因为人类也一直在这么做)。只要愿意这么做,人与人之间也有理性客观的沟通能力(因为我们能彼此互相学习),所以我们相信主题阅读是可行的。\n7.主题阅读精华摘要 # 我们已经谈完主题阅读了。让我们将这个层次的阅读的每个步骤列举出来。\n我们说过,在主题阅读中有两个阶段。一个是准备阶段,另一个是主题阅读本身。让我们复习一下这些不同的步骤:\n一、 观察研究范围:主题阅读的准备阶段\n(1) 针对你要研究的主题,设计一份试验性的书目。你可以参考图书馆目录、专家的建议与书中的书目索引。\n(2) 浏览这份书目上所有的书,确定哪些与你的主题相关,并就你的主题建立起清楚的概念。\n二、 主题阅读:阅读所有第一阶段收集到的书籍\n(1) 浏览所有在第一阶段被认定与你主题相关的书,找出最相关的章节。\n(2) 根据主题创造出一套中立的词汇,带引作者与你达成共识—无论作者是否实际用到这些词汇,所有的作者,或至少绝大部分的作者都可以用这套词汇来诠释。\n(3) 建立一个中立的主旨,列出一连串的问题—无论作者是否明白谈过这些问题,所有的作者,或者至少大多数的作者都要能解读为针对这些问题提供了他们的回答。\n(4) 界定主要及次要的议题。然后将作者针对各个问题的不同意见整理陈列在各个议题之旁。你要记住,各个作者之间或之中,不见得一定存在着某个议题。有时候,你需要针对一些不是作者主要关心范围的事情,把他的观点解读,才能建构出这种议题。\n(5) 分析这些讨论。这得把问题和议题按顺序排列,以求突显主题。比较有共通性的议题,要放在比较没有共通性的议题之前。各个议题之间的关系也要清楚地界定出来。注意:理想上,要一直保持对话式的疏离与客观。要做到这一点,每当你要解读某个作家对一个议题的观点时,必须从他自己的文章中引一段话来并列。\n第二十一章 阅读与心智的成长 # 我们已经完成了在本书一开始时就提出的内容大要。我们已经说明清楚,良好的阅读基础在于主动的阅读。阅读时越主动,就读得越好。\n所谓主动的阅读,也就是能提出问题来。我们也指出在阅读任何一本书时该提出什么样的问题,以及不同种类的书必须怎样以不同的方式回答这些问题。\n我们也区分并讨论了阅读的四种层次,并说明这四个层次是累积渐进的,前面或较低层次的内容包含在后面较高层次的阅读里。接着,我们刻意强调后面较高层次的阅读,而比较不强调前面较低层次的阅读。因此,我们特别强调分析阅读与主题阅读。因为对大多数的读者来说,分析阅读可能是最不熟悉的一种阅读方式,我们特别花了很长的篇幅来讨论,定出规则,并说明应用的方法。不过分析阅读中的所有规则,只要照最后一章所说的略加调整,就同样适用于接下来的主题阅读。\n我们完成我们的工作了,但是你可能还没有完成你的工作。我们用不着再提醒你,这是一本实用性的书,或是阅读这种书的读者有什么特殊的义务。我们认为,如果读者阅读了一本实用的书,并接受作者的观点,认同他的建议是适当又有效的,那么读者一定要照着这样的建议行事。你可能不接受我们所支持的主要目标—也就是你应该有能力读得更透彻—也不同意我们建议达到目标的方法—也就是检视阅读、分析阅读与主题阅读的规则。(但如果是这样,你可能也读不到这一页了。)不过如果你接受这个目标,也同意这些方法是适当的,那你就一定要以自己以前可能从没有经历过的方式来努力阅读了。\n这就是你的工作与义务。我们能帮得上什么忙吗?\n我们想应该可以。这个工作主要的责任在你—你要做这所有的事(同时也获得所有的利益)。不过有几件关于目标与手段的事情还没谈到。现在就让我们先谈谈后者吧!\n1.好书能给我们什么帮助 # “手段\u0026quot;(means)这两个字可以解释成两种意义。在前面的章节中,我们将手段当作是阅读的规则,也就是使你变成一个更好的阅读者的方法。但是手段也可以解释为你所阅读的东西。空有方法却没有可以运用的材料,就和空有材料却没有可以运用的方法一样是毫无用处的。\n以“手段”的后一种意思来说,未来提升你阅读能力的手段其实是你将阅读的那些书。我们说过,这套阅读方法适用于任何一本,以及任何一种你所阅读的书—无论是小说还是非小说,想像文学还是论说性作品,实用性还是理论性。但是事实上,起码就我们在探讨分析阅读与主题阅读过程中所显示的这套方法并不适用于所有的书。原因是有些书根本用不上这样的阅读。\n我们在前面已经提过这一点了,但我们想要再提一遍,因为这与你马上要做的工作有关。.如果你的阅读目的是想变成一个更好的阅读者,你就不能摸到任何书或文章都读。如果你所读的书都在你的能力范围之内,你就没法提升自己的阅读能力。你必须能操纵超越你能力的书,或像我们所说的,阅读超越你头脑的书。只有那样的书能帮助你的思想增长_除非你能增长心智,否则你学不到东西。\n因此,对你来说最重要的是,你不只要能读得好,还要有能力分辨出哪些书能帮助你增进阅读能力。一本消遣或娱乐性的书可能会给你带来一时的欢愉,但是除了享乐之外,你也不可能再期待其他的收获了。我们并不是反对娱乐性的作品,我们要强调的是这类书无法让你增进阅读的技巧。只是报导一些你不知道的事实,却没法让你增进对这些事实的理解的书,也是同样的道理。为了讯息而阅读,就跟为了娱乐阅读一样,没法帮助你心智的成长。也许看起来你会以为是有所成长,但那只是因为你脑袋里多了一些你没读这本书之前所没有的讯息而已。然而,你的心智基本上跟过去没什么两样,只是阅读数量改变了,技巧却毫无进步。\n我们说过很多次,一个好的读者也是自我要求很高的读者。他在阅读时很主动,努力不懈。现在我们要谈的是另外一些观念。你想要用来练习阅读技巧,尤其是分析阅读技巧的书,一定要对你也有所要求。这些书一定要看起来是超越你的能力才行。你大可不必担心真的如此,只要你能运用我们所说的阅读技巧,没有一本书能逃开你的掌握。当然,这并不是说所有的技巧可以一下子像变魔术一样让你达到目标。无论你多么努力,总会有些书是跑在你前面的。事实上,这些书就是你要找的书,因为它们能让你变成一个更有技巧的读者。\n有些读者会有错误的观念,以为那些书—对读者的阅读技巧不断提出挑战的书籍—都是自己不熟悉的领域中的书。结果一般人都相信,对大多数读者来说,只有科学作品,或是哲学作品才是这种书。但是事实并非如此。我们已经说过,伟大的科学作品比一些非科学的书籍还要容易阅读,因为这些科学作者很仔细地想要跟你达成共识,帮你找出关键主旨,同时还把论述说明清楚。在文学作品中,找不到这样的帮助,所以长期来说,那些书才是要求最多,最难读的书。譬如从许多方面来说,荷马的书就比牛顿的书难读—尽管你在第一次读的时候,可能对荷马的体会较多。荷马之所以难读,是因为他所处理的主题是很难写好的东西。\n我们在这里所谈的困难,跟阅读一本烂书所谈的困难是不同的。阅读一本烂书也是很困难的事,因为那样的书会抵消你为分析阅读所作的努力,每当你认为能掌握到什么的时候又会溜走。事实上,一本烂书根本不值得你花时间去努力,甚至根本不值得作这样的尝试。你努力半天还是一无所获。\n读一本好书,却会让你的努力有所回报。最好的书对你的回馈也最多。当然,这样的回馈分成两种:第一,当你成功地阅读了一本难读的好书之后,你的阅读技巧必然增进了。第二—长期来说这一点更重要—一本好书能教你了解这个世界以及你自己。你不只更懂得如何读得更好,还更懂得生命。你变得更有智慧,而不只是更有知识—像只提供讯息的书所形成的那样。你会成为一位智者,对人类生命中永恒的真理有更深刻的体认。\n毕竟,人间有许多问题是没有解决方案的。一些人与人之间,或人与非人世界之间的关系,谁也不能下定论。这不光在科学与哲学的领域中是如此,因为关于自然与其定律,存在与演变,谁都还没有,也永远不可能达到最终的理解,就是在一些我们熟悉的日常事物,诸如男人与女人,父母与孩子,或上帝与人之间的关系,也都如此。这事你不能想太多,也想不好。伟大的经典就是在帮助你把这些问题想得更清楚一点,因为这些书的作者都是比一般人思想更深刻的人。\n2.书的金字塔 # 西方传统所写出的几百万册的书籍中,百分之九十九都对你的阅读技巧毫无帮助。这似乎是个令人困恼的事实,不过连这个百分比也似乎高估了。但是,想想有这么多数量的书籍,这样的估算还是没错。有许多书只能当作娱乐消遣或接收资讯用。娱乐的方式有很多种,有趣的资讯也不胜枚举,但是你别想从中学习到任何重要的东西。事实上,你根本用不着对这些书做分析阅读。扫描一下便够了。\n第二种类型的书籍是可以让你学习的书—学习如何阅读,如何生活。只有百分之一,千分之一,甚或万分之一的书籍合乎这样的标准。这些书是作者的精心杰作,所谈论的也是人类永远感兴趣,又有特殊洞察力的主题。这些书可能不会超过几千本,对读者的要求却很严苛,值得做一次分析阅读—一次。如果你的技巧很熟练了,好好地阅读过一次,你就能获得所有要获得的主要概念了。你把这本书读过一遍,便可以放回架上。你知道你用不着再读一遍,但你可能要常常翻阅,找出一些特定的重点,或是重新复习一下一些想法或片段。(你在这类书中的空白处所做的一些笔记,对你会特别有帮助。)\n你怎么知道不用再读那本书了呢?因为你在阅读时,你的心智反应已经与书中的经验合而为一了。这样的书会增长你的心智,增进你的理解力。就在你的心智成长,理解力增加之后,你了解到—这是多少有点神秘的经验—这本书对你以后的心智成长不会再有帮助了。你知道你已经掌握这本书的精髓了。你将精华完全吸收了。你很感激这本书对你的贡献,但你知道它能付出的仅止于此了。\n在几千本这样的书里,还有更少的一些书—很可能不到一百种—却是你读得再通,也不可能尽其究竟。你要如何分辨哪些书是属于这一类的呢?这又是有点神秘的事了,不过当你尽最大的努力用分析阅读读完一本书,把书放回架上的时候,你心中会有点疑惑,好像还有什么你没弄清楚的事。我们说“疑惑”,是因为在这个阶段可能仅只是这种状态。如果你确知你错过了什么,身为分析阅读者,就有义务立刻重新打开书来,厘清自己的问题是什么。事实上,你没法一下子指出问题在哪里,但你知道在哪里。你会发现自己忘不了这本书,一直想着这本书的内容,以及自己的反应。最后,你又重看一次。然后非常特殊的事就发生了。\n如果这本书是属于前面我们所说第二种类型的书,重读的时候,你会发现书中的内容好像比你记忆中的少了许多。当然,原因是在这个阶段中你的心智成长了许多。你的头脑充实了,理解力也增进了。书籍本身并没有改变,改变的是你自己。这样的重读,无疑是让人失望的。\n但是如果这本书是属于更高层次的书—只占浩瀚书海一小部分的书—你在重读时会发现这本书好像与你一起成长了。你会在其中看到新的事物—一套套全新的事物—那是你以前没看到的东西。你以前对这本书的理解并不是没有价值(假设你第一次就读得很仔细了),真理还是真理,只是过去是某一种面貌,现在却呈现出不同的面貌。\n一本书怎么会跟你一起成长呢?当然这是不可能的。一本书只要写完出版了,就不会改变了。只是你到这时才会开始明白,你最初阅读这本书的时候,这本书的层次就远超过你,现在你重读时仍然超过你,未来很可能也一直超过你。因为这是一本真正的好书—我们可说是伟大的书—所以可以适应不同层次的需要。你先前读过的时候感到心智上的成长,并不是虚假的。那本书的确提升了你。但是现在,就算你已经变得更有智慧也更有知识,这样的书还是能提升你,而且直到你生命的尽头。\n显然并没有很多书能为我们做到这一点。我们评估这样的书应该少于一百本。但对任何一个特定的读者来说,数目还会更少。人类除了心智力量的不同之外,还有许多其他的不同。他们的品味不同,同一件事对这个人的意义就大过对另一个人。你对牛顿可能就从没有对莎士比亚的那种感觉,这或许是因为你能把牛顿的书读得很好,所以用不着再读一遍,或许是因为数学系统的世界从来就不是你能亲近的领域。如果你喜欢数学—像达尔文就是个例子—牛顿跟其他少数的几本书对你来说就是伟大的作品,而不是莎士比亚。\n我们并不希望很权威地告诉你,哪些书对你来说是伟大的作品。不过在我们的第一个附录中,我们还是列了一些清单,因为根据我们的经验,这些书对许多读者来说都是很有价值的书。我们的重点是,你该自己去找出对你有特殊价值的书来。这样的书能教你很多关于阅读与生命的事情。这样的书你会想一读再读。这也是会帮助你不断成长的书。\n3.生命与心智的成长 # 有一种很古老的测验—上一个世纪很流行的测验—目的在于帮你找出对你最有意义的书目。测验是这样进行的:如果你被警告将在一个无人荒岛度过余生,或至少很长的一段时间,而假设你有时间作一些准备,可以带一些实际有用的物品到岛上,还能带十本书去,你会选哪十本?\n试着列这样一份书单是很有指导性的,这倒不只是因为可以帮助你发现自己最想一读再读的书是哪些。事实上,和另外一件事比起来,这一点很可能是微不足道的。那件事就是:当你想像自己被隔绝在一个没有娱乐、没有资讯、没有可以理解的一般事物的世界时,比较起来你是否会对自己了解得更多一点?记住,岛上没有电视也没有收音机,更没有图书馆,只有你跟十本书。\n你开始想的时候,会觉得这样想像的情况有点稀奇古怪,不太真实。当真如此吗?我们不这么认为。在某种程度上,我们都跟被放逐到荒岛上的人没什么两样。我们面对的都是同样的挑战—如何找出内在的资源,过更美好的人类生活的挑战。\n人类的心智有很奇怪的一点,主要是这一点划分了我们心智与身体的截然不同。我们的身体是有限制的,心智却没有限制。其中一个迹象是,在力量与技巧上,身体不能无限制地成长。人们到了30岁左右,身体状况就达到了巅峰,随着时间的变化,身体的状况只有越来越恶化,而我们的头脑却能无限地成长与发展下去。我们的心智不会因为到了某个年纪死就停止成长,只有当大脑失去活力,僵化了,才会失去了增加技巧与理解力的力量。\n这是人类最明显的特质,也是万物之灵与其他动物最主要不同之处。其他的动物似乎发展到某个层次之后,便不再有心智上的发展。但是人类独有的特质,却也潜藏着巨大的危险。心智就跟肌肉一样,如果不常运用就会萎缩。心智的萎缩就是在惩罚我们不经常动脑。这是个可怕的惩罚,因为证据显示,心智萎缩也可能要人的命。除此之外,似乎也没法说明为什么许多工作忙碌的人一旦退休之后就会立刻死亡。他们活着是因为工作对他们的心智上有所要求,那是一种人为的支撑力量,也就是外界的力量。一旦外界要求的力量消失之后,他们又没有内在的心智活动,他们便停止了思考,死亡也跟着来了。\n电视、收音机及其他天天围绕在我们身边的娱乐或资讯,也都是些人为的支撑物。它们会让我们觉得自己在动脑,因为我们要对外界的刺激作出反应。但是这些外界刺激我们的力量毕竟是有限的。像药品一样,一旦习惯了之后,需要的量就会越来越大。到最后,这些力量就只剩下一点点,甚或毫无作用了。这时,如果我们没有内在的生命力量,我们的智力、品德与心灵就会停止成长。当我们停止成长时,也就迈向了死亡。\n好的阅读,也就是主动的阅读,不只是对阅读本身有用,也不只是对我们的工作或事业有帮助,更能帮助我们的心智保持活力与成长。\n"},{"id":134,"href":"/zh/docs/culture/%E5%A6%82%E4%BD%95%E9%98%85%E8%AF%BB%E4%B8%80%E6%9C%AC%E4%B9%A6/%E7%AC%AC%E4%B8%89%E7%AF%87_%E9%98%85%E8%AF%BB%E4%B8%8D%E5%90%8C%E8%AF%BB%E7%89%A9%E7%9A%84%E6%96%B9%E6%B3%95/","title":"第三篇 阅读不同读物的方法","section":"如何阅读一本书","content":"第三篇 阅读不同读物的方法\n第十三章 如何阅读实用型的书 # 在任何艺术或实务的领域中,有些规则太通用这一点是很令人扫兴的。越通用的规则就越少,这算是一个好处。而越通用的规则,也越容易理解—容易学会与使用这些规则。但是,说实在的,当你置身错综复杂的实际情况,想要援用一些规则的时候,你也会发现越通用的规则离题越远。\n我们前面谈过分析阅读的规则,一般来说是适用于论说性的作品—也就是说任何一种传达知识的书。但是你不能只用一般通用的方法来读任何一本书。你可能读这本书那本书,或是任何一种特殊主题的书,可能是历史、数学、政治论文或科学研究,或是哲学及神学理论,因此,在运用以下这些规则时,你一定要有弹性,并能随时调整。幸运的是,当你开始运用这些规则时,你会慢慢感觉到这些规则是如何在不同的读物上发挥作用。\n要特别提醒的是,在第十一章结尾时所说明的十五个阅读规则并不适用于阅读小说或诗集。一本虚构作品的纲要架构,与论说性的作品是完全不同的。小说、戏剧与诗并不是照着共识、主旨、论述来发展的。换句话说,这些作品的基本内容没有逻辑可言,要评论这些作品也要有不同的前提才行。然而,如果你认为阅读富有想像力的作品毫无规则可言,那也是错的。事实上,下一章我们会讨论到阅读那种作品的另一套应用规则。那些规则一方面本身就很有效,另一方面如果能检验这些规则和阅读论说性作品规则的不同之处,还可以帮助你对阅读论说性作品的规则多一层认识。\n你用不着担心又要学一整套十五个或更多的阅读小说与诗的规则。你会很容易了解到这两种规则之间的关联性。其中也包括了我们一再强调的事实,你在阅读时一定要能提出问题来,尤其是四个最特殊的问题,不论在阅读什么样的书时都要能提出来。这四个问题与任何一本书都有关,不论是虚构或非虚构,不论是诗、历史、科学或哲学。我们已经知道阅读论说性作品的规则如何互相连贯,又是如何从这四个问题中发展出来的。同样的,阅读富有想像力作品的规则也是来自这四个问题,只不过这两类作品的题材不同,会造成规则上的部分差异。\n因此,在这一篇里,比起阅读的规则,我们会谈更多有关这几个问题的话题。我们会偶尔提一个新规则,也会重新调整某一个旧的规则。不过大多数时候,既然我们谈的是阅读不同读物的方法,我们会强调基本要问的不同问题,以及会获得什么样的不同的回答。\n在论说性作品的部分,我们谈过基本上要区分出实用性与理论性,两种作品—前者是有关行动的问题,后者只和要传递的知识有关。我们也说过,理论性的作品可以进一步划分为历史、科学(与数学)、哲学。实用性作品则没有任何界限,因此我们要进一步分析这类书的特质,并提供一些阅读时的建议指南与方法。\n1.两种实用性的书 # 关于实用性的书有一件事要牢记在心:任何实用性的书都不能解决该书所关心的实际问题。一本理论性的作品可以解决自己提出的问题。但是实际的问题却只能靠行动来解决。当你的实际问题是如何赚钱谋生时,一本教你如何交朋友或影响别人的书,虽然可能建议你很多事,但却不能替你解决问题。没有任何捷径能解决这个问题,只能靠你自己去赚钱谋生才能解决。\n以本书为例。这是一本实用的书,如果你对这本书的实用性(当然也可能只是理论性)感兴趣,那你就是想要解决学习阅读的问题。但除非你真的学到了,你不可能认为那些问题都解决,消失不见了。本书没法为你解决那些问题,只能帮助你而已。你必须自己进行有活力的阅读过程,不只是读这本书,还要读很多其他的书。这也是为什么老话说:只有行动能解决问题。行动只能在现世发生,而不是在书本中发生。\n每个行动发生时都有特殊情况,都发生在不同的时间、地点与特殊环境中。你没法照一般的反应来行动。要立即采取行动的特殊判断力,更是极为特别。这可以用文字来表达,却几乎没见过。你很难在书中找到这样的说明,因为实用书的作者不能亲身体验读者在面临的特殊状况时,必须采取的行动。他可能试着想要帮忙,但他不能提供现场的实际建议。只有另一个置身一模一样情况的人,才能帮得上忙。\n然而,实用性的书多少还是可以提供一些可以应用在同类型特殊状况中的通用规则。任何人想要使用这样的书,一定要把这些规则运用在特殊的状况下,因此一定要练习特殊的判断力才行。换句话说,读者一定要能加上一点自己的想法,才能运用在实际的状况中。他要能更了解实际状况,更有判断力,知道如何将规则应用在这样的状况中。\n任何书里包含了规则—原理、准则或任何一种一般的指导—你都要认定是一本实用性的书。但是一本实用性的书所包含的不只是规则而已。它可能会说明规则底下的原理,使之浅显易懂.譬如在这本与阅读有关的特殊主题的书中,我们不断地简要阐释文法、修辞与逻辑原理,来解说阅读规则。规则底下的原理通常都很科学,换言之,属于理论性的知识。规则与原理的结合,就是事物的理论。因此,我们谈造桥的理论,也谈打桥牌的理论。我们的意思是,理论性的原则会归纳出出色的行事规则。\n实用性的书因此可分为两种类型。其中一种,就像本书一样,或是烹饪书、驾驶指南,基本上都是在说明规则的。无论其中谈论到什么问题,都是为了说明规则而来的。这类书很少有伟大的作品。另一类的 实用书主要是在阐述形成规则的原理。许多伟大的经济、政治、道德巨著就属于这一类。\n这样的区分并不是绝对的。在一本书中,同时可以找到原理与规 则。重点在特别强调其中哪一项。要将这两种类型区分出来并不困 难。不管是在什么领域中,谈规则的书都可以立刻认出来是实用性的。 一本谈实用原理的书,乍看之下会以为是理论性的书。从某个程度来说,的确没错。它所讨论的是一种特殊状况中的理论。无论如何,你还是看得出来它是实用性的书。它要处理的那些问题的本质会露底。这样的书所谈的总是人类行为领域中,怎样可能做得更好或更糟。\n在阅读一本以规则为主的书时,要找寻的主旨当然是那些规则。 阐述这些规则通常是用命令句,而不是叙述句。那是一种命令。譬如说:“及时一针,胜过事后九针。”这样的规则也可以改为叙述式的说法:“如果你及时补上一针,就省下后来要补的九针。”两个句子都是在提示 争取时间的价值,命令式的语句比较强烈,但却不见得就比较容易记住。\n无论是叙述句或命令句,你总是能认出一个规则来,因为它在建议你某件事是值得做的,而且一定会有收获。因此,要你与作者达成共识的那条命令式的阅读规则,也可以改成建议式的说法:“成功的阅读牵涉到读者与作者达成共识。”“成功”这两个字就说明了一切,意味着这种阅读是值得去做的一件事。\n这类实用书的论述都是在向你表示:它们所说的规则都是确切可 行的。作者可能会用原理来说明这些规则的可信度,或是告诉你一些实例,证明这些规则是可行的。看看这两种论述,诉诸原理的论述通常比较没有说服力,但却有一个好处。比起举实例的方法,诉诸原理的论述比较能将规则的理由说明得清楚一些。\n在另一种实用性书中,主要谈的是规则背后的原理。当然,其中的主旨与论述看起来就跟纯理论性的书一模一样。其中的主旨是在说明某件事的状态,而论述就是在强调真的是如此。\n但是阅读这样的一本书,与阅读纯理论的书还是有很大的不同。因为要解决的问题终究是实用的问题—行动的问题,人类在什么状态下可以做得更好或更糟的问题—所以当聪明的读者看到“实用原理”这样的书时,总是能读出言外之意。他可能会看出那些虽然没有明说,但却可以由原理衍生出来的规则。他还会更进一步,找出这些规则应该如何实际应用。\n除非这样阅读,否则一本实用的书便没有被实用地阅读。无法让一本实用的书被实用地阅读,就是失败的阅读。你其实并不了解这本书,当然也不可能正确地评论这本书了。如果在原理中能找到可以理解的规则,那么也就可以在由原理引导出来的规则或建议的行动中,找到实用原理的意义。\n这些是你要了解任何一种实用性书籍,或是在作某种批评时的最高原则。在纯理论性的书中,相同或反对的意见是与书中所谈的真理有关。但是现实的真理与理论的真理不同。行为规则要谈得上是真理,有两种情况:一是真的有效;二是这样做能带引你到正确的结果,达到你的期望。\n假设作者认为你应该寻求的正确结果,你并不以为然,那么就算他的建议听起来很完整,由于那个目标的缘故,你可能还是不会同意他的观点。你会因此而判断他的书到底实不实用。如果你不认同仔细、头脑清楚地阅读是件值得做的事情,那么纵使本书的规则真的有效,这本书对你来说还是没什么实用性。\n注意这段话的意义。在评断一本理论性的书时,读者必须观察他自己与作者之间的原理与假设的一致性或差异性。在评断一本实用性的书时,所有的事都与结果及目标有关。如果你不能分享马克思对经济价值的狂热,他的经济教条与改革措施对你来说就有点虚假或无关197痛痒。譬如你可能和埃德蒙·柏克(Edmund Burke)一样,认为维持现状就是最好的策略,而且在全面考量过后,你相信还有比改变资本不平等更重要的事。你的判断主要是与结果达成共识,而非方法。就算 方法非常真实有用,如果所达到的目的是我们不关心或不期望的结果, 我们也不会有半点兴趣的。\n2.说服的角色 # 以上的简单讨论,可以给你一些线索。当你在阅读任何一种实用书时,一定要问你自己两个主要的问题。第一:作者的目的是什么?第二:他建议用什么方法达到这个目的?以原理为主的书要比以规则 为主的书还要难回答这两个问题。在这些书中,目的与方法可能都不很明显。但如果你想要了解与评论实用性的书,就必须回答这两个问题。\n还要提醒你的是,前面我们讨论过的实用作品的写作问题。每一本实用的书中都混杂着雄辩或宣传。我们还没读过一本政治哲学的书—无论是多理论性的,无论谈的是多么“深奥”的原理—是不是想说服读者有关“最好的政府形态”的道理。相同的,道德理论的书也想 要说服读者有关“美好生活”的道理,同时建议一些达到目标的方法。我们也一直试着要说服你照某种特定的方式来阅读一本书,以达到你可能想要追求的理解力。\n你可以知道为什么实用书的作者多少都是个雄辩家或宣传家。因为你对他作品最终的评断是来自你是否接受他的结论.与他提议的方法。这完全要看作者能不能将你引导到他的结论上。要这么做,他讨论的方法必须要能打动你的心智。他可能必须激起你的情绪反应,左右你的意志。\n这并没有错,也没有恶意。这正是实用书的特性,一个人必须要被 说服,以采取特定的思想与行动。实际的思考与行动除了需要理智以 外,情感也是重要的因素。没有人可以没有受到感动,却认真采取实际 评论或行动的。如果可以的话,这个世界可能会比较美好,但一定是个不同的世界。一本实用书的作者认知不到这一点,就不算成功。一位读者如果认知不到这一点,就像买了一堆货物,却不知道自己买了些什么。\n不想被宣传所困惑,就得了解宣传的内容是什么。难以察觉的隐藏式雄辩是最狡猾的。那会直接打动你的心,而不经过你的头脑,就像是从背后吓你一跳,把你吓得魂不附体一样。这样的宣传手法就像是你吞了一颗药,自己却完全不知道。宣传的影响力是很神秘的,事后你并不知道自己为什么会那样感觉与思考。\n一个人如果真正读懂了一本实用的书,他知道这本书的基本共识、主旨、论述是什么,就能觉察出作者的雄辩。他会觉察到某一段话是“情绪用字”。他知道自己是被说服的对象,他有办法处理这些诉求的重点。他对推销有抵抗力,但并不是百分之百的需要。对推销有抵抗力是好的,能帮你避免在匆忙又欠考虑的情况下买东西。但是,一个读者如果完全不接受所有内容的诉求,那就不必阅读实用性的书了。\n另外还有一个重点。因为实用问题的特性,也因为所有实用作品中都混杂了雄辩,作者的“性格”在实用书中就比理论书中还要来得重要。你在读一本数学用书时,用不着知道作者是谁。他的理论不是好就是坏,这跟他的人格怎样一点关系也没有。但是为了要了解与评断一本道德的论述、政治论文或经济论著,你就要了解一点作者的人格、生活与时代背景。譬如在读亚里士多德的《政治学》之前,就非常需要知道希腊的社会背景是奴隶制的。同样的,在读《君主论》之前,就要知道马基雅维里当时意大利的政治情况,与他跟美第奇家族的关系。因此,在读霍布斯的《利维坦》一书时,就要了解他生活在英国的内战时期,社会中充满暴力与混乱,使整个时代都沉浸在悲哀的病态之中。\n3.赞同实用书之后 # 我们确定你已经看出来了,你在读一本书时要提出的四个问题,到了读实用性的书时有了一点变化。我们就来说明一下这些变化。\n第一个问题:这本书是在谈些什么?并没有改变多少。因为一本实用的书是论说性的,仍然有必要回答这个问题,并作出这本书的大纲架构。\n然而,虽然读任何书都得想办法找出一个作者的问题是什么(规则四涵盖这一点),不过在读实用性的书时,格外是一个决定性的关键。我们说过,你一定要了解作者的目的是什么。换句话说,你一定要知道他想解决的问题是什么。你一定要知道他想要做些什么—因为,在实用性的书中,知道他要做的是什么,就等于是知道他想要你做的是什么。这当然是非常重要的事了。\n第二个问题的变化也不大。为了要能回答关于这本书的意义或内容,你仍然要能够找出作者的共识、主旨与论述。但是,这虽然是第二阶段最后的阅读工作(规则八),现在却显得更重要了。你还记得规则八要你说出哪些是作者已经解决的问题,哪些是还没有解决的问题。在阅读实用性的书籍时,这个规则就有变化了。你要发现并了解作者所建议的、达到他目标的方法。换句话说,在阅读实用性书时,如果规则四调整为:“找出作者想要你做什么。”规则八就该调整为:“了解他要你这么做的目的。”\n第三个问题:内容真实吗?比前两个改变得更多了。在理论性作品中,当你根据自己的知识来比较作者对事物的描绘与说明时,这个问题的答案便出来了。如果这本书所描述的大致与你个人的体验相似时,你就必须承认那是真实的,或至少部分是真实的。实用性的书,虽然也会与真实作比较,但最主要的却是你能不能接受作者的宗旨—他最终的目标,加上他建议的达成目标的方法—这要看你认为追求的是什么,以及什么才是最好的追求方法而定。\n第四个问题:这本书与我何干?可说全部改变了。如果在阅读一本理论性的书之后,你对那个主题的观点多少有点变化了,你对一般事物的看法也就会多少有些调整。(如果你并不觉得需要调整,可能你并没有从那本书中学到什么。)但是这样的调整并不是惊天动地的改变,毕竟,这些调整并不一定需要你探取行动。\n赞同一本实用性的书,却确实需要你采取行动。如果你被作者说服了,他所提议的结论是有价值的,甚至进一步相信他的方法真的能达到目的,那就很难拒绝作者对你的要求了。你会照着作者希望你做的方式来行动。\n当然,我们知道这种情形并不一定会发生。但我们希望你了解的是,如果你不这样做的话,到底代表什么意思。那就表示虽然这个读者表面上同意了作者的结论,也接受了他提出来的方法,但是实际上并没有同意,也没有接受。如果他真的都同意也接受了,他没有理由不采取行动。\n我们用一个例子来说明一下。如果读完本书的第二部分,你(1)同意分析阅读是值得做的。(2)接受这些阅读规则,当作是达到目标的基本要件,你会像我们现在所说的一样,开始照着阅读起来。如果你没有这么做,可能并不是你偷懒或太累了,而是你并不真的同意(1)或(2)。\n在这个论述中有一个明显的例外。譬如你读了一篇文章,是关于如何做巧克力慕斯的。你喜欢巧克力慕斯,也赞同这个作者的结论是对的。你也接受了这个作者所建议的达到目标的方法—他的食谱。但你是男性读者,从不进厨房,也没做过慕斯。在这样的情况中,我们的观点是否就不成立了?\n并不尽然。这正好显示出我们应该要提到的,区分各种类型实用书的重要性。某些作者提出的结论是很通用或一般性的—可供所有的人类使用—另外一些作者的结论却只有少数人能运用。如果结论是通用的—譬如像本书,所谈的是使所有人都能阅读得更好,而不是只有少数人—那么我们所讨论的便适用于每位读者。如果结论是经过筛选的,只适用于某个阶层的人,那么读者便要决定他是否属于那个阶层了。如果他属于那个阶层,这些内容就适合他应用,他多少也有义务照作者的建议采取行动。如果他不属于这个阶层,他可能就没有这样的义务。\n我们说“可能没有这样的义务”,是因为很可能这位读者只是被自己愚弄了,或误解了他自己的动机,而认为自己并不属于那个结论所牵涉的阶层。以巧克力慕斯的例子来说,他不采取行动,可能是表示:虽然慕斯是很可口的东西,但是别人—或许是他妻子—应该做给他吃。在许多例子中,我们承认这个结论是可取的,方法也是可行的,但我们却懒得去做。让别人去做,我们会说,这就算是交待了。\n当然,这个问题主要不是阅读的,而是心理的问题。心理问题会影响我们阅读实用性的作品,因此我们在这里有所讨论。\n第十四章 如何阅读想像文学 # 到目前为止,本书已经讨论的只是大部分人阅读的一半而已。不过,这恐怕也是广义的估算。或许一般人真正花时间阅读的只是报纸与杂志,以及与个人工作有关的读物。就以书籍来说,我们读的小说也多于非小说。而在非小说的领域中,像报章杂志,与当代重大新闻有关的议题最受欢迎。\n我们在前面所设定的规则并不是在欺骗你。在讨论细节之前,我们说明过,我们必须将范围限制在严肃的非小说类中。如果同时解说想像文学与论说性作品,会造成困扰。但是现在我们不能再忽略这一类型的作品了。\n在开始之前,我们要先谈一个有点奇怪的矛盾说法。阅读想像文学的问题比阅读论说性作品的问题更为困难。然而,比起阅读科学、哲学、政治、经济与历史,一般人却似乎更广泛地拥有阅读文学的技巧。为什么会出现这种情况呢?\n当然,也许很多人只是欺骗自己有阅读小说的能力。从我们的教学经验中,当我们问到一个人为什么喜欢小说时,他总是表现出瞳目结舌的样子。很明显,他们乐在其中,但是他们说不出来乐在哪里,或是哪一部分的内容让他们觉得愉悦。这可能说明了,人们可能是好的小说读者,却不是好的评论者。我们怀疑这只是部分的真相。评论式的阅读依赖一个人对一本书的全盘了解。这些说不出他们喜欢小说的理由的人,可能只是阅读了表象,而没有深入内里。无论如何,这个矛盾的概念还不只于此。想像文学的主要目的是娱乐,而非教育。以娱乐为主的读物比教育为主的读物容易讨好,但要知道为什么能讨好则比较困难。要分析美丽,比美丽本身困难多了。\n要将这个重点说清楚,需要对美学作更进一步的分析。我们没法在这里这么做。但是,我们能给你一些如何阅读想像文学的建议。一开始,我们会从否定的说法谈起,而不建立一些规则。其次,我们要用类推的方法,简短地将阅读非小说的规则转化为阅读小说的规则。最后,在下一章,我们会谈到阅读特殊形态的想像文学时所发生的问题,像是小说、戏剧与抒情诗。\n1.读想像文学的“不要” # 为了要用否定的形态来作说明,一开始就有必要掌握论说性作品与文学作品的差异。这些区别会解释为什么我们阅读小说不能像阅读哲学作品一样,或是像证明数学理论那样阅读诗。\n最明显的差别,前面已经提过,与两种文体的目标有关。论说性作品要传达的是知识—在读者经验中曾经有过或没有过的知识。想像文学是在阐述一个经验本身—那是读者只能借着阅读才能拥有或分享的经验—如果成功了,就带给读者一种享受。因为企图不同,这两种不同的作品对心智便有不同的诉求。\n我们都是经由感官与想像来体验事情。我们都是运用判断与推论,也就是理智,才能理解事情。这并不是说我们在思考时用不上想像力,或我们的感官经验完全独立于理性的洞察与反应之外。关键在强调哪一方面的问题而已。小说主要是运用想像力。这也是为什么称之为想像文学的原因,这与理性的科学或哲学相反。\n有关想像文学的事实,带引出我们要建议的否定的指令:不要抗拒想像文学带给你的影响力。\n我们讨论过很多主动的阅读方法。这适用于任何一本书。但在论说性作品与想像文学中,适用的方法却不大相同。阅读论说性作品,读者应该像个捕食的小鸟,经常保持警觉,随时准备伸出利爪。在阅读诗与小说时,相同的活动却有不同的表现方法。如果容许的话,我们可以说那是有点被动的活动,或者,更恰当的说法应该是,那是带着活力的热情。在阅读一个故事时,我们一定要用那样的方式来表现,让故事在我们身上活动。我们要让故事贯穿我们,做任何它想要做的事。我们一定得打开心灵,接纳它。\n我们应该感激论说性的作品—哲学、科学、数学—这些学科塑造出我们活着的真实世界。但我们也不能活在一个完全是这些东西的世界里,偶尔我们也要摆脱一下这些东西。我们并不是说想像文学永远或基本上是逃避现实的。如果从一般的观点来看,逃避的概念是很可鄙的。但事实上就算我们真的要逃避现实,应该也是逃避到一个更深沉、或更伟大的真实里。这是我们内在的真实世界,我们独特的世界观。发现这个真相让我们快乐。这个经验会深深满足我们平时未曾接触的部分自我。总之,阅读一部伟大的文学作品的规则应该以达成某种深沉的经验为目标。这些规则应该尽可能去除我们体验这种深刻感受的阻碍。\n论说性作品与想像文学的基本不同,又造成另一个差异。因为目标完全不同,这两种作品的写法必然不同。想像文学会尽量使用文字潜藏的多重字义,好让这些字特有的多元性增加文章的丰富性与渲染力。作者会用隐喻的方式让整本书整合起来,就像注重逻辑的作者会用文字将单一的意义说明清楚一样。但丁的《神曲》使用的是一般的诗与小说,但每个人阅读起来却各有不同的体会。论说性作品的逻辑目标则是完全清晰,毫无言外之意的解说。在字里行间不能有其他的含意。任何相关与可以陈述的事都得尽可能说个一清二楚才行。相反地,想像文学却要依赖文字中的言外之意。多重含意的隐喻在字里行间所传达的讯息,有时比文字本身还要丰富。整首诗或故事所说的东西,不是语言或文字所能描述的。\n从这个事实,我们得到另一个否定的指令:在想像文学中,不要去找共识、主旨或论述。那是逻辑的,不是诗的,二者完全不同。诗人马克·范多伦(Mark Van Doren)曾经说:“在诗与戏剧中,叙述是让人更模糊的一种媒介。”譬如,你根本就无法在一首抒情诗的任何文句中找到任何他想要“说明”的东西。然而整首诗来看,所有字里行间的关联与彼此的互动,却又陈述了某种完全超越主旨的东西。(然而,想像文学包含的要素也类似共识、主旨、论述,我们待会再讨论。)\n当然,我们可以从想像文学中学习,从诗、故事,特别是戏剧中学习—但是与我们从哲学或科学的书中学习的方法不同。我们都懂得从经验中学习—我们每天生活中的经验。所以,我们也可以从小说在我们想像中所创造出来的经验中学习。在这样的状况下,诗与故事能带给我们愉悦,同时也能教育我们。但这与科学及哲学教导我们的方式不同。论说性的作品不会提供我们新奇的经验。他们所指导的经验是我们已经有的或可以获得的。这也是为什么说论说性作品是教导我们基本的原理,而想像文学则藉由创造我们可以从中学习的经验,教导我们衍生的意义。为了从这样的书中学习,我们要从自己的经验中思考。为了从哲学与科学的书中学习,我们首先必须了解他们的思想。\n最后一个否定的指令:不要用适用于传递知识的,与真理一致的标准来批评小说。对一个好故事来说,所谓“真理”就是一种写实,一种内在可能性,或与真实的神似。那一定要像个故事,但用不着像在做研究或实验一样来形容生活的事实或社会的真相。许多世纪前,亚里士多德强调:“诗与政治对正确的标准是不一致的。”或是说,与物理学或心理学也是不一致的。如果是解剖学、地理或历史作品,被当作是专门的论述,却出现技术上的错误,那就应该被批评。但将事实写错却不会影响到一本小说,只要它能自圆其说,将整体表现得活灵活现便行了。我们阅读历史时,希望多少能看到事实。如果没有看到史实,我们有权利抱怨。我们阅读小说时,我们想要的是一个故事,这个故事只要确实可能在小说家笔下所创造,再经过我们内心重新创造的世界中发生,就够了。\n我们读了一本哲学的书,也了解了之后,我们会做什么呢?我们会考验这本书,与大家共通的经验作对照—这是它的灵感起源,这也是它惟一存在的理由。我们会说:这是真的吗?我们也有这样的感觉吗?我们是不是总是这样想,却从来没有意识到?以前或许很模糊的事,现在是不是却很明显了?作者的理论或说明虽然可能很复杂,是不是却比我们过去对这个观念的混淆来得清楚,也简单多了?\n如果我们能很肯定地回答上述问题,我们与作者之间的沟通便算是建立起来了。当我们了解,也不反对作者的观点时,我们一定要说:“这确实是我们共通的观念。我们测验过你的理论,发现是正确的。”\n但是诗不一样。我们无法依据自己的经验来评断《奥赛罗》(Othello),除非我们也是摩尔人,也和被怀疑不贞的威尼斯淑女结婚。而就算如此,也不是每一个摩尔人都是奥赛罗,每一个威尼斯淑女都是苔丝德蒙娜。而大部分这样的夫妻婚姻都可能很幸福,不会碰到阴险的伊亚格。事实上,这么不幸的人,万中不见一。奥赛罗与这出戏一样,都是独一无二的。\n2.阅读想像文学的一般规则 # 为了让上面所谈的“不要”的指令更有帮助,一定还需要一些建设性的建议。这些建议可以由阅读论说性作品的规则中衍生出来。\n前面我们谈过阅读论说性作品的三组规则,第一组是找出作品的整体及部分结构,第二组是定义与诠释书中的共识、主旨与论述。第三组是评论作者的学说,以赞同或反对的意见完成我们对他的作品的理解。我们称这三组规则为架构性、诠释性与评论性的。同样,在阅读诗、小说与戏剧时,我们也可以发现类似的规则。\n首先,我们可以将架构性的规则—拟大纲的规则—改变为适合阅读小说的规则:\n(1)你必须将想像文学作品分类。抒情诗在叙述故事时,基本上是以表达个人情绪的经验为主。小说与戏剧的情节比较复杂,牵涉到许多角色,彼此产生互动与反应,以及在过程中情感的变化。此外,每个人都知道戏剧与小说不同,因为戏剧是以行动与说话来叙述剧情的。(在后面我们会谈到一些有趣的例外。)剧作家不需要自己现身说法,小说家却经常这么做。所有这些写作上的差异,带给读者不同的感受。因此,你应该能一眼看出你在读的是哪一种作品。\n(2)你要能抓住整本书的大意。你能不能掌握这一点,要看你能不能用一两句话来说明整本书的大意。对论说性的作品来说,重点在作者想要解决的主要问题上。因此,这类书的大意可以用解决间题的方程式,或对问题的回答来作说明。小说的整体大意也与作者面对的问题有关,而我们知道这个问题就是想要传达一个具体的经验,所以一篇故事的大意总是在情节之中。除非你能简要地说明剧情—不是主旨或论述—否则你还是没有抓住重点。在情节中就有大意。\n要注意到,我们所说的整体情节与小说中所要使用的独特语言之间毫无冲突之处。就是一首抒情诗也有我们这里所谓的“情节”。然而,不论是抒情诗、小说,还是戏剧的“情节”,指的都只是其中的架构或场景,而不是读者透过作品在心中重新创造的具体经验。情节代表的是整本作品的大意,而整本作品才是经验本身。这就像对论说性作品作一个逻辑上的总结,就代表了对书中的论述作个总结。\n(3)你不仅要能将整本书简化为大意,还要能发现整本书各个部分是如何架构起来的。在论说性作品中,部分的架构是与整体架构有关的,部分问题的解决对整体问题的解决是有帮助的。在小说中,这些部分就是不同的阶段,作者借此发展出情节来—角色与事件的细节。在安排各个部分的架构上,这两种类型的书各有巧妙。在科学或哲学的作品中,各个部分必须有条理,符合逻辑。在故事中,这些部分必须要在适当的时机与规划中出现,也就是从开头、中间到结尾的一个过程。要了解一个故事的架构,你一定要知道故事是从哪里开始的—当然,不一定是从第一页开始的—中间经过些什么事,最后的结局是什么。你要知道带来高潮的各种不同的关键是什么,高潮是在哪里、又如何发生的,在这之后的影响又是什么?(我们说“在这之后的影响”并不是说故事结束之后的事,没有人能知道那些事。我们的意思是在故事中的高潮发生之后,带来什么样的后果。)\n随着我们刚刚所提的重点,出现了一个重要的结果。在论说性作品中,各个部分都可以独立解读,而小说却不同。欧几里得将他的《几何原理》分成三十个部分发表,或照他所说的分成三十册发表,其中每一部分都可以单独阅读。这是论说性作品中组织得最完整的一个例子。其中的每个部分或章节,分开来看或合起来看都有意义。但是一本小说中的一章,剧本中的一幕,或是一句诗从整体中抽出来之后,通常就变得毫无意义了。\n其次,阅读小说时候的诠释规则是什么?我们在前面谈过,诗与逻辑作品所使用的语言是不同的,因此在找出共识、主旨与论述时,所使用的规则也要有点变化。我们知道我们不该这么做的,不过我们非得找出类似的规则才行。\n(1)小说的要素是插曲、事件、角色与他们的思想、言语、感觉及行动。这些都是作者所创造出来的世界中的要素。作者操纵着这些要素的变化来说故事。这些要素就是逻辑作品中的共识。就像你要跟逻辑作品的作者达成共识一样,你也要能熟知每个事件与人物的细节。如果你对角色并不熟悉,也无法对事件感同身受,你就是还没有掌握到故事的精髓。\n(2)共识与主旨有关。小说的要素与整个表现的场景或背景有关。一个富有想像力的作者创造出一个世界来,他的角色在其中“生活,行动,有自己的天地。”因此,阅读小说时类似指导你找出作者主旨的规则,可以说明如下:在这个想像的世界中宾至如归。知道一切事件的进行,就像你亲临现场,身历其境。变成其中的一个成员,愿意与其中的角色做朋友,运用同情心与洞察力参与事件的发生,就像你会为朋友的遭遇所做的事一样。如果你能这么做,小说中的要素便不会再像一个棋盘上机械式移动的孤单棋子,你会找出其间的关联性,赋予他们真正存活的活力。\n\u0026lt;3)如果说论说性作品中有任何活动,那就是论述的发展。由证据与理由到结构的一个逻辑性的演变。在阅读这样的一本书时,必须追踪论述的发展。先找出共识与主旨之后,然后分析其推论。而在诠释小说的阅读中,也有类似的最后一个规则。你对角色都熟悉了,你加人了这个想像的世界,与他们生活在一起,同意这个社会的法律,呼吸同样的空气,品味同样的食物,在同样的高速公路上旅行。现在,你一定要跟随他们完成这场探险。这些场景或背景,社会的组合,是小说中各个要素之间静态的联系(如同主旨一样)。而情节的披露(如同论述或推论)是动态的联系。亚里士多德说情节是一个故事的灵魂。要把一个故事读好,你就要能把手指放在作者的脉搏上,感觉到每一次的心跳。\n结束讨论小说的类似阅读规则之前,我们要提醒你,不要太仔细检验这些类似的规则。这些类似的规则就像是一个隐喻或象征,如果压迫得太用力,可能就会崩溃了。我们所建议的三个列出大纲的步骤,可以让你逐步了解作者如何在想像的世界中完成一个作品。这不但不会破坏你阅读小说或戏剧的乐趣,还能加强你的乐趣,让你对自己喜乐的来源有更多的了解。你不但知道自己喜欢什么,还知道为什么会喜欢。\n另一个提醒:前面所说的规则主要适用于小说与戏剧。引申到有故事叙述的抒情诗,也同样适用。没有故事叙述的抒情诗,仍然可以适用这个规则,只是没那么贴切。一首抒情诗是在呈现一个具体的经验,就像一个长篇故事一样,想要在读者心中重新塑造这种经验。就算最短的诗里也有开始,过程与结束。就像任何经验都有时间顺序一样,无论多么短暂飘渺的经验都是如此。在短短的抒情诗中,虽然角色可能非常少,但至少永远有一个角色—诗人本身。\n第三,也是最后一个,小说的阅读批评规则是什么?你可能记得我们在论说性作品中作的区隔,也就是根据一般原理所作的批评,与根据个人特殊观点所作的评论—特殊评论。根据一般原理的部分,只要作一点变化就行了。在论说性作品中,这个规则是:在你还不了解一本书之前,不要评论一本书—不要说你同意或反对这个论点。所以在这里,类似的规则是:在你衷心感激作者试着为你创造的经验之前,不要批评一本想像的作品。\n这里有一个重要的推论。一个好读者不会质疑作者所创造出来,然后在他自己心中又重新再创造一遍的世界。亨利·詹姆斯(HenryJames)在《小说的艺术》(The Art of Fiction)中曾说道:“我们要接纳作者的主题、想法与前提。我们所能批评的只是他所创造出来的结果。”这就是说,我们要感激作者将故事写出来。譬如故事发生在巴黎,就不该坚持说如果发生在明尼苏达州的明尼阿波里斯市会比较好。但是我们有权利批评他所写的巴黎人与巴黎这个城市。\n换句话说,对于小说,我们不该反对或赞成,而是喜欢或不喜欢。我们在批评论说性作品时,关心的是他们所陈述的事实。在批评唯美文学时,就像字义所形容的,我们主要关心的是它的美丽。这样的美丽,与我们深切体会之后的喜悦密切呼应。\n让我们在下面重述一下这些规则。在你说自己喜欢或不喜欢一本文学作品之前,首先你要能真正努力过并欣赏作者才行。所谓欣赏,指的是欣赏作者借着你的情绪与想像力,为你创造的一个世界。因此,如果你只是被动地阅读一本小说(事实上,我们强调过,要热情地阅读),是没法欣赏一本小说的。就像在阅读哲学作品时,被动的阅读也一样无法增进理解力的。要做到能够欣赏,能够理解,在阅读时一定要主动,要把我们前面说过的,所有分析阅读的规则全拿出来用才行。\n你完成这样的阅读阶段后,就可以作评论了。你的第一个评论自然是一种你的品味。但是除了说明喜欢或不喜欢之外,还要能说出为什么。当然,你所说的原因,可能真的是在批评书的本身,但乍听之下,却像是在批评你自己—你的偏好与偏见—而与书无关。因此,要完成批评这件事,你要客观地指出书中某些事件造成你的反感。你不只要能说明你自己为什么喜欢或不喜欢,还要能表达出这本书中哪些地方是好的,哪些是不好的,并说明理由才行。\n你越能明白指出诗或小说带给你喜悦的原因,你就越了解这本书的优点是什么。你会慢慢建立起批评的标准,你也会发现许多跟你有同样品味的人与你一起分享你的论点。你还可能会发现一件我们相信如此的事:懂得阅读方法的人,文学品味都很高。\n第十五章 阅读故事、戏剧与诗的一些建议 # 在前一章里,我们已经谈过阅读想像文学的一般规则,同样也适用于更广义的各种想像文学—小说、故事,无论是散文或诗的写法(包括史诗);戏剧,不论是悲剧、喜剧或不悲不喜;抒情诗,无论长短或复杂程度。\n这些一般规则运用在不同的想像文学作品时,就要作一些调整。在这一章里,我们会提供一些调整的建议。我们会特别谈到阅读故事、戏剧、抒情诗的规则,还会包括阅读史诗及伟大的希腊悲剧时,特殊问题的注意事项。\n在开始之前,必须再提一下前面已经提过的阅读一本书的四个问题。这四个问题是主动又有要求的读者一定会对一本书提出来的问题,在阅读想像文学作品时也要提出这些问题来。\n你还记得前三个问题是:第一,这整本书的内容是在谈些什么?第二,内容的细节是什么?是如何表现出来的?第三,这本书说的是真实的吗?全部真实或部分真实?前一章已经谈过这三个规则运用在想像文学中的方法了。要回答第一个问题,就是你能说出关于一个故事、戏剧或诗的情节大意,并要能广泛地包括故事或抒情诗中的动作与变化。要回答第二个问题,你就要能辨识剧中所有不同的角色,并用你自己的话重新叙述过发生在他们身上的关键事件。要回答第三个问题,就是你能合理地评断一本书的真实性。这像一个故事吗?这本书能满足你的心灵与理智吗?你欣赏这本书带来的美吗?不管是哪一种观点,你能说出理由吗?\n第四个问题是,这本书与我何关?在论说性作品中,要回答这个问题就是要采取一些行动。在这里,“行动”并不是说走出去做些什么。我们说过,在阅读实用性书时,读者同意作者的观点—也就是同意最后的结论—就有义务采取行动,并接受作者所提议的方法。如果论说性的作品是理论性的书时,所谓的行动就不是一种义务的行为,而是精神上的行动。如果你同意那样的书是真实的,不论全部或部分,你就一定要同意作者的结论。如果这个结论暗示你对事物的观点要作一些调整,那么你多少都要调整一下自己的看法。\n现在要认清楚的是,在想像文学作品中,第四个也是最后一个问题要作一些相当大的调整。从某方面来说,这个间题与阅读诗与故事毫无关系。严格说起来,在你读好了—也就是分析好了小说、戏剧或诗之后,是用不着采取什么行动的。在你采取类似的分析阅读,回答前面三个问题之后,你身为读者的责任就算尽到了。\n我们说“严格说起来”,是因为想像文学显然总是会带引读者去做各种各样的事。比起论说性作品,有时候一个故事更能带动一个观点—在政治、经济、道德上的观点。乔治·奥威尔(George Orwell)的,《动物农庄》(Animal Farm)与《一九八四》都强烈地攻击极权主义。赫胥黎(Aldous Huxley)的《美丽新世界》(Brave New World)则激烈地讽刺科技进步下的暴政。索尔仁尼琴(Alexander Solzhenitsyn)的《第一圈》(The First Circle)告诉我们许多琐碎、残酷又不人道的苏联官僚政治问题,那比上百种有关事实的研究报告还要惊人。那样的作品在人类历史上被查禁过许多次,原因当然很明显。怀特(E. B. White)曾经说过:“暴君并不怕唠叨的作家宣扬自由的思想—他害怕一个醉酒的诗人说了一个笑话,吸引了全民的注意力。”\n不过,阅读故事与小说的主要目的并不是要采取实际的行动。想像文学可以引导出行动,但却并非必要,因为它们属于纯艺术的领域。\n所谓“纯”艺术,并不是因为“精致”或“完美”,而是因为作品本身就是一个结束,不再与其他的影响有关。就如同爱默生所说的,美的本身就是存在的惟一理由。\n因此,要将最后一个问题应用在想像文学中,就要特别注意。如果你受到一本书的影响,而走出户外进行任何行动时,要问问你自己,那本书是否包含了激励你的宣言,让你产生行动力?诗人,正确来说,不是要来提出宣言的。不过许多故事与诗确实含有宣言主张,只是被深藏起来而已。注意到他们的想法,跟着作出反应并没有问题。但是要记得,你所留意的与反应出来的是另外一些东西,而不是故事或诗的本身。这是想像文学本身就拥有的自主权。要把这些文学作品读通,你惟一要做的事就是去感受与体验。\n1.如何阅读故事书 # 我们要给你阅读故事书的第一个建议是:快读,并且全心全意地读。理想上来说,一个故事应该一口气读完,但是对忙碌的人来说,要一口气读完长篇小说几乎是不可能的事。不过,要达到这个理想,最接近的方法就是将阅读一篇好故事的时间压缩到合理的长度。否则你可能会忘了其间发生的事情,也会漏掉一些完整的情节,最后不知道自己在读的是什么了。\n有些读者碰到自己真正喜欢的小说时,会想把阅读的时间拉长,好尽情地品味,浸淫在其中。在这样的情况中,他们可能并不想借着阅读小说,来满足他们对一些未知事件或角色的了解。在后面我们会再谈到这一点。\n我们的建议是要读得很快,而且全神投人。我们说过,最重要的是要让想像的作品在你身上发生作用。这也就是说,让角色进入你的心灵之中,相信其中发生的事件,就算有疑惑也不要怀疑。在你了解一个角色为什么要做这件事之前,不要心存疑虑。尽量试着活在他的世界里,而不是你的世界,这样他所做的事就很容易理解了。除非你真的尽力“活在”这样的虚构世界中,否则不要任意批评这个世界。\n下面的规则中,我们要让你自己回答第一个问题,那也是阅读每一本书时要提出的间题—这整本书在谈些什么?除非你能很快读完,否则你没法看到整个故事的大要。如果你不专心一致地读,你也会漏掉其中的细节。\n根据我们的观察,一个故事的词义,存在于角色与事件之中。你要对他们很熟悉,才能厘清彼此的关系。有一点要提醒的,以《战争与和平》为例,许多读者开始阅读这本小说巨著时,都会被一堆出场的人物所混淆了,尤其是那些名字听起来又陌生得不得了。他们很快便放弃了这本书,因为他们立刻认为自己永远不会搞清楚这些人彼此之间的关系了。对任何大部头的小说而言,都是如此—而如果小说真的很好,我们可希望它越厚越好。\n对懦弱的读者来说,这样的情况还不只发生在阅读上。当他们搬到一个新的城市或郊区,开始上新的学校或开始新的工作,甚至刚到达一个宴会里时,都会发生类似的情形。在这样的情境中,他们并不会放弃。他们知道过一阵子之后,个人就会融人整体中,朋友也会从那一批看不清长相的同事、同学与客人中脱颖而出。我们可能没办法记住一个宴会中所有人的姓名,但我们会记起一个跟我们聊了一小时的男人,或是我们约好下次要见面的一个女人,或是跟我们孩子同校的一个家长。在小说中也是同样的情况。我们不期望记住每一个名字,许多人不过是背景人物,好衬托出主角的行动而已。无论如何,当我们读完《战争与和平》或任何大部头的书时,我们就知道谁是重要的人物,我们也不会忘记。虽然托尔斯泰的作品是我们很多年前读的书,但是皮埃尔、安德鲁、娜塔莎、玛丽公主、尼可拉斯—这些名字会立刻回到我们的记忆中。\n不管发生了多少事件,我们也会很快就明白其中哪些才是重要的。一般来说,作者在这一点上都会帮上很多忙。他们并不希望读者错过主要的情节布局,所以他们从不同的角度来铺陈。但我们的重点是:就算一开始不太清楚,也不要焦虑。事实上,一开始本来就是不清楚的。故事就像我们的人生一样,在生命中,我们不可能期望了解每一件发生在我们身上的事,或把一生全都看清楚。但是,当我们回顾过去时,我们便了解为什么了。所以,读者在阅读小说时,全部看完之后再回顾一下,就会了解事件的关联与活动的前后顺序了。\n所有这些都回到同一个重点:你一定要读完一本小说之后,才能谈你是否把这个故事读通了。无论如何,矛盾的是,在小说的最后一页,故事就不再有生命了。我们的生活继续下去,故事却没有。走出书本之外,那些角色就没有了生命力。在阅读一本小说时,在第一页之前,到最后一页之后,你对那些角色会发生些什么事所产生的想像,跟下一个阅读的人没什么两样。事实上,这些想像都是毫无意义的。有些人写了《哈姆雷特》的前部曲,但是都很可笑。当《战争与和平》一书结束后,我们也不该问皮埃尔与娜塔莎的结局是什么?我们会满意莎士比亚或托尔斯泰的作品,部分原因是他们在一定的时间里讲完了故事,而我们的需求也不过如此。\n我们所阅读的大部分是故事书,各种各样的故事。不能读书的人,也可以听故事。我们甚至还会自己编故事。对人类而言,小说或虚构的故事似乎是不可或缺的。为什么?\n其中一个理由是:小说能满足我们潜意识或意识中许多的需要。如果只是触及意识的层面,像论说性作品一样,当然是很重要的。但小说一样也很重要,因为它触及潜意识的层面。\n简单来说—如果要深人讨论这个主题会很复杂—我们喜欢某种人,或讨厌某种人,但却并不很清楚为什么。如果是在小说中,某个人受到奖励或处罚,我们都会有强烈的反应。我们会甚至因而对这本书有艺术评价之外的正面或负面的印象。\n譬如小说中的一个角色继承了遗产,或发了大财,我们通常也会跟着高兴。无论如何,这只有当角色是值得同情时才会发生—意思就是我们认同他或她的时候。我们并不是说我们也想继承遗产,只是说我们喜欢这本书而已。\n或许我们都希望自己拥有的爱比现在拥有的还要丰富。许多小说是关于爱情的—或许绝大多数—当我们认同其中恋爱的角色时,我们会觉得快乐。他们很自由,而我们不自由。但我们不愿意承认这一点,因为这会让我们觉得我们所拥有的爱是不完整的。\n其实,在每个人的面具之下,潜意识里都可能有些虐待狂或被虐狂。这些通常在小说中获得了满足,我们会认同那位征服者或被虐者,或是两者皆可。在这样的状况中,我们只会简单地说:我们喜欢“那种小说”—用不着把理由说得太清楚。\n最后,我们总是怀疑生命是不公平的。为什么好人受苦,坏人却成功?我们不知道,也无法知道为什么,但这个事实让所有的人焦虑。在故事中,这个混乱又不愉快的情况被矫正过来了,我们觉得格外满足。\n在故事书中—小说、叙事诗或戏剧—公理正义确实是存在的。人们得到他们该得的。对书中的角色来说,作者就像上帝一样,依照他们的行为给他们应得的奖励或惩罚。在一个好故事中,在一个能满足我们的故事中,至少该做到这一点。关于一个坏故事最惹人厌的一点是,一个人受奖励或惩罚一点都不合情合理。真正会说故事的人不会在这一点上出错。他要说服我们:正义—我们称之为诗的正义(poetic justice)—已经战胜了。\n大悲剧也是如此。可怕的事情发生在好人身上,我们眼中的英雄不该承受这样的厄运,但最后也只好理解命运的安排。而我们也非常渴望能与他分享他的领悟。如果我们知道如此—我们也能面对自己在现实世界中所要碰上的事了。《我要知道为什么》(I Want to knowWhy)是舍伍德·安德森(Sherwood Anderson)所写的一个故事,也可以用作许多故事的标题。那个悲剧英雄确实学到了为什么,当然过程很困难,而且是在生活都被毁了之后才明白的。我们可以分享他的洞察力,却不需要分享他的痛苦遭遇。\n因此,在批评小说时,我们要小心区别这两种作品的差异:一种是满足我们个人特殊潜意识需求的小说—那会让我们说:“我喜欢这本书,虽然我并不知道为什么。”另一种则是满足大多数人潜意识需求的小说。用不着说,后者会是一部伟大的作品,世代相传,永不止息。只要人活着一天,这样的小说就能满足他,给他一些他需要的东西—对正义的信念与领悟,平息心中的焦虑。我们并不知道,也不能确定真实的世界是很美好的。但是在伟大的作品中,世界多多少少是美好的。只要有可能,我们希望能经常住在那个故事的世界里。\n2.关于史诗的重点 # 在西方传统作品中,最伟大的荣耀,也最少人阅读的就是史诗了。特别像是荷马的《伊里亚特》与《奥德赛》,维吉尔的《埃涅阿斯纪》,但丁的《神曲》与弥尔顿的《失乐园》。其中的矛盾之处值得我们注意。\n从过去二千五百年以来只写成极少数的史诗就可以看出来,这是人类最难写的一种作品。这并不是我们不愿意尝试,几百首史诗都曾经开始写过,其中像华兹华斯(Wordsworth)的《序曲》(Prelude)、拜伦(Byron)的《唐璜》(Don Juan),都已经写了大部分,却并没有真正完成。执着于这份工作,而且能完成工作的诗人是值得荣耀的。而更伟大的荣耀是属于写出那五本伟大作品的诗人,但这样的作品并不容易阅读。\n这并不只是因为这些书都是用韵文写的—除了原本就是以英语写作的《失乐园》之外,其他的史诗都有散文的诠释作品出现,以帮助我们理解。真正的困难似乎在于如何跟随作品逐步升高那种环绕着主题的追寻。阅读任何一部重要的史诗对读者来说都有额外的要求—要求你集中注意力,全心参与并运用想像力。阅读史诗所要求的努力确实是不简单的。\n大部分人都没注意到,只不过因为不肯付出这种努力来阅读,我们的损失有多大。因为好的阅读—我们该说是分析阅读—能让我们收获良多,而阅读史诗,至少就像阅读其他小说作品一样,能让我们的心灵更上层楼。不幸的是,如果读者不能善用阅读技巧来阅读这些史诗,将会一无所获。\n我们希望你能痛下决心,开始阅读这五本史诗,你会逐步了解这些作品的。如果你这么做,我们确定你不会失望。你还可能享受到更进一步的满足感。荷马、维吉尔、但丁与弥尔顿—每一个优秀的诗人都是他们的读者,其他作者也不用说。这五本书再加上《圣经》,是任何一个认真的读书计划所不可或缺的读物。\n3.如何阅读戏剧 # 一个剧本是一篇小说、故事,同时也真的该像读一个故事一样阅读。因为剧本不像小说将背景描绘得清楚,或许读者阅读的时候要更主动一些,才能创造出角色生活与活动的世界的背景。不过在阅读时,两者的基本问题是相似的。\n然而,其中还是有一个重要的差异。你在读剧本时,不是在读一个已经完全完成的作品。完成的剧本(作者希望你能领会的东西)只出现在舞台的表演上。就像音乐一样必须能倾听,阅读剧本所缺乏的就是身体语言实际的演出。读者必须自己提供那样的演出。\n要做到这一点的惟一方法是假装看到演出的实景。因此,一旦你发现这个剧本谈的是什么,不论是整体或部分,一旦你能回答有关阅读的所有问题后,你就可以开始导演这个剧本。假设你有六七个演员在你眼前,等待你的指令。告诉他们如何说这一句台词,如何演那一幕。解释一下重要的句子,说明这个动作如何让整出戏达到高潮。你会玩得很开心,也会从这出戏中学到很多。\n有个例子可以说明我们的想法。在《哈姆雷特》第二幕第二场中,波隆尼尔向国王与王后密告哈姆雷特的愚行,因为他爱上了奥菲莉雅,而她会阻碍王子的前程。国王与王后有点迟疑,波隆尼尔便要国王跟他躲在挂毯后面,好偷听哈姆雷特与奥菲莉雅的谈话。这一幕出现在第二幕第二场中,原文第160至170行。很快地,哈姆雷特读着书上场了,他对波隆尼尔说的话像打哑谜,于是波隆尼尔说道:“他虽疯,但却有一套他自己的理论。”过了一阵子,第三幕的开头,哈姆雷特进场,说出了著名的独白:“要活,还是要死?”然后奥菲莉雅出现在他眼前,打断了他的话。他与她说了一段话,看起来神智正常,但突然间他狂叫道:“啊!啊!你是真诚的吗?”(第三幕,第一场,103行)。现在的问题是:哈姆雷特是否偷听到波隆尼尔与国王准备侦察他的对话?或是他听到了波隆尼尔说要“让我的女儿去引诱他”?如果真是如此,那么哈姆雷特与波隆尼尔及奥菲莉雅的对话代表的都是同一件事。如果他并没有听到这个密谋,那又是另一回事了。莎士比亚并没有留下任何舞台指导,读者(或导演)必须自己去决定。你自己的判断会是了解整出剧的中心点。\n莎士比亚的许多剧本都需要读者这样主动地阅读。我们的重点是,无论剧作家写得多清楚,一字不误地告诉我们发生了什么事,还是很值得做这件事。(我们没法抱怨说听不清楚,因为对白全在我们眼前。)如果你没有将剧本搬上心灵的舞台演出过,或许你还不能算是读过剧本了。就算你读得再好,也只是读了一部分而已。\n前面我们提过,这个阅读规则有一个有趣的例外,就是剧作家不能像小说家一样对读者直接说话。(菲尔丁所写的《汤姆琼斯》就会直接向读者发言,这也是一部伟大的小说。)其中有两个例外前后将近相差了二十五世纪之久。阿里斯托芬(Aristophanes),古希腊的喜剧剧作家,写过一些所谓的“古老喜剧”(Old Comedy)的例子留传下来。在阿里斯托芬的戏剧中,经常会或至少会有一次,主要演员从角色中脱身而出,甚至走向观众席,发表一场政治演说,内容与整出戏是毫无关联的。那场演说只是在表达作者个人的感觉而已。现在偶尔还有戏剧会这么做—没有一项有用的艺术手法是会真正失传的—只是他们表现的手法或许比不上阿里斯托芬而已。\n另一个例子是萧伯纳,他不但希望自己的剧本能够演出,还希望能让读者阅读。他出版了所有的剧本,甚至有一本《心碎之家》(Heart break House)是在演出之前就出版的。在剧本之前,他写了很长的序言,解释剧本的意义,还告诉读者如何去理解这出剧。(在剧本中他还附上详尽的舞台指导技巧。)要阅读萧伯纳式的剧本,却不读萧伯纳所写的前言,就等于是拒绝了作者最重要的帮助,不让他辅助你理解这出戏。同样地,一些现代剧作家也学习萧伯纳的做法,但都比不上他的影响力。\n另一点建议可能也有帮助,尤其是在读莎士比亚时更是如此。我们已经提过,在阅读剧本时最好是一气呵成,才能掌握住整体的感觉。但是,许多剧本都是以韵文写的,自从1600年以来语言变化之后,韵文的句子读起来就相当晦涩,因此,把剧本大声地读出来倒经常是不错的方法。要慢慢读,就像是听众在听你说话一样,还是带着感情读—也就是说要让那些句子对你别有深意。这个简单的建议会帮助你解决许多问题。只有当这样做之后还有问题,才要找注解来帮助你阅读。\n4.关于悲剧的重点 # 大多数剧本是不值得阅读的。我们认为这是因为剧本并不完整。剧本原来就不是用来阅读的—而是要演出的。有许多伟大的论说性作品,也有伟大的小说、故事与抒情诗,却只有极少数的伟大剧本。无论如何,这些少数的剧作—埃斯库罗斯(Aeschylus)、索福克勒斯(Sophocles)、欧里庇得斯(Euripedes)的悲剧,莎士比亚的戏剧,莫里哀(Moliere)的喜剧及少数的现代作品—都是非常伟大的作品。因为在他们的作品中包含了人类所能表现的既深刻又丰富的洞察力。\n在这些剧本中,对初学者来说,希腊悲剧可能是最难人门的。其中一个原因是,在古代,这些悲剧是一次演出三幕的,三幕谈的都是同一个主题,但是今天除了埃斯库罗斯的《俄瑞斯底亚》(Oresteia)之外,其他的都只剩下独幕剧。另一个原因是,几乎很难在心中模拟这些悲剧,因为我们完全不知道希腊的导演是如何演出这样的戏剧的。还有一个原因,这些悲剧通常来自一些故事,这对当时的观众来说是耳熟能详的事,对我们而言却只是一个剧本。以俄狄浦斯的故事为例,尽管我们非常熟悉那个故事,就像我们熟悉华盛顿与樱桃树的故事一样,但是看索福克勒斯如何诠释这个故事是一回事,把《俄狄浦斯王》当作一个主要的故事,然后来想像这个熟悉的故事所提供的背景是什么,又是另一回事。\n不过,这些悲剧非常有力量,虽然有这么多障碍却仍然流传至今。把这些剧本读好是很重要的,因为它们不只告诉我们有关这个世界的一切,也是一种文学形式的开端,后来的许多剧作家如拉辛(Racine)及奥尼尔(O\u0026rsquo; Neil)都是以此为基础的。下面还有两点建议可能对你阅读希腊悲剧有帮助。\n第一,记住悲剧的精髓在时间,或是说缺乏时间。如果在希腊悲剧中有足够的时间,就没有解决不了的事。问题是时间永远不够。决定或选择都要在一定的时刻完成,没有时间去思考,衡量轻重。因为就算悲剧英雄也是会犯错的—或许是特别会犯错—所作的决定也是错的。对我们来说很容易看出来该做些什么,但我们能在有限的时间中看清楚一切吗?在阅读希腊悲剧时,你要一直把这个问题放在心中。\n第二,我们确实知道在希腊的戏剧中,所有的悲剧演员都穿一种高出地面几英寸的靴子(他们也戴面具)。叙述旁白的演员虽然有时会戴面具,但不会穿这种靴子。因此,一边是悲剧的主角,另一边是叙述旁白的演员,两相比较之下,就可以看出极大的差异了。因此你要记得,在读旁白的部分时,你要想像这些台词是跟你一般身高的人所说出来的话,而在读悲剧人物的台词时,你要想像这是出自一个大人物的口中,他们不只是在形象上,在实际身高上也高出你一截。\n5.如何阅读抒情诗(Lyric Poetry) # 最简单的有关诗的定义(就跟这个标题一样,这里所谓的诗是有所限制的),就是诗人所写的东西。这样的定义看起来够浅显明白了,但是仍然有人会为此争执不已。他们认为诗是一种人格的自然宣泄,可能借文字表达出来,也可能借身体的行动传达出来,或是由音乐宣泄出来,甚至只是一种感觉而已。当然,诗与这些都有点关系,诗人也能接受这样的说法。关于诗有一种很古老的观念,那就是诗人要向内心深处探索,才能创造出他们的诗句。因此,他们的心灵深处是一片神秘的“创造之泉”。从这个角度来看,任何人在任何时间,只要处于孤独又敏感的状态,都可以创造出诗句来。虽然我们都承认这样的定义已经说中了要点,不过下面我们要用来说明诗的又是更狭窄的定义。无论我们心中如何激荡着原始的诗情,但是诗仍是由文字组成的,而且是以条理分明,精巧熟练的方式所组合出来的。\n另一种关于诗的定义,同样也包含了一些要点。那就是诗(主要是抒情诗)如果不是赞美,或是唤起行动(通常是革命行动),或者如果不是以韵文写作,特别是运用所谓“诗的语言”来写作,那就算不上是真正的诗。在这个定义中,我们故意将一些最新跟最旧的理论融合起来。我们的观点是,所有这些定义,包括我们还会提到的一些定义,那太狭隘了。而上一段所说的诗的定义,又太广泛了。\n在狭隘与广泛的定义之间,有一个核心概念,那就是只要他们觉得适合,就会承认那是诗了。如果我们想要特别说明出这核心概念是什么,我们就是在给自己找麻烦,而我们不打算这么做。此外,我们也确定你知道我们在谈的是什么。我们十之八九敢肯定,或是百分之九十九确定你会同意我们所说的X是诗,Y不是诗的道理。这个概念足够说明我们的议题了。\n许多人相信他们不能读抒情诗—尤其是现代诗。他们认为这种诗读起来很困难,含糊不清又复杂无比,需要花上很多的注意力,自己要很努力才行,因此实在不值得花上这么多时间来读。我们要说两个观念:第一,抒情诗,任何现代诗,只要你肯拿起来读,你会发现并不像你想的要花那么大的工夫。其次,那绝对是值得你花时间与精力去做的事。\n我们并不是说你在读诗就不用花精神。一首好诗可以用心研读,一读再读,并在你一生当中不断地想起这首诗。你会在诗中不断地找到新点子、新的乐趣与启示,对你自己及这个世界产生新的想法。我们的意思是;接近一首诗,研读这首诗,并不像你以为的那样困难。\n阅读抒情诗的第一个规则是:不论你觉得自己懂不懂,都要一口气读完,不要停。这个建议与阅读其他类型书的建议相同,只是比起阅读哲学或科学论文,甚至小说或戏剧,这个规则对诗来说更重要。\n事实上,许多人在阅读诗,尤其是现代诗时会有困难,因为他们并不知道阅读诗的第一个规则。面对艾略特、迪兰·托马斯(DylanThomas)或其他“费解”的现代诗时,他们决定全神投人,但读了第一行或第一段之后便放弃了。他们没法立即了解这行诗,便以为整首诗都是如此了。他们在字谜间穿梭,想重新组合混乱的语法,很快地他们放弃了,并下结论说:他们怀疑现代诗对他们而言是太难理解了。\n不光是现代抒情诗难懂。许多好诗用词都很复杂,而且牵涉到他们当时的语言与思想。此外,许多外表看起来很简单的诗,其实内在的架构都很复杂。\n但是任何一首诗都有个整体大意。除非我们一次读完,否则无法理解大意是什么,也很难发现诗中隐藏的基本感觉与经验是什么。尤其是在一首诗中,中心思想绝不会在第一行或第一段中出现的。那是整首诗的意念,而不是在某一个部分里面。\n阅读抒情诗的第二个规则是:重读一遍—大声读出来。我们在前面这样建议过,譬如像是诗般的戏剧如莎士比亚的作品就要朗诵出声来。读戏剧,那会帮助你了解。读诗,这却是基本。你大声朗诵诗句,会发现似乎说出来的字句可以帮助你更了解这首诗。如果你朗诵出来,比较不容易略过那些不了解的字句,你的耳朵会抗议你的眼睛所忽略的地方。诗中的节奏或是有押韵的地方,能帮助你把该强调的地方突显出来,增加你对这首诗的了解。最后,你会对这首诗打开心灵,让它对你的心灵发生作用—一如它应有的作用。\n在阅读抒情诗时,前面这两个规则比什么都重要。我们认为如果一个人觉得自己不能读诗,只要能遵守前面这两个规则来读,就会发现比较容易一些了。一旦你掌握住一首诗的大意时,就算是很模糊的大意,你也可以开始提出问题来。就跟论说性作品一样,这是理解之钥。\n对论说性作品所提出的问题是文法与逻辑上的问题。对抒情诗的问题却通常是修辞的问题,或是句法的问题。你无法与诗人达成共识,但是你能找出关键字。你不会从文法中分辨出来,而是从修辞上找到。 为什么诗中有些字会跳出来,凝视着你?是因为节奏造成的?还是押韵的关系?还是这个字一直在重复出现?如果好几段谈的都是同样的概念,那么彼此之间到底有什么关联?你找出的答案能帮助你了解这首诗。在大部分好的抒情诗中,都存在着一些冲突。有时是对立的两方—或是个人,或是想像与理想的象征—出场了,然后形容双方之间的冲突。如果是这样的写法,就很容易掌握。但是通常冲突是隐藏在其中,没有说出口的。譬如大多数的伟大抒情诗—或许最主要的都是如此—所谈的都是爱与时间、生与死、短暂的美与永恒的胜利之间的冲突。但是在诗的本身,却可能看不到这些字眼。\n有人说过,所有莎士比亚的十四行诗都是在谈他所谓的“贪婪的时间”造成的毁坏。有些诗确实是如此,因为他一再地强调出来:\n我曾窥见时间之手的残酷\n被陈腐的岁月掩埋就是辉煌的代价\n这是第64首十四行诗,列举了时间战胜了一切,而人们却希望能与时间对抗。他说:\n断垣残壁让我再三思量\n岁月终将夺走我的爱人这样的十四行诗当然没有问题。在第116首的名句中,同样包含了下面的句子:\n爱不受时间愚弄,虽然红唇朱颜\n敌不过时间舞弄的弯刀;\n爱却不因短暂的钟点与周期而变貌,\n直到末日尽头仍然长存。\n而在同样有名的第138首十四行诗中,开始时是这么写的:\n我的爱人发誓她是真诚的\n我真的相信她,虽然我知道她在说谎\n谈的同样是时间与爱的冲突,但是“时间”这两个字却没有出现在诗中。\n这样你会发现读诗并不太困难。而在读马维尔(Marvell )的庆典抒情诗《给害羞的女主人》(To His Coy Mistress)时,你也不会有困难。因为这首诗谈的是同样的主题,而且一开始便点明了:\n如果我们拥有全世界的时间,\n这样的害羞,女郎,绝不是罪过。\n但是我们没有全世界的时间,马维尔继续说下去:\n在我背后我总是听见\n时间的马车急急逼进;\n无垠的远方横亘在我们之上\n辽阔的沙漠永无止境。\n因此,他恳求女主人:\n让我们转动全身的力量\n让全心的甜蜜融入舞会中,\n用粗暴的争吵撕裂我们的欢愉\n彻底的挣脱生命的铁门。\n这样,虽然我们不能让阳光\n静止,却能让他飞奔而去。\n阿契伯·麦克莱西(Archibald MacLeisch)的诗《你,安德鲁·马维尔》(You,Andrew Marvell),可能比较难以理解,但所谈的主题却\n是相同的。这首诗是这样开始的:\n在这里脸孔低垂到太阳之下\n在这里望向地球正午的最高处\n感觉到阳光永远的来临\n黑夜永远升起麦克莱西要我们想像一个人(诗人?说话的人?读者?)躺在正午的阳光下—同样的,在这灿烂温暖的当儿,警觉到“尘世黑暗的凄凉”。他想像夕阳西沉的阴影—所有历史上依次出现过又沉没了的夕阳—吞噬了整个世界,淹没了波斯与巴格达……他感到“黎巴嫩渐渐淡出,以及克里特”,“与西班牙沉人海底、非洲海岸的金色沙滩也消失了”……“现在海上的一束亮光也不见了”。他最后的结论是:\n在这里脸孔沉落到太阳之下\n感觉到多么快速,多么神秘,\n夜晚的阴影来临了……\n这首诗中没有用到“时间”这两个字,也没有谈到爱情。此外,诗的标题让我们联想到马维尔的抒情诗的主题:“如果我们拥有全世界的时间”。因此,这首诗的组合与标题诉求的是同样的冲突,在爱(或生命)与时间之间的冲突—这样的主题也出现在我们所提的其他诗之中。\n关于阅读抒情诗,还有最后的一点建议。一般来说,阅读这类书的读者感觉到他们一定要多知道一点关于作者及背景的资料,其实他们也许用不上这些资料。我们太相信导论、评论与传记—但这可能只是因为我们怀疑自己的阅读能力。只要一个人愿意努力,几乎任何人都能读任何诗。你发现任何有关作者生活与时代的资讯,只要是确实的都有帮助。但是关于一首诗的大量背景资料并不一定保证你能了解这首诗。要了解一首诗,一定要去读它—一遍又一遍地读。阅读任何伟大的抒情诗是一生的工作。当然,并不是说你得花一生的时间来阅读伟大的抒情诗,而是伟大的抒情诗值得再三玩味。而在放下这首诗的时候,我们对这首诗所有的体会,可能更超过我们的认知。\n第十六章 如何阅读历史书 # “历史”就跟“诗”一样,含有多重意义。为了要让这一章对你有帮助,我们一定要跟你对这两个字达成共识—也就是说我们是如何运用这两个字的。\n首先,就事实而言的历史(history as fact)与就书写记录而言的历史(history as a written record of the tacts)是不同的。显然,在这里我们要用的是后者的概念,因为我们谈的是“阅读”,而事实是无法阅读的。所谓历史书有很多种书写记录的方式。收集特定事件或时期的相关资料,可以称作那个时期或事件的历史。口头采访当事人的口述记录,或是收集这类的口述记录,也可以称作那个事件或那些参与者的历史。另外一些出发点相当不同的作品,像是个人日记或是信件收集,也可以整理成一个时代的历史。历史这两个字可以用在,也真的运用在几乎各种针对某一段时间,或读者感兴趣的事件上所写的读物。\n下面我们所要用到的“历史”这两个字,同时具有更狭义与更广义的含义。所谓更狭义,指的是我们希望限制在针对过去某段时期、某个事件或一连串的事件,来进行基本上属于叙事风格,多少比较正式的描述。这也是“历史”的传统词义,我们毋须为此道歉。就像我们为抒情诗所下的定义一样,我们认为你会同意我们所采用的一般定义,而我们也会将焦点集中在这种一般类型上。\n但是,在更广义的部分,我们比当今许多流行的定义还要广。我们认为,虽然并不是所有的历史学家都赞同,但我们还是强调历史的基本是叙事的,所谓的事指的就是“故事”,这两个字能帮助我们理解基本的含意。就算是一堆文状的收集,说的还是“故事”。这些故事可能没有解说—因为历史学家可能没有将这些资料整理成“有意义的”秩序。但不管有没有秩序,其中都隐含着主题。否则,我们认为这样的收集就不能称之为那个时代的历史。\n然而,不论历史学家赞不赞同我们对历史的理念,其实都不重要。我们要讨论的历史书有各种写作形态,至少你可能会想要读其中的一两种。在这一点上我们希望能帮助你使把劲。\n1.难以捉摸的史实 # 或许你加人过陪审团,倾听过像车祸这类单纯的事件。或许你加人的是高等法院陪审团,必须决定一个人是否杀了另一个人。如果这两件事你都做过,你就会知道要一个人回忆他亲眼见到的事情,将过去重新整理出来有多困难—就是一个小小的单纯事件也不容易。\n法庭所关心的是最近发生的事件与现场目击的证人,而且对证据的要求是很严格的。一个目击者不能假设任何事,不能猜测,不能保证,也不能评估(除非是在非常仔细的情况掌控之下)。当然,他也不可以说谎。\n在所有这些严格规范的证据之下,再加上详细检验之后,身为陪审团的一员,你是否就能百分之百地确定,你真的知道发生了什么事吗?\n法律的设定是你不必做到百分之百的确定。因为法律设定陪审团的人心中总是有些怀疑的感觉。实际上,为了审判可以有这样与那样的不同决定,法律虽然允许这些怀疑影响你的判断,但一定要“合理”才行。换句话说,你的怀疑必须强到要让你的良心觉得困扰才行。\n历史学家所关心的是已经发生的事件,而且绝大部分是发生在很久以前的事件。所有事件的目击者都死了,他们所提的证据也不是在庭上提出的—也就是没有受到严格、仔细的规范。这样的证人经常在猜测、推想、估算、设定与假设。我们没法看到他们的脸孔,好推测他们是否在撒谎(就算我们真的能这样判断一个人的话)。他们也没有经过严格检验。没有人能保证他们真的知道他们在说些什么。\n所以,如果一个人连一件单纯的事都很难确知自己是否明白,就像法庭中的陪审团难下决定一样,那么想知道历史上真正发生了什么事的困难就更可想而知了。一件历史的“事实”—虽然我们感觉很相信这两个字代表的意义,但却是世上最难以捉摸的。\n当然,某一种历史事实是可以很确定的。1861年4月12日,美国在桑姆特要塞掀起了内战;1865年4月9日,李将军在阿波米脱克斯法庭向格兰特将军投降,结束了内战。每个人都会同意这些日期。虽然不是绝无可能,但总不太可能当时全美国的日历都不正确。\n但是,就算我们确实知道内战是何时开始,何时结束,我们又从中学到了什么?事实上,这些日期确实被质疑着—不是因为所有的日历都错了,而是争论的焦点在这场内战是否应该起于1860年的秋天,林肯当选总统,而结束于李将军投降后五天,林肯被刺为止。另外一些人则声称内战应该开始得更早一点—要比1861年还早个五到十或二十年—还有,我们也知道到1865年美国一些边睡地带仍然继续进行着战争,因此北方的胜利应该推迟到1865年的5月、6月或7月。甚至还有人认为美国的内战直到今天也没有结束—除非哪一天美国的黑人能获得完全的自由与平等,或是南方各州能脱离联邦统治,或是联邦政府可以下达各州的控制权能够确立,并为所有美国人所接受,否则美国的内战就永远称不上结束。\n你可以说,至少我们知道,不论内战是不是从桑姆特之役开始,这场战役确实是发生在1861年4月12日。这一点是毋庸置疑的—我们前面提过,这是在特定限制之下的史实。但是为什么会有桑姆特之役?这显然是另一个问题。在那场战役之后,内战是否仍然可以避免呢?如果可以,我们对一个多世纪之前,一个如此这般的春日,所发生的如此这般的战役,还会如此关心吗?如果我们不关心—我们对许多确实发生过,但自己却一无所知的战役都不关心—那么桑姆特之役仍然会是一件意义重大的史实吗?\n2.历史的理论 # 如果非要分类不可的话,我们应该把历史,也就是过去的故事—归类为小说,而非科学—就算不分类,如果能让历史停格在这两类书之中的话,那么通常我们会承认,历史比较接近小说,而非科学。\n这并不是说历史学家在捏造事实,就像诗人或小说家那样。不过,太强调这些作家都是在编造事实,也可能自我麻烦。我们说过,他们在创造一个世界。这个新世界与我们所居住的世界并非截然不同—事实上,最好不是—而一个诗人也是人,透过人的感官进行自己的学习。他看事情跟我们没什么两样(虽然角度可能比较美好或有点不同)。他的角色所用的语言也跟我们相同(否则我们没法相信他们)。只有在梦中,人们才会创造真正不同的全新世界—但是就算在最荒谬的梦境中,这些想像的事件与生物也都是来自每天的生活经验,只是用一种奇异而崭新的方法重新组合起来而已。\n当然,一个好的历史学家是不会编造过去的。他认为自己对某些观念、事实,或精准的陈述责无旁贷。不过,有一点不能忘记的是,历史学家一定要编纂一些事情。他不是在许多事件中找出一个共通的模式,就是要套上一个模式。他一定要假设他知道为什么这些历史上的人物会做出这些事。他可能有一套理论或哲学,像是上帝掌管人间的事物一样,编纂出适合他理论的历史。或者,他会放弃任何置身事外或置身其上的模式,强调他只是在如实报导所发生过的事件。但是即使如此,他也总不免要指出事件发生的原因及行为的动机。你在读历史书时,最基本的认知就是要知道作者在运作的是哪一条路。\n不想采取这个或那个立场,就得假设人们不会故意为某个目的而做一件事,或者就算有目的,也难以察觉—换句话说,历史根本就没有模式可循。\n托尔斯泰对历史就有这样的理论。当然,他不是历史学家,而是小说家。但是许多历史学家也有同样的观点,近代的历史学家更是如此。托尔斯泰认为,造成人类行为的原因太多,又太复杂,而且动机又深深隐藏在潜意识里,因此我们无法知道为什么会发生某些事。\n因为关于历史的理论不同,因为历史家的理论会影响到他对历史事件的描述,因此如果我们真的想要了解一个事件或时期的历史,就很有必要多看一些相关的论著。如果我们所感兴趣的事件对我们又有特殊意义的话,就更值得这么做了。或许对每个美国人来说,知道一些有关内战的历史是有特殊意义的。我们仍然生活在那场伟大又悲惨的冲突的余波中,我们生活在这件事所形成的世界中。但是如果我们只是经由一个人的观点,单方面的论断,或是某个现代学院派历史学家来观察的话,是没法完全理解这段历史的。如果有一天,我们打开一本新的美国内战史,看到作者写着:“公正客观的美国内战史—由南方的观点谈起”,那这位作者看起来是很认真的。或许他真的如此,或许这样的公正客观真的可能。无论如何,我们认为每一种历史的写作都必定是从某个观点出发的。为了追求真相,我们必须从更多不同的角度来观察才行。\n3.历史中的普遍性 # 关于一个历史事件,我们不见得总能读到一种以上的书。当我们做不到的时候,我们必须承认,我们没有那么多机会提出问题,以学习到有关的事实—明白真正发生了什么。不过,这并不是阅读历史的惟一理由。可能会有人说,只有专业历史学家,那个写历史的人,才应该严格检验他的资料来源,与其他相反的论点作仔细的核对验证。如果他知道关于这个主题他该知道些什么,他就不会产生误解。我们,身 为历史书的半吊子读者,介于专业历史学家与阅读历史纯粹只是好玩,不负任何责任的外行读者之间。\n让我们用修昔底德(Thucydides)做例子。你可能知道他写过一本有关公元前五世纪末,伯罗奔尼撒战争的史实,这是当时惟一的一本主要的历史书。在这样的情况下,没有人能查证他作品的对错。那么,我们也能从这样的书中学到什么吗?\n希腊现在只是个小小的国家。一场发生在25世纪以前的战争,对今天的我们真的起不了什么作用。每一个参与战事的人都早已长眠,而引发战争的特殊事件也早已不再存在。胜利者到了现在也毫无意义了,失败者也不再有伤痛。那些被征服又失落的城市已化作烟尘。事实上,如果我们停下来想一想,伯罗奔尼撒战争所遗留下来的似乎也就只有修昔底德这本书了。\n但是这样的记录还是很重要的。因为修昔底德的故事—我们还是觉得用这两个字很好—影响到后来人类的历史。后代的领导者会读修昔底德的书。他们会发现自己的处境仿佛与惨遭分割的希腊城邦的命运一样,他们把自己比作雅典或斯巴达。他们把修昔底德当作借口或辩解的理由,甚至行为模式的指引。结果,就因为修昔底德在公元前5世纪的一些观点,整个世界的历史都逐渐被一点点虽然极为微小,却仍然可以察觉的改变所影响。因此我们阅读修昔底德的历史,不是因为他多么精准地描述出在他写书之前的那个世界,而是因为他对后代发生的事有一定的影响力。虽然说起来很奇怪,但是我们阅读他的书是为了想要了解目前发生的事。\n亚里士多德说:“诗比历史更有哲学性。”他的意思是诗更具一般性,更有普遍影响力。一首好诗不只在当时当地是一首好诗,也在任何时间任何地点都是好诗。这样的诗对所有人类来说都有意义与力量。历史不像诗那样有普遍性。历史与事件有关,诗却不必如此。但是一本好的历史书仍然是有普遍性的。\n修昔底德说过,他写历史的原因是:希望经由他所观察到的错误,以及他个人受到的灾难与国家所受到的苦楚,将来的人们不会重蹈覆辙。他所描述的人们犯下的错误,不只对他个人或希腊有意义,对整个人类来说更有意义。在二千五百年以前,雅典人与斯巴达人所犯的错误,今天人们仍然同样在犯—或至少是非常接近的错误—修昔底德以降,这样的戏码一再上演。\n如果你阅读历史的观点是设限的,如果你只想知道真正发生了什么事,那你就不会从修昔底德,或任何一位好的历史学家手中学到东西。如果你真把修昔底德读通了,你甚至会扔开想要深究当时到底发生了什么事的念头。\n历史是由古到今的故事。我们感兴趣的是现在—以及未来。有一部分的未来是由现在来决定的。因此,你可以由历史中学习到未来的事物,甚至由修昔底德这样活在二千年前的人身上学到东西。\n总之,阅读历史的两个要点是:第一,对你感兴趣的事件或时期,尽可能阅读一种以上的历史书。第二,阅读历史时,不只要关心在过去某个时间、地点真正发生了什么事,还要读懂在任何时空之中,尤其是现在,人们为什么会有如此这般行动的原因。\n4.阅读历史书要提出的问题 # 尽管历史书更接近小说,而非科学,但仍然能像阅读论说性作品一样来阅读,也应该如此阅读。因此,在阅读历史时,我们也要像阅读论说性作品一样,提出基本的问题。因为历史的特性,我们要提出的问题有点不同,所期待的答案也稍微不同。\n第一个问题关心的是,每一本历史书都有一个特殊而且有限定范围的主题。令人惊讶的是,通常读者很容易就看出这样的主题,不过,不见得会仔细到看出作者为自己所设定的范围。一本美国内战的书,固然不是在谈19世纪的世界史,可能也不涉及1860年代的美国西部史。虽然不应该,但它可能还是把当年的教育状况,美国西部拓荒的历史或美国人争取自由的过程都略过不提。因此,如果我们要把历史读好,我们就要弄清楚这本书在谈什么,没有谈到的又是什么。当然,如果我们要批评这本书,我们一定要知道它没谈到的是什么。一位作者不该因为他没有做到他根本就没想做的事情而受到指责。\n根据第二个问题,历史书在说一个故事,而这个故事当然是发生在一个特定的时间里。一般的纲要架构因此决定下来了,用不着我们去搜寻。但是说故事的方法有很多种,我们一定要知道这位作者是用什么方法来说故事的。他将整本书依照年代、时期或世代区分为不同的章节?还是按照其他的规则定出章节?他是不是在这一章中谈那个时期的经济历史,而在别章中谈战争、宗教运动与文学作品的产生?其中哪一个对他来说最重要?如果我们能找出这些,如果我们能从他的故事章节中发现他最重视的部分,我们就能更了解他。我们可能不同意他对这件事的观点,但我们仍然能从他身上学到东西。\n批评历史有两种方式。我们可以批评—但永远要在我们完全了解书中的意义之后—这本历史书不够逼真。也许我们觉得,人们就是不会像那样行动的。就算历史学家提供出资料来源,就算我们知道这些是相关的事实,我们仍然觉得他误解了史实,他的判断失真,或是他无法掌握人性或人类的事物。譬如,我们对一些老一辈历史学家的作品中没有包括经济事务,就可能会有这种感觉。对另一些书中所描述的一些大公无私,有太多高贵情操的“英雄”人物,我们也会抱持着怀疑的态度。\n另一方面,我们会认为—尤其是我们对这方面的主题有特殊研究时—作者误用了资料。我们发现他竟然没有读过我们曾经读过的某本书时,会有点生气的感觉。他对这件事所掌握的知识可能是错误的。在这种状况下,他写的就不是一本好的历史书。我们希望一位历史学家有完备知识。\n第一种批评比较重要。一个好的历史学家要能兼具说故事的人与科学家的能力。他必须像某些目击者或作家说一些事情确实发生过一样,知道一些事情就是可能发生过。\n关于最后一个问题:这与我何干?可能没有任何文学作品能像历史一样影响人类的行为。讽刺文学及乌托邦主义的哲学对人类的影响不大。我们确实希望这个世界更好,但是我们很少会被一些只会挖苦现实,只是区别出理想与现实的差异这类作者的忠告所感动。历史告诉我们人类过去所做的事,也经常引导我们作改变,尝试表现出更好的自我。一般来说,政治家接受历史的训练会比其他的训练还要收获良多。历史会建议一些可行性,因为那是以前的人已经做过的事。既然是做过的事,就可能再做一次—或是可以避免再做。\n因此,“与我何干”这个问题的答案,就在于实务面,也就是你的政治行为面。这也是为什么说要把历史书读好是非常重要的。不幸的是,政治领导人物固然经常根据历史知识来采取行动,但却还不够。这个世界已经变得很渺小又危机四伏,每个人都该开始把历史读好才行。\n5.如何阅读传记与自传 # 传记是一个真人的故事。这种作品一直以来就是有混合的传统,因此也保持着混杂的特性。\n有些传记作者可能会反对这样的说法。不过,一般来说,一本传记是关于生活、历史、男人或女人及一群人的一种叙述。因此,传记也跟历史一样有同样的问题。读者也要问同样的问题—作者的目的是什么?他所谓真实包含哪些条件?—这也是在读任何一本书时都要提出的问题。\n传记有很多种类型。“定案本”(definitive)的传记是对一个人的一生作详尽完整的学术性报告,这个人重要到够得上写这种完结篇的传记。定案本的传记绝不能用来写活着的人。这类型的传记通常是先出现好几本非定案的传记之后,才会写出来。而那些先出的传记当中总会有些不完整之处。在写作这样的传记时,作者要阅读所有的资料及信件,还要查证大批当代的历史。因为这种收集资料的能力,与用来写成一本好书的能力不同,因此“定案本”的传记通常是不太容易阅读的。这是最可惜的一点。一本学术性的书不一定非要呆板难读不可。鲍斯韦尔(Boswell)的《约翰逊传》(Life of Johnson)就是一本伟大的传记,但却精彩绝伦。这确实是一本定案本的传记(虽然之后还出现了其他的约翰逊传记),但是非常独特有趣。\n一本定案本的传记是历史的一部分—这是一个人和他生活的那个时代的历史,就像从他本人的眼中所看到的一样。应该用读历史的方法来读这种传记。“授权本”(authorized)传记又是另一回事了。这样的工作通常是由继承人,或是某个重要人物的朋友来负责的。因为他们的写作态度很小心,因此这个人所犯的错,或是达到的成就都会经过润饰。有时候这也会是很好的作品,因为作者的优势—其他作者则不见得—能看到所有相关人士所掌控的资料。当然,授权本的传记不能像定案本的传记那样受到相同的信任。读这种书不能像读一般的历史书一样,读者必须了解作者可能会有偏见—这是作者希望读者能用这样的想法来看书中的主角,这也是他的朋友希望世人用这样的眼光来看他。\n授权本的传记是一种历史,却是非常不同的历史。我们可以好奇什么样利害关系的人会希望我们去了解某一个人的私生活,但我们不必指望真正了解这个人的私生活真相。在阅读授权本的传记时,这本书通常在告诉我们有关当时的时代背景,人们的生活习惯与态度,以及当时大家接受的行为模式—关于不可接受的行为也同时作了点暗示及推论。如果我们只读了单方面的官方传记,我们不可能真的了解这个人的真实生活,就像我们也不可能指望了解一场战役的真相一样。要得到真相,必须要读所有正式的文件,询问当时在场的人,运用我们的头脑从混乱中理出头绪来。定案本的传记已经做过这方面的工作了,授权本的传记(几乎所有活着的人的传记都属于这一种)还有很多要探索的。\n剩下的是介于定案本与授权本之间的传记。或许我们可以称这种传记是一般的传记。在这种传记中,我们希望作者是正确的,是了解事实的。我们最希望的是能超越另一个时空,看到一个人的真实面貌。人是好奇的动物,尤其是对另一个人特别的好奇。\n这样的书虽然比不上定案本的传记值得信任,却很适合阅读。如果世上没有了艾萨克·沃顿(Izaak Walton)为他的朋友,诗人约翰·多恩(John Donne)与乔治·赫伯特(George Herbert)所写的《传记》(Lives)〔沃顿最著名的作品当然是《钓客清话》(The Compleat Angler)],或是约翰·丁达尔(John Tyndall)为朋友迈克尔·法拉第(Michael Faraday)写的《发明家法拉第》(Faraday the Discoverer),这世界将会逊色不少。\n有些传记是教诲式的,含有道德目的。现在很少人写这类传记了,以前却很普遍。(当然,儿童书中还有这样的传记。)普鲁塔克(Plu-tarch)的《希腊罗马名人传》(Lives of the Noble Grecians and Romans)就是这种传记。普鲁塔克告诉人们有关过去希腊、罗马人的事迹,以帮助当代人也能有同样的高贵情操,并帮助他们避免落入过去的伟人所常犯—或确实犯下的错误。这是一本绝妙的作品。虽然书中有许多关于某个人物的叙述,但我们并不把这本书当作收集资料的传记来读,而是一般生活的读物。书中的主角都是有趣的人物,有好有坏,但绝不会平淡无奇。普鲁塔克自己也了解这一点。他说他原本要写的是另一本书,但是在写作的过程中,他却发现在“让这些人物一个个进出自己的屋子之后”,却是自己受益最多,受到很大的启发。\n此外,普鲁塔克所写的其他的历史作品对后代也有相当的影响力。譬如他指出亚历山大大帝模仿阿喀琉斯的生活形态(他是从荷马的书中学到的),所以后代的许多征服者也模仿普鲁塔克所写的亚历山大大帝的生活方式。\n自传所呈现的又是不同的有趣问题。首先要问的是,是否有人真的写出了一本真实的自传?如果了解别人的生活很困难,那么了解自己的生活就更困难了。当然,所有自传所写的都是还未完结的生活。\n没有人能反驳你的时候,你可能会掩盖事实,或夸大事实,这是无可避免的事。每个人都有些不愿意张扬的秘密,每个人对自己都有些幻想,而且不太可能承认这些幻想是错误的。无论如何,虽然不太可能写一本真实的自传,但也不太可能整本书中都是谎言。就像没有人能撒谎撒得天衣无缝,即使作者想要掩盖一些事实,自传还是会告诉我们一些有关作者的真面目。\n一般人都容易认为卢梭的《忏悔录》或同一时期的某部其他作品(约18世纪中叶),是真正称得上自传的开始。这样就忽略了像奥古斯丁的《忏悔录》(Confessions)及蒙田的《散文集》(Essays)。真正的错误还不在这里。事实上,任何人所写的任何主题多少都有点自传的成分。像柏拉图的《理想国》(Republic)、弥尔顿的《失乐园》或歌德的《浮士德》(Faust)中,都有很强烈的个人的影子—只是我们没法一一指认而已。如果我们对人性感兴趣,在合理的限度内,我们在阅读任何一本书的时候,都会张开另一只眼睛,去发现作者个人的影子。\n自传在写得过火时,会陷人所谓“感情谬误\u0026quot;(pathetic fallacy)的状态中,但这用不着过度担心。不过我们要记得,没有任何文字是自己写出来的—我们所阅读到的文字都是由人所组织撰写出来的。柏拉图与亚里士多德说过一些相似的事,也说过不同的事。但就算他们完全同意彼此的说法,他们也不可能写出同样的一本书,因为他们是不同的人。我们甚至可以发现在阿奎那的作品《神学大全》,这样一部显然一切摊开来的作品中,也有些隐藏起来的东西。\n因此,所谓正式的(formal)自传并不是什么新的文学形式。从来就没有人能让自己完全摆脱自己的作品。蒙田说过:“并不是我在塑造我的作品,而是我的作品在塑造我。一本书与作者是合而为一的,与自我密切相关,也是整体生活的一部分。”他还说:“任何人都能从我的书中认识我,也从我身上认识我的书。”这不只对蒙田如此,惠特曼谈到他的《草叶集))(Leaves of Grass)时说:“这不只是一本书,接触到这本书时,也就是接触到一个生命。”\n在阅读传记与自传时还有其他的重点吗?这里还有一个重要的提醒。无论这类书,尤其是自传,揭露了多少有关作者的秘密,我们都用不着花上一堆时间来研究作者并未言明的秘密。此外,由于这种书比较更像是文学小说,而不是叙事或哲学的书,是一种很特别的历史书,因此我们还有一点点想提醒大家的地方。当然,你该记得,如果你想知道一个人的一生,你就该尽可能去阅读你能找到的资料,包括他对自己一生的描述(如果他写过)。阅读传记就像阅读历史,也像阅读历史的原因。对于任何自传都要有一点怀疑心,同时别忘了,在你还不了解一本书之前,不要妄下论断。至于“这本书与我何干?\u0026lsquo;\u0026lsquo;这个问题,我们只能说:传记,就跟历史一样,可能会导引出某个实际的、良心的行动。传记是有启发性的。那是生命的故事,通常是成功者一生的故事—也可以当作我们生活的指引。\n6.读关于当前的事件 # 我们说过,分析阅读的规则适用于任何作品,而不只是书。现在我们要把这个说法作个调整,分析阅读并不是永远都有必要的。我们所阅读的许多东西都用不上分析阅读的努力跟技巧,那也就是我们所谓第三层次的阅读能力。此外,虽然这样的阅读技巧并不一定要运用出来,但是在阅读时,四个基本问题是一定要提出来的。当然,即使当你在面对我们一生当中花费很多时间阅读的报纸、杂志、当代话题之类的书籍时,也一定要提出这些问题来。\n毕竟,历史并没有在一千年或一百年前停顿下来,世界仍在继续运转,男男女女继续写作世上在发生些什么事情,以及事情在如何演变。或许现代的历史没法跟修昔底德的作品媲美,但这是要由后代来评价的。身为一个人及世界的公民,我们有义务去了解围绕在我们身边的世界。\n接下来的问题就是要知道当前确实发生了些什么事。我们用“确实”这两个字是有用意的。法文是用“确实”(actualites)这两个字代表新闻影片。所谓当前发生的事件(current events),也就是跟“新闻”这两个字很类似。我们要如何获得新闻,又如何知道我们获得的新闻是真实的?\n你会立刻发现我们面对的问题与历史本身的问题是一样的。就像我们无法确定过去的事实一样,我们不能确定我们所获得的是不是事实—我们也无法确定我们现在所知道的是事实。但是我们还是要努力去了解真实的情况。\n如果我们能同时出现在任何地方,收听到地球上所有的对话,看穿所有活着的人的心里,我们就可以确定说我们掌握了当前的真实情况。但是身为人类就有先天的限制,我们只能仰赖他人的报导。所谓记者,就是能掌握一小范围内所发生的事,再将这些事在报纸、杂志或书中报导出来的人。我们的资讯来源就要靠他们了。\n理论上,一位记者,不论是哪一类的记者,都该像一面清澈的玻璃,让真相反映出来—或透射过来。但是人类的头脑不是清澈的玻璃,不是很好的反映材料,而当真相透射过来时,我们的头脑也不是很好的过滤器。它会将自认为不真实的事物排除掉。当然,记者不该报导他认为不真实的事。但是,他也可能会犯错。\n因此,最重要的是,在阅读当前事件的报导时,要知道是谁在写这篇报导。这里所说的并不是要认识那位记者,而是要知道他写作的心态是什么。滤镜式的记者有许多种类型,要了解记者心中戴着什么样的过滤器,我们一定要提出一连串的问题。这一连串的问题与任何一种报导现状的作品都有关。这些问题是:(1)这个作者想要证明什么?(2)他想要说服谁?(3)他具有的特殊知识是什么?(4)他使用的特殊语言是什么?(5)他真的知道自己在说些什么吗?\n大体而言,我们可以假设关于当前事件的书,都是想要证明什么事情。通常,这件事情也很容易发现。书衣上通常就会将这本书的主要内容写出来了。就算没有出现在封面,也会出现在作者的前言中。\n问过作者想要证明的是什么之后,你就要问作者想要说服的是什么样的人了?这本书是不是写给那些“知道内情的人”(in the know)—你是其中一个吗?那本书是不是写给一小群读过作者的描绘之后能快速采取某种行动的读者,或者,就是为一般人写的?如果你并不属于作者所诉求的对象,可能你就不会有兴趣阅读这样的一本书。\n接下来,你要发现作者假设你拥有哪种特定的知识。这里所说的“知识”含意很广,说成“观念”或“偏见”可能还更适合一些。许多作者只是为了同意他看法的读者而写书。如果你不同意作者的假设,读这样的书只会使你光火而已。\n作者认为你与他一起分享的假设,有时很难察觉出来。巴兹尔·威利(Basil Willey)在《17世纪背景》(The Seventeenth Century Background)一书中说:\n想要知道一个人惯用的假设是极为困难的,所谓‘以教条为事实\u0026rsquo;,在运用形上学的帮助以及长期苦思之后,你会发现教条就是教条,却绝不是事实。他继续说明要找出不同时代的“以教条为事实”的例子很容易,而这也是他在书中想要做的事。无论如何,阅读当代作品时,我们不会有时空的隔阂,因此我们除了要厘清作者心中的过滤器之外,也要弄清楚自己的想法才行。\n其次,你要问作者是否使用了什么特殊的语言?在阅读杂志或报纸时,这个问题尤其重要。阅读所有当代历史书的时候也用得上这个问题。特定的字眼会激起我们特定的反应,却不会对一个世纪以后的人发生作用。譬如“共产主义”或“共产党”就是一个例子。我们应该能掌握相关的反应,或至少知道何时会产生这样的反应。\n最后,你要考虑五个间题中的最后一个问题,这也可能是最难回答的问题。你所阅读的这位报导作者真的知道事实吗?是否知道被报导的人物私下的思想与决定?他有足够的知识以写出一篇公平客观的报导吗?\n换句话说,我们所强调的是:我们要注意的,不光是一个记者可能会有的偏差。我们最近听到许多“新闻管理”(management of thenews)这样的话题。这样的观念不只对我们这些大众来说非常重要,对那些“知道内情”的记者来说更重要。但是他们未必清楚这一点。一个记者尽管可能抱持着最大的善意,一心想提供读者真实的资料,在一些秘密的行动或协议上仍然可能“知识不足”。他自己可能知道这一点,也可能不知道。当然,如果是后者,对读者来说就非常危险了。\n你会注意到,这里所提的五个问题,其实跟我们说过阅读论说性作品时要提出的问题大同小异。譬如知道作者的特殊用语,就跟与作者达成共识是一样的。对身为现代读者的我们来说,当前事件的著作或与当代有关的作品传达的是特殊的问题,因此我们要用不同的方法来提出这些疑问。\n也许,就阅读这类书而言,整理一堆“规则”还比不上归纳为一句警告。这个警告就是:读者要擦亮眼睛(Caveat lector)!在阅读亚里士多德、但丁或莎士比亚的书时,读者用不着担这种心。而写作当代事件的作者却可能(虽然不见得一定)在希望你用某一种方式了解这件事的过程中,有他自己的利益考虑。就算他不这么想,他的消息来源也会这么想。你要搞清楚他们的利益考虑,阅读任何东西都要小心翼翼。\n7.摘的注意事项 # 我们谈过在阅读任何一种作品时,都有一种基本的区别—为了获得资讯而阅读,还是为了理解而阅读。其实,作这种区别还有另一种后续作用。那就是,有时候我们必须阅读一些有关理解的资讯—换言之,找出其他人是如何诠释事实的。让我们试着说明如下。\n我们阅读报纸、杂志,甚至广告,主要都是为了获得资讯。这些资料的量太大了,今天已没有人有时间去阅读所有的资讯,顶多阅读一小部分而己。在这类阅读领域中,大众的需要激发了许多优秀的新事业的出现。譬如像《时代》(Time)或《新闻周刊)) (Newsweek),这种新闻杂志,对大多数人来说就有难以言喻的功能,因为它们能代替我们阅读新闻,还浓缩成包含最基本要素的资讯。这些杂志新闻写作者基本上都是读者。他们阅读新闻的方法,则已经远远超越一般读者的能力。\n对《读者文摘》(Reader\u0026rsquo;s Digest)这类出版品来说,也是同样的情况。这样的杂志声称要给读者一种浓缩的形式,让我们将注意力由一般杂志转移到一册塞满资讯的小本杂志上。当然,最好的文章,就像最好的书一样,是不可能经过浓缩而没有遗珠之憾的。譬如像蒙田的散文如果出现在现代的期刊上,变成一篇精华摘要,是绝对没法满足我们的。总之,在这样的情况下,浓缩的惟一功能就是激励我们去阅读原著。至于一般的作品,浓缩是可行的,而且通常要比原著还好。因为一般的文字主要都是与资讯有关的。要编纂《读者文摘》或同类期刊的技巧,最重要的就是阅读的技巧,然后是写作要清晰简单。我们没几个人拥有类似的技巧—就算有时间的话—它为做了我们自己该做的事,将核心的资讯分解开来,然后以比较少的文字传达出主题。\n毕竟,最后我们还是得阅读这些经过摘要的新闻与资讯的期刊。如果我们希望获得资讯,不论摘要已经做得多好,我们还是无法避免阅读这件事。在所有分析的最后一步,也就是阅读摘要这件事情,与杂志编辑以紧凑的方式浓缩原文的工作是一样的。他们已经替我们分担了一些阅读的工作,但不可能完全取代或解决阅读的问题。因此,只有当我们尽心阅读这些摘要,就像他们在之前的尽心阅读以帮助我们作摘要一样,他们的功能对我们才会真正有帮助。\n这其中同时涉及为了增进理解而阅读,以及为了获得资讯而阅读这两件事。显然,越是浓缩过的摘要,筛选得越厉害。如果一千页的作品摘成九百页,这样的问题不大。如果一千页的文字浓缩成十页或甚至一页,那么到底留下来的是些什么东西就是个大向题了。内容被浓缩得越多,我们对浓缩者的特质就更要有所了解。我们在前面所提出的“警告”在这里的作用就更大了。毕竟,在经过专业浓缩过的句子中,读者更要能读出言外之意才行。你没法找回原文,看看是删去了哪些,你必须要从浓缩过的文字中自己去判定。因此,阅读文摘,有时是最困难又自我要求最多的一种阅读方式。\n第十七章 如何阅读科学与数学 # 这一章的标题可能会让你误解。我们并不打算给你有关阅读任何一种科学与数学的建议。我们只限定自己讨论两种形式的书:一种是在我们传统中,伟大的科学与数学的经典之作。另一种则是现代科普著作。我们所谈的往往也适用于阅读一些主题深奥又特定的研究论文,但是我们不能帮助你阅读这类文章。原因有两个,第一个很简单,我们没有资格这么做。\n第二个则是:直到大约19世纪末,主要的科学著作都是给门外汉写的。这些作者—像伽利略、牛顿与达尔文—并不反对他们领域中的专家来阅读,事实上,他们也希望接触到这样的读者。但在那个时代,爱因斯坦所说的“科学的快乐童年时代”,科学专业的制度还没有建立起来。聪明又能阅读的人阅读科学书就跟阅读历史或哲学一样,中间没有艰困与速度的差距,也没有不能克服的障碍。当代的科学著作,并没有明显表示出要忽视一般读者或门外汉。不过大多数现代科学著作并不关心门外汉读者的想法,甚至也不想尝试让这样的读者理解。\n今天,科学论文已经变成专家写给专家看的东西了。就某个严肃的科学主题的沟通中,读者也要有相对的专业知识才行,通常不是这个领域中的读者根本无法阅读这类文章。这样的倾向有明显的好处,这使科学的进步更加快速。专家之间彼此交换专业知识,很快就能互相沟通,达到重点—他们很快便能看出问题所在,并想办法解决。但是付出的代价也很明显。你—也就是我们在本书中所强调的一般水平的读者—就没法阅读这类文章了。\n事实上,这样的情况也已经出现在其他的领域中,只是科学的领域更严重一些罢了。今天,哲学家也不再为专业的哲学家以外的读者写作,经济学家只写给经济学家看,甚至连历史学家都开始写专业的论著。而在科学界,专家透过专业论文来作沟通早已是非常重要的方式,比起写给所有读者的那种传统叙事性的写法,这样的方式更方便彼此的意见交流。\n在这样的情况下,一般的读者该怎么办呢?他不可能在任何一个领域中都成为专家。他必须退一步,也就是阅读流行的科普书。其中有些是好书,有些是坏书。但是我们不仅要知道这中间的差别,最重要的是还要能在阅读好书时达到充分的理解。\n1.了解科学这一门行业 # 科学史是学术领域中发展最快速的一门学科。在过去的几年当中,我们看到这个领域在明显地改变。“严肃的”科学家瞧不起科学历史家,是没多久以前的事。在过去,科学历史家被认为是以研究历史为主,因为他们没有能力拓展真正的科学领域。这样的态度可以用萧伯纳的一句名言来作总结:“有能力的人,就去做。没有能力的人,就去教。”\n目前已经很少听到有关这种态度的描述了。科学史这个部门已经变得很重要,卓越的科学家们研究也写出有关科学的历史。其中有个例子就是“牛顿工业\u0026quot;(Newton Industry)。目前,许多国家都针对牛顿的理论及其独特的人格,作密集又大量的研究。最近也出版了六七本相关的书籍。原因是科学家比以前更关心科学这个行业本身了。\n因此,我们毫不迟疑地要推荐你最少要阅读一些伟大的科学经典巨著。事实上,你真的没有借口不阅读这样的书。其中没有一本真的很难读,就算牛顿的《自然哲学的数学原理》(Mathematical Principles of Natural Philosophy),只要你真的肯努力,也是可以读得通的。\n这是我们给你最有帮助的建议。你要做的就是运用阅读论说性作品的规则,而且要很清楚地知道作者想要解决的问题是什么。这个分析阅读的规则适用于任何论说性的作品,尤其适用于科学与数学的作品。\n换句话说,你是门外汉,你阅读科学经典著作并不是为了要成为现代专业领域的专家。相反地,你阅读这些书只是为了了解科学的历史与哲学。事实上,这也是一个门外汉对科学应有的责任。只有当你注意到伟大的科学家想要解决的是什么问题时—注意到问题的本身及问题的背景—你的责任才算结束了。\n要跟上科学发展的脚步,找出事实、假定、原理与证据之间的相互关联,就是参与了人类理性的活动,而那可能是人类最成功的领域。也许,光这一点就能印证有关科学历史研究的价值了。此外,这样的研究还能在某种程度上消除一些对科学的谬误。最重要的是,那是与教育的根本相关的脑力活动,也是从苏格拉底到我们以来,一直被认为是中心的目标,也就是透过怀疑的训练,而释放出一个自由开放的心灵。\n2.阅读科学经典名著的建议 # 所谓科学作品,就是在某个研究领域中,经过实验或自然观察得来的结果,所写成的研究报告或结论。叙述科学的问题总要尽量描述出正确的现象,找出不同现象之间的互动关系。\n伟大的科学作品,尽管最初的假设不免个人偏见,但不会有夸大或宣传。你要注意作者最初的假设,放在心上,然后把他的假设与经过论证之后的结论作个区别。一个越“客观”的科学作者,越会明白地要求你接受这个、接受那个假设。科学的客观不在于没有最初的偏见,而在于坦白承认。\n在科学作品中,主要的词汇通常都是一些不常见的或科技的用语。这些用语很容易找出来,你也可以经由这些用语找到主旨。主旨通常都是很一般性的。科学不是编年史,科学家跟历史学家刚好相反,他们要摆脱时间与地点的限制。他要说的是一般的现象,事物变化的一般规则。\n在阅读科学作品时,似乎有两个主要的难题。一个是有关论述的问题。科学基本上是归纳法,基本的论述也就是经由研究查证,建立出来的一个通则—可能是经由实验所创造出来的一个案例,也可能是长期观察所收集到的一连串案例。还有另外一些论述是运用演绎法来推论的。这样的论述是借着其他已经证明过的理论,再推论出来的。在讲求证据这一点上,科学与哲学其实差异不大。不过归纳法是科学的特质。\n会出现第一个困难的原因是:为了了解科学中归纳法的论点,你就必须了解科学家引以为理论基础的证据。不幸的是,那是很难做到的事。除了手中那本书之外,你仍然一无所知。如果这本书不能启发一个人时,读者只有一个解决办法,就是自己亲身体验以获得必要的特殊经验。他可能要亲眼看到实验的过程,或是去观察与操作书中所提到的相同的实验仪器。他也可能要去博物馆观察标本与模型。\n任何人想要了解科学的历史,除了阅读经典作品外,还要能自己做实验,以熟悉书中所谈到的关系重大的实验。经典实验就跟经典作品一样,如果你能亲眼目睹,亲自动手做出伟大科学家所形容的实验,那也是他获得内心洞察力的来源,那么对于这本科学经典巨著,你就会有更深人的理解。\n这并不是说你一定要依序完成所有的实验才能开始阅读这本书。以拉瓦锡(Lavoisier)的《化学原理》(Elements of Chemistry)为例,这本书出版于1789年,到目前已不再被认为是化学界有用的教科书了,一个高中生如果想要通过化学考试,也绝不会笨到来读这本书。不过在当时他所提出来的方法仍是革命性的,他所构思的化学元素大体上我们仍然沿用至今。因此阅读这本书的重点是:你用不着读完所有的细节才能获得启发。譬如他的前言便强调了科学方法的重要,便深具启发性。拉瓦锡说:\n任何自然科学的分支都要包含三个部分:在这个科学主题中的连续事实,呈现这些事实的想法,以及表达这些事实的语言……因为想法是由语言来保留与沟通的,如果我们没法改进科学的本身,就没法促进科学语言的进步。换个角度来看也一样,我们不可能只改进科学的语言或术语,却不改进科学的本身。这正是拉瓦锡所做的事。他借着改进化学的语言以推展化学,就像牛顿在一个世纪以前将物理的语言系统化、条理化,以促进物理的进步—你可能还记得,在这样的过程中,他发展出微积分学。\n提到微积分使我们想到在阅读科学作品时的第二个困难,那就是数学的问题。\n3.面对数学的问题 # 很多人都很怕数学,认为自己完全无法阅读这样的书。没有人能确定这是什么原因。一些心理学家认为这就像是“符号盲\u0026quot; (Symbleblindness)无法放下对实体的依赖,转而理解在控制之下的符号转换。或许这有点道理,但文字也转换,转换得多少比较更不受控制,甚至也许更难以理解。还有一些人认为问题出在数学的教学上。如果真是如此,我们倒要松口气,因为近来有许多研究已经投注在如何把数学教好这个问题上了。\n其中的部分原因是没有人告诉我们,或是没有早点告诉我们,好让我们深人了解:数学其实是一种语言,我们可以像学习自己的语言一样学习它。在学习自己的语言时,我们要学两次:第一次是学习如何说话,第二次是学习如何阅读。幸运的是,数学只需要学一次,因为它完全是书写的语言。\n我们在前面说过,学习新的书写语言,牵涉到基础阅读的问题。当我们在小学第一次接受阅读指导时,我们的问题在要学习认出每一页中出现的特定符号,还要记得这些符号之间的关系。就算是后来变成阅读高手的人,偶尔还是要用基础阅读来阅读。譬如我们看到一个不认得的字时,还是得去翻字典。如果我们被一个句子的句法搞昏头时,也得从基础的层次来解决。只有当我们解决了这些问题时,我们的阅读能力才能更上层楼。\n数学既然是一种语言,那就拥有自己的字汇、文法与句法(Syntax),初学者一定要学会这些东西。特定的符号或符号之间的关系要记下来。因为数学的语言与我们常用的语言不同,问题也会不同,但从理论上来说,不会难过我们学习英文、法文或德文。事实上,从基础阅读的层次来看,可能还要简单一点。\n任何一种语言都是一种沟通的媒介,借着语言人们能彼此了解共同的主题。一般日常谈话的主题不外是关于情绪上的事情或人际关系。其实,如果是两个不同的人,对于那样的主题彼此未必能完全沟通。但是不同的两个人,撇开情绪性的话题,却可以共同理解与他们无关的第三种事件,像电路、等腰三角形或三段论法。原因是当我们的话题牵涉到情绪时,我们很难理解一些言外之意。数学却能让我们避免这样的问题。只要能适当地运用数学的共识、主旨与等式,就不会有情绪上言外之意的问题。\n除此之外,也没有人告诉我们,至少没有早一点告诉我们,数学是如何优美、如何满足智力的一门学问。如果任何人愿意费点力气来读数学,要领略数学之美永远不嫌晚。你可以从欧几里得开始,他的《几何原理》是所有这类作品中最清晰也最优美的作品。\n让我们以《几何原理》第一册的前五个命题来作说明。(如果你手边有这本书,你该打开来看看。)基本几何学的命题有两种:(1)有关作图问题的叙述。(2)有关几何图形与各相关部分之间的关系的定理。作图的问题必须着手去做,定理的问题就得去证明。在欧几里得作图问题的结尾部分,通常会有Q. E. F. (Quod erat faciendum)的字样,意思是“作图完毕”,而在定理的结尾,你会看到Q. E. D. (Quod eratdemonstrandum)的字样,意思是“证明完毕,,。\n《几何原理》第一册的前三个命题的问题,都是与作图有关的。为什么呢?一个答案是这些作图是为了要证明定理用的。在前四个命题中,我们看不出来,到了第五个,就是定理的部分,我们就可以看出来了。譬如等腰三角形(一个三角形有两个相等的边)的两底角相等,这就需要运用上“命题三”,一条短线取自一条长线的道理。而“命题三”又跟“命题二”的作图有关,“命题二”则跟“命题一”的作图有关,所以为了要证明“命题五”,就必须要先作三个图。\n我们也可以从另外一个目的来看作图的问题。作图很明显地与公设(postulate)相似,两者都声称几何的运作是可以执行出来的。在公设的案例中,这个可能性是假定(assumed)出来的。在命题的案例中,那是要证明(proved)出来的。当然,要这样证明,需要用到公设。因此,举例来说,我们可能会疑惑是否真的有“定义二0”中所定义的等边三角形这回事。但是我们用不着为这些数学物件是否存在而困扰,至少我们可以看到“命题一”所说的:基于有这些直线与圆的假定,自然可以导引出有像等边三角形这样东西的存在了。\n我们再回到“命题五”,有关等腰三角形的内角相同的定理。要达到这个结论,牵涉前面许多命题与公设,并且必须证明本身的命题。这样就可以看出,如果某件事为真(也就是我们有一个等腰三角形的假设),并且如果其他某些附加条件也成立(定义、公设与前面其他的命题),那么另一件事(也就是结论)亦为真。命题所重视的是“若……则”这样的关系。命题要确定的不是假设是否为真,也不是结论是否为真—除非假设为真的时候。而除非命题得到证明,否则我们就无法确认假设和结论的关系是否为真。命题所证明的,纯粹是这种关系是否为真。别无其他。\n说这样的东西是优美的,有夸大其词吗?我们并不这么认为。我们在这里所谈的只是针对一个真正有范围限制的问题.作出真正逻辑的解释。在解释的清晰与问题范围有限制的特质之中,有一种特别的吸引力。在一般的谈话中,就算是非常好的哲学家在讨论,也没法将问题如此这般说得一清二楚。而在哲学问题中,即使用上逻辑的概念,也很难像这样清晰地解说出来。\n关于前面所列举的“命题五”的论点,与最简单的三段论法之间的差异性,我们再作些说明。所谓三段论法就是:\n所有的动物终有一死;\n所有的人都是动物;\n因此,所有的人终有一死。\n这个推论也确实适用于某些事。我们可以把它想成是数学上的推论。假定有动物及人这些东西,再假设动物是会死的。那就可以导引出像前面所说三角形那样确切的结论了。但这里的问题是动物和人是确切存在的,我们是就一些真实存在的东西来假设一些事情。我们一定得用数学上用不着的方法,来检验我们的假设。欧几里得的命题就不担心这一点。他并不在意到底有没有等腰三角形这回事。他说的是,如果有等腰三角形,如果如此定义,那一定可以导引出两个底角相同的结论。你真的用不着怀疑这件事—永远不必。\n4.掌握科学作品中的数学问题 # 关于欧几里得的话题已经有点离题了。我们所关心的是在科学作品中有相当多的数学问题,而这也是一个主要的阅读障碍。关于这一点有几件事要说明如下。\n第一,你至少可以把一些比你想像的基础程度的数学读得更明白。我们已经建议你从欧几里得开始,我们确定你只要花几个晚上把《几何原理》读好,就能克服对数学的恐惧心理。读完欧几里得之后,你可以进一步,看看其他经典级的希腊数学大师的作品—阿基米德(Archimedes) ,阿波罗尼乌斯(Apollonius),尼科马科斯(Nicomachus)。这些书并不真的很难,而且你可以跳着略读。\n这就带人了我们要说的第二个重点。如果你阅读数学书的企图是要了解数学本身,当然你要读数学,从头读到尾—手上还要拿枝笔,这会比阅读任何其他的书还需要在书页空白处写些笔记。但是你的企图可能并非如此,而是只想读一本有数学在内的科学书,这样跳着略读反而是比较聪明的。\n以牛顿的《自然哲学的数学原理》为例,书中包含了很多命题,有作图问题与定理。但你用不着真的每一个都仔细地去读,尤其第一次从头看一遍的时候更是如此。先看定理的说明,再看看结论,掌握一下这是如何证明出来的。读读引理(lemmas)及系理(corollaries)的说明,再读所谓旁注(scholiums)(基本上这是讨论命题与整个问题之间的关系)。这么做了之后,你会看到整本书的全貌,也会发现牛顿是如何架构这个系统的—哪个先哪个后,各个部分又如何密切呼应起来。用这样的方法读这本书,觉得困难就不要看图表(许多读者是这么做的),只挑你感兴趣的内容来看,但要确定没错过牛顿所强调的重点。其中一个重点出现在第三卷的结尾,名称是“宇宙系统”,牛顿称之为一般的旁注,不但总结了前人的重点,也提出了一个物理学上几乎所有后人都会思考的伟大问题。\n牛顿的《光学》(Optics)也是另一部伟大的科学经典作品,你应该也试着读一下。其实书中谈到的数学部分不多,但你一开始看时可能不这么认为,因为书中到处都是图表。其实这些图表只是用来说明牛顿的实验:让阳光穿过一个小洞,射进一个黑暗的房间,用棱镜截取光线,下面放一张白纸,就可以看到光线中各种不同的颜色呈现在纸上。你自己就可以很简单地重复这样的实验,这是做起来很好玩的事,因为色彩很美丽,而且描绘得一清二楚。除了有关这个实验的形容,你还会想读一下有关不同定理或命题的说明,以及三卷书中每卷结尾部分的讨论,牛顿在这里会对他的发现作个总结,并指出其意义。第三卷的结尾尤其出名,在这里牛顿对科学这个行业作了一些说明,很值得一读。\n科学作品中经常会包括数学,主要因为我们前面说过数学精确、清晰与范围限定的特质。有时候你能读懂一些东西,却用不着深人数学的领域,像牛顿的书就是个例子。奇怪的是,就算数学对你来说可怕得不得了,但是一点也没有数学有时造成的麻烦还可能更大呢!譬如在伽利略的《两种新科学》中,这是物质能量与运动的名作,对现代读者来说特别困难,因为基本上这不是数学的书,而是以对话形式来进行的。对话的形式被诸如柏拉图的大师运用在舞台或哲学讨论上,非常适合,运用在科学的讨论上就不太适合了。因此要明白伽利略到底谈的是什么其实是很困难的。不过如果你试着读一下,你会发现他在谈一些革新的创见。\n当然,并不是所有的科学经典作品都用上了数学,或是一定要用数学。像希腊医学之父,希波克拉底(Hippocrates)的作品就没有数学。你可以很容易读完这本书,发现希波克拉底的医学观点—预防胜于治疗的艺术。不幸的是,现代已经不流行这样的想法。威廉·哈维讨论血液循环的问题,或是威廉·吉伯特讨论磁场的问题,都与数学无关。只要你记住,你的责任不是成为这个主题的专家,而是要去了解相关的问题,在阅读时就会轻松许多。\n5.关于科普书的重点 # 从某一方面而言,关于阅读科普书,我们没有什么更多的话要说了。就定义上来说,这些书—不论是书或文章—都是为广泛的大众而写的,而不只是为专家写的。因此,如果你已经读了一些科学的经典名作,这类流行书对你来说就毫无问题了。这是因为这些书虽然与科学有关,但一般来说,读者都已经避免了阅读原创性科学巨著的两个难题。第一,他们只谈论一点相关的实验内容(他们只报告出实验的结果)。第二,内容只包括一点数学(除非是以数学为主的畅销书)。\n科普文章通常比科普书要容易阅读,不过也并非永远如此。有时候这样的文章很好—像《科学美国人》(Scientific American)月刊或更专业的《科学》(Science)周刊。当然,无论这些刊物有多好,编辑有多仔细多负责任,都还是会出现上一章结尾时所谈到的问题。在阅读这些文章时,我们就得靠记者为我们过滤资讯了。如果他们是好的记者,我们就很幸运。如果不是,我们就一无所获。\n阅读科普书绝对比阅读故事书要困难得多。就算是一篇三页没有实验报告,没有图表,也没有数学方程式需要读者去计算的有关DNA的文章,阅读的时候如果你不全神贯注,就是没法理解。因此,在阅读这种作品时所需要的主动性比其他的书还要多。要确认主题。要发现整体与部分之间的关系。要与作者达成共识。要找出主旨与论述。在评估或衡量意义之前,要能完全了解这本书才行。现在这些规则对你来说应该都很熟悉了。但是在这里运用起来更有作用。\n短文通常都是在传递资讯,你阅读的时候用不着太多主动的思考。你要做的只是去了解,明白作者所说的话,除此之外大多数情况就用不着花太大的力气了。至于阅读另外一些很出色的畅销书,像怀特海的《数学人门》(Introduction to Mathematics)、林肯·巴内特(LincolnBarnett)的《宇宙和爱因斯坦博士》、巴瑞·康孟纳(Barry Commoner)的《封闭的循环》(The Closing Circle)等等,需要的则比较多了。康孟纳的书更是如此,他所谈的主题—环保危机—对现代的我们来说都很感兴趣又很重要。他的书写得很密实,需要一直保持注意力。整本书就是一个暗示,仔细的读者不该忽略才对。虽然这不是实用的作品,不是我们在第十三章中所谈到的作品,但是书中的结论对我们的生活有重大影响。书中的主题—环保危机—谈的就是这个。环保问题是我们的问题,如果出现了危机,我们就不得不注意。就算作者没有说明—事实上他说了—我们还是身处在危机中。在面对危机时,(通常)会出现特定的反应,或是停止某种反应。因此康孟纳的书虽然基本上是理论性的,但已经超越了理论,进人实用的领域。\n这并不是说康孟纳的书特别重要,而怀特海或巴内特的书不重要。《宇宙和爱因斯坦博士》写出来之后,像这样一本为一般读者所写,研究原子的历史的理论书,让大家警觉到以刚发明不久的原子弹为主要代表、但不是全部代表的原子物理本质上的严重危机。因此,理论性的书一样会带来实际的结果。就算现代人不注意逐渐逼近的原子或核战争,阅读这类书仍然有实际的需要。因为原子或核物理是我们这个年代最伟大的成就,为我们带来许多美好的承诺,同样也带来许多重大危机。一个有知识、而且有心的读者应该尽可能阅读有关这方面的书籍。\n在怀特海的《数学人门》中,是另一个有点不同的重要讯息。数学是现代几个重要的神秘事物之一。或许,也是最有指标性的一个,在我们社会中占有像古代宗教所占有的地位。如果我们想要了解我们存活的这个年代,我们就该了解一下数学是什么,数学家是如何运用数学,如何思考的。怀特海的作品虽然没有深人讨论这个议题,但对数学的原理却有卓越的见解。如果这本书对你没有其他的作用,至少也对细心的读者显示了数学家并不是魔术师,而是个普通的人。这样的发现,对一个想要超越一时一地的思想与经验,想要扩大自己领域的读者来说尤其重要。\n第十八章 如何阅读哲学书 # 小孩常会问些伟大的问题:“为什么会有人类?”、“猫为什么会那样做?”、“这世界最初名叫什么?”、“上帝创造世界的理由是什么?”这些话从孩子的口中冒出来,就算不是智慧,至少也是在寻找智慧。根据亚里士多德的说法,哲学来自怀疑。那必然是从孩提时代就开始的疑问,只是大多数人的疑惑也就止于孩提时代。\n孩子是天生的发问者。并不是因为他提出的问题很多,而是那些问题的特质,使他与成人有所区别。成人并没有失去好奇心,好奇心似乎是人类的天生特质,但是他们的好奇心在性质上有了转化。他们想要知道事情是否如此,而非为什么如此。但是孩子的问题并不限于百科全书中能解答的问题。\n从托儿所到大学之间,发生了什么事使孩子的问题消失了?或是使孩子变成一个比较呆板的成人,对于事实的真相不再好奇?我们的头脑不再被好问题所刺激,也就不能理解与欣赏最好的答案的价值。要知道答案其实很容易。但是要发展出不断追根究底的心态,提出真正有深度的问题—这又是另一回事了。\n为什么孩子天生就有的心态,我们却要努力去发展呢?在我们成长的过程中,不知是什么原因,成人便失去了孩提时代原本就有的好奇心。或许是因为学校教育使头脑僵化了—死背的学习负荷是主因,尽管其中有大部分或许是必要的。另一个更可能的原因是父母的错。就算有答案,我们也常告诉孩子说没有答案,或是要他们不要再问问题了。碰到那些看来回答不了的问题时,我们觉得困窘,便想用这样的方法掩盖我们的不自在。所有这些都在打击一个孩子的好奇心。他可能会以为问问题是很不礼貌的行为。人类的好问从来没有被扼杀过,但却很快地降格为大部分大学生所提的问题—他们就像接下来要变成的成人一样,只会问一些资讯而已。\n对这个问题我们没有解决方案,当然也不会自以为是,认为我们能告诉你如何回答孩子们所提出来的深刻问题。但是我们要提醒你一件很重要的事,就是最伟大的哲学家所提出来的深刻问题,正是孩子们所提出的问题。能够保留孩子看世界的眼光,又能成熟地了解到保留这些问题的意义,确实是非常稀有的能力—拥有这种能力的人也才可能对我们的思想有重大的贡献。\n我们并不一定要像孩子般地思考,才能了解存在的问题。孩子们其实并不了解,也没法了解这样的问题—就算真有人能了解的话。但是我们一定要能够用赤子之心来看世界,怀疑孩子们怀疑的问题,间他们提出的问题。成人复杂的生活阻碍了寻找真理的途径。伟大的哲学家总能厘清生活中的复杂,看出简单的差别—只要经由他们说明过,原先困难无比的事就变得很简单了。如果我们要学习他们,提问题的时候就一定也要有孩子气的单纯—而回答时却成熟而睿智。\n1.哲学家提出的问题 # 这些哲学家所提出的“孩子气的单纯”问题,到底是些什么问题?我们写下来的时候,这些问题看起来并不简单,因为要回答起来是很困难的。不过,由于这些问题都很根本也很基础,所以乍听之下很简单。\n下面就拿“有”或“存在”这样的问题作例子:存在与不存在的区别在哪里?所有存在事物的共同点是什么?每一种存在事物的特质是什么?事物存在的方法是否各有不同—各有不同的存在形式?是否某些事物只存在心中,或只为了心灵而存在?而存在于心灵之外的其他事物,是否都为我们所知,或是否可知?是否所有存在的事物都是具体的,或是在具体物质之外仍然存在着某些事物?是否所有的事都会改变,还是有什么事是永恒不变的?是否任何事物都有存在的必要?还是我们该说:目前存在的事物不见得从来都存在?是否可能存在的领域要大于实际存在的领域?\n一个哲学家想要探索存在的特质与存在的领域时,这些就是他们会提出来的典型问题。因为是问题,并不难说明或理解,但要回答,却难上加难—事实上困难到即使是近代的哲学家,也无法作出满意的解答。\n哲学家会提的另一组问题不是存在,而是跟改变或形成有关。根据我们的经验,我们会毫不迟疑地指出某些事物是存在的,但是我们也会说所有这些事物都是会改变的。它们存在过,却又消失了。当它们存在时,大多数都会从一个地方移动到另一个地方,其中有许多包括了质与量上的改变:它们会变大或变小,变重或变轻,或是像成熟的苹果与过老的牛排,颜色会有改变。\n改变所牵涉到的是什么呢?在每一个改变的过程中,是否有什么坚持不变的东西?以及这个坚持不变的东西是否有哪些方面还是要遭逢改变?当你在学习以前不懂的东西时,你因为获得了知识而在某方面有了改变,但你还是和以前一样是同一个人。否则,你不可能说因为学习而有所改变了。是否所有的改变都是如此?譬如对于生死这样巨大的改变—也就是存在的来临与消失—是否也是如此?还是只对一些不太重要的改变,像某个地区内的活动、成长或某种质地上的变动来说,才如此?不同的改变到底有多少种?是否所有的改变都有同样的基本要素或条件?是否所有这些因素或条件都会产生作用?我们说造成改变的原因是什么意思呢?在改变中是否有不同的原因呢?造成改变—或变化的原因,跟造成存在的原因是相同的吗?\n哲学家提出这样的问题,就是从注意事物的存在到注意事物的转变,并试着将存在与改变的关系建立起来。再强调一次,这些问题并不难说明及理解,但要回答得清楚又完整却极不容易。从上面两个例子中,你都可以看出来,他们对我们所生活的世界抱持着一种多么孩子气的单纯心态。\n很遗憾,我们没有多余的篇幅继续深人探讨所有这些问题。我们只能列举一些哲学家提出并想要解答的问题。那些伺题不只关于存在或改变,也包括必然性与偶然性,物质与非物质,自然与非自然,自由与不确定性(indeterminacy) ,人类心智的力量与人类知识的本质及范围,以及自由意志的问题。\n就我们用来区别理论与实用领域的词义而言,以上这些问题都是属于思辩性或理论性的问题。但是你知道,哲学并不只限于理论性的问题而已。\n以善与恶为例。孩子特别关心好跟坏之间的差别,如果他们弄错了,可能还会挨打。但是直到我们成人之后,对这两者之间的差异也不会停止关心。在善与恶之间,是否有普遍被认可的区别?无论在任何情况中,是否某些事永远是好的,某些事永远是坏的?或是就像哈姆雷特引用蒙田的话:“没有所谓好跟坏,端看你怎么去想它。”\n当然,善与恶跟对与错并不相同。这两组词句所谈的似乎是两种不同的事。尤其是,就算我们会觉得凡是对的事情就是善的,但我们可能不觉得凡是错的事情就一定是恶的。那么,要如何才能清楚地区分呢?\n“善”是重要的哲学字眼,也是我们日常生活重要的字眼。想要说明善的意义,是一件棘手的事。在你弄清楚以前,你已经深陷哲学的迷思中了。有许多事是善的,或像我们常用的说法,有许多善行。能将这些善行整理出条理来吗?是不是有些善行比另一些更重要?是否有些善行要依赖另一些善行来完成?在某些情况中,是否两种善行会互相抵触,你必须选择一种善行,而放弃另一种?\n同样的,我们没有篇幅再深入讨论这个问题。我们只能在这个实用领域中再列举一些其他问题。有些问题不只是善与恶、对与错或是善行的等级,同时是义务与责任,美德与罪行,幸福与人生的目标,人际关系与社会互动之中的公理及正义,礼仪与个人的关系,美好的社会与公平的政府与合理的经济,战争与和平等问题。\n我们所讨论的两种问题,区分出两种主要不同的哲学领域。第一组,关于存在与变化的问题,与这个世界上存在与发生的事有关。这类问题在哲学领域中属于理论或思辩型的部分。第二组,关于善与恶,好与坏的问题,和我们应该做或探寻的事有关,我们称这是隶属于哲学中实用的部分,更正确来说该是规范(normative)的哲学。一本教你做些什么事的书,像烹饪书,或是教你如何做某件事,像驾驶手册,用不着争论你该不该做个好厨师或好驾驶,他们假设你有意愿要学某件事或做某件事,只要教你如何凭着努力做成功而已。相对的,哲学规范的书基本上关心的是所有人都应该追求的目标—像过好生活,或组织一个好社会—与烹饪书或驾驶手册不同的是,他们就应该运用什么方法来达成目的的这一点上,却仅仅只会提供一些最普遍的共识。\n哲学家提出来的问题,也有助于哲学两大领域中次分类的区分。如果思辩或理论型的哲学主要在探讨存在的问题,那就属于形上学。如果问题与变化有关—关于特质与种类的演变,变化的条件与原因—就是属于自然哲学的。如果主要探讨的是知识的问题—关于我们的认知,人类知识的起因、范围与限制,确定与不确定的问题—那就属于认识论(epistemology)的部分,也称作知识论。就理论与规范哲学的区分而言,如果是关于如何过好生活,个人行为中善与恶的标准,这都与伦理学有关,也就是理论哲学的领域;如果是关于良好的社会,个人与群体之间的行为问题,则是政治学或政治哲学的范畴,也就是规范哲学的领域。\n2.现代哲学与传承 # 为了说明简要,让我们把世上存在及发生了什么事,或人类该做该追求的问题当作“第一顺位问题”。我们要认知这样的问题。然后是“第二顺位问题”:关于我们在第一顺位问题中的知识,我们在回答第一顺位问题时的思考模式,我们如何用语言将思想表达出来等问题。\n区别出第一顺位与第二顺位问题是有帮助的。因为那会帮助我们理解近年来的哲学界发生了什么变化。当前主要的专业哲学家不再相信第一顺位的问题是哲学家可以解决的问题。目前大多数专业哲学家将心力投注在第二顺位的问题上,经常提出来的是如何用言语表达思想的问题。\n往好处想,细部挑剔些总没什么坏处。问题在于今天大家几乎全然放弃了第一顺位的疑问,也就是对门外汉读者来说最可能感兴趣的那些问题。事实上,今天的哲学,就像当前的科学或数学一样,已经不再为门外汉写作了。第二顺位的问题,几乎可以顾名思义,都是些诉求比较窄的问题,而专业的哲学家,就像科学家一样,他们惟一关心的只有其他专家的意见。\n这使得现代哲学作品对一个非哲学家来说格外难读—就像科学书对非科学家来说一样的困难。只要是关于第二顺位的哲学作品,我们都无法指导你如何去阅读。不过,还是有一些你可以读的哲学作品,我们相信也是你该读的书。这些作品提出的问题是我们所说的第一顺位问题。毫无意外的,这些书主要也是为门外汉而写的,而不是专业哲学家写给专业同行看的。\n上溯至1930年或稍晚一点,哲学书是为一般读者而写作的。哲学家希望同行会读他们的书,但也希望一般有知识的读者也能读。因为他们所提的问题,想要回答的问题都是与一般人切身相关的,因此他们认为一般人也该知道他们的思想。\n从柏拉图以降,所有哲学经典巨著,都是从这个观点来写作的。一般门外汉的读者也都能接受这样的书,只要你愿意,你就能读这些书。我们在这一章所说的一切,都是为了鼓励你这么做。\n3.哲学的方法 # 至少就提出与回答第一顺位问题的哲学而言,了解哲学方法的立足点是很重要的。假设你是一个哲学家,你对我们刚才提的那些孩子气的单纯问题感到很头痛—像任何事物存在的特质,或是改变的特质与成因等问题。那你该怎么做?\n如果你的问题是科学的,你会知道要如何回答。你该进行某种特定的研究,或许是发展一种实验,以检验你的回答,或是广泛地观察各种现象以求证。如果你的问题是关于历史的,你会知道也要做一些研究,当然是不同的研究。但是要找出普遍存在的特质,却没有实验方法可循。而要找出改变是什么,事情为什么会改变,既没有特殊的现象可供你观察,更没有文献记载可以寻找阅读。你惟一能做的是思考问题本身,简单来说,哲学就是一种思考,别无他物。\n当然,你并不是在茫然空想。真正好的哲学并不是“纯”思维—脱离现实经验的思考。观念是不能任意拼凑的。回答哲学问题,有严格的检验,以确认答案是否合乎逻辑。但这样的检验纯粹是来自一般的经验—你身而为人就有的经验,而不是哲学家才有的经验。你透过人类共同经验而对“改变”这种现象的了解,并不比任何人差—有关你的一切,都是会改变的。只要改变的经验持续下去,你就可以像个伟大的哲学家一样,思考有关改变的特质与起因。而他们之所以与你不同,就在他们的思想极为缜密:他们能整理出所有可能问到的最尖锐的问题,然后再仔细清楚地找出答案来。他们用什么方法找出答案来呢?不是观察探索,也不是寻找比一般人更多的经验,而是比一般人更深刻地思考这个问题。\n了解这一点还不够。我们还要知道哲学家所提出来与回答的问题,并非全部都是真正哲学的问题。他们自己没法随时觉察到这一点,因而在这一点上的疏忽或错误,常会让洞察力不足的读者倍增困扰。要避免这样的困难,读者必须有能力把哲学家所处理真正哲学性的问题,和他们可能处理,但事实上应该留给后来科学家来寻找答案的其他问题作一区别。哲学家看不出这样的问题可以经由科学研究来解决的时候,就会被误导—当然,在他写作的那个年代,他很可能料想不到有这一天。\n其中一个例子是古代哲学家常会问天体(celestrial bodies)与地体(terrestrial bodies)之间的关系。因为没有望远镜的帮助,在他们看来,天体的改变移动只是位置的移动,从没有像动物或植物一样诞生与消失的问题,而且也不会改变尺寸或性质。因为天体只有一种改变的方式—位置的移动—而地体的改变却是不同的方式,古人便下结论说组成天体的成分必然是不同的。他们没有臆测到,他们也不可能臆测到,在望远镜发明之后,我们会知道天体的可变性远超过我们一般经验所知。因此,过去认为应该由哲学家回答的问题,其实该留到后来由科学家来探索。这样的调查研究是从伽利略用望远镜发现木星的卫星开始的,这引发了后来开普勒(Kepler)发表革命性的宣言:天体的性质与地球上的物体完全一样。而这又成了后来牛顿天体机械理论的基础,在物理宇宙中,各运动定律皆可适用。\n整体来说,除了这些可能会产生的困扰之外,缺乏科学知识的缺点并不影响到哲学经典作品的本身。原因是当我们在阅读一本哲学书时,所感兴趣的是哲学的问题,而不是科学或历史的问题。在这里我们要冒着重复的风险再说一次,我们要强调的是,要回答哲学的问题,除了思考以外,别无他法。如果我们能建造一架望远镜或显微镜,来检验所谓存在的特质,我们当然该这么做,但是不可能有这种工具的。\n我们并不想造成只有哲学家才会犯我们所说的错误的印象。假设有一位科学家为人类该过什么样的生活而困扰。这是个规范哲学的问题,除了思考以外没有别的回答方法。但是科学家可能不了解这一点,而认为某种实验或研究能给他答案。他可能会去问一千个人他们想要过什么样的生活,然后他的答案便是根据这些回答而来的。但是,显然他的答案是毫无意义哟,就像亚里士多德对天体的思考一样是离题的。\n4.哲学的风格 # 虽然哲学的方法只有一种,但是在西方传统中,伟大的哲学家们至少采用过五种论述的风格。研究或阅读哲学的人应该能区别出其间的不同之处,以及各种风格的优劣。\n(1)哲学对话:第一种哲学的论说形式,虽然并不是很有效,但首次出现在柏拉图的《对话录)) (Dialogues)中。这种风格是对话的,甚至口语的,一群人跟苏格拉底讨论一些主题(或是后来一些对话讨论中,是和一个名叫“雅典陌生人”\u0026quot;[the Athenian Stranger]的人来进行的)。通常在一阵忙乱的探索讨论之后,苏格拉底会开始提出一连串的问题,然后针对主题加以说明。在柏拉图这样的大师手中,这样的风格是启发性的,的确能引领读者自己去发现事情。这样的风格再加上苏格拉底的故事的高度戏剧性—或是说高度的喜剧性—就变得极有力量。\n柏拉图却一声不响地做到了。怀特海有一次强调,全部西方哲学,不过是“柏拉图的注脚”。后来的希腊人自己也说:“无论我想到什么,都会碰到柏拉图的影子。”无论如何,不要误会了这些说法。柏拉图自己显然并没有哲学系统或教条—若不是没有教条,我们也没法单纯地保持对话,提出问题。因为柏拉图,以及在他之前的苏格拉底,已经把后来的哲学家认为该讨论的所有重要问题,几乎都整理、提问过了。\n(2)哲学论文或散文:亚里士多德是柏拉图最好的学生,他在柏拉图门下学习了二十年。据说他也写了对话录,却完全没有遗留下来。所遗留下来的是一些针对不同的主题,异常难懂的散文或论文。亚里士多德无疑是个头脑清晰的思想家,但是所存留的作品如此艰涩,让许多学习者认为这些原来只是演讲或书本的笔记—不是他自己的笔记,就是听到大师演讲的学生记录下来的。我们可能永远不知道事情的真相,但是无论如何,亚里士多德的文章是一种哲学的新风格。\n亚里士多德的论文所谈论的主题,所运用的各种不同的叙述方式,都表现出他的研究发现,也有助于后来几个世纪中建立起哲学的分科与方法。关于他的作品,一开始是一些所谓很普及的作品—大部分是对话录,传到今天只剩下一些残缺不全的资料。再来是文献的收集,我们知道其中最重要的是希腊158个城邦的个别宪法。其中只有雅典的宪法存留下来,那是1890年从一卷纸莎草资料中发现的。最后是他主要的论文,像《物理学》、《形上学》(Metaphysics)、《伦理学》、《政治学》与《诗学》。这些都是纯粹的哲学作品,是一些理论或规范。其中有一本《灵魂论》(On the Soul)则是混合了哲学理论与早期的科学研究。其他一些诸如生物论文的作品,则是自然历史中主要的科学著作。\n虽然从哲学的观点来看,康德受到柏拉图的影响很大,但是他采用了亚里士多德的论说方法。与亚里士多德不同的是,康德的作品是精致的艺术。他的书中会先谈到主要问题,然后有条不紊地从方方面面完整地讨论主题,最后,或是顺便再讨论一些特殊的问题。也许,康德与亚里士多德作品的清楚明白,立足于他们处理一个主题的秩序上。我们可以从他们的作品中看到哲学论述的开头、发展与结尾。同时,尤其是在亚里士多德的作品中,我们会看到他提出观点与反对立场。因此,从某个角度来看,论文的形式与对话的形式差不多。但是在康德或亚里士多德的作品中都不再有戏剧化的表现手法,不再像柏拉图是由立场与观点的冲突来表达论说,而是由哲学家直接叙述自己的观点。\n(3)面对异议:中世纪发展的哲学风格,以圣托马斯·阿奎那的《神学大全》为极致,兼有前述两者的风貌。我们说过,哲学中不断提到的问题大部分是柏拉图提出的;我们应该也谈到,苏格拉底在对话过程中问的是那种小孩子才会问的简单又深刻的向题。而亚里士多德,我们也说过,他会指出其他哲学家的不同意见,并作出回应。\n阿奎那的风格,结合了提出问题与面对异议的两种形态。《神学大全》分成几个部分:论文、问题与决议。所有文章的形式都相同。先是提出问题,然后是呈现对立面(错误)的回答,然后演绎一些支持这个错误回答的论述,然后先以权威性的经文(通常摘自《圣经》)来反驳这些论述,最后,阿奎那提出自己的回答或解决方案。开头一句话一定是:“我回答如下”,陈述他自己的观点之后,针对每一个错误回答的论述作出回应。\n对一个头脑清晰的人来说,这样整齐有序的形式是十分吸引人的。但这并不是托马斯式的哲学中最重要的一点。在阿奎那的作品中,最重要的是,他能明确指陈各种冲突,将不同的观点都说明出来,然后再面对所有不同的意见,提出自己的解决方案。从对立与冲突中,让真理逐渐浮现,这是中世纪非常盛行的想法。在阿奎那的时代,哲学家接受这样的方式,事实上是因为他们随时要准备当众,或在公开的论争中为自己的观点作辩护—这些场合通常群聚着学生和其他利害相关的人。中世纪的文化多半以口述方式流传,部分原因可能是当时书籍很少,又很难获得。一个主张要被接受,被当作是真理,就要能接受公开讨论的测试。哲学家不再是孤独的思考者,而是要在智力的市场上(苏格拉底可能会这么说),接受对手的挑战。因此,《神学大全》中便渗透了这种辩论与讨论的精神。\n(4)哲学系统化:在17世纪,第四种哲学论说形式又发展出来了。这是两位著名的哲学家,笛卡尔与斯宾诺莎所发展出来的。他们着迷于数学如何组织出一个人对自然的知识,因此他们想用类似数学组织的方式,将哲学本身整理出来。\n笛卡尔是伟大的数学家,虽然某些观点可能是错的,也是一位值得敬畏的哲学家。基本上,他尝试要做的是为哲学披上数学的外衣—给哲学一些确定的架构组织,就像二千年前,欧几里得为几何学所作的努力。在这方面,笛卡尔并不算完全成功,但是他主张思想要清楚又独立,对照着当时混乱的知识氛围,其影响在相当程度上是不言自明的。他也写一些多少有点传统风格的哲学论文,其中包括一些他对反对意见的回应。\n斯宾诺莎将这样的概念发展到更深的层次。他的《伦理学》(Ethics)是用严格的数学方式来表现的,其中有命题、证明、系理、引理、旁注等等。然而,关于形上学或伦理道德的问题,用数学的方法来解析不能让人十分满意,数学的方法还是比较适合几何或其他的数学问题,而不适合用在哲学问题上。当你阅读斯宾诺莎的时候,可以像你在阅读牛顿的时候那样略过很多地方,在阅读康德或亚里士多德时,你什么也不能略过,因为他们的理论是一直连续下来的。读柏拉图时也不能省略,你漏掉一点就像看一幕戏或读一首诗时,错过了其中一部分,这样整个作品就不完整了。\n或许,我们可以说,遣字用句并没有绝对的规则。问题是,像斯宾诺莎这样用数学的方法来写哲学的作品,是否能达到令人满意的结果?就像伽利略一样,用对话的形式来写科学作品,是否能产生令人满意的科学作品?事实上,这两个人在某种程度上都无法与他们想要沟通的对象作沟通,看起来,这很可能在于他们所选择的沟通形式。\n(5)格言形式:还有另一种哲学论说形式值得一提,只不过没有前面四种那么重要。这就是格言的形式,是由尼采在他的书《查拉图斯特拉如是说))(Thus Spake Zarathustra)中所采用的,一些现代的法国哲学家也运用这样的方式。上个世纪这样的风格之所以受到欢迎,可能是因为西方的读者对东方的哲学作品特别感兴趣,而那些作品就多是用格言的形式写作的。这样的形式可能也来自帕斯卡尔的《沉思录》(Pensees)。当然,帕斯卡尔并不想让自己的作品就以这样简短如谜的句子面世,但是在他想要以文章形式写出来之前,他就已经去世了。\n用格言的形式来解说哲学,最大的好处在于有启发性。这会给读者一个印象,就像在这些简短的句子中还有言外之意,他必须自己运用思考来理解—他要能够自己找出各种陈述之间的关联,以及不同论辩的立足点。同样地,这样的形式也有很大的缺点,因为这样的形式完全没法论说。作者就像个撞了就跑的司机,他碰触到一个主题,谈到有关的真理与洞见,然后就跑到另一个主题上,却并没有为自己所说的话作适当的辩解。因此,格言的形式对喜欢诗词的人来说是很有意思的,但对严肃的哲学家来说却是很头痛的,因为他们希望能跟随着作者的思想,对他作出评论。\n到目前为止,我们知道在西方的文化传统中,没有其他重要的哲学形式了。(像卢克莱修的《物性论》[On the Nature of Things]并不是特例,这本书原是以韵文写作,但是风格发展下去,跟其他的哲学论文又差不多了。不管怎么说,今天我们读到的一般都是翻译成散文的版本。)也就是说,所有伟大的哲学作品都不出这五种写作形式,当然,有时哲学家会尝试一种以上的写作方式。不论过去或现在,哲学论文或散文都可能是最普遍的形式,从最高超最困难的作品,像康德的书,到最普遍的哲学论文都包括在其中。对话形式是出了名的难写,而几何形式是既难读又难写。格言形式对哲学家来说是绝对不能满意的。而托马斯形式则是现代较少采用的一种方式。或许这也是现代读者不喜欢的一种方式,只是很可惜这样的方式却有很多的好处。\n5.阅读哲学的提示 # 到目前为止,读者应该很清楚在阅读任何哲学作品时,最重要的就是要发现问题,或是找到书中想要回答的问题。这些问题可能详细说明出来了,也可能隐藏在其中。不管是哪一种,你都要试着找出来。\n作者会如何回答这些问题,完全受他的中心思想与原则的控制。在这一方面作者可能也会说明出来,但不一定每本书都如此。我们前面已经引述过巴兹尔·威利的话,要找出作者隐藏起来、并未言明的假设,是多么困难—也多么重要的—事情。这适用于每一种作品。运用在哲学书上尤其有力。\n伟大的哲学作品不至于不诚实地隐藏起他们的假设,或是提出含混不清的定义或假定。一位哲学家之所以伟大,就是因为他能比其他的作者解说得更淋漓尽致。此外,伟大的哲学家在他的作品背后,都有自己特定的中心思想与原则。你可以很容易就看出他是否清楚地写在你读的那本书里。但是他也可能不这么做,保留起来在下一本书里再说明白。也可能他永远都不会明讲,但是在每本书里都有点到。\n这样的中心思想的原则,很难举例说明。我们所举出的例子可能会引起哲学家的抗议,我们在这里也没有多余的空间能为自己的选择作辩解。然而,我们可以指出柏拉图一个中心思想的原则是什么—他认为,有关哲学主题的对话,可能是人类所有活动中最重要的一个活动。在柏拉图的各种对话中,几乎看不到他明讲这种观点—只有《自辩篇》(Apology)中苏格拉底讲过没有反省的生活是不值得活下去的生活,以及柏拉图在《第七封信》(Seventh Letter)中提到过。重点是,柏拉图在许多其他地方都提到这样的观点,虽然使用的字数不多。譬如在《诡辩篇》(Protagoras)中,诡辩者罗普罗泰格拉斯不愿意继续跟苏格拉底谈话时,旁边的听众就表现出很不满意的样子。另一个例子是在《理想国》第一卷,克法洛斯刚好有事要办,便离去了。虽然并没有详尽的说明,但柏拉图想要说的似乎是:一个人不论是为了任何理由而拒绝参与追求真理,都是人性最深沉的背叛。但是,就像我们强调过的,一般人并不会把这一点当作柏拉图的一个“观念”,因为在他的作品中,几乎从没有明白地讨论过这一点。\n我们可以在亚里士多德中找到其他的例子。在阅读亚里士多德的书时,一开始就要注意到一件重要的事:在他所有作品中,所讨论的问题都是彼此相关的。他在《工具论》(Organon)中详细说明的逻辑基本原则,在《物理学》中却是他的假设。其次,由于部分原因归之于这些论文都是未完成的工作,因此他中心思想的原则也就没法到处都很清楚地说明出来。《伦理学》谈到很多事:幸福、习惯、美德、喜悦等等—可以写上一长串。但是只有最细心的读者才能看出他所领悟的原则是什么。这个领悟就是幸福是善的完整(whole of the good),而不是最高的(highest)善,因为如果是那样,那就只有一种善了。认知到这一点,我们可以看出幸福并不是在追求自我完美或自我改进的善,虽然这些在一些部分的善中是最高的。幸福,如亚里士多德所言,是一个完整生命的品质。他所说的“完整”不只是从一时的观点来看,也是从整体生命的所有角度来看的。因而我们现在或许可以说,一个幸福的人,是具现了生命的完整,而且一生都保持这种完整的人。这一点几乎影响到《伦理学》中所有其它想法与观点的中心思想,但是在书中却并没有怎么明白说明。\n再举个例子。康德的成熟思想通常被认为是批判的哲学。他自己将“批判主义”与“教条主义”作了比较,把过去许多哲学家归类为后者。他所谓的“教条主义”,就是认为只要凭着思考,用不着考虑本身的局限性,人类的知性就可以掌握最重要的真理。照康德的看法,人类的第一要务就是要严格地检查并评估心智的资源与力量。因此,人类心智的局限就是康德中心思想的原则,在他之前没有任何一位哲学家这样说过。在《纯粹理性批判》中,这个概念被清楚地解说出来了。但是在康德主要的美学著作《批判力批判(Critique o f Judgment)中,却没有说明出来,而只是假设如此。然而,不管怎么说,在那本书里,这还是他的中心思想原则。\n关于由哲学作品中找出中心思想的原则,我们能说的就是这些,因为我们不确定能否告诉你如何找到这样的中心思想。有时候那需要花上许多年的时间,阅读很多书,然后又重新阅读过,才能找到。对一个思虑周详的好读者来说,这是一个理想的目标,毕竟,你要记得,如果你想要了解你的作者,这还是你必需要做的事。尽管要找出中心思想的原则很困难,但是我们仍然不主张你走捷径,去阅读一些关于哲学家生活或观察点的书。你自己找到的原则,会比其他人的观点还更有价值。\n一旦你找到作者中心思想的原则后,你就会想要看作者怎能将这样的概念在整本书中贯彻到底。遗憾的是,哲学家们,就算是最好的哲学家,通常也做不到这一点。爱默生说过,一贯性“是小智小慧的骗人伎俩\u0026quot;(hobgoblin of little minds)。虽然我们也该记住这个非常轻松的说法,但也不该忘了,哲学家前后不一致是个非常严重的问题。如果哲学家前后说法不一,你就要判断他所说的两个想法中哪一个才是真的—他在前面说的原则,还是最后没有从原则中导引出来的结论?或许你会决定两者都不可信。\n阅读哲学作品有些特点,这些特点和哲学与科学的差异有关。我们这里所谈的哲学只是理论性作品,如形上学的论述或关于自然哲学的书。\n哲学问题是要去解说事物的本质,而不像科学作品要的是描述事物的本质。哲学所询问的不只是现象之间的联系,更要追寻潜藏在其中的最终原因与条件。要回答这些问题,只有清楚的论述与分析,才能让我们感到满意。\n因此,读者最要花力气的就是作者的词义与基本主旨。虽然哲学家跟科学家一样,有一些专门的技术用语,但他们表达思想的词句通常来自日常用语,只是用在很特殊的意义上。读者需要特别注意这一点。如果他不能克服自己,总是想将一个熟悉的字看作一般意义的想法,最后他会让整本书变成胡说八道又毫无意义。\n哲学讨论的基本词义就像科学作品一样,当然是抽象的。其实,任何具有共通性的知识,除了抽象的词义外,无从表达。抽象并没什么特别难的。我们每天都在运用,也在各谈话中运用这些抽象词义。不过,似乎很多人都为“抽象”或“具体”的用词而感到困扰。\n每当你一般性地谈到什么事情,你就使用抽象的字眼。你经由感官察觉到的永远是具体与个别的,而你脑中所想的永远是抽象又普遍的。要了解一个“抽象的字眼”,就要掌握这个字眼所表达的概念。所谓你对某件事“有了概念”,也就是你对自己具体经验到的某些事情的普遍性层面有了了解。你不能看到,碰触到,甚或想像到这里所谓的普遍性层面。如果你做得到,那么感官与思想就毫无差别了。人们总想想像出是什么概念在困扰他们,最后却会对所有抽象的东西感到绝望。\n在阅读科学作品时,归纳性的论证是读者特别需要注意的地方。在哲学作品中也是一样,你一定要很注意哲学家的原则。这很可能是一些他希望你跟他一起接受的假设,也可能是一些他所谓的自明之理。假设的本身没有问题。但就算你有自己相反的假设,也不妨看看他的假设会如何导引下去。假装相信一些其实你并不相信的事,是很好的心智训练。当你越清楚自己的偏见时,你就越不会误判别人的偏见了。\n另外有一种原则可能会引起困扰。哲学作品几乎没有不陈述一些作者认为不证自明的主旨。这种主旨都直接来自经验,而不是由其他主旨证明而来。\n要记住的是,我们前面已经提过不只一次,这些来自哲学本身的经验,与科学家的特殊经验不同,是人类共同的经验。哲学家并没有在实验室中工作,也不做田野研究调查。因此要了解并测验一位哲学家的主要原则,你用不着借重经由方法调查而获得的特殊经验,这种额外的助力。他诉求的是你自己的普通常识,以及对你自己所生存的这个世界的日常观察。\n换句话说,你在阅读哲学书时要用的方法,就跟作者在写作时用的方法是一样的。哲学家在面对问题时,除了思考以外,什么也不能做。读者在面对一本哲学书时,除了阅读以外,什么也不能做—那也就是说,要运用你的思考。除了思考本身外,没有任何其他的帮助。\n这种存在于读者与一本书之间的必要的孤独,是我们在长篇大论讨论分析阅读时,一开始就想像到的。因此你可以知道,为什么我们在叙述并说明阅读的规则、认为这些规则用在哲学书上的时候,会比其他书来得更适用。\n6.厘清你的思绪 # 一本好的哲学理论的书,就像是好的科学论文,不会有滔滔雄辩或宣传八股的文字。你用不着担心作者的“人格”问题,也不必探究他的社会或经济背景。不过,找一些周详探讨过这个问题的其他伟大的哲学家的作品来读,对你来说会有很实际的帮助。在思想的历史上,这些哲学家彼此之间已经进行了长久的对话。在你确认自己能明白其中任何一人在说些什么之前,最好能仔细倾听。\n哲学家彼此意见往往不合这一点,不应该是你的困扰。这有两个原因。第一,如果这些不同的意见一直存在,可能就指出一个没有解决,或不能解决的大问题。知道真正的奥秘所在是件好事。第二,哲学家意见合不合其实并不重要,你的责任只是要厘清自己的思路。就哲学家透过他们的作品而进行的长程对话,你一定要能判断什么成立,什么不成立才行。如果你把一本哲学书读懂了—意思是也读懂了其他讨论相同主题的书—你就可以有评论的立场了。\n的确,哲学问题的最大特色就在每个人必须为自己回答这些间题。采用别人的观点并没有解决这些问题,只是在逃避问题而已。你的回答一定要很实在,而且还要有理论根据。总之,这跟科学研究不同,你无法依据专家的证词来回答。\n原因是,哲学家所提出的问题,比其他任何人所提的问题都简单而重要。孩子除外。\n7.关于神学的重点 # 神学有两种类型,自然神学(natural theology)与教义神学(dogmatic theoloev)。自然神学是哲学的一支,也是形而上学的最后一部分。譬如你提出一个问题,因果关系是否永无止境?每件事是否都有起因?如果你的答案是肯定的,你可能会陷入一种永无止境的循环当中。因此,你可能要设定某个不因任何事物而发生的原始起因的别称。亚里士多德称这种没有起因的原因是“不动的原动者”(unmoved mover)。你可以另外命名—甚至可以说那不过是上帝的别称—但是重点在,你要透过不需要外力支援的—自然进行的—思考,达成这番认知。\n教义神学与哲学则不同,因为教义神学的首要原则就是某个宗教的教徒所信奉的经文。教义神学永远依赖教义与宣扬教义的宗教权威人士。\n如果你没有这样的信仰,也不属于某个教派,想要把教义神学的书读好,你就得拿出读数学的精神来读。但是你得永远记住,在有关信仰的文章中,信仰不是一种假设。对有信仰的人来说,那是一种确定的知识,而不是一种实验性的观点。\n今天许多读者了解这一点似乎很困难。一般来说,在面对教义神学的书时,他们会犯一两个错。第一个错是拒绝接受—即使是暂时的接受—作者首要原则的经文。结果,读者一直跟这些首要原则挣扎,根本注意不到书的本身。第二个错是认为,既然整本书的首要原则是教义的,依据这些教义而来的论述,这些教义所支持的推论,以及所导引出来的结论,都必然也都是属于教义的。当然,如果我们接受某些原则,立足于这些原则的推论也能令人信服,那么我们就必须接受这样所得出的结论—至少在那些原则的范围内如此。但是如果推论是有问题的,那么原来再可以接受的首要原则,也会导出无效的结论。\n谈到这里,你该明白一个没有信仰的读者要阅读神学书时有多困难了。在阅读这样的书时,他要做的就是接受首要原则是成立的,然后用阅读任何一本好的论说性作品都该有的精神来阅读。至于一个有信仰的读者在阅读与自己信仰有关的书籍时,要面对的则是另一些困难了。这些问题并不只限于阅读神学才出现。\n8.如何阅读“经书” # 有一种很有趣的书,一种阅读方式,是我们还没提到的。我们用“经书\u0026quot;(canonical)来称呼这种书,如果传统一点,我们可能会称作“圣\u0026quot;(sacred)或“神书”(holy)。但是今天这样的称呼除了在某些这类书上还用得着之外,已经不适用于所有这类书籍了。\n一个最基本的例子就是《圣经》。这本书不是被当作文学作品来读,而是被当作神的话语来读。\n经书的范围不只这些明显的例子。任何一个机构—教会、政党或社会—在其他的功能之外,如果(1)有教育的功能,(2)有一套要教育的课本(a body of doctrine to teach),(3)有一群虔诚又顺服的成员,那么属于这类组织的成员在阅读的时候都会必恭必敬。他们不会—也不能—质疑这些对他们而言就是“经书”的书籍的权威与正确的阅读方法。信仰使得这些信徒根本不会发现“神圣的”经书中的错误,更别提要找出其中道理不通的地方。\n正统的犹太人是以这样的态度来阅读《旧约》的。基督徒则是这样阅读《新约》。回教徒是这样读《古兰经》。马克思主义信徒则是这样阅读马克思或列宁的作品,有时看政治气候的转变,也会这样读斯大林的作品。弗洛伊德心理学的信徒就是这样读弗洛伊德的。美国的陆军军官是这样读步兵手册的。你自己也可以想出更多的例子。\n事实上,对大多数人来说,就算没有严重到那个程度,在阅读某些必须要当作经典的作品时,也是抱着这种心态来读的。一位准律师为了通过律师考试,一定要用虔敬的心来阅读某些特定的教材,才能在考试中赢得高分。对医生或其他专业人士来说也都是如此。事实上,对大多数人来说,还在学生时代时,我们都会依照教授的说法,“虔诚地”阅读教科书。(当然,并不是所有的教授都会把跟他唱反调的学生判为不及格!)\n这种阅读的特质,我们或许可以用“正统”两个字来概括。这两个字几乎是放诸四海皆准的,在英文中,“正统”(orthodox)原始的字根来自希腊文,意思是“正确观点”。这类作品是一本或惟一的一本正确的读物,阅读任何其他的作品都会带来危机,从考试失去高分到灵魂遭天谴都有可能。这样的特质是有义务性的。一个忠诚的读者在阅读经书时,有义务要从中找到意义,并能从其他的“事实”中举证其真实性。如果他自己不能这么做,他就有义务去找能做到的人。这个人可能是牧师或祭司,或是党派中的上级指导者,或是他的教授。在任何状况中,他都必须接受对方提供给他的解决之道。他的阅读基本上是没有自由可言的。相对地,他也会获得阅读其他书所没有的一种满足感当作回报。\n其实我们该停止了。阅读《圣经》的问题—如果你相信那是神的话语—是阅读领域中最困难的一个问题。有关如何阅读《圣经》的书,加起来比所有其他指导阅读的书的总和还多。所谓上帝的话语,是人类所能阅读的作品中最困难的一种,而如果你真的相信那是上帝的话语,对你来说也是最重要的一种。信徒阅读这本书要付出的努力和难度成正比。至少在欧洲的传统中,《圣经》是一本有多重意义的书。在所有的书籍中,那不只是读者最广泛,同时也是被最仔细地阅读的一本书。\n第十九章如何阅读社会科学 # 社会科学的观念与术语几乎渗透了所有我们今天在阅读的作品中。\n譬如像现代的新闻记者,不再限定自己只报导事实。只有在报纸头版出现,简短的“谁—发生了什么事—为什么发生—何时何地发生”新闻提要,才是以事实为主。一般来说,记者都会将事实加上诠释、评论、分析,再成为新闻报导。这些诠释与评论都是来自社会科学的观念与术语。\n这些观念与术语也影响到当代许多书籍与文章,甚至可以用社会评论来作一个归类。我们也看到许多文学作品是以这类的主题来写作的:种族问题、犯罪、执法、贫穷、教育、福利、战争与和平、好政府与坏政府。这类文学作品便是向社会科学借用了思想意识与语言。\n社会科学作品并不只限定于非小说类。仍然有一大批重要的当代作家所写的是社会科学的小说。他们的目标是创立一个人造的社会模型,能够让我们在科技的发展之下,检验出社会受到的影响。在小说、戏剧、故事、电影、电视中,对社会的权力组织、各种财富与所有权、财富的分配都作了淋漓尽致的描绘、谴责与赞扬。这些作品被认为有社会意义,或是包含了“重要的讯息”。在这同时,他们取得也散播了社会科学的元素。\n此外,无论是任何社会、经济或政治的问题,几乎全都有专家在作研究。这些专家不是自己作研究,就是由直接面对这些问题的官方单位邀请来做。在社会科学专家的协助下,这些问题有系统地阐释出来,并要想办法解决这些问题。\n社会科学的成长与普及,最重要的因素是在高中与大专教育中引进了社会科学。事实上,选修社会科学课程的学生,远比选修传统文学或语言课程的学生还要多很多。而选修社会科学的学生也远超过选修“纯”科学的学生。\n1.什么是社会科学? # 我们在谈论社会科学时,好像是在谈一个完全独立的学科。事实上并非如此。\n究竟社会科学是什么呢?有一个方法可以找出答案,就是去看看大学中将哪些学科与训练课程安排在这样的科系之下。社会科学的部门中通常包括了人类学、经济学、政治学与社会学。为什么没有包括法律、教育、商业、社会服务与公共行政呢?所有这些学科也都是运用社会科学的概念与方法才发展出来的啊?对于这个问题,最常见的回答是:后面这些学科的目的,在于训练大学校园以外的专业工作者,而前面所提的那些学科却是比较专注于追求人类社会的系统知识,通常是在大学校园中进行的。\n目前各个大学都有建立跨科系的研究中心或机构的趋势。这些研究中心超越传统社会科学与专业科系的界限,同时针对许多理论与方法的研究,其中包括了统计学、人口学、选举学(关于选举与投票的科学)、政策与决策制定、人事训练管理、公共行政、人类生态学,以及其他等等。这些中心产生的研究与报告,往往结合了十多种以上的专业。光是要辨认这许多种专业努力的结果就已经够复杂了,更别提还要判断这些发现与结论是否成立。\n那么心理学呢?一些划分严格的社会科学家会将心理学排除在社会科学之外,因为他们认为心理学所谈的是个人的特质问题,而社会科学关心的却是文化、制度与环境因素。一些区分比较没那么严格的学者,则认为生理心理学应该归类为生物科学,而不论是正常或变态心理学则该隶属于社会科学,因为个人与社会整体是不可分割的。\n附带一提的是,在现在的社会科学课程中,心理学是最受学生欢迎的一门课。如果全国统计起来,选修心理学的学生可能比任何其他课系的学生都要多。有关心理学的著作,从最专业到最普遍的都出版了许多。\n那么行为科学呢?他们在社会科学中担任什么样的角色?依照原始的用法,行为科学中包括了社会学、人类学、行为生物学、经济学、地理学、法律、心理学、精神病学与政治科学。行为科学特别强调对可观察、可测量的行为作系统化的研究,以获得可被证实的发现。近年来,行为科学几乎跟社会科学变成同义词了,但许多讲究传统的人反对这样的用法。\n最后要谈的是,历史呢?大家都知道,社会科学引用历史的研究,是为了取得资料,并为他们的推论作例证。然而,虽然历史在叙述特殊事件与人物时,在知识的架构上勉强称得上科学,但是就历史本身对人类行为与发展模式及规则所提供的系统知识而言,却称不上科学。\n那么,我们能给社会科学下个定义吗?我们认为可以,至少就这一章的目的来说可以。诸如人类学、经济学、政治学、社会学的学科,都是组成社会科学的核心,几乎所有的社会科学家都会将这些学科归纳进来。此外,我们相信大部分社会科学家应该会认为,即使不是全部,但大部分有关法律、教育、公共行政的作品,及一部分商业、社会服务的作品,再加上大量的心理学作品,也都适合社会科学的定义。我们推测这样的定义虽然并不精密,但你可以明白接下来我们要说的了。\n2.阅读社会科学的容易处 # 绝大部分社会科学看起来都像是非常容易阅读的作品。这些作品的内容通常取材自读者所熟悉的经验—在这方面,社会科学就跟诗与哲学一样—论说的方式也经常是叙述式的,这对读过小说与历史的读者来说都很熟悉。\n此外,我们都已经很熟悉社会科学的术语,而且经常在使用。诸如文化(比较文化、反文化、次文化)、集团、疏离、地位、输入/输出、下层结构、伦理、行为、共识等很多这样的术语,几乎是现代人交谈与阅读时经常会出现的字眼。\n想想“社会”,这是一个多么变色龙的词,前面不知可以加上多少形容词,但它总是在表达一种人民群居生活,而非离群索居的广阔定义。我们听到过失序的社会、不健全的社会、沉默的社会、贪婪的社会、富裕的社会……,我们可以从英文字典中第一个字母找起,最后找到“发酵的”(zymotic)社会这样的形容词—这是指持续动荡的社会,就跟我们所处的社会一样。\n我们还可以把“社会”看作是形容词,同样有许多熟悉的意义。像社会力量、社会压力、社会承诺,当然还有无所不在的社会问题。在阅读或写作社会科学时,最后一种是特别容易出现的题材。我们敢打赌,如果不是在最近几周,也是在最近的几个月内,你总可能读过,甚至写过有关“政治、经济与社会问题”的文章。当你阅读或写作时,你可能很清楚政治与经济问题所代表的意义,但是你,或是作者所说的社会问题,到底指的是什么呢?\n社会学家在写作时所用的术语及隐喻,加上字里行间充满深刻的情感,让我们误以为这是很容易阅读的。书中所引用的资料对读者来说是很熟悉的,的确,那是他们天天读到或听到的字眼。此外,读者的态度与感觉也都跟着这些问题的发展紧密联系在一起。哲学问题所谈论的也是我们一般知道的事情,但是通常我们不会“投人”哲学问题中。不过对于社会科学所讨论的问题,我们都会有很强烈的意见。\n3.阅读社会科学的困难处 # 说来矛盾,我们前面所说的让社会科学看来很容易阅读的因素,却也是让社会科学不容易阅读的因素。譬如我们前面所提到的最后一个因素—你身为一个读者,要对作者的观点投人一些看法。许多读者担心,如果承认自己与作者意见不合,而且客观地质疑自己阅读的作品,是一种对自己投人不忠的行为。但是,只要你是用分析阅读来阅读,这样的态度是必要的。我们所谈的阅读规则中已经指出了这样的态度,至少在做大纲架构及诠释作品的规则中指出过。如果你要回答阅读任何作品都该提出的头两个问题,你一定要先检查一下你自己的意见是什么。如果你拒绝倾听一位作者所说的话,你就无法了解这本书了。\n社会科学中熟悉的术语及观点,同时也造成了理解上的障碍。许多社会科学家自己很清楚这个问题。他们非常反对在一般新闻报导或其他类型的写作中,任意引用社会科学的术语及观点。譬如国民生产总值(GNP Gross National Product)这个概念,在严肃的经济作品中,这个概念有特定限制的用法。但是,一些社会科学家说,许多记者及专栏作者让这个概念承担了太多的责任。他们用得太浮滥,却完全不知道真正的意义是什么。显然,如果在你阅读的作品中,作者将一个自己都不太清楚的词句当作是关键字,那你一定也会跟着摸不着头脑的。\n让我们把这个观点再说明清楚一点。我们要先把社会科学与自然科学—物理、化学等—区分出来。我们已经知道,科学作品(指的是后面那种“科学”)的作者会把假设与证明说得十分清楚,同时也确定读者很容易与他达成共识,并找到书中的主旨。因为在阅读任何论说性作品时,与作者达成共识并找到主旨是最重要的一部分,科学家的作法等于是帮你做了这部分的工作。不过你还是会发现用数学形式表现的作品很难阅读,如果你没法牢牢掌握住论述、实验,以及对结论的观察基础,你会发现很难对这本书下评论—也就是回答“这是真实的吗?”“这本书与我何干?”的问题。然而,有一点很重要的是,阅读科学作品要比阅读任何其他论说性作品都来得容易。\n换句话说,自然科学的作者必须做的是“把他的用语规定出来”—这也就是说,他告诉你,在他的论述中有哪些基本的词义,而他会如何运用。这样的说明通常会出现在书的一开头,可能是解释、假设、公理等等。既然说明用语是这个领域中的特质,因此有人说它们像是一种游戏,或是有“游戏的架构”。说明用语就像是一种游戏规则。如果你想打扑克牌,你不会争论三张相同的牌,是否比两对的牌要厉害之类的游戏规则。如果你要玩桥牌,你也不会为皇后可以吃杰克(同一种花色),或是最高的王牌可以吃任何一张牌(在定约桥牌中)这样的规则而与人争辩。同样地,在阅读自然科学的作品时,你也不会与作者争辩他的使用规则。你接受这些规则,开始阅读。\n直到最近,在自然科学中已经很普遍的用语说明,在社会科学中却仍然不太普遍。其中一个理由是,社会科学并不能数学化,另一个理由是在社会或行为科学中,要说明用语比较困难。为一个圆或等腰三角形下定义是一回事,而为经济萧条或心理健康下定义又是另一回事。就算一个社会科学家想要为这样的词义下定义,他的读者也会想质疑他的用法是否正确。结果,社会科学家只好在整本书中为自己的词义挣扎不已—他的挣扎也带给读者阅读上的困难。\n阅读社会科学作品最困难的地方在于:事实上,在这个领域中的作品是混杂的,而不是纯粹的论说性作品。我们已经知道历史是如何混杂了虚构与科学,以及我们阅读时要如何把这件事谨记在心。对于这种混杂,我们已经很熟悉,也有大量的相关经验。但在社会科学的状况却完全不同。太多社会科学的作品混杂了科学、哲学与历史,甚至为了加强效果,通常还会带点虚构的色彩。\n如果社会科学只有一种混杂法,我们也会很熟悉,因为历史就是如此。但是实际上并非如此。在社会科学中,每一本书的混杂方式都不同,读者在阅读时必须先确定他在阅读的书中混杂了哪些因素。这些因素可能在同一本书中就有所变动,也可能在不同的书中有所变动。要区分清楚这一切,并不容易。\n你还记得分析阅读的第一个步骤是回答这个问题:这是本什么样的书?如果是小说,这个问题相当容易回答。如果是科学或哲学作品,也不难。就算是形式混杂的历史,一般来说读者也会知道自己在读的是历史。但是组成社会科学的不同要素—有时是这种,有时是那种,有时又是另一种模式—使我们在阅读任何有关社会科学的作品时,很难回答这个问题。事实上,这就跟要给社会科学下定义是同样困难的事。\n不过,分析阅读的读者还是得想办法回答这个问题。这不只是他要做的第一件工作,也是最重要的工作。如果他能够说出他所阅读的这本书是由哪些要素组成的,他就能更进一步理解这本书了。\n要将一本社会科学的书列出纲要架构不是什么大问题,但是要与作者达成共识,就像我们所说的,这可是极为困难的事。原因就在于作者无法将自己的用语规则说明清楚。不过,还是可以对关键字有些概括性的了解。从词义看到主旨与论述,如果是本好书,这些仍然都不是问题。但是最后一个问题:这与我何干?就需要读者有点自制力了。这时,我们前面提过的一种情况就可能发生—读者可能会说:“我找不出作者的缺点,但是我就是不同意他的看法。”当然,这是因为读者对作者的企图与结论已经有偏见了。\n4.阅读社会科学作品 # 在这一章里,我们说过很多次“社会科学作品”,却没说过“社会科学书”。这是因为在阅读社会科学时,关于一个主题通常要读好几本书,而不会只读一本书。这不只是因为社会科学是个新领域,只有少数经典作品,还因为我们在阅读社会科学时,主要的着眼点在一个特殊的事件或问题上,而非一个特殊的作者或一本书。譬如我们对强制执行法感兴趣,我们会同时读上好几本相关的书。或许我们关心的是种族、教育、税收与地方政府的问题,这也是同样的状况。基本上,在这些领域中,并没有什么权威的著作,因此我们必须读很多本相关的书。而社会科学家本身也有一个现象,就是为了要能跟得上时代,他们必须不断地推陈出新,重新修订他们的作品,新作品取代旧作品,过时的论述也不断被淘汰了。\n在某个程度上说,如我们所看到的,哲学也会发生同样的状况。要完全了解一位哲学家,你应该阅读这位哲学家自己在阅读的书,以及影响他的其他哲学家的书。在某种程度上,历史也是如此。我们提到过,如果你想要发现过去的事实,你最好多读几本书,而不是只读一本书。不过在这些情况中,你找到一本主要的、权威的著作的可能,是相当大的。社会科学中却并非如此,因此在阅读这类书时更需要同时阅读许多相关书籍了。\n分析阅读的规则并不适用于就一个主题同时阅读很多本书的情况。分析阅读适用于阅读个别的书籍。当然,如果你想要善用这些规则,就要仔细地研究观察。接下来要介绍的新的阅读规则,则需要我们通过第三层次的阅读(分析阅读),才能进人这第四层次的阅读(主题阅读)。我们现在就准备要讨论第四层次的阅读。因为社会科学作品有这样的特质,所以必须要用这样的阅读。\n指出这一点,就可以说明为什么我们会把社会科学的问题放在本书第三篇的最后来讨论。现在你应该明白为什么我们会这样整理我们的讨论。一开始我们谈的是如何阅读实用性作品,这与其他的阅读完全不同,因为读者有特定的义务,也就是如果他同意作者的观点,就要采取行动。然后我们讨论小说与诗,提出和阅读论说性作品不同的问题。最后,我们讨论的是三种理论性的论说作品—科学与数学、哲学、社会科学。社会科学放在最后,是因为这样的书需要用上主题阅读。因此这一章可说是第三篇的结尾,也是第四篇的引言。\n"},{"id":135,"href":"/zh/docs/culture/%E5%A6%82%E4%BD%95%E9%98%85%E8%AF%BB%E4%B8%80%E6%9C%AC%E4%B9%A6/%E7%AC%AC%E4%BA%8C%E7%AF%87_%E9%98%85%E8%AF%BB%E7%9A%84%E7%AC%AC%E4%B8%89%E4%B8%AA%E5%B1%82%E6%AC%A1_%E5%88%86%E6%9E%90%E9%98%85%E8%AF%BB/","title":"第二篇 阅读的第三个层次:分析阅读","section":"如何阅读一本书","content":"第二篇 阅读的第三个层次:分析阅读\n第六章 一本书的分类 # 在本书的一开头,我们就已经说过了,这些阅读的规则适用于任何你必须读或想要读的读物。然而,在说明分析阅读,也就是这第二篇的内容中,我们却似乎要忽略这个原则。我们所谈的阅读,就算不全是,也经常只是指“书”而言。为什么呢?\n答案很简单。阅读一整本书,特别是又长又难读的一本书,要面对的是一般读者很难想像,极为艰困的问题。阅读一篇短篇故事,总比读一本小说来得容易。阅读一篇文章,总比读一整本同一个主题的书籍来得轻松。但是如果你能读一本史诗或小说,你就能读一篇抒情诗或短篇故事。如果你能读一本理论的书—一本历史、哲学论述或科学理论—你就可以读同一个领域中的一篇文章或摘要。\n因此,我们现在要说的阅读技巧,也可以应用在其他类型的读物上。你要了解的是,当我们提到读书的时候,所说明的阅读规则也同样适用于其他比较易于阅读的资料。虽然这些规则程度不尽相当,应用在后者身上时,有时候作用不尽相同,但是只要你拥有这些技巧,懂得应用,总可以比较轻松。\n1.书籍分类的重要性 # 分析阅读的第一个规则可以这么说:规则一,你一定要知道自己在读的是哪一类书,而且要越早知道越好。最好早在你开始阅读之前就先知道。\n譬如,你一定要知道,在读的到底是虚构的作品—小说、戏剧、史诗、抒情诗—还是某种论说性的书籍?几乎每个读者在看到一本虚构的小说时都会认出来,所以就会认为要分辨这些并不困难—其实不然。像《波特诺的牢骚》(Portnoy\u0026rsquo;s、Complaint),是小说还是心理分析的论著?《裸体午宴)(Naked Lunch)是小说,还是反对药物泛滥的劝导手册,像那些描述酒精的可怕,以帮助读者戒酒之类的书?《飘》(Gone With The Wind)是爱情小说,还是美国内战时期的南方历史?《大街》(Main Street)与《愤怒的葡萄))(The Grapes o f Wrath),一本都会经验,一本农村生活,到底是纯文学,还是社会学的论著?\n当然,这些书都是小说,在畅销书排行榜上,都是排在小说类的。但是问这些问题并不荒谬。光是凭书名,像《大街》或《米德尔顿》,很难猜出其中写的是小说,还是社会科学论述。在当代的许多小说中,有太多社会科学的观点,而社会科学的论著中也有很多小说的影子,实在很难将二者区别开来。但是还有另一些科学—譬如物理及化学—出现在像是科幻小说《安珠玛特病毒》(The Andromeda Strain),或是罗伯特·海莱因(Robert Heinlein)、亚瑟·克拉克(Arthur C. Clarke)的书中。而像《宇宙与爱因斯坦博士》(The Universe and Dr. Einstein)这本书,明明不是小说,却几乎跟有“可读性”的小说一模一样。或许就像福克纳(William Faulkner)所说的,这样的书比其他的小说还更有可读性。\n一本论说性的书的主要目的是在传达知识。“知识”在这样的书中被广泛地解说着。任何一本书,如果主要的内容是由一些观点、理论、假设、推断所组成,并且作者多少表示了这些主张是有根据的,有道理的,那这种传达知识的书,就是一本论说性(expository)的书。就跟小说一样,大多数人看到论说性的书也一眼就能辨识出来。然而,就像要分辨小说与非小说很困难一样,要区别出如此多样化的论说性书籍也并非易事。我们要知道的不只是哪一类的书带给我们指导,还要知道是用什么方法指导。历史类的书与哲学类的书,所提供的知识与启发方式就截然不同。在物理学或伦理学上,处理同一个问题的方法可能也不尽相同。更别提各个不同作者在处理这么多不同问题时所应用的各种不同方法了。\n因此,分析阅读的第一个规则,虽然适用于所有的书籍,却特别适合用来阅读非小说,论说性的书。你要如何运用这个规则呢?尤其是这个规则的最后那句话?\n之前我们已经建议过,一开始时,你要先检视这本书—用检视阅读先浏览一遍。你读读书名、副标题、目录,然后最少要看看作者的序言、摘要介绍及索引。如果这本书有书衣,要看看出版者的宣传文案。这些都是作者在向你传递讯号,让你知道风朝哪个方向吹。如果你不肯停、看、听,那也不是他的错。\n2.从一本书的书名中你能学到什么 # 对于作者所提出的讯号视而不见的读者,比你想像中还要多得多。我们跟学生在一起,就已经一再感觉如此了。我们问他们这本书在说些什么?我们要他们用最简单的通常用语,告诉我们这本书是哪一类的书。这是很好的,也是要开始讨论一本书几乎必要的方式。但是,我们的问题,却总是很难得到任何答案。\n我们举一两个这种让人困扰的例子吧!1859年,达尔文(CharlesDarwin)出版了一本很有名的书。一个世纪之后,所有的英语国家都在庆贺这本书的诞生。这本书引起无止境的争论,不论是从中学到一点东西,还是没学到多少东西的评论者,一致肯定其影响力。这本书谈论的是人类的进化,书名中有个“种\u0026quot;(species)字。到底这个书名在说些什么?\n或许你会说那是《物种起源))(The Origin of Species),这样说你就对了。但是你也可能不会这样说,你可能会说那是《人种起源》(TheOrigin of the Species).最近我们问了一些年纪在25岁左右,受过良好教育的年轻人,到底达尔文写的是哪一本书,结果有一半以上的人说是《人种起源》。出这样的错是很明显的,他们可能从来没有读过那本书,只是猜想那是一本谈论人类种族起源的书。事实上,这本书跟这个主题只有一点点关联,甚至与此毫无关系。达尔文是在后来才又写了一本与此有关的书《人类始祖》(The Descent of Man)。《物种起源》,就像书名所说的一样,书中谈的是自然世界中,大量的植物、动物一开始是从少量的族群繁衍出来的,因此他声明了“物竞天择”的原理。我们会指出这个普遍的错误,是因为许多人以为他们知道这本书的书名,而事实上只有少之又少的人真的用心读过书名,也想过其中的含意。\n再举一个例子。在这个例子中,我们不要你记住书名,但去想想其中的含意。吉朋写了一本很有名的书,而且还出名地长,是一本有关罗马帝国的书,他称这本书为《罗马帝国衰亡史》。几乎每个人拿到那本书都会认得这个书名,还有很多人即使没看到书,也知道这个书名。事实上,“衰亡”已经变成一个家喻户晓的用语了。虽然如此,当我们问到同样一批二十五岁左右,受过良好教育的年轻人,为什么第一章要叫做:《安东尼时代的帝国版图与武力》时,他们却毫无头绪。他们并没有看出整本书的书名既然叫作“衰亡史”,叙事者当然就应该从罗马帝国极盛时期开始写,一直到帝国衰亡为止。他们无意识地将“衰亡”两个字转换成“兴亡”了。他们很困惑于书中并没有提到罗马共和国,那个在安东尼之前一个半世纪就结束的时代。如果他们将标题看清楚一点,就算以前不知道,他们也可以推断安东尼时代就是罗马帝国的巅峰时期。阅读书名,换句话说,可以让阅读者在开始阅读之前,获得一些基本的资讯。但是他们不这么做,甚至更多人连不太熟悉的书也不肯看一下书名。\n许多人会忽略书名或序言的原因之一是,他们认为要将手边阅读的这本书做分类是毫无必要的。他们并没有跟着分析阅读的第一个规则走。如果他们试着跟随这个规则,那就会很感激作者的帮忙。显然,作者认为,让读者知道他在写的是哪一类的书是很重要的。这也是为什么他会花那么多精神,不怕麻烦地在前言中做说明,通常也试着想要让他的书名—至少副标题—是让人能理解的。因此,爱因斯坦与英费尔德(Infeld)在他们所写的《物理之演进(The Evolution o fPhysics)一书的前言中告诉读者,他们写的是一本“科学的书,虽然很受欢迎,但却不能用读小说的方法来读”。他们还列出内容的分析表,提醒读者进一步了解他们概念中的细节。总之,列在一本书前面那些章节的标题,可以进一步放大书名的意义。\n如果读者忽略了这一切,却答不出“这是一本什么样的书”的问题,那他只该责怪自己了。事实上,他只会变得越来越困惑。如果他不能回答这个问题,如果他从没问过自己这个问题,他根本就不可能回答随之而来的,关于这本书的其他问题。\n阅读书名很重要,但还不够。除非你能在心中有一个分类的标准,否则世上再清楚的书名,再详尽的目录、前言,对你也没什么帮助。\n如果你不知道心理学与几何学都是科学,或者,如果你不知道这两本书书名上的“原理”与“原则”是大致相同的意思(虽然一般而言不尽相同),你就不知道欧几里得(Euclid)的《几何原理》(Elements of Geometry)与威廉·詹姆斯(William James)的《心理学原理》(Principlesof Psychology)是属于同一种类的书—此外,除非你知道这两本书是不同类型的科学,否则就也无法进一步区分其间的差异性。相同的,以亚里士多德的《政治学))(The Politics)与亚当·斯密的《国富论》为例,除非你了解一个现实的问题是什么,以及到底有多少不同的现实问题,否则你就无法说出这两本书相似与相异之处。\n书名有时会让书籍的分类变得比较容易一些。任何人都会知道欧几里得的《几何原理》、笛卡尔的《几何学》(Geometry)与希尔伯特(HilBert)的《几何基础)(Foundations of Geometry)都是数学的书,彼此多少和同一个主题相关。但这不是百试百中。光是从书名,也可能并不容易看出奥古斯丁的《上帝之城》(The City of God)、霍布斯的《利维坦》(Leviathan)与卢梭的《社会契约论》(Social Contract)都是政治的论述—虽然,如果仔细地阅读这三本书的章名,会发现它们都想探讨的一些共同问题。\n再强调一次,光是将书籍分类到某一个种类中还是不够的。要跟随第一个阅读步骤,你一定要知道这个种类的书到底是在谈些什么?书名不会告诉你,前言等等也不会说明,有时甚至整本书都说不清楚,只有当你自己心中有一个分类的标准,你才能做明智的判断。换句话说,如果你想简单明白地运用这个规则,那就必须先使这个规则更简单明白一些。只有当你在不同的书籍之间能找出区别,并且定出一些合理又经得起时间考验的分类时,这个规则才会更简单明白一些。\n我们已经粗略地谈过书籍的分类了。我们说过,主要的分类法,一种是虚构的小说类,另一种是传达知识,说明性的论说类。在论说性的书籍中,我们可以更进一步将历史从哲学中分类出来,也可以将这二者从科学与数学中区分出来。\n到目前为止,我们都说得很清楚。这是一个相当清楚的书籍分类法,大多数人只要想一想这个分类法,就能把大多数书都做出适当的分类了。但是,并不是所有的书都可以。\n问题在于我们还没有一个分类的原则。在接下来更高层次的阅读中,我们会谈更多有关分类的原则。现在,我们要确定的是一个基本的分类原则,这个原则适用于所有的论说性作品。这也就是用来区分理论性与实用性作品的原则。\n3.实用性VS.理论性作品 # 所有的人都会使用“实用”跟“理论”这两个字眼,但并不是每个人都说得出到底是什么意思—像那种既现实又坚决的人,当然就更如此,他们最不信任的就是理论家,特别是政府里的理论家。对这样的人来说,“理论”意味着空想或不可思议,而“实用”代表着某种有效的东西,可以立即换成金钱回来。这里面确实有一些道理。实用是与某种有效的做法有关,不管是立即或长程的功效。而理论所关注的却是去明白或了解某件事。如果我们仔细想想这里所提出来的粗略的道理,就会明白知识与行动之间的区别,正是作者心目中可能有的两种不同的概念。\n但是,你可能会问,我们在看论说性的作品时,不就是在接受知识的传递吗?这样怎么会有行动可言?答案是,当然有,明智的行动就是来自知识。知识可以用在许多方面,不只是控制自然,发明有用的机器或工具,还可以指导人类的行为,在多种技术领域中校正人类的运作技巧。这里我们要举的例子是纯科学与应用科学的区别,或是像通常非常粗糙的那种说法,也就是科学与科技之间的区别。\n有些书或有些老师,只对他们要传达的知识本身感兴趣。这并不是说他们否定知识的实用性,或是他们坚持只该为知识而知识。他们只是将自己限制在某一种沟通或教学方式中,而让其他人去用别的方式。其他这些人的兴趣则在追求知识本身以外的事上,他们关切的是哪些知识能帮忙解决的人生问题。他们也传递知识,但永远带着一种强调知识的实际应用的观点。\n要让知识变成实用,就要有操作的规则。我们一定要超越“知道这是怎么回事”,进而明白“如果我们想做些什么,应该怎么利用它”。概括来说,这也就是知与行的区别。理论性的作品是在教你这是什么,实用性的作品在教你如何去做你想要做的事,或你认为应该做的事。\n本书是实用的书,而不是理论的书。任何一本指南类的书都是实用的。任何一本书告诉你要该做什么,或如何去做,都是实用的书。因此,你可以看出来,所有说明某种艺术的学习技巧,任何一个领域的实用手册,像是工程、医药或烹饪,或所有便于分类为“教导性”(moral)的深奥论述,如经济、伦理或政治问题的书,都是实用的书。我们在后面会说明为什么这类书,一般称作“规范性”(normative)的书,会在实用类的书中作一个很特别的归类。\n或许没有人会质疑我们将艺术的学习技巧,或实用手册、规则之类的书归类为论说性的书籍。但是我们前面提过的那种现实型的人,可能会反对我们将伦理,或经济类的书也归类为实用的书。他会说那样的书并不实用,因为书中所说的并没有道理,或者行不通。\n事实上,就算一本经济的书没有道理,是本坏书,也不影响这一点。严格来说,任何一本教我们如何生活,该做什么,不该做什么,同时说明做了会有什么奖赏,不做会有什么惩罚的伦理的书,不论我们是否同意他的结论,都得认定这是一本实用的书。(有些现代的社会学研究只提供人类的行为观察,而不加以批判,既非伦理也无关实用,那就是理论型的书—科学作品。)\n在经济学中也有同样的状况。经济行为的研究报告,数据分析研究,这类工作是理论性的,而非实用的。除此之外,一些通常教导我们如何认知经济生活环境(个别的或社会整体的),教导我们该做不该做的事,如果不做会有什么惩罚等,则是实用的书。再强调一次,我们可能不同意作者的说法,但是我们的不同意,并不能将这类书改变为非实用的书。\n康德写了两本有名的哲学著作,一本是《纯粹理性批判》(The Critique of Pure Reason),另一本是《实践理性批判》(The Critique ofPractical Reason)。第一本是关于知,我们何以知(不是指如何知,而是我们为何就是知),以及什么是我们能知与不能知的事。这是一本精彩绝伦的理论性书籍。《实践理性批判》则是关于一个人应该如何自我管理,而哪些是对的、有道德的品行。这本书特别强调责任是所有正确行为的基础,而他所强调的正是现代许多读者所唾弃的想法。他们甚至会说,如果相信责任在今天仍然是有用的道德观念,那是“不实际的”想法。当然,他们的意思是,从他们看来,康德的基本企图就是错误的。但是从我们的定义来看,这并不有损于这是一本实用的书。\n除了实用手册与(广义的)道德论述之外,另一种实用型的作品也要提一下。任何一种演说,不论是政治演说或道德规劝,都是想告诉你该做些什么,或你该对什么事有什么样的反应。任何人就任何一个题目写得十分实用的时候,都不只是想要给你一些建议,而且还想说服你跟随他的建议。因此在每一种道德论述的文字中,都包含了雄辩或规劝的成分。这样的状况也出现在教导某种艺术的书本中,如本书便是。因此,除了想要教你如何读得更好之外,我们试着,也将一直继续尝试说服你作这样的努力。\n虽然实用的书都是滔滔雄辩又忠告勉励,但是滔滔雄辩又忠告勉励的书却不见得都实用。政治演说与政治论文大有不同,而经济宣传文告与经济问题的分析也大有出人。《共产党宣言》(The Communist Manifesto)是一篇滔滔雄辩,但马克思的《资本论》(Capital)却远不止于此。\n有时你可以从书名中看出一本书是不是实用的。如果标题有“……的技巧”或“如何……”之类的字眼,你就可以马上归类。如果书名的领域你知道是实用的,像是伦理或政治,工程或商业,还有一些经济、法律、医学的书,你都可以相当容易地归类。\n书名有时能告诉你的资讯还不止于此。洛克(John Locke)写了两本书名很相近的书:《论人类悟性》(An Essay Concerning Human Understanding) 及《论文明政府的起源、扩张与终点》(A Treatise Concerning the Origin, Extent,and End of Civil Government),哪一本是理论的,哪一本又是实用的书呢?\n从书名,我们可以推论说第一本是理论的书,因为任何分析讨论的书都是理论的书,第二本则是实用的书,因为政府的问题就是他们的实际问题。但是运用我们所建议的检视阅读,一个人可以超越书名来作判断。洛克为《论人类悟性》写了一篇前言介绍,说明他企图探索的是“人类知识的起源、真理与极限”,和另一本书的前言很相似,却有一个重要的不同点。在第一本书中,洛克关心的是知识的确实性或有效性,另一本书所关心的却是政府的终点或目的。质疑某件事的有效性是理论,而质疑任何事的目的,却是实用。\n在说明检视阅读的艺术时,我们提醒过你在读完前言或索引之后,不要停下来,要看看书中的重点摘要部分。此外也要看看这本书的开头跟结尾,以及主要的内容。\n有时候,从书名或前言等还是无法分辨出一本书的类型时,就很必要从一本书的主要内容来观察。这时候,你得倚赖在主体内文中所能发现的蛛丝马迹。只要注意内容的文字,同时将分类的基本条件放在心中,你不必读太多就应该能区分出这是哪一类的书了。\n一本实用的书会很快就显露它的特质,因为它经常会出现“应该”和“应当”、“好”和“坏”、“结果”和“意义”之类的字眼。实用书所用到的典型陈述,是某件事应该做完(或做到);这样做(或制造)某个东西是对的;这样做会比那样做的结果好;这样选择要比那样好,等等。相反的,理论型的作品却常常说“是”,没有“应该”或“应当”之类的字眼。那是在表示某件事是真实的,这些就是事实,不会说怎样换一个样子更好,或者按照这个方法会让事情变得更好等等。\n在谈论有关理论性书籍的话题之前,让我们先提醒你,那些问题并不像你分辨该喝咖啡或牛奶那样简单。我们只不过提供了一些线索,让你能开始分辨。等你对理论与实用的书之区别懂得越多,你就越能运用这些线索了。\n首先,你要学习去怀疑一切。在书籍分类上,你要有怀疑心。我们强调过经济学的书基本上通常是实用性的书,但仍然有些经济学的书是纯理论的。同样的,虽然谈理解力的书基本上通常是理论性的书,仍然有些书(大部分都很恐怖)却要教你“如何思想”。你也会发现很多作者分不清理论与实用的区别,就像一个小说家搞不清楚什么是虚构故事,什么是社会学。你也会发现一本书有一部分是这一类,另一部分却是别一类,斯宾诺莎的《伦理学》(Ethics)就是这样。然而,这些都在提醒你身为一个读者的优势,透过这个优势,你可以发现作者是如何面对他要处理的问题。\n4.理论性作品的分类 # 照传统的分法,理论性的作品会被分类为历史、科学和哲学等等。所有的人都约略知道其间的差异性。但是,如果你要作更仔细的划分与更精确的区隔时,困难就来了。此刻,我们先避过这样的危险,作一个大略的说明吧。\n以历史书来说,秘诀就在书名。如果书名中没有出现“历史”两个字,其他的前言等等也会告诉我们这本书所缺的东西是发生在过去—不一定是远古时代,当然,也很可能是发生在昨天的事。历史的本质就是口述的故事,历史是某个特殊事件的知识,不只存在于过去,而且还历经时代的不同有一连串的演变。历史家在描述历史时,通常会带有个人色彩—个人的评论、观察或意见。\n历史就是纪事(Chronotopic)。在希腊文中,chronos的意思是时间,topos的意思是地点。历史就是在处理一些发生在特定时间,特定地点的真实事件。“纪事”这两个字就是要提醒你这一点。\n科学则不会太在意过去的事,它所面对的是可能发生在任何时间、地点的事。科学家寻求的是定律或通则。他要知道在所有的情况或大多的情况中,事情是如何发生的,而不像历史学家要知道为什么某个特定的事件,会发生在过去某个特定的时间与地点。\n科学类的书名所透露的讯息,通常比历史类的书要少。有时会出现“科学”两个字,但大部分出现的是心理学、几何学或物理学之类的字眼。我们必须要知道这本书所谈论的主题是哪一类的,像几何学当然就是科学,而形上学就是哲学的。问题在很多内容并不是一清二楚的,在很多时候,许多科学家与哲学家都将物理学与心理学纳入自己研究的范围。碰到“哲学”与“科学”这两个词时,麻烦就会出现了,因为他们已经被运用得太广泛了。亚里士多德称自己的作品《物理学》(Physics)是科学论述,但如果以目前的用法,我们该归类为哲学类。牛顿将自己伟大的作品定名为《自然哲学的数学原理》(Mathematical Princ-ples of Natural Philosophy),而我们却认为是科学上的伟大著作。\n哲学比较像科学,不像历史,追求的是一般的真理,而非发生在过去的特定事件,不管那个过去是近代或较远的年代。但是哲学家所提出的问题跟科学家又不一样,解决问题的方法也不相同。\n既然书名或前言之类的东西并不能帮助我们确定一本书是哲学或科学的书,那我们该怎么办?有一个判断依据我们认为永远有效,不过你可能要把一本书的内容读了相当多之后才能应用。如果一本理论的书所强调的内容,超乎你日常、例行、正常生活的经验,那就是科学的书。否则就是一本哲学的书。\n这样的区别可能会让你很惊讶。让我们说明一下。(记住,这只适用于科学或哲学的书,而不适用于其他类型的书。)伽利略的《两种新科学》(Two New Sciences)要你发挥想像力,或在实验室中以斜面重复某种实验。牛顿的《光学》(Opticks)则提到以棱镜、镜面与特殊控制的光线,在暗室中做实验。这些作者所提到的特殊经验,可能并不是他们自己真的在实验室中完成的。达尔文所写的《物种起源》是他自己经过多年实地观察才得到的报告。虽然这些事实可以,也已经由其他的观察家在作过同样的努力之后所证实,但却不是一般人在日常生活中所能查证的。\n相对的,哲学家所提出来的事实或观察,不会超越一般人的生活经验。一个哲学家对读者所提及的事,都是自己正常及普通的经验,以证明或支持他所说的话。因此,洛克的《论人类悟性》是心理学中的哲学作品。而弗洛伊德的作品却是科学的。洛克所讨论的重点都来自我们生活中所体验的心路历程,而弗洛伊德提出的却是报告他在精神分析诊所中所观察到的临床经验。\n另一个伟大的心理学家,威廉·詹姆斯,采取的是有趣的中间路线。他提出许多细节,只有受过训练的细心的专家才会注意到,但他也常向读者查证,由他们自己的经验来看,他的理论是否正确。所以詹姆斯的作品《心理学原理》是科学也是哲学的,虽然基本上仍然以科学为主。\n如果我们说科学家是以实验为基础,或仰赖精确的观察研究,而哲学家只是坐在摇椅上的思考者,大部分人都能接受这样的差异比较,不会有什么意见。这种对比的说法,应该不致令人不快。确实有某些问题,非常重要的问题,一个懂得如何利用人类共通经验来思考的人,可以坐在摇椅上就想出解决的方案。也有些其他的问题,却绝不是坐在摇椅中思考就能解决的。要解决那样的问题必须要作研究调查—在实验室中作实验或作实地考察—要超越一般例行的生活经验才行。在这样的情况中,特殊的经验是必要的。\n这并不是说哲学家就是纯粹的思考者,而科学家只是个观察者。他们都同样需要思考与观察,只是他们会就不同的观察结果来思考。不论他们如何获得自己想要证明的结论,他们证明的方法就是各不相同:科学家会从他特殊经验的结果作举证,哲学家却会以人类的共通性作例证。\n哲学或科学的书中,经常会出现这种方法的差异性,而这也会让你明白你在读的是什么样的书。如果你能把书中所提到的经验类别当作了解内容的条件,那么你就会明白这本书是哲学或科学的作品了。\n明白这一点是很重要的。因为哲学家与科学家除了所依赖的经验不同之外,他们思考的方式也并不全然相同。他们论证问题的方式也不同。你一定要有能力在这些不同种类的论证中,看得出是哪些关键的词目或命题构成了其间的差异—这里我们谈得有点远了。\n在历史书方面的状况也类似。历史学家的说法跟科学家、哲学家也不相同。历史学家论证的方式不同,说明事实的方式也不一样。何况典型的历史书都是以说故事的形态出现。不管说的是事实或小说,说故事就是说故事。历史学家的文词必须要优美动人,也就是说他要遵守说一个好故事的规则。因此,无论洛克的《论人类悟性》或牛顿的《自然哲学的数学原理》有多杰出伟大,却都不是很好的故事书。\n你可能会抗议我们对书籍的分类谈得太琐碎了,至少,对一个还没开始读的人来说太多了。这些事真的有那么重要吗?\n为了要消除你的抗议,我们要请你想一件事情。如果你走进一间教室,老师正在讲课或指导学生,你会很快地发现这间教室是在上历史、科学或哲学课。这跟老师讲课的方式有关,他使用的词句,讨论的方式,提出的问题,期望学生作出的答案,都会表现出他隶属的是哪个学科。如果你想继续很明白地听下去,先了解这一点是很重要的。\n简单来说,不同的课程有不同的教法,任何一个老师都知道这一点。因为课程与教法的不同,哲学老师会觉得以前没有被其他哲学老师教过的学生比较好教,而科学老师却会希望学生已经被其他科学老师有所训练过。诸如此类。\n就像不同的学科有不同的教法一样,不同的课程也有不同的学习方法。学生对老师的教法,多少要有一些相对的回应。书本与阅读者之间的关系,跟老师和学生之间的关系是相同的。因此,既然书本所要传达给我们的知识不同,对我们的指导方式也会不同。如果我们要跟随这些书本的指导,那就应该学习以适当的态度来阅读不同的书。\n第七章 透视一本书 # 每一本书的封面之下都有一套自己的骨架。作为一个分析阅读的读者,你的责任就是要找出这个骨架。\n一本书出现在你面前时,肌肉包着骨头,衣服裹着肌肉,可说是盛装而来。你用不着揭开它的外衣,或是撕去它的肌肉,才能得到在柔软表皮下的那套骨架。但是你一定要用一双X光般的透视眼来看这本书,因为那是你了解一本书,掌握其骨架的基础。\n知道掌握一本书的架构是绝对需要的,这能带引你发现阅读任何一本书的第二及第三个规则。我们说的是“任何一本书”。这些规则适用于诗集,也适用于科学书籍,或任何一种论说性作品。当然,根据书本的不同,这些规则在应用时会各不相同。一本小说和一本政治论述的书,整体结构不同,组成的篇章不同,次序也不同。但是,任何一本值得读的书,都会有一个整体性与组织架构。否则这本书会显得乱七八糟,根本没法阅读。而烂书就是如此。\n我们会尽量简单地叙述这两个规则。然后我们会加以说明及解释。\n分析阅读的第二个规则是:使用一个单一的句子,或最多几句话(一小段文字)来叙述整本书的内容。\n这就是说你要尽量简短地说出整本书的内容是什么。说出整本书在干什么,跟说出这本书是什么类型是不同的(这在规则一已经说明过了)。“干什么”这个字眼可能会引起误解。从某一方面来说,每一本书都有一个“干什么”的主题,整本书就是针对这个主题而展开。如果你知道了,就明白了这是什么样的书。但“干什么”还有另一个层面的意思,就是更口语化的意义。我们会问一个人是干什么的,他想做什么等等。所以,我们也可以揣测一个作者想要干什么,想要做什么。找出一本书在干什么,也就是在发现这本书的主题或重点。\n一本书是一个艺术作品。(我们又要提醒你了,不要将“艺术”想得太狭隘。我们不想、也不只是在强调“纯艺术”。一本书是一个有特别技巧的人所做的成品,他创作的就是书,而其中一本我们正在这里受益。)就一本书就是一件艺术品的立场来说,书除了要外观的精致之外,相对应地,还要有更接近完美、更具有渗透力的整体内容。这个道理适用于音乐或美术,小说或戏剧,传递知识的书当然也不例外。\n对于“整体内容”这件事,光是一个模糊的认知是不够的,你必须要确切清楚地了解才行。只有一个方法能知道你是否成功了。你必须能用几句话,告诉你自己,或别人,这整本书在说的是什么。(如果你要说的话太多,表示你还没有将整体的内容看清楚,而只是看到了多样的内容。)不要满足于“感觉上的整体”,自己却说不出口。如果一个阅读者说:“我知道这本书在谈什么,但是我说不出来。”应该是连自己也骗不过的。\n第三个规则可以说成是:将书中重要篇章列举出来,说明它们如何按照顺序组成一个整体的架构。\n这个规则的理由很明显。如果一个艺术作品绝对简单,当然可能没有任何组成部分。但这从来就不可能存在。人类所知的物质,或人类的产品中,没有一样是绝对简单的。所有的东西都是复杂的组合体。当你看一个整体组成复杂的东西的时候,如果只看出它“怎样呈现一体”的面貌,那是还没有掌握精髓,你还必须要明白它“怎样呈现多个”的面貌—但不是各自为政,互不相干的“多个”,而是互相融合成有机体的“多个”。如果组成的各个部分之间没有有机的关联,一定不会形成一个整体。说得严格一点,根本不会有整体,只是一个集合体而已。\n这就像是一堆砖头,跟一栋由砖头建造起来的房子是有区别的。而一栋单一的房子,与一整组的房子也不相同。一本书就像一栋单一的房子。那是一栋大厦,拥有许多房间,每层楼也都有房间,有不同的尺寸与形状,不同的外观,不同的用途。这些房间是独立的,分离的。每个房间都有自己的架构与装湟设计,但却不是完全独立与分离的。这些房间是用门、拱门、走廊、楼梯串连起来的,也就是建筑师所谓的“动线\u0026quot;(traffic pattern)架构。因为这些架构是彼此连结的,因此每个部分在整体的使用功能上都要贡献出一己的力量。否则,这栋房子便是不适于居住的。\n这样的比喻简直是接近完美了。一本好书,就像一栋好房子,每个部分都要很有秩序地排列起来。每个重要部分都要有一定的独立性。就像我们r看到的,每个单一部分有自己的室内架构,装湟的方式也可能跟其他部分不同。但是却一定要跟其他部分连接起来—这是与功能相关—否则这个部分便无法对整体的智能架构作出任何贡献了。\n就像一栋房子多少可以居住一样,一本书多少也可以阅读一下。可读性最高的作品是作者达到了建筑学上最完整的整体架构。最好的书都有最睿智的架构。虽然他们通常比一些差一点的书要复杂一些,但他们的复杂也是一种单纯,因为他们的各个部分都组织得更完善,也更统一。\n这也是为什么最好的书,也是可读性最高的书的理由之一。比较次级的作品,在阅读时真的会有一些比较多的困扰。但是要读好这些书—就它们原本所值得的程度读好—你就要从中找出它们的规划,当初如果这些作者自己把规划弄得更清楚一些,这些书都可能再更好一些。但只要大致还可以,只要内容不仅是集合体,还够得上是某种程度的整体组合,那其中就必然有一个架构规划,而你一定要找出来才行。\n1.结构与规划:叙述整本书的大意 # 让我们回到第二个规则,也就是要你说出整本书的大意。对这个规则的运用再作一些说明,或许能帮助你确实用上这个技巧。\n让我们从最出名的一个例子来说吧!你在学校大概听过荷马的《奥德赛))(Odyssey)。就算没有,你一定也听过奥德赛—或尤利西斯,罗马人这么叫他—的故事。这个男人在特洛伊围城之战之后,花了十年时间才回到家乡,却发现忠心的妻子佩尼洛普被一些追求者包围着。就像荷马所说的,这是一个精致而复杂的故事,充满了兴奋刺激的海上、陆上冒险,有各种不同的插曲与复杂的情节。但整个故事仍然是一个整体,一个主要的情节牵扯着所有的事情连结在一起。\n亚里士多德在他的《诗学》(Poetics)中,坚称这是非常好的故事、小说或戏剧的典范。为了支持他的观点,他说他可以用几句话将《奥德赛》的精华摘要出来:\n某个男人离家多年。海神嫉妒他,让他一路尝尽孤独和悲伤。在这同时,他的家乡也濒临险境。一些企图染指他妻子的人尽情挥霍他的财富,对付他的儿子。最后在暴风雨中,他回来了,他让少数几个人认出他,然后亲手攻击那些居心不良的人,摧毁了他们之后,一切又重新回到他手中。\n“这个,”亚里士多德说,“就是情节的真正主干,其他的都是插曲。”\n你用这样的方式来了解一个故事之后,透过整体调性统一的叙述,就能将不同的情节部分放人正确的位置了。你可能会发现这是很好的练习,可以用来重新看你以前看过的小说。找一些好书来看,像是菲尔丁(Fielding)的《汤姆琼斯》 (Tome Jones)、陀思妥耶夫斯基(Dostoevsky)的《罪与罚)(Crime and Punishment)或乔伊斯(Joyce)的现代版《尤利西斯》(Ulysses)等。以《汤姆琼斯》的情节为例,可以简化为一个熟悉的公式:男孩遇到女孩,男孩失掉女孩,男孩又得到女孩。这真的是每一个罗曼史的情节。认清这一点,也就是要明白,为什么所有的故事情节不过那几个的道理。同样的基本情节,一位作者写出来的是好故事或坏故事,端看他如何装点这副骨架。\n你用不着光靠自己来发掘故事的情节。作者通常会帮助你。有时候,光读书名就好了。在18世纪,作者习惯列出详细的书名,告诉读者整本书在说些什么。杰瑞米·科利尔(Jeremy Collier),一位英国的牧师,用了这样一个书名来攻击王权复兴时期的戏剧之猥亵—或许我们该说是色情—《英国戏剧的不道德与猥亵之一瞥—从古典的观点来探讨》(A Short View of the Immorality and Profaneness of the English Stage,together with the Sense o f Antiquity upon this Argument)。比起今天许多人的习惯性反应,他的抨击倒真的是学养甚佳。从这个书名你可以想像得出来,科利尔一定在书中引述了许多恶名昭彰的不道德的例子,而且从古人的争论当中找出许多例子来支持他的观点。譬如柏拉图说的,舞台使年轻人腐败堕落,或是早期教会里的神父所说的,戏剧是肉体与魔鬼的诱惑。\n有时候作者会在前言说明他整体内容的设计。就这一点而言,论说性的书籍不同于小说。一位科学或哲学的作者没有理由让你摸不着头脑。事实上,他让你的疑虑减到越少,你就会越乐意继续努力阅读他的思想。就像报纸上的新闻报导一样,论说性的书开宗明义就会将要点写在第一段文字中。\n如果作者提供帮助,不要因为太骄傲而拒绝。但是,也不要完全依赖他在前言中所说的话。一个作者最好的计划,就像人或老鼠经常在作的计划一样,常常会出错。你可以借着作者对内容提示的指引来读,但永远要记得,最后找出一个架构是读者的责任,就跟当初作者有责任自己设定一个架构一样。只有当你读完整本书时,才能诚实地放下这个责任。\n希罗多德(Herodotus)所写有关希腊民族与波斯民族战争的《历史》中,有一段引言介绍,可说是相当精华的摘要:\n这本书是希罗多德所作的研究。他出版这本书是希望提醒人们,前人所做的事情,以免希腊人与巴比伦人伟大的事迹失去了应得的光荣,此外还记录下他们在这些夙怨中的领土状态。\n对一个读者来说,这是很棒的开头,简要地告诉了你整本书要说的是什么。\n但是你最好不要就停在那里。在你读完希罗多德九个部分的历史之后,你很可能会发现这段说明需要再丰富一些,才能把全书的精神呈现出来。你可能想要再提一下那些波斯国王—居鲁士(Cyrus),大流士(Darius)与薛西斯(Xerxes),以德密托克里斯(Themistocles)为代表的那些希腊英雄,以及许多动人心魄的事件,诸如黑勒斯波(Hellespont)海峡之横越,还有像德默皮烈之役(Thermopylae)及撒拉密斯之役(Salamis)那些战役。\n其他所有精彩绝伦的细节,都是希罗多德为了烘托他的高潮而给你准备的,在你的结构大纲中,大可删去。注意,在这里,整个历史才是贯穿全体的主要脉络,这跟小说有点相像。既然关心的是整体的问题,在阅读历史时跟小说一样,阅读的规则在探索的都是同样的答案。\n还要再补充一些说明。让我们以一本实用的书做例子。亚里士多德的《伦理学》可以简述为:\n这本书是在探索人类快乐的本质,分析在何种状态下,人类会获得或失去快乐,并说明在行为与思想上该如何去做,才能变得快乐或避免不幸。虽然其他美好的事物也被认可为幸福快乐的必要条件,像是财富、健康、友谊与生活在公正的社会中,但原则上还是强调以培养道德与心智上的善行为主。\n另一本实用的作品是亚当·斯密的《国富论》。一开始,作者就写了一篇“本书计划”的声明来帮助读者。但这篇文章有好几页长。整体来说可以缩简为以下的篇幅:\n本书在探讨国家财富的资源。任何一个封劳力分工为主的经济体制,都要考虑到薪资的给付,资本利润的回收,积欠地主的租金等关系,这些就是物品价格的基本因素。本书讨论到如何更多元化地有效运用资本,并从金钱的起源与使用,谈到累积资本及使用资本。本书借着检验不同国家在不同状况下的富裕发展,比较了不同的政经系统,讨论了自由贸易的好处。\n如果一个读者能用这样的方法掌握住《国富论》的重点,并对马克思的《资本论》作同样的观察,他就很容易看出,过去两个世纪以来最有影响力的这两本书之间有什么关联了。\n达尔文的《物种起源》是另一个好例子,可以帮我们看到科学类理论作品的整体性。这本书可以这么说:\n这本书所叙述的是,生物在数不清世代中所产生的变化,以及新种类的动物或植物如何从其中演变出来。本书讨论了动物在畜养状态下的变化,也讨论了动物在自然状态下的变化,进而说明“物竞天择,适者生存”之类的原理,如何形成并维持一个个族群。此外,本书也主张,物种并不是固定、永恒不变的族群,而是在世代交替中,由比较小的转变成比较明显的、固定的特征。有一些地层中的绝种动物,以及胚胎学与解剖学的比较证据,可以支持这些论点。\n这段说明看来好像很难一口消化,但是对许多19世纪的读者来说,那本书的本身才更难消化—部分原因,是他们懒得花精神去找出书中真正的意旨。\n最后,让我们以洛克的《论人类悟性》当作哲学类理论性作品的例子。你大概还记得我们谈到洛克自己说他的作品是“探讨人类知识的起源、真理与极限,并同时讨论信仰、观点与核准的立场与程度”。作者对自己作品的规划说明得这么精彩,我们当然不会和他争辩什么,不过,我们想要再加两点附带的补充说明,以便把这篇论文第一部分和第三部分的精神也表达清楚。我们会这么加一段话:本书显示出人类没有与生俱来的观念,人类所有的知识都是由经验而来的。本书并论及语言是一个传递思想的媒介—适当的使用方法与最常出现的滥用,在本书中都有指证。\n在继续讨论之前,我们要提醒你两件事。首先,一位作者,特别是好的作者,会经常想要帮助你整理出他书中的重点。尽管如此,当你要求读者择要说出一本书的重点时,大多数人都会一脸茫然。一个原因是今天的人们普遍不会用简明的语言表达自己,另一个原因,则是他们忽视了阅读的这一条规则。当然,这也说明太多读者根本就不注意作者的前言,也不注意书名,才会有这样的结果。\n其次,是要小心,不要把我们提供给你的那些书的重点摘要,当作是它们绝对又惟一的说明。一本书的整体精神可以有各种不同的诠释,没有哪一种一定对。当然,某些诠释因为够精简、准确、容易理解,就是比另一些诠释好。不过,也有些南辕北辙的诠释,不是高明得不相上下,就是烂得不相上下。\n我们在这里谈的一些书的整体重点,跟作者的解释大不一样,但并不觉得需要道歉。你的摘要也可以跟我们的大不一样。毕竟,虽然是同一本书,但对每个阅读者来说都是不同的。如果这种不同透过读者的诠释来表达,毫不足为奇。但,这也不是说就可以爱怎么说就怎么说。虽然读者不同,书的本身还是一样的,不论是谁作摘要,还是有一个客观的标准来检验其正确与真实性。\n2.驾驭复杂的内容:为一本书拟大纲的技巧 # 现在我们来谈另一个结构的规则,这个规则要求我们将一本书最重要的部分照秩序与关系,列举出来。这是第三个规则,与第二个规则关系很密切。一份说明清楚的摘要会指出全书最重要的构成部分。你看不清楚这些构成部分,就没法理解全书。同样的,除非你能掌握全书各个部分之间的组织架构,否则你也无法理解全书。\n那么,为什么要弄两个规则,而不是一个?主要是为了方便。用两个步骤来掌握一个复杂又未划分的架构,要比一个步骤容易得多。第二个规则在指导你注意一本书的整体性,第三个则在强调一本书的复杂度。要这样区分还有另一个理由。当你掌握住一本书的整体性时,便会立刻抓住其中一些重要的部分。但是这每个部分的本身通常是很复杂,各有各的内在结构需要你去透视。因此第三个规则所谈的,不只是将各个部分排列出来,而且要列出各个部分的纲要,就像是各个部分自成一个整体,各有各的整体性与复杂度。\n根据第三个规则,可以有一套运用的公式。这个公式是可以通用的。根据第二个规则,我们可以说出这本书的内容是如此这般。做完这件事之后,我们可以依照第三个规则,将内容大纲排列如下:(1)作者将全书分成五个部分,第一部分谈的是什么,第二部分谈的是什么,第三部分谈的是别的事,第四部分则是另外的观点,第五部分又是另一些事。(2)第一个主要的部分又分成三个段落,第一段落为X,第二段落为Y,第三段落为Z。(3)在第一部分的第一阶段,作者有四个重点,第一个重点是A,第二个重点是B,第三个重点是C,第四个重点是D等等。\n你可能会反对这样列大纲。照这样阅读岂不是要花上一辈子的时间才能读完一本书了?当然,这只是一个公式而已。这个规则看起来似乎要你去做一件不可能做到的事。但事实上,一个优秀的阅读者会习惯性地这么做,而且轻而易举。他可能不会全部写出来,在阅读时也不会在口头上说出来。但是如果你问他这本书的整体架构时,他就会写出一些东西来,而大概就跟我们所说的公式差不多。\n“大概”这两个字可以舒解一下你的焦虑。一个好的规则总是会将最完美的表现形容出来。但一个人可以做一个艺术家,却不必做个理想的艺术家。如果他大概可以依照这个规则,就会是个很好的练习者了。我们所说明的规则是个理想的标准。如果你能作出一个草稿来,跟这里所要求的很类似,就该感到满足了。\n就算你已经很熟练阅读技巧了,你也不一定读每本书都要用上同样的力气。你会发现在某些书上运用这些技巧是个浪费。就是最优秀的阅读者也只会选少数相关的几本书,依照这个规则的要求做出近似的大纲来。在大多数情况下,他们对一本书的架构有个粗浅的了解已经很满意了。你所做的大纲与规则相近的程度,是随你想读的书的特质而变化的。但是不管千变万化,规则本身还是没有变。不论你是完全照做,或是只掌握一个形式,你都得了解要如何跟着规则走才行。\n你要了解,影响你执行这个规则的程度的因素,不光是时间和力气而已。你的生命是有限的,终有一死。一本书的生命也是有限的,就算不死,也跟所有人造的东西一样是不完美的。因为没有一本书是完美的,所以也不值得为任何一本书写出一个完美的纲要。你只要尽力而为就行了。毕竟,这个规则并没有要你将作者没有放进去的东西加在里面。你的大纲是关于作品本身的纲要,而不是这本书要谈的主题的纲要。或许某个主题的纲要可以无限延伸,但那却不是你要为这本书写的纲要—你所写的纲要对这个主题多少有点规范。不过,你可不要觉得我们在鼓励你偷懒。因为就算你真想跟随这个规则,也还是不可能奋战到底的。\n用一个公式将一本书各个部分的秩序与关系整理出来,是非常艰难的。如果举几个实例来说明,或许会觉得容易些,不过,要举例来说明这个规则,还是比举例说明另一个抓出重点摘要的规则要难多了。毕竟,一本书的重点摘要可以用一两个句子,或是几段话就说明清楚了。但是对一本又长又难读的书,要写出仔细又适当的纲要,将各部分,以及各部分中不同的段落,各段落中不同的小节,一路细分到最小的结构单位都写清楚,可是要花上好几张纸才能完成的工作。\n理论上来说,这份大纲可以比原著还要长。中世纪有些对亚里士多德作品的注释,都比原著还长。当然,他们所含的是比大纲还要多的东西,因为他们是一句一句地解释作者的想法。有些现代的注释也是如此,像一些对康德《纯粹理性批判》一书所作的注释便是一个例子。莎士比亚的注释剧本集也是如此,其中包括了详尽无比的纲要与其他的论述,往往有原著的好几倍长—甚或十倍之长。如果你想要知道照这条规则可以做到多详尽的地步,不妨找一些这类的注释来看看。阿奎那(Aquinas)在注解亚里士多德的书时,每个注释的起头都要针对亚里士多德在他作品中表达的某个重点,拟一份漂亮的纲要,然后不厌其烦地说明这个重点如何与亚里士多德的全书融为一体,这个重点和前后文又有多么密切的关系。\n让我们找一些比亚里士多德的论述要简单一点的例子。亚里士多德的文章是最紧凑简洁的,要拿他的作品来拟大纲,必然费时又困难。为了要举一个适当的例子,让我们都同意一点:就算我们有很长的篇幅可以用,我们还是放弃把这个例子举到尽善尽美程度的想法吧。\n美国联邦宪法是很有趣又实用的文献,也是组织整齐的文字。如果你检验一下,会很容易找出其中的重要部分来。这些重要部分本来就标示得很清楚,不过你还是得下点功夫作些归纳。以下是这份大纲的建议写法:第一:前言,声明宪法的目的。第二:第一条,关于政府立法部门的问题。第三:第二条,关于政府行政部门的问题。第四:第三条,关于政府司法部门的问题。第五:第四条,关于州政府与联邦政府之间的关系。第六:第五、六、七条,关于宪法修正案的问题,宪法有超越所有法律、提供认可之地位。第七:宪法修正案的前十条,构成人权宣言。第八:其他持续累积到今天的修正案。\n这些是主要的归纳。现在就用其中的第二项,也就是宪法第一条为例,再列一些纲要。就跟其他的条一样,这一条(Article)也区分为几个款(Section)。以下是我们建议的纲要写法:\n二之一:第一款,制定美国国会的立法权,国会分成两个部分,参议院与众议院。\n二之二:第二、三款,个别说明参议院与众议院的组成架构,与成员的条件。此外,惟有众议院有弹劾的权力,唯有参议院有审理弹劾的权力。\n二之三:第四、五款,关于国会两院的选举,内部的组织与事务。\n二之四:第六款,关于两院所有成员的津贴与薪金的规定,并设定成员使用公民权的限制。\n二之五:第七款,设定政府立法与行政部门之间的关系,说明总统的否决权。\n二之六:第八款,说明国会的权力。\n二之七:第九款,说明第八款国会权力的限制。\n二之八:第十款,说明各州的权力限制,以及他们必须要把某些权力交给国会的情况。\n然后,我们可以将其他要项也都写出类似的纲要。都完成之后,再回头写每一个小款的纲要。其中有些小款,像是第一条的第八款,需要再用许多不同的主题与次主题来确认。\n当然,这只是其中一种方法。还有很多其他拟定纲要的方法。譬如前三项可以合并归纳为一个题目,或者,不要将宪法修正案区分为两项来谈,而是根据所处理问题的性质,将修正案划分为更多项来谈。我们建议你自己动手,用你的观点将宪法区分为几个主要的部分,列出大纲。你可以做得比我们更详细,在小款中再区分出小点来。你可能读过宪法很多次了,但是以前可能没用过这种方法来读,现在你会发现用这样的方法来阅读一份文献,会看到许多以前你没看到的东西。\n接下来是另外一个例子,也是很短的例子。我们已经将亚里士多德的《伦理学》做过重点摘要,现在让我们首次试着将全书的结构作一个近似的说明。全书可以区分为以下的几个重要部分:一,把快乐当作是生命的终极目标,讨论快乐与其他善行的关系。二,讨论天生自然的行为,与养成好习惯、坏习惯的关系。三,讨论伦理与智性中各种不同的善行与恶行。四,讨论非善非恶的道德状态。五,讨论友谊。六,也是最后一个,讨论喜悦,并完成一开始所谈有关人类快乐的主题。\n这个大纲显然与《伦理学》的十卷内容并不完全相符合。因为第一部分是第一卷中所谈论的内容。第二部分包含了第二卷及第三卷的前半部内容。第三部分则从第三卷后半部一直延伸到第六卷。讨论享乐的最后一部分则是包含了第七卷的结尾与第十卷的开头。\n我们要举出这个例子,是要让你明白,你用不着跟着书上所出现的章节来归纳一本书的架构。当然,原来的结构可能比你区分的纲要好,但也很可能比不上你的纲要。无论如何,你得自己拟纲要就对了。作者拟定了纲要,以写出一本好书。而你则要拟定你的纲要,才能读得明白。如果他是个完美的作家,而你是个完美的读者,那你们两个人所列的纲要应该是相同的。如果你们两人之中有人偏离了朝向完美的努力,那结果就免不了产生许多出人。\n这并不是说你可以忽略作者所设定的章节与段落的标题,我们在做美国宪法的纲要时,并没有忽略这些东西,但我们也没有盲从。那些章节是想要帮助你,就跟书名与前言一样。不过你应该将这些标题当作是你自己活动的指南,而不是完全被动地仰赖它们。能照自己所列的纲要执行得很完美的作者,寥寥无几。但是在一本好书里,经常有许多作者的规划是你一眼看不出来的。表象可能会骗人的。你一定要深人其间,才能发现真正的架构。\n找出真正的架构到底有多重要呢?我们认为非常重要。用另一种方法来说,就是除非你遵循规则三—要求你说明组成整体的各个部分—否则就没有办法有效地运用规则二—要求你作全书的重点摘要。你可能有办法粗略地瞄一本书,就用一两个句子说出全书的重点摘要,而且还挺得体。但是你却无法真的知道到底得体在哪里。另一个比你仔细读过这本书的人,就可能知道得体在哪里,因而对你的说法给予很高的评价。但是对你来说,那只能算是你猜对了,运气很好罢了。因此说,要完成第二个规则,第三个规则是绝对必要的。\n我们会用一个简单的例子向你说明我们的想法。一个两岁的孩子,刚开始说话,可能会说出:“二加二等于四”这样的句子。的确,这句话是千真万确的,但我们可能会因此误下结论,认为这个孩子懂数学。事实上,这个孩子可能根本不知道自己在说些什么。因此,虽然这句话是正确的,这个孩子还是需要接受这方面的训练。同样的,你可能猜对了一本书的主题重点,但你还是需要自我训练,证明你是“如何”,又“为什么”这么说。因此,要求你将书中的重要部分列出纲要,并说明这些部分如何印证、发展出全书的主题,就有助于你掌握全书的重点摘要。\n3.阅读与写作的互惠技巧 # 乍看之下,我们前面讨论的两个阅读规则,看起来就跟写作规则一\n样。的确没错。写作与阅读是一体两面的事,就像教书与被教一样。\n如果作者跟老师无法将自己要传达的东西整理出架构,不能整合出要\n讲的各个部分的顺序,他们就无法指导读者和学生去找出他们要讲的重点,也没法发现全书的整体架构。\n尽管这些规则是一体两面,但实行起来却不相同。读者是要“发现”书中隐藏着的骨架。而作者则是以制造骨架为开始,但却想办法把骨架“隐藏”起来。他的目的是,用艺术的手法将骨架隐藏起来,或是说,在骨架上添加血肉。如果他是个好作者,就不会将一个发育不良的骨架埋藏在一堆肥肉里,同样的,也不会瘦得皮包骨,让人一眼就看穿。如果血肉匀称,也没有松弛的赘肉,那就可以看到关节,可以从身体各个部位的活动中看出其中透露的言语。\n为什么这么说呢?为什么论说性的书,这种本来就想条理井然地传达一种知识的书,不能光是把主题纲要交待清楚便行?原因是,不仅大多数人都不会读纲要,而且对一位自我要求较高的读者来说,他并不喜欢这样的书,他会认为他可以做自己分内的事,而作者也该做他自己分内的事。还有更多的原因。对一本书来说,血肉跟骨架是一样重要的。书,真的就跟人或动物是一模一样的。—血肉,就是为纲要所作的进一步详细解释,或是我们有时候所说的“解读”(read out)。血肉,为全书增添了必要的空间与深度。对动物来说,血肉就是增加了生命。因此,根据一个纲要来写作一本书,不论这个纲要详尽的程度如何,都在给予这本书一种生命,而这种效果是其他情况所达不到的。\n我们可以用一句老话来概括以上所有的概念,那就是一个作品应该有整体感,清楚明白,前后连贯。这确实是优秀写作的基本准则。我们在本章所讨论的两个规则,都是跟随这个写作准则而来的。如果这本书有整体的精神,那我们就一定要找出来。如果全书是清楚明白又前后一贯的,我们就要找出其间的纲要区隔,与重点的秩序来当作回报。所谓文章的清楚明白,就是跟纲要的区隔是否清楚有关,所谓文章的前后一贯,就是能把不同的重点条理有序地排列出来。\n这两个规则可以帮助我们区分好的作品与坏的作品。如果你运用得已经成熟了,却不论花了多少努力来了解一本书的重点,还是没法分辨出其间的重点,也找不出彼此之间的关系,那么不管这本书多有名,应该还是一本坏书。不过你不该太快下这样的结论,或许错误出在你身上,而不是书的本身。无论如何,千万不要在读不出头绪的时候,就总以为是自己的问题。事实上,无论你身为一个读者的感受如何,通常问题还是出在书的本身。因为大多数的书—绝大多数—的作者,都没有依照这些规则来写作,因而就这一点来说,都可以说是很糟。\n我们要再强调的是,这两个规则不但可以用来阅读一整本论说性的书,也可以用来阅读其中某个特别重要的部分。如果书中某个部分是一个相当独立又复杂的整体,那么就要分辨出这部分的整体性与复杂性,才能读得明白。传达知识的书,与文学作品、戏剧、小说之间,有很大的差异。前者的各个部分可以是独立的,后者却不能。如果一个人说他把那本小说已经“读到够多,能掌握主题了”,那他一定根本不知道自己在说些什么。这句话一定不通,因为一本小说无论好坏都是一个整体,所有的概念都是一个整体的概念,不可能只读了一部分就说懂得了整体的概念。但是你读亚里士多德的《伦理学》或达尔文的《物种起源》,却可以光是仔细地阅读某一个部分,就能得到整体的概念。不过,在这种情况下,你就做不到规则三所说的了。\n4.发现作者的意图 # 在这一章,我们还想再讨论另一条阅读规则。这个规则可以说得简短一点,只需要一点解释,不需要举例。如果你已经在运用规则二跟规则三了的话,那这一条规则就不过是换种说法而已。但是重复说明这个规则很有帮助,你可以借此用另一个角度来了解全书与各个重要部分。\n这第四个规则可以说是:找出作者要问的问题。一本书的作者在开始写作时,都是有一个问题或一连串的问题,而这本书的内容就是一个答案,或许多答案。\n作者可能会,也可能不会告诉你他的问题是什么,就像他可能会,也可能不会给你他工作的果实,也就是答案。不论他会不会这么做—尤其是不会的情况—身为读者,你都有责任尽可能精确地找出这些问题来。你应该有办法说出整本书想要解答的问题是什么。如果主要的问题很复杂,又分成很多部分,你还要能说出次要的问题是什么。你应该不只是有办法完全掌握住所有相关的问题,还要能明智地将这些问题整合出顺序来。哪一个是主要的,哪个是次要的?哪个问题要先回答,哪些是后来才要回答的?\n从某方面来说,你可以看出这个规则是在重复一些事情,这些事情在你掌握一本书的整体精神和重要部分的时候已经做过了。然而,这个规则的确可以帮你做好这些事。换句话说,遵守规则四,能让你和遵守前两条规则产生前后呼应的效果。\n虽然你对这个规则还不像其他两个规则一样熟悉,但这个规则确实能帮助你应对一些很困难的书。但我们要强调一点:我们不希望你落入批评家所认为的“意图谬误\u0026quot;(intentional fallacy)。这种谬误就是你认为自己可以从作者所写的作品中看透他的内心。这样的状况特别会出现在文学作品中。譬如,想从《哈姆雷特》来分析莎士比亚的心理,就是一个严重的错误。然而,就真是一本诗集,这个规则也能极有助于你说出作者想要表达的是什么。对论说性的书来说,这个规则的好处当然就更明显。但是,大多数读者不论其他技巧有多熟练,还是会忽略这个规则。结果,他们对一本书的主题或重点就可能很不清楚,当然,所列出的架构也是一团混乱。他们看不清一本书的整体精神,因为他们根本不知道整本书为什么要有这样的整体精神。他们所理解的整本书的骨架,也欠缺这个骨架最后想说明的目的。\n如果你能知道每个人都会问的一些问题,你就懂得如何找出作者的问题。这个可以列出简短的公式:某件事存在吗?是什么样的事?发生的原因是什么?或是在什么样的情况下存在?或为什么会有这件事的存在?这件事的目的是什么?造成的影响是什么?特性及特征是、什么?与其他类似事件,或不相同事件的关联是什么?这件事是如何进行的?以上这些都是理论性的问题。有哪些结果可以选择?应该采取什么样的手段才能获得某种结果?要达到某个目的,应该采取哪些行动?以什么顺序?在这些条件下,什么事是对的,或怎样才会更好,而不是更糟?在什么样的条件下,这样做会比那样做好一些?以上这些都是实用的问题。\n这些问题还不够详尽,但是不论阅读理论性还是实用性的书,这些都是经常会出现的典型问题。这会帮助你发现一本书想要解决的问题。在阅读富有想像力的文学作品时,这些问题要稍作调整,但还是非常有用。\n5.分析阅读的第一个阶段 # 我们已经说明也解释了阅读的前四个规则。这些是分析阅读的规则。如果在运用之前能先做好检视阅读,会更能帮助你运用这些规则。\n最重要的是,要知道这前四个规则是有整体性,有同一个目标的。这四个规则在一起,能提供读者对一本书架构的认识。当你运用这四个规则来阅读一本书,或任何又长又难读的书时,你就完成了分析阅读的第一个阶段。\n除非你是刚开始练习使用分析阅读,否则你不该将“阶段”一词当作一个前后顺序的概念。因为你没有必要为了要运用前四个规则,而将一本书读完,然后为了要运用其他的规则,再重新一遍又一遍地读。真正实际的读者是一次就完成所有的阶段。不过,你要了解的是,在分析阅读中,要明白一本书的架构是有阶段性的进展的。\n换一种说法是,运用这前四个规则,能帮助你回答关于一本书的一些基本问题。你会想起第一个问题是:整本书谈的是什么?你也会想起,我们说这是要找出整本书的主题,以及作者是如何运用一些根本性的次要主题或议题,按部就班来发展这个主题。很明显的,运用这前四个阅读规则,能提供你可以回答这个问题的大部分内容—不过这里要指出一点,等你可以运用其他规则来回答其他问题的时候,你回答这个问题的精确度会提高许多。\n既然我们已经说明了分析阅读的第一个阶段,让我们暂停一下,将这四个规则按照适当的标题,顺序说明一下:分析阅读的第一阶段,或,找出一本书在谈些什么的四个规则:\n(1)\n依照书本的种类与主题作分类。\n(2)\n用最简短的句子说出整本书在谈些什么。\n(3)\n按照顺序与关系,列出全书的重要部分。将全书的纲要拟出来之后,再将各个部分的纲要也一一列出。\n(4)找出作者在问的问题,或作者想要解决的问题。\n第八章 与作者找出共通的词义 # 如果你运用了前一章结尾时所谈到的前四个规则,你就完成了分析阅读的第一个阶段。这四个规则在告诉你一本书的内容是关于什么,要如何将架构列成纲要。现在你准备好要进行第二个阶段了。这也包括了四个阅读规则。第一个规则,我们简称为“找出共通的词义”。\n在任何一个成功的商业谈判中,双方找出共同的词义,也就是达成共识(coming to terms),通常是最后一个阶段。剩下惟一要做的就是在底线上签字。但是在用分析阅读阅读一本书时,找出共通的词义却是第一个步骤。除非读者与作者能找出共通的词义,否则想要把知识从一方传递到另一方是不可能的事。因为词义(term)是可供沟通的知识的基本要素。\n1.单字vs.词义 # 词义和单字(word)不同—至少,不是一个没有任何进一步定义的单字。如果词义跟单字完全相同,你只需要找出书中重要的单字,就能跟作者达成共识了。但是一个单字可能有很多的意义,特别是一个重要的单字。如果一个作者用了一个单字是这个意义,而读者却读成其他的意义,那这个单字就在他们之间擦身而过,他们双方没有达成共识。只要沟通之中还存有未解决的模糊地带,就表示没有达成沟通,或者顶多说还未达成最好的沟通。\n看一下“沟通”(communication)这个字,字根来自“共通”(common)。我们谈一个社群(community),就是一群有共通性的人。而沟通是一个人努力想要跟别人(也可能是动物或机器)分享他的知识、判断与情绪。只有当双方对一些事情达成共识,譬如彼此对一些资讯或知识都有分享,沟通才算成功。\n当知识沟通的过程中产生模糊地带时,双方惟一共有的是那些在讲在写、在听在读的单字。而只要模糊地带还存在,就表示作者和读者之间对这些单字的意义还没有共识。为了要达成完全的沟通,最重要的是双方必须要使用意义相同的单字—简单来说,就是,找出共通的词义达成共识。双方找出共通的词义时,沟通就完成了,两颗心也奇迹似地拥有了相同的想法。\n词义可以定义为没有模糊地带的字。这么说并非完全正确,因为严格来说,没有字是没有模糊地带的。我们应该说的是:当一个单字使用得没有模糊意义的时候,就是一个词义了。字典中充满了单字。就这些单字都有许多意义这一点而言,它们几乎都意义模糊。但是一个单字纵然有很多的意义,每一次使用却只能有一种意义。当某个时间,作者与读者同时在使用同一个单字,并采取惟一相同的意义时,在那种毫无模糊地带的状态中,他们就是找出共通的词义了。\n你不能在字典中找到词义,虽然那里有制造词义的原料。词义只有在沟通的过程中才会出现。当作者尽量避免模糊地带,读者也帮助他,试着跟随他的字义时,双方才会达成共识。当然,达成共识的程度有高下之别。达成共识是作者与读者要一起努力的事。因为这是阅读与写作的艺术要追求的终极成就,所以我们可以将达成共识看作是一种使用文字的技巧,以达到沟通知识的目的。\n在这里,如果我们专就论说性作家或论说性的作品来举例子,可能会更清楚一些。诗与小说不像论说性的作品—也就是我们所说的传达广义知识的作品—那么介意文字的模糊地带。有人说,最好的诗是含有最多模糊地带的。也有人很公允地说,一个优秀的诗人,不时会故意在作品中造成一些模糊。这是关于诗的重要观点,我们后面会再讨论这个问题。这是诗与其他论说性、科学性作品最明显的不同之处。\n我们要开始说明第五个阅读规则了(以论说性的作品为主)。简略来说就是:你必须抓住书中重要的单字,搞清楚作者是如何使用这个单字的。不过我们可以说得更精确又优雅一些:规则五,找出重要单字,透过它们与作者达成共识。要注意到这个规则共分两个部分,第一个部分是找出重要单字,那些举足轻重的单字。第二部分是确认这些单字在使用时的最精确的意义。\n这是分析阅读第二阶段的第一个规则,目标不是列出一本书的架构纲要,而是诠释内容与讯息。这个阶段的其他规则将会在下一章讨论到,意义也跟这个规则一样。那些规则也需要你采取两个步骤:第一个步骤是处理语言的问题。第二个步骤是超越语言,处理语言背后的思想涵义。\n如果语言是纯粹又完美的思想媒介,这些步骤就用不着分开来了。如果每个单字只有一个意义,如果使用单字的时候不会产生模糊地带,如果,说得简短一点,每个单字都有一个理想的共识,那么语言就是个透明的媒介了。读者可以直接透过作者的文字,接触到他内心的思想。如果真是如此,分析阅读的第二个阶段就完全用不上了。对文字的诠释也毫无必要了。\n当然,实际情况并非如此。不必难过,想刻意制造一个不可能实现的理想语言的方案—像是哲学家莱布尼兹和他学生想要做的事—也是枉然。事实上,如果他们成功了,这世上就不再有诗了。因此,在论说性的作品中,惟一要做的事就是善用语言。想要做到这一点,惟一的路就是当你在传递、接受知识时,要尽可能巧妙地运用语言的技巧。\n因为语言并不是完美的传递知识的媒介,因而在沟通时也会有形成障碍的作用。追求具备诠释能力的阅读,规则就在克服这些障碍。我们可以期望一个好作者尽可能穿过语言所无法避免形成的障碍,和我们接触,但是我们不能期望只由他一个人来做这样的工作。我们应该在半途就跟他相会。身为读者,我们应该从我们这一边来努力打通障碍。两个心灵想透过语言来接触,需要作者与读者双方都愿意共同努力才行。就像教学,除非被教的学生产生呼应的活力,否则光靠老师是行不通的。作者也是一样,不论他写作技巧如何,如果读者没有呼应的技巧,双方就不可能达成沟通。如果不是这样,双方不论付出多大的努力,各行其是的阅读和写作技巧终究不会将两个心灵联系在一起。就像在一座山的两边分头凿隧道一样,不论花了多少力气,如果双方不是照着同样的工程原理来进行计算,就永远不可能相遇。\n就像我们已经指出的,每一种具备诠释能力的阅读都包含两个步骤。暂且用些术语吧,我们可以说这些规则是具有文法与逻辑面向的。文法面向是处理单字的。逻辑面向是处理这些单字的意义,或说得更精确一点,是处理词义的。就沟通而言,每个步骤都不可或缺。如果在运用语言时毫无思想,就没有任何沟通可言。而没有了语言,思想与知识也无法沟通。文法与逻辑是艺术,它们和语言有关;语言与思想有关,而思想又与语言有关。这也是为什么透过这些艺术,阅读与写作的技巧会增进的原因。\n语言与思想的问题—特别是单字与词义之间的差异—是非常重要的。因此我们宁愿冒着重复的风险,也要确定这个重点被充分了解。这个重点就是,一个单字可能代表许多不同的词义,而一个词义可以用许多不同的单字来解释。让我们以下面的例子来做说明。在我们的讨论中,“阅读”这两个字已经出现过许多不同的意义。让我们挑出其中三个意义:当我们谈到“阅读”时,可能是指(1)为娱乐而阅读;(2)为获得资讯而阅读;(3)为追求理解力而阅读。\n让我们用X来代表“阅读”这两个字,而三种意义以a,b,c来代替。那么Xa,Xb,Xc代表什么?那不是三个不同的单字,因为X始终并没有改变。但那是三种不同的词义—如果你身为读者,我们身为作者,都知道X在这里指的是什么意思的话。如果我们在一个地方写了Xa,而你读起来却是Xb,那我们写的,你读的都是同一个单字,却是不同的意义。这个模糊的意义会中止,或至少妨碍我们的沟通。只有当你看到这个单字的时候所想的字义跟我们想的一样,我们之间才有共同的思想。我们的思想不会在X中相遇,而只会在Xa,Xb或Xc中 相遇。这样我们才算找出共通的词义。\n2.找出关键字 # 现在我们准备要为找出共通词义的这个规则加点血肉了。怎样才能找出共通词义?在一本书中,要怎样才能找出那些重要的字,或所谓的关键字来?有一件事你可以确定:并不是作者所使用的每一个字都很重要。更进一步说,作者所使用的字大多数都不重要。只有当他以特殊的方法来运用一些字的时候,那些字对他来说,对身为读者的我们来说,才是重要的。当然,这并不是百分之百的,总有程度之不同。或许文字多少都有重要性,但我们所关心的只是在一本书中,哪些字要比其他的字更重要一些。在某种极端情况下,一个作者所用的字可能就和街坊邻居的遣词用字是一模一样的。由于作者所用的这些字跟一般人日常谈话是相同的,读者应该不难理解才对。他很熟悉这些字眼的模糊地带,也习惯于在上下文不同的地方看出不同的含义来。\n譬如爱丁顿(A. S. Eddington)的《物理世界的本质》》(The Nature of the Physical World)一书出现“阅读”这个字的时候,他谈的是“仪表阅读”(pointer-readings),专门以科学仪器上的指针与仪表为对象的阅读。他在这里所用的“阅读”,是一般常用的意思之一。对他来说那不是特殊的专业用语。他用一般的含义,就可以说明他要告诉读者的意思。就算他在这本书其他地方把“阅读”作为其他不同的意义来用—譬如说,他用了个“阅读本质\u0026quot;(reading nature)的句子—他还是相信读者会注意到在这里一般的“阅读”已经转换为另一个意义了。读者做不到这一点的话,他就没法跟朋友谈话,也不能过日常生活了。\n但是爱丁顿在使用“原因\u0026quot;(cause)这个字的时候就不能如此轻松了。这可能是个很平常的字眼,但是当他在讨论因果论的时候用到这个字,肯定是用在一个非常特别的意义上。这个字眼如果被误解了,他和读者之间一定会产生困扰。同样的,在本书中,“阅读”这个字眼是非常重要的。我们不能只以一般的看法来运用。\n一个作者用字,泰半和一般人谈话时的用字差不多—这些字都有不同的意义,讲话的人也相信随着上下文的变化,对方可以自动就找出其不同的意义。知道这一点,有助于找出那些比较重要的字眼。然而,我们不要忘了,在每天的日常谈话中,不同的时间、地点下,同一个熟悉的字也可能变得没那么熟悉。当代作者所使用的字,大多都是今天日常生活中所使用的含义。你会懂,是因为你也活在今天。但是阅读一些过去人所写的书,要找出作者在当时时空背景下照大多数人习惯而使用的那些字眼的意思,就可能困难许多了。加上有些作者会故意用古字,或是陈旧的含义,就更增加了复杂度。这问题就跟翻译外文书是一样的。\n尽管如此,任何一本书中的泰半字句,都可以像是跟朋友说话中的遣字用词那样阅读。打开我们这本书,翻到任何一页,用这样的方法算算我们使用了哪些字:介词、连接词、冠词,以及几乎全部的动词、名词、副词与形容词。在这一章,到目前为止其实只出现了几个重要的字关键:“单字”、“词义”、“模糊”、“沟通”,或顶多再加一两个其他重要的字。当然,“模糊”显然是最重要的字,其他的字眼都跟它有关。\n如果你不想办法了解这些关键字所出现的那些段落的意思,你就没法指出哪些字是关键字了。这句话听起来有点矛盾。如果你了解那些段落的意思,当然会知道其中哪几个字是非常重要的。如果你并不完全了解那些段落的意思,很可能是因为你并不清楚作者是如何使用一些特定的字眼。如果你把觉得有困扰的字圈出来,很可能就找出了作者有特定用法的那些字了。之所以会如此,是因为如果作者所用的都只是一般日常用语的含义,对你来说就根本不存在有困扰的问题了。\n因此,从一个读者的角度来看,最重要的字就是那些让你头痛的字。这些字很可能对作者来说也很重要。不过,有时也并非如此。\n也很可能,对作者来说很重要的字,对你却不是问题—因为你已经了解了这些字。在这种状况下,你与作者就是已经找出共通的词义,达成共识了。只有那些还未达成共识的地方,还需要你的努力。\n3.专门用语及特殊字汇 # 到目前为止,我们谈的都是消极地排除日常用语的方法。事实上,你也会发现一些对你来说并不是日常用语的字,因而发现那是一些重要的字眼。这也是为什么这些字眼会困扰到你。但是,是否有其他方法能找出重要的字眼?是否有更积极的方法能找出这些关键字?\n确实有几个方法。第一个,也是最明显的信号是,作者开诚布公地强调某些特定的字,而不是其他的字。他会用很多方法来做这件事。他会用不同的字体来区分,如加括号,斜体字等记号以提醒你。他也会明白地讨论这些字眼不同的意义,并指出他是如何在书中使用这些不同的字义,以引起你对这些字的注意。或是他会借着这个字来命名另外一个东西的定义,来强调这个字。\n如果一个人不知道在欧几里得的书中,“点”、“线”、“面”、“角”、“平行线”等是最重要的字眼,他就无法阅读欧几里得的书了。这些字都是欧几里得为几何学所定义的一些东西的名称。还有另外一些重要的字,像是“等于”、“整体”、“部分”等,但这些字都不是任何定义的名称。你因为从定理中看到这些字眼而知道是重要的字。欧几里得在一开始就详述了这些主要的定理,以便帮助你了解书的内容。你可以猜到描述这些定理的词义都是最根本的,而那些底下划了线的单字,就是这些词义。你对这些单字可能不会有什么问题,因为都是一般口语里使用的单字,而欧几里得似乎就是想这样使用这些字的。\n你可能会说,如果每个作者都像欧几里得一样,阅读这件事没什么困难嘛!当然,这是不可能的—尽管有人认为任何主题都能用几何的方法来详细叙述。在数学上行得通的步骤—叙述和证明的方法—不一定适用于其他领域的知识。但无论如何,我们只要能指出各种论述的共通点是什么就够了。那就是每一个知识领域都有独特的专门用语(technical vocabulary)。欧几里得一开头就将这些用语说明得一清二楚。其他用几何方法写作的作者,像伽利略或牛顿也都是如此。其他领域,或用其他不同写法写的书,专门用语就得由读者自己找出来了。\n如果作者自己没有指出来,读者就要凭以往对这个主题的知识来寻找。如果他在念达尔文或亚当‘斯密的作品之前,有一些生物学或经济学的知识,当然比较容易分辨出其中的专门用语。分析一本书的架构的规则,这时可能帮得上忙。如果你知道这是什么种类的书,整本书在谈的主题是什么,有哪些重要的部分,将大大帮助你把专门用语从一般用语中区分出来。作者的书名、章节的标题、前言,在这方面也都会有些帮助。\n举例来说,这样你就可以明白对亚当·斯密而言,“财富”就是专门用语,“物种”则是达尔文的专门用语。因为一个专门用语会带出另一个专门用语,你只能不断地发现同样形式的专门用语。你很快就能将亚当·斯密所使用的重要字眼列出来了:劳工、资本、土地、薪资、利润、租金、商品、价格、交易、成品、非成品、金钱等等。有些字则是在达尔文的书中你一定不会错过的:变种、种属、天择、生存、适应、杂种、适者、宇宙。\n某些知识领域有一套完整的专门用语,在一本这种主题的书中找出重要的单字,相形之下就很容易了。就积极面来说,只要熟悉一下那个领域,你就能找出这些专门的单字;就消极面来说,你只要看到不是平常惯见的单字,就会知道那些字一定是专门用语。遗憾的是,许多领域都并未建立起完善的专门用语系统。\n哲学家以喜欢使用自己特有的用语而闻名。当然,在哲学领域中,有一些字是有着传统涵义的。虽然不见得每个作者使用这些字的时候意思都相同,但这些字讨论某些特定问题的时候,还是一些专门用语。可是哲学家经常觉得需要创造新字,或是从日常用语中找出一些字来当作是专门用语。后者常会误导读者,因为他会以为自己懂得这个字义,而把它当作是日常用语。不过,大多数好的作者都能预见这样的困扰,只要出现这样的字义时,都会事先做详尽的说明解释。\n另外一个线索是,作者与其他作者争执的某个用语就是重要的字。当你发现一位作者告诉你某个特定的字曾经被其他人如何使用,而他为什么选择不同的用法时,你就可以知道这个字对他来说意义非凡。\n在这里我们强调的是专门用语的概念,但你绝不要把它看得太狭隘了。作者还有些用来阐述自己主旨及重要概念,数量相对而言比较少的特殊用语(special vocabulary)。这些字眼是他要作分析与辩论时用的。如果他想要作最初步的沟通,其中有一些字他会用很特殊的方法来使用,而另外一些字则会依照这个领域中传统的方法来运用。不论是哪一种情况,这些字对他来说都重要无比。而对身为读者的你来说,应该也同样重要才对。除此之外,任何其他字义不明的字,对你也很重要。\n大多数读者的问题,在于他们根本就不太注意文字,找不出他们的困难点。他们区分不出自己很明白的字眼与不太明白的字眼。除非你愿意努力去注意文字,找出它们所传递的意义,否则我们所建议帮助你在一本书里找出重要字句的方法就一点用也没有了。如果读者碰到一个不了解的字不愿意深思,或至少作个记号,那他不了解的这个字就一定会给他带来麻烦。\n如果你在读一本有助于增进理解力的书,那你可能无法了解这本书里的每一个字,是很合理的。如果你把它们都看作是日常用语,像是报纸新闻那样容易理解的程度,那你就无法进一步了解这本书了。你会变成看书就像在看报纸一样—如果你不试着去了解一本书,这本书对你就一点启发也没有了。\n大多数人都习惯于没有主动的阅读。没有主动的阅读或是毫无要求的阅读,最大的问题就在读者对字句毫不用心,结果自然无法跟作者达成共识了。\n4.找出字义 # 找出重要的关键字只是开始的工作。那只是在书中标明了你需要努力的地方而已。这第五个阅读规则还有另一个部分。让我们来谈谈这个部分的问题吧!假设你已经将有问题的字圈出来了,接下来怎么办?\n有两种主要的可能:一是作者在全书每个地方用到这个字眼的时候都只有单一的意义,二是同一个字他会使用两三种意义,在书中各处不断地变换字义。第一种情况,这个单字代表着单一的词义。使用关键字都局限于单一意义的例子,最出名的就是欧几里得。第二种情况,那些单字就代表着不同的词义。\n要了解这些不同的状况,你就要照下面的方法做:首先,要判断这个字是有一个还是多重意义。如果有多重意义,要看这些意义之间的关系如何。最后,要注意这些字在某个地方出现时,使用的是其中哪一种意义。看看上下文是否有任何线索,可以让你明白变换意义的理由。最后这一步,能让你跟得上字义的变化,也就是跟作者在使用这些字眼时一样变化自如。\n但是你可能会抱怨,这样什么都清楚了,可是什么也不清楚了。你到底要怎样才能掌握这许多不同的意思呢?答案很简单,但你可能不满意。耐心与练习会让你看到不同的结果。答案是:你一定要利用上下文自己已经了解的所有字句,来推敲出你所不了解的那个字的意义。不论这个方法看起来多么像是在绕圈子,但却是惟一的方法。\n要说明这一点,最简单的方法就是看定义的例子。定义是许多字组合起来的。如果你不了解其中任何一个字,你就无法了解为这些定义内容而取名的那个字的意思了。“点”是几何学中基本的字汇,你可以认为自己知道这个字的用法(在几何学中),但欧几里得想要确定你只能以惟一的意义来使用这个字。他为了让你明白他的意思,一开始就把接下来要取名为“点”的这个东西详加定义。他说:“点,不含有任何部分。”(A point is that which has no part.)\n这会怎样帮助你与他达成共识呢?他假设,你对这句话中的其他每一个字都了解得非常清楚。你知道任何含有“部分”的东西,都是一个复杂的“整体\u0026quot;(whole)。你知道复杂的相反就是简单。要简单就是不要包含任何部分,你知道因为使用了“是”(is)和“者\u0026quot;(that\nwhich)这些字眼,所指的东西一定是某种“个体”(entity)。顺便一提的是,依此类推,如果没有任何一样实体东西是没有“部分”的,那么欧几里得所谈的“点”,就不可能是物质世界中的个体。\n以上的说明,是你找出字义的一个典型过程。你要用自己已经了解的一些字义来运作这个过程。如果一个定义里的每个字都还需要去定义时,那没有任何一个东西可以被定义了。如果书中每个字对你来说都陌生无比,就像你在读一本完全陌生的外文书一样的话,你会一点进展也没有。\n这就是一般人所说的,这本书读起来就像是希腊文的意思。如果这本书真的是用希腊文写的,可能这样说还公平一些。但他们只是不想去了解这本书,而不是真的看到了希腊文。任何一本书中的字,大部分都是我们所熟悉的。这些熟悉的字围绕着一些陌生的字,一些专门用语,一些可能会给读者带来困扰的字。这些围绕着的字,就是用来解读那些不懂的字的上下文。读者早就有他所需要的材料来做这件事了。\n我们并不是要假装这是一件很容易的事。我们只是坚持这并不是做不到的事。否则,没有任何人能借着读书来增进理解力。事实上,一本书之所以能给你带来新的洞察力或启发,就是因为其中有一些你不能一读即懂的字句。如果你不能自己努力去了解这些字,那就不可能学会我们所谈的这种阅读方法。你也不可能作到自己阅读一本书的时候,从不太了解进展到逐渐了解的境界。\n要做到这件事,没有立竿见影的规则。整个过程有点像是在玩拼图时尝试错误的方法。你所拼起来的部分越多,越容易找到还没拼的部分,原因只不过剩下的部分减少了。一本书出现在你面前时,已经有一堆各就各位的字。一个就位的字就代表一个词义。当你和作者用同样一个意思来使用这个字的时候,这个字就因为这个意思而被定位了。剩下的那些字也一定要找到自己的位置。你可以这样试试,那样试试,帮它们找到自己的定位。你越了解那些已经就位的文字所局部透露的景象,就越容易和剩余的文字找出共通的词义来拼好全景。每个字都找到定位,接下来的调整就容易多了。\n当然,这当中你一定会出错的。你可能以为自己已经找到某个字的归属位置与意义,但后来才发现另外一个字更适合,因而不得不整体重作一次调整。错误一定会被更正的,因为只要错误还没有被发现,整个全图就拼不出来。一旦你在这样的努力中有了找出共通词义的经验后,你很快就有能力检验自己了。你会知道自己成功了没有。当你还不了解时,你再也不会漫不经心地自以为已经了解了。\n将一本书比作拼图,其中有一个假设其实是不成立的。当然,一个好的拼图是每个部分都吻合全图的。整张图形可以完全拼出来。理想上一本好书也该是如此,但世界上并没有这样一本书。只能说如果是好书,作者会把所有的词义都整理得很清楚,很就位,以便读者能充分理解。这里,就像我们谈过的其他阅读规则一样,坏书不像好书那样有可读性。除了显示它们有多坏之外,怎么阅读它们这些规则完全帮不上。如果作者用字用得模糊不清,你根本就搞不清楚他说的是什么。你只会发现他并不知道自己说的是什么。\n但是你会问了,如果一个作者使用一个字的多重意义,难道就不是用字用得模糊不清吗?作者使用一个字,特别是非常重要的字时,包含多重意义不是很平常的事吗?\n第一个问题的答案是:不是。第二个答案是:没错。所谓用字模糊不清,是使用这个字的多重意义时,没有区别或指出其中相关的意义。(譬如我们在这一章使用“重要”这个词的时候可能就有模糊不清的现象,因为我们并没有清楚强调这是对作者来说很重要,还是对读者来说很重要。)作者这么做,就会让读者很难与他达成共识,但是作者在使用某个重要的字眼时,如果能区别其中许多不同的意义,让读者能据以辨识,那就是和读者达成共识了。\n你不要忘了一个单字是可以代表许多不同词义的。记住这件事的一个方法,是区分作者的用语(vocabulary)与专业术语(terminology)之间的不同。如果你把重要的关键字列出一张清单,再在旁边一栏列出这些字的重要意义,你就会发现用语与专业术语之间的关系了。\n另外还有一些更复杂的情况。首先,一个可以有许多不同意义的字,在使用的时候可以只用其中一个意义,也可以把多重意义合起来用。让我们再用“阅读”来当例子。在本书某些地方,我们用来指阅读任何一种书籍。在另一些地方,我们指的是教导性的阅读,而非娱乐性的阅读。还有一些其他地方,我们指的更是启发性的阅读,而非只是获得资讯。\n现在我们用一些符号来比喻,就像前面所做的,这三种不同意思的阅读,就分别是Xa,Xb及Xc。第一个地方所指的阅读是Xabc,第二个地方是Xbc,第三个是Xc。换句话说,如果这几个意思是相关的,那我们可以用一个字代表所有的状况,也可以代表部分的状况,或只是一种状况。只要把每一种用法都区分清楚,每次使用这个字就有一个不同的词义。\n其次,还有同义字的问题。除非是数学的作品,否则一个同样的字使用了一遍又一遍,看起来很别扭又无趣。因此许多好作者会在书中使用一些意义相同或是非常相似的不同的字,来代替行文中那些重要的字眼。这个情况跟一个字能代表多重意义的状况刚好相反,在这里,同一个词义,是由两个以上的同义字所代表的。\n接下来我们要用符号来解释这个问题。假设X跟Y是不同的两个字,譬如说是“启发”与“领悟”。让a代表这两个字都想表达的一个意思,譬如说“理解力的增进”,那么Xa与Ya虽然字面不同,代表的却是同样的词义。我们说阅读让我们“领悟”,或说阅读给我们“启发”,说的是同样的一种阅读。因为这两个句子说的是同样的意义。字面是不同的,但你要掌握的词义却只有一种。\n当然,这是非常重要的。如果你以为作者每次更换字眼就更换了词义,那就和你以为他每次使用同一个字都用的是同一个词义一样,犯了大错。当你将作者的用语与专业术语分别记下来的时候,要把这一点放在心上。你会发现两种关系。一种是单一个字可能与好几个词义有关,而一个词义也可能与好几个字有关。\n第三点,也是最后一点,就是片语(phrase)的问题。如果一个片语是个独立的单位,也就是说它完整,可以当一个句子的“主语\u0026quot;(subject)或“谓语\u0026quot;(predicate),那就可以把它当一个单一的字来看。这个片语就像单一的字一样可以用来形容某件事。\n因此,一个词义,可以只用一个字,也可以用一个片语来表达。所有单字与词义之间的关系,都成立于片语与词义之间的关系。两个片语所代表的可能是同一个词义,一个片语也可能表达好几个词义,这完全要看组成片语的字是如何应用的。\n一般说来,一个片语比较不会像单一的字那么容易产生模糊不清的情况。因为那是一堆字的组合,上下文的字都互相有关联,因而单个的字的意思都比较受局限。这也是为什么当作者想确定读者能充分了解他意思的时候,会喜欢用比较细致的片语来取代单字的原因。\n再作一个说明就应该很清楚了。为了确定你跟我们对于阅读这件事达成了共识,我们用类似“启发性的阅读”的句子来代替“阅读”这两个字。为了更确定清楚,我们又用了类似“如何运用你的心智来阅读一本书,也就是如何让自己从不太理解到逐渐理解的一个过程”的长句子来说明一个词义,这个词义也就是本书最强调的一种阅读。但这个词义却分别用了一个字、一个片语及一个长句子来作说明。\n这是很难写的一章,可能也是很难读的一章。原因很清楚。如果我们不用一些文法与逻辑的字眼来说明文字与词义之间的关系,我们所讨论的阅读规则就没办法让你完全清楚地理解。\n事实上,我们所谈的只是其中的一小部分。如果要完全说清楚可能要花上许多章的篇幅。我们只是将最核心部分说明清楚了。我们希望我们的说明足以在你练习时提供有用的指导。你练习得越多,越会感激那些错综复杂的问题。你也会想知道一些文学与隐喻的用字方法,抽象与具象字眼之区别,以及特殊名称与普通名称之分。你也会对所谓定义这件事感兴趣:定义一个字和定义一件事的差别是什么?为什么有些字无法定义的,却有明确的意义,等等等等。你会想要找出所谓“文字的情绪性用途”是什么意思?那就是运用文字唤醒情绪,感动一个人采取行动,或是改变思想,这是与传达知识不同的用途。你甚至会有兴趣了解日常“理性”(rational)的谈话,与“情绪性”(bizarre)或“疯狂”(crazy)的对话有何不同—后两种谈话是精神状态受到干扰,使用的每个字都很怪异,出乎意外,却又有清楚的弦外之音。\n如果因为练习分析阅读而引发你的兴趣,你可以利用这种阅读多读一点和这些主题相关的书。在阅读这些书时,你会获得更多的好处,因为你是在阅读的经验中,提出了自己的问题而去找这些书的。文法与逻辑学,是架构以上这些规则的基础,如果你想研究这两门学问,必须实际运用才有用。\n你也可能并不想再研究下去。就算你不想,只要你肯花一点精神,在读一本书的时候,找出重要的关键字,确认每个字不同意义的转换,并与作者找出共通的词义,你对一本书的理解力就会大大增加了。很少有一些习惯上的小小改变,会产生如此宏大的效果。\n第九章 判断作者的主旨 # 书的世界与生意的世界一样,不但要懂得达成共识,还要懂得提案。买方或卖方的提案是一种计划、一种报价或承诺。在诚实的交易中,一个人提案,就是声明他准备依照某种模式来做事的意图。成功的谈判协商,除了需要诚实外,提案还要清楚,有吸引力。这样交易的双方才能够达成共识。\n书里的提案,也就是主旨,也是一种声明。那是作者在表达他对某件事的判断。他断言某件他认为是真的事,或否定某件他判断是假的事。他坚持这个或那个是事实。这样的提案,是一种知识的声明,而不是意图的声明。作者的意图可能在前言的一开头就告诉我们了。就一部论说性的作品来说,通常他会承诺要指导我们做某件事。为了确定他有没有遵守这些承诺,我们就一定要找出他的主旨(propositions)才行。\n一般来说,阅读的过程与商业上的过程正好相反。商人通常是在找出提案是什么后,才会达成共识。但是读者却要先与作者达成共识,才能明白作者的主旨是什么,以及他所声明的是什么样的判断。这也是为什么分析阅读的第五个规则会与文字及词义有关,而第六个,也就是我们现在要讨论的,是与句子及提案有关的规则。\n第七个规则与第六个规则是息息相关的。一位作者可能借着事件、事实或知识,诚实地表达自己的想法。通常我们也是抱着对作者的信任感来阅读的。但是除非我们对作者的个性极端感兴趣,否则只是知道他的观点并不能满足我们。作者的主旨如果没有理论的支持,就只是在抒发个人想法罢了。如果是这本书、这个主题让我们感兴趣,而不是作者本身,那么我们不只想要知道作者的主张是什么,还想知道为什么他认为我们该被说服,以接受这样的观点。\n因此,第七个规则与各种论述(arguments)有关。一种说法总是受到许多理由、许多方法的支持。有时候我们可以强力主张真实,有时候则顶多谈谈某件事的可能。但不论哪种论点都要包含一些用某种方式表达的陈述。“因为”那样,所以会说这样。“因为”这两个字就代表了一个理由。\n表达论述时,会使用一些字眼把相关的陈述联系起来,像是:“如果”真是如此,“那么”就会那样。或“因为”如此,“所以”那样。或“根据”这个论述,那就会如此这般。在本书较前面的章节中,也出现这种前后因果相关的句子。因为对我们这些离开学校的人来说,我们了解到,如果我们还想要继续学习与发现,就必须知道如何能让一本书教导我们。在那样的情况中,“如果”我们想要继续学习,“那么”我们就要知道如何从书中,从一个不在我们身边的老师那儿学习。\n一个论述总是一套或一连串的叙述,提供某个结论的根据或理由。因此,在说明论点时,必须要用到一段文字,或至少一些相关的句子来阐述。一开始可能不会先说论点的前提或原则,但那却是结论的来源。如果这个论述成立,那么结论一定是从前提中推演出来的。不过这么说也并不表示这个结论就一定真实,因为可能有某个或所有的前提假设都是错的。\n我们说明这些规则的顺序,都是有文法与逻辑的根据的。我们从共识谈到主旨,再谈到论点,表达的方法是从字(与词)到一个句子,再到一连串的句子(或段落)来作说明。我们从最简单的组合谈到复杂的组合。当然,一本书含有意义的最小单位就是“字”。但是如果说一本书就是一连串字的组合,没有错,却并不恰当。书中也经常把一组组的字,或是一组组的句子来当单位。一个主动的读者,不只会注意到字,也会注意到句子与段落。除此之外,没有其他方法可以发现一个作者的共识、主旨与论点。\n我们把分析阅读谈到这里时—目的是在诠释作者的意图—似乎和第一个阶段的发展方向背道而驰—第一阶段的目的是掌握结构大纲。我们原先从将一本书当作是个整体,谈到书中的主要部分,再谈到次要的部分。不过你可能也猜得到,这两种方法会有交集点。书中的主要部分,与主要的段落都包含了许多主旨,通常还有许多论点。如果你继续将一本书细分成许多部分,最后你会说:“在这一部分,导引出来了下面这些重点。”现在,每一个重点都像是主旨,而其中有一些主旨可能还组成一个论述。\n因此,这两个过程,掌握大纲与诠释意图,在主旨与论述的层次中互相交集了。你将一本书的各个部分细分出来,就可以找出主旨与论述。然后你再仔细分析一个论述由哪些主旨,甚至词义而构成。等这两个步骤你都完成时,就可以说是真的了解一本书的内容了。\n1.句子与主旨 # 我们已经提到,在这一章里,我们还会讨论与这个规则有关的其他的事。就像关于字与共识的问题一样,我们也要谈语言与思想的关系。句子与段落是文法的单位、语言的单位。主旨与论述是逻辑的单位,也就是思想与知识的单位。\n我们在这里要面对的问题,跟上一章要面对的问题很相似。因为语言并不是诠释思想最完美的媒介;因为一个字可以有许多意义,而不只一个字也可能代表同一种的意义,我们可以看出一个作者的用语与专业术语之间的关系有多复杂了。一个字可能代表多重的意思,一个意思也可能以许多字来代表。\n数学家将一件上好的外套上的纽扣与纽扣洞之间,比喻成一对一的关系。每一个纽扣有一个适合的纽扣洞,每一个纽扣洞也有一个适合的纽扣。不过,重点是:字与意思之间的关系并不是一对一的。在应用这个规则时,你会犯的最大错误就是认为在语言及思想或知识之间,是一对一的关系。\n事实上,聪明一点的做法是,即使是纽扣与纽扣洞之间的关系,也不要作太简单的假设。男人西装外套的袖子上面有纽扣,却没有纽扣洞。外套穿了一阵子,上面也可能只有洞,而没有纽扣。\n让我们说明句子与主旨之间的关系。并不是一本书中的每一句话都在谈论主旨。有时候,一些句子在表达的是疑问。他们提出的是问题,而不是答案。主旨则是这些问题的答案。主旨所声明的是知识或观点。这也是为什么我们说表达这种声明的句子是叙述句(declarative),而提出问题的句子是疑问句(interrogative)\no其他有些句子则在表达希望或企图。这些句子可能会让我们了解一些作者的意图,却并不传达他想要仔细推敲的知识。\n除此之外,并不是每一个叙述句都能当作是在表达一个主旨。这么说至少有两个理由。第一个是事实上,字都有歧义,可以用在许多不同的句子中。因此,如果字所表达的意思改变了,很可能同样的句子却在阐述不同的主旨。“阅读就是学习”,这是一句简单的陈述。但是有时候,我们说“学习”是指获得知识,而在其他时候我们又说学习是发展理解力。因为意思并不一样,所以主旨也都不同。但是句子却是相同的。\n另一个理由是,所有的句子并不像“阅读就是学习”这样单纯。当一个简单的句子使用的字都毫无歧义时,通常在表达的是一个单一的主旨。但就算用字没有歧义,一个复合句也可能表达一个或两个主旨。一个复合句其实是一些句子的组合,其间用一些字如“与”、“如果……就”或“不但……而且”来作连接。你可能会因而体认到,一个复合句与一小段文章段落之间的差异可能很难区分。一个复合句也可以用论述方式表达许多不同的主旨。\n那样的句子可能很难诠释。让我们从马基雅维里(Niccolo Machiavelli)的《君主论》(The Prince)中找一段有趣的句子来作说明:\n一个君王就算无法赢得人民的爱戴,也要避免憎恨,以唤起人民的敬畏;因为只要他不剥夺人民的财产与女人,他就不会被憎恨,也就可以长长久久地承受人民的敬畏。在文法上来说,这是一个单一的句子,不过却十分复杂。分号与“因为”是全句的主要分段。第一个部分的主旨是君王应该要以某种方法引起人民的敬畏。\n而从“因为”开始,事实上是另一句话。(这也可以用另一种独立的叙述方式:“他之所以能长久承受人民敬畏,原因是……”等等。)这个句子至少表达了两个主旨:(1)一个君王应该要引起人民敬畏的原因是,只要他不被憎恨,他就能长长久久地被人民敬畏着。(2)要避免被人民憎恨,他就不要去剥夺人民的财产与女人。\n在一个又长又复杂的句子里,区分出不同的主旨是很重要的。不论你想要同意或不同意马基雅维里的说法,你都要先了解他在说的是什么意思。但是在这个句子中,他谈到的是三件事。你可能不同意其中的一点,却同意其他两点。你可能认为马基雅维里是错的,因为他在向所有的君王推广恐怖主义。但你可能也注意到他精明地说,最好不要让人民在敬畏中带有恨意。你可能也会同意不要剥夺人民的财产与女人,是避免憎恨的必要条件。除非你能在一个复杂句中辨认出不同的主旨,否则你无法判断这个作者在谈些什么。\n律师都非常清楚这个道理。他们会仔细看原告陈述的句子是什么,被告否认的说法又是什么。一个简单的句子:“约翰·唐签了三月二十四日的租约。”看起来够简单了,但却说了不只一件事,有些可能是真的,有些却可能是假的。约翰·唐可能签了租约,但却不是在三月二十四日,而这个事实可能很重要。简单来说,就算一个文法上的单一句子,有时候说的也是两个以上的主旨。\n在区分句子与主旨之间,我们已经说得够清楚了。它们并不是一对一的关系。不只是一个单一的句子可以表达出不同的主旨,不管是有歧义的句子或复合句都可以,而且同一个主旨也能用两个或更多不同的句子来说明。如果你能抓住我们在字里行间所用的同义字,你就会知道我们在说:“教与学的功能是互相连贯的”与“传授知识与接受知识是息息相关的过程”这两句话时,所谈的是同一件事。\n我们不再谈文法与逻辑相关的重点,而要开始谈规则了。在这一章里,就跟上一章一样,最难的就是要停止解释。无论如何,我们假设你已经懂一点文法了。我们并不是说你一定要完全精通语句结构,但你应该注意一个句子中字的排列顺序,与彼此之间的关系。对一个阅读者来说,有一些文法的知识是必要的。除非你能越过语言的表象,看出其中的意义,否则你就无法处理有关词义、主旨与论述—思想的要素—的问题。只要文字、句子与段落是不透明的、未解析的,他们就是沟通的障碍,而不是媒介。你阅读了一些字,却没有获得知识。\n现在来谈规则。你在上一章已经看到第五个规则了:找出关键字,与作者达成共识。第六个规则可以说是:将一本书中最重要的句子圈出来,找出其中的主旨。第七个规则是:从相关文句的关联中,设法架构出一本书的基本论述。等一会儿你会明白,在这个规则中,我们为什么不用“段落”这样的字眼。\n顺便一提的是,这些新规则与前面所说的与作者达成共识的规则一样,适用于论说性的作品。当你在念一本文学作品—小说、戏剧与诗时,这些关于主旨与论述的规则又大不相同。后面我们会谈到在应用时要如何作些改变,以便阅读那些书籍。\n2.找出关键句 # 在一本书中,最重要的句子在哪里?要如何诠释这些句子,才能找到其中包含的一个或多个主旨?\n再一次,我们的重点在于挑出什么才是重要的。我们说一本书中真正的关键句中只有少数的几句话,并不是说你就可以忽略其他的句子。当然,你应该要了解每一个句子。而大多数的句子,就像大多数的文字一样,对你来说都是毫无困难的。我们在谈速读时提到过,在读这些句子时可以相当快地读过去。从一个读者的观点来看,对你重要的句子就是一些需要花一点努力来诠释的句子,因为你第一眼看到这些句子时并不能完全理解。你对这些句子的理解,只及于知道其中还有更多需要理解的事。这些句子你会读得比较慢也更仔细一点。这些句子对作者来说也许并不是最重要的,但也很可能就是,因为当你碰到作者认为最重要的地方时,应该会特别吃力。用不着说,你在读这些部分 时应该特别仔细才好。\n从作者的观点来看,最重要的句子就是在整个论述中,阐述作者判断的部分。一本书中通常包含了一个以上或一连串的论述。作者会解释为什么他现在有这样的观点,或为什么他认为这样的情况会导致严重的后果。他也可能会讨论他要使用的一些字眼。他会批评别人的作品。他会尽量加人各种相关与支持的论点。但他沟通的主要核心是他所下的肯定与否定的判断.以及他为什么会这么做的理由。因此,要掌握住重点,就要从文章中看出浮现出来的重要句子。\n有些作者会帮助你这么做。他们会在这些字句底下划线。他们不是告诉你说这些是重点,就是用不同的印刷字体将主要的句子凸显出来。当然,如果你阅读时昏昏沉沉的,这些都帮不上忙了。我们碰到过许多读者或学生,根本不注意这些已经弄得非常清楚的记号。他们只是一路读下去,而不肯停下来仔细地观察这些重要的句子。\n有少数的书会将主旨写在前面,用很明显的位置来加以说明。欧几里得就给了我们一个最明显的例子。他不只一开始就说明他的定义,假设及原理—他的基本主旨—同时还将每个主旨都加以证明。你可能并不了解他的每一种说法,也可能不同意他所有的论点,但你却不能不注意到这些重要的句子,或是证明他论述的一连串句子。\n圣托马斯·阿奎那写的《神学大全》(Summa Theologica),解说重要句子的方式也是将这些重点特别凸显出来。他用的方式是提出问题。在每一个段落的开始会先提出问题来。这些问题都暗示着阿奎那想要辩解的答案,且包括了完全相对立的说法。阿奎那想要为自己的想法辩护时,会用“我的回答”这样的句子标明出来。在这样的书—既说明理由,又说出结论的书中,没有理由说看不到重要的句子。但是对一些把任何内容都同等重视的读者来说,这样的书还是一团迷雾。他们在阅读时不管是快或慢,都以同样的速度阅读全书。而这通常也意味着所有的内容都不太重要。\n除了这些特别标明重点、提醒读者注意哪些地方很需要诠释的书之外,找出重要的句子其实是读者要替自己做的工作。他可以做的事有好几件。我们已经提过其中一件了。如果他发现在阅读时,有的一读便懂,有的却难以理解,他就可以认定这个句子是含有主要的意义了。或许你开始了解了,阅读的一部分本质就是被困惑,而且知道自己被困惑。怀疑是智慧的开始,从书本上学习跟从大自然学习是一样的。如果你对一篇文章连一个问题也提不出来,那么你就不可能期望一本书能给你一些你原本就没有的视野。\n另一个找出关键句的线索是,找出组成关键句的文字来。如果你已经将重要的字圈出来了,它一定会引导你看到值得注意的句子。因此在诠释阅读法中,第一个步骤是为第二个步骤作准备的。反之亦然。很可能你是因为对某些句子感到困惑,而将一些字作上记号的。事实上,虽然我们在说明这些规则时都固定了前后的顺序,但你却不一定要依照这个顺序来阅读。词义组成了主旨,主旨中又包含了词汇。如果你知道这个字要表达的意思,你就能抓住这句话中的主旨了。如果你了解了一句话要说明的主旨,你也就是掌握了其中词义的意思。\n接下来的是更进一步找出最主要的主旨的线索。这些主旨一定在一本书最主要的论述中—不是前提就是结论。因此,如果你能依照顺序找出这些前后相关的句子—找出有始有终的顺序,你可能就已经找到那些重要的关键句子了。\n我们所说的顺序,要有始有终。任何一种论述的表达,都需要花点时间。你可以一口气说完一句话,但你要表达一段论述的时候却总要有些停顿。你要先说一件事,然后说另一件事,接下来再说另一件事。一个论述是从某处开始,经过某处,再到达某处的。那是思想的演变移转。可能开始时就是结论,然后再慢慢地将理由说出来。也可能是先说出证据与理由,再带引你达到结论。\n当然,这里还是相同的道理:除非你知道怎么运用,否则线索对你来说是毫无用处的。当你看到某个论述时,你要去重新整理。虽然有过一些失望的经验,我们仍然相信,人类头脑看到论述时之敏感,一如眼睛看到色彩时的反应。(当然,也可能有人是“论述盲”的!)但是如果眼睛没有张开,就看不到色彩。头脑如果没有警觉,就无法察觉论述出现在哪里了。\n许多人认为他们知道如何阅读,因为他们能用不同的速度来阅读。但是他们经常在错误的地方暂停,慢慢阅读。他们会为了一个自己感兴趣的句子而暂停,却不会为了感到困扰的句子而暂停。事实上,在阅读非当代作品时,这是最大的障碍。一本古代的作品包含的内容有时很令人感到新奇,因为它们与我们熟知的生活不同。但是当你想要在阅读中获得理解时,你要追寻的就不是那种新奇的感觉了。一方面你会对作者本身,或对他的语言,或他使用的文字感兴趣,另一方面,你想要了解的是他的思想。就因为有这些原因,我们所讨论的规则是要帮助你理解一本书,而不是满足你的好奇心。\n3.找出主旨 # 假设你已经找到了重要的句子,接下来就是第六个规则的另一个,要求了。你必须找出每个句子所包含的主旨。这是你必须知道句子在说什么的另一种说法。当你发现一段话里所使用的文字的意义时,你就和作者找到了共识。同样的,诠释过组成句子的每个字,特别是关键字之后,你就会发现主旨。\n再说一遍,除非你懂一点文法,否则没法做好这件事。你要知道形容词与副词的用法,而动词相对于名词的作用是什么,一些修饰性的文字与子句,如何就它们所修饰的字句加以限制或扩大等等。理想上,你可以根据语句结构的规则,分析整个句子。不过你用不着很正式地去做这件事。虽然现在学校中并不太重视文法教学,但我们还是假设你已经懂一点文法了。我们不能相信你不懂这回事,不过在阅读的领域中,可能你会因为缺少练习而觉得生疏。\n在找出文字所表达的意思与句子所阐述的主旨之间,只有两个不同之处。一个是后者所牵涉的内容比较多。就像你要用周边的其他字来解释一个特殊的字一样,你也要借助前后相关的句子来了解那个问题句。在两种情况中,都是从你了解的部分,进展到逐渐了解你原来不懂的部分。\n另一个不同是,复杂的句子通常要说明的不只一个主旨。除非你能分析出所有不同,或相关的主旨,否则你还是没有办法完全诠释一个重要的句子。要熟练地做到这一点,就需要常常练习。试着在本书中找出一些复杂的句子,用你自己的话将其中的主旨写出来。列出号码,找出其间的相关性。\n“用你自己的话来说”,是测验你懂不懂一个句子的主旨的最佳方法。如果要求你针对作者所写的某个句子作解释,而你只会重复他的话,或在前后顺序上作一些小小的改变,你最好怀疑自己是否真的了解了这句话。理想上,你应该能用完全不同的用语说出同样的意义。当然,这个理想的精确度又可以分成许多程度。但是如果你无法放下作者所使用的字句,那表示他所传给你的,只是这个“字”,而不是他的“思想或知识”。你知道的只是他的用字,而不是他的思想。他想要跟你沟通的是知识,而你获得的只是一些文字而已。\n将外国语文翻译成英文的过程,与我们所说的这个测验有关。如果你不能用英文的句子说出法文的句子要表达的是什么,那你就知道自己其实并不懂这句法文。就算你能,你的翻译可能也只停留在口语程度—因为就算你能很精确地用英文复述一遍,你还是可能不清楚法文句子中要说明的是什么。\n要把一句英文翻译成另一种语文,就更不只是口语的问题了。你所造出来的新句子,并不是原文的口语复制。就算精确,也只是意思的精确而已。这也是为什么说如果你想要确定自己是否吸收了主旨,而不只是生吞活剥了字句,最好是用这种翻译来测试一下。就算你的测验失败了,你还是会发现自己的理解不及在哪里。如果你说你了解作者在说些什么,却只能重复作者所说过的话,那一旦这些主旨用其他字句来表达时,你就看不出来了。\n一个作者在写作时,可能会用不同的字来说明同样的主旨。读者如果不能经由文字看出一个句子的主旨,就容易将不同的句子看作是在说明不同的主旨。这就好像一个人不知道2+2=4跟4-2=2虽然是不同的算式,说明的却是同一个算术关系—这个关系就是四是二的双倍,或二是四的一半。\n你可以下结论说,这个人其实根本不懂这个问题。同样的结论也可以落在你身上,或任何一个无法分辨出用许多相似句子说明同一个主旨的人,或是当你要他说出一个句子的主旨时,他却无法用自己的意思作出相似的说明。\n这里已经涉及主题阅读—就同一个主题,阅读好几本书。不同的作者经常会用不同的字眼诉说同一件事,或是用同样的字眼来说不同的事。一个读者如果不能经由文字语言看出意思与主旨,就永远不能作相关作品的比较。因为口语的各不相同,他会误以为一些作者互不同意对方的说法,也可能因为一些作者叙述用语相近,而忽略了他们彼此之间的差异。\n还有另一个测验可以看出你是否了解句中的主旨。你能不能举出一个自己所经历过的主旨所形容的经验,或与主旨有某种相关的经验?你能不能就作者所阐述的特殊情况,说明其中通用于一般的道理?虚构一个例子,跟引述一个真实的例子都行。如果你没法就这个主旨举任何例子或作任何说明,你可能要怀疑自己其实并不懂这个句子在说些什么。\n并不是所有的主旨都适用这样的测验方法。有些需要特殊的经验,像是科学的主旨你可能就要用实验室来证明你是否明白了。但是主要的重点是很清楚的。主旨并非存在于真空状态,而是跟我们生存的世界有关。除非你能展示某些与主旨相关的,实际或可能的事实,否则你只是在玩弄文字,而非理解思想或知识。\n让我们举一个例子。在形上学中,一个基本的主旨可以这样说明:“除了实际存在的事物,没有任何东西能发生作用。”我们听到许多学生很自满地向我们重复这个句子。他们以为只要以口语完美地重复这个句子,就对我们或作者有交待了。但是当我们要他们以不同的句子说明这句话中的主旨时,他们就头大了。很少有人能说出:如果某个东西不存在,就不能有任何作用之类的话。但是这其实是最浅显的即席翻译—至少,对任何一个懂得原句主旨的人来说,是非常浅显的。\n既然没有人能翻译出来,我们只好要他们举出一个主旨的例证。如果他们之中有人能说出:只靠可能会卞的雨滴,青草是不会滋长的;或者,只靠可能有的储蓄,一个人的存款账目是不会增加的。这样我们就知道他们真的抓到主旨了。\n“口语主义”(verbalism)的弊端,可以说是一种使用文字,没有体会其中的思想传达,或没有注意到其中意指的经验的坏习惯。那只是在玩弄文字。就如同我们提出来的两个测验方法所指出的,不肯用分析阅读的人,最容易犯玩弄文字的毛病。这些读者从来就没法超越文字本身。他们只能记忆与背诵所读的东西而已。现代教育家所犯的一个最大的错误就是违反了教育的艺术,他们只想要背诵文字,最后却适得其反。没有受过文法和逻辑艺术训练的人,他们在阅读上的失败—以及处处可见的“口语主义”—可以证明如果缺乏这种训练,会如何成为文字的奴隶,而不是主人。\n4.找出论述 # 我们已经花了很多时间来讨论主旨。现在来谈一下分析阅读的第七个规则。这需要读者处理的是一堆句子的组合。我们前面说过,我们不用“读者应该找出最重要的段落”这样的句子来诠释这条阅读规则,是有理由的。这个理由就是,作者写作的时候,并没有设定段落的定则可循。有些伟大的作家,像蒙田、洛克或普鲁斯特,写的段落奇长无比;其他一些作家,像马基雅维里、霍布斯或托尔斯泰,却喜欢短短的段落。现代人受到报纸与杂志风格的影响,大多数作者会将段落简化,以符合快速与简单的阅读习惯。譬如现在这一段可能就太长了。如果我们想要讨好读者,可能得从“有些伟大的作家”那一句另起一段。\n这个问题不只跟长度有关。还牵涉到语言与思想之间关系的问题。指导我们阅读的第七个规则的逻辑单位,是“论述”—一系列先后有序,其中某些还带有提出例证与理由作用的主旨。如同“意思”之于文字,“主旨”之于句子,“论述”这个逻辑单位也不会只限定于某种写作单位里。一个论述可能用一个复杂的句子就能说明。可能用一个段落中的某一组句子来说明。可能等于一个段落,但又有可能等于好几个段落。\n另外还有一个困难点。在任何一本书中都有许多段落根本没有任何论述—就连一部分也没有。这些段落可能是一些说明证据细节,或者如何收集证据的句子。就像有些句子因为有点离题比较远而属于次要,段落也有这种情况。用不着说,这部分可以快快地读过去。\n因此,我们建议第七个规则可以有另一个公式:如果可以,找出书中说明重要论述的段落。但是,如果这个论述并没有这样表达出来,你就要去架构出来。你要从这一段或那一段中挑选句子出来,然后整理出前后顺序的主旨,以及其组成的论述。\n等你找到主要的句子时,架构一些段落就变得很容易了。有很多方法可试。你可以用一张纸,写下构成一个论述的所有主旨。通常更好的方法是,就像我们已经建议过的,在书的空白处作上编号,再加上其他记号,把一些应该排序而读的句子标示出来。\n读者在努力标示这些论述的时候,作者多少都帮得上一点忙。一个好的论说性书籍的作者会想要说出自己的想法,而不是隐藏自己的想法。但并不是每个好作者用的方法都一模一样。像欧几里得、伽利略、牛顿(以几何学或数学方式写作的作者),就很接近这样的想法:一个段落就是一个论述。在非数学的领域中,大多数作者不是在一个段落里通常会有一两个以上的论点,就是一个论述就写上好几段。\n一本书的架构比较松散时,段落也比较零乱。你经常要读完整章的段落,才能找出几个可供组合一个论述的句子。有些书会让你白费力气,有些书甚至不值得这么做。\n一本好书在论述进行时会随时作摘要整理。如果作者在一章的结尾为你作摘要整理,或是摘在某个精心设计的部分,你就要回顾一下刚才看的文章,找出他作摘要的句子是什么。在《物种起源》中,达尔文在最后一章为读者作全书的摘要,题名为“精华摘要与结论”。看完全书的读者值得受到这样的帮助。没看过全书的人,可就用不上了。\n顺便一提,如果在进行分析阅读之前,你已经浏览过一本书,你会知道如果有摘要,会在哪里。当你想要诠释这本书时,你知道如何善用这些摘要。\n一本坏书或结构松散的书的另一个征兆是忽略了论述的步骤。有时候这些忽略是无伤大雅,不会造成不便,因为纵使主旨不清楚,读者也可以借着一般的常识来补充不足之处。但有时候这样的忽略却会产生误导,甚至是故意的误导。一些演说家或宣传家最常做的诡计就是留下一些未说的话,这些话与他们的论述极为有关,但如果说得一清二楚,可能就会受到挑战。我们并不担心一位想要指导我们的诚恳的作者使用这样的手法。但是对一个用心阅读的人来说,最好的法则还是将每个论述的步骤都说明得一清二楚。\n不论是什么样的书,你身为读者的义务都是一样的。如果这本书有一些论述,你应该知道是些什么论述,而能用简洁的话说出来。任何一个好的论述都可以作成简要的说明。当然,有些论述是架构在其他的论述上。在精细的分析过程中,证实一件事可能就是为了证实另一件事。而这一切又可能是为了作更进一步的证实。然而,这些推理的单位都是一个个的论述。如果你能在阅读任何一本书时发现这些论述,你就不太可能会错过这些论述的先后顺序了。\n你可能会抗议,这些都是说来容易的事。但是除非你能像一个逻辑学家那样了解各种论述的架构,否则当作者并没有在一个段落中说明清楚这论述时,谁能在书中找出这些论述,更别提要架构出来?\n这个问题的答案很明显,对于论述,你用不着像是一个逻辑学者一样来研究。不论如何,这世上只有相对少数的逻辑学者。大多数包含着知识,并且能指导我们的书里,都有一些论述。这些论述都是为一般读者所写作的,而不是为了逻辑专家写的。\n在阅读这些书时用不着伟大的逻辑概念。我们前面说过,在阅读的过程中你能让大脑不断地活动,能跟作者达成共识,找到他的主旨,那么你就能看出他的论述是什么了。而这也就是人类头脑的自然本能。\n无论如何,我们还要谈几件事,可能会有助于你进一步应用这个阅读规则。首先,要记住所有的论述都包含了一些声明。其中有些是你为什么该接受作者这个论述的理由。如果你先找到结论,就去看看理由是什么。如果你先看到理由,就找找看这些理由带引你到什么样的结论上。\n其次,要区别出两种论述的不同之处。一种是以一个或多个特殊的事实证明某种共通的概念,另一种是以连串的通则来证明更进一步的共通概念。前者是归纳法,后者是演绎法。但是这些名词并不重要。重点在如何区分二者的能力。\n在科学著作中,看一本书是用推论来证实主张,还是用实验来证实主张,就可以看出两者的区别。伽利略在《两种新科学》中,借由实验结果来说明数学演算早就验证的结论。伟大的生理学家威廉·哈维(William Harvey)在他的书《心血运动论》(On the Motion of\ntheHeart)中写道:“经由推论与实验证明,心室的脉动会让血液流过肺部及心脏,再推送到全身。”有时候,一个主旨是有可能同时被一般经验的推论,及实验两者所支持的。有时候,则只有一种论述方法。\n第三,找出作者认为哪些事情是假设,哪些是能证实的或有根据的,以及哪些是不需要证实的自明之理。他可能会诚实地告诉你他的假设是什么,或者他也可能很诚实地让你自己去发掘出来。显然,并不1是每件事都是能证明的,就像并不是每个东西都能被定义一样。如果每一个主旨都要被证实过,那就没有办法开始证实了。像定理、假设或推论,就是为了证实其他主旨而来的。如果这些其他的主旨被证实了,就可以作更进一步论证的前提了。\n换句话说,每个论述都要有开端。基本上,有两种开始的方法或地方:一种是作者与读者都同意的假设,一种是不论作者或读者都无法否认的自明之理。在第一种状况中,只要彼此认同,这个假设可以是任何东西。第二个情况就需要多一点的说明了。\n近来,不言自明的主旨都被冠上“废话重说\u0026quot;\n(tautology)的称呼。这个说法的背后隐藏着一种对细微末节的轻蔑态度,或是怀疑被欺骗的感觉。这就像是兔子正在从帽子里被揪出来。你对这个事实下了一个定义,然后当他出现时,你又一副很惊讶的样子。然而,不能一概而论。\n譬如在“父亲的父亲就是祖父”,与“整体大于部分”两个主旨之间,就有值得考虑的差异性。前面一句话是自明之理,主旨就涵盖在定义之中。那只是肤浅地掩盖住一种语言的约定:“让我们称父母的父母为祖父母。”这与第二个主旨的情形完全不同。我们来看看为什么会这样。\n“整体大于部分。”这句话在说明我们对一件事的本质,与他们之间关系的了解,不论我们所使用的文字或语言有什么变迁,这件事都不会改变的。定量的整体,一定可以区分成是量的部分,就像一张纸可以切成两半或分成四份一样。既然我们已经了解了一个定量的整体(指任何一种有限的定量的整体),也知道在定量的整体中很明确的某一部分,我们就可以知道整体比这个部分大,或这个部分比整体小了。到目前为止,这些都是口头上的说明,我们并不能为“整体”或“部分”下定义。这两个概念是原始的或无法定义的观念,我们只能借着整体与部分之间的关系,表达出我们对整体与部分的了解。\n这个说法是一种不言自明的道理—尤其当我们从相反的角度来看,一下子就可以看出其中的错误。我们可以把一张纸当作是一个“部分”,或是把纸切成两半后,将其中的一半当作是“整体”,但我们不能认为这张纸在还没有切开之前的“部分”,小于切开来后的一半大小的“整体”。无论我们如何运用语言,只有当我们了解定量的整体与其中明确的部分之后,我们才能说我们知道整体大于部分了。而我们所知道的是存在的整体与部分之间的关系,不只是知道名词的用法或意义而已。\n这种不言自明的主旨是不需要再证实,也不可否认的事实。它们来自一般的经验,也是普通常识的一部分,而不是有组织的知识;不隶属哲学、数学,却更接近科学或历史。这也是为什么欧几里得称这种概念为“普通观念\u0026quot;(Common notion)。尽管像洛克等人并不认为如此,但这些观念还是有启迪的作用。洛克看不出一个没有启发性的主旨(像关于祖父母的例子),和一个有启发性的主旨(像整体与部分关系的例子),两者之间到底有什么不同—后者对我们真的有教育作用,如果我们不学习就不会明白其中的道理。今天有些人认为所有的这类主旨都是“废话重说”,也是犯了同样的错误。他们没看出来有些所谓的“废话重说”确实能增进我们的知识—当然,另外有一些则的确不能。\n5.找出解答 # 这三个分析阅读的规则—关于共识、主旨与论述—可以带出第八个规则了,这也是诠释一本书的内容的最后一个步骤。除此之外,那也将分析阅读的第一个阶段(整理内容大纲)与第二阶段(诠释内容)连接起来了。\n在你想发现一本书到底在谈些什么的最后一个步骤是:找出作者在书中想要解决的主要问题(如果你回想一下,这在第四个规则中已经谈过了)。现在,你已经跟作者有了共识,抓到他的主旨与论述了,你就该检视一下你收集到的是什么资料,并提出一些更进一步的问题来。作者想要解决的问题哪些解决了?为了解决问题,他是否又提出了新问题?无论是新问题或旧问题,哪些是他知道自己还没有解决的?一个好作者,就像一个好读者一样,应该知道各个问题有没有解决—当然,对读者来说,要承认这个状况是比较容易的。\n诠释作品的阅读技巧的最后一部分就是:规则八,找出作者的解答。你在应用这个规则及其他三个规则来诠释作品时,你可以很清楚地感觉到自己已经开始在了解这本书了。如果你开始读一本超越你能力的书—也就是能教导你的书—你就有一段长路要走了。更重要的是,你现在已经能用分析阅读读完一本书了。这第三个,也是最后一个阶段的工作很容易。你的心灵及眼睛都已经打开来了,而你的嘴闭上了。做到这一点时,你已经在伴随作者而行了。从现在开始,你可以有机会与作者辩论,表达你自己的想法。\n6.分析阅读的第二个阶段 # 我们已经说明清楚分析阅读的第二个阶段。换句话说,我们已经准备好材料,要回答你在看一本书,或任何文章都应该提出来的第二个基本问题了。你会想起第二个问题是:这本书的详细内容是什么?如何叙述的?只要运用五到八的规则,你就能回答这个问题。当你跟作者达成共识,找出他的关键主旨与论述,分辨出如何解决他所面对的问题,你就会知道他在这本书中要说的是什么了。接下来,你已经准备好要问最后的两个基本问题了。\n我们已经讨论完分析阅读的另一个阶段,就让我们暂停一下,将这个阶段的规则复述一遍:\n分析阅读的第二个阶段,或找出一本书到底在说什么的规则(诠释一本书的内容):\n(5)诠释作者使用的关键字,与作者达成共识。\n(6)从最重要的句子中抓出作者的重要主旨。\n(7)找出作者的论述,重新架构这些论述的前因后果,以明白作者的主张。\n(8)确定作者已经解决了哪些问题,还有哪些是未解决的。在未解决的问题中,确定哪些是作者认为自己无法解决的问题。\n第十章 公正地评断一本书 # 在上一章的结尾,我们说,我们走了一段长路才来到这里。我们已经学过如何为一本书列出大纲。我们也学过诠释书本内容的四个规则。现在我们准备要做的就是分析阅读的最后一个阶段。在这个阶段中,你前面所做的努力都会有回报了。\n阅读一本书,是一种对话。或许你不这么认为,因为作者一路说个不停,你却无话可说。如果你这么想,你就是并不了解作为一个读者的义务—你也并没有掌握住自己的机会。\n事实上,读者才是最后一个说话的人。作者要说的已经说完了,现在该读者开口了。一本书的作者与读者之间的对话,就跟平常的对话没有两样,每个人都有机会开口说话,也不会受到干扰。如果读者没受过训练又没礼貌,这样的对话可能会发生任何事,却绝不会井井有条。可怜的作者根本没法为自己辩护。他没法说:“喂!等我说完,你再表示不同的意见可以吗?”读者误解他,或错过重点时,他也没法抗议。\n在一般的交谈中,必须双方都很有礼貌才能进行得很好。我们所想的礼貌却并不是一般社交礼仪上的礼貌。那样的礼貌其实并不重要。真正重要的是遵守思维的礼节。如果没有这样的礼节,谈话会变成争吵,而不是有益的沟通。当然,我们的假设是这样的谈话跟严肃的问题有关,一个人可以表达相同或不同的意见。他们能不能把自己表达得很好就变得很重要了。否则这个活动就毫无利益而言了。善意的对话最大的益处就是能学到些什么。\n在一般谈话来说有道理的事,对这种特殊的交谈情况—作者与读者借一本书来进行对话—又更有道理一些。我们姑且认为作者受过良好的训练,那么在一本好书中,他的谈话部分就扮演得很好,而读者要如何回报呢?他要如何圆满地完成这场交谈呢?\n读者有义务,也有机会回话。机会很明显。没有任何事能阻碍一个读者发表自己的评论。无论如何,在读者与书本之间的关系的本质中,有更深一层的义务关系。\n如果一本书是在传递知识的,作者的目标就是指导。他在试着教导读者。他想要说服或诱导读者相信某件事。只有当最后读者说:“我学到了。你已经说服我相信某些事是真实的,或认为这是可能发生的”,这位作者的努力才算成功了。但是就算读者未被说服或诱导,作者的企图与努力仍然值得尊敬。读者需要还他一个深思熟虑的评断。如果他不能说:“我同意。”至少他也要有不同意的理由,或对间题提出怀疑的论断。\n其实我们要说的前面已经不知说过多少次了。一本好书值得主动地阅读。主动的阅读不会为了已经了解一本书在说些什么而停顿下来,必须能评论,提出批评,才算真正完成了这件事。没有自我期许的读者没法达到这个要求,也不可能作到分析或诊释一本书。他不但没花心力去理解一本书,甚至根本将书搁在一边,忘个一干二净。这比不会赞赏一本书还糟,因为他对这本书根本无可奉告。\n1.受教是一种美德 # 我们前面所说的读者可以回话,并不是回与阅读无关的事。现在是分析阅读的第三个阶段。跟前面的两个阶段一样,这里也有一些规则。有些规则是一般思维的礼节。在这一章中,我们要谈的就是这个问题。其他有关批评观点的特殊条件,将会在下一章讨论到。\n一般人通常认为,水准普通的读者是不够格评论一本好书的。读者与作者的地位并不相等。在这样的观点中,作者只能接受同辈作家的批评。记得培根曾建议读者说:“阅读时不要反驳或挑毛病;也不要太相信,认为是理所当然;更不要交谈或评论。只要斟酌与考虑。”瓦尔特·司各特(Sir Walter Scott)要把“阅读时怀疑,或轻蔑作者的人”大加挞伐。\n当然,说一本书如何毫无瑕疵,因而对作者产生多少崇敬等等,这些话是有些道理,但却也有不通之处。读者或许像个孩子,因此一位伟大的作者可以教育他们,但这并不是说他们就没有说话的权利。塞万提斯说:“没有一本书会坏到找不到一点好处的。”或许他是对的,或许也是错的。更确定的说法该是:没有一本书会好到无懈可击。\n的确,如果一本书会启发读者,就表示作者高于读者,除非读者完全了解这本书,否则是不该批评的。但是等他们能这么做时,表示他们已经自我提升到与作者同样的水平了。现在他们拥有新的地位,可以运用他们的特权。如果他们现在不运用自己批评的才能,对作者来说就是不公平的事。作者已经完成他的工作—让读者与他齐头并进。这时候读者就应该表现得像是他的同辈,可以与他对话或回话。\n我们要讨论的是受教的美德—这是一种长久以来一直受到误解的美德。受教通常与卑躬屈膝混为一谈。一个人如果被动又顺从,可能就会被误解为他是受教的人。相反的,受教或是能学习是一种极为主动的美德。一个人如果不能自动自发地运用独立的判断力,他根本就不可能学习到任何东西。或许他可以受训练,却不能受教。因此,最能学习的读者,也就是最能批评的读者。这样的读者在最后终于能对一本书提出回应,对于作者所讨论的问题,会努力整理出自己的想法。\n我们说“最后”,是因为要能受教必须先完全听懂老师的话,而且在批评以前要能完全了解。我们还要加一句:光是努力,并不足以称得上受教。读者必须懂得如何评断一本书,就像他必须懂得如何才能了解一本书的内容。这第三组的阅读规则,也就是引导读者在最后一个阶段训练自己受教的能力。\n2.修辞的作用 # 我们经常发现教学与受教之间的关系是互惠的,而一个作者能深思熟虑地写作的技巧,和一个读者能深思熟虑地掌握这本书的技巧之间,也有同样的互惠关系。我们已经看到好的写作与阅读,都是以文法与逻辑的原则为基础规则。到现在为止,我们所讨论的规则都与作者努力达到能被理解的地步,而读者努力作到理解作品的地步有关。这最后阶段的一些规则,则超越理解的范畴,要作出评论。于是,这就涉及修辞。\n当然,修辞有很多的用途。我们通常认为这与演说或宣传有关。但是以最普通的意义来说,修辞和人类的任何一种沟通都有关,如果我们在说话,我们不只希望别人了解我们,也希望别人能同意我们的话。如果我们沟通的目的是很认真的,我们就希望能说服或劝导对方—更精确地说,说服对方接受我们的理论,劝导对方最终受到我们的行为与感觉的影响。\n在作这样的沟通时,接受的一方如果也想同样认真,那就不但要有回应,还要做一个负责的倾听者。你对自己所听到的要有回应,还要注意到对方背后的意图。同时,你还要能有自己的主见。当你有自己的主见时,那就是你的主张,不是作者的主张了。如果你不靠自己,只想依赖别人为你作判断,那你就是在做奴隶,不是自由的人了。思想教育之受推崇,正因如此。\n站在叙述者或作者的角度来看,修辞就是要知道如何去说服对方。因为这也是最终的目标,所有其他的沟通行为也必须做到这个程度才行。在写作时讲求文法与逻辑的技巧,会使作品清晰,容易理解,也是达到目标的一个过程。相对的,在读者或听者的立场,修辞的技巧是知道当别人想要说服我们时,我们该如何反应。同样的,文法及逻辑的技巧能让我们了解对方在说什么,并准备作出评论。\n3.暂缓评论的重要性 # 现在你可以看出来,在精雕细琢的写作或阅读过程中,文法、逻辑和修辞这三种艺术是如何协调与掌控的。在分析阅读前两个阶段的技巧中,需要精通文法与逻辑。在第三个阶段的技巧中,就要靠修辞的艺术了。这个阶段的阅读规则建立在最广义的修辞原则上。我们会认为这些原则代表一种礼节,让读者不只是有礼貌,还能有效地回话的礼节。(虽然这不是一般的认知,但是礼节应该要有这两个功能,而不是只有前面一项礼貌的功能。)\n你大概已经知道第九个阅读规则是什么了。前面已经讲过很多遍了。除非你听清楚了,也确定自己了解了,否则就不要回话。除非你真的很满意自己完成的前两个阅读阶段,否则不会感觉到可以很自由地表达自己的想法。只有当你做到这些事时,你才有批评的权力,也有责任这么做。\n这就是说,事实上,分析阅读的第三阶段最后一定要跟着前两个阶段来进行。前面两个阶段是彼此连贯的,就是初学者也能将两者合并到某种程度,而专家几乎可以完全连贯合并。他可以将整体分成许多部分,同时又能找出思想与知识的要素,与作者达成共识,找出主旨与论述,再重新架构出一个整体。此外,对初学者来说,前面两个阶段所需要做的工作,其实只要做好检视阅读就已经完成一大部分了。但是就下评论来说,即使是阅读专家,也必须跟初学者一样,不等到他完全了解是不能开始的。\n以下就是我们再详细说明的第九个规则:在你说出“我同意”,“我不同意”,或“我暂缓评论”之前,你一定要能肯定地说:“我了解了。”上述三种意见代表了所有的评论立场。我们希望你不要弄错了,以为所谓评论就是要不同意对方的说法。这是非常普遍的误解。同意对方说法,与不同意对方说法都一样要花心力来作判断的。同意或不同意都有可能对,也都有可能不对。毫无理解便同意只是愚蠢,还不清楚便不同意也是无礼。\n虽然乍看之下并不太明显,但暂缓评论也是评论的一种方式。那是一种有些东西还未表达的立场。你在说的是,无论如何,你还没有被说服。\n你可能会怀疑,这些不过是普通常识,为什么要大费周章地说明?有两个理由。第一点,前面已经说过,许多人会将评论与不同意混为一谈(就算是“建设性”的批评也是不同意)。其次,虽然这些规则看起来很有理,在我们的经验中却发现很少有人能真正运用。这就是古人说的光说不练的道理。\n每位作者都有被瞎批评的痛苦经验。这些批评者并不觉得在批评之前应该要做好前面的两个阅读步骤。通常这些批评者会认为自己不需要阅读,只需要评论就可以了。演讲的人,都会碰上一些批评者其实根本不了解他在说的是什么,就提出尖锐问题的经验。你自己就可能记得这样的例子:一个人在台上讲话,台下的人一口气或最多两口气就冒出来:“我不知道你在说什么,但我想你错了。”\n对于这样的批评,根本不知从何答起。你惟一能做的是有礼貌地请他们重述你的论点,再说明他们对你的非难之处。如果他们做不到,或是不能用他们自己的话重述你的观点,你就知道他们其实并不了解你在说什么。这时你不理会他们的批评是绝对有道理的。他们的意见无关紧要,因为那些只是毫无理解的批评而已。只有当你发现某个人像你自己一般真的知道你在说什么的时候,你才需要为他的同意而欢喜,或者为他的反对而苦恼。\n这么多年来教学生阅读各种书籍的经验中,我们发现遵守规则的人少,违反规则的人很多。学生经常完全不知道作者在说些什么,却毫不迟疑地批评起作者来。他们不但对自己不懂的东西表示反对意见,更糟的是,就算他们同意作者的观点,也无法用自己的话说出个道理来。他们的讨论,跟他们的阅读一样,都只是些文字游戏而已。由于他们缺乏理解,无论肯定或否定的意见就都毫无意义,而且无知。就算是暂缓评论,如果对自己暂缓评论的内容是些什么并不明所以的话,这种暂缓的立场也不见得有什么高明。\n关于这个规则,下面还有几点是要注意的。如果你在读一本好书,在你说出“我懂了”之前,最好迟疑一下。在你诚实又自信地说出这句话之前,你有一堆的工作要做呢!当然,在这一点上,你要先评断自己的能力,而这会让你的责任更加艰巨。\n当然,说出“我不懂”也是个很重要的评断,但这只能在你尽过最大努力之后,因为书而不是你自己的理由才能说这样的话。如果你已经尽力,却仍然无法理解,可能是这本书真的不能理解。对一本书,尤其是一本好书来说,这样的假设是有利的。在阅读一本好书时,无法理解这本书通常是读者的错。因此,在分析阅读中,要进人第三阶段之前,必须花很多时间准备前面两个阶段的工作。所以当你说“我不懂”时,要特别注意其中并没有错在你自己身上的可能。\n在以下的两种状况中,你要特别注意阅读的规则。如果一本书你只读了一部分,就更难确定自己是不是了解了这本书,在这时候你的批评也就要更小心。还有时候,一本书跟作者其他的书有关,必须看了那本书之后才能完全理解。在这种情况中,你要更小心说出“我懂了”这句话,也要更慢慢地举起你评论的长矛。\n对于这种自以为是的状况,有一个很好的例子。许多文学评论家任意赞成或反对亚里士多德的《诗学》,却并不了解他在分析诗的主要论点,其实立足于他其他有关心理学、逻辑与形上学的一些著作之上。他们其实根本不知道自己在赞成或反对的是什么。\n同样的状况也发生在其他作者身上,像柏拉图、康德、亚当·斯密与马克思等人—这些人不可能在一本书中将自己所有的思想与知识全部写出来。而那些评论康德《纯粹理性批判》,却根本没看过他《实践理性批判》的人;批评亚当·斯密的《国富论》,却没看过他《道德情操论》(Theory of Moral Sentiments)的人;或是谈论《共产党宣言》,却没有看过马克思《资本论》的人,他们都是在赞成或反对一些自己并不了解的东西。\n4.避免争强好辩的重要性 # 评论式阅读的第二个规则的道理,与第一个一样清楚,但需要更详尽的说明与解释。这是规则十:当你不同意作者的观点时,要理性地表达自己的意见,不要无理地辩驳或争论。如果你知道或怀疑自己是错的,就没有必要去赢得那场争辩。事实上,你赢得争辩可能真的会在世上名噪一时,但长程来说,诚实才是更好的策略。\n我们先从柏拉图与亚里士多德的例子来谈这个规则。在柏拉图的《会饮篇》(Symposium)中,有一段对话:\n“我不能反驳你,苏格拉底,”阿加顿说:“让我们假设你说的都对好了。”\n“阿加顿,你该说你不能反驳真理,因为苏格拉底是很容易被反驳的。”亚里士多德的《诗学》中也提到了这一段。他说:\n“其实这就是我们的责任。为了追求真理,要毁掉一些我们内心最亲近的事物,尤其像我们这样的哲学家或热爱智慧的人更是如此。因为,纵使双方是挚友,我们对真理的虔诚却是超越友谊的。”\n柏拉图与亚里士多德给了我们一个大多数人忽略的忠告。大多数人会以赢得辩论为目标,却没想到要学习的是真理。\n把谈话当作是战争的人,要赢得战争就得为反对而反对,不论自己对错,都要反对成功。抱持着这种心态来阅读的人,只是想在书中找出反对的地方而已。这些好辩的人专门爱在鸡蛋里挑骨头,对自己的心态是否偏差,则完全置之不顾。\n读者在自己书房和一本书进行对话的时候,没有什么可以阻止他去赢得这场争辩。他可以掌控全局。作者也不在现场为自己辩护。如果他想要作者现身一下的虚荣,他可以很容易就做到这一点。他几乎不必读完全书就能做到。他只要翻一下前面几页就够了。\n但是,如果他了解到,在与作者—活着或死了的老师—对话中,真正的好处是他能从中学到什么;如果他知道所谓的赢只在于增进知识,而不是将对方打败,他就会明白争强好辩是毫无益处的。我们并不是说读者不可以极端反对或专门挑作者的毛病,我们要说的只是:就像他反对一样,他也要有同意的心理准备。不论要同意还是反对,他该顾虑的都只有一点—事实,关于这件事的真理是什么。\n这里要求的不只是诚实。读者看到什么应该承认是不必说的。当必须同意作者的观点,而不是反对的,也不要有难过的感觉。如果有这样的感觉,他就是个积习已深的好辩者。就这第二个规则而言,这样的读者是情绪化的,而不是理性的。\n5.化解争议 # 第三个规则与第二个很接近。所叙述的是在提出批评之前的另一个条件。这是建议你把不同的观点当作是有可能解决的问题。第二个规则是敦促你不要争强好辩,这一个规则是提醒你不要绝望地与不同的意见对抗。一个人如果看不出所有理性的人都可能达成一致的意见,那他就会对波涛汹涌的讨论过程感到绝望。注意我们说的是“可能达成一致的意见”,而不是说每个有理性的人都会达成一致的意见。就算他们现在不同意,过一阵子他们也可能变成同意。我们要强调的重点是,除非我们认为某个不同的意见终究有助于解决某个问题,否则就会徒乱心意。\n人们确实会同意、也会不同意的两个事实,来自人类复杂的天性。人是理性的动物。理性是人类表达同意的力量泉源。人类的兽性与理性中不完美的部分,则是造成许多不同意的原因。人是情绪与偏见的动物。他们必须用来沟通的语言是不完美的媒介,被情绪遮盖着,被个人的喜好渲染着,被不恰当的思想穿梭着。不过在人是理性的程度之内,这些理解上的困难是可以克服的。从误解而产生的不同意见只是外表的,是可以更正的。\n当然,还有另一种不同意是来自知识的不相当。比较无知的人和超越自己的人争论时,经常会错误地表示反对的意见。然而,学识比较高的人,有权指正比较无知的人所犯的错误。这种不同意见所造成的争论也是可以更正的。知识的不相当永远可以用教导来解决。\n还有一些争论是被深深隐藏起来的,而且还可能是沉潜在理性之中。这种就很难捉摸,也难以用理性来说明。无论如何,我们刚刚所说是大部分争论形式—只要排除误解,增加知识就能解决这些争论。这两种解药尽管经常很困难,通常却都管用。因此,一个人在与别人对话时,就算有不同的意见,最后还是有希望达成共识。他应该准备好改变自己的想法,才能改变别人的想法。他永远要先想到自己可能误解了,或是在某一个问题上有盲点。在争论之中,一个人绝不能忘了这是教导别人,也是自己受教的一个机会。\n问题在许多人并不认为争议是教导与受教的一个过程。他们认为任何事都只是一个观点问题。我有我的观点,你也有你的,我们对自己的观点都有神圣不可侵犯的权利,就像我们对自己的财产也有同样的权利。如果沟通是为了增进知识,从这个角度出发的沟通是不会有收获的。这样的交谈,顶多像是一场各持己见的乒乓球赛,没有人得分,没有人赢,每个人都很满意,因为自己没有输—结果,到最后他还是坚持最初的观点。\n如果我们也是这样的观点,我们不会—也写不出这本书来。相反的,我们认为知识是可以沟通传达的,争议可以在学习中获得解决。如果真正的知识(不是个人的意见)是争议的焦点,那么在大多数情况下,这些争议或者只是表面的,借由达成共识或心智的交流就可以消除,或者就算真正存在,仍然可以借由长期的过程以事实与理性来化解。有理性的争议方法就是要有长久的耐心。简短来说,争议是可争辩的事物。除非双方相信透过相关证据的公开,彼此可以借由理性来达成一种理解,进而解决原始的争议议题,否则争议只是毫无意义的事。\n第三个规则要如何应用在读者与作者的对话中呢?这个规则要怎样转述成阅读的规则呢?当读者发现自己与书中某些观点不合时,就要运用到这个规则了。这个规则要求他先确定这个不同的意见不是出于误解。再假设这个读者非常注意,除非自己真的了解,而且确实毫无疑问,否则不会轻易提出评断的规则,那么,接下来呢?\n接下来,这个规则要求他就真正的知识与个人的意见作出区别。还要相信就知识而言,这个争议的议题是可以解决的。如果他继续进一步追究这个问题,作者的观点就会指引他,改变他的想法。如果这样的状况没有发生,就表示他的论点可能是正确的,至少在象征意义上,他也有能力指导作者。至少他可以希望如果作者还活着,还能出席的话,作者也可能改变想法。\n你可能还记得上一章的结尾部分谈过一点这个主题。如果一个作者的主旨没有理论基础,就可以看作是作者个人的意见。一个读者如果不能区别出知识的理论说明与个人观点的阐述,那他就无法从阅读中学到东西。他感兴趣的顶多只是作者个人,把这本书当作是个人传记来读而已。当然,这样的读者无所谓同意或不同意,他不是在评断这本书,而是作者本身。\n无论如何,如果读者基本的兴趣是书籍本身,而不是作者本身,对于自己有责任评论这件事就要认真地对待。在这一点上,读者要就真正的知识与他个人观点以及作者个人观点之不同之处,作出区分。因此,除了表达赞成或反对的意见之外,读者还要作更多的努力。他必须为自己的观点找出理由来。当然,如果他赞同作者的观点,就是他与作者分享同样的理论。但是如果他不赞同,他一定要有这么做的理论基础。否则他就只是把知识当作个人观点来看待了。\n因此,以下是规则十一,尊重知识与个人观点的不同,在作任何评断之前,都要找出理论基础。\n顺便强调的是,我们并不希望大家认为我们主张有许多“绝对”的知识。我们前一章提到的自明之理,对我们来说是不能证明,也无法否定的真理。然而,大多数的知识都无法做到绝对的地步。我们所拥有的知识都是随时可以更正的。我们所知道的知识都有理论支持,或至少有一些证据在支持着,但我们不知道什么时候会出现新的证据,或许就会推翻我们现在相信的事实。\n不过这仍然不会改变我们一再强调区别知识与意见的重要性。如果你愿意,那么知识存在于可以辩护的意见之中—那些有某种证据支持的意见。因此,如果我们真的知道些什么,我们就要相信我们能以自己所知来说服别人。至于“意见”,就我们一直使用这个字眼的意义来说,代表没有理论支持的评断。所以谈到“意见”的时候,我们一直和“只是”或“个人”等词汇联用。当我们除了个人的感觉与偏见,并没有其他证据或理由来支持一个陈述,就说某件事是真理的话,那未免儿戏了。相对地如果我们手中有一些有理性的人都能接受的客观证据,我们就可以说这是真理,而我们也知道这么说没错。\n现在我们要摘要说明这一章所讨论的三个规则。这三个规则在一起所说明的是批评式阅读的条件,而在这样的阅读中,读者应该能够与作者“辩论”。\n第一:要求读者先完整地了解一本书,不要急着开始批评。第二:恳请读者不要争强好辩或盲目反对。第三:将知识上的不同意见看作是大体上可以解决的问题。这个规则再进一步的话,就是要求读者要为自己不同的意见找到理论基础,这样这个议题才不只是被说出来,而且会解释清楚。只有这样,才有希望解决这个问题。\n第十一章 赞同或反对作者 # 一个读者所能说的第一件事是他读懂了,或是他没读懂。事实上,他必须先说自己懂了,这样才能说更多的话。如果他没懂,就应该心平气和地回头重新研究这本书。\n在第二种难堪的情况中,有一个例外。“我没懂”这句话也可能本身就是个评论。但下这个评论之前,读者必须有理论支持才行。如果问题出在书本,而不是读者自己,他就必须找出问题点。他可以发现这本书的架构混乱,每个部分都四分五裂,各不相干,或是作者谈到重要的字眼时模棱两可,造成一连串的混淆困扰。在这样的状态中,读者可以说这本书是没法理解的,他也没有义务来作评论。\n然而,假设你在读一本好书,也就是说这是一本可以理解的书。再假设最后你终于可以说:“我懂了!”再假设除了你看懂了全书之外,还对作者的意见完全赞同,这样,阅读工作才算是完成了。分析阅读的过程已经完全结束。你已经被启发,被说服或被影响了。当然,如果你对作者的意见不同意或暂缓评论,我们还会有进一步的考量。尤其是不同意的情况比较常见。\n作者与读者争辩—并希望读者也能提出辩驳时—一个好的读者一定要熟悉辩论的原则。在辩论时他要有礼貌又有智慧。这也是为什么在这本有关阅读的书中,要另辟一章来谈这个问题的原因。当读者不只是盲目地跟从作者的论点,还能和作者的论点针锋相对时,他最后才能提出同意或反对的有意义的评论。\n同意或反对所代表的意义值得我们进一步讨论。一位读者与作者达成共识后,掌握住他的主旨与论述,便是与作者心意相通了。事实上,诠释一本书的过程是透过言语的媒介,达到心灵上的沟通。读懂一本书可以解释为作者与读者之间的一种认同。他们同意用这样的说法来说明一种想法。因为这样的认同,读者便能透过作者所用的语言,看出他想要表达的想法。\n如果读者读懂了一本书,怎么会不同意这本书的论点呢?批评式阅读要求他保持自己的想法。但是当他成功地读懂这本书时,便是与作者的心意合一了。这时他还有什么空间保持自己的想法呢?\n有些人不知道所谓的“同意”其实是包含两种意义的,于是,错误的观念就形成前面的难题。结果,他们误以为两人之间如果可以互相了解,便不可能不同意对方的想法。他们认为反对的意见纯粹来自不了解。\n只要我们想想作者都是在对我们所生活的世界作出评论,这个错误就很容易看出来了。他声称提供给我们有关事物存在与行动的理论知识,或是我们该做些什么的实务知识,当然,他可能是对的,也可能是错的。只有当他说的是事实,而且提出相关的证据时,他的说法才成立。否则就是毫无根据的说辞。\n譬如你说:“所有的人都是平等的。”我们可能会认为你说的是人生而具有的智慧、力量与其他能力都是相同的。但就我们对事实的观察,我们不同意你的观点。我们认为你错了。但也可能我们误解你了。或许你要说的是每个人的政治权利是平等的。因为我们误解了你的意思,所以我们的不同意是毫无意义的。现在假设这个误解被纠正了。仍然可能有两种回答。我们可以同意,也可以不同意。但是这时如果我们不同意,我们之间就出现了一个真正的议题。我们了解你的政治立场,但我们的立场与你相反。\n只有当双方都了解对方所说的内容时,关于事实或方向的议题—关于一件事是什么或该如何做的议题—才是真实的。在讨论一件事时,双方都要对文字上的应用没有意见之后,才能谈到同意或不同意的观点。这是因为(不是尽管),当你透过对一本书的诠释理解,与作者达成了共识之后,才可以决定同意他的论点,或是不同意他的立场。\n1.偏见与公正 # 现在我们来谈谈你读懂了一本书,但是却不同意作者的状况。如果你都接受前一章所谈的规则,那么你的不同意就是因为作者在某一点上出错了。你并没有偏见,也不是情绪化。因为这是事实,那么要作到理想化的辩论就必须满足以下三种条件:\n第一点,因为人有理性的一面,又有动物的一面,所以在争辩时就要注意你会带进去的情绪,或是在当场引发的脾气。否则你的争论会流于情绪化,而不是在说理了。当你的情绪很强烈时,你可能会认为自己很有道理。\n第二点,你要把自己的前提或假设摊出来。你要知道你的偏见是什么—这也是你的预先评断。否则你就不容易接受对手也有不同假设的权利。一场好的辩论是不会为假设而争吵的。譬如作者明白地请你接受某个前提假设,你就不该因为也可以接受相反的前提假设就不听他的请求。如果你的偏见正好在相反的那一边,而你又不肯承认那就是偏见,你就不能给作者一个公平的机会表达意见了。\n第三点也是最后一点,派别之争几乎难以避免地会造成一些盲点,要化解这些盲点,应尽力尝试不偏不倚。当然,争论而不想有派别之分是不可能的事。但是在争论时应该多一点理性的光,少一点激情的热,每个参与辩论的人至少都该从对方的立场来着想。如果你不能用同理心来阅读一本书,你的反对意见会更像是争吵,而不是文明的意见交流。\n理想上,这三种心态是明智与有益的对话中必要的条件。这三种要件显然也适用在阅读上—那种作者与读者之间的对话上。对一个愿意采取理性争论方式的读者来说,每一个建议对他都是金玉良言。\n但这只是理想,仅能做到近似而已。我们不敢对人抱持这样的奢望。我们得赶快承认,我们也充分地注意到自己的缺点。我们也会违反我们自己所定的辩论中该有的明智规则。我们发现自己也会攻击一本书,而不是在评论,我们也会穷追猛打,辩不过的时候也继续反对,把自己的偏见讲得理直气壮,好像我们比作者要更胜一筹似的。\n然而,无论如何,我们仍然相信,作者与读者的对话及批评式的阅读,是可以相当有纪律的。因此,我们要介绍一套比较容易遵守,可以取代这三种规则的替代方法。这套方法指出四种站在对立角度来评论一本书之道。我们希望即使读者想要提出这四种评论时,也不会陷人情绪化或偏见的状态中。\n以下是这四点的摘要说明。我们的前提是读者能与作者进行对话,并能回应他所说的话。在读者说出:“我了解,但我不同意。”之后,他可以用以下的概念向作者说明:(1)你的知识不足(uninformed)。(2)你的知识有错误(misinformed)。(3)你不合逻辑—你的推论无法令人信服。(4)你的分析不够完整。\n这四点可能并不完整,不过我们认为已经够了。无论如何,这确实是一位读者在不同意时,基本上可以作出的重点声明。这四个声明多少有点独立性。只用其中一点,不会妨害到其他重点的运用。每个重点或全部的重点都可以用上,因为这些重点是不会互相排斥的。\n不过,再强调一次,读者不能任意使用这些评论,除非他确定能证明这位作者是知识不足、知识有误或不合逻辑。一本书不可能所有的内容都是知识不足或知识有误。一本书也不可能全部都不合逻辑。而要作这样评论的读者,除了要能精确地指认作者的问题之外,还要能进一步证明自己的论点才行。他要为自己所说的话提出理由来。\n2.判断作者的论点是否正确 # 这四个重点之中,第四个重点与前三个略微不同,我们会继续讨论这一点。我们先简单地谈一下前三点,再谈第四点。\n(1)说一位作者知识不足,就是在说他缺少某些与他想要解决的问题相关的知识。在这里要注意的是,除非这些知识确实相关,否则就没有理由作这样的评论。要支持你的论点,你就要能阐述出作者所缺乏的知识,并告诉他这些知识如何与这个问题有关,如果他拥有这些知识会如何让他下一个不同的结论。\n我们还要补充说明一点。达尔文缺乏基因遗传学的知识,这些是由孟德尔及后继者研究证实的知识。在他的《物种起源》中,最大的缺点就是他对遗传机能的知识一无所知。吉朋,则缺乏一些后来的历史学家研究证明所显示出罗马沦亡的关键点。通常,在科学与历史中,前人缺乏的知识都是由后来的人发掘出来的。科技的进步与时间的延长,使得大部分的研究调查都能做到这一点。但在哲学领域中,状况却可能相反。似乎时间越久远,知识只有衰退,而毫无增进。譬如古人就已经懂得分辨出人的意识、想像与理解力。在18世纪,休谟(DavidHume)的作品中对人的想像与思想的区别一无所知,然而早期的哲学家早已建立起这个概念了。\n(2)说一位作者的知识错误,就是说他的理念不正确。这样的错误可能来自缺乏知识,但也可能远不只于此。不论是哪一种,他的论点就是与事实相反。作者所说的事实或可能的事实,其实都是错的,而且是不可能的。这样的作者是在主张他自己其实并没有拥有的知识,当然,除非这样的缺点影响到作者的结论,否则并没必要指出来。要作这个评论,你必须要能说明事实,或是能采取比作者更有可能性的相反立场来支持你的论点。\n譬如斯宾诺莎的一本政治论著中,谈到民主是比专制更原始的一种政治形态。这与已经证实的政治史实完全相反。斯宾诺莎这个错误的观点,影响到他接下来的论述。亚里士多德误以为在动物的传宗接代中,雌性因素扮演着重要的角色,结果导致一个难以自圆其说的生殖过程的结论。阿奎那的错误在他认为天体与星球是截然不同的,因为他认为前者只会改变位置,此外无从改变。现代的天文学家更正了这个错误,而使得古代及中世纪的天文学往前迈进一大步。但是他这个错误只与部分内容相关。他出了这个错,却不影响他在形上学的论点,他认为所有可知觉的事物都是由内容及形式所组成的。\n前两点的批评是互相有关联的。知识不足,就可能造成我们所说的知识错误。此外,任何人的某种知识错误,也就是在那方面知识不足。不过,这两种不足在消极与积极面上的影响,还是有差别的。缺乏相关的知识,就不太可能解决某个特定的问题,或支持某一种结论。错误的知识却会引导出错误的结论,与站不住脚的解答。这两个评论合.在一起,指出的是作者的前提有缺陷。他需要充实知识。他的证据与论点无论在质与量上都还不够好。\n(3)说一位作者是不合逻辑的,就是说他的推论荒谬。、一般来说,荒谬有两种形态。一种是缺乏连贯,也就是结论冒出来了,却跟前面所说的理论连不起来。另一种是事件变化的前后不一致,也就是作者所说的两件事是前后矛盾的。要批评这两种问题,读者一定要能例举精确的证据,而那是作者的论点中所欠缺的使人信服的力量。只要当主要的结论受到这些荒谬推论的影响时,这个缺点才要特别地提出来。一本书中比较无关的部分如果缺乏信服力,也还说得过去。\n第三点比较难以例举说明。因为真正的好书,很少在推论上出现明显的错误。就算真的发生了,通常也是精巧地隐藏起来,只有极有洞察力的读者才能发掘出来。但是我们可以告诉你一个出现在马基雅维里的《君主论》中的谬论。马基雅维里说:\n所有的政府,不论新或旧,主要的维持基础在法律。如果这个政府没有很好的武装力量,就不会有良好的法律。也就是说,只要政府有很好的武装力量,就会有好的法律。\n所谓良好的法律来自良好的警察力量,所谓只要警察力量是强大的,法律也自然是良好的,是不通的。我们暂且忽略这个议题中高度的可疑性。我们关心的只是其中的连贯性。就算我们说快乐来自于健康比好法律来自有效力的警察力量还要有道理一些,但是也不能跟着说:健康的人都是快乐的人。\n在霍布斯的《法律的原理》(Elements of Law)中,他主张所有的物体不过是在运动中的物质数量而已。他说在物体的世界中是没有品质可言的。但是在另一个地方,他主张人本身就不过是个物体,一组在运动中的原子的组合。他一方面承认人有感官品质的存在—颜色、气味、味觉等等—一方面又说这都不过是大脑中原子的运动所造成的。这个结论与前面第一个论点无法呼应,在那个论点中他说的是在运动中的物体是没有品质的。他所说的所有运动中的物体,应该也包括任何一组特殊的物体,大脑的原子运动自然也该在其中才对。\n这第三个批评点与前两个是互相关联的。当然,有时候作者可能没法照他自己所提的证据或原则得出结论。这样他的推论就不够完整。但是这里我们主要关心的还是一个作者的理论根据很好,导出来的结论却很差的情况。发现作者的论点没有说服人的力量,是因为前提不正确或证据不足,虽然很有趣,但却一点也不重要。\n如果一个人设定了很完整的前提,结论却伺题百出,那从某个角度而言,就是他的知识有错误。不过,到底这些错误的论述来自推论有毛病的问题,还是因为一些其他的缺点,特别像是相关知识不足等等,这两者之间的差异倒是值得我们细细推敲的.\n3.判断作者论述的完整性 # 我们刚谈过的前面三个批评点,是与作者的声明与论述有关的。让我们谈一下读者可以采取的第四个批评点。这是在讨论作者是否实际完成了他的计划—也就是对于他的工作能否交待的满意度。\n在开始之前,我们必须先澄清一件事。如果你说你读懂了,而你却找不出证据来支持前面任何一个批评点·的话,这时你就有义务要同意作者的任何论点。这时你完全没有自主权。你没有什么神圣的权利可以决定同意或不同意。\n如果你不能用相关证据显示作者是知识不足、知识有误,或不合逻辑,你就不能反对他。你不能像很多学生或其他人说的:“你的前提都没有错,推论也没问题,但我就是不同意你的结论。”这时候你惟一能说的可能只是你“不喜欢”这个结论。你并不是在反对。你只在表达你的情绪或偏见。如果你已经被说服了,就该承认。(如果你无法提出证据来支持前三项批评点,但仍然觉得没有被作者说服,可能在一开始时你就不该说你已经读懂了这本书。)\n前面三个批评点与作者的共识、主旨与论述有关。这些是作者开始写作时要用来解决问题的要素。第四点—这本书是否完整了—与整本书的架构有关。\n(4)说一位作者的分析是不完整的,就是说他并没有解决他一开始提出来的所有问题,或是他并没有尽可能善用他手边的资料,或是他并没有看出其间的含意与纵横交错的关系,或是他没法让自己的想法与众不同。但这还不够去说一本书是不完整的。任何人都可以这样评论一本书。人是有限的,他们所做的任何工作也都是有限的,不完整的。因此,作这样的评论是毫无意义的。除非读者能精确地指出书中的问题点—不论是来自他自己的努力求知,或是靠其他的书帮忙—才能作这样的批评。\n让我们作一个简要的说明。在亚里士多德的《政治学》中,有关政府形态的分析是不完整的。因为他受时代的限制,以及他错误地接受奴隶制度,亚里士多德没有想到,或说构想到,真正的民主架构在人民的普选权。他也没法想像到代议政治与现代的联邦体制。如果有的话,他的分析应该延伸到这些政治现实才行。欧几里得的《几何原理》也是叙述不完整。因为欧几里得没想到平行线之间其他的公理。现代几何学提出了其他的假设,补足了这个缺陷。杜威的《如何思考》(How We Think),关于思考的分析是不完整的。因为他没有提到在阅读时产生的思考,在老师指导之下的思考,以及在研究发现时所产生的思考。对相信人类永生的基督徒而言,埃比克泰德(Epictetus)或奥勒留(Marcus Aurelius)有关人类幸福的论述也是不完整的。\n严格来说,第四点并不能作为不同意一个作者的根据。我们只能就作者的成就是有限的这一点而站在对立面上。然而,当读者找不出任何理由提出其他批评点而同意一本书的部分理论时,或许会因为这第四点,关于一本书是不完整的论点,而暂缓评论整本书。站在读者的立场,暂缓评论一本书就是表示作者并没有完全解决他提出的问题。\n阅读同样领域的书,可以用这四种评论的标准来作比较。如果一本书能比另一本书说出较多的事实,错误也较少,就比较好一点。但如果我们想要借读书来增进知识,显然一本能对主题作最完整叙述的书是最好的。这位作者可能会缺乏其他作者所拥有的知识;这位作者所犯的错误,可能是另一位作者绝不会发生的;即使是相同的根据,这位作者的说服力也可能比不上另一位作者。但是唯有比较每位作者在分析论点时的完整性,才是真正有深度的比较。比较每本书里有效而且突出的论点有多少,就可以当作评断其完整性的参考了。这时你会发现能与作者找出共同的词义是多么有用了。突出的词义越多,突出的论述也就越多。\n你也可能观察到第四个批评点与分析阅读的三个阶段是息息相关的。在拟大纲的最后阶段,就是要知道作者想要解决的问题是什么。诠释一本书的最后阶段,就是要知道作者解决了哪些问题,还有哪些问题尚未解决。批评一本书的最后阶段,就是要检视作者论述的完整性。这跟全书大纲,作者是否把问题说明清楚,也跟诠释一本书,衡量他多么完满地解决了问题都有关。\n4.分析阅读的三阶段 # 现在我们已经大致完成了分析阅读的举证与讨论。我们现在要把所有的规则按适当的次序,用合宜的标题写出来:\n一、 分析阅读的第一阶段:找出一本书在谈些什么的规则\n(1)\n依照书的种类与主题来分类。\n(2)\n使用最简短的文字说明整本书在谈些什么。\n(3)\n将主要部分按顺序与关联性列举出来。将全书的大纲列举出来,并将各个部分的大纲也列出来。\n(4)\n确定作者想要解决的问题。\n二、 分析阅读的第二阶段:诊释一本书的内容规则\n(5)诊释作者的关键字,与他达成共识。\n(6)由最重要的句子中,抓住作者的重要主旨。\n(7)知道作者的论述是什么,从内容中找出相关的句子,再重新架构出来。\n(8)确定作者已经解决了哪些问题,还有哪些是没解决的。再判断哪些是作者知道他没解决的问题。\n三、 分析阅读的第三阶段:像是沟通知识一样地评论一本书的规则\nA.\n智慧礼节的一般规则\n(9)除非你已经完成大纲架构,也能诠释整本书了,否则不要轻易批评。(在你说出:“我读懂了!”之前,不要说你同意、不同意或暂缓评论。)\n(10)不要争强好胜,非辩到底不可。\n(11)在说出评论之前,你要能证明自己区别得出真正的知识与个人观点的不同。B.批评观点的特别标准\n(12)证明作者的知识不足。\n(13)证明作者的知识错误。\n(14)证明作者不合逻辑。\n(15)证明作者的分析与理由是不完整的。\n注意:关于最后这四点,前三点是表示不同意见的准则,如果你无法提出相关的佐证,就必须同意作者的说法,或至少一部分说法。你只能因为最后一点理由,对这本书暂缓评论。\n本书在第七章结尾时,已经提出分析阅读的前四个规则,以便帮助你回答对一本书提出来的一个基本向题:这本书大体上来说是在谈些什么?同样的,在第九章的结尾,诠释一本书的四个规则能帮助你回答第二个问题,这也是你一定会问的问题:这本书详细的内容是什么?作者是如何写出来的?很清楚,剩下来的七个阅读规则—评论的智慧礼节、批评观点的特别标准—能帮助你回答第三与第四个基本问题。你一定还记得这两个问题:这是真实的吗?有意义吗?\n“这是真实的吗?\u0026lsquo;\u0026lsquo;的问题,可以拿来问我们阅读的任何一种读物。我们可以对任何一种读物提出“真实性”的疑问—数学、科学、哲学、历史与诗。人类发挥心智所完成的作品,如果就其真实性而受到赞美,可说是再也没有比这更高的评价了。同样的,就其真实性而进行批评,也是认真对待一部正经作品的态度。但奇怪的是,近几年来,在西方社会中,第一次出现了这种最高评价的标准逐渐丧失的现象。赢得批评家的喝彩,广受大众瞩目的书本,几乎都是在嘲弄事实的作品—越是夸大,效果越好。大部分读者,特别是阅读流行读物的读者,在使用不同的评论标准赞美或责难一本书—这本书是否新奇、哗众取宠,有没有诱惑力,有没有威力能迷惑读者的心等等,而不是在这本书的真实性,论点是否清晰,或是启发人心的力量上。这些标准之所以落伍,或许是现代有许多非科学类的作者,他们对于真实性要求不高的原因。我们可以推想这样的危机:如果任何有关真实的作品不再是关心的焦点时,那么愿意写作、出版、阅读这样的书的人就更少了。\n除非你阅读的东西在某种程度上是真实的,否则你用不着再读下去。就算是这样,你还是要面对最后一个问题。如果你是为了追求知识而阅读,除非你能判断作者所提出的事实的意义,或者应该具备的意义,否则称不上有头脑的阅读。作者所提出的事实,很少没经过有意无意的诠释。尤其如果你读的是文摘类的作品,那都是根据某种意义,或某种诠释原则而过滤过的事实。如果你阅读的是启发性的作品,这个问题更是没有终了的时刻。在学习的任何一个阶段,你都要回顾一下这个问题:“这究竟有没有意义?”\n我们已经提过的这四个问题,总结了身为读者应尽的义务。前三个,与人类语言的沟通天性有关。如果沟通并不复杂,就用不着做出大纲来。如果语言是完美的沟通媒介,而不是有点不透明,就用不着诠释彼此的想法了。如果错误与无知不会局限真实或知识,我们也根本用不着批评T。第四个间题区别T讯息(information)与理解( understanding)之间的差异。如果你阅读的读物是以传递讯息为主,你就要自己更进一步,找出其中的启发性来。即使你被自己阅读的东西所启发了,你也还要继续往前探索其中的意义。\n在进入本书的第三篇之前,或许我们该再强调一次,这些分析阅读的规则是一个理想化的阅读。没有多少人用过这样的方法来阅读一本书。而使用过这些方法的人,可能也没办法用这些规则来阅读许多本书。无论如何,这些规则只是衡量阅读层次的理想标准。你是个好读者,也就能达到你应该达到的阅读层次。\n当我们说某人读书“读得很好”(Well-read)时,我们心中应该要有这些标准来作衡量的依据。太多时候,我们是用这样的句子来形容一个人阅读的量,而非阅读的质。一个读得很广泛,却读不精的人,与其值得赞美,不如值得同情。就像霍布斯所说:“如果我像一般人一样读那么多书,我就跟他们一样愚蠢了。”\n伟大的作者经常也是伟大的读者,但这并不是说他们阅读所有的书。只是在我们的生活中,阅读是不可或缺的。在许多例子中,他们所阅读的书比我们在大学念的书还要少,但是他们读得很精。因为他们精通自己所阅读的书,他们的程度就可以跟作者相匹敌。他们有权被称作权威人士。在这种状况下,很自然地,一个好学生通常会变成老师,而一位好的读者也会变成作者。\n我们并不是企图要指引你开始写作,而是要提醒你,运用本书所提供的规则,仔细地阅读一本书,而不是浮面地阅读大量的书,就是一个好读者能达到的理想境界了。当然,许多书都值得精读。但有更多的书只要浏览一下就行了。要成为一个好读者,就要懂得依照一本书的特质,运用不同的阅读技巧来阅读。\n第十二章 辅助阅读 # 除了书籍本身之外,任何辅助阅读我们都可以称作是外在的阅读。所谓“内在阅读”(intrinsic reading),意思是指阅读书籍的本身,与所有其他的书都是不相关的。而“外在阅读”(extrinsic reading)指的是我们借助其他一些书籍来阅读一本书。到目前为止,我们故意避免谈到外在的辅助阅读。我们前面所谈的阅读规则,是有关内在阅读的规则—并不包括到这本书以外的地方寻找意义。有好几个理由让我们坚持到现在,一直将焦点集中在身为读者的基本工作上—拿起一本书来研究,运用自己的头脑来努力\u0026rsquo;,不用其他的帮助。但是如果一直这样做下去,可能就错了。外在阅读可以帮上这个忙。有时候还非要借助外在阅读,才能完全理解一本书呢!\n我们一直到现在才提出外在阅读的一个理由是:在理解与批评一本书的过程中,内在与外在的阅读通常会混在一起。在诠释、批评与做大纲时,我们都难免受到过去经验的影响。在阅读这本书之前,我们一定也读过其他的书。没有人是从分析阅读开始阅读第一本书的。我们可能不会充分对照其他书籍或自己生活里的经验,但是我们免不了会把某一位作者对某件事的声明与结论,拿来跟我们所知的,许多不同来源的经验作比较。这也就是俗话说的,我们不应该,也不可能完全孤立地阅读一本书。\n但是要拖到现在才提出外在阅读的主要理由是:许多读者太依赖外在的辅助了,我们希望你了解这是毫无必要的。阅读一本书时,另一只手上还拿着一本字典,其实是个坏主意。当然这并不是说你在碰到生字时也不可以查字典。同样地,一本书困扰住你时,我们也不会建议你去阅读评论这本书的文章。整体来说,在你找寻外力帮助之前,最好能自己一个人阅读。如果你经常这么做,最后你会发现越来越不需要外界的助力了。\n外在的辅助来源可以分成四个部分。在这一章中,我们会依照以下的顺序讨论出来:第一,相关经验。第二,其他的书。第三,导论与摘要。第四,工具书。\n要如何运用或何时运用这些外在的辅助资料,我们无法针对特例一一说明,但我们可以作一般性的说明。根据一般的阅读常识来说,你依照内在阅读的规则尽力将一本书读完之后,却还是有一部分不懂或全部都不懂时,就应该要找外在的帮助了。\n1.相关经验的角色 # 有两种形态的相关经验可以帮助我们了解在阅读时有困难的书。在第六章,我们已经谈到一般经验与特殊经验的不同之处。一般经验适用于任何一个活着的男人跟女人。特殊经验则需要主动地寻找,只有当一个人碰到困难时才会用得上。特殊经验的最佳例子就是在实验室中进行的实验,但也不一定需要有实验室。譬如一位人类学家的特殊经验可以是旅行到亚马逊流域,去研究一个尚未被开发的原始土著的居住形态。他因此增加了一些别人没有的特殊经验,也是许多人不可能有的经验。如果大多数科学家探险过那个区域之后,他的经验就失去了独特性。同样的,太空人登陆月球也是非常特殊的经验,而月球并不是一般人习以为常的实验室。大多数人并没有机会知道居住在没有空气的星球上是什么滋味,而在这成为一般经验之前,大多数人还是会保持这样的状态。同样的,上面有庞大地心引力的木星,在一般人心中也会继续想成一个像实验室般的地方,而且可能会一直如此。\n一般的经验并不一定要每个人都有才叫一般。一般(Common)与全体(Universal)是有点差别的。譬如并不是每个人都能经历生下来就有父母的经验,因为有些人一出生就是孤儿。然而,家庭生活却是一般人的普通经验,因为这是大多数男人跟女人在正常生活中的体验。同样的,性爱也不是每个人都有的经验,但是这是个共通的经验,因此我们称这个经验为一般经验。有些男人或女人从没有过这样的经验,但是这个经验被绝大多数的人类共享着,因此不能称作特殊经验。(这并不是说性爱经验不能在实验室中作研究,实际上也有很多人在做了。)被教导也不是每个人都有的经验,有些人从未上过学,但是这也属于一般经验。\n这两种经验主要是跟不同的书籍有关。一般经验在一方面与阅读小说有关,另一方面与阅读哲学书籍有关。判断一本小说的写实性,完全要依赖一般的经验。就像所有的人一样,我们从自己的生活体验来看这本书是真实或不够真实。哲学家与诗人一样,也是诉诸人类的共通经验。他并没有在实验室工作,或到外面作专门的研究调查。因此你用不着外界特殊经验的辅助,就能理解一位哲学家的主要原则。他谈的是你所知道的一般经验,与你每天生活中所观察到的世界。\n特殊经验主要是与阅读科学性作品有关。要理解与判断一本科学作品所归纳的论点,你就必须了解科学家所作的实验报告与证明。有时候科学家在形容一个实验时栩栩如生,你读起来一点困难也没有。有时说明图表会帮助你了解这些像是奇迹般的描述。\n阅读历史作品,同时与一般经验及特殊经验都有关。这是因为历史掺杂着虚构与科学的部分。从一方面来说,口述历史是个故事,有情节、角色、插曲、复杂的动作、高潮、余波。这就像一般经验也适用于阅读小说跟戏剧一样。但是历史也像科学一样,至少有些历史学家自己研究的经验是相当独特的。他可能有机会阅读到一些机密文件,而一般人如果阅读这些文件是会有麻烦的。他可能作过广泛的研究,不是进人残存的古老文明地区,就是访问过偏远地区的人民生活。\n要怎样才能知道你是否适当地运用自己的经验,来帮助你读懂一本书呢?最确定的测验方式就是我们讨论过的方式,跟测验你的理解力一样,问问你自己:在你觉得自己了解了的某一点上,能不能举出一个实例来?很多次我们要学生这么做,学生们却答不出来。他们看起来是了解了某个重点,但叫他起来举例说明时,他又是一脸茫然的样子。显然,他们并不是真的读懂了那本书。在你不太确定自己有没有掌握一本书时,不妨这样测验一下你自己。以亚里士多德在《伦理学》中讨论的道德为例,他一再强调,道德意味着过与不及之间的状态。他举出了一些具体的例子,你能同样举出类似的例子吗?如果可以,你就大致了解了他的重点。否则你该重新回到原点,再读一次他的论点。\n2.其他的书可以当作阅读时的外在助力 # 在后面我们会讨论到主题阅读,那是在同一个主题下,阅读很多本书。此刻,我们要谈的是阅读其他的书籍,以辅助我们阅读某一本书的好处。\n我们的建议尤其适用于所谓巨著。一般人总是抱着热忱想要阅读巨著,但是当他绝望地感觉到自己无法理解这本书时,热忱很快便消退了。其中一个原因,当然是因为一般人根本不知道要如何好好地阅读一本书。但还不只如此,还有另一个原因:他们认为自己应该能够读懂自己所挑选的第一本书,用不着再读其他相关的著作。他们可能想要阅读联邦公报,却没有事前先看过联邦条例和美国宪法。或是他们读了这些书,却没有看看孟德斯鸿的《论法的精神》与卢梭的《社会契约论》。\n许多伟大的作品不只是互相有关联,而且在写作时还有特定的先后顺序,这都是不该忽略的事。后人的作品总是受到前人的影响。如果你先读前一位的作品,他可能会帮助你了解后人的作品。阅读彼此相关的书籍‘,依照写作的时间顺序来读,对你了解最后写的作品有很大帮助。这就是外在辅助阅读的基本常识与规则。\n外在辅助阅读的主要功用在于延伸与一本书相关的内容脉络。我们说过文章的脉络有助于诠释字义与句子,找出共识与主旨。就像一整本书的脉络是由各个部分贯穿起来一样,相关的书籍也能提供一个大型的网路脉络,以帮助你诠释你正在阅读的书。\n我们经常会发现,一本伟大的著作总会有很长的对话部分。伟大的作者也是伟大的读者,想要了解他们,不妨读一读他们在读的书。身为读者,他们也是在与作者对话,就像我们在跟我们所阅读的书进行对话一样。只不过我们可能没写过其他的书。\n想要加人这样的谈话,我们一定要读与巨著相关的著作,而且要依照写作前后的年表来阅读。有关这些书的对话是有时间顺序的。时间顺序是最基本的,千万不要忽略了。阅读的顺序可以是从现代到过去,也可以从过去到现代。虽然从过去读到现代的作品因为顺其自然而有一定的好处,不过年代的角度也可以倒过来观察。\n顺便提醒一下,比起科学与小说类的书,阅读历史与哲学的书时,比较需要阅读相关的书籍。尤其是阅读哲学书时更重要,因为哲学家互相都是彼此了不起的读者。在小说与戏剧中,这就比较不重要了。如果真是好作品,可以单独阅读。当然一些文评家并不想限制自己这么做。\n3.如何运用导读与摘要 # 第三种外在的辅助阅读包括导读(commentary)与摘要(abstract)。这里要强调的是,在运用这些资料时要特别聪明,也就是要尽量少用。这么说有两个理由。\n第一,一本书的导读并不一定都是对的。当然,这些导读的用处很大,但却并不像我们希望的那样经常有用。在大学的书店里,到处都有阅读手册(handbook)与阅读指南(manual)。高中生也常到书店买这类书。这种书就经常产生误导。这些书都号称可以帮助学生完全了解老师指定他们阅读的某本书,但是他们的诠释有时错得离谱,除此之外,他们也实际上惹怒了一些老师与教授。\n但是就这些导读的书籍而言,我们不能不承认它们往往对考试过关大有助益。此外,好像是为了要与某些被惹恼的老师取得平衡,有些老师上课也会使用这些书。\n尽量少用导读的第二个原因是,就算他们写对了,可能也不完整。因此,你可能在书中发现一些重点,而那些导读者却没有发现到。阅读这类导读,尤其是自以为是的导读,会限制你对一本书的理解,就算你的理解是对的。\n因此,我们要给你一些关于如何使用导读的建议。事实上,这已经很相当于外在阅读的基本规则。内在阅读的规则是在阅读一本书之前,你要先看作者的序与前言。相反地,外在的阅读规则是除非你看完了一本书,否则不要看某个人的导读。这个规则尤其适用于一些学者或评论家的导言。要正确地运用这些导读,必须先尽力读完一本书,然后还有些问题在干扰着你时,你才运用这些导读来解答问题。如果你先读了这些导读,可能会让你对这本书产生曲解。你会只想看那些学者或批评家提出的重点,而无法看到可能同样重要的其他论点。\n如果是用这样的方法阅读,附带读一些这类的导读书籍是很有趣的事。你已经读过全书,也都了解了。而那位导读者也读过这本书,甚至可能读了好几次,他对这本书有自己的理解。你接近他的作品时,基本上是与他站在同一个水平上的。然而如果你在阅读全书之前,先看了他的导读手册,你就隶属于他了。\n要特别注意的是,你必须读完全书之后,才能看这类诠释或导读手册,而不是在之前看。如果你已经看过全书,知道这些导读如果有错,是错在哪里,那么这样的导读就不会对你造成伤害。但是如果你完全依赖这样的书,根本没读过原书,你的麻烦就大了。\n还有另一个重点。如果你养成了依赖导读的习惯,当你找不到这类书时,你会完全不知所措。你可能可以借着导读来了解某一本作品,但一般而言,你不会是个好读者。\n这里所说的外在阅读的规则也适用于摘录或情节摘要之类的作品。他们有两种相关的用途,也只有这两种。第一,如果你已经读过一本书,这些摘要能唤醒你的记忆。理想上,在分析阅读时,你就该自己作这样的摘要。但如果你还没这样做,一份内容摘要对你来说是有帮助的。第二,在主题阅读时,摘要的用处很大,你可以因此知道某些特定的议题是与你的主题密切相关的。摘要绝不能代替真正的阅读,但有时却能告诉你,你想不想或需不需要读这本书。\n4.如何运用工具书 # 工具书的类型有许多种。下面是我们认为最主要的两种:字典与百科全书。无论如何,对于其他类型的工具书,我们也还是有很多话要说的。\n虽然这是事实,但可能很多人不了解,那就是在你能运用工具书之前,你自己已经具备了很多知识:尤其是你必须有四种基本的知识。因此,工具书对矫正无知的功能是有限的。那并不能帮助文盲,也不能代替你思考。\n要善用工具书,首先你必须有一些想法,不管是多模糊的想法,那就是你想要知道些什么?你的无知就像是被光圈围绕着的黑暗。你一定要能将光线带进黑暗之中才行。而除非光圈围绕着黑暗,否则你是无法这么做的。换句话说,你一定要能对工具书问一个明智的问题。否则如果你只是仿徨迷失在无知的黑幕中,工具书也帮不上你的忙。\n其次,你一定要知道在哪里找到你要找的答案。你要知道自己问的是哪一类的问题,而哪一类的工具书是回答这类问题的。没有一本工具书能回答所有的问题,无论过去或现在,所有的工具书都是针对特定问题而来的。尤其是,事实上,在你能有效运用工具书之前,你必须要对主要类型的工具书有一个全盘的了解。\n在工具书对你发挥功用之前,你还必须有第三种知识。你必须要知道这本书是怎么组织的。如果你不知道要如何使用这本工具书的特殊功能,那就无助于你知道自己想要的是什么,也不知道该用哪种工具书。因此,阅读工具书跟阅读其他的书籍一样,也是有阅读的艺术的。此外,编辑工具书的技巧也有关系。作者或编者应该知道读者在找的是什么样的资料,然后编排出读者需要的内容。不过,他可能没办法先预测到这一点,这也是为什么这个规则要你在阅读一本书之前,先看序言与前言的原因。在阅读工具书时也一样,要看完编辑说明如何使用这本书之后,才开始阅读内容。\n当然,工具书并不能回答所有的问题。你找不到任何一本工具书,能同时回答在托尔斯泰的《人类的生活)(What Men Live By)中,上帝对天使提出的三个问题:“人类的住所是什么?”、“人类缺乏的是什么?”、“人类何以为生?”你也没法找到托尔斯泰另一个问题的答案。他的另一个故事的篇名是:“一个人需要多大的空间?”这类问题可说是不胜枚举。只有当你知道一本工具书能回答哪类问题,不能回答哪一类问题时,这本工具书对你才是有用的。这个道理也适用于一般人所共同认同的事物。在工具书中你只能看到约定俗成的观念,未获得普遍支持的论点不会出现在这种书中,虽然有时候也会悄悄挤进一两则惊人之论。\n我们都同意,在工具书中可以找到人的生卒年份,以及类似的事实。我们也相信工具书能定义字或事物,以及描绘任何历史事件。我们不同意的是,一些道德问题,有关人类未来的问题等等,这类间题却无法在工具书中找到答案。我们假定在我们生活的时代,物质世界是有秩序的,因此所有东西都可以在工具书中找到。但是事实并非如此,因此,历史性的工具书就很有趣,因为它能告诉我们,在人类可知的事物中,人们的观点是如何变迁的。\n要明智地运用工具书的第四个条件就是:你必须知道你想要找的是什么,在哪一种工具书中能找到这样的东西。你也要知道如何在工具书中找到你要的资料,还要能确定该书的编者或作者知道哪个答案。在你使用工具书之前,这些都是你应该清楚知道的事。对一无所知的人来说,工具书可说是毫无用处。工具书并不是茫然无知的指南。\n5.如何使用字典 # 字典是一种工具书,以上所说的工具书问题在使用时都要考虑进去。但是字典也被当作是一种好玩的读物。在无聊的时候可以坐下来对它进行挑战。毕竟这比其他许多消磨时间的方法高明许多。\n字典中充满了晦涩的知识,睿智繁杂的资讯。更重要的是,当然,字典也有严肃的用途。要能善用字典,就必须知道字典这种书的特点在哪里。\n桑塔亚那(Santayana)评论希腊民族是在欧洲历史中,惟一未受教育的一群人。他的话有双重的意义。当然,他们大部分人是没受过教育的,但即使是少数有知识的—有闲阶级—的人,就教育要接受外来大师的熏陶这一点而言,也是没有受过教育的。所谓教育,是由罗马人开始的,他们到学校受希腊人的指导,征服希腊后与希腊文化接触,而变得文明起来。\n所以,一点也用不着惊讶,世上最早的字典是关于荷马书中专门用语的字典,以帮助罗马人阅读《伊利亚特》及《奥德赛》,及其他同样运用荷马式古典字汇的希腊书籍。同样的,今天我们也需要专门用语字典才能阅读莎士比亚,或是乔臾的书。\n中世纪出现了许多字典,通常是有关世界知识的百科全书,还包括一些学习论述中最重要的技巧的讨论。在文艺复兴时期,出现了外语字典(希腊文与拉丁文双语),因为当时主要的教育是用古代语言教学的,事实上也必须有这类字典才行。纵使所谓鄙村野语—意大利语、法语、英语—慢慢取代拉丁文,成为学习使用的语言,追求学问仍然是少数人的特权。在这样的情况下,字典是只属于少数人的读物,主要用作帮助阅读与写作重要的文学作品。\n因此,我们可以看出来,从一开始,教育的动机便左右了字典的编排,当然,保留语言的纯粹与条理是另一个原因。就后一个原因而言,有些字典的目的却刚好相反,像《牛津英语字典》,开始于1857年,就是一个新的里程碑。在这本字典中不再规定用法,而是精确地呈现历史上出现的各种用法—最好的与最坏的都有,同时取材自通俗作品与高雅的作品。把自己看作是仲裁者的字典编辑,与把自己看作是历史学家的字典编辑之间的冲突,可以暂时搁在一边,毕竟,不论字典是如何编辑的,主要目的还是教育的工具。\n这个事实与善用一本字典,当作是外在辅助阅读工具的规则有关。阅读任何一本书的第一个规则是:知道这是一本什么样的书。也就是说,知道作者的意图是什么,在他的书中你可以看到什么样的资讯。如果你把一本字典当作是查拼字或发音的指南,你是在使用这本书,但却用得不够好。如果你了解字典中富含历史资料,并清楚说明有关语言的成长与发展,你会多花点注意力,不只是看每个字下面列举的意义,还会看看它们之间的秩序与关系。\n最重要的是,如果你想要自己进修,可以依照一本字典的基本意图来使用—当作是帮助阅读的工具,否则你会觉得太困难了。因为在字典中包含了科技的字汇、建筑用语、文学隐喻,甚至非常熟悉的字的过时用法。\n当然,想要读好一本书,除了作者使用字汇所造成的问题外,还有许多其他的问题。我们一再强调我们反对—特别是第一次阅读一本难读的书时—一手拿着书,另一手拿着字典。如果一开始阅读你就要查很多生字的话,你一定会跟不上整本书的条理。字典的基本用途是在你碰到一个专业术语,或完全不认识的字时,才需要使用上。即使如此,在你第一次阅读一本好书时,也不要急着使用字典,除非是那个字与作者的主旨有很大的关联,才可以查证一下。\n其他还有一些负面的告诫。如果你想要从字典中找出有关解决共产主义、正义、自由这类问题的结论,绝对是最讨人厌的。字典的编纂者可能是用字的权威专家,却不是最高的智慧根源。另一条否定的规则是:不要囫囵吞枣地将字典背下来。不要为了想立即增进字汇能力,就将一连串的生字背下来,那些字义跟你的实际生活经验一点关联也没有。简单来说,字典是关于字的一本书,而不是关于事的一本书。\n如果我们记得了这些,便可以推衍出一些明智地使用字典的规则。于是我们可以从四个方面来看待文字:\n(1)文字是物质的—可以写成字,也可以说出声音。因此,在拼字与发音上必须统一,虽然这种统一常被特例变化所破坏,但并不像你某些老师所说的那样重要。\n(2)文字是语言的一部分。在一个较复杂的句子或段落的结构中,文字扮演了文法上的角色。同一个字可以有多种不同的用法,随着不同的谈话内容而转变意义,特别是在语音变化不明显的英文中更是如此。\n(3)文字是符号—这些符号是有意义的,不只一种意义,而是很多种意义。这些意义在许多方面是互相关联的。有时候会细微地变化成另一种意义,有时候一个字会有一两组完全不相干的意义。因为意义上的相通,不同的字也可能互相连接起来—就像同义字,不同的字却有同样的意义。或是反义字,不同的字之间有相反或对比的意义。此外,既然文字是一种符号,我们就将字区分为专有名词与普通名词(根据他们指的是一件事,或是很多的事);具体名词或抽象名词(根据他们指的是我们能感知的事,或是一些我们能从心里理解,却无法由外在感知的事)。\n最后,(4)文字是约定俗成的—这是人类创造的符号。这也是为什么每个字都有历史,都有历经变化的文化背景。从文字的字根、字首、字尾,到词句的来源,我们可以看出文字的历史。那包括了外形的变化,在拼字与发音上的演变,意义的转变,哪些是古字、废字,哪些是现代的标准字,哪些是习惯用语,或口语、俚语。\n一本好字典能回答这四个不同类型的有关文字的问题。要善用一本字典,就是要知道问什么样的问题,如何找到答案。我们已经将问题建议出来了,字典应该告诉你如何找到解答。\n字典是一种完美的自修工具书,因为它告诉你要注意什么,如何诠释不同的缩写字,以及上面所说的四种有关文字符号的知识。任何人不善读一本字典开头时所作的解释以及所列的缩写符号,那用不好字典就只能怪他自己了。\n6.如何使用百科全书 # 我们所说的有关字典的许多事也适用于百科全书。跟字典一样,百科全书也是种好玩的读物,既有娱乐消遣价值,对某些人来说还能镇定神经。但是和字典一样,如果你想通读百科全书,那是没有意义的。一个将百科全书强记在心的人,会有被封为“书呆子”的危险。\n许多人用字典找出一个字的拼法与读法。百科全书相似的用法是查出时间、地点等简单的事实。但如果只是这样,那是没有善用、或误用了百科全书。就跟字典一样,百科全书也是教育与知识的工具。看看百科全书的历史,你就能确定这一点。\n虽然百科全书(encyclopedia)这个字来自希腊文,希腊却没有百科全书,同样地,他们也没有字典。百科全书这个字对他们来说,并不是指一本有关知识的书,或是沉淀知识的书,而是知识的本身—所有受过教育的人都该有的知识。同样的,又是罗马人发现百科全书的必要性。最早的一本百科全书是由罗马人普林尼(Pliny)编纂的。\n最有趣的是,第一本依照字母排列顺序编辑的百科全书是在1700年才出现的。从此大部分重要的百科全书都是照字母顺序来排列的。这是解决所有争议最简单的方法,也使得百科全书的编辑迈进了一大步。\n百科全书与光是文字的书所产生的问题有点不同。对一本字典来说,按字母排列是最自然不过的事了。但是世界上的知识—这是百科全书的主题—能以字母来排列吗?显然不行。那么,要如何安排出秩序来呢?这又跟知识是如何安排出秩序有关了。\n知识的顺序是随着时代而变迁的。在过去,所有相关的知识是以七种教育艺术来排列的—文法、修辞、逻辑三学科,与算术、几何、天文、音乐四学科组合而成。中世纪的百科全书显现出这样的安排。因为大学是照这样的系统来安排课程的,学生也照样学习,因此这样的安排对教育是有用的。\n现代的大学与中世纪的大学大不相同了,这些改变也反映在百科全书的编纂上。知识是按专业来区分的,大学中不同的科系也大致是照这样的方法来区分的。但是这样的安排,虽然大致上来自百科全书的背景,但仍然受到用字母编排资料的影响。\n这个内在的结构—借用社会学家的术语—就是善用百科全书的人要去找出来的东西。的确没错,他基本上要找的是真实的知识,但他不能单独只看一种事实。百科全书所呈现给他的是经过安排的一整套的事实—一整套彼此相关的事实。因此,百科全书和一般光提供讯息的书不同,它所能提供的理解取决于你对这些相关事实之间的关系的了解。\n在字母编排的百科全书中,知识之间的关联变得很模糊。而以主题来编排的百科全书,当然就能很清楚看出其间的相关性。但是以主题编排的百科全书也有许多缺点,其中有许多事实是一般人不会经常使用到的。理想上,最好的百科全书应该是又以主题,又按字母来编排的。它呈现的材料以一篇篇文章表现时,是按字母来排列,但其中又包括某个主题的关键与大纲—基本上就是一个目录。(目录是在编排一本书的文章时用的,与索引不同。索引是用字母排列的。)以我们所知,目前市面上还没有这样的百科全书,但值得努力去尝试一下。\n使用百科全书,读者必须要依赖编者的帮忙与建议。任何一本好的百科全书都有引言,指导读者如何有效地运用这本书,你一定要照着这些指示阅读。通常,这些引言都会要使用者在翻开字母排列的内容之前,先查证一下索引。在这里,索引的功能就跟目录一样,不过并不十分理想。因为索引是在同一个标题下,把百科全书中分散得很广,但是和某一个相关主题有关的讨论都集中起来。这反映一个事实,虽然索引是照字母排列的,但是下一层的细分内容,却是按照主题编排的。而这些主题又必须是按字母排列的,虽然这也并不是最理想的编排。因此,一本真正好的百科全书,像大英百科全书的索引,有一部分就可以看出他们整理知识的方法。因为这个原因,一个读者如果不能善用索引,无法让百科全书为己所用,也只能怪他自己了。\n关于使用百科全书,跟字典一样,也有一些负面的告诫。就跟字典一样,百科全书是拿来阅读好书用的—坏书通常用不着百科全书,但是同样的,最聪明的做法是不要被百科全书限制住了。这又跟字典一样,百科全书不是拿来解决某个不同观点的争论用的。不过,倒是可以用来快速而且一劳永逸地解决相关事实的争论。从一开始,事实就是没有必要争论的。一本百科全书会让这种徒劳无益的争吵变得毫无必要,因为百科全书中所记载的全是事实。理想上,除了事实外,百科全书里应该没有别的东西。最后,虽然不同的字典对文字的说明有同样的看法,但是百科全书对事实的说明却不尽相同。因此,如果你真的对某个主题很感兴趣,而且要靠着百科全书的说明来理解的话,不要只看一本百科全书,要看一种以上的百科全书,选择在不同的时间被写过很多次的解释。\n我们写过几个要点,提供给使用字典的读者。在百科全书的例子中,与事实相关的要点是相同的。因为字典是关于文字的,而百科全书是关于事实的。\n(1)事实是一种说法(proposition)说明一个事实时,会用一组文字来表达。如“亚伯拉罕·林肯出生于1809年2月12日。”或“金的原子序是79。”事实不像文字那样是物质的,但事实仍然需要解释。为了全盘地了解知识,你必须知道事实的意义—这个意义又如何影响到你在找寻的真理。如果你知道的只是事实本身,表示你了解的并不多。\n(2)事实是一种“真实”的说法(\u0026ldquo;True\u0026rdquo; proposition)事实不是观点。当有人说:“事实上……”的时候,表示他在说的是一般人同意的事。他不是说,也不该说,以他个人或少数人的观察,得来的事实是如此这般。百科全书的调性与风格,就在于这种事实的特质。一本百科全书如果包含了编者未经证实的观点,就是不诚实的做法。虽然一本百科全书也可能报导观点(譬如说某些人持这样的主张,另一些人则又是另一种主张),但却一定要清楚标明出来。由于百科全书必须只报导事实,不掺杂观点(除了上述的方法),因而也限制了记载的范围。它不能处理一些未达成共识的主题—譬如像道德的问题。如果真的要处理这些问题,只能列举人们各种不同的说法。\n(3)事实是真相的反映—事实可能是(1)一个资讯;(2)不受怀疑的推论。不管是哪一种,都代表着事情的真相。(林肯的生日是一个资讯。金原子的序号是一个合理的推论。)因此,事实如果只是对真相提出一点揣测,那就称不上是观念或概念,以及理论。同样地,对真相的解释(或部分解释),除非众所公认是正确的,否则就不能算是事实。\n在最后一点上,有一个例外。如果新理论与某个主题、个人或学派有关时,即使这个理论不再正确,或是尚未全部证实,百科全书仍然可以完全或部分报导。譬如我们不再相信亚里士多德对天体的观察是真确的,但是在亚里士多德的理论部分我们还是可以将它记录下来。\n(4)事实是某种程度上的约定俗成—我们说事实会改变。我们的意思是,某个时代的事实,到了另一个时代却不是事实了。但既然事实代表“真实”,当然是不会变的。因为真实,严格来说是不会变的,事实也不会变。不过所有我们认为是真实的主旨,并不一定都是真实的。我们一定要承认的是,任何我们认为是真实的主旨,都可能被更有包容力、或更正确的观察与调查证明是错的。与科学有关的事实更是如此。\n事实—在某种程度上—也受到文化的影响。譬如一个原子能科学家在脑中所设定的真实是十分复杂的,因此对他来说,某些特定的事实就跟在原始人脑中所想像与接受的不同了。这并不是说科学家与原始人对任何事实都无法取得共鸣,譬如说他们都会同意,二加二等于四,物质的整体大于部分。但是原始人可能不同意科学家所认为的原子核微粒的事实,科学家可能也不同意原始人所说的法术仪式的事实。(这是很难写的一段,因为我们文化背景的影响,我们会想同意科学家的说法,而很想在原始人认为的事实这两个字上加引号。这就是真正的重点所在。)\n如果你记住前面有关事实的叙述,一本好的百科全书会回答你有关事实的所有问题。将百科全书当作是辅助阅读的艺术,也就是能对事实提出适当问题的艺术。就跟字典一样,我们只是帮你提出问题来,百科全书会提供答案的。\n还要记得一点,百科全书不是追求知识最理想的途径。你可能会从其中条理分明的知识中,获得启发,但就算是在最重要的问题上,百科全书的启发性也是有限的。理解需要很多相关条件,在百科全书中却找不到这样的东西。\n百科全书有两个明显的缺失。照理说,百科全书是不记载论点的。除非这个论点已经被广泛接受了,或已成为历史性的趣味话题。因此,在百科全书中,主要缺少的是说理的写法。此外,百科全书虽然记载了有关诗集与诗人的事实,但是其中却不包含诗与想像的文学作品。因为想像与理性都是追求理解必要的条件,因此在求知的过程中,百科全书无法让人完全满意,也就不可避免了。\n"},{"id":136,"href":"/zh/docs/technology/MySQL/_how_mysql_run_/08/","title":"08数据的家--MySQL的数据目录","section":"_MySQL是怎样运行的_","content":" # # 数据库和文件系统的工具 # 数据目录的结构 # 表在文件系统中的表示 # kjskfjksdf\ns ksfd\nInnoDB是如何存储表数据的1 # 系统表空间1 # 撒旦发就\n系统表空间2 # 撒旦发就\n系统表空间3 # 撒旦发就\nInnoDB是如何存储表数据的2 # "},{"id":137,"href":"/zh/docs/technology/MySQL/_%E9%AB%98%E6%80%A7%E8%83%BDMySQL_/%E5%B0%81%E9%9D%A2-%E7%89%88%E6%9D%83/","title":"封面-版权","section":"高性能 My SQL","content":"\n内容简介\n本书是MySQL领域的经典之作,拥有广泛的影响力。第3版更新了大量的内容,不但涵盖了最新MySQL 5.5版本的新特性,也讲述了关于固态盘、高可扩展性设计和云计算环境下的数据库相关的新内容,原有的基准测试和性能优化部分也做了大量的扩展和补充。全书共分为16章和6个附录,内容涵盖MySQL架构和历史,基准测试和性能剖析,数据库软硬件性能优化,复制、备份和恢复,高可用与高可扩展性,以及云端的MySQL和MySQL相关工具等方面的内容。每一章都是相对独立的主题,读者可以有选择性地单独阅读。\n本书不但适合数据库管理员(DBA)阅读,也适合开发人员参考学习。不管是数据库新手还是专家,相信都能从本书有所收获。\n©2012 by Baron Schwartz,Peter Zaitsev,Vadim Tkachenko.\nSimplified Chinese Edition,jointly published by O\u0026rsquo;Reilly Media,Inc. and Publishing House of Electronics Industry,2013. Authorized translation of the English edition,2012 O\u0026rsquo;Reilly Media,Inc.,the owner of all rights to publish and sell the same.\nAll rights reserved including the rights of reproduction in whole or in part in any form.\n本书简体中文版专有出版权由O\u0026rsquo;Reilly Media,Inc.授予电子工业出版社。未经许可,不得以任何方式复制或抄袭本书的任何部分。专有出版权受法律保护。\n版权贸易合同登记号图字:01-2013-1661\n图书在版编目(CIP)数据\n高性能MySQL:第3版/(美)施瓦茨(Schwartz,B.),(美)扎伊采夫(Zaitsev,P.),(美)特卡琴科(Tkachenko,V.)著;宁海元等译.—北京:电子工业出版社,2013.5\n书名原文:High Performance MySQL,Third Edition\nISBN 978-7-121-19885-4\nⅠ.①高… Ⅱ.①施… ②扎… ③特… ④宁… Ⅲ.①关系数据库系统 Ⅳ.①TP311.138\n中国版本图书馆CIP数据核字(2013)第054420号\n策划编辑:张春雨\n责任编辑:白 涛 贾 莉\n封面设计:Karen Montgomery 张 健\n印 刷:三河市鑫金马印装有限公司\n装 订:三河市鑫金马印装有限公司\n出版发行:电子工业出版社\n北京市海淀区万寿路173信箱 邮编:100036\n开 本:787×980 1/16\n印 张:50\n字 数:1040千字\n印 次:2013年5月第1次印刷\n定 价:128.00元\n凡所购买电子工业出版社图书有缺损问题,请向购买书店调换。若书店售缺,请与本社发行部联系,联系及邮购电话:(010)88254888。\n质量投诉请发邮件至zlts@phei.com.cn,盗版侵权举报请发邮件至dbqq@phei.com.cn。\n服务热线:(010)88258888。\nO\u0026rsquo;Reilly Media,Inc.介绍\nO\u0026rsquo;Reilly Media通过图书、杂志、在线服务、调查研究和会议等方式传播创新知识。自1978年开始,O\u0026rsquo;Reilly一直都是前沿发展的见证者和推动者。超级极客们正在开创着未来,而我们关注真正重要的技术趋势——通过放大那些“细微的信号”来刺激社会对新科技的应用。作为技术社区中活跃的参与者,O\u0026rsquo;Reilly的发展充满了对创新的倡导、创造和发扬光大。\nO\u0026rsquo;Reilly为软件开发人员带来革命性的“动物书”;创建第一个商业网站(GNN);组织了影响深远的开放源代码峰会,以至于开源软件运动以此命名;创立了Make杂志,从而成为DIY革命的主要先锋;公司一如既往地通过多种形式缔结信息与人的纽带。O\u0026rsquo;Reilly的会议和峰会集聚了众多超级极客和高瞻远瞩的商业领袖,共同描绘出开创新产业的革命性思想。作为技术人士获取信息的选择,O\u0026rsquo;Reilly现在还将先锋专家的知识传递给普通的计算机用户。无论是通过书籍出版、在线服务或者面授课程,每一项O\u0026rsquo;Reilly的产品都反映了公司不可动摇的理念——信息是激发创新的力量。\n业界评论 # “O\u0026rsquo;Reilly Radar博客有口皆碑。”\n——Wired\n“O\u0026rsquo;Reilly凭借一系列(真希望当初我也想到了)非凡想法建立了数百万美元的业务。”\n——Business 2.0\n“O\u0026rsquo;Reilly Conference是聚集关键思想领袖的绝对典范。”\n——CRN\n“一本O\u0026rsquo;Reilly的书就代表一个有用、有前途、需要学习的主题。”\n——Irish Times\n“Tim是位特立独行的商人,他不光放眼于最长远、最广阔的视野并且切实地按照Yogi Berra的建议去做了:‘如果你在路上遇到岔路口,走小路(岔路)。’回顾过去Tim似乎每一次都选择了小路,而且有几次都是一闪即逝的机会,尽管大路也不错。”\n——Linux Journal\n译者序\n在互联网行业,MySQL数据库毫无疑问已经是最常用的数据库。LAMP(Linux+Apache+MySQL+PHP)甚至已经成为专有名词,也是很多中小网站建站的首选技术架构。我所在的公司淘宝网,在2003年非典肆虐期间创立时,选择的就是LAMP架构,当时MySQL的版本还是4.0。但是到了2003年底,由于业务超预期的增长,MySQL 4.0(当时用的还是MyISAM引擎)的很多缺点在高并发大压力下暴露了出来,于是技术上开始改用商业的Oracle数据库。随后几年Oracle加小型机和高端存储的数据库架构支撑了淘宝网业务的爆炸式增长,数据库也从最初的两三个库增长到十几个库,并且每个库的硬件已经逐步升级到顶配,“天花板”很明显地摆在了眼前。于是在2008年,基于PC服务器的MySQL数据库再次成为DBA团队的选择,这时候MySQL的稳定版本已经升级到5.0,并且5.1也已经在开发中,性能和特性相对于2003年的时候已经有了非常大的提升。淘宝网的数据库架构也逐渐从垂直拆分走向水平拆分,在大规模水平集群的架构设计中,开源的MySQL受到的关注度越来越高,并且一年多来的实践也证明了MySQL(存储引擎主要使用的是InnoDB)在高压力下的可用性。于是从2009年开始,后来颇受外界关注的所谓“去IOE”开始实施,经过三年多的架构改造,到2012年整个淘宝网的核心交易系统已经全部运行在基于PC服务器的MySQL数据库集群中,全部实例数超过2000个。今年的“双11”大促中,MySQL单库经受了最高达6.5万的QPS,某个拥有32个节点的核心集群的总QPS则稳定在86万以上,并且在整个大促(包括之前三年的“双11”大促)期间,数据库未发生过任何影响大促的重大故障。当然,这个结果,也得益于淘宝网整个应用架构的设计,以及这几年来革命性的闪存设备的迅猛发展。\n2008年,淘宝DBA团队准备从Oracle转向MySQL的时候,团队中的大多数人对MySQL的了解都非常之少。当时国内技术圈对MySQL的讨论也不多见,网上能找到的大多数中文资料基本上关注的还是如何安装,如何配置主备复制等。而MySQL中文类的书籍,大部分还是和PHP放在一起,作为PHP开发中的一环来讲述的。所以当我们发现mysqlperformanceblog.com这个相当专业的国外博客的时候,无不欣喜莫名。同时也知道了博客的作者们2008年出版的High Performance MySQL第二版(中文版于2010年1月出版),这本书被很多MySQL DBA们奉为圭臬,书的三位主要作者Baron Schwartz、Peter Zaitsev和Vadim Tkachenko也在MySQL DBA圈中耳熟能详,他们组建的Percona公司和Percona Server分支版本以及XtraDB存储引擎也逐渐为国内DBA所熟知。2011年12月,淘宝网和O\u0026rsquo;Reilly在北京联合举办的Velocity China 2011技术大会上,我们有幸邀请到Percona公司的华人专家季海东(目前已离职)来介绍MySQL 5.5 InnoDB/XtraDB的性能优化和诊断方法。在季海东先生的引荐下,我们也和Peter通过Skype电话会议有过沟通,介绍了MySQL在淘宝的应用情况,我们对MySQL一些特性的需求,以及对MySQL做的一些patch,并随后保持了密切的邮件联系。有了这些铺垫,我们对于在生产系统中采用Percona Server 5.5也有了更大的信心,如今已有超过1000个Percona Server 5.5的实例在线上运行。所以今年上半年电子工业出版社的张春雨(侠少)编辑找到我来翻译本书的第三版的时候,很是激动,一口应承。\n考虑到这么经典的书应该尽快地和读者见面,故此我邀请了团队中的MySQL专家周振兴(花名:苏普)、彭立勋、翟卫祥(花名:印风)、刘辉(花名:希羽)一起来翻译。其中,我负责前、推荐序和第1、2、3章,周振兴负责第5、6、7章,彭立勋负责第4、8、9、14章,翟卫祥负责第10、11、12、13章,刘辉负责第15、16章和附录部分,最后由我负责统稿。所以毫无疑问,这本书是团队合作的结晶。虽然我们满怀激情,但由于都是第一次参与翻译技术书籍,确实对困难有些预估不足,加上下半年为了准备“双11”等各种大促,需要在DBA团队满负荷的工作间隙挤出个人时间,初稿出来后,由于每个人翻译风格不太一致,几次审稿修订,也让本书的编辑李云静和白涛吃了不少苦头,在此对大家表示深深的感谢,是大家不懈的努力,才使得本书能够顺利地和读者见面。但书中肯定还存在不少问题,恳请读者不吝指出,欢迎大家和我的新浪微博 http://weibo.com/NinGoo进行互动。\n同时还要感谢本书第二版的译者们,他们娴熟的语言技巧给了我们很多的参考。也要感谢帮助审稿的同事们,包括但并不仅限于张新铭(花名:俊达)、张瑞(花名:张瑞)、吴学章(花名:维西)等,彭立勋甚至还发动了他女朋友加入到审稿工作中,在此一并表示感谢。当然,最后还要感谢我的妻子Lalla,在我占用了大量周末时间的时候能够给予支持,并承担了全部的家务,让我以译书为借口毫无心理负担地偷懒。\n宁海元(花名:江枫)\n2013年3月于余杭\n目录\nO\u0026rsquo;Reilly Media,Inc.介绍\n译者序\n推荐序\n前言\n第1章 MySQL架构与历史 1.1 MySQL逻辑架构 1.1.1 连接管理与安全性\n1.1.2 优化与执行\n1.2 并发控制 1.2.1 读写锁\n1.2.2 锁粒度\n1.3 事务 1.3.1 隔离级别\n1.3.2 死锁\n1.3.3 事务日志\n1.3.4 MySQL中的事务\n1.4 多版本并发控制\n1.5 MySQL的存储引擎 1.5.1 InnoDB存储引擎\n1.5.2 MyISAM存储引擎\n1.5.3 MySQL内建的其他存储引擎\n1.5.4 第三方存储引擎\n1.5.5 选择合适的引擎\n1.5.6 转换表的引擎\n1.6 MySQL时间线(Timeline)\n1.7 MySQL的开发模式\n1.8 总结\n第2章 MySQL基准测试 2.1 为什么需要基准测试\n2.2 基准测试的策略 2.2.1 测试何种指标\n2.3 基准测试方法 2.3.1 设计和规划基准测试\n2.3.2 基准测试应该运行多长时间\n2.3.3 获取系统性能和状态\n2.3.4 获得准确的测试结果\n2.3.5 运行基准测试并分析结果\n2.3.6 绘图的重要性\n2.4 基准测试工具 2.4.1 集成式测试工具\n2.4.2 单组件式测试工具\n2.5 基准测试案例 2.5.1 http_load\n2.5.2 MySQL基准测试套件\n2.5.3 sysbench\n2.5.4 数据库测试套件中的dbt2 TPC-C测试\n2.5.5 Percona的TPCC-MySQL测试工具\n2.6 总结\n第3章 服务器性能剖析 3.1 性能优化简介 3.1.1 通过性能剖析进行优化\n3.1.2 理解性能剖析\n3.2 对应用程序进行性能剖析 3.2.1 测量PHP应用程序\n3.3 剖析MySQL查询 3.3.1 剖析服务器负载\n3.3.2 剖析单条查询\n3.3.3 使用性能剖析\n3.4 诊断间歇性问题 3.4.1 单条查询问题还是服务器问题\n3.4.2 捕获诊断数据\n3.4.3 一个诊断案例\n3.5 其他剖析工具 3.5.1 使用USER_STATISTICS表\n3.5.2 使用strace\n3.6 总结\n第4章 Schema与数据类型优化 4.1 选择优化的数据类型 4.1.1 整数类型\n4.1.2 实数类型\n4.1.3 字符串类型\n4.1.4 日期和时间类型\n4.1.5 位数据类型\n4.1.6 选择标识符(identifier)\n4.1.7 特殊类型数据\n4.2 MySQL schema设计中的陷阱\n4.3 范式和反范式 4.3.1 范式的优点和缺点\n4.3.2 反范式的优点和缺点\n4.3.3 混用范式化和反范式化\n4.4 缓存表和汇总表 4.4.1 物化视图\n4.4.2 计数器表\n4.5 加快ALTER TABLE操作的速度 4.5.1 只修改.frm文件\n4.5.2 快速创建MyISAM索引\n4.6 总结\n第5章 创建高性能的索引 5.1 索引基础 5.1.1 索引的类型\n5.2 索引的优点\n5.3 高性能的索引策略 5.3.1 独立的列\n5.3.2 前缀索引和索引选择性\n5.3.3 多列索引\n5.3.4 选择合适的索引列顺序\n5.3.5 聚簇索引\n5.3.6 覆盖索引\n5.3.7 使用索引扫描来做排序\n5.3.8 压缩(前缀压缩)索引\n5.3.9 冗余和重复索引\n5.3.10 未使用的索引\n5.3.11 索引和锁\n5.4 索引案例学习 5.4.1 支持多种过滤条件\n5.4.2 避免多个范围条件\n5.4.3 优化排序\n5.5 维护索引和表 5.5.1 找到并修复损坏的表\n5.5.2 更新索引统计信息\n5.5.3 减少索引和数据的碎片\n5.6 总结\n第6章 查询性能优化 6.1 为什么查询速度会慢\n6.2 慢查询基础:优化数据访问 6.2.1 是否向数据库请求了不需要的数据\n6.2.2 MySQL是否在扫描额外的记录\n6.3 重构查询的方式 6.3.1 一个复杂查询还是多个简单查询\n6.3.2 切分查询\n6.3.3 分解关联查询\n6.4 查询执行的基础 6.4.1 MySQL客户端/服务器通信协议\n6.4.2 查询缓存\n6.4.3 查询优化处理\n6.4.4 查询执行引擎\n6.4.5 返回结果给客户端\n6.5 MySQL查询优化器的局限性 6.5.1 关联子查询\n6.5.2 UNION的限制\n6.5.3 索引合并优化\n6.5.4 等值传递\n6.5.5 并行执行\n6.5.6 哈希关联\n6.5.7 松散索引扫描\n6.5.8 最大值和最小值优化\n6.5.9 在同一个表上查询和更新\n6.6 查询优化器的提示(hint)\n6.7 优化特定类型的查询 6.7.1 优化COUNT()查询\n6.7.2 优化关联查询\n6.7.3 优化子查询\n6.7.4 优化GROUP BY和DISTINCT\n6.7.5 优化LIMIT分页\n6.7.6 优化SQL_CALC_FOUND_ROWS\n6.7.7 优化UNION查询\n6.7.8 静态查询分析\n6.7.9 使用用户自定义变量\n6.8 案例学习 6.8.1 使用MySQL构建一个队列表\n6.8.2 计算两点之间的距离\n6.8.3 使用用户自定义函数\n6.9 总结\n第7章 MySQL高级特性 7.1 分区表 7.1.1 分区表的原理\n7.1.2 分区表的类型\n7.1.3 如何使用分区表\n7.1.4 什么情况下会出问题\n7.1.5 查询优化\n7.1.6 合并表\n7.2 视图 7.2.1 可更新视图\n7.2.2 视图对性能的影响\n7.2.3 视图的限制\n7.3 外键约束\n7.4 在MySQL内部存储代码 7.4.1 存储过程和函数\n7.4.2 触发器\n7.4.3 事件\n7.4.4 在存储程序中保留注释\n7.5 游标\n7.6 绑定变量 7.6.1 绑定变量的优化\n7.6.2 SQL接口的绑定变量\n7.6.3 绑定变量的限制\n7.7 用户自定义函数\n7.8 插件\n7.9 字符集和校对 7.9.1 MySQL如何使用字符集\n7.9.2 选择字符集和校对规则\n7.9.3 字符集和校对规则如何影响查询\n7.10 全文索引 7.10.1 自然语言的全文索引\n7.10.2 布尔全文索引\n7.10.3 MySQL 5.1中全文索引的变化\n7.10.4 全文索引的限制和替代方案\n7.10.5 全文索引的配置和优化\n7.11 分布式(XA)事务 7.11.1 内部XA事务\n7.11.2 外部XA事务\n7.12 查询缓存 7.12.1 MySQL如何判断缓存命中\n7.12.2 查询缓存如何使用内存\n7.12.3 什么情况下查询缓存能发挥作用\n7.12.4 如何配置和维护查询缓存\n7.12.5 InnoDB和查询缓存\n7.12.6 通用查询缓存优化\n7.12.7 查询缓存的替代方案\n7.13 总结\n第8章 优化服务器设置 8.1 MySQL配置的工作原理 8.1.1 语法、作用域和动态性\n8.1.2 设置变量的副作用\n8.1.3 入门\n8.1.4 通过基准测试迭代优化\n8.2 什么不该做\n8.3 创建MySQL配置文件 8.3.1 检查MySQL服务器状态变量\n8.4 配置内存使用 8.4.1 MySQL可以使用多少内存\n8.4.2 每个连接需要的内存\n8.4.3 为操作系统保留内存\n8.4.4 为缓存分配内存\n8.4.5 InnoDB缓冲池(Buffer Pool)\n8.4.6 MyISAM键缓存(Key Caches)\n8.4.7 线程缓存\n8.4.8 表缓存(Table Cache)\n8.4.9 InnoDB数据字典(Data Dictionary)\n8.5 配置MySQL的I/O行为 8.5.1 InnoDB I/O配置\n8.5.2 MyISAM的I/O配置\n8.6 配置MySQL并发 8.6.1 InnoDB并发配置\n8.6.2 MyISAM并发配置\n8.7 基于工作负载的配置 8.7.1 优化BLOB和TEXT的场景\n8.7.2 优化排序(Filesorts)\n8.8 完成基本配置\n8.9 安全和稳定的设置\n8.10 高级InnoDB设置\n8.11 总结\n第9章 操作系统和硬件优化 9.1 什么限制了MySQL的性能\n9.2 如何为MySQL选择CPU 9.2.1 哪个更好:更快的CPU还是更多的CPU\n9.2.2 CPU架构\n9.2.3 扩展到多个CPU和核心\n9.3 平衡内存和磁盘资源 9.3.1 随机I/O和顺序I/O\n9.3.2 缓存,读和写\n9.3.3 工作集是什么\n9.3.4 找到有效的内存/磁盘比例\n9.3.5 选择硬盘\n9.4 固态存储 9.4.1 闪存概述\n9.4.2 闪存技术\n9.4.3 闪存的基准测试\n9.4.4 固态硬盘驱动器(SSD)\n9.4.5 PCIe存储设备\n9.4.6 其他类型的固态存储\n9.4.7 什么时候应该使用闪存\n9.4.8 使用Flashcache\n9.4.9 优化固态存储上的MySQL\n9.5 为备库选择硬件\n9.6 RAID性能优化 9.6.1 RAID的故障转移、恢复和镜像\n9.6.2 平衡硬件RAID和软件RAID\n9.6.3 RAID配置和缓存\n9.7 SAN和NAS 9.7.1 SAN基准测试\n9.7.2 使用基于NFS或SMB的SAN\n9.7.3 MySQL在SAN上的性能\n9.7.4 应该用SAN吗\n9.8 使用多磁盘卷\n9.9 网络配置\n9.10 选择操作系统\n9.11 选择文件系统\n9.12 选择磁盘队列调度策略\n9.13 线程\n9.14 内存交换区\n9.15 操作系统状态 9.15.1 如何阅读vmstat的输出\n9.15.2 如何阅读iostat的输出\n9.15.3 其他有用的工具\n9.15.4 CPU密集型的机器\n9.15.5 I/O密集型的机器\n9.15.6 发生内存交换的机器\n9.15.7 空闲的机器\n9.16 总结\n第10章 复制 10.1 复制概述 10.1.1 复制解决的问题\n10.1.2 复制如何工作\n10.2 配置复制 10.2.1 创建复制账号\n10.2.2 配置主库和备库\n10.2.3 启动复制\n10.2.4 从另一个服务器开始复制\n10.2.5 推荐的复制配置\n10.3 复制的原理 10.3.1 基于语句的复制\n10.3.2 基于行的复制\n10.3.3 基于行或基于语句:哪种更优\n10.3.4 复制文件\n10.3.5 发送复制事件到其他备库\n10.3.6 复制过滤器\n10.4 复制拓扑 10.4.1 一主库多备库\n10.4.2 主动-主动模式下的主-主复制\n10.4.3 主动-被动模式下的主-主复制\n10.4.4 拥有备库的主-主结构\n10.4.5 环形复制\n10.4.6 主库、分发主库以及备库\n10.4.7 树或金字塔形\n10.4.8 定制的复制方案\n10.5 复制和容量规划 10.5.1 为什么复制无法扩展写操作\n10.5.2 备库什么时候开始延迟\n10.5.3 规划冗余容量\n10.6 复制管理和维护 10.6.1 监控复制\n10.6.2 测量备库延迟\n10.6.3 确定主备是否一致\n10.6.4 从主库重新同步备库\n10.6.5 改变主库\n10.6.6 在一个主-主配置中交换角色\n10.7 复制的问题和解决方案 10.7.1 数据损坏或丢失的错误\n10.7.2 使用非事务型表\n10.7.3 混合事务型和非事务型表\n10.7.4 不确定语句\n10.7.5 主库和备库使用不同的存储引擎\n10.7.6 备库发生数据改变\n10.7.7 不唯一的服务器ID\n10.7.8 未定义的服务器ID\n10.7.9 对未复制数据的依赖性\n10.7.10 丢失的临时表\n10.7.11 不复制所有的更新\n10.7.12 InnoDB加锁读引起的锁争用\n10.7.13 在主-主复制结构中写入两台主库\n10.7.14 过大的复制延迟\n10.7.15 来自主库的过大的包\n10.7.16 受限制的复制带宽\n10.7.17 磁盘空间不足\n10.7.18 复制的局限性\n10.8 复制有多快\n10.9 MySQL复制的高级特性\n10.10 其他复制技术\n10.11 总结\n第11章 可扩展的MySQL 11.1 什么是可扩展性 11.1.1 正式的可扩展性定义\n11.2 扩展MySQL 11.2.1 规划可扩展性\n11.2.2 为扩展赢得时间\n11.2.3 向上扩展\n11.2.4 向外扩展\n11.2.5 通过多实例扩展\n11.2.6 通过集群扩展\n11.2.7 向内扩展\n11.3 负载均衡 11.3.1 直接连接\n11.3.2 引入中间件\n11.3.3 一主多备间的负载均衡\n11.4 总结\n第12章 高可用性 12.1 什么是高可用性\n12.2 导致宕机的原因\n12.3 如何实现高可用性 12.3.1 提升平均失效时间(MTBF)\n12.3.2 降低平均恢复时间(MTTR)\n12.4 避免单点失效 12.4.1 共享存储或磁盘复制\n12.4.2 MySQL同步复制\n12.4.3 基于复制的冗余\n12.5 故障转移和故障恢复 12.5.1 提升备库或切换角色\n12.5.2 虚拟IP地址或IP接管\n12.5.3 中间件解决方案\n12.5.4 在应用中处理故障转移\n12.6 总结\n第13章 云端的MySQL 13.1 云的优点、缺点和相关误解\n13.2 MySQL在云端的经济价值\n13.3 云中的MySQL的可扩展性和高可用性\n13.4 四种基础资源\n13.5 MySQL在云主机上的性能 13.5.1 在云端的MySQL基准测试\n13.6 MySQL数据库即服务(DBaaS) 13.6.1 Amazon RDS\n13.6.2 其他DBaaS解决方案\n13.7 总结\n第14章 应用层优化 14.1 常见问题\n14.2 Web服务器问题 14.2.1 寻找最优并发度\n14.3 缓存 14.3.1 应用层以下的缓存\n14.3.2 应用层缓存\n14.3.3 缓存控制策略\n14.3.4 缓存对象分层\n14.3.5 预生成内容\n14.3.6 作为基础组件的缓存\n14.3.7 使用HandlerSocket和memcached\n14.4 拓展MySQL\n14.5 MySQL的替代品\n14.6 总结\n第15章 备份与恢复 15.1 为什么要备份\n15.2 定义恢复需求\n15.3 设计MySQL备份方案 15.3.1 在线备份还是离线备份\n15.3.2 逻辑备份还是物理备份\n15.3.3 备份什么\n15.3.4 存储引擎和一致性\n15.4 管理和备份二进制日志 15.4.1 二进制日志格式\n15.4.2 安全地清除老的二进制日志\n15.5 备份数据 15.5.1 生成逻辑备份\n15.5.2 文件系统快照\n15.6 从备份中恢复 15.6.1 恢复物理备份\n15.6.2 还原逻辑备份\n15.6.3 基于时间点的恢复\n15.6.4 更高级的恢复技术\n15.6.5 InnoDB崩溃恢复\n15.7 备份和恢复工具 15.7.1 MySQL Enterprise Backup\n15.7.2 Percona XtraBackup\n15.7.3 mylvmbackup\n15.7.4 Zmanda Recovery Manager\n15.7.5 mydumper\n15.7.6 mysqldump\n15.8 备份脚本化\n15.9 总结\n第16章 MySQL用户工具 16.1 接口工具\n16.2 命令行工具集\n16.3 SQL实用集\n16.4 监测工具 16.4.1 开源的监控工具\n16.4.2 商业监控系统\n16.4.3 Innotop的命令行监控\n16.5 总结\n附录A MySQL分支与变种\n附录B MySQL服务器状态\n附录C 大文件传输\n附录D EXPLAIN\n附录E 锁的调试\n附录F 在MySQL上使用Sphinx\n索引\n推荐序\n很多年前我就是这本书的“粉丝”了,这是一本伟大的书,第三版尤其如此。这些世界级的专家不仅仅分享他们的专业知识,也花了很多时间来更新和添加新的章节,且都是高品质的内容。本书有大量关于如何获得MySQL高性能的细节信息,并且关注的是提升性能的过程,而不仅仅是描述事实结果和琐碎的细枝末节。这本书将告诉读者如何将事情做得更好,不管MySQL在不同版本中的行为有多么大的改变。\n毫无疑问,本书的作者是唯一有资格来写这么一本书的人,他们经验丰富,有合理的方法,关注效率,并且精益求精。说到经验丰富,本书的作者已经在MySQL性能领域工作多年,从MySQL还没有什么可扩展性和可测量性的时代,直到现在这些方面已经有了长足的进步。而说到合理的方法,他们简直把这件事情当成了科学,首先定义需要解决的问题,然后通过合理的猜测和精确的测量来解决问题。\n我对作者在效率方面的关注尤其印象深刻。作为顾问,他们时间宝贵。客户是按照他们的时间付费的,所以都希望能更快地解决问题。所以本书作者定义了一整套的流程,开发了很多的工具,让事情变得正确和高效。在本书中,作者详细描述了这些流程,并且发布了工具的源代码。\n最后,本书作者在工作上一直精益求精。比如从吞吐量到响应时间的关注,致力于了解MySQL在新硬件上的性能表现,追求新的技能如排队理论对性能的影响,等等。\n我相信本书预示了MySQL的光明前景。MySQL已经支持高要求的工作负载,本书作者也在努力提升MySQL社区内对性能的认识。同时,他们还直接为性能提升做出了贡献,包括XtraDB和XtraBackup。一直以来我从他们身上学到了不少东西,也希望读者多花点时间读读本书,一定会同样有所收益。\n——Mark Callaghan,Facebook软件工程师\n前言\n我们写这本书不仅仅是为了满足MySQL应用开发者的需求,也是为了满足MySQL数据库管理员的需要。我们假定读者已经有了一定的MySQL基础。我们还假定读者对于系统管理、网络和类Unix的操作系统都有一些了解。\n本书的第二版为读者提供了大量的信息,但没有一本书是可以涵盖一个主题的所有方面的。在第二版和第三版之间的这段时间里,我们记录了数以千计有趣的问题,其中有些是我们解决的,也有一些是我们观察到其他人解决的。当我们在规划第三版的时候发现,如果要把这些主题完全覆盖,可能三千页到五千页的篇幅都还不够,这样本书的完成就遥遥无期了。在反思这个问题后,我们意识到第二版强调的广泛的覆盖度事实上有其自身的限制,从某种意义上来说也没有引导读者如何按照MySQL的方式来思考问题。\n所以第三版和第二版的关注点有很大的不同。我们虽然还是会包含很多的信息,并且会强调同样的诸如可靠性和正确性的目标,但我们也会在本书中尝试更深入的讨论:我们会指出MySQL为什么会这样做,而不是MySQL做了什么。我们会使用更多的演示和案例学习来将上述原则落地。通过这样的方式,我们希望能够尝试回到下面这样的问题:“给出MySQL的内部结构和操作,对于实际应用能带来什么帮助?为什么能有这样的帮助?如何让MySQL适合(或者不适合)特定的需求?”\n最后,我们希望关于MySQL内部原理的知识能够帮助大家解决本书没有覆盖到的一些情况。我们更希望读者能培养发现新问题的洞察力,能学习和实践合理的方式来设计、维护和诊断基于MySQL的系统。\n本书是如何组织的 # 本书涵盖了许多复杂的主题。在这里,我们将解释一下是如何将这些主题有序地组织在一起的,以便于阅读和学习。\n概述 # 第1章是非常基础的一章,在更深入地学习之前建议先熟悉一下这部分内容。在有效地使用MySQL之前应当理解它是如何组织的。本章解释了MySQL的架构及其存储引擎的关键设计。如果读者还不太熟悉关系数据库和事务的基础知识,本章也可以带来一点帮助。如果之前已经对其他关系数据库如Oracle比较熟悉,本章也可以帮助读者了解MySQL的入门知识。本章还包括了一点MySQL的历史背景:MySQL随着时间的演进、最近的公司所有权更替,以及我们认为比较重要的内容。\n打造坚实的基础 # 本书前几章的内容在今后使用MySQL的过程中可能会被不断地引用到,它们是非常基础的内容。\n第2章讨论了基准测试的基础,例如服务器可以处理的工作负载的类型、处理特定任务的速度等。基准测试是一项至关重要的技能,可用于评估服务器在不同负载下的表现,但也要明白在什么情况下基准测试不能发挥作用。\n第3章介绍了我们常用于故障诊断和服务器性能问题分析的一种面向响应时间的方法。该方法已经被证明可以解决我们曾碰到过的一些极为棘手的问题。当然也可以选择修改我们所使用的方法(实际上我们的方法也是从Cary Millsap的方法修改而来的),但无论如何,至少不能没有方法胡乱猜测。\n从第4章到第6章,连续介绍了三个关于良好的数据库逻辑设计和物理设计基础的话题。第4章涵盖了不同数据类型的细节差别以及表设计的原则。第5章则展开讨论了索引,这是数据库的物理设计。对于索引的深入理解和利用是高效使用MySQL的基础,相信这一章会经常需要回头翻看。而第6章则包含了分析MySQL的查询是如何执行的,以及如何利用查询优化器的话题。该章也包含了大量常见类型查询的例子,演示了MySQL是如何做好工作的,以及如何改写查询以利用MySQL的特性。\n到此为止,已经覆盖了关于数据库的基础内容:表、索引、数据和查询。第7章则在MySQL基础知识之外介绍了MySQL的高级特性是如何工作的。这章的内容包括分区、存储引擎、触发器,以及字符集。MySQL中这些特性的实现可能不同于其他数据库,可能之前读者并不清楚这些不同,因此理解它们对于性能可能会带来新的收益。\n配置应用程序 # 接下来的两章讲述的是如何让MySQL、应用程序及硬件一起很好地工作。第8章介绍了如何配置MySQL,以便更好地利用硬件,达到更好的可靠性和鲁棒性。第9章解释了如何让操作系统和硬件工作得更好。另外也深入讨论了固态硬盘,为高可扩展性应用发挥更好的性能提供了硬件配置的建议。\n上面两章都一定程度地涉及了MySQL的内部知识。这将会是一个反复出现的主题,附录中也会有相关内容可以学习到MySQL的内部是如何实现的,理解了这些知识将帮助读者更好地理解某些现象背后的原理。\n作为基础设施组件的MySQL # MySQL不是存在于真空中的,而是应用整体的一个环节,因此需要考虑整个应用架构的鲁棒性。下面的章节将告诉我们该如何做到这一点。\n第10章讨论了MySQL的杀手级特性:能够设置多个服务器从一台主服务器同步数据。不幸的是,复制可能也是MySQL给很多用户带来困扰的一个特性。但实际上不应该发生这样的情况,本章将告诉你如何让复制运行得更好。\n第11章讨论了什么是可扩展性(这和性能不是一回事),应用和系统为什么会无法扩展,该怎么改善扩展性。如果能够正确地处理,MySQL的可扩展性是足以应付任何需求的。第12章讲述的是和可扩展性相关但又完全不同的主题:如何保障MySQL稳定而正确地持续运行。第13章将告诉你当MySQL在云计算环境中运行时会有什么不同的事情发生。第14章解释了什么是全方位的优化(full-stack optimization),就是从前端到后端的整体优化,从用户体验开始直到数据库。\n即使是世界上设计最好、最具可扩展性的架构,如果停电会导致彻底崩溃,无法抵御恶意攻击,解决不了应用的bug和程序员的错误,以及其他一些灾难场景,那就不是什么好的架构。第15章讨论了MySQL数据库各种备份与恢复的场景。这些策略可以帮助读者减少在各种不可抗的硬件失效时的宕机时间,保证在各种灾难下的数据最终可恢复。\n其他有用的主题 # 在本书的最后一章以及附录中,我们探讨了一些无法明确地放到前面章节的内容,以及一些被前面多个章节引用而需要特别注意的主题。\n第16章探索了一些可以帮助用户更有效地管理和监控MySQL服务器的工具,有些是开源的,也有些是商业的。\n附录A介绍了近年来成长迅速的三个主要的非MySQL官方版本,其中一个是我们公司在维护的产品。知道还有其他什么是可用的选择是有价值的;很多MySQL难以解决的棘手问题在其他的变种版本中说不定就不是问题了。这三个版本中的两个(Percona Server和MariaDB)是MySQL的完全可替换版本,所以尝试使用的成本相对来说是很低的。当然,在这里我们也需要补充一点,Oracle提供的MySQL官方版本对于大多数用户来说都能服务得很好。\n附录B演示了如何检查MySQL服务器。知道如何从服务器获取状态信息是非常重要的;而了解这些状态代表的意义则更加重要。这里将覆盖SHOW INNODB STATUS的输出结果,因此这里包含了InnoDB事务存储引擎的深入信息。在这个附录中讨论了很多InnoDB的内部信息。\n附录C演示了如何高效地将大文件从一个地方复制到另外一个地方。如果要管理大量的数据,这种操作是经常都会碰到的。附录D演示了如何真正地使用并理解EXPLAIN命令。附录E演示了如何破除不同查询所请求的锁互相干扰的问题。最后,附录F介绍了Sphinx,一个基于MySQL的高性能的全文索引系统。\n软件版本与可用性 # MySQL是一个移动靶。从Jeremy写作本书第一版到现在,MySQL已经发布了好几个版本。当本书第一版的初稿交给出版社的时候,MySQL 4.1和5.0还只是alpha版本,而如今MySQL 5.1和5.5已经是很多在线应用的主力版本。在我们写完这第三版的时候,MySQL 5.6也即将发布。\n本书的内容并不依赖某一个具体的版本。相反,我们会利用自己在实际环境中获得的更广泛的知识。本书的核心内容主要关注MySQL 5.1和5.5版本,因为我们认为这是“当前”的版本。本书的大多数例子都假设运行在MySQL 5.1的某个成熟版本上,比如MySQL 5.1.50或者更高的版本。对于在旧版本中可能不存在,或者只在即将到来的5.6版本中出现的特性或者功能,我们也会特别标注出来。然而,关于某个MySQL版本的特性的权威指南还是要看官方文档。在阅读本书时,建议随时访问在线官方文档的相关内容( http://dev.mysql.com/doc/)。\nMySQL的另外一个伟大特点是能够运行在现今流行的所有平台:Mac OS X,Windows,GNU/Linux,Solaris,FreeBSD,以及只要你能举出名字的其他平台。然而,本书主要基于GNU/Linux(1)和其他类Unix系统。Windows的用户可能会碰到一些困难。比如说文件路径就和Windows完全不一样。我们也会引用一些Unix的命令行工具,我们假设读者能够知道Windows上对应的工具是什么(2)。\n在Windows上搞MySQL的另外一个难点是Perl。MySQL中有很多有用的工具是用Perl写的。在本书的一些章节中,也有一些Perl脚本,在此基础上可以构建更加复杂的工具。Percona Toolkit是不可多得的MySQL管理工具,也是用Perl写的。然而,Windows平台默认是没有Perl环境的。为了使用这些工具,需要从ActiveState下载Perl的Windows版本,以及访问MySQL所需要的一些额外的模块(DBI和DBD::MySQL)。\n本书使用的约定 # 下面是本书中使用的一些约定。\n斜体(Italic)\n新的名字、URL、邮件地址、用户名、主机名、文件名、文件扩展名、路径名、目录,以及Unix命令和工具都使用斜体表示。\n等宽字体(Constant width)\n包括代码元素、配置选项、数据库和表名、变量和值、函数、模块、文件内容、命令输出等,使用的是等宽字体。\n加粗的等宽字体(Constant width bold)\n命令或者其他需要用户输入的文本,命令输出中需要强调的某些内容,会使用加粗的等宽字体。\n斜体的等宽字体(Constant width italic)\n需要用户替换的文本以斜体的等宽字体表示。\n这个图标表示提示、建议,或者一般的记录。\n这个图标表示一个警告或者提醒。\n使用示例代码 # 本书的目标是为了帮助读者更好地工作。一般来说,你可以在程序或者文档中使用本书中的代码。只要不是大规模地复制重要的代码,使用的时候不需要联系我们。例如,你编写的程序中如果只是使用了本书部分的代码片段则无须取得授权,而出售或者分发O\u0026rsquo;Reilly书籍示例代码的CD-ROM盘片则需要经过授权。引用本书的代码回答问题也无须取得授权,而大量引用本书的示例代码到产品文档中则需要获取授权。\n示例代码维护在 http://www.highperfmysql.com站点中,会及时保持更新。但我们无法确保代码会跟随每一个MySQL的小版本进行更新和测试。\n我们欢迎大家在使用了本书代码后进行反馈,但这不是一个强制要求。反馈时请提供标题、作者、出版公司和ISBN。例如:“High Performance MySQL,Third Edition,by Baron Schwartz et al.(O\u0026rsquo;Reilly). Copyright 2012 Baron Schwartz,Peter Zaitsev,and Vadim Tkachenko,978-1-449-31428-6”。\n如果你使用了本书的代码,但又不在上面描述的一些无须授权的范围之内,不确定是否需要获取授权时,请联系 permissions@oreilly.com。\nSafari在线书店 # Safari在线书店( www.safaribooksonline.com)是一家提供定制服务的数字图书馆,提供技术和商务领域内顶级作家的高质量内容的书籍和音像制品。很多技术专家、软件开发者、Web设计师、商务人士和创新专家都将Safari在线书店作为他们研究、解决问题、学习和认证练习的首选资料来源。\nSafari在线书店为组织、政府机构和个人提供了一系列的产品组合和定价计划。订阅者可以访问数以千计的图书、培训视频和手稿,这些存在于一个可搜索的数据库中,涵盖的出版公司有O\u0026rsquo;Reilly Media,Prentice Hall Professional,Addison-Wesley Professional,Microsoft Press,Sams,Que,Peachpit Press,Focal Press,Cisco Press,John Wiley\u0026amp;Sons,Syngress,Morgan Kaufmann,IBM Redbooks,Packt,Adobe Press,FT Press,Apress,Manning,New Riders,McGraw-Hill,Jones\u0026amp;Bartlett,Course Technology,等等。如需了解更多关于Safari在线书店的情况,请访问在线网站。\n如何联系我们 # 若有关于本书的任何评论或者问题,请和出版公司联系。\n美国:\nO\u0026rsquo;Reilly Media,Inc.\n1005 Gravenstein Highway North\nSebastopol,CA 95472\n中国:\n北京市西城区西直门南大街2号成铭大厦C座807室(100035)\n奥莱利技术咨询(北京)有限公司\n本书有一个配套的网页,上面列出了勘误表、示例代码及其他相关信息。下面是此网页的地址:\nhttp://shop.oreilly.com/product/0636920022343.do\n如果有关于本书的评论和技术问题,也可以通过邮件进行沟通:\nbookquestions@oreilly.com\n如果想了解更多关于我们出版公司的书籍、会议、资源中心和O\u0026rsquo;Reilly网络的信息,请访问网站:\nhttp://www.oreilly.com\n我们的Facebook: http://facebook.com/oreilly\n我们的Twitter: http://twitter.com/oreillymedia\n我们的YouTube: http://www.youtube.com/oreillymedia\n当然,读者也可以直接和作者取得联系,可以访问作者的公司网站 http://www.percona.com。我们将乐于收到大家的反馈。\n本书第三版的致谢 # 感谢以下人员给予的各种帮助:Brian Aker,Johan Andersson,Espen Braekken,Mark Callaghan,James Day,Maciej Dobrzanski,Ewen Fortune,Dave Hildebrandt,Fernando Ipar,Haidong Ji,Giuseppe Maxia,Aurimas Mikalauskas,Istvan Podor,Yves Trudeau,Matt Yonkovit,Alex Yurchenko。感谢Percona公司的所有员工,多年来为本书提供了无数的支持。感谢很多著名博主(3)和技术大会的演讲者,他们为本书的很多思想提供了大量的素材,尤其是Yoshinori Matsunobu。另外也要感谢本书前面两版的作者:Jeremy D. Zawodny、Derek J. Balling和Arjen Lentz。感谢Andy Oram、Rachel Head,以及O\u0026rsquo;Reilly的整个编辑团队,你们为本书的出版和发行做了卓有成效的工作。非常感谢Oracle的才华横溢且专注的MySQL团队,以及所有之前的MySQL开发者,不管你现在是在SkySQL还是在Monty团队。\nBaron也要感谢他的妻子Lynn、他的母亲Connie,以及他的岳父母Jane和Roger,感谢他们一如既往地支持他的工作,尤其是不断地鼓励他,并且承担了所有的家务和照顾整个家庭的重任。也要感谢Peter和Vadim,你们是如此优秀的老师和同事。Baron将此版本献给Alan Rimm-Kaufman,以纪念他给予的伟大的爱和鼓励,这些都将永志不忘。\n本书第二版的致谢 # Sphinx的开发者Andrew Aksyonoff编写了附录F。我们非常感谢他首次对此进行深入的讨论。\n在编写本书的时候,我们得到了很多人的无私帮助。在此无法一一列举——我们真的非常感谢MySQL社区和MySQL AB公司的每一个人。下面是对本书做出了直接贡献的人,如有遗漏,还请见谅。他们是:Tobias Asplund,Igor Babaev,Pascal Borghino,Roland Bouman,Ronald Bradford,Mark Callaghan,Jeremy Cole,Britt Crawford和他的HiveDB项目,Vasil Dimov,Harrison Fisk,Florian Haas,Dmitri Joukovski和他的Zmanda项目(同时感谢Dmitri为解释LVM快照提供的配图),Alan Kasindorf,Sheeri Kritzer Cabral,Marko Makela,Giuseppe Maxia,Paul McCullagh,B. Keith Murphy,Dhiren Patel,Sergey Petrunia,Alexander Rubin,Paul Tuckfield,Heikki Tuuri,以及Michael“Monty”Widenius。在这里还要特别感谢O\u0026rsquo;Reilly的编辑Andy Oram和助理编辑Isabel Kunkle,以及审稿人Rachel Wheeler,同时也要感谢O\u0026rsquo;Reilly团队的其他所有成员。\n来自Baron # 我要感谢我的妻子Lynn Rainville和小狗Carbon。如果你也曾写过一本书,我相信你就能体会到我是如何地感谢他们。我也非常感谢Alan Rimm-Kaufman和我在Rimm-Kaufman集团的同事,在写书的过程中,他们给了我支持和鼓励。谢谢Peter、Vadim和Arjen,是你们给了我梦想成真的机会。最后,我要感谢Jeremy和Derek为我们开了个好头。\n来自Peter # 我从事MySQL性能和可扩展性方面的演讲、培训和咨询工作已经很多年了,我一直想把它们扩展到更多的受众。因此,当Andy Oram加入到本书的编写当中时,我感到非常兴奋。此前我没有写过书,所以我对所需要的时间和精力都毫无把握。一开始我们谈到只对第一版做一些更新,以跟上MySQL最新的版本升级,但我们想把更多新素材加入到书中,结果几乎相当于重写了整本书。\n这本书是真正的团队合作的结晶。因为我忙于Percona公司的事情——这是我和Vadim的咨询公司,而且英语并非我的第一语言,所以我们有着不同的角色分工。我负责提供大纲和技术性内容,评审所有的材料,在写作的时候再进行修订和扩展。当Arjen(MySQL文档团队的前负责人)加入之后,我们就开始勾画出整个提纲。在Baron加入后,一切才开始真正行动起来,他能够以不可思议的速度编写出高质量的内容。Vadim则在深入检查MySQL源代码和提供基准测试及其他研究来巩固我们的论点时提供了巨大的帮助。\n当我们编写本书时,我们发现有越来越多的领域需要刨根问底。本书的大部分主题,如复制、查询优化、InnoDB、架构和设计都足以单独成书。因此,有时候我们不得不在某个点停止深入,把余下的材料用在将来可能出版的新版本中,或者我们的博客、演讲和技术文章中。\n本书的评审者给了我们非常大的帮助,无论是来自MySQL AB公司内部的人员,还是外部的人员,他们都是MySQL领域最优秀的世界级专家。其中包括MySQL的创建者Michael Widenius、InnoDB的创建者Heikki Tuuri、MySQL优化器团队的负责人Igor Babaev,以及其他人。\n我还要感谢我的妻子Katya Zaytseva,我的孩子Ivan和Nadezhda,他们允许我把家庭时间花在了本书的写作上。我也要感谢Percona的员工,当我在公司里“人间蒸发”去写书时,他们承担了日常事务的处理工作。当然,我也要感谢O\u0026rsquo;Reilly和Andy Oram让这一切成为可能。\n来自Vadim # 我要感谢Peter,能够在本书中和他合作,我感到十分开心,期望在其他项目中能继续共事。我也要感谢Baron,他在本书的写作过程中起了很大的作用。还有Arjen,跟他一起工作非常好玩。我还要感谢我们的编辑Andy Oram,他抱着十二万分的耐心和我们一起工作。此外,还要感谢MySQL团队,是他们创造了这个伟大的软件。我还要感谢我们的客户给予我调优MySQL的机会。最后,我要特别感谢我的妻子Valerie,以及我们的孩子Myroslav和Timur,他们一直支持我,帮助我一步步前进。\n来自Arjen # 我要感谢Andy的睿智、指导和耐心,感谢Baron中途加入到我们当中来、感谢Peter和Vadim坚实的背景信息和基准测试。也要感谢Jeremy和Derek在第一版中打下的基础。在我的书上,Derak题写着:“要诚实——这就是我的所有要求”。\n我也要感谢我在MySQL AB公司时的所有同事,在那里我获得了关于本书主题的大多数知识。在此,我还要特别提到Monty,我一直认为他是令人自豪的MySQL之父,尽管他的公司如今已经成为Sun公司的一部分。我要感谢全球MySQL社区里的每一个人。\n最后同样重要的是,我要感谢我的女儿Phoebe,在她尚年少的生活舞台上,不用关心什么是MySQL,也不用考虑Wiggles指的是什么东西。从某些方面来讲,无知就是福。它能给予我们一个全新的视角来看清生命中真正重要的是什么。对于读者,祝愿你们的书架上又增添了一本有用的书,还有,不要忘记你的生活。\n本书第一版的致谢 # 要完成这样一本书的写作,离不开许许多多人的帮助。没有他们的无私援助,你手上的这本书就可能仍然是我们显示器屏幕四周的那一堆贴纸。这是本书的一部分,在这里,我们可以感谢每一个曾经帮助我们脱离困境的人,而无须担心突然奏响的背景音乐催促我们闭上嘴巴赶紧走掉——如同你在电视里看到的颁奖晚会那样。\n如果没有编辑Andy Oram坚决的督促、请求、央求和支持,我们就无法完成本书。如果要找对于本书最负责的一个人,那就是Andy。我们真的非常感谢每周一次的唠唠叨叨的会议。\n然而,Andy不是一个人在战斗。在O\u0026rsquo;Reilly,还有一批人都参与了将那些小贴纸变成你正在看的这本书的工作。所以我们也要感谢那些在生产、插画和销售环节的人们,感谢你们把这本书变成实体。当然,也要感谢Tim O\u0026rsquo;Reilly,是他持久不变地承诺为广大开源软件出版一批业内最好的图书。\n最后,我们要把感谢给予那些同意审阅本书不同版本草稿,并告诉我们哪里有错误的人们:我们的评审者。他们把2003年假期的一部分时间用在了审阅这些格式粗糙,充满了打字符号、误导性的语句和彻底的数学错误的文本上。我们要感谢(排名不分先后):Brian“Krow”Aker,Mark“JDBC”Matthews,Jeremy“the other Jeremy”Cole,Mike“VBMySQL.com( http://vbmysql.com)”Hillyer,Raymond“Rainman”De Roo,Jeffrey“Regex Master”Friedl,Jason DeHaan,Dan Nelson,Steve“Unix Wiz”Friedl,Kasia“Unix Girl”Trapszo。\n来自Jeremy # 我要在此感谢Andy,是他同意接纳这个项目,并持续不断地鞭策我们加入新的章节内容。Derek的帮助也非常关键,本书最后的20%~30%内容由他一手完成,这使得我们没有错过下一个目标日期。感谢他同意中途加入进来,代替我只能偶尔爆发一下的零星生产力,完成了关于XML的烦琐工作、第10章、附录F,以及我丢给他的那些活儿。\n我也要感谢我的父母,在多年以前他们就给我买了Commodore 64电脑,他们不仅在前10年里容忍了我就像要以身相许般的对电子和计算机技术的痴迷,并在之后还成为我不懈学习和探索的支持者。\n接下来,我要感谢在过去几年里在Yahoo!布道推广MySQL时遇到的那一群人。跟他们共事,我感到非常愉快。在本书的筹备阶段,Jeffrey Friedl和Ray Goldberger给了我鼓励和反馈意见。在他们之后还有Steve Morris、James Harvey和Sergey Kolychev容忍了我在Yahoo! Finance MySQL服务器上做着看似固定不变的实验,即使这打扰了他们的重要工作。我也要感谢Yahoo!的其他成员,是他们帮我发现了MySQL上的那些有趣的问题和解决方法。还有,最重要的是要感谢他们对我有足够的信任和信念,让我把MySQL用在Yahoo!重要和可见的部分业务上。\nAdam Goodman,一位出版家和Linux Magazine的拥有者,他帮助我轻装上阵为技术受众撰写文章,并在2001年下半年第一次出版了我的MySQL相关的长篇文章。自那以后,他教授给我更多他所能认识到的关于编辑和出版的技能,还鼓励我通过在杂志上开设月度专栏在这条路上继续走下去。谢谢你,Adam。\n我要感谢Monty和David,感谢你们与这个世界分享了MySQL。说到MySQL AB,也要感谢在那里的其他“牛”人,是他们鼓励我写成本书:Kerry,Larry,Joe,Marten,Brian,Paul,Jeremy,Mark,Harrison,Matt,以及团队中的其他人。他们真的非常棒。\n最后,我要感谢我博客的读者,是他们鼓励我撰写基于日常工作的非正式的MySQL及其他技术文章。最后同样重要的是,感谢Goon Squad。\n来自Derek # 就像Jeremy一样,有太多同样的原因,我也要感谢我的家庭。我要感谢我的父母,是他们不停地鼓励我去写一本书,哪怕他们头脑中都没有任何和它相关的东西。我的祖父母给我上了两堂很有价值的课:美元的含义,以及我跟电脑相爱有多深,他们还借钱给我去购买了我平生第一台电脑:Commodore VIC-20。\n我万分感谢Jeremy邀请我加入他那旋风般的写作“过山车”中来。这是一个很棒的体验,我希望将来还能跟他一起工作。\n我要特别感谢Raymond De Roo,Brian Wohlgemuth,David Calafrancesco,Tera Doty,Jay Rubin,Bill Catlan,Anthony Howe,Mark O\u0026rsquo;Neal,George Montgomery,George Barber,以及其他无数耐心听我抱怨的人,我从他们那里了解到我所讲述的内容是否能让门外汉也能理解,或者仅仅得到一个我所希望的笑脸。没有他们,这本书可能也能写出来,但我几乎可以肯定我会在这个过程中疯掉。\n————————————————————\n(1) 为了避免产生疑惑,如果我们指的是内核的时候用的是Linux,如果指的是支持应用的整个操作系统环境的时候用的是GNU/Linux。\n(2) 可以从 http://unxutils.sourceforge.net或者 http://gnuwin32.sourceforge.net获得Unix工具的Windows兼容版本。\n(3) 在 http://planet.mysql.com网站上可以找到很多优秀的技术博客。\n"},{"id":138,"href":"/zh/docs/technology/MySQL/_%E9%AB%98%E6%80%A7%E8%83%BDMySQL_/%E7%AC%AC9%E7%AB%A0%E6%93%8D%E4%BD%9C%E7%B3%BB%E7%BB%9F%E5%92%8C%E7%A1%AC%E4%BB%B6%E4%BC%98%E5%8C%96/","title":"第9章操作系统和硬件优化","section":"高性能 My SQL","content":"第9章 操作系统和硬件优化\nMySQL服务器性能受制于整个系统最薄弱的环节,承载它的操作系统和硬件往往是限制因素。磁盘大小、可用内存和CPU资源、网络,以及所有连接它们的组件,都会限制系统的最终容量。因此,需要小心地选择硬件,并对硬件和操作系统进行合适的配置。例如,若工作负载是I/O密集型的,一种方法是设计应用程序使得最大限度地减少MySQL的I/O操作。然而,更聪明的方式通常是升级I/O子系统,安装更多的内存,或重新配置现有的磁盘。\n硬件的更新换代非常迅速,所以本章有关特定产品或组件的内容可能将很快变得过时。像往常一样,我们的目标是帮助提升对这些概念的理解,这样对于即使没有直接覆盖到的知识也可以举一反三。这里我们将通过现有的硬件来阐明我们的观点。\n9.1 什么限制了MySQL的性能 # 许多不同的硬件都可以影响MySQL的性能,但我们认为最常见的两个瓶颈是CPU和I/O资源。当数据可以放在内存中或者可以从磁盘中以足够快的速度读取时,CPU可能出现瓶颈。把大量的数据集完全放到大容量的内存中,以现在的硬件条件完全是可行的(1)。\n另一方面,I/O瓶颈,一般发生在工作所需的数据远远超过有效内存容量的时候。如果应用程序是分布在网络上的,或者如果有大量的查询和低延迟的要求,瓶颈可能转移到网络上,而不再是磁盘I/O(2)。\n第3章中提及的技巧可以帮助找到系统的限制因素,但即使你认为已经找到了瓶颈,也应该透过表象去看更深层次的问题。某一方面的缺陷常常会将压力施加在另一个子系统,导致这个子系统出问题。例如,若没有足够的内存,MySQL可能必须刷出缓存来腾出空间给需要的数据——然后,过了一小会,再读回刚刚刷新的数据(读取和写入操作都可能发生这个问题)。本来是内存不足,却导致出现了I/O容量不足。当找到一个限制系统性能的因素时,应该问问自己,“是这个部分本身的问题,还是系统中其他不合理的压力转移到这里所导致的?”在第3章的诊断案例中也有讨论到这个问题。\n还有另外一个例子:内存总线的瓶颈也可能表现为CPU问题。事实上,我们说一个应用程序有“CPU瓶颈”或者是“CPU密集型”,真正的意思应该是计算的瓶颈。接下来将深入探讨这个问题。\n9.2 如何为MySQL选择CPU # 在升级当前硬件或购买新的硬件时,应该考虑下工作负载是不是CPU密集型。\n可以通过检查CPU利用率来判断是否是CPU密集型的工作负载,但是仅看CPU整体的负载是不合理的,还需要看看CPU使用率和大多数重要的查询的I/O之间的平衡,并注意CPU负载是否分配均匀。本章稍后讨论的工具可以用来弄清楚是什么限制了服务器的性能。\n9.2.1 哪个更好:更快的CPU还是更多的CPU # 当遇到CPU密集型的工作时,MySQL通常可以从更快的CPU中获益(相对更多的CPU)。\n但这不是绝对的,因为还依赖于负载情况和CPU数量。更古老的MySQL版本在多CPU上有扩展性问题,即使新版本也不能对单个查询并发利用多个CPU。因此,CPU速度限制了每个CPU密集型查询的响应时间。\n当我们讨论CPU的时候,为保证本文易于阅读,对某些术语将不会做严格的定义。现在一般的服务器通常都有多个插槽(Socket),每个插槽上都可以插一个有多个核心的CPU(有独立的执行单元),并且每个核心可能有多个“硬件线程”。这些复杂的架构需要有点耐心去了解,并且我们不会总是明确地区分它们。不过,在一般情况下,当谈到CPU速度的时候,谈论的其实是执行单元的速度,当提到的CPU数量时,指的通常是在操作系统上看到的数量,尽管这可能是独立的执行单元数量的多倍(3)。\n这几年CPU在各个方面都有了很大的提升。例如,今天的Intel CPU速度远远超过前几代,这得益于像直接内存连接(directly attached memory)技术以及PCIe卡之类的设备互联上的改善等。这些改进对于存储设备尤其有效,例如Fusion-io和Virident的PCIe闪存驱动器。\n超线程的效果相比以前也要好得多,现在操作系统也更了解如何更好地使用超线程。而以前版本的操作系统无法识别两个虚拟处理器实际上是在同一芯片上,认为它们是独立的,于是会把任务安排在两个实际上是相同物理执行单元上的虚拟处理器。实际上单个执行单元并不是真的可以在同一时间运行两个进程,所以这样做会发生冲突和争夺资源。而同时其他CPU却可能在闲置,从而浪费资源。操作系统需要能感知超线程,因为它必须知道什么时候执行单元实际上是闲置的,然后切换相应的任务去执行。这个问题之前常见的原因是在等待内存总线,可能花费需要高达一百个CPU周期,这已经类似于一个轻量级的I/O等待。新的操作系统在这方面有了很大的改善。超线程现在已经工作得很好。过去,我们时常提醒人们禁用它,但现在已经不需要这样做了。\n这就是说,现在可以得到大量的快速的CPU——比本书的第2版出版的时候要多得多。所以多和快哪个更重要?一般来说两个都想要。从广义上来说,调优服务器可能有如下两个目标:\n低延时(快速响应)\n要做到这一点,需要高速CPU,因为每个查询只能使用一个CPU。\n高吞吐\n如果能同时运行很多查询语句,则可以从多个CPU处理查询中受益。然而,在实践中,还要取决于具体情况。因为MySQL还不能在多个CPU中完美地扩展,能用多少个CPU还是有极限的。在旧版本的MySQL中(MySQL 5.1以后的版本已经有一些提升),这个限制非常严重。在新的版本中,则可以放心地扩展到16或24个CPU,或者更多,取决于使用的是哪个版本(Percona往往在这方面略占优势)。\n如果有多路CPU,并且没有并发执行查询语句,MySQL依然可以利用额外的CPU为后台任务(例如清理InnoDB缓冲、网络操作,等等)服务。然而,这些任务通常比执行查询语句更加轻量化。\nMySQL复制(将在下一章中讨论)也能在高速CPU下工作得非常好,而多CPU对复制的帮助却不大。如果工作负载是CPU密集型,主库上的并发任务传递到备库以后会被简化为串行任务,这样即使备库硬件比主库好,也可能无法保持跟主库之间的同步。也就是说,备库的瓶颈通常是I/O子系统,而不是CPU。\n如果有一个CPU密集型的工作负载,考虑是需要更快的CPU还是更多CPU的另外一个因素是查询语句实际在做什么。在硬件层面,一个查询可以在执行或等待。处于等待状态常见的原因是在运行队列中等待(进程已经是可运行状态,但所有的CPU都忙)、等待闩锁(Latch)或锁(Lock)、等待磁盘或网络。那么你期望查询是等待什么呢?如果等待闩锁或锁,通常需要更快的CPU;如果在运行队列中等待,那么更多或者更快的CPU都可能有帮助。(也可能有例外,例如,查询等待InnoDB日志缓冲区的Mutex,直到I/O完成前都不会释放——这可能表明需要更多的I/O容量)。\n这就是说,MySQL在某些工作负载下可以有效地利用很多CPU。例如,假设有很多连接查询的是不同表(假设这些查询不会造成表锁的竞争,实际上对MyISAM和MEMORY表可能会有问题),并且服务器的总吞吐量比任何单个查询的响应时间都更重要。吞吐量在这种情况下可以非常高,因为线程可以同时运行而互不争用。\n再次说明,在理论上这可能更好地工作:不管查询是读取不同的表还是相同的表, InnoDB都会有一些全局共享的数据结构,而MyISAM在每个缓冲区都有全局锁。而且不仅仅是存储引擎,服务器层也有全局锁。以前InnoDB承担了所有的骂名,但最近做了一些改进后,暴露了服务器层中的其他瓶颈。例如臭名昭著的LOCK_open互斥量(Mutex),在MySQL 5.1和更早版本中可能就是个大问题,另外还有其他一些服务器级别的互斥量(例如查询缓存)。\n通常可以通过堆栈跟踪来诊断这些类型的竞争问题,例如Percona Toolkit中的pt-pmp工具。如果遇到这样的问题,可能需要改变服务器的配置,禁用或改变引起问题的组件,进行数据分片(Sharding),或者通过某种方式改变做事的方法。这里无法列举所有的问题和相应的解决方案,但是一旦有一个确定的诊断,答案通常是显而易见的。大部分不幸遇到的问题都是边缘场景,最常见的问题随着时间的推移都在服务器上被修复了。\n9.2.2 CPU架构 # 可能99%以上的MySQL实例(不含嵌入式使用)都运行在Intel或者AMD芯片的x86架构下。本书中我们基本都是针对这种情况。\n64位架构现在都是默认的了,32位CPU已经很难买到了。MySQL在64位架构上工作良好,尽管有些事暂时不能利用64位架构来做。因此,如果使用的是较老旧版本的MySQL,在64位服务器上可能要小心。例如,在MySQL 5.0发布的早期时候,每个MyISAM键缓冲区被限制为4 GB,由一个32位整数负责寻址。(可以创建多个键缓冲区来解决这个问题。)\n确保在64位硬件上使用64位操作系统!最近这种情况已经不太常见了,但以前经常可以遇到,大多数主机托管提供商暂时还是在服务器上安装32位操作系统,即使是64位CPU。32位操作系统意味着不能使用大量的内存:尽管某些32位系统可以支持大量的内存,但不能像64位系统一样有效地利用,并且在32位系统上,任何一个单独的进程都不能寻址4 GB以上的内存。\n9.2.3 扩展到多个CPU和核心 # 多CPU在联机事务处理(OLTP)系统的场景中非常有用。这些系统通常执行许多小的操作,并且是从多个连接发起请求,因此可以在多个CPU上运行。在这样的环境中,并发可能成为瓶颈。大多数Web应用程序都属于这一类。\nOLTP服务器一般使用InnoDB,尽管它在多CPU的环境中还存在一些未解决的并发问题。然而,不只是InnoDB可能成为瓶颈:任何共享资源都是潜在的竞争点。InnoDB之所以获得大量关注是因为它是高并发环境下最常见的存储引擎,但MyISAM在大压力时的表现也不好,即使不修改任何数据只是读取数据也是如此。许多并发瓶颈,如InnoDB的行级锁和MyISAM的表锁,没有办法优化——除了尽可能快地处理任务之外,没有别的办法解决,这样,锁就可以尽快分配给等待的任务。如果一个锁是造成它们(其他任务)都在等待的原因,那么不管有多少CPU都一样。因此,即使是一些高并发工作负载,也可以从更快的CPU中受益。\n实际上有两种类型的数据库并发问题,需要不同的方法来解决,如下所示。\n逻辑并发问题\n应用程序可以看到资源的竞争,如表或行锁争用。这些问题通常需要好的策略来解决,如改变应用程序、使用不同的存储引擎、改变服务器的配置,或使用不同的锁定提示或事务隔离级别。\n内部并发问题\n比如信号量、访问InnoDB缓冲池页面的资源争用,等等。可以尝试通过改变服务器的设置、改变操作系统,或使用不同的硬件解决这些问题,但通常只能缓解而无法彻底消灭。在某些情况下,使用不同的存储引擎或给存储引擎打补丁,可以帮助缓解这些问题。\nMySQL的“扩展模式”是指它可以有效利用的CPU数量,以及在压力不断增长的情况下如何扩展,这同时取决于工作负载和系统架构。通过“系统架构”的手段是指通过调整操作系统和硬件,而不是通过优化使用MySQL的应用程序。CPU架构(RISC、CISC、流水线深度等)、CPU型号和操作系统都影响MySQL的扩展模式。这也是为什么说基准测试是非常重要的:一些系统可以在不断增加的并发下依然运行得很好,而另一些的表现则糟糕得多。\n有些系统在更多的处理器下甚至可能降低整体性能。这是相当普遍的情况,我们了解到许多人试图升级到有多个CPU的系统,最后只能被迫恢复到旧系统(或绑定MySQL进程到其中某些核心),因为这种升级反而降低了性能。在MySQL 5.0时代,Google的补丁和Percona Server出现之前,能有效利用的CPU核数是4核,但是现在甚至可以看到操作系统报告多达80个“CPU”的服务器。如果规划一个大的升级,必须要同时考虑硬件、服务器版本和工作负载。\n某些MySQL扩展性瓶颈在服务器层,而其他一些在存储引擎层。存储引擎是怎么设计的至关重要,有时更换到一个不同的引擎就可以从多处理器上获得更多效果。\n我们看到在世纪之交围绕处理器速度的战争在一定程度上已经平息,CPU厂商更多地专注于多核CPU和多线程的变化。CPU设计的未来很可能是数百个处理器核心,四核心和六核心的CPU在今天是很常见的。不同厂商的内部架构差异很大,不可能概括出线程、CPU和内核之间的相互作用。内存和总线如何设计也是非常重要的。归根结底,多个内核和多个物理CPU哪个更好,这是由硬件体系结构决定的。\n现代CPU的另外两个复杂之处也值得提一下。首先是频率调整。这是一种电源管理技术,可以根据CPU上的压力而动态地改变CPU的时钟速度。问题是,它有时不能很好地处理间歇性突发的短查询的情况,因为操作系统可能需要一段时间来决定CPU的时钟是否应该变化。结果,查询可能会有一段时间速度较慢,并且响应时间增加了。频率调整可能使间歇性的工作负载性能低下,但可能更重要的是,它会导致性能波动。\n第二个复杂之处是boost技术,这个技术改变了我们对CPU模式的看法。我们曾经以为四核2GHz CPU有四个同样强大的核心,不管其中有些是闲置或非闲置。因此,一个完美的可扩展系统,当它使用所有四个内核的时候,可以预计得到四倍的提升。但是现在已经不是这样了,因为当系统只使用一个核心时,处理器会运行在更高的时钟速度上,例如3GHz。这给很多的规划容量和可扩展性建模的工具出了一个难题,因为系统性能表现不再是线性的变化了。这也意味着,“空闲CPU”并不代表相同规模的资源浪费,如果有一台服务器上只运行了备库的复制,而复制执行是单线程的,所以有三个CPU是空闲的,因此认为可以利用这些CPU资源执行其他任务而不影响复制,可能就想错了。\n9.3 平衡内存和磁盘资源 # 配置大量内存最大的原因其实不是因为可以在内存中保存大量数据:最终目的是避免磁盘I/O,因为磁盘I/O比在内存中访问数据要慢得多。关键是要平衡内存和磁盘的大小、速度、成本和其他因素,以便为工作负载提供高性能的表现。在讨论如何做到这一点之前,暂时先回到基础知识上来。\n计算机包含一个金字塔型的缓存体系,更小、更快、更昂贵的缓存在顶端,如图9-1所示。\n图9-1:缓存层级\n在这个高速缓存层次中,最好是利用各级缓存来存放“热点”数据,以获得更快的访问速度,通常使用一些启发式的方法,例如“最近被使用的数据可能很快再次被使用”以及“相邻的数据可能很快需要使用”,这些算法非常有效,因为它们参考了空间和时间的局部性原理。\n从程序员的视角来看,CPU寄存器和高速缓存是透明的,并且与硬件架构相关。管理它们是编译器和CPU的工作。然而,程序员会有意识地注意到内存和硬盘的不同,并且在程序中通常区分使用它们(4)。\n在数据库服务器上尤其明显,其行为往往非常符合我们刚才提到的预测算法所做的预测。设计良好的数据库缓存(如InnoDB缓冲池),其效率通常超过操作系统的缓存,因为操作系统缓存是为通用任务设计的。数据库缓存更了解数据库存取数据的需求,它包含特殊用途的逻辑(例如写入顺序)以帮助满足这些需求。此外,系统调用不需要访问数据库中的缓存数据。\n这些专用的缓存需求就是为什么必须平衡缓存层次结构以适应数据库服务器特定的访问模式的原因。因为寄存器和芯片上的高速缓存不是用户可配置的,内存和存储是唯一可以改变的东西。\n9.3.1 随机I/O和顺序I/O # 数据库服务器同时使用顺序和随机I/O,随机I/O从缓存中受益最多。想像有一个典型的混合工作负载,均衡地包含单行查找与多行范围扫描,可以说服自己相信这个说法。典型的情况是“热点”数据随机分布。因此,缓存这些数据将有助于避免昂贵的磁盘寻道。相反,顺序读取一般只需要扫描一次数据,所以缓存对它是没用的,除非能完全放在内存中缓存起来。\n顺序读取不能从缓存中受益的另一个原因是它们比随机读快。这有以下两个原因:\n顺序I/O比随机I/O快。\n顺序操作的执行速度比随机操作快,无论是在内存还是磁盘上。假设磁盘每秒可以做100个随机I/O操作,并且可以完成每秒50MB的顺序读取(这大概是消费级磁盘现在能达到的水平)。如果每行100字节,随机读每秒可以读100行,相比之下顺序读可以每秒读500000行——是随机读的5000倍,或几个数量级的差异。因此,在这种情况下随机I/O可以从缓存中获得很多好处。\n顺序访问内存行的速度也快于随机访问。现在的内存芯片通常每秒可以随机访问约250000次100字节的行,或者每秒500万次的顺序访问。请注意,内存随机访问速度比磁盘随机访问快了2 500倍,而内存中顺序访问只有磁盘10倍的速度。\n存储引擎执行顺序读比随机读快。\n一个随机读一般意味着存储引擎必须执行索引操作。(这个规则也有例外,但对InnoDB和MyISAM都是对的)。通常需要通过B树的数据结构查找,并且和其他值比较。相反,连续读取一般需要遍历一个简单的数据结构,例如链表。这样就少了很多工作,反复这样操作,连续读取的速度就比随机读取要快了。\n最后,随机读取通常只要查找特定的行,但不仅仅只读取一行——而是要读取一整页的数据,其中大部分是不需要的。这浪费了很多工作。另一方面,顺序读取数据,通常发生在想要的页面上的所有行,所以更符合成本效益。\n综上所述,通过缓存顺序读取可以节省一些工作,但缓存随机读取可以节省更多的工作。换句话说,如果能负担得起,增加内存是解决随机I/O读取问题最好的办法。\n9.3.2 缓存,读和写 # 如果有足够的内存,就完全可以避免磁盘读取请求。如果所有的数据文件都可以放在内存中,一旦服务器缓存“热”起来了,所有的读操作都会在缓存命中。虽然还是会有逻辑读取,不过物理读取就没有了。但写入是不同的问题。写入可以像读一样在内存中完成,但迟早要被写入到磁盘,所以它是需要持久化的。换句话说,缓存可延缓写入,但不能像消除读取一样消除写入。\n事实上,除了允许写入被延迟,缓存可以允许它们被集中操作,主要通以下两个重要途径:\n多次写入,一次刷新\n一片数据可以在内存中改变很多次,而不需要把所有的新值写到磁盘。当数据最终被刷新到磁盘后,最后一次物理写之前发生的修改都被持久化了。例如,许多语句可以更新内存中的计数器。如果计数器递增100次,然后写入到磁盘,100次修改就被合并为一次写。\nI/O合并\n许多不同部分的数据可以在内存中修改,并且这些修改可以合并在一起,通过一次磁盘操作完成物理写入。\n这就是为什么许多交易系统使用预写日志(WAL)策略。预写日志采用在内存中变更页面,而不马上刷新到磁盘上的策略,因为刷新磁盘通常需要随机I/O,这非常慢。相反,如果把变化的记录写到一个连续的日志文件,这就很快了。后台线程可以稍后把修改的页面刷新到磁盘;并在刷新过程中优化写操作。\n写入从缓冲中大大受益,因为它把随机I/O更多地转换到连续I/O。异步(缓冲)写通常是由操作系统批量处理,使它们能以更优化的方式刷新到磁盘。同步(无缓冲)写必须在写入到磁盘之后才能完成。这就是为什么它们受益于RAID控制器中电池供电的回写(Write-Back)高速缓存(我们稍后讨论RAID)。\n9.3.3 工作集是什么 # 每个应用程序都有一个数据的“工作集”——就是做这个工作确实需要用到的数据。很多数据库都有大量不在工作集内的数据。\n可以把数据库想象为有抽屉的办公桌。工作集就是放在桌面上的完成工作必须使用的文件。桌面是这个比喻中的主内存,而抽屉就是硬盘。\n就像完成工作不需要办公桌里每一张纸一样,也不需要把整个数据库装到内存中来获得最佳性能——只需要工作集就可以。\n工作集大小的不同取决于应用程序。对于某些应用程序,工作集可能是总数据大小的1%,而对于其他应用,也可能接近100%。当工作集不能全放在内存中时,数据库服务器必须在磁盘和内存之间交换数据,以完成工作。这就是为什么内存不足可能看起来却像I/O问题。有时没有办法把整个工作集的数据放在内存中,并且有时也并不真的想这么做(例如,若应用需要大量的顺序I/O)。工作集能否完全放在内存中,对应用程序体系结构的设计会产生很大的影响。\n工作集可以定义为基于时间的百分比。例如,一小时的工作集可能是一个小时内数据库使用的95%的页面,除了5%的最不常用的页面。百分比是考虑这个问题最有用的方式,因为每小时可能需要访问的数据只有1%,但超过24小时,需要访问的数据可能会增加到整个数据库中20%的不同页面。根据需要被缓存起来的数据量多少,来思考工作集会更加直观,缓存的数据越多,工作负载就越可能成为CPU密集型。如果不能缓存足够的数据,工作集就不能完全放在内存中。\n应该依据最常用的页面集来考虑工作集,而不是最频繁读写的页面集。这意味着,确定工作集需要在应用程序内有测量的模块,而不能仅仅看外部资源的利用,例如I/O访问,因为页面的I/O操作跟逻辑访问页面不是同一回事。例如,MySQL可能把一个页面读入内存,然后访问它数百万次,但如果查看strace,只会看到一个I/O操作。缺乏确定工作集所需的检测模块,最大的原因是没有对这个主题有较多的研究。\n工作集包括数据和索引,所以应该采用缓存单位来计数。一个缓存单位是存储引擎工作的数据最小单位。\n不同存储引擎的缓存单位大小是不一样的,因此也使得工作集的大小不一样。例如, InnoDB在默认情况下是16 KB的页。如果InnoDB做一个单行查找需要读取磁盘,就需要把包含该行的整个页面读入缓冲池进行缓存,这会引起一些缓存的浪费。假设要随机访问100字节的行。InnoDB将用掉缓冲池中很多额外的内存来缓存这些行,因为每一行都必须读取和缓存一个完整的16KB页面。因为工作集也包括索引,InnoDB也会读取并缓存查找行所需的索引树的一部分。InnoDB的索引页大小也是16 KB,这意味着访问一个100字节的行可能一共要使用32 KB的缓存空间(有可能更多,这取决于索引树有多深)。因此,缓存单位也是在InnoDB中精心挑选聚集索引非常重要的另一个原因。聚集索引不仅可以优化磁盘访问,还可以帮助在同一页面存储相关的数据,因此在缓存中可以尽量放下整个工作集。\n9.3.4 找到有效的内存/磁盘比例 # 找到一个良好的内存/磁盘比例最好的方式是通过试验和基准测试。如果可以把所有东西放入内存,你就大功告成了——后面没有必要再为此考虑什么。但大多数的时候不可能这么做,所以需要用数据的一个子集来做基准测试,看看将会发生什么。测试的目标是一个可接受的缓存命中率。缓存未命中是当有查询请求数据时,数据不能在内存中命中,服务器需要从磁盘获取数据。\n缓存命中率实际上也会决定使用了多少CPU,所以评估缓存命中率的最好方法是查看CPU使用率。例如,若CPU使用了99%的时间工作,用了1%的时间等待I/O,那缓存命中率还是不错的。\n让我们考虑下工作集是如何影响高速缓存命中率的。首先重要的一点,要认识到工作集不仅是一个单一的数字而是一个统计分布,并且缓存命中率是非线性分布的。例如,有10GB内存,并且缓存未命中率为10%,你可能会认为只需要增加11%以上的内存(5),就可以降低缓存的未命中率到0。但实际上,诸如缓存单位的大小之类的问题会导致缓存效率低下,可能意味着理论上需要50GB的内存,才能把未命中率降到1%。即使与一个完美的缓存单位相匹配,理论预测也可能是错误的:例如数据访问模式的因素也可能让事情更复杂。解决1%的缓存未命中率甚至可能需要500GB的内存,这取决于具体的工作负载!\n有时候很容易去优化一些可能不会带来多少好处的地方。例如,10%的未命中率可能导致80%的CPU使用率,这已经是相当不错的了。假设增加内存,并能够让缓存未命中率下降到5%,简单来说,将提供另外约6%的数据给CPU。再简化一下,也可以说,把CPU使用率增加到了84.8%。然而,考虑到为了得到这个结果需要购买的内存,这可不一定是一个大胜利。在现实中,因为内存和磁盘访问速度之间的差异、CPU真正操作的数据,以及许多其他因素,降低缓存未命中率到5%可能都不会太多改变CPU使用率。\n这就是为什么我们说,你应该争取一个可接受的缓存命中率,而不是将缓存未命中率降低到零。没有一个应该作为目标的数字,因为“可以接受”怎么定义,取决于应用程序和工作负载。有些应用程序有1%的缓存未命中都可以工作得非常好,而另一些应用实际上需要这个比例低到0.01%才能良好运转。(“良好的缓存未命中率”是个模糊的概念,其实有很多方法来进一步计算未命中率。)\n最好的内存/磁盘的比例还取决于系统上的其他组件。假设有16 GB的内存、20 GB的数据,以及大量未使用的磁盘空间系统。该系统在80%的CPU利用率下运行得很好。如果想在这个系统上放置两倍多的数据,并保持相同的性能水平,你可能会认为只需要让CPU数量和内存量也增加到两倍。然而,即使系统中的每个组件都按照增加的负载扩展相同的量(一个不切实际的假设),这依然可能会使得系统无法正常工作。有20GB数据的系统可能使用了某些组件超过50%的容量——例如,它可能已经用掉了每秒I/O最大操作数的80%。并且在系统内排队也是非线性的。服务器将无法处理两倍的负载。因此,最好的内存/磁盘比例取决于系统中最薄弱的组件。\n9.3.5 选择硬盘 # 如果无法满足让足够的数据在内存中的目标——例如,估计将需要500 GB的内存才能完全让CPU负载起当前的I/O系统——那么应该考虑一个更强大的I/O子系统,有时甚至要以牺牲内存为代价,同时应用程序的设计应该能处理I/O等待。\n这听起来似乎有悖常理。毕竟,我们刚刚说过,更多的内存可以缓解I/O子系统的压力,并减少I/O等待。为什么要加强I/O子系统呢,如果只增加内存能解决问题吗?答案就在所涉及的因素之间的平衡,例如读写之间的平衡,每个I/O操作的大小,以及每秒有多少这样的操作发生。例如,若需要快速写日志,就不能通过增加大量有效内存来避免磁盘写入。在这种情况下,投资一个高性能的I/O系统与带电池支持的写缓存或固态存储,可能是个更好的主意。\n作为一个简要回顾,从传统磁盘读取数据的过程分为三个步骤:\n移动读取磁头到磁盘表面上的正确位置。 等待磁盘旋转,所有所需的数据在读取磁头下。 等待磁盘旋转过去,所有所需的数据都被读取磁头读出。 磁盘执行这些操作有多快,可以浓缩为两个数字:访问时间(步骤1和2合并)和传输速度。这两个数字也决定延迟和吞吐量。不管是需要快速访问时间还是快速的传输速度——或混合两者——依赖于正在运行的查询语句的种类。从完成一次磁盘读取所需要的总时间来说,小的随机查找以步骤1和2为主,而大的顺序读主要是第3步。\n其他一些因素也可以影响磁盘的选择,哪个重要取决于应用。假设正在为一个在线应用选择磁盘,例如一个受欢迎的新闻网站,有大量小的磁盘随机读取。可能需要考虑下列因素:\n存储容量\n对在线应用来说容量很少成为问题,因为现在的磁盘通常足够大了。如果不够,用RAID把小磁盘组合起来是标准做法(6)。\n传输速度\n现代磁盘通常数据传输速度非常快,正如我们前面看到的。究竟多快主要取决于主轴转速和数据存储在磁盘表面上的密度,再加上主机系统的接口的限制(许多现代磁盘读取数据的速度比接口可以传输的快)。无论如何,传输速度通常不是在线应用的限制因素,因为它们一般会做很多小的随机查找。\n访问时间\n对随机查找的速度而言,这通常是个主要因素,所以应该寻找更快的访问时间的磁盘。\n主轴转速\n现在常见的转速是7 200RPM、10000RPM,以及15000RPM。转速不管对随机查找还是顺序扫描都有很大影响。\n物理尺寸\n所有其他条件都相同的情况下,磁盘的物理尺寸也会带来差别:越小的磁盘,移动读取磁头需要的时间就越短。服务器级的2.5英寸磁盘性能往往比它们的更大的盘更快。它们还可以节省电力,并且通常可以融入机箱中。\n和CPU一样,MySQL如何扩展到多个磁盘上取决于存储引擎和工作负载。InnoDB能很好地扩展到多个硬盘驱动器。然而,MyISAM的表锁限制其写的可扩展性,因此写繁重的工作加在MyISAM上,可能无法从多个驱动器中收益。虽然操作系统的文件系统缓冲和后台并发写入会有点帮助,但MyISAM相对于InnoDB在写可扩展性上有更多的限制。\n和CPU一样,更多的磁盘也并不总是更好。有些应用要求低延迟需要的是更快的驱动器,而不是更多的驱动器。例如,复制通常在更快的驱动器上表现更好,因为备库的更新是单线程的。(7)\n9.4 固态存储 # 固态(闪存)存储器实际上是有30年历史的技术,但是它作为新一代驱动器而成为热门则是最近几年的事。固态存储现在越来越便宜,并且也更成熟了,它正在被广泛使用,并且可能会在不久的将来在多种用途上代替传统磁盘。\n固态存储设备采用非易失性闪存芯片而不是磁性盘片组成。它们也被称为NVRAM,或非易失性随机存取存储器。固态存储设备没有移动部件,这使得它们表现得跟硬盘驱动器有很大的不同。我们将详细探讨其差异。\n目前MySQL用户感兴趣的技术可分为两大类:SSD(固态硬盘)和PCIe卡。SSD通过实现SATA(串行高级技术附件)接口来模拟标准硬盘,所以可以替代硬盘驱动器,直接插入服务器机箱中的现有插槽。PCIe卡使用特殊的操作系统驱动程序,把存储设备作为一个块设备输出。PCIe和SSD设备有时可以简单地都认为是SSD。\n下面是闪存性能的快速小结。高质量闪存设备具备:\n相比硬盘有更好的随机读写性能。闪存设备通常读明显比写要快。 相比硬盘有更好的顺序读写性能。但是相比而言不如随机I/O的改善那么大,因为硬盘随机I/O比顺序I/O要慢得多。入门级固态硬盘的顺序读取实际上还可能比传统硬盘慢。 相比硬盘能更好地支持并发。闪存设备可以支持更多的并发操作,事实上,只有大量的并发请求才能真正实现最大吞吐量。 最重要的事情是提升随机I/O和并发性。闪存记忆体可以在高并发下提供很好的随机I/O性能,这正是范式化的数据库所需要的。设计非范式化的Schema最常见的原因之一是为了避免随机I/O,并且使得查询可能转化为顺序I/O。\n因此,我们相信固态存储未来将从根本上改变RDBMS技术。当前这一代的RDBMS技术几十年来都是为机械磁盘做优化的。同样成熟和深入的研究工作在固态存储上还没有真正出现(8)。\n9.4.1 闪存概述 # 硬盘驱动器使用旋转盘片和可移动磁头,其物理结构决定了磁盘固有的局限性和特征。对固态存储也是一样,它是构建在闪存之上的。不要以为固态存储很简单,实际上比硬盘驱动器在某些方面更复杂。闪存的限制实际上是相当严重的,并且难以克服,所以典型的固态设备都有错综复杂的架构、缓存,以及独有的“法宝”。\n闪存的最重要的特征是可以迅速完成多次小单位读取,但是写入更有挑战性。闪存不能在没有做擦除操作前改写一个单元(Cell)(9),并且一次必须擦除一个大块——例如,512 KB。擦除周期是缓慢的,并且最终会磨损整个块。一个块可以容忍的擦除周期次数取决于所使用的底层技术,有关这些内容我们稍后再讲。\n写入的限制是固态存储复杂的原因。这也是为什么一些设备供应商在设备的稳定、性能的一致性等方面和其他供应商有区别的原因。“魔法”全部都在其专有的固件、驱动程序,以及其他零零碎碎的东西里,这些东西使得固态设备良好运转。为了使写入表现良好,并避免闪存块过早损耗完寿命,设备必须能够搬迁页面并执行垃圾收集和所谓的磨损均衡。写放大用于描述数据从一个地方移动到另一个地方的额外写操作,多次写数据和元数据导致局部块经常写。如果你有兴趣,维基百科中的写放大的文章,是个学习的好地方,可以从其中了解更多关于闪存的知识。\n垃圾收集对理解闪存很重要。为了保持一些块是干净的并且可以被写入,设备需要回收脏块。这需要设备上有一些空闲空间。无论是设备内部有一些看不到的预留空间,或者通过不写那么多数据来预留需要的空间——不同的设备可能有所不同。无论哪种方式,设备填满了,垃圾收集就必须更加努力地工作,以保持一些块是干净的,所以写放大的倍数就增加了。\n因此,许多设备在被填满后会开始变慢。到底会慢多少,不同的制造商和型号之间有所不同,依赖于设备的架构。有些设备为高性能而设计,即使写得非常满,依然可以保持高性能。但是,通常一个100GB的文件在160GB和320GB的SSD上表现完全不同。速度下降是由于没有空闲块时必须等待擦写完成所造成的。写到一个空闲块只需要花费数百微秒,但是擦写慢得多——通常需要几个毫秒。\n9.4.2 闪存技术 # 有两种主要的闪存设备类型,当考虑购买闪存存储时,理解两者之间的不同是很重要的。这两种类型分别是单层单元(SLC)和多层单元(MLC)。\nSLC的每个单元存储数据的一个比特:可以是0或1。SLC相对更昂贵,但非常快,并且擦写寿命高达100000个写周期,具体值取决于供应商和型号。这听起来好像不多,但在现实中一个好的SLC设备应该持续使用大约20年左右,甚至比卡上安装的控制器更耐用和可靠。缺点则是存储密度相对较低,所以不能在每个设备上得到那么多空间。MLC每个单元存储2个比特、3个比特的设备也正在进入市场。这使得通过MLC设备获得更高的存储密度(更大的容量)成为可能。成本更低了,但是速度和耐擦写性也下降了。一个不错的MLC设备可能被定为10000个写循环周期。\n可以在大众市场上购买到这两种类型的闪存设备,它们之间的竞争有助于闪存的发展。目前,SLC仍持有“企业”级服务器的存储解决方案的声誉,通常被视为消费级的MLC设备,一般使用在笔记本电脑和数码相机等地方。然而,这种情况正在改变,出现了一种新兴的所谓企业级MLC(eMLC)存储。\nMLC技术的发展是很有意思的,如果正在考虑购买闪存存储,这个发展方向值得密切关注。MLC非常复杂,包含很多有助于设备质量和性能的重要因素。任何给定的芯片仅靠自身是不能持久化的,因为有着相对较短的信号保持周期,以及较高的错误率必须纠正。随着市场转移到更小、密度更高的芯片,其中的芯片单元可以存储3比特,单个芯片变得更不可靠以及更容易出错。\n然而,这并不是一个不可逾越的工程问题。厂商正在制造一些有越来越多隐藏容量的设备,因此有足够的内部冗余。尽管闪存厂商非常注意保护自己的商业秘密,还是有传言称,某些设备可能有比它标称大小多出高达两倍的存储空间。使MLC芯片更耐用的另一种方法是通过固件逻辑。平衡磨损和重映射的算法是非常重要的。\n寿命的长短取决于真实的容量,固件逻辑等——所以最终是因供应商而异的。我们听说过在几个星期里密集使用导致设备报废的报告!\n因此,MLC设备最关键的环节是内置的算法和智能。制造一个好的MLC设备比制造一个SLC设备难得多,但也是可能的。随着工程学的伟大进步,以及容量和密度的增加,一些最好的供应商提供的设备,是值得用eMLC这个标签的。这个领域随着时间的推移进步得很快,本书对MLC与SLC的意见可能很快会变得过时。\n设备的寿命还剩多久?\nVirident担保其FlashMax 1.4 TB MLC设备可以持续写入15 PB数据,但这是在闪存级别的数据,用户可见的写入是会放大的。我们跑了一个小实验来发现特定的工作负载下的写入放大因子。\n我们创建了一个500GB的数据集,然后在上面运行tpcc-mysql基准测试,跑了一个小时。在这个小时里,/proc/diskstats报告了984GB的写入,然后Virident配置工具显示在闪存层有1 125GB的写入,因此写入放大因子是1.14。记住,如果设备上消耗了更多空间,这个值会更高,并且这个值的浮动还基于写入方式是顺序还是随机。\n在这样的比率下,如果不间断地跑一年半的基准测试,就可以用完设备的寿命。当然,真实的工作负载很少是写密集型的,所以这个卡在实际使用中应该可以持续工作很多年。这个观点不是说该设备将很快磨损——它是说,写放大系数是很难预测的,需要检查设备,根据工作量来查看它的行为。\n容量对寿命的影响也很大,正如我们已经提到的。更大容量的设备会使得寿命显著增长,这是为什么MLC越来越流行——最近我们看到足够大的容量可以延长寿命是有理由的。\n9.4.3 闪存的基准测试 # 对闪存设备进行基准测试是复杂并且困难的。有很多情况会导致测试错误,需要了解特定设备的知识,并且需要有极大的耐心和关注,才能正确地操作。\n闪存设备有一个三阶段模式,我们称为A-B-C性能特性。它们开始阶段运行非常快(阶段A),然后垃圾回收器开始工作,这将导致在一段时间内,设备处于过渡到稳定状态(阶段B)的阶段,最后设备进入一个稳定状态(状态C)。所有我们测试过的设备都有这个特点。\n当然,我们感兴趣的是阶段C的性能,所以基准测试只需要测量这个部分的运行过程。这意味着基准测试要做的不仅仅是基准测试:还需要先进行一下预热,然后才能进行基准测试。但是,定义预热的终点和基准测试的起点会非常棘手。\n设备、文件系统,以及操作系统通过不同方式提供TRIM命令的支持,这个命令标记空间准备重用。有时当删除所有文件时设备会被TRIM。如果在基准测试运行的情况下发生,设备将重置到阶段A,然后必须重新执行A和B之间的运行阶段。另一个因素是设备被填充得很满或者不满时,不同的性能表现。一个可重复的基准测试必须覆盖到所有这些因素。\n通过上述分析,可知基准测试的复杂性,所以就算厂商如实地报告测试结果,但对于外行来说,厂商的基准测试和规格说明书依然可能有很多“坑”。\n通常可以从供应商那得到四个数字。这里有一个设备规格的例子:\n设备读取性能最高达520 MB/s。 设备写入性能最高达480 MB/s。 设备持续写入速度可以稳定在420 MB/s。 设备每秒可以执行70000个4 KB的写操作。 如果再次复核这些数字,你会发现峰值4KB写入达到70000个IOPS(每秒输入/输出操作),这么算每秒写入大约只有274 MB/s,这比第二点和第三点中说明的高峰写入带宽少了很多。这是因为达到峰值写入带宽时是用更大的块,例如64 KB或128 KB。用更小的块大小来达到峰值IOPS。\n大部分应用不会写这么大的块。InnoDB写操作通常是16KB或512字节的块组合到一起写回。因此,设备应该只有274MB/s的写出带宽——这是阶段A的情况,在垃圾回收器开启和设备达到长期稳定的性能等级前!\n在我们的博客中,可以找到目前的MySQL基准测试,以及在固态硬盘上裸设备文件I/O的工作负载: http://www.ssdperformanceblog.com和 http://www.mysqlperformanceblog.com。\n9.4.4 固态硬盘驱动器(SSD) # SSD模拟SATA硬盘驱动器。这是一个兼容性功能:替换SATA硬盘不需要任何特殊的驱动程序或接口。\n英特尔的X-25E驱动器可能是我们今天看到在服务器中最常见的固态硬盘,但也有很多其他选择。X-25E是为“企业”级消费市场开发的,但也有用MLC存储的X-25M,这是为笔记本电脑用户等大众市场准备的。此外,英特尔还销售320系列,也有很多人正在使用。再次,这仅仅是一个供应商——还有很多,在这本书去印刷的时候,我们所写的关于SSD的一些东西可能已经过时。\n关于SSD的好处是,它们有大量的品牌和型号相对是比较便宜的,同时它们比硬盘快了很多。最大的缺点是,它们并不总是像硬盘一样可靠,这取决于品牌和型号。直到最近,大多数设备都没有板载电池,但大多数设备上有一个写缓存来缓冲写入。写入缓存在没有电池备份的情况下并不能持久化,但是在快速增长的写负载下,它不能关闭,否则闪存存储无法承受。所以,如果禁用了驱动器的高速缓存以获得真正持久化的存储,将会更快地耗完设备寿命,在某些情况下,这将导致保修失效。\n有些厂家完全不急于告诉购买他们固态硬盘的客户关于SSD的特点,并且他们对设备的内部架构等细节守口如瓶。是否有电池或电容保护写缓存的数据安全,在电源故障的情况下,通常是一个悬而未决的问题。在某些情况下,驱动器会接受禁用缓存的命令,但忽略了它。所以,除非做过崩溃试验,否则真的有可能不知道驱动器是否是持久化的,我们对一些驱动器进行了崩溃测试,发现了不同的结果。如今,一些驱动器有电容器保护缓存,使其可以持久化,但一般来说,如果驱动器不是自夸有一个电池或电容,那么它就没有。这意味着在断电的情况下不是持久化的,所以可能出现数据已经损坏却还不知情的情况。SSD是否配置电容或电池是我们必须关注的特性。\n通常,使用SSD都是值得的。但底层技术的挑战是不容易解决的。很多厂家做出的驱动器在高负载下很快就崩溃了,或不提供持续一致的性能。一些低端的制造商有一个习惯,每次发布新一代驱动器,就声称他们已经解决了老一代的所有问题。这往往是不真实的,当然,如果关心可靠性和持续的高性能,“企业级”的设备通常值得它的价钱。\n用SSD做RAID # 我们建议对SATA SSD盘使用RAID(Redundant Array of Inexpensive Disks,磁盘冗余阵列)。单一驱动器的数据安全是无法让人信服的。\n许多旧的RAID控制器并不支持SSD。因为它们假设管理的是机械硬盘,包括写缓冲和写排序这些特性都是为机械硬盘而设计的。这不但纯属无效工作,也会增加响应时间,因为SSD暴露的逻辑位置会被映射到底层闪存记忆体中的任意位置。现在这种情况好一点。有些RAID控制器的型号末尾有一个字母,表明它们是为SSD做了准备的。例如, Adaptec控制器用Z标识。\n然而,即使支持闪存的控制器,也不一定真的就对闪存支持很好。例如,Vadim对Adaptec 5805Z控制器进行了基准测试,他用了多种驱动器做RAID 10,16个并发操作500 GB的文件。结果是很糟糕的:95%的随机写延迟在两位数的毫秒,在最坏的情况下,超过一秒钟(10)。(期望的应该是亚毫秒级写入。)\n这种特定的比较,是一家客户为了看到Micron SSD是否会比64GB的Intel SSD更好而做的,该比较是基于相同的配置的。当为英特尔驱动器进行基准测试时,我们发现了相同的性能特征。因此,我们尝试了一些其他驱动器的配置,不管有没有SAS扩展器,看看会发生什么。表9-1显示了这个结果。\n表9-1:在Adaptec RAID控制器上用SSD进行的基准测试 这些结果都没有达到我们对这么多驱动器的期望。在一般情况下,RAID控制器的性能表现,只能满足对6~8个驱动器的期望,而不是几十个。原因很简单,RAID控制器达到了瓶颈。这个故事的重点是,在对硬件投入巨资前,应该先仔细进行基准测试——结果可能与期望的有相当大的区别。\n9.4.5 PCIe存储设备 # 相对于SATA SSD,PCIe设备没有尝试模拟硬盘驱动器。这种设计是好事:服务器和硬盘驱动器之间的接口不能完全发挥闪存的性能。SAS/SATA互联带宽比PCIe要低,所以PCIe对高性能需求是更好的选择。PCIe设备延迟也低得多,因为它们在物理上更靠近CPU。\n没有什么比得上从PCIe设备上获得的性能。缺点就是它们太贵了。\n所有我们熟悉的型号都需要一个特殊的驱动程序来创建块设备,让操作系统把它认成一个硬盘驱动器。这些驱动程序使用着混合磨损均衡和其他逻辑的策略;有些使用主机系统的CPU和内存,有些使用板载的逻辑控制器和RAM(内存)。在许多场景下,主机系统有丰富的CPU和RAM资源,所以相对于购买一个自身有这些资源的卡,利用主机上的资源实际上是更划算的策略。\n我们不建议对PCIe设备建RAID。它们用RAID就太昂贵了,并且大部分设备无论以何种方式,都有它们自己板载的RAID。我们并不真的知道控制器坏了以后会怎么样,但是厂商说他们的控制器通常跟网卡或者RAID控制器一样好,看起来确实是这样。换句话说,这些设备的平均无故障时间间隔(MTBF)接近于主板,所以对这些设备使用RAID只是增加了大量成本而没有多少好处。\n有许多家供应商在生产PCIe闪存卡。对MySQL用户来说最著名的厂商是Fusion-io和Virident,但是像Texas Memory Systems、STEC和OCZ这样的厂商也有用户。SLC和MLC都有相应的PCIe卡产品。\n9.4.6 其他类型的固态存储 # 除了SSD和PCIe设备,也有其他公司的产品可以选择,例如Violin Memory、SandForce和Texas Memory Systems。这些公司提供有几十TB存储容量,本质上是闪存SAN的大箱子。它们主要用于大型数据中心存储的整合。虽然价格非常昂贵,但是性能也非常高。我们知道一些使用的例子,并在某些场景下测量过性能。这些设备能够提供相当好的延迟,除了网络往返时间——例如,通过NFS有小于4毫秒的延迟。\n然而这些都不适合一般的MySQL市场。它们的目标更针对其他数据库,如Oracle,可以用来做共享存储集群。一般情况下,MySQL在如此大规模的场景下,不能有效利用如此强大的存储优势,因为在数十个TB的数据下MySQL很难良好地工作——MySQL对这样一个庞大的数据库的回答是,拆分、横向扩展和无共享(Shared-nothing)架构。\n虽然专业化的解决方案可能能够利用这些大型存储设备——例如Infobright可能成为候选人。ScaleDB可以部署在共享存储(Shared-storage)架构,但我们还没有看到它在生产环境应用,所以我们不知道其工作得如何。\n9.4.7 什么时候应该使用闪存 # 固态存储最适合使用在任何有着大量随机I/O工作负载的场景下。随机I/O通常是由于数据大于服务器的内存导致的。用标准的硬盘驱动器,受限于转速和寻道延迟,无法提供很高的IOPS。闪存设备可以大大缓解这种问题。\n当然,有时可以简单地购买更多内存,这样随机工作负载就可以转移到内存,I/O就不存在了。但是当无法购买足够的内存时,闪存也可以提供帮助。另一个不能总是用内存解决的问题是,高吞吐的写入负载。增加内存只能帮助减少写入负载到磁盘,因为更多的内存能创造更多的机会来缓冲、合并写。这允许把随机写转换为更加顺序的I/O。\n然而,这并不能无限地工作下去,一些事务或插入繁忙的工作负载不能从这种方法中获益。闪存存储在这种情况下却也有帮助。\n单线程工作负载是另一个闪存的潜在应用场景。当工作负载是单线程的时候,它是对延迟非常敏感的,固态存储更低的延迟可以带来很大的区别。相反,多线程工作负载通常可以简单地加大并行化程度以获得更高的吞吐量。MySQL复制是单线程工作的典型例子,它可以从低延迟中获得很多收益。在备库跟不上主库时,使用闪存存储往往可以显著提高其性能。\n闪存也可以为服务器整合提供巨大的帮助,尤其是PCIe方式的。我们已经看到了机会,把很多实例整合到一台物理服务器——有时高达10或15倍的整合都是可能的。更多关于这个话题的信息,请参见第11章。\n然而闪存也可能不一定是你要的答案。一个很好的例子是,像InnoDB日志文件这样的顺序写的工作负载,闪存不能提供多少成本与性能优势,因为在这种情况下,闪存连续写方面不比标准硬盘快多少。这样的工作负载也是高吞吐的,会更快耗尽闪存的寿命。在标准硬盘上存放日志文件通常是一个更好的主意,用具有电池保护写缓存的RAID控制器。\n有时答案在于内存/磁盘的比例,而不只是磁盘。如果可以买足够的内存来缓存工作负载,就会发现这更便宜,并且比购买闪存存储设备更有效。\n9.4.8 使用Flashcache # 虽然有很多因素需要在闪存、硬盘和RAM之间权衡,在存储层次结构中,这些设备没有被当作一个整体处理。有时可以使用磁盘和内存技术的结合,这就是Flashcache。\nFlashcache是这种技术的一个实现,可以在许多系统上发现类似的使用,例如Oracle数据库、ZFS文件系统,甚至许多现代的硬盘驱动器和RAID控制器。下面讨论的许多东西应用广泛,但我们将只专注于Flashcache,因为它和厂商、文件系统无关。\nFlashcache是一个Linux内核模块,使用Linux的设备映射器(Device Mapper)。它在内存和磁盘之间创建了一个中间层。这是Facebook开源和使用的技术之一,可以帮助其优化数据库负载。\nFlashcache创建了一个块设备,并且可以被分区,也可以像其他块设备一样创建文件系统,特点是这个块设备是由闪存和磁盘共同支撑的。闪存设备用作读取和写入的智能高速缓存。\n虚拟块设备远比闪存设备要大,但是没关系,因为数据最终都存储在磁盘上。闪存设备只是去缓冲写入和缓存读取,有效弥补了服务器内存容量的不足(11)。\n这种性能有多好呢?Flashcache似乎有相对较高的内核开销。(设备映射并不总是像看起来那么有效,但我们还没深入调查找出原因。)但是,尽管Flashcache理论上可能更高效,但最终的性能表现并不如底层的闪存存储那么好,不过它仍然比磁盘快很多,所以还是值得考虑的方案。\n我们用包含数百个基准测试的一系列测试来评估Flashcache的性能,但是我们发现在人工模拟的工作负载下,测出有意义的数据是非常困难的。于是我们得出结论,虽然并不清楚Flashcache通常对写负载有多大好处,但是对读肯定是有帮助的。于是它适合这样的情况使用:有大量的读I/O,并且工作集比内存大得多。\n除了实验室测试,我们有一些生产环境中应用Flashcache的经验。想到的一个例子是,有个4TB的数据库,这个数据库遇到了很大的复制延迟。我们给系统加了半个TB的Virident PCIe卡作为存储。然后安装了Flashcache,并且把PCIe卡作为绑定设备的闪存部分,复制速度就翻了一倍。\n当闪存卡用得很满时使用Flashcache是最经济的,因此选择一张写得很满时其性能不会降低多少的卡非常重要。这就是为什么我们选择Virident卡。\nFlashcache就是一个缓存系统,所以就像任何其他缓存一样,它也有预热问题。虽然预热时间可能会非常长。例如,在我们刚才提到的情况下,Flashcache需要一个星期的预热,才能真正对性能产生帮助。\n应该使用Flashcache吗?根据具体情况可能会有所不同,所以我们认为在这一点上,如果你觉得不确定,最好得到专家的意见。理解Flashcache的机制和它们如何影响你的数据库工作集大小是很复杂的,在数据库下层(至少)有三层存储:\n首先,是InnoDB缓冲池,它的大小跟工作集大小一起可以决定缓存的命中率。缓存命中是非常快的,响应时间非常均匀。 在缓冲池中没有命中,就会到Flashcache设备上去取,这就会产生分布比较复杂的响应时间。Flashcache的缓存命中率由工作集大小和闪存设备大小决定。从闪存上命中比在磁盘上查找要快得多。 Flashcache设备缓存也没有命中,那就得到磁盘上找,这也会看到分布相当均匀的比较慢的响应时间。 有可能还有更多层次:例如,SAN或RAID控制器的缓存。\n这有一个思维实验,说明这些层是如何交互的。很显然,从Flashcache设备访问的响应时间不会像直接访问闪存设备那么稳定和高速。但是想象一下,假设有1TB的数据,其中100 GB在很长一段时间会承受99%的I/O操作。也就是说,大部分时候99%的工作集只有100 GB。\n现在,假设有以下的存储设备:一个很大的RAID卷,可以执行1000 IOPS,以及一个可以达到100000 IOPS的更小的闪存设备。闪存设备不足以存放所有的数据——假设只有128 GB——因此单独使用闪存不是一种可能的选择。如果用闪存设备做Flashcache,就可以期望缓存命中远远快于磁盘检索,但Flashcache整体比单独使用闪存设备要慢。我们坚持用数字说话,如果90%的请求落到Flashcache设备,相当于达到50000 IOPS。这个思维实验的结果是什么呢?有两个要点:\n系统使用Flashcache比不使用的性能要好很多,因为大多数在缓冲池未命中的页面访问都被缓存在闪存卡上,相对于磁盘可以提供快得多的访问速度。(99%的工作集可以完全放在闪存卡上。) Flashcache设备上有90%的命中率意味着有10%没有命中。因为底层的磁盘只能提供1000 IOPS,因此整个Flashcache设备可以支持10000的IOPS。为了明白为什么是这样的,想象一下如果我们要求不止于此会发生什么:10%的I/O操作在缓存中没有命中而落到了RAID卷上,则肯定要求RAID卷提供超过1000 IOPS,很显然是没法处理的。因此,即使Flashcache比闪存卡慢,系统作为一个整体仍然受限于RAID卷,不止是闪存卡或Flashcache。 归根到底,Flashcache是否合适是一个复杂的决定,涉及的因素很多。一般情况下,它似乎最适合以读为主的I/O密集型负载,并且工作集太大,用内存优化并不经济的情况。\n9.4.9 优化固态存储上的MySQL # 如果在闪存上运行MySQL,有一些配置参数可以提供更好的性能。InnoDB的默认配置从实践来看是为硬盘驱动器定制的,而不是为固态硬盘定制的。不是所有版本的InnoDB都提供同样等级的可配置性。尤其是很多为提升闪存性能设计的参数首先出现在Percona Server中,尽管这些参数很多已经在Oracle版本的InnoDB中实现,或者计划在未来的版本中实现。\n改进包括:\n增加InnoDB的I/O容量\n闪存比机械硬盘支持更高的并发量,所以可以增加读写I/O线程数到10或15来获得更好的结果。也可以在2000~20000范围内调整innodb_io_capacity选项,这要看设备实际上能支撑多大的IOPS。尤其是对Oracle官方的InnoDB这个很有必要,内部有更多算法依赖于这个设置。\n让InnoDB日志文件更大\n即使最近版本的InnoDB中改进了崩溃恢复算法,也不应该把磁盘上的日志文件调得太大,因为崩溃恢复时需要随机I/O访问,会导致恢复需要很长一段时间。闪存存储让这个过程快很多,所以可以设置更大的InnoDB日志文件,以帮助提升和稳定性能。对于Oracle官方的InnoDB,这个设置尤其重要,它维持一个持续的脏页刷新比例有点麻烦,除非有相当大的日志文件——4GB或者更大的日志文件,在写的时候对服务器来说是个不错的选择。Percona Server和MySQL 5.6支持大于4GB的日志文件。\n把一些文件从闪存转移到RAID\n除了把InnoDB日志文件设置得更大,把日志文件从数据文件中拿出来,单独放在一个带有电池保护写缓存的RAID组上而不是固态设备上,也是个好主意。这么做有几个原因。一个原因是日志文件的I/O类型,在闪存设备上不比在这样一个RAID组上要快。InnoDB写日志是以512字节为单位的顺序I/O写下去,并且除了崩溃恢复会顺序读取,其他时候绝不会去读。这样的I/O操作类型用闪存设备是很浪费的。并且把小的写入操作从闪存转移到RAID卷也是个好主意,因为很小的写入会增加闪存设备的写放大因子,会影响一些设备的使用寿命。大小写操作混合到一起也会引起某些设备延时的增加。\n基于相同的原因,有时把二进制日志文件转移到RAID卷也会有好处。并且你可能会认为ibdata1文件也适合放在RAID卷上,因为ibdata1文件包含双写缓冲(Doublewrite Buffer)和插入缓冲(Insert Buffer),尤其是双写缓冲会进行很多重复写入。在Percona Server中,可以把双写缓冲从ibdata1文件中拿出来,单独存放到一个文件,然后把这个文件放在RAID卷上。\n还有另一个选择:可以利用Percona Server的特性,使用4KB的块写事务日志,而不是512字节。因为这会匹配大部分闪存本身的块大小,所以可以获得更好的效果。所有的上述建议是对特定硬件而言的,实际操作的时候可能会有所不同,所以在大规模改动存储布局之前要确保已经理解相关的因素——并辅以适当的测试。\n禁用预读\n预读通过通知和预测读取模式来优化设备的访问,一旦认为某些数据在未来需要被访问到,就会从设备上读取这些数据。实际上在InnoDB中有两种类型的预读,我们发现在多种情况下的性能问题,其实都是预读以及它的内部工作方式造成的。在许多情况下开销比收益大,尤其是在闪存存储,但我们没有确凿的证据或指导,禁用预读究竟可以提高多少性能。\n在MySQL 5.1的InnoDB Plugin中,MySQL禁用了所谓的“随机预读”,然后在MySQL 5.5又重新启用了它,可以在配置文件用一个参数配置。Percona Server能让你在旧版本里也一样可以配置为random(随机)或linear read-ahead(线性预读)。\n配置InnoDB刷新算法\n这决定InnoDB什么时候、刷新多少、刷新哪些页面,这是个非常复杂的主题,这里我们没有足够的篇幅来讨论这些具体的细节。这也是个研究比较活跃的主题,并且实际上在不同版本的InnoDB和MySQL中有多种有效的算法。\n标准InnoDB算法没有为闪存存储提供多少可配置性,但是如果用的是Percona XtraDB(包含在Percona Server和MariaDB中),我们建议设置innodb_adaptive_checkpoint选项为keep_average,不要用默认值estimate。这可以确保更持续的性能,并且避免服务器抖动,因为estimate算法会在闪存存储上引起抖动。我们专门为闪存存储开发了keep_average,因为我们意识到对于闪存设备,把希望操作的大量I/O推到设备上,并不会引起瓶颈或发生抖动。\n另外,建议为闪存设备设置innodb_flush_neighbor_pages=0。这样可以避免InnoDB尝试查找相邻的脏页一起刷写。这个算法可能会导致更大块的写、更高的延迟,以及内部竞争。在闪存存储上这完全没必要,也不会有什么收益,因为相邻的页面单独刷新不会冲击性能。\n禁用双写缓冲的可能\n相对于把双写缓存转移到闪存设备,可以考虑直接关闭它。有些厂商声称他们的设备支持16KB的原子写入,使得双写缓冲成为多余的。如果需要确保整个存储系统被配置得可以支持16KB的原子写入,通常需要O_DIRECT和XFS文件系统。\n没有确凿的证据表明原子操作的说法是真实的,但由于闪存存储的工作方式,我们相信写数据文件发生页面写一部分的情况是大大减少的,并且这个收益在闪存设备上比在传统磁盘上要高得多,禁用双写缓冲在闪存存储上可以提高MySQL整体性能差不多50%,尽管我们不知道这是不是100%安全的,但是你可以考虑下这么做。\n限制插入缓冲大小\n插入缓冲(在新版InnoDB中称为变更缓冲(Change Buffer))设计来用于减少当更新行时不在内存中的非唯一索引引起的随机I/O。在硬盘驱动器上,减少随机I/O可以带来巨大的性能提升。对某些类型的工作负载,当工作集比内存大很多时,差异可能达到近两个数量级。插入缓冲在这类场景下就很有用。\n然而,对闪存就没有必要了。闪存上随机I/O非常快,所以即使完全禁用插入缓冲,也不会带来太大影响,尽管如此,可能你也不想完全禁用插入缓存。所以最好还是启用,因为I/O只是修改不在内存中的索引页面的开销的一部分。对闪存设备而言,最重要的配置是控制最大允许的插入缓冲大小,可以限制为一个相对比较小的值,而不是让它无限制地增长,这可以避免消耗设备上的大量空间,并避免ibdata1文件变得非常大的情况。在本书写作的时候,标准InnoDB还不能配置插入缓存的容量上限,但是在Percona XtraDB(Percona Server和MariaDB都包含XtraDB)里可以。MySQL 5.6里也会增加一个类似的变量。\n除了上述的配置建议,我们还提出或讨论了其他一些闪存优化策略。然而,不是所有的策略都非常容易明白,所以我们只是提到了一部分,最好自己研究在具体情况下的好处。首先是InnoDB的页大小。我们发现了不同的结果,所以我们现在还没有一个明确的建议。好消息是,在Percona Server中不需要重编译也能配置页面大小,在MySQL 5.6中这个功能也可能实现。以前版本的MySQL需要重新编译服务器才能使用不同大小的页面,所以大部分情况都是运行在默认的16KB页面。当页面大小更容易让更多人进行实验时,我们期待更多非标准页面大小的测试,可能能从中得到很多重要的结论。\n另一个提到的优化是InnoDB页面校验(Checksum)的替代算法。当存储系统响应很快时,校验值计算可能开始成为I/O相关操作中显著影响时间的因素,并且对某些人来说这个计算可能替代I/O成为新的瓶颈。我们的基准测试还没有得出可适用于普遍场景的结论,所以每个人的情况可能有所不同。Percona XtraDB允许修改校验算法,MySQL 5.6也有了这个功能。\n可能已经提醒过了,我们提到的很多功能和优化在标准版本的InnoDB中是无效的。我们希望并且相信我们引入Percona Server和XtraDB中的改进点,最终将会被广大用户接受。与此同时,如果正使用Oracle官方MySQL分发版本,依然可以对服务器采取措施为闪存进行优化。建议使用innodb_file_per_table,并且把数据文件目录放到闪存设备。然后移动ibdata1和日志文件,以及其他所有日志文件(二进制日志、复制日志,等等),到RAID卷,正如我们之前讨论的。这会把随机I/O集中到闪存设备上,然后把大部分顺序写入的压力尽可能转移出闪存,因而可以节省闪存空间并且减少磨损。\n另外,所有版本的MySQL服务器,都应该确认超线程开启了。当使用闪存存储时,这有很大的帮助,因为磁盘通常不再是瓶颈,任务会更多地从I/O密集变为CPU密集。\n9.5 为备库选择硬件 # 为备库选择硬件与为主库选择硬件很相似,但是也有些不同。如果正计划着建一个备库做容灾,通常需要跟主库差不多的配置。不管备库是不是仅仅作为一个主库的备用库,都应该强大到足以承担主库上发生的所有写入,额外的不利因素是备库只能序列化串行执行。(下一章有更多关于这方面的内容)。\n备库硬件主要考虑的是成本:需要在备库硬件上花费跟主库一样多的成本吗?可以把备库配置得不一样以便从备库上获得更多性能吗?如果备库跟主库工作负载不一样,可以从不一样的硬件配置上获得隐含的收益吗?\n这一切都取决于备库是否只是备用的,你可能希望主库和备库有相同的硬件和配置,但是,如果只是用复制作为扩展更多读容量的方法,那备库可以有多种不同的捷径。例如,可能在备库使用不一样的存储引擎,并且有些人使用更便宜的硬件或者用RAID 0代替RAID 5或RAID 10。也可以取消一些一致性和持久性的保证让备库做更少的工作。\n这些措施在大规模部署的情况下具有很好的成本效益,但是在小规模的情况下,可能只会使事情变得更加复杂。在实践中,似乎大多数人都会选择以下两种策略为备库选择硬件:主备使用相同的硬件,或为主库购买新的硬件,然后让备库使用主库淘汰的老硬件。\n在备库很难跟上主库时,使用固态硬盘有很大的意义。很好的随机I/O性能有助于缓解单个复制线程的影响。\n9.6 RAID性能优化 # 存储引擎通常把数据和索引都保存在一个大文件中,这意味着用RAID(Redundant Array of Inexpensive Disks,磁盘冗余阵列)存储大量数据通常是最可行的方法(12)。RAID可以帮助做冗余、扩展存储容量、缓存,以及加速。但是从我们看到的一些优化案例来说,RAID上有多种多样的配置,为需求选择一个合适的配置非常重要。\n我们不想覆盖所有的RAID等级,或者深入细节来分析不同的RAID等级分别如何存储数据。关于这个主题有很多好资料,在一些书籍和在线文档可以找到(13)。因此,我们专注于怎样配置RAID来满足数据库服务器的需求。最重要的RAID级别如下:\nRAID 0\n如果只是简单地评估成本和性能,RAID 0是成本最低和性能最高的RAID配置(但是,如果考虑数据恢复的因素,RAID 0的代价会非常高)。因为RAID 0没有冗余,建议只在不担心数据丢失的时候使用,例如备库或者因某些原因只是“一次性”使用的时候。典型的案例是可以从另一台备库轻易克隆出来的备库服务器。再次说明, RAID 0没有提供任何冗余,即使R在RAID中表示冗余。实际上,RAID 0阵列的损坏概率比单块磁盘要高,而不是更低!\nRAID 1\nRAID 1在很多情况下提供很好的读性能,并且在不同的磁盘间冗余数据,所以有很好的冗余性。RAID 1在读上比RAID 0快一些。它非常适合用来存放日志或者类似的工作,因为顺序写很少需要底层有很多磁盘(随机写则相反,可以从并发中受益)。这通常也是只有两块硬盘又需要冗余的低端服务器的选择。\nRAID 0和RAID 1很简单,在软件中很好实现。大部分操作系统可以很简单地用软件创建RAID 0和RAID 1。\nRAID 5\nRAID 5有点吓人,但是对某些应用,这是不可避免的选择,因为价格或者磁盘数量(例如需要的容量用RAID 1无法满足)的原因。它通过分布奇偶校验块把数据分散到多个磁盘,这样,如果任何一个盘的数据失效,都可以从奇偶校验块中重建。但如果有两个磁盘失效了,则整个卷的数据无法恢复。就每个存储单元的成本而言,这是最经济的冗余配置,因为整个阵列只额外消耗了一块磁盘的存储空间。\n在RAID 5上随机写是昂贵的,因为每次写需要在底层磁盘发生两次读和两次写,以计算和存储校验位。如果写操作是顺序的,那么执行起来会好一些,或者有很多物理磁盘也行。另外说一下,随机读和顺序读都能很好地在RAID 5下执行(14)。RAID 5用作存放数据或者日志是一种可接受的选择,或者是以读为主的业务,不需要消耗太多写I/O的场景。\nRAID 5最大的性能消耗发生在磁盘失效时,因为数据需要重分布到其他磁盘。这会严重影响性能,如果有很多磁盘会更糟糕。如果在重建数据时还保持服务器在线服务,那就别指望重建的速度或者阵列的性能会好。如果使用RAID 5,最好有一些机制可以做故障迁移,当有问题的时候让一台机器不再提供服务,另一台接管。不管怎样,对系统做一下故障恢复时的性能测试很有必要,这样就可以知道故障恢复时的性能表现到底如何。如果一块磁盘失效,RAID组在重建过程中,会导致磁盘性能下降,使用这个存储的服务器整体性能可能会不成比例地被影响到慢两倍到五倍。\nRAID 5的奇偶校验块会带来额外的性能开销,这会限制它的可扩展性,超过10块硬盘后RAID 5就不能很好地扩展,RAID缓存也会有些问题。RAID 5的性能严重依赖于RAID控制器的缓存,这可能跟数据库服务器需要的缓存冲突了。我们稍后会讨论缓存。\n尽管RAID 5有这么多问题,但有个有利因素是它非常受欢迎。因此,RAID控制器往往针对RAID 5做了高度优化,虽然有理论极限,但是智能控制器充分利用高速缓存使得RAID 5在某些场景下有时可以达到接近RAID 10的性能。实际上这可能反映了RAID 10的控制器缺少很好的优化,但不管是什么原因,这就是我们所见到的。\nRAID 10\nRAID 10对数据存储是个非常好的选择。它由分片的镜像组成,所以对读和写都有良好的扩展性。相对于RAID 5,重建起来很简单,速度也很快。另外RAID 10还可以在软件层很好地实现。\n当失去一块磁盘时,性能下降还是比较明显的,因为条带可能成为瓶颈(15)。性能可能下降为50%,具体要看工作负载。需要注意的一件事是,RAID控制器对RAID 10采用了一种“串联镜像”的实现。这不是最理想的实现,由于条带化的缺点是“最经常访问的数据可能仅被放置在一对机械磁盘上,而不是分布很多份,”所以可能会遇到性能不佳的情况。\nRAID 50\nRAID 50由条带化的RAID5组成,如果有很多盘的话,这可能是RAID 5的经济性和RAID 10的高性能之间的一个折中。它的主要用处是存放非常庞大的数据集,例如数据仓库或者非常庞大的OLTP系统。\n表9-2是多种RAID配置的总结。\n表9-2:RAID等级之间的比较 9.6.1 RAID的故障转移、恢复和镜像 # RAID配置(除了RAID 0)都提供了冗余。这很重要,但很容易让人低估磁盘同时发生故障的可能性。千万不要认为RAID能提供一个强有力的数据安全性保证(16)。\nRAID不能消除甚至减少备份的需求。当出现问题的时候,恢复时间要看控制器、RAID等级、阵列大小、硬盘速度,以及重建阵列时是否需要保持服务器在线。\n硬盘在完全相同的时间损坏是有可能的。例如,峰值功率或过热,可以很容易地废掉两个或更多的磁盘。然而,更常见的是,两个密切相关的磁盘(17)出现故障。许多这样的隐患可能被忽视了。一个常见的情况是,很少被访问的数据,在物理媒介上损坏了。这可能几个月都检测不到,直到尝试读取这份数据,或另一个硬盘也失效了,然后RAID控制器尝试使用损坏的数据来重建阵列。越大的硬盘驱动器,越容易发生这种情况。\n这就是为什么做RAID阵列的监控如此重要。大部分控制器提供了一些软件来报告阵列的状态,并且需要持续跟踪这些状态,因为不这么做可能就会忽略了驱动器失效。你可能丧失恢复数据和发现问题的时机,当第二块硬盘损坏时,已经晚了。因此应该配置一个监控系统来提醒硬盘或者RAID卷发生降级或失效了。\n对阵列积极地进行定期一致性检查,可以减少潜在的损坏风险。某些控制器有后台巡检(Background Patrol Read)功能,当所有驱动器都在线服务时,可以检查媒介是否有损坏并且修复,也可以帮助避免此类问题的发生。在恢复时,非常大型的阵列可能会降低检查速度,所以创建大型阵列时一定要确保制定了相应的计划。\n也可以添加一个热备盘,这个盘一般是未使用状态,并且配置为备用状态,有硬盘坏了之后控制器会自动把这块盘恢复为使用状态。如果依赖于每个服务器的可用性(18),这是一个好主意。对只有少数硬盘驱动器的服务器,这么做是很昂贵的,因为一个空闲磁盘的成本比例比较高,但如果有多个磁盘,而不设一个热备盘,就是愚蠢的做法。请记住,更多的磁盘驱动器会让发生故障的概率迅速增加。\n除了监控硬盘失效,还应该监控RAID控制器的电池备份单元以及写缓存策略。如果电池失效,大部分控制器默认设置会禁用写缓存,把缓存策略从WriteBack改为WriteThrough。这可能导致服务器性能下降。很多控制器会通过一个学习过程周期性地对电池充放电,在这个过程中缓存是被禁用的。RAID控制器管理工具应该可以浏览和配置电池充放电计划,不会让人措手不及。\n也许希望把缓存策略设为WriteThrough来测试系统,这样就可以知道系统性能的期望值。也许需要计划电池充放电的周期,安排在晚上或者周末,重新配置服务器修改innodb_flush_log_at_trx_commit和sync_binlog变量,或者在电池充放电时简单地切换到另一台服务器。\n9.6.2 平衡硬件RAID和软件RAID # 操作系统、文件系统和操作系统看到的驱动器数量之间的相互作用可以是复杂的。Bug、限制或只是错误配置,都可能会把性能降低到远远低于理论值。\n如果有10块硬盘,理想中应该能够承受10个并行的请求,但有时文件系统、操作系统或RAID控制器会把请求序列化。面对这个问题一个可行的办法是尝试不同的RAID配置。例如,如果有10个磁盘,并且必须使用镜像冗余,性能也要好,可以考虑下面几种配置:\n配置一个包含五个镜像对(RAID 1)的RAID 10卷(19)。操作系统只会看到一个很大的单独的硬盘卷,RAID控制器会隐藏底层的10块硬盘。 在RAID控制器中配置五个RAID 1镜像对,然后让操作系统使用五个卷而不是一个卷。(20) 在RAID控制器中配置五个RAID 1镜像对,然后使用软件RAID 0把五个卷做成一个逻辑卷,通过部分硬件、部分软件的实现方式,有效地实现了RAID 10。(21) 哪个选项是最好的?这依赖于系统中所有的组件如何相互协作。不同的配置可能获得相同的结果,也可能不同。\n我们已经提醒了多种配置可能导致串行化。例如,ext3文件系统每个inode有一个单一的Mutex,所以当InnoDB是配置为innodb_flush_method=O_DIRECT(常见的配置)时,在文件系统会有inode级别的锁定。这使得它不可能对文件进行I/O并发操作,因而系统表现会远低于其理论上的能力。\n我们见过的另一个案例,请求串行地发送到一个10块盘的RAID10卷中的每个设备,使用ReiserFS文件系统,InnoDB打开了innodb_file_per_table选项。尝试在硬件RAID 1的基础上用软件RAID 0做成RAID 10的方式,获得了五倍多的吞吐,因为存储系统开始表现出五个硬盘同时工作的特性,而不再是一个了。造成这种情况的是一个已经被修复的Bug,但是这是一个很好的例证,说明这类事情可能发生。\n串行化可能发生在任何的软件或硬件堆栈层。如果看到这个问题发生了,可能需要更改文件系统、升级内核、暴露更多的设备给操作系统,或使用不同的软件或硬件RAID组合方式。应该检查你的设备的并发性以确保它确实是在做并发I/O(本章稍后有更多关于这个话题的内容)。\n最后,当准备上线一种新服务器时,不要忘了做基准测试!这会帮助你确认能获得所期望的性能。例如,若一个硬盘驱动器每秒可以做200个随机读,一个有8个硬盘驱动器的RAID 10卷应该接近每秒1 600个随机读。如果观察到的结果比这个少得多,比如说每秒500个随机读,就应该研究下哪里可能有问题了。确保基准测试对I/O子系统施加了跟MySQL一样的方式的压力——例如,使用O_DIRECT标记,并且如果使用没有打开innodb_file_per_table选项的InnoDB,要用一个单一的文件测试I/O性能。通常可以使用sysbench来验证新的硬件设置都是正确的。\n9.6.3 RAID配置和缓存 # 配置RAID控制器通常有几种方法,一是可以在机器启动时进入自带的设置工具,或从命令行中运行。虽然大多数控制器提供了很多选项,但其中有两个是我们特别关注的,一是条带化阵列的块大小(Chunk Size),还有就是控制器板载缓存(也称为RAID缓存,我们使用术语)。\nRAID条带块大小 # 最佳条带块大小和具体工作负载以及硬件息息相关。从理论上讲,对随机I/O来说更大的块更好,因为这意味着更多的读取可以从一个单一的驱动器上满足。\n为什么会是这样?在工作负载中找出一个典型的随机I/O操作,如果条带的块大小足够大,至少大到数据不用跨越块的边界,就只有单个硬盘需要参与读取。但是,如果块大小比要读取的数据量小,就没有办法避免多个硬盘参与读取。\n这只是理论上的观点。在实践中,许多RAID控制器在大条带下工作得不好。例如,控制器可能用缓存中的缓存单元大小作为块大小,这可能有浪费。控制器也可能把块大小、缓存大小、读取单元的大小(在一个操作中读取的数据量)匹配起来。如果读的单位太大, RAID缓存可能不太有效,最终可能会读取比真正需要的更多的数据,即使是微小的请求。当然,在实践中很难知道是否有数据会跨越多个驱动器。即使块大小为16 KB,与InnoDB的页大小相匹配,也不能让所有的读取对齐16 KB的边界。文件系统可能会把文件分成片段,每个片段的大小通常与文件系统的块大小对齐,大部分文件系统的块大小为4KB。一些文件系统可能更聪明,但不应该指望它。\n可以配置系统以便从应用到底层存储所有的块都对齐:InnoDB的块、文件系统的块、LVM,分区偏移、RAID条带、磁盘扇区。我们的基准测试表明,当一切都对齐时,随机读和随机写的性能可能分别提高15%和23%。对齐一切的精密技术太特殊了,无法在这细说,但其他很多地方有很多不错的信息,包括我们的博客, http://www.mysqlperformanceblog.com。\nRAID缓存 # RAID缓存就是物理安装在RAID控制器上的(相对来说)少量内存。它可以用来缓冲硬盘和主机系统之间的数据。下面是RAID卡使用缓存的几个原因:\n缓存读取\n控制器从磁盘读取数据并发送到主机系统后,通过缓存可以存储读取的数据,如果将来的请求需要相同的数据,就可以直接使用而无须再次去读盘。\n这实际上是RAID缓存一个很糟糕的用法。为什么呢?由于操作系统和数据库服务器有自己更大得多的缓存。如果数据在这些上层缓存中命中了,RAID缓存中的数据就不会被使用。相反,如果上层的缓存没有命中,就有可能在RAID缓存中命中,但这个概率是微乎其微的。因为RAID缓存要小得多,几乎肯定会被刷新掉,被其他数据填上去了。无论哪种方式,缓冲读都是浪费RAID缓存的事。\n缓存预读数据\n如果RAID控制器发现连续请求的数据,可能会决定做预读操作——就是预先取出估计很快会用到的数据。在数据被请求之前,必须有地方放这些数据。这也会使用RAID缓存来放。预读对性能的影响可能有很大的不同,应该检查确保预读确实有帮助。如果数据库服务器做了自己的智能预读(例如InnoDB的预读),RAID控制器的预读可能就没有帮助,甚至可能会干扰所有重要的缓冲和同步写入。\n缓冲写入\nRAID控制器可以在高速缓存里缓冲写操作,并且一段时间后再写到硬盘。这样做有双重的好处:首先,可以快得多地返回给主机系统写“成功”的信号,远远比写入到物理磁盘上要快;其次,可以通过积累写操作从而更有效地批量操作(22)。\n内部操作\n某些RAID的操作是非常复杂的——尤其是RAID 5的写入操作,其中要计算校验位,用来在发生故障时重建数据。控制器做这类内部操作需要使用一些内存。\n这也是RAID 5在一些RAID控制器上性能差的原因:为了好的性能需要读取大量数据到内存。有些控制器不能较好地平衡缓存写和RAID 5校验位操作所需要的内存。\n一般情况下,RAID控制器的内存是一种稀缺资源,应该尽量用在刀刃上。缓存读取通常是一种浪费,但是缓冲写入是加速I/O性能的一个重要途径。许多控制器可以选择如何分配内存。例如,可以选择有多少缓存用于写入和多少用于读取。对于RAID 0、RAID 1和RAID 10,应该把控制器缓存100%分配给写入用。对于RAID 5,应该保留一些内存给内部操作。通常这是正确的建议,但并不总是适用——不同的RAID卡需要不同的配置。\n当正在用RAID缓存缓冲写入时,许多控制器可以配置延迟写入多久时间(例如一秒钟、五秒钟,等等)是可以接受的。较长的延迟意味着更多的写入可以组合在一起更有效地刷新到磁盘。缺点是写入会变得更加“突发的”。但这不是一件坏事,除非应用一连串的写请求把控制器的缓存填满了才被刷新到磁盘。如果没有足够的空间存放应用程序的写入请求,写操作就会等待。保持短的延迟意味着可以有更多的写操作,并且会更低效(23),但能抚平性能波动,并且有助于保持更多的空闲缓存,来接收应用程序的爆发请求。(我们在这里简化了——事实上控制器往往很复杂,不同的供应商有自己的均衡算法,所以我们只是试图覆盖基本原则。)\n写入缓冲对同步写入非常有用,例如事务日志和二进制日志(sync_binlog设置为1)调用的fsync(),但是除非控制器有电池备份单元(BBU)或其他非易失性存储(24),否则不应该启用RAID缓存。不带BBU的情况下缓冲写,在断电时,有可能损坏数据库,甚至是事务性文件系统。然而,如果有BBU,启用写入缓存可以提升很多日志刷新的工作的性能,例如事务提交时刷新事务日志。\n最后要考虑的是,许多硬盘驱动器有自己的缓存,可能有“假”的fsync()操作,欺骗RAID控制器说数据已被写入物理介质(25)。有时可以让硬盘直接挂载(而不是挂到RAID控制器上),让操作系统管理它们的缓存,但这并不总是有效。这些缓存通常在做fsync()操作时被刷新,另外同步I/O也会绕过它们直接访问磁盘,但是再次提醒,硬盘驱动器可能会骗你。应该确保这些缓存在fsync()时真的刷新了,否则就禁用它们,因为磁盘缓存没有电池供电(所以断电会丢失)。操作系统或RAID固件没有正确地管理硬盘管理已经造成了许多数据丢失的案例。\n由于这个以及其他原因,当安装新硬件时,做一次真实的宕机测试(比如拔掉电源)是很有必要的。通常这是找出隐藏的错误配置或者诡异的硬盘问题的唯一办法。在 http://brad.livejournal.com/2116715.html有个方便的脚本可以使用。\n为了测试是否真的可以依赖RAID控制器的BBU,必须像真实情况一样切断电源一段时间,因为某些单元断电超过一段时间后就可能会丢失数据。这里再次重申,任何一个环节出现问题都会使整个存储组件失效。\n9.7 SAN和NAS # SAN(Storage Area Network)和NAS(Network-Attached Storage)是两个外部文件存储设备加载到服务器的方法。不同的是访问存储的方式。访问SAN设备时通过块接口,服务器直接看到一块硬盘并且可以像硬盘一样使用,但是NAS设备通过基于文件的协议来访问,例如NFS或SMB。SAN设备通常通过光纤通道协议(FCP)或iSCSI连接到服务器,而NAS设备使用标准的网络连接。还有一些设备可以同时通过这两种方式访问,比如NetApp Filer存储系统。\n在接下来的讨论中,我们将把这两种类型的存储统一称为SAN。在后面的阅读应该记住这一点。主要区别在于作为文件还是块设备访问存储。\nSAN允许服务器访问非常大量的硬盘驱动器——通常在50块以上——并且通常配置大容量智能高速缓存来缓冲写入。块接口在服务器上以逻辑单元号(LUN)或者虚拟卷(除非使用NFS)出现。许多SAN也允许多节点组成集群来获得更好的性能或者增加存储容量。\n目前新一代SAN跟几年前的不同。许多新的SAN混合了闪存和机械硬盘,而不仅仅是机械硬盘了。它们往往有大到TB级或以上的闪存作为缓存,不像旧的SAN,只有相对较小的缓存。此外,旧的SAN无法通过配置更大的缓存层来“扩展缓冲池”,而新的SAN有时可以。因此,相比之下新的SAN可以提供更好的性能。\n9.7.1 SAN基准测试 # 我们已经测试了多个SAN厂商的多种产品。表9-3展示了一些低并发场景下的典型测试结果。\n表9-3:以16KB为单位同步单线程操作单个4GB文件的IOPS 具体的SAN厂商名字和配置做了保密处理,但是可以透露的是这些都不是便宜的SAN。测试都是用同步的16 KB操作,模拟InnoDB配置在O_DIRECT模式时的操作方式。\n从表9-3中可以得出什么结论?我们测试的系统不是都可以直接比较的,所以盯着这些好看的数据点来看不能客观地做出评价。然而,这些结果很好地说明了这类设备的总体性能表现,SAN可以承受大量的连续写入,因为可以缓冲并合并I/O。SAN提供顺序读取没有问题,因为可以做预读并从缓存中提出数据。在随机写上会慢一些,因为写入操作不能较好地合并。因为读取通常在缓存中无法命中,必须等待硬盘驱动器响应,所以SAN很不适合做随机读取。最重要的是,服务器和SAN之间有传输延迟。这是为什么通过NFS连接SAN时,提供的每秒随机读还不如一块本地磁盘的原因。\n我们已经用较大尺寸的文件做了基准测试,但没有用其他尺寸的文件在上述的系统中测试。然而,无论结果如何,可以预见的是:不管多么强大的SAN,对于小的随机操作,都无法获得良好的响应时间和吞吐量。延时的大部分都是由于服务器和SAN之间的链路导致的。\n我们的基准测试显示的每秒操作吞吐量,并没有说出完整的故事。至少有三个重要指标:每秒吞吐量字节数、并发性和响应时间。在一般情况下,相对于直接连接存储(DAS), SAN无论读写都可以提供更好的顺序I/O吞吐量。大多数SAN可以支持大量的并发性,但基准测试只有一个线程,这可以说明最坏的情况。但是,当工作集不能放到SAN的缓存时,随机读在吞吐量和延迟方面将变得很差,甚至延迟将高于直接访问本地存储。\n9.7.2 使用基于NFS或SMB的SAN # 某些SAN,例如NetApp Filer存储,通常通过NFS访问,而不是通过光纤或者iSCSI。这曾经是我们希望避免的情况,但是NFS今天比以前好了很多。通过NFS可以获得相当好的性能,尽管需要专门配置网络。SAN厂商提供的最佳实践指导可以帮助了解怎样配置。\n主要考虑的事情是NFS协议自身怎样影响性能。许多文件元信息操作,通常在本地文件系统或者SAN设备(非NAS)的内存中执行,但是在NAS上可能需要一次网络来回发送。例如,我们提醒过把二进制日志存在NFS上会损害服务器性能,即使关闭sync_binlog也无济于事。\n也可以通过SMB协议访问SAN或者NAS,需要考虑的问题类似:可能有更多的网络通信,会对延迟产生影响。对传统桌面用户这没什么影响,他们通常只是在挂载的驱动器上存储电子表格或者其他文档,或者只是为了备份复制一些东西到另一台服务器。但是用作MySQL读写它的文件,就会有严重的性能问题。\n9.7.3 MySQL在SAN上的性能 # I/O基准测试只是一种观察的方式,MySQL在SAN上具体性能表现如何?在许多情况下, MySQL运行得还可以,可以避免很多SAN可能导致性能下降的情况。仔细地做好逻辑和物理设计,包括索引和适当的服务器硬件(尽量配置更多的内存!)可避免很多的随机I/O操作,或者可以转化为顺序的I/O。然而,应该知道的是,通过一段时间的运行,这种系统可以达到一个微妙的平衡——引入一个新的查询,Schema的变化或频繁的操作,都很容易扰乱这种平衡。\n例如,一个SAN用户,我们知道他对每天的性能表现非常满意,直到有一天他想清理一张变得非常大的旧表中的大量数据行。这会导致一个长时间运行的DELETE语句,每秒只能删几百行,因为删除每行所需的随机I/O,SAN无法有效快速地执行。有没有办法来加快操作,它只是要花费很长的时间才能完成。另一个让他大吃一惊的事是,当对一个大表执行ALTER类似的操作时明显速度减慢。\n这些都是些典型的例子,哪些工作放在SAN上不合适:执行大量的随机I/O的单线程任务。在当前版本的MySQL中,复制是另一个单线程任务。因此,备库的数据存储在SAN上,可能更容易落后于主库。批处理作业也可能运行得更慢。在非高峰时段或周末执行一个一次性的延迟敏感的操作是可以的,但是服务器的很多部分依然需要很好的性能,例如拷贝、二进制日志,以及InnoDB的事务日志上总是需要很好的小随机I/O性能。\n9.7.4 应该用SAN吗 # 嗯,这是个长期存在的问题——在某些情况下,数百万美元的问题。有很多因素要考虑,以下我们列出其中的几个。\n备份\n集中存储使备份更易于管理。当所有数据都存储在一个地方时,可以只备份SAN,只要确保已经确认过了所有的数据都在。这简化了问题,例如“你确定我们要备份所有的数据吗?”此外,某些设备有如连续数据保护(CDP)以及强大的快照功能等功能,使得备份更容易、更灵活。\n简化容量规划\n不确定需要多大容量吗?SAN可以提供这种能力——购买大容量存储、分享给很多应用,并且可以调整大小并按需求重新发布。\n存储整合还是服务器整合\n某些CIO盘点数据中心运行了哪些东西时,可能会得出结论说大量的I/O容量被浪费了,这是把存储空间和I/O容量混为一谈了。毫无疑问的是,如果集中存储可以确保更好地利用存储资源,但这样做将会如何影响使用存储的系统?典型的数据库操作在性能上可以达到数量级的差异,因此可能会发现,如果集中存储可能需要增加10倍的服务器(或更多)才能处理原来的工作。尽管数据中心的I/O容量在SAN上可以更好地被利用,但是会导致其他系统无法充分被利用(例如数据库服务器花费大量时间等待I/O、应用程序服务器花费大量时间等待数据库,依此类推)。在现实中我们已经看到过很多通过分散存储来整合服务器并削减成本的例子。\n高可用\n有时人们认为SAN是高可用解决方案。之所以会这样认为,可能是因为对高可用的真实含义的理解出现了分歧,我们将在第12章给出建议。\n根据我们的经验,SAN经常与故障和停机联系在一起,这不是因为它们不可靠——它们没什么问题,也确实很少出故障——只是因为人们都不愿意相信这样的工程奇迹其实也会坏的,因而缺乏这方面的准备。此外,SAN有时是一个复杂的、神秘的黑盒子,当出问题的时候没有人知道该如何解决,并且价格昂贵,难以快速构建管理SAN所需的专业知识。大多数的SAN都对外缺乏可见性(就是个黑盒子),这也是为什么不应该只是简单地信任SAN管理员、支持人员或管理控制台的原因。我们看到过所有这三种人都错了的情况:当SAN出了问题,如出现硬盘驱动器故障导致性能下降(26)的案例。这是另一个推荐使用sysbench的理由:sysbench可以快速地完成一个I/O基准测试以证明是否是SAN的问题。\n服务器之间的交互\n共享存储可能会导致看似独立的系统实际上是相互影响的,有时甚至会很严重。例如,我们知道一个SAN用户有个很粗放的认识,当开发服务器上有I/O密集型操作时,会引起数据库服务器几乎陷于停顿。批处理作业、ALTER TABLE、备份——任何一个系统上产生大量的I/O操作都可能会导致其他系统的I/O资源不足。有时的影响远远比直觉想象的糟糕,一个看似不起眼的操作可能会导致严重的性能下降。\n成本\n成本是什么?管理和行政费用?每秒I/O操作数(IOPS)中每个I/O操作的成本?标价?\n有充分的理由使用SAN,但无论销售人员说什么,至少从MySQL需要的性能类型来看,SAN不是最佳的选择。(选择一个SAN供应商并跟它们的销售谈,你可能听到他们一般也是同意的,然后告诉你他们的产品是一个例外。)如果考虑性价比,结论会更加清楚,因为闪存存储或配置有电池支持写缓存的RAID控制器加上老式硬盘驱动器,可以在低得多的价格下提供更好的性能。\n关于这个话题,不要忘了让销售给你两台SAN的价格。至少需要两台,否则这台昂贵的SAN可能会成为故障中的单点。\n有许多“血泪史”可以引以为戒,这不是试图吓唬你远离SAN。我们知道的SAN用户都非常地爱这些存储!如果正在考虑是否使用SAN,最重要的事情是想清楚要解决什么问题。SAN可以做很多事情,但解决性能问题只是其中很小的一部分。相比之下,当不要求很多高性能的随机I/O,但是对某些功能感兴趣的话,如快照、存储整合、重复数据删除和虚拟化,SAN可能非常适合。\n因此,大多数Web应用不应该让数据库使用SAN,但SAN在所谓的企业级应用很受欢迎。企业通常不太受预算限制,所以能够负担得起作为“奢侈品”的SAN。(有时SAN甚至作为一种身份的象征!)\n9.8 使用多磁盘卷 # 我们迟早都会碰到文件应该放哪的问题,因为MySQL创建了多种类型的文件:\n数据和索引文件 事务日志文件 二进制日志文件 常规日志(例如,错误日志、查询日志和慢查询日志) 临时文件和临时表 MySQL没有提供复杂的空间管理功能。默认情况下,只是简单地把每个Schema的文件放入一个单独的目录。有少量选项来控制数据文件放哪。例如,可以指定MyISAM表的索引位置,也可以使用MySQL 5.1的分区表。\n如果正在使用InnoDB默认配置,所有的数据和文件都放在一组数据文件(共享表空间)中,只有表定义文件放在数据目录。因此,大部分用户把所有的数据和文件放在了单独的卷。\n然而,有时使用多个卷可以帮助解决I/O负载高的问题。例如,一个批处理作业需要写入很多数据到一张巨大的表,将这张表放在单独的卷上,可以避免其他查询的I/O受到影响。理想的情况下,应该分析不同数据的I/O访问类型,才能把数据放在适当的位置,但这很难做到,除非已经把数据放在不同的卷上。\n你可能已经听过标准建议,就是把事务日志和数据文件放在不同的卷上面,这样日志的顺序I/O和数据的随机I/O不会互相影响。但是除非有很多硬盘(20或更多)或者闪存存储,否则在这样做之前应该考虑清楚代价。\n二进制日志和数据文件分离的真正的优势,是减少事故中同时丢失数据和日志文件的可能性。如果RAID控制器上没有电池支持的写缓存,把它们分开是很好的做法。\n但是,如果有备用电池单元,分离卷就可能不是想象中那么必要了。性能差异很小是主要原因。这是因为即使有大量的事务日志写入,其中大部分写入都很小。因此,RAID缓存通常会合并I/O请求,通常只会得到每秒的物理顺序写请求。这通常不会干预数据文件的随机I/O,除非RAID控制器真的整体上饱和了。一般的日志,其中有连续的异步写入、负荷也低,可以较好地与数据分享一个卷。\n将日志放在独立的卷是否可以提升性能?通常情况下是的,但是从成本的角度来看这个问题,是否真的值得这么做,答案往往是否定的,尽管很多人不这么认为。\n原因是:为事务日志提供专门的硬盘是很昂贵的。假设有六个硬盘驱动器,最常规的做法是把所有六块盘放到一个RAID卷,或者分成两部分,四个放数据,两个放事务日志。不过如果这样做,就减少了三分之一的硬盘放数据文件,这会导致性能显著地下降。此外,专门提供两个驱动器,对负载的影响也微不足道(假设RAID控制器有电池支持的写缓存)。\n另一方面,如果有很多硬盘,投入一些给事务日志可能会从中受益。例如,一共有30块硬盘,可以分两块硬盘(配置为一个RAID 1的卷)给日志,能让日志写尽可能快。对于额外的性能,也可以在RAID控制器中分配一些写缓存空间给这个RAID卷。\n成本效益不是唯一考虑的因素。可能想保持InnoDB的数据和事务日志在同一个卷的另一个原因是,这种策略可以使用LVM快照做无锁的备份。某些文件系统允许一致的多卷快照,并且对这些文件系统,这是一个很轻量的操作,但对于ext3有很多东西需要注意。(也可以使用Percona XtraBackup来做无锁备份,关于此主题更多的信息,请参阅第15章)\n如果已经启用sync_binlog,二进制日志在性能方面与事务日志相似了。然而,二进制日志存储跟数据放在不同的卷,实际上是一个好主意——把它们分开存放更安全,因此即使数据丢失,二进制日志也可以保存下来。这样,可以使用二进制日志做基于时间点的恢复。这方面的考虑并不适用于InnoDB的事务日志,因为没有数据文件,它们就没用了,你不能将事务日志应用到昨晚的备份。(事务日志和二进制日志之间的区别在其他数据库的DBA看来,很难搞明白,在其他数据库这就是同一个东西。)\n另外一个常见的场景是分离出临时目录的文件,MySQL做filesorts(文件排序)和使用磁盘临时表时会写到临时目录。如果这些文件不会太大的话,最好把它们放在临时内存文件系统,如tmpfs。这是速度最快的选择。如果在你的系统上这不可行,就把它们放在操作系统盘上。\n典型的磁盘布局是有操作系统盘、交换分区和二进制日志的盘,它们放在RAID 1卷上。还要有一个单独的RAID 5或RAID 10卷,放其他的一切东西。\n9.9 网络配置 # 就像延迟和吞吐量是硬盘驱动器的限制因素一样,延迟和带宽(实际上和吞吐量是同一回事)也是网络连接的限制因素。对于大多数应用程序来说,最大的问题是延时。典型的应用程序都需要传输很多很小的网络包,并且每次传输的轻微延迟最终会被累加起来。\n运行不正常的网络通常也是主要的性能瓶颈之一。丢包是一个普遍存在的问题。即使1%的丢包率也足以造成显著的性能下降,因为在协议栈的各个层次都会利用各种策略尝试修复问题,例如等待一段时间再重新发送数据包,这就增加了额外的时间。另一个常见的问题是域名解析系统(DNS)损坏或者变慢了。\nDNS足以称为“阿基里斯之踵”,因此在生产服务器上启用skip_name_resolve是个好主意。损坏或缓慢的DNS解析对许多应用程序都是个问题,对MySQL尤为严重。当MySQL收到一个连接请求时,它同时需要做正向和反向DNS查找。有很多原因可能导致这个过程出问题。当问题出现时,会导致连接被拒绝,或者使得连接到服务器的过程变慢,这通常都会造成严重的影响,甚至相当于遭遇了拒绝服务攻击(DDOS)。如果启用skip_name_resolve选项,MySQL将不会做任何DNS查找的工作。然而,这也意味着,用户账户必须在host列使用具有唯一性的IP地址,“localhost”或者IP地址通配符。那些在host列使用主机名的用户账户都将不能登录。\n典型的Web应用中另一个常见的问题来源是TCP积压,可以通过MySQL的back_log选项来配置。这个选项控制MySQL的传入TCP连接队列的大小。在每秒有很多连接创建和销毁的环境中,默认值50是不够的。设置不够的症状是,客户端会看到零星的“连接被拒绝”的错误,配以三秒超时规则。在繁忙的系统中这个选项通常应加大。把这个选项增加到数百甚至数千,似乎没有任何副作用,事实上如果你看得远一些,可能还需要配置操作系统的TCP网络设置。在GNU/Linux系统,需要增加somaxconn限制,默认只有128,并且需要检查sysctl的tcp_max_syn_back_log设置(在本节稍后有一个例子)。\n应该设计性能良好的网络,而不是仅仅接受默认配置的性能。首先,分析节点之间有多少跳跃点,以及物理网络布局之间的映射关系。例如,假设有10个网页服务器,通过千兆以太网(1 GigE)连接到“网页”交换机,这个交换机也通过千兆网络连接到“数据库”交换机。如果不花时间去追踪连接,可能不会意识到从所有数据库服务器到所有网页服务器的总带宽是有限的!并且每次跨越交换机都会增加延时。\n监控网络性能和所有网络端口的错误是正确的做法,要监控服务器、路由器和交换机的每个端口。多路由流量绘图器(Multi Router Traffic Grapher),或者说MRTG( http://oss.oetiker.ch/mrtg/),对设备监控而言是个靠得住的开源解决方案。其他常见的网络性能监控工具(与设备监控不同)还有Smokeping( http://oss.oetiker.ch/smokeping/)和Cacti( http://www.cacti.net)。\n网络物理隔离也是很重要的因素。城际网络相比数据中心的局域网的延迟要大得多,即使从技术上来说带宽是一样的。如果节点真的相距甚远,光速也会造成影响。例如,在美国的西部和东部海岸都有数据中心,相隔约3000公里。光的速度是186000米每秒,因此一次通信不可能低于16毫秒,往返至少需要32毫秒。物理距离不仅是性能上的考虑,也包括设备之间通信的考虑。中继器、路由器和交换机,所有的性能都会有所降级。再次,越广泛地分隔开的网络节点,连接的不可预知和不可靠因素越大。\n尽可能避免实时的跨数据中心的操作是明智的(27)。如果不可能做到这一点,也应该确保应用程序能正常处理网络故障。例如,我们不希望看到由于Web服务器通过丢包严重的网络连接远程的数据中心时,由于Apache进程挂起而新建了很多进程的情况发生。\n在本地,请至少用千兆网络。骨干交换机之间可能需要使用万兆以太网。如果需要更大的带宽,可以使用网络链路聚合:连接多个网卡(NIC),以获得更多的带宽。链路聚合本质上是并行网络,作为高可用策略的一部分也很有帮助。\n如果需要非常高的吞吐量,也许可以通过改变操作系统的网络配置来提高性能。如果连接不多,但是有很多的查询和很大的结果集,则可以增加TCP缓冲区的大小。具体的实现依赖于操作系统,对于大多数的GNU/Linux系统,可以改变*/etc/sysctl.conf中的值并执行sysctl-p*,或者使用*/proc文件系统写入一个新的值到/proc/sys/net/*里面的文件。搜索“TCP tuning guide”,可以找到很多好的在线教程。\n然而,调整设置以有效地处理大量连接和小查询的情况通常更重要。比较常见的调整之一,就是要改变本地端口的范围。系统的默认值如下:\n[root@server ~]# ** cat /proc/sys/net/ipv4/ip_local_port_range** 32768 61000 有时你也许需要改变这些值,调整得更大一些。例如:\n[root@server ~]# ** echo 1024 65535 \u0026gt; /proc/sys/net/ipv4/ip_local_port_range** 如果要允许更多的连接进入队列,可以做如下操作:\n[root@server ~]# ** echo 4096 \u0026gt; /proc/sys/net/ipv4/tcp_max_syn_backlog** 对于只在本地使用的数据库服务器,对于连接端还未断开,但是通信已经中断的事件中使用的套接字,可以缩短TCP保持状态的超时时间。在大多数系统上默认是一分钟,这个时间太长了:\n[root@server ~]# ** echo \u0026lt;value\u0026gt; \u0026gt; /proc/sys/net/ipv4/tcp_fin_timeout** 这些设置大部分时间可以保留默认值。通常只有当发生了特殊情况,例如网络性能极差或非常大量的连接,才需要进行修改。在互联网上搜索“TCP variables”,可以发现很多不错的阅读资料,除了上面提到的,还能看到很多其他的变量。\n9.10 选择操作系统 # GNU/Linux如今是高性能MySQL最常用的操作系统,但是MySQL本身可以运行在很多操作系统上。\nSolaris是SPARC硬件上的领跑者,在x86硬件上也可以运行。Solaris常用在要求高可靠的应用上面。Solaris在某些方面的易用性可能没有GNU/Linux的名气大,但确实是一个坚固的操作系统,包含许多先进的功能。尤其是Solaris 10增加了ZFS文件系统,包含了很多先进的故障排除工具(如DTrace)、良好的多线程性能,以及称为Solaris Zones的虚拟化技术,有助于资源管理。\nFreeBSD是另一种选择。它历来与MySQL配合有一些问题,大多时候都涉及到线程支持,但新的版本要好得多。如今,看到MySQL在FreeBSD上大规模部署的场景并不是什么稀罕事。ZFS也可以在FreeBSD上使用。\n通常用于开发和桌面应用程序的MySQL选择的是Windows。也有企业级的MySQL部署在Windows上,但一般的企业级MySQL更多的还是部署在类UNIX操作系统上。我们不希望引起任何有关操作系统的争论,需要指出的是在异构操作系统环境中使用MySQL是不存在问题的。在类UNIX的操作系统上运行的MySQL服务器,同时在Windows上运行Web服务器,然后通过高品质的.NET连接器(这是MySQL免费提供的)进行连接,这是一个非常合理的架构。从UNIX连接到Windows上的MySQL服务器和连接到另一台UNIX上的MySQL服务器一样简单。\n当选择操作系统时,如果使用的是64位架构的硬件(见前面介绍的“CPU架构”),请确保安装的是64位版本的操作系统。\n谈到GNU/Linux发行版时,个人的喜好往往是决定性的因素。我们认为最好的策略是使用专为服务器应用程序设计的发行版,而不是桌面发行版。要考虑发行版的生命周期、发布和更新政策,并检查供应商的支持是否有效。红帽子企业版Linux是一个高品质、稳定的发行版;CentOS是一个受欢迎的二进制兼容替代品(免费),但已经因为延后时间较长获得了一些批评;还有Oracle发行的Oracle Enterprise Linux;另外,Ubuntu和Debian也是流行的发行版。\n9.11 选择文件系统 # 文件系统的选择非常依赖于操作系统。在许多系统中,如Windows就只有一到两个选择,而且只有一个(NTFS)真的是能用的。比较而言,GNU/Linux则支持多种文件系统。\n许多人想知道哪个文件系统在GNU/Linux上能提供最好的MySQL性能,或者更具体一些,哪个对InnoDB和MyISAM而言是最好的选择。实际的基准测试表明,大多数文件系统在很多方面都非常接近,但测试文件系统的性能确实是一件烦心事。文件系统的性能是与工作负载相关的,没有哪个文件系统是“银弹”。大部分情况下,给定的文件系统不会明显地表现得与其他文件系统不一样。除非遇到了文件系统的限制,例如,它怎么支持并发、怎么在多文件下工作、怎么对文件切片,等等。\n要考虑的更重要的问题是崩溃恢复时间,以及是否会遇到特定的限制,如目录下有许多文件会导致运行缓慢(这是ext2和旧版本ext3下一个臭名昭著的问题,但当前版本的ext3和ext4中用dir_index选项解决了问题)。文件系统的选择对确保数据安全是非常重要的,所以我们强烈建议不要在生产系统做实验。\n如果可能,最好使用日志文件系统,例如ext3、ext4、XFS、ZFS或者JFS。如果不这么做,崩溃后文件系统的检查可能耗费相当长的时间。如果系统不是很重要,非日志文件系统性能可能比支持事务的好。例如,ext2可能比ext3工作得好,或者可以使用tunefs关闭ext3的日志记录功能。挂载时间对某些文件系统也是一个因素。例如,ReiserFS,在一个大的分区上可能用很长时间来挂载和执行日志恢复。\n如果使用ext3或者其继承者ext4,有三个选项来控制数据怎么记日志,这可以放在*/etc/fstab*中作为挂载选项:\ndata=writeback\n这个选项意味着只有元数据写入日志。元数据写入和数据写入并不同步。这是最快的配置,对InnoDB来说通常是安全的,因为InnoDB有自己的事务日志。唯一的例外是,崩溃时恰好导致*.frm*文件损坏了。\n这里给出一个使用这个配置可能导致问题的例子。当程序决定扩展一个文件使其更大,元数据(文件大小)会在数据实际写到(更大的)文件之前记录并写下操作情况。结果就是文件的尾部——最新扩展的区域——会包含垃圾数据。\ndata=ordered\n这个选项也只会记录元数据,但提供了一些一致性保证,在写元数据之前会先写数据,使它们保持一致。这个选项只是略微比writeback选项慢,但是崩溃时更安全。\n在此配置中,如果我们再次假设程序想要扩展一个文件,该文件的元数据将不能反映文件的新大小,直到驻留在新扩展区域中的数据被写到文件中了。\ndata=journal\n此选项提供了原子日志的行为,在数据写入到最终位置之前将记录到日志中。这个选项通常是不必要的,它的开销远远高于其他两个选项。然而,在某些情况下反而可以提高性能,因为日志可以让文件系统延迟把数据写入最终位置的操作。\n不管哪种文件系统,都有一些特定的选项最好禁用,因为它们没有提供任何好处,反而增加了很多开销。最有名的是记录访问时间的选项,甚至读文件或目录时也要进行一次写操作。在*/etc/fstab中添加noatime、nodiratime挂载选项可以禁用此选项,这样做有时可以提高5%~10%的性能,具体取决于工作负载和文件系统(虽然在其他场景下差别可能不是太大)。下面是/etc/fstab*中的一个例子,对ext3选项做设置的行:\n/dev/sda2 /usr/lib/mysql ext3 noatime,nodiratime,data=writeback 0 1 还可以调整文件系统的预读的行为,因为这可能也是多余的。例如,InnoDB有自己的预读策略,所以文件系统的预读就是重复多余的。禁用或限制预读对Solaris的UFS尤其有利。使用O_DIRECT选项会自动禁用预读。\n一些文件系统可能不支持某些需要的功能。例如,若让InnoDB使用O_DIRECT刷新方式,文件系统能支持Direct I/O是非常重要的。此外,一些文件系统处理大量底层驱动器比其他的文件系统更好,举例来说XFS在这方面通常比ext3好。最后,如果打算使用LVM快照来初始化备库或进行备份,应该确认选择的文件系统和LVM版本能很好地协同工作。\n表9-4某些常见文件系统的特性总结。\n表9-4:常见文件系统特性 文件系统 操作系统 支持日志 大目录 ext2 GNU/Linux 否 否 ext3 GNU/Linux 可选 可选/部分 ext4 GNU/Linux 是 是 HFS Plus Mac OS 可选 是 JFS GNU/Linux 是 否 NTFS Windows 是 是 ReiserFS GNU/Linux 是 是 UFS (Solaris) Solaris 是 可调的 UFS (FreeBSD) FreeBSD 否 可选/部分 UFS2 FreeBSD 否 可选/部分 XFS GNU/Linux 是 是 ZFS Solaris, FreeBSD 是 是\n我们通常建议客户使用XFS文件系统。ext3文件系统有太多严重的限制,例如inode只有一个互斥变量,并且fsync()时会刷新所有脏块,而不只是单个文件。很多人感觉ext4文件系统用在生产环境有点太新了,不过现在似乎正日益普及。\n9.12 选择磁盘队列调度策略 # 在GNU/Linux上,队列调度决定了到块设备的请求实际上发送到底层设备的顺序。默认情况下使用cfq(Completely Fair Queueing,完全公平排队)策略。随意使用的笔记本和台式机使用这个调度策略没有问题,并且有助于防止I/O饥饿,但是用于服务器则是有问题的。在MySQL的工作负载类型下,cfq会导致很差的响应时间,因为会在队列中延迟一些不必要的请求。\n可以用下面的命令来查看系统所有支持的以及当前在用的调度策略:\n** $ cat /sys/block/sda/queue/scheduler** noop deadline [cfq] 这里sda需要替换成想查看的磁盘的盘符。在我们的例子中,方括号表示正在使用的调度策略。cfq之外的两个选项都适合服务器级的硬件,并且在大多数情况下,它们工作同样出色。noop调度适合没有自己的调度算法的设备,如硬件RAID控制器和SAN。deadline则对RAID控制器和直接使用的磁盘都工作良好。我们的基准测试显示,这两者之间的差异非常小。重要的是别用cfq,这可能会导致严重的性能问题。\n不过这个建议也需要有所保留的,因为磁盘调度策略实际上在不同的内核有很多不一样的地方,千万不能望文生义。\n9.13 线程 # MySQL每个连接使用一个线程,另外还有内部处理线程、特殊用途的线程,以及所有存储引擎创建的线程。在MySQL 5.5中,Oracle提供了一个线程池插件,但目前尚不清楚在实际应用中能获得什么好处。\n无论哪种方式,MySQL都需要大量的线程才能有效地工作。MySQL确实需要内核级线程的支持,而不只是用户级线程,这样才能更有效地使用多个CPU。另外也需要有效的同步原子,例如互斥变量。操作系统的线程库必须提供所有的这些功能。\nGNU/Linux提供两个线程库:LinuxThreads和新的原生POSIX线程库(NPTL)。LinuxThreads在某些情况下仍然使用,但现在的发行版已经切换到NPTL,并且大部分应用已经不再加载LinuxThreads。NPTL更轻量,更高效,也不会有那些LinuxThreads遇到的问题。\nFreeBSD会加载许多线程库。从历史上看,它对线程的支持很弱,但现在已经变得好多了,在一些测试中,甚至优于SMP系统上的GNU/Linux。在FreeBSD 6和更新版本,推荐的线程库是libthr,早期版本使用的linuxthreads,是FreeBSD从GNU/Linux上移植的LinuxThreads库。\n通常,线程问题都是过去的事了,现在GNU/Linux和FreeBSD都提供了很好的线程库。\nSolaris和Windows一直对线程有很好的支持,尽管直到5.5发布之前,MyISAM都不能在Windows下很好地使用线程,但5.5里有显著的提升。\n9.14 内存交换区 # 当操作系统因为没有足够的内存而将一些虚拟内存写到磁盘就会发生内存交换(28)。内存交换对操作系统中运行的进程是透明的。只有操作系统知道特定的虚拟内存地址是在物理内存还是硬盘。\n内存交换对MySQL性能影响是很糟糕的。它破坏了缓存在内存的目的,并且相对于使用很小的内存做缓存,使用交换区的性能更差。MySQL和存储引擎有很多算法来区别对待内存中的数据和硬盘上的数据,因为一般都假设内存数据访问代价更低。\n因为内存交换对用户进程不可见,MySQL(或存储引擎)并不知道数据实际上已经移动到磁盘,还会以为在内存中。\n结果会导致很差的性能。例如,若存储引擎认为数据依然在内存,可能觉得为“短暂”的内存操作锁定一个全局互斥变量(例如InnoDB缓冲池Mutex)是OK的。如果这个操作实际上引起了硬盘I/O,直到I/O操作完成前任何操作都会被挂起。这意味着内存交换比直接做硬盘I/O操作还要糟糕。\n在GNU/Linux上,可以用vmstat(在下一部分展示了一些例子)来监控内存交换。最好查看si和so列报告的内存交换I/O活动,这比看swpd列报告的交换区利用率更重要。swpd列可以展示那些被载入了但是没有被使用的进程,它们并不是真的会成为问题。我们喜欢si和so列的值为0,并且一定要保证它们低于每秒10个块。\n极端的场景下,太多的内存交换可能导致操作系统交换空间溢出。如果发生了这种情况,缺乏虚拟内存可能让MySQL崩溃。但是即使交换空间没有溢出,非常活跃的内存交换也会导致整个操作系统变得无法响应,到这种时候甚至不能登录系统去杀掉MySQL进程。有时当交换空间溢出时,甚至Linux内核都会完全hang住。\n绝不要让系统的虚拟内存溢出!对交换空间利用率做好监控和报警。如果不知道需要多少交换空间,就在硬盘上尽可能多地分配空间,这不会对性能造成冲击,只是消耗了硬盘空间。有些大的组织清楚地知道内存消耗将有多大,并且内存交换被非常严格地控制,但是对于只有少量多用途的MySQL实例,并且工作负载也多种多样的环境,通常不切实际。如果后者的描述更符合实际情况,确认给服务器一些“呼吸”的空间,分配足够的交换空间。\n在特别大的内存压力下经常发生的另一件事是内存不足(OOM),这会导致踢掉和杀掉一些进程。在MySQL进程这很常见。在另外的进程上也挺常见,比如SSH,甚至会让系统不能从网络访问。可以通过设置SSH进程的oom_adj或oom_score_adj值来避免这种情况。\n可以通过正确地配置MySQL缓冲来解决大部分内存交换问题,但是有时操作系统的虚拟内存系统还是会决定交换MySQL的内存。这通常发生在操作系统看到MySQL发出了大量I/O,因此尝试增加文件缓存来保存更多数据时。如果没有足够的内存,有些东西就必须被交换出去,有些可能就是MySQL本身。有些老的Linux内核版本也有一些适得其反的优先级,导致本不应该被交换的被交换出去,但是在最近的内核都被缓解了。\n有些人主张完全禁用交换文件。尽管这样做有时在某些内核拒绝工作的极端场景下是可行的,但这降低了操作系统的性能(在理论上不会,但是实际上会的)。同时这样做也是很危险的,因为禁用内存交换就相当于给虚拟内存设置了一个不可动摇的限制。如果MySQL需要临时使用很大一块内存,或者有很耗内存的进程运行在同一台机器(如夜间批量任务),MySQL可能会内存溢出、崩溃,或者被操作系统杀死。\n操作系统通常允许对虚拟内存和I/O进行一些控制。我们提供过一些GNU/Linux上控制它们的办法。最基本的方法是修改*/proc/sys/vm/swappiness*为一个很小的值,例如0或1。这告诉内核除非虚拟内存完全满了,否则不要使用交换区。下面是如何检查这个值的例子:\n** $ cat /proc/sys/vm/swappiness** 60 这个值显示为60,这是默认的设置(范围是0~100)。对服务器而言这是个很糟糕的默认值。这个值只对笔记本适用。服务器应该设置为0:\n** $ echo 0 \u0026gt; /proc/sys/vm/swappiness** 另一个选项是修改存储引擎怎么读取和写入数据。例如,使用innodb_flush_method=O_DIRECT,减轻I/O压力。Direct I/O并不缓存,因此操作系统并不能把MySQL视为增加文件缓存的原因。这个参数只对InnoDB有效。你也可以使用大页,不参与换入换出。这对MyISAM和InnoDB都有效。\n另一个选择是使用MySQL的memlock配置项,可以把MySQL锁定在内存。这可以避免交换,但是也可能带来危险:如果没有足够的可锁定内存,MySQL在尝试分配更多内存时会崩溃。这也可能导致锁定的内存太多而没有足够的内存留给操作系统。\n很多技巧都是对于特定内核版本的,因此要小心,尤其是当升级的时候。在某些工作负载下,很难让操作系统的行为合情合理,并且仅有的资源可能让缓冲大小达不到最满意的值。\n9.15 操作系统状态 # 操作系统会提供一些帮助发现操作系统和硬件正在做什么的工具。在这一节,我们会展示一些例子,包括关于怎样使用两个最常用的工具——iostat和vmstat。如果系统不提供它们中的任何一个,有可能提供了相似的替代品。因此,我们的目的不是让大家熟练使用iostat和vmstat,而是告诉你用类似的工具诊断问题时应该看什么指标。\n除了这些,操作系统也许还提供了其他的工具,如mpstat或者sar。如果对系统的其他部分感兴趣,例如网络,你可能希望使用ifconfig(除了其他信息,它能显示发生了多少次网络错误)或者netstat。\n默认情况下,vmstat和iostat只是生成一个报告,展示自系统启动以来很多计数器的平均值,这其实没什么用。然而,两个工具都可以给出一个间隔参数,让它们生成增量值的报告,展示服务器正在做什么,这更有用。(第一行显示的是系统启动以来的统计,通常可以忽略这一行。)\n9.15.1 如何阅读vmstat的输出 # 我们先看一个vmstat的例子。用下面的命令让它每5秒钟打印出一个报告:\n可以用Ctrl+C停止vmstat。可以看到输出依赖于所用的操作系统,因此可能需要阅读一下手册来解读报告。\n刚启动不久,即使采用增量报告,第一行的值还是显示自系统启动以来的平均值,第二行开始展示现在正在发生的情况,接下来的行会展示每5秒的间隔内发生了什么。每一列的含义在头部,如下所示:\nprocs\nr这一列显示了多少进程正在等待CPU,b列显示多少进程正在不可中断地休眠(通常意味着它们在等待I/O,例如磁盘、网络、用户输入,等等)。\nmemory\nswpd列显示多少块被换出到了磁盘(页面交换)。剩下的三个列显示了多少块是空闲的(未被使用)、多少块正在被用作缓冲,以及多少正在被用作操作系统的缓存。\nswap\n这些列显示页面交换活动:每秒有多少块正在被换入(从磁盘)和换出(到磁盘)。它们比监控swpd列重要多了。\n大部分时间我们喜欢看到si和so列是0,并且我们很明确不希望看到每秒超过10个块。突发性的高峰一样很糟糕。\nio\n这些列显示有多少块从块设备读取(bi)和写出(bo)。这通常反映了硬盘I/O。\nsystem\n这些列显示了每秒中断(in)和上下文切换(cs)的数量。\ncpu\n这些列显示所有的CPU时间花费在各类操作的百分比,包括执行用户代码(非内核)、执行系统代码(内核)、空闲,以及等待I/O。如果正在使用虚拟化,则第五个列可能是st,显示了从虚拟机中“偷走”的百分比。这关系到那些虚拟机想运行但是系统管理程序转而运行其他的对象的时间。如果虚拟机不希望运行任何对象,但是系统管理程序运行了其他对象,这不算被偷走的CPU时间。\nvmstat的输出跟系统有关,所以如果看到跟我们展示的例子不同的输出,应该阅读系统的vmstat(8)手册。一个重要的提示是:内存、交换区,以及I/O统计是块数而不是字节。在GNU/Linux,块大小通常是1 024字节。\n9.15.2 如何阅读iostat的输出 # 现在让我们转移到iostat(29)。默认情况下,它显示了与vmstat相同的CPU使用信息。我们通常只是对I/O统计感兴趣,所以使用下面的命令只展示扩展的设备统计:\n** $ iostat -dx 5** Device: rrqm/s wrqm/s r/s w/s rsec/s wsec/s avgrq-sz avgqu-sz await svctm %util sda 1.6 2.8 2.5 1.8 138.8 36.9 40.7 0.1 23.2 6.0 2.6 与vmstat一样,第一行报告显示的是自系统启动以来的平均值(通常删掉它节省空间),然后接下来的报告显示了增量的平均值,每个设备一行。\n有多种选项显示和隐藏列。官方文档有点难以理解,因此我们必须从源码中挖掘真正显示的内容是什么。说明的列信息如下:\nrrqm/s和wrqm/s\n每秒合并的读和写请求。“合并的”意味着操作系统从队列中拿出多个逻辑请求合并为一个请求到实际磁盘。\nr/s和w/s\n每秒发送到设备的读和写请求。\nrsec/s和wsec/s\n每秒读和写的扇区数。有些系统也输出为rkB/s和wkB/s,意为每秒读写的千字节数。为了简洁,我们省略了那些指标说明。\navgrq-sz\n请求的扇区数。\navgqu-sz\n在设备队列中等待的请求数。\nawait\n磁盘排队上花费的毫秒数。很不幸,iostat没有独立统计读和写的请求,它们实际上不应该被一起平均。当你诊断性能案例时这通常很重要。\nsvctm\n服务请求花费的毫秒数,不包括排队时间。\n%util\n至少有一个活跃请求所占时间的百分比。如果熟悉队列理论中利用率的标准定义,那么这个命名很莫名其妙。它其实不是设备的利用率。超过一块硬盘的设备(例如RAID控制器)比一块硬盘的设备可以支持更高的并发,但是%util从来不会超过100%,除非在计算时有四舍五入的错误。因此,这个指标无法真实反映设备的利用率,实际上跟文档说的相反,除非只有一块物理磁盘的特殊例子。\n可以用iostat的输出推断某些关于机器I/O子系统的实际情况。一个重要的度量标准是请求服务的并发数。因为读写的单位是每秒而服务时间的单位是千分之一秒,所以可以利用利特尔法则(Little\u0026rsquo;s Law)得到下面的公式,计算出设备服务的并发请求数(30):\nconcurrency = (r/s + w/s) * (svctm/1000) 这是一个iostat的输出示例:\nDevice: rrqm/s wrqm/s r/s w/s rsec/s wsec/s avgrq-sz avgqu-sz await svctm %util sda 105 311 298 820 3236 9052 10 127 113 9 96 把数字带入并发公式,可以得到差不多9.6的并发性(31)。这意味着在一个采样周期内,这个设备平均要服务9.6次的请求。例子来自于一个10块盘的RAID 10卷,所以操作系统对这个设备的并行请求运行得相当好。另一方面,这是一个出现串行请求的设备:\nDevice: rrqm/s wrqm/s r/s w/s rsec/s wsec/s avgrq-sz avgqu-sz await svctm %util sdc 81 0 280 0 3164 0 11 2 7 3 99 并发公式展示了这个设备每秒只处理一个请求。两个设备都接近于满负荷利用,但是它们的性能表现完全不一样。如果设备一直都像这些例子展示的一样忙,那么应该检查一下并发性,不管是不是接近于设备中的物理盘数,都需要注意。更低的值则说明有如文件系统串行的问题,就像我们前面讨论的。\n9.15.3 其他有用的工具 # 我们展示vmstat和iostat是因为它们部署最广泛,并且vmstat通常默认安装在许多类UNIX操作系统上。然而,每种工具都有自身的限制,例如莫名奇妙的度量单位、当操作系统更新统计时取样间隔不一致,以及不能一次性看到所有重要的点。如果这些工具不能符合需求,你可能会对*dstat(http://dag.wieers.com/home-made/dstat/)或collectl (http://collectl.sourceforge.net)*感兴趣。\n我们也喜欢用mpstat来观察CPU统计;它提供了更好的办法来观察CPU每个工作是如何进行的,而不是把它们搅在一块。有时在诊断问题时这非常重要。当分析硬盘I/O利用的时候,blktrace可能也非常有用。\n我们自己开发了iostat的替代品,叫做pt-diskstats。这是Percona Toolkit的一部分。它解决了一些对iostat的抱怨,例如显示读写统计的方式,以及缺乏对并发量的可见性。它也是交互式的,并且是按键驱动的,所以可以放大缩小、改变聚集、过滤设备,以及显示和隐藏列。即使没有安装这个工具,也可以通过简单的Shell脚本来收集一些硬盘统计状态,这个工具也支持分析这样采集出来的文本。可以抓取一些硬盘活动样本,然后发邮件或者保存起来,稍后分析。实际上,我们第3章中介绍的pt-stalk、pt-collect、和pt-sift三件套,都被设计得可以跟pt-diskstats很好地配合。\n9.15.4 CPU密集型的机器 # CPU密集型服务器的vmstat输出通常在us列会有一个很高的值,报告了花费在非内核代码上的CPU时钟;也可能在sy列有很高的值,表示系统CPU利用率,超过20%就足以令人不安了。在大部分情况下,也会有进程队列排队时间(在r列报告的)。下面是一个例子:\n注意,这里也有合理数量的上下文切换(在cs列),除非每秒超过100000次或更多,一般都不用担心上下文切换。当操作系统停止一个进程转而运行另一个进程时,就会产生上下文切换。\n例如,一查询语句在MyISAM上执行了一个非覆盖索引扫描,就会先从索引中读取元素,然后根据索引再从磁盘上读取页面。如果页面不在操作系统缓存中,就需要从磁盘进行物理读取,这就会导致上下文切换中断进程处理,直到I/O完成。这样一个查询可以导致大量的上下文切换。\n如果我们在同一台机器观察iostat的输出(再次剔除显示启动以来平均值的第一行),可以发现磁盘利用率低于50%:\n这台机器不是I/O密集型的,但是依然有相当数量的I/O发生,在数据库服务器中这种情况很少见。另一方面,传统的Web服务器会消耗大量CPU资源,但是很少发生I/O,所以Web服务器的输出不会像这个例子。\n9.15.5 I/O密集型的机器 # 在I/O密集型工作负载下,CPU花费大量时间在等待I/O请求完成。这意味着vmstat会显示很多处理器在非中断休眠(b列)状态,并且在wa这一列的值很高,下面是个例子:\n这台机器的iostat输出显示硬盘一直很忙:(32)\n%util的值可能因为四舍五入的错误超过100%。什么迹象意味着机器是I/O密集的呢?只要有足够的缓冲来服务写请求,即使机器正在做大量的写操作,也可能可以满足,但是却通常意味着硬盘可能会无法满足读请求。这听起来好象违反直觉,但是如果思考读和写的本质,就不会这么认为了:\n写请求能够缓冲或同步操作。它们可以被我们本书讨论过的任意一层缓冲:操作系统层、RAID控制器层,等等。 读请求就其本质而言都是同步的。当然程序可以猜测到可能需要某些数据,并异步地提前读取(预读)。无论如何,通常程序在继续工作前必须得到它们需要的数据。这就强制读请求为同步操作:程序必须被阻塞直到请求完成。 想想这种方式:你可以发出一个写请求到缓冲区的某个地方,然后过一会完成。甚至可以每秒发出很多这样的请求。如果缓冲区正确工作,并且有足够的空间,每个请求都可以很快完成,并且实际上写到物理硬盘是被重新排序后更有效地批量操作的。然而,没有办法对读操作这么做——不管多小或多少的请求,都不可能让硬盘响应说“这是你的数据,我等一会读它”。这就是为什么读需要I/O等待是可以理解的原因。\n9.15.6 发生内存交换的机器 # 一台正在发生内存交换的机器可能在swpd列有一个很高的值,也可能不高。但是可以看到si和so列有很高的值,这是我们不希望看到的。下面是一台内存交换严重的机器的vmstat输出:\n9.15.7 空闲的机器 # 为完整起见,下面也给出一台空闲机器上的vmstat输出。注意,没有在运行或被阻塞的进程,idle列显示CPU是100%的空闲。这个例子来源于一台运行红帽子企业版Linux 5(RHEL 5)的机器,并且st列展示了从“虚拟机”偷来的时间。\n9.16 总结 # 为MySQL选择和配置硬件,以及根据硬件配置MySQL,并不是什么神秘的艺术。通常,对于大部分目标需要的都是相同的技巧和知识。当然,也需要知道一些MySQL特有的特点。\n我们通常建议大部分人在性能和成本之间找到一个好的平衡点。首先,出于多种原因,我们喜欢使用廉价服务器。举个例子,如果在使用服务器的过程中遇到了麻烦,并且在诊断时需要停止服务,或者希望只是简单地把出问题的服务器用另一台替换,如果使用的是一台$5000的廉价服务器,肯定比使用一台超过$50000或者更贵的服务器要简单得多。MySQL通常也更适应廉价服务器,不管是从软件自身而言还是从典型的工作负载而言。\nMySQL需要的四种基本资源是:CPU、内存、硬盘以及网络资源。网络一般不会作为很严重的瓶颈出现,而CPU、内存和磁盘通常是主要的瓶颈所在。对MySQL而言,通常希望有很多快速CPU可以用,但如果必须在快和多之间做选择,则一般会选择更快而不是更多(其他条件相同的情况下)。\nCPU、内存以及磁盘之间的关系错综复杂,一个地方的问题可能会在其他地方显现出来。在对一个资源抛出问题时,问问自己是不是可能是由另外的问题导致的。如果遇到硬盘密集的操作,需要更多的I/O容量吗?或者是更多的内存?答案取决于工作集大小,也就是给定的时间内最常用的数据集。\n在本书写作的过程中,我们觉得以下做法是合理的。首先,通常不要超过两个插槽。现在即使双路系统也可以提供很多CPU核心和硬件线程了,而且四路服务器的CPU要贵得多。另外,四路CPU的使用不够广泛(也就意味着缺少测试和可靠性),并且使用的是更低的时钟频率。最终,四路插槽的系统跨插槽的同步开销也显著增加。在内存方面,我们喜欢用价格经济的服务器内存。许多廉价服务器目前有18个DIMM槽,单条8GB的DIMM是最好的选择——每GB的价格与更低容量的DIMM相比差不多,但是比16GB的DIMM便宜多了。这是为什么我们今天看到很多服务器是144GB的内存的原因。这个等式会随着时间的变化而变化——可能有一天具有最佳性价比的是16GB的DIMM,并且服务器出厂的内存槽数量也可能不一样——但是一般的原则还是一样的。\n持久化存储的选择本质上归结为三个选项,以提高性能的次序排序:SAN、传统硬盘,以及固态存储设备。\n当需要功能和纯粹的容量时,SAN是不错的。它们对许多工作负载都运行得不错,但缺点是很昂贵,并且对小的随机I/O操作有很大的延时,尤其是使用更慢的互联方式(如NFS)或工作集太大不足以匹配SAN内存的缓存时,延时会更大。要注意SAN的性能突变的情况,并且要非常小心避免灾难的场景。 传统硬盘很大,便宜,但是对随机读很慢。对大部分场景,最好的选择是服务器硬盘组成RAID 10卷。通常应该使用带有电池保护单元的RAID控制器,并且设置写缓存为WriteBack策略。这样一个配置对大部分工作负载都可以运行良好。 固态盘相对比较小并且昂贵,但是随机I/O非常快。一般分为两类:SSD和PCIe设备。广泛地来说,SSD更便宜,更慢,但缺少可靠性验证。需要对SSD做RAID以提升可靠性,但是大多数硬件RAID控制器不擅长这个任务(33)。PCIe设备很昂贵并且有容量限制,但是非常快并且可靠,而且不需要RAID。 固态存储设备可以很大地提升服务器整体性能。有时候一个不算昂贵的SSD,可以帮助解决经常在传统硬盘上遇到的特定工作负载的问题,如复制。如果真的需要很强的性能,应该使用PCIe设备。增加高速I/O设备会把服务器的性能瓶颈转移到CPU,有时也会转移到网络。\nMySQL和InnoDB并不能完全发挥高端固态存储设备的性能,并且在某些场景下操作系统也不能发挥。但是提升依然很明显。Percona Server对固态存储做了很多改进,并且很多改进在5.6发布时已经进入了MySQL主干代码。\n对操作系统而言,只有很少的一些重要配置需要关注,大部分是关于存储、网络和虚拟内存管理的。如果像大部分MySQL用户一样使用GNU/Linux,建议采用XFS文件系统,并且为服务器的页面交换倾向率(swapiness)和硬盘队列调度器设置恰当的值。有一些网络参数需要改变,可能还有一些其他的地方(例如禁用SELinux)需要调优,但是前面说的那些改动的优先级应该更高一些。\n————————————————————\n(1) 普通PC Server也能配到192GB内存。——译者注\n(2) 网络吞吐也是一种I/O。——译者注\n(3) 超线程技术。——译者注\n(4) 然而,程序可能依赖大量在操作系统内存中缓存的数据,对程序来说,概念上属于“在磁盘上”的数据。例如,MyISAM就是这么做的,它把数据文件放在磁盘上,并通过操作系统缓存磁盘上的数据,使其访问速度更快。\n(5) 正确的数字是11%而不是10%。10%的未命中率对应90%的命中率,所以你需要用10GB除以90%,就是11.111GB。\n(6) 有趣的是,有些人故意买更大容量的磁盘,然后只使用20%~30%的容量。这增加了数据局部性和减少寻道时间,有时可以证明值得它们高的价格。\n(7) 5.6也可以按库做多线程复制。——译者注\n(8) 有些公司声称,他们抛弃过去主轴(机械)的羁绊,从一个干净的石板开始。温和的怀疑是有道理的;解决RDBMS的挑战是不容易的。\n(9) 这是一种简化,但细节在这里并不重要。如果你喜欢,可以阅读维基百科上的更多信息。\n(10) 但这不是全部。我们在基准测试后检查了驱动器,并且发现两块SSD坏盘,有一块不一致。\n(11) 意思就是内存放不下要缓存的数据时,换出到Flashcache上,Flashache的闪存设备可以帮助继续缓存,而不会立刻落到磁盘。——译者注\n(12) 分区(看第7章)是另一个好办法,因为它通常把文件分成多份,你可以放在不同的磁盘上。但是,相对于分区,RAID对于很大数据是一个更简单的解决方案。这不需要你手动进行负载平衡或者在负载分布发生变化时进行干预,并且可以提供冗余,而你不能把分区文件放在不同的磁盘。\n(13) 两个很好的RAID学习资源是维基百科上的文章( http://en.wikipedia.org/wiki/RAID)和AC&NC教程 http://www.acnc.com/04_00.html。\n(14) 因为读取并不需要写校验位。——译者注\n(15) 意思是损失一块盘,读取的时候本来可以从相互镜像的两块盘中同时读,少了一块盘就只能从另一块镜像盘上去读了。——译者注\n(16) 尤其是SSD盘,同时损坏的可能性是比较大的。——译者注\n(17) 例如一份数据的两个镜像就在这两个盘上。——译者注\n(18) 就是每个服务器上的数据都会被业务使用,没有机器作为备用的。——译者注\n(19) 就是先做五个两块盘的RAID 1,然后再把五个镜像对做成RAID 0,形成RAID 10。——译者注\n(20) 就是做五个两块盘的RAID 1,然后交给操作系统使用。——译者注\n(21) 有些RAID卡不支持直接做RAID 10,只能做成几组RAID 1,然后由操作系统LVM再做RAID 0,最终形成RAID 10。——译者注\n(22) 可以缓冲随机I/O部分合并为顺序I/O。——译者注\n(23) 因为没有充分地合并I/O。——译者注\n(24) 有几种技术,包括电容器和闪存存储,但这里我们都归结到BBU这一类。。\n(25) 就是fsync只是刷新到了硬盘上的缓存,这个缓存是没有电池的,所以掉电会丢失数据。——译者注\n(26) 基于网络的SAN管理控制台坚持所有硬盘驱动器是健康的——直到我们要求管理员按Shift+F5来禁用他的浏览器缓存并强制刷新控制台!\n(27) 复制不算实时跨数据中心操作,它不是实时的,并且通常把数据复制到一个远程位置有助于提升数据安全性(容灾)。我们下一章会更多覆盖这个内容。\n(28) 内存交换有时称为页面交换。从技术上来说,它们是不同的东西,但是人们通常把它们作为同义词。\n(29) 我们本书展示的iostat的例子为了印刷被稍微重排了:我们减少了小数位来避免换行。我们是在GNU/Linux上展示例子。其他操作系统输出可能不完全一样。\n(30) 另一种计算并发的方式是通过平均队列大小、服务时间,以及平均等待时间:(avuqu_sz*svctm)/await。\n(31) 如果你做这个计算,会得到大约10,因为为了格式化我们已经取整了iostat的输出。相信我们,确实是9.6。\n(32) 在书的第二版中,我们混淆了“总是很忙”和“完全饱和”。总是在做事的硬盘并不总是达到极限,因为它们可能也能支持一些并发。\n(33) 有些RAID控制器对SSD支持很差,做了RAID性能下降。——译者注\n"},{"id":139,"href":"/zh/docs/technology/MySQL/_%E9%AB%98%E6%80%A7%E8%83%BDMySQL_/%E7%AC%AC8%E7%AB%A0%E4%BC%98%E5%8C%96%E6%9C%8D%E5%8A%A1%E5%99%A8%E8%AE%BE%E7%BD%AE/","title":"第8章优化服务器设置","section":"高性能 My SQL","content":"第8章 优化服务器设置\n在这一章,我们将解释为这是我的撒旦JFK数据库嘎斯公开就开始打山豆根士大夫 圣诞节复活节是是国家开始大幅机啊可是对方看见噶开暗杀是的JFK开始讲课的感觉爱看书的JFK史蒂夫卡卡萨丁咖啡碱撒快递费始东方会i二位人家儿童科技数据库的房价开始JFK注释MySQL服务器创建一个靠谱的配置文件的过程。这是一个很绕的过程,有很多有意思的关注点和值得关注的思路。关注这些点很有必要,因为创建一个好配置的最快方法不是从学习配置项开始,也不是从问哪个配置项应该怎么设置或者怎么修改开始,更不是从检查服务器行为和询问哪个配置项可以提升性能开始。最好是从理解MySQL内核和行为开始。然后可以利用这些知识来指导配置MySQL。最后,可以将想要的配置和当前配置进行比较,然后纠正重要并且有价值的不同之处。\n人们经常问,“我的服务器有32GB内存,12核CPU,怎样配置最好?”很遗憾,问题没这么简单。服务器的配置应该符合它的工作负载、数据,以及应用需求,并不仅仅看硬件的情况。\nMySQL有大量可以修改的参数——但不应该随便去修改。通常只需要把基本的项配置正确(大部分情况下只有很少一些参数是真正重要的),应该将更多的时间花在schema的优化、索引,以及查询设计上。在正确地配置了MySQL的基本配置项之后,再花力气去修改其他配置项的收益通常就比较小了。\n从另一方面来说,没用的配置导致潜在风险的可能更大。我们碰到过不止一个“高度调优”过的服务器不停地崩溃,停止服务或者运行缓慢,结果都是因为错误的配置导致的。我们将花一点时间来解释为什么会发生这种情况,并且告诉大家什么是不该做的。\n那么什么是该做的呢?确保基本的配置是正确的,例如InnoDB的Buffer Pool和日志文件缓存大小,如果想防止出问题(提醒一下,这样做通常不能提升性能——它们只能避免问题),就设置一个比较安全和稳健的值,剩下的配置就不用管了。如果碰到了问题,可以使用第3章提到的技巧小心地进行诊断。如果问题是由于服务器的某部分导致的,而这恰好可以通过某个配置项解决,那么需要做的就是更改配置。\n有时候,在某些特定的场景下,也有可能设置某些特殊的配置项会有显著的性能提升。但无论如何,这些特殊的配置项不应该成为服务器基本配置文件的一部分。只有当发现特定的性能问题才应该设置它们。这就是为什么我们不建议通过寻找有问题的地方修改配置项的原因。如果有些地方确实需要提升,也需要在查询响应时间上有所体现。最好是从查询语句和响应时间入手来开始分析问题,而不是通过配置项。这可以节省大量的时间,避免很多的问题。\n另一个节省时间和避免麻烦的好办法是使用默认配置,除非是明确地知道默认值会有问题。很多人都是在默认配置下运行的,这种情况非常普遍。这使得默认配置是经过最多实际测试的。对配置项做一些不必要的修改可能会遇到一些意料之外的bug。\n8.1 MySQL配置的工作原理 # 在讨论如何配置MySQL之前,我们先来解释一下MySQL的配置机制。MySQL对配置要求非常宽松,但是下面这些建议可能会为你节省大量的工作和时间。\n首先应该知道的是MySQL从哪里获得配置信息:命令行参数和配置文件。在类UNIX系统中,配置文件的位置一般在*/etc/my.cnf或者/etc/mysql/my.cnf*。如果使用操作系统的启动脚本,这通常是唯一指定配置设置的地方。如果手动启动MySQL,例如在测试安装时,也可以在命令行指定设置。实际上,服务器会读取配置文件的内容,删除所有注释和换行,然后和命令行选项一起处理。\n关于术语的说明:因为很多MySQL命令行选项跟服务器变量相同,我们有时把选项和变量替换使用。大部分变量和它们对应的命令行选项名称一样,但是有一些例外。例如,\u0026ndash;memlock选项设置了locked_in_memory变量。\n任何打算长期使用的设置都应该写到全局配置文件,而不是在命令行特别指定。否则,如果偶然在启动时忘了设置就会有风险。把所有的配置文件放在同一个地方以方便检查也是个好办法。\n一定要清楚地知道服务器配置文件的位置!我们见过有些人尝试修改配置文件但是不生效,因为他们修改的并不是服务器读取的文件,例如Debian下,/etc/mysql/my.cnf才是MySQL读取的配置文件,而不是*/etc/my.cnf*。有时候好几个地方都有配置文件,也许是因为之前的系统管理员也没搞清楚情况(因此在各个可能的位置都放了一份)。如果不知道当前使用的配置文件路径,可以尝试下面的操作:\n** $ which mysqld** /usr/sbin/mysqld ** $ /usr/sbin/mysqld --verbose --help | grep -A 1 'Default options'** Default options are read from the following files in the given order: /etc/mysql/my.cnf ~/.my.cnf /usr/etc/my.cnf 对于服务器上只有一个MySQL实例的典型安装,这个命令很有用。也可以设计更复杂的配置,但是没有标准的方法告诉你怎么来做。MySQL发行版包含了一个现在废弃了的程序,叫mysqlmanager,可以在一个有多个独立部分的配置文件上运行多个实例。(现在已经被一样古老的mysqld_multi脚本替代。)然而许多操作系统发行版本在启动脚本中并不包含或使用这个程序。实际上,很多系统甚至没有使用MySQL提供的启动脚本。\n配置文件通常分成多个部分,每个部分的开头是一个用方括号括起来的分段名称。MySQL程序通常读取跟它同名的分段部分,许多客户端程序还会读取client部分,这是一个存放公用设置的地方。服务器通常读取mysqld这一段。一定要确认配置项放在了文件正确的分段中,否则配置是不会生效的。\n8.1.1 语法、作用域和动态性 # 配置项设置都使用小写,单词之间用下画线或横线隔开。下面的例子是等价的,并且可能在命令行和配置文件中都看到这两种格式:\n/usr/sbin/mysqld --auto-increment-offset=5 /usr/sbin/mysqld --auto-increment-offset=5 我们建议使用一种固定的风格。这样在配置文件中搜索配置项时会容易得多。\n配置项可以有多个作用域。有些设置是服务器级的(全局作用域),有些对每个连接是不同的(会话作用域),剩下的一些是对象级的。许多会话级变量跟全局变量相等,可以认为是默认值。如果改变会话级变量,它只影响改动的当前连接,当连接关闭时所有参数变更都会失效。下面有一些例子,你应该清楚这些不同类型的行为:\nquery_cache_sizey变量是全局的。 sort_buffer_sizey变量默认是全局相同的,但是每个线程里也可以设置。 join_buffer_sizey变量也有全局默认值且每个线程是可以设置的,但是若一个查询中关联多张表,可以为每个关联分配一个关联缓冲(join buffer),所以每个查询可能有多个关联缓冲。 另外,除了在配置文件中设置变量,有很多变量(但不是所有)也可以在服务器运行时修改。MySQL把这些归为动态配置变量。下面的语句展示了动态改变sort_buffer_size的会话值和全局值的不同方式:\nSET sort_buffer_size = \u0026lt;* value* \u0026gt;; SET GLOBAL sort_buffer_size = \u0026lt;* value* \u0026gt;; SET @@sort_buffer_size := \u0026lt;* value* \u0026gt;; SET @@session.sort_buffer_size := \u0026lt;* value* \u0026gt;; SET @@global.sort_buffer_size := \u0026lt;* value* \u0026gt;; 如果动态地设置变量,要注意MySQL关闭时可能丢失这些设置。如果想保持这些设置,还是需要修改配置文件。\n如果在服务器运行时修改了变量的全局值,这个值对当前会话和其他任何已经存在的会话都不起效果,这是因为会话的变量值是在连接创建时从全局值初始化来的。在每次变更之后,应该检查SHOW GLOBAL VARIABLES的输出,确认已经按照期望变更了。\n有些变量使用了不同的单位,所以必须知道每个变量的正确单位。例如,table_cache变量指定了表可以被缓存的数量,而不是表可以被缓存的字节数。key_buffer_size则是以字节为单位,还有一些其他变量指定的是页的数量或者其他单位,例如百分比。\n许多变量可以通过后缀指定单位,例如1M表示一百万字节。然而,这只能在配置文件或者作为命令行参数时有效。当使用SQL的SET命令时,必须使用数字值1048576,或者1024*1024这样的表达式。但在配置文件中不能使用表达式。\n有个特殊的值可以通过SET命令赋值给变量:DEFAULT。把这个值赋给会话级变量可以把变量改为使用全局值,把它赋值给全局变量可以设置这个变量为编译内置的默认值(不是在配置文件中指定的值)。当需要重置会话级变量的值回到连接刚打开的时候,这是很有用的。建议不要对全局变量这么用,因为可能它做的事不是你希望的,它不会把值设置到服务器刚启动时候的那个状态。\n8.1.2 设置变量的副作用 # 动态设置变量可能导致意外的副作用,例如从缓冲中刷新脏块。务必小心那些可以在线更改的设置,因为它们可能导致数据库做大量的工作。\n有时可以通过名称推断一个变量的作用。例如,max_heap_table_size的作用就像听起来那样:它指定隐式内存临时表最大允许的大小。然而,命名约定并不完全一样,所以不能总是通过看名称来猜测一个变量有什么效果。\n让我们来看一些常用的变量和动态修改它们的效果。\nkey_buffer_size\n设置这个变量可以一次性为键缓冲区(key buffer,也叫键缓存key cache)分配所有指定的空间。然而,操作系统不会真的立刻分配内存,而是到使用时才真正分配。例如设置键缓冲的大小为1GB,并不意味着服务器立刻分配1GB的内存。(我们下一章会讨论如何查看服务器的内存使用。)\nMySQL允许创建多个键缓存,这一章后面我们会探讨这个问题。如果把非默认键缓存的这个变量设置为0,MySQL将丢弃缓存在该键缓存中的索引,转而使用默认键缓存,并且当不再有任何引用时会删除该键缓存。为一个不存在的键缓存设置这个变量,将会创建新的键缓存。对一个已经存在的键缓存设置非零值,会导致刷新该键缓存的内容。这会阻塞所有尝试访问该键缓存的操作,直到刷新操作完成。\ntable_cache_size\n设置这个变量不会立即生效——会延迟到下次有线程打开表才有效果。当有线程打开表时,MySQL会检查这个变量的值。如果值大于缓存中的表的数量,线程可以把最新打开的表放入缓存;如果值比缓存中的表数小,MySQL将从缓存中删除不常使用的表。\nthread_cache_size\n设置这个变量不会立即生效——将在下次有连接被关闭时产生效果。当有连接被关闭时,MySQL检查缓存中是否还有空间来缓存线程。如果有空间,则缓存该线程以备下次连接重用;如果没有空间,它将销毁该线程而不再缓存。在这个场景中,缓存中的线程数,以及线程缓存使用的内存,并不会立刻减少;只有在新的连接删除缓存中的一个线程并使用后才会减少。(MySQL只在关闭连接时才在缓存中增加线程,只在创建新连接时才从缓存中删除线程。)\nquery_cache_size\nMySQL在启动的时候,一次性分配并且初始化这块内存。如果修改这个变量(即使设置为与当前一样的值),MySQL会立刻删除所有缓存的查询,重新分配这片缓存到指定大小,并且重新初始化内存。这可能花费较长的时间,在完成初始化之前服务器都无法提供服务,因为MySQL是逐个清理缓存的查询,不是一次性全部删掉。\nread_buffer_size\nMySQL只会在有查询需要使用时才会为该缓存分配内存,并且会一次性分配该参数指定大小的全部内存。\nread_rnd_buffer_size\nMySQL只会在有查询需要使用时才会为该缓存分配内存,并且只会分配需要的内存大小而不是全部指定的大小。(max_read_rnd_buffer_size这个名字更能表达这个变量实际的含义。)\nsort_buffer_size\nMySQL只会在有查询需要做排序操作时才会为该缓存分配内存。然后,一旦需要排序,MySQL就会立刻分配该参数指定大小的全部内存,而不管该排序是否需要这么大的内存。\n我们在其他地方也对这些参数做过更多细节的说明,这里不是一个完整的列表。这里的目的只是简单地告诉大家,当修改一些常见的变量时,会有哪些期望的行为发生。\n对于连接级别的设置,不要轻易地在全局级别增加它们的值,除非确认这样做是对的。有一些缓存会一次性分配指定大小的全部内存,而不管实际上是否需要这么大,所以一个很大的全局设置可能导致浪费大量内存。更好的办法是,当查询需要时在连接级别单独调大这些值。\n最常见的例子是sort_buffer_size,该参数控制排序操作的缓存大小,应该在配置文件里把它配置得小一些,然后在某些查询需要排序时,再在连接中把它调大。在分配内存后, MySQL会执行一些初始化的工作。\n另外,即使是非常小的排序操作,排序缓存也会分配全部大小的内存,所以如果把参数设置得超过平均排序需求太多,将会浪费很多内存,增加额外的内存分配开销。许多读者认为内存分配是一个很简单的操作,听到内存分配的代价可能会很吃惊。不需要深入很多技术细节就可以讲清楚为什么内存分配也是昂贵的操作,内存分配包括了地址空间的分配,这相对来说是比较昂贵的。特别在Linux上,内存分配根据大小使用多种开销不同的策略。\n总的来说,设置很大的排序缓存代价可能非常高,所以除非确定必须要这么大,否则不要增加排序缓存的大小。\n如果查询必须使用一个更大的排序缓存才能比较好地执行,可以在查询执行前增加sort_buffer_size的值,执行完成后恢复为DEFAULT。\n下面是一个实际的例子:\nSET @@session.sort_buffer_size := \u0026gt;* value* \u0026gt;; -- Execute the query... SET @@session.sort_buffer_size := DEFAULT; 可以将类似的代码封装在函数中以方便使用。其他可以设置的单个连接级别的变量有read_buffer_size、read_rnd_buffer_size、tmp_table_size、以及myisam_sort_buffer_size(在修复表的操作中会用到)。\n如果有需要也可以保存并还原原来的自定义值,可以像下面这样做:\nSET @saved_\u0026lt;* unique_variable_name* \u0026gt; := @@session.sort_buffer_size; SET @@session.sort_buffer_size := \u0026lt;value\u0026gt;; -- Execute the query... SET @@session.sort_buffer_size := @saved_\u0026lt;* unique_variable_name* \u0026gt;; 排序缓冲大小是关注的众多“调优”中一个设置。一些人似乎认为越大越好,我们甚至见过把这个变量设为1GB的。这可能导致服务器尝试分配太多内存而崩溃,或者为查询初始化排序缓存时消耗大量的CPU,这不是什么出乎意料的事。从MySQL的Bug 37359可以看到有关于这个问题的细节。\n不要把排序缓存大小放在太重要的位置。查询真的需要128MB的内存来排序10行数据然后返回给客户端吗?思考一下查询语句是什么类型的排序、多大的排序,首先考虑通过索引和SQL写法来避免排序(看第5章和第6章),这比调优排序缓存要快得多。并且应该仔细分析查询开销,看看排序是否是无论如何都需要重点关注的部分。第3章有一个例子,一个查询执行了一个排序,但是没有花很多排序时间。\n8.1.3 入门 # 设置变量时请小心,并不是值越大就越好,而且如果设置的值太高,可能更容易导致问题:可能会由于内存不足导致服务器内存交换,或者超过地址空间。(1)\n应该始终通过监控来确认生产环境中变量的修改,是提高还是降低了服务器的整体性能。基准测试是不够的,因为基准测试不是真实的工作负载。如果不对服务器的性能进行实际的测量,可能性能降低了都没有发现。我们见过很多情况,有人修改了服务器的配置,并认为它提高了性能,其实服务器的整体性能恶化了,因为在一个星期或一天的不同时间,工作负载是不一样的。\n如果你经常做笔记,在配置文件中写好注释,可能会节省自己(和同事)大量的工作。一个更好的主意是把配置文件置于版本控制之下。无论如何,这是一个很好的做法,因为它让你有机会撤销变更。要降低管理很多配置文件的复杂性,简单地创建一个从配置文件到中央版本控制库的符号链接。\n在开始改变配置之前,应该优化查询和schema,至少先做明显要做的事情,例如添加索引。如果先深入调整配置,然后修改了查询语句和schema,也许需要回头再次评估配置。请记住,除非硬件、工作负载和数据是完全静态的,否则都可能需要重新检查配置文件。实际上,大部分人的服务器甚至在一天中都没有稳定的工作负载——意味着对上午来说“完美”的配置,下午就不对了!显然,追求传说中的“完美”配置是完全不切实际的。因此,没有必要榨干服务器的每一点性能,实际上,这种调优的时间投入产出是非常小的。我们建议在“足够好”的时候就可以停下了,除非有理由相信停下会导致放弃重大的性能提升的机会。\n8.1.4 通过基准测试迭代优化 # 你也许期望(或者相信自己会期望)通过建立一套基准测试方案,然后不断迭代地验证对配置项的修改来找到最佳配置方案。通常我们都不建议大家这么做。这需要做非常多的工作和研究,并且大部分情况下潜在的收益是非常小的,这可能导致巨大的时间浪费。而把时间花在检查备份、监控执行计划的变动之类的事情上,可能会更有意义。\n即使更改一个选项后基准测试出现了提升,也无法知道长期运行后这个变更会有什么副作用。基准测试也不能衡量一切,或者没有运行足够长的时间来检测系统的长期稳定性,修改就可能导致如周期性性能抖动或者周期性的慢查询等问题。这是很难察觉到的。\n有的时候我们运行某些组合的基准测试,来仔细验证或压测服务器的某些特定部分,使得我们可以更好地理解这些行为。一个很好的例子是,我们使用了很多年的一些基准测试,用来理解InnoDB的刷新行为,来寻找更好的刷新算法,以适应多种工作负载和多种硬件类型。我们经常测试各种各样的设置,来理解它们的影响以及怎么优化它们。但这不是一件简单的事——这可能会花费很多天甚至很多个星期——而且对大部分人来说这没有收益,因为服务器特定部分的认识局限往往会掩盖了其他问题。例如,有时我们发现,特定的设置项组合,在特定的边缘场景可能有更好的性能,但是在实际生产环境这些配置项并不真的合适,例如,浪费大量的内存,或者优化了吞吐量却忽略了崩溃恢复的影响。\n如果必须这样做,我们建议在开始配置服务器之前,开发一个定制的基准测试包。你必须做这些事情来包含所有可能的工作负载,甚至包含一些边缘的场景,例如很庞大很复杂的查询语句。在实际的数据上重放工作负载通常是一个好办法。如果已经定位到了一个特定的问题点——例如一个查询语句运行很慢——也可以尝试专门优化这个点,但是可能不知道这会对其他查询有什么负面影响。\n最好的办法是一次改变一个或两个变量,每次一点点,每次更改后运行基准测试,确保运行足够长的时间来确认性能是否稳定。有时结果可能会令你感到惊讶,可能把一个变量调大了一点,观察到性能提升,然后再调大一点,却发现性能大幅下降。如果变更后性能有隐患,可能是某些资源用得太多了,例如,为缓冲区分配太多内存、频繁地申请和释放内存。另外,可能导致MySQL和操作系统或硬件之间的不匹配。例如,我们发现sort_buffer_size的最佳值可能会被CPU缓存的工作方式影响,还有read_buffer_size需要服务器的预读和I/O子系统的配置相匹配。更大并不总是更好,还可能更糟糕。一些变量也依赖于一些其他的东西,这需要通过经验和对系统架构的理解来学习。\n什么情况下进行基准测试是好的建议\n对于前面提到不建议大多数人执行基准测试的情况也有例外的时候。我们有时会建议人们跑一些迭代基准测试,尽管通常跟“服务器调优”有不同的内容。这里有一些例子:\n如果有一笔大的投资,如购买大量新的服务器,可以运行一下基准测试以了解硬件需求。(这里的上下文指是容量规划,不是服务器调优),我们特别喜欢对不同大小的InnoDB缓冲池进行基准测试,这有助于我们制定一个“内存曲线”,以展示真正需要多少内存,不同的内存容量如何影响存储系统的要求。 如果想了解InnoDB从崩溃中恢复需要多久时间,可以反复设置一个备库,故意让它崩溃,然后“测试”InnoDB在重启中需要花费多久时间来做恢复。这里的背景是做高可用性的规划。 以读为主的应用程序,在慢查询日志中捕捉所有的查询(或者用pt-query-digest分析TCP流量)是个很好的主意,在服务器完全打开慢查询日志记录时,使用pt-log-player重放所有的慢查询,然后用pt-query-digest来分析输出报告。这可以观察在不同硬件、软件和服务器设置下,查询语句运行的情况。例如,我们曾经帮助客户评估迁移到更多的内存但硬盘更慢的服务器上的性能变化。大多数查询变得更快,但一些分析型查询语句变慢,因为它们是I/O密集型的。这个测试的上下文背景就是不同工作负载的比较。 8.2 什么不该做 # 在我们开始配置服务器之前,希望鼓励大家去避免一些我们已经发现有风险或有害的做法。警告:本节有些观点可能会让有些人不舒服!\n首先,不要根据一些“比率”来调优。一个经典的按“比率”调优的经验法则是,键缓存的命中率应该高于某个百分比,如果命中率过低,则应该增加缓存的大小。这是非常错误的意见。无论别人怎么跟你说,缓存命中率跟缓存是否过大或过小没有关系。首先,命中率取决于工作负载——某些工作负载就是无法缓存的,不管缓存有多大——其次,缓存命中没有什么意义,我们将在后面解释原因。有时当缓存太小时,命中率比较低,增加缓存的大小确实可以提高命中率。然而,这只是个偶然情况,并不表示这与性能或适当的缓存大小有任何关系。\n这种相关性,有时候看起来似乎真正的问题是,人们开始相信它们将永远是真的。Oracle DBA很多年前就放弃了基于命中率的调优,我们希望MySQL DBA也能跟着走(2)。我们更强烈地希望人们不要去写“调优脚本”,把这些危险的做法编写到一起,并教导成千上万的人这么做。这引出了我们第二个不该做的建议:不要使用调优脚本!有几个这样的可以在互联网上找到的脚本非常受欢迎,最好是忽略它们(3)。\n我们还建议避免调(tuning)这个词,我们在前面几段中使用这个词是有点随意的。我们更喜欢使用“配置(Configuration)”或“优化(Optimize)”来代替(只要这是你真正在做的,见第3章)。“调优”这个词,容易让人联想到一个缺乏纪律的新手对服务器进行微调,并观察发生了什么。我们建议上一节的练习最好留给那些正在研究服务器内核的人。“调优”服务器可能浪费大量的时间。\n另外说一句,在互联网搜索如何配置并不总是一个好主意。在博客、论坛等地方(4)都可能找到很多不好的建议。虽然许多专家在网上贡献了他们了解的东西,但并不总是能容易地分辨出哪些是正确的建议。我们也不能给出中肯的建议在哪里能找到真正的专家(5)。但我们可以说,可信的、声誉好的MySQL服务供应商一般比简单的互联网搜索更安全,因为有好的客户才可能做出正确的事情。然而,即使是他们的意见,没有经过测试和理解就使用,也可能有危险,因为它可能对某种解决方案有了思维定势,跟你的思维不一样,可能用了一种你无法理解的方法。\n最后,不要相信很流行的内存消耗公式——是的,就是MySQL崩溃时自身输出的那个内存消耗公式(我们这里就不再重复了)。这个公式已经很古老了,它并不可靠,甚至也不是一个理解MySQL在最差情况下需要使用多少内存的有用的办法。在互联网上可能还会看到这个公式的很多变种。即使在原公式上增加了更多原来没有考虑到的因素,还是有同样的缺陷。事实上不可能非常准确地把握MySQL内存消耗的上限。MySQL不是一个完全严格控制内存分配的数据库服务器。这个结论可以非常简单地证明,登录到服务器,并执行一些大量消耗内存的查询:\nmysql\u0026gt; ** SET @crash_me_1 := REPEAT('a', @@max_allowed_packet);** mysql\u0026gt; ** SET @crash_me_2 := REPEAT('a', @@max_allowed_packet);** # ... run a lot of these ... mysql\u0026gt; ** SET @crash_me_1000000 := REPEAT('a', @@max_allowed_packet);** 在一个循环中运行这些语句,每次都创建新的变量,最后服务器内存必然耗尽,然后系统崩溃!运行这个测试不需要任何特殊权限。\n在本节我们试图说明的观点是,有时候我们在那些认为我们很傲慢的人面前变得不受欢迎,他们认为我们正在试图诋毁他人,把自己塑造成唯一的权威,或者觉得我们是在试图推销我们的服务。我们的目的不是利己。我们只是看到非常多很糟糕的建议,如果没有足够的经验,这看上去似乎还是合理的。另外我们重复这么多次说明这些糟糕的建议,因为我们认为揭穿一些神话是很重要的,并提醒我们的读者要小心他们信任的那些人的专业水准。我们还是尽量避免在这里继续说这些不好听的吧。\n8.3 创建MySQL配置文件 # 正如我们在本章开头提到的,没有一个适合所有场景的“最佳配置文件”,比方说,对一台有16 GB内存和12块硬盘的4路CPU服务器,不会有一个相应的“最佳配置文件”。应该开发自己的配置,因为即使是一个好的起点,也依赖于具体是如何使用服务器的。\nMySQL编译的默认设置并不都是靠谱的,虽然其中大部分都比较合适。它们被设计成不要使用大量的资源,因为MySQL的使用目标是非常灵活的,它并没有假设自己是服务器上唯一的应用。默认情况下,MySQL只是使用恰好足够的资源来启动,运行一些少量数据的简单查询。如果有超过几MB的数据,就一定会需要自己定制MySQL配置。\n你可能会先从一个包含在MySQL发行版本中的示例配置文件开始,但这些示例配置有自己的问题。例如,它们有很多注释掉的设置,可能会诱使你认为应该选择一个值,并取消注释(这有点让人联想到Apache配置文件)。同时它们有很多乏味的注释,只是为了解释选项的含义,但这些解释并不总是通顺、完整甚至正确的,有些选项甚至并不适用于流行的操作系统!最后,这些示例相对于现代的硬件和工作负载,总是过时的。\nMySQL专家们关于如何解决这些问题多年来进行了许多对话,但这些问题依然存在。下面是我们的建议:不要使用这些文件作为(创建配置文件的)起点,也不要使用操作系统的安装包自带的配置文件。最好是从头开始。\n这就是本章要做的事情。实际上MySQL的可配置性太强也可以说是个弱点,看起来好像需要花很多时间在配置上,其实大多数配置的默认值已经是最佳配置了,所以最好不要改动太多配置,甚至可以忘记某些配置的存在。这就是为什么我们为本书创建了一个完整的最小的示例配置文件,可以作为自己的服务器配置文件的一个好的起点。有一些配置项是必选的,我们将在本章稍后解释。下面就是这个基础配置文件:\n[mysqld] # GENERAL datadir = /var/lib/mysql socket = /var/lib/mysql/mysql.sock pid_file = /var/lib/mysql/mysql.pid user = mysql port = 3306 storage_engine = InnoDBdefault_storage_engine # INNODB innodb_buffer_pool_size = \u0026lt;value\u0026gt; innodb_log_file_size = \u0026lt;value\u0026gt; innodb_file_per_table = 1 innodb_flush_method = O_DIRECT # MyISAM key_buffer_size = \u0026lt;value\u0026gt; # LOGGING log_error = /var/lib/mysql/mysql-error.log log_slow_queries = /var/lib/mysql/mysql-slow.logslow_query_log # OTHER tmp_table_size = 32M max_heap_table_size = 32M query_cache_type = 0 query_cache_size = 0 max_connections = \u0026lt;value\u0026gt; thread_cache_size = \u0026lt;value\u0026gt;thread_cache table_cache_size = \u0026lt;value\u0026gt;table_cache open_files_limit = 65535 [client] socket = /var/lib/mysql/mysql.sock port = 3306 和你见过的其他配置文件(6)相比,这里的配置选项可能太少了。但实际上已经超过了许多人的需要。有一些其他类型的配置选项可能也会用到,比如二进制日志,我们会在本章后面以及其他章节覆盖这些内容。\n配置文件的第一件事是设置数据的位置。我们选择了 /var/lib/mysql 路径存储数据,因为在许多类UNIX系统中这是最常见的位置。选择另外的位置也没有错,可以根据需要决定。我们把PID文件也放到相同的位置,但许多操作系统希望放在 /var/run 目录下,这也可以。只需要简单地为这些选项配置一下就可以了。顺便说一下,不要把Socket文件和PID文件放到MySQL编译默认的位置,在不同的MySQL版本里这可能会导致一些错误。最好明确地设置这些文件的位置。(这么说并不是建议选择不同的位置,只是建议确保在 my.cnf 文件中明确指定了这些文件的存放地点,这样升级MySQL版本时这些路径就不会改变。)\n这里还指定了操作系统必须用mysql用户来运行mysqld进程。需要确保这个账户存在,并且拥有操作数据目录的权限。端口设置为默认的3306,但有时可能需要修改一下。\n我们选择InnoDB作为默认的存储引擎,这个值得向大家解释一下。InnoDB在大多数情况下是最好的选择,但并不总是如此。例如,一些第三方的软件,可能假设默认存储引擎是MyISAM,所以创建表时没有指定存储引擎。这可能会导致软件故障,例如,这些应用可能会假定可以创建全文索引(7)。默认存储引擎也会在显式创建临时表时用到,可能会引起服务器做一些意料之外的工作。如果希望持久化的表使用InnoDB,但所有临时表使用MyISAM,那应该确保在CREATE TABLE语句中明确指定了存储引擎。\n一般情况下,如果决定使用一个存储引擎作为默认引擎,最好显式地进行配置。许多用户认为只使用了某个特定的存储引擎,但后来发现正在用的其实是另一个引擎,就是因为默认配置的是另外一个引擎。\n接下来我们将阐述InnoDB的基础配置。InnoDB在大多数情况下如果要运行得很好,配置大小合适的缓冲池(Buffer Pool)和日志文件(Log File)是必须的。默认值都太小了。其他所有的InnoDB设置都是可选的,尽管示例配置中因为可管理性和灵活性的原因启用了innodb_file_per_table。设置InnoDB日志文件的大小和innodb_fush_method是本章后面要讨论的主题,其中innodb_fush_method是类UNIX系统特有的选项。\n有一个流行的经验法则说,应该把缓冲池大小设置为服务器内存的约75%~80%。这是另一个偶然有效的“比率”,但并不总是正确的。有一个更好的办法来设置缓冲池大小,大致如下:\n从服务器内存总量开始。 减去操作系统的内存占用,如果MySQL不是唯一运行在这个服务器上的程序,还要扣掉其他程序可能占用的内存。 减去一些MySQL自身需要的内存,例如为每个查询操作分配的一些缓冲。 减去足够让操作系统缓存InnoDB日志文件的内存,至少是足够缓存最近经常访问的部分。(此建议适用于标准的MySQL,Percona Server可以配置日志文件用O_DIRECT方式打开,绕过操作系统缓存),留一些内存至少可以缓存二进制日志的最后一部分也是个很好的选择,尤其是如果复制产生了延迟,备库就可能读取主库上旧的二进制日志文件,给主库的内存造成一些压力。 减去其他配置的MySQL缓冲和缓存需要的内存,例如MyISAM的键缓存(Key Cache),或者查询缓存(Query Cache)。 除以105%,这差不多接近InnoDB管理缓冲池增加的自身管理开销。 把结果四舍五入,向下取一个合理的数值。向下舍入不会太影响结果,但是如果分配太多可能就会是件很糟糕的事情。 我们对这里有些内存总量相关的问题有一点感到厌倦——什么是“操作系统的一个位(Bit)”?那是变化的,在本章和这本书的其余部分,我们将对此做一定深度的讨论。你必须了解你的系统,并且估算它需要多少内存才能良好地运转。这是为什么一个适合所有场景的配置文件是不存在的。经验,以及有时一点数学知识将给你提供指导。\n下面是一个例子,假设有一个192GB内存的服务器,只运行MySQL并且只使用InnoDB,没有查询缓存(Query Cache),也没有非常多的连接连到服务器。如果日志文件总大小是4 GB,可能会像这样处理:“我认为所有内存的5%或者2GB,取较大的那个,应该足够操作系统和MySQL的其他内存需求,为日志文件减去4 GB,剩下的都给InnoDB用”。结果差不多是177 GB,但是配置得稍微低一点可能是个好主意。比如可以先配置缓存池为168GB。在服务器实际运行中若发现还有不少内存没有分配使用,在出于某些目的有机会重启时,可以再适当调大缓冲池的大小。\n如果有大量MyISAM表需要缓存它们的索引,结果自然会有很大不同。在Windows下这也是完全不同的,大多数的MySQL版本在Windows下使用大内存都有问题(虽然在MySQL 5.5中有所改进),或者是出于某种原因不使用O_DIRECT也会有不同的结果。\n正如你所看到的,从一开始就获得精确的设置并不是关键。从一个比默认值大一点但不是大得很离谱的安全值开始是比较好的,在服务器运行一段时间后,可以看看服务器真实情况需要使用多少内存。这些东西是很难预测,因为MySQL的内存利用率并不总是可以预测的:它可能依赖很多的因素,例如查询的复杂性和并发性。如果是简单的工作负载,MySQL的内存需求是非常小的——大约256 KB的每个连接。但是,使用临时表、排序、存储过程等的复杂查询,可能使用更多的内存。\n这就是我们选择一个非常安全的起点的原因。可以看到,即使是保守的InnoDB的缓冲池设置,实际上也是服务器内存的87.5%——超过75%,这就是为什么我们说简单地按比例是不正确的方法的原因。\n我们建议,当配置内存缓冲区的时候,宁可谨慎,而不是把它们配置得过大。如果把缓冲池配置得比它可以设的值少了20%,很可能只会对性能产生小的影响,也许就只影响几个百分点。如果设置得大了20%,则可能会造成更严重的问题:内存交换、磁盘抖动,甚至内存耗尽和硬件死机。\n这份InnoDB配置的例子说明了我们配置服务器的首选途径:了解它内部做了什么,以及参数之间如何相互影响,然后再决定。\n时间改变一切\n精确地配置MySQL的内存缓冲区随着时间的推移变得不那么重要。当一个强大的服务器只有4 GB内存的时候,我们努力地平衡其资源使它可以运行1000个连接。这通常需要我们为MySQL保留1GB的内存,这是服务器总内存的四分之一,而且会极大地影响我们设置缓冲池的大小。Time Changes Everything The need to configure MySQL\u0026rsquo;s memory buffers precisely has become less important over time. When a powerful server had 4 GB of memory, we worked hard to balance its resources so it could run a thousand connections. This typically required us to re-\n如今类似的服务器有144 GB的内存,但是在大多数应用中我们通常看到的连接数是相同的,每个连接的缓冲区并没有真的改变太多。因此,我们可能会慷慨地为MySQL保留4GB的内存,这只是九牛一毛而已。它不会对我们的缓冲池的大小设置产生太大影响。\n示例配置文件中的其他一些设置,大多是不言自明的,其中很多配置都是是与否的判断。在本章的其余部分,我们将探讨其中的几个。可以看到,我们已经启用日志记录、禁用了查询缓存,等等。在这一章的后面,我们还将讨论一些安全性和完整性的设置,它可以使服务器更强健,并对防止数据损坏和其他问题非常有帮助。我们并没有在这里展示这些设置。\n这里需要解释的一个选项是open_files_limit。在典型的Linux系统上我们把它设置得尽可能大。现代操作系统中打开文件句柄开销都很小。如果这个参数不够大,将会碰到经典的24号错误,“打开的文件太多(too many open files)”。\n跳过其他的直接看到末尾,在配置文件的最后一节,是为了如mysql和mysqladmin之类的客户端程序做的设置,可以简化这些程序连接到服务器的步骤。应该为客户端程序设置那些匹配服务器的配置项。\n8.3.1 检查MySQL服务器状态变量 # 有时可以使用SHOW GLOBAL STATUS的输出,作为配置的输入,以更好地通过工作负载来自定义配置。为了达到最佳效果,既要看绝对值,又要看值是如何随时间而改变的,最好为高峰和非高峰时间的值做几个快照。可以使用以下命令每隔60秒来查看状态变量的增量变化:\n** $ mysqladmin extended-status -ri60** 在解释配置设置的时候,我们经常会提到随着时间的推移各种状态变量的变化。所以通常可以预料到需要分析如刚才那个命令的输出的情况。有一些有用的工具,如Percona Toolkit中的pt-mext或PT-mysql-summary,可以简洁地显示状态计数器的变化,不用直接看那些SHOW命令的输出。\n好吧,前面的内容算是预热,接下来我们将进入一些服务器内核的东西,并将相关的配置建议穿插在其中。然后再回头来看示例配置文件,就会有足够的背景知识来选择适当的配置选项的值了。\n8.4 配置内存使用 # 配置MySQL正确地使用内存量对高性能是至关重要的。肯定要根据需求来定制内存使用。可以认为MySQL的内存消耗分为两类:可以控制的内存和不可以控制的内存。无法控制MySQL服务器运行、解析查询,以及其内部管理所消耗的内存,但是为特定目的而使用多少内存则有很多参数可以控制(8)。用好可以控制的内存并不难,但需要对配置的含义非常清楚。\n像前面展示的,按下面的步骤来配置内存:\n确定可以使用的内存上限。 确定每个连接MySQL需要使用多少内存,例如排序缓冲和临时表。 确定操作系统需要多少内存才够用。包括同一台机器上其他程序使用的内存,如定时任务。 把剩下的内存全部给MySQL的缓存,例如InnoDB的缓冲池,这样做很有意义。 我们将在后面的章节详细说明这些步骤,然后我们对各种MySQL的缓存需求做更细节的分析。\n8.4.1 MySQL可以使用多少内存 # 在任何给定的操作系统上,MySQL都有允许使用的内存上限。基本出发点是机器上安装了多少物理内存。如果服务器就没装这么多内存,MySQL肯定也不能用这么多内存。\n还需要考虑操作系统或架构的限制,如32位操作系统对一个给定的进程可以处理多少内存是有限制的。因为MySQL是单进程多线程的运行模式,它整体可用的内存量也许会受操作系统位数的严格限制——例如,32位Linux内核通常限制任意进程可以使用的内存量在2.5GB~2.7GB范围内。运行时地址空间溢出是非常危险的,可能导致MySQL崩溃。现在这种情况非常难得一见,但以前这种情况很常见。\n有许多其他的操作系统——特殊的参数和古怪的事情必须考虑到,例如不只是每个进程有限制,而且堆栈大小和其他设置也有限制。系统的glibc库也可能限制每次分配的内存大小。例如,若glibc库支持单次分配的最大大小是2GB,那么可能就无法设置innodb_buffer_pool的值大于2 GB。\n即使在64位服务器上,依然有一些限制。例如,许多我们讨论的缓冲区,如键缓存(Key Buffer),在5.0以及更早的MySQL版本上,有4GB的限制,即使在64位服务器上也是如此。在MySQL 5.1中,部分限制被取消了,在MySQL手册中记载了每个变量的最大值,有需要可以查阅。\n8.4.2 每个连接需要的内存 # MySQL保持一个连接(线程)只需要少量的内存。它还要求一个基本量的内存来执行任何给定查询。你需要为高峰时期执行的大量查询预留好足够的内存。否则,查询执行可能因为缺乏内存而导致执行效率不佳或执行失败。\n知道在高峰时期MySQL将消耗多少内存是非常有用的,但一些习惯性用法可能意外地消耗大量内存,这使得对内存使用量的预测变得比较困难。绑定变量就是一个例子,因为可以一次打开很多绑定变量语句。另一个例子是InnoDB数据字典(关于这个后面我们再细说)。\n当预测内存峰值消耗时,没必要假设一个最坏情况。例如,配置MySQL允许最多100个连接,在理论上可能出现100个连接同时在运行很大的查询,但在现实情况中,这可能不会发生。例如,设置myisam_sort_buffer_size为256MB,最差情况下至少需要使用25 GB内存,但这种最差情况在实际中几乎是不可能发生的。使用了许多大的临时表或复杂存储过程的查询,通常是导致高内存消耗最可能的原因。\n相对于计算最坏情况下的开销,更好的办法是观察服务器在真实的工作压力下使用了多少内存,可以在进程的虚拟内存大小那里看到。在许多类UNIX系统里,可以观察top命令中的VIRT列,或者ps命令中的VSZ列的值。下一章有更多关于如何监视内存使用情况的信息。\n8.4.3 为操作系统保留内存 # 跟查询一样,操作系统也需要保留足够的内存给它工作。如果没有虚拟内存正在交换(Paging)到磁盘,就是表明操作系统内存足够的最佳迹象。(关于这个话题,请参阅下一章。)\n至少应该为操作系统保留1GB~2GB的内存——如果机器内存更多就再多预留一些。我们建议2 GB或总内存的5%作为基准,以较大者为准。为了安全再额外增加一些预留,并且如果机器上还在运行内存密集型任务(如备份),则可以再多增加一些预留。不要为操作系统的缓存增加任何内存,因为它们可能会变得非常大。操作系统通常会利用所有剩下的内存来做文件系统缓存,我们认为,这应该从操作系统自身的需求里分离出来。\n8.4.4 为缓存分配内存 # 如果服务器只运行MySQL,所有不需要为操作系统以及查询处理保留的内存都可以用作MySQL缓存。\n相比其他,MySQL需要为缓存分配更多的内存。它使用缓存来避免磁盘访问,磁盘访问比内存访问数据要慢得多。操作系统可能会缓存一些数据,这对MySQL有些好处(尤其是对MyISAM),但是MySQL自身也需要大量内存。\n下面是我们认为对大部分情况来说最重要的缓存:\nInnoDB缓冲池 InnoDB日志文件和MyISAM数据的操作系统缓存 MyISAM键缓存 查询缓存 无法手工配置的缓存,例如二进制日志和表定义文件的操作系统缓存 还有些其他缓存,但是它们通常不会使用太多内存。我们在前面的章节中讨论了查询缓存(Query Cache)的细节,所以接下来的部分我们专注于InnoDB和MyISAM良好工作需要的缓存。\n如果只使用单一存储引擎,配置服务器就简单多了。如果只使用MyISAM表,就可以完全关闭InnoDB,而如果只使用InnoDB,就只需要分配最少的资源给MyISAM(MySQL内部系统表采用MyISAM)。但是如果正混合使用各种存储引擎,就很难在它们之间找到恰当的平衡。我们发现最好的办法是先做一个有根据的猜测,然后在运行中观察服务器(再进行调整)。\n8.4.5 InnoDB缓冲池(Buffer Pool) # 如果大部分都是InnoDB表,InnoDB缓冲池或许比其他任何东西更需要内存。InnoDB缓冲池并不仅仅缓存索引:它还会缓存行的数据、自适应哈希索引、插入缓冲(Insert Buffer)、锁,以及其他内部数据结构。InnoDB还使用缓冲池来帮助延迟写入,这样就能合并多个写入操作,然后一起顺序地写回。总之,InnoDB严重依赖缓冲池,你必须确认为它分配了足够的内存,通常就像这一章前面展示的那样处理。可以使用通过SHOW命令得到的变量或者例如innotop这样的工具监控InnoDB缓冲池的内存利用情况。\n如果数据量不大,并且不会快速增长,就没必要为缓冲池分配过多的内存。把缓冲池配置得比需要缓存的表和索引还要大很多实际上没有什么意义。当然,对一个迅速增长的数据库做超前的规划没有错,但有时我们也会看到一个巨大的缓冲池只缓存一点点数据,这就没有必要了。\n很大的缓冲池也会带来一些挑战,例如,预热和关闭都会花费很长的时间。如果有很多脏页在缓冲池里,InnoDB关闭时可能会花费较长的时间,因为在关闭之前需要把脏页写回数据文件。也可以强制快速关闭,但是重启时就必须多做更多的恢复工作,也就是说无法同时加速关闭和重启两个动作。如果事先知道什么时候需要关闭InnoDB,可以在运行时修改innodb_max_dirty_pages_pct变量,将值改小,等待刷新线程清理缓冲池,然后在脏页数量较少时关闭。可以监控the Innodb_buffer_pool_pages_dirty状态变量或者使用innotop来监控SHOW INNODB STATUS来观察脏页的刷新量。\n更小的innodb_max_dirty_pages_pct变量值并不保证InnoDB将在缓冲池中保持更少的脏页。它只是控制InnoDB是否可以“偷懒(Lazy)”的阈值。InnoDB默认通过一个后台线程来刷新脏页,并且会合并写入,更高效地顺序写出到磁盘。这个行为之所以被称为“偷懒(Lazy)”,是因为它使得InnoDB延迟了缓冲池中刷写脏页的操作,直到一些其他数据必须使用空间时才刷写。当脏页的百分比超过了这个阈值,InnoDB将快速地刷写脏页,尝试让脏页的数量更低。当事务日志没有足够的空间剩余时,InnoDB也将进入“激烈刷写(Furious Flushing)”模式,这就是大日志可以提升性能的一个原因。\n当有一个很大的缓冲池,重启后服务器也许需要花很长的时间(几个小时甚至几天)来预热缓冲池,尤其是磁盘很慢的时候。在这种情况下,可以利用Percona Server的功能来重新载入缓冲池的页(9),从而节省时间。这可以让预热时间减少到几分钟。MySQL 5.6也提供了一个类似的功能。这个功能对复制尤其有好处,因为单线程复制导致备库需要额外的预热时间。\n如果不能使用Percona Server的快速预热功能,也可以在重启后立刻进行全表扫描或者索引扫描,把索引载入缓冲池。这是比较粗暴的方式,但是有时候比什么都不做还是要好。可以使用init_file设置来实现这个功能。把SQL放到一个文件里,然后当MySQL启动的时候来执行。文件名必须在init_file选项中指定,文件中可以包含多条SQL命令,每一条单独一行(不允许使用注释)。\n8.4.6 MyISAM键缓存(Key Caches) # MyISAM的键缓存也被称为键缓冲,默认只有一个键缓存,但也可以创建多个。不像InnoDB和其他一些存储引擎,MyISAM自身只缓存索引,不缓存数据(依赖操作系统缓存数据)。如果大部分是MyISAM表,就应该为键缓存分配比较多的内存。\n最重要的配置项是key_buffer_size。任何没有分配给它的内存(10)都可以被操作系统缓存利用。MySQL 5.0有一个规定的有效上限是4GB,不管系统是什么架构。MySQL 5.1允许更大的值。可以查看正在使用的MySQL版本的官方手册来了解这个限制。\n在决定键缓存需要分配多少内存之前,先去了解MyISAM索引实际上占用多少磁盘空间是很有帮助的。肯定不需要把键缓冲设置得比需要缓存的索引数据还大。查询INFORMATION_SCHEMA表的INDEX_LENGTH字段,把它们的值相加,就可以得到索引存储占用的空间:\nSELECT SUM(INDEX_LENGTH) FROM INFORMATION_SCHEMA.TABLES WHERE ENGINE='MYISAM'; 如果是类UNIX系统,也可以使用下面的命令:\n** $ du -sch `find /path/to/mysql/data/directory/ -name\u0026quot;*.MYI\u0026quot;`** 应该把键缓存设置得多大?不要超过索引的总大小,或者不超过为操作系统缓存保留总内存的25%~50%,以更小的为准。\n默认情况下,MyISAM将所有索引都缓存在默认键缓存中,但也可以创建多个命名的键缓冲。这样就可以同时缓存超过4GB的内存。如果要创建名为key_buffer_1和key_buffer_2的键缓冲,每个大小为1GB,则可以在配置文件中添加如下配置项:\nkey_buffer_1.key_buffer_size = 1G key_buffer_2.key_buffer_size = 1G 现在有了三个键缓冲:两个由这两行配置明确定义,还有一个是默认键缓冲。可以使用CACHE INDEX命令来将表映射到对应的缓冲区。使用下面的语句,让MySQL使用key_buffer_1缓冲区来缓存t1和t2表的索引:\n现在当MySQL从这些表的索引读取块时,将会在指定的缓冲区内缓存这些块。也可以把表的索引预载入到缓存中,通过init_file设置或者LOAD INDEX命令:\n任何没明确指定映射到哪个键缓冲区的索引,在MySQL第一次需要访问*.MYI*文件的时候,都会被分配到默认缓冲区。\n可以通过SHOW STATUS和SHOW VARIABLES命令的信息来监控键缓冲的使用情况。下面的公式可以计算缓冲区的使用率:\n100 - ( (Key_blocks_unused * key_cache_block_size) * 100 / key_buffer_size ) 如果服务器运行了很长一段时间后,还是没有使用完所有的键缓冲,就可以把缓冲区调小一点。\n键缓冲命中率有什么意义?正如我们之前解释的那样,这个数字没什么用。例如,99%和99.9%之间看起来差别很小,但实际上代表了10倍的差距。缓存命中率也是和应用相关的:有些应用可以在95%的命中率下工作良好,但是也有些应用可能是I/O密集型的,必须在99.9%的命中率下工作。甚至有可能在恰当大小的缓存设置下获得99.99%的命中率。\n从经验上来说,每秒缓存未命中的次数要更有用。假定有一个独立的磁盘,每秒可以做100个随机读。每秒5次缓存未命中可能不会导致I/O繁忙,但是每秒80次缓存未命中则可能出现问题。可以使用下面的公式来计算这个值:\nKey_reads / Uptime 通过间隔10~100秒来计算这段时间内缓存未命中次数的增量值,可以获得当前性能的情况。下面的命令可以每10秒钟获取一次状态值的变化量:\n** $ mysqladmin extended-status -r -i 10 | grey Key_reads** 记住,MyISAM使用操作系统缓存来缓存数据文件,通常数据文件比索引要大。因此,把更多的内存保留给操作系统缓存而不是键缓存是有意义的。即使你有足够的内存来缓存所有索引,并且键缓存命中率很低,当MySQL尝试读取数据文件时(不是索引文件),在操作系统层还是可能发生缓存未命中,这对MySQL完全透明,MySQL并不能感知到。因此,这种情况下可能会有大量数据文件缓存未命中,这和索引的键缓存未命中率是完全不相关的。\n最后,即使没有任何MyISAM表,依然需要将key_buffer_size设置为较小的值,例如32M。MySQL服务器有时会在内部使用MyISAM表,例如GROUP BY语句可能会使用MyISAM做临时表。\nMySQL键缓存块大小(Key Block Size) # 块大小也是很重要的(尤其是写密集型负载),因为它影响了MyISAM、操作系统缓存,以及文件系统之间的交互。如果缓存块太小了,可能会碰到写时读取(read-around write),就是操作系统在执行写操作之前必须先从磁盘上读取一些数据。下面说明一下这种情况是怎么发生的,假设操作系统的页大小是4KB(在x86架构上通常都是这样),并且索引块大小是1KB:\nMyISAM请求从磁盘上读取1KB的块。 操作系统从磁盘上读取4KB的数据并缓存,然后发送需要的1KB数据给MyISAM。 操作系统丢弃缓存数据以给其他数据腾出缓存。 MyISAM修改1KB的索引块,然后请求操作系统把它写回磁盘。 操作系统从磁盘读取同一个4KB的数据,写入操作系统缓存,修改MyISAM改动的这1KB数据,然后把整个4KB的块写回磁盘。 在第5步中,当MyISAM请求操作系统去写4KB页的部分内容时,就发生了写时读取(read-around write)。如果MyISAM的块大小跟操作系统的相匹配,在第5步的磁盘读就可以避免(11)。\n很遗憾,MySQL 5.0以及更早的版本没有办法配置索引块大小。但是,在MySQL 5.1以及更新版本中,可以设置MyISAM的索引块大小跟操作系统一样,以避免写时读取。myisam_block_size变量控制着索引块大小。也可以指定每个索引的块大小,在CREATE TABLE或者CREATE INDEX语句中使用KEY_BLOCK_SIZE选项即可,但是因为同一个表的所有索引都保存在同一个文件中,因此该表所有索引的块大小都需要大于或者等于操作系统的块大小,才能避免由于边界对齐导致的写时读取。(例如,若同一个表的两个索引,一个块大小是1KB,另一个是4KB。那么4KB的索引块边界很可能和操作系统的页边界是不对齐的,这样还是会发生写时读取。)\n8.4.7 线程缓存 # 线程缓存保存那些当前没有与连接关联但是准备为后面新的连接服务的线程。当一个新的连接创建时,如果缓存中有线程存在,MySQL从缓存中删除一个线程,并且把它分配给这个新的连接。当连接关闭时,如果线程缓存还有空间的话,MySQL又会把线程放回缓存。如果没有空间的话,MySQL会销毁这个线程。只要MySQL在缓存里还有空闲的线程,它就可以迅速地响应连接请求,因为这样就不用为每个连接创建新的线程。\nthread_cache_size变量指定了MySQL可以保持在缓存中的线程数。一般不需要配置这个值,除非服务器会有很多连接请求。要检查线程缓存是否足够大,可以查看Threads_created状态变量。如果我们观察到很少有每秒创建的新线程数少于10个的时候,通常应该尝试保持线程缓存足够大,但是实际上经常也可能看到每秒少于1个新线程的情况。\n一个好的办法是观察Threads_connected变量并且尝试设置thread_cache_size足够大以便能处理业务压力正常的波动。例如,若Threads_connected通常保持在100~120,则可以设置缓存大小为20。如果它保持在500~700,200的线程缓存应该足够大了。可以这样认为:在700个连接的时候,可能没有线程在缓存中;在500个连接的时候,有200个缓存的线程准备为负载再次增加到700个连接时使用。\n把线程缓存设置得非常大在大部分时候是没有必要的,但是设置得很小也不能节省太多内存,所以也没什么好处。每个在线程缓存中的线程或者休眠状态的线程,通常使用256KB左右的内存。相对于正在处理查询的线程来说,这个内存不算很大。通常应该保证线程缓存足够大,以避免Threads_created频繁增长。如果这个数字很大(例如,几千个线程),可能需要把thread_cache_size设置得稍微小一些,因为一些操作系统不能很好地处理庞大的线程数,即使其中大部分是休眠的。\n8.4.8 表缓存(Table Cache) # 表缓存和线程缓存的概念是相似的,但存储的对象代表的是表。每个在缓存中的对象包含相关表*.frm*文件的解析结果,加上一些其他数据。准确地说,在对象里的其他数据的内容依赖于表的存储引擎。例如,对MyISAM,是表的数据和索引的文件描述符。对于Merge表则可能是多个文件描述符,因为Merge表可以有很多的底层表。\n表缓存可以重用资源。举个实际的例子,当一个查询请求访问一张MyISAM表, MySQL也许可以从缓存的对象中获取到文件描述符。尽管这样做可以避免打开一个文件描述符的开销,但这个开销其实并不大。打开和关闭文件描述符在本地存储是很快的,服务器可以轻松地完成每秒100万次的操作(尽管这跟网络存储不同)。对MyISAM表来说,表缓存的真正好处是,可以让服务器避免修改MyISAM文件头来标记表“正在使用中”(12)。\n表缓存的设计是服务器和存储引擎之间分离不彻底的产物,属于历史问题。表缓存对InnoDB重要性就小多了,因为InnoDB不依赖它来做那么多的事(例如持有文件描述符,InnoDB有自己的表缓存版本)。尽管如此,InnoDB也能从缓存解析的*.frm*文件中获益。\n在MySQL 5.1版本中,表缓存分离成两部分:一个是打开表的缓存,一个是表定义缓存(通过table_open_cache和table_defnition_cache变量来配置)。其结果是,表定义(解析.frm文件的结果)从其他资源中分离出来了,例如表描述符。打开的表依然是每个线程、每个表用的,但是表定义是全局的,可以被所有连接有效地共享。通常可以把table_definition_cache设置得足够高,以缓存所有的表定义。除非有上万张表,否则这可能是最简单的方法。\n如果Opened_tables状态变量很大或者在增长,可能是因为表缓存不够大,那么可以人为增加table_cache系统变量(或者是MySQL 5.1中的table_open_cache)。然而,当创建和删除临时表时,要注意这个计数器的增长,如果经常需要创建和删除临时表,那么该计数器就会不停地增长。\n把表缓存设置得非常大的缺点是,当服务器有很多MyISAM表时,可能会导致关机时间较长,因为关机前索引块必须完成刷新,表都必须标记为不再打开。同样的原因,也可能使FLUSH TABLES WITH READ LOCK操作花费很长一段时间。更为严重的是,检查表缓存算法不是很有效,稍后会更详细地说明。\n如果遇到MySQL无法打开更多文件的错误(可以使用perror工具来检查错误号代表的含义),那么可能需要增加MySQL允许打开文件的数量。这可以通过在my.cnf文件中设置open_files_limit服务器变量来实现。\n线程和表缓存实际上用的内存并不多,相反却可以有效节约资源。虽然创建一个新线程或者打开一个新的表,相对于其他MySQL操作来说代价并不算高,但它们的开销是会累加的。所以缓存线程和表有时可以提升效率。\n8.4.9 InnoDB数据字典(Data Dictionary) # InnoDB有自己的表缓存,可以称为表定义缓存或者数据字典,在目前的MySQL版本中还不能对它进行配置。当InnoDB打开一张表,就增加了一个对应的对象到数据字典。每张表可能占用4KB或者更多的内存(尽管在MySQL 5.1中对空间的需求小了很多)。当表关闭的时候也不会从数据字典中移除它们。\n因此,随着时间的推移,服务器可能出现内存泄露,导致数据字典中的元素不断地增长。但这不是真的内存泄露,只是没有对数据字典实现任何一种缓存过期策略。通常只有当有很多(数千或数万)张大表时才是个问题。如果这个问题有影响,可以使用Percona Server,有一个选项可以控制数据字典的大小,它会从数据字典中移除没有使用的表。MySQL 5.6尚未发布的版本中也有个类似的功能。\n另一个性能问题是第一次打开表时会计算统计信息,这需要很多I/O操作,所以代价很高。相比MyISAM,InnoDB没有将统计信息持久化,而是在每次打开表时重新计算,在打开之后,每隔一段过期时间或者遇到触发事件(改变表的内容或者查询INFORMATION_SCHEMA表,等等),也会重新计算统计信息。如果有很多表,服务器可能会花费数个小时来启动并完全预热,在这个时候服务器可能花费更多的时间在等待I/O操作,而不是做其他事。可以在Percona Server(在MySQL 5.6中也可以,但是叫做innodb_analyze_is_persistent)中打开innodb_use_sys_stats_table选项来持久化存储统计信息到磁盘,以解决这个问题。\n即使在启动之后,InnoDB统计操作还可能对服务器和一些特定的查询产生冲击。可以关闭innodb_stats_on_metadata选项来避免耗时的表统计信息刷新。当例如IDE这样的工具执行INFORMATION_SCHEMA表的查询时,关闭这个选项后的表现是很不一样的(当然是快了不少)。\n如果设置了InnoDB的innodb_file_per_table选项(后面会描述),InnoDB任意时刻可以保持打开.ibd文件的数量也是有其限制的。这由InnoDB存储引擎负责,而不是MySQL服务器管理,并且由innodb_open_files来控制。InnoDB打开文件和MyISAM的方式不一样,MyISAM用表缓存来持有打开表的文件描述符,而InnoDB在打开表和打开文件之间没有直接的关系。InnoDB为每个.ibd文件使用单个、全局的文件描述符。如果可以,最好把innodb_open_files的值设置得足够大以使服务器可以保持所有的*.ibd*文件同时打开。\n8.5 配置MySQL的I/O行为 # 有一些配置项影响着MySQL怎样同步数据到磁盘以及如何做恢复操作。这些操作对性能的影响非常大,因为都涉及到昂贵的I/O操作。它们也表现了性能和数据安全之间的权衡。通常,保证数据立刻并且一致地写到磁盘是很昂贵的。如果能够冒一点磁盘写可能没有真正持久化到磁盘的风险,就可以增加并发性和减少I/O等待,但是必须决定可以容忍多大的风险。\n8.5.1 InnoDB I/O配置 # InnoDB不仅允许控制怎么恢复,还允许控制怎么打开和刷新数据(文件),这会对恢复和整体性能产生巨大的影响。尽管可以影响它的行为,InnoDB的恢复流程实际上是自动的,并且经常在InnoDB启动时运行。撇开恢复并假设InnoDB没有崩溃或者出错, InnoDB依然有很多需要配置的地方。它有一系列复杂的缓存和文件设计可以提升性能,以及保证ACID特性,并且每一部分都是可配置的,图8-1阐述了这些文件和缓存。\n对于常见的应用,最重要的一小部分内容是InnoDB日志文件大小、InnoDB怎样刷新它的日志缓冲,以及InnoDB怎样执行I/O。\n图8-1:InnoDB的缓存和文件\nInnoDB事务日志\nInnoDB使用日志来减少提交事务时的开销。因为日志中已经记录了事务,就无须在每个事务提交时把缓冲池的脏块刷新(flush)到磁盘中。事务修改的数据和索引通常会映射到表空间的随机位置,所以刷新这些变更到磁盘需要很多随机I/O。InnoDB假设使用的是常规磁盘(机械磁盘),随机I/O比顺序I/O要昂贵得多,因为一个I/O请求需要时间把磁头移到正确的位置,然后等待磁盘上读出需要的部分,再转到开始位置。\nInnoDB用日志把随机I/O变成顺序I/O。一旦日志安全写到磁盘,事务就持久化了,即使变更还没写到数据文件。如果一些糟糕的事情发生了(例如断电了),InnoDB可以重放日志并且恢复已经提交的事务。\n当然,InnoDB最后还是必须把变更写到数据文件,因为日志有固定的大小。InnoDB的日志是环形方式写的:当写到日志的尾部,会重新跳转到开头继续写,但不会覆盖还没应用到数据文件的日志记录,因为这样做会清掉已提交事务的唯一持久化记录。\nInnoDB使用一个后台线程智能地刷新这些变更到数据文件。这个线程可以批量组合写入,使得数据写入更顺序,以提高效率。实际上,事务日志把数据文件的随机I/O转换为几乎顺序的日志文件和数据文件I/O。把刷新操作转移到后台使查询可以更快完成,并且缓和查询高峰时I/O系统的压力。\n整体的日志文件大小受控于innodb_log_file_size和innodb_log_files_in_group两个参数,这对写性能非常重要。日志文件的总大小是每个文件的大小之和。默认情况下,只有两个5MB的文件,总共10MB。对高性能工作来说这太小了。至少需要几百MB,或者甚至上GB的日志文件。\nInnoDB使用多个文件作为一组循环日志。通常不需要修改默认的日志数量,只修改每个日志文件的大小即可。要修改日志文件大小,需要完全关闭MySQL,将旧的日志文件移到其他地方保存,重新配置参数,然后重启。一定要确保MySQL干净地关闭了,或者还有日志文件可以保证需要应用到数据文件的事务记录,否则数据库就无法恢复了!当重启服务器的时候,查看MySQL的错误日志。在重启成功之后,才可以删除旧的日志文件。\n日志文件大小和日志缓存。要确定理想的日志文件大小,必须权衡正常数据变更的开销和崩溃恢复需要的时间。如果日志太小,InnoDB将必须做更多的检查点,导致更多的日志写。在极个别情况下,写语句可能被拖累,在日志没有空间继续写入前,必须等待变更被应用到数据文件。另一方面,如果日志太大了,在崩溃恢复时InnoDB可能不得不做大量的工作。这可能极大地增加恢复时间,尽管这个处理在新的MySQL版本中已经改善很多。\n数据大小和访问模式也将影响恢复时间。假设有一个1TB的数据和16GB的缓冲池,并且全部日志大小是128MB。如果缓冲池里有很多脏页(例如,页被修改了还没被刷写回数据文件),并且它们均匀地分布在1TB数据中,崩溃后恢复将需要相当长一段时间。InnoDB必须从头到尾扫描日志,仔细检查数据文件,如果需要还要应用变更到数据文件。这是很庞大的读写操作!另一方面,如果变更是局部性的——就是说,如果只有几百MB数据被频繁地变更——恢复可能就很快,即使数据和日志文件很大。恢复时间也依赖于普通修改操作的大小,这跟数据行的平均长度有关系。较短的行使得更多的修改可以放在同样的日志中,所以InnoDB可能必须在恢复时重放更多修改操作(13)。\n当InnoDB变更任何数据时,会写一条变更记录到内存日志缓冲区。在缓冲满的时候、事务提交的时候,或者每一秒钟,InnoDB都会刷写缓冲区的内容到磁盘日志文件——无论上述三个条件哪个先达到。如果有大事务,增加日志缓冲区(默认1MB)大小可以帮助减少I/O。变量innodb_log_buffer_size可以控制日志缓冲区的大小。\n通常不需要把日志缓冲区设置得非常大。推荐的范围是1MB~8MB,一般来说足够了,除非要写很多相当大的BLOB记录。相对于InnoDB的普通数据,日志条目是非常紧凑的。它们不是基于页的,所以不会浪费空间来一次存储整个页。InnoDB也使得日志条目尽可能地短。有时甚至会保存为函数号和C函数的参数!\n较大的日志缓冲区在某些情况下也是有好处的:可以减少缓冲区中空间分配的争用。当配置一台有大内存的服务器时,有时简单地分配32MB~128MB的日志缓冲,因为花费这么点相对(整机)而言比较小的内存并没有什么不好,还可以帮助避免压力瓶颈。如果有问题,瓶颈一般会表现为日志缓冲Mutex的竞争。\n可以通过检查SHOW INNODB STATUS的输出中LOG部分来监控InnoDB的日志和日志缓冲区的I/O性能,通过观察Innodb_os_log_written状态变量来查看InnoDB对日志文件写出了多少数据。一个好用的经验法则是,查看10~100秒间隔的数字,然后记录峰值。可以用这个来判断日志缓冲是否设置得正好。例如,若看到峰值是每秒写100KB数据到日志,那么1MB的日志缓冲可能足够了。也可以使用这个衡量标准来决定日志文件设置多大会比较好。如果峰值是100KB/s,那么256MB的日志文件足够存储至少2 560秒的日志记录。这看起来足够了。作为一个经验法则,日志文件的全部大小,应该足够容纳服务器一个小时的活动内容。\nInnoDB怎样刷新日志缓冲。当InnoDB把日志缓冲刷新到磁盘日志文件时,先会使用一个Mutex锁住缓冲区,刷新到所需要的位置,然后移动剩下的条目到缓冲区的前面。当Mutex释放时,可能有超过一个事务已经准备好刷新其日志记录。InnoDB有一个Group Commit功能,可以在一个I/O操作内提交多个事务,但是在MySQL 5.0中当打开二进制日志时这个功能就不能用了。我们在前一章写了一些关于Group Commit的东西。\n日志缓冲必须被刷新到持久化存储,以确保提交的事务完全被持久化了。如果和持久相比更在乎性能,可以修改innodb_flush_log_at_trx_commit变量来控制日志缓冲刷新的频繁程度。可能的设置如下:\n0\n把日志缓冲写到日志文件,并且每秒钟刷新一次,但是事务提交时不做任何事。\n1\n将日志缓冲写到日志文件,并且每次事务提交都刷新到持久化存储。这是默认的(并且是最安全的)设置,该设置能保证不会丢失任何已经提交的事务,除非磁盘或者操作系统是“伪”刷新。\n2\n每次提交时把日志缓冲写到日志文件,但是并不刷新。InnoDB每秒钟做一次刷新。0与2最重要的不同是(也是为什么2是更合适的设置),如果MySQL进程“挂了”, 2不会丢失任何事务。如果整个服务器“挂了”或者断电了,则还是可能会丢失一些事务。\n了解清楚“把日志缓冲写到日志文件”和“把日志刷新到持久化存储”之间的不同是很重要的。在大部分操作系统中,把缓冲写到日志只是简单地把数据从InnoDB的内存缓冲转移到了操作系统的缓存,也是在内存里,并没有真的把数据写到了持久化存储。\n因此,如果MySQL崩溃了或者电源断电了,设置0和2通常会导致最多一秒的数据丢失,因为数据可能只存在于操作系统的缓存。我们说“通常”,因为不论如何InnoDB会每秒尝试刷新日志文件到磁盘,但是在一些场景下也可能丢失超过1秒的事务,例如当刷新被推迟了。\n与此相反,把日志刷新到持久化存储意味着InnoDB请求操作系统把数据刷出缓存,并且确认写到磁盘了。这是一个阻塞I/O的调用,直到数据被完全写回才会完成。因为写数据到磁盘比较慢,当innodb_flush_log_at_trx_commit被设置为1时,可能明显地降低InnoDB每秒可以提交的事务数。今天的高速驱动器(14)可能每秒只能执行一两百个磁盘事务,受限于磁盘旋转速度和寻道时间。\n有时硬盘控制器或者操作系统假装做了刷新,其实只是把数据放到了另一个缓存,例如磁盘自己的缓存。这更快但是很危险,因为如果驱动器断电,数据依然可能丢失。这甚至比设置innodb_flush_log_at_trx_commit为不为1的值更糟糕,因为这可能导致数据损坏,不仅仅是丢失事务。\n设置innodb_flush_log_at_trx_commit为不为1的值可能导致丢失事务。然而,如果不在意持久性(ACID中的D),那么设置为其他的值也是有用的。也许你只是想拥有InnoDB的其他一些功能,例如聚簇索引、防止数据损坏,以及行锁。但仅仅因为性能原因用InnoDB替换MyISAM的情况也并不少见。\n高性能事务处理需要的最佳配置是把innodb_flush_log_at_trx_commit设置为1且把日志文件放到一个有电池保护的写缓存的RAID卷中。这兼顾了安全和速度。事实上,我们敢说任何希望能扛过高负荷工作负载的产品数据库服务器,都需要有这种类型的硬件。\nPercona Server扩展了innodb_fush_log_at_trx_commit变量,使得它成为一个会话级变量,而不是一个全局变量。这允许有不同的性能和持久化要求的应用,可以使用同样的数据库,同时又避免了标准MySQL提供的一刀切的解决方案。\nInnoDB怎样打开和刷新日志以及数据文件 # 使用innodb_fush_method选项可以配置InnoDB如何跟文件系统相互作用。从名字来看,会以为只能影响InnoDB怎么写数据,实际上还影响了InnoDB怎么读数据。Windows和非Windows的操作系统对这个选项的值是互斥的:async_unbuffered、unbuffered和normal只能在Windows下使用,并且Windows下不能使用其他的值。在Windows下默认值是unbuffered,其他操作系统都是fdatasync。(如果SHOW GLOBAL VARIABLES显示这个变量为空,意味着它被设置为默认值了。)\n改变InnoDB执行I/O操作的方式可以显著地影响性能,所以请确认你明白了在做什么后再去做改动!\n这是个有点难以理解的选项,因为它既影响日志文件,也影响数据文件,而且有时候对不同类型的文件的处理也不一样。如果有一个选项来配置日志,另一个选项来配置数据文件,这样最好了,但实际上它们混合在同一个配置项中。\n下面是一些可能的值:\nfdatasync\n这在非Windows系统上是默认值:InnoDB用fsync()来刷新数据和日志文件。\nInnoDB通常用fsync()代替fdatasync(),即使这个值似乎表达的是相反的意思。fdatasync()跟fsync()相似,但是只刷新文件的数据,而不包括元数据(最后修改时间,等等)。因此,fsync()会导致更多的I/O。然而InnoDB的开发者都很保守,他们发现某些场景下fdatasync()会导致数据损坏。InnoDB决定了哪些方法可以更安全地使用,有一些是编译时设置的,也有一些是运行时设置的。它使用尽可能最快的安全方法。\n使用fsync()的缺点是操作系统至少会在自己的缓存中缓冲一些数据。理论上,这种双重缓冲是浪费的,因为InnoDB管理自己的缓冲比操作系统能做的更加智能。然而,最后的影响跟操作系统和文件系统非常相关。如果能让文件系统做更智能的I/O调度和批量操作,双重缓冲可能并不是坏事。有的文件系统和操作系统可以积累写操作后合并执行,通过对I/O重新排序来提升效率,或者并发写入多个设备。它们也可能做预读优化,例如,若连续请求了几个顺序的块,它会通知硬盘预读下一个块。\n有时这些优化有帮助,有时没有。如果你好奇你的系统中的fsync()会做哪些具体的事,可以阅读系统的帮助手册,看下fsync(2)。\ninnodb_file_per_table选项会导致每个文件独立地做fsync(),这意味着写多个表不能合并到一个I/O操作。这可能导致InnoDB执行更多的fsync()操作。\nO_DIRECT\nInnoDB对数据文件使用O_DIRECT标记或directio()函数,这依赖于操作系统。这个设置并不影响日志文件并且不是在所有的类UNIX系统上都有效。但至少GNU/Linux、FreeBSD,以及Solaris(5.0以后的新版本)是支持的。不像O_DSYNC标记,它同时会影响读和写。\n这个设置依然使用fsync()来刷新文件到磁盘,但是会通知操作系统不要缓存数据,也不要用预读。这个选项完全关闭了操作系统缓存,并且使所有的读和写都直接通过存储设备,避免了双重缓冲。\n在大部分系统上,这个实现用fcntl()调用来设置文件描述符的O_DIRECT标记,所以可以阅读fcntl(2)的手册页来了解系统上这个函数的细节。在Solaris系统,这个选项用directio()。\n如果RAID卡支持预读,这个设置不会关闭RAID卡的预读(15)。这个设置只能关闭操作系统和文件系统的预读。\n如果使用O_DIRECT选项,通常需要带有写缓存的RAID卡,并且设置为Write-Back策略(16),因为这是典型的唯一能保持好性能的方法。当InnoDB和实际存储设备之间没有缓冲时使用O_DIRECT,例如当RAID卡没有写缓存时,可能导致严重的性能下降。现在有了多个写线程,这个问题稍微小一点(并且MySQL 5.5提供了原生异步I/O),但是通常还是有问题。\n这个选项可能导致服务器预热时间变长,特别是操作系统的缓存很大的时候。也可能导致小容量的缓冲池(例如,默认大小的缓冲池)比缓冲I/O(Buffered IO)方式操作要慢的多。这是因为操作系统不会通过保持更多数据在自己的缓存中来“帮助”(提升性能)。如果需要的数据不在缓冲池,InnoDB将不得不直接从磁盘读取。\n这个选项不会对innodb_file_per_table产生任何额外的损失。相反,如果不用innodb_file_per_table,当使用O_DIRECT时,可能由于一些顺序I/O而遭受性能损失。这种情况的发生是因为一些文件系统(包括Linux所有的ext文件系统)每个inode有一个Mutex。当在这些文件系统上使用O_DIRECT时,确实需要打开innodb_file_per_table。我们下一章会更深入地探究文件系统。\nALL_O_DIRECT\n这个选项在Percona Server和MariaDB中可用。它使得服务器在打开日志文件时,也能使用标准MySQL中打开数据文件的方式(O_DIRECT)。\nO_DSYNC\n这个选项使日志文件调用open()函数时设置O_SYNC标记。它使得所有的写同步——换个说法,只有数据写到磁盘后写操作才返回。这个选项不影响数据文件。\nO_SYNC标记和O_DIRECT标记的不同之处在于O_SYNC没有禁用操作系统层的缓存。因此,它没有避免双重缓冲,并且它没有使写操作直接操作到磁盘。用了O_SYNC标记,在缓存中写数据,然后发送到磁盘。\n使用O_SYNC标记做同步写操作,听起来可能跟fsync()做的事情非常相似,但是它们两个的实现无论在操作系统层还是在硬件层都非常不同。用了O_SYNC标记后,操作系统可能把“使用同步I/O”标记下传给硬件层,告诉设备不要使用缓存。另一方面,fsync()告诉操作系统把修改过的缓冲数据刷写到设备上,如果设备支持,紧接着会传递一个指令给设备刷新它自己的缓存,所以,毫无疑问,数据肯定记录在了物理媒介上。另一个不同是,用了O_SYNC的话,每个write()或pwrite()操作都会在函数完成之前把数据同步到磁盘,完成前函数调用是阻塞的。相对来看,不用O_SYNC标记的写入调用fsync()允许写操作积累在缓存(使得每个写更快),然后一次性刷新所有的数据。\n再一次吐槽下这个名称,这个选项设置O_SYNC标记,不是O_DSYNC标记,因为InnoDB开发者发现了O_DSYNC的Bug。O_SYNC和O_DSYNC类似于fysnc()和fdatasync():O_SYNC同时同步数据和元数据,但是O_DSYNC只同步数据。\nasync_unbuffered\n这是Windows下的默认值。这个选项让InnoDB对大部分写使用没有缓冲的I/O;例外是当innodb_flush_log_at_trx_commit设置为2的时候,对日志文件使用缓冲I/O。\n这个选项使得InnoDB在Windows 2000、XP,以及更新版本中对数据读写都使用操作系统的原生异步(重叠的)I/O。在更老的Windows版本中,InnoDB使用自己用多线程模拟的异步I/O。\nunbuffered\n只对Windows有效。这个选项与async_unbuffered类似,但是不使用原生异步I/O。\nnormal\n只对Windows有效。这个选项让InnoDB不要使用原生异步I/O或者无缓冲I/O。\nNosync和littlesync\n只为开发使用。这两个选项在文档中没有并且对生产环境来说不安全,不应该使用这个。\n如果这些看起来像是一堆不带建议的说明,那么下面是一些建议:如果使用类UNIX操作系统并且RAID控制器带有电池保护的写缓存,我们建议使用O_DIRECT。如果不是这样,默认值或者O_DIRECT都可能是最好的选择,具体要看应用类型。\nInnoDB表空间 # InnoDB把数据保存在表空间内,本质上是一个由一个或多个磁盘文件组成的虚拟文件系统。InnoDB用表空间实现很多功能,并不只是存储表和索引。它还保存了回滚日志(旧版本行)、插入缓冲(Insert Buffer)、双写缓冲(Doublewrite Buffer,后面的章节里就会描述),以及其他内部数据结构。\n配置表空间。通过innodb_data_file_path配置项可以定制表空间文件。这些文件都放在innodb_data_home_dir指定的目录下。这是一个例子:\ninnodb_data_home_dir = /var/lib/mysql/ innodb_data_file_path = ibdata1:1G;ibdata2:1G;ibdata3:1G 这里在三个文件中创建了3GB的表空间。有时人们并不清楚可以使用多个文件分散驱动器的负载,像这样:\ninnodb_data_file_path = /disk1/ibdata1:1G;/disk2/ibdata2:1G;... 在这个例子中,表空间文件确实放在代表不同驱动器的不同目录中,InnoDB把这些文件首尾相连组合起来。因此,通常这种方式并不能获得太多收益。InnoDB先填满第一个文件,当第一个文件满了再用第二个,如此循环;负载并没有真的按照希望的高性能方式分布。用RAID控制器是分布负载更聪明的方式。\n为了允许表空间在超过了分配的空间时还能增长,可以像这样配置最后一个文件自动扩展:\n...ibdata3:1G:autoextend 默认的行为是创建单个10MB的自动扩展文件。如果让文件可以自动扩展,那么最好给表空间大小设置一个上限,别让它扩展得太大,因为一旦扩展了,就不能收缩回来。例如,下面的例子限制了自动扩展文件最多到2GB:\n...ibdata3:1G:autoextend:max:2G 管理一个单独的表空间可能有点麻烦,尤其是如果它是自动扩展的,并且希望回收空间时(因为这个原因,我们建议关闭自动扩展功能,至少设置一个合理的空间范围)。回收空间唯一的方式是导出数据,关闭MySQL,删除所有文件,修改配置,重启,让InnoDB创建新的数据文件,然后导入数据。InnoDB这种表空间管理方式很让人头疼——不能简单地删除文件或者改变大小。如果表空间损坏了,InnoDB会拒绝启动。对日志文件也一样的严格。如果像MyISAM一样随便移动文件,千万要谨慎!\ninnodb_file_per_table选项让InnoDB为每张表使用一个文件,MySQL 4.1和之后的版本都支持。它在数据字典存储为“表名.ibd”的数据。这使得删除一张表时回收空间简单多了,并且可以容易地分散表到不同的磁盘上。然而,把数据放到多个文件,总体来说可能导致更多的空间浪费,因为把单个InnoDB表空间的内部碎片浪费分布到了多个*.ibd*文件。对于非常小的表,这个问题更大,因为InnoDB的页大小是16 KB。即使表只有1 KB的数据,仍然需要至少16 KB的磁盘空间。\n即使打开innodb_file_per_table选项,依然需要为回滚日志和其他系统数据创建共享表空间。没有把所有数据存在其中是明智的做法,但最好还是关闭它的自动增长,因为无法在不重新导入全部数据的情况下给共享表空间瘦身。\n一些人喜欢使用innodb_file_per_table,只是因为特别容易管理,并且可以看到每个表的文件。例如,可以通过查看文件的大小来确认表的大小,这比用SHOW TABLE STATUS来看快多了,这个命令需要执行很多复杂的工作来判断给一个表分配了多少页面。\n设置innodb_file_per_table也有不好的一面:更差的DROP TABLE性能。这可能足以导致显而易见的服务器端阻塞。因为有如下两个原因: 删除表需要从文件系统层去掉(删除)文件,这可能在某些文件系统(ext3,说的就是你)上会很慢。可以通过欺骗文件系统来缩短这个过程:把.ibd文件链接到一个0字节的文件,然后手动删除这个文件,而不用等待MySQL来做。 当打开这个选项,每张表都在InnoDB中使用自己的表空间。结果是,移除表空间实际上需要InnoDB锁定和扫描缓冲池,查找属于这个表空间的页面,在一个有庞大的缓冲池的服务器上做这个操作是非常慢的。如果打算删除很多InnoDB表(包括临时表)并且用了innodb_file_per_table,可能会从Percona Server包含的一个修复中获益,它可以让服务器慢慢地清理掉属于被删除表的页面。只需要设置innodb_lazy_drop_table这个选项。 什么是最终的建议?我们建议使用innodb_file_per_table并且给共享表空间设置大小范围,这样可以过得舒服点(不用处理那些空间回收的事)。如果遇到任何头痛的场景,就像上面说的,考虑用下Percona的那个修复。\n提醒一下,事实上没有必要把InnoDB文件放在传统的文件系统上。像许多的传统数据库服务器一样,InnoDB提供使用裸设备的选项——例如,一个没有格式化的分区——作为它的存储。然而,今天的文件系统已经可以存放足够大的文件,所以已经没有必要使用这个选项。使用裸设备可能提升几个百分点的性能,但是我们不认为这点小提升足以抵消这样做带来的坏处,我们不能直接用文件管理数据。当把数据存在一个裸设备分区时,不能使用mv、cp或其他任何工具来操作它。最终,这点小的性能收益显然不值得。\n行的旧版本和表空间 在一个写压力大的环境下,InnoDB的表空间可能增长得非常大。如果事务保持打开状态很久(即使它们没有做任何事),并且使用默认的REPEATABLE READ事务隔离级别,InnoDB将不能删除旧的行版本,因为没提交的事务依然需要看到它们。InnoDB把旧版本存在共享表空间,所以如果有更多的数据在更新,共享表空间会持续增长。有时这个问题并非是没提交的事务的原因,也可能是工作负载的问题:清理过程只有一个线程处理,直到最近的MySQL版本才改进,这可能导致清理线程处理速度跟不上旧版本行数增加的速度。\n无论发生何种情况,SHOW INNODB STATUS的数据都可以帮助定位问题。查看历史链表的长度会显示了回滚日志的大小,以页为单位。\n分析TRANSACTIONS部分的第一行和第二行可以证实这个观点,这部分展示了当前事务号以及清理线程完成到了哪个点。如果这个差距很大,可能有大量的没有清理的事务。\n这有个例子:\n------------ TRANSACTIONS ------------ Trx id counter 0 80157601 Purge done for trx's n:o \u0026lt;0 80154573 undo n:o \u0026lt;0 0 事务标识是一个64比特的数字,由两个32比特的数字(在更新版本的InnoDB中这是个十六进制的数字)组成,所以需要做一点数学计算来计算差距。在这个例子中就很简单了,因为最高位是0:那么有80 157 601–80 154 573=3 028个“潜在的”没有被清理的事务(innotop可以做这个计算)。我们说“潜在的”,是因为这跟有很多没有清理的行是有很大区别的。只有改变了数据的事务才会创建旧版本的行,但是有很多事务并没有修改数据(相反的,一个事务也可能修改很多行)。\n如果有个很大的回滚日志并且表空间因此增长很快,可以强制MySQL减速来使InnoDB的清理线程可以跟得上。这听起来不怎么样,但是没办法。否则,InnoDB将保持数据写入,填充磁盘直到最后磁盘空间爆满,或者表空间大于定义的上限。\n为了控制写入速度,可以设置innodb_max_purge_lag变量为一个大于0的值。这个值表示InnoDB开始延迟后面的语句更新数据之前,可以等待被清除的最大的事务数量。你必须知道工作负载以决定一个合理的值。例如,事务平均影响1KB的行,并且可以容许表空间里有100MB的未清理行,那么可以设置这个值为100000。\n牢记,没有清理的行版本会对所有的查询产生影响,因为它们事实上使得表和索引更大了。如果清理线程确实跟不上,性能可能显著的下降。设置innodb_max_purge_lag变量也会降低性能,但是它的伤害较少。(17)\n在更新版本的MySQL中,甚至在更早版本的Percona Server和MariaDB,清理过程已经显著地提升了性能,并且从其他内部工作任务中分离出来。甚至可以创建多个专用的清理线程来更快地做这个后台工作。如果可以利用这些特性,会比限制服务器的服务能力要好得多。\n双写缓冲(Doublewrite Buffer) # InnoDB用双写缓冲来避免页没写完整所导致的数据损坏。当一个磁盘写操作不能完整地完成时,不完整的页写入就可能发生,16KB的页可能只有一部分被写到磁盘上。有多种多样的原因(崩溃、Bug,等等)可能导致页没有写完整。双写缓冲在这种情况发生时可以保证数据完整性。\n双写缓冲是表空间一个特殊的保留区域,在一些连续的块中足够保存100个页。本质上是一个最近写回的页面的备份拷贝。当InnoDB从缓冲池刷新页面到磁盘时,首先把它们写(或者刷新)到双写缓冲,然后再把它们写到其所属的数据区域中。这可以保证每个页面的写入都是原子并且持久化的。\n这意味着每个页都要写两遍?是的,但是因为InnoDB写页面到双写缓冲是顺序的,并且只调用一次fsync()刷新到磁盘,所以实际上对性能的冲击是比较小的——通常只有几个百分点,肯定没有一半那么多,尽管这个开销在SSD上更明显,我们下一章会讨论这个问题。更重要的是,这个策略允许日志文件更加高效。因为双写缓冲给了InnoDB一个非常牢固的保证,数据页不会损坏,InnoDB日志记录没必要包含整个页,它们更像是页面的二进制变化量。\n如果有一个不完整的页写到了双写缓冲,原始的页依然会在磁盘上它的真实位置。当InnoDB恢复时,它将用原始页面替换掉双写缓冲中的损坏页面。然而,如果双写缓冲成功写入,但写到页的真实位置失败了,InnoDB在恢复时将使用双写缓冲中的拷贝来替换。InnoDB知道什么时候页面损坏了,因为每个页面在末尾都有校验值(Checksum)。校验值是最后写到页面的东西,所以如果页面的内容跟校验值不匹配,说明这个页面是损坏的。因此,在恢复的时候,InnoDB只需要读取双写缓冲中每个页面并且验证校验值。如果一个页面的校验值不对,就从它的原始位置读取这个页面。\n有些场景下,双写缓冲确实没必要——例如,你也许想在备库上禁止双写缓冲。此外一些文件系统(例如ZFS)做了同样的事,所以没必要再让InnoDB做一遍。可以通过设置innodb_doublewrite为0来关闭双写缓冲。在Percona Server中,可以配置双写缓冲存到独立的文件中,所以可以把这部分工作压力分离出来放在单独的盘上。\n其他的I/O配置项 # sync_binlog选项控制MySQL怎么刷新二进制日志到磁盘。默认值是0,意味着MySQL并不刷新,由操作系统自己决定什么时候刷新缓存到持久化设备。如果这个值比0大,它指定了两次刷新到磁盘的动作之间间隔多少次二进制日志写操作(如果autocommit被设置了,每个独立的语句都是一次写,否则就是一个事务一次写)。把它设置为0和1以外的值是很罕见的。\n如果没有设置sync_binlog为1,那么崩溃以后可能导致二进制日志没有同步事务数据。这可以轻易地导致复制中断,并且使得及时恢复变得不可能。无论如何,可以把这个值设置为1来获得安全的保障。这样就会要求MySQL同步把二进制日志和事务日志这两个文件刷新到两个不同的位置。这可能需要磁盘寻道,相对来说是个很慢的操作。\n像InnoDB日志文件一样,把二进制日志放到一个带有电池保护的写缓存的RAID卷,可以极大地提升性能。事实上,写和刷新二进制日志缓存其实比InnoDB事务日志要昂贵多了,因为不像InnoDB事务日志,每次写二进制日志都会增加它们的大小。这需要每次写入文件系统都更新元信息。所以,设置sync_binlog=1可能比innodb_fush_log_at_trx_commit=1对性能的损害要大得多,尤其是网络文件系统,例如NFS。\n一个跟性能无关的提示,关于二进制日志:如果希望使用expire_logs_days选项来自动清理旧的二进制日志,就不要用rm命令去删。服务器会感到困惑并且拒绝自动删除它们,并且PURGE MASTER LOGS也将停止工作。解决的办法是,如果发现了这种情况,就手动重新同步“主机名-bin.index”文件,可以用磁盘上现有日志文件的列表来更新。\n我们将在下一章更深入地涉及RAID,但是值得在这里重复一下,把带有电池保护写缓存的高质量RAID控制器设置为使用写回(Writeback)策略,可以支持每秒数千的写入,并且依然会保证写到持久化存储。数据写到了带有电池的高速缓存,所以即使系统断电它也能存在。但电源恢复时,RAID控制器会在磁盘被设置为可用前,把数据从缓存中写到磁盘。因此,一个带有电池保护写缓存的RAID控制器可以显著地提升性能,这是非常值得的投资。当然,SSD存储是另一个选择,我们也会在下一章讲到。\n8.5.2 MyISAM的I/O配置 # 让我们从分析MyISAM怎么为索引操作I/O开始。MyISAM通常每次写操作之后就把索引变更刷新磁盘。如你打算在一张表上做很多修改,那么毫无疑问,批量操作会更快一些。一种办法是用LOCK TABLES延迟写入,直到解锁这些表。这是个提升性能的很有价值的技巧,因为它使得你精确控制哪些写被延迟,以及什么时候把它们刷到磁盘。可以精确延迟那些希望延迟的语句。\n通过设置delay_key_write变量,也可以延迟索引的写入。如果这么做,修改的键缓冲块直到表被关闭才会刷新。(18)可能的配置如下:\nOFF\nMyISAM每次写操作后刷新键缓冲(键缓存,Key Buffer)中的脏块到磁盘,除非表被LOCK TABLES锁定了。\nON\n打开延迟键写入,但是只对用DELAY_KEY_WRITE选项创建的表有效。\nALL\n所有的MyISAM表都会使用延迟键写入。\n延迟键写入在某些场景下可能很有帮助,但是通常不会带来很大的性能提升。当键缓冲的读命中很好但写命中不好时,数据又比较小,这可能很有用。当然也有一小部分缺点:\n如果服务器缓存并且块没有被刷到磁盘,索引可能会损坏。 如果很多写被延迟了,MySQL可能需要花费更长时间去关闭表,因为必须等待缓冲刷新到磁盘。在MySQL 5.0这可能引起很长的表缓存锁。 由于上面提到的原因,FLUSH TABLES可能需要很长时间。如果为了做逻辑卷(LVM)快照或者其他备份操作,而执行FLUSH TABLES WITH READ LOCK,那可能增加操作的时间。 键缓冲中没有刷回去的脏块可能占用空间,导致从磁盘上读取的新块没有空间存放。因此,查询语句可能需要等待MyISAM释放一些键缓存的空间。 另外,除了配置MyISAM的索引I/O还可以配置MyISAM怎样尝试从损坏中恢复。myisam_recover选项控制MyISAM怎样寻找和修复错误。需要在配置文件或者命令行中设置这个选项。可以通过下面的SQL语句查看选项的值,但是不能修改(这不是个印刷错误——系统里变量名跟命令的变量名有差异):\nmysql\u0026gt; ** SHOW VARIABLES LIKE 'myisam_recover_options';** 打开这个选项通知MySQL在表打开时,检查是否损坏,并且在找到问题的时候进行修复。可以设置的值如下:\nDEFAULT(或者不设置)\n使MySQL尝试修复任何被标记为崩溃或者没有标记为完全关闭的表。默认值不要求在恢复时执行其他动作。跟大多数变量不同,这里DEFAULT值不是重置变量的值为编译值;它本质上意味着“没有设置”。\nBACKUP\n让MySQL将数据文件的备份写到*.BAK*文件,以便随后进行检查。\nFORCE\n即使*.MYD*文件中丢失的数据可能超过一行,也让恢复继续。\nQUICK\n除非有删除块,否则跳过恢复。块中有已经删除的行也依然会占用空间,但是可以被后面的INSERT语句重用。这可能比较有用,因为MyISAM大表的恢复可能花费相当长的时间。\n可以使用多个设置,用逗号分隔。例如“BACKUP,FORCE”会强制恢复并且创建备份。这是为什么我们在这一章前面部分的示例配置中这么用的原因。\n我们建议打开这个选项,尤其是只有一些小的MyISAM表时。服务器运行着一些损坏的MyISAM表是很危险的,因为它们有时可以导致更多数据损坏,甚至服务器崩溃。然而,如果有很大的表,原子恢复是不切实际的:它导致服务器打开所有的MyISAM表时都会检查和修复,这是低效的做法。在这段时间,MySQL会阻止连接做任何工作。如果有一大堆的MyISAM表,比较好的主意还是启动后用CHECK TABLES和REPAIR TABLES命令来做(19),这样对服务器影响比较少。不管哪种方式,检查和修复表都是很重要的。\n打开数据文件的内存映射(MMAP)访问是另一个有用的MyISAM选项。内存映射使得MyISAM直接通过操作系统的页面缓存访问*.MYD*文件,避免系统调用的开销。在MySQL 5.1和更新的版本中,可以通过myisam_use_mmap选项打开内存映射。更老版本的MySQL只能对压缩的MyISAM表使用内存映射。\n8.6 配置MySQL并发 # 当MySQL承受高并发压力时,可能会遇到不曾遇到过的瓶颈。这个章节阐述了当这些问题出现的时候,怎样去发现它们,以及在MyISAM和InnoDB遇到这样的压力时怎样获得尽可能最好的性能。\n8.6.1 InnoDB并发配置 # InnoDB是为高性能设计的,在最近几年它的提升非常明显,但依然不完美。InnoDB架构在有限的内存、单CPU、单磁盘的系统中仍然暴露出一些根本性问题。在高并发场景下, InnoDB的某些方面的性能可能会降低,唯一的办法是限制并发。可以参考第3章中使用的技巧来诊断并发问题。\n如果在InnoDB并发方面有问题,解决方案通常是升级服务器。相比当前的版本,像MySQL 5.0和早期的MySQL 5.1这样的旧版本,在高并发下完全是个悲剧。所有的东西都在全局Mutex(例如,缓冲池Mutex)上排队,导致服务器几乎陷入停顿。如果升级到某个更新版本的MySQL,在大部分场景都不再需要限制并发。\n如果需要这么做,这里会介绍它是怎么工作的。InnoDB有自己的“线程调度器”控制线程怎么进入内核访问数据,以及它们在内核中一次可以做哪些事。最基本的限制并发的方式是使用innodb_thread_concurrency变量,它会限制一次性可以有多少线程进入内核,0表示不限制。如果在旧的MySQL版本里有InnoDB并发问题,这个变量是最重要的配置之一(20)。\n在任何架构和业务压力下,给这个变量设置个“靠谱”的值都很重要,理论上,下面的公式可以给出一个这样的值:\n并发值=CPU数量*磁盘数量*2 但是在实践中,使用更小的值会更好一点。必须做实验来找出适合系统的最好的值。\n如果已经进入内核的线程超过了允许的数量,新的线程就无法再进入内核。InnoDB使用两段处理来尝试让线程尽可能高效地进入内核。两段策略减少了因操作系统调度引起的上下文切换。线程第一次休眠innodb_thread_sleep_delay微秒,然后再重试。如果它依然不能进入内核,则放入一个等待线程队列,让操作系统来处理。\n第一阶段默认的休眠时间是10000微秒。当CPU有大量的线程处在“进入队列前的休眠”状态,因而没有被充分利用时,改变这个值在高并发环境里可能会有帮助。如果有大量的小查询,默认值可能也太大了,因为这增加了10毫秒的查询延时。\n一旦线程进入内核,它会有一定数量的“票据(Tickets)”,可以让它“免费”返回内核,不需再做并发检查。这限制了一个线程回到其他等待线程之前可以做多少事。innodb_concurrency_tickets选项控制票据的数量。它很少需要修改,除非有很多运行时间极长的查询。票据是按查询授权的,不是按事务。一旦查询完成,它没用完的票据就销毁了。除了缓冲池和其他结构的瓶颈,还有另一个提交阶段的并发瓶颈,这个时候I/O非常密集,因为需要做刷新操作。innodb_commit_concurrency变量控制有多少个线程可以在同一时间提交。如果innodb_thread_concurrency配置得很低也有大量的线程冲突,那么配置这个选项可能会有帮助。\n最后,有一个新的解决方案值得考虑:使用线程池(Thread Pool)来限制并发。原始的线程池实现已经随着MySQL 6.0的代码树一起被废弃了,并且有严重缺陷。但是MariaDB已经重新实现了,并且Oracle最近放出了一个商业插件可以为MySQL 5.5提供线程池功能。对这些东西我们都没有足够的经验来指导你怎么做,你也许会更加困惑,因为我们会指出这两种实现似乎都不满足Facebook,它在自己内部私有的MySQL分支中有一个叫做“准入控制”的特殊功能。如果可能的话,在这本书的第4版我们将分享一些线程池的知识,以及什么时候它们可以工作,什么时候不能工作。\n8.6.2 MyISAM并发配置 # 在某些条件下,MyISAM也允许并发插入和读取,这使得可以“调度”某些操作以尽可能少地产生阻塞。\n在讲述MyISAM的并发设置之前,理解MyISAM是怎样删除和插入行的,是非常重要的。删除操作不会重新整理整个表,它们只是把行标记为删除,在表中留下“空洞”。MyISAM倾向于在可能的时候填满这些空洞,在插入行时重新利用这些空间。如果没有空洞了,它就把新行插入表的末尾。\n尽管MyISAM是表级锁,它依然可以一边读取,一边并发追加新行。这种情况下只能读取到查询开始时的所有数据,新插入的数据是不可见的。这样可以避免不一致读。\n然而,若表中间的某些数据变动了的话,还是难以提供一致读。MVCC是解决这个问题最流行的方法:一旦修改者创建了新版本,它就让读取者读数据的旧版本。可是, MyISAM并不像InnoDB那样支持MVCC,所以除非插入操作在表的末尾,否则不能支持并发插入。\n通过设置concurrent_insert这个变量,可以配置MyISAM打开并发插入,可以配置为如下值:\n0\nMyISAM不允许并发插入,所有插入都会对表加互斥锁。\n1\n这是默认值。只要表中没有空洞,MyISAM就允许并发插入。\n2\n这个值在MySQL 5.0以及更新版本中有效。它强制并发插入到表的末尾,即使表中有空洞。如果没有线程从表中读取数据,MySQL将把新行放在空洞里。使用这个设置通常会使表更加碎片化。\n如果合并操作可以更加高效,也可以配置MySQL对一些操作进行延迟。举个实例,可以通过delay_key_write变量延迟写索引,正如这一章前面我们提到的。这牵涉到熟悉的权衡:立即写索引(安全但是昂贵),或者等待但是祈求在写发生前别断电(更快,但是遇到崩溃时可能引起巨大的索引损坏,因为索引文件已经过期了)。\n也可以让INSERT、REPLACE、DELETE、以及UPDATE语句的优先级比SELECT语句更低,设置low_priority_updates选项就可以了。这相当于把LOW_PRIORITY修饰符应用到全局UPDATE语句。当使用MyISAM时,这是个非常重要的选项,这让SELECT语句可以获得相当好的并发度,否则一小部分获取高优先级写锁的语句就可能导致SELECT无法获取资源。\n最后,尽管InnoDB的扩展性问题更经常被提及,但是MyISAM一样也有长时间获取Mutex的问题。在MySQL 4.0和更早版本里,有一个全局的Mutex保护所有的键缓存I/O,在多处理器和多磁盘环境下很容易引起扩展性问题。MySQL 4.1的键缓存代码做了改进,就不再有这些问题了,但是它依然对每个键缓冲区持有一个Mutex。当一个线程从键缓冲中复制键数据块到本地磁盘时会有竞争,从磁盘上读取时就没这个问题。磁盘瓶颈没了,但是当你在键缓冲里访问数据时,另一个瓶颈出现了。有时可以围绕这个问题把键缓冲分成多个区,但是这条路不总是行得通。例如,只涉及一个独立索引的时候,这问题就没有办法解决。于是,在多处理器的机器上SELECT查询并发可能相对单CPU的机器显著下降,即使当时只有这些SELECT查询在执行。\nMariaDB提供分开的(分区的)键缓冲,如果经常遇到这个问题,也许可以带来帮助。\n8.7 基于工作负载的配置 # 配置服务器的一个目标是把它定制得符合特定的工作负载。这需要精通所有类型的服务器活动的数量、类型,以及频率——不仅仅是查询语句,也包括其他的活动,例如连接服务器以及刷新表。\n第一件应该做的事情是熟悉你的服务器,如果还没做就赶紧。了解什么样的查询跑在上面。用例如innotop这样的工具来监控它,用pt-query-digest来创建查询报告。这不仅帮助你全面地了解服务器正在做什么,还可以知道查询花费大量时间做了哪些事。第3章阐明了怎么把这些东西找出来。\n当服务器在满载情况下运行时,请尝试记录所有的查询语句,因为这是最好的方式来查看哪种类型的查询语句占用资源最多。同时,创建processlist快照,通过state或者command字段来聚合它们(innotop可以实现,或者可以使用第3章展示的脚本)。例如,是否大量地在复制数据到临时表,或者排序数据?如果有,也许需要优化查询语句,以及查看临时表和排序缓冲配置项。\n8.7.1 优化BLOB和TEXT的场景 # BLOB和TEXT列对MySQL来说是特殊类型的场景(我们把所有BLOB和TEXT都简单称为BLOB类型,因为它们属于相同类型的数据)。BLOB值有几个限制使得服务器对它的处理跟其他类型不一样。一个最重要的注意事项是,服务器不能在内存临时表中存储BLOB值(21),因此,如果一个查询涉及BLOB值,又需要使用临时表——不管它多小——它都会立即在磁盘上创建临时表。这样效率很低,尤其是对小而快的查询。临时表可能是查询中最大的开销。\n有两种办法来减轻这个不利的情况:通过SUBSTRING()函数(第4章有更多关于这个函数的细节)把值转换为VARCHAR,或者让临时表更快一些。\n让临时表运行更快的最好方式是,把它们放在基于内存的文件系统(GNU/Linux上是tmpfs)。这会降低一些开销,尽管这依然比内存表慢许多。因为操作系统会避免把数据写到磁盘,所以内存文件系统可以帮助提升性能(22)。一般的文件系统也会在内存中缓存,但是操作系统会每隔几秒就刷新一次。tmpfs文件系统从来不会刷新,它就是为低开销和简单起见而设计的。例如,没必要为这个文件系统预备任何恢复方案。这使得它更快。\n服务器设置里控制临时表文件放在哪的是tmpdir。建议监控文件系统使用率以保证有足够的空间存放临时表。如果需要,可以指定多个临时表存放位置,MySQL将会轮询使用。\n如果BLOB列非常大,并且用的是InnoDB,也许可以调大InnoDB日志缓冲大小。在这一章前面有更多关于这方面的内容。\n对于很长的变长列(例如,BLOB、TEXT,以及长字符列),InnoDB存储一个768字节的前缀在行内(23)。如果列的值比前缀长,InnoDB会在行外分配扩展存储空间来存剩下的部分。它会分配一个完整的16KB的页,像其他所有的InnoDB页面一样,每个列都有自己的页面(不同的列不会共享扩展存储空间)。InnoDB一次只为一个列分配一个页的扩展存储空间,直到使用了超过32个页以后,就会一次性分配64个页面。\n注意,我们说过InnoDB可能会分配扩展存储空间。如果总的行长(包括大字段的完整长度)比InnoDB的最大行长限制要短(比8KB小一些),InnoDB将不会分配扩展存储空间,即使大字段(Long column)的长度超过了前缀长度。\n最后,当InnoDB更新存储在扩展存储空间中的大字段时,将不会在原来的位置更新。而是会在扩展存储空间中写一个新值到一个新的位置,并且不会删除旧的值。\n所有这一切都有以下后果:\n大字段在InnoDB里可能浪费大量空间。例如,若存储字段值只是比行的要求多了一个字节,也会使用整个页面来存储剩下的字节,浪费了页面的大部分空间。同样的,如果有一个值只是稍微超过了32个页的大小,实际上就需要使用96个页面。 扩展存储禁用了自适应哈希,因为需要完整地比较列的整个长度,才能发现是不是正确的数据(哈希帮助InnoDB非常快速地找到“猜测的位置”,但是必须检查“猜测的位置”是不是正确)。因为自适应哈希是完全的内存结构,并且直接指向Buffer Pool中访问“最”频繁的页面,但对于扩展存储空间却无法使用自适应哈希。 太长的值可能使得在查询中作为WHERE条件不能使用索引,因而执行很慢。在应用WHERE条件之前,MySQL需要把所有的列读出来,所以可能导致MySQL要求InnoDB读取很多扩展存储,然后检查WHERE条件,丢弃所有不需要的数据。查询不需要的列绝不是好主意,在这种特殊的场景下尤其需要避免这样做。如果发现查询正遇到这个限制带来的问题,可以尝试通过覆盖索引来解决部分问题。 如果一张表里有很多大字段,最好是把它们组合起来单独存到一个列里面,比如说用XML文档格式存储。这让所有的大字段共享一个扩展存储空间,这比每个字段用自己的页要好。 有时候可以把大字段用COMPRESS()压缩后再存为BLOB,或者在发送到MySQL前在应用程序中进行压缩,这可以获得显著的空间优势和性能收益。 8.7.2 优化排序(Filesorts) # 从第6章我们知道MySQL有两种排序算法。如果查询中所有需要的列和ORDER BY的列总大小超过max_length_for_sort_data字节,则采用two-pass算法。或者当任何需要的列——即使没有被ORDER BY使用的列——是BLOB或者TEXT,也会采用这个算法。(可以用SUBSTRING()把这些列转换一下,就可以用single-pass算法了。)\nMySQL有两个变量可以控制排序怎样执行。通过修改max_length_for_sort_data变量(24)的值,可以影响MySQL选择哪种排序算法。因为single-pass算法为每行需要排序的数据创建一个固定大小的缓冲,对于VARCHAR列,在和max_length_for_sort_data比较时,使用的是其定义的最大长度,而不是所存储数据的实际长度。这也是为什么我们建议只选择必要的列的一个原因。\n当MySQL必须排序BLOB或TEXT字段时,它只会使用前缀,然后忽略剩下部分的值。这是因为缓冲只能分配固定大小的结构体来保存要排序的值,然后从扩展存储空间中复制前缀到这个结构体中。使用max_sort_length变量可以指定这个前缀有多大。\n可惜,MySQL无法查看它用了哪个算法。如果增加了max_length_for_sort_data变量的值,磁盘使用率上升了,CPU使用率下降了,并且Sort_merge_passes状态变量相对于修改之前开始很快地上升,也许是强制让很多的排序使用了single-pass算法。\n8.8 完成基本配置 # 我们已经完成了服务器内核的旅程——希望你喜欢这个旅程!现在让我们回到示例配置,并且看下怎样修改剩下的配置。\n我们已经讨论了怎样设置一般的选项,例如数据目录、InnoDB和MyISAM缓存、日志,还有其他的一些。让我们重温剩下的那些:\ntmp_table_size和max_heap_table_size\n这两个设置控制使用Memory引擎的内存临时表能使用多大的内存。如果隐式内存临时表的大小超过这两个设置的值,将会被转换为磁盘MyISAM表,所以它的大小可以继续增长。(隐式临时表是一种并非由自己创建,而是服务器创建,用于保存执行中的查询的中间结果的表。)\n应该简单地把这两个变量设为同样的值。我们的示例配置文件中选择了32M。这可能不够,但是要谨防这个变量太大了。临时表最好呆在内存里,但是如果它们被撑得很大,实际上还是让它们使用磁盘比较好,否则可能会让服务器内存溢出。\n假设查询语句没有创建庞大的临时表(通常可以通过合理的索引和查询设计来避免),那把这些变量设大一点,免得需要把内存临时表转换为磁盘临时表。这个过程可以在SHOW PROCESSLIST中看到。\n可以查看服务器的SHOW STATUS计数器在某段时间内的变化,以此来查看创建临时表的频率以及是否是磁盘临时表。你不能判断一张(临时)表是先创建为内存表然后被转换为了磁盘表,还是一开始就创建的磁盘表(可能因为有BLOB字段),但是至少可以看到创建磁盘临时表有多频繁。仔细检查Created_tmp_disk_tables和Created_tmp_tables变量。\nmax_connections\n这个设置的作用就像一个紧急刹车,以保证服务器不会因应用程序激增的连接而不堪重负。如果应用程序有问题,或者服务器遇到如连接延迟的问题,会创建很多新连接。但是如果不能执行查询,那打开一个连接没有好处,所以被“太多的连接”的错误拒绝是一种快速而代价小的失败方式。\n把max_connections设置得足够高,以容纳正常可能达到的负载,并且要足够安全,能保证允许你登录和管理服务器。例如,若认为正常情况将有300或者更多连接,则可以设置为500或者更多。如果不知道将会有多少连接,500也不是一个不合理的起点。默认值是100,对大部分应用来说这都不够。\n要时时小心可能遇到连接限制的突然袭击。例如,若重新启动应用服务器,可能没有把它的连接关闭干净,同时MySQL可能没有意识到它们已经被关闭了。当应用服务器重新开始运转,并试图打开到数据库的连接,就可能由于挂起的连接还没有超时,而使新连接被拒绝。\n观察Max_used_connections状态变量随着时间的变化。这个是高水位标记,可以告诉你服务器连接是不是在某个时间点有个尖峰。如果这个值达到了max_connections,说明客户端至少被拒绝了一次,并且当它重现的时候,应该使用第3章中的技巧来抓取服务器的活动状态。\nthread_cache_size\n设置这个变量,可以通过观察服务器一段时间的活动,来计算一个有理有据的值。观察Threads_connected状态变量并且找到它在一般情况下的最大值和最小值。你也许希望把线程缓存设置得足够大,以在高峰和低谷时都足够,甚至可能更大方一些,因为就算设置得有点太大了,一般也不是大问题。你也许可以设置为波动范围两到三倍的大小。例如,若Threads_connected状态从150变化到175,可以设置线程缓存为75。但是也不用设置得非常大,因为保持大量等待连接的空闲线程并没有什么真正的用处。250的上限是个不错的估算值(或者256,如果你喜欢2的次方。)也可以观察Threads_created状态随着时间的变化。如果这个值很大或者一直增长,这是另一个线索,告诉你可能需要调大thread_cache_size变量。查看Threads_cached来看有多少线程已经在缓存中了。\n一个相关的状态变量是Slow_launch_threads。这个状态如果是个很大的值,那么意味着某些情况延迟了连接分配新线程。这也是个线索,可能服务器有些问题了,但是不能明确地指出是哪出问题了。一般来说,可能是系统过载了,导致操作系统不能为新创建的线程调度CPU。这不是说你就需要增加线程缓存的大小了。你应该诊断这个问题并且修复它,而不是用缓存来掩盖问题,因为这还可能导致其他问题。\ntable_cache_size\n这个缓存(或者在MySQL 5.1中被分成两个缓存区)应该被设置得足够大,以避免总是需要重新打开和重新解析表的定义。你可以通过观察Open_tables的值及其在一段时间的变化来检查该变量。如果你看到Opened_tables每秒变化很大,那么table_cache值可能不够大。隐式临时表也可能导致打开表的数量不断增长,即使表缓存并没有用满,所以这可能也没什么问题。\n该问题的线索应该是Opened_tables不断地增长,即使Open_tables并不跟table_cache_size一样大。\n虽然表缓存很有用,也不应该把这个变量设置得太大。表缓存可能在两种情况下适得其反。\n首先,MySQL没有一个很有效的方法来检查缓存,所以如果真的太大了,可能效率会下降。在大部分情况下,不应该把它设置得大于10000,或者是10 240,如果喜欢使用2的N次方的话。(25)\n第二个原因是有些类型的工作负载是不能缓存的。如果工作负载不是可缓存的,不管把缓存设置得多大,任何访问都无法在缓存命中,忘记缓存吧,把它设置为0!这可以避免情况变得更糟糕,缓存不命中比昂贵的缓存检查后再不命中还是要好的。什么类型的工作负载不是可缓存的?如果有几万或几十万张表,并且它们都很均匀地被使用,就不可能把它们全缓存了,最好把这个变量设得小一点。当系统上有数量非常多的并行应用而其中没有一个是非常忙碌的,有时候这是适当的。\n这个值从max_connections的10倍开始设置是比较有道理的,但是再次说明,在大部分场景下最好保持在10000以下甚至更低。\n还有其他一些类型的设置可能经常会包含在配置文件中,包括二进制日志以及复制设置。二进制日志对恢复到某个时间点,以及复制是非常有用的,另外复制还有一些它自己的设置。我们会在本书后面的章节中覆盖复制和备份的重要设置。\n8.9 安全和稳定的设置 # 基本配置设置到位后,可能希望启用一些使服务器更安全和更可靠的设置。它们中的一些会影响性能,因为保证安全性和可靠性往往要付出一些代价。有些人意识到了:他们能阻止愚蠢的错误发生,比如把无意义的数据插入服务器,以及一些变动在日常操作中没有啥区别,只是在很边缘的情况防止糟糕的事情发生。\n让我们首先来看看收集的一些对一般服务器都有用的配置项:\nexpire_logs_days\n如果启用了二进制日志,应该打开这个选项,可以让服务器在指定的天数之后清理旧的二进制日志。如果不启用,最终服务器的空间会被耗尽,导致服务器卡住或崩溃。我们建议把这个选项设置得足够从两个备份之前恢复(在最近的备份失败的情况下)。即使每天都做备份,还是建议留下7~14天的二进制日志。从我们的经验来看,当遇到一些不常见的问题时,你会感谢有这一两个星期的二进制日志。例如重搭一个备机再次尝试赶上主库。应该保持足够多的二进制日志,遇到这些情况时可以给自己一些呼吸的空间。\nmax_allowed_packet\n这个设置防止服务器发送太大的包,也会控制多大的包可以被接收。默认值可能太小了,但设置得太大也可能有危险。如果设置得太小,有时复制上会出问题,通常表现为备库不能接收主库发过来的复制数据。你也许需要增加这个设置到16MB或者更大。这些文档里没有,但这个选项也控制在一个用户定义的变量的最大值,所以如果需要非常大的变量,要小心——如果超过这个变量的大小,它们可能被截断或者设置为NULL。\nmax_connect_errors\n如果有时网络短暂抽风了,或者应用配置出现错误,或者有另外的问题,如权限,在短暂的时间内不断地尝试连接,客户端可能被列入黑名单,然后将无法连接,直到再次刷新主机缓存。这个选项的默认设置太小了,很容易导致问题。你也许希望增加这个值,实际上,如果知道服务器可以充分抵御蛮力攻击,可以把这个值设得非常大,以有效地禁用主机黑名单。\nskip_name_resolve\n这个选项禁用了另一个网络相关和鉴权认证相关的陷阱:DNS查找。DNS是MySQL连接过程中的一个薄弱环节。当连接服务器时,默认情况下,它试图确定连接和使用的主机的主机名,作为身份验证凭据的一部分。(就是说,你的凭据是用户名,主机名、以及密码——并不只是用户名和密码)但是验证主机来源,服务器需要执行DNS的正向和反向查找。要是DNS有问题就悲剧了,在某些时间点这是必然的事。当发生这样的情况时,所有事都会堆积起来,最终导致连接超时。为了避免这种情况,我们强烈建议设置这个选项,在验证时关闭DNS查找。然而,如果这么做,需要把基于主机名的授权改为用IP地址、通配符,或者特定主机名“localhost”,因为基于主机名的账号会被禁用。\nsql_mode\n这个设置可以接受多种多样的值来改变服务器行为。我们不建议只是为了好玩而改变这个值;最好在大多数情况下让MySQL像MySQL,不要尝试让它的行为像其他数据库服务器。(许多客户端和图形界面工具,除了MySQL还有它们自己的SQL方言,例如,若修改它用更符合ANSI的SQL,有些操作会没法做。)然而,有些选项值是很有用的,有些在具体情况可能是值得考虑的。建议查看文档中下面这些选项,并且考虑使用它们:STRICT_TRANS_TABLES、ERROR_FOR_DIVISION_BY_ZERO、NO_AUTO_CREATE_USER、NO_AUTO_VALUE_ON_ZERO、NO_ENGINE_SUBSTITUTION、NO_ZERO_DATE、NO_ZERO_IN_DATE和ONLY_FULL_GROUP_BY。\n然而,要意识到对已经存在的应用修改这些设置值可不是个好主意,因为这么做可能让服务器跟应用预期不兼容。人们不经意间写的查询中应用的列不在GROUP BY中,或者使用聚合函数,这种情况非常常见,例如,若想打开ONLY_FULL_GROUP_BY选项,最好首先在开发或未上线服务器上做一下测试,一旦要在生产环境部署则必须确认所有地方都可以工作。\nsysdate_is_now\n这是另一个可能导致与应用预期向后不兼容的选项。但如果不是明确需要SYSDATE()函数的非确定性行为(非确定性行为可能会导致复制中断或者使得基于时间点的备份恢复结果不可信),那么你可能希望打开该选项以确保SYSDATE()函数有确定的行为。\n下面的选项可以控制复制行为,并且对防止备库出问题非常有帮助:\nread_only\n这个选项禁止没有特权的用户在备库做变更,只接受从主库传输过来的变更,不接受从应用来的变更。我们强烈建议把备库设置为只读模式。\nskip_slave_start\n这个选项阻止MySQL试图自动启动复制。因为在不安全的崩溃或其他问题后,启动复制是不安全的,所以需要禁用自动启动,用户需要手动检查服务器,并确定它是安全的之后再开始复制。\nslave_net_timeout\n这个选项控制备库发现跟主库的连接已经失败并且需要重连之前等待的时间。默认值是一个小时,太长了。设置为一分钟或更短。\nsync_master_info、sync_relay_log、sync_relay_log_info\n这些选项,在MySQL 5.5以及更新版本中可用,解决了复制中备库长期存在的问题:不把它们的状态文件同步到磁盘,所以服务器崩溃后可能需要人来猜测复制的位置实际上在主库是哪个位置,并且可能在中继日志(Relay Log)里有损坏。这些选项使得备库崩溃后,更容易从崩溃中恢复。这些选项默认是不打开的,因为它们会导致备库额外的fsync()操作,可能会降低性能。如果有很好的硬件,我们建议打开这些选项,如果复制中出现fsync()造成的延时问题,就应该关闭它们。\nPercona Server中有一种侵入性更小的方式来做这些工作,即打开innodb_overwrite_relay_log_info选项。这可以让InnoDB在事务日志中存储复制的位置,这是完全事务化的,并且不需要任何额外的fsync()操作。在崩溃恢复期间, InnoDB会检查复制的元信息文件,如果文件过期了就更新为正确的位置。\n8.10 高级InnoDB设置 # 回到第1章我们讨论的InnoDB历史:首先是内建(built-in)的版本,然后有了两个有效版本,现在更新的版本再次变成了一个。更新的InnoDB代码有更多的功能和非常好的扩展性。如果正在使用MySQL 5.1,应该明确地配置MySQL忽略旧版本的InnoDB而使用新版的。这将极大地提升服务器性能。需要打开ignore_builtin_innodb选项,然后配置plugin_load选项把InnoDB作为插件打开。建议参考InnoDB文档中对应平台上的扩展语法(26)。\n对于新版本的InnoDB,有一些新的选项可以用。如果启用,它们中有些对服务器性能相当重要,也有一些安全性和稳定性的选项,如下所示。\ninnodb\n这个看似平淡无奇的选项实际上非常重要,如果把这个值设置为FORCE,只有在InnoDB可以启动时,服务器才会启动。如果使用InnoDB作为默认存储引擎,这一定是你期望的结果。你应该不会希望在InnoDB失败(例如因为错误的配置而导致的不可启动)的情况下启动服务器,因为写的不好的应用可能之后会连接到服务器,导致一些无法预知的损失和混乱。最好是整个服务器都失败,强制你必须查看错误日志,而不是以为服务器正常启动了。\ninnodb_autoinc_lock_mode\n这个选项控制InnoDB如何生成自增主键值,某些情况下,例如高并发插入时,自增主键可能是个瓶颈。如果有很多事务等待自增锁(可以在SHOW ENGINE INNODB STATUS里看到),应该审视这个变量的设置。手册上已经详细解释了该选项的行为,在此我们就不再重复了。\ninnodb_buffer_pool_instances\n这个选项在MySQL 5.5和更新的版本中出现,可以把缓冲池切分为多段,这可能是在高负载的多核机器上提升MySQL可扩展性最重要的一个方式了。多个缓冲池分散了工作压力,所以一些全局Mutex竞争就没有那么大了。\n目前尚不清楚什么情况下应该选择多个缓冲池实例。我们运行过八个实例的基准,但是直到MySQL 5.5已经广泛部署了很长一段时间,我们依然不明白多个缓冲池实例的一些微妙之处。\n我们不是暗示MySQL 5.5没有在生产环境广泛部署。只是对我们已经帮助解决过的大部分互斥锁相互争用的极端场景的用户来说,升级可能需要很多个月的时间来计划、验证,并执行。这些用户有时运行着高度定制化的MySQL版本,使得更加倍谨慎地对待升级。当越来越多的这类用户升级到MySQL 5.5,并以他们独特的方式进行压力验证,我们可能会学到关于多缓冲池的一些我们没见过的有趣的事情。也许直到那时,我们才可以说运行八个缓冲池实例是非常有益的。\n值得注意的是Percona Server用了不同的方法来解决InnoDB互斥锁争用问题。相对于把缓冲池分成多个——一个在许多像InnoDB的系统下经过检验无可否认的方法——我们选择把一些全局Mutex拆分为更细、更专用的Mutex。我们的测试显示最好的方式是结合这两种方法,在Percona Server 5.5版本中已经可用了:多缓冲区和更细粒度的锁。\ninnodb_io_capacity\nInnoDB曾经在代码里写死了假设服务器运行在每秒100个I/O操作的单硬盘上。默认值很糟糕。现在可以告诉InnoDB服务器有多大的I/O能力。InnoDB有时需要把这个设置得相当高(在像PCI-E SSD这样极快的存储设备上需要设置为上万)才能稳定地刷新脏页,原因解释起来相当复杂。\ninnodb_read_io_threads和innodb_write_io_threads\n这些选项控制有多少后台线程可以被I/O操作使用。最近版本的MySQL里,默认值是4个读线程和4个写线程,对大部分服务器这都足够了,尤其是MySQL 5.5里面可以用操作系统原生的异步I/O以后。如果有很多硬盘并且工作负载并发很大,可以发现这些线程很难跟上,这种情况下可以增加线程数,或者可以简单地把这个选项的值设置为可以提供I/O能力的磁盘数量(即使后面是一个RAID控制器)。\ninnodb_strict_mode\n这个设置让MySQL在某些条件下把警告改成抛错,尤其是无效的或者可能有风险的CREATE TABLE选项。如果打开这个设置,就必然会检查所有CREATE TABLE选项,因为它不会让你创建一些用起来比较爽(但是有隐患)的表。有时这有点悲观,过于严格了。当尝试恢复备份时可能就不希望打开这个选项了。\ninnodb_old_blocks_time\nInnoDB有个两段缓冲池LRU(最近最少使用)链表,设计目的是防止换出长期使用很多次的页面。像mysqldump产生的这种一次性的(大)查询,通常会读取页面到缓冲池的LRU列表,从中读取需要的行,然后移动到下一页。理论上,两段LRU链表将阻止此页取代很长一段时间内都需要用到的页面被放入“年轻(Young)”子链表,并且只在它已被浏览过多次后将其移动到“年老(Old)”子链表。但是InnoDB默认没有配置为防止这种情况,因为页内有很多行,所以从页面读取的行的多次访问,会导致它立即被转移到“年老(Old)”子链表,对那些需要长时间缓存的页面带来换出的压力。\n这个变量指定一个页面从LRU链表的“年轻”部分转移到“年老”部分之前必须经过的毫秒数。默认情况下它设置为0,将它设为诸如1000毫秒(一秒)这样的小一点的值,在我们的基准测试中已被证明非常有效。\n8.11 总结 # 在阅读完这一章节之后,你应该有了一个比默认设置好得多的服务器配置。服务器应该更快更稳定了,并且除非运行出现了罕见的状况,都应该没有必要再去做优化配置的工作了。\n复习一下,我们建议从参考示例配置文件开始,设置符合服务器和工作负载的基本选项,增加安全性和完整性所需的选项,并且,如果合适的话,在MySQL 5.5中配置新版的InnoDB Plugin才有的配置项。这就是关于优化服务器配置所需要做的全部的事情。\n如果使用的是InnoDB,最重要的选项是下面这两个:\ninnodb_buffer_pool_size innodb_log_file_size 恭喜你——你解决了我们见过的真实存在的配置问题中的绝大部分!如果使用我们的在线配置工具 http://tools.percona.com,对这些问题和其他配置选项的使用,会得到很好的建议。\n我们也提出了很多关于不要做什么的建议。其中最重要的是不要“调优”服务器;不要使用比率、公式或“调优脚本”作为设置配置变量的基础;不要信任来自互联网上的不明身份的人的意见;不要为了看起来很糟糕的事情去不断地刷SHOW STATUS。如果有些设置其实是错误的,在剖析服务器性能时也会展现出来。\n有几个重要的设置没有在本章讨论,主要是因为它们是为特定类型的硬件和工作负载服务的。我们暂不讨论这些设置,因为我们相信,任何关于怎样设置的意见,都需要与内部流程的解释工作一起来做。这给我们带来了下一章,它会告诉你如何优化MySQL的硬件和操作系统,反之亦然。\n————————————————————\n(1) 我们见过的一个常见的错误是,配置一台新服务器的内存是另一台已经存在的服务器的两倍,并且——使用旧服务器的配置作为基线——创建一份新的配置,只是简单地在旧服务器的配置上乘以2。这不起作用。\n(2) 如果你还是不相信“按比率调优”的方法是错误的,请阅读Cary Millsap的Optimizing Oracle Performance(O\u0026rsquo;Reilly出版)。他甚至为这个主题专门写了一个附录,提供了一个可以智能地产生任何你想要的命中率的工具,甚至不管系统正运行得多么糟糕都可以做到很好的命中率!当然,这一切的目的都是为了说明比率是多么无用。\n(3) 一个例外:我们维护了一个(好用的)免费的在线配置工具,在 http://tools.percona.com。是的,我们确实有倾向性。\n(4) 问:(坏的)查询是如何形成的?答:这需要去问那些杀死了坏查询的DBA是怎么回事,查询本身是不可能回击的。\n(5) Percona当然认为在Percona邮件组能找到真正的专家,所以说他们不能中肯。——译者注\n(6) 问:为排序缓存(Sort Buffer)和读缓存(Read Buffer)设置大小的选项在哪?答:它们已经很专注自己的事情了,除非觉得默认值不够好,否则保留默认值就可以了。\n(7) InnoDB在5.1/5.5中都不支持全文索引,直到5.6版本InnoDB才支持全文索引。——译者注\n(8) 例如Join Buffer/Sort Buffer等。——译者注\n(9) 这个功能是Dump/Restore of the Buffer Pool,详情查看: http://www.percona.com/doc/percona-server/5.5/management/innodb_lru_dump_restore.html。——译者注\n(10) 当然还要排除各种操作系统自身占用的内存,还有MySQL自身占用的内存等。——译者注\n(11) 理论上,如果能确认原生4KB的数据依然在操作系统缓存中,读操作就不需要了。然而,你没法控制操作系统把哪些块放到缓存中。通过fncore工具可以看到哪些块在缓存中,地址在: http://net.doit.wisc.edu/~plonka/fncore/。\n(12) “打开的表(Opened Table)”的概念,可能有点混乱。当不同的查询同时访问一张表,或者是一个单独的查询引用同一张表超过一次,比如子查询或者自关联,MySQL都会对一张表作为打开状态多次计数。MyISAM表的索引文件包含一个计数器,MyISAM表打开时递增,关闭时递减。这使得对于MyISAM表可以看到是不是关闭干净了:如果首次打开一个表,计数器不为零,说明表没有关闭干净。\n(13) 对于好奇的人,Percona Server的innodb_recovery_stats选项可以帮助你从执行崩溃恢复的立场来理解服务器的工作负载。\n(14) 我们说的是基于旋转盘片的机械磁盘,不是SSD盘,它们的性能特点完全不一样。\n(15) RAID卡的预读控制必须在RAID卡的设置中调整。——译者注\n(16) 就是写入会在RAID卡缓存上进行缓冲,不直接写到硬盘。——译者注\n(17) 请注意,这种实现思路是一个存在很多争议的话题,请看MySQL的bug 60776来获得更多细节信息。\n(18) 表可能因为多种原因被关闭。例如,服务器因为表缓存没有空间了就会关闭表,或者有人执行了FLUSH TABLES。\n(19) 一些Debian系统会自动做这些事,像一个钟摆的摆动,朝着不同的方向不停地摇摆。只是把这个行为配置为Debian默认做的事不是一个好主意,应该由DBA来决定。\n(20) 事实上,在某些工作负载下,并发限制实现可能自己就成为了系统的瓶颈,所以有时它需要打开,但另一些时候它需要关闭。性能分析会告诉你该怎么做。\n(21) 最近版本的Percona Server对某些场景消除了这个限制。\n(22) 如果操作系统把它交换(Swap)出内存,数据依然会到磁盘。\n(23) 这个长度足够在列上创建一个255字符的索引,即使是utf8的(每个字符可能需要三个字节)。前缀是InnoDB的Antelope文件格式特有的,MySQL 5.1和更新版本中的Barracuda格式(默认不打开的)没有前缀。\n(24) 在带有LIMIT语句的查询中,MySQL 5.6会改变排序缓冲的用法,并且会修正一个可导致执行一个昂贵的安装历程而使用庞大的排序缓冲的问题,所以如果升级到了MySQL 5.6,需要特别小心地检查这些设置中任何自定义的设置。\n(25) 你听说过一个关于二进制的笑话嘛?世界上有10种人:部分是懂二进制的,部分不懂二进制。还有另外10种人:一些认为二进制/十进制的笑话有意思,一些是精虫上脑。我们不会说我们是否认为这是滑稽的。\n(26) 在Percona Server中,只有一个版本的InnoDB,并且是内建的,所以你不需要禁用一个版本然后载入另一个版本替换它。\n"},{"id":140,"href":"/zh/docs/technology/MySQL/_%E9%AB%98%E6%80%A7%E8%83%BDMySQL_/%E7%AC%AC7%E7%AB%A0MySQL%E9%AB%98%E7%BA%A7%E7%89%B9%E6%80%A7/","title":"第7章MySQL高级特性","section":"高性能 My SQL","content":"第7章 MySQL高级特性\nMySQL从5.0和5.1版本开始引入了很多高级特性,例如分区、触发器等,这对有其他关系型数据库使用背景的用户来说可能并不陌生。这些新特性吸引了很多用户开始使用MySQL。不过,这些特性的性能到底如何,还需要用户真正使用过才能知道。本章我们将为大家介绍,在真实的世界中,这些特性表现如何,而不是只简单地介绍参考手册或者宣传材料上的数据。\n7.1 分区表 # 对用户来说,分区表是一个独立的逻辑表,但是底层由多个物理子表组成。实现分区的代码实际上是对一组底层表的句柄对象(Handler Object)的封装。对分区表的请求,都会通过句柄对象转化成对存储引擎的接口调用。所以分区对于SQL层来说是一个完全封装底层实现的黑盒子,对应用是透明的,但是从底层的文件系统来看就很容易发现,每一个分区表都有一个使用#分隔命名的表文件。\nMySQL实现分区表的方式——对底层表的封装——意味着索引也是按照分区的子表定义的,而没有全局索引。这和Oracle不同,在Oracle中可以更加灵活地定义索引和表是否进行分区。\nMySQL在创建表时使用PARTITION BY子句定义每个分区存放的数据。在执行查询的时候,优化器会根据分区定义过滤那些没有我们需要数据的分区,这样查询就无须扫描所有分区——只需要查找包含需要数据的分区就可以了。\n分区的一个主要目的是将数据按照一个较粗的粒度分在不同的表中。这样做可以将相关的数据存放在一起,另外,如果想一次批量删除整个分区的数据也会变得很方便。\n在下面的场景中,分区可以起到非常大的作用:\n表非常大以至于无法全部都放在内存中,或者只在表的最后部分有热点数据,其他均是历史数据。 分区表的数据更容易维护。例如,想批量删除大量数据可以使用清除整个分区的方式。另外,还可以对一个独立分区进行优化、检查、修复等操作。 分区表的数据可以分布在不同的物理设备上,从而高效地利用多个硬件设备。 可以使用分区表来避免某些特殊的瓶颈,例如InnoDB的单个索引的互斥访问、ext3文件系统的inode锁竞争等。 如果需要,还可以备份和恢复独立的分区,这在非常大的数据集的场景下效果非常好。 MySQL的分区实现非常复杂,我们不打算介绍实现的全部细节。这里我们将专注在分区性能方面,所以如果想了解更多的关于分区的基础知识,我们建议阅读MySQL官方手册中的“分区”一节,其中介绍了很多分区相关的基础知识。另外,还可以阅读CREATE TABLE、SHOW CREATE TABLE、ALTER TABLE和INFORMATION_SCHEMA.PARTITIONS、EXPLAIN关于分区部分的介绍。分区特性使得CREATE TABLE和ALTER TABLE命令变得更加复杂了。\n分区表本身也有一些限制,下面是其中比较重要的几点:\n一个表最多只能有1024个分区。 在MySQL 5.1中,分区表达式必须是整数,或者是返回整数的表达式。在MySQL 5.5中,某些场景中可以直接使用列来进行分区。 如果分区字段中有主键或者唯一索引的列,那么所有主键列和唯一索引列都必须包含进来。 分区表中无法使用外键约束。 7.1.1 分区表的原理 # 如前所述,分区表由多个相关的底层表实现,这些底层表也是由句柄对象(Handler object)表示,所以我们也可以直接访问各个分区。存储引擎管理分区的各个底层表和管理普通表一样(所有的底层表都必须使用相同的存储引擎),分区表的索引只是在各个底层表上各自加上一个完全相同的索引。从存储引擎的角度来看,底层表和一个普通表没有任何不同,存储引擎也无须知道这是一个普通表还是一个分区表的一部分。\n分区表上的操作按照下面的操作逻辑进行:\nSELECT查询\n当查询一个分区表的时候,分区层先打开并锁住所有的底层表,优化器先判断是否可以过滤部分分区,然后再调用对应的存储引擎接口访问各个分区的数据。\nINSERT操作\n当写入一条记录时,分区层先打开并锁住所有的底层表,然后确定哪个分区接收这条记录,再将记录写入对应底层表。\nDELETE操作\n当删除一条记录时,分区层先打开并锁住所有的底层表,然后确定数据对应的分区,最后对相应底层表进行删除操作。\nUPDATE操作\n当更新一条记录时,分区层先打开并锁住所有的底层表,MySQL先确定需要更新的记录在哪个分区,然后取出数据并更新,再判断更新后的数据应该放在哪个分区,最后对底层表进行写入操作,并对原数据所在的底层表进行删除操作。\n有些操作是支持过滤的。例如,当删除一条记录时,MySQL需要先找到这条记录,如果WHERE条件恰好和分区表达式匹配,就可以将所有不包含这条记录的分区都过滤掉。这对UPDATE语句同样有效。如果是INSERT操作,则本身就是只命中一个分区,其他分区都会被过滤掉。MySQL先确定这条记录属于哪个分区,再将记录写入对应的底层分区表,无须对任何其他分区进行操作。\n虽然每个操作都会“先打开并锁住所有的底层表”,但这并不是说分区表在处理过程中是锁住全表的。如果存储引擎能够自己实现行级锁,例如InnoDB,则会在分区层释放对应表锁。这个加锁和解锁过程与普通InnoDB上的查询类似。\n后面我们会通过一些例子来看看,当访问一个分区表的时候,打开和锁住所有底层表的代价及其带来的后果。\n7.1.2 分区表的类型 # MySQL支持多种分区表。我们看到最多的是根据范围进行分区,每个分区存储落在某个范围的记录,分区表达式可以是列,也可以是包含列的表达式。例如,下表就可以将每一年的销售额存放在不同的分区里:\nCREATE TABLE sales ( order_date DATETIME NOT NULL, -- Other columns omitted ) ENGINE=InnoDB PARTITION BY RANGE(YEAR(order_date)) ( PARTITION p_2010 VALUES LESS THAN (2010), PARTITION p_2011 VALUES LESS THAN (2011), PARTITION p_2012 VALUES LESS THAN (2012), PARTITION p_catchall VALUES LESS THAN MAXVALUE ); PARTITION分区子句中可以使用各种函数。但有一个要求,表达式返回的值要是一个确定的整数,且不能是一个常数。这里我们使用函数YEAR(),也可以使用任何其他的函数,如TO_DAYS()。根据时间间隔进行分区,是一种很常见的分区方式,后面我们还会再回过头来看这个例子,看看如何优化这个例子来避免一些问题。\nMySQL还支持键值、哈希和列表分区,这其中有些还支持子分区,不过我们在生产环境中很少见到。在MySQL 5.5中,还可以使用RANGE COLUMNS类型的分区,这样即使是基于时间的分区也无须再将其转化成一个整数,后面将详细介绍。\n在我们看过的一个子分区的案例中,对一个类似于前面我们设计的按时间分区的InnoDB表,系统通过子分区可降低索引的互斥访问的竞争。最近一年的分区的数据会被非常频繁地访问,这会导致大量的互斥量的竞争。使用哈希子分区可以将数据切成多个小片,大大降低互斥量的竞争问题。\n我们还看到的一些其他的分区技术包括:\n根据键值进行分区,来减少InnoDB的互斥量竞争。 使用数学模函数来进行分区,然后将数据轮询放入不同的分区。例如,可以对日期做模7的运算,或者更简单地使用返回周几的函数,如果只想保留最近几天的数据,这样分区很方便。 假设表有一个自增的主键列id,希望根据时间将最近的热点数据集中存放。那么必须将时间戳包含在主键当中才行,而这和主键本身的意义相矛盾。这种情况下也可以使用这样的分区表达式来实现相同的目的:HASH(id DIV 1000000),这将为100万数据建立一个分区。这样一方面实现了当初的分区目的,另一方面比起使用时间范围分区还避免了一个问题,就是当超过一定阈值时,如果使用时间范围分区就必须新增分区。 7.1.3 如何使用分区表 # 假设我们希望从一个非常大的表中查询出一段时间的记录,而这个表中包含了很多年的历史数据,数据是按照时间排序的,例如,希望查询最近几个月的数据,这大约有10亿条记录。可能过些年本书会过时,不过我们还是假设使用的是2012年的硬件设备,而原表中有10TB的数据,这个数据量远大于内存,并且使用的是传统硬盘,不是闪存(多数SSD也没有这么大的空间)。你打算如何查询这个表?如何才能更高效?\n首先很肯定:因为数据量巨大,肯定不能在每次查询的时候都扫描全表。考虑到索引在空间和维护上的消耗,也不希望使用索引。即使真的使用索引,你会发现数据并不是按照想要的方式聚集的,而且会有大量的碎片产生,最终会导致一个查询产生成千上万的随机I/O,应用程序也随之僵死。情况好一点的时候,也许可以通过一两个索引解决一些问题。不过多数情况下,索引不会有任何作用。这时候只有两条路可选:让所有的查询都只在数据表上做顺序扫描,或者将数据表和索引全部都缓存在内存里。\n这里需要再陈述一遍:在数据量超大的时候,B-Tree索引就无法起作用了。除非是索引覆盖查询,否则数据库服务器需要根据索引扫描的结果回表,查询所有符合条件的记录,如果数据量巨大,这将产生大量随机I/O,随之,数据库的响应时间将大到不可接受的程度。另外,索引维护(磁盘空间、I/O操作)的代价也非常高。有些系统,如Infobright,意识到这一点,于是就完全放弃使用B-Tree索引,而选择了一些更粗粒度的但消耗更少的方式检索数据,例如在大量数据上只索引对应的一小块元数据。\n这正是分区要做的事情。理解分区时还可以将其当作索引的最初形态,以代价非常小的方式定位到需要的数据在哪一片“区域”。在这片“区域”中,你可以做顺序扫描,可以建索引,还可以将数据都缓存到内存,等等。因为分区无须额外的数据结构记录每个分区有哪些数据——分区不需要精确定位每条数据的位置,也就无须额外的数据结构——所以其代价非常低。只需要一个简单的表达式就可以表达每个分区存放的是什么数据。\n为了保证大数据量的可扩展性,一般有下面两个策略:\n全量扫描数据,不要任何索引。\n可以使用简单的分区方式存放表,不要任何索引,根据分区的规则大致定位需要的数据位置。只要能够使用WHERE条件,将需要的数据限制在少数分区中,则效率是很高的。当然,也需要做一些简单的运算保证查询的响应时间能够满足需求。使用该策略假设不用将数据完全放入到内存中,同时还假设需要的数据全都在磁盘上,因为内存相对很小,数据很快会被挤出内存,所以缓存起不了任何作用。这个策略适用于以正常的方式访问大量数据的时候。警告:后面我们会详细解释,必须将查询需要扫描的分区个数限制在一个很小的数量。\n索引数据,并分离热点。\n如果数据有明显的“热点”,而且除了这部分数据,其他数据很少被访问到,那么可以将这部分热点数据单独放在一个分区中,让这个分区的数据能够有机会都缓存在内存中。这样查询就可以只访问一个很小的分区表,能够使用索引,也能够有效地使用缓存。\n仅仅知道这些还不够,MySQL的分区表实现还有很多陷阱。下面我们看看都有哪些,以及如何避免。\n7.1.4 什么情况下会出问题 # 上面我们介绍的两个分区策略都基于两个非常重要的假设:查询都能够过滤(prunning)掉很多额外的分区、分区本身并不会带来很多额外的代价。而事实证明,这两个假设在某些场景下会有问题。下面介绍一些可能会遇到的问题。\nNULL值会使分区过滤无效\n关于分区表一个容易让人误解的地方就是分区的表达式的值可以是NULL:第一个分区是一个特殊分区。假设按照PARTITION BY RANGE YEAR(order_date)分区,那么所有order_date为NULL或者是一个非法值的时候,记录都会被存放到第一个分区(1)。现在假设有下面的查询:WHERE order_date BETWEEN \u0026lsquo;2012-01-01\u0026rsquo; AND \u0026lsquo;2012-01-31\u0026rsquo;。实际上,MySQL会检查两个分区,而不是之前猜想的一个:它会检查2012年这个分区,同时它还会检查这个表的第一个分区。检查第一个分区是因为YEAR()函数在接收非法值的时候可能会返回NULL值,那么这个范围的值可能会返回NULL而被存放到第一个分区了。这一点对于其他很多函数,例如TO_DAYS()也一样。(2)\n如果第一个分区非常大,特别是当使用“全量扫描数据,不要任何索引”的策略时,代价会非常大。而且扫描两个分区来查找列也不是我们使用分区表的初衷。为了避免这种情况,可以创建一个“无用”的第一个分区,例如,上面的例子中可以使用PARTITION p_nulls VALUES LESS THAN(0)来创建第一个分区。如果插入表中的数据都是有效的,那么第一个分区就是空的,这样即使需要检测第一个分区,代价也会非常小。\n在MySQL 5.5中就不需要这个优化技巧了,因为可以直接使用列本身而不是基于列的函数进行分区:PARTITION BY RANGE COLUMNS(order_date)。所以这个案例最好的解决方法是能够直接使用MySQL 5.5的这个语法。\n分区列和索引列不匹配\n如果定义的索引列和分区列不匹配,会导致查询无法进行分区过滤。假设在列a上定义了索引,而在列b上进行分区。因为每个分区都有其独立的索引,所以扫描列b上的索引就需要扫描每一个分区内对应的索引。如果每个分区内对应索引的非叶子节点都在内存中,那么扫描的速度还可以接受,但如果能跳过某些分区索引当然会更好。要避免这个问题,应该避免建立和分区列不匹配的索引,除非查询中还同时包含了可以过滤分区的条件。\n听起来避免这个问题很简单,不过有时候也会遇到一些意想不到的问题。例如,在一个关联查询中,分区表在关联顺序中是第二个表,并且关联使用的索引和分区条件并不匹配。那么关联时针对第一个表符合条件的每一行,都需要访问并搜索第二个表的所有分区。\n选择分区的成本可能很高\n如前所述分区有很多类型,不同类型分区的实现方式也不同,所以它们的性能也各不相同。尤其是范围分区,对于回答“这一行属于哪个分区”、“这些符合查询条件的行在哪些分区”这样的问题的成本可能会非常高,因为服务器需要扫描所有的分区定义的列表来找到正确的答案。类似这样的线性搜索的效率不高,所以随着分区数的增长,成本会越来越高。\n我们所实际碰到的类似这样的最糟糕的一次问题是按行写入大量数据的时候。每写入一行数据到范围分区的表时,都需要扫描分区定义列表来找到合适的目标分区。可以通过限制分区的数量来缓解此问题,根据实践经验,对大多数系统来说,100个左右的分区是没有问题的。\n其他的分区类型,比如键分区和哈希分区,则没有这样的问题。\n打开并锁住所有底层表的成本可能很高\n当查询访问分区表的时候,MySQL需要打开并锁住所有的底层表,这是分区表的另一个开销。这个操作在分区过滤之前发生,所以无法通过分区过滤降低此开销,并且该开销也和分区类型无关,会影响所有的查询。这一点对一些本身操作非常快的查询,比如根据主键查找单行,会带来明显的额外开销。可以用批量操作的方式来降低单个操作的此类开销,例如使用批量插入或者LOAD DATA INFILE、一次删除多行数据,等等。当然同时还是需要限制分区的个数。\n维护分区的成本可能很高\n某些分区维护操作的速度会非常快,例如新增或者删除分区(当删除一个大分区可能会很慢,不过这是另一回事)。而有些操作,例如重组分区或者类似ALTER语句的操作:这类操作需要复制数据。重组分区的原理与ALTER类似,先创建一个临时的分区,然后将数据复制到其中,最后再删除原分区。\n如上所述,分区表不是什么“银弹”。下面是目前分区实现中的一些其他限制:\n所有分区都必须使用相同的存储引擎。 分区函数中可以使用的函数和表达式也有一些限制。 某些存储引擎不支持分区。 对于MyISAM的分区表,不能再使用LOAD INDEX INTO CACHE操作。 对于MyISAM表,使用分区表时需要打开更多的文件描述符。虽然看起来是一个表,其实背后有很多独立的分区,每一个分区对于存储引擎来说都是一个独立的表。这样即使分区表只占用一个表缓存条目,文件描述符还是需要多个。因此,即使已经配置了合适的表缓存,以确保不会超过操作系统的单个进程可以打开的文件描述符的个数,但对于分区表而言,还是会出现超过文件描述符限制的问题。 最后,需要指出的是较老版本的MySQL问题会更多些。所有的软件都是有bug的。分区表在MySQL 5.1中引入,在后面的5.1.40和5.1.50之后修复了很多分区表的bug。在MySQL 5.5中,分区表又做了很多改进,这才使得分区表可以逐步考虑用在生产环境了。在即将发布的MySQL 5.6版本中,分区表做了更多的增强,例如新引入的ALTER TABLE EXCHANGE PARTITION。\n7.1.5 查询优化 # 引入分区给查询优化带来了一些新的思路(同时也带来新的bug)。分区最大的优点就是优化器可以根据分区函数来过滤一些分区。根据粗粒度索引的优势,通过分区过滤通常可以让查询扫描更少的数据(在某些场景下)。\n所以,对于访问分区表来说,很重要的一点是要在WHERE条件中带入分区列,有时候即使看似多余的也要带上,这样就可以让优化器能够过滤掉无须访问的分区。如果没有这些条件,MySQL就需要让对应存储引擎访问这个表的所有分区,如果表非常大的话,就可能会非常慢。\n使用EXPLAIN PARTITION可以观察优化器是否执行了分区过滤,下面是一个示例:\nmysql\u0026gt; ** EXPLAIN PARTITIONS SELECT * FROM sales \\G** *************************** 1. row *************************** id: 1 select_type: SIMPLE table: sales_by_day partitions: p_2010,p_2011,p_2012 type: ALL possible_keys: NULL key: NULL key_len: NULL ref: NULL rows: 3 Extra: 正如你所看到的,这个查询将访问所有的分区。下面我们在WHERE条件中再加入一个时间限制条件:\nmysql\u0026gt; ** EXPLAIN PARTITIONS SELECT * FROM sales_by_day WHERE day \u0026lt; '2011-01-01'\\G** *************************** 1. row *************************** id: 1 select_type: SIMPLE table: sales_by_day partitions: p_2011,p_2012 MySQL优化器已经很善于过滤分区。比如它能够将范围条件转化为离散的值列表,并根据列表中的每个值过滤分区。然而,优化器也不是万能的。下面查询的WHERE条件理论上可以过滤分区,但实际上却不行:\nmysql\u0026gt; ** EXPLAIN PARTITIONS SELECT * FROM sales_by_day WHERE YEAR(day) = 2010\\G** *************************** 1. row *************************** id: 1 select_type: SIMPLE table: sales_by_day partitions: p_2010,p_2011,p_2012 MySQL只能在使用分区函数的列本身进行比较时才能过滤分区,而不能根据表达式的值去过滤分区,即使这个表达式就是分区函数也不行。这就和查询中使用独立的列才能使用索引的道理是一样的(参考第5章的相关内容)。所以只需要把上面的查询等价地改写为如下形式即可:\nmysql\u0026gt; ** EXPLAIN PARTITIONS SELECT * FROM sales_by_day** -\u0026gt; ** WHERE day BETWEEN '2010-01-01' AND '2010-12-31'\\G** *************************** 1. row *************************** id: 1 select_type: SIMPLE table: sales_by_day partitions: p_2010 这里写的WHERE条件中带入的是分区列,而不是基于分区列的表达式,所以优化器能够利用这个条件过滤部分分区。一个很重要的原则是:即便在创建分区时可以使用表达式,但在查询时却只能根据列来过滤分区。\n优化器在处理查询的过程中总是尽可能聪明地去过滤分区。例如,若分区表是关联操作中的第二张表,且关联条件是分区键,MySQL就只会在对应的分区里匹配行。(EXPLAIN无法显示这种情况下的分区过滤,因为这是运行时的分区过滤,而不是查询优化阶段的。)\n7.1.6 合并表 # 合并表(Merge table)是一种早期的、简单的分区实现,和分区表相比有一些不同的限制,并且缺乏优化。分区表严格来说是一个逻辑上的概念,用户无法访问底层的各个分区,对用户来说分区是透明的。但是合并表允许用户单独访问各个子表。分区表和优化器的结合更紧密,这也是未来发展的趋势,而合并表则是一种将被淘汰的技术,在未来的版本中可能被删除。\n和分区表类似的是,在MyISAM中各个子表可以被一个结构完全相同的逻辑表所封装。可以简单地把这个表当作一个“老的、早期的、功能有限的”的分区表,因为它自身的特性,甚至可以提供一些分区表没有的功能(3)。\n合并表相当于一个容器,里面包含了多个真实表。可以在CREATE TABLE中使用一种特别的UNION语法来指定包含哪些真实表。下面是一个创建合并表的例子:\n注意到,这里最后建立的合并表和前面的各个真实表字段完全相同,在合并表中有的索引各个真实子表也有,这是创建合并表的前提条件。另外还注意到,各个子表在对应列上都有主键限制,但是最终的合并表中仍然出现了重复值,这是合并表的另一个不足:合并表中的每一个子表行为和表定义都是相同,但是合并表在全局上并不受这些条件限制。\n这里的语法INSERT_METHOD=LAST告诉MySQL,将所有的INSERT语句都发送给最后一个表。指定FIRST或者LAST关键字是唯一可以控制行插入到合并表的哪一个子表的方式(当然,还是可以直接在SQL中明确地操作任何一个子表)。而分区表则有更多的方式可以控制数据写入到哪一个子表中。\nINSERT语句的执行结果可以在最终的合并表中看到,也可以在对应的子表中看到:\n合并表还有些有趣的限制和特性,例如,在删除合并表或者删除一个子表的时候会怎样?删除一个合并表,它的子表不会受任何影响,而如果直接删除其中一个子表则可能会有不同的后果,这要视操作系统而定。例如在GNU/Linux上,如果子表的文件描述还是被打开的状态,那么这个表还存在,但是只能通过合并表才能访问到:\n合并表还有很多其他的限制和行为,下面列举的这几点需要在使用的时候时刻记住。\n在使用CREATE语句创建一个合并表的时候,并不会检查各个子表的兼容性。如果子表的定义稍有不同,那么MySQL就可能创建出一个后面无法使用的合并表。另外,如果在成功创建了合并表后再修改某个子表的定义,那么之后再使用合并表可能会看到这样的报错:ERROR 1168 (HY000):Unable to open underlying table which is differently defined or of non-MyISAM type or doesn\u0026rsquo;t exist。\n根据合并表的特性,不难发现,在合并表上无法使用REPLACE语法,无法使用自增字段。更多的细节请参阅MySQL官方手册。\n如果一个查询访问合并表,那么它需要访问所有子表。这会让根据键查找单行的查y询速度变慢,如果能够只访问一个对应表,速度肯定将更快。所以,限制合并表中的子表数量很重要,特别是当合并表是某个关联查询的一部分的时候,因为这时访问一个表的记录数可能会将比较操作传递到关联的其他表中,这时减少记录的访问就是减少整个关联操作。当你打算使用合并表的时候,还需要记住以下几点:\n执行范围查询时,需要在每一个子表上各执行一次,这比直接访问单个表的性─能要差很多,而且子表越多,性能越糟。\n全表扫描和普通表的全表扫描速度相同。\n在合并表上做唯一键和主键查询时,一旦找到一行数据就会停止。所以一旦查─询在合并表的某一个子表中找到一行数据,就会立刻返回,不会再访问任何其他的表。\n子表的读取顺序和CREATE TABLE语句中的顺序相同。如果需要频繁地按照某个特定顺序访问表,那么可以通过这个特性来让合并排序操作更高效。\n因为合并表的各个子表可以直接被访问,所以它还具有一些MySQL 5.5分区所不能提供的特性:\n一个MyISAM表可以是多个合并表的子表。 可以通过直接复制y*.frm、.MYI、.MYD*文件,来实现在不同的服务器之间复制各个子表。 在合并表中可以很容易地添加新的子表:直接修改合并表的定义就可以了。 可以创建一个合并表,让它只包含需要的数据,例如只包含某个时间段的数据,而在分区表中是做不到这一点的。 如果想对某个子表做备份、恢复、修改、修复或者别的操作时,可以先将其从合并表中删除,操作结束后再将其加回去。 可以使用myisampack来压缩所有的子表。 相反,分区表的子表都是被MySQL隐藏的,只能通过分区表去访问子表。\n7.2 视图 # MySQL 5.0版本之后开始引入视图。视图本身是一个虚拟表,不存放任何数据。在使用SQL语句访问视图的时候,它返回的数据是MySQL从其他表中生成的。视图和表是在同一个命名空间,MySQL在很多地方对于视图和表是同样对待的。不过视图和表也有不同,例如,不能对视图创建触发器,也不能使用DROP TABLE命令删除视图。\n在MySQL官方手册中对如何创建和使用视图有详细的介绍,本书不会详细介绍这些。我们将主要介绍视图是如何实现的,以及优化器如何处理视图,通过了解这些,希望可以让大家在使用视图时获得更高的性能。我们将使用示例数据库world来演示视图是如何工作的:\nmysql\u0026gt; ** CREATE VIEW Oceania AS** -\u0026gt; ** SELECT * FROM Country WHERE Continent = 'Oceania'** -\u0026gt; ** WITH CHECK OPTION;** 实现视图最简单的方法是将SELECT语句的结果存放到临时表中。当需要访问视图的时候,直接访问这个临时表就可以了。我们先来看看下面的查询:\nmysql\u0026gt; ** SELECT Code, Name FROM Oceania WHERE Name = 'Australia';** 下面是使用临时表来模拟视图的方法。这里临时表的名字是为演示用的:\nmysql\u0026gt; ** CREATE TEMPORARY TABLE TMP_Oceania_123 AS** -\u0026gt; ** SELECT * FROM Country WHERE Continent = 'Oceania';** mysql\u0026gt; ** SELECT Code, Name FROM TMP_Oceania_123 WHERE Name = 'Australia';** 这样做会有明显的性能问题,优化器也很难优化在这个临时表上的查询。实现视图更好的方法是,重写含有视图的查询,将视图的定义SQL直接包含进查询的SQL中。下面的例子展示的是将视图定义的SQL合并进查询SQL后的样子:\nmysql\u0026gt; ** SELECT Code, Name FROM Country** -\u0026gt; ** WHERE Continent = 'Oceania' AND Name = 'Australia';** MySQL可以使用这两种办法中的任何一种来处理视图。这两种算法分别称为合并算法(MERGE)和临时表算法(TEMPTABLE)(4),如果可能,会尽可能地使用合并算法。MySQL甚至可以嵌套地定义视图,也就是在一个视图上再定义另一个视图。可以在EXPLAIN EXTENDED之后使用SHOW WARNINGS来查看使用视图的查询重写后的结果。\n如果是采用临时表算法实现的视图,EXPLAIN中会显示为派生表(DERIVED)。图7-1展示了这两种实现的细节。\n图7-1:视图的两种实现\n如果视图中包含GROUY BY、DISTINCT、任何聚合函数、UNION、子查询等,只要无法在原表记录和视图记录中建立一一映射的场景中,MySQL都将使用临时表算法来实现视图。上面列举的可能不全,而且这些规则在未来的版本中也可能会改变。如果你想确定MySQL到底是使用合并算法还是临时表算法,可以EXPLAIN一条针对视图的简单查询:\n这里的select_type为“DERIVED”,说明该视图是采用临时表算法实现的。不过要注意:如果产生的底层派生表很大,那么执行EXPLAIN可能会非常慢。因为在MySQL 5.5和更老的版本中,EXPLAIN是需要实际执行并产生该派生表的。\n视图的实现算法是视图本身的属性,和作用在视图上的查询语句无关。例如,可以为一个基于简单查询的视图指定使用临时表算法:\n** CREATE ALGORITHM=TEMPTABLE VIEW v1 AS SELECT * FROM** sakila.actor; 实现该视图的SQL本身并不需要临时表,但基于该视图无论执行什么样的查询,视图都会生成一个临时表。\n7.2.1 可更新视图 # 可更新视图(updatable view)是指可以通过更新这个视图来更新视图涉及的相关表。只要指定了合适的条件,就可以更新、删除甚至向视图中写入数据。例如,下面就是一个合理的操作:\nmysql\u0026gt; ** UPDATE Oceania SET Population = Population * 1.1 WHERE Name = 'Australia';** 如果视图定义中包含了GROUP BY、UNION、聚合函数,以及其他一些特殊情况,就不能被更新了。更新视图的查询也可以是一个关联语句,但是有一个限制,被更新的列必须来自同一个表中。另外,所有使用临时表算法实现的视图都无法被更新。\n在上一节定义视图时使用的CHECK OPTION子句,表示任何通过视图更新的行,都必须符合视图本身的WHERE条件定义。所以不能更新视图定义列以外的列,比如上例中不能更新Continent列,也不能插入不同Continent值的新数据,否则MySQL会报如下的错误:\nmysql\u0026gt; ** UPDATE Oceania SET Continent = 'Atlantis';** ERROR 1369 (HY000):CHECK OPTION failed 'world.Oceania' 某些关系数据库允许在视图上建立INSTEAD OF触发器,通过触发器可以精确控制在修改视图数据时做些什么。不过MySQL不支持在视图上建任何触发器。\n7.2.2 视图对性能的影响 # 多数人认为视图不能提升性能,实际上,在MySQL中某些情况下视图也可以帮助提升性能。而且视图还可以和其他提升性能的方式叠加使用。例如,在重构schema的时候可以使用视图,使得在修改视图底层表结构的时候,应用代码还可能继续不报错的运行。\n可以使用视图实现基于列的权限控制,却不需要真正的在系统中创建列权限,因此没有额外的开销。\nCREATE VIEW public.employeeinfo AS SELECT firstname, lastname -- but not socialsecuritynumber FROM private.employeeinfo; GRANT SELECT ON public.* TO public_user; 有时候也可以使用伪临时视图实现一些功能。MySQL虽然不能创建只在当前连接中存在的真正的临时视图,但是可以建一个特殊名字的视图,然后在连接结束的时候删除该视图。这样在连接过程中就可以在FROM子句中使用这个视图,和使用子查询的方式完全相同,因为MySQL在处理视图和处理子查询的代码路径完全不同,所以它们的性能也不同。下面是一个例子:\n-- Assuming 1234 is the result of CONNECTION_ID() CREATE VIEW temp.cost_per_day_1234 AS SELECT DATE(ts) AS day, sum(cost) AS cost FROM logs.cost GROUP BY day; SELECT c.day, c.cost, s.sales FROM temp.cost_per_day_1234 AS c INNER JOIN sales.sales_per_day AS s USING(day); DROP VIEW temp.cost_per_day_1234; 我们这里使用连接ID作为视图名字的一部分来避免冲突。在应用发生崩溃和别的意外导致未清理临时视图的时候,这个技巧使得清理临时视图变得很简单。详细的信息可以参考后面的“丢失的临时表”。\n使用临时表算法实现的视图,在某些时候性能会很糟糕(虽然可能比直接使用等效查询语句要好一点)。MySQL以递归的方式执行这类视图,先会执行外层查询,即使外层查询优化器将其优化得很好,但是MySQL优化器可能无法像其他的数据库那样做更多的内外结合的优化。外层查询的WHERE条件无法“下推”到构建视图的临时表的查询中,临时表也无法建立索引(5)。下面是一个例子,还是基于temp.cost_per_day_1234这个视图:\nmysql\u0026gt; ** SELECT c.day, c.cost, s.sales** -\u0026gt; ** FROM temp.cost_per_day_1234 AS c** -\u0026gt; ** INNER JOIN sales.sales_per_day AS s USING(day)** -\u0026gt; ** WHERE day BETWEEN '2007-01-01' AND '2007-01-31';** 在这个查询中,MySQL先执行视图的SQL生成临时表,然后再将sales_per_day和临时表进行关联。这里的WHERE子句中的BETWEEN条件并不能下推到视图当中,所以视图在创建的时候仍然需要将所有的数据都放到临时表当中,而不仅仅是一个月的数据。而且临时表中不会有索引。这个案例中,索引还不是问题:MySQL将临时表作为关联顺序中的第一个表,因此这里可以使用sales_per_day中的索引。不过,如果是对两个视图做关联的话,优化器就没有任何索引可以使用了。\n视图还引入了一些并非MySQL特有的其他问题。很多开发者以为视图很简单,但实际上其背后的逻辑可能非常复杂。开发人员如果没有意识到视图背后的复杂性,很可能会以为是在不停地重复查询一张简单的表,而没有意识到实际上是代价高昂的视图。我们见过不少案例,一条看起来很简单的查询,EXPLAIN出来却有几百行,因为其中一个或者多个表,实际上是引用了很多其他表的视图。\n如果打算使用视图来提升性能,需要做比较详细的测试。即使是合并算法实现的视图也会有额外的开销,而且视图的性能很难预测。在MySQL优化器中,视图的代码执行路径也完全不同,这部分代码测试还不够全面,可能会有一些隐藏缺陷和问题。所以,我们认为视图还不是那么成熟。例如,我们看到过这样的案例,复杂的视图和高并发的查询导致查询优化器花了大量时间在执行计划生成和统计数据阶段,这甚至会导致MySQL服务器僵死,后来通过将视图转换成等价的查询语句解决了问题。这也说明视图——即使是使用合并算法实现的——并不总是有很优化的实现。\n7.2.3 视图的限制 # 在其他的关系数据库中你可能使用过物化视图,MySQL还不支持物化视图(物化视图是指将视图结果数据存放在一个可以查看的表中,并定期从原始表中刷新数据到这个表中)。MySQL也不支持在视图中创建索引。不过,可以使用构建缓存表或者汇总表的办法来模拟物化视图和索引。可以直接使用Justin Swanhart\u0026rsquo;s的工具Flexviews来实现这个目的。参考第4章可以获得更多的相关细节。\nMySQL视图实现上也有一些让人烦恼的地方。例如,MySQL并不会保存视图定义的原始SQL语句,所以如果打算通过执行SHOW CREATE VIEW后再简单地修改其结果的方式来重新定义视图,可能会大失所望。SHOW CREATE VIEW出来的视图创建语句将以一种不友好的内部格式呈现,充满了各种转义符和引号,没有代码格式化,没有注释,也没有缩进。\n如果打算重新修改一个视图,并且没法找到视图的原始的创建语句的话,可以通过使用视图的*.frm文件的最后一行获得一些信息。如果有FILE权限,甚至可以直接使用SQL语句中的LOAD_FILE()来读取.frm*中的视图创建信息。再加上一些字符处理工作,就可以获得一个完整的视图创建语句了,感谢Roland Bouman创造性的实现:\n7.3 外键约束 # InnoDB是目前MySQL中唯一支持外键的内置存储引擎,所以如果需要外键支持那选择就不多了(PBXT也有外键支持)。\n使用外键是有成本的。比如外键通常都要求每次在修改数据时都要在另外一张表中多执行一次查找操作。虽然InnoDB强制外键使用索引,但还是无法消除这种约束检查的开销。如果外键列的选择性很低,则会导致一个非常大且选择性很低的索引。例如,在一个非常大的表上有status列,并希望限制这个状态列的取值,如果该列只能取三个值——虽然这个列本身很小,但是如果主键很大,那么这个索引就会很大——而且这个索引除了做这个外键限制,也没有任何其他的作用了。\n不过,在某些场景下,外键会提升一些性能。如果想确保两个相关表始终有一致的数据,那么使用外键比在应用程序中检查一致性的性能要高得多,此外,外键在相关数据的删除和更新上,也比在应用中维护要更高效,不过,外键维护操作是逐行进行的,所以这样的更新会比批量删除和更新要慢些。\n外键约束使得查询需要额外访问一些别的表,这也意味着需要额外的锁。如果向子表中写入一条记录,外键约束会让InnoDB检查对应的父表的记录,也就需要对父表对应记录进行加锁操作,来确保这条记录不会在这个事务完成之时就被删除了。这会导致额外的锁等待,甚至会导致一些死锁。因为没有直接访问这些表,所以这类死锁问题往往难以排查。\n有时,可以使用触发器来代替外键。对于相关数据的同时更新外键更合适,但是如果外键只是用作数值约束,那么触发器或者显式地限制取值会更好些。(这里,可以直接使用ENUM类型。)\n如果只是使用外键做约束,那通常在应用程序里实现该约束会更好。外键会带来很大的额外消耗。这里没有相关的基准测试的数据,不过我们碰到过很多案例,在对性能进行剖析时发现外键约束就是瓶颈所在,删除外键后性能立即大幅提升。\n7.4 在MySQL内部存储代码 # MySQL允许通过触发器、存储过程、函数的形式来存储代码。从MySQL 5.1开始,还可以在定时任务中存放代码,这个定时任务也被称为“事件”。存储过程和存储函数都被统称为“存储程序”。\n这四种存储代码都使用特殊的SQL语句扩展,它包含了很多过程处理语法,例如循环和条件分支等(6)。不同类型的存储代码的主要区别在于其执行的上下文——也就是其输入和输出。存储过程和存储函数都可以接收参数然后返回值,但是触发器和事件却不行。\n一般来说,存储代码是一种很好的共享和复用代码的方法。Giuseppe Maxia和其他一些人也建立了一些通用的存储过程库,在网站 http://mysql-sr-lib.sourceforge.net可以找到。不过因为不同的关系数据库都有各自的语法规则,所以不同的数据库很难复用这些存储代码(DB2是一个例外,它和MySQL基于相同的标准,有着非常类似的语法)(7)。\n这里将主要关注存储代码的性能,而不是如何实现。如果你打算学习如何编写存储过程,那么Guy Harrison和Steven Feuerstein编写的MySQL Stored Procedure Programming(O\u0026rsquo;Reilly)应该会有帮助。\n有人倡导使用存储代码,也有人反对。这里我们不站在任何一边,只是列举一下在MySQL中使用存储代码的优点和缺点。首先,它有如下优点:\n它在服务器内部执行,离数据最近,另外在服务器上执行还可以节省带宽和网络延迟。 这是一种代码重用。可以方便地统一业务规则,保证某些行为总是一致,所以也可以为应用提供一定的安全性。 它可以简化代码的维护和版本更新。 它可以帮助提升安全,比如提供更细粒度的权限控制。一个常见的例子是银行用于转移资金的存储过程:这个存储过程可以在一个事务中完成资金转移和记录用于审计的日志。应用程序也可以通过存储过程的接口访问那些没有权限的表。 服务器端可以缓存存储过程的执行计划,这对于需要反复调用的过程,会大大降低消耗。 因为是在服务器端部署的,所以备份、维护都可以在服务器端完成。所以存储程序的维护工作会很简单。它没什么外部依赖,例如,不依赖任何Perl包和其他不想在服务器上部署的外部软件。 它可以在应用开发和数据库开发人员之间更好地分工。不过最好是由数据库专家来开发存储过程,因为不是每个应用开发人员都能写出高效的SQL查询。 存储代码也有如下缺点:\nMySQL本身没有提供好用的开发和调试工具,所以编写MySQL的存储代码比其他的数据库要更难些。 较之应用程序的代码,存储代码效率要稍微差些。例如,存储代码中可以使用的函数非常有限,所以使用存储代码很难编写复杂的字符串维护功能,也很难实现太复杂的逻辑。 存储代码可能会给应用程序代码的部署带来额外的复杂性。原本只需要部署应用代码和库表结构变更,现在还需要额外地部署MySQL内部的存储代码。 因为存储程序都部署在服务器内,所以可能有安全隐患。如果将非标准的加密功能放在存储程序中,那么若数据库被攻破,数据也就泄漏了。但是若将加密函数放在应用程序代码中,那么攻击者必须同时攻破程序和数据库才能获得数据。 存储过程会给数据库服务器增加额外的压力,而数据库服务器的扩展性相比应用服务器要差很多。 MySQL并没有什么选项可以控制存储程序的资源消耗,所以在存储过程中的一个小错误,可能直接把服务器拖死。 存储代码在MySQL中的实现也有很多限制——执行计划缓存是连接级别的,游标的物化和临时表相同,在MySQL 5.5版本之前,异常处理也非常困难,等等。(我们会在介绍它的各个特性的同时介绍相关的限制)。简而言之,较之T-SQL或者PL/SQL,MySQL的存储代码功能还非常非常弱。 调试MySQL的存储过程是一件很困难的事情。如果慢日志只是给出CALL XYZ(\u0026lsquo;A\u0026rsquo;),通常很难定位到底是什么导致的问题,这时不得不看看存储过程中的SQL语句是如何编写的。(这在Percona Server中可以通过参数控制。) 它和基于语句的二进制日志复制合作得并不好。在基于语句的复制中,使用存储代码通常有很多的陷阱,除非你在这方面的经验非常丰富或者非常有耐心排查这类问题,否则需要谨慎使用。 这个缺陷列表很长——那么在真实世界中,这意味着什么?我们来看一个真实世界中弄巧成拙的案例:在一个实例中,创建了一个存储过程来给应用程序访问数据库中的数据,这使得所有的数据访问都需要通过这个接口,甚至很多根据主键的查询也是如此,这大概使系统的性能降低了五倍左右。\n最后,存储代码是一种帮助应用隐藏复杂性,使得应用开发更简单的方法。不过,它的性能可能更低,而且会给MySQL的复制等增加潜在的风险。所以当你打算使用存储过程的时候,需要问问自己,到底希望程序逻辑在哪儿实现:是数据库中还是应用代码中?这两种做法都可以,也都很流行。只是当你编写存储代码的时候,你需要明白这是将程序逻辑放在数据库中。\n7.4.1 存储过程和函数 # MySQL的架构本身和优化器的特性使得存储代码有一些天然的限制,它的性能也一定程度受限于此。在本书编写的时候,有如下的限制:\n优化器无法使用关键字DETERMINISTIC来优化单个查询中多次调用存储函数的情况。 优化器无法评估存储函数的执行成本。 每个连接都有独立的存储过程的执行计划缓存。如果有多个连接需要调用同一个存储过程,将会浪费缓存空间来反复缓存同样的执行计划。(如果使用的是连接池或者是持久化连接,那么执行计划缓存可能会有更长的生命周期。) 存储程序和复制是一组诡异组合。如果可以,最好不要复制对存储程序的调用。直接复制由存储程序改变的数据则会更好。MySQL 5.1引入的行复制能够改善这个问题。如果在MySQL 5.0中开启了二进制日志,那么要么在所有的存储过程中都增加DETERMINISTIC限制或者设置MySQL的选项log_bin_trust_function_creators。 我们通常会希望存储程序越小、越简单越好。希望将更加复杂的处理逻辑交给上层的应用实现,通常这样会使代码更易读、易维护,也会更灵活。这样做也会让你拥有更多的计算资源,潜在的还会让你拥有更多的缓存资源(8)。\n不过,对于某些操作,存储过程比其他的实现要快得多——特别是当一个存储过程调用可以代替很多小查询的时候。如果查询很小,相比这个查询执行的成本,解析和网络开销就变得非常明显。为了证明这一点,我们先创建一个简单的存储过程,用来写入一定数量的数据到一个表中,下面是存储过程的代码:\n1 DROP PROCEDURE IF EXISTS insert_many_rows; 2 3 delimiter // 4 5 CREATE PROCEDURE insert_many_rows (IN loops INT) 6 BEGIN 7 DECLARE v1 INT; 8 SET v1=loops; 9 WHILE v1 \u0026gt; 0 DO 10 INSERT INTO test_table values(NULL,0, 11 'qqqqqqqqqqwwwwwwwwwweeeeeeeeeerrrrrrrrrrtttttttttt', 12 'qqqqqqqqqqwwwwwwwwwweeeeeeeeeerrrrrrrrrrtttttttttt'); 13 SET v1 = v1 - 1; 14 END WHILE; 15 END; 16 // 17 18 delimiter ; 然后对该存储过程执行基准测试,看插入一百万条记录的时间,并和通过客户端程序逐条插入一百万条记录的时间进行对比。这里表结构和硬件并不重要——重要的是两种方式的相对速度。另外,我们还测试了使用MySQL Proxy连接MySQL来执行客户端程序测试的性能。为了让事情简单,整个测试在一台服务器上完成,包括客户端程序和MySQL Proxy实例。表7-1展示了测试结果。\n表7-1:写入一百万数据所花费的总时间 写入方式 总消耗时间 存储过程 101 sec 客户端程序 279 sec 使用MySQL Proxy的客户端程序 307 sec\n可以看到存储过程要快很多,很大程度因为它无须网络通信开销、解析开销和优化器开销等。\n我们将在本章的后半部分介绍如何维护存储过程。\n7.4.2 触发器 # 触发器可以让你在执行INSERT、UPDATE或者DELETE的时候,执行一些特定的操作。可以在MySQL中指定是在SQL语句执行前触发还是在执行后触发。触发器本身没有返回值,不过它们可以读取或者改变触发SQL语句所影响的数据。所以,可以使用触发器实现一些强制限制,或者某些业务逻辑,否则,就需要在应用程序中实现这些逻辑。\n因为使用触发器可以减少客户端和服务器之间的通信,所以触发器可以简化应用逻辑,还可以提高性能。另外,还可以用于自动更新反范式化数据或者汇总表数据。例如,在示例数据库Sakila中,我们可以使用触发器来维护film_text表。\nMySQL触发器的实现非常简单,所以功能也有限。如果你在其他数据库产品中已经重度依赖触发器,那么在使用MySQL的时候需要注意,很多时候MySQL触发器的表现和预想的并不一样。特别需要注意以下几点:\n对每一个表的每一个事件,最多只能定义一个触发器(换句话说,不能在AFTER INSERT上定义两个触发器)。 MySQL只支持“基于行的触发”——也就是说,触发器始终是针对一条记录的,而不是针对整个SQL语句的。如果变更的数据集非常大的话,效率会很低。 下面这些触发器本身的限制也适用于MySQL:\n触发器可以掩盖服务器背后的工作,一个简单的SQL语句背后,因为触发器,可能包含了很多看不见的工作。例如,触发器可能会更新另一个相关表,那么这个触发器会让这条SQL影响的记录数翻一倍。 触发器的问题也很难排查,如果某个性能问题和触发器相关,会很难分析和定位。 触发器可能导致死锁和锁等待。如果触发器失败,那么原来的SQL语句也会失败。如果没有意识到这其中是触发器在搞鬼,那么很难理解服务器抛出的错误代码是什么意思。 如果仅考虑性能,那么MySQL触发器的实现中对服务器限制最大的就是它的“基于行的触发”设计。因为性能的原因,很多时候无法使用触发器来维护汇总和缓存表。使用触发器而不是批量更新的一个重要原因就是,使用触发器可以保证数据总是一致的。\n触发器并不能一定保证更新的原子性。例如,一个触发器在更新MyISAM表的时候,如果遇到什么错误,是没有办法做回滚操作的。这时,触发器可以抛出错误。假设你在一个MyISAM表上建立一个AFTER UPDATE的触发器,用来更新另一个MyISAM表。如果触发器在更新第二个表的时候遇到错误导致更新失败,那么第一个表的更新并不会回滚。\n在InnoDB表上的触发器是在同一个事务中完成的,所以它们执行的操作是原子的,原操作和触发器操作会同时失败或者成功。不过,如果在InnoDB表上建触发器去检查数据的一致性,需要特别小心MVCC,稍不小心,你可能会获得错误的结果。假设,你想实现外键约束,但是不打算使用InnoDB的外键约束。若打算编写一个BEFORE INSERT触发器来检查写入的数据对应列在另一个表中是存在的,但若你在触发器中没有使用SELECT FOR UPDATE,那么并发的更新语句可能会立刻更新对应记录,导致数据不一致。我们不是危言耸听,让大家不要使用触发器。相反,触发器非常有用,尤其是实现一些约束、系统维护任务,以及更新反范式化数据的时候。\n还可以使用触发器来记录数据变更日志。这对实现一些自定义的复制会非常方便,比如需要先断开连接,然后修改数据,最后再将所有的修改重新合并回去的情况。一个简单的例子是,一组用户各自在自己的个人电脑上工作,但他们的操作都需要同步到一台主数据库上,然后主数据库会将他们所有人的操作都分发给每个人。实现这个系统需要做两次同步操作。触发器就是构建整个系统的一个好办法。每个人的电脑上都可以使用一个触发器来记录每一次数据的修改,并将其发送到主数据库中。然后,再使用MySQL的复制将主数据库上的所有操作都复制一份到本地并应用。这里需要额外注意的是,如果触发器基于有自增主键的记录,并且使用的是基于语句的复制,那么自增长可能会在复制中出现不一致。\n有时候可以使用一些技巧绕过触发器是“基于行的触发”这个限制。Roland Bouman发现,对于BEFORE触发器除了处理的第一条记录,触发器函数ROW_COUNT()总是会返回1。可以使用这个特点,使得触发器不再是针对每一行都运行,而是针对一条SQL语句运行一次。这和真正意义上的单条SQL语句的触发器并不相同,不过可以使用这个技术来模拟单条SQL语句的BEFORE触发器。这个行为可能是MySQL的一个缺陷,未来版本中可能会被修复,所以在使用这个技巧的时候,需要先验证在你的MySQL版本中是否适用,另外,在升级数据库的时候还需要检查这类触发器是否还能够正常工作。下面是一个使用这个技巧的例子:\nCREATE TRIGGER fake_statement_trigger BEFORE INSERT ON sometable FOR EACH ROW BEGIN DECLARE v_row_count INT DEFAULT ROW_COUNT(); IF v_row_count \u0026lt;\u0026gt; 1 THEN -- Your code here END IF; END; 7.4.3 事件 # 事件是MySQL 5.1引入的一种新的存储代码的方式。它类似于Linux的定时任务,不过是完全在MySQL内部实现的。你可以创建事件,指定MySQL在某个时候执行一段SQL代码,或者每隔一个时间间隔执行一段SQL代码。通常,我们会把复杂的SQL都封装到一个存储过程中,这样事件在执行的时候只需要做一个简单的CALL调用。\n事件在一个独立事件调度线程中被初始化,这个线程和处理连接的线程没有任何关系。它不接收任何参数,也没有任何的返回值。可以在MySQL的日志中看到命令的执行日志,还可以在表INFORMATION_SCHEMA.EVENTS中看到各个事件状态,例如这个事件最后一次被执行的时间等。\n类似的,一些适用于存储过程的考虑也同样适用于事件。首先,创建事件意味着给服务器带来额外工作。事件实现机制本身的开销并不大,但是事件需要执行SQL,则可能会对性能有很大的影响。更进一步,事件和其他的存储程序一样,在和基于语句的复制一起工作时,也可能会触发同样的问题。事件的一些典型应用包括定期地维护任务、重建缓存、构建汇总表来模拟物化视图,或者存储用于监控和诊断的状态值。\n下面的例子创建了一个事件,它会每周一次针对某个数据库运行一个存储过程(后面我们将展示如何创建这个存储过程):\nCREATE EVENT optimize_somedb ON SCHEDULE EVERY 1 WEEK DO CALL optimize_tables('somedb'); 你可以指定事件本身是否被复制。根据需要,有时需要被复制,有时则不需要。看前面的例子,你可能会希望在所有的备库上都运行OPTIMIZE TABLE,不过要注意如果所有的备库同时执行,可能会影响服务器的性能(会对表加锁)。\n最后,如果一个定时事件执行需要很长的时间,那么有可能会出现这样的情况,即前面一个事件还未执行完成,下一个时间点的事件又开始了。MySQL本身不会防止这种并发,所以需要用户自己编写这种情况下的防并发代码。你可以使用函数GET_LOCK()来确保当前总是只有一个事件在被执行:\nCREATE EVENT optimize_somedb ON SCHEDULE EVERY 1 WEEK DO BEGIN DECLARE CONTINUE HANLDER FOR SQLEXCEPTION BEGIN END; IF GET_LOCK('somedb', 0) THEN DO CALL optimize_tables('somedb'); END IF; DO RELEASE_LOCK('somedb'); END 这里的“CONTINUE HANLDER”用来确保,即使当事件执行出现了异样,仍然会释放持有的锁。\n虽然事件的执行是和连接无关的,但是它仍然是线程级别的。MySQL中有一个事件调度线程,必须在MySQL配置文件中设置,或者使用下面的命令来设置:\nmysql\u0026gt; ** SET GLOBAL event_scheduler := 1;** 该选项一旦设置,该线程就会执行各个用户指定的事件中的各段SQL代码。你可以通过观察MySQL的错误日志来了解事件的执行情况。\n虽然事件调度是一个单独的线程,但是事件本身是可以并行执行的。MySQL会创建一个新的进程用于事件执行。在事件的代码中,如果你调用函数CONNECTION_ID(),也会返回一个唯一值,和一般的线程返回值一样——虽然事件和MySQL的连接线程是无关的(这里的函数CONNECTION_ID()返回的只是线程ID)。这里的进程和线程生命周期就是事件的执行过程。可以通过SHOW PROCESSLIST中的Command列来查看,这些线程的该列总是显示为“Connect”。\n虽然事件处理进程需要创建一个线程来真正地执行事件,但该线程在时间执行结束后会被销毁,而不会放到线程缓存中,并且状态值Threads_created也不会被增加。\n7.4.4 在存储程序中保留注释 # 存储过程、存储函数、触发器、事件通常都会包含大量的重要代码,在这些代码中加上注释就非常有必要了。但是这些注释可能不会存储在MySQL服务器中,因为MySQL的命令行客户端会自动过滤注释(命令行客户端的这个“特性”令人生厌,不过这就是生活)。\n一个将注释存储到存储程序中的技巧就是使用版本相关的注释,因为这样的注释可能被MySQL服务器执行(例如,只有版本号大于某个值的时候才执行的代码)。服务器和客户端都知道这不是普通的注释,所以也就不会删除这些注释。为了让这样的“版本相关的代码”不被执行,可以指定一个非常大的版本号,例如99 999。我们现在给触发器加上一些注释文档,让它更易读:\nCREATE TRIGGER fake_statement_trigger BEFORE INSERT ON sometable FOR EACH ROW BEGIN DECLARE v_row_count INT DEFAULT ROW_COUNT(); ** /*!99999 ROW_COUNT() is 1 except for the first row, so this executes** ** only once per statement. */** IF v_row_count \u0026lt;\u0026gt; 1 THEN -- Your code here END IF; END; 7.5 游标 # MySQL在服务器端提供只读的、单向的游标,而且只能在存储过程或者更底层的客户端API中使用。因为MySQL游标中指向的对象都是存储在临时表中而不是实际查询到的数据,所以MySQL游标总是只读的。它可以逐行指向查询结果,然后让程序做进一步的处理。在一个存储过程中,可以有多个游标,也可以在循环中“嵌套”地使用游标。MySQL的游标设计也为粗心的人“准备”了陷阱。因为是使用临时表实现的,所以它在效率上给开发人员一个错觉。需要记住的最重要的一点是:当你打开一个游标的时候需要执行整个查询。考虑下面的存储过程:\n1 CREATE PROCEDURE bad_cursor() 2 BEGIN 3 DECLARE film_id INT; 4 DECLARE f CURSOR FOR SELECT film_id FROM sakila.film; 5 OPEN f; 6 FETCH f INTO film_id; 7 CLOSE f; 8 END 从这个例子中可以看到,不用处理完所有的数据就可以立刻关闭游标。使用Oracle或者SQL Server的用户不会认为这个存储过程有什么问题,但是在MySQL中,这会带来很多的不必要的额外操作。使用SHOW STATUS来诊断这个存储过程,可以看到它需要做1000个索引页的读取,做1000个写入。这是因为在表sakila.film中有1000条记录,而所有这些读和写都发生在第五行的打开游标动作。\n这个案例告诉我们,如果在关闭游标的时候你只是扫描一个大结果集的一小部分,那么存储过程可能不仅没有减少开销,相反带来了大量的额外开销。这时,你需要考虑使用LIMIT来限制返回的结果集。\n游标也会让MySQL执行一些额外的I/O操作,而这些操作的效率可能非常低。因为临时内存表不支持BLOB和TEXT类型,如果游标返回的结果包含这样的列的话,MySQL就必须创建临时磁盘表来存放,这样性能可能会很糟。即使没有这样的列,当临时表大于tmp_table_size的时候,MyQL也还是会在磁盘上创建临时表。\nMySQL不支持客户端的游标,不过客户端API可以通过缓存全部查询结果的方式模拟客户端的游标。这和直接将结果放在一个内存数组中来维护并没有什么不同。参考第6章,你可以看到更多关于一次性读取整个结果集到客户端时的性能。\n7.6 绑定变量 # 从MySQL 4.1版本开始,就支持服务器端的绑定变量(prepared statement),这大大提高了客户端和服务器端数据传输的效率。你若使用一个支持新协议的客户端,如MySQL CAPI,就可以使用绑定变量功能了。另外,Java和.NET的也都可以使用各自的客户端Connector/J和Connector/NET来使用绑定变量。最后,还有一个SQL接口用于支持绑定变量,后面我们将讨论这个(这里容易引起困扰)。\n当创建一个绑定变量SQL时,客户端向服务器发送了一个SQL语句的原型。服务器端收到这个SQL语句框架后,解析并存储这个SQL语句的部分执行计划,返回给客户端一个SQL语句处理句柄。以后每次执行这类查询,客户端都指定使用这个句柄。\n绑定变量的SQL,使用问号标记可以接收参数的位置,当真正需要执行具体查询的时候,则使用具体值代替这些问号。例如,下面是一个绑定变量的SQL语句:\nINSERT INTO tbl(col1, col2, col3) VALUES (?, ?, ?); 可以通过向服务器端发送各个问号的取值和这个SQL的句柄来执行一个具体的查询。反复使用这样的方式执行具体的查询,这正是绑定变量的优势所在。具体如何发送取值参数和SQL句柄,则和各个客户端的编程语言有关。使用Java和.NET的MySQL连接器就是一种办法。很多使用MySQL C语言链接库的客户端可以提供类似的接口,需要根据使用的编程语言的文档来了解如何使用绑定变量。\n因为如下的原因,MySQL在使用绑定变量的时候可以更高效地执行大量的重复语句:\n在服务器端只需要解析一次SQL语句。 在服务器端某些优化器的工作只需要执行一次,因为它会缓存一部分的执行计划。 以二进制的方式只发送参数和句柄,比起每次都发送ASCII码文本效率更高,一个二进制的日期字段只需要三个字节,但如果是ASCII码则需要十个字节。不过最大的节省还是来自于BLOB和TEXT字段,绑定变量的形式可以分块传输,而无须一次性传输。二进制协议在客户端也可能节省很多内存,减少了网络开销,另外,还节省了将数据从存储原始格式转换成文本格式的开销。 仅仅是参数——而不是整个查询语句——需要发送到服务器端,所以网络开销会更小。 MySQL在存储参数的时候,直接将其存放到缓存中,不再需要在内存中多次复制。 绑定变量相对也更安全。无须在应用程序中处理转义,一则更简单了,二则也大大减少了SQL注入和攻击的风险。(任何时候都不要信任用户输入,即使是使用绑定变量的时候。)\n可以只在使用绑定变量的时候才使用二进制传输协议。如果使用普通的mysql_query()接口则不会使用二进制传输协议。还有一些客户端让你使用绑定变量,先发送带参数的绑定SQL,然后发送变量值,但是实际上,这些客户端只是模拟了绑定变量的接口,最后还是会直接用具体值代替参数后,再使用mysql_query()发送整个查询语句。\n7.6.1 绑定变量的优化 # 对使用绑定变量的SQL,MySQL能够缓存其部分执行计划,如果某些执行计划需要根据传入的参数来计算时,MySQL就无法缓存这部分的执行计划。根据优化器什么时候工作,可以将优化分为三类。在本书编写的时候,下面的三点是适用的。\n在准备阶段\n服务器解析SQL语句,移除不可能的条件,并且重写子查询。\n在第一次执行的时候\n如果可能的话,服务器先简化嵌套循环的关联,并将外关联转化成内关联。\n在每次SQL语句执行时\n服务器做如下事情:\n过滤分区。 如果可能的话,尽量移除COUNT()、MIN()和MAX()。 移除常数表达式。 检测常量表。 做必要的等值传播。 分析和优化ref、range和索引优化等访问数据的方法。 优化关联顺序。 参考第6章,可以了解更多关于这些优化的信息。理论上,有些优化只需要做一次,但实际上,上面的操作还是都会被执行。\n7.6.2 SQL接口的绑定变量 # 在4.1和更新的版本中,MySQL支持了SQL接口的绑定变量。不使用二进制传输协议也可以直接以SQL的方式使用绑定变量。下面案例展示了如何使用SQL接口的绑定变量:\n当服务器收到这些SQL语句后,先会像一般客户端的链接库一样将其翻译成对应的操作。\n这意味着你无须使用二进制协议也可以使用绑定变量。\n正如你看到的,比起直接编写的SQL语句,这里的语法看起来有一些怪怪的。那么,这种写法实现的绑定变量到底有什么优势呢?\n最主要的用途就是在存储过程中使用。在MySQL 5.0版本中,就可以在存储过程中使用绑定变量,其语法和前面介绍的SQL接口的绑定变量类似。这意味,可以在存储过程中构建并执行“动态”的SQL语句,这里的“动态”是指可以通过灵活地拼接字符串等参数构建SQL语句。例如,下面的示例存储过程中可以针对某个数据库执行OPTIMIZE TABLE的操作:\nDROP PROCEDURE IF EXISTS optimize_tables; DELIMITER // CREATE PROCEDURE optimize_tables(db_name VARCHAR(64)) BEGIN DECLARE t VARCHAR(64); DECLARE done INT DEFAULT 0; DECLARE c CURSOR FOR SELECT table_name FROM INFORMATION_SCHEMA.TABLES WHERE TABLE_SCHEMA = db_name AND TABLE_TYPE = 'BASE TABLE'; DECLARE CONTINUE HANDLER FOR SQLSTATE '02000' SET done = 1; OPEN c; tables_loop: LOOP FETCH c INTO t; IF done THEN LEAVE tables_loop; END IF; SET @stmt_text := CONCAT(\u0026quot;OPTIMIZE TABLE \u0026quot;, db_name, \u0026quot;.\u0026quot;, t); PREPARE stmt FROM @stmt_text; EXECUTE stmt; DEALLOCATE PREPARE stmt; END LOOP; CLOSE c; END// DELIMITER ; 可以这样调用这个存储过程:\nmysql\u0026gt; ** CALL optimize_tables('sakila')** 另一种实现存储过程中循环的办法是:\nREPEAT FETCH c INTO t; IF NOT done THEN SET @stmt_text := CONCAT(\u0026quot;OPTIMIZE TABLE \u0026quot;, db_name, \u0026quot;.\u0026quot;, t); PREPARE stmt FROM @stmt_text; EXECUTE stmt; DEALLOCATE PREPARE stmt; END IF; UNTIL done END REPEAT; 这两种循环结构最重要的区别在于:REPEAT会为每个循环检查两次循环条件。在这个例子中,因为循环条件检查的是一个整数判断,并不会有什么性能问题,如果循环的判断条件非常复杂的话,则需要注意这两者的区别。\n像这样使用SQL接口的绑定变量拼接表名和库名是很常见的,这样的好处是无须使用任何参数就能完成SQL语句。而库名和表名都是关键字,在二进制协议的绑定变量中是不能将这两部分参数化的。另一个经常需要动态设置的就是LIMIT子句,因为二进制协议中也无法将这个值参数化。\n另外,编写存储过程时,SQL接口的绑定变量通常可以很大程度地帮助我们调试绑定变量,如果不是在存储过程中,SQL接口的绑定变量就不是那么有用了。因为SQL接口的绑定变量,它既没有使用二进制传输协议,也没有能够节省带宽,相反还总是需要增加至少一次额外网络传输才能完成一次查询。所有只有在某些特殊的场景下SQL接口的绑定变量才有用,比如当SQL语句非常非常长,并且需要多次执行的时候。\n7.6.3 绑定变量的限制 # 关于绑定变量的一些限制和注意事项如下:\n绑定变量是会话级别的,所以连接之间不能共用绑定变量句柄。同样地,一旦连接断开,则原来的句柄也不能再使用了。(连接池和持久化连接可以在一定程度上缓解这个问题。) 在MySQL 5.1版本之前,绑定变量的SQL是不能使用查询缓存的。 并不是所有的时候使用绑定变量都能获得更好的性能。如果只是执行一次SQL,那么使用绑定变量方式无疑比直接执行多了一次额外的准备阶段消耗,而且还需要一次额外的网络开销。(要正确地使用绑定变量,还需要在使用完成后,释放相关的资源。) 当前版本下,还不能在存储函数中使用绑定变量(但是存储过程中可以使用)。 如果总是忘记释放绑定变量资源,则在服务器端很容易发生资源“泄漏”。绑定变量 SQL总数的限制是一个全局限制,所以某一个地方的错误可能会对所有其他的线程都产生影响。 有些操作,如BEGIN,无法在绑定变量中完成。 不过使用绑定变量最大的障碍可能是:它是如何实现以及原理是怎样的,这两点很容易让人困惑。有时,很难解释如下三种绑定变量类型之间的区别是什么:\n客户端模拟的绑定变量\n客户端的驱动程序接收一个带参数的SQL,再将指定的值带入其中,最后将完整的查询发送到服务器端。\n服务器端的绑定变量\n客户端使用特殊的二进制协议将带参数的字符串发送到服务器端,然后使用二进制协议将具体的参数值发送给服务器端并执行。\nSQL接口的绑定变量\n客户端先发送一个带参数的字符串到服务器端,这类似于使用PREPARE的SQL语句,然后发送设置参数的SQL,最后使用EXECUTE来执行SQL。所有这些都使用普通的文本传输协议。\n7.7 用户自定义函数 # 从很早开始,MySQL就支持用户自定义函数(UDF)。存储过程只能使用SQL来编写,而UDF没有这个限制,你可以使用支持C语言调用约定的任何编程语言来实现。\nUDF必须事先编译好并动态链接到服务器上,这种平台相关性使得UDF在很多方面都很强大。UDF速度非常快,而且可以访问大量操作系统的功能,还可以使用大量库函数。使用SQL实现的存储函数在实现一些简单操作上很有优势,诸如计算球体上两点之间的距离,但是如果操作涉及到网络交互,那么只能使用UDF了。同样地,如果需要一个MySQL不支持的统计聚合函数,而且无法使用SQL编写的存储函数来实现的话,通常使用UDF是很容易实现的。\n能力越大,责任越大。所以在UDF中的一个错误很可能会让服务器直接崩溃,甚至扰乱服务器的内存或者数据,另外,所有C语言具有的潜在风险,UDF也都有。\n和使用SQL语言编写存储程序不同,UDF无法读写数据表——至少,无法在调用UDF的线程中使用当前事务处理的上下文来读写数据表。这意味着,它更适合用作计算或者与外面的世界交互。MySQL已经支持越来越多的方式和外面的资源交互了。Brian Aker和Patrick Galbraith创建的与memcached通信的函数就是一个UDF很好的案例(参考: http://tangent.org/586/Memcached_Functions_for_MySQL.html)。\n如果打算使用UDF,那么在MySQL版本升级的时候需要特别注意做相应的改变,因为很可能需要重新编译这些UDF,或者甚至需要修改UDF来让它能在新的版本中工作。还需要注意的是,你需要确保UDF是线程安全的,因为它们需要在MySQL中执行,而MySQL是一个纯粹的多线程环境。\n现在已经有很多写好的UDF直接提供给MySQL使用,还有很多UDF的示例可供参考,以便完成自己的UDF。现在UDF最大的仓库是 http://www.mysqludf.org。\n下面是一个用户自定义函数NOW_USEC()的代码,这个函数在第10章中我们将用它来测量复制的速度:\n#include \u0026lt;my_global.h\u0026gt; #include \u0026lt;my_sys.h\u0026gt; #include \u0026lt;mysql.h\u0026gt; #include \u0026lt;stdio.h\u0026gt; #include \u0026lt;sys/time.h\u0026gt; #include \u0026lt;time.h\u0026gt; #include \u0026lt;unistd.h\u0026gt; extern \u0026quot;C\u0026quot; { my_bool now_usec_init(UDF_INIT *initid, UDF_ARGS *args, char *message); char *now_usec( UDF_INIT *initid, UDF_ARGS *args, char *result, unsigned long *length, char *is_null, char *error); } my_bool now_usec_init(UDF_INIT *initid, UDF_ARGS *args, char *message) { return 0; } char *now_usec(UDF_INIT *initid, UDF_ARGS *args, char *result, unsigned long *length, char *is_null, char *error) { struct timeval tv; struct tm* ptm; char time_string[20]; /* e.g. \u0026quot;2006-04-27 17:10:52\u0026quot; */ char *usec_time_string = result; time_t t; /* Obtain the time of day, and convert it to a tm struct. */ gettimeofday (\u0026amp;tv, NULL); t = (time_t)tv.tv_sec; ptm = localtime (\u0026amp;t); /* Format the date and time, down to a single second. */ | Chapter 7: Advanced MySQL Features strftime (time_string, sizeof (time_string), \u0026quot;%Y-%m-%d %H:%M:%S\u0026quot;, ptm); /* Print the formatted time, in seconds, followed by a decimal point * and the microseconds. */ sprintf(usec_time_string, \u0026quot;%s.%06ld\\n\u0026quot;, time_string, tv.tv_usec); *length = 26; return(usec_time_string); } 参考前一章中的案例学习,可以看到如何使用用户自定义函数来解决一些棘手的问题。我们在Percona Toolkit中也使用了UDF来完成一些工作,例如高效的数据复制校验,或者在Sphinx索引之前使用UDF来预处理一些问题等。UDF是一款非常强大的工具。\n7.8 插件 # 除了UDF,MySQL还支持各种各样的插件。这些插件可以在MySQL中新增启动选项和状态值,还可以新增INFORMATION_SCHEMA表,或者在MySQL的后台执行任务,等等。在MySQL 5.1和更新的版本中,MySQL新增了很多的插件接口,使得你无须直接修改MySQL的源代码就可以大大扩展它的功能。下面是一个简单的插件列表。\n存储过程插件\n存储过程插件可以帮你在存储过程运行后再处理一次运行结果。这是一个很古老的插件了,和UDF有些类似,多数人都可能忘记了这个插件的存在。内置的PROCEDURE ANALYSE就是一个很好的示例。\n后台插件\n后台插件可以让你的程序在MySQL中运行,可以实现自己的网络监听、执行自己的定期任务。后台插件的一个典型例子就是在Percona Server中包含的Handler-Socket插件。它监听一个新的网络端口,使用一个简单的协议可以帮你无须使用SQL接口直接访问InnoDB数据,这也使得MySQL能够像一些NoSQL一样具有非常高的性能。\nINFORMATION_SCHEMA插件\n这个插件可以提供一个新的内存INFORMATION_SCHEMA表。\n全文解析插件\n这个插件提供一种处理文本的功能,可以根据自己的需求来对一个文档进行分词,所以如果给定一个PDF文档目录,可以使用这个插件对这个文档进行分词处理。也可以用此来增强查询执行过程中的词语匹配功能。\n审计插件\n审计插件在查询执行的过程中的某些固定点被调用,所以它可以用作(例如)记录MySQL的事件日志。\n认证插件\n认证插件既可以在MySQL客户端也可在它的服务器端,可以使用这类插件来扩展MySQL的认证功能,例如可以实现PAM和LDAP认证。\n要了解更多细节,可以参考MySQL的官方手册,或者读读由Sergei Golubchik和Andrew Hutchings (Packt)编写的MySQL 5.1 Plugin Development。如果你需要一个插件,但是却不知道怎么实现,有很多公司都提供这类咨询服务,例如Monty Program、Open Query、Percona和SkySQL。\n7.9 字符集和校对 # 字符集是指一种从二进制编码到某类字符符号的映射,可以参考如何使用一个字节来表示英文字母。“校对”是指一组用于某个字符集的排序规则。MySQL 4.1和之后的版本中,每一类编码字符都有其对应的字符集和校对规则(9)。MySQL对各种字符集的支持非常完善,但是这也带来了一定的复杂性,某些场景下甚至会有一定的性能牺牲。(另外,曾经Drizzle放弃了所有的字符集,所有字符全部统一使用UTF-8。)\n本节将解释在实际使用中,你可能最需要的一些设置和功能。如果想了解更多细节,可以详细地阅读MySQL官方手册的相关章节。\n7.9.1 MySQL如何使用字符集 # 每种字符集都可能有多种校对规则,并且都有一个默认的校对规则。每个校对规则都是针对某个特定的字符集的,和其他的字符集没有关系。校对规则和字符集总是一起使用的,所以后面我们将这样的组合也统称为一个字符集。\nMySQL有很多的选项用于控制字符集。这些选项和字符集很容易混淆,一定要记住:只有基于字符的值才真正的“有”字符集的概念。对于其他类型的值,字符集只是一个设置,指定用哪一种字符集来做比较或者其他操作。基于字符的值能存放在某列中、查询的字符串中、表达式的计算结果中或者某个用户变量中,等等。\nMySQL的设置可以分为两类:创建对象时的默认值、在服务器和客户端通信时的设置。\n创建对象时的默认设置 # MySQL服务器有默认的字符集和校对规则,每个数据库也有自己的默认值,每个表也有自己的默认值。这是一个逐层继承的默认设置,最终最靠底层的默认设置将影响你创建的对象。这些默认值,至上而下地告诉MySQL应该使用什么字符集来存储某个列。\n在这个“阶梯”的每一层,你都可以指定一个特定的字符集或者让服务器使用它的默认值:\n创建数据库的时候,将根据服务器上的character_set_server设置来设定该数据库的默认字符集。 创建表的时候,将根据数据库的字符集设置指定这个表的字符集设置。 创建列的时候,将根据表的设置指定列的字符集设置。 需要记住的是,真正存放数据的是列,所以更高“阶梯”的设置只是指定默认值。一个表的默认字符集设置无法影响存储在这个表中某个列的值。只有当创建列而没有为列指定字符集的时候,如果没有指定字符集,表的默认字符集才有作用。\n服务器和客户端通信时的设置 # 当服务器和客户端通信的时候,它们可能使用不同的字符集。这时,服务器端将进行必要的翻译转换工作:\n服务器端总是假设客户端是按照character_set_client设置的字符来传输数据和SQL语句的。 当服务器收到客户端的SQL语句时,它先将其转换成字符集character_set_connection。它还使用这个设置来决定如何将数据转换成字符串。 当服务器端返回数据或者错误信息给客户端时,它会将其转换成character_set_result。 图7-2展示了这个过程。\n图7-2:客户端和服务器的字符集\n根据需要,可以使用SET NAMES或者SET CHARACTER SET语句来改变上面的设置。不过在服务器上使用这个命令只能改变服务器端的设置。客户端程序和客户端的API也需要使用正确的字符集才能避免在通信时出现问题。\n假设使用latin1字符集(这是默认字符集)打开一个连接,并使用SET NAMES utf8来告诉服务器客户端将使用UTF-8字符集来传输数据。这样就创建了一个不匹配的字符集,可能会导致一些错误甚至出现一些安全性问题。应当先设置客户端字符集然后使用函数mysql_real_escape_string()在需要的时候进行转义。在PHP中,可以使用mysql_set_charset()来修改客户端的字符集。\nMySQL如何比较两个字符串的大小 # 如果比较的两个字符串的字符集不同,MySQL会先将其转成同一个字符集再进行比较。如果两个字符集不兼容的话,则会抛出错误,例如“ERROR 1267(HY000):Illegal mix of collations”。这种情况下需要通过函数CONVERT()显式地将其中一个字符串的字符集转成一个兼容的字符集。MySQL 5.0和更新的版本经常会做这样的隐式转换,所以这类错误通常是在MySQL 4.1中比较常见。\nMySQL还会为每个字符串设置一个“可转换性”(10)。这个设置决定了值的字符集的优先级,因而会影响MySQL做字符集隐式转换后的值。另外,也可以使用函数CHARSET()、COLLATION()、和COERCIBILITY()来定位各种字符集相关的错误。\n还可以使用前缀和COLLATE子句来指定字符串的字符集或者校对字符集。例如,下面的示例中使用了前缀(由下画线开始)来指定utf8字符集,还使用了COLLATE子句指定了使用二进制校对规则:\n一些特殊情况 # MySQL的字符集行为中还是有一些隐藏的“惊喜”的。下面列举了一些需要注意的地方:\n诡异的character_set_database设置\ncharacter_set_database设置的默认值和默认数据库的设置相同。当改变默认数据库的时候,这个变量也会跟着变。所以当连接到MySQL实例上又没有指定要使用的数据库时,默认值会和character_set_server相同。\nLOAD DATA INFILE\n当使用LOAD DATA INFILE的时候,数据库总是将文件中的字符按照字符集character_set_database来解析。在MySQL 5.0和更新的版本中,可以在LOAD DATA INFILE中使用子句CHARACTER SET来设定字符集,不过最好不要依赖这个设定。我们发现指定字符集最好的方式是先使用USE指定数据库,再执行SET NAMES来设定字符集,最后再加载数据。MySQL在加载数据的时候,总是以同样的字符集处理所有数据,而不管表中的列是否有不同的字符集设定。\nSELECT INTO OUTFILE\nMySQL会将SELECT INTO OUTFILE的结果不做任何转码地写入文件。目前,除了使用函数CONVERT()将所有的列都做一次转码外,还没有什么别的办法能够指定输出的字符集。\n嵌入式转义序列\nMySQL会根据character_set_client的设置来解析转义序列,即使是字符串中包含前缀或者COLLATE子句也一样。这是因为解析器在处理字符串中的转义字符时,完全不关心校对规则——对解析器来说,前缀并不是一个指令,它只是一个关键字而已。\n7.9.2 选择字符集和校对规则 # MySQL 4.1和之后的版本支持很多的字符集和校对规则,包括支持使用Unicode编码的多字节UTF-8字符集(MySQL支持UTF-8的一个三字节子集,这几乎可以包含世界上的所有字符集)。可以使用命令SHOW CHARACTERSET和SHOW COLLATION来查看MySQL支持的字符集和校对规则。\n极简原则\n在一个数据库中使用多个不同的字符集是一件很让人头疼的事情,字符集之间的不兼容问题会很难缠。有时候,一切都看起来正常,但是当某个特殊字符出现的时候,所有类型的操作都可能会无法进行(例如多表之间的关联)。你可以使用ALTER TABLE命令将对应列转成相互兼容的字符集,还可以使用编码前缀和COLLATE子句将对应的列值转成兼容的编码。\n正确的方法是,最好先为服务器(或者数据库)选择一个合理的字符集。然后根据不同的实际情况,让某些列选择合适的字符集。\n对于校对规则通常需要考虑的一个问题是,是否以大小写敏感的方式比较字符串,或者是以字符串编码的二进制值来比较大小。它们对应的校对规则的前缀分别是_cs、_ci和_bin,根据需要很容易选择。大小写敏感和二进制校对规则的不同之处在于,二进制校对规则直接使用字符的字节进行比较,而大小写敏感的校对规则在多字节字符集时,如德语,有更复杂的比较规则。\n在显式设置字符集的时候,并不是必须同时指定字符集和校对规则的名字。如果缺失了其中一个或者两个,MySQL会使用可能的默认值来进行填充。表7-2表示了MySQL如何选择字符集和校对规则。\n表7-2:MySQL如何选择字符集和校对规则 用户设置 返回结果的字符集 返回结果的校对规则 同时设置字符集和校对规则 与用户设置相同 与用户设置相同 仅设置字符集 与用户设置相同 与字符集的默认校对规则相同 仅设置校对规则 与校对规则对应的字符集相同 与用户设置相同 都未设置 使用默认值 使用默认值\n下面的命令展示了在创建数据库、表、列的时候如何显式地指定字符集和校对规则:\nCREATE DATABASE d CHARSET latin1; CREATE TABLE d.t( col1 CHAR(1), col2 CHAR(1) CHARSET utf8, col3 CHAR(1) COLLATE latin1_bin ) DEFAULT CHARSET=cp1251; 这个表最后的字符集和校对规则如下:\n7.9.3 字符集和校对规则如何影响查询 # 某些字符集和校对规则可能会需要更多的CPU操作,可能会消耗更多的内存和存储空间,甚至还会影响索引的正常使用。所以在选择字符集的时候,也有一些需要注意的地方。\n不同的字符集和校对规则之间的转换可能会带来额外的系统开销。例如,数据表sakila.film在列title上有索引,可以加速下面的ORDER BY查询:\nmysql\u0026gt; ** EXPLAIN SELECT title, release_year FROM sakila.film ORDER BY title\\Gmysql\u0026gt; EXPLAIN SELECT title, release_year FROM sakila.film ORDER BY title\\G** *************************** 1. row *************************** id: 1 select_type: SIMPLE table: film type: index possible_keys: NULL key: idx_title key_len: 767 ref: NULL rows: 953 Extra: 只有排序查询要求的字符集与服务器数据的字符集相同的时候,才能使用索引进行排序。索引根据数据列的校对规则(11)进行排序,这里使用的是utf8_general_ci。如果希望使用别的校对规则进行排序,那么MySQL就需要使用文件排序:\nmysql\u0026gt; ** EXPLAIN SELECT title, release_year** -\u0026gt; ** FROM sakila.film ORDER BY title COLLATE utf8_bin\\G** *************************** 1. row *************************** id: 1 select_type: SIMPLE table: film type: ALL possible_keys: NULL key: NULL key_len: NULL ref: NULL rows: 953 Extra: ** Using filesort** 为了能够适应各种字符集,包括客户端字符集、在查询中显式指定的字符集,MySQL会在需要的时候进行字符集转换。例如,当使用两个字符集不同的列来关联两个表的时候,MySQL会尝试转换其中一个列的字符集。这和在数据列外面封装一个函数一样,会让MySQL无法使用这个列上的索引。如果你不确定MySQL内部是否做了这种转换,可以在EXPLAIN EXTENDED后使用SHOW WARNINGS来查看MySQL是如何处理的。从输出中可以看到查询中使用的字符集,也可以看出MySQL是否做了字符集转换操作。\nUTF-8是一种多字节编码,它存储一个字符会使用变长的字节数(一到三个字节)。在MySQL内部,通常使用一个定长的空间来存储字符串,再进行相关操作,这样做的目的是希望总是保证缓存中有足够的空间来存储字符串。例如,一个编码是UTF-8的CHAR(10)需要30个字节,即使最终存储的时候没有存储任何“多字节”字符也是一样。变长的字段类型(VARCHAR TEXT)存储在磁盘上时不会有这个困扰,但当它存储在临时表中用来处理或者排序时,也总是会分配最大可能的长度。\n在多字节字符集中,一个字符不再是一个字节。所以,在MySQL中有两个函数LENGTH()和CHAR_LENGTH()来计算字符串的长度,在多字节字符集中,这两个函数的返回结果会不同。如果使用的是多字节字符集,那么确保在统计字符集的时候使用CHAR_LENGTH()。(例如需要做SUBSTRING()操作的时候)。其实,在应用程序中也同样要注意多字节字符集的这个问题。\n另一个“惊喜”可能是关于索引限制方面的。如果要索引一个UTF-8字符集的列, MySQL会假设每一个字符都是三个字节,所以最长索引前缀的限制一下缩短到原来的三分之一了:\n注意到,MySQL的索引前缀自动缩短到333个字符了:\nmysql\u0026gt; ** SHOW CREATE TABLE big_string\\G** *************************** 1. row *************************** Table: big_string Create Table: CREATE TABLE `big_string` ( `str` varchar(500) default NULL, KEY `str` (`str`(333)) ) ENGINE=MyISAM DEFAULT CHARSET=utf8 如果你不注意警告信息也没有再重新检查表的定义,可能不会注意到这里仅仅是在该列的前缀上建立了索引。这会对MySQL使用索引有一些影响,例如无法使用索引覆盖扫描。\n也有人建议,直接使用UTF-8字符集,“整个世界都清净了”。不过从性能的角度来看这不是一个好主意。根据存储的数据,很多应用无须使用UTF-8字符集,如果坚持使用UTF-8,只会消耗更多的磁盘空间。\n在考虑使用什么字符集的时候,需要根据存储的具体内存来决定。例如,存储的内容主要是英文字符,那么即使使用UTF-8也不会消耗太多的存储空间,因为英文字符在UTF-8字符集中仍然使用一个字节。但如果需要存储一些非拉丁语系的字符,如俄语、阿拉伯语,那么区别会很大。如果应用中只需要存储阿拉伯语,那么可以使用cp1256字符集,这个字符集可以用一个字节表示所有的阿拉伯语字符。如果还需要存储别的语言,那么就应该使用UTF-8了,这时相同的阿拉伯语字符会消耗更多的空间。类似地,当从某个具体的语种编码转换成UTF-8时,存储空间的使用会相应增加。如果使用的是InnoDB表,那么字符集的改变可能导致数据的大小超过可以在页内存储的临界值,需要保存在额外的外部存储区,这会导致很严重的空间浪费,还会带来很多空间碎片。\n有时候根本不需要使用任何的字符集。通常只有在做大小写无关的比较、排序、字符串操作(例如SUBSTRING()的时候才需要使用字符集。如果你的数据库不关心字符集,那么可以直接将所有的东西存储到二进制列中,包括UTF-8编码数据也可以存储在其中。这么做,可能还需要一个列记录字符的编码集。虽然很多人一直都是这么用的,但还是有不少事项需要注意。这会导致很多难以排查的错误,例如,忘记了多个字节才是一个字符时,还继续使用SUBSTRING()和LENGTH()做字符串操作,就会出错。如果可能,我们建议尽量不要这样做。\n7.10 全文索引 # 通过数值比较、范围过滤等就可以完成绝大多数我们需要的查询了。但是,如果你希望通过关键字的匹配来进行查询过滤,那么就需要基于相似度的查询,而不是原来的精确数值比较。全文索引就是为这种场景设计的。\n全文索引有着自己独特的语法。没有索引也可以工作,如果有索引效率会更高。用于全文搜索的索引有着独特的结构,帮助这类查询找到匹配某些关键字的记录。\n你可能没有在意过全文索引,不过至少应该对一种全文索引技术比较熟悉:互联网搜索引擎。虽然这类搜索引擎的索引对象是超大量的数据,并且通常其背后都不是关系型数据库,不过全文索引的基本原理都是一样的。\n全文索引可以支持各种字符内容的搜索(包括CHAR、VARCHAR和TEXT类型),也支持自然语言搜索和布尔搜索。在MySQL中全文索引有很多的限制(12),其实现也很复杂,但是因为它是MySQL内置的功能,而且满足很多基本的搜索需求,所以它的应用仍然非常广泛。本章我们将介绍如何使用全文索引,以及如何为应用设计更高性能的全文索引。在本书编写时,在标准的MySQL中,只有MyISAM引擎支持全文索引。不过在还没有正式发布的MySQL 5.6中,InnoDB已经实验性质地支持全文索引了。除此,还有第三方的存储引擎,如Groonga,也支持全文索引。\n事实上,MyISAM对全文索引的支持有很多的限制,例如表级别锁对性能的影响、数据文件的崩溃、崩溃后的恢复等,这使得MyISAM的全文索引对于很多应用场景并不合适。所以,多数情况下我们建议使用别的解决方案,例如Sphinx、Lucene、Solr、Groonga、Xapian或者Senna,再或者可以等MySQL 5.6版本正式发布后,直接使用InnoDB的全文索引。如果MyISAM的全文索引确实能满足应用的需求,那么可以继续阅读本节。\nMyISAM的全文索引作用对象是一个“全文集合”,这可能是某个数据表的一列,也可能是多个列。具体的,对数据表的某一条记录,MySQL会将需要索引的列全部拼接成一个字符串,然后进行索引。\nMyISAM的全文索引是一类特殊的B-Tree索引,共有两层。第一层是所有关键字,然后对于每一个关键字的第二层,包含的是一组相关的“文档指针”。全文索引不会索引文档对象中的所有词语,它会根据如下规则过滤一些词语:\n停用词列表中的词都不会被索引。默认的停用词根据通用英语的使用来设置,可以使用参数ft_stopword_file指定一组外部文件来使用自定义的停用词。 对于长度大于ft_min_word_len的词语和长度小于ft_max_word_len的词语,都不会被索引。 全文索引并不会存储关键字具体匹配在哪一列,如果需要根据不同的列来进行组合查询,那么不需要针对每一列来建立多个这类索引。\n这也意味着不能在MATCH AGAINST子句中指定哪个列的相关性更重要。通常构建一个网站的搜索引擎是需要这样的功能,例如,你可能希望优先搜索出那些在标题中出现过的文档对象。如果需要这样的功能,则需要编写更复杂的查询语句。(后面将会为大家展示如何实现。)\n7.10.1 自然语言的全文索引 # 自然语言搜索引擎将计算每一个文档对象和查询的相关度。这里,相关度是基于匹配的关键词个数,以及关键词在文档中出现的次数。在整个索引中出现次数越少的词语,匹配时的相关度就越高。相反,非常常见的单词将不会搜索,即使不在停用词列表中出现,如果一个词语在超过50%的记录中都出现了,那么自然语言搜索将不会搜索这类词语。(13)\n全文索引的语法和普通查询略有不同。可以根据WHERE子句中的MATCH AGAINST来区分查询是否使用全文索引。我们来看一个示例。在标准的数据库Sakila中,数据表film_text在字段title和description上建立了全文索引:\n下面是一个使用自然语言搜索的查询:\nMySQL将搜索词语分成两个独立的关键词进行搜索,搜索在title和description字段组成的全文索引上进行。注意,只有一条记录同时包含全部的两个关键词,有三个查询结果只包含关键字“casualties”(这是整个表中仅有的三条包含该关键词的记录),这三个结果都在结果列表的前面。这是因为查询结果是根据与关键词的相似度来进行排序的。\n和普通查询不同,这类查询自动按照相似度进行排序。在使用全文索引进行排序的时候,MySQL无法再使用索引排序。所以如果不想使用文件排序的话,那么就不要在查询中使用ORDER BY子句。\n从上面的示例可以看到,函数MATCH()将返回关键词匹配的相关度,是一个浮点数字。你可以根据相关度进行匹配,或者将此直接展现给用户。在一个查询中使用两次MATCH()函数并不会有额外的消耗,MySQL会自动识别并只进行一次搜索。不过,如果你将MATCH()函数放到ORDER BY子句中,MySQL将会使用文件排序。\n在MATCH()函数中指定的列必须和在全文索引中指定的列完全相同,否则就无法使用全文索引。这是因为全文索引不会记录关键字是来自哪一列的。\n这也意味着无法使用全文索引来查询某个关键字是否在某一列中存在。这里介绍一个绕过该问题的办法:根据关键词在多个不同列的全文索引上的相关度来算出排名值,然后依此来排序。我们可以在某一列上加上如下索引:\nmysql\u0026gt; ** ALTER TABLE film_text ADD FULLTEXT KEY(title) ;** 这样,我们可以将title匹配乘以2来提高它的相似度的权重:\n因为上面的查询需要做文件排序,所以这并不是一个高效的做法。\n7.10.2 布尔全文索引 # 在布尔搜索中,用户可以在查询中自定义某个被搜索的词语的相关性。布尔搜索通过停用词列表过滤掉那些“噪声”词,除此之外,布尔搜索还要求搜索关键词长度必须大于ft_min_word_len,同时小于ft_max_word_len(14)。搜索返回的结果是未经排序的。\n当编写一个布尔搜索查询时,可以通过一些前缀修饰符来定制搜索。表7-3列出了最常用的修饰符。\n表7-3:布尔全文索引通用修饰符 Example Meaning dinosaur 包含“dinosaur”的行rank值更高 ~dinosaur 包含“dinosaur”的行rank值更低 +dinosaur 行记录必须包含“dinosaur” -dinosaur 行记录不可以包含“dinosaur” dino* 包含以“dino”开头的单词的行rank值更高\n还可以使用其他的操作,例如使用括号分组。基于此,就可以构造出一些复杂的搜索查询。\n还是继续用sakila.film_text来举例,现在我们需要搜索既包含词“factory”又包含“casualties”的记录。在前面,我们已经使用自然语言搜索查询实现找到这两个词中的任何一个的SQL写法。使用布尔搜索查询,我们可以指定返回结果必须同时包含“factory”和“casualties”:\n查询中还可以使用括号进行“短语搜索”,让返回结果精确匹配指定的短语:\n短语搜索的速度会比较慢。只使用全文索引是无法判断是否精确匹配短语的,通常还需要查询原文确定记录中是否包含完整的短语。由于需要进行回表过滤,所以速度会很慢。\n要完成上面的查询,MySQL需要先从索引中找出所有同时包含“spirited”和“casualties”的索引条目,然后取出这些记录再判断是否是精确匹配短语。因为这个操作会先从索引中过滤出一些记录,所以通常认为这样做的速度是很快的——比LIKE操作要快很多。事实上,这样做的确很快,但是搜索的关键词不能是太常见的词语。如果搜索的关键词太常见,因为前一步的过滤会返回太多的记录需要判断,因此LIKE操作反而更快。这种情况下LIKE操作是完全的顺序读,相比索引返回值的随机读,会快很多。\n只有MyISAM引擎才能使用布尔全文索引,但并不是一定要有全文索引才能使用布尔全文搜索。当没有全文索引的时候,MySQL就通过全表扫描来实现。所以,你甚至还可以在多表上使用布尔全文索引,例如在一个关联结果上进行。只不过,因为是全表扫描,速度可能会很慢。\n7.10.3 MySQL 5.1中全文索引的变化 # 在MySQL 5.1中引入了一些和全文索引相关的改进,包括一些性能上的提升和新增插件式的解析,通过此用户可以自己定制增强搜索功能。例如,插件可以改变索引文本的方式。可以用更灵活的方式进行分词(例如,可以指定C++作为一个单独的词语)、预处理、可以对不同的文档类型进行索引(如PDF),还可以做一些自定义的词干规则。插件还可以直接影响全文搜索的工作方式——例如,直接使用词干进行搜索。\n7.10.4 全文索引的限制和替代方案 # MySQL的全文索引实现有很多的设计本身带来的限制。在某些场景下这些限制是致命的,不过也有很多办法绕过这些限制。\n例如,MySQL全文索引中只有一种判断相关性的方法:词频。索引也不会记录索引词在字符串中的位置,所以位置也就无法用在相关性上。虽然大多数情况下,尤其是数据量很小的时候,这些限制都不会影响使用,但也可能不是你所想要的。而且MySQL的全文索引也没有提供其他可选的相关性排序算法。(它无法存储基于相对位置的相关性排序数据。)\n数据量的大小也是一个问题。MySQL的全文索引只有全部在内存中的时候,性能才非常好。如果内存无法装载全部索引,那么搜索速度可能会非常慢。当你使用精确短语搜索时,想要好的性能,数据和索引都需要在内存中。相比其他的索引类型,当INSERT、UPDATE和DELETE操作进行时,全文索引的操作代价都很大:\n修改一段文本中的100个单词,需要100次索引操作,而不是一次。 一般来说列长度并不会太影响其他的索引类型,但是如果是全文索引,三个单词的文本和10000个单词的文本,性能可能会相差几个数量级。 全文索引会有更多的碎片,可能需要做更多的OPTIMIZE TABLE操作。 全文索引还会影响查询优化器的工作。索引选择、WHERE子句、ORDER BY都有可能不是按照你所预想的方式来工作:\n如果查询中使用了MATCH AGAINST子句,而对应列上又有可用的全文索引,那么MySQL就一定会使用这个全文索引。这时,即使有其他的索引可以使用,MySQL也不会去比较到底哪个索引的性能更好。所以,即使这时有更合适的索引可以使用, MySQL仍然会置之不理。 全文索引只能用作全文搜索匹配。任何其他操作,如WHERE条件比较,都必须在MySQL完成全文搜索返回记录后才能进行。这和其他普通索引不同,例如,在处理WHERE条件时,MySQL可以使用普通索引一次判断多个比较表达式。 全文索引不存储索引列的实际值。也就不可能用作索引覆盖扫描。 除了相关性排序,全文索引不能用作其他的排序。如果查询需要做相关性以外的排序操作,都需要使用文件排序。 让我们看看这些限制如何影响查询语句。来看一个例子,假设有一百万个文档记录,在文档的作者author字段上有一个普通的索引,在文档内容字段content上有全文索引。现在我们要搜索作者是123,文档中又包含特定词语的文档。很多人可能会按照下面的方式来写查询语句:\n... WHERE MATCH(content) AGAINST ('High Performance MySQL') AND author = 123; 而实际上,这样做的效率非常低。因为这里使用了MATCH AGAINST,而且恰好上面有全文索引,所以MySQL优先选择使用全文索引,即先搜索所有的文档,查找是否有包含关键词的文档,然后返回记录看看作者是否是123。所以这里也就没有使用author字段上的索引。\n一个替代方案是将author列包含到全文索引中。可以在author列的值前面附上一个不常见的前缀,然后将这个带前缀的值存放到一个单独的filters列中,并单独维护该列(也许可以使用触发器来做维护工作)。\n这样就可以扩展全文索引,使其包含filters列,上面的查询就可以改写为:\n... WHERE MATCH(content, filters) AGAINST ('High Performance MySQL +author_id_123' IN BOOLEAN MODE); 这个案例中,如果author列的选择性非常高,那么MySQL能够根据作者信息很快地将需要过滤的文档记录限制在一个很小的范围内,这个查询的效率也就会非常好。如果author列的选择性很低,那么这个替代方案的效率会比前面那个更糟,所以使用的时候要谨慎。\n全文索引有时候还可以实现一些简单的“边框”搜索。例如,希望搜索某个坐标范围时,将坐标按某种方式转换成文本再进行全文索引。假设某条记录的坐标为X=123和Y=456。可以按照这样的方式交错存储坐标:XY142536,然后对此进行全文索引。这时,希望查询某矩形——X取值100至199,Y取值400至499——范围时,可以在查询直接搜索“+XY14*”。这比使用WHERE条件过滤的效率要高很多。\n全文索引的另一个常用技巧是缓存全文索引返回的主键值,这在分页显示的时候经常使用。当应用程序真的需要输出结果时,才通过主键值将所有需要的数据返回。这个查询就可以自由地使用其他索引、或者自由地关联其他表。\n虽然只有MyISAM表支持全文索引,但是如果仍然希望使用InnoDB或其他引擎,可以将原表复制到一个备库,再将备库上的表改成MyISAM并建上相应的全文索引。如果不希望在另一个服务器上完成查询,还可以对表进行垂直拆分,将需要索引的列放到一个单独的MyISAM表中。\n将需要索引的列额外地冗余在另一个MyISAM表中也是一个办法。在测试库中sakila.film_text就是使用这个策略,这里使用触发器来维护这个表的数据。最后,你还可以使用一个包含内置全文索引的引擎,如Lucene或者Sphinx。更多关于Shpinx的内容请参考附录F。\n因为使用全文索引的时候,通常会返回大量结果并产生大量随机I/O,如果和GROUP BY一起使用的话,还需要通过临时表或者文件排序进行分组,性能会非常非常糟糕。这类查询通常只是希望查询分组后的前几名结果,所以一个有效的优化方法是对结果集进行抽样而不是精确计算。例如,仅查询前面的1000条记录,进行分组并返回前几名的结果。\n7.10.5 全文索引的配置和优化 # 全文索引的日常维护通常能够大大提升性能。“双B-Tree”的特殊结构、在某些文档中比其他文档要包含多得多的关键字,这都使得全文索引比起普通索引有更多的碎片问题。所以需要经常使用OPTIMIZE TABLE来减少碎片。如果应用是I/O密集型的,那么定期地进行全文索引重建可以让性能提升很多。\n如果希望全文索引能够高效地工作,还需要保证索引缓存足够大,从而保证所有的全文索引都能够缓存在内存中。通常,可以为全文索引设置单独的键缓存(Key cache),保证不会被其他的索引缓存挤出内存。键缓存的配置和使用可以参考第8章。\n提供一个好的停用词表也很重要。默认的停用词表对常用英语来说可能还不错,但是如果是其他语言或者某些专业文档就不合适了,例如技术文档。例如,若要索引一批MySQL相关的文档,那么最好将mysql放入停用词表,因为在这类文档中,这个词会出现得非常频繁。\n忽略一些太短的单词也可以提升全文索引的效率。索引单词的最小长度可以通过参数ft_min_word_len配置。修改该参数可以过滤更多的单词,让查询速度更快,但是也会降低精确度。还需要注意一些特殊的场景,有时确实需要索引某些非常短的词语。例如,对一个电子消费品文档进行索引,除非我们允许对很短的单词进行索引,否则搜索“cd player”可能会返回大量的结果。因为单词“cd”比默认允许的最短长度4还要小,所以这里只会对“Player”进行搜索,而通常搜索“cd player”的客户,其实对MP3或者DVD播放器并不感兴趣。\n停用词表和允许最小词长都可以通过减少索引词语来提升全文索引的效率,但是同时也会降低搜索的精确度。这需要根据实际的应用场景找到合适的平衡点。如果你希望同时获得好的性能和好的搜索质量,那么需要自己定制这些参数。一个好的办法是通过日志系统来研究用户的搜索行为,看看一些异常的查询,包括没有结果返回的查询或者返回过多结果的用户查询。通过这些用户行为和被搜索的内容来判断应该如何调整索引策略。\n需要注意,当调整“允许最小词长”后,需要通过OPTIMIZE TABLE来重建索引才会生效。另一个参数ft_max_word_len和该参数行为类似,它限制了允许索引的最大词长。\n当向一个有全文索引的表中导入大量数据的时候,最好先通过命令DISABLE KEYS来禁用全文索引,然后在导入结束后使用ENABLE KYES来建立全文索引。因为全文索引的更新是一个消耗很大的操作,所以上面的细节会帮你节省大量时间。另外,这样还顺便为全文索引做了一次碎片整理工作。\n如果数据集特别大,则需要对数据进行手动分区,然后将数据分布到不同的节点,再做并行的搜索。这是一个复杂的工作,最好通过一些外部的搜索引擎来实现,如Lucene或者Sphinx。我们的经验显示这样做性能会有指数级的提升。\n7.11 分布式(XA)事务 # 存储引擎的事务特性能够保证在存储引擎级别实现ACID(参考前面介绍的“事务”),而分布式事务则让存储引擎级别的ACID可以扩展到数据库层面,甚至可以扩展到多个数据库之间——这需要通过两阶段提交实现。MySQL 5.0和更新版本的数据库已经开始支持XA事务了。\nXA事务中需要有一个事务协调器来保证所有的事务参与者都完成了准备工作(第一阶段)。如果协调器收到所有的参与者都准备好的消息,就会告诉所有的事务可以提交了,这是第二阶段。MySQL在这个XA事务过程中扮演一个参与者的角色,而不是协调者。\n实际上,在MySQL中有两种XA事务。一方面,MySQL可以参与到外部的分布式事务中;另一方面,还可以通过XA事务来协调存储引擎和二进制日志。\n7.11.1 内部XA事务 # MySQL本身的插件式架构导致在其内部需要使用XA事务。MySQL中各个存储引擎是完全独立的,彼此不知道对方的存在,所以一个跨存储引擎的事务就需要一个外部的协调者。如果不使用XA协议,例如,跨存储引擎的事务提交就只是顺序地要求每个存储引擎各自提交。如果在某个存储提交过程中发生系统崩溃,就会破坏事务的特性(要么就全部提交,要么就不做任何操作)。\n如果将MySQL记录的二进制日志操作看作一个独立的“存储引擎”,就不难理解为什么即使是一个存储引擎参与的事务仍然需要XA事务了。在存储引擎提交的同时,需要将“提交”的信息写入二进制日志,这就是一个分布式事务,只不过二进制日志的参与者是MySQL本身。\nXA事务为MySQL带来巨大的性能下降。从MySQL 5.0开始,它破坏了MySQL内部的“批量提交”(一种通过单磁盘I/O操作完成多个事务提交的技术),使得MySQL不得不进行多次额外的fsync()调用(15)。具体的,一个事务如果开启了二进制日志,则不仅需要对二进制日志进行持久化操作,InnoDB事务日志还需要两次日志持久化操作。换句话说,如果希望有二进制日志安全的事务实现,则至少需要做三次fsync()操作。唯一避免这个问题的办法就是关闭二进制日志,并将innodb_support_xa设置为0(16)。\n但这样的设置是非常不安全的,而且这会导致MySQL复制也没法正常工作。复制需要二进制日志和XA事务的支持,另外——如果希望数据尽可能安全——最好还要将sync_binlog设置成1,这时存储引擎和二进制日志才是真正同步的。(否则,XA事务支持就没有意义了,因为事务提交了二进制日志却可能没有“提交”到磁盘。)这也是为什么我们强烈建议使用带电池保护的RAID卡写缓存:这个缓存可以大大加快fsync()操作的效率。\n下一章我们将更进一步地介绍如何配置事务日志和二进制日志。\n7.11.2 外部XA事务 # MySQL能够作为参与者完成一个外部的分布式事务。但它对XA协议支持并不完整,例如,XA协议要求在一个事务中的多个连接可以做关联,但目前的MySQL版本还不能支持。\n因为通信延迟和参与者本身可能失败,所以外部XA事务比内部消耗会更大。如果在广域网中使用XA事务,通常会因为不可预测的网络性能导致事务失败。如果有太多不可控因素,例如,不稳定的网络通信或者用户长时间地等待而不提交,则最好避免使用XA事务。任何可能让事务提交发生延迟的操作代价都很大,因为它影响的不仅仅是自己本身,它还会让所有参与者都在等待。\n通常,还可以使用别的方式实现高性能的分布式事务。例如,可以在本地写入数据,并将其放入队列,然后在一个更小、更快的事务中自动分发。还可以使用MySQL本身的复制机制来发送数据。我们看到很多应用程序都可以完全避免使用分布式事务。\n也就是说,XA事务是一种在多个服务器之间同步数据的方法。如果由于某些原因不能使用MySQL本身的复制,或者性能并不是瓶颈的时候,可以尝试使用。\n7.12 查询缓存 # 很多数据库产品都能够缓存查询的执行计划,对于相同类型的SQL就可以跳过SQL解析和执行计划生成阶段。MySQL在某些场景下也可以实现,但是MySQL还有另一种不同的缓存类型:缓存完整的SELECT查询结果,也就是“查询缓存”。本节将详细介绍这类缓存。\nMySQL查询缓存保存查询返回的完整结果。当查询命中该缓存,MySQL会立刻返回结果,跳过了解析、优化和执行阶段。\n查询缓存系统会跟踪查询中涉及的每个表,如果这些表发生变化,那么和这个表相关的所有的缓存数据都将失效。这种机制效率看起来比较低,因为数据表变化时很有可能对应的查询结果并没有变更,但是这种简单实现代价很小,而这点对于一个非常繁忙的系统来说非常重要。\n查询缓存对应用程序是完全透明的。应用程序无须关心MySQL是通过查询缓存返回的结果还是实际执行返回的结果。事实上,这两种方式执行的结果是完全相同的。换句话说,查询缓存无须使用任何语法。无论是MySQL开启或关闭查询缓存,对应用程序都是透明的(17)。\n随着现在的通用服务器越来越强大,查询缓存被发现是一个影响服务器扩展性的因素。它可能成为整个服务器的资源竞争单点,在多核服务器上还可能导致服务器僵死。后面我们将详细介绍如何配合查询缓存,但是很多时候我们还是认为应该默认关闭查询缓存,如果查询缓存作用很大的话,那就配置一个很小的查询缓存空间(如几十兆)。后面我们将解释如何判断在你的系统压力下打开查询缓存是否有好处。\n7.12.1 MySQL如何判断缓存命中 # MySQL判断缓存命中的方法很简单:缓存存放在一个引用表中,通过一个哈希值引用,这个哈希值包括了如下因素,即查询本身、当前要查询的数据库、客户端协议的版本等一些其他可能会影响返回结果的信息。\n当判断缓存是否命中时,MySQL不会解析、“正规化”或者参数化查询语句,而是直接使用SQL语句和客户端发送过来的其他原始信息。任何字符上的不同,例如空格、注释——任何的不同——都会导致缓存的不命中。(18)所以在编写SQL语句的时候,需要特别注意这点。通常使用统一的编码规则是一个好的习惯,在这里这个好习惯会让你的系统运行得更快。\n当查询语句中有一些不确定的数据时,则不会被缓存。例如包含函数NOW()或者CURRENT_DATE()的查询不会被缓存。类似的,包含CURRENT_USER或者CONNECTION_ID()的查询语句因为会根据不同的用户返回不同的结果,所以也不会被缓存。事实上,如果查询中包含任何用户自定义函数、存储函数、用户变量、临时表、mysql库中的系统表,或者任何包含列级别权限的表,都不会被缓存。(如果想知道所有情况,建议阅读MySQL官方手册。)\n我们常听到:“如果查询中包含一个不确定的函数,MySQL则不会检查查询缓存”。这个说法是不正确的。因为在检查查询缓存的时候,还没有解析SQL语句,所以MySQL并不知道查询语句中是否包含这类函数。在检查查询缓存之前,MySQL只做一件事情,就是通过一个大小写不敏感的检查看看SQL语句是不是以SEL开头。\n准确的说法应该是:“如果查询语句中包含任何的不确定函数,那么在查询缓存中是不可能找到缓存结果的”。因为即使之前刚刚执行了这样的查询,结果也不会放在查询缓存中。MySQL在任何时候只要发现不能被缓存的部分,就会禁止这个查询被缓存。\n所以,如果希望换成一个带日期的查询,那么最好将日期提前计算好,而不要直接使用函数。例如:\n... DATE_SUB(CURRENT_DATE, INTERVAL 1 DAY) -- Not cacheable! ... DATE_SUB('2007-07-14', INTERVAL 1 DAY) -- Cacheable 因为查询缓存是在完整的SELECT语句基础上的,而且只是在刚收到SQL语句的时候才检查,所以子查询和存储过程都没办法使用查询缓存。在MySQL 5.1之前的版本中,绑定变量也无法使用查询缓存。\nMySQL的查询缓存在很多时候可以提升查询性能,在使用的时候,有一些问题需要特别注意。首先,打开查询缓存对读和写操作都会带来额外的消耗:\n读查询在开始之前必须先检查是否命中缓存。 如果这个读查询可以被缓存,那么当完成执行后,MySQL若发现查询缓存中没有这个查询,会将其结果存入查询缓存,这会带来额外的系统消耗。 这对写操作也会有影响,因为当向某个表写入数据的时候,MySQL必须将对应表的所有缓存都设置失效。如果查询缓存非常大或者碎片很多,这个操作就可能会带来很大系统消耗(设置了很多的内存给查询缓存用的时候)。 虽然如此,查询缓存仍然可能给系统带来性能提升。但是,如上所述,这些额外消耗也可能不断增加,再加上对查询缓存操作是一个加锁排他操作,这个消耗可能不容小觑。\n对InnoDB用户来说,事务的一些特性会限制查询缓存的使用。当一个语句在事务中修改了某个表,MySQL会将这个表的对应的查询缓存都设置失效,而事实上,InnoDB的多版本特性会暂时将这个修改对其他事务屏蔽。在这个事务提交之前,这个表的相关查询是无法被缓存的,所以所有在这个表上面的查询——内部或外部的事务——都只能在该事务提交后才被缓存。因此,长时间运行的事务,会大大降低查询缓存的命中率。\n如果查询缓存使用了很大量的内存,缓存失效操作就可能成为一个非常严重的问题瓶颈。如果缓存中存放了大量的查询结果,那么缓存失效操作时整个系统都可能会僵死一会儿。因为这个操作是靠一个全局锁操作保护的,所有需要做该操作的查询都要等待这个锁,而且无论是检测是否命中缓存、还是缓存失效检测都需要等待这个全局锁。第3章中有一个真实的案例,为大家展示查询缓存过大时带来的系统消耗。\n7.12.2 查询缓存如何使用内存 # 查询缓存是完全存储在内存中的,所以在配置和使用它之前,我们需要先了解它是如何使用内存的。除了查询结果之外,需要缓存的还有很多别的维护相关的数据。这和文件系统有些类似:需要一些内存专门用来确定哪些内存目前是可用的、哪些是已经用掉的、哪些用来存储数据表和查询结果之前的映射、哪些用来存储查询字符串和查询结果。\n这些基本的管理维护数据结构大概需要40KB的内存资源,除此之外,MySQL用于查询缓存的内存被分成一个个的数据块,数据块是变长的。每一个数据块中,存储了自己的类型、大小和存储的数据本身,还外加指向前一个和后一个数据块的指针。数据块的类型有:存储查询结果、存储查询和数据表的映射、存储查询文本,等等。不同的存储块,在内存使用上并没有什么不同,从用户角度来看无须区分它们。\n当服务器启动的时候,它先初始化查询缓存需要的内存。这个内存池初始是一个完整的空闲块。这个空闲块的大小就是你所配置的查询缓存大小再减去用于维护元数据的数据结构所消耗的空间。\n当有查询结果需要缓存的时候,MySQL先从大的空间块中申请一个数据块用于存储结果。这个数据块需要大于参数query_cache_min_res_unit的配置,即使查询结果远远小于此,仍需要至少申请query_cache_min_res_unit空间。因为需要在查询开始返回结果的时候就分配空间,而此时是无法预知查询结果到底多大的,所以MySQL无法为每一个查询结果精确分配大小恰好匹配的缓存空间。\n因为需要先锁住空间块,然后找到合适大小数据块,所以相对来说,分配内存块是一个非常慢的操作。MySQL尽量避免这个操作的次数。当需要缓存一个查询结果的时候,它先选择一个尽可能小的内存块(也可能选择较大的,这里将不介绍细节),然后将结果存入其中。如果数据块全部用完,但仍有剩余数据需要存储,那么MySQL会申请一块新数据块——仍然是尽可能小的数据块——继续存储结果数据。当查询完成时,如果申请的内存空间还有剩余,MySQL会将其释放,并放入空闲内存部分。图7-3展示了这个过程(19)。\n图7-3:查询缓存如何分配内存来存储结果数据\n我们上面说的“分配内存块”,并不是指通过函数malloc()向操作系统申请内存,这个操作只在初次创建查询缓存的时候执行一次。这里“分配内存块”是指在空闲块列表中找到一个合适的内存块,或者从正在使用的、待淘汰的内存块中回收再使用。也就是说,这里MySQL自己管理一大块内存,而不依赖操作系统的内存管理。\n至此,一切都看起来很简单。不过实际情况比图7-3要更复杂。例如,我们假设平均查询结果非常小,服务器在并发地向不同的两个连接返回结果,返回完结果后MySQL回收剩余数据块空间时会发现,回收的数据块小于query_cache_min_res_unit,所以不能够直接在后续的内存块分配中使用。如果考虑到这种情况,数据块的分配就更复杂些,如图7-4所示。\n图7-4:查询缓存中存储查询结果后剩余的碎片\n在收缩第一个查询结果使用的缓存空间时,就会在第二个查询结果之间留下一个“空隙”——一个非常小的空闲空间,因为小于query_cache_min_res_unit而不能再次被查询缓存使用。这类“空隙”我们称为“碎片”,这在内存管理、文件系统管理上都是经典问题。有很多种情况都会导致碎片,例如缓存失效时,可能导致留下太小的数据块无法在后续缓存中使用。\n7.12.3 什么情况下查询缓存能发挥作用 # 并不是什么情况下查询缓存都会提高系统性能的。缓存和失效都会带来额外的消耗,所以只有当缓存带来的资源节约大于其本身的资源消耗时才会给系统带来性能提升。这跟具体的服务器压力模型有关。\n理论上,可以通过观察打开或者关闭查询缓存时候的系统效率来决定是否需要开启查询缓存。关闭查询缓存时,每个查询都需要完整的执行,每一次写操作执行完成后立刻返回;打开查询缓存时,每次读请求先检查缓存是否命中,如果命中则立刻返回,否则就完整地执行查询,每次写操作则需要检查查询缓存中是否有需要失效的缓存,然后再返回。这个过程还比较简单明了,但是要评估打开查询缓存是否能够带来性能提升却并不容易。还有一些外部的因素需要考虑,例如,查询缓存可以降低查询执行的时间,但是却不能减少查询结果传输的网络消耗,如果这个消耗是系统的主要瓶颈,那么查询缓存的作用也很小。\n因为MySQL在SHOW STATUS中只能提供一个全局的性能指标,所以很难根据此来判断查询缓存是否能够提升性能(20)。很多时候,全局平均不能反映实际情况。例如,打开查询缓存可以使得一个很慢的查询变得非常快,但是也会让其他查询稍微慢一点点。有时候如果能够让某些关键的查询速度更快,稍微降低一下其他查询的速度是值得的。不过,这种情况我们推荐使用SQL_CACHE来优化对查询缓存的使用。\n对于那些需要消耗大量资源的查询通常都是非常适合缓存的。例如一些汇总计算查询,具体的如COUNT()等。总地来说,对于复杂的SELECT语句都可以使用查询缓存,例如多表JOIN后还需要做排序和分页,这类查询每次执行消耗都很大,但是返回的结果集却很小,非常适合查询缓存。不过需要注意的是,涉及的表上UPDATE、DELETE和INSERT操作相比SELECT来说要非常少才行。\n一个判断查询缓存是否有效的直接数据是命中率,就是使用查询缓存返回结果占总查询的比率。当MySQL接收到一个SELECT查询的时候,要么增加Qcache_hits的值,要么增加Com_select的值。所以查询缓存命中率可以由如下公式计算:Qcache_hits/(Qcache_hits+Com_select)。\n不过,查询缓存命中率是一个很难判断的数值。命中率多大才是好的命中率?具体情况要具体分析。只要查询缓存带来的效率提升大于查询缓存带来的额外消耗,即使30%命中率对系统性能提升也有很大好处。另外,缓存了哪些查询也很重要,例如,被缓存的查询本身消耗非常巨大,那么即使缓存命中率非常低,也仍然会对系统性能提升有好处。所以,没有一个简单的规则可以判断查询缓存是否对系统有好处。\n任何SELECT语句没有从查询缓存中返回都称为“缓存未命中”。缓存未命中可能有如下几种原因:\n查询语句无法被缓存,可能是因为查询中包含一个不确定的函数(如CURRENT_DATA),或者查询结果太大而无法缓存。这都会导致状态值Qcache_not_cached增加。 MySQL从未处理这个查询,所以结果也从不曾被缓存过。 还有一种情况是虽然之前缓存了查询结果,但是由于查询缓存的内存用完了,MySQL需要将某些缓存“逐出”,或者由于数据表被修改导致缓存失效。(后续会详细介绍缓存失效。) 如果你的服务器上有大量缓存未命中,但是实际上绝大数查询都被缓存了,那么一定是有如下情况发生:\n查询缓存还没有完成预热。也就是说,MySQL还没有机会将查询结果都缓存起来。 查询语句之前从未执行过。如果你的应用程序不会重复执行一条查询语句,那么即使完成预热仍然会有很多缓存未命中。 缓存失效操作太多了。 缓存碎片、内存不足、数据修改都会造成缓存失效。如果配置了足够的缓存空间,而且query_cache_min_res_unit设置也合理的话,那么缓存失效应该主要是数据修改导致的。可以通过参数Com_*来查看数据修改的情况(包括Com_update,Com_delete,等等),还可以通过Qcache_lowmem_prunes来查看有多少次失效是由于内存不足导致的。\n在考虑缓存命中率的同时,通常还需要考虑缓存失效带来的额外消耗。一个极端的办法是,对某一个表先做一次只有查询的测试,并且所有的查询都命中缓存,而另一个相同的表则只做修改操作。这时,查询缓存的命中率就是100%。但因为会给更新操作带来额外的消耗,所以查询缓存并不一定会带来总体效率的提升。这里,所有的更新语句都会做一次缓存失效检查,而检查的结果都是相同的,这会给系统带来额外的资源浪费。所以,如果你只是观察查询缓存的命中率的话,可能完全不会发现这样的问题。\n在MySQL中如果更新操作和带缓存的读操作混合,那么查询缓存带来的好处通常很难衡量。更新操作会不断地使得缓存失效,而同时每次查询还会向缓存中再写入新的数据。所以只有当后续的查询能够在缓存失效前使用缓存才会有效地利用查询缓存。\n如果缓存的结果在失效前没有被任何其他的SELECT语句使用,那么这次缓存操作就是浪费时间和内存。我们可以通过查看Com_select和Qcache_inserts的相对值来看看是否一直有这种情况发生。如果每次查询操作都是缓存未命中,然后需要将查询结果放到缓存中,那么Qcache_inserts的大小应该和Com_select相当。所以在缓存完成预热后,我们总希望看到Qcache_inserts远远小于Com_select。不过由于缓存和服务器内部的复杂和多样性,仍然很难说,这个比率是多少才是一个合适的值。\n所以,上面的“命中率”和“INSERTS和SELECT比率”都无法直观地反应查询缓存的效率。那么还有什么直观的办法能够反映查询缓存是否对系统有好处?这里推荐查看另一个指标:“命中和写入”的比率,即Qcache_hits和Qcache_inserts的比值。根据经验来看,当这个比值大于3:1时通常查询缓存是有效的,不过这个比率最好能够达到10:1。如果你的应用没有达到这个比率,那么就可以考虑禁用查询缓存了,除非你能够通过精确的计算得知:命中带来的性能提升大于缓存失效的消耗,并且查询缓存并没有成为系统的瓶颈。\n每一个应用程序都会有一个“最大缓存空间”,甚至对一些纯读的应用来说也一样。最大缓存空间是能够缓存所有可能查询结果的缓存空间总和。理论上,对多数应用来说,这个数值都会非常大。而实际上,由于缓存失效的原因,大多数应用最后使用的缓存空间都比预想的要小。即使你配置了足够大的缓存空间,由于不断地失效,导致缓存空间一直都不会接近“最大缓存空间”。\n通常可以通过观察查询缓存内存的实际使用情况,来确定是否需要缩小或者扩大查询缓存。如果查询缓存空间长时间都有剩余,那么建议缩小;如果经常由于空间不足而导致查询缓存失效,那么则需要增大查询缓存。不过需要注意,如果查询缓存达到了几十兆这样的数量级,是有潜在危险的。(这和硬件以及系统压力大小有关)。\n另外,可能还需要和系统的其他缓存一起考虑,例如InnoDB的缓存池,或者MyISAM的索引缓存。关于这点是没法简单给出一个公式或者比率来判断的,因为真正的平衡点与应用程序有很大的关系。\n最好的判断查询缓存是否有效的办法还是通过查看某类查询时间消耗是否增大或者减少来判断。Percona Server通过扩展慢查询可以观察到一个查询是否命中缓存。如果查询缓存没有为系统节省时间,那么最好禁用它。\n7.12.4 如何配置和维护查询缓存 # 一旦理解查询缓存工作的原理,配置起来就很容易了。它也只有很少的参数可供配置,如下所示。\nquery_cache_type\n是否打开查询缓存。可以设置成OFF、ON或DEMAND。DEMAND表示只有在查询语句中明确写明SQL_CACHE的语句才放入查询缓存。这个变量可以是会话级别的也可以是全局级别的(会话级别和全局级别的概念请参考第8章)。\nquery_cache_size\n查询缓存使用的总内存空间,单位是字节。这个值必须是1 024的整数倍,否则MySQL实际分配的数据会和你指定的略有不同。\nquery_cache_min_res_unit\n在查询缓存中分配内存块时的最小单位。在前面我们已经介绍了这个参数,后面我们还将进一步讨论它。\nquery_cache_limit\nMySQL能够缓存的最大查询结果。如果查询结果大于这个值,则不会被缓存。因为查询缓存在数据生成的时候就开始尝试缓存数据,所以只有当结果全部返回后, MySQL才知道查询结果是否超出限制。\n如果超出,MySQL则增加状态值Qcache_not_cached,并将结果从查询缓存中删除。如果你事先知道有很多这样的情况发生,那么建议在查询语句中加入SQL_NO_CACHE来避免查询缓存带来的额外消耗。\nquery_cache_wlock_invalidate\n如果某个数据表被其他的连接锁住,是否仍然从查询缓存中返回结果。这个参数默认是OFF,这可能在一定程序上会改变服务器的行为,因为这使得数据库可能返回其他线程锁住的数据。将参数设置成ON,则不会从缓存中读取这类数据,但是这可能会增加锁等待。对于绝大数应用来说无须注意这个细节,所以默认设置通常是没有问题的。\n配置查询缓存通常很简单,但是如果想知道修改这些参数会带来哪些改变,则是一项很复杂的工作。后续的章节,我们将帮助你来决定怎样设置这些参数。\n减少碎片 # 没什么办法能够完全避免碎片,但是选择合适的query_cache_min_res_unit可以帮你减少由碎片导致的内存空间浪费。设置合适的值可以平衡每个数据块的大小和每次存储结果时内存块申请的次数。这个值太小,则浪费的空间更少,但是会导致更频繁的内存块申请操作;如果这个值设置得太大,那么碎片会很多。调整合适的值其实是在平衡内存浪费和CPU消耗。\n这个参数的最合适的大小和应用程序的查询结果的平均大小直接相关。可以通过内存实际消耗(query_cache_size−Qcache_free_memory)除以Qcache_queries_in_cache计算单个查询的平均缓存大小。如果你的应用程序的查询结果很不均匀,有的结果很大,有的结果很小,那么碎片和反复的内存块分配可能无法避免。如果你发现缓存一个非常大的结果并没有什么意义(通常确实是这样),那么你可以通过参数query_cache_limit限制可以缓存的最大查询结果,借此大大减少大的查询结果的缓存,最终减少内存碎片的发生。\n还可以通过参数Qcache_free_blocks来观察碎片。参数Qcache_free_blocks反映了查询缓存中空闲块的多少,在图7-4的配置中我们看到,有两个空闲块。最糟糕的情况是,任何两个存储结果的数据块之间都有一个非常小的空闲块。所以如果Qcache_free_blocks大小恰好达到Qcache_total_blocks/2,那么查询缓存就有严重的碎片问题。而如果你还有很多空闲块,而状态值Qcache_lowmem_prunes还不断地增加,则说明由于碎片导致了过早地在删除查询缓存结果。\n可以使用命令FLUSH QUERY CACHE完成碎片整理。这个命令会将所有的查询缓存重新排序,并将所有的空闲空间都聚集到查询缓存的一块区域上。不过需要注意,这个命令并不会将查询缓存清空,清空缓存由命令RESET QUERY CACHE完成。FLUSH QUERY CACHE会访问所有的查询缓存,在这期间任何其他的连接都无法访问查询缓存,从而会导致服务器僵死一段时间,使用这个命令的时候需要特别小心这点。另外,根据经验,建议保持查询缓存空间足够小,以便在维护时可以将服务器僵死控制在非常短的时间内。\n提高查询缓存的使用率 # 如果查询缓存不再有碎片问题,但你仍然发现命中率很低,还可能是查询缓存的内存空间太小导致的。如果MySQL无法为一个新的查询缓存结果的时候,则会选择删除某个老的缓存结果。\n当由于这个原因导致删除老的缓存结果时,会增加状态值Qcache_lowmem_prunes。如果这个值增加得很快,那么可能是由下面两个原因导致的:\n如果还有很多空闲块,那么碎片可能是罪魁祸首(参考前面的小节)。 如果这时没什么空闲块了,就说明在这个系统压力下,你分配的查询缓存空间不够大。你可以通过检查状态值Qcache_free_memory来查看还有多少没有使用的内存。 如果空闲块很多,碎片很少,也没有什么由于内存导致的缓存失效,但是命中率仍然很低,那么很可能说明,在你的系统压力下,查询缓存并没有什么好处。一定是什么原因导致查询缓存无法为系统服务,例如有大量的更新或者查询语句本身都不能被缓存。\n如果在观察命中率时,仍然无法确定查询缓存是否给系统带来了好处,那么可以通过禁用它,然后观察系统的性能,再重新打开它,观察性能变化,据此来判断查询缓存是否给系统带来了好处。可以通过将query_cache_size设置成0,来关闭查询缓存。(改变query_cache_type的全局值并不会影响已经打开的连接,也不会将查询缓存的内存释放给系统。)你还可以通过系统测试来验证,不过一般都很难精确地模拟实际情况。\n图7-5展示了一个用来分析和配置查询缓存的流程图。\n图7-5:如何分析和配置查询缓存\n7.12.5 InnoDB和查询缓存 # 因为InnoDB有自己的MVCC机制,所以相比其他存储引擎,InnoDB和查询缓存的交互要更加复杂。MySQL 4.0版本中,在事务处理中查询缓存是被禁用的,从4.1和更新的InnoDB版本开始,InnoDB会控制在一个事务中是否可以使用查询缓存,InnoDB会同时控制对查询缓存的读(从缓存中获取查询结果)和写操作(向查询缓存写入结果)。\n事务是否可以访问查询缓存取决于当前事务ID,以及对应的数据表上是否有锁。每一个InnoDB表的内存数据字典都保存了一个事物ID号,如果当前事务ID小于该事务ID,则无法访问查询缓存。\n如果表上有任何的锁,那么对这个表的任何查询语句都是无法被缓存的。例如,某个事务执行了SELECT FOR UPDATE语句,那么在这个锁释放之前,任何其他的事务都无法从查询缓存中读取与这个表相关的缓存结果。\n当事务提交时,InnoDB持有锁,并使用当前的一个系统事务ID更新当前表的计数器。锁一定程度上说明事务需要对表进行修改操作,当然有可能事务获得锁,却不进行任何更新操作,但是如果想更新任何表的内容,获得相应锁则是前提条件。InnoDB将每个表的计数器设置成某个事务ID,而这个事务ID就代表了当前存在的且修改了该表的最大的事务ID。\n那么下面的一些事实也就成立:\n所有大于该表计数器的事务才可以使用查询缓存。例如当前系统的事务ID是5,且事务获取了该表的某些记录的锁,然后进行事务提交操作,那么事务1至4,都不应该再读取或者向查询缓存写入任何相关的数据。 该表的计数器并不是直接更新为对该表进行加锁的事务ID,而是被更新成一个系统事务ID。所以,会发现该事务自身后续的更新操作也无法读取和修改查询缓存。 查询缓存存储、检索和失效操作都是在MySQL层面完成,InnoDB无法绕过或者延迟这个行为。但InnoDB可以在事务中显式地告诉MySQL何时应该让某个表的查询缓存都失效。在有外键限制的时候这是必须的,例如某个SQL语句有ON DELETE CASCADE,那么相关联表的查询缓存也是要一起失效的。\n原则上,在InnoDB的MVCC架构下,当某些修改不影响其他事务读取一致的数据时,是可以使用查询缓存的。但是这样实现起来会非常复杂,InnoDB做了一个简化,让所有有加锁操作的事务都不使用任何查询缓存,这个限制其实并不是必须的。\n7.12.6 通用查询缓存优化 # 库表结构的设计、查询语句、应用程序设计都可能会影响到查询缓存的效率。除了前文介绍的之外,这里还有一些要点需要注意:\n用多个小表代替一个大表对查询缓存有好处。这个设计将会使得失效策略能够在一个更合适的粒度上进行。当然,不要让这个原则过分影响你的设计,毕竟其他的一些优势可能很容易就弥补了这个问题。 批量写入时只需要做一次缓存失效,所以相比单条写入效率更好。(另外需要注意,不要同时做延迟写和批量写,否则可能会因为失效导致服务器僵死较长时间。) 因为缓存空间太大,在过期操作的时候可能会导致服务器僵死。一个简单的解决办法就是控制缓存空间的大小(query_cache_size),或者直接禁用查询缓存。 无法在数据库或者表级别控制查询缓存,但是可以通过SQL_CACHE和SQL_NO_CACHE来控制某个SELECT语句是否需要进行缓存。你还可以通过修改会话级别的变量query_cache_type来控制查询缓存。 对于写密集型的应用来说,直接禁用查询缓存可能会提高系统的性能。关闭查询缓存可以移除所有相关的消耗。例如将query_cache_size设置成0,那么至少这部分就不再消耗任何内存了。 因为对互斥信号量的竞争,有时直接关闭查询缓存对读密集型的应用也会有好处。如果你希望提高系统的并发,那么最好做一个相关的测试,对比打开和关闭查询缓存时候的性能差异。 如果不想所有的查询都进入查询缓存,但是又希望某些查询走查询缓存,那么可以将query_cache_type设置成DEMAND,然后在希望缓存的查询中加上SQL_CACHE。这虽然需要在查询中加入一些额外的语法,但是可以让你非常自由地控制哪些查询需要被缓存。相反,如果希望缓存多数查询,而少数查询又不希望缓存,那么你可以使用关键字SQL_NO_CACHE。\n7.12.7 查询缓存的替代方案 # MySQL查询缓存工作的原则是:执行查询最快的方式就是不去执行,但是查询仍然需要发送到服务器端,服务器也还需要做一点点工作。如果对于某些查询完全不需要与服务器通信效果会如何呢?这时客户端的缓存可以很大程度上帮你分担MySQL服务器的压力。我们将在第14章详细介绍更多关于缓存的内容。\n7.13 总结 # 本章详细介绍了前面各个章节中提到的一些MySQL特性。这里我们将再来回顾一下其中的一些重点内容。\n分区表\n分区表是一种粗粒度的、简易的索引策略,适用于大数据量的过滤场景。最适合的场景是,在没有合适的索引时,对其中几个分区进行全表扫描,或者是只有一个分区和索引是热点,而且这个分区和索引能够都在内存中;限制单表分区数不要超过150个,并且注意某些导致无法做分区过滤的细节,分区表对于单条记录的查询并没有什么优势,需要注意这类查询的性能。\n视图\n对好几个表的复杂查询,使用视图有时候会大大简化问题。当视图使用临时表时,无法将WHERE条件下推到各个具体的表,也不能使用任何索引,需要特别注意这类查询的性能。如果为了便利,使用视图是很合适的。\n外键\n外键限制会将约束放到MySQL中,这对于必须维护外键的场景,性能会更高。不过这也会带来额外的复杂性和额外的索引消耗,还会增加多表之间的交互,会导致系统中更多的锁和竞争。外键可以被看作是一个确保系统完整性的额外的特性,但是如果设计的是一个高性能的系统,那么外键就显得很臃肿了。很多人在更在意系统的性能的时候都不会使用外键,而是通过应用程序来维护。\n存储过程\nMySQL本身实现了存储过程、触发器、存储函数和事件,老实说,这些特性并没什么特别的。而且对于基于语句的复制还有很多问题。通常,使用这些特性可以帮你节省很多的网络开销——很多情况下,减少网络开销可以大大提升系统的性能。在某些经典的场景下你可以使用这些特性(例如中心化业务逻辑、绕过权限系统,等等),但需要注意在MySQL中,这些特性并没有别的数据库系统那么成熟和全面。\n绑定变量\n当查询语句的解析和执行计划生成消耗了主要的时间,那么绑定变量可以在一定程度上解决问题。因为只需要解析一次,对于大量重复类型的查询语句,性能会有很大的提高。另外,执行计划的缓存和传输使用的二进制协议,这都使得绑定变量的方式比普通SQL语句执行的方式要更快。\n插件\n使用C或者C++编写的插件可以让你最大程度地扩展MySQL功能。插件功能非常强大,我们已经编写了很多UDF和插件,在MySQL中解决了很多问题。\n字符集\n字符集是一种字节到字符之间的映射,而校对规则是指一个字符集的排序方法。很多人都使用Latin1(默认字符集,对英语和某些欧洲语言有效)或者UTF-8。如果使用的是UTF-8,那么在使用临时表和缓冲区的时候需要注意:MySQL会按照每个字符三个字节的最大占用空间来分配存储空间,这可能消耗更多的内存或者磁盘空间。注意让字符集和MySQL字符集配置相符,否则可能会由于字符集转换让某些索引无法正常使用。\n全文索引\n在本书编写的时候只有MyISAM支持全文索引,不过据说从MySQL 5.6开始, InnoDB也将支持全文索引。MyISAM因为在锁粒度和崩溃恢复上的缺点,使得在大型全文索引场景中基本无法使用。这时,我们通常帮助客户构建和使用Sphinx来解决全文索引的问题。\nXA事务\n很少有人用MySQL的XA事务特性。除非你真正明白参数innodb_support_xa的意义,否则不要修改这个参数的值,并不是只有显式使用XA事务时才需要设置这个参数。InnoDB和二进制日志也是需要使用XA事务来做协调的,从而确保在系统崩溃的时候,数据能够一致地恢复。\n查询缓存\n完全相同的查询在重复执行的时候,查询缓存可以立即返回结果,而无须在数据库中重新执行一次。根据我们的经验,在高并发压力环境中查询缓存会导致系统性能的下降,甚至僵死。如果你一定要使用查询缓存,那么不要设置太大内存,而且只有在明确收益的时候才使用。那该如何判断是否应该使用查询缓存呢?建议使用Percona Server,观察更细致的日志,并做一些简单的计算。还可以查看缓存命中率(并不总是有用)、“INSERTS和SELECT比率”(这个参数也并不直观)、或者“命中和写入比率”(这个参考意义较大)。查询缓存是一个非常方便的缓存,对应用程序完全透明,无须任何额外的编码,但是,如果希望有更高的缓存效率,我们建议使用memcached或者其他类似的解决方案。第14章介绍了更多的细节供大家参考。\n————————————————————\n(1) 因为可以在这里存放一个非法的日期,所以甚至当order_date是一个非NULL值的时候,仍然会出现这样情况。\n(2) 从用户角度来看,这应该是一个缺陷,不过从MySQL开发者的角度来看这是一个特性。\n(3) 这些特性也是一些“鲜为人知的犀利”特性。\n(4) 这里的“temp table”并不是指真正的物理上存在的临时表。没有经过这些改进和试验,MySQL视图也不会有现在的效率。\n(5) 在MySQL 5.6中可能会有所改进,但是在本书写作的时候5.6还没有发布。\n(6) 这个语法是SQL/PSM的一个子集,SQL/PSM是SQL标准中的持久化存储模块,在ISO/IEC 9075-4:2003(E)中定义。\n(7) 有一些专门用作移植的工具,例如tsql2mysql项目就是专门用于移植SQL Server上的存储过程。参考: http://sourceforge.net/projects/tsql2mysql。\n(8) 通常各个层都有自己的缓存。——译者注\n(9) MySQL 4.0和更早的版本中,如果设置服务器的全局设置,有几种8字节的字符集可以选择。\n(10) coercibility()函数的返回值。——译者注\n(11) 即排序规则。——译者注\n(12) 在MySQL 5.1中,可以使用全文解析器插件来扩展全文索引的功能。不过,MySQL的全文索引本身还是有很多限制的,可能导致无法在你的应用场景中使用。我们将在附录F中介绍如何将Sphinx作为一个MySQL内部搜索引擎来使用。\n(13) 在测试使用时的一个常见错误就是,只是用很小的数据集合进行全文索引,所以总是无法返回结果。原因在于,每个搜索关键词都可能在一半以上的记录里面出现过。\n(14) 事实上,全文索引根本不会对太短或者太长的词语进行索引,但是这里说的不是一回事。一般地, MySQL本身并不会因为搜索关键词过长或过短而忽略这些词语,但是查询优化器的某些部分却可能这样做。\n(15) 在撰写本书的时候,“批量提交”的问题已经有了很多解决方案,其中至少有三种是很优秀的。还需要进一步观察到底MySQL官方会采用哪一种,到底到哪个版本MySQL才会合并到源码。目前,使用MariaDB和Percona Server就可以避免这个问题。\n(16) 一个常见的误区是认为innodb_support_xa只有在需要XA事务时才需要打开。这是错误的:该参数还会控制MyQSL内部存储引擎和二进制日志之间的分布式事务。如果你真正关心你的数据,你需要将这个参数打开。\n(17) 有一种方式查询缓存可能和原生的SQL工作方式有所不同:默认的,当要查询的表被LOCK TABLES锁住时,查询仍然可以通过查询缓存返回数据。你可以通过参数query_cache_wlock_invalidate打开或者关闭这种行为。\n(18) 对于这个规则,Percona Server是个例外。它会先将所有的注释语句删除,然后再比较查询语句是否有缓存。这是一个通用的需求,这样可以在查询语句中带入更多的处理过程信息。前面第3章我们介绍的MySQL监控系统就依赖于此。\n(19) 这里绘制的查询缓存内存分配图,仍然是一种简化的情况。MySQL实际管理查询缓存的方式比这要更复杂。如果你想知道更多的细节,在源代码文件sql/sql_cache.cc开头的注释中有非常详细的解释。\n(20) Percona和MariaDB对MySQL慢日志进行了改进,会记录慢日志中的查询是否命中查询缓存。\n"},{"id":141,"href":"/zh/docs/technology/MySQL/_%E9%AB%98%E6%80%A7%E8%83%BDMySQL_/%E7%AC%AC6%E7%AB%A0%E6%9F%A5%E8%AF%A2%E6%80%A7%E8%83%BD%E4%BC%98%E5%8C%96/","title":"第6章查询性能优化","section":"高性能 My SQL","content":"第6章 查询性能优化\n前面的章节我们介绍了如何设计最优的库表结构、如何建立最好的索引,这些对于高性能来说是必不可少的。但这些还不够——还需要合理的设计查询。如果查询写得很糟糕,即使库表结构再合理、索引再合适,也无法实现高性能。\n查询优化、索引优化、库表结构优化需要齐头并进,一个不落。在获得编写MySQL查询的经验的同时,也将学习到如何为高效的查询设计表和索引。同样的,也可以学习到在优化库表结构时会影响到哪些类型的查询。这个过程需要时间,所以建议大家在学习后面章节的时候多回头看看这三章的内容。\n本章将从查询设计的一些基本原则开始——这也是在发现查询效率不高的时候首先需要考虑的因素。然后会介绍一些更深的查询优化的技巧,并会介绍一些MySQL优化器内部的机制。我们将展示MySQL是如何执行查询的,你也将学会如何去改变一个查询的执行计划。最后,我们要看一下MySQL优化器在哪些方面做得还不够,并探索查询优化的模式,以帮助MySQL更有效地执行查询。\n本章的目标是帮助大家更深刻地理解MySQL如何真正地执行查询,并明白高效和低效的原因何在,这样才能充分发挥MySQL的优势,并避开它的弱点。\n6.1 为什么查询速度会慢 # 在尝试编写快速的查询之前,需要清楚一点,真正重要是响应时间。如果把查询看作是一个任务,那么它由一系列子任务组成,每个子任务都会消耗一定的时间。如果要优化查询,实际上要优化其子任务,要么消除其中一些子任务,要么减少子任务的执行次数,要么让子任务运行得更快(1)。\nMySQL在执行查询的时候有哪些子任务,哪些子任务运行的速度很慢?这里很难给出完整的列表,但如果按照第3章介绍的方法对查询进行剖析,就能看到查询所执行的子任务。通常来说,查询的生命周期大致可以按照顺序来看:从客户端,到服务器,然后在服务器上进行解析,生成执行计划,执行,并返回结果给客户端。其中“执行”可以认为是整个生命周期中最重要的阶段,这其中包括了大量为了检索数据到存储引擎的调用以及调用后的数据处理,包括排序、分组等。\n在完成这些任务的时候,查询需要在不同的地方花费时间,包括网络,CPU计算,生成统计信息和执行计划、锁等待(互斥等待)等操作,尤其是向底层存储引擎检索数据的调用操作,这些调用需要在内存操作、CPU操作和内存不足时导致的I/O操作上消耗时间。根据存储引擎不同,可能还会产生大量的上下文切换以及系统调用。\n在每一个消耗大量时间的查询案例中,我们都能看到一些不必要的额外操作、某些操作被额外地重复了很多次、某些操作执行得太慢等。优化查询的目的就是减少和消除这些操作所花费的时间。\n再次申明一点,对于一个查询的全部生命周期,上面列的并不完整。这里我们只是想说明:了解查询的生命周期、清楚查询的时间消耗情况对于优化查询有很大的意义。有了这些概念,我们再一起来看看如何优化查询。\n6.2 慢查询基础:优化数据访问 # 查询性能低下最基本的原因是访问的数据太多。某些查询可能不可避免地需要筛选大量数据,但这并不常见。大部分性能低下的查询都可以通过减少访问的数据量的方式进行优化。对于低效的查询,我们发现通过下面两个步骤来分析总是很有效:\n确认应用程序是否在检索大量超过需要的数据。这通常意味着访问了太多的行,但有时候也可能是访问了太多的列。 确认MySQL服务器层是否在分析大量超过需要的数据行。 6.2.1 是否向数据库请求了不需要的数据 # 有些查询会请求超过实际需要的数据,然后这些多余的数据会被应用程序丢弃。这会给MySQL服务器带来额外的负担,并增加网络开销(2),另外也会消耗应用服务器的CPU和内存资源。\n这里有一些典型案例:\n查询不需要的记录\n一个常见的错误是常常会误以为MySQL会只返回需要的数据,实际上MySQL却是先返回全部结果集再进行计算。我们经常会看到一些了解其他数据库系统的人会设计出这类应用程序。这些开发者习惯使用这样的技术,先使用SELECT语句查询大量的结果,然后获取前面的N行后关闭结果集(例如在新闻网站中取出100条记录,但是只是在页面上显示前面10条)。他们认为MySQL会执行查询,并只返回他们需要的10条数据,然后停止查询。实际情况是MySQL会查询出全部的结果集,客户端的应用程序会接收全部的结果集数据,然后抛弃其中大部分数据。最简单有效的解决方法就是在这样的查询后面加上LIMIT。\n多表关联时返回全部列\n如果你想查询所有在电影Academy Dinosaur中出现的演员,千万不要按下面的写法编写查询:\nmysql\u0026gt; ** SELECT * FROM sakila.actor** -\u0026gt; ** INNER JOIN sakila.film_actor USING(actor_id)** -\u0026gt; ** INNER JOIN sakila.film USING(film_id)** -\u0026gt; ** WHERE sakila.film.title = 'Academy Dinosaur';** 这将返回这三个表的全部数据列。正确的方式应该是像下面这样只取需要的列:\nmysql\u0026gt; ** SELECT sakila.actor.* FROM sakila.actor...;** 总是取出全部列\n每次看到SELECT *的时候都需要用怀疑的眼光审视,是不是真的需要返回全部的列?很可能不是必需的。取出全部列,会让优化器无法完成索引覆盖扫描这类优化,还会为服务器带来额外的I/O、内存和CPU的消耗。因此,一些DBA是严格禁止SELECT *的写法的,这样做有时候还能避免某些列被修改带来的问题。\n当然,查询返回超过需要的数据也不总是坏事。在我们研究过的许多案例中,人们会告诉我们说这种有点浪费数据库资源的方式可以简化开发,因为能提高相同代码片段的复用性,如果清楚这样做的性能影响,那么这种做法也是值得考虑的。如果应用程序使用了某种缓存机制,或者有其他考虑,获取超过需要的数据也可能有其好处,但不要忘记这样做的代价是什么。获取并缓存所有的列的查询,相比多个独立的只获取部分列的查询可能就更有好处。\n重复查询相同的数据\n如果你不太小心,很容易出现这样的错误——不断地重复执行相同的查询,然后每次都返回完全相同的数据。例如,在用户评论的地方需要查询用户头像的URL,那么用户多次评论的时候,可能就会反复查询这个数据。比较好的方案是,当初次查询的时候将这个数据缓存起来,需要的时候从缓存中取出,这样性能显然会更好。\n6.2.2 MySQL是否在扫描额外的记录 # 在确定查询只返回需要的数据以后,接下来应该看看查询为了返回结果是否扫描了过多的数据。对于MySQL,最简单的衡量查询开销的三个指标如下:\n响应时间 扫描的行数 返回的行数 没有哪个指标能够完美地衡量查询的开销,但它们大致反映了MySQL在内部执行查询时需要访问多少数据,并可以大概推算出查询运行的时间。这三个指标都会记录到MySQL的慢日志中,所以检查慢日志记录是找出扫描行数过多的查询的好办法。\n响应时间 # 要记住,响应时间只是一个表面上的值。这样说可能看起来和前面关于响应时间的说法有矛盾?其实并不矛盾,响应时间仍然是最重要的指标,这有一点复杂,后面细细道来。\n响应时间是两个部分之和:服务时间和排队时间。服务时间是指数据库处理这个查询真正花了多长时间。排队时间是指服务器因为等待某些资源而没有真正执行查询的时间——可能是等I/O操作完成,也可能是等待行锁,等等。遗憾的是,我们无法把响应时间细分到上面这些部分,除非有什么办法能够逐个测量上面这些消耗,不过很难做到。一般最常见和重要的等待是I/O和锁等待,但是实际情况更加复杂。\n所以在不同类型的应用压力下,响应时间并没有什么一致的规律或者公式。诸如存储引擎的锁(表锁、行锁)、高并发资源竞争、硬件响应等诸多因素都会影响响应时间。所以,响应时间既可能是一个问题的结果也可能是一个问题的原因,不同案例情况不同,除非能够使用第3章的“单个查询问题还是服务器问题”一节介绍的技术来确定到底是因还是果。\n当你看到一个查询的响应时间的时候,首先需要问问自己,这个响应时间是否是一个合理的值。实际上可以使用“快速上限估计”法来估算查询的响应时间,这是由TapioLahdenmaki和Mike Leach编写的Relational Database Index Design and the Optimizers(Wiley出版社)一书提到的技术,限于篇幅,在这里不会详细展开。概括地说,了解这个查询需要哪些索引以及它的执行计划是什么,然后计算大概需要多少个顺序和随机I/O,再用其乘以在具体硬件条件下一次I/O的消耗时间。最后把这些消耗都加起来,就可以获得一个大概参考值来判断当前响应时间是不是一个合理的值。\n扫描的行数和返回的行数 # 分析查询时,查看该查询扫描的行数是非常有帮助的。这在一定程度上能够说明该查询找到需要的数据的效率高不高。\n对于找出那些“糟糕”的查询,这个指标可能还不够完美,因为并不是所有的行的访问代价都是相同的。较短的行的访问速度更快,内存中的行也比磁盘中的行的访问速度要快得多。\n理想情况下扫描的行数和返回的行数应该是相同的。但实际情况中这种“美事”并不多。例如在做一个关联查询时,服务器必须要扫描多行才能生成结果集中的一行。扫描的行数对返回的行数的比率通常很小,一般在1:1和10:1之间,不过有时候这个值也可能非常非常大。\n扫描的行数和访问类型 # 在评估查询开销的时候,需要考虑一下从表中找到某一行数据的成本。MySQL有好几种访问方式可以查找并返回一行结果。有些访问方式可能需要扫描很多行才能返回一行结果,也有些访问方式可能无须扫描就能返回结果。\n在EXPLAIN语句中的type列反应了访问类型。访问类型有很多种,从全表扫描到索引扫描、范围扫描、唯一索引查询、常数引用等。这里列的这些,速度是从慢到快,扫描的行数也是从小到大。你不需要记住这些访问类型,但需要明白扫描表、扫描索引、范围访问和单值访问的概念。\n如果查询没有办法找到合适的访问类型,那么解决的最好办法通常就是增加一个合适的索引,这也正是我们前一章讨论过的问题。现在应该明白为什么索引对于查询优化如此重要了。索引让MySQL以最高效、扫描行数最少的方式找到需要的记录。\n例如,我们看看示例数据库Sakila中的一个查询案例:\nmysql\u0026gt; ** SELECT *FROM sakila.film_actor WHERE film_id = 1;** 这个查询将返回10行数据,从EXPLAIN的结果可以看到,MySQL在索引idx_fk_film_id上使用了ref访问类型来执行查询:\nmysql\u0026gt; EXPLAIN SELECT * FROM sakila.film_actor WHERE film_id = 1\\G *************************** 1. row *************************** id: 1 select_type: SIMPLE table: film_actor type: ref possible_keys: idx_fk_film_id key: idx_fk_film_id key_len: 2 ref: const rows: 10 Extra: EXPLAIN的结果也显示MySQL预估需要访问10行数据。换句话说,查询优化器认为这种访问类型可以高效地完成查询。如果没有合适的索引会怎样呢?MySQL就不得不使用一种更糟糕的访问类型,下面我们来看看如果我们删除对应的索引再来运行这个查询:\nmysql\u0026gt; ** ALTER TABLE sakila.film_actor DROP FOREIGN KEY fk_film_actor_film;** mysql\u0026gt; ** ALTER TABLE sakila.film_actor DROP KEY idx_fk_film_id;** mysql\u0026gt; ** EXPLAIN SELECT * FROM sakila.film_actor WHERE film_id = 1\\G** *************************** 1. row *************************** id: 1 select_type: SIMPLE table: film_actor type: ALL possible_keys: NULL key: NULL key_len: NULL ref: NULL rows: 5073 Extra: Using where 正如我们预测的,访问类型变成了一个全表扫描(ALL),现在MySQL预估需要扫描5073条记录来完成这个查询。这里的“Using Where”表示MySQL将通过WHERE条件来筛选存储引擎返回的记录。\n一般MySQL能够使用如下三种方式应用WHERE条件,从好到坏依次为:\n在索引中使用WHERE条件来过滤不匹配的记录。这是在存储引擎层完成的。 使用索引覆盖扫描(在Extra列中出现了Using index)来返回记录,直接从索引中过滤不需要的记录并返回命中的结果。这是在MySQL服务器层完成的,但无须再回表查询记录。 从数据表中返回数据,然后过滤不满足条件的记录(在Extra列中出现Using Where)。这在MySQL服务器层完成,MySQL需要先从数据表读出记录然后过滤。 上面这个例子说明了好的索引多么重要。好的索引可以让查询使用合适的访问类型,尽可能地只扫描需要的数据行。但也不是说增加索引就能让扫描的行数等于返回的行数。例如下面使用聚合函数COUNT()的查询(3):\nmysql\u0026gt; ** SELECT actor_id,** COUNT(*)** FROM sakila.film_actor GROUP BY actor_id;** 这个查询需要读取几千行数据,但是仅返回200行结果。没有什么索引能够让这样的查询减少需要扫描的行数。\n不幸的是,MySQL不会告诉我们生成结果实际上需要扫描多少行数据(4),而只会告诉我们生成结果时一共扫描了多少行数据。扫描的行数中的大部分都很可能是被WHERE条件过滤掉的,对最终的结果集并没有贡献。在上面的例子中,我们删除索引后,看到MySQL需要扫描所有记录然后根据WHERE条件过滤,最终只返回10行结果。理解一个查询需要扫描多少行和实际需要使用的行数需要先去理解这个查询背后的逻辑和思想。\n如果发现查询需要扫描大量的数据但只返回少数的行,那么通常可以尝试下面的技巧去优化它:\n使用索引覆盖扫描,把所有需要用的列都放到索引中,这样存储引擎无须回表获取对应行就可以返回结果了(在前面的章节中我们已经讨论过了)。 改变库表结构。例如使用单独的汇总表(这是我们在第4章中讨论的办法)。 重写这个复杂的查询,让MySQL优化器能够以更优化的方式执行这个查询(这是本章后续需要讨论的问题)。 6.3 重构查询的方式 # 在优化有问题的查询时,目标应该是找到一个更优的方法获得实际需要的结果——而不一定总是需要从MySQL获取一模一样的结果集。有时候,可以将查询转换一种写法让其返回一样的结果,但是性能更好。但也可以通过修改应用代码,用另一种方式完成查询,最终达到一样的目的。这一节我们将介绍如何通过这种方式来重构查询,并展示何时需要使用这样的技巧。\n6.3.1 一个复杂查询还是多个简单查询 # 设计查询的时候一个需要考虑的重要问题是,是否需要将一个复杂的查询分成多个简单的查询。在传统实现中,总是强调需要数据库层完成尽可能多的工作,这样做的逻辑在于以前总是认为网络通信、查询解析和优化是一件代价很高的事情。\n但是这样的想法对于MySQL并不适用,MySQL从设计上让连接和断开连接都很轻量级,在返回一个小的查询结果方面很高效。现代的网络速度比以前要快很多,无论是带宽还是延迟。在某些版本的MySQL上,即使在一个通用服务器上,也能够运行每秒超过10万的查询,即使是一个千兆网卡也能轻松满足每秒超过2000次的查询。所以运行多个小查询现在已经不是大问题了。\nMySQL内部每秒能够扫描内存中上百万行数据,相比之下,MySQL响应数据给客户端就慢得多了。在其他条件都相同的时候,使用尽可能少的查询当然是更好的。但是有时候,将一个大查询分解为多个小查询是很有必要的。别害怕这样做,好好衡量一下这样做是不是会减少工作量。稍后我们将通过本章的一个示例来展示这个技巧的优势。\n不过,在应用设计的时候,如果一个查询能够胜任时还写成多个独立查询是不明智的。例如,我们看到有些应用对一个数据表做10次独立的查询来返回10行数据,每个查询返回一条结果,查询10次!\n6.3.2 切分查询 # 有时候对于一个大查询我们需要“分而治之”,将大查询切分成小查询,每个查询功能完全一样,只完成一小部分,每次只返回一小部分查询结果。\n删除旧的数据就是一个很好的例子。定期地清除大量数据时,如果用一个大的语句一次性完成的话,则可能需要一次锁住很多数据、占满整个事务日志、耗尽系统资源、阻塞很多小的但重要的查询。将一个大的DELETE语句切分成多个较小的查询可以尽可能小地影响MySQL性能,同时还可以减少MySQL复制的延迟。例如,我们需要每个月运行一次下面的查询:\nmysql\u0026gt; ** DELETE FROM messages WHERE created \u0026lt; DATE_SUB(NOW(),INTERVAL 3 MONTH);** 那么可以用类似下面的办法来完成同样的工作:\nrows_affected = 0 do { rows_affected = do_query( \u0026#34;DELETE FROM messages WHERE created \u0026lt; DATE_SUB(NOW(),INTERVAL 3 MONTH) LIMIT 10000\u0026#34;) } while rows_affected \u0026gt; 0 一次删除一万行数据一般来说是一个比较高效而且对服务器(5)影响也最小的做法(如果是事务型引擎,很多时候小事务能够更高效)。同时,需要注意的是,如果每次删除数据后,都暂停一会儿再做下一次删除,这样也可以将服务器上原本一次性的压力分散到一个很长的时间段中,就可以大大降低对服务器的影响,还可以大大减少删除时锁的持有时间。\n6.3.3 分解关联查询 # 很多高性能的应用都会对关联查询进行分解。简单地,可以对每一个表进行一次单表查询,然后将结果在应用程序中进行关联。例如,下面这个查询:\nmysql\u0026gt; ** SELECT * FROM tag** -\u0026gt; ** JOIN tag_post ON tag_post.tag_id=tag.id** -\u0026gt; ** JOIN post ON tag_post.post_id=post.id** -\u0026gt; ** WHERE tag.tag='mysql';** 可以分解成下面这些查询来代替:\nmysql\u0026gt; ** SELECT * FROM tag_post WHERE tag_id=1';** mysql\u0026gt; ** SELECT * FROM tag_post WHERE tag_id=1234;** mysql\u0026gt; ** SELECT * FROM post WHERE post.id in (123,456,567,9098,8904);** 到底为什么要这样做?乍一看,这样做并没有什么好处,原本一条查询,这里却变成多条查询,返回的结果又是一模一样的。事实上,用分解关联查询的方式重构查询有如下的优势:\n让缓存的效率更高。许多应用程序可以方便地缓存单表查询对应的结果对象。例如,上面查询中的tag已经被缓存了,那么应用就可以跳过第一个查询。再例如,应用中已经缓存了ID为123、567、9098的内容,那么第三个查询的IN()中就可以少几个ID。另外,对MySQL的查询缓存来说(6),如果关联中的某个表发生了变化,那么就无法使用查询缓存了,而拆分后,如果某个表很少改变,那么基于该表的查询就可以重复利用查询缓存结果了。 将查询分解后,执行单个查询可以减少锁的竞争。 在应用层做关联,可以更容易对数据库进行拆分,更容易做到高性能和可扩展。 查询本身效率也可能会有所提升。这个例子中,使用IN()代替关联查询,可以让MySQL按照ID顺序进行查询,这可能比随机的关联要更高效。我们后续将详细介绍这点。 可以减少冗余记录的查询。在应用层做关联查询,意味着对于某条记录应用只需要查询一次,而在数据库中做关联查询,则可能需要重复地访问一部分数据。从这点看,这样的重构还可能会减少网络和内存的消耗。 更进一步,这样做相当于在应用中实现了哈希关联,而不是使用MySQL的嵌套循环关联。某些场景哈希关联的效率要高很多(本章后续我们将讨论这点)。 在很多场景下,通过重构查询将关联放到应用程序中将会更加高效,这样的场景有很多,比如:当应用能够方便地缓存单个查询的结果的时候、当可以将数据分布到不同的MySQL服务器上的时候、当能够使用IN()的方式代替关联查询的时候、当查询中使用同一个数据表的时候。\n6.4 查询执行的基础 # 当希望MySQL能够以更高的性能运行查询时,最好的办法就是弄清楚MySQL是如何优化和执行查询的。一旦理解这一点,很多查询优化工作实际上就是遵循一些原则让优化器能够按照预想的合理的方式运行。\n换句话说,是时候回头看看我们前面讨论的内容了:MySQL执行一个查询的过程。根据图6-1,我们可以看到当向MySQL发送一个请求的时候,MySQL到底做了些什么:\n图6-1:查询执行路径\n客户端发送一条查询给服务器。 服务器先检查查询缓存,如果命中了缓存,则立刻返回存储在缓存中的结果。否则进入下一阶段。 服务器端进行SQL解析、预处理,再由优化器生成对应的执行计划。 MySQL根据优化器生成的执行计划,调用存储引擎的API来执行查询。 将结果返回给客户端。 上面的每一步都比想象的复杂,我们在后续章节中将继续讨论。我们会看到在每一个阶段查询处于何种状态。查询优化器是其中特别复杂也特别难理解的部分。还有很多的例外情况,例如,当查询使用绑定变量后,执行路径会有所不同,我们将在下一章讨论这点。\n6.4.1 MySQL客户端/服务器通信协议 # 一般来说,不需要去理解MySQL通信协议的内部实现细节,只需要大致理解通信协议是如何工作的。MySQL客户端和服务器之间的通信协议是“半双工”的,这意味着,在任何一个时刻,要么是由服务器向客户端发送数据,要么是由客户端向服务器发送数据,这两个动作不能同时发生。所以,我们无法也无须将一个消息切成小块独立来发送。\n这种协议让MySQL通信简单快速,但是也从很多地方限制了MySQL。一个明显的限制是,这意味着没法进行流量控制。一旦一端开始发生消息,另一端要接收完整个消息才能响应它。这就像来回抛球的游戏:在任何时刻,只有一个人能控制球,而且只有控制球的人才能将球抛回去(发送消息)。\n客户端用一个单独的数据包将查询传给服务器。这也是为什么当查询的语句很长的时候,参数max_allowed_packet就特别重要了(7)。一旦客户端发送了请求,它能做的事情就只是等待结果了。\n相反的,一般服务器响应给用户的数据通常很多,由多个数据包组成。当服务器开始响应客户端请求时,客户端必须完整地接收整个返回结果,而不能简单地只取前面几条结果,然后让服务器停止发送数据。这种情况下,客户端若接收完整的结果,然后取前面几条需要的结果,或者接收完几条结果后就“粗暴”地断开连接,都不是好主意。这也是在必要的时候一定要在查询中加上LIMIT限制的原因。\n换一种方式解释这种行为:当客户端从服务器取数据时,看起来是一个拉数据的过程,但实际上是MySQL在向客户端推送数据的过程。客户端不断地接收从服务器推送的数据,客户端也没法让服务器停下来。客户端像是“从消防水管喝水”(这是一个术语)。\n多数连接MySQL的库函数都可以获得全部结果集并缓存到内存里,还可以逐行获取需要的数据。默认一般是获得全部结果集并缓存到内存中。MySQL通常需要等所有的数据都已经发送给客户端才能释放这条查询所占用的资源,所以接收全部结果并缓存通常可以减少服务器的压力,让查询能够早点结束、早点释放相应的资源。\n当使用多数连接MySQL的库函数从MySQL获取数据时,其结果看起来都像是从MySQL服务器获取数据,而实际上都是从这个库函数的缓存获取数据。多数情况下这没什么问题,但是如果需要返回一个很大的结果集的时候,这样做并不好,因为库函数会花很多时间和内存来存储所有的结果集。如果能够尽早开始处理这些结果集,就能大大减少内存的消耗,这种情况下可以不使用缓存来记录结果而是直接处理。这样做的缺点是,对于服务器来说,需要查询完成后才能释放资源,所以在和客户端交互的整个过程中,服务器的资源都是被这个查询所占用的(8)。\n我们看看当使用P H P的时候是什么情况。首先,下面是我们连接M y S Q L的通常写法:\n\u0026lt;?php $link = mysql_connect(\u0026#39;localhost\u0026#39;, \u0026#39;user\u0026#39;, \u0026#39;p4ssword\u0026#39;); $result = mysql_query(\u0026#39;SELECT * FROM HUGE_TABLE\u0026#39;, $link); while ( $row = mysql_fetch_array($result) ) { // Do something with result } ?\u0026gt;} 这段代码看起来像是只有当你需要的时候,才通过循环从服务器端取出数据。而实际上,在上面的代码中,在调用mysql_query()的时候,PHP就已经将整个结果集缓存到内存中。下面的while循环只是从这个缓存中逐行取出数据,相反如果使用下面的查询,用mysql_unbuffered_query()代替mysql_query(),PHP则不会缓存结果:\n\u0026lt;?php $link = mysql_connect(\u0026#39;localhost\u0026#39;, \u0026#39;user\u0026#39;, \u0026#39;p4ssword\u0026#39;); $result = mysql_unbuffered_query(\u0026#39;SELECT * FROM HUGE_TABLE\u0026#39;, $link); while ( $row = mysql_fetch_array($result) ) { // Do something with result } ?\u0026gt; 不同的编程语言处理缓存的方式不同。例如,在Perl的DBD:mysql驱动中需要指定C连接库的mysql_use_result属性(默认是mysql_buffer_result)。下面是一个例子\n#!/usr/bin/perl use DBI; my $dbh = DBI-\u0026gt;connect('DBI:mysql:;host=localhost', 'user', 'p4ssword'); my $sth = $dbh-\u0026gt;prepare('SELECT * FROM HUGE_TABLE', { mysql_use_result =\u0026gt; 1 }); $sth-\u0026gt;execute(); while ( my $row = $sth-\u0026gt;fetchrow_array() ) { # Do something with result } 注意到上面的prepare()调用指定了mysql_use_result属性为1,所以应用将直接“使用”返回的结果集而不会将其缓存。也可以在连接MySQL的时候指定这个属性,这会让整个连接都使用不缓存的方式处理结果集:\nmy $dbh = DBI-\u0026gt;connect('DBI:mysql:;mysql_use_result=1', 'user', 'p4ssword'); 查询状态 # 对于一个MySQL连接,或者说一个线程,任何时刻都有一个状态,该状态表示了MySQL当前正在做什么。有很多种方式能查看当前的状态,最简单的是使用SHOW FULL PROCESSLIST命令(该命令返回结果中的Command列就表示当前的状态)。在一个查询的生命周期中,状态会变化很多次。MySQL官方手册中对这些状态值的含义有最权威的解释,下面将这些状态列出来,并做一个简单的解释。\nSleep\n线程正在等待客户端发送新的请求。\nQuery\n线程正在执行查询或者正在将结果发送给客户端。\nLocked\n在MySQL服务器层,该线程正在等待表锁。在存储引擎级别实现的锁,例如InnoDB的行锁,并不会体现在线程状态中。对于MyISAM来说这是一个比较典型的状态,但在其他没有行锁的引擎中也经常会出现。\nAnalyzing and statistics\n线程正在收集存储引擎的统计信息,并生成查询的执行计划。\nCopying to tmp table [on disk]\n线程正在执行查询,并且将其结果集都复制到一个临时表中,这种状态一般要么是在做GROUP BY操作,要么是文件排序操作,或者是UNION操作。如果这个状态后面还有“on disk”标记,那表示MySQL正在将一个内存临时表放到磁盘上。\nThe thread is\n线程正在对结果集进行排序。\nSending data\n这表示多种情况:线程可能在多个状态之间传送数据,或者在生成结果集,或者在向客户端返回数据。\n了解这些状态的基本含义非常有用,这可以让你很快地了解当前“谁正在持球”(9)。在一个繁忙的服务器上,可能会看到大量的不正常的状态,例如statistics正占用大量的时间。这通常表示,某个地方有异常了,可以通过使用第3章的一些技巧来诊断到底是哪个环节出现了问题。\n6.4.2 查询缓存**(10)** # 在解析一个查询语句之前,如果查询缓存是打开的,那么MySQL会优先检查这个查询是否命中查询缓存中的数据。这个检查是通过一个对大小写敏感的哈希查找实现的。查询和缓存中的查询即使只有一个字节不同,那也不会匹配缓存结果(11),这种情况下查询就会进入下一阶段的处理。\n如果当前的查询恰好命中了查询缓存,那么在返回查询结果之前MySQL会检查一次用户权限。这仍然是无须解析查询SQL语句的,因为在查询缓存中已经存放了当前查询需要访问的表信息。如果权限没有问题,MySQL会跳过所有其他阶段,直接从缓存中拿到结果并返回给客户端。这种情况下,查询不会被解析,不用生成执行计划,不会被执行。在第7章中的查询缓存一节,你将学习到更多细节。\n6.4.3 查询优化处理 # 查询的生命周期的下一步是将一个SQL转换成一个执行计划,MySQL再依照这个执行计划和存储引擎进行交互。这包括多个子阶段:解析SQL、预处理、优化SQL执行计划。这个过程中任何错误(例如语法错误)都可能终止查询。这里不打算详细介绍MySQL内部实现,而只是选择性地介绍其中几个独立的部分,在实际执行中,这几部分可能一起执行也可能单独执行。我们的目的是帮助大家理解MySQL如何执行查询,以便写出更优秀的查询。\n语法解析器和预处理 # 首先,MySQL通过关键字将SQL语句进行解析,并生成一棵对应的“解析树”。MySQL解析器将使用MySQL语法规则验证和解析查询。例如,它将验证是否使用错误的关键字,或者使用关键字的顺序是否正确等,再或者它还会验证引号是否能前后正确匹配。\n预处理器则根据一些MySQL规则进一步检查解析树是否合法,例如,这里将检查数据表和数据列是否存在,还会解析名字和别名,看看它们是否有歧义。\n下一步预处理器会验证权限。这通常很快,除非服务器上有非常多的权限配置。\n查询优化器 # 现在语法树被认为是合法的了,并且由优化器将其转化成执行计划。一条查询可以有很多种执行方式,最后都返回相同的结果。优化器的作用就是找到这其中最好的执行计划。\nMySQL使用基于成本的优化器,它将尝试预测一个查询使用某种执行计划时的成本,并选择其中成本最小的一个。最初,成本的最小单位是随机读取一个4K数据页的成本,后来(成本计算公式)变得更加复杂,并且引入了一些“因子”来估算某些操作的代价,如当执行一次WHERE条件比较的成本。可以通过查询当前会话的Last_query_cost的值来得知MySQL计算的当前查询的成本。\n这个结果表示MySQL的优化器认为大概需要做1040个数据页的随机查找才能完成上面的查询。这是根据一系列的统计信息计算得来的:每个表或者索引的页面个数、索引的基数(索引中不同值的数量)、索引和数据行的长度、索引分布情况。优化器在评估成本的时候并不考虑任何层面的缓存,它假设读取任何数据都需要一次磁盘I/O。\n有很多种原因会导致MySQL优化器选择错误的执行计划,如下所示:\n统计信息不准确。MySQL依赖存储引擎提供的统计信息来评估成本,但是有的存储引擎提供的信息是准确的,有的偏差可能非常大。例如,InnoDB因为其MVCC的架构,并不能维护一个数据表的行数的精确统计信息。 执行计划中的成本估算不等同于实际执行的成本。所以即使统计信息精准,优化器给出的执行计划也可能不是最优的。例如有时候某个执行计划虽然需要读取更多的页面,但是它的成本却更小。因为如果这些页面都是顺序读或者这些页面都已经在内存中的话,那么它的访问成本将很小。MySQL层面并不知道哪些页面在内存中、哪些在磁盘上,所以查询实际执行过程中到底需要多少次物理I/O是无法得知的。 MySQL的最优可能和你想的最优不一样。你可能希望执行时间尽可能的短,但是 MySQL只是基于其成本模型选择最优的执行计划,而有些时候这并不是最快的执行方式。所以,这里我们看到根据执行成本来选择执行计划并不是完美的模型。 MySQL从不考虑其他并发执行的查询,这可能会影响到当前查询的速度。 MySQL也并不是任何时候都是基于成本的优化。有时也会基于一些固定的规则,例如,如果存在全文搜索的MATCH()子句,则在存在全文索引的时候就使用全文索引。即使有时候使用别的索引和WHERE条件可以远比这种方式要快,MySQL也仍然会使用对应的全文索引。 MySQL不会考虑不受其控制的操作的成本,例如执行存储过程或者用户自定义函数的成本。 后面我们还会看到,优化器有时候无法去估算所有可能的执行计划,所以它可能错过实际上最优的执行计划。 MySQL的查询优化器是一个非常复杂的部件,它使用了很多优化策略来生成一个最优的执行计划。优化策略可以简单地分为两种,一种是静态优化,一种是动态优化。静态优化可以直接对解析树进行分析,并完成优化。例如,优化器可以通过一些简单的代数变换将WHERE条件转换成另一种等价形式。静态优化不依赖于特别的数值,如WHERE条件中带入的一些常数等。静态优化在第一次完成后就一直有效,即使使用不同的参数重复执行查询也不会发生变化。可以认为这是一种“编译时优化”。\n相反,动态优化则和查询的上下文有关,也可能和很多其他因素有关,例如WHERE条件中的取值、索引中条目对应的数据行数等。这需要在每次查询的时候都重新评估,可以认为这是“运行时优化”。\n在执行语句和存储过程的时候,动态优化和静态优化的区别非常重要。MySQL对查询的静态优化只需要做一次,但对查询的动态优化则在每次执行时都需要重新评估。有时候甚至在查询的执行过程中也会重新优化。(12)\n下面是一些MySQL能够处理的优化类型:\n重新定义关联表的顺序\n数据表的关联并不总是按照在查询中指定的顺序进行。决定关联的顺序是优化器很重要的一部分功能,本章后面将深入介绍这一点。\n将外连接转化成内连接\n并不是所有的OUTER JOIN语句都必须以外连接的方式执行。诸多因素,例如WHERE条件、库表结构都可能会让外连接等价于一个内连接。MySQL能够识别这点并重写查询,让其可以调整关联顺序。\n使用等价变换规则\nMySQL可以使用一些等价变换来简化并规范表达式。它可以合并和减少一些比较,还可以移除一些恒成立和一些恒不成立的判断。例如,(5=5 AND a\u0026gt;5)将被改写为a\u0026gt;5。类似的,如果有 (a\u0026lt;b AND b=c) AND a=5则会改写为b\u0026gt;5 AND b=c AND a=5。这些规则对于我们编写条件语句很有用,我们将在本章后续继续讨论。\n优化COUNT()、MIN()和MAX()\n索引和列是否可为空通常可以帮助MySQL优化这类表达式。例如,要找到某一列的最小值,只需要查询对应B-Tree索引最左端的记录,MySQL可以直接获取索引的第一行记录。在优化器生成执行计划的时候就可以利用这一点,在B-Tree索引中,优化器会将这个表达式作为一个常数对待。类似的,如果要查找一个最大值,也只需读取B-Tree索引的最后一条记录。如果MySQL使用了这种类型的优化,那么在EXPLAIN中就可以看到“Select tables optimized away”。从字面意思可以看出,它表示优化器已经从执行计划中移除了该表,并以一个常数取而代之。\n类似的,没有任何WHERE条件的COUNT(*)查询通常也可以使用存储引擎提供的一些优化(例如,MyISAM维护了一个变量来存放数据表的行数)。\n预估并转化为常数表达式\n当MySQL检测到一个表达式可以转化为常数的时候,就会一直把该表达式作为常数进行优化处理。例如,一个用户自定义变量在查询中没有发生变化时就可以转换为一个常数。数学表达式则是另一种典型的例子。\n让人惊讶的是,在优化阶段,有时候甚至一个查询也能够转化为一个常数。一个例子是在索引列上执行MIN()函数。甚至是主键或者唯一键查找语句也可以转换为常数表达式。如果WHERE子句中使用了该类索引的常数条件,MySQL可以在查询开始阶段就先查找到这些值,这样优化器就能够知道并转换为常数表达式。下面是一个例子:\nMySQL分两步来执行这个查询,也就是上面执行计划的两行输出。第一步先从film表找到需要的行。因为在film_id字段上有主键索引,所以MySQL优化器知道这只会返回一行数据,优化器在生成执行计划的时候,就已经通过索引信息知道将返回多少行数据。因为优化器已经明确知道有多少个值(WHERE条件中的值)需要做索引查询,所以这里的表访问类型是const。\n在执行计划的第二步,MySQL将第一步中返回的film_id列当作一个已知取值的列来处理。因为优化器清楚在第一步执行完成后,该值就会是明确的了。注意到正如第一步中一样,使用flm_actor字段对表的访问类型也是const。\n另一种会看到常数条件的情况是通过等式将常数值从一个表传到另一个表,这可以通过WHERE、USING或者ON语句来限制某列取值为常数。在上面的例子中,因为使用了USING子句,优化器知道这也限制了film_id在整个查询过程中都始终是一个常量——因为它必须等于WHERE子句中的那个取值。\n覆盖索引扫描\n当索引中的列包含所有查询中需要使用的列的时候,MySQL就可以使用索引返回需要的数据,而无须查询对应的数据行,在前面的章节中我们已经讨论过这点了。\n子查询优化\nMySQL在某些情况下可以将子查询转换一种效率更高的形式,从而减少多个查询多次对数据进行访问。\n提前终止查询\n在发现已经满足查询需求的时候,MySQL总是能够立刻终止查询。一个典型的例子就是当使用了LIMIT子句的时候。除此之外,MySQL还有几类情况也会提前终止查询,例如发现了一个不成立的条件,这时MySQL可以立刻返回一个空结果。从下面的例子可以看到这一点:\n从这个例子看到查询在优化阶段就已经终止。除此之外,MySQL在执行过程中,如果发现某些特殊的条件,则会提前终止查询。当存储引擎需要检索“不同取值”或者判断存在性的时候,MySQL都可以使用这类优化。例如,我们现在需要找到没有演员的所有电影(13):\nmysql\u0026gt; ** SELECT film.film_id** -\u0026gt; ** FROM sakila.film** -\u0026gt; ** LEFT OUTER JOIN sakila.film_actor USING(film_id)** -\u0026gt; ** WHERE film_actor.film_id IS NULL;** 这个查询将会过滤掉所有有演员的电影。每一部电影可能会有很多的演员,但是上面的查询一旦找到任何一个,就会停止并立刻判断下一部电影,因为只要有一名演员,那么WHERE条件则会过滤掉这类电影。类似这种“不同值/不存在”的优化一般可用于DISTINCT、NOT EXIST()或者LEFT JOIN类型的查询。\n等值传播\n如果两个列的值通过等式关联,那么MySQL能够把其中一个列的WHERE条件传递到另一列上。例如,我们看下面的查询:\nmysql\u0026gt; ** SELECT film.film_id** -\u0026gt; ** FROM sakila.film** -\u0026gt; ** INNER JOIN sakila.film_actor USING(film_id)** -\u0026gt; ** WHERE film.film_id \u0026gt; 500** 因为这里使用了film_id字段进行等值关联,MySQL知道这里的WHERE子句不仅适用于flm表,而且对于flm_actor表同样适用。如果使用的是其他的数据库管理系统,可能还需要手动通过一些条件来告知优化器这个WHERE条件适用于两个表,那么写法就会如下:\n... WHERE film.film_id \u0026gt; 500 AND film_actor.film_id \u0026gt; 500 在MySQL中这是不必要的,这样写反而会让查询更难维护。\n列表IN()的比较\n在很多数据库系统中,IN()完全等同于多个OR条件的子句,因为这两者是完全等价的。在MySQL中这点是不成立的,MySQL将IN()列表中的数据先进行排序,然后通过二分查找的方式来确定列表中的值是否满足条件,这是一个O(log n)复杂度的操作,等价地转换成OR查询的复杂度为O(n),对于IN()列表中有大量取值的时候,MySQL的处理速度将会更快。\n上面列举的远不是MySQL优化器的全部,MySQL还会做大量其他的优化,即使本章全部用来描述也会篇幅不足,但上面的这些例子已经足以让大家明白优化器的复杂性和智能性了。如果说从上面这段讨论中我们应该学到什么,那就是“不要自以为比优化器更聪明”。最终你可能会占点便宜,但是更有可能会使查询变得更加复杂而难以维护,而最终的收益却为零。让优化器按照它的方式工作就可以了。\n当然,虽然优化器已经很智能了,但是有时候也无法给出最优的结果。有时候你可能比优化器更了解数据,例如,由于应用逻辑使得某些条件总是成立;还有时,优化器缺少某种功能特性,如哈希索引;再如前面提到的,从优化器的执行成本角度评估出来的最优执行计划,实际运行中可能比其他的执行计划更慢。\n如果能够确认优化器给出的不是最佳选择,并且清楚背后的原理,那么也可以帮助优化器做进一步的优化。例如,可以在查询中添加hint提示,也可以重写查询,或者重新设计更优的库表结构,或者添加更合适的索引。\n数据和索引的统计信息 # 重新回忆一下图1-1,MySQL架构由多个层次组成。在服务器层有查询优化器,却没有保存数据和索引的统计信息。统计信息由存储引擎实现,不同的存储引擎可能会存储不同的统计信息(也可以按照不同的格式存储统计信息)。某些引擎,例如Archive引擎,则根本就没有存储任何统计信息!\n因为服务器层没有任何统计信息,所以MySQL查询优化器在生成查询的执行计划时,需要向存储引擎获取相应的统计信息。存储引擎则提供给优化器对应的统计信息,包括:每个表或者索引有多少个页面、每个表的每个索引的基数是多少、数据行和索引长度、索引的分布信息等。优化器根据这些信息来选择一个最优的执行计划。在后面的小节中我们将看到统计信息是如何影响优化器的。\nMySQL如何执行关联查询 # MySQL中“关联”(14)一词所包含的意义比一般意义上理解的要更广泛。总的来说,MySQL认为任何一个查询都是一次“关联”——并不仅仅是一个查询需要到两个表匹配才叫关联,所以在MySQL中,每一个查询,每一个片段(包括子查询,甚至基于单表的SELECT)都可能是关联。\n所以,理解MySQL如何执行关联查询至关重要。我们先来看一个UNION查询的例子。对于UNION查询,MySQL先将一系列的单个查询结果放到一个临时表中,然后再重新读出临时表数据来完成UNION查询。在MySQL的概念中,每个查询都是一次关联,所以读取结果临时表也是一次关联。\n当前MySQL关联执行的策略很简单:MySQL对任何关联都执行嵌套循环关联操作,即MySQL先在一个表中循环取出单条数据,然后再嵌套循环到下一个表中寻找匹配的行,依次下去,直到找到所有表中匹配的行为止。然后根据各个表匹配的行,返回查询中需要的各个列。MySQL会尝试在最后一个关联表中找到所有匹配的行,如果最后一个联表无法找到更多的行以后,MySQL返回到上一层次关联表,看是否能够找到更多的匹配记录,依此类推迭代执行。(15)\n按照这样的方式查找第一个表记录,再嵌套查询下一个关联表,然后回溯到上一个表,在MySQL中是通过嵌套循环的方式实现——正如其名“嵌套循环关联”。请看下面的例子中的简单查询:\nmysql\u0026gt; ** SELECT tbl1.col1, tbl2.col2** -\u0026gt; ** FROM tbl1 INNER JOIN tbl2 USING(col3)** -\u0026gt; ** WHERE tbl1.col1 IN(5,6)** 假设MySQL按照查询中的表顺序进行关联操作,我们则可以用下面的伪代码表示MySQL将如何完成这个查询:\nouter_iter = iterator over tbl1 where col1 IN(5,6) outer_row = outer_iter.next while outer_row inner_iter = iterator over tbl2 where col3 = outer_row.col3 inner_row = inner_iter.next while inner_row output [ outer_row.col1, inner_row.col2 ] inner_row = inner_iter.next end outer_row = outer_iter.netxt end 上面的执行计划对于单表查询和多表关联查询都适用,如果是一个单表查询,那么只需完成上面外层的基本操作。对于外连接上面的执行过程仍然适用。例如,我们将上面查询修改如下:\nmysql\u0026gt; ** SELECT tbl1.col1, tbl2.col2** -\u0026gt; ** FROM tbl1 LEFT OUTER JOIN tbl2 USING(col3)** -\u0026gt; ** WHERE tbl1.col1 IN(5,6);** 对应的伪代码如下,我们用黑体标示不同的部分:\nouter_iter = iterator over tbl1 where col1 IN(5,6) outer_row = outer_iter.next while outer_row inner_iter = iterator over tbl2 where col3 = outer_row.col3 inner_row = inner_iter.next if inner_row while inner_row output [ outer_row.col1, inner_row.col2 ] inner_row = inner_iter.next end else output [ outer_row.col1, NULL ] end outer_row = outer_iter.next end 另一种可视化查询执行计划的方法是根据优化器执行的路径绘制出对应的“泳道图”。如图6-2所示,绘制了前面示例中内连接的泳道图,请从左至右,从上至下地看这幅图。\n图6-2:通过泳道图展示MySQL如何完成关联查询\n从本质上说,MySQL对所有的类型的查询都以同样的方式运行。例如,MySQL在FROM子句中遇到子查询时,先执行子查询并将其结果放到一个临时表中(16),然后将这个临时表当作一个普通表对待(正如其名“派生表”)。MySQL在执行UNION查询时也使用类似的临时表,在遇到右外连接的时候,MySQL将其改写成等价的左外连接。简而言之,当前版本的MySQL会将所有的查询类型都转换成类似的执行计划。(17)\n不过,不是所有的查询都可以转换成上面的形式。例如,全外连接就无法通过嵌套循环和回溯的方式完成,这时当发现关联表中没有找到任何匹配行的时候,则可能是因为关联是恰好从一个没有任何匹配的表开始。这大概也是MySQL并不支持全外连接的原因。还有些场景,虽然可以转换成嵌套循环的方式,但是效率却非常差,后面我们会看一个这样的例子。\n执行计划 # 和很多其他关系数据库不同,MySQL并不会生成查询字节码来执行查询。MySQL生成查询的一棵指令树,然后通过存储引擎执行完成这棵指令树并返回结果。最终的执行计划包含了重构查询的全部信息。如果对某个查询执行EXPLAIN EXTENDED后,再执行SHOW WARNINGS,就可以看到重构出的查询(18)。\n任何多表查询都可以使用一棵树表示,例如,可以按照图6-3执行一个四表的关联操作。\n图6-3:多表关联的一种方式\n在计算机科学中,这被称为一颗平衡树。但是,这并不是MySQL执行查询的方式。正如我们前面章节介绍的,MySQL总是从一个表开始一直嵌套循环、回溯完成所有表关联。所以,MySQL的执行计划总是如图6-4所示,是一棵左测深度优先的树。\n图6-4:MySQL如何实现多表关联\n关联查询优化器 # MySQL优化器最重要的一部分就是关联查询优化,它决定了多个表关联时的顺序。通常多表关联的时候,可以有多种不同的关联顺序来获得相同的执行结果。关联查询优化器则通过评估不同顺序时的成本来选择一个代价最小的关联顺序。\n下面的查询可以通过不同顺序的关联最后都获得相同的结果:\nmysql\u0026gt; ** SELECT film.film_id, film.title, film.release_year, actor.actor_id,** -\u0026gt; ** actor.first_name, actor.last_name** -\u0026gt; ** FROM sakila.film** -\u0026gt; ** INNER JOIN sakila.film_actor USING(film_id)** -\u0026gt; ** INNER JOIN sakila.actor USING(actor_id);** 容易看出,可以通过一些不同的执行计划来完成上面的查询。例如,MySQL可以从film表开始,使用film_actor表的索引film_id来查找对应的actor_id值,然后再根据actor表的主键找到对应的记录。Oracle用户会用下面的术语描述:“film表作为驱动表先查找fle_actor表,然后以此结果为驱动表再查找actor表”。这样做效率应该会不错,我们再使用EXPLAIN看看MySQL将如何执行这个查询:\n*************************** 1. row *************************** id: 1 select_type: SIMPLE table: actor type: ALL possible_keys: PRIMARY key: NULL key_len: NULL ref: NULL rows: 200 Extra: *************************** 2. row *************************** id: 1 select_type: SIMPLE table: film_actor type: ref possible_keys: PRIMARY,idx_fk_film_id key: PRIMARY key_len: 2 ref: sakila.actor.actor_id rows: 1 Extra: Using index *************************** 3. row *************************** id: 1 select_type: SIMPLE table: film type: eq_ref possible_keys: PRIMARY key: PRIMARY key_len: 2 ref: sakila.film_actor.film_id rows: 1 Extra: 这和我们前面给出的执行计划完全不同。MySQL从actor表开始(我们从上面的EXPLAIN结果的第一行输出可以看出这点),然后与我们前面的计划按照相反的顺序进行关联。这样是否效率更高呢?我们来看看,我们先使用STRAIGHT_JOIN关键字,按照我们之前的顺序执行,这里是对应的EXPLAIN输出结果:\nmysql\u0026gt; ** EXPLAIN SELECT STRAIGHT_JOIN film.film_id...\\G** *************************** 1. row *************************** id: 1 select_type: SIMPLE table: film type: ALL possible_keys: PRIMARY key: NULL key_len: NULL ref: NULL rows: 951 Extra: *************************** 2. row *************************** id: 1 select_type: SIMPLE table: film_actor type: ref possible_keys: PRIMARY,idx_fk_film_id key: idx_fk_film_id key_len: 2 ref: sakila.film.film_id rows: 1 Extra: Using index *************************** 3. row *************************** id: 1 select_type: SIMPLE table: actor type: eq_ref possible_keys: PRIMARY key: PRIMARY key_len: 2 ref: sakila.film_actor.actor_id rows: 1 Extra: 我们来分析一下为什么MySQL会将关联顺序倒转过来:可以看到,关联顺序倒转后的第一个关联表只需要扫描很少的行数(19)。在两种关联顺序下,第二个和第三个关联表都是根据索引查询,速度都很快,不同的是需要扫描的索引项的数量是不同的:\n将film表作为第一个关联表时,会找到951条记录,然后对film_actor和actor表进行嵌套循环查询。 如果MySQL选择首先扫描actor表,只会返回200条记录进行后面的嵌套循环查询。 换句话说,倒转的关联顺序会让查询进行更少的嵌套循环和回溯操作。为了验证优化器的选择是否正确,我们单独执行这两个查询,并且看看对应的Last_query_cost状态值。我们看到倒转的关联顺序的预估成本(20)为241,而原来的查询的预估成本为1 154。\n这个简单的例子主要想说明MySQL是如何选择合适的关联顺序来让查询执行的成本尽可能低的。重新定义关联的顺序是优化器非常重要的一部分功能。不过有的时候,优化器给出的并不是最优的关联顺序。这时可以使用STRAIGHT_JOIN关键字重写查询,让优化器按照你认为的最优的关联顺序执行——不过老实说,人的判断很难那么精准。绝大多数时候,优化器做出的选择都比普通人的判断要更准确。\n关联优化器会尝试在所有的关联顺序中选择一个成本最小的来生成执行计划树。如果可能,优化器会遍历每一个表然后逐个做嵌套循环计算每一棵可能的执行计划树的成本,最后返回一个最优的执行计划。\n不过,糟糕的是,如果有超过n个表的关联,那么需要检查n的阶乘种关联顺序。我们称之为所有可能的执行计划的“搜索空间”,搜索空间的增长速度非常块——例如,若是10个表的关联,那么共有3628800种不同的关联顺序!当搜索空间非常大的时候,优化器不可能逐一评估每一种关联顺序的成本。这时,优化器选择使用“贪婪”搜索的方式查找“最优”的关联顺序。实际上,当需要关联的表超过optimizer_search_depth的限制的时候,就会选择“贪婪”搜索模式了(optimizer_search_depth参数可以根据需要指定大小)。\n在MySQL这些年的发展过程中,优化器积累了很多“启发式”的优化策略来加速执行计划的生成。绝大多数情况下,这都是有效的,但因为不会去计算每一种关联顺序的成本,所以偶尔也会选择一个不是最优的执行计划。\n有时,各个查询的顺序并不能随意安排,这时关联优化器可以根据这些规则大大减少搜索空间,例如,左连接、相关子查询(后面我将继续讨论子查询)。这是因为,后面的表的查询需要依赖于前面表的查询结果。这种依赖关系通常可以帮助优化器大大减少需要扫描的执行计划数量。\n排序优化 # 无论如何排序都是一个成本很高的操作,所以从性能角度考虑,应尽可能避免排序或者尽可能避免对大量数据进行排序。\n在第3章中我们已经看到MySQL如何通过索引进行排序。当不能使用索引生成排序结果的时候,MySQL需要自己进行排序,如果数据量小则在内存中进行,如果数据量大则需要使用磁盘,不过MySQL将这个过程统一称为文件排序(filesort),即使完全是内存排序不需要任何磁盘文件时也是如此。\n如果需要排序的数据量小于“排序缓冲区”,MySQL使用内存进行“快速排序”操作。如果内存不够排序,那么MySQL会先将数据分块,对每个独立的块使用“快速排序”进行排序,并将各个块的排序结果存放在磁盘上,然后将各个排好序的块进行合并(merge),最后返回排序结果。\nMySQL有如下两种排序算法:\n两次传输排序(旧版本使用)\n读取行指针和需要排序的字段,对其进行排序,然后再根据排序结果读取所需要的数据行。\n这需要进行两次数据传输,即需要从数据表中读取两次数据,第二次读取数据的时候,因为是读取排序列进行排序后的所有记录,这会产生大量的随机I/O,所以两次数据传输的成本非常高。当使用的是MyISAM表的时候,成本可能会更高,因为MyISAM使用系统调用进行数据的读取(MyISAM非常依赖操作系统对数据的缓存)。不过这样做的优点是,在排序的时候存储尽可能少的数据,这就让“排序缓冲区”(21)中可能容纳尽可能多的行数进行排序。\n单次传输排序(新版本使用)\n先读取查询所需要的所有列,然后再根据给定列进行排序,最后直接返回排序结果。这个算法只在MySQL 4.1和后续更新的版本才引入。因为不再需要从数据表中读取两次数据,对于I/O密集型的应用,这样做的效率高了很多。另外,相比两次传输排序,这个算法只需要一次顺序I/O读取所有的数据,而无须任何的随机I/O。缺点是,如果需要返回的列非常多、非常大,会额外占用大量的空间,而这些列对排序操作本身来说是没有任何作用的。因为单条排序记录很大,所以可能会有更多的排序块需要合并。\n很难说哪个算法效率更高,两种算法都有各自最好和最糟的场景。当查询需要所有列的总长度不超过参数max_length_for_sort_data时,MySQL使用“单次传输排序”,可以通过调整这个参数来影响MySQL排序算法的选择。关于这个细节,可以参考第8章“文件排序优化”。\nMySQL在进行文件排序的时候需要使用的临时存储空间可能会比想象的要大得多。原因在于MySQL在排序时,对每一个排序记录都会分配一个足够长的定长空间来存放。\n这个定长空间必须足够长以容纳其中最长的字符串,例如,如果是VARCHAR列则需要分配其完整长度;如果使用UTF-8字符集,那么MySQL将会为每个字符预留三个字节。我们曾经在一个库表结构设计不合理的案例中看到,排序消耗的临时空间比磁盘上的原表要大很多倍。\n在关联查询的时候如果需要排序,MySQL会分两种情况来处理这样的文件排序。如果ORDER BY子句中的所有列都来自关联的第一个表,那么MySQL在关联处理第一个表的时候就进行文件排序。如果是这样,那么在MySQL的EXPLAIN结果中可以看到Extra字段会有“Using filesort”。除此之外的所有情况,MySQL都会先将关联的结果存放到一个临时表中,然后在所有的关联都结束后,再进行文件排序。这种情况下,在MySQL的EXPLAIN结果的Extra字段可以看到“Using temporary;Using filesort”。如果查询中有LIMIT的话,LIMIT也会在排序之后应用,所以即使需要返回较少的数据,临时表和需要排序的数据量仍然会非常大。\nMySQL 5.6在这里做了很多重要的改进。当只需要返回部分排序结果的时候,例如使用了LIMIT子句,MySQL不再对所有的结果进行排序,而是根据实际情况,选择抛弃不满足条件的结果,然后再进行排序。\n6.4.4 查询执行引擎 # 在解析和优化阶段,MySQL将生成查询对应的执行计划,MySQL的查询执行引擎则根据这个执行计划来完成整个查询。这里执行计划是一个数据结构,而不是和很多其他的关系型数据库那样会生成对应的字节码。\n相对于查询优化阶段,查询执行阶段不是那么复杂:MySQL只是简单地根据执行计划给出的指令逐步执行。在根据执行计划逐步执行的过程中,有大量的操作需要通过调用存储引擎实现的接口来完成,这些接口也就是我们称为“handler API”的接口。查询中的每一个表由一个handler的实例表示。前面我们有意忽略了这点,实际上,MySQL在优化阶段就为每个表创建了一个handler实例,优化器根据这些实例的接口可以获取表的相关信息,包括表的所有列名、索引统计信息,等等。\n存储引擎接口有着非常丰富的功能,但是底层接口却只有几十个,这些接口像“搭积木”一样能够完成查询的大部分操作。例如,有一个查询某个索引的第一行的接口,再有一个查询某个索引条目的下一个条目的功能,有了这两个功能我们就可以完成全索引扫描的操作了。这种简单的接口模式,让MySQL的存储引擎插件式架构成为可能,但是正如前面的讨论,也给优化器带来了一定的限制。\n并不是所有的操作都由handler完成。例如,当MySQL需要进行表锁的时候。handler可能会实现自己的级别的、更细粒度的锁,如InnoDB就实现了自己的行基本锁,但这并不能代替服务器层的表锁。正如我们第1章所介绍的,如果是所有存储引擎共有的特性则由服务器层实现,比如时间和日期函数、视图、触发器等。\n为了执行查询,MySQL只需要重复执行计划中的各个操作,直到完成所有的数据查询。\n6.4.5 返回结果给客户端 # 查询执行的最后一个阶段是将结果返回给客户端。即使查询不需要返回结果集给客户端,MySQL仍然会返回这个查询的一些信息,如该查询影响到的行数。\n如果查询可以被缓存,那么MySQL在这个阶段也会将结果存放到查询缓存中。\nMySQL将结果集返回客户端是一个增量、逐步返回的过程。例如,我们回头看看前面的关联操作,一旦服务器处理完最后一个关联表,开始生成第一条结果时,MySQL就可以开始向客户端逐步返回结果集了。\n这样处理有两个好处:服务器端无须存储太多的结果,也就不会因为要返回太多结果而消耗太多内存。另外,这样的处理也让MySQL客户端第一时间获得返回的结果(22)。\n结果集中的每一行都会以一个满足MySQL客户端/服务器通信协议的封包发送,再通过TCP协议进行传输,在TCP传输的过程中,可能对MySQL的封包进行缓存然后批量传输。\n6.5 MySQL查询优化器的局限性 # MySQL的万能“嵌套循环”并不是对每种查询都是最优的。不过还好,MySQL查询优化器只对少部分查询不适用,而且我们往往可以通过改写查询让MySQL高效地完成工作。还有一个好消息,MySQL 5.6版本正式发布后,会消除很多MySQL原本的限制,让更多的查询能够以尽可能高的效率完成。\n6.5.1 关联子查询 # MySQL的子查询实现得非常糟糕。最糟糕的一类查询是WHERE条件中包含IN()的子查询语句。例如,我们希望找到Sakila数据库中,演员Penelope Guiness(他的actor_id为1)参演过的所有影片信息。很自然的,我们会按照下面的方式用子查询实现:\nmysql\u0026gt; ** SELECT * FROM sakila.film** -\u0026gt; ** WHERE film_id IN(** -\u0026gt; ** SELECT film_id FROM sakila.film_actor WHERE actor_id = 1);** 因为MySQL对IN()列表中的选项有专门的优化策略,一般会认为MySQL会先执行子查询返回所有包含actor_id为1的film_id。一般来说,IN()列表查询速度很快,所以我们会认为上面的查询会这样执行:\n-- SELECT * FROM sakila.film-- SELECT GROUP_CONCAT(film_id) FROM sakila.film_actor WHERE actor_id = 1; -- Result: 1,23,25,106,140,166,277,361,438,499,506,509,605,635,749,832,939,970,980 SELECT * FROM sakila.film WHERE film_id IN(1,23,25,106,140,166,277,361,438,499,506,509,605,635,749,832,939,970,980); 很不幸,MySQL不是这样做的。MySQL会将相关的外层表压到子查询中,它认为这样可以更高效率地查找到数据行。也就是说,MySQL会将查询改写成下面的样子:\nSELECT * FROM sakila.film WHERE EXISTS ( SELECT * FROM sakila.film_actor WHERE actor_id = 1 AND film_actor.film_id = film.film_id); 这时,子查询需要根据film_id来关联外部表film,因为需要film_id字段,所以MySQL认为无法先执行这个子查询。通过EXPLAIN我们可以看到子查询是一个相关子查询(DEPENDENT SUBQUERY)(可以使用EXPLAIN EXTENDED来查看这个查询被改写成了什么样子):\n根据EXPLAIN的输出我们可以看到,MySQL先选择对file表进行全表扫描,然后根据返回的flm_id逐个执行子查询。如果是一个很小的表,这个查询糟糕的性能可能还不会引起注意,但是如果外层的表是一个非常大的表,那么这个查询的性能会非常糟糕。当然我们很容易用下面的办法来重写这个查询:\nmysql\u0026gt; ** SELECT film.* FROM sakila.film** -\u0026gt; ** INNER JOIN sakila.film_actor USING(film_id)** -\u0026gt; ** WHERE actor_id = 1;** 另一个优化的办法是使用函数GROUP_CONCAT()在IN()中构造一个由逗号分隔的列表。有时这比上面的使用关联改写更快。因为使用IN()加子查询,性能经常会非常糟,所以通常建议使用EXISTS()等效的改写查询来获取更好的效率。下面是另一种改写IN()加子查询的办法:\nmysql\u0026gt; ** SELECT * FROM sakila.film** -\u0026gt; ** WHERE EXISTS(** -\u0026gt; ** SELECT * FROM sakila.film_actor WHERE actor_id = 1** -\u0026gt; ** AND film_actor.film_id = film.film_id);** 这里讨论的优化器的限制直到Oracle推出的MySQL 5.5都一直存在。MySQL的另一个分支MariaDB则在原有的优化器的基础上做了大量的改进,例如这里提到的IN()加子查询改进。\n如何用好关联子查询 # 并不是所有关联子查询的性能都会很差。如果有人跟你说:“别用关联子查询”,那么不要理他。先测试,然后做出自己的判断。很多时候,关联子查询是一种非常合理、自然,甚至是性能最好的写法。我们看看下面的例子:\nmysql\u0026gt; ** EXPLAIN SELECT film_id, language_id FROM sakila.film** -\u0026gt; ** WHERE NOT EXISTS(** -\u0026gt; ** SELECT * FROM sakila.film_actor** -\u0026gt; ** WHERE film_actor.film_id = film.film_id** -\u0026gt; ** )\\G** *************************** 1. row *************************** id: 1 select_type: PRIMARY table: film type: ALL possible_keys: NULL key: NULL key_len: NULL ref: NULL rows: 951 Extra: Using where *************************** 2. row *************************** id: 2 select_type: DEPENDENT SUBQUERY table: film_actor type: ref possible_keys: idx_fk_film_id key: idx_fk_film_id key_len: 2 ref: film.film_id rows: 2 Extra: Using where; Using index 一般会建议使用左外连接(LEFT OUTER JOIN)重写该查询,以代替子查询。理论上,改写后MySQL的执行计划完全不会改变。我们来看这个例子:\nmysql\u0026gt; ** EXPLAIN SELECT film.film_id, film.language_id** -\u0026gt; ** FROM sakila.film** -\u0026gt; ** LEFT OUTER JOIN sakila.film_actor USING(film_id)** -\u0026gt; ** WHERE film_actor.film_id IS NULL\\G** *************************** 1. row *********************** id: 1 select_type: SIMPLE table: film type: ALL possible_keys: NULL key: NULL key_len: NULL ref: NULL rows: 951 Extra: *************************** 2. row ********************** id: 1 select_type: SIMPLE table: film_actor type: ref possible_keys: idx_fk_film_id key: idx_fk_film_id key_len: 2 ref: sakila.film.film_id rows: 2 Extra: Using where; Using index; Not exists 可以看到,这里的执行计划基本上一样,下面是一些微小的区别:\n表flm_actor的访问类型一个是DEPENDENT SUBQUERY,而另一个是SIMPLE。这个不同是由于语句的写法不同导致的,一个是普通查询,一个是子查询。这对底层存储引擎接口来说,没有任何不同。 对film表,第二个查询的Extra中没有“Using where”,但这不重要,第二个查询的USING子句和第一个查询的WHERE子句实际上是完全一样的。 在第二个表film_actor的执行计划的Extra列有“Not exists”。这是我们前面章节中提到的提前终止算法(early-termination algorithm),MySQL通过使用“Not exists”优化来避免在表film_actor的索引中读取任何额外的行。这完全等效于直接编写NOT EXISTS子查询,这个执行计划中也是一样,一旦匹配到一行数据,就立刻停止扫描。 所以,从理论上讲,MySQL将使用完全相同的执行计划来完成这个查询。现实世界中,我们建议通过一些测试来判断使用哪种写法速度会更快。针对上面的案例,我们对两种写法进行了测试,表6-1中列出了测试结果。\n表6-1:NOT EXISTS和左外连接的性能比较 查询 每秒查询数结果(QPS) NOT EXISTS 子查询 360 QPS LEFT OUTER JOIN 425 QPS\n我们的测试显示,使用子查询的写法要略微慢些!\n不过每个具体的案例会各有不同,有时候子查询写法也会快些。例如,当返回结果中只有一个表中的某些列的时候。听起来,这种情况对于关联查询效率也会很好。具体情况具体分析,例如下面的关联,我们希望返回所有包含同一个演员参演的电影,因为一个电影会有很多演员参演,所以可能会返回一些重复的记录:\nmysql\u0026gt; ** SELECT film.film_id FROM sakila.film** -\u0026gt; ** INNER JOIN sakila.film_actor USING(film_id);** 我们需要使用DISTINCT和GROUP BY来移除重复的记录:\nmysql\u0026gt; ** SELECT DISTINCT film.film_id FROM sakila.film** -\u0026gt; ** INNER JOIN sakila.film_actor USING(film_id);** 但是,回头看看这个查询,到底这个查询返回的结果集意义是什么?至少这样的写法会让SQL的意义很不明显。如果使用EXISTS则很容易表达“包含同一个参演演员”的逻辑,而且不需要使用DISTINCT和GROUP BY,也不会产生重复的结果集,我们知道一旦使用了DISTINCT和GROUP BY,那么在查询的执行过程中,通常需要产生临时中间表。下面我们用子查询的写法替换上面的关联:\nmysql\u0026gt; ** SELECT film_id FROM sakila.film** -\u0026gt; ** WHERE EXISTS(SELECT * FROM sakila.film_actor** -\u0026gt; ** WHERE film.film_id = film_actor.film_id);** 再一次,我们需要通过测试来对比这两种写法,哪个更快一些。测试结果参考表6-2。\n表6-2:EXISTS和关联性能对比 查询 每秒查询数结果(QPS) INNER JOIN 185 QPS EXISTS子查询 325 QPS\n在这个案例中,我们看到子查询速度要比关联查询更快些。\n通过上面这个详细的案例,主要想说明两点:一是不需要听取那些关于子查询的“绝对真理”,二是应该用测试来验证对子查询的执行计划和响应时间的假设。最后,关于子查询我们需要提到的是一个MySQL的bug。在MYSQL 5.1.48和之前的版本中,下面的写法会锁住table2中的一条记录:\nSELECT ... FROM table1 WHERE col = (SELECT ... FROM table2 WHERE ...); 如果遇到该bug,子查询在高并发情况下的性能,就会和在单线程测试时的性能相差甚远。这个bug的编号是46947,虽然这个问题已经被修复了,但是我们仍然要提醒读者:不要主观猜测,应该通过测试来验证猜想。\n6.5.2 UNION的限制 # 有时,MySQL无法将限制条件从外层“下推”到内层,这使得原本能够限制部分返回结果的条件无法应用到内层查询的优化上。\n如果希望UNION的各个子句能够根据LIMIT只取部分结果集,或者希望能够先排好序再合并结果集的话,就需要在UNION的各个子句中分别使用这些子句。例如,想将两个子查询结果联合起来,然后再取前20条记录,那么MySQL会将两个表都存放到同一个临时表中,然后再取出前20行记录:\n(SELECT first_name, last_name FROM sakila.actor ORDER BY last_name) UNION ALL (SELECT first_name, last_name FROM sakila.customer ORDER BY last_name) LIMIT 20; 这条查询将会把actor中的200条记录和customer表中的599条记录存放在一个临时表中,然后再从临时表中取出前20条。可以通过在UNION的两个子查询中分别加上一个LIMIT 20来减少临时表中的数据:\n(SELECT first_name, last_name FROM sakila.actor ORDER BY last_name LIMIT 20) UNION ALL (SELECT first_name, last_name FROM sakila.customer ORDER BY last_name LIMIT 20) LIMIT 20; 现在中间的临时表只会包含40条记录了,除了性能考虑之外,在这里还需要注意一点:从临时表中取出数据的顺序并不是一定的,所以如果想获得正确的顺序,还需要加上一个全局的ORDER BY和LIMIT操作。\n6.5.3 索引合并优化 # 在前面的章节已经讨论过,在5.0和更新的版本中,当WHERE子句中包含多个复杂条件的时候,MySQL能够访问单个表的多个索引以合并和交叉过滤的方式来定位需要查找的行。\n6.5.4 等值传递 # 某些时候,等值传递会带来一些意想不到的额外消耗。例如,有一个非常大的IN()列表,而MySQL优化器发现存在WHERE、ON或者USING的子句,将这个列表的值和另一个表的某个列相关联。\n那么优化器会将IN()列表都复制应用到关联的各个表中。通常,因为各个表新增了过滤条件,优化器可以更高效地从存储引擎过滤记录。但是如果这个列表非常大,则会导致优化和执行都会变慢。在本书写作的时候,除了修改MySQL源代码,目前还没有什么办法能够绕过该问题(不过这个问题很少会碰到)。\n6.5.5 并行执行 # MySQL无法利用多核特性来并行执行查询。很多其他的关系型数据库能够提供这个特性,但是MySQL做不到。这里特别指出是想告诉读者不要花时间去尝试寻找并行执行查询的方法。\n6.5.6 哈希关联 # 在本书写作的时候,MySQL并不支持哈希关联——MySQL的所有关联都是嵌套循环关联。不过,可以通过建立一个哈希索引来曲线地实现哈希关联。如果使用的是Memory存储引擎,则索引都是哈希索引,所以关联的时候也类似于哈希关联。可以参考第5章的“创建自定义哈希索引”部分。另外,MariaDB已经实现了真正的哈希关联。\n6.5.7 松散索引扫描**(23)** # 由于历史原因,MySQL并不支持松散索引扫描,也就无法按照不连续的方式扫描一个索引。通常,MySQL的索引扫描需要先定义一个起点和终点,即使需要的数据只是这段索引中很少数的几个,MySQL仍需要扫描这段索引中每一个条目。\n下面我们通过一个示例说明这点。假设我们有如下索引(a,b),有下面的查询:\nmysql\u0026gt; ** SELECT ... FROM tbl WHERE b BETWEEN 2 AND 3;** 因为索引的前导字段是列a,但是在查询中只指定了字段b,MySQL无法使用这个索引,从而只能通过全表扫描找到匹配的行,如图6-5所示。\n图6-5:MySQL通过全表扫描找到需要的记录\n了解索引的物理结构的话,不难发现还可以有一个更快的办法执行上面的查询。索引的物理结构(不是存储引擎的API)使得可以先扫描a列第一个值对应的b列的范围,然后再跳到a列第二个不同值扫描对应的b列的范围。图6-6展示了如果由MySQL来实现这个过程会怎样。\n图6-6:使用松散索引扫描效率会更高,但是MySQL现在还不支持这么做\n注意到,这时就无须再使用WHERE子句过滤,因为松散索引扫描已经跳过了所有不需要的记录。\n上面是一个简单的例子,除了松散索引扫描,新增一个合适的索引当然也可以优化上述查询。但对于某些场景,增加索引是没用的,例如,对于第一个索引列是范围条件,第二个索引列是等值条件的查询,靠增加索引就无法解决问题。\nMySQL 5.0之后的版本,在某些特殊的场景下是可以使用松散索引扫描的,例如,在一个分组查询中需要找到分组的最大值和最小值:\nmysql\u0026gt; ** EXPLAIN SELECT actor_id, MAX(film_id)** -\u0026gt; ** FROM sakila.film_actor** -\u0026gt; ** GROUP BY actor_id\\G** *************************** 1. row *************************** id: 1 select_type: SIMPLE table: film_actor type: range possible_keys: NULL key: PRIMARY key_len: 2 ref: NULL rows: 396 Extra: Using index for group-by 在EXPLAIN中的Extra字段显示“Using index for group-by”,表示这里将使用松散索引扫描,不过如果MySQL能写上“loose index probe”,相信会更好理解。\n在MySQL很好地支持松散索引扫描之前,一个简单的绕过问题的办法就是给前面的列加上可能的常数值。在前面索引案例学习的章节中,我们已经看到这样做的好处了。\n在MySQL 5.6之后的版本,关于松散索引扫描的一些限制将会通过“索引条件下推(index condition pushdown)”的方式解决。\n6.5.8 最大值和最小值优化 # 对于MIN()和MAX()查询,MySQL的优化做得并不好。这里有一个例子:\nmysql\u0026gt; ** SELECT MIN(actor_id) FROM sakila.actor WHERE first_name='PENELOPE';** 因为在first_name字段上并没有索引,因此MySQL将会进行一次全表扫描。如果MySQL能够进行主键扫描,那么理论上,当MySQL读到第一个满足条件的记录的时候,就是我们需要找的最小值了,因为主键是严格按照actor_id字段的大小顺序排列的。但是MySQL这时只会做全表扫描,我们可以通过查看SHOW STATUS的全表扫描计数器来验证这一点。一个曲线的优化办法是移除MIN(),然后使用LIMIT来将查询重写如下:\nmysql\u0026gt; ** SELECT actor_id FROM sakila.actor USE INDEX(PRIMARY)** -\u0026gt; ** WHERE first_name = 'PENELOPE' LIMIT 1;** 这个策略可以让MySQL扫描尽可能少的记录数。如果你是一个完美主义者,可能会说这个SQL已经无法表达她的本意了。一般我们通过SQL告诉服务器我们需要什么数据,由服务器来决定如何最优地获取数据,不过在这个案例中,我们其实是告诉MySQL如何去获取我们需要的数据,通过SQL并不能一眼就看出我们其实是想要一个最小值。确实如此,有时候为了获得更高的性能,我们不得不放弃一些原则。\n6.5.9 在同一个表上查询和更新 # MySQL不允许对同一张表同时进行查询和更新。这其实并不是优化器的限制,如果清楚MySQL是如何执行查询的,就可以避免这种情况。下面是一个无法运行的SQL,虽然这是一个符合标准的SQL语句。这个SQL语句尝试将两个表中相似行的数量记录到字段cnt中:\nmysql\u0026gt; ** UPDATE tbl AS outer_tbl** -\u0026gt; ** SET cnt = (** -\u0026gt; ** SELECT count(*) FROM tbl AS inner_tbl** -\u0026gt; ** WHERE inner_tbl.type = outer_tbl.type** -\u0026gt; ** );** ERROR 1093 (HY000): You can't specify target table 'outer_tbl' for update in FROM clause 可以通过使用生成表的形式来绕过上面的限制,因为MySQL只会把这个表当作一个临时表来处理。实际上,这执行了两个查询:一个是子查询中的SELECT语句,另一个是多表关联UPDATE,只是关联的表是一个临时表。子查询会在UPDATE语句打开表之前就完成,所以下面的查询将会正常执行:\nmysql\u0026gt; ** UPDATE tbl** -\u0026gt; ** INNER JOIN(www.it-eboo** -\u0026gt; ** SELECT type, count(*) AS cnt** -\u0026gt; ** FROM tbl** -\u0026gt; ** GROUP BY type** -\u0026gt; ** ) AS der USING(type)** -\u0026gt; ** SET tbl.cnt = der.cnt;** 6.6 查询优化器的提示(hint) # 如果对优化器选择的执行计划不满意,可以使用优化器提供的几个提示(hint)来控制最终的执行计划。下面将列举一些常见的提示,并简单地给出什么时候使用该提示。通过在查询中加入相应的提示,就可以控制该查询的执行计划。关于每个提示的具体用法,建议直接阅读MySQL官方手册。有些提示和版本有直接关系。可以使用的一些提示如下:\nHIGH_PRIORITY和LOW_PRIORITY\n这个提示告诉MySQL,当多个语句同时访问某一个表的时候,哪些语句的优先级相对高些、哪些语句的优先级相对低些。\nHIGH_PRIORITY用于SELECT语句的时候,MySQL会将此SELECT语句重新调度到所有正在等待表锁以便修改数据的语句之前。实际上MySQL是将其放在表的队列的最前面,而不是按照常规顺序等待。HIGH_PRIORITY还可以用于INSERT语句,其效果只是简单地抵消了全局LOW_PRIORITY设置对该语句的影响。\nLOW_PRIORITY则正好相反:它会让该语句一直处于等待状态,只要队列中还有需要访问同一个表的语句——即使是那些比该语句还晚提交到服务器的语句。这就像一个过于礼貌的人站在餐厅门口,只要还有其他顾客在等待就一直不进去,很明显这容易把自己给饿坏。LOW_PRIORITY提示在SELECT、INSERT、UPDATE和DELETE语句中都可以使用。\n这两个提示只对使用表锁的存储引擎有效,千万不要在InnoDB或者其他有细粒度锁机制和并发控制的引擎中使用。即使是在MyISAM中使用也要注意,因为这两个提示会导致并发插入被禁用,可能会严重降低性能。\nHIGH_PRIORITY和LOW_PRIORITY经常让人感到困惑。这两个提示并不会获取更多资源让查询“积极”工作,也不会少获取资源让查询“消极”工作。它们只是简单地控制了MySQL访问某个数据表的队列顺序。\nDELAYED\n这个提示对INSERT和REPLACE有效。MySQL会将使用该提示的语句立即返回给客户端,并将插入的行数据放入到缓冲区,然后在表空闲时批量将数据写入。日志系统使用这样的提示非常有效,或者是其他需要写入大量数据但是客户端却不需要等待单条语句完成I/O的应用。这个用法有一些限制:并不是所有的存储引擎都支持这样的做法;并且该提示会导致函数LAST_INSERT_ID()无法正常工作。\nSTRAIGHT_JOIN\n这个提示可以放置在SELECT语句的SELECT关键字之后,也可以放置在任何两个关联表的名字之间。第一个用法是让查询中所有的表按照在语句中出现的顺序进行关联。第二个用法则是固定其前后两个表的关联顺序。\n当MySQL没能选择正确的关联顺序的时候,或者由于可能的顺序太多导致MySQL无法评估所有的关联顺序的时候,STRAIGHT_JOIN都会很有用。在后面这种情况,MySQL可能会花费大量时间在“statistics”状态,加上这个提示则会大大减少优化器的搜索空间。\n可以先使用EXPLAIN语句来查看优化器选择的关联顺序,然后使用该提示来重写查询,再看看它的关联顺序。当你确定无论怎样的where条件,某个固定的关联顺序始终是最佳的时候,使用这个提示可以大大提高优化器的效率。但是在升级MySQL版本的时候,需要重新审视下这类查询,某些新的优化特性可能会因为该提示而失效。\nSQL_SMALL_RESULT和SQL_BIG_RESULT\n这两个提示只对SELECT语句有效。它们告诉优化器对GROUP BY或者DISTINCT查询如何使用临时表及排序。SQL_SMALL_RESULT告诉优化器结果集会很小,可以将结果集放在内存中的索引临时表,以避免排序操作。如果是SQL_BIG_RESULT,则告诉优化器结果集可能会非常大,建议使用磁盘临时表做排序操作。\nSQL_BUFFER_RESULT\n这个提示告诉优化器将查询结果放入到一个临时表,然后尽可能快地释放表锁。这和前面提到的由客户端缓存结果不同。当你没法使用客户端缓存的时候,使用服务器端的缓存通常很有效。带来的好处是无须在客户端上消耗太多的内存,还可以尽可能快地释放对应的表锁。代价是,服务器端将需要更多的内存。\nSQL_CACHE和SQL_NO_CACHE\n这个提示告诉MySQL这个结果集是否应该缓存在查询缓存中,下一章我们将详细介绍如何使用。\nSQL_CALC_FOUND_ROWS\n严格来说,这并不是一个优化器提示。它不会告诉优化器任何关于执行计划的东西。它会让MySQL返回的结果集包含更多的信息。查询中加上该提示MySQL会计算除去LIMIT子句后这个查询要返回的结果集的总数,而实际上只返回LIMIT要求的结果集。可以通过函数FOUND_ROW()获得这个值。(参阅后面的“SQL_CALC_FOUND_ROWS优化”部分,了解下为什么不应该使用该提示。)\nFOR UPDATE和LOCK IN SHARE MODE\n这也不是真正的优化器提示。这两个提示主要控制SELECT语句的锁机制,但只对实现了行级锁的存储引擎有效。使用该提示会对符合查询条件的数据行加锁。对于INSERT\u0026hellip;SELECT语句是不需要这两个提示的,因为对于MySQL 5.0和更新版本会默认给这些记录加上读锁。(可以禁用该默认行为,但不是个好主意,在后面关于复制和备份的章节中将解释这一点。)\n唯一内置的支持这两个提示的引擎就是InnoDB。另外需要记住的是,这两个提示会让某些优化无法正常使用,例如索引覆盖扫描。InnoDB不能在不访问主键的情况下排他地锁定行,因为行的版本信息保存在主键中。\n糟糕的是,这两个提示经常被滥用,很容易造成服务器的锁争用问题,后面章节我们将讨论这点。应该尽可能地避免使用这两个提示,通常都有其他更好的方式可以实现同样的目的。\nUSE INDEX、IGNORE INDEX和FORCE INDEX\n这几个提示会告诉优化器使用或者不使用哪些索引来查询记录(例如,在决定关联顺序的时候使用哪个索引)。在MySQL 5.0和更早的版本,这些提示并不会影响到优化器选择哪个索引进行排序和分组,在MyQL 5.1和之后的版本可以通过新增选项FOR ORDER BY和FOR GROUP BY来指定是否对排序和分组有效。\nFORCE INDEX和USE INDEX基本相同,除了一点:FORCE INDEX会告诉优化器全表扫描的成本会远远高于索引扫描,哪怕实际上该索引用处不大。当发现优化器选择了错误的索引,或者因为某些原因(比如在不使用ORDER BY的时候希望结果有序)要使用另一个索引时,可以使用该提示。在前面关于如何使用LIMIT高效地获取最小值的案例中,已经演示过这种用法。\n在MySQL 5.0和更新版本中,新增了一些参数用来控制优化器的行为:\noptimizer_search_depth\n这个参数控制优化器在穷举执行计划时的限度。如果查询长时间处于“Statistics”状态,那么可以考虑调低此参数。\noptimizer_prune_level\n该参数默认是打开的,这让优化器会根据需要扫描的行数来决定是否跳过某些执行计划。\noptimizer_switch\n这个变量包含了一些开启/关闭优化器特性的标志位。例如在MySQL 5.1中可以通过这个参数来控制禁用索引合并的特性。\n前两个参数是用来控制优化器可以走的一些“捷径”。这些捷径可以让优化器在处理非常复杂的SQL语句时,仍然可以很高效,但这也可能让优化器错过一些真正最优的执行计划。所以应该根据实际需要来修改这些参数。\nMySQL升级后的验证\n在优化器面前耍一些“小聪明”是不好的。这样做收效甚小,但是却给维护带来了很多额外的工作量。在MySQL版本升级的时候,这个问题就很突出了,你设置的“优化器提示”很可能会让新版的优化策略失效。\nMySQL 5.0版本引入了大量优化策略,在还没有正式发布的5.6版本中,优化器的改进也是近些年来最大的一次改进。如果要更新到这些版本,当然希望能够从这些改进中受益。\n新版MySQL基本上在各个方面都有非常大的改进,5.5和5.6这两个版本尤为突出。升级操作一般来说都很顺利,但仍然建议仔细检查各个细节,以防止一些边界情况影响你的应用程序。不过还好,要避免这些,你不需要付出太多的精力。使用Percona Toolkit中的pt-upgrade工具,就可以检查在新版本中运行的SQL是否与老版本一样,返回相同的结果。\n6.7 优化特定类型的查询 # 这一节,我们将介绍如何优化特定类型的查询。在本书的其他部分都会分散介绍这些优化技巧,不过这里将会汇总一下,以便参考和查阅。\n本节介绍的多数优化技巧都是和特定的版本有关的,所以对于未来MySQL的版本未必适用。毫无疑问,某一天优化器自己也会实现这里列出的部分或者全部优化技巧。\n6.7.1 优化COUNT()查询 # COUNT()聚合函数,以及如何优化使用了该函数的查询,很可能是MySQL中最容易被误解的前10个话题之一。在网上随便搜索一下就能看到很多错误的理解,可能比我们想象的多得多。\n在做优化之前,先来看看COUNT()函数真正的作用是什么。\nCOUNT()的作用 # COUNT()是一个特殊的函数,有两种非常不同的作用:它可以统计某个列值的数量,也可以统计行数。在统计列值时要求列值是非空的(不统计NULL)。如果在COUNT()的括号中指定了列或者列的表达式,则统计的就是这个表达式有值的结果数(24)。因为很多人对NULL理解有问题,所以这里很容易产生误解。如果想了解更多关于SQL语句中NULL的含义,建议阅读一些关于SQL语句基础的书籍。(关于这个话题,互联网上的一些信息是不够精确的。)\nCOUNT()的另一个作用是统计结果集的行数。当MySQL确认括号内的表达式值不可能为空时,实际上就是在统计行数。最简单的就是当我们使用COUNT(*)的时候,这种情况下通配符*并不会像我们猜想的那样扩展成所有的列,实际上,它会忽略所有的列而直接统计所有的行数。\n我们发现一个最常见的错误就是,在括号内指定了一个列却希望统计结果集的行数。如果希望知道的是结果集的行数,最好使用COUNT(*),这样写意义清晰,性能也会很好。\n关于MyISAM的神话 # 一个容易产生的误解就是:MyISAM的COUNT()函数总是非常快,不过这是有前提条件的,即只有没有任何WHERE条件的COUNT(*)才非常快,因为此时无须实际地去计算表的行数。MySQL可以利用存储引擎的特性直接获得这个值。如果MySQL知道某列col不可能为NULL值,那么MySQL内部会将COUNT(col)表达式优化为COUNT(*)。\n当统计带WHERE子句的结果集行数,可以是统计某个列值的数量时,MyISAM的COUNT()和其他存储引擎没有任何不同,就不再有神话般的速度了。所以在MyISAM引擎表上执行COUNT()有时候比别的引擎快,有时候比别的引擎慢,这受很多因素影响,要视具体情况而定。\n简单的优化 # 有时候可以使用MyISAM在COUNT(*)全表非常快的这个特性,来加速一些特定条件的COUNT()的查询。在下面的例子中,我们使用标准数据库world来看看如何快速查找到所有ID大于5的城市。可以像下面这样来写这个查询:\nmysql\u0026gt; ** SELECT COUNT(*) FROM world.City WHERE ID\u0026gt;5;** 通过SHOW STATUS的结果可以看到该查询需要扫描4097行数据。如果将条件反转一下,先查找ID小于等于5的城市数,然后用总城市数一减就能得到同样的结果,却可以将扫描的行数减少到5行以内:\nmysql\u0026gt; ** SELECT (SELECT COUNT(*) FROM world.City) - COUNT(*)** -\u0026gt; ** FROM world.City WHERE ID \u0026lt;= 5;** 这样做可以大大减少需要扫描的行数,是因为在查询优化阶段会将其中的子查询直接当作一个常数来处理,我们可以通过EXPLAIN来验证这点:\n在邮件组和IRC聊天频道中,通常会看到这样的问题:如何在同一个查询中统计同一个列的不同值的数量,以减少查询的语句量。例如,假设可能需要通过一个查询返回各种不同颜色的商品数量,此时不能使用OR语句(比如SELECT COUNT(color=\u0026lsquo;blue\u0026rsquo; OR color=\u0026lsquo;red\u0026rsquo;) FROM items;),因为这样做就无法区分不同颜色的商品数量;也不能在WHERE条件中指定颜色(比如SELECT COUNT(*) FROM items WHERE color=\u0026lsquo;blue\u0026rsquo; AND color=\u0026lsquo;RED\u0026rsquo;;),因为颜色的条件是互斥的。下面的查询可以在一定程度上解决这个问题(25)。\nmysql\u0026gt; ** SELECT SUM(IF(color = 'blue', 1, 0)) AS blue,SUM(IF(color = 'red', 1, 0))** -\u0026gt; ** AS red FROM items;** 也可以使用COUNT()而不是SUM()实现同样的目的,只需要将满足条件设置为真,不满足条件设置为NULL即可:\nmysql\u0026gt; ** SELECT COUNT(color = 'blue' OR NULL) AS blue, COUNT(color = 'red' OR NULL)** -\u0026gt; ** AS red FROM items;** 使用近似值 # 有时候某些业务场景并不要求完全精确的COUNT值,此时可以用近似值来代替。EXPLAIN出来的优化器估算的行数就是一个不错的近似值,执行EXPLAIN并不需要真正地去执行查询,所以成本很低。\n很多时候,计算精确值的成本非常高,而计算近似值则非常简单。曾经有一个客户希望我们统计他的网站的当前活跃用户数是多少,这个活跃用户数保存在缓存中,过期时间为30分钟,所以每隔30分钟需要重新计算并放入缓存。因此这个活跃用户数本身就不是精确值,所以使用近似值代替是可以接受的。另外,如果要精确统计在线人数,通常WHERE条件会很复杂,一方面需要剔除当前非活跃用户,另一方面还要剔除系统中某些特定ID的“默认”用户,去掉这些约束条件对总数的影响很小,但却可能很好地提升该查询的性能。更进一步地优化则可以尝试删除DISTINCT这样的约束来避免文件排序。这样重写过的查询要比原来的精确统计的查询快很多,而返回的结果则几乎相同。\n更复杂的优化 # 通常来说,COUNT()都需要扫描大量的行(意味着要访问大量数据)才能获得精确的结果,因此是很难优化的。除了前面的方法,在MySQL层面还能做的就只有索引覆盖扫描了。如果这还不够,就需要考虑修改应用的架构,可以增加汇总表(第4章已经介绍过),或者增加类似Memcached这样的外部缓存系统。可能很快你就会发现陷入到一个熟悉的困境,“快速,精确和实现简单”,三者永远只能满足其二,必须舍掉其中一个。\n6.7.2 优化关联查询 # 这个话题基本上整本书都在讨论,这里需要特别提到的是:\n确保ON或者USING子句中的列上有索引。在创建索引的时候就要考虑到关联的顺序。当表A和表B用列c关联的时候,如果优化器的关联顺序是B、A,那么就不需要在B表的对应列上建上索引。没有用到的索引只会带来额外的负担。一般来说,除非有其他理由,否则只需要在关联顺序中的第二个表的相应列上创建索引。 确保任何的GROUP BY和ORDER BY中的表达式只涉及到一个表中的列,这样MySQL才有可能使用索引来优化这个过程。 当升级MySQL的时候需要注意:关联语法、运算符优先级等其他可能会发生变化的地方。因为以前是普通关联的地方可能会变成笛卡儿积,不同类型的关联可能会生成不同的结果等。 6.7.3 优化子查询 # 关于子查询优化我们给出的最重要的优化建议就是尽可能使用关联查询代替,至少当前的MySQL版本需要这样。本章的前面章节已经详细介绍了这点。“尽可能使用关联”并不是绝对的,如果使用的是MySQL 5.6或更新的版本或者MariaDB,那么就可以直接忽略关于子查询的这些建议了。\n6.7.4 优化GROUP BY和DISTINCT # 在很多场景下,MySQL都使用同样的办法优化这两种查询,事实上,MySQL优化器会在内部处理的时候相互转化这两类查询。它们都可以使用索引来优化,这也是最有效的优化办法。\n在MySQL中,当无法使用索引的时候,GROUP BY使用两种策略来完成:使用临时表或者文件排序来做分组。对于任何查询语句,这两种策略的性能都有可以提升的地方。可以通过使用提示SQL_BIG_RESULT和SQL_SMALL_RESULT来让优化器按照你希望的方式运行。在本章的前面章节我们已经讨论了这点。\n如果需要对关联查询做分组(GROUP BY),并且是按照查找表中的某个列进行分组,那么通常采用查找表的标识列分组的效率会比其他列更高。例如下面的查询效率不会很好:\nmysql\u0026gt; ** SELECT actor.first_name, actor.last_name, COUNT(*)** -\u0026gt; ** FROM sakila.film_actor** -\u0026gt; ** INNER JOIN sakila.actor USING(actor_id)** -\u0026gt; ** GROUP BY actor.first_name, actor.last_name;** 如果查询按照下面的写法效率则会更高:\nmysql\u0026gt; ** SELECT actor.first_name, actor.last_name, COUNT(*)** -\u0026gt; ** FROM sakila.film_actor** -\u0026gt; ** INNER JOIN sakila.actor USING(actor_id)** -\u0026gt; ** GROUP BY film_actor.actor_id;** 使用actor.actor_id列分组的效率甚至会比使用film_actor.actor_id更好。这一点通过简单的测试即可验证。\n这个查询利用了演员的姓名和ID直接相关的特点,因此改写后的结果不受影响,但显然不是所有的关联语句的分组查询都可以改写成在SELECT中直接使用非分组列的形式的。甚至可能会在服务器上设置SQL_MODE来禁止这样的写法。如果是这样,也可以通过MIN()或者MAX()函数来绕过这种限制,但一定要清楚,SELECT后面出现的非分组列一定是直接依赖分组列,并且在每个组内的值是唯一的,或者是业务上根本不在乎这个值具体是什么:\nmysql\u0026gt; ** SELECT MIN(actor.first_name), MAX(actor.last_name), ...;** 较真的人可能会说这样写的分组查询是有问题的,确实如此。从MIN()或者MAX()函数的用法就可以看出这个查询是有问题的。但若更在乎的是MySQL运行查询的效率时这样做也无可厚非。如果实在较真的话也可以改写成下面的形式:\nmysql\u0026gt; ** SELECT actor.first_name, actor.last_name, c.cnt** -\u0026gt; ** FROM sakila.film_actor** -\u0026gt; ** INNER JOIN (** -\u0026gt; ** SELECT actor_id, COUNT(*) AS cnt** -\u0026gt; ** FROM sakila.film_actor** -\u0026gt; ** GROUP BY actor_id** -\u0026gt; ** ) AS c USING(actor_id) ;** 这样写更满足关系理论,但成本有点高,因为子查询需要创建和填充临时表,而子查询中创建的临时表是没有任何索引的(26)。\n在分组查询的SELECT中直接使用非分组列通常都不是什么好主意,因为这样的结果通常是不定的,当索引改变,或者优化器选择不同的优化策略时都可能导致结果不一样。我们碰到的大多数这种查询最后都导致了故障(因为MySQL不会对这类查询返回错误),而且这种写法大部分是由于偷懒而不是为优化而故意这么设计的。建议始终使用含义明确的语法。事实上,我们建议将MySQL的SQL_MODE设置为包含ONLY_FULL_GROUP_BY,这时MySQL会对这类查询直接返回一个错误,提醒你需要重写这个查询。\n如果没有通过ORDER BY子句显式地指定排序列,当查询使用GROUP BY子句的时候,结果集会自动按照分组的字段进行排序。如果不关心结果集的顺序,而这种默认排序又导致了需要文件排序,则可以使用ORDER BY NULL,让MySQL不再进行文件排序。也可以在GROUP BY子句中直接使用DESC或者ASC关键字,使分组的结果集按需要的方向排序。\n优化GROUP BY WITH ROLLUP # 分组查询的一个变种就是要求MySQL对返回的分组结果再做一次超级聚合。可以使用WITH ROLLUP子句来实现这种逻辑,但可能会不够优化。可以通过EXPLAIN来观察其执行计划,特别要注意分组是否是通过文件排序或者临时表实现的。然后再去掉WITH ROLLUP子句看执行计划是否相同。也可以通过本节前面介绍的优化器提示来固定执行计划。\n很多时候,如果可以,在应用程序中做超级聚合是更好的,虽然这需要返回给客户端更多的结果。也可以在FROM子句中嵌套使用子查询,或者是通过一个临时表存放中间数据,然后和临时表执行UNION来得到最终结果。\n最好的办法是尽可能的将WITH ROLLUP功能转移到应用程序中处理。\n6.7.5 优化LIMIT分页 # 在系统中需要进行分页操作的时候,我们通常会使用LIMIT加上偏移量的办法实现,同时加上合适的ORDER BY子句。如果有对应的索引,通常效率会不错,否则,MySQL需要做大量的文件排序操作。\n一个非常常见又令人头疼的问题就是,在偏移量非常大的时候(27),例如可能是LIMIT 1000,20这样的查询,这时MySQL需要查询10 020条记录然后只返回最后20条,前面10000条记录都将被抛弃,这样的代价非常高。如果所有的页面被访问的频率都相同,那么这样的查询平均需要访问半个表的数据。要优化这种查询,要么是在页面中限制分页的数量,要么是优化大偏移量的性能。\n优化此类分页查询的一个最简单的办法就是尽可能地使用索引覆盖扫描,而不是查询所有的列。然后根据需要做一次关联操作再返回所需的列。对于偏移量很大的时候,这样做的效率会提升非常大。考虑下面的查询:\nmysql\u0026gt; ** SELECT film_id, description FROM sakila.film ORDER BY title LIMIT 50, 5;** 如果这个表非常大,那么这个查询最好改写成下面的样子:\nmysql\u0026gt; ** SELECT film.film_id, film.description** -\u0026gt; ** FROM sakila.film** -\u0026gt; ** INNER JOIN (** -\u0026gt; ** SELECT film_id FROM sakila.film** -\u0026gt; ** ORDER BY title LIMIT 50, 5** -\u0026gt; ** ) AS lim USING(film_id);** 这里的“延迟关联”将大大提升查询效率,它让MySQL扫描尽可能少的页面,获取需要访问的记录后再根据关联列回原表查询需要的所有列。这个技术也可以用于优化关联查询中的LIMIT子句。\n有时候也可以将LIMIT查询转换为已知位置的查询,让MySQL通过范围扫描获得到对应的结果。例如,如果在一个位置列上有索引,并且预先计算出了边界值,上面的查询就可以改写为:\nmysql\u0026gt; ** SELECT film_id, description FROM sakila.film** -\u0026gt; ** WHERE position BETWEEN 50 AND 54 ORDER BY position;** 对数据进行排名的问题也与此类似,但往往还会同时和GROUP BY混合使用。在这种情况下通常都需要预先计算并存储排名信息。\nLIMIT和OFFSET的问题,其实是OFFSET的问题,它会导致MySQL扫描大量不需要的行然后再抛弃掉。如果可以使用书签记录上次取数据的位置,那么下次就可以直接从该书签记录的位置开始扫描,这样就可以避免使用OFFSET。例如,若需要按照租借记录做翻页,那么可以根据最新一条租借记录向后追溯,这种做法可行是因为租借记录的主键是单调增长的。首先使用下面的查询获得第一组结果:\nmysql\u0026gt; ** SELECT * FROM sakila.rental** -\u0026gt; ** ORDER BY rental_id DESC LIMIT 20;** 假设上面的查询返回的是主键为16049到16030的租借记录,那么下一页查询就可以从16030这个点开始:\nmysql\u0026gt; ** SELECT * FROM sakila.rental** -\u0026gt; ** WHERE rental_id \u0026lt; 16030** -\u0026gt; ** ORDER BY rental_id DESC LIMIT 20;** 该技术的好处是无论翻页到多么后面,其性能都会很好。\n其他优化办法还包括使用预先计算的汇总表,或者关联到一个冗余表,冗余表只包含主键列和需要做排序的数据列。还可以使用Sphinx优化一些搜索操作,参考附录F可以获得更多相关信息。\n6.7.6 优化SQL_CALC_FOUND_ROWS # 分页的时候,另一个常用的技巧是在LIMIT语句中加上SQL_CALC_FOUND_ROWS提示(hint),这样就可以获得去掉LIMIT以后满足条件的行数,因此可以作为分页的总数。看起来,MySQL做了一些非常“高深”的优化,像是通过某种方法预测了总行数。但实际上,MySQL只有在扫描了所有满足条件的行以后,才会知道行数,所以加上这个提示以后,不管是否需要,MySQL都会扫描所有满足条件的行,然后再抛弃掉不需要的行,而不是在满足LIMIT的行数后就终止扫描。所以该提示的代价可能非常高。\n一个更好的设计是将具体的页数换成“下一页”按钮,假设每页显示20条记录,那么我们每次查询时都是用LIMIT返回21条记录并只显示20条,如果第21条存在,那么我们就显示“下一页”按钮,否则就说明没有更多的数据,也就无须显示“下一页”按钮了。\n另一种做法是先获取并缓存较多的数据——例如,缓存1000条——然后每次分页都从这个缓存中获取。这样做可以让应用程序根据结果集的大小采取不同的策略,如果结果集少于1000,就可以在页面上显示所有的分页链接,因为数据都在缓存中,所以这样做性能不会有问题。如果结果集大于1000,则可以在页面上设计一个额外的“找到的结果多于1000条”之类的按钮。这两种策略都比每次生成全部结果集再抛弃掉不需要的数据的效率要高很多。\n有时候也可以考虑使用EXPLAIN的结果中的rows列的值来作为结果集总数的近似值(实际上Google的搜索结果总数也是个近似值)。当需要精确结果的时候,再单独使用COUNT(*)来满足需求,这时如果能够使用索引覆盖扫描则通常也会比SQL_CALC_FOUND_ROWS快得多。\n6.7.7 优化UNION查询 # MySQL总是通过创建并填充临时表的方式来执行UNION查询。因此很多优化策略在UNION查询中都没法很好地使用。经常需要手工地将WHERE、LIMIT、ORDER BY等子句“下推”到UNION的各个子查询中,以便优化器可以充分利用这些条件进行优化(例如,直接将这些子句冗余地写一份到各个子查询)。\n除非确实需要服务器消除重复的行,否则就一定要使用UNION ALL,这一点很重要。如果没有ALL关键字,MySQL会给临时表加上DISTINCT选项,这会导致对整个临时表的数据做唯一性检查。这样做的代价非常高。即使有ALL关键字,MySQL仍然会使用临时表存储结果。事实上,MySQL总是将结果放入临时表,然后再读出,再返回给客户端。虽然很多时候这样做是没有必要的(例如,MySQL可以直接把这些结果返回给客户端)。\n6.7.8 静态查询分析 # Percona Toolkit中的pt-query-advisor能够解析查询日志、分析查询模式,然后给出所有可能存在潜在问题的查询,并给出足够详细的建议。这像是给MySQL所有的查询做一次全面的健康检查。它能检测出许多常见的问题,诸如我们前面介绍的内容。\n6.7.9 使用用户自定义变量 # 用户自定义变量是一个容易被遗忘的MySQL特性,但是如果能够用好,发挥其潜力,在某些场景可以写出非常高效的查询语句。在查询中混合使用过程化和关系化逻辑的时候,自定义变量可能会非常有用。单纯的关系查询将所有的东西都当成无序的数据集合,并且一次性操作它们。MySQL则采用了更加程序化的处理方式。MySQL的这种方式有它的弱点,但如果能熟练地掌握,则会发现其强大之处,而用户自定义变量也可以给这种方式带来很大的帮助。\n用户自定义变量是一个用来存储内容的临时容器,在连接MySQL的整个过程中都存在。可以使用下面的SET和SELECT语句来定义它们(28):\nmysql\u0026gt; ** SET @one := 1;** mysql\u0026gt; ** SET @min_actor := (SELECT MIN(actor_id) FROM sakila.actor);** mysql\u0026gt; ** SET @last_week := CURRENT_DATE-INTERVAL 1 WEEK;** 然后可以在任何可以使用表达式的地方使用这些自定义变量:\nmysql\u0026gt; ** SELECT ... WHERE col\u0026lt;=@last_week;** 在了解自定义变量的强大之前,我们再看看它自身的一些属性和限制,看看在哪些场景下我们不能使用用户自定义变量:\n使用自定义变量的查询,无法使用查询缓存。 不能在使用常量或者标识符的地方使用自定义变量,例如表名、列名和LIMIT子句中。 用户自定义变量的生命周期是在一个连接中有效,所以不能用它们来做连接间的通信。 如果使用连接池或者持久化连接,自定义变量可能让看起来毫无关系的代码发生交互(如果是这样,通常是代码bug或者连接池bug,这类情况确实可能发生)。 在5.0之前的版本,是大小写敏感的,所以要注意代码在不同MySQL版本间的兼容性问题。 不能显式地声明自定义变量的类型。确定未定义变量的具体类型的时机在不同MySQL版本中也可能不一样。如果你希望变量是整数类型,那么最好在初始化的时候就赋值为0,如果希望是浮点型则赋值为0.0,如果希望是字符串则赋值为\u0026rsquo;\u0026rsquo;,用户自定义变量的类型在赋值的时候会改变。MySQL的用户自定义变量是一个动态类型。 MySQL优化器在某些场景下可能会将这些变量优化掉,这可能导致代码不按预想的方式运行。 赋值的顺序和赋值的时间点并不总是固定的,这依赖于优化器的决定。实际情况可能很让人困惑,后面我们将看到这一点。 赋值符号:=的优先级非常低,所以需要注意,赋值表达式应该使用明确的括号。使用未定义变量不会产生任何语法错误,如果没有意识到这一点,非常容易犯错。 优化排名语句 # 使用用户自定义变量(29)的一个重要特性是你可以在给一个变量赋值的同时使用这个变量。换句话说,用户自定义变量的赋值具有“左值”特性。下面的例子展示了如何使用变量来实现一个类似“行号(row number)”的功能:\n这个例子的实际意义并不大,它只是实现了一个和该表主键一样的列。不过,我们也可以把这当作一个排名。现在我们来看一个更复杂的用法。我们先编写一个查询获取演过最多电影的前10位演员,然后根据他们的出演电影次数做一个排名,如果出演的电影数量一样,则排名相同。我们先编写一个查询,返回每个演员参演电影的数量:\n现在我们再把排名加上去,这里看到有四名演员都参演了35部电影,所以他们的排名应该是相同的。我们使用三个变量来实现:一个用来记录当前的排名,一个用来记录前一个演员的排名,还有一个用来记录当前演员参演的电影数量。只有当前演员参演的电影的数量和前一个演员不同时,排名才变化。我们先试试下面的写法:\nOops——排名和统计列一直都无法更新,这是什么原因?\n对这类问题,是没法给出一个放之四海皆准的答案的,例如,一个变量名的拼写错误就可能导致这样的问题(这个案例中并不是这个原因),具体问题要具体分析。这里,通过EXPLAIN我们看到将会使用临时表和文件排序,所以可能是由于变量赋值的时间和我们预料的不同。\n在使用用户自定义变量的时候,经常会遇到一些“诡异”的现象,要揪出这些问题的原因通常都不容易,但是相比其带来的好处,深究这些问题是值得的。使用SQL语句生成排名值通常需要做两次计算,例如,需要额外计算一次出演过相同数量电影的演员有哪些。使用变量则可一次完成——这对性能是一个很大的提升。\n针对这个案例,另一个简单的方案是在FROM子句中使用子查询生成一个中间的临时表:\n避免重复查询刚刚更新的数据 # 如果在更新行的同时又希望获得该行的信息,要怎么做才能避免重复的查询呢?不幸的是,MySQL并不支持像PostgreSQL那样的UPDATE RETURNING语法,这个语法可以帮你在更新行的时候同时返回该行的信息。还好在MySQL中你可以使用变量来解决这个问题。例如,我们的一个客户希望能够更高效地更新一条记录的时间戳,同时希望查询当前记录中存放的时间戳是什么。简单地,可以用下面的代码来实现:\nUPDATE t1 SET lastUpdated = NOW() WHERE id = 1; SELECT lastUpdated FROM t1 WHERE id = 1; 使用变量,我们可以按如下方式重写查询:\nUPDATE t1 SET lastUpdated = NOW() WHERE id = 1 AND @now := NOW(); SELECT @now; 上面看起来仍然需要两个查询,需要两次网络来回,但是这里的第二个查询无须访问任何数据表,所以会快非常多。(如果网络延迟非常大,那么这个优化的意义可能不大,不过对这个客户,这样做的效果很好。)\n统计更新和插入的数量 # 当使用了INSERT ON DUPLICATE KEY UPDATE的时候,如果想知道到底插入了多少行数据,到底有多少数据是因为冲突而改写成更新操作的?Kerstian Köhntopp在他的博客上给出了一个解决这个问题的办法(30)。实现办法的本质如下:\nINSERT INTO t1(c1, c2) VALUES(4, 4), (2, 1), (3, 1) ON DUPLICATE KEY UPDATE c1 = VALUES(c1) + ( 0 * ( @x := @x +1 ) ); 当每次由于冲突导致更新时对变量@x自增一次。然后通过对这个表达式乘以0来让其不影响要更新的内容。另外,MySQL的协议会返回被更改的总行数,所以不需要单独统计这个值。\n确定取值的顺序 # 使用用户自定义变量的一个最常见的问题就是没有注意到在赋值和读取变量的时候可能是在查询的不同阶段。例如,在SELECT子句中进行赋值然后在WHERE子句中读取变量,则可能变量取值并不如你所想。下面的查询看起来只返回一个结果,但事实并非如此:\n因为WHERE和SELECT是在查询执行的不同阶段被执行的。如果在查询中再加入ORDER BY的话,结果可能会更不同:\nmysql\u0026gt; ** SET @rownum := 0;** mysql\u0026gt; ** SELECT actor_id, @rownum := @rownum + 1 AS cnt** -\u0026gt; ** FROM sakila.actor** -\u0026gt; ** WHERE @rownum \u0026lt;= 1** -\u0026gt; ** ORDER BY first_name;** 这是因为ORDER BY引入了文件排序,而WHERE条件是在文件排序操作之前取值的,所以这条查询会返回表中的全部记录。解决这个问题的办法是让变量的赋值和取值发生在执行查询的同一阶段:\n小测试:如果在上面的查询中再加上ORDER BY,那会返回什么结果?试试看吧。如果得出的结果出乎你的意料,想想为什么?再看下面这个查询会返回什么,下面的查询中ORDER BY子句会改变变量值,那WHERE语句执行时变量值是多少。\nmysql\u0026gt; ** SET @rownum := 0;** mysql\u0026gt; ** SELECT actor_id, first_name, @rownum AS rownum** -\u0026gt; ** FROM sakila.actor** -\u0026gt; ** WHERE @rownum \u0026lt;= 1** -\u0026gt; ** ORDER BY first_name, LEAST(0, @rownum := @rownum + 1);** 这个最出人意料的变量行为的答案可以在EXPLAIN语句中找到,注意看在Extra列中的“Using where”、“Using temporary”或者“Using filesort”。\n在上面的最后一个例子中,我们引入了一个新的技巧:我们将赋值语句放到LEAST()函数中,这样就可以在完全不改变排序顺序的时候完成赋值操作(在上面例子中,LEAST()函数总是返回0)。这个技巧在不希望对子句的执行结果有影响却又要完成变量赋值的时候很有用。这个例子中,无须在返回值中新增额外列。这样的函数还有GREATEST()、LENGHT()、ISNULL()、NULLIFL()、IF()和COALESCE(),可以单独使用也可以组合使用。例如,COALESCE()可以在一组参数中取第一个已经被定义的变量。\n编写偷懒的UNION # 假设需要编写一个UNION查询,其第一个子查询作为分支条件先执行,如果找到了匹配的行,则跳过第二个分支。在某些业务场景中确实会有这样的需求,比如先在一个频繁访问的表中查找“热”数据,找不到再去另外一个较少访问的表中查找“冷”数据。(区分热数据和冷数据是一个很好的提高缓存命中率的办法)。\n下面的查询会在两个地方查找一个用户——一个主用户表、一个长时间不活跃的用户表,不活跃用户表的目的是为了实现更高效的归档(31):\nSELECT id FROM users WHERE id=123 UNION ALL SELECT id FROM users_archived WHERE id=123; 上面这个查询是可以正常工作的,但是即使在users表中已经找到了记录,上面的查询还是会去归档表users_archived中再查找一次。我们可以用一个偷懒的UNION查询来抑制这样的数据返回,而且只有当第一个表中没有数据时,我们才在第二个表中查询。一旦在第一个表中找到记录,我们就定义一个变量@found。我们通过在结果列中做一次赋值来实现,然后将赋值放在函数GREATEST中来避免返回额外的数据。为了明确我们的结果到底来自哪个表,我们新增了一个包含表名的列。最后我们需要在查询的末尾将变量重置为NULL,这样保证遍历时不干扰后面的结果。完成的查询如下:\nSELECT GREATEST(@found := −1, id) AS id, 'users' AS which_tbl FROM users WHERE id = 1 UNION ALL SELECT id, 'users_archived' FROM users_archived WHERE id = 1 AND @found IS NULL UNION ALL SELECT 1, 'reset' FROM DUAL WHERE ( @found := NULL ) IS NOT NULL; 用户自定义变量的其他用处 # 不仅是在SELECT语句中,在其他任何类型的SQL语句中都可以对变量进行赋值。事实上,这也是用户自定义变量最大的用途。例如,可以像前面使用子查询的方式改进排名语句一样来改进UPDATE语句。\n不过,我们需要使用一些技巧来获得我们希望的结果。有时,优化器会把变量当作一个编译时常量来对待,而不是对其进行赋值。将函数放在类似于LEAST()这样的函数中通常可以避免这样的问题。另一个办法是在查询被执行前检查变量是否被赋值。不同的场景下使用不同的办法。\n通过一些实践,可以了解所有用户自定义变量能够做的有趣的事情,例如下面这些用法:\n查询运行时计算总数和平均值。 模拟GROUP语句中的函数FIRST()和LAST()。 对大量数据做一些数据计算。 计算一个大表的MD5散列值。 编写一个样本处理函数,当样本中的数值超过某个边界值的时候将其变成0。 模拟读/写游标。 在SHOW语句的WHERE子句中加入变量值。 C.J. DATE的难题\nC.J. DATE建议在使用数据库设计方法时尽量让SQL数据库符合传统关系数据库的要求。这也是根据关系模型设计SQL时的初衷,但坦白地说,在这一点上,MySQL远不如其他数据库管理系统做得好。所以如果按照C.J. DATE书中的建议编写的适合关系模型的SQL语句在MySQL中运行的效率并不高,例如编写一个多层的子查询。很不幸,这是因为MySQL本身的限制导致无法按照标准的模式运行。我们强烈建议你阅读这本书SQL and Relational Theory:How to Write Accurate SQL Code( http://shop.xreilly.com/product/0636920022879.do)(O\u0026rsquo;Reilly出版),它将改变你对SQL语句的认识。\n6.8 案例学习 # 通常,我们要做的不是查询优化,不是库表结构优化,不是索引优化也不是应用设计优化——在实践中可能要面对所有这些搅和在一起的情况。本节的案例将为大家介绍一些经常困扰用户的问题和解决方法。另外我们还要推荐Bill Karwin的书SQL Antipatterns(一本实践型的书籍)。它将介绍如何使用SQL解决各种程序员疑难杂症。\n6.8.1 使用MySQL构建一个队列表 # 使用MySQL来实现队列表是一个取巧的做法,我们看到很多系统在高流量、高并发的情况下表现并不好。典型的模式是一个表包含多种类型的记录:未处理记录、已处理记录、正在处理记录等。一个或者多个消费者线程在表中查找未处理的记录,然后声称正在处理,当处理完成后,再将记录更新成已处理状态。一般的,例如邮件发送、多命令处理、评论修改等会使用类似模式。\n通常有两个原因使得大家认为这样的处理方式并不合适。第一,随着队列表越来越大和索引深度的增加,找到未处理记录的速度会随之变慢。你可以通过将队列表分成两部分来解决这个问题,就是将已处理记录归档或者存放到历史表,这可以始终保证队列表很小。\n第二,一般的处理过程分两步,先找到未处理记录然后加锁。找到记录会增加服务器的压力,而加锁操作则会让各个消费者进程增加竞争,因为这是一个串行化的操作。在第11章,我们会看到这为什么会限制可扩展性。\n找到未处理记录一般来说都没问题,如果有问题则可以通过使用消息的方式来通知各个消费者。具体的,可以使用一个带有注释的SLEEP()函数做超时处理,如下:\nSELECT /* waiting on unsent_emails */ SLEEP (10000); 这让线程一直阻塞,直到两个条件之一满足:10000秒后超时,或者另一个线程使用KILL QUERY结束当前的SLEEP。因此,当再向队列表中新增一批数据后,可以通过SHOW PROCESSLIST,根据注释找到当前正在休眠的线程,并将其KILL。你可以使用函数GET_LOCK()和RELEASE_LOCK()来实现通知,或者可以在数据库之外实现,例如使用一个消息服务。\n最后需要解决的问题是如何让消费者标记正在处理的记录,而不至于让多个消费者重复处理一个记录。我们看到大家一般使用SELECT FOR UPDATE来实现。这通常是扩展性问题的根源,这会导致大量的事务阻塞并等待。\n一般,我们要尽量避免使用SELECT FOR UPDATE。不光是队列表,任何情况下都要尽量避免。总是有别的更好的办法实现你的目的。在队列表的案例中,可以直接使用UPDATE来更新记录,然后检查是否还有其他的记录需要处理。我们看看具体实现,我们先建立如下的表:\nCREATE TABLE unsent_emails ( id INT NOT NULL PRIMARY KEY AUTO_INCREMENT -- columns for the message, from, to, subject, etc. status ENUM('unsent', 'claimed', 'sent'), owner INT UNSIGNED NOT NULL DEFAULT 0, ts TIMESTAMP, KEY (owner, status, ts) ); 该表的列owner用来存储当前正在处理这个记录的连接ID,即由函数CONNECTION_ID()返回的ID。如果当前记录没有被任何消费者处理,则该值为0。\n我们还经常看到的一个办法是,如下面所示的一次处理10条记录:\nBEGIN; SELECT id FROM unsent_emails LIMIT 10 FOR UPDATE; -- result: 123, 456, 789 UPDATE unsent_emails SET status = 'claimed', owner = CONNECTION_ID() WHERE id IN(123, 456, 789); COMMIT; 看到这里的SELECT查询可以使用到索引的两个列,因此理论上查找的效率应该更快。问题是,在上面两个查询之间的“间隙时间”,这里的锁会让所有其他同样的查询全部都被阻塞。所有的这样的查询将使用相同的索引,扫描索引相同的部分,所以很可能会被阻塞。\n如果改进成下面的写法,则会更加高效:\nSET AUTOCOMMIT = 1; COMMIT; UPDATE unsent_emails SET status = 'claimed', owner = CONNECTION_ID() WHERE owner = 0 AND status = 'unsent' LIMIT 10; SET AUTOCOMMIT = 0; SELECT id FROM unsent_emails WHERE owner = CONNECTION_ID() AND status = 'claimed'; -- result: 123, 456, 789 根本就无须使用SELECT查询去找到哪些记录还没有被处理。客户端的协议会告诉你更新了几条记录,所以可以知道这次需要处理多少条记录。\n所有的SELECT FOR UPDATE都可以使用类似的方法改写。\n最后还需要处理一种特殊情况:那些正在被进程处理,而进程本身却由于某种原因退出的情况。这种情况处理起来很简单。你只需要定期运行UPDATE语句将它都更新成原始状态就可以了,然后执行SHOW PROCESSLIST,获取当前正在工作的线程ID,并使用一些WHERE条件避免取到那些刚开始处理的进程。假设我们获取的线程ID有(10、20、30),下面的更新语句会将处理时间超过10分钟的记录状态都更新成初始状态:\nUPDATE unsent_emails SET owner = 0, status = \u0026#39;unsent\u0026#39; WHERE owner NOT IN(0, 10, 20, 30) AND status = \u0026#39;claimed\u0026#39; AND ts \u0026lt; CURRENT_TIMESTAMP - INTERVAL 10 MINUTE; 另外,注意看看是如何巧妙地设计索引让这个查询更加高效的。这也是上一章和本章知识的结合。因为我们将范围条件放在WHERE条件的末尾,这个查询恰好能够使用索引的全部列。其他的查询也都能用上这个索引,这就避免了再新增一个额外的索引来满足其他的查询。\n这里我们将总结一下这个案例中的一些基础原则:\n尽量少做事,可以的话就不要做任何事情。除非不得已,否则不要使用轮询,因为这会增加负载,而且还会带来很多低产出的工作。 尽可能快地完成需要做的事情。尽量使用UPDATE代替先SELECT FOR UPDATE再UPDATE的写法,因为事务提交的速度越快,持有的锁时间就越短,可以大大减少竞争和加速串行执行效率。将已经处理完成和未处理的数据分开,保证数据集足够小。 这个案例的另一个启发是,某些查询是无法优化的;考虑使用不同的查询或者不同的策略去实现相同的目的。通常对于SELECT FOR UPDATE就需要这样处理。 有时,最好的办法就是将任务队列从数据库中迁移出来。Redis就是一个很好的队列容器,也可以使用memcached来实现。另一个选择是使用Q4M存储引擎,但我们没有在生产环境使用过这个存储引擎,所以这里也没办法提供更多的参考。RabbitMQ和Gearman(32)也可以实现类似的功能。\n6.8.2 计算两点之间的距离 # 地理信息计算再次出现在我们的书中了。不建议用户使用MySQL做太复杂的空间信息存储——PostgreSQL在这方面是不错的选择——我们这里将介绍一些常用的计算模式。一个典型的例子是计算以某个点为中心,一定半径内的所有点。\n典型的实际案例可能是查找某个点附近所有可以出租的房子,或者社交网站中“匹配”附近的用户,等等。假设我们有如下表:\nCREATE TABLE locations ( id INT NOT NULL PRIMARY KEY AUTO_INCREMENT, name VARCHAR(30), lat FLOAT NOT NULL, lon FLOAT NOT NULL ); INSERT INTO locations(name, lat, lon) VALUES('Charlottesville, Virginia', 38.03, −78.48), ('Chicago, Illinois', 41.85, −87.65), ('Washington, DC', 38.89, −77.04); 这里经度和纬度的单位是“度”,通常我们假设地球是圆的,然后使用两点所在最大圆(半正矢)公式来计算两点之间的距离。现在有坐标latA和lonA、latB和lonB,那么点A和点B的距离计算公式如下:\nACOS( COS(latA) * COS(latB) * COS(lonA - lonB) + SIN(latA) * SIN(latB) ) 计算出的结果是一个弧度,如果要将结果的单位转换成英里或者千米,则需要乘以地球的半径,也就是3 959英里或者6 371千米。假设我们需要找出所有距离Baron所居住的地方Charlottesville 100英里以内的点,那么我们需要将经纬度带入上面的计算公式:\n这类查询不仅无法使用索引,而且还会非常消耗CPU时间,给服务器带来很大的压力,而且我们还得反复计算这个。那要怎样优化呢?\n这个设计中有几个地方可以做优化。第一,看看是否真的需要这么精确的计算。其实这种算法已经有很多不精确的地方了,如下所示:\n两个地方之间的直线距离可能是100英里,但实际上它们之间的行走距离很可能不是这个值。无论你们在哪两个地方,要到达彼此位置的行走距离多半都不是直线距离,路上可能需要绕很多的弯,比如说如果有一条河,需要绕远走到一个有桥的地方。所以,这里计算的绝对距离只是一个参考值。 如果我们根据邮政编码来确定某个人所在的地区,再根据这个地区的中心位置计算他和别人的距离,那么这本身就是一个估算。Baron住在Charlottesville,不过不是在中心地区,他对华盛顿物理位置的中心也不感兴趣。 所以,通常并不需要精确计算,很多应用如果这样计算,多半是认真过头了。这类似于有效数字的估算:计算结果的精度永远都不会比测量的值更高。(换句话说,“错进,错出”。)\n如果不需要太高的精度,那么我们认为地球是圆的应该也没什么问题,其实准确的说应该是椭圆。根据毕达哥拉斯定理,做些三角函数变换,我们可以把上面的公式转换得更简单,只需要做些求和、乘积以及平方根运算,就可以得出一个点是否在另一个点多少英里之内。(33)\n等等,为什么就到这为止?我们是否真需要计算一个圆周呢?为什么不直接使用一个正方形代替?边长为200英里的正方形,一个顶点到中心的距离大概是141英里,这和实际计算的100英里相差得并不是那么远。那我们根据正方形公式来计算弧度为0.0253(100英里)的中心到边长的距离:\nSELECT * FROM locations WHERE lat BETWEEN 38.03 - DEGREES(0.0253) AND 38.03 + DEGREES(0.0253) AND lon BETWEEN −78.48 - DEGREES(0.0253) AND −78.48 + DEGREES(0.0253); 现在我们看看如何使用索引来优化这个查询。简单地,我们可以增加索引(lat,lon)或者(lon,lat)。不过这样做效果并不会很好。正如我们所知,MySQL 5.5和之前的版本,如果第一列是范围查询的话,就无法使用索引后面的列了。因为两个列都是范围的,所以这里只能使用索引的一个列(BETWEEN等效于一个大于和一个小于)。\n我们再次想起了通常使用的IN()优化。我们先新增两个列,用来存储坐标的近似值FLOOR(),然后在查询中使用IN()将所有点的整数值都放到列表中。下面是我们需要新增的列和索引:\nmysql\u0026gt; ** ALTER TABLE locations** -\u0026gt; ** ADD lat_floor INT NOT NULL DEFAULT 0,** -\u0026gt; ** ADD lon_floor INT NOT NULL DEFAULT 0,** -\u0026gt; ** ADD KEY(lat_floor, lon_floor);** mysql\u0026gt; ** UPDATE locations** -\u0026gt; ** SET lat_floor = FLOOR(lat), lon_floor = FLOOR(lon);** 现在我们可以根据坐标的一定范围的近似值来搜索了,这个近似值包括地板值和天花板值,地理上分别对应的是南北。下面的查询为我们只展示了如何查某个范围的所有点;数值需要在应用程序中计算而不是MySQL中:\n现在我们就可以生成IN()列表中的整数了,也就是前面计算的地板和天花板数值之间的数字。下面是加上WHERE条件的完整查询:\nSELECT * FROM locations WHERE lat BETWEEN 38.03 - DEGREES(0.0253) AND 38.03 + DEGREES(0.0253) AND lon BETWEEN −78.48 - DEGREES(0.0253) AND −78.48 + DEGREES(0.0253) AND lat_floor IN(36,37,38,39,40) AND lon_floor IN(-80,-79,-78,-77); 使用近似值会让我们的计算结果有些偏差,所以我们还需要一些额外的条件剔除在正方形之外的点。这和前面使用CRC32做哈希索引类似:先建一个索引帮我们过滤出近似值,再使用精确条件匹配所有的记录并移除不满足条件的记录。\n事实上,到这时我们就无须根据正方形的近似来过滤数据了,我们可以使用最大圆公式或者毕达哥拉斯定理来计算:\nSELECT * FROM locations WHERE lat_floor IN(36,37,38,39,40) AND lon_floor IN(-80,-79,-78,-77) AND 3979 * ACOS( COS(RADIANS(lat)) * COS(RADIANS(38.03)) * COS(RADIANS(lon) - RADIANS(-78.48)) + SIN(RADIANS(lat)) * SIN(RADIANS(38.03)) ) \u0026lt;= 100; 这时计算精度再次回到前面——使用一个精确的圆周——不过,现在的做法更快(34)。只要能够高效地过滤掉大部分的点,例如使用近似整数和索引,之后再做精确数学计算的代价并不大。只是不要直接使用大圆周的算法,否则速度会很慢。\nSphinx有很多内置的地理信息搜索功能,比MySQL实现要好很多。如果正在考虑使用MyISAM的GIS函数,并使用上面的技巧来计算,那么你需要记住:这样做效果并不会很好,MyISAM本身也并不适合大数据量、高并发的应用,另外MyISAM本身还有一些弱点,如数据文件崩溃、表级锁等。\n回顾一下上面的案例,我们采用了下面这些常用的优化策略:\n尽量少做事,可能的话尽量不做事。这个案例中就不要对所有的点计算大圆周公式;先使用简单的方案过滤大多数数据,然后再到过滤出来的更小的集合上使用复杂的公式运算。 快速地完成事情。确保在你的设计中尽可能地让查询都用上合适的索引,使用近似计算(例如本案例中,认为地球是平的,使用一个正方形来近似圆周)来避免复杂的计算。 需要的时候,尽可能让应用程序完成一些计算。例如本案例中,在应用程序中计算所有的三角函数。 6.8.3 使用用户自定义函数 # 当SQL语句已经无法高效地完成某些任务的时候,这里我们将介绍最后一个高级的优化技巧。当你需要更快的速度,那么C和C++是很好的选择。当然,你需要一定的C或C++编程技巧,否则你写的程序很可能会让服务器崩溃。这和“能力越强,责任越大”类似。\n我们将在下一章为你展示如何编写一个用户自定义函数(UDFs),不过这一章就将通过一个案例看看如何用好一个用户自定义函数。有一个客户,在项目中需要如下的功能:“我们需要根据两个随机的64位数字计算它们的XOR值,来看两个数值是否匹配。大约有3500万条的记录需要在秒级别完成。”经过简单的计算就知道,当前的硬件条件下,不可能在MySQL中完成。那如何解决这个问题呢?\n问题的答案是使用Yves Trudeau编写的一个计算程序,这个程序使用SSE4.2指令集,以一个后台程序的方式运行在通用服务器上,然后我们编写一个用户自定义函数,通过简单的网络通信协议和前面的程序进行交互。\nYves的测试表明,分布式运行上面的程序,可以达到在130毫秒内完成4百万次匹配计算。通过这样的方式,可以将密集型的计算放到一些通用的服务器上,同时可以对外界完全透明,看起来是MySQL完成了全部的工作。正如他们在Twitter上说的:#太好了!这是一个典型的业务优化案例,而不仅仅是优化了一个简单的技术问题。\n6.9 总结 # 如果把创建高性能应用程序比作是一个环环相扣的“难题”,除了前面介绍的schema、索引和查询语句设计之外,查询优化应该是解开“难题”的最后一步了。要想写一个好的查询,你必须要理解schema设计、索引设计等,反之亦然。\n理解查询是如何被执行的以及时间都消耗在哪些地方,这依然是前面我们介绍的响应时间的一部分。再加上一些诸如解析和优化过程的知识,就可以更进一步地理解上一章讨论的MySQL如何访问表和索引的内容了。这也从另一个维度帮助读者理解MySQL在访问表和索引时查询和索引的关系。\n优化通常都需要三管齐下:不做、少做、快速地做。我们希望这里的案例能够帮助你将理论和实践联系起来。\n除了这些基础的手段,包括查询、表结构、索引等,MySQL还有一些高级的特性可以帮助你优化应用,例如分区,分区和索引有些类似但是原理不同。MySQL还支持查询缓存,它可以帮你缓存查询结果,当完全相同的查询再次执行时,直接使用缓存结果(回想一下,“不做”)。我们将在下一章中介绍这些特性。\n————————————————————\n(1) 有时候你可能还需要修改一些查询,减少这些查询对系统中运行的其他查询的影响。这种情况下,你是在减少一个查询的资源消耗,这我们在第3章已经讨论过。\n(2) 如果应用服务器和数据库不在同一台主机上,网络开销就显得很明显了。即使是在同一台服务器上仍然会有数据传输的开销。\n(3) 更多内容请参考后面的“优化COUNT()查询”。\n(4) 例如关联查询结果返回的一条记录通常是由多条记录组成。——译者注\n(5) Percona Toolkit中的pt-archiver工具就可以安全而简单地完成这类工作。\n(6) Query Cache。——译者注\n(7) 如果查询太大,服务端会拒绝接收更多的数据并抛出相应错误。\n(8) 你可以使用SQL_BUFFER_RESULT,后面将再介绍这点。\n(9) 回忆一下前面的客户端和服务器的“传球”比喻。——译者注\n(10) 这里是指Query Cache。——译者注\n(11) Percona版本的MySQL中提供了一个新的特性,可以在计算查询语句哈希值时,先将注释移除再算哈希值,这对于不同注释的相同查询可以命中相同的查询缓存结果。\n(12) 例如,在关联操作中,范围检查的执行计划会针对每一行重新评估索引。可以通过EXPLAIN执行计划中的Extra列是否有“range checked for each record”来确认这一点。该执行计划还会增加select_full_range_join这个服务器变量的值。\n(13) 一部电影没有演员,是有点奇怪。不过在示例数据库Sakila中影片SLACKER LIAISONS没有任何演员,它的描述是“鲨鱼和见识过中国古代鳄鱼的学生的简短传说”。\n(14) join。——译者注\n(15) 后面我们会看到MySQL查询执行过程并没有这么简单,MySQL做了很多优化操作。\n(16) MySQL的临时表是没有任何索引的,在编写复杂的子查询和关联查询的时候需要注意这一点。这一点对UNION查询也一样。\n(17) 在MySQL 5.6和MariaDB中有了重大改变,这两个版本都引入了更加复杂的执行计划。\n(18) MySQL根据执行计划生成输出。这和原查询有完全相同的语义,但是查询语句可能并不完全相同。\n(19) 严格来说,MySQL并不根据读取的记录来选择最优的执行计划。实际上,MySQL通过预估需要读取的数据页来选择,读取的数据页越少越好。不过读取的记录数通常能够很好地反映一个查询的成本。\n(20) 查询的cost。——译者注\n(21) 内存。——译者注\n(22) 可以通过一些办法来影响这个行为——例如,我们可以使用SQL_BUFFER_RESULT。参考后面的“查询优化提示”。\n(23) 相当于Oracle中的跳跃索引扫描(skip index scan)。——译者注\n(24) 而不是NULL。——译者注\n(25) 也可以写成这样的SUM()表达式:SUM(color=\u0026lsquo;blue\u0026rsquo;),SUM(color=\u0026lsquo;red\u0026rsquo;)。\n(26) 值得一提的是,MariaDB修复了这个限制。\n(27) 翻页到非常靠后的页面。——译者注\n(28) 在某些场景下,也可以直接使用=进行赋值,不过为了避免歧义,建议始终使用:=。\n(29) 为行文方便,后面在不引起歧义的情况下将简称为“变量”。——译者注\n(30) 参考 http://mysqldump.azundris.com/archives/86-Down-the-dirty-road.html。\n(31) Baron认为在一些社交网站上归档一些常见不活跃用户后,用户重新回到网站时有这样的需求,当用户再次登录时,一方面我们需要将其从归档中重新拿出来,另外,还可以给他发送一份欢迎邮件。这对一些不活跃的用户是非常好的一个优化。在第11章我们还会再次讨论这个问题。\n(32) 参考 http://www.rabbitmq.com和 http://gearman.org。\n(33) 要想有更多的优化,你可以将三角函数的计算放到应用中,而不要在数据库中计算。三角函数是非常消耗CPU的操作。如果将坐标都转换成弧度存放,则对数据库来说就简化了很多。为了保证我们的案例简单,不要引入太多别的因子,所以这里我们将不再做更多的优化了。\n(34) 再一次,需要使用应用程序中的代码来计算这样的表达式COS(RADIANS(38.03))。\n"},{"id":142,"href":"/zh/docs/technology/MySQL/_%E9%AB%98%E6%80%A7%E8%83%BDMySQL_/%E7%AC%AC5%E7%AB%A0%E5%88%9B%E5%BB%BA%E9%AB%98%E6%80%A7%E8%83%BD%E7%9A%84%E7%B4%A2%E5%BC%95/","title":"第5章创建高性能的索引","section":"高性能 My SQL","content":"第5章 创建高性能的索引\n索引(在MySQL中也叫做“键(key)”)是存储引擎用于快速找到记录的一种数据结构。这是索引的基本功能,除此之外,本章还将讨论索引其他一些方面有用的属性。\n索引对于良好的性能非常关键。尤其是当表中的数据量越来越大时,索引对性能的影响愈发重要。在数据量较小且负载较低时,不恰当的索引对性能的影响可能还不明显,但当数据量逐渐增大时,性能则会急剧下降(1)。\n不过,索引却经常被忽略,有时候甚至被误解,所以在实际案例中经常会遇到由糟糕索引导致的问题。这也是我们把索引优化放在了靠前的章节,甚至比查询优化还靠前的原因。\n索引优化应该是对查询性能优化最有效的手段了。索引能够轻易将查询性能提高几个数量级,“最优”的索引有时比一个“好的”索引性能要好两个数量级。创建一个真正“最优”的索引经常需要重写查询,所以,本章和下一章的关系非常紧密。\n5.1 索引基础 # 要理解MySQL中索引是如何工作的,最简单的方法就是去看看一本书的“索引”部分:如果想在一本书中找到某个特定主题,一般会先看书的“索引”,找到对应的页码。\n在MySQL中,存储引擎用类似的方法使用索引,其先在索引中找到对应值,然后根据匹配的索引记录找到对应的数据行。假如要运行下面的查询:\nmysql\u0026gt; ** SELECT first_name FROM sakila.actor WHERE actor_id=5;** 如果在actor_id列上建有索引,则MySQL将使用该索引找到actor_id为5的行,也就是说,MySQL先在索引上按值进行查找,然后返回所有包含该值的数据行。\n索引可以包含一个或多个列的值。如果索引包含多个列,那么列的顺序也十分重要,因为MySQL只能高效地使用索引的最左前缀列。创建一个包含两个列的索引,和创建两个只包含一列的索引是大不相同的,下面将详细介绍。\n如果使用的是ORM,是否还需要关心索引?\n简而言之:是的,仍然需要理解索引,即使是使用对象关系映射(ORM)工具。\nORM工具能够生产符合逻辑的、合法的查询(多数时候),除非只是生成非常基本的查询(例如仅是根据主键查询),否则它很难生成适合索引的查询。无论是多么复杂的ORM工具,在精妙和复杂的索引面前都是“浮云”。读完本章后面的内容以后,你就会同意这个观点的!很多时候,即使是查询优化技术专家也很难兼顾到各种情况,更别说ORM了。\n5.1.1 索引的类型 # 索引有很多种类型,可以为不同的场景提供更好的性能。在MySQL中,索引是在存储引擎层而不是服务器层实现的。所以,并没有统一的索引标准:不同存储引擎的索引的工作方式并不一样,也不是所有的存储引擎都支持所有类型的索引。即使多个存储引擎支持同一种类型的索引,其底层的实现也可能不同。\n下面我们先来看看MySQL支持的索引类型,以及它们的优点和缺点。\nB-Tree索引 # 当人们谈论索引的时候,如果没有特别指明类型,那多半说的是B-Tree索引,它使用B-Tree数据结构来存储数据(2)。大多数MySQL引擎都支持这种索引。Archive引擎是一个例外:5.1之前Archive不支持任何索引,直到5.1才开始支持单个自增列(AUTO_INCREMENT)的索引。\n我们使用术语“B-Tree”,是因为MySQL在CREATE TABLE和其他语句中也使用该关键字。不过,底层的存储引擎也可能使用不同的存储结构,例如,NDB集群存储引擎内部实际上使用了T-Tree结构存储这种索引,即使其名字是BTREE;InnoDB则使用的是B+Tree,各种数据结构和算法的变种不在本书的讨论范围之内。\n存储引擎以不同的方式使用B-Tree索引,性能也各有不同,各有优劣。例如,MyISAM使用前缀压缩技术使得索引更小,但InnoDB则按照原数据格式进行存储。再如MyISAM索引通过数据的物理位置引用被索引的行,而InnoDB则根据主键引用被索引的行。\nB-Tree通常意味着所有的值都是按顺序存储的,并且每一个叶子页到根的距离相同。图5-1展示了B-Tree索引的抽象表示,大致反映了InnoDB索引是如何工作的。MyISAM使用的结构有所不同,但基本思想是类似的。\n图5-1:建立在B-Tree结构(从技术上来说是B+Tree)上的索引\nB-Tree索引能够加快访问数据的速度,因为存储引擎不再需要进行全表扫描来获取需要的数据,取而代之的是从索引的根节点(图示并未画出)开始进行搜索。根节点的槽中存放了指向子节点的指针,存储引擎根据这些指针向下层查找。通过比较节点页的值和要查找的值可以找到合适的指针进入下层子节点,这些指针实际上定义了子节点页中值的上限和下限。最终存储引擎要么是找到对应的值,要么该记录不存在。\n叶子节点比较特别,它们的指针指向的是被索引的数据,而不是其他的节点页(不同引擎的“指针”类型不同)。图5-1中仅绘制了一个节点和其对应的叶子节点,其实在根节点和叶子节点之间可能有很多层节点页。树的深度和表的大小直接相关。\nB-Tree对索引列是顺序组织存储的,所以很适合查找范围数据。例如,在一个基于文本域的索引树上,按字母顺序传递连续的值进行查找是非常合适的,所以像“找出所有以I到K开头的名字”这样的查找效率会非常高。\n假设有如下数据表:\nCREATE TABLE People ( last_name varchar(50) not null, first_name varchar(50) not null, dob date not null, gender enum('m', 'f') not null, key(last_name, first_name, dob) ); 对于表中的每一行数据,索引中包含了last_name、frst_name和dob列的值,图5-2显示了该索引是如何组织数据的存储的。\n图5-2:B-Tree(从技术上来说是B+Tree)索引树中的部分条目示例\n请注意,索引对多个值进行排序的依据是CREATE TABLE语句中定义索引时列的顺序。看一下最后两个条目,两个人的姓和名都一样,则根据他们的出生日期来排列顺序。\n可以使用B-Tree索引的查询类型。B-Tree索引适用于全键值、键值范围或键前缀查找。其中键前缀查找只适用于根据最左前缀的查找(3)。前面所述的索引对如下类型的查询有效。\n全值匹配\n全值匹配指的是和索引中的所有列进行匹配,例如前面提到的索引可用于查找姓名为Cuba Allen、出生于1960-01-01的人。\n匹配最左前缀\n前面提到的索引可用于查找所有姓为Allen的人,即只使用索引的第一列。\n匹配列前缀\n也可以只匹配某一列的值的开头部分。例如前面提到的索引可用于查找所有以J开头的姓的人。这里也只使用了索引的第一列。\n匹配范围值\n例如前面提到的索引可用于查找姓在Allen和Barrymore之间的人。这里也只使用了索引的第一列。\n精确匹配某一列并范围匹配另外一列\n前面提到的索引也可用于查找所有姓为Allen,并且名字是字母K开头(比如Kim、Karl等)的人。即第一列last_name全匹配,第二列frst_name范围匹配。\n只访问索引的查询\nB-Tree通常可以支持“只访问索引的查询”,即查询只需要访问索引,而无须访问数据行。后面我们将单独讨论这种“覆盖索引”的优化。\n因为索引树中的节点是有序的,所以除了按值查找之外,索引还可以用于查询中的ORDER BY操作(按顺序查找)。一般来说,如果B-Tree可以按照某种方式查找到值,那么也可以按照这种方式用于排序。所以,如果ORDER BY子句满足前面列出的几种查询类型,则这个索引也可以满足对应的排序需求。\n下面是一些关于B-Tree索引的限制:\n如果不是按照索引的最左列开始查找,则无法使用索引。例如上面例子中的索引无法用于查找名字为Bill的人,也无法查找某个特定生日的人,因为这两列都不是最左数据列。类似地,也无法查找姓氏以某个字母结尾的人。 不能跳过索引中的列。也就是说,前面所述的索引无法用于查找姓为Smith并且在某个特定日期出生的人。如果不指定名(first_name),则MySQL只能使用索引的第一列。 如果查询中有某个列的范围查询,则其右边所有列都无法使用索引优化查找。例如有查询WHERE last_name=\u0026lsquo;Smith\u0026rsquo; AND frst_name LIKE \u0026lsquo;J%\u0026rsquo; AND dob=\u0026lsquo;1976-12-23\u0026rsquo;,这个查询只能使用索引的前两列,因为这里LIKE是一个范围条件(但是服务器可以把其余列用于其他目的)。如果范围查询列值的数量有限,那么可以通过使用多个等于条件来代替范围条件。在本章的索引案例学习部分,我们将演示一个详细的案例。 到这里读者应该可以明白,前面提到的索引列的顺序是多么的重要:这些限制都和索引列的顺序有关。在优化性能的时候,可能需要使用相同的列但顺序不同的索引来满足不同类型的查询需求。\n也有些限制并不是B-Tree本身导致的,而是MySQL优化器和存储引擎使用索引的方式导致的,这部分限制在未来的版本中可能就不再是限制了。\n哈希索引 # 哈希索引(hash index)基于哈希表实现,只有精确匹配索引所有列的查询才有效(4)。对于每一行数据,存储引擎都会对所有的索引列计算一个哈希码(hash code),哈希码是一个较小的值,并且不同键值的行计算出来的哈希码也不一样。哈希索引将所有的哈希码存储在索引中,同时在哈希表中保存指向每个数据行的指针。\n在MySQL中,只有Memory引擎显式支持哈希索引。这也是Memory引擎表的默认索引类型,Memory引擎同时也支持B-Tree索引。值得一提的是,Memory引擎是支持非唯一哈希索引的,这在数据库世界里面是比较与众不同的。如果多个列的哈希值相同,索引会以链表的方式存放多个记录指针到同一个哈希条目中。\n下面来看一个例子。假设有如下表:\nCREATE TABLE testhash ( fname VARCHAR(50) NOT NULL, lname VARCHAR(50) NOT NULL, KEY USING HASH(fname) ) ENGINE=MEMORY; 表中包含如下数据:\n假设索引使用假想的哈希函数f(),它返回下面的值(都是示例数据,非真实数据):\nf('Arjen')= 2323 f('Baron')= 7437 f('Peter')= 8784 f('Vadim')= 2458 则哈希索引的数据结构如下: 槽(Slot) 值(Value) 2323 指向第1 行的指针 2458 指向第4 行的指针 7437 指向第2 行的指针 8784 指向第3 行的指针\n注意每个槽的编号是顺序的,但是数据行不是。现在,来看如下查询:\nmysql\u0026gt; ** SELECT lname FROM testhash WHERE fname='Peter';** MySQL先计算\u0026rsquo;Peter\u0026rsquo;的哈希值,并使用该值寻找对应的记录指针。因为f(\u0026lsquo;Peter\u0026rsquo;)=8784,所以MySQL在索引中查找8784,可以找到指向第3行的指针,最后一步是比较第三行的值是否为\u0026rsquo;Peter\u0026rsquo;,以确保就是要查找的行。\n因为索引自身只需存储对应的哈希值,所以索引的结构十分紧凑,这也让哈希索引查找的速度非常快。然而,哈希索引也有它的限制:\n哈希索引只包含哈希值和行指针,而不存储字段值,所以不能使用索引中的值来避免读取行。不过,访问内存中的行的速度很快,所以大部分情况下这一点对性能的影响并不明显。 哈希索引数据并不是按照索引值顺序存储的,所以也就无法用于排序。 哈希索引也不支持部分索引列匹配查找,因为哈希索引始终是使用索引列的全部内容来计算哈希值的。例如,在数据列(A,B)上建立哈希索引,如果查询只有数据列A,则无法使用该索引。 哈希索引只支持等值比较查询,包括=、IN()、\u0026lt;=\u0026gt;(注意\u0026lt;\u0026gt;和\u0026lt;=\u0026gt;是不同的操作)。也不支持任何范围查询,例如WHERE price\u0026gt;100。 访问哈希索引的数据非常快,除非有很多哈希冲突(不同的索引列值却有相同的哈希值)。当出现哈希冲突的时候,存储引擎必须遍历链表中所有的行指针,逐行进行比较,直到找到所有符合条件的行。 如果哈希冲突很多的话,一些索引维护操作的代价也会很高。例如,如果在某个选择性很低(哈希冲突很多)的列上建立哈希索引,那么当从表中删除一行时,存储引擎需要遍历对应哈希值的链表中的每一行,找到并删除对应行的引用,冲突越多,代价越大。 因为这些限制,哈希索引只适用于某些特定的场合。而一旦适合哈希索引,则它带来的性能提升将非常显著。举个例子,在数据仓库应用中有一种经典的“星型”schema,需要关联很多查找表,哈希索引就非常适合查找表的需求。\n除了Memory引擎外,NDB集群引擎也支持唯一哈希索引,且在NDB集群引擎中作用非常特殊,但这不属于本书的范围。\nInnoDB引擎有一个特殊的功能叫做“自适应哈希索引(adaptive hash index)”。当InnoDB注意到某些索引值被使用得非常频繁时,它会在内存中基于B-Tree索引之上再创建一个哈希索引,这样就让B-Tree索引也具有哈希索引的一些优点,比如快速的哈希查找。这是一个完全自动的、内部的行为,用户无法控制或者配置,不过如果有必要,完全可以关闭该功能。\n创建自定义哈希索引。如果存储引擎不支持哈希索引,则可以模拟像InnoDB一样创建哈希索引,这可以享受一些哈希索引的便利,例如只需要很小的索引就可以为超长的键创建索引。\n思路很简单:在B-Tree基础上创建一个伪哈希索引。这和真正的哈希索引不是一回事,因为还是使用B-Tree进行查找,但是它使用哈希值而不是键本身进行索引查找。你需要做的就是在查询的WHERE子句中手动指定使用哈希函数。\n下面是一个实例,例如需要存储大量的URL,并需要根据URL进行搜索查找。如果使用B-Tree来存储URL,存储的内容就会很大,因为URL本身都很长。正常情况下会有如下查询:\nmysql\u0026gt; ** SELECT id FROM url WHERE url=\u0026quot;http://www.mysql.com\u0026quot;;** 若删除原来URL列上的索引,而新增一个被索引的url_crc列,使用CRC32做哈希,就可以使用下面的方式查询:\nmysql\u0026gt; ** SELECT id FROM url WHERE url=\u0026quot;http://www.mysql.com\u0026quot;** -\u0026gt; ** AND url_crc=CRC32(\u0026quot;http://www.mysql.com\u0026quot;);** 这样做的性能会非常高,因为MySQL优化器会使用这个选择性很高而体积很小的基于url_crc列的索引来完成查找(在上面的案例中,索引值为1560514994)。即使有多个记录有相同的索引值,查找仍然很快,只需要根据哈希值做快速的整数比较就能找到索引条目,然后一一比较返回对应的行。另外一种方式就是对完整的URL字符串做索引,那样会非常慢。\n这样实现的缺陷是需要维护哈希值。可以手动维护,也可以使用触发器实现。下面的案例演示了触发器如何在插入和更新时维护url_crc列。首先创建如下表:\nCREATE TABLE pseudohash ( id int unsigned NOT NULL auto_increment, url varchar(255) NOT NULL, url_crc int unsigned NOT NULL DEFAULT 0, PRIMARY KEY(id) ); 然后创建触发器。先临时修改一下语句分隔符,这样就可以在触发器定义中使用分号:\nDELIMITER // CREATE TRIGGER pseudohash_crc_ins BEFORE INSERT ON pseudohash FOR EACH ROW BEGIN SET NEW.url_crc=crc32(NEW.url); END; // CREATE TRIGGER pseudohash_crc_upd BEFORE UPDATE ON pseudohash FOR EACH ROW BEGIN SET NEW.url_crc=crc32(NEW.url); END; // DELIMITER ; 剩下的工作就是验证一下触发器如何维护哈希索引:\n如果采用这种方式,记住不要使用SHA1()和MD5()作为哈希函数。因为这两个函数计算出来的哈希值是非常长的字符串,会浪费大量空间,比较时也会更慢。SHA1()和MD5()是强加密函数,设计目标是最大限度消除冲突,但这里并不需要这样高的要求。简单哈希函数的冲突在一个可以接受的范围,同时又能够提供更好的性能。\n如果数据表非常大,CRC32()会出现大量的哈希冲突,则可以考虑自己实现一个简单的64位哈希函数。这个自定义函数要返回整数,而不是字符串。一个简单的办法可以使用MD5()函数返回值的一部分来作为自定义哈希函数。这可能比自己写一个哈希算法的性能要差(参考第7章),不过这样实现最简单:\n处理哈希冲突。当使用哈希索引进行查询的时候,必须在WHERE子句中包含常量值:\nmysql\u0026gt; ** SELECT id FROM url WHERE url_crc=CRC32(\u0026quot;http://www.mysql.com\u0026quot;)** -\u0026gt; ** AND url=\u0026quot;http://www.mysql.com\u0026quot;;** 一旦出现哈希冲突,另一个字符串的哈希值也恰好是1560514994,则下面的查询是无法正确工作的。\nmysql\u0026gt; ** SELECT id FROM url WHERE url_crc=CRC32(\u0026quot;http://www.mysql.com\u0026quot;);** 因为所谓的“生日悖论”(5),出现哈希冲突的概率的增长速度可能比想象的要快得多。CRC32()返回的是32位的整数,当索引有93000条记录时出现冲突的概率是1%。例如我们将*/usr/share/dict/words*中的词导入数据表并进行CRC32()计算,最后会有98 569行。这就已经出现一次哈希冲突了,冲突让下面的查询返回了多条记录:\n正确的写法应该如下:\n要避免冲突问题,必须在WHERE条件中带入哈希值和对应列值。如果不是想查询具体值,例如只是统计记录数(不精确的),则可以不带入列值,直接使用CRC32()的哈希值查询即可。还可以使用如FNV64()函数作为哈希函数,这是移植自Percona Server的函数,可以以插件的方式在任何MySQL版本中使用,哈希值为64位,速度快,且冲突比CRC32()要少很多。\n空间数据索引(R-Tree) # MyISAM表支持空间索引,可以用作地理数据存储。和B-Tree索引不同,这类索引无须前缀查询。空间索引会从所有维度来索引数据。查询时,可以有效地使用任意维度来组合查询。必须使用MySQL的GIS相关函数如MBRCONTAINS()等来维护数据。MySQL的GIS支持并不完善,所以大部分人都不会使用这个特性。开源关系数据库系统中对GIS的解决方案做得比较好的是PostgreSQL的PostGIS。\n全文索引 # 全文索引是一种特殊类型的索引,它查找的是文本中的关键词,而不是直接比较索引中的值。全文搜索和其他几类索引的匹配方式完全不一样。它有许多需要注意的细节,如停用词、词干和复数、布尔搜索等。全文索引更类似于搜索引擎做的事情,而不是简单的WHERE条件匹配。\n在相同的列上同时创建全文索引和基于值的B-Tree索引不会有冲突,全文索引适用于MATCH AGAINST操作,而不是普通的WHERE条件操作。\n我们将在第7章讨论更多的全文索引的细节。\n其他索引类别 # 还有很多第三方的存储引擎使用不同类型的数据结构来存储索引。例如TokuDB使用分形树索引(fractal tree index),这是一类较新开发的数据结构,既有B-Tree的很多优点,也避免了B-Tree的一些缺点。如果通读完本章,可以看到很多关于InnoDB的主题,包括聚簇索引、覆盖索引等。多数情况下,针对InnoDB的讨论也都适用于TokuDB。\nScaleDB使用Patricia tries(这个词不是拼写错误),其他一些存储引擎技术如InfiniDB和Infobright则使用了一些特殊的数据结构来优化某些特殊的查询。\n5.2 索引的优点 # 索引可以让服务器快速地定位到表的指定位置。但是这并不是索引的唯一作用,到目前为止可以看到,根据创建索引的数据结构不同,索引也有一些其他的附加作用。\n最常见的B-Tree索引,按照顺序存储数据,所以MySQL可以用来做ORDER BY和GROUP BY操作。因为数据是有序的,所以B-Tree也就会将相关的列值都存储在一起。最后,因为索引中存储了实际的列值,所以某些查询只使用索引就能够完成全部查询。据此特性,总结下来索引有如下三个优点:\n索引大大减少了服务器需要扫描的数据量。 索引可以帮助服务器避免排序和临时表。 索引可以将随机I/O变为顺序I/O。 “索引”这个主题完全值得单独写一本书,如果想深入理解这部分内容,强烈建议阅读由Tapio Lahdenmaki和Mike Leach编写的Relational Database Index Design and the Optimizers(Wiley出版社)一书,该书详细介绍了如何计算索引的成本和作用、如何评估查询速度、如何分析索引维护的代价和其带来的好处等。\nLahdenmaki和Leach在书中介绍了如何评价一个索引是否适合某个查询的“三星系统”(three-star system):索引将相关的记录放到一起则获得一星;如果索引中的数据顺序和查找中的排列顺序一致则获得二星;如果索引中的列包含了查询中需要的全部列则获得“三星”。后面我们将会介绍这些原则。\n索引是最好的解决方案吗?\n索引并不总是最好的工具。总的来说,只有当索引帮助存储引擎快速查找到记录带来的好处大于其带来的额外工作时,索引才是有效的。对于非常小的表,大部分情况下简单的全表扫描更高效。对于中到大型的表,索引就非常有效。但对于特大型的表,建立和使用索引的代价将随之增长。这种情况下,则需要一种技术可以直接区分出查询需要的一组数据,而不是一条记录一条记录地匹配。例如可以使用分区技术,请参考第7章。\n如果表的数量特别多,可以建立一个元数据信息表,用来查询需要用到的某些特性。例如执行那些需要聚合多个应用分布在多个表的数据的查询,则需要记录“哪个用户的信息存储在哪个表中”的元数据,这样在查询时就可以直接忽略那些不包含指定用户信息的表。对于大型系统,这是一个常用的技巧。事实上,Infobright就是使用类似的实现。对于TB级别的数据,定位单条记录的意义不大,所以经常会使用块级别元数据技术来替代索引。\n5.3 高性能的索引策略 # 正确地创建和使用索引是实现高性能查询的基础。前面已经介绍了各种类型的索引及其对应的优缺点。现在我们一起来看看如何真正地发挥这些索引的优势。\n高效地选择和使用索引有很多种方式,其中有些是针对特殊案例的优化方法,有些则是针对特定行为的优化。使用哪个索引,以及如何评估选择不同索引的性能影响的技巧,则需要持续不断地学习。接下来的几个小节将帮助读者理解如何高效地使用索引。\n5.3.1 独立的列 # 我们通常会看到一些查询不当地使用索引,或者使得MySQL无法使用已有的索引。如果查询中的列不是独立的,则MySQL就不会使用索引。“独立的列”是指索引列不能是表达式的一部分,也不能是函数的参数。\n例如,下面这个查询无法使用actor_id列的索引:\nmysql\u0026gt; ** SELECT actor_id FROM sakila.actor WHERE actor_id + 1 = 5;** 凭肉眼很容易看出WHERE中的表达式其实等价于actor_id=4,但是MySQL无法自动解析这个方程式。这完全是用户行为。我们应该养成简化WHERE条件的习惯,始终将索引列单独放在比较符号的一侧。\n下面是另一个常见的错误:\nmysql\u0026gt; ** SELECT ... WHERE TO_DAYS(CURRENT_DATE) - TO_DAYS(date_col) \u0026lt;= 10;** 5.3.2 前缀索引和索引选择性 # 有时候需要索引很长的字符列,这会让索引变得大且慢。一个策略是前面提到过的模拟哈希索引。但有时候这样做还不够,还可以做些什么呢?\n通常可以索引开始的部分字符,这样可以大大节约索引空间,从而提高索引效率。但这样也会降低索引的选择性。索引的选择性是指,不重复的索引值(也称为基数,cardinality)和数据表的记录总数(#T)的比值,范围从1/#T到1之间。索引的选择性越高则查询效率越高,因为选择性高的索引可以让MySQL在查找时过滤掉更多的行。唯一索引的选择性是1,这是最好的索引选择性,性能也是最好的。\n一般情况下某个列前缀的选择性也是足够高的,足以满足查询性能。对于BLOB、TEXT或者很长的VARCHAR类型的列,必须使用前缀索引,因为MySQL不允许索引这些列的完整长度。\n诀窍在于要选择足够长的前缀以保证较高的选择性,同时又不能太长(以便节约空间)。前缀应该足够长,以使得前缀索引的选择性接近于索引整个列。换句话说,前缀的“基数”应该接近于完整列的“基数”。\n为了决定前缀的合适长度,需要找到最常见的值的列表,然后和最常见的前缀列表进行比较。在示例数据库Sakila中并没有合适的例子,所以我们从表city中生成一个示例表,这样就有足够的数据进行演示:\nCREATE TABLE sakila.city_demo(city VARCHAR(50) NOT NULL); INSERT INTO sakila.city_demo(city) SELECT city FROM sakila.city; -- Repeat the next statement five times: city_demo; Now randomize the distribution (inefficiently but conveniently): UPDATE sakila.city_demo SET city = (SELECT city FROM sakila.city ORDER BY RAND() LIMIT 1); 现在我们有了示例数据集。数据分布当然不是真实的分布;因为我们使用了RAND(),所以你的结果会与此不同,但对这个练习来说这并不重要。首先,我们找到最常见的城市列表:\n注意到,上面每个值都出现了45~65次。现在查找到最频繁出现的城市前缀,先从3个前缀字母开始:\n每个前缀都比原来的城市出现的次数更多,因此唯一前缀比唯一城市要少得多。然后我们增加前缀长度,直到这个前缀的选择性接近完整列的选择性。经过实验后发现前缀长度为7时比较合适:\n计算合适的前缀长度的另外一个办法就是计算完整列的选择性,并使前缀的选择性接近于完整列的选择性。下面显示如何计算完整列的选择性:\n通常来说(尽管也有例外情况),这个例子中如果前缀的选择性能够接近0.031,基本上就可用了。可以在一个查询中针对不同前缀长度进行计算,这对于大表非常有用。下面给出了如何在同一个查询中计算不同前缀长度的选择性:\n查询显示当前缀长度到达7的时候,再增加前缀长度,选择性提升的幅度已经很小了。\n只看平均选择性是不够的,也有例外的情况,需要考虑最坏情况下的选择性。平均选择性会让你认为前缀长度为4或者5的索引已经足够了,但如果数据分布很不均匀,可能就会有陷阱。如果观察前缀为4的最常出现城市的次数,可以看到明显不均匀:\n如果前缀是4个字节,则最常出现的前缀的出现次数比最常出现的城市的出现次数要大很多。即这些值的选择性比平均选择性要低。如果有比这个随机生成的示例更真实的数据,就更有可能看到这种现象。例如在真实的城市名上建一个长度为4的前缀索引,对于以“San”和“New”开头的城市的选择性就会非常糟糕,因为很多城市都以这两个词开头。\n在上面的示例中,已经找到了合适的前缀长度,下面演示一下如何创建前缀索引:\nmysql\u0026gt; ** ALTER TABLE sakila.city_demo ADD KEY (city(7));** 前缀索引是一种能使索引更小、更快的有效办法,但另一方面也有其缺点:MySQL无法使用前缀索引做ORDER BY和GROUP BY,也无法使用前缀索引做覆盖扫描。\n一个常见的场景是针对很长的十六进制唯一ID使用前缀索引。在前面的章节中已经讨论了很多有效的技术来存储这类ID信息,但如果使用的是打包过的解决方案,因而无法修改存储结构,那该怎么办?例如使用vBulletin或者其他基于MySQL的应用在存储网站的会话(SESSION)时,需要在一个很长的十六进制字符串上创建索引。此时如果采用长度为8的前缀索引通常能显著地提升性能,并且这种方法对上层应用完全透明。\n有时候后缀索引(suffix index)也有用途(例如,找到某个域名的所有电子邮件地址)。MySQL原生并不支持反向索引,但是可以把字符串反转后存储,并基于此建立前缀索引。可以通过触发器来维护这种索引。参考5.1节中“创建自定义哈希索引”部分的相关内容。\n5.3.3 多列索引 # 很多人对多列索引的理解都不够。一个常见的错误就是,为每个列创建独立的索引,或者按照错误的顺序创建多列索引。\n我们会在5.3.4节中单独讨论索引列的顺序问题。先来看第一个问题,为每个列创建独立的索引,从SHOW CREATE TABLE中很容易看到这种情况:\nCREATE TABLE t ( c1 INT, c2 INT, c3 INT, KEY(c1), KEY(c2), KEY(c3) ); 这种索引策略,一般是由于人们听到一些专家诸如“把WHERE条件里面的列都建上索引”这样模糊的建议导致的。实际上这个建议是非常错误的。这样一来最好的情况下也只能是“一星”索引,其性能比起真正最优的索引可能差几个数量级。有时如果无法设计一个“三星”索引,那么不如忽略掉WHERE子句,集中精力优化索引列的顺序,或者创建一个全覆盖索引。\n在多个列上建立独立的单列索引大部分情况下并不能提高MySQL的查询性能。MySQL 5.0和更新版本引入了一种叫“索引合并”(index merge)的策略,一定程度上可以使用表上的多个单列索引来定位指定的行。更早版本的MySQL只能使用其中某一个单列索引,然而这种情况下没有哪一个独立的单列索引是非常有效的。例如,表film_actor在字段film_id和actor_id上各有一个单列索引。但对于下面这个查询WHERE条件,这两个单列索引都不是好的选择:\nmysql\u0026gt; ** SELECT film_id, actor_id FROM sakila.film_actor** -\u0026gt; ** WHERE actor_id = 1 OR film_id = 1;** 在老的MySQL版本中,MySQL对这个查询会使用全表扫描。除非改写成如下的两个查询UNION的方式:\nmysql\u0026gt; ** SELECT film_id, actor_id FROM sakila.film_actor WHERE actor_id = 1** -\u0026gt; ** UNION ALL** -\u0026gt; ** AND actor_id \u0026lt;\u0026gt; 1;** 但在MySQL 5.0和更新的版本中,查询能够同时使用这两个单列索引进行扫描,并将结果进行合并。这种算法有三个变种:OR条件的联合(union),AND条件的相交(intersection),组合前两种情况的联合及相交。下面的查询就是使用了两个索引扫描的联合,通过EXPLAIN中的Extra列可以看到这点:\nmysql\u0026gt; ** EXPLAIN SELECT film_id, actor_id FROM sakila.film_actor** -\u0026gt; ** WHERE actor_id = 1 OR film_id = 1\\G** *************************** 1. row *************************** id: 1 select_type: SIMPLE table: film_actor type: index_merge possible_keys: PRIMARY,idx_fk_film_id key: PRIMARY,idx_fk_film_id key_len: 2,2 ref: NULL rows: 29 Extra: Using union(PRIMARY,idx_fk_film_id); Using where MySQL会使用这类技术优化复杂查询,所以在某些语句的Extra列中还可以看到嵌套操作。\n索引合并策略有时候是一种优化的结果,但实际上更多时候说明了表上的索引建得很糟糕:\n当出现服务器对多个索引做相交操作时(通常有多个AND条件),通常意味着需要一个包含所有相关列的多列索引,而不是多个独立的单列索引。 当服务器需要对多个索引做联合操作时(通常有多个OR条件),通常需要耗费大量CPU和内存资源在算法的缓存、排序和合并操作上。特别是当其中有些索引的选择性不高,需要合并扫描返回的大量数据的时候。 更重要的是,优化器不会把这些计算到“查询成本”(cost)中,优化器只关心随机页面读取。这会使得查询的成本被“低估”,导致该执行计划还不如直接走全表扫描。这样做不但会消耗更多的CPU和内存资源,还可能会影响查询的并发性,但如果是单独运行这样的查询则往往会忽略对并发性的影响。通常来说,还不如像在MySQL 4.1或者更早的时代一样,将查询改写成UNION的方式往往更好。 如果在EXPLAIN中看到有索引合并,应该好好检查一下查询和表的结构,看是不是已经是最优的。也可以通过参数optimizer_switch来关闭索引合并功能。也可以使用IGNORE INDEX提示让优化器忽略掉某些索引。\n5.3.4 选择合适的索引列顺序 # 我们遇到的最容易引起困惑的问题就是索引列的顺序。正确的顺序依赖于使用该索引的查询,并且同时需要考虑如何更好地满足排序和分组的需要(顺便说明,本节内容适用于B-Tree索引;哈希或者其他类型的索引并不会像B-Tree索引一样按顺序存储数据)。\n在一个多列B-Tree索引中,索引列的顺序意味着索引首先按照最左列进行排序,其次是第二列,等等。所以,索引可以按照升序或者降序进行扫描,以满足精确符合列顺序的ORDER BY、GROUP BY和DISTINCT等子句的查询需求。\n所以多列索引的列顺序至关重要。在Lahdenmaki和Leach的“三星索引”系统中,列顺序也决定了一个索引是否能够成为一个真正的“三星索引”(关于三星索引可以参考本章前面的5.2节)。在本章的后续部分我们将通过大量的例子来说明这一点。\n对于如何选择索引的列顺序有一个经验法则:将选择性最高的列放到索引最前列。这个建议有用吗?在某些场景可能有帮助,但通常不如避免随机IO和排序那么重要,考虑问题需要更全面(场景不同则选择不同,没有一个放之四海皆准的法则。这里只是说明,这个经验法则可能没有你想象的重要)。\n当不需要考虑排序和分组时,将选择性最高的列放在前面通常是很好的。这时候索引的作用只是用于优化WHERE条件的查找。在这种情况下,这样设计的索引确实能够最快地过滤出需要的行,对于在WHERE子句中只使用了索引部分前缀列的查询来说选择性也更高。然而,性能不只是依赖于所有索引列的选择性(整体基数),也和查询条件的具体值有关,也就是和值的分布有关。这和前面介绍的选择前缀的长度需要考虑的地方一样。可能需要根据那些运行频率最高的查询来调整索引列的顺序,让这种情况下索引的选择性最高。\n以下面的查询为例:\nSELECT * FROM payment WHERE staff_id = 2 AND ** customer_id** = 584; 是应该创建一个(staff_id,customer_id)索引还是应该颠倒一下顺序?可以跑一些查询来确定在这个表中值的分布情况,并确定哪个列的选择性更高。先用下面的查询预测一下(6),看看各个WHERE条件的分支对应的数据基数有多大:\nmysql\u0026gt; ** SELECT SUM(staff_id = 2), SUM(customer_id = 584) FROM payment\\G** *************************** 1. row *************************** SUM(staff_id = 2): 7992 SUM(customer_id = 584): 30 根据前面的经验法则,应该将索引列customer_id放到前面,因为对应条件值的customer_id数量更小。我们再来看看对于这个customer_id的条件值,对应的staff_id列的选择性如何:\nmysql\u0026gt; ** SELECT SUM(staff_id = 2) FROM payment WHERE customer_id = 584\\G** *************************** 1. row *************************** SUM(staff_id = 2): 17 这样做有一个地方需要注意,查询的结果非常依赖于选定的具体值。如果按上述办法优化,可能对其他一些条件值的查询不公平,服务器的整体性能可能变得更糟,或者其他某些查询的运行变得不如预期。\n如果是从诸如pt-query-digest这样的工具的报告中提取“最差”查询,那么再按上述办法选定的索引顺序往往是非常高效的。如果没有类似的具体查询来运行,那么最好还是按经验法则来做,因为经验法则考虑的是全局基数和选择性,而不是某个具体查询:\nmysql\u0026gt; ** SELECT COUNT(DISTINCT staff_id)/COUNT(*) AS staff_id_selectivity,** \u0026gt; ** COUNT(DISTINCT customer_id)/COUNT(*) AS customer_id_selectivity,** \u0026gt; ** COUNT(*)** \u0026gt; ** FROM payment\\G** *************************** 1. row *************************** staff_id_selectivity: 0.0001 customer_id_selectivity: 0.0373 COUNT(*): 16049 customer_id的选择性更高,所以答案是将其作为索引列的第一列:\nmysql\u0026gt; ** ALTER TABLE payment ADD KEY(customer_id, staff_id);** 当使用前缀索引的时候,在某些条件值的基数比正常值高的时候,问题就来了。例如,在某些应用程序中,对于没有登录的用户,都将其用户名记录为“guset”,在记录用户行为的会话(session)表和其他记录用户活动的表中“guest”就成为了一个特殊用户ID。一旦查询涉及这个用户,那么和对于正常用户的查询就大不同了,因为通常有很多会话都是没有登录的。系统账号也会导致类似的问题。一个应用通常都有一个特殊的管理员账号,和普通账号不同,它并不是一个具体的用户,系统中所有的其他用户都是这个用户的好友,所以系统往往通过它向网站的所有用户发送状态通知和其他消息。这个账号的巨大的好友列表很容易导致网站出现服务器性能问题。\n这实际上是一个非常典型的问题。任何的异常用户,不仅仅是那些用于管理应用的设计糟糕的账号会有同样的问题;那些拥有大量好友、图片、状态、收藏的用户,也会有前面提到的系统账号同样的问题。\n下面是一个我们遇到过的真实案例,在一个用户分享购买商品和购买经验的论坛上,这个特殊表上的查询运行得非常慢:\nmysql\u0026gt; ** SELECT COUNT(DISTINCT threadId) AS COUNT_VALUE** -\u0026gt; ** FROM Message** -\u0026gt; ** WHERE (groupId = 10137) AND (userId = 1288826) AND (anonymous = 0)** -\u0026gt; ** ORDER BY priority DESC, modifiedDate DESC** 这个查询看似没有建立合适的索引,所以客户咨询我们是否可以优化。EXPLAIN的结果如下:\nid: 1 select_type: SIMPLE table: Message type: ref key: ix_groupId_userId key_len: 18 ref: const,const rows: 1251162 Extra: Using where MySQL为这个查询选择了索引(groupId,userId),如果不考虑列的基数,这看起来是一个非常合理的选择。但如果考虑一下user ID和group ID条件匹配的行数,可能就会有不同的想法了:\nmysql\u0026gt; ** SELECT COUNT(*), SUM(groupId = 10137),** -\u0026gt; ** SUM(userId = 1288826), SUM(anonymous = 0)** -\u0026gt; ** FROM Message\\G** *************************** 1. row *************************** count(*): 4142217 sum(groupId = 10137): 4092654 sum(userId = 1288826): 1288496 sum(anonymous = 0): 4141934 从上面的结果来看符合组(groupId)条件几乎满足表中的所有行,符合用户(userId)条件的有130万条记录——也就是说索引基本上没什么用。因为这些数据是从其他应用中迁移过来的,迁移的时候把所有的消息都赋予了管理员组的用户。这个案例的解决办法是修改应用程序代码,区分这类特殊用户和组,禁止针对这类用户和组执行这个查询。\n从这个小案例可以看到经验法则和推论在多数情况是有用的,但要注意不要假设平均情况下的性能也能代表特殊情况下的性能,特殊情况可能会摧毁整个应用的性能。\n最后,尽管关于选择性和基数的经验法则值得去研究和分析,但一定要记住别忘了WHERE子句中的排序、分组和范围条件等其他因素,这些因素可能对查询的性能造成非常大的影响。\n5.3.5 聚簇索引 # 聚簇索引(7)并不是一种单独的索引类型,而是一种数据存储方式。具体的细节依赖于其实现方式,但InnoDB的聚簇索引实际上在同一个结构中保存了B-Tree索引和数据行。当表有聚簇索引时,它的数据行实际上存放在索引的叶子页(leaf page)中。术语“聚簇”表示数据行和相邻的键值紧凑地存储在一起(8)。因为无法同时把数据行存放在两个不同的地方,所以一个表只能有一个聚簇索引(不过,覆盖索引可以模拟多个聚簇索引的情况,本章后面将详细介绍)。\n因为是存储引擎负责实现索引,因此不是所有的存储引擎都支持聚簇索引。本节我们主要关注InnoDB,但是这里讨论的原理对于任何支持聚簇索引的存储引擎都是适用的。\n图5-3展示了聚簇索引中的记录是如何存放的。注意到,叶子页包含了行的全部数据,但是节点页只包含了索引列。在这个案例中,索引列包含的是整数值。\n图5-3:聚簇索引的数据分布\n一些数据库服务器允许选择哪个索引作为聚簇索引,但直到本书写作之际,还没有任何一个MySQL内建的存储引擎支持这一点。InnoDB将通过主键聚集数据,这也就是说图5-3中的“被索引的列”就是主键列。\n如果没有定义主键,InnoDB会选择一个唯一的非空索引代替。如果没有这样的索引,InnoDB会隐式定义一个主键来作为聚簇索引。InnoDB只聚集在同一个页面中的记录。包含相邻键值的页面可能会相距甚远。\n聚簇主键可能对性能有帮助,但也可能导致严重的性能问题。所以需要仔细地考虑聚簇索引,尤其是将表的存储引擎从InnoDB改成其他引擎的时候(反过来也一样)。\n聚集的数据有一些重要的优点:\n可以把相关数据保存在一起。例如实现电子邮箱时,可以根据用户ID来聚集数据,这样只需要从磁盘读取少数的数据页就能获取某个用户的全部邮件。如果没有使用聚簇索引,则每封邮件都可能导致一次磁盘I/O。 数据访问更快。聚簇索引将索引和数据保存在同一个B-Tree中,因此从聚簇索引中获取数据通常比在非聚簇索引中查找要快。 使用覆盖索引扫描的查询可以直接使用页节点中的主键值。 如果在设计表和查询时能充分利用上面的优点,那就能极大地提升性能。同时,聚簇索引也有一些缺点:\n聚簇数据最大限度地提高了I/O密集型应用的性能,但如果数据全部都放在内存中,则访问的顺序就没那么重要了,聚簇索引也就没什么优势了。 插入速度严重依赖于插入顺序。按照主键的顺序插入是加载数据到InnoDB表中速度最快的方式。但如果不是按照主键顺序加载数据,那么在加载完成后最好使用OPTIMIZE TABLE命令重新组织一下表。 更新聚簇索引列的代价很高,因为会强制InnoDB将每个被更新的行移动到新的位置。 基于聚簇索引的表在插入新行,或者主键被更新导致需要移动行的时候,可能面临“页分裂(page split)”的问题。当行的主键值要求必须将这一行插入到某个已满的页中时,存储引擎会将该页分裂成两个页面来容纳该行,这就是一次页分裂操作。页分裂会导致表占用更多的磁盘空间。 聚簇索引可能导致全表扫描变慢,尤其是行比较稀疏,或者由于页分裂导致数据存储不连续的时候。 二级索引(非聚簇索引)可能比想象的要更大,因为在二级索引的叶子节点包含了引用行的主键列。 二级索引访问需要两次索引查找,而不是一次。 最后一点可能让人有些疑惑,为什么二级索引需要两次索引查找?答案在于二级索引中保存的“行指针”的实质。要记住,二级索引叶子节点保存的不是指向行的物理位置的指针,而是行的主键值。\n这意味着通过二级索引查找行,存储引擎需要找到二级索引的叶子节点获得对应的主键值,然后根据这个值去聚簇索引中查找到对应的行。这里做了重复的工作:两次B-Tree查找而不是一次(9)。对于InnoDB,自适应哈希索引能够减少这样的重复工作。\nInnoDB和MyISAM的数据分布对比 # 聚簇索引和非聚簇索引的数据分布有区别,以及对应的主键索引和二级索引的数据分布也有区别,通常会让人感到困扰和意外。来看看InnoDB和MyISAM是如何存储下面这个表的:\nCREATE TABLE layout_test ( col1 int NOT NULL, col2 int NOT NULL, PRIMARY KEY(col1), KEY(col2) ); 假设该表的主键取值为1~10000,按照随机顺序插入并使用OPTIMIZE TABLE命令做了优化。换句话说,数据在磁盘上的存储方式已经最优,但行的顺序是随机的。列col2的值是从1~100之间随机赋值,所以有很多重复的值。\nMyISAM的数据分布。MyISAM的数据分布非常简单,所以先介绍它。MyISAM按照数据插入的顺序存储在磁盘上,如图5-4所示。\n图5-4:MyISAM表layout_test的数据分布\n在行的旁边显示了行号,从0开始递增。因为行是定长的,所以MyISAM可以从表的开头跳过所需的字节找到需要的行(MyISAM并不总是使用图5-4中的“行号”,而是根据定长还是变长的行使用不同策略)。\n这种分布方式很容易创建索引。下面显示的一系列图,隐藏了页的物理细节,只显示索引中的“节点”,索引中的每个叶子节点包含“行号”。图5-5显示了表的主键。\n图5-5:MyISAM表layout_test的主键分布\n这里忽略了一些细节,例如前一个B-Tree节点有多少个内部节点,不过这并不影响对非聚簇存储引擎的基本数据分布的理解。\n那col2列上的索引又会如何呢?有什么特殊的吗?回答是否定的:它和其他索引没有什么区别。图5-6显示了col2列上的索引。\n图5-6:MyISAM表layout_test的col2列索引的分布\n事实上,MyISAM中主键索引和其他索引在结构上没有什么不同。主键索引就是一个名为PRIMARY的唯一非空索引。\nInnoDB的数据分布。因为InnoDB支持聚簇索引,所以使用非常不同的方式存储同样的数据。InnoDB以如图5-7所示的方式存储数据。\n图5-7:InnoDB表layout_test的主键分布\n第一眼看上去,感觉该图和前面的图5-5没有什么不同,但再仔细看细节,会注意到该图显示了整个表,而不是只有索引。因为在InnoDB中,聚簇索引“就是”表,所以不像MyISAM那样需要独立的行存储。\n聚簇索引的每一个叶子节点都包含了主键值、事务ID、用于事务和MVCC(10)的回滚指针以及所有的剩余列(在这个例子中是col2)。如果主键是一个列前缀索引,InnoDB也会包含完整的主键列和剩下的其他列。\n还有一点和MyISAM的不同是,InnoDB的二级索引和聚簇索引很不相同。InnoDB二级索引的叶子节点中存储的不是“行指针”,而是主键值,并以此作为指向行的“指针”。这样的策略减少了当出现行移动或者数据页分裂时二级索引的维护工作。使用主键值当作指针会让二级索引占用更多的空间,换来的好处是,InnoDB在移动行时无须更新二级索引中的这个“指针”。\n图5-8显示了示例表的col2索引。每一个叶子节点都包含了索引列(这里是col2),紧接着是主键值(col1)。\n图5-8展示了B-Tree的叶子节点结构,但我们故意省略了非叶子节点这样的细节。InnoDB的非叶子节点包含了索引列和一个指向下级节点的指针(下一级节点可以是非叶子节点,也可以是叶子节点)。这对聚簇索引和二级索引都适用。\n图5-8:InnoDB表layout_test的二级索引分布\n图5-9是描述InnoDB和MyISAM如何存放表的抽象图。从图5-9中可以很容易看出InnoDB和MyISAM保存数据和索引的区别。\n图5-9:聚簇和非聚簇表对比图\n如果还没有理解聚簇索引和非聚簇索引有什么区别、为何有这些区别及这些区别的重要性,也不用担心。随着学习的深入,尤其是学完本章剩下的部分以及下一章以后,这些问题就会变得越发清楚。这些概念有些复杂,需要一些时间才能完全理解。\n在InnoDB表中按主键顺序插入行 # 如果正在使用InnoDB表并且没有什么数据需要聚集,那么可以定义一个代理键(surrogate key)作为主键,这种主键的数据应该和应用无关,最简单的方法是使用AUTO_INCREMENT自增列。这样可以保证数据行是按顺序写入,对于根据主键做关联操作的性能也会更好。\n最好避免随机的(不连续且值的分布范围非常大)聚簇索引,特别是对于I/O密集型的应用。例如,从性能的角度考虑,使用UUID来作为聚簇索引则会很糟糕:它使得聚簇索引的插入变得完全随机,这是最坏的情况,使得数据没有任何聚集特性。\n为了演示这一点,我们做如下两个基准测试。第一个使用整数ID插入userinfo表:\nCREATE TABLE userinfo ( id int unsigned NOT NULL AUTO_INCREMENT, name varchar(64) NOT NULL DEFAULT '', email varchar(64) NOT NULL DEFAULT '', password varchar(64) NOT NULL DEFAULT '', dob date DEFAULT NULL, address varchar(255) NOT NULL DEFAULT '', city varchar(64) NOT NULL DEFAULT '', state_id tinyint unsigned NOT NULL DEFAULT '0', zip varchar(8) NOT NULL DEFAULT '', country_id smallint unsigned NOT NULL DEFAULT '0', gender ('M','F')NOT NULL DEFAULT 'M', account_type varchar(32) NOT NULL DEFAULT '', verified tinyint NOT NULL DEFAULT '0', allow_mail tinyint unsigned NOT NULL DEFAULT '0', parrent_account int unsigned NOT NULL DEFAULT '0', closest_airport varchar(3) NOT NULL DEFAULT '', PRIMARY KEY (id), UNIQUE KEY email (email), KEY country_id (country_id), KEY state_id (state_id), KEY state_id_2 (state_id,city,address) ) ENGINE=InnoDB 注意到使用了自增的整数ID作为主键(11)。\n第二个例子是userinfo_uuid表。除了主键改为UUID,其余和前面的userinfo表完全相同。\nCREATE TABLE userinfo_uuid ( uuid varchar(36) NOT NULL, ... 我们测试了这两个表的设计。首先,我们在一个有足够内存容纳索引的服务器上向这两个表各插入100万条记录。然后向这两个表继续插入300万条记录,使索引的大小超过服务器的内存容量。表5-1对测试结果做了比较。\n表5-1:向InnoDB表插入数据的测试结果 表名 行数 时间(秒) 索引大小(MB) userinfo 1000000 137 342 userinfo_uuid 1000000 180 544 userinfo 3000000 1233 1036 userinfo_uuid 3000000 4525 1707\n注意到向UUID主键插入行不仅花费的时间更长,而且索引占用的空间也更大。这一方面是由于主键字段更长;另一方面毫无疑问是由于页分裂和碎片导致的。\n为了明白为什么会这样,来看看往第一个表中插入数据时,索引发生了什么变化。图5-10显示了插满一个页面后继续插入相邻的下一个页面的场景。\n图5-10:向聚簇索引插入顺序的索引值\n如图5-10所示,因为主键的值是顺序的,所以InnoDB把每一条记录都存储在上一条记录的后面。当达到页的最大填充因子时(InnoDB默认的最大填充因子是页大小的15/16,留出部分空间用于以后修改),下一条记录就会写入新的页中。一旦数据按照这种顺序的方式加载,主键页就会近似于被顺序的记录填满,这也正是所期望的结果(然而,二级索引页可能是不一样的)。\n对比一下向第二个使用了UUID聚簇索引的表插入数据,看看有什么不同,图5-11显示了结果。\n因为新行的主键值不一定比之前插入的大,所以InnoDB无法简单地总是把新行插入到索引的最后,而是需要为新的行寻找合适的位置——通常是已有数据的中间位置——并且分配空间。这会增加很多的额外工作,并导致数据分布不够优化。下面是总结的一些缺点:\n写入的目标页可能已经刷到磁盘上并从缓存中移除,或者是还没有被加载到缓存中,InnoDB在插入之前不得不先找到并从磁盘读取目标页到内存中。这将导致大量的随机I/O。 因为写入是乱序的,InnoDB不得不频繁地做页分裂操作,以便为新的行分配空间。页分裂会导致移动大量数据,一次插入最少需要修改三个页而不是一个页。 由于频繁的页分裂,页会变得稀疏并被不规则地填充,所以最终数据会有碎片。 在把这些随机值载入到聚簇索引以后,也许需要做一次OPTIMIZE TABLE来重建表并优化页的填充。\n从这个案例可以看出,使用InnoDB时应该尽可能地按主键顺序插入数据,并且尽可能地使用单调增加的聚簇键的值来插入新行。\n图5-11:向聚簇索引中插入无序的值\n顺序的主键什么时候会造成更坏的结果?\n对于高并发工作负载,在InnoDB中按主键顺序插入可能会造成明显的争用。主键的上界会成为“热点”。因为所有的插入都发生在这里,所以并发插入可能导致间隙锁竞争。另一个热点可能是AUTO_INCREMENT锁机制;如果遇到这个问题,则可能需要考虑重新设计表或者应用,或者更改innodb_autoinc_lock_mode配置。如果你的服务器版本还不支持innodb_autoinc_lock_mode参数,可以升级到新版本的InnoDB,可能对这种场景会工作得更好。\n5.3.6 覆盖索引 # 通常大家都会根据查询的WHERE条件来创建合适的索引,不过这只是索引优化的一个方面。设计优秀的索引应该考虑到整个查询,而不单单是WHERE条件部分。索引确实是一种查找数据的高效方式,但是MySQL也可以使用索引来直接获取列的数据,这样就不再需要读取数据行。如果索引的叶子节点中已经包含要查询的数据,那么还有什么必要再回表查询呢?如果一个索引包含(或者说覆盖)所有需要查询的字段的值,我们就称之为“覆盖索引”。\n覆盖索引是非常有用的工具,能够极大地提高性能。考虑一下如果查询只需要扫描索引而无须回表,会带来多少好处:\n索引条目通常远小于数据行大小,所以如果只需要读取索引,那MySQL就会极大地减少数据访问量。这对缓存的负载非常重要,因为这种情况下响应时间大部分花费在数据拷贝上。覆盖索引对于I/O密集型的应用也有帮助,因为索引比数据更小,更容易全部放入内存中(这对于MyISAM尤其正确,因为MyISAM能压缩索引以变得更小)。 因为索引是按照列值顺序存储的(至少在单个页内是如此),所以对于I/O密集型的范围查询会比随机从磁盘读取每一行数据的I/O要少得多。对于某些存储引擎,例如MyISAM和Percona XtraDB,甚至可以通过OPTIMIZE命令使得索引完全顺序排列,这让简单的范围查询能使用完全顺序的索引访问。 一些存储引擎如MyISAM在内存中只缓存索引,数据则依赖于操作系统来缓存,因此要访问数据需要一次系统调用。这可能会导致严重的性能问题,尤其是那些系统调用占了数据访问中的最大开销的场景。 由于InnoDB的聚簇索引,覆盖索引对InnoDB表特别有用。InnoDB的二级索引在叶子节点中保存了行的主键值,所以如果二级主键能够覆盖查询,则可以避免对主键索引的二次查询。 在所有这些场景中,在索引中满足查询的成本一般比查询行要小得多。\n不是所有类型的索引都可以成为覆盖索引。覆盖索引必须要存储索引列的值,而哈希索引、空间索引和全文索引等都不存储索引列的值,所以MySQL只能使用B-Tree索引做覆盖索引。另外,不同的存储引擎实现覆盖索引的方式也不同,而且不是所有的引擎都支持覆盖索引(在写作本书时,Memory存储引擎就不支持覆盖索引)。\n当发起一个被索引覆盖的查询(也叫做索引覆盖查询)时,在EXPLAIN的Extra列可以看到“Using index”的信息(12)。例如,表sakila.inventory有一个多列索引(store_id,flm_id)。MySQL如果只需访问这两列,就可以使用这个索引做覆盖索引,如下所示:\nmysql\u0026gt; ** EXPLAIN SELECT store_id, film_id FROM sakila.inventory\\G** *************************** 1. row *************************** id: 1 select_type: SIMPLE table: inventory type: index possible_keys: NULL key: idx_store_id_film_id key_len: 3 ref: NULL rows: 4673 Extra: Using index 索引覆盖查询还有很多陷阱可能会导致无法实现优化。MySQL查询优化器会在执行查询前判断是否有一个索引能进行覆盖。假设索引覆盖了WHERE条件中的字段,但不是整个查询涉及的字段。如果条件为假(false),MySQL 5.5和更早的版本也总是会回表获取数据行,尽管并不需要这一行且最终会被过滤掉。\n来看看为什么会发生这样的情况,以及如何重写查询以解决该问题。从下面的查询开始:\nmysql\u0026gt; ** EXPLAIN SELECT * FROM products WHERE actor='SEAN CARREY'** -\u0026gt; ** AND title like '%APOLLO%'\\G** *************************** 1. row *************************** id: 1 select_type: SIMPLE table: products type: ref possible_keys: ACTOR,IX_PROD_ACTOR key: ACTOR key_len: 52 ref: const rows: 10 Extra: Using where 这里索引无法覆盖该查询,有两个原因:\n没有任何索引能够覆盖这个查询。因为查询从表中选择了所有的列,而没有任何索引覆盖了所有的列。不过,理论上MySQL还有一个捷径可以利用:WHERE条件中的列是有索引可以覆盖的,因此MySQL可以使用该索引找到对应的actor并检查title是否匹配,过滤之后再读取需要的数据行。 MySQL不能在索引中执行LIKE操作。这是底层存储引擎API的限制,MySQL 5.5和更早的版本中只允许在索引中做简单比较操作(例如等于、不等于以及大于)。MySQL能在索引中做最左前缀匹配的LIKE比较,因为该操作可以转换为简单的比较操作,但是如果是通配符开头的LIKE查询,存储引擎就无法做比较匹配。这种情况下,MySQL服务器只能提取数据行的值而不是索引值来做比较。 也有办法可以解决上面说的两个问题,需要重写查询并巧妙地设计索引。先将索引扩展至覆盖三个数据列(artist,title,prod_id),然后按如下方式重写查询:\nmysql\u0026gt; ** EXPLAIN SELECT *** -\u0026gt; ** FROM products** -\u0026gt; ** JOIN (** -\u0026gt; ** SELECT prod_id** -\u0026gt; ** FROM products** -\u0026gt; ** WHERE actor='SEAN CARREY' AND title LIKE '%APOLLO%'** -\u0026gt; ** ) AS t1 ON (t1.prod_id=products.prod_id)\\G** *************************** 1. row *************************** id: 1 select_type: PRIMARY table: \u0026lt;derived2\u0026gt; ...omitted... *************************** 2. row *************************** id: 1 select_type: PRIMARY table: products ...omitted... *************************** 3. row *************************** id: 2 select_type: DERIVED table: products type: ref possible_keys: ACTOR,ACTOR_2,IX_PROD_ACTOR key: ACTOR_2 key_len: 52 ref: rows: 11 Extra: Using where; Using index 我们把这种方式叫做延迟关联(deferred join),因为延迟了对列的访问。在查询的第一阶段MySQL可以使用覆盖索引,在FROM子句的子查询中找到匹配的prod_id,然后根据这些prod_id值在外层查询匹配获取需要的所有列值。虽然无法使用索引覆盖整个查询,但总算比完全无法利用索引覆盖的好。\n这样优化的效果取决于WHERE条件匹配返回的行数。假设这个products表有100万行,我们来看一下上面两个查询在三个不同的数据集上的表现,每个数据集都包含100万行:\n第一个数据集,Sean Carrey出演了30000部作品,其中有20000部的标题中包含了Apollo。 第二个数据集,Sean Carrey出演了30000部作品,其中40部的标题中包含了Apollo。 第三个数据集,Sean Carrey出演了50部作品,其中10部的标题中包含了Apollo。 使用上面的三种数据集来测试两种不同的查询,得到的结果如表5-2所示。\n表5-2:索引覆盖查询和非覆盖查询的测试结果 数据集 原查询 优化后的查询 示例 1 每秒5 次查询 每秒5 次查询 示例 2 每秒7 次查询 每秒35 次查询 示例 3 每秒2400 次查询 每秒2000 次查询\n下面是对结果的分析:\n在示例1中,查询返回了一个很大的结果集,因此看不到优化的效果。大部分时间都花在读取和发送数据上了。 在示例2中,经过索引过滤,尤其是第二个条件过滤后只返回了很少的结果集,优化的效果非常明显:在这个数据集上性能提高了5倍,优化后的查询的效率主要得益于只需要读取40行完整数据行,而不是原查询中需要的30000行。 在示例3中,显示了子查询效率反而下降的情况。因为索引过滤时符合第一个条件的结果集已经很小,所以子查询带来的成本反而比从表中直接提取完整行更高。 在大多数存储引擎中,覆盖索引只能覆盖那些只访问索引中部分列的查询。不过,可以更进一步优化InnoDB。回想一下,InnoDB的二级索引的叶子节点都包含了主键的值,这意味着InnoDB的二级索引可以有效地利用这些“额外”的主键列来覆盖查询。\n例如,sakila.actor使用InnoDB存储引擎,并在last_name字段有二级索引,虽然该索引的列不包括主键actor_id,但也能够用于对actor_id做覆盖查询:\nmysql\u0026gt; ** EXPLAIN SELECT actor_id, last_name** -\u0026gt; ** FROM sakila.actor WHERE last_name = 'HOPPER'\\G** *************************** 1. row *************************** id: 1 select_type: SIMPLE table: actor type: ref possible_keys: idx_actor_last_name key: idx_actor_last_name key_len: 137 ref: const rows: 2 Extra: Using where; Using index 未来MySQL版本的改进\n上面提到的很多限制都是由于存储引擎API设计所导致的,目前的API设计不允许MySQL将过滤条件传到存储引擎层。如果MySQL在后续版本能够做到这一点,则可以把查询发送到数据上,而不是像现在这样只能把数据从存储引擎拉到服务器层,再根据查询条件过滤。在本书写作之际,MySQL 5.6版本(未正式发布)包含了在存储引擎API上所做的一个重要的改进,其被称为“索引条件推送(index condition pushdown)”。这个特性将大大改善现在的查询执行方式,如此一来上面介绍的很多技巧也就不再需要了。\n5.3.7 使用索引扫描来做排序 # MySQL有两种方式可以生成有序的结果:通过排序操作;或者按索引顺序扫描(13);如果EXPLAIN出来的type列的值为“index”,则说明MySQL使用了索引扫描来做排序(不要和Extra列的“Using index”搞混淆了)。\n扫描索引本身是很快的,因为只需要从一条索引记录移动到紧接着的下一条记录。但如果索引不能覆盖查询所需的全部列,那就不得不每扫描一条索引记录就都回表查询一次对应的行。这基本上都是随机I/O,因此按索引顺序读取数据的速度通常要比顺序地全表扫描慢,尤其是在I/O密集型的工作负载时。\nMySQL可以使用同一个索引既满足排序,又用于查找行。因此,如果可能,设计索引时应该尽可能地同时满足这两种任务,这样是最好的。\n只有当索引的列顺序和ORDER BY子句的顺序完全一致,并且所有列的排序方向(倒序或正序)都一样时,MySQL才能够使用索引来对结果做排序(14)。如果查询需要关联多张表,则只有当ORDER BY子句引用的字段全部为第一个表时,才能使用索引做排序。ORDER BY子句和查找型查询的限制是一样的:需要满足索引的最左前缀的要求;否则,MySQL都需要执行排序操作,而无法利用索引排序。\n有一种情况下ORDER BY子句可以不满足索引的最左前缀的要求,就是前导列为常量的时候。如果WHERE子句或者JOIN子句中对这些列指定了常量,就可以“弥补”索引的不足。\n例如,Sakila示例数据库的表rental在列(rental_date,inventory_id,customer_id)上有名为rental_date的索引。\n(rental_date, inventory_id, customer_id): CREATE TABLE rental ( ... PRIMARY KEY (rental_id), UNIQUE KEY rental_date (rental_date,inventory_id,customer_id), KEY idx_fk_inventory_id (inventory_id), KEY idx_fk_customer_id (customer_id), KEY idx_fk_staff_id (staff_id), ... ); MySQL可以使用rental_date索引为下面的查询做排序,从EXPLAIN中可以看到没有出现文件排序(filesort)操作(15):\nmysql\u0026gt; ** EXPLAIN SELECT rental_id, staff_id FROM sakila.rental** -\u0026gt; ** WHERE rental_date = '2005-05-25'** -\u0026gt; ** ORDER BY inventory_id, customer_id\\G** *************************** 1. row *************************** type: ref possible_keys: rental_date key: rental_date rows: 1 Extra: Using where 即使ORDER BY子句不满足索引的最左前缀的要求,也可以用于查询排序,这是因为索引的第一列被指定为一个常数。\n还有更多可以使用索引做排序的查询示例。下面这个查询可以利用索引排序,是因为查询为索引的第一列提供了常量条件,而使用第二列进行排序,将两列组合在一起,就形成了索引的最左前缀:\n... WHERE rental_date = '2005-05-25' ORDER BY inventory_id DESC; 下面这个查询也没问题,因为ORDER BY使用的两列就是索引的最左前缀:\n下面是一些不能使用索引做排序的查询:\n下面这个查询使用了两种不同的排序方向,但是索引列都是正序排序的:\n... WHERE rental_date = '2005-05-25' ORDER BY inventory_id DESC, customer_id ASC; 下面这个查询的ORDER BY子句中引用了一个不在索引中的列:\n... WHERE rental_date='2005-05-25' ORDER BY inventory_id,staff_id; 下面这个查询在索引列的第一列上是范围条件,所以MySQL无法使用索引的其余列:\n... WHERE rental_date \u0026gt; '2005-05-25' ORDER BY inventory_id, customer_id; 这个查询在y inventory_id列上有多个等于条件。对于排序来说,这也是一种范围查询:\n... WHERE rental_date = '2005-05-25' AND inventory_id IN(1,2) ORDER BY customer_ id; 下面这个例子理论上是可以使用索引进行关联排序的,但由于优化器在优化时将film_actor表当作关联的第二张表,所以实际上无法使用索引:\n使用索引做排序的一个最重要的用法是当查询同时有ORDER BY和LIMIT子句的时候。后面我们会具体介绍这些内容。\n5.3.8 压缩(前缀压缩)索引 # MyISAM使用前缀压缩来减少索引的大小,从而让更多的索引可以放入内存中,这在某些情况下能极大地提高性能。默认只压缩字符串,但通过参数设置也可以对整数做压缩。MyISAM压缩每个索引块的方法是,先完全保存索引块中的第一个值,然后将其他值和第一个值进行比较得到相同前缀的字节数和剩余的不同后缀部分,把这部分存储起来即可。例如,索引块中的第一个值是“perform”,第二个值是“performance”,那么第二个值的前缀压缩后存储的是类似“7,ance”这样的形式。MyISAM对行指针也采用类似的前缀压缩方式。\n压缩块使用更少的空间,代价是某些操作可能更慢。因为每个值的压缩前缀都依赖前面的值,所以MyISAM查找时无法在索引块使用二分查找而只能从头开始扫描。正序的扫描速度还不错,但是如果是倒序扫描——例如ORDER BY DESC——就不是很好了。所有在块中查找某一行的操作平均都需要扫描半个索引块。\n测试表明,对于CPU密集型应用,因为扫描需要随机查找,压缩索引使得MyISAM在索引查找上要慢好几倍。压缩索引的倒序扫描就更慢了。压缩索引需要在CPU内存资源与磁盘之间做权衡。压缩索引可能只需要十分之一大小的磁盘空间,如果是I/O密集型应用,对某些查询带来的好处会比成本多很多。\n可以在CREATE TABLE语句中指定PACK_KEYS参数来控制索引压缩的方式。\n5.3.9 冗余和重复索引 # MySQL允许在相同列上创建多个索引,无论是有意的还是无意的。MySQL需要单独维护重复的索引,并且优化器在优化查询的时候也需要逐个地进行考虑,这会影响性能。\n重复索引是指在相同的列上按照相同的顺序创建的相同类型的索引。应该避免这样创建重复索引,发现以后也应该立即移除。\n有时会在不经意间创建了重复索引,例如下面的代码:\nCREATE TABLE test ( ID INT NOT NULL PRIMARY KEY, A INT NOT NULL, B INT NOT NULL, UNIQUE(ID), INDEX(ID) ) ENGINE=InnoDB; 一个经验不足的用户可能是想创建一个主键,先加上唯一限制,然后再加上索引以供查询使用。事实上,MySQL的唯一限制和主键限制都是通过索引实现的,因此,上面的写法实际上在相同的列上创建了三个重复的索引。通常并没有理由这样做,除非是在同一列上创建不同类型的索引来满足不同的查询需求(16)。\n冗余索引和重复索引有一些不同。如果创建了索引(A,B),再创建索引(A)就是冗余索引,因为这只是前一个索引的前缀索引。因此索引(A,B)也可以当作索引(A)来使用(这种冗余只是对B-Tree索引来说的)。但是如果再创建索引(B,A),则不是冗余索引,索引(B)也不是,因为B不是索引(A,B)的最左前缀列。另外,其他不同类型的索引(例如哈希索引或者全文索引)也不会是B-Tree索引的冗余索引,而无论覆盖的索引列是什么。\n冗余索引通常发生在为表添加新索引的时候。例如,有人可能会增加一个新的索引(A,B)而不是扩展已有的索引(A)。还有一种情况是将一个索引扩展为(A,ID),其中ID是主键,对于InnoDB来说主键列已经包含在二级索引中了,所以这也是冗余的。\n大多数情况下都不需要冗余索引,应该尽量扩展已有的索引而不是创建新索引。但也有时候出于性能方面的考虑需要冗余索引,因为扩展已有的索引会导致其变得太大,从而影响其他使用该索引的查询的性能。\n例如,如果在整数列上有一个索引,现在需要额外增加一个很长的VARCHAR列来扩展该索引,那性能可能会急剧下降。特别是有查询把这个索引当作覆盖索引,或者这是MyISAM表并且有很多范围查询(由于MyISAM的前缀压缩)的时候。\n考虑一下前面“在InnoDB中按主键顺序插入行”一节提到的userinfo表。这个表有1000000行,对每个state_id值大概有20000条记录。在state_id列有一个索引对下面的查询有用,假设查询名为Q1:\nmysql\u0026gt; ** SELECT count(*) FROM userinfo WHERE state_id=5;** 一个简单的测试表明该查询的执行速度大概是每秒115次(QPS)。还有一个相关查询需要检索几个列的值,而不是只统计行数,假设名为Q2:\nmysql\u0026gt; ** SELECT count(*) FROM userinfo WHERE state_id=5;** 对于这个查询,测试结果QPS小于10(17)。提升该查询性能的最简单办法就是扩展索引为(state_id,city,address),让索引能覆盖查询:\nmysql\u0026gt; ** ALTER TABLE userinfo DROP KEY state_id,** -\u0026gt; ** ADD KEY state_id_2 (state_id, city, address);** 索引扩展后,Q2运行得更快了,但是Q1却变慢了。如果我们想让两个查询都变得更快,就需要两个索引,尽管这样一来原来的单列索引是冗余的了。表5-3显示这两个查询在不同的索引策略下的详细结果,分别使用MyISAM和InnoDB存储引擎。注意到只有state_id_2索引时,InnoDB引擎上的查询Q1的性能下降并不明显,这是因为InnoDB没有使用索引压缩。\n表5-3:使用不同索引策略的SELECT查询的QPS测试结果 有两个索引的缺点是索引成本更高。表5-4显示了向表中插入100万行数据所需要的时间。\n表5-4:在使用不同索引策略时插入100万行数据的速度 可以看到,表中的索引越多插入速度会越慢。一般来说,增加新索引将会导致INSERT、UPDATE、DELETE等操作的速度变慢,特别是当新增索引后导致达到了内存瓶颈的时候。解决冗余索引和重复索引的方法很简单,删除这些索引就可以,但首先要做的是找出这样的索引。可以通过写一些复杂的访问INFORMATION_SCHEMA表的查询来找,不过还有两个更简单的方法。可使用Shlomi Noach的common_schema中的一些视图来定位,common_schema是一系列可以安装到服务器上的常用的存储和视图( http://code.google.com/p/common-schema/)。这比自己编写查询要快而且简单。另外也可以使用Percona Toolkit中的pt-duplicate-key-checker,该工具通过分析表结构来找出冗余和重复的索引。对于大型服务器来说,使用外部的工具可能更合适些;如果服务器上有大量的数据或者大量的表,查询INFORMATION_SCHEMA表可能会导致性能问题。\n在决定哪些索引可以被删除的时候要非常小心。回忆一下,在前面的InnoDB的示例表中,因为二级索引的叶子节点包含了主键值,所以在列(A)上的索引就相当于在(A,ID)上的索引。如果有像WHERE A=5 ORDER BY ID这样的查询,这个索引会很有作用。但如果将索引扩展为(A,B),则实际上就变成了(A,B,ID),那么上面查询的ORDER BY子句就无法使用该索引做排序,而只能用文件排序了。所以,建议使用Percona工具箱中的pt-upgrade工具来仔细检查计划中的索引变更。\n5.3.10 未使用的索引 # 除了冗余索引和重复索引,可能还会有一些服务器永远不用的索引。这样的索引完全是累赘,建议考虑删除(18)。有两个工具可以帮助定位未使用的索引。最简单有效的办法是在Percona Server或者MariaDB中先打开userstates服务器变量(默认是关闭的),然后让服务器正常运行一段时间,再通过查询INFORMATION_SCHEMA.INDEX_STATISTICS就能查到每个索引的使用频率。\n另外,还可以使用Percona Toolkit中的pt-index-usage,该工具可以读取查询日志,并对日志中的每条查询进行EXPLAIN操作,然后打印出关于索引和查询的报告。这个工具不仅可以找出哪些索引是未使用的,还可以了解查询的执行计划——例如在某些情况有些类似的查询的执行方式不一样,这可以帮助你定位到那些偶尔服务质量差的查询,优化它们以得到一致的性能表现。该工具也可以将结果写入到MySQL的表中,方便查询结果。\n5.3.11 索引和锁 # 索引可以让查询锁定更少的行。如果你的查询从不访问那些不需要的行,那么就会锁定更少的行,从两个方面来看这对性能都有好处。首先,虽然InnoDB的行锁效率很高,内存使用也很少,但是锁定行的时候仍然会带来额外开销;其次,锁定超过需要的行会增加锁争用并减少并发性。\nInnoDB只有在访问行的时候才会对其加锁,而索引能够减少InnoDB访问的行数,从而减少锁的数量。但这只有当InnoDB在存储引擎层能够过滤掉所有不需要的行时才有效。如果索引无法过滤掉无效的行,那么在InnoDB检索到数据并返回给服务器层以后,MySQL服务器才能应用WHERE子句(19)。这时已经无法避免锁定行了:InnoDB已经锁住了这些行,到适当的时候才释放。在MySQL 5.1和更新的版本中,InnoDB可以在服务器端过滤掉行后就释放锁,但是在早期的MySQL版本中,InnoDB只有在事务提交后才能释放锁。\n通过下面的例子再次使用数据库Sakila很好地解释了这些情况:\nmysql\u0026gt; ** SET AUTOCOMMIT=0;** mysql\u0026gt; ** BEGIN;** mysql\u0026gt; ** SELECT actor_id FROM sakila.actor WHERE actor_id \u0026lt; 5** -\u0026gt; ** AND actor_id \u0026lt;\u0026gt; 1 FOR UPDATE;** 这条查询仅仅会返回2~4之间的行,但是实际上获取了1~4之间的行的排他锁。InnoDB会锁住第1行,这是因为MySQL为该查询选择的执行计划是索引范围扫描:\n换句话说,底层存储引擎的操作是“从索引的开头开始获取满足条件actor_id\u0026lt;5的记录”,服务器并没有告诉InnoDB可以过滤第1行的WHERE条件。注意到EXPLAIN的Extra列出现了“Using where”,这表示MySQL服务器将存储引擎返回行以后再应用WHERE过滤条件。\n下面的第二个查询就能证明第1行确实已经被锁定,尽管第一个查询的结果中并没有这个第1行。保持第一个连接打开,然后开启第二个连接并执行如下查询:\nmysql\u0026gt; ** SET AUTOCOMMIT=0;** mysql\u0026gt; ** BEGIN;** mysql\u0026gt; ** SELECT actor_id FROM sakila.actor WHERE actor_id = 1 FOR UPDATE;** 这个查询将会挂起,直到第一个事务释放第1行的锁。这个行为对于基于语句的复制(将在第10章讨论)的正常运行来说是必要的。(20)\n就像这个例子显示的,即使使用了索引,InnoDB也可能锁住一些不需要的数据。如果不能使用索引查找和锁定行的话问题可能会更糟糕,MySQL会做全表扫描并锁住所有的行,而不管是不是需要。\n关于InnoDB、索引和锁有一些很少有人知道的细节:InnoDB在二级索引上使用共享(读)锁,但访问主键索引需要排他(写)锁。这消除了使用覆盖索引的可能性,并且使得SELECT FOR UPDATE比LOCK IN SHARE MODE或非锁定查询要慢很多。\n5.4 索引案例学习 # 理解索引最好的办法是结合示例,所以这里准备了一个索引的案例。\n假设要设计一个在线约会网站,用户信息表有很多列,包括国家、地区、城市、性别、眼睛颜色,等等。网站必须支持上面这些特征的各种组合来搜索用户,还必须允许根据用户的最后在线时间、其他会员对用户的评分等对用户进行排序并对结果进行限制。如何设计索引满足上面的复杂需求呢?\n出人意料的是第一件需要考虑的事情是需要使用索引来排序,还是先检索数据再排序。使用索引排序会严格限制索引和查询的设计。例如,如果希望使用索引做根据其他会员对用户的评分的排序,则WHERE条件中的age BETWEEN 18 AND 25就无法使用索引。如果MySQL使用某个索引进行范围查询,也就无法再使用另一个索引(或者是该索引的后续字段)进行排序了。如果这是很常见的WHERE条件,那么我们当然就会认为很多查询需要做排序操作(例如文件排序filesort)。\n5.4.1 支持多种过滤条件 # 现在需要看看哪些列拥有很多不同的取值,哪些列在WHERE子句中出现得最频繁。在有更多不同值的列上创建索引的选择性会更好。一般来说这样做都是对的,因为可以让MySQL更有效地过滤掉不需要的行。\ncountry列的选择性通常不高,但可能很多查询都会用到。sex列的选择性肯定很低,但也会在很多查询中用到。所以考虑到使用的频率,还是建议在创建不同组合索引的时候将(sex,country)列作为前缀。\n但根据传统的经验不是说不应该在选择性低的列上创建索引的吗?那为什么这里要将两个选择性都很低的字段作为索引的前缀列?我们的脑子坏了?\n我们的脑子当然没坏。这么做有两个理由:第一点,如前所述几乎所有的查询都会用到sex列。前面曾提到,几乎每一个查询都会用到sex列,甚至会把网站设计成每次都只能按某一种性别搜索用户。更重要的一点是,索引中加上这一列也没有坏处,即使查询没有使用sex列也可以通过下面的“诀窍”绕过。\n这个“诀窍”就是:如果某个查询不限制性别,那么可以通过在查询条件中新增AND SEX IN(\u0026rsquo;m\u0026rsquo;,\u0026lsquo;f\u0026rsquo;)来让MySQL选择该索引。这样写并不会过滤任何行,和没有这个条件时返回的结果相同。但是必须加上这个列的条件,MySQL才能够匹配索引的最左前缀。这个“诀窍”在这类场景中非常有效,但如果列有太多不同的值,就会让IN()列表太长,这样做就不行了。\n这个案例显示了一个基本原则:考虑表上所有的选项。当设计索引时,不要只为现有的查询考虑需要哪些索引,还需要考虑对查询进行优化。如果发现某些查询需要创建新索引,但是这个索引又会降低另一些查询的效率,那么应该想一下是否能优化原来的查询。应该同时优化查询和索引以找到最佳的平衡,而不是闭门造车去设计最完美的索引。\n接下来,需要考虑其他常见WHERE条件的组合,并需要了解哪些组合在没有合适索引的情况下会很慢。(sex,country,age)上的索引就是一个很明显的选择,另外很有可能还需要(sex,country,region,age)和(sex,country,region,city,age)这样的组合索引。\n这样就会需要大量的索引。如果想尽可能重用索引而不是建立大量的组合索引,可以使用前面提到的IN()的技巧来避免同时需要(sex,country,age)和(sex,country,region,age)的索引。如果没有指定这个字段搜索,就需要定义一个全部国家列表,或者国家的全部地区列表,来确保索引前缀有同样的约束(组合所有国家、地区、性别将会是一个非常大的条件)。\n这些索引将满足大部分最常见的搜索查询,但是如何为一些生僻的搜索条件(比如has_pictures、eye_color、hair_color和education)来设计索引呢?这些列的选择性高、使用也不频繁,可以选择忽略它们,让MySQL多扫描一些额外的行即可。另一个可选的方法是在age列的前面加上这些列,在查询时使用前面提到过的IN()技术来处理搜索时没有指定这些列的场景。\n你可能已经注意到了,我们一直将age列放在索引的最后面。age列有什么特殊的地方吗?为什么要放在索引的最后?我们总是尽可能让MySQL使用更多的索引列,因为查询只能使用索引的最左前缀,直到遇到第一个范围条件列。前面提到的列在WHERE子句中都是等于条件,但是age列则多半是范围查询(例如查找年龄在18~25岁之间的人)。\n当然,也可以使用IN()来代替范围查询,例如年龄条件改写为IN(18,19,20,21,22,23,24,25),但不是所有的范围查询都可以转换。这里描述的基本原则是,尽可能将需要做范围查询的列放到索引的后面,以便优化器能使用尽可能多的索引列。\n前面提到可以在索引中加入更多的列,并通过IN()的方式覆盖那些不在WHERE子句中的列。但这种技巧也不能滥用,否则可能会带来麻烦。因为每额外增加一个IN()条件,优化器需要做的组合都将以指数形式增加,最终可能会极大地降低查询性能。考虑下面的WHERE子句:\nWHERE eye_color IN('brown','blue','hazel') AND hair_color IN('black','red','blonde','brown') AND sex IN('M','F') 优化器则会转化成4×3×2=24种组合,执行计划需要检查WHERE子句中所有的24种组合。对于MySQL来说,24种组合并不是很夸张,但如果组合数达到上千个则需要特别小心。老版本的MySQL在IN()组合条件过多的时候会有很多问题。查询优化可能需要花很多时间,并消耗大量的内存。新版本的MySQL在组合数超过一定数量后就不再进行执行计划评估了,这可能会导致MySQL不能很好地利用索引。\n5.4.2 避免多个范围条件 # Avoiding Multiple Range Conditions\n假设我们有一个last_online列并希望通过下面的查询显示在过去几周上线过的用户:\nWHERE eye_color IN('brown','blue','hazel') AND hair_color IN('black','red','blonde','brown') AND sex IN('M','F') AND last_online \u0026gt; DATE_SUB(NOW(), INTERVAL 7 DAY) AND age BETWEEN 18 AND 25 什么是范围条件?\n从EXPLAIN的输出很难区分MySQL是要查询范围值,还是查询列表值。EXPLAIN使用同样的词“range”来描述这两种情况。例如,从type列来看,MySQL会把下面这种查询当作是“range”类型:\nmysql\u0026gt; ** EXPLAIN SELECT actor_id FROM sakila.actor** -\u0026gt; ** WHERE actor_id \u0026gt; 45\\G** ************************* 1. row ************************* id: 1 select_type: SIMPLE table: actor type: range 但是下面这条查询呢?\nmysql\u0026gt; ** EXPLAIN SELECT actor_id FROM sakila.actor** -\u0026gt; ** WHERE actor_id IN(1, 4, 99)\\G** ************************* 1. row ************************* id: 1 select_type: SIMPLE table: actor type: range 从EXPLAIN的结果是无法区分这两者的,但可以从值的范围和多个等于条件来得出不同。在我们看来,第二个查询就是多个等值条件查询。\n我们不是挑剔:这两种访问效率是不同的。对于范围条件查询,MySQL无法再使用范围列后面的其他索引列了,但是对于“多个等值条件查询”则没有这个限制。\n这个查询有一个问题:它有两个范围条件,last_online列和age列,MySQL可以使用last_online列索引或者age列索引,但无法同时使用它们。\n如果条件中只有last_online而没有age,那么我们可能考虑在索引的后面加上last_online列。这里考虑如果我们无法把age字段转换为一个IN()的列表,并且仍要求对于同时有last_online和age这两个维度的范围查询的速度很快,那该怎么办?答案是,很遗憾没有一个直接的办法能够解决这个问题。但是我们能够将其中的一个范围查询转换为一个简单的等值比较。为了实现这一点,我们需要事先计算好一个active列,这个字段由定时任务来维护。当用户每次登录时,将对应值设置为1,并且将过去连续七天未曾登录的用户的值设置为0。\n这个方法可以让MySQL使用(active,sex,country,age)索引。active列并不是完全精确的,但是对于这类查询来说,对精度的要求也没有那么高。如果需要精确数据,可以把last_online列放到WHERE子句,但不加入到索引中。这和本章前面通过计算URL哈希值来实现URL的快速查找类似。所以这个查询条件没法使用任何索引,但因为这个条件的过滤性不高,即使在索引中加入该列也没有太大的帮助。换个角度来说,缺乏合适的索引对该查询的影响也不明显。\n到目前为止,我们可以看到:如果用户希望同时看到活跃和不活跃的用户,可以在查询中使用IN()列表。我们已经加入了很多这样的列表,但另外一个可选的方案就只能是为不同的组合列创建单独的索引。至少需要建立如下的索引:(active,sex,country,age),(active,country,age),(sex,country,age)和(country,age)。这些索引对某个具体的查询来说可能都是更优化的,但是考虑到索引的维护和额外的空间占用的代价,这个可选方案就不是一个好策略了。\n在这个案例中,优化器的特性是影响索引策略的一个很重要的因素。如果未来版本的MySQL能够实现松散索引扫描,就能在一个索引上使用多个范围条件,那也就不需要为上面考虑的这类查询使用IN()列表了。\n5.4.3 优化排序 # 在这个学习案例中,最后要介绍的是排序。使用文件排序对小数据集是很快的,但如果一个查询匹配的结果有上百万行的话会怎样?例如如果WHERE子句只有sex列,如何排序?\n对于那些选择性非常低的列,可以增加一些特殊的索引来做排序。例如,可以创建(sex,rating)索引用于下面的查询:\nmysql\u0026gt; ** SELECT\u0026lt;cols\u0026gt; FROM profiles WHERE sex='M' ORDER BY rating LIMIT 10;** 这个查询同时使用了ORDER BY和LIMIT,如果没有索引的话会很慢。\n即使有索引,如果用户界面上需要翻页,并且翻页翻到比较靠后时查询也可能非常慢。下面这个查询就通过ORDER BY和LIMIT偏移量的组合翻页到很后面的时候:\nmysql\u0026gt; ** SELECT\u0026lt;cols\u0026gt; FROM profiles WHERE sex='M' ORDER BY rating LIMIT 100000; 10;** 无论如何创建索引,这种查询都是个严重的问题。因为随着偏移量的增加,MySQL需要花费大量的时间来扫描需要丢弃的数据。反范式化、预先计算和缓存可能是解决这类查询的仅有策略。一个更好的办法是限制用户能够翻页的数量,实际上这对用户体验的影响不大,因为用户很少会真正在乎搜索结果的第10000页。\n优化这类索引的另一个比较好的策略是使用延迟关联,通过使用覆盖索引查询返回需要的主键,再根据这些主键关联原表获得需要的行。这可以减少MySQL扫描那些需要丢弃的行数。下面这个查询显示了如何高效地使用(sex,rating)索引进行排序和分页:\nmysql\u0026gt; ** SELECT \u0026lt;cols\u0026gt; FROM profiles INNER JOIN (** -\u0026gt; ** SELECT \u0026lt;primary key cols\u0026gt; FROM profiles** -\u0026gt; ** WHERE x.sex='M' ORDER BY rating LIMIT 100000, 10** -\u0026gt; ** ) AS x USING(\u0026lt;primary key cols\u0026gt;);** 5.5 维护索引和表 # 即使用正确的类型创建了表并加上了合适的索引,工作也没有结束:还需要维护表和索引来确保它们都正常工作。维护表有三个主要的目的:找到并修复损坏的表,维护准确的索引统计信息,减少碎片。\n5.5.1 找到并修复损坏的表 # 表损坏(corruption)是很糟糕的事情。对于MyISAM存储引擎,表损坏通常是系统崩溃导致的。其他的引擎也会由于硬件问题、MySQL本身的缺陷或者操作系统的问题导致索引损坏。\n损坏的索引会导致查询返回错误的结果或者莫须有的主键冲突等问题,严重时甚至还会导致数据库的崩溃。如果你遇到了古怪的问题——例如一些不应该发生的错误——可以尝试运行CHECK TABLE来检查是否发生了表损坏(注意有些存储引擎不支持该命令;而有些引擎则支持以不同的选项来控制完全检查表的方式)。CHECK TABLE通常能够找出大多数的表和索引的错误。\n可以使用REPAIR TABLE命令来修复损坏的表,但同样不是所有的存储引擎都支持该命令。如果存储引擎不支持,也可通过一个不做任何操作(no-op)的ALTER操作来重建表,例如修改表的存储引擎为当前的引擎。下面是一个针对InnoDB表的例子:\nmysql\u0026gt; ** ALTER TABLE innodb_tbl ENGINE=INNODB;** 此外,也可以使用一些存储引擎相关的离线工具,例如myisamchk;或者将数据导出一份,然后再重新导入。不过,如果损坏的是系统区域,或者是表的“行数据”区域,而不是索引,那么上面的办法就没有用了。在这种情况下,可以从备份中恢复表,或者尝试从损坏的数据文件中尽可能地恢复数据。\n如果InnoDB引擎的表出现了损坏,那么一定是发生了严重的错误,需要立刻调查一下原因。InnoDB一般不会出现损坏。InnoDB的设计保证了它并不容易被损坏。如果发生损坏,一般要么是数据库的硬件问题例如内存或者磁盘问题(有可能),要么是由于数据库管理员的错误例如在MySQL外部操作了数据文件(有可能),抑或是InnoDB本身的缺陷(不太可能)。常见的类似错误通常是由于尝试使用rsync备份InnoDB导致的。不存在什么查询能够让InnoDB表损坏,也不用担心暗处有“陷阱”。如果某条查询导致InnoDB数据的损坏,那一定是遇到了bug,而不是查询的问题。\n如果遇到数据损坏,最重要的是找出是什么导致了损坏,而不只是简单地修复,否则很有可能还会不断地损坏。可以通过设置innodb_force_recovery参数进入InnoDB的强制恢复模式来修复数据,更多细节可以参考MySQL手册。另外,还可以使用开源的InnoDB数据恢复工具箱(InnoDB Data Recovery Toolkit)直接从InnoDB数据文件恢复出数据(下载地址: http://www.percona.com/software/mysql-innodb-data-recovery-tools/)。\n5.5.2 更新索引统计信息 # MySQL的查询优化器会通过两个API来了解存储引擎的索引值的分布信息,以决定如何使用索引。第一个API是records_in_range(),通过向存储引擎传入两个边界值获取在这个范围大概有多少条记录。对于某些存储引擎,该接口返回精确值,例如MyISAM;但对于另一些存储引擎则是一个估算值,例如InnoDB。\n第二个API是info(),该接口返回各种类型的数据,包括索引的基数(每个键值有多少条记录)。\n如果存储引擎向优化器提供的扫描行数信息是不准确的数据,或者执行计划本身太复杂以致无法准确地获取各个阶段匹配的行数,那么优化器会使用索引统计信息来估算扫描行数。MySQL优化器使用的是基于成本的模型,而衡量成本的主要指标就是一个查询需要扫描多少行。如果表没有统计信息,或者统计信息不准确,优化器就很有可能做出错误的决定。可以通过运行ANALYZE TABLE来重新生成统计信息解决这个问题。\n每种存储引擎实现索引统计信息的方式不同,所以需要进行ANALYZE TABLE的频率也因不同的引擎而不同,每次运行的成本也不同:\nMemory引擎根本不存储索引统计信息。 MyISAM将索引统计信息存储在磁盘中,ANALYZE TABLE需要进行一次全索引扫描来计算索引基数。在整个过程中需要锁表。 直到MySQL 5.5版本,InnoDB也不在磁盘存储索引统计信息,而是通过随机的索y引访问进行评估并将其存储在内存中。 可以使用SHOW INDEX FROM命令来查看索引的基数(Cardinality)。例如:\nmysql\u0026gt; ** SHOW INDEX FROM sakila.actor\\G** *************************** 1. row *************************** Table: actor Non_unique: 0 Key_name: PRIMARY Seq_in_index: 1 Column_name: actor_id Collation: A Cardinality: 200 Sub_part: NULL Packed: NULL Null: Index_type: BTREE Comment: *************************** 2. row *************************** Table: actor Non_unique: 1 Key_name: idx_actor_last_name Seq_in_index: 1 Column_name: last_name Collation: A Cardinality: 200 Sub_part: NULL Packed: NULL Null: Index_type: BTREE Comment: 这个命令输出了很多关于索引的信息,在MySQL手册中对上面每个字段的含义都有详细的解释。这里需要特别提及的是索引列的基数(Cardinality),其显示了存储引擎估算索引列有多少个不同的取值。在MySQL 5.0和更新的版本中,还可以通过INFORMATION_SCHEMA.STATISTICS表很方便地查询到这些信息。例如基于INFORMATION_SCHEMA的表,可以编写一个查询给出当前选择性比较低的索引。需要注意的是,如果服务器上的库表非常多,则从这里获取元数据的速度可能会非常慢,而且会给MySQL带来额外的压力。\nInnoDB的统计信息值得深入研究。InnoDB引擎通过抽样的方式来计算统计信息,首先随机地读取少量的索引页面,然后以此为样本计算索引的统计信息。在老的InnoDB版本中,样本页面数是8,新版本的InnoDB可以通过参数innodb_stats_sample_pages来设置样本页的数量。设置更大的值,理论上来说可以帮助生成更准确的索引信息,特别是对于某些超大的数据表来说,但具体设置多大合适依赖于具体的环境。\nInnoDB会在表首次打开,或者执行ANALYZE TABLE,抑或表的大小发生非常大的变化(大小变化超过十六分之一或者新插入了20亿行都会触发)的时候计算索引的统计信息。\nInnoDB在打开某些INFORMATION_SCHEMA表,或者使用SHOW TABLE STATUS和SHOW INDEX,抑或在MySQL客户端开启自动补全功能的时候都会触发索引统计信息的更新。如果服务器上有大量的数据,这可能就是个很严重的问题,尤其是当I/O比较慢的时候。客户端或者监控程序触发索引信息采样更新时可能会导致大量的锁,并给服务器带来很多的额外压力,这会让用户因为启动时间漫长而沮丧。只要SHOW INDEX查看索引统计信息,就一定会触发统计信息的更新。可以关闭innodb_stats_on_metadata参数来避免上面提到的问题。\n如果使用Percona版本,使用的就是XtraDB引擎而不是原生的InnoDB引擎,那么可以通过innodb_stats_auto_update参数来禁止通过自动采样的方式更新索引统计信息,这时需要手动执行ANALYZE TABLE命令来更新统计信息。如果某些查询执行计划很不稳定的话,可以用该办法固化查询计划。我们当初引入这个参数也正是为了解决一些客户的这种问题。\n如果想要更稳定的执行计划,并在系统重启后更快地生成这些统计信息,那么可以使用系统表来持久化这些索引统计信息。甚至还可以在不同的机器间迁移索引统计信息,这样新环境启动时就无须再收集这些数据。在Percona 5.1版本和官方的5.6版本都已经加入这个特性。在Percona版本中通过innodb_use_sys_stats_table参数可以启用该特性,官方5.6版本则通过innodb_analyze_is_persistent参数控制。\n一旦关闭索引统计信息的自动更新,那么就需要周期性地使用ANALYZE TABLE来手动更新。否则,索引统计信息就会永远不变。如果数据分布发生大的变化,可能会出现一些很糟糕的执行计划。\n5.5.3 减少索引和数据的碎片 # B-Tree索引可能会碎片化,这会降低查询的效率。碎片化的索引可能会以很差或者无序的方式存储在磁盘上。\n根据设计,B-Tree需要随机磁盘访问才能定位到叶子页,所以随机访问是不可避免的。然而,如果叶子页在物理分布上是顺序且紧密的,那么查询的性能就会更好。否则,对于范围查询、索引覆盖扫描等操作来说,速度可能会降低很多倍;对于索引覆盖扫描这一点更加明显。\n表的数据存储也可能碎片化。然而,数据存储的碎片化比索引更加复杂。有三种类型的数据碎片。\n行碎片(Row fragmentation)\n这种碎片指的是数据行被存储为多个地方的多个片段中。即使查询只从索引中访问一行记录,行碎片也会导致性能下降。\n行间碎片(Intra-row fragmentation)\n行间碎片是指逻辑上顺序的页,或者行在磁盘上不是顺序存储的。行间碎片对诸如全表扫描和聚簇索引扫描之类的操作有很大的影响,因为这些操作原本能够从磁盘上顺序存储的数据中获益。\n剩余空间碎片(Free space fragmentation)\n剩余空间碎片是指数据页中有大量的空余空间。这会导致服务器读取大量不需要的数据,从而造成浪费。\n对于MyISAM表,这三类碎片化都可能发生。但InnoDB不会出现短小的行碎片;InnoDB会移动短小的行并重写到一个片段中。\n可以通过执行OPTIMIZE TABLE或者导出再导入的方式来重新整理数据。这对多数存储引擎都是有效的。对于一些存储引擎如MyISAM,可以通过排序算法重建索引的方式来消除碎片。老版本的InnoDB没有什么消除碎片化的方法。不过最新版本InnoDB新增了“在线”添加和删除索引的功能,可以通过先删除,然后再重新创建索引的方式来消除索引的碎片化。\n对于那些不支持OPTIMIZE TABLE的存储引擎,可以通过一个不做任何操作(no-op)的ALTER TABLE操作来重建表。只需要将表的存储引擎修改为当前的引擎即可:\nmysql\u0026gt; ** ALTER TABLE \u0026lt;* table* \u0026gt; ENGINE=\u0026lt;* engine* \u0026gt;;** 对于开启了expand_fast_index_creation参数的Percona Server,按这种方式重建表,则会同时消除表和索引的碎片化。但对于标准版本的MySQL则只会消除表(实际上是聚簇索引)的碎片化。可用先删除所有索引,然后重建表,最后重新创建索引的方式模拟Percona Server的这个功能。\n应该通过一些实际测量而不是随意假设来确定是否需要消除索引和表的碎片化。Percona的XtraBackup有个*\u0026ndash;stats*参数以非备份的方式运行,而只是打印索引和表的统计情况,包括页中的数据量和空余空间。这可以用来确定数据的碎片化程度。另外也要考虑数据是否已经达到稳定状态,如果你进行碎片整理将数据压缩到一起,可能反而会导致后续的更新操作触发一系列的页分裂和重组,这会对性能造成不良的影响(直到数据再次达到新的稳定状态)。\n5.6 总结 # 通过本章可以看到,索引是一个非常复杂的话题! MySQL和存储引擎访问数据的方式,加上索引的特性,使得索引成为一个影响数据访问的有力而灵活的工作(无论数据是在磁盘中还是在内存中)。\n在MySQL中,大多数情况下都会使用B-Tree索引。其他类型的索引大多只适用于特殊的目的。如果在合适的场景中使用索引,将大大提高查询的响应时间。本章将不再介绍更多这方面的内容了,最后值得总的回顾一下这些特性以及如何使用B-Tree索引。\n在选择索引和编写利用这些索引的查询时,有如下三个原则始终需要记住:\n单行访问是很慢的。特别是在机械硬盘存储中(SSD的随机I/O要快很多,不过这一点仍然成立)。如果服务器从存储中读取一个数据块只是为了获取其中一行,那么就浪费了很多工作。最好读取的块中能包含尽可能多所需要的行。使用索引可以创建位置引用以提升效率。 按顺序访问范围数据是很快的,这有两个原因。第一,顺序I/O不需要多次磁盘寻道,所以比随机I/O要快很多(特别是对机械硬盘)。第二,如果服务器能够按需要顺序读取数据,那么就不再需要额外的排序操作,并且GROUP BY查询也无须再做排序和将行按组进行聚合计算了。 索引覆盖查询是很快的。如果一个索引包含了查询需要的所有列,那么存储引擎就不需要再回表查找行。这避免了大量的单行访问,而上面的第1点已经写明单行访问是很慢的。 总的来说,编写查询语句时应该尽可能选择合适的索引以避免单行查找、尽可能地使用数据原生顺序从而避免额外的排序操作,并尽可能使用索引覆盖查询。这与本章开头提到的Lahdenmaki和Leach的书中的“三星”评价系统是一致的。\n如果表上的每一个查询都能有一个完美的索引来满足当然是最好的。但不幸的是,要这么做有时可能需要创建大量的索引。还有一些时候对某些查询是不可能创建一个达到“三星”的索引的(例如查询要按照两个列排序,其中一个列正序,另一个列倒序)。这时必须有所取舍以创建最合适的索引,或者寻求替代策略(例如反范式化,或者提前计算汇总表等)。\n理解索引是如何工作的非常重要,应该根据这些理解来创建最合适的索引,而不是根据一些诸如“在多列索引中将选择性最高的列放在第一列”或“应该为WHERE子句中出现的所有列创建索引”之类的经验法则及其推论。\n那如何判断一个系统创建的索引是合理的呢?一般来说,我们建议按响应时间来对查询进行分析。找出那些消耗最长时间的查询或者那些给服务器带来最大压力的查询(第3章中介绍了如何测量),然后检查这些查询的schema、SQL和索引结构,判断是否有查询扫描了太多的行,是否做了很多额外的排序或者使用了临时表,是否使用随机I/O访问数据,或者是有太多回表查询那些不在索引中的列的操作。\n如果一个查询无法从所有可能的索引中获益,则应该看看是否可以创建一个更合适的索引来提升性能。如果不行,也可以看看是否可以重写该查询,将其转化成一个能够高效利用现有索引或者新创建索引的查询。这也是下一章要介绍的内容。\n如果根据第3章介绍的基于响应时间的分析不能找出有问题的查询呢?是否可能有我们没有注意到的“很糟糕”的查询,需要一个更好的索引来获取更高的性能?一般来说,不可能。对于诊断时抓不到的查询,那就不是问题。但是,这个查询未来有可能会成为问题,因为应用程序、数据和负载都在变化。如果仍然想找到那些索引不是很合适的查询,并在它们成为问题前进行优化,则可以使用pt-query-digest的查询审查“review”功能,分析其EXPLAIN出来的执行计划。\n————————————————————\n(1) 除非特别说明,本章假设使用的都是传统的硬盘驱动器。固态硬盘驱动器有着完全不同的性能特性,本书将对此进行详细的描述。然而即使是固态硬盘,索引的原则依然成立,只是那些需要尽量避免的糟糕索引对于固态硬盘的影响没有传统硬盘那么糟糕。\n(2) 实际上很多存储引擎使用的是B+Tree,即每一个叶子节点都包含指向下一个叶子节点的指针,从而方便叶子节点的范围遍历。对于B-Tree更详细的细节可以参考相关计算机科学方面的书籍。\n(3) 这是MySQL相关的特性,甚至和具体的版本也相关。其他有些数据库也可以使用索引的非前缀部分,虽然使用完全的前缀的效率会更好。MySQL未来也可能会提供这个特性;本章后面也会介绍一些绕过限制的方法。\n(4) 关于哈希表请参考相关计算机科学方面的书籍。\n(5) 参考 http://en.wikipedia.org/wiki/Birthday_problem。——译者注\n(6) 某些优化极客(geek)将这称之为“sarg”,这是“可搜索的参数(searchable argument)”的缩写。好吧,学会了这个词你也是一个极客了。\n(7) Oracle用户可能更熟悉索引组织表(index-organized table)的说法,实际上是一样的意思。\n(8) 这并非总成立,很快就可以看到。\n(9) 顺便提一下,并不是所有的非聚簇索引都能做到一次索引查询就找到行。当行更新的时候可能无法存储在原来的位置,这会导致表中出现行的碎片化或者移动行并在原位置保存“向前指针”,这两种情况都会导致在查找行时需要更多的工作。\n(10) 多版本控制。——译者注\n(11) 值得指出的是,这是一个真实案例中的表,有很多二级索引和列。如果删除这些二级索引只测试主键,那么性能差异将会更明显。\n(12) 很容易把Extra列的“Using index”和type列的“index”搞混淆。其实这两者完全不同,type列和覆盖索引毫无关系;它只是表示这个查询访问数据的方式,或者说是MySQL查找行的方式。MySQL手册中称之为连接方式(join type)。\n(13) MySQL有两种排序算法,更多细节可以阅读第7章。\n(14) 如果需要按不同方向做排序,一个技巧是存储该列值的反转串或者相反数。\n(15) MySQL这里称其为文件排序(filesort),其实并不一定使用磁盘文件。\n(16) 如果索引类型不同,并不算是重复索引。例如经常有很好的理由创建KEY(col)和FULLTEXT KEY(col)两种索引。\n(17) 这里使用了全内存的案例,如果表逐渐变大,导致工作负载变成I/O密集型时,性能测试结果差距会更大。对于COUNT()查询,覆盖索引性能提升100倍也是很有可能的。\n(18) 有些索引的功能相当于唯一约束,虽然该索引一直没有被查询使用,却可能是用于避免产生重复数据的。\n(19) 再说一下,MySQL 5.6对于这里的问题可能会有很大的帮助。\n(20) 尽管理论上使用基于行的日志模式时,在某些事务隔离级别下,服务器不再需要锁定行,但实践中经常发现无法实现这种预期的行为。直到MySQL 5.6.3版本,在read-commit隔离级别和基于行的日志模式下,这个例子还是会导致锁。\n"},{"id":143,"href":"/zh/docs/technology/MySQL/_%E9%AB%98%E6%80%A7%E8%83%BDMySQL_/%E7%AC%AC4%E7%AB%A0Schema%E4%B8%8E%E6%95%B0%E6%8D%AE%E7%B1%BB%E5%9E%8B%E4%BC%98%E5%8C%96/","title":"第4章Schema与数据类型优化","section":"高性能 My SQL","content":"第4章 Schema与数据类型优化\n良好的逻辑设计和物理设计是高性能的基石,应该根据系统将要执行的查询语句来设计schema,这往往需要权衡各种因素。例如,反范式的设计可以加快某些类型的查询,但同时可能使另一些类型的查询变慢。比如添加计数表和汇总表是一种很好的优化查询的方式,但这些表的维护成本可能会很高。MySQL独有的特性和实现细节对性能的影响也很大。\n本章和聚焦在索引优化的下一章,覆盖了MySQL特有的schema设计方面的主题。我们假设读者已经知道如何设计数据库,所以本章既不会介绍如何入门数据库设计,也不会讲解数据库设计方面的深入内容。这一章关注的是MySQL数据库的设计,主要介绍的是MySQL数据库设计与其他关系型数据库管理系统的区别。如果需要学习数据库设计方面的基础知识,建议阅读Clare Churcher的Beginning Database Design(Apress出版社)一书。\n本章内容是为接下来的两个章节做铺垫。在这三章中,我们将讨论逻辑设计、物理设计和查询执行,以及它们之间的相互作用。这既需要关注全局,也需要专注细节。还需要理解整个系统以便弄清楚各个部分如何相互影响。如果在阅读完索引和查询优化章节后再回头来看这一章,也许会发现本章很有用,很多讨论的议题不能孤立地考虑。\n4.1 选择优化的数据类型 # MySQL支持的数据类型非常多,选择正确的数据类型对于获得高性能至关重要。不管存储哪种类型的数据,下面几个简单的原则都有助于做出更好的选择。\n更小的通常更好。\n一般情况下,应该尽量使用可以正确存储数据的最小数据类型(1)。更小的数据类型通常更快,因为它们占用更少的磁盘、内存和CPU缓存,并且处理时需要的CPU周期也更少。\n但是要确保没有低估需要存储的值的范围,因为在schema中的多个地方增加数据类型的范围是一个非常耗时和痛苦的操作。如果无法确定哪个数据类型是最好的,就选择你认为不会超过范围的最小类型。(如果系统不是很忙或者存储的数据量不多,或者是在可以轻易修改设计的早期阶段,那之后修改数据类型也比较容易)。\n简单就好\n简单数据类型的操作通常需要更少的CPU周期。例如,整型比字符操作代价更低,因为字符集和校对规则(排序规则)使字符比较比整型比较更复杂。这里有两个例子:一个是应该使用MySQL内建的类型(2)而不是字符串来存储日期和时间,另外一个是应该用整型存储IP地址。稍后我们将专门讨论这个话题。\n尽量避免NULL\n很多表都包含可为NULL(空值)的列,即使应用程序并不需要保存NULL也是如此,这是因为可为NULL是列的默认属性(3)。通常情况下最好指定列为NOT NULL,除非真的需要存储NULL值。\n如果查询中包含可为NULL的列,对MySQL来说更难优化,因为可为NULL的列使得索引、索引统计和值比较都更复杂。可为NULL的列会使用更多的存储空间,在MySQL里也需要特殊处理。当可为NULL的列被索引时,每个索引记录需要一个额外的字节,在MyISAM里甚至还可能导致固定大小的索引(例如只有一个整数列的索引)变成可变大小的索引。\n通常把可为NULL的列改为NOT NULL带来的性能提升比较小,所以(调优时)没有必要首先在现有schema中查找并修改掉这种情况,除非确定这会导致问题。但是,如果计划在列上建索引,就应该尽量避免设计成可为NULL的列。\n当然也有例外,例如值得一提的是,InnoDB使用单独的位(bit)存储NULL值,所以对于稀疏数据(4)有很好的空间效率。但这一点不适用于MyISAM。\n在为列选择数据类型时,第一步需要确定合适的大类型:数字、字符串、时间等。这通常是很简单的,但是我们会提到一些特殊的不是那么直观的案例。\n下一步是选择具体类型。很多MySQL的数据类型可以存储相同类型的数据,只是存储的长度和范围不一样、允许的精度不同,或者需要的物理空间(磁盘和内存空间)不同。相同大类型的不同子类型数据有时也有一些特殊的行为和属性。\n例如,DATETIME和TIMESAMP列都可以存储相同类型的数据:时间和日期,精确到秒。\n然而TIMESTAMP只使用DATETIME一半的存储空间,并且会根据时区变化,具有特殊的自动更新能力。另一方面,TIMESTAMP允许的时间范围要小得多,有时候它的特殊能力会成为障碍。\n本章只讨论基本的数据类型。MySQL为了兼容性支持很多别名,例如INTEGER、BOOL,以及NUMERIC。它们都只是别名。这些别名可能令人不解,但不会影响性能。如果建表时采用数据类型的别名,然后用SHOW CREATE TABLE检查,会发现MySQL报告的是基本类型,而不是别名。\n4.1.1 整数类型 # 有两种类型的数字:整数(whole number)和实数(real number)。如果存储整数,可以使用这几种整数类型:TINYINT,SMALLINT,MEDIUMINT,INT,BIGINT。分别使用8,16,24,32,64位存储空间。它们可以存储的值的范围从−2(N−1)到2(N−1)−1,其中N是存储空间的位数。\n整数类型有可选的UNSIGNED属性,表示不允许负值,这大致可以使正数的上限提高一倍。例如TINYINT UNSIGNED可以存储的范围是0~255,而TINYINT的存储范围是−128~127。\n有符号和无符号类型使用相同的存储空间,并具有相同的性能,因此可以根据实际情况选择合适的类型。\n你的选择决定MySQL是怎么在内存和磁盘中保存数据的。然而,整数计算一般使用64位的BIGINT整数,即使在32位环境也是如此。(一些聚合函数是例外,它们使用DECIMAL或DOUBLE进行计算)。\nMySQL可以为整数类型指定宽度,例如INT(11),对大多数应用这是没有意义的:它不会限制值的合法范围,只是规定了MySQL的一些交互工具(例如MySQL命令行客户端)用来显示字符的个数。对于存储和计算来说,INT(1)和INT(20)是相同的。\n一些第三方存储引擎,比如Infobright,有时也有自定义的存储格式和压缩方案,并不一定使用常见的MySQL内置引擎的方式。\n4.1.2 实数类型 # 实数是带有小数部分的数字。然而,它们不只是为了存储小数部分;也可以使用DECIMAL存储比BIGINT还大的整数。MySQL既支持精确类型,也支持不精确类型。\nFLOAT和DOUBLE类型支持使用标准的浮点运算进行近似计算。如果需要知道浮点运算是怎么计算的,则需要研究所使用的平台的浮点数的具体实现。\nDECIMAL类型用于存储精确的小数。在MySQL 5.0和更高版本,DECIMAL类型支持精确计算。MySQL 4.1以及更早版本则使用浮点运算来实现DECIAML的计算,这样做会因为精度损失导致一些奇怪的结果。在这些版本的MySQL中,DECIMAL只是一个“存储类型”。\n因为CPU不支持对DECIMAL的直接计算,所以在MySQL 5.0以及更高版本中,MySQL服务器自身实现了DECIMAL的高精度计算。相对而言,CPU直接支持原生浮点计算,所以浮点运算明显更快。\n浮点和DECIMAL类型都可以指定精度。对于DECIMAL列,可以指定小数点前后所允许的最大位数。这会影响列的空间消耗。MySQL 5.0和更高版本将数字打包保存到一个二进制字符串中(每4个字节存9个数字)。例如,DECIMAL(18,9)小数点两边将各存储9个数字,一共使用9个字节:小数点前的数字用4个字节,小数点后的数字用4个字节,小数点本身占1个字节。\nMySQL 5.0和更高版本中的DECIMAL类型允许最多65个数字。而早期的MySQL版本中这个限制是254个数字,并且保存为未压缩的字符串(每个数字一个字节)。然而,这些(早期)版本实际上并不能在计算中使用这么大的数字,因为DECIMAL只是一种存储格式;在计算中DECIMAL会转换为DOUBLE类型。\n有多种方法可以指定浮点列所需要的精度,这会使得MySQL悄悄选择不同的数据类型,或者在存储时对值进行取舍。这些精度定义是非标准的,所以我们建议只指定数据类型,不指定精度。\n浮点类型在存储同样范围的值时,通常比DECIMAL使用更少的空间。FLOAT使用4个字节存储。DOUBLE占用8个字节,相比FLOAT有更高的精度和更大的范围。和整数类型一样,能选择的只是存储类型;MySQL使用DOUBLE作为内部浮点计算的类型。\n因为需要额外的空间和计算开销,所以应该尽量只在对小数进行精确计算时才使用DECIMAL——例如存储财务数据。但在数据量比较大的时候,可以考虑使用BIGINT代替DECIMAL,将需要存储的货币单位根据小数的位数乘以相应的倍数即可。假设要存储财务数据精确到万分之一分,则可以把所有金额乘以一百万,然后将结果存储在BIGINT里,这样可以同时避免浮点存储计算不精确和DECIMAL精确计算代价高的问题。\n4.1.3 字符串类型 # MySQL支持多种字符串类型,每种类型还有很多变种。这些数据类型在4.1和5.0版本发生了很大的变化,使得情况更加复杂。从MySQL 4.1开始,每个字符串列可以定义自己的字符集和排序规则,或者说校对规则(collation)(更多关于这个主题的信息请参考第7章)。这些东西会很大程度上影响性能。\nVARCHAR和CHAR类型 # VARCHAR和CHAR是两种最主要的字符串类型。不幸的是,很难精确地解释这些值是怎么存储在磁盘和内存中的,因为这跟存储引擎的具体实现有关。下面的描述假设使用的存储引擎是InnoDB和/或者MyISAM。如果使用的不是这两种存储引擎,请参考所使用的存储引擎的文档。\n先看看VARCHAR和CHAR值通常在磁盘上怎么存储。请注意,存储引擎存储CHAR或者VARCHAR值的方式在内存中和在磁盘上可能不一样,所以MySQL服务器从存储引擎读出的值可能需要转换为另一种存储格式。下面是关于两种类型的一些比较。\nVARCHAR\nVARCHAR类型用于存储可变长字符串,是最常见的字符串数据类型。它比定长类型更节省空间,因为它仅使用必要的空间(例如,越短的字符串使用越少的空间)。有一种情况例外,如果MySQL表使用ROW_FORMAT=FIXED创建的话,每一行都会使用定长存储,这会很浪费空间。\nVARCHAR需要使用1或2个额外字节记录字符串的长度:如果列的最大长度小于或等于255字节,则只使用1个字节表示,否则使用2个字节。假设采用latin1字符集,一个VARCHAR(10)的列需要11个字节的存储空间。VARCHAR(1000)的列则需要1002个字节,因为需要2个字节存储长度信息。\nVARCHAR节省了存储空间,所以对性能也有帮助。但是,由于行是变长的,在UPDATE时可能使行变得比原来更长,这就导致需要做额外的工作。如果一个行占用的空间增长,并且在页内没有更多的空间可以存储,在这种情况下,不同的存储引擎的处理方式是不一样的。例如,MyISAM会将行拆成不同的片段存储,InnoDB则需要分裂页来使行可以放进页内。其他一些存储引擎也许从不在原数据位置更新数据。\n下面这些情况下使用VARCHAR是合适的:字符串列的最大长度比平均长度大很多;列的更新很少,所以碎片不是问题;使用了像UTF-8这样复杂的字符集,每个字符都使用不同的字节数进行存储。\n在5.0或者更高版本,MySQL在存储和检索时会保留末尾空格。但在4.1或更老的版本,MySQL会剔除末尾空格。\nInnoDB则更灵活,它可以把过长的VARCHAR存储为BLOB,我们稍后讨论这个问题。\nCHAR\nCHAR类型是定长的:MySQL总是根据定义的字符串长度分配足够的空间。当存储CHAR值时,MySQL会删除所有的末尾空格(在MySQL 4.1和更老版本中VARCHAR也是这样实现的——也就是说这些版本中CHAR和VARCHAR在逻辑上是一样的,区别只是在存储格式上)。CHAR值会根据需要采用空格进行填充以方便比较。\nCHAR适合存储很短的字符串,或者所有值都接近同一个长度。例如,CHAR非常适合存储密码的MD5值,因为这是一个定长的值。对于经常变更的数据,CHAR也比VARCHAR更好,因为定长的CHAR类型不容易产生碎片。对于非常短的列,CHAR比VARCHAR在存储空间上也更有效率。例如用CHAR(1)来存储只有Y和N的值,如果采用单字节字符集(5)只需要一个字节,但是VARCHAR(1)却需要两个字节,因为还有一个记录长度的额外字节。\nCHAR类型的这些行为可能有一点难以理解,下面通过一个具体的例子来说明。首先,我们创建一张只有一个CHAR(10)字段的表并且往里面插入一些值:\nmysql\u0026gt; CREATE TABLE char_test( char_col CHAR(10)); mysql\u0026gt; INSERT INTO char_test(char_col) VALUES -\u0026gt; ('string1'), (' string2'), ('string3 ') 当检索这些值的时候,会发现string3末尾的空格被截断了。\n如果用VARCHAR(10)字段存储相同的值,可以得到如下结果(6):\n数据如何存储取决于存储引擎,并非所有的存储引擎都会按照相同的方式处理定长和变长的字符串。Memory引擎只支持定长的行,即使有变长字段也会根据最大长度分配最大空间(7)。不过,填充和截取空格的行为在不同存储引擎都是一样的,因为这是在MySQL服务器层进行处理的。\n与CHAR和VARCHAR类似的类型还有BINARY和VARBINARY,它们存储的是二进制字符串。二进制字符串跟常规字符串非常相似,但是二进制字符串存储的是字节码而不是字符。填充也不一样:MySQL填充BINARY采用的是\\0(零字节)而不是空格,在检索时也不会去掉填充值(8)。\n当需要存储二进制数据,并且希望MySQL使用字节码而不是字符进行比较时,这些类型是非常有用的。二进制比较的优势并不仅仅体现在大小写敏感上。MySQL比较BINARY字符串时,每次按一个字节,并且根据该字节的数值进行比较。因此,二进制比较比字符比较简单很多,所以也就更快。\n慷慨是不明智的\n使用VARCHAR(5)和VARCHAR(200)存储’hello’的空间开销是一样的。那么使用更短的列有什么优势吗?\n事实证明有很大的优势。更长的列会消耗更多的内存,因为MySQL通常会分配固定大小的内存块来保存内部值。尤其是使用内存临时表进行排序或操作时会特别糟糕。在利用磁盘临时表进行排序时也同样糟糕。\n所以最好的策略是只分配真正需要的空间。\nBLOB和TEXT类型 # BLOB和TEXT都是为存储很大的数据而设计的字符串数据类型,分别采用二进制和字符方式存储。\n实际上,它们分别属于两组不同的数据类型家族:字符类型是TINYTEXT,SMALLTEXT,TEXT,MEDIUMTEXT,LONGTEXT;对应的二进制类型是TINYBLOB,SMALLBLOB,BLOB,MEDIUMBLOB,LONGBLOB。BLOB是SMALLBLOB的同义词,TEXT是SMALLTEXT的同义词。\n与其他类型不同,MySQL把每个BLOB和TEXT值当作一个独立的对象处理。存储引擎在存储时通常会做特殊处理。当BLOB和TEXT值太大时,InnoDB会使用专门的“外部”存储区域来进行存储,此时每个值在行内需要1~4个字节存储一个指针,然后在外部存储区域存储实际的值。\nBLOB和TEXT家族之间仅有的不同是BLOB类型存储的是二进制数据,没有排序规则或字符集,而TEXT类型有字符集和排序规则。\nMySQL对BLOB和TEXT列进行排序与其他类型是不同的:它只对每个列的最前max_sort_length字节而不是整个字符串做排序。如果只需要排序前面一小部分字符,则可以减小max_sort_length的配置,或者使用ORDER BY SUSTRING(column,length)。\nMySQL不能将BLOB和TEXT列全部长度的字符串进行索引,也不能使用这些索引消除排序。(关于这个主题下一章会有更多的信息。)\n磁盘临时表和文件排序\n因为Memory引擎不支持BLOB和TEXT类型,所以,如果查询使用了BLOB或TEXT列并且需要使用隐式临时表,将不得不使用MyISAM磁盘临时表,即使只有几行数据也是如此(Percona Server的Memory引擎支持BLOB和TEXT类型,但直到本书写作之际,同样的场景下还是需要使用磁盘临时表)。\n这会导致严重的性能开销。即使配置MySQL将临时表存储在内存块设备上(RAM Disk),依然需要许多昂贵的系统调用。\n最好的解决方案是尽量避免使用BLOB和TEXT类型。如果实在无法避免,有一个技巧是在所有用到BLOB字段的地方都使用SUBSTRING(column,length)将列值转换为字符串(在ORDER BY子句中也适用),这样就可以使用内存临时表了。但是要确保截取的子字符串足够短,不会使临时表的大小超过max_heap_table_size或tmp_table_size,超过以后MySQL会将内存临时表转换为MyISAM磁盘临时表。\n最坏情况下的长度分配对于排序的时候也是一样的,所以这一招对于内存中创建大临时表和文件排序,以及在磁盘上创建大临时表和文件排序这两种情况都很有帮助。\n例如,假设有一个1000万行的表,占用几个GB的磁盘空间。其中有一个utf8字符集的VARCHAR(1000)列。每个字符最多使用3个字节,最坏情况下需要3000字节的空间。如果在ORDER BY中用到这个列,并且查询扫描整个表,为了排序就需要超过30GB的临时表。\n如果EXPLAIN执行计划的Extra列包含”Using temporary”,则说明这个查询使用了隐式临时表。\n使用枚举(ENUM)代替字符串类型 # 有时候可以使用枚举列代替常用的字符串类型。枚举列可以把一些不重复的字符串存储成一个预定义的集合。MySQL在存储枚举时非常紧凑,会根据列表值的数量压缩到一个或者两个字节中。MySQL在内部会将每个值在列表中的位置保存为整数,并且在表的*.frm*文件中保存“数字-字符串”映射关系的“查找表”。下面有一个例子:\nmysql\u0026gt; CREATE TABLE enum_test( -\u0026gt; e ENUM ('fish', 'apple', 'dog') NOT NULL -\u0026gt; ); mysql\u0026gt; INSERT INTO enum_test(e) VALUES('fish'), ('dog'), ('apple'); 这三行数据实际存储为整数,而不是字符串。可以通过在数字上下文环境检索看到这个双重属性:\n如果使用数字作为ENUM枚举常量,这种双重性很容易导致混乱,例如ENUM(\u0026lsquo;1\u0026rsquo;,\u0026lsquo;2\u0026rsquo;,\u0026lsquo;3\u0026rsquo;)。建议尽量避免这么做。\n另外一个让人吃惊的地方是,枚举字段是按照内部存储的整数而不是定义的字符串进行排序的:\n一种绕过这种限制的方式是按照需要的顺序来定义枚举列。另外也可以在查询中使用FIELD()函数显式地指定排序顺序,但这会导致MySQL无法利用索引消除排序。\n如果在定义时就是按照字母的顺序,就没有必要这么做了。\n枚举最不好的地方是,字符串列表是固定的,添加或删除字符串必须使用ALTER TABLE。因此,对于一系列未来可能会改变的字符串,使用枚举不是一个好主意,除非能接受只在列表末尾添加元素,这样在MySQL 5.1中就可以不用重建整个表来完成修改。\n由于MySQL把每个枚举值保存为整数,并且必须进行查找才能转换为字符串,所以枚举列有一些开销。通常枚举的列表都比较小,所以开销还可以控制,但也不能保证一直如此。在特定情况下,把CHAR/VARCHAR列与枚举列进行关联可能会比直接关联CHAR/VARCHAR列更慢。\n为了说明这个情况,我们对一个应用中的一张表进行了基准测试,看看在MySQL中执行上面说的关联的速度如何。该表有一个很大的主键:\nCREATE TABLE webservicecalls ( day date NOT NULL, account smallint NOT NULL, service varchar(10) NOT NULL, method varchar(50) NOT NULL, calls int NOT NULL, items int NOT NULL, time float NOT NULL, cost decimal(9,5) NOT NULL, updated datetime, PRIMARY KEY (day, account, service, method) ) ENGINE=InnoDB; 这个表有11万行数据,只有10MB大小,所以可以完全载入内存。service列包含了5个不同的值,平均长度为4个字符,method列包含了71个值,平均长度为20个字符。\n我们复制一下这个表,但是把service和method字段换成枚举类型,表结构如下:\nCREATE TABLE webservicecalls_enum ( ... omitted ... service ENUM(...values omitted...) NOT NULL, method ENUM(...values omitted...) NOT NULL, ... omitted ... ) ENGINE=InnoDB; 然后我们用主键列关联这两个表,下面是所使用的查询语句:\nmysql\u0026gt; SELECT SQL_NO_CACHE COUNT(*) -\u0026gt; FROM webservicecalls -\u0026gt; JOIN webservicecalls USING(day, account, service, method); 我们用VARVHAR和ENUM分别测试了这个语句,结果如表4-1所示。\n表4-1:连接VARCHAR和ENUM列的速度 测试 QPS VARCHAR 关联 VARCHAR 2.6 VARCHAR 关联 ENUM 1.7 ENUM 关联 VARCHAR 1.8 ENUM 关联 ENUM 3.5\n从上面的结果可以看到,当把列都转换成ENUM以后,关联变得很快。但是当VARCHAR列和ENUM列进行关联时则慢很多。在本例中,如果不是必须和VARCHAR列进行关联,那么转换这些列为ENUM就是个好主意。这是一个通用的设计实践,在“查找表”时采用整数主键而避免采用基于字符串的值进行关联。\n然而,转换列为枚举型还有另一个好处。根据SHOW TABLE STATUS命令输出结果中Data_length列的值,把这两列转换为ENUM可以让表的大小缩小1/3。在某些情况下,即使可能出现ENUM和VARCHAR进行关联的情况,这也是值得的(9)。同样,转换后主键也只有原来的一半大小了。因为这是InnoDB表,如果表上有其他索引,减小主键大小会使非主键索引也变得更小。稍后再解释这个问题。\n4.1.4 日期和时间类型 # MySQL可以使用许多类型来保存日期和时间值,例如YEAR和DATE。MySQL能存储的最小时间粒度为秒(MariaDB支持微秒级别的时间类型)。但是MySQL也可以使用微秒级的粒度进行临时运算,我们会展示怎么绕开这种存储限制。\n大部分时间类型都没有替代品,因此没有什么是最佳选择的问题。唯一的问题是保存日期和时间的时候需要做什么。MySQL提供两种相似的日期类型:DATETIME和TIMESTAMP。对于很多应用程序,它们都能工作,但是在某些场景,一个比另一个工作得好。让我们来看一下。\nDATETIME\n这个类型能保存大范围的值,从1001年到9999年,精度为秒。它把日期和时间封装到格式为YYYYMMDDHHMMSS的整数中,与时区无关。使用8个字节的存储空间。\n默认情况下,MySQL以一种可排序的、无歧义的格式显示DATETIME值,例如“2008-01-16 22:37:08”。这是ANSI标准定义的日期和时间表示方法。\nTIMESTAMP\n就像它的名字一样,TIMETAMP类型保存了从1970年1月1日午夜(格林尼治标准时间)以来的秒数,它和UNIX时间戳相同。TIMESTAMP只使用4个字节的存储空间,因此它的范围比DATETIME小得多:只能表示从1970年到2038年。MySQL提供了FROM_UNIXTIME()函数把Unix时间戳转换为日期,并提供了UNIX_TIMESTAMP()函数把日期转换为Unix时间戳。\nMySQL 4.1以及更新的版本按照DATETIME的方式格式化TIMESTAMP的值,但是MySQL 4.0以及更老的版本不会在各个部分之间显示任何标点符号。这仅仅是显示格式上的区别,TIMESTAMP的存储格式在各个版本都是一样的。\nTIMESTAMP显示的值也依赖于时区。MySQL服务器、操作系统,以及客户端连接都有时区设置。\n因此,存储值为0的TIMESTAMP在美国东部时区显示为“1969-12-31 19:00:00”,与格林尼治时间差5个小时。有必要强调一下这个区别:如果在多个时区存储或访问数据,TIMESTAMP和DATETIME的行为将很不一样。前者提供的值与时区有关系,后者则保留文本表示的日期和时间。\nTIMESTAMP也有DATETIME没有的特殊属性。默认情况下,如果插入时没有指定第一个TIMESTAMP列的值,MySQL则设置这个列的值为当前时间(10)。在插入一行记录时,MySQL默认也会更新第一个TIMESTAMP列的值(除非在UPDATE语句中明确指定了值)。你可以配置任何TIMESTAMP列的插入和更新行为。最后,TIMESTAMP列默认为NOT NULL,这也和其他的数据类型不一样。\n除了特殊行为之外,通常也应该尽量使用TIMESTAMP,因为它比DATETIME空间效率更高。有时候人们会将Unix时间截存储为整数值,但这不会带来任何收益。用整数保存时间截的格式通常不方便处理,所以我们不推荐这样做。\n如果需要存储比秒更小粒度的日期和时间值怎么办?MySQL目前没有提供合适的数据类型,但是可以使用自己的存储格式:可以使用BIGINT类型存储微秒级别的时间截,或者使用DOUBLE存储秒之后的小数部分。这两种方式都可以,或者也可以使用MariaDB替代MySQL。\n4.1.5 位数据类型 # MySQL有少数几种存储类型使用紧凑的位存储数据。所有这些位类型,不管底层存储格式和处理方式如何,从技术上来说都是字符串类型。\nBIT\n在MySQL 5.0之前,BIT是TINYINT的同义词。但是在MySQL 5.0以及更新版本,这是一个特性完全不同的数据类型。下面我们将讨论BIT类型新的行为特性。\n可以使用BIT列在一列中存储一个或多个true/false值。BIT(1)定义一个包含单个位的字段,BIT(2)存储2个位,依此类推。BIT列的最大长度是64个位。\nBIT的行为因存储引擎而异。MyISAM会打包存储所有的BIT列,所以17个单独的BIT列只需要17个位存储(假设没有可为NULL的列),这样MyISAM只使用3个字节就能存储这17个BIT列。其他存储引擎例如Memory和InnoDB,为每个BIT列使用一个足够存储的最小整数类型来存放,所以不能节省存储空间。\nMySQL把BIT当作字符串类型,而不是数字类型。当检索BIT(1)的值时,结果是一个包含二进制0或1值的字符串,而不是ASCII码的“0”或“1”。然而,在数字上下文的场景中检索时,结果将是位字符串转换成的数字。如果需要和另外的值比较结果,一定要记得这一点。例如,如果存储一个值b'00111001\u0026rsquo;(二进制值等于57)到BIT(8)的列并且检索它,得到的内容是字符码为57的字符串。也就是说得到ASCII码为57的字符“9”。但是在数字上下文场景中,得到的是数字57:\n这是相当令人费解的,所以我们认为应该谨慎使用BIT类型。对于大部分应用,最好避免使用这种类型。\n如果想在一个bit的存储空间中存储一个true/false值,另一个方法是创建一个可以为空的CHAR(0)列。该列可以保存空值(NULL)或者长度为零的字符串(空字符串)。\nSET\n如果需要保存很多true/false值,可以考虑合并这些列到一个SET数据类型,它在MySQL内部是以一系列打包的位的集合来表示的。这样就有效地利用了存储空间,并且MySQL有像FIND_IN_SET()和FIELD()这样的函数,方便地在查询中使用。它的主要缺点是改变列的定义的代价较高:需要ALTER TABLE,这对大表来说是非常昂贵的操作(但是本章的后面给出了解决办法)。一般来说,也无法在SET列上通过索引查找。\n在整数列上进行按位操作\n一种替代SET的方式是使用一个整数包装一系列的位。例如,可以把8个位包装到一个TINYINT中,并且按位操作来使用。可以在应用中为每个位定义名称常量来简化这个工作。\n比起SET,这种办法主要的好处在于可以不使用ALTER TABLE改变字段代表的“枚举”值,缺点是查询语句更难写,并且更难理解(当第5个bit位被设置时是什么意思?)。一些人非常适应这种方式,也有一些人不适应,所以是否采用这种技术取决于个人的偏好。\n一个包装位的应用的例子是保存权限的访问控制列表(ACL)。每个位或者SET元素代表一个值,例如CAN_READ、CAN_WRITE,或者CAN_DELETE。如果使用SET列,可以让MySQL在列定义里存储位到值的映射关系;如果使用整数列,则可以在应用代码里存储这个对应关系。这是使用SET列时的查询:\n如果使用整数来存储,则可以参考下面的例子:\n这里我们使用MySQL变量来定义值,但是也可以在代码里使用常量来代替。\n4.1.6 选择标识符(identifier) # 为标识列(identifier column)选择合适的数据类型非常重要。一般来说更有可能用标识列与其他值进行比较(例如,在关联操作中),或者通过标识列寻找其他列。标识列也可能在另外的表中作为外键使用,所以为标识列选择数据类型时,应该选择跟关联表中的对应列一样的类型(正如我们在本章早些时候所论述的一样,在相关的表中使用相同的数据类型是个好主意,因为这些列很可能在关联中使用)。\n当选择标识列的类型时,不仅仅需要考虑存储类型,还需要考虑MySQL对这种类型怎么执行计算和比较。例如,MySQL在内部使用整数存储ENUM和SET类型,然后在做比较操作时转换为字符串。\n一旦选定了一种类型,要确保在所有关联表中都使用同样的类型。类型之间需要精确匹配,包括像UNSIGNED这样的属性(11)。混用不同数据类型可能导致性能问题,即使没有性能影响,在比较操作时隐式类型转换也可能导致很难发现的错误。这种错误可能会很久以后才突然出现,那时候可能都已经忘记是在比较不同的数据类型。\n在可以满足值的范围的需求,并且预留未来增长空间的前提下,应该选择最小的数据类型。例如有一个state_id列存储美国各州的名字(12),就不需要几千或几百万个值,所以不需要使用INT。TINYINT足够存储,而且比INT少了3个字节。如果用这个值作为其他表的外键,3个字节可能导致很大的性能差异。下面是一些小技巧。\n整数类型\n整数通常是标识列最好的选择,因为它们很快并且可以使用AUTO_INCREMENT。\nENUM和SET类型\n对于标识列来说,EMUM和SET类型通常是一个糟糕的选择,尽管对某些只包含固定状态或者类型的静态“定义表”来说可能是没有问题的。ENUM和SET列适合存储固定信息,例如有序的状态、产品类型、人的性别。\n举个例子,如果使用枚举字段来定义产品类型,也许会设计一张以这个枚举字段为主键的查找表(可以在查找表中增加一些列来保存描述性质的文本,这样就能够生成一个术语表,或者为网站的下拉菜单提供有意义的标签)。这时,使用枚举类型作为标识列是可行的,但是大部分情况下都要避免这么做。\n字符串类型\n如果可能,应该避免使用字符串类型作为标识列,因为它们很消耗空间,并且通常比数字类型慢。尤其是在MyISAM表里使用字符串作为标识列时要特别小心。MyISAM默认对字符串使用压缩索引,这会导致查询慢得多。在我们的测试中,我们注意到最多有6倍的性能下降。\n对于完全“随机”的字符串也需要多加注意,例如MD5()、SHA1()或者UUID()产生的字符串。这些函数生成的新值会任意分布在很大的空间内,这会导致INSERT以及一些SELECT语句变得很慢(13):\n因为插入值会随机地写到索引的不同位置,所以使得INSERT语句更慢。这会导致页分裂、磁盘随机访问,以及对于聚簇存储引擎产生聚簇索引碎片。关于这一点第5章有更多的讨论。 SELECT语句会变得更慢,因为逻辑上相邻的行会分布在磁盘和内存的不同地方。 随机值导致缓存对所有类型的查询语句效果都很差,因为会使得缓存赖以工作的访问局部性原理失效。如果整个数据集都一样的“热”,那么缓存任何一部分特定数据到内存都没有好处;如果工作集比内存大,缓存将会有很多刷新和不命中。 如果存储UUID值,则应该移除“-”符号;或者更好的做法是,用UNHEX()函数转换UUID值为16字节的数字,并且存储在一个BINARY(16)列中。检索时可以通过HEX()函数来格式化为十六进制格式。\nUUID()生成的值与加密散列函数例如SHA1()生成的值有不同的特征:UUID值虽然分布也不均匀,但还是有一定顺序的。尽管如此,但还是不如递增的整数好用。\n当心自动生成的schema\n我们已经介绍了大部分重要数据类型的考虑(有些会严重影响性能,有些则影响较小),但是我们还没有提到自动生成的schema设计有多么糟糕。\n写得很烂的schema迁移程序,或者自动生成schema的程序,都会导致严重的性能问题。有些程序存储任何东西都会使用很大的VARCHAR列,或者对需要在关联时比较的列使用不同的数据类型。如果schema是自动生成的,一定要反复检查确认没有问题。\n对象关系映射(ORM)系统(以及使用它们的“框架”)是另一种常见的性能噩梦。一些ORM系统会存储任意类型的数据到任意类型的后端数据存储中,这通常意味着其没有设计使用更优的数据类型来存储。有时会为每个对象的每个属性使用单独的行,甚至使用基于时间戳的版本控制,导致单个属性会有多个版本存在。\n这种设计对开发者很有吸引力,因为这使得他们可以用面向对象的方式工作,不需要考虑数据是怎么存储的。然而,“对开发者隐藏复杂性”的应用通常不能很好地扩展。我们建议在用性能交换开发人员的效率之前仔细考虑,并且总是在真实大小的数据集上做测试,这样就不会太晚才发现性能问题。\n4.1.7 特殊类型数据 # 某些类型的数据并不直接与内置类型一致。低于秒级精度的时间戳就是一个例子;本章的前面部分也演示过存储此类数据的一些选项。\n另一个例子是一个IPv4地址。人们经常使用VARCHAR(15)列来存储IP地址。然而,它们实际上是32位无符号整数,不是字符串。用小数点将地址分成四段的表示方法只是为了让人们阅读容易。所以应该用无符号整数存储IP地址。MySQL提供INET_ATON()和INET_NTOA()函数在这两种表示方法之间转换。\n4.2 MySQL schema设计中的陷阱 # 虽然有一些普遍的好或坏的设计原则,但也有一些问题是由MySQL的实现机制导致的,这意味着有可能犯一些只在MySQL下发生的特定错误。本节我们讨论设计MySQL的schema的问题。这也许会帮助你避免这些错误,并且选择在MySQL特定实现下工作得更好的替代方案。\n太多的列\nMySQL的存储引擎API工作时需要在服务器层和存储引擎层之间通过行缓冲格式拷贝数据,然后在服务器层将缓冲内容解码成各个列。从行缓冲中将编码过的列转换成行数据结构的操作代价是非常高的。MyISAM的定长行结构实际上与服务器层的行结构正好匹配,所以不需要转换。然而,MyISAM的变长行结构和InnoDB的行结构则总是需要转换。转换的代价依赖于列的数量。当我们研究一个CPU占用非常高的案例时,发现客户使用了非常宽的表(数千个字段),然而只有一小部分列会实际用到,这时转换的代价就非常高。如果计划使用数千个字段,必须意识到服务器的性能运行特征会有一些不同。\n太多的关联\n所谓的“实体-属性-值”(EAV)设计模式是一个常见的糟糕设计模式,尤其是在MySQL下不能靠谱地工作。MySQL限制了每个关联操作最多只能有61张表,但是EAV数据库需要许多自关联。我们见过不少EAV数据库最后超过了这个限制。事实上在许多关联少于61张表的情况下,解析和优化查询的代价也会成为MySQL的问题。一个粗略的经验法则,如果希望查询执行得快速且并发性好,单个查询最好在12个表以内做关联。\n全能的枚举\n注意防止过度使用枚举(ENUM)。下面是我们见过的一个例子:\nCREATE TABLE ... ( country enum('','0','1','2',...,'31') 这种模式的schema设计非常凌乱。这么使用枚举值类型也许在任何支持枚举类型的数据库都是一个有问题的设计方案,这里应该用整数作为外键关联到字典表或者查找表来查找具体值。但是在MySQL中,当需要在枚举列表中增加一个新的国家时就要做一次ALTER TABLE操作。在MySQL 5.0以及更早的版本中ALTER TABLE是一种阻塞操作;即使在5.1和更新版本中,如果不是在列表的末尾增加值也会一样需要ALTER TABLE(我们将展示一些骇客式的方法来避免阻塞操作,但是这只是骇客的玩法,别轻易用在生产环境中)。\n变相的枚举\n枚举(ENUM)列允许在列中存储一组定义值中的单个值,集合(SET)列则允许在列中存储一组定义值中的一个或多个值。有时候这可能比较容易导致混乱。这是一个例子:\nCREATE TABLE ... ( is_default set ('Y','N') NOT NULL default 'N' 如果这里真和假两种情况不会同时出现,那么毫无疑问应该使用枚举列代替集合列。\n非此发明(Not Invent Here)的NULL\n我们之前写了避免使用NULL的好处,并且建议尽可能地考虑替代方案。即使需要存储一个事实上的“空值”到表中时,也不一定非得使用NULL。也许可以使用0、某个特殊值,或者空字符串作为代替。\n但是遵循这个原则也不要走极端。当确实需要表示未知值时也不要害怕使用NULL。在一些场景中,使用NULL可能会比某个神奇常数更好。从特定类型的值域中选择一个不可能的值,例如用−1代表一个未知的整数,可能导致代码复杂很多,并容易引入bug,还可能会让事情变得一团糟。处理NULL确实不容易,但有时候会比它的替代方案更好。\n下面是一个我们经常看到的例子:\nCREATE TABLE ...( dt DATETIME NOT NULL DEFAULT '0000-00-00 00:00:00' 伪造的全0值可能导致很多问题(可以配置MySQL的SQL_MODE来禁止不可能的日期,对于新应用这是个非常好的实践经验,它不会让创建的数据库里充满不可能的值)。值得一提的是,MySQL会在索引中存储NULL值,而Oracle则不会。\n4.3 范式和反范式 # 对于任何给定的数据通常都有很多种表示方法,从完全的范式化到完全的反范式化,以及两者的折中。在范式化的数据库中,每个事实数据会出现并且只出现一次。相反,在反范式化的数据库中,信息是冗余的,可能会存储在多个地方。\n如果不熟悉范式,则应该先学习一下。有很多这方面的不错的书和在线资源;在这里,我们只是给出阅读本章所需要的这方面的简单介绍。下面以经典的“雇员,部门,部门领导”的例子开始: EMPLOYEE DEPARTMENT HEAD Jones Accounting Jones Smith Engineering Smith Brown Accounting Jones Green Engineering Smith\n这个schema的问题是修改数据时可能发生不一致。假如Say Brown接任Accounting部门的领导,需要修改多行数据来反映这个变化,这是很痛苦的事并且容易引入错误。如果“Jones”这一行显示部门的领导跟“Brown”这一行的不一样,就没有办法知道哪个是对的。这就像是有句老话说的:“一个人有两块手表就永远不知道时间”。此外,这个设计在没有雇员信息的情况下就无法表示一个部门——如果我们删除了所有Accounting部门的雇员,我们就失去了关于这个部门本身的所有记录。要避免这个问题,我们需要对这个表进行范式化,方式是拆分雇员和部门项。拆分以后可以用下面两张表分别来存储雇员表: EMPLOYEE_NAME DEPARTMENT Jones Accounting Smith Engineering Brown Accounting Green Engineering\n和部门表: DEPARTMENT HEAD Accounting Jones Engineering Smith\n这样设计的两张表符合第二范式,在很多情况下做到这一步已经足够好了。然而,第二范式只是许多可能的范式中的一种。\n这个例子中我们使用姓(Last Name)作为主键,因为这是数据的“自然标识”。从实践来看,无论如何都不应该这么用。这既不能保证唯一性,而且用一个很长的字符串作为主键是很糟糕的主意。\n4.3.1 范式的优点和缺点 # 当为性能问题而寻求帮助时,经常会被建议对schema进行范式化设计,尤其是写密集的场景。这通常是个好建议。因为下面这些原因,范式化通常能够带来好处:\n范式化的更新操作通常比反范式化要快。 当数据较好地范式化时,就只有很少或者没有重复数据,所以只需要修改更少的数据。 范式化的表通常更小,可以更好地放在内存里,所以执行操作会更快。 很少有多余的数据意味着检索列表数据时更少需要DISTINCT或者GROUP BY语句。还是前面的例子:在非范式化的结构中必须使用DISTINCT或者GROUP BY才能获得一份唯一的部门列表,但是如果部门(DEPARTMENT)是一张单独的表,则只需要简单的查询这张表就行了。 范式化设计的schema的缺点是通常需要关联。稍微复杂一些的查询语句在符合范式的schema上都可能需要至少一次关联,也许更多。这不但代价昂贵,也可能使一些索引策略无效。例如,范式化可能将列存放在不同的表中,而这些列如果在一个表中本可以属于同一个索引。\n4.3.2 反范式的优点和缺点 # 反范式化的schema因为所有数据都在一张表中,可以很好地避免关联。\n如果不需要关联表,则对大部分查询最差的情况——即使表没有使用索引——是全表扫描。当数据比内存大时这可能比关联要快得多,因为这样避免了随机I/O(14)。\n单独的表也能使用更有效的索引策略。假设有一个网站,允许用户发送消息,并且一些用户是付费用户。现在想查看付费用户最近的10条信息。如果是范式化的结构并且索引了发送日期字段published,这个查询也许看起来像这样:\nmysql\u0026gt; SELECT message_text, user_name -\u0026gt; FROM message -\u0026gt; INNER JOIN user ON message.user_id=user.id -\u0026gt; WHERE user.account_type='premiumv -\u0026gt; ORDER BY message.published DESC LIMIT 10; 要更有效地执行这个查询,MySQL需要扫描message表的published字段的索引。对于每一行找到的数据,将需要到user表里检查这个用户是不是付费用户。如果只有一小部分用户是付费账户,那么这是效率低下的做法。\n另一种可能的执行计划是从user表开始,选择所有的付费用户,获得他们所有的信息,并且排序。但这可能更加糟糕。\n主要问题是关联,使得需要在一个索引中又排序又过滤。如果采用反范式化组织数据,将两张表的字段合并一下,并且增加一个索引(account_type,published),就可以不通过关联写出这个查询。这将非常高效:\nmysql\u0026gt; SELECT message_text,user_name -\u0026gt; FROM user_messages -\u0026gt; WHERE account_type='premium' -\u0026gt; ORDER BY published DESC -\u0026gt; LIMIT 10; 4.3.3 混用范式化和反范式化 # 范式化和反范式化的schema各有优劣,怎么选择最佳的设计?\n事实是,完全的范式化和完全的反范式化schema都是实验室里才有的东西:在真实世界中很少会这么极端地使用。在实际应用中经常需要混用,可能使用部分范式化的schema、缓存表,以及其他技巧。\n最常见的反范式化数据的方法是复制或者缓存,在不同的表中存储相同的特定列。在MySQL 5.0和更新版本中,可以使用触发器更新缓存值,这使得实现这样的方案变得更简单。\n在我们的网站实例中,可以在user表和message表中都存储account_type字段,而不用完全的反范式化。这避免了完全反范式化的插入和删除问题,因为即使没有消息的时候也绝不会丢失用户的信息。这样也不会把user_message表搞得太大,有利于高效地获取数据。\n但是现在更新用户的账户类型的操作代价就高了,因为需要同时更新两张表。至于这会不会是一个问题,需要考虑更新的频率以及更新的时长,并和执行SELECT查询的频率进行比较。\n另一个从父表冗余一些数据到子表的理由是排序的需要。例如,在范式化的schema里通过作者的名字对消息做排序的代价将会非常高,但是如果在message表中缓存author_name字段并且建好索引,则可以非常高效地完成排序。\n缓存衍生值也是有用的。如果需要显示每个用户发了多少消息(像很多论坛做的),可以每次执行一个昂贵的子查询来计算并显示它;也可以在user表中建一个num_messages列,每当用户发新消息时更新这个值。\n4.4 缓存表和汇总表 # 有时提升性能最好的方法是在同一张表中保存衍生的冗余数据。然而,有时也需要创建一张完全独立的汇总表或缓存表(特别是为满足检索的需求时)。如果能容许少量的脏数据,这是非常好的方法,但是有时确实没有选择的余地(例如,需要避免复杂、昂贵的实时更新操作)。\n术语“缓存表”和“汇总表”没有标准的含义。我们用术语“缓存表”来表示存储那些可以比较简单地从schema其他表获取(但是每次获取的速度比较慢)数据的表(例如,逻辑上冗余的数据)。而术语“汇总表”时,则保存的是使用GROUP BY语句聚合数据的表(例如,数据不是逻辑上冗余的)。也有人使用术语“累积表(Roll-Up Table)”称呼这些表。因为这些数据被“累积”了。\n仍然以网站为例,假设需要计算之前24小时内发送的消息数。在一个很繁忙的网站不可能维护一个实时精确的计数器。作为替代方案,可以每小时生成一张汇总表。这样也许一条简单的查询就可以做到,并且比实时维护计数器要高效得多。缺点是计数器并不是100%精确。\n如果必须获得过去24小时准确的消息发送数量(没有遗漏),有另外一种选择。以每小时汇总表为基础,把前23个完整的小时的统计表中的计数全部加起来,最后再加上开始阶段和结束阶段不完整的小时内的计数。假设统计表叫作msg_per_hr并且这样定义:\nCREATE TABLE msg_per_hr ( hr DATETIME NOT NULL, cnt INT UNSIGNED NOT NULL, PRIMARY KEY(hr) ); 可以通过把下面的三个语句的结果加起来,得到过去24小时发送消息的总数。我们使用LEFT(NOW(),14)来获得当前的日期和时间最接近的小时:\nmysql\u0026gt; ** SELECT SUM(cnt) FROM msg_per_hr** -\u0026gt; ** WHERE hr BETWEEN** -\u0026gt; ** CONCAT(LEFT(NOW(), 14), '00:00') - INTERVAL 23 HOUR** -\u0026gt; ** AND CONCAT(LEFT(NOW(), 14), '00:00') - INTERVAL 1 HOUR;** mysql\u0026gt; ** SELECT COUNT(*) FROM message** -\u0026gt; ** WHERE posted \u0026gt;= NOW() - INTERVAL 24 HOUR** -\u0026gt; ** AND posted \u0026lt; CONCAT(LEFT(NOW(), 14), '00:00') - INTERVAL 23 HOUR;** mysql\u0026gt; ** SELECT COUNT(*) FROM message** -\u0026gt; ** WHERE posted \u0026gt;= CONCAT(LEFT(NOW(), 14), '00:00');** 不管是哪种方法——不严格的计数或通过小范围查询填满间隙的严格计数——都比计算message表的所有行要有效得多。这是建立汇总表的最关键原因。实时计算统计值是很昂贵的操作,因为要么需要扫描表中的大部分数据,要么查询语句只能在某些特定的索引上才能有效运行,而这类特定索引一般会对UPDATE操作有影响,所以一般不希望创建这样的索引。计算最活跃的用户或者最常见的“标签”是这种操作的典型例子。\n缓存表则相反,其对优化搜索和检索查询语句很有效。这些查询语句经常需要特殊的表和索引结构,跟普通OLTP操作用的表有些区别。\n例如,可能会需要很多不同的索引组合来加速各种类型的查询。这些矛盾的需求有时需要创建一张只包含主表中部分列的缓存表。一个有用的技巧是对缓存表使用不同的存储引擎。例如,如果主表使用InnoDB,用MyISAM作为缓存表的引擎将会得到更小的索引占用空间,并且可以做全文搜索。有时甚至想把整个表导出MySQL,插入到专门的搜索系统中获得更高的搜索效率,例如Lucene或者Sphinx搜索引擎。\n在使用缓存表和汇总表时,必须决定是实时维护数据还是定期重建。哪个更好依赖于应用程序,但是定期重建并不只是节省资源,也可以保持表不会有很多碎片,以及有完全顺序组织的索引(这会更加高效)。\n当重建汇总表和缓存表时,通常需要保证数据在操作时依然可用。这就需要通过使用“影子表”来实现,“影子表”指的是一张在真实表“背后”创建的表。当完成了建表操作后,可以通过一个原子的重命名操作切换影子表和原表。例如,如果需要重建my_summary,则可以先创建my_summary_new,然后填充好数据,最后和真实表做切换:\nmysql\u0026gt; ** DROP TABLE IF EXISTS my_summary_new, my_summary_old;** mysql\u0026gt; ** CREATE TABLE my_summary_new LIKE my_summary;** -- populate my_summary_new as desired mysql\u0026gt; ** RENAME TABLE my_summary TO my_summary_old, my_summary_new TO my_summary;** 如果像上面的例子一样,在将my_summary这个名字分配给新建的表之前将原始的my_summary表重命名为my_summary_old,就可以在下一次重建之前一直保留旧版本的数据。如果新表有问题,则可以很容易地进行快速回滚操作。\n4.4.1 物化视图 # 许多数据库管理系统(例如Oracle或者微软SQL Server)都提供了一个被称作物化视图的功能。物化视图实际上是预先计算并且存储在磁盘上的表,可以通过各种各样的策略刷新和更新。MySQL并不原生支持物化视图(我们将在第7章详细探讨支持这种视图的细节)。然而,使用Justin Swanhart的开源工具Flexviews( http://code.google.com/p/flexviews/),也可以自己实现物化视图。Flexviews比完全自己实现的解决方案要更精细,并且提供了很多不错的功能使得可以更简单地创建和维护物化视图。它由下面这些部分组成:\n变更数据抓取(Change Data Capture,CDC)功能,可以读取服务器的二进制日志并且解析相关行的变更。 一系列可以帮助创建和管理视图的定义的存储过程。 一些可以应用变更到数据库中的物化视图的工具。 对比传统的维护汇总表和缓存表的方法,Flexviews通过提取对源表的更改,可以增量地重新计算物化视图的内容。这意味着不需要通过查询原始数据来更新视图。例如,如果创建了一张汇总表用于计算每个分组的行数,此后增加了一行数据到源表中,Flexviews简单地给相应的组的行数加一即可。同样的技术对其他的聚合函数也有效,例如SUM()和AVG()。这实际上是有好处的,基于行的二进制日志包含行更新前后的镜像,所以Flexviews不仅仅可以获得每行的新值,还可以不需要查找源表就能知道每行数据的旧版本。计算增量数据比从源表中读取数据的效率要高得多。\n因为版面的限制,这里我们不会完整地探讨怎么使用Flexviews,但是可以给出一个概略。先写出一个SELECT语句描述想从已经存在的数据库中得到的数据。这可能包含关联和聚合(GROUP BY)。Flexviews中有一个辅助工具可以转换SQL语句到Flexviews的API调用。Flexviews会做完所有的脏活、累活:监控数据库的变更并且转换后用于更新存储物化视图的表。现在应用可以简单地查询物化视图来替代查询需要检索的表。\nFlexviews有不错的SQL覆盖范围,包括一些棘手的表达式,你可能没有料到一个工具可以在MySQL服务器之外处理这些工作。这一点对创建基于复杂SQL表达式的视图很有用,可以用基于物化视图的简单、快速的查询替换原来复杂的查询。\n4.4.2 计数器表 # 如果应用在表中保存计数器,则在更新计数器时可能碰到并发问题。计数器表在Web应用中很常见。可以用这种表缓存一个用户的朋友数、文件下载次数等。创建一张独立的表存储计数器通常是个好主意,这样可使计数器表小且快。使用独立的表可以帮助避免查询缓存失效,并且可以使用本节展示的一些更高级的技巧。\n应该让事情变得尽可能简单,假设有一个计数器表,只有一行数据,记录网站的点击次数:\nmysql\u0026gt; ** CREATE TABLE hit_counter (** -\u0026gt; ** cnt int unsigned not null** -\u0026gt; ** ) ENGINE=InnoDB;** 网站的每次点击都会导致对计数器进行更新:\nmysql\u0026gt; ** UPDATE hit_counter SET cnt = cnt + 1;** 问题在于,对于任何想要更新这一行的事务来说,这条记录上都有一个全局的互斥锁(mutex)。这会使得这些事务只能串行执行。要获得更高的并发更新性能,也可以将计数器保存在多行中,每次随机选择一行进行更新。这样做需要对计数器表进行如下修改:\nmysql\u0026gt; ** CREATE TABLE hit_counter (** -\u0026gt; ** slot tinyint unsigned not null primary key,** -\u0026gt; ** cnt int unsigned not null** -\u0026gt; ** ) ENGINE=InnoDB;** 然后预先在这张表增加100行数据。现在选择一个随机的槽(slot)进行更新:\nmysql\u0026gt; ** UPDATE hit_counter SET cnt = cnt + 1 WHERE slot = RAND() * 100;** 要获得统计结果,需要使用下面这样的聚合查询:\nmysql\u0026gt; ** SELECT SUM(cnt) FROM hit_counter;** 一个常见的需求是每隔一段时间开始一个新的计数器(例如,每天一个)。如果需要这么做,则可以再简单地修改一下表设计:\nmysql\u0026gt; ** CREATE TABLE daily_hit_counter (** -\u0026gt; ** day date not null,** -\u0026gt; ** slot tinyint unsigned not null,** -\u0026gt; ** cnt int unsigned not null,** -\u0026gt; ** primary key(day, slot)** -\u0026gt; ** ) ENGINE=InnoDB;** 在这个场景中,可以不用像前面的例子那样预先生成行,而用ON DUPLICATE KEY UPDATE代替:\nmysql\u0026gt; ** INSERT INTO daily_hit_counter(day, slot, cnt)** -\u0026gt; ** VALUES(CURRENT_DATE, RAND() * 100, 1)** -\u0026gt; ** ON DUPLICATE KEY UPDATE cnt = cnt + 1;** 如果希望减少表的行数,以避免表变得太大,可以写一个周期执行的任务,合并所有结果到0号槽,并且删除所有其他的槽:\nmysql\u0026gt; ** UPDATE daily_hit_counter as c** -\u0026gt; ** INNER JOIN (** -\u0026gt; ** SELECT day, SUM(cnt) AS cnt, MIN(slot) AS mslot** -\u0026gt; ** FROM daily_hit_counter** -\u0026gt; ** GROUP BY day** -\u0026gt; ** ) AS x USING(day)** -\u0026gt; ** SET c.cnt = IF(c.slot = x.mslot, x.cnt, 0),** -\u0026gt; ** c.slot = IF(c.slot = x.mslot, 0, c.slot);** mysql\u0026gt; ** DELETE FROM daily_hit_counter WHERE slot \u0026lt;\u0026gt; 0 AND cnt = 0;** 更快地读,更慢地写\n为了提升读查询的速度,经常会需要建一些额外索引,增加冗余列,甚至是创建缓存表和汇总表。这些方法会增加写查询的负担,也需要额外的维护任务,但在设计高性能数据库时,这些都是常见的技巧:虽然写操作变得更慢了,但更显著地提高了读操作的性能。\n然而,写操作变慢并不是读操作变得更快所付出的唯一代价,还可能同时增加了读操作和写操作的开发难度。\n4.5 加快ALTER TABLE操作的速度 # MySQL的ALTER TABLE操作的性能对大表来说是个大问题。MySQL执行大部分修改表结构操作的方法是用新的结构创建一个空表,从旧表中查出所有数据插入新表,然后删除旧表。这样操作可能需要花费很长时间,如果内存不足而表又很大,而且还有很多索引的情况下尤其如此。许多人都有这样的经验,ALTER TABLE操作需要花费数个小时甚至数天才能完成。\nMySQL 5.1以及更新版本包含一些类型的“在线”操作的支持,这些功能不需要在整个操作过程中锁表。最近版本的InnoDB(15)也支持通过排序来建索引,这使得建索引更快并且有一个紧凑的索引布局。\n一般而言,大部分ALTER TABLE操作将导致MySQL服务中断。我们会展示一些在DDL操作时有用的技巧,但这是针对一些特殊的场景而言的。对常见的场景,能使用的技巧只有两种:一种是先在一台不提供服务的机器上执行ALTER TABLE操作,然后和提供服务的主库进行切换;另外一种技巧是“影子拷贝”。影子拷贝的技巧是用要求的表结构创建一张和源表无关的新表,然后通过重命名和删表操作交换两张表。也有一些工具可以帮助完成影子拷贝工作:例如,Facebook数据库运维团队( https://launchpad.net/mysqlatfacebook)的“online schema change”工具、Shlomi Noach的openark toolkit( http://code.openark.org/),以及Percona Toolkit( http://www.percona.com/software/)。如果使用Flexviews(参考4.4.1节),也可以通过其CDC工具执行无锁的表结构变更。\n不是所有的ALTER TABLE操作都会引起表重建。例如,有两种方法可以改变或者删除一个列的默认值(一种方法很快,另外一种则很慢)。假如要修改电影的默认租赁期限,从三天改到五天。下面是很慢的方式:\nmysql\u0026gt; ** ALTER TABLE sakila.film** -\u0026gt; ** MODIFY COLUMN rental_duration TINYINT(3) NOT NULL DEFAULT 5;** SHOW STATUS显示这个语句做了1000次读和1000次插入操作。换句话说,它拷贝了整张表到一张新表,甚至列的类型、大小和可否为NULL属性都没改变。\n理论上,MySQL可以跳过创建新表的步骤。列的默认值实际上存在表的*.frm*文件中,所以可以直接修改这个文件而不需要改动表本身。然而MySQL还没有采用这种优化的方法,所有的MODIFY COLUMN操作都将导致表重建。\n另外一种方法是通过ALTER COLUMN(16)操作来改变列的默认值:\nmysql\u0026gt; ** ALTER TABLE sakila.film** -\u0026gt; ** ALTER COLUMN rental_duration SET DEFAULT 5;** 这个语句会直接修改*.frm*文件而不涉及表数据。所以,这个操作是非常快的。\n4.5.1 只修改.frm文件 # 从上面的例子我们看到修改表的*.frm*文件是很快的,但MySQL有时候会在没有必要的时候也重建表。如果愿意冒一些风险,可以让MySQL做一些其他类型的修改而不用重建表。\n我们下面要演示的技巧是不受官方支持的,也没有文档记录,并且也可能不能正常工作,采用这些技术需要自己承担风险。建议在执行之前首先备份数据!\n下面这些操作是有可能不需要重建表的:\n移除(不是增加)一个列的AUTO_INCREMENT属性。 增加、移除,或更改ENUM和SET常量。如果移除的是已经有行数据用到其值的常量,查询将会返回一个空字串值。 基本的技术是为想要的表结构创建一个新的*.frm文件,然后用它替换掉已经存在的那张表的.frm*文件,像下面这样:\n创建一张有相同结构的空表,并进行所需要的修改(例如增加ENUM常量)。 执行FLUSH TABLES WITH READ LOCK。这将会关闭所有正在使用的表,并且禁止任何表被打开。 交换*.frm*文件. 执行UNLOCK TABLES来释放第2步的读锁。 下面以给sakila.flm表的rating列增加一个常量为例来说明。当前列看起来如下:\n假设我们需要为那些对电影更加谨慎的父母们增加一个PG-14的电影分级:\nmysql\u0026gt; ** CREATE TABLE sakila.film_new LIKE sakila.film;** mysql\u0026gt; ** ALTER TABLE sakila.film_new** -\u0026gt; ** MODIFY COLUMN rating ENUM('G','PG','PG-13','R','NC-17', 'PG-14')** -\u0026gt; ** DEFAULT 'G';** mysql\u0026gt; ** FLUSH TABLES WITH READ LOCK;** 注意,我们是在常量列表的末尾增加一个新的值。如果把新增的值放在中间,例如PG-13之后,则会导致已经存在的数据的含义被改变:已经存在的R值将变成PG-14,而已经存在的NC-17将成为R,等等。\n接下来用操作系统的命令交换*.frm*文件:\n/var/lib/mysql/sakila# mv film.frm film_tmp.frm /var/lib/mysql/sakila# mv film_new.frm film.frm /var/lib/mysql/sakila# mv film_tmp.frm film_new.frm 再回到MySQL命令行,现在可以解锁表并且看到变更后的效果了:\nmysql\u0026gt; ** UNLOCK TABLES;** mysql\u0026gt; ** SHOW COLUMNS FROM sakila.film LIKE 'rating'\\G** *************************** 1. row *************************** Field: rating Type: enum('G','PG','PG-13','R','NC-17','PG-14') 最后需要做的是删除为完成这个操作而创建的辅助表:\nmysql\u0026gt; DROP TABLE sakila.film_new; 4.5.2 快速创建MyISAM索引 # 为了高效地载入数据到MyISAM表中,有一个常用的技巧是先禁用索引、载入数据,然后重新启用索引:\nmysql\u0026gt; ** ALTER TABLE test.load_data ENABLE KEYS;** -- load the data mysql\u0026gt; ** ALTER TABLE test.load_data ENABLE KEYS;** 这个技巧能够发挥作用,是因为构建索引的工作被延迟到数据完全载入以后,这个时候已经可以通过排序来构建索引了。这样做会快很多,并且使得索引树(17)的碎片更少、更紧凑。\n不幸的是,这个办法对唯一索引无效,因为DISABLE KEYS只对非唯一索引有效。MyISAM会在内存中构造唯一索引,并且为载入的每一行检查唯一性。一旦索引的大小超过了有效内存大小,载入操作就会变得越来越慢。\n在现代版本的InnoDB版本中,有一个类似的技巧,这依赖于InnoDB的快速在线索引创建功能。这个技巧是,先删除所有的非唯一索引,然后增加新的列,最后重新创建删除掉的索引。Percona Server可以自动完成这些操作步骤。\n也可以使用像前面说的ALTER TABLE的骇客方法来加速这个操作,但需要多做一些工作并且承担一定的风险。这对从备份中载入数据是很有用的,例如,当已经知道所有数据都是有效的并且没有必要做唯一性检查时就可以这么来操作。\n再次说明,这是没有文档说明并且不受官方支持的技巧。若使用的话,需要自己承担风险,并且操作之前一定要先备份数据。\n下面是操作步骤:\n用需要的表结构创建一张表,但是不包括索引。 载入数据到表中以构建*.MYD*文件。 按照需要的结构创建另外一张空表,这次要包含索引。这会创建需要的*.frm和.MYI*文件。 获取读锁并刷新表。 重命名第二张表的*.frm和.MYI*文件,让MySQL认为是第一张表的文件。 释放读锁。 使用REPAIR TABLE来重建表的索引。该操作会通过排序来构建所有索引,包括唯一索引。 这个操作步骤对大表来说会快很多。\n4.6 总结 # 良好的schema设计原则是普遍适用的,但MySQL有它自己的实现细节要注意。概括来说,尽可能保持任何东西小而简单总是好的。MySQL喜欢简单,需要使用数据库的人应该也同样会喜欢简单的原则:\n尽量避免过度设计,例如会导致极其复杂查询的schema设计,或者有很多列的表设计(很多的意思是介于有点多和非常多之间)。 使用小而简单的合适数据类型,除非真实数据模型中有确切的需要,否则应该尽可能地避免使用NULL值。 尽量使用相同的数据类型存储相似或相关的值,尤其是要在关联条件中使用的列。 注意可变长字符串,其在临时表和排序时可能导致悲观的按最大长度分配内存。 尽量使用整型定义标识列。 避免使用MySQL已经遗弃的特性,例如指定浮点数的精度,或者整数的显示宽度。 小心使用ENUM和SET。虽然它们用起来很方便,但是不要滥用,否则有时候会变成陷阱。最好避免使用BIT。 范式是好的,但是反范式(大多数情况下意味着重复数据)有时也是必需的,并且能带来好处。第5章我们将看到更多的例子。预先计算、缓存或生成汇总表也可能获得很大的好处。Justin Swanhart的Flexviews工具可以帮助维护汇总表。\n最后,ALTER TABLE是让人痛苦的操作,因为在大部分情况下,它都会锁表并且重建整张表。我们展示了一些特殊的场景可以使用骇客方法;但是对大部分场景,必须使用其他更常规的方法,例如在备机执行ALTER并在完成后把它切换为主库。本书后续章节会有更多关于这方面的内容。\n————————————————————\n(1) 例如只需要存0~200,tinyint unsigned更好。——译者注\n(2) date,time,datatime——译者注\n(3) 如果定义表结构时没有指定列为NOT NULL,默认都是允许为NULL的。\n(4) 很多值为NULL,只有少数行的列有非NULL值。——译者注\n(5) 记住字符串长度定义不是字节数,是字符数。多字节字符集会需要更多的空间存储单个字符。\n(6) string3尾部的空格还在。——译者注\n(7) Percona Server里的Memory引擎支持变长的行。\n(8) 如果需要在检索时保持值不变,则需要特别小心BINARY类型,MySQL会用\\0将其填充到需要的长度。\n(9) 这很可能可以节省I/O。——译者注\n(10) TIMESTAMP的行为规则比较复杂,并且在不同的MySQL版本里会变动,所以你应该验证数据库的行为是你需要的。一个好的方式是修改完TIMESTAMP列后用SHOW CREATE TABLE命令检查输出。\n(11) 如果使用的是InnoDB存储引擎,将不能在数据类型不是完全匹配的情况下创建外键,否则会有报错信息:“ERROR 1005(HY000):Can\u0026rsquo;t create table”,这个信息可能让人迷惑不解,这个问题在MySQL邮件组也经常有人抱怨(但奇怪的是,在不同长度的VARCHAR列上创建外键又是可以的)。\n(12) 这是关联到另一张存储名字的表的ID。——译者注\n(13) 另一方面,对一些有很多写的特别大的表,这种伪随机值实际上可以帮助消除热点。\n(14) 全表扫描基本上是顺序I/O,但也不是100%的,跟引擎的实现有关。——译者注\n(15) 就是所谓的“InnoDB plugin”,MySQL 5.5和更新版本中唯一的InnoDB。请参考第1章中关于InnoDB发布历史的细节。\n(16) ALTER TABLE允许使用ALTER COLUMN、MODIFY COLUMN和CHANGE COLUMN语句修改列。这三种操作都是不一样的。\n(17) 如果使用的是LOAD DATA FILE,并且要载入的表是空的,MyISAM也可以通过排序来构造索引。\n"},{"id":144,"href":"/zh/docs/technology/MySQL/_%E9%AB%98%E6%80%A7%E8%83%BDMySQL_/%E7%AC%AC3%E7%AB%A0%E6%9C%8D%E5%8A%A1%E5%99%A8%E6%80%A7%E8%83%BD%E5%89%96%E6%9E%90/","title":"第3章服务器性能剖析","section":"高性能 My SQL","content":"第3章 服务器性能剖析\n在我们的技术咨询生涯中,最常碰到的三个性能相关的服务请求是:如何确认服务器是否达到了性能最佳的状态、找出某条语句为什么执行不够快,以及诊断被用户描述成“停顿”、“堆积”或者“卡死”的某些间歇性疑难故障。本章将主要针对这三个问题做出解答。我们将提供一些工具和技巧来优化整机的性能、优化单条语句的执行速度,以及诊断或者解决那些很难观察到的问题(这些问题用户往往很难知道其根源,有时候甚至都很难察觉到它的存在)。\n这看起来是个艰巨的任务,但是事实证明,有一个简单的方法能够从噪声中发现苗头。这个方法就是专注于测量服务器的时间花费在哪里,使用的技术则是性能剖析(profiling)。在本章,我们将展示如何测量系统并生成剖析报告,以及如何分析系统的整个堆栈(stack),包括从应用程序到数据库服务器到单个查询。\n首先我们要保持空杯精神,抛弃掉一些关于性能的常见的误解。这有一定的难度,下面我们一起通过一些例子来说明问题在哪里。\n3.1 性能优化简介 # 问10个人关于性能的问题,可能会得到10个不同的回答,比如“每秒查询次数”、“CPU利用率”、“可扩展性”之类。这其实也没有问题,每个人在不同场景下对性能有不同的理解,但本章将给性能一个正式的定义。我们将性能定义为完成某件任务所需要的时间度量,换句话说,性能即响应时间,这是一个非常重要的原则。我们通过任务和时间而不是资源来测量性能。数据库服务器的目的是执行SQL语句,所以它关注的任务是查询或者语句,如SELECT、UPDATE、DELETE等(1)。数据库服务器的性能用查询的响应时间来度量,单位是每个查询花费的时间。\n还有另外一个问题:什么是优化?我们暂时不讨论这个问题,而是假设性能优化就是在一定的工作负载下尽可能地(2)降低响应时间。\n很多人对此很迷茫。假如你认为性能优化是降低CPU利用率,那么可以减少对资源的使用。但这是一个陷阱,资源是用来消耗并用来工作的,所以有时候消耗更多的资源能够加快查询速度。很多时候将使用老版本InnoDB引擎的MySQL升级到新版本后,CPU利用率会上升得很厉害,这并不代表性能出现了问题,反而说明新版本的InnoDB对资源的利用率上升了。查询的响应时间则更能体现升级后的性能是不是变得更好。版本升级有时候会带来一些bug,比如不能利用某些索引从而导致CPU利用率上升。CPU利用率只是一种现象,而不是很好的可度量的目标。\n同样,如果把性能优化仅仅看成是提升每秒查询量,这其实只是吞吐量优化。吞吐量的提升可以看作性能优化的副产品(3)。对查询的优化可以让服务器每秒执行更多的查询,因为每条查询执行的时间更短了(吞吐量的定义是单位时间内的查询数量,这正好是我们对性能的定义的倒数)。\n所以如果目标是降低响应时间,那么就需要理解为什么服务器执行查询需要这么多时间,然后去减少或者消除那些对获得查询结果来说不必要的工作。也就是说,先要搞清楚时间花在哪里。这就引申出优化的第二个原则:无法测量就无法有效地优化。所以第一步应该测量时间花在什么地方。\n我们观察到,很多人在优化时,都将精力放在修改一些东西上,却很少去进行精确的测量。我们的做法完全相反,将花费非常多,甚至90%的时间来测量响应时间花在哪里。如果通过测量没有找到答案,那要么是测量的方式错了,要么是测量得不够完整。如果测量了系统中完整而且正确的数据,性能问题一般都能暴露出来,对症下药的解决方案也就比较明了。测量是一项很有挑战性的工作,并且分析结果也同样有挑战性,测出时间花在哪里,和知道为什么花在那里,是两码事。\n前面提到需要合适的测量范围,这是什么意思呢?合适的测量范围是说只测量需要优化的活动。有两种比较常见的情况会导致不合适的测量:\n在错误的时间启动和停止测量。 测量的是聚合后的信息,而不是目标活动本身。 例如,一个常见的错误是先查看慢查询,然后又去排查整个服务器的情况来判断问题在哪里。如果确认有慢查询,那么就应该测量慢查询,而不是测量整个服务器。测量的应该是从慢查询的开始到结束的时间,而不是查询之前或查询之后的时间。\n完成一项任务所需要的时间可以分成两部分:执行时间和等待时间。如果要优化任务的执行时间,最好的办法是通过测量定位不同的子任务花费的时间,然后优化去掉一些子任务、降低子任务的执行频率或者提升子任务的效率。而优化任务的等待时间则相对要复杂一些,因为等待有可能是由其他系统间接影响导致,任务之间也可能由于争用磁盘或者CPU资源而相互影响。根据时间是花在执行还是等待上的不同,诊断也需要不同的工具和技术。\n刚才说到需要定位和优化子任务,但只是一笔带过。一些运行不频繁或者很短的子任务对整体响应时间的影响很小,通常可以忽略不计。那么如何确认哪些子任务是优化的目标呢?这个时候性能剖析就可以派上用场了。\n如何判断测量是正确的?\n如果测量是如此重要,那么测量错了会有什么后果?实际上,测量经常都是错误的。对数量的测量并不等于数量本身。测量的错误可能很小,跟实际情况区别不大,但错的终归是错的。所以这个问题其实应该是:“测量到底有多么不准确?”这个问题在其他一些书中有详细的讨论,但不是本书的主题。但是要意识到使用的是测量数据,而不是其所代表的实际数据。通常来说,测量的结果也可能有多种模糊的表现,这可能导致推断出错误的结论。\n3.1.1 通过性能剖析进行优化 # 一旦掌握并实践面向响应时间的优化方法,就会发现需要不断地对系统进行性能剖析(profiling)。\n性能剖析是测量和分析时间花费在哪里的主要方法。性能剖析一般有两个步骤:测量任务所花费的时间;然后对结果进行统计和排序,将重要的任务排到前面。\n性能剖析工具的工作方式基本相同。在任务开始时启动计时器,在任务结束时停止计时器,然后用结束时间减去启动时间得到响应时间。也有些工具会记录任务的父任务。这些结果数据可以用来绘制调用关系图,但对于我们的目标来说更重要的是,可以将相似的任务分组并进行汇总。对相似的任务分组并进行汇总可以帮助对那些分到一组的任务做更复杂的统计分析,但至少需要知道每一组有多少任务,并计算出总的响应时间。通过性能剖析报告(profile report)可以获得需要的结果。性能剖析报告会列出所有任务列表。每行记录一个任务,包括任务名、任务的执行时间、任务的消耗时间、任务的平均执行时间,以及该任务执行时间占全部时间的百分比。性能剖析报告会按照任务的消耗时间进行降序排序。\n为了更好地说明,这里举一个对整个数据库服务器工作负载的性能剖析的例子,主要输出的是各种类型的查询和执行查询的时间。这是从整体的角度来分析响应时间,后面会演示其他角度的分析结果。下面的输出是用Percona Toolkit中的pt-query-digest(实际上就是著名的Maatkit工具中的mk-query-digest)分析得到的结果。为了显示方便,对结果做了一些微调,并且只截取了前面几行结果:\nRank Response time Calls R/Call Item ==== ================ ===== ====== ======= 1 11256.3618 68.1% 78069 0.1442 SELECT InvitesNew 2 2029.4730 12.3% 14415 0.1408 SELECT StatusUpdate 3 1345.3445 8.1% 3520 0.3822 SHOW STATUS 上面只是性能剖析结果的前几行,根据总响应时间进行排名,只包括剖析所需要的最小列组合。每一行都包括了查询的响应时间和占总时间的百分比、查询的执行次数、单次执行的平均响应时间,以及该查询的摘要。通过这个性能剖析可以很清楚地看到每个查询相互之间的成本比较,以及每个查询占总成本的比较。在这个例子中,任务指的就是查询,实际上在分析MySQL的时候经常都指的是查询。\n我们将实际地讨论两种类型的性能剖析:基于执行时间的分析和基于等待的分析。基于执行时间的分析研究的是什么任务的执行时间最长,而基于等待的分析则是判断任务在什么地方被阻塞的时间最长。\n如果任务执行时间长是因为消耗了太多的资源且大部分时间花费在执行上,等待的时间不多,这种情况下基于等待的分析作用就不大。反之亦然,如果任务一直在等待,没有消耗什么资源,去分析执行时间就不会有什么结果。如果不能确认问题是出在执行还是等待上,那么两种方式都需要试试。后面会给出详细的例子。\n事实上,当基于执行时间的分析发现一个任务需要花费太多时间的时候,应该深入去分析一下,可能会发现某些“执行时间”实际上是在等待。例如,上面简单的性能剖析的输出显示表InvitesNew上的SELECT查询花费了大量时间,如果深入研究,则可能发现时间都花费在等待I/O完成上。\n在对系统进行性能剖析前,必须先要能够进行测量,这需要系统可测量化的支持。可测量的系统一般会有多个测量点可以捕获并收集数据,但实际系统很少可以做到可测量化。大部分系统都没有多少可测量点,即使有也只提供一些活动的计数,而没有活动花费的时间统计。MySQL就是一个典型的例子,直到版本5.5才第一次提供了Performance Schema,其中有一些基于时间的测量点(4),而版本5.1及之前的版本没有任何基于时间的测量点。能够从MySQL收集到的服务器操作的数据大多是show status计数器的形式,这些计数器统计的是某种活动发生的次数。这也是我们最终决定创建Percona Server的主要原因,Percona Server从版本5.0开始提供很多更详细的查询级别的测量点。\n虽然理想的性能优化技术依赖于更多的测量点,但幸运的是,即使系统没有提供测量点,也还有其他办法可以展开优化工作。因为还可以从外部去测量系统,如果测量失败,也可以根据对系统的了解做出一些靠谱的猜测。但这么做的时候一定要记住,不管是外部测量还是猜测,数据都不是百分之百准确的,这是系统不透明所带来的风险。\n举个例子,在Percona Server 5.0中,慢查询日志揭露了一些性能低下的原因,如磁盘I/O等待或者行级锁等待。如果日志中显示一条查询花费10秒,其中9.6秒在等待磁盘I/O,那么追究其他4%的时间花费在哪里就没有意义,磁盘I/O才是最重要的原因。\n3.1.2 理解性能剖析 # MySQL的性能剖析(profile)将最重要的任务展示在前面,但有时候没显示出来的信息也很重要。可以参考一下前面提到过的性能剖析的例子。不幸的是,尽管性能剖析输出了排名、总计和平均值,但还是有很多需要的信息是缺失的,如下所示。\n值得优化的查询(worthwhile query)\n性能剖析不会自动给出哪些查询值得花时间去优化。这把我们带回到优化的本意,如果你读过Cary Millsap的书,对此就会有更多的理解。这里我们要再次强调两点:第一,一些只占总响应时间比重很小的查询是不值得优化的。根据阿姆达尔定律(Amdahl\u0026rsquo;s Law),对一个占总响应时间不超过5%的查询进行优化,无论如何努力,收益也不会超过5%。第二,如果花费了1000美元去优化一个任务,但业务的收入没有任何增加,那么可以说反而导致业务被逆优化了1000美元。如果优化的成本大于收益,就应当停止优化。\n异常情况\n某些任务即使没有出现在性能剖析输出的前面也需要优化。比如某些任务执行次数很少,但每次执行都非常慢,严重影响用户体验。因为其执行频率低,所以总的响应时间占比并不突出。\n未知的未知(5)\n一款好的性能剖析工具会显示可能的“丢失的时间”。丢失的时间指的是任务的总时间和实际测量到的时间之间的差。例如,如果处理器的CPU时间是10秒,而剖析到的任务总时间是9.7秒,那么就有300毫秒的丢失时间。这可能是有些任务没有测量到,也可能是由于测量的误差和精度问题的缘故。如果工具发现了这类问题,则要引起重视,因为有可能错过了某些重要的事情。即使性能剖析没有发现丢失时间,也需要注意考虑这类问题存在的可能性,这样才不会错过重要的信息。我们的例子中没有显示丢失的时间,这是我们所使用工具的一个局限性。\n被掩藏的细节\n性能剖析无法显示所有响应时间的分布。只相信平均值是非常危险的,它会隐藏很多信息,而且无法表达全部情况。Peter经常举例说医院所有病人的平均体温没有任何价值(6)。假如在前面的性能剖析的例子的第一项中,如果有两次查询的响应时间是1秒,而另外12771次查询的响应时间是几十微秒,结果会怎样?只从平均值里是无法发现两次1秒的查询的。要做出最好的决策,需要为性能剖析里输出的这一行中包含的12773次查询提供更多的信息,尤其是更多响应时间的信息,比如直方图、百分比、标准差、偏差指数等。\n好的工具可以自动地获得这些信息。实际上,pt-query-digest就在剖析的结果里包含了很多这类细节信息,并且输出在剖析报告中。对此我们做了简化,可以将精力集中在重要而基础的例子上:通过排序将最昂贵的任务排在前面。本章后面会展示更多丰富而有用的性能剖析的例子。\n在前面的性能剖析的例子中,还有一个重要的缺失,就是无法在更高层次的堆栈中进行交互式的分析。当我们仅仅着眼于服务器中的单个查询时,无法将相关查询联系起来,也无法理解这些查询是否是同一个用户交互的一部分。性能剖析只能管中窥豹,而无法将剖析从任务扩展至事务或者页面查看(page view)的级别。也有一些办法可以解决这个问题,比如给查询加上特殊的注释作为标签,可以标明其来源并据此做聚合,也可以在应用层面增加更多的测量点,这是下一节的主题。\n3.2 对应用程序进行性能剖析 # 对任何需要消耗时间的任务都可以做性能剖析,当然也包括应用程序。实际上,剖析应用程序一般比剖析数据库服务器容易,而且回报更多。虽然前面的演示例子都是针对MySQL服务器的剖析,但对系统进行性能剖析还是建议自上而下地进行(7),这样可以追踪自用户发起到服务器响应的整个流程。虽然性能问题大多数情况下都和数据库有关,但应用导致的性能问题也不少。性能瓶颈可能有很多影响因素:\n外部资源,比如调用了外部的Web服务或者搜索引擎。 应用需要处理大量的数据,比如分析一个超大的XML文件。 在循环中执行昂贵的操作,比如滥用正则表达式。 使用了低效的算法,比如使用暴力搜索算法(naïve search algorithm)来查找列表中的项。 幸运的是,确定MySQL的问题没有这么复杂,只需要一款应用程序的剖析工具即可(作为回报,一旦拥有这样的工具,就可以从一开始就写出高效的代码)。\n建议在所有的新项目中都考虑包含性能剖析的代码。往已有的项目中加入性能剖析代码也许很困难,新项目就简单一些。\n性能剖析本身会导致服务器变慢吗?\n说“是的”,是因为性能剖析确实会导致应用慢一点;说“不是”,是因为性能剖析可以帮助应用运行得更快。先别急,下面就解释一下为什么这么说。\n性能剖析和定期检测都会带来额外开销。问题在于这部分的开销有多少,并且由此获得的收益是否能够抵消这些开销。\n大多数设计和构建过高性能应用程序的人相信,应该尽可能地测量一切可以测量的地方,并且接受这些测量带来的额外开销,这些开销应该被当成应用程序的一部分。Oracle的性能优化大师Tom Kyte曾被问到Oracle中的测量点的开销,他的回答是,测量点至少为性能优化贡献了10%。对此我们深表赞同,而且大多数应用并不需要每天都运行详细的性能测量,所以实际贡献甚至要超过10%。即使不同意这个观点,为应用构建一些可以永久使用的轻量级的性能剖析也是有意义的。如果系统没有每天变化的性能统计,则碰到无法提前预知的性能瓶颈就是一件头痛的事情。发现问题的时候,如果有历史数据,则这些历史数据价值是无限的。而且性能数据还可以帮助规划好硬件采购、资源分配,以及预测周期性的性能尖峰。\n那么何谓“轻量级”的性能剖析?比如可以为所有SQL语句计时,加上脚本总时间统计,这样做的代价不高,而且不需要在每次页面查看(page view)时都执行。如果流量趋势比较稳定,随机采样也可以,随机采样可以通过在应用程序中设置实现:\n\u0026lt;?php $profiling_enabled=rand(0,100)\u0026gt;99; ?\u0026gt; 这样只有1%的会话会执行性能采样,来帮助定位一些严重的问题。这种策略在生产环境中尤其有用,可以发现一些其他方法无法发现的问题。\n几年前在写作本书的第二版的时候,流行的Web编程语言和框架中还没有太多现成的性能剖析工具可以用于生产环境,所以在书中展示了一段示例代码,可以简单而有效地复制使用。而到了今天,已经有了很多好用的工具,要做的只是打开工具箱,就可以开始优化性能。\n首先,这里要“兜售”的一个好工具是一款叫做New Relic的软件即服务(software-as-a-service)产品。声明一下我们不是“托”,我们一般不会推荐某个特定公司或产品,但这个工具真的非常棒,建议大家都用它。我们的客户借助这个工具,在没有我们帮助的情况下,解决了很多问题;即使有时候找不到解决办法,但依然能够帮助定位到问题。New Relic会插入到应用程序中进行性能剖析,将收集到的数据发送到一个基于Web的仪表盘,使用仪表盘可以更容易利用面向响应时间的方法分析应用性能。这样用户只需要考虑做那些正确的事情,而不用考虑如何去做。而且New Relic测量了很多用户体验相关的点,涵盖从Web浏览器到应用代码,再到数据库及其他外部调用。\n像New Relic这类工具的好处是可以全天候地测量生产环境的代码——既不限于测试环境,也不限于某个时间段。这一点非常重要,因为有很多剖析工具或者测量点的代价很高,所以不能在生产环境全天候运行。在生产环境运行,可以发现一些在测试环境和预发环境无法发现的性能问题。如果工具在生产环境全天候运行的成本太高,那么至少也要在集群中找一台服务器运行,或者只针对部分代码运行,原因请参考前面的“性能剖析本身会导致服务器变慢吗?”。\n3.2.1 测量PHP应用程序 # 如果不使用New Relic,也有其他的选择。尤其是对PHP,有好几款工具都可以帮助进行性能剖析。其中一款叫做xhprof( http://pecl.php.net/package/xhprof),这是Facebook开发给内部使用的,在2009年开源了。xhprof有很多高级特性,并且易于安装和使用,它很轻量级,可扩展性也很好,可以在生产环境大量部署并全天候使用,它还能针对函数调用进行剖析,并根据耗费的时间进行排序。相比xhprof,还有一些更底层的工具,比如xdebug、Valgrind和cachegrind,可以从多个角度对代码进行检测(8)。有些工具会产生大量输出,并且开销很大,并不适合在生产环境运行,但在开发环境却可以发挥很大的作用。\n下面要讨论的另外一个PHP性能剖析工具是我们自己写的,基于本书第二版的代码和原则扩展而来,名叫IfP(instrumentation-for-php),代码托管在Goole Code上( http://code.google.com/p/instrumentation-for-php/)。Ifp并不像xhprof一样对PHP做深入的测量,而是更关注数据库调用。所以当无法在数据库层面进行测量的时候,Ifp可以很好地帮助应用剖析数据库的利用率。Ifp是一个提供了计数器和计时器的单例类,很容易部署到生产环境中,因为不需要访问PHP配置的权限(对很多开发人员来说,都没有访问PHP配置的权限,所以这一点很重要)。\nIfp不会自动剖析所有的PHP函数,而只是针对重要的函数。例如,对于某些需要剖析的地方要用到自定义的计数器,就需要手工启动和停止。但Ifp可以自动对整个页面的执行进行计时,这样对自动测量数据库和memcached的调用就比较简单,对于这种情况就无须手工启动或者停止。这也意味着,Ifp可以剖析三种情况:应用程序的请求(如page view)、数据库的查询和缓存的查询。Ifp还可以将计数器和计时器输出到Apache,通过Apache可以将结果写入到日志中。这是一种方便且轻量的记录结果的方式。Ifp不会保存其他数据,所以也不需要有系统管理员的权限。\n使用Ifp,只需要简单地在页面的开始处调用start_request()。理想情况下,在程序的一开始就应当调用:\nrequire_once('Instrumentation.php'); Instrumentation::get_instance()-\u0026gt;start_request(); 这段代码注册了一个shutdown函数,所以在执行结束的地方不需要再做更多的处理。\nIfp会自动对SQL添加注释,便于从数据库的查询日志中更灵活地分析应用的情况,通过SHOW PROCESSLIST也可以更清楚地知道性能低的查询出自何处。大多数情况下,定位性能低下查询的来源都不容易,尤其是那些通过字符串拼接出来的查询语句,都没有办法在源代码中去搜索。那么Ifp的这个功能就可以帮助解决这个问题,它可以很快定位到查询是从何处而来的,即使应用和数据库中间加了代理或者负载均衡层,也可以确认是哪个应用的用户,是哪个页面请求,是源代码中的哪个函数、代码行号,甚至是所创建的计数器的键值对。下面是一个例子:\n** --File: index.php Line: 118 Function: fullCachePage request_id: ABC session_id: XYZ** ** SELECT * FROM ...** 如何测量MySQL的调用取决于连接MySQL的接口。如果使用的是面向对象的mysqli接口,则只需要修改一行代码:将构造函数从mysqli改为可以自动测量的mysqli_x即可。mysqli_x构造函数是由Ifp提供的子类,可以在后台测量并改写查询。如果使用的不是面向对象的接口,或者是其他的数据库访问层,则需要修改更多的代码。如果数据库调用不是分散在代码各处还好,否则建议使用集成开发环境(IDE)如Eclipse,这样修改起来要容易些。但不管从哪个方面来看,将访问数据库的代码集中到一起都可以说是最佳实践。\nIfp的结果很容易分析。Percona Toolkit中的pt-query-digest能够很方便地从查询注释中抽取出键值对,所以只需要简单地将查询记录到MySQL的日志文件中,再对日志文件进行处理即可。Apache的mod_log_config模块可以利用Ifp输出的环境变量来定制日志输出,其中的宏%D还可以以微秒级记录请求时间。\n也可以通过LOAD DATA INFILE将Apache的日志载入到MySQL数据库中,然后通过SQL进行查询。在Ifp的网站上有一个PDF的幻灯片,详细给出了使用示例,包括查询和命令行参数都有。\n或许你会说不想或者没时间在代码中加入测量的功能,其实这事比想象的要容易得多,而且花在优化上的时间将会由于性能的优化而加倍地回报给你。对应用的测量是不可替代的。当然最好是直接使用New Relic、xhprof、Ifp或者其他已有的优化工具,而不必重新去发明“轮子”。\nMySQL企业监控器的查询分析功能\nMySQL的企业监控器(Enterprise Monitor)也是值得考虑的工具之一。这是Oracle提供的MySQL商业服务支持中的一部分。它可以捕获发送给服务器的查询,要么是通过应用程序连接MySQL的库文件实现,要么是在代理层实现(我们并不太建议使用代理层)。该工具有设计良好的用户界面,可以直观地显示查询的剖析结果,并且可以根据时间段进行缩放,例如可以选择某个异常的性能尖峰时间来查看状态图。也可以查看EXPLAIN出来的执行计划,这在故障诊断时非常有用。\n3.3 剖析MySQL查询 # 对查询进行性能剖析有两种方式,每种方式都有各自的问题,本章会详细介绍。可以剖析整个数据库服务器,这样可以分析出哪些查询是主要的压力来源(如果已经在最上面的应用层做过剖析,则可能已经知道哪些查询需要特别留意)。定位到具体需要优化的查询后,也可以钻取下去对这些查询进行单独的剖析,分析哪些子任务是响应时间的主要消耗者。\n3.3.1 剖析服务器负载 # 服务器端的剖析很有价值,因为在服务器端可以有效地审计效率低下的查询。定位和优化“坏”查询能够显著地提升应用的性能,也能解决某些特定的难题。还可以降低服务器的整体压力,这样所有的查询都将因为减少了对共享资源的争用而受益(“间接的好处”)。降低服务器的负载也可以推迟或者避免升级更昂贵硬件的需求,还可以发现和定位糟糕的用户体验,比如某些极端情况。\nMySQL的每一个新版本中都增加了更多的可测量点。如果当前的趋势可靠的话,那么在性能方面比较重要的测量需求很快能够在全球范围内得到支持。但如果只是需要剖析并找出代价高的查询,就不需要如此复杂。有一个工具很早之前就能帮到我们了,这就是慢查询日志。\n捕获MySQL的查询到日志文件中 # 在MySQL中,慢查询日志最初只是捕获比较“慢”的查询,而性能剖析却需要针对所有的查询。而且在MySQL 5.0及之前的版本中,慢查询日志的响应时间的单位是秒,粒度太粗了。幸运的是,这些限制都已经成为历史了。在MySQL 5.1及更新的版本中,慢日志的功能已经被加强,可以通过设置long_query_time为0来捕获所有的查询,而且查询的响应时间单位已经可以做到微秒级。如果使用的是Percona Server,那么5.0版本就具备了这些特性,而且Percona Server提供了对日志内容和查询捕获的更多控制能力。\n在MySQL的当前版本中,慢查询日志是开销最低、精度最高的测量查询时间的工具。如果还在担心开启慢查询日志会带来额外的I/O开销,那大可以放心。我们在I/O密集型场景做过基准测试,慢查询日志带来的开销可以忽略不计(实际上在CPU密集型场景的影响还稍微大一些)。更需要担心的是日志可能消耗大量的磁盘空间。如果长期开启慢查询日志,注意要部署日志轮转(log rotation)工具。或者不要长期启用慢查询日志,只在需要收集负载样本的期间开启即可。\nMySQL还有另外一种查询日志,被称之为“通用日志”,但很少用于分析和剖析服务器性能。通用日志在查询请求到服务器时进行记录,所以不包含响应时间和执行计划等重要信息。MySQL 5.1之后支持将日志记录到数据库的表中,但多数情况下这样做没什么必要。这不但对性能有较大影响,而且MySQL 5.1在将慢查询记录到文件中时已经支持微秒级别的信息,然而将慢查询记录到表中会导致时间粒度退化为只能到秒级。而秒级别的慢查询日志没有太大的意义。\nPercona Server的慢查询日志比MySQL官方版本记录了更多细节且有价值的信息,如查询执行计划、锁、I/O活动等。这些特性都是随着处理各种不同的优化场景的需求而慢慢加进来的。另外在可管理性上也进行了增强。比如全局修改针对每个连接的long_query_time的阈值,这样当应用使用连接池或者持久连接的时候,可以不用重置会话级别的变量而启动或者停止连接的查询日志。总的来说,慢查询日志是一种轻量而且功能全面的性能剖析工具,是优化服务器查询的利器。\n有时因为某些原因如权限不足等,无法在服务器上记录查询。这样的限制我们也常常碰到,所以我们开发了两种替代的技术,都集成到了Percona Toolkit中的pt-query-digest中。第一种是通过*\u0026ndash;processlist*选项不断查看SHOW FULL PROCESSLIST的输出,记录查询第一次出现的时间和消失的时间。某些情况下这样的精度也足够发现问题,但却无法捕获所有的查询。一些执行较快的查询可能在两次执行的间隙就执行完成了,从而无法捕获到。\n第二种技术是通过抓取TCP网络包,然后根据MySQL的客户端/服务端通信协议进行解析。可以先通过tcpdump将网络包数据保存到磁盘,然后使用pt-query-digest的*\u0026ndash;type=tcpdump*选项来解析并分析查询。此方法的精度比较高,并且可以捕获所有查询。还可以解析更高级的协议特性,比如可以解析二进制协议,从而创建并执行服务端预解析的语句(prepared statement)及压缩协议。另外还有一种方法,就是通过MySQL Proxy代理层的脚本来记录所有查询,但在实践中我们很少这样做。\n分析查询日志 # 强烈建议大家从现在起就利用慢查询日志捕获服务器上的所有查询,并且进行分析。可以在一些典型的时间窗口如业务高峰期的一个小时内记录查询。如果业务趋势比较均衡,那么一分钟甚至更短的时间内捕获需要优化的低效查询也是可行的。\n不要直接打开整个慢查询日志进行分析,这样做只会浪费时间和金钱。首先应该生成一个剖析报告,如果需要,则可以再查看日志中需要特别关注的部分。自顶向下是比较好的方式,否则有可能像前面提到的,反而导致业务的逆优化。\n从慢查询日志中生成剖析报告需要有一款好工具,这里我们建议使用pt-query-digest,这毫无疑问是分析MySQL查询日志最有力的工具。该工具功能强大,包括可以将查询报告保存到数据库中,以及追踪工作负载随时间的变化。\n一般情况下,只需要将慢查询日志文件作为参数传递给pt-query-digest,就可以正确地工作了。它会将查询的剖析报告打印出来,并且能够选择将“重要”的查询逐条打印出更详细的信息。输出的报告细节详尽,绝对可以让生活更美好。该工具还在持续的开发中,因此要了解最新的功能请阅读最新版本的文档。\n这里给出一份pt-query-digest输出的报告的例子,作为进行性能剖析的开始。这是前面提到过的一个未修改过的剖析报告:\n# Profile # Rank Query ID Response time Calls R/Call V/M Item # ==== ================== ================ ===== ====== ===== ======= # 1 0xBFCF8E3F293F6466 11256.3618 68.1% 78069 0.1442 0.21 SELECT InvitesNew? # 2 0x620B8CAB2B1C76EC 2029.4730 12.3% 14415 0.1408 0.21 SELECT StatusUpdate? # 3 0xB90978440CC11CC7 1345.3445 8.1% 3520 0.3822 0.00 SHOW STATUS # 4 0xCB73D6B5B031B4CF 1341.6432 8.1% 3509 0.3823 0.00 SHOW STATUS # MISC 0xMISC 560.7556 3.4% 23930 0.0234 0.0 \u0026lt;17 ITEMS\u0026gt; 可以看到这个比之前的版本多了一些细节。首先,每个查询都有一个ID,这是对查询语句计算出的哈希值指纹,计算时去掉了查询条件中的文本值和所有空格,并且全部转化为小写字母(请注意第三条和第四条语句的摘要看起来一样,但哈希指纹是不一样的)。该工具对表名也有类似的规范做法。表名InvitesNew后面的问号意味着这是一个分片(shard)的表,表名后面的分片标识被问号替代,这样就可以将同一组分片表作为一个整体做汇总统计。这个例子实际上是来自一个压力很大的分片过的Facebook应用。\n报告中的V/M列提供了方差均值比(variance-to-mean ratio)的详细数据,方差均值比也就是常说的离差指数(index of dispersion)。离差指数高的查询对应的执行时间的变化较大,而这类查询通常都值得去优化。如果pt-query-digest指定了*\u0026ndash;explain*选项,输出结果中会增加一列简要描述查询的执行计划,执行计划是查询背后的“极客代码”。通过联合观察执行计划列和V/M列,可以更容易识别出性能低下需要优化的查询。\n最后,在尾部也增加了一行输出,显示了其他17个占比较低而不值得单独显示的查询的统计数据。可以通过*\u0026ndash;limit和\u0026ndash;outliers*选项指定工具显示更多查询的详细信息,而不是将一些不重要的查询汇总在最后一行。默认只会打印时间消耗前10位的查询,或者执行时间超过1秒阈值很多倍的查询,这两个限制都是可配置的。\n剖析报告的后面包含了每种查询的详细报告。可以通过查询的ID或者排名来匹配前面的剖析统计和查询的详细报告。下面是排名第一也就是“最差”的查询的详细报告:\n查询报告的顶部包含了一些元数据,包括查询执行的频率、平均并发度,以及该查询性能最差的一次执行在日志文件中的字节偏移值,接下来还有一个表格格式的元数据,包括诸如标准差一类的统计信息(9)。\n接下来的部分是响应时间的直方图。有趣的是,可以看到上面这个查询在Query_time distribution部分的直方图上有两个明显的高峰,大部分情况下执行都需要几百毫秒,但在快三个数量级的部分也有一个明显的尖峰,几百微秒就能执行完成。如果这是Percona Server的记录,那么在查询日志中还会有更多丰富的属性,可以对查询进行切片分析到底发生了什么。比如可能是因为查询条件传递了不同的值,而这些值的分布很不均衡,导致服务器选择了不同的索引;或者是由于查询缓存命中等。在实际系统中,这种有两个尖峰的直方图的情况很少见,尤其是对于简单的查询,查询越简单执行计划也越稳定。\n在细节报告的最后部分是方便复制、粘贴到终端去检查表的模式和状态的语句,以及完整的可用于EXPLAIN分析执行计划的语句。EXPLAIN分析的语句要求所有的条件是文本值而不是“指纹”替代符,所以是真正可直接执行的语句。在本例中是执行时间最长的一条实际的查询。\n确定需要优化的查询后,可以利用这个报告迅速地检查查询的执行情况。这个工具我们经常使用,并且会根据使用的情况不断进行修正以帮助提升工具的可用性和效率,强烈建议大家都能熟练使用它。MySQL本身在未来或许也会有更多复杂的测量点和剖析工具,但在本书写作时,通过慢查询日志记录查询或者使用pt-query-digest分析tcpdump的结果,是可以找到的最好的两种方式。\n3.3.2 剖析单条查询 # 在定位到需要优化的单条查询后,可以针对此查询“钻取”更多的信息,确认为什么会花费这么长的时间执行,以及需要如何去优化。关于如何优化查询的技术将在本书后续的一些章节讨论,在此之前还需要介绍一些相关的背景知识。本章的主要目的是介绍如何方便地测量查询执行的各部分花费了多少时间,有了这些数据才能决定采用何种优化技术。\n不幸的是,MySQL目前大多数的测量点对于剖析查询都没有什么帮助。当然这种状况正在改善,但在本书写作之际,大多数生产环境的服务器还没有使用包含最新剖析特性的版本。所以在实际应用中,除了SHOW STATUS、SHOW PROFILE、检查慢查询日志的条目(这还要求必须是Percona Server,官方MySQL版本的慢查询日志缺失了很多附加信息)这三种方法外就没有什么更好的办法了。下面将逐一演示如何使用这三种方法来剖析单条查询,看看每一种方法是如何显示查询的执行情况的。\n使用SHOW PROFILE # SHOW PROFILE命令是在MySQL 5.1以后的版本中引入的,来源于开源社区中的Jeremy Cole的贡献。这是在本书写作之际唯一一个在GA版本中包含的真正的查询剖析工具。默认是禁用的,但可以通过服务器变量在会话(连接)级别动态地修改。\n** mysql\u0026gt; SET profiling = 1;** 然后,在服务器上执行的所有语句,都会测量其耗费的时间和其他一些查询执行状态变更相关的数据。这个功能有一定的作用,而且最初的设计功能更强大,但未来版本中可能会被Performance Schema所取代。尽管如此,这个工具最有用的作用还是在语句执行期间剖析服务器的具体工作。\n当一条查询提交给服务器时,此工具会记录剖析信息到一张临时表,并且给查询赋予一个从1开始的整数标识符。下面是对Sakila样本数据库的一个视图的剖析结果(10):\nmysql\u0026gt; ** SELECT * FROM sakila.nicer_but_slower_film_list;** [query results omitted] 997 rows in set (0.17 sec) 该查询返回了997行记录,花费了大概1/6秒。下面看一下SHOW PROFILES有什么结果:\n首先可以看到的是以很高的精度显示了查询的响应时间,这很好。MySQL客户端显示的时间只有两位小数,对于一些执行得很快的查询这样的精度是不够的。下面继续看接下来的输出:\n剖析报告给出了查询执行的每个步骤及其花费的时间,看结果很难快速地确定哪个步骤花费的时间最多。因为输出是按照执行顺序排序,而不是按花费的时间排序的——而实际上我们更关心的是花费了多少时间,这样才能知道哪些开销比较大。但不幸的是无法通过诸如ORDER BY之类的命令重新排序。假如不使用SHOW PROFILE命令而是直接查询INFORMATION_SCHEMA中对应的表,则可以按照需要格式化输出:\n效果好多了!通过这个结果可以很容易看到查询时间太长主要是因为花了一大半的时间在将数据复制到临时表这一步。那么优化就要考虑如何改写查询以避免使用临时表,或者提升临时表的使用效率。第二个消耗时间最多的是“发送数据(Sending data)”,这个状态代表的原因非常多,可能是各种不同的服务器活动,包括在关联时搜索匹配的行记录等,这部分很难说能优化节省多少消耗的时间。另外也要注意到“结果排序(Sorting result)”花费的时间占比非常低,所以这部分是不值得去优化的。这是一个比较典型的问题,所以一般我们都不建议用户在“优化排序缓冲区(tuning sort buffer)”或者类似的活动上花时间。\n尽管剖析报告能帮助我们定位到哪些活动花费了最多的时间,但并不会告诉我们为什么会这样。要弄清楚为什么复制数据到临时表要花费这么多时间,就需要深入下去,继续剖析这一步的子任务。\n使用SHOW STATUS # MySQL的SHOW STATUS命令返回了一些计数器。既有服务器级别的全局计数器,也有基于某个连接的会话级别的计数器。例如其中的Queries(11)在会话开始时为0,每提交一条查询增加1。如果执行SHOW GLOBAL STATUS(注意到新加的GLOBAL关键字),则可以查看服务器级别的从服务器启动时开始计算的查询次数统计。不同计数器的可见范围不一样,不过全局的计数器也会出现在SHOW STATUS的结果中,容易被误认为是会话级别的,千万不要搞迷糊了。在使用这个命令的时候要注意几点,就像前面所讨论的,收集合适级别的测量值是很关键的。如果打算优化从某些特定连接观察到的东西,测量的却是全局级别的数据,就会导致混乱。MySQL官方手册中对所有的变量是会话级还是全局级做了详细的说明。\nSHOW STATUS是一个有用的工具,但并不是一款剖析工具(12)。SHOW STATUS的大部分结果都只是一个计数器,可以显示某些活动如读索引的频繁程度,但无法给出消耗了多少时间。SHOW STATUS的结果中只有一条指的是操作的时间(Innodb_row_lock_time),而且只能是全局级的,所以还是无法测量会话级别的工作。\n尽管SHOW STATUS无法提供基于时间的统计,但对于在执行完查询后观察某些计数器的值还是有帮助的。有时候可以猜测哪些操作代价较高或者消耗的时间较多。最有用的计数器包括句柄计数器(handler counter)、临时文件和表计数器等。在附录B中会对此做更详细的解释。下面的例子演示了如何将会话级别的计数器重置为0,然后查询前面(“使用SHOW PROFILE”一节)提到的视图,再检查计数器的结果:\n从结果可以看到该查询使用了三个临时表,其中两个是磁盘临时表,并且有很多的没有用到索引的读操作(Handler_read_rnd_next)。假设我们不知道这个视图的具体定义,仅从结果来推测,这个查询有可能是做了多表关联(join)查询,并且没有合适的索引,可能是其中一个子查询创建了临时表,然后和其他表做联合查询。而用于保存子查询结果的临时表没有索引,如此大致可以解释这样的结果。\n使用这个技术的时候,要注意SHOW STATUS本身也会创建一个临时表,而且也会通过句柄操作访问此临时表,这会影响到SHOW STATUS结果中对应的数字,而且不同的版本可能行为也不尽相同。比较前面通过SHOW PROFILES获得的查询的执行计划的结果来看,至少临时表的计数器多加了2。\n你可能会注意到通过EXPLAIN查看查询的执行计划也可以获得大部分相同的信息,但EXPLAIN是通过估计得到的结果,而通过计数器则是实际的测量结果。例如,EXPLAIN无法告诉你临时表是否是磁盘表,这和内存临时表的性能差别是很大的。附录D包含更多关于EXPLAIN的内容。\n使用慢查询日志 # 那么针对上面这样的查询语句,Percona Server对慢查询日志做了哪些改进?下面是“使用SHOW PROFILE”一节演示过的相同的查询执行后抓取到的结果:\n# Time: 110905 17:03:18 # User@Host: root[root] @ localhost [127.0.0.1] # Thread_id: 7 Schema: sakila Last_errno: 0 Killed: 0 # Query_time: 0.166872 Lock_time: 0.000552 Rows_sent: 997 Rows_examined: 24861 Rows_affected: 0 Rows_read: 997 # Bytes_sent: 216528 Tmp_tables: 3 Tmp_disk_tables: 2 Tmp_table_sizes: 11627188 # InnoDB_trx_id: 191E # QC_Hit: No Full_scan: Yes Full_join: No Tmp_table: Yes Tmp_table_on_disk: Yes # Filesort: Yes Filesort_on_disk: No Merge_passes: 0 # InnoDB_IO_r_ops: 0 InnoDB_IO_r_bytes: 0 InnoDB_IO_r_wait: 0.000000 # InnoDB_rec_lock_wait: 0.000000 InnoDB_queue_wait: 0.000000 # InnoDB_pages_distinct: 20 # PROFILE_VALUES ... Copying to tmp table: 0.090623... [omitted] SET timestamp=1315256598; SELECT * FROM sakila.nicer_but_slower_film_list; 从这里看到查询确实一共创建了三个临时表,其中两个是磁盘临时表。而SHOW PROFILE看起来则隐藏了信息(可能是由于服务器执行查询的方式有不一样的地方造成的)。这里为了方便阅读,对结果做了简化。但最后对该查询执行SHOW PROFILE的数据也会写入到日志中,所以在Percona Server 中甚至可以记录SHOW PROFILE的细节信息。\n另外也可以看到,慢查询日志中详细记录的条目包含了SHOW PROFILE和SHOW STATUS所有的输出,并且还有更多的信息。所以通过pt-query-digest发现“坏”查询后,在慢查询日志中可以获得足够有用的信息。查看pt-query-digest的报告时,其标题部分一般会有如下输出:\n# Query 1:0 QPS, 0x concurrency, ID 0xEE758C5E0D7EADEE at byte 3214_____ 可以通过这里的字节偏移值(3214)直接跳转到日志的对应部分,例如用下面这样的命令即可:\ntail -c +3214 /path/to/query.log | head -n100 这样就可以直接跳转到细节部分了。另外,pt-query-digest能够处理Percona Server在慢查询日志中增加的所有键值对,并且会自动在报告中打印更多的细节信息。\n使用Performance Schema # Using the Performance Schema\n在本书写作之际,在MySQL 5.5中新增的Performance Schema表还不支持查询级别的剖析信息。Performance Schema还是非常新的特性,并且还在快速开发中,未来的版本中将会包含更多的功能。尽管如此,MySQL 5.5的初始版本已经包含了很多有趣的信息。例如,下面的查询显示了系统中等待的主要原因:\n目前还有一些限制,使得Performance Schema还无法被当作一个通用的剖析工具。首先,它还无法提供查询执行阶段的细节信息和计时信息,而前面提供的很多现有的工具都已经能做到这些了。其次,还没有经过长时间、大规模使用的验证,并且自身的开销也还比较大,多数比较保守的用户还对此持有疑问(不过有理由相信这些问题很快都会被修复的)。\n最后,对大多数用户来说,直接通过Performance Schema的裸数据获得有用的结果相对来说过于复杂和底层。到目前为止实现的这个特性,主要是为了测量当为提升服务器性能而修改MySQL源代码时使用,包括等待和互斥锁。MySQL 5.5中的特性对于高级用户也很有价值,而不仅仅为开发者使用,但还是需要开发一些前端工具以方便用户使用和分析结果。目前就只能通过写一些复杂的语句去查询大量的元数据表的各种列。这在使用过程中需要花很多时间去熟悉和理解。\n在MySQL 5.6或者以后的版本中,Performance Schema将会包含更多的功能,再加上一些方便使用的工具,这样就更“爽”了。而且Oracle将其实现成表的形式,可以通过SQL访问,这样用户可以方便地访问有用的数据。但其目前还无法立即取代慢查询日志等其他工具用于服务器和查询的性能优化。\n3.3.3 使用性能剖析 # 当获得服务器或者查询的剖析报告后,怎么使用?好的剖析报告能够将潜在的问题显示出来,但最终的解决方案还需要用户来决定(尽管报告可能会给出建议)。优化查询时,用户需要对服务器如何执行查询有较深的了解。剖析报告能够尽可能多地收集需要的信息、给出诊断问题的正确方向,以及为其他诸如EXPLAIN等工具提供基础信息。这里只是先引出话题,后续章节将继续讨论。\n尽管一个拥有完整测量信息的剖析报告可以让事情变得简单,但现有系统通常都没有完美的测量支持。从前面的例子来说,我们虽然推断出是临时表和没有索引的读导致查询的响应时间过长,但却没有明确的证据。因为无法测量所有需要的信息,或者测量的范围不正确,有些问题就很难解决。例如,可能没有集中在需要优化的地方测量,而是测量了服务器层面的活动;或者测量的是查询开始之前的计数器,而不是查询开始后的数据。\n也有其他的可能性。设想一下正在分析慢查询日志,发现了一个很简单的查询正常情况下都非常快,却有几次非常不合理地执行了很长时间。手工重新执行一遍,发现也非常快,然后使用EXPLAIN查询其执行计划,也正确地使用了索引。然后尝试修改WHERE条件中使用不同的值,以排除缓存命中的可能,也没有发现有什么问题,这可能是什么原因呢?\n如果使用官方版本的MySQL,慢查询日志中没有执行计划或者详细的时间信息,对于偶尔记录到的这几次查询异常慢的问题,很难知道其原因在哪里,因为信息有限。可能是系统中有其他东西消耗了资源,比如正在备份,也可能是某种类型的锁或者争用阻塞了查询的进度。这种间歇性的问题将在下一节详细讨论。\n3.4 诊断间歇性问题 # 间歇性的问题比如系统偶尔停顿或者慢查询,很难诊断。有些幻影问题只在没有注意到的时候才发生,而且无法确认如何重现,诊断这样的问题往往要花费很多时间,有时候甚至需要好几个月。在这个过程中,有些人会尝试以不断试错的方式来诊断,有时候甚至会想要通过随机地改变一些服务器的设置来侥幸地找到问题。\n尽量不要使用试错的方式来解决问题。这种方式有很大的风险,因为结果可能变得更坏。这也是一种令人沮丧且低效的方式。如果一时无法定位问题,可能是测量的方式不正确,或者测量的点选择有误,或者使用的工具不合适(也可能是缺少现成的工具,我们已经开发过工具来解决各个系统不透明导致的问题,包括从操作系统到MySQL都有)。\n为了演示为什么要尽量避免试错的诊断方式,下面列举了我们认为已经解决的一些间歇性数据库性能问题的实际案例:\n应用通过curl从一个运行得很慢的外部服务来获取汇率报价的数据。 memcached缓存中的一些重要条目过期,导致大量请求落到MySQL以重新生成缓存条目。 DNS查询偶尔会有超时现象。 可能是由于互斥锁争用,或者内部删除查询缓存的算法效率太低的缘故,MySQL的查询缓存有时候会导致服务有短暂的停顿。 当并发度超过某个阈值时,InnoDB的扩展性限制导致查询计划的优化需要很长的时间。 从上面可以看到,有些问题确实是数据库的原因,也有些不是。只有在问题发生的地方通过观察资源的使用情况,并尽可能地测量出数据,才能避免在没有问题的地方耗费精力。\n下面不再多费口舌说明试错的问题,而是给出我们解决间歇性问题的方法和工具,这才是“王道”。\n3.4.1 单条查询问题还是服务器问题 # 发现问题的蛛丝马迹了吗?如果有,则首先要确认这是单条查询的问题,还是服务器的问题。这将为解决问题指出正确的方向。如果服务器上所有的程序都突然变慢,又突然都变好,每一条查询也都变慢了,那么慢查询可能就不一定是原因,而是由于其他问题导致的结果。反过来说,如果服务器整体运行没有问题,只有某条查询偶尔变慢,就需要将注意力放到这条特定的查询上面。\n服务器的问题非常常见。在过去几年,硬件的能力越来越强,配置16核或者更多CPU的服务器成了标配,MySQL在SMP架构的机器上的可扩展性限制也就越来越显露出来。尤其是较老的版本,其问题更加严重,而目前生产环境中的老版本还非常多。新版本MySQL依然也还有一些扩展性限制,但相比老版本已经没有那么严重,而且出现的频率相对小很多,只是偶尔能碰到。这是好消息,也是坏消息:好消息是很少会碰到这个问题;坏消息则是一旦碰到,则需要对MySQL内部机制更加了解才能诊断出来。当然,这也意味着很多问题可以通过升级到MySQL新版本来解决(13)。\n那么如何判断是单条查询问题还是服务器问题呢?如果问题不停地周期性出现,那么可以在某次活动中观察到;或者整夜运行脚本收集数据,第二天来分析结果。大多数情况下都可以通过三种技术来解决,下面将一一道来。\n使用SHOW GLOBAL STATUS # 这个方法实际上就是以较高的频率比如一秒执行一次SHOW GLOBAL STATUS命令捕获数据,问题出现时,则可以通过某些计数器(比如Threads_running、Threads_connected、Questions和Queries)的“尖刺”或者“凹陷”来发现。这个方法比较简单,所有人都可以使用(不需要特殊的权限),对服务器的影响也很小,所以是一个花费时间不多却能很好地了解问题的好方法。下面是示例命令及其输出:\n$ mysqladmin ext -i1 | awk ' /Queries/{q=$4-qp;qp=$4} /Threads_connected/{tc=$4} /Threads_running/{printf \u0026quot;%5d %5d %5d\\n\u0026quot;, q, tc, $4}' 2147483647 136 7 798 136 7 767 134 9 828 134 7 683 134 7 784 135 7 614 134 7 108 134 24 187 134 31 179 134 28 1179 134 7 1151 134 7 1240 135 7 1000 135 7 这个命令每秒捕获一次SHOW GLOBAL STATUS的数据,输出给awk计算并输出每秒的查询数、Threads_connected和Threads_running(表示当前正在执行查询的线程数)。这三个数据的趋势对于服务器级别偶尔停顿的敏感性很高。一般发生此类问题时,根据原因的不同和应用连接数据库方式的不同,每秒的查询数一般会下跌,而其他两个则至少有一个会出现尖刺。在这个例子中,应用使用了连接池,所以Threads_connected没有变化。但正在执行查询的线程数明显上升,同时每秒的查询数相比正常数据有严重的下跌。\n如何解析这个现象呢?凭猜测有一定的风险。但在实践中有两个原因的可能性比较大。其中之一是服务器内部碰到了某种瓶颈,导致新查询在开始执行前因为需要获取老查询正在等待的锁而造成堆积。这一类的锁一般也会对应用服务器造成后端压力,使得应用服务器也出现排队问题。另外一个常见的原因是服务区突然遇到了大量查询请求的冲击,比如前端的memcached突然失效导致的查询风暴。\n这个命令每秒输出一行数据,可以运行几个小时或者几天,然后将结果绘制成图形,这样就可以方便地发现是否有趋势的突变。如果问题确实是间歇性的,发生的频率又较低,也可以根据需要尽可能长时间地运行此命令,直到发现问题再回头来看输出结果。大多数情况下,通过输出结果都可以更明确地定位问题。\n使用SHOW PROCESSLIST # 这个方法是通过不停地捕获SHOW PROCESSLIST的输出,来观察是否有大量线程处于不正常的状态或者有其他不正常的特征。例如查询很少会长时间处于“statistics”状态,这个状态一般是指服务器在查询优化阶段如何确定表关联的顺序——通常都是非常快的。另外,也很少会见到大量线程报告当前连接用户是“未经验证的用户(Unauthenticated\nuser)”,这只是在连接握手的中间过程中的状态,当客户端等待输入用于登录的用户信息的时候才会出现。\n使用SHOW PROCESSLIST命令时,在尾部加上\\G可以垂直的方式输出结果,这很有用,因为这样会将每一行记录的每一列都单独输出为一行,这样可以方便地使用sort|uniq|sort一类的命令来计算某个列值出现的次数:\n** $ mysql -e 'SHOW PROCESSLIST\\G' | grep State: | sort | uniq -c | sort -rn** 744 State: 67 State: Sending data 36 State: freeing items 8 State: NULL 6 State: end 4 State: Updating 4 State: cleaning up 2 State: update 1 State: Sorting result 1 State: logging slow query 如果要查看不同的列,只需要修改grep的模式即可。在大多数案例中,State列都非常有用。从这个例子的输出中可以看到,有很多线程处于查询执行的结束部分的状态,包括“freeing items”、“end”、“cleaning up”和“logging slow query”。事实上,在案例中的这台服务器上,同样模式或类似的输出采样出现了很多次。大量的线程处于“freeing items”状态是出现了大量有问题查询的很明显的特征和指示。\n用这种技术查找问题,上面的命令行不是唯一的方法。如果MySQL服务器的版本较新,也可以直接查询INFORMATION_SCHEMA中的PROCESSLIST表;或者使用innotop工具以较高的频率刷新,以观察屏幕上出现的不正常查询堆积。上面演示的这个例子是由于InnoDB内部的争用和脏块刷新所导致,但有时候原因可能比这个要简单得多。一个经典的例子是很多查询处于“Locked”状态,这是MyISAM的一个典型问题,它的表级别锁定,在写请求较多时,可能迅速导致服务器级别的线程堆积。\n使用查询日志 # 如果要通过查询日志发现问题,需要开启慢查询日志并在全局级别设置long_query_time为0,并且要确认所有的连接都采用了新的设置。这可能需要重置所有连接以使新的全局设置生效;或者使用Percona Server的一个特性,可以在不断开现有连接的情况下动态地使设置强制生效。\n如果因为某些原因,不能设置慢查询日志记录所有的查询,也可以通过tcpdump和pt-query-digest工具来模拟替代。要注意找到吞吐量突然下降时间段的日志。查询是在完成阶段才写入到慢查询日志的,所以堆积会造成大量查询处于完成阶段,直到阻塞其他查询的资源占用者释放资源后,其他的查询才能执行完成。这种行为特征的一个好处是,当遇到吞吐量突然下降时,可以归咎于吞吐量下降后完成的第一个查询(有时候也不一定是第一个查询。当某些查询被阻塞时,其他查询可以不受影响继续运行,所以不能完全依赖这个经验)。\n再重申一次,好的工具可以帮助诊断这类问题,否则要人工去几百GB的查询日志中找原因。下面的例子只有一行代码,却可以根据MySQL每秒将当前时间写入日志中的模式统计每秒的查询数量:\n** $ awk '/^# Time:/{print$3,$4,c;c=0}/^# User/{c++}' slow-query.log** 080913 21:52:17 51 080913 21:52:18 29 080913 21:52:19 34 080913 21:52:20 33 080913 21:52:21 38 080913 21:52:22 15 080913 21:52:23 47 080913 21:52:24 96 080913 21:52:25 6 080913 21:52:26 66 080913 21:52:27 37 080913 21:52:28 59 从上面的输出可以看到有吞吐量突然下降的情况发生,而且在下降之前还有一个突然的高峰,仅从这个输出而不去查询当时的详细信息很难确定发生了什么,但应该可以说这个突然的高峰和随后的下降一定有关联。不管怎么说,这种现象都很奇怪,值得去日志中挖掘该时间段的详细信息(实际上通过日志的详细信息,可以发现突然的高峰时段有很多连接被断开的现象,可能是有一台应用服务器重启导致的。所以不是所有的问题都是MySQL的问题)。\n理解发现的问题(Making sense of the findings) # 可视化的数据最具有说服力。上面只演示了很少的几个例子,但在实际情况中,利用上面的工具诊断时可能产生大量的输出结果。可以选择用gnuplot或R,或者其他绘图工具将结果绘制成图形。这些绘图工具速度很快,比电子表格要快得多,而且可以对图上的一些异常的地方进行缩放,这比在终端中通过滚动条翻看文字要好用得多,除非你是“黑客帝国”中的矩阵观察者(14)。\n我们建议诊断问题时先使用前两种方法:SHOW STATUS和SHOW PROCESSLIST。这两种方法的开销很低,而且可以通过简单的shell脚本或者反复执行的查询来交互式地收集数据。分析慢查询日志则相对要困难一些,经常会发现一些蛛丝马迹,但仔细去研究时可能又消失了。这样我们很容易会认为其实没有问题。\n发现输出的图形异常意味着什么?通常来说可能是查询在某个地方排队了,或者某种查询的量突然飙升了。接下来的任务就是找出这些原因。\n3.4.2 捕获诊断数据 # Capturing Diagnostic Data\n当出现间歇性问题时,需要尽可能多地收集所有数据,而不只是问题出现时的数据。虽然这样会收集大量的诊断数据,但总比真正能够诊断问题的数据没有被收集到的情况要好。\n在开始之前,需要搞清楚两件事:\n一个可靠且实时的“触发器”,也就是能区分什么时候问题出现的方法。 一个收集诊断数据的工具。 诊断触发器 # 触发器非常重要。这是在问题出现时能够捕获数据的基础。有两个常见的问题可能导致无法达到预期的结果:误报(false positive)或者漏检(false negative)。误报是指收集了很多诊断数据,但期间其实没有发生问题,这可能浪费时间,而且令人沮丧。而漏检则指在问题出现时没有捕获到数据,错失了机会,一样地浪费时间。所以在开始收集数据前多花一点时间来确认触发器能够真正地识别问题是划算的。\n那么好的触发器的标准是什么呢?像前面的例子展示的,Threads_running的趋势在出现问题时会比较敏感,而没有问题时则比较平稳。另外SHOW PROCESSLIST中线程的异常状态尖峰也是个不错的指标。当然除此之外还有很多的方法,包括SHOW INNODB STATUS的特定输出、服务器的平均负载尖峰等。关键是找到一些能和正常时的阈值进行比较的指标。通常情况下这是一个计数,比如正在运行的线程的数量、处于“freeing items”状态的线程的数量等。当要计算线程某个状态的数量时,grep的*-c*选项非常有用:\n** $ mysql -e 'SHOW PROCESSLIST\\G' | grep -c \u0026quot;State: freeing items\u0026quot;** 36 选择一个合适的阈值很重要,既要足够高,以确保在正常时不会被触发;又不能太高,要确保问题发生时不会错过。另外要注意,要在问题开始时就捕获数据,就更不能将阈值设置得太高。问题持续上升的趋势一般会导致更多的问题发生,如果在问题导致系统快要崩溃时才开始捕获数据,就很难诊断到最初的根本原因。如果可能,在问题还是涓涓细流的时候就要开始收集数据,而不要等到波涛汹涌才开始。举个例子,Threads_connected偶尔出现非常高的尖峰值,在几分钟时间内会从100冲到5000或者更高,所以设置阈值为4999也可以捕获到问题,但为什么非要等到这么高的时候才收集数据呢?如果在正常时该值一般不超过150,将阈值设置为200或者300会更好。\n回到前面关于Threads_running的例子,正常情况下的并发度不超过10。但是阈值设置为10并不是一个好注意,很可能会导致很多误报。即使设置为15也不够,可能还是会有很多正常的波动会到这个范围。当并发运行线程到15的时候可能也会有少量堆积的情况,但可能还没到问题的引爆点。但也应该在糟糕到一眼就能看出问题前就清晰地识别出来,对于这个例子,我们建议阀值可以设置为20。\n我们当然希望在问题确实发生时能捕获到数据,但有时候也需要稍微等待一下以确保不是误报或者短暂的尖峰。所以,最后的触发条件可以这样设置:每秒监控状态值,如果Threads_running连续5秒超过20,就开始收集诊断数据(顺便说一句,我们的例子中问题只持续了3秒就消失了,这是为了使例子简单而设置的。3秒的故障不容易诊断,而我们碰到过的大部分问题持续时间都会更长一些)。\n所以我们需要利用一种工具来监控服务器,当达到触发条件时能收集数据。当然可以自己编写脚本来实现,不过不用那么麻烦,Percona Toolkit中的pt-stalk就是为这种情况设计的。这个工具有很多有用的特性,只要碰到过类似问题就会明白这些特性的必要性。例如,它会监控磁盘的可用空间,所以不会因为收集太多的数据将空间耗尽而导致服务器崩溃。如果之前碰到过这样的情况,你就会理解这一点了。\npt-stalk的用法很简单。可以配置需要监控的变量、阈值、检查的频率等。还支持一些比实际需要更多的花哨特性,但在这个例子中有这些已经足够了。在使用之前建议先阅读附带的文档。pt-stalk还依赖于另外一个工具执行真正的收集工作,接下来会讨论。\n需要收集什么样的数据 # 现在已经确定了诊断触发器,可以开始启动一些进程来收集数据了。但需要收集什么样的数据呢?就像前面说的,答案是尽可能收集所有能收集的数据,但只在需要的时间段内收集。包括系统的状态、CPU利用率、磁盘使用率和可用空间、ps的输出采样、内存利用率,以及可以从MySQL获得的信息,如SHOW STATUS、SHOW PROCESSLIST和SHOW INNODB STATUS。这些在诊断问题时都需要用到(可能还会有更多)。\n执行时间包括用于工作的时间和等待的时间。当一个未知问题发生时,一般来说有两种可能:服务器需要做大量的工作,从而导致大量消耗CPU;或者在等待某些资源被释放。所以需要用不同的方法收集诊断数据,来确认是何种原因:剖析报告用于确认是否有太多工作,而等待分析则用于确认是否存在大量等待。如果是未知的问题,怎么知道将精力集中在哪个方面呢?没有更好的办法,所以只能两种数据都尽量收集。\n在GNU/Linux平台,可用于服务器内部诊断的一个重要工具是oprofile。后面会展示一些例子。也可以使用strace剖析服务器的系统调用,但在生产环境中使用它有一定的风险。后面还会继续讨论它。如果要剖析查询,可以使用tcpdump。大多数MySQL版本无法方便地打开和关闭慢查询日志,此时可以通过监听TCP流量来模拟。另外,网络流量在其他一些分析中也非常有用。\n对于等待分析,常用的方法是GDB的堆栈跟踪(15)。MySQL内的线程如果卡在一个特定的地方很长时间,往往都有相同的堆栈跟踪信息。跟踪的过程是先启动gdb,然后附加(attach)到mysqld进程,将所有线程的堆栈都转储出来。然后可以利用一些简短的脚本将类似的堆栈跟踪信息做汇总,再利用sort|uniq|sort的“魔法”排序出总计最多的堆栈信息。稍后将演示如何用pt-pmp工具来完成这个工作。\n也可以使用SHOW PROCESSLIST和SHOW INNODB STATUS的快照信息观察线程和事务的状态来进行等待分析。这些方法都不完美,但实践证明还是非常有帮助的。\n收集所有的数据听起来工作量很大。或许读者之前已经做过类似的事情,但我们提供的工具可以提供一些帮助。这个工具名为pt-collect,也是Percona Toolkit中的一员。pt-collect一般通过pt-stalk来调用。因为涉及很多重要数据的收集,所以需要用root权限来运行。默认情况下,启动后会收集30秒的数据,然后退出。对于大多数问题的诊断来说,这已经足够,但如果有误报(false positive)的问题出现,则可能收集的信息就不够。这个工具很容易下载到,并且不需要任何配置,配置都是通过pt-stalk进行的。系统中最好安装gdb和oprofile,然后在pt-stalk中配置使用。另外mysqld也需要有调试符号信息(16)。当触发条件满足时,pt-collect会很好地收集完整的数据。它也会在目录中创建时间戳文件。在本书写作之际,这个工具是基于GNU/Linux的,后续会迁移到其他操作系统,这是一个好的开始。\n解释结果数据 # 如果已经正确地设置好触发条件,并且长时间运行pt-stalk,则只需要等待足够长的时间来捕获几次问题,就能够得到大量的数据来进行筛选。从哪里开始最好呢?我们建议先根据两个目的来查看一些东西。第一,检查问题是否真的发生了,因为有很多的样本数据需要检查,如果是误报就会白白浪费大量的时间。第二,是否有非常明显的跳跃性变化。\n在服务器正常运行时捕获一些样本数据也很重要,而不只是在有问题时捕获数据。这样可以帮助对比确认是否某些样本,或者样本中的某部分数据有异常。例如,在查看进程列表(process list)中查询的状态时,可以回答一些诸如“大量查询处于正在排序结果的状态是不是正常的”的问题。\n查看异常的查询或事务的行为,以及异常的服务器内部行为通常都是最有收获的。查询或事务的行为可以显示是否是由于使用服务器的方式导致的问题:性能低下的SQL查询、使用不当的索引、设计糟糕的数据库逻辑架构等。通过抓取TCP流量或者SHOW PROCESSLIST输出,可以获得查询和事务出现的地方,从而知道用户对数据库进行了什么操作。通过服务器的内部行为则可以清楚服务器是否有bug,或者内部的性能和扩展性是否有问题。这些信息在类似的地方都可以看到,包括在oprofile或者gdb的输出中,但要理解则需要更多的经验。\n如果遇到无法解释的错误,则最好将收集到的所有数据打包,提交给技术支持人员进行分析。MySQL的技术支持专家应该能够从数据中分析出原因,详细的数据对于支持人员来说非常重要。另外也可以将Percona Toolkit中另外两款工具pt-mysql-summary和pt-summary的输出结果打包,这两个工具会输出MySQL的状态和配置信息,以及操作系统和硬件的信息。\nPercona Toolkit还提供了一款快速检查收集到的样本数据的工具:pt-sift。这个工具会轮流导航到所有的样本数据,得到每个样本的汇总信息。如果需要,也可以钻取到详细信息。使用此工具至少可以少打很多字,少敲很多次键盘。\n前面我们演示了状态计数器和线程状态的例子。在本章结束之前,将再给出一些oprofile和gdb的输出例子。下面是一个问题服务器上的oprofile输出,你能找到问题吗?\n** samples % image name app name symbol name** 893793 31.1273 /no-vmlinux /no-vmlinux (no symbols) 325733 11.3440 mysqld mysqld Query_cache::free_memory_block() 117732 4.1001 libc libc (no symbols) 102349 3.5644 mysqld mysqld my_hash_sort_bin 76977 2.6808 mysqld mysqld MYSQLparse() 71599 2.4935 libpthread libpthread pthread_mutex_trylock 52203 1.8180 mysqld mysqld read_view_open_now 46516 1.6200 mysqld mysqld Query_cache::invalidate_query_block_list() 42153 1.4680 mysqld mysqld Query_cache::write_result_data() 37359 1.3011 mysqld mysqld MYSQLlex() 35917 1.2508 libpthread libpthread __pthread_mutex_unlock_usercnt 34248 1.1927 mysqld mysqld __intel_new_memcpy 如果你的答案是“查询缓存”,那么恭喜你答对了。在这里查询缓存导致了大量的工作,并拖慢了整个服务器。这个问题是一夜之间突然发生的,系统变慢了50倍,但这期间系统没有做过任何其他变更。关闭查询缓存后系统性能恢复了正常。这个例子比较简单地解释了服务器内部行为对性能的影响。\n另外一个重要的关于等待分析的性能瓶颈分析工具是gdb的堆栈跟踪。下面是对一个线程的堆栈跟踪的输出结果,为了便于印刷做了一些格式化:\nThread 992 (Thread 0x7f6ee0111910 (LWP 31510)): #0 0x0000003be560b2f9 in pthread_cond_wait@@GLIBC_2.3.2 () from /libpthread.so.0 #1 0x00007f6ee14f0965 in os_event_wait_low () at os/os0sync.c:396 #2 0x00007f6ee1531507 in srv_conc_enter_innodb () at srv/srv0srv.c:1185 #3 0x00007f6ee14c906a in innodb_srv_conc_enter_innodb () at handler/ha_innodb.cc:609 #4 ha_innodb::index_read () at handler/ha_innodb.cc:5057 #5 0x00000000006538c5 in ?? () #6 0x0000000000658029 in sub_select() () #7 0x0000000000658e25 in ?? () #8 0x00000000006677c0 in JOIN::exec() () #9 0x000000000066944a in mysql_select() () #10 0x0000000000669ea4 in handle_select() () #11 0x00000000005ff89a in ?? () #12 0x0000000000601c5e in mysql_execute_command() () #13 0x000000000060701c in mysql_parse() () #14 0x000000000060829a in dispatch_command() () #15 0x0000000000608b8a in do_command(THD*) () #16 0x00000000005fbd1d in handle_one_connection () #17 0x0000003be560686a in start_thread () from /lib64/libpthread.so.0 #18 0x0000003be4ede3bd in clone () from /lib64/libc.so.6 #19 0x0000000000000000 in ?? () 堆栈需要自下而上来看。也就是说,线程当前正在执行的是pthread_cond_wait函数,这是由os_event_wait_low调用的。继续往下,看起来是线程试图进入到InnoDB内核(srv_conc_enter_innodb),但被放入了一个内部队列中(os_event_wait_low),原因应该是内核中的线程数已经超过innodb_thread_concurrency的限制。当然,要真正地发挥堆栈跟踪的价值需要将很多的信息聚合在一起来看。这种技术是由Domas Mituzas推广的,他以前是MySQL的支持工程师,开发了著名的穷人剖析器“poor man\u0026rsquo;s profiler”。他目前在Facebook工作,和其他人一起开发了更多的收集和分析堆栈跟踪的工具。可以从他的这个网站发现更多的信息: http://www.poormansprofiler.org。\n在Percona Toolkit中我们也开发了一个类似的穷人剖析器,叫做pt-pmp。这是一个用shell和awk脚本编写的工具,可以将类似的堆栈跟踪输出合并到一起,然后通过sort|uniq|sort将最常见的条目在最前面输出。下面是一个堆栈跟踪的完整例子,通过此工具将重要的信息展示了出来。使用了-l5选项指定了堆栈跟踪不超过5层,以免因太多前面部分相同而后面部分不同的跟踪信息而导致无法聚合到一起的情况,这样才能更好地显示到底在哪里产生了等待:\n** $ pt-pmp -l 5 stacktraces.txt** 507 pthread_cond_wait,one_thread_per_connection_end,handle_one_connection, start_thread,clone 398 pthread_cond_wait,os_event_wait_low,srv_conc_enter_innodb, innodb_srv_conc_enter_innodb,ha_innodb::index_read 83 pthread_cond_wait,os_event_wait_low,sync_array_wait_event,mutex_spin_wait, mutex_enter_func 10 pthread_cond_wait,os_event_wait_low,os_aio_simulated_handle,fil_aio_wait, io_handler_thread 7 pthread_cond_wait,os_event_wait_low,srv_conc_enter_innodb, innodb_srv_conc_enter_innodb,ha_innodb::general_fetch 5 pthread_cond_wait,os_event_wait_low,sync_array_wait_event,rw_lock_s_lock_spin, rw_lock_s_lock_func 1 sigwait,signal_hand,start_thread,clone,?? 1 select,os_thread_sleep,srv_lock_timeout_and_monitor_thread,start_thread,clone 1 select,os_thread_sleep,srv_error_monitor_thread,start_thread,clone 1 select,handle_connections_sockets,main 1 read,vio_read_buff,::??,my_net_read,cli_safe_read 1 pthread_cond_wait,os_event_wait_low,sync_array_wait_event,rw_lock_x_lock_low, rw_lock_x_lock_func 1 pthread_cond_wait,MYSQL_BIN_LOG::wait_for_update,mysql_binlog_send, dispatch_command,do_command 1 fsync,os_file_fsync,os_file_flush,fil_flush,log_write_up_to 第一行是MySQL中非常典型的空闲线程的一种特征,所以可以忽略。第二行才是最有意思的地方,看起来大量的线程正在准备进入到InnoDB内核中,但都被阻塞了。从第三行则可以看到许多线程都在等待某些互斥锁,但具体的是什么锁不清楚,因为堆栈跟踪更深的层次被截断了。如果需要确切地知道是什么互斥锁,则需要使用更大的-l选项重跑一次。一般来说,这个堆栈跟踪显示很多线程都在等待进入到InnoDB,这是为什么呢?这个工具并不清楚,需要从其他的地方来入手。\n从前面的堆栈跟踪和oprofile报表来看,如果不是MySQL和InnoDB源码方面的专家,这种类型的分析很难进行。如果用户在进行此类分析时碰到问题,通常需要求助于这样的专家才行。\n在下面的例子中,通过剖析和等待分析都无法发现服务器的问题,需要使用另外一种不同的诊断技术。\n3.4.3 一个诊断案例 # 在本节中,我们将逐步演示一个客户实际碰到的间歇性性能问题的诊断过程。这个案例的诊断需要具备MySQL、InnoDB和GNU/Linux的相关知识。但这不是我们要讨论的重点。要尝试从疯狂中找到条理:阅读本节并保持对之前的假设和猜测的关注,保持对之前基于合理性和基于可度量的方式的关注,等等。我们在这里深入研究一个具体和详细的案例,为的是找到一个简单的一般性的方法。\n在尝试解决其他人提出的问题之前,先要明确两件事情,并且最好能够记录下来,以免遗漏或者遗忘:\n首先,问题是什么?一定要清晰地描述出来,费力去解决一个错误的问题是常有的事。在这个案例中,用户抱怨说每隔一两天,服务器就会拒绝连接,报max_connections错误。这种情况一般会持续几秒到几分钟,发生的时间非常随机。 其次,为解决问题已经做过什么操作?在这个案例中,用户没有为这个问题做过任何操作。这个信息非常有帮助,因为很少有其他事情会像另外一个人来描述一件事情发生的确切顺序和曾做过的改变及其后果一样难以理解(尤其是他们还是在经过几个不眠之夜后满嘴咖啡味道地在电话里绝望呐喊的时候)。如果一台服务器遭受过未知的变更,产生了未知的结果,问题就更难解决了,尤其是时间又非常有限的时候。 搞清楚这两个问题后,就可以开始了。不仅需要去了解服务器的行为,也需要花点时间去梳理一下服务器的状态、参数配置,以及软硬件环境。使用pt-summary和pt-mysql-summary工具可以获得这些信息。简单地说,这个例子中的服务器有16个CPU核心,12GB内存,数据量有900MB,且全部采用InnoDB引擎,存储在一块SSD固态硬盘上。服务器的操作系统是GNU/Linux、MySQL版本5.1.37,使用的存储引擎版本是InnoDB plugin 1.0.4。之前我们已经为这个客户解决过一些异常问题,所以对其系统已经比较了解。过去数据库从来没有出过问题,大多数问题都是由于应用程序的不良行为导致的。初步检查了服务器也没有发现明显的问题。查询有一些优化的空间,但大多数情况下响应时间都不到10毫秒。所以我们认为正常情况下数据库服务器运行良好(这一点比较重要,因为很多问题一开始只是零星地出现,慢慢地累积成大问题。比如RAID阵列中坏了一块硬盘这种情况)。\n这个案例研究可能有点乏味。这里我们不厌其烦地展示所有的诊断数据,解释所有的细节,对几个不同的可能性深入进去追查原因。在实际工作中,其实不会对每个问题都采用这样缓慢而冗长的方式,也不推荐大家这样做。这里只是为了更好地演示案例而已。\n我们安装好诊断工具,在Threads_connected上设置触发条件,正常情况下Threads_connected的值一般都少于15,但在发生问题时该值可能飙升到几百。下面我们会先给出一个样本数据的收集结果,后续再来评论。首先试试看,你能否从大量的输出中找出问题的重点在哪里:\n查询活动从1000到10000的QPS,其中有很多是“垃圾”命令,比如ping一下服务器确认其是否存活。其余的大部分是SELECT命令,大约每秒300~2000次,只有很少的UPDATE命令(大约每秒五次)。\n在SHOW PROCESSLIST中主要有两种类型的查询,只是在WHERE条件中的值不一样。下面是查询状态的汇总数据:\n** $ grep State: processlist.txt | sort | uniq -c | sort -rn** 161 State: Copying to tmp table 156 State: Sorting result 136 State: statistics 50 State: Sending data 24 State: NULL 13 State: 7 State: freeing items 7 State: cleaning up 1 State: storing result in query cache 1 State: end 大部分查询都是索引扫描或者范围扫描,很少有全表扫描或者表关联的情况。\n每秒大约有20~100次排序,需要排序的行大约有1000到12000行。\n每秒大约创建12~90个临时表,其中有3~5个是磁盘临时表。\n没有表锁或者查询缓存的问题。\n在SHOW INNODB STATUS中可以观察到主要的线程状态是“flushing buffer pool pages”,但只有很少的脏页需要刷新(Innodb_buffer_pool_pages_dirty),Innodb_buffer_pool_pages_flushed也没有太大的变化,日志顺序号(log sequence number)和最后检查点(last checkpoint)之间的差距也很少。InnoDB缓存池也还远没有用满;缓存池比数据集还要大很多。大多数线程在等待InnoDB队列:“12 queries inside InnoDB,495 queries in queue”(12个查询在InnoDB内部执行,495个查询在队列中)。\n每秒捕获一次iostat输出,持续30秒。从输出可以发现没有磁盘读,而写操作则接近了“天花板”,所以I/O平均等待时间和队列长度都非常高。下面是部分输出结果,为便于打印输出,这里截取了部分字段:\nr/s w/s rsec/s wsec/s avgqu-sz await svctm %util 1.00 500.00 8.00 86216.00 5.05 11.95 0.59 29.40 0.00 451.00 0.00 206248.00 123.25 238.00 1.90 85.90 0.00 565.00 0.00 269792.00 143.80 245.43 1.77 100.00 0.00 649.00 0.00 309248.00 143.01 231.30 1.54 100.10 0.00 589.00 0.00 281784.00 142.58 232.15 1.70 100.00 0.00 384.00 0.00 162008.00 71.80 238.39 1.73 66.60 0.00 14.00 0.00 400.00 0.01 0.93 0.36 0.50 0.00 13.00 0.00 248.00 0.01 0.92 0.23 0.30 0.00 13.00 0.00 408.00 0.01 0.92 0.23 0.30 vmstat的输出也验证了iostat的结果,并且CPU的大部分时间是空闲的,只是偶尔在写尖峰时有一些I/O等待时间(最高约占9%的CPU)。\n是不是感觉脑袋里塞满了东西?当你深入一个系统的细节并且没有任何先入为主(或者故意忽略了)的观念时,很容易碰到这种情况,最终只能检查所有可能的情况。很多被检查的地方最终要么是完全正常的,要么发现是问题导致的结果而不是问题产生的原因。尽管此时我们会有很多关于问题原因的猜测,但还是需要继续检查下面给出的oprofile报表,并且在给出更多数据的时候添加一些评论和解释:\nsamples % image name app name symbol name 473653 63.5323 no-vmlinux no-vmlinux /no-vmlinux 95164 12.7646 mysqld mysqld /usr /libexec/mysqld 53107 7.1234 libc-2.10.1.so libc-2.10.1.so memcpy 13698 1.8373 ha_innodb.so ha_innodb.so build_template() 13059 1.7516 ha_innodb.so ha_innodb.so btr_search_guess_on_hash 11724 1.5726 ha_innodb.so ha_innodb.so row_sel_store_mysql_rec 8872 1.1900 ha_innodb.so ha_innodb.so rec_init_offsets_comp_ordinary 7577 1.0163 ha_innodb.so ha_innodb.so row_search_for_mysql 6030 0.8088 ha_innodb.so ha_innodb.so rec_get_offsets_func 5268 0.7066 ha_innodb.so ha_innodb.so cmp_dtuple_rec_with_match 这里大多数符号(symbol)代表的意义并不是那么明显,而大部分的时间都消耗在内核符号(no-vmlinux)(17)和一个通用的mysqld符号中,这两个符号无法告诉我们更多的细节(18)。不要被多个ha_innodb.so符号分散了注意力,看一下它们占用的百分比就知道了,不管它们在做什么,其占用的时间都很少,所以应该不会是问题所在。这个例子说明,仅仅从剖析报表出发是无法得到解决问题的结果的。我们追踪的数据是错误的。如果遇到上述例子这样的情况,需要继续检查其他的数据,寻找问题根源更明显的证据。\n到这里,如果希望从gdb的堆栈跟踪进行等待分析,请参考3.4.2节的最后部分内容。那个案例就是我们当前正在诊断的这个问题。回想一下,当时的堆栈跟踪分析的结果是正在等待进入到InnoDB内核,所以SHOW INNODB STATUS的输出结果中有“12 queries inside InnoDB,495 queries in queue”。\n从上面的分析发现问题的关键点了吗?没有。我们看到了许多不同问题可能的症状,根据经验和直觉可以推测至少有两个可能的原因。但也有一些没有意义的地方。如果再次检查一下iostat的输出,可以发现wsec/s列显示了至少在6秒内,服务器每秒写入了几百MB的数据到磁盘。每个磁盘扇区是512B,所以这里采样的结果显示每秒最多写入了150MB数据。然而整个数据库也只有900MB大小,系统的压力又主要是SELECT查询。怎么会出现这样的情况呢?\n对一个系统进行检查的时候,应该先问一下自己,是否也碰到过上面这种明显不合理的问题,如果有就需要深入调查。应该尽量跟进每一个可能的问题直到发现结果,而不要被离题太多的各种情况分散了注意力,以致最后都忘记了最初要调查的问题。可以把问题写在小纸条上,检查一个划掉一个,最后再确认一遍所有的问题都已经完成调查(19)。\n在这一点上,我们可以直接得到一个结论,但却可能是错误的。可以看到主线程的状态是InnoDB正在刷新脏页。在状态输出中出现这样的情况,一般都意味着刷新已经延迟了。我们知道这个版本的InnoDB存在“疯狂刷新”的问题(或者也被称为检查点停顿)。发生这样的情况是因为InnoDB没有按时间均匀分布刷新请求,而是隔一段时间突然请求一次强制检查点导致大量刷新操作。这种机制可能会导致InnoDB内部发生严重的阻塞,导致所有的操作需要排队等待进入内核,从而引发InnoDB上一层的服务器产生堆积。在第2章中演示的例子就是一个因为“疯狂刷新”而导致性能周期性下跌的问题。很多类似的问题都是由于强制检查点导致的,但在这个案例中却不是这个问题。有很多方法可以证明,最简单的方法是查看SHOW STATUS的计数器,追踪一下Innodb_buffer_pool_pages_flushed的变化,之前已经提到了,这个值并没有怎么增加。另外,注意到InnoDB缓冲池中也没有大量的脏页需要刷新,肯定不到几百MB。这并不值得惊讶,因为这个服务器的工作压力几乎都是SELECT查询。所以可以得到一个初步的结论,我们要关注的不是InnoDB刷新的问题,而应该是刷新延迟的问题,但这只是一个现象,而不是原因。根本的原因是磁盘的I/O已经饱和,InnoDB无法完成其I/O操作。至此我们消除了一个可能的原因,可以从基于直觉的原因列表中将其划掉了。\n从结果中将原因区别出来有时候会很困难。当一个问题看起来很眼熟的时候,也可以跳过调查阶段直接诊断。当然最好不要走这样的捷径,但有时候依靠直觉也非常重要。如果有什么地方看起来很眼熟,明智的做法还是需要花一点时间去测量一下其充分必要条件,以证明其是否就是问题所在。这样可以节省大量时间,避免查看大量其他的系统和性能数据。不过也不要过于相信直觉而直接下结论,不要说“我之前见过这样的问题,肯定就是同样的问题”。而是应该去收集相关的证据,尤其是能证明直觉的证据。\n下一步是尝试找出是什么导致了服务器的I/O利用率异常的高。首先应该注意到前面已经提到过的“服务器有连续几秒内每秒写入了几百MB数据到磁盘,而数据库一共只有900MB大小,怎么会发生这样的情况?”,注意到这里已经隐式地假设是数据库导致了磁盘写入。那么有什么证据表明是数据库导致的呢?当你有未经证实的想法,或者觉得不可思议时,如果可能的话应该去进行测量,然后排除掉一些怀疑。\n我们看到了两种可能性:要么是数据库导致了I/O(如果能找到源头的话,那么可能就找到了问题的原因);要么不是数据库导致了所有的I/O而是其他什么导致的,而系统因为缺少I/O资源影响了数据库性能。我们也很小心地尽力避免引入另外一个隐式的假设:磁盘很忙并不一定意味着MySQL会有问题。要记住,这个服务器主要的压力是内存读取,所以也很可能出现磁盘长时间无法响应但没有造成严重问题的现象。\n如果你一直跟随我们的推理逻辑,就可以发现还需要回头检查一下另外一个假设。我们已经知道磁盘设备很忙,因为其等待时间很高。对于固态硬盘来说,其I/O平均等待时间一般不会超过1/4秒。实际上,从iostat的输出结果也可以发现磁盘本身的响应还是很快的,但请求在块设备队列中等待很长的时间才能进入到磁盘设备。但要记住,这只是iostat的输出结果,也可能是错误的信息。\n究竟是什么导致了性能低下?\n当一个资源变得效率低下时,应该了解一下为什么会这样。有如下可能的原因:\n资源被过度使用,余量已经不足以正常工作。 资源没有被正确配置。 资源已经损坏或者失灵。 回到上面的例子中,iostat的输出显示可能是磁盘的工作负载太大,也可能是配置不正确(在磁盘响应很快的情况下,为什么I/O请求需要排队这么长时间才能进入到磁盘?)。然而,比较系统的需求和现有容量对于确定问题在哪里是很重要的一部分。大量的基准测试证明这个客户使用的这种SSD是无法支撑几百MB/s的写操作的。所以,尽管iostat的结果表明磁盘的响应是正常的,也不一定是完全正确的。在这个案例中,我们没有办法证明磁盘的响应比iostat的结果中所说的要慢,但这种情况还是有可能的。所以这不能改变我们的看法:可能是磁盘被滥用(20),或者是错误的配置,或者两者兼而有之,是性能低下的罪魁祸首。\n在检查过所有诊断数据之后,接下来的任务就很明显了:测量出什么导致了I/O消耗。不幸的是,客户当前使用的GNU/Linux版本对此的支持不力。通过一些工作我们可以做一些相对准确的猜测,但首先还是需要探索一下其他的可能性。我们可以测量有多少I/O来自MySQL,但客户使用的MySQL版本较低以致缺乏一些诊断功能,所以也无法提供确切有利的支持。\n作为替代,基于我们已经知道MySQL如何使用磁盘,我们来观察MySQL的I/O情况。通常来说,MySQL只会写数据、日志、排序文件和临时表到磁盘。从前面的状态计数器和其他信息来看,首先可以排除数据和日志的写入问题。那么,只能假设MySQL突然写入大量数据到临时表或者排序文件,如何来观察这种情况呢?有两个简单的方法:一是观察磁盘的可用空间,二是通过lsof命令观察服务器打开的文件句柄。这两个方法我们都采用了,结果也足以满足我们的需求。下面是问题期间每秒运行df–h的结果:\nFilesystem Size Used Avail Use% Mounted on /dev/sda3 58G 20G 36G 36% / /dev/sda3 58G 20G 36G 36% / /dev/sda3 58G 19G 36G 35% / /dev/sda3 58G 19G 36G 35% / /dev/sda3 58G 19G 36G 35% / /dev/sda3 58G 19G 36G 35% / /dev/sda3 58G 18G 37G 33% / /dev/sda3 58G 18G 37G 33% / /dev/sda3 58G 18G 37G 33% / 下面则是lsof的数据,因为某些原因我们每五秒才收集一次。我们简单地将mysqld在*/tmp*中打开的文件大小做了加总,并且把总大小和采样时的时间戳一起输出到结果文件中:\n$ awk ' /mysqld.*tmp/ { total += $7; } /^Sun Mar 28/ \u0026amp;\u0026amp; total { printf \u0026quot;%s %7.2f MB\\n\u0026quot;, $4, total/1024/1024; total = 0; }' lsof.txt 18:34:38 1655.21 MB 18:34:43 1.88 MB 18:34:48 1.88 MB 18:34:53 1.88 MB 18:34:58 1.88 MB 从这个数据可以看出,在问题之初MySQL大约写了1.5GB的数据到临时表,这和之前在SHOW PROCESSLIST中有大量的“Copying to tmp table”相吻合。这个证据表明可能是某些效率低下的查询风暴耗尽了磁盘资源。根据我们的工作直觉,出现这种情况比较普遍的一个原因是缓存失效。当memcached中所有缓存的条目同时失效,而又有很多应用需要同时访问的时候,就会出现这种情况。我们给开发人员出示了部分采样到的查询,并讨论这些查询的作用。实际情况是,缓存同时失效就是罪魁祸首(这验证了我们的直觉)。一方面开发人员在应用层面解决缓存失效的问题;另一方面我们也修改了查询,避免使用磁盘临时表。这两个方法的任何一个都可以解决问题,当然最好是两个都实施。\n如果读者一直顺着我们前面的思路读下来,可能还会有一些疑问。在这里我们可以稍微解释一下(我们在本章引用的方法在审阅的时候已经检查过一遍):\n为什么我们不一开始就优化慢查询?\n因为问题不在于慢查询,而是“太多连接”的错误。当然,因为慢查询,太多查询的时间过长而导致连接堆积在逻辑上也是成立的。但也有可能是其他原因导致连接过多。如果没有找到问题的真正原因,那么回头查看慢查询或其他可能的原因,看是否能够改善是很自然的事情(21)。但这样做大多时候会让问题变得更糟。如果你把一辆车开到机械师那里抱怨说有异响,假如机械师没有指出异响的原因,也不去检查其他的地方,而是直接做了四轮平衡和更换变速箱油,然后把账单扔给你,你也会觉得不爽的吧?\n但是查询由于糟糕的执行计划而执行缓慢不是一种警告吗?\n在事故中确实如此。但慢查询到底是原因还是结果?在深入调查前是无法知晓的。记住,在正常的时候这个查询也是正常运行的。一个查询需要执行filesort和创建临时表并不一定意味着就是有问题的。尽管消除filesort和临时表通常来说是“最佳实践”。\n通常的“最佳实践”自然有它的道理,但不一定是解决某些特殊问题的“灵丹妙药”。比如说问题可能是因为很简单的配置错误。我们碰到过很多这样的案例,问题本来是由于错误的配置导致的,却去优化查询,这不但浪费了时间,也使得真正问题被解决的时间被拖延了。\n如果缓存项被重新生成了很多次,是不是会导致产生很多同样的查询呢?\n这个问题我们确实还没有调查到。如果是多线程重新生成同样的缓存项,那么确实有可能导致产生很多同样的查询(这和很多同类型的查询不同,比如WHERE子句中的参数可能不一样)。注意到这样会刺激我们的直觉,并更快地带我们找到问题的解决方案。\n每秒有几百次SELECT查询,但只有五次UPDATE。怎么能确定这五次UPDATE的压力不会导致问题呢?\n这些UPDATE有可能对服务器造成很大的压力。我们没有将真正的查询语句展示出来,因为这样可能会将事情搞得更杂乱。但有一点很明确,某种查询的绝对数量不一定有意义。\nI/O风暴最初的证据看起来不是很充分?\n是的,确实是这样。有很多种解释可以说明为什么一个这么小的数据库可以产生这么大量的写入磁盘,或者说为什么磁盘的可用空间下降得这么快。这个问题中使用的MySQL和GNU/Linux版本都很难对一些东西进行测量(但不是说完全不可能)。尽管在很多时候我们可能扮演“魔鬼代言人”的角色,但我们还是以尽量平衡成本和潜在的利益为第一优先级。越是难以准确测量的时候,成本/收益比越攀升,我们也更愿意接受不确定性。\n之前说过“数据库过去从来没出过问题”是一种偏见吗?\n是的,这就是偏见。如果抓住问题,很好;如果没有,也可以是证明我们都有偏见的很好例子。\n至此我们要结束这个案例的学习了。需要指出的是,如果使用了诸如New Relic这样的剖析工具,即使没有我们的参与,也可能解决这个问题。\n3.5 其他剖析工具 # 我们已经演示了很多剖析MySQL、操作系统及查询的方法。我们也演示了那些我们觉得很有用的案例。当然,通过本书,我们还会展示更多工具和技术来检查和测量系统。但是等一下,本章还有更多工具没介绍呢。\n3.5.1 使用USER_STATISTICS表 # Percona Server和MariaDB都引入了一些额外的对象级别使用统计的INFORMATION_SCHEMA表,这些最初是由Google开发的。这些表对于查找服务器各部分的实际使用情况非常有帮助。在一个大型企业中,DBA负责管理数据库,但其对开发缺少话语权,那么通过这些表就可以对数据库活动进行测量和审计,并且强制执行使用策略。对于像共享主机环境这样的多租户环境也同样有用。另外,在查找性能问题时,这些表也可以帮助找出数据库中什么地方花费了最多的时间,或者什么表或索引使用得最频繁,抑或最不频繁。下面就是这些表:\n这里我们不会详细地演示针对这些表的所有有用的查询,但有几个要点要说明一下:\n可以查找使用得最多或者使用得最少的表和索引,通过读取次数或者更新次数,或者两者一起排序。 可以查找出从未使用的索引,可以考虑删除之。 可以看看复制用户的CONNECTED_TIME和BUSY_TIME,以确认复制是否会很难跟上主库的进度。 在MySQL 5.6中,Performance Schema中也添加了很多类似上面这些功能的表。\n3.5.2 使用strace # strace工具可以调查系统调用的情况。有好几种可以使用的方法,其中一种是计算系统调用的时间并打印出来:\n这种用法和oprofile有点像。但是oprofile还可以剖析程序的内部符号,而不仅仅是系统调用。另外,strace拦截系统调用使用的是不同于oprofile的技术,这会有一些不可预期性,开销也更大些。strace度量时使用的是实际时间,而oprofile使用的是花费的CPU周期。举个例子,当I/O等待出现问题的时候,strace能将它们显示出来,因为它从诸如read或者pread64这样的系统调用开始计时,直到调用结束。但oprofile不会这样,因为I/O系统调用并不会真正地消耗CPU周期,而只是等待I/O完成而已。\n我们会在需要的时候使用oprofile,因为strace对像mysqld这样有大量线程的场景会产生一些副作用。当strace附加上去后,mysqld的运行会变得很慢,因此不适合在产品环境中使用。但在某些场景中strace还是相当有用的,Percona Toolkit中有一个叫做pt-ioprofile的工具就是使用strace来生成I/O活动的剖析报告的。这个工具很有帮助,可以证明或者驳斥某些难以测量的情况下的一些观点,此时其他方法很难达到目的(如果运行的是MySQL 5.6,使用Performance Schema也可以达到目的)。\n3.6 总结 # 本章给出了一些基本的思路和技术,有助于你成功地进行性能优化。正确的思维方式是开启系统的全部潜力和应用本书其他章节提供的知识的关键。下面是我们试图演示的一些基本知识点:\n我们认为定义性能最有效的方法是响应时间。 如果无法测量就无法有效地优化,所以性能优化工作需要基于高质量、全方位及完整的响应时间测量。 测量的最佳开始点是应用程序,而不是数据库。即使问题出在底层的数据库,借助良好的测量也可以很容易地发现问题。 大多数系统无法完整地测量,测量有时候也会有错误的结果。但也可以想办法绕过一些限制,并得到好的结果(但是要能意识到所使用的方法的缺陷和不确定性在哪里)。 完整的测量会产生大量需要分析的数据,所以需要用到剖析器。这是最佳的工具,可以帮助将重要的问题冒泡到前面,这样就可以决定从哪里开始分析会比较好。 剖析报告是一种汇总信息,掩盖和丢弃了太多细节。而且它不会告诉你缺少了什么,所以完全依赖剖析报告也是不明智的。 有两种消耗时间的操作:工作或者等待。大多数剖析器只能测量因为工作而消耗的时间,所以等待分析有时候是很有用的补充,尤其是当CPU利用率很低但工作却一直无法完成的时候。 优化和提升是两回事。当继续提升的成本超过收益的时候,应当停止优化。 注意你的直觉,但应该只根据直觉来指导解决问题的思路,而不是用于确定系统的问题。决策应当尽量基于数据而不是感觉。 总体来说,我们认为解决性能问题的方法,首先是要澄清问题,然后选择合适的技术来解答这些问题。如果你想尝试提升服务器的总体性能,那么一个比较好的起点是将所有查询记录到日志中,然后利用pt-query-digest工具生成系统级别的剖析报告。如果是要追查某些性能低下的查询,记录和剖析的方法也会有帮助。可以把精力放在寻找那些消耗时间最多的、导致了糟糕的用户体验的,或者那些高度变化的,抑或有奇怪的响应时间直方图的查询。当找到了这些“坏”查询时,要钻取pt-query-digest报告中包含的该查询的详细信息,或者使用SHOW PROFILE及其他诸如EXPLAIN这样的工具。\n如果找不到这些查询性能低下的原因,那么也可能是遇到了服务器级别的性能问题。这时,可以较高精度测量和绘制服务器状态计数器的细节信息。如果通过这样的分析重现了问题,则应该通过同样的数据制定一个可靠的触发条件,来收集更多的诊断数据。多花费一点时间来确定可靠的触发条件,尽量避免漏检或者误报。如果已经可以捕获故障活动期间的数据,但还是无法找到其根本原因,则要么尝试捕获更多的数据,要么尝试寻求帮助。\n我们无法完整地测量工作系统,但说到底它们都是某种状态机,所以只要足够细心,逻辑清晰并且坚持下去,通常来说都能得到想要的结果。要注意的是不要把原因和结果搞混了,而且在确认问题之前也不要随便针对系统做变动。\n理论上纯粹的自顶向下的方法分析和详尽的测量只是理想的情况,而我们常常需要处理的是真实系统。真实系统是复杂且无法充分测量的,所以我们只能根据情况尽力而为。使用诸如pt-query-digest和MySQL企业监控器的查询分析器这样的工具并不完美,通常都不会给出问题根源的直接证据。但真的掌握了以后,已经足以完成大部分的优化诊断工作了。\n————————————————————\n(1) 本书不会严格区分查询和语句,DDL和DML等。不管给服务器发送什么命令,关心的都是执行命令的速度。本书将使用“查询”一词泛指所有发送给服务器的命令。\n(2) 本书尽量避免从理论上来阐述性能优化一词,如果有兴趣可以参考阅读另外两篇文章。在Percona的网站( http://www.percona.com)上,有一篇名为Goal-Driven Performance Optimization的白皮书,这是一篇紧凑的快速参考页。另外一篇是Cary Millsap的Optimizing Oracle Performance(O\u0026rsquo;Reilly出版)。Cary的优化方法,被称为R方法,是Oracle世界的优化黄金定律。\n(3) 也有人将优化定义为提升吞吐量,这也没有什么问题,但本书采用的不是这个定义,因为我们认为响应时间更重要,尽管吞吐量在基准测试中更容易测量。\n(4) MySQL 5.5的Performance Schema也没有提供查询级别的细节数据,要到MySQL 5.6才提供。\n(5) 在此向Donald Rumsfeld道歉。他的评论尽管听起来可笑,但实际上非常有见地。\n(6) 啊!(这只是个玩笑,我们并不坚持。)\n(7) 我们将在后面展示例子,因为需要有一些先验知识,这个问题跟底层相关,所以我们先跳过自顶向下的方法。\n(8) 不像PHP,大部分其他编程语言都有一些内建的剖析功能。例如Ruby可以使用-r选项,Perl则可以使用perl-d:DProf,等等。\n(9) 这里已经是尽可能地简化描述了,实际上Percona Server的查询日志报告会包含更多细节信息,可以帮助理解为什么某条查询花费了144ms去获取一行数据,这个时间实在是太长了。\n(10) 整个视图太长,无法在书中全部打印出来,但Sakila数据库可以从MySQL网站上下载到。\n(11) 原文用的Queries,实际上这里有点问题,虽然文档上也说这个参数是会话级的,但在MySQL 5.1/5.5多个版本中实际查询时发现其是全局级别的。——译者注\n(12) 如果你有本书的第二版,可能会注意到我们正在彻底改变这一点。\n(13) 再次强调,在没有足够的理由确信这是解决办法之前,不要随便去做升级操作。\n(14) 到目前为止我们还没发现红衣女,如果发现了,一定会让你知道的。\n(15) 警告:使用GDB是有侵入性的。它会暂时造成服务器停顿,尤其是有很多线程的时候,甚至有可能造成崩溃。但有时候收益还是大于风险的。如果服务器本身问题已经严重到无法提供服务了,那么使用GBD再造成一些暂停也就无所谓了。\n(16) 有时候为了“优化”而不安装符号信息,实际上这样做不会有多少优化的效果,反而会造成诊断问题更困难。可以使用nm工具检查是否安装了符号信息,如果没有,则可以通过安装MySQL的debuginfo包来安装。\n(17) 理论上,我们需要内核符号(kernel symbol)才能理解内核中发生了什么。实际上,安装内核符号可能会比较麻烦,并且从vmstat的输出可以看到系统CPU的利用率很低,所以即使安装了,很可能也会发现内核大多数是处于“sleeping”(睡眠)状态的。\n(18) 这看起来是一个编译有问题的MySQL版本。\n(19) 或者换个说法,不要把所有的鸡蛋都混在一个篮子里。\n(20) 也有人会拨打1-800热线电话。\n(21) 就像常说的“当你手中有了锤子,所有的东西看起来都是钉子”一样。\n"},{"id":145,"href":"/zh/docs/technology/MySQL/_%E9%AB%98%E6%80%A7%E8%83%BDMySQL_/%E7%AC%AC2%E7%AB%A0MySQL%E5%9F%BA%E5%87%86%E6%B5%8B%E8%AF%95/","title":"第2章MySQL基准测试","section":"高性能 My SQL","content":"第2章 MySQL基准测试\n基准测试(benchmark)是MySQL新手和专家都需要掌握的一项基本技能。简单地说,基准测试是针对系统设计的一种压力测试。通常的目标是为了掌握系统的行为。但也有其他原因,如重现某个系统状态,或者是做新硬件的可靠性测试。本章将讨论MySQL和基于MySQL的应用的基准测试的重要性、策略和工具。我们将特别讨论一下sysbench,这是一款非常优秀的MySQL基准测试工具。\n2.1 为什么需要基准测试 # 为什么基准测试很重要?因为基准测试是唯一方便有效的、可以学习系统在给定的工作负载下会发生什么的方法。基准测试可以观察系统在不同压力下的行为,评估系统的容量,掌握哪些是重要的变化,或者观察系统如何处理不同的数据。基准测试可以在系统实际负载之外创造一些虚构场景进行测试。基准测试可以完成以下工作,或者更多:\n验证基于系统的一些假设,确认这些假设是否符合实际情况。 重现系统中的某些异常行为,以解决这些异常。 测试系统当前的运行情况。如果不清楚系统当前的性能,就无法确认某些优化的效果如何。也可以利用历史的基准测试结果来分析诊断一些无法预测的问题。 模拟比当前系统更高的负载,以找出系统随着压力增加而可能遇到的扩展性瓶颈。 规划未来的业务增长。基准测试可以评估在项目未来的负载下,需要什么样的硬件,需要多大容量的网络,以及其他相关资源。这有助于降低系统升级和重大变更的风险。 测试应用适应可变环境的能力。例如,通过基准测试,可以发现系统在随机的并发峰值下的性能表现,或者是不同配置的服务器之间的性能表现。基准测试也可以测试系统对不同数据分布的处理能力。 测试不同的硬件、软件和操作系统配置。比如RAID 5还是RAID 10更适合当前的系统?如果系统从ATA硬盘升级到SAN存储,对于随机写性能有什么帮助?Linux 2.4系列的内核会比2.6系列的可扩展性更好吗?升级MySQL的版本能改善性能吗?为当前的数据采用不同的存储引擎会有什么效果?所有这类问题都可以通过专门的基准测试来获得答案。 证明新采购的设备是否配置正确。笔者曾经无数次地通过基准测试来对新系统进行压测,发现了很多错误的配置,以及硬件组件的失效等问题。因此在新系统正式上线到生产环境之前进行基准测试是一个好习惯,永远不要相信主机提供商或者硬件供应商的所谓系统已经安装好,并且能运行多快的说法。如果可能,执行实际的基准测试永远是一个好主意。 基准测试还可以用于其他目的,比如为应用创建单元测试套件。但本章我们只关注与性能有关的基准测试。\n基准测试的一个主要问题在于其不是真实压力的测试。基准测试施加给系统的压力相对真实压力来说,通常比较简单。真实压力是不可预期而且变化多端的,有时候情况会过于复杂而难以解释。所以使用真实压力测试,可能难以从结果中分析出确切的结论。\n基准测试的压力和真实压力在哪些方面不同?有很多因素会影响基准测试,比如数据量、数据和查询的分布,但最重要的一点还是基准测试通常要求尽可能快地执行完成,所以经常给系统造成过大的压力。在很多案例中,我们都会调整给测试工具的最大压力,以在系统可以容忍的压力阈值内尽可能快地执行测试,这对于确定系统的最大容量非常有帮助。然而大部分压力测试工具不支持对压力进行复杂的控制。务必要记住,测试工具自身的局限也会影响到结果的有效性。\n使用基准测试进行容量规划也要掌握技巧,不能只根据测试结果做简单的推断。例如,假设想知道使用新数据库服务器后,系统能够支撑多大的业务增长。首先对原系统进行基准测试,然后对新系统做测试,结果发现新系统可以支持原系统40倍的TPS(每秒事务数),这时候就不能简单地推断说新系统一定可以支持40倍的业务增长。这是因为在业务增长的同时,系统的流量、用户、数据以及不同数据之间的交互都在增长,它们不可能都有40倍的支撑能力,尤其是相互之间的关系。而且当业务增长到40倍时,应用本身的设计也可能已经随之改变。可能有更多的新特性会上线,其中某些特性可能对数据库造成的压力远大于原有功能。而这些压力、数据、关系和特性的变化都很难模拟,所以它们对系统的影响也很难评估。\n结论就是,我们只能进行大概的测试,来确定系统大致的余量有多少。当然也可以做一些真实压力测试(和基准测试有区别),但在构造数据集和压力的时候要特别小心,而且这样就不再是基准测试了。基准测试要尽量简单直接,结果之间容易相互比较,成本低且易于执行。尽管有诸多限制,基准测试还是非常有用的(只要搞清楚测试的原理,并且了解如何分析结果所代表的意义)。\n2.2 基准测试的策略 # 基准测试有两种主要的策略:一是针对整个系统的整体测试,另外是单独测试MySQL。这两种策略也被称为集成式(full-stack)以及单组件式(single-component)基准测试。针对整个系统做集成式测试,而不是单独测试MySQL的原因主要有以下几点:\n测试整个应用系统,包括Web服务器、应用代码、网络和数据库是非常有用的,因为用户关注的并不仅仅是MySQL本身的性能,而是应用整体的性能。 MySQL并非总是应用的瓶颈,通过整体的测试可以揭示这一点。 只有对应用做整体测试,才能发现各部分之间的缓存带来的影响。 整体应用的集成式测试更能揭示应用的真实表现,而单独组件的测试很难做到这一点。 另外一方面,应用的整体基准测试很难建立,甚至很难正确设置。如果基准测试的设计有问题,那么结果就无法反映真实的情况,从而基于此做的决策也就可能是错误的。\n不过,有时候不需要了解整个应用的情况,而只需要关注MySQL的性能,至少在项目初期可以这样做。基于以下情况,可以选择只测试MySQL:\n需要比较不同的schema或查询的性能。 针对应用中某个具体问题的测试。 为了避免漫长的基准测试,可以通过一个短期的基准测试,做快速的“周期循环”,来检测出某些调整后的效果。 另外,如果能够在真实的数据集上执行重复的查询,那么针对MySQL的基准测试也是有用的,但是数据本身和数据集的大小都应该是真实的。如果可能,可以采用生产环境的数据快照。\n不幸的是,设置一个基于真实数据的基准测试复杂而且耗时。如果能得到一份生产数据集的拷贝,当然很幸运,但这通常不太可能。比如要测试的是一个刚开发的新应用,它只有很少的用户和数据。如果想测试该应用在规模扩张到很大以后的性能表现,就只能通过模拟大量的数据和压力来进行。\n2.2.1 测试何种指标 # 在开始执行甚至是在设计基准测试之前,需要先明确测试的目标。测试目标决定了选择什么样的测试工具和技术,以获得精确而有意义的测试结果。可以将测试目标细化为一系列的问题,比如,“这种CPU是否比另外一种要快?”,或“新索引是否比当前索引性能更好?”\n有时候需要用不同的方法测试不同的指标。比如,针对延迟(latency)和吞吐量(throughput)就需要采用不同的测试方法。\n请考虑以下指标,看看如何满足测试的需求。\n吞吐量\n吞吐量指的是单位时间内的事务处理数。这一直是经典的数据库应用测试指标。一些标准的基准测试被广泛地引用,如TPC-C(参考 http://www.tpc.org),而且很多数据库厂商都努力争取在这些测试中取得好成绩。这类基准测试主要针对在线事务处理(OLTP)的吞吐量,非常适用于多用户的交互式应用。常用的测试单位是每秒事务数(TPS),有些也采用每分钟事务数(TPM)。\n响应时间或者延迟\n这个指标用于测试任务所需的整体时间。根据具体的应用,测试的时间单位可能是微秒、毫秒、秒或者分钟。根据不同的时间单位可以计算出平均响应时间、最小响应时间、最大响应时间和所占百分比。最大响应时间通常意义不大,因为测试时间越长,最大响应时间也可能越大。而且其结果通常不可重复,每次测试都可能得到不同的最大响应时间。因此,通常可以使用百分比响应时间(percentile response time)来替代最大响应时间。例如,如果95%的响应时间都是5毫秒,则表示任务在95%的时间段内都可以在5毫秒之内完成。\n使用图表有助于理解测试结果。可以将测试结果绘制成折线图(比如平均值折线或者95%百分比折线)或者散点图,直观地表现数据结果集的分布情况。通过这些图可以发现长时间测试的趋势。本章后面将更详细地讨论这一点。\n并发性\n并发性是一个非常重要又经常被误解和误用的指标。例如,它经常被表示成多少用户在同一时间浏览一个Web站点,经常使用的指标是有多少个会话(1)。然而,HTTP协议是无状态的,大多数用户只是简单地读取浏览器上显示的信息,这并不等同于Web服务器的并发性。而且,Web服务器的并发性也不等同于数据库的并发性,而仅仅只表示会话存储机制可以处理多少数据的能力。Web服务器的并发性更准确的度量指标,应该是在任意时间有多少同时发生的并发请求。\n在应用的不同环节都可以测量相应的并发性。Web服务器的高并发,一般也会导致数据库的高并发,但服务器采用的语言和工具集对此都会有影响。注意不要将创建数据库连接和并发性搞混淆。一个设计良好的应用,同时可以打开成百上千个MySQL数据库服务器连接,但可能同时只有少数连接在执行查询。所以说,一个Web站点“同时有50000个用户”访问,却可能只有10~15个并发请求到MySQL数据库。\n换句话说,并发性基准测试需要关注的是正在工作中的并发操作,或者是同时工作中的线程数或者连接数。当并发性增加时,需要测量吞吐量是否下降,响应时间是否变长,如果是这样,应用可能就无法处理峰值压力。\n并发性的测量完全不同于响应时间和吞吐量。它不像是一个结果,而更像是设置基准测试的一种属性。并发性测试通常不是为了测试应用能达到的并发度,而是为了测试应用在不同并发下的性能。当然,数据库的并发性还是需要测量的。可以通过sysbench指定32、64或者128个线程的测试,然后在测试期间记录MySQL数据库的Threads_running状态值。在第11章将讨论这个指标对容量规划的影响。\n可扩展性\n在系统的业务压力可能发生变化的情况下,测试可扩展性就非常必要了。第11章将更进一步讨论可扩展性的话题。简单地说,可扩展性指的是,给系统增加一倍的工作,在理想情况下就能获得两倍的结果(即吞吐量增加一倍)。或者说,给系统增加一倍的资源(比如两倍的CPU数),就可以获得两倍的吞吐量。当然,同时性能(响应时间)也必须在可以接受的范围内。大多数系统是无法做到如此理想的线性扩展的。随着压力的变化,吞吐量和性能都可能越来越差。\n可扩展性指标对于容量规范非常有用,它可以提供其他测试无法提供的信息,来帮助发现应用的瓶颈。比如,如果系统是基于单个用户的响应时间测试(这是一个很糟糕的测试策略)设计的,虽然测试的结果很好,但当并发度增加时,系统的性能有可能变得非常糟糕。而一个基于不断增加用户连接的情况下的响应时间测试则可以发现这个问题。\n一些任务,比如从细粒度数据创建汇总表的批量工作,需要的是周期性的快速响应时间。当然也可以测试这些任务纯粹的响应时间,但要注意考虑这些任务之间的相互影响。批量工作可能导致相互之间有影响的查询性能变差,反之亦然。\n归根结底,应该测试那些对用户来说最重要的指标。因此应该尽可能地去收集一些需求,比如,什么样的响应时间是可以接受的,期待多少的并发性,等等。然后基于这些需求来设计基准测试,避免目光短浅地只关注部分指标,而忽略其他指标。\n2.3 基准测试方法 # 在了解基本概念之后,现在可以来具体讨论一下如何设计和执行基准测试。但在讨论如何设计好的基准测试之前,先来看一下如何避免一些常见的错误,这些错误可能导致测试结果无用或者不精确:\n使用真实数据的子集而不是全集。例如应用需要处理几百GB的数据,但测试只有1GB数据;或者只使用当前数据进行测试,却希望模拟未来业务大幅度增长后的情况。 使用错误的数据分布。例如使用均匀分布的数据测试,而系统的真实数据有很多热点区域(随机生成的测试数据通常无法模拟真实的数据分布)。 使用不真实的分布参数,例如假定所有用户的个人信息(profile)都会被平均地读取(2)。 在多用户场景中,只做单用户的测试。 在单服务器上测试分布式应用。 与真实用户行为不匹配。例如Web页面中的“思考时间”。真实用户在请求到一个页面后会阅读一段时间,而不是不停顿地一个接一个点击相关链接。 反复执行同一个查询。真实的查询是不尽相同的,这可能会导致缓存命中率降低。而反复执行同一个查询在某种程度上,会全部或者部分缓存结果。 没有检查错误。如果测试的结果无法得到合理的解释,比如一个本应该很慢的查询突然变快了,就应该检查是否有错误产生。否则可能只是测试了MySQL检测语法错误的速度了。基准测试完成后,一定要检查一下错误日志,这应当是基本的要求。 忽略了系统预热(warm up)的过程。例如系统重启后马上进行测试。有时候需要了解系统重启后需要多长时间才能达到正常的性能容量,要特别留意预热的时长。反过来说,如果要想分析正常的性能,需要注意,若基准测试在重启以后马上启动,则缓存是冷的、还没有数据,这时即使测试的压力相同,得到的结果也和缓存已经装满数据时是不同的。 使用默认的服务器配置。第3章将详细地讨论服务器的优化配置。 测试时间太短。基准测试需要持续一定的时间。后面会继续讨论这个话题。 只有避免了上述错误,才能走上改进测试质量的漫漫长路。\n如果其他条件相同,就应努力使测试过程尽可能地接近真实应用的情况。当然,有时候和真实情况稍有些出入问题也不大。例如,实际应用服务器和数据库服务器分别部署在不同的机器。如果采用和实际部署完全相同的配置当然更真实,但也会引入更多的变化因素,比如加入了网络的负载和速度等。而在单一节点上运行测试相对要容易,在某些情况下结果也可以接受,那么就可以在单一节点上进行测试。当然,这样的选择需要根据实际情况来分析是否合适。\n2.3.1 设计和规划基准测试 # 规划基准测试的第一步是提出问题并明确目标。然后决定是采用标准的基准测试,还是设计专用的测试。\n如果采用标准的基准测试,应该确认选择了合适的测试方案。例如,不要使用TPC-H测试电子商务系统。在TPC的定义中,“TPC-H是即席查询和决策支持型应用的基准测试”,因此不适合用来测试OLTP系统。\n设计专用的基准测试是很复杂的,往往需要一个迭代的过程。首先需要获得生产数据集的快照,并且该快照很容易还原,以便进行后续的测试。\n然后,针对数据运行查询。可以建立一个单元测试集作为初步的测试,并运行多遍。但是这和真实的数据库环境还是有差别的。更好的办法是选择一个有代表性的时间段,比如高峰期的一个小时,或者一整天,记录生产系统上的所有查询。如果时间段选得比较小,则可以选择多个时间段。这样有助于覆盖整个系统的活动状态,例如每周报表的查询、或者非峰值时间运行的批处理作业(3)。\n可以在不同级别记录查询。例如,如果是集成式(full-stack)基准测试,可以记录Web服务器上的HTTP请求,也可以打开MySQL的查询日志(Query Log)。倘若要重演这些查询,就要确保创建多线程来并行执行,而不是单个线程线性地执行。对日志中的每个连接都应该创建独立的线程,而不是将所有的查询随机地分配到一些线程中。查询日志中记录了每个查询是在哪个连接中执行的。\n即使不需要创建专用的基准测试,详细地写下测试规划也是必需的。测试可能要多次反复运行,因此需要精确地重现测试过程。而且也应该考虑到未来,执行下一轮测试时可能已经不是同一个人了。即使还是同一个人,也有可能不会确切地记得初次运行时的情况。测试规划应该记录测试数据、系统配置的步骤、如何测量和分析结果,以及预热的方案等。\n应该建立将参数和结果文档化的规范,每一轮测试都必须进行详细记录。文档规范可以很简单,比如采用电子表格(spreadsheet)或者记事本形式,也可以是复杂的自定义的数据库。需要记住的是,经常要写一些脚本来分析测试结果,因此如果能够不用打开电子表格或者文本文件等额外操作,当然是更好的。\n2.3.2 基准测试应该运行多长时间 # 基准测试应该运行足够长的时间,这一点很重要。如果需要测试系统在稳定状态时的性能,那么当然需要在稳定状态下测试并观察。而如果系统有大量的数据和内存,要达到稳定状态可能需要非常长的时间。大部分系统都会有一些应对突发情况的余量,能够吸收性能尖峰,将一些工作延迟到高峰期之后执行。但当对机器加压足够长时间之后,这些余量会被消耗尽,系统的短期尖峰也就无法维持原来的高性能。\n有时候无法确认测试需要运行多长的时间才足够。如果是这样,可以让测试一直运行,持续观察直到确认系统已经稳定。下面是一个在已知系统上执行测试的例子,图2-1显示了系统磁盘读和写吞吐量的时序图。\n图2-1:扩展基准测试的I/O性能图\n系统预热完成后,读I/O活动在三四个小时后曲线趋向稳定,但写I/O至少在八小时内变化还是很大,之后有一些点的波动较大,但读和写总体来说基本稳定了(4)。一个简单的测试规则,就是等系统看起来稳定的时间至少等于系统预热的时间。本例中的测试持续了72个小时才结束,以确保能够体现系统长期的行为。\n一个常见的错误的测试方式是,只执行一系列短期的测试,比如每次60秒,并在此测试的基础上去总结系统的性能。我们经常可以听到类似这样的话:“我尝试对新版本做了测试,但还不如旧版本快”,然而我们分析实际的测试结果后发现,测试的方式根本不足以得出这样的结论。有时候人们也会强调说不可能有时间去测试8或者12个小时,以验证10个不同并发性在两到三个不同版本下的性能。如果没有时间去完成准确完整的基准测试,那么已经花费的所有时间都是一种浪费。有时候要相信别人的测试结果,这总比做一次半拉子的测试来得到一个错误的结论要好。\n2.3.3 获取系统性能和状态 # 在执行基准测试时,需要尽可能多地收集被测试系统的信息。最好为基准测试建立一个目录,并且每执行一轮测试都创建单独的子目录,将测试结果、配置文件、测试指标、脚本和其他相关说明都保存在其中。即使有些结果不是目前需要的,也应该先保存下来。多余一些数据总比缺乏重要的数据要好,而且多余的数据以后也许会用得着。需要记录的数据包括系统状态和性能指标,诸如CPU使用率、磁盘I/O、网络流量统计、SHOW GLOBAL STATUS计数器等。\n下面是一个收集MySQL测试数据的shell脚本:\n#!/bin/sh INTERVAL=5 PREFIX=$INTERVAL-sec-status RUNFILE=/home/benchmarks/running mysql -e 'SHOW GLOBAL VARIABLES' \u0026gt;\u0026gt; mysql-variables while test -e $RUNFILE; do file=$(date +%F_%I) sleep=$(date +%s.%N | awk \u0026quot;{print $INTERVAL - (\\$1 % $INTERVAL)}\u0026quot;) sleep $sleep ts=\u0026quot;$(date +\u0026quot;TS %s.%N %F %T\u0026quot;)\u0026quot; loadavg=\u0026quot;$(uptime)\u0026quot; echo \u0026quot;$ts $loadavg\u0026quot; \u0026gt;\u0026gt; $PREFIX-${file}-status mysql -e 'SHOW GLOBAL STATUS' \u0026gt;\u0026gt; $PREFIX-${file}-status \u0026amp; echo \u0026quot;$ts $loadavg\u0026quot; \u0026gt;\u0026gt; $PREFIX-${file}-innodbstatus mysql -e 'SHOW ENGINE INNODB STATUS\\G' \u0026gt;\u0026gt; $PREFIX-${file}-innodbstatus \u0026amp; echo \u0026quot;$ts $loadavg\u0026quot; \u0026gt;\u0026gt; $PREFIX-${file}-processlist mysql -e 'SHOW FULL PROCESSLIST\\G' \u0026gt;\u0026gt; $PREFIX-${file}-processlist \u0026amp; echo $ts done echo Exiting because $RUNFILE does not exist. 这个shell脚本很简单,但提供了一个有效的收集状态和性能数据的框架。看起来好像作用不大,但当需要在多个服务器上执行比较复杂的测试的时候,要回答以下关于系统行为的问题,没有这种脚本的话就会很困难了。下面是这个脚本的一些要点:\n迭代是基于固定时间间隔的,每隔5秒运行一次收集的动作,注意这里sleep的时间有一个特殊的技巧。如果只是简单地在每次循环时插入一条“sleep 5”的指令,循环的执行间隔时间一般都会稍大于5秒,那么这个脚本就没有办法通过其他脚本和图形简单地捕获时间相关的准确数据。即使有时候循环能够恰好在5秒内完成,但如果某些系统的时间戳是15:32:18.218192,另外一个则是15:32:23.819437,这时候就比较讨厌了。当然这里的5秒也可以改成其他的时间间隔,比如1、10、30或者60秒。不过还是推荐使用5秒或者10秒的间隔来收集数据。 每个文件名都包含了该轮测试开始的日期和小时。如果测试要持续好几天,那么这个文件可能会非常大,有必要的话需要手工将文件移到其他地方,但要分析全部结果的时候要注意从最早的文件开始。如果只需要分析某个时间点的数据,则可以根据文件名中的日期和小时迅速定位,这比在一个GB以上的大文件中去搜索要快捷得多。 每次抓取数据都会先记录当前的时间戳,所以可以在文件中搜索某个时间点的数据。也可以写一些awk或者sed脚本来简化操作。 这个脚本不会处理或者过滤收集到的数据。先收集所有的原始数据,然后再基于此做分析和过滤是一个好习惯。如果在收集的时候对数据做了预处理,而后续分析发现一些异常的地方需要用到更多的原始数据,这时候就要“抓瞎”了。 如果需要在测试完成后脚本自动退出,只需要删除*/home/benchmarks/running*文件即可。 这只是一段简单的代码,或许不能满足全部的需求,但却很好地演示了该如何捕获测试的性能和状态数据。从代码可以看出,只捕获了MySQL的部分数据,如果需要,则很容易通过修改脚本添加新的数据捕获。例如,可以通过pt-diskstats工具(5)捕获*/proc/diskstats*的数据为后续分析磁盘I/O使用。\n2.3.4 获得准确的测试结果 # 获得准确测试结果的最好办法,是回答一些关于基准测试的基本问题:是否选择了正确的基准测试?是否为问题收集了相关的数据?是否采用了错误的测试标准?例如,是否对一个I/O密集型(I/O-bound)的应用,采用了CPU密集型(CPU-bound)的测试标准来评估性能?\n接着,确认测试结果是否可重复。每次重新测试之前要确保系统的状态是一致的。如果是非常重要的测试,甚至有必要每次测试都重启系统。一般情况下,需要测试的是经过预热的系统,还需要确保预热的时间足够长(请参考前面关于基准测试需要运行多长时间的内容)、是否可重复。如果预热采用的是随机查询,那么测试结果可能就是不可重复的。\n如果测试的过程会修改数据或者schema,那么每次测试前,需要利用快照还原数据。在表中插入1000条记录和插入100万条记录,测试结果肯定不会相同。数据的碎片度和在磁盘上的分布,都可能导致测试是不可重复的。一个确保物理磁盘数据的分布尽可能一致的办法是,每次都进行快速格式化并进行磁盘分区复制。\n要注意很多因素,包括外部的压力、性能分析和监控系统、详细的日志记录、周期性作业,以及其他一些因素,都会影响到测试结果。一个典型的案例,就是测试过程中突然有cron定时作业启动,或者正处于一个巡查读取周期(Patrol Read cycle),抑或RAID卡启动了定时的一致性检查等。要确保基准测试运行过程中所需要的资源是专用于测试的。如果有其他额外的操作,则会消耗网络带宽,或者测试基于的是和其他服务器共享的SAN存储,那么得到的结果很可能是不准确的。\n每次测试中,修改的参数应该尽量少。如果必须要一次修改多个参数,那么可能会丢失一些信息。有些参数依赖其他参数,这些参数可能无法单独修改。有时候甚至都没有意识到这些依赖,这给测试带来了复杂性(6)。\n一般情况下,都是通过迭代逐步地修改基准测试的参数,而不是每次运行时都做大量的修改。举个例子,如果要通过调整参数来创造一个特定行为,可以通过使用分治法(divide-and-conquer,每次运行时将参数对分减半)来找到正确的值。\n很多基准测试都是用来做预测系统迁移后的性能的,比如从Oracle迁移到MySQL。这种测试通常比较麻烦,因为MySQL执行的查询类型与Oracle完全不同。如果想知道在Oracle运行得很好的应用迁移到MySQL以后性能如何,通常需要重新设计MySQL的schema和查询(在某些情况下,比如,建立一个跨平台的应用时,可能想知道同一条查询是如何在两个平台运行的,不过这种情况并不多见)。\n另外,基于MySQL的默认配置的测试没有什么意义,因为默认配置是基于消耗很少内存的极小应用的。有时候可以看到一些MySQL和其他商业数据库产品的对比测试,结果很让人尴尬,可能就是MySQL采用了默认配置的缘故。让人无语的是,这样明显有误的测试结果还容易变成头条新闻。\n固态存储(SSD或者PCI-E卡)给基准测试带来了很大的挑战,第9章将进一步讨论。\n最后,如果测试中出现异常结果,不要轻易当作坏数据点而丢弃。应该认真研究并找到产生这种结果的原因。测试可能会得到有价值的结果,或者一个严重的错误,抑或基准测试的设计缺陷。如果对测试结果不了解,就不要轻易公布。有一些案例表明,异常的测试结果往往都是由于很小的错误导致的,最后搞得测试无功而返(7)。\n2.3.5 运行基准测试并分析结果 # 一旦准备就绪,就可以着手基准测试,收集和分析数据了。\n通常来说,自动化基准测试是个好主意。这样做可以获得更精确的测试结果。因为自动化的过程可以防止测试人员偶尔遗漏某些步骤,或者误操作。另外也有助于归档整个测试过程。\n自动化的方式有很多,可以是一个Makefile文件或者一组脚本。脚本语言可以根据需要选择:shell、PHP、Perl等都可以。要尽可能地使所有测试过程都自动化,包括装载数据、系统预热、执行测试、记录结果等。\n一旦设置了正确的自动化操作,基准测试将成为一步式操作。如果只是针对某些应用做一次性的快速验证测试,可能就没必要做自动化。但只要未来可能会引用到测试结果,建议都尽量地自动化。否则到时候可能就搞不清楚是如何获得这个结果的,也不记得采用了什么参数,这样就很难再通过测试重现结果了。\n基准测试通常需要运行多次。具体需要运行多少次要看对结果的记分方式,以及测试的重要程度。要提高测试的准确度,就需要多运行几次。一般在测试的实践中,可以取最好的结果值,或者所有结果的平均值,抑或从五个测试结果里取最好三个值的平均值。可以根据需要更进一步精确化测试结果。还可以对结果使用统计方法,确定置信区间(confidence interval)等。不过通常来说,不会用到这种程度的确定性结果(8)。只要测试的结果能满足目前的需求,简单地运行几轮测试,看看结果的变化就可以了。如果结果变化很大,可以再多运行几次,或者运行更长的时间,这样都可以获得更确定的结果。\n获得测试结果后,还需要对结果进行分析,也就是说,要把“数字”变成“知识”。最终的目的是回答在设计测试时的问题。理想情况下,可以获得诸如“升级到4核CPU可以在保持响应时间不变的情况下获得超过50%的吞吐量增长”或者“增加索引可以使查询更快”的结论。如果需要更加科学化,建议在测试前读读null hypothesis一书,但大部分情况下不会要求做这么严格的基准测试。\n如何从数据中抽象出有意义的结果,依赖于如何收集数据。通常需要写一些脚本来分析数据,这不仅能减轻分析的工作量,而且和自动化基准测试一样可以重复运行,并易于文档化。下面是一个非常简单的shell脚本,演示了如何从前面的数据采集脚本采集到的数据中抽取时间维度信息。脚本的输入参数是采集到的数据文件的名字。\n#!/bin/sh # This script converts SHOW GLOBAL STATUS into a tabulated format, one line # per sample in the input, with the metrics divided by the time elapsed # between samples. awk ' BEGIN { printf \u0026quot;#ts date time load QPS\u0026quot;; fmt = \u0026quot; %.2f\u0026quot;; } /^TS/ { # The timestamp lines begin with TS. ts = substr($2, 1, index($2, \u0026quot;.\u0026quot;) - 1); load = NF - 2; diff = ts - prev_ts; prev_ts = ts; printf \u0026quot;\\n%s %s %s %s\u0026quot;, ts, $3, $4, substr($load, 1, length($load)-1); } /Queries/ { printf fmt, ($2-Queries)/diff; Queries=$2 } ' \u0026quot;$@\u0026quot; 假设该脚本名为analyze,当前面的脚本生成状态文件以后,就可以运行该脚本,可能会得到如下的结果:\n[baron@ginger ~]$ ./analyze 5-sec-status-2011-03-20 #ts date time load QPS 1300642150 2011-03-20 17:29:10 0.00 0.62 1300642155 2011-03-20 17:29:15 0.00 1311.60 1300642160 2011-03-20 17:29:20 0.00 1770.60 1300642165 2011-03-20 17:29:25 0.00 1756.60 1300642170 2011-03-20 17:29:30 0.00 1752.40 1300642175 2011-03-20 17:29:35 0.00 1735.00 1300642180 2011-03-20 17:29:40 0.00 1713.00 1300642185 2011-03-20 17:29:45 0.00 1788.00 1300642190 2011-03-20 17:29:50 0.00 1596.40 第一行是列的名字;第二行的数据应该忽略,因为这是测试实际启动前的数据。接下来的行包含Unix时间戳、日期、时间(注意时间数据是每5秒更新一次,前面脚本说明时曾提过)、系统负载、数据库的QPS(每秒查询次数)五列,这应该是用于分析系统性能的最少数据需求了。接下来将演示如何根据这些数据快速地绘成图形,并分析基准测试过程中发生了什么。\n2.3.6 绘图的重要性 # 如果你想要统治世界,就必须不断地利用“阴谋”(9)。而最简单有效的图形,就是将性能指标按照时间顺序绘制。通过图形可以立刻发现一些问题,而这些问题在原始数据中却很难被注意到。或许你会坚持看测试工具打印出来的平均值或其他汇总过的信息,但平均值有时候是没有用的,它会掩盖掉一些真实情况。幸运的是,前面写的脚本的输出都可以定制作为gnuplot或者R绘图的数据来源。假设使用gnuplot,假设输出的数据文件名是QPS-per-5-seconds:\ngnuplot\u0026gt; plot \u0026quot;QPS-per-5-seconds\u0026quot; using 5 w lines title\u0026quot;QPS\u0026quot; 该gnuplot命令将文件的第五列qps数据绘成图形,图的标题是QPS。图2-2是绘制出来的结果图。\n图2-2:基准测试的QPS图形\n下面我们讨论一个可以更加体现图形价值的例子。假设MySQL数据正在遭受“疯狂刷新(furious flushing)”的问题,在刷新落后于检查点时会阻塞所有的活动,从而导致吞吐量严重下跌。95%的响应时间和平均响应时间指标都无法发现这个问题,也就是说这两个指标掩盖了问题。但图形会显示出这个周期性的问题,请参考图2-3。\n图2-3显示的是每分钟新订单的交易量(NOTPM,new-order transactions per minute)。从曲线可以看到明显的周期性下降,但如果从平均值(点状虚线)来看波动很小。一开始的低谷是由于系统的缓存是空的,而后面其他的下跌则是由于系统刷新脏块到磁盘导致。如果没有图形,要发现这个趋势会比较困难。\n图2-3:一个30分钟的dbt2测试的结果\n这种性能尖刺在压力大的系统比较常见,需要调查原因。在这个案例中,是由于使用了旧版本的InnoDB引擎,脏块的刷新算法性能很差。但这个结论不能是想当然的,需要认真地分析详细的性能统计。在性能下跌时,SHOW ENGINE INNODB STATUS的输出是什么?SHOW FULL PROCESSLIST的输出是什么?应该可以发现InnoDB在持续地刷新脏块,并且阻塞了很多状态是“waiting on query cache lock”的线程,或者其他类似的现象。在执行基准测试的时候要尽可能地收集更多的细节数据,然后将数据绘制成图形,这样可以帮助快速地发现问题。\n2.4 基准测试工具 # 没有必要开发自己的基准测试系统,除非现有的工具确实无法满足需求。下面的章节会介绍一些可用的工具。\n2.4.1 集成式测试工具 # 回忆一下前文提供的两种测试类型:集成式测试和单组件式测试。毫不奇怪,有些工具是针对整个应用进行测试,也有些工具是针对MySQL或者其他组件单独进行测试的。集成式测试,通常是获得整个应用概况的最佳手段。已有的集成式测试工具如下所示。\nab\nab是一个Apache HTTP服务器基准测试工具。它可以测试HTTP服务器每秒最多可以处理多少请求。如果测试的是Web应用服务,这个结果可以转换成整个应用每秒可以满足多少请求。这是个非常简单的工具,用途也有限,只能针对单个URL进行尽可能快的压力测试。关于ab的更多信息可以参考 http://httpd.apache.org/docs/2.0/programs/ab.html。\nhttp_load\n这个工具概念上和ab类似,也被设计为对Web服务器进行测试,但比ab要更加灵活。可以通过一个输入文件提供多个URL,http_load在这些URL中随机选择进行测试。也可以定制http_load,使其按照时间比率进行测试,而不仅仅是测试最大请求处理能力。更多信息请参考 http://www.acme.com/software/http-load/。\nJMeter\nJMeter是一个Java应用程序,可以加载其他应用并测试其性能。它虽然是设计用来测试Web应用的,但也可以用于测试其他诸如FTP服务器,或者通过JDBC进行数据库查询测试。\nJMeter比ab和http_load都要复杂得多。例如,它可以通过控制预热时间等参数,更加灵活地模拟真实用户的访问。JMeter拥有绘图接口(带有内置的图形化处理的功能),还可以对测试进行记录,然后离线重演测试结果。更多信息请参考 http://jakarta.apache.org/jmeter/。\n2.4.2 单组件式测试工具 # 有一些有用的工具可以测试MySQL和基于MySQL的系统的性能。2.5节将演示如何利用这些工具进行测试。\nmysqlslap\nmysqlslap( http://dev.mysql.com/doc/refman/5.1/en/mysqlslap.html)可以模拟服务器的负载,并输出计时信息。它包含在MySQL 5.1的发行包中,应该在MySQL 4.1或者更新的版本中都可以使用。测试时可以执行并发连接数,并指定SQL语句(可以在命令行上执行,也可以把SQL语句写入到参数文件中)。如果没有指定SQL语句,mysqlslap会自动生成查询schema的SELECT语句。\nMySQL Benchmark Suite(sql-bench)\n在MySQL的发行包中也提供了一款自己的基准测试套件,可以用于在不同数据库服务器上进行比较测试。它是单线程的,主要用于测试服务器执行查询的速度。结果会显示哪种类型的操作在服务器上执行得更快。\n这个测试套件的主要好处是包含了大量预定义的测试,容易使用,所以可以很轻松地用于比较不同存储引擎或者不同配置的性能测试。其也可以用于高层次测试,比较两个服务器的总体性能。当然也可以只执行预定义测试的子集(例如只测试UPDATE的性能)。这些测试大部分是CPU密集型的,但也有些短时间的测试需要大量的磁盘I/O操作。\n这个套件的最大缺点主要有:它是单用户模式的,测试的数据集很小且用户无法使用指定的数据,并且同一个测试多次运行的结果可能会相差很大。因为是单线程且串行执行的,所以无法测试多CPU的能力,只能用于比较单CPU服务器的性能差别。使用这个套件测试数据库服务器还需要Perl和BDB的支持,相关文档请参考 http://dev.mysql.com/doc/en/mysql-benchmarks.html/。\nSuper Smack\nSuper Smack( http://vegan.net/tony/supersmack/)是一款用于MySQL和PostgreSQL的基准测试工具,可以提供压力测试和负载生成。这是一个复杂而强大的工具,可以模拟多用户访问,可以加载测试数据到数据库,并支持使用随机数据填充测试表。测试定义在“smack”文件中,smack文件使用一种简单的语法定义测试的客户端、表、查询等测试要素。\nDatabase Test Suite\nDatabase Test Suite是由开源软件开发实验室(OSDL,Open Source Development Labs)设计的,发布在SourceForge网站( http://sourceforge.net/projects/osdldbt/)上,这是一款类似某些工业标准测试的测试工具集,例如由事务处理性能委员会(TPC,Transaction Processing Performance Council)制定的各种标准。特别值得一提的是,其中的dbt2就是一款免费的TPC-C OLTP测试工具(未认证)。之前本书作者经常使用该工具,不过现在已经使用自己研发的专用于MySQL的测试工具替代了。\nPercona\u0026rsquo;s TPCC-MySQL Tool\n我们开发了一个类似TPC-C的基准测试工具集,其中有部分是专门为MySQL测试开发的。在评估大压力下MySQL的一些行为时,我们经常会利用这个工具进行测试(简单的测试,一般会采用sysbench替代)。该工具的源代码可以在 https://launchpad.net/perconatools下载,在源码库中有一个简单的文档说明。\nsysbench\nsysbench( https://launchpad.net/sysbench)是一款多线程系统压测工具。它可以根据影响数据库服务器性能的各种因素来评估系统的性能。例如,可以用来测试文件I/O、操作系统调度器、内存分配和传输速度、POSIX线程,以及数据库服务器等。sysbench支持Lua脚本语言( http://www.lua.org),Lua对于各种测试场景的设置可以非常灵活。sysbench是我们非常喜欢的一种全能测试工具,支持MySQL、操作系统和硬件的硬件测试。\nMySQL的BENCHMARK()函数\nMySQL有一个内置的BENCHMARK()函数,可以测试某些特定操作的执行速度。参数可以是需要执行的次数和表达式。表达式可以是任何的标量表达式,比如返回值是标量的子查询或者函数。该函数可以很方便地测试某些特定操作的性能,比如通过测试可以发现,MD5()函数比SHA1()函数要快:\n执行后的返回值永远是0,但可以通过客户端返回的时间来判断执行的时间。在这个例子中可以看到MD5()执行比SHA1()要快。使用BENCHMARK()函数来测试性能,需要清楚地知道其原理,否则容易误用。这个函数只是简单地返回服务器执行表达式的时间,而不会涉及分析和优化的开销。而且表达式必须像这个例子一样包含用户定义的变量,否则多次执行同样的表达式会因为系统缓存命中而影响结果(10)。\n虽然BENCHMARK()函数用起来很方便,但不合适用来做真正的基准测试,因为很难理解真正要测试的是什么,而且测试的只是整个执行周期中的一部分环节。\n2.5 基准测试案例 # 本节将演示一些利用上面提到的基准测试工具进行测试的真实案例。这些案例未必涵盖所有测试工具,但应该可以帮助读者针对自己的测试需要来做出判断和选择,并作为入门的开端。\n2.5.1 http_load # 下面通过一个简单的例子来演示如何使用http_load。首先创建一个urls.txt文件,输入如下的URL:\nhttp://www.mysqlperformanceblog.com/ http://www.mysqlperformanceblog.com/page/2/ http://www.mysqlperformanceblog.com/mysql-patches/ http://www.mysqlperformanceblog.com/mysql-performance-presentations/ http://www.mysqlperformanceblog.com/2006/09/06/slow-query-log-analyzes-tools/ http_load最简单的用法,就是循环请求给定的URL列表。测试程序将以最快的速度请求这些URL:\n** $ http_load -parallel 1 -seconds 10 urls.txt** 19 fetches, 1 max parallel, 837929 bytes, in 10.0003 seconds 44101.5 mean bytes/connection 1.89995 fetches/sec, 83790.7 bytes/sec msecs/connect: 41.6647 mean, 56.156 max, 38.21 min msecs/first-response: 320.207 mean, 508.958 max, 179.308 min HTTP response codes: code 200 - 19 测试的结果很容易理解,只是简单地输出了请求的统计信息。下面是另外一个稍微复杂的测试,还是尽可能快地循环请求给定的URL列表,不过模拟同时有五个并发用户在进行请求:\n** $ http_load -parallel 5 -seconds 10 urls.txt** 94 fetches, 5 max parallel, 4.75565e+06 bytes, in 10.0005 seconds 50592 mean bytes/connection 9.39953 fetches/sec, 475541 bytes/sec msecs/connect: 65.1983 mean, 169.991 max, 38.189 min msecs/first-response: 245.014 mean, 993.059 max, 99.646 min HTTP response codes: code 200 - 94 另外,除了测试最快的速度,也可以根据预估的访问请求率(比如每秒5次)来做压力模拟测试。\n** $ http_load -rate 5 -seconds 10 urls.txt** 48 fetches, 4 max parallel, 2.50104e+06 bytes, in 10 seconds 52105 mean bytes/connection 4.8 fetches/sec, 250104 bytes/sec msecs/connect: 42.5931 mean, 60.462 max, 38.117 min msecs/first-response: 246.811 mean, 546.203 max, 108.363 min HTTP response codes: code 200 - 48 最后,还可以模拟更大的负载,可以将访问请求率提高到每秒20次请求。请注意,连接和请求响应时间都会随着负载的提高而增加。\n** $ http_load -rate 20 -seconds 10 urls.txt** 111 fetches, 89 max parallel, 5.91142e+06 bytes, in 10.0001 seconds 53256.1 mean bytes/connection 11.0998 fetches/sec, 591134 bytes/sec msecs/connect: 100.384 mean, 211.885 max, 38.214 min msecs/first-response: 2163.51 mean, 7862.77 max, 933.708 min HTTP response codes: code 200 -- 111 2.5.2 MySQL基准测试套件 # MySQL基准测试套件(MySQL Benchmark Suite)由一组基于Perl开发的基准测试工具组成。在MySQL安装目录下的sql-bench子目录中包含了该工具。比如在Debian GNU/Linux系统上,默认的路径是*/usr/share/mysql/sql-bench*。\n在用这个工具集测试前,应该读一下README文件,了解使用方法和命令行参数说明。如果要运行全部测试,可以使用如下的命令:\n** $ cd /usr/share/mysql/sql-bench/** sql-bench$ ./run-all-tests --server=mysql --user=root --log --fast Test finished. You can find the result in: output/RUN-mysql_fast-Linux_2.4.18_686_smp_i686 运行全部测试需要比较长的时间,有可能会超过一个小时,其具体长短依赖于测试的硬件环境和配置。如果指定了*\u0026ndash;log命令行,则可以监控到测试的进度。测试的结果都保存在output*子目录中,每项测试的结果文件中都会包含一系列的操作计时信息。下面是一个具体的例子,为方便印刷,部分格式做了修改。\nsql-bench$ tail −5 output/select-mysql_fast-Linux_2.4.18_686_smp_i686 Time for count_distinct_group_on_key (1000:6000): 34 wallclock secs ( 0.20 usr 0.08 sys + 0.00 cusr 0.00 csys = 0.28 CPU) Time for count_distinct_group_on_key_parts (1000:100000): 34 wallclock secs ( 0.57 usr 0.27 sys + 0.00 cusr 0.00 csys = 0.84 CPU) Time for count_distinct_group (1000:100000): 34 wallclock secs ( 0.59 usr 0.20 sys + 0.00 cusr 0.00 csys = 0.79 CPU) Time for count_distinct_big (100:1000000): 8 wallclock secs ( 4.22 usr 2.20 sys + 0.00 cusr 0.00 csys = 6.42 CPU) Total time: 868 wallclock secs (33.24 usr 9.55 sys + 0.00 cusr 0.00 csys = 42.79 CPU) 如上所示,count_distinct_group_on_key(1000:6000)测试花费了34秒(wallclock secs),这是客户端运行测试花费的总时间;其他值(包括usr,sys,cursr,csys)则占了测试的0.28秒的开销,这是运行客户端测试代码所花费的时间,而不是等待MySQL服务器响应的时间。而测试者真正需要关心的测试结果,是除去客户端控制的部分,即实际运行时间应该是33.72秒。\n除了运行全部测试集外,也可以选择单独执行其中的部分测试项。例如可以选择只执行\ninsert测试,这会比运行全部测试集所得到的汇总信息给出更多的详细信息:\nsql-bench$ ** ./test-insert** Testing server 'MySQL 4.0.13 log' at 2003-05-18 11:02:39 Testing the speed of inserting data into 1 table and do some selects on it. The tests are done with a table that has 100000 rows. Generating random keys Creating tables Inserting 100000 rows in order Inserting 100000 rows in reverse order Inserting 100000 rows in random order Time for insert (300000): 42 wallclock secs ( 7.91 usr 5.03 sys + 0.00 cusr 0.00 csys = 12.94 CPU) Testing insert of duplicates Time for insert_duplicates (100000): 16 wallclock secs ( 2.28 usr 1.89 sys + 0.00 cusr 0.00 csys = 4.17 CPU) 2.5.3 sysbench # sysbench可以执行多种类型的基准测试,它不仅设计用来测试数据库的性能,也可以测试运行数据库的服务器的性能。实际上,Peter和Vadim最初设计这个工具是用来执行MySQL性能测试的(尽管并不能完成所有的MySQL基准测试)。下面先演示一些非MySQL的测试场景,来测试各个子系统的性能,这些测试可以用来评估系统的整体性能瓶颈。后面再演示如何测试数据库的性能。\n强烈建议大家都能熟悉sysbench测试,在MySQL用户的工具包中,这应该是最有用的工具之一。尽管有其他很多测试工具可以替代sysbench的某些功能,但那些工具有时候并不可靠,获得的结果也不一定和MySQL性能相关。例如,I/O性能测试可以用iozone、bonnie++等一系列工具,但需要注意设计场景,以便可以模拟InnoDB的磁盘I/O模式。而sysbench的I/O测试则和InnoDB的I/O模式非常类似,所以fleio选项是非常好用的。\nsysbench的CPU基准测试 # 最典型的子系统测试就是CPU基准测试。该测试使用64位整数,测试计算素数直到某个最大值所需要的时间。下面的例子将比较两台不同的GNU/Linux服务器上的测试结果。第一台机器的CPU配置如下:\n[server1 ~]$ ** cat /proc/cpuinfo** ... model name : AMD Opteron(tm) Processor 246 stepping : 1 cpu MHz : 1992.857 cache size : 1024 KB 在这台服务器上运行如下的测试:\n[server1 ~]$ ** sysbench --test=cpu --cpu-max-prime=20000 run** sysbench v0.4.8: multithreaded system evaluation benchmark ... Test execution summary: ** total time:** ** 121.7404s** 第二台服务器配置了不同的CPU:\n[server2 ~]$ ** cat /proc/cpuinfo** ... model name : Intel(R) Xeon(R) CPU 5130 @ 2.00GHz stepping : 6 cpu MHz : 1995.005 测试结果如下:\n[server1 ~]$ ** sysbench --test=cpu --cpu-max-prime=20000 run** sysbench v0.4.8: multithreaded system evaluation benchmark ... Test execution summary: ** total time:** ** 61.8596s** 测试的结果简单打印出了计算出素数的时间,很容易进行比较。在上面的测试中,第二台服务器的测试结果显示比第一台快两倍。\nsysbench的文件I/O基准测试 # 文件I/O(fileio)基准测试可以测试系统在不同I/O负载下的性能。这对于比较不同的硬盘驱动器、不同的RAID卡、不同的RAID模式,都很有帮助。可以根据测试结果来调整I/O子系统。文件I/O基准测试模拟了很多InnoDB的I/O特性。\n测试的第一步是准备(prepare)阶段,生成测试用到的数据文件,生成的数据文件至少要比内存大。如果文件中的数据能完全放入内存中,则操作系统缓存大部分的数据,导致测试结果无法体现I/O密集型的工作负载。首先通过下面的命令创建一个数据集:\n** $ sysbench --test=fileio --file-total-size=150G prepare** 这个命令会在当前工作目录下创建测试文件,后续的运行(run)阶段将通过读写这些文件进行测试。第二步就是运行(run)阶段,针对不同的I/O类型有不同的测试选项:\nseqwr\n顺序写入。\nseqrewr\n顺序重写。\nseqrd\n顺序读取。\nrndrd\n随机读取。\nrndwr\n随机写入。\nrdnrw\n混合随机读/写。\n下面的命令运行文件I/O混合随机读/写基准测试:\n** $ sysbench --test=fileio --file-total-size=150G --file-test-mode=rndrw/** ** --init-rng=on--max-time=300--max-requests=0 run** 结果如下:\nsysbench v0.4.8: multithreaded system evaluation benchmark Running the test with following options: Number of threads: 1 Initializing random number generator from timer. Extra file open flags: 0 128 files, 1.1719Gb each 150Gb total file size Block size 16Kb Number of random requests for random IO: 10000 Read/Write ratio for combined random IO test: 1.50 Periodic FSYNC enabled, calling fsync() each 100 requests. Calling fsync() at the end of test, Enabled. Using synchronous I/O mode Doing random r/w test Threads started! Time limit exceeded, exiting... Done. Operations performed: 40260 Read, 26840 Write, 85785 Other = 152885 Total Read 629.06Mb Written 419.38Mb Total transferred 1.0239Gb (3.4948Mb/sec) 223.67 Requests/sec executed Test execution summary: total time: 300.0004s total number of events: 67100 total time taken by event execution: 254.4601 per-request statistics: min: 0.0000s avg: 0.0038s max: 0.5628s approx. 95 percentile: 0.0099s Threads fairness: events (avg/stddev): 67100.0000/0.00 execution time (avg/stddev): 254.4601/0.00 输出结果中包含了大量的信息。和I/O子系统密切相关的包括每秒请求数和总吞吐量。在上述例子中,每秒请求数是223.67 Requests/sec,吞吐量是3.4948MB/sec。另外,时间信息也非常有用,尤其是大约95%的时间分布。这些数据对于评估磁盘性能十分有用。\n测试完成后,运行清除(cleanup)操作删除第一步生成的测试文件:\n** $ sysbench --test=fileio --file-total-size=150G cleanup** sysbench的OLTP基准测试 # OLTP基准测试模拟了一个简单的事务处理系统的工作负载。下面的例子使用的是一张超过百万行记录的表,第一步是先生成这张表:\n** $ sysbench --test=oltp --oltp-table-size=1000000 --mysql-db=test/** ** --mysql-user=root prepare** sysbench v0.4.8: multithreaded system evaluation benchmark No DB drivers specified, using mysql Creating table 'sbtest'... Creating 1000000 records in table 'sbtest'... 生成测试数据只需要上面这条简单的命令即可。接下来可以运行测试,这个例子采用了8个并发线程,只读模式,测试时长60秒:\n** $ sysbench --test=oltp --oltp-table-size=1000000 --mysql-db=test --mysql-user=root/** ** --max-time=60 --oltp-read-only=on --max-requests=0 --num-threads=8 run** sysbench v0.4.8: multithreaded system evaluation benchmark No DB drivers specified, using mysql WARNING: Preparing of \u0026quot;BEGIN\u0026quot; is unsupported, using emulation (last message repeated 7 times) Running the test with following options: Number of threads: 8 Doing OLTP test. Running mixed OLTP test Doing read-only test Using Special distribution (12 iterations, 1 pct of values are returned in 75 pct cases) Using \u0026quot;BEGIN\u0026quot; for starting transactions Using auto_inc on the id column Threads started! Time limit exceeded, exiting... (last message repeated 7 times) Done. OLTP test statistics: queries performed: read: 179606 write: 0 other: 25658 total: 205264 transactions: 12829 (213.07 per sec.) deadlocks: 0 (0.00 per sec.) read/write requests: 179606 (2982.92 per sec.) other operations: 25658 (426.13 per sec.) Test execution summary: total time: 60.2114s total number of events: 12829 total time taken by event execution: 480.2086 per-request statistics: min: 0.0030s avg: 0.0374s max: 1.9106s approx. 95 percentile: 0.1163s Threads fairness: events (avg/stddev): 1603.6250/70.66 execution time (avg/stddev): 60.0261/0.06 如上所示,结果中包含了相当多的信息。其中最有价值的信息如下:\n总的事务数。 每秒事务数。 时间统计信息(最小、平均、最大响应时间,以及95%百分比响应时间)。 线程公平性统计信息(thread-fairness),用于表示模拟负载的公平性。 这个例子使用的是sysbench的第4版,在SourceForge.net可以下载到这个版本的编译好的可执行文件。也可以从Launchpad下载最新的第5版的源代码自行编译(这是一件简单、有用的事情),这样就可以利用很多新版本的特性,包括可以基于多个表而不是单个表进行测试,可以每隔一定的间隔比如10秒打印出吞吐量和响应的结果。这些指标对于理解系统的行为非常重要。\nsysbench的其他特性 # sysbench还有一些其他的基准测试,但和数据库性能没有直接关系。\nmemory内存(memory)\n测试内存的连续读写性能。\n线程(thread)\n测试线程调度器的性能。对于高负载情况下测试线程调度器的行为非常有用。\n互斥锁(mutex)\n测试互斥锁(mutex)的性能,方式是模拟所有线程在同一时刻并发运行,并都短暂请求互斥锁(互斥锁mutex是一种数据结构,用来对某些资源进行排他性访问控制,防止因并发访问导致问题)。\n顺序写(seqwr)\n测试顺序写的性能。这对于测试系统的实际性能瓶颈很重要。可以用来测试RAID控制器的高速缓存的性能状况,如果测试结果异常则需要引起重视。例如,如果RAID控制器写缓存没有电池保护,而磁盘的压力达到了3000次请求/秒,就是一个问题,数据可能是不安全的。\n另外,除了指定测试模式参数(\u0026ndash;test)外,sysbench还有其他很多参数,比如*\u0026ndash;num-threads、\u0026ndash;max-requests和\u0026ndash;max-time*参数,更多信息请查阅相关文档。\n2.5.4 数据库测试套件中的dbt2 TPC-C测试 # 数据库测试套件(Database Test Suite)中的dbt2是一款免费的TPC-C测试工具。TPC-C是TPC组织发布的一个测试规范,用于模拟测试复杂的在线事务处理系统(OLTP)。它的测试结果包括每分钟事务数(tpmC),以及每事务的成本(Price/tpmC)。这种测试的结果非常依赖硬件环境,所以公开发布的TPC-C测试结果都会包含具体的系统硬件配置信息。\ndbt2并不是真正的TPC-C测试,它没有得到TPC组织的认证,它的结果不能直接跟TPC-C的结果做对比。而且本书作者开发了一款比dbt2更好的测试工具,详细情况见2.5.5节。\n下面看一个设置和运行dbt2基准测试的例子。这里使用的是dbt2 0.37版本,这个版本能够支持MySQL的最新版本(还有更新的版本,但包含了一些MySQL不能提供完全支持的修正)。下面是测试步骤。\n1.准备测试数据。\n下面的命令会在指定的目录创建用于10个仓库的数据。每个仓库使用大约700MB磁盘空间,测试所需要的总的磁盘空间和仓库的数量成正比。因此,可以通过-w参数来调整仓库的个数以生成合适大小的数据集。\n** # src/datagen -w 10 -d /mnt/data/dbt2-w10** warehouses = 10 districts = 10 customers = 3000 items = 100000 orders = 3000 stock = 100000 new_orders = 900 Output directory of data files: /mnt/data/dbt2-w10 Generating data files for 10 warehouse(s)... Generating item table data... Finished item table data... Generating warehouse table data... Finished warehouse table data... Generating stock table data... 2.加载数据到MySQL数据库。\n下面的命令创建一个名为dbt2w10的数据库,并且将上一步生成的测试数据加载到数据库中(-d参数指定数据库,-f参数指定测试数据所在的目录)。\n** # scripts/mysql/mysql_load_db.sh -d dbt2w10 -f /mnt/data/dbt2-w10/** ** -s /var/lib/mysql/mysql.sock** 3.运行测试。\n最后一步是运行scripts脚本目录中的如下命令执行测试:\n最重要的结果是输出信息中末尾处的一行:\n3396.95 new-order transactions per minute(NOTPM) 这里显示了系统每分钟可以处理的最大事务数,越大越好(new-order并非一种事务类型的专用术语,它只是表明测试是模拟用户在假想的电子商务网站下的新订单)。\n通过修改某些参数可以定制不同的基准测试。\n-c\n到数据库的连接数。修改该参数可以模拟不同程度的并发性,以测试系统的可扩展性。-e\n-e\n启用零延迟(zero-delay)模式,这意味着在不同查询之间没有时间延迟。这可以对数据库施加更大的压力,但不符合真实情况。因为真实的用户在执行一个新查询前总需要一个“思考时间(think time)”。\n-t\n基准测试的持续时间。这个参数应该精心设置,否则可能导致测试的结果是无意义的。对于I/O密集型的基准测试,太短的持续时间会导致错误的结果,因为系统可能还没有足够的时间对缓存进行预热。而对于CPU密集型的基准测试,这个时间又不应该设置得太长;否则生成的数据量过大,可能转变成I/O密集型。\n这种基准测试的结果,可以比单纯的性能测试提供更多的信息。例如,如果发现测试有很多的回滚现象,那么就可以判定很可能什么地方出现错误了。\n2.5.5 Percona的TPCC-MySQL测试工具 # 尽管sysbench的测试很简单,并且结果也具有可比性,但毕竟无法模拟真实的业务压力。相比而言,TPC-C测试则能模拟真实压力。2.5.4节谈到的dbt2是TPC-C的一个很好的实现,但也还有一些不足之处。为了满足很多大型基准测试的需求,本书的作者重新开发了一款新的类TPC-C测试工具,代码放在Launchpad上,可以通过如下地址获取: https://code.launchpad.net/~percona-dev/perconatools/tpcc-mysql,其中包含了一个README文件说明了如何编译。该工具使用很简单,但测试数据中的仓库数量很多,可能需要用到其中的并行数据加载工具来加快准备测试数据集的速度,否则这一步会花费很长时间。\n使用这个测试工具,需要创建数据库和表结构、加载数据、执行测试三个步骤。数据库和表结构通过包含在源码中的SQL脚本创建。加载数据通过用C写的tpcc_load工具完成,该工具需要自行编译。加载数据需要执行一段时间,并且会产生大量的输出信息(一般都应该将程序输出重定向到文件中,这里尤其应该如此,否则可能丢失滚动的历史信息)。下面的例子显示了配置过程,创建了一个小型(五个仓库)的测试数据集,数据库名为tpcc5。\n\u0026lt;b\u0026gt;$ ./tpcc_load localhost tpcc5 username p4ssword 5\u0026lt;/b\u0026gt; ************************************* *** ###easy### TPC-C Data Loader *** ************************************* \u0026lt;Parameters\u0026gt; [server]: localhost [port]: 3306 [DBname]: tpcc5 [user]: username [pass]: p4ssword [warehouse]: 5 TPCC Data Load Started... Loading Item .................................................. 5000 .................................................. 10000 .................................................. 15000 [output snipped for brevity] Loading Orders for D=10, W= 5 .......... 1000 .......... 2000 .......... 3000 Orders Done. ...DATA LOADING COMPLETED SUCCESSFULLY. 然后,使用tpcc_start工具开始执行基准测试。其同样会产生很多输出信息,还是建议重定向到文件中。下面是一个简单的示例,使用五个线程操作五个仓库,30秒预热时间,30秒测试时间:\n** $ ./tpcc_start localhost tpcc5 username p4ssword 5 5 30 30** *************************************** *** ###easy### TPC-C Load Generator *** *************************************** \u0026lt;Parameters\u0026gt; [server]: localhost [port]: 3306 [DBname]: tpcc5 [user]: username [pass]: p4ssword [warehouse]: 5 [connection]: 5 [rampup]: 30 (sec.) [measure]: 30 (sec.) RAMP-UP TIME.(30 sec.) MEASURING START. 10, 63(0):0.40, 63(0):0.42, 7(0):0.76, 6(0):2.60, 6(0):0.17 20, 75(0):0.40, 74(0):0.62, 7(0):0.04, 9(0):2.38, 7(0):0.75 30, 83(0):0.22, 84(0):0.37, 9(0):0.04, 7(0):1.97, 9(0):0.80 STOPPING THREADS..... \u0026lt;RT Histogram\u0026gt; 1.New-Order 2.Payment 3.Order-Status 4.Delivery 5.Stock-Level \u0026lt;90th Percentile RT (MaxRT)\u0026gt; New-Order : 0.37 (1.10) Payment : 0.47 (1.24) Order-Status : 0.06 (0.96) Delivery : 2.43 (2.72) Stock-Level : 0.75 (0.79) \u0026lt;Raw Results\u0026gt; [0] sc:221 lt:0 rt:0 fl:0 [1] sc:221 lt:0 rt:0 fl:0 [2] sc:23 lt:0 rt:0 fl:0 [3] sc:22 lt:0 rt:0 fl:0 [4] sc:22 lt:0 rt:0 fl:0 in 30 sec. \u0026lt;Raw Results2(sum ver.)\u0026gt; [0] sc:221 lt:0 rt:0 fl:0 [1] sc:221 lt:0 rt:0 fl:0 [2] sc:23 lt:0 rt:0 fl:0 [3] sc:22 lt:0 rt:0 fl:0 [4] sc:22 lt:0 rt:0 fl:0 \u0026lt;Constraint Check\u0026gt; (all must be [OK]) [transaction percentage] Payment: 43.42% (\u0026gt;=43.0%) [OK] Order-Status: 4.52% (\u0026gt;= 4.0%) [OK] Delivery: 4.32% (\u0026gt;= 4.0%) [OK] Stock-Level: 4.32% (\u0026gt;= 4.0%) [OK] [response time (at least 90% passed)] New-Order: 100.00% [OK] Payment: 100.00% [OK] Order-Status: 100.00% [OK] Delivery: 100.00% [OK] Stock-Level: 100.00% [OK] \u0026lt;TpmC\u0026gt; 442.000 TpmC 最后一行就是测试的结果:每分钟执行完的事务数(11)。如果紧挨着最后一行前发现有异常结果输出,比如有关于约束检查的信息,那么可以检查一下响应时间的直方图,或者通过其他详细输出信息寻找线索。当然,最好是能使用本章前面提到的一些脚本,这样就可以很容易获得测试执行期间的详细的诊断数据和性能数据。\n2.6 总结 # 每个MySQL的使用者都应该了解一些基准测试的知识。基准测试不仅仅是用来解决业务问题的一种实践行动,也是一种很好的学习方法。学习如何将问题分解成可以通过基准测试来获得答案的方法,就和在数学课上从文字题目中推导出方程式一样。首先正确地描述问题,之后选择合适的基准测试来回答问题,设置基准测试的持续时间和参数,运行测试,收集数据,分析结果数据,这一系列的训练可以帮助你成为更好的MySQL用户。\n如果你还没有做过基准测试,那么建议至少要熟悉sysbench。可以先学习如何使用oltp和fileio测试。oltp基准测试可以很方便地比较不同系统的性能。另一方面,文件系统和磁盘基准测试,则可以在系统出现问题时有效地诊断和隔离异常的组件。通过这样的基准测试,我们多次发现了一些数据库管理员的说法存在问题,比如SAN存储真的出现了一块坏盘,或者RAID控制器的缓存策略的配置并不是像工具中显示的那样。通过对单块磁盘进行基准测试,如果发现每秒可以执行14000次随机读,那要么是碰到了严重的错误,要么是配置出现了问题(12)。\n如果经常执行基准测试,那么制定一些原则是很有必要的。选择一些合适的测试工具并深入地学习。可以建立一个脚本库,用于配置基准测试,收集输出结果、系统性能和状态信息,以及分析结果。使用一种熟练的绘图工具如gnuplot或者R(不用浪费时间使用电子表格,它们既笨重,速度又慢)。尽量早和多地使用绘图的方式,来发现基准测试和系统中的问题和错误。你的眼睛是比任何脚本和自动化工具都更有效的发现问题的工具。\n————————————————————\n(1) 特别是一些论坛软件,已经让很多管理员错误地相信同时有成千上万的用户正在同时访问网站。\n(2) Justin Bieber,我们爱你。这只是开个玩笑。\n(3) 当然,做这么多的前提是希望获得完美的基准测试结果,实际情况通常不会很顺利。\n(4) 顺便说一下,写I/O的活动图展示的性能非常差。这个系统的稳定状态从性能上来说是一种灾难。已经达到“稳定”可以说是笑话,不过这里我们的重点在于说明系统的长期行为。\n(5) 关于pt-diskstats工具的更多信息,请参考第9章。\n(6) 有时,这并不是问题。例如,如果正在考虑从基于SPARC的Solaris系统迁移到基于x86的GNU/Linux系统,就没有必要测试基于x86的Solaris作为中间过程。\n(7) 本书的任何一位作者都还没发生过这样的事情,仅供参考。\n(8) 如果真的需要科学可靠的结果,应该去读读关于如何设计和执行可控测试的书籍,这个已经超出了本书讨论的范畴。\n(9) 英语中plot既有“阴谋”的意思,也有“绘图”的意思,所以这里是一句双关语。——译者注\n(10) 本书作者之一碰到了这个问题,因为发现循环执行1000次表达式和只执行一次表达式的时间居然差不多,这只能说明缓存命中了。实际上,当碰到此类情况时,第一反应就应当是缓存命中或者出错了。\n(11) 我们是在笔记本电脑上运行这个基准测试的,这只是作为演示用的。真实服务器的速度肯定比这快得多。\n(12) 一块机械磁盘每秒只能执行几百次的随机读操作,因为寻道操作是需要时间的。\n"},{"id":146,"href":"/zh/docs/technology/MySQL/_%E9%AB%98%E6%80%A7%E8%83%BDMySQL_/%E7%AC%AC1%E7%AB%A0MySQL%E6%9E%B6%E6%9E%84%E4%B8%8E%E5%8E%86%E5%8F%B2/","title":"第1章MySQL架构与历史","section":"高性能 My SQL","content":"第1章 MySQL架构与历史\n和其他数据库系统相比,MySQL有点与众不同,它的架构可以在多种不同场景中应用并发挥好的作用,但同时也会带来一点选择上的困难。MySQL并不完美,却足够灵活,能够适应高要求的环境,例如Web类应用。同时,MySQL既可以嵌入到应用程序中,也可以支持数据仓库、内容索引和部署软件、高可用的冗余系统、在线事务处理系统(OLTP)等各种应用类型。\n为了充分发挥MySQL的性能并顺利地使用,就必须理解其设计。MySQL的灵活性体现在很多方面。例如,你可以通过配置使它在不同的硬件上都运行得很好,也可以支持多种不同的数据类型。但是,MySQL最重要、最与众不同的特性是它的存储引擎架构,这种架构的设计将查询处理(Query Processing)及其他系统任务(Server Task)和数据的存储/提取相分离。这种处理和存储分离的设计可以在使用时根据性能、特性,以及其他需求来选择数据存储的方式。\n本章概要地描述了MySQL的服务器架构、各种存储引擎之间的主要区别,以及这些区别的重要性。另外也会回顾一下MySQL的历史背景和基准测试,并试图通过简化细节和演示案例来讨论MySQL的原理。这些讨论无论是对数据库一无所知的新手,还是熟知其他数据库的专家,都不无裨益。\n1.1 MySQL逻辑架构 # 如果能在头脑中构建出一幅MySQL各组件之间如何协同工作的架构图,就会有助于深入理解MySQL服务器。图1-1展示了MySQL的逻辑架构图。\n图1-1:MySQL服务器逻辑架构图\n最上层的服务并不是MySQL所独有的,大多数基于网络的客户端/服务器的工具或者服务都有类似的架构。比如连接处理、授权认证、安全等等。\n第二层架构是MySQL比较有意思的部分。大多数MySQL的核心服务功能都在这一层,包括查询解析、分析、优化、缓存以及所有的内置函数(例如,日期、时间、数学和加密函数),所有跨存储引擎的功能都在这一层实现:存储过程、触发器、视图等。\n第三层包含了存储引擎。存储引擎负责MySQL中数据的存储和提取。和GNU/Linux下的各种文件系统一样,每个存储引擎都有它的优势和劣势。服务器通过API与存储引擎进行通信。这些接口屏蔽了不同存储引擎之间的差异,使得这些差异对上层的查询过程透明。存储引擎API包含几十个底层函数,用于执行诸如“开始一个事务”或者“根据主键提取一行记录”等操作。但存储引擎不会去解析SQL(1),不同存储引擎之间也不会相互通信,而只是简单地响应上层服务器的请求。\n1.1.1 连接管理与安全性 # 每个客户端连接都会在服务器进程中拥有一个线程,这个连接的查询只会在这个单独的线程中执行,该线程只能轮流在某个CPU核心或者CPU中运行。服务器会负责缓存线程,因此不需要为每一个新建的连接创建或者销毁线程(2)。\n当客户端(应用)连接到MySQL服务器时,服务器需要对其进行认证。认证基于用户名、原始主机信息和密码。如果使用了安全套接字(SSL)的方式连接,还可以使用X.509证书认证。一旦客户端连接成功,服务器会继续验证该客户端是否具有执行某个特定查询的权限(例如,是否允许客户端对world数据库的Country表执行SELECT语句)。\n1.1.2 优化与执行 # MySQL会解析查询,并创建内部数据结构(解析树),然后对其进行各种优化,包括重写查询、决定表的读取顺序,以及选择合适的索引等。用户可以通过特殊的关键字提示(hint)优化器,影响它的决策过程。也可以请求优化器解释(explain)优化过程的各个因素,使用户可以知道服务器是如何进行优化决策的,并提供一个参考基准,便于用户重构查询和schema、修改相关配置,使应用尽可能高效运行。第6章我们将讨论更多优化器的细节。\n优化器并不关心表使用的是什么存储引擎,但存储引擎对于优化查询是有影响的。优化器会请求存储引擎提供容量或某个具体操作的开销信息,以及表数据的统计信息等。例如,某些存储引擎的某种索引,可能对一些特定的查询有优化。关于索引与schema的优化,请参见第4章和第5章。\n对于SELECT语句,在解析查询之前,服务器会先检查查询缓存(Query Cache),如果能够在其中找到对应的查询,服务器就不必再执行查询解析、优化和执行的整个过程,而是直接返回查询缓存中的结果集。第7章详细讨论了相关内容。\n1.2 并发控制 # 无论何时,只要有多个查询需要在同一时刻修改数据,都会产生并发控制的问题。本章的目的是讨论MySQL在两个层面的并发控制:服务器层与存储引擎层。并发控制是一个内容庞大的话题,有大量的理论文献对其进行过详细的论述。本章只简要地讨论MySQL如何控制并发读写,因此读者需要有相关的知识来理解本章接下来的内容。\n以Unix系统的email box为例,典型的mbox文件格式是非常简单的。一个mbox邮箱中的所有邮件都串行在一起,彼此首尾相连。这种格式对于读取和分析邮件信息非常友好,同时投递邮件也很容易,只要在文件末尾附加新的邮件内容即可。\n但如果两个进程在同一时刻对同一个邮箱投递邮件,会发生什么情况?显然,邮箱的数据会被破坏,两封邮件的内容会交叉地附加在邮箱文件的末尾。设计良好的邮箱投递系统会通过锁(lock)来防止数据损坏。如果客户试图投递邮件,而邮箱已经被其他客户锁住,那就必须等待,直到锁释放才能进行投递。\n这种锁的方案在实际应用环境中虽然工作良好,但并不支持并发处理。因为在任意一个时刻,只有一个进程可以修改邮箱的数据,这在大容量的邮箱系统中是个问题。\n1.2.1 读写锁 # 从邮箱中读取数据没有这样的麻烦,即使同一时刻多个用户并发读取也不会有什么问题。因为读取不会修改数据,所以不会出错。但如果某个客户正在读取邮箱,同时另外一个用户试图删除编号为25的邮件,会产生什么结果?结论是不确定,读的客户可能会报错退出,也可能读取到不一致的邮箱数据。所以,为安全起见,即使是读取邮箱也需要特别注意。\n如果把上述的邮箱当成数据库中的一张表,把邮件当成表中的一行记录,就很容易看出,同样的问题依然存在。从很多方面来说,邮箱就是一张简单的数据库表。修改数据库表中的记录,和删除或者修改邮箱中的邮件信息,十分类似。\n解决这类经典问题的方法就是并发控制,其实非常简单。在处理并发读或者写时,可以通过实现一个由两种类型的锁组成的锁系统来解决问题。这两种类型的锁通常被称为共享锁(shared lock)和排他锁(exclusive lock),也叫读锁(read lock)和写锁(write lock)。\n这里先不讨论锁的具体实现,描述一下锁的概念如下:读锁是共享的,或者说是相互不阻塞的。多个客户在同一时刻可以同时读取同一个资源,而互不干扰。写锁则是排他的,也就是说一个写锁会阻塞其他的写锁和读锁,这是出于安全策略的考虑,只有这样,才能确保在给定的时间里,只有一个用户能执行写入,并防止其他用户读取正在写入的同一资源。\n在实际的数据库系统中,每时每刻都在发生锁定,当某个用户在修改某一部分数据时,MySQL会通过锁定防止其他用户读取同一数据。大多数时候,MySQL锁的内部管理都是透明的。\n1.2.2 锁粒度 # 一种提高共享资源并发性的方式就是让锁定对象更有选择性。尽量只锁定需要修改的部分数据,而不是所有的资源。更理想的方式是,只对会修改的数据片进行精确的锁定。任何时候,在给定的资源上,锁定的数据量越少,则系统的并发程度越高,只要相互之间不发生冲突即可。\n问题是加锁也需要消耗资源。锁的各种操作,包括获得锁、检查锁是否已经解除、释放锁等,都会增加系统的开销。如果系统花费大量的时间来管理锁,而不是存取数据,那么系统的性能可能会因此受到影响。\n所谓的锁策略,就是在锁的开销和数据的安全性之间寻求平衡,这种平衡当然也会影响到性能。大多数商业数据库系统没有提供更多的选择,一般都是在表上施加行级锁(row-level lock),并以各种复杂的方式来实现,以便在锁比较多的情况下尽可能地提供更好的性能。\n而MySQL则提供了多种选择。每种MySQL存储引擎都可以实现自己的锁策略和锁粒度。在存储引擎的设计中,锁管理是个非常重要的决定。将锁粒度固定在某个级别,可以为某些特定的应用场景提供更好的性能,但同时却会失去对另外一些应用场景的良好支持。好在MySQL支持多个存储引擎的架构,所以不需要单一的通用解决方案。下面将介绍两种最重要的锁策略。\n表锁(table lock) # 表锁是MySQL中最基本的锁策略,并且是开销最小的策略。表锁非常类似于前文描述的邮箱加锁机制:它会锁定整张表。一个用户在对表进行写操作(插入、删除、更新等)前,需要先获得写锁,这会阻塞其他用户对该表的所有读写操作。只有没有写锁时,其他读取的用户才能获得读锁,读锁之间是不相互阻塞的。\n在特定的场景中,表锁也可能有良好的性能。例如,READ LOCAL表锁支持某些类型的并发写操作。另外,写锁也比读锁有更高的优先级,因此一个写锁请求可能会被插入到读锁队列的前面(写锁可以插入到锁队列中读锁的前面,反之读锁则不能插入到写锁的前面)。\n尽管存储引擎可以管理自己的锁,MySQL本身还是会使用各种有效的表锁来实现不同的目的。例如,服务器会为诸如ALTER TABLE之类的语句使用表锁,而忽略存储引擎的锁机制。\n行级锁(row lock) # 行级锁可以最大程度地支持并发处理(同时也带来了最大的锁开销)。众所周知,在InnoDB和XtraDB,以及其他一些存储引擎中实现了行级锁。行级锁只在存储引擎层实现,而MySQL服务器层(如有必要,请回顾前文的逻辑架构图)没有实现。服务器层完全不了解存储引擎中的锁实现。在本章的后续内容以及全书中,所有的存储引擎都以自己的方式显现了锁机制。\n1.3 事务 # 在理解事务的概念之前,接触数据库系统的其他高级特性还言之过早。事务就是一组原子性的SQL查询,或者说一个独立的工作单元。如果数据库引擎能够成功地对数据库应用该组查询的全部语句,那么就执行该组查询。如果其中有任何一条语句因为崩溃或其他原因无法执行,那么所有的语句都不会执行。也就是说,事务内的语句,要么全部执行成功,要么全部执行失败。\n本节的内容并非专属于MySQL,如果读者已经熟悉了事务的ACID的概念,可以直接跳转到1.3.4节。\n银行应用是解释事务必要性的一个经典例子。假设一个银行的数据库有两张表:支票(checking)表和储蓄(savings)表。现在要从用户Jane的支票账户转移200美元到她的储蓄账户,那么需要至少三个步骤:\n检查支票账户的余额高于200美元。 从支票账户余额中减去200美元。 在储蓄账户余额中增加200美元。 上述三个步骤的操作必须打包在一个事务中,任何一个步骤失败,则必须回滚所有的步骤。\n可以用START TRANSACTION语句开始一个事务,然后要么使用COMMIT提交事务将修改的数据持久保留,要么使用ROLLBACK撤销所有的修改。事务SQL的样本如下:\n1 START TRANSACTION; 2 SELECT balance FROM checking WHERE customer_id = 10233276; 3 UPDATE checking SET balance = balance - 200.00 WHERE customer_id = 10233276; 4 UPDATE savings SET balance = balance + 200.00 WHERE customer_id = 10233276; 5 COMMIT; 单纯的事务概念并不是故事的全部。试想一下,如果执行到第四条语句时服务器崩溃了,会发生什么?天知道,用户可能会损失200美元。再假如,在执行到第三条语句和第四条语句之间时,另外一个进程要删除支票账户的所有余额,那么结果可能就是银行在不知道这个逻辑的情况下白白给了Jane 200美元。\n除非系统通过严格的ACID测试,否则空谈事务的概念是不够的。ACID表示原子性(atomicity)、一致性(consistency)、隔离性(isolation)和持久性(durability)。一个运行良好的事务处理系统,必须具备这些标准特征。\n原子性(atomicity)\n一个事务必须被视为一个不可分割的最小工作单元,整个事务中的所有操作要么全部提交成功,要么全部失败回滚,对于一个事务来说,不可能只执行其中的一部分操作,这就是事务的原子性。\n一致性(consistency)\n数据库总是从一个一致性的状态转换到另外一个一致性的状态。在前面的例子中,一致性确保了,即使在执行第三、四条语句之间时系统崩溃,支票账户中也不会损失200美元,因为事务最终没有提交,所以事务中所做的修改也不会保存到数据库中。\n隔离性(isolation)\n通常来说,一个事务所做的修改在最终提交以前,对其他事务是不可见的。在前面的例子中,当执行完第三条语句、第四条语句还未开始时,此时有另外一个账户汇总程序开始运行,则其看到的支票账户的余额并没有被减去200美元。后面我们讨论隔离级别(Isolation level)的时候,会发现为什么我们要说“通常来说”是不可见的。\n持久性(durability)\n一旦事务提交,则其所做的修改就会永久保存到数据库中。此时即使系统崩溃,修改的数据也不会丢失。持久性是个有点模糊的概念,因为实际上持久性也分很多不同的级别。有些持久性策略能够提供非常强的安全保障,而有些则未必。而且不可能有能做到100%的持久性保证的策略(如果数据库本身就能做到真正的持久性,那么备份又怎么能增加持久性呢?)。在后面的一些章节中,我们会继续讨论MySQL中持久性的真正含义。\n事务的ACID特性可以确保银行不会弄丢你的钱。而在应用逻辑中,要实现这一点非常难,甚至可以说是不可能完成的任务。一个兼容ACID的数据库系统,需要做很多复杂但可能用户并没有觉察到的工作,才能确保ACID的实现。\n就像锁粒度的升级会增加系统开销一样,这种事务处理过程中额外的安全性,也会需要数据库系统做更多的额外工作。一个实现了ACID的数据库,相比没有实现ACID的数据库,通常会需要更强的CPU处理能力、更大的内存和更多的磁盘空间。正如本章不断重复的,这也正是MySQL的存储引擎架构可以发挥优势的地方。用户可以根据业务是否需要事务处理,来选择合适的存储引擎。对于一些不需要事务的查询类应用,选择一个非事务型的存储引擎,可以获得更高的性能。即使存储引擎不支持事务,也可以通过LOCK TABLES语句为应用提供一定程度的保护,这些选择用户都可以自主决定。\n1.3.1 隔离级别 # 隔离性其实比想象的要复杂。在SQL标准中定义了四种隔离级别,每一种级别都规定了一个事务中所做的修改,哪些在事务内和事务间是可见的,哪些是不可见的。较低级别的隔离通常可以执行更高的并发,系统的开销也更低。\n每种存储引擎实现的隔离级别不尽相同。如果熟悉其他的数据库产品,可能会发现某些特性和你期望的会有些不一样(但本节不打算讨论更详细的内容)。读者可以根据所选择的存储引擎,查阅相关的手册。\n下面简单地介绍一下四种隔离级别。\nREAD UNCOMMITTED(未提交读)\n在READ UNCOMMITTED级别,事务中的修改,即使没有提交,对其他事务也都是可见的。事务可以读取未提交的数据,这也被称为脏读(Dirty Read)。这个级别会导致很多问题,从性能上来说,READ UNCOMMITTED不会比其他的级别好太多,但却缺乏其他级别的很多好处,除非真的有非常必要的理由,在实际应用中一般很少使用。\nREAD COMMITTED(提交读)\n大多数数据库系统的默认隔离级别都是READ COMMITTED(但MySQL不是)。READ COMMITTED满足前面提到的隔离性的简单定义:一个事务开始时,只能“看见”已经提交的事务所做的修改。换句话说,一个事务从开始直到提交之前,所做的任何修改对其他事务都是不可见的。这个级别有时候也叫做不可重复读(nonrepeatable read),因为两次执行同样的查询,可能会得到不一样的结果。\nREPEATABLE READ(可重复读)\nREPEATABLE READ解决了脏读的问题。该级别保证了在同一个事务中多次读取同样记录的结果是一致的。但是理论上,可重复读隔离级别还是无法解决另外一个幻读(Phantom Read)的问题。所谓幻读,指的是当某个事务在读取某个范围内的记录时,另外一个事务又在该范围内插入了新的记录,当之前的事务再次读取该范围的记录时,会产生幻行(Phantom Row)。InnoDB和XtraDB存储引擎通过多版本并发控制(MVCC,Multiversion Concurrency Control)解决了幻读的问题。本章稍后会做进一步的讨论。\n可重复读是MySQL的默认事务隔离级别。\nSERIALIZABLE(可串行化)\nSERIALIZABLE是最高的隔离级别。它通过强制事务串行执行,避免了前面说的幻读的问题。简单来说,SERIALIZABLE会在读取的每一行数据上都加锁,所以可能导致大量的超时和锁争用的问题。实际应用中也很少用到这个隔离级别,只有在非常需要确保数据的一致性而且可以接受没有并发的情况下,才考虑采用该级别。\n表1-1:ANSI SQL隔离级别 1.3.2 死锁 # 死锁是指两个或者多个事务在同一资源上相互占用,并请求锁定对方占用的资源,从而导致恶性循环的现象。当多个事务试图以不同的顺序锁定资源时,就可能会产生死锁。多个事务同时锁定同一个资源时,也会产生死锁。例如,设想下面两个事务同时处理StockPrice表:\n事务1\nSTART TRANSACTION; UPDATE StockPrice SET close = 45.50 WHERE stock_id = 4 and date = '2002-05-01'; UPDATE StockPrice SET close = 19.80 WHERE stock_id = 3 and date = '2002-05-02'; COMMIT; 事务2\nSTART TRANSACTION; UPDATE StockPrice SET high = 20.12 WHERE stock_id = 3 and date = '2002-05-02'; UPDATE StockPrice SET high = 47.20 WHERE stock_id = 4 and date = '2002-05-01'; COMMIT; 如果凑巧,两个事务都执行了第一条UPDATE语句,更新了一行数据,同时也锁定了该行数据,接着每个事务都尝试去执行第二条UPDATE语句,却发现该行已经被对方锁定,然后两个事务都等待对方释放锁,同时又持有对方需要的锁,则陷入死循环。除非有外部因素介入才可能解除死锁。\n为了解决这种问题,数据库系统实现了各种死锁检测和死锁超时机制。越复杂的系统,比如InnoDB存储引擎,越能检测到死锁的循环依赖,并立即返回一个错误。这种解决方式很有效,否则死锁会导致出现非常慢的查询。还有一种解决方式,就是当查询的时间达到锁等待超时的设定后放弃锁请求,这种方式通常来说不太好。InnoDB目前处理死锁的方法是,将持有最少行级排他锁的事务进行回滚(这是相对比较简单的死锁回滚算法)。\n锁的行为和顺序是和存储引擎相关的。以同样的顺序执行语句,有些存储引擎会产生死锁,有些则不会。死锁的产生有双重原因:有些是因为真正的数据冲突,这种情况通常很难避免,但有些则完全是由于存储引擎的实现方式导致的。\n死锁发生以后,只有部分或者完全回滚其中一个事务,才能打破死锁。对于事务型的系统,这是无法避免的,所以应用程序在设计时必须考虑如何处理死锁。大多数情况下只需要重新执行因死锁回滚的事务即可。\n1.3.3 事务日志 # 事务日志可以帮助提高事务的效率。使用事务日志,存储引擎在修改表的数据时只需要修改其内存拷贝,再把该修改行为记录到持久在硬盘上的事务日志中,而不用每次都将修改的数据本身持久到磁盘。事务日志采用的是追加的方式,因此写日志的操作是磁盘上一小块区域内的顺序I/O,而不像随机I/O需要在磁盘的多个地方移动磁头,所以采用事务日志的方式相对来说要快得多。事务日志持久以后,内存中被修改的数据在后台可以慢慢地刷回到磁盘。目前大多数存储引擎都是这样实现的,我们通常称之为预写式日志(Write-Ahead Logging),修改数据需要写两次磁盘。\n如果数据的修改已经记录到事务日志并持久化,但数据本身还没有写回磁盘,此时系统崩溃,存储引擎在重启时能够自动恢复这部分修改的数据。具体的恢复方式则视存储引擎而定。\n1.3.4 MySQL中的事务 # MySQL提供了两种事务型的存储引擎:InnoDB和NDB Cluster。另外还有一些第三方存储引擎也支持事务,比较知名的包括XtraDB和PBXT。后面将详细讨论它们各自的一些特点。\n自动提交(AUTOCOMMIT) # MySQL默认采用自动提交(AUTOCOMMIT)模式。也就是说,如果不是显式地开始一个事务,则每个查询都被当作一个事务执行提交操作。在当前连接中,可以通过设置AUTOCOMMIT变量来启用或者禁用自动提交模式:\n1或者ON表示启用,0或者OFF表示禁用。当AUTOCOMMIT=0时,所有的查询都是在一个事务中,直到显式地执行COMMIT提交或者ROLLBACK回滚,该事务结束,同时又开始了另一个新事务。修改AUTOCOMMIT对非事务型的表,比如MyISAM或者内存表,不会有任何影响。对这类表来说,没有COMMIT或者ROLLBACK的概念,也可以说是相当于一直处于AUTOCOMMIT启用的模式。\n另外还有一些命令,在执行之前会强制执行COMMIT提交当前的活动事务。典型的例子,在数据定义语言(DDL)中,如果是会导致大量数据改变的操作,比如ALTER TABLE,就是如此。另外还有LOCK TABLES等其他语句也会导致同样的结果。如果有需要,请检查对应版本的官方文档来确认所有可能导致自动提交的语句列表。\nMySQL可以通过执行SET TRANSACTION ISOLATION LEVEL命令来设置隔离级别。新的隔离级别会在下一个事务开始的时候生效。可以在配置文件中设置整个数据库的隔离级别,也可以只改变当前会话的隔离级别:\nmysql\u0026gt; ** SET SESSION TRANSACTION ISOLATION LEVEL READ COMMITTED;** MySQL能够识别所有的4个ANSI隔离级别,InnoDB引擎也支持所有的隔离级别。\n在事务中混合使用存储引擎 # MySQL服务器层不管理事务,事务是由下层的存储引擎实现的。所以在同一个事务中,使用多种存储引擎是不可靠的。\n如果在事务中混合使用了事务型和非事务型的表(例如InnoDB和MyISAM表),在正常提交的情况下不会有什么问题。\n但如果该事务需要回滚,非事务型的表上的变更就无法撤销,这会导致数据库处于不一致的状态,这种情况很难修复,事务的最终结果将无法确定。所以,为每张表选择合适的存储引擎非常重要。\n在非事务型的表上执行事务相关操作的时候,MySQL通常不会发出提醒,也不会报错。有时候只有回滚的时候才会发出一个警告:“某些非事务型的表上的变更不能被回滚”。但大多数情况下,对非事务型表的操作都不会有提示。\n隐式和显式锁定 # InnoDB采用的是两阶段锁定协议(two-phase locking protocol)。在事务执行过程中,随时都可以执行锁定,锁只有在执行COMMIT或者ROLLBACK的时候才会释放,并且所有的锁是在同一时刻被释放。前面描述的锁定都是隐式锁定,InnoDB会根据隔离级别在需要的时候自动加锁。\n另外,InnoDB也支持通过特定的语句进行显式锁定,这些语句不属于SQL规范(3):\nSELECT \u0026hellip; LOCK IN SHARE MODE SELECT \u0026hellip; FOR UPDATE MySQL也支持LOCK TABLES和UNLOCK TABLES语句,这是在服务器层实现的,和存储引擎无关。它们有自己的用途,但并不能替代事务处理。如果应用需要用到事务,还是应该选择事务型存储引擎。\n经常可以发现,应用已经将表从MyISAM转换到InnoDB,但还是显式地使用LOCK TABLES语句。这不但没有必要,还会严重影响性能,实际上InnoDB的行级锁工作得更好。\nLOCK TABLES和事务之间相互影响的话,情况会变得非常复杂,在某些MySQL版本中甚至会产生无法预料的结果。因此,本书建议,除了事务中禁用了AUTOCOMMIT,可以使用LOCK TABLES之外,其他任何时候都不要显式地执行LOCK TABLES,不管使用的是什么存储引擎。\n1.4 多版本并发控制 # MySQL的大多数事务型存储引擎实现的都不是简单的行级锁。基于提升并发性能的考虑,它们一般都同时实现了多版本并发控制(MVCC)。不仅是MySQL,包括Oracle、PostgreSQL等其他数据库系统也都实现了MVCC,但各自的实现机制不尽相同,因为MVCC没有一个统一的实现标准。\n可以认为MVCC是行级锁的一个变种,但是它在很多情况下避免了加锁操作,因此开销更低。虽然实现机制有所不同,但大都实现了非阻塞的读操作,写操作也只锁定必要的行。\nMVCC的实现,是通过保存数据在某个时间点的快照来实现的。也就是说,不管需要执行多长时间,每个事务看到的数据都是一致的。根据事务开始的时间不同,每个事务对同一张表,同一时刻看到的数据可能是不一样的。如果之前没有这方面的概念,这句话听起来就有点迷惑。熟悉了以后会发现,这句话其实还是很容易理解的。\n前面说到不同存储引擎的MVCC实现是不同的,典型的有乐观(optimistic)并发控制和悲观(pessimistic)并发控制。下面我们通过InnoDB的简化版行为来说明MVCC是如何工作的。\nInnoDB的MVCC,是通过在每行记录后面保存两个隐藏的列来实现的。这两个列,一个保存了行的创建时间,一个保存行的过期时间(或删除时间)。当然存储的并不是实际的时间值,而是系统版本号(system version number)。每开始一个新的事务,系统版本号都会自动递增。事务开始时刻的系统版本号会作为事务的版本号,用来和查询到的每行记录的版本号进行比较。下面看一下在REPEATABLE READ隔离级别下,MVCC具体是如何操作的。\nSELECT\nInnoDB会根据以下两个条件检查每行记录:\nInnoDB只查找版本早于当前事务版本的数据行(也就是,行的系统版本号小于或等于事务的系统版本号),这样可以确保事务读取的行,要么是在事务开始前已经存在的,要么是事务自身插入或者修改过的。 行的删除版本要么未定义,要么大于当前事务版本号。这可以确保事务读取到的行,在事务开始之前未被删除。 只有符合上述两个条件的记录,才能返回作为查询结果。\nINSERT\nInnoDB为新插入的每一行保存当前系统版本号作为行版本号。\nDELETE\nInnoDB为删除的每一行保存当前系统版本号作为行删除标识。\nUPDATE\nInnoDB为插入一行新记录,保存当前系统版本号作为行版本号,同时保存当前系统版本号到原来的行作为行删除标识。\n保存这两个额外系统版本号,使大多数读操作都可以不用加锁。这样设计使得读数据操作很简单,性能很好,并且也能保证只会读取到符合标准的行。不足之处是每行记录都需要额外的存储空间,需要做更多的行检查工作,以及一些额外的维护工作。\nMVCC只在REPEATABLE READ和READ COMMITTED两个隔离级别下工作。其他两个隔离级别都和MVCC不兼容(4),因为READ UNCOMMITTED总是读取最新的数据行,而不是符合当前事务版本的数据行。而SERIALIZABLE则会对所有读取的行都加锁。\n1.5 MySQL的存储引擎 # 本节只是概要地描述MySQL的存储引擎,而不会涉及太多细节。因为关于存储引擎的讨论及其相关特性将会贯穿全书,而且本书也不是存储引擎的完全指南,所以有必要阅读相关存储引擎的官方文档。\n在文件系统中,MySQL将每个数据库(也可以称之为schema)保存为数据目录下的一个子目录。创建表时,MySQL会在数据库子目录下创建一个和表同名的*.frm文件保存表的定义。例如创建一个名为MyTable的表,MySQL会在MyTable.frm*文件中保存该表的定义。因为MySQL使用文件系统的目录和文件来保存数据库和表的定义,大小写敏感性和具体的平台密切相关。在Windows中,大小写是不敏感的;而在类Unix中则是敏感的。不同的存储引擎保存数据和索引的方式是不同的,但表的定义则是在MySQL服务层统一处理的。\n可以使用SHOW TABLE STATUS命令(在MySQL 5.0以后的版本中,也可以查询INFORMATION_SCHEMA中对应的表)显示表的相关信息。例如,对于mysql数据库中的user表:\nmysql\u0026gt; ** SHOW TABLE STATUS LIKE 'user' \\G** *************************** 1. row *************************** Name: user Engine: MyISAM Row_format: Dynamic Rows: 6 Avg_row_length: 59 Data_length: 356 Max_data_length: 4294967295 Index_length: 2048 Data_free: 0 Auto_increment: NULL Create_time: 2002-01-24 18:07:17 Update_time: 2002-01-24 21:56:29 Check_time: NULL Collation: utf8_bin Checksum: NULL Create_options: Comment: Users and global privileges 1 row in set (0.00 sec) 输出的结果表明,这是一个MyISAM表。输出中还有很多其他信息以及统计信息。下面简单介绍一下每一行的含义。\nName\n表名。\nEngine\n表的存储引擎类型。在旧版本中,该列的名字叫Type,而不是Engine。\nRow_format\n行的格式。对于MyISAM表,可选的值为Dynamic、Fixed或者Compressed。Dynamic的行长度是可变的,一般包含可变长度的字段,如VARCHAR或BLOB。Fixed的行长度则是固定的,只包含固定长度的列,如CHAR和INTEGER。Compressed的行则只在压缩表中存在,请参考第19页“MyISAM压缩表”一节。\nRows\n表中的行数。对于MyISAM和其他一些存储引擎,该值是精确的,但对于InnoDB,该值是估计值。\nAvg_row_length\n平均每行包含的字节数。\nData_length\n表数据的大小(以字节为单位)。\nMax_data_length\n表数据的最大容量,该值和存储引擎有关。\nIndex_length\n索引的大小(以字节为单位)。\nData_free\n对于MyISAM表,表示已分配但目前没有使用的空间。这部分空间包括了之前删除的行,以及后续可以被INSERT利用到的空间。\nAuto_increment\n下一个AUTO_INCREMENT的值。\nCreate_time\n表的创建时间。\nUpdate_time\n表数据的最后修改时间。\nCheck_time\n使用CKECK TABLE命令或者myisamchk工具最后一次检查表的时间。\nCollation\n表的默认字符集和字符列排序规则。\nChecksum\n如果启用,保存的是整个表的实时校验和。\nCreate_options\n创建表时指定的其他选项。\nComment\n该列包含了一些其他的额外信息。对于MyISAM表,保存的是表在创建时带的注释。对于InnoDB表,则保存的是InnoDB表空间的剩余空间信息。如果是一个视图,则该列包含“VIEW”的文本字样。\n1.5.1 InnoDB存储引擎 # InnoDB是MySQL的默认事务型引擎,也是最重要、使用最广泛的存储引擎。它被设计用来处理大量的短期(short-lived)事务,短期事务大部分情况是正常提交的,很少会被回滚。InnoDB的性能和自动崩溃恢复特性,使得它在非事务型存储的需求中也很流行。除非有非常特别的原因需要使用其他的存储引擎,否则应该优先考虑InnoDB引擎。如果要学习存储引擎,InnoDB也是一个非常好的值得花最多的时间去深入学习的对象,收益肯定比将时间平均花在每个存储引擎的学习上要高得多。\nInnoDB的历史 # InnoDB有着复杂的发布历史,了解一下这段历史对于理解InnoDB很有帮助。2008年,发布了所谓的InnoDB plugin,适用于MySQL 5.1版本,但这是Oracle创建的下一代InnoDB引擎,其拥有者是InnoDB而不是MySQL。这基于很多原因,这些原因如果要一一道来,恐怕得喝掉好几桶啤酒。MySQL默认还是选择了集成旧的InnoDB引擎。当然用户可以自行选择使用新的性能更好、扩展性更佳的InnoDB plugin来覆盖旧的版本。直到最后,在Oracle收购了Sun公司后发布的MySQL 5.5中才彻底使用InnoDB plugin替代了旧版本的InnoDB(是的,这也意味着InnoDB plugin已经是原生编译了,而不是编译成一个插件,但名字已经约定俗成很难更改)。\n这个现代的InnoDB版本,也就是MySQL 5.1中所谓的InnoDB plugin,支持一些新特性,诸如利用排序创建索引(building index by sorting)、删除或者增加索引时不需要复制全表数据、新的支持压缩的存储格式、新的大型列值如BLOB的存储方式,以及文件格式管理等。很多用户在MySQL 5.1中没有使用InnoDB plugin,或许是因为他们没有注意到有这个区别。所以如果你使用的是MySQL 5.1,一定要使用InnoDB plugin,真的比旧版本的InnoDB要好很多。\nInnoDB是一个很重要的存储引擎,很多个人和公司都对其贡献代码,而不仅仅是Oracle公司的开发团队。一些重要的贡献者包括Google、Yasufumi Kinoshita、Percona、Facebook等,他们的一些改进被直接移植到官方版本,也有一些由InnoDB团队重新实现。在过去的几年间,InnoDB的改进速度大大加快,主要的改进集中在可测量性、可扩展性、可配置化、性能、各种新特性和对Windows的支持等方面。MySQL 5.6实验室预览版和里程碑版也包含了一系列重要的InnoDB新特性。\n为改善InnoDB的性能,Oracle投入了大量的资源,并做了很多卓有成效的工作(外部贡献者对此也提供了很大的帮助)。在本书的第二版中,我们注意到在超过四核CPU的系统中InnoDB表现不佳,而现在已经可以很好地扩展至24核的系统,甚至在某些场景,32核或者更多核的系统中也表现良好。很多改进将在即将发布的MySQL 5.6中引入,当然也还有机会做更进一步的改善。\nInnoDB概览 # InnoDB的数据存储在表空间(tablespace)中,表空间是由InnoDB管理的一个黑盒子,由一系列的数据文件组成。在MySQL 4.1以后的版本中,InnoDB可以将每个表的数据和索引存放在单独的文件中。InnoDB也可以使用裸设备作为表空间的存储介质,但现代的文件系统使得裸设备不再是必要的选择。\nInnoDB采用MVCC来支持高并发,并且实现了四个标准的隔离级别。其默认级别是REPEATABLE READ(可重复读),并且通过间隙锁(next-key locking)策略防止幻读的出现。间隙锁使得InnoDB不仅仅锁定查询涉及的行,还会对索引中的间隙进行锁定,以防止幻影行的插入。\nInnoDB表是基于聚簇索引建立的,我们会在后面的章节详细讨论聚簇索引。InnoDB的索引结构和MySQL的其他存储引擎有很大的不同,聚簇索引对主键查询有很高的性能。不过它的二级索引(secondary index,非主键索引)中必须包含主键列,所以如果主键列很大的话,其他的所有索引都会很大。因此,若表上的索引较多的话,主键应当尽可能的小。InnoDB的存储格式是平台独立的,也就是说可以将数据和索引文件从Intel平台复制到PowerPC或者Sun SPARC平台。\nInnoDB内部做了很多优化,包括从磁盘读取数据时采用的可预测性预读,能够自动在内存中创建hash索引以加速读操作的自适应哈希索引(adaptive hash index),以及能够加速插入操作的插入缓冲区(insert buffer)等。本书后面将更详细地讨论这些内容。InnoDB的行为是非常复杂的,不容易理解。如果使用了InnoDB引擎,笔者强烈建议阅读官方手册中的“InnoDB事务模型和锁”一节。如果应用程序基于InnoDB构建,则事先了解一下InnoDB的MVCC架构带来的一些微妙和细节之处是非常有必要的。存储引擎要为所有用户甚至包括修改数据的用户维持一致性的视图,是非常复杂的工作。\n作为事务型的存储引擎,InnoDB通过一些机制和工具支持真正的热备份,Oracle提供的MySQL Enterprise Backup、Percona提供的开源的XtraBackup都可以做到这一点。MySQL的其他存储引擎不支持热备份,要获取一致性视图需要停止对所有表的写入,而在读写混合场景中,停止写入可能也意味着停止读取。\n1.5.2 MyISAM存储引擎 # 在MySQL 5.1及之前的版本,MyISAM是默认的存储引擎。MyISAM提供了大量的特性,包括全文索引、压缩、空间函数(GIS)等,但MyISAM不支持事务和行级锁,而且有一个毫无疑问的缺陷就是崩溃后无法安全恢复。正是由于MyISAM引擎的缘故,即使MySQL支持事务已经很长时间了,在很多人的概念中MySQL还是非事务型的数据库。尽管MyISAM引擎不支持事务、不支持崩溃后的安全恢复,但它绝不是一无是处的。对于只读的数据,或者表比较小、可以忍受修复(repair)操作,则依然可以继续使用MyISAM(但请不要默认使用MyISAM,而是应当默认使用InnoDB)。\n存储 # MyISAM会将表存储在两个文件中:数据文件和索引文件,分别以*.MYD和.MYI*为扩展名。MyISAM表可以包含动态或者静态(长度固定)行。MySQL会根据表的定义来决定采用何种行格式。MyISAM表可以存储的行记录数,一般受限于可用的磁盘空间,或者操作系统中单个文件的最大尺寸。\n在MySQL 5.0中,MyISAM表如果是变长行,则默认配置只能处理256TB的数据,因为指向数据记录的指针长度是6个字节。而在更早的版本中,指针长度默认是4字节,所以只能处理4GB的数据。而所有的MySQL版本都支持8字节的指针。要改变MyISAM表指针的长度(调高或者调低),可以通过修改表的MAX_ROWS和AVG_ROW_LENGTH选项的值来实现,两者相乘就是表可能达到的最大大小。修改这两个参数会导致重建整个表和表的所有索引,这可能需要很长的时间才能完成。\nMyISAM特性 # 作为MySQL最早的存储引擎之一,MyISAM有一些已经开发出来很多年的特性,可以满足用户的实际需求。\n加锁与并发\nMyISAM对整张表加锁,而不是针对行。读取时会对需要读到的所有表加共享锁,写入时则对表加排他锁。但是在表有读取查询的同时,也可以往表中插入新的记录(这被称为并发插入,CONCURRENT INSERT)。\n修复\n对于MyISAM表,MySQL可以手工或者自动执行检查和修复操作,但这里说的修复和事务恢复以及崩溃恢复是不同的概念。执行表的修复可能导致一些数据丢失,而且修复操作是非常慢的。可以通过CHECK TABLE mytable检查表的错误,如果有错误可以通过执行REPAIR TABLE mytable进行修复。另外,如果MySQL服务器已经关闭,也可以通过myisamchk命令行工具进行检查和修复操作。\n索引特性\n对于MyISAM表,即使是BLOB和TEXT等长字段,也可以基于其前500个字符创建索引。MyISAM也支持全文索引,这是一种基于分词创建的索引,可以支持复杂的查询。关于索引的更多信息请参考第5章。\n延迟更新索引键(Delayed Key Write)\n创建MyISAM表的时候,如果指定了DELAY_KEY_WRITE选项,在每次修改执行完成时,不会立刻将修改的索引数据写入磁盘,而是会写到内存中的键缓冲区(in-memory key buffer),只有在清理键缓冲区或者关闭表的时候才会将对应的索引块写入到磁盘。这种方式可以极大地提升写入性能,但是在数据库或者主机崩溃时会造成索引损坏,需要执行修复操作。延迟更新索引键的特性,可以在全局设置,也可以为单个表设置。\nMyISAM压缩表 # 如果表在创建并导入数据以后,不会再进行修改操作,那么这样的表或许适合采用MyISAM压缩表。\n可以使用myisampack对MyISAM表进行压缩(也叫打包pack)。压缩表是不能进行修改的(除非先将表解除压缩,修改数据,然后再次压缩)。压缩表可以极大地减少磁盘空间占用,因此也可以减少磁盘I/O,从而提升查询性能。压缩表也支持索引,但索引也是只读的。\n以现在的硬件能力,对大多数应用场景,读取压缩表数据时的解压带来的开销影响并不大,而减少I/O带来的好处则要大得多。压缩时表中的记录是独立压缩的,所以读取单行的时候不需要去解压整个表(甚至也不解压行所在的整个页面)。\nMyISAM性能 # MyISAM引擎设计简单,数据以紧密格式存储,所以在某些场景下的性能很好。MyISAM有一些服务器级别的性能扩展限制,比如对索引键缓冲区(key cache)的Mutex锁,MariaDB基于段(segment)的索引键缓冲区机制来避免该问题。但MyISAM最典型的性能问题还是表锁的问题,如果你发现所有的查询都长期处于“Locked”状态,那么毫无疑问表锁就是罪魁祸首。\n1.5.3 MySQL内建的其他存储引擎 # MySQL还有一些有特殊用途的存储引擎。在新版本中,有些可能因为一些原因已经不再支持;另外还有些会继续支持,但是需要明确地启用后才能使用。\nArchive引擎 # Archive存储引擎只支持INSERT和SELECT操作,在MySQL 5.1之前也不支持索引。\nArchive引擎会缓存所有的写并利用zlib对插入的行进行压缩,所以比MyISAM表的磁盘I/O更少。但是每次SELECT查询都需要执行全表扫描。所以Archive表适合日志和数据采集类应用,这类应用做数据分析时往往需要全表扫描。或者在一些需要更快速的INSERT操作的场合下也可以使用。\nArchive引擎支持行级锁和专用的缓冲区,所以可以实现高并发的插入。在一个查询开始直到返回表中存在的所有行数之前,Archive引擎会阻止其他的SELECT执行,以实现一致性读。另外,也实现了批量插入在完成之前对读操作是不可见的。这种机制模仿了事务和MVCC的一些特性,但Archive引擎不是一个事务型的引擎,而是一个针对高速插入和压缩做了优化的简单引擎。\nBlackhole引擎 # Blackhole引擎没有实现任何的存储机制,它会丢弃所有插入的数据,不做任何保存。但是服务器会记录Blackhole表的日志,所以可以用于复制数据到备库,或者只是简单地记录到日志。这种特殊的存储引擎可以在一些特殊的复制架构和日志审核时发挥作用。但这种应用方式我们碰到过很多问题,因此并不推荐。\nCSV引擎 # CSV引擎可以将普通的CSV文件(逗号分割值的文件)作为MySQL的表来处理,但这种表不支持索引。CSV引擎可以在数据库运行时拷入或者拷出文件。可以将Excel等电子表格软件中的数据存储为CSV文件,然后复制到MySQL数据目录下,就能在MySQL中打开使用。同样,如果将数据写入到一个CSV引擎表,其他的外部程序也能立即从表的数据文件中读取CSV格式的数据。因此CSV引擎可以作为一种数据交换的机制,非常有用。\nFederated引擎 # Federated引擎是访问其他MySQL服务器的一个代理,它会创建一个到远程MySQL服务器的客户端连接,并将查询传输到远程服务器执行,然后提取或者发送需要的数据。最初设计该存储引擎是为了和企业级数据库如Microsoft SQL Server和Oracle的类似特性竞争的,可以说更多的是一种市场行为。尽管该引擎看起来提供了一种很好的跨服务器的灵活性,但也经常带来问题,因此默认是禁用的。MariaDB使用了它的一个后续改进版本,叫做FederatedX。\nMemory引擎 # 如果需要快速地访问数据,并且这些数据不会被修改,重启以后丢失也没有关系,那么使用Memory表(以前也叫做HEAP表)是非常有用的。Memory表至少比MyISAM表要快一个数量级,因为所有的数据都保存在内存中,不需要进行磁盘I/O。Memory表的结构在重启以后还会保留,但数据会丢失。\nMemroy表在很多场景可以发挥好的作用:\n用于查找(lookup)或者映射(mapping)表,例如将邮编和州名映射的表。 用于缓存周期性聚合数据(periodically aggregated data)的结果。 用于保存数据分析中产生的中间数据。 Memory表支持Hash索引,因此查找操作非常快。虽然Memory表的速度非常快,但还是无法取代传统的基于磁盘的表。Memroy表是表级锁,因此并发写入的性能较低。它不支持BLOB或TEXT类型的列,并且每行的长度是固定的,所以即使指定了VARCHAR列,实际存储时也会转换成CHAR,这可能导致部分内存的浪费(其中一些限制在Percona版本已经解决)。\n如果MySQL在执行查询的过程中需要使用临时表来保存中间结果,内部使用的临时表就是Memory表。如果中间结果太大超出了Memory表的限制,或者含有BLOB或TEXT字段,则临时表会转换成MyISAM表。在后续的章节还会继续讨论该问题。\n人们经常混淆Memory表和临时表。临时表是指使用CREATE TEMPORARY TABLE语句创建的表,它可以使用任何存储引擎,因此和Memory表不是一回事。临时表只在单个连接中可见,当连接断开时,临时表也将不复存在。\nMerge引擎 # Merge引擎是MyISAM引擎的一个变种。Merge表是由多个MyISAM表合并而来的虚拟表。如果将MySQL用于日志或者数据仓库类应用,该引擎可以发挥作用。但是引入分区功能后,该引擎已经被放弃(参考第7章)。\nNDB集群引擎 # 2003年,当时的MySQL AB公司从索尼爱立信公司收购了NDB数据库,然后开发了NDB集群存储引擎,作为SQL和NDB原生协议之间的接口。MySQL服务器、NDB集群存储引擎,以及分布式的、share-nothing的、容灾的、高可用的NDB数据库的组合,被称为MySQL集群(MySQL Cluster)。本书后续会有章节专门来讨论MySQL集群。\n1.5.4 第三方存储引擎 # MySQL从2007年开始提供了插件式的存储引擎API,从此涌出了一系列为不同目的而设计的存储引擎。其中有一些已经合并到MySQL服务器,但大多数还是第三方产品或者开源项目。下面探讨一些我们认为在它设计的场景中确实很有用的第三方存储引擎。\nOLTP类引擎 # Percona的XtraDB存储引擎是基于InnoDB引擎的一个改进版本,已经包含在Percona Server和MariaDB中,它的改进点主要集中在性能、可测量性和操作灵活性方面。XtraDB可以作为InnoDB的一个完全的替代产品,甚至可以兼容地读写InnoDB的数据文件,并支持InnoDB的所有查询。\n另外还有一些和InnoDB非常类似的OLTP类存储引擎,比如都支持ACID事务和MVCC。其中一个就是PBXT,由Paul McCullagh和Primebase GMBH开发。它支持引擎级别的复制、外键约束,并且以一种比较复杂的架构对固态存储(SSD)提供了适当的支持,还对较大的值类型如BLOB也做了优化。PBXT是一款社区支持的存储引擎,MariaDB包含了该引擎。\nTokuDB引擎使用了一种新的叫做分形树(Fractal Trees)的索引数据结构。该结构是缓存无关的,因此即使其大小超过内存性能也不会下降,也就没有内存生命周期和碎片的问题。TokuDB是一种大数据(Big Data)存储引擎,因为其拥有很高的压缩比,可以在很大的数据量上创建大量索引。在本书写作时,这个引擎还处于早期的生产版本状态,在并发性方面还有很多明显的限制。目前其最适合在需要大量插入数据的分析型数据集的场景中使用,不过这些限制可能在后续版本中解决掉。\nRethinkDB最初是为固态存储(SSD)而设计的,然而随着时间的推移,目前看起来和最初的目标有一定的差距。该引擎比较特别的地方在于采用了一种只能追加的写时复制B树(append-only copyon-write B-Tree)作为索引的数据结构。目前还处于早期开发状态,我们还没有测试评估过,也没有听说有实际的应用案例。\n在Sun收购MySQL AB以后,Falcon存储引擎曾经作为下一代存储引擎被寄予期望,但现在该项目已经被取消很久了。Falcon的主要设计者Jim Starkey创立了一家新公司,主要做可以支持云计算的NewSQL数据库产品,叫做NuoDB(之前叫NimbusDB)。\n面向列的存储引擎 # MySQL默认是面向行的,每一行的数据是一起存储的,服务器的查询也是以行为单位处理的。而在大数据量处理时,面向列的方式可能效率更高。如果不需要整行的数据,面向列的方式可以传输更少的数据。如果每一列都单独存储,那么压缩的效率也会更高。\nInfobright是最有名的面向列的存储引擎。在非常大的数据量(数十TB)时,该引擎工作良好。Infobright是为数据分析和数据仓库应用设计的。数据高度压缩,按照块进行排序,每个块都对应有一组元数据。在处理查询时,访问元数据可决定跳过该块,甚至可能只需要元数据即可满足查询的需求。但该引擎不支持索引,不过在这么大的数据量级,即使有索引也很难发挥作用,而且块结构也是一种准索引(quasi-index)。Infobright需要对MySQL服务器做定制,因为一些地方需要修改以适应面向列存储的需要。如果查询无法在存储层使用面向列的模式执行,则需要在服务器层转换成按行处理,这个过程会很慢。Infobright有社区版和商业版两个版本。\n另外一个面向列的存储引擎是Calpont公司的InfiniDB,也有社区版和商业版。InfiniDB可以在一组机器集群间做分布式查询,但目前还没有生产环境的应用案例。\n顺便提一下,在MySQL之外,如果有面向列的存储的需求,我们也评估过LucidDB和MonetDB。在我们的MySQL性能博客(5)上有相应的性能测试数据,或许随着时间的推移,这些数据慢慢会过期,但依然可以作为参考。\n社区存储引擎 # 如果要列举社区提供的所有存储引擎,可能会有两位数,甚至三位数。但是负责任地说,其中大部分影响力有限,很多可能都没有听说过,或者只有极少人在使用。在这里列举了一些,也大都没有在生产环境中应用过,慎用,后果自负。\nAria\n之前的名字是Maria,是MySQL创建者计划用来替代MyISAM的一款引擎。MariaDB包含了该引擎,之前计划开发的很多特性,有些因为在MariaDB服务器层实现,所以引擎层就取消了。在本书写作之际,可以说Aria就是解决了崩溃安全恢复问题的MyISAM,当然也还有一些特性是MyISAM不具备的,比如数据的缓存(MyISAM只能缓存索引)。\nGroonga\n这是一款全文索引引擎,号称可以提供准确而高效的全文索引。\nOQGraph\n该引擎由Open Query研发,支持图操作(比如查找两点之间的最短路径),用SQL很难实现该类操作。\nQ4M\n该引擎在MySQL内部实现了队列操作,而用SQL很难在一个语句实现这类队列操作。\nSphinxSE\n该引擎为Sphinx全文索引搜索服务器提供了SQL接口,在附录F中将做进一步的详细讨论。\nSpider\n该引擎可以将数据切分成不同的分区,比较高效透明地实现了分片(shard),并且可以针对分片执行并行查询(分片可以分布在不同的服务器上)。\nVPForMySQL\n该引擎支持垂直分区,通过一系列的代理存储引擎实现。垂直分区指的是可以将表分成不同列的组合,并且单独存储。但对查询来说,看到的还是一张表。该引擎和Spider的作者是同一人。\n1.5.5 选择合适的引擎 # 这么多存储引擎,我们怎么选择?大部分情况下,InnoDB都是正确的选择,所以Oracle在MySQL 5.5版本时终于将InnoDB作为默认的存储引擎了。对于如何选择存储引擎,可以简单地归纳为一句话:“除非需要用到某些InnoDB不具备的特性,并且没有其他办法可以替代,否则都应该优先选择InnoDB引擎”。例如,如果要用到全文索引,建议优先考虑InnoDB加上Sphinx的组合,而不是使用支持全文索引的MyISAM。当然,如果不需要用到InnoDB的特性,同时其他引擎的特性能够更好地满足需求,也可以考虑一下其他存储引擎。举个例子,如果不在乎可扩展能力和并发能力,也不在乎崩溃后的数据丢失问题,却对InnoDB的空间占用过多比较敏感,这种场合下选择MyISAM就比较合适。\n除非万不得已,否则建议不要混合使用多种存储引擎,否则可能带来一系列复杂的问题,以及一些潜在的bug和边界问题。存储引擎层和服务器层的交互已经比较复杂,更不用说混合多个存储引擎了。至少,混合存储对一致性备份和服务器参数配置都带来了一些困难。\n如果应用需要不同的存储引擎,请先考虑以下几个因素。\n事务\n如果应用需要事务支持,那么InnoDB(或者XtraDB)是目前最稳定并且经过验证的选择。如果不需要事务,并且主要是SELECT和INSERT操作,那么MyISAM是不错的选择。一般日志型的应用比较符合这一特性。\n备份\n备份的需求也会影响存储引擎的选择。如果可以定期地关闭服务器来执行备份,那么备份的因素可以忽略。反之,如果需要在线热备份,那么选择InnoDB就是基本的要求。\n崩溃恢复\n数据量比较大的时候,系统崩溃后如何快速地恢复是一个需要考虑的问题。相对而言,MyISAM崩溃后发生损坏的概率比InnoDB要高很多,而且恢复速度也要慢。因此,即使不需要事务支持,很多人也选择InnoDB引擎,这是一个非常重要的因素。\n特有的特性\n最后,有些应用可能依赖一些存储引擎所独有的特性或者优化,比如很多应用依赖聚簇索引的优化。另外,MySQL中也只有MyISAM支持地理空间搜索。如果一个存储引擎拥有一些关键的特性,同时却又缺乏一些必要的特性,那么有时候不得不做折中的考虑,或者在架构设计上做一些取舍。某些存储引擎无法直接支持的特性,有时候通过变通也可以满足需求。\n你不需要现在就做决定。本书接下来会提供很多关于各种存储引擎优缺点的详细描述,也会讨论一些架构设计的技巧。一般来说,可能有很多选项你还没有意识到,等阅读完本书回头再来看这个问题可能更有帮助些。如果无法确定,那么就使用InnoDB,这个默认选项是安全的,尤其是搞不清楚具体需要什么的时候。\n如果不了解具体的应用,上面提到的这些概念都是比较抽象的。所以接下来会讨论一些常见的应用场景,在这些场景中会涉及很多的表,以及这些表如何选用合适的存储引擎,下一节将进行一些总结。\n日志型应用 # 假设你需要实时地记录一台中心电话交换机的每一通电话的日志到MySQL中,或者通过Apache的mod_log_sql模块将网站的所有访问信息直接记录到表中。这一类应用的插入速度有很高的要求,数据库不能成为瓶颈。MyISAM或者Archive存储引擎对这类应用比较合适,因为它们开销低,而且插入速度非常快。\n如果需要对记录的日志做分析报表,则事情就会变得有趣了。生成报表的SQL很有可能会导致插入效率明显降低,这时候该怎么办?\n一种解决方法,是利用MySQL内置的复制方案将数据复制一份到备库,然后在备库上执行比较消耗时间和CPU的查询。这样主库只用于高效的插入工作,而备库上执行的查询也无须担心影响到日志的插入性能。当然也可以在系统负载较低的时候执行报表查询操作,但应用在不断变化,如果依赖这个策略可能以后会导致问题。\n另外一种方法,在日志记录表的名字中包含年和月的信息,比如web_logs_2012_01或者web_logs_2012_jan。这样可以在已经没有插入操作的历史表上做频繁的查询操作,而不会干扰到最新的当前表上的插入操作。\n只读或者大部分情况下只读的表 # 有些表的数据用于编制类目或者分列清单(如工作岗位、竞拍、不动产等),这种应用场景是典型的读多写少的业务。如果不介意MyISAM的崩溃恢复问题,选用MyISAM引擎是合适的。不过不要低估崩溃恢复问题的重要性,有些存储引擎不会保证将数据安全地写入到磁盘中,而许多用户实际上并不清楚这样有多大的风险(MyISAM只将数据写到内存中,然后等待操作系统定期将数据刷出到磁盘上)。\n一个值得推荐的方式,是在性能测试环境模拟真实的环境,运行应用,然后拔下电源模拟崩溃测试。对崩溃恢复的第一手测试经验是无价之宝,可以避免真的碰到崩溃时手足无措。\n不要轻易相信“MyISAM比InnoDB快”之类的经验之谈,这个结论往往不是绝对的。在很多我们已知的场景中,InnoDB的速度都可以让MyISAM望尘莫及,尤其是使用到聚簇索引,或者需要访问的数据都可以放入内存的应用。在本书后续章节,读者可以了解更多影响存储引擎性能的因素(如数据大小、I/O请求量、主键还是二级索引等)以及这些因素对应用的影响。\n当设计上述类型的应用时,建议采用InnoDB。MyISAM引擎在一开始可能没有任何问题,但随着应用压力的上升,则可能迅速恶化。各种锁争用、崩溃后的数据丢失等问题都会随之而来。\n订单处理 # 如果涉及订单处理,那么支持事务就是必要选项。半完成的订单是无法用来吸引用户的。另外一个重要的考虑点是存储引擎对外键的支持情况。InnoDB是订单处理类应用的最佳选择。\n电子公告牌和主题讨论论坛 # 对于MySQL用户,主题讨论区是个很有意思的话题。当前有成百上千的基于PHP或者Perl的免费系统可以支持主题讨论。其中大部分的数据库操作效率都不高,因为它们大多倾向于在一次请求中执行尽可能多的查询语句。另外还有部分系统设计为不采用数据库,当然也就无法利用到数据库提供的一些方便的特性。主题讨论区一般都有更新计数器,并且会为各个主题计算访问统计信息。多数应用只设计了几张表来保存所有的数据,所以核心表的读写压力可能非常大。为保证这些核心表的数据一致性,锁成为资源争用的主要因素。\n尽管有这些设计缺陷,但大多数应用在中低负载时可以工作得很好。如果Web站点的规模迅速扩展,流量随之猛增,则数据库访问可能变得非常慢。此时一个典型的解决方案是更改为支持更高读写的存储引擎,但有时用户会发现这么做反而导致系统变得更慢了。用户可能没有意识到这是由于某些特殊查询的缘故,典型的如:\nmysql\u0026gt; ** SELECT COUNT(*) FROM table;** 问题就在于,不是所有的存储引擎运行上述查询都非常快:对于MyISAM确实会很快,但其他的可能都不行。每种存储引擎都能找出类似的对自己有利的例子。下一章将帮助用户分析这些状况,演示如何发现和解决存在的这类问题。\nCD-ROM应用 # 如果要发布一个基于CD-ROM或者DVD-ROM并且使用MySQL数据文件的应用,可以考虑使用MyISAM表或者MyISAM压缩表,这样表之间可以隔离并且可以在不同介质上相互拷贝。MyISAM压缩表比未压缩的表要节约很多空间,但压缩表是只读的。在某些应用中这可能是个大问题。但如果数据放到只读介质的场景下,压缩表的只读特性就不是问题,就没有理由不采用压缩表了。\n大数据量 # 什么样的数据量算大?我们创建或者管理的很多InnoDB数据库的数据量在3~5TB之间,或者更大,这是单台机器上的量,不是一个分片(shard)的量。这些系统运行得还不错,要做到这一点需要合理地选择硬件,做好物理设计,并为服务器的I/O瓶颈做好规划。在这样的数据量下,如果采用MyISAM,崩溃后的恢复就是一个噩梦。\n如果数据量继续增长到10TB以上的级别,可能就需要建立数据仓库。Infobright是MySQL数据仓库最成功的解决方案。也有一些大数据库不适合Infobright,却可能适合TokuDB。\n1.5.6 转换表的引擎 # 有很多种方法可以将表的存储引擎转换成另外一种引擎。每种方法都有其优点和缺点。在接下来的章节中,我们将讲述其中的三种方法。\nALTER TABLE # 将表从一个引擎修改为另一个引擎最简单的办法是使用ALTER TABLE语句。下面的语句将mytable的引擎修改为InnoDB:\nmysql\u0026gt; ** ALTER TABLE mytable ENGINE=InnoDB;** 上述语法可以适用任何存储引擎。但有一个问题:需要执行很长时间。MySQL会按行将数据从原表复制到一张新的表中,在复制期间可能会消耗系统所有的I/O能力,同时原表上会加上读锁。所以,在繁忙的表上执行此操作要特别小心。一个替代方案是采用接下来将讨论的导出与导入的方法,手工进行表的复制。\n如果转换表的存储引擎,将会失去和原引擎相关的所有特性。例如,如果将一张InnoDB表转换为MyISAM,然后再转换回InnoDB,原InnoDB表上所有的外键将丢失。\n导出与导入 # 为了更好地控制转换的过程,可以使用mysqldump工具将数据导出到文件,然后修改文件中CREATE TABLE语句的存储引擎选项,注意同时修改表名,因为同一个数据库中不能存在相同的表名,即使它们使用的是不同的存储引擎。同时要注意mysqldump默认会自动在CREATE TABLE语句前加上DROP TABLE语句,不注意这一点可能会导致数据丢失。\n创建与查询(CREATE和SELECT) # 第三种转换的技术综合了第一种方法的高效和第二种方法的安全。不需要导出整个表的数据,而是先创建一个新的存储引擎的表,然后利用INSERT…SELECT语法来导数据:\nmysql\u0026gt; ** CREATE TABLE innodb_table LIKE myisam_table;** mysql\u0026gt; ** ALTER TABLE innodb_table ENGINE=InnoDB;** mysql\u0026gt; ** INSERT INTO innodb_table SELECT * FROM myisam_table;** 数据量不大的话,这样做工作得很好。如果数据量很大,则可以考虑做分批处理,针对每一段数据执行事务提交操作,以避免大事务产生过多的undo。假设有主键字段id,重复运行以下语句(最小值x和最大值y进行相应的替换)将数据导入到新表:\nmysql\u0026gt; ** START TRANSACTION;** mysql\u0026gt; ** INSERT INTO innodb_table SELECT * FROM myisam_table** -\u0026gt; ** WHERE id BETWEEN x AND y;** mysql\u0026gt; ** COMMIT;** 这样操作完成以后,新表是原表的一个全量复制,原表还在,如果需要可以删除原表。如果有必要,可以在执行的过程中对原表加锁,以确保新表和原表的数据一致。\nPercona Toolkit提供了一个pt-online-schema-change的工具(基于Facebook的在线schema变更技术),可以比较简单、方便地执行上述过程,避免手工操作可能导致的失误和烦琐。\n1.6 MySQL时间线(Timeline) # 在选择MySQL版本的时候,了解一下版本的变迁历史是有帮助的。对于怀旧者也可以享受一下过去的好日子里是怎么使用MySQL的。\n版本3.23(2001)\n一般认为这个版本的发布是MySQL真正“诞生”的时刻,其开始获得广泛使用。在这个版本,MySQL依然只是一个在平面文件(Flat File)上实现了SQL查询的系统。但一个重要的改进是引入MyISAM代替了老旧而且有诸多限制的ISAM引擎。InnoDB引擎也已经可以使用,但没有包含在默认的二进制发行版中,因为它太新了。所以如果要使用InnoDB,必须手工编译。版本3.23还引入了全文索引和复制。复制是MySQL成为互联网应用的数据库系统的关键特性(killer feature)。\n版本4.0(2003)\n支持新的语法,比如UNION和多表DELETE语法。重写了复制,在备库使用了两个线程来实现复制,避免了之前一个线程做所有复制工作的模式下任务切换导致的问题。InnoDB成为标准配备,包括了全部的特性:行级锁、外键等。版本4.0中还引入了查询缓存(自那以后这部分改动不大),同时还支持通过SSL进行连接。\n版本4.1(2005)\n引入了更多新的语法,比如子查询和INSERT ON DUPLICATE KEY UPDATE。开始支持UTF-8字符集。支持新的二进制协议和prepared语句。\n版本5.0(2006)\n这个版本出现了一些“企业级”特性:视图、触发器、存储过程和存储函数。老的ISAM引擎的代码被彻底移除,同时引入了新的Federated等引擎。\n版本5.1(2008)\n这是Sun收购MySQL AB以后发布的首个版本,研发时间长达五年。版本5.1引入了分区、基于行的复制,以及plugin API(包括可插拔存储引擎的API)。移除了BerkeyDB引擎,这是MySQL最早的事务存储引擎。其他如Federated引擎也将被放弃。同时Oracle收购的InnoDB Oy(6)发布了InnoDB plugin。\n版本5.5(2010)\n这是Oracle收购Sun以后发布的首个版本。版本5.5的主要改善集中在性能、扩展性、复制、分区、对微软Windows系统的支持,以及一些其他方面。InnoDB成为默认的存储引擎。更多的一些遗留特性和不建议使用的特性被移除。增加了PERFORMANCE_SCHEMA库,包含了一些可测量的性能指标的增强。增加了复制、认证和审计API。半同步复制(semisynchronous replication)插件进入实用阶段。Oracle还在2011年发布了商用的认证插件和线程池(thread pooling)。InnoDB在架构方面也做了较大的改进,比如多个子缓冲池(buffer pool)。\n版本5.6(还未发布)\n版本5.6将包含一些重大更新。比如多年来首次对查询优化器进行大规模的改进,更多的插件API(比如全文索引),复制的改进,以及PERFORMANCE_SCHEMA库增加了更多的性能指标。InnoDB团队也做了大量的改进工作,这些改进在已经发布的里程碑版本和实验室版本中都已经包括。MySQL 5.5主要着重在基础部分的改进和加强,引入了部分新特性。而MySQL 5.6则在MySQL 5.5的基础上提升服务器的开发和性能。\n版本6.0(已经取消)\n版本6.0的概念有些模糊。最早在版本5.1还在开发的时候就宣布要开发版本6.0。传说中宣布要开发的6.0拥有大量的新特性,包括在线备份、服务器层面对所有存储引擎的外键支持,以及子查询的改进和线程池。后来该版本号被取消,Sun将其改为版本5.4继续开发,最后发布时变成版本5.5。版本6.0中很多特性的代码陆续出现在版本5.5和5.6中。\n简单总结一下MySQL的发展史:早期的MySQL是一种破坏性创新(7),有诸多限制,并且很多功能只能说是二流的。但是它的特性支持和较低的使用成本,使得其成为快速增长的互联网时代的杀手级应用。在5.x版本的早期,MySQL引入了视图和存储过程等特性,期望成为“企业级”数据库,但并不算成功,成长并非一帆风顺。从事后分析来看,MySQL 5.0充满了bug,直到5.0.50以后的版本才算稳定。这种情况在MySQL 5.1也依然没有太多改善。版本5.0和5.1的发布都延期了许多时日,而且Sun和Oracle的两次收购也使得社区人士有所担心。但我们认为事情还在按部就班地发展,MySQL 5.5可以说是MySQL历史上质量最高的版本。Oracle收购以后帮助MySQL更好地往企业级应用的方向发展,MySQL 5.6也承诺在功能和性能方面将有显著提升。\n提到性能,我们可以比较一下在不同时代MySQL的性能测试的数据。在目前的生产环境中4.0及更老的版本已经很少见了,所以这里不打算测试4.1之前的版本。另外,如此多的版本如果要做完全等同的测试是比较困难的,具体原因将在后面的章节讨论。我们尝试设计了多个测试方案来尽量保证在不同版本中的基准一致,并为此做了很多努力。表1-2显示了在服务器层面不同并发下的每秒事务数的测试结果。\n表1-2:多个不同MySQL版本的只读测试 注a:在测试的时候,版本5.6还没有GA(正式发布)。\n很容易将表1-2的数据以图的方式展示出来,如图1-2所示。\n图1-2:MySQL不同版本的只读基准测试\n在解释结果之前,需要先介绍一下测试环境。测试的机器是Cisco UCS C250,两颗6核CPU,每个核支持两个线程,内存为384GB,测试的数据集是2.5GB,所以MySQL的buffer pool设置为4GB。采用SysBench的read-only只读测试进行压测,并采用InnoDB存储引擎,所有的数据都可以放入内存,因此是CPU密集型(CPU-bound)的测试。每次测试持续60分钟,每10秒获取一次吞吐量的结果,前面900秒用于预热数据,以避免预热时的I/O影响测试结果。\n现在来看看结果,有两个很明显的趋势。第一个趋势,采用了InnoDB plugin的版本,在高并发的时候性能明显更好,可以说InnoDB plugin的扩展性更好。这是可以预期的结果,旧的版本在高并发时确实存在问题。第二个趋势,新的版本在单线程的时候性能比旧版本更差。一开始可能无法理解为什么会这样,仔细想想就能明白,这是一个非常简单的只读测试。新版本的SQL语法更复杂,针对复杂查询增加了很多特性和改进,这对于简单查询可能带来了更多的开销。旧版本的代码简单,对于简单的查询反而会更有利。\n原计划做一个更复杂的不同并发条件下的读写混合场景的测试(类似TPC-C),但要在不同版本间做到可比较基本是不可能的。一般来说,新版本在复杂场景时性能有更多的优化,尤其是高并发和大数据集的情况下。\n那么该如何选择版本呢?这更多地取决于业务需求而不是技术需求。理想情况下当然是版本越新越好,当然也可以选择等到第一个bug修复版本以后再采用新的大版本。如果应用还没有上线,也可以采用即将发布的新版本,以尽可能地延迟应用上线后的升级操作。\n1.7 MySQL的开发模式 # MySQL的开发过程和发布模型在不同的阶段有很大的变化,但目前已经基本稳定下来。在Oracle定期发布的新里程碑开发版本中,会包含即将在下一个GA(8)版本发布的新特性。这样做是为了测试和获得反馈,请不要在生产环境使用此版本,虽然Oracle宣称每个里程碑版本的质量都是可靠的,并随时可以正式发布(到目前为止也没有任何理由去推翻这个说法)。Oracle也会定期发布实验室预览版,主要包含一些特定的需要评估的特性,这些特性并不保证会在下一个正式版本中包括进去。最终,Oracle会将稳定的特性打包发布一个新的GA版本。\nMySQL依然遵循GPL开源协议,全部的源代码(除了一些商业版本的插件)都会开放给社区。Oracle似乎也理解,为社区和付费用户提供不同的版本并非明智之举。MySQL AB曾经尝试过不同版本的策略,结果导致付费用户变成了“睁眼瞎”,无法从社区的测试和反馈中获得好处。不同版本的策略并不受企业用户的欢迎,所以后来被Sun废除了。\n现在Oracle为付费用户单独提供了一些服务器插件,而MySQL本身还是遵循开源模式。尽管对于私有的服务器插件的发布有一些抱怨,但这只是少数的声音,并且慢慢地在平息。大多数MySQL用户对此并不在意,有需求的用户也能够接受商业授权的付费插件。\n无论如何,不开源的扩展也只是扩展而已,并不会将MySQL变成受限制的非开源模式。没有这些扩展,MySQL也是功能完整的数据库。坦白地说,我们也很欣赏Oracle将更多的特性做成插件的开发模式。如果将特性直接包含在服务器中而不是API的方式,那就更加没有选择了:用户只能接受这种实现,而失去了选择更适合业务的实现的机会。例如,如果Oracle将InnoDB的全文索引功能以API的方式实现,那么就可能以同样的API实现Sphinx或者Lucene的插件,这可能对一些用户更有用。服务器内部的API设计也很干净,这对于提升代码质量非常有帮助,谁不想要这个呢?\n1.8 总结 # MySQL拥有分层的架构。上层是服务器层的服务和查询执行引擎,下层则是存储引擎。虽然有很多不同作用的插件API,但存储引擎API还是最重要的。如果能理解MySQL在存储引擎和服务层之间处理查询时如何通过API来回交互,就能抓住MySQL的核心基础架构的精髓。\nMySQL最初基于ISAM构建(后来被MyISAM取代),其后陆续添加了更多的存储引擎和事务支持。MySQL有一些怪异的行为是由于历史遗留导致的。例如,在执行ALTER TABLE时,MySQL提交事务的方式是由于存储引擎的架构直接导致的,并且数据字典也保存在*.frm*文件中(这并不是说InnoDB会导致ALTER变成非事务型的。对于InnoDB来说,所有的操作都是事务)。\n当然,存储引擎API的架构也有一些缺点。有时候选择多并非好事,而在MySQL 5.0和MySQL 5.1中有太多的存储引擎可以选择。InnoDB对于95%以上的用户来说都是最佳选择,所以其他的存储引擎可能只是让事情变得复杂难搞,当然也不可否认某些情况下某些存储引擎能更好地满足需求。\nOracle一开始收购了InnoDB,之后又收购了MySQL,在同一个屋檐下对于两者都是有利的。InnoDB和MySQL服务器之间可以更快地协同发展。MySQL依然基于GPL协议开放全部源代码,社区和客户都可以获得坚固而稳定的数据库,MySQL正在变得越来越可扩展和有用。\n————————————————————\n(1) InnoDB是一个例外,它会解析外键定义,因为MySQL服务器本身没有实现该功能。\n(2) MySQL 5.5或者更新的版本提供了一个API,支持线程池(Thread-Pooling)插件,可以使用池中少量的线程来服务大量的连接。\n(3) 这些锁定提示经常被滥用,实际上应当尽量避免使用。第6章有更详细的讨论。\n(4) MVCC并没有正式的规范,所以各个存储引擎和数据库系统的实现都是各异的,没有人能说其他的实现方式是错误的。\n(5) mysqlperformanceblog.com。——译者注\n(6) Oracle也已经收购了BerkeyDB。\n(7) “破坏性创新”一词出自Clayton M. Christensen的The Innovator\u0026rsquo;s Dilemma(Harper)。\n(8) GA(Generally Available)的意思是通常可用的版本,对于最挑剔的老板来说,这种版本也意味着达到了满足生产环境中使用的质量标准。\n"},{"id":147,"href":"/zh/docs/technology/MySQL/_%E9%AB%98%E6%80%A7%E8%83%BDMySQL_/%E7%AC%AC16%E7%AB%A0MySQL%E7%94%A8%E6%88%B7%E5%B7%A5%E5%85%B7/","title":"第16章MySQL用户工具","section":"高性能 My SQL","content":"第16章 MySQL用户工具\nMySQL服务器发行包中并没有包含针对许多常用任务的工具,例如监控服务器或比较不同服务器间数据的工具。幸运的是,Oracle的商业版提供了一些扩展工具,并且MySQL活跃的开源社区和第三方公司也提供了一系列的工具,降低了自己“重复发明轮子”的需要。\n16.1 接口工具 # 接口工具可以帮助运行查询,创建表和用户,以及执行其他日常任务等。本节将简单介绍一些用于此用途的最流行的工具。一般可以用SQL查询或命令做所有这些或其中大部分的工作——我们这里讨论的工具只是更为方便,可帮助避免错误和加快工作。\nMySQL Workbench\nMySQL Workbench是一个一站式的工具,可以完成例如管理服务器、写查询、开发存储过程,以及Schema设计图相关的工作。可以通过一个插件接口来编写自己的工具并集成到这个工作平台上,有一些Python脚本和库就使用了这个插件接口。MySQL Workbench有社区版和商业版两个版本,商业版只是增加了其他的一些高级特性。免费版对于大部分需要早已足够了。在 http://www.mysql.com/products/workbench/可以学到更多相关的内容。\nSQLyog\nSQLyog是MySQL最流行的可视化工具之一,有许多很好的特性。它与MySQL Workbench是同级别的工具,但两个工具都有一些对方没有的特性。SQLyog只能在微软的Windows下使用,拥有全部特性的版本需要付费,但有限制功能的免费版本。关于SQLyog的更多信息可以参考 http://www.webyog.com。\nphpMyAdmin\nphpMyAdmin是一个流行的管理工具,运行在Web服务器上,并且提供基于浏览器的MySQL服务器访问接口。尽管基于浏览器的访问有时很好,但phpMyAdmin是个大而复杂的工具,曾被指责有许多安全问题。对此要格外小心。我们建议不要安装在任何可以从互联网访问的地方。更多信息请参考 http://sourceforge.net/projects/phpmyadmin/。\nAdminer\nAdminer是个基于浏览器的安全的轻量级管理工具,它与phpMyAdmin同类。其开发者将其定位为phpMyAdmin的更好的替代品。尽管它看起来更安全,但我们仍建议安装在任何可公开访问的地方时要谨慎。更多详情可参考 http://www.adminer.org。\n16.2 命令行工具集 # MySQL包含了一些命令行工具集,例如mysqladmin和mysqlcheck。这些在MySQL手册上都有提及和记录。MySQL社区同样创建了大量高质量的工具包,并有很好的文档支撑这些实用工具集。\nPercona Toolkit\nPercona Toolkit是MySQL管理员必备的工具包。它源自Baron早期的工具包Maatkit和Aspersa,很多人认为这两个工具应该是正式的MySQL部署必须强制要求使用的。Percona Toolkit包括许多针对类似日志分析、复制完整性检测、数据同步、模式和索引分析、查询建议和数据归档目的的工具。如果刚开始接触MySQL,我们建议首先学习这些关键的工具:pt-mysql-summary、pt-table-checksum、pt-table-sync和pt-query-digest。更多信息可参考 http://www.percona.com/software/。\nMaatkit and Aspersa\n这两个工具约从2006年以某种形式出现,两者都被认为是MySQL用户的基本工具。它们现在已经并入 Percona Toolkit。\nThe openark kit\nShlomi Noach的openark kit( http://code.openark.org/forge/openark-kit)包含了可以用来做一系列管理任务的Python脚本。\nMySQL Workbench工具集\nMySQL Workbench工具集中的某些工具可以作为单独的Python脚本使用。可参考 https://launchpad.net/mysql-utilities。\n除了这些工具外,还有其他一系列没有太正式包装和维护的工具。许多杰出的MySQL社区成员时不时地贡献工具,其中大多数托管在他们自己的网站或MySQL Forge(http:// forge.mysql.com)上。可以通过不时地查看Planet MySQL博客聚合器获取大量的信息( http://planet.mysql.com),但不幸的是这些工具没有一个集中的目录。\n16.3 SQL实用集 # 服务器本身也内置有一系列免费的附加组件和实用集可以使用;其中一些确实相当强大。\ncommon_schema\nShlomi Noach的common_schema项目( http://code.openark.org/forge/common_schema)是一套针对服务器脚本化和管理的强大的代码和视图。common_schema对于MySQL好比jQuery对于JavaScript。\nmysql-sr-lib\nGiuseppe Maxia为MySQL创建了一个存储过程的代码库,可以在* http://www.nongnu.org/mysql-sr-lib/*找到。\nMySQL UDF仓库\nRoland Bouman建立了一个MySQL自定义函数的收藏馆,可以在 http://www. mysqludf.org获取。\nMySQL Forge\n在MySQL Forge上( http://forge.mysql.com),可以找到上百个社区贡献的程序、脚本、代码片断、实用集和技巧及陷阱。\n16.4 监测工具 # 以我们的经验来看,大多数MySQL商店需要提供两种类型的监测工具:健康监测工具——检测到异常时告警——和为趋势、诊断、问题排查、容量规划等记录指标的工具。大多数系统仅在这些任务中的一个方面做得很好,而不能两者兼顾。更不幸的是,有十几种工具可选,使得评估和选择一款适合的工具非常耗时。\n许多监控系统不是专门为MySQL服务器设计。它们是通用系统,用于周期性地检测许多种类型的资源,从机器到路由再到软件(例如MySQL)。它们一般有某些类型的插件架构,经常会伴随有一些MySQL插件。\n一般会在专用服务器上安装监控系统来监测其他服务器。如果是监控重要的系统,它很快会变成架构中至关重要的一部分,因此可能需要采取额外的步骤,例如做监控系统本身的灾备。\n16.4.1 开源的监控工具 # 下面是一些最受欢迎的开源集成监控系统。\nNagios\nNagios( http://www.nagios.org)也许是开源世界中最流行的问题检测和告警系统。它周期性检测监控的服务器并将结果与默认或自定义的阈值相比较。如果结果超出了限制,Nagios会执行某个程序并且(或)把问题的告警发给某些人。Nagios的通信和告警系统可以将告警发给不同的联系人,改变告警,或根据一天中的时间和其他条件将其发送到不同的位置,并且对计划内的宕机可以特殊处理。Nagios同样理解服务之间的依赖,因此,如果是因为中间的路由层宕机或者主机本身宕机导致MySQL实例不可用,Nagios不会发送告警来烦你。\nNagios能将任何一个可执行文件以插件形式运行,只要给予其正确参数就可得到正确输出。因此,Nagios插件在多种语言中都存在,例如shell、Perl、Python、Ruby和其他脚本语言。就算找不到一个能真正满足你需求的插件,自己创建一个也很简单。一个插件只需要接收标准的参数,以一个合适的状态退出,然后选择性地打印Nagios捕获的输出。\n然而,Nagios也有一些严重的缺点。即使你很了解它,也仍然难以维护。它将所有配置保存在文件而不是数据库中。文件有一个特别容易出错的语法,当系统增长和发展时,修改配置文件就很费事。Nagios可扩展性并不好;你可以很容易地写出监控插件,但这也就是你能够做的一切。最后,它的图形化、趋势化和可视化能力都有限。Nagios将一些性能和其他数据存储到MySQL服务器中,一般从中生成图形,但并不像其他一些系统那么灵活。因为不同“政见”的原因,使得上面所有的问题继续变得更糟。因为或真实、或臆测的涉及代码、参与者的问题,Nagios至少分化出了两个分支。两个分支的名字分别是Opsview( http://www.opsview.com)和Icinga( http://www.icinga.org)。它们比Nagios更受到人们的亲睐。\n有一些专门介绍Nagios的书籍;我们倾向于Wolfgang Barth的Nagios System and Network Monitoring(No Starch出版公司)。\nZabbix\nZabbix是一个同时支持监控和指标收集的完整系统。例如,它将所有配置和其他数据存储到数据库而不是配置文件中。它存储了比Nagios更多的数据类型,因而可以得到更好的趋势和历史报表。其网络画图和可视能力也比Nagios更强,配置更简单,更灵活,且更具可扩展性。可参考 http://www.zabbix.com获取更多信息。\nZenoss\nZenoss是用Python写的,拥有一个基于浏览器的用户界面,使用了Ajax,这使它更快和更高效。它可以自动发现网络上的资源,并将监控、告警、趋势、绘图和记录历史数据整合到了一个统一的工具中。Zenoss默认使用SNMP来从远程服务器上收集数据,但也可以使用SSH,并且支持Nagios插件。更多信息请参考http://www. zenoss.com。\nHyperic HQ\nHyperic HQ是一个基于Java的监控系统,比起同级别的其他大部分系统,它更称得上是企业级监控。像Zenoss一样,它可以自动发现网络上的资源和支持Nagios插件,但它的逻辑组织和架构不同,有点“笨重”。更多信息可参考 http://www.hyperic.com。\nOpenNMS\nOpenNMS也是用Java开发,有一个活跃的开发社区。它拥有常规的特性,例如监控和告警,但同样也增加了绘图和趋势功能。它的目标是高性能、可扩展、自动化和灵活。像Hyperic一样,它也致力于为大型和关键系统做企业级监控。更多信息请参考 http://www.opennms.org。\nGroundwork Open Source\nGroundwork Open Source用一个可移植的接口把Nagios和其他几个工具整合到了一个系统中。对于这个工具最好的描述是:如果你是Nagios、Cacti和其他几个工具方面的专家,并且花了许多时间将它们整合一起,那很可能你是在闭门造车。更多信息可参考 http://www.gwos.com。\n相比于集所有功能于一身的系统,还有一系列软件专注于收集指标和画图以及可视化,而不是进行性能监控检查。他们中有很多是建立在RRDTool( http://www.rrdtool.org)之上,存储时序数据到轮询数据库(RRD)文件中。RRD文件自动聚集输入数据,对没有预期传送的输入值进行插值,并有强大的绘图工具可以生成漂亮有特色的图。有很多基于RRDTool的系统,下面是其中最受欢迎的几个。\nMRTG\nMulti Router Traffic Grapher或称MRTG( http://oss.oetiker.ch/mrtg/),是典型的基于RRDTool的系统。最初是为记录网络流量而设计的,但同样可以扩展到用于对其他指标进行记录和绘图。\nCacti\nCacti ( http://www.cacti.net)可能是最流行的基于RRDTool的系统。它采用PHP网页来与RRDTool进行交互,并使用MySQL数据库来定义服务器、插件、图像等。因为是模板驱动,故而可以定义模板然后应用到系统上。Baron为MySQL和其他系统写了一组非常流行的模板;更多信息请参考 http://code.google.com/p/mysql-cacti-templates/。这些也已经被移植到Munin、OpenNMS和Zabbix。\nGanglia\nGanglia( http://ganglia.sourceforge.net)与Cacti类似,但是为监控集群和网格系统而设计,所以可以汇总查看许多服务器的数据,如果需要也可以细分查看单台服务器的详细数据。\nMunin\nMunin( http://munin.projects.linpro.no)收集数据并存入RRDTool中,然后以几个不同级别的粒度生成数据图。它从配置中生成静态HTML文件,因此可以很容易地浏览和查看趋势。定义一个图形较容易;只需要创建一个插件脚本,其命令行帮助输出有一些Munin可以识别的特别语法的画图指令。\n基于RRDTool的系统有些限制,例如不能用标准查询语言来查询存储的数据,不能永久保留数据,存在某些数据不能轻松地使用简单计数器和标准数值表示的问题,需要预先定义指标和图形等。理想情况下,我们需要的监控系统可以接受任何发送给它的指标,而不需要预先进行定义,并且后续可以绘制任意需要的图形,也不需要预先进行定义。可能我们所看到的最接近的系统是Graphite( http://graphite.wikidot.com)。\n这些系统都可以用来对MySQL收集、记录和绘制数据图表并且生成报表,有着不同程度的灵活性,目标也稍微有些不同。但它们都缺乏真正可以在问题出现时及时告警的灵活性。\n我们提到的大多数系统的主要问题是,它们明显是由那些因为现有系统不能满足他们所有需求的人设计的,因此他们又重复设计了另一个无法完全满足其他人的所有需求的系统。大部分这样的系统都有一些基础的限制,例如使用一个奇怪的数据模型存储内部数据,而导致在很多场合都无法很好地工作。在很多时候,这都令人沮丧,使用这些系统都像是把一个圆形的钉子钉到了一个方形的洞里面。\n16.4.2 商业监控系统 # 尽管我们知道许多MySQL用户热衷使用开源工具,但也有许多人愿意为合适的软件买单,只要这些软件可以让工作更好地完成,为他们节省时间,减少烦恼。下面是一些可以利用的商业选件。\nMySQL Enterprise Monitor\nMySQL Enterprise Monitor包含在Oracle的MySQL支持服务中。它将监控、指标和画图、咨询服务和查询分析等特性整合到了一个工具中。通过在服务器上使用agent来监测状态计数器(也包含操作系统的关键指标)。它能以两种方式抓取查询:通过MySQL代理(MySQL Proxy),或使用合适的MySQL连接器,例如Java的Connector/J或PHP的MySQLi。尽管是为监控MySQL而设计的,但某种程度上也可以进行扩展。同样,这个工具也无法监控基础架构中所有的服务器和所有的服务。更多信息请参考 http://www.mysql.com/products/enterprise/monitor.html。\nMONyog\nMONyog( http://www.webyog.com)是一个运行在桌面上的基于浏览器且无agent的监控系统。它会启动一个HTTP服务器,然后就可以通过浏览器来使用此工具。\nNew Relic\nNew Relic( http://newrelic.com)是一个托管式的软件即服务(Saas)的应用性能管理系统,它可以分析整个应用的性能,从应用代码(采用Ruby,PHP,Java和其他语言)到运行在浏览器上的JavaScript,到数据库的SQL调用,甚至是服务器的磁盘空间,CPU利用率和其它指标。\nCirconus\nCirconus( https://circonus.com)是一个源于OmniTI的托管式的软件即服务(SaaS)的指标和告警系统。通过agent从一个或多个服务器上收集指标并转发到Circonus,然后就可以通过一个基于浏览器的仪表盘来查看。\nMonitis\nMonitis( http://monitis.com)是另外一个云托管式的软件即服务(SaaS)的监控系统。它被设计成监控“一切”,这意味着它有点普遍性。它有一个入门级的免费版Monitor.us ( http://mon.itor.us),也有支持MySQL的插件。\nSplunk\nSplunk( http://www.splunk.com)是一个日志聚集器和搜索引擎,可以帮助获得环境中所有机器生成的数据并进行运营分析。\nPingdom\nPingdom ( http://www.pingdom.com)从世界的多个位置来监控网站的可用性和性能。实际上有许多像Pingdom一样的服务,我们并不需要特别推荐某一个这样的服务,但是我们确实建议使用一些外部的监控服务,以便让你在网站不可用时能够及时得到通知。很多类似的服务远不止Ping或获取网页。\n还有许多其他的商业监控工具——我们可以凭印象列举出十几个或更多。对所有监控系统而言,要注意的一点是它们对服务器的影响。有些工具相当直白,因为它们由一些没有实际的大型高负载MySQL系统经验的公司设计。例如,我们不止一次通过禁止每分钟对所有的数据库执行 一次SHOW TABLE STATUS的监控功能来解决突发事件。(这个命令在高I/O限制的系统上特别有破坏性。)频繁查询INFORMATION_SCHEMA表的工具也会导致负面影响。\n16.4.3 Innotop的命令行监控 # 有一些基于命令行的监测工具,它们大部分在某种方面模拟了UNIX中的top工具。其中最精致和最胜任的是innotop(http://code.google.com/p/innotop/),我们将详细探讨。此外,还有几个其他的工具,例如mtop*(http://mtop.sourceforge.net)*、mytop(http://jeremy.zawodny.com/mysql/mytop/)和一些基于网页的mytop克隆版本。\n尽管mytop是MySQL上最原始的top克隆,但innotop比mytop拥有更多功能,这也是我们看重innotop的原因。\n本书的作者之一Balon Schwartz编写了innotop。它展示了服务器正在发生事情的实时更新视图。别去理会它的名称,实际上它不仅仅用于监控InnoDB,还可以监控MySQL任何其他的方面。它也能同时监控多个MySQL 实例,极具可配置性和可扩展性。\n它的功能特性包括以下这些:\n事务列表可以显示InnoDB 当前的全部事务。 查询列表可以显示当前正在运行的查询。 可以显示当前锁和锁等待的列表。 以相对值显示服务器状态和变量的汇总信息。 有多种模式可用来显示InnoDB 内部信息,例如缓冲区、死锁、外键错误、I/O 活动情况、行操作、信号量,以及其他更多的内容。 复制监控,将主服务器和从服务器的状态显示在一起。 显示任意服务器变量的模式。 服务器组可以更方便地组织多台服务器。 在命令行脚本下可以使用非交互式模式。 innotop的安装很容易,可以从操作系统的软件仓库安装,也可以从* http://code.google.com/p/innotop/*下载到本地,然后解压缩,运行标准的make install安装过程。\nperl Makefile.PL make install 一旦安装完成,就可以在命令行里执行innotop,然后它会引导你完成连接到MySQL实例的过程。引导过程会读取*~/.my.cnf*选项文件,这样,除了输入服务器的主机名和按几次Enter键之外,什么都不用做。连接完成以后,就处在T(InnoDB Transaction)模式了,这时,应该可看到InnoDB 事务列表,如图16-1 所示。\n图16-1:处在T(InnoDB Transaction)模式的innotop\n默认情况下,innotop采用过滤器来减少零乱的信息(对于显示的所有信息,都可以定义自己的过滤器或者定制内部的过滤器)。在图16-1里,大多数事务都己经被过滤掉了,只显示出了当前活动的事务。可以按i 键禁掉过滤,让数量众多的事务信息填满整个屏幕。\ninnotop在这个模式下会显示头部信息和主线程列表。头部信息里显示一些InnoDB的总体信息,例如,历史清单的长度、还未清除的InnoDB 事务数目、缓冲池中脏缓冲所占的百分比等。\n你要按的第一个键应该是问号(?),以查看帮助信息。虽然在屏幕上显示出的帮助内容会根据当前模式的不同而不同,但是每一个活动的键都总是会显示出来,因此能看到所有可执行的动作。图16-2显示的是T模式下的帮助信息。\n图16-2:innotop 帮助信息\n在这里不会详细讲解所有的模式,但还是可以从帮助信息里看出,innotop有许许多多的功能特性。\n这里唯一要提及的是一些基本的自定义功能,告诉你如何监控想要监控的信息。innotop的强大功能之一就是能够解释用户定义的表达式,例如Uptime/Questions是生成每秒钟的查询指标。它会显示自服务器启动以来和/或自上次采样之后递增累加的结果值。\n这使得往显示表格里添加自己的列方便很多。例如,在Q(Query List)模式下,头部信息能显示出服务器的一些总体信息。让我们看看怎么将它修改一下,使它能显示出索引键缓存有多满。启动innotop,按下Q键进入Q模式。这时的操作结果看起来像图16-3一样。\n图16-3:Q模式(查询列表)下的innotop\n这个屏幕截图只截取了一部分,因为在这个练习里,我们对查询列表没有兴趣;我们只关心头部信息。\n头部显示了“当前”统计(统计自从上次innotop用服务器上的新数据刷新后的累计增量)和“总计”统计(统计自MySQL服务器启动以来所有的活动,这个实例中是25天前)。头部的每一列都是来自SHOW STATUS和SHOW VARIABLES相对应的变量值。图16-3中显示的头部是内建的,但也很容易增加自定义的。需要做的只是增加一列到头部“表”。按^键来打开表编辑器,然后在提示符后输入q_header来编辑头部表(图16-4)。由于内置有Tab键自动补齐功能,因此可以敲入q然后按Tab键来补充完成整个词。\n图16-4:增加一个头部(开始)\n在此之后,你将会看到Q模式头部的表定义(图16-5)。该表定义显示了表的列。第一列被选中。我们可以移动选项,重新排序和编辑列,还可做其他的很多事情(按?键可以看到一个完整的列表),但我们只打算创建一个新列。按n键然后输入列名(图16-6)。\n图16-5:增加头部(选择)\n图16-6:增加头部(命名列)\n接着,输入列的头部,它将在列的顶部显示(图16-7)。最后,选择列源。这是一个innotop内部编译为函数的表达式。你可以使用SHOW VARIABLES和SHOW STATUS中对应变量的名字,就像是方程中的变量一样。我们使用了一些括号和Perl式“或”默认值以防止被零除,除此而外这个等式相当直白。我们同样可以使用innotop中的percent()转换来以百分比形式格式化结果列;更多信息请参考innotop的文档。图16-8显示了这个表达式。\n图16-7:增加头部(列的文本)\n图16-8:增加头部(要计算的表达式)\n按Enter键,你将会和之前一样看到表的定义,但是在底部有了新增加的列。按几次+键将它往列表上方移,挨着key_buffer_hit列,然后按q键退出表编辑器。瞧,新的列嵌在KCacheHit和BpsIn之间(图16-9)。可以通过定制innotop很容易地监控想要的信息。如果它真的不能满足你的需求,甚至还可以编写对应的插件。更多文档见 http://code.google.com/p/innotop/。\n图16-9:增加头(结果)\n16.5 总结 # 好的工具对管理MySQL至关重要。推荐使用一些已经可用、广泛测试过、流行的工具,例如Percona Toolkit(旧名Maatkit)。当接触新的服务器时,实践中我们首先要做的是运行pt-summary和pt-mysql-summary。如果在一台服务器上工作,可能需要在另外一个终端下运行innotop来观察它以及任何相关的服务器。\n监控工具是另外一个更复杂的话题,这是由于它们对于管理非常重要。如果你是一名开源倡导者,想使用开源的监控系统,或许可以尝试Nagios结合带Baron的 Cacti模板的Cacti,或者尝试Zabbix,前提是作不介意复杂的接口。如果想要监控MySQL的商业工具, MySQL Enterprise Monitor可以胜任,我们知道有很多用户使用得很好。如果想监控整个环境和其中所有软硬件信息,你可能需要自己去做一些调查——这个话题超出了本书讨论的范围。\n"},{"id":148,"href":"/zh/docs/technology/MySQL/_%E9%AB%98%E6%80%A7%E8%83%BDMySQL_/%E7%AC%AC15%E7%AB%A0%E5%A4%87%E4%BB%BD%E4%B8%8E%E6%81%A2%E5%A4%8D/","title":"第15章备份与恢复","section":"高性能 My SQL","content":"第15章 备份与恢复\n如果没有提前做好备份规划,也许以后会发现已经错失了一些最佳的选择。例如,在服务器已经配置好以后,才想起应该使用LVM,以便可以获取文件系统的快照——但这时已经太迟了。在为备份配置系统参数时,可能没有注意到某些系统配置对性能有着重要影响。如果没有计划做定期的恢复演练,当真的需要恢复时,就会发现并没有那么顺利。\n相对于本书的第一版和第二版来说,我们在此假设大部分用户主要使用InnoDB而不是MyISAM。在本章中,我们不会涵盖一个精心设计的备份和恢复解决方案的所有部分——而仅涉及与MySQL相关的部分。我们不打算包括的话题如下:\n安全(访问备份,恢复数据的权限,文件是否需要加密)。 备份存储在哪里,包括它们应该离源数据多远(在一块不同的盘上,一台不同的服务器上,或离线存储),以及如何将数据从源头移动到目的地。 保留策略、审计、法律要求,以及相关的条款。 存储解决方案和介质,压缩,以及增量备份。 存储的格式。 对备份的监控和报告。 存储层内置备份功能,或者其他专用设备,例如预制式文件服务器。 像这样的话题已经在许多书中涉及,例如W. Curtis Preston的Backup& Recouery ( O\u0026rsquo;Reilly)。\n在开始本章之前,让我们先澄清几个核心术语。首先,经常可以听到所谓的热备份、暖备份和冷备份。人们经常使用这些词来表示一个备份的影响:例如,“热”备份不需要任何的服务停机时间。问题是对这些术语的理解因人而异。有些工具虽然在名字中使用了“热备份”,但实际上并不是我们所认为的那样。我们尽量避开这些术语,而直接说明某个特别的技术或工具对服务器的影响。\n另外两个让人困惑的词是还原和恢复。在本章中它们有其特定的含义。还原意味着从备份文件中获取数据,可以加载这些文件到MySQL里,也可以将这些文件放置到MySQL期望的路径中。恢复一般意味着当某些异常发生后对一个系统或其部分的拯救。包括从备份中还原数据,以及使服务器完全恢复功能的所有必要步骤,例如重启MySQL、改变配置和预热服务器的缓存等。\n在很多人的概念中,恢复仅意味着修复崩溃后损坏的表。这与恢复一个完整的服务器是不同的。存储引擎的崩溃恢复要求数据和日志文件一致。要确保数据文件中只包含已经提交的事务所做的修改,恢复操作会将日志中还没有应用到数据文件的事务重新执行。这也许是恢复过程的一部分,甚至是备份的一部分。然而,这和一个意外的DROP TABLE事故后需要做的事是不一样的。\n15.1 为什么要备份 # 下面是备份非常重要的几个理由:\n灾难恢复\n灾难恢复是下列场景下需要做的事情:硬件故障、一个不经意的Bug导致数据损坏,或者服务器及其数据由于某些原因不可获取或无法使用等。你需要准备好应付很多问题:某人偶然连错服务器执行了一个ALTER TABLE(1)的操作,机房大楼被烧毁,恶意的黑客攻击或MySQL的Bug等。尽管遭受任何一个特殊的灾难的几率都非常低,但所有的风险叠加在一起就很有可能会碰到。\n人们改变想法\n不必惊讶,很多人经常会在删除某些数据后又想要恢复这些数据。\n审计\n有时候需要知道数据或Schema在过去的某个时间点是什么样的。例如,你也许被卷入一场法律官司,或发现了应用的一个Bug,想知道这段代码之前干了什么(有时候,仅仅依靠代码的版本控制还不够)。\n测试\n一个最简单的基于实际数据来测试的方法是,定期用最新的生产环境数据更新测试服务器。如果使用备份的方案就非常简单:只要把备份文件还原到测试服务器上即可。检查你的假设。例如,你认为共享虚拟主机供应商会提供MySQL服务器的备份?许多主机供应商根本不备份MySQL服务器,另外一些也仅仅在服务器运行时复制文件,这可能会创建一个损坏的没有用处的备份。\n15.2 定义恢复需求 # 如果一切正常,那么永远也不需要考虑恢复。但是,一旦需要恢复,只有世界上最好的备份系统是没用的,还需要一个强大的恢复系统。\n不幸的是,让备份系统平滑工作比构造良好的恢复过程和工具更容易。原因如下:\n备份在先。只有已经做了备份才可能恢复,因此在构建系统时,注意力自然会集中在备份上。 备份由脚本和任务自动完成。经常不经意地,我们会花些时间调优备份过程。花5分钟来对备份过程做小的调整看起来并不重要,但是你是否天天同样地重视恢复呢? 备份是日常任务,但恢复常常发生在危急情形下。 因为安全的需要,如果正在做异地备份,可能需要对备份数据进行加密,或采取其他措施来进行保护。安全性往往只关注数据被盗用的后果,但是有没有人想过,如果没有人能对用来恢复数据的加密卷解锁,或需要从一个整块的加密文件中抽取单个文件时,损害又是多大? 只有一个人来规划、设计和实施备份。当灾难袭来时,那个人可能不在。因此需要培养几个人并有计划地互为备份,这样就不会要求一个不合格的人来恢复数据。 这里有一个我们看到的真实例子:一个客户报告说当mysqldump加上-d选项后,备份变得像闪电一般快,他想知道为什么没有一个人提出该选项可以如此快地加速备份过程。如果这个客户已经尝试还原这些备份,就不难发现其原因:使用-d选项将不会备份数据!这个客户关注备份,却没有关注恢复,因此完全没有意识到这个问题。\n规划备份和恢复策略时,有两个重要的需求可以帮助思考:恢复点目标(PRO)和恢复时间目标(RTO)。它们定义了可以容忍丢失多少数据,以及需要等待多久将数据恢复。在定义RPO和RTO时,先尝试回答下面几类问题:\n在不导致严重后果的情况下,可以容忍丢失多少数据?需要故障恢复,还是可以接受自从上次日常备份后所有的工作全部丢失?是否有法律法规的要求? 恢复需要在多长时间内完成?哪种类型的宕机是可接受的?哪种影响(例如,部分服务不可用)是应用和用户可以接受的?当那些场景发生时,又该如何持续服务? 需要恢复什么?常见的需求是恢复整个服务器,单个数据库,单个表,或仅仅是特定的事务或语句。 建议将上面这些问题的答案明确地用文档记录下来,同时还应该明确备份策略,以及备份过程。\n备份误区1:“复制就是备份”\n这是我们经常碰到的一个误区。复制不是备份,当然使用RAID阵列也不是备份。为什么这么说?可以考虑一下,如果意外地在生产库上执行了DROP DATABASE,它们是否可以帮你恢复所有的数据?RAID和复制连这个简单的测试都没法通过。它们不是备份,也不是备份的替代品。只有备份才能满足备份的要求。\n15.3 设计MySQL备份方案 # 备份MySQL比看起来难。最基本的,备份仅是数据的一个副本,但是受限于应用程序的要求、MySQL的存储引擎架构,以及系统配置等因素,会让复制一份数据都变得很困难。\n在深入所有选项细节之前,先来看一下我们的建议:\n在生产实践中,对于大数据库来说,物理备份是必需的:逻辑备份太慢并受到资源限制,从逻辑备份中恢复需要很长时间。基于快照的备份,例如Percona XtraBackup和MySQL Enterprise Backup是最好的选择。对于较小的数据库,逻辑备份可以很好地胜任。 保留多个备份集。 定期从逻辑备份(或者物理备份)中抽取数据进行恢复测试。 保存二进制日志以用于基于故障时间点的恢复。expire_logs_days参数应该设置得足够长,至少可以从最近两次物理备份中做基于时间点的恢复,这样就可以在保持主库运行且不应用任何二进制日志的情况下创建一个备库。备份二进制日志与过期设置无关,二进制日志备份需要保存足够长的时间,以便能从最近的逻辑备份进行恢复。 完全不借助备份工具本身来监控备份和备份的过程。需要另外验证备份是否正常。 通过演练整个恢复过程来测试备份和恢复。测算恢复所需要的资源(CPU、磁盘空间、实际时间,以及网络带宽等)。 对安全性要仔细考虑。如果有人能接触生产服务器,他是否也能访问备份服务器?反过来呢? 弄清楚RPO和RTO可以指导备份策略。是需要基于故障时间点的恢复能力,还是从昨晚的备份中恢复但会丢失此后的所有数据就足够了?如果需要基于故障时间点的恢复,可能要建立日常备份并保证所需要的二进制日志是有效的,这样才能从备份中还原,并通过重放二进制日志来恢复到想要的时间点。\n一般说来,能承受的数据丢失越多,备份越简单。如果有非常苛刻的需求,要确保能恢复所有数据,备份就很困难。基于故障时间点的恢复也有几类。一个“宽松”的故障时间点恢复需求意味着需要重建数据,直到“足够接近”问题发生的时刻。一个“硬性”的需求意味着不能容忍丢失任何一个已提交的事务,即使某些可怕的事情发生(例如服务器着火了)。这需要特别的技术,例如将二进制日志保存在一个独立的SAN卷或使用DRBD磁盘复制。\n15.3.1 在线备份还是离线备份 # 如果可能,关闭MySQL做备份是最简单最安全的,也是所有获取一致性副本的方法中最好的,而且损坏或不一致的风险最小。如果关闭了MySQL,就根本不用关心InnoDB缓冲池中的脏页或其他缓存。也不需要担心数据在尝试备份的过程被修改,并且因为服务器不对应用提供访问,所以可以更快地完成备份。\n尽管如此,让服务器停机的代价可能比看起来要更昂贵。即使能最小化停机时间,在高负载和高数据量下关闭和重启MySQL也可能要花很长一段时间,这在第8章中讨论过。我们演示过一些使这个影响最小化的技术,但并不能将其减少为零。因此,必须要设计不需要生产服务器停机的备份。即便如此,由于一致性的需要,对服务器进行在线备份仍然会有明显的服务中断。\n在众多的备份方法中,一个最大问题就是它们会使用FLUSH TABLES WITH READ LOCK操作。这会导致MySQL关闭并锁住所有的表,将MyISAM的数据文件刷新到磁盘上(但InnoDB不是这样的!),并且刷新查询缓存。该操作需要非常长的时间来完成。具体需要多长时间是不可预估的;如果全局读锁要等待一个长时间运行的语句完成,或有许多表,那么时间会更长。除非锁被释放,否则就不能在服务器上更改任何数据,一切都会被阻塞和积压(2)。FLUSH TABLES WITH READ LOCK不像关闭服务器的代价那么高,因为大部分缓存仍然在内存中,并且服务器一直是“预热”的,但是它也有非常大的破坏性。如果有人说这样做很快,可能是准备向你推销某种从来没有在真正的线上服务器上运行过的东西。\n避免使用FLUSH TABLES WITH READ LOCK的最好的方法是只使用InnoDB表。在权限和其他系统信息表中使用MyISAM表是不可避免的,但是如果数据改变量很少(正常情况下),你可以只刷新和锁住这些表,这不会有什么问题。\n在规划备份时,有一些与性能相关的因素需要考虑。\n锁时间\n需要持有锁多长时间,例如在备份期间持有的全局FLUSH TABLES WITH READ LOCK?\n备份时间\n复制备份到目的地需要多久?\n备份负载\n在复制备份到目的地时对服务器性能的影响有多少?\n恢复时间\n把备份镜像从存储位置复制到MySQL服务器,重放二进制日志等,需要多久?\n最大的权衡是备份时间与备份负载。可以牺牲其一以增强另外一个。例如,可以提高备份的优先级,代价是降低服务器性能。\n同样,也可以利用负载的特性来设计备份。例如,如果服务器在晚上的8小时内仅仅有50%的负载,那么可以尝试规划备份,使得服务器的负载低于50%且仍能在8小时内完成。可以采用许多方法来完成这个目标,例如,可以用ionice和nice来提高复制或压缩操作的优先级,使用不同的压缩等级,或在备份服务器上压缩而不是在MySQL服务器上。甚至可以利用lzo或pigz以获取更快的压缩。也可以使用O_DIRECT或fadvise()在复制操作时绕开操作系统的缓存,以避免污染服务器的缓存。像Percona XtraBackup和MySQL Enterprise Backup这样的工具都有限流选项,可在使用pv时加\u0026ndash;rate-limit选项来限制备份脚本的吞吐量。\n15.3.2 逻辑备份还是物理备份 # 有两种主要的方法来备份MySQL数据:逻辑备份(也叫“导出”)和直接复制原始文件的物理备份。逻辑备份将数据包含在一种MySQL能够解析的格式中,要么是SQL,要么是以某个符号分隔的文本(3)。原始文件是指存在于硬盘上的文件。\n任何一种备份都有其优点和缺点。\n逻辑备份 # 逻辑备份有如下优点:\n逻辑备份是可以用编辑器或像grep和sed之类的命令查看和操作的普通文件。当需要恢复数据或只想查看数据但不恢复时,这都非常有帮助。 恢复非常简单。可以通过管道把它们输入到mysql,或者使用mysqlimport。 可以通过网络来备份和恢复——就是说,可以在与MySQL主机不同的另外一台机器上操作。 可以在类似Amazon RDS这样不能访问底层文件系统的系统中使用。 非常灵活,因为mysqldump——大部分人喜欢的工具——可以接受许多选项,例如可以用WHERE子句来限制需要备份哪些行。 与存储引擎无关。因为是从MySQL服务器中提取数据而生成,所以消除了底层数据存储和不同。因此,可以从InnoDB表中备份,然后只需极小的工作量就可以还原到MyISAM表中。而对于原始数据却不能这么做。 有助于避免数据损坏。如果磁盘驱动器有故障而要复制原始文件时,你将会得到一个错误并且/或生成一个部分或损坏的备份。如果MySQL在内存中的数据还没有损坏,当不能得到一个正常的原始文件复制时,有时可以得到一个可以信赖的逻辑备份。 尽管如此,逻辑备份也有它的缺点:\n必须由数据库服务器完成生成逻辑备份的工作,因此要使用更多的CPU周期。 逻辑备份在某些场景下比数据库文件本身更大(4)。ASCII形式的数据不总是和存储引擎存储数据一样高效。例如,一个整型需要4字节来存储,但是用ASCII写入时,可能需要12个字符。当然也可以压缩文件以得到一个更小的备份文件,但这样会使用更多的CPU资源。(如果索引比较多,逻辑备份一般要比物理备份小。) 无法保证导出后再还原出来的一定是同样的数据。浮点表示的问题、软件Bug等都会导致问题,尽管非常少见。 从逻辑备份中还原需要MySQL加载和解释语句,转化为存储格式,并重建索引,所有这一切会很慢。 最大的缺点是从MySQL中导出数据和通过SQL语句将其加载回去的开销。如果使用逻辑备份,测试恢复需要的时间将非常重要。\nPercona Server中包含的mysqldump,在使用InnoDB表时能起到帮助作用,因为它会对输出格式化,以便在重新加载时利用InnoDB的快速建索引的优点。我们的测试显示这样做可以减少2/3甚至更多的还原时间。索引越多,好处越明显。\n物理备份 # 物理备份有如下好处:\n基于文件的物理备份,只需要将需要的文件复制到其他地方即可完成备份。不需要其他额外的工作来生成原始文件。 物理备份的恢复可能就更简单了,这取决于存储引擎。对于MyISAM,只需要简单地复制文件到目的地即可。对于InnoDB则需要停止数据库服务,可能还要采取其他一些步骤。 InnoDB和MyISAM的物理备份非常容易跨平台、操作系统和MySQL版本。(逻辑导出亦如此。这里特别指出这一点是为了消除大家的担心。) 从物理备份中恢复会更快,因为MySQL服务器不需要执行任何SQL或构建索引。如果有很大的InnoDB表,无法完全缓存到内存中,则物理备份的恢复要快非常多——至少要快一个数量级。事实上,逻辑备份最可怕的地方就是不确定的还原时间。 物理备份也有其缺点,比如:\nInnoDB的原始文件通常比相应的逻辑备份要大得多。InnoDB的表空间往往包含很多未使用的空间。还有很多空间被用来做存储数据以外的用途(插入缓冲,回滚段等)。 物理备份不总是可以跨平台、操作系统及MySQL版本。文件名大小写敏感和浮点格式是可能会遇到麻烦。很可能因浮点格式不同而不能移动文件到另一个系统(虽然主流处理器都使用IEEE浮点格式。) 物理备份通常更加简单高效(5)。尽管如此,对于需要长期保留的备份,或者是满足法律合规要求的备份,尽量不要完全依赖物理备份。至少每隔一段时间还是需要做一次逻辑备份。\n除非经过测试,不要假定备份(特别是物理备份)是正常的。对InnoDB来说,这意味着需要启动一个MySQL实例,执行InnoDB恢复操作,然后运行CHECK TABLES。也可以跳过这一操作,仅对文件运行innochecksum,但我们不建议这样做。对于MyISAM,可以运行CHECK TABLES,或者使用mysqlcheck。使用mysqlcheck可以对所有的表执行CHECK TABLES操作。\n建议混合使用物理和逻辑两种方式来做备份:先使用物理复制,以此数据启动MySQL服务器实例并运行mysqlcheck。然后,周期性地使用mysqldump执行逻辑备份。这样做可以获得两种方法的优点,不会使生产服务器在导出时有过度负担。如果能够方便地利用文件系统的快照,也可以生成一个快照,将该快照复制到另外一个服务器上并释放,然后测试原始文件,再执行逻辑备份。\n15.3.3 备份什么 # 恢复的需求决定需要备份什么。最简单的策略是只备份数据和表定义,但这是一个最低的要求。在生产环境中恢复数据库一般需要更多的工作。下面是MySQL备份需要考虑的几点。\n非显著数据\n不要忘记那些容易被忽略的数据:例如,二进制日志和InnoDB事务日志。\n代码\n现代的MySQL服务器可以存储许多代码,例如触发器和存储过程。如果备份了mysql数据库,那么大部分这类代码也备份了,但如果需要还原单个业务数据库会比较麻烦,因为这个数据库中的部分“数据”,例如存储过程,实际是存放在mysql数据库中的。\n复制配置\n如果恢复一个涉及复制关系的服务器,应该备份所有与复制相关的文件,例如二进制日志、中继日志、日志索引文件和.info文件。至少应该包含SHOW MASTER STATUS和/或SHOW SLAVE STATUS的输出。执行FLUSH LOGS也非常有好处,可以让MySQL从一个新的二进制日志开始。从日志文件的开头做基于故障时间点的恢复要比从中间更容易。\n服务器配置\n假设要从一个实际的灾难中恢复,比如说,地震过后在一个新数据中心中构建服务器,如果备份中包含服务器配置,你一定会喜出望外。\n选定的操作系统文件\n对于服务器配置来说,备份中对生产服务器至关重要的任何外部配置,都十分重要。在UNIX服务器上,这可能包括cron任务、用户和组的配置、管理脚本,以及sudo规则。\n这些建议在许多场景下会被当作“备份一切”。然而,如果有大量的数据,这样做的开销将非常高,如何做备份,需要更加明智的考虑。特别是,可能需要在不同备份中备份不同的数据。例如,可以单独地备份数据、二进制日志和操作系统及系统配置。\n增量备份和差异备份 # 当数据量很庞大时,一个常见的策略是做定期的增量或差异备份。它们之间的区别有点容易让人混淆,所以先来澄清这两个术语:差异备份是对自上次全备份后所有改变的部分而做的备份,而增量备份则是自从任意类型的上次备份后所有修改做的备份。\n例如,假如在每周日做一个全备份。在周一,对自周日以来所有的改变做一个差异备份。在周二,就有两个选择:备份自周日以来所有的改变(差异),或只备份自从周一备份后所有的改变(增量)。\n增量和差异备份都是部分备份:它们一般不包含完整的数据集,因为某些数据几乎肯定没有改变。部分备份对减少服务器开销、备份时间及备份空间而言都很适合。尽管某些部分备份并不会真正减少服务器的开销。例如,Percona XtraBackup和MySQL Enterprise Backup,仍然会扫描服务器上的所有数据块,因而并不会节约太多的开销,但它们确实会减少一定量的备份时间和大量用于压缩的CPU时间,当然也会减少磁盘空间使用(6)。\n不要因为会用高级备份技术而自负,解决方案越复杂,可能面临的风险也越大。要注意分析隐藏的危险,如果多次迭代备份紧密地耦合在一起,则只要其中的一次迭代备份有损坏,就可能会导致所有的备份都无效。\n下面有一些建议:\n使用Percona XtraBackup和MySQL Enterprise Backup中的增量备份特性。 备份二进制日志。可以在每次备份后使用FLUSH LOGS来开始一个新的二进制日志,这样就只需要备份新的二进制日志。 不要备份没有改变的表。有些存储引擎,例如MyISAM,会记录每个表最后修改时间。可以通过查看磁盘上的文件或运行SHOW TABLE STATUS来看这个时间。如果使用InnoDB,可以利用触发器记录修改时间到一个小的“最后修改时间”表中,帮助跟踪最新的修改操作。需要确保只对变更不频繁的表进行跟踪,这样才能降低开销。通过定制的备份脚本可以轻松获取到哪些表有变更。\n例如,如果有包含不同语种各个月的名称列表,或者州或区域的简写之类的“查找”表,将它们放在一个单独的数据库中是个好主意,这样就不需要每次都备份这些表。 不要备份没有改变的行。如果一个表只做插入,例如记录网页页面点击的表,那么可以增加一个时间戳的列,然后只备份自上次备份后插入的行。 某些数据根本不需要备份。有时候这样做影响会很大——例如,如果有一个从其他数据构建的数据仓库,从技术上讲完全是冗余的,就可以仅备份构建仓库的数据,而不是数据仓库本身。即使从源数据文件重建仓库的“恢复”时间较长,这也是个好想法。相对于从全备中可能获得的快速恢复时间,避免备份可以节约更多的总的时间开销。临时数据也可以不用备份,例如保留网站会话数据的表。 备份所有的数据,然后发送到一个有去重特性的目的地,例如ZFS文件管理程序。 增量备份的缺点包括增加恢复复杂性,额外的风险,以及更长的恢复时间。如果可以做全备,考虑到简便性,我们建议尽量做全备。\n不管如何,还是需要经常做全备份——建议至少一周一次。你肯定不会希望使用一个月的所有增量备份来进行恢复。即使一周也还是有很多的工作和风险的。\n15.3.4 存储引擎和一致性 # MySQL对存储引擎的选择会导致备份明显更复杂。问题是,对于给定的存储引擎,如何得到一致的备份。\n实际上有两类一致性需要考虑:数据一致性和文件一致性。\n数据一致性 # 当备份时,应该考虑是否需要数据在指定时间点一致。例如,在一个电子商务数据库中,可能需要确保发货单和付款之间一致。恢复付款时如果不考虑相应的发货单,或反过来,都会导致麻烦。\n如果做在线备份(从一个运行的服务器做备份),可能需要所有相关表的一致性备份。这意味着不能一次锁住一张表然后做备份——因而意味着备份可能比预想的要更有侵入性。如果使用的不是事务型存储引擎,则只能在备份时用LOCK TABLES来锁住所有要一起备份的表,备份完成后再释放锁。\nInnoDB的多版本控制功能可以帮到我们。开始一个事务,转储一组相关的表,然后提交事务。(如果使用了事务获取一致性备份,则不能用LOCK TABLES,因为它会隐式地提交事务——详情参见MySQL手册。)只要在服务器上使用REPEATABLE READ事务隔离级别,并且没有任何DDL,就一定会有完美的一致性,以及基于时间点的数据快照,且在备份过程中不会阻塞任何后续的工作。\n尽管如此,这种方法并不能保护逻辑设计很差的应用。假如在电子商务库中插入一条付款记录,提交事务,然后在另外一个事务中插入一条发货单记录。备份过程可能在这两个操作之间开始,备份了付款记录却不包括发货单记录。这就是必须仔细设计事务以确保相关的操作放在一个组内的原因。\n也可以用mysqldump来获得InnoDB表的一致性逻辑备份,采用\u0026ndash;single-transaction选项可以按照我们所描述的那样工作。但是,这可能会导致一个非常长的事务,在某些负载下会导致开销大到不可接受。\n文件一致性 # 每个文件的内部一致性也非常重要——例如,一条大的UPDATE语句执行时备份反映不出文件的状态——并且所有要备份的文件相互间也应一致。如果没有内部一致的文件,还原时可能会感到惊讶(它们可能已经损坏)。如果是在不同的时间复制相关的文件,它们彼此可能也不一致。MyISAM的.MYD和.MYI文件就是个例子。InnoDB如果检测到不一致或损坏,会记录错误日志乃至让服务器崩溃。\n对于非事务性存储引擎,例如MyISAM,可能的选项是锁住并刷新表。这意味着要么用LOCK TABLES和FLUSH TABLES结合的方法以使服务器将内存中的变更刷到磁盘上,要么用FLUSH TABLES WITH READ LOCK。一旦刷新完成,就可以安全地复制MyISAM的原始文件。\n对于InnoDB,确保文件在磁盘上一致更困难。即使使用FLUSH TABLES WITH READ LOCK,InnoDB依旧在后台运行:插入缓存、日志和写线程继续将变更合并到日志和表空间文件中。这些线程设计上是异步的——在后台执行这些工作可以帮助InnoDB取得更高的并发性——正因为如此它们与LOCK TABLES无关。因此,不仅需要确保每个文件内部是一致的,还需要同时复制同一个时间点的日志和表空间文件。如果在备份时有其他线程在修改文件,或在与表空间文件不同的时间点备份日志文件,会在恢复后再次因系统损坏而告终。可以通过下面几个方法规避这个问题。\n等待直到InnoDB的清除线程和插入缓冲合并线程完成。可以观察SHOW INNODB STATUS的输出,当没有脏缓存或挂起的写时,就可以复制文件。尽管如此,这种方法可能需要很长一段时间;因为InnoDB的后台线程涉及太多的干扰而不太安全。所以我们不推荐这种方法。 在一个类似LVM的系统中获取数据和日志文件一致的快照,必须让数据和日志文件在快照时相互一致;单独取它们的快照是没有意义的。在本章后续的LVM快照中会讨论。 发送一个STOP信号给MySQL,做备份,然后再发送一个CONT信号来再次唤醒MySQL。看起来像是一个很少推荐的方法,但如果另外一种方法是在备份过程中需要关闭服务器,则这种方法值得考虑。至少这种技术不需要在重启服务器后预热。 在复制数据文件到其他地方后,就可以释放锁以使MySQL服务器再次正常运行。\n复制 # 从备库中备份最大的好处是可以不干扰主库,避免在主库上增加额外的负载。这是一个建立备库的好理由,即使不需要用它做负载均衡或高可用。如果钱是个问题,也可以把备份用的备库用于其他用途,例如报表服务——只要不对其做写操作,以确保备份时不会修改数据。备库不必只用于备份的目的;只需要在下次备份时能及时跟上主库,即使有时因作为其他用途导致复制延时也没有关系。\n当从备库备份时,应该保存所有关于复制进程的信息,例如备库相对于主库的位置。这对于很多情况都非常有用:克隆新的备库,重新应用二进制日志到主库上以获得指定时间点的恢复,将备库提升为主库等。如果停止备库,需要确保没有打开的临时表,因为它们可能导致不能重启备库。\n故意将一个备库延时一段时间对于某些灾难场景非常有用。例如延时复制一小时,当一个不期望的语句在主库上运行后,将有一个小时的时间观察到并在从中继日志重放之前停掉复制。然后可以将备库提升为主库,重放少量相关的日志事件,跳过错误的语句。这比我们后面将要讨论的指定时间点的恢复技术可能要快很多。Percona Toolkit中pt-slave-delay工具可以帮助实现这个方案。\n备库可能与主库数据不完全一样。许多人认为备库是主库完全一样的副本,但以我们的经验,主库与备库数据不匹配是很常见的,并且MySQL没有方法检测这个问题。检测这个问题的唯一方法是使用Percona Toolkit中的pt-table-checksum之类的工具。\n拥有一个复制的备库可能在诸如主库的硬盘烧坏时提供帮助,但却不能提供保证。复制不是备份。\n15.4 管理和备份二进制日志 # 服务器的二进制日志是备份的最重要因素之一。它们对于基于时间点的恢复是必需的,并且通常比数据要小,所以更容易进行频繁的备份。如果有某个时间点的数据备份和所有从那时以后的二进制日志,就可以重放自从上次全备以来的二进制日志并“前滚”所有的变更。\nMySQL复制也使用二进制日志。因此备份和恢复的策略经常和复制配置相互影响。\n二进制日志很“特别”。如果丢失了数据,你一定不希望同时丢失了二进制日志。为了让这种情况发生的几率减少到最小,可以在不同的卷上保存数据和二进制日志。即使在LVM下生成二进制日志的快照,也是可以的。为了额外的安全起见,可以将它们保存在SAN上,或用DRBD复制到另外一个设备上。\n经常备份二进制日志是个好主意。如果不能承受丢失超过30分钟数据的价值,至少要每30分钟就备份一次。也可以用一个配置\u0026ndash;log_slave_update的只读备库,这样可以获得额外的安全性。备库上日志位置与主库不匹配,但找到恢复时正确的位置并不难。最后,MySQL 5.6版本的mysqlbinlog有一个非常方便的特性,可连接到服务器上来实时对二进制日志做镜像,比起运行一个mysqld实例要简单和轻便。它与老版本是向后兼容的。\n请参考第8章和第10章中我们推荐的关于二进制日志的服务器配置。\n15.4.1 二进制日志格式 # 二进制日志包含一系列的事件。每个事件有一个固定长度的头,其中有各种信息,例如当前时间戳和默认的数据库。可以使用mysqlbinlog工具来查看二进制日志的内容,打印出一些头信息。下面是一个输出的例子。\n1 # at 277 2 #071030 10:47:21 server id 3 end_log_pos 369 Query thread_id=13 exec_time=0 error_code=0 3 SET TIMESTAMP=1193755641/*!*/; 4 insert into test(a) values(2)/*!*/; 第一行包含日志文件内的偏移字节值(本例中为277)。\n第二行包含如下几项。\n事件的日期和时间,MySQL会使用它们来产生SET TIMESTAMP语句。 原服务器的服务器ID,对于防止复制之间无限循环和其他问题是非常有必要的。 end_log_pos,下一个事件的偏移字节值。该值对一个多语句事务中的大部分事件是不正确的。在此类事务过程中,MySQL的主库会复制事件到一个缓冲区,但这样做的时候它并不知道下个日志事件的位置。 事件类型。本例中的类型是Query,但还有许多不同的类型。 原服务器上执行事件的线程ID,对于审计和执行CONNECTION_ID()函数很重要。 exec_time,这是语句的时间戳和写入二进制日志的时间之差。不要依赖这个值,因为它可能在复制落后的备库上会有很大的偏差。 在原服务器上事件产生的错误代码。如果事件在一个备库上重放时导致不同的错误,那么复制将因安全预警而失败。 后续的行包含重放变更时所需的数据。用户自定义的变更和任何其他特定设置,例如当语句执行时有效的时间戳,也将会出现在这里。\n如果使用的是MySQL 5.1中基于行的日志,事件将不再是SQL。而是可读性较差的由语句对表所做变更的“镜像”。\n15.4.2 安全地清除老的二进制日志 # 需要决定日志的过期策略以防止磁盘被二进制日志写满。日志增长多大取决于负载和日志格式(基于行的日志会导致更大的日志记录)。我们建议,如果可能,只要日志有用就尽可能保留。保留日志对于设置复制、分析服务器负载、审计和从上次全备按时间点进行恢复,都很有帮助。当决定想要保留日志多久时,应该考虑这些需求。\n一个常见的设置是使用expire_log_days变量来告诉MySQL定期清理日志。这个变量直到MySQL 4.1才引入;在此之前的版本,必须手动清理二进制日志。因此,你可能看到一些用类似下面的cron项来删除老的二进制日志的建议。\n0 0 * * * /usr/bin/find /var/log/mysql -mtime +* N* -name \u0026quot;mysql-bin.[0-9]*\u0026quot; | xargs rm 尽管这是在MySQL 4.1之前清除日志的唯一办法,但在新版本中不要这么做!用rm删除日志会导致mysql-bin.index状态文件与磁盘上的文件不一致,有些语句,例如SHOW MASTER LOGS可能会受到影响而悄然失败。手动修改mysql-bin.index文件也不会修复这个问题。应该用类似下面的cron命令。\n** 0 0 * * * /usr/bin/mysql -e \u0026quot;PURGE MASTER LOGS BEFORE CURRENT_DATE - INTERVAL***** N***** DAY\u0026quot;** expire_logs_days设置在服务器启动或MySQL切换二进制日志时生效,因此,如果二进制日志从没有增长和切换,服务器不会清除老条目。此设置是通过查看日志的修改时间而不是内容来决定哪个文件需要被清除。\n15.5 备份数据 # 大多数时候,生成备份有好的也有差的方法——有时候显而易见的方法并不是好方法。一个有用的技巧是应该最大化利用网络、磁盘和CPU的能力以尽可能快地完成备份。这是一个需要不断去平衡的事情,必须通过实验以找到“最佳平衡点”。\n15.5.1 生成逻辑备份 # 对于逻辑备份,首先要意识到的是它们并不是以同样方式创建的。实际上有两种类型的逻辑备份:SQL导出和符号分隔文件。\nSQL导出 # SQL导出是很多人所熟悉的,因为它们是mysqldump默认的方式。例如,用默认选项导出一个小表将产生如下(有删减)输出。\n** $ mysqldump test t1** ** -- [Version and host comments]** ** /*!40101 SET @OLD_CHARACTER_SET_CLIENT=@@CHARACTER_SET_CLIENT */;** ** -- [More version-specific comments to save options for restore]** ** --** ** -- Table structure for table `t1`** ** --** ** DROP TABLE IF EXISTS `t1`;** ** CREATE TABLE `t1` (** ** `a` int(11) NOT NULL,** ** PRIMARY KEY (`a`)** ** ) ENGINE=MyISAM DEFAULT CHARSET=latin1;** ** --** ** -- Dumping data for table `t1`** ** --** ** LOCK TABLES `t1` WRITE;** ** /*!40000 ALTER TABLE `t1` DISABLE KEYS */;** ** INSERT INTO `t1` VALUES (1);** ** /*!40000 ALTER TABLE `t1` ENABLE KEYS */;** ** UNLOCK TABLES;** ** /*!40103 SET TIME_ZONE=@OLD_TIME_ZONE */;** ** /*!40101 SET SQL_MODE=@OLD_SQL_MODE */;** ** -- [More option restoration]** 导出文件包含表结构和数据,均以有效的SQL命令形式写出。文件以设置MySQL各种选项的注释开始。这些要么是为了使恢复工作更高效,要么是因为兼容性和正确性。接下来可以看到表结构,然后是数据。最后,脚本重置在导出开始时变更的选项。\n导出的输出对于还原操作来说是可执行的。这很方便,但mysqldump默认选项对于生成一个巨大的备份却不是太适合(后续我们会深入介绍mysqldump的选项)。\nmysqldump不是生成SQL逻辑备份的唯一工具。例如,也可以用mydumper或phpMyAdmin工具来创建(7)。我们想指出的是,不是某一个特定的工具有多大的问题,而是做SQL逻辑备份本身就有一些缺点。下面是主要问题点:\nSchema和数据存储在一起\n如果想从单个文件恢复这样做会非常方便,但如果只想恢复一个表或只想恢复数据就很困难了。可以通过导出两次的方法来减缓这个问题——一次只导出数据,另外一次只导出Schema——但还是会有下一个麻烦。\n巨大的SQL语句\n服务器分析和执行SQL语句的工作量非常大,所以加载数据时会非常慢。\n单个巨大的文件\n大部分文本编辑器不能编辑巨大的或者包含非常长的行的文件。尽管有时候可以用命令行的流编辑器——例如sed或grep——来抽出需要的数据,但保持文件小型化仍然是更合适的。\n逻辑备份的成本很高\n比起逻辑备份这种从存储引擎中读取数据然后通过客户端/服务器协议发送结果集的方式,还有其他更高效的方法。\n这些限制意味着SQL导出在表变大时可能变得不可用。不过,还有另外一个选择:导出数据到符号分隔的文件中。\n符号分隔文件备份 # 可以使用SQL命令 SELECT INTO OUTFILE以符号分隔文件格式创建数据的逻辑备份。(可以用mysqldump的\u0026ndash;tab选项导出到符号分隔文件中)。符号分隔文件包含以ASCII展示的原始数据,没有SQL、注释和列名。下面是一个导出为逗号分隔值(CVS)格式的例子,对于表格形式的数据来说这是一个很好的通用格式。\n** mysql\u0026gt; SELECT * INTO OUTFILE '/tmp/t1.txt'** ** -\u0026gt; FIELDS TERMINATED BY ',' OPTIONALLY ENCLOSED BY '\u0026quot;'** ** -\u0026gt; LINES TERMINATED BY '\\n'** ** -\u0026gt; FROM test.t1;** 比起SQL导出文件,符号分隔文件要更紧凑且更易于用命令行工具操作,这种方法最大的优点是备份和还原速度更快。可以和导出时使用一样的选项,用LOAD DATA INFILE方法加载数据到表中:\n** mysql\u0026gt; LOAD DATA INFILE '/tmp/t1.txt'** ** -\u0026gt; INTO TABLE test.t1** ** -\u0026gt; FIELDS TERMINATED BY ',' OPTIONALLY ENCLOSED BY '\u0026quot;'** ** -\u0026gt; LINES TERMINATED BY '\\n';** 下面这个非正式的测试演示了SQL文件和符号分隔文件在备份和还原上的速度差异。在测试中,我们对生产数据做了些修改。导出的表看起来像下面这样:\n** CREATE TABLE load_test (** ** col1 date NOT NULL,** ** col2 int NOT NULL,** ** col3 smallint unsigned NOT NULL,** ** col4 mediumint NOT NULL,** ** col5 mediumint NOT NULL,** ** col6 mediumint NOT NULL,** ** col7 decimal(3,1) default NULL,** ** col8 varchar(10) NOT NULL default '',** ** col9 int NOT NULL,** ** PRIMARY KEY (col1, col2)** ** ) ENGINE=InnoDB;** 这张表有1500万行,占用近700MB的磁盘空间。表15-1对比了两种备份和还原方法的性能。可以看到测试中还原时间有较大的差异。\n表15-1:SQL和符号分隔导出所用的备份和恢复时间 方法 导出大小 导出时间 还原时间 SQL导出 727 MB 102 600 符号分隔导出 669 MB 86 301\n但是SELECT INTO OUTFILE方法也有一些限制。\n只能备份到运行MySQL服务器的机器上的文件中。(可以写一个自定义的SELECT INTO OUTFILE程序,在读取SELECT结果的同时写到磁盘文件中,我们已经看到有些人采用这种方法。) 运行MySQL的系统用户必须有文件目录的写权限,因为是由MySQL服务器来执行文件的写入,而不是运行SQL命令的用户。 出于安全原因,不能覆盖已经存在的文件,不管文件权限如何。 不能直接导出到压缩文件中。 某些情况下很难进行正确的导出或导入,例如非标准的字符集。 15.5.2 文件系统快照 # 文件系统快照是一种非常好的在线备份方法。支持快照的文件系统能够瞬间创建用来备份的内容一致的镜像。支持快照的文件系统和设备包括FreeBSD的文件系统、ZFS文件系统、GNU/Linux的逻辑卷管理(LVM),以及许多的SAN系统和文件存储解决方案,例如NetApp存储。\n不要把快照和备份相混淆。创建快照是减少必须持有锁的时间的一个简单方法;释放锁后,必须复制文件到备份中。事实上,有些时候甚至可以创建InnoDB快照而不需要锁定。我们将要展示两种使用LVM来对InnoDB文件系统做备份的方法,可以选择最小化锁或零锁的方案。\n快照对于特别用途的备份是一个非常好的方法。一个例子是在升级过程中遇到有问题而回退的情况。可以在升级前创建一个镜像,这样如果升级有问题,只需要回滚到该镜像。可以对任何不确定和有风险的操作都这么做,例如对一个巨大的表做变更(需要多少时间是未知的)。\nLVM快照是如何工作的 # LVM使用写时复制(copy-on-write)的技术来创建快照——例如,对整个卷的某个瞬间的逻辑副本。这与数据库中的MVCC有点像,不同的是它只保留一个老的数据版本。\n注意,我们说的不是物理副本。逻辑副本看起来好像包含了创建快照时卷中所有的数据,但实际上一开始快照是不包含数据的。相比复制数据到快照中,LVM只是简单地标记创建快照的时间点,然后对该快照请求读数据时,实际上是从原始卷中读取的。因此,初始的复制基本上是一个瞬间就能完成的操作,不管创建快照的卷有多大。\n当原始卷中某些数据有变化时,LVM在任何变更写入之前,会复制受影响的块到快照预留的区域中。LVM不保留数据的多个“老版本”,因此对原始卷中变更块的额外写入并不需要对快照做其他更多的工作。换句话说,对每个块只有第一次写入才会导致写时复制到预留的区域。\n现在,在快照中请求这些块时,LVM会从复制块中而不是从原始卷中读取。所以,可以继续看到快照中相同时间点的数据而不需要阻塞任何原始卷。图15-1描述了这个方案。\n图15-1:写时复制技术如何减少单个卷快照需要的大小\n快照会在/dev目录下创建一个新的逻辑卷,可以像挂载其他设备一样挂载它。\n理论上讲,这种技术可以对一个非常大的卷做快照,而只需要非常少的物理存储空间。但是,必须设置足够的空间,保证在快照打开时,能够保存所有期望在原始卷上更新的块。如果不预留足够的写时复制空间,当快照用完所有的空间后,设备就会变得不可用。这个影响就像拔出一个外部设备:任何从设备上读的备份工作都会因I/O错误而失败。\n先决条件和配置 # 创建一个快照的消耗几乎微不足道,但还是需要确保系统配置可以让你获取在备份瞬间的所有需要的文件的一致性副本。首先,确保系统满足下面这些条件。\n所有的InnoDB文件(InnoDB的表空间文件和InnoDB的事务日志)必须是在单个逻辑卷(分区)。你需要绝对的时间点一致性,LVM不能为多于一个卷做某个时间点一致的快照。(这是LVM的一个限制;其他一些系统没有这个问题。) 如果需要备份表定义,MySQL数据目录必须在相同的逻辑卷中。如果使用另外一种方法来备份表的定义,例如只备份Schema到版本控制系统中,就不需要担心这个问题。 必须在卷组中有足够的空闲空间来创建快照。需要多少取决于负载。当配置系统时,应该留一些未分配的空间以便后面做快照。 LVM有卷组的概念,它包含一个或多个逻辑卷。可以按照如下的方式查看系统中的卷组:\n** # vgs** VG #PV #LV #SN Attr VSize VFree vg 1 4 0 wz--n- 534.18G 249.18G 输出显示了一个分布在一个物理卷上的卷组,它有四个逻辑卷,大概有250GB空间空闲。如果需要,可用vgdisplay命令产生更详细的输出。现在让我们看一下系统上的逻辑卷:\n# ** lvs** LV VG Attr LSize Origin Snap% Move Log Copy% home vg -wi-ao 40.00G mysql vg -wi-ao 225.00G tmp vg -wi-ao 10.00G var vg -wi-ao 10.00G 输出显示mysql卷有225GB的空间。设备名是/dev/vg/mysql。这仅是个名字,尽管看起来像一个文件系统路径。更加让人困惑的是,还有个符号链接从相同名字的文件链到*/dev/mapper/vg-mysql的设备节点,用ls和mount*命令可以观察到。\n** # ls -l /dev/vg/mysql** lrwxrwxrwx 1 root root 20 Sep 19 13:08 /dev/vg/mysql -\u0026gt; /dev/mapper/vg-mysql ** # mount | grep mysql** /dev/mapper/vg-mysql on /var/lib/mysql 有了这个信息,就可以创建文件系统快照了。\n创建、挂载和删除LVM快照 # 一条命令就能创建快照。只需要决定快照存放的位置和分配给写时复制的空间大小即可。不要纠结于是否使用比想象中的需求更多的空间。LVM不会马上使用完所有指定的空间,只是为后续使用预留而已。因此多预留一点空间并没有坏处,除非你必须同时为其他快照预留空间。\n让我们来练习创建一个快照。我们给它16GB的写时复制空间,名字为backup_mysql。\n# ** lvcreate --size 16G --snapshot --name backup_mysql /dev/vg/mysql** Logical volume \u0026quot;backup_mysql\u0026quot; created 这里特意命名为backup_mysql卷而不是mysql_backup,是为了避免Tab键自动补全造成误会。这有助于避免因为Tab键自动补全导致突然误删除mysql卷组的可能。\n现在让我们看看新创建的卷的状态。\n** # lvs** LV VG Attr LSize Origin Snap% Move Log Copy% backup_mysql vg swi-a- 16.00G mysql 0.01 home vg -wi-ao 40.00G mysql vg owi-ao 225.00G tmp vg -wi-ao 10.00G var vg -wi-ao 10.00G 可以注意到,快照的属性与原设备不同,而且该输出还显示了一点额外的信息:原始卷组和分配了16GB的写时复制空间目前已经使用了多少。备份时对此进行监控是个非常好的主意,可以知道是否会因为设备写满而备份失败。可以交互地监控设备的状态,或使用诸如Nagios这样的监控系统。\n# ** watch 'lvs | grep backup'** 从前面mount的输出可以看到,mysql卷包含一个文件系统。这意味着快照也同样如此,可以像其他文件系统一样挂载。\n** # mkdir /tmp/backup** ** # mount /dev/mapper/vg-backup_mysql /tmp/backup** ** # ls -l /tmp/backup/mysql** total 5336 -rw-r----- 1 mysql mysql 0 Nov 17 2006 columns_priv.MYD -rw-r----- 1 mysql mysql 1024 Mar 24 2007 columns_priv.MYI -rw-r----- 1 mysql mysql 8820 Mar 24 2007 columns_priv.frm -rw-r----- 1 mysql mysql 10512 Jul 12 10:26 db.MYD -rw-r----- 1 mysql mysql 4096 Jul 12 10:29 db.MYI -rw-r----- 1 mysql mysql 9494 Mar 24 2007 db.frm ... omitted ... 这里只是为了练习,因此我们卸载这个快照并用lvremove命令将其删除。\n** # umount /tmp/backup** ** # rmdir /tmp/backup** ** # lvremove --force /dev/vg/backup_mysql** Logical volume \u0026quot;backup_mysql\u0026quot; successfully removed 用于在线备份的LVM快照 # 现在已经知道如何创建、加载和删除快照,可以使用它们来进行备份了。首先看一下如何在不停止MySQL服务的情况下备份InnoDB数据库,这里需要使用一个全局的读锁。连接MySQL服务器并使用一个全局读锁将表刷到磁盘上,然后获取二进制日志的位置:\nmysql\u0026gt; ** FLUSH TABLES WITH READ LOCK; SHOW MASTER STATUS;** 记录SHOW MASTER STATUS的输出,确保到MySQL的连接处于打开状态,以使读锁不被释放。然后获取LVM的快照并立刻释放该读锁,可以使用UNLOCK TABLES或者直接关闭连接来释放锁。最后,加载快照并复制文件到备份位置。\n这种方法最主要的问题是,获取读锁可能需要一点时间,特别是当有许多长时间运行的查询时。当连接等待全局读锁时,所有的查询都将被阻塞,并且不可预测这会持续多久。\n文件系统快照和InnoDB\n即使锁住所有的表,InnoDB的后台线程仍会继续工作,因此,即使在创建快照时,仍然可以往文件中写入。并且,由于InnoDB没有执行关闭操作,如果服务器意外断电,快照中InnoDB的文件会和服务器意外掉电后文件的遭遇一样。\n这不是什么问题,因为InnoDB是个ACID系统。任何时刻(例如快照时),每个提交的事务要么在InnoDB数据文件中要么在日志文件中。在还原快照后启动MySQL时,InnoDB将运行恢复进程,就像服务器断过电一样。它会查找事务日志中任何提交但没有应用到数据文件中的事务然后应用,因此不会丢失任何事务。这正是要强制InnoDB数据文件和日志文件在一起快照的原因。\n这也是在备份后需要测试的原因。启动一个MySQL实例,把它指向一个新备份,让InnoDB执行崩溃恢复过程,然后检测所有的表。通过这种方法,就不会备份损坏了却还不知道(文件可能由于任何原因损坏)。这么做的另外一个好处是,未来需要从备份中还原时会更快,因为已经在备份上运行过一遍恢复程序了。\n甚至还可以在将快照复制到备份目的地之前,直接在快照上做上面的操作,但增加一点点额外开销。所以需要确保这是计划内的操作。(后面会有更多说明。)\n使用LVM快照无锁InnoDB备份 # 无锁备份只有一点不同。区别是不需要执行FLUSH TABLES WITH READ LOCK。这意味着不能保证MyISAM文件在磁盘上一致,如果只使用InnoDB,这就不是问题。mysql系统数据库中依然有部分MyISAM表,但如果是典型的工作负载,在快照时这些表不太可能发生改变。\n如果你认为mysql系统表可能会变更,那么可以锁住并刷新这些表。一般不会对这些表有长时间运行的查询,所以通常会很快。\nmysql\u0026gt; ** LOCK TABLES mysql.user READ, mysql.db READ, ...;** mysql\u0026gt; ** FLUSH TABLES mysql.user, mysql.db, ...;** 由于没有用全局读锁,因此不会从SHOW MASTER STATUS中获取到任何有用的信息。尽管如此,基于快照启动MySQL(来验证备份的完整性)时,也将会在日志文件中看到像下面的内容。\nInnoDB: Doing recovery: scanned up to log sequence number 0 40817239 InnoDB: Starting an apply batch of log records to the database... InnoDB: Progress in percents: 3 4 5 6 ...[omitted]... 97 98 99 InnoDB: Apply batch completed InnoDB: ** Last MySQL binlog file position 0 3304937, file name** ** /var/log/mysql/mysql-bin.000001** 070928 14:08:42 InnoDB: Started; log sequence number 0 40817239 InnoDB记录了MySQL已经恢复的时间点对应的二进制日志位置。这个二进制日志位置可以用来做基于时间点的恢复。\n使用快照进行无锁备份的方法在MySQL 5.0或更新版本中有变动。这些MySQL版本使用XA来协调InnoDB和二进制日志。如果还原到一个与备份时server_id不同的服务器,服务器在准备事务阶段可能发现这是从另外一个与自己有不同ID的服务器来的。在这种情况下,服务器会变得困惑,恢复事务时可能会卡在PREPARED状态。这种情况很少发生,但是存在可能性。这也是只有经过验证才可以说备份成功的原因。有些备份也许是不能恢复的。\n如果是在备库上获取快照,InnoDB恢复时还会打印如下几行日志。\nInnoDB: In a MySQL replica the last master binlog file InnoDB: position 0 115, file name mysql-bin.001717 输出显示了InnoDB已经恢复的基于主库的二进制日志位置(相对于备库二进制日志位置),这对于基于备库备份或基于其他备库克隆备库来说非常有用。\n规划LVM备份 # LVM快照备份也是有开销的。服务器写到原始卷的越多,引发的额外开销也越多。当服务器随机修改许多不同块时,磁头需要自写时复制空间来来回回寻址,并且将数据的老版本写到写时复制空间。从快照中读取也有开销,因为LVM需要从原始卷中读取大部分数据。只有快照创建后修改过的数据从写时复制空间读取;因此,逻辑顺序读取快照数据实际上也可能导致磁头来回移动。\n所以应该为此规划好快照。快照实际上会导致原始卷和快照都比正常的读/写性能要差——如果使用过多的写时复制空间,性能可能会差很多。这会降低MySQL服务器和复制文件进行备份的性能。我们做了基准测试,发现LVM快照的开销要远高于它本应该有的——我们发现性能最多可能会慢5倍,具体取决于负载和文件系统。在规划备份时要记得这一点。\n规划中另外一个重要的事情是,为快照分配足够多的空间。我们一般采取下面的方法。\n记住,LVM只需要复制每个修改块到快照一次。MySQL写一个块到原始卷中时,它会复制这个块到快照中,然后对复制的块在例外表中生成一个标记。后续对这个块的写不会产生任何到快照的复制。 如果只使用InnoDB,要考虑InnoDB是如何写数据的。InnoDB实际需要对数据写两遍,至少一半的InnoDB的写I/O会到双写缓冲(doublewrite buffer)、日志文件,以及其他磁盘上相对小的区域中。这部分会多次重用相同的磁盘块,因此第一次时对快照有影响,但写过一次以后就不会对快照带来写压力。 接下来,相对于反复修改同样的数据,需要评估有多少I/O需要写入到那些还没有复制到快照写时复制空间的块中,对评估的结果要保留足够的余量。 使用vmstat或iostat来收集服务器每秒写多少块的统计信息。 衡量(或评估)复制备份到其他地方需要多久。换言之,需要在复制期间保持LVM快照打开多长时间。 假设评估出有一半的写会导致往快照的写时复制空间的写操作,并且服务器支持10MB/s的写入。如果需要一个小时(3600s)将快照复制到另外一个服务器上,那么将需要1/2×10MB×3600即18GB的快照空间。考虑到容错,还要增加一些额外的空间。\n有时候当快照保持打开时,很容易计算会有多少数据发生改变。让我们看个例子。BoardReader论坛搜索引擎每个存储节点有约1TB的InnoDB表。但是,我们知道最大的开销是加载新数据。每天新增近10GB的数据,因此50GB的快照空间应该完全足够。然而这样来评估并不总是正确的。假设在某个时间点,有一个长时间运行的依次修改每个分片的ALTER TABLE操作,它会修改超过50GB的数据;在这个时间点,就不能做备份操作。为了避免这样的问题,可以稍后再创建快照,因为创建快照后会导致一个负载的高峰。\n备份误区2:“快照就是备份”\n一个快照,不论是LVM快照、ZFS快照,还是SAN快照,都不是实际的备份,因为它不包含数据的完整副本。正因为快照是写时复制的,所以它只包含实际数据和快照发生的时间点的数据之间的差异数据。如果一个没有被修改的块在备份副本时被损坏,那就没有该块的正常副本可以用来恢复,并且备份副本时每个快照看到的都是相同的损坏的块。可以使用快照来“冻结”备份时的数据,但不要把快照当作一个备份。\n快照的其他用途和替代方案 # 快照有更多的其他用途,而不仅仅用于备份。例如,之前提到,在一个有潜在危险的动作之前生成一个“检查点”会有帮助。有些系统允许将快照提升为原文件系统,这使得回滚到生成快照的时间点的数据非常简单。\n文件系统快照不是取得数据瞬间副本的唯一方法。另外一个选择是RAID分裂:举个例子,如果有一个三磁盘的软RAID镜像,就可以从该RAID组中移出来一个磁盘单独加载。这样做没有写时复制的代价,并且需要时将此类“快照”提升为主副本的操作也很简单。不错,如果要将磁盘加回到RAID集合,就必须重新进行同步。当然,天下没有免费的午餐。\n15.6 从备份中恢复 # 如何恢复数据取决于是怎么备份的。可能需要以下部分或全部步骤。\n停止MySQL服务器。 记录服务器的配置和文件权限。 将数据从备份中移到MySQL数据目录。 改变配置。 改变文件权限。 以限制访问模式重启服务器,等待完成启动。 载入逻辑备份文件。 检查和重放二进制日志。 检测已经还原的数据。 以完全权限重启服务器。 我们在接下来的章节中将演示这些步骤的具体操作。我们也会对本节及本章后面几节提及的一些特殊的备份方法和工具做一些解释。\n如果有机会使用文件的当前版本,就不要用备份中的文件来代替。例如,如果备份包含二进制日志,并且需要重放这些日志来做基于时间点的恢复,那么不要把当前二进制日志用备份中的老的副本替代。如果有需要,可以将其重命名或移动到其他地方。\n在恢复过程中,保证MySQL除了恢复进程外不接受其他访问,这一点往往比较重要。我们喜欢以\u0026ndash;skip-networking和\u0026ndash;socket=/tmp/mysql_recover.sock选项来启动MySQL,以确保它对于已经存在的应用不可访问,直到我们检测完并重新提供服务。这对于按块加载的逻辑备份的恢复来说尤其重要。\n15.6.1 恢复物理备份 # 恢复物理备份往往非常直接——换言之,没有太多的选项。这可能是好事,也可能是坏事,具体取决于恢复的需求。一般过程是简单地复制文件到正确位置。\n是否需要关闭MySQL取决于存储引擎。MyISAM的文件一般相互独立,即使服务器正在运行,简单地复制每个表的.frm、.MYI和.MYD文件也可以正常操作。一旦有任何对此表的查询,或者其他会导致服务器访问此表的操作(例如,执行SHOW TABLES), MySQL都会立刻找到这些表。如果在复制这些文件时表是打开的,可能会有麻烦,因此操作前要么删除或重命名该表,要么使用LOCK TABLES和FLUSH TABLES来关闭它。\nInnoDB的情况有所不同。如果用传统的InnoDB的步骤来还原,即所有表都存储在单个表空间,就必须关闭MySQL,复制或移动文件到正确位置上,然后重启。同样也需要InnoDB的事务日志文件与表空间文件匹配。如果文件不匹配——例如,替换了表空间文件但没有替换事务日志文件——InnoDB将会拒绝启动。这也是将日志和数据文件一起备份非常关键的一个原因。\n如果使用InnoDB file-per-table特性(innodb_file_per_table),InnoDB会将每个表的数据和索引存储于一个.ibd文件中,这就像MyISAM的.MYI和.MYD文件合在一起。可以在服务器运行时通过复制这些文件来备份和还原单个表,但这并不像MyISAM中那样简单。这些文件并不完全独立于InnoDB。每个.ibd文件都有一些内部的信息,保存着它与主(共享)表空间之间的关系。在还原这样的文件时,需要让InnoDB先“导入”这个文件。\n这个过程有许多的限制,如果有需要可以阅读MySQL用户手册中关于每个表使用独立表空间中的部分。最大的限制是只能在当初备份的服务器上还原单个表。用这种配置来备份和还原多个表不是不可能,但可能比想象的要更棘手。\nPercona Server和Percona XtraBackup有一些改进,放宽了部分关于这个过程的限制,例如同一服务器的限制。\n所有这些复杂度意味着还原物理备份会非常乏味,并且容易出错。一个好的值得倡导的规则是,恢复过程越难越复杂,也就越需要逻辑备份的保护。为了防止一些无法意料的情况或者某些无法使用物理备份的场景,准备好逻辑备份总是值得推荐的。\n还原物理备份后启动MySQL # 在启动正在恢复的MySQL服务器之前,还有些步骤要做。\n首先,最重要且最容易忘记的事情,是在启动MySQL服务器之前检查服务器的配置,确保恢复的文件有正确的归属和权限。这些属性必须完全正确,否则MySQL可能无法启动。这些属性因系统的不同而不同,因此要仔细检查是否和之前做的记录吻合。一般都需要mysql用户和组拥有这些文件和目录,并且只有这个用户和组拥有可读/写权限。\n建议观察MySQL启动时的错误日志。在UNIX类系统上,可以如下观察文件。\n$ ** tail -f /var/log/mysql/mysql.err** 注意错误日志的准确位置会有所不同。一旦开始监测文件,就可以启动MySQL服务器并监测错误。如果一切进展顺利,MySQL启动后就有一个恢复好的数据库服务器了。\n观察错误日志对于新的MySQL版本更为重要。老版本在InnoDB有错时不会启动,但新版本不管怎样都会启动,而只是让InnoDB失效。即使服务器看起来启动没有任何问题,也应该对每个数据库运行SHOW TABLE STATUS来再次检测错误日志。\n15.6.2 还原逻辑备份 # 如果还原的是逻辑备份而不是物理备份,则与使用操作系统简单地复制文件到适当位置的方式不同,需要使用MySQL服务器本身来加载数据到表中。\n在加载导出文件之前,应该先花一点时间考虑文件有多大,需要多久加载完,以及在启动之前还需要做什么事情,例如通知用户或禁掉部分应用。禁掉二进制日志也是个好主意,除非需要将还原操作复制到备库:服务器加载一个巨大的导出文件的代价很高,并且写二进制日志会增加更多的(可能没有必要的)开销。加载巨大的文件对于一些存储引擎也有影响。例如,在单个事务中加载100GB数据到InnoDB就不是个好想法,因为巨大的回滚段将会导致问题。应该以可控大小的块来加载,并且逐个提交事务。有两种类型的逻辑备份,所以相应地有两种类型的还原操作。\n加载SQL文件 # 如果有一个SQL导出文件,它将包含可执行的SQL。需要做的就是运行这个文件。假设备份Sakila示例数据库和Schema到单个文件,下面是用来还原的常用命令。\n** $ mysql \u0026lt; sakila-backup.sql** 也可以从mysql命令行客户端用SOURCE命令加载文件。这只是做相同事情的不同方法,不过该方法使得某些事情更简单。例如,如果你是MySQL管理用户,就可以关闭用客户端连接执行时的二进制记录,然后加载文件而不需要重启MySQL服务器。\nmysql\u0026gt; ** SET SQL_LOG_BIN = 0;** mysql\u0026gt; ** SOURCE sakila-backup.sql;** mysql\u0026gt; ** SET SQL_LOG_BIN = 1;** 需要注意的是,如果使用SOURCE,当定向文件到mysql时,默认情况下,发生一个错误不会导致一批语句退出。\n如果备份做过压缩,那么不要分别解压缩和加载。应该在单个操作中完成解压缩和加载。这样做会快很多。\n** $ gunzip –c sakila-backup.sql.gz | mysql** 如果想用SOURCE命令加载一个压缩文件,可参考下节中关于命名管道的讨论。\n如果只想恢复单个表(例如,actor表),要怎么做呢?如果数据没有分行但有schema信息,那么还原数据并不难。\n** $ grep 'INSERT INTO ‘actor‘' sakila-backup.sql | mysql sakila** 或者,如果文件是压缩过的,那么命令如下。\n** $ gunzip –c sakila-backup.sql.gz | grep 'INSERT INTO ‘actor‘'| mysql sakila** 如果需要创建表并还原数据,而在单个文件中有整个数据库,则必须先编辑这个文件。这也是有一些人喜欢导出每个表到各自文件中的原因。大部分编辑器无法应付巨大的文件,尤其如果它们是压缩过的。另外,也不会想实际地编辑文件本身——只想抽取相关的行——因此可能必须做一些命令行工作。使用grep来仅抽出给定表的INSERT语句较简单,就像我们在前面命令中做的那样,但得到CREATE TABLE语句比较难。下面是抽取所需段落的sed脚本。\n$ ** sed -e '/./{H;$!d;}' -e 'x;/CREATE TABLE `actor`/!d;q' sakila-backup.sql** 我们得承认这条命令非常隐晦。如果必须以这种方式还原数据,那只能说明备份设计非常糟糕。如果有一点规划,可能就不会需要痛苦地去尝试弄清楚sed如何工作了。只需要备份每个表到各自的文件,或者可以更进一步,分别备份数据和Schema。\n加载符号分隔文件 # 如果是通过SELECT INTO OUTFILE导出的符号分隔文件,可以使用LOAD DATA INFILE通过相同的参数来加载。也可以用mysqlimport,这是LOAD DATA INFILE的一个包装。这种方式依赖命名约定决定从哪里加载一个文件的数据。\n我们希望你导出了Schema,而不仅是数据。如果是这样,那应该是一个SQL导出,就可以使用上一节中描述的技术来加载。\n使用LOAD DATA INFILE有一个非常好的优化技巧。LOAD DATA INFILE必须直接从文本文件中读取,因此,如果是压缩文件很多人会在加载前先解压缩,这是非常慢的磁盘密集型的操作。然而,在支持FIFO“命名管道”文件的系统如GNU/Linux上,对这种操作有个很好的方法。首先,创建一个命名管道并将解压缩数据流到它里面。\n** $ mkfifo /tmp/backup/default/sakila/payment.fifo** ** $ chmod 666 /tmp/backup/default/sakila/payment.fifo** ** $ gunzip -c /tmp/backup/default/sakila/payment.txt.gz** ** \u0026gt; /tmp/backup/default/sakila/payment.fifo** 注意我们使用了一个大于号字符(\u0026gt;)来重定向解压缩输出到payment.fifo文件中——而不是在不同程序之间创建匿名管道的管道符号。\n管道会等待,直到其他程序打开它并从另外一端读取数据。简单一点说,MySQL服务器可以从管道中读取解压缩后的数据,就像其他文件一样。如果可能,不要忘记禁掉二进制日志。\nmysql\u0026gt; ** SET SQL_LOG_BIN = 0; -- Optional** -\u0026gt; ** LOAD DATA INFILE '/tmp/backup/default/sakila/payment.fifo'** -\u0026gt; ** INTO TABLE sakila.payment;** Query OK, 16049 rows affected (2.29 sec) Records: 16049 Deleted: 0 Skipped: 0 Warnings: 0 一旦MySQL加载完数据,gunzip就会退出,然后可以删除该命令管道。在MySQL命令行客户端使用SOURCE命令加载压缩的文件也可以使用此技术。Percona Toolkit中的pt-fifo-split程序还可以帮助分块加载大文件,而不是在单个大事务中操作,这样效率更高。\n你无法从这里到达那里\n本书的作者之一曾将一列从DATETIME变为TIMESTAMP,以节约空间并使处理过程更快,就像第3章中推荐的那样。结果表定义如下。\n** CREATE TABLE tbl (** ** col1 timestamp NOT NULL,** ** col2 timestamp NOT NULL default CURRENT_TIMESTAMP** ** on update CURRENT_TIMESTAMP,** ** ... more columns ...** ); 这个表定义在MySQL 5.0.40版本上导致了一个语法错误,而这是创建时的版本。可以执行导出,但无法加载。这很奇怪,诸如这样无法预料的错误也是测试备份重要的原因之一。你永远不会知道什么会阻止你还原数据!\n15.6.3 基于时间点的恢复 # 对MySQL做基于时间点的恢复常见的方法是还原最近一次全备份,然后从那个时间点开始重放二进制日志(有时叫“前滚恢复”)。只要有二进制日志,就可以恢复到任何希望的时间点。甚至可以不太费力地恢复单个数据库。\n主要的缺点是二进制日志重放可能会是一个很慢的过程。它大体上等同于复制。如果有一个备库,并且已经测量到SQL线程的利用率有多高,那么对重放二进制日志会有多快就会心里有数了。例如,如果SQL线程约有50%被利用,则恢复一周二进制日志的工作可能在三到四天内完成。\n一个典型场景是对有害的语句的结果做回滚操作,例如DROP TABLE。让我们看一个简化的例子,看只有MyISAM表的情况下该如何做。假如是在半夜,备份任务在运行与下面所列相当的语句,复制数据库到同一服务器上的其他地方。\nmysql\u0026gt; ** FLUSH TABLES WITH READ LOCK;** -\u0026gt; server1# ** cp -a /var/lib/mysql/sakila /backup/sakila;** mysql\u0026gt; ** FLUSH LOGS;** -\u0026gt; server1# ** mysql -e \u0026quot;SHOW MASTER STATUS\u0026quot; --vertical \u0026gt; /backup/master.info;** mysql\u0026gt; ** UNLOCK TABLES;** 然后,假设有人在晚些时间运行下列语句。\nmysql\u0026gt; ** USE sakila;** mysql\u0026gt; ** DROP TABLE sakila.payment;** 为了便于说明,我们先假设可以单独地恢复这个数据库(即此库中的表不涉及跨库查询)。再假设是直到后来出问题才意识到这个有问题的语句。目标是恢复数据库中除了有问题的语句之外所有发生的事务。也就是说,其他表已经做的所有修改都必须保持,包括有问题的语句运行之后的修改。\n这并不是很难做到。首先,停掉MySQL以阻止更多的修改,然后从备份中仅恢复sakila数据库。\nserver1# ** /etc/init.d/mysql stop** server1# ** mv /var/lib/mysql/sakila /var/lib/mysql/sakila.tmp** server1# ** cp –a /backup/sakila /var/lib/mysql** 再到运行的服务器的my.cnf中添加如下配置以禁止正常的连接。\nskip-networking socket=/tmp/mysql_recover.sock 现在可以安全地启动服务器了。\nserver1# ** /etc/init.d/mysql start** 下一个任务是从二进制日志中分出需要重放和忽略的语句。事发时,自半夜的备份以来,服务器只创建了一个二进制日志。我们可以用grep来检查二进制日志文件以找到问题语句。\nserver1# ** mysqlbinlog --database=sakila /var/log/mysql/mysql-bin.000215** ** | grep -B3 -i 'drop table sakila.payment'** # at 352 #070919 16:11:23 server id 1 end_log_pos 429 Query thread_id=16 exec_time=0 error_code=0 SET TIMESTAMP=1190232683/*!*/; DROP TABLE sakila.payment/*!*/; 可以看到,我们想忽略的语句在日志文件中的352位置,下一个语句位置是429。可以用下面的命令重放日志直到352位置,然后从429继续。\nserver1# ** mysqlbinlog --database=sakila /var/log/mysql/mysql-bin.000215** ** --stop-position=352 | mysql -uroot –p** server1# ** mysqlbinlog --database=sakila /var/log/mysql/mysql-bin.000215** ** --start-position=429 | mysql -uroot -p** 接下来要做的是检测数据以确保没有问题,然后关闭服务器并撤消对my.cnf的改变,最后重启服务器。\n15.6.4 更高级的恢复技术 # 复制和基于时间点的恢复使用的是相同的技术:服务器的二进制日志。这意味着复制在恢复时会是个非常有帮助的工具,哪怕方式不是很明显。在本节中我们将演示一些可以用到的方法。这里列出来的不是一个完全的列表,但应该可以为你根据需求设计恢复方案带来一些想法。记得编写脚本,并且对恢复过程中需要用到的所有技术进行预演。\n用于快速恢复的延时复制 # 在本章的前面已经提到,如果有一个延时的备库,并且在备库执行问题语句之前就发现了问题,那么基于时间点的恢复就更快更容易了。\n恢复的过程与本章前几节描述的有点不一样,但思路是相同的。停止备库,用START SLAVE UNTIL来重放事件直到要执行问题语句。接着,执行SET GLOBAL SQL_SLAVE_SKIP_COUNTER=1来跳过问题语句。如果想跳过多个事件,可以设置一个大于1的值(或简单地使用CHANGE MASTER TO来前移备库在日志中的位置)。\n然后要做的就是执行START SLAVE,让备库执行完所有的中继日志。这样就利用备库完成了基于时间点的恢复中所有冗长的工作。现在可以将备库提升为主库,整个恢复过程基本上没有中断服务。\n即使没有延时的备库来加速恢复,普通的备库也有好处,至少会把主库的二进制日志复制到另外的机器上。如果主库的磁盘坏了,备库上的中继日志可能就是唯一能够获取到的最接近主库二进制日志的东西了。\n使用日志服务器进行恢复 # 还有另外一种使用复制来做恢复的方法:设置日志服务器。我们感觉复制比mysqlbinlog更可靠,mysqlbinlog可能会有一些导致异常行为的奇怪的Bug和不常见的情况。使用日志服务器进行恢复比mysqlbinlog更灵活更简单,不仅因为START SLAVE UNTIL选项,还因为那些可以采用的复制规则(例如replicate-do-table)。使用日志服务器,相对其他的方式来说,可以做到更复杂的过滤。\n例如,使用日志服务器可以轻松地恢复单个表。而用mysqlbinlog和命令行工具则要困难得多——事实上,这样做太复杂了,所以我们一般不建议进行尝试。\n假设粗心的开发人员像前面的例子一样删除了同样的表,现在想恢复此误操作,但又不想让整个服务器退到昨晚的备份。下面是利用日志服务器进行恢复的步骤:\n将需要恢复的服务器叫作server1。\n在另外一台叫做server2的服务器上恢复昨晚的备份。在这台服务器上运行恢复进程,以免在恢复时犯错而导致事情更糟。\n按照第10章的做法设置日志服务器来接收server1的二进制日志(复制日志到另外一个服务器并设置日志服务器是个好想法,但是要格外注意。)\n改变server2的配置文件,增加如下内容。\nreplicate-do-table=sakila.payment 重启server2,然后用CHANGE MASTER TO来让它成为日志服务器的备库。配置它从昨晚备份的二进制日志坐标读取。这时候切记不要运行START SLAVE。\n检测server2上的SHOW SLAVE STATUS的输出,验证一切正常。要三思而行!\n找到二进制日志中问题语句的位置,在server2上执行START SLAVE UNTIL来重放事件直到该位置。\n在server2上用STOP SLAVE停掉复制进程。现在应该有被删除表,因为现在从库停止在被删除之前的时间点。\n将所需表从server2复制到server1。\n只有没有任何多表的UPDATE、DELETE或INSERT语句操作这个表时,上述流程才是可行的。任何这样的多表操作语句在被记录的时候,可能是基于多个数据库的状态,而不仅仅是当前要恢复的这个数据库,所以这样恢复出来的数据可能和原始的有所不同。(只有在使用基于语句的二进制日志时才会有这个问题;如果使用的是基于行的日志,重放过程不会碰到这个错误。)\n15.6.5 InnoDB崩溃恢复 # InnoDB在每次启动时都会检测数据和日志文件,以确认是否需要执行恢复过程。而且, InnoDB的恢复过程与我们在本章之前谈论的不是一回事。它并不是恢复备份的数据;而是根据日志文件将事务应用到数据文件,将未提交的变更从数据文件中回滚。\n精确地描述InnoDB如何进行恢复工作,这有点太过复杂。我们要关注的焦点是当InnoDB有严重问题时如何实际执行恢复。\n大部分情况下InnoDB可以很好地解决问题。除非MySQL有Bug或硬件有问题,否则不需要做任何非常规的事情,哪怕是服务器意外断电。InnoDB会在启动时执行正常的恢复,然后就一切正常了。在日志文件中,可以看到如下信息。\nInnoDB: Doing recovery: scanned up to log sequence number 0 40817239 InnoDB: Starting an apply batch of log records to the database... InnoDB会在日志文件中输出恢复进度的百分比信息。有些人说直到整个过程完成才能看到这些信息。耐心点,这个恢复过程是急不来的。如果心急而杀掉进程并重启,只会导致需要更长的恢复时间。\n如果服务器硬件有严重问题,例如内存或磁盘损坏,或遇到了MySQL或InnoDB的Bug,可能就不得不介入,这时要么进行强制恢复,要么阻止正常恢复发生。\nInnoDB损坏的原因 # InnoDB非常健壮且可靠,并且有许多的内建安全检测来防止、检测和修复损坏的数据——比其他MySQL存储引擎要强很多。然而,InnoDB并不能保护自己避免一切错误。\n最起码,InnoDB依赖于无缓存的I/O调用和fsync()调用,直到数据完全地写入到物理介质上才会返回。如果硬件不能保证写入的持久化,InnoDB也就不能保证数据的持久,崩溃就有可能导致数据损坏。\n很多InnoDB损坏问题都是与硬件有关的(例如,因电力问题或内存损坏而导致损坏页的写入)。然而,在我们的经验中,错误配置的硬件是更多的问题之源。常见的错误配置包括打开了不包含电池备份单元的RAID卡的回写缓存,或打开了硬盘驱动器本身的回写缓存。这些错误将会导致控制器或驱动器“撒谎”,在数据实际上只写入到回写缓存上而不是磁盘上时,却说fsync()已经完成。换句话说,硬件没有提供保持InnoDB数据安全的保证。\n有时候机器默认就会这样配置,因为这样做可以得到更好的性能——对于某些场景确实很好,但是对事务数据服务来说却是个大问题。\n如果在网络附加存储(NAS)上运行InnoDB,也可能会遇到损坏,因为对NAS设备来说完成fsync()只是意味着设备接收到了数据。如果InnoDB崩溃,数据是安全的,但如果是NAS设备崩溃就不一定了。\n严重的损坏会使InnoDB或MySQL崩溃,而不那么严重的损坏则可能只是由于日志文件未真正同步到磁盘而丢掉了某些事务。\n如何恢复损坏的InnoDB数据 # InnoDB损坏有三种主要类型,它们对数据恢复有着不同程度的要求。\n二级索引损坏\n一般可以用OPTIMIZE TABLE来修复损坏的二级索引;此外,也可以用SELECT INTO OUTFILE,删除和重建表,然后LOAD DATA INFILE的方法。(也可以将表改为使用MyISAM再改回来。)这些过程都是通过构建一个新表重建受影响的索引,来修复损坏的索引数据。\n聚簇索引损坏\n如果是聚簇索引损坏,也许只能使用innodb_force_recovery选项来导出表(关于这点后续会讲更多)。有时导出过程会让InnoDB崩溃;如果出现这样的情况,或许需要跳过导致崩溃的损坏页以导出其他的记录。聚簇索引的损坏比二级索引要更难修复,因为它会影响数据行本身,但在多数场合下仍然只需要修复受影响的表。\n损坏系统结构\n系统结构包括InnoDB事务日志、表空间的撤销日志(undo log)区域和数据字典。这种损坏可能需要做整个数据库的导出和还原,因为InnoDB内部绝大部分的工作都可能受到影响。\n一般可以修复损坏的二级索引而不丢失数据。然而,另外两种情形经常会引起数据的丢失。如果已经有备份,那最好还是从备份中还原,而不是试着从损坏的文件里去提取数据。\n如果必须从损坏的文件里提取数据,那一般过程是先尝试让InnoDB运行起来,然后使用SELECT INTO OUTFILE导出数据。如果服务器已经崩溃,并且每次启动InnoDB都会崩溃,那么可以配置InnoDB停止常规恢复和后台进程的运行。这样也许可以启动服务器,然后在缺少或不做完整性检查的情况下做逻辑备份。\ninnodb_force_recovery参数控制着InnoDB在启动和常规操作时要做哪一种类型的操作。通常情况下这个值是0,可以增大到6。MySQL使用手册里记录了每个数值究竟会产生什么行为;在此我们不会重复这段信息,但是要告诉你:在有点危险的前提下,可以把这个数值调高到4。使用这个设置时,若有数据页损坏,将会丢失一些数据;如果将数值设得更高,可能会从损坏的页里提取到坏掉的数据,或者增加执行SELECT INTO OUTFILES时崩溃的风险。换句话说,这个值直到4都对数据没有损害,但可能丧失修复问题的机会;而到5 和6 会更主动地修复问题,但损害数据的风险也会很大。\n当把innodb_force_recovery设为大于0的某个值时,InnoDB 基本上是只读的,但是仍然可以创建和删除表。这可以阻止进一步的损坏,InnoDB会放松一些常规检查,以便在发现坏数据时不会特意崩溃。在常规操作中,这样做是有安全保障的,但是在恢复时,最好还是避免这样做。如果需要执行InnoDB 强制恢复,有个好主意是配置MySQL,使它在操作完成之前不接受常规的连接请求。\n如果InnoDB的数据损坏到了根本不能启动MySQL的程度,还可以使用Percona出品的InnoDB Recovery Toolkit从表空间的数据文件里直接抽取数据。这个工具由本书的几个作者开发,可以从http://www.percona.com/software免费获取。Percona Server还有允许服务器在某些表损坏时仍能运行的选项,而不是像MySQL那样在单个表损坏页被检测出时就默认强制崩溃。\n15.7 备份和恢复工具 # 有各种各样的好的和不是那么好的备份工具。我们喜欢对LVM使用mylvmbackup做快照备份,使用Percona Xtrabackup(开源)或MySQL Enterprise Backup(收费)做InnoDB热备份。不建议对大数据量使用mysqldump,因为它对服务器有影响,并且漫长的还原时间不可预知。\n有一些备份工具已经出现多年了,不幸的是有些已经过时。最明显的例子是Maatkit的mk-parallel-dump,它从没有正确运行,甚至被重新设计过好几次还是不行。另外一个工具是mysqlhotcopy,它适合于古老的MyISAM表。大部分场景下这两个工具都无法让人相信数据是安全的,它们会使人误以为备份了数据实际上却非如此。例如,当使用InnoDB的innodb_file_per_table时,mysqlhotcopy会复制.ibd文件,这会使一些人误以为InnoDB的数据已经备份完成。在某些场景下,这两个工具都对服务器有一些负面影响。\n如果你在2008或2009年时在看MySQL的路线图,可能听说过MySQL在线备份。这是一个可以用SQL命令来开始备份和还原的特性。它原本是规划在MySQL 5.2版本中,后来重新安排在了MySQL 6.0中,再后来,据我们所知被永久取消了。\n15.7.1 MySQL Enterprise Backup # 这个工具之前叫做InnoDB Hot Backup或ibbackup,是从Oracle购买的MySQL Enterprise中的一部分。使用此工具备份不需要停止MySQL,也不需要设置锁或中断正常的数据库活动(但是会对服务器造成一些额外的负载)。它支持类似压缩备份、增量备份和到其他服务器的流备份的特性。这是MySQL“官方”的备份工具。\n15.7.2 Percona XtraBackup # Percona XtraBackup与MySQL Enterprise Backup在很多方面都非常类似,但它是开源并且免费的。除了核心备份工具外,还有一个用Perl写的封装脚本,可以提供更多高级功能。它支持类似流、增量、压缩和多线程(并行)备份操作。也有许多特别的功能,用以降低在高负载的系统上备份的影响。\nPercona XtraBackup的工作方式是在后台线程不断追踪InnoDB日志文件尾部,然后复制InnoDB数据文件。这是个轻量级侵入过程,依靠特别的检测机制确保复制的数据是一致的。当所有的数据文件被复制完,日志复制线程就结束了。结果是在不同的时间点的所有数据的副本。然后可以使用InnoDB崩溃恢复代码应用事务日志,以达到所有数据文件一致的状态。这一步叫作准备过程。一旦准备好,备份就会完全一致,并且包含文件复制过程最后时间点已经提交的事务。一切都在MySQL外部完成,因此不需要以任何方式连接或访问MySQL。\n包装脚本包含通过复制备份到原位置的方式进行恢复的能力。还有Lachlan Mulcahy的XtraBack Manager项目,功能更多,详情参见 http://code.google.com/p/xtrabackup-manager/。\n15.7.3 mylvmbackup # Lenz Grimmer的mylvmbackup(http://lenz.homelinux.org/mylvmbackup/)是一个Perl脚本,它通过LVM快照帮助MySQL自动备份。此工具首先获取全局读锁,创建快照,释放锁。然后通过tar压缩数据并移除快照。它通过备份时的时间戳命名压缩包。它还有几个高级选项,但总的来说,这是一个执行LVM备份的非常简单明了的工具。\n15.7.4 Zmanda Recovery Manager # 适用于MySQL的Zmanda Recovery Manager,或ZRM( http://www.zmanda.com),有免费(GPL)和商业两种版本。企业版提供基于网页图形接口的控制台,用来配置、备份、验证、恢复、报告和调度。开源的版本包含了所有核心功能,但缺少一些额外的特性,例如基于网页的控制台。 正如其名,ZRM实际上是一个备份和恢复管理器,而并非单一工具。它封装了自有的基于标准工具和技术,例如mysqldump、LVM快照和Percona XtraBackup等之上的功能。它将许多冗长的备份和恢复工作进行了自动化。\n15.7.5 mydumper # 几名MySQL现在和之前的工程师利用他们多年的经验创建了mydumper,用来替代mysqldump。这是一个多线程(并发)的备份和还原MySQL和Drizzle的工具集,有许多很好的特性。大概有许多人会发现多线程备份和还原的速度是这个工具最吸引人的特色。尽管我们知道有些人在生产环境中使用,但我们还没有在任何产品中使用的经验。可以在 http://www.mydumper.org找到更多信息。\n15.7.6 mysqldump # 大部分人在使用这个与MySQL一起发行的程序,因此,尽管它有缺点,但创建数据和Schema的逻辑备份最常见的选择还是mysqldump。这是一个通用工具,可以用于许多的任务,例如在服务器间复制表。\n** $ mysqldump --host=server1 test t1 | mysql --host=server2 test** 我们在本章中展示了几个用mysqldump创建逻辑备份的例子。该工具默认会输出包含创建表和填充数据的所有需要的命令;也有选项可以控制输出视图、存储代码和触发器。下面有一些典型的例子。\n对服务器上所有的内容创建逻辑备份到单个文件中,每个库中所有的表在相同逻辑时间点备份:\n** $ mysqldump –all-databases \u0026gt; dump.sql** 创建只包含Sakila示例数据库的逻辑备份:\n** $ mysqldump –databases sakila \u0026gt;dump.sql** 创建只包含sakila.actor表的逻辑备份:\n** $ mysqldump sakila actor \u0026gt; dump.sql** 可以使用\u0026ndash;result-file选项来指定输出文件,这可以帮助防止在Windows上发生换行符转换:\n** $ mysqldum sakila actor –result-file=dump.sql** mysqldump的默认选项对于大多数备份目的来说并不够好。多半要显式地指定某些选项以改变输出。下面是一些我们经常使用的选项,可以让mysqldump更加高效,输出更容易使用。\n\u0026ndash;opt\n启用一组优化选项,包括关闭缓冲区(它会使服务器耗尽内存),导出数据时把更多的数据写在更少的SQL语句里,以便在加载的时候更有效率,以及做其他一些有用的事情。更多细节可以阅读帮助文件。如果关闭了这组选项,mysqldump会在把表写到磁盘之前,把它们都导出到内存里,这对于大型的表而言是不切实际的。\n\u0026ndash;allow-keywords, \u0026ndash;quote-names\n使用户在导出和恢复表时,可以使用保留字作为表的名字。\n--complete-insert\n使用户能在不完全相同列的表之间移动数据。\n\u0026ndash;tz-utc\n使用户能在具有不同时区的服务器之间移动数据。\n--lock-all-tables\n使用FLUSH TABLE WITH READ LOCK来获取全局一致的备份。\n\u0026ndash;tab\n用SELECT INTO OUTFILE导出文件。\n--skip-extended-insert\n使每一行数据都有自己的INSERT语句。必要时这可以用于有选择地还原某些行。它的代价是文件更大,导入到MySQL时开销会更大。因此,要确保只有在需要时才启用它。\n如果在mysqldump上使用\u0026ndash;databases或\u0026ndash;all-databases选项,那么最终导出的数据在每个数据库中都一致,因为mysqldump会在同一时间锁定并导出一个数据库里的所有表。然而,来自不同数据库的各个表就未必是相互一致的。使用\u0026ndash;lock-all-tables选项可以解决这个问题。\n对于InnoDB备份,应该增加\u0026ndash;single-transaction选项,这会使用InnoDB的MVCC特性在单个时间点创建一个一致的备份,而不需要使用LOCK TABLES锁定所有表。如果增加*\u0026ndash;master-data*选项,备份还会包括在备份时服务器的二进制日志文件位置,这对基于时间点的恢复和设置复制非常有帮助。然而也要知道,获得日志位置时需要使用FLUSH TABLES WITH READ LOCK冻结服务器。\n15.8 备份脚本化 # 为备份写一些脚本是标准做法。展示一个示例程序,其中必定有很多辅助内容,这只会增加篇幅,在这里我们更愿意列举一些典型的备份脚本功能,展示一些Perl脚本的代码片断。你可以把这些当作可重用的代码块,在创建自己的脚本时可以直接组合起来使用。下面将大致按照使用顺序来展示。\n安全检测\n安全检测可以让自己和同事的生活更简单点——打开严格的错误检测,并且使用英文变量名。\nuse strict; use warnings FATAL =\u0026gt; 'all'; use English qw(-no_match_vars); 如果是在Bash下使用脚本,还可以做更严格的变量检测。下面的设置会让替换中有未定义的变量或程序出错退出时产生一个错误。\nset -u; set -e; 命令行参数\n增加命令行选项处理最好的方法是用标准库,它已包含在Perl标准安装中。\nuse Getopt::Long; Getopt::Long::Configure('no_ignore_case', 'bundling'); GetOptions(....); 连接MySQL\n标准的Perl DBI库几乎无所不在,提供了许多强大和灵活的功能。使用详情请参阅Perldoc(可从 http://search.cpna.org在线获取)。可以像下面这样使用DBI来连接MySQL。\nuse DBI; $dbh = DBI-\u0026gt;connect( 'DBI:mysql:;host-localhost', 'user', 'p4ssword', {RaiseError =\u0026gt; 1}); 对于编写命令行脚本,请阅读标准mysql程序的\u0026ndash;help参数的输出文本。它有许多选项可更友好地支持脚本。例如,在Bash中遍历数据库列表如下。\nmysql -ss -e 'SHOW DATABASES' | while read DB; do ech \u0026quot;${DB}\u0026quot; done 停止和启动MySQL\n停止和启动MySQL最好的方法是使用操作系统推荐的方法,例如运行*/etc/init.d/mysql init*脚本或通过服务控制(在Windows下)。然而这并不是唯一的方法。可以从Perl中用一个已存在的数据库连接来关闭数据库。\n$dbh-\u0026gt;func(\u0026quot;shutdown\u0026quot;, 'admin'); 当这个命令完成时不要太指望MySQL已经被关闭——它可能正在关闭的过程中。也可以通过命令行来停掉MySQL。\n** $ mysqladmin shutdown** 获取数据库和表的列表\n每个备份脚本都会查询MySQL以获取数据库和表的列表。要注意那些实际上并不是数据库的条目,例如一些日志系统中的lost+found文件夹和INFORMATION_SCHEMA。也要确保脚本已经准备好应付视图,同时也要知道SHOW TABLE STATUS在InnoDB中有大量数据时可能耗时很长。\nmysql\u0026gt; ** SHOW DATABASES;** mysql\u0026gt; ** SHOW /*!50002 FULL*/ TABLES FROM \u0026lt;* database* \u0026gt;;** mysql\u0026gt; ** SHOW TABLE STATUS FROM \u0026lt;* database* \u0026gt;;** 对表加锁、刷新并解锁\n如果需要对一个或多个表加锁并且/或刷新,要么按名字锁住所需的表,要么使用全局锁锁住所有的表。\nmysql\u0026gt; ** LOCK TABLES \u0026lt;* database.table* \u0026gt; READ [, ...];** mysql\u0026gt; ** FLUSH TABLES;** mysql\u0026gt; ** FLUSH TABLES \u0026lt;* database.table* \u0026gt; [, ...];** mysql\u0026gt; ** FLUSH TABLES WITH READ LOCK;** mysql\u0026gt; ** UNLOCK TABLES;** 在获取所有的表并锁住它们时要格外注意竞争条件。期间可能会有新表创建,或有表被删除或重命名。如果一个表一个表地锁住然后备份,将无法得到一致性的备份。\n刷新二进制日志\n让服务器开始一个新的二进制日志非常简单(一般在锁住表后但在备份前做这个操作):\nmysql\u0026gt; ** FLUSH LOGS;** 这样做使得恢复和增量备份更简单,因为不需要考虑从一个日志文件中间开始操作。此操作会有一些副作用,比如刷新和重新打开错误日志,也可能销毁老的日志条目,因此,注意不要扔掉需要用到的数据。\n获取二进制日志位置\n脚本应该获取并记录主库和备库的状态——即使服务器仅是个主库或备库。\nmysql\u0026gt; ** SHOW MASTER STATUS\\G** mysql\u0026gt; ** SHOW SLAVE STATUS\\G** 执行这两条语句并忽略错误,以使脚本可以获取到所有可能的信息。\n导出数据\n最好的选择是使用mysqldump、mydumper或SELECT INTO OUTFILE。\n复制数据\n可以使用本章中演示的任何一个方法。\n这些都是构造备份脚本的基础。比较困难的部分是将管理和恢复任务脚本化。如果想获得实现的灵感,可以看看ZRM的源码。\n15.9 总结 # 每个人都知道需要备份,但并不是每个人都意识到需要的是可恢复的备份。有许多方法可以规划能满足恢复需求的备份。为了避免这个问题,我们建议明确并记录恢复点目标和恢复时间目标,并且在选择备份系统时将其作为参考。\n在日常基础上做恢复测试以确保备份可以正常工作也很重要。设置mysqldump并让它在每天晚上运行是很简单的,但很多时候不会意识到数据随着时间已经增长到可能需要几天或几周才能再次导入的地步。最糟糕的是当你真正需要恢复的时候,才发现原来需要这么长时间。毫不夸张地说,一个在几个小时内完成的备份可能需要几周时间来恢复,具体取决于硬件、Schema、索引和数据。\n不要掉进备库就是备份的陷阱。备库对生成备份是一个干涉较少的源,但它不是备份本身。对于RAID卷、SAN和文件系统快照,也同样如此。确保备份可以通过DROP TABLE测试(或“遭受黑客攻击”的测试),也要能通过数据中心失败的测试。如果是基于备库生成备份,确保使用pt-table-checksum验证复制的完整性。\n我们最喜欢的两种备份方式,一种是从文件系统或者SAN快照中直接复制数据文件,一种是使用Percona XtraBackup做热备份。这两种方法都可以无侵入地实现二进制的原始数据备份,这样的备份可以通过启动mysqld实例检查所有的表进行验证。有时候甚至可以一石二鸟:可以在开发或者预发环境每天将备份进行还原来执行恢复测试,然后再将数据导出为逻辑备份。我们也建议备份二进制日志,并且尽可能久地保留多份备份的数据和二进制文件。这样即使最近的备份无法使用了,还可以使用较老的备份来执行恢复或者创建新的备库。\n除了提到的许多开源工具,也有很多很好的商业备份工具,其中最重要的是MySQL Enterprise Backup。对包括在GUI SQL编辑器、服务器管理工具和类似工具中的“备份”工具要特别小心。同样地,有一些出品“一招吃遍天下”的备份工具的公司,对于它们宣称的支持MySQL的“MySQL备份插件”也要特别小心。我们需要的是主要为MySQL设计的优秀备份工具,而不是一个支持上百个其他数据库并恰巧支持MySQL的工具。有许多备份工具的供应者并不知道或明白诸如FLUSH TABLES WITH READ LOCK操作对数据库的影响。在我们看来,使用这种SQL命令的方案应该自动退出“热”备份的行列。如果只使用InnoDB表,就更加不需要这类工具。\n————————————————————\n(1) Baron仍然记得他毕业后的第一个工作,当时他把电子商务网站的生产服务器上的发货表删除了两列。\n(2) 是的,即使SELECT查询也会被阻塞,因为如果有一个查询需要修改某些数据,只要它开始等待表上的写锁,所有尝试获取读锁的查询也必须等待。\n(3) 由mysqldump生成的逻辑备份并不一定是文本文件。SQL导出会包含许多不同的字符集,同样也会包含二进制数据,这些数据并不是有效的字符。对于许多编辑器来说,文件行也可能会太长。但是,大多数这样的文件还是可以被编辑器打开和读取,特别是mysqldump使用了\u0026ndash;hex-blob选项时。\n(4) 以我们的经验,逻辑备份往往比物理备份要小许多,但也并不总是如此。\n(5) 值得一提的是物理备份会更易出错;很难像mysqldump一样简单。\n(6) Percona XtraBackup正在开发“真正的”增量备份特性。它将能够备份变更的块,而不需要扫描每个块。\n(7) 请不要用Maatkit的mk-parallel-dump和mk-parallel-restore工具。它们并不安全。\n"},{"id":149,"href":"/zh/docs/technology/MySQL/_%E9%AB%98%E6%80%A7%E8%83%BDMySQL_/%E7%AC%AC14%E7%AB%A0%E5%BA%94%E7%94%A8%E5%B1%82%E4%BC%98%E5%8C%96/","title":"第14章应用层优化","section":"高性能 My SQL","content":"第14章 应用层优化\n如果在提高MySQL的性能上花费太多时间,容易使视野局限于MySQL本身,而忽略了用户体验。回过头来看,也许可以意识到,或许MySQL已经足够优化,对于用户看到的响应时间而言,其所占的比重已经非常之小,此时应该关注下其他部分了。这是个很不错的观点,尤其是对DBA而言,这是很值得去做的正确的事。但如果不是MySQL,那又是什么导致了问题呢?使用第3章提到的技术,通过测量可以快速而准确地给出答案。如果能顺着应用的逻辑过程从头到尾来剖析,那么找到问题的源头一般来说并不困难。有时,尽管问题在MySQL上,也很容易在系统的另一部分得到解决。\n无论问题出在哪里,都至少可以找到一个靠谱的工具来帮助进行分析,而且通常是免费的。例如,如果有JavaScript或者页面渲染的问题,可以使用包括Firefox浏览器的Firebug插件在内的调优工具,或者使用Yahoo!的YSlow工具。我们在第3章提到了几个应用层工具。一些工具甚至可以剖析整个堆栈:New Relic是一个很好的例子,它可以剖析Web应用的前端、应用以及后端。\n14.1 常见问题 # 我们在应用中反复看到一些相同的问题,经常是因为人们使用了缺乏设计的现成系统或者简单开发的流行框架。虽然有时候可以通过这些框架更快更简单地构建系统,但是如果不清楚这些框架背后做了什么操作,反而会增加系统的风险。\n下面是我们经常会碰到的问题清单,通过这些过程可以激发你的思维。\n什么东西在消耗系统中每台主机的CPU、磁盘、网络,以及内存资源?这些值是否合理?如果不合理,对应用程序做基本的检查,看什么占用了资源。配置文件通常是解决问题最简单的方式。例如,如果Apache因为创建1000个需要50MB内存的工作进程而导致内存溢出,就可以配置应用程序少使用一些Apache工作进程。也可以配置每个进程少使用一些内存。 应用真的需要所有获取到的数据吗?获取1000行数据但只显示10行,而丢弃剩下的990行,这是常见的错误。(如果应用程序缓存了另外的990行备用,这也许是有意的优化。) 应用在处理本应由数据库处理的事情吗,或者反过来?这里有两个例子,从表中获取所有的行在应用中进行统计计数,或者在数据库中执行复杂的字符串操作。数据库擅长统计计数,而应用擅长正则表达式。要善于使用正确的工具来完成任务。 应用执行了太多的查询?ORM宣称的把程序员从写SQL中解放出来的语句接口通常是罪魁祸首。数据库服务器为从多个表匹配数据做了很多优化,因此应用程序完全可以删掉多余的嵌套循环,而使用数据库的关联来代替。 应用执行的查询太少了?好吧,上面只说了执行太多SQL可能成为问题。但是,有时候让应用来做“手工关联”以及类似的操作也可能是个好主意。因为它们允许更细的粒度控制和更有效的使用缓存,以及更少的锁争用,甚至有时应用代码里模拟的哈希关联会更快(MySQL的嵌套循环的关联方法并不总是高效的)。 应用创建了没必要的MySQL连接吗?如果可以从缓存中获得数据,就不要再连接数据库。 应用对一个MySQL实例创建连接的次数太多了吗(也许因为应用的不同部分打开了它们自己的连接)?通常来说更好的办法是重用相同的连接。 应用做了太多的“垃圾”查询?一个常见的例子是发送查询前先发送一个ping命令看数据库是否存活,或者每次执行SQL前选择需要的数据库。总是连接到一个特定的数据库并使用完整的表名也许是更好的方法。(这也使得从日志或者通过SHOW PROCESSLIST看SQL更容易了,因为执行日志中的SQL语句的时候不用再切换到特定的数据库,数据库名已经包含在SQL语句中了。)“预备(Preparing)”连接是另一个常见问题。Java驱动在预备期间会做大量的操作,其中大部分可以禁用。另一个常见的垃圾查询是SET NAMES UTF8,这是一个错误的方法(它不会改变客户端库的字符集,只会影响服务器的设置)。如果应用在大部分情况使用特定的字符集工作,可以修改配置文件把特定字符集设为默认值,而不需要在每次执行时去做修改。 应用使用了连接池吗?这既可能是好事,也可能是坏事。连接池可以帮助限制总的连接数,有大量SQL执行的时候效果不错(Ajax应用是一个典型的例子)。然而,连接池也可能有一些副作用,比如说应用的事务、临时表、连接相关的配置项,以及用户自定义变量之间相互干扰等。 应用是否使用长连接?这可能导致太多连接。通常来说长连接不是个好主意,除非网络环境很慢导致创建连接的开销很大,或者连接只被一或两个很快的SQL使用,或者连接频率很高导致客户端本地端口不够用。如果MySQL的配置正确,也许就不需要长连接了。比如使用skip-name-resolve来避免DNS反向查询,确保 thread_cache足够大,并且增加back_log。可以参考第8章和第9章得到更多的细节。 应用是否在不使用的时候还保持连接打开?如果是这样,尤其是连接到很多服务器时,可能会过多地消耗其他进程所需要的连接。例如,假设你连接到10个MySQL服务器。从一个Apache进程中获取10个连接不是问题,但是任意时刻其中只有1个在真正工作。其他9个大部分时间都处于Sleep状态。如果其中一台服务器变慢了,或者有一个很长的网络请求,其他的服务器就可能因为连接数过多受到影响。解决方案是控制应用怎么使用连接。例如,可以将操作批量地依次发送到每个MySQL实例,并且在下一次执行SQL前关闭每个连接。如果执行的是比较消耗时间的操作,例如调用Web服务接口,甚至可以先关闭MySQL连接,执行耗时的工作,再重新打开MySQL连接继续在数据库上工作。 长连接和连接池的区别可能使人困惑。长连接可能跟连接池有同样的副作用,因为重用的连接在这两种情况下都是有状态的。\n然而,连接池通常不会导致服务器连接过多,因为它们会在进程间排队和共享连接。另一方面,长连接是在每个进程基础上创建,不会在进程间共享。\n连接池也比共享连接的方式对连接策略有更强的控制力。连接池可以配置为自动扩展,但是通常的实践经验是,当遇到连接池完全占满时,应该将连接请求进行排队而不是扩展连接池。这样做可以在应用服务器上进行排队等待,而不是将压力传递到MySQL数据库服务器上导致连接数太多而过载。\n有很多方法可以使得查询和连接更快,但是一般的规则是,如果能够直接避免进行查询和连接,肯定比努力提升查询和连接的性能能获得更好的优化结果。\n14.2 Web服务器问题 # Apache是最流行的Web应用服务器软件。它在许多情况下都运行良好,但如果使用不当也会消耗大量的资源。最常见的问题是保持它的进程的存活(alive)时间过长,或者在各种不同的用途下混合使用,而不是分别对不同类型的工作进行优化。\nApache通常是通过prefork配置来使用mod_php、mod_perl和mod_python模块的。prefork模式会为每个请求预分配进程。因为PHP、Perl和Python脚本是可以定制化的,每个进程使用50MB或100MB内存的情况并不少见。当一个请求完成后,会释放大部分内存给操作系统,但并不是全部。Apache会保持进程处于打开状态以备后来的请求重用。这意味着,如果下一个请求是请求静态文件,比如一个CSS文件或者一张图片,就会出现用一个占用内存很多的进程来为一个很小的请求服务的情况。这就是使用Apache作为通用Web服务器很危险的原因。它的确是为通用目的而设计的,但如果能够有针对性地使用其长处,会获得更好的性能。\n另一个主要的问题是,如果开启了Keep-Alive设置,进程可能很长时间处于繁忙状态。当然,即使没有开启Keep-Alive,某些进程也可能存活很久,“填鸭式”地将内容传给客户端可能导致获取数据很慢(1)。\n人们常犯的另外一个错误,就是保持那些Apache默认开启的模块不动。\n最好能够精简Apache的模块,移除掉那些不需要的。这很简单:只需要检查Apache的配置文件,注释掉不想要的模块,然后重启Apache就行。也可以在php.ini文件中删除不使用的PHP模块。\n最差情况是,如果用一个通用目的的Apache配置直接用于Web服务,最后很可能产生很多重量级的Apache进程。这将浪费Web服务器的资源。它们还可能保持大量MySQL连接,浪费MySQL的资源。下面是一些可以降低服务器负载的方法(2)。\n不要使用Apache来做静态内容服务,或者至少和动态服务使用不同的Apache实例。流行的替代品有Nginx( http://www.nginx.com)和lighttpd ( http://www.lighttpd.net)。\n使用缓存代理服务器,比如Squid或者Varnish,防止所有的请求都到达Web服务器。这个层面即使不能缓存所有页面,也可以缓存大部分页面,并且使用像ESI(Edge Side Includes,参见 http://www.esi.org)这样的技术来将部分页面中的小块的动态内容嵌入到静态缓存部分。 对动态和静态资源都设置过期策略。可以使用Squid这样的缓存代理显式地使内容过期。维基百科就使用了这个技术来清理缓存中变更过的文章。 有时也许还需要修改应用程序,以便得到更长的过期时间。例如,如果你告诉浏览器永久缓存CSS和JavaScript文件,然后对站点的HTML做了一个修改,这个页面渲染将会出问题。这种情况可以为文件的每个版本设定唯一的文件名。例如,你可以定制网站的发布脚本,复制CSS文件到*/css/123_frontpage.css*,这里的123就是版本管理器中的版本号。对图片文件的文件名也可以这么做——永不重用文件名,这样页面就不会在升级时出问题,浏览器缓存多久的文件都没问题。\n不要让Apache填鸭式地服务客户端,这不仅仅会导致慢,也会导致DDoS攻击变得简单。硬件负载均衡器通常可以做缓冲,所以Apache可以快速地完成,让负载均衡器通过缓存响应客户端的请求,也可以在应用服务器前端使用Nginx、Squid或者事件驱动模式下的Apache。 打开gzip压缩。对于现在的CPU而言这样做的代价很小,但是可以节省大部分流量。如果想节省CPU周期,可以使用缓存,或者诸如Nginx这样的轻量级服务器保存压缩过的页面版本。 不要为用于长距离连接的Apache配置启用Keep-Alive选项,因为这会使得重量级的Apache进程存活很长时间。可以用服务器端的代理来处理保持连接的工作,从而防止Apache被客户端拖垮。配置Apache到代理之间的连接使用Keep-Alive是可以的,因为代理只会使用很少的Apache连接去获取数据。图14-1展示了这个区别。 图14-1:代理可以使Apache不被长连接拖垮,产生更少的Apache工作进程。\n这些策略可以使Apache进程存活时间变得很短,所以会有比实际需求更多的进程。无论如何,有些操作依然可能导致Apache进程存活时间太长,并且占用大量资源。举个例子,一个请求查询延时非常大的外部资源,例如远程的Web服务,就会出现Apache进程存活时间太长的问题。这种问题通常是无解的。\n14.2.1 寻找最优并发度 # 每个Web服务器都有一个最佳并发度——就是说,让进程处理请求尽可能快,并且不超过系统负载的最优的并发连接数。这就是我们在第11章说的最大系统容量。进行一个简单的测量和建模,或者只是反复试验,就可以找到这个“神奇的数”,为此花一些时间是值得的。\n对于大流量的网站,Web服务器同一时刻处理上千个连接是很常见的。然而,只有一小部分连接需要进程实时处理。其他的可能是读请求,处理文件上传,填鸭式服务内容,或者只是等待客户端的下一步请求。\n随着并发的增加,服务器会逐渐到达它的最大吞吐量。在这之后,吞吐量通常开始降低。更重要的是,响应时间(延迟)也会因为排队而开始增加。\n为什么会这样呢?试想,如果服务器只有一个CPU,同时接收到了100个请求,会发生什么事情呢?假设CPU每秒能够处理一个请求。即便理想情况下操作系统没有调度的开销,也没有上下文切换的成本,那100个请求也需要CPU花费整整100s才能完成。\n处理请求的最好方法是什么?可以将其一个个地排到队列中,也可以并行地执行并在不同请求之间切换,每次切换都给每个请求相同的服务时间。在这两种情况下,吞吐量都是每秒处理一个请求。然而,如果使用队列(并发=1),平均延时是50s,如果是并发执行(并发=100)则是100s。在实践中,并发执行会使平均延时更高,主要是因为上下文切换的代价。\n对于CPU密集型工作负载,最佳并发度等于CPU数量(或者CPU核数)。然而,进程并不总是处于可运行状态的,因为会有一些阻塞式请求,例如I/O、数据库查询,以及网络请求。因此,最佳并发度通常会比CPU数量高一些。\n可以预测最优并发度,但是这需要精确的分析。尝试不同的并发值,看看在不增加响应时间的情况下的最大吞吐量是多少,或者测量真正的工作负载并且进行分析,这通常更容易。Percona Toolkit的pt-tcp-model工具可以帮助从TCP转储中测量和建模分析系统的可扩展性和性能特性。\n14.3 缓存 # 缓存对高负载应用来说是至关重要的。一个典型的Web应用程序会提供大量的内容,直接生成这些内容的成本比采用缓存要高得多(包含检查和缓存超时的开销),所以采用缓存通常可以获得数量级的性能提升。诀窍是找到正确的粒度和缓存过期策略组合。另外也需要决定哪些内容适合缓存,缓存在哪里。\n典型的高负载应用会有很多层缓存。缓存并不仅仅发生在服务器上,而是在每一个环节,甚至包括用户的Web浏览器(这就是内容过期头的用处)。通常,缓存越接近客户端,就越节省资源并且效率更高。从浏览器缓存提供一张图片比从Web服务器的内存获取快得多,而从服务器的内存读取又比从服务器的磁盘上读取好得多。每种类型的缓存有其不一样的特点,例如容量和延时;在后面的章节我们会解释其中的一部分。\n可以把缓存分成两大类:被动缓存和主动缓存。被动缓存除了存储和返回数据外不做任何事情。当从被动缓存请求一些内容时,要么可以得到结果,要么得到“结果不存在”。被动缓存的一个典型例子是memcached。\n相比之下,主动缓存会在访问未命中时做一些额外的工作。通常会将请求转发送给应用的其他部分来生成请求结果,然后存储该结果并返回给应用。Squid缓存代理服务器就是一个主动缓存。\n设计应用程序时,通常希望缓存是主动的(也可以叫做透明的),因为它们对应用隐藏了检查—生成—存储这个逻辑过程。也可以在被动缓存的前面构建一个主动缓存。\n14.3.1 应用层以下的缓存 # MySQL服务器有自己的内部缓存,但也可以构建你自己的缓存和汇总表。可以对缓存表量身定制,使它们最有效地过滤、排序、与其他表关联、计数,或者用于其他用途。缓存表也比许多应用层缓存更持久,因为在服务器重启后它们还存在。\n在第4章和第5章已经介绍了关于缓存策略的内容,所以在这一章,我们主要关注应用层以及更高层次的缓存。\n缓存并不总是有用\n必须确认缓存真的可以提升性能,因为有时缓存可能没有任何帮助。例如,在实践中发现从Nginx的内存中获取内容比从缓存代理中获取要快。如果代理的缓存在磁盘上则尤其如此。\n原因很简单:缓存自身也有一些开销。比如检查缓存是否存在,如果命中则直接从缓存中返回数据。另外将缓存对象失效或者写入新的缓存对象都会有开销。缓存只在这些开销比没有缓存的情况下生成和提供数据的开销少时才有用。\n如果知道所有这些操作的开销,就可以计算出缓存能提供多少帮助。没有缓存时的开销就是为每个请求生成数据的开销。有缓存时的开销是检查缓存的开销加上缓存不命中的概率乘以生成数据的开销,再加上缓存命中的概率乘以缓存提供数据的开销。\n如果有缓存时的开销比没有时要低,则说明缓存可能有用,但依然不能保证。还要记住,就像从Nginx的内存中获取数据比从代理在磁盘中的缓存获取要好一样,有些缓存的开销比另外一些要低。\n14.3.2 应用层缓存 # 应用层缓存通常在同一台机器的内存中存储数据,或者通过网络存在另一台机器的内存中。\n因为应用可以缓存部分计算结果,所以应用层缓存可能比更低层次的缓存更有效。因此,应用层缓存可以节省两方面的工作:获取数据以及基于这些数据进行计算。一个很好的例子是HTML文本块。应用程序可以生成例如头条新闻的标题这样的HTML片段,并且做好缓存。后续的页面视图就可以简单地插入这个缓存过的文本。一般来说,在缓存数据前对数据做的处理越多,缓冲命中节省的工作就越多。\n但应用层缓存也有缺点,那就是缓存命中率可能更低,并且可能使用较多的内存。假设需要50个不同版本的头条新闻标题,以使不同地区生活的用户看到不同的内容,那就需要足够的内存去存储全部50个版本,任何给定版本的标题命中次数都会更少,并且失效策略也会更加复杂。\n应用缓存有许多种,下面是其中的一小部分。\n本地缓存\n这种缓存通常很小,只在进程处理请求期间存在于进程内存中。本地缓存可以有效地避免对某些资源的重复请求。这种类型的缓存技术并不复杂:通常只是应用代码中的一个变量或者哈希表。例如,假设需要显示一个用户名,而且已经知道其ID,就可以创建一个get_name_from_id() 函数并且在其中增加缓存,像下面这样。\n\u0026lt;?php function get_name_from_id($user_id) { static $name; // static makes the variable persist if ( !$name ) { // Fetch name from database } return $name; } ?\u0026gt; 如果使用的是Perl,那么Memoize模块是函数调用结果标准的缓存方式。\nuse Memoize qw(memoize); memoize 'get_name_from_id'; sub get_name_from_id { my ( $user_id ) = @_; my $name = # get name from database return $name; } 这些技巧都很简单,但却可以为应用程序节省很多工作。\n本地共享内存缓存\n这种缓存一般是中等大小(几个GB),快速,难以在多台机器间同步。它们对小型的半静态位数据比较合适。例如每个州的城市列表,分片数据存储的分区函数(映射表),或者使用存活时间(TTL)策略进行失效的数据等。共享内存最大的好处是访问非常快——通常比其他任何远程缓存访问都要快不少。\n分布式内存缓存\n最常见的分布式内存缓存的例子是memcached。分布式缓存比本地共享内存缓存要大得多,增长也容易。缓存中创建的数据的每一个比特都只有一份副本,这样既不会浪费内存,也不会因为相同的数据存在不同的地方而引入一致性问题。分布式内存非常适合存储共享对象,例如用户资料、评论,以及HTML片段。\n分布式缓存比本地共享缓存的延时要高得多,所以最高效的使用方法是批量进行多个获取操作(例如,在一次循环中获取多个对象)。分布式缓存还需要考虑怎么增加更多的节点,以及某个节点崩溃了怎么处理。对于这两个场景,应用程序必须决定在节点间怎么分布或重分布缓存对象。当缓存集群增加或减少一台服务器时,一致性缓存对避免性能问题而言是非常重要的。在下面这个网站有一个为memcached做的一致性缓存库: http://www.audioscrobbler.net/development/ketama/。\n磁盘上的缓存\n磁盘是很慢的,所以缓存在磁盘上的最好是持久化对象,很难全部装进内存的对象,或者静态内容(例如预处理的自定义图片)。\n对于磁盘上的缓存和Web服务器,一个非常有用的技巧是使用404错误处理机制来捕捉缓存未命中的情况。假设Web应用要在头部展示一张基于用户名(“欢迎回来, John!”)的自定义图片,并且通过*/images/welcomeback/john.jpg*这样的路径引用此图片。如果图片不存在,将会导致一个404错误,并且触发上述错误处理。这个错误处理可以生成图片,在磁盘上存储它,然后发出一个重定向或者将该图片传回浏览器。后续的请求只需要从文件中直接返回图片。\n有很多类型的内容可以使用这种技巧。例如,不用再将最近的标题作为HTML部分进行缓存,可以在JavaScript文件中存储这些东西,然后在网页头中引用这个文件:latest_headlines.js。\n缓存失效很简单:删除文件即可。可以通过执行一个删除N分钟前所创建的文件的定时任务,来实现TTL失效。如果想要限制缓存大小,也可以通过按最近访问时间排序来删除文件,从而实现最近最少使用(LRU)失效算法。\n如果失效策略是基于最近访问时间,则必须在文件系统挂载参数中打开访问时间记录。(忽略noatime选项即可。)如果这么做,应该使用内存文件系统来避免大量磁盘操作。\n14.3.3 缓存控制策略 # 缓存也有像反范式化数据库设计一样的问题:重复数据,也就是说有多个地方需要更新数据,所以需要想办法避免读到脏数据。下面是一些最常见的缓存控制策略。\nTTL(time to live,存活时间)\n缓存对象存储时设置一个过期时间;可以通过清理进程在达到过期时间后删掉对象,或者先留着直到下次访问时再清理(清理后需要使用新的版本替换)。对于数据很少变更或者没有新数据的情况,这是最好的失效策略。\n显式失效\n如果不能接受脏数据,那么进程在更新原始数据时需要同时使缓存失效。这种策略有两个变种:写—失效和写—更新。写—失效策略很简单:只需要标记缓存数据已经过期(是否清理缓存数据是可选的)。写—更新策略需要多做一些工作,因为在更新数据时就需要替换掉缓存项。无论如何,这都是非常有益的,特别是当生成缓存数据代价很昂贵时(写线程也许已经做了)。如果更新缓存数据,后续的请求将不再需要等待应用来生成。如果在后台做失效处理,例如基于TTL的失效,就可以在一个从用户请求完全分离出来的进程中生成失效数据的新版本。\n读时失效\n在更改旧数据时,为了避免要同时失效派生出来的脏数据,可以在缓存中保存一些信息,当从缓存中读数据时可以利用这些信息判断数据是否已经失效。和显式失效策略相比,这样做有很大的优势:成本固定且可以分散在不同时间内。假设要失效一个有一百万缓存对象依赖的对象,如果采用写时失效,需要一次在缓存中失效一百万个对象,即使有高效的方法来找到这些对象,也可能需要很长的时间才能完成。如果采用读时失效,写操作可以立即完成,但后续这一百万对象的读操作可能会有略微的延迟。这样就把失效一百万对象的开销分散了,并且可以帮助避免出现负载冲高和延迟增大的峰值。\n一种最简单的读时失效的办法是采用对象版本控制。使用这种方法,在缓存中存储一个对象时,也可以存储对象所依赖的数据的当前版本号或者时间戳。例如,假设要缓存用户博客日志的统计信息,包括用户发表的博客数。当缓存blog_stats对象时,也可以同时存储用户的当前版本号,因为该统计信息是依赖于用户的。\n不管什么时候更新依赖于用户的数据,都需要更新用户的版本号,假设用户的版本号初始为0,并且由你来生成和缓存统计信息。当用户发表了一篇博客,就增加用户的版本号到1(当然也要同时存储这篇博客,尽管在这个例子并没有用到博客数据)。然后当需要显示统计数据的时候,可以对缓存中blog_stats对象的版本与缓存的用户版本进行比较。因为用户的版本比对象的版本高,所以可以知道缓存的统计信息已经过期了,需要重新计算。\n这是一个非常粗糙的内容失效方式,因为它假设依赖于用户的每一个比特的数据与所有其他数据都有交互。但这个假设并不总是成立的。举个例子,如果一个用户对一篇博客做了编辑,你也需要增加用户的版本号,这就会导致存储的统计信息失效,而实际上统计信息(发表的博客数)并没真的改变。这个取舍是很简单的。一个简单的缓存失效策略不只是更容易创建,也可能更加高效。\n对象版本控制是一种简单的标记缓存方法,它可以处理更复杂的依赖关系。一个标记的缓存可以识别不同类型的依赖,并且分别跟踪每个依赖的版本。回到第11章中图书俱乐部的例子,你可以通过下面的版本号标记评论,使缓存的评论依赖于用户的版本和书的版本:user_ver=1234和book_ver=5678。任一版本号变了,都应该刷新缓存的评论。\n14.3.4 缓存对象分层 # 分层缓存对象对检索、失效和内存利用都有帮助。相对于只缓存对象,也可以缓存对象的ID、对象的ID组等通常需要一起检索的数据。\n电子商务网站的搜索结果是这种技术很好的例子。一次搜索可能返回一个匹配产品的列表,包括名称、描述、缩略图,以及价格。缓存整个列表的效率很低:其他的搜索也可能会包含一些相同的产品,这就会导致数据重复,并且浪费内存。这种策略也使得当一个产品的价格变动时,找出并失效搜索结果变得很困难,因为你必须查看每个列表,找到哪些列表包含了更新过的产品。\n可以缓存关于搜索的最小信息,而不必缓存整个列表,例如返回结果的数量以及列表中的产品ID。然后可以再单独缓存每个产品。这样做可解决两个问题:不会重复存放任何结果数据,也更容易在失效产品的粒度上去失效缓存。\n缺点则是,相对于一次性获得整个搜索结果,必须在缓存中检索多个对象。然而不管怎么说,为搜索结果缓存产品ID的列表都是更有效的做法。先在一个缓存命中返回ID的列表,再使用这些ID去请求缓存获得产品信息。如果缓存允许在一次调用里返回多个结果,第二次请求就可以返回多个产品(memcached通过mget()调用来支持)。\n如果使用不当,这种方法可能会导致奇怪的结果。假设使用TTL策略来失效搜索结果,并且当产品变更时显式地去失效单个产品。现在想象一下,一个产品的描述发生了变化,不再包含搜索中匹配的关键字,但是搜索结果的缓存还没有过期失效。此时用户就会看到错误的搜索结果,因为缓存的搜索结果将会引用这个变化了的产品,即使它不再包含匹配搜索的关键字。\n对于大多数应用程序来说,这不是问题。如果应用程序不能容忍这种情况,可以使用基于版本的缓存,并在执行搜索时在结果中存储产品的版本号。当发现搜索结果在缓存中时,可以将当前搜索结果的版本号和搜索结果中每个产品的版本号做比较。如果发现任何一个产品的版本数据不一致,可以重新搜索并且重新缓存结果。\n这对理解远程缓存访问的花销是多么昂贵非常重要。虽然缓存很快,也可以避免很多工作,但在LAN环境下网络往返缓存服务器通常也需要0.3ms左右。我们见过很多案例,复杂的网页需要一千次左右的缓存访问来组合页面结果,这将会耗费3s左右的网络延时,意味着你的页面可能慢得不可接受,即使它甚至不需要访问数据库!因此,在这种情况下对缓存使用批量获取调用是非常重要的。对缓存进行分层,采用小一些的本地缓存,也可能获得很大的收益。\n14.3.5 预生成内容 # 除了在应用程序级别缓存位数据,也可以在后台预先请求一些页面,并且将结果存为静态页面。如果页面是动态的,也可以预先生成页面的部分内容,然后使用像服务端包含(SSI)这样的技术创建最终页面。这有助于减小预生成内容的大小和开销,否则可能在将不同部分拼装到最终页面的时候,由于微小的变化产生大量的重复内容。几乎可以对任何类型的缓存使用预生成策略,包括memcached。\n预生成内容有几个重要的好处。\n应用代码没有复杂的命中和未命中处理路径。 当未命中的处理路径慢得不可接受时,这种方案可以很好地工作,因为它保证了未命中的情况永远不会发生。实际上,在任何时候设计任何类型的缓存系统,总是应该考虑未命中的路径有多慢。如果平均性能提升很大,但是因为要预生成缓存内容,偶尔有一些请求变得非常缓慢,这时可能比不用缓存还糟糕。性能的持续稳定通常跟高性能一样重要。 预生成内容可以避免在缓存未命中时导致的雪崩效应。 缓存预生成好的内容可能占用大量空间,并且并不总能预生成所有东西。无论是哪种形式的缓存,需要预生成的内容中最重要的部分是那些最经常被请求,或者生成的成本最高的,所以可以通过本章前面提到的404错误处理机制来按需生成。\n预生成的内容有时候也可以从内存文件系统中获益,因为可以避免磁盘I/O。\n14.3.6 作为基础组件的缓存 # 缓存有可能成为基础设施的重要组成部分。也很容易陷入一个陷阱,认为缓存虽然很好用,但并不是重要到非有不可的东西。你也许会辩驳,如果缓存服务器宕机或者缓存被清空,请求也可以直接落在数据库上,系统依然可以正常运行。如果是刚刚将缓存加入应用系统,这也许是对的,但是缓存的加入可以使得在应用压力显著增长时不需要对系统的某些部分同比增加资源投入——通常是数据库部分。因此,系统可能慢慢地变得对缓存非常依赖,却没有被发觉。\n例如, 如果高速缓存命中率是90%,当由于某种原因失去缓存,数据库上的负载将增加到原来的10倍。这很可能导致压力超过数据库服务器的性能极限。\n为了避免像这样的意外,应该设计一些高可用性缓存(包括数据和服务)的解决方案,或者至少是评估好禁用缓存或丢失缓存时的性能影响。比如说可以设计应用在遇到这样的情况时能够进行降级处理。\n14.3.7 使用HandlerSocket和memcached # 相对于数据存储在MySQL中而缓存在MySQL外部的缓存方案,另外有一种替代方法是为MySQL创建一个更快的访问路径,直接绕过使用缓存。对于小而简单的查询语句,很大一部分开销来自解析SQL,检查权限,生成执行计划,等等。如果这种开销可以避免, MySQL在处理简单查询时将非常快。\n目前有两个解决方案可以用所谓的NoSQL方式访问MySQL。第一种是一个后台进程插件,称为HandlerSocket,由DeNA开发,这是日本最大的社交网站。HandlerSocket允许通过一个简单的协议访问InnoDB Handler对象。实际上,也就是绕过了上层的服务器层,通过网络直接连接到了InnoDB引擎层。有报告称HandlerSocket每秒可以执行超过750 000条查询。Percona Server分支中自带了HandlerSocket插件引擎层。\n第二个方案是通过memcached协议访问InnoDB。MySQL 5.6的实验室版本有一个插件提供了这个接口。\n两种方法都有一些限制——特别是memcached的方法,这种方法对很多访问数据的方法都不支持。为什么会希望采用SQL以外的什么办法访问数据呢?除了速度之外,最大的原因可能是简单。这样做最大的好处是可以摆脱缓存,以及所有的失效逻辑,还有为它们服务的额外的基础设施。\n14.4 拓展MySQL # 如果MySQL不能做你需要的事,一种可能是拓展其功能。在这里我们不会展示如何做到这一点,但会提供一些可能的方向。如果你对进一步探索有兴趣,那么有很多很好的在线资源,以及许多关于这些内容的书籍可以参考。\n当我们说“MySQL不能做你需要的事”,我们指的是两件事情:MySQL根本做不到这一点,或者MySQL可以做到,但是只能通过缓慢或笨拙的方法,总之做得不够好。无论哪个都是需要对MySQL拓展的原因。好消息是,MySQL已经越来越模块化和通用。\n存储引擎是拓展MySQL的一个很好的方式。Brian Aker已经写了一个存储引擎的框架,还有一系列介绍有关如何开始编写自己的存储引擎的文章。这是目前几个主要的第三方存储引擎的基础。许多公司都编写了它们自己的内部存储引擎。例如,一些社交网络公司使用了特殊的为社交图形操作设计的存储引擎,我们还知道有个公司定制了一个用于模糊搜索的引擎。写一个简单的自定义存储引擎并不难。\n还可以使用存储引擎作为另一个软件的接口。Sphinx引擎就是一个很好的例子,该引擎是Sphinx全文检索软件的接口(见附录F)。\n14.5 MySQL的替代品 # MySQL并不是适合每一个场景的解决方案。有些工作通常在MySQL以外来做会更好,即使MySQL理论上也可以做到。\n最明显的一个例子是在传统的文件系统中存储文件,而不是在表中。图像文件是经典案例:虽然可以把它们放到一个BLOB列,但这通常不是个好办法(3)。一般的做法是,在文件系统中存储图片或其他大型二进制文件,而在MySQL中只存储文件名;然后应用程序在MySQL之外存取文件。对于Web应用程序,可以把文件名放在 \u0026lt;img\u0026gt;元素的src属性中,这样就可以实现对文件的存取。\n全文检索是另一个最好放在MySQL之外处理的例子——MySQL在全文搜索方面明显不如Lucene和Sphinx。\nNDB API也可能对某些任务有用。例如,尽管MySQL的NDB集群存储引擎(目前还)不适合存储一个高性能Web应用程序的全部数据,但用NDB API直接存储网站会话数据或用户注册信息还是可能的。在如下网站可以了解到更多关于NDB API的内容: http://dev.mysql.com/doc/ndbapi/en/index.html。还有供Apache使用的NDB模块,mod_ndb,可以在* http://code.google.com/p/mod-ndb/*下载。\n最后,对于某些操作——如图形关系和树遍历——关系型数据库并不总是正确的典范。MySQL并不擅长分布式数据处理,因为它缺乏并行执行查询的能力。出于这些目的情况还是建议使用其他工具(可能与MySQL结合)。现在想到的例子包括:\n对于简单的键—值存储,在复制严重落后的非常高速的访问场景中,我们建议用Redis替换MySQL。即使MySQL主库可以承担这样的压力,备库的延迟也是非常让人头疼的。Redis也常用来做队列,因为它对队列操作支持得很好。 Hadoop是房间中的大象,一语双关。混合MySQL/Hadoop的部署在处理大型或半结构化数据时非常常见。 14.6 总结 # 优化并不只是数据库的事。正如我们在第3章建议的,最高形式的优化既包含业务上的,也包含用户层的。全方位的优化才是好的优化。\n一般来说,首先要做的事是测量。认真剖析每一层的问题。哪一层导致了大部分的响应时间?对这一层就要重点关注。如果用户的经验是大部分的时间消耗在浏览器的DOM渲染上面,MySQL只贡献总响应时间的一小部分,那么进一步优化查询语句绝对不可能明显地改善用户体验。在测量完成后,通常很容易理解应该在哪里投入精力。我们建议阅读Steve Souders的两本书(High Performance Web Sites和Even Faster Web Sites),并且建议使用New Relic工具。\n在Web服务器的配置和缓存中经常可以发现大问题,而这些问题往往很容易解决。还有一个固有的观念,“总是数据库的问题”,但这其实是不正确的。应用程序中的其他层也同样重要,它们很可能被错误配置,尽管有时不太明显。特别是缓存,能承受比只使用MySQL要低得多的成本传递大量内容。虽然Apache依然是世界上最流行的Web服务器软件,但它并不总是最合适的工具,因此考虑像Nginx这样的替代方案也是非常有意义的。\n————————————————————\n(1) 填鸭式抓取发生在当一个客户端发起HTTP请求,但是没有迅速获取结果时。直到客户端获取整个结果,HTTP连接——以及处理的Apache进程——都将保持活跃。\n(2) 有一本关于如何优化Web应用的很不错的书——High Performance Web Sites,作者是Steve Souders (O\u0026rsquo;Reilly)。尽管书中大部分内容是从客户的角度来看如何让Web站点运行更快,但是参考他的建议也有利于你的服务器。Steve后续的一本书Even Faster Web Sites也很不错,值得阅读。\n(3) 使用MySQL的复制来快速分布镜像到其他机器更有优势,我们知道一些程序使用这种技术。\n"},{"id":150,"href":"/zh/docs/technology/MySQL/_%E9%AB%98%E6%80%A7%E8%83%BDMySQL_/%E7%AC%AC13%E7%AB%A0%E4%BA%91%E7%AB%AF%E7%9A%84MySQL/","title":"第13章云端的MySQL","section":"高性能 My SQL","content":"第13章 云端的MySQL\n许多人在云中使用MySQL,有时候规模还非常庞大,这并不奇怪。从我们的经验来看,大多数人使用的是Amazon Web Services平台(AWS):特别是Amazon的弹性计算云(Elastic Compute Cloud,EC2),弹性块存储(Elastic Block Store,EBS),以及更小众的关系数据库服务(Relational Database Service,RDS)。\n为了便于讨论MySQL在云中的应用,可以将其粗略分为两类。\nIaaS(基础设施即服务)\nIaas是用于托管自有的MySQL服务器的云端基础架构。可以在云端购买虚拟的服务器资源来安装运行MySQL实例。也可以根据需求随意配置MySQL和操作系统,但没有权限也无法看到处于底层的物理硬件设备。\nDBaaS(数据库即服务)\nMySQL本身作为由云端管理的资源。用户需要先收到MySQL服务器的访问许可(通常是一个连接串)才能访问。也可以配置一些MySQL选项,但没有权限去控制或查看底层的操作系统或虚拟服务器实例。例如 Amazon运行MySQL的RDS。其中一些服务器并非真的使用MySQL,但它们能兼容MySQL协议和查询语言。\n我们讨论的重点主要集中在第一类:云托管平台,例如AWS、Rackspace Cloud以及Joyent(1)。有许多很好的资源介绍如何部署和管理MySQL及其运行所需要的资源,并且也有非常多的平台来完全满足这样的需求,所以我们不会展示代码样例或讨论具体的操作技术。因此,本章关注的重点是,在云端运行MySQL还是在传统服务器上部署MySQL,它们在最终经济上和性能特性上的关键区别是什么。我们假定你对云计算很熟悉。这里不是对云计算概念的简单介绍,我们的目的只是帮助那些还不熟悉在云端部署MySQL的用户在使用时避免一些可能遇到的陷阱。\n一般来说,MySQL能够在云中很好地运行。在云中运行MySQL并不比在其他平台困难,但有一些非常重要的差别。你需要注意这些差别并据此设计应用和架构来获得好的效果。某些场景下在云端托管MySQL并不是非常适合,有时候则很适合,但大多数时候云仅仅是另外一个部署平台而已。\n云是一个部署平台,而不是一种架构,理解这一点很重要。架构会受平台的影响,但平台和架构明显不同。如果你把架构和平台搞混了,就可能会做出不合适的选择而给以后带来麻烦。这也正是我们要花时间讨论云端的MySQL到底有什么不同的原因。\n13.1 云的优点、缺点和相关误解 # 云计算有许多优点,但很少是为MySQL特别设计。有一些书籍已经介绍了相关的话题(2),这里我们不再赘述。不过我们会列出一些比较重要的条目供参考,因为接下来会讨论到云计算的缺点,我们不希望你认为我们是在过分苛求云计算。\n云是一种将基础设施外包出去无须自己管理的方法。你不需要寻找供应商购买硬件,也不需要维护和供应商之间的关系,更无须替换失效的硬盘驱动器等。 云一般是按照即用即付的方式支付,可以把前期的大量资本支出转换为持续的运营成本。 随着供应商发布新的服务和成本降低,云提供的价值越来越大。你自己无须做任何事情(例如升级服务器),就可以从这些提升中获益;随着时间推移你会很容易地获得更多更好的选择并且费用更低。 云能够帮助你轻松地准备好服务器和其他资源,在用完后直接将其关闭,而无须关注怎么处理它们,或者怎么卖掉它们收回成本。 云代表了对基础设施的另一种思考方式——作为通过API来定义和控制的资源——支持更多的自动化操作。从“私有云”中也可以获得这些好处。 当然,不是所有跟云相关的东西都是好的。这里有一些缺点可能会构成挑战(在本章稍后部分我们会列出MySQL特有的缺点)。\n资源是共享并且不可预测的,实际上你可以获得比你支付的更多的资源。这听起来很不错,但却导致容量规划很难做。如果你在不知情的情况下获得了比理应享受到的更多的计算资源,那么就存在这样的风险:别人也许会索要他们应得的资源,这会使你的应用性能退化到应有的水平。一般来说,很难确切地知道本来应该得到多少(资源),大多数云托管服务提供商不会对此给出确切的答案。 无法保证容量和可用性。你可能以为还可以获得新实例,但如果供应商已经超额销售了呢?这在有很多共享资源的情况下会发生,同样也会发生在云中。 虚拟的共享资源导致排查故障更加困难,特别是在无法访问底层物理硬件的情况下无法检查并弄清到底发生了什么。例如,我们曾经看到过一些系统的iostat显示的I/O很正常或者vmstat显示的CPU很正常,而当实际衡量完成一个任务需要的时间时,资源却被系统上的其他东西严重占用了。如果在云平台上出现了性能问题,尤其需要去仔细地分析检测。如果对此并不擅长,可能就无法确认到底是底层系统性能差,还是你做了什么事情导致应用出现不合理的资源需求。 总的来说,云平台上对性能、可用性和容量的透明性和控制力都有所下降。最后,还有一些对云的误解需要记住。\n云天生具备更好的可扩展性\n应用、云的架构,以及管理云服务的组织是不是都是可扩展的。云并不是天生可扩展的,云也仅仅是云而已,选择一个可扩展的平台并不能自动使应用变得可扩展。的确,如果云托管提供商没有超售,那么你可以根据需求来购买资源,但在需要时能够获得资源仅仅是扩展性的一个方面而已。\n云可以自动改善甚至保证可用时间\n一般来说,个别在云端托管的服务器比那些经过良好设计的专用基础设施更容易发生故障或运行中断。但是许多人并没有意识到这一点。例如,有人这样写道:“我们将基础设施升级到基于云构建的系统以保证100%的可用时间和可扩展性”。而就在这之前AWS遭受了两次大规模的运行中断故障,导致很大一部分用户受影响。好的架构能够用不可靠的组件设计出可靠的系统,但通常更可靠的基础设施可以获得更高的可用性。(当然不可能有100%的可用时间的系统。)\n另一方面,购买云计算服务,实际上是购买一个由专家构建的平台。他们已经考虑了许多底层的东西,这意味着你可以更专注于上层工作。如果构建自己的平台而对其中的那些细枝末节并不精通,就可能犯一些初学者的错误,早晚会导致一些宕机时间。从这一点来说,云计算能够帮助改善可用时间。\n云是唯一能提供[这里填入任意的优点]的东西\n事实上,许多云的优点是继承自构建云平台所用到的技术,即使不使用云也可以获得(3)。例如,通过管理得当的虚拟化和容量规划,可以像任何一个云平台那样简单快速地启动(spin up)一台新的机器。完全没必要专门使用云来做到这一点。\n云是一个“银弹”(silver bullet)\n虽然大部分人会认为这很荒谬,但确实有人会这么认为。实际上完全没有这回事。\n无可否认,云计算提供了独特的优点,随着时间的推移,关于云计算是什么,以及它们在什么情况下会有帮助,我们会获得更多的共识。但有一点非常肯定:它是全新的,我们现在所做的任何预测都未必经得起时间的考验。我们会在本书讨论相对安全的部分,而将剩下的部分留给读者讨论。\n13.2 MySQL在云端的经济价值 # 在一些场景下云托管比传统的服务器部署方式更经济。以我们的经验来看,云托管比较适合尚处于初级阶段的企业,或者那些持续接触新概念并且本质上是以适用为主的企业,例如移动应用开发者或游戏开发者。这些技术的市场随着移动计算的扩张出现了爆炸式增长,并且仍然是快速发展的领域。在许多情况下,成功的因素并不为开发者所控制,例如口口相传的推荐或者恰逢重要国际事件的时机。\n我们已经帮助很多公司在云中构建移动应用、社交网络以及游戏应用。其中一个他们大量使用的策略是尽可能又快又便宜地开发和发布应用。如果一个应用碰巧变得流行了,公司将投入资源扩大其规模;否则就会很快终结这些应用。一些公司构建并发布的应用的生命周期甚至只有几个星期,在这样的环境下,可以毫不犹豫地选择云托管。\n如果是一个小规模的公司,可能无法提供足够的硬件来自建数据中心以满足一个非常流行的Facebook应用的发展曲线。我们也协助过一些大型的Facebook应用进行扩展,它们能够以今人惊讶的速度增长——有时甚至会快到让一个主机托管公司耗尽资源。更为严重的是,这些应用的增长是完全无法预测的;它们可能只有极少量的用户(也可能突然有了爆炸性的用户数量增长)。我们在数据中心和云中都遇到过这样的应用。如果是一个小公司,云可以帮你避免前期快速注入大量的资金来获得更快更大规模的风险。\n云的另一种潜在的大用途是运行不是很重要的基础设施,例如集成环境、开发测试平台,以及评估环境。假设部署周期是两个星期。你会每天每个小时都测试部署一次,还是只在项目最后的冲刺时测试?许多用户只是偶尔需要筹划和部署测试环境。在这种场景下,云可以帮助节约不少钱。\n以下是我们使用云的两种方式。第一个是作为我们对技术职员面试的一部分,我们会询问如何解决一些实际的问题。我们使用AMI(Amazon Machine Images)来模拟一些被“破坏”的机器,然后让求职者登录并在服务器上执行一系列任务。我们不必开放他们到内部网络的授权,这种方案显然要方便得多。另一个是作为新项目的工作平台和开发服务器。有一个这样的项目已经在一台云端开发服务器上运行了数个月,而花费不足一美元!这在我们自己的基础设施上是不可能做到的。单是发送一封邮件给系统管理员申请开发服务器的时间价值就不止一美元。\n但是另一方面,云托管对于长期项目而言可能会更加昂贵。如果打算长远地使用云,就需要花时间来计算一下(它是否划算)。除了猜想未来的创新能给云计算和商用硬件带来什么,还需要做基准测试以及一个完整的总体持有成本(TCO)账单。为了理清事情的本质并考虑全面所有相关的细节,你需要把所有的事情最终归结为一个数字:每美元的业务交易数。事情变化得太快,所以我们将这个留给读者思考。\n13.3 云中的MySQL的可扩展性和高可用性 # 正如我们之前提到的,MySQL并不会在云端自动变得更具扩展性。事实上,如果机器的性能较差,会导致过早使用横向扩展策略。况且云托管服务器相比专用的硬件可靠性和可预测性要更差些,所以想在云端获得高可用性需要更多的创新。\n但是总的来说,在云端中扩展MySQL和在其他地方扩展没有太多的差别。最大的不同就是按需提供服务器的能力。但是也有某些限制会导致扩展和高可用实现起来有点麻烦,至少在有些云环境中是这样的。例如,在AWS云平台中,无法使用类似虚拟IP地址的功能来完成快速原子故障转移。像这种对资源的有限控制意味着你需要使用其他办法,例如代理。(ScaleBase也值得去看看。)\n云另外一个迷惑人的地方是梦想中的自动扩展——就是根据需求的增加或减少来启动或关闭实例。尽管对于诸如Web服务器这样的无状态部分是可行的,但对于数据库服务器而言则很难做到,因为它是有状态的。对于一些特定的场景,例如以读为主的应用,可以通过增加备库的方式来获得有限的自动扩展(4),但这并不是一个通用的解决方案。实际上,虽然许多应用在Web层使用了自动扩展,但MySQL并不具备在一个无共享(Shared Nothing)集群中的对等角色服务器之间迁移的能力。你可以通过分片架构来自动重新分片并自动增长或收缩(5),但MySQL本身是无法自动扩展的。\n事实上,因为数据库通常是一个应用系统中主要或唯一的有状态并且持久化的组件,所以把应用服务迁移到云端是很普遍的事情,因为除数据库之外的所有部分都可以从云中收益——Web服务器、工作队列服务器、缓存等——而MySQL只需要处理剩下的东西。毕竟,数据库并非世界的中心。如果应用系统其他部分获得的好处,超过了让MySQL运行得足够好而投入的额外开销和必需的工作量,那这不是一个是否会发生的问题,而是怎么发生的问题。要回答这个问题,最好先了解你在云中可能碰到的额外的挑战。这些通常围绕着数据库服务器的可用资源。\n13.4 四种基础资源 # MySQL需要四种基础资源来完成工作:CPU周期、内存、I/O,以及网络。这四种资源的特性和重要程度在不同的云平台上各不相同。可以通过了解它们的不同之处和对MySQL的影响,以决定是否选择在云中托管MySQL。\nCPU通常很少且慢。在写作本书时最大的标准EC2实例提供8个虚拟CPU核心。EC2提供的虚拟CPU比高端CPU的速度明显要慢很多(可以查看本章稍后的基准测试结果)。虽然可能略有不同,但很可能在大多数云托管平台中这都是一种普遍现象。EC2提供使用多个CPU资源的实例,但它们的最大可用内存却更低。在写作本书时商用服务器能提供几十个CPU核心——甚至更多,如果按硬件线程算的话。(6) 内存大小受限制。最大的EC2实例当前能提供68.4GB的内存。与此相比,商用服务器能提供512GB~1TB的内存。 I/O的吞吐量、延迟以及一致性受到限制。在AWS云中有两个存储选项。\n第一个选择是使用EBS卷,这有点类似云中的SAN。AWS的最佳实践是在用EBS组建的RAID10卷上建立服务器。但是EBS是一个共享资源,就像EC2服务器和EBS服务器之间的网络连接。延迟可能会很高并且不可预测,即使是在适量的吞吐量需求下也是如此。我们已经测得EBS设备的I/O延迟可以达到十几分之一秒。相比之下,直接插在本机的商用硬盘驱动器只需几个毫秒,而闪存设备比硬盘驱动器的速度又要高出几个数量级。但另一方面,EBS卷也有许多很好的特性,例如和其他AWS服务、快照等结合起来使用。\n第二个选择是实例的本地存储。每个EC2服务器有一定数量的本地存储,实际安装在底层服务器上。它能够比EBS提供更多的一致性性能(7),但如果实例停止了就无法做到持久化。正是由于这样的特性导致其不适合大多数的数据库服务器场景。 尽管网络通常是一个变化多端的共享资源,但是性能通常比较好。虽然使用商用硬件可以获得更快更持续的网络性能,但CPU、RAM和I/O更容易成为主要的性能瓶颈,在AWS云中我们还没有遇到过网络性能问题。 正如你所看到的,四种基础资源中有三种在AWS云中是受限的,在某些场景下尤其明显。总的来说,这些基础资源并没有商业硬件那样的性能。下一节我们会讨论这些确切的结论。\n13.5 MySQL在云主机上的性能 # 通常,由于较差的CPU、内存以及I/O性能,在类似AWS这样的云托管平台上MySQL所表现出来的性能并不如在其他地方好。这些情况在不同的云平台之间略有不同,但这依然是普遍的事实(8)。然而对于你的需求而言,云主机可能仍然是一个性能足够高的平台,在某些需求上云平台可能比另外的解决方案要好。\n如果使用更糟糕的硬件来运行MySQL,无法让MySQL性能比托管在云平台上更高,这并不奇怪。真正让人感到困惑的是在相似规格的物理硬件条件下却无法获得同样的运行速度。例如,如果有一台服务器拥有8个CPU核心,16GB内存以及一个中等的RAID阵列,你可能认为能够获得和一个拥有8个EC2计算单元、15GB内存以及少量EBS卷的EC2实例相同的性能,但这是无法保证的。EC2实例的性能可能比你的物理硬件更加多变,特别是它不是一个超大实例时,可以推测它跟其他实例共享了同样的硬件资源。\n稳定性确实非常重要。MySQL和InnoDB尤其不喜欢不稳定的性能——特别是不稳定的I/O性能。I/O操作会请求服务器内部的互斥锁,当持续时间太长时,就会显著地导致很多“阻塞”进程堆积起来,出现令人难以理解的长时间运行的查询语句,以及例如Threads_running或Threads_connected这样的状态变量产生毛刺。\n实际应用中前后不一致或者无法预测的性能导致的结果就是排队变得越来越严重。排队是响应时间和到达间隔时间多变自然会导致的结果,并且有个完整的数学分支专门致力于排队的研究。所有的计算机都是队列系统的网络,当需要请求的资源(CPU、I/O,网络,等等)繁忙时,请求必须等待。当资源性能更加多变时,请求更容易堆叠,会出现更多的排队现象。因此,在大多数云计算平台上很难获得高并发或者稳定的低响应时间。我们有很多次在EC2平台上遭受到这个限制的经验。以我们的经验来看,即便在最大的实例上运行的MySQL,在典型的Web OLTP工作负载上,你能够期待的最高并发度也就是Threads_running值为8~12。根据经验,当超过这个值时,性能会越来越不可接受。\n注意我们所说的“典型的Web OLTP工作负载”,并非所有的工作负载都以相同的方式反映云平台的限制。确实有一些工作负载在云中表现得很好,而有一些则受到严重影响,让我们看看到底有哪些。\n正如我们刚讨论的,需要高并发的工作负载并不是非常适合云计算。对于那些要求非常快的响应时间的应用同样如此。原因可以归结于虚拟CPU的数目和速度方面的限制。每个MySQL查询运行在一个单独的CPU上,所以查询响应时间实际上是由CPU的原始速度决定的。如果期望得到更快的响应时间,就需要更快的CPU。为了支持更高的并发度,你需要更多的CPU。MySQL和InnoDB不会因为运行在大量CPU核心上而提供爆炸式的改进,但目前通常能在至少24个核心上获得比较好的横向扩展,这通常比在云中能够获得的核心数更多。 那些需要大量I/O的工作负载在云中并不总是表现很好。当I/O很慢并且不稳定时,工作会很快中断。但另一方面,如果你的工作负载不需要太多的I/O,不管是吞吐量(每秒的执行量)还是带宽(每秒字节数),MySQL就可以运行得很好。 之前的几点是根据云端的CPU和I/O资源的缺点得出的。那么关于这些你可以做点什么呢?对于CPU限制你做不了太多,不够就是不够。但是I/O则不同。I/O实际上是两种存储器的交换:非永久存储器(RAM)和持久化存储器(磁盘、EBS,或者其他你所拥有的)。因此MySQL的I/O需求会受系统内存大小的影响。当有足够的内存时,可以从缓存中读取数据,从而减少读和写操作的I/O。写入同样可以缓存在内存里,多个对相同内存比特位的写入可以合并成单个I/O操作。\n内存的限制就出现了。当拥有足够的内存来存放工作数据集时(9),某些工作负载的I/O需求可以明显减少。更大的EC2实例也会提供更好的网络性能,更有利于EBS卷的I/O。但如果工作集太大,无法装入可用的最大实例,则I/O需求会逐渐上升,并开始阻塞甚至停止服务,正如我们之前讨论的那样。EC2中内存最大的实例能够很好地为许多工作负载提供足够的内存。但是你需要意识到,预热时间可能会很长;关于这一话题本节后面会有更多的讨论。\n哪种类型的工作负载无法通过增加更多的内存来解决呢?除了缓存外,一些写入很大的工作负载需要的I/O比你能从多数云计算平台上获得的要多。例如,如果每秒执行事务数很多,那么每秒就需要执行更多的I/O操作以保证持久性。你只能从诸如EBS这样的系统中获得这么多的吞吐量。同样地,如果你正在将大量数据写入到数据库中,可能会超过可用的带宽。\n你可能认为通过RAID来为EBS卷进行条带(striping)和镜像可以改善I/O性能。在某种程度上确实有帮助。问题是,当增加更多的EBS卷时,在我们需要某个EBS卷的任意时间点都增加了它性能变差的可能性,而根据InnoDB内部I/O工作的方式,最差的一环通常是整个系统的瓶颈。实际上,我们已经尝试过10和20个EBS卷的RAID 10集合, 20卷的RAID比10卷的遭遇了更多的停顿(stall)问题。当我们测量底层块设备的I/O性能时,很明显只有一或两个EBS卷表现得很慢,但是却已经影响了整个系统。\n你也可以改变应用和服务器来减少I/O需求,考虑周到的逻辑和物理数据库设计(Schema和索引)对于减少I/O请求大有帮助,应用程序优化和查询优化也一样。这是减少I/O最有效的手段。例如插入量很大的工作负载,明智地使用分区,将I/O集中到索引能完全加载到内存中的单个分区上,就会有所帮助。你也可以通过设置innodb_flush_logs_at_trx_commit=2 和来降低持久性,或者将InnoDB事务日志和二进制日志从EBS卷中转移到一个本地驱动器上(尽管这有风险)。但是你从服务器上压榨一点额外的性能越困难,就越不可避免地要引入更大的复杂性(以及它们的成本)。\n此外还可以升级MySQL服务器软件。新版本的MySQL和InnoDB(最新的使用InnoDB Plugin的MySQL 5.1,或者MySQL 5.5及更新的版本)能够提供更好的I/O性能以及更少的内部瓶颈,并且相比5.1及之前的版本遭受的停顿和堆积会少很多。Percona Server在某些工作负载下能够提供更多的好处。例如,Percona Server的快速预热缓冲池特性在服务器重启后能够帮助备用服务器快速运行起来,特别是I/O性能不是很好并且服务器依赖于内存时。这也是我们讨论能在云中获得好的性能的候选场景,这里服务器比备用硬件更容易发生故障。Percona Server能够将预热时间从几个小时甚至几天减少到几分钟。在写作本书时,类似的预热特性在MySQL 5.6的开发里程碑版本里已经可用了。\n尽管最终一个增长的应用总会达到一个顶点,届时你不得不对数据库进行拆分以保证数据能够存放到云中。我们倾向于尽量不拆分,但如果你只有这么点马力,当达到某个点时,就不得不去其他地方(离开这个云),或者将其拆分为多份,使每份数据需要的资源不超过虚拟硬件能提供的。通常当工作集无法适应内存大小时就得要进行分片了,这意味着在最大的EC2实例上的工作集大小为50GB~60GB。与之相对,我们已经有很多在物理硬件上运行几个TB大小级别数据库的经验。在云中你需要更早进行分片。\n13.5.1 在云端的MySQL基准测试 # 我们进行了一些基准测试以说明MySQL在AWS云环境中的性能。当需要大量I/O时要在云中获得始终稳定并且可重现的基准测试结果几乎是不可能的,所以我们选择一个内存中的工作负载,本质上可以衡量除了I/O外的所有因素。我们使用Percona Server 5.5.16,缓冲池为4GB,在一千万行数据上运行标准SysBench只读基准测试。这样就可以根据不同的实例大小进行比较。我们忽略了高频率CPU实例,因为它们实际上比m2.4xlarge 实例的CPU性能要差。我们还引用了一台Cisco服务器作为参考。Cisco机器性能非常高但有点老化了,使用的是两个2.93GHz的Xeon X5670 Nehalem CPU。每个CPU有6个核心,每个核心上有两个硬件线程,在操作系统来看总共有24个CPU。图13-1显示了测试的结果。\n图13-1:使用SysBench对AWS云中的MySQL进行只读基准测试\n根据工作负载和硬件来看,这样的结果并不奇怪。例如,最大的EC2实例最高有8个线程,因为它有8个CPU核心。(读/写工作负载会花费一些CPU之外的时间来做I/O,所以我们能获得超过8个线程的有效并发度)。图13-1可能会让你认为Cisco的优势就是CPU能力,这也是我们原本认为的。所以我们使用SysBench的质数基准测试来测试原始CPU性能。结果如图13-2所示。\n图13-2:使用SysBench对AWS服务器进行CPU质数基准测试\nCisco服务器每个CPU的性能比EC2服务器要低,奇怪么?我们也感到非常奇怪。质数基准测试本质上是原始CPU指令,因此不应该有非常明显的虚拟化开销或者太多的内存交换。对于这样的结果我们的解释是这样的:Cisco服务器的CPU已经使用了很多年了,并且比EC2服务器的要慢。但是对于一些更加复杂的任务,例如运行数据库服务器, EC2服务器会受到虚拟化开销的影响。区分慢CPU、慢内存访问以及虚拟化开销并不总是很容易,但在这个实例中这种区别看起来很明显。\n13.6 MySQL数据库即服务(DBaaS) # 在云端服务器上安装MySQL并不是在云中使用MySQL的唯一方法。已经有越来越多的公司开始将数据库本身作为云资源,称之为数据库即服务(DBaaS,有时候也叫DaaS),这意味着你可以在一个地方使用云中的数据库,而在另外的地方运行真正的服务。虽然我们在本章花很多时间解释了IaaS,但IaaS市场正在快速商品化,我们期望未来重点会转到DBaaS。在写作本书时已经有以下几个DBaaS服务提供商。\n13.6.1 Amazon RDS # 我们发现在Amazon的关系数据库(RDS)上进行的开发比其他任何一个DBaaS提供商都要多很多。Amazon RDS不仅仅是一个兼容MySQL的服务;它事实上就是MySQL,所以能够完全兼容你所拥有的MySQL 服务器(10)并能作为替代品提供服务。我们不是很确定,但如大多数人一样,我们相信RDS是托管在使用EBS卷的EC2机器上——Amazon并没有公布底层的技术,但当你足够了解RDS时,这看起来很明显就是MySQL、EC2以及EBS。\n系统管理职责完全由Amazon来承担。你没有访问EC2机器的权限;只有登入MySQL的访问凭证。你可以创建数据库、插入数据等。你并没有被控制住,如果有需要,可以将数据导出来转移到其他地方,也可以创建卷快照并挂载到其他机器上。\n为了防止你检查或干涉Amazon对服务器或主机实例的管理,RDS做了一些限制。例如一些权限限制。你不能利用SELECT INTO OUTFILE、FILE()、LOAD DATA INFILE或其他方法来通过MySQL访问服务器的文件系统。你不能做任何和复制相关的事情,也不能为自己赋予更高的权限。Amazon通过诸如在系统表上设置触发器等方法来进行阻止。并且作为服务条款的一部分,你要同意不会试图绕过这些限制。\n安装的MySQL版本做了轻微的修改以阻止用户干涉服务器,其他部分看起来和原版MySQL一样。我们对RDS、EBS和EC2做了基准测试,并没有从该平台上发现超出我们预期的变化。也就是说,看起来Amazon并没有对服务器做任何性能增强。\nRDS可以提供一些比较吸引人的好处,这取决于你的具体情况。\n你可以将系统管理甚至许多数据库管理的工作留给Amazon。例如,他们会为你进行复制并保证你不会把事情搞砸。 RDS相比其他选择而言可能更便宜,这取决于你的成本结构和人力资源。 RDS中的限制也许是件好事:Amazon拿走了那把子弹上膛的枪,防止你用它自残。 但是,它也有一些潜在的缺点。\n由于无法控制服务器,也就无法弄清操作系统中到底发生了什么。例如,你无法衡量I/O响应时间和CPU利用率。Amazon通过另一个服务CloudWatch提供了这一功能。它给出了足够的指标用于排查许多性能问题,但有时候你需要原始数据以知道到底发生了什么。(也无法使用类似FILE()这样的函数来访问 /proc/diskstats。) 无法获得完整的慢查询日志文件。你可以指定MySQL将慢查询记录到一个CSV日志表中,但这并不是很好。它会消耗很多服务器资源,并且不会给出精确的查询响应时间。这使得很难去分析和排除SQL故障。 如果你希望得到最新最好的,或者一些性能上的增强,例如那些你可以从Percona Server上获得的提升,那就不走运了,RDS并不提供这些。 你必须依赖Amazon的支持团队来解决一些问题,而这些问题可能本来是你自己可以解决的。例如,假设查询挂起了,或者服务器由于数据损坏崩溃了。你既可以等待Amazon来解决,也可以自己解决。如果是后者你就需要把数据转移到别的地方。你无法通过访问实例本身来解决。如果想这么做,你不得不额外花一些时间并支付额外的资源。这不只是理论上的推测;我们已经接到过许多技术支持请求,这些请求通常需要系统权限以进行故障排查,因此对于RDS用户而言是无法真正解决的。 正如我们所说,在性能方面,RDS跟一个大型大内存的使用EBS存储和原始MySQL的EC2实例相似。如果直接使用EC2和EBS并安装一个高性能版本的MySQL(例如Percona Server),你可以从AWS云中压榨出一点更高的性能,但这不会是一个数量级上的区别。考虑到这一点,有理由根据你的商业需求而非性能需求来决定是否使用RDS。如果确实非常要求高性能,那你根本就不应该使用AWS云。\n13.6.2 其他DBaaS解决方案 # Amazon RDS并不是MySQL用户唯一可选的DBaaS解决方案。还有诸如 FathomDB ( http://fathomdb.com)以及Xeround( http://xeround.com)等服务。但我们并没有足够的第一手经验来介绍它们,因为我们还没有在这些服务上做任何的生产部署。从关于FathomDB的一些有限的公开信息来看,它和Amazon RDS有点类似,虽然它也和AWS云一样可以在Rackspace云上获得。在写作本书时它还处于内部测试阶段。\nXeround 则有很大的不同之处:它是一个分布式服务器集群,前端是一个包含特定存储引擎的MySQL。它似乎和原始版本MySQL有少量的不兼容或不同之处。但它只是最近才发布正式GA版本(GA,generally available),所以现在下定论为时尚早。存储引擎似乎是用于和后台集群系统通信,这看起来有点和NDB CLuster类似。它增加了自动重分布功能,可以在工作负载增加或减少时自动地增加和去除节点(动态扩展)。\n还有许多其他的DBaaS服务,新的服务也在不断地推出。我们这里所写的任何内容都可能在你阅读时已经过时了,所以我们将其留给你自己来研究。\n13.7 总结 # 在云端使用MySQL至少有两种主流的方法:在云服务器上安装MySQL,或者使用DBaaS服务。MySQL能够在云主机上运行得很好,但云环境中的限制常常会导致更早需要进行数据拆分。并且尽管云服务器看起来和你的物理硬件很相似,但可能性能和服务质量要更低。\n有时候似乎有人会说“云就是答案,有什么问题吗?”这是一个极端,但那些认为云是一个银弹的狂热信众,也有类似的问题。数据库所需要的四种基础资源中的三种(CPU、内存和磁盘)在云中明显更差并且/或者效率更低,会直接影响到MySQL的性能。\n但是对于很多工作负载而言,MySQL能够在云中运行得很好。通常来说,如果能将工作集加载到内存中,并且产生的写入负载不超过云能支撑的I/O量,那么就可以获得很好的效果。通过严谨的设计和架构,选择正确的MySQL版本并做合适的配置,可以使你的数据库工作负载和容量能适应云的长处。但是MySQL并不是天生的云数据库;也就是说,它无法完全使用云计算理论上能提供的优点,例如自动扩展。但是一些可替代的技术(例如Xeround)正在尝试解决这些缺点。\n我们已经讨论了很多跟云相关的缺点,这也许会给你一个我们反对云计算的印象。并非如此。这只是因为我们只集中在MySQL上,而不是讨论云计算所有的优点,这可能跟你从其他地方阅读到的非常不一样。我们在试着指出在云端运行MySQL有哪些不同,以及哪些是你需要知道的。\n我们看到在云中最大的成功是由于商业原因做出的决策。即使长期来看每个商业交易的开销在云中会更高,但其他方面的因素,诸如增加了弹性、减少了前期成本、减少了推向市场的时间,以及降低了风险,这可能更重要。并且你的应用中其他和MySQL无关的部分所获得的好处要远远大于(在云端)使用MySQL带来的弊端。\n————————————————————\n(1) OK,我们承认。Amazon网络服务是一个云。本章主要讨论AWS。\n(2) 参阅 George Reese所写的Cloud Application Architectures(O\u0026rsquo;Reilly)。\n(3) 我们不是说这会更加容易或便宜,我们只是说云并不是能获得这些好处的唯一途径。\n(4) Scalr ( http://scalr.net)是一个流行的开源服务,用于在云中进行MySQL复制自动扩展。\n(5) 计算机科学家喜欢将之称为“重大挑战”(non-trivial challenge)。\n(6) 在CPU、RAM以及I/O上,商用硬件能够提供超过MySQL可以有效利用的硬件能力,所以将云与云之外可获得的最强硬件相比较并不是完全公平的。\n(7) 直到写入的时候本地存储才会被分配给实例,导致每个写入的块发生“第一次写处罚”(first-write penalty)。避免这个问题的办法是使用dd去写满设备。\n(8) 如果你相信 http://www.xkcd.com/908/,那么显然所有的云都有同样的缺点,我们刚刚已经提过。\n(9) 参阅第9章关于工作集的定义及其如何影响I/O需求的讨论。\n(10) 除非你使用别的存储引擎或者其他一些非标准的MySQL修改版本。\n"},{"id":151,"href":"/zh/docs/technology/MySQL/_%E9%AB%98%E6%80%A7%E8%83%BDMySQL_/%E7%AC%AC12%E7%AB%A0%E9%AB%98%E5%8F%AF%E7%94%A8%E6%80%A7/","title":"第12章高可用性","section":"高性能 My SQL","content":"第12章 高可用性\n本章将讲述我们提到的复制、可扩展性以及高可用性三个主题中的第三个。归根结底,高可用性实际上意味着“更少的宕机时间”。然而糟糕的是,高可用性经常和其他相关的概念混淆,例如冗余、保障数据不丢失,以及负载均衡。我们希望之前的两章已经为清楚地理解高可用性做了足够的铺垫。跟其他两章一样,这一章也不仅仅是关注高可用性的内容,一些相关的话题也会综合阐述。\n12.1 什么是高可用性 # 高可用性实际上有点像神秘的野兽。它通常以百分比表示,这本身也是一种暗示:高可用性不是绝对的,只有相对更高的可用性。100%的可用性是不可能达到的。可用性的“9”规则是表示可用性目标最普遍的方法。你可能也知道,“5个9”表示99.999%的正常可用时间。换句话说,每年只允许5分钟的宕机时间。对于大多数应用这已经是令人惊叹的数字,尽管还有一些人试图获得更多的“9”。\n每个应用对可用性的需求各不相同。在设定一个可用时间的目标之前,先问问自己,是不是确实需要达到这个目标。可用性每提高一点,所花费的成本都会远超之前;可用性的效果和开销的比例并不是线性的。需要保证多少可用时间,取决于能够承担多少成本。高可用性实际上是在宕机造成的损失与降低宕机时间所花费的成本之间取一个平衡。换句话说,如果需要花大量金钱去获得更好的可用时间,但所带来的收益却很低,可能就不值得去做。总的来说,应用在超过一定的点以后追求更高的可用性是非常困难的,成本也会很高,因此我们建议设定一个更现实的目标并且避免过度设计。幸运的是,建立2个9或3个9的可用时间的目标可能并不困难,具体情况取决于应用。\n有时候人们将可用性定义成服务正在运行的时间段。我们认为可用性的定义还应该包括应用是否能以足够好的性能处理请求。有许多方法可以让一个服务器保持运行,但服务并不是真正可用。对一个很大的服务器而言,重启MySQL之后,可能需要几个小时才能充分预热以保证查询请求的响应时间是可以接受的,即使服务器只接收了正常流量的一小部分也是如此。\n另一个需要考虑的问题是,即使应用并没有停止服务,但是否可能丢失了数据。如果服务器遭遇灾难性故障,可能多少都会丢失一些数据,例如最近已经写入(最新丢失的)二进制日志但尚未传递到备库的中继日志中的事务。你能够容忍吗?大多数应用能够容忍;因为替代方案大多非常昂贵且复杂,或者有一些性能开销。例如,可以使用同步复制,或是将二进制日志放到一个通过DRBD进行复制的设备上,这样就算服务器完全失效也不用担心丢失数据。(但是整个数据中心也有可能会掉电。)\n一个良好的应用架构通常可以降低可用性方面的需求,至少对部分系统而言是这样的,良好的架构也更容易做到高可用。将应用中重要和不重要的部分进行分离可以节约不少工作量和金钱,因为对于一个更小的系统改进可用性会更容易。可以通过计算“风险敞口(risk exposure)”,将失效概率与失效代价相乘来确认高优先级的风险。画一个简单的风险计算表,以概率、代价和风险敞口作为列,这样很容易找到需要优先处理的项目。\n在前一章我们通过讨论如何避免导致糟糕的可扩展性的原因,来推出如何获得更好的可扩展性。这里也会使用相似的方法来讨论可用性,因为我们相信,理解可用性最好的方法就是研究它的反面——宕机时间。接下来的小节我们会讨论为什么会出现宕机。\n12.2 导致宕机的原因 # 我们经常听到导致数据库宕机最主要的原因是编写的SQL查询性能很差,真的是这样吗?2009年我们决定分析我们客户的数据库所遇到的问题,以找出那些真正引起宕机的问题,以及如何避免这些问题(1)。结果证实了一些我们已有的猜想,但也否定了一些(错误的)认识,我们从中学到了很多。\n我们首先对宕机事件按表现方式而非导致的原因进行分类。一般来说,“运行环境”是排名第一的宕机类别,大约35%的事件属于这一类。运行环境可以看作是支持数据库服务器运行的系统和资源集合,包括操作系统、硬盘以及网络等。性能问题紧随其后,也是约占35%;然后是复制,占20%;最后剩下的10%包含各种类型的数据丢失或损坏,以及其他问题。\n我们对事件按类型进行分类后,确定了导致这些事件的原因。以下是一些需要注意的地方:\n在运行环境的问题中,最普遍的问题是磁盘空间耗尽。 在性能问题中,最普遍的宕机原因确实是运行很糟糕的SQL,但也不一定都是这个原因,比如也有很多问题是由于服务器Bug或错误的行为导致的。 糟糕的Schema和索引设计是第二大影响性能的问题。 复制问题通常由于主备数据不一致导致。 数据丢失问题通常由于DROP TABLE的误操作导致,并总是伴随着缺少可用备份的问题。 复制虽然常被人们用来改善可用时间,但却也可能导致宕机。这主要是由于不正确的使用导致的,即便如此,它也阐明了一个普遍的情况:许多高可用性策略可能会产生反作用,我们会在后面讨论这个话题。\n现在我们已经知道了主要宕机类别,以及有什么需要注意,下面我们将专门介绍如何获得高可用性。\n12.3 如何实现高可用性 # 可以通过同时进行以下两步来获得高可用性。首先,可以尝试避免导致宕机的原因来减少宕机时间。许多问题其实很容易避免,例如通过适当的配置、监控,以及规范或安全保障措施来避免人为错误。第二,尽量保证在发生宕机时能够快速恢复。最常见的策略是在系统中制造冗余,并且具备故障转移能力。这两个维度的高可用性可以通过两个相关的度量来确定:平均失效时间(MTBF)和平均恢复时间(MTTR)。一些组织会非常仔细地追踪这些度量值。\n第二步——通过冗余快速恢复——很不幸,这里是最应该注意的地方,但预防措施的投资回报率会很高。接下来我们来探讨一些预防措施。\n12.3.1 提升平均失效时间(MTBF) # 其实只要尽职尽责地做好一些应做的事情,就可以避免很多宕机。在分类整理宕机事件并追查导致宕机的根源时,我们还发现,很多宕机本来是有一些方法可以避免的。我们发现大部分宕机事件都可以通过全面的常识性系统管理办法来避免。以下是从我们的白皮书中摘录的指导性建议,在白皮书中有我们详细的分析结果。\n测试恢复工具和流程,包括从备份中恢复数据。 遵从最小权限原则。 保持系统干净、整洁。 使用好的命名和组织约定来避免产生混乱,例如服务器是用于开发还是用于生产环境。 谨慎安排升级数据库服务器。 在升级前,使用诸如Percona Toolkit中的pt-upgrade之类的工具仔细检查系统。 使用InnoDB并进行适当的配置,确保InnoDB是默认存储引擎。如果存储引擎被禁止,服务器就无法启动。 确认基本的服务器配置是正确的。 通过skip_name_resolve禁止DNS。 除非能证明有效,否则禁用查询缓存。 避免使用复杂的特性,例如复制过滤和触发器,除非确实需要。 监控重要的组件和功能,特别是像磁盘空间和RAID卷状态这样的关键项目,但也要避免误报,只有当确实发生问题时才发送告警。 尽量记录服务器的状态和性能指数,如果可能就尽量久地保存。 定期检查复制完整性。 将备库设置为只读,不要让复制自动启动。 定期进行查询语句审查。 归档并清理不需要的数据。 为文件系统保留一些空间。在GNU/Linux中,可以使用-m选项来为文件系统本身保留空间。还可以在LVM卷组中留下一些空闲空间。或者,更简单的方法,仅仅创建一个巨大的空文件,在文件系统快满时,直接将其删除。(2) 养成习惯,评估和管理系统的改变、状态以及性能信息。 我们发现对系统变更管理的缺失是所有导致宕机的事件中最普遍的原因。典型的错误包括粗心的升级导致升级失败并遭遇一些Bug,或是尚未测试就将Schema或查询语句的更改直接运行到线上,或者没有为一些失败的情况制定计划,例如达到了磁盘容量限制。另外一个导致问题的主要原因是缺少严格的评估,例如因为疏忽没有确认备份是否是可以恢复的。最后,可能没有正确地监控MySQL的相关信息。例如缓存命中率报警并不能说明出现问题,并且可能产生大量的误报,这会使监控系统被认为不太有用,于是一些人就会忽略报警。有时候监控系统失效了,甚至没人会注意到,直至你的老板质问你,“为什么Nagios没有告诉我们磁盘已经满了”。\n12.3.2 降低平均恢复时间(MTTR) # 之前提到,可以通过减少恢复时间来获得高可用性。事实上,一些人走得更远,只专注于减少恢复时间的某个方面:通过在系统中建立冗余来避免系统完全失效,并避免单点失效问题。\n在降低恢复时间上进行投资非常重要,一个能够提供冗余和故障转移能力的系统架构,则是降低恢复时间的关键环节。但实现高可用性不单单是一个技术问题,还有许多个人和组织的因素。组织和个人在避免宕机和从宕机事件中恢复的成熟度和能力层次各不相同。\n团队成员是最重要的高可用性资产,所以为恢复制定一个好的流程非常重要。拥有熟练技能、应变能力、训练有素的雇员,以及处理紧急事件的详细文档和经过仔细测试的流程,对从宕机中恢复有巨大的作用。但也不能完全依赖工具和系统,因为它们并不能理解实际情况的细微差别,有时候它们的行为在一般情况下是正确的,但在某些场景下却会是个灾难!\n对宕机事件进行评估有助于提升组织学习能力,可以帮助避免未来发生相似的错误,但是不要对“事后反思”或“事后的调查分析”期待太高。后见之明被严重曲解,并且一味想找到导致问题的唯一根源,这可能会影响你的判断力(3)。许多流行的方法,例如“五个为什么”,可能会被过度使用,导致一些人将他们的精力集中在找到唯一的替罪羊。很难去回顾我们解决的问题当时所处的状况,也很难理解真正的原因,因为原因通常是多方面的。因此,尽管事后反思可能是有用的,但也应该对结论有所保留。即使是我们给出的建议,也是基于长期研究导致宕机事件的原因以及如何预防它们所得,并且只是我们的观点而已。\n这里我们要反复提醒:所有的宕机事件都是由多方面的失效联合在一起导致的。因此,可以通过利用合适的方法确保单点的安全来避免。整个链条必须要打断,而不仅仅是单个环节。例如,那些向我们求助恢复数据的人不仅遭受数据丢失(存储失效,DBA误操作等),同时还缺少一个可用的备份。\n这样说来,当开始调查并尝试阻止失效或加速恢复时,大多数人和组织不应太过于内疚,而是要专注于技术上的一些措施——特别是那些很酷的方法,例如集群系统和冗余架构。这些是有用的,但要记住这些系统依然会失效。事实上,在本书第二版中提到的MMM复制管理,我们已经失去了兴趣,因为它被证明可能导致更多的宕机时间。你应该不会奇怪一组Perl脚本会陷于混乱,但即使是特别昂贵并精密设计的系统也会出现灾难性的失效——是的,即使是花费了大量金钱的SAN也是如此。我们已经见过太多的SAN失效。\n12.4 避免单点失效 # 找到并消除系统中的可能失效的单点,并结合切换到备用组件的机制,这是一种通过减少恢复时间(MTTR)来改善可用性的方法。如果你够聪明,有时候甚至能将实际的恢复时间降低至0,但总的来说这很困难。(即使一些非常引人注目的技术,例如昂贵的负载均衡器,在发现问题并进行反馈时也会导致一定的延迟。)\n思考并梳理整个应用,尝试去定位任何可能失效的单点。是一个硬盘驱动器,一台服务器,一台交换或路由器,还是某个机架的电源?所有数据都在一个数据中心,或者冗余数据中心是由同一个公司提供的吗?系统中任何不冗余的部分都是一个可能失效的单点。其他比较普遍的单点失效依赖于一些服务,例如DNS、单一网络提供商(4)、单个云“可用区域”,以及单个电力输送网,具体有哪些取决于你的关注点。\n单点失效并不总是能够消除。增加冗余或许也无法做到,因为有些限制无法避开,例如地理位置,预算,或者时间限制等。试着去理解每一个影响可用性的部分,采取一种平衡的观点来看待风险,并首先解决其中影响最大的那个。一些人试图编写一个软件来处理所有的硬件失效,但软件本身导致的宕机时间可能比它节约的还要多。也有人想建立一种“永不沉没”的系统,包括各种冗余,但他们忘记了数据中心可能掉电或失去连接。或许他们彻底忘记了恶意攻击者和程序错误的可能性,这些情况可能会删除或损坏数据——一个不小心执行的DROP TABLE也会产生宕机时间。\n可以采用两种方法来为系统增加冗余:增加空余容量和重复组件。增加容量余量通常很简单——可以使用本章或前一章讨论的任何技术。一个提升可用性的方法是创建一个集群或服务器池,并使用负载均衡解决方案。如果一台服务器失效,其他服务器可以接管它的负载。有些人有意识地不使用组件的全部能力,这样可以保留一些“动态余量”来处理因为负载增加或组件失效导致的性能问题。\n出于很多方面的考虑会需要冗余组件,并在主要组件失效时能有一个备件来随时替换。冗余组件可以是空闲的网卡、路由器或者硬盘驱动器——任何能想到的可能失效的东西。完全冗余MySQL服务器可能有点困难,因为一个服务器在没有数据时毫无用处。这意味着你必须确保备用服务器能够获得主服务器上的数据。共享或复制存储是一个比较流行的办法,但这真的是一个高可用性架构吗?让我们深入其中看看。\n12.4.1 共享存储或磁盘复制 # 共享存储能够为数据库服务器和存储解耦合,通常使用的是SAN。使用共享存储时,服务器能够正常挂载文件系统并进行操作。如果服务器挂了,备用服务器可以挂载相同的文件系统,执行需要的恢复操作,并在失效服务器的数据上启动MySQL。这个过程在逻辑上跟修复那台故障的服务器没什么两样,不过更快速,因为备用服务器已经启动,随时可以运行。当开始故障转移时,检查文件系统、恢复InnoDB以及预热(5)是最有可能遇到延迟的地方,但检测失效本身在许多设置中也会花费很长时间。\n共享存储有两个优点:可以避免除存储外的其他任何组件失效所引起的数据丢失,并为非存储组件建立冗余提供可能。因此它有助于减少系统一些部分的可用性需求,这样就可以集中精力关注一小部分组件来获得高可用性。不过,共享存储本身仍是可能失效的单点。如果共享存储失效了,那整个系统也失效了,尽管SAN通常设计良好,但也可能失效,有时候需要特别关注。就算SAN本身拥有冗余也会失效。\n主动—主动访问模式的共享存储怎么样?\n在一个SAN、NAS或者集群文件系统上以主动—主动模式运行多个实例怎么样?MySQL不能这么做。因为MySQL并没有被设计成和其他MySQL实例同步对数据的访问,所以无法在同一份数据上开启多个MySQL实例。(如果在一份只读的静态数据上使用MyISAM,技术上是可行的,但我们还没有见过任何实际的应用。)(6)\nMySQL的一个名为ScaleDB的存储引擎在底层提供了操作共享存储的API,但我们还没有评估过,也没有见过任何生产环境使用。在写作本书时它还是beta版。\n共享存储本身也有风险,如果MySQL崩溃等故障导致数据文件损坏,可能会导致备用服务器无法恢复。我们强烈建议在使用共享存储策略时选择InnoDB存储引擎或其他稳定的ACID存储引擎。一次崩溃几乎肯定会损坏MyISAM表,需要花费很长时间来修复,并且会丢失数据。我们也强烈建议使用日志型文件系统。我们见过比较严重的情况是,使用非日志型文件系统和SAN(这是文件系统的问题,跟SAN无关)导致数据损坏无法恢复。\n磁盘复制技术是另外一个获得跟SAN类似效果的方法。MySQL中最普遍使用的磁盘复制技术是DRBD( http://www.drbd.org),并结合Linux-HA项目中的工具使用(后面会介绍到)。\nDRBD是一个以Linux内核模块方式实现的块级别同步复制技术。它通过网卡将主服务器的每个块复制到另外一个服务器的块设备上(备用设备),并在主设备提交块之前记录下来(7)。由于在备用DRBD设备上的写入必须要在主设备上的写入完成之前,因此备用设备的性能至少要和主设备一样,否则就会限制主设备的写入性能。同样,如果正在使用DRBD磁盘复制技术以保证在主设备失效时有一个可随时替换的备用设备,备用服务器的硬件应该跟主服务器的相匹配。带电池写缓存的RAID控制器对DRBD而言几乎是必需的,因为在没有这样的控制器时性能可能会很差。\n如果主服务器失效,可以把备用设备提升为主设备。因为DRBD是在磁盘块层进行复制,而文件系统也可能会不一致。这意味着最好是使用日志型文件系统来做快速恢复。一旦设备恢复完成,MySQL还需要运行自身的恢复。原故障服务器恢复后,会与新的主设备进行同步,并假定自身角色为备用设备。\n从如何实际地实现故障转移的角度来看,DRBD和SAN很相似:有一个热备机器,开始提供服务时会使用和故障机器相同的数据。最大的不同是,DRBD是复制存储——不是共享存储——所以当使用DRBD时,获得的是一份复制的数据,而SAN则是使用与故障机器同一物理设备上的相同数据副本。换句话说,磁盘复制技术的数据是冗余的,所以存储和数据本身都不会存在单点失效问题。这两种情况下,当启动备用机器时, MySQL服务器的缓存都是空的。相比之下,备库的缓存至少是部分预热的。\nDRBD有一些很好的特性和功能,可以防止集群软件普遍会遇到的一些问题。一个典型的例子是“脑裂综合征”,在两个节点同时提升自己为主服务器时会发生这种问题。可以通过配置DRBD来防止这种事件发生。但是DRBD也不是一个能满足所有需求的完美解决方案。我们来看看它有哪些缺点:\nDRBD的故障转移无法做到秒级以内。它通常至少需要几秒钟时间来将备用设备提升成主设备,这还不包括任何必要的文件系统恢复和MySQL恢复。 它很昂贵,因为必须在主动—被动模式下运行。热备服务器的复制设备因为处于被动模式,无法用于其他任务。当然这是不是缺点取决于看问题的角度。如果你希望获得真正的高可用性并且在发生故障时不能容忍服务降级,就不应该在一台机器上运行两台服务器的负载量,因为如果这么做了,当其中一台发生故障时,就无法处理这些负载了。可以用这些备用服务器做一些其他用途,例如用作备库,但还是会有一些资源浪费。 对于MyISAM表实际上用处不大,因为MyISAM表崩溃后需要花费很长时间来检查和修复。对任何期望获得高可用性的系统而言,MyISAM都不是一个好选择;请使用InnoDB或其他支持快速、安全恢复的存储引擎来代替MyISAM。 DRBD无法代替备份。如果磁盘由于蓄意的破坏、误操作、Bug或者其他硬件故障导致数据损坏,DRBD将无济于事。此时复制的数据只是被损坏数据的完美副本。你需要使用备份(或MySQL延时复制)来避免这些问题。 对写操作而言增加了负担。具体会增加多少负担呢?通常可以使用百分比来表示,但这并不是一个好的度量方法。你需要理解写入时增加的延迟主要由网络往返开销和远程服务器存储导致,特别是对于小的写入而言延迟会更大。尽管增加的延迟可能也就0.3ms,这看起来比在本地磁盘上I/O的4~10ms的延迟要小很多,但却是正常的带有写缓存的RAID控制器的延迟的3~4倍。使用DRBD导致服务器变慢最常见的原因是MySQL使用InnoDB并采取了完全持久化模式(8),这会导致许多小的写入和fsync()调用,通过DRBD同步时会非常慢。(9) 我们倾向于只使用DRBD复制存放二进制日志的设备。如果主动节点失效,可以在被动节点上开启一个日志服务器,然后对失效主库的所有备库应用这些二进制日志。接下来可以选择其中一个备库提升为主库,以代替失效的系统。\n说到底,共享存储和磁盘复制与其说是高可用性(低宕机时间)解决方案,不如说是一种保证数据安全的方法。只要拥有数据,就可以从故障中恢复,并且比无法恢复的情况的MTTR更低。(即使是很长的恢复时间也比不能恢复要快。)但是相比于备用服务器启动并一直运行的架构,大多数共享存储或磁盘复制架构会增加MTTR。有两种启用备用设备并运行的方法:我们在第10章讨论的标准的MySQL复制,以及接下来会讨论的同步复制。\n12.4.2 MySQL同步复制 # 当使用同步复制时,主库上的事务只有在至少一个备库上提交后才能认为其执行完成。这实现了两个目标:当服务器崩溃时没有提交的事务会丢失,并且至少有一个备库拥有实时的数据副本。大多数同步复制架构运行在主动-主动模式。这意味着每个服务器在任何时候都是故障转移的候选者,这使得通过冗余获得高可用性更加容易。\n在写作本书时,MySQL本身并不支持同步复制(10),但有两个基于MySQL的集群解决方案支持同步复制。你还可以阅读第10章、第11章和第13章讨论的其他产品,例如 Continuent Tungsten 以及Clustrix,这些都相当有意思。\n1.MySQL Cluster # MySQL中的同步复制首先出现在MySQL Cluster(NDB Cluster)。它在所有节点上进行同步的主-主复制。这意味着可以在任何节点上写入;这些节点拥有等同的读写能力。每一行都是冗余存储的,这样即使丢失了一个节点,也不会丢失数据,并且集群仍然能提供服务。尽管MySQL Cluster还不是适用于所有应用的完美解决方案,但正如我们在前一章提到的,在最近的版本中它做了非常快速的改进,现在已经拥有大量的新特性和功能:非索引数据的磁盘存储、增加数据节点能够在线扩展、使用ndbinfo表来管理集群、配置和管理集群的脚本、多线程操作、下推(push-down)的关联操作(现在称为自适应查询本地化)、能够处理BLOB列和很多列的表、集中式的用户管理,以及通过像memcached协议一样的NDB API来实现NoSQL访问。在下一个版本中将包含最终一致运行模式,包括为跨数据中心的主动-主动复制提供事务冲突检测和跨WAN解决方案。简而言之,MySQL Cluster是一项引人注目的技术。\n现在至少有两个为简化集群部署和管理提供附加产品的供应商:Oracle针对MySQL Cluster的服务支持包含了MySQL Cluster Manager工具;Severalnines提供了Cluster Control工具( http://www.severalnines.com),该工具还能够帮助部署和管理复制集群。\n2.Percona XtraDB Cluster # Percona XtraDB Cluster是一个相对比较新的技术,基于已有的XtraDB(InnoDB)存储引擎增加了同步复制和集群特性,而不是通过一个新的存储引擎或外部服务器来实现。它是基于Galera(支持在集群中跨节点复制写操作)实现的(11),这是一个在集群中不同节点复制写操作的库。跟MySQL Cluster类似,Percona XtraDB Cluster提供同步多主库复制(12),支持真正的任意节点写入能力,能够在节点失效时保证数据零丢失(持久性, ACID中的D),另外还提供高可用性,在整个集群没有失效的情况下,就算单个节点失效也没有关系。\nGalera作为底层技术,使用一种被称为写入集合(write-set)复制的技术。写入集合实际上被作为基于行的二进制日志事件进行编码,目的是在集群中的节点间传输并进行更新,但是这不要求二进制日志是打开的。\nPercona XtraDB Cluster的速度很快。跨节点复制实际上比没有集群还要快,因为在完全持久性模式下,写入远程RAM比写入本地磁盘要快。如果你愿意,可以选择通过降低每个节点的持久性来获得更好的性能,并且可以依赖于多个节点上的数据副本来获得持久性。NDB也是基于同样的原理实现的。集群在整体上的持久性并没有降低;仅仅是降低了本地节点的持久性。除此之外,还支持行级别的并发(多线程)复制,这样就可以利用多个CPU核心来执行写入集合。这些特性结合起来使得Percona XtraDB Cluster非常适合云计算环境,因为云计算环境中的CPU和磁盘通常比较慢。\n在集群中通过设置auto_increment_offset和auto_increment_increment来实现自增键,以使节点间不会生成冲突的主键值。锁机制和标准InnoDB完全相同,使用的是乐观并发控制。当事务提交时,所有的更新是序列化的,并在节点间传输,同时还有一个检测过程,以保证一旦发生更新冲突,其中一些更新操作需要丢弃。这样如果许多节点同时修改同样的数据,可能产生大量的死锁和回滚。\nPercona XtraDB Cluster只要集群内在线的节点数不少于“法定人数(quorum)”就能保证服务的高可用性。如果发现某个节点不属于“法定人数”中的一员,就会从集群中将其踢出。被踢出的节点在再次加入集群前必须重新同步。因此集群也无法处理“脑裂综合征”;如果出现脑裂则集群会停止服务。在一个只有两个节点的集群中,如果其中一个节点失效,剩下的一个节点达不到“法定人数”,集群将停止服务,所以实际上最少需要三个节点才能实现高可用的集群。\nPercona XtraDB Cluster有许多优点:\n提供了基于InnoDB的透明集群,所以无须转换到另外的技术,例如NDB这样完全不同的技术需要很多学习成本和管理。 提供了真正的高可用性,所有节点等效,并在任何时候提供读写服务。相比较而言,MySQL内建的异步复制和半同步复制必须要有一个主库,并且不能保证数据被复制到备库,也无法保证备库数据是最新的并能够随时提升为主库。 节点失效时保证数据不丢失。实际上,由于所有的节点都拥有全部数据,因此可以丢失任意一个节点而不会丢失数据(即使集群出现脑裂并停止工作)。这和NDB不同, NDB通过节点组进行分区,当在一个节点组中的所有服务器失效时就可能丢失数据。 备库不会延迟,因为在事务提交前,写入集合已经在集群的所有节点上传播并被确认了。 因为是使用基于行的日志事件在备库上进行更新,所以执行写入集合比直接执行更新的开销要小很多,就和使用基于行的复制差不多。当结合多线程应用的写入集合时,可以使其比MySQL本身的复制更具备可扩展性。 当然我们也需要提及Percona XtraDB Cluster的一些缺点:\n它很新,因此还没有足够的经验来证明其优点和缺点,也缺乏合适的使用案例。 整个集群的写入速度由最差的节点决定。因此所有的节点最好拥有相同的硬件配置,如果一个节点慢下来(例如,RAID卡做了一次battery-learn 循环),所有的节点都会慢下来。如果一个节点接收写入操作变慢的可能性为P,那么有3个节点的集群变慢的可能性为3P。 没有NDB那样节省空间,因为每个节点都需要保存全部数据,而不是仅仅一部分。但另一方面,它基于Percona XtraDB(InnoDB的增强版本),也就没有NDB关于磁盘数据限制的担忧。 当前不支持一些在异步复制中可以做的操作,例如在备库上离线修改schema,然后将其提升为主库,然后在其他节点上重复离线修改操作。当前可替代的选择是使用诸如Percona Toolkit中的在线schema修改工具。不过滚动式schema升级(rolling schema upgrade)在写作本书时也即将发布。 当向集群中增加一个新节点时,需要复制所有的数据,还需要跟上不断进行的写入操作,所以一个拥有大量写入的大型集群很难进行扩容。这实际上限制了集群的数据大小。我们无法确定具体的数据。但悲观地估计可能低至100GB或更小,也可能会大得多。这一点需要时间和经验来证明。 复制协议在写入时对网络波动比较敏感,这可能导致节点停止并从集群中踢出。所以我们推荐使用高性能网络,另外还需要很好的冗余。如果没有可靠的网络,可能会导致需要频繁地将节点加入到集群中。这需要重新同步数据。在写本书时,有一个几乎接近可用的特性,即通过增量状态传输来避免完全复制数据集,因此未来这并不是一个问题。还可以配置Galera以容忍更大的网络延迟(以延迟故障检测为代价),另外更加可靠的算法也计划在未来的版本中实现。 如果没有仔细关注,集群可能会增长得太大,以至于无法重启失效节点,就像在一个合理的时间范围内,如果在日常工作中没有定期做恢复演练,备份也会变得太过庞大而无法用于恢复。我们需要更多的实践经验来了解它事实上是如何工作的。 由于在事务提交时需要进行跨节点通信,写入会更慢,随着集群中增加的节点越来越多,死锁和回滚也会更加频繁。(参阅前一章了解为什么会发生这种情况。) Percona XtraDB Cluster 和Galera 都处于其生命周期的早期,正在被快速地修改和改进。在写作本书时,正在进行或即将进行的改进包括群体行为、安全性、同步性、内存管理、状态转移等。未来还可以为离线节点执行诸如滚动式schema变更的操作。\n12.4.3 基于复制的冗余 # 复制管理器是使用标准MySQL复制来创建冗余的工具(13)。尽管可以通过复制来改善可用性,但也有一些“玻璃天花板”会阻止MySQL当前版本的异步复制和半同步复制获得和真正的同步复制相同的结果。复制无法保证实时的故障转移和数据零丢失,也无法将所有节点等同对待。\n复制管理器通常监控和管理三件事:应用和MySQL间的通信、MySQL服务器的健康度,以及MySQL服务器间的复制关系。它们既可以修改负载均衡的配置,也可以在必要的时候转移虚拟IP地址以使应用连接到合适的服务器上,还能够在一个伪集群中操纵复制以选择一个服务器作为写入节点。大体上操作并不复杂:只需要确定写入不会发送到一个还没有准备好提供写服务的服务器上,并保证当需要提升一台备库为主库时记录下正确的复制坐标。\n这听起来在理论上是可行的,但我们的经验表明实际上并不总是能有效工作。事实上这非常糟糕,有些时候最好有一些轻量级的工具集来帮助从常见的故障中恢复并以很少的开销获得较高的可用性。不幸的是,在写作本书时我们还没有听说任何一个好的工具集可以可靠地完成这一点。稍后我们会介绍两个复制管理器(14),其中一个很新,而另外一个则有很多问题。\n我们发现很多人试图去写自己的复制管理器。他们常常会陷入很多人已经遭遇过的陷阱。自己去写一个复制管理器并不是好主意。异步组件有大量的故障形式,很多你从未亲身经历过,其中一些甚至无法理解,并且程序也无法适当处理,因此从这些异步组件中得到正确的行为相当困难,并且可能遭遇数据丢失的危险。事实上,机器刚开始出现问题时,由一个经验丰富的人来解决是很快的,但如果其他人做了一些错误的修复操作则可能导致问题更严重。\n我们要提到的第一个复制管理器是MMM( http://mysql-mmm.org),本书的作者对于该工具集是否适用于生产环境部署的意见并不一致(尽管该工具的原作者也承认它并不可靠)。我们中有些人认为它在一些人工—故障转移模式下的场景中比较有用,而有些人甚至从不使用这个工具。我们的许多客户在自动—故障转移模式下使用该工具时确实遇到了许多严重的问题。它会导致健康的服务器离线,也可能将写入发送到错误的地点,并将备库移动到错误的坐标。有时混乱就接踵而至。\n另外一个比较新一点的工具是Yoshinori Matsunobu的MHA工具集( http://code.google.com/p/mysql-master-ha/)。它和MMM一样是一组脚本,使用相同的通用技术来建立一个伪集群,但它不是一个完全的替换者;它不会去做太多的事情,并且依赖于Pacemaker来转移虚拟IP地址。一个主要的不同是,MHA有一个很好的测试集,可以防止一些MMM遇到过的问题。除此之外,我们对该工具集还没有更多的认识,我们只和Yoshinori 讨论过,但还没有真正使用过。\n基于复制的冗余最终来说好坏参半。只有在可用性的重要性远比一致性或数据零丢失保证更重要时才推荐使用。例如,一些人并不会真的从他们的网站功能中获利,而是从它的可用性中赚钱。谁会在乎是否出现了故障导致一张照片丢失了几条评论或其他什么东西呢?只要广告收益继续滚滚而来,可能并不值得花更多成本去实现真正的高可用性。但还是可以通过复制来建立“尽可能的”高可用性,当遇到一些很难处理的严重宕机时可能会有所帮助。这是一个大赌注,并且可能对大多数人而言太过于冒险,除非是那些老成(或者专业)的用户。\n问题是许多用户不知道如何去证明自己有资格并评估复制“轮盘赌”是否适合他们。这有两个方面的原因。第一,他们并没有看到“玻璃天花板”,错误地认为一组虚拟IP地址、复制以及管理脚本能够实现真正的高可用性。第二,他们低估了技术的复杂度,因此也低估了严重故障发生后从中恢复的难度。一些人认为他们能够使用基于复制的冗余技术,但随后他们可能会更希望选择一个有更强保障的简单系统。\n其他一些类型的复制,例如DRBD或者SAN,也有它们的缺点——请不要认为我们将这些技术说得无所不能而把MySQL自身的复制贬得一团糟,那不是我们的本意。你可以为DRBD写出低质量的故障转移脚本,这很简单,就像为MySQL复制编写脚本一样。主要的区别是MySQL复制非常复杂,有很多非常细小的差别,并且不会阻止你干坏事。\n12.5 故障转移和故障恢复 # 冗余是很好的技术,但实际上只有在遇到故障需要恢复时才会用到。(见鬼,这可以用备份来实现)。冗余一点儿也不会增加可用性或减少宕机。在故障转移的过程中,高可用性是建立在冗余的基础上。当有一个组件失效,但存在冗余时,可以停止使用发生故障的组件,而使用冗余备件。冗余和故障转移结合可以帮助更快地恢复,如你所知,MTTR的减少将降低宕机时间并改善可用性。\n在继续这个话题之前,我们先来定义一些术语。我们统一使用“故障转移(failover)”,有些人使用“回退”(fallback)表达同一意思。有时候也有人说“切换(switchover)”,以表明一次计划中的切换而不是故障后的应对措施。我们也会使用“故障恢复”来表示故障转移的反面。如果系统拥有故障恢复能力,故障转移就是一个双向过程:当服务器A失效,服务器B代替它,在修复服务器A后可以再替换回来。\n故障转移比仅仅从故障中恢复更好。也可以针对一些情况制订故障转移计划,例如升级、schema变更、应用修改,或者定期维护,当发生故障时可以根据计划进行故障转移来减少宕机时间(改善可用性)。\n你需要确定故障转移到底需要多快,也要知道在一次故障转移后替换一个失效组件应该多快。在你恢复系统耗尽的备件容量之前,会出现冗余不足,并面临额外风险。因此,拥有一个备件并不能消除即时替换失效组件的需求。构建一个新的备用服务器,安装操作系统,并复制数据的最新副本,可以多快呢?有足够的备用机器吗?你可能需要不止一台以上。\n故障转移的缘由各不相同。我们已经讨论了其中的一些,因为负载均衡和故障转移在很多方面很相似,它们之间的分界线比较模糊。总的来说,我们认为一个完全的故障转移解决方案至少能够监控并自动替换组件。它对应用应该是透明的。负载均衡不需要提供这些功能。\n在UNIX领域,故障转移常常使用High Availability Linux 项目( http://linux-ha.org)提供的工具来完成,该项目可在许多类UNIX系统上运行,而不仅仅是Linux。Linux-HA栈在最近几年明显多了很多新特性。现在大多数人认为Pacemaker是栈中的一个主要组件。Pacemaker替代了老的心跳工具。还有其他一些工具实现了IP托管和负载均衡功能。可以将它们跟DRBD和/或者LVS结合起来使用。\n故障转移最重要的部分就是故障恢复。如果服务器间不能自如切换,故障转移就是一个死胡同,只能是延缓宕机时间而已。这也是我们倾向于对称复制布局,例如双主配置,而不会选择使用三台或更多的联合主库(co-master)来进行环形复制的原因。如果配置是对等的,故障转移和故障恢复就是在相反方向上的相同操作。(值得一提的是DRBD具有内建的故障恢复功能。)\n在一些应用中,故障转移和故障恢复需要尽量快速并具备原子性。即便这不是决定性的,不依靠那些不受你控制的东西也依然是个好主意,例如DNS变更或者应用程序配置文件。一些问题直到系统变得更加庞大时才会显现出来,例如当应用程序强制重启以及原子性需求出现时。\n由于负载均衡和故障转移两者联系较紧密,有些硬件和软件是同时为这两个目的设计的,因此我们建议所选择的任何负载均衡技术应该都提供故障转移功能。这也是我们建议避免使用DNS和修改代码来做负载均衡的真实原因。如果为负载均衡采用了这些策略,就需要做一些额外的工作:当需要高可用性时,不得不重写受影响的代码。\n以下小节讨论了一些比较普遍的故障转移技术。可以手动执行或使用工具来实现。\n12.5.1 提升备库或切换角色 # 提升一台备库为主库,或者在一个主—主复制结构中调换主动和被动角色,这些都是许多MySQL故障转移策略很重要的一部分。具体细节参见第10章。正如本章之前提到的,我们不能认定自动化工具总能在所有的情况下做正确的事情——或者至少以我们的名誉担保没有这样的工具。\n你不应该假定在发生故障时能够立刻切换到被动备库,这要看具体的工作负载。备库会重放主库的写入,但如果不用来提供读操作,就无法进行预热来为生产环境负载提供服务。如果希望有一个随时能承担读负载的备库,就要不断地“训练”它,既可以将其用于分担工作负载,也可以将生产环境的读查询镜像到备库上。我们有时候通过监听TCP流量,截取出其中的SELECT查询,然后在备库上重放来实现这个目的。Percona Toolkit中有一些工具可以做到这一点。\n12.5.2 虚拟IP地址或IP接管 # 可以为需要提供特定服务的MySQL实例指定一个逻辑IP地址。当MySQL实例失效时,可以将IP地址转移到另一台MySQL服务器上。这和我们在前一章提到的思想本质上是相同的,唯一的不同是现在是用于故障转移,而不是负载均衡。\n这种方法的好处是对应用透明。它会中断已有的连接,但不要求修改配置。有时候还可以原子地转移IP地址,保证所有的应用在同一时间看到这一变更。当服务器在可用和不可用状态间“摇摆”时,这一点尤其重要。\n以下是它的一些不足之处:\n需要把所有的IP地址定义在同一网段,或者使用网络桥接。 改变IP地址需要系统root权限。 有时候还需要更新ARP缓存。有些网络设备可能会把ARP信息保存太久,以致无法即时将一个IP地址切换到另一个MAC地址上。我们看到过很多网络设备或其他组件不配合切换的例子,结果系统的许多部分可能无法确定IP地址到底在哪里。 需要确定网络硬件支持快速IP接管。有些硬件需要克隆MAC地址后才能工作。 有些服务器即使完全丧失功能也会保持持有IP地址,所以可能需要从物理上关闭或断开网络连接。这就是为人所熟知的“击中其他节点的头部”(shoot the other node in the head,简称STONITH)。它还有一个更加微妙并且比较官方的名字:击剑(fencing)。 浮动IP地址和IP接管能够很好地应付彼此临近(也就是在同一子网内)的机器之间的故障转移。但是最后需要提醒的是,这种策略并不总是万无一失,还取决于网络硬件等因素。\n等待更新扩散\n经常有这种情况,在某一层定义了一个冗余后,需要等待低层执行一些改变。在本章前面的篇幅里,我们指出通过DNS修改服务器是一个很脆弱的解决方案,因为DNS的更新扩散速度很慢,改变IP地址可给予你更多的控制,但在一个LAN中的IP地址同样依赖于更低层——ARP——来扩散更新。\n12.5.3 中间件解决方案 # 可以使用代理、端口转发、网络地址转换(NAT)或者硬件负载均衡来实现故障转移和故障恢复。这些都是很好的解决方案,不像其他方法可能会引入一些不确定性(所有系统组件认同哪一个是主库吗?它能够及时并原子地更改吗?),它们是控制应用和服务器间连接的中枢。但是,它们自身也引入了单点失效,需要准备冗余来避免这个问题。\n使用这样的解决方案,你可以将一个远程数据中心设置成看起来好像和应用在同一个网络里。这样就可以使用诸如浮动IP地址这样的技术让应用和一个完全不同的数据中心开始通信。你可以配置每个数据中心的每台应用服务器,通过它自己的中间件连接,将流量路由到活跃数据中心的机器上。图12-1描述了这种配置。\n图12-1:使用中间件来在各数据中心间路由MySQL连接\n如果活跃数据中心安装的MySQL彻底崩溃了,中间件可以路由流量到另外一个数据中心的服务器池中,应用无须知道这个变化。\n这种配置方法的主要缺点是在一个数据中心的Apache服务器和另外一个数据中心的MySQL服务器之间的延迟比较大。为了缓和这个问题,可以把Web服务器设置为重定向模式。这样通信都会被重定向到放置活跃MySQL服务器的数据中心。还可以使用HTTP代理来实现这一目标。\n图12-1显示了如何使用代理来连接MySQL服务器,也可以将这个方法和许多别的中间件架构结合在一起,例如LVS和硬件负载均衡器。\n12.5.4 在应用中处理故障转移 # 有时候让应用来处理故障转移会更简单或者更加灵活。例如,如果应用遇到一个错误,这个错误外部观察者正常情况下是无法察觉的,例如关于数据库损坏的错误日志信息,那么应用可以自己来处理故障转移过程。\n虽然把故障转移处理过程整合到应用中看起来比较吸引人,但可能没有想象中那么有效。大多数应用有许多组件,例如cron任务、配置文件,以及用不同语言编写的脚本。将故障转移整合到应用中可能导致应用变得太过笨拙,尤其是当应用增大并变得更加复杂时。\n但是将监控构建到应用中是一个好主意,当需要时,能够立刻开始故障转移过程。应用应该也能够管理用户体验,例如提供降级功能,并显示给用户合适的信息。\n12.6 总结 # 可以通过减少宕机来获得高可用性,这需要从以下两个方面来思考:增加两次故障之间的正常运行时间(MTBF),或者减少从故障中恢复的时间(MTTR)。\n要增加两次故障之间的正常运行时间,就要尝试去防止故障发生。悲剧的是,在预防故障发生时,它仍然会觉得你做的不够多,所以预防故障的努力经常会被忽视掉。我们已经着重提到了如何在MySQL系统中预防宕机;具体的细节可以参阅我们的白皮书,从http://www.percona.com上可以获得。试着从宕机中获得经验教训,但也要谨防在故障根源分析和事后检验时集中在某一点上而忽略其他因素。\n缩短恢复时间可能更复杂并且代价很高。从简单和容易的方面来说,可以通过监控来更快地发现问题,并记录大量的度量值以帮助诊断问题。作为回报,有时候可以在发生宕机前就发现问题。监控并有选择地报警以避免无用的信息,但也要及时记录状态和性能度量值。\n另外一个减少恢复时间的策略是为系统建立冗余,并使系统具备故障转移能力,这样当故障发生时,可以在冗余组件间进行切换。不幸的是,冗余会让系统变得相当复杂。现在应用不再是集中化的,而是分布式的,这意味着协调、同步、CAP定理、拜占庭将军问题,以及所有其他各种杂乱的东西。这也是像NDB Cluster这样的系统很难创建并且很难提供足够的通用性来为所有的工作负载提供服务的原因。但这种情况正在改善,也许到本书第四版的时候我们就可以称赞一个或多个集群数据库了。\n本章和前面两章提及的话题常常被放在一起讨论:复制、可扩展性,以及高可用性。我们已经尽量将它们独立开来,因为这有助于理清这些话题的不同之处。那么这三章有哪些关联之处呢?\n在其应用增长时,人们一般希望从他们的数据库中知道三件事:\n他们希望能够增加容量来处理新增的负载而不会损失性能。 他们希望保证不丢失已提交的事务。 他们希望应用能一直在线并处理事务,这样他们就能够一直赚钱。 为了达到这些目的,人们常常首先增加冗余。结合故障转移机制,通过最小化MTTR来提供高可用性。这些冗余还提供了空闲容量,可以为更多的负载提供服务。\n当然,除了必要的资源外,还必须要有一份数据副本。这有助于在损失服务器时避免丢失数据,从而增强持久性。生成数据副本的唯一办法是通过某种方法进行复制。不幸的是,数据副本可能会引入不一致。处理这个问题需要在节点间协调和通信。这给系统带来了额外的负担;这也是系统或多或少存在扩展性问题的原因。\n数据副本还需要更多的资源(例如更多的硬盘驱动器,更多的RAM),这会增加开销。有一个办法可以减少资源消耗和维护一致性的开销,就是为数据分区(分片)并将每个分片分发到特定的系统中。这可以减少需要复制的重复数据的次数,并从资源冗余中分离数据冗余。\n所以,尽管一件事总会导致另外一件事,但我们是在讨论一组相关的观点和实践来达成一系列目的。他们不仅仅是讲述同一件事的不同方式。\n最后,需要选择一个对你和应用有意义的策略。决定选择一个完全的端到端(end-to-end)高可用性策略并不能通过简单的经验法则来处理,但我们给出的一些粗略的指引也许会有所帮助。\n为了获得很短的宕机时间,需要冗余服务器能够及时地接管应用的工作负载。它们必须在线并一直执行查询,而不仅仅是备用,因此它们是“预热”过的,处于随时可用的状态。\n如果需要很强的可用性保证,就需要诸如MySQL Cluster、Percona XtraDB Cluster,或者Clustrix这样的集群产品。如果能容忍在故障转移过程中稍微慢一些,标准的MySQL复制也是个很好的选择。要谨慎使用自动化故障转移机制;如果没有按照正确的方式工作,它们可能会破坏数据。\n如果不是很在意故障转移花费的时间,但希望避免数据丢失,就需要一些强力保证数据的冗余——例如,同步复制。在存储层,这可以通过廉价的DRBD来实现,或者使用两个昂贵的SAN来进行同步复制。也可以选择在数据库层复制数据,可以使用的技术包括 MySQL Cluster、Percona XtraDB Cluster或者Clustrix。也可以使用一些中间件,例如Tungsten Replicator。如果不需要强有力的保护,并且希望尽量保证简单,那么正常的异步复制或半同步复制在开销合理时可能是很好的选择。\n或者也可以将应用放到云中。为什么不呢?这样难道不是能够立刻获得高可用性和无限扩展能力吗?下一章将继续探讨这个问题。\n————————————————————\n(1) 我们在一个冗长的白皮书中完整地描述了对客户的宕机事故的分析,并于随后在另一份白皮书中介绍了如何防止宕机,包括可以定期执行的详细检查清单。本书没有这么多篇幅来描述所有的细节,你可以从Percona的网站( http://www.percona.com)获得这两份白皮书。\n(2) 这是100%跨平台兼容的。\n(3) 这里推荐两篇反驳常识的文章:Richard Cook的论文“How Complex Systems Fail”(http://www. ctlab.org/documents/How%20Complex%20Systems%20Fail.pdf)和Malcolm Gladwell在他的What the Dog Saw(Little, Brown)一书中关于挑战者号航天飞机灾难事件的文章。\n(4) 感觉太偏执了?检查你的冗余网络连接是不是真的连接到不同的互联网主干,确保它们的物理位置不在同一条街道或者同一个电线杆上,这样它们才不会被同一个挖土机或者汽车破坏掉。\n(5) Percona Server提供了一个新特性,能够把buffer pool保存下来并在重启后还原,在使用共享存储时能够很好地工作。这可以减少几个小时甚至好几天的预热时间。MySQL 5.6也有相似的特性。\n(6) MySQL 5.6.8之后InnoDB也增加了一个只读模式,可以只读的方式用多个实例访问一份只读数据文件。——译者注\n(7) 事实上可以调整DRDB的同步级别,将其设置成异步等待远程设备接收数据,或者在远程设备将数据写入磁盘前一直阻塞住。同样,强烈建议为DRBD专门使用一块网卡。\n(8) 这里的意思应该是innodb_flush_log_at_trx_commit=1的情况。——译者注\n(9) 另一方面,大的序列写入又是另外一种情况,由DRBD导致的增加的延迟实际上消失了,但吞吐量的限制依然存在。一个合适的RAID阵列能够提供200~500MB/s的序列写入吞吐,大大超过千兆网络所能获得的吞吐量。\n(10) MySQL 5.5支持半同步复制,参见第10章。\n(11) Galera技术由Codership Oy( http://www.codership.com)开发,可以作为一个补丁在标准的MySQL和InnoDB中使用。Percona XtraDB Cluster除了其他特性和功能外,还包含这组补丁的修改版本. Percona XtraDB Cluster是一个可以直接使用的基于Galera的解决方案。\n(12) 你可以通过配置主备只写入其中一个节点来实现,但在集群配置中,对于这种模式的操作没有什么不同。\n(13) 在本小节我们会很小心,以避免产生混淆。冗余并不等同于高可用性。\n(14) 我们同样在开发基于Pacemaker和Linux-HA栈的解决方案,但并不准备在本书中提及。这个脚注稍后会自毁,10……9……8……\n"},{"id":152,"href":"/zh/docs/technology/MySQL/_%E9%AB%98%E6%80%A7%E8%83%BDMySQL_/%E7%AC%AC11%E7%AB%A0%E5%8F%AF%E6%89%A9%E5%B1%95%E7%9A%84MySQL/","title":"第11章可扩展的MySQL","section":"高性能 My SQL","content":"第11章 可扩展的MySQL\n本章将展示如何构建一个基于MySQL的应用,并且当规模变得越来越庞大时,还能保证快速、高效并且经济。\n有些应用仅仅适用于一台或少数几台服务器,那么哪些可扩展性建议是和这些应用相关的呢?大多数人从不会维护超大规模的系统,并且通常也无法效仿在主流大公司所使用的策略。本章会涵盖这一系列的策略。我们已经建立或者协助建立了许多应用,包括从单台或少量服务器的应用到使用上千台服务器的应用。选择一个合适的策略能够大大地节约时间和金钱。\nMySQL经常被批评很难进行扩展,有些情况下这种看法是正确的,但如果选择正确的架构并很好地实现,就能够非常好地扩展MySQL。但是扩展性并不是一个很好理解的主题,所以我们先来理清一些容易混淆的地方。\n11.1 什么是可扩展性 # 人们常常把诸如“可扩展性”、“高可用性”以及“性能”等术语在一些非正式的场合用作同义词,但事实上它们是完全不同的。在第3章已经解释过,我们将性能定义为响应时间。我们也可以很精确地定义可扩展性,稍后将完整讨论。简要地说,可扩展性表明了当需要增加资源以执行更多工作时系统能够获得划算的等同提升(equal bang for the buck)的能力。缺乏扩展能力的系统在达到收益递减的转折点后,将无法进一步增长。\n容量是一个和可扩展性相关的概念。系统容量表示在一定时间内能够完成的工作量(1),但容量必须是可以有效利用的。系统的最大吞吐量并不等同于容量。大多数基准测试能够衡量一个系统的最大吞吐量,但真实的系统一般不会使用到极限。如果达到最大吞吐量,则性能会下降,并且响应时间会变得不可接受地大且非常不稳定。我们将系统的真实容量定义为在保证可接受的性能的情况下能够达到的吞吐量。这就是为什么基准测试的结果通常不应该简化为一个单独的数字。\n容量和可扩展性并不依赖于性能。以高速公路上的汽车来类比的话:\n性能是汽车的时速。 容量是车道数乘以最大安全时速。 可扩展性就是在不减慢交通的情况下,能增加更多车和车道的程度。 在这个类比中,可扩展性依赖于多个条件:换道设计得是否合理、路上有多少车抛锚或者发生事故,汽车行驶速度是否不同或者是否频繁变换车道——但一般来说和汽车的引擎是否强大无关。这并不是说性能不重要,性能确实重要,只是需要指出,即使系统性能不是很高也可以具备可扩展性。\n从较高层次看,可扩展性就是能够通过增加资源来提升容量的能力。\n即使MySQL架构是可扩展的,但应用本身也可能无法扩展,如果很难增加容量,不管原因是什么,应用都是不可扩展的。之前我们从吞吐量方面来定义容量,但同样也需要从较高的层次来看待容量问题。从有利的角度来看,容量可以简单地认为是处理负载的能力,从不同的角度来考虑负载很有帮助。\n数据量\n应用所能累积的数据量是可扩展性最普遍的挑战,特别是对于现在的许多互联网应用而言,这些应用从不删除任何数据。例如社交网站,通常从不会删除老的消息或评论。\n用户量\n即使每个用户只有少量的数据,但在累计到一定数量的用户后,数据量也会开始不成比例地增长且速度快过用户数增长。更多的用户意味着要处理更多的事务,并且事务数可能和用户数不成比例。最后,大量用户(以及更多的数据)也意味着更多复杂的查询,特别是查询跟用户关系相关时(用户间的关联数可以用N×(N−1)来计算,这里N表示用户数)。\n用户活跃度\n不是所有的用户活跃度都相同,并且用户活跃度也不总是不变的。如果用户突然变得活跃,例如由于增加了一个吸引人的新特性,那么负载可能会明显提升。用户活跃度不仅仅指页面浏览数,即使同样的页面浏览数,如果网站的某个需要执行大量工作的部分变得流行,也可能导致更多的工作。另外,某些用户也会比其他用户更活跃:他们可能比一般人有更多的朋友、消息和照片。\n相关数据集的大小\n如果用户间存在关系,应用可能需要在整个相关联用户群体上执行查询和计算,这比处理一个一个的用户和用户数据要复杂得多。社交网站经常会遇到由那些人气很旺的用户组或朋友很多的用户所带来的挑战(2)。\n11.1.1 正式的可扩展性定义 # 有必要探讨一下可扩展性在数学上的定义了,这有助于在更高层次的概念上清晰地理解可扩展性。如果没有这样的基础,就可能无法理解或精确地表达可扩展性。不过不用担心,这里不会涉及高等数学,即使不是数学天才,也能够很直观地理解它。\n关键是之前我们使用的短语:“划算的等同提升(equal bang for the buck)”。另一种说法是,可扩展性是当增加资源以处理负载和增加容量时系统能够获得的投资产出率(ROI)。假设有一个只有一台服务器的系统,并且能够测量它的最大容量,如图11-1所示。\n图11-1:一个只有一台服务器的系统\n假设现在我们增加一台服务器,系统的能力加倍,如图11-2所示。\n图11-2:一个线性扩展的系统能由两台服务器获得两倍容量\n这就是线性扩展。我们增加了一倍的服务器,结果增加了一倍的容量。大部分系统并不是线性扩展的,而是如图11-3所示的扩展方式。\n图11-3:一个非线性扩展的系统\n大部分系统都只能以比线性扩展略低的扩展系数进行扩展。越高的扩展系数会导致越大的线性偏差。事实上,多数系统最终会达到一个最大吞吐量临界点,超过这个点后增加投入反而会带来负回报——继续增加更多工作负载,实际上会降低系统的吞吐量。(3)\n这怎么可能呢?这些年产生了许多可扩展性模型,它们有着不同程度的良好表现和实用性。我们这里所讲的可扩展性模型是基于某些能够影响系统扩展的内在机制。这就是Neil J. Gunther博士提出的通用可扩展性定律(Universal Scalability Law,USL)。Gunther博士将这些详尽地写到了他的书中,包括Guerrilla Capacity Planning (Springer)。这里我们不会深入到背后的数学理论中,如果你对此感兴趣,他撰写的书籍以及由他的公司Performance Dynamics提供的训练课程可能是比较好的资源。(4)\n简而言之,USL说的是线性扩展的偏差可通过两个因素来建立模型:无法并发执行的一部分工作,以及需要交互的另外一部分工作。为第一个因素建模就有了著名的Amdahl定律,它会导致吞吐量趋于平缓。如果部分任务无法并行,那么不管你如何分而治之,该任务至少需要串行部分的时间。\n增加第二个因素——内部节点间或者进程间的通信——到Amdahl定律就得出了USL。这种通信的代价取决于通信信道的数量,而信道的数量将按照系统内工作者数量的二次方增长。因此最终开销比带来的收益增长得更快,这是产生扩展性倒退的原因。图11-4阐明了目前讨论到的三个概念:线性扩展、Amdahl扩展,以及USL扩展。大多数真实系统看起来更像USL曲线。\n图11-4:线性扩展、AmdahI扩展以及USL扩展定律\nUSL可以应用于硬件和软件领域。对于硬件,横轴表示硬件的数量,例如服务器数量或CPU数量。每个硬件的工作量、数据大小以及查询的复杂度必须保持为常量(5)。对于软件,横轴表示并发度,例如用户数或线程数。每个并发的工作量必须保持为常量。\n有一点很重要,USL并不能完美地描述真实系统,它只是一个简化模型。但这是一个很好的框架,可用于理解为什么系统增长无法带来等同的收益。它也揭示了一个构建高可扩展性系统的重要原则:在系统内尽量避免串行化和交互。\n可以衡量一个系统并使用回归来确定串行和交互的量。你可以将它作为容量规划和性能预测评估的最优上限值。也可以检查系统是怎么偏离USL模型的,将其作为最差下限值以指出系统的哪一部分没有表现出它应有的性能。这两种情况下,USL给出了一个讨论可扩展性的参考。如果没有USL,那即使盯着系统看也无法知道期望的结果是什么。如果想深入了解这个主题,最好去看一下对应的书籍。Gunther博士已经写得很清楚,因此我们不会再深入讨论下去。\n另外一个理解可扩展性问题的框架是约束理论,它解释了如何通过减少依赖事件和统计变化(statistical variation)来改进系统的吞吐量和性能。这在Eliyahu M. Goldratt所撰写的The Goal(North River)一书中有描述,其中有一个关于管理制造业设备的延伸的比喻。尽管这看起来和数据库服务器没有什么关联,但其中包含的法则和排队理论以及其他运筹学方面是一样的。\n扩展模型不是最终定论\n虽然有许多理论,但在现实中能做到何种程度呢?正如牛顿定律被证明只有远低于光速时才合理,那些“扩展性定律”也只是在某些场景下才能很好工作的简化模型。有一种说法认为所有的模型都是错误的,但有一些模型还是有用的,特别是USL能够帮助理解一些导致扩展性差的因素。\n当工作负载和其所运行的系统存在微妙的关系时,USL理论可能失效。例如,一个USL无法很好建模的常见情况是:当集群的总内存由于数据集大小而发生改变时,也会导致系统的行为发生变化。USL不允许比线性更好的可扩展性,但现实中可能会发生这样的事情:增加系统的资源后,原来一部分I/O密集型的工作变成了纯内存工作,因此获得了超过线性的性能扩展。\n还有一些情况,USL无法很好描述系统行为。当系统或数据集大小改变时算法的复杂度可能改变,类似这样的情况下可能就无法建立模型(USL由O(1)复杂度和O(N2)复杂度两部分构成,那么对于诸如O(logN)或者O(NlogN)这样复杂度的部分呢?)。根据一些思考和实际经验,我们可以将USL扩展以覆盖这些比较普遍的场景中的一部分。但这会将一个简单并且有用的模型变得复杂并难以使用。事实上,它在很多情况下都是很好的,足以为你所能想象到的系统行为建立模型。这也是为什么我们发现它是在正确性和有效性之间的一个很好的妥协。\n简单地说:有保留地使用模型,并且在使用中验证你的发现。\n11.2 扩展MySQL # 如果将应用所有的数据简单地放到单个MySQL服务器实例上,则无法很好地扩展,迟早会碰到性能瓶颈。对于许多类型的应用,传统的解决方法是购买更多强悍的机器,也就是常说的“垂直扩展”或者“向上扩展”。另外一个与之相反的方法是将任务分配到多台计算机上,这通常被称为“水平扩展”或者“向外扩展”。我们将讨论如何联合使用向上扩展和向外扩展的解决方案,以及如何使用集群方案来进行扩展。最后,大部分应用还会有一些很少或者从不需要的数据,这些数据可以被清理或归档。我们将这个方案称为“向内扩展”,这么取名是为了和其他策略相匹配。\n11.2.1 规划可扩展性 # 人们通常只有在无法满足增加的负载时才会考虑到可扩展性,具体表现为工作负载从CPU密集型变成I/O密集型,并发查询的竞争,以及不断增大的延迟。主要原因是查询的复杂度增加或者内存中驻留着一部分不再使用的数据或者索引。你可能看到一部分类型的查询发生改变,例如大的查询或者复杂查询常常比那些小的查询更影响系统。\n如果是可扩展的应用,则可以简单地增加更多的服务器来分担负载,这样就没有性能问题了。但如果不是可扩展的,你会发现自己将遭遇到无穷无尽的问题。可以通过规划可扩展性来避免这个问题。\n规划可扩展性最困难的部分是估算需要承担的负载到底有多少。这个值不一定非常精确,但必须在一定的数量级范围内。如果估计过高,会浪费开发资源。但如果低估了,则难以应付可能的负载。\n另外还需要大致正确地估计日程表——也就是说,需要知道底线在哪里。对于一些应用,一个简单的原型可以很好地工作几个月,从而有时间去筹资建立一个更加可扩展的架构。对于其他的一些应用,你可能需要当前的架构能够为未来两年提供足够的容量。\n以下问题可以帮助规划可扩展性:\n应用的功能完成了多少?许多建议的可扩展性解决方案可能会导致实现某些功能变得更加困难。如果应用的某些核心功能还没有开始实现,就很难看出如何在一个可扩展的应用中实现它们。同样地,在知道这些特性如何真实地工作之前也很难决定使用哪一种可扩展性解决方案。 预期的最大负载是多少?应用应当在最大负载下也可以正常工作。如果你的网站和Yahoo! News或者Slashdot的首页一样,会发生什么呢?即使不是很热门的网站,也同样有最高负载。比如,对于一个在线零售商,假日期间——尤其是在圣诞前的几个星期——通常是负载达到巅峰的时候。在美国,情人节和母亲节前的周末对于在线花店来说也是负载高峰期。 如果依赖系统的每个部分来分担负载,在某个部分失效时会发生什么呢?例如,如果依赖备库来分担读负载,当其中一个失效时,是否还能正常处理请求?是否需要禁用一些功能?可以预先准备一些空闲容量来防范这种问题。 11.2.2 为扩展赢得时间 # 在理想情况下,应该是计划先行、拥有足够的开发者、有花不完的预算,等等。但现实中这些情况会很复杂,在扩展应用时常常需要做一些妥协,特别是需要把对系统大的改动推迟一段时间再执行。在深入MySQL扩展的细节前,以下是一些可以做的准备工作:\n优化性能\n很多时候可以通过一个简单的改动来获得明显的性能提升,例如为表建立正确的索引或从MyISAM切换到InnoDB存储引擎。如果遇到了性能限制,可以打开查询日志进行分析,详情请参阅第3章。\n在修复了大多数主要的问题后,会到达一个收益递减点,这时候提升性能会变得越来越困难。每个新的优化都可能耗费更多的精力但只有很小的提升,并会使应用更加复杂。\n购买性能更强的硬件\n升级或增加服务器在某些场景下行之有效,特别是对处于软件生命周期早期的应用,购买更多的服务器或者增加内存通常是个好办法。另一个选择是尽量在一台服务器上运行应用程序。比起修改应用的设计,购买更多的硬件可能是更实际的办法,特别是时间紧急并且缺乏开发者的时候。\n如果应用很小或者被设计为便于利用更多的硬件,那么购买更多的硬件应该是行之有效的办法。对于新应用这是很普遍的,因为它们通常很小或者设计合理。但对于大型的旧应用,购买更多硬件可能没什么效果,或者代价太高。服务器从1台增加到3台或许算不了什么,但从100台增加到300台就是另外一回事了——代价非常昂贵。如果是这样,花一些时间和精力来尽可能地提升现有系统的性能就很划算。\n11.2.3 向上扩展 # 向上扩展(有时也称为垂直扩展)意味着购买更多性能强悍的硬件,对很多应用来说这是唯一需要做的事情。这种策略有很多好处。例如,单台服务器比多台服务器更加容易维护和开发,能显著节约开销。在单台服务器上备份和恢复应用同样很简单,因为无须关心一致性或者哪个数据集是权威的。当然,还有一些别的原因。从复杂性的成本来说,向上扩展比向外扩展更简单。\n向上扩展的空间其实也很大。拥有0.5TB内存、32核(或者更多)CPU以及更强悍I/O性能的(例如PCIe卡的flash存储)商用服务器现在很容易获得。优秀的应用和数据库设计,以及很好的性能优化技能,可以帮助你在这样的服务器上建立一个MySQL大型应用。\n在现代硬件上MySQL能扩展到多大的规模呢?尽管可以在非常强大的服务器上运行,但和大多数数据库服务器一样,在增加硬件资源的情况下MySQL也无法很好地扩展(非常奇怪!)。为了更好地在大型服务器上运行MySQL,一定要尽量选择最新的版本。由于内部可扩展性问题,MySQL 5.0和5.1在大型硬件里的表现并不理想。建议使用MySQL 5.5或者更新的版本,或者Percona Server 5.1及后续版本。即便如此,当前合理的“收益递减点”的机器配置大约是256GB RAM,32核CPU以及一个PCIe flash驱动器。如果继续提升硬件的配置,MySQL的性能虽然还能有所提升,但性价比就会降低,实际上,在更强大的系统上,也可以通过运行多个小的MySQL实例来替代单个大实例,这样可以获得更好的性能。当然,机器配置的变化速度非常快,这个建议也许很快就会过时了。\n向上扩展的策略能够顶一段时间,实际很多应用是不会达到天花板的。但是如果应用变得非常庞大(6),向上扩展可能就没有办法了。第一个原因是钱,无论服务器上运行什么样的软件,从某种角度来看,向上扩展都是个糟糕的财务决策,当超出硬件能够提供的最优性价比时,就会需要非同寻常的特殊配置的硬件,这样的硬件往往非常昂贵。这意味着能向上扩展到什么地步是有实际的限制的。如果使用了复制,那么当主库升级到高端硬件后,一般是不太可能配置出一台能够跟上主库的强大备库的。一个高负载的主库通常可以承担比拥有同样配置的备库更多的工作,因为备库的复制线程无法高效地利用多核CPU和磁盘资源。\n最后,向上扩展不是无限制的,即使最强大的计算机也有限制。单服务器应用通常会首先达到读限制,特别是执行复杂的读查询时。类似这样的查询在MySQL内部是单线程的,因此只能使用一个CPU,这种情况下花钱也无法提升多少性能。即使购买最快的CPU也仅仅会是商用CPU的几倍速度。增加更多的CPU或CPU核数并不能使慢查询执行得更快。当数据变得庞大以至于无法有效缓存时,内存也会成为瓶颈,这通常表现为很高的磁盘使用率,而磁盘是现代计算机中最慢的部分。\n无法使用向上扩展最明显的场景是云计算。在大多数公有云中都无法获得性能非常强的服务器,如果应用肯定会变得非常庞大,就不能选择向上扩展的方式。在第13章我们会深入这个话题。\n因此,我们建议,如果系统确实有可能碰到可扩展性的天花板,并且会导致严重的业务问题,那就不要无限制地做向上扩展的规划。如果你知道应用会变得很庞大,在实现另外一种解决方案前,短期内购买更优的服务器是可以的。但是最终还是需要向外扩展,这也是下一节我们要讲述的主题。\n11.2.4 向外扩展 # 可以把向外扩展(有时也称为横向扩展或者水平扩展)策略划分为三个部分:复制、拆分,以及数据分片(sharding)。\n最简单也最常见的向外扩展的方法是通过复制将数据分发到多个服务器上,然后将备库用于读查询。这种技术对于以读为主的应用很有效。它也有一些缺点,例如重复缓存,但如果数据规模有限这就不是问题。关于这些问题我们在前一章已经讨论得足够多,后面会继续提到。\n另外一个比较常见的向外扩展方法是将工作负载分布到多个“节点”。具体如何分布工作负载是一个复杂的话题。许多大型的MySQL应用不能自动分布负载,就算有也没有做到完全的自动化。本节我们会讨论一些可能的分布负载的方案,并探讨它们的优点和缺点。\n在MySQL架构中,一个节点(node)就是一个功能部件。如果没有规划冗余和高可用性,那么一个节点可能就是一台服务器。如果设计的是能够故障转移的冗余系统,那么一个节点通常可能是下面的某一种:\n一个主—主复制双机结构,拥有一个主动服务器和被动服务器。 一个主库和多个备库。 一个主动服务器,并使用分布式复制块设备(DRBD)作为备用服务器。 一个基于存储区域网络(SAN)的“集群”。 大多数情况下,一个节点内的所有服务器应该拥有相同的数据。我们倾向于把主—主复制架构作为两台服务器的主动—被动节点。\n1.按功能拆分 # 按功能拆分,或者说按职责拆分,意味着不同的节点执行不同的任务。我们之前已经提到了一些类似的实现,在前一章我们描述了如何为OLTP和OLAP工作负载设计不同的服务器。按功能拆分采取的策略比这些更进一步,将独立的服务器或节点分配给不同的应用,这样每个节点只包含它的特定应用所需要的数据。\n这里我们显式地使用了“应用”一词。所指的并不是一个单独的计算机程序,而是相关的一系列程序,这些程序可以很容易地彼此分离,没有关联。例如,如果有一个网站,各个部分无须共享数据,那么可以按照网站的功能区域进行划分。门户网站常常把不同的栏目放在一起;在门户网站,可以浏览网站新闻、论坛,寻求支持和访问知识库,等等。这些不同功能区域的数据可以放到专用的MySQL服务器中,如图11-5所示。\n图11-5:一个门户网站以及专用于不同功能区域的节点\n如果应用很庞大,每个功能区域还可以拥有其专用的Web服务器,但没有专用的数据库服务器这么常见。\n另一个可能的按功能划分方法是对单个服务器的数据进行划分,并确保划分的表集合之间不会执行关联操作。当必须执行关联操作时,如果对性能要求不高,可以在应用中做关联。虽然有一些变通的方法,但它们有一个共同点,就是每种类型的数据只能在单个节点上找到。这并不是一种通用的分布数据方法,因为很难做到高效,并且相比其他方案没有任何优势。\n归根结底,还是不能通过功能划分来无限地进行扩展,因为如果一个功能区域被捆绑到单个MySQL节点,就只能进行垂直扩展。其中的一个应用或者功能区域最终增长到非常庞大时,都会迫使你去寻求一个不同的策略。如果进行了太多的功能划分,以后就很难采用更具扩展性的设计了。\n2.数据分片 # 在目前用于扩展大型MySQL应用的方案中,数据分片(7)是最通用且最成功的方法。它把数据分割成一小片,或者说一块,然后存储到不同的节点中。\n数据分片在和某些类型的按功能划分联合使用时非常有用。大多数分片系统也有一些“全局的”数据不会被分片(例如城市列表或者登录数据)。全局数据一般存储在单个节点上,并且通常保存在类似memcached这样的缓存里。\n事实上,大多数应用只会对需要的数据做分片——通常是那些将会增长得非常庞大的数据。假设正在构建的博客服务,预计会有1000万用户,这时候就无须对注册用户进行分片,因为完全可以将所有的用户(或者其中的活跃用户)放到内存中。假如用户数达到5亿,那么就可能需要对用户数据分片。用户产生的内容,例如发表的文章和评论,几乎肯定需要进行数据分片,因为这些数据非常庞大,并且还会越来越多。\n大型应用可能有多个逻辑数据集,并且处理方式也可以各不相同。可以将它们存储到不同的服务器组上,但这并不是必需的。还可以以多种方式对数据进行分片,这取决于如何使用它们。下文我们会举例说明。\n分片技术和大多数应用的最初设计有着显著的差异,并且很难将应用从单一数据存储转换为分片架构。如果在应用设计初期就已经预计到分片,那实现起来就容易得多。\n许多一开始没有建立分片架构的应用都会碰到规模扩大的情形。例如,可以使用复制来扩展博客服务的读查询,直到它不再奏效。然后可以把服务器划分为三个部分:用户信息、文章,以及评论。可以将这些数据放到不同的服务器上(按功能划分),也许可以使用面向服务的架构,并在应用层执行联合查询。图11-6显示了从单台服务器到按功能划分的演变。\n图11-6:从单个实例到按功能划分的数据存储\n最后,可以通过用户ID来对文章和评论进行分片,而将用户信息保留在单个节点上。如果为全局节点配置一个主—备结构并为分片节点使用主—主结构,最终的数据存储可能如图11-7所示。\n图11-7:一个全局节点和六个主—主结构节点的数据存储方式\n如果事先知道应用会扩大到很大的规模,并且清楚按功能划分的局限性,就可以跳过中间步骤,直接从单个节点升级为分片数据存储。事实上,这种前瞻性可以帮你避免由于粗糙的分片方案带来的挑战。\n采用分片的应用常会有一个数据库访问抽象层,用以降低应用和分片数据存储之间通信的复杂度,但无法完全隐藏分片。因为相比数据存储,应用通常更了解跟查询相关的一些信息。太多的抽象会导致低效率,例如查询所有的节点,可实际上需要的数据只在单一节点上。\n分片数据存储看起来像是优雅的解决方案,但很难实现。那为什么要选择这个架构呢?答案很简单:如果想扩展写容量,就必须切分数据。如果只有单台主库,那么不管有多少备库,写容量都是无法扩展的。对于上述缺点而言,数据分片是我们首选的解决方案。\n分片?还是不分片?\n这是一个问题,对吧?答案很简单:如非必要,尽量不分片。首先看是否能通过性能调优或者更好的应用或数据库设计来推迟分片。如果能足够长时间地推迟分片,也许可以直接购买更大的服务器,升级MySQL到性能更优的版本,然后继续使用单台服务器,也可以增加或减少复制。\n简单的说,对单台服务器而言,数据大小或写负载变得太大时,分片将是不可避免的。如果不分片,而是尽可能地优化应用,系统能扩展到什么程度呢?答案可能会让你很惊讶。有些非常受欢迎的应用,你可能以为从一开始就分片了,但实际上直到已经值数十亿美元并且流量极其巨大也还没有采用分片的设计。分片不是城里唯一的游戏,在没有必要的情况下采用分片的架构来构建应用会步履维艰。\n3.选择分区键(partitioning key) # 数据分片最大的挑战是查找和获取数据:如何查找数据取决于如何进行分片。有很多方法,其中有一些方法会比另外一些更好。\n我们的目标是对那些最重要并且频繁查询的数据减少分片(记住,可扩展性法则的其中一条就是要避免不同节点间的交互)。这其中最重要的是如何为数据选择一个或多个分区键。分区键决定了每一行分配到哪一个分片中。如果知道一个对象的分区键,就可以回答如下两个问题:\n应该在哪里存储数据? 应该从哪里取到希望得到的数据? 后面将展示多个选择和使用分区键的方法。先看一个例子。假设像MySQL NDB Cluster那样来操作,并对每个表的主键使用哈希来将数据分割到各个分片中。这是一种非常简单的实现,但可扩展性不好,因为可能需要频繁检查所有分片来获得需要的数据。例如,如果想查看user3的博客文章,可以从哪里找到呢?由于使用主键值而非用户名进行分割,博客文章可能均匀分散在所有的数据分片中。使用主键值哈希简化了判断数据存储在何处的操作,但却可能增加获取数据的难度,具体取决于需要什么数据以及是否知道主键。\n跨多个分片的查询比单个分片上的查询性能要差,但只要不涉及太多的分片,也不会太糟糕。最糟糕的情况是不知道需要的数据存储在哪里,这时候就需要扫描所有分片。\n一个好的分区键常常是数据库中一个非常重要的实体的主键。这些键值决定了分片单元。例如,如果通过用户ID或客户端ID来分割数据,分片单元就是用户或者客户端。\n确定分区键一个比较好的办法是用实体—关系图,或一个等效的能显示所有实体及其关系的工具来展示数据模型。尽量把相关联的实体靠得更近。这样可以很直观地找出候选分区键。当然不要仅仅看图,同样也要考虑应用的查询。即使两个实体在某些方面是相关联的,但如果很少或几乎不对其做关联操作,也可以打断这种联系来实现分片。\n某些数据模型比其他的更容易进行分片,具体取决于实体—关系图中的关联性程度。图11-8的左边展示了一个易于分片的数据模型,右边的那个则很难分片。\n图11-8:两个数据模型,一个易于分片,另一个则难以分片\n左边的数据模型比较容易分片,因为与之相连的子图中大多数节点只有一个连接,很容易切断子图之间的联系。右边的数据模型则很难分片,因为它没有类似的子图。幸好大多数数据模型更像左边的图。\n选择分区键的时候,尽可能选择那些能够避免跨分片查询的,但同时也要让分片足够小,以免过大的数据片导致问题。如果可能,应该期望分片尽可能同样小,这样在为不同数量的分片进行分组时能够很容易平衡。例如,如果应用只在美国使用,并且希望将数据集分割为20个分片,则可能不应该按照州来划分,因为加利福尼亚的人口非常多。但可以按照县或者电话区号来划分,因为尽管并不是均匀分布的,但足以选择20个集合以粗略地表示等同的密集程度,并且基本上避免跨分片查询。\n4.多个分区键 # 复杂的数据模型会使数据分片更加困难。许多应用拥有多个分区键,特别是存在两个或更多个“维度”的时候。换句话说,应用需要从不同的角度看到有效且连贯的数据视图。这意味着某些数据在系统内至少需要存储两份。\n例如,需要将博客应用的数据按照用户ID和文章ID进行分片,因为这两者都是应用查询数据时使用比较普遍的方式。试想一下这种情形:频繁地读取某个用户的所有文章,以及某个文章的所有评论。如果按用户分片就无法找到某篇文章的所有评论,而按文章分片则无法找到某个用户的所有文章。如果希望这两个查询都落到同一个分片上,就需要从两个维度进行分片。\n需要多个分区键并不意味着需要去设计两个完全冗余的数据存储。我们来看看另一个例子:一个社交网站下的读书俱乐部站点,该站点的所有用户都可以对书进行评论。该网站可以显示所有书籍的所有评论,也能显示某个用户已经读过或评论过的所有书籍。\n假设为用户数据和书籍数据都设计了分片数据存储。而评论同时拥有用户ID和评论ID,这样就跨越了两个分片的边界。实际上却无须冗余存储两份评论数据,替代方案是,将评论和用户数据一起存储,然后把每个评论的标题和ID与书籍数据存储在一起。这样在渲染大多数关于某本书的评论的视图时无须同时访问用户和书籍数据存储,如果需要显示完整的评论内容,可以从用户数据存储中获得。\n5.跨分片查询 # 大多数分片应用多少都有一些查询需要对多个分片的数据进行聚合或关联操作。例如,一个读书俱乐部网站要显示最受欢迎或最活跃的用户,就必须访问每一个分片。如何让这类查询很好地执行,是实现数据分片的架构中最困难的部分。虽然从应用的角度来看,这是一条查询,但实际上需要拆分成多条并行执行的查询,每个分片上执行一条。一个设计良好的数据库抽象层能够减轻这个问题,但类似的查询仍然会比分片内查询要慢并且更加昂贵,所以通常会更加依赖缓存。\n一些语言,如PHP,对并行执行多条查询的支持不够好。普遍的做法是使用C或Java编写一个辅助应用来执行查询并聚合结果集。PHP应用只需要查询该辅助应用即可,例如Web服务或者类似Gearman的工作者服务。\n跨分片查询也可以借助汇总表来执行。可以遍历所有分片来生成汇总表并将结果在每个分片上冗余存储。如果在每个分片上存储重复数据太过浪费,也可以把汇总表放到另外一个数据存储中,这样就只需要存储一份了。\n未分片的数据通常存储在全局节点中,可以使用缓存来分担负载。\n如果数据的均衡分布非常重要,或者没有很好的分区键,一些应用会采用随机分片的方式。分布式检索应用就是个很好的例子。这种场景下,跨分片查询和聚合查询非常常见。跨分片查询并不是数据分片面临的唯一难题。维护数据一致性同样困难。外键无法在分片间工作,因此需要由应用来检查参照一致性,或者只在分片内使用外键,因为分片内的内部一致性可能是最重要的。还可以使用XA事务,但由于开销太大,现实中使用很少。\n还可以设计一些定期执行的清理过程。例如,如果一个用户的读书俱乐部账号到期,并不需要立刻将其移除。可以写一个定期任务将用户评论从每个书籍分片中移除。也可以写一个检查脚本周期性运行以确保分片间的数据一致性。\n6.分配数据、分片和节点 # 分片和节点不一定是一对一的关系,应该尽可能地让分片的大小比节点容量小很多,这样就可以在单个节点上存储多个分片。\n保持分片足够小更容易管理。这将使数据的备份和恢复更加容易,如果表很小,那么像更改表结构这样的操作会更加容易。例如,假设有一个100GB的表,你可以直接存储,也可以将其划分为100个1GB的分片,并存储在单个节点上。现在假如要向表上增加一个索引,在单个100GB的表上的执行时间会比100个1GB分片上执行的总时间更长,因为1GB的分片更容易全部加载到内存中。并且在执行ALTER TABLE时还会导致数据不可用,阻塞1GB的数据比阻塞100GB的数据要好得多。\n小一点的分片也便于转移。这有助于重新分配容量,平衡各个节点的分片。转移分片的效率一般都不高。通常需要先将受影响的分片设置为只读模式(这也是需要在应用中构建的特性),提取数据,然后转移到另外一个节点。这包括使用mysqldump获取数据然后使用mysql命令将其重新导入。如果使用的是Percona Server,可以通过XtraBackup在服务器间转移文件,这比转储和重新载入要高效得多。\n除了在节点间移动分片,你可能还需要考虑在分片间移动数据,并尽量不中断整个应用提供服务。如果分片太大,就很难通过移动整个分片来平衡容量,这时候可能需要将一部分数据(例如一个用户)转移到其他分片。分片间转移数据比转移分片要更复杂,应该尽量避免这么做。这也是我们建议设置分片大小尽量易于管理的原因之一。\n分片的相对大小取决于应用的需求。简单的说,我们说的“易于管理的大小”是指保持表足够小,以便能在5或10分钟内提供日常的维护工作,例如ALTER TABLE、CHECK TABLE或者OPTIMIZE TABLE。\n如果将分片设置得太小,会产生太多的表,这可能引发文件系统或MySQL内部结构的问题。另外太小的分片还会导致跨分片查询增多。\n7.在节点上部署分片 # 需要确定如何在节点上部署数据分片。以下是一些常用的办法:\n每个分片使用单一数据库,并且数据库名要相同。典型的应用场景是需要每个分片都能镜像到原应用的结构。这在部署多个应用实例,并且每个实例对应一个分片时很有用。 将多个分片的表放到一个数据库中,在每个表名上包含分片号(例如bookclub.comments_23)。这种配置下,单个数据库可以支持多个数据分片。 为每个分片使用一个数据库,并在数据库中包含所有应用需要的表。在数据库名中包含分片号(例如表名可能是bookclub_23.comments或者bookclub_23.users等),但表名不包括分片号。当应用连接到单个数据库并且不在查询中指定数据库名时,这种做法很常见。其优点是无须为每个分片专门编写查询,也便于对只使用单个数据库的应用进行分片。 每个分片使用一个数据库,并在数据库名和表名中包含分片号(例如表名可以是bookclub_23.comments_23)。 在每个节点上运行多个MySQL实例,每个实例上有一个或多个分片,可以使用上面提到的方式的任意组合来安排分片。 如果在表名中包含了分片号,就需要在查询模板里插入分片号。常用的方法是在查询中使用特殊的“神奇的”占位符,例如sprintf()这样的格式化函数中的%s,或者使用变量做字符串插值。以下是在PHP中创建查询模板的方法:\n$sql = \u0026quot;SELECT book_id, book_title FROM bookclub_%d.comments_%d...|'; $res = mysql_query(sprintf($sql, $shardno, $shardno), $conn); 也可以就使用字符串插值的方法:\n$sql = \u0026quot;SELECT book_id, book_title FROM bookclub_$shardno.comments_$shardno...\u0026quot;; $res = mysql_query($sql, $conn); 这在新应用中很容易实现,但对于已有的应用则有点困难。构建新应用时,查询模板并不是问题,我们倾向于使用每个分片一个数据库的方式,并把分片号写到数据库名和表名中。这会增加例如ALTER TABLE这类操作的复杂度,但也有如下一些优点:\n如果分片全部在一个数据库中,转移分片会比较容易。 因为数据库本身是文件系统中的一个目录,所以可以很方便地管理一个分片的文件。 如果分片互不关联,则很容易查看分片的大小。 全局唯一表名可避免误操作。如果表名每个地方都相同,很容易因为连接到错误的节点而查询了错误的分片,或者是将一个分片的数据误导入另外一个分片的表中。 你可能想知道应用的数据是否具有某种“分片亲和性”。也许将某些分片放在一起(在同一台服务器,同一个子网,同一个数据中心,或者同一个交换网络中)可以利用数据访问模式的相关性,能够带来些好处。例如,可以按照用户进行分片,然后将同一个国家的用户放到同一个节点的分片上。\n为已有的应用增加分片支持的结果往往是一个节点对应一个分片。这种简化的设计可以减少对应用查询的修改。分片对应用而言通常是一种颠覆性的改变,所以应尽可能简化它。如果在分片后,每个节点看起来就像是整个应用数据的缩略图,就无须去改变大多数查询或担心查询是否传递到期望的节点。\n8.固定分配 # 将数据分配到分片中有两种主要的方法:固定分配和动态分配。两种方法都需要一个分区函数,使用行的分区键值作为输入,返回存储该行的分片。(8)\n固定分配使用的分区函数仅仅依赖于分区键的值。哈希函数和取模运算就是很好的例子。这些函数按照每个分区键的值将数据分散到一定数量的“桶”中。\n假设有100个桶,你希望弄清楚用户111该放到哪个桶里。如果使用的是对数字求模的方式,答案很简单:111对100取模的值为11,所以应该将其放到第11个分片中。\n而如果使用CRC32()函数来做哈希,答案是81。\n固定分配的主要优点是简单,开销低,甚至可以在应用中直接硬编码。\n但固定分配也有如下缺点:\n如果分片很大并且数量不多,就很难平衡不同分片间的负载。 固定分片的方式无法自定义数据放到哪个分片上,这一点对于那些在分片间负载不均衡的应用来说尤其重要。一些数据可能比其他的更加活跃,如果这些热点数据都分配到同一个分片中,固定分配的方式就无法通过热点数据转移的方式来平衡负载。(如果每个分片的数据量切分得比较小,这个问题就没那么严重,根据大数定律,这样做会更容易将热点数据平均分配到不同分片。) 修改分片策略通常比较困难,因为需要重新分配已有的数据。例如,如果通过模10的哈希函数来进行分片,就会有10个分片。如果应用增长使得分片变大,如果要拆分成20个分片,就需要对所有数据重新哈希,这会导致更新大量数据,并在分片间转移数据。 正是由于这些限制,我们倾向于为新应用选择动态分配的方式。但如果是为已有的应用做分片,使用固定分配策略可能会更容易些,因为它更简单。也就是说,大多数使用固定分配的应用最后迟早要使用动态分配策略。\n9.动态分配 # 另外一个选择是使用动态分配,将每个数据单元映射到一个分片。假设一个有两列的表,包括用户ID和分片ID。\nCREATE TABLE user_to_shard ( user_id INT NOT NULL, shard_id INT NOT NULL, PRIMARY KEY (user_id) ); 这个表本身就是分区函数。给定分区键(用户ID)的值就可以获得分片号。如果该行不存在,就从目标分片中找到并将其加入到表中。也可以推迟更新——这就是动态分配的含义。\n动态分配增加了分区函数的开销,因为需要额外调用一次外部资源,例如目录服务器(存储映射关系的数据存储节点)。出于效率方面的考虑,这种架构常常需要更多的分层。例如,可以使用一个分布式缓存系统将目录服务器的数据加载到内存中,因为这些数据平时改动很小。或者更普遍地,你可以直接向USERS表中增加一个shard_id列用于存储分片号。\n动态分配的最大好处是可以对数据存储位置做细粒度的控制。这使得均衡分配数据到分片更加容易,并可提供适应未知改变的灵活性。\n动态映射可以在简单的键—分片(key-to-shard)映射的基础上建立多层次的分片策略。例如,可以建立一个双重映射,将每个分片单元指定到一个分组中(例如,读书俱乐部的用户组),然后尽可能将这些组保持在同一个分片中。这样可以利用分片亲和性,避免跨分片查询。\n如果使用动态分配策略,可以生成不均衡的分片。如果服务器能力不相同,或者希望将其中一些分片用于特定目的(例如归档数据),这可能会有用。如果能够做到随时重新平衡分片,也可以为分片和节点间维持一一对应的映射关系,这不会浪费容量。也有些人喜欢简单的每个节点一个分片的方式。(但是请记住,保持分片尽可能小是有好处的。)动态分配以及灵活地利用分片亲和性有助于减轻规模扩大而带来的跨分片查询问题。假设一个跨分片查询涉及四个节点,当使用固定分配时,任何给定的查询可能需要访问所有分片,但动态分配策略则可能只需要在其中的三个节点上运行同样的查询。这看起来没什么大区别,但考虑一下当数据存储增加到400个分片时会发生什么?固定分配策略需要访问400个分片,而动态分配方式依然只需要访问3个。\n动态分配可以让分片策略根据需要变得很复杂。固定分配则没有这么多选择。\n10.混合动态分配和固定分配 # 可以混合使用固定分配和动态分配。这种方法通常很有用,有时候甚至必须要混合使用。目录映射不太大时,动态分配可以很好胜任。但如果分片单元太多,效果就会变差。\n以一个存储网站链接的系统为例。这样一个站点需要存储数百亿的行,所使用的分区键是源地址和目的地址URL的组合。(这两个URL的任意一个都可能有好几亿的链接,因此,单独一个URL并不适合做分区键)。但是在映射表中存储所有的源地址和目的地址URL组合并不合理,因为数据量太大了,每个URL都需要很多存储空间。\n一个解决方案是将URL相连并将其哈希到固定数目的桶中,然后把桶动态地映射到分片上。如果桶的数目足够大——例如100万个——你就能把大多数数据分配到每个分片上,获得动态分配的大部分好处,而无须使用庞大的映射表。\n11.显式分配 # 第三种分配策略是在应用插入新的数据行时,显式地选择目标分片。这种策略在已有的数据上很难做到。所以在为应用增加分片时很少使用。但在某些情况下还是有用的。\n这个方法是把数据分片号编码到ID中,这和之前提到的避免主—主复制主键冲突策略比较相似。(详情请参阅“在主—主复制结构中写入两台主库”。)\n例如,假设应用要创建一个用户3,将其分配到第11个分片中,并使用BIGINT列的高八位来保存分片号。这样最终的ID就是(11\u0026laquo;56)+3,即792633534417207299。应用可以很方便地从中抽取出用户ID和分片号,如下例所示。\n现在假设要为该用户创建一条评论,并存储在同一个分片中。应用可以为该用户分配一个评论ID 5,然后以同样的方式组合5和分片号11。\n这种方法的好处是每个对象的ID同时包含了分区键,而其他方法通常需要一次关联或查找来确定分区键。如果要从数据库中检索某个特定的评论,无须知道哪个用户拥有它;对象ID会告诉你到哪里去找。如果对象是通过用户ID动态分片的,就得先找到该评论的用户,然后通过目录服务器找到对应的数据分片。\n另一个解决方案是将分区键存储在一个单独的列里。例如,你可能从不会单独引用评论5,但是评论5属于用户3。这种方法可能会让一些人高兴,因为这不违背第一范式;然而额外的列会增加开销、编码,以及其他不便之处。(这也是我们将两值存在单独一列的优点之一。)\n显式分配的缺点是分片方式是固定的,很难做到分片间的负载均衡。但结合固定分配和动态分配,该方法就能够很好地工作。不再像之前那样哈希到固定数目的桶里并将其映射到节点,而是将桶作为对象的一部分进行编码。这样应用就能够控制数据的存储位置,因此可以将相关联的数据一起放到同样的分片中。\nBoardReader( http://boardreader.com)使用了该技术的一个变种:它把分区键编码到Sphinx的文档ID内。这使得在分片数据存储中查找每个查询结果的关联数据变得容易,更多关于Sphinx的内容可以查阅附录F。\n我们讨论了混合分配方式,因为在某些场景下它是有用的。但正常情况下我们并不推荐这样用。我们倾向于尽可能使用动态分配,避免显式分配。\n12.重新均衡分片数据 # 如有必要,可以通过在分片间移动数据来达到负载均衡。举个例子,许多读者可能听一些大型图片分享网站或流行社区网站的开发者提到过用于分片间移动用户数据的工具。在分片间移动数据的好处很明显。例如,当需要升级硬件时,可以将用户数据从旧分片转移到新分片上,而无须暂停整个分片的服务或将其设置为只读。\n然而,我们也应该尽量避免重新均衡分片数据,因为这可能会影响用户使用。在分片间转移数据也使得为应用增加新特性更加困难,因为新特性可能还需要包含针对重新均衡脚本的升级。如果分片足够小,就无须这么做;也可以经常移动整个分片来重新均衡负载,这比移动分片中的部分数据要容易得多(并且以每行数据开销来衡量的话,更有效率)。\n一个较好的策略是使用动态分片策略,并将新数据随机分配到分片中。当一个分片快满时,可以设置一个标志位,告诉应用不要再往这里放数据了。如果未来需要向分片中放入更多数据,可以直接把标记位清除。\n假设安装了一个新的MySQL节点,上面有100个分片。先将它们的标记设置为1,这样应用就知道它们正准备接受新数据。一旦它们的数据足够多时(例如,每个分片10 000个用户),就把标记位设置为0。之后,如果节点因为大量废弃账号导致负载不足,可以重新打开一些分片向其中增加新用户。\n如果升级应用并且增加的新特性会导致每个分片的查询负载升高,或者只是算错了负载,可以把一些分片移到新节点来减轻负载。缺点是操作期间整个分片会变成只读或者处于离线状态。这需要根据实际情况来看是否能接受。\n另外一种使用得较多的策略是为每个分片设置两台备库,每个备库都有该分片的完整数据。然后每个备库负责其中一半的数据,并完全停止在主库上查询。这样每个备库都会有一半它不会用到的数据;我们可以使用一些工具,例如Percona Toolkit的pt-archiver,在后台运行,移除那些不再需要的数据。这种办法很简单并且几乎不需要停机。\n13.生成全局唯一ID # 当希望把一个现有系统转换为分片数据存储时,经常会需要在多台机器上生成全局唯一ID。单一数据存储时通常可以使用AUTO_INCREMENT列来获取唯一ID。但涉及多台服务器时就不凑效了。以下几种方法可以解决这个问题:\n使用auto_increment_increment和auto_increment_offset\n这两个服务器变量可以让MySQL以期望的值和偏移量来增加AUTO_INCREMENT列的值。举一个最简单的场景,只有两台服务器,可以配置这两台服务器自增幅度为2,其中一台的偏移量设置为1,另外一台为2(两个都不可以设置为0)。这样一台服务器总是包含偶数,另外一台则总是包含奇数。这种设置可以配置到服务器的每一个表里。\n这种方法简单,并且不依赖于某个节点,因此是生成唯一ID的比较普遍的方法。但这需要非常仔细地配置服务器。很容易因为配置错误生成重复数字,特别是当增加服务器需要改变其角色,或进行灾难恢复时。\n全局节点中创建表\n在一个全局数据库节点中创建一个包含AUTO_INCREMENT列的表,应用可以通过这个表来生成唯一数字。\n使用memcached\n在memcached的API中有一个incr()函数,可以自动增长一个数字并返回结果。另外也可以使用Redis。\n批量分配数字\n应用可以从一个全局节点中请求一批数字,用完后再申请。\n使用复合值\n可以使用一个复合值来做唯一ID,例如分片号和自增数的组合。具体参阅之前的章节。\n使用GUID值\n可以使用UUID()函数来生成全局唯一值。注意,尽管这个函数在基于语句的复制时不能正确复制,但可以先获得这个值,再存放到应用的内存中,然后作为数字在查询中使用。GUID的值很大并且不连续,因此不适合做InnoDB表的主键。具体参考“和InnoDB主键一致地插入行”。在5.1及更新的版本中还有一个函数UUID_SHORT(),能够生成连续的值,并使用64位代替了之前的128位。\n如果使用全局分配器来产生唯一ID,要注意避免单点争用成为应用的性能瓶颈。\n虽然memcached方法执行速度快(每秒数万个值),但不具备持久性。每次重启memcached服务都需要重新初始化缓存里的值。由于需要首先找到所有分片中的最大值,因此这一过程非常缓慢并且难以实现原子性。\n14.分片工具 # 在设计数据分片应用时,首先要做的事情是编写能够查询多个数据源的代码。\n如果没有任何抽象层,直接让应用访问多个数据源,那绝对是一个很差的设计,因为这会增加大量的编码复杂性。最好的办法是将数据源隐藏在抽象层中。这个抽象层主要完成以下任务:\n连接到正确的分片并执行查询。 分布式一致性校验。 跨分片结果集聚合。 跨分片关联操作。 锁和事务管理。 创建新的数据分片(或者至少在运行时找到新分片)并重新平衡分片(如果有时间实现)。 你可能不需要从头开始构建分片结构。有一些工具和系统可以提供一些必要的功能或专门设计用来实现分片架构。\nHibernate Shards( http://shards.hibernate.org)是一个支持分片的数据库抽象层,基于Java语言的开源的Hibernate ORM库扩展,由谷歌提供。它在Hibernate Core 接口上提供了分片感知功能,所以应用无须专门为分片设计;事实上,应用甚至无须知道它正在使用分片。Hibernate Shards 通过固定分配策略向分片分配数据。另外一个基于Java的分片系统是HiveDB( http://www.hivedb.org)。\n如果使用的是PHP语言,可以使用Justin Swanhart提供的Shard-Query系统( http://code.google.com/p/shard-query/),它可以自动分解查询,并发执行,并合并结果集。另外一些有同样用途的商用系统有ScaleBase( http://www.scalebase.com)、ScalArc( http://www.scalarc.com),以及dbShards( http://www.dbshards.com)。\nSphinx是一个全文检索引擎,虽然不是分片数据存储和检索系统,但对于一些跨分片数据存储的查询依然有用。Sphinx可以并行查询远程系统并聚合结果集。在附录F中会详细讨论Sphinx。\n11.2.5 通过多实例扩展 # 一个分片较多的架构可能会更有效地利用硬件。我们的研究和经验表明MySQL并不能完全发挥现代硬件的性能。当扩展到超过24个CPU核心时,MySQL的性能开始趋于平缓,不再上升。当内存超过128GB时也同样如此,MySQL甚至不能完全发挥诸如Virident或Fusion-io卡这样的高端PCIe flash设备的I/O性能。\n不要在一台性能强悍的服务器上只运行一个服务器实例,我们还有别的选择。你可以让数据分片足够小,以使每台机器上都能放置多个分片(这也是我们一直提倡的),每台服务器上运行多个实例,然后划分服务器的硬件资源,将其分配给每个实例。\n这样做尽管比较烦琐,但确实有效。这是一种向上扩展和向外扩展的组合方案。也可以用其他方法来实现——不一定需要分片——但分片对于在大型服务器上的联合扩展具有天然的适应性。\n一些人倾向于通过虚拟化技术来实现合并扩展,这有它的好处。但虚拟化技术本身有很大的性能损耗。具体损耗多少取决于具体的技术,但通常都比较明显,尤其是I/O非常快的时候损耗会非常惊人。另一种选择是运行多个MySQL实例,每个实例监听不同的网络端口,或绑定到不同的IP地址。\n我们已经在一台性能强悍的硬件上获得了10倍或15倍的合并系数。你需要平衡管理复杂度代价和更优性能的收益,以决定哪种方法是最优的。\n这时候网络可能会成为瓶颈——这个问题大多数MySQL用户都不会遇到。可以通过使用多块网卡并进行绑定来解决这个问题。但Linux内核可能会不理想,这取决于内核版本,因为老的内核对每个绑定设备的网络中断只能使用一个CPU。因此不要把太多的连线绑定到很少的虚拟设备上,否则会遇到内核层的网络瓶颈。新的内核在这一方面会有所改善,所以需要检查你的系统版本,以确定该怎么做。\n另一个方法是将每个MySQL实例绑定到特定的CPU核心上。这有两点好处:第一,由于MySQL内部的可扩展性限制,当核心数较少时,能够在每个核心上获得更好的性能;第二,当实例在多个核心上运行线程时,由于需要在多核心上同步共享数据,因而会有一些额外的开销。这可以避免硬件本身的可扩展性限制。限制MySQL到少数几个核心能够帮助减少CPU核心之间的交互。注意到反复出现的问题了没?将进程绑定到具有相同物理套接字的核心上可以获得最优的效果。\n11.2.6 通过集群扩展 # 理想的扩展方案是单一逻辑数据库能够存储尽可能多的数据,处理尽可能多的查询,并如期望的那样增长。许多人的第一想法就是建立一个“集群”或者“网格”来无缝处理这些事情,这样应用就无须去做太多工作,也不需要知道数据到底存在哪台服务器上。随着云计算的流行,自动扩展——根据负载或数据大小变化动态地在集群中增加/移除服务器——变得越来越有趣。\n在本书第二版时,我们遗憾地看到已有的技术无法完成这一任务。从那时开始,出现了许多被称为NoSQL的技术。许多NoSQL的支持者发表了一些奇怪且未经证实的观点,例如“关系模型无法进行扩展”,或者“SQL无法扩展”。随着新概念的出现,也出现了一些新的术语。最近谁没有听说过最终一致性、BASE、矢量时钟,或者CAP理论呢?\n但随着时间推移,理性开始逐渐回归。经验表明许多NoSQL数据库太过于简单,并且无法完成很多工作(9)。同时一些基于SQL的技术开始出现——例如451集团(451 Group)的Matt Aslett所提到的NewSQL数据库。SQL和NewSQL到底有什么区别呢?NewSQL数据库中SQL及相关技术都不应该成为问题。而可扩展性问题在关系型数据库中是一个实现上的难题,但新的实现正表现出越来越好的结果。\n所有的旧事物都变成新的了吗?是,但也不是。许多关系型数据库集群的高性能设计正在被构建到系统的更低层,在NoSQL数据库中,特别是使用键—值存储时,这一点很明显。例如NDB Cluster并不是一个SQL数据库;它是一个可扩展的数据库,使用其原生API来控制,通常是使用NoSQL,但也可以通过在前端使用MySQL存储引擎来支持SQL。它是一个完全分布式、非共享高性能、自动分片并且不存在单点故障的事务型数据库服务器。最近几年正变得更强大、更复杂,用途也更广泛。同时,NoSQL数据库也逐渐看起来越来越像关系型数据库。有些甚至还开发了类SQL查询语言。未来典型的集群数据库可能更像是SQL和NoSQL的混合体,有多种存取机制来满足不同的使用需求。所以,我们在从NoSQL中汲取优点,但SQL仍然会保留在集群数据库中。\n在写作本书时,和MySQL结合在一起的集群或分布式数据库技术大致包括:NDB Cluster、Clustrix、Percona XtraDB Cluster、Galera、Schooner Active Cluster、Continuent Tungsten、ScaleBase、ScaleArc、dbShards、Xeround、Akiban、VoltDB,以及GenieDB。这些或多或少以MySQL为基础,或通过MySQL进行控制,或是和MySQL相关。本书会讲到这其中的一部分——例如,在第13章我们会讲到Xeround,在第10章我们讲到了Continuent Tungsten和其他几种技术——这里我们同样会对其中的几个进行描述。\n在开始前,需要指出,可扩展性、高可用性、事务性等是数据库系统的不同特性。许多人会感到困惑并将这些当作是相同的东西,但事实上不是。本章我们主要集中讨论可扩展性。但事实上,可扩展的数据库并不一定非常优秀,除非它能保证高性能,谁愿意牺牲高可用性来进行扩展呢?这些特性的组合堪称数据库的必杀技,但这很难实现。当然这不是本章要讨论的内容。\n最后,除NDB Cluster外,大多数NewSQL集群产品都是比较新的事物。我们还没有看到足够多的生产环境部署以完全获知其优点和限制。尽管它们提到了MySQL协议或其他与MySQL相关的地方,但它们毕竟不是MySQL,因此不在本书讨论的范围内。我们仅仅稍微提一下,由你自己来判断它们是否适用。\n1.MySQL Cluster(NDB Cluster) # MySQL Cluster是两项技术的结合:NDB数据库,以及作为SQL前端的MySQL存储引擎。NDB是一个分布式、具备容错性、非共享的数据库,提供同步复制以及节点间的数据自动分片。NDB Cluset存储引擎将SQL转换为NDB API调用,但遇到NDB不支持的操作时,就会在MySQL服务器上执行(NDB是一个键—值数据存储,无法执行类似联接或聚合的复杂操作)。\nNDB是一个非常复杂的数据库,和MySQL几乎完全不同。在使用NDB时甚至可以不需要MySQL:你可以把它作为一个独立的键—值数据库服务器。它的亮点包括非常高的写入和按键查询吞吐量。NDB可以基于键的哈希自动决定哪个节点应该存储给定的数据。当通过MySQL来控制NDB时,行的主键就是键,其他的列是值。\n因为它基于一些新的技术,并且集群具有容错性和分布式特性,所以管理NDB需要非常专业和特殊的技能。有许多动态变化的部分,还有类似升级集群或增加节点的操作必须正确执行以防止意外的问题。NDB是一项开源技术,但也可以从Oracle购买商业支持。商业支持中包括能够获得专门的集群管理产品Cluster Manager,可以自动执行一些枯燥且棘手的任务。(Severalnines同样提供了一个集群管理产品,参见http://www. severalnines.com)。\nMySQL Cluster正在迅速地增加越来越多的特性和功能。例如在最近的版本中,它开始支持更多类型的集群变更而无须停机操作,并且能够在数据存储的节点上执行一些特定类型的查询,以减少数据传递给MySQL层并在其中执行查询的必要性。(这个特性已由关联下推(push-down join)更名为自适应查询本地化(adaptive query localization)。)\nNDB曾经相对其他MySQL存储引擎具有完全不同的性能特性,但最近的版本更加通用化了。它正在成为越来越多应用的更好的解决方案,包括游戏和移动应用。我们必须强调,NDB是一项重要的技术,能够支持全球最大的关键应用,这些应用处于极高的负载下,具有非常严苛的延迟要求以及不间断要求。举个例子,世界上任何一个通过移动电话网络呼叫的电话使用的就是NDB,并且不是临时方案——对于许多移动电话提供商而言,它是一个主要的并且非常重要的数据库。\nNDB需要一个快速且可靠的网络来连接节点。为了获得最好的性能,最好使用特定的高速连接设备。由于大多数情况下需要内存操作,因此服务器间需要大量的内存。\n那么它有什么缺点呢?复杂查询现在支持得还不是很好,例如那些有很多关联和聚合的查询。所以不要指望用它来做数据仓库。NDB是一个事务型系统,但不支持MVCC,所以读操作也需要加锁,也不做任何的死锁检测。如果发生死锁,NDB就以超时返回的方式来解决。还有很多你应该知道的要点和警告,可以专门写一本书了。(有一些关于MySQL Cluster的书,但大多数都过时了,最好的办法是阅读手册。)\n2.CIustrix # Clustrix( http://www.clustrix.com)是一个分布式数据库,支持MySQL协议,所以它可以直接替代MySQL。除了协议外,它是一个全新的技术,并非建立在MySQL的基础之上。它是一个完全支持ACID,支持MVCC的事务型SQL数据库,主要用于OLTP负载场景。Clustrix 在节点间进行数据分片以满足容错性,并对查询进行分发,在节点上并发执行,而不是将所有节点上取得的数据集中起来执行。集群可以在线扩展节点来处理更多的数据或负载。在某些方面Clustrix和MySQL Cluster很像;关键的不同点是,Clustrix是完全分布式执行并且缺少顶层的“代理”或者集群前端的查询协调器(query coordinator)。Clustrix本身能够理解MySQL协议,所以无须MySQL来进行协议转换。相比较而言, MySQL cluster是由三个部分组成的:MySQL,NDB集群存储引擎,以及NDB。\n我们的实验评估和性能测试表明,Clustrix能够提供高性能和可扩展性。Clustrix看起来是一项比较有前景的技术,我们将继续观察和评估。\n3.ScaleBase # ScaleBase( http://www.scalebase.com)是一个软件代理,处于应用和多个后端MySQL服务器之间。它会把发起的查询进行分裂,并将其分发到后端服务器并发执行,然后汇集结果返回给应用。不过在写作本书时,我们还没有使用该产品的经验。另外的竞争产品有ScaleArc( http://www.calearc.com)和dbShards( http://www.dbshards.com)。\n4.GenieDB # GenieDB( http://www.geniedb.com)最开始用于地理上分布部署的NoSQL文档存储。现在它也有一个SQL层,可以通过MySQL存储引擎进行控制。它包含了很多技术,包括本地内存缓存、消息层,以及持久化磁盘数据存储。将这些技术汇集在一起,就可以使用松散的最终一致性,让应用在本地快速执行查询,或是通过分布式集群(会增加网络延迟)来保证最新的数据视图。\n通过存储引擎实现的MySQL兼容层不能提供100%的MySQL特性,但对于支持类似Joomla!、WordPress,以及Drupal 这样的应用已经够用了。MySQL存储引擎的用处主要是使GenieDB能够结合存储引擎获得对ACID的支持,例如InnoDB。GenieDB本身并不是ACID数据库。\n我们还没用应用过GenieDB,也没有看到任何生产环境部署。\n5.Akiban # 对Akiban( http://www.akiban.com)最好的描述应该是查询加速器。它通过存储物理数据来匹配查询模式,使得低开销的跨表关联操作成为可能。尽管类似反范式化(denormalization),但数据层并不是冗余的,所以这和预先计算关联并存储结果的方式是不同的。关联表中元组是互相交错的,所以能够按照关联顺序进行顺序扫描。这就要求管理员确定查询模式能够从所谓的“表组”(table grouping)技术中受益,并需要为查询优化设计表组。目前建议的系统架构是将Akiban配置为MySQL主库的备库,并用它来为可能较慢的查询提供服务。加速系数是一到两个数量级。但是我们还没有看到生产环境部署或者相关的实验评估。(10)\n11.2.7 向内扩展 # 处理不断增长的数据和负载最简单的办法是对不再需要的数据进行归档和清理。这种操作可能会带来显著的成效,具体取决于工作负载和数据特性。这种做法并不用来代替其他策略,但可以作为争取时间的短期策略,也可以作为处理大数据量的长期计划之一。\n在设计归档和清理策略时需要考虑到如下几点。\n对应用的影响\n一个设计良好的归档系统能够在不影响事务处理的情况下,从一个高负载的OLTP服务器上移除数据。这里的关键是能高效地找到要删除的行,然后一小块一小块地移除。通常需要平衡一次归档的行数和事务的大小,以找到一个锁竞争和事务负载量的平衡。还需要设计归档任务在必要的时候让步于事务处理。\n要归档的行\n当知道某些数据不再使用后,就可以立刻清理或归档它们。也可以设计应用去归档那些几乎不怎么使用的数据。可以把归档的数据置于核心表附近,通过视图来访问,或完全转移到别的服务器上。\n维护数据一致性\n当数据间存在联系时,会导致归档和清理工作更加复杂。一个设计良好的归档任务能够保证数据的逻辑一致性,或至少在应用需要时能够保证一致,而无须在大量事务中包含多个表。\n当表之间存在关系时,哪个表首先归档是个问题。在归档时需要考虑孤立行的影响。可以选择违背外键约束(可以通过执行SET FOREIGN_KEY_CHECKS=0禁止InnoDB的外键约束)或暂时把“悬空指针”(dangling pointer)记录放到一边。如果应用层认为这些相关联的表具有层次关系,那么归档的顺序也应该和它一样。例如,如果应用总是先检查订单再检查发货单,就先归档订单。应用应该看不到孤立的发货单,因此接下来就可以将发货单归档。\n避免数据丢失\n如果是在服务器间归档,归档期间可能就无法做分布式事务处理,也有可能将数据归档到MyISAM或其他非事务型的存储引擎中。因此,为了避免数据丢失,在从源表中删除时,要保证已经在目标机器上保存。将归档数据单独写到一个文件里也是个好主意。可以将归档任务设计为能够随时关闭或重启,并且不会引起不一致或索引冲突之类的错误。\n解除归档(unarchiving)\n可以通过一些解除归档策略来减少归档的数据量。它可以帮助你归档那些不确定是否需要的数据,并在以后可以通过选项进行回退。如果可以设置一些检查点让系统来检查是否有需要归档的数据,那么这应该是一个很容易实现的策略。例如,要对不活跃的用户进行归档,检查点就可以设置在登录验证时。如果因为用户不存在导致登录失败,可以去检查归档数据中是否存在该用户,如果有,则从中取出来并完成登录。\nPercona Toolkit包含的工具pt-archiver能够帮助你有效地归档和清理MySQL表,但不提供解除归档功能。\n保持活跃数据独立 # 即使并不真的把老数据转移到别的服务器,许多应用也能受益于活跃数据和非活跃数据的隔离。这有助于高效利用缓存,并为活跃和不活跃的数据使用不同的硬件或应用架构。下面列举了几种做法:\n将表划分为几个部分\n分表是一种比较明智的办法,特别是整张表无法完全加载到内存时。例如,可以把users表划分为active_users和inactive_users表。你可能认为这并不需要,因为数据库本身只缓存“热”数据,但事实上这取决于存储引擎。如果用的是InnoDB,每次缓存一页,而一页能存储100个用户,但只有10%是活跃的,那么这时候InnoDB可能认为所有的页都是“热”的——因此每个“热”页的90%将被浪费掉。将其拆成两个表可以明显改善内存利用率。\nMySQL分区\nMySQL 5.1本身提供了对表进行分区的功能,能够帮助把最近的数据留在内存中。第7章详细介绍了分区表。\n基于时间的数据分区\n如果应用不断有新数据进来,一般新数据总是比旧数据更加活跃。例如,我们知道博客服务的流量大多是最近七天发表的文章和评论。更新的大部分是相同的数据集。因此这些数据被完整地保留在内存中,使用复制来保证在主库失效时有一份可用的备份。其他数据则完全可以放到别的地方去。\n我们也看到过这样一种设计,在两个节点的分片上存储用户数据。新数据总是进入“活跃”节点,该节点使用更大的内存和快速硬盘,另外一个节点存储旧数据,使用非常大(但比较慢)的硬盘。应用假设不太会需要旧数据。对于很多应用而言这是合理的假设,依靠10%的最新数据能够满足90%或更多的请求。\n可以通过动态分片来轻松实现这种策略。例如,分片目录表可能定义如下:\nCREATE TABLE users ( user_id int unsigned not null, shard_new int unsigned not null, shard_archive int unsigned not null, archive_timestamp timestamp, PRIMARY KEY (user_id) ); 通过一个归档脚本将旧数据从活跃节点转移到归档节点,当移动用户数据到归档节点时,更新archive_timestamp列的值。shard_new和shard_archive列记录存储数据的分片号。\n11.3 负载均衡 # 负载均衡的基本思路很简单:在一个服务器集群中尽可能地平均负载量。通常的做法是在服务器前端设置一个负载均衡器(一般是专门的硬件设备)。然后负载均衡器将请求的连接路由到最空闲的可用服务器。图11-9显示了一个典型的大型网站负载均衡设置,其中一个负载均衡器用于HTTP流量,另一个用于MySQL访问。\n图11-9:一个典型的读密集型网站负载均衡架构\n负载均衡有五个常见目的。\n可扩展性\n负载均衡对某些扩展策略有所帮助,例如读写分离时从备库读数据。\n高效性\n负载均衡有助于更有效地使用资源,因为它能够控制请求被路由到何处。如果服务器处理能力各不相同,这就尤为重要:你可以把更多的工作分配给性能更好的机器。\n可用性\n一个灵活的负载均衡解决方案能够使用时刻保持可用的服务器。\n透明性\n客户端无须知道是否存在负载均衡设置,也不需要关心在负载均衡器的背后有多少机器,它们的名字是什么。负载均衡器给客户端看到的只是一个虚拟的服务器。\n一致性\n如果应用是有状态的(数据库事务,网站会话等),那么负载均衡器就应将相关的查询指向同一个服务器,以防止状态丢失。应用无须去跟踪到底连接的是哪个服务器。\n在与MySQL相关的领域里,负载均衡架构通常和数据分片及复制紧密相关。你可以把负载均衡和高可用性结合在一起,部署到应用的任一层次上。例如,可以在MySQL Cluster集群的多个SQL节点上做负载均衡,也可以在多个数据中心间做负载均衡,其中每个数据中心又可以使用数据分片架构,每个节点实际上是拥有多个备库的主—主复制对结构,这里又可以做负载均衡。对于高可用性策略也同样如此:在一个架构里可以配置多层的故障转移机制。\n负载均衡有许多微妙之处,举个例子,其中一个挑战就是管理读/写策略。有些负载均衡技术本身能够实现这一点,但其他的则需要应用自己知道哪些节点是可读的或可写的。\n在决定如何实现负载均衡时,应该考虑到这些因素。有许多负载均衡解决方案可以使用,从诸如Wackamole(http://www.backhand.org/wackamole/)这样基于端点的(peer-based)实现,到DNS、LVS(Linux Virtual Server, http://www.linuxvirtualserver.org)、硬件负载均衡器、TCP代理、MySQL Proxy,以及在应用中管理负载均衡。\n在我们的客户中,最普遍的策略是使用硬件负载均衡器,大多是使用HAProxy( http://haproxy.1wt.eu),它看起来很流行并且工作得很好。还有一些人使用TCP代理,例如Pen( http://siag.nu/pen/)。但MySQL Proxy用得并不多。\n11.3.1 直接连接 # 有些人认为负载均衡就是配置在应用和MySQL服务器之间的东西。但这并不是唯一的负载均衡方法。你可以在保持应用和MySQL连接的情况下使用负载均衡。事实上,集中化的负载均衡系统只有在存在一个对等置换的服务器池时才能很好工作。如果应用需要做一些决策,例如在备库上执行读操作是否安全,就需要直接连接到服务器。\n除了可能出现的一些特定逻辑,应用为负载均衡做决策是非常高效的。例如,如果有两个完全相同的备库,你可以使用其中的一个来处理特定分片的数据查询,另一个处理其他的查询。这样能够有效利用备库的内存,因为每个备库只会缓存一部分数据。如果其中一个备库失效,另外一个备库拥有所有的数据,仍然能提供服务。\n接下来的小节将讨论一些应用直连的常见方法,以及在评估每一个选项时的注意点。\n1.复制上的读/写分离 # MySQL复制产生了多个数据副本,你可以选择在备库还是主库上执行查询。由于备库复制是异步的,因此主要的难点是如何处理备库上的脏数据。应该将备库用作只读的,而主库可以同时处理读和写查询。\n通常需要修改应用以适应这种分离需求。然后应用就可以使用主库来进行写操作,并将读操作分配到主库和备库上;如果不太关心数据是否是脏的,可以使用备库,而对需要即时数据的请求使用主库。我们将这称为读/写分离。\n如果使用的是主动—被动模式的主—主复制对,同样也要考虑这个问题。使用这种配置时,只有主动服务器接受写操作。如果能够接受读到脏数据,可以将读分配给被动服务器。\n最大的问题是如何避免由于读了脏数据引起的奇怪问题。一个典型的例子是当一个用户做了某些修改,例如增加了一条博客文章的评论,然后重新加载页面,但并没有看到更新,因为应用从备库读取到了脏的数据。\n比较常见的读/写分离方法如下:\n基于查询分离\n最简单的分离方法是将所有不能容忍脏数据的读和写查询分配到主动或主库服务器上。其他的读查询分配到备库或被动服务器上。该策略很容易实现,但事实上无法有效地使用备库,因为只有很少的查询能容忍脏数据。\n基于脏数据分离\n这是对基于查询分离方法的小改进。需要做一些额外的工作,让应用检查复制延迟,以确定备库数据是否太旧。许多报表类应用都使用这个策略:只需要晚上加载的数据复制到备库即可,它们并不关心是不是100%跟上了主库。\n基于会话分离\n另一个决定能否从备库读数据的稍微复杂一点的方法是判读用户自己是否修改了数据。用户不需要看到其他用户的最新数据,但需要看到自己的更新。可以在会话层设置一个标记位,表明做了更新,就将该用户的查询在一段时间内总是指向主库。这是我们通常推荐的策略,因为它是在简单和有效性之间的一种很好的妥协。\n如果有足够的想象力,可以把基于会话的分离方法和复制延迟监控结合起来。如果用户在10秒前更新了数据,而所有备库延迟在5秒内,就可以安全地从备库中读取数据。但为整个会话选择同一个备库是一个很好的主意,否则用户可能会奇怪有些备库的更新速度比其他服务器要慢。\n基于版本分离\n这和基于会话的分离方法相似:你可以跟踪对象的版本号以及/或者时间戳,通过从备库读取对象的版本或时间戳来判断数据是否足够新。如果备库的数据太旧,可以从主库获取最新的数据。即使对象本身没有变化,但如果是顶层对象,只要下面的任何对象有变化,也可以增加版本号,这简化了脏数据检查(只需要检查顶层对象一处就能判断是否有更新)。例如,在用户发表了一篇新文章后,可以更新用户的版本。这样就会从主库去读取数据了。\n基于全局版本/会话分离\n这个办法是基于版本分离和基于会话分离的变种。当应用执行写操作时,在提交事务后,执行一次SHOW MASTER STATUS操作。然后在缓存中存储主库日志坐标,作为被修改对象以及/或者会话的版本号。当应用连接到备库时,执行SHOW SLAVE STATUS并将备库上的坐标和缓存中的版本号相对比。如果备库相比记录点更新,就可以安全地读取备库数据。\n大多数读/写分离解决方案都需要监控复制延迟来决策读查询的分配,不管是通过复制或负载均衡器,或是一个中间系统。如果这么做,需要注意通过SHOW SLAVE STATUS得到的Seconds_behind_master列的值并不能准确地用于监控延迟。(详情参阅第10章)。Percona Toolkit中的pt-heartbeat工具能够帮助监控延迟,并维护元数据,例如二进制日志位置,这可以减轻之前我们讨论的一些策略存在的问题。\n如果不在乎用昂贵的硬件来承载压力,也就可以不使用复制来扩展读操作,这样当然更简单。这可以避免在主备上分离读的复杂性。有些人认为这很有意义;也有人认为会浪费硬件。这种分歧是由于不同的目的引起的:你是只需要可扩展性,还是要同时具有可扩展性和高利用率?如果需要高利用率,那么备库除了保存数据副本外还需要承担其他任务,就不得不处理这些额外的复杂度。\n2.修改应用的配置 # 还有一个分发负载的方法是重新配置应用。例如,你可以配置多个机器来分担生成大报表操作的负载。每台机器可以配置成连接到不同的MySQL备库,并为第N个用户或网站生成报表。\n这样的系统很容易实现,但如果需要修改一些代码——包括配置文件修改——会变得脆弱且难以处理。硬编码有着固有的限制,需要在每台服务器上修改硬编码,或者在一个中心服务器上修改,然后通过文件副本或代码控制更新命令“发布”到其他服务器上。如果将配置存储在服务器或缓存中,就可以避免这些麻烦。\n3.修改DNS名 # 这是一个比较粗糙的负载均衡技术,但对于一些简单的应用,为不同的目的创建DNS还是很实用的。你可以为不同的服务器指定一个合适的名字。最简单的方法是只读服务器拥有一个DNS名,而给负责写操作的服务器起另外一个DNS名。如果备库能够跟上主库,那就把只读DNS名指定给备库,当出现延迟时,再将该DNS名指定给主库。\n这种DNS技术非常容易实现,但也有很多缺点。最大的问题是无法完全控制DNS。\n修改DNS并不是立刻生效的,也不是原子的。将DNS的变化传递到整个网络或在网络间传播都需要比较长的时间。 DNS数据会在各个地方缓存下来,它的过期时间是建议性质的,而非强制的。 可能需要应用或服务器重启才能使修改后的DNS完全生效。 多个IP地址共用一个DNS名并依赖于轮询行为来均衡请求,这并不是一个好主意。因为轮询行为并不总是可预知的。 DBA可能没有权限直接访问DNS。 除非应用非常简单,否则依赖于不受控制的系统会非常危险。你可以通过修改/etc/hosts文件而非DNS来改善对系统的控制。当发布一个对该文件的更新时,会知道该变更已经生效。这比等待缓存的DNS失效要好得多。但这仍然不是理想的办法。\n我们通常建议人们构建一个完全不依赖DNS的应用。即使应用很简单也适用,因为你无法预知应用会增长到多大规模。\n4.转移IP地址 # 一些负载均衡解决方案依赖于在服务器间转移虚拟地址(11),一般能够很好地工作。这听起来和修改DNS很像,但完全是两码事。服务器不会根据DNS名去监听网络流量,而是根据指定的IP地址去监听流量,所以转移IP地址允许DNS名保持不变。你可以通过ARP(地址解析协议)命令强制使IP地址的更改快速而且原子性地通知到网络上。\n我们看过的使用最普遍的技术是Pacemaker,这是Linux-HA项目的Heartbeat工具的继承者。你可以使用单个IP地址,为其分配一个角色,例如read-only,当需要在机器间转移IP地址时,它能够感知到。其他类似的工具包括LVS和Wackamole。\n一个比较方便的技术是为每个物理服务器分配一个固定的IP地址。该IP地址固定在服务器上,不再改变。然后可以为每个逻辑上的“服务”使用一个虚拟IP地址。它们能够很方便地在服务器间转移,这使得转移服务和应用实例无须再重新配置应用,因此更加容易。即使不怎么经常转移IP地址,这也是一个很好的特性。\n11.3.2 引入中间件 # 迄今为止,我们所讨论的方案都假定应用跟MySQL服务器是直接相连的。但是许多负载均衡解决方案都会引入一个中间件,作为网络通信的代理。它一边接受所有的通信请求,另一边将这些请求派发到指定的服务器上,然后把执行结果发送回请求的机器上。中间件可以是硬件设备或是软件(12)。图11-10描述了这种架构。这种解决方案通常能工作得很好,当然除非为负载均衡器本身增加冗余,这样才能避免单点故障引起的整个系统瘫痪。从开源软件,如HAProxy,到许多广为人知的商业系统,有许多负载均衡器得到了成功的应用。\n图11-10:作为中间件的负载均衡器\n1.负载均衡器 # 在市场上有许多负载均衡硬件和软件,但很少有专门为MySQL服务器设计的(13)。Web服务器通常更需要负载均衡,因此许多多用途的负载均衡设备都会支持HTTP,而对其他用途则只有一些很少的基本特性。MySQL连接都只是正常的TCP/IP连接,所以可以在MySQL上使用多用途负载均衡器。但由于缺少MySQL专有的特性,因此会多一些限制。\n除非负载均衡器知道MySQL的真实负载,否则在分发请求时可能无法做到很好的负载均衡。不是所有的请求都是等同的,但多用途负载均衡器通常对所有的请求一视同仁。 许多负载均衡器知道如何检查一个HTTP请求并把会话“固定”到一个服务器上以保护在Web服务器上的会话状态。MySQL连接也是有状态的,但负载均衡器可能并不知道如何把所有从单个HTTP会话发送的连接请求“固定”到一个MySQL服务器上。这会损失一部分效率。(如果单个会话的请求都是发到同一个MySQL服务器,服务器的缓存会更有效率。) 连接池和长连接可能会阻碍负载均衡器分发连接请求。例如,假如一个连接池打开了预先配置好的连接数,负载均衡器在已有的四个MySQL服务器上分发这些连接。现在增加了两个以上的MySQL服务器。由于连接池不会请求新连接,因而新的服务器会一直空闲着。池中的连接会在服务器间不公平地分配负载,导致一些服务器超出负载,一些则几乎没有负载。可以在多个层面为连接设置失效时间来缓解这个问题,但这很复杂并且很难做到。连接池方案只有它们本身能够处理负载均衡时才能工作得很好。 许多多用途负载均衡器只会针对HTTP服务器做健康和负载检查。一个简单的负载均衡器最少能够核实服务器在一个TCP端口上接受的连接数。更好的负载均衡器能够自动发起一个HTTP请求,并检查返回值以确定这个Web服务器是否正常运转。MySQL并不接受到3306端口的HTTP请求,因此需要自己来构建健康检查方法。你可以在MySQL服务器上安装一个HTTP服务器软件,并将负载均衡器指向一个脚本,这个脚本检查MySQL服务器的状态并返回一个对应的状态值(14)。最重要的是检查操作系统负载(通过查看/proc/loadavg)、复制状态,以及MySQL的连接数。 2.负载均衡算法 # 有许多算法用来决定哪个服务器接受下一个连接。每个厂商都有各自不同的算法,下面这个清单列出了一些可用的方法:\n随机\n负载均衡器随机地从可用的服务器池中选择一个服务器来处理请求。\n轮询\n负载均衡器以循环顺序发送请求到服务器,例如:A,B,C,A,B,C。\n最少连接数\n下一个连接请求分配给拥有最少活跃连接的服务器。\n最快响应\n能够最快处理请求的服务器接受下一个连接。当服务器池里同时存在快速和慢速服务器时,这很有效。即使同样的查询在不同的场景下运行也会有不同的表现,例如当查询结果已经缓存在查询缓存中,或者服务器缓存中已经包含了所需要的数据时。\n哈希\n负载均衡器通过连接的源IP地址进行哈希,将其映射到池中的同一个服务器上。每次从同一个IP地址发起请求,负载均衡器都会将请求发送给同样的服务器。只有当池中服务器数目改变时这种绑定才会发生变化。\n权重\n负载均衡器能够结合使用上述几种算法。例如,你可能拥有单CPU和双CPU的机器。双CPU机器有接近两倍的性能,所以可以让负载均衡器分派两倍的请求给双CPU机器。\n哪种算法最优取决于具体的工作负载。例如最少连接算法,如果有新机器加入,可能会有大量连接涌入该服务器,而这时候它的缓存还没有包含热数据。本书第一版的作者曾经亲身体验了这种情况。\n你需要通过测试来为你的工作负载找到最好的性能。除了正常的日常运转,还需要考虑极端情况。在比较极端的情况下——例如负载升高,修改模式,或者多台服务器下线——至少要避免系统出现重大错误。\n我们这里只描述了即时处理请求的算法,无须对连接请求排队。但有时候使用排队算法可能更有效。例如,一个算法可能只维护给定的数据库服务器并发数目,同一时刻只允许不超过N个活跃事务。如果有太多的活跃事务,就将新的请求放到一个队列里,然后让可用服务器列表的第一个来处理它。有些连接池也支持队列算法。\n3.在服务器池中增加/移除服务器 # 增加一个服务器到池中并不是简单地插入进去,然后通知负载均衡器就可以了。你可能以为只要不是一下子涌进大量连接请求就可以了,但并不一定如此。有时候你会缓慢增加一台服务器的负载,但一些缓存还是“冷”的服务器可能会慢到在一段时间内都无法处理任何的用户请求。如果用户浏览一个页面需要30秒才能返回数据,即使流量很小,这个服务器也是不可用的。有一个方法可以避免这个问题,在通知负载均衡器有新服务器加入前,可以暂时把SELECT查询映射到一台活跃服务器上。然后在新开启的服务器上读取和重放活跃服务器上的日志文件,或者捕捉生产服务器上的网络通信,并重放它的一部分查询。Percona Toolkit中的pt-query-digest工具能够有所帮助。另一个有效的办法是使用Percona Server或MySQL 5.6的快速预热特性。\n在配置连接池中的服务器时,要保证有足够多未使用的容量,以备在撤下服务器做维护时使用,或者当服务器失效时可以派上用场。每台服务器上都应该保留高于“足够”的容量。\n要确保配置的限制值足够高,即使从池中撤出一些服务器也能够工作。举个例子,如果你发现每个MySQL服务器一般有100个连接,应该设置池中每个服务器的max_connections值为200。这样就算一半的服务器失效,服务器池整体也能处理同样数量的请求。\n11.3.3 一主多备间的负载均衡 # 最常见的复制拓扑结构就是一个主库加多个备库。我们很难绕开这个架构。许多应用都假设只有一个目标机器用于所有的写操作,或者所有的数据都可以从单个服务器上获得。尽管这个架构不太具有很好的可扩展性,但可以通过一些办法结合负载均衡来获得很好的效果。本小节将讲述其中的一些技术。\n功能分区\n正如之前讨论的,对于特定的目的可以通过配置备库或一组备库来极大地扩展容量。一些比较常见的功能包括报表、分析、数据仓库,以及全文检索。在第10章有更多的细节。\n过滤和数据分区\n可以使用复制过滤技术在相似的备库上对数据进行分区(参考第10章)。只要数据在主库上已经被隔离到不同的数据库或表中,这种方法就可以奏效。不幸的是,没有内建的办法在行级别上进行复制过滤。你需要使用一些独创性的技术来实现这一点,例如使用触发器和一组不同的表。\n即使不把数据分区到各个备库上,也可以通过对读进行分区而不是随机分配来提高缓存效率。例如,可以把对以字母A—M开头的用户名的读操作分配给一个给定的备库,把以N—Z开头的分配给另外一个。这能够更好地利用每台机器的缓存,因为分离读更可能在缓存中找到相关的数据。最好的情况下,当没有写操作时,这样使用的缓存相当于两台服务器缓存的总和。相比之下,如果随机地在备库上分配读操作,每个机器的缓存本质上还是重复的数据,而总的有效缓存效率和一个备库缓存一样,不管你有多少台备库。\n将部分写操作转移到备库\n主库并不总是需要处理写操作中的所有工作。你可以分解写查询,并在备库上执行其中的一部分,从而显著减少主库的工作量。更多内容参见第10章。\n保证备库跟上主库\n如果要在备库执行某种操作,它需要即时知道数据处于哪个时间点——哪怕需要等待一会儿才能到达这个点——可以使用函数MASTER_POS_WAIT()阻塞直到备库赶上了设置的主库同步点。另一种替代方案是使用复制心跳来检查延迟情况;更多内容参见第10章。\n同步写操作\n也可以使用MASTER_POS_WAIT()函数来确保写操作已经被同步到一个或多个备库上。如果应用需要模拟同步复制来保证数据安全性,就可以在多个备库上轮流执行MASTER_POS_WAIT()函数。这就类似创建了一个“同步屏障”,当任意一个备库出现复制延迟时,都可能花费很长时间完成,所以最好在确实需要的时候才使用这种方法。(如果你的目的只是确保某些备库拥有事件,可以只等待一台备库接收到事件。MySQL 5.5增加了半同步复制,能够支持这项技术。)\n11.4 总结 # 正确地扩展MySQL并没有看起来那么美好。从第一天就建立下一个Facebook架构,这并不是正确的方式。最好的策略是实现应用所明确需要的,并为可能的快速增长做好预先规划,成功的规划是可以为任何必要的措施筹集资金以满足需求。\n为可扩展性制定一个数学意义上的定义是很有意义的,就像为性能制定了一个精确概念一样。USL能够提供一个有帮助的框架。如果知道系统无法做到线性扩展是因为诸如序列化或交互操作的开销,将可以帮助你避免将这些问题带入到应用中。同时,许多可扩展性问题并不是可以从数学上定义的;可能是由于组织内部的问题,例如缺少团队协作或其他不适当的问题。Neil J. Gunther博士所写的Guerrilla Capacity Planning以及Eliyahu M. Goldratt写的The Goal可以帮助有兴趣的读者了解为什么系统无法扩展。\n在MySQL扩展策略方面,典型的应用在增长到非常庞大时,通常先从单个服务器转移到向外扩展的拥有读备库的架构,再到数据分片和/或者按功能分区。我们并不同意那些提倡为每个应用“尽早分片,尽量分片”(shard early, shard often)的建议。这很复杂且代价昂贵,并且许多应用可能根本不需要。可以花一些时间去看看新的硬件和新版本的MySQL有哪些变化,或者MySQL Cluster有哪些新的进展,甚至去评估一些专门的系统,例如Clustrix。毕竟数据分片是一个手工搭建的集群系统,如果没有必要,最好不要重复发明轮子。\n当存在多个服务器时,可能出现跟一致性或原子性相关的问题。我们看到的最普遍的问题是缺少会话一致性(在网站上发表一篇评论,刷新页面,但找不到刚刚发布的评论),或者无法有效告诉应用哪些服务器是可写的,哪些是可读的。后一种可能更严重,如果将应用的写操作指向多个地方,就会不可避免地遭遇数据问题,需要花费大量时间而且很难解决。负载均衡器可以解决这个问题,但它本身也有一些问题,有时候还会使得原本希望解决的问题恶化。这也是我们在下一章要讲述高可用性的原因。\n————————————————————\n(1) 从物理学来看,单位时间内做的功称为功率(power),而在计算机领域,“power”是一个被反复使用的术语,含义模糊,因此应避免使用它。但是关于容量的精确定义是系统的最大功率输出。\n(2) Justin Bieber, 我们仍然爱你!\n(3) 事实上,“投资产出率”也可以从金融投资的角度来考虑。将一个组件的容量升级到两倍所需要付出的常常不止是最初开销的两倍。虽然在现实世界里我们常常这么考虑,但在讨论中会将其忽略掉,因为它会使一个已经复杂的主题变得更加复杂。\n(4) 你也可以阅读我们的白皮书“Forecasting MySQL Scalability with the Universal Scalability Law”,该书扼要地总结了USL中的数学运算和法则,可以从 http://www.percona.com获得。\n(5) 现实中很难精确定义硬件的可扩展性,因为当你改变你的系统中的服务器数量时很难保证那些变量不变。\n(6) 我们避免使用措辞“web扩展”(web scale),因为它已经变得毫无意义,参阅 http://www.xtranormal.com/ watch/6995033/。\n(7) 分片也被称为“分裂”、“分区”,但是我们使用“分片”以避免混淆。谷歌将它称为“分片”,如果谷歌觉得这样称呼合适,我们采取这种称呼也就合适了。\n(8) 这里的“函数”使用了其数学涵义,表示从输入(域)到输出(区间)的映射。如你所见,可以用很多方式来创建类似的函数,包括在数据库中使用查找表。\n(9) Yeah, yeah, 我们知道,为你的工作选择正确的工具。这里引用显而易见但听起来很有意义的评论。\n(10) 我们将Akiban包含在集群数据库列表中可能并不准确,因为它并不是真正的集群数据库。但在某种程度上它和其他一些NewSQL数据库很像。\n(11) 虚拟IP地址不是直接连接到任何特定的计算机或网络端口,而是“漂浮”在计算机之间。\n(12) 你可以把诸如LVS这样的解决方案配置成只有应用需要创建一个新连接时才参与进来,此后不再作为中间件。\n(13) MySQL Proxy是个例外,但目前还未能证明能够很好地工作,因为它会带来一些问题,例如延迟增加以及可扩展性瓶颈。\n(14) 实际上,如果能编码实现一个监听80端口的程序,或者配置xinetd来调用程序,甚至不需要再安装一个Web服务器。\n"},{"id":153,"href":"/zh/docs/technology/MySQL/_%E9%AB%98%E6%80%A7%E8%83%BDMySQL_/%E7%AC%AC10%E7%AB%A0%E5%A4%8D%E5%88%B6/","title":"第10章复制","section":"高性能 My SQL","content":"第10章 复制\nMySQL内建的复制功能是构建基于MySQL的大规模、高性能应用的基础,这类应用使用所谓的“水平扩展”的架构。我们可以通过为服务器配置一个或多个备库(1)的方式来进行数据同步。复制功能不仅有利于构建高性能的应用,同时也是高可用性、可扩展性、灾难恢复、备份以及数据仓库等工作的基础。事实上,可扩展性和高可用性通常是相关联的话题,我们会在接下来的三章详细阐述。\n本章将阐述所有与复制相关的内容,首先简要介绍复制如何工作,然后讨论基本的复制服务搭建,包括与复制相关的配置以及如何管理和优化复制服务器。虽然本书的主题是高性能,但对于复制来说,我们同样需要关注其准确性和可靠性,因此我们也会讲述复制在什么情况下会失败,以及如何使其更好地工作。\n10.1 复制概述 # 复制解决的基本问题是让一台服务器的数据与其他服务器保持同步。一台主库的数据可以同步到多台备库上,备库本身也可以被配置成另外一台服务器的主库。主库和备库之间可以有多种不同的组合方式。\nMySQL支持两种复制方式:基于行的复制和基于语句的复制。基于语句的复制(也称为逻辑复制)早在MySQL 3.23版本中就存在,而基于行的复制方式在5.1版本中才被加进来。这两种方式都是通过在主库上记录二进制日志(2)、在备库重放日志的方式来实现异步的数据复制。这意味着,在同一时间点备库上的数据可能与主库存在不一致,并且无法保证主备之间的延迟。一些大的语句可能导致备库产生几秒、几分钟甚至几个小时的延迟。\nMySQL复制大部分是向后兼容的,新版本的服务器可以作为老版本服务器的备库,但反过来,将老版本作为新版本服务器的备库通常是不可行的,因为它可能无法解析新版本所采用的新的特性或语法,另外所使用的二进制文件的格式也可能不相同。例如,不能从MySQL 5.1复制到MySQL 4.0。在进行大的版本升级前,例如从4.1升级到5.0,或从5.1升级到5.5,最好先对复制的设置进行测试。但对于小版本号升级,如从5.1.51升级到5.1.58,则通常是兼容的。通过阅读每次版本更新的ChangeLog可以找到不同版本间做了什么修改。\n复制通常不会增加主库的开销,主要是启用二进制日志带来的开销,但出于备份或及时从崩溃中恢复的目的,这点开销也是必要的。除此之外,每个备库也会对主库增加一些负载(例如网络I/O开销),尤其当备库请求从主库读取旧的二进制日志文件时,可能会造成更高的I/O开销。另外锁竞争也可能阻碍事务的提交。最后,如果是从一个高吞吐量(例如5000或更高的TPS)的主库上复制到多个备库,唤醒多个复制线程发送事件的开销将会累加。\n通过复制可以将读操作指向备库来获得更好的读扩展,但对于写操作,除非设计得当,否则并不适合通过复制来扩展写操作。在一主库多备库的架构中,写操作会被执行多次,这时候整个系统的性能取决于写入最慢的那部分。\n当使用一主库多备库的架构时,可能会造成一些浪费,因为本质上它会复制大量不必要的重复数据。例如,对于一台主库和10台备库,会有11份数据拷贝,并且这11台服务器的缓存中存储了大部分相同的数据。这和在服务器上有11路RAID 1类似。这不是一种经济的硬件使用方式,但这种复制架构却很常见,本章我们将讨论解决这个问题的方法。\n10.1.1 复制解决的问题 # 下面是复制比较常见的用途:\n数据分布\nMySQL复制通常不会对带宽造成很大的压力,但在5.1版本引入的基于行的复制会比传统的基于语句的复制模式的带宽压力更大。你可以随意地停止或开始复制,并在不同的地理位置来分布数据备份,例如不同的数据中心。即使在不稳定的网络环境下,远程复制也可以工作。但如果为了保持很低的复制延迟,最好有一个稳定的、低延迟连接。\n负载均衡\n通过MySQL复制可以将读操作分布到多个服务器上,实现对读密集型应用的优化,并且实现很方便,通过简单的代码修改就能实现基本的负载均衡。对于小规模的应用,可以简单地对机器名做硬编码或使用DNS轮询(将一个机器名指向多个IP地址)。当然也可以使用更复杂的方法,例如网络负载均衡这一类的标准负载均衡解决方案,能够很好地将负载分配到不同的MySQL服务器上。Linux虚拟服务器(Linux Virtual Server,LVS)也能够很好地工作,第11章将详细地讨论负载均衡。\n备份\n对于备份来说,复制是一项很有意义的技术补充,但复制既不是备份也不能够取代备份。\n高可用性和故障切换\n复制能够帮助应用程序避免MySQL单点失败,一个包含复制的设计良好的故障切换系统能够显著地缩短宕机时间,我们将在第12章讨论故障切换。\nMySQL升级测试\n这种做法比较普遍,使用一个更高版本的MySQL作为备库,保证在升级全部实例前,查询能够在备库按照预期执行。\n10.1.2 复制如何工作 # 在详细介绍如何设置复制之前,让我们先看看MySQL实际上是如何复制数据的。总的来说,复制有三个步骤:\n在主库上把数据更改记录到二进制日志(Binary Log)中(这些记录被称为二进制日志事件)。 备库将主库上的日志复制到自己的中继日志(Relay Log)中。 备库读取中继日志中的事件,将其重放到备库数据之上。 以上只是概述,实际上每一步都很复杂,图10-1更详细地描述了复制的细节。\n图10-1:MySQL复制如何工作\n第一步是在主库上记录二进制日志(稍后介绍如何设置)。在每次准备提交事务完成数据更新前,主库将数据更新的事件记录到二进制日志中。MySQL会按事务提交的顺序而非每条语句的执行顺序来记录二进制日志。在记录二进制日志后,主库会告诉存储引擎可以提交事务了。\n下一步,备库将主库的二进制日志复制到其本地的中继日志中。首先,备库会启动一个工作线程,称为I/O线程,I/O线程跟主库建立一个普通的客户端连接,然后在主库上启动一个特殊的二进制转储(binlog dump)线程(该线程没有对应的SQL命令),这个二进制转储线程会读取主库上二进制日志中的事件。它不会对事件进行轮询。如果该线程追赶上了主库,它将进入睡眠状态,直到主库发送信号量通知其有新的事件产生时才会被唤醒,备库I/O线程会将接收到的事件记录到中继日志中。\nMySQL 4.0之前的复制与之后的版本相比改变很大,例如MySQL最初的复制功能没有使用中继日志,所以复制只用到了两个线程,而不是现在的三个线程。目前大部分人都是使用的最新版本,因此在本章我们不会去讨论关于老版本复制的更多细节。\n备库的SQL线程执行最后一步,该线程从中继日志中读取事件并在备库执行,从而实现备库数据的更新。当SQL线程追赶上I/O线程时,中继日志通常已经在系统缓存中,所以中继日志的开销很低。SQL线程执行的事件也可以通过配置选项来决定是否写入其自己的二进制日志中,它对于我们稍后提到的场景非常有用。\n图10-1显示了在备库有两个运行的线程,在主库上也有一个运行的线程:和其他普通连接一样,由备库发起的连接,在主库上同样拥有一个线程。\n这种复制架构实现了获取事件和重放事件的解耦,允许这两个过程异步进行。也就是说I/O线程能够独立于SQL线程之外工作。但这种架构也限制了复制的过程,其中最重要的一点是在主库上并发运行的查询在备库只能串行化执行,因为只有一个SQL线程来重放中继日志中的事件。后面我们将会看到,这是很多工作负载的性能瓶颈所在。虽然有一些针对该问题的解决方案,但大多数用户仍然受制于单线程。\n10.2 配置复制 # 为MySQL服务器配置复制非常简单。但由于场景不同,基本的步骤还是有所差异。最基本的场景是新安装的主库和备库,总的来说分为以下几步:\n在每台(3)服务器上创建复制账号。 配置主库和备库。 通知备库连接到主库并从主库复制数据。 这里我们假定大部分配置采用默认值即可,在主库和备库都是全新安装并且拥有同样的数据(默认MySQL数据库)时这样的假设是合理的。接下来我们将展示如何一步步配置复制:假设有服务器server1(IP地址192.168.0.1)和服务器server2(IP地址192.168.0.2),我们将解释如何给一个已经运行的服务器配置备库,并探讨推荐的复制配置。\n10.2.1 创建复制账号 # MySQL会赋予一些特殊的权限给复制线程。在备库运行的I/O线程会建立一个到主库的TCP/IP连接,这意味着必须在主库创建一个用户,并赋予其合适的权限。备库I/O线程以该用户名连接到主库并读取其二进制日志。通过如下语句创建用户账号:\nmysql\u0026gt; GRANT REPLICATION SLAVE, REPLICATION CLIENT ON *.* -\u0026gt; TO repl@'192.168.0.%' IDENTIFIED BY 'p4ssword',; 我们在主库和备库都创建该账号。注意我们把这个账户限制在本地网络,因为这是一个特权账号(尽管该账号无法执行select或修改数据,但仍然能从二进制日志中获得一些数据)。\n复制账户事实上只需要有主库上的REPLICATION SLAVE权限,并不一定需要每一端服务器都有REPLICATION CLIENT权限,那为什么我们要把这两种权限给主/备库都赋予呢?这有两个原因:\n用来监控和管理复制的账号需要REPLICATION CLIENT权限,并且针对这两种目的使用同一个账号更加容易(而不是为某个目的单独创建一个账号)。 如果在主库上建立了账号,然后从主库将数据克隆到备库上时,备库也就设置好了——变成主库所需要的配置。这样后续有需要可以方便地交换主备库的角色。 10.2.2 配置主库和备库 # 下一步需要在主库上开启一些设置,假设主库是服务器server1,需要打开二进制日志并指定一个独一无二的服务器ID(server ID),在主库的my.cnf文件中增加或修改如下内容:\nlog_bin = mysql-bin server_id = 10 实际取值由你决定,这里只是为了简单起见,当然也可以设置更多需要的配置。\n必须明确地指定一个唯一的服务器ID,默认服务器ID通常为1(这和版本相关,一些MySQL版本根本不允许使用这个值)。使用默认值可能会导致和其他服务器的ID冲突,因此这里我们选择10来作为服务器ID。一种通用的做法是使用服务器IP地址的末8位,但要保证它是不变且唯一的(例如,服务器都在一个子网里)。最好选择一些有意义的约定并遵循。\n如果之前没有在MySQL的配置文件中指定log-bin选项,就需要重新启动MySQL。为了确认二进制日志文件是否已经在主库上创建,使用SHOW MASTER STATUS命令,检查输出是否与如下的一致。MySQL会为文件名增加一些数字,所以这里看到的文件名和你定义的会有点不一样。\n备库上也需要在my.cnf中增加类似的配置,并且同样需要重启服务器。\nlog_bin = mysql-bin server_id = 2 relay_log = /var/lib/mysql/mysql-relay-bin| Chapter 10:Chapter 10: Replication Replicationlog_slave_updates = 1 read_only = 1 从技术上来说,这些选项并不总是必要的。其中一些选项我们只是显式地列出了默认值。事实上只有server_id是必需的。这里我们同样也使用了log_bin,并赋予了一个明确的名字。默认情况下,它是根据机器名来命名的,但如果机器名变化了可能会导致问题。为了简便起见,我们将主库和备库上的log-bin设置为相同的值。当然如果你愿意的话,也可以设置成别的值。\n另外我们还增加了两个配置选项:relay_log(指定中继日志的位置和命名)和log_slave_updates(允许备库将其重放的事件也记录到自身的二进制日志中),后一个选项会给备库增加额外的工作,但正如后面将会看到的,我们有理由为每个备库设置该选项。\n有时候只开启了二进制日志,但却没有开启log_slave_updates,可能会碰到一些奇怪的现象,例如,当配置错误时可能会导致备库数据被修改。如果可能的话,最好使用read_only配置选项,该选项会阻止任何没有特权权限的线程修改数据(所以最好不要给予用户超出需要的权限)。但read_only选项常常不是很实用,特别是对于那些需要在备库建表的应用。\n不要在配置文件my.cnf中设置master_port或master_host这些选项,这是老的配置方式,已经被废弃,它只会导致问题,不会有任何好处。\n10.2.3 启动复制 # 下一步是告诉备库如何连接到主库并重放其二进制日志。这一步不要通过修改my.cnf来配置,而是使用CHANGE MASTER TO语句,该语句完全替代了my.cnf中相应的设置,并且允许以后指向别的主库时无须重启备库。下面是开始复制的基本命令:\nmysql\u0026gt; ** CHANGE MASTER TO MASTER_HOST='server1',** -\u0026gt; ** MASTER_USER='repl',** -\u0026gt; ** MASTER_PASSWORD='p4ssword',** -\u0026gt; ** MASTER_LOG_FILE='mysql-bin.000001',** -\u0026gt; ** MASTER_LOG_POS=0;** MASTER_LOG_POS参数被设置为0,因为要从日志的开头读起。当执行完这条语句后,可以通过SHOW SLAVE STATUS语句来检查复制是否正确执行。\nmysql\u0026gt; ** SHOW SLAVE STATUS\\G** *************************** 1. row *************************** Slave_IO_State: Master_Host: server1 Master_User: repl Master_Port: 3306 Connect_Retry: 60 Master_Log_File: mysql-bin.000001 Read_Master_Log_Pos: 4 Relay_Log_File: mysql-relay-bin.000001 Relay_Log_Pos: 4 Relay_Master_Log_File: mysql-bin.000001 Slave_IO_Running: No Slave_SQL_Running: No ...omitted... Seconds_Behind_Master: NULL Slave_IO_State、Slave_IO_Running和Slave_SQL_Running这三列显示当前备库复制尚未运行。聪明的读者可能已经注意到日志的开头是4而不是0,这是因为0其实不是日志真正开始的位置,它仅仅意味着“在日志文件头”,MySQL知道第一个事件从文件的第4位(4)开始读。\n运行下面的命令开始复制:\nmysql\u0026gt; ** START SLAVE;** 执行该命令没有显示错误,现在我们再用SHOW SLAVE STATUS命令检查:\nmysql\u0026gt; ** SHOW SLAVE STATUS\\G** *************************** 1. row ******************* Slave_IO_State: Waiting for master to send event Master_Host: server1 Master_User: repl Master_Port: 3306 Connect_Retry: 60 Master_Log_File: mysql-bin.000001 Read_Master_Log_Pos: 164 Relay_Log_File: mysql-relay-bin.000001 Relay_Log_Pos: 164 Relay_Master_Log_File: mysql-bin.000001 Slave_IO_Running: Yes Slave_SQL_Running: Yes ...omitted... Seconds_Behind_Master: 0 从输出可以看出I/O线程和SQL线程都已经开始运行,Seconds_Behind_Master的值也不再为NULL(稍后再解释Seconds_Behind_Master的含义)。I/O线程正在等待从主库传递过来的事件,这意味着I/O线程已经读取了主库所有的事件。日志位置发生了变化,表明已经从主库获取和执行了一些事件(你的结果可能会有所不同)。如果在主库上做一些数据更新,就会看到备库的文件或者日志位置都可能会增加。备库中的数据同样会随之更新。\n我们还可以从线程列表中看到复制线程。在主库上可以看到由备库I/O线程向主库发起的连接。\nmysql\u0026gt; ** SHOW PROCESSLIST\\G** *************************** 1. row *************************** Id: 55 User: repl Host: replica1.webcluster_1:54813 db: NULL Command: Binlog Dump Time: 610237 State: Has sent all binlog to slave; waiting for binlog to be updated Info: NULL 同样,在备库也可以看到两个线程,一个是I/O线程,一个是SQL线程:\nmysql\u0026gt; ** SHOW PROCESSLIST\\G** *************************** 1. row *************************** Id: 1 User: system user Host: db: NULL Command: Connect Time: 611116 State: Waiting for master to send event Info: NULL *************************** 2. row *************************** Id: 2 User: system user Host: db: NULL Command: Connect Time: 33 State: Has read all relay log; waiting for the slave I/O thread to update it Info: NULL 这些简单的输出来自一台已经运行了一段时间的服务器,所以I/O线程在主库和备库上的Time列的值较大。SQL线程在备库已经空闲了33秒。这意味着33秒内没有重放任何事件。\n这些线程总是运行在“system user”账号下,其他列的值则不相同。例如,当SQL线程回放事件时,Info列可能显示正在执行的查询。\n如果只是想实验MySQL的复制,Giuseppe Maxia的MySQL沙箱脚本( http://mysqlsandbox.net)能够帮助你从一个之前下载的安装包中一次性安装。通过如下命令只需要几次按键和大约15秒,就可以运行一个主库和两个备库:\n$ ** ./set_replication.pl /path/to/mysql-tarball.tar.gz** 10.2.4 从另一个服务器开始复制 # 前面的设置都是假定主备库均为刚刚安装好且都是默认的数据,也就是说两台服务器上数据相同,并且知道当前主库的二进制日志。这不是典型的案例。大多数情况下有一个已经运行了一段时间的主库,然后用一台新安装的备库与之同步,此时这台备库还没有数据。\n有几种办法来初始化备库或者从其他服务器克隆数据到备库。包括从主库复制数据、从另外一台备库克隆数据,以及使用最近的一次备份来启动备库,需要有三个条件来让主库和备库保持同步:\n在某个时间点的主库的数据快照。 主库当前的二进制日志文件,和获得数据快照时在该二进制日志文件中的偏移量,我们把这两个值称为日志文件坐标(log file coordinates)。通过这两个值可以确定二进制日志的位置。可以通过SHOW MASTER STATUS命令来获取这些值。 从快照时间到现在的二进制日志。 下面是一些从别的服务器克隆备库的方法:\n使用冷备份\n最基本的方法是关闭主库,把数据复制到备库(高效复制文件的方法参考附录C)。重启主库后,会使用一个新的二进制日志文件,我们在备库通过执行CHANGE MASTER TO指向这个文件的起始处。这个方法的缺点很明显:在复制数据时需要关闭主库。\n使用热备份\n如果仅使用了MyISAM表,可以在主库运行时使用mysqlhotcopy或rsync来复制数据,更多细节参阅第15章。\n使用mysqldump\n如果只包含InnoDB表,那么可以使用以下命令来转储主库数据并将其加载到备库,然后设置相应的二进制日志坐标:\n** $ mysqldump --single-transaction --all-databases --master-data=1--host=server1 \\** ** | mysql --host=server2** 选项*\u0026ndash;single-transaction使得转储的数据为事务开始前的数据。如果使用的是非事务型表,可以使用\u0026ndash;lock-all-tables*选项来获得所有表的一致性转储。\n使用快照或备份\n只要知道对应的二进制日志坐标,就可以使用主库的快照或者备份来初始化备库(如果使用备份,需要确保从备份的时间点开始的主库二进制日志都要存在)。只需要把备份或快照恢复到备库,然后使用CHANGE MASTER TO指定二进制日志的坐标。第15章会介绍更多的细节,也可以使用LVM快照、SAN快照、EBS快照——任何快照都可以。\n使用Percona Xtrabackup\nPercona的Xtrabackup是一款开源的热备份工具,多年前我们就介绍过。它能够在备份时不阻塞服务器的操作,因此可以在不影响主库的情况下设置备库。可以通过克隆主库或另一个已存在的备库的方式来建立备库。\n在15章会介绍更多使用Percona Xtrabackup的细节。这里会介绍一些相关的功能。创建一个备份(不管是从主库还是从别的备库),并将其转储到目标机器,然后根据备份获得正确的开始复制的位置。\n如果是从主库获得备份,可以从xtrabackup_binlog_pos_innodb文件中获得复制开始的位置。 如果是从另外的备库获得备份,可以从xtrabackup_slave_info文件中获得复制开始的位置。 另外,在第15章提到的InnoDB热备份和MySQL企业版的备份,也是比较好的初始化备库方式。\n使用另外的备库\n可以使用任何一种提及的克隆或者拷贝技术来从任意一台备库上将数据克隆到另外一台服务器。但是如果使用的是mysqldump,\u0026ndash;master-data选项就会不起作用。\n此外,不能使用SHOW MASTER STATUS来获得主库的二进制日志坐标,而是在获取快照时使用SHOW SLAVE STATUS来获取备库在主库上的执行位置。\n使用另外的备库进行数据克隆最大的缺点是,如果这台备库的数据已经和主库不同步,克隆得到的就是脏数据。\n不要使用LOAD DATA FROM MASTER或者LOAD TABLE FROM MASTER!这些命令过时、缓慢,并且非常危险,并且只适用于MyISAM存储引擎。\n不管选择哪种技术,都要能熟练运用,要记录详细的文档或编写脚本。因为可能不止一次需要做这样的事情。甚至当错误发生时,也需要能够处理。\n10.2.5 推荐的复制配置 # 有许多参数来控制复制,其中一些会对数据安全和性能产生影响。稍后我们会解释何种规则在何时会失效。本小节推荐的一种“安全”的配置,可以最小化问题发生的概率。\n在主库上二进制日志最重要的选项是sync_binlog:\nsync_binlog=1 如果开启该选项,MySQL每次在提交事务前会将二进制日志同步到磁盘上,保证在服务器崩溃时不会丢失事件。如果禁止该选项,服务器会少做一些工作,但二进制日志文件可能在服务器崩溃时损坏或丢失信息。在一个不需要作为主库的备库上,该选项带来了不必要的开销。它只适用于二进制日志,而非中继日志。\n如果无法容忍服务器崩溃导致表损坏,推荐使用InnoDB。在表损坏无关紧要时, MyISAM是可以接受的,但在一次备库服务器崩溃重启后,MyISAM表可能已经处于不一致状态。一种可能是语句没有完全应用到一个或多个表上,那么即使修复了表,数据也可能是不一致的。\n如果使用InnoDB,我们强烈推荐设置如下选项:\ninnodb_flush_logs_at_trx_commit=1 # Flush every log write innodb_support_xa=1 # MySQL 5.0 and newer only innodb_safe_binlog # MySQL 4.1 only, roughly equivalent to # innodb_support_xa 这些是MySQL 5.0及最新版本中的默认配置,我们推荐明确指定二进制日志的名字,以保证二进制日志名在所有服务器上是一致的,避免因为服务器名的变化导致的日志文件名变化。你可能认为以服务器名来命名二进制日志无关紧要,但经验表明,当在服务器间转移文件、克隆新的备库、转储备份或者其他一些你想象不到的场景下,可能会导致很多问题。为了避免这些问题,需要给log_bin选项指定一个参数。可以随意地给一个绝对路径,但必须明确地指定基本的命名(正如本章之前讨论的)。\nlog_bin=/var/lib/mysql/mysql-bin # Good; specifies a path and base namelog_bin=/var/lib/mysql/mysql-bin # Good; specifies a path and base name#log_bin # Bad; base name will be server's hostname #log_bin # Bad; base name will be server's hostname 在备库上,我们同样推荐开启如下配置选项,为中继日志指定绝对路径:\nrelay_log=/path/to/logs/relay-bin skip_slave_start read_only 通过设置relay_log可以避免中继日志文件基于机器名来命名,防止之前提到的可能在主库发生的问题。指定绝对路径可以避免多个MySQL版本中存在的Bug,这些Bug可能会导致中继日志在一个意料外的位置创建。skip_slave_start选项能够阻止备库在崩溃后自动启动复制。这可以给你一些机会来修复可能发生的问题。如果备库在崩溃后自动启动并且处于不一致的状态,就可能会导致更多的损坏,最后将不得不把所有数据丢弃,并重新开始配置备库。\nread_only选项可以阻止大部分用户更改非临时表,除了复制SQL线程和其他拥有超级权限的用户之外,这也是要尽量避免给正常账号授予超级权限的原因之一。\n即使开启了所有我们建议的选项,备库仍然可能在崩溃后被中断,因为master.info和中继日志文件都不是崩溃安全的。默认情况下甚至不会刷新到磁盘,直到MySQL 5.5版本才有选项来控制这种行为。如果正在使用MySQL 5.5并且不介意额外的fsync()导致的性能开销,最好设置以下选项:\nsync_master_info = 1 sync_relay_log = 1 sync_relay_log_info = 1 如果备库与主库的延迟很大,备库的I/O线程可能会写很多中继日志文件,SQL线程在重放完一个中继日志中的事件后会尽快将其删除(通过relay_log_purge选项来控制)。但如果延迟非常严重,I/O线程可能会把整个磁盘撑满。解决办法是配置relay_log_space_limit变量。如果所有中继日志的大小之和超过这个值,I/O线程会停止,等待SQL线程释放磁盘空间。\n尽管听起来很美好,但有一个隐藏的问题。如果备库没有从主库上获取所有的中继日志,这些日志可能在主库崩溃时丢失。早先这个选项存在一些Bug,使用率也不高,所以用到这个选项遇到Bug的风险会更高。除非磁盘空间真的非常紧张,否则最好让中继日志使用其需要的磁盘空间,这也是为什么我们没有将relay_log_space_limit列入推荐的配置选项的原因。\n10.3 复制的原理 # 我们已经介绍了复制的一些基本概念,接下来要更深入地了解复制。让我们看看复制究竟是如何工作的,有哪些优点和弱点,最后介绍一些更高级的复制配置选项。\n10.3.1 基于语句的复制 # 在MySQL 5.0及之前的版本中只支持基于语句的复制(也称为逻辑复制),这在数据库领域是很少见的。基于语句的复制模式下,主库会记录那些造成数据更改的查询,当备库读取并重放这些事件时,实际上只是把主库上执行过的SQL再执行一遍。这种方式既有好处,也有缺点。\n最明显的好处是实现相当简单。理论上讲,简单地记录和执行这些语句,能够让主备保持同步。另一个好处是二进制日志里的事件更加紧凑,所以相对而言,基于语句的模式不会使用太多带宽。一条更新好几兆数据的语句在二进制日志里可能只占几十个字节。另外mysqlbinlog工具(本章多处会提到)是使用基于语句的日志的最佳工具。\n但事实上基于语句的方式可能并不如其看起来那么便利。因为主库上的数据更新除了执行的语句外,可能还依赖于其他因素。例如,同一条SQL在主库和备库上执行的时间可能稍微或很不相同,因此在传输的二进制日志中,除了查询语句,还包括了一些元数据信息,如当前的时间戳。即便如此,还存在着一些无法被正确复制的SQL。例如,使用CURRENT_USER()函数的语句。存储过程和触发器在使用基于语句的复制模式时也可能存在问题。\n另外一个问题是更新必须是串行的。这需要更多的锁——有时候要特别关注这一点。另外不是所有的存储引擎都支持这种复制模式。尽管这些存储引擎是包括在MySQL 5.5及之前版本中发行的。\n可以在MySQL手册与复制相关的章节中找到基于语句的复制存在的限制的完整列表。\n10.3.2 基于行的复制 # MySQL 5.1开始支持基于行的复制,这种方式会将实际数据记录在二进制日志中,跟其他数据库的实现比较相像。它有其自身的一些优点和缺点。最大的好处是可以正确地复制每一行。一些语句可以被更加有效地复制。\n基于行的复制没有向后兼容性,和MySQL 5.1一起发布的mysqlbinlog工具可以读取基于行的复制的事件格式(它对人是不可读的,但MySQL可以解释),但是早期版本的mysqlbinlog无法识别这类事件,在遇到错误时会退出。\n由于无须重放更新主库数据的查询,使用基于行的复制模式能够更高效地复制数据。重放一些查询的代价可能会很高。例如,下面有一个查询将数据从一个大表中汇总到小表:\nmysql\u0026gt; ** INSERT INTO summary_table(col1, col2, sum_col3)** -\u0026gt; ** SELECT col1, col2, sum(col3)** -\u0026gt; ** FROM enormous_table** -\u0026gt; ** GROUP BY col1, col2;** 想象一下,如果表enormous_table的列col1和col2有三种组合,这个查询可能在源表上扫描多次,但最终只在目标表上产生三行数据。但使用基于行的复制方式,在备库上开销会小很多。这种情况下,基于行的复制模式更加高效。\n但在另一方面,下面这条语句使用基于语句的复制方式代价会小很多:\nmysql\u0026gt; ** UPDATE enormous_table SET col1 = 0;** 由于这条语句做了全表更新,使用基于行的复制开销会很大,因为每一行的数据都会被记录到二进制日志中,这使得二进制日志事件非常庞大。并且会给主库上记录日志和复制增加额外的负载,更慢的日志记录则会降低并发度。\n由于没有哪种模式对所有情况都是完美的,MySQL能够在这两种复制模式间动态切换。默认情况下使用的是基于语句的复制方式,但如果发现语句无法被正确地复制,就切换到基于行的复制模式。还可以根据需要来设置会话级别的变量binlog_format,控制二进制日志格式。\n对于基于行的复制模式,很难进行时间点恢复,但这并非不可能。稍后讲到的日志服务器对此会有帮助。\n10.3.3 基于行或基于语句:哪种更优 # 我们已经讨论了这两种复制模式的优点和缺点,那么在实际应用中哪种方式更优呢?\n理论上基于行的复制模式整体上更优,并且在实际应用中也适用于大多数场景。但这种方式太新了以至于没有将一些特殊的功能加入到其中来满足数据库管理员的操作需求。因此一些人直到现在还没有开始使用。以下详细地阐述两种方式的优点和缺点,以帮助你决定哪种方式更合适。\n基于语句的复制模式的优点\n当主备的模式不同时,逻辑复制能够在多种情况下工作。例如,在主备上的表的定义不同但数据类型相兼容、列的顺序不同等情况。这样就很容易先在备库上修改schema,然后将其提升为主库,减少停机时间。基于语句的复制方式一般允许更灵活的操作。\n基于语句的方式执行复制的过程基本上就是执行SQL语句。这意味着所有在服务器上发生的变更都以一种容易理解的方式运行。这样当出现问题时可以很好地去定位。\n基于语句的复制模式的缺点\n很多情况下通过基于语句的模式无法正确复制,几乎每一个安装的备库都会至少碰到一次。事实上对于存储过程,触发器以及其他的一些语句的复制在5.0和5.1的一系列版本中存在大量的Bug。这些语句的复制的方式已经被修改了很多次,以使其更好地工作。简单地说:如果正在使用触发器或者存储过程,就不要使用基于语句的复制模式,除非能够清楚地确定不会碰到复制问题。\n基于行的复制模式的优点\n几乎没有基于行的复制模式无法处理的场景。对于所有的SQL构造、触发器、存储过程等都能正确执行。只是当你试图做一些诸如在备库修改表的schema这样的事情时才可能导致复制失败。\n这种方式同样可能减少锁的使用,因为它并不要求这种强串行化是可重复的。\n基于行的复制模式会记录数据变更,因此在二进制日志中记录的都是实际上在主库上发生了变化的数据。你不需要查看一条语句去猜测它到底修改了哪些数据。在某种程度上,该模式能够更加清楚地知道服务器上发生了哪些更改,并且有一个更好的数据变更记录。另外在一些情况下基于行的二进制日志还会记录发生改变之前的数据,因此这可能有利于某些数据恢复。\n在很多情况下,由于无须像基于语句的复制那样需要为查询建立执行计划并执行查询,因此基于行的复制占用更少的CPU。\n最后,在某些情况下,基于行的复制能够帮助更快地找到并解决数据不一致的情况。举个例子,如果是使用基于语句的复制模式,在备库更新一个不存在的记录时不会失败,但在基于行的复制模式下则会报错并停止复制。\n基于行的复制模式的缺点\n由于语句并没有在日志里记录,因此无法判断执行了哪些SQL,除了需要知道行的变化外,这在很多情况下也很重要(这可能在未来的MySQL版本中被修复)。\n使用一种完全不同的方式在备库进行数据变更——而不是执行SQL。事实上,执行基于行的变化的过程就像一个黑盒子,你无法知道服务器正在做什么。并且没有很好的文档和解释。因此当出现问题时,可能很难找到问题所在。例如,若备库使用一个效率低下的方式去寻找行记录并更新,你无法观察到这一点。\n如果有多层的复制服务器,并且所有的都被配置成基于行的复制模式,当会话级别的变量@@binlog_format被设置成STATEMENT时,所执行的语句在源服务器上被记录为基于语句的模式,但第一层的备库可能将其记录成行模式,并传递给其他层的备库。也就是说你期望的基于语句的日志在复制拓扑中将会被切换到基于行的模式。基于行的日志无法处理诸如在备库修改表的schema这样的情况,而基于语句的日志可以。\n在某些情况下,例如找不到要修改的行时,基于行的复制可能会导致复制停止,而基于语句的复制则不会。这也可以认为是基于行的复制的一个优点。该行为可以通过slave_exec_mode来进行配置。\n这些缺点正在被慢慢解决,但直到写作本书时,它们在大多数生产环境中依然存在。\n10.3.4 复制文件 # 让我们来看看复制会使用到的一些文件。前面已经介绍了二进制日志文件和中继日志文件,其实还有其他的文件会被用到。不同版本的MySQL默认情况下可能将这些文件放到不同的目录里,大多取决具体的配置选项。可能在data目录或者包含服务器.pid文件的目录下(对于类UNIX系统可能是*/var/run/mysqld*)。它们的详细介绍如下。\nmysql-bin.index\n当在服务器上开启二进制日志时,同时会生成一个和二进制日志同名的但以.index作为后缀的文件,该文件用于记录磁盘上的二进制日志文件。这里的“index”并不是指表的索引,而是说这个文件的每一行包含了二进制文件的文件名。\n你可能认为这个文件是多余的,可以被删除(毕竟MySQL可以在磁盘上找到它需要的文件)。事实上并非如此,MySQL依赖于这个文件,除非在这个文件里有记录,否则MySQL识别不了二进制日志文件。\nmysql-relay-bin-index\n这个文件是中继日志的索引文件,和mysql-bin.index的作用类似。\nmaster.info\n这个文件用于保存备库连接到主库所需要的信息,格式为纯文本(每行一个值),不同的MySQL版本,其记录的信息也可能不同。此文件不能删除,否则备库在重启后无法连接到主库。这个文件以文本的方式记录了复制用户的密码,所以要注意此文件的权限控制。\nrelay-log.info\n这个文件包含了当前备库复制的二进制日志和中继日志坐标(例如,备库复制在主库上的位置),同样也不要删除这个文件,否则在备库重启后将无法获知从哪个位置开始复制,可能会导致重放已经执行过的语句。\n使用这些文件来记录MySQL复制和日志状态是一种非常粗糙的方式。更不幸的是,它们不是同步写的。如果服务器断电并且文件数据没有被刷新到磁盘,在重启服务器后,文件中记录的数据可能是错误的。正如之前提到的,这些问题在MySQL 5.5里做了改进。\n以*.index作为后缀的文件也与设置expire_logs_days存在交互,该参数定义了MySQL清理过期日志的方式,如果文件mysql-bin.index在磁盘上不存在,在某些MySQL版本自动清理就会不起作用,甚至执行PURGE MASTER LOGS语句也没有用。这个问题的解决方法通常是使用MySQL服务器管理二进制日志,这样就不会产生误解(这意味着不应该使用rm*来自己清理日志)\n最好能显式地执行一些日志清理策略,比如设置expire_logs_days参数或者其他方式,否则MySQL的二进制日志可能会将磁盘撑满。当做这些事情时,还需要考虑到备份策略。\n10.3.5 发送复制事件到其他备库 # log_slave_updates选项可以让备库变成其他服务器的主库。在设置该选项后,MySQL会将其执行过的事件记录到它自己的二进制日志中。这样它的备库就可以从其日志中检索并执行事件。图10-2阐述了这一过程。\n图10-2:将复制事件传递到更多的备库\n在这种场景下,主库将数据更新事件写入二进制日志,第一个备库提取并执行这个事件。这时候一个事件的生命周期应该已经结束了,但由于设置了log_slave_updates,备库会将这个事件写到它自己的二进制日志中。这样第二个备库就可以将事件提取到它的中继日志中并执行。这意味着作为源服务器的主库可以将其数据变化传递给没有与其直接相连的备库上。默认情况下这个选项是被打开的,这样在连接到备库时就不需要重启服务器。\n当第一个备库将从主库获得的事件写入到其二进制日志中时,这个事件在备库二进制日志中的位置与其在主库二进制日志中的位置几乎肯定是不相同的,可能在不同的日志文件或文件内不同的位置。这意味着你不能假定所有拥有同一逻辑复制点的服务器拥有相同的日志坐标。稍后我们会提到,这种情况会使某些任务更加复杂,例如,修改一个备库的主库或将备库提升为主库。\n除非你已经注意到要给每个服务器分配一个唯一的服务器ID,否则按照这种方式配置备库会导致一些奇怪的错误,甚至还会导致复制停止。一个更常见的问题是:为什么要指定服务器ID,难道MySQL在不知道复制命令来源的情况下不能执行吗?为什么MySQL要在意服务器ID是全局唯一的。问题的答案在于MySQL在复制过程中如何防止无限循环。当复制SQL线程读中继日志时,会丢弃事件中记录的服务器ID和该服务器本身ID相同的事件,从而打破了复制过程中的无限循环。在某些复制拓扑结构下打破无限循环非常重要,例如主-主复制结构(5)。\n如果在设置复制的时候碰到问题,服务器ID应该是需要检查的因素之一。当然只检查@@server_id是不够的,它有一个默认值,除非在my.cnf文件或通过SET命令明确指定它的值,复制才会工作。如果使用SET命令,确保同时也更新了配置文件,否则SET命令的设定可能在服务器重启后丢失。\n10.3.6 复制过滤器 # 复制过滤选项允许你仅复制服务器上一部分数据,不过这可能没有想象中那么好用。有两种复制过滤方式:在主库上过滤记录到二进制日志中的事件,以及在备库上过滤记录到中继日志的事件。图10-3显示了这两种类型。\n图10-3:复制过滤选项\n使用选项binlog_do_db和binlog_ignore_db来控制过滤,稍后我们会解释为什么通常不需要开启它们,除非你乐于向老板解释为什么数据会永久丢失并且无法恢复。\n在备库上,可以通过设置replicate_*选项,在从中继日志中读取事件时进行过滤。你可以复制或忽略一个或多个数据库,把一个数据库重写到另外一个数据库,或使用类似LIKE的模式复制或忽略数据库表。\n要理解这些选项,最重要是弄清楚*_do_db和*_ignore_db在主库和备库上的意义,它们可能不会按照你所设想的那样工作。你可能会认为它会根据目标数据库名过滤,但实际上过滤的是当前的默认数据库(6)。也就是说,如果在主库上执行如下语句:\nmysql\u0026gt; ** USE test;** mysql\u0026gt; ** DELETE FROM sakila.film;** *_do_db和*_ignore_db都会在数据库test上过滤DELETE语句,而不是在sakila上。这通常不是想要的结果,可能会导致执行或忽略错误的语句。*_do_db和*_ignore_db有一些作用,但非常有限。必须要很小心地去使用这些参数,否则很容易造成主备不同步或复制出错。\nbinlog_do_db和binlog_ignore_db不仅可能会破坏复制,还可能会导致从某个时间点的备份进行数据恢复时失败。在大多数情况下都不应该使用这些参数。本章稍后部分我们展示了一些使用blackhole表进行复制过滤的方法。\n总地来说,复制过滤随时可能会发生问题。举个例子,假如要阻止赋权限操作传递给备库,这种需求是很普遍的。(提醒一下,这样做可能是错误的,有别的更好的方式来达成真正的目的)。过滤系统表的复制当然能够阻止GRANT语句的复制,但同样也会阻止事件和定时任务的复制。正是这些不可预知的后果,使用复制过滤要非常慎重。更好的办法是阻止一些特殊的语句被复制,通常是设置SQL_LOG_BIN=0,虽然这种方法也有它的缺点。总地来说,除非万不得已,不要使用复制过滤,因为它很容易中断复制并导致问题,在需要灾难恢复时也会带来极大的不方便。\n过滤选项在MySQL文档里介绍得很详细,因此本书不再重复更多的细节。\n10.4 复制拓扑 # 可以在任意个主库和备库之间建立复制,只有一个限制:每一个备库只能有一个主库。有很多复杂的拓扑结构,但即使是最简单的也可能会非常灵活。一种拓扑可以有多种用途。关于使用复制的不同方式可以很轻易地写一本书。\n我们已经讨论了如何为主库设置一个备库,本节我们讨论其他比较普遍的拓扑结构以及它们的优缺点。记住下面的基本原则:\n一个MySQL备库实例只能有一个主库。 每个备库必须有一个唯一的服务器ID。 一个主库可以有多个备库(或者相应的,一个备库可以有多个兄弟备库)。 如果打开了log_slave_updates选项,一个备库可以把其主库上的数据变化传播到其他备库。 10.4.1 一主库多备库 # 除了我们已经提过的两台服务器的主备结构外,这是最简单的拓扑结构。事实上一主多备的结构和基本配置差不多简单,因为备库之间根本没有交互(7),它们仅仅是连接到同一个主库上。图10-4显示了这种结构。\n在有少量写和大量读时,这种配置是非常有用的。可以把读分摊到多个备库上,直到备库给主库造成了太大的负担,或者主备之间的带宽成为瓶颈为止。你可以按照之前介绍的方法一次性设置多个备库,或者根据需要增加备库。\n图10-4:一主多备结构\n尽管这是非常简单的拓扑结构,但它非常灵活,能满足多种需求。下面是它的一些用途:\n为不同的角色使用不同的备库(例如添加不同的索引或使用不同的存储引擎)。 把一台备库当作待用的主库,除了复制没有其他数据传输。 将一台备库放到远程数据中心,用作灾难恢复。 延迟一个或多个备库,以备灾难恢复。 使用其中一个备库,作为备份、培训、开发或者测试使用服务器。 这种结构流行的原因是它避免了很多其他拓扑结构的复杂性。例如:可以方便地比较不同备库重放的事件在主库二进制日志中的位置。换句话说,如果在同一个逻辑点停止所有备库的复制,它们正在读取的是主库上同一个日志文件的相同物理位置。这是个很好的特性,可以减轻管理员许多工作,例如把备库提升为主库。\n这种特性只存在于兄弟备库之间。在没有直接的主备或者兄弟关系的服务器上去比较日志文件的位置要复杂很多。之后我们会提到的许多拓扑结构,例如树形复制或分布式主库,很难计算出复制的事件的逻辑顺序。\n10.4.2 主动-主动模式下的主-主复制 # 主-主复制(也叫双主复制或双向复制)包含两台服务器,每一个都被配置成对方的主库和备库,换句话说,它们是一对主库。图10-5显示了该结构。\n图10-5:主-主复制\n主动-主动模式下主-主复制有一些应用场景,但通常用于特殊的目的。一个可能的应用场景是两个处于不同地理位置的办公室,并且都需要一份可写的数据拷贝。\n这种配置最大的问题是如何解决冲突,两个可写的互主服务器导致的问题非常多。这通常发生在两台服务器同时修改一行记录,或同时在两台服务器上向一个包含AUTO_INCREMENT列的表里插入数据(8)。\nMySQL不支持多主库复制\n多主库复制(multisource replication)特指一个备库有多个主库。不管之前你知道什么,但MySQL(和其他数据库产品不一样)现在不支持如图10-6所示的结构,本章稍后我们会向你介绍如何模仿多主库复制。\n图10-6:MySQL不支持多主库复制\nMySQL 5.0增加了一些特性,使得这种配置稍微安全了点,就是设置auto_increment_increment和auto_increment_offset。通过这两个选项可以让MySQL自动为INSERT语句选择不互相冲突的值。然而允许向两台主库上写入仍然很危险。在两台机器上根据不同的顺序更新,可能会导致数据不同步。例如,一个只有一列的表,只有一行值为1的记录,假设同时执行下面两条语句:\n在第一台主库上:\nmysql\u0026gt; ** UPDATE tbl SET col=col + 1;** 在第二台主库上:\nmysql\u0026gt; ** UPDATE tbl SET col=col + 2;** 那么结果呢?一台服务器上值为4,另一台的值为3,并且没有报告任何复制错误。\n数据不同步还仅仅是开始。当正常的复制发生错误停止了,但应用仍在同时向两台服务器写入数据,这时候会发生什么呢?你不能简单地把数据从一台服务器复制到另外一台,因为这两台机器上需要复制的数据都可能发生了变化。解决这个问题将会非常困难。\n如果足够仔细地配置这种架构,例如很好地划分数据和权限,并且你很清楚自己在做什么,可以避免一些问题(9)。然而这通常很难做好,并且有更好的办法来实现你所需要的。\n总地来说,允许向两个服务器上写入所带来的麻烦远远大于其带来的好处,但下一节描述的主动-被动模式则会非常有用。\n10.4.3 主动-被动模式下的主-主复制 # 这是前面描述的主-主结构的变体,它能够避免我们之前讨论的问题。这也是构建容错性和高可用性系统的非常强大的方式,主要区别在于其中的一台服务器是只读的被动服务器,如图10-7所示。\n图10-7:主动-被动模式下的主-主复制\n这种方式使得反复切换主动和被动服务器非常方便,因为服务器的配置是对称的。这使得故障转移和故障恢复很容易。它也可以让你在不关闭服务器的情况下执行维护、优化表、升级操作系统(或者应用程序、硬件等)或其他任务。\n例如,执行ALTER TABLE操作可能会锁住整个表,阻塞对表的读和写,这可能会花费很长时间并导致服务中断。然而在主-主配置下,可以先停止主动服务器上的备库复制线程(这样就不会在被动服务器上执行任何更新),然后在被动服务器上执行ALTER操作,交换角色,最后在先前的主动服务器上(10)启动复制线程。这个服务器将会读取中继日志并执行相同的ALTER语句。这可能花费很长时间,但不要紧,因为该服务器没有为任何活跃查询提供服务。\n主动-被动模式的主-主结构能够帮助回避许多MySQL的问题和限制,此外还有一些工具可以完成这种类型的操作。\n让我们看看如何配置主-主服务器对,在两台服务器上执行如下设置后,会使其拥有对称的设置:\n确保两台服务器上有相同的数据。 启用二进制日志,选择唯一的服务器ID,并创建复制账号。 启用备库更新的日志记录,后面将会看到,这是故障转移和故障恢复的关键。 把被动服务器配置成只读,防止可能与主动服务器上的更新产生冲突,这一点是可选的。 启动每个服务器的MySQL实例。 将每个主库设置为对方的备库,使用新创建的二进制日志开始工作。 让我们看看主动服务器上更新时会发生什么事情。更新被记录到二进制日志中,通过复制传递给被动服务器的中继日志中。被动服务器执行查询并将其记录到自己的二进制日志中(因为开启了log_slave_updates选项)。由于事件的服务器ID与主动服务器的相同,因此主动服务器将忽略这些事件。在后面的“修改主库”可了解更多的角色切换相关内容。\n设置主动-被动的主-主拓扑结构在某种意义上类似于创建一个热备份,但是可以使用这个“备份”来提高性能,例如,用它来执行读操作、备份、“离线”维护以及升级等。真正的热备份做不了这些事情。然而,你不会获得比单台服务器更好的写性能(稍后会提到)。\n当我们讨论使用复制的场景和用途时,还会提到这种复制方式。它是一种非常常见并且重要的拓扑结构。\n10.4.4 拥有备库的主-主结构 # 另外一种相关的配置是为每个主库增加一个备库,如图10-8所示。\n图10-8:拥有备库的主-主结构\n这种配置的优点是增加了冗余,对于不同地理位置的复制拓扑,能够消除站点单点失效的问题。你也可以像平常一样,将读查询分配到备库上。\n如果在本地为了故障转移使用主-主结构,这种配置同样有用。当主库失效时,用备库来代替主库还是可行的,虽然这有点复杂。同样也可以把备库指向一个不同的主库,但需要考虑增加的复杂度。\n10.4.5 环形复制 # 如图10-9所示,双主结构实际上是环形结构的一种特例(11)。环形结构可以有三个或更多的主库。每个服务器都是在它之前的服务器的备库,是在它之后的服务器的主库。这种结构也称为环形复制(circular replication)。\n环形结构没有双主结构的一些优点,例如对称配置和简单的故障转移,并且完全依赖于环上的每一个可用节点,这大大增加了整个系统失效的几率。如果从环中移除一个节点,这个节点发起的事件就会陷入无限循环:它们将永远绕着服务器链循环。因为唯一可以根据服务器ID将其过滤的服务器是创建这个事件的服务器。总地来说,环形结构非常脆弱,应该尽量避免。\n图10-9:环形复制拓扑\n可以通过为每个节点增加备库的方式来减少环形复制的风险,如图10-10所示。但这仅仅防范了服务器失效的危险,断电或者其他一些影响到网络连接的问题都可能破坏整个环。\n图10-10:拥有备库的环形结构\n10.4.6 主库、分发主库以及备库 # 我们之前提到当备库足够多时,会对主库造成很大的负载。每个备库会在主库上创建一个线程,并执行binlog dump命令。该命令会读取二进制日志文件中的数据并将其发送给备库。每个备库都会重复这样的工作,它们不会共享binlog dump的资源。\n如果有很多备库,并且有大的事件时,例如一次很大的LOAD DATA INFILE操作,主库上的负载会显著上升,甚至可能由于备库同时请求同样的事件而耗尽内存并崩溃。另一方面,如果备库请求的数据不在文件系统的缓存中,可能会导致大量的磁盘检索,这同样会影响主库的性能并增加锁的竞争。\n因此,如果需要多个备库,一个好办法是从主库移除负载并使用分发主库。分发主库事实上也是一个备库,它的唯一目的就是提取和提供主库的二进制日志。多个备库连接到分发主库,这使原来的主库摆脱了负担。为了避免在分发主库上做实际的查询,可以将它的表修改为blackhole存储引擎,如图10-11所示。\n图10-11:一个主库、一个分发主库和多个备库\n很难说当备库数据达到多少时需要一个分发主库。按照通用准则,如果主库接近满负载,不应该为其建立10个以上的备库。如果有少量的写操作,或者只复制其中一部分表,主库就可以提供更多的复制。另外,也不一定只使用一个分发主库。如果需要的话,可以使用多个分发主库向大量的备库进行复制,或者使用金字塔状的分发主库。在某些情况下,可以通过设置slave_compressed_protocol来节约一些主库带宽。这对跨数据中心复制很有好处。\n还可以通过分发主库实现其他目的,例如,对二进制日志事件执行过滤和重写规则。这比在每个备库上重复进行日志记录、重写和过滤要高效得多。\n如果在分发主库上使用blackhole表,可以支持更多的备库。虽然会在分发主库执行查询,但其代价非常小,因为blackhole表中没有任何数据。blockhole表的缺点是其存在Bug,例如在某些情况下会忘记将自增ID写入到二进制日志中。所以要小心使用blackhole表(12)。\n一个比较常见的问题是如何确保分发服务器上的每个表都是blackhole存储引擎。如果有人在主库创建了一个表并指定了不同的存储引擎呢?确实,不管什么时候,在备库上使用不同的存储引擎总会导致同样的问题。常见的解决方案是设置服务器的storage_engine选项:\nstorage_engine=blackhole 这只会影响那些没有指定存储引擎的CREATE TABLE的语句。如果有一个无法控制的应用,这种拓扑结构可能会非常脆弱。可以通过skip_innodb选项禁止InnoDB,将表退化为MyISAM。但你无法禁止MyISAM或者Memory引擎。\n使用分发主库另外一个主要的缺点是无法使用一个备库来代替主库。因为由于分发主库的存在,导致各个备库与原始主库的二进制日志坐标已经不相同(13)。\n10.4.7 树或金字塔形 # 如果正在将主库复制到大量的备库中。不管是把数据分发到不同的地方,还是提供更高的读性能,使用金字塔结构都能够更好地管理,如图10-12所示。\n这种设计的好处是减轻了主库的负担,就像前一节提到的分发主库一样。它的缺点是中间层出现的任何错误都会影响到多个服务器。如果每个备库和主库直接相连就不会存在这样的问题。同样,中间层次越多,处理故障会更困难、更复杂。\n图10-12:金字塔形复制拓扑\n10.4.8 定制的复制方案 # MySQL的复制非常灵活,可以根据需要定制解决方案。典型的定制方案包括组合过滤、分发和向不同的存储引擎复制。也可以使用“黑客手段”,例如,从一个使用blackhole存储引擎的服务器上复制或复制到这样的服务器上(本章已讨论过)。可以根据需要任意设计。这其中最大的限制是合理地监控和管理,以及所拥有资源的约束(网络带宽、CPU能力等)。\n选择性复制 # 为了利用访问局部性原理(locality of reference),并将需要读的工作集驻留在内存中,可以复制少量数据到备库中。如果每个备库只拥有主库的一部分数据,并且将读分配给备库,就可以更好地利用备库的内存。并且每个备库也只有主库一部分的写入负载,这样主库的能力更强并能保证备库延迟。\n这个方案有点类似下一章我们会讨论到的水平数据划分,但它的优势在于主库包含了所有的数据集,这意味着无须为了一条写入查询去访问多个服务器。如果读操作无法在备库上找到数据,还可以通过主库来查询。即使不能从备库上读取所有数据,也可以移除大量的主库读负担。\n最简单的方法是在主库上将数据划分到不同的数据库里。然后将每个数据库复制到不同的备库上。例如,若需要将公司的每一个部门的数据复制到不同的备库,可以创建名为sales、marketing、procurement等的数据库,每个备库通过选项replicate_wild_do_table选项来限制给定数据库的数据。下面是sales数据库的配置:\nreplicate_wild_do_table = sales.% 也可以通过一台分发主库进行分发。举个例子,如果想通过一个很慢或者非常昂贵的网络,从一台负载很高的数据库上复制一部分数据,就可以使用一个包含blackhole表和过滤规则的本地分发主库,分发主库可以通过复制过滤移除不需要的日志。这可以避免在主库上进行不安全的日志选项设定,并且无须传输所有的数据到远程备库。\n分离功能 # 许多应用都混合了在线事务处理(OLTP)和在线数据分析(OLAP)的查询。OLTP查询比较短并且是事务型的,OLAP查询则通常很大,也很慢,并且不要求绝对最新的数据。这两种查询给服务器带来的负担完全不同,因此它们需要不同的配置,甚至可能使用不同的存储引擎或者硬件。\n一个常见的办法是将OLTP服务器的数据复制到专门为OLAP工作负载准备的备库上。这些备库可以有不同的硬件、配置、索引或者不同的存储引擎。如果决定在备库上执行OLAP查询,就可能需要忍受更大的复制延迟或降低备库的服务质量。这意味着在一个非专用的备库上执行一些任务时,可能会导致不可接受的性能,例如执行一条长时间运行的查询。\n无须做一些特殊的配置,除了需要选择忽略主库上的一些数据,前提是能获得明显的提升。即使通过复制过滤器过滤掉一小部分的数据也会减少I/O和缓存活动。\n数据归档 # 可以在备库上实现数据归档,也就是说可以在备库上保留主库上删除过的数据,在主库上通过delete语句删除数据是确保delete语句不传递到备库就可以实现。有两种通常的办法:一种是在主库上选择性地禁止二进制日志,另一种是在备库上使用replicate_ignore_db规则(是的,两种方法都很危险)。\n第一种方法需要先将SQL_LOG_BIN设置为0,然后再进行数据清理。这种方法的好处是不需要在备库进行任何配置,由于SQL语句根本没有记录到二进制日志中,效率会稍微有所提升。最大缺点也正因为没有将在主库的修改记录下来,因此无法使用二进制日志来进行审计或者做按时间点的数据恢复。另外还需要SUPER权限。\n第二种方法是在清理数据之前对主库上特定的数据库使用USE语句。例如,可以创建一个名为purge的数据库,然后在备库的my.cnf文件里设置replicate_ignore_db=purge并重启服务器。备库将会忽略使用了USE语句指定的数据库。这种方法没有第一种方法的缺点,但有另一个小小的缺点:备库需要去读取它不需要的事件。另外,也可能有人在purge数据库上执行非清理查询,从而导致备库无法重放该事件。\nPercona Toolkit中的pt-archiver支持以上两种方式。\n第三种办法是利用binlog_ignore_db来过滤复制事件。但正如之前提到的,这是一种很危险的操作。\n将备库用作全文检索 # 许多应用要求合并事务和全文检索。然而在写作本书时,仅有MyISAM支持全文检索,但是MyISAM不支持事务(在MySQL 5.6有一个实验室预览版本实现了InnoDB的全文检索,但尚未GA)。一个普遍的做法是配置一台备库,将某些表设置为MyISAM存储引擎,然后创建全文索引并执行全文检索查询。这避免了在主库上同时使用事务型和非事务型存储引擎所带来的复制问题,减轻了主库维护全文索引的负担。\n只读备库 # 许多机构选择将备库设置为只读,以防止在备库进行的无意识修改导致复制中断。可以通过设置read_only选项来实现。它会禁止大部分写操作,除了复制线程和拥有超级权限的用户以及临时表操作。只要不给也不应该给普通用户超级权限,这应该是很完美的方法。\n模拟多主库复制 # 当前MySQL不支持多主库复制(一个备库拥有多个主库)。但是可以通过把一台备库轮流指向多台主库的方式来模拟这种结构。例如,可以先将备库指向主库A,运行片刻,再将其指向主库B并运行片刻,然后再次切换回主库A。这种办法的效果取决于数据以及两台主库导致备库所需完成的工作量。如果主库的负载很低,并且主库之间不会产生更新冲突,就会工作得很好。\n需要做一些额外的工作来为每个主库跟踪二进制日志坐标。可能还需要保证备库的I/O线程在每一次循环提取超过需要的数据,否则可能会因为每次循环反复地提取和抛弃大量数据导致主库的网络流量和开销明显增大。\n还可以使用主-主(或者环形)复制结构以及使用blackhole存储引擎表的备库来进行模拟,如图10-13所示。\n图10-13:使用双主结构和blackhole存储引擎表模拟多主复制\n在这种配置中,两台主库拥有自己的数据,但也包含了对方的表,但是对方的表使用blackhole存储引擎以避免在其中存储实际数据。备库和其中任意一个主库相连都可以。备库不使用blackhole存储引擎,因此其对两个主库而言都是有效的。\n事实上并不一定需要主-主拓扑结构来实现,可以简单地将server1复制到server2,再从server2复制到备库。如果在server2上为从server1上复制的数据使用blackhole存储引擎,就不会包含任何server1的数据,如图10-14所示。\n图10-14:另一种模拟多主复制的方法\n这些配置方法常常会碰到一些常见的问题,例如,更新冲突或者建表时明确指定存储引擎。\n另外一个选择是使用Continuent的Tungsten Replicator,我们会在本章稍后部分讨论。\n创建日志服务器 # 使用MySQL复制的另一种用途就是创建没有数据的日志服务器。它唯一的目的就是更加容易重放并且/或者过滤二进制日志事件。就如本章稍后所述,它对崩溃后重启复制很有帮助。同时对基于时间点的恢复也很有帮助,在第15章我们会讨论。\n假设有一组二进制日志或中继日志——可能从备份或者一台崩溃的服务器上获取——希望能够重放这些日志中的事件,可以通过mysqlbinlog工具从其中提取出事件,但更加方便和高效的方法是配置一个没有任何数据的MySQL实例并使其认为这些二进制日志是它拥有的。如果只是临时需要,可以从 http://mysqlsandbox.net上获得一个MySQL沙箱脚本来创建日志服务器。因为无须执行二进制日志,日志服务器也就不需要任何数据。它的目的仅仅是将数据提供给别的服务器(但复制账户还是需要的)。\n我们来看看该策略是如何工作的(稍后会展示一些相关应用)。假设日志被命名为somelog-bin.000001、somelog-bin.000002,等等,将这些日志放到日志服务器的日志文件夹中,假设为*/var/log/mysql*。然后在启动服务器前编辑my.cnf文件,如下所示:\nlog_bin = /var/log/mysql/somelog-bin log_bin_index = /var/log/mysql/somelog-bin.index 服务器不会自动发现日志文件,因此还需要更新日志的索引文件。下面这个命令可以在类UNIX系统上完成(14)。\n** # /bin/ls -1 /var/log/mysql/somelog-bin.[0-9]* \u0026gt; /var/log/mysql/somelog-bin.index** 确保运行MySQL的账户能够读写日志索引文件。现在可以启动日志服务器并通过SHOW MASTER LOGS命令来确保其找到日志文件。\n为什么使用日志服务器比用mysqlbinlog来实现恢复更好呢?有以下几个原因:\n复制作为应用二进制日志的方法已经被大量的用户所测试,能够证明是可行的。mysqlbinlog并不能确保像复制那样工作,并且可能无法正确生成二进制日志中的数据更新。 复制的速度更快,因为无须将语句从日志导出来并传送给MySQL。 可以很容易观察到复制过程。 能够更方便处理错误。例如,可以跳过执行失败的语句。 更方便过滤复制事件。 有时候mysqlbinlog会因为日志记录格式更改而无法读取二进制日志。 10.5 复制和容量规划 # 写操作通常是复制的瓶颈,并且很难使用复制来扩展写操作。当计划为系统增加复制容量时,需要确保进行了正确的计算,否则很容易犯一些复制相关的错误。\n例如,假设工作负载为20%的写以及80%的读。为了计算简单,假设有以下前提:\n读和写查询包含同样的工作量。 所有的服务器是等同的,每秒能进行1000次查询。 备库和主库有同样的性能特征。 可以把所有的读操作转移到备库。 如果当前有一个服务器能支持每秒1000次查询,那么应该增加多少备库才能处理当前两倍的负载,并将所有的读查询分配给备库?\n看上去应该增加两个备库并将1 600次读操作平分给它们。但是不要忘记,写入负载同样增加到了400次每秒,并且无法在主备服务器之间进行分摊。每个备库每秒必须处理400次写入,这意味着每个备库写入占了40%,只能每秒为600次查询提供服务。因此,需要三台而不是两台备库来处理双倍负载。\n如果负载再增加一倍呢?将有每秒800次写入,这时候主库还能处理,但备库的写入同样也提升到80%,这样就需要16台备库来处理每秒3 200次读查询。并且如果再增加一点负载,主库也会无法承担。\n这远远不是线性扩展,查询数量增加4倍,却需要增加17倍的服务器。这说明当为单台主库增加备库时,将很快达到投入远高于回报的地步。这仅仅是基于上面的假设,还忽略了一些事情,例如,单线程的基于语句的复制常常导致备库容量小于主库。真实的复制配置比我们的理论计算还要更差。\n10.5.1 为什么复制无法扩展写操作 # 糟糕的服务容量比例的根本原因是不能像分发读操作那样把写操作等同地分发到更多服务器上。换句话说,复制只能扩展读操作,无法扩展写操作。\n你可能想知道到底有没有办法使用复制来增加写入能力。答案是否定的,根本不行。对数据进行分区是唯一可以扩展写入的方法,我们在下一章会讲到。\n一些读者可能会想到使用主-主拓扑结构(参阅前面介绍的“主动-主动模式下的主-主复制”)并为两个服务器执行写操作。这种配置比主备结构能支持稍微多一点的写入,因为可以在两台服务器之间共享串行化带来的开销。如果每台服务器上执行50%的写入,那复制的执行量也只有50%需要串行化。理论上讲,这比在一台机器上(主库)对100%的写入并发执行,而在另外一台机器(备库)上对100%的写入做串行化要更优。这可能看起来很吸引人,然而这种配置还比不上单台服务器能支持的写入。一个有50%的写入被串行化的服务器性能比一台全部写入都并行化的服务器性能要低。\n这是这种策略不能扩展写入的原因。它只能在两台服务器间共享串行化写入的缺点。所以“链中最弱的一环”并不是那么弱,它只提供了比主动-被动复制稍微好点的性能,但是增加了很大的风险,通常不能带来任何好处,具体原因见下一节。\n10.5.2 备库什么时候开始延迟 # 一个关于备库比较普遍的问题是如何预测备库会在何时跟不上主库。很难去描述备库使用的复制容量为5%与95%的区别,但是至少能够在接近饱和前预警并估计复制容量。\n首先应该观察复制延迟的尖刺。如果有复制延迟的曲线图,需要注意到图上的一些短暂的延迟骤升,这时候可能负载加大,备库短时间内无法跟上主库。当负载接近耗尽备库的容量时,会发现曲线上的凸起会更高更宽。前面曲线的上升角度不变,但随后当备库在产生延迟后开始追赶主库时,将会产生一个平缓的斜坡。这些突起的出现和增长是一个警告信息,意味着已经接近容量限制。\n为了预测在将来的某个时间点会发生什么,可以人为地制造延迟,然后看多久备库能赶上主库。目的是为了明确地说明曲线上的斜坡的陡度。如果将备库停止一个小时,然后开启并在1小时内追赶上,说明正常情况下只消耗了一半的容量。也就是说,如果中午12:00停止备库复制,在1:00开启,并且在2:00追赶上,备库在一小时内完成了两个小时内所有的变更,说明复制可以在双倍速度下运行。\n最后,如果使用的是Percona Server或者MariaDB,也可以直接获取复制的利用率。打开服务器变量userstat,然后执行如下语句:\nmysql\u0026gt; ** SELECT * FROM INFORMATION_SCHEMA.USER_STATISTICS** -\u0026gt; ** WHERE USER='#mysql_system#'\\G** *************************** 1. row *************************** USER: #mysql_system# OTAL_CONNECTIONS: 1 CONCURRENT_CONNECTIONS: 2 CONNECTED_TIME: 46188 BUSY_TIME: 719 ROWS_FETCHED: 0 ROWS_UPDATED: 1882292 SELECT_COMMANDS: 0 UPDATE_COMMANDS: 580431 OTHER_COMMANDS: 338857 COMMIT_TRANSACTIONS: 1016571 ROLLBACK_TRANSACTIONS: 0 可以将BUSY_TIME和CONNECTED_TIME的一半(因为备库有两个复制线程)做比较,来观察备库线程实际执行命令所花费的时间(15)。在我们的例子里,备库大约使用了其3%的能力,这并不意味着它不会遇到偶然的延迟尖刺——如果主库运行了一个超过10分钟才完成的变更,可能延迟的时间和变更执行的时间是相同的——但这很好地暗示了备库能够很快从一个延迟尖刺中恢复。\n10.5.3 规划冗余容量 # 在构建一个大型应用时,有意让服务器不被充分使用,这应该是一种聪明并且划算的方式,尤其在使用复制的时候。有多余容量的服务器可以更好地处理负载尖峰,也有更多能力处理慢速查询和维护工作(如OPTIMIZE TABLE操作),并且能够更好地跟上复制。\n试图同时向主-主拓扑结构的两个节点写入来减少复制问题通常是不划算的。分配给每台机器的读负载应该低于50%,否则,如果某台服务器失效,就没有足够的容量了。如果两台服务器都能够独立处理负载,就用不着担心复制的问题了。\n构建冗余容量也是实现高可用性的最佳方式之一,当然还有别的方式,例如,当错误发生时让应用在降级模式下运行,第12章会介绍更多的细节。\n10.6 复制管理和维护 # 配置复制一般来说不会是需要经常做的工作,除非有很多服务器。但是一旦配置了复制,监控和管理复制拓扑应该成为一项日常工作,不管有多少服务器。\n这些工作应该尽量自动化,但不一定需要自己写工具来实现:在16章我们讨论了几个MySQL工具,其中许多都拥有内建的监控复制的能力或插件。\n10.6.1 监控复制 # 复制增加了MySQL监控的复杂性。尽管复制发生在主库和备库上,但大多数工作是在备库上完成的,这也正是最常出问题的地方。是否所有的备库都在工作?最慢的备库延迟是多大?MySQL本身提供了大量可以回答上述问题的信息,但要实现自动化监控过程以及使复制更健壮还是需要用户做更多的工作。\n在主库上,可以使用SHOW MASTER STATUS命令来查看当前主库的二进制日志位置和配置(详细参阅前面介绍的“配置主库和备库”部分)。还可以查看主库当前有哪些二进制日志是在磁盘上的:\n该命令用于给PURGE MASTER LOGS命令决定使用哪个参数。另外还可以通过SHOW BINLOG EVENTS来查看复制事件。例如,在运行前一个命令后,我们在另一个不曾使用过的服务器上创建一个表,因为知道这是唯一改变数据的语句,而且也知道语句在二进制日志中的偏移量是13634,所以我们可以看到如下内容:\nmysql\u0026gt; ** SHOW BINLOG EVENTS IN 'mysql-bin.000223' FROM 13634\\G** *************************** 1. row *************************** Log_name: mysql-bin.000223 Pos: 13634 Event_type: Query Server_id: 1 End_log_pos: 13723 Info: use `test`; CREATE TABLE test.t(a int) 10.6.2 测量备库延迟 # 一个比较普遍的问题是如何监控备库落后主库的延迟有多大。虽然SHOW SLAVE STATUS输出的Seconds_behind_master列理论上显示了备库的延时,但由于各种各样的原因,并不总是准确的:\n备库Seconds_behind_master值是通过将服务器当前的时间戳与二进制日志中的事件的时间戳相对比得到的,所以只有在执行事件时才能报告延迟。 如果备库复制线程没有运行,就会报延迟为NULL。 一些错误(例如主备的max_allowed_packet不匹配,或者网络不稳定)可能中断复制并且/或者停止复制线程,但Seconds_behind_master将显示为0而不是显示错误。 即使备库线程正在运行,备库有时候可能无法计算延时。如果发生这种情况,备库会报0或者NULL。 一个大事务可能会导致延迟波动,例如,有一个事务更新数据长达一个小时,最后提交。这条更新将比它实际发生时间要晚一个小时才记录到二进制日志中。当备库执行这条语句时,会临时地报告备库延迟为一个小时,然后又很快变成0。 如果分发主库落后了,并且其本身也有已经追赶上它的备库,备库的延迟将显示为0,而事实上和源主库之间是有延迟的。 解决这些问题的办法是忽略Seconds_behind_master的值,并使用一些可以直接观察和衡量的方式来监控备库延迟。最好的解决办法是使用heartbeat record,这是一个在主库上会每秒更新一次的时间戳。为了计算延时,可以直接用备库当前的时间戳减去心跳记录的值。这个方法能够解决刚刚我们提到的所有问题,另外一个额外的好处是我们还可以通过时间戳知道备库当前的复制状况。包含在Percona Toolkit里的pt-heartbeat脚本是“复制心跳”最流行的一种实现。\n心跳还有其他好处,记录在二进制日志中的心跳记录拥有许多用途,例如在一些很难解决的场景下可以用于灾难恢复。\n我们刚刚所描述的几种延迟指标都不能表明备库需要多长时间才能赶上主库。这依赖于许多因素,例如备库的写入能力以及主库持续写入的次数。关于这个话题,详细参阅前面介绍的“何时备库开始延迟”。\n10.6.3 确定主备是否一致 # 在理想情况下,备库和主库的数据应该是完全一样的。但事实上备库可能发生错误并导致数据不一致。即使没有明显的错误,备库同样可能因为MySQL自身的特性导致数据不一致,例如MySQL的Bug、网络中断、服务器崩溃,非正常关闭或者其他一些错误。(16)\n按照我们的经验来看,主备一致应该是一种规范,而不是例外,也就是说,检查你的主备一致性应该是一个日常工作,特别是当使用备库来做备份时尤为重要,因为你肯定不希望从一个已经损坏的备库里获得备份数据。\nMySQL并没有内建的方法来比较一台服务器与别的服务器的数据是否相同。它提供了一些组件来为表和数据生成校验值,例如CHECKSUM TABLE。但当复制正在进行时,这种方法是不可行的。\nPercona Toolkit里的pt-table-checksum能够解决上述几个问题。其主要特性是用于确认备库与主库的数据是否一致。工作方式是通过在主库上执行INSERT\u0026hellip;SELECT查询。\n这些查询对数据进行校验并将结果插入到一个表中。这些语句通过复制传递到备库,并在备库执行一遍,然后可以比较主备上的结果是否一样。由于该方法是通过复制工作的,它能够给出一致的结果而无须同时把主备上的表都锁上。\n通常情况下可以在主库上运行该工具,参数如下:\n$ ** pt-table-checksum --replicate=test.checksum \u0026lt;master_host\u0026gt;** 该命令将检查所有的表,并将结果插入到test.checksum表中。当查询在备库执行完后,就可以简单地比较主备之间的不同了。pt-table-checksum能够发现服务器所有的备库,在每台备库上运行查询,并自动地输出结果。在写作本书时,pt-table-checksum是唯一能够有效地比较主备一致性的工具。\n10.6.4 从主库重新同步备库 # 在你的职业生涯中,也许会不止一次需要去处理未被同步的备库。可能是使用校验工具发现了数据不一致,或是因为已经知道是备库忽略了某条查询或者有人在备库上修改了数据。\n传统的修复不一致的办法是关闭备库,然后重新从主库复制一份数据。当备库数据不一致的问题可能导致严重后果时,一旦发现就应该将备库停止并从生产环境移除,然后再从一个备份中克隆或恢复备库。\n这种方法的缺点是不太方便,特别是数据量很大时。如果能够找出并修复不一致的数据,要比从其他服务器上重新克隆数据要有效得多。如果发现的不一致并不严重,就可以保持备库在线,并重新同步受影响的数据。\n最简单的办法是使用mysqldump转储受影响的数据并重新导入。在整个过程中,如果数据没有发生变化,这种方法会很好。你可以在主库上简单地锁住表然后进行转储,再等待备库赶上主库,然后将数据导入到备库中。(需要等待备库赶上主库,这样就不至于为其他表引入新的不一致,例如那些可能通过和失去同步的表做join后进行数据更新的表)。\n虽然这种方法在许多场景下是可行的,但在一个繁忙的服务器上有可能行不通。另外一个缺点是在备库上通过非复制的方式改变数据。通过复制改变备库数据(通过在主库上执行更新)通常是一种安全的技术,因为它避免了竞争条件和其他意料外的事情。如果表很大或者网络带宽受限,转储和重载数据的代价依然很高。当在一个有一百万行的表上只有一千行不同的数据呢?转储和重载表的数据是非常浪费资源的。\npt-table-sync是Percona Toolkit中的另外一个工具,可以解决该问题。该工具能够高效地查找并解决表之间的不同。它同样通过复制工作,在主库上执行查询,在备库上重新同步,这样就没有竞争条件。它是结合pt-table-checksum生成的checksum表来工作的,所以只能操作那些已知不同步的表的数据块。但该工具不是在所有场景下都有效。为了正确地同步主库和备库,该工具要求复制是正常的,否则就无法工作。pt-table-sync设计得很高效,但当数据量非常大时效率还是会很低。比较主库和备库上1TB的数据不可避免地会带来额外的工作。尽管如此,在那些合适的场景中,该工具依然能节约大量的时间和工作。\n10.6.5 改变主库 # 迟早会有把备库指向一个新的主库的需求。也许是为了更迭升级服务器,或者是主库出现问题时需要把一台备库转换成主库,或者只是希望重新分配容量。不管出于什么原因,都需要告诉其他的备库新主库的信息。\n如果这是计划内的操作,会比较容易(至少比紧急情况下要容易)。只需在备库简单地使用CHANGE MASTER TO命令,并指定合适的值。大多数值都是可选的。只需要指定需要改变的项即可。备库将抛弃之前的配置和中继日志并从新的主库开始复制。同样新的参数会被更新到master.info文件中,这样就算重启,备库配置信息也不会丢失。\n整个过程中最难的是获取新主库上合适的二进制日志位置,这样备库才可以从和老主库相同的逻辑位置开始复制。\n把备库提升为主库要更困难一点。有两种场景需要将备库替换为主库,一种是计划内的提升,一种是计划外的提升。\n计划内的提升 # 把备库提升为主库理论上是很简单的。简单来说,有以下步骤:\n停止向老的主库写入。 让备库追赶上主库(可选的,会简化下面的步骤)。 将一台备库配置为新的主库。 将备库和写操作指向新的主库,然后开启主库的写入。 但这其中还隐藏着很多细节。一些场景可能依赖于复制的拓扑结构。例如,主-主结构和主-备结构的配置就有所不同。\n更深入一点,下面是大多数配置需要的步骤:\n停止当前主库上的所有写操作。如果可以,最好能将所有的客户端程序关闭(除了复制连接)。为客户端程序建立一个“do not run”这样的类似标记可能会有所帮助。如果正在使用虚拟IP地址,也可以简单地关闭虚拟IP,然后断开所有的客户端连接以关闭其打开的事务。 通过FLUSH TABLES WITH READ LOCK在主库上停止所有活跃的写入,这一步是可选的。也可以在主库上设置read_only选项。从这一刻开始,应该禁止向即将被替换的主库做任何写入。因为一旦它不是主库,写入就意味着数据丢失。注意,即使设置read_only也不会阻止当前已存在的事务继续提交。为了更好地保证这一点,可以“kill”所有打开的事务,这将会真正地结束所有写入。 选择一个备库作为新的主库,并确保它已经完全跟上主库(例如,让它执行完所有从主库获得的中继日志)。 确保新主库和旧主库的数据是一致的。可选。 在新主库上执行STOP SLAVE。 在新主库上执行CHANGE MASTER TO MASTER_HOST=\u0026rsquo;\u0026rsquo;,然后再执行RESET SLAVE,使其断开与老主库的连接,并丢弃master.info里记录的信息(如果连接信息记录在my.cnf里,会无法正确工作,这也是我们建议不要把复制连接信息写到配置文件里的原因之一)。 执行SHOW MASTER STATUS记录新主库的二进制日志坐标。 确保其他备库已经追赶上。 关闭旧主库。 在MySQL 5.1及以上版本中,如果需要,激活新主库上事件。 将客户端连接到新主库。 在每台备库上执行CHANGE MASTER TO语句,使用之前通过SHOW MASTER STATUS获得的二进制日志坐标,来指向新的主库。 当将备库提升为主库时,要确保备库上任何特有的数据库、表和权限已经被移除。可能还需要修改备库特有的配置选项,例如innodb_flush_log_at_trx_commit选项。同样的,如果是把主库降级为备库,也要保证进行需要的配置。\n如果主备的配置相同,就不需要做任何改变。\n计划外的提升 # 当主库崩溃时,需要提升一台备库来代替它,这个过程可能就不太容易。如果只有一台备库,可以直接使用这台备库。但如果有超过一台的备库,就需要做一些额外的工作。\n另外,还有潜在的丢失复制事件的问题。可能有主库上已发生的修改还没有更新到它的任何一台备库上的情况。甚至还可能一条语句在主库上执行了回滚,但在备库上没有回滚,这样备库可能超过主库的逻辑复制位置(17)。如果能在某一点恢复主库的数据,也许就可以取得丢失的语句并手动执行它们。\n在以下步骤中,需要确保在计算中使用Master_Log_File和Read_Master_Log_Pos的值。以下是对主备拓扑结构中的备库进行提升的过程:\n确定哪台备库的数据最新。检查每台备库上SHOW SLAVE STATUS命令的输出,选择其中Master_Log_File/read_Master_Log_Pos的值最新的那个。 让所有备库执行完所有其从崩溃前的旧主库那获得的中继日志。如果在未完成前修改备库的主库,它会抛弃剩下的日志事件,从而无法获知该备库在什么地方停止。 执行前一小节的5~7步。 比较每台备库和新主库上的Master_Log_File/Read_Master_Log_Pos的值。 执行前一小节的10~12步。 正如本章开始我们推荐的,假设已经在所有的备库上开启了log_bin和log_slave_updates,这样可以帮助你将所有的备库恢复到一个一致的时间点,如果没有开启这两个选项,则不能可靠地做到这一点。\n确定期望的日志位置 # 如果有备库和新主库的位置不相同,则需要找到该备库最后一条执行的事件在新主库的二进制日志中相应的位置,然后再执行CHANGE MASTER TO。可以通过mysqlbinlog工具来找到备库执行的最后一条查询,然后在主库上找到同样的查询,进行简单的计算即可得到。\n为了便于描述,假设每个日志事件有一个自增的数字ID,最新的备库,也就是新主库,在旧主库崩溃时获得了编号为100的事件,假设有另外两台备库:replica2和replica3。replica2已经获取了99号事件,replica3获取了98号事件。如果把两台备库都指向新主库的同一个二进制日志位置,它们将从101号事件开始复制,从而导致数据不同步。但只要新主库的二进制日志已经通过log_slave_updates打开,就可以在新主库的二进制日志中找到99号和100号日志,从而将备库恢复到一致的状态。\n由于服务器重启,不同的配置,日志轮转或者FLUSH LOGS命令,同一个事件在不同的服务器上可能有不同的偏移量。找到这些事件可能会耗时很长并且枯燥,但是通常没有难度。通过mysqlbinlog从二进制日志或中继日志中解析出每台备库上执行的最后一个事件,并同样使用该命令解析新主库上的二进制日志,找到相同的查询,mysqlbinlog会打印出该事件的偏移量,在CHANGE MASTER TO命令中使用这个值(18)。\n更快的方法是把新主库和停止的备库上的字节偏移量相减,它显示了字节位置的差异。然后把这个值和新主库当前二进制日志的位置相减,就可以得到期望的查询的位置。只需要验证一下就可以据此启动备库。\n让我们看看一个相关的例子,假设server1是server2和server3的主库,其中服务器server1已经崩溃。根据SHOW SLAVE STATUS获得Master_Log_File/Read_Master_Log_Pos的值,server2已经执行完了server1上所有的二进制日志,但server3还不是最新数据。图10-15显示了这个场景(日志事件和偏移量仅仅是为了举例)。\n正如图10-15所示,我们可以肯定server2已经执行完了主库上的所有二进制日志,因为Master_Log_File和Read_Master_Log_Pos值和server1上最后的日志位置是相吻合的,因此我们可以将server2提升为新主库,并将server3设置为server2的备库。\n图10-15:当server1崩溃,server2已追赶上,但server3的复制落后\n应该在server3上为需要执行的CHANGE MASTER TO语句赋予什么样的参数呢?这里需要做一点点计算和调查。server3在偏移量1493停止,比server2执行的最后一条语句的偏移量1582要小89字节。server2正在向偏移量为8167的二进制日志写入,8167-89= 8078,因此理论上我们应该将server3指向server2的日志的偏移量为8078的位置。最好去确认下这个位置附近的日志事件,以确定在该位置上是否是正确的日志事件,因为可能有别的例外,例如有些更新可能只发生在server2上。\n假设我们观察到的事件是一样的,下面这条命令会将server3切换为server2的备库。\nserver2\u0026gt; ** CHANGE MASTER TO MASTER_HOST=\u0026quot;server2\u0026quot;, MASTER_LOG_FILE=\u0026quot;mysql-bin.000009\u0026quot;,** ** MASTER_LOG_POS=8078;** 如果服务器在它崩溃时已经执行完成并记录了超过一个事件,会怎么样呢?因为server2仅仅读取并执行到了偏移位置1582,你可能永远地失去了一个事件。但是如果老主库的磁盘没有损坏,仍然可以通过mysqlbinlog或者从日志服务器的二进制日志中找到丢失的事件。\n如果需要从老主库上恢复丢失的事件,建议在提升新主库之后且在允许客户端连接之前做这件事情。这样就无须在每台备库上都执行丢失的事件,只需使用复制来完成。但如果崩溃的老主库完全不可用,就不得不等待,稍后再做这项工作。\n上述流程中一个可调整的地方是使用可靠的方式来存储二进制日志,如SAN或分布式复制数据库设备(DRBD)。即使主库完全失效,依然能够获得它的二进制日志。也可以设置一个日志服务器,把备库指向它,然后让所有备库赶上主库失效的点。这使得提升一个备库为新的主库没那么重要,本质上这和计划中的提升是相同的。我们将在下一章进一步讨论这些存储选项。\n当提升一台备库为主库时,千万不要将它的服务器ID修改成原主库的服务器ID,否则将不能使用日志服务器从一个旧主库来重放日志事件。这也是确保服务器ID最好保持不变的原因之一。\n10.6.6 在一个主-主配置中交换角色 # 主-主复制拓扑结构的一个好处就是可以很容易地切换主动和被动的角色,因为其配置是对称的。本小节介绍如何完成这种切换。\n当在主-主配置下切换角色时,必须确保任何时候只有一个服务器可以写入。如果两台服务器交叉写入,可能会导致写入冲突。换句话说,在切换角色后,原被动服务器不应该接收到主动服务器的任何二进制日志。可以通过确保原被动服务器的复制SQL线程在该服务器可写之前已经赶上主动服务器来避免。\n通过以下步骤切换服务器角色,可以避免更新冲突的危险:\n停止主动服务器上的所有写入。 在主动服务器上执行SET GLOBAL read_only=1,同时在配置文件里也设置一下read_only,防止重启后失效。但记住这不会阻止拥有超级权限的用户更改数据。如果想阻止所有人更改数据,可以执行FLUSH TABLES WITH READ LOCK。如果没有这么做,你必须kill所有的客户端连接以保证没有长时间运行的语句或者未提交的事务。 在主动服务器上执行SHOW MASTER STATUS并记录二进制日志坐标。 使用主动服务器上的二进制日志坐标在被动服务器上执行SELECT MASTER_POS_WAIT()。该语句将阻塞住,直到复制跟上主动服务器。 在被动服务器上执行SET GLOBAL read_only=0,这样就变换成主动服务器。 修改应用的配置,使其写入到新的主动服务器中。 可能还需要做一些额外的工作,包括更改两台服务器的IP地址,这取决于应用的配置,我们将在下一节讨论这个话题。\n10.7 复制的问题和解决方案 # 中断MySQL的复制并不是件难事。因为实现简单,配置相当容易,但也意味着有很多方式会导致复制停止,陷入混乱并中断。本章描述了一些比较普遍的问题,讨论如何重现这些问题,以及当遇到这些问题时如何解决或者阻止其发生。\n10.7.1 数据损坏或丢失的错误 # 由于各种各样的原因,MySQL的复制并不能很好地从服务器崩溃、掉电、磁盘损坏、内存或网络错误中恢复。遇到这些问题时几乎可以肯定都需要从某个点开始重启复制。\n大部分由于非正常关机后导致的复制问题都是由于没有把数据及时地刷到磁盘。下面是意外关闭服务器时可能会碰到的情况。\n主库意外关闭\n如果没有设置主库的sync_binlog选项,就可能在崩溃前没有将最后的几个二进制日志事件刷新到磁盘中。备库I/O线程因此也可一直处于读不到尚未写入磁盘的事件的状态中。当主库重新启动时,备库将重连到主库并再次尝试去读该事件,但主库会告诉备库没有这个二进制日志偏移量。二进制日志转储线程通常很快,因此这种情况并不经常发生。\n解决这个问题的方法是指定备库从下一个二进制日志的开头读日志。但是一些日志事件将永久地丢失,建议使用Percona Toolkit中的pt-table-checksum工具来检查主备一致性,以便于修复。可以通过在主库开启sync_binlog来避免事件丢失。\n即使开启了sync_binlog,MyISAM表的数据仍然可能在崩溃的时候损坏,对于InnoDB事务,如果innodb_flush_log_at_trx_commit没有设为1,也可能丢失数据(但数据不会损坏)。\n备库意外关闭\n当备库在一次非计划中的关闭后重启时,会去读master.info文件以找到上次停止复制的位置。不幸的是,该文件并没有同步写到磁盘,文件中存储的信息可能是错误的。备库可能会尝试重新执行一些二进制日志事件,这可能会导致唯一索引错误。除非能确定备库在哪里停止(通常不太可能),否则唯一的办法就是忽略那些错误。Percona Toolkit中的pt-slave-restart工具可以帮助完成这一点。\n如果使用的都是InnoDB表,可以在重启后观察MySQL错误日志。InnoDB在恢复过程中会打印出它的恢复点的二进制日志坐标。可以使用这个值来决定备库指向主库的偏移量。Percona Server提供了一个新的特性,可以在恢复的过程中自动将这些信息提取出来,并更新master.info文件,从根本上使得复制能够协调好备库上的事务。MySQL 5.5也提供了一些选项来控制如何将master.info和其他文件刷新到磁盘,这有助于减少这些问题。\n除了由于MySQL非正常关闭导致的数据丢失外,磁盘上的二进制日志或中继日志文件损坏并不罕见。下面是一些更普遍的场景:\n主库上的二进制日志损坏\n如果主库上的二进制日志损坏,除了忽略损坏的位置外你别无选择。可以在主库上执行FLUSH LOGS命令,这样主库会开始一个新的日志文件,然后将备库指向该文件的开始位置。也可以试着去发现损坏区域的结束位置。某些情况下可以通过SET GLOBAL SQL_SLAVE_SKIP_COUNTER = 1来忽略一个损坏的事件。如果有多个损坏的事件,就需要重复该步骤,直到跳过所有损坏的事件。但如果有太多的损坏事件,这么做可能就没有意义了。损坏的事件头会阻止服务器找到下一个事件。这种情况下,可能不得不手动地去找到下一个完好的事件。\n备库上的中继日志损坏\n如果主库上的日志是完好的,就可以通过CHANGE MASTER TO命令丢弃并重新获取损坏的事件。只需要将备库指向它当前正在复制的位置(Relay_Master_Log_File/Exec_Master_Log_Pos)。这会导致备库丢弃所有在磁盘上的中继日志。就这一点而言, MySQL 5.5做了一些改进,它能够在崩溃后自动重新获取中继日志。\n二进制日志与InnoDB事务日志不同步\n当主库崩溃时,InnoDB可能将一个事务标记为已提交,此时该事务可能还没有记录到二进制日志中。除非是某个备库的中继日志已经保存,否则没有任何办法恢复丢失的事务。在MySQL 5.0版本可以设置sync_binlog选项来防止该问题,对于更早的MySQL 4.1可以设置sync_binlog和safe_binlog选项。\n当一个二进制日志损坏时,能恢复多少数据取决于损坏的类型,有几种比较常见的类型:\n数据改变,但事件仍是有效的SQL\n不幸的是,MySQL甚至无法察觉这种损坏。因此最好还是经常检查备库的数据是否正确。在MySQL未来的版本中可能会被修复。\n数据改变并且事件是无效的SQL\n这种情况可以通过mysqlbinlog提取出事件并看到一些错乱的数据,例如:\nUPDATE tbl SET col????????????????? 可以通过增加偏移量的方式来尝试找到下一个事件,这样就可以只忽略这个损坏的事件。\n数据遗漏并且/或者事件的长度是错误的\n这种情况下,mysqlbinlog可能会发生错误退出或者直接崩溃,因为它无法读取事件,并且找不到下一个事件的开始位置。\n某些事件已经损坏或被覆盖,或者偏移量已经改变并且下一个事件的起始偏移量也是错误的\n同样的,这种情况下mysqlbinlog也起不了多少作用。\n当损坏非常严重,通过mysqlbinlog已经无法获取日志事件时,就不得不进行一些十六进制的编辑或者通过一些烦琐的技术来找到日志事件的边界。这通常并不困难,因为有一些可辨识的标记会分割事件。\n如下例所示,首先使用mysqlbinlog找到样例日志的日志事件偏移量:\n$ ** mysqlbinlog mysql-bin.000113 | egrep '^# at '** # at 4 # at 98 # at 185 # at 277 # at 369 # at 447 一个找到日志偏移量的比较简单的方法是比较一下string命令输出的偏移量:\n** $ strings -n 2 -t d mysql-bin.000113** 1 binpC'G 25 5.0.38-Ubuntu_0ubuntu1.1-log 99 C'G 146 std 156 test 161 create table test(a int) 186 C'G 233 std 243 test 248 insert into test(a) values(1) 278 C'G 325 std 335 test 340 insert into test(a) values(2) 370 C'G 417 std 427 test 432 drop table test 448 D'G 474 mysql-bin.000114 有一些可辨别的模式可以帮助定位事件的开头,注意以\u0026rsquo;G结尾的字符串在日志事件开头的一个字节后的位置。它们是固定长度的事件头的一部分。\n这些值因服务器而异,因此结果也可能取决于解析的日志所在的服务器。简单地分析后应该能够从二进制日志中找到这些模式并找到下一个完整的日志事件偏移量。然后通过mysqlbinlog的\u0026ndash;start-position选项来跳过损坏的事件,或者使用CHANGE MASTER TO命令的MASTER_LOG_POS参数。\n10.7.2 使用非事务型表 # 如果一切正常,基于语句的复制通常能够很好地处理非事务型表。但是当对非事务型表的更新发生错误时,例如查询在完成前被kill,就可能导致主库和备库的数据不一致。\n例如,假设更新一个MyISAM表的100行数据,若查询更新到了其中50条时有人kill该查询,会发生什么呢?一半的数据改变了,而另一半则没有,结果是复制必然不同步,因为该查询会在备库重放并更新完100行数据(MySQL随后会在主库上发现查询引起的错误,而备库上则没有报错,此后复制将会发生错误并中断)。\n如果使用的是MyISAM表,在关闭MySQL之前需要确保已经运行了STOP SLAVE,否则服务器在关闭时会kill所有正在运行的查询(包括没有完成的更新)。事务型存储引擎则没有这个问题。如果使用的是事务型表,失败的更新会在主库上回滚并且不会记录到二进制日志中。\n10.7.3 混合事务型和非事务型表 # 如果使用的是事务型存储引擎,只有在事务提交后才会将查询记录到二进制日志中。因此如果事务回滚,MySQL就不会记录这条查询,也就不会在备库上重放。\n但是如果混合使用事务型和非事务型表,并且发生了一次回滚,MySQL能够回滚事务型表的更新,但非事务型表则被永久地更新了。只要不发生类似查询中途被kill这样的错误,这就不是问题:MySQL此时会记录该查询并记录一条ROLLBACK语句到日志中。结果是同样的语句也在备库执行,所有的都很正常。这样效率会低一点,因为备库需要做一些工作并且最后再把它们丢弃掉。但理论上能够保证主备的数据一致。\n目前看来一切很正常。但是如果备库发生死锁而主库没有也可能会导致问题。事务型表的更新会被回滚,而非事务型表则无法回滚,此时备库和主库的数据是不一致的。\n防止该问题的唯一办法是避免混合使用事务型和非事务型表。如果遇到这个问题,唯一的解决办法是忽略错误,并重新同步相关的表。\n基于行的复制不会受这个问题的影响。因为它记录的是数据的更改,而不是SQL语句。如果一条语句改变了一个MyISAM表和一个InnoDB表的某些行,然后主库上发生了一次死锁,InnoDB表的更新会被回滚,而MyISAM表的更新仍会被记录到日志中并在备库重放。\n10.7.4 不确定语句 # 当使用基于语句的复制模式时,如果通过不确定的方式更改数据可能会导致主备不一致。例如,一条带LIMIT的UPDATE语句更改的数据取决于查找行的顺序,除非能保证主库和备库上的顺序相同。例如,若行根据主键排序,一条查询可能在主库和备库上更新不同的行,这些问题非常微妙并且很难注意到。所以一些人禁止对那些会更新数据的语句使用LIMIT。另外一种不确定的行为是在一个拥有多个唯一索引的表上使用REPLACE或者INSERT IGNORE语句——MySQL在主库和备库上可能会选择不同的索引。\n另外还要注意那些涉及INFORMATION_SCHEMA表的语句。它们很容易在主库和备库上产生不一致,其结果也会不同。最后,需要注意许多系统变量,例如@@server_id和@@hostname,在MySQL 5.1之前无法正确地复制。\n基于行的复制则没有上述限制。\n10.7.5 主库和备库使用不同的存储引擎 # 正如本章之前提到的,在备库上使用不同的存储引擎,有时候可以带来好处。但是在一些场景下,当使用基于语句的复制方式时,如果备库使用了不同的存储引擎,则可能造成一条查询在主库和备库上的执行结果不同,例如不确定语句(如前一小节提到的)在主备库使用不同的存储引擎时更容易导致问题。\n如果发现主库和备库的某些表已经不同步,除了检查更新这些表的查询外,还需要检查两台服务器上使用的存储引擎是否相同。\n10.7.6 备库发生数据改变 # 基于语句的复制方式前提是确保备库上有和主库相同的数据,因此不应该允许对备库数据的任何更改(比较好的办法是设置read_only选项)。假设有如下语句:\nmysql\u0026gt; ** INSERT INTO table1 SELECT * FROM table2;** 如果备库上table2的数据和主库上不同,该语句会导致table1的数据也会不一致。换句话说,数据不一致可能会在表之间传播。不仅仅是INSERT\u0026hellip;\u0026hellip;SELECT查询,所有类型的查询都可能发生。有两种可能的结果:备库上发生重复索引键冲突错误或者根本不提示任何错误。如果能报告错误还好,起码能够提示你主备数据已经不一致。无法察觉的不一致可能会悄无声息地导致各种严重的问题。\n唯一的解决办法就是重新从主库同步数据。\n10.7.7 不唯一的服务器ID # 这种问题更加难以捉摸。如果不小心为两台备库设置了相同的服务器ID,看起来似乎没有什么问题,但如果查看错误日志,或者使用innotop查看主库,可能会看到一些古怪的信息。\n在主库上,会发现两台备库中只有一台连接到主库(通常情况下所有的备库都会建立连接以等待随时进行复制)。在备库的错误日志中,则会发现反复的重连和连接断开信息,但不会提及被错误配置的服务器ID。\nMySQL可能会缓慢地进行正确的复制,也可能无法进行正确复制,这取决于MySQL的版本,给定的备库可能会丢失二进制日志事件,或者重复执行事件,导致重复键错误(或者不可见的数据损坏)。也可能因为备库的互相竞争造成主库的负载升高。如果备库竞争非常激烈,会导致错误日志在很短的时间内急剧增大。\n唯一的解决办法是小心设置备库的服务器ID。一个比较好的办法是创建一个主库到备库的服务器ID映射表,这样就可以跟踪到备库的ID信息(19)。如果备库全在一个子网络内,可以将每台机器IP的后八位作为唯一ID。\n10.7.8 未定义的服务器ID # 如果没有在my.cnf里定义服务器ID,可以通过CHANGE MASTER TO来设置备库,但却无法启动复制:\nmysql\u0026gt; ** START SLAVE;** ERROR 1200 (HY000): The server is not configured as slave; fix in config file or with CHANGE MASTER TO 这个报错可能会让人困惑,因为刚刚执行CHANGE MASTER TO设置了备库,并且通过SHOW MASTER STATUS也确认了。执行SELECT @@server_id也可以获得一个值,但这只是默认值,必须为备库显式地设置服务器ID。\n10.7.9 对未复制数据的依赖性 # 如果在主库上有备库不存在的数据库或表,复制会很容易意外中断,反之亦然。假设主库上有一个备库不存在的数据库,命名为scratch。如果在主库上发生对该数据库中表的更新,备库会在尝试重放这些更新时中断。同样的,如果在主库上创建一个备库上已存在的表,复制也可能中断。\n没有什么好的解决办法,唯一的办法就是避免在主库上创建备库上没有的表。\n这样的表是如何创建的呢?有很多可能的方式,其中一些可能更难防范。例如,假设先在备库上创建一个数据库scratch,该数据库在主库上不存在,然后因为某些原因切换了主备。当完成这些后,可能忘记了移除scratch数据库以及它的权限。这时候一些人就可以连接到该数据库并执行一些查询,或者一些定期的任务会发现这些表,并在每个表上执行OPTIMIZE TABLE命令。\n当提升备库为主库时,或者决定如何配置备库时,需要注意这一点。任何导致主备不同的行为都会产生潜在的问题。\n10.7.10 丢失的临时表 # 临时表在某些时候比较有用,但不幸的是,它与基于语句的复制方式是不相容的。如果备库崩溃或者正常关闭,任何复制线程拥有的临时表都会丢失。重启备库后,所有依赖于该临时表的语句都会失败。\n当基于语句进行复制时,在主库上并没有安全使用临时表的方法。许多人确实很喜欢临时表,所以很难去说服他们,但这是不可否认的(20)。不管它们的存在多么短暂,都会使得备库的启动和停止以及崩溃恢复变得困难,即使是在一个事务内使用也一样。(如果在备库使用临时表可能问题会少些,但如果备库本身也是一个主库,问题依然存在。)\n如果备库重启后复制因找不到临时表而停止,可能需要做以下一些事情:可以直接跳过错误,或者手动地创建一个名字和结构相同的表来代替消失的临时表。不管用什么办法,如果写入查询依赖于临时表,都可能造成数据不一致。\n避免使用临时表没有看起来那么难,临时表主要有两个比较有用的特性:\n只对创建临时表的连接可见。所以不会和其他拥有相同名字临时表的连接起冲突。 随着连接关闭而消失,所以无须显式地移除它们。 可以保留一个专用的数据库,在其中创建持久表,把它们作为伪临时表,以模拟这些特性。只需要为它们选择一个唯一的名字。还好这很容易做到:简单地将连接ID拼接到表名之后。例如,之前创建临时表的语句为:CREATE TEMPORARY TABLE top_users(\u0026hellip;),现在则可以执行CREATE TABLE temp.top_users_1234(\u0026hellip;),其中1234是函数CONNECTION_ID()的返回值。当应用不再使用该伪临时表后,可以将其删除或使用一个清理线程来将其移除。表名中使用连接ID可以用于确定哪些表不再被使用——可以通过SHOW PROCESSLIST命令来获得活跃连接列表,并将其与表名中的连接ID相比较(21)。\n使用实体表而非临时表还有别的好处。例如,能够帮助你更容易调试应用程序,因为可以通过别的连接来查看应用正在维护的数据。如果使用的是临时表,可能就没这么容易做到。\n但是实体表可能会比临时表多一些开销,例如创建会更慢,因为为这些表分配的.frm文件需要刷新到磁盘。可以通过禁止sync_frm选项来加速,但这可能会导致潜在的风险。\n如果确实需要使用临时表,也应该在关闭备库前确保Slave_open_temp_tables状态变量值为0。如果不是0,在重启备库后就可能会出现问题。合适的流程是执行STOP SLAVE,检查变量,然后再关闭备库。如果在停止复制前检查变量,可能会发生竞争条件的风险。\n10.7.11 不复制所有的更新 # 如果错误地使用SET SQL_LOG_BIN=0或者没有理解过滤规则,备库可能会丢失主库上已经发生的更新。有时候希望利用此特性来做归档,但常常会导致意外并出现不好的结果。\n例如,假设设置了replicate_do_db规则,把sakila数据库的数据复制到某一台备库上。如果在主库上执行如下语句,会导致主备数据不一致:\nmysql\u0026gt; ** USE test;** mysql\u0026gt; ** UPDATE sakila.actor ...** 其他类型的语句甚至会因为没有复制依赖导致备库复制抛出错误而失败。\n10.7.12 InnoDB加锁读引起的锁争用 # 正常情况下,InnoDB的读操作是非阻塞的,但在某些情况下需要加锁。特别是在使用基于语句的复制方式时,执行INSERT\u0026hellip;SELECT操作会锁定源表上的所有行。MySQL需要加锁以确保该语句的执行结果在主库和备库上是一致的。实际上,加锁导致主库上的语句串行化,以确保和备库上执行的方式相符。\n这种设计可能导致锁竞争、阻塞,以及锁等待超时等情况。一种缓解的办法就是避免让事务开启太久以减少阻塞。可以在主库上尽快地提交事务以释放锁。\n把大命令拆分成小命令,使其尽可能简短。这也是一种减少锁竞争的有效方法。即使有时很难做到,但也是值得的(使用Percona Toolkit中的pt-archiver工具会很简单)。\n另一种方法是替换掉INSERT\u0026hellip;SELECT语句,在主库上先执行SELECT INTO OUTFILE,再执行LOAD DATA INFILE。这种方法更快,并且不需要加锁。这种方法很特殊,但有时还是有用的。最大的问题是为输出文件选择一个唯一的名字,并在完成后清理掉文件。可以通过之前讨论过的CONNECTION_ID()来保证文件名的唯一性,并且可以使用定时任务(UNIX的crontab,Windows平台的计划任务)在连接不再使用这些文件后进行自动清理。\n也可以尝试关闭上面的这种锁机制,而不是使用上面的变通方法。有一种方法可以做到,但在大多数场景下并不是好办法,备库可能会在不知不觉间就失去和主库的数据同步。这也会导致在做恢复时二进制日志变得毫无用处。但如果确实觉得这么做的利大于弊,可以使用下面的办法来关闭这种锁机制:\n# THIS IS NOT SAFE! innodb_locks_unsafe_for_binlog = 1 这使得查询的结果所依赖的数据不再加锁。如果第二条查询修改了数据并在第一条查询之前先提交。在主库和备库上执行这两条语句的结果可能不相同。对于复制和基于时间点的恢复都是如此。\n为了了解锁定读取是如何防止混乱的,假设有两张表:一个没有数据,另一个只有一行数据,值为99。有两个事务更新数据。事务1将第二张表的数据插入到第一张表,事务2更新第二张表(源表),如图10-16所示。\n图10-16:两个事务更新数据,使用共享锁串行化更新\n第二步非常重要,事务2尝试去更新源表,这需要在更新的行上加排他锁(写锁)。排他锁与其他锁是不相容的,包括事务1在行记录上加的共享锁。因此事务2需要等待直到事务1完成。事务按照其提交的顺序在二进制日志中记录,所以在备库重放这些事务时产生相同的结果。\n但从另一方面来说,如果事务1没有在读取的行上加共享锁,就无法保证了。图10-17显示了在没有锁的情况下可能的事件序列。\n图10-17:两个事务更新数据,但未使用共享锁来串行化更新\n如果没有加锁,记录在日志中的事务顺序在主备上可能会产生不同的结果。MySQL会先记录事务2,这会影响到事务1在备库上的结果,而主库上则不会发生,从而导致了主备的数据不一致。\n我们强烈建议在大多数情况下将innodb_locks_unsafe_for_binlog的值设置为0。基于行的复制由于记录了数据的变化而非语句,因此不会存在这个问题。\n10.7.13 在主-主复制结构中写入两台主库 # 试图向两台主库写入并不是一个好主意。如果同时还希望安全地写入两台主库,会碰到很多问题,有些问题可以解决,有些则很难。一个专业人员可能需要经历大量的教训才能明白其中的不同。\n在MySQL 5.0中,有两个变量可以用于帮助解决AUTO_INCREMENT自增主键冲突的问题:auto_increment_increment和auto_increment_offset。可以通过设置这两个变量来错开主库和备库生成的数字,这样可以避免自增列的冲突。\n但是这并不能解决所有由于同时写入两台主库所带来的问题;自增问题只是其中的一小部分。而且这种做法也带来了一些新的问题:\n很难在复制拓扑间做故障转移。 由于在数字之间出现间隙,会引起键空间的浪费。 只有在使用了AUTO_INCREMENT主键的时候才有用。有时候使用AUTO_INCREMENT列作为主键并不总是好主意。 你也可以自己来生成不冲突的主键值。一种办法是创建一个多个列的主键,第一列使用服务器ID值。这种办法很好,但却使得主键的值变得更大,会对InnoDB二级索引键值产生多重影响。\n也可以使用只有一列的主键,在主键的高字节位存储服务器ID。简单的左移位(除法)和加法就可以实现。例如,使用的是无符号BIGINT(64位)的高8位来保存服务器ID,可以按照如下方法在服务器15上插入值11:\nmysql\u0026gt; ** INSERT INTO test(pk_col, ...) VALUES((15 \u0026lt;\u0026lt; 56) + 11, ...);** 如果想把结果转换为二进制,并将其填充为64位,其效果显而易见:\n该方法的缺点是需要额外的方式来产生键值,因为AUTO_INCREMENT无法做到这一点。不要在INSERT语句中将常量15替换为@@server_id,因为这可能在备库产生不同的结果。\n还可以使用MD5()或UUID()等函数来获取伪随机数,但这样做性能可能会很差,因为它们产生的值较大,并且本质上是随机的,这尤其会影响到InnoDB(除非是在应用中产生值,否则不要使用UUID(),因为基于语句的复制模式下UUID()不能正确复制)。\n这个问题很难解决,我们通常推荐重构应用程序,以保证只有一个主库是可写的。谁能想得到呢?\n10.7.14 过大的复制延迟 # 复制延迟是一个很普遍的问题。不管怎么样,最好在设计应用程序时能够让其容忍备库出现延迟。如果系统在备库出现延迟时就无法很好地工作,那么应用程序也许就不应该用到复制。但是也有一些办法可以让备库跟上主库。\nMySQL单线程复制的设计导致备库的效率相当低下。即使备库有很多磁盘、CPU或者内存,也会很容易落后于主库。因为备库的单线程通常只会有效地使用一个CPU和磁盘。而事实上,备库通常都会和主库使用相同配置的机器。\n备库上的锁同样也是问题。其他在备库运行的查询可能会阻塞住复制线程。因为复制是单线程的,复制线程在等待时将无法做别的事情。\n复制一般有两种产生延迟的方式:突然产生延迟然后再跟上,或者稳定的延迟增大。前一种通常是由于一条运行很长时间的查询导致的,而后者即使在没有长时间运行的查询时也会出现。\n不幸的是,目前我们没那么容易确定备库是否接近其容量上限。正如之前提到的。如果负载总是保持均匀的,备库在负载达到99%时和其负载在10%的时候表现的性能相同,但一旦达到100%时就会突然开始产生延迟。但实际上负载不太可能很稳定,所以当备库接近写容量时,就可能在尖峰负载时看到复制延迟的增加。\n当备库无法跟上时,可以记录备库上的查询并使用一个日志分析工具找出哪里慢了。不要依赖于自己的直觉,也不要基于查询在主库上的查询性能进行判断,因为主库和备库性能特征很不相同。最好的分析办法是暂时在备库上打开慢查询日志记录,然后使用第3章讨论的pt-query-digest工具来分析。如果打开了log_slow_slave_statements选项,在标准的MySQL慢查询日志能够记录MySQL 5.1及更新的版本中复制线程执行的语句,这样就可以找到在复制时哪些语句执行慢了。Percona Server和MariaDB允许开启或禁止该选项而无须重启服务器。\n除了购买更快的磁盘和CPU(固态硬盘能够提供极大的帮助,详细参阅第9章),备库没有太多的调优空间。大部分选项都是禁止某些额外的工作以减少备库的负载。一个简单的办法是配置InnoDB,使其不要那么频繁地刷新磁盘,这样事务会提交得更快些。可以通过设置innodb_flush_log_at_trx_commit的值为2来实现。还可以在备库上禁止二进制日志记录,把innodb_locks_unsafe_for_binlog设置为1,并把MyISAM的delay_key_write设置为ALL。但是这些设置以牺牲安全换取速度。如果需要将备库提升为主库,记得把这些选项设置回安全的值。\n不要重复写操作中代价较高的部分 # 重构应用程序并且/或者优化查询通常是最好的保持备库同步的办法。尝试去最小化系统中重复的工作。任何主库上昂贵的写操作都会在每一个备库上重放。如果可以把工作转移到备库,那么就只有一台备库需要执行,然后我们可以把写的结果回传到主库,例如,通过执行LOAD DATA INFILE。\n这里有个例子,假设有一个大表,需要汇总到一个小表中用于日常的操作:\nmysql\u0026gt; ** REPLACE INTO main_db.summary_table (col1, col2, ...)** -\u0026gt; ** SELECT col1, sum(col2, ...)** -\u0026gt; ** FROM main_db.enormous_table GROUP BY col1;** 如果在主库上执行查询,每个备库将同样需要执行庞大的GROUP BY查询。当进行太多这样的操作时,备库将无法跟上。把这些工作转移到一台备库上也许会有帮助。在备库上创建一个特别保留的数据库,用于避免和从主库上复制的数据产生冲突。可以执行以下查询:\nmysql\u0026gt; ** REPLACE INTO summary_db.summary_table (col1, col2, ...)** -\u0026gt; ** SELECT col1, sum(col2, ...)** -\u0026gt; ** FROM main_db.enormous_table GROUP BY col1;** 现在可以执行SELECT INTO OUTFILE,然后再执行LOAD DATA INFILE,将结果集加载到主库中。现在重复工作被简化为LOAD DATA INFILE操作。如果有N个备库,就节约了N-1次庞大的GROUP BY操作。\n该策略的问题是需要处理陈旧数据。有时候从备库读取的数据和写入主库的数据很难保持一致(下一章我们会详细描述这个问题)。如果难以在备库上读取数据,依然能够简化并节省库备工作。如果分离查询的REPLACE和SELECT部分,就可以把结果返回给应用程序,然后将其插入到主库中。首先,在主库执行如下查询:\nmysql\u0026gt; ** SELECT col1, sum(col2, ...) FROM main_db.enormous_table GROUP BY col1;** 然后为结果集的每一行重复执行如下语句,将结果插入到汇总表中:\nmysql\u0026gt; ** REPLACE INTO main_db.summary_table (col1, col2, ...) VALUES (?, ?, ...);** 这种方法再次避免了在备库上执行查询中的GROUP BY部分。将SELECT和REPLACE分离后意味着查询的SELECT操作不会在每一台备库上重放。\n这种通用的策略——节约了备库上昂贵的写入操作部分——在很多情况下很有帮助:计算查询的结果代价很昂贵,但一旦计算出来后,处理就很容易。\n在复制之外并行写入 # 另一种避免备库严重延迟的办法是绕过复制。任何在主库的写入操作必须在备库串行化。因此有理由认为“串行化写入”不能充分利用资源。所有写操作都应该从主库传递到备库吗?如何把备库有限的串行写入容量留给那些真正需要通过复制进行的写入?\n这种考虑有助于对写入进行区分。特别是,如果能确定一些写入可以轻易地在复制之外执行,就可以并行化这些操作以利用备库的写入容量。\n一个很好的例子是之前讨论过的数据归档。OLTP归档需求通常是简单的单行操作。如果只是把不需要的记录从一个表移到另一个表,就没有必要将这些写入复制到备库。可以禁止归档查询记录到二进制日志中,然后分别在主库和备库上单独执行这些归档查询。\n自己复制数据到另外一台服务器,而不是通过复制,这听起来有些疯狂,但却对一些应用有意义,特别是如果应用是某些表的唯一更新源。复制的瓶颈通常集中在小部分表上。如果能在复制之外单独处理这些表,就能够显著地加快复制。\n为复制线程预取缓存 # 如果有正确的工作负载,就能通过预先将数据读入内存中,以受益于在备库上的并行I/O所带来的好处。这种方式并不广为人知。大多数人不会使用,因为除非有正确的工作负载特性和硬件配置,否则可能没有任何用处。我们刚刚讨论过的其他几种变通方式通常是更好的选择,并且有更多的方法来应用它们。但是我们知道也有小部分应用会受益于数据预取。\n有两种可行的实现方法。一种是通过程序实现,略微比备库SQL线程提前读取中继日志并将其转换为SELECT语句执行。这会使得服务器将数据从磁盘加载到内存中,这样当SQL线程执行到相应的语句时,就无须从磁盘读取数据。事实上,SELECT语句可以并行地执行,所以可以加速SQL线程的串行I/O。当一条语句正在执行时,下一条语句需要的数据也正在从磁盘加载到内存中。\n如果满足下面这些条件,预取可能会有效:\n复制SQL线程是I/O密集型的,但备库服务器并不是I/O密集型的。一个完全的I/O密集型服务器不会受益于预取,因为它没有多余的磁盘性能来提供预取。 备库有多个硬盘驱动器,也许8个或者更多。 使用的是InnoDB引擎,并且工作集远不能完全加载到内存中。 一个受益于预读取的例子是随机单行UPDATE语句,这些语句通常在主库上高并发执行。DELETE语句也可能受益于这种方法,但INSERT语句则不太可能会——尤其是当顺序插入时——因为前一次插入已经使索引“预热”了。\n如果表上有很多索引,同样无法预取所有将要被修改的数据。UPDATE语句可能需要更新所有索引,但SELECT语句通常只会读取主键和一个二级索引。UPDATE语句依然需要去读取其他索引的数据以进行更新。在多索引表上这种方法的效率会降低。\n这种技术并不是“银弹”,有很多原因会导致其不能工作,甚至适得其反。只有在清楚硬件和操作系统的状况时才能尝试这种方法。我们知道有些人利用这种办法将复制速度提升了300%到400%,但我们也尝试过很多次,并发现这种方法常常无法工作。正确地设置参数非常重要,但并没有绝对正确的参数组合。\nmk-slave-prefetch是Maatkit中的一款工具,该工具实现了本节所提到的预取策略。mk-slave-prefetch本身有很多复杂的策略以保证其在尽可能多的场景下工作。但缺点是它实在太复杂并且需要许多专业知识来使用。另一款工具是Anders Karlsson的slavereadahead工具,可以从 http://sourceforge.net/projects/slavereadahead/获得。\n另一种方法在写作本书时还正在开发中,它是在InnoDB内部实现的。它可以允许设置事务为特殊的模式,以允许InnoDB执行“假”更新。因此可以使用一个程序来执行这些假更新,这样复制线程就可以更快地执行真正的更新。我们已经在Percona Server中为一个非常流行的互联网网络应用单独开发了该功能。可以去检查一下此特性现在的状态,因为在本书出版时或许已经更新过了。\n如果正在考虑这项技术,可以从一个熟悉其工作原理及可用选项的专家那里获得很好的建议。这应该作为其他方案都不可行时最后的解决办法。\n10.7.15 来自主库的过大的包 # 另一个难以追踪的问题是主库的max_allowed_packet值和备库的不匹配。在这种情况下,主库可能会记录一个备库认为过大的包。当备库获取到该二进制日志事件时,可能会碰到各种各样的问题,包括无限报错和重试,或者中继日志损坏。\n10.7.16 受限制的复制带宽 # 如果使用受限的带宽进行复制,可以开启备库上的slave_compressed_protocol选项(在MySQL 4.0及新版本中可用)。当备库连接主库时,会请求一个被压缩的连接——和MySQL客户端使用的压缩连接一样。使用的压缩引擎是zlib,我们的测试表明它能将文本类型的数据压缩到大约其原始大小的三分之一。其代价是需要额外的CPU时间,包括在主库上压缩数据和在备库上解压数据。\n如果主库和其备库间的连接是慢速连接,可能需要将分发主库和备库分布在同一地点。这样就只有一台服务器通过慢速连接和主库相连,可以减少链路上的带宽负载以及主库的CPU负载。\n10.7.17 磁盘空间不足 # 复制有可能因为二进制日志、中继日志或临时文件将磁盘撑满。特别是在主库上执行了LOAD DATA INFILE查询并在备库开启了log_slave_updates选项。延迟越严重,接收到但尚未执行的中继日志会占用越多的磁盘空间。可以通过监控磁盘并设置relay_log_space选项来避免这个问题。\n10.7.18 复制的局限性 # MySQL复制可能失败或者不同步,不管有没有报错,这是因为其内部的限制导致的。大量的SQL函数和编程实践不能被可靠地复制(本章我们已经讨论了许多这样的例子)。很难确保应用代码里不会出现这样或那样的问题,特别是应用或者团队非常庞大的时候。(22)\n另外一个问题是服务器的Bug,虽然听起来很消极,但大多数MySQL的主版本都存在着历史遗留的复制Bug。特别是每个主版本的第一个版本。诸如存储过程这样的新特性常常会导致更多的问题。\nMySQL复制非常复杂。应用程序越复杂,你就需要越小心。但是如果学会了如何使用,复制会工作得很好。\n10.8 复制有多快 # 关于复制的一个比较普遍的问题是复制到底有多快?简单来讲,它与MySQL从主库复制事件并在备库重放的速度一样快。如果网络很慢并且二进制日志事件很大,记录二进制日志和在备库上执行的延迟可能会非常明显。如果查询需要执行很长时间而网络很快,通常可以认为查询时间占据了更多的复制时间开销。\n更完整的答案是计算每一步花费的时间,并找到应用中耗时最多的那一部分。一些读者可能只关注主库上记录事件和将事件复制到中继日志的时间间隔。对于那些想了解更多细节的读者,我们可以做一个快速的实验。\n我们在本书的第一版详细描述了复制的过程和Giuseppe Maxia提供的测量高精度复制速度的方法(23)。我们创建了一个非确定性的用户自定义函数(UDF),以微秒精度返回系统时间(源代码参阅前面的“用户定义函数”):\n首先将NOW_USEC()函数的值插入到主库的一张表中,然后比较它在备库上的值,以此来测量复制的速度。\n为了测量延迟,我们在一台服务器上开启两个MySQL实例,以避免由于时钟引起的不精确。我们将其中一个实例配置为另一个的备库,然后在主库实例上执行如下语句:\nmysql\u0026gt; ** CREATE TABLE test.lag_test(** -\u0026gt; ** id INT NOT NULL AUTO_INCREMENT PRIMARY KEY,** -\u0026gt; ** now_usec VARCHAR(26) NOT NULL** -\u0026gt; ** );** mysql\u0026gt; ** INSERT INTO test.lag_test(now_usec) VALUES( NOW_USEC() );** 我们使用的是VARCHAR列,因为MySQL内建的时间类型只能精确到秒(尽管一些时间函数可以执行小于秒级别的计算),剩下的就是比较主备的差异。这里我们使用Federated表(24)。在备库上执行:\nmysql\u0026gt; ** CREATE TABLE test.master_val (** -\u0026gt; ** id INT NOT NULL AUTO_INCREMENT PRIMARY KEY** -\u0026gt; ** now_usec VARCHAR(26) NOT NULL** -\u0026gt; ** ) ENGINE=FEDERATED** -\u0026gt; ** connection='mysql://user:pass@127.0.0.1/test/lag_test',;** 简单的关联和TIMESTAMPDIFF()函数可以微秒精度显示主库和备库上执行查询的延迟。\n我们使用Perl脚本向主库中插入1 000行数据,每个插入间有10毫秒的延时,以避免主备实例竞争CPU时间。然后创建一个临时表来存储每个事件的延迟:\nmysql\u0026gt; ** CREATE TABLE test.lag AS** \u0026gt; ** SELECT TIMESTAMPDIFF(FRAC_SECOND, m.now_usec, s.now_usec) AS lag** -\u0026gt; ** FROM test.master_val AS m** -\u0026gt; ** INNER JOIN test.lag_test as s USING(id);** 接着根据延迟时间分组,可以看到最常见的延迟时间是多少:\n结果显示大多数小查询在主库上的执行时间和备库上的执行时间间隔大多数小于0.3毫秒。\n复制过程中没有计算的部分是事件在主库上记录到二进制日志后需要多长时间传递到备库。有必要知道这一点,因为备库越快接收到日志事件越好。如果备库已经接收到了事件,它就能在主库崩溃时提供一个拷贝。\n尽管我们的测量结果没有精确地显示这部分需要多长时间,但理论上非常快(例如,仅仅受限于网络速度)。MySQL二进制日志转储线程并没有通过轮询的方式从主库请求事件,而是由主库来通知备库新的事件,因为前者低效且缓慢。从主库读取一个二进制日志事件是一个阻塞型网络调用,当主库记录事件后,马上就开始发送。因此可以说,只要复制线程被唤醒并且能够通过网络传输数据,事件就会很快到达备库。\n10.9 MySQL复制的高级特性 # Oracle对MySQL 5.5的复制有着明显的改进。更多的特性还在开发中,MySQL 5.6将包含这些新特性。一些改进使得复制更加强健,例如,增加了多线程(并行)复制以减少当前单线程复制的瓶颈。另外,还有一些改进增加了一些高级特性,使得复制更加灵活并可控制。我们不会描述太多尚未GA的功能,但会讨论一些MySQL 5.5关于复制的改进。第一个是半同步复制,基于Google多年前所做的工作。这是自MySQL 5.1引入行复制后最大的改进。它可以帮助你确保备库拥有主库数据的拷贝,减少了潜在的数据丢失危险。\n半同步复制在提交过程中增加了一个延迟:当提交事务时,在客户端接收到查询结束反馈前必须保证二进制日志已经传输到至少一台备库上。主库将事务提交到磁盘上之后会增加一些延迟。同样的,这也增加了客户端的延迟,因此其执行大量事务的速度不会比将这些事务传递给备库的速度更快。\n关于半同步,有一些普遍的误解,下面是它不会去做的:\n在备库提示其已经收到事件前,会阻塞主库上的事务提交。事实上在主库上已经完成事务提交,只有通知客户端被延迟了。 直到备库执行完事务后,才不会阻塞客户端。备库在接收到事务后发送反馈而非完成事务后发送。 半同步不总是能够工作。如果备库一直没有回应已收到事件,会超时并转化为正常的异步复制模式。 尽管如此,这仍然是一个很好用的工具,有助于确保备库提供更好的冗余度和持久性。\n在性能方面,从客户端的角度来看,增加了事务提交的延时,延时的多少取决于网络传输,数据写入和刷新到备库磁盘的时间(如果开启了配置)以及备库反馈的网络时间。听起来似乎这是累加的,但测试证明这些几乎是不重要的,也许延迟是由其他原因引起的。Giuseppe Maxia发现每次提交大约延时200微秒(25)。对于小事务开销可能会比较明显,这也是预期中的。\n事实上半同步复制在某些场景下确实能够提供足够的灵活性以改善性能,在主库关闭sync_binlog的情况下保证更加安全。写入远程的内存(一台备库反馈)比写入本地的磁盘(写入并刷新)要更快。Henrik Ingo运行了一些性能测试表明,使用半同步复制相比在主库上进行强持久化的性能有两倍的改善(26)。在任何系统上都没有绝对的持久化——只有更加高的持久化层次——并且看起来半同步复制应该是一种比其他替代方案开销更小的系统数据持久化方法。\n除了半同步复制,MySQL 5.5还提供了复制心跳,保证备库一直与主库相联系,避免悄无声息地断开连接。如果出现断开的网络连接,备库会注意到丢失的心跳数据。当使用基于行的复制时,还提供了一种改进的能力来处理主库和备库上不同的数据类型。有几个选项可以用于配置复制元数据文件是如何刷新到磁盘以及在一次崩溃后如何处理中继日志,减少了备库崩溃恢复后出现问题的概率。\n我们还没有看到MySQL 5.5对复制的改进大规模地在生产环境进行部署,因此还需要进行更多的研究。\n除了上面提到的,这里简要地列出其他一些改进,包括MySQL以及第三方分支,例如Percona Server以及MariaDB:\nOracle在MySQL 5.6实验室版本和开发里程碑版本中有许多的改进。\n事务复制状态,即使崩溃也不会导致元数据失去同步(Percona Server和 ─MariaDB已经以别的形式实现了)。\n二进制日志的checksum值,用于检测中继日志中损坏的事件。\n备库延迟复制,用于替代Percona Toolkit中的 ─pt-slave-delay工具。\n允许基于行的二进制日志事件也包含在主库执行的SQL。\n实现多线程复制(并行复制)。\nMySQL5.6、Percona Server、Facebook以及MariaDB提供了三种修复方法解决了MySQL 5.0引入的GROUP COMMIT的问题。\n10.10 其他复制技术 # MySQL内建的复制并不是将数据从一台服务器复制到另外一台服务器的唯一办法,尽管大多数时候是最好的办法。(与PostgreSQL相比,MySQL并没有大量附加的复制选项,可能是因为复制功能在早期就已经引入了)。\n我们已经讨论了MySQL复制的一些扩展技术,如Oracle GoldenGate,但对大多数工具我们都不熟悉,因此无法讨论太多。但是有两个我们需要指出来,第一个是Percona XtraDB Cluster的同步复制,我们会在第12章介绍,因为它比较适合在高可用性这一章讲述。另一个是Continuent的Tungsten Replicator ( http://code.google.com/p/tungsten-replicator/)。\nTungsten是一个用Java编写的开源的中间件复制产品。它的功能和Oracle GoldenGate类似,并且看起来在未来发布的版本中将逐步增加许多复杂的特性。在写作本书时,它已经提供了一些特性,例如,在服务器间复制数据、自动数据分片、在备库并发执行更新(多线程复制)、当主库失败时提升备库、跨平台复制,以及多源复制(多个复制源到一个目标)。它是Tungsten数据库clustering suite的开源版本。\nTungsten同样实现了多主库集群,可以把写入指向集群中任意一台服务器。这种架构的实现通常都包含冲突发现与解决。这一点很难做到,并且不总是需要的。Tungsten的实现稍微做了点限制,不是所有的数据都能在所有的节点写入,每个节点被标记为记录系统,以接收特定的数据。例如,在西雅图的办公室可以拥有并写入它的数据,然后复制到休斯敦和巴尔的摩。在休斯敦和巴尔的摩本地可以实现低延迟读数据,但在这里Tungsten不允许写入数据,这样数据冲突就不存在了。当然休斯敦和巴尔的摩可以更新它们自己的数据,并被复制到其他地点。这种“记录系统”方案解决了人们需要在环形结构中频繁调整内建MySQL复制的问题。我们之前讨论的环形复制还远远不够安全或强健。\nTungsten Replicator不仅仅是嵌入或管理MySQL复制,而是直接替代它。它通过读取主库的二进制日志来获得数据更新,那里正是内建MySQL复制工作结束的地方,然后由Tungsten Replicator接管。它读取二进制日志,并抽取出事务,然后在备库执行它们。\n该过程比MySQL复制本身有更丰富的功能集。实际上,Tungsten Replicator是第一个提供MySQL并行复制支持的。虽然我们还没有看到其被应用到生产环境中,但它声称能够提供最多三倍的复制速度改善,具体取决于负载特性。基于该架构以及我们对该产品的了解,这看起来是可信的。\n以下是关于Tungsten Replicator中值得欣赏的部分:\n它提供了内建的数据一致性检查。 提供了插件特性,因此你可以编写自己的函数。MySQL的复制源代码非常难以理解并且很难去修改。即使非常聪明的程序员在试图修改时,也会引入新的Bug。因而能有种途径去修改复制而无须修改MySQL的复制代码,是非常理想的。 拥有全局事务ID,能够帮助你了解每个服务器相互之间的状态而无须去匹配二进制日志名和偏移量。 它是一个高可用的解决方案,能够快速地将一台备库提升为主库。 提供异构数据复制(例如,在MySQL和PostgreSQL之间或者MySQL和Oracle之间)。 支持不同版本的MySQL复制,以防止MySQL复制不能反向兼容。这对某些升级的场景非常有用。当升级运行得不理想时,你可能无法设计一个可行的回滚方案,或者必须升级服务器到一个并不是你期望的版本。 并行复制的设计非常适用于共享应用程序或多任务应用程序。 Java应用能够明确地写入主库并从备库读取。 得益于Giuseppe Maxia作为QA主管的大量工作,现在比以往更加简单并且更加容易配置和管理。 以下是它的一些缺点:\n它比内建的MySQL复制更加复杂,有更多可变动的地方需要配置和管理,毕竟它是一个中间件。 在你的应用栈中需要多学习和理解一个新的工具。 它并不像内建的MySQL复制那样轻量级,并且没有同样的性能。使用Tungsten Replicator进行单线程复制比MySQL的单线程复制要慢。 作为MySQL复制并没有经过广泛的测试和部署,所以Bug和问题的风险很高。 总而言之,我们很高兴Tungsten Replicator是可用的,并且在积极的开发中,稳定地释放新的特性和功能。拥有一个可替代内建MySQL复制的选择,这非常棒,使得MySQL能够适用于更多的应用场景,并且足够灵活,能够满足内建的MySQL复制可能永远无法满足的需求。\n10.11 总结 # MySQL复制是其内建功能中的“瑞士军刀”,显著增加了MySQL的功能和可用性。事实上这也是MySQL这么快就如此流行的关键原因之一。\n尽管复制有许多限制和风险,但大多数相对不重要或者对大多数用户而言是可以避免的。许多缺点只在一些高级特性的特殊行为中,这些特性对少数需要的人而言是有帮助的,但大多数人并不会用到。\n正因为复制提供了如此重要和复杂的功能,服务器本身不提供所有其他你需要的功能,例如,配置、监控、管理和优化。第三方工具可以很好地帮助你。虽然可能有失偏颇,但我们认为最值得关注的工具一定是Percona Toolkit和Percona XtraBackup,它们能够很好地改进你对复制的使用。在使用别的工具前,建议你先检查它们的测试集合,如果没有正式的、自动化的测试集合,在将其应用到你的数据之前请认真考虑。\n对于复制,应该铭记K.I.S.S(27)原则。不要按照想象做事,例如,使用环形复制、黑洞表或者复制过滤,除非确实有需要。使用复制简单地去镜像一份完整的数据拷贝,包括所有的权限。在各方面保持你的主备库相同可以帮助你避免很多问题。\n谈到保持主库和备库相同,这里有一个简短但很重要的列表告诉你在使用复制的时候需要做什么:\n使用Percona Toolkit中的pt-table-checksum以确定备库是主库的真实拷贝。 监控复制以确定其正在运行并且没有落后于主库。 理解复制的异步本质,并且设计你的应用以避免或容忍从备库读取脏的数据。 在一个复制拓扑中不要写入超过一个服务器,把备库配置为只读,并降低权限以阻止对数据的改变。 打开本章所讨论的那些明智并且安全的设置。 正如我们将要在第12章讨论的,复制失败是MySQL故障时间中最普遍的原因之一。为了避免复制的问题,阅读第12章,并尝试应用其给予的建议。你同样也应该通读MySQL手册中关于复制的章节,并了解复制如何工作以及如何去管理它。如果乐于阅读, Charles Bell et al. 所著的MySQL High Availability(O\u0026rsquo;Reilly)一书中有许多关于复制内部的有用信息。但你依然需要阅读手册!\n————————————————————\n(1) 可能有些地方将会复制备库(replica)称为从库(slave),这里我们尽量避免这种叫法。\n(2) 如果对二进制日志感到陌生,可以在第8章、本章剩下的部分以及第15章获得更多的信息。\n(3) 严格来讲这不是必需的,但我们推荐这么做,稍后我们会解释为什么。\n(4) 事实上,正如之前从SHOW MASTER STATUS看到的,真正的日志起始位置是98,一旦备库连接到主库就开始工作,现在连接还未发生。\n(5) 语句在无限循环中来回传递也是多服务器环形复制拓扑结构中比较有意思的话题之一,后面我们会提到。要尽量避免环形复制。\n(6) 如果使用的是基于语句的复制,就会有这样的问题,但基于行的复制方式则不会(另一个远离它们的理由)。\n(7) 从技术上讲这并非正确的。但如果有重复的服务器ID,它们将陷入竞争,并反复将对方从主库上踢出。\n(8) 事实上这些问题经常一周发生三次,并且我们也发现需要好几个月才能解决这些问题。\n(9) 一些,但不是全部——我们可以吹毛求疵,并指出任何你可以想象的漏洞。\n(10) 可以通过设置SQL_LOG_BIN=0来暂时禁止记录二进制日志而无须停止复制。一些语句,例如Optimize TABLE,也支持LOCAL或者NO_WRITE_TO_BINLOG这些停止日志的选项。\n(11) 也许应该说,是更明智的特例。\n(12) 从MySQL Bug 35178和62829开始查阅,总地来说,如果使用的是不标准的存储引擎特性,最好去看看那些打开或者关闭的受影响的Bug。\n(13) 可以使用Percona的工具集中的pt-heartbeat来创建一个粗糙的全局事务ID。这样可以很方便地在多个服务器上寻找二进制日志的位置。因为“心跳表”本身就记录了大概的二进制日志位置。\n(14) 我们明确地使用*/bin/ls*以避免启用通用别名,它们会为终端着色添加转义码。\n(15) 如果复制线程总是在运行,你可以使用服务器的uptime来代替CONNECTED_TIME的一半。\n(16) 如果你正在使用非事务型存储引擎,不首先调用STOP SLAVE就关闭服务器是很不妥当的。\n(17) 这是有可能的,即使MySQL在事务提交前并不记录任何事件。具体参阅“混合事务型和非事务型表”。另外一种场景是主库崩溃后恢复,但没有设置innodb_flush_log_at_trx_commit的值为1,所以可能丢失一些更新。\n(18) 正如前面提到的,pt-heartbeat的心跳记录能够很好地帮助你找到你正在查找的事件的大约位置。\n(19) 也许你想把它保存在服务器中,这不完全是玩笑,可以给ID列添加一个唯一索引。\n(20) 我们已经有人尝试各种方法来解决这个问题,但对于基于语句的复制并没有安全的临时表创建方法。起码一段时期是这样,不管你如何认为,起码我们已经证明了这是不可行的。\n(21) pt-find——另一个Percona Toolkit工具——通过\u0026ndash;connection-id和\u0026ndash;server-id选项能够轻易地移除伪临时表。\n(22) 最近的MySQL版本没有forbid_operations_unsafe_for_replication选项,但它确实对一些不安全的事情起到了警示,甚至拒绝。\n(23) 查看 http://datacharmer.blogspot.com/2006/04/measuring-replication-speed.html。\n(24) 顺便说一下,这也是一些作者唯一一次使用Federated存储引擎。\n(25) 参阅 http://datacharmer.blogspot.com/2011/05/price-of-safe-data-benchmarking-semi.html。\n(26) 参阅 http://openlife.cc/blogs/2011/may/drbd-and-semi-sync-shootout-large-server。\n(27) Keep It Simple, Schwartz!总之一些人认为这是K.I.S.S的含义。\n"},{"id":154,"href":"/zh/docs/culture/%E5%8F%A4%E4%BB%A3%E5%A4%A9%E6%96%87%E5%8E%86%E6%B3%95%E8%AE%B2%E5%BA%A7/%E9%99%84%E5%BD%95-%E9%99%84%E8%A1%A8-%E5%90%8E%E8%AE%B0/","title":"附录-附表-后记","section":"古代天文历法讲座","content":" 附录 # 西周金文“初吉”之研究 # 一、传统解说难于否定\n西周行用朔望月历制,朔与望至关重要。朔称初吉、月吉,或称吉,又叫既死霸(取全是背光面之义,死霸指背光面),或叫朔月。这种种名称,反映了周人对月相的重视以及朔日在历制中的特殊地位。\n传统的解说,初吉即朔。\n《诗·小明》“正月初吉”,毛传:初吉,朔日也。\n《国语·周语》“自今至于初吉”,韦昭注初吉:二月朔日也。\n《周礼》“月吉则属民而读邦法”,郑注月吉:每月朔日也。\n《论语》“吉月必朝服而朝”,孔曰:吉月,月朔也。\n《诗·十月之交》“朔月辛卯”,唐石经作“朔日辛卯”。\n《礼记·祭义》:“朔月月半,君巡牲。”\n《礼记·玉藻》“朔月大牢”,陈澔《礼记集说》:朔月,月朔也。\n日本竹添光鸿《毛诗会笺》云:古人朔日称朔月。《仪礼》《礼记》皆有朔月之文。《尚书》或称元日、上日而不曰朔日。即望亦但曰月几望或既望而不曰望日,故知经文定当以朔月为是也。凡月朔皆称朔月。《论语》亦以月吉为吉月。古人多倒语,犹《书》之“月正元日”乃正月元日也。\n《周礼》“正月之吉”,郑注:吉谓朔日。\n《周礼》“及四时之孟月吉日”,郑注:四孟之月朔日。\n郑玄作为两汉经学之集大成者,对朔为吉日的认识是十分明确的,或称月吉,或称吉日,或称吉,都肯定了朔为吉日这一点。\n朔即月初一,故称初吉,亦属自然,这与望为吉日亦相对应。朔望月历制,朔为吉日,望亦为吉日。《易·归妹》“月几望,吉”可证。\n毛传释初吉为朔日,韦昭注《国语》“初吉”为朔日,反映古人对“初吉”的正确认识。\n尤其当注意的是,初吉为朔的解说,两千年来没有任何一位严肃的学者持有异议。\n我们没有理由不尊重文献。应当说,传统对于初吉的解说是难于否定的,是不容否定的。\n二、朔望月历制\n西周是明白无误的朔望月历制,绝对不是什么“朏为月首”。\n我们从载籍文字中可以找到若干证据:\n《周礼·大史》“掌建邦之六典,以逆邦国之治。……正岁年以序事,颁之于官府及都鄙。(郑注:中数曰岁,朔数曰年。中朔大小不齐,正之以闰若今时历日矣。定四时,以次序授民时之事。)颁告朔于邦国。(郑注:天子班朔于诸侯,诸侯藏之于祖庙。至朔,朝于庙,告而受行之。郑司农云,以十二月朔布告天下诸侯。)”\n这里的告朔之制,当然也包括西周一代。依郑玄说,岁指回归年长度(阳历),年指十二个朔望月长度(阴历),两者不一致,添加闰月来协调,这就是周代的阴阳合历体制。\n西周一代,“保章氏掌天星以志星辰日月之变动”,强调天象的观察与记录;“冯相氏掌十有二岁,十有二月,十有二辰”(《周礼》),侧重在历术的推求。\n《礼记·玉藻》:“天子听朔于南门之外。闰月则阖门左扉,立于其中。”陈澔《集说》引“方氏曰:天子听朔于南门,示受之于天。诸侯听朔于太庙,示受之于祖。原其所自也”。\n历术是皇权的象征,掌握在周天子手中,天子于南门从冯相氏得每年十二个月朔的安排,然后颁朔于诸侯,诸侯藏之祖庙。至朔,朝于庙(即“听朔于太庙”),告而受行之。历术推求的依据是天象,所以“示受之于天”,“原其所自也”。\n《逸周书·史记解》“朔望以闻”,是记周穆王时事。朔望月历制是明明白白的。\n《礼记·祭义》“朔月月半,君巡牲”,这当然是说,初一与十五,人君巡视之。这难道不是朔望月的明证?\n《吕氏春秋》保存了先秦的若干旧说,上至三皇五帝,史料价值不可忽视。《贵因》载:“夫审天者,察列星而知四时,因也。推历者视月行而知晦朔,因也。”\n视月行,就是月相的观察。干什么?确定晦朔而已。很明白,观察月相就是为了确定一年十二个月朔的干支,以“颁告朔于邦国”。\n《逸周书·宝典解》“维王三祀二月丙辰朔”,历日清清楚楚。过去说此篇是记武王的。事实上,历日唯合成王亲政三年,《宝典解》反映了西周初期朔望月历制。《逸周书》成书于西周以后,而这个历日当是前朝的实录,绝不是后人的伪造或推加。这是“朏为月首”说无法作出解释的。\n《汉书·世经》云:“古文《月采》篇曰‘三日曰朏’。”师古注:《月采》,说月之光采,其书则亡。——这也许是记录月相的专著,可惜我们已不能见到了。刘歆是见过的,他持定点说当有充分依据。《月采》明确朏是初三。“朏为月首”是没有依据的。\n大量出土的西周器物证实,西周历制是朔望月而不是“朏为月首”。\n《作册令方彝》:隹八月辰在甲申……丁亥……;隹十月月吉癸未……甲申……乙酉……”“辰在××”是周人表达朔日的一种固定格式,出土器物已有二十余例,校比天象无一不是朔日。推比历朔知:八月甲申朔,初四丁亥;九月甲寅朔(或癸丑朔);十月癸未朔,甲申初二,乙酉初三。“月吉癸未”即朔日癸未,与文献记载亦相吻合。《令方彝》的八月、十月,中间无闰月可插,一个月就只有一个朔日即一个月吉,这怎么能“说明西周时代每个月都可能有若干个吉日”呢?\n西周金文记载初吉尤多,初吉即朔,也只能证明西周是朔望月制而不是“朏为月首”。\n常识告诉我们,历术是关于年月日的协调。日因于太阳出没,白昼黑夜,是计时的基本单位;年以太阳的回归年长度为依据,表现为寒来暑往,草木荣枯,《尧典》“期三百有六旬有六日,以闰月定四时成岁”;而月亮的隐现圆缺,只能靠肉眼观察。西周制历,尚未找到年月日的调配规律,只能随时观察随时置闰,一年十二个月朔的确定也靠“观月行”。这就是西周人频频记录月相的缘由。\n日与年易于感知,观象授时的主要内容是观察月相,两望之间必朔,两朔之间必望,朔望月也是不难掌握的。何况司历专职,勤劬观察,不会将初一说成初二,更不会说成初三。肉眼观察的失朔限度也只在半日之内。\n董作宾先生以为,知道日食就会知道朔,知道月食就会知道望。朔望月历制当追溯到殷商。\n持“朏为月首”说者以为,“朔”字在西周后期才出现,猜想西周前期当是“朏为月首”。殊不知,殷商后期以来,朔望的概念十分明确,表达朔日的词语甚多,初吉为朔,既死霸为朔,月吉(吉月)为朔,“辰在××”为朔,并非一定要用“朔”字不可。\n西周一代,未找到协调年月日的规律,月相的观察就显得特别重要,文献以及出土器物有关月相的记载也就特别的多。到了春秋中期以后,十九年七闰已很明确,连大月设置也逐渐有了规律,朔日的推演已不为难事。所以,鲁文公“四不视朔”,“子贡欲去告朔之饩羊”,不仅证实西周以来的告朔礼制已经走向衰败没落,还反映出四分术的推演已为司历者大体掌握。历术已由观象授时上升到推步制历,已从室外观月步入室内推算。这样,月相的观察与记录自然就不那么重要了。这就是春秋以后,作为月相的“既死霸”“既生霸”“既望”在金文中基本消失的原因。\n三、初吉即朔\n西周金文大量使用“初吉”,凡可考知的,无一不是朔日。\n有的器铭,年、月、月相、日干支俱全,校比天象,十分方便。利用张培瑜先生《中国先秦史历表》,便可一目了然。\n例1,攸从鼎:隹卅又一年三月初吉壬辰。(郭沫若:《两周金文辞大系图录考释》,下简称《大系录》,118)\n校比公元前848年厉王三十一年天象,丑正,三月壬辰朔。\n例2,无其簋:隹十又三年正月初吉壬寅。(《大系录》107)\n校比公元前829年共和十三年天象,丑正,正月壬寅朔。\n例3,虢季子白盘:隹王十有二年,正月初吉丁亥。(《大系录》88)\n校比公元前816年宣王十二年天象,子正,正月丁亥朔(定朔戊子03h49m,合朔在后半夜,失朔不到四小时)。\n例4,叔尃父:隹王元年六月初吉丁亥。(《考古》65.9)\n校比公元前770年平王元年天象,丑正六月丁亥朔(定朔戊子02h01m,失朔仅两小时)。\n厉王以前的若干铜器,因王年尚无共识的结论,仅举几例说明。\n例5,谏簋:隹五年三月初吉庚寅。(《大系录》101)\n校比公元前889年夷王五年天象,丑正,三月庚寅朔。\n例6,王臣簋:隹二年三月初吉庚寅。(《文物》80.5)\n校比公元前915年懿王二年天象,丑正,三月庚寅朔。\n例7,柞钟:隹王三年四月初吉甲寅。(《文物》61.7)\n校比公元前914年懿王三年天象,丑正,四月甲寅朔。此器与王臣簋历日前后连贯,丝毫不乱,列为同一王世之器,更可证初吉即朔。\n总之,初吉即朔,这是金文历日明确记载的,绝不是泛指某月中的任何一日。\n四、关于静簋\n刘雨先生在《再论金文“初吉”》(《中国文物报》,1997-04-20)中把静簋历日作为立论的主要依据,以此否定初吉为朔,这就有必要重点讨论了。\n刘先生说:西周金文中……只有静簋记有两个“初吉”,而且相距不到三个月,没有历律和年代等未知因素干扰,是西周金文中最能说明“初吉”性质的珍贵资料。——这就是他为什么特别重视静簋的原因。\n过去我将静簋视为厉王三十五年器,“六月初吉丁卯”合公元前844年天象,“八月初吉庚寅”合公元前843年天象,两个初吉间隔一年,与何幼琦先生的认识暗合。刘雨先生此文给我以启发,两初吉确实当为一年之内的两初吉,不必间隔一年。不过,两初吉的解说都当指朔日,而不是泛指某月中任何一日。\n排比静簋历朔知:六月丁卯朔,七月当丙申朔(或丁酉朔),八月丙寅朔。\n这个“丙寅”,铸器者并不书为丙寅,而是书为吉日庚寅。这就是静簋“六月初吉丁卯……八月初吉庚寅”的由来。\n我们在研究金文历日中发现,除了丁亥,古人亦视庚寅为吉日。一部《春秋》,经文记有八个庚寅日,几乎都系于公侯卒日,《左传》十一次记庚寅日,几乎都涉及戎事。大事择庚寅必视庚寅为吉利。至于西周铜器铭文,书庚寅者甚夥。查厉宣时代器铭,其书庚寅者多取其吉利,实非庚寅日而多为丙寅或其他寅日。\n例1,盘:隹廿又八年五月既望庚寅。(《大系录》117)\n此器为宣王二十八年器,校比公元前800年天象,冬至月朔甲寅,建寅,五月辛亥朔,既望十六丙寅。盘书为“既望庚寅”,取其吉利。\n例2,克钟:隹十又六年九月初吉庚寅。(《大系录》93)\n例3,克:隹十又八年十又二月初吉庚寅。(《大系录》112)\n克钟与克,作器者同为一人。克钟历日合宣王十六年(前812年)天象,九月庚寅朔。据历朔规律知,有十六年九月初吉庚寅,就不得有十八年十二月初吉庚寅,两器历日彼此不容。现已肯定克钟为宣王器,克历日又不合厉王,只能定为宣王器。\n校比宣王十八年(前810年)天象,建子,十二月戊寅朔。克书戊寅朔为“初吉庚寅”,取庚寅吉利之义。似乎只有这唯一的解说,历日方可无碍。\n金文“庚寅”往往并非实实在在的庚寅日,为取庚寅吉利之义,凡丙寅、戊寅皆可书为庚寅。这就是我们在研究铜器历日中所归纳出来的“庚寅为寅日例”。(见《铜器历日研究》,贵州人民出版社,1999。)\n以此诠释静簋两个初吉历日,并无任何扞格难通之处。只能证明初吉即朔,初吉并不作其他任何解说。\n五、关于师兑簋\n刘雨先生说,静簋并非孤证。又举出师兑簋两器作为初吉非朔的佐证,以此否定传统说法。为了弄清事实真相,看来师兑簋两器也有讨论的必要。\n师兑簋甲:隹元年五月初吉甲寅。(《大系录》146)\n师兑簋乙:隹三年二月初吉丁亥。(《大系录》150)\n按:排比历朔,元年五月甲寅朔,三年二月不得有丁亥朔,只有乙亥朔。从元年五月朔到三年二月朔,其间经21个月,12个大月,9个小月,计621日。干支周60日经十轮,余21日。甲寅去乙亥,在21日。可见任何元年五月甲寅朔到三年二月不可能有丁亥朔。甲寅去丁亥33日,显然不合。师兑簋两器,内容彼此衔接,不可能别作他解。三年二月初吉丁亥,实为二月初吉乙亥。是乙亥书为丁亥。书丁亥者,取其大吉大利之义。\n六十个干支日,丁亥实为一个最大的吉日,故金文多用之。器铭“初吉丁亥”,若以丁亥朔释之,则往往不合。若以乙亥朔或其他亥日解说,则吻合不误。\n《仪礼·少牢馈食礼》“来日丁亥”郑注:“丁未必亥也,直举一日以言之耳。《禘于太庙礼》曰‘日用丁亥’,不得丁亥,则己亥、辛亥亦用之。无则苟有亥焉可也。”郑玄对丁亥的解说再明白不过了,丁亥当以亥日为依托。\n再举一例,伊簋:隹王廿又七年正月既望丁亥。(《大系录》116)\n按:既望十六丁亥,必正月壬申朔。伊簋,郭氏《大系录》,吴其昌氏、容庚氏列为厉王器,董作宾氏列为夷王器,均与实际天象不合。校比宣王二十七年(公元前801年)天象,冬至月朔庚申,建子,正月庚申朔,有既望十六乙亥。器铭书为“既望丁亥”乃取丁亥吉祥之义。\n除此之外,大簋、大鼎、师簋诸器都能说明问题。这就是我们在研究金文历日条例中所定下的“丁亥为亥日例”。(见《铜器历日研究》)\n遍查西周铜器历日,唯丁亥为多,乙亥次之,庚寅又次之。细加考察,乙亥实为吉日丁亥与吉日庚寅之桥梁。至迟商代后期,便视丁亥为吉日。从月相角度说,朔为吉日,望亦为吉日,而真正的月满圆多在十六,故既望亦为吉日。故有初吉乙亥,亦有既望乙亥。有初吉乙亥,必有十六既望庚寅,是庚寅亦得为吉日。故有既望庚寅,又有初吉庚寅。金文中,凡丁亥、乙亥、庚寅,不可都视为实指。凡亥日,或书为丁亥,也可书为乙亥;凡寅日,可书为庚寅,皆取吉利之义。\n总之,在涉及出土器物铭文历日的研究中,我始终觉得,要做到文献材料、器物铭文与实际天象(历朔干支)紧密联系起来,做到“三证合一”,才会有可信的结论。\n六、铜器专家如是说\n这里,我还要引用西北大学张懋镕先生的见解,以正视听。\n他说:“初吉是否为月相语词,恐怕还得由西周金文自身来回答。”\n他列举了鼎、免簋、免盘、簋、方尊等五器铭文之后说:“以上五器记载周王(王后)对臣属的赏赐或册命赏赐,有时间、有地点,其时日自然是具体的某一天。与其他器不同的是,初吉后未有干支日,显而易见,此初吉便是周王(王后)赏赐或册命赏赐的那一天。在免簋中,昧爽在初吉之后,系指初吉的清晨,所以这个初吉日一定是定点的,否则无从附着。昧爽又见于小盂鼎,与免簋相较,益可证明初吉是固定的一天。”\n“不仅初吉是指具体的某一天,其他月相语词也具有这样的特性。”他列举了遹簋、公姞鬲、师趛、七年趞曹鼎之后接着说:“七年趞曹鼎与免簋相类,其‘旦’当指既生霸这一日的早晨。可见,当月相语词后面带有干支日时,干支日就是事情发生的这一天;如果月相语词后面不带干支日,事情就发生在初吉或既生霸、既望、既死霸这一天。”他接着说:\n金文月相词语之所以是定点的,原因在于:\n1.凡带有月相语词的金文,不论其长短,都是记叙文。既为记叙文,不可缺少的就是时间要素,而月相语词正是表示时间的定位。时间必须是具体而不能含糊的。\n2.上举免簋、簋、方尊、七年趞曹鼎属于册命赏赐金文。其内容是周王(王后)对器主职官的任命,任命仪式之隆重,程序之规范,是不言而喻的。册命赏赐关乎器主一生的命运及其家族的兴旺,所以令器主难以忘怀,常常镌之于铜器之上,以求天子保佑,子孙永宝。既然如此,发生这一重大事情的日子是不会被忘记的。上述四例中的初吉和既生霸,自然是某年某月的某一天。\n册命金文中恒见“初吉”,那是因为册命一般在月初进行。说初吉可以是月中的任何一天,不仅悖于情理,也有违于金文本身。\n殷周金文发展的历程,也证明了这一点。先看晚殷金文:\n1.宰椃角:庚申,王才(在)阑。王各(格)宰椃从。易(锡)贝五朋。用乍(作)父丁彝。才(在)六月,隹王廿祀翌又五。\n2.小臣艅尊:丁巳,王省夔且。王易(锡)小臣艅夔贝。隹王来正(征)人方。隹王十祀又五,日。\n3.鬲:戊辰,弜师易(锡)户贝。才(在)十月。隹王廿祀。\n其特点是干支纪日在铭首,年、月在铭末。方法同于殷代甲骨文。显然,在器主眼中,最重要的是被赏赐的具体时日,纪日为主,年、月尚在其次,故常常省去年、月,只保留干支日。这一点在西周早期金文中表现得很充分:\n1.利簋:武王征商,隹甲子朝。\n2.大丰簋:乙亥,王又大丰,王凡三方。\n3.新邑鼎:癸卯,王来奠新邑。\n4.士卿尊:丁巳,王才新邑。\n5.保卣:乙卯,王令保及殷东国五侯,兄六品。……才二月既望。\n成王之后,铭文加长,但事情发生的具体日子是一定会写明的。偶有纪月不纪日者,是有其他原因的。需要说明的是,西周晚期册命金文在月相词语后系干支日,不系者似乎未有。或许是随着时代变迁,金文体例更为整饬的缘故吧。\n我用治铜器的专家张懋镕先生这段文字作为关于金文“初吉”研究的结尾,恐怕是最为恰当不过的了。\n再谈金文之“初吉” # 再谈金文之“初吉”\n一年多来,我从“断代工程”简报上陆续获悉李学勤先生关于“初吉”以及月相名词的解说,如:“吉的意义是朔。月吉(或吉月)就是朔日,因而是定点的。《诗》毛传暗示初吉是定点的”(第41期);“经李学勤先生指示,我们相信《武成》《世俘》诸篇与金文中月相术语有不同的定义,而《武成》《世俘》诸篇的月相采李先生的定点解读”(第44期);“李学勤等从金文研究和文献学的角度都认为定点说难于成立”(第38期);李先生在《“天大曀”与静方鼎》中说“月吉癸未初三日,初吉庚申初四日”(第62期);“这样的吉日多数应发生在每月的月初,但也有一部分会发生在月中或月末。……李学勤、张长寿先生在总结发言中肯定了这一点”(第57期);李学勤先生金文历谱方案:“初吉己卯,先实朔二日。初吉壬辰,初七日。初吉辛巳,初五日。初吉庚戌,先实朔二日。初吉丁亥,初三日。初吉戊申,初九日。初吉丁亥,先实朔一日。初吉庚寅,初一日。初吉庚寅(戌),初四日”(第53期);“在本次会议上,李学勤先生放弃了原来认为‘初吉’表朔日为月相的观点。李学勤先生认为,初吉:有初一(含先实朔一、二日者)、初四、初五、初七、初九、初十等日;既生霸:有初三、初五、初十、十四等日;既望:有十八、十九、二十等日;既死霸:有二十一、二十四、二十八、二十九等日”(第52期)。……这些不一的看法,给人总的感觉是:李先生在月相问题上摇摆不定,陷入一种“二元论”的尴尬境地——又定点又不定点,或者典籍《武成》《世俘》《诗毛传》定点而金文中不定点。因为李先生长期信奉“四分一月”说,要改从定点说就非常之难。最终他放弃了古文献的定点说,而以金文历日的主观解说为依据,走上了“两分”说(既生霸指上半月,既死霸指下半月。见简报第57期),比“四分一月”说走得更远了。在这个基础上主持“夏商周断代工程”探求西周王年,其结论就可想而知了。\n最近,从人大复印资料上读到李学勤先生《由蔡侯墓青铜器看“初吉”和“吉日”》一文,李先生认为,“初吉”不一定是朔日,但包括朔,必在一月之初(不定点的);而“元日”“吉日”与“吉”均同义,即为朔日(定点的)。\n两年前我写有《西周金文“初吉”之研究》一文,载《考古与文物》1999年3期,又收入个人专著《铜器历日研究》(贵州人民出版社,1999)一书,认定“初吉”是指朔日,别无他解。现就李先生文章中涉及关于“初吉”的解说,再谈一下个人的看法。\n一、关于蔡侯墓青铜器的历日 # 李先生文章是从1955年发掘的蔡侯墓入手,论述“初吉”和“吉日”的。\n蔡侯编钟云:“惟正五月初吉孟庚,蔡侯□曰:余惟(虽)末少子,余非敢宁忘,有虔不易,(左)右楚王……建我邦国。”\n李先生认为此器是蔡平侯作器,作于鲁昭公十三年,推出夏正五月戊戌朔,初庚即五月第一个庚日庚子,是初三。\n如果视此器为蔡昭侯作器,结论就大不一样。昭侯乃悼侯之弟,自称“少子”;欲结楚欢,追怀楚平王“建我邦国”亦合情理。楚平王立蔡平侯,“平侯立而杀隐太子,故平侯卒而隐太子之子东国攻平侯子而代立,是为悼侯。悼侯三年卒,弟昭侯申立”。蔡国的动乱,发生在楚平王的眼皮下,昭侯立,不对楚王表忠心是不可能的。\n此器作于昭侯二年(前517年)。“五月初吉孟庚”当指周正五月庚寅朔日。蔡乃姬姓国,用周正当属常理。初吉指朔当无疑义。\n又,蔡侯申盘铭:“元年正月初吉辛亥,蔡侯申虔恭大命……肇天子,用诈(作)大孟姬……敬配吴王……”\n这是指蔡与吴结婚姻之好。李先生说:“唯一合理的解释,是‘元年’为吴王光(阖闾)的元年(前514年),即蔡昭侯五年,鲁昭公二十八年。”于是便推算出,初吉辛亥是初八日。\n这个“元年”如果不是指吴王光元年,说法又不大一样了。\n蔡昭侯即位之初,为避免招祸,不得不结好楚平王。到楚昭王时代,蔡昭侯被“留之楚三年”,“归而之晋,请与晋伐楚。……楚怒,攻蔡,蔡昭侯使其子为质于吴,以共伐楚。冬,与吴王阖闾遂破楚入郢”。这一年,正是陈“怀公元年,吴破楚”。\n昭侯怒楚,“请与晋伐楚”,招致“楚怒,攻蔡”,才与吴结盟,不仅“使其子为质于吴”,还于次年初嫁大孟姬与吴王,选定的日子就是“正月初吉辛亥”。\n这与陈怀公又有什么瓜葛呢?这得从陈蔡的关系上看。陈为妫姓国,在蔡之北,相与为邻。《史记》载:“齐桓公伐蔡,蔡败。南侵楚,至召陵。还过陈,陈大夫辕涛涂恶其过陈,诈齐令出东道。”这是明白无误的唇齿相依的关系。又《史记》载“(蔡)哀侯娶陈”,“(陈)厉公取蔡女”。陈蔡彼此嫁娶,有婚姻关系。楚国灭蔡灭陈,又复蔡复陈,道出了陈蔡的休戚与共。又,公子光“败陈蔡之师”,暗示陈蔡有军事同盟关系。总之,陈蔡始终是坐在一条船上的。到楚昭王时代,楚攻蔡,蔡共吴伐楚,蔡昭侯自然要把新即位的陈怀公拉过来。蔡昭侯嫁大孟姬与吴王,当是通过陈怀公从中拉线。陈怀公在蔡与吴的合婚上是起了重要作用的。昭侯作器,一方面称颂吴王(肇天子),一方面又用陈怀公元年记事,自有他的良苦用心,希望把陈国拉入同一阵容以对付楚国。\n陈怀公元年即鲁定公五年,吴王光十年,蔡昭侯十四年。此时的蔡已与楚彻底决裂,完全倒向了吴国一边。是年周正元月辛亥朔,初吉仍指朔。足见陈蔡均用周正,而不是依附楚国用夏正。\n为什么不必像李学勤先生理解为“吴王光元年”呢?吴王僚八年“吴使公子光伐楚。……因北伐,败陈蔡之师”,“九年(蔡昭侯元年)公子光伐楚”。昭侯初年绝不能与吴国友好。吴王光元年(前514年),蔡昭侯五年,楚昭王二年,蔡与楚还维持着友好关系。到昭侯十年(公子光六年),蔡侯还“朝楚昭王”,还“持美裘二,献其一于昭王,自衣其一”。结果得罪子常,招祸,“留之楚三年”。归蔡之后,昭侯并未亲近吴国,而是“之晋,请与晋伐楚”。可见,蔡昭侯十三年之前并未与吴王光结为婚姻,这个“元年”显然与吴王光无涉。\n再看吴王光鉴、吴王光编钟,均有“吉日初庚”。诚如李先生言,“所叙乃吴王嫁女于蔡之事”,所指乃公元前505年,周正五月庚戌朔。“吉日初庚”是五月初一。\n稍加理顺:公元前506年蔡昭侯十三年,楚怒,攻蔡,蔡昭侯使其子为质于吴,以共伐楚。冬,与吴王阖闾遂破楚入郢。\n公元前505年,蔡昭侯十四年,吴王光十年,陈怀公元年“正月初吉辛亥”(朔日辛亥),昭侯嫁长女给吴王光。五月吉日初庚(朔日庚戌)吴王嫁女于蔡。\n这就是蔡侯墓青铜器涉及的几个历日,铭文所叙,与文献所记吻合。初吉为朔是定点的,并不指朔前或初三、初五或初十。\n二、关于“准此逆推上去” # 李先生文章还引用张永山先生论文的话:“‘初吉’的含义自然是继承西周而来,准此逆推上去,当会对探讨西周月相的真实情况有所裨益。”\n因为李先生认为,蔡侯墓铜器说明,“初吉”不一定是朔日,但必在一月之初,合于王国维先生之说或类似学说。引用张永山的文字,不过也是“准此逆推上去”,西周时代的“初吉”自然也合于王国维先生的“四分一月”说,最终还是回到了他信奉的“月相四分”说的原位。\n这个“准此逆推上去”,貌似有理,实则是以今律古的不可取的手法。\n西周的月相记载,限于观象授时,只能是定点的,失朔限也只在四分术的499分(一日940分)之内,不可能有什么游移。为了证明西周月相干支有两天、三天的活动,有人便引用东汉《说文》“承大月二日,小月三日”关于“朏”的解说,或初二,或初三,有两天的活动。又引用刘熙《释名》释“望”,“月大十六日,小十五日”。或十六,或十五,有两天的活动。殊不知,这是汉代使用四分术推步而导致历法后天的实录。西周人重视月相,肉眼观察,历不成“法”,不得后天。承大月承小月是汉代之说,“准此逆推上去”,以之律古,认为西周一代必得如此,则无根据。\n以蔡侯墓铜器而言,时至春秋后期,“五行说”早已兴起,即使“初吉”可别作解说,也不可准此逆推上去。因为“五行说”是以纪日干支为基础,利用五行相生相克确定吉日与非吉日。准此,则一月内有多个吉日,终于形成了时至今日的流行观念,“初吉”的含义便只能是一月的第一个吉日了。\n从蔡侯墓铜器历日考求,“初吉”仍确指朔日,说明还没有受到“五行说”的影响。尽管如此,还是不必“准此逆推上去”。宁可谨严,不可宽漫。\n再谈吴虎鼎 # 朱凤翰先生主编的《西周诸王年代研究》(贵州人民出版社,1998)列有长安县文管会所藏吴虎鼎,铭文历日是:“唯十有八年十有三月既生霸丙戌,王在周康宫宫。”我注意到李学勤先生在该书《序》中说:“吴虎鼎作于十八年闰月,而同时出现夷王、厉王名号,其系宣王标准器断无疑义。”\n1998年12月我写有《吴虎鼎与厉王纪年》,此文收入我的《铜器历日研究》一书,只是未在刊物上公开发表过罢了。\n最近读到《文津演讲录(二)》中李先生的文章,文中说道:“特别是新发现了一件没有异议的宣王时代的青铜器吴虎鼎,它是周宣王十八年的十三月(是个闰月)铸造的,这年推算正好是闰年。”(该书112页)\n吴虎鼎记录的厉王十八年十三月天象,李先生视为宣王十八年十三月铸造的。李先生还说过:“铭中有夷王之庙,又有厉王之名,所以鼎作为宣王时全无疑义,因为幽王没有十八年,平王则已东迁了。”(《吴虎鼎考释》,载《考古与文物》1998年第3期)进一步,作为“夏商周断代工程”研究的主要依据之一——所谓“支点”,被大加利用,牵动就太大。正因为这样,我就不得不再加辨析,以正是非。\n一、宣王十八年天象 # 月相定点,定于一日。既生霸为望为十五,既生霸丙戌则壬申朔。查看宣王十八年实际天象,加以比较,就可以明了。按旧有观点,宣王十八年是公元前810年;按新出土眉县四十二年、四十三年两器及其他宣王器考知,宣王元年乃公元前826年,十八年是公元前809年。公元前810年实际天象是:子月癸丑71(癸丑02h56m)、丑月壬午、寅月壬子、闰月辛巳……实际用历,建子,正月癸丑、二月壬午……十二月丁丑、十三月丁未(括号内是张培瑜先生《中国先秦史历表》所载定朔的时(h)与分(m))。\n公元前809年实际用历,子正月丙子915(丁丑05h27m),二月丙午……十一月壬申、十二月辛丑764(壬寅02h12m)。\n这哪里有“十八年十三月壬申朔”的影子?除非你将月相“既生霸”胡乱解释为十天半月,才有可能随心所欲地安插。这岂不是太随意了吗?\n还有,公认的十八年克 是宣王器,历日是“隹十又八年十又二月初吉庚寅”。如果吴虎鼎真是宣王十八年器,这个“十三月既生霸丙戌”与“十二月初吉庚寅”又怎么能够联系起来呢?月相定点,“十二月庚寅朔”与“十三月壬申朔”风马牛不相及,怎么能够硬扯在一起呢?丙戌与庚寅相去仅四天,就算你把初吉、既生霸说成十天半月,两者还是风马牛不相及。这就否定了吴虎鼎历日与宣王十八年有关。\n二、厉王十八年天象 # 我们再来看看厉王十八年天象,司马迁《史记》明示,厉王在位三十七年,除了以否定司马迁为荣的少数史学家外,自古以来并无异议。共和元年是公元前841年,前推37年,厉王元年在公元前878年,厉王十八年乃公元前861年。\n公元前861年实际天象是:子月戊申756(己酉05h27m)、丑月戊寅315、寅月丁未814、卯月丁丑373……亥月癸酉605、(接公元前860年)子月癸卯161、丑月壬申660(癸酉04h 20m )、寅月壬寅219(壬寅17h 32m )……\n厉王十八年(公元前861年)实际用历,建丑,正月戊寅、二月丁未、三月丁丑……十二月(子)癸卯、十三月(丑)壬申。——这个“十三月壬申朔”,就是吴虎鼎历日“十三月既生霸丙戌”之所在。\n我们说,吴虎鼎历日合厉王十八年天象,与宣王十八年天象绝不吻合。\n三、涉及的几个问题 # 一件铜器上的历日,它的具体年代只能有一个,唯一解。为什么说法如此不一致呢?\n其一,对月相的不同理解,就是分歧之所在。\n自古以来,月相就是定点的,且定于一日。月相后紧接干支,月相所指之日就是那个干支日。春秋以前,历不成“法”,也就是说没有找到年、月、日的调配规律,大体上只能“一年三百又六旬又六日,以闰月定四时成岁”。年、月、日的调配只能靠“观象日月星辰,敬授民时”。观象,包括星象、物象、气象,而月亮的盈亏又是至关重要的。月缺、月圆,有目共睹,可借以确定与矫正朔望与置闰。在历术未进入室内演算之前,室外观象就是最重要的调历手段,所以月相记录频频。这正是古人留给我们的宝贵遗产。进入春秋后期,人们已掌握了年、月、日调配的规律,有了可供运算的四分历术,即取回归年长度36514日作为历术基础来推演历日,室外观象就显得不那么重要了,月相的记录自然也就随之逐步消失。\n铜器上以及文献上的月相保留了下来,后人就有一个正确理解的问题。\n西周行用朔望月历制,朔与望至关重要。朔称初吉、月吉,或称吉,又叫既死霸,或叫朔月。传统的解说,初吉即朔。《诗·小明》毛传:初吉,朔日也。《国语·周语》韦注:初吉,二月朔日也。《周礼》郑注:月吉,每月朔日也。\n最早对月相加以完整解说的是刘歆。《汉书·世经》中引用他的话:“(既)死霸,朔也;(既)生霸,望也。”他对古文《武成》历日还有若干解说,归纳起来:\n初一:初吉、朔、既死霸\n初二:旁死霸\n初三:朏、哉生霸\n十五:既生霸\n十六:既望、旁生霸\n十七:既旁生霸\n刘歆的理解是对的,月相定点,定于一日。月相不定点,记录月相何用?古文《武成》在月相干支后,又紧记“越×日”“翌日”,月相不定点,就不可能有什么“越×日”“翌日”的记录。《世经》引古文《月采》篇曰:“三日曰朏。”足见刘歆以前的古人,对月相也是作定点解说。望为十五,《释名·释天》“日在东,月在西,遥相望也”。《书·召诰》传:“周公摄政七年二月十五日,日月相望,故记之。”既望指十六,自古及今无异辞。初吉、月吉、朏、望、既望自古以来是定点的,焉有其他月相为不定点乎?明确月相是定点的,即所有月相都是定点的。不可能说,文献上的月相是定点的,而铜器上的月相是不定点的。所以,我们毫不动摇地坚持古已有之的月相定点说。用定点说解释铜器历日,虽然要求严密,难度很大,也正好体现它的科学性、唯一性。\n只是到了近代,王国维先生用四分术周历推算铜器历日,发现自算的天象与历日总有两天、三天的误差,才“悟”出“月相四分”。事实上,静安先生的运算所得并非实际天象,因为四分术“三百年辄差一日”,不计算年差分(3.06分)就得不出实际天象。“月相四分”实不足取。当然,更不可能有什么“月相二分”。按“二分说”,上半月既生霸,下半月既死霸,那真是宽漫无边,解释铜器历日大可以随心所欲了。谁人相信?\n其二,对铭文的理解明显不同。\n李先生反复强调,吴虎鼎“同时出现夷王、厉王名号”,所以“系宣王标准器断无疑义”。\n查吴虎鼎铭:“王在周康宫宫,导入右吴虎,王命膳夫丰生、司空雍毅,(申)敕(厉)王命。”\n关于“康宫宫”,按唐兰先生解说,通夷,宫指夷王之庙。重要的是王命“(申)敕(厉)王命”这一句。后面有“(申)敕(厉)王命”,前一个“王”就一定是指周宣王吗?我们以为,不是。这明明是追记,是叙史。铭文中的“王”,都是确指厉王,即“厉王在夷王庙,右者导引吴虎入内,厉王命膳夫丰生、司空雍毅,重申他厉王的指令”。前两处用“王”,是因后面“厉王”而省,而与宣王无关。正因为这样,这个历日就与它下面的记事(厉王时事)结合,根本不涉及宣王。\n其三,铜器历日不等于铸器时日。\n吴虎鼎历日是叙史,与周宣王无关,更不会是“周宣王十八年十三月铸造的”。这是考古学界常犯的错误,把铜器历日统统视为铸器时日。\n如果排除时王生称说,吴虎鼎作于厉王以后,或共和,或宣王,都不会错。作器者的本义是在显示他(或其先人)曾经在厉王身边的崇高地位,于是追记厉王十八年十三月的往事。类似这种叙史,这种追记,铜器中甚多,如元年曶鼎、十五年趞曹鼎、子犯和钟……这些铜器历日怎么能看成是铸器时日呢!\n簋及穆王年代 # 国家博物馆新藏无盖簋簋,王年、月、月相、日干支四要素俱全,是考察西周年代又一个重要材料。《中国历史文物》2006年第3期发表了王冠英、李学勤先生的文章 [1] ,编辑部“希望能听到更多学者的意见”,进行深入研究。读了王、李二位文字,本人想就此谈谈我的看法,仅供参考。\n簋铭文重要的有两点:其一,关于“”这个人;其二,簋历日及有关时王的年代。\n这个人,在二十四年九月既望(十六)庚寅日,周“王呼作册尹册申命曰:更乃祖服,作家嗣(司)马”。他是承继祖父的官职,祖父叫“幽伯”。这个“册申命”,即重申册命,商周时期应是常见。《帝王世纪》载:“文王即位四十二[年],岁在鹑火,文王更为受命之元年,始称王矣。”文王死后,武王承继,还得商王重申册命。《逸周书·丰保》就记载姬发正式受命为西伯侯,“诸侯咸格来庆”,那是文王死后第四年的事了。《史记·周本纪》载,武王克商后“封周公旦于少昊之虚曲阜,是为鲁公。周公不就封……而使其子代就封于鲁。……伯禽即位之后,有管、蔡等反也”。伯禽在周公摄政七年期间是代父治鲁,到成王亲政,《汉书》载:“元年正月己巳朔,此命伯禽俾侯于鲁之岁也。”师古注:“俾,使也,封之始为诸侯。”这是成王对伯禽重申册命,尽管“伯禽即位”好几年了。“册申命”,在世袭的体制下,并不是自然的交接班,还得天子君王的册封认可,就含有正式任命之义。\n的祖父不过是“家司马”,管理王室事务的某个方面。在二十四年接手之后,受到周王的赏识,几年后得到提升,做了地位很高的引人朝见周王的“司马井伯”。铜器铭文涉及“司马井伯”的,已有十多件,据此系联,可以归并这些铜器为相近的王世,至少不会相距太远。\n关于簋的具体年代,由于年、月、月相、日干支四样俱全,就便于我们考察。因为历日的制定得依据天象,历日自然也是反映天象的。我们可用实际天象勘比历日,得出确切的年月日。当然,这种考校得有个原则,不能凭个人的想当然。比如,月相是定点的,就不能说一个月相管三天两天,七天八天,甚至十天半个月。朏为初三,望为十五,既望为十六,古今一贯,定点的,其他月相怎么就不定点了呢?文献记载:“越若来二月既死魄,越五日甲子朝。”越,铜器用粤,或用雩,都是相距义。“既死魄”不定点,解释为十天半月,何有过五日的甲子?用一“越”字,就肯定了月相定点。\n实际天象是可以推算复原的,用四分术加年差分推算,得平朔平气(合朔、交气取平均值)。 [2] 用现代科技手段,可得出准确的实际天象,张培瑜《中国先秦史历表》有载,可直接利用,免去繁复的运算。\n古文《武成》《逸周书·世俘》记载了克商时日的月朔干支及月相,稍加归纳,得知:正月辛卯朔,二月庚申朔,四月己丑朔。 [3]\n以此勘合实际天象,公元前1044年、前1075年、前1106年具备“正月辛卯朔,二月庚申朔……”。历朔干支周期是三十一年,克商年代必在这三者之中。依据文献记载(纸上材料)、考求铜器铭文(地下材料)、验证实际天象(天上材料),做到“三证合一”,武王克商只能是公元前1106年。 [4] 依据《史记·鲁世家》及《汉书·律历志》记载,西周总年数是:\n武王2年+周公摄政7年+伯禽46年+考公4年+炀公60年+幽公14年+魏(微)公50年+厉公37年+献公32年+真公30年+武公9年+懿公9年+伯御11年+孝公25年=336年。\n从平王东迁公元前770年,前推336年,克商当是公元前1106年。\n《晋书》载,“自周受命至穆王百年”,有人说“受命”指“文王受命”,实乃指武王克商。武王2年+周公摄政7年+成王30年+康王26年+昭王35年=100年,正百年之数。《小盂鼎》铭文旧释“廿又五祀”,当是“卅又五祀”,乃昭王时器。昭王在位三十五年,享年七十岁以上,才可能有一个五十岁的儿子穆王。昭王在位十九年说违背起码的生理常识。\n又,《史记·秦本纪》张守节《正义》云:“年表穆王元年去楚文王元年三百一十八年。”楚文王元年即周庄王八年,合公元前689年。318+689=1007,不算外,穆王元年当是公元前1006年,至克商之年公元前1106年正百年之数。\n穆王在位五十五年,《竹书纪年》《史记·周本纪》均有明确记载。穆王在位的具体年代就明白了,公元前1006年—公元前952年,共王元年当为公元前951年。\n在这样的背景下考求簋及其有关铜器的具体年代才有可能,而簋及有关铜器的历日干支反过来又能验证西周王年的正确与否。其中的关键环节是校比实际天象,铜器历日与实际天象完全吻合,才能坐实铜器的具体年代。\n簋历日:唯廿又四年九月既望庚寅。\n这个二十四年的王,指穆王的话,核对穆王二十四年实际天象,看它是否吻合就行了。穆王元年乃公元前1006年,二十四年即公元前983年。\n查公元前983年实际天象:子月丁卯139分(丁卯08h 51m ),丑月丙寅……未月癸巳812分(癸巳12h 00m ),申月癸亥371分(壬戌20h 19m )…… [5]\n是年建子,正月丁卯朔……九月癸亥朔。癸亥初一,既望十六戊寅。簋书戊寅为庚寅,取庚寅吉利之义。金文历日,书丁亥最多,其次庚寅,校比天象,细加考查,并非都是实实在在的丁亥日、庚寅日。凡亥日可书为丁亥,凡寅日可书为庚寅。丁亥得以亥日为依托,庚寅得以寅日为依托,并非宽泛无边。这就是铜器历日研究归纳出来的“变例”:丁亥为亥日例,庚寅为寅日例。 [6]\n盘:隹廿又八年五月既望庚寅。\n查宣王二十八年公元前800年天象:建寅,五月辛亥朔,既望十六丙寅。盘书丙寅为庚寅,如此而已。\n克钟:隹十又六年九月初吉庚寅。\n克:隹十又八年十又二月初吉庚寅。\n作器者为一人,当是同一王世。据历朔规律知,有十六年初吉庚寅,不得有十八年十二月初吉庚寅,历日不容。查宣王十八年公元前810年天象:是年建子,十二月戊寅朔。是作器者书戊寅为庚寅。克钟合宣王十六年公元前812年天象:建亥,九月辛卯54分(06h 24m ),余分小,实际用历书为庚寅朔。克历日作变例处理,两相吻合。否则,永无解说。\n簋涉及走簋,走簋铭文中有“司马井伯”,这个“井伯”并不是,而是的文祖“幽伯”。十二年走簋在簋前,不在簋之后。这是从历日中考知的。\n走簋:隹王十又二年三月既望庚寅……司马井伯[入]右走。\n查穆王十二年公元前995年天象:建丑,三月乙亥641分(13″23)。乙亥朔,既望十六庚寅。这是实实在在的庚寅日。走簋历日确认这个司马井伯不是,当是的祖父;走簋历日确认穆王十二年天象与之吻合。(见《西周王年论稿》,270页)\n穆王二十七年公元前980年天象:建丑,六月丙子朔。这就与“师奎父鼎”历日吻合。\n师奎父鼎:隹六月既生霸庚寅……司马上井伯右师奎父。既生霸为望为十五,丙子朔初一,有十五庚寅。\n这个司马井伯当然是了。至此,穆王二十七年,做家司马的已是地位很高的司马井伯了。\n师簋、豆闭簋也载有司马井伯。\n师簋:隹二月初吉戊寅……司马井伯右师……\n豆闭簋:隹二月既生霸,辰在戊寅……井伯入右豆闭。\n这是穆王五十三年的事。查穆王五十三年公元前954年天象:建丑,二月戊寅朔。既生霸十五壬辰。初一戊寅,司马井伯[入]右师;十五壬辰,司马井伯[]入右豆闭。\n穆王时代,朔望月历制已经相当成熟了,朔日望日都视为吉日。这里提供两个证据:其一,《逸周书·史记》载:“乃取遂事之要戒,俾戎夫主之,朔望以闻。”这是穆王要左史辑录可鉴戒的史事,每月朔日望日讲给自己听。其二,《穆天子传》记录穆王十三年至十四年的西征史事,月日干支与公元前994年、前993年实际天象完全吻合,《传》除记录日干支外,援例记录季夏丁卯、孟秋丁酉、孟秋癸巳、[仲]秋癸亥、孟冬壬戌,即每月的朔日干支。\n铜器记录大事,日干支基本上都在朔望(含既望),这与朔望月历制视朔望为吉日有关。穆王“朔望以闻”,朔日(初吉戊寅)接见师,十五(既生霸)接见豆闭,既望册申命,都体现了这一文化礼制现象。月相是定点的,记录大事的铜器上的月相更不会有什么游移,也必须是定点的。\n司马井伯,从穆王后期直到共王时代,一直位高权重。师虎簋、趞曹鼎、永盂等都反映了共王一代司马井伯的活动。\n穆王在位五十五年,共王元年即公元前951年。\n师虎簋:隹元年六月既望甲戌……井伯入右师虎。(《大系录》58)\n既望十六甲戌,必己未朔。查共王元年公元前951年天象:子月辛酉245分(辛酉08h 41m )。上年当闰未闰,建亥,二月辛酉,三月庚寅,四月庚申,五月己丑,六月己未,七月戊子。\n这个六月己未朔,就是师虎簋历日之所在。郭沫若定师虎簋为共王元年器,正合。\n趞曹鼎:隹十又五年五月既生霸壬午。(《大系录》39)\n这里说说“永盂”。\n《文物》1972年第1期载,永盂:隹十又二年初吉丁卯。\n历日有误。历日缺月。铭文有井伯,有师奎父,可放在共王世考校。查共王十年公元前942年实际天象:子月己亥146分(07h 42m ),丑月戊辰,寅月戊戌,卯月丁卯。建寅,二月朔(初吉)丁卯。这就是永盂历日之所在。当是:[共]王十年二月初吉丁卯。\n这让我们明白了两点:1.铜器历日也可能出现误记,当然并非一件永盂;2.可以借助实际天象恢复历日的本来面目,纠正误记的历日。\n以上文字,利用实际天象考察铜器历日,自然会得出这样的结论:月相是定点的,簋乃记周穆王二十四年事,穆王元年在公元前1006年,穆王在位五十五年,共王元年即公元前951年。\n伯吕父的王年 # “断代工程简报”151期,发有李学勤先生关于“伯吕父”的文章。该器铭文的王年、月序、月相、干支四样俱全,考证其具体年代是可能的。就此谈谈我的看法。\n铭文所载历日是:惟王元年六月既眚(生)霸庚戌,伯吕又(父)作旅。\n这个历日明白无误是作器时日,从器型学断其大体年代是可行的。陈佩芬先生认为“此的形制、纹饰均属西周晚期”,李先生以为“应排在西周中期后段”。\n以历日勘比天象,西周晚期周王的元年无一可合。“排在西周中期后段”则是唯一的首选。\n月相定点,定于一日。既生霸为望为十五,十五庚戌,月朔为丙申。连读是:[]王元年六月丙申朔,十五既生霸庚戌,伯吕父作旅。\n历日四要素俱全的铜器已有数十件,用历日系联,每一件铜器都不会是孤立的,都可以在具体的年代中找到它的准确位置。这就是历日勘比天象的妙处。董作宾先生就此将铜器列入各个王世,排出共王铜器组、夷王铜器组、厉王铜器组……得出的结论似更可信。\n共和元年为公元前841年,这是没有疑义的。厉王在位三十七年,司马迁有记载,不必推翻。厉王元年为公元前878年。\n有两件铜器的历日与公元前878年的天象吻合。\n师簋:隹王元年正月初吉丁亥。(《大系录》98)\n师兑簋甲:隹元年五月初吉甲寅。(《大系录》146)\n公元前878年实际天象:丑正月丁亥19h56m★;二月丙辰,三月丙戌,四月乙卯,闰月乙酉,五月甲寅18h36m★,六月甲申……(★为符合天象的铜器历日)\n前推,当是夷王。夷王世的铜器有:\n卫盉:隹三年三月既生(死)霸壬寅。(《文物》1976年第5期)\n兮甲盘:唯五年三月既死霸庚寅。(《大系录》134)\n谏簋:隹五年三月初吉庚寅。(《大系录》101)\n大师虘簋:正月既望甲申……隹十又二年。(《考古学报》1956年第4期)\n从夷王末年向前考察实际天象,公元前882年丑正月庚辰02h07m,二月己酉,三月己卯……正月庚辰分数小,司历定己卯★。这就是大师虘簋历日之所在。正月既望十六甲申,则月朔己卯。\n定公元前882年为夷王十二年的话,公元前889年为夷王五年。公元前889年天象:丑正月辛卯,二月庚申,三月庚寅03h08m★,四月己未……三月庚寅就是兮甲盘、谏簋历日之所在。兮甲盘用“既死霸”,谏簋用“初吉”,并无二致。\n前推,公元前891年当为夷王三年。公元前891年天象:上年当闰未闰,子正变亥正,正月癸卯,二月壬申,三月壬寅04h30m★。这就是卫盉历日之所在。卫盉的“既生霸”应是“既死霸”,忌“死”用“生”而已,不为误。这就是“铜器历日研究”中的“既生霸为既死霸例”(详见《铜器历日研究》,贵州人民出版社,1999)。\n用铜器历日勘合天象,夷王元年为公元前893年(鲁厉公卅一年),在位十五年。\n往前,进入另一王世。史书记为孝王,后人多从。用铜器历日考校,当是懿王。用历日系联,这一王世的铜器有:\n元年曶鼎、二年王臣簋、三年柞钟、九年卫鼎、十五年大鼎、二十年休盘、二十二年庚赢鼎。\n庚赢鼎、休盘靠近夷王,不妨以两器为例讨论之。\n庚赢鼎:隹廿又二年四月既望己酉。(《大系录》22)\n休盘:隹廿年正月既望甲戌(壬戌)。(《大系录》143)\n既望己酉,则四月甲午朔;甲与壬形近,既望甲戌则己未朔;既望壬戌则丁未朔。\n公元前897年天象:丑正月丁未651分★(戊申00h11m),二月丁丑……\n公元前895年天象:丑正月乙丑,二月乙未,三月乙丑,四月甲午21h09m★。\n其他多件懿王铜器均可如法一一勘合。得知,公元前895年乃懿王廿二年,夷王元年是公元前893年,懿王在位当是二十三年。\n前推,公元前899年有“天再旦于郑”的天象,不可易。公元前899年合懿王十八年。古多合文,“十八”应是合文,误释为“元”,便出现“懿王元年天再旦于郑”的文字。我们确定夷王之前是懿王,当然与“天再旦于郑”的日食天象有关。“共懿孝夷”的王序,虽有新出“”(李学勤先生释为“佐”)器佐证,那实在是“五世共庙制”造成的误会,当专文解说。实际的王序是:共、孝、懿、夷。那是铜器历日明确告诉了我们的。\n经过历日与天象勘合,十五年大鼎历日合公元前902年天象,九年卫鼎合公元前908年天象,三年柞钟合公元前914年天象,二年王臣簋合公元前915年天象,元年曶鼎合公元前916年天象。\n公元前916年实际用历:丑正月戊辰,二月丁酉,三月丁卯,四月丁酉★,五月丙寅,六月丙申★……(见《西周王年论稿》147~148页)这里的“四月丁酉”就是曶鼎的“四月辰在丁酉”。这里的“六月丙申(朔)”就是伯吕父的“惟王元年六月既生霸庚戌(丙申朔)”。\n不难看出,伯吕父的历日吻合公元前916年实际天象,这个元年的王是懿王。\n结论是明确的:伯吕父乃周懿王元年器,与曶鼎同王同年,其绝对年代是公元前916年。\n关于成钟 # 《上海博物馆集刊》第8期刊发《新获两周青铜器》一文,内有“成钟”一件,钲部与鼓部有文:“隹(唯)十又六年九月丁亥,王在周康徲宫,王寴易成此钟,成其子子孙孙永宝用享。”陈佩芬先生说:“从成钟形式和纹饰判断,这是属于西周中晚期的器。自西周穆王到宣王,王世有十六年以上的仅有孝王和厉王,据《西周青铜器铭文年历表》所载,西周孝王十六年为公元前909年,九月甲申朔,四日得丁亥。西周厉王十六年为公元前863年,九月丙戌朔,次日得丁亥,此两王世均可相合。铭文中虽无月相记载,但都与‘初吉’相合。”\n我们也注意到李学勤先生的文字:“成钟的时代,就铭文内容而言,其实是蛮清楚的。铭中有周康宫夷宫,年数又是十六年,这当不外于厉王、宣王二世。查宣王十六年,为公元前812年,该年历谱已排有克鎛、克钟,云‘十又六年九月初吉庚寅’,据《三千五百年历日天象》,庚寅是该月朔日。成钟与之月分相同,而日为丁亥,丁亥在庚寅前三天,无法相容。再查历谱厉王十六年,是公元前862年,其年九月庚辰朔,丁亥为初八日。这证明,把成钟排在厉王十六年,就历谱来说,刚好是调协的,由此足以加强我们对历谱的信心。”\n综合两位先生的见解,铭文历日应当这样理解:厉王十六年九月初吉丁亥。\n这里有两个重要的问题:厉王十六年是公元前863年,还是公元前862年?初吉是指朔日(定点的),还是指初二、初四或初八(不定点的,包括初一到初八甚至朔前一二日)?\n公元前862年天象:九月庚辰朔。如果初吉定点,指朔日,公元前862年就不可能是厉王十六年,“断代工程”关于西周年代的结论则将从根本上动摇,什么“金文历谱”就成了想当然的摆设。只有将“初吉”理解为上旬中的任何一天,公元前862年才能容纳成钟的历日。与此相应,成钟历日可以适合若干年份的九月上旬的丁亥。如公元前863年、公元前909年等等。排定成钟历日就有很大的随意性,大体上可以随心所欲。\n再看克钟历日:“惟十又六年九月初吉庚寅。”校比宣王十六年公元前812年天象:“九月庚寅朔”,正好吻合。这里,初吉即朔,没有摆动的余地。正因为月相定点不容许有什么摆动,没有随意性,一般人就感到很难,不得不知难而退,避难就易,误信“月相四分”,甚至发明了“月相二分”(一个月相可合上半月或下半月任何一天)。定点的确很难,但体现了它的严密,不容你主观武断,避免了信口雌黄。克钟历日,初吉定点,只能勘合前公元812年天象,坐实在宣王十六年,摆在任何其他地方都不合适,这就叫“对号入座”。\n让我们来分析成钟的历日:十六年九月(初吉)丁亥。用司马迁“厉王在位三十七年”说,厉王十六年为公元前863年。查对公元前863年实际天象:冬至月(子月)朔庚寅、丑月庚申00h18m、寅月己丑、卯月戊午20h16m、辰月戊子、巳月丁巳19h56m、午月丁亥、未月丁巳01h20m、申月丙戌17h19m、酉月丙辰、戌月丙戌01h22m、亥月乙卯。(见张培瑜《中国先秦史历表》,55页)\n对照四分术殷历,公元前863年天象:子月庚寅、丑月庚申66、寅月己丑、卯月己未124、辰月戊子、巳月戊午182、午月丁亥、未月丁巳240、申月丙戌739、酉月丙辰、戌月乙酉797、亥月乙卯。(见张闻玉《西周王年论稿》,303页)\n《历表》用定朔,四分术用平朔,余分略有不同。一般人看来,有卯月、巳月、戌月三个月的干支不合,好像彼此相差一天。因为一个朔望月是29.53日,干支纪日以整数,余数0.53不能用干支表示,而合朔的时刻不可能都在半夜0点,或早或晚,余分就有大有小。表面上干支不合,而余分相差都在0.53日之内。这是定朔与平朔精确程度不同造成的正常差异。余分只要在0.53日(约13小时,四分术499分)之内,都应视为吻合。\n西周观象授时,历不成法,朔闰都由专职的司历通过观测确定。以上为例,卯月余分大,戊午20h16m(合朔在晚上20点16分),司历可定为己未。从四分术角度看,己未124,余分小(合朔在凌晨3点多),司历可定为戊午。申月丙戌(定朔与四分术干支同),余分大,司历不用丙戌而定为“丁亥”。司历一旦确定,颁行天下,这就是“实际用历”。\n可以看出,《历表》用定朔,有连小月(庚申、己丑、戊午),两个连大月(丁巳、丁亥、丁巳;丙戌、丙辰、丙戌)。四分术殷历无连小月,只有连大月,公元前864年最后三个月连大,公元前863年一大一小相间。\n可以推知,公元前863年的实际用历当是:正月(子月)庚寅、二月庚申、三月己丑、四月己未、五月戊子、六月戊午、七月丁亥、八月丁巳、九月丁亥、十月丙辰、十一月丙戌、十二月乙卯。——大体上一大一小相间,取七月、八月连大。\n公元前863年实际用历:九月丁亥朔。这就是成钟“唯十又六年九月丁亥”所记载的历日。成钟的“十六年”,公元前863年,即厉王十六年。\n大量的铜器历日与实际天象勘合,结论都是一个:厉王在位三十七年,厉王元年即公元前878年。根本不存在什么“共和当年改元”的神话(另文述及)。\n李先生的“金文历谱”,也即是“断代工程”的“金文历谱”的根本失误在哪里?这是一个值得认真探讨的问题。\n李先生以铜器器型学为基准,确定器物的王世,再用铭文历日去较比实际天象,历日的月相用宽漫无边的“四分说”甚至“二分说”进行解说,最后排出一个“金文历谱”。\n粗看起来,这样的研究程序也似乎无可挑剔,细细一琢磨,其中的问题就不少。比如,器型涉及制作工艺,可以反映制作的时代,铭文历日是不是就是制作时日?一般青铜器专家总是将铭文历日视为制作时日,器型就成了断代的基本依据。事实上,铭文可以叙史。正如郭沫若先生所说,“其子孙为其祖若父作祭器”,追记先祖功德正是叙史,与史事有关的历日就与器物的制作无关,这几乎是简单的常识。从器型学的角度看,当是西周晚期器物,而铭文记录西周中期甚至前期的史事,也属正常。\n其二,自古以来,月相是定点的且定于一日。月相紧连干支,就是记录那个干支日的月相。古人观察月相做什么?“观象授时”,确定每年的朔闰。这其中,月朔干支尤其重要。月亮的圆缺是确定朔干支的依据,或者说唯一的依据。月相不定点,记录月相何用?初吉为朔,望为十五,既望为十六,朏为初三,是定点的;焉有其他月相为不定点乎?如果一个月相可以上下游移十天半月,紧连的干支怎能纪日?\n其三,排定历谱只能以实际天象为依据,金文历日必须对号入座,舍此别无他法。把铜器器型进行分类,那是古董鉴赏家的方法,不可能在此基础上产生什么“金文历谱”。\n不难明白,由于先排定了器型,器物上的历日便不可能与实际天象吻合,再错下去,就只有对月相进行随心所欲的解说,以求与天象相合。\n比照成钟的历日,不是很能说明这些问题么?\n关于士山盘 # 摘要士山盘铭文历日当是“唯王十又六年九月既生霸丙申”,不是“甲申”。将西周中期诸多铜器历日系联,可归类为同一王世的四组铜器。士山盘历日与共王诸器历日不合,也与元年逆钟、四年散伯车父鼎、六年师伯硕父鼎、八年师才鼎、十二年大鼎等同一王世诸器历日不合,唯合元年曶鼎、二年王臣簋、三年柞钟、九年卫鼎、十五年大鼎等同一王世诸器。懿王元年乃公元前916年。懿王十八年公元前899年四月丁亥朔日食,“天再旦于郑”。“十八”乃合文,后人误释为“元”,便有“懿王元年天再旦于郑”的记载。士山盘历日与公元前901年天象吻合,此乃懿王十六年。\n关键词士山盘丙申既生霸为既死霸例\n从“断代工程”简报上先后读到有关新出现的成钟、士山盘两件器铭历日的文字,李学勤先生以此来“检验《报告》简本中的西周金文历谱”,认为“两器都可以和‘工程’所排历谱调谐,由此可以加强对历谱的信心”。另一位专家陈久金先生同意朱凤瀚《士山盘铭初释》所论,认为“士山盘的历日也合于‘工程’对金文纪时词语‘既生霸’的界说”。\n朱凤瀚先生的文章发于《中国历史文物》2002年1期,还附有相片及拓本,拓本较摄影更为清晰。细审拓本,铭文历日当是“隹王十又六年九月既生霸丙申”,朱先生释为“甲申”。“丙”字在“霸”字右边的“月”下,比较清楚。如果从历日角度研究,这一字之差,牵动就大了,也就无从谈及对“金文历谱”的肯定。\n就成钟历日“隹十又六年九月丁亥”而言,依自古以来的月相定点说来验证历日天象,符合厉王十六年九月丁亥朔。厉王十六年当是公元前863年,而不是“金文历谱”所排定的公元前862年。本人另有专文《关于成钟》,已做了详细讨论。\n现在来讨论一下“士山盘”历日,结论恐怕就与“断代工程”专家们的看法不同。相反,两件器物的历日,足以否定“工程”的“金文历谱”。\n正确识读士山盘历日至关重要:隹王十又六年九月既生霸丙申。\n朱凤瀚先生断为西周中期器,列入共王十六年。我只想说,历日合懿王十六年公元前901年天象。\n我可以列出十个、二十个、三十个以上的“支点”来支持西周总年数是336年的结论。可参考《西周王年足徵》 [7] ,“足徵”,不过就是“证据充足”之义。\n朱先生文章引用了“宰兽簋”历日“六年二月初吉甲戌”(见《文物》1998年第8期)。与天象框合,符合公元前1036年昭王六年天象,建丑,正月甲辰,二月甲戌。初吉指朔日。紧接着有“齐生鲁方彝盖”历日,“八年十二月初吉丁亥”,这与昭王八年公元前1034年天象相吻合:建丑,正月壬戌,二月壬辰……十一月戊午,十二月丁亥。两器历日,前后连贯,依董作宾先生的研究可归入“昭王铜器组”。\n往下,昭王十八年公元前1024年天象,建寅,正月甲午……四月壬戌……八月庚申。这便是“静方鼎”历日:八月初吉庚申,[]月既望丁丑。月相定点,既望十六丁丑,则壬戌朔。四月壬戌朔,正合。“月既望”,非当月既望,而是追记前事,实乃“四月既望丁丑”,与曶鼎铭文追记前事同例。\n接着,昭王“十九年,天大曀,雉兔皆震”,这是公元前1023年午月丙戌的日食天象。查《日月食典》,查张培瑜《历表》(即《中国先秦史历表》,下同),都可以证实公元前1023年六月丙戌日确有日食,这几乎是公元前1023年为昭王十九年的铁证。\n小盂鼎有“廿又五祀”说,又确实存在“卅又五祀”的版本。校比天象,小盂鼎历日“八月既望辰在甲申”合昭王三十五年公元前1007年天象,建子,七月甲寅,八月甲申。“辰在甲申”即甲申朔。\n穆王元年为公元前1006年,在位五十五年。共王元年乃公元前951年。\n师虎簋历日:隹元年六月既望甲戌。(《大系录》58)最早,王国维氏断为宣王元年器,后来郭沫若氏断为共王元年器。六月既望十六甲戌,则六月己未朔。王氏云:“宣王元年六月丁巳朔,十八日得甲戌。是十八日可谓之既望也。”王氏用四分术推算,不知道四分术先天的误差,也读不到张培瑜的《历表》,所以便有“四分月相”的错误结论。\n如果我们自己用四分术加年差分推算,或者直接查对张培瑜氏《历表》,公元前951年(共王元年)与公元前827年(宣王元年),都有六月己未朔。虽然可以肯定月相定点,既望是十六,不可能是十八,而一器合宣王又合共王,该如何解释?\n有历术常识的人都会知道,日干支60日一轮回,月朔干支31年一轮回。公元前951年与公元前827年,正是月朔干支的四个轮回,所以都有六月己未朔。\n应该说,郭沫若氏看到的器物更多,断代更为合理。近年发现虎簋盖,可以与师虎簋联系,师虎簋列为共王元年器,也就顺理成章。\n趞曹鼎:隹十又五年五月既生霸壬午。月相定点,既生霸十五壬午,则五月戊辰朔。大家相信,这是共王标准器。\n查对共王十五年公元前937年天象:建子,正月己巳朔……四月戊戌朔,五月戊辰朔。完全吻合。\n西周中期器物甚多,最值得注意的是几个元年器。明确这些元年器的准确年代,以此为基准,用历日系联其他器物就有可能归类为同一王世的一组铜器,这正是董作宾先生的研究方法。用历日系联,就得对历术有通透的了解,最好是自己能推演实际天象,方可做到心明眼亮,是非分明,从而避免人云亦云。\n涉及西周中期铜器,盛冬铃先生有一篇很好的文章,发在《文史》十七辑。笔者当年从中受到许多启发,才有了尔后对铜器历日的深入研究。盛先生还来不及从历术的角度进行探讨,就过早地走了,实在是铜器考古学界的悲哀。当今,能达到盛冬铃先生研究水平的人似乎太少,而想当然的主观臆度者比比皆是,皮相之见又自视甚高者亦大有人在。\n先说元年器逆钟,历日“隹王元年三月既生霸庚申”。(《考古与文物》1981年第1期)\n今按,既生霸十五庚申,则丙午朔。共王以后,公元前928年天象:建子,正月丁未,二月丁丑,三月丙午(定朔丁未01h50m,余分小,司历定为丙午)。\n这个元年的王,只能是懿王或孝王,共王在位23年得以明确。\n与逆钟历日系联的器物有“散伯车父鼎”,历日“隹王四年八月初吉丁亥”。合公元前925年天象:建子,八月丁亥朔。\n还有“师伯硕父鼎”,历日是“隹六年八月初吉乙巳”。合公元前923年天象:建子,八月乙巳朔。\n还有“师才鼎”,历日是“隹王八祀正月,辰在丁卯”。辰在丁卯即丁卯朔。合公元前921年天象:上年当闰不闰,故建亥,正月丁卯朔。\n还有“大簋”,历日是“隹十又二年二月既生霸丁亥”。既生霸十五丁亥,则癸酉朔。合公元前917年天象,建子,二月癸酉朔。\n这样,从元年逆钟,到四年散伯车父鼎,到六年师伯硕父鼎,到八年师才鼎,到十二年大簋,铜器历日与实际天象完全吻合。以上都是同一王世器。这个元年为公元前928年的王应该是孝王,在共王之后,兄终弟及。共王之后,不是司马迁所记的懿王,懿王应在孝王之后。说见《共孝懿夷王序王年考》 [8] 。\n再看一件元年器曶鼎。\n曶鼎历日:“唯王元年六月,既望乙亥”;“惟王四月既生霸,辰在丁酉”。\n王国维氏以为,四月在六月前,为同一年间事。可从。铭文分三段。此乃立足六月(首段),又追记四月(次段),更追记往“昔”(三段)。\n“辰在丁酉”即丁酉朔,既生霸十五干支辛亥不言自明。当年朔闰是:四月丁酉,五月丙寅,六月丙申。\n丙申朔,既望十六即辛亥。古人记亥日,以乙亥为吉,丁亥为大吉。这两段历日都是辛亥。次段四月不言辛亥,而以月相“既生霸”称之,补充朔日“辰在丁酉”。前段还是避开辛亥,以吉日“乙亥”代之。\n校比公元前916年天象,可考知实际用历是建丑,四月丁酉朔,六月丙申朔。\n详见《曶鼎王年考》。 [9]\n接续下去,王臣簋历日“隹二年三月初吉庚寅”,合公元前915年天象。\n接续下去,柞钟历日“隹王三年四月初吉甲寅”,合公元前914年天象。\n接续下去,卫鼎历日“隹九年正月既死霸庚辰”,合公元前908年天象。\n接续下去,大鼎历日“隹十又五年三月既(死)霸丁亥(乙亥)”,合公元前902年天象。\n公元前899年,懿王十八年的四月丁亥朔日(建丑),天亮后发生了一次最大食分为0.97的日全食,天黑下来,到5.30分,天又亮了。当是“懿王十八年天再旦于郑”。以讹传讹,文献记载为:“懿王元年,天再旦于郑。”古人竖写,“十八”误合为“元”。“合二字为一字之误”,古已有之。最明显的是:“左师触龙言”成了“左师触詟”,迷误了两千余年。前几年出土了地下简文,才算明白了:只有触龙,并无触詟。或者说,“十八”本来就是合文,正如甲文“義京”“雍己”“祖乙”是合文一样,后人将“十八”释读成了“元”。\n以上器物,当归属懿王铜器组,这是借助器物自身的历日系联出来的,没有任何人为的强合或臆度。这是天象,是经得起历史检验的。\n公元前916年是懿王元年,懿王十六年当是公元前901年,是年天象:建丑,正月庚子,二月庚午,三月己亥,五月戊戌,六月戊辰,七月丁酉,八月丁卯,九月丙申,十月丙寅……这里的“九月丙申朔”,就是士山盘历日所反映的天象。\n“士山盘”历日是“既生霸”,月相定点,既死霸为朔为初一,既生霸当是十五。又岂能吻合?\n古今华夏人的文化心态是相通的:图吉利,避邪恶。“死”,不吉利。所以有人就忌讳,自然也有人不忌讳。不忌讳的,直言之,直书之。忌讳的,可以少一“死”字,有意不言不书;也可以改“死”为“生”,图个吉利。\n有意避“死”字不书的,如大鼎,历日“隹十又五年三月既霸丁亥”。我们早先总以为,“既霸”不词,是掉了字,是“历日自误”。经历术考证,乃“既死霸”,当补一“死”字。如果从避讳角度看,乃有意为之,不是误不误的问题。\n改“死”为“生”的忌讳,就是“既生霸为既死霸例”。虽书为“既生霸”,实即“既死霸”(朔日),以望日十五求之,无一处天象符合;以朔日求之,则吻合不误。铜器历日已有数例,我们归纳为“既生霸为既死霸”这一特殊条例,借以解说特殊铜器历日。 [10] 公元前901年懿王十六年,九月丙申朔,这就是士山盘历日“隹王十又六年九月既生(死)霸丙申”的具体天象位置所在。\n结论很清楚:月相定点,定于一日;没有两天、三天的活动,更不得有七天、八天的活动;什么“上半月既生霸、下半月既死霸”更是想当然的梦呓。王国维氏用四分术求天象,没有考虑年差分(就是365.25日与真值365.2422日的误差),便“悟”出“四分月相”,已经与实际天象不合。在“四分”的基础上,“二分月相”走得更远,谁人相信?以此排定“金文历谱”,以此考求西周年代,其结论的错误也就不言而喻了。\n穆天子西征年月日考证——周穆王西游三千年祭 # 公元2007年是周穆王西游三千年的重要纪年,千载难逢。我们应当记得它,应当纪念它。\n周穆王西征,有《穆天子传》为证。事涉“三千年”,当然得从西周的年代说起。\n司马迁《史记》的明确纪年始于西周共和元年,即公元前841年。那之前的年代,都是后人推算的。其中,武王克商的确切年代最为关键。克商年代,至今已有三四十家不同说法。影响大的有两家。旧说,即刘歆之说,克商在公元前1122年,有两千年了,史学界大体依从。新说,当是国家斥巨资集多方面力量,称之为“夏商周断代工程”所得出的结论,克商在公元前1046年。差异如此之大,靠得住吗?\n姑且不说新说、旧说的是非,看一看克商年代的文献依据就能让我们头脑清醒。\n反映克商月朔日干支的文字,一是古文《武成》,一是《逸周书·世俘》。\n《新唐书·历志》称“班(固)氏不知历”,他的《汉书·律历志》多采用刘歆的文字,刘歆在《世经》中引了《周书·武成》:“惟一月壬辰旁死霸,若翌日癸巳,武王乃朝步自周,于征伐纣。”\n又引《武成》曰:“粤若来三[二]月既死霸,粤五日甲子,咸刘商王纣。”\n又引《武成》曰:“惟四月既旁生霸,粤六日庚戌,武王燎于周庙。翌日辛亥,祀于天位。粤五日乙卯,乃以庶国祀馘于周庙。”\n这就是今天我们能见到的古文《武成》。虽也有人提出过异议,史学界还是认同它的真实性。刘歆在引用后还写有他对原文的解说。如:“至庚申,二月朔日也。四日癸亥,至牧野,夜阵。甲子昧爽而合矣。”又说:“明日闰月庚申朔。……四月己丑朔[既]死霸。……是月甲辰望,乙巳旁之。”——这就是刘歆对克商月朔干支的理解。\n稍加排列,是年前几月朔日干支便清清楚楚:一月辛卯朔,二月庚申朔,四月己丑朔。\n再看《逸周书·世俘》:\n维四月乙未日,武王成辟。四方通殷命,有国。\n维一月丙午旁生魄,若翼日丁未,王乃步自于周,征伐商王纣。\n越若来二月既死魄,越五日甲子朝至接于商,则咸刘商王纣,执矢恶臣百人。\n《武成》说,一月初二(旁死霸)壬辰,初三癸巳,“武王乃朝步自周”。《逸周书》说,一月十六(旁生魄)丙午,第二天丁未,“王乃步自于周”。《武成》立足于朔,《世俘》立足于望,日序一致,两者并不矛盾。当是初三癸巳起兵,中间有停留,十七丁未又出发。二月既死魄(庚申朔),第五天“甲子朝至接于商”。《世俘》与《武成》吻合。\n月相的含义也清楚明白:既死魄(霸)为朔为初一,旁死魄(霸)取傍近既死魄之义为初二;既生魄与既死魄相对为望为十五,旁生魄(霸)取傍近既生魄之义为望为十六,既旁生魄(霸)指旁生魄后一日为十七。月相是定点的,定于一日。一个月相不会管两天三天,也不会管七天八天,更不会相当于半个月。这是至关重要的。这是《武成》与《世俘》明白告诉我们的。有的人就是视而不见!\n克商之年前几月的朔干支当是:一月辛卯朔,二月庚申朔,×月庚寅朔,×月己未朔,四月己丑朔。二月至四月间必有一闰,刘歆据四分术朔闰定二月闰,可从。闰二月庚寅朔,三月己未朔。\n以此勘比实际天象,公元前1122年、前1046年皆不符合。历日干支与公元前1044年、前1075年、前1106年天象可合。因为历朔干支周期是三十一年,克商年代必在这三者之中。依据文献记载,考求出土铜器铭文,武王克商只能是公元前1106年。\n1976年于临潼出土利簋,铭文:“王武征商,唯甲子朝。”确证了克商的时日干支。\n西周的总年数,可参照《史记·鲁世家》。因为《鲁世家》记载鲁公在位年数大体完整。《史记·鲁世家》记:封周公旦于少昊之虚曲阜,是为鲁公。周公不就封,留佐武王。武王克殷二年,天下未集,武王有疾,不豫……其后武王既崩,成王少,在襁褓之中。周公恐天下闻武王崩而畔,周公乃践祚,代成王摄行政当国。……于是卒相成王,而使其子伯禽代就封于鲁……伯禽即位之后,有管、蔡等反也。淮夷、徐戎,亦并兴反。于是伯禽率师伐之于慈……遂平徐戎,定鲁。鲁公伯禽卒,子考公酋立。考公四年卒,立弟熙,是为炀公……六年卒,子幽公宰立。幽公十四年,幽公弟照杀幽公而自立是为魏公。魏公五十年卒,子厉公擢立。厉公三十七年卒,鲁人立其弟具,是为献公。献公三十二年卒,子真公濞立。真公十四年,周厉王无道,出奔彘,共和行政。二十九年,周宣王即位。三十年,真公卒,弟敖立,是为武公。武公九年春,武公与长子括、少子戏西朝周宣王。宣王爱戏……卒立戏为太子。夏,武公归而卒,戏立,是为懿公。懿公九年,懿公兄括之子伯御与鲁人攻弑懿公,而立伯御为君。伯御即位十一年,周宣王伐鲁,杀其君伯御……乃立称(鲁懿公弟)于夷宫,是为孝公……孝公二十五年,诸侯畔周,犬戎杀幽王。\n司马迁所记西周一代鲁公年次,大体是清楚的。异议最多只有两处:伯禽年数,炀公年数。伯禽卒于康王十六年,这是明确的。周公摄政,七年而返政成王,“后三十年四月……乙丑,成王崩”。伯禽代父治鲁是在周公摄政之初,而不是成王亲政之后。伯禽治鲁后,有管、蔡等反,淮夷徐戎亦反。接着有周公东征,伯禽亦率师伐徐戎,定鲁。《鲁世家》载,伯禽代父治鲁之后“三年而后报政周公”,“太公亦封于齐,五月而报政周公”,引起周公有“何迟”“何疾”之叹。很清楚,周公与太公受封是在武王克殷之后,伯禽“之鲁”当在周公摄政之初,是代父治鲁。成王亲政元年,“此命伯禽俾侯于鲁之岁也”(《汉书·律历志》),成王正式封伯禽为鲁侯。到康王十六年,伯禽卒。这样,代父治鲁七年,作为鲁侯治鲁四十六年,总计五十三年。这与《史记集解》“成王元年封,四十六年,康王十六年卒”的记载也是吻合的。\n鲁炀公年数,《鲁世家》记“六年”,《汉书·世经》作“《世家》:炀公即位六十年”,汲古阁本《汉书》作“炀公即位十六年”。《世经》同时又记“炀公二十四年正月丙申朔旦冬至”为蔀首之年,至“微(魏)公二十六年正月乙亥朔旦冬至”复为蔀首之年。这就否定了六年说、十六年说。这一蔀七十六年中间,还有幽公十四年,炀公在位必六十年无疑。\n这样,武王2年+周公摄政7年+伯禽46年+考公4年+炀公60年+幽公14年+魏公50年+厉公37年+献公32年+真公30年+武公9年+懿公9年+伯御11年+孝公25年=336年。这是明白无误的《鲁世家》文字,是考证西周一代王年的依据。西周总年数336年,武王克商当在公元前1106年。实际天象,出土铭文,文献记载,都证实了这一结论。\n《史记·封禅书》:“武王克殷二年,天下未宁而崩。”周公摄政七年,返政成王,《汉书·律历志》载:“后三十年四月……乙丑,成王崩。”《竹书纪年》载,康王在位二十六年。昭王在位年数众说纷纭,而小盂鼎铭文旧释“廿又五祀”,当是“卅又五祀”,乃昭王时器,可证昭王在位三十五年。\n武2+摄政7+成30+康26+昭35=100年,正百年之数,这就证实《晋书》所载“自周受命至穆王百年”是靠得住的。前人说“受命”指的是“文王受命”,实则指武王克商。又,昭王在位之年,其说甚多,十九年说影响尤大。《史记》载,穆王即位“春秋已五十矣”,这就否定了昭王在位十九年说、二十四年说(新城新藏)。在位三十五年,昭王年岁当在七十以上,才可能有一个五十岁的儿子穆王。这是简单的生理常识啊!\n又,《史记·秦本纪》张守节《正义》云:“年表穆王元年去楚文王元年三百一十八年。”楚文王元年即周庄王八年,合公元前689年。318+689=1007,不算外,穆王元年当是公元前1006年,上距克商的公元前1106年正是“自周受命至穆王百年”。\n文献记载的穆王高寿长命,都是于史有据的。《史记·周本纪》载:“穆王即位,春秋已五十矣。……穆王立五十五年崩,子共王翳扈立。”《竹书纪年》记穆王“五十五年,王陟于祗宫”。《太平御览》引《帝王世纪》:“五十五年,王年百岁,崩于祗宫。”《尚书·吕刑》载:“唯吕命,王享国百年,耄荒,度作刑,以诘四方。”这里的“百岁”“百年”,当然指的是整数,《帝王世纪》的作者不会不读《史记》。穆王活到一百零五岁,古人不疑,今人反认为不可能,于是穆王在位就有了45年(马承源)、41年(董作宾、刘起益)、37年(丁山、刘雨)、27年(周法高),甚至20年(陈梦家)、14年(何幼琦)种种说法。如此不顾文献,实在令人惊讶。我们说,离开了文献记载,还有什么历史可言啊!百年来,东西方文化交流,西方人怎么“大胆假设”,还可理解,号称史学家的中国人抛弃文献信口雌黄,就不好理解了。\n穆王元年乃公元前1006年,这是不容置疑的。\n弄明白穆王在位的具体年代,公元前1006年—前952年,计55年,再考求穆天子西游的年月日才有可能。\n中华民族最重视史事的记录,汉字的史、事本来就是一个字,帝王身边有史官记言记事,古代史料记载的丰富是不言而喻的。中华民族的历史既是悠长的,更是延绵不断的,这在世界上绝无仅有。《春秋》仅是鲁国的大事记国史,只不过经孔子整理而得以保存下来。其实,各诸侯国都是有国史的,从《竹书纪年》看,周王朝的大事记更不当缺。《逸周书·史记》载,周穆王要左史“取遂事之要戒”,朔日、望日讲给他听。也就是录取史料中的重要的可鉴戒的事,供他参考借鉴。足见周穆王时是有史事记录的,左史才可能给他辑录。可惜,国史仅传下来一部《春秋》,更早的只有一些零散的文字。\n史载,西晋初年汲郡人不准盗取战国古墓,有大量竹简古书,经当时学者荀勖等人整理,一批古籍得以保留下来,其中就有史事记录两种,这就是《竹书纪年》与《穆天子传》。\n《穆天子传》记录周穆王西行的史事,历时两年,远行到今之中亚,文字中干支历日明明白白,地名记载清清楚楚,即便是经过春秋、战国间人整理,作为穆王的史事,还是可信的,不当有什么疑义,更不必看作什么传奇小说,当作古人的故事编写。\n据《艺文类聚》载,“穆王十三年,西征,至于青鸟之所憩”,这当是穆王的初次西行。《穆天子传》卷四载,“比及三年,将复而野”,还要再去。因为传文残缺,无明确年月,只有日干支记录。我们仅能据干支将行程一一复原,再现三千年前穆王西行的史事。\n穆王十三年即公元前994年,我们将前后年次的月朔干支一一列出,穆王的西行也就大体明白了。\n注:84,指四分数小余。07h19m,指合朔07时19分,见张培瑜《中国先秦史历表》。\n郭沫若氏《大系录》61载走簋“隹王十又二年三月既望庚寅”。既望十六庚寅,必乙亥朔。这正合穆王十二年天象:卯月乙亥朔。见上★处。是年建丑,当闰未置闰,转入下年建子。\n郭沫若《大系录》80载,望簋“唯王十又三年六月初吉戊戌”。六月戊戌朔,正合穆王十三年天象:巳月戊戌朔。见上★处。本年不当闰而闰,转入下年建丑。\n以上所列子、丑、寅、卯……是实际天象,是用四分术推算出来的。在不能推步制历的春秋后期以前,是观察星象制历,即观象授时。在没有找到朔闰规律之前,只能随时观察,随时置闰。这样,实际用历与实际天象就不可能完全吻合,允许有一定的误差。月球周期29.53日,有个0.53,半日还稍多。而干支纪日是整数,不可能记“半”,这个0.53必然地前后游移,甲子记为乙丑,乙丑记为甲子,都算正常。还有个置闰问题,按推步制历当闰,而实际用历却未闰,不当闰却又置闰了,建正就有个游移,或建丑或建子,并不固定。懂得以上两点,实际用历与实际天象的勘合与校比,才有可能。\n下面,我们将《穆天子传》有关文字录入,穆王西游的整个行程也就昭白于天下。\n卷一,开篇“饮天子蠲山之上”,说明书已残缺,当有穆天子从宗周洛邑出发过黄河至蠲山的记录。书的首页,按后面的惯例应当是“仲春庚子”“季春庚午”之类的纪时文字。第一个纪日干支是“戊寅”,是在朔日庚午之后,说明穆天子在季春三月初出发,几天后到了黄河之北山西东部的蠲山。从上面公元前994年(穆王十三年)实际天象推知,三月朔庚午(小余46分),分数小,也可以是“三月己巳朔”,顾实《穆天子传西征讲疏》就定“己巳朔”,戊寅初十。顾实在二月后置闰,戊寅就成了“闰二月初十”。本不为错,考虑到接续“望簋”历日,闰二月就不恰当了。\n定三月庚午朔(05h49m)。初九戊寅,天子北征,乃绝漳水。十一庚辰,至于□。十四癸未,雨雪,天子猎于邢山之西阿。十六日乙酉,天子北升于□,天子北征于犬戎。二十一日庚寅,北风雨雪,天子以寒之故,命王属休。二十五日甲午,天子西征,乃绝隃之关隥(今雁门山)。\n四月己亥朔(13h43m)。初一己亥,至于焉居、禺知之平。初三辛丑,天子西征,至于人。初五癸卯〔酉〕(此月无癸酉),天子舍于漆泽,乃西钓于河。初六甲辰,天子猎于渗泽。初八丙午,天子饮于河水之阿。初十戊申〔寅〕(此月无戊寅),天子西征,骛行,至于阳纡之山。十五癸丑,天子大朝于燕然之山,河水之阿。二十日戊午,天子命吉日戊午,天子大服,天子授河宗璧。二十一己未,天子大朝于黄之山。二十七乙丑,天子西济于河。二十八丙寅,天子属官效器。\n五月己巳朔。《传》无载。\n六月戊戌朔(05h34m)。望簋:唯王十又三年六月初吉戊戌。铭文与天象吻合。\n卷二,丁巳……知此前有若干脱漏。丁谦云:距前五十一日。盖自河宗至昆仑、赤水须经西夏、珠余、河首、襄山诸地。五十一日行四千里恰合。\n戊戌朔,二十日丁巳,天子西南升□之所主居。二十一戊午,寿□之人居虑。二十四吉日辛酉,天子升于昆仑之丘,以观黄帝之宫。二十六癸亥,天子具蠲齐牲全,以禋□昆仑之丘。二十七甲子,天子北征,舍于珠泽。\n《传》载“季夏丁卯”,即六月丁卯朔。说明实际用历,前六月戊戌朔,月小,二十九日。而实际天象,午月戊辰朔162分(丁卯15h10m),实际用历午月(后六月)丁卯朔,不用四分术戊辰162分,更近准确。\n闰六月丁卯朔,季夏(初一)丁卯,天子北升于舂山之上以望四野。初六壬申,天子西征。初八甲戌,至于赤乌之人其献酒千斛于天子。十三日己卯,天子北征,赵行□舍。十四日庚辰,济于洋水。十五日辛巳,入于曹奴之人戏觞天子于洋水之上。顾实云:“曹奴当即疏勒。”十六壬午,天子北征,东还。十八日甲申,至于黑水。降雨七日。二十五辛卯,天子北征,东还,乃循黑水。二十七癸巳,至于群玉之山。\n闰六月,月大,三十日。故《传》载“孟秋丁酉”,进入七月。\n七月丁酉朔(02h47m),四分术丁酉朔661分。孟秋初一丁酉,天子北征。初二戊戌,天子西征。初五辛丑,至于剞闾氏。初六壬寅,天子祭于铁山。已祭而行,乃遂西征。初十丙午,至于韩氏。十一日丁未,天子大朝于平衍之中。十三日己酉,天子大飨正工、诸侯、王吏、七萃之士于平衍之中。十四日庚戌,天子西征,至于玄池。天子三日休于玄池之上。十七日癸丑,天子乃遂西征。二十日丙辰,至于苦山。二十一日丁巳,天子西征。二十三日己未,宿于黄鼠之山西(阿)。二十七癸亥,至于西王母之邦。\n卷三,吉日甲子二十八日,天子宾于西王母。二十九乙丑,天子觞西王母于瑶池之上。\n八月丙寅朔(16h58m),四分术丁卯朔220分。《传》无载。\n九月丙申朔(09h51m),四分术丙申朔719分。\n实际用历九月丙申朔。初一丙申。十二丁未,天子饮于温山。十四日己酉,天子饮于溽水之上。六师之人毕聚于旷原。天子三月舍于旷原。六师之人翔畋于旷原。六师之人大畋九日。\n十月丙寅朔(04h42m),四分术丙寅朔278分。\n十一月乙未(23h58m),四分术乙未朔777分。\n十二月乙丑朔(17h48m),四分术乙丑朔333分。\n公元前993年,穆王十四年,上年置闰,闰六月,转入今年建丑,正月乙未朔(08h54m),四分术甲午832分。甲午分数大,与乙未相差无几。实际用历取甲午,或取乙未,均可。\n正月(丑)甲午朔(乙未08h54m)。《传》无记。\n二月(寅)甲子朔391分(甲子20h57m)。《传》无记。\n三月癸巳890分(甲午06h28m)。顾实取甲午朔,己亥初六。癸巳朔,初七己亥,天子东归。初八庚子,至于□之山而休,以待六师之人。\n四月癸亥朔449分(12h25m)。顾实取四月甲子朔,初一甲子,十七庚辰,天子东征。二十日癸未,至于戊□之山。二十二乙酉,天子南征,东还。二十六己丑,至于献水,乃遂东征。\n五月癸巳朔8分(壬辰21h37m)。癸巳分数小,壬辰分数大,朔日近之。因为后有“孟秋癸巳”“(仲)秋癸亥”的文字,顾实取五月甲午朔,虽朔差一日,视为实际用历,可从。这样,从二月甲子朔算起,出现四个连大月,似乎不好理解。考虑到历术的粗略,又是远在千里万里之外的记录,朔差一日,也是情有可原的,未便苛求。否则,后面的“孟秋癸巳”就不好解释了。实际天象不会错,是实际用历出了偏差,将一个小月误记为大月,如此而已。\n五月甲午朔,初六己亥,至于瓜之山。初八辛丑,天子渴于沙衍,求饮未至,七萃之士高奔戎刺其左骖之颈,取其青血以饮天子。十一日甲辰,至于积山之边。十二日乙巳,诸飦献酒于天子。\n六月壬戌朔507分(04h49m)。实际用历,顾实定癸亥朔,朔差一日。\n卷四,初一癸亥,十八庚辰,至于滔水。十九辛巳,天子东征。二十一日癸未,至于苏谷。二十四丙戌,至于长。二十五丁亥,天子升于长,乃遂东征。二十八庚寅,至于重邕氏黑水之阿。\n七月辛卯朔(12h54m),四分术壬辰66分。实际用历,顾实据《传》记“孟秋癸巳”“五日丁酉”定癸巳朔,朔差一日。\n七月初一癸巳,孟秋癸巳,命重邕氏供食于天子之属。“五日丁酉”即初五丁酉,天子升于采石之山,于是取采石焉。天子一月休。\n八月庚申朔(22h56m),四分术辛酉565分。实际用历,顾实据《传》“(仲)秋癸亥”定八月癸亥朔。援例,“季夏丁卯”“孟秋丁酉”“孟秋癸巳”“(仲)秋癸亥”,皆指朔日。四分术,七月壬辰66分,月大,八月壬戌朔。壬戌之去癸亥,还是朔差一日,这是记事者延续前面的失误而不知而不改。这个“失误”仅是今人的认识,反映了当时人的历术水平而已。干支纪日并不紊乱,大原则没有出错,只是在处理月大月小上没有找到规律。到春秋时代,大月小月的周期才得以逐步掌握,从《春秋左氏传》的历日中可以考知。\n(仲)秋癸亥,八月癸亥朔。初一癸亥,天子觞重邕之人 鸳。初三乙丑,天子东征, 鸳送天子至于长沙之山。初四丙寅,天子东征,南还。初七己巳,至于文山。天子三日游于文山。初十壬申(误记“壬寅”,本月无壬寅),天子饮于文山之下。十一癸酉,天子命驾八骏之乘。十二甲戌,巨蒐之人 奴觞天子于焚留之山。十三日乙亥,天子南征阳纡之东尾。十九日辛巳,至于□ 河之水北阿。\n九月庚寅朔(11h58m),四分术辛卯124分,两者误差在半日,算是吻合。实际用历,承上月癸亥朔,本月壬辰朔,与辛卯朔差一日。\n九月壬辰朔,二十二癸丑,天子东征,栢夭送天子至于 人。天子五日休于澡泽之上。二十七戊午,天子东征。\n十月庚申(04h22m),四分术庚申623分。实际用历,承上月壬辰朔,本月壬戌朔。\n“孟冬壬戌”即十月壬戌朔,与上诸例吻合。十月初一壬戌,至于雷首。犬戎胡觞天子于雷首之阿。初二癸亥,天子南征。初五丙寅,天子至于钘山之队(隧)。十二癸酉,天子命驾八骏之乘,赤骥之驷,造父为御。南征翔行,迳绝翟道,升于太行,南济于河,驰驱千里,遂入于宗周。十九庚辰天子大朝于宗周之庙。吉日甲申二十三,天子祭于宗周之庙。二十四乙酉,天子□六师之人于洛水之上。二十六丁亥,天子北济于河。\n十一月己丑(23h22m),四分术庚寅182分,己丑合朔在夜半23h22m,与庚寅吻合。实际用历,承上月壬戌朔,定本月壬辰朔。朔差一日。\n十一月壬辰朔,记“仲冬壬辰”,至累山之上。初六吉日丁酉,天子入于南郑。西征结束。\n以上,我们将《穆天子传》主体文字录入纪时系统,可以弄明白很多问题:\n《穆天子传》是一部珍贵的史料记录,记录了周穆王西征的整个行程,季节时日记载得清清楚楚,历日干支前后连贯,一丝不乱,这就体现了它的真实性与可靠性。说明周穆王时代是有“史记”的,整个西周一代也是有“史记”的,没有这个“源”,就没有《春秋》这个“流”。\n《穆天子传》记录了周穆王十三年、十四年西行的主要活动,反映了三千年前中原与西域与中亚的沟通,各民族的交流往来可追溯到三千年前,穆王西征有开拓性的意义。\n周穆王十三年合公元前994年,十四年合公元前993年,实际天象与《穆天子传》所记历日干支完全吻合,这难道是偶然的吗?历日干支的记录反映了中华民族三千年前的历术水平。借助干支历日的记录,三千年后的今天,我们能够将它们一一复原,本身就说明华夏民族早期的历术水平是高超的,大体准确的,不用说在当时也是首屈一指的。\n干支历日的勘合校比,证实周穆王元年当在公元前1006年,它对于整个西周一代王年的探讨有重要意义。旧说克商在公元前1122年,新说克商在公元前1046年,都会从根本上动摇。\n从观象授时到四分历法——张汝舟与古代天文历法学说 # 张汝舟先生\n【表一】资料图片\n【表二】资料图片\n【表三】\n【表四】\n【求索】\n顾炎武《日知录》有言:“三代以上,人人皆知天文。‘七月流火’,农夫之辞也;‘三星在户’,妇人之语也;‘月离于毕’,戍卒之作也;‘龙尾伏辰’,儿童之谣也。”\n在中国古诗文中提及天文星象的比比皆是,如“七月流火,九月授衣”(《诗经·豳风·七月》);“牵牛西北回,织女东南顾”(晋陆机《拟迢迢牵牛星》);“人生不相见,动如参与商”(唐杜甫《赠卫八处士》);等等。可见,在古代,“观星象”是件寻常事,绝非难事。\n但到了近现代,天文却成为“百姓日用而不知”的学问。所以顾炎武慨叹:“后世文人学士,有问之而茫然不知者矣。”\n20世纪60年代,张汝舟先生凭借其扎实的古汉语功底、精密的考据学研究方法和现代天文历算知识,完整地释读了中国古代天文历法发展主线。从夏商周三代“观象授时”到战国秦汉之际历法的产生与使用过程,他拨开重重迷雾,厘清了天文学史中的诸多疑难问题,使得这一传统绝学恢复其“大道至简”的本质,成为简明、实用的学问。\n考据成果\n《周易》《尚书》《诗经》《春秋》《国语》《左传》《吕氏春秋》《礼记》《尔雅》《淮南子》等古籍中有大量详略不同的星宿记载和天象描述。《史记·天官书》《汉书·天文志》更是古天文学的专门之作。\n夏、商、周三代观象授时的“真相”,经历春秋战国的社会动荡,到汉代已经说不清楚了。历法产生后,不必再详细记录月相,以致古代月相名称“生霸”“死霸”的确切含义竟也失传。自汉代至今,众多学者研究天文历法,著作浩如烟海。研究者受限于时代或者本人天文历算水平,有些谬误甚深,把可靠的古代天文历法宝贵资料弄得迷雾重重。张汝舟先生对此一一加以梳理。\n1.厘清“岁星纪年”迷雾。“岁星纪年”在春秋时期一度行用于世,少数姬姓国及几个星象家都用过。岁星,即木星,运行周期为11.86年,接近12年。“观象”发现岁星每年在星空中走过一辰30°,将周天分为十二辰,岁星每年居一辰,这就是岁星纪年的天象依据。可是,岁星运行周期不是12年整,每过八十余年就发生超辰现象。这是客观规律,无法更改。鲁襄公二十八年(公元前545年),出现了“岁在星纪而淫于玄枵”。“岁星纪年”因此破产,仅行用百余年。而古星历家用以描述岁星运行的十二次(十二宫)名称(星纪、玄枵、娵訾……)却流传下来。而后,星历家又假想一个理想天体“太岁”,与岁星运行方向相反,产生“太岁纪年法”。但终因缺乏实观天象的支撑,也仅昙花一现。另取别名“摄提格”“单阏”“执徐”“大荒落”……作为太岁纪年的名称,代替十二地支。阅读古籍时,将这些“特殊名称”理解为干支的别名即可(见春秋战国时期所用干支纪年别名与干支对应关系表)。\n2.纠正“四象”贻害。张汝舟先生绘制的星历表是依据宋人黄裳《星图》所绘二十八宿次序画的。传统星历表迷信《史记·天官书》的“四象”说,二十八宿分为东方苍龙、北方玄武、西方白虎、南方朱雀。由于四灵要配四象,于是宿位排列颠倒了,后人误排二十八宿、十二宫方向,贻误不浅。(见【表一】)\n张氏星历表(见【表二】)纠正了二十八宿排列次序;删除外圈十二地支;增加“岁差”方向;增加二十八宿上方括号内数字,这是唐宋历家所测,与春秋时期数据差异不大。用此表释读古籍中的天象清晰明了。\n3.否定“三正论”。观象授时时期,古人规定冬至北斗柄起于子月,终于亥月,这是实际天象,不可更改。每年以何月为正月,则会导致月份与季节之间调配不同,这就是“建正”(用历)问题。春秋时期人们迷信帝王嬗代之应,“三正论”大兴,他们认为夏商周三代使用了不同的历法,“夏正建寅,殷正建丑,周正建子”,即夏以寅月为正月,殷以丑月为正月,周以子月为正月。“改正朔”,以示“受命于天”。秦始皇统一中国后,以十月为岁首,也源于此。(见【表三】)\n实际上,四分历产生之前,还只是观象授时,根本不存在夏商周三代不同正朔的历法。所谓周历、殷历、夏历不过是春秋时期各诸侯国所用的子正、丑正、寅正的代称罢了。春秋时代诸侯各国用历不同是事实,实则建正不一。大量铜器历日证明,西周用历建丑为主,失闰才建子建寅。春秋经传历日证明,前期建丑为主,后期建子为主。\n排除“三正论”的干扰,中流伏内的含义才得以显现。依据《夏小正》“八月辰(房宿)伏”“九月内(入或纳)火”“正月初昏参中”“三月参则伏”等连续的星象记载,确定中、流、伏、内是二十八宿每月西移一宫(30°)的定量表述。张汝舟在《〈(夏)小正〉校释》里详加阐释。《诗经·七月》中“七月流火”是实际天象,是七月心宿(大火)在偏西30°的位置,则六月大火正中,这是殷历建丑的标志。毛亨注“七月流火”(“火,大火也;流,下也”)已经不能精确释读天象了。后世多依毛氏阐述,远离了天文的“真相”。(见【表四】)\n4.否定《三统历》。汉代刘歆编制的“三统历”详载于班固《汉书·律历志》,《三统历》被推为我国三大名历(汉《三统历》、唐《大衍历》、元《授时历》)之首,实则徒有虚名。“三统历”本质即为四分历,是《殷历》“甲寅元”的变种,且从未真正行用过。刘歆用“三统历”推算西周纪元元年,但受时代限制,他不明四分术本身的误差,也不知道“岁差”的存在。所以他推算西周历日总有三天、四天的误差。王国维先生即是据《三统历》推算结果悟出“月相四分说”,上了刘歆的当。\n“四象”“三正论”“三统历”“岁星纪年”,张汝舟称之为“四害”。去除“四害”,方能建立正确的星历观。\n四分历法\n语言学家、楚辞学家汤炳正先生曾言:“两千年以来,汝舟先生是第一位真正搞清楚《史记·历书·历术甲子篇》与《汉书·律历志·次度》的学者。”《历术甲子篇》《次度》是中国古代天文历法的两大宝书,尘封两千余年,无人能识。张汝舟先生考据出司马迁所记《历术甲子篇》正是我国第一部历法——四分历;《次度》所记载的实际天象,正是四分历实施之时,在战国初年公元前427年(甲寅年)。依此两部宝书,张汝舟先生还原了我国从战国初到三国蜀汉亡行用了700年的四分历。\n四分历是以365又1/4日为回归年长度,29又499/940日为朔策(平均一月长度),十九年闰七为置闰方法的最简明历法。张汝舟先生熟知现代天文历法体系,明了四分历的误差,发明出3.06年差分的算法,以公元前427年为原点,前加后减,修正四分历的误差。这一算法的发明,使古老的四分历焕发青春。简明的四分历法成为可以独立运用的历法体系,上推几千载,下算数千年。其推算结果,既与现代天文学推测的实际天象相吻合(只有平朔、定朔的误差而已),又与古籍、出土文物中的历点相吻合,客观上验证了张汝舟先生所建立的天文历法体系的正确性。张汝舟先生不仅还原了四分历的使用历史,同时构建了一套完整自洽并可以独立运用的古代天文历法体系。\n张汝舟先生精研古代天文历法,首先应用于西周年代学研究。1964年发表《西周考年》,得出武王克商在公元前1106年,西周总年数336年的确凿结论。\n《史记》年表起于共和元年(公元前841年),共和元年至今近三千年纪年,历历分明。共和之前西周各王年,向无定说。最重要的时间点即是“武王克商”之年。李学勤先生说:“武王克商之年的重要,首先在于这是商周两个朝代的分界点,因此是年代学研究上不可回避的。这一分界点的推定,对其后的西周来说,影响到王年数的估算;对其前的夏商而言,又是其积年的起点。”\n《西周考年》中利用古籍、出土器物的41个宝贵历点(有王年、月份、纪日干支及月相的四要素信息),以天上材料(实际天象)、地下材料(出土文献)与纸上材料(典籍记载)“三证合一”的系统方法论,确证武王克商在公元前1106年。张汝舟先生总结他的方法为一套技术——四分历推步,四个论点——否定“三统历”、否定“三正论”、否定“月相四分说”、确定“失闰限”与“失朔限”。\n“月相四分说”与“月相定点说”是目前史学界针锋相对的两种观点。“月相四分说”是王国维先生在“三统历”基础上悟出的,在夏商周断代工程中进一步演化为“月相二分说”。而张汝舟先生坚持的“月相定点说”是四分历推步的必然结果,有古籍、青铜器中历点一一印证。月相定点与否的争执,本质是对古代四分历法是否有足够清晰认识的问题。\n清儒有言:“不通声韵训诂,不懂天文历法,不能读古书。”诚非虚言。考据古天文历法是一项庞大繁难的系统工程。古天文历法源远流长,张汝舟先生的学术博大精深,本文所述仅是“冰山一角”。我们在从汝舟师学习的过程中有这样的体会:一是要树立正确的星历观点,才不至为千百年来的惑乱所迷;二是要进行认真的推算,达到熟练程度,才能更好地掌握他的整个体系。张汝舟先生古天文历法体系是简明、实用的,用于考证古籍中的疑年问题游刃有余,用于先秦史年代学的研究屡建奇功。\n应用举例\n例1.《尚书·尧典》四仲中星及“岁差”\n《尧典》所记“日中星鸟,以殷仲春”“日永星火,以正仲夏”“宵中星虚,以殷中秋”“日短星昴,以正仲冬”是观象授时的最早星象记录,当时仅凭目力观测,未必十分准确。《尧典》作于西周时代应该无疑。运用张氏星历表计算,南方星宿至东方心宿(大火)的距离为星7/2+张18+翼18+轸17+角12+亢9+氐15+房5+心宿5/2=100度(首尾两星宿用度数1/2,其他星宿顺序相加),心宿至北方虚宿82.75度,虚宿至西方昴宿94.5度,昴宿至星宿88度,四个数相加正合周天365.25度(中国古代一周天为365.25度,等于现代天文学的360°,古代一度略小于1°)。四个星宿大致四分周天,均在90度上下,正对应四个季节时间中点。若昏时观天象,春分时,星宿在南中天。夏至时是大火正中,秋分时是虚宿,冬至时为昴宿。\n东晋成帝时代,虞喜根据《尧典》“日短星昴”的记载,对照当时冬至点日昏中星在壁宿的天象,确认每年冬至日太阳并没有回到星空中原来恒星的位置,而是差了一点儿,这被称为岁差。\n张汝舟先生利用“岁差”,分析古籍中“冬至点”的位置变化,最终得出《次度》所记“星纪:初,斗十二度,大雪;中,牵牛初,冬至;终于婺女七度”是战国初期四分历初创时的实际天象。\n张氏星历表(见【表二】)可以直观解读古籍中的天文天象。\n例2.屈原的出生年月问题\n这是文史界的热门话题。近人多信“岁星纪年”,用所谓“太岁超辰”来推证,生出多种多样的结论,但却无法令人信服。\n《离骚》开篇“摄提贞于孟陬兮,惟庚寅吾以降”,就告诉了我们屈原生于寅年寅月寅日。考虑屈原政治活动的时代背景,其出生年只能在两个寅年,一是公元前355年丙寅(游兆摄提格),一是公元前343年戊寅(徒维摄提格)。我们用四分历推步法来检验(推算过程略)。公元前355年丙寅年寅月没有庚寅日,应该舍弃。公元前343年(楚宣王二十七年),戊寅年正月(寅月)二十一日(庚寅),正是屈原的出生日。这也是清人邹汉勋、陈瑒,近人刘师培的结论,张汝舟《再谈屈原的生卒》又加以申说、推算。\n学术发展\n受历史条件的限制,张汝舟《西周考年》中只用到41个历点。20世纪80年代后,陆续出土上千件西周青铜器,其中四要素俱全者已接近百件。我们积累了文献中16个历点,青铜器82个历点,继续张汝舟先生的学术方向,更进一步确证武王克商之年在公元前1106年,得出西周中期准确的王序王年,排出可靠的《西周历谱》,这些成果见于《西周王年论稿》(贵州人民出版社,1996年),汇总于《西周纪年研究》(贵州大学出版社,2010年)。\n我们以张汝舟先生古代天文历法体系为基础理论,以“三重证据法”为系统方法论,坚持“月相定点”说。针对日益增多的出土铜器铭文,发展出铜器历日研究的正例变例研究方法、铜器王世系联法等理论。我们有《铜器历日研究》(贵州人民出版社,1999年)一书为证。\n我们坚信西周历谱的可靠,是因为每一个历点均与实际天象相合,非人力所能妄为。我们坚守乾嘉学派的学风,“例不十,法不立”,反对孤证。对每一件铜器、每一个古籍文字均详加考据。饶尚宽教授2001年排出《西周历谱》后,又有畯簋、天亡簋等多件新增青铜器的重新释读,均能够一一放入排定的框架,绝无障碍。我们自信地说,今后再有新的历日出现,也必然出不了这个框架。\n“六经皆史,三代乃根”,这几乎是历代文化人的共识。中华文明五千年,她的根在夏商周“三代”。弄明白三代的历史,是中国史学家的职责。2016年科学出版社出版了《夏商周三代纪年》一书。西周年代采用张汝舟先生可靠的336年说,商朝纪年采用628年说,夏朝纪年采用471年说,都做到于史有据。李学勤先生为此书题词:“观天象而推历数,遵古法以建新说。”以此表示肯定。\n随着学术的蓬勃发展,张汝舟先生的弟子、再传弟子不断有著作问世,丰富了其古天文学说。贵州社科院蒋南华教授出版了《中华传统天文历术》(海南出版社,1996年)、《中华古历与推算举要》(与黎斌合著,上海大学出版社,2016年);新疆师大饶尚宽教授出版有《古历论稿》(新疆科技出版社,1994年)、《春秋战国秦汉朔闰表》(商务印书馆,2006年)、《西周历谱》(收入《西周纪年研究》,贵州大学出版社,2010年);后学桂珍明参与编著《夏商周三代纪年》《夏商周三代事略》;后学马明芳女士参与整理古天文学著作,写有普及本《走进天文历法》,并到各地书院面授这一学术。种种说明,古天文“绝学”后继有人,溢彩流光。\n古代天文历法,是“人类第一学,文明第一法”。张汝舟先生古代天文历法体系提供了一套可靠的研究古籍天象的系统理论,必将在未来的应用中发扬光大。\n学人小传\n张汝舟(1899—1982)名渡,自号二毋居士,安徽全椒县章辉乡南张村人。少时家贫而颖异好学,赖宗族资助读书。1919年毕业于全椒县立中学校,无力升学,被荐至江浦县三虞村任塾师八年。1926年考入中央大学国文系,受业于王冬饮、黄季刚、吴霜崖等著名学者门下,学业日进。毕业后,任教于合肥国立六中、湖南蓝田国立师范学院等校。1945年任贵州大学教授。1978年应聘到滁州师专任顾问教授。1982年病逝于滁州师专。曾担任中国训诂学研究会顾问、中国佛教协会理事、《汉语大词典》安徽编纂处复审顾问、安徽省政协委员等社会职务。\n张汝舟从教工作、学术研究相得益彰,一生笔耕不辍,完成书稿近300万字。他学问广博,著述涉及经学、史学、文学、哲学、文字学、声韵学、训诂学、考据学、佛学等各个领域,均有独到见解。他对声韵、训诂、考据学的研究,发扬了章(太炎)、黄(侃)学派声韵训诂学的成果,坚持乾嘉学派的治学方法,凡所称引,必言而有据;他对汉语语法的研究,坚持用中国的语言体系来研究古汉语语法,简明、实用。他在古诗古文方面的著述涉及面甚广,足以展现一代学人的全面风采。他对古代天文历法的研究,于繁芜中见精要,于纷乱中显明晰,完整诠释了古代观象授时及四分历法产生的全过程,独树一帜,自成一家。他为人平易纯朴、恭谨谦逊,遇到不平之事却敢于仗义执言。对青年后学循循善诱、诲人不倦,深受朋辈及后学的尊崇和爱戴。\n张闻玉,1941年生,四川省巴中人,现任贵州大学先秦史研究中心主任,曾在安徽滁州张汝舟先生门下问学,又从金景芳先生学《易》,在高校主讲古代汉语、古代历术、传统小学、三代纪年等课程,从事先秦史学术研究,强调传世文献、出土器物、历日天象“三证合一”;马明芳,毕业于北京大学物理系,师从张闻玉先生。\n本文作者:张闻玉 马明芳(执笔)\n原文刊载于《光明日报》( 2017年06月12日 16版)\n附表一观象授时要籍对照表 # 附表二殷历朔闰中气表 # 附表三术语表 # 蔀:4章,76年,940月,27759日。(章:19年,235月。)\n定气:以太阳在黄道上位置来划分节气,两节气之间的时间长度就会不同。定气反映真实天象。\n平气:两冬至之间的时日二十四等分之,所得为二十四节气之平气。每气为15732日。\n中气:从冬至开始的二十四节气中逢单数的节气。依照《汉书·次度》记载,这十二节气正处于相应宫次的中点,故称中气。\n气余:中气之余分。干支只能记整数,涉及小数得化为余分。\n定朔:朔为初一。合朔时刻不取平均值而采用实际天象的合朔时刻。\n经朔:以四分术推算的平朔。\n平朔:两朔日之间的时日,以平均值29499940日计,为平朔。\n朔策:一个朔望月长度,29.5306日。四分历朔策为29499940日。\n年差分:四分历基本数据是一年36514日,与真值365.2422有误差,每年之差3.06分(一日940分计)即年差分。\n岁实:根据相邻两次冬至时刻而定出的年,即回归年长度,36514日。\n岁差密律:冬至点每年在黄道上西移50.2秒,71年8个月岁差一度。\n算外:自古计数得计入起点日,与算法相差为1,运算计数得加这个“1”,叫算外。\n昏旦中星:观星象可在晨昏两时,观测者头上的星就是中星。我们处北半球,中星总是在偏南的上方。\n去极度:所测天体距北极的角距离。\n入宿度:以二十八宿中某宿的距星为标准,所测天体与这个距星之间的赤经差。\n推步:指室内推算,对日月运行时间进行计算,使回归年与朔望月长度配合得大体一致。\n积年:历术的年月日是有周期的,干支纪年纪日也是周而复始。古人将这个周期无限加大,干支年累积若干倍,甚至上溯到二百七十万年前,目的是追求一个理想的历元。这就是积年、积年术。神秘的数字,并无实际意义。\n二次差:即二次差内插公式。指计算函数的近似值的方法。在已知若干函数值的情况下,构造一简单函数,来代替所计算的函数,达到化难为易的目的。三次差内插法的计算较二次差更为准确。主要征引书目 [TP版(-+29mm,0)%]\n主要征引书目 # 一、张汝舟:二毋室古代天文历法论丛.浙江古籍出版社,1987\n二、饶尚宽:上古天文学讲义(打印本,新疆师大)\n三、中国天文学史.科学出版社,1981\n四、陈遵妫:中国天文学史.上海人民出版社,1980\n五、郑文光:中国天文学源流.科学出版社,1979\n六、历代天文律历等志汇编.中华书局,1975\n七、南京大学编印章太炎先生国学讲演录.1984\n八、冯秀藻、欧阳海:廿四节气.农业出版社,1982\n九、王国维:观堂集林.中华书局,1959\n十、张钰哲主编:天问.江苏科技出版社,1984\n十一、淮南子.诸子集成初编\n十二、礼记.十三经注疏本\n十三、春秋经传集释.四部丛刊初编\n后记 # 汤炳正先生爱孙序波同志为其祖父整理出版了《楚辞讲座》,2006年9月出版后,序波很快于11月寄来一本,要我写点介绍文字。因为新中国成立前汤先生曾在贵州大学任教过,与先师张汝舟先生过从甚密,我也受惠于汤先生,多有交往。我毫无推辞地写了《从〈楚辞讲座〉出版想到的》在《贵州日报》上发表。就中,我对广西师范大学出版社有极好的印象,感到他们的远见卓识,在商潮涌涌的今天,还踏踏实实地在弘扬传统文化,向炎黄子孙推出一本又一本的学术精品,使文化学术界弥漫出一股久违的清新空气。我的文章第一句话就是:“一流的大学出版社未必办在一流的大学,这是指广西师大出版社。”那的确是我真切的感受。其后,序波从短信上告诉我,他已推荐我的《古代天文历法说解》给师大出版社。我知道出版学术著作的艰难,商业运作不赚钱就得赔本,谁个会干?也就并不在意。有老母在堂,春节期间我一直在四川老家省亲,其间序波短信说“出版社同意审稿”,要我马上寄出稿件。3月中旬回到学校后,耽误几天才将稿子邮寄。5月初,编辑王强先生寄来了“出版合同”。审稿的及时、出版的决断,都在我的意料之外。我只得请贵州大学已毕业的古代文学硕士现任教于贵州广播电视大学的邹尤小朋友帮助,完成该书的电子文本。他熬了几个通宵扫描传输,在月内就一一处理完毕。\n古代天文历法,号称“绝学”,海外华人美曰“国宝”。我受教于张汝舟先生,二十多年来,能与交流者寥寥,深感知音难得。反而是老一辈学者如东北师大的陈连庆老先生的苦苦追求,令我感动。周原岐山的庞怀靖老先生,八十几岁高龄,还孜孜不倦地学习、掌握历术的推演,直到弄明白了月相定点,毅然抛弃信奉了几十年的“月相四分”,另做铜器考释文章。庞老先生真正做到了“朝闻道,夕死可矣”。\n古代天文历法,自然是科学的。科学,就无神秘可言,它必须是简明而实用的。要掌握它,也就不难。就其内容,如《尧典》说,两个字:历、象。司马迁《史记》理解为“数、法”。一个是天象,天之道,自然之道,有“法”可依之道;一个是历术,推算之术,以“数”进行推演。日月在天,具备基本的天文知识,古书中的文字就容易把握。涉及历术,就得学会推算,用四分法推算实际天象。推演历日是最重要的步骤,不下这个功夫,历法就无从谈起。我在书后附有几篇文章,算是历术的具体运用,给青年学人一个示范。掌握了历术的推演,在历史年代的研究中,在铜器年代的考释中,你都会感到游刃有余。张汝舟先生在王国维“二重证据法”之外,加一个“天象依据”,做到“三证合一”,结论自然可靠。只有可靠的结论,才能经受时代的验证,对得起子孙后代,三百年也不会过时。“三证合一”,古代天文历法在文史研究中的地位,就不是可有可无的了。你要学会了推算,考释几个历日,你自会感受这门学问的妙不可言,奇妙无穷。她不愧是华夏民族的瑰宝!\n2007年5月30日贵阳花溪寓所\n[1] 李学勤:《论簋的年代》,《中国历史文物》,2006年第3期;王冠英:《簋考释》,《中国历史文物》,2006年第3期。\n[2] 见张闻玉《西周王年论稿》,贵州人民出版社,1996年。其中载《西周朔闰表》,是用四分术推算出来的实际天象。另见张培瑜《中国先秦史历表》,齐鲁书社,1987年。\n[3] 见《西周王年论稿》,第86页。\n[4] 张闻玉:《武王克商在公元前1106年》,见《西周王年论稿》。\n[5] 08h51m,指合朔的时(h)与分(m),准确的实际天象,引自《中国先秦史历表》。\n[6] 张闻玉:《铜器历日研究》,贵州人民出版社,1999年,第36~41页。\n[7] 见《西周诸王年代研究》,贵州人民出版社,1997年,第367~379页。\n[8] 载《人文杂志》,1989年第5期;又见《西周王年论稿》,贵州人民出版社,1996年。\n[9] 载台湾《大陆杂志》,1992年第2期第85卷;又见《西周王年论稿》。\n[10] 见《铜器历日研究》,贵州人民出版社,1999年,第35页。\n"},{"id":155,"href":"/zh/docs/culture/%E5%8F%A4%E4%BB%A3%E5%A4%A9%E6%96%87%E5%8E%86%E6%B3%95%E8%AE%B2%E5%BA%A7/%E7%AC%AC6%E8%AE%B2-%E7%AC%AC7%E8%AE%B2/","title":"第6讲-第7讲","section":"古代天文历法讲座","content":" 第六讲四分历的应用 # 四分历法是观象授时高度发展的产物,古人制定四分历法就是为了取代观象授时,服务于人类社会的生产和生活,这是毫无疑义的。因此,年代学的基础课题就是掌握四分历法,用它来推算上古历点,为解决有关的学术问题服务。特别是近代,出土文物越来越多,古史古事的考订,都需要我们确定其年代及月日。我们依据四分历法仍可以求得密近的实际天象,解决其中的疑难。这正是文史工作者学习古天文历法的目的之一。\n一、应用四分历的原则 # 明确了殷历甲寅元(即《历术甲子篇》)创制于公元前427年,就可以将四分历在实际考证中普遍应用,推算古代典籍及出土的地下器物所载的历点,并在推算中验证殷历甲寅元的正确性。\n四分历是战国初期创制并行用,大体到三国时期的蜀汉废止。如果将四分历广泛应用,必须明确几个问题。\n第一,殷历甲寅元一经创制行用,就成为中华民族的共同财富,通行于当时各国。战国纷争,诸侯力征,不统于王,各国用历也标新立异,所以后人总认为“战国时代各国历法不同”。这只看到了问题的表象。四分历于战国初期行用,这一法则在当时就是不可改变的了。各国用历虽花样繁多,名号各殊,或岁首不同,或建正有异,都只能在四分历法则内改头换面,实质不变也变不了。我们用四分历推算有关历点,是掌握了一个普遍的原则,所得结果自然不会有误。\n战国时代,各国是否一致行用四分历法呢?\n不难明白,历法不是产生于某国某君某人之手,而是历代星历家血汗的结晶。可能经过某些君王(比如魏文侯)的提倡归功于某些星历家(比如楚人甘德、魏人石申)的勤劬。但历法一旦创制就不可能为某国某君所垄断,必然普施于华夏人民足迹之所至。谁会舍先进的历法不用而去吃观象授时的苦头?且战国初期,朝秦暮楚的士大夫比比皆是,历法一经行用自然不受国界的约束。因此四分历必能普施于战国时期各诸侯国。\n再说,经商周至战国初年,干支纪年已千百年不紊,各国都使用一个共同的干支日历,月球的朔望又人人可见,日与月的一致自不待言。有这样一个共同的月历、日历作为基础,历法普施于战国才有可能。\n从现有文献资料看,《孟子》所记时令与《楚辞》所记,仅只是岁首不同而已。据《孟子》载:“七八月之间雨集,沟浍皆盈”(《孟子·离娄下》);又“七八月之间旱则苗槁矣,天油然作云沛然下雨,则苗浡然兴之矣”(《孟子·梁惠王上》)。讲的是下暴雨。我国山东一带下暴雨的时间,当是夏历五、六月。因《孟子》一书的用历是取建子为正,所以与建寅为正的夏历有两月之差,究其实则是指同一天象,《孟子》用的也是四分历。\n据《楚辞·怀沙》载:“滔滔孟夏兮,草木莽莽。”孟夏即四月,草木繁茂,与建寅为正的夏历合。又《楚辞·抽思》:“望孟夏之短夜兮,何晦明之若岁。”讲初夏昼长夜短明显起来,正合夏历。\n秦用四分历,从《史记·秦本纪》中也有反映:“(昭襄王)四十八年十月韩献垣雍。……正月兵罢复守上党,其十月五大夫陵攻赵邯郸。又四十九年正月益发卒佐陵。陵战不善,免,王龁代将,其十月将军张唐攻魏。”此两处,先记秦十月、正月,再记“其十月”(它的十月)。因为兵入赵魏之地,故用赵魏之月序记。足证秦与赵魏同用四分历,只不过秦以十月为岁首,三晋用夏正罢了。\n燕国僻远,用历无考,以理推之,密近三晋。一句话,《历术甲子篇》通用于七国,战国时代实际全用四分历。\n由于齐鲁建子为正,秦历又建亥为首,与楚、晋各异,似乎战国有多种历法了,这便给“三正论”者以生事的机会,造成后世的惑乱。\n战国用历原本四分术,然而为什么名目如此繁多呢?\n首先,列强出于政治斗争的需要,在用历上往往变换一些手法,以示与周王朝分庭抗礼,尽管都用四分历却有意标新立异,独树一帜。\n其次,自封为王,欲兼天下,必然要利用“君权神授”的观念,这就是历志上“改正朔,易服色”的记载,用以表明“受命于天”,从而威天下而揽民心。\n再次,托古作伪以自重,也是列强君王惯用的手法。四分历创制之初,就曾伪称“成汤用事十三年”把创立之功归于前代圣王。秦历托名“颛顼”,也同样出于托古自重。战国时代所谓“周历”“夏历”,莫不如此。汉代有“古六历”之说(黄帝历、颛顼历、夏历、殷历、周历、鲁历),那虽是后人的附会,实际也可见托古作伪的痕迹。\n战国用历从表现形式看,或建正不同(齐鲁建子为正,秦楚三晋建寅为正),或岁首不同(齐以子月为岁首,楚三晋以寅月为岁首,秦以十月为岁首),或历名不同(秦称颛顼历,以别于殷历),如此而已。而其所宗之“法”,也都为四分术。在当时的条件下四分历的周密与完整是无法取代的。\n这种种名目,却给“三正论”制造者以可乘之机。按照“三正论”者对“周正建子、殷正建丑、夏正建寅”的解释,夏、商、周三代使用了不同的历法,即夏代之历以寅月为正,殷代之历以丑月为正,周朝之历以子月为正。夏商周三朝迭相替代,故“改正朔”以示“受命于天”。秦王迷于“三正论”,继周之后以十月为岁首,也有绍续前朝,秉天所命之意。实际上,四分历产生之前,还只是观象授时,根本不存在完整的行用于夏时之夏历,行用于殷商时代之殷历,行用于西周之周历,所谓夏历、殷历、周历,纯然是后人的概念。\n懂得了战国用历的实质,排除“三正论”的干扰,就可以运用四分历进行具体历点的推算。\n第二,四分历取岁实36514日,与实际回归年长度必有误差,307年盈一日。如果将一日化为940分,940÷307=3.06(分/年),即每年有3.06分的误差。这样,以公元前427年四分历行用之时为基点,在它以后的年份每年有+3.06分的误差,在它以前的年份,每年有-3.06分的误差。因此,在推算实际天象时,公元前427年之前的年份,每年要加3.06分;公元前427年之后的年份,每年要减3.06分。这就是前加后减的原则。只有这样,才能得出密近的实际天象。3.06分就是推求实际天象的改正值。\n在四分历行用的年代,由于时人不了解这个误差,自然不可能将误差计算进去。所以,典籍中总有历法与天象不符的记载,汉初“日食在晦”的文字就属此类。我们在考究战国至汉末这段时期的历点时,除了顾及朝代交接和改历等重大问题外,应用四分历进行推算时,不必使用“前加后减”的原则。因为追求实际天象除了验证朔望,反而与实际用历相违。实际用历还不知道这个3.06分。\n第三,公元前427年之前的年份,仍可用四分历推算月日。公元前427年之前,未行用四分历法,还是观象授时阶段。但月相在天,有目共睹,干支纪日从殷商时代已延续不断,人皆遵用。这就构成了历法推算的基础。前代学者依据《春秋》所载月日干支,编制出春秋时代的历谱。张汝舟先生《西周经朔谱》《春秋经朔谱》就立足于殷历的朔闰,取密近的实际天象,将古代文献所记这两个时期的年、月、日一一归队入谱,贯穿解说,对前人之误见逐次加以澄清。因此,“两谱”既是对两周文献纪日的研究成果,也是广大文史工作者研究两周文史的极好工具。\n要之,编定历谱或考释历点,都得以《历术甲子篇》为依据,将四分历普遍地应用于文史研究工作中。\n二、失闰与失朔 # 年、月、日能够有规律地进行调配的真正历法(四分历)产生于战国初期,有历法之前都还是观象授时。观象授时就是制历。制历的主要内容就是告朔和置闰两件大事。告朔是定每月朔日的干支,朔日干支一经确定,其余日序自有干支。置闰是定节气,一年之气,冬至最要紧。冬至一经确定,闰与不闰及全年月序就自然清楚。\n在观象授时阶段,告朔就全凭月相。古人凭月相告朔,承大月二日朏,月牙初见,承小月三日朏,月牙初见(见《说文》)。同理,承大月十五日望,月满圆,承小月十六日望,月满圆。月相分明,只在一天。\n在观象授时阶段,置闰须观斗柄所指方位,观二十八宿中天位置,验之气象、物象,加以土圭测影。随着长年的经验积累,观测仪器的精当,测定气节的准确程度必然逐有提高。前已述及,到春秋中期,十九年七闰的规律就已完全掌握了。\n四分历的回归年长度定为36514日,且使用平朔、平气,所以失闰,特别是失朔还不能完全避免。更何况春秋、西周还处在观象授时的时代,失闰与失朔当是屡见不鲜的。比如,实际是乙丑朔,因为分数小,司历定为甲子朔。如果乙丑分数大,司历定为丙寅朔。这叫失朔。\n失闰,说得确切些,就是失气。实际是子月初冬至,司历错到亥月末,亥月就成了岁首(建亥)。冬至若在下旬,司历错到丑月,丑月就成了岁首(建丑)。失闰由失气而起,我们还叫失闰。\n失朔,失闰,《春秋》有宝贵资料。例如,昭公十五年经朔:\n子月大,己未623分合朔\n丑月小,己丑182分合朔\n寅月大,戊午681分合朔\n卯月大,戊子240分合朔\n辰月大,丁巳740分合朔\n……\n《春秋》载:“二月癸酉,有事于武宫。”“六月丁巳朔,日有食之。”以此二条验谱,己未朔,癸酉乃十五日,子月实《春秋》所书“二月”。“六月丁巳朔”正合辰月。这一年必是建亥为正,子月顺次定为“二月”,辰月顺次定为“六月”,全合。大量材料证实,春秋后期建子为正,现在正月到了亥月,这就是失闰之铁证。\n将一部《春秋》进行研究,可以发现:\n隐、桓、庄、闵共63年49年建丑,8年建寅,6年建子;\n僖、文、宣、成共87年58年建子,16年建丑,13年建亥。\n这说明,前四公,即春秋前期,建丑为正,建子、建寅都算失闰,而没有建亥的。后四公,即春秋后期,建子为正,建亥、建丑都算失闰,而没有建寅的。这又说明,失闰不会超过一个月。按平气计算,一般失闰都在半月之内,只有周幽王六年失闰十七天(据《诗经·十月之交》所给历点推算)。\n《春秋》记37次日食,有5个书月日不书朔。《左传》认为“史失之”,未免武断。因为食不在朔,所以《公羊传》云“或失之前,或失之后”,是正确的。失朔一般在半天之内,只有鲁文公元年“二月癸亥,日有食之”,失朔508分,超过半天(一日940分)。\n为什么要掌握一个失闰限、失朔限呢?这是应用四分历推演经朔考订古籍古器历点必须遵循的准则。如果历点与实际天象所确定的朔、闰相差甚远,失闰超过一月,失朔超过一天,就宁可存疑也断不可硬套,去企求得出一个相合的结论。如果没有一个失闰、失朔限,古器物上的历点就可左右逢源,安在哪一年都会大致相符。记有历点的出土文物,一到专家的手里,考证出的结论往往大相径庭,其道理就在这里。可见,确定失闰限、失朔限是多么重要。它提醒你,要严谨,不可信口雌黄。\n有没有“再失闰”的情况?古籍中确有记载。《汉书·律历志》载,襄公二十七年“九月乙亥朔,是建申之月也。鲁史书:‘十二月乙亥朔,日有食之。’传曰:‘冬十一月乙亥朔,日有食之,於是辰在申,司历过也,再失闰矣。’言时实行以为十一月也,不察其建,不考之于天也”。\n《春秋》经文杜注:“今长历推为十一月朔,非十二月。传曰辰在申,再失闰。若是十二月,则为三失闰,故知经误。”\n《左传》杜注:“谓斗建指申,周十一月今之九月,斗当建戌而在申,故知再失闰也。文十一年三月甲子至今七十一岁应有二十六闰,今长历推得二十四闰,通计少再闰。释例言之详矣。”\n杜预这两条注,将《春秋》经传所记,辨析明白,断定经误传是。传文“再失闰”是可信的。杜以自编《经传长历》验证,确为“再失闰”。《汉书·律历志》解释说,当时是记为十一月的,这种“再失闰”是不观察斗柄所指,不考之于天象的原因。可见,观象授时阶段失闰是不足为怪的,但已不可能在春秋时代出现“再失闰”的怪现象。\n如果用《历术甲子篇》推演,襄公二十七年(公元前546年)朔闰如次。\n是年入辛卯蔀(蔀余27)第三十四年。\n太初三十四年:前大余四十八,小余五百五十二先天+364分\n子月朔己卯552分916己卯十五\n丑月朔己酉111分475己酉 四十五\n寅月朔戊寅610分34 己卯 十五\n卯月朔戊申169分533戊申 四十四\n辰月朔丁丑668分92 戊寅 十四\n巳月朔丁未227分591丁未 四十三\n午月朔丙子726分150丁丑 十三\n未月朔丙午285分649丙午 四十二\n申月朔乙亥784分208丙子 十二\n酉月朔乙巳343分707乙巳 四十一\n戌月朔甲戌842分266乙亥 十一\n亥月朔甲辰401分765甲辰 四十\n甲戌(十)\n春秋后期(襄公)行子正。\n如果记上实际天象3.06×(546-427)=364分。加上364分,则子正十一月乙亥朔,日食,确。\n所谓“斗建申”乙亥朔,是战国人用四分历推算的结果。“九月乙亥朔”更是东汉人的口气。杜预以申月、酉月连大,得戌月乙亥朔。考之实际天象,春秋中期十九年七闰的规律已经掌握,并无“再失闰”这种怪现象。\n三、甲寅元与乙卯元的关系 # 关于古历,经过刘歆的制作,西汉以后就众说纷纭了。《汉书·律历志》云:“三代既没,五伯之末史官丧纪,畴人子弟分散,或在夷狄,故其所记,有黄帝、颛顼、夏、殷、周及鲁历。”这就是古六历说之来源。到了《后汉书·律历志》,又大加发挥:“黄帝造历,元起辛卯,而颛顼用乙卯,虞用戊午,夏用丙寅,殷用甲寅,周用丁巳,鲁用庚子。汉兴承秦,初用乙卯,至武帝元封,不与天合,乃会术士作《太初历》,元以丁丑。”六历之外,又有虞舜之历及太初历,每历之“元”也有了,且历元彼此不同,更显殊异。\n如果认真研究,什么六历、八历,徒有其名而已。南朝大科学家祖冲之说:“古之六术,并同四分。四分之法,久则后天。以食检之,经三百年辄差一日。古历课今,其甚疏者后天过二日有余。以此推之,古术之作,皆汉初周末,理不得远。”(见《宋书·历志》)祖冲之的论断有他的科学基础,回归年长度经过实测,推算所得数据更近准确。他指出古六历均为四分,而四分历法三百年差一日,无疑是正确的。他笼统地将古六历产生的时代归于“汉初周末”,问题并未解决。\n其实,古六历名目虽多,而史籍有据的只有天正甲寅元(殷历)和人正乙卯元(颛顼历),其他四历都是东汉人的附会。\n天正甲寅元与人正乙卯元,即殷历与颛顼历究竟有什么关系?\n《后汉书·律历志》说:“甲寅之元,天正正月甲子朔旦冬至,七曜之起,始于牛初。乙卯之元,人正己巳朔旦立春,三光聚于天庙(即营室)五度。”这就是甲寅元与乙卯元历元近距的天象记录。\n我们知道,立春距冬至是四十六日,营室五度按《开元占经》所列二十八宿的古代距度计算,离牵牛初度也正是四十六度。当时划周天36514度,太阳日行一度。因此,立春时太阳在营室五度也就是冬至时太阳在牵牛初度,甲寅元与乙卯元的天象起点就是一致的了。\n唐一行《大衍历议》引刘向《洪范传》和《后汉书·律历志》刘洪的话,都讲到颛顼历的历元是正月己巳朔旦立春。不过,刘向仍把年名称为甲寅,刘洪却称之为乙卯,日名己巳。颛顼历称乙卯元,又称己巳元,道理亦如此。刘洪称颛顼历年为乙卯,而刘向仍称甲寅,二者是一致的吗?\n近代学者朱文鑫据《后汉书》所记甲寅元与乙卯元的星宿差度,计算天正冬至和人正立春的测定时日,断定天正冬至点的测定早在人正立春点测定之前。(见《历法通志》)学者董作宾进一步推定:殷历天纪甲寅元第十六蔀第一年天正己酉朔旦冬至为其测定行用之时,其第六十二年乙卯岁正月甲寅朔旦立春为人正乙卯元测定行用之时。(见《殷历谱》)他们的研究是有成效的,但仍没有弄清甲寅元与乙卯元的联系和区别,最终未能从根本上解决问题。\n甲寅元与乙卯元有什么联系和区别呢?\n古人迷信阴阳五行,颛顼帝以水德王,秦自以为获水之德,故用颛顼名历,汉高祖也“自以为获水德之瑞”,故袭用秦颛顼历,这似乎是明白无误的,然而,奇怪的是,为什么后世历家总是对此满怀疑虑,不得其解呢?\n北宋刘羲叟作《长历》,用颛顼历推算西汉朔闰往往不合,最后只好说:“汉初用殷历,或云用颛顼历,今两存之。”(见《资治通鉴目录》)清汪日桢说:“秦用此术(指颛顼历乙卯元),以十月为岁首,闰在岁末,谓之后九月,汉初承秦制,或云用殷术,或云用颛顼术,故刘氏长术两存之,今仍其例。”其推算结论是“以史文考之,似殷术为合”。(见《历代长术辑要》)陈垣也认为:“汉未改历前用殷历,或云仍秦制用颛顼历,故刘氏、汪氏两存之。今考纪志多与殷合,故从殷历。”(见《二十史朔闰表》)问题就是这样奇怪。《后汉书》记为“乙卯”,而推算结果又肯定了“殷历(甲寅)”,这究竟为什么?\n前面在考证殷历甲寅元的历元及其近距时,曾经提到天正甲寅元和人正乙卯元的问题,这里有必要进一步探讨。朱文鑫证明了天正冬至点的测定早在人正立春点之前,即甲寅元的产生早于乙卯元。董作宾又推定,殷历天纪甲寅元第十六蔀第一年天正己酉朔旦冬至为其测定行用之时(即公元前427年,甲寅),其第六十二年乙卯岁正月甲寅朔旦立春为人正乙卯元测定行用之时(即公元前366年,乙卯)。但他们都惑于颛顼之名,将秦颛顼历与乙卯元颛顼历混为一谈,自然不得其解。\n我们认为,汉初行用秦颛顼历是完全可信的,秦颛顼历以十月为岁首,秦朝记事起自十月,终于九月,直至汉武帝太初改历以前,均同此例,这是汉初承袭秦颛顼历的铁证。问题在于,秦颛顼历实为殷历甲寅元,只是岁首不同而已;而所谓乙卯颛顼历,虽有六历中颛顼之名,实为殷历甲寅元的“变种”,这是好事者的历法游戏、模仿之作,从未真正行用过。前代历家每每惑于古六历之说,用假颛顼历(乙卯元)取代真颛顼历(甲寅元),拿不曾行用过的乙卯元验证古历点,自然不合,所以,最后都倾向于殷历甲寅元。\n这种论断有根据吗?有的。下面可以用历法来验证。\n427-366=61算外62(年)\n说明乙卯元之历元在甲寅元历元之后六十二年,公元前366年入殷历第十六蔀(己酉45)第六十二年。\n查《历术甲子篇》六十二年(端蒙单阏、乙卯)十二\n大余六小余二百四十六\n大余二十小余八\n据此,可以排出所谓乙卯元测定之年正月朔日立春干支:\n子月小庚午6246冬至甲申 20小余 8\n丑月大己亥35745大寒甲寅50小余 22\n寅月小己巳5304立春己巳5小余 29\n由此可知,乙卯元该年正月(寅)己巳合朔立春,这就是乙卯元近距的首日,故又称“己巳元”。可见乙卯元脱胎于甲寅元,纯系甲寅元的变种。\n但是应该注意到,乙卯元己巳朔并非夜半0时(子),尚有朔余304分,被乙卯元弃而不记(这是历元、首的要求),所以甲寅元与乙卯元的朔余总有304分之差,正因为如此,乙卯元的推算才会干支错乱,与历点不合,这就是乙卯元虽脱胎于甲寅元却运算不准的根源。刘、汪、陈诸家其所以屡遭挫折而后肯定殷历,原因盖出于此。\n以上是以乙卯元断取甲寅元的历点来计算的。若以甲寅元计,殷历第十六蔀当有蔀余45,首日己酉,6+45=51(乙卯),20+45=65-60=5(己巳),该年前十一月应乙卯朔己巳冬至。据此,其推算如下:\n子月小乙卯51246冬至己巳58\n丑月大甲申20745大寒己亥3522\n寅月小甲寅50304立春甲寅5029\n说明按甲寅元计,该年实为甲寅朔日立春。这样,所谓乙卯元就有正月己巳朔立春和正月甲寅朔立春两种说法,其真相就是如此。后人不知其故,作出种种解释,祖冲之说“颛顼历元岁在乙卯;而《命历序》云此术设元岁在甲寅”;《新唐书·历志》说“颛顼历上元甲寅岁,正月甲寅晨初合朔立春,七曜皆直艮维之首”,“其后,吕不韦得之,以为秦法,更考中星,断取近距,以乙卯岁正月己巳合朔立春为上元”等等,不过是一场历史的误会。\n由此可证,汉初行用的不是什么乙卯元,而是殷历甲寅元,不过按秦正朔以十月为岁首,终于九月,故又有颛顼历之名。至于乙卯元,冒名颛顼,以假乱真,其实从来是纸上谈兵,未曾行用,应该否定。\n张汝舟先生在《历术甲子篇浅释》中说:甲寅元的殷历起于周考王十四年年前十一月己酉朔夜半冬至,后六十一年(算外六十二年),即周显王三年(公元前366年)另创新历“人正乙卯元”,与殷历并峙。可是殷历施行已逾六十年,固定了,干支纪年也固定了。颛顼历的创制者只好自称“乙卯元”。殷历是母亲,颛顼历是她生的娃娃,不是事实吗?试检《历术甲子篇》太初六十二年乙卯“前大余六”,即年前十一月庚午朔。十一月、十二月共59天,所以是年正月是己巳朔。年是乙卯,日是己巳。所以“乙卯元”又叫“己巳元”,说明天正与人正的母子关系。\n不难得出结论:秦的颛顼历实是殷历。这个“乙卯元”的颛顼历根本没有施行过。\n四、元光历谱之研究 # “汉武帝元光元年历谱”竹简是1972年临沂银雀山二号墓出土的珍贵文物之一。它基本上完整地记载着汉武帝元光元年一年的历日,是我们探讨汉初历法又一份最直接的材料。\n对于“元光历谱”的研究,已经有陈久金、张培瑜等同志的文章。由于对古代历法的认识不一,研究者所取的角度不同,已有的结论似尚不足以服众。这里依据《史记·历术甲子篇》的记载,来揭示“元光历谱”的隐秘,希望通过讨论求得一个较完满的解释。\n前已述及,对秦汉所用历法,有“殷历”“颛顼历”等不同的称说,究其实,都是四分历,都是以岁实36514日,朔策29444940日分为基本数据的四分古法,这一点当无不同意见。\n出土的元光元年历谱原文是:\n元光元年十月己丑\n十一月己未二十八日冬至丙戌\n十二月戊子\n正月戊午十五日立春壬申\n二月戊子\n三月丁巳\n四月丁亥\n五月丙辰\n六月丙戌三日夏至戊子\n七月乙卯二十日立秋甲戌\n八月乙酉\n九月甲寅\n后九月甲申\n元光二年十月\n根据十二月、正月两个连大月,我们可以列出朔日的小余范围。\n十二月戊子882~939\n正月戊午441~498\n二月戊子0~57\n得出十一月(子)己未的小余范围:383~440分。\n以十月为岁首的所谓“颛顼历”,仍依“归余于终”的故例。如果弄明白它和殷历(甲寅元)的关系,而人正乙卯元的“颛顼历”从未施行过,秦历“颛顼”就仍是殷历。以此探求它的推算起点,其气余就远不及朔余重要,“小余范围”就应予以特别重视。\n元光历谱朔日干支一定,我们便可以由此上推若干年到这种历法的“推算起点”,下推若干年到汉武帝太初元年。如何推演呢?当然得利用确定朔日干支的“小余范围”。\n由于《史记·历术甲子篇》只列子月(十一月)朔干支及余分,我们可将“元光历谱”十一月(子)朔的小余范围己未(383~440)对号入座。核对甲子蔀七十六年的小余,只有\n太初四十八年小余399分\n太初五十二年小余410分\n太初六十九年小余419分\n太初七十三年小余430分\n这四个年头符合“元光历谱”十一月小余的范围。下面我们可一一分析,寻求它的推算起点。\n假设之一,是“元光历谱”合太初四十八年,“大余五十七,小余三百九十九”。“元光历谱”子月朔为己未(55)。则,前大余(57)+蔀余=子月朔(55),57+58=115(逢60去之,即55),蔀余当为58(壬戌)。四分历无壬戌部。此一假设不能存立。假设之二,是“元光历谱”合太初七十三年,“大余二,小余四百三十”。“元光历谱”子月朔己未(55),则蔀余当为53(丁巳)。四分历无丁巳蔀,此一假设亦不能存立。\n假设之三,“元光历谱”记有“后九月”,若从有闰无闰的角度看,太初五十二年有闰。则元光元年当入乙卯(51)蔀第五十二年(大余四)。蔀余51+大余4=子月朔55。\n如果从乙卯蔀前推九蔀,得甲子蔀。76×9+52=736年\n历元甲子蔀首年当在元光元年(公元前134年)之前736年,为公元前870年,这就又与殷历蔀首年不相吻合了。\n从公元前134年起算,章首之年如次:\n134+52=186(不算外,前185年为高祖后三年)\n185+19=204(前204年为汉高祖三年)\n204+19=223(前223年为秦王政二十四年)\n185-19=164(前164年为文帝十六年)\n以上各年均不能充当历元近距,不能作为“元光历谱”这种历法推算的起点。此一假设亦不能成立。\n若元光元年合太初六十九年,“大余五十五,小余四百一十九”则是年合甲子蔀六十九年。其章蔀首年次是:\n134+69=203(不算外,是前202,汉高五年)\n汉高祖五年,是刘邦登基称帝之年。《史记·高祖本纪》:“五年,正月,诸侯及将相相与共请尊汉王为皇帝。甲午乃即皇帝位汜水之阳。”《史记·秦楚之际月表》:“(五年)二月甲午,王更号,即皇帝位于定陶。”\n我们用四分历(殷历)章蔀推算,一一吻合。\n汉高祖五年入殷历丁卯蔀(蔀余3)第七十四年,大余56,小余778。知汉高祖五年子月癸亥(56+3)朔778分。则\n十月甲午朔279分\n十一月癸亥朔778分\n这就是能够以十一月甲子朔做计算起点的缘由。这不是改历,仅是加大分数。加大162分,就改子月癸亥朔为甲子朔。不难看出,《史记·历书》所载帝王“改正朔,易服色”事,是包括了汉高祖刘邦称帝改朔这一史实的。\n如果我们从汉高祖五年(前202年)起,据十九年七闰之成法,将汉初闰年排列,足可以看出其中之规律来。\n明确了置闰的安排,找到了相应的章蔀首日干支及小余,以此为推算的起点,汉初历法的本来面目也就一清二楚了。见《汉初朔闰表》(见下页)。如果用四分历章蔀运算,必须加上162分才能吻合。检验史籍所载汉初历点,更能证实“元光历谱”所反映的汉初历法是以公元前202年为计算起点的四分历。或者说,汉初历法是以殷历做基础,只不过是从公元前202年起多加上162分计算罢了。\n出土的《马王堆导引图》上有一个历点:文帝十二年二月乙巳朔。查上表:文帝十二年十月丙午朔900分,推演:\n十一月丙子459,十二月丙午18,正月乙亥517,二月乙巳76。\n用《历术甲子篇》推演:\n文帝十二年(公元前168年)入丙午蔀(42)第三十二年。\n太初三十二年:大余三十,小余二百九十七\n得知,子月朔十二(42+30),即丙子朔297\n丑月朔乙巳796\n寅月朔乙亥355\n卯月朔甲辰854\n二月甲辰朔854分,与出土所记不合。如果加162分,即将高祖五年(前202年)作为推算起点,推演:\n子月丙子297,加162分,得\n子月丙子459分,丑月丙午18分,\n寅月乙亥517分,卯月乙巳76分。\n如果将汉初百年所记朔晦干支一一核实,用《历术甲子篇》推演,加上162分,均能得出可信的结论。\n五、疑年的答案及其他 # 在史籍记载及出土文物中,留有不少至今未能取得一致看法的历点,我们统称之为“疑年”。造成疑年的事实,有史料本身记述不清的原因,也是古历研究者各执一端、见仁见智的结果。我们如遵循《史记·历术甲子篇》的记载,以此追求密近的实际天象,很多问题还是不难解决的。\n1.关于屈原的生年月日。\n这是文史界的热门话题。近人多信“岁星纪年”,用所谓“太岁超辰法”来推证,生出多种多样的结论,无法令人信服。\n《离骚》开篇:“摄提贞于孟陬兮,惟庚寅吾以降。”就告诉了我们,屈原生于寅年寅月寅日。\n对于屈原生年月日的考证,可以从几个不同角度来进行。比如屈原政治活动的时代背景,《离骚》全诗的语言特色及文意,历法的推算自然也是一个重要方面。这里,就四分历的具体推算来考证屈原生年月日。\n四分历(即殷历甲寅元)创制,行用于周考王十四年,干支纪年起始。否定了“岁星纪年法”,不承认“太岁超辰”,明确战国时代四分历普遍行用,楚用寅正,利用四分历推演屈原生年就是可能的了。\n考虑屈原生年只能在两个寅年,一是公元前355年丙寅(游兆摄提格),一是公元前343年戊寅(徒维摄提格)。\n公元前355年入殷历第十六蔀己酉蔀(45)第七十三年。\n太初七十三年:大余二小余四百三十\n2+45=47(辛亥)\n得知,前355年子月辛亥朔430分\n丑月庚辰朔929分\n寅月庚戌朔488分\n正月(寅)庚戌朔,庚寅日在朔日后41天,不在正月之内。故公元前355年(丙寅)不合“三寅”条件,应该放弃。\n公元前343年入殷历第十七戊子蔀(24)第九年。\n太初九年:闰十三,大余十四小余二十二\n14+24=38(壬寅)\n得知,前343年子月壬寅朔22分\n丑月辛未朔521分\n闰月辛丑朔80分\n寅月庚午朔579分\n正月(寅)庚午朔,庚寅为正月二十一日。\n从《历术甲子篇》数据推演,得知屈原生于戊寅年(前343年)正月(寅)二十一日(庚寅)。这也是清人邹汉勋、陈旸,近人刘师培的结论,张汝舟先生《再谈屈原之生卒》又加以申说、推算。\n其余各家之说,皆可以用四分历推演加以检验,指出粗陋或疏失之处。\n2.武王克商之年。\n关于武王克商年代的考证,说法有三十余家。据《汉书》载,《周书·武成》篇:“粤若来三月既死霸,粤(越)五日甲子,咸刘商王纣。”\n又,《武成》篇载:“惟一月壬辰旁死霸,若翌日癸巳,武王乃朝步自周,于征伐纣。”\n又,《武成》篇载:“惟四月既旁生霸,粤六日庚戌,武王燎于周庙。”\n张汝舟先生在《西周考年》一文中,对古书古器41个历点加以考证,论定武王克商在公元前1106年。对这一结论,我们可以通过演算,求出实际天象,加以证实。对其他各家之说,也可以用推算验证结论的错误而不可信。\n公元前1106年入戊午蔀(54)第六年。\n太初六年:大余一小余三百五十九\n先天:(1106-427)×3.06=2078(分)\n2078÷940=2……余198\n据前加后减的原则2.198+1.359+54=57.557(分)\n得知,前1106年子月辛酉(57)朔557分\n丑月辛卯(定朔辛卯111分)\n寅月庚申(定朔庚申915分)\n卯月庚寅(定朔庚寅657分)\n辰月己未(定朔庚申266分)\n巳月己丑(定朔己丑701分)\n张汝舟先生将定朔算出列于每月后面的括号内。我们用这个实际天象来验证《周书·武成》的记载,则一一吻合。\n是年丑正,一月辛卯朔,旁死霸初二壬辰,初三癸巳。\n二月庚申朔。越五日甲子,武王克商。\n闰月庚寅朔。\n三月庚申朔。\n四月己丑朔。既旁生霸(即十六)甲辰,越六日庚戌(二十二日),武王燎于周庙。\n联系对于月相的正确解释,足证武王克商之年确实是在公元前1106年。\n1978年湖北随县(今随州)擂鼓墩发掘的战国曾侯乙墓有一漆箱,除箱盖上环绕“斗”字的二十八宿名称及苍龙、白虎之形外,还有“甲寅三日”的字样。据专家考证,曾侯乙卒于楚惠王五十六年(公元前433年),“甲寅三日”当为死者卒日。试以四分历验证之。 公元前433年入殷历第十五蔀庚午蔀(6)第七十一年\n太初七十一年:闰十三,大余四十四小余一百七十五\n大余七小余十六\n44+6=50(甲寅)7+6=13(丁丑)\n该年前十一月(子)甲寅朔,丁丑冬至。据此排出朔闰干支:\n子月小甲寅175,冬至丁丑16\n丑月大癸未674,大寒丁未30\n寅月小癸丑233,惊蛰戊寅12\n卯月大壬午732,春分戊申26\n辰月小壬子291,清明己卯 8\n巳月大辛巳790,小满己酉22\n闰月小辛亥349,(无中气)\n午月大庚辰848,夏至庚辰 4\n欲得“甲寅三日(初三)”初一(朔)必为壬子。从上表看,辰月壬子朔,正当寅正三月,这说明曾侯乙卒于楚惠王五十六年(公元前433年)三月初三日。有的考证文章据日人新城新藏《战国秦汉长历图》,定“甲寅三日”为该年五月初三,明显是取子正,这是不妥当的。曾国作为楚国的属国,典章制度、官职名称既然与楚国相同,其用历建正必然与楚国是一致的,曾国不可能独立行事。如前所述,楚以建寅为正,这有《楚辞》大量诗句为证,若用子正是无法解释的。因此,“甲寅三日”应为该年三月初三。\n4.《史记·秦始皇本纪》:“(三十七年)七月丙寅,始皇崩于沙丘平台。”\n秦历托名颛顼,实为四分,只是记事以十月为岁首,并不改寅正月序。下面以四分历验之。\n始皇三十七年(公元前210年)入殷历第十八蔀丁卯蔀(3)第六十六年。\n查《历术甲子篇》太初六十六年闰十三\n大余十三小余二百五十七\n大余四十一小余八\n13+3=16(庚辰)41+3=44(戊申)\n得知,该年前十一月庚辰朔,戊申冬至。据此可排出朔闰:\n子月庚辰257,冬至戊申 8\n丑月己酉756,大寒戊寅22\n闰月己卯315,(无中气)\n寅月戊申814,惊蛰己酉 4\n卯月戊寅373,春分己卯18\n辰月丁未872,清明庚戌 0\n巳月丁丑431,小满庚辰14\n午月丙午930,夏至庚戌28\n未月丙子489,大暑辛巳10\n申月丙午 48,处暑辛亥24\n七月为申月,该年七月丙午朔,“丙寅”为七月二十一日,此日始皇崩于沙丘平台。可见此年置闰并非在所谓“后九月”而是闰在十二月。如果置闰“后九月”那么未月当为七月,该月丙子朔,无丙寅日,怎样解释?\n始皇之崩,何等大事!史官是绝不会记错的。清代汪日桢《历代长术辑要》记该年六月丙午朔,七月乙亥朔,置闰后九月。然而七月乙亥朔而无丙寅日,足见有误。如果立足“后九月”置闰,这个闰月只能放在始皇三十六年。\n5.《汉书·武帝纪》:“元朔二年三月己亥晦,日有食之。”元朔二年为公元前127年,入丙午蔀第七十三年。\n推算得知,子月(十一月)戊申朔430分\n十二月丁丑929\n正月丁未488\n二月丁丑47\n三月丙午546\n四月丙子105\n(下略)\n三月丙午朔,四月丙子朔,则三月乙亥晦。\n可知史书“己亥”乃“乙亥”之误。\n由上数例可知,凭借司马迁为我们保存的历法宝典《历术甲子篇》,对古代历点进行推算检验,不仅可以解决史籍中的许多疑年,且能矫正史料中有关的许多错误。第七讲历法上的几个问题\n第七讲历法上的几个问题 # 前面,我们将张汝舟先生有关古代天文历法的主要内容系统地进行了讲述,又依据《历术甲子篇》所载四分历(即殷历)掌握了推步的方法并用来解决一些实际问题,比如战国以前古历点的推求及汉初历谱的验证。这都说明,四分历在战国秦汉的广泛应用。如果我们立足于张汝舟先生古天文学说的基本观点,对汉代以来纷纭不已的几个历法上的主要问题加以检讨,就会澄清若干悬而不决或决而已误的问题,得到更近于事实的结论,恢复古代历法的本来面目。这样,我们在古代天文历法的学习与研究方面就前进了一大步。\n一、太初改历 # 通过殷历甲寅元的历元和历元近距的考证,通过汉初历点的推算,汉太初以前的用历情况和改历原因实际上已经清楚,为了更系统地说明问题,下面再进一步加以讨论。\n汉武帝元封七年(公元前104年)下诏改历,改年号为“太初”,史称“太初改历”。这次改历在我国历术史上是第一次,承上启下,影响深远,历代正史多有评述,但因种种原因,至今很多问题尚未澄清,从而直接影响到上古天文历法的研究。\n汉武帝继文景之治登上皇位,以自己的雄才大略,内外经营,励精图治,使西汉王朝进入政治稳定、国力强盛、经济繁荣、文化发达的全盛时期。如果说,汉初“天下初定,方纲纪大基,高后女主,皆未遑,故袭秦正朔服色”(《史记·历书》),那么,到了汉武帝时代应该说改历的时机成熟了,条件具备了。而且,武帝作为一代英主,深知“天之历数在尔躬”的古训,明白“改正朔,易服色,所以明受命于天”(《汉书·律历志》)的政治影响,需要借改历来强化统治。但是,并不能得出这样的结论:只要社会条件允许,帝王出于政治需要,就可以随意改历,因为这些只是改历的外部因素,“外因是变化的条件,内因是变化的根据”(《矛盾论》),促成改历的根本原因要从历法本身去找,由用历与天象相适应的程度来决定,如果用历密合天象,改历是多此一举;如果用历明显背离天象,改历则势在必行。\n如前所述,据《史记》《汉书》记载,汉兴以后,多次发生“日食在晦”的反常天象,即所谓“朔晦月见,弦望满亏,多非是”(《汉书·律历志》),这是不容忽视的史实。它明确地告诉我们,此时用历明显不准,已经超越天象一日,这是四分历固有的误差造成的,与前面考证的殷历甲寅元创制行用于公元前427年这一结论是完全相符的。\n历法以固定的章蔀统筹多变的天象,行之日久,必有误差,即就今天使用的历法,也不是绝对准确的,何况古人凭目测天象制历,误差较大并不奇怪。除前面引过的祖冲之的论述之外,刘宋星历家何承天也说:“四分于天,出三百年而盈一日”(《宋书·卷十二》);唐一行又说:“古历与近代密率相较,二百年气差一日,三百年朔差一日。推而上之,久益先天;引而下之,久益后天”(《唐书·历志三上》)。因此,后世历家认为:“四时寒暑之形运于下,天日月星有象而见于上,二者常动而不息,一有一无,出入升降,或迟或疾,不相为谋,其久而不能无差忒者,势使之然也。故为历者,其始未尝不精密,而其后多疏而不合,亦理之然也。”(《唐书·历志一》)又说:“盖天有不齐之运,而历为一定之法,所以既久而不能不差,既差则不可不改也。”(《元史·历志一》)汉初星历家虽然没有如此系统的理论,但他们凭实测发现了问题,因此,元封七年大中大夫公孙卿、壶遂和司马迁等人才联名向汉武帝提出“历纪坏废,宜改正朔”的建议(见《汉书·律历志》)。这个建议符合汉武帝的主观需要,又为当时社会条件所允许,于是很快被武帝采纳,并付诸行动。以上就是太初改历的原因。\n既然太初改历的原因如此,探求太初改历的真相就有了正确的途径。根据前面的考证,汉初行用的是创制于公元前427年的殷历甲寅元,不过以十月(亥)为岁首而已。下面且看史书是怎样记载的:\n《史记·历书》说:“……故袭秦正朔服色。”\n《汉书·律历志》说:“汉兴,方纲纪大基,庶事草创,袭秦正朔,以北平张苍言,用颛顼历……”\n《后汉书·律历志》又说:“汉兴承秦初用乙卯,至武帝元封不于天合,乃会术士作太初历,元用丁丑。”\n史书所记的“承秦正朔”与我们的考证是一致的,但为什么《后汉书·律历志》记“承秦初用乙卯”,而不是“甲寅”呢?这就要弄清“甲寅元”与“乙卯元”的联系和区别。前已讲到,秦的颛顼历就是殷历,所谓“乙卯元”的颛顼历从未施行过。如果排除“乙卯元”的干扰,就可直接探求太初改历。\n元封七年(即太初元年)为公元前104年(丁丑)\n427-104=323算外324(年)\n324÷76=4……余20(年)\n说明元封七年位于殷历第二十蔀(乙酉21)第二十年,即该蔀第二章首年。\n查《历术甲子篇》二十年十二\n大余三十九小余七百五\n大余三十九小余二十四\n39+21=60(0甲子)39+21=60(0甲子)\n705940-2432=0\n说明该年前十一月(子)甲子日酉时(18时)合朔冬至。\n因为太初改历的原因就在年差分积累过大,造成“日食在晦”的反常天象,现在改历者看准了这一时机,为纠正用历的误差,取消朔日余分705和冬至余分24(即消除年差分),便使元封七年十一月甲子日夜半0时合朔冬至无余分,这样就大致避免了“日食在晦”的天象,无疑是个巧妙的方法。同时,还改岁首(以寅月为正月,该年十五个月,其中丙子年前105年三个月,丁丑年前104年十二个月),改年号为“太初”以为纪念。正如《史记·武帝本纪》说:“夏(五月下诏改历),汉改历,以正月为岁首,而色尚黄,官名更印章以五字,因为太初元年。”《史记·历书》亦说:“自是以后,气复正,羽声复清,名复正,变以至子日当冬至,则阴阳离合之道行焉。十一月甲子朔旦冬至已詹,其更以七年为太初元年。”其后附记《历术甲子篇》全文,以明所用的历法准则,这就是发生在元封七年的改历情况。\n这样改历在当时无疑是一场革命,必然触动那些顽固守旧派的神经,遭到他们的反对。汉昭帝元凤三年(公元前78年,太初改历后二十七年)太史令张寿王上书攻击太初改历,坚持所谓“黄帝调律历”,“妄言太初历亏四分日之三,去小余七百五分,以故阴阳不调”。为此满朝震动,争论持续数年之久,最后以张寿王的失败而告终。张寿王墨守成规,反对变革,自然是愚昧可笑的,但他提供的数据无疑给我们透露了太初改历的秘密。至此,所谓汉初承用秦颛顼历的真相可以大白;况且论战最后已经证明,“寿王历乃太史官殷历也”。(均见《汉书·律历志》)\n二、八十一分法 # 据《汉书·律历志》记载,太初改历还采用了邓平的八十一分法。所谓八十一分法,就是以29 43/81日为朔策(朔望月),邓平认为499/940太繁,而26/49<499/940,17/32>499/940,故采用26+17=43做分子,49+32=81为分母,以43/81取代499/940。因此,其章蔀为:\n八十一分法虽来自四分法,43/81比499/940简化,但是其朔策 > (日),其岁实 > (日),所以其精度反不及四分法,这是肯定的。\n《汉书·律历志》虽然记载了邓平八十一分法及其章蔀编制但并未说明邓平八十一分法取代四分法的确切时间,而且太初改历的参与者司马迁著的《史记》对邓平及其八十一分法竟然只字未提,这就引起后世历家的种种猜疑,不得定论,直到现代,古历研究者还在争论着。\n著名天文学家陈遵妫说:“制定太初历的时候,是由司马迁和其他许多历家来共同研究的。不久所决定的历法是《史记·历书》所载的《历术甲子篇》,即以太初元年前十一月甲子朔为历元的四分历法;当时并且还颁布过施行这种历法的诏书。但这种历法把当时人们算为丙子的太初元年,改称为甲寅岁,并以立春正月改为冬至正月;可以说是完全属于理想的历法。以致施行的时候,有些不便,似乎曾经受过各方面的激烈反对,不得不把施行这历法的命令撤回。后来又增加专家,重行研究,不久才决定采用邓平的八十一分法。”(《中国古代天文学简史》)\n何幼琦说,太初改历“是天正派复辟和人正派拨乱反正的公开较量”,“司马迁是失败者,心怀不满,所以在《历书》中既不详述改历的过程,又不附录太初历,反而附载了他的《历术甲子篇》;改洛下为‘落下’也不会是笔误吧”。(见《学术研究》1981年第3期何幼琦《关于“五星占”问题答客难》)\n《中国天文学史》(1981)虽看出其中有问题,但依然认为:“太史令司马迁虽提议改历,却并未想到要改变四分历的计算方法,他在编写《史记·历书》竟不提邓平的八十一分法,而仍以四分法编他的《历术甲子篇》附在后面。显然,他是不同意八十一分法的。”\n他们提出了两个值得注意的问题:(1)《历术甲子篇》出自司马迁编排;(2)《史记·历书》其所以不记邓平法,是因为《历术甲子篇》被否决,司马迁心怀不满,不同意邓平的八十一分法。至于是太初元年改历时就行用邓平法呢,还是《历术甲子篇》行用一段时间后再用邓平法呢?仍无定说。\n我们认为,关于《历术甲子篇》是否是由司马迁编排的,通过前面大量的推算考证已经完全解决,毋庸赘言。关于不记邓平法,是由于司马迁心怀不满、发泄私愤的说法,只是猜测之辞,并没有史料根据。司马迁《史记》的实录精神历来为史家称道,对《史记》颇有微词的班固也不得不承认:“……自刘向、扬雄,博极群书,皆称迁有良史之材,服其善序事理,辨而不华,质而不俚,其文直,其事核,不虚美,不隐恶,故谓之实录。”(《汉书·司马迁传赞》)这也是后世公认的。因此即使司马迁是太初改历的失败者,也不会文过饰非,歪曲史实,以致发展到违抗圣旨、篡改诏书的严重程度,这不仅违背司马迁的人品、性格,而且无异于拿自己的生命开玩笑,再遭一次“李陵之祸”。何况,《历术甲子篇》本来就是行用了三百多年的殷历甲寅元,司马迁不过采自史官旧牒,随文记录而已,即使用邓平法取代《历术甲子篇》,与司马迁有什么利害关系?司马迁用不着心怀不满,故意隐瞒。那么,为什么《史记·历书》不记邓平及其八十一分法呢?合理的解释只能是,太初改历实际上分两步进行。在元封七年进行改历时(消余分、改岁首),邓平尚未参加,八十一分法也没有制定,司马迁只是按当时的实际情况来记载,所以《史记》详记《历术甲子篇》而不记邓平及其八十一分法——司马迁不能未卜而先知!至于邓平八十一分法取代四分法,那已是太初改历的第二步。其时司马迁或衰病无力,或不在人世,所以《史记·历书》根本不提邓平法。\n这种解释的根据是:\n第一,从鉴别史料的角度出发,《史记》产生于《汉书》之前,司马迁身为太史公,学有家传,是太初改历的首倡者和直接参与者,无论从经历还是从学识来说,都比班固更有发言权,更具权威性,因此,对太初改历的记述,《史记》比《汉书》的可靠性更大些。班固《汉书》产生于太初之后180多年,其间又经战乱,史料保存不善是造成错误的一个因素,更主要的是,班固本人对历法不熟习,对改历不清楚,完全迷信刘歆的曲说,所以《汉书·律历志》多录自刘歆《三统历》,论及太初改历自然不能不受其影响。刘歆作为一代学者,他对汉初用历、太初改历都十分清楚,否则,他就不会依据殷历甲寅元编排他的《三统历》(详见下文),但他为了给王莽篡权制造理论根据,故意歪曲史实,巧言夏、商、周三代更替,对后世产生了恶劣影响,贻害无穷。班固不明此理,妄誉刘歆《三统历》“推法密要”,正好暴露了他在这方面的弱点,所以遭到后世的非议。鉴于这种情况,他的《律历志》中记述的太初改历,可信程度就有限了。比如“乃以前历上元泰初四千六百一十七岁,至于元封七年,复得阏逢摄提格之岁”,这一句就很成问题。既然追元封七年之“前历”上元泰初,该用四分法章蔀推算,而:“四千六百一十七岁”却来自八十一分法的元法;“复得”一句更是错把殷历甲寅元的历元当汉太初元年干支号,让人不可理解。\n第二,对比《史记·历书》和《汉书·律历志》,关于太初改历的经过记载不同。\n《史记·历书》说:“至今上即位,招致方士唐都分其天部,而巴落下闳运算转历,然后日辰之度与夏正同。乃改元,更官号,封泰山。”接着记改历诏书,后附《历术甲子篇》,以明示历法根据。记载简明扼要,但清楚合理。\n《汉书·律历志》记载较详,先讲司马迁等人上书建议改历,武帝诏御史大夫兒宽与博士议,问:“今宜何以为正朔?”然后,博士赐等均说:“帝王必改正朔……则今夏时也。臣等闻学褊陋,不能明。”接着又大讲三统之制,后圣复前圣(显然语出刘歆)。在群臣大发议论之时,武帝即下诏改历,“其以七年元年”,结果是公孙卿、壶遂、司马迁等人“定东西,立晷仪,下漏刻”,忙碌一阵之后,却原来这一伙人“不能为算”,于是又招募治历者,邓平等人入选,“都分天部,而闳运算转历”,这样才产生了邓平的八十一分法。\n《汉书·律历志》的记载是值得推敲的,在臣下毫无准备的情况下,武帝怎能先下诏改历、更定年号,而后再做具体工作?欲定“朔晦分至,躔离弦望”,以及“太初本星度新正”,绝非一日之功,必须早有准备,怎能在仓促间得之?更奇怪的是,司马迁身为皇家太史令,学有家传,却“不能为算”,只好请邓平等人来帮忙;何况无论从理论还是从实践上看,八十一分法都比四分法粗疏。至于“后圣复前圣”之说,出自刘歆《三统历》,怎会在太初改历时出现?……这些问题都值得深思。\n第三,关于改历的诏书记载不同。\n汉武帝的改历诏书,无论《史记·历书》或《汉书·律历志》所记,都未提及邓平八十一分法,可见改历之初邓平并未参与。\n《史记·历书》说:“因诏御史曰:‘乃者有司言星度之未定也,广延宣问,以理星度,未能詹也。盖闻昔者黄帝合而不死,名察度验,定清浊,起五部,建气物分数,然盖尚矣。书缺乐弛,朕甚闵焉,朕唯未能循明也。绩日分,率应水德之胜。今日顺夏至,黄钟为宫,林钟为徵,太簇为商,南吕为羽,姑洗为角。自是以后,气复正,羽声复清,名复正变,以至子日当冬至,则阴阳离合之道行焉。十一月甲子朔旦冬至已詹,其更以七年为太初元年。’”\n《汉书·律历志》中,从“乃者有司言历未定”到“未能循明”一段,基本上全抄《史记》,奇怪的是,班固竟不抄完,中间空了关键性的一大段话,最后只录“其以七年为元年”一句作结束。这样改动,文意不通姑且不论,最让人难解的是不合情理,既然“未能循明”,又贸然宣布“其以七年为元年”,如此行事,岂不荒唐!\n根据上述分析,可以得出这样的结论:\n(1)元封七年改历之初,邓平并未参与,更没有八十一分法,用邓平法取代四分法是后来的事。\n(2)《汉书·律历志》记述不清,是造成种种误解的根源。一方面由于年代久远,史料不全,另一方面由于班固本人对汉初用历、太初改历缺乏正确的认识,以致使《汉书·律历志》的记述陷入混乱,前后矛盾;而《史记·历书》出自太初改历当事人之手,自然比较可靠可信。当然,这并不排除《汉书·律历志》保存的其他资料的可靠性,比如前面一再提到的“次度”,就出自《汉书·律历志》。\n那么,邓平八十一分法究竟在什么时候取代了四分法呢?我们不妨从历法上进行一些探讨。\n如前所述,太初元年改历取消前小余705分,后小余24分。\n太初元年实为:\n无大余无小余\n无大余无小余\n如果除去太初元年丁丑(元用丁丑)不记,那么太初元年前十一月甲子日朔夜半冬至无余分,实际上相当于殷历甲寅元第一蔀(甲子蔀)首年,即《历术甲子篇》首年,后面年份的朔闰均可依《历术甲子篇》计算。后人不明其中的缘故,在《历术甲子篇》上加记汉太初以后的年号、年数,误认为《历术甲子篇》是太初改历时司马迁的创作,其原因盖出于此。\n虽然太初改历消除了朔余705分,但并未从根本上改变“日食在晦”的天象,所以,太初改历仍有“日食在晦”的现象发生,这恐怕就是促使邓平等人创制八十一分法的原因。他想用简化朔余、改变章蔀的方法来提高历法的精度。尽管八十一分法疏于四分法,但这种创新精神是可贵的。\n既然太初改历确定以夏正正月(寅)为岁首,那么八十一分法取代四分法的理想时机就应该是“正月朔日立春”,作为历元起点。用这个标准衡量太初元年,显然不合格:\n太初元年立春在年前十二月(丑)十七日,自然不能作为八十一分历的起点,所以,认为自从太初元年改历起就行用邓平八十一分法,是没有历法根据的。\n从历法上看,在太初元年以后的十多年间,只有征和元年(公元前92年)比较理想。\n104-92=12(算外13年)\n查《历术甲子篇》十三年十二\n大余五十小余五百三十二\n大余三无小余\n说明该年前十一月甲寅朔丁卯冬至。据此可得正月朔日立春干支:\n子大甲寅50532冬至丁卯 3 0\n丑小甲申2091大寒丁酉3314\n寅大癸丑49590立春壬子4821\n由此可知该年正月合朔与立春密近(气余甚大),可以作为八十一分历的起点。\n但是作为新历首月应为小月( <30),邓平等人为了避免日食在晦的天象,减少朔余,所以用了首月为小减少余分的方法(即先借半日),从而实现了四分历向八十一分历的顺利过渡。《汉书·律历志》所谓“先籍半日,名曰阳历”,即指此事。邓平历由于其本身精度不及四分法,行用时间并不长,到东汉章帝元和二年(公元85年)只好又改行四分法,那是后来的事了。\n可见,八十一分法取代四分法已到了太初元年之后的第十三年,其时司马迁的《史记》已经完成,司马迁本人或衰病无力,或不在人世,怎么会在《史记》中补记邓平八十一分法呢?司马迁若因此而受到后人责难,岂不冤哉!\n这个八十一分法就是《后汉书·律历志》所说的“三统历”。原文有:“自太初元年始用三统历,施行百有余年,历稍后天,朔先于历,朔或在晦,月或朔见。”\n这个八十一分法也就是《后汉书·律历志》所说的“太初历”。原文是:“至元和二年,太初失天益远,日月宿度相觉浸多。”还有:“(贾)逵论曰:太初历冬至日在牵牛初者,牵牛中星也。”贾逵又曰:“太初历不能下通于今,新历不能上得汉元,一家历法必在三百年之间。”新历指后汉四分历,太初历当然指的是八十一分法。原文还有:“昔太初历之兴也,发谋于元封,启定于元凤,积三十年,是非乃审。”这个发谋于元封的“太初历”还是指的八十一分法。\n总之,古历的惑乱,东汉人已无法说清,贻误后世,更可想见。\n三、关于刘歆的三统历 # 刘歆是西汉学者刘向之子,学有家传,也是西汉后期著名学者。他在其他方面的贡献,这里不必评述,我们只谈刘歆的三统历。\n刘歆的三统历曾与唐一行的大衍历、元郭守敬的授时历并列,被称为中国古代三大名历,其实是徒有虚名。《汉书·律历志》说:“至孝成世,刘向总六历,列是非,作《五纪论》,向子歆究其微妙,作三统历及谱以说春秋,推法密要,故述焉。”可见,班固是刘歆三统历的重要吹捧者。这样,《汉书·律历志》大量记载和宣传刘歆三统历,就是必然的。正因为如此,显露了班固的弱点,引起后世历家的非议。《晋书·律历志(中)》曰:“其后刘歆更造三统,以说《左传》,辩丽非实,班固惑之,采以为志。”《宋书·卷十二》曰:“向子歆作三统历以说《春秋》,属辞比事,虽尽精巧,非其实也。班固谓之密要,故汉《历志》述之。”《宋书·卷十二》何承天说:“刘歆三统法尤复疏阔,方于四分,六千余年又益一日。扬雄心惑其说,采为《太玄》;班固谓之最密,著于《汉志》;司彪因曰:‘自太初元年始用三统历,施行百有余年。’曾不忆刘歆之生,不逮太初,二三君子言历,几乎不知而妄言欤!”\n上述的评论无疑是正确的。\n但是,刘歆三统历究竟从何而来?它与四分法、八十一分法究竟有什么联系和区别?前人尚未论及,为了戳穿刘歆精心安排的骗局,廓清长期危害古历研究的迷雾,张汝舟先生生前进行了精心研究,取得了重大成就。\n首先应该指出,刘歆三统历不同于邓平八十一分法,二者除产生年代相差甚远之外,内部编制也根本不同:邓平八十一分法的三统是章蔀名(一元三统),刘歆三统,实为历名(即孟统、仲统、季统);邓平三统年数相加为一元,刘歆三统,各成一统,虽交错编排,实际自成体系;邓平历为八十一分法,刘歆历为四分法。有人将邓平历与刘歆历混为一谈,甚至认为自太初元年即行用三统历,正如何承天所说,纯属“妄言”。刘歆曾为王莽国师,在太初之后一百多年,太初改历时刘歆尚未出世,何来三统历?\n同时还应该看到,刘歆编三统历完全是为王莽篡权这一政治目的服务的。为了给王莽上台制造理论根据,披上合法的外衣,必须“改正朔,易服色,所以明受命于天也”。刘歆编三统历的背景如此,目的如此,必然要歪曲史实,巧言夏、商、周三代更替,以便为王莽上台鸣锣开道。然而,殷历甲寅元四分法古来行用,深入人心;邓平八十一分法新近改制,天下尽知;刘歆作为一代学者,父子相继,是深知其中的奥秘的,他虽无法另起炉灶,但可以巧立名目,于是暗用四分法岁实,又偷取邓平历的统法(1统81章),使孟、仲、季各成一统,交错迭用,形成三代更替的模式,号称三统历。《汉书·律历志》中那份《三统历章蔀表》就是这种货色,其中仲统(殷)以甲子为元首,季统(夏)以甲辰为元首,孟统(周)以甲申为元首,每统的第八十一章,与元首第一章相同,看来好似神乎其神,异常玄妙,其实,只要结合《世经》(见《汉书·律历志》)有关历点查对,就会发现,刘歆三统历不过是殷历甲寅元的模仿之作。\n三统历将殷太甲元年记为季统七十七章首乙丑,将周公摄政五年(庚寅,1111年)记为孟统二十九章首丁巳,说明三统历的排列,孟统先于殷历四章(即四分法的一蔀)。按四分章蔀计算,先于一蔀,必有蔀余39,孟统起于甲申(20),39+20=59(癸亥),而殷历起于甲子(0),实为殷历甲寅元的首日,所以孟统记事必先于殷历一日(即一位干支)。由此可知,前面考证殷历甲寅元历元时,曾列举《汉书·律历志·世经》的历点:“元帝初元二年十一月癸亥朔旦冬至,殷历以为甲子,以为纪首。”其所以《世经》记为“癸亥”,而殷历认为“甲子”,就是因为《世经》是出自刘歆之手,以孟统记事为主,而殷历实同仲统章蔀,所以记为“甲子”。其他《世经》中的历点,如“(汉高祖皇帝)八年十一月乙巳朔旦冬至,殷历以为丙午”,“(武帝)元朔六年十一月甲申朔旦冬至,殷历认为乙酉”,均同此例,可见刘歆用心良苦!\n前人不明其中道理,深受刘歆三统历的毒害和欺骗,于是导致了月相名称不得定解和月相四分法的产生,因此考证古历点(特别是古器铭文)便困难重重。为了彻底揭开刘歆三统历的神秘外衣,我们可以把所谓“三统”摊开列表(见拉页),加以对照,只要记住仲统即殷历甲寅元,了解孟统和殷历的关系,就照样可以按照《历术甲子篇》来查算其朔闰,推证历点。\n比如“虢季子白盘”盘铭“十二年正月初吉丁亥”,前人定为周宣王十二年正月初三,王国维说:“宣王十二年正月乙酉朔,丁亥乃月三日。”\n周宣王十二年为公元前816年。查《三统历与殷历章蔀对照表》,该年入孟统甲寅(50)蔀六十八年。\n同时又入殷历乙卯(51)蔀六十八年,查《历术甲子篇》六十八年:\n前大余三十一,前小余五百一十二。\n按孟统计算:\n50+31=81(21乙酉)\n说明该年周正月(即子月)乙酉朔,丁亥果为初三。王国维就是这样用孟统计算,把初吉定为月初三,从而形成了他的“月相四分法”。\n若按殷历计算:\n51+31=82(22丙戌)\n殷历创制行用于公元前427年。\n(816-427)×3.06=1190(分)\n1190-940=250(分)\n说明当时先天1日250分\n22.512+1.250=23.762(日加日,分加分)\n即该年应:前大余为23(丁亥),前小余762。即该年正月(子月)丁亥762分合朔,这是当时的天象实况。显然,“初吉丁亥”就是月初一。\n王氏轻信刘歆三统历,既不知孟统先于殷历一日,又不考虑年差分的修正,必然会得出初吉为月初三的结论,最后形成了“月相四分法”。追根溯源,刘歆三统历实为罪魁。\n刘歆要突出三统历的地位,必然要抹杀殷历甲寅元,掩盖历史真相,否则三统历是难以招摇撞骗欺世盗名的,联系《历术甲子篇》多处被篡改,不能不使人对他产生怀疑。后来刘歆虽以谋反罪被迫自杀,三统历也没有实际运用,但它的恶劣影响却长达近两千年,实为历术研究的不幸。\n四、后汉四分历 # 八十一分法粗于四分,使用一久,必与天象不合。《后汉书·律历志》记,汉明帝永平“十二年十一月丙子,诏书令(张)盛、(景)防代(杨)岑署弦望月食加时。四分之术,始颇施行。是时盛、防等未能分明历元,综校分度,故但用其弦望而已”。又说:“至元和二年,《太初》失天益远,日月宿度相觉浸多,而候者皆知冬至之日日在斗二十一度,未至牵牛五度,而以为牵牛中星,后天四分日之三,晦朔弦望差天一日,宿差五度。章帝知其谬错,以问史官,虽知不合,而不能易。故召治历编、李梵等综校其间,二月甲寅遂下诏。”于是四分施行,这就是后汉四分历。\n比较汉武帝改历后行用的八十一分法,后汉四分历的交气、合朔时刻提前了34日,从而利于校正八十一分法后天的现象。后汉四分历把战国以来四分历(殷历)沿用的冬至点在牵牛初度这个位置改正到斗宿2114度;它用黄道度数来计算日、月的运动和位置;它还根据实际观测定下了二十八宿距星间的赤道度数和黄道度数,二十四节气的太阳所在位置和昏旦中星,昼夜漏刻和八尺表的影长等重要数据。这在《后汉书·律历志》中有明确详细记载,这些内容在历法上都是首创。贾逵说:“元和二年八月,诏书曰‘石不可离’,令两候,上得算多者。太史令玄等候元和二年至永元元年,五岁中课日行及冬至斗二十一度四分一,合古历建星《考灵曜》日所起,其星间距度皆如石氏故事。他术以为冬至日在牵牛初者,自此遂黜也。”经过实测,确定冬至点在斗2114度,冬至点在牵牛初度的古制自此不再行用了。\n汉明帝时虽用四分术推定弦望月食,而“未能分明历元,综校分度”,到元和二年行用后汉四分历,才明确以文帝后元三年(公元前161年)十一月夜半朔旦冬至为历元。这正如汉顺帝时代太史令虞恭所言:“建历之本,必先立元,元正然后定日法,法定然后度周天以定分至。三者有程,则历可成也。四分历仲统之元,起于孝文皇帝后元三年,岁在庚辰。上四十五岁,岁在乙未,则汉兴元年也。又上二百七十五,岁在庚申,则孔子获麟。二百七十六万岁,寻之上行,复得庚申。岁岁相承,从下寻上,其执不误。此四分历元明文图谶所著也。”\n东汉纬书奉孔子为圣人,宣传孔子在哀公十四年庚申岁(公元前481年)获得一只麒麟。《春秋元命苞》《易乾凿度》等纬书认为,从获麟那时上推276万年,就是所谓天地开辟的年代。虞恭认为,这就是四分历之元。\n从文帝后元三年上溯到鲁哀公十四年(前481年)是320年(前481—前插拉伸页单插拉伸页双161),这320年加276万年,是2760320年,正好是四分历朔望月、回归年和六十干支周的共同周期1520年的整倍数(1816倍)。\n后汉四分历还认为,自文帝后元三年再上推两元(4560×2=9120年),即公元前9281(9120+161)的年前十一月朔夜半不但是甲子朔冬至,而且还是月食和五星运动的起点。《后汉书·律历志》载:“斗之二十一度,去极至远也,日在焉而冬至,群物於是乎生。故律首黄锺,历始冬至,月先建子,时平夜半。当汉高皇帝受命四十有五岁,阳在上章,阴在执徐,冬十有一月甲子朔旦冬至,日月闰积之数皆自此始,立元正朔,谓之汉历。又上两元,而月食五星之元,并发端焉。”\n改用四分历同历次改历一样,也遭到保守派的反对。安帝延光二年(公元123年)亶诵等人攻击后汉四分历,说什么“《四分》虽密于《太初》,复不正,皆不可用,甲寅元(纬书记载的四分历)与天相应,合图谶,可施行”。甚至说:“孝章改《四分》,灾异卒甚,未有善应。”亶诵等人的言论,受到张衡的有力反驳,他严肃地指出:“天之历教,不可任疑从虚,以非易是。”并讽嘲他们“不以成数相参,考真求实,而泛采妄说”。\n又过五十年,灵帝熹平四年(公元175年),保守派冯光、陈晃等人又出来攻击后汉四分历。他们说:“历元不正,故妖民叛寇益州,盗贼相续为害。”(《后汉书·律历志》)把社会动乱和自然灾异归咎于历元变更。蔡邕等人当即驳斥了这种观点,维护了四分历的顺利推行。\n在围绕后汉四分历的斗争中,产生了东汉末年刘洪的乾象历。乾象历取365145589日为一年,即365.2462日,由十九年七闰规律可推算出乾象历的朔望月数值为29.53054日,即297731457日。它还引进月行迟疾的历法,由此可更准确地推算日食和月食。由于东汉王朝的腐败,终及汉世,乾象历也未被采用。到三国时代的孙吴政权,才于公元223年颁行刘洪的乾象历,曹魏到景初元年(公元237年)颁行杨伟造的景初历。后汉四分历,由汉末延续到蜀汉政权的灭亡,才由泰始历(由景初历改名而来)取代。\n关于后汉四分历与殷历、三统历的关系可见张汝舟先生1959年所制《三统历与殷历章蔀对照表》(见前拉页)。\n五、古历辨惑 # 综上所述,历法中要明确下事。\n1.三套周历\n齐鲁用的四分术周历,实是子正之四分术。战国以降,四分术普行,齐鲁之周历,不过用子正而已,别无新异之处。\n六历中有周历。东汉纬书只谈天正甲寅元、人正乙卯元。其他黄帝历、夏历、周历、鲁历,均是东汉人的附会。六历之周历,不过是一个虚妄的名词而已。唐代《开元占经》记有古六历的上元到开元二年(公元714年,甲寅岁)的积年数字,这些数字也都在276万年以上。古六历之间的差别相对说来反而小得多。对于古六历的积年,都认为是东汉人在原来比较简单的上元积年数据上追加了一种带有神秘性的高位数字而成。《后汉书·律历志》载,蔡邕以为,“历法,黄帝、颛顼、夏、殷、周、鲁,凡六家,各自为元”。前面介绍的后汉四分历,虞恭将孔子获麟之上276万年,作为上古历元。六历积年与此不能说没有关系。\n《开元占经》列古历上元积年表是:\n黄帝历辛卯2,760,863\n颛顼历乙卯2,761,019\n夏历乙丑2,760,589\n殷历甲寅2,761,080\n周历丁巳2,761,137\n鲁历庚子2,761,334\n有人以为,纬书中这类大数字的上元积年的推求,大概在刘歆的“三统历”里就已开始。姑存其说。\n刘歆的“三统”以孟统为周历。这仍是四分术。这个三统的周历与甲寅元殷历的关系,见《三统历与殷历章蔀对照表》。刘歆的三统历并未施行过,后人据以推求西周铜器铭文,多有龃龉。\n这就是同名而实异的三套周历。\n2.两套颛顼历\n六历中的颛顼历,就是东汉盛传的“人正乙卯元”,又称己巳元。东汉刘洪言:“推汉己巳元,则《考灵曜》旃蒙之岁乙卯元也。与(冯)光、(陈)晃甲寅元相经纬。”又说:“乙卯之元人正己巳朔旦立春,三光聚天庙五度。”为什么叫“人正”?区别于殷历以年前十一月甲子朔旦夜半冬至的“天正”。甲寅元与乙卯元的关系,刘洪说:“课两元端,闰余差百五十二分之三,朔三百四,中节之余二十九。”前面已专章讲到甲寅元与乙卯元的关系。用乙卯元的数据,验证秦汉历点,均不相符。可见这个六历中的“颛顼历”从来没有施行过。刘洪说:“甲寅历于孔子时效;己巳颛顼秦所施行,汉兴草创,因而不易。至元封中,迂阔不审,更用太初,应期三百改宪之节。”(《后汉书·律历志》)这个说法是靠不住的。\n秦所施行的颛顼历,非己巳即人正乙卯元之颛顼。秦用颛顼,还是四分术,是殷历的一种变化形式,所不同者,以十月为岁首,闰在岁末,称“后九月”。四分法的殷历行用已久,秦不可变,连以寅为正的月序关系也已深入人心,不可改易。如果用殷历(四分法)验证典籍所记秦汉历点,更能证实秦所用颛顼历就是殷历。\n3.两套三统历\n刘歆的三统历,乃四分术,在《汉书·律历志》中有详细记载:“三代各据一统,明三统常合,而迭为首。……天施复于子,地化自丑毕于辰,人生自寅成于申。故历术三统,天以甲子,地以甲辰,人以甲申。孟仲季迭用事为统首。”刘歆的“三统”,就是孟统、仲统、季统。其章蔀配合各朝纪事,尽在《汉书·律历志》。\n八十一分法之三统,是三统年数加起来为一元,所谓“三统法,得元法”。《汉书·律历志》载:“统母,日法八十一。”孟康注:“分一日为八十一分,为三统之本母也。”《后汉书·律历志》载:“自太初元年始用三统历,施行百有余年,历稍后天,朔先于历,朔或在晦,月或朔见。考其行,日有退无进,月有进无退。建武八年中,太仆朱浮、太中大夫许淑等数上书,言历朔不正,宜当更改。时分度觉差甚微,上以天下初定,未遑考正。”这里的“三统历”,无疑是指八十一分法而言。\n4.两套太初历\n《史记·历术甲子篇》所记“太初”是年号,纪念改历之意,同时还是四分。张汝舟先生考证,武帝太初元年前十多年朔闰只合四分,十多年之后的朔闰只合八十一分。这也符合“昔太初历之兴也,发谋于元封,启定于元凤,积三十年,是非乃审”(《后汉书·律历志》)的记载。元封七年改历,至元凤年间才最后完成。验之朔闰,最初改“十月岁首”为正月岁首,行“无中气置闰”,第二步才行用八十一分法。《史记·历书》大谈太初改历,而无丝毫八十一分法痕迹,又详记了四分法一蔀七十六年朔闰,也反映了这一事实。这是四分术的太初历,即甲寅元殷历。\n《汉书·律历志》所记“太初历”,是明白无误的八十一分法。所谓邓平、落下闳之法,“一月之日二十九日八十一分日之四十三”。《后汉书》载贾逵言:“太初历不能下通于今,新历不能上得汉元。”新历指后汉四分历,“太初历”仍是指八十一分法之历。章帝元和二年之前,上至汉武帝时,数百年均行用八十一分法。东汉人心目中的“太初历”是没有歧义的。\n不通历法而编写《律历志》的班固,误信刘歆三统历“推法密要”;又以为“三代既没,五伯之末,史官丧纪,畴人子弟分散,或在夷狄,故其所记,有黄帝、颛顼、夏、殷、周及鲁历”;又以为汉初“用颛顼历,比于六历,疏阔中最为微近”;又记“乃诏(司马)迁用邓平所造八十一分律历,罢废尤疏远者十七家,复使校历律昏明”;并以太初历为八十一分法之专属,全然勾销了《史记·历术甲子篇》作为历法的功用。\n于是,刘歆所造而并未施行过的三统历身价百倍,迭经渲染,成为古代三大名历之首;于是,古有“六历”之说风行于世,东汉纬书连六历上元积年都推算出来了;于是,六历之颛顼,即从未施行的人正乙卯元与秦所用颛顼历混为一谈;于是,邓平八十一分法得太初历之专名,司马迁《史记·历书》所载竟成了古之遗物,无人过问,后代视《历术甲子篇》为一张历表,皆轻贱之。总之,班固在中国古代历法史上所造成的迷误是应该加以清理的时候了。\n六、岁星纪年 # 从观测天体运行的角度来说,发现木星十二年一周天,并用之纪年,无疑是个伟大的创造。但实践证明,岁星纪年并不理想,因为岁星并非恰好十二年一周天,而是11.8622年一周天,这样每过八十余年就要发生岁星超次(宫)的现象,这是不以人的意志为转移的客观规律。《左传·襄公二十八年》记“岁在星纪而淫于元(玄)枵”,就是古人发现岁星超次的真实可靠的记录。如果我们把这次记载看做首次,那么可以断定,岁星纪年至迟产生在鲁襄公二十八年(公元前545年)以前八十多年,即公元前7世纪,有人认为岁星纪年产生于公元前4世纪初,未免估计不足。\n岁星纪年毕竟属于观象授时的范畴,它要受到天象观测的制约,岁星超次的发现必定给岁星纪年造成混乱,使岁星纪年面临淘汰的危机。因为,既然岁星已经不能成为纪年的永久性标志,岁星纪年赖以存在的基础便随之动摇崩溃,其寿命必然是短暂的,绝非人力所能挽救。同时,岁星纪年与太岁纪年同时并用,时间一长也会引起混乱,所以诗人屈原咏叹上古的著名诗篇《天问》,就有“十二焉分”的疑问,可见岁星纪年和太岁纪年不能长期并存。再说古人对五星运行的观测虽然给天文学留下宝贵的资料,同时也被占星家用来占卜凶吉,充满了迷信色彩,木星的运行尤为占星家看重。上古的星占已如前述,就是到了明末清初,大学问家顾炎武写《日知录》时还说:“吴伐越,岁在越,故卒受其凶。苻(坚)(前)秦灭(前)燕(370年),岁在燕,故(后)燕之复建不过一纪,二者信矣。(南燕)慕容超之亡,岁在齐,而为刘裕所破,国遂以亡,岂非天道有时而不验耶?是以天时不如地利。……以近事考之,岁星当居不居,其地必有殃咎。”可见影响之深远!正因为岁星并非主要和专一用于纪年,所以前面所引《淮南子》《史记》《汉书》都将其归于天文类,并附记于太岁纪年之后,是很有道理的。它告诉我们,岁星纪年的寿命不会很长。\n科学与迷信、真实与虚假总是不相容的,古代星历家也不会长期使用早已破产了的岁星纪年法。现在有人不考虑岁星纪年本身的局限性,无限延长它的寿命,甚至说它一直行用到西汉太初(公元前104年),这显然是不妥当的。试想,就是从鲁襄公二十八年(公元前545年)算起,到西汉太初元年,已有441年。倘若一直行用岁星纪年法,以八十余年超一次计,到太初元年必超五次,加上鲁襄公二十八年超一次,共超六次之多。也就是说,应该“岁在星纪”,却已“淫于鹑首”了。如此纪年,还有什么准确性可言。不能想象,古人竟会如此之愚。有人认为“战国时代不存在岁星超辰的实际问题”,显然是以主观臆断代替客观规律。至于以汉太初元年为寅年来逆推岁星,那更是失之毫厘,差之千里了。\n因为岁星运行的方向与古人所熟悉的天体十二辰(以十二地支配二十八宿)划分的方向正好相反,在实际运用中很不方便,星历家便设想出一个假岁星叫太岁(《汉书·天文志》叫太岁,《史记·天官书》叫岁阴,《淮南子·天文训》叫太阴,名异而实同),让它与真岁星“背道而驰”,与十二辰(即二十八宿)的运行方向相一致,同时另取“摄提格、单阏、执徐、大荒落、敦、协洽、涒滩、作噩、阉茂、大渊献、困敦、赤奋若”(即地支别名,见《尔雅·释天》)等十二名,作为太岁纪年的名称,所以,《周礼》注云:“岁星为阳,右行于天,太岁为阴,左行于地。”(见39页图)\n左行、右行之说,使不少人觉得难解,其实正如《晋书·天文志》所描述的那样:“天旁转如推磨而左行,日月右行,随天左转。故日月实东行,而天牵之以西没。譬之于蚁行磨石之上,磨左旋而蚁右去,磨疾而蚁迟,故不得不随磨以左回焉。”五星的运行与之同理。\n由此可知,岁星纪年与太岁纪年从一开始就既有联系,又有区别,在古人心目中也是十分清楚的。比如:\n《淮南子·天文训》曰:“太阴在寅,岁名曰摄提格,其雄为岁星,舍斗、牵牛。”\n《史记·天官书》曰:“摄提格岁,岁阴左行在寅,岁星右转居丑,正月与斗、牵牛晨出东方,名曰监德。”\n《汉书·天文志》曰:“太岁在寅曰摄提格,岁星正月晨出东方,石氏曰名监德,在斗、牵牛。”\n这些上古天象观测材料,行文上辨析分明,太岁(太阴,岁阴)归太岁,岁星归岁星,而且是太岁在前,岁星附记,二者不容混淆。我们不要以为这些资料出自汉代典籍,便认为这是汉代的星象和纪年法。古人认为“天不变道亦不变”,所以总是把古老的传闻世代相袭记载下来,文中的“石氏”就是战国时代魏国的大星历家石申(著有《石氏星经》),由此可知,这些资料至少产生于战国以前。\n与岁星不同,太岁只是一个假想的天体,正因为其“假”,它不会像真岁星一样要以天象观测为依据,不受什么运行规律的制约,因此也不会像岁星一样存在“超辰”问题,更不会为顺应真岁星超次而超辰,它不过以抽象的代号纪年罢了。当岁星纪年因超次逐渐被淘汰之后,太岁纪年必然会脱离岁星纪年而独立存在,成为不受外来影响的理想的纪年法。“摄提格”等十二名与十二地支相应,实际上就是地支的别名,所以太岁纪年十二年一循环,本质上就是地支纪年,也就是向干支纪年的过渡形式。到了“阏逢、旃蒙、柔兆、强圉、著雍、屠维、上章、重光、玄黓、昭阳”(实为天干)十岁阳之名(见《尔雅·释天》),与“摄提格”等十二岁阴之名相配合,便成了完整的干支纪年,保留在《史记·历书》中的《历术甲子篇》便是以岁阳、岁阴来纪年的(与《尔雅》所记名称略有差异),如“岁名焉逢摄提格”,就是甲寅年。因此,可以这样说《历术甲子篇》创制行用之时,就是干支纪年开始之日。\n星历家其所以用岁阳、岁阴纪年,是为了与干支纪日相区别,正如顾炎武《日知录》卷二十曰:“《尔雅》疏曰:甲至癸为十日,日为阳;寅至丑为十二辰,辰为阴,此二十二名,古人用以纪日,不以纪岁,岁自有阏逢至昭阳十名为岁阳,摄提格至赤奋若十二名为岁名。后人谓甲子岁、癸亥岁非古也。自汉以前,初不假借。《史记·历书》‘太初元年,岁名焉逢摄提格,月名毕聚,日得甲子,夜半朔旦冬至’,其辨析如此。若《吕氏春秋·序意篇》‘维秦八年,岁在涒滩,秋甲子朔’;贾谊《鵩鸟赋》‘单阏之岁兮,四月孟夏,庚子日斜兮鵩集于舍’;许氏《说文后叙》‘粤在永元困顿之年孟陬之月朔日甲子’,亦皆用岁阳岁名不与日同之证。《汉书·郊祀歌》‘天马徕,执徐时’,谓武帝太初四年,岁在庚辰,兵诛大宛也。”但是,岁阳岁名与干支在本质上是一样的,不过名目不同而已。同时,岁阳岁名纪年本身就反映了太岁纪年向干支纪年过渡的历史痕迹,汉以前的所谓太岁纪年无一不与干支纪年相吻合,就是这个道理。后世文人好古,纪年常用岁阳岁阴,司马光《资治通鉴》卷一百七十六《陈纪十》曰“起阏逢执徐,尽著雍涒滩,凡五年”,即从甲辰到戊申共五年;清人许梿《六朝文絜原序》云:“道光五年,岁在旃蒙作噩壮月,海昌许梿书于古韵阁。”“旃蒙作噩壮月”就是乙酉年八月。\n以上是我们对于岁星纪年和太岁纪年的关系,以及岁星纪年被淘汰、太岁纪年向岁阳岁阴纪年(即干支纪年)过渡的认识,还可以用历法运算进一步证实。\n到了清代,钱大昕在《潜研堂文集·太阴太岁辨》中提出:“太阴自太阴,太岁自太岁”,“太阴纪岁、太岁超辰之法,东汉已废而不用”。他认为:(1)太阴、太岁不是一回事;(2)太岁有超辰之法;(3)由此引申出干支纪年起于东汉的说法。钱大昕的观点对后世产生了很大影响,不能不进行一番辨析。\n首先起来驳难钱大昕的是他的学生孙星衍。孙星衍《问字堂卷五·再答钱少詹书》云:“今按《史记》十二诸侯年表自共和迄孔子,太岁未闻超辰,表自庚申纪岁,终于甲子,自属史迁本文,亦不得谓古人不以甲子纪岁。《货殖传》云,‘太阴在卯,穰,明岁衰恶,至午旱,明岁美。’此亦甲子纪岁之明征,不独《后汉书》‘今年岁在辰,来年岁在巳’之文矣。”\n更为有力的驳论出自王引之。他为了全面论述问题,专写《太岁考》一文(见《经义述闻·卷三十》)。他说:“潜研堂文集乃谓太阴、岁阴非太岁,假如太阴与太岁不同,则古人纪岁宜于太岁之外别言太阴,何以《尔雅》言太岁而不及太阴,《淮南》言太阴而不及太岁乎?斯足明太阴之即太岁矣。钱说失之。”又说:“古人言太岁常与岁星相应,故《史记·天官书》有岁阴在卯,岁星居丑之说,而不知岁星之久而超辰。《左传》襄二十八年有曰:岁在星纪而淫于元枵,又曰岁弃其次而旅于明年之次。夫岁星当在星纪而进及元枵,此超辰之渐而谓之曰淫曰旅,则不知有超辰,而以为岁星之赢缩也。……刘歆三统数岁星百四十四年超一次,是岁星超辰之说自刘歆始也。岁星超辰而太岁不与俱超,则不能相应,故又有太岁超辰之说。……干支相承有一定之序,若太岁超辰则百四十四年而越一干支,甲寅之后遂为丙辰,大乱纪年之序者,无此矣。且岁星百四十四年超一辰,则七十二年已超半辰,太岁又将何以应之乎?古人但知岁星岁行一辰而不知其久而超辰,故谓太岁与星岁相应,后人知岁星超辰,则当星自为星,岁自为岁,方得推步之实而合纪年之序,乃必强太岁超辰以应岁星,不亦谬戾而难行乎!故论岁星之行度,则久而超辰,不与太岁相应,古法相应之说断不可泥。论古人之法则,当时且不知岁星之超辰,又安得有太岁超辰之说乎?”他还说:“晓徵(即钱大昕)先生不信高帝元年乙未、太初元年丁丑之说,而以为后人强名之,武帝诏书之乙卯、天马徕之执徐,岂亦后人强名之乎?斯不然矣。”\n王氏此论言之有理,雄辩有力,他否定了“太阴自太阴,太岁自太岁”,否定了太岁超辰法,也否定了干支纪年起于东汉的说法,是完全可信的。遗憾的是,王引之的宏论没有引起后人的重视,钱大昕的观点却发生了很大影响。自郭沫若用太岁超辰法考证屈原生年(见《屈原研究》)以后,特别是浦江清用太岁超辰法具体推算屈原出生年月日(见《屈原生年月日的推算问题》)以后,近年形成风气,效法者有近十家之多,于是屈原生年就有公元前339年、前340年、前341年、前342年、前353年等多种说法。他们所用方法略同,所得结论大异,实际上就宣告了太岁超辰法的破产。因为,既然他们使用太岁超辰法,就必须遵循86年超1辰的法则,而所谓“超辰”又是随着时间的推移逐年递加造成的,推算的起点不同,该“超辰”的年份就不同。所以,无论逆推也好,顺推也好,怎样巧手安排,都无法得出可信的结论,最后只能自相矛盾,互相否定。\n查验五星运行规律作为天象观测的重要内容,后世在长期进行着,但木星作为纪年标准,从发现它超次之日起,便逐步被淘汰,完成了它的历史使命。太岁纪年则向干支纪年过渡,最后进入历法时代。岁星纪年法作为一种历史陈迹,后世仿古者有之,招魂者有之,乱用者亦有之,但大多没有什么实际意义,因为那早已不是岁星纪年的本来面目。\n“岁星纪年”的破产,逼使星历家又回头来重新研究日月运行周期与回归年的配合。此后百把年,才有四分历的创制与使用。\n岁星纪年创立的十二宫(次)的名目本是用来纪年的,恰又与地支十二的数目吻合。昙花一现的岁星虽已过时,而纪年的名目却保留下来并为四分历法创制者所利用,以代替十二辰、十二支,只不过用来纪月而不再纪年了。《汉书·律历志》中“次度”就是这样记载的:“星纪:初,斗十二度,大雪;中,牵牛初,冬至……”这里的“星纪”“玄枵”之称显然指的是纪月了。\n由于有“岁星纪年”这么一段插曲,尔后,纪年的名目又与十二支配合用于纪月,又加干支纪年的行用,史籍上“太岁在寅”“岁在星纪”之类的记载,便叫人迷混不清了。\n我们不妨理出这样一个头绪:\n纪年岁星纪年:星纪、玄枵、娵訾、降娄……\n干支纪年:子、丑、寅、卯……\n纪月十二支:子、丑、寅、卯……\n十二宫次:星纪、玄枵、娵訾、降娄……\n不难看出,十二地支实在是造成迷乱的症结,而好古的文人又从中施放一些烟幕,确令后人糊涂了。\n由于岁星纪年仅在少数几个姬姓国行用,有特定的环境,而且行用时间是短暂的,因此,万不可将它从春秋推及后世,只要把“太岁在寅”“岁在星纪”理解为寅年、子年就够了,况且干支纪年行用以后,“太岁”与木星再也不能与之相提并论了。\n一般历史学家迷于史籍中“太岁在卯”“太岁在寅”等记载,总是认为这是指“岁星在×宫”,造成这种错觉的根本原因就在于对“岁星纪年”行用的历史缺乏正确的估价。当然,史学家迷信“岁星纪年”还有另外的原因,那就是对于干支纪年究竟起于何时的问题缺乏一致的认识。\n鉴于一些史学家惯用“岁星纪年”推算史籍的历点,看来有必要对“岁星纪年”的推算作一番探讨。\n采用“岁星纪年”推考历点,往往是私意确定推算起点,只图自圆其说,不求上下贯通。或因《史记·历书》有“太初元年,岁名焉逢摄提格”的记载,就立太初元年(公元前104年)为“岁在星纪”,定“星纪为寅(摄提格)”,于是以汉太初元年为推算起点。或以《吕氏春秋》所记“维秦八年,岁在涒滩”为依据,定始皇八年为申年,再用岁星纪年周期来上下推算各个历点。起点不可靠,结论自然不会正确。\n须知,“岁星纪年”在《春秋左氏传》上早有记载,岁星纪年的起点应该在那上面去找。《左传·襄公二十八年》:“岁在星纪而淫于玄枵。”岁星在这年跳辰,则襄公二十七年(公元前546年)岁在星纪无疑。又,《左传·昭公三十二年》(公元前510年)载“越得岁”,杜注“是年岁在星纪”。用木星周期核对这两条记载,两相吻合,这难道不是岁星纪年的可靠起点吗?襄公十八年(公元前555年)“岁在娵訾”,则襄十六(前557)“岁在星纪”无疑。\n我们据此列出“岁在星纪”与跳辰之年如下表:\n公元前545年、前462年……前130年为跳辰之年。有了这个“岁星纪年表”,就可以用它检验一切用木星周期推算史载历点的结论是否正确,尽管我们不相信“岁星纪年”有什么生命力。\n七、关于“月相四分”的讨论 # 在上古,月亮关系到人们的生产生活,引起人们丰富的想象,“嫦娥奔月”之类的神话故事在古典文学中是很多的。从天文历法角度来说,古人对于月球的观测主要用于月相纪日,设置闰月和确定月朔(岁首)。\n如前所述,月球是地球的卫星,月球围绕地球运行的轨道(白道)与黄道有5度的倾角,太阳、地球、月球三者的位置常动而不息,所以月相总是呈周期性的变化,古人对于不同的月相定下不同的名称,用以纪日,这在殷周钟鼎铭器和上古文献中留下不少记载。\n如《周书·武成》曰:\n惟一月壬辰旁死霸,若翌日癸巳,武王乃朝步自周,于征伐纣。\n粤若来三月既死霸,粤五日甲子,咸刘商王纣。\n惟四月既旁生霸,粤六日庚戌,武王燎于周庙。翌日辛亥,祀于天位。粤五日乙卯,乃以庶国祀馘于周庙。\n《尚书·顾命》曰:\n惟四月哉生魄,王不怿。甲子王洮颒水,相被冕服,凭玉几。\n《大敦》曰:\n隹王十又二年二月既生霸丁亥。\n对于上面“既死霸、旁死霸、哉生魄、既生霸、旁生霸”以及“初吉、既望”这些名称的含意和指代,古来无定说,所以字典辞书至今无确解。这些名称是古历点的重要组成部分,月相名称无确解,古历点必无定论,考证上古史料便失去可靠的依据。因此,我国信史的起点——周武王克纣之年,至今竟有几十种说法,问题就在这里。\n关于月相名称的解释,除了西汉刘歆之外,近代以俞樾、王国维两家为代表。\n俞樾《春在堂全书》有《生霸死霸考》一文。他认为:“惟以古义言之,则霸者月之光也。朔为死霸之极,望为生霸之极,以三统术言之,即霸者月之无光处也,朔为死霸之始,望为生霸之始,其于古义翩其反矣。”并释月相名称于后:“一日既死霸;二日旁死霸;三日载生霸,亦谓之朏;十五日既生霸;十六日旁生霸;十七日既旁生霸。”他还指出:“夫明生为生霸,则明尽为死霸,是故晦日者死霸也。晦日为死霸,故朔日为既死霸,二日为旁死霸。”\n俞樾主张的是“月相定点说”,以月相名称指代固定的月相,用以纪日,这是符合古历点记事实际的。这种见解难能可贵,为考释迷乱千古的月相名称奠定了基础,可惜诠释未精,尚有漏洞:\n1.月面明暗相依成相,若“霸”只释为“月之光”,“死霸”“生霸”将作何解?\n2.若释月“明生为生霸”,与“载生霸”有什么区别?其后月相名称怎样辨别?\n3.若以“死霸”为晦日,则朔日当为“旁死霸”,为什么又称“既死霸”?如以“既死霸”为朔日,“既生霸”为望日,望日之前一日当为“生霸”,才能与晦日为“死霸”相应,然而,古历点从无此例。\n虽然如此,瑕不掩瑜,俞樾首创系统的“月相定点说”,功不可没。\n后于俞氏的王国维先生在《观堂集林》中也有《生霸死霸考》一文。他分一月之日为四分:初吉一日至七八日;既生霸八九日至十四日;既望十五六至二十三日;既死霸二十三日至晦日。他说:“八九日以降,月虽未满,而未盛之明生已久;二十三日以降,月虽未晦,然始生之明固已死矣。盖月受日光之处,虽同此一面,然自地观之,则二十三日以后月无光之处,正八日以前月有光之处,此即上弦下弦之由分,以始生之明既死,故谓之既死霸。此生霸死霸之确解,亦即古代一月四分之术也。”又说:“凡初吉、既生霸、既望、既死霸,各七日或八日,哉生魄、旁生霸、旁死霸各有五日若六日,而第一日亦得专其名。”\n王国维此说可伸可缩,面面俱到,好似言之成理,万无一失,其实自相矛盾。“未盛之明”自朏日(初三)已渐生,何不称朏日至望日为“既生霸”?“始生之明”自望日后即渐死,何不称既望至晦日为“既死霸”?这样,“月相四分”就变成“月相二分”。古人记月相是为了纪日,古历点中的月相名称总是与纪日干支相连,这是月相定“点(一日)”而不是定“段(数日)”的铁证。否则,纪日干支已经包含在“月相四分”之中,古人又何必另外注明、不惮其烦?再说,月面圆缺不断变化,一个月相名称代七八天不同的月相,有什么实用价值?俞樾《生霸死霸考》说:“使书之载籍而无定名,必使人推求历法而知之,不亦迂远之甚乎?且如成王之崩,何等大事,而其书于史也,止曰:‘惟四月载[哉]生霸[魄]王不怿。’使载生霸无一定之日,则并其下甲子、乙丑莫知为何日矣,古人之文必不若是疏。”这一推论是完全正确的,好似预先就对王国维进行了驳难。虽然王国维补充说“第一日亦得专其名”,但“月相四分”与“专其名”又如何分辨呢?最终只能是主观安排。\n然而,由于王国维在学术界的地位和影响,“月相四分说”广为流传,不少学者引以为据,甚至天文学界都深受其影响。有人以此断定我国远在周代就有现今行用的星期制,有人确实相信“月相四分”。更有甚者如章鸿钊合中(王国维)日(新城新藏)之说主张以“朏”为月始,违背了中国古代礼仪习俗和历法惯例,只能是主观臆断的产物。\n我们认为,古文典籍关于月相名称的记载可以给我们几点启示:\n1.“霸(魄)”字从不单独使用,说明它不是月相名称,不能表示确定的月相;\n2.霸(魄)前加“生、死”二字,构成“生霸(魄)”“死霸(魄)”,它们有各自独立、相互对立的含义,与月相的关系极为密切,但并非月相专名,也不单独表示确定的月相;\n3.“既生霸、旁生霸、哉生霸”等名才是月相名称,是具有特定含义的独立的词。其中的“既、旁、哉”等字有修饰限制作用,是这种月相区别于他种月相的标志和特征,因此,它们是月相名称中不可缺少的组成部分;\n4.月相名称总是与纪日干支紧密配合使用,说明每一个月相名称,只能确指一种固定的月相,用以纪日。\n由此可见,王国维的说法是不可信的,俞越的论点在原则上是正确的。下面我们从考释“霸(魄)”字本义入手,详释月相名称于后。\n霸:许慎《说文》曰:“霸,月始生魄然也。承大月二日,承小月三日。从月,声。《周书》曰哉生霸。”段玉裁注云:“霸魄迭韵,《乡饮酒义》曰,月者三日则成魄。正义云,前月大则月二日生魄,前月小则三日始生魄。马注《康诰》云,魄,朏也。谓月三日始生兆朏,名曰魄。《白虎通》曰,月三日成魄,八日成光,按已上皆谓月初生明为霸。而《律历志》曰,死霸,朔也,生霸,望也。孟康曰,月二日以往明生魄死,故言死魄。魄,月质也。三统说是,则前说非矣。普伯切。《汉志》所引《武成》《顾命》皆作霸,后代魄行而霸废矣,俗用为王霸字,实伯之假借字也。”足见上述解释相互矛盾,实无定见,后世字典辞书依然如此。\n今按“霸”:月貌,霸从月声,为形声字,《说文》曰:“,雨濡革也。从雨革。”为会意字。段注“,雨濡革则虚起,今俗语若扑。”可见“”为“霸”字声符,又兼表义。因为雨下皮革,浸湿处变形虚起,未浸处依然如故,正应日照月球,受光面逐渐变白,背光面暗然转黑之形貌,如同“娶”字之“取”兼表声、义一样。月面明暗相依,变化呈形,“霸”字只是泛称,并不确指某一固定月相。霸、魄迭韵,故相通。若释“霸”为“有光处”或“无光处”,将月面明暗断然分开,各执一端,必然不得其解。《文选·谢庄月赋》“朏魄示冲”一句,李善注:“朏,月未成光;魄,月始生魄然也。”这是因袭旧解,并未注通文意,应释为“朏日(初三)月光初现之形貌”才妥当。\n死魄、生魄:死魄,月面背光处之貌;生魄,月面受光处之貌。《说文》:“死,澌也。”段注:“水部曰,澌,水索也。方言,澌,索也,尽也。”《白虎通》:“死之言澌,精气穷也。”月面背光处之貌,黯然无色,隐入夜空,如精气穷尽,故为“死魄”。\n《说文》又云:“生,进也。”《韵会》:“死之对也。”月面受光处之貌,光生辉现,与“死魄”相对,故为“生魄”。死魄、生魄相互依存,相辅相成,然而,月貌随时变化,天天不同,死魄、生魄并不能用来单独确指某一固定月相,自然不能纪日。\n朔日、既死魄、初吉:月初一。《说文》:“朔,月一日始苏也。”段注:“朔苏迭韵。日部曰晦者,月尽也。尽而苏也。《乐记》注曰,更息曰苏。息,止也,生也;止而生矣。引申为凡始之称,北方曰朔方,亦始之义也。”晦为月尽,朔为月初,贯穿于中国古代天文历法和史书记事的始终。真正合朔的时间很短,先之一瞬则月面之东尚余一丝残光,后之一瞬而月面之西又有一线新辉,然而人们自地目视,朔日太阳、月亮同升,月面隐而不现,即月面全部背光,故称之为“既死魄”。“既”表月相有二义:尽也,已也。段注:“引伸之义为尽也,已也,如《春秋》日有食之既。《周本纪》东西周皆入于秦,周既不祀。”月相名称中“既死魄、既生魄”之“既”当释为“尽”,“既望、既旁生魄”之“既”当释为“已”。“既死魄”即月面尽(全部)为背光之貌,故为朔日、月初一。\n“初吉”,不由月相得名,但有表月相纪日之实。因为朔日为一月之始,古代帝王重“告朔”之礼,以朔日为吉日,望日亦为吉日。故“初吉”实指朔日,即月初一,这就是铭器常以“初吉”记事的缘故。\n初吉指朔,古今无异辞。《诗·小雅·小明》“二月初吉”,毛传:“初吉,朔日也。”《周语上》“自今至于初吉”,韦注:“初吉,二月朔日也。”亦省作“吉”。《论语》:“吉月必朝服而朝。”孔安国注:“吉月,月朔也。”按:吉月犹《小雅·十月之交》言“朔月”,是“吉”即“朔”。《周礼·天官》“正月之吉”,郑注:“吉谓朔日。”\n旁死霸:月初二。“旁死魄”实为“旁既死魄”之省文。《释名》“左边曰旁”,《玉篇》“旁,犹侧也”,此处“旁”为依傍于(既死魄)边侧之义,故“旁死魄”为月初二。\n哉生魄、朏:月初三。《尔雅·释诂》:“哉,始也。”古文哉、才相通。“生魄”为月面受光处之貌。“哉”用以修饰描述,“哉生魄”就是月面开始(才)受光之貌,承小月者本月大,初三可见新月;承大月者本月小,初二偶尔可见一线生魄,但此种情况少有。已有“旁死魄”之名,故“哉生魄”实指月初三。《说文》云:“朏,月未盛之明也,从月出”,为会意字,与“哉生魄”同义,亦为初三。\n望、既生魄:月十五。《说文》:“望,月满也,与日相望。”段注:“日兆月而月乃有光。人自地视之,惟于望得见其光盈。”月满为望,多为月十五,这时月面全部(尽)为受光之貌,故称之“既生魄”。\n既望、旁生魄:月十六,望日之后一日为“既望”。此处“既”应释为“已”,古今无异辞。“旁生魄”为“旁既生魄”之省文,为“既生魄”之后一日,与“旁死魄”同理。\n既旁生魄:月十七,此“既”为“已”,“既旁生魄”为“旁生魄”之后一日。\n由此可见,古人所用的月相名称,只集中表示朔日后三天(初一到初三)和望日后三天(十五到十七),这显然与古人的吉祥记事有关,同时也反映了古人对朔、望之后月貌显著变化的准确认识。其余日期的月相虽然也在变化,但难以精确地命名表述,故用干支纪日配合使用,此亦“月相定点”之一证。另有“月半、上弦、下弦”之名,如《仪礼·士丧礼》“月半不殷奠”,《释名·释天》“弦,月半之名也”,这是后来的补充。\n月相名称是古历点的重要组成部分,因此考释其含义不仅是个训诂问题,而且要受到历法运算的检验,这将留待下面讨论。\n现将月亮出没规律列于后:\n朔月:日出月出,日没月没;\n上弦:中午月出,子夜月没;\n望月:日没月出,日出月没;\n下弦:子夜月出,中午月没。\n归纳一下。从月相定点说,张汝舟先生以为,古人重朔、望,月相就指以朔或望为中心的两三天。\n初一:朔,初吉,吉,既死魄;十五:望,既生魄;\n初二:旁死魄;十六:既望,旁生魄;\n初三:哉生魄,朏;十七:哉死魄,既旁生魄。\n从王国维月相四分说,则\n从月牙初露到月半圆,称初吉。首日朏(初三)。\n从月半圆到满圆,称既生霸。首日哉生霸(初八)。\n从月满圆到月半圆,称既望。首日望(十六)。\n从月半圆到消失,称既死霸。首日哉死霸(二十三)。\n“月相四分”说影响很大,传到日本,研究东洋历法的专家新城新藏氏据此附会,说中国古时每月以初三为月首,至下月初二为一月。国内信其说者,至今犹不乏其人,在文物考古界颇有市场。甚至更有人把“月相四分”与西方七日一星期联系起来,其穿凿程度令人发笑。\n古人凭月相定时日,其重要性可想而知。月相不定点,月相的概念也就毫无价值。如果我们用四分术,每年加上3.06分的误差,以实际天象来验证古器上的历点,“月相四分”说就不攻自破了。\n例一,“师虎簋”记:隹元年六月既望甲戌。\n王国维解释说:“宣王元年六月丁巳朔,十八日得甲戌。是十八日可谓之既望也。”\n王氏定此器为周宣王时铭器,他用刘歆三统历之孟统推算,得不出实际天象,甲戌算到十八去了,不得不用“月相四分”来曲解,硬说十八也可叫既望。\n我们用前面的推算方法验证这个历点:\n公元前827年(宣王元年)入四分历乙卯蔀57年。\n太初五十七年:前大余三十五小余三百二十八\n乙卯蔀蔀余5151+35=86(26庚寅)\n实际天象应是(827-427)×3.06=1224分=1284940日\n26.328+1.284=27.612(日加日,分加分)\n得知,宣王元年子月辛卯(27)日612分合朔\n子月辛卯612分丑月辛酉171分\n寅月庚寅670分卯月庚申229分\n辰月己已728分巳月己未287分\n是年子正,六月己未朔,既望十六,正是甲戌。\n出土文物多是西周时代的史料,这些历点,远在四分历创制之前五六百年。用四分术推算势必相差两日,加之孟统比殷历甲寅元又提早一日,所以,王氏的初吉不在初一,总是在初三或初四。这就“悟”出“月相四分”,加以曲解。\n注:郭沫若定师虎簋为共王器。共王元年为公元前951年。\n例二,“虢季子白盘”记:十二年正月初吉丁亥。\n孙诒让说:“此盘平定张石州孝廉以四分周术推,为周宣王十二年正月三日,副贡(刘寿曾)之弟以三统术推之,亦与张推四分术合。”\n用上面的推算方法,周宣王十二年(公元前816年)当入仲统之甲午蔀第49年,查表:大余五十一,小余七百四十七\n甲午蔀蔀余3030+51=81(21乙酉)\n得知周正子月大乙酉,丁亥初三。\n所谓四分周术,即是三统历之仲统。此张石州氏推算之结果。\n又,是年入孟统甲寅蔀第68年,大余三十一\n甲寅蔀蔀余5050+31=81(21乙酉)\n正月朔乙酉。此乃刘贵曾氏(副贡之弟)所推之结果。\n初吉果月初三乎?实际天象并非如此。\n用四分历近距推算,是年(前816年)入乙卯蔀(蔀余51)第68年。\n太初六十八年:大余三十一小余五百一十二\n51+31=82(22丙戌)\n先天(816-427)×3.06=1190分=1250940日\n22.512+1.250=23.762\n实际天象是正月丁亥762分合朔。\n结论很清楚:丁亥是朔日,是初一,不是初三。朔即初吉。\n金文中备记“王年、月、日、月相”者甚多,其中载有“初吉”字样的也不少,以实际天象考之,无一不是朔日,足证“月相四分”之不可信。\n"},{"id":156,"href":"/zh/docs/culture/%E5%8F%A4%E4%BB%A3%E5%A4%A9%E6%96%87%E5%8E%86%E6%B3%95%E8%AE%B2%E5%BA%A7/%E7%AC%AC4%E8%AE%B2-%E7%AC%AC5%E8%AE%B2/","title":"第4讲-第5讲","section":"古代天文历法讲座","content":" 第四讲二十四节气 # 古代劳动人民在认识自然、改造自然的过程中,创造了先进的耕作制度,形成了精耕细作的优良传统,推动了农业生产不断发展。在漫长的岁月中,对与农业生产紧密相关的农业气象条件,进行过精细的观察、深入的研究,逐步形成了二十四节气,概括了黄河中下游地区农业气候特征。它利用简要的两个字,把这一地区的日地关系、气候特点以及相应的农事活动恰当地表达出来。可以说,二十四节气是古代天文、气候和农业生产实践最成功的结合,从古到今都起着一种简明而又切合农业生产需要的农事历的作用。\n二十四节气一旦形成,劳动人民就因时、因地加以发展,它的应用就不仅仅局限于黄河中下游地区了,而是逐步推广到全国各地,几乎渗透到我们这个农业大国的各个领域,甚至涉及人们的衣食住行。所以,对依据古代天文而形成的这样一部农事历——二十四节气进行一番研究,就是很有必要的了。\n一、先民定时令 # 有了年、月、日的时间概念,并不等于就能得心应手地安排好时令。汉枚乘诗:“野人无历日,鸟啼知四时。”讲的是当时的“野人”,亦可想见先民的时令观念。《后汉书·乌桓鲜卑传》云“见鸟兽孳乳,以别四节”,道理亦同。《魏书》卷一百一讲到宕昌羌族“俗无文字,但候草木荣枯,记其岁时”。宋代洪皓《松漠纪闻》亦云:“女真……其民皆不知记年,问之则曰我见草青几度矣。盖以草青为一岁也。”据此推知,先民的时令,最早主要是靠物象——动植物的表象来确定的。\n《山海经》记载了先民观察太阳升落位置以定季节的材料。《大荒东经》上记有六座日出之山:\n东海之外,大荒之中,有山名曰大言,日月所出。\n大荒之中,有山名曰合虚,日月所出。\n大荒之中,有山名曰明星,日月所出。\n大荒之中,有山名曰鞠陵,于天东极离瞀,日月所出。\n大荒之中,有山名曰猗天苏门,日月所出。\n大荒之中,有山名曰壑明俊疾,日月所出。\n《大荒西经》上记有六座日入之山:\n西海之外,大荒之中,有方山者,上有青树,名曰柜格之松,日月所出入也。\n大荒之中,有山名曰丰沮玉门,日月所入。\n大荒之中,有龙山,日月所入。\n大荒之中,有山名曰日月山,天枢也。吴姖天门,日月所入。\n大荒之中,有山名曰鏖鏊钜,日月所入者。\n大荒之中,有山名曰常阳之山,日月所入。\n大荒之中,有山名曰大荒之山,日月所入。\n这是在不同季节、不同月份,观察到的太阳出山入山的不同位置。这种观察方法同观察鸟啼、鸟兽孳乳、草木荣枯的方法一样,是凭着经验,凭着目睹耳闻的感受,其粗疏是自不待言的。因为观察者的地域毕竟狭小,局限性很大,以此定季节势必误差很大。\n观察太阳运行的另一种方法是观察日影长度的变化。最早当是利用自然的影长,进一步发展就是人为的立竿测影。\n太阳视运动的轨迹无法在天空中标示,反映到地面上就是事物的投影。高山、土阜、树木、房舍,晴日白昼都会留下或长或短的影子。《吕氏春秋》“审堂下之阴,而知日月之行,阴阳之变”,就是这个意思。根据这些影子的长短可以判明时间的早晚,有经验的老人往往判断得十分精确,这无疑是依靠长期的经验积累。\n如果要有意测影以确定时令,这得人为地在平地上立一根规定长度的竿子,把它的影子在地面上标示出来。这根竿子就是“表”,《周髀算经》中称之为“髀”。“表”的影子,古字写作“景”。这就是土圭测景。\n从出土的甲骨文中考察殷商文化,可以明白地看到,殷商时代测定方向、时刻都已比较准确。卜辞中将一天的时刻分为:明(旦)、大采、大食、中日、昃、小食、小采、暮等时间段落。甲骨文中的“昃”字,就是人侧影的象形。作为时段,日侧之时为昃。发掘出的殷代宫殿基址是南北方向的,其方向所指与今天的指南针方向无异。这种方向的确定及中日、昃等时刻的测定,显然和观测日影紧密相关。\n这都说明,殷商时代已有了早期的圭表。实践证明,通过长期测日影的实践就会认识到冬至、夏至、春分、秋分。甲骨卜辞中,有一些文字很可能就是至日的记录。\n有了圭表,就能够比较准确地确定分、至,就可以对闰月的设置(闰在岁末)加以规律化的安排。所以,推知殷商之历应该比较规整,岁首应该比较固定,误差不会大于一个月。有人统计了记有月名的“今何月雨”“田”,其他农事季节及其他天文气象卜辞,证明了殷代月名和季节基本上已有了固定关系。《尧典》“期三百有六旬有六日,以闰月定四时成岁”的记载,大体符合这个时代的情况。\n二、土圭测景 # 日影的长短与寒暑变化有关,这是先民积累的生活常识。要准确地测量寒来暑往的季节变化,很自然地就产生了立竿测影的方法。这是用最简易的天文仪器来研究历法、确定时令,是天文学发展的一次飞跃。\n立竿测影又称土圭测景、圭表测景。表是直立的竿子,圭是平放在地上的玉版。《说文》云:“圭,瑞玉也。上圆下方。”日影长短就从平放的圭上显示出来。土,度也,测量的意思。土圭,就是度圭,测量圭上日影的长短以定时令。远在周代,“表”就规定为八尺,已有了长度标准。《周礼·考工记》云:“土圭尺有五寸,以致日,以土地。”致是推算义,土是量度义。土圭长一尺五,来推算节气日期,量度土地远近。《周礼·夏官司马》云:“土方氏,掌土圭之法以致日景。以土地相宅而建邦国都鄙。”注曰:土方氏,主四方邦国之土地。可见,周代已有人家来掌管土圭测景了。\n《周礼·地官大司徒》云:“日至之影,尺有五寸。”这是说,夏至时,圭上影子有一尺五寸长。这样看来,圭长一尺五寸就远远不够了。《周礼·春官冯相氏》郑玄注云:“冬至,日在牵牛,景丈三尺;夏至,日在东井,景尺五寸。此长短之极,极则气至。冬无潜阳,夏无伏阴。春分,日在娄;秋分,日在角;而月弦于牵牛东井,亦以其景知气至不。春秋冬夏气皆至,则是四时之叙正矣。”圭有多长?当在一丈三尺以上。\n根据《史记》记载,圭表测景当更早在传说中的黄帝时代。《史记·历书》“索隐”说:“黄帝使羲和占日,常仪占月,臾区占星气,伶纶造律品,大挠造甲子,隶首作算数,容成综此六术而著调历也。”不仅有专门测定日影的专家,并在测量日、月、星有关数据的基础上,利用甲子推算,创制时历。《尚书·尧典》“期三百有六旬有六日,以闰月定四时成岁”,可看作是远古时代测量日、月、星而后制历的发展。这就是以岁实366日为基本数据的我国有文字记载的最早的阴阳历。\n制历调历是一件神圣的工作,《尧典》说“允厘百工,庶绩咸熙”,起到一个信治百官、兴起众功的作用。正因为这样,圭表测景就不可能是民间百姓的事,只能在天子或君王旨意下由专职官员负责进行。周代的测景遗址——周公测景台还保留在今天河南登封告成镇(古称阳城)这个地方。\n阳城地处中原,物产丰富,文化发达。周公想迁都中原,视阳城为“地中”,居天下九州中心的意思。《周礼·地官大司徒》云:“以土圭之法测土深,正日景,以求地中。日南则景短,多暑。日北则景长,多寒。日东则景夕,多风。日西则景朝,多阴。日至之景,尺有五寸,谓之地中。天地之所和也,四时之所交也,风雨之所会也,阴阳之所合也。然则百物阜安,乃建王国焉。”如此详细地叙述求地中的方法,“地中”地理位置如此重要,占尽地理之便。这就是周公为迁都造下的舆论。实际上,所谓地中,是指当时国土南北的中心线而已。\n告成镇的周公测景台,有一个高耸的测量台,相当于一个坚固的“表”,平铺于地面的是“量天尺”,也就是一个放大了的石“圭”。现今遗留的测景台,元代初建,明代重修。重修的测景台是正南正北走向,高出圭面8.5米,下面的圭长30.3米。\n从周公在这里主持测景后,历代都在这里进行过测量,至今还有公元724年唐代所立的石“表”,上面刻有“周公测景台”五字。\n三、冬至点的测定 # 我国古代以冬至作为一个天文年度的起算点,冬至的时刻确定得准不准,关系着全年节气的预报。古代天文学家的一项重要任务就是测定准确的冬至时刻。测出两次冬至时刻,就能得到一年的时间长度。这样定出的年,就是回归年,古代称为“岁实”。《后汉书·律历志》说:“日发其端,周而为岁,然其景不复。四周,千四百六十一日而景复初,是则日行之终。以周除日,得三百六十五四分日之一,为岁之日数。”四分历的岁实36514日就是这样测出来的,这是利用冬至日正午日影长度四年之后变化一周这一实测得出的数据。这样的数据,四年之后误差积累才有0.0312日,即不到45分钟。这已是测得很精确的了。可以认为,过四年后,冬至日正午影长大体复回到最初的长度。\n下面介绍祖冲之测刘宋武帝(刘骏)大明五年(公元461年)十一月冬至时刻的方法。文载《宋书·历志》。\n十月十日影一丈七寸七分半\n十月十日影长10.7750尺\n十一月二十五日一丈八寸一分太\n十一月二十五日影长10.8175尺\n二十六日一丈七寸五分强\n二十六日影长10.7508尺\n折取其中,则中天冬至\n冬至应在十月十日与十一月二十五日之间\n应在十一月三日\n正中那一天,即十一月三日\n求其早晚\n求冬至时刻在早晚什么时候\n令后二日影相减,则一日差率也\n一日差率=10.8175-10.7508=0.0667\n倍之为法\n法=0.0667×2=0.1334\n前二日减,以百刻乘之为实\n实=(10.8175-10.7750)×100刻=4.25刻\n以法除实,得冬至加时,在夜半后三十一刻\n冬至时刻=实÷法=4.25÷0.1334=31.86\n因为十月十日和十一月二十五日正午之间的中点是在十一月三日的子夜,冬至时刻从子夜起算。又,古历计算中通常不进位,故31.86刻记为31刻。又,“太”即34;“强”即112。\n不难看出,在只有圭表测影的时代,祖冲之测定冬至时刻的方法确实是大大进步了。\n前已提到,冬至点是指冬至时太阳在恒星间的位置,现代天文学是以赤经、赤纬来表示,我国古代是以距离二十八宿距星的赤经差(称入宿度)来表示。四分历明确记载,冬至点在牵牛初度。冬至点这个数据如何测定,没有留下任何文字记录。《左传》上有两次“日南至”的记载:一是僖公五年“春王正月辛亥朔,日南至”;一是昭公二十年“春王二月己丑,日南至”。说明鲁僖公时代有过日南至的观测,可是没有留下如何观测的记录。唐代僧一行(张遂)在《大衍历议·日度议》提到,古代测定太阳位置的方法是测定昏旦时刻的中星,由此可以推算出夜半时刻中星的位置,在它相对的地方就是夜半时刻太阳的位置。这是间接推求冬至点的方法。《大衍历议》也提到,后来采用直接测量夜半时刻中星的办法。这就要求漏刻(计时工具)有比较稳定的精确度。利用太阳日行一度的规律,求出某日夜半时刻太阳在星空间的位置,就不难求得冬至时刻太阳所在位置,即冬至点的位置。\n冬至点在牵牛初度,这是四分历(殷历)的计算起点。战国时代所谓“颛顼历”取立春时太阳在营室五度为起算点,按中国古度推算,太阳冬至点的位置仍是牵牛初度。这也说明,颛顼历乃是殷历的改头换面,其天象依据全抄殷历。\n四、岁差 # 地球是一个椭圆体,又由于自转轴对黄道平面是倾斜的,地球的赤道部分受到日月等吸引而引起地轴绕黄极作缓慢的移动,大约26000年移动一周,这就是岁周。即是说,经过一年之后,冬至点并不回到原来的位置,而是在黄道上大约每年西移50.2秒,就是71年8个月差一度,依中国古度就是70.64年差一度,所以叫岁差。\n据推算,在公元前2800年右枢最近北极,几乎一致。传说的尧舜时代,右枢距天极约3~4度,仍可称为北极星。\n北极移动曲线图\n北极按箭头方向移动。众星位置是按公元1900年初的北极来表示。\n现在北极星即勾陈一(小熊座α星)离北极一度多,公元2102年最接近北极,那时北极距约27分37秒。《晋志》所谓天枢(鹿豹座∑1694星)是一颗五等星,在中唐时代是理想的北极星。右枢(天龙座α星)是一颗四等星,在公元前2800年最近北极,几乎和北极一致,传说中的尧舜时代,它的北极距约3—4度,仍可称为北极星。\n公元前1100年前后,周初时代,帝星距极六度半。天极附近又只有帝星明亮,便视为北极星。《周髀算经》所谓“北极中大星”,就是指此。如果画出这颗“北极中大星”绕天球北极的圆周运动,就叫做北极璇玑四游。因为距极六度半,可看出明显的旋转位移。这就是《吕氏春秋》所谓“极星与天俱游而天极不移”。到西汉末年,帝星距极八度三,汉人仍依旧说,帝星为北极中大星。\n中唐时代(公元766—835年),天枢是理想的北极星。\n现今,视勾陈一为北极星,它距北极一度多。到公元2102年,勾陈一最接近北天极,距极只有27分37秒。公元7500年,天钩五(仙王座α星)将成为北极星。公元13600年时,明亮的织女星将作为北极星出现在天穹。\n冬至点在黄道上的移动是缓慢的,短时期内不易测出。晋代以前,古人不知有岁差,天周与岁周不分。《吕氏春秋·有始览》“极星与天俱游而天极不移”,也只认为北天极是固定点,注意到了极星不在极点上,北极星与北天极是两码事。冬至点位移,汉代人从实测中是注意到的。汉武帝元封七年(公元前104年)改历,测得元封七年十一月甲子朔旦冬至“日月在建星”。《汉书·律历志》所载《三统历》也提到,经过一元之后,日月五星“进退于牵牛之前四度五分”。这是刘歆的认识。这无异于承认了冬至点已不在牵牛初度了。\n东汉贾逵明白地肯定了冬至点的位置变动。他说:“《石氏星经》曰:黄道规牵牛初值斗二十一度,去极百一十五度。于赤道,斗二十一度也。”这就是汉代石申学派通过实测改进冬至点的数据,明确了冬至赤道位置在斗二十一度。这相当于公元70年左右的天象。东汉四分历所定冬至点在斗二十一度四分之一,就是采用石申学派实测的数据。\n东晋成帝时代,虞喜根据《尧典》“日短星昴”的记载,对照当时冬至日昏中星在壁宿的天象,意识到一个回归年后,太阳没有在天上行一周天,而是“每岁渐差”。他第一次明确提出冬至点有缓慢移动,应该“天自为天,岁自为岁”。太阳从上一个冬至到下个冬至,并没有回到原来恒星间的位置,还不到一周天。于是他称这个现象为岁差,取每岁渐差之义。虞喜把“日短星昴”认定为他之前2700余年的尧的时代的记录,由此求得岁差积五十年差一度。\n虞喜之后,祖冲之首先在历法计算中引进了岁差,他实测得冬至点在斗十五度,得出45年11个月差一度。\n隋代刘焯在他的“皇极历”中,改岁差为75年差一度,比虞喜和祖冲之的推算更接近于实测值。唐宋时代,大都沿用刘焯的岁差数值。\n南宋杨忠辅“统天历”和元代郭守敬“授时历”,采用66年8个月差一度,就更为精密了。\n五、节气的产生 # 冬至点准确测定是产生二十四节气的基础。似乎把两冬至之间的时日二十四等分之,就可以得出二十四节气。事实上,先民认识节气,经历了一个漫长的过程。\n我国是农耕发达最早的国家之一,先民在长期的农业生产中,十分重视天时的作用。《韩非子》说:“非天时,虽十尧不能冬生一穗。”北魏贾思勰著《齐民要术》,提出“顺天时,量地利,则用力少而成功多,任情返道,劳而无获”。天,天时,对农业生产起着重要的作用。\n“天”是什么?天并非自然界和人类社会的最高主宰。荀子认为,“天”是自然界,而自然界的变化是有它的客观规律的,“不为尧存,不为桀亡”,它的变化是客观存在的。\n按现代的说法,“天”指的是宇宙和地球表面的大气层。大气层中出现的种种气象现象,阴晴冷暖,雨雪风霜,直接影响着农业生产。今年五谷丰收,我们说“老天爷帮了忙”;要是减产歉收,我们就说“老天不开眼”。从农业生产角度看,天指的是气象条件,说得确切些,指的是农业气象条件。天时的“时”,农业活动的“时”,不是简单地指时间历程,它要求能反映出农业气象条件,反映四季冷暖及阴晴雨雪的变化。\n二十四节气的节气,是表示一年四季天气变化与农业生产关系的。我国古代,节气简称气,这个“气”,实际是天气、气候的意思。\n从根本上说,二十四节气是由地球绕太阳公转的运动决定的。现代天文学把地球公转一周即一年分为四段,划周天为360度。自春分开始,夏至为90度,秋分为180度,冬至为270度,再至春分合成360度。每一段即每相距90度又分为六个小段。这样,一年便分为二十四个小段,每段的交接点就是二十四节气。西方至今还只有两分、两至,仅具有天文意义。可以说,二十四节气是中华民族几千年来特有的表达农业气象条件的一套完整的时令系统。\n二十四节气始于何时?一般认为,《尚书·尧典》中的仲春、仲夏、仲秋、仲冬就是指春分、夏至、秋分、冬至四气。果真这样,应该看成是二十四节气形成的初始阶段。《左传·昭公十七年》提到传说中的少昊氏设置历官:“凤鸟氏,历正也;玄鸟氏,司分者也;伯赵氏,司至者也;青鸟氏,司启者也;丹鸟氏,司闭者也。”一般都认为,分指春分、秋分,至指夏至、冬至,启指立春、立夏,闭指立秋、立冬。少昊氏时代,以鸟为图腾,物象与时令已密切相关。玄鸟即燕子,春分来秋分去,标志着春分、秋分的到来。伯赵,鸟名,一名,夏至鸣冬至止,标志着夏至、冬至的到来。青鸟、丹鸟均鸟名,分别标志着立春、立夏和立秋、立冬的到来。二分二至和四立,是二十四节气中最重要的八气,也是最先产生的八气。当然不必追溯到传说的少昊时代。\n两分两至虽然能定岁时,但分一年为四个时段,各长九十余天,各段的天气、气候有显著的差异,就远不能满足农业生产上每一环节所要求掌握的天时。所以,必须加以细分。《左传》中多次提到分、至、启、闭,可见四立也产生得很早。分、至加四立,恰好把一年分为八个基本相等的时段,从而把春、夏、秋、冬四季的时间范围确定了下来。这就基本上能够适应农业生产的需要。《吕氏春秋》十二纪中就只记载了这八个节气——立春、春分(日夜分)、立夏、夏至(日长至)、立秋、秋分(日夜分)、立冬、冬至(日短至)。看起来,分、至加四立,有一个较长的稳定时期。在此基础上发展,才形成二十四节气。\n西汉《淮南子》记载了完整的二十四节气,这可能是目前见到的完整二十四节气的最早文字记载。二十四节气的顺序也和现代的完全一致,并确定十五日为一节,以北斗星定节气。《淮南子》说:“日行一度,十五日为一节,以生二十四时之变。斗指子,则冬至……加十五日指癸,则小寒……”\n有人认为二十四节气最早见于《周髀算经》,而《周髀》成书于何时,历来的看法也不一样。李长年认为《周髀算经》是战国前期的书籍,钱宝琮认为《周髀》是公元前100年前后(汉武帝时代)的作品。李俨在《中算史论丛》第一集中认为二十四节气大约是战国前的成果。《逸周书》是从战国魏安釐王墓中发现的,其《时训解》中已有完整的二十四节气记载。不仅如此,每气还分三候,五日为一候,而且物象的描写又十分细致。怎么解释《逸周书·时训解》中细致的物象描写?《左传·僖公五年》载:“凡分、至、启、闭,必书云物,为备故也。”就是说,每逢两分、两至、四立时,必须把当时的天气和物象记录下来,作为准备各项农事活动的依据。详细地记录物象、气象,是先民长期形成的传统,是重视农业生产的必要手段。《吕氏春秋》除了记载二十四气中最重要的八气外,还记载了许多关于温度、降水变化以及由此影响的自然、物候现象。这也是先民记录物象、气象的优良习俗的文字遗迹,与《左传·僖公五年》所载是吻合的。但这并不能说明《吕氏春秋》这部书产生的时代二十四气尚未形成。\n《逸周书》虽有人疑为后人伪托,但战国时代二十四节气已全部形成还是可信的。我们以为,《汉书·次度》所记二十四节气,其顺次与《淮南子》所记汉代节气顺次小有差异,并定“冬至点在牵牛初度”,应看作是战国初期的记载。明确些说,二十四节气在战国之前已经形成。\n六、二十四节气的意义 # 在我国古代,二十四节气的日期是由圭表测景来决定的。《周髀算经》和《后汉书·律历志》等许多古书都记载着二十四节气的日影长短数值。这说明二十四节气实际上是太阳视运动的一种反映,与月亮运动没有丝毫的关系。二十四节气的每一节气都是表示地球在绕太阳运行的轨道上的一定的位置的。地球通过这些位置的时刻,就称交节气。它表明这个节气刚好在这个时刻通过,是在某月、某日、某时、某分交这个节气的。因此,从天文角度来理解节气的时间概念,它是指的瞬间时刻,而不是一个时段。从农业生产的实际出发,农事活动不限于一日,瞬时的气象条件也不能决定农作物的生长发育,它需要一段时间的气象条件作保证。因此,节气必须具有时间幅度,应理解为一段时间,而不是交节气那一天,更不是那一瞬间。\n二十四节气的每一节气都有它特定的意义。仅是节气的名称便点出了这段时间气象条件的变化以及它与农业生产的密切关系。现将每个节气的含义简述如下。\n夏至、冬至,表示炎热的夏天和寒冷的冬天快要到来。我国广大地区,最热的月份是7月,夏至是6月22日,表示最热的夏天快要到了,我国各地最冷的月份是1月,冬至是12月23日,表示最冷的冬天快要到来,所以称作夏至、冬至。夏至日白昼最长,冬至日白昼最短,古代又分别称之为日长至(日北至)和日短至(日南至)。\n春分、秋分,表示昼夜平分。这两天正是昼夜相等,平分了一天,古时统称为日夜分。这两个节气又正处在立春与立夏、立秋与立冬的中间,把春季与秋季各分为两半。\n立春、立夏、立秋、立冬,我国古代天文学上把四立作为四季的开始,自立春到立夏为春季,自立夏到立秋为夏季,自立秋到立冬为秋季,自立冬到立春为冬季。立是开始的意思,因此,这四个节气是指春、夏、秋、冬四季的开始。\n二分、二至、四立,来自天文,但它们中的春、夏、秋、冬四字都具有农业意义,即春种、夏长、秋收、冬藏。春、夏、秋、冬四个字概括了农业生产与气象关系的全过程,反映了一年里的农业气候规律。\n雨水,表示少雨雪的冬季已过,降雨开始,雨量开始逐渐增加了。\n惊蛰,蛰是藏,生物钻到土里冬眠过冬叫入蛰。回春后出土活动,古时认为是被雷震醒的,所以称惊蛰。惊蛰时节,地温渐高,土壤解冻,正是春耕开始时。\n清明,天气晴和,草木现青,处处清洁明净。\n谷雨,降雨明显增加。越冬作物返青拔节,春播作物生根出苗,都需雨水润溉。取雨生百谷意。\n小满,麦类等夏熟作物籽粒开始饱满,但未成熟。\n芒种,小麦、大麦等有芒作物种子已成熟,可以收割。又正是夏播作物播种季节。芒种又称“忙种”,指节气的农事繁忙。\n小暑、大暑,开始炎热称小暑,最热时候称大暑。\n处暑,处是终止、躲藏之意。表示炎夏将去。\n白露,处暑后气温降低快,夜间温度已达成露条件,露水凝结得较多、较重,呈现白露。\n寒露,气温更低,露水更多,有时成冻露,故称寒露。\n霜降,气候已渐寒冷,开始出现白霜。\n小雪、大雪,入冬后开始下雪,称小雪。大雪时,地面可积雪。\n小寒、大寒,一年中最冷的季节。开始寒冷称小寒,最冷时节称大寒。相对小暑、大暑,间隔正半年。\n为便于记忆,民间流行着一首歌诀:\n七、节气的分类 # 从上节二十四节气的含义可以看出,节气可概括分为三类:\n第一类是反映季节的。二分、二至和四立是用来表明季节,划分一年为四季的。二至二分是太阳高度变化的转折点,是从天文角度上来划分的,适用我国全部地区。四立划分四季,有很强的地区性。\n第二类是反映气候特征的。直接反映热量状况的有小暑、大暑、处暑、小寒、大寒五个节气,它们用来表示不同时期寒暑程度以及暑热将去等都很确切。直接反映降水现象的有雨水、谷雨、小雪、大雪四个节气,表明降雨、降雪的时间和其强度。还有三个节气白露、寒露、霜降,讲水汽凝结成露成霜,有水分意义;也反映温度下降过程和气温下降的程度,有热量意义。\n第三类是反映动植物表象的。小满、芒种反映作物成熟和收种情况;惊蛰、清明反映自然现象,都有它们的农业气象意义。\n二十四节气中,直接谈到温度变化的有五个节气。小暑、大暑在7月上旬到8月上旬,说明天气最热。小寒、大寒在1月初到2月初,说明天气最冷。从黄河中下游各地的气候来看是完全符合的。洛阳、郑州、开封、济南等地最冷时段在1月中旬,最热时段在7月下旬。\n从天文角度看,夏至日视太阳最高,冬至日视太阳最低,我国的最热时期不在夏至前后,最冷时期不在冬至前后,这是为什么呢?夏至前后,虽然视太阳最高,辐射最强,地面吸热最多,但地面没有达到积累和保持热量最多之时。夏至以后,地面吸热减少,温度继续升高,直到地面吸收的热量等于它所放出的热量之时,地面温度才不再升高。这便是最热的季节,相当于小暑、大暑节气。过后,地面放出的热量多于地面吸收的热量时,气温开始降低。用类似的道理可以解释小寒、大寒最冷,而不是冬至前后最冷。\n处暑表示炎夏即将过去。从黄河中下游地区立秋以后气温下降趋势可以看出,处暑以前气温下降并不明显,处暑以后气温却急剧下降,是天气转凉的象征,正合处暑的含义。\n前面说过,白露、寒露、霜降既表示水汽凝结现象,也表明温度的下降幅度。黄河中下游地区的初霜期,平均在10月下旬到11月初,与霜降节气的时段完全符合。白露、寒露、霜降如实地反映了黄河中下游地区出露、初霜期的时段。\n二十四节气中有关降水的有四个节气。雨水包含开始下雨和雨量开始增多两个含义。从黄河中下游地区降雨日期和降雨量统计看,雨水节气反映了雨量开始增多的含义。\n谷雨表示降雨有明显增加。黄河中下游地区降水量的变化情况可以证实,谷雨时段的降水量,不仅明显地多于谷雨前的清明时段,也多于其后的立夏节气。所谓“春雨贵如油”反映出谷雨节气雨水对农作物的播种和出苗的重要作用。\n小雪表示已开始降雪。西安等地平均初雪日期在11月下旬。小雪在11月22日,两者相符。\n大雪表示从此雪将大起来。雪大,可以积雪日期和降雪天数较多为尺度来衡量。统计资料告诉我们,西安等地平均积雪初日多在12月上旬至中旬初,12月份以后积雪日数明显增多,各地12月份积雪日数均比11月份高出一倍以上,济南且高出四倍多。这说明大雪节气也反映了黄河中下游地区这段时期的“大雪”气候特征。从统计资料知道,大雪期间降水量并不增加而是逐渐减少。这又看出,大雪并不包含降雪量最大的意思。\n二十四节气中,属于物象的节气有四个。惊蛰在3月6日,取雷鸣开始和地下冬眠的生物开始出土活动,两者有因果关系。黄河中下游地区各地雷暴初日很不规律,或早于惊蛰,或迟于惊蛰,变动范围很大。洛阳的雷暴初日,1951—1970年统计,有早在2月10日的,有晚在5月27日的。这说明惊蛰的意义并非雷始鸣而引起地下冬眠的生物出土活动。冬眠生物复苏的原因不是雷鸣,而主要取决于适宜的温度条件。如果把惊蛰理解为因地温升高蛰伏地下的生物开始出土活动是比较符合实际的。\n清明是4月5日。西安、洛阳等地区这段时期的平均温度为13°C~14℃,年际变化在11℃~18℃之间。这正是初春的气温,气候宜人,草木繁茂,处处明朗清新,春光明媚。\n小满指麦类作物籽粒开始饱满,约相当于乳熟后期。芒种指麦类等有芒作物收获和谷子、黍、稷等作物播种之时。小满指作物行将成熟,芒种指一收一种。据近年物象资料显示,西安一带小麦乳熟后期约为5月中下旬,河南、山东沿黄河一带也大致如此。与小满节气所处时段非常接近。黄河中下游各地小麦都在6月上旬先后成熟,芒种反映了小麦的收获季节。\n总起来看,二十四节气是反映了黄河流域中下游地区的气候特征和农业生产特点的,并将各个时期的农业气象特征概括为简要的名称。仅仅两个字,内容却十分丰富,不仅对古代农业的发展起了很大的作用,就是在今天,仍有现实意义,全国各地都在灵活地运用二十四节来安排农业生产。\n八、节气的应用 # 二十四节气直接反映黄河中下游地区的农业气象特征,对于指导农事活动具有重要的作用。这些地区的劳动人民将节气与几种主要作物的种、收时间联系起来编成谚语,代代相传,节气就直接应用于农业生产了。\n就播种期说,种麦的谚语有:\n寒露到霜降,种麦日夜忙。\n秋分早、霜降迟,只有寒露正当时。\n立冬不交股(分蘖),不如土里捂。\n种高粱、谷子的谚语有:\n清明高粱谷雨谷,立夏芝麻小满黍。\n清明后,谷雨前,高粱苗儿要露尖。\n种棉花的谚语有:\n清明早,小满迟,谷雨种花正当时。\n清明玉米谷雨花,谷子播种到立夏。\n谷雨前,好种棉。\n比较四川、华中地区的谚语“清明前,好种棉”,江浙一带的谚语“要穿棉,棉花种在立夏前”,显出时令的不同。\n就黄河中下游地区收获季节的谚语,也看出节气与农事的联系。\n麦到谷雨谷到秋(立秋),过了霜降刨甘薯。\n麦到谷雨谷到秋,过了天社(秋社)用镰钩(割豆子)。\n谷雨麦怀胎,立夏麦胚黄,芒种见麦茬。\n白露不秀,寒露不收(谷子)。\n处暑见三新(指高粱、小米、棉花开始成熟)。\n处暑见新花。\n芒种不出头(棉花),不如拔了饲老牛。\n二十四节气是古代黄河中下游劳动人民长期进行农业活动的经验总结,随着中华民族经济、文化的发展,二十四节气也在全国各地得到广泛的运用。各地区的劳动人民都是因地、因时灵活地应用二十四节气以指导农业生产,节气在各地又有新的内容。如,各地冬小麦播种的适宜节气,用谚语反映出来就是:\n北疆:“立秋早,寒露迟,白露麦子正当时。”\n南疆:“秋分麦子正当时。”\n甘肃陇南山区:“白露早,寒露迟,秋分种麦正当时。”\n北京地区:“秋分种麦,前十天不早,后十天不晚。”\n河南、山东一带:“骑寒露种麦,十种九得。”\n华中地区:“寒露、霜降种麦正当时。”\n长江中下游地区:“霜降种麦正当时。”\n浙江:“立冬种麦正当时。”“大麦不过年,小麦立冬前。”\n同一个节气,反映在不同地区的动植物表象又是千差万别的。比如清明:\n华北、华中:“清明断雪,谷雨断霜。”\n东北、西北、内蒙古:“清明断雪不断雪,谷雨断霜不断霜。”指当断雪而此地不断,当断霜而此地不断。\n黄河中下游地区:“柳近清明翠缕长,多情右衮不相忘。”\n江南:“清明时节雨纷纷,路上行人欲断魂。”\n岭南:“梅熟迎时雨,苍茫值小春。”\n河西走廊:“绝域阳关道,胡沙与塞尘。三春时有雁,万里少行人。”\n青藏高原、东北北部:“天山雪后渔风寒,横笛偏吹行路难。”\n正因为同一节气各地的气象与物象的差别如此鲜明,各地劳动人民在应用节气指导农业生产时,自然得灵活地因地制宜,才能发挥节气的真正作用。\n这里介绍一首节气歌,也是反映节气与物象、气象关系的。\n立春阳气转,雨水沿河边。\n惊蛰乌鸦叫,春分地皮干。\n清明忙种麦,谷雨种大田。\n立夏鹅毛住,小满雀来全。\n芒种开了铲,夏至不纳棉。\n小暑不算热,大暑三伏天。\n立秋忙打靛,处暑动刀镰。\n白露烟上架,秋分无生田。\n寒露不算冷,霜降变了天。\n立冬交十月,小雪地封严。\n大雪江封冻,冬至冰雪寒。\n小寒过去了,大寒要过年。\n还有人将二十四节气编入诗中,并在每句嵌入一出戏文名,组成二十四节气名诗。这是清末苏州弹词艺人马如飞的创造。\n西园梅放立春先,云镇霄光雨水连。\n惊蛰初交河跃鲤,春分蝴蝶梦花间。\n清明时放风筝误,谷雨西厢好养蚕。\n牡丹亭立夏花零落,玉簪小满布庭前。\n隔溪芒种渔家乐,义侠同耘夏至田。\n小暑白罗衫着体,望河亭大暑对风眠。\n立秋向日葵花放,处暑西楼听晚蝉。\n翡翠园中零白露,秋分折桂月华天。\n烂枯山寒露惊鸿雁,霜降芦花红蓼滩。\n立冬畅饮麒麟阁,绣襦小雪咏诗篇。\n幽闺大雪红炉暖,冬至琵琶懒去弹。\n小寒高卧邯郸梦,一捧雪飘空交大寒。\n九、杂节气 # 古代劳动人民在生活与生产活动中,常用一些简要的词语表示冷、暖、干、湿等气象现象,如三伏、九九之类。它们在一定程度上补充了节气的不足,有人称之为杂节气。“热在中伏”,“冷在三九”,杂节气在人们的生产与生活中有着一定的意义。\n三伏伏的本义是指隐伏,躲避盛暑之义。以后就指一年里最热的日子。一年中最热的日子分为三个时段,即头伏、二伏、三伏。从夏至后第三个庚日算起,第一个顺序十天,叫做头伏或初伏;第二个顺序十天,叫中伏或二伏;立秋后第一个庚日算起,往后顺序十天叫末伏或三伏。\n所谓庚日,指干支纪日逢庚的日子而言。六十甲子,每隔十天就有一个庚日,一个甲子周期有六个庚日,夏至后第三个庚日是公历哪一天呢?阳历一年365天,闰年还要多一天,都不是十的整倍数。因此,今年某一天是庚日,下一年同一天就不可能还是庚日。\n九九指一年中较冷到最冷又回暖的那些日子。把这些日子按九天分为一段,共分九段,顺次称为一九、二九、三九……到八九、九九,共计八十一天,即所谓数九寒天。它是从冬至这天作为一九开始,即从12月22日或23日开始,依日序九天一段,直到惊蛰前两三天而为九九。\n怎样衡量每个九日的寒冷程度呢?黄河中下游地区民间流传着一首九九歌:“一九二九不出手(天气冷了),三九四九河上走(河水结冰),五九六九沿河看柳(柳树发芽),七九河开(江河解冻),八九雁来,九九耕牛遍地走。”歌谣中把整个寒冬的全过程的变化顺次写出来,其中“不出手”“河上走”“沿河看柳”“河开”“雁来”等,实际上是候应。到九九,“耕牛遍地走”,春耕繁忙起来,说明九九歌的目的是为了掌握农时。\n由于各地气候条件的差异,江南地区的九九歌的内容又有不同:“一九二九相见弗出手;三九二十七,篱头吹筚篥(寒风吹得篱笆啪啪响);四九三十六,夜晚如鹭宿(寒夜,人像白鹭蜷曲身体入睡);五九四十五,太阳开门户;六九五十四,贫儿争意气;七九六十三,布衲担头担;八九七十二,猫儿寻阳地;九九八十一,犁耙一齐出。”\n冬有九九,夏亦有九九。宋代周遵道《豹隐纪谈》载有夏至后九九歌:“一九二九,扇子不离手;三九二十七,吃茶如蜜汁;四九三十六,争向路头宿;五九四十五,树头秋叶舞;六九五十四,乘凉不出寺;七九六十三,夜眠寻被单;八九七十二,被单添夹被;九九八十一,家家打炭墼。”这首歌确切地反映了夏至后天气逐渐变热,再转凉变寒的气温变化过程,反映了从夏至后起经小暑、大暑、立秋、处暑到白露这一过程的气候特征对人们生活的影响。\n霉江淮流域一带,一般每年6月上旬以后出现一段阴沉多雨、温高、湿大的天气。这段时期,器物容易发霉,人们称这种天气为霉雨,简称霉。这段时期又是江南梅子成熟的时候,所以又称为梅雨或黄梅雨。两者含义相同,气象学上称为梅雨,但历书上多称霉雨。把霉雨开始之日叫入霉(梅),结束之日叫出霉(梅)。历书上入霉、出霉日期是这样得出来的:《月令广义》(冯应京纂辑)提出“芒种后逢丙入梅,小暑后逢未出梅”,即芒种后第一个丙日称入霉,小暑后第一个未日称出霉。所以入霉总在6月6日到6月15日之间(天干十数),出霉总是在7月8日到19日之间(地支十二数)。\n社日立春后五戊为社。最初系指立春后第五个戊日叫社日,以后立秋后第五个戊日也叫社日,分别称为春社与秋社。春社敬祀土神以祈祷农业丰收,秋社敬祀土神以酬谢农业获得丰收。\n寒食冬至后一百零五日称寒食,刚好是清明日的前一天,所以寒食与清明往往并用,作为节气名称之一。有诗云:“一百五日寒食雨,二十四番花信风。”\n广为流传的《幼学琼林》在叙述杂节气时写道:“二月朔为中和节,三月三为上巳辰。冬至百六是清明,立春五戊为春社。寒食节是清明前一日,初伏日是夏至第三庚。四月乃是麦秋,端午却为蒲节。六月六日节名天贶,五月五日节号天中。”\n十、七十二候 # 上一讲讲到观象授时,观象授时的“观象”,主要是观天象,还要观气象、物象。天象,即日月星辰的运行;物象,即动植物顺应节气而有一定的表象,如“鸿雁来”“桃始华”之类;气象,指风雨雷电、“凉风至”“雷发声”之类。应该说,最早的观察还是从气象、物象开始的,因为气象、物象与先民的生产、生活有切身的利害关系,比起天象来得更直接,显得更具体实在。古代记载观象授时的文字,比如《尧典》《夏小正》《月令》,虽有天象记载,而大量的文字还是关于气象、物象的记录。流传至今的很多农谚,就是观察气象与物象的经验总结。汉代崔寔《四民月令》就是汉代以前关于气象、物象资料的总结。元代末年娄元礼编撰《田家五行》记载了农谚140多条,不少是天象结合气象、物象的内容。如:月晕主风,日晕主雨。一个星,保夜晴。星光闪烁不定,主有风。夏夜见星密,主热。东风急备蓑笠,风急云起,愈急必雨。鸦浴风,鹊浴雨,八哥儿洗浴断风雨。獭窟近水,主旱;登岸,主水。\n上古时代,先民将全年每月的天象、物象、气象,择要记录下来,以此指导农事活动,这在当时无疑具有重要意义,所以古代典籍都非常郑重地加以记载。\n宋代王应麟《玉海》中记载了用鸟兽草木的变动来验证月令的变易,并说:“五日一候,三候一气,故一岁有二十四节气。”这样,一月六候,一岁七十二候,将气象、物象与月、岁的配合规律化,整齐划一。这是把古来的零散、杂乱记载加以集成、整理的结果。\n在研究二十四节气时,有必要讨论一下七十二候。候是气候义。每候有一个相应的物候现象,叫做候应。物候自然包括气象、物象两个内容。七十二候可说是我国古代的物候历。\n最早的物候记载,见于《诗经·七月》,其中“四月秀葽,五月鸣蜩”“五月斯螽动股,六月莎鸡振羽”“八月其获,十月陨箨”等都确切地反映了物候现象与季节、农事活动的密切关系,为后世编制农事历创造了良好的范例。较多的候应记载,见于《大戴礼记》中之《夏小正》及《礼记》中之《月令》。\n《夏小正》很少提到节气,只有启蛰、日冬至可以认为是惊蛰、冬至两节气,候应虽较完整,但不十分系统,各月多少也不一致。如正月所列候应有雁北乡、雉震雊、启蛰、鱼涉负冰、囿有见韭、时有俊风、寒日涤冻涂、田鼠出、獭献鱼、鹰则为鸠、农及雪泽、采芸、柳稊、梅杏杫、桃则华等十五项;而十月则仅有豺祭兽、黑鸟浴、玄雉入于淮为蜃三项。这该怎么解释?\n现代学者有《夏小正》与彝族十月历吻合的见解。他们认为,从《夏小正》中的物候记录来看,基本上符合十月历而与十二月历不合。《夏小正》正月的物候与农历大致相同,但以后便逐渐增大差距。他们认为,《夏小正》的物候记录原本是按十个月排列的,其最后两个月是整理者主观加上去的,无星象文字,物候记录则是从十月中分出的。\n完整的七十二候,最早见于《吕氏春秋》十二纪中,除七十二候外,还记有十余候。可以认为,《吕氏春秋》十二纪取材于《月令》,上溯至《夏小正》,是物候历系统,而并不理会二十四节气。我们以为,《逸周书》反映出,还有一个二十四节气的节气历系统,两者并行不悖。汉代《淮南子》宗法《逸周书》,将七十二候与二十四节气两个系统配合起来,合二为一,成为一个完整的农事历体系。\n《吕氏春秋》十二纪中以每月至少六候编入各月。有的物候现象与节气大体一致。孟春纪中有蛰虫始振;仲春纪中有始雨水;仲夏纪中有小暑至;孟秋纪中有白露降;季秋纪中有霜始降。相应的节气是惊蛰、雨水、小暑、白露、霜降。有的成为七十二候中的候应,如东风解冻、蛰虫始振、鱼上冰、獭祭鱼等。还有的物候文字如天气下降、地气上腾、天地和同与木槿荣、芸始生等就没有编入七十二候中。\n汉代以后,很多农书以二十四节气、七十二候为中心内容作些修改补充,制定出各种农事历、农家历、田家历、田家月令、每月栽种书、每月纪事、逐月事宜等一类的农家历书。各代通行的历书,也将二十四节气和七十二候以及相应的农事活动编了进去。\n七十二候的候应中有生物物候和非生物物候。生物物候中有植物的与动物的。有栽培或饲养的,也有野生的,野生植物八项,栽培植物五项;野生动物最多,有三十八项,饲养的最少,只有一项。非生物物候二十项,其中反自映然现象的七项,反映气象现象的十三项。除野生动物外,以气象为最多。这些候应多确切地反映了天气、气候的变化,包含的面很广泛,且是人们日常生活中最易感知的。比如燕子(玄鸟)春去秋来,鸿雁冬来夏往,反映时令十分准确,历来把它们称为候鸟;而蝉(即蜩)、蚯蚓、蛙(即蝼蝈)等顺季节而隐现也很明显,历来把它们称为候虫。它们的来去、隐现所反映出的时令实际包括了温度、光照气象条件的综合。有些植物如桃、桐、菊、苦菜等开花以及草木的荣枯还反映了过去一定时期内的积温,反映了对水分、光照等条件的要求,反映了当时气象条件的综合。应当说,这些物候现象是气象要素综合影响的结果。所以,七十二候候应所反映的农业气象条件,有它明显的特点:具体简单,用于指导农事活动也来得准确、直接。物候所以起源很早,而且一直沿用至今,原因就在这里。\n如果从物候学观点来看待七十二候,很显然,有些物候现象是不科学的,如:腐草化为萤、雀入大水为蛤、雉入大水为鹰等都是没有的事;虎始交、鹿角解、麋角解等是很难甚至不可能见到。有些候应的意义较为晦涩,如天地始肃、地气上腾、天气下降、闭塞成冬等是无法观测的。有些候应名称不通用,难以准确理解,如仓庚(指莺的一种)、戴胜(一种鸟,状似鹊)、荔(一种草,似蒲而小)、等等。此外,七十二候受了二十四节气的约束,每一个节气非三候不可,五天有一个变化,反而无法充分发挥物候应有的作用。现代的一般历书删去了七十二候,道理就在这里。\n《月令总图》(见本书91页)外圈所列即七十二候顺次配合十二个月。\n十一、四季的划分 # 一年四季,春夏秋冬,按照传统的观念,阴历正、二、三月为春季,四、五、六月为夏季,七、八、九月为秋季,十、冬、腊月为冬季。“一年之计在于春”,春节当然是阴历正月初一了。然而,阴历以月亮盈亏来计算月份,就不能准确地反映季节的变迁。今年正月初一到下年正月初一,可能是354天(平年),也可能是384天(闰年),日数差到30日,所以按朔望月划分季节是不可取的。\n我国古代典籍中多以四立作为四季的开端,每一个节气还有相应的候应作为季节的标志,这种划分标准反映了黄河流域四季分明的气候特点。\n立春。立春第一候候应是东风解冻,作为春季开始的标志。从黄河中下游各地土壤开始解冻日期来看,这一带10厘米深土层开始解冻的平均日期约从1月底到2月上旬,如西安平均为2月2日,开封为1月24日,济南为2月9日,与古代立春节气第一候候应基本一致。再从日最高气温等于或小于0℃的终止日期看,也能说明立春的气候意义。黄河中下游地区各地日最高气温小于或等于0℃终日约为2月11日到2月21日之间。可见,这一地区立春节气白天温度开始上升到0℃以上,土壤解冻,春天即将到来。白居易诗:“野火烧不尽,春风吹又生。”春风或东风,指较暖湿的偏南和偏东风。它们吹来时,野草开始萌动,象征春天将到,土壤开始解冻。从西安地区看,2月份“东风”显著增加。结合土壤解冻日期,“东风解冻”反映了黄河中下游地区2月上旬(立春)的气候特点。\n立冬。立冬第一候候应“水始冰”,作为冬季开始的标志。据《中国气候图简编》看,黄河中下游地区平均开始结冰日期大致为11月1日、11日、21日三条等日期线所通过。此外,黄河中下游地区最低气温等于或小于0℃的开始日期大致在11月1日到11日之间,济南为11月11日,可见立冬开始是与“水始冰”基本一致的。\n立秋。立秋第一候候应为“凉风至”。夏秋之季,北风刮来,给人带来凉意。“凉风至”如可解释为最多风向是偏北或偏北风频率迅速增多,偏南风频率迅速减少,那么黄河中下游地区8月份风向转变情况是与立秋的“凉风至”相一致的。\n立夏。立夏第一候候应为蝼蝈鸣,而目前黄河中下游一带青蛙始鸣日期与立夏第一候蝼蝈鸣是有较大差别的。西安3月上旬,洛阳3月下旬初,德州4月初,安阳4月下旬初,而立夏在5月初。\n如果以四立划分四季,立春就是春季的开始,此时正是阳光从最南的位置(冬至)到适中的位置(春分)的过渡阶段,即是冬季到春季的过渡阶段。真是这样划分四季,那还是不符合天气变化的实际。立春日正是“五九”将尽而“六九”开始之际,天气还相当寒冷。我国北方的立春日,可冷到-20℃左右,因为冬至日太阳在最南的位置,大地丧失热量入不敷出的状况尚未达到顶点,要等一两月后北半球热量丧失过多而气温降到最低,那正是立春日前后。因此,冬季往往到立春前后才最冷,把最冷的立春作为春季的开始,显然是不恰当的。\n天文学上是以春分、夏至、秋分、冬至作为春、夏、秋、冬四季的开始。两分两至是根据视太阳在黄道上运行的位置而制定出来的,因此它不但适用于黄河流域,而且对全国来说都是适用的。这样的四季划分确实反映了自然界的变化,如树木发芽,雷雨出现,草木枯黄,首次见霜等现象,这与以气温变化来决定季节也是大体吻合的。从春分以后,太阳的位置愈来愈高,大地接受到愈来愈多的热量,确实开始了一个温暖的季节。\n现在通用的是从气候学上划分四季,标准是以候平均气温低于10°C为冬季,高于22°C为夏季,界于10°C和22°C之间分别为春季、秋季。按这样的标准,各地四季的长短就大不相同。昆明可以是“四季如春”,青藏高原和东北北部的冬季就十分漫长。\n如果按节气来划分四季,不管是我国古代以四立为标准分出春夏秋冬,还是通行于世的二分、二至划分四季,春夏秋冬四季的时间间隔都完全相等。按气温来划分,我国广大地区是春秋短而冬夏长,这是我国季风气候的一个显著特征。\n十二、平气与定气 # 二十四节气的计算方法,最初是把一个回归年长度均匀地分为二十四等分。四分历的回归年长度为36514日,每一节气的时间长度是36514÷24=15732日。从立春时刻开始,每过15732日就交一个新的节气,这就是平气。清代以前,历法都用平气划分二十四节气。\n太阳周年视运动实际是不等速的。《隋书·天文志》载,北齐天文学家张子信已经发现“日行在春分后则迟,秋分后则速”。\n隋代刘焯在《皇极历》中提出以太阳黄道位置来分节气。他把黄道一周天从冬至开始,均匀地分成二十四份,太阳每走到一个分点就是交一个节气,这叫定气,取每个节气太阳所在位置固定的意思。两个节气之间太阳所走的距离是一定的,而所用的时间长度都不相等。冬至前后太阳移动快,只要十四日多就从一个分点走到下一个分点。夏至太阳移动慢,将近十六日才走到下一个分点。刘焯的定气在民用历本上一直没有采用。\n唐代僧一行《大衍历议·日缠盈缩略例》中批评了刘焯对于太阳运动规律的错误认识。他指出:“焯术于春分前一日最急,后一日最舒;秋分前一日最舒,后一日最急。舒急同于二至,而中间一日平行,其说非是。”他指出的规律是接近实际的:“日南至,日行最急。急而渐损,至春分,及中,而后迟。至日北至,其行最舒。而渐益之,以至秋分,又及中,而后益急。”(见《新唐书·历志》)\n为计算任意时刻的太阳位置,一行发明了不等间距的二次差内插公式,在实际计算中,元代“授时历”已经考虑到三次差。\n不过,清代“时宪历”才用定气注历本。第五讲四分历的编制\n第五讲四分历的编制 # 在有规律地调配年、月、日的历法产生以前,都还是观象授时的阶段。观象,主要是观测星象,是以二十八宿为基准,记述时令的昏旦中星,这是采用二十八宿体系的授时系统。\n由于二十八宿之间跨度广狭相当悬殊,势必影响所确定的时令的准确度。随着农业的精耕细作,对时令的准确性要求越来越高,观星定时令也就发展为以二十四气定时令,这是采用二十四气体系的授时系统。\n二十八宿体系是依据具体的星象以朔望月为基础加置闰月的办法调整年月日的阴阳历系统,二十四气体系是依据太阳周年视运动划分周天为二十四等分,形成纯粹的太阳历系统。到二十四气的产生,记述时令的办法就由观测具体的星象进入了一个可运算的抽象化的时代。二十四气的诞生,是观象授时走向更普遍、更概括,经过抽象化而上升为理论的阶段。到了这时,观象授时才算完成了自己的任务为二十四气所取代了。从此,在我国古代天文学史上,就同时并存有两套不同的授时系统。\n伴随着二十四气而来的,就是古代四分历的出现。\n一、产生四分历的条件 # 所谓“四分历”,是以36514日为回归年长度调整年、月、日周期的历法。冬至起于牵牛初度,则14日记在斗宿末,为斗分,是回归年长度的小数,正好把一日四分,所以古称“四分历”。\n四分历是我国第一部有规律地调配年、月、日的科学历法,它要求有实测的回归年长度36514日,要求有比较准确的朔望月周期。由于是阴阳合历的性质,就必须掌握十九年七闰的规律。只有满足了这些条件,以36514日为回归年长度的四分历的年、月、日推演才有可能进行,四分历才有可能产生。\n关于回归年长度的测量。圭表测景之法在商周时代就已经有了。《尧典》所载“期三百有六旬有六日”的文字,应看作商末或更早的实测。回归年长度定为366日,是不可能产生历法的。古代典籍中,关于冬至日的最早记载,在《左传》中有两次。一次在僖公五年(公元前655年):“春王正月辛亥朔,日南至。”一次在昭公二十年(公元前522年):“春王二月己丑,日南至。”只要不能证实这是古人的凭空编造,就应该承认,在鲁僖公时代,是有过日南至(冬至)的观测的。冬至日期的确定,古代是利用土圭对每天中午表影长度变化的观测得来的。只要长期使用圭表测影来定冬至(或夏至)日期,就可以得到较为准确的回归年长度——36514日。据《后汉书·律历志》载:“日发其端,周而为岁,然其景不复。四周,千四百六十一日而景复初,是则日行之终。以周除日,得三百六十五四分日之一,为岁之日数。”四分历的回归年长度就是这样观测出来的。从《后汉书》的记载看出,利用圭表测影,不难得到四分历所要求的回归年长度:36514日。\n关于朔望月周期。月相在天,容易观测。从一个满月到下一个满月,就得到一个朔望月的长度。如果经常观测,就会知道一个朔望月的长度比29天半稍长。按照朔望月来安排历日,必然是小月和大月相间,而到一定时间之后,还得安插一个连大月。只有掌握了比较准确的朔望月周期,连大月的设置才会显现出它的规律。从文献上考查,《春秋》所记月朔干支告诉我们,春秋中期以前,连大月的安插并无明显的规律性。在鲁襄公二十一年(公元前552年)的九、十两个连大月以后,除襄公二十四年八、九两个月连大外,其余所有连大月的安插都显示了15个月~17个月有一个连大月的间隔规律。这表明,春秋中期以后,四分历所要求的朔望月长度已为司历者所掌握。\n又,据统计,《春秋》37次日食记载中,宣公以前有15次,记明是朔日的只有6次。鲁成公(公元前590年—公元前573年)以后有22次,记明朔日的竟达21次。由此可见,春秋中期以后,朔日的推算已相当准确。这说明,不仅掌握了比较准确的朔望月长度,日月合朔的时刻也定得比较准确。\n关于十九年七闰的规律。《春秋》所记近三百年(前772年—前479年)史料中,有700多个月名,394个干支日名,37个日食记录。后人据此研究,排定春秋时代的全部历谱。晋杜预有《经传长历》,清王韬有《春秋历学三种》,邹伯奇有《春秋经传日月考》,张冕有《春秋至朔通考》,日人新城新藏有《春秋长历》,张汝舟先生编有《春秋经朔谱》,都是研究春秋史的很好工具。从这些历谱可以看出,鲁文公(前626年—前609年)、宣公(前608年—前591年)以前,冬至大都出现在十二月,置闰无明显规律,大、小月安排是随意的。这以后,置闰已大致符合四分历的要求——十九年七闰,大月小月的安排也比较有规律。在没有掌握较准确的回归年长度以前,只能依据观测天象来安插闰月,随时发现季节与月令发生差异就可随时置闰,无规律可言。如果观测出回归年长度为36514日,根据长期的经验积累,人们自会摸索出一些安置闰月的规律。《说文》释:“闰,余分之月,五岁再闰也。”所谓“三年一闰,五年再闰”,是比较古老的置闰法。十九年七闰是四分历法所要求的调整回归年与朔望月长度的必要条件。从前人的研究成果可看出,春秋中期已掌握了十九年七闰的规律。据王韬、新城氏等人的工作统计,自公元前722年到公元前476年间的置闰情况可以列为一表:\n722—704闰7627—6097532—5147\n703—6856608—5908513—4957\n684—6667589—5717494—4767\n665—6477570—5527\n646—6286551—5337\n从表上可看出,从公元前589年(鲁成公二年)以来,十九年七闰已成规律了。结论是:春秋中期以后,产生四分历的条件已经具备。\n二、《次度》及其意义 # 在《汉书·律历志》中,保存了一份珍贵的史料——《次度》。这是一份古代天象实测记录,包含着丰富的内容,涉及古代天文历法研究中一系列基本问题。现介绍如次。原文:\n星纪。初斗十二度,大雪。中牵牛初,冬至(于夏为十一月,商为十二月,周为正月)。终于婺女七度。\n玄枵。初婺女八度,小雪。中危初,大寒(于夏为十二月,商为正月,周为二月)。终于危十五度。\n娵訾。初危十六度,立春。中营室十四度,惊蛰(今曰雨水。于夏为正月,商为二月,周为三月)。终于奎四度。\n降娄。初奎五度,雨水(今曰惊蛰)。中娄四度,春分(于夏为二月,商为三月,周为四月)。终于胃六度。\n大梁。初胃七度,谷雨(今曰清明)。中昴八度,清明(今曰谷雨。于夏为三月,商为四月,周为五月)。终于毕十一度。\n实沈。初毕十二度,立夏。中井初,小满(于夏为四月,商为五月,周为六月)。终于井十五度。\n鹑首。初井十六度,芒种。中井三十一度,夏至(于夏为五月,商为六月,周为七月)。终于柳八度。\n鹑火。初柳九度,小暑。中张三度,大暑(于夏为六月,商为七月,周为八月)。终于张十七度。\n鹑尾。初张十八度,立秋。中翼十五度,处暑(于夏为七月,商为八月,周为九月)。终于轸十一度。\n寿星。初轸十二度,白露。中角十度,秋分(于夏为八月,商为九月,周为十月)。终于氐四度。\n大火。初氐五度,寒露。中房五度,霜降(于夏为九月,商为十月,周为十一月)。终于尾九度。\n析木。初尾十度,立冬。中箕七度,小雪(于夏为十月,商为十一月,周为十二月)。终于斗十一度。\n首先,《次度》依据二十八宿距度,把日期的变更与星象的变化紧密联系起来,形成了二十八宿与二十四节气、十二月的对应关系。一岁二十四节气与二十八宿一周天正好相应。二十八宿的距度明确,《次度》便以精确的宿度来标志节气,比起《月令》以昏旦中星定节气,无疑更加准确而科学。\n其次,春秋中期以后,十九年七闰已经形成规律,平常年十二个朔望月,逢闰年有十三个朔望月,《次度》以平常年份排列,把十二月与二十四节气相配,实际上构成了阴阳合历的格局。同时,也把置闰与节气联系起来,为“无中气置闰法”创造了条件。若按《次度》的二十四节气继续排列下去,闰月就自有恰当的位置。\n第三,《次度》逐月将当时流行的三正月序附记于后,说明《次度》是三正论盛行时期的产物,它不仅适用于建寅为正之历,也适用于建丑为正、建子为正之历,是当时创制历法的天象依据,不受各国建正、岁首异制的影响。又,惊蛰后注明“今曰雨水”,雨水后注明“今曰惊蛰”;谷雨后注明“今曰清明”,清明后注明“今曰谷雨”,说明《次度》是古代遗留的典籍,节气顺次与汉代的不同,一一注明,可见非汉代人的编造。\n第四,《次度》中“星纪,玄枵……”等十二名,本是岁星纪年十二次用以纪年的专用名称,而《次度》却用来纪月。这一变革有很重要的意义。岁星纪年是春秋中期昙花一现的纪年法,它以木星十二岁绕天一周为周期。实际木星周期11.86年,过八十余年必有明显的岁星超次。所以,岁星纪年法不可能长期使用。《次度》用以纪月,说明《次度》产生于岁星纪年法破产之后,它伴随着一种新型的纪年法出现,标志着纪年方法的根本变革。\n最后,《次度》标明冬至点在牵牛初度,这就等于把它产生的年代告诉了我们。今人研究,冬至起于牛初,与公元前450年左右的天象相符。冬至点在牛初,一岁之末必在斗宿26度之后。斗宿计2614度,正是“斗分”。所以《次度》所记,正是四分历的天象。\n总之,《次度》中二十八宿、二十四节气和十二月的完美结合,概括了观象授时的全部成果,形成了阴阳合历的体制,显示了天文观测的高度水准,提供了创制四分历法的天象依据。可以说,《次度》的产生就预示着历法时代的开始。\n三、四分历产生的年代 # 有了《次度》所记天象和时令作为依据,有了观象实测得来的回归年、朔望月长度和十九年七闰的置闰规律,就可以进而制定历法。从《春秋》所记史料研究得知,四分历法的创制当在春秋后期至战国初期的某个时候。\n四分历究竟是什么时候创制、使用的呢?这个问题始终是古代天文历法史上的一大疑难,争论颇多。根据张汝舟先生的考证,四分历创制于战国初期,于周考王十四年(公元前427年)行用。他有什么主要依据呢?\n1.《次度》所载,“星纪”所记冬至点在牵牛初度,这正是创制四分历的实际天象。星纪者,星之序也。星纪起于牛初,最后当然是斗宿,分数14必在斗宿度数之内,这就是星历家所称之“斗分”。没有斗分便没有四分历,而斗分的概念也专属于四分历,它是编制四分历的基本数据。\n汉初的实际天象是冬至点在建星(见《汉书·律历志》)。建星在南斗尾附近。《后汉书·律历志》记冬至点在斗2114度。据岁差密律,每71年8个月,冬至点西移1度。\n5×7123=358.3年\n古人凭肉眼观察,差1度就差70多年。可以推知《次度》保留的是战国初期的实际天象。前已说过,以科学的数据推知,《次度》所显示的是公元前450年左右的实际天象。\n2.《次度》所载春天三个月的节气,顺次是立春、惊蛰、雨水、春分、谷雨、清明,与汉朝以后迄今未变的节气顺次不同。足证《次度》所记之四分历到汉初已行用了相当长一段时间,才有足够的经验加以改进。\n3.有了“斗分”,定岁实为36514日,以它作基础调配年月日,就能得出一个朔望月(朔策)为29499940日。《历术甲子篇》通篇的大余、小余,就反映了四分历的岁实与朔策的调配关系。那通篇的大余、小余使我们明白,《历术甲子篇》就是司马迁为我们保存下来的中国最早的完整的历法。《历术甲子篇》中“焉逢摄提格”之类的称谓就是干支的别名,全篇取甲寅年为太初元年,以甲子月甲子日夜半冬至合朔为历元,其历元近距是周考王十四年(甲寅)己酉日夜半冬至合朔。据此推演下来,千百年之干支纪年,朔日与余分,一一吻合。这不是偶合,是法则,是规律,足证四分历以公元前427年为历元近距之考证不误。\n4.再以《史记》《汉书》所记汉初实际天象说,汉初“日食在晦”频频出现。四分历的岁实是36514日,与实际天象每年实浮3.06分,由此可以推知四分历的行用至汉代已近三百年左右,才会有“后天一日”的记录。“日食在晦”的反常现象正是四分历的固有误差(三百年而盈一日)造成的。确证公元前427年为四分历行用之年是可信的。通过后面的演算,对汝舟先生的结论更会确信不疑。\n5.《汉书·律历志·世经》说:“元帝初元二年十一月癸亥朔旦冬至,殷历以为甲子,以为纪首。”据此,可以进行如下推算。\n汉元帝初元二年为公元前47年(甲戌),殷历以该年十一月的癸亥朔旦冬至为甲子日朔旦冬至(癸亥先于甲子一日,这是刘歆《三统历》造成的),并以为纪首。按四分历章蔀编制,一纪20蔀共1520年,上一纪首当为1520+47=1567年(甲寅),正与《历术甲子篇》首年干支相合,说明公元前1567年(甲寅)既为纪首年,又为甲子蔀首年,这就是所谓历元,即殷历甲寅元。但是,殷历甲寅元并非产生于公元前1567年。《次度》和汉初日食在晦的天象已经告诉我们,它产生于汉初之前300年左右。这就要求创制殷历的这一年作为制历的首年,应该既是甲寅年(作为历元的标志),又是蔀首年(便于起算),可以用推求一蔀76年与60位干支最小公倍数的方法,推算此年:\n由此可知,殷历甲寅元创制之年是公元前427年,此年为甲寅年,位于殷历第十六蔀首年,在太初改历(公元前104年)之前323年,完全满足上述条件和天象、史实记载的要求,因此可以断定,公元前427年为殷历甲寅元创制行用之年。\n由于纪首公元前1567年年前十一月朔旦冬至从甲子日起算,到公元前427年朔旦冬至并不逢甲子:1140×36514÷60=6939……余45(己酉),而是在甲子之后的45位干支己酉(即第十六蔀蔀余),说明己酉为第十六蔀首日,按照“甲寅岁甲子月甲子日夜半甲子时合朔冬至”的要求,公元前427年显然不配称为历元,故称之为“历元近距”。由此我们可以推知,殷历制造者正是以公元前427年(甲寅)首日己酉为基点,逆推历元公元前1567年(甲寅)首日甲子,进而编排《二十蔀首表》的,而《历术甲子篇》就是殷历甲寅元的推算法规。\n生活于公元前4世纪的孟子曾充满自信地说:“天之高也,星辰之远也,苟求其故,千岁之日至,可坐而致也。”(《孟子·离娄下》)这正是当时人们长期运用四分历法,推算时令节气的真实写照。反之,如果当时还处于观象授时阶段,没有行用历法,那么“千岁之日至”何以“坐而致”呢?\n考证出殷历甲寅元(即《历术甲子篇》)创制于公元前427年,就可以用来推算上古历点,并在推算中验证殷历甲寅元的正确性。\n四、四分历的数据 # 四分历的基本数据是定岁实为36514日,推知朔策为29499940日。因为太阳与月亮运行周期都不是日的整倍数,要调配年、月、日以相谐和,就必须有更大的数据,才能反映这种谐和的周期,这就形成了大于年的计算单位:章、蔀、纪、元。\n一章:19年235月\n一蔀:4章76年940月27759日\n一纪:20蔀1520年\n一元:3纪4560年\n岁实是从冬至到下一个冬至的时日,比较好理解。由于月亮圆缺周期是29日多,12个月6大6小(大月30日,小月29日)才354日,还与岁实差1114日,三年置一闰月还有余,所以远古时候我们祖先就懂得“三年一闰,五年再闰”。四分历明确“十九年七闰”,成为规律,所以19年为一章,共235月。19年中设置7个闰月就能调配一年四季与月亮运行周期大体相合。\n要使月亮运行周期(朔望月)与岁实完全调配无余分,19年还做不到,必须76年才有可能,所以又规定一蔀4章76年计940个月,得36514×76=27759日。若以月数(940)除日数,便得朔策499940日。\n历法必须与干支纪日联系在一起。一蔀之日27759日,干支以60为周期:27759÷60=462……余39(日),这就是蔀余。即一蔀之日不是60干支的整倍数,尚余39日(即39位干支),也就是说,若一蔀首日为甲子日,最后一天即为壬寅日。为了构成日数与干支的完整周期,必须以二十蔀为一个单元:\n27759×20÷60=9253(无余数)\n这就是一纪二十蔀的来由,即一纪起自甲子日,终于癸亥日,是9253个完整的干支周期。据此,可制成二十蔀表:\n汝舟先生在表中立了“蔀余”,很重要:“蔀余”指的是每蔀后列之数字。《历术甲子篇》只代表四分历一元之第一蔀(甲子蔀)七十六年。所余前大余为39(即太初第七十七年前大余三十九),进入第二蔀即为癸卯蔀蔀余。以后每蔀递加39,就得该蔀之蔀余。如果递加结果超过了一甲数60,则减去一甲数。\n一纪二十蔀,共1520年,甲子日夜半冬至合朔又回复一次。但1520年还不是干支60的整倍数,所以一元辖三纪,4560年,才能回复到甲寅年甲子月甲子日甲子时(夜半)冬至合朔。这就是一元三纪的来由。\n如果我们将二十蔀首年与公元年份配合起来,就是下面的关系(见下页)。十六蔀己酉,蔀首年是公元前427年,又是公元1094年(北宋哲宗绍兴元年)。公元1930年乃第七戊午蔀首年,公元2006年乃第八丁酉蔀首年。推知2004年当为戊午蔀第七十五年。\n《历术甲子篇》之所以是四分历之“法”,就在于它将甲子蔀(四分历的第一蔀)七十六年的朔闰一一确定下来,使之规律化;由此一蔀可以推知二十蔀,推知整个一元4560年的朔闰规律。我们读懂了《历术甲子篇》的大余、小余,四分历就算通透明白,就可以应用于对证历点考察史料。\n《历术甲子篇》所载之“太初”,乃四分历历元之太初,非汉武帝之年号太初。“太初”前之一“汉”字,是后人妄加。历代星历家对此早有怀疑,但一直未能找到症结所在,致使这部极为重要的历法著述被视为一张普通的历表,淹没了千百年。\n《历术甲子篇》列出每年前大余、前小余、后大余、后小余。“大余者,日也;小余者,日之分数也。”这个解释是对的。\n前大余是记年前十一月朔在哪一天;\n前小余是记当日合朔时的分数(每日以940分计);\n后大余是记年前冬至在哪一天;\n后小余是记冬至日冬至时的分数(每日四分之,化14为832)。\n如:太初二年前大余五十四\n前小余三百四十八\n后大余五\n后小余八\n前大余指合朔干支,查《一甲数次表》,五十四为戊午;前小余即合朔时刻,在348940分。即,太初二年子月戊午348分合朔。\n后大余指冬至干支,查表,五是己巳;后小余即冬至时刻,在832分即14日(卯时)。即,太初二年子月己巳日卯时冬至。\n五、《历术甲子篇》的编制 # 明白了四分历章蔀编制的内在联系,就可以探讨《历术甲子篇》的编制原理。\n要理解《历术甲子篇》,必须首先澄清两个问题:\n1.《历术甲子篇》是一部历法书,不是一份起自汉太初元年(公元前104年)的编年表。在《史记·历书·历术甲子篇》中,在焉逢摄提格太初元年之后,逐一列举了天汉元年、太始元年等年号、年数,直至汉成帝建始四年(公元前29年),因此有人将《历术甲子篇》认定为汉太初改历后行用的太初历或编年表,这是不正确的。细读《史记》,不难发现其中的谬误。\n清张文虎《史记札记》说:“历术甲子篇:《志疑》云此乃当时历家之书,后人谬附增入‘太初’等年号、年数,其所说仍古四分之法,非邓平、落下闳更定之《太初历》也。”\n日本学者泷川资言《史记会注考证》也说:“太初元年至建始元年年号年数,后人妄增。”\n可见前人对此早有觉察。\n现在可进一步确证,太史公司马迁生于汉景帝中元五年(公元前145年),武帝太初元年(公元前104年)参与改历,是年42岁,之后开始撰写《史记》。天汉三年(公元前98年)因李陵事受宫刑,到太始四年(公元前93年,写《报任安书》时)《史记》一书已成,是年53岁。史家认为自此以后,司马迁事迹已不可考,约卒于武帝末年。倘若司马迁活到汉成帝建始四年(公元前29年),当享年117岁,这是不可能的事。由此可知,混入《历术甲子篇》中的年号、年数,断非出自司马迁的手笔,纯系后人妄加。现在应该删去这些年号、年数,恢复《历术甲子篇》作为历法宝书的本来面目。\n2.《历术甲子篇》虽行用日久,但系皇家宝典,外人难以知道其中的奥秘,所以后世曲解误断者自不可免,如其中“大余者,日也;小余者,月也”一句,便不可解,正因为如此,这样一部历法宝书才被埋没了两千年之久。现经张汝舟教授多年研究考订,终于拭去了历史的尘垢,使它焕发出夺目的光彩。以下随文一一说明。\n《历术甲子篇》浅释:\n[原文]元年,岁名焉逢摄提格,月名毕聚,日得甲子,夜半朔旦冬至。\n正北十二无大余无小余无大余无小余[浅释]所谓“甲子篇”,即20蔀中的第1蔀甲子蔀,蔀首日甲子,干支序号为0。1蔀76年,以下顺次排列朔闰谱。这里虽只列1蔀朔闰法,其他19蔀与之同法同理,所不同者唯蔀余(即蔀首日干支序号)而已。\n“元年,岁名焉逢摄提格。”“元年”即四分历甲子蔀第一年;“岁名焉逢摄提格”即该年名为“甲寅”。此处言“岁名”而不说“岁在”,可知此“岁”字不是岁星之“岁”,而只是指此年,与岁星纪年划清了界线。\n“月名毕聚。”《尔雅·释天》“月在甲日毕”,“正月为陬”。作为历法,是以冬至为起算点,冬至正在夏正十一月(子月),即此历以甲子月(子月)起算。聚与陬、娵相通,从《次度》可知,娵訾为寅月,此处“正月为陬”即以寅月为正月。\n“日得甲子,夜半朔旦冬至。”“日得甲子”即甲子蔀首日为甲子;“夜半朔旦冬至”即这天的夜半子时0点合朔冬至。“旦”字后人妄加,应删。将子、丑等十二辰配二十四小时,子时分初、正,包括23到1点两个小时,那是中古以后的事。\n上文告诉我们,这部历法的第一蔀开始于甲寅岁、甲子月、甲子日夜半子时0点合朔冬至,显然这是一个非常理想的时刻,即所谓“历始冬至,月先建子,时平夜半”(《后汉书·律历志》)。\n“正北。”古人以十二地支配四方,子属正北,卯属正东,午属正南,酉属正西。此年前十一月子时0点合朔冬至,故曰“正北”。\n“十二。”记这一年为十二个月,无闰月,平年;有闰月的年份为“闰十三”。\n“无大余,无小余;无大余,无小余。”“前大余”为年前十一月(子月)朔日干支号,“前小余”为合朔余分(朔余),“后大余”为年前十一月冬至干支号,“后小余”为冬至余分(气余)。此处前、后、大、小余均无,即说明在甲子日夜半子时0点合朔冬至,正与前文相应。\n[原文]端蒙单阏二年十二\n大余五十四小余三百四十八\n大余五小余八\n[浅释]此年乙卯年。端蒙,乙;单阏,卯。\n由前文可知,前大余、前小余与年前十一月合朔有关,属于太阴历系统;后大余、后小余与年前十一月冬至有关,属于太阳历系统,这两者的结合,就是阴阳合历,这就是中国历法的特点。\n前“大余五十四”:如前所述,太阴历一年十二个月,六大六小,30×6+29×6=354(日),354÷60=5……余54(日)。查干支表,54为戊午,即知此年前十一月戊午朔。\n前“小余三百四十八”:按四分历章蔀,一个朔望月为12 日(朔策),一年十二个月,29×12+ ×12=348+6 =354 (日),此处只记分子348,不记分母940。\n换句话说,大月30日-29 日= (日)多用了441分;小月29日,尚余499分,一大一小,499-441=58(分)。一年六大月六小月,58×6=348(分),这就是该年前十一月朔余。\n348分意味着什么?化成今天的小时:\n348/940×24=8.885(小时)\n60×0.885=53.1(分)\n60×0.1=6(秒)\n就是说,该年前十一月戊午日八时五十三分六秒合朔。\n后“大余五”:一个回归年36514日,以60干支除之。36514÷60=6……余514(日),后大余只记冬至日干支号五。查干支表五为己巳,即该年前十一月己巳冬至为十一月十二日(朔为戊午)。\n后“小余八”:后大余已记整数五,尚余14,为运算方便,将分子分母同时扩大四倍,即化14为832,此处只记分子八,不记分母,即为后小余。832×24=6(时),即说明该年前十一月己巳(十二日)六时冬至。\n为什么要化14为832?为了便于推算一年二十四节气。因为当时用平气,冬至已定,其他节气均可推出:\n即每个节气均有15日7/32分之差,从冬至起算,逐一叠加,可以算出每个节气的干支和气余。可见四分历创制者是何等聪明智慧、精研巧思!\n明白了《历术甲子篇》元年、二年的编制,就可逐月排出朔、气干支如下:\n由以上推算可知:\n在推算朔日时,由于大月亏441分,小月盈499分,所以凡朔余大于441分者为大月,小于441分者为小月。因为每两月(一大一小)要盈58分,所以逐月积累,小月朔余大于441分变大月,这就出现所谓“连大月”,如二年之辰月。但二年十二个月仍为六大六小,所以该年总日数并未变。有的年份出现连大月,会使全年十二个月变成七大五小(355日),后面将会遇到。\n在节气推算中,后小余(气余)满32进1位干支。每月中气间相隔30日14分,可逐一叠加推出。如前所述,一个回归年(36514日)大于十二个朔望月(354日)1114日,两年即多出22.5日,所以二年亥月(十月)小雪甲辰,已是该月22日了。到了第三年即多出3334日,必置闰月加以调整。\n[原文]游兆执徐三年闰十三\n大余四十八小余六百九十六\n大余十小余十六\n[浅释]此年丙辰年。\n“前大余”:54(二年前大余)+54(二年日干支余数)\n=108。\n108÷60=1……余48(壬子)\n“前小余”:348(二年前小余)+348(二年朔余)\n=696(分)。\n“后大余”:5(二年后大余)+5(二年气干支余数)=10(甲戌)\n“后小余”:8(二年后小余)+8(二年气余)=16(分)\n即该年前十一月壬子朔甲戌冬至。此为闰年,可排出下列朔闰表:\n由上表可知,未月之后应为大月戊申朔,该月晦日应为丁丑;而未月中气大暑丁未,下一个中气处暑戊寅,后于丁丑一天,不在该月之内,该月只有节气立秋壬戌而无中气处暑,故设闰月,此为“无中气置闰”。古人最初采用过岁末置闰,即闰月设置在岁末,但卜辞中就有闰在岁中的记载,可见闰在岁中和闰在岁末有一个相当漫长的并用时期。一般认为,汉太初(公元前104年)改历后才使用闰在岁中(即无中气置闰法),这是值得进一步研究的。下面在推算历点时再进行讨论。\n其实,后大余减前大余,也能大致判断出该年闰年、闰月的情况:\n后大余10-前大余48=70-48=22\n说明该年冬至已到年前十一月二十三日,该年又有回归年与十二朔望月相差的1114日,说明该年必闰。\n1114÷12=0.9375\n二年22.5+0.9375×8=30\n所以三年从冬至起算的第八月后置闰。\n[原文]强梧大荒落四年十二\n大余十二小余六百三\n大余十五小余二十四\n[浅释]此年丁巳年。\n三年为闰年,七大六小30×7+29×6=384(日)\n[48(三年前大余)+384]÷60=7……余12\n故四年前大余为十二。\n696(三年前小余)+348(三年朔余)+499-940=603(前小余)\n后大余逐年递加五,满六十周而复始;后小余逐年递加八,满三十二进一位。以下同理。\n依此,可排出四年朔、气干支:\n因为四年子月大,而戌、亥两月又连大,全年十二月七大五小,共355天,这是推算五年前大余要注意的。\n[原文]徒维敦牂五年十二\n大余七小余十一\n大余二十一无小余\n[浅释]此年戊午年。\n[12(四年前大余)+355(五年日数)]÷60=6……余7\n603(四年前小余)+348-940=11此为前小余。\n24(四年后小余)+8=32(进一位)\n15(四年后大余)+5+1=21\n此为后大余、后小余。\n[原文]祝犁协洽六年闰十三\n大余一小余三百五十九\n大余二十六小余八\n[浅释]此年己未年。\n[7(五年前大余)+354]÷60=6……余1\n11(五年前小余)+348=359\n此为前大余、前小余。后大余、后小余按常规递加。\n26(后大余)-1(前大余)=2525+1114\u0026gt;30\n说明该年冬至已到十一月二十六日,必须置闰。依此,排出该朔闰表:\n[原文]商横涒滩七年十二\n大余二十五小余二百六十六\n大余三十一小余十六\n[浅释]此年庚申年。\n[1(六年前大余)+384(六年总日数)]÷60=6……余25\n359(六年前小余)+348+499-940=266\n此为前大余、前小余。后大余、后小余如常递加。\n[原文]昭阳作噩八年十二\n大余十九小余六百一十四\n大余三十六小余二十四\n[浅释]此年辛酉年。\n推算如前,该年七大五小共355日。\n[原文]横艾淹茂九年闰十三\n大余十四小余二十二\n大余四十二无小余\n[浅释]此年壬戌年。\n推算如前,该年闰十三,六大七小。共383日。\n[原文]尚章大渊献十年十二\n大余三十七小余八百六十九\n大余四十七小余八\n[浅释]此年癸亥年。\n该年七大五小共355日。\n[原文]焉逢困敦十一年闰十三\n大余三十二小余二百七十七\n大余五十二小余一十六\n[浅释]此年甲子年。\n该年闰十三,七大六小共384日。\n[原文]端蒙赤奋若十二年十二\n大余五十六小余一百八十四\n大余五十七小余二十四\n[浅释]此年乙丑年。\n[原文]游兆摄提格十三年十二\n大余五十小余五百三十二\n大余三无小余\n[浅释]此年丙寅年。\n[原文]强梧单阏十四年闰十三\n大余四十四小余八百八十\n大余八小余八\n[浅释]此年丁卯年。\n该年闰十三,七大六小共384日。\n[原文]徒维执徐十五年十二\n大余八小余七百八十七\n大余十三小余十六\n[浅释]此年戊辰年。\n该年七大五小共355日。\n[原文]祝犁大荒落十六年十二\n大余三小余一百九十五\n大余十八小余二十四\n[浅释]此年己巳年。\n[原文]商横敦十七年闰十三\n大余五十七小余五百四十三\n大余二十四无小余\n[浅释]此年庚午年。\n该年闰十三,七大六小共384日。\n[原文]昭阳协洽十八年十二\n大余二十一小余四百五十\n大余二十九小余八\n[浅释]此年辛未年。\n[原文]横艾涒滩十九年闰十三\n大余十五小余七百九十八\n大余三十四小余十六\n[浅释]此年壬申年。\n该年闰十三,七大六小共384日。\n按四分历章蔀十九年七闰为一章,到此十九年七闰已毕,甲子蔀第一章完。在这一章里第3、6、9、11、14、17、19七年为闰年。\n[原文]尚章作噩二十年正西十二\n大余三十九小余七百五\n大余三十九小余二十四\n[浅释]此年癸酉年。\n如前所述,古人以十二地支配四方,此年前十一月合朔冬至同日同时,正是酉时,故标“正西”。其余推算如常,全年七大五小共355日。\n此年为第二章首年。\n[原文]焉逢淹茂二十一年十二\n大余三十四小余一百一十三\n大余四十五无小余\n[浅释]此年甲戌年。\n[原文]端蒙大渊献二十二年闰十三\n大余二十八小余四百六十一\n大余五十小余八\n[浅释]此年乙亥年。\n该年闰十三,七大六小共384日。\n[原文]游兆困敦二十三年十二\n大余五十二小余三百六十八\n大余五十五小余十六\n[浅释]此年丙子年。\n[原文]强梧赤奋若二十四年十二\n大余四十六小余七百一十六\n无大余小余二十四\n[浅释]此年丁丑年。\n全年七大五小共355日。\n[原文]徒维摄提格二十五年闰十三\n大余四十一小余一百二十四\n大余六无小余\n[浅释]此年戊寅年。\n该年闰十三,七大六小共384日。\n[原文]祝犁单阏二十六年十二\n大余五小余三十一\n大余十一小余八\n[浅释]此年己卯年。\n[原文]商横执徐二十七年十二\n大余五十九小余三百七十九\n大余十六小余十六\n[浅释]此年庚辰年。\n[原文]昭阳大荒落二十八年闰十三\n大余五十三小余七百二十七\n大余二十一小余二十四\n[浅释]此年辛巳年。\n该年闰十三,七大六小共384日。\n[原文]横艾敦二十九年十二\n大余十七小余六百三十四\n大余二十七无小余\n[浅释]此年壬午年。\n该年七大五小共355日。\n[原文]尚章协洽三十年闰十三\n大余十二小余四十二\n大余三十二小余八\n[浅释]此年癸未年。\n该年闰十三,七大六小共383日。\n[原文]焉逢涒滩三十一年十二\n大余三十五小余八百八十九\n大余三十七小余十六\n[浅释]此年甲申年。\n该年七大五小共355日。\n[原文]端蒙作噩三十二年十二\n大余三十小余二百九十七\n大余四十二小余二十四\n[浅释]此年乙酉年。\n[原文]游兆淹茂三十三年闰十三\n大余二十四小余六百四十五\n大余四十八无小余\n[浅释]此年丙戌年。\n该年闰十三,七大六小共384日。\n[原文]强梧大渊献三十四年十二\n大余四十八小余五百五十二\n大余五十三小余八\n[浅释]此年丁亥年。\n[原文]徒维困敦三十五年十二\n大余四十二小余九百\n大余五十八小余十六\n[浅释]此年戊子年。\n该年七大五小共355日。\n[原文]祝犁赤奋若三十六年闰十三\n大余三十七小余三百八\n大余三小余二十四\n[浅释]此年己丑年。\n该年闰十三,七小六大共384日。\n[原文]商横摄提格三十七年十二\n大余一小余二百一十五\n大余九无小余\n[浅释]此年庚寅年。\n[原文]昭阳单阏三十八年闰十三\n大余五十五小余五百六十三\n大余十四小余八\n[浅释]此年辛卯年。\n该年闰十三,七大六小共384日。\n到此第二章十九年七闰完,其中第22、25、28、30、33、36、38七年为闰年。\n[原文]横艾执徐三十九年正南十二\n大余十九小余四百七十\n大余十九小余十六\n[浅释]此年壬辰年。\n此年为第三章首年。年前十一月朔日与冬至同日同时正当午时,故标“正南”。\n[原文]尚章大荒落四十年十二\n大余十三小余八百一十八\n大余二十四小余二十四\n[浅释]此年癸巳年。\n该年七大五小共355日。\n[原文]焉逢敦四十一年闰十三\n大余八小余二百二十六\n大余三十无小余\n[浅释]此年甲午年。\n该年闰十三,七大六小共384日。\n[原文]端蒙协洽四十二年十二\n大余三十二小余一百三十三\n大余三十五小余八\n[浅释]此年乙未年。\n[原文]游兆涒滩四十三年十二\n大余二十六小余四百八十一\n大余四十小余十六\n[浅释]此年丙申年。\n[原文]强梧作噩四十四年闰十三\n大余二十小余八百二十九\n大余四十五小余二十四\n[浅释]此年丁酉年。\n该年闰十三,七大六小共384日。\n[原文]徒维淹茂四十五年十二\n大余四十四小余七百三十六\n大余五十一无小余\n[浅释]此年戊戌年。\n该年七大五小共355日。\n[原文]祝犁大渊献四十六年十二\n大余三十九小余一百四十四\n大余五十六小余八\n[浅释]此年己亥年。\n[原文]商横困敦四十七年闰十三\n大余三十三小余四百九十二\n大余一小余十六\n[浅释]此年庚子年。\n该年闰十三。七大六小共384日。\n[原文]昭阳赤奋若四十八年十二\n大余五十七小余三百九十九\n大余六小余二十四\n[浅释]此年辛丑年。\n[原文]横艾摄提格四十九年闰十三\n大余五十一小余七百四十七\n大余十二无小余\n[浅释]此年壬寅年。\n全年闰十三,七大六小共384日。\n[原文]尚章单阏五十年十二\n大余十五小余六百五十四\n大余十七小余八\n[浅释]此年癸卯年。\n全年七大五小共355日。\n[原文]焉逢执徐五十一年十二\n大余十小余六十二\n大余二十二小余十六\n[浅释]此年甲辰年。\n[原文]端蒙大荒落五十二年闰十三\n大余四小余四百一十\n大余二十七小余二十四\n[浅释]此年乙巳年。\n该年闰十三,七大六小共384日。\n[原文]游兆敦五十三年十二\n大余二十八小余三百一十七\n大余三十三无小余\n[浅释]此年丙午年。\n[原文]强梧协洽五十四年十二\n大余二十二小余六百六十五\n大余三十八小余八\n[浅释]此年丁未年。\n该年七大五小共355日。\n[原文]徒维涒滩五十五年闰十三\n大余十七小余七十三\n大余四十三小余十六\n[浅释]此年戊申年。\n该年闰十三。七小六大共383日。\n[原文]祝犁作噩五十六年十二\n大余四十小余九百二十\n大余四十八小余二十四\n[浅释]此年己酉年。\n[原文]商横淹茂五十七年闰十三\n大余三十五小余三百二十八\n大余五十四无小余\n[浅释]此年庚戌年。\n全年闰十三,七大六小共384日。\n到此第三章十九年七闰完,其中第41、44、47、49、52、55、57七年为闰年。\n[原文]昭阳大渊献五十八年正东十二\n大余五十九小余二百三十五\n大余五十九小余八\n[浅释]此年辛亥年。\n此为第四章首年。该年年前十一月合朔冬至同日同时,正当卯时,故标“正东”。\n[原文]横艾困敦五十九年十二\n大余五十三小余五百八十三\n大余四小余十六\n[浅释]此年壬子年。\n[原文]尚章赤奋若六十年闰十三\n大余四十七小余九百三十一\n大余九小余二十四\n[浅释]此年癸丑年。\n该年闰十三,七大六小共384日。\n[原文]焉逢摄提格六十一年十二\n大余十一小余八百三十八\n大余十五无小余\n[浅释]此年甲寅年。\n全年七大五小共355日。\n[原文]端蒙单阏六十二年十二\n大余六小余二百四十六\n大余二十小余八\n[浅释]此年乙卯年。\n[原文]游兆执徐六十三年闰十三\n无大余小余五百九十四\n大余二十五小余十六\n[浅释]此年丙辰年。\n该年闰十三,七大六小共384日。\n[原文]强梧大荒落六十四年十二\n大余二十四小余五百一\n大余三十小余二十四\n[浅释]此年丁巳年。\n[原文]徒维敦 六十五年十二\n大余十八小余八百四十九\n大余三十六无小余\n[浅释]此年戊午年。\n该年七大五小共355日。\n[原文]祝犁协洽六十六年闰十三\n大余十三小余二百五十七\n大余四十一小余八\n[浅释]此年己未年。\n全年七大六小共384日。\n[原文]商横涒滩六十七年十二\n大余三十七小余一百六十四\n大余四十六小余十六\n[浅释]此年庚申年。\n[原文]昭阳作噩六十八年闰十三\n大余三十一小余五百一十二\n大余五十一小余二十四\n[浅释]此年辛酉年。\n全年七大六小共384日。\n[原文]横艾淹茂六十九年十二\n大余五十五小余四百一十九\n大余五十七无小余\n[浅释]此年壬戌年。\n[原文]尚章大渊献七十年十二\n大余四十九小余七百六十七\n大余二小余八\n[浅释]此年癸亥年。\n全年七大五小共355日。\n[原文]焉逢困敦七十一年闰十三\n大余四十四小余一百七十五\n大余七小余十六\n[浅释]此年甲子年。\n全年闰十三,七大六小共384日。\n[原文]端蒙赤奋若七十二年十二\n大余八小余八十二\n大余十二小余二十四\n[浅释]此年乙丑年。\n[原文]游兆摄提格七十三年十二\n大余二小余四百三十\n大余十八无小余\n[浅释]此年丙寅年。\n[原文]强梧单阏七十四年闰十三\n大余五十六小余七百七十八\n大余二十三小余八\n[浅释]此年丁卯年。\n全年闰十三,七大六小共384日。\n[原文]徒维执徐七十五年十二\n大余二十小余六百八十五\n大余二十八小余十六\n[浅释]此年戊辰年。\n全年七大五小共355日。\n[原文]祝犁大荒落七十六年闰十三\n大余十五小余九十三\n大余三十三小余二十四\n[浅释]此年己巳年。\n全年闰十三,七大六小共384日。\n到此第四章十九年七闰完,其中第60、63、66、68、71、74、76七年为闰年。此年亦是甲子蔀最后一年,到此四分历第一蔀(即甲子蔀)结束,但尚有蔀余三十九,且看下文。\n[原文]商横敦七十七年\n右历书:大余者,日也;小余者,月也。端(旃)蒙者,年名也。支:丑名赤奋若,寅名摄提格。干:丙名游兆。正北(原注:冬至加子时),正西(原注:加酉时),正南(原注:加午时),正东(原注:加卯时)。\n[浅释]此年庚午年。\n此年应为四分历第二蔀(癸卯蔀)首年。原文脱落有误,尤其“大余者,日也;小余者,月也”一句,造成历史误解,使《历术甲子篇》竟成读不懂的天书,现经张汝舟先生考订。原文应为:\n商横敦七十七年正北十二\n大余三十九无小余\n大余三十九无小余\n右历书:大余者,日也;小余者,日之余分也。前大余者,年前十一月朔也;后大余者,年前十一月冬至也。前小余者,合朔加时也;后小余者,冬至加时也。端蒙赤奋若者,年干支名也。支:丑名赤奋若,寅名摄提格;干:丙名游兆。正北:合朔冬至加子时;正西:加酉时;正南:加午时;正东:加卯时。\n因为[15(七十六年前大余)+384(七十六年总日数)]÷60=6……余39(癸卯)\n93(七十六年前小余)+348+499-940=0\n33(七十六年后大余)+5+1=39(癸卯)\n24(七十六年后小余)+8=32(进一位为0)\n说明商横敦七十七年前十一月癸卯日夜半子时0点合朔冬至,这正是四分历第二蔀癸卯蔀的起算点。由此起算,癸卯蔀的朔闰推算完全同于甲子蔀。\n《历术甲子篇》虽然只列了甲子蔀七十六年的大余、小余,并依此推算各年朔闰。其实,其他十九蔀均可照此办理(只需加算蔀余),这是一个有规律的固定周期,所以我们称之为“历法”。由《二十蔀表》和《历术甲子篇》的内部编制,我们深深感到,上古星历家、四分历的创制者的确运筹精密,独具匠心,实在令人惊叹!\n为了运算查阅方便,下面特列出甲子蔀朔日表:\n六、入蔀年的推算 # 《历术甲子篇》只列四分历第一蔀七十六年之大余小余,因为是“法”,是规律,自可以一蔀该二十蔀。\n上节所述《历术甲子篇》内部的编制,可利用朔策及气余推算出甲子蔀太初以下七十六年之朔闰,这当然是基本的。在实际应用时,多涉及史料所记载的年代,只需要以历元近距(公元前427年)为基点进行推算。\n要知某年之朔闰,当先以历元近距前427年为依据,算出该年入二十蔀表中某蔀第几年。入蔀年可用146页表。“年”用《历术甲子篇》之年序,从太初元年至太初七十六年查得该年之前大余再加该蔀蔀余,则得该年子月之朔日干支,其余各月朔闰则按上节推算法即得。\n如,睡虎地秦墓竹简载:“秦王二十年四月丙戌朔丁亥。”\n验证这个历点的办法是:\n秦王政二十年为公元前227年。(须查《中国历史纪年表》,下同。)\n推算:427-227=200(年)(上距前427年200年)\n200÷76=2……余48(算外49)\n前427年为己酉蔀第一年,顺推两蔀进丁卯蔀,知前227年为丁卯蔀第四十九年。丁卯蔀蔀余是3。(见145页表)\n查《历术甲子篇》太初四十九年,大余五十一,小余七百四十七。(见上页朔日表)\n蔀余加前大余3+51=54。\n查147页《一甲数次表》,54为戊午。\n得知,前227年子月戊午747分合朔。\n按月推知,得\n丑月戊子,306\n寅月丁巳,805\n卯月丁亥,364\n辰月丙辰,863\n巳月丙戌,422\n午月乙卯(下略)\n巳月(夏历四月)朔丙戌,丁亥是初二。与出土文物所记吻合。\n又,贾谊《鵩鸟赋》:“单阏之岁兮,四月孟夏,庚子日斜兮,鵩集于舍。”\n“单阏”是“卯”的别名。根据贾谊生活时代推知,卯年即丁卯年。单阏乃“强梧单阏”之省称。这是汉文帝六年公元前174年丁卯年。\n推算:427-174=253(年)\n以蔀法除之253÷76=3……余25\n该年为丙午蔀第26年。(前427年在己酉蔀,己酉蔀之后三蔀即丙午蔀。算外,入第26年。)\n查,《历术甲子篇》太初二十六年,大余五,小余三十一。\n蔀余加前大余42+5=47(辛亥)\n得知,前174年子月辛亥日31分合朔。\n按月推之:\n丑月庚辰,530\n寅月庚戌,89\n卯月己卯,588\n辰月己酉,147\n巳月戊寅,646\n午月戊申(下略)\n巳月(夏历四月)戊寅朔,则二十三日庚子。\n贾谊所记乃汉文帝六年(丁卯年)四月二十三日事。\n七、实际天象的推算 # 四分历的岁实为36514日,与一个回归年的实际长度比较密近而并不相等,由此产生的朔策29日499分也就必然与实测有一定误差。所以,四分历使用日久,势必与实际天象不合。南朝天文学家何承天、祖冲之就已经指出四分历的不精。何承天说:“四分于天,出三百年而盈一日,积代不悟。”祖冲之说:“四分之法,久则后天,以食检之,经三百年辄差一日。”因为\n四分历朔策:29 =29.53085106日\n实测朔策:29.530588日\n每月超过实测:0.00026306日\n十九年七闰计235月,每年余0.0032536日\n1÷0.0032536=307(年)\n即307年辄差一日,每日940分计\n940÷307=3.06(分)\n即四分历每年约浮3.06分。\n如果用《历术甲子篇》的“法”来推演,再加上每年所浮3.06分,上推千百年至西周殷商,下推千百年至21世纪的今天,所得朔闰也能与实际天象密近(区别仅在平朔与定朔,平气与定气而已)。\n因为四分历行用于公元前427年,所以推算前427年之前的实际天象,每年当加3.06分;推算前427年之后的实际天象,每年当减3.06分,简言之,即“前加后减”。一些考古学家不明这个道理,用刘歆之孟统推算西周实际天象,总是与铭器所记不合,总是要发生两三天的误差,根本原因就是没有把每年浮3.06分计算在内,最后不得不以“月相四分”来自圆其说。\n这里举几个例子,用3.06分前加后减,求出实际天象。\n例1.《诗·十月之交》:“十月之交,朔月辛卯,日有食之。”这是一次日食的记载,发生在十月朔日辛卯这一天。前人已考定为周幽王六年事,试以四分历法则为基础求出实际天象验之:\n查,周幽王六年为公元前776年。用前节推算方法,先入蔀入年。知前776年入甲午蔀第32年。\n查《历术甲子篇》太初三十二年:前大余三十,小余二百九十七。\n甲午蔀蔀余是30,30+30=60(即0,即甲子)\n按正常推算,前776年子月甲子日297分合朔。因为四分历先天每年浮3.06分,实际天象应从前427年起每年加3.06分。\n即(776-427)×3.06≈1068(分)\n逢940分进一,得1.128\n1.128+0.297(小余)=1.425(日加日,分加分)\n得知公元前776年实际天象:子月乙丑(1)日425分合朔(平朔)。\n该年每月朔闰据此推算为\n子月乙丑425丑月甲午924\n寅月甲子483卯月甲午 42\n辰月癸亥541巳月癸巳100\n午月壬戌599未月壬辰158\n申月辛酉657酉月辛卯216\n戌月庚申715亥月庚寅274\n西周建丑为正,此年失闰建子,十月(酉)辛卯朔,吻合不误,足证《诗·十月之交》所记确为幽王六年事。\n例2.《史记·晋世家》:“五年春,晋文公欲伐曹,假道于卫,卫人弗许。……三月丙午,晋师入曹……四月戊辰,宋公、齐将、秦将与晋侯次城濮。乙巳,与楚兵合战……甲午,晋师还至衡雍,作王宫于践土。”\n晋文公五年为公元前632年,632-427=205(距前427年年数)\n205÷76=2……余53(年)\n76-53=23(算外24)\n该年入四分历第十三蔀(壬子蔀)第二十四年。\n查《历术甲子篇》太初二十四年:大余四十六,小余七百一十六。\n壬子蔀蔀余48+46(前大余)=94(逢60去之,34戊戌)\n四分历先天205×3.06=627(分)\n34.716+0.627=35.403(日加日,分加分。分数940分进一日。)\n得知,公元前632年实际天象:\n晋、楚用寅正,三月(辰)丁酉朔,丙午为三月初十。四月丁卯朔,戊辰为四月初二,己巳为四月初三,甲午为四月二十八日。历历分明。\n例3:《汉书·五行志》:“高帝三年十月甲戌晦,日有食之。”\n汉高帝三年为公元前204年(丁酉年)\n427-204=223\n223÷76=2……余71(算外72)\n该年入第十八蔀(丁卯蔀)第七十二年。\n查《历术甲子篇》七十二年:大余八,小余八十二。\n蔀余3+8(前大余)=11(乙亥)\n说明该年前十一月(子)乙亥朔。汉承秦制,在太初改历前的汉朝记事,都是起自十月,终于九月。十一月乙亥朔,则十月晦必为甲戌,正与《汉书》所记相同。\n为什么日食会发生在晦日呢?这是年差分造成的。如果求出实际天象,日食在晦就很容易得到解释。\n223×3.06≈682(分)\n11.082-0.682=10.340\n实际天象是,该年十一月(子)甲戌(10)日340分合朔。\n可见,因四分历行用日久,年差分积累过大,才发生日食在晦的反常天象。这从历法推算上显示得清清楚楚。\n例4:推算公元1981年实际天象。\n先看陈垣《二十史朔闰表》所列1981年朔日:\n十一月(子)甲寅十二月(丑)甲申\n正月(寅)甲寅二月(卯)癸未\n三月(辰)癸丑四月(巳)壬午\n五月(午)辛亥六月(未)辛巳\n七月(申)庚戌八月(酉)己卯\n九月(戌)己酉十月(亥)己卯\n十一月(子)戊申十二月(丑)戊寅\n用四分历推算,1981年入戊午蔀第52年。\n查《历术甲子篇》太初五十二年:大余四,小余四百一十。\n戊午蔀蔀余54+4(前大余)=58\n四分历先天(427+1981)×3.06=7369(分)\n7369÷940=7……余789\n“前加后减”,58.410-7.789=50.561(日减日,分减分)\n得知:1981年年前十一月甲寅(50)日561分合朔\n据此推演,1981年各月朔日如次。\n两相对照,所不合者正月、三月、八月,如果考虑到它们的分数,则相差不会超过半天。按四分历算,太初五十二年当是闰年,十三个月,这是据“十九年七闰”的成规;今天的阴历(夏历)置闰已不用旧法,闰在1982年。因为今天的阴历(夏历)早已不用平朔、平气,而使用定朔、定气,所以有上述的差别。\n这样的推算在今天虽然没有什么实用价值,但由此可以证实,如果考虑到年差分,修正四分历的误差,仍可以得出密近的实际天象,以上举例都说明了这一点。\n八、古代历法的置闰 # 世界各国现今通用的阳历,或称公历,是以一个回归年长度为依据的历法。一回归年是365日5小时48分46秒,相当于365.2422日。阳历以365日为一年,每年所余0.2422日,累积四年,大约一天。所以阳历每四年增加一天,加在2月末,得366日,这就是阳历的闰年。四年加一天又比回归年实际长度多了44分56秒,积满128年左右,就又多算一天,相当于400年中约多算三天。因此,阳历置闰规定,除公元年数可以4整除的算闰年外,公元世纪的整数,须用400来整除的才算闰年,这就巧妙地在四百年中减去了三天。这就是阳历的置闰。我国的农历,又称“阴历”,主要依据朔望月(月亮绕地球周期),同时兼顾回归年,实质上是一种阴阳合历。朔望月(从朔到朔或从望到望)周期是29.5306日。农历一年十二个月,一般六大六小,只有354日,比一个回归年少11.2422日。不到三年必须加一月,才能使朔望月与回归年相适应。这是用置闰办法来调整回归年与朔望月,使月份与季节大体吻合。中国古代历法的频繁改革,主要内容之一就是调配回归年与朔望月的长度,使之相等。简单地说,就是调整闰周,确定多少年置一闰月。《左传·文公六年》:“闰月不告朔,非礼也。闰以正时,时以作事,事以厚生,生民之道,于是乎在矣,不告朔闰,弃时政也,何以为民?”大意是说,置闰的目的是定季节,定季节的目的是干农活。君王的职责之一就是公告闰朔。如果违背这种制度,怎么治理百姓?\n由于置闰是人为的操作,历代对闰月的安排也就很不相同。已发现的殷墟卜辞中,武丁卜辞多有“十三月”的记载,祖庚、祖甲时代又有“多八月”“冬八月”“冬六月”“冬五月”和“冬十三月”的刻辞。“多”即“闰”,“冬”即“终”也就是“后”的意思。所以“多八月”“冬六月”即“后八月”“后六月”,也是“闰八月”“闰六月”的意思。“冬十三月”即“闰十三月”。卜辞里面,还有“十四月”的记载,古历称之为“再闰”,就是一年置两个闰月。殷周金文里面,“十四月”刻辞并不鲜见。周《金雝公缄鼎》“隹十又四月,既生霸壬午”,就是例子。到春秋时代,这种一年再置闰的情况就没有了。\n闰十三月,就是年终置闰。闰六月、闰八月,算是年中置闰。这说明古历置闰并无规律,这与回归年和朔望月的调配没有找到规律有关。在年、月、日的调配无“法”可依,没有找到规律之前,都是观象授时的时代。观象,主要是观天象,观察日月星辰的运动变化规律。比如昏旦中星的变化和北斗斗柄所指的方向,以此作为置闰的依据。因为是肉眼观测,不可能精确,只能随时观测随时置闰,所以古历多有失闰的记载。多置一闰,子正就成了丑正,少置一闰,丑正就成了子正。据《春秋》经传考证,到春秋中期古人就大体掌握了十九年七闰的方法。\n为什么十九年要置七闰呢?因为春秋中期之后,根据圭表测影的方法已初步掌握了一回归年的长度为36514日。有了这个数据,年、月、日的调配就有了可能。\n四分历由36514日推出朔望月长度(朔策)为29 日。\n十九年中要有235个朔望月才能与十九个回归年日数大体相等。即\n19×365.25≈235×29\n而一年十二个月的话,19×12=228(月),必须加七个闰月才能达到目的。这就是十九年七闰的来源。\n东汉许慎《说文》云:“闰,余分之月,五岁再闰也。告朔之礼,天子居宗庙,闰月居门中,从王在门中。周礼,闰月王居门中终月也。”此处“五岁再闰”就是五个回归年中要置两个闰月。“三年一闰,五年再闰”,这是较古老的方法。\n365.25×5\u0026lt;354×5+60\n1826.25\u0026lt;1830\n五年之中竟有近4天的差误,根本无法持久使用。十九年七闰的规律掌握以后,置闰就有“法”可依了。四分历规定十九年为一章,这个“章”法,就是反映置闰规律的。\n古法有“归余于终”之说,是将闰月放在年终,方便易行。春秋战国时代大多如此。齐鲁建子,闰在亥月后。晋楚建寅,闰在丑月后。秦历以十月为岁首,闰在岁末,称“后九月”。汉初一仍秦法,直至汉武帝太初改历,才改闰在岁末为无中气置闰。这个无中气置闰原则就一直行用到现在,只不过当今对中气的计算更细致更精确罢了。这就是《汉书·律历志》所谓“朔不得中,是为闰月”,闰月设置在没有中气的月份。\n《礼记·月令》注疏者说:“中数曰岁,朔数曰年。中数者,谓十二月中气一周,总三百六十五日四分之一,谓之一岁。朔数者,谓十二月之朔一周,总三百五十四日,谓之为年。”这里把岁与年区分得很清楚:岁是二十四节气(其中十二中气)组成的回归年,是太阳历;年是十二个朔望月组成的太阴年,是太阴历。中国农历是阴阳历系统,必须反映二十四节气和朔望月的配合关系。\n根据张汝舟先生的研究,中国最早的历法就是战国初期创制,行用于周考王十四年(公元前427年)的殷历(称“天正甲寅元”的四分历)。《史记·历术甲子篇》就是这一部历法的文字记录,《汉书·次度》是它的天象依据。《历术甲子篇》所列七十六年前大余,就是年前十一月(子月)朔日干支序数,前小余就是十一月合朔的分数;后大余是冬至日干支序数,后小余是冬至时分数。不难看出,前大余小余是记录朔望月的朔日的,是太阴历系统;后大余、小余是记录冬至(中气)干支及余分的,反映回归年长度,属太阳历系统。两相调配,《历术甲子篇》就是中国最早的一部阴阳合历的历法宝典。\n汉武帝太初改历,一改“归余于终”的古法,行无中气置闰。所谓中气,是指从冬至开始的二十四节气中逢单数的节气。依照《汉书·次度》记载,这十二节气正处于相应宫次的中点(冬至为星纪次中点,大寒为玄枵次中点,惊蛰——汉以后称雨水,是娵訾次中点……),故称中气。其他十二节气,则在各次的初始(星纪之初为大雪,玄枵之初为小雪,娵訾之初为立春……),如竹之结节,故仍称节气。\n因为一个朔望月(29.5306日)比两个中气之间的时间(365.2512日)距离要短约一天,如果从历法计算的起点算,过32个月之后这个差数积就会超过一个月,就会出现一个没有中气的月份,本应在这个月的中气便推移到下个月去了。若不置闰,后面的中气都要迟出一个月。长期下去,各个月份和天象、物象、气象的相对关系就要错乱。三次不置闰,春季就会出现冰天雪地的景象,深秋还是烈日炎炎,历法就失去指导农业生产的意义了。\n中国最早的历法——殷历,即《历术甲子篇》的无中气置闰与今天农历的无中气置闰大不相同。殷历用平朔平气,春夏秋冬一年十二个月均可置闰。从清代“时宪历”起用定气注历,至今未变,闰月多在夏至前后几个月,冬至前后(秋分到次年春分之间)则无闰月。这是因为春分到秋分间太阳视运动要经186天,而从秋分到春分间却只需要179天。日子一短,则节气间相距的日子就短,所以不宜设置闰月。\n我们知道,四分历的岁实是36514日,而平年六大六小,只用去了354日,每年尚余1114日。由于历元是取冬至日合朔,1114必是冬至(气)之余,也称“气余”。按每年余1114日计算,两年则余2224日,经三年则余3334日,就是说3334日之内无中气,所以第三个年头上就必须置闰了。这就是四分历安排置闰的依据。\n气余1114日每年递加,并无困难。由于第三年置闰,又有连大月,全年达384日,比平年(354日)多了30日。所以\n354+3334-384=334(日)\n334日为第三年实际气余。\n第四年再加1114,得15日,由于第四年七大五小,为355日,比平年多1日,所以\n15-1或354+15-355=14(日)\n14日便是第四年气余。\n根据这个办法我们可以将一蔀七十六年各年气余推算出来,也就可以据此考虑闰在某月了。\n前面说过,平年六大六小,每年气余1114日。若将它用一年十二个月平分,则每月气余0.9375日。这样以上年气余为基数,从该年子月开始逐月递加0.9375日,到某月超过30日或29日(小月),便知某月之后是置闰之月。\n如第三年当闰,以上年气余2224日为基数,从子月起逐月递加0.9375日,到第八个月便超过30日了。所以四分历是在第八个月之后置一闰月,夏历用寅正,从子月算起到第八个月,则闰六月。又如第十九年当闰,便以上年气余1912日为基数,从子月起逐月递加0.9375,到第十二个月超过了30日,便在此月之后置闰。\n《历术甲子篇》是通过后大余/后小余反映二十四节气的。后大余是冬至的干支代号,后小余是冬至时的分数。这个小余的分母是32(分),与前小余分数的分母是940(分)不同。为什么要化1/4为8/32?这是便于推算一年二十四节气。因为四分历是平气,冬至一定,其他节气便可逐一推出。\n即两个节气相距15日7分。分母化为32,才会除尽有余分7。从冬至日算起,顺次累加,可以算出一年二十四个节气的干支和气余。\n《历术甲子篇》只列出太初七十六年每年冬至干支及余分,我们可以据此排出七十六年各月的朔、气干支及余分。两个中气相距30日14分,置闰之“法”就反映在朔(前大余)与中气(后大余)的关系上。\n由于朔策数据是29499940,逢小月小余499分,逢大月小余减441分,中气的大小余推演从冬至起每月累加30日14分。\n由于《历术甲子篇》已列出每年年前十一月(子月)朔日及冬至的大小余,便可以从每年的十一月(子月)做起算点推演每月朔日与中气,我们以太初三年作推演示范。\n《历术甲子篇》的后大余是冬至日干支,二十四节气由此推演还好理解,太初三年置闰也很明确,只是从何知道必在六月(未月)之后置闰呢?\n这个闰六月是前大余四十四(戊申朔)与处暑十四(戊寅处暑)的关系确定下来的,戊申朔,处暑戊寅必在下月,则此月无中气,依无中气之月置闰的原则,闰在六月后就可以肯定了。\n所以说,《历术甲子篇》通篇的大余、小余有极其丰富的内容,二十四节气可由此推演,无中气置闰规则也包含其中。\n无中气置闰还有另一种推算方法。一岁36514日,以12除,得302148日。即两中气间隔302148日,上月中气加30日21分,得本月中气。到中气日期超过29或30,小月亦应置闰,中气就在下月初了。《殷历朔闰中气表》就是这样编制的,它的特点是,中气日期不用干支序数而用一月内日的序数,这就与蔀余不发生关系而自成系统了。\n这就是魏晋以前中国古代历法置闰的全部内容。\n九、殷历朔闰中气表 # 中国最早的历法,前人有所谓“古六历”之说——黄帝历、颛顼历、夏历、殷历、周历、鲁历,近人以为都是四分历数据。其实,“古六历”是东汉人的附会。汉代盛传所谓“天正甲寅元”与“人正乙卯元”,其间也有承继关系,人正乙卯元的颛顼历实是天正甲寅元的殷历的变种。所以,中国最早的历法就是天正甲寅元的殷历,就是以寅为正的真夏历假殷历,也就是四分历。历法产生之前,包括“岁星纪年”在内,都还是观象授时阶段。进入“法”的时代,就意味着年、月、日的调配有了可能,也有了规律,由此可以求得密近的实际天象——这是一切历法生命力之所在。\n根据张汝舟先生的苦心研究,《史记·历书·历术甲子篇》就是司马迁为我们保存下来的殷历历法,《汉书·次度》就是殷历历法的天象依据。利用这两篇宝贵资料,可以诠释上古若干天文历法问题。并推算出文献记载的以及出土文物中的若干历点。\n《历术甲子篇》记载了历元太初第一蔀七十六年的子月朔日及合朔分数(前大余、前小余)和冬至日及冬至时分数(后大余、后小余),即公元前1567年至公元前1492年的子月朔日、冬至日及余分。由于是“法”,自可以一蔀该二十蔀,贯通四分历法的古今。\n要推算任何一年的朔与气,必须将该年纳入殷历的某蔀第几年。“蔀”用“殷历二十蔀表”,“年”用《历术甲子篇》之年序。查得该年之前大余,加上该蔀蔀余,就得出该年子月之朔日干支。使用《殷历朔闰中气表》(见书后299页附表二)求各月朔日干支,就更为便捷,只要某月大余加上该年入蔀之蔀余就得该月之朔日干支。《殷历朔闰中气表》已将各年十二中气算出,中气依《次度》立名。表中“惊蛰”即汉以后之“雨水”,表中“清明”即汉以后之“谷雨”。\n由于四分历粗疏,“三百年辄差一日”,每年比实际天象约浮3.06分(940分进位)。要求出实际天象,必须考虑3.06这个年差分。张汝舟先生考订,殷历创制行用于周考王十四年(公元前427年)。所以必须以公元前427年(入己酉蔀元年)为准,前加后减。即前427年之前每年加3.06分,前427年之后每年减3.06分,方能得出密近的实际天象。\n殷历甲寅元一经创制行用,就成为中华民族的共同财富通行于当时各国,所不同者唯岁首和建正而已。四分历法则在当时是不可改变的,认为“战国时代各国历法不同”,没有充分根据。\n公元前427年以前的年份,虽未行用有规则的历法,但朔望在天,有目共睹,加以干支纪日延续不断,历代不紊,构成了历法推算的基础,只要年代确凿,考虑到建正、岁首、置闰等方面情况的不同,仍然可以用四分历推算。第六讲四分历的应用\n"},{"id":157,"href":"/zh/docs/culture/%E5%8F%A4%E4%BB%A3%E5%A4%A9%E6%96%87%E5%8E%86%E6%B3%95%E8%AE%B2%E5%BA%A7/%E7%AC%AC1%E8%AE%B2-%E7%AC%AC3%E8%AE%B2/","title":"第1讲-第3讲","section":"古代天文历法讲座","content":" 第一讲为什么要了解古天文历法 # 我国是世界上文明古国之一,先民出于农事需要,积累了丰富的天文学知识。随着文明的进化,这些丰富的天文学知识,必然反映到记载古代文化的书籍典册之中,遗留于后世。出土的殷商时代甲骨刻辞早就有了某些星宿名称和日食、月食记载。《周易》《尚书》《诗经》《春秋》《国语》《左传》《吕氏春秋》《礼记》《尔雅》《淮南子》等书更有大量的详略不同的星宿记载和天象叙述。《史记·天官书》《汉书·天文志》更是古天文学的专门之作。文史工作者随时接触古代典籍,势必常与古代天文历法打交道。如果对此一知半解或不甚了了,很难谈得上进行深入的研究。就是一般爱好文史的青年,有一定的古天文学知识,对阅读古书也是大有帮助的。\n常识告诉我们,一切与古代典籍有关的学科,无不与时间的记载,也就是古代天文历法有关。清人汪日桢说:“读史而考及于月日干支,小事也,然亦难事也。欲知月日,必求朔闰;欲求朔闰,必明推步……盖其事甚小,为之则难。不知推步者,欲为之而不能为;知推步者,能为之而不屑为也。”(见《历代长术辑要》载《二十四史月日考序目》)可见,古人深知“推步”的重要和“推步”的甘苦。白寿彝教授也指出:“关于时间的记载,是历史记载必要的构成部分,年代学的研究是历史文献学研究的主要课题。”(《人民日报》,1980年12月30日)\n当今的现状是,有关古天文之学众说纷纭,头绪繁杂,令人不知从何下手,欲读不能。一般著述往往博大疏浅,叙史而已,或者演算繁难,玄秘莫测,“不把金针度与人”。读者终书,竟无法找到打开古天文历法大门的钥匙,未免望之兴叹,视为畏途。此篇以基本的天文常识入手,依据本师张汝舟先生星历观点,深入浅出,意欲将古籍中需要涉及的古天文学问题,逐一展开讨论,希望能对校读古籍有所助益,且能由一般文史工作者自行独立推演年月日时,掌握一套基本的“推步”技术,为深入的研究打下扎实的基础。\n一、时间与天文历法 # 中国古代,合天文历法为一事,历法以天象为依据,历法属于实用天文学的重要内容。所以,中国古代文学与年、月、日、时这些时间观念紧密相依。学习古代天文学,就从认识“时间”这个概念开头吧!\n中央人民广播电台每日整点都发出“嘟——嘟——”的时间讯号,以此统一全国民用时间。全国各行各业都按这个统一的标准时间学习和工作。没有统一的时间观念,一切工作都无法正常进行,社会将发生混乱。可知,人类社会对于时间的首要要求,就是有统一的计量标准,不能各自为政,自行其是。远古时代,人类分为若干互不交往的群体,各有自己的一套计时方法。随着社会的进步,交流的频繁,彼此认识到生活在地球这个大家庭里,还必须有统一的国际标准时间来协调全人类的活动,才能促进社会的更大发展。\n在古代,人们对于时间的精确度要求不高,最早是把一天分为朝、午、昏、夜四个时段,后来又分为十个时段、十二个时段,也就大体够用了。随着生产力的发展,要求时间的精确度越来越高。现代科学技术,更要求计量时间不能有一秒的误差。测定人造卫星的位置,如果误差1秒,就有7~8公里的差距。精密的电子工业,无线电技术,运输通讯,卫星、导弹的发射,要求的精确度都很高。因此,现代生活要求有精确的统一的时间计量标准,指导全人类的生产劳动。\n时间不是人的主观臆造。时间是客观存在的与物质运动紧密相连的一种物质存在的形式。人们只能依据物质的运动来规定时间,寻找计时的单位。\n我国古代,先民以太阳东升西落确定一天的时间,单位是日;以月亮的隐现圆缺定一月的时间,单位是月;以寒来暑往及草木禾稼的荣枯定一年的时间,单位是年。远古时代人们的时间计量单位之所以仍有作用,今天还在指导着人们的活动,就在于完全符合人类对时间计量方法的基本要求:既承认时间是物质存在的形式,又以有规律的、匀速的、周而复始的运动形式作为计量标准。这种从不间断的、匀速的、重复出现的物质运动形式,在人们的周围是存在着的,这就是日月星辰的出没所组成的若干天文现象。时间计量单位的确定完全以天象为依据,就是这个道理。尽管上古先民长期坚持“地心说”,认为日月星辰都在围绕着地球转动,但这种周而复始的物质运动形式却是古今一致的。\n在所有的计时单位中,人们把地球自转一周作为计时的最基本单位——日,古人认为是太阳东升西落绕了地球一圈。月、年是比日更大的计时单位。时辰、小时、刻、分、秒,是比日小的计时单位。时、分是日的分数,古人称为日之余分。\n明确了时间的计量单位,还有一个时段和时刻的问题。换句话说,通常所谓“时间”,包含着两个含义:一是指某一瞬间,即古人所谓“时刻”;一是指两个瞬时之间隔,即一个有始有终的长度。从时刻的含义出发,时间有早迟之分。从时段的含义出发,时间有长久与短暂之别。历法中的节气与节气的交替(交节),月亮运行在太阳、地球之间的平面上成一直线的天象(合朔),日与日的交接(夜半0点整)等都应该是指时刻而言,十分确切,具体到某时几分几秒的那一瞬间,毫无含糊。月亮最圆的时间,与合朔时间一样只有那么一瞬时。差一秒还不是最圆,过一秒也不可能最圆。电台报时的“嘟——嘟——”那最后特殊一响,就是时刻概念的具体化。而平常所说的几分、几小时、几日,都是指的一个时段,它必有一个起算时刻。计时的基本单位——日,是从夜半0点起算的,止于24点整。任何一个更长的时段,比如百年、千年,都必须明确它的起算时刻。任何历法都很强调它的起算点,都希望找一个理想的起算时刻作为它的初始,这就是历法之“元”,称“历元”。\n我们的先民,十分重视时间,特别是与农事有关的天时,古籍中记载特多。其实,古人的“天时”,是指一年四季包括风、雨、雷、电等直接关系农事活动的自然现象,古人认为这些是上天主宰的,所以称为“天时”。\n《孟子》云:“不违农时,谷不可胜食也。”\n《荀子》云:“春耕、夏耘、秋收、冬藏,四时不失时,故五谷不绝而百姓有余食也。”\n《韩非子》云:“非天时,虽十尧不能冬生一穗。”\n《吕氏春秋》有:“夫稼,为之者人也,生之者地也,养之者天也。是故得时之稼兴,失时之稼约。”\n《齐民要术》有:“顺天时,量地利,则用力少而成功多,任情返道,劳而无获。”\n《农书》有:“力不失时,则食不困。……故知时为上,知土次之。”\n这些典籍中所谓“时”“天时”,实际是指关系农事成败的气候。气候的变动,与时令的推移有关,也直接与天象关联着,所以也应视为古代天文历法的内容。\n《说文解字》云:“时,四时也。”指的是春夏秋冬四季。据吴泽先生的研究,在殷墟甲骨文中,已出现春夏秋冬四字。春字字形像枝木条达的形状;夏字字形一像草木繁茂之状,一像蝉形,蝉是夏虫,被认为是夏的象征;秋字像果实累累,谷物成熟,正是收获之时;冬字则形如把谷物藏于仓廪之中。这四个字,都与农业有关。春种、夏长、秋收、冬藏,季节、时令都同农事密切相关。\n时间,关系到人类社会的政治、生产、生活等各方面的活动。自古以来,我们的祖先就十分重视年、月、日、时的安排,创制了多种多样的历法;对各项活动发生的年、月、日、时也做了大量的准确记录,保存在浩如烟海的典籍之中。古史古事就靠这些年、月、日、时的记载有了一个清晰的脉络,我们据此研究古代人类社会生活的各个方面。如果没有年、月、日、时的记载,众多的典籍史料就成了一堆杂乱无章的文字记录,其价值也就可想而知。中国古代大量珍贵史料就是靠年、月、日、时的记载而保存下来的。我们还可以用后代的历法依据古籍中年、月、日、时的记载推演出当时的实际天象,解决历史上若干悬而未决的年代问题。如果没有关于时间的文字记载,这种推算也就无法进行。\n二、天文与历法 # 什么是天文?什么是历法?这是首先应该弄清楚的问题。\n《说文》云“文,错画也。象交文”,又说“仰则观象于天”。高诱注《淮南子·天文训》说:“文者象也。天先垂文象日月五星及彗孛,皆谓以谴告一人。故曰天文。”王逸注《楚辞》“象”字云“法也”。《易·系辞》:“天垂象见吉凶,圣人则之。”可见,天文就是天象,就是天法,就是日月星辰在天幕呈现的有规律的运动形式。它不以人的意志为转移,反而影响着支配着人类的各种活动。正因为这样,远古的人就视之为神圣,把天象看成是上帝、上天给人的吉凶预兆,敬若神明。历代君王重视天文,因为它是上天意旨的体现,它直接关系着人类的生产、生活,影响帝王统治权力的基础。\n繁体曆法之曆,最早的写法是秝,后写作、厤,再后写作曆。《玉篇》曰:“稀疏秝秝然。”段玉裁以为:“从二禾,禾之疏密有章也。”《说文》释:“厤,治也。”“,和也。”《释诂》释:“厤,数也。”从这些释义看,就是均匀调治之义。从二禾,禾的生长受日月星辰运行的天象支配,即受日月运行所确定的季节的支配,所以秝、厤与天象有关。\n秝,古书写作,表示人在有庄稼的地里行走,引申为日月运行及日月运行所确定的季节、时令等时间计量。首先,这种运行是有规律的,“疏密有章”;其次,还需要调治,要均匀地调治,使日月运行的时日彼此协调。所以,秝就是均匀地调治天象所显示的年、月、日、时等计量时间单位的手段。\n《史记·历书》以厤为推步学,以象为占验学,把两者的区别说得清清楚楚。占验,当然指天象,指上天通过天象显示给人们的吉凶预兆。推步,就是对日月星辰,主要是日月的运行时间进行计算,使日绕地球一圈所形成的寒暑交替与月绕地球一圈所呈现的圆缺隐现彼此配合得大体一致。这就是制历,也就是推步学。\n历是什么,简单说就是计量年、月、日的方法,就是年、月、日的安排。这种安排、计量的依据是天象变化的规律,是依据日月星辰有规律的运行来确定年、月、日、时和四季、节气,或者说推算天象以定岁时。作为一种纪时系统,目的只能是服务于人类的生产生活。\n一般将历法之“法”,解释为制历的方法。不对。这个“法”,正如语法之“法”,指法则、规律。远古时代的夏商周,当然有它的年月日安排的方法,虽然还比较粗疏,但还有它那时的“历”以指导人的社会生产活动。这种历是否成“法”呢?如果确定一年为“三百有六旬有六日”(《尧典》),是不可能有规律地调配年月日的,还形不成“法”。只有到春秋中期以后,测量出一回归年为36514日,到战国初期创制、行用四分历,才可能有“法”可依,才称得上有了历法。有历法之前,都是根据天象的观测,调整年月日,随时观测,随时调整,这还是观象授时的时代。到了有“法”可依的时代,就有可能将天象的数据抽象化,就有可能依据日月星辰运行的规律,通过演算,上推千百年,下推千百年,考求、预定年、月、日、时。我国最早的一部历法——四分历,就具备了这种条件。 可见,历与历法不能混为一谈。什么是历法呢?历法就是利用天象的变化规律调配年、月、日、时的一种纪时法则。\n历法与天象那么紧密不可分,正是我国古代历法独具的特点。在我国古代,历法就包含在古天文学之中,历法是古代天文学中一个很重要的领域。历法的普遍内容包括节气的安排,一年中月的安排,一月中日的安排以及闰月安插规则,等等。我国古代历法还有关于日食、月食的预报和五大行星运行的推算。总之,离开天文就无所谓历法,历法反映了大量的天文现象,历法中有丰富的天文学内容,历法就是古天文学的一个部分。我国古代合天文、历法为一事,就是这个道理。同样的原因,古人称天文历法为历算、星算、天算、星历……总是将天文、历法合在一起加以表述。\n历法的内容,一部分属于实用天文学的范围,另一部分属于理论天文学的范围。测时与制历就是天文学为生产服务的主要工作。我国古代历法重视对天象的推算,不仅反映了对天文学的重视,也常常以此来考核历法的准确性。古代历法史上的多次改革,其直接原因之一就是由于日食等天象的预推出现了差误。从一定程度上来说,我国古代的编历工作,也就是一种编算天文年历的工作。由此可见,我国古代天文学家何等重视实践与理论的结合。\n正因为这样,当我们谈到古代天文学,那实际已经包括了古代历法的内容。\n三、天文常识 # 人类社会各个民族生活的地域不同,星象与季节的相应关系也不同,但是用天象定岁时都是共同的。古代埃及人重视观测天狼星,因为每年天狼星与太阳一起升起的时候,就预示着尼罗河要泛滥,而尼罗河泛滥带来的肥沃土壤,正是埃及人播种的需要。我国上古的夏朝,重视参宿三星的观察,每年三星昏见西方,就意味着春耕季节的开始,参宿就成了夏族主祭祀的星了。晚起的商族,着重观察黄昏现于东方地平线上的亮星,看中了心宿三星,最亮的心宿二就是“大火”。大火昏见东方,也正是春耕季节播种的日子。大火就成了商族主祭祀的星。所以《公羊传·昭公十七年》载:“大火为大辰,伐为大辰,北极亦为大辰。”何休《公羊解诂》云:“大火谓心星,伐为参星。大火与伐,所以示民时之早晚。”这里所谓“大辰”,就是观察天象的标准星,均指恒星而言。大火为大辰,是就商代而言;伐为大辰,是就夏朝而言;北极亦为大辰,当指以北极星为观察天象的标准的更古时代。于此可见,我国上古对于北极星的认识,起源更早。\n现代天文学知识告诉我们,在太阳系里有水星、金星、地球、火星、木星、土星、天王星、海王星共八大行星围绕着太阳,按照各自的轨道和速度运行着。——古人凭肉眼观测,以地球为中心,早就认识了五大行星(金、木、水、火、土)并了解到它们绕地球一圈的时间,掌握了它们的运行规律。\n地球绕太阳公转的同时,还在自转。公转一周为365.24219日,自转一周为24小时。由于地球自转轨道与公转轨道有23°26′的倾斜角,地球表面受到太阳照射的程度不同(直射或斜射,斜射还有角度的不同),便有了春夏秋冬四季冷暖的变化。\n月球是地球的卫星,它围绕着地球旋转,运行一周为29.53059日,月球本身不发光,人们所见到的月相是月球对太阳光的反射。随着地球、月球与太阳相互位置的变化,月相也周期性变化着。当月亮的背光面对着地球,人们看不到有光的月面,即为朔日(阴历初一);当月亮的受光面全部对着地球,人们看到一轮满月,即为望日(阴历十五)。从朔日到望日,望日到朔日之间还有各种月相。人们根据月相变化和月亮出没时间,便知道阴历的日期。俗话说:“初三初四蛾眉月,初七初八月半边,十五十六月团圆。”这种以月相变化为依据,从朔到朔或从望到望的周期长度,叫朔望月,就是阴历的一个月。\n每一个朔望月,月球都要行经地球和太阳之间的空间一次,如果大体在一个平面上,月球遮住了太阳射向地球的光线,就会发生日食;当地球运行到太阳和月球中间(每月有一次机会),如果大体在一个平面上,地球就会挡住太阳射向月球的光线,就要发生月食。因此,日食总是发生在朔日,月食总是发生在望日。古人特别重视日食的记载,认为是上天对君主的警告,是凶兆。古代天文学家还以日食检验历法的准确性,食不在朔,便据以调历。\n前人是怎样以地球为中心表述日食、月食这些天象的?我们用曾运乾先生《尚书正读》注文来回答这个问题,至少可以给我们一些启发。注云:当朔而日为月所掩,是为日食。当望而月为日所冲,是为月食。又说,古人制字,“朔”“望”“有”均从月得义。朔字从月从屰(屰,不顺也)。月与日同经度而不同纬度,则相屰而为合朔。若同经度而又同纬度,则相屰而为日食。望,为月食专字。从月从壬(壬,朝廷也),取日月相对望也。从亡,遇食则有亡象焉。有,为日食专字。从月,月光蔽其明也。从又,一指蔽前,泰山不见也。则知日月食之由于蔽也。《说文》:“有,不宜有也。春秋传曰,日月又食之。从月又声。”段氏注云:“谓本是不当有而有之称,引申遂为凡有之称。”\n古代先民只是直观地以地球为中心来观测天体的运行,这就是西方科学未传入中国之前我国古代长期行用的地心说。日月星辰的东升西落,实际是因为地球从西向东在转动。这种地心说并非全无道理。比如上和下,是一种比较的说法。在地球上的上与下,其实都是在和地球中心比较,拿地球中心做标准来比较是有道理的。舍此,就无所谓上与下。同样,国际通用的标准时自有好处,而各个地方时更为各地的使用者称便。道理都一样,地心说对观测者似更方便。古人想象,地球四周被巨大的天球包围着,所有的日月星辰都在天球上运行。太阳系八大行星,古人凭肉眼观测,以地球为中心,只能见到金、木、水、火、土五大行星,并掌握了它们各自绕地球一圈的时间及运行规律,记之甚详。古代典籍关于天象的记载,立足于地心说。古代星图、天球仪之类也据此成象。阅读古籍者不可不知。\n四、历的种类 # 人类对天象进行观测以确定计时标准,其中观测的主要对象是日、月的运行,依据日、月的运行周期以制定各自的历法。迄今为止,世界上的历法可分为三类:太阴历、太阳历和阴阳合历。\n甲,太阴历。它是以月球受光面的圆缺晦明变动为基础,利用月球运行周期(朔望月)为标准制定的历法。月亮运行的周期是29.53日,太阴历就用大月(30日)、小月(29日)相间,一大一小来调整。因为每两月有0.06日盈余,还需要配置连大月才能保证月初必朔,月中必望。太阴历以十二个朔望月为一年计算,共354日或355日。它把月相与日期固定地联系在一起,见月相而知日期,知日期亦知月相。这在上古,无疑给人们的生产和生活带来方便。其致命的弱点是,十二个朔望月(平年354日)与太阳的运行周期(即回归年长度365.2422日)不相吻合,太阴历每年与回归年有11日多的时差,积三年就相差34日。这就必将搅乱月份与回归年长度确定的春夏秋冬四季的关系,冷暖四季与月份的关系错乱,又会给人们的生产、生活带来困难。\n从古代历史记载得知,世界上最早制历的国家都首先使用过太阴历,因为月球的盈亏变化对人类而言较为明显而又亲切。上古时代,日苦其短,年嫌其长,月的周期最能适应宗教仪式的需要,朔望月自然就占有了重要的地位。\n伊斯兰教用于祭祀节日的回回历就是现存的唯一纯太阴历。回历以公元622年7月16日,即穆罕默德避难麦加的次日为元年元日,以朔望月计,十二月为一年,每月以月牙初见为第一日,单月30日,双月29日,大月小月相间,全年354日,不置闰月。由于十二个朔望月共354日8时48分34秒,每年多出8小时有余,积三年就多出一天有余。所以,回历每三十年共置十一个闰日。在三十年中,第2、5、7、10、13、16、18、21、24、26、29年为闰年,每年355日,闰日放在十二月。\n由于太阴历和回归年的日差,回历的岁首和节日(如肉孜节、古尔邦节)寒暑不定,便是可以理解的了。\n陈垣先生《二十史朔闰表》附有回历与公元历、阴历的日期对照,便于检查。\n乙,太阳历。它是以太阳的回归年周期为基本数据制定的历法。欧洲太阳历是古罗马恺撒在公元前46年请埃及天文学家索西琴尼斯协助制定的,世称“儒略历”或“旧太阳历”。当时测得的回归年长度为36514日。因此,儒略历规定,每四年中前三年为平年365日,第四年为闰年366日,即逢四或逢四的倍数的年份为闰年。一年十二个月,单月为大月31天,双月为小月30天。起自3月,终于2月,与月相完全无关。因为罗马帝国每年2月(年终)处决犯人,视为不吉,所以减去一日,平年只有29日,闰年为30日。又因为恺撒养子屋大维(奥古斯都)生于8月(小月),又从2月减一日加到8月,变8月小为8月大(31日)。这样,2月即为28日(闰年为29日)。为了避免由于2月小、8月大而造成的7月、8月、9月三个月连大,又改为7月、8月连大,9月、11月为小月,10月、12月为大月。这都是人为的规定。\n公元325年,罗马帝国召开宗教会议,决定统一采用儒略历,并依据当时的天文观测,定3月21日为春分日。\n回归年长度为365.24219日,即365日5时48分46秒。而儒略历是以36514日,即365日6时为数据制定的。两者有11分14秒之差,长期积累就会形成明显误差(128年差1日),这在当时并不为人所知。到公元1582年,人们发现春分点竟在3月11日,与公元325年的春分点相差十日之多,即1258年间(325—1582)间差十日,相当于每400年误差3日。为此,罗马教皇格里高利十三世只好召集学者研究,改革儒略历,采取每400年取消3闰(即400年97闰)的方法,规定把1582年10月4日以后的一天算为1582年10月15日,所有百位数以上的年数能被400除尽者才能算闰年(如1600年,2000年)。这样,一方面纠正了儒略历的误差,另一方面又提高了太阳历的精度。改革以后的儒略历称为格里历,其精确度很高:\n365×400+97=146097(日)\n146097÷400=365.2425(日)\n格里历这个回归年长度365.2425日比现代实测回归年长度只有0.0003日(即近26秒)之差,积累3320年才会有一日的误差。这对日用历来说,已是十分精确的了。\n我国元代郭守敬至元十八年(公元1281年)制定的“授时历”,其回归年长度已达到365.2425日的精确度,比格里历早了三百年。\n当今世界通用公元纪年,共同使用的就是格里历。而公元纪年并不开始于公元元年,而是开始于公元532年(据说基督就诞生在公元532年之前,532年正是我国南朝梁武帝中大通四年)。这是出于宗教的考虑。因为532这个数字正是星期日数7、闰年周期4和所谓月周(即一定历日的时间地球上看到月面形状变化的周期)19(年)的最小公倍数。每过532年,基督教的节日(比如复活节)又会是同一日期、星期和月相。因此,公元532年之前的公元纪年都是后来逆推而定的。\n太阳历以回归年周期为依据,四季与月份的关系稳定。中国古历形成的二十四节气就比较固定地配合在太阳历的一些日子里。\n埃及人在远古时代曾一度使用太阴历,后来因为尼罗河涨水对生产影响极大,需要预报涨水时期,而尼罗河水涨和夏至是在天狼星出现的第一天早晨同时来到。古埃及人知道太阳在天球上的运行与尼罗河的洪水期有关,所以特别注意太阳在一年中各时期的高度,以及日出、日没时间和方位。同时还精密地观测了天狼星及南河三等恒星的周年运动,发现了太阳运行周期——回归年长度为36514日。在公元前2000多年古埃及人就制定了以365日为一年的太阳历,而放弃了太阴历的使用。\n格里历所代表的太阳历也有不便之处,一是每月天数不统一,二是完全排除了月相周期。因此,历法研究者曾提出不少改革格里历的方案,有代表性的方案有两种。\n第一种方案。把一个回归年分为十三个月,每月28日,四个星期,唯独第十三个月为29日(闰年30日)。年终可多休息几日,很便于掌握。这种方案的缺点是无法安排习惯上常用的春夏秋冬四季。\n第二种方案。每年分四个季度,十二个月。每季度第一月为31日,其余两月各30日,共91日,一季度十三周。每季度的头一天为星期日,每季度最后一天是星期六。上半年和下半年各为182日。四个季度加起来364日,剩下一日安排在年末,不列入星期,也不列入日期名称,算做国际新年休息日。闰年多出的另一天安排在6月30日之后,作为休息日,也不列入星期和日期名称。这是1923年国际联盟在日内瓦设立的“修订历法委员会”提出来的方案。几十年来,已有比较一致的肯定意见。这个方案比现行日历优越,不但大月、小月、星期有规律,而且每年十二个月可以平分、三等分、四等分和六等分,便于计划、统计和比较。\n丙,阴阳合历。太阳历仅注重太阳的运行(实际是地球运行所产生的视动),完全与月球的运行无关;而太阴历则只注意月亮的运行,不涉及太阳的回归年长度,这就使得四季变化没有一定的时间,于生产十分不便。于是又有了折中办法,即阴阳合历。所谓折中,就是将太阳历与太阴历结合起来制历,用设置闰月或用其他计算法以调和四季,使季节能近于天时,便利农事。阴阳合历既照顾月相周期,又符合四季变化。\n我国上古自有文字记载以来,一直使用阴阳合历,这正是中华民族“文明”的标志,也正是我们要探讨的主要问题。因为回归年、朔望月和计时的基本单位——日,始终不是整倍数的关系,年与月无法公约。如何调整年、月、日的计量关系,便是提高阴阳合历精度的关键,也是我国千百年来频繁改历的主要原因之一。\n世界上几个文明古国,在上古时代使用的历法,比如希腊历、犹太历、巴比伦历、印度历以及我们的中国古历,可以说都属于阴阳合历的范围。\n以上是就一般历法分类说的。此外,还有一些特殊的历法。比如非洲古国埃塞俄比亚的纪年法与计时法,就与世界通用的不同。埃塞历一年为十三个月,前十二月每月30天,第十三个月平年为5天,闰年为6天。埃塞历的新年在公历的9月11日。埃塞历比公历纪年迟7年8个月10天。埃塞历的计时法,以每天早晨的6点为0时,每天也是24小时。\n此外,还有信奉佛教的国家缅甸,它的历法以开始出现月亮到形成满月之间的时日算做一个月。也就是说,一个月只有两个星期,一年有二十四个月。这个“月”,自然与朔望月不是一回事。\n埃塞历也好,缅历也好,都只能划归太阳历一类,因为它的“年”是符合回归年长度的。\n五、古天文学与星占 # 原始社会时期,生产力十分低下,面对风雨雷电等各种无法解释的自然现象,初民都视之为“神”,认为那是上天的旨意。在没有完全认识自然规律之前的蒙昧时代,以预卜吉凶祸福为目的的星占神学就得到迅速发展,并控制着初民的整个思想领域。天幕上的日食、月食,五大行星的运行,流星、彗星、极光、新星等天象被看作是上天给人的启示或警告。这些天象的发生,也就为星相家所详细记录。在星占学盛行的时代,天文学自然是它的附庸,并成为一种保密的学问,变成支持星占神学的皇室的专有品,由皇家的专门机构如钦天监等把持,甚至规定不准私习天文。我国历代编写的《天文志》,除了讲述星区的划分,解释对宇宙的看法外,充斥了大量的星占学内容,道理就在这里。正因为这样,要了解天意,要利用将要发生的天象作出吉凶祸福的准确预报,就必须对天象作大量的观测、研究,以掌握它的运行变化规律。\n中国古代星占家,不仅观察日月五星的运行,而且还计算它们的运行周期,决定年、月、日、时。这样,客观上就为制历提供了数据。所以,著名的星占家往往就是有成就的天文学家,也就不足为怪了。司马迁《史记》以历为推步学,以象为占验学,正反映了天文与星占的关系。\n据《魏书·崔浩传》载,北魏拓跋氏天文学家崔浩,作过一次被认为“非他人所及”的神占。原文是:\n姚兴(后秦政权)死之前岁(公元415年姚兴死)也,太史奏:荧惑在匏瓜星中,一夜忽然亡失,不知所在。或谓下入危亡之国,将为童谣妖言,而后行其灾祸。太宗(拓跋嗣,明元帝)闻之,大惊。乃召诸硕儒十数人,令与史官求其所诣。浩对曰:“案《春秋左氏传》说,神降于莘,其至之日,各以其物祭也。请以日辰推之,庚午之夕,辛未之朔,天有阴云,荧惑之亡,当在此二日内。庚之与未,皆主于秦,辛为西夷。今姚兴据成阳,是荧惑入秦矣。”诸人皆作色曰:“天上失星,人安能知其所诣,而妄说无徵之言。”浩笑而不应。后八十余日,荧惑果出于东井,留守盘旋。秦中大旱,赤地,昆明池水竭。童谣讹言,国内喧扰。明年,姚兴死,二子交兵,三年国灭。于是诸人服曰:非所及也。\n从天文学的角度看,崔浩不过根据荧惑(火星)的顺、留、逆行,预报了一次火星的运动而已。以星占说附会之,就带上了极其神秘的色彩。\n实际的天象观测受到星占学的束缚,作为实用天文学的古代历法也一样不能摆脱星占学的桎梏。历法的基本数据与理论,都必须符合星占学的意识,星占家都要对之做出他所需要的歪曲解释。如汉代行用过的八十一分法,这不过是四分历法的一种简化形式,它是取朔望月 日为月法,并无神秘之处。在星占盛行的汉代,其解说却令人头晕目眩。刘歆说:\n元始有象一也,春秋二也,三统三也,四时四也,合而为十,成五体。以五乘十,大衍之数也,而道据其一,其余四十九所当用也。故筮以为数,以象两两之,又以象三三之,又以象四四之。又归奇象闰十九,及所据一,加之。因以再扐两之,是为月法之实。如日法得一,则一月之日数也。\n把这段神乎其神的文字用数学公式写出来就是\n星占术的依据是阴阳五行说。阴阳五行说可分为阴阳说和五行说两种,但五行说必含阴阳,而阴阳必含五行。阴阳说以阴阳二气的相对势力为天地万物生成的基础。五行说是以木、火、土、金、水五种物质形式作为构成天地及各种变化规律的基础。阴阳五行说在古代发展为指导人类行为的基本原理,联系着政治、军事、农业、星象乃至伦理、艺术、宗教等各个领域,几乎成了各种学科的总枢,到汉代尤为盛行。董仲舒治公羊之学,刘向治穀梁之学,刘歆治左传,都以阴阳五行为说。《史记·天官书》云:“天有五星,地有五行。”是以地上的五种物质形式相配天上的五颗行星,仅有对照的含义。《汉书》首创《五行志》以日食星象说灾异,算是阴阳五行说渗透天文学的一次总结。其余医卜星相,无不以之为原则。\n《淮南子·天文训》把天地万物,日月星辰的起源,都以阴阳五行说作解释:“宇宙生气,气有涯垠。清阳者,薄靡而为天;重浊者,凝滞而为地。……天地之袭精为阴阳,阴阳之专精为四时,四时之散精为万物。积阳之热气生火,火气之精者为日。积阴之寒气为水,水气之精者为月。日月之淫为精者为星辰。天受日月星辰,地受水潦尘埃。”据此,日叫太阳,月叫太阴,也就明白了。星辰是从日月溢出的气的结合物,则行星与众星不过是阳精与阴精的不同量的结合罢了,这如同“四时之散精为万物”一样。\n《淮南子》以后,对五行的运用更为广泛,五帝、五方、五色、五音、五味、五脏、五数、五器……凡以五为一组的事物都配以五行。就是四时、四方、四相,也牵合为五时、五方、五兽(《天官书》四相加黄龙),以顺应五行之说(见下页表),足见五行说之无孔不入。\n天有五星,地有五行,人有五德(仁、义、礼、智、信)。天、地、人三界是彼此影响、相互关联着的。天上的木星有了异象,地上的木和人心的仁都会有异象发生;天上的土星有了异象,地上的土及人心的礼都会产生变异。星占术就以此为基础解说天象,预卜人世祸福。\n星占术以肉眼能见的五大行星为主要观测对象,五星在古代又各有不同的名称,这些不同的称号又来源于长期的实际观测。\n木星。古称岁星,春秋时代曾用以纪岁。古人已知木星十二年运行一周天,一年行一次,故有岁星纪年之说。\n火星。名荧惑,因为它的光度常有变化,顺行逆行使人迷惑,难以掌握。\n土星。名镇星,古人以为它二十八年运行一周天,一年行一宿,如同二十八宿坐镇天上。\n金星。名太白,因为它光辉夺目,是天球上最明最白的一颗星。\n水星。又名辰星,因为它距太阳最近,相距不及一辰。\n五星的名称,足以反映古人对行星观测的精细及勤劬。古人又观察到木星色青,火星色赤,土星色黄,金星色白,水星色灰,便以五色配五行,这不能不说是天文学的发展丰富了阴阳五行说的内容。\n阴阳五行说,起源于殷代,盛行于汉魏,流传至唐宋,宋代理学家谈性理,奉阴阳五行说为金科玉律。影响及今,中医理论及占卜原理都以阴阳五行为说。\n要之,阴阳五行说是星占的依据,星占与古天文学又密切难分。要想对古天文学有正确的认识,不能不对阴阳五行说有个大致的了解。\n由于阴阳五行说的泛滥,天干地支也蒙上五行说的尘埃,要通读古籍就不可不知它们的关系。\n五行配干支(纳音五行):\n甲子乙丑海中金,丙寅丁卯炉中火。\n戊辰己巳大林木,庚午辛未路旁土。\n壬申癸酉剑锋金,甲戌乙亥山头火。\n丙子丁丑涧下水,戊寅己卯城头土。\n庚辰辛巳白蜡金,壬午癸未杨柳木。\n甲申乙酉泉中水,丙戌丁亥屋上土。\n戊子己丑霹雳火,庚寅辛卯松柏木。\n壬辰癸巳长流水,甲午乙未沙中金。\n丙申丁酉山下火,戊戌己亥平地木。\n庚子辛丑壁上土,壬寅癸卯金泊金。\n甲辰乙巳覆灯火,丙午丁未天河水。\n戊申己酉大驿土,庚戌辛亥钗钏金。\n壬子癸丑桑柏木,甲寅乙卯大溪水。\n丙辰丁巳沙中金,戊午己未天上火。\n庚申辛酉石榴木,壬戌癸亥大海水。\n六、古代天文学在阅读古籍中的作用 # 古代天文历法在研究古代科技史、古代历史、文物考古等方面均有实用意义,这里谈谈它在指导我们阅读古代典籍中的作用。\n清儒有言,不通声韵训诂,不懂天文历法,不能读古书。粗看起来有点夸大其词,细加思忖,不无道理。\n我国是世界文明古国之一,也最早进入人类社会的农牧业时代,对与农耕生活紧密相关的天文学的研究,自然源远流长。先民在这方面积累了极其丰富的知识,并将它广泛地应用到社会生活的各个领域,这在古代典籍中得到了充分的反映。明末大学者顾炎武在《日知录》卷三十里说:\n三代以上,人人皆知天文。“七月流火”,农夫之辞也。“三星在户”,妇人之语也。“月离于毕”,戍卒之作也。“龙尾伏辰”,儿童之谣也。后世文人学士,有问之而茫然不知者。\n古代典籍是古代社会生活的真实记录或艺术化的再现,这就必然要涉及古代天文历法的内容。古代大量的神话传说、民间故事,都源于古人的天文学知识。先民世代相传,不断丰富,一经文人的妙笔加工,就成为我国古代文化遗产的重要组成部分。\n跂彼织女,终日七襄。虽则七襄,不成报章。睆彼牵牛,不以服箱。东有启明,西有长庚。有捄天毕,载施之行。维南有箕,不可以簸扬。维北有斗,不可以挹酒浆。维南有箕,载翕其舌。维北有斗,西柄之揭。(《诗·小雅·大东》)\n诗人在这里运用了“织女、牵牛、启明、长庚、天毕、箕、北斗”等星象,巧积成文,反复歌咏,生动形象地表达了深沉幽思的感情。\n昔高辛氏有二子,伯曰阏伯,季曰实沈,居于旷林,不相能也。日寻干戈,以相征讨。后帝不臧,迁阏伯于商丘,主辰(主祀大火),商人是因,故辰为商星(即心宿);迁实沈于大夏(晋阳),主参(主祀参星),唐人是因……故参为晋星。由是观之,则实沈参神也。(《左传·昭公元年》)\n这是一个影响深远的历史故事:传说中的帝王高辛氏有二子——阏伯、实沈,彼此不和,争斗不已。高辛氏迁阏伯于商丘主商,迁实沈于西方大夏主参,彼出则此没,解决了兄弟间的矛盾。故事虽具有浓郁的神话色彩,却有可靠的天象依据。参宿与商(心宿),一个东升,一个西落,永不相见。因此,后世便以参商喻兄弟不和或久违难见。曹植《与吴质书》:“面有逸景之速,别有参商之阔。”陆机《为顾彦先赠妇诗》:“形影参商乖,音息旷不达。”王勃《七夕赋》:“谓河汉之无浪,似参商之永年。”杜甫《赠卫八处士》更有名句:“人生不相见,动如参与商。”诸多用典,即由此而来。\n古代关于牛郎织女的传说,关于嫦娥奔月的神话,《庄子》中傅说死精神托于箕尾的文字,《列子》中两小儿辩日的记载……无一不与日月星辰永无休止的运转相关。\n如果说上面的举例还属于虚幻不实,出于艺术加工,有牵强附会之嫌的话,那么关于记人记事,确不可疑的实例在古代典籍中也比比皆是。\n帝高阳之苗裔兮,朕皇考曰伯庸。摄提贞于孟陬兮,惟庚寅吾以降。(屈原《离骚》)\n这是自叙家世,自报出生年月日的写实文字,无半点虚浮。这又该如何理解?用什么方法推算年月日才算可靠?千百年来众说纷纭,文史界至今尚在探讨。仿屈原用同一手法记年月日的,还有贾谊《鵩鸟赋》:“单阏之岁兮,四月孟夏,庚子日斜兮,鵩集于舍。”这也要具有古天文学知识才能理解。\n七月流火,九月授衣。一之日觱发,二之日栗烈,无衣无褐,何以卒岁?三之日于耜,四之日举趾。同我妇子,饁彼南亩,田畯至喜。(《诗·七月》)\n这是《诗经》中的名篇,人尽皆知的农事诗。农事必与月份、季节有关,诗中的纪月就标志着用历。而这里的“七月”“九月”的时令与后世的农历(夏历)并不一致,一般注释家认为这是周历与夏历并用。在夏历解释不通的地方,说是用的周历;在周历无法诠释之处,说是用的夏历。这种理解显然不合常理,绝不可能有一首诗兼用两种历。有人认为《七月》属“豳风”,用的是一种古拙的豳历。那又实在缺乏依据,是“大胆假设”的变种了。如果我们将诗中涉及的天象、气象、物象和农事记载,与《夏小正》《月令》《淮南子》等古籍中有关的文字比较,《七月》的用历也就迎刃而解了。\n其次,《吕氏春秋·序意》“维秦八年,岁在涒滩,秋甲子朔。朔之日,良人请问十二纪”的纪年月日,《诗经·十月之交》“十月之交,朔月辛卯,日有食之”所歌咏的中国文献可靠的日食记载,近代以来出土文物中诸多的干支纪日原文,都是不能用想象、用夸张的感情去理解的。\n一句话,要解决这些疑难,要读懂古代典籍,古代天文历法知识是不可或缺的。\n为了更好地说明问题,我们举几个常见又常被误解或忽视的例子,以引起大家对古天文学知识的高度重视。\n第一例。有名的汉乐府民歌《陌上桑》:“日出东南隅,照我秦氏楼。秦氏有好女,自名为罗敷。罗敷喜蚕桑,采桑城南隅。”\n这首一句,文学家的理解一般是不错的,就是“日出东南方”。而有的训诂家为了证明词的偏义,认为只有“日出东方”,没有日出东南方,“东南”义偏在东,“南”字虚拟。认为这是与“便可白公姥”“我有亲父兄”同例。“白公姥”,刘兰芝有姥无公,偏义在姥;“亲父兄”,刘兰芝有兄无父,义偏在兄。抠字眼的训诂家这些刻板的理解,当然会受到文艺评论家的讥笑。这毕竟是文学作品呀!而文艺家的认识“日出东南方”是否就尽善尽美了呢?从古天文学角度看,还有深进一层的必要。\n“日出东南隅”,这是初春的天象。时令规律是,日南至——冬至之后,太阳北回,大地逐渐返春。地处黄河流域的古人眼里,初春季节,太阳从东南方升,从西南方向落。《淮南子》称“天有四维”,“日冬至,出东南维,入西南维……夏至,出东北维,入西北维”。冬至之后春分之前这一段时间,太阳不从正东而从东偏南方向出来。这种观察太阳升起和落山位置以定季节的办法在《山海经》中也有记载。《山海经·大荒东经》就记载了六座日出之山,《山海经·大荒山经》里记载了六座日入之山。六座日出之山,六座日入之山,两两成对。说明古人对不同季节不同月份太阳出山入山时在不同的方位已有了清晰的认识。六座日出之山,从东北到东南,相当于太阳从夏至到下一个夏至往返一次,即一年十二个月太阳出入的不同方位。日出东南隅,这正是初春的天象。\n为什么一开始写一个初春的天象呢?这不仅写罗敷喜蚕桑,初春里就养蚕了,那么勤劳,也衬托了少女罗敷的美丽。虽然诗的后面对罗敷的美有大量描写,但一开头就放她在初春的环境里活动,无异于告诉读者,少女罗敷就如同初春般的含苞待放,如同初春般的令人可亲可近。这个开头就不是一般的交代时令了。\n接着还有“采桑城南隅”一句,这仍是写初春的天象。不到城东、城北、城西采桑,而采桑城南隅。因为初春天,阳气始回,草木萌动,太阳总是从南向照来,靠南的枝、芽、叶、果,总是先生先发,利于率先采摘。一旦阳春三月,就是阴山背后的草木也已蓬勃生机了。所以,“采桑城南隅”也不能简单地理解为在城南采桑而已,仍是以初春嫩桑初发在写罗敷的勤劳与美丽。\n第二例。苏轼有一首词,《江城子·密州出猎》,内中有一句:“会挽雕弓如满月,西北望,射天狼。”注释家对“雕弓”的理解是:弓臂上刻镂花纹的弓。这样的理解有违作者之初志。苏轼在这里以天象入词,指北兵入侵之时,自己虽不能临阵退敌,仍不失慷慨意气。\n天狼星在南天,一等大星,很明亮。古埃及人凭天狼星预报尼罗河水的上涨。《史记·天官书》载:“狼比地有犬星,曰南极老人。”杜甫诗《泊松滋江亭》“今宵南极外,甘作老人星”就是指此。天狼星靠近南极老人,当在南天。如果按注释家的意见,“雕弓”真的指弓,指弓臂上刻花的弓,则只能是西南望或南望了,与“西北望”正相反。其实,“雕弓”也指星官,即天弓“弧矢”星。《史记·天官书》:“弧九星,在狼东南,天之弓也。以伐叛怀远,又主备盗贼之知奸邪者。”《晋书·天文志》:“狼一星,在东井南,为野将,主侵掠。”又:“弧九星,在狼东南,天弓也,主备盗贼,常向于狼。”不难看出,天弓即弧矢,就是对付天狼的。(见上图)\n弧矢、天狼并用,古诗中早有。《楚辞·九歌·东君》:“青云衣兮白霓裳,举长矢兮射天狼;操吾弧兮反沦降,援北斗兮酌桂浆。”《增补事类赋·星象》:“阙邱三水纷汤汤,引弧矢兮射天狼。”注引《天皇会通》:“狼主相侵盗贼也。弧,天弓也,常属矢拟射于狼。”白居易诗《答箭镞》:“寄言控弦者,愿君少留听:何不向西射?西天有天狼;何不向东射?东海有长鲸。”取意亦同。苏词以雕弓这一艺术形象取代弧矢,也是天弓、天狼并用,就是挽弧矢射天狼。\n天狼隐喻辽,而不是西夏。这不是实地的西北望,不必看成实指在西北方的西夏,这是天象上的西北望,得从天象上申说。《宋史·天文志》引武密语:“天弓张,北兵起。”苏词作于宋神宗熙宁八年,当时侵宋的正是北兵辽。《宋史·天文志》载:“弧矢九星,在狼东南,天弓也。……流星入,北兵起,屠城杀将。”同书《流陨》篇记:“(熙宁)八年十月乙未,星出弧矢西北,如杯,东南缓行,至烛没,青白,有尾迹,照地明。”这是苏轼写词的当年有关流星的记载。因为历代天文志都与星占术有密切关系,流星的记载正应验外兵的入侵。可见,当时天象指北兵,即辽兵入侵。正因为这样,苏轼的《江城子·密州出猎》为什么不可以看做是对抗辽兵入侵的战斗演习呢?无疑,这是一首充满豪迈气概的诗篇。\n第三例。关于《周易》丰卦“日中见斗”的理解。列为群经之首的《周易》,历来是被当作卜筮之书看待的。一般将“丰卦”之六二、九四爻辞“丰其蔀,日中见斗”理解为:大房子用草盖房顶,白天能见到北斗星。(见李镜池先生《周易通义》)这是就《周易》卦爻辞的字面意义分别解说的。\n《天文学报》1979年第四期的一篇文章认为,“日中见斗”及九三“日中见沫”两条筮辞就是古代太阳黑子记录的一种表达形式。文章首先肯定这是两条天象记载,进一步肯定是太阳黑子的记录。作者认为,最迟到公元前800年,《周易》成书的时代(李镜池说),中国已有了关于太阳黑子的明文记载,这是世界上最早的记录。\n如果我们用古代天文学常识并结合考据学方法来研究这两条爻辞,就可以得出不同的结论。\n“日中见斗”的“日中”,《周易》经文已无从找到内证,而与《周易》大体同时代的《尚书·尧典》,有“日中星鸟,以殷仲春”这一条观象授时的记录,所谓“日中”是指春分时节,是春天的天象记录。如果这样理解,“日中见斗”的“见”读xian(现),是指春天夜晚北斗现。爻辞“丰其蔀,日中见斗”,“丰其沛,日中见沫”是说,春天来了,满天星斗,北斗最显眼,要观察星象,就在原野上搭个棚,棚顶盖上草。这反映出远古时代初民的穴居生活以及初民对星象的重视。\n这样理解,就与“白天见到北斗星”完全不同,也与“最早的太阳黑子记录”不相干。而哪一种解说最接近事理?请大家自行判断吧。不过,有一点应该注意,解读《周易》中的天象记录,务必参照《尚书·尧典》的文字,才能得其真谛。\n第四例。《左传》关于阏伯、实沈故事的意义。《诗·唐风·绸缪》“绸缪束薪,三星在天”,“绸缪束刍,三星在隅”,“绸缪束楚,三星在户”,历代注诗者对“三星”理解各不相同。有注“三星”为“心宿”的(如朱熹《集传》);有人说第一章指参宿三星,第二章指心宿三星,第三章指河鼓三星(朱文鑫),似不可从;毛传以三星为参宿三星。王力先生主编《古代汉语》面对诸家之说,认为“那要看诗人做诗的时令了”。实际上没有任何结论。\n前引《左传·昭公元年》高辛氏“迁阏伯于商丘,主辰。商人是因,故辰为商星。迁实沈于大夏,主参,唐人是因”。实沈是传说中夏氏族的始祖,以参宿为族星,大夏正是夏代的古都。夏为商汤所灭,其地称为“唐”。《左传·定公四年》记“封唐叔于夏墟”,成王姬诵封其弟于此,称唐叔虞,就是晋国的始祖。\n《唐风》是晋人的民歌,歌颂参宿,以示不忘先祖,不忘本源,也反映了早就灭亡了的夏民族的观星习俗。春秋时代的晋国采用以寅为正的夏历,战国时代韩、赵、魏仍袭其旧。时至今日,山西临汾地区还有观参宿的习俗,称参三星为“三晋”,可见大夏民族的流风余韵,影响何其深远!\n《左传》所记阏伯、实沈之争是上古时代夏商两族长期征战不休的形象化的反映。商族胜了夏族,商族始祖阏伯被尊为老大,夏族始祖实沈就只能屈居老二了。不难看出,《左传》有关文字已打上了商代文化的烙印。\n所以《唐风》所记”三星”是指参宿三星,毛传的解说是可信的。\n明确了夏商两族观测天象各有不同的习惯,选择不同的标准星宿,可以断定,《左传》“阏伯、实沈之争”的记载是商代文化的遗迹。还可以推知,十二地支(子丑寅卯辰巳午未申酉戌亥)当起源于传说中的夏代。因为十二地支之首的“子”字,最早用“崽”字,甲骨文崽作,郑文光同志以为“子”(崽)字是从代表夏族的参星图形衍化而来,觜宿三星形似三根小辫。(见上图)\n第五例。历法与《红楼梦》研究。有人说《红楼梦》是一部奇书,是一部封建社会末期的所谓“百科全书”,充满了许多特异的记载。例如第二十七回,写“宝钗扑蝶”“黛玉葬花”,这也是全书的重要情节之一。书上明白写着,那一天是四月二十六日,交芒种节。按照风俗习惯,芒种这天要摆设各种礼物,祭饯花神。因为芒种一过,便是夏日了,众花皆谢,花神退位,需要饯行。这个芒种节与《红楼梦》,与曹雪芹有什么关系?根据历法我们知道,乾隆元年(公元1736年)的芒种节,正好是四月二十六日(阳历是6月5日),曹雪芹死于乾隆二十八年(公元1763年),终年四十岁。由此推算,乾隆元年曹雪芹正好十三岁,这与黛玉葬花时贾宝玉的年龄相同。这就是《红楼梦》一书为自传说的有力佐证。可以认为,贾宝玉这一艺术形象是以作者自己为模特儿来描写的,虽然我们不会在贾宝玉与曹雪芹之间画等号。研究《红楼梦》的专家周汝昌先生早就提出了这条证据,表达了他的独到见解。可见,搞古代文学研究的人,懂得古代天文历法还是必要的。\n以上举例不过是想说明,一切与古代典籍有关的学科无不与时间的记载(即古代天文历法)有关,甚至古代字书的解说也不例外。《说文解字》释“龙”字:“龙,麟虫之长也。春分而登天,秋分而潜渊。”这是一条什么样的龙呢?春分登天,秋分潜渊。在许慎生活的东汉时代,作为麟虫之长的“龙”早已绝迹,许慎无从得见,也无法交代清楚,就利用天象上的角亢氐房心尾箕——东方苍龙七宿来加以描绘。天幕上的苍龙七宿从春分到秋分这一段时间里,每当初昏时候就横亘南中天。到现在,民间传说还有“二月初二龙抬头”的说法。春分后苍龙七宿现,秋分后苍龙七宿初昏已入地了。原来许慎在用天象释字义。\n古代天文学在阅读古籍中的作用是不可忽视的,当代一些著名学者于此深有体会。王力先生主编的《古代汉语》开了先例,将古代天文历法作为古代汉语基础知识的一部分专章讲授。南京大学程千帆先生将古代天文历法列入古代文化史课程的重要内容,作为研究生的必修课之一。这都是远见卓识之举,将有深远意义。\n我们学习古代天文学知识,不仅仅是为了校读古代典籍,继承优秀文化遗产,发扬我中华民族的古老文化传统。同时可以从中看到我们祖先非凡的聪明才智,激发我们的民族自豪感和爱国热情,攀登新时代的科学高峰。\n七、怎样学好古天文历法 # 我国浩如烟海的古代典籍中涉及天文历法的部分实在不少。从《史记·天官书》始,历代官修正史,大多有《天文志》,详细记述星官及特异的天文现象,对于与生产紧密相关的历法的研究,那就更为丰富了。据朱文鑫先生《历法通志》统计,我国古历在百种以上。可以毫不夸张地说,世界上没有任何一个国家,任何一个民族像我们祖先这样重视和研究历法的。从古至今,研究古天文历法的学人,根据自己的见解,留下了大量的文字。这些材料,头绪纷繁,解说各异,要想找到入学的正确门径确是很难的。再说,中国古代历法,十分重视推步,重视对日、月、五星运动规律的观测与运算,这涉及较为高深的数学知识,对一般文史工作者来说,要读懂这些有关推步的文字实为难事,若为阅读古籍而回头钻研高等数学又实无必要。怎样解决这个矛盾,使一般文史工作者能有一个便捷之法掌握古天文历法这把打开阅读古籍大门的钥匙?\n这就得大体了解近代关于古天文历法研究的一些方法与流派,扬己之长,避己之短,走自己的路,收事半功倍之效。\n我国近代关于古天文历法的研究,无论从规模、质量、成绩几方面看,都是历代封建统治下学者个人奋斗达到的水平所不能相比的。1949年后,研究受到党和政府的重视,不仅有了专门的研究机构,集中了不少专门人才,而且在其他部门从事古天文历法研究者亦逐日增多。尽管科学的研究方法还处于萌芽状态,但就现状看,由于研究者本身所取的角度不同,研究方法也就大不一样,各自具有特点,形成了几个不同的研究流派。这是初入门者不能不知道的。\n1.从历史学角度研究的。以刘坦、浦江清先生为代表。刘坦著《关于岁星纪年》一书,浦先生有《屈原生年月日的推算问题》一文。他们所据春秋、战国、秦、汉诸多记载,不承认“焉逢摄提格”是干支别名,认定那是“岁星纪年”的实录,否定干支纪年起于战国初期。因此,他们以木星运行周期11.8622年为基本数据推考纪年,或从“太岁超辰”之说推求历点。\n2.从考古学角度研究的。以王国维先生为代表。他们根据出土文物、鼎盘铭器上的历点,用刘歆的“孟统”(周历)进行推算。由于不考虑“先天”的条件,西周历法总是与实际天象相差两日到三日。王国维氏著《生霸死霸考》,倡“月相四分”之说,在文物考古界影响很大,至今沿用者不在少数。王氏弟子吴其昌作《金文历朔疏证》,更发挥了王氏的观点。\n3.从现代天文学角度研究的。以朱文鑫、陈遵妫先生为代表。朱文鑫著《历法通志》,陈遵妫有《中国天文学史》一书。目前国内各科研机构(自然科学史研究所、紫金山天文台、北京天文台等)都有这方面的专门小组。其特点是拥有现代天文学的科研手段,所测数据准确,研究者本人精于数学,长于推算。但是,用今天的科学手段比勘古人的肉眼目测,结论往往是有差谬的。如果硬搬《-2500年到+2000年太阳和五星的经度表》以考证古事,又常常有违于古代典籍的记载。\n4.从考据学角度研究的。以张汝舟先生为代表。张氏上世纪50年代末著《西周经朔谱》《春秋经朔谱》《西周考年》等,未遑问世。1979年著《历术甲子篇浅释》《古代天文历法表解》两种,比较集中地代表了他的星历观点。他提出纸上材料(文献记录)、地下材料(出土文物)、天上材料(实际天象)对证。做到“三证合一”才算可靠,尤其重视实际天象。他剔除了《历术甲子篇》中后人的妄改,视它与《次度》为古天文历法之双璧,确证四分历创制于战国初期,行用于周考王十四年(公元前427年)。汝舟先生之说信而有征。经他的友辈和门弟子宣讲阐释,其科学性、实用性已逐渐为文史界所重视。\n笔者于1980年底为通俗地介绍张汝舟先生星历观点,曾编写《古代天文历法浅释》一稿,并由南京大学中文系收入所编《章太炎先生国学讲演录》附录中。《浅释》的前言曾说:古代天文历法这门学问,并不如某些人想象的那么神秘。正因为这样,要学习它、掌握它就不是什么难事。古往今来,有关的材料实在不少,前人的解释也众说纷纭。只要找到一把好的钥匙,这个大门还是容易打开的。至于如何掌握这把钥匙,我在从汝舟师学习的过程中有这样的体会:一是要树立正确的星历观点,才不至为千百年来的惑乱所迷;二是要进行认真的推算,达到熟练程度,才能更好地掌握他的整个体系。由此下去,“博观则有所归宿,精论则有所凭依”。(黄季刚先生语)\n张氏星历观点的运算立足于四分历,这自然最能反映我国古代历法的实际。从现有记载看,中国最早的历法,所谓“古六历”——黄帝历、颛顼历、夏历、殷历、周历、鲁历,都依照四分历数据。在四分历法产生之前,包括“岁星纪年”在内,都还是观象授时阶段。进入“法”的时代,就意味着年、月、日的调配有了可能,也有了规律,可以由此求得密近的实际天象。——这是一切历法生命力之所在。\n一般文史工作者掌握四分历的运算也就够了,这不需要高深的数学知识,不会感到繁难,再进而钻研古天文历法也就有了一个很好的基础。通过演算,相信你会产生越来越大的兴趣,因为它可以引导你解决一些实际问题。你会感到古代天文历法并不如想象的那么玄虚,那么高不可攀。任何一门学问,如果被某些研究者研究得神秘莫测,那就失去了科学的意义。张汝舟先生的星历观点之所以可信,就在于他恢复了这一门学科的本来面目,于古代文献、古代历史、古代文学、古代语言都具有真正的实用价值。\n在当代古天文学研究领域,张氏的观点独具一格,于繁芜中见精要,于纷乱中显明晰,力排众论,自成一家言。这种以古治古的方法,尤其为文史工作者所易接受。掌握张氏的古天文观点与推演方法,于古代文献的释读,于古史古事的考订,都会深感灵便,情趣无限。第二讲纪时系统\n第二讲纪时系统 # 从服务于生产这个角度说,历法是一种纪时系统,是关于年、月、日、时的有规律的安排与记载。日是计时的基本单位,纪日法当最早产生。年,反映了春夏秋冬寒暑交替,直接关系农事的耕种收藏。依章太炎先生说:“年,从禾,人声。”“年”的概念也应当起源甚早。年嫌其长,日苦其短,才利用月亮隐现圆缺的周期纪时,才有朔望月的产生。这种周期不长不短的纪时法最适用于人类生活的需要,配合年、日,行用不衰。这就是年、月、日用于纪时的社会作用。比“日”小的计时单位是时(非四季之时,指时辰、小时、刻),那是起于人类有了一定程度的文明,需要有较细致的时间概念之后。为叙述的方便,我们以纪年、纪月、纪日、纪时的顺次,一一说解。\n一、纪年法 # 帝王纪年法 从西周大量金文及出土的殷商甲骨可以断定,殷商和西周都依商王、周王在位年数来纪年。这就是帝王纪年法。春秋以降,周王权力削弱,各诸侯国均用本地诸侯在位年数纪年。记载鲁史的《春秋》就用鲁侯在位年数纪年。其他诸侯国史虽已不存,但从《国语》中可以看出,各诸侯国都用本国君王在位年数纪年。如:\n《晋语》记献公事:“十七年冬,公使太子伐东山。……二十一年公子重耳出亡。……二十六年献公卒。”\n记文公事:“元年春,公及夫人嬴氏至自王城。……二年春,公以二军下次于阳樊。……文公立四年,楚成王伐宋。”\n记悼公事:“三年公始合诸侯。……四年诸侯会于鸡丘。……十二年公伐郑。”\n又,《越语》:“越王勾践即位三年而伐吴。……四年王召范蠡而问焉。”\n记周王事,仍用周王在位年数纪年。如《周语》记幽王事:“幽王三年西周三川皆震。……十一年幽王乃灭,周乃东迁。”\n记惠王事:“惠王三年边伯、石速、蒍国出王而立王子颓。……十五年有神降于莘。”\n记襄王事:“襄王十三年郑人伐滑。……二十四年秦师将袭郑。过周北门。”\n记景王事:“景王二十一年将铸大钱。……二十三年将铸无射而为之大林。……二十四年钟成。”\n乱世乱时,不统于王,各自为政,就出现了纪年的混乱。纪月起始也各有一套,并不划一。\n年号纪年法 秦始皇统一六国,仍用帝王纪年法。到汉武帝元鼎元年(公元前116年)正式建立年号,并将元鼎以前在位的二十四年每六年追建一个年号。按顺次是建元、元光、元朔、元狩,接着元鼎。这就是中国皇帝年号纪年的开始。皇帝一般在即位时用新年号,中间根据需要可随时更换。年号换得最多的是武则天,她在位二十年(公元684~704年),先后使用过十八个年号,随心所欲,经常一年换用两个年号。从汉武帝起,直到清末,中国历史上使用过的年号共计约六百五十个,其中有不少是重复使用的。重复最多的是“太平”年号,先后用过八次。——这从《中国历史纪年表·年号索引》中一查即得。年号最多用六个字组成,如西夏景宗“天授礼法延祚”,西夏惠宗“天赐礼盛国庆”。一般年号是两个字组成,也有三字、四字的。\n并不是皇帝非用年号不可,就有不用年号的。如西魏的废帝、恭帝和北周的闵帝。也有沿用前帝年号不改的。唐昭宗年号天祐,哀帝沿用不改;辽太祖年号天显,太宗沿用不改;后晋高祖年号天福,出帝沿用不改;金太宗用天会年号,熙宗沿用不改。\n明朝基本上一个皇帝一个年号,只有明成祖夺位后先用了一年“洪武”表示继替朱元璋正统,此后才使用年号“永乐”。明英宗先后两次登极,用了两个年号(用正统十四年,用天顺八年)。清朝皇帝一律一帝一年号。大家习惯于用年号来称呼皇帝本人。说清圣祖、清高宗、清仁宗、清德宗,反而不熟悉,一提康熙、乾隆、嘉庆、光绪,人皆尽知指谁。康熙在位六十一年,乾隆在位六十年,年号使用时间也就最长久。\n年号纪年实有不便,但有影响的帝王年号在典籍中不乏记载,甚至常用一些简称。诸如“太初改历”(汉武帝),“元嘉体”“元嘉草草”(刘宋文帝),“贞观之治”(唐太宗),“开元天宝”(唐玄宗),“元和体”“元和姓纂”“元和郡县志”(唐宪宗),“元祐党争”(宋哲宗),“宣和遗事”(宋徽宗),“靖康耻”(宋钦宗),“永乐大典”(明成祖),“天启通宝”(明熹宗),“启崇遗诗考”(天启,崇祯——明思宗),“康熙字典”(清圣祖),“乾嘉学派”(乾隆,清高宗;嘉庆,清仁宗),等等,我们都是应该弄明白的。\n岁星纪年法 春秋时代,各国纪年以本国君王在位为依准,各有一套,诸多不便。虽然各国纪年都以太阳的回归年周期为基础,但回归年周期与朔望月长度的调配尚未找到理想的规律。天文学家便在总结前人观测行星的经验及资料的基础上,加上亲身的观测,确知木星运行一周天用十二个回归年周期,即十二年,便定木星为岁星,用以纪年。这是企望扩大回归年周期的倍数,以与朔望月协调,使年与月的安排能够规律化。为此,古天文学家把天赤道带均匀地分为十二等分,作为起始的一分就叫做“星纪”。星纪者,星之序也。由西往东,依次是:星纪,玄枵,娵訾,降娄,大梁,实沈,鹑首,鹑火,鹑尾,寿星,大火,析木。这正是岁星行进的方向。这天赤道带的十二等分,叫十二次,岁星一年行经一次,岁星纪年就这样与十二次联系起来。\n岁星纪年法首先出现于《国语》和《左传》。如《周语》“武王伐纣,岁在鹑火”。《左传·襄公二十八年》“岁在星纪而淫于玄枵”。\n岁星纪年法是以天象为基础的纪年法,无疑可以比照各诸侯国的纪年。要是它准确无误的话,自会成为统一的纪年法,流行于普天之下。而木星运行周期并非整十二年,而是11.8622年。经历几个周期,岁星就要超次,如《左传》所记,本应“岁在星纪”,而岁星“淫于玄枵”,岁星纪年就失灵了。古籍中虽有关于岁星纪年的记载,但由于岁星本身运行周期不是整十二年,它便不能长久,它只能是春秋时代昙花一现的纪年法。我们自不可将它扩而广之,延及春秋之后。春秋时代的天文学家虽已观测到岁星的“淫”而有记录,但最早据文字记载正式提出岁星超辰的学人却是汉代的刘歆。\n太岁纪年法 木星运行周期不是整十二年,用实际岁星的位置来纪年就不准确,自不会符合创始者的初衷。人们就另外设想了一个理想的天体,与岁星运行方向相反,从东到西,速度均匀地十二年一周天,仍利用分周天赤道带十二等分的方法,将地平圈分为十二等分,只是方向相反:以玄枵次为子,星纪次为丑,析木次为寅……(见下图)称为十二辰,与岁星纪年的十二次区别。这个理想的天体就称为岁阴、太阴或太岁。太岁和木星保持着大致一定的对应关系。一般是,木星在星纪,太岁在寅;木星在玄枵,太岁在卯……这种用假想的天体——太岁所在的辰来纪年,可以叫做太岁纪年法。\n太岁纪年法十二次与十二辰\n可以明确,太岁纪年法的产生是在岁星纪年失灵之后。太岁是天文学家在不能放弃分周天十二等分而又无法克服木星运行周期非整十二年的矛盾情况下假想的一个理想天体。由于创始行用之初要接续岁星纪年,这个天体——太岁就必然与木星有一定的对应关系,以便有一个接续点,一个起算期。如“木星在星纪,太岁在寅”就是。使用太岁纪年法推算历点者,总是要先确定木星的实际位置,特别是确定木星在星纪的位置,以求找到太岁纪年的起算点,道理就在这里。由于木星周期非整十二年,而理想的太岁周期必须整十二年,这就必然要发生无法调和的矛盾。木星与太岁的对应关系会很快打破,将不再发挥作用。太岁一旦失去了与木星的对应关系,太岁纪年法也就无可依存,自当寿终正寝。如果认为太岁纪年法生命力如何之长久,那也是不合事理的。它仍是春秋后期昙花一现的纪年法,只不过是在岁星纪年法之后,干支纪年之前。\n《周礼注》云:“岁星为阳,右行于天;太岁为阴,左行于地。”由阴阳关系转化为雌雄关系,即岁星为雄,太岁为雌。《淮南子·天文训》所列一套十二个岁名,与太岁居辰有了固定关系。\n太阴在寅,岁名曰摄提格,其雄为岁星,舍斗、牵牛;\n太阴在卯,岁名曰单阏,岁星舍须女、虚、危;\n太阴在辰,岁名曰执徐,岁星舍营室、东壁;\n太阴在巳,岁名曰大荒落,岁星舍奎、娄;\n太阴在午,岁名曰敦牂,岁星舍胃、昴、毕;,\n太阴在未,岁名曰协洽,岁星舍觜、参;\n太阴在申,岁名曰涒滩,岁星舍东井、舆鬼;\n太阴在酉,岁名曰作鄂,岁星舍柳、七星、张;\n太阴在戌,岁名曰阉茂,岁星舍翼、轸;\n太阴在亥,岁名曰大渊献,岁星舍角、亢;\n太阴在子,岁名曰困敦,岁星舍氐、房、心;\n太阴在丑,岁名曰赤奋若,岁星舍尾、箕。\n这与《史记·历术甲子篇》所记岁名大体相同。如果抛开那个假想的天体不论,太岁纪年法就是十二地支纪年法,由此过渡到干支纪年就很好理解。\n后世探讨关于岁星与太岁的文字很多。清代钱大昕、孙星衍、王引之都有专文涉及。近代一些学人如浦江清、郭沫若等就据以推算屈原生年。如果弄清上面的纪年法,这些探讨岁星与太岁纪年的文字都是不难读懂的,也是不难定其是非的。\n岁星纪年因木星周期非整十二年而失灵,太岁纪年亦因之而无用,但划周天为十二等分的辰与次却保存下来,继续发挥作用。因为十二等分正好与十二地支配合,太岁纪年法的十二辰就用十二地支名目与岁星纪年的十二次相对应,由太岁纪年过渡到干支纪年就是十分自然的了。在那同时,天文学家已测得回归年周期为 日,冬至点在牵牛初度。制历有了基本数据,调配年、月、日有了可能,又有天象作依据,四分历由此产生。那时已进入战国时代。\n干支纪年法 天干地支的名目起源很早。郭沫若氏《释支干》以为十二支是从观察天象产生的,郑文光氏以为起源于传说的夏代。因为十二支之首的“子”(崽),甲骨文有作,是从参宿的图形衍化出来的。参宿正是夏氏的族星,是夏民族观测星象的标准星。郑氏的研究以为,十二支的名目都来自天上星宿的图形。不管怎么说,天干地支在秦汉以前已失去了创始的含义。秦汉之后,更无人说得清子丑寅卯、甲乙丙丁的意义。东汉许慎《说文解字》的解说,多从阴阳五行为说,夹杂了更多的汉代人的观念。\n甲骨文中已数次发现完整的六十干支片,那当是纪日所用。由于“三正论”的产生与影响,纪月也用了地支名目,所谓“斗建”。若再由十二次、十二辰转入十二地支纪年,就有彼此相混的可能。所以,四分历创制者本欲用干支纪年而故避子丑寅卯等文字,而用了干支的别名。那就是《史记·历术甲子篇》所载:\n《淮南子·天文训》所记,与此小有出入。\n后世学者文人仿古,往往亦用干支别名纪年。如《说文解字叙》记“粤在永元困敦之年孟陬之月朔日甲申”,困敦是子,徐锴注:汉和帝永元十二年岁在庚子也。魏源写《圣武记》末记“道光二十有二载玄黓摄提格之岁”,《淮南子》玄黓指壬,摄提格指寅,即壬寅年。\n这些古怪的干支别名,或者说太岁纪年法的十二个岁名,从何而来?近人研究,当源于少数民族语言,是民语的音译。陈遵妫先生以为:“这也许是占星术上的术语,因系占星家所创用,所以一般都忘却了意义。”如果进一步推求,我以为这是战国初期楚国星历大家甘德的创作,是楚文化的遗迹。司马迁对楚国文化有相当深入的研究,《史记·律书》所依据的天象就是楚人甘德的体系,《史记·历术甲子篇》所记当同。《历术甲子篇》所反映的“四分历”的创制不能说与当时的大星历家甘德无关。楚文化到汉代已更加昌明,由楚辞发展而来的汉赋几独霸汉代文坛,传习成风。这不仅由于汉高皇帝生于楚,功臣武将大多来于楚,也由于楚文化本身在春秋末期就已有相当实力,足以对后世的文化产生绝大的影响。就天文学而言,楚国在春秋后期就足以与中原各国的水准相匹敌。战国初期之甘德可视为楚国天文学说之集大成者。《淮南子》关于岁星、太阴的记载,已不是初创之作,而是战国初期的遗留文字,或经加工。“太阴在寅,岁名曰摄提格,其雄为岁星,舍斗、牵牛”,只能认为是楚文化的遗风。楚行寅正,寅为初始。“岁星舍斗、牵牛”,斗牛为二十八宿的初始。四分历以牛宿初度为冬至点,岁星居此辰,也有初始之义,这正是战国初期的实际天象。《天文训》所载,就不必看作是汉朝人的创作。\n干支纪年在东汉普行,干支别名纪年就只存在于前代典籍之中。因为官方通行的是帝王年号纪年法,干支别名纪年和干支纪年只能起一个延续久长的纪年作用。\n十二生肖纪年法 干支纪年到了民间,却有超越帝王年号纪年法的永无更换的突出优点,六十年一周期也大体可以记录人生的整个旅程。民间还略去天干成分,用十二种动物表示十二地支,这就是十二生肖纪年法。根据王充《论衡·物势》及《论衡·言毒》篇载,汉代“十二辰禽”——子鼠、丑牛、寅虎、卯兔、辰龙、巳蛇、午马、未羊、申猴、酉鸡、戌狗、亥豕,与流传至今的十二生肖完全一样。\n十二生肖纪年法,以与人类生活相关而常见的动物代替十二支,十二年一周期,形象易记,屈指可数,更称方便。不仅在汉族地区广为流传,而且一直传播到各兄弟民族地区,只不过为适应各民族的生活环境与习惯,取用的十二种动物略有不同罢了。例如云南的傣族用象代替猪(豕),用蛟、大蛇代替龙,用小蛇称蛇;哀牢山的彝族用穿山甲代龙;新疆维吾尔族用鱼代龙;等等。\n藏族的纪年完全接受了十二生肖法,并配合来自汉族的阴阳五行说组成十天干,构成六十循环的纪年法,这可以看成干支纪年法的另一种形式。\n十天干:甲——阳木乙——阴木\n丙——阳火丁——阴火\n戊——阳土己——阴土\n庚——阳金辛——阴金\n壬——阳水癸——阴水\n甲子年称阳木鼠年,乙丑年称阴木牛年,丙寅年称阳火虎年……余可类推。\n1949年后我国已采用国际通用的公元纪年法,这是可以长期延续而永不重复的纪年法,优越之处是显而易见的。公元纪年,使用精确度很高的格里历,三千多年才有一日的误差。\n在介绍了我国从古及今的各种纪年法之后,还有几个问题需要明确,才算真正懂得纪年法的意义与作用。\n1.十二,天之大数\n前面提到,干支纪年法以十天干与十二地支相配组成六十个干支纪年,十二生肖纪年法是十二地支的形象化纪年,岁星纪年法分为十二次,太岁纪年法有十二辰。纪年法的“十二”这个数字太重要了。\n十二,确实是中国古代天文学的一个重要数字。《尚书·尧典》记:“舜受终于文祖……肇十有二州,封十有二山。……咨十有二牧……。”《周礼·春官》载:“冯相氏掌十有二岁,十有二月,十有二辰……。”《左传·哀公七年》载:“周之王也。制礼上物,不过十二,以为天之大数也。”《左传·襄公九年》:“十二年矣,是谓一终,一星终也。”《山海经》记载的神话里有“生月十有二”的帝俊妻常羲,“生岁十有二”的噎鸣。屈原《天问》有“天何所沓?十二焉分”,屈原问:这个“天之大数”怎么来的?\n殷墟甲骨已数次发现完整的干支表,证明武丁时代(公元前14世纪)就有了十二支的划分。十二支应用于天空区划就是十二辰,是将沿着地平线的大圆划为十二等分,以正北为子,向东、东南、向西依次排列子丑寅卯辰巳午未申酉戌亥。这就是以十二支划分的地平方位,正东为卯,正南为午,正西为酉,正北为子。南北经纬线又称子午线,来源于此。\n岁星纪年所用十二次是沿天球赤道,自北向西、向南、向东依次记为星纪、玄枵、娵訾、降娄、大梁、实沈、鹑首、鹑火、鹑尾、寿星、大火、析木。十二次与十二辰方向正好相反。如以十二辰为左旋的话,十二次便是右旋。\n十二次、十二辰当来源于十二支。前已述及,郭沫若氏《释支干》认为十二支是从观察天象诞生的,郑文光氏进一步研究,十二支起源于传说中的夏代,这是根据夏氏的族星参宿的图像衍化为“子”(甲骨文作)确认的。\n干支周期的十干,出自人手有十指,从而有了十进位的记数法。十二支“十二”这个数字,只有十二个朔望月约略等于一年这个周期与人最为亲近。可以说,由一年十二个朔望月产生了“十二”这个天之大数,由此依周天星象产生十二支,由十二支产生了十二次、十二辰。由此演化下去,“十二”的应用就更广泛了。\n2.次和辰\n岁星与星宿对应关系\n岁星居维,宿星二;岁星居中,宿星三《左传·庄公三年》:“凡师,一宿为舍,再宿为信,过信为次。”可见次与宿紧密相关。十二次的得名,当源于二十八宿。而十二次与二十八宿相配并不划一。有的次含三宿,有的次含两宿。长沙马王堆三号汉墓出土的帛书中有“岁星居维,宿星二”,“岁星居中,宿星三”,就是这个意思。\n为什么有“岁星居维”“岁星居中”之分呢?郑文光氏以为,这是远古时代天圆地方说的残存。《淮南子·天文训》有“帝张四维,运之以斗”即是指此。据高诱注:“四角为维。”一个方形的大地,自然有四个角落。岁星运行至此,需要拐弯,只住两“宿”。运行到两维之间,即“中”,是直线行进,就经历三“宿”。(见右图)\n郑氏以为,“十二次并不单纯是天空区划,而是照应到天地关系”,“还保留着天圆地方说的残余”,意味着其来源“甚古”。\n岁星与星宿对应关系 岁星居维,宿星二;岁星居中,宿星三\n关于辰,用法十分广泛,解说也有不同。《左传·昭公七年》:“日月之会是谓辰,故以配日。”《公羊传·昭公十七年》说:“大火为大辰,伐为大辰,北极亦为大辰。”日本天文学家新城新藏以为,观象授时,“所观测之标准星象……通称之谓辰。所以随着时代的不同,它的含义有种种变迁”。\n在中国古籍记载中,“日月星辰”总是相提并论。今人将“星辰”连着解释,以此律古,那就不着边际。《管子·四时》篇记,“东方曰星”,“南方曰日”,“西方曰辰”,“北方曰月”,日月星辰各有所指。《国语·周语》载:“昔武王伐殷,岁在鹑火,月在天驷,日在析木之津,辰在斗柄,星在天鼋,星与日、辰之位皆在北维。”星与辰在这里区别清楚。同样,《尧典》“历象日月星辰,敬授民时”,也应理解成分别为说。\n在上古人眼里,日、月、星、辰并不相混,当各有内涵。日与月自不待说,星当指行星,辰当指恒星。这就包括了肉眼能见的除彗星、流星外的所有天体。星与辰的区别在行与恒,动与不动。金木水火土五大行星称“星”,不与辰相混,辰指水星当在有了十二辰之后。辰虽指恒星,说也笼统。有不动,有不动之动。在所有恒星中,北极星可视为不动之恒星,其余则可视为运转之恒星,所谓“北极亦为大辰”就是这个意思。如果把“大辰”理解为“所观测的标准星象”,那么上古人类最先是以北极星为标准星的。因为所有天体,不考虑岁差,只有北极星是真正不动的,最易识别。现今人们肉眼观星,也总是先找北极星,再取北斗定方位,并确定其他星象,道理相同。依郑文光氏说,到了传说中的夏代,为了农事的需要,观测星象取的标准是参宿(伐星的位置),这就是《公羊传》所谓“伐为大辰”。商代观星取商星(心宿,即大火)为标准星,这就是“大火为大辰”。伐、大火,或者说参宿、商宿,每年春季的黄昏或晨旦都出现在大体相同的位置上。作为“大辰”,仍取一个相对固定的意思。总之,上古典籍中的星与辰是不容混淆的。\n至于十二辰,沈括在《梦溪笔谈》中说:“今考子丑至于戌亥,谓之十二辰者,《左传》云‘日月之会是谓辰’。一岁日月十二会。则十二辰也。”对十二辰的解释历来大都从此。\n总之,十二次与十二辰的划分,除了方向相反以外,其他方面完全一致。\n十二次与十二岁名的对应关系\n3.十二次名的来源\n岁星纪年的十二次,取名都是有来源的,比较清楚。\n星纪:星之序也。表示岁星纪年以此次为首。战国以后历代都将冬至点的牵牛初度安放在星纪次的中点,反映的是战国初期的天象。《淮南子》“岁星舍斗、牵牛”,正是星纪一次。\n玄枵:即传说时代黄帝的儿子,亦写作玄嚣。\n娵訾:是传说时代帝喾的妻子。\n降娄:即奎、娄二宿名。\n大梁:地名。战国魏后期的都城。\n实沈:传说时代高辛氏(帝喾)的次子,即夏族的先祖。\n鹑首、鹑火、鹑尾:把南天一片星座联想成鸟的形象,分鸟头、鸟心、鸟尾三次。\n寿星:传说中的神名。\n析木:地名。属燕国。\n4.公元与干支纪年的换算\n接触文史古籍的同志,经常要遇到公元纪年与干支纪年的换算问题。除了利用《中国历史纪年表》直接查对之外,还有什么简便的换算法呢?这里给大家介绍两种方法。\n第一法,用“一甲数次表”以数学公式推算。\n甲,公元后年干支的推算法。\n公元1年是辛酉年,2年壬戌,3年癸亥,4年甲子。\n4加56才等于60,才能符合一甲数。“56”这个数字就是至关重要的了。干支纪年60年一轮回。凡大于60的干支年序数都必须逢60去之,余数才与60干支序数相吻合。推算法是:\n(x+56)÷60=商数……余数\n余数就是年甲子序数。从“一甲数次表”中可查得。(表见147页)\n为什么“一甲数次表”中,甲子的代号数是0,而不是常用的1?这是因为中国最早的历法——殷历,记载太初历元的甲子朔日与冬至甲子日是用“无大余”表示的。乙丑日用“一”表示,丙寅用“二”表示。“无大余”就是0,0就是甲子的代表数。(见《史记·历术甲子篇》)\n例1求公元1984年的年干支\n(1984+56)÷60=34……无余数\n能被60整除而无余数者(余数为0),则年干支为“甲子”。\n例2求公元3年的年干支\n3+56=59\n年数加56,小于60,这个“和”就是年干支代表数。查“一甲数次表”59为癸亥,则公元3年即癸亥年。\n例3求公元1840年的年干支\n(1840+56)÷60=31……36(余数)\n“一甲数次表”中,36是庚子的代号。公元1840年即庚子年。\n乙,公元前年干支的推算法。\n公元前1年是庚申,公元1年辛酉,2年壬戌,3年癸亥,4年甲子。\n数学上从-1到1,中间还有整数0,间隔是2。而历法纪年无公元0年,从公元前1年直接进入公元后1年。故庚申56加3即得一甲数60。公式为:\n(x+3)÷60=商数……余数\n因为公元前某年是从公元前1年起逆推,必须60减去余数,才得年干支序数。\n例4求公元前427年干支\n(427+3)÷60=7……10(余数)\n60-10=50(干支序数)得甲寅\n例5求公元前1106年干支\n(1106+3)÷60=18……29(余数)\n60-29=31(干支序数)得乙未\n例6求公元前10年干支\n10+3=13\n相加小于60,即以之做余数\n60-13=47(干支序数)得辛亥\n第二法,用“甲子检查表”直接查干支。这是已故历史学家万国鼎先生在《中国历史纪年表》中所载两个表,我们加以介绍。\n先看《公元前甲子检查表》。左边竖行是十干,其余六竖行是十二支。右边一竖行数码是代表个位数公元纪年。公元前1年庚申,公元前2年己未……公元前9年壬子。\n表中间部分五横行数码代表十位数公元纪年,又分三组与下面三方框内数码相衔接,下面三个方框内数码代表百位数和千位数公元纪年。左右两方框的百、千位数各有线条与中间代表十位数的数码相连。中间方框百、千位数就与无框无线的十位数码配合。\n查公元前89年干支。无百位数,百位数就看做0。在下面右边方框内找到0。由线条右上找到十位数,框内找到8。由8直行上,与个位数9的横向相交,得地支“辰”,天干是“壬”。得壬辰,即是年干支。\n又查公元前1066年干支。先在下方框内找到百位千位数10,在中方框内。相衔接的十位数,在中间,无线条系联,在内找到6(十位数)由6直上,与右竖行个位数6横向相交,在“亥”。横看干支是乙亥。即公元前1066年,干支乙亥。\n《公元后甲子检查表》(见下页)使用方法同前表。如,查1981年干支。先在下面三个方框内找到百位千位数19,在中间;则十位数在中间三组中无线条系联之内,找到8;由8直上,与右边竖行个位数1相交在“酉”。得1981年干支辛酉。\n这确实是公元纪年与干支纪年换算最简易之法。然后从干支纪年换算出公元纪年则有一定困难。因为干支60年一轮回,同一个干支年对应一系列的公元纪年,它们之间要么相差60年,要么相差60年的倍数。尽管如此,“检查表”还可以供我们利用,从已知干支查找公元纪年。\n例1胡诠有《戊午上高宗封事》一文,求戊午的公元纪年。\n查法:先找出个位数,戊午是8;再找出百位、千位数;最后判断出十位数。\n胡诠是南宋高宗、孝宗时人。南宋孝宗于公元1163年即位,所以百位千位数是11。已查出个位数是8,戊午之“午”下向,十位数就只能是3或9。结论一定是1138,不可能是1198。\n例2查近代“庚子赔款”的公元纪年。\n查法:先查出个位数。庚子是0;再确定百位千位数,18或19;最后判断出十位数。结果,1840年与1900年都是庚子年。近代史常识告诉我们,庚子年赔款是1900年事,与1840年无关。\n例3求“甲午海战”的公元纪年。\n查法:先查出甲午的个位数4;\n再确定百位千位数18;\n最后查出十位数:3或9。\n近代史常识帮助我们得出结论:甲午海战发生在1894年。\n例4郭沫若氏有《甲申三百年祭》,求甲申的公元纪年。\n查法:先查出甲申的个位数4;\n再确定百位千位数16;\n最后查出十位数4。\n得知:公元1644年甲申。亦知郭文写于1944年前后。\n二、纪月法 # 甲骨文、金文中尚未发现“朔”字,证明“朔”较晚出。因为朔日无月相,肉眼看不见,朔日只能根据月满时日得知。《诗·十月之交》有“朔月辛卯”的记载,至少西周时代已用朔为每月之首日了。\n比“朔”为早,甲骨文中有“朏”字,指新月初见。于是有人据以指出,中国古代最早是以新月初见为一月之首的,像当今的回历一样。这种以造字为说的结论是靠不住的,犹如说“日”字中有阴影,证明中国人早就观察到太阳出现黑子,才造出了一个日字一样。\n可以确切地说,从我国上古制历开始,一贯是以朔作为每月的起首。\n至于纪月法,从甲骨文、金文中可以看出,最早是以数序从一到十二来纪月份的。几千年的文明史都是这样,主要是用数序纪月。\n春秋时代,各诸侯国以自己的君王在位年数纪年,岁首之月也不尽相同,纪年、纪月都呈现出混乱。这时,天文学已相当发达,天象的观测日渐勤奋和准确。天文学家创制了岁星纪年,可据以参照各诸侯国的纪年,还创制了以斗柄所指方位用十二支纪月,可据以对照各国不同的岁首,这就是十二支纪月法。\n十二支纪月以天象为依据。纪年的十二辰,是将地平圈分为十二等分,用十二支定名,北斗柄指向十二辰中的某个方位就是某月:斗柄指向地平圈的寅位,就是寅月;指向卯位就称卯月。这就是所谓“斗建”。典籍中的有关记载还不少。\n十二支纪月是将冬至所在之月的子月(斗柄正北向,指地平圈子位)作为一岁之首,依次到岁终的亥月即十二月,这就是所谓建子为正,称子正。这种用十二支纪月之法,民用日历虽不行用,在古代却有相当影响。据以制历和对照先秦古籍有关文字是缺之不得的。\n星象家不仅用十二支纪月,还配上十干,成为干支纪月。干支纪月法很少有科学上的意义,搞命理预测,推算生辰八字才用到它。\n此外,古人纪月还用别名。《诗·小明》:“昔我往矣,日月方除。”郑笺:四月为除。《国语》载:“至于玄月是也。”玄月指九月。《离骚》有“摄提贞于孟陬兮”。孟陬指春季正月。\n《尔雅·释天·月名》记:“正月为陬,二月为如,三月为窉,四月为余,五月为皋,六月为且,七月为相,八月为壮,九月为玄,十月为阳,十一月为辜,十二月为涂。”\n又,《尔雅·释天·月阳》载:“月在甲曰毕,在乙曰橘,在丙曰修,在丁曰圉,在戊曰厉,在己曰则,在庚曰窒,在辛曰塞,在壬曰终,在癸曰极。”\n知道月名、月阳这些别名,《史记·历书》“月名毕聚”也就好懂了。毕即甲,聚通陬,均取正、首之义。岁首用“子”,斗柄指子位,聚实指“子”。毕聚即甲子月。据此知,《尔雅》之“正月为陬”,是以子月(冬至之月)为首月,《尔雅》用子正(周正)。可见月名、月阳是先秦的文字,汉人录之而已,亦见司马迁《史记·历书》仍有干支纪月的流风。\n由于先秦典籍毁者太多,存者甚少,月名、月阳这些纪月的别称没有全部得到文献上的验证。\n除此之外,一年四季,一年十二月都有种种别名。如古代将音律与历法的纪月联系起来,将十二律分配在十二月上以代月名。文献典籍中这类材料实在不少,我们不可不知。\n二月称酣春,有李贺诗句“劳劳莺燕怨酣春”。\n三月称杪春,取义于岁杪。《礼·王制》:“冢宰制国用,必于岁之杪。”杪春指三月,杪秋指九月,杪冬指十二月。\n四月称麦秋,见《礼·月令》“孟夏麦秋至”,“孟夏之月,农乃登麦”。四月称清和,见谢朓诗“麦候始清和,凉雨销炎燠”。又,曹丕《槐赋》有“伊暮春之既替,即首夏之初期,天清和而温润,气恬淡以安治”。\n五月称小刑,见《淮南子·天文训》:“阴生于午,故五月为小刑。”\n称蒲月,民俗端午节将菖蒲做剑悬于门首,作为应时的辟邪景物,故五月又称蒲月。\n六月称溽暑。见《礼·月令》:“土润溽暑,大雨时行。”谢惠连诗“溽暑扇温飚”,意即六月湿热。\n称徂暑,见《诗·四月》:“四月维夏,六月徂暑。”杜甫诗:“密云虽聚散,徂暑终衰歇。”\n称荷月,荷花盛开之月。江南旧俗以六月二十四日为荷花生日。《内观日疏》云:“六月二十四为观莲节。”《吴郡记》:“荷花荡在葑门之外,每年六月二十四,游人最盛。”\n七月称兰秋,谢惠连诗:“凄凄乘兰秋,言践千里舟。”也称开秋、早秋、新秋。梁元帝《纂要》:七月曰孟秋、首秋、初秋、上秋。又因兰花吐芳而称兰月。\n八月称仲商,见《礼·月令》:“孟秋之月,其音商。”可见古以“商”为秋之音,仲商即仲秋也。\n九月称青女月,《淮南子·天文训》云:“至秋三月,青女乃出,以降霜雪。”相传青女是天神,即青霄玉女,主霜雪之降。\n十月称良月,见《左传·庄公十六年》:“使以十月入。曰良月也,就盈数焉。”数至十为小盈,取其义。\n称朽月,见《礼·月令》:“孟冬之月其味咸。其臭朽。”臭,指以嗅觉闻之。气若有若无为朽。\n十一月称畅月,见《礼·月令》:“仲冬之月命之曰畅月。”孙希旦注:畅,达也;时当闭藏,万物充实。\n十二月称腊月,也叫蜡月、嘉平、清祀。腊,原是祭的别称。古代常于此月行祭祀,故后世称为腊月。\n称暮节,见《初学记》:“十二月也称暮节。”也称暮冬、穷冬、穷纪、晚冬、残冬、三冬。\n称星回节,语出《玉溪编事》:“南诏以十二月十二日为星回节。”\n还有一个四季与月份的配合问题,即所谓“建正”。在制历而尚无规律可循的时代,只能随时观测天象而定季节,设置闰月以确定月份。也就是说,每年春季第一个月是哪一月,并不是固定不变的,或者说岁首并不一致。北斗柄所指的方位是固定的,斗建起于子,终于亥。因为这是天象,不可能含糊。春秋各国的岁首月份,有在子月的,有在丑月的,有在寅月的,这就有一个“建正”问题。\n《左传·昭公十七年》载:“火出,于夏为三月,于商为四月,于周为五月。”这是以天象“火出”为依据,十分客观的记载。\n《史记·历书》云:“夏正以正月,殷正以十二月,周正以十一月。盖三王之正若循环,穷则反本。”这是司马迁立足于夏正为说。\n上两处都触及建正问题,即所谓夏、商、周三正。所谓周正,是以冬至所在之月,斗建子月(夏历十一月)为正月,即建子为正,又称子正;所谓殷正,是以斗建丑月(夏历十二月)为正月,即建丑为正,又称丑正;所谓夏正,是以立春之月,斗建寅月为正月,即建寅为正,又称寅正。春秋时代,依据建正不同,称子正之历为周历,丑正之历为殷历,寅正之历为夏历。\n三正与四季的对应关系是不同的。\n这样一来,春夏秋冬四季则各有所指。先秦典籍记载时令,往往与今人的习俗不同,彼此之间也经常两样。究其实,仍是建正不一所致。\n古人迷信阴阳五行和帝王嬗代之应,春秋时代“三正论”大兴,就是顺应了时代的需要。按照三正论者对“周正建子、殷正建丑、夏正建寅”的解释,夏商周三代使用了不同的历法,即夏代历法以寅月为正,殷代历法以丑月为正,周代历法以子月为正,夏商周三代迭替,故“改正朔”以示“受命于天”。秦王迷于“三正论”,继周之后,以十月为岁首,也有绍续前朝,秉天所命之意。实际上,四分历产生之前,还只是观象授时,根本不存在夏商周三代不同正朔的历法。所谓周历、殷历、夏历,不过是春秋时代各诸侯国使用的子正、丑正、寅正的代称罢了。近代学者新城新藏、郭沫若、钱宝琮对此均有研究,一致否定了“三正论”。张汝舟先生对古书古器留下的四十一个西周历点详加考证,结论是建丑居多,少数失闰建子建寅。(见所著《西周考年》)他的《中国古代天文历法表解》更以大量确证,论定西周承用殷历建丑。这里摘要列举《表解》所列之“表三”:\n表中所列,不仅“火(心宿)”的中流伏内顺次不紊,就是《尧典》与《月令》所举中星亦分明不误。不难看出,《尧典》用夏正不用周正,《夏小正》《诗·七月》《月令》,皆用殷正,不用周正。\n结论只有一个:西周一代并不是建子为正。\n春秋用历,有记载可考。隐公三年寅月己巳朔,经书“二月己巳,日有食之”,当是建丑为正;桓公三年未月定朔壬辰,经书“七月壬辰朔,日有食之”,亦是建丑为正。其他春秋纪日,皆可定出月建。事实是,僖公以前,春秋初期是建丑为正,这自然是赓续西周。不能设想,西周建子为正,到春秋突来一段丑正。\n正因为是观象授时,无历法以确定置闰,确定朔日余分,失闰失朔便极为自然。少置一闰,丑正就成了子正;多置一闰,丑正就成了寅正。到僖公以后,出现建子为正,也就是顺理成章之事。\n到了战国时期,各国普遍行用四分历,建正不同是事实。齐鲁尊周,建子为正;三晋与楚建寅,使用夏正;秦用夏正,又以十月(亥)为岁首。明白这个道理,对阅读古书大有好处。\n《春秋》《孟子》用周正建子,所以《春秋·成公八年》云:“二月无冰。”(适夏正十二月,当冰而不冰,反常天气,故记。)《春秋·庄公七年》云:“秋,大水,无麦苗。”(注:今五月,周之秋。平地出水,漂杀熟麦及五稼之苗。)《孟子·梁惠王上》云:“王知夫苗乎?七八月之间旱,则苗槁矣。天油然作云,沛然下雨,则苗浡然兴之矣。”(朱子集注:“周七八月,夏五六月也。”)《孟子·滕文公上》云:“昔者孔子没。……他日,子夏、子张、子游以有若似圣人。欲以所事孔子事之,强曾子。曾子曰:不可。江汉以濯之,秋阳以暴之(暴,蒲木反),皜皜乎不可尚已。”(集注:“江水多,言濯之洁也。秋日燥烈,言暴之干也。”周正之秋阳,正夏正之赤日炎炎,故言“秋日燥烈”。)这些记载,都可用子月为正解读。《楚辞》用寅正,诗句中明明白白。《九章·抽思》:“望孟夏之短夜兮,何晦明之若岁”;《九章·怀沙》:“滔滔孟夏兮,草木莽莽”;《湘夫人》有“嫋嫋兮秋风,洞庭波兮木叶下”;《九辩》有“秋既先戒以白露兮,冬又申之以严霜”,“无衣裘以御冬兮,恐溘死不得见乎阳春”等,这些文句只能用夏正建寅解释。因为夏正孟夏四月(巳)近于夏至(五月中气),日长夜短,故称“短夜”;南方夏正四月,正值立夏、小满,草木茂盛,才以“莽莽”状之;夏正九秋十冬,节气有白露、霜降、冬至、大寒,才可言“白露”“严霜”;至于衣裘御寒之时,必在夏正冬季,才有“不得见乎阳春”之说。\n《史记·秦本纪》记昭襄王四十二年“十月宣太后薨。……九月穰侯出之陶。……四十八年十月韩献垣雍……王龁将。伐赵。……正月兵罢,复守上党。其十月五大夫陵攻赵邯郸。四十九年正月益发卒佐陵。……其十月将军张唐攻魏”。这里记昭襄王四十二年、四十八年,都先记“十月”,后记“九月”“正月”。这是秦历——颛顼历,起于冬十月(岁首)止于秋九月。昭襄王四十八年、四十九年记有“其十月”。正月前的十月是秦历十月,正月后的十月,是三晋历的十月。三晋以正月(寅正,同秦)为岁首。“其十月”之在秦历,是在明年岁首,今记在本年内,注明“其十月”,犹言“他的十月”。如果三晋不是寅正是周历子正,“其十月”是秦历“八月”,秦史正好用秦历“八月”顺记下来,何来一个“其十月”呢?《秦本纪》中两个“其十月”是三晋用寅正之确证。\n《史记·魏其武安侯列传》载武帝元光五年(公元前130年)十月杀灌夫,十二月晦杀魏其,“其春武安侯病,专呼服谢罪。使巫视鬼者视之,见魏其、灌夫共守,欲杀之”。这里,先记十月,接着记十二月,之后不说“明春”,而说“其春”,是什么原因?因为秦用寅正,使用寅正月序,又以夏正十月为岁首记事,号称颛顼历。汉初承用秦制,也以十月为岁首,当年春天在当年十二月之后,故称“其春”。直到汉代武帝太初(公元前104年)改历之后,才以正月为岁首。此后两千余年,除了王莽和魏明帝(曹叡)时用殷正(建丑),武则天和唐肃宗(李亨)一度用周正(建子)之外,都用夏正建寅,延续至今。今农历称夏历,取建寅为正之夏,非夏朝之夏。\n从中可看出:建正与岁首一般是统一的。但建正多属于天文(斗建),岁首多属于用历,有时并不一致,如秦用夏正建寅的月序,又以十月为岁首记事。其次,有建正并不等于有历法。因为只要纪年月就有建正、有岁首,但不一定就有了制历法则,所以不能说夏历、殷历、周历就是夏、殷、周三代的历法。今农历称夏历,取建寅为正之夏,非夏朝之夏。又,夏正建寅之历,作为战国时代四分历的内容,古称殷历,假托成汤所制。实即假殷历真夏历。假殷历者,取名而已;真夏历者,取寅正之义;具体内容是古四分历的法则所推演的年月日安排。\n在“纪月法”这一部分,还有一个月甲子的推算问题。\n根据“五虎遁”已知:\n甲年和己年正月之甲子为“丙寅”\n乙年和庚年正月之甲子为“戊寅”\n丙年和辛年正月之甲子为“庚寅”\n丁年和壬年正月之甲子为“壬寅”\n戊年和癸年正月之甲子为“甲寅”\n如1984年之年干支为甲子,即“甲年”,正月之月甲子(干支)即丙寅。二月即丁卯,三月戊辰,四月己巳,五月庚午,六月辛未,余顺推。1980年之年干支为庚申,即“庚年”,正月之月甲子即戊寅,二月即己卯,三月庚辰,四月辛巳,五月壬午,六月癸未,余可顺推而出。列表如下。\n所谓“五虎遁”者,记住十干之年的正月五个寅(虎)月干支即可依次顺推之意。编成歌诀即是:\n甲己之年丙作首,\n乙庚之年戊为头,\n丙辛之岁庚寅上。\n丁壬壬寅顺水流,\n若言戊癸何方起,\n甲寅之上去寻求。\n三、纪日法 # 日是最基本的时间计量单位,也是最重要的时间单位。只有认识了日,把日子连续不断地记录下来,才可能产生比日大的时间单位——月、年,安排年、月、日才有可能,制历才有基础。\n日是有长度的时段,有起止时刻,或者说,日与日有一个分界。初民“日出而作,日入而息”,是把白天当一日,夜晚并不重要。有了火的发明,夜以继日,一日一夜合在一起,称为一日。\n《史记·天官书》载,“用昏建者杓”(杓指摇光),“夜半建者横”(横即玉衡),“平旦建者魁”(魁指天枢)。这是讲上古观测北斗星以定季节的三种不同的观测系统。而值得注意的是,先民所选取的观测时刻,正可以代表他们认识日的观念,平旦、黄昏、夜半都可以作为日与日的分界标志。日出(平旦)、日落(黄昏)作为日的分界,标志最为显明,生产力低下的部落人也最易掌握。但冬至日短,夏至日长,日出、日落的时刻一年四季是不固定的。在生产力有了相当发展的阶段显然就不能适应人类社会的需要,必须选用另外的标志以确定日的分界。现代天文学上使用的儒略日制度是以日中做分界标志的,这在实际生活中极为不便。我国古代,至迟春秋时代就以夜半作为日的起点了。\n以夜半划分日期必须有较精确的计时器,这就是漏壶。漏壶是计时刻的。只要用漏壶测得两次日中之间的长度,取其半就能得到较准确的夜半时刻。传说周公已有测景台,春秋时代已有了精确的圭表测影。圭表测影必取日中时刻。从现存文献看,战国初期行用的四分历就以夜半子时为一日的计算起点了,至今不废。\n有了正确的日的概念,古人用什么方法纪日呢?最原始的办法当然是结绳和刻木(竹)。新中国成立前夕,云南的独龙族还用结绳法纪日,佤族则用刻竹法。这都是原始纪日法的遗留。\n有了文字,纪日法就简单多了。甲骨卜辞使用的是干支纪日,如“己巳卜,庚雨”,“乙卯卜,翌丙羽”。现今不仅已数次发现完整的六十干支骨片,还发现有长达五百多天的日数累计结果。有人以为,完整的干支片,就是古人的日历牌。可见干支的创制当在殷商之前。\n干支纪日法是我国古代历法的重要内容。利用古历推算任何日期(包括节气、朔、望),都是首先推出它的干支数。要掌握古代历法的基本知识,就必须学会干支纪日的推算。《左传·宣公二年》“乙丑,赵穿攻灵公于桃园”,《离骚》“唯庚寅吾以降”,贾谊“庚子日斜兮,鵩集于舍”……这些干支纪日的记载,文献中比比皆是。据可靠的资料看,鲁隐公三年(公元前720年)二月己巳日至今,干支纪日从未间断,这是人类社会迄今所知的最长的纪日文字记载。\n干支纪日对于历史学、考古文献学,对于科技史有着重要意义。我国浩如烟海的古代典籍,大量珍贵史料赖干支纪日的行用而有条不紊地留传下来。没有干支纪日,史迹的推算便失去时间脉络,众多原始珍宝就成了杂乱无章的文字记录,价值也就可想而知。\n干支纪日法至今还有它一定的作用。有些历日还必须用干支来推求。如三伏、社日的计算。《幼学琼林》云:“冬至百六是清明,立春五戊为春社。寒食节是清明前一日,初伏日是夏至第三庚。”注说:立秋后戊为秋社。夏至后四庚为中伏,立秋后逢庚为末伏。这就是逢戊记社,逢庚记伏。过去西南一些地方赶场也是按干支纪日,主要是用十二支所代表的十二生肖称呼场地:牛场、马场、龙场、猫场、兔场……七天一大场,五天一小场。逢丑(牛)日赶场的集镇称牛场,逢寅(虎)日赶场的集镇叫猫场,余可推知。镇远侗族婚礼,定在每年阴历十月辛卯、癸卯两个卯日举行。\n干支纪日的局限是明显的。因为干支六十个序数一周期,延续不断,如果不知道朔日干支,就无法明确某个干支在该月的序次。在阅读古籍遇到纪日的干支还必须有专门的朔闰表来检查日子。\n除了干支纪日法还有数序纪日法。最早的数序纪日法资料是1972年于山东临沂出土的汉武帝七年(元光元年,公元前134年)历谱竹简。这份历谱在三十根竹简顶上标了从一到三十的数字,这是每月内各个日子的序数。每根简下面写着各个月中这个日子的干支日名。从那以后,凡出土的汉武帝以来的历谱都记有月内各日的序次数字。尽管有数序纪日法,民用甚为方便,历代史官的记载仍主要采用干支纪日法。\n星期是七天一周的纪日法。按“日、一、二、三、四、五、六”顺次排列。远在古巴比伦时代就采用了星期纪日法,后来和基督教一起传入希腊和罗马,现在已在全世界通用。星期的每一天,按照罗马占星术的观点,是由当时所知道的七颗行星(日、月和五星)中的一个所庇护的。因此,一星期中每一天的命名就用一个星的名字。这些名字到现代还保留在西欧的语言中。\n1583年法国学者斯加利杰(J.J.Scaliger)倡议在公历之外创立一种不间断的纪日尺度。它以太阳周28年、章法19年和律会15年相乘,得7980年为一总,称为儒略周。\n28×19×15=7980\n因为儒略历每年36514日,28年的日数恰为7的倍数,所以一太阳周后某月某日的星期又和以前同。\n章法:19个儒略年比235个朔望月约多一个半小时,可看作大体相等。所以某年1月1日合朔,一章19年后的1月1日仍必合朔。\n律会:当时罗马税周,15年为一周期,和天文没有关系。\n太阳周、章法、律会的“元”都起于儒略历1月1日。于是上溯得公元前4713年1月1日平午为一总的纪元(公元前1年在天文上记为0年,公元前4713年记为公元前4712年)。一切有史以来的时日都可以包括在儒略周一总之内,预推未来可应用的时日也足够了。\n儒略周的连续不断的纪日法在现代天文学上还很有用,因为这种纪日法是脱离年、月羁束的唯一长期纪日法,要想求得两个天象发生的准确时间距离,使用这种纪日法最为方便。\n每年的天文年历登载有每天的儒略日,可以查用。下面摘出近代主要年份元旦儒略周的日数,供查对。\n1800年237,8497\n1840年239,3106\n1900年241,5021\n1920年242,2325\n1950年243,3283\n1970年244,0588\n1980年244,4240\n这里谈谈关于公元日与干支日的推算问题。\n中国古代以干支纪日为主,六十日一轮回,周而复始。现代通行于世的格里历以公元年月数字纪日。两者均可顺推或逆推出若干年的干支日或数字日。二者必有一个彼此换算的问题。公元后若干年的数字纪日常需要换算成干支纪日,近代以前若干年的干支纪日也常需要换算成公元数字纪日。除了查阅陈垣先生《二十史朔闰表》之外,还有什么快速便捷之法?国内对此进行研究者不乏其人。重庆张致中、张幼明兄弟经多年研究,创“万年甲子速查法”,把万年内外的公元数字纪日快速地变换为干支日。张氏兄弟的“速查法”服务于中医研究,于古史、考古、星历等亦有参考价值。我们知道,当干支成为纪日工具时,它便很快被引入医学领域,借以说明人体很多复杂的生理病理现象,创立了相应的中医基础理论和治疗方法,如五运六气、天人相应学说、子午流注针法等等便是。因此,进行中医的理论研究与治疗,均不能离开干支纪日,常常需要将公元年月日的数字纪日迅速地换算出干支日,这是张氏“速查法”的主要作用。\n历法上的运算,当然也离不开干支日,但主要是推求朔日干支。而朔日又依据农历的朔望月,这与公元纪年的“月”内容不一样,是不能不知道的。\n四、纪时法 # 1.时的概念\n时的概念古今是不一致的。《说文》云:“时,四时也。”指一年春夏秋冬四时。古籍中的“时”,多指季节、时令,《孟子》“斧斤以时入山林”就是。这样理解,时就是一个比年小、比月大的时间单位。文史学家常顺次排列为“年、时、月、日”。《春秋经传》记事,多有类似记载。如:\n(文公)“传十六年春王正月及齐平。”\n“夏五月公四不视朔。”\n“秋八月辛未声姜薨。”\n“冬十一月甲寅宋昭公将田孟诸。”\n其中的春、夏、秋、冬,指的是“时”,就是季节。《说文》据以释义。\n纪时法之“时”,是指比日小的时间单位,时辰、时刻之类属此。\n2.地方时、世界时、北京时\n如果没有钟表,人们习惯于把太阳在正南的时刻说成中午12点,此时地球另一面正在背着太阳的地点,必然是夜里12点,也就是午夜0点。这种由观测者所在地,根据太阳位置所确定的时刻叫做地方时。地球绕太阳转动,各地的观测者都有各自正对太阳的时刻,都有各自的正午。只有同一条经线上的地方时才相同,地方时随着地理东西经度的不同而有差别。国际上规定,以通过英国格林尼治天文台的经度(0度)为起点,向东至180度称东经,向西自0度至180度称西经。地球自转一周360度,每小时转过15度(360÷24)。\n每距经度一度,时差4分钟(60÷15)。\n每距经度一分,时差4秒钟(60分为一度)。\n这就是地方时与经度的关系。这样,地球上东西任意两点同一时刻的地方时差,都可以通过两点的经度差算出来。\n随着人类社会彼此交往的频繁,全人类统一的时间系统的建立就是必不可少的了。公元1884年的一次国际会议上,建立了统一的世界纪时的区时系统。规定,将地球表面分成24个时区。太阳每小时所经过的地方即每15经度范围内为一个时区。东经12区,西经12区。东经180度即西经180度作为国际日期变更线。一日之内,东早西迟。人们沿用0时区的区时,即0度经线上的地方平时,作为国际通用的世界时。\n我们常用“北京时间”以统一祖国各地的地方时。北京位于东经116度20分,属于东八区。因此规定东八区的区时为“北京时间”。东八区的区时是指东经120度经线上的地方平时,并不是北京所在东经116度20分线上的地方时。杭州、常州正好位于东经120度经线上,严格说,“北京时间”与杭州、常州的地方平时才正好相当。\n美国时间1971年10月25日夜11点22分联合国大会以压倒性多数通过恢复中华人民共和国在联合国的合法权利,这在当时是一个振奋人心的消息。这是北京时间的几时几分呢?这就涉及区时的关系。\n所谓美国时间,就是指华盛顿时间。华盛顿位于西经77度,属西五区。美国时间就是西五区的区时,也就是西经75度经线上的地方平时。西五区的区时与北京时间即东八区的区时,相差13小时。\n美国时间10月25日晚11时22分正是北京时间10月26日中午12时22分。\n在我国国内,北京时间与各地的地方时差,也可以由各地的经度与东经120度线的经度差推算出来。\n3.古代的测景报时\n中国古代是利用表影的方位来报时,自成系统,这也是观测太阳位置来确定时辰。\n最早当是就近利用自然物(山势、树木)的影长,进一步发展就是立竿测影。古代的“表”,就是竿,直立的竿子。古人十分重视“表”的作用,观测十分勤勉。表的用途甚多,主要有三个:\n一是定方位。《周礼·冬官·考工记》云:“匠人建国,水地以县,县槷而眡以景。”郑玄注:“于所平之地中央,树八尺之臬,以县正之。眡之以景,将以正四方也。”《周礼》的“”,就是郑注的“臬”,实际就是表。《诗·大雅·公刘》:“既景迺冈,相其阴阳。”传云,考于日影,参之高冈。可见,依太阳高度利用日影测量地理位置,那是周代以前就有的了。\n二是报时辰。这是观测表影角度的变化,从日出到日落,以定出一天之内的时间。这种“表”发展为后来的日晷。古人将地平圈从北向东向南向西按十二支顺次分为十二等分,定出地平方位。春分、秋分日出正东而没于正西,即日出卯位没于酉位。冬至日出东南而没于西南,即日出辰位而入于申位。夏至日出东北而没于西北,即日出寅位而入于戌位。这是利用表影的方位来报时。\n三是定时令。观测每天正午的日影长度及其变化,测量回归年长度并确定一年二十四节气。这就是“土圭之法”,所谓“日中,立竿测影”。报时辰的“表”发展成“日晷”,作用也就专门化了。《说文》云:“晷,日景也。”日景即今影子。晷确真是测日影的。故宫太和殿前左边摆着的就是我国传统的赤道式日晷。古代日晷,晷面一般为石质,晷面和地球的赤道面平行。与赤道面平行,必然和地平面成一角度,角度的大小随地理纬度不同而变化。北京地理纬度40度,日晷与地面角度即为40度。晷面中心立一根垂直于晷面的钢制指针,这根指针同地球自转轴的方向也是平行的。晷面边缘刻有子丑寅卯辰巳午未申酉戌亥等十二时辰。每年春分以后,太阳位置升高,看盘上面的针影所指;秋分以后,太阳位置降低,看盘下面的针影所对时辰,十分准确。\n4.十二时辰\n从甲骨文材料看,殷人对每天各个不同时刻,已有专门称呼。大体上是将一日分为四个时段:旦(旦、明、大采),午(中日),昏(昏、昃日),夜(夕、小采)。这种粗略的纪时法,在生产力低下的时代,已足够应用了。\n在一日分四个时段的基础上,利用起源很早的十二地支定时辰,那也是很自然的。顾炎武《日知录》卷二十载:“自汉以下,历法渐密,于是以一日分为十二时,盖不知始于何人,而至今遵用不废……《左氏传》卜楚丘曰:‘日之数十,故有十时。’而杜元凯注则以为十二时。”顾氏以为“一日分为十二,始见于此”。即\n夜半者子也,鸡鸣者丑也,平旦者寅也,日出者卯也,\n食时者辰也,隅中者巳也,日中者午也,日昳者未也,\n哺时者申也,日入者酉也,黄昏者戌也,人定者亥也。\n古籍涉及时辰者不少。《诗·女曰鸡鸣》“女曰鸡鸣,士曰昧旦”(卜辞作未旦、旦);宋玉《神女赋序》“哺夕之后,精神恍忽”;《淮南子·天文训》“(日)至于衡阳,是谓隅中;至于昆吾,是谓正中”;《汉书·游侠传》云“诸客奔走市买,至日昳皆会”;《古诗·孔雀东南飞》“唵唵黄昏后,寂寂人定初”;杜甫诗“荒庭日欲哺”等等。\n顾炎武认为十二时辰的划分起于杜预的注,其实在商周之间就有了。《诗·小雅·大东》曰:“跂彼织女,终日七襄。虽则七襄,不成报章。”郑玄以为:“从旦至暮七辰,辰一移,因谓之七襄。”郑玄讲反了,不是从旦到暮而是指织女星从升到落,在天上走了七个时辰。这个“七襄”,已透露了西周时代一天分十二时辰的消息。\n除了常见的分一日为十二时辰外,还有将昼夜各分为五个时段的,那就是“日之数十,故有十时”。《隋书·天文志》载:“昼:有朝,有禺,有中,有哺,有夕。夜,有甲、乙、丙、丁、戊。”由此又称夜为“五更”。《颜氏家训·书证篇》解释道:“或问:‘一夜何故五更?更何为训?’答曰:汉魏以来,谓为甲夜、乙夜、丙夜、丁夜、戊夜;或云鼓,一鼓、二鼓、三鼓、四鼓、五鼓;亦云一更、二更、三更、四更、五更,以五为节。……所以尔者,假令正月建寅,斗柄夕则指寅,晓则指午矣。自寅至午,凡历五辰,冬之月虽复长短参差,然辰间,阔盈不至六,缩不至四。进退常在五者之间。更,历也,经也,故曰五更尔。”颜氏结合星象说五更,是可信的。\n更有《淮南子·天文训》将白天分为十五个时段:晨明、朏明、旦明、蚤食、晏时、隅中、正中、小还、时、大还、高舂、下舂、悬车、黄昏、定昏。这是就太阳的位置“日出于旸谷”至“日入于虞渊之汜”来划分白昼的。\n宋代以后,又规定把十二时辰的每个时辰平分为初、正两个部分。子初、子正,直到亥初、亥正。初或正都等于一个时辰的二分之一。“小时”之称由此而来。\n5.百刻制度\n与十二时辰同时并行的是昼夜均衡的百刻制度。这应是由“十时”制发展而来的更细致的一种纪时法。百刻制,当以漏壶的产生为基础。漏壶是古人制作的计时仪器,用箭来指示时刻。箭上刻着一条条横道,这就是刻。刻应比箭更早。\n《周礼》一书就有关于漏壶及昼夜时刻划分的记载,那时已有报时的制度和专职人员。《周礼》的内容反映了春秋乃至西周的社会礼制。\n《周礼·春官·鸡人》载:“夜呼旦,以叫百官。”\n《周礼·秋官·司寤氏》载:“掌夜时。以星分夜,以诏夜士夜禁。御晨行者,禁宵行者、夜游者。”这里的“鸡人”“司寤氏”,应是值夜班的专职人员,职责明确。\n《周礼·夏官·絜壶氏》记:“掌絜壶以令军井。……凡军事悬壶以序聚柝……以水火守之,分以日夜。”郑注:“悬壶以为漏,以序聚柝,以次更聚击柝备守也。……以水守壶者为沃漏也,以火守壶者夜则观刻数也。分以日夜者,异昼夜漏也。漏之箭昼夜共百刻,冬夏之间有长短焉。”\n《诗·东方未明》有:“狂夫瞿瞿,不能辰夜,不夙则莫。”毛诗序:“刺无节也。朝廷兴居无节,号令不明,絜壶不能掌其职焉。”严粲以为此诗主刺哀公,兴居无节,故归咎于司漏者以讽之。\n这些记载都说明,时刻制度行用是很早的。\n昼夜漏刻制以太阳出没为基础。秦和西汉规定,冬至日昼漏40刻,夜漏60刻;夏至日昼漏60刻,夜漏40刻;春分秋分则昼夜漏都是50刻。\n古代还明确规定昏旦时刻,以利于早晚观测中星及其他天象。秦汉以前,大体是日出前三刻为旦,日没后三刻为昏。秦汉以后改三刻为二刻半,一直用到明末。东汉以前,从冬至日起,每隔九日昼漏增一刻;夏至日起,每隔九日昼漏减一刻。因冬至与夏至相距百八十二、三天,昼漏或夜漏时刻相差二十刻,约合每九日一刻。《秦会要订补》卷十二《历数上》说:“至冬至,昼漏四十五刻。冬至之后,日长,九日加一刻,以至夏至,昼漏六十五刻。夏至之后,日短,九日减一刻。”昼漏或夜漏时刻虽略有不同,但九日增减一刻却是一致的。\n吕才漏刻图在钟表传入之前,漏壶一直是传统的纪时工具。由于一百刻与十二时辰无整倍数关系,难于协调,于是有改革百刻制的出现。汉成帝、哀帝及新莽时短时间行用过甘忠可推衍的一百二十刻制。梁武帝天监六年(公元507年)曾改为九十六刻制,大同十年(公元544年)又改为一百零八刻制,只用了数十年。陈文帝天嘉年间又复用百刻制。明代末期西学传入九十六刻制,清初定为正式制度,废百刻。\n一个时辰等于八刻又三分之一,这三分之一刻又称为小刻。\n吕才漏刻图\n6.时甲子的推算\n十二支用于纪时,民间又往往配以十干,发展为干支纪时。\n具体推算如下表。\n根据“五鼠遁”,已知:\n甲日和己日子时之干支为甲子,\n乙日和庚日子时之干支为丙子,\n丙日和辛日子时之干支为戊子,\n丁日和壬日子时之干支为庚子,\n戊日和癸日子时之干支为壬子。\n归纳成歌诀,便是:\n甲己还加甲,乙庚丙作首,\n丙辛生戊子,丁壬庚子头,\n戊癸起壬子,周而复始求。\n如公元1949年10月1日干支甲子日,那么甲日之子时干支为甲子,丑时即为乙丑,寅时即为丙寅……亥时即为乙亥。\n又公元1981年10月1日干支壬子日,那么壬日之子时干支为庚子,丑时即为辛丑,寅时即为壬寅……亥时即为辛亥。\n一般十二时辰配24小时,是23~1时为子,1~3时为丑……21~23时为亥。张汝舟先生以为,夜半子时作为一日之首,正如夜半0点作为一日之起始一样。夜半11点59分还是前一日,少一分都不行,少一分都未进入下一天。0点以后才是一日开始,也就是子时的开始。子时应指0点到2点之间这两个小时,余当类推。\n旧有的说法是将子时分为子初、子正,子初指23~24时,子正指0~1时。具体应用时,将“子初”归于上一日,0点是分界线。第三讲观象授时\n第三讲观象授时 # “观象授时”这一术语是清代毕沅在《夏小正考证》中首先提出来的,十分形象地描述了原始民族的天文学知识,也表达了先民在上古时期制历依据天象的事实。我国古籍中《尚书·尧典》《夏小正》《逸周书·时训解》等书里都有不少观象授时的记述。下面就有关的几个主要问题,分别加以解说。\n一、地平方位 # 观测天象,不仅有一个标准的时间计量,还得有一个统一的方位概念。这就得从地平四方说起。\n一分为二,二分为四,就是四方、四时概念的发展。四分体制几乎是世界古老民族都具备的原始计数体制。古巴比伦把宇宙看成一个四等分的圆周,根据月相(新月、上弦、满月、下弦)把一个月分为四等分。古希腊于公元前6世纪就产生了以水、火、气、土为宇宙万物四大本源的理论。古印度有所谓“四大种子”,指的是风、火、水、地。\n中国古代的八卦,事实上也是一种四行理论:天生风,地载山,雷出火,水成泽。何尝不可以看成风、地、火、水四种物质元素?虽然五行——金、木、水、火、土这种多元物质本源论泛滥无涯,但不能否认产生得更早的八卦——即四行的存在。\n甲骨文中已有明确的四方记载:“东土受年,南土受年,西土受年,北土受年。”还有关于四方风的叙述:“东方曰析,南方曰夷,西方曰韦,□□□勹。”(后者阙文,自然指“北方曰勹”。)《山海经》已分大荒东经、南经、西经、北经,也有类似甲骨文中四方和四方风名的描写。《尧典》更将四仲中星所代表的春夏秋冬四季与东南西北四方联系起来。《管子·四时篇》说得很清楚:\n是故阴阳者,天地之大理也。四时者,阴阳之大经也……\n东方曰星,其时曰春,其气曰风。\n南方曰日,其时曰夏,其气曰阳。\n西方曰辰,其时曰秋,其气曰阴。\n北方曰月,其时曰冬,其气曰寒。\n这里的四方、四时、四气与日月星辰配,把天象与地平方位、四季、气令结合起来,顺次井然,脉络清晰。\n前些年发掘出来的殷代宫殿基址,其南北方向跟今天指南针所指方向完全吻合,这说明殷商时代测定东南西北方位已是完全准确的了。这准确的方位,古人是怎样测出的呢?《周髀算经》载:“以日始出,立表而识其晷;日入复识其晷。晷之两端相值者,正东西也。中折之,指表者,正南北也。”或者说,日出时表影的端点和日没时表影的端点的连线,就是正东和正西;线的中点,跟表本身的连线就是正南和正北。\n表示方位的词,一般都用东、南、西、北。东(東),古人以为“从日在木中”,指太阳升起的方位。西,古字形作,下像巢,上像鸟,《说文》云“鸟在巢上也”,又说“日在西方而鸟西”,可见西方之“西”是一个假借字。古人为区别字义而新造一个“栖”字,鸟西作鸟栖,“西”就专表西方了。南,是草木到夏天长满了枝叶的意思,所以“从(pō)(rěn)声”。夏天,太阳从那个方向来,就是南方。北,古时就是“背”字。朝南为正,背南当然是“北”了。古人又说北方是伏方,取万物伏藏的意思。\n中华民族的祖先生息在黄河流域一带,处在北半球,太阳总是在古人视觉的南面运动着,坐北朝南就成了人们生活的习俗,所以古代典籍记载东南西北方位的方法以及左右的概念与我们今天的认识是大不相同的。长沙马王堆三号汉墓出土的地形图及驻军图,都是坐北朝南的,上方指南,下方指北,与当今地图南北向正相反对。《楚辞·哀郢》“上洞庭而下江”也是指方位的,洞庭湖在南、长江在北,用上、下标志。\n因为是坐北朝南,古人的左右就是指东西方位而言了。《史记·项羽本纪》:“纵江东父兄怜而王我,我何面目见之?”《晋书·温峤传》:“江左自有管夷吾,吾复何虑?”江左就是江东。\n古代匈奴有左贤王、右贤王,以今之方位言之,则左在西,右在东,实际上左贤王居东部,右贤王居西部。古人所谓“左地”,即东部地区,“右地”即西部地区。\n古有左将军、右将军,左侍部、右侍部,左庶子、右庶子之类的职官,办公或用事之地,都是左在东,右在西。\n古人观天象以三垣二十八宿为基准。紫微垣有左垣、右垣,太微垣有左垣、右垣,天市垣亦有左垣、右垣。左垣就是东垣,右垣就是西垣。《史记·天官书》:“紫宫左三星曰天枪,右五星曰天棓。”《史记正义》云:“天市垣在太微垣左,房、心东北。”类似以左右指方位的记载在古书中比比皆是。要是我们夜观天象,使用当代星图,就要面对北极星才能切合。如果用旧式星图,必须坐北朝南,才能分出左右,不致迷惑。\n扬雄《解嘲》云:“今大汉左东海,右渠搜,前番禺,后椒途,东南一尉,西北一侯。”即用左右前后,亦指东西南北,方位还是准确的。\n表示方位的左右,古人也常用来表示地位的尊卑。这与中国西高东低的自然地形也是吻合的。左表示东方,又表示地位低下;右表示西方,又表示地位尊显。《史记·廉蔺列传》载:“以相如功大,拜为上卿,位在廉颇之右。”指地位高过廉颇。《后汉书·儒林传》:“(董钧)后坐事,左转骑都尉,年七十卒于家。”左转即左迁,就是降职。左低右高,若设左右二丞相,则右丞相地位高于左丞相;若有东宫西宫,自然西宫尊于东宫。因为右方地位高上,左方地位卑下,亲近赞助用右,疏远贬损用左,《战国策·魏策二》:“衍将右韩而左魏。”即公孙衍亲近、赞助韩国而疏远、损害魏国。\n东南西北也常用来表示地位、身份。历代帝王宝座都朝正南方,故有“南面称王”之说。这就是清代学者凌廷堪所说“堂上南向为尊”。贾谊《过秦论》:“秦并海内,兼诸侯,南面称帝,以养四海。”以坐北朝南为尊。胡诠《戊午上高宗封事》:“向者陛下间关海道,危如累卵,当时尚不肯北面臣虏。”指北面为臣。引申开去,没有占上风,处在下位,打败仗,称“北”。“败北”“追亡逐北”就是此意。这里的“南面”“北面”,是朝南、朝北,不是南方、北方。就座位说,北边的位子是尊位了。东与西,即前面讲的左与右,左东为低下,所以主人之位在东,右面为尊崇,所以宾客之位在西。主人称“东家”“做东”“东道主”,客位称“西席”“西宾”。\n君王南面而坐,公侯将相则东向而朝,以坐西向东为尊贵,这就是凌廷堪所谓“宫中以东向为尊”。《史记·廉蔺列传》载:“今括一旦为将,东向而朝,军吏无敢仰视之者。”《项羽本纪》载:“项王即日因留沛公与饮。项王、项伯东向坐;亚父南向坐——亚父者,范增也;沛公北向坐;张良西向侍。”不难看出,项王座位最尊崇,范增次之,沛公又次之,张良在东且是“侍”。后来樊哙撞进来,还是“披帷西向立”,站在东边。这自然是一张合乎礼仪及习俗的座次表了。\n古书里,又常以山川地势作为定方位的基准。山之南称阳,山之北称阴,水之南称阴,水之北称阳。衡阳,处衡山之南;贵阳,得名于贵山之南;华阴,处华山之北;河阳,指黄河之北;洛阳,处洛河之北;江阴,处长江之南;汉阴,指汉水之南。\n龙、虎也用来表示东西两个方位。《书·传》:“东方成龙形,西方成虎形。”因为“东溟积水成渊,蛟龙生之,西岳山峦潜形,虎豹存焉。”所谓“左青龙、右白虎”,就是东有青龙之象,西有白虎之形。东西有龙虎,南方配以鸟,北方配以龟,就是“南朱雀、北玄武”。综合龙、虎、鸟、龟,古人谓之“四象”。在古代的天文星图中,作为观象授时主要依据的二十八宿,也配以“四象”。传统的二十八宿歌诀是:\n角、亢、氐、房、心、尾、箕——东苍龙,\n斗、牛、女、虚、危、室、壁——北玄武,\n奎、娄、胃、昴、毕、觜、参——西白虎,\n井、鬼、柳、星、张、翼、轸——南朱雀。\n二十八宿配四象,起源很早。《书·尧典》载“二月日中星鸟”就是以井鬼柳星张翼轸之“星”宿为中星,不说“星星”,而说“星鸟”,可见四象的端倪。1979年湖北随县(今随州)出土了一只绘有二十八宿星图的箱盖,已确认是战国初期的实物。箱盖上,二十八宿之外,左绘龙形,右绘虎形,也是表方位的。\n由于在划分恒星群基础上产生的“四象”有着广泛的影响,所以古书利用它表方位的描写就很多。《曲礼》以“前朱雀、后玄武、左青龙、右白虎”这种星宿的布列显示行军布阵之法。张衡在《灵宪》中更有生动的描写:“苍龙连蜷于左,白虎猛踞于右,朱雀奋翼于前,灵龟圈首于后。”这左右前后,自然指的是东西南北方位。《鹖冠子》云“前张后极,左角右钺”,其含义也与《曲礼》《灵宪》所写四象同。张,指井鬼柳星张翼轸——南方七宿朱雀之“张”;极,指北极;角,东方苍龙七宿——角亢氐房心尾箕之“角”;钺,指西方白虎七宿——奎娄胃昴毕觜参之参宿区界内的钺星。由于北方七宿玄武之象隐入地平线下,人所不见,背后唯北极而已,故言“后极”。从表方位角度说,与言“后玄武”是一个意思。《说文》释“龙”字云:“龙,鳞虫之长也。春分而登天,秋分而潜渊。”前句还可以接受,后句“登天”“潜渊”就玄乎了。如果与四象之“左苍龙”联系起来就不难理解。恒星群东方七宿——角亢氐房心尾箕,春分后黄昏时候开始出现在东方,这就是民间所谓“二月初二龙抬头”的传说。经半年,秋分后这条龙就从西方地平线隐没了。这不正是“春分登天”“秋分潜渊”吗?\n四象与方位紧密联系,四象也就可做东南西北的代称了。南京的玄武湖,实际上是北湖的意思。唐朝长安有玄武门,当然是指北门。旧金陵有朱雀门、朱雀桥,长安旧城内有朱雀门,其他各地旧城的朱雀门,都指的是南门。同理,东海之滨的青龙镇,自然坐落在国土之东。其他,称青龙港、青龙河、青龙桥、青龙塔,都标志了其所在的地理位置。古时的“白虎”含贬义,这与原始时代虎豹危害人类生存的事实有关,所以做地名白虎洞者少有,而白虎堂、白虎厅之类已喻义军机紧要,示禁入内之意。\n两千多年来列为群经之首的《周易》是用卦爻辞反映作者思想体系的。《周易》基本卦次是乾 、坤 、震 、艮 、离 、坎 、兑 、巽 。司马迁有“文王拘而演周易”之说,所谓“文王八卦方位”西周时代就有了。其具体方位是:东(震)、南(离)、西(兑)、北(坎)、西北(乾)、西南(坤)、东南(巽)、东北(艮)。搞占卜预测的端公道士,就利用阴 阳 的变化,演示八卦,定出方位,以占筮的卦爻辞进行说解,俘获人心。\n《淮南子》称“天有四维”,这四维,并不指东南西北四方。而是“日冬至,日出东南维,入西南维……夏至,出东北维,入西北维。”这与八卦中的巽(东南)、坤(西南)、艮(东北)、乾(西北)的方位是一致的。\n能推演的历法产生以前还是观象授时。观象不仅可以授时,亦可以定方位。利用北斗七星斗柄所指,就可以确定方位与季节。《夏小正》载“正月斗柄悬在下”,“六月斗柄正在上”。因为北天极处在高空,绕极而转的斗柄悬在下,指北方;正在上,指南方。《淮南子·齐俗训》说:“夫乘舟而惑者不知东西,见斗极则寤矣。”东晋僧人法显《佛国记》云:“大海弥漫无边,不识东西,唯望日、月、星宿而进。”古人在茫茫大海里用北斗、北极星及其他星宿确定方位那是很自然的。保存了一些原始记载的《鹖冠子·环流》说:“斗柄东指,天下皆春;斗柄南指,天下皆夏;斗柄西指,天下皆秋;斗柄北指,天下皆冬。”斗柄的东南西北紧系着天下的春夏秋冬。所以,春夏秋冬也可以配合方位。那就是东方春,南方夏,西方秋,北方冬。\n地平方位图天干地支的使用远在商代以前。发掘出土的甲骨中已数次发现完整的干支表。殷周以降,干支不仅用来纪日、纪月,还用来纪年,也用来表示方位。天干表示方位的方法是,甲乙为东,丙丁为南,戊己为中,庚辛为西,壬癸为北。东南西北中配以十干,这自然是受了阴阳五行学说的影响。地支表示方位,汉代已属常见。《史记·历书》以“正北”代夜半子时,以“正东”代晨旦卯时,以“正西”代黄昏酉时,以“正南”代日中午时。《周髀算经》卷下有“冬至夜极长,日出辰而入申;夏至昼极长,日出寅而入戌”,这显然也是以地支表方位的。子为北,午为南,南北经线我们称之为子午线,一同此理。\n历代制作的浑仪以及天球仪上都装有地平环,一般都用四维、八干、十二支代表二十四个方位,位置的显示就精确得多了。(见右图)汉唐以来的月令图及民间使用的罗盘都用它来表示方位。\n地平方位图\n地平方位图甚至还用于标志汉字古读的声调。唐人在字的四角点四声。张守节《史记正义·论例》“发字例”云:若发平声每从寅位起,上点巳位,去点申位,入点亥位。到宋代改点为圈,位置依旧。对照附图,平上去入四声标志的位置就不难明了。\n二、三垣二十八宿 # 天空间繁星密布,日夜运转,周而复始。怎样从纷繁中理出一个头绪?最简便的就是识别一些亮星,再划分天区,以利观测。中国古代天文学分天区以三垣二十八宿,形成别具一格的划分法,自成独特的观象系统。按照先民的划分,每一天区的星又分成若干群,一群之内的星用各种假想的线连接起来,组成各种图形,并给一个相应的名字。古代称这一星群为星官,《史记·天官书》之“官”,源于此。“虽群星之散乱,但依象而堪核其实。”北斗七星可连成像一只长把的勺和古人用的酒斗,与“斗”相似,星官取名为斗。箕宿四星可连成簸箕的形状,名之曰箕。《诗·大东》:“维南有箕,不可以簸扬。维北有斗,不可以挹酒浆。”是就这两个星官说的。\n三垣的名称,依现存文字记载,完整的提法初见于唐初的《玄象诗》。《史记·天官书》中尚无“天市垣”,称紫微垣为“紫宫”,太微垣只称“太微”。可见形成一个体系较晚。\n二十八宿就不同了。有些星名在甲骨文中就已出现。《尚书·尧典》作为四仲中星的星已包括在二十八宿之中。日本天文学家新城新藏提出西周初年二十八宿已经形成,虽嫌证据不足,但亦不致晚于春秋。1972年湖北随州出土一件漆箱,箱盖上围绕北斗的“斗”字,有一圈二十八宿的名称。这座古墓的时代,比较肯定的说法是春秋末年或战国初期。\n二十八宿的名称是:\n东方七宿:角、亢(天根、本)、氐、房、心(农祥、天驷、大火)、尾、箕;北方七宿:斗、牛(牵牛)、女(须女、婺女)、虚、危、室(定、营室)、壁(东壁);\n西方七宿:奎、娄、胃、昴、毕、觜(觜觿)、参;\n南方七宿:井(东井)、鬼(舆鬼)、柳、星(七星)、张、翼、轸。\n早期星官的命名,都和生产生活中常见的事物有关。\n人物:老人、织女、农丈人;\n动物:鳖、天狼、角、牛、翼、尾;\n用具:五车、天船、斗、轸;、\n农具:箕、定(《尔雅·释器》注:锄属);\n猎具:弧矢、毕(带网的叉子);\n物件:天门、南门、柱、室、井、糠;\n其他:柳、火、天津、江、河。\n人类进入阶级社会,人间的一套政治机构和社会组织也相应地搬到了天上。如帝、太子、上辅、上弼、帝座、侯、宗人、上将、次将、上相、次相、五诸侯……这些帝王将相的名称,大理、天牢、垒壁阵、羽林军、八魁、军市、明堂、灵台、华盖……这些人世间常见的机构、组织、器物专名。正如古人言:\n天市者,原系天之市也。觉亦不应乎人之市。故垣外则有齐楚燕郑并姬周分封之国。垣之中,则有斗、斛、市楼及宗人、宗星、宗正、车肆、列肆、屠肆等星。故凡地之所有者,天亦应地而有之。如少微者,是少于紫微也。微何以言少?试观垣中,则太子、幸臣、从官之类,并三公、九卿之俦,及中独列五帝之座。可知上相、次相、上将、次将,皆属辅嫡子以嗣统者也。至于以列星而论,若有东咸,必有西咸,有南河必有北河,有左旗、左更、左辖、左摄提,则必有右旗、右更、右辖、右摄提,有外屏必有内屏,有三台、三辅必有三公、三师,有杵必有臼。有离瑜必有离珠,有土公而有土公吏、土司空者,由之有水委必有水府,水积之位也。推而广之,言水族者,则有鱼龟与鳖,言昆虫者,则有螣蛇与蜂,言旗者则有节游与弁,言戈者则有鈇钺与籥,言鸟者则鹤与火鸟、天鸤也,言兽者则有马与狗国、天狗也。然而星象甚繁,不能枚举。通盘考核,皆可取配成趣,变化致祥。学者当自举一反三耳。\n入宿度、去极度示意图\n若A为二十八宿距星,B为另一天体,过A、B的赤经圈分别交赤道于a、b。则B天体的入宿度为 ,去极度为 。\n所以,遍观中国的星座如同认识一个完整的封建社会\n古人又是怎样标示这些繁多的天体的具体位置呢?\n从天文学角度说,任何天体的位置可以由赤道坐标系标示也可以由黄道坐标系来标示。中国古代,广泛应用赤道坐标系标注天体位置。赤道,是指天球上一个与天极处处垂直的大圆。赤道坐标的两个分量是入宿度和去极度,即用去极度和入宿度表示天体位置。\n古代分周天36514度,配合二十八宿,一回归年运行一周天。二十八宿都是星群,测量两星群之间的距离得取其中一星为标准,定为距星,下宿距星与本宿距星之间的赤经差,就叫本宿的距度。二十八宿每宿的距度是不等的,加起来合36514度。\n《汉书·律历志·距度》载:\n角十二。亢九。氐十五。房五。心五。尾十八。箕十一。东七十五度。\n斗二十六(又四分之一)。牛八。女十二。虚十。危十七。营室十六。壁九。北九十八度(又四分之一)。\n奎十六。娄十二。胃十四。昴十一。毕十六。觜二。参九。西八十度。\n井三十三。鬼四。柳十五。星七。张十八。翼十八。轸十七。南百十二度。\n这样,每过一天,二十八宿便向西运行一度。每过一回归年,二十八宿便运行一周天。从而把日期的变更与星象的位移紧密联系起来,形成了二十八宿与十二月、二十四节气的对应关系。\n所谓“入宿度”就是以二十八宿中某宿的距星为标准,测出这个天体与这个距星之间的赤经差。如织女星入斗五度,就是说,它在斗宿范围内,与斗宿距星(斗一)的赤经差为五度。\n所谓“去极度”,就是所测天体距北极的角距离。\n弄明白表示天体位置的方法,古籍中有关的记载就可以读懂。如下面是以宋皇祐年间观测为准所编制的《宋代星宿》(日本学者薮内清著)中几个天体的位置。\n表列“赤经”,是指自春分点起沿着与天球周日运动相反的方向量度的数据。从0度到360度。现今之春分点在奎宿内(距星奎二赤经359度50),经井(井一赤经81度39)、角(角一赤经188度96)、斗(斗一赤经266度54)。分周天360度,这是西方用法,中国古代是不用它的。\n用入宿度和去极度标示天体位置的赤道坐标系和观测点的位置无关,而且同一天体的赤道坐标也不随时间而变化。因此在天文历表中,一般都用赤道坐标表示恒星的位置。只有研究太阳系天体位置和运动时,一般采用黄道坐标。\n中国古代分天区为三垣二十八宿,而国标上通行的是划分为八十八个星座。现今人们夜晚观星,有的用西名星座,有的用古代星名,更多的是中西星名杂用。如北京天文馆天象厅演示天象时,《怎样认星》《夏夜星空》《冬夜星空》等认星歌就是中西杂用,《夏夜星空》的认星歌是:\n认星先从北斗来,由北向西再展开。\n两颗极星指北极,向西轩辕十四在。\n大角、角宿沿斗把,天蝎、南斗把头抬。\n顺着银河向北看,天鹰天琴紧相挨。\n天鹅飞在银河上,夏夜星空记心怀。\n中国古代既有独特的观象体系——三垣二十八宿,所以我们得熟悉自己的这一套,便于考之于古籍。这里编一首《星象名称对照歌》,将三垣二十八宿所涉及的西方星座名称与中国古代名称对照起来,帮助记忆。\n《星象名称对照歌》:\n斗转星移满苍穹,中西名称两不同。\n划分三垣廿八宿,勾一为心各西东。\n北极勾陈小熊座,紫微左垣乘天龙。\n轩辕五帝座狮子,内屏端门室女空。\n天市两垣跨巨蛇,宗人宗正蛇夫中。\n织女渐台名天琴,河鼓右旗叫天鹰。\n北斗文昌大熊座,三台靠边熊掌跟。\n大角梗河两摄提,玄戈招摇牧夫星。\n南有角亢嫁室女,氐做天秤也公平。\n房心尾宿在天蝎,箕与斗建人马星。\n牛宿天田在摩羯,女虚坟墓居宝瓶。\n危室雷电如飞马,壁一在南当边兵。\n天大将军与奎北,壁二也归仙女星。\n奎南右更叫双鱼,黄道之上有外屏。\n娄胃左更白羊头,昴毕天关像金牛。\n觜参参旗当猎户,五车在北做御夫。\n井与北河成双子,南河水位小犬居。\n天狼军市大犬座,鬼在巨蟹翼巨爵。\n柳星张摆长蛇阵,轸上乌鸦叫不停。\n二十八宿加三垣,西洋名字要记清。\n隋代丹元子把周天各星的步位,编成一篇七字长歌,文辞浅近,便于传诵,当时就成为初习天文的必读歌诀,非常流行,这就是《唐书·艺文志》初载的《步天歌》。《步天歌》将星空分为三十一大区,即三垣加二十八宿,包括了当时全天已定名的1464颗恒星。我们读着《步天歌》,按着方向,或向东,或向南,由甲星到乙星,到丙星,好像在天上一步一步地走过去一样,条理分明,方便记忆。清代星历家梅文鼎评价《步天歌》:“句中有图,言下见象,或丰或约,无余无失。”\n现今能见到的《步天歌》已非丹元子原文,多源于《仪象考成续编》卷三所载之《星图步天歌》。今录《古今图书集成·乾象典》所载《步天歌》有关紫微垣一段,以示一斑。\n中原北极紫微宫,北极五帝在其中。\n大帝之座第二珠,第三之星庶子居,\n第一号曰为太子,四为后宫五天枢。\n左右四星为四辅,天乙太乙当门路。\n左枢右枢夹南门,左八右七十有五。\n上少宰兮上少弼,上少卫兮少丞数,\n前连左枢共八星,后边门东大赞府。\n少尉上辅少辅继,上卫少卫上丞比,\n以及右枢共七星,两藩营卫于斯至。\n阴德门里两黄聚,尚书以次其位五。\n女史柱史各一星,御女四星天柱五。\n大理两黄阴德边,勾陈尾指北极颠,\n勾陈六星六甲前。天皇独在勾陈里,\n五帝内座后门是。华盖并杠十六星,\n杠作柄象华盖形,盖上连连九个星,\n名曰传舍如连丁。垣外左右各六珠,\n右是内阶左天厨。阶前八星名八穀,\n厨下五个天棓宿。天体六星两枢外,\n内厨两星左枢对。文昌斗上半月形,\n依稀分明六个星。文昌之下曰三师,\n太尊只向中台明。天牢六星太尊边,\n太阳之守四势前。一个宰相太阳侧,\n更有三公柄西偏。杓下元戈一星圆,\n天理四星斗里暗,辅星近著太阳淡,\n北斗之宿七星明,第一主帝为枢精,\n第二第三璇玑是,第四名权第五衡,\n开阳摇光六七名,摇光左三号天枪。\n隋代以前,二十八宿仅指星宿个体,而《步天歌》始,每宿所指已是一大片星区。如讲到角宿,歌词是:\n两星南北正直著。中有平道上天田,\n总是黑星两相连。前有一鸟名进贤,\n平道右畔独渊然。最上三星周鼎形,\n角下天门左平星,双双横于库楼上,\n库楼十星屈曲明。楼中柱有十五星,\n三三相聚如鼎形。其中四星别名衡,\n南门楼外两星横。\n角宿星区所指,北至周鼎(去极64度半),南至南门(去极137度),实际上已包括了十一个星座,南北一大片了。夜观天象,常常以亮星为基准,由此推延开去。西方天文学上表示星的亮度,有一套独特的“星等”系统,天文图上就根据星等标志星宿的亮度。两千年前,希腊天文学家喜帕恰斯把肉眼可见的星按亮度分为六等,最亮的星称为一等星,肉眼刚能看到的星为六等星,其他星按视亮度插入,星越亮,星等越小。后人沿用这套星等系统,并经仪器检验加以精密化,规定:星等相差5等,亮度相差100倍。因此,星等增加一等,亮度变暗1001/5,即2.512倍。\n现今能用望远镜拍摄到暗达23等的星。有几颗亮星比1等星更亮,便向0等、负的等星扩充。最亮的恒星天狼星是-1.45等。金星最亮时达-4.22等,满月是-12.73等,太阳是-26.82等。下面是天空中二十颗最明亮的星的视星等及中西名称对照。\n这二十颗亮星中,南十字座α星与β星是我们北半球区不能见到的,清代以前尚无中文名称。从上可知,只有心宿二(古称火、大火)才是标准的一等星。\n这里我们讲一讲古代天文图。现今常见的天文图有两种,一是《辞海》理科分册所附“天文图”,那是以世界通用的西方八十八个星座划分天区的天文图。还有一种是王力主编《古代汉语》所附伊世同绘“天文图”,图中将我国古代主要星群都绘制出来,图上列有西方星座名称,初学者使用方便,堪称简明。\n东汉官图\n中国古代的天文图是一个圆形图,符合天圆之说。至迟在汉代就比较完备了。蔡邕《月令章句》中有一段文字记叙了当时的天文史官使用的官图。根据构拟,东汉官图如图(见下页)所示:用红色绘出三个不同直径的同心圆,圆心就是北天极。最内的小圆称作内规,也叫恒显圈。最外面的大圆称作外规,即南天可见的界线。中间一个圆代表赤道,它距南北两极相等,所以称“据天地之中”。文字中有“图中赤规截娄,角者是也”的话,所以二十八宿和黄道也就必不可少。除了二十八宿,还应当有中、外星官等。这就是《汉书·天文志》所载“天文在图籍昭昭可知者”的图籍。后代圆形星图的大圈外还标明地理分野、十二辰、十二次;靠内记二十八宿距度,赤经线按二十八宿距度画出,可从图上看出采用赤道坐标系标注天体位置。\n圆形星图的黄道本应是一个扁圆形,但古人也画成了一个正圆,致使星图上赤道以南的星官形状变形很大。隋代前后出现了一种用直角坐标投影的卷形星图,称作横图,弥补了这个缺点。《隋书·经籍志》载“《天文横图》一卷高文洪撰”就是。后来,除了一张表示赤道附近星官的横图,又画了一张以北极为中心的圆形图标注赤极附近的星官。王力《古代汉语》中所附天文图就源于隋唐时代的这种星图,一张横图,一张圆形图。\n这里再给大家介绍两个图表。表一(见下页)显示了中国传统的天文学观点,表二(见下页)是张汝舟先生的创制,反映张氏的古天文观。表一、表二的大圆圈,就是从地球赤道线之北23度半的圆周线上向高空延展而成的。二十八宿罗列在这条线上或稍南或稍北,就在这个大圆圈上移动。这个大圆圈,又名天球上的北回归线,自古一贯称为黄道。南回归线在南半球的高空,我们祖先没有利用它。西方天文学讲的“黄道”,与我国旧说不同。我们用旧说是为便于清楚地说明问题。\n表一向西指的箭头,是表示二十八宿的西行。表二加画一个东指箭头,表示二十八宿却又向东缓慢偏移,形成了“岁差”,从而规定了二十八宿以及北斗柄的运行关系。\n表一的二十八宿配四象,所以必按四象次序,从东方的南端,列角、亢、氐、房、心、尾、箕,向西移动;接着北方七宿斗、牛、女、虚、危、室、壁,也就从北方的东端向西移;西方、南方同样如此排列。这个排列次序,表明二十八宿向西移动。\n表一表二如用《尧典》“日永星火”“宵中星虚”来检验,可见表一之误。按表一所示,虚宿在火(心)宿之西83度弱(心52度加尾18度,加箕11度,加斗2614度,加牛8度,加女12度,加虚102度,得此数)。夏历五月,“日永星火”,即夏至昏火中。二十八宿西移,到八月,火(心)宿已落到地平线下,虚宿应在中天,即“宵中星虚”。按表一,火(心)宿纳入地平,虚宿已在地平之下83度多了,根本不合天象。\n更以岁星东移证明表一的错误。春秋期间,星历家把赤道圈划为十二等分,名十二宫,又名十二次,即星纪、玄枵、娵訾、降娄、大梁、实沈、鹑首、鹑火、鹑尾、寿星、大火、析木。每年岁星(木星)顺序移一次,十二年一转头,叫“岁星纪年”。《国语》《左传》里所谓“岁在星纪”“岁在玄枵”……代表子年、丑年……汉代理解为与岁星运行方向相反的“太岁”年,称为十二辰。《淮南子·天文训》称:“岁星为阳,右行于天;太阴为阴,左行于地。”《史记·天官书》称:“岁阴左行在寅,岁星右转居丑。”太阴、岁阴,就是太岁,也称“假岁星”。表一就在于反映这个“太岁左行”“岁星右行”。由于四象作祟,把宿位排颠倒了,造成混乱,与十二辰不相应,不得不把它也倒过来。反加混乱。从《汉书·律历志》“次度”可看出,星纪是十一月,建子;玄枵是十二月,建丑……只纪月不纪年,足证星纪、玄枵等名目与岁星纪年毫无关系。\n因四象之害,以至引起二十八宿排列之误倒,不得不妄列十二支与传统相反的排列,如表一外列的十二支,形成一个西行的箭头指示,不符合北斗柄东指十二支以定月;不符合冬至点71年8个月西移一度,就是恒星东移一度,十二次也是东移。表二改动的要点在此。\n表二纠正了历代就二十八宿配四象所造成的错误,恢复了二十八宿宿位排列的本来面目。这张表由于调整了二十八宿的位置,调整了十二宫、辰的位置,加了岁差、木星、北斗柄的方向,与传统的排列法比较,不仅彻底摆脱了“四象”的束缚,放正了二十八宿的位置,使“地望”“占卜术”无所依存,而且在以下几个方面有它突出的意义。\n1.二十八宿的运行与二十四节气的配合取得了一致。此表依据《次度》,二十八宿运行方向由东向西(箭头标明),冬至点在牛初,春分点在娄4度,夏至点在井31度,秋分点在角10度,历历分明。节气顺次与二十八宿运行方向一致。\n2.北斗柄方向与四季的方向、二十八宿运行方向吻合。由于北斗柄绕着北极转动,一年四季斗柄指向不同的方位,此表加一个北斗柄方向,与二十四节气顺次配合无误,与二十八宿运行也就吻合。这也表示了古人把北斗、北极与二十八宿紧密相连的观测星象方法。\n3.否定了“岁星纪年”。传统的二十八宿安排有内外十二支排列,这是迷恋岁星纪年,又假想出一个与木星成相反方向运行的假岁星(太岁)。张汝舟先生表二取消了假岁星的安排,明确了木星运行方向,十二支与纪月的“星纪、玄枵、娵訾……”相配合,彻底改变了对“岁星纪年”的认识。昙花一现的“岁星纪年”不过是四分历产生之前观象授时阶段的一个插曲而已。\n4.明确了岁差与岁差的方向。岁差虽是东晋人虞喜发现并加以计算,可是汉代已有明确记载:西汉末冬至点在建星(南斗尾附近),东汉时冬至点在斗宿2114度。冬至点从牛初的移动表明,汉代天文学家已观察到恒星的位移,并记录下来,更证明“冬至点在牛初”是战国初期的实际天象。\n三、《尧典》及四仲中星 # 上古时代,观象授时的历史是相当漫长的。《史记·天官书》载:“昔之传天数者:高辛之前,重、黎;于唐、虞,羲、和;有夏,昆吾;殷商,巫咸;周室,史佚、苌弘;于宋,子韦;郑则裨灶;在齐,甘公;楚,唐昧;赵,尹皋;魏,石申。”《史记·历书》记:“太史公曰:神农以前尚矣,盖黄帝考定星历,建立五行,起消息,正闰余,于是有天地神祇物类之官,是谓五官。各司其序不相乱也。”《国语·楚语》云:“少昊之衰也,九黎乱德,民神杂糅,不可方物。颛顼受之,乃命南正重司天以属神,命火正黎司地以属民。其后三苗复九黎之德,尧复育重黎之后不忘旧者,使复典之,故重黎氏世叙天地。”《国语·郑语》也说:“夫黎为高辛氏火正,以淳耀惇大,天明地德,光昭四海,故命之曰祝融。”\n这些记载都说明,先民对天象的观测可追溯到传说的远古时代。那时,南正火正的职务是分别由两人(重、黎)担任的。到后来,合南正、火正之职由一人主之,又以氏代事,重黎由人名变成职事之名,由二人之名合一名了。《尧典》所叙“羲和”与此大致略同。有以羲和为一人的,有以羲氏和和氏相称的,都不难理解。《楚语》韦昭注云:“尧继高辛氏平三苗之乱,继育重黎之后,使复典天地之官,羲氏和氏是也。”由此看出,传天数者还是祖传,有世家,观测天象的连续性有了保证。典籍中所记的结论也就比较可信,不必看做全是传说虚构。\n现存典籍最早而又比较完整记录观象授时的文字是《尚书·尧典》。对这段文字我们应当高度重视,因为它涉及的内容比较广泛,可以看做是上古观象授时的总结,可从中窥探先民丰富的天文学知识。\n其文曰:“乃命羲和,钦若昊天,历象日月星辰,敬授民时。”\n这一段讲尧用羲氏和氏家族中之贤能者,敬顺天理,观测日月星辰的运行,掌握其规律,以审知时候而授民,便于农事。\n要点:\n1.韦昭云,重黎之后为羲和。郑玄谓,尧育重黎之后羲氏和氏之贤者,使掌旧职天地之官。\n2.历,数也。就是观测。日月星辰,如《管子·四时篇》言,分别有所指。《左传·昭公七年》“晋侯谓伯瑕曰:何谓六物?对曰:岁、时、日、月、星、辰是也”,亦可证之。又,《尸子》“燧人察辰心而出火”,所谓“辰心”,就是恒星心,就是心宿。观心宿昏现而举行“出火”活动。比照《公羊传·昭公十七年》“大火为大辰,伐为大辰,北极亦为大辰”,凡大辰所指,皆为恒星。所以言“大”,是以之为观测星象的标准星。最早,先民是以北极星作为观测天象的标准星的,所以“北极亦为大辰”。夏代以参星(伐在参宿中)作为观测天象的标准星,所以“伐为大辰”。商代以大火(心宿二,即商星)作为观测天象的标准星,所以“大火为大辰”。凡日月星辰并举之“辰”,当指恒星而言。星,当指五星,《左传·昭公七年》“日月之会是谓辰”,那是指“一岁日月十二会,则十二辰”,是明显的时间概念。水星古名辰星,取近日不出一辰。辰星之“辰”是一个空间概念。后代“星辰”连续,泛指除日月之外的所有行星、恒星,也是可以理解的。\n分命羲仲,宅嵎夷,曰旸谷。寅宾出日,平秩东作。日中星鸟,以殷仲春。厥民析,鸟兽孳尾。\n申命羲叔,宅南交,曰明都。平秩南讹,敬致。日永星火,以正仲夏。厥民因,鸟兽希革。\n分命和仲,宅西,曰昧谷。寅饯纳日,平秩西成。宵中星虚,以殷仲秋。厥民夷,鸟兽毛毨。\n申命和叔,宅朔方,曰幽都。平秩朔易。日短星昴,以正仲冬。厥民隩,鸟兽氄毛。\n此四段文字可合读。有祖传专长的天文官分布四方,设固定观测点,进行长期观测,以东南西北、春夏秋冬分叙所观测到的日月星象、民事、物候等。\n要点:\n1.羲仲,官名,指春官。羲叔指夏官。和仲指秋官。和叔指冬官。仲、叔指羲氏和氏家族之子。《楚语》所谓“重黎氏世叙天地”,至夏商为羲氏和氏。\n宅,度。宅,古音定纽铎部;度,定纽模部。纽同,唐铎模对转。度即测量,观测。观测什么?当是表景。以定日出日入的时辰,表则相当于日晷。日中测景定节气,则为土圭。\n嵎夷、南交,不必是实指辽西某地或古交趾。嵎夷泛指东方,南交泛指南方,与下文西、朔方一致。连前“宅”字,当为观测四方表景,定春夏秋冬四时的日出日入时分。\n旸谷、明都、昧谷、幽都,当实指,即具体观测点。旸谷即首阳山谷,在今辽阳境。明都,依郑注增,不可考。昧谷即蒙谷,无考。幽都即幽州。\n2.寅宾,寅,敬也,礼敬如接宾。出日,方出之日。春分日测朝日之景,当在卯时,必先候之,如接宾客。秋分日测夕日之景,当在酉时,有饯别之义,故“寅饯纳日”。\n平秩,当作釆秩,辨别秩序义。东作,东始。言日月之行从东始,即以春分日为起算点。秋分时日月正好运行一年之半,故言“西成”,即西平,取平半义。\n南讹、朔易:讹,动义;易,变义。古称赤道为中衡,北回归线古称内衡,南回归线古称外衡。南讹,言日自内衡南行。朔易,言日自外衡北返。这几句是说,观测日景变化,分别日月运行的起点——东作(春分点),南讹极点(冬至点),中点——西成(秋分点),朔易极点(夏至点)。\n敬致,言冬夏致日,致日犹底日(《左传》)、待日,与寅宾、寅饯参照,言夏冬待正午日出以观表景。\n3.日中星鸟,日永星火,宵中星虚,日短星昴。这是讲春分、夏至、秋分、冬至四个气日的中星。浑言之,仲春的中星是星宿,仲夏的中星是火(心宿二),仲秋的中星是虚宿,仲冬的中星是昴宿,所谓“四仲中星”指此。\n4.析、因、夷、隩,皆为动词,指春夏秋冬四时民众的活动。析,分也,春日万物始动,当分散求食也。因,就也,夏日花果繁茂,当聚合就食也。夷,通怡,秋日果实累累,食多喜悦也。隩,冬日寒气降,当掘室避寒也。这是就上古部族人的活动说的。民以食为天,以“食”为解,更近其实。\n5.鸟兽孳尾、希革、毛毨、氄毛,指鸟兽在不同时令的表象。春季,鸟兽交配繁殖。夏季,毛稀而露皮革。秋季,毛羽鲜洁。冬季,生细毛自温。\n帝曰:咨,汝羲暨和。期三百有六旬有六日,以闰月定四时成岁。允厘百工,庶绩咸熙。\n这一段意思是,帝尧说:你们羲氏和氏子弟,观测天象,得知春夏秋冬一年有366日,又以置闰月的办法调配月与岁,使春夏秋冬四时不差,这就可以信治百官,取得各方面的成功了。这几句可以看做是尧对羲氏和氏勤劬观测验天象的“嘉奖令”。亦见上古帝王对观象制历何等重视,更看出星历在指导生产中的重要作用,在社会职事上的特殊地位。\n《尧典》一书,很多人认为是周代史官根据古代传闻旧说而编写的,还经春秋、战国时代所增补,但所记天象却不是春秋以后的,这一点可以肯定。“日中星鸟,以殷仲春”,“日永星火,以正仲夏”,“宵中星虚,以殷仲秋”,“日短星昴,以正仲冬”,这四句是实际天象的记录,标志着它产生的时代。历代都有人对《尧典》四仲中星进行研究,希望找到产生四仲中星的准确时代。其方法是,依据四颗中星的赤经差,再用岁差法计算出它的年代。近人更应用现代天文学的方法严格地推算。《新唐书·天文志》载,李淳风说:“若冬至昴中,则夏至、秋分,星火、星虚皆在未正之西。若以夏至火中、秋分虚中,则冬至昴在巳正之东。”四仲中星彼此是有矛盾的。正如竺可桢先生说:“以鸟、火、虚三宿而论,至早不能为商代以前之现象。惟星昴则为唐尧以前之天象,与鸟、火、虚三者俱不相合。”他以为观测星昴出现于南中天的冬季,正值农闲,天气寒冷,观测时间一定大为提前。这样解释,四仲中星就只能是殷末周初的天象。这是以鸟、火、虚三宿位置确定的。\n竺可桢先生研究《尧典》四仲中星,还认为四季的划分在认识天象方面起了关键作用。因为先民早就意识到,四季交替与恒星的运转有一种内在的联系,上古“观天数者”主要任务之一就是探索这内在联系的规律性。因此,先民对恒星分布的认识当是起源很早的,这才有《尧典》产生时代的比较准确的四仲中星的记载。\n以现代天文学科学数据逆推,四仲中星是公元前2000年的天象。据发掘陶寺夏墟遗址,确定的夏代纪年:约公元前2500年至公元前1900年,那么,四仲中星当是夏代的星象,考虑到肉眼观测的粗疏,再参证出土的甲骨卜辞,可以断定,至迟到殷商时代(公元前18世纪到前11世纪,准确地说,商代当是公元前1734—前1107年)古人已能用昏南中星测定二分二至,并能用闰月调整朔望月与回归年的关系。回归年计为366日,与真值相差甚远,不能据以创制历法,只能靠观象确定大致的节气时日。不过,更能说明不是什么“朏为月首”。\n《尧典》四仲中星举出了四颗标志星:鸟、火、虚、昴。鸟,即星宿。不言“星星”而言“星鸟”,避不成词。这四颗星是二十八宿中最关键的星,彼此的间距不是精确地相等,但已大致将周天划为四段,已见“四象”的雏形。所谓“星鸟”,以鸟代星宿,足证“四象”是以朱鸟为基础逐步发展完善的。《尧典》四仲中星已有周天恒星分为四方的意思。二十八宿中最关键的四颗星都处在四方的腹心位置。二十八宿当以这四颗星为基础发展完备起来。而二十八宿分为四群的意识产生得也很早,虽不能断定就有“四象”,说《尧典》已见“四象”的端倪还是不错的。\n《左传·昭公十七年》载:“我高祖少皞挚之立也,凤鸟适至,故纪于鸟,为鸟师而鸟名:凤鸟氏,历正也;玄鸟氏,司分者也;伯赵氏,司至者也;青鸟氏,司启者也;丹鸟氏,司闭者也。”凤鸟氏是历正,下面还有四名鸟官分管分(春分、秋分)、至(夏至、冬至)、启(立春、立夏)、闭(立秋、立冬),足见鸟的形象与天文学的密切关系。郑文光以为,鸟与云、火、龙一样,为原始氏族的图腾或自然崇拜。以鸟为图腾的原始氏族,把春天初昏南天的星象描绘成一只鸟,那也是容易理解的。更后,岁星(木星)纪年划周天为十二次,其中鹑首、鹑火、鹑尾三次相连,就是南宫朱鸟,包括井鬼柳星张翼轸七宿。岁星纪年虽行用于春秋中期之后,而“鹑”之名是早就有的了。\n总之,《尧典》所记载的天象,内容是丰富的。元代许谦在《读书丛说》中这样概括它:“仲叔专候天以验历:以日景验,一也;以中星验,二也;既仰观而又俯察于人事,三也;析因夷隩,皆人性不谋而同者,又虑人为或相习而成,则又远诸物,四也。盖鸟兽无智而囿于气,其动出于自然故也。”\n四、《礼记·月令》的昏旦中星 # 除了《尧典》,观象授时的完整记载还保存在《礼记·月令》之中。战国末期的《吕氏春秋》及西汉《淮南子》所记,亦与之大体吻合。前贤多以“月令载于《吕览》”为说,而《月令》对后世的影响是很大的,因为它与农业生产关系密切,直接指导着农事的安排。汉代以后,差不多历代都有类似《月令》的农书或总括天象、节气的《月令图》。著名的如汉代《四民月令》(崔寔)和《唐月令》。清李调元说:“自唐以后,言月令者无虑数十百家。”可见古人对《月令》的重视。\n《礼记·月令》所记天象是:\n孟春之月,日在营室,昏参中,旦尾中。\n仲春之月,日在奎,昏弧*中,旦建星中。\n季春之月,日在胃,昏七星中,旦牵牛中。\n孟夏之月,日在毕,昏翼中,旦婺女中。\n仲夏之月,日在东井,昏亢中,旦危中。\n季夏之月,日在柳,昏火中,旦奎中。\n孟秋之月,日在翼,昏建星中,旦毕中。\n仲秋之月,日在角,昏牵牛中,旦觜觿中。\n季秋之月,日在房,昏虚中,旦柳中。\n孟冬之月,日在尾,昏危中,旦七星中。\n仲冬之月,日在斗,昏东壁中,旦轸中。\n季冬之月,日在婺女,昏娄中,旦氐中。\n*弧在舆鬼南,建星近斗。\n二十八宿横亘一周天,如果知道初昏中星,就能确知其他三个时辰的中星。子、卯、午、酉四个时辰正好一周,处于四个象限,一推即得。正午太阳的位置,就是午时中星的位置,与夜半中星相冲的星度正是太阳所在,这时(午)太阳的视位最好,正好测影。\n我们利用《天文图》找到它的春分点、夏至点、秋分点、冬至点,就可以推知一日四个时辰(卯—旦,午—日中,酉—昏,子—夜半)的中星。因为二十八宿不仅有周年视运动,也有周日视运动,即一日行经一周天(实际是地球自转一周)。\n如果某日晨朝(卯)的星象是《天文图》上春分点的星象,当日正午就是夏至点的星象,当日黄昏就是秋分点的星象,当日夜半就是冬至点的星象。其余可按上表类推。\n《月令》所记是一年十二个月昏、旦、午三个时辰的宿位,夜半(子)的中星自然是容易推出的。应注意的是,冬夏季节昼夜时刻不均,用对应方法(利用四个象限)推求,未必就得实际天象。当然相去是不会远的。如果用二十八宿距度对照《月令》所记,我们可以发现,《月令》宿位已经考虑了四季昼夜时刻不均等的现象,《月令》所记应看做是实际天象的实录。虽然一般的看法以为《月令》录于《吕览》,而《吕览》所记仍来源于前朝史料,非战国末年的观测记录。从《月令》与诸典籍有关星象记载的对照可以看出,《月令》乃丑正实录,当是春秋前期或更早时期的星象记录。\n二十八宿与回归年的节气时令有如此紧密的联系,所以古人用二十八宿的中天位置来表达节气时令。古籍中这方面的记载很多,都应看做是观象授时的文字材料。\n《诗·定之方中》:“定之方中,作于楚宫。”定,即室宿。室宿初昏见于南天正中,正值秋末冬初,农事已毕,可以大兴土木,营建宫室。\n《周礼·夏官》:“季春出火,民咸从之,季秋内火,民亦如之。”此处“火”星即心宿,从商代起就受到重视,古书中多有记载。这里的“出火”,指火星昏现,这里的“内火”,有人解作“火星始伏”,其实“内”与“伏”还不是一回事。辨见后“《诗·七月》的用历”一节。\n《书·传》云:“主春者张,昏中可以种谷;主夏者火,昏中可以种黍;立秋者虚,昏中可以种麦;立冬者昴,昏中可以收敛。”这实际上是对《尧典》四仲中星的解说,只不过把“日中星鸟”之“鸟”理解为张宿。张、火、虚、昴成了春夏秋冬四仲中星,昏现南中天,与农事大有关系。亦见观象授时服务于农事。\n《国语·周语》载:“辰角见而雨毕,天根见而水涸,本见而草木节解。驷见而陨霜,火见而清风戒寒。”这是晨旦观星象的记录,对初秋到深秋的物象变化结合天象作了一番描述。角宿晨见,雨季已毕;天根(氐宿)晨见,河水干涸;本(亢宿)晨见,草木枯落;驷(房宿)晨见,开始降霜;火(心宿)晨见,寒风即至。从星象与时令关系说,“辰角见”即“晨角见”,辰通晨。有人是将“辰角”作为角宿看待的。\n另外,《尚书·洪范》伪孔传:“月经于箕则多风,离于毕则多雨。”就是源于《诗·渐渐之石》“月离于毕,俾滂沱矣”,指月亮经天,在箕宿或毕宿的位置。苏轼《前赤壁赋》“月出于东山之上,徘徊于斗牛之间”,也是以二十八宿(斗、牛)来表述月亮的位置。\n附《月令总图》于下。\n月令总图\n五、北极与北斗 # 地球自转有一定的倾斜度,其自转轴的北端总是正对着天球北极。地球自转,反映出恒星在天幕上的周日视动,地球公转反映出恒星在天幕上的周年视动。在恒星的视运动过程中,天球北极是不动的,其他恒星都在绕着它旋转。身处北半球的华夏族先民,对北极星的观测是高度重视的,所谓“北极亦为大辰”,指的是夏代以前的传说时代以北极星为观测群星运动的标准星。《论语·为政》“为政以德,譬如北辰,居其所,而众星共之”,是以众星绕北极旋转的天象来说明事理。《周礼·冬官·考工记》云:“昼参诸日中之景,夜考之极星,以正朝夕。”后人的记述确也反映了北极星在观象授时的早期所起的重要作用。\n《吕氏春秋·有始览》云:“极星与天俱游而天极不移。”古人已看出,当时的北极星(应是帝星即小熊座β)不在北天极上,北极星也在绕天极旋转,只不过它的视运动轨迹所形成的圆圈很小罢了。这就引出了我国古代天文学中一个独特的概念——璇玑玉衡。\n《尚书·大传》称:“璇者还也,玑者几也,微也。其变几微,而所动者大,谓之璇玑。是故璇玑谓之北极。”《星经》(即《续汉志十注补》)称:“璇玑者谓北极也。”刘向《说苑·辨物》也说:“璇玑谓北辰,勾陈枢星也。”\n不难明白,凡是旋转的东西都可以称为“璇玑”。北极星靠近北天极,也以很小的圆形轨迹绕天极旋转,所以称“北极璇玑”。\n所谓玉衡,是指极星附近很明亮的北斗七星。我国黄河中下游约处于北纬36度,天球北极也高出当地地平线36度。以36度为半径画一个圆,叫恒显圈,其中的星星绕北极旋转而始终不隐入地平线下。北斗七星正处在恒显圈内,终年可见。北斗由七星构成大勺形(见左图)。天枢、天璇、天玑、天权组成斗身,古称魁;玉衡、开阳、摇光组成斗柄,古称杓。《史记·索隐》引《春秋纬·运斗枢》:“斗,第一天枢,第二璇,第三玑,第四权,第五衡,第六开阳,第七摇光。第一至第四为魁,第五至第七为杓,合而为斗。”如图所示,将天璇、天枢连成直线,延长五倍的距离,可以找到北极星。北极星就是北方的标志。古人观星,总是将北极与北斗联系起来,以此定方位,定季节时令。《淮南子·齐俗训》云:“夫乘舟而惑者不知东西,见斗极则寤矣。”《史记·天官书》说:“北斗七星,所谓‘璇玑玉衡以齐七政’。……斗为帝车,运于中央,临制四乡,分阴阳,建四时,均五行,移节度,定诸纪,皆系于斗。”\n如果用地支指代方位,将北斗柄所指与二十八宿、二十四节气配合起来,按斗柄所指定出月份,即所谓“斗建”。\n斗柄所指孟春,日月会于娵訾,斗建寅\n仲春,会于降娄,斗建卯\n季春,会于大梁,斗建辰\n孟夏,会于实沈,斗建巳\n仲夏,会于鹑首,斗建午\n季夏,会于鹑火,斗建未\n孟秋,会于鹑尾,斗建申\n仲秋,会于寿星,斗建酉\n季秋,会于大火,斗建戌\n孟冬,会于析木,斗建亥\n仲冬,会于星纪,斗建子\n季冬,会于玄枵,斗建丑\n是随十二月运会,斗柄随月以建。《淮南子·时则训》与此同理:“孟春之月,招摇指寅,昏参中,旦尾中。……仲春之月,招摇指卯,昏弧中,旦建星中。”《古诗十九首》“明月皎夜光,促织鸣东壁;玉衡指孟冬,众星何历历”,是以斗柄所指来描绘夜色。用招摇,用玉衡,皆同斗柄。在更古的年代,招摇、玄戈也在恒显圈内,所谓“斗九星”即是。\n至于《鹖冠子·环流》所记:“斗柄东指,天下皆春;斗柄南指,天下皆夏;斗柄西指,天下皆秋;斗柄北指,天下皆冬。”那是保留了比较古老的根据斗柄回转而定四时的俗谚。\n肉眼观察到的北极星,位置是固定的,北斗七星在星空中也十分显眼,那就不难测出它们方位的变化。所以,先民观察北斗的回转以定四时。古籍中众多的关于北斗的记载就反映了上古的遗迹。\n毕竟北斗只在一个不大的恒显圈内回转,比不上赤道附近恒星群视运动的视角大,更便于观测。所以,在夏商时代,先民就有观察某些特定恒星以定时令的习惯,夏代以参宿昏现西方,殷代以大火昏现东方,作为春季到来的标志。更进一步,就以二十八宿为背景,测定昏旦中星以定四时。《尚书·尧典》就体现了用四颗恒星的昏中来测定四时的观测方法。\n《史记·天官书》记“杓携龙角(斗柄指向角宿),衡殷南斗(衡对向南斗宿),魁枕参首(1~4星枕于参宿之首)”,是将北斗与二十八宿联系起来,摆到更大的空间来加以描述。这就可以通过对北斗星的观测,估计出处于地平线以下各宿的大约位置。\n所谓“璇玑玉衡”在远古时代就是指北极、北斗。随着观测星象由斗极转移至恒星群,加以观测仪器的创制,“璇玑玉衡”似又有了新的含义。东汉时代起,更有人认为,“璇玑玉衡”是一种天文仪器,如马融、郑玄、蔡邕是。《后汉书·天文志》:“帝在璇玑玉衡以齐七政”,孔安国注云:“在,察也。璇,美玉也。玑、衡,王者正天文之器,可运转者。七政,日月五星各异政。舜察天文,齐七政也。”宋代沈括在《梦溪笔谈》卷七中说:“天文学家有浑仪,测天之器,设于崇台,以候垂象者,即古玑衡是也。”这显然是把璇玑玉衡看做类似浑仪的仪器。\n《隋书·天文志》有一段话比较客观:“璇玑者谓浑天仪也。……而先儒或因星官名,北斗第二星名璇,第三星名玑,第五星名玉衡,仍七政之言,即以为北斗七星。载笔之官,莫之或辨。”可见持北斗说,来源甚早;持天文仪器说,大有人在。\n郑文光氏以为:“星象观测和仪器的发明之间存在一定的关系,天文仪器的设计思想,往往是从星辰的运动得到启示的。”由北极璇玑四游的实际天象,到利用北斗七星回转的斗柄所指定方位,定四时,再进而创造天文仪器——“璇玑玉衡”——浑仪的前身,这就是一条发展的线索。所以郑氏说:“璇玑玉衡既可以是仪器,又可以是星象。或者说,是两者的辩证的统一。”\n六、分野 # 古代的占星术充分利用了各种天文现象,占星对古代天文学的影响是很大的。比如星宿的分野就是明显的反映。《史记·天官书》说:“天则有列宿,地则有州域。”把天上的星宿与地上的州国联系起来,并以星宿的运动及其变异现象来预卜州国的吉凶祸福。列宿配州国,就是所谓的“分野”。\n由于占星术随着时代有所发展,加之占星家各自所采用的系统不同,对于州国的分配方法,各种史料的记载是不一致的。\n有按五星分配的,如《史记·天官书》太史公曰:“二十八舍主十二州。斗秉兼之,所从来久矣。秦之疆也,候在太白,占于狼弧;吴楚之疆,候在荧惑,占于鸟衡;燕齐之疆,候在辰星,占于虚危;宋郑之疆,候在岁星,占于房心;晋之疆,亦候在辰星,占于参罚。”\n有按北斗七星分配的,如《春秋纬》称:“雍州属魁星,冀州属枢星,兖州、青州属机星,徐州、扬州属权星,荆州属衡星,梁州属开星,豫州属摇星。”其中魁星指天璇,枢星指天枢,机星指天机,权星指天权,衡星指玉衡,开星指开阳,摇星指摇光。《月令辑要》卷一所载,按斗九星分野叙说。\n有按十二次分配的,如《周礼·春官·保章氏》郑注称:“九州州中诸国之封域,于星亦有分焉;今其存可言者,十二次之分也。星纪,吴越也;玄枵,齐也;娵訾,卫也;降娄,鲁也;大梁,赵也;实沈,晋也;鹑首,秦也;鹑火,周也;鹑尾,楚也;寿星,郑也;大火,宋也;析木,燕也。”\n有按二十八宿分配的,如《史记·天官书》称:“角、亢、氐,兖州;房、心,豫州;尾、箕,幽州;斗,江、湖;牵女、婺女,扬州;虚、危,青州;营室至东壁,并州;奎、娄、胃,徐州;昴、毕,冀州;觜觿、参,益州;东井、舆鬼,雍州;柳、七星、张,三河;翼、轸,荆州。”\n《吕氏春秋·有始览》分配方法又不同:“天有九野,地有九州。……何谓九野?中央曰钧天,其星角、亢、氐;东方曰苍天,其星房、心、尾;东北曰变天,其星箕、斗、牵牛;北方曰玄天,其星婺女、虚、危、营室;西北曰幽天,其星东壁、奎、娄;西方曰颢天,其星胃、昴、毕;西南曰朱天,其星觜觿、参、东井;南方曰炎天,其星舆鬼、柳、七星;东南曰阳天,其星张、翼、轸。何谓九州?河汉之间为豫州,周也;两河之间为冀州,晋也;河济之间为兖州,卫也;东方为青州,齐也;泗上为徐州,鲁也;东南为扬州,越也;南方为荆州,楚也;西方为雍州,秦也;北方为幽州,燕也。”这是按照中央及八方位把天分为九野,以中、东、北、西、南顺次配以二十八宿(北方独配四宿)。其东方曰苍天,北方曰玄天,西方曰颢天(颢即白义),西南曰朱天,南方曰炎天,是从五行说而来的。其东北曰变天,西北曰幽天,东南曰阳天,是从阴阳说而来的。高诱注说:“东北,水之季,阴气所尽,阳气所始,万物向生,故曰变天。西北,金之季也,将及太阴,故曰幽天。”又说:“钧,平也。为四方主,故曰钧天。”\n《淮南子·天文训》载“天有九野”和《吕氏春秋》同,只是颢天改为昊天,婺女改为须女而已。九野与地上诸国的关系,就明显不同。《天文训》称:“星部地名:角、亢,郑;氐、房、心,宋;尾、箕,燕;斗、牵牛,越;须女,吴;虚、危,齐;营室、东壁,卫;奎、娄,鲁;胃、昴、毕,魏;觜觿、参,赵;东井、舆鬼,秦;柳、七星、张,周;翼、轸,楚。”许慎注说:“角、亢、氐,韩、郑之分野;尾、箕一名析木,燕之分野;斗,吴之分野;牵牛一名星纪,越之分野;虚、危一名玄枵,齐之分野;营室、东壁一名承委,卫之分野;奎、娄一名降娄,鲁之分野;昴、毕一名大梁,赵之分野,觜觿、参一名实沈,晋之分野;柳、七星、张一名鹑火,周之分野;翼、轸一名鹑尾,楚之分野。”\n《汉书·地理志》的分野是:“秦地于天官,东井、舆鬼之分野也。……自井十度至柳三度,谓之鹑首之次,秦之分也。魏地,觜觿、参之分野也。……周地,柳、七星、张之分野也。……自柳三度至张十二度,谓之鹑火之次,周之分也。韩地,角、亢、氐之分野也。……及《诗·风》陈、郑之国,与韩同星分焉。郑国,今河南之新郑,本高辛氏火正祝融之虚也。……自东井六度至亢六度,谓之寿星之次,郑之分野,与韩同分。赵地,昴、毕之分野也。……燕地,尾、箕之分野也。……自危四度至斗六度,谓之析木之次,燕之分也。齐地,虚、危之分野也。……鲁地,奎、娄之分野也。……宋地,房、心之分野也。……卫地,营室、东壁之分野也。……楚地,翼、轸之分野也……吴地,斗分野也。……粤地,牵牛、婺女之分野也。”如果按二十八宿次度排列,就是:\n韩地——角、亢、氐宋地——房、心\n燕地——尾、箕吴地——斗\n粤地——牵牛、婺女齐地——虚、危\n卫地——营室、东壁鲁地——奎、娄\n赵地——昴、毕魏地——觜觿、参\n秦地——东井、舆鬼周地——柳、七星、张\n楚地——翼、轸\n这和上面所列许慎、高诱的分野说完全一样,应看做是东汉时代的分野思想。\n至于分野的来历,《名义考》说:“古者封国,皆有分星,以观妖祥,或系之北斗,如魁主雍;或系之二十八宿,如星纪主吴越;或系之五星,如岁星主齐吴之类。有土南而星北,土东而星西,反相属者,何耶?先儒以为受封之日,岁星所在之辰,其国属焉。吴越同次者,以同日受封也。”这是说,分野主要依据该国受封之日岁星具体时辰具体位置。郑文光氏以为,至少有三国不是这样分的。一个是宋,“大火,宋也”。周克商,封殷商后裔于宋,殷人的族星为大火,仍以大火为宋的分野。一个是周,“鹑火,周也”。周人沿袭殷人后期观察鹑火以定农时的习俗,鹑火于是成了周的分野。一个是晋,“实沈,晋也”。实沈是夏族的始祖,夏为商灭,其地称唐,周成王封其弟于此,称唐叔虞,就是晋国。这三个分野实际上反映了古代不同民族观测的不同的星辰。可见,分野说不能笼统地视为宗教迷信。相反,可以说,我们的研究似乎还未够深入。\n分野与社会人事相配合,成了天人相应,这是占星术的内容。古籍中有关的记载很多。《左传·昭公三十一年》记:“吴其入郢乎。……火胜金。”是就五星分野预卜吉凶的。《史记·天官书》记:“毕曰罕车,为边兵,主弋猎,其大星傍小星为附耳,附耳摇动,有谗乱臣在侧。”《后汉书·天文志》载:“王莽地皇三年十一月,有星孛于张东南,行五日不见。孛星者,恶气所生,为乱兵。……张为周地,星孛于张,东南行即翼、轸之分。翼轸为楚,是周楚之地,将有兵乱。后一年正月,光武起兵舂陵。……(孝安永初)四年六月癸酉,太白入舆鬼。指上阶,为三公。后太尉张禹、司空张敏皆免官。太白入舆鬼,为将凶。后中郎将任尚坐赃千万,槛车徵,弃市。”历代天文志,言及星象,多是这方面的内容。\n古代文学作品关于分野的写法,多是指地域说的,可看成文人以分野用典。如庾信《哀江南赋》说:“以鹑首而赐秦,天何为而此醉?”这是用十二次分野的陈规发问。王勃《滕王阁序》开头四句是:“豫章故郡,洪都新府。星分翼轸,地接衡庐。”翼宿、轸宿的分野是楚,是荆州,包括了洪州郡,滕王阁即在郡治南昌的长洲上。李白《蜀道难》有“扪参历井仰胁息”句,依《史记·天官书》,参宿分野是益州,井宿分野是雍州,“扪参历井”极写从雍州到益州整个路途的艰难。\n七、五星运行 # 除了满天的恒星,天穹中还有肉眼可见的五大行星,古人将它们与日、月合称七政或七曜。《尚书·舜典》有“在璇玑玉衡以齐七政”,后人理解为“日月五星,谓之七政”。五星又称五纬。\n五星都很明亮,比一等星还亮,金星、木星、火星的亮度超过了最亮的恒星——天狼星。加之行星在夜空中的位置常常发生变化,五星早就是先民观测的目标了。民间文学作品更将其当作歌咏的对象。《诗·大东》:“东有启明,西有长庚。”《诗·女曰鸡鸣》:“子兴视夜,明星有烂。”《诗·东门之杨》:“昏以为期,明星煌煌。”证明了先民对行星的认识已相当成熟。\n古人称金星为明星、太白,黎明前见于东方叫启明,黄昏见于西方叫长庚。古人称木星为岁星,火星又名荧惑,土星又叫镇星、填星,称水星为辰星。这些命名,当在春秋时代已经完成。战国时代五行说得以发展,金木水火土之名才冠于行星之上。\n古人观测五星运动是以二十八宿坐标为背景的。《论衡·变虚》“荧惑守心”,是指火星在心宿的位置。邹阳《狱中上梁王书》有“太白食昴”,指金星占了昴宿的位置。\n到了汉代,阴阳五行说得其完备,占星术更有发展,五星的运动也同样被附上吉凶含义。《汉书·天文志》载:“岁星所在,国不可伐,可以伐人。超舍而前为赢,退舍为缩。赢,其国有兵不复;缩,其国有忧,其将死,国倾败。所去失地,所之得地。”“荧惑,曰南方夏火,礼也,视也。礼亏视失,逆夏令,伤火气,罚见荧惑。逆行一舍二舍为不祥,居之三月国有殃,五月受兵,七月国半亡地,九月地大半亡。因与俱出入,国绝祀。……荧惑,天子理也,故曰虽有明天子,必视荧惑所在。”“太白出而留桑榆间,病其下国。上而疾,未尽期日过参天,病其对国。太白经天,天下革,民更王,是为乱纪,人民流亡。昼见与日争明,强国弱,小国强,女主昌。”“辰星,杀伐之气,战斗之象也。与太白俱出东方,皆赤而角,夷狄败,中国胜;与太白俱出西方,皆赤而角,中国败,夷狄胜。”“填星所居,国吉。未当居而居之,若已去而复还居之,国得土,不乃得女子。”等等。显然没有什么科学根据。\n现代天文学告诉我们:\n1.各行星绕日公转的方向(由西向东)是一样的,且跟地球自转方向一致。\n2.行星自转方向几乎相同,也就是自西向东,只有金星和天王星逆向自转。\n3.各行星的轨道接近圆形,且接近于一个平面,即地球轨道平面。\n4.各行星距离太阳有一定的规律。\n在长期的实践过程中,我国古历天文工作者逐渐认识了五星运行的许多特性,逐步掌握了五星运动的规律。《汉书·天文志》说:“古代五星之推无逆行者,至甘氏、石氏经,以荧惑、太白为有逆行。”《隋书·天文志》也说:“古历五星并顺行,秦历始有金、火之逆。又甘、石并时,自有差异。汉初测候,乃知五星皆有逆行。”\n行星在天空星座的背景上自西往东走,叫顺行,反之,叫逆行。顺行时间多,逆行时间少,顺行由快而慢而“留”(不动)而逆行,逆行亦由快而慢而留而顺行。\n行星的真实运动情况\n本来,行星都是由西向东运行的,各自在自己的轨道上绕太阳公转,公转一圈的时间叫做“恒星周期”。水星88日,金星225日,地球1年,火星1.88年,木星11.86年,土星29.46年。恒星周期代表日心运动,而我们是从运动中的地球观察行星的,这就有一个太阳、地球和行星三者之间的关系问题。\n如图示,我们把行星(P)、地球(E)和太阳(S)之间的夹角PES叫距角,即从地球上看,行星和太阳之间的角距离。这个距离可以由太阳和行星的黄经差来表示。黄经即从春分点起,沿黄道大圆所量度的角度。显然,对于外行星(火、木、土等)来说,距角可以从0°到180°,但对于内行星(金、水)则不能超过某一最大值。这一最大值随行星轨道的直径而异,金星为48°,水星为28°,水星离太阳的视距离不过一辰,古人因此称水星为辰星。内行星处在这个最远位置时,在太阳之东叫东大距,在西叫西大距,此时最便于观测。\n当距角∠PES=0°,即行星、太阳和地球处在一条直线上,并且行星和太阳又在同一方向时叫“合”。行星从合到合所需的时间,叫做“会合周期”。水星115.88日,金星583.92日,火星779.94日,木星398.88日,土星378.09日。对内行星来说,尚有上合和下合之分,会合周期从上合或下合算起都行。上合时行星离地球最远,显得小一点,光亮的半面朝着地球,下合时情况相反。合的前后,行星与太阳同时出现,无法看到。合,只能由推算求得。从文字记载看,到东汉四分历(公元85年)才出现了合的概念。\n就内行星来说,上合以后出现在太阳东边,表现为夕始见。此时在天空中顺行,由快到慢,离太阳越来越远。过了东大距以后不久,经过留转变为逆行,过下合以后表现为晨始见,再逆行一段,经过留又表现为顺行,由慢到快,过西大距以至上合,周而复始。在星空背景上所走的轨迹如图示,呈柳叶状。宋代沈括在《梦溪笔谈》卷八里曾说:“予尝考古今历法五星行度,唯逆、留之际最多差。自内而进者,其退必向外,自外而进者,其退必自内。其迹如循柳叶,两末锐,中间往返之道,相去甚远。”\n一个会合周期里内行星在星座间的移动情况(柳叶形)\n和内行星不同,外行星在合以后,不是出现在太阳的东边,而是在西边,表现为晨始见。因为外行星的线速度比太阳的小,虽然仍是顺行,离太阳却越来越远,结果它在星空所走的轨迹如图示,呈“之”字形。其先后次序是:合→西方照→留→冲→留→东方照→合。方照即距角PES<90°。西方照时,行星于日出前出现在正南方天空;东方照时,行星于日落后见于南中天。外行星的逆行发生在冲的前后,两次留之间,这时行星也最亮。正如《史记·天官书》所说:“反逆行,尝盛大而变色。\n一个会合周期里外行星在星座间的移动情况(“之”字形)\n内行星与外行星明显的不同是,内行星有“晨始见”和“夕始见”,而外行星只有“晨始见”。因为外行星在一个会合期内,只有一次(上)合日。《汉书·律历志》所记三统历,把外行星的会合周期叫做“见”(一次始见),内行星的叫做“复”(两次始见)。这说明汉代已注意到了内行星与外行星的区别。\n古人对行星亮度的变化也加以记载。《开元占经》卷六十四所引文字,是战国初期天文学家甘德、石申等人的遗笔,他们将五星亮度强弱分为四类:喜、怒、芒和角。历代多沿用这四个专有名词来描述五星亮度的变化:“润泽和顺为喜”,“光芒隆谓之怒”,“光五寸以内为芒”,“光一尺以内为角,岁星七寸以上为角”。\n关于五星会合周期,唐代大衍历以前,古人的定义是:从晨始见到下次晨始见的时间间隔。大衍历之后,五星会合周期的定义与现代同,指行星连续两次与太阳相合的时间。古人很重视五星会合周期,《汉书·律历志》云“日月如合璧,五星如连珠”,并以此作为理想的历元,历法中的积年法就利用行星会合周期推算出这个“五星连珠”的理想历元。1982年3月出现的天文奇观——九星连珠,是千载难逢的景象。要是发生在古代,又是大吉之兆了。\n古人对五星运行的观测有很高的水平。1974年长沙马王堆三号汉墓(葬于公元前168年)出土的《五星占》(帛书),有六千字的专文记述五星的运行。其中还排列了秦王政元年(公元前246年)到汉文帝三年(前177年)共七十年间土星、木星和金星的位置及五大行星的会合周期。《五星占》是秦汉之际人们对五星认识的宝贵资料。《五星占》中关于金星动态的叙述最为详细:\n秦始皇帝元年正月,太白出东方,[日]行百廿分,[百日;行益疾,日行一度,百六十日;]行有[益]疾,日行一度百八十七分以从日,六十四日而复遝日,晨入东方,凡二百廿四日。浸行百廿日,夕出西方。太白出西方[始日行一度百八十七分,百日],行益徐,日行一度以待之,六十日,[行]有益徐,日行画卌分,六十四日而入西方,凡二百廿四日。伏十六日九十六分。[太白一复]为日五[百八十四日九十六分日,凡出入东、西各五,复]与营室晨出东方,为八岁。\n这段文字井然有序地把金星在一个会合周期内的动态分为:晨出东方——顺行——伏——夕出西方——顺行——伏——晨出东方几个大的阶段,对第一次顺行给出了先缓后急两个不同的速率,对第二次顺行更给出先疾、“益徐”和“有益徐”三个各异的速率。这些描述都是合乎金星运行的事实的。从《五星占》可见当时人们对五星会合周期认识的明显进步,记载金星会合周期的误差已小于0.5日。\n下面将西汉以前古人关于行星周期的认识列为一表(见下表),可看出其观测精确度的不断提高。\n八、《诗·七月》的用历 # 《诗·豳风·七月》是一首上古著名的农事诗,历来为人们所珍视,但是,要想准确、完整地解释这首诗并非一件易事,记事的用历就一直解说不清。《七月》按月份记农事,月份和农事关系极为密切,从而产生了一个令人费解的问题,即《七月》诗的月份是怎样安排的?\n对这个问题,历代学者有不同的解释:《毛诗》主张周正建子,《郑笺》主张夏正建寅,王力《古代汉语》主张周历、夏历并用,高亨《诗经选注》更提出用的是特殊的豳历。然而用周正建子或夏正建寅都无法通释全诗;周历、夏历并用之说好似圆通,其实违反常理,古今中外从无一首兼用两历让人糊涂的怪诗;至于特殊而古拙的豳历说,并无史料依据,只是臆度假想。因此,《七月》诗的用历依然是个谜。\n《毛诗》产生于“三正论”流行的战国末年。毛亨相传为鲁人,齐鲁尊周,建子为正,毛亨用周正解释《七月》诗是很自然的。《毛诗》云:“一之日,十之余也。一之日,周正月也;二之日,殷正月也;三之日,夏正月也;四之日,周四月也。”既然用“十之余”通释“一之日”“二之日”“三之日”“四之日”,那么这四个月就应该是周十一月(戌)、周十二月(亥)、周正月(子)、周二月(丑),即相当于夏正(寅)的九月、十月、十一月、十二月。然而,以夏正计,“九月觱发”“十月栗烈”“十一月于耜”“十二月举趾”,是无论如何也讲不通的。毛亨也感觉难以自圆其说,于是暗中将“四之日”释为“周四月”(按周正十四月应为周二月)。这样一来,周正四月(卯)实际相当于夏正二月(卯),虽然可以疏通下文,但是自己破坏了“一之日,十之余”的前例,令丑、寅两月无着落,所谓周历也就失去了统一性。可见,用周正建子解释《七月》诗是说不通的。\n到了东汉,郑玄释《七月》。因太初改历夏正为岁首深入人心,郑玄也就用夏正建寅笺注《七月》,但同样不能令人信服。比如,“七月鸣”一句,郑笺云:“伯劳(即)鸣,将寒之候也。五月则鸣。豳地晚寒,鸟物之候从其气焉。”就是说,伯劳就该五月鸣,因为豳地(今陕西一带)晚寒,到七月才叫起来。古代陕西一带竟比中原地区晚寒两个月,这是不合情理的。再说,夏正五月芒种、夏至,六月小暑、大暑,何来“将寒之候”?再如“七月食瓜”,夏正七月立秋、处暑,一开始吃瓜就是秋瓜,就不合物候农时,至于“九月筑场圃,十月纳禾稼”,也嫌太晚了。可见,用夏正解释也不妥当。\n更遗憾的是,无论周正也好,夏正也好,都无法准确地解释“七月流火”这一天象。《毛传》释:“火,大火也;流,下也。”后世大多依此阐述。余冠英《诗经选释》说:“秋季黄昏后,大火星向西而下,就叫做‘流火’。”北京大学《先秦文学史参考资料》认为:“每年夏历五月的黄昏,这星出现于正南方,方向最正而位置最高。六月以后,就偏西而下行,所以说是‘流’。”《七月》诗如用夏正,何以不说“六月流火”呢?至于周正七月正当夏正五月,大火星正当南天正中,是不会“流”的。\n我们认为,《七月》除了月份与农事的联系之外,诗中“七月流火”一句反复咏叹,是解决该诗用历的关键,也是该诗用历的标志。如前所述,上古经历了漫长的观象授时时期,古人把星象与时令联系起来,用以安排农事,《七月》诗就是典型的例证。古人心中的“火”,指二十八宿中的心宿,星大而呈红色,人人可见,称为“火”、“大火”,因为它与农事关系极为密切,商周时代对它的出没是相当重视的。《尚书·尧典》已有“日永星火”的记载。古人将一周天分为36514度(以二十八宿为坐标),由于地球公转,二十八宿每天西移一度,每月向西移约三十度。如果定点定时观测,某星宿正月初一在南天正中,二月初一就偏西约三十度,三月初一就偏西约六十度,四月初一就偏西约九十度入西方地平线下。这就是古人观星的“中、流、伏、内”四位。《夏小正》载:“正月初昏参中”“三月参则伏”“八月辰则伏”(传:房星也。房近火,即火伏)、“九月内火”;《月令》亦云“季夏之月(六月)昏火中”,《七月》又云“七月流火”。张汝舟先生考证,《夏小正》《月令》与《七月》星象吻合,建正一致,中流伏内顺次不紊。上述记载不仅明确告诉我们星位变化,而且具体说明了火星(心宿)的运行规律,即“六月火中”“七月流火”“八月火伏”“九月内火”。可见,“流火”之“流”不能只作为“西流”“流下”泛泛解释,应该理解为火星偏西约三十度的态势。火星的中、流、伏、内(纳,或入)表明了不同月份火星在天幕上的不同位置。明白中流伏内的概念,对于古人制历,对于季节的认识,都很有帮助。\n每一星宿都是十二个月一周天,每月某星移动的位置正好与钟表的十二个刻表相合。时钟从十二点到九点的距度正合火星中流伏内的方位。季夏之月(六月)初昏火中,指火星正当顶,在时钟刻度的十二点;七月火西流,指火星向西流下约三十度,在时钟的十一点上;八月再向西流下约三十度,火星在时钟的十点,这时西方日光还强,火星未落已不能再见,所以叫“火伏”;九月,火星再向西流约三十度,相当于时钟九点的位置,与地面平行,火星已潜下,所以叫“内(纳)火”。由此可知,“中”指星宿的位置居上,正南天;“流”指西移约三十度;“伏”指隐而不见,西移约六十度;“内”指落(纳入地平线)而不见。\n如前所述,《尧典》记“日永星火”,即夏至之月火星初昏位于南天正中,《尧典》用夏正,二至(夏至、冬至)二分(春分、秋分)必在仲月,夏至正当夏正五月,正与《夏小正》《七月》《月令》所记星象有一月之差。可见,《尧典》用夏正建寅,则《月令》《七月》《夏小正》必用殷正建丑。春秋前期及至西周一代,用丑正不用子正,不用夏正。《七月》用丑正,正与此合。\n不同建正的星位对比图\n为了说明问题,下面将不同建正的星位加以对比。\n由图可知,若依《尧典》建寅为正用夏历,当为:五月火中,六月流火,七月火伏,八月内火;若依《夏小正》《月令》《七月》建丑为正用殷历,当为:六月火中,七月流火,八月火伏,九月内火;若依周历建子为正,当为:七月火中,八月流火,九月火伏,十月内火。星象如此,非人所能妄测妄断。\n如果我们不计较冬夏黄昏的时差,将《月令》《夏小正》等古籍的初昏中星记载,按中流伏内的规律列出一个表,当时的天象就十分清楚。这个表帮助我们了解星宿运动的一般规律,有助于掌握观察天象的方法。\n再说《七月》。《毛传》认为“一之日,十之余也”,无疑是正确的。《七月》诗不说“十一月”“十二月”“十三月”“十四月”,而说一之日、二之日、三之日、四之日,是为了修辞,不死板,只要理解了“七月流火”一句建丑为正的实质,就不难列出《七月》诗的月序。\n《七月》诗用殷正建丑,《诗经》中并非仅有。《小雅·四月》云:“四月维夏,六月徂暑。”徂者,往也。若按周正建子,其六月相当于夏正四月(巳),正当立夏、小满,五月芒种、夏至,六月才小暑、大暑。周正“六月徂暑”岂不太早?若按夏正建寅,其六月已是小暑、大暑。正当暑天,何“徂”之有?只有用殷正建丑来解释,其六月正当夏正五月,正值芒种、夏至,才正合“六月徂暑”(六月走向暑天)之意。《毛传》云:“六月火星中,暑盛而往矣。”显然不妥。\n所以,张汝舟先生说:《七月》诗开头一句“七月流火”,就把它用的历告诉人们了,何况还有另外一大堆资料可凭呢!\n九、观象授时要籍对照表 # 在有规律地调配年、月、日的历法产生以前,中国古代漫长的岁月都是观象授时的时代。\n对上古先民观象授时,不少典籍都有详略不同的记载,先民将全年每月的天象、气象、物象加以记录,并以此为依据提示人们各月的农事活动。这在当时自有重要意义,文字给以郑重记载,后人视为经典,也就十分自然。\n历代学人对观象授时的有关记载认识不同,在中国古代文化史的研究中便生出许多疑窦,有关典籍的真实面目反而蒙混不清了。迷误世代相传,实有澄清之必要。\n为了对观象授时进行科学的研究,为了阅读有关典籍的方便,我们收录重要典籍的有关文字,依据相同的天象条件排列为一张表(见书后303页附表一),每月分天象(天)、气象(气)、物象(物)、农事活动(事)四项,比照内容,先民观象授时的概况就可了如指掌。\n这样一经对照,我们可以发现:\n1.《尧典》全年仲月星象正与《夏小正》《诗·七月》《月令》《淮南子·时则训》季月星象相应,可见《尧典》为寅正,其余四书为丑正。足见春秋以前,没有子正。《淮南子》虽汉代之书,《时则训》全抄《月令》,《月令》实前朝之旧典。\n2.除《尧典》外,余四书建正一致,但气象、物象小有差异,这是观察时地不同造成的。《月令》记载详于《夏小正》,足证《月令》的问世必在《夏小正》之后。\n3.典籍标明各月星宿“中、流、伏、内”四位,为揭示上古建正提供了天象证据。在这个基础上考证《诗·七月》及其他诗篇的用历,就可排除三正论的干扰,得到可信的结论。张汝舟先生有《〈诗经·七月〉之用历》一文,可供参阅。\n关于《夏小正》有关时令的解说,张汝舟先生有《〈夏小正〉校释》一文,刊于《贵州文史丛刊》1983年第1期,又收入《二毋室古代天文历法论丛》,浙江古籍出版社1987年版。第四讲二十四节气\n"},{"id":158,"href":"/zh/docs/culture/%E5%8F%A4%E4%BB%A3%E5%A4%A9%E6%96%87%E5%8E%86%E6%B3%95%E8%AE%B2%E5%BA%A7/%E7%89%88%E6%9D%83-%E5%BA%8F-%E5%89%8D%E8%A8%80/","title":"版权-序-前言","section":"古代天文历法讲座","content":" 版权信息 # 图书在版编目(CIP)数据\n古代天文历法讲座/张闻玉著.—2版.—桂林: 广西师范大学出版社,2017.10 (中华优秀传统文化名家讲座) ISBN 978-7-5495-9716-1\nⅠ.①古… Ⅱ.①张… Ⅲ.①古历法-基本知识- 中国 Ⅳ.①P194.3\n中国版本图书馆CIP数据核字(2017)第106428号\n广西师范大学出版社出版发行( 广西桂林市中华路22号 邮政编码:541001 )\n出版人:张艺兵\n全国新华书店经销\n开本:700 mm × 970 mm 1/16\n印张:23 字数:280千字\n2017年10月第2版 2017年10月第1次印刷\n印数:0 001~4 000册 定价:59. 80元\n目录\n版权信息\n《古代天文历法讲座》新版序\n序\n前言\n第一讲为什么要了解古天文历法 一、时间与天文历法\n二、天文与历法\n三、天文常识\n四、历的种类\n五、古天文学与星占\n六、古代天文学在阅读古籍中的作用\n七、怎样学好古天文历法\n第二讲纪时系统 一、纪年法\n二、纪月法\n三、纪日法\n四、纪时法\n第三讲观象授时 一、地平方位\n二、三垣二十八宿\n三、《尧典》及四仲中星\n四、《礼记·月令》的昏旦中星\n五、北极与北斗\n六、分野\n七、五星运行\n八、《诗·七月》的用历\n九、观象授时要籍对照表\n第四讲二十四节气 一、先民定时令\n二、土圭测景\n三、冬至点的测定\n四、岁差\n五、节气的产生\n六、二十四节气的意义\n七、节气的分类\n八、节气的应用\n九、杂节气\n十、七十二候\n十一、四季的划分\n十二、平气与定气\n第五讲四分历的编制 一、产生四分历的条件\n二、《次度》及其意义\n三、四分历产生的年代\n四、四分历的数据\n五、《历术甲子篇》的编制\n六、入蔀年的推算\n七、实际天象的推算\n八、古代历法的置闰\n九、殷历朔闰中气表\n第六讲四分历的应用 一、应用四分历的原则\n二、失闰与失朔\n三、甲寅元与乙卯元的关系\n四、元光历谱之研究\n五、疑年的答案及其他\n第七讲历法上的几个问题 一、太初改历\n二、八十一分法\n三、关于刘歆的三统历\n四、后汉四分历\n五、古历辨惑\n六、岁星纪年\n七、关于“月相四分”的讨论\n附录 西周金文“初吉”之研究\n再谈金文之“初吉”\n一、关于蔡侯墓青铜器的历日\n二、关于“准此逆推上去”\n再谈吴虎鼎\n一、宣王十八年天象\n二、厉王十八年天象\n三、涉及的几个问题\n簋及穆王年代\n伯吕父的王年\n关于成钟\n关于士山盘\n穆天子西征年月日考证——周穆王西游三千年祭\n从观象授时到四分历法——张汝舟与古代天文历法学说\n附表一观象授时要籍对照表\n附表二殷历朔闰中气表\n附表三术语表\n主要征引书目\n后记\n《古代天文历法讲座》新版序 # 汤序波\n张闻玉先生是章黄学派在当代传统小学界的重要传人,在古代天文历法研究与西周年代考证等方面成果尤为丰硕,是学界公认的当代天文历法考据学派代表性人物。李学勤先生曾赞许张闻玉先生这方面的研究是“观天象而推历数,遵古法以建新说”。\n天文历法学乃闻玉先生学术中最为重要和精彩的部分,也是他为学的看家本领。窃以为先生学术有两大支撑:一是小学;一是古天文历法。而最有特色、影响最大的当推后者,堪为张门的独门绝技。我们知道,古天文历法至近代已几为绝学,研习殊难。清初,顾炎武在《日知录》卷三十里曾感慨:“三代以上,人人皆知天文”,而“后世文人学士,有问之而茫然不知者矣”。曾国藩在《家训》中曾说“余生平有三耻”,而第一耻即不懂“天文算学”。然而在闻玉先生他们那儿,这门学问并不如人们想象的那么神秘,这得归功于他们当年遇到了“明师”张汝舟(1899—1982)先生。\n汝舟先生系黄侃先生在中央大学时的高弟,向有“博极群书”之誉,曾在贵州高校从教二十七年,“桃李满黔中”。1957年5月,老先生因在省委统战部召开的知识分子座谈会上发表所谓“三化”言论(即奴才进步化、党团宗派化、辩证唯心化),后被打成“极右派”。“文革”中更被遣返故乡滁州南张村。他在困境中,精究古代天文历法不辍,终于拨雾见天,破解了向来被视为“天书”的《史记·历书·历术甲子篇》(四分术法则)和《汉书·律历志·次度》(天象依据),从而建立了完备而独具特色的古天文历法体系,学术贡献殊巨(汝舟先生在郁闷中读懂无人能懂的“天书”《历术甲子篇》,应了古语“文王拘而演《周易》,仲尼厄而作《春秋》”)。对这一体系,殷孟伦先生赞其“尤为绝唱”;王驾吾先生称其“补司马之历,一时无两”;而先祖父汤炳正先生告诉我:“两千年以来,汝舟先生是第一位真正搞清楚《史记·历书·历术甲子篇》与《汉书·律历志·次度》的学者。”\n1980年10月,由黄门高弟南京大学王气中教授、山东大学殷孟伦教授、南京师范大学徐复教授共同发起举办了“中国古代天文历法讲习会”,地点设在老先生任顾问教授的滁州师范专科学校,学习时间为一周。参与者有国内十七个单位的四十余人。当时老先生年事已高,辅导工作主要就是由一年前最先到滁州师专进修的闻玉先生担任。其间,先生完成了自己第一本天文历法论著《古代天文历法浅释》。此书通俗地论述了汝舟先生星历理论,是学习乃师天文历法学的重要入门书。它曾被多所大学翻印作为研究生教材,还收进程千帆先生点校南京大学1984年印的《章太炎先生国学讲演录》的《附中国文化史参考资料辑要》中。此书也是我生平第一次接触到的张氏天文历法学方面的著作,阅后如醍醐灌顶,至为心折。\n如何打开古天文历法学这扇大门,先生在书中写道:“我在从汝舟师学习的过程中有这样的体会:一是要树立正确的星历观点,才不至为千百年来的惑乱所迷;二是要进行认真的推算,达到熟练程度,才能更好地掌握他的整个体系。”又说:“古代天文历法的核心问题就是历术推算,不能掌握实际天象的推演,永远是个门外汉。”“历术,自古以来都认为推步最难,不免望而却步。依张汝舟的研究,利用两张表就能很便捷地推演上下五千年的任何一年的朔闰中气,不过加减乘除而已,平常人都能掌握。”先生整理出版汝舟先生《二毋室古代天文历法论丛》(浙江古籍出版社1987年版),书里也附有此书。先生的天文历法学说,以得汝舟先生天文历法之真传并发扬光大之,学界美誉为“张汝舟—张闻玉天文历法体系”。2009年11 月日本山口大学曾邀请他参加“东亚历法与现代化”的国际论坛,发表有关“东亚历法”的主旨演讲;《香港商报》也曾在封面以整版的篇幅介绍他的学说。先生这方面论著还有《古代天文历法论集》《古代天文历法讲座》二种,尤其是后者曾加印过多次,在读书界影响极大。《南方都市报》2008年3月23日的“国学课”(第四堂)“与古人一起仰望夜空”,所用教材即此书。米鸿宾先生主持的“十翼书院”向学员极力推崇此书,也专请闻玉先生到场讲授。1984年以来,闻玉先生先后到南京大学、湖南师大、东北师大、南昌大学、四川大学等国内高校给文史研究生亲授天文历法知识,让一代年轻学人获益。顺真居士说:“2008年,广西师范大学出版社出版了闻玉先生的《古代天文历法讲座》一书,又使这一传统绝学‘飞入寻常百姓家’,推动了‘国学’在科学性方面的进展,其嘉惠学林、开拓未来,确实是功德无量。”\n“又使这一传统绝学‘飞入寻常百姓家’”,的然。毫无疑问,闻玉先生的《古代天文历法讲座》一书,是打开“张汝舟—张闻玉天文历法体系”之大门最好的钥匙,有着长久的学术生命力。由读是书再进而研习汝舟、闻玉师弟的相关论著,这对未来一代学子于“古天文历法学”之“登堂入室”,自是有所裨益的。\n闻玉先生是书将再版,辱承下顾,徵序于余,理不当应命,义不敢遽辞,乃缀数语聊表钦仰云。后学汤序波敬序于丁酉年二月十九日。\n序 # 王气中\n古代天文历法是古代劳动人民在生产斗争中伟大的发现和创造,它代表着一个民族的文化水平,是古代文明的标志。我国是世界上最早发明天文历法的文明古国之一,我们的祖先被称为“全世界最坚毅、最精明的天文观测者”。远在四五千年以前,我国历史进入有文字记载的初期,我们的祖先已经知道观测天象,根据日、月、星辰的运转和气候的变化以及草木的荣枯和鸟兽的生灭,创造了历法。在现存的古代典籍中保存下来的关于古代天文历法的文献资料,是我们伟大中华民族的宝贵遗产。我国古代,由于生产发展的需要和社会分工,天文历法的管理和编订很早就设有专职人员。到了阶级社会,这些专职管理天文历法的人员逐渐成为统治者的附庸和臣仆,所谓“文史星历”,不得不听从最高统治者的指挥命令,因此观象授时成为国家权力的一部分,改正朔,颁布历法,成为权力的象征。加以古人对于自然现象的观察和理解都还不够精明,在很长的时间内古代天文历法蒙上了一层神秘的外衣,往往和封建迷信纠缠在一起。后之学者在传注古代典籍的时候,因为受到这种影响和局限,不能作出正确的解释。历代相传,以讹传讹,成为阅读古书的障碍。一直到现在,我们虽然已经对于我国古代天文历法有了比较深入的研究,取得了丰富的成果,但由于不能突破前人的束缚,许多重要的问题,尤其汉代以前的历法,仍然得不到确切的解答。\n已故贵州大学张汝舟教授为读通古书,对我国载籍中涉及天文历法的部分,作了深入的研究。他运用深湛的古汉语专业知识和精密的考据方法,结合现代天文科学的成就和地下出土的文物资料,对过去学者的研究成果作了细致的分析研究,去伪存真,去粗取精,建立了古代天文历法的科学体系。根据他的体系来解释汉以前的古代典籍,大都能够破除迷障,贯通大义,一扫我国古代天文历法研究中的重重雾障,为我国古代天文历法的研究开拓一个新的局面。如他认为西周时代并不是用所谓“周正”,而是仍然用殷历,以建丑为正。因此,对于《诗经》中的《豳风·七月》、《大戴记》中的《夏小正》以及《礼记》里面的《月令》等篇都能得到符合实际的解释。如他认为王国维的“月相四分说”是想当然的误解,并没有科学的根据,批判根据王氏“月相四分说”而建立起来的当代古历研究中的种种错误。如他对于日本天文史学者新城新藏定周武王克商之年在公元前1066年的错误,从多方面给以论证,指斥我国现代一些书刊仍然沿袭新城氏之说的谬误。如他对于刘歆的“三统历”,我国相传的“三正论”、“岁星纪年”、二十八宿分“四象”,以及古代相传的积年术和占卜法等等,都据理分析批判,指出它们在古代天文历法研究中的有害影响。所有这些,都是张汝舟先生对于我国古代天文历法研究的巨大贡献。\n闻玉同志受业于张汝舟先生,亲承教言。根据师说,发挥他的心得体会,曾写了《古代天文历法浅释》,先后在南京大学和湖南师范大学两校中文系、东北师范大学历史系,为硕士研究生作过专题讲演,深受同学们的欢迎。这部《古代天文历法讲座》是他在讲析的基础上,参证古籍,考释出土文物并结合教学实践经验,补充修订写成的。\n天文历法是一门专科的学术。我国古代天文历法又有自己的特殊体系和习惯用语,只有运用我国传统的体系的推步方法,许多问题才能迎刃而解。张汝舟先生《二毋室古代天文历法论丛》是一部学术专著,虽然力求浅显易懂,但不能同时兼顾古代天文历法基础知识的解说。因此,初学的人或对古代典籍涉猎不多的读者,阅读他的《论丛》仍然感到困难。这部《讲座》可以说是张先生《论丛》的衍义。\n《讲座》分章对张先生《论丛》作系统的说明。它为一般读者大众说法,补充介绍一些天文历法方面的基础知识和简明的推步方法。读者可以通过这部书对我国古代天文历法的体系获得初步的理解,对于古书中有关天文历法的问题作出确切的解释。如果进一步深入下去,读张汝舟先生的《论丛》就会更容易理解,对于我国古代历法的探索和研究,也会取得入门的途径。\n这部书的特色和价值,读者会自己去体会印证,无待烦言。但可以肯定地说,它是一部有用的、值得一读的关于我国古代历法的好书。\n一九八五年十二月于南京大学\n前言 # 1984年6月,应南京大学中文系及程千帆教授、王气中教授之邀,给南大中文系与南京师大中文系部分研究生讲了一个月(每周四次)古代天文历法,目的是让青年同志们通释古籍中可能遇到的有关问题。当时,只准备了一些粗略的材料。\n我在那次讲授的“开场白”中用了一副对联表述当时的心境:“班门弄斧,诚惶诚恐;大树遮阴,无虑无忧。”因为南京这地方,尤其是南京大学,乃藏龙卧虎之地。南大本校有天文学系,有研究古历的专家。南京有中国最高水平的紫金山天文台,台内有古历专家组。我区区无名,地处边荒,来南京讲古天文,岂不是关公面前舞大刀?自然该诚惶诚恐了。好在众望所归的几位老先生,程先生、王先生、徐复先生、管雄先生以及过世的洪诚先生,都是我的师辈,他们与先师张汝舟先生或先后同学于中央大学文学系,或共事于大江南北,我在南大演讲他的古天文学有如游子归家,是大树底下乘荫凉了,确有无虑无忧的感受。为了驱散我的惶恐,讲课时索性将题目也改作“张汝舟古天文历法”。\n讲授之后,大家反映还好,以为有实用价值。9月秋凉才想到在此基础上写一个讲稿。1985年5月,受湖南师大中文系及宋祚胤教授邀请,赴长沙讲学一月,大体就以此为本。后来,又重新整理,算是有了一个雏形。\n古历法问题,新疆师范大学饶尚宽兄进行过专门的研究,有《古历论稿》若干文字为证。我在各地讲授时直接引用了他的许多材料。\n还应当说明,在天文部分,我采用了郑文光先生的不少观点,他在《中国天文学源流》中有很多精辟的见解,给我不少启发。读者是不难从中看到痕迹的。\n第四讲《二十四节气》,我直接引用了冯秀藻、欧阳海两位专家《廿四节气》中的若干材料。因为是讲稿,要顾及知识的系统性,缺了这一部分就显得不完整。我未能与两位老先生取得联系,愧疚不已。\n稿子整理出来了。只能说,在先师张汝舟先生的导引下,我从前辈学人如陈遵妫、席泽宗诸先生的文字中,大体掌握了古代天文历法这门学问的基础知识,然后薪尽火传,希望被称为“绝学”的古代天文历法代有传人,而我自己的深入研究才刚刚起步。不过,我会切切实实地努力。\n承王气中教授亲切关怀,以八十余岁高龄为这本书稿写了序文,褒美之辞就算是对先师张汝舟先生的深切怀念吧!\n"},{"id":159,"href":"/zh/docs/culture/%E7%BD%AE%E8%BA%AB%E4%BA%8B%E5%86%85/%E4%B8%8B%E7%AF%87/","title":"下篇","section":"置身事内","content":" 下篇 宏观现象 # 上篇介绍了地方政府推动经济发展的模式。这种模式的第一个特点是城市化过程中“重土地、轻人”,优点是可以快速推进城市化和基础设施建设,缺点是公共服务供给不足,推高了房价和居民债务负担,拉大了地区差距和贫富差距。第五章分析这些内容,并介绍土地流转和户籍改革等要素市场的改革。第二个特点是招商引资竞争中“重规模、重扩张”,优点是推动了企业成长和快速工业化,缺点是加重了债务负担。企业、地方政府、居民三部门债务互相作用,加大了经济整体的债务和金融风险。第六章分析这些内容,并介绍“供给侧结构性改革”,详述“去库存、去产能、去杠杆”及“防范化解重大金融风险”。第三个特点是发展战略“重投资、重生产、轻消费”,优点是拉动了经济快速增长,扩大了对外贸易,使我国迅速成为制造业强国,缺点是经济结构不平衡。对内,资源向企业部门转移,居民收入和消费占比偏低,不利于经济长期稳定发展;对外,国内无法消纳的产能向国外输出,加剧了贸易冲突。第七章分析这些内容,并介绍党的十九大重新定义“主要矛盾”后的相关改革,详述“形成以国内大循环为主体、国内国际双循环相互促进的新发展格局”所需要的改革。\n第五章 城市化与不平衡 # 教书久了,对年轻人不同阶段的心态深有体会。大一新生刚从中学毕业,无忧无虑,爱思考“为什么”;大四毕业生和研究生则要走向社会,扛起工作和生活的重担,普遍焦虑,好琢磨“怎么办”。大多数人的困境可以概括为:有心仪工作的城市房价太高,而房价合适的城市没有心仪的工作。梦想买不起,故乡回不去。眼看着大城市一座座高楼拔地而起,却难觅容身之所。为什么房子这么贵?为什么归属感这么低?为什么非要孤身在外地闯荡,不能和父母家人在一起?这些问题都与地方政府推动经济发展的模式有关。\n城市化需要投入大量资金建设基础设施,“土地财政”和“土地金融”是非常有效的融资手段。通过出让城市土地使用权,可以积累以土地为信用基础的原始资本,推动工业化和城市化快速发展。中国特有的城市土地国有制度,为政府垄断土地一级市场创造了条件,将这笔隐匿的财富变成了启动城市化的巨大资本,但也让地方财源高度依赖土地价值,依赖房地产和房价。房价连着地价,地价连着财政,财政连着基础设施投资,于是经济增长、地方财政、银行、房地产之间就形成了“一荣俱荣,一损俱损”的复杂关系。\n这种以土地为中心的城市化忽视了城市化的真正核心:人。地价要靠房价拉动,但房价要由老百姓买单,按揭要靠买房者的收入来还。所以土地的资本化,实质是个人收入的资本化。支撑房价和地价的,是人的收入。忽略了人,忽略了城市化本该服务于人,本该为人创造更好的环境和更高的收入,城市化就入了歧途。\n1980年,我国城镇常住人口占总人口比重不足两成,2019年超过了六成(见图5-1)。短短40年,超过5亿人进了城,这是不折不扣的城市化奇迹。但若按户籍论,2019年的城镇户籍人口只占总人口的44%,比常住人口占比少了16个百分点。也就是说有超过2亿人虽然常住城镇,却没有当地户口,不能完全享受到应有的公共服务(如教育),因为这些服务的供给是按户籍人数来规划的。这种巨大的供需矛盾,让城市新移民没有归属感,难以在城市中安身立命,也让“留守儿童、留守妇女、留守老人”成为巨大的社会问题。近年来一系列改革措施的出台,都是为了扭转这种现状,让城市化以人为本。\n图5-1 城镇人口占总人口比重\n数据来源:万得数据库与国家统计局历年《国民经济和社会发展统计公报》。\n本章第一节分析房价和土地供需间的关系,讨论高房价带来的日益沉重的居民债务负担。第二节分析地区间发展不平衡,其根源之一在于土地和人口等生产要素流动受限,所以近年来在土地流转和户籍制度等方面的改革非常重要。第三节分析我国经济发展过程中出现的贫富差距,这一现象也和房价以及要素市场改革有关。\n第一节 房价与居民债务 # 1994年分税制改革(第二章)是很多重大经济现象的分水岭,也是城市化模式的分水岭。1994年之前实行财政包干制,促进了乡镇企业的崛起,为工业化打下了基础,但农民离土不离乡,大多就地加入乡镇企业,没有大量向城市移民。分税制改革后,乡镇企业式微,农民工大潮开始形成。从图5-1中可以清楚地看到,城镇常住人口自1995年起加速上涨,城市化逐渐进入了以“土地财政”和“土地金融”为主要推手的阶段。这种模式的关键是房价,所以城市化的矛盾焦点也是房价。房价短期内受很多因素影响,但中长期主要由供求决定。无论是发达国家还是发展中国家,房屋供需都与人口结构密切相关,因为年轻人是买房主力。年轻人大都流入经济发达城市,但这些城市的土地供应又受政策限制,因此房屋供需矛盾突出,房价居高不下。\n房价与土地供需 # 现代经济集聚效应很强,经济活动及就业越来越向大城市集中。随着收入增长和生活水平提高,人们高价竞争城市住房。这种需求压力是否会推升房价,取决于房屋和住宅用地供给是否灵活。若政策严重限制了供给,房价上涨就快。一个地区的土地面积虽然固定,但建造住宅的用地指标可以调整;同一块住宅开发用地上,容积率和绿化面积也可以调整。 11 这些调整都受政策的影响。美国虽然是土地私有制,但城市建设和用地规划也要受政府管制。比如旧金山对新建住房的管制就特别严格,所以即使在20世纪90年代房价也不便宜。在21世纪初的房地产投机大潮中,旧金山的住房建设指标并没有增加,房价于是飙升。再比如亚特兰大,住房建设指标能够灵活调整,因此虽然也有大量人口涌入,但房价一直比较稳定。 22 我国的城市化速度很快,居民收入增长的速度也很快,所以住房需求和房价上涨很快。按照国家统计局的数据,自1998年住房商品化改革以来,全国商品房均价在20年间涨了4.2倍。但各地涨幅大不相同。三四线城市在2015年实行货币化棚改(见第六章)之前,房价涨幅和当地人均收入涨幅差不多;但在二线城市,房价就比人均收入涨得快了;到了一线城市,房价涨幅远远超过了收入:2015年之前的十年间,北、上、广、深房价翻了两番,年均增速13%。 33 地区房价差异的主要原因是供需失衡。人口大量涌入的大城市,居住用地的供给速度远赶不上人口增长。2006年至2014年,500万人和1 000万人以上的大城市城区人口增量占全国城区人口增量的近四成,但居住用地增量才占全国增量的两成,房价自然快速上涨。而在300万人以下尤其是100万人以下的小城市中,居住用地增量比城镇人口增量更快,房价自然涨不上去。从地理分布上看,东部地区的城镇人口要比用地增速高出近10%,住房十分紧张;而西部和东北地区则反过来,建设用地指标增加得比人口快。 44 中国对建设用地指标实行严格管理,每年的新增指标由中央分配到省,再由省分配到地方。这些指标无法跨省交易,所以即使面对大量人口流入,东部也无法从西部调剂用地指标。2003年后的十年间,为了支持西部大开发并限制大城市人口规模,用地指标和土地供给不但没有向人口大量流入的东部倾斜,反而更加向中西部和中小城市倾斜。2003年,中西部土地供给面积占全国新增供给的比重不足三成,2014年上升到了六成。2002年,中小城市建成区面积占全国的比重接近一半,2013年上升到了64%。 55 土地流向与人口流向背道而驰,地区间房价差距因此越拉越大。\n然而这种土地倾斜政策并不能改变人口流向,人还是不断向东部沿海和大城市集聚。这些地区不仅房价一直在涨,大学的高考录取分数也一直在涨。中西部房价虽低,但年轻人还是愿意到房价高的东部,因为那里有更多的工作机会和资源。倾斜的土地政策并没有留住人口,也很难留住其他资源。很多资本利用了西部的优惠政策和廉价土地,套取了资源,又回流到东部去“炒”房地产,没在西部留下可持续发展的经济实体,只给当地留下了一堆债务和一片空荡荡的工业园区。\n建设用地指标不能在全国交易,土地使用效率很难提高。地方政府招商引资竞争虽然激烈,也经常以土地作为手段,却很难持续提高土地资源利用效率。发达地区土地需求旺盛,地价大涨,本应增加用地指标,既满足需求也抑制地价。但因为土地分配受制于行政边界,结果却是欠发达地区能以超低价格(甚至免费)大量供应土地。这种“东边干旱,西边浇水”的模式需要改革。2020年,中央提出要对建设用地指标的跨区域流转进行改革,探索建立全国性建设用地指标跨区域交易机制(见第二节),已是针对这一情况的改革尝试。 66 房价与居民债务:欧美的经验和教训 # 居民债务主要来自买房,房价越高,按揭就越高,债务负担也就越重。各国房价上涨都是因为供不应求,一来城市化过程中住房需求不断增加;二来土地和银行按揭的供给都受政治因素影响。\n在西方,“自有住房”其实是个比较新的现象,“二战”之前,大部分人并没有自己的房子。哪怕在人少地多的美国,1900—1940年的自有住房率也就45%左右。“二战”后这一比率才开始增长,到2008年全球金融危机之前达到68%。英国也差不多,“二战”前的自有住房率基本在30%,战后才开始增长,全球金融危机前达到70%。 77 正因为在很长一段时间里英美大部分人都租房,所以主流经济学教材在讲述供需原理时,几乎都会用房租管制举例。1998年,我第一次了解到房租管制,就是在斯蒂格利茨的《经济学》教科书中。逻辑虽容易理解,但并没有直观感受,因为当时我认识的人很少有租房的,农民有宅基地,城里人有单位分房。城市住房成为全民热议的话题,也是个新现象。\n欧美自有住房率不断上升,有两个后果。第一是对待房子的态度变化。对租房族来说,房子就是个住的地方,但对房主来说,房子是最重要的资产。随着房子数量和价格的攀升,房产成了国民财富中最重要的组成部分。1950年至2010年,英国房产价值占国民财富的比例从36%上升到57%,法国从28%升到61%,德国从28%升到57%,美国从38%升到42%。 88 第二个变化是随着房主越来越多,得益于房价上涨的人就越来越多。所以政府为讨好这部分选民,不愿让房价下跌。无房者也想尽快买房,赶上房价上涨的财富快车,政府于是顺水推舟,降低了买房的首付门槛和按揭利率。\n美国房地产市场和选举政治紧密相关。美国的收入不平等从20世纪七八十年代开始迅速扩大,造成了很多政治问题。而推行根本性的教育或税制等方面的改革,政治阻力很大,且难以在短期见效。相比之下,借钱给穷人买房就容易多了,既能缓解穷人的不满,让人人都有机会实现“美国梦”,又能抬高房价,让房主的财富也增加,拉动他们消费,创造更多就业,可谓一举多得。于是政府开始利用房利美(Fannie Mae)和房地美(Freddie Mac)公司(以下简称“两房”)来支持穷人贷款买房。“两房”可以买入银行的按揭贷款,相当于借钱给银行发放更多按揭。 99 1995年,克林顿政府规定“两房”支持低收入者的房贷要占到总资产的42%。2000年,也就是克林顿执政的最后一年,这一比率提高到50%。2004年,小布什政府将这一比率进一步提高到56%。 1010 “两房”也乐此不疲,因为给穷人的贷款利润较高,风险又似乎很低。此外,对购房首付的管制也越来越松。2008年全球金融危机前很多房贷的首付为零,引发了投机狂潮,推动房价大涨。根据Case-Shiller房价指数,2002年至2007年,美国房价平均涨了将近60%。危机之后,房价从2007年的最高点一直下跌到2012年,累积跌幅27%,之后逐步回升,2016年才又回到十年前的高点。\n房价下挫和收入下降会加大家庭债务负担,进而抑制消费。消费占美国GDP的七成,全球金融危机中消费大幅下挫,把经济推向衰退。危机前房价越高的地区,危机中消费下降越多,经济衰退也越严重,失业率越高。 1111 欧洲情况也大致如此。大多数欧洲国家在2008年之前也经历了长达十年的房价上涨。涨幅越大的国家居民债务负担越重(绝大多数债务是房贷),危机中消费下降也越多。 1212 房地产常被称作“经济周期之母”,根源就在于其内在的供需矛盾:一方面,银行可以通过按揭创造几乎无限的新购买力;而另一方面,不可再生的城市土地供给却有限。这对矛盾常常会导致资产泡沫与破裂的周期循环,是金融和房地产不稳定的核心矛盾。而房地产不仅连接着银行,还连接着千家万户的财富和消费,因此影响很大。\n房价与居民债务:我国的情况 # 2008年之后的10年,我国房价急速上涨,按揭总量越来越大,居民债务负担上涨了3倍多(图5-2)。2018年末,居民债务占GDP的比重约为54%,虽仍低于美国的76%,但已接近德国和日本。根据中国人民银行的信贷总量数据,居民债务中有53%是住房贷款,24%是各类消费贷(如车贷)。 1313 这一数据可能还低估了与买房相关的债务。实际上一些消费贷也被用来买了房,比如违规用于购房首付。而且人民银行的数据还无法统计到民间借贷等非正规渠道。\n图5-2 居民债务占GDP比重\n数据来源:IMF全球债务数据库。此处债务仅包括银行贷款和债券。\n图5-2中债务负担的分母是GDP,这一比率常用于跨国比较,但它低估了居民的实际债务负担。还债不能用抽象的GDP,必须用实实在在的收入。2019年末,中国人民银行调查统计司调查了全国3万余户城镇居民(农民负债率一般较低,大多没有房贷)的收入和债务情况。接近六成家庭有负债,平均债务收入比为1.6,也就是说债务相当于1.6倍的家庭年收入。这个负担不低,接近美国。2000年,美国家庭负债收入比约为1.5,2008全球金融危机前飙升至2.1,之后回落到1.7左右。 1414 根据中国人民银行的这项调查,城镇居民2019年的负债中有76%是房贷。而从资产端看,城镇居民的主要财产也就是房子。房产占了家庭资产的近七成,其中六成是住房,一成是商铺。而在美国居民的财富中,72%是金融资产,房产占比不到28%。 1515 中国人财富的压舱石是房子,美国人财富的压舱石是金融资产。这个重大差别可以帮助理解两国的一些基本政策,比如中国对房市的重视以及美国对股市的重视。\n总体看来,我国居民的债务负担不低,且仍在快速上升。最主要的原因是房价上涨。居民债务的攀升已然影响到了消费。以买车为例,这是房子之外最贵的消费品类,对宏观经济非常重要,约占我国社会商品零售总额的10%。车是典型的奢侈品,需求收入弹性很大,收入增加时需求大增,收入减少时需求大减。随着居民债务增加,每月还债后的可支配收入减少,所以经济形势一旦变差,买车需求就会大减。我国家用轿车市场经历了多年高速增长,2018年的私家车数量是2005年的14倍。但是从2018年下半年开始,“贸易战”升级,未来经济形势不确定性增大,轿车销量开始下降,一直到2019年底,几乎每个月同比都在下降。在新冠肺炎疫情影响之下,2020年2月份的销量同比下跌八成,3月份同比下跌四成,各地于是纷纷出台刺激汽车消费的政策。\n房价与居民债务风险 # 按照中国人民银行的调查数据,北京居民的户均总资产(不是净资产,未扣除房贷和其他负债)是893万元,上海是807万元,是新疆(128万元)和吉林(142万元)的六七倍。这个差距大部分来自房价。房价上涨也拉大了同城之内的不平等。房价高的城市房屋空置率往往也高,一边很多人买不起房,一边很多房子空置。如果把房子在内的所有家庭财富(净资产)算在一起的话,按照上述中国人民银行的调查数据,2019年最富有的10%的人占有总财富的49%,而最穷的40%的人只占有总财富的8%。 1616 房价上涨不仅会增加按揭债务负担,还会拉大贫富差距,进而刺激低收入人群举债消费,这一现象被称为“消费下渗”(trickle-down consumption),这在发达国家是很普遍的。 1717 2014—2017年间,我国收入最低的50%的人储蓄基本为零甚至为负(入不敷出)。 1818 自2015年起,信用卡、蚂蚁花呗、京东白条等各种个人消费贷激增。根据中国人民银行关于支付体系运行情况的数据,2016—2018年这三年,银行信用卡和借记卡内合计的应偿还信贷余额年均增幅接近30%。2019年,信用卡风险浮现,各家银行纷纷刹车。\n在负债的人当中,低收入人群的债务负担尤其重。城镇居民的平均债务收入比约为1.6,而年收入6万元以下的家庭债务收入比接近3。资产最少的20%的家庭还会更多使用民间借贷,风险更大。 1919 2020年,随着蚂蚁金服上市被叫停,各种讨论年轻人“纵欲式消费”的文章在社交媒体上讨论热烈,都与消费类债务急升的大背景有关。这种依靠借债的消费无法持续,因为钱都被花掉了,没有形成未来更高的收入,债务负担只会越来越重。\n居民债务居高不下,就很难抵御经济衰退,尤其是房产价格下跌所引发的经济衰退。低收入人群的财富几乎全部是房产,其中大部分是欠银行的按揭,负债率很高,很容易受到房价下跌的打击。在2008年美国的房贷危机中,每4套按揭贷款中就有1套资不抵债,很多穷人的资产一夜清零。2007年至2010年,美国最穷的20%的人,净资产从平均3万美元下降到几乎为零。而最富的20%的人,净资产只下跌了不到10%,从平均320万美元变成了290万美元,而且这种下跌非常短暂。2016年,随着股市和房市的反弹,最富的10%的人实际财富(扣除通货膨胀)比危机前还增长了16%。但收入底部的50%的人,实际财富被腰斩,回到了1971年的水平。40年的积累,在一场危机后荡然无存。 2020 我国房价和居民债务的上涨虽然也会引发很多问题,但不太可能突发美国式的房贷和金融危机。首先,我国住房按揭首付比例一般高达30%,而不像美国在金融危机前可以为零,所以银行风险小。除非房价暴跌幅度超过首付比例,否则居民不会违约按揭,损失掉自己的首付。2018年末,我国个人住房贷款的不良率仅为0.3%。 2121 其次,住房按揭形成的信贷资产,没有被层层嵌套金融衍生品,在金融体系中来回翻滚,规模和风险被放大几十倍。2019年末,我国住房按揭资产证券(RMBS)总量占按揭贷款的总量约3%,而美国这个比率为63%,这还不算基于这种证券的各种衍生产品。 2222 再次,由于资本账户管制,外国资金很少参与我国的住房市场。综上所述,像美国那样由房价下跌引发大量按揭违约,并触发衍生品连锁雪崩,再通过金融市场扩散至全球的危机,在我国不太可能会出现。\n要化解居民债务风险,除了遏制房价上涨势头以外,根本的解决之道还在于提高收入,尤其是中低收入人群的收入,鼓励他们到能提供更多机会和更高收入的地方去工作。让地区间的经济发展和收入差距成为低收入人群谋求发展的机会,而不是变成人口流动的障碍。\n第二节 不平衡与要素市场改革 # 2017年党的十九大报告指出:我国社会主要矛盾已经转化为人民日益增长的美好生活需要和不平衡不充分的发展之间的矛盾。这是自1981年党的十一届六中全会提出“我国所要解决的主要矛盾”(即人民日益增长的物质文化需要同落后的社会生产之间的矛盾)以来,中央首次重新定义“主要矛盾”,说明经济政策的根本导向发生了变化。\n过去40年间,我国居民收入差距有明显扩大,同期很多发达国家的收入差距也在扩大,与它们相比,我国的收入差距有两个特点:一是城乡差距,二是地区差距。2018年,城镇居民人均可支配收入是农村居民的2.7倍,而北京和上海的人均可支配收入是贵州、甘肃、西藏等地的3.5倍。这两项差距都与人口流动受限有关。\n人口流动与收入平衡 # 低收入人群想要提高收入,最直接的方式就是到经济发达城市打工,这些城市能为低技能工作(如快递或家政)提供不错的收入。若人口不能自由流动,被限制在农村或经济落后地区,那人与人之间的收入差距就会拉大,地区和城乡间的收入差距也会拉大。目前,我国人口流动依然受限,以地方政府投资为主推动的城市化和经济发展模式是重要因素之一。重土地轻人,民生支出不足,相关公共服务(教育、医疗、养老等)供给不足,不利于外来人口在城市中真正安家落户,不利于农村转移劳动力在城市中谋求更好的发展。地方政府长期倚重投资,还会导致收入分配偏向资本,降低劳动收入占比,对中低收入人群尤其不利。第七章会讨论这种分配结构及其带来的各种问题,本节先聚焦人口流动问题。\n在深入分析之前,我们先来看看如果人口可以自由流动,地区间平衡是个什么样子。图5-3(a)中的柱子代表美国各州GDP占美国全国的比重,折线则代表各州人口占比。美国各州GDP规模差别很大,仅加州就占了美国GDP的15%,而一些小州的占比连1%都不到。GDP衡量的是经济总量,人口越多的地方GDP自然越大,所以图中折线的高度和柱子高度差不多。假如一个州的GDP占比为3%,人口占比差不多也是3%。换句话说,州与州之间虽然规模差别很大,但人均GDP差别很小,无论生活在哪个州,平均生活水平都差不太多。\n图5-3(a) 2019年美国各州占全国GDP和人口比重\n这种规模不平衡但人均平衡的情况,和我国的情况差别很大。图5-3(b)是我国各省份的情况,柱子与折线的高度差别很大,有高有低,省省不同。在广东、江苏、浙江、上海和北京等发达地区,折线比柱子低很多,人口规模远小于经济规模,更少的人分更多的收入,自然相对富有。而在其他大多数省份,柱子比折线低很多,经济规模小于人口规模,更多的人分更少的收入,自然相对贫穷。\n图5-3(b) 2019年中国各省份占全国GDP和人口的比重 2323 要想平衡地区间的发展差距,关键是要平衡人均差距而不是规模差距。想达到地区间规模的平均是不可能的。让每个城市都像上海和北京一样,或者在内地再造长三角和珠三角这样巨大的工业和物流网络(包括港口),既无可能也无必要。现代经济越来越集聚,即使在欧美和日本,经济在地理上的集聚程度也依然还在加强,没有减弱。 2424 所以理想的状况是达到地区间人均意义上的平衡。而要实现这种均衡,关键是让劳动力自由流动。人的收入不仅受限于教育和技能,也受限于所处环境。目前城镇常住人口只占总人口的六成,还有四成人口在农村,但农业产出仅占GDP的一成。四成人口分一成收入,收入自然就相对低。就算部分农民也从事非农经济活动(这部分很难统计),收入也还是相对低。所以,要鼓励更多人进入城市,尤其是大城市。因为大城市市场规模大,分工细,哪怕低技能的人生产率和收入也更高。比如城市里一个早点摊儿可能就够养活一家人,甚至有机会发展成连锁生意。而在农村,早餐都在家里吃,市场需求小,可能都没有专门做早餐的生意。类似的例子还有家政、外卖、快递、代驾、餐厅服务员等。因为人口密度高和市场需求大所带来的分工细化,这些工作在大城市的收入都不低。\n正是这些看上去低技能的服务业工作,支撑着大城市的繁华,也支撑着所谓“高端人才”的生活质量。若没有物美价廉的服务,生活成本会急升。我家门口有一片商业办公楼宇,离地铁站很近,有不少餐厅。前几年很多服务业人员离开,餐厅成本急升,一些餐厅倒闭了,剩下的也都涨了价,于是带饭上班的白领就多了起来。如果一个城市只想要高技能人才,结果多半会事与愿违:服务业价格会越来越高,收入会被生活成本侵蚀,各种不便利也会让生活质量下降,“高端人才”最终可能也不得不离开。靠行政规划来限制人口规模,成功例子不多。人口不断流入的城市,规划人口往往过少;而人口不断流出的城市,规划人口往往过多。\n城市规模扩大和人口密度上升,不仅能提高本地分工程度和生产率,也能促进城市与城市之间、地区与地区之间的分工。有做高端制造的,也有做中低端制造的,有做大规模农场的,也有搞旅游的。各地区发展符合自身优势的经济模式,互通有无,整体效率和收入都会提高。就算是专搞农业的地方,人均收入也会提升,不仅因为规模化后的效率提升,也因为人口基数少了,流动到其他地方搞工商业去了。\n让更多人进入城市,尤其是大城市,逻辑上的好处是清楚的,但在现实中尚有很多争议,主要是担心人口涌入会造成住房、教育、医疗、治安等资源紧张。这种担心可以理解,任何城市都不可能无限扩张。劳动力自由流动意味着有人来也有人走,若拥挤带来的代价超过收益,自会有人离开。至于教育、医疗等公共服务,缓解压力的根本之道是增加供给,而不是限制需求。涌入城市的人是来工作和谋生的,他们不仅分享资源,也会创造资源。举个例子来说,2019年末,上海60岁以上的老年人口共512万,占户籍总人口的35%,老龄化严重。若没有不断涌入的城市新血,社保怎么维持?养老服务由谁来做?但如果为这些新移民提供的公共服务覆盖有限,孩子上学难,看病报销难,他们便无法安居乐业。存在了很多年的“留守”问题,也还会持续下去。\n土地流转与户籍改革 # 增加城市中的学校和医院数量,可能还相对容易些,增加住房很困难。大城市不仅土地面积有限,而且由于对建设用地指标的管制,就算有土地也盖不了房子。假如用地指标可以跟着人口流动,人口流出地的用地指标减少,人口流入地的指标增多,就可能缓解土地供需矛盾、提高土地利用效率。而要让建设用地指标流转起来,首先是让农村集体用地参与流转。我国的土地分为两类(见第二章):城市土地归国家所有,可以在市场上流转;农村土地归集体所有,流转受很多限制。要想增加城市土地供应,最直接的办法是让市区和近郊的集体建设用地参与流转。比如在北京市域内,集体建设用地占建设用地总量的五成,但容积率平均只有0.3—0.4,建设密度远低于国有土地。上海的集体建设用地占总建设用地三成,开发建设强度也大大低于国有土地。 2525 关于集体土地入市,早在2008年党的十七届三中全会审议通过的《中共中央关于推进农村改革发展若干重大问题的决定》里就有了原则性条款:“逐步建立城乡统一的建设用地市场,对依法取得的农村集体经营性建设用地,必须通过统一有形的土地市场、以公开规范的方式转让土地使用权,在符合规划的前提下与国有土地享有平等权益。”但地方有地方的利益,这些原则当时未能落到实处。2008年后的数年间,地方政府的主要精力还是在“土地财政/金融”的框架下征收集体用地,扩张城市。\n自2015年起,全国33个试点县市开始试行俗称“三块地”的改革,即农村土地征收、集体经营性建设用地入市以及宅基地制度改革。在此之前也有一些零星的地方试点和创新,比较有名的是重庆的“地票”制度。若一个农民进了城,家里闲置两亩宅基地,他可以将其还原成耕地,据此拿到两亩地“地票”,在土地交易所里卖给重庆市域内需要建设指标的区县。按每亩“地票”均价20万元算,扣除两亩地的复耕成本约5万元,净所得为35万元。农户能分到其中85%(其余15%归村集体),差不多30万元,可以帮他在城里立足。每年国家给重庆主城区下达的房地产开发指标约2万亩,“地票”制度每年又多供应了2万亩,相当于土地供给翻了一番,所以房价一直比较稳定。 2626 2017年,中央政府提出,“在租赁住房供需矛盾突出的超大和特大城市,开展集体建设用地上建设租赁住房试点”。 2727 这是一个体制上的突破,意味着城市政府对城市住宅用地的垄断将被逐渐打破。2019年,第一批13个试点城市选定,既包括北、上、广等一线城市,也包括沈阳、南京、武汉、成都等二线城市。 2828 同年,《土地管理法》修正案通过,首次在法律上确认了集体经营性建设用地使用权可以直接向市场中的用地者出让、出租或作价出资入股,不再需要先行征收为国有土地。农村集体经营性用地与城市国有建设用地从此拥有了同等权能,可以同等入市,同权同价,城市政府对土地供应的垄断被打破了。\n所谓“集体经营性建设用地”,只是农村集体建设用地的一部分,并不包括宅基地,后者的面积占集体建设用地的一半。虽然宅基地改革的政策尚未落地,但在住房需求旺盛的地方,宅基地之上的小产权房乃至宅基地本身的“非法”转让,一直存在。2019年新的《土地管理法》对宅基地制度改革只做了些原则性规定:国家允许进城落户的村民依法自愿有偿退出宅基地,鼓励农村集体经济组织及其成员盘活利用闲置宅基地和闲置住宅。2020年,中央又启动了新一轮的宅基地制度改革试点,继续探索“三权分置”,即保障宅基地农户资格权、农民房屋财产权、适度放活宅基地和农民房屋使用权。强调要守住“三条底线”:土地公有制性质不改变、耕地红线不突破、农民利益不受损。在这些改革原则之下,具体的政策细则目前仍在探索阶段。\n土地改革之外,在“人”的城镇化和户籍制度等方面也推出了一系列改革。2013年,首次中央城镇化会议召开,明确提出“以人为本,推进以人为核心的城镇化”。2014年,两会报告中首次把人口落户城镇作为政府工作目标,之后开始改革户籍制度。逐步取消了农业户口与非农业户口的差别,建立了城乡统一的“居民户口”登记制度,并逐步按照常住人口(而非户籍人口)规模来规划公共服务供给,包括义务教育、就业服务、基本养老、基本医疗卫生、住房保障等。 2929 2016年,中央政府要求地方改进用地计划安排,实施“人地挂钩”,要依据土地利用总体规划和上一年度进城落户人口数量,合理安排城镇新增建设用地计划,保障进城落户人口用地需求。 3030 户籍制度改革近两年开始加速。2019年,发改委提出:“城区常住人口100万—300万的Ⅱ型大城市要全面取消落户限制;城区常住人口300万—500万的Ⅰ型大城市要全面放开放宽落户条件,并全面取消重点群体落户限制。超大特大城市要调整完善积分落户政策,大幅增加落户规模、精简积分项目,确保社保缴纳年限和居住年限分数占主要比例。……允许租赁房屋的常住人口在城市公共户口落户。” 3131 目前,在最吸引人的特大和超大城市,落户门槛依然不低。虽然很多特大城市近年都加入了“抢人才大战”,放开了包括本科生在内的高学历人才落户条件,甚至还提供生活和住房补贴等,但这些举措并未惠及农村转移人口。这种情况最近也开始改变。2020年4月以来,南昌、昆明、济南等省会城市先后宣布全面放开本市城镇落户限制,取消落户的参保年限、学历要求等限制,实行“零门槛”准入政策。\n一国之内,产品的流动和市场化最终会带来生产要素的流动和市场化。农产品可以自由买卖,农民可以进城打工,农村土地的使用权最终也该自主转让。人为限定城市土地可以转让而集体土地不能转让,用户籍把人分为三六九等,除非走计划经济的回头路,否则难以持久。就算不谈权利和价值观,随着市场化改革的深入,这些限定性的制度所带来的扭曲也会越来越严重,代价会高到不可维持,比如留守儿童、留守妇女、留守老人所带来的巨大社会问题。\n城市化的核心不应该是土地,应该是人。要实现地区间人均收入均衡、缩小贫富差距,关键也在人。要真正帮助低收入群体,就要增加他们的流动性和选择权,帮他们离开穷地方,去往能为他的劳动提供更高报酬的地方,让他的人力资本更有价值。同时也要允许农民所拥有的土地流动,这些土地资产才会变得更有价值。\n2020年4月发布的《中共中央 国务院关于构建更加完善的要素市场化配置体制机制的意见》(以下简称《意见》),全面阐述了包括土地、劳动力、资本、技术等生产要素的未来改革方向。针对土地,《意见》强调“建立健全城乡统一的建设用地市场……制定出台农村集体经营性建设用地入市指导意见”。针对劳动力,要求“深化户籍制度改革。推动超大、特大城市调整完善积分落户政策,探索推动在长三角、珠三角等城市群率先实现户籍准入年限同城化累计互认。放开放宽除个别超大城市外的城市落户限制,试行以经常居住地登记户口制度。建立城镇教育、就业创业、医疗卫生等基本公共服务与常住人口挂钩机制,推动公共资源按常住人口规模配置。”总的改革方向,就是让市场力量在各类要素分配中发挥更大作用,让资源更加自由流动,提高资源利用效率。\n第三节 经济发展与贫富差距 # 在我国城市化和经济发展的过程中,贫富差距也在扩大。本节讨论这一问题的三个方面。第一,我国十几亿人在40年间摆脱了贫困,大大缩小了全世界70亿人之间的不平等。第二,在经济快速增长过程中,虽然收入差距在拉大,但低收入人群的收入水平也在快速上升,社会对贫富差距的敏感度在一段时间之内没有那么高。第三,在经济增长减速时,社会对不平等的容忍度会减弱,贫富差距更容易触发社会矛盾。\n收入差距 # 中国的崛起极大地降低了全球不平等。按照世界银行对极端贫困人口的定义(每人每天的收入低于1.9美元),全世界贫困人口从1981年的19亿下降为2015年的7亿,减少了12亿(图5-4)。这是个了不起的成就,因为同期的世界总人口还增加了约30亿。但如果不算中国,全球同期贫困人口只减少了不到3亿人。而在1981年至2008年的近30年间,中国以外的世界贫困人口数量基本没有变化。可以说,全球的减贫成绩主要来自中国。 3232 图5-4 世界极端贫困人口数量变化\n数据来源:世界银行。此处极端贫困人口的定义为每人每日收入少于1.9美元。\n中国的崛起也彻底改变了全球收入分布的格局。1990年,全球共有53亿人,其中最穷的一半人中约四成生活在我国,而最富的20%里几乎没有中国人,绝大多数是欧美人。到了2016年,全球人口将近74亿,其中最穷的一半人中只有约15%是中国人,而最富的另一半人中约22%是中国人。我国占全球人口的比重约为19%,因此在全球穷人中中国人占比偏低,在中高收入组别中中国人占比偏高。 3333 按国别分,全球中产阶级人口中我国所占的比重也最大。\n我国的改革开放打破了计划经济时代的平均主义,收入差距随着市场经济改革而扩大。衡量收入差距的常用指标是“基尼系数”,这是一个0到1之间的数字,数值越高说明收入差距越大。20世纪80年代初,我国居民收入的基尼系数约为0.3,2017年上升到了0.47。 3434 按照国家统计局公布的居民收入数据,2019年收入最高的20%人群占有全部收入的48%,而收入最低的20%人群只占有全部收入的4%。\n虽然收入差距在扩大,但因为经济整体在飞速增长,所以几乎所有人的绝对收入都在快速增加。经济增长的果实是普惠的。1988年至2018年,无论是在城镇还是在农村,人均实际可支配收入(扣除物价上涨因素)都增加了8—10倍。无论是低收入人群、中等收入人群还是高收入人群,收入都在快速增加。以城镇居民为例,虽然收入最高的20%其实际收入30年间增长了约13倍,但收入最低的40%和居中的40%的收入也分别增长了6倍和9倍。 3535 经济增长过程伴随着生产率的提高和各种新机会的不断涌现,虽然不一定会降低收入差距,但可以在一定程度上遏制贫富差距在代际间传递。如果每代人的收入都远远高于上一代人,那人们就会更看重自己的劳动收入,继承自父母的财富相对就不太重要。对大多数“70后”来说,生活主要靠自己打拼,因为父母当年收入很低,储蓄也不多。经济和社会的剧烈变化,也要求“70后”必须掌握新的技能、离开家乡在新的地方工作,父母的技能和在家乡的人脉关系,帮助有限。\n但对“80后”和“90后”来说,父母的财富和资源对子女收入的影响就大了。 3636 原因之一是财富差距在其父母一代中就扩大了,财产性收入占收入的比重也扩大了,其中最重要的是房产。在一二线城市,房价的涨幅远远超过了收入涨幅。 3737 房产等有形财产与人力资本不同。人力资本无法在代际之间不打折扣地传承,但房产和存款却可以。聪明人的孩子不见得更聪明,“学霸”的孩子也不见得就能成为“学霸”。即使不考虑后天教育中的不确定性,仅仅是从遗传角度讲,父母一代特别突出的特征(如身高和智商等)也可能在下一代中有所减弱。因为这种“均值回归”现象,人力资本很难百分之百地遗传。但有形资产的传承则不受这种限制,若没有遗产税,100万元传给下一代也还是100万元,100平方米的房子传给下一代也还是100平方米。\n累积的财富差距一般远大于每年的收入差距,因为有财富的人往往更容易积累财富,资产回报更高,可选择的投资方式以及应对风险的手段也更多。如前文所述,按照国家统计局公布的城镇居民收入数据:2019年收入最高的20%的人占有全部收入的48%,而最低的20%的人只占4%。而按照中国人民银行对城镇居民的调查数据,2019年净资产最高的20%的家庭占有居民全部净资产的65%,而最低的20%只占有2%。 3838 在经济发达、资产增值更快的沿海省份,父母累积的财产对子女收入的影响,比在内地省份更大。 3939 当经济增速放缓、新创造的机会变少之后,年轻人间的竞争会更加激烈,而其父母的财富优势会变得更加重要。如果“拼爹”现象越来越严重的话,社会对不平等的容忍程度便会下降,不安定因素会增加。\n对收入差距的容忍度 # 收入差距不可能完全消除,但社会也无法承受过大的差距所带来的剧烈冲突,因此必须把不平等控制在可容忍的范围之内。影响不平等容忍程度的因素有很多,其中最重要的是经济增速,因为经济增速下降首先冲击的是穷人收入。不妨想象正在排队的两队人,富人队伍前进得比穷人快,但穷人队伍也在不停前进,所以排队的穷人相对来说比较有耐心。但如果穷人的队伍完全静止不动,哪怕富人队伍的前进速度也减慢了,困在原地的穷人也会很快失去耐心而骚动起来。这种现象被称为“隧道效应”(tunnel effect),形容隧道中两条车道一动一静时,静的那条的焦虑和难耐。 4040 上文提到,1988年以来,我国城镇居民中高收入群体的实际收入(扣除物价因素)增长了约13倍,低收入群体和中等收入群体的收入也分别增长了6倍和9倍。在“经济蛋糕”膨胀的过程中,虽然高收入群体切走了更大一块,但所有人分到的蛋糕都比以前大多了,因此暂时可以容忍贫富差距拉大。美国情况则不同,自20世纪70年代以来,穷人(收入最低的50%)的实际收入完全没有增长,中产(收入居中的40%)的收入近40年的累积增幅不过区区35%,而富人(收入最高的10%)的收入却增长了2.5倍。因此社会越来越无法容忍贫富差距。2008年的全球金融危机让穷人财富大幅缩水,贫富差距进一步扩大,引发了“占领华尔街运动”,之后特朗普当选,美国政治和社会的分裂越来越严重。\n另一个影响不平等容忍度的因素是人群的相似性。改革开放前后,绝大多数中国人的生活经历都比较相似,或者在农村的集体生产队干活,或者在城镇的单位上班。在这种情况下,有些人先富起来可能会给另一些人带来希望:“既然大家都差不多,那我也可以,也有机会。”20世纪90年代很多人“下海”发了财,而其他人在羡慕之余也有些不屑:“他们哪里比我强?我要去的话我也行,只不过我不想罢了。”但如果贫富差距中参杂了人种、肤色、种姓等因素,那人们感受就不一样了。这些因素无法靠努力改变,所以穷人就更容易愤怒和绝望。最近这些年,美国种族冲突加剧,根本原因之一就是黑人的贫困。黑人家庭的收入中位数不及白人的六成,且这种差距可能一代代延续下去。一个出身贫困(父母家庭收入属于最低的20%)的白人,“逆袭”成为富人(同代家庭收入最高的20%)的概率是10.6%,而继续贫困下去的概率是29%。但对一个出身贫困的黑人来说,“逆袭”的概率只有区区2.5%,但继续贫困的概率却高达37%。 4141 家庭观念也会影响对不平等的容忍度。在家庭观念强的地方,如果子女发展得好、有出息,自己的生活就算是有了保障,对贫富差距容忍度也会比较高,毕竟下一代还能赶上。而影响子女收入最重要的因素就是经济增长的大环境。我国的“70后”和“80后”中绝大多数人的收入都超过父辈。若父母属于收入最低的40%人群,子女收入超过父母的概率接近九成;即便父母属于收入居中的40%人群,子女超越的概率也有七成。这种情况很像美国的“战后黄金一代”。美国的“40后”和“50后”收入超越父母的概率很接近我国的“70后”和“80后”。但到了美国的“80后”,这概率就低多了:如果父母是穷人(收入最低的40%),子女超越的概率还不到六成;若父母是中产(收入居中的40%),子女超越的概率仅四成。 4242 总的来说,经济增长与贫富差距之间的关系非常复杂。经济学中有一条非常有名的“库兹涅茨曲线”,宣称收入不平等程度会随着经济增长先上升而后下降,呈现出“倒U形”模式。这条在20世纪50年代声名大噪的曲线,其实不过是一些欧美国家在“二战”前后那段特殊时期中的特例。一旦把时间拉长、样本扩大,数据中呈现的往往不是“倒U形”,而是贫富差距不断起起伏伏的“波浪形”。 4343 造成这些起落的因素很多,既有内部的也有外部的,既有经济的也有政治的。并没有什么神秘的经济力量会自动降低收入不平等,“先富带动后富”也不会自然发生,而需要政策的干预。不断扩大的不平等会让社会付出沉重的代价,必须小心谨慎地对待。 4444 结语 # 我国的城市化大概可以分为三个阶段。第一阶段是1994年之前,乡镇企业崛起,农民离土不离乡,城市化速度不快。第二阶段是1994年分税制改革后,乡镇企业式微,农民工进城大潮形成。这个阶段的主要特征是土地的城市化速度远远快于人的城市化速度,土地撬动的资金支撑了大规模城市建设,但并没有为大多数城市新移民提供应有的公共服务。第三个阶段是党的十八大以后,随着一系列改革的陆续推行,城市化的重心开始逐步从“土地”向“人”转移。\n城市化和工业化互相作用。上述三个阶段背后的共同动力之一就是工业化。1994年之前,工业和基础设施比较薄弱,小规模的乡镇企业可以迅速切入本地市场,满足本地需求,而农村土地改革也解放了大量劳动力,可以从事非农工作,为乡镇企业崛起创造了条件。到了90年代中后期,工业品出口开始加速。2001年,中国加入WTO和国际竞争体系之后,工业企业必须扩大规模,充分利用规模效应来增强竞争力,同时需要靠近港口以降低出口运输成本。因此制造业开始加速向沿海地区集聚,大量农民工也随之迁徙。如今我国虽已成为“世界工厂”,但产业升级要求制造业企业不断转型,充分利用包括金融、科技、物流等要素在内的生产和销售网络,所以各项产业仍然集聚在沿海或一些中心大城市。这种集聚促进了当地服务业飞速发展,吸纳了从农村以及中小城市转移出来的新增劳动力。这些新一代移民已经适应了城市生活,很多“农二代”已经不具备从事农业生产所需的技能,更希望定居在城市。所以城市化需要转型,以人为本,为人们提供必要的住房、教育、医疗等公共资源。\n在大规模城市化过程中,地方政府背上了沉重的债务。地价和房价飞涨,也让居民背上了沉重的债务。这些累积的债务为宏观经济和金融体系增加了很大风险。最近几年的供给侧结构性改革,首要任务之一就是“去杠杆”,而所谓“三大攻坚战”之首就是“防范化解重大风险”。那么这些风险究竟是什么?如何影响经济?又推行了哪些具体的改革措施?这是下一章的主题。\n扩展阅读 # 地方政府以土地为杠杆撬动的飞速城市化,是历史上的一件大事。如今站在新一轮改革的起点上,上海交通大学陆铭的著作《大国大城:当代中国的统一、发展与平衡》(2016)值得阅读。该书聚焦城市化过程中的“人”,主张扩大城市规模,让更多人定居在城市,在不断集聚中走向地区间人均意义上的平衡。北京大学周其仁的著作《城乡中国(修订版)》(2017)和东南大学华生的著作《城市化转型与土地陷阱》(2014)也是理解城市化的上佳读物。他们在很多问题上持不同观点。兼听则明,读者可自行判断。\n经济学近年来最热门的研究课题就是不平等,优秀的论文和著作很多。对比较严肃的读者,我还是推荐法国经济学家皮凯蒂的著作《21世纪资本论》(2014)。这是本很多人知道但很少人读完的巨著,因为太厚了。但厚有厚的好处,这本大书里散落着很多有意思的内容,作者思考的深度和广度远非各类书评中的“中心思想”所能概括。即便只读该书前两部分,也能学到关于经济发展的很多内容。对非专业读者而言,本书中有些内容不太容易理解,而且没有多少关于中国的内容。我国收入分配研究领域的两位专家,北京师范大学的李实和中国人民大学的岳希明写了一本导读,《〈21世纪资本论〉到底发现了什么》(2015),解释了原作中一些概念,也对我国收入差距的情况做了简要说明和分析。\n11 容积率就是建筑面积和其下土地面积的比值,比值越高,建筑面积越大,楼层越高,容纳的人也越多。给定土地位置,规划容积率越高越值钱。厦门大学傅十和与暨南大学谷一桢等人的论文(Brueckner et al., 2017)发现,我国房地产开发限制越严格的地方,容积率和地价间关联越紧密。\n22 旧金山和亚特兰大的例子来自哈佛大学格莱泽(Glaeser)和沃顿商学院吉尤科(Gyourko)的论文(2018)。\n33 各类城市房价和人均可支配收入数据来自宾夕法尼亚大学方汉明等人的论文(Fang et al., 2015)。\n44 不同地区城镇人口和土地数据来自恒大经济研究院任泽平、夏磊和熊柴的著作(2017)。\n55 数字来自复旦大学韩立彬和上海交通大学陆铭的论文(2018),他们详细分析了土地供给政策倾斜和地区间房价分化。\n66 2020年4月发布了《中共中央 国务院关于构建更加完善的要素市场化配置体制机制的意见》。\n77 美国的数字来自哈佛大学的研究报告(Spader, McCue and Herbert, 2016)。英国的数字来自三位英国经济学家的著作(Ryan-Collins, Lloyd and Macfarlane, 2017)。\n88 欧洲房产价值占国民财富比例大幅上升,与“二战”后经济复苏与重建有关。美国上升幅度相对较小,部分是因为美国在战后成为超级大国,所以作为分母的国民财富增幅巨大。各国财富构成的数据来自巴黎经济学院皮凯蒂(Piketty)和伯克利加州大学祖克曼(Zucman)的论文(2014)。\n99 “两房”并非国企,而是和政府联系非常紧密的私企,属于“政府支持企业”(government-sponsored enterprise),享受各种政府优惠,也承担政策任务。“两房”可以从财政部获取信用额度,几乎相当于政府对其债务的隐形担保,虽然法律上政府并无担保义务。\n1010 数据来自芝加哥大学拉詹(Rajan)的著作(2015)。\n1111 传统的经济周期理论非常注重投资的作用。虽然投资占GDP的比重在发达国家相对较小,但波动远比消费剧烈,常常是经济周期的主要推手。随着对债务研究的深入,经济学家越来越重视消费对经济周期的影响。普林斯顿大学迈恩(Mian)和芝加哥大学苏非(Sufi)的著作(2015)详细介绍了美国居民部门的债务和消费情况。\n1212 美联储旧金山分行的研究报告(Glick and Lansing, 2010)显示:2008年之前的10年间,欧美主要国家的房价和居民负债高度正相关,而负债越多的国家危机之后消费下降也越多。\n1313 剩余23%是各种经营性贷款。我国的统计口径把所有部门分为政府、居民、企业,但居民中还包括各种非法人企业,比如个体户,所以居民贷款中含有经营性贷款。\n1414 我国的数据来自中国人民银行调查统计司的报告(2020)。美国数据来自美联储纽约分行的《家庭债务与信用季报》(Quarterly Report on Household Debt and Credit)。\n1515 美国居民财富组成的数据来自美联储发布的2019年度美国金融账户组成数据。\n1616 中央财经大学张川川、国务院发展研究中心贾珅、北京大学杨汝岱研究了房价和空置率的正向关系,认为二者同时受到收入不平等扩大的影响(2016)。\n1717 即低收入群体通过借贷消费,可参考芝加哥大学贝特朗(Bertrand)和莫尔斯(Morse)的论文(2016)。\n1818 储蓄不平等的数据来自西南财经大学的甘犁、赵乃宝和孙永智等人的研究(2018)。\n1919 中国人民银行调查统计司的报告(2020)指出,资产最少的20%的负债家庭中,民间借贷占债务的比重将近10%。年收入6万元以下家庭的债务收入比数据来自中国人民银行金融稳定分析小组的报告(2019)。\n2020 此处数字来自普林斯顿大学迈恩和芝加哥大学苏非的著作(2015)以及德国波恩大学三位经济学家的论文(Kuhn, Schularick and Steins, 2020)。\n2121 数据来自中国人民银行金融稳定分析小组的报告(2019)。\n2222 我国住房按揭资产证券数据来自万得数据库。2019年末的美国数据也包含了商业地产,按揭总量数据来自美联储,住房按揭资产证券总量数据来自sifma网站。\n2323 本图设计来自上海交通大学陆铭的著作(2016),我更新了数据。\n2424 上海交通大学陆铭的著作(2016)指出,发达国家的经济集聚和城市化还在继续。\n2525 数据来自国务院发展研究中心邵挺、清华大学田莉、中国人民大学陶然的论文(2018)。\n2626 “地票”价格和土地供应数据来自重庆市前市长黄奇帆的著作(2020)。\n2727 2017年发布的《住房城乡建设部 国土资源部关于加强近期住房及用地供应管理和调控有关工作的通知》。\n2828 2019年,国土资源部与住房和城乡建设部印发《利用集体建设用地建设租赁住房试点方案》,确定北京、上海、沈阳、南京、杭州、合肥、厦门、郑州、武汉、广州、佛山、肇庆、成都等13个城市为第一批试点。\n2929 在居民户口制度下,原城镇户口居民基本不受影响,原农业户居民可以继续保有和农村土地相关的权益(如土地承包经营权和宅基地使用权),且在社会保障方面同城镇居民接轨。\n3030 2016年,国土资源部联合五家中央部委印发《关于建立城镇建设用地增加规模同吸纳农业转移人口落户数量挂钩机制的实施意见》。\n3131 国家发展改革委《2019年新型城镇化建设重点任务》。\n3232 世界银行定义的每天1.9美元的极端贫困收入标准,按2011年购买力平价调整后相当于每年2 441元人民币。而我国2011年的农村最低贫困线标准是每年2 300元,城镇的贫困线标准则高于世行标准。\n3333 全球人口按不同收入组别在各国之间的分布,来自“全球不平等实验室”的报告(World Inequality Lab 2017)。\n3434 基尼系数的数字来自北京师范大学李实和朱梦冰的论文(2018)。\n3535 城镇低收入人群的平均实际收入年均增长率为6.2%,中等收入人群为7.6%,高收入人群为8.9%。\n3636 新加坡国立大学樊漪、易君健和浙江大学张俊森的论文(Fan, Yi and Zhang,2021)研究了父母收入对子女收入的影响。\n3737 房价和收入增长数据可参考宾夕法尼亚大学方汉明等人的研究(Fang et al., 2015)。\n3838 数据来自中国人民银行调查统计司的报告(2020)。\n3939 在沿海省份,“80后”收入与其父母收入的相关性,高于“70后”;但在内陆,这一相关性在“80后”与“70后”之间变化不大。这一发现来自新加坡国立大学樊漪、易君健以及浙江大学张俊森的论文(Fan, Yi and Zhang, 2021)。\n4040 这一效应由已故的传奇经济学家赫希曼(Hirschman)提出,详见其文集(Hirschman, 2013)。他也讨论了影响不平等容忍度的诸多因素,如人群相似性与家庭观念等。\n4141 数据来自哈佛大学切蒂(Chetty)等人的论文(2020)。\n4242 子女收入超越父母的概率,称为“绝对流动性”。对我国绝对流动性的估计,来自新加坡国立大学樊漪、易君健以及浙江大学张俊森的论文(Fan, Yi and Zhang, 2021);对美国的估计来自哈佛大学切蒂(Chetty)等人的论文(2017)。在本书写作之际(2020年),中国“90后”才刚刚进入劳动力市场,收入还未稳定下来,数据也有待收集。\n4343 法国经济学家皮凯蒂在著作(2014)中详细分析了“库兹涅茨曲线”理论的来龙去脉。世界银行的米兰诺维奇(Milanovic)在著作(2019)中描述了起起落落的“库兹涅茨波浪”。\n4444 诺贝尔经济学奖得主斯蒂格利茨(Stiglitz)的著作(2013)讨论了不平等的种种代价。斯坦福大学历史学教授沙伊德尔(Scheidel)的著作(2019)指出,历史上不断扩大的不平等几乎都难以善终,最后往往以大规模的暴力和灾难重新洗牌。\n第六章 债务与风险 # 有一对年轻情侣,都在上海的金融行业工作,收入不错。研究生刚毕业没几年,算上年终奖,两人每月到手共5万元。他们对前途非常乐观,又到了谈婚论嫁的年龄,所以决定买房结婚。家里老人凑齐了首付,又贷了几百万元银行按揭,每月还款3万元。上海物价和生活费用不低,年轻人也少不了娱乐和应酬,还完房贷后存不下什么钱。但好在前途光明,再努力几年,收入如果翻一番,房贷压力也就轻了。何况房价一直在涨,就算把买房当投资,回报也不错。\n天有不测风云。从2018年开始,金融行业日子开始不好过。一个人的公司倒闭了,暂时失业;另一个也没了年终奖,收入下降不少。每月到手的钱从5万变成了2万,可按揭3万还雷打不动。老人们手头也不宽裕,毕竟一辈子的积蓄大多已经交了首付,顶多也就能帮着再支撑几个月。于是年轻人找工作的时候也不敢太挑,总算找到了一份收入还过得去的,新冠肺炎疫情又来了……\n人们在乐观时往往会低估负债的风险,过多借债。当风险出现时,又会因为债务负担沉重而缺乏腾挪空间,没办法应对。从上述故事中可以看到,就算房价不下跌,债务负担重的家庭也面临至少三大风险。一是债务缺乏弹性。若顺风顺水发了财,债务不会跟着水涨船高;可一旦倒了霉,债务也一分不会少。二是收入变化弹性很大。影响个人收入的因素数之不尽,宏观的、行业的、公司的、领导的、同事的、个人的……谁能保证自己未来几十年收入只涨不跌?就算不会失业,收入也不下降,但只要收入增长缓慢或不增长,对于高负债的家庭就已经构成了风险。既要还本又要付息,每个月紧巴巴的“月光”生活,能挺几年?第三个风险来自家庭支出的变动。突然有事要用钱怎么办?家里老人生病怎么办?要养孩子怎么办?\n可见债务负担如果过重,会产生各种难以应对的风险。2018年末,我国的债务总量达到了GDP的258%(图6-1),已经和美国持平(257%),超过了德国(173%),也远高于一些发展中大国,比如巴西(158%)和印度(123%)。而且我国债务增长的速度快于这些国家,债务总量在10年间增加了5.5倍。即便我国经济增长强劲,同期GDP还增加了2.8倍,但债务占GDP的比重在10年间还是翻了一番,引发了国内外的广泛关注和担忧。近几年供给侧结构性改革中的诸多举措,尤其是“去产能”“去库存”“去杠杆”,都与债务问题和风险有关。\n图6-1 中、美、德、日四国债务占各自GDP比重\n数据来源:IMF全球债务数据库。此处债务仅包括银行贷款和债券。\n债务占GDP的比重是否就能衡量真实的债务负担,目前尚有争议。当利率为零甚至是负的时候,只要名义GDP(内含物价)保持上涨,债务占GDP的比重可能不是个大问题,至少对政府的公共债务来说不是大问题,可以不断借新还旧。 11 但对居民和企业而言,债务总量快速上升,依然会带来很大风险。第五章已经分析了居民债务的风险,本章重点分析企业和银行的风险。\n第一节和第二节解释债务的一般经济学原理。这部分介绍欧美情况多一点,希望读者明白我国债务问题虽有诸多特色,但与欧美也有不少相似之处,前车可鉴。第三节分析我国债务的成因、风险、后果。无论是居民、企业还是政府,负债都与地方政府推动经济发展的模式有关。第四节讨论如何偿还已有债务和遏制新增债务。\n第一节 债务与经济衰退 # 经济的正常运行离不开债务。企业在卖出产品收到货款之前,需要先建设厂房,购买设备,支付工资,这些支出通常需要从银行贷款。个人买房也往往需要贷款,否则光靠一点点储蓄去全款买房,恐怕退休之前都买不起。政府也常需要借钱,否则无力建设周期长、投资大的基础设施。\n债务关系让经济各部门之间的联系变得更加紧密,任何部门出问题都可能传导到其他部门,一石激起千层浪,形成系统风险。银行既贷款给个人,也贷款给企业。若有人不还房贷,银行就会出现坏账,需要压缩贷款;得不到贷款的企业就难以维持,需要减产裁员;于是更多人失去工作,还不上房贷;银行坏账进一步增加,不得不继续压缩贷款……如此,恶性循环便产生了。如果各部门负债都高,那应对冲击的资源和办法就不多,风吹草动就可能引发危机。这类危机往往来势汹汹,暴发和蔓延速度很快,原因有二。\n第一,负债率高的经济中,资产价格的下跌往往迅猛。若债务太重,收入不够还本,甚至不够还息,就只能变卖资产,抛售的人多了,资产价格就会跳水。这种情况屡见不鲜。 22 2008—2009年的美国次贷危机中,美国家庭房贷负担很重,很多人不得不卖房,房价不到一年就跌了两成。2011—2012年,借钱炒房的“温州炒房团”和温州中小企业资金链断裂,导致房产纷纷被抛售,温州房价一年内跌了近三成。 33 2013—2014年,内蒙古和晋陕等地的煤炭企业崩盘。很多煤老板曾在煤价上涨时大肆借债扩张,煤价大跌后无力还债,大幅折价变卖豪车和房产。\n第二,资产价格下跌会引起信贷收缩,导致资金链断裂。借债往往需要抵押物(如房产和煤矿),若抵押物价值跳水,债权人(通常是银行)坏账就会飙升,不得不大幅缩减甚至干脆中止新增信贷,导致债务人借不到钱,资金链断裂,业务难以为继。2004—2008年,爱尔兰经济过热,信贷供给年均增速为20%。2009年,美国次贷危机波及爱尔兰,银行业出现危机,2009—2013年信贷增速剧烈收缩至1.3%,导致大量企业资金链断裂。 44 在2011—2012年的温州民间借贷危机中,一些人跑路逃债,信任危机迅速发酵,所有人都捂紧钱包,信用良好的人也借不到钱。可见债务危机往往也会殃及那些债务水平健康的部门,形成连锁反应,造成地区性甚至全国范围的经济衰退。\n一个部门的负债对应着另一个部门的资产。债务累积或“加杠杆”的过程,就是人与人之间商业往来增加的过程,会推动经济繁荣。而债务紧缩或“去杠杆”也就是商业活动减少的过程,会带来经济衰退。举例来说,若房价下跌,老百姓感觉变穷了,就会勒紧裤腰带、压缩消费。东西卖不出去,企业收入减少,就难以还债,债务负担过高的企业就会破产,银行会出现坏账,压缩贷款,哪怕好企业的日子也更紧了。这个过程中物价和工资会下跌(通货紧缩),而欠的钱会因为物价下跌变得更值钱了,实际债务负担就更重了。 55 发达国家经济中最重要的组成部分是消费,对经济影响很大。美国的消费约占GDP七成,2008年的全球金融危机中消费大幅下挫,成了经济衰退的主要推手。危机之前房价越高的州,老百姓债务负担越重,消费下降也越多,经济衰退越严重。在欧洲,2008年之前房价涨幅越大的国家,居民债务负担越重,危机中消费下降也越多。 66 债务带来的经济衰退还会加剧不平等(第五章),因为债务危机对穷人和富人的打击高度不对称。这种不对称源于债的特性,即法律优先保护债权人的索赔权,而欠债的无论是公司还是个人,即使破产也要清算偿债。以按揭为例,穷人因为收入低,买房借债的负担也重,房价一旦下跌,需要先承担损失,直到承担不起破产了,损失才转到银行及其债主或股东,后者往往是更富的人。换句话说,债务常常把风险集中到承受能力最弱的穷人身上。一个比较极端的例子是西班牙。在大多数国家,还不起房贷的人可以宣布破产,银行把房子收走,也就两清了。但在西班牙,哪怕房主把房子给了银行并宣布破产,也只能免于偿还按揭利息,本金仍然要偿还,否则累计的罚金和负债将一直存在,会上失信名单,很难正常生活。在金融危机中,这项法律引起了社会的不满和动荡,开锁匠和警察拒绝配合银行驱逐房主。破产了也消不掉的债成了沉重的负担:全球金融危机爆发五年后,西班牙是全球经济衰退最严重的国家之一。 77 第二节 债台为何高筑:欧美的教训 # 债务源于人性:总想尽早满足欲望,又对未来盲目乐观,借钱时总觉得将来能还上。但人性亘古不变,债务周期却有起有落,每一次起伏都由特定的外部因素推动,这些因素会引发乐观情绪、刺激人们借债,也会增加资金供给、为借债大开方便之门。\n20世纪80年代以来欧美的政治经济环境,刺激了居民对房子的需求(第五章)。但买房的前提是银行愿意放贷,否则需求就无法转化为实际购买力。若只是借贷需求增加而资金供给不增加,那利息就会上涨,需求会被抑制,贷款数量和债务水平不一定会上升。居民和企业的债务规模,换个角度看也就是银行的信贷和资产规模。所以要理解债务的增长,首先要理解银行为什么会大量放贷。\n资金供给与银行管制 # 资金供给的增加源于金融管制的放松。一方面,银行越做越大,创造的信贷越来越多;另一方面,金融创新和衍生品层出不穷,整个金融部门的规模和风险也越滚越大。\n全球金融自由化浪潮始于20世纪70年代布雷顿森林体系的解体。在布雷顿森林体系下,各国货币以固定比例与美元挂钩,美元则以固定比例与黄金挂钩。要维持这一固定汇率体系,各国都需要充足的外汇储备去干预市场,防止汇率波动。所以国际资本流动的规模不能太大,否则就可能冲破某些国家的外汇储备,威胁整个体系。而要限制国际资本流动,就要限制国内银行放贷规模,否则借到钱的居民或企业就会增加消费品或投资品的进出口,过量的国际贸易和结算会引发过量的国际资本流动。\n布雷顿森林体系解体后,发达国家之间实行了浮动汇率,放开了跨境资本流动。企业和居民既可以从本国银行借钱,也可以从外国银行借钱,所以单方面管控国内银行的信贷规模就没用了,于是各国纷纷放松了对银行和金融机构的业务限制,自由化浪潮席卷全球。但银行危机也随之而来。1980年至2010年,全球发生了153次银行危机,平均每年5次。而在布雷顿森林体系下,1945年至1970年,全球总共才发生了2次银行危机。纵观整个19世纪和“二战”之前,全球银行危机的频率都与国际资本流动规模高度相关。 88 金融风险的核心是银行,历次金融危机几乎都伴随着银行危机。简单说来原因有四。 99 第一,银行规模大、杠杆高。美国银行业资产规模在1950年只占GDP的六成,而到了2008年全球金融危机之前已经超过了GDP,且银行自有资本占资产规模的比重下降到了5%左右。换句话说,美国银行业在用5块钱的本钱做着100块钱的生意,平均杠杆率达到了20倍。理论上只要亏5%,银行就蚀光了本。欧洲银行的杠杆率甚至更高,风险可想而知。 1010 第二,银行借进来的钱很多是短期的(比如活期存款),但贷出去的钱却大都是长期的(比如企业贷款),这种负债和资产的期限不匹配会带来流动性风险。一旦储户集中提取存款,银行贷出去的钱又不能立刻收回来,手里钱不够,会出大乱子。后来银行业引入了存款保险制度,承诺对个人存款进行保险,才缓解了挤提风险,但并没有完全解除。现代银行业务复杂,早已不是简单的存贷款机构,很多负债并非来自个人存款,而是来自货币基金和对冲基金,并不受存款保险制度保护。 1111 一旦机构客户信心不足或急需流动性,也会形成挤提。\n第三,银行信贷大都和房地产有关,常常与土地和房产价值一同起落,放大经济波动。银行因为杠杆率高,所以要特别防范风险,贷款往往要求抵押物。土地和房子就是最好的抵押物,不会消失也不会跑掉,价值稳定,潜在用途广,就算砸手里也不难转让出去。因此银行喜欢贷款给房地产企业,也喜欢做居民按揭。2012年,英国的银行贷款中79%都和住房或商业地产有关,其中65%是按揭。美国的银行贷款中也有接近七成是按揭或其他房地产相关贷款。平均来看,欧美主要国家的银行信贷中将近六成都是按揭或不动产行业贷款。 1212 所以房地产周期和银行信贷周期常常同步起伏,而这两个行业的杠杆率又都不低,也就进一步放大了经济波动。\n土地价值顺着经济周期起落,繁荣时地价上涨,衰退时地价下跌。而以土地为抵押物的银行信贷也顺着土地价值起落:地价上涨,抵押物价值上行,银行利润上升,资本充足率也上升,更加愿意多放贷,为此不惜降低放贷标准,逐渐积累了风险。经济衰退时,上述过程逆转。所以银行很少雪中送炭,却常常晴天送伞,繁荣时慷慨解囊、助推经济过热,衰退时却捂紧口袋、加剧经济下行。举例来说,在21世纪初的爱尔兰,大量银行资金涌入房地产行业,刺激房价飞涨。金融危机前,爱尔兰房地产建设投资占GDP比重由4%升至9%,建筑部门的就业人数也迅速增加。危机之后,房地产行业萎缩严重,急需周转资金,然而银行的信贷增速却从危机前的每年20%剧烈收缩至1.3%,很多企业因缺乏资金而倒闭,失业率居高不下。 1313 第四,银行风险会传导到其他金融部门。比如银行可以把各种按揭贷款打包成一个证券组合,卖给其他金融机构。这种业务挫伤了银行信贷分析的积极性。如果银行借出去的钱转手就能打包卖给下家,那银行就不会在乎借钱的人是不是真的有能力还钱。击鼓传花的游戏,传的是什么东西并不重要,只要有人接盘就行。在2008年金融危机中,美国很多按揭贷款的质量很差,借款人根本没能力还钱,有人甚至用宠物的名字都能申请到按揭,所以这次危机也被称为“次贷危机”。\n随着银行和其他金融机构之间的交易越来越多,整个金融部门的规模也越滚越大,成了经济中最大的部门。金融危机前,金融部门的增加值已经占到美国GDP的8%。 1414 频繁的金融活动并没有提高资本配置的效率,反而给经济带来了不必要的成本。过多的短期交易扩大了市场波动,挤压了实体经济的发展空间。资金和资源在金融体系内部空转,但实体经济的蛋糕却没有做大。而且大量金融交易都是业内互相“薅羊毛”,所以“军备竞赛”不断升级,大量投资硬件,高薪聘请人才,导致大量高学历人才放弃本专业而转投金融部门。 1515 金融危机后,金融部门的过度繁荣引发了各界的反思和批评,也引发了“占领华尔街”之类的社会运动。\n国际不平衡与国内不平等 # 金融自由化浪潮为借贷打开了方便之门,但如果没有大量资金涌入金融系统,借贷总量也难以增加。以美国为例,这些资金来源有二。其一,一些国家把钱借给了美国,比如我国就是美国最大的债主之一。其二,美国国内不平等急剧扩大,财富高度集中,富人有了更多花不完的钱可以借给穷人。\n中国等东亚国家借钱给美国,与贸易不平衡有关。2018年,中美双边贸易逆差约4 000亿美元,也就是说美国需要从全世界借入4 000亿美元来为它从中国额外的进口买单,其中最主要的债主就是中国和其他东亚国家。后者在1997年东亚金融危机中吃过美元储备不足的大亏,所以之后大量增加美元储备,买入美国国债或其他证券,相当于把钱借给了美国。这种现象被美联储前主席本·伯南克(Ben Bernanke)称为“全球储蓄过剩”。他认为这些钱流入美国后压低了美国利率,推动了房地产投机,是引发2008年全球金融危机的重要原因。\n然而借钱给美国的还有欧洲,后者受美国金融危机冲击最大、损失也最大。美国各种金融“毒资产”的最大海外持有者并不在亚洲,而在欧洲。东亚借钱给美国与贸易不平衡有关,资金主要是单向流动。而欧洲和美国的贸易基本平衡,资金主要是双向流动:欧洲借钱给美国,美国也借钱给欧洲。这种资本流动总量虽巨大,但双向抵销后的净流量却不大。正是这种“净流量小”的假象掩盖了“总流量大”的风险。“你借我、我借你”的双向流动,让围绕“毒资产”的交易规模越滚越大,风险也越来越大。比如,一家德国银行可以在美国发行用美元计价的债券,借入美元,然后再用这些美元购买美国的房贷抵押证券,钱又流回了美国。这家银行的负债和资产业务都是美元业务,仿佛是一家美国银行,只不过总部设在德国罢了。类似的欧洲银行很多。金融危机前,跨大西洋的资本流动远多于跨太平洋资本流动。而在危机中,美联储为救市所发放的紧急贷款,实际上大部分给了欧洲银行。 1616 国际资本流入美国,也有美国自身的原因,否则为什么不流入其他国家?美元是全世界最重要的储备货币,以美元计价的金融资产也是最重要的投资标的,受到全球资金的追捧,所以美国可以用很低的利率从全球借钱。大量资本净流入美国,会加剧美国贸易逆差,因为外国人手里的美元也不是自己印出来的,而是把商品和服务卖给美国换来的。为保持美元的国际储备货币地位,美国的对外贸易可能需要常年保持逆差,以向世界提供更多美元。但持续的逆差会累积债务,最终威胁美元的储备货币地位,这个逻辑也被称为“特里芬悖论”。 1717 所以如今的全球经济失衡,是贸易失衡和美元地位带来的资本流动失衡所共同造就的。\n国际资本流入不是美国可贷资金增加的唯一原因,另一个重要原因是国内的贫富差距。如果全部财富集中在极少数人手中,富人就会有大量的闲置资金可以借贷,而大部分穷人则需要借钱生存,债务总量就会增加。假如一个国家只有两个人,每人需要消费50元才能活下去。若总产出100元被二人平分,那总消费就等于产出,既没有储蓄也没有负债。但若甲分得100元而乙分得0元,那甲就花50元存50元,乙就需要借50元,这个国家的储蓄率和负债率就都变成了50%。\n在大多数发达国家,过去40年国内贫富差距的扩大都伴随着国内债务水平的上升。 1818 以美国为例:2015年,最富有的10%的人占有将近一半的全部收入,而40年前只占35%。换句话说,40年前每生产100元,富人拿35元,其他人拿65元,但如今变成了对半分,富人从国民收入这块蛋糕里多切走了15%。与这个收入转移幅度相比,常被政客们说起的中美双边贸易“巨额”逆差,2018年只占美国GDP的2%不到。\n如果不看每年收入的差距而看累积的财富差距的话,不平等就更加惊人。2015年,美国最富有的10%的人占有了全部财富的78%。 1919 富人的钱花不完,消费远低于收入,就产生了大量储蓄。过去40年,美国国内最富有的1%的人的过剩储蓄,与伯南克所谓的由海外涌入美国的全球过剩储蓄,体量相当。 2020 理论上讲,这些富人的储蓄可以借给国内,也可以借给国外。但事实上,美国国内资金并没有流出,反而有大量国际资本流入了美国,因此富人的储蓄必然是借给了国内的企业、政府或居民。然而在全球金融危机前的几十年,美国国内企业的投资不增反降,政府每年的赤字和借债也相对稳定,所以富人的储蓄实际上就是借给了其他居民(穷人),变成了他们的债务。\n穷人借债主要是买房,因此富人的余钱也就通过银行等金融中介流向了房地产。金融危机前,美国银行业将近七成的贷款是按揭或其他房地产相关贷款。所以大部分银行并没有把社会闲散资金导向实体企业,变成生产性投资,而是充当了富人借钱给穷人买房的中介。这种金融服务的扩张,降低了资金配置效率,加大了风险。\n这种金融资源“脱实向虚”的现象,在我国也引发了广泛关注。在2019年的上海“陆家嘴论坛”上,中国银行保险监督管理委员会主席郭树清就强调要提高资金使用效率,解决好“脱实向虚”问题,下大力气清理金融体系内部的空转资金。而且特别强调了房地产金融化的问题:“一些房地产企业融资过度挤占了信贷资源,导致资金使用效率进一步降低,助长了房地产投资投机行为。”\n实体企业投资需求不足 # 债务本身并不可怕,如果借来的钱能用好,投资形成的资产能增加未来收入,还债就不成问题。假如资金能被实体企业投资所吸纳,就不会流到房地产和金融行业去推升资产泡沫。然而在过去40年间,主要发达国家的投资占GDP的平均比重从20世纪70年代的28%下跌到了20%。 2121 一个原因是大公司把投资转移到了发展中国家(包括中国),制造业整体外迁。而制造业又是重资产和重投资的行业,所以国内制造业占比下降就推动了投资下降。同时,随着通信技术的发展,机器变得越来越智能化,需要运用大量软件和服务,而设备本身的相对价值越来越低。所以大量投资进入了所谓的“无形资产”和服务业,而服务业更依赖于人的集聚,也就推升了对特定地段的住房和社交空间(即各类商业地产)的需求。 2222 实体投资下降的另一个原因是发达国家经济的整体竞争性在减弱:行业集中度越来越高,大企业越变越大。理论上说,这不一定是坏事,若明星企业通过竞争击败对手、占据市场后依然锐意进取、积极创新,那么投资和生产率还会继续上升。然而实际情况是,美国各行业集中度的提高与企业规模的扩张,往往伴随着投资下降和生产率降低。 2323 大量资金的涌入增加了资金供给,而企业投资需求不足又降低了资金需求,所以发达国家的长期实际利率(扣除物价因素)在过去40年间一直稳步下降,如今基本为零。 2424 因为缺乏能获得长期稳定收益的资产,各种短期投机便大行其道,所谓“金融创新”层出不穷,“房地产泡沫”一个接一个。金融危机之后,美联储常年的宽松货币政策让短期利率也变得极低,大企业便借机利用融资优势大肆购并小企业,进一步增加了行业集中度,降低了竞争。这种低利率环境也把大量追逐回报的资金推入了股市,推高了股价。而美国最富的10%的人掌握着90%的股市资产,贫富差距进一步拉大。 2525 这种情况也引起了我国政策制定者的警惕。2019年,中国人民银行行长易纲指出:“在缺乏增长点的情况下,央行给银行体系提供流动性,但商业银行资金贷不出去,容易流向资产市场。放松货币条件总体上有利于资产持有者,超宽松的货币政策可能加剧财富分化,固化结构扭曲,使危机调整的过程更长。” 2626 第三节 中国的债务与风险 # 我国债务迅速上涨的势头始于2008年。当年金融危机从美国蔓延至全球,严重打击了我国的出口。为防止经济下滑,中央立即出台了财政刺激计划,同时放宽了许多金融管制以及对地方政府的投融资限制,带动了基础设施投资大潮,也推动了大量资金涌入房地产。在不断的投资扩张和房价上涨中,融资平台、房地产企业、贷款买房的居民,债务都迅速上升。其他企业(尤其是国有企业)也在宽松的金融环境中举债扩张,但投资回报率却在下降,积累了低效产能。债务(分子)比GDP(分母)增长速度快,因此债务负担越来越重。\n与其他发展中国家相比,我国外债水平很低,债务基本都是以人民币计价的内债,所以不太可能出现国际上常见的外债危机,像希腊的主权债务危机和每过几年就要上演一次的阿根廷债务危机。根据国家外汇管理局的《中国国际收支报告》,我国2019年末外债余额占GDP的比重只有14%(国际公认安全线是20%),外汇储备是短期外债的2.6倍(国际公认安全线是1倍),足够应对短期偿付。而且即使在外债中也有35%是以人民币计价,违约风险很小。\n债务累积过程简述:2008—2018年 # 图6-2描述了中国始于2008年的债务累积过程。\n图6-2 中国的宏观债务占GDP比重\n数据来源:IMF全球债务数据库。此处债务仅包括银行贷款和债券。\n2008年至2009年,为应对全球金融危机,我国迅速出台“4万亿”计划,其中中央政府投资1.18万亿元(包括对汶川地震重建的拨款),地方政府投资2.82万亿元。为配合政策落地、帮助地方政府融资,中央放松了对地方融资平台的限制(第三章),同时不断降准降息,放宽银行信贷。这些资金找到了基建和房地产两大载体,相关投资迅猛增加。比如地方政府配合当时的铁道部,大量借债建设高铁:全国铁路固定资产投资从2007年的2 500亿元,猛增到2009年的7 000亿元和2010年的8 300亿元。\n2010年至2011年,前期刺激下的经济出现过热迹象,再加上猪肉价格大涨的影响,通货膨胀抬头,所以货币政策开始收紧。到了2011年年中,欧债危机爆发,国内制造业陷入困境,于是央行在2012年又开始降准降息,并放松了对地方融资平台发债的限制,城投债于是激增,净融资额比上年翻了一番还多。也是从2012年开始,以信托贷款为主的“影子银行” 2727 开始扩张,把大量资金引向融资平台,推动当年基建投资猛涨,债务负担从2012年起再次加速上涨。这一时期,中央开始加强了对房地产行业的控制和监管。\n2015年遭遇“股灾”,前些年投资过度造成的产能过剩和房地产库存问题也开始凸显。2015年末,美联储退出量化宽松,美元开始加息,再加上一系列内外因素,导致2015—2016年连续两年的大量资本流出,人民币对美元汇率一路贬值,接近破七。央行于是连续降准降息,财政部开始置换地方债(第三章),中央也放松了对房地产的调控,全国棚户区改造从实物安置转变为货币化安置,带动房价进一步上涨。同时,“影子银行”开始“变形”:信托贷款在严监管下大幅萎缩,而银行理财产品规模开始爆发,流向融资平台和房地产行业的资金总量没有减少,总体债务负担在2015年又一次加速增长。\n2016年,在货币化“棚改”的帮助下,三四线城市房地产去库存告一段落,中央在年底首次提出“房住不炒”的定位,全面收紧房地产调控。也是在这一年,“去产能”改革开始见效,工业企业利润率开始回升,工业品出厂价格指数结束了长达五年的下跌,首次转正。\n2018年上半年,在连续两年相对宽松的外部条件下,央行等四部委联合出台“资管新规”,严控“影子银行”,试图降低累积多年的金融风险。信用和资金开始收缩,民营企业的融资困境全面暴露。下半年,“中美贸易战”开始,经济增长继续放缓。2018年末,我国债务总量占比达到GDP的258%:其中居民债务为54%,政府债务为51%,非金融企业为154%(图6-3)。在政府债务中,中央国债约为17%,地方政府债务为34%。 2828 第三章和第五章已经分别讨论过地方政府债务和居民债务,此处不再赘述,本章重点介绍企业债务以及债主银行的风险。\n图6-3 2018年中、美、德、日四国各部门债务占GDP比重\n数据来源:IMF全球债务数据库。此处债务仅包括银行贷款和债券。\n企业债务 # 从图6-3中可以看出,我国居民债务负担接近发达国家,政府债务负担低于发达国家,但企业债务负担远高于发达国家。2018年,美国和德国企业债务占GDP的比重分别为75%和58%,但我国高达154%。原因之一是资本市场发展不充分,企业融资以债务尤其是银行贷款为主,股权融资占比很低。2018年末,企业债务总额约140万亿元,但在境内的股票融资余额不过区区7万亿元。 2929 企业债务中第一个广受关注的问题是地方政府融资平台企业的债务,约占GDP的40%,资金主要投向基础设施,项目回报率很低(平均1%左右)。第三章已详细讨论过这类债务,不再赘述。\n第二个问题是所谓“国进民退”现象。2008年以后,国有企业规模快速扩张,但效率比私营企业低,多占用的资金没有转化为同比例的新增收入,推升了整体债务负担。按照财政部《中国财政年鉴》中的数据,1998—2007年的10年间,国企资产总额只增长了1.6倍,但2008—2017年这10年间却激增了4.4倍,负债总额也相应增长了4.7倍,占GDP的比重从78%变成144%。 3030 但国企总利润占GDP的比重却从4.2%下降到了3.9%,营业收入占GDP比重从72%下降到了65%。 3131 上述国企数据被广泛使用,但这些数据及相关研究尚有诸多不明之处,很难确知国企的整体情况。 3232 国有工业类企业中的数据更清楚一些,因为国家统计局一直记录详细的工企数据。2008—2017年,国有工业企业的资产和负债规模相对GDP来说并没有大幅扩张,只是略有上升,基本稳定,所以在工业领域并没有出现明显的“国进民退”现象。国企的资产负债率一直高于私营企业,但利润率却较低。2008年“4万亿”计划出台之后,国企与私企的利润率差距进一步扩大,2013年之后才开始缩小。这一变化的主要原因不在国企,而是私企利润率在“4万亿”计划之后飙升,2012年以后回落。可能的一个原因是在信贷宽松的刺激之下,很多有资源、有关系的私营企业(比如上市公司)大肆扩张,偏离主营业务,去“炒地皮”和“炒房子”,虽然获得了短期收益,但最终造成资金使用效率的下降。 3333 低效率乃至亏损的国企或大中型私企,若不能破产重组,常年依靠外力“输血”,挤占有限的信贷资源,变成“僵尸企业”,就会拉低经济整体效率,推升宏观债务负担。针对这一情况,近年来的改革重点包括:推进国企混改,限制地方政府干预;加强金融监管,从源头上拧紧资金的水龙头;在要素市场上推行更加全面的改革,让市场力量在资金、土地、技术、劳动力等生产要素配置中发挥更大作用;改革和完善《企业破产法》,在债务重整过程中“去行政化”,避免地方官员主导企业破产重组,损害债权人利益(比如第四章中的江西赛维案)。\n关于企业债务的第三个广受关注的问题是房地产企业的债务问题。房地产是支柱型产业,不仅本身规模巨大,而且直接带动钢铁、玻璃、家具、家电等众多行业。以2013年为例,房地产及其直接相关行业创造的增加值占GDP的比重超过15%,且增速极快,对GDP增长率的贡献接近30%。 3434 由于房地产开发需要大量资金去购置土地,建设周期也很长,所以企业经营依赖负债,资产负债率接近80%,流动性风险很大。一旦举债渠道受阻,企业就难以为继。举个例子,在上市房企中,与“买地”有关的成本约占总成本的五六成。 3535 在购置土地环节,发达国家一般要求企业使用自有资本金,而我国允许房企借钱“买地”,这就刺激了房企竞相抬高地价和储备土地。储备的土地又可以作为抵押去撬动更多借贷资金,进而储备更多土地,所以房企规模和债务都越滚越大。\n2018年,我国房企总债务占GDP的比重达到了75%,且大量债务来自“影子银行”或其他监管薄弱的渠道。 3636 房企的现金流依赖房产预售款和个人按揭,这两项收入占2018年实际到位资金的将近一半。一旦由于疫情等外部冲击原因出现房产销售问题,房企就可能面临资金链断裂的风险。2020年,这类风险开始显现,无论是泰禾集团的违约还是恒大集团的“内部文件”,都吸引了广泛关注。一旦房企出现债务危机,无疑会冲击金融系统和宏观经济。而且房价连着地价,地价高低又直接影响地方政府收入,危及地方政府及融资平台的债务偿付能力。2020年8月,城乡住房建设部、中国人民银行出台了对重点房地产企业资金监测和融资管理规则,针对企业的关键债务指标画下“三道红线”,也规定企业不得再挪用贷款购地或竞买炒作土地。 3737 还有一种房企债务是在海外发行的美元债,在外国发行,以外币计价,所以不计入外汇管理局的宏观外债统计口径。截至2019年7月末,这类海外债余额约1 739亿美元。其中可能有风险,因为大多数房企并没有海外收入。自2019年7月起,发改委收紧了房企在海外发债。2020年上半年,这类债务开始净减少。 3838 总体看来,我国企业债务负担较重,应对风险的能力受限。若遭遇重大外部冲击,就可能面临债务违约风险。而企业裁员甚至倒闭,会降低居民收入,加大居民的风险,也加大其债主银行的风险。\n银行风险 # 无论是居民债务还是企业债务,都是从债务人角度看待风险,要想完整理解债务风险,还需要了解债权人的风险。中国的债权人主要是银行,不仅发放贷款,也持有大多数债券。上文讨论的欧美银行业的很多风险点,同样适用于我国。首先是对信贷放松管制,银行规模迅速膨胀。2008年的“4万亿”计划,不仅是财政刺激,也是金融刺激,存款准备金率和基准贷款利率大幅下调。银行信贷总额占GDP的比重从2008年的1.2左右一路上升到2016年的2.14。\n其次是银行偏爱以土地和房产为抵押物的贷款。我再用两个小例子来详细解释一下。先看住房按揭。银行借给张三100万元买房,实质不是房子值100万元,而是张三值100万元,因为他未来有几十年的收入。但未来很长,张三有可能还不了钱,所以银行要张三先抵押房子,才肯借钱。房子是个很好的抵押物,不会消失且容易转手,只要这房子还有人愿意买,银行风险就不大。若没有抵押物,张三的风险就是银行的风险,但有了抵押物,风险就由张三和银行共担。张三还要付30万元首付,相当于抵押了100万元的房子却只借到了70万元,银行的安全垫很厚。再来看企业贷款。银行贷给企业家李四500万元买设备,实质也不是因为设备值钱,而是用设备生产出的产品值钱,这500万元来源于李四公司未来数年的经营收入。但作为抵押物,设备的专用性太强,价值远不如住房或土地,万一出事,想找到人接盘并不容易。就算有人愿意接,价格恐怕也要大打折扣,所以银行风险不小。但若李四的企业有政府担保,甚至干脆就是国企,银行风险就小多了。\n所以如果优良的抵押物(住房和土地)越来越多,或者有政府信用担保的企业越来越多,那银行就有动力不断扩大信贷规模。在我国这样一个银行主导的金融体系中,地方融资平台能抵押的土地增加、涌入城市买房的人增加、地方政府的隐性担保增加等,都会从需求端刺激信贷规模的扩张。所以商业银行的信贷扩张,固然离不开宽松的货币环境,但也同样离不开信贷需求的扩张,离不开地方政府的土地金融和房地产繁荣,此所谓“银根连着地根”。\n第三是银行风险会传导到其他金融部门,这与“影子银行”的兴起有关。所谓“影子银行”,就是类似银行的信贷业务,却不在银行的资产负债表中,不受银行监管规则的约束。银行是金融体系核心,规模大,杠杆高,又涉及千家万户的储蓄,牵一发动全身,所以受严格监管。若某房地产企业愿意用10%的利息借钱,银行想借,但我国严格限制银行给房企的贷款量,怎么办?银行可以卖给老百姓一个理财产品,利息5%,再把筹来的钱委托给信托公司,让信托公司把钱借给房企。在这笔“银信合作”业务中,发行的理财产品不算银行储蓄,委托给信托公司的投资不算银行贷款,所以这笔“表外业务”就绕开了对银行的监管,是一种“影子银行”业务。\n有借钱需求的公司很多,愿意买银行理财产品的老百姓也很多,所以“影子银行”风生水起。相关的监管措施效果有限,往往是“按下葫芦起了瓢”。限制了“银信合作”业务,“银证信合作”业务又兴起:银行把钱委托给券商的资管计划,再让券商委托给信托公司把钱借给企业。管来管去,银行的钱到处跑,渠道越拉越长,滋润着中间各类资管行业欣欣向荣,整个金融业规模越滚越大。21世纪初,金融业增加值占GDP的比重大约在4%左右,而2015—2019年平均达到了8%,相当于美国在全球金融危机前的水平。但美国的资本市场是汇聚了全世界的资金后才达到这个规模,我国的资本市场尚未完全开放,金融业规模显然过大了。资金在金融系统内转来转去,多转一道就多一道费用,利息就又高了一点,等转到实体企业手中的时候,利息已经变得非常高,助推了各种投机行为和经济“脱实向虚”。此外,银行理财产品虽然表面上不在银行资产负债表中,银行既不保本也不保息,但老百姓认为银行要负责,而银行也确实为出问题的产品兜过底。这种刚性兑付的压力加大了银行和金融机构的风险。 3939 我国各种“影子银行”业务大都由银行主导,是银行链条的延伸,因此也被称为“银行的影子”。这与国外以非银金融机构主导的“影子银行”不同。中国的业务模式大多简单,无非多转了两道手而已,证券化程度不高,衍生品很少,参与的国际资本也很少,所以监管难度相对较低。2018年“资管新规”出台,就拧紧了“影子银行”的总闸,也打断了各种通道。但这波及的不仅是想借钱的房地产企业和政府融资平台,也挤压了既没有土地抵押也没有政府背书的中小私营企业,它们融资难和融资贵的问题在“资管新规”之后全面暴露。\n第四节 化解债务风险 # 任何国家的债务问题,解决方案都可以分成两个部分:一是偿还已有债务;二是遏制新增债务,改革滋生债务的政治、经济环境。\n偿还已有债务 # 对债务人来说,偿债是个算术问题:或提高收入,或压缩支出,或变卖资产拆东补西。实在还不上,就只能违约,那债权人就要受损。最大的债权人是银行,若出现大规模坏账,金融系统会受到冲击。\n如果借来的钱能用好,能变成优质资产、产生更高收入,那债务负担就不是问题。但如果投资失败或干脆借钱消费挥霍,那就没有新增收入,还债就得靠压缩支出:居民少吃少玩,企业裁员控费,政府削减开支。但甲的支出就是乙的收入,甲不花钱乙就不挣钱,乙也得压缩支出。大家一起勒紧裤腰带,整个经济就会收缩,大家的收入一起减少。若收入下降得比债务还快,债务负担就会不降反升。这个过程很痛苦,日子紧巴巴,东西没人买,物价普遍下跌,反而会加重实际债务负担,因为钱更值钱了。如果抛售资产去还债,资产价格就下跌,银行抵押物价值就下降,风险上升,可能引发连锁反应。\n以地方政府为例。政府借债搞土地开发和城市化,既能招商引资提高税收,又能抬高地价增加收入,一举两得,债务负担似乎不是大问题。可一旦经济下行,税收减少,土地卖不上价钱,诸多公共支出又难以压缩,债务负担就会加重,就不得不转让和盘活手里的其他资产,比如国有企业。最近一两年经济压力大,中央又收紧了融资渠道,于是地方的国企混改就加速了。比如在2019年,珠海国资委转让了部分格力电器的股份,芜湖国资委也转让了部分奇瑞汽车的股份。\n还债让债务人不好过,赖账让债权人不好过。所以偿债过程很痛苦,还有可能陷入经济衰退。相比之下,增发货币也能缓解债务负担,似乎还不那么痛苦,因为没有明显的利益受损方,实施起来阻力也小。增发货币的方式大概有三类。第一类是以增发货币来降低利率,这是2008年全球金融危机前的主流做法。低利率既能减少利息支出,也能刺激投资和消费,提振经济。若经济增长、实际收入增加,就可以减轻债务负担。就算实际收入不增加,增发货币也能维持稳定温和的通货膨胀,随着物价上涨和时间推移,债务负担也会减轻,因为欠的债慢慢也就不值钱了。\n第二类方式是“量化宽松”,即央行增发货币来买入各类资产,把货币注入经济,这是金融危机后发达国家的主流做法。在危机中,很多人变卖资产偿债,资产市价大跌,连锁反应后果严重。央行出手买入这些资产,可以托住资产价格,同时为经济注入流动性,让大家有钱还债,缓解债务压力。从记账角度看,增发的货币算央行负债,所以“量化宽松”不过是把其他部门的负债转移到了央行身上,央行自身的资产负债规模会迅速膨胀。但只要这些债务以本国货币计价,理论上央行可以无限印钱,想接手多少就接手多少。这种做法不一定会推高通货膨胀,因为其他经济部门受债务所困,有了钱都在还债,没有增加支出,也就没给物价造成压力。欧美日在2008年全球金融危机之后都搞了大规模“量化宽松”,都没有出现通货膨胀。\n“量化宽松”的主要问题是难以把增发的货币转到穷人手中,因此难以刺激消费支出,还会拉大贫富差距。央行“发钱”的方式是购买各种金融资产,所以会推高资产价格,受益的是资产所有者,也就是相对富裕的人。2008年全球金融危机之后,美国“零利率”和“量化宽松”维持了好些年,股市大涨,房价也反弹回危机前的水平,但底层百姓并没得到什么实惠,房子在危机中已经没了,手里也没多少股票,眼睁睁看着富人财富屡创新高,非常不满(第五章)。这种不满情绪的高涨对政局的影响也从选举上反映了出来。2020年新冠肺炎疫情在美国暴发后,美联储再次开闸放水,资产负债表规模在3个月内扩张了六成以上,而随后的经济反弹被戏称为“K形反弹”:富人往上,穷人向下。\n第三类增加货币供给的做法是把债务货币化。政府加大财政支出去刺激经济,由财政部发债融资,央行直接印钱买过来,无需其他金融机构参与也无需支付利息,这便是所谓“赤字货币化”。2008年全球金融危机后,前两类增发货币的方式基本已经做到了尽头,而经济麻烦依然不断,新冠肺炎疫情雪上加霜,所以近两年对“赤字货币化”这种激进政策的讨论异常热烈,支持这种做法的所谓“现代货币理论”(Modern Monetary Theory,MMT)也进入了大众视野。\n“赤字货币化”的核心,是用无利率的货币替代有利率的债务,以政府预算收支的数量代替金融市场的价格(即利率)来调节经济资源配置。从理论上说,若私人部门陷入困境,而政府治理能力和财政能力过硬,“赤字货币化”也不是不能做。但若政府能力如此过硬却还是陷入了需要货币化赤字的窘境,那也正说明外部环境相当恶劣莫测。在这种情况下,“赤字货币化”的效果不能仅从理论推断,要看历史经验。从历史上看,大搞“赤字货币化”的国家普遍没有好下场,会引发物价飞涨的恶性通货膨胀,损害货币和国家信用,陷经济于混乱。这种后果每本宏观经济学教科书里都有记录,“现代货币理论”的支持者当然也知道。但他们认为历史上那些恶性通货膨胀的根源不在货币,而在于当时恶劣的外部条件(如动荡和战争)摧毁了产能、削弱了政府,若产能和政府都正常,就可以通过货币化赤字来提振经济。可这又回到了根本问题:若产能和政府都正常,怎么会陷入需要货币化赤字的困境?背后的根本原因能否靠货币化赤字化解?财政花钱要花在哪里?谁该受益谁该受损?\n国民党政府就曾经搞过赤字化,彻底搞垮了货币经济。抗日战争结束后,国民党政府大手花钱打内战。仅1948年上半年的财政赤字就已经是1945年全年赤字的780倍。央行新发行的货币(“法币”)几乎全部用来为政府垫款,仅1948年上半年新发行的纸币数量就是1945年全年新增发行量的194倍。物价完全失控。1948年8月的物价是1946年初的558万倍。很多老百姓放弃使用法币,宁可以物易物或使用黄金。1948年8月,国民党推行币制改革,用金圆券替换法币,但政府信用早已尽失。仅8个月后,以金圆券计价的物价就又上涨了112倍。据季羡林先生回忆,当时清华大学教授们领了工资以后要立刻跑步去买米,“跑快跑慢价格不一样!” 4040 我国目前的货币政策比较谨慎,国务院和央行都数次明确表态不搞“大水漫灌”,“不搞竞争性的零利率或量化宽松政策”。 4141 主要原因可能有二:第一,政府不愿看到宽松的货币政策再次推高房价,“房住不炒”是个底线原则;第二,货币政策治标不治本,无法从根本上解决债务负担背后的经济增速放缓问题,因为这是结构性的问题,是地方政府推动经济发展的模式问题。\n遏制新增债务 # 理解了各类债务的成因之后,也就不难理解遏制新增债务的一些基本原则:限制房价上涨,限制“土地财政”和“土地金融”,限制政府担保和国有企业过度借贷,等等。但困难在于,就算搞清楚了原因,也不一定就能处理好后果,因为“因”毕竟是过去的“因”,但“果”却是现在的“果”,时过境迁,很多东西都变了。好比一个人胡吃海塞成了大胖子,要想重获健康,少吃虽然是必须的,但简单粗暴的节食可能会出大问题,必须小心处理肥胖引起的很多并发症。\n反过来看,当年种下的“因”,也有当年的道理,或干脆就是不得已而为之。当下债务问题的直接起因是2008年的全球金融危机。当时金融海啸一浪高过一浪,出口订单锐减,若刺激力度不够,谁也不知道后果会如何。虽然现在回过头看,有不少声音认为“4万亿”计划用力过猛,但历史不能假设。\n再比如,政府通过或明或暗的担保来帮助企业借款,不一定总是坏事。在经济发展早期,有很多潜在收益很高的项目,由于金融市场不发达、制度不规范,不确定性很强,很难吸引足够的投资。正是由于有了政府担保,这些项目才得以进行。但随着市场经济不断发展,粗放式投资就能带来高收益的项目减少,融资需求逐渐多元化,若此时政府仍过多干预,难免把资金导入低效率的企业,造成过剩产能,挤占其他企业的发展空间。投资效益越来越低,对经济的拉动效果也越来越弱,债务负担和偿债风险就越来越高。\n总的说来,我国的债务问题是以出口和投资驱动的经济体系的产物。2008年之后,净出口对GDP的拉动作用减弱,所以国内投资就变得更加重要(见下一章的图7-4以及相关详细解释)。而无论是基建还是房地产投资,都由掌握土地和银行系统的政府所驱动,由此产生的诸多债务,抛开五花八门的“外衣”,本质上都是对政府信用的回应。所形成的债务风险,虽然表现为债主银行的风险,但最终依然是政府风险。最近几年围绕供给侧结构性改革所推行的一系列重大经济金融改革,包括严控房价上涨、“资管新规”、限制土地融资、债务置换、“反腐”、国企混改等,确实有效遏制了新增债务的增长,但是高度依赖负债和投资的发展模式还没有完成转型,因此限制债务虽限制了这种模式的运转,但并不会自动转化为更有效率的模式,于是经济增速下滑。\n限制债务增长的另一项根本性措施是资本市场改革,改变以银行贷款为主的间接融资体系,拓展直接融资渠道,既降低债务负担,也提高资金使用效率。与债权相比,股权的约束力更强。一来股东风险共担,共赚共赔;二来股权可以转让,股价可以约束公司行为。哪怕同样是借债,债券的约束力也比银行贷款强,因为债券也可以转让。\n这些年资本市场的改革进展相对缓慢,根本原因并不难理解。融资体系和投资体系是一体两面:谁来做投资决策,谁就该承担投资风险,融资体系也就应该把资源和风险向谁集中。若投资由政府和国企主导,风险也自然该由它们承担。目前的融资体系正是让政府承担风险的体系,因为银行的风险最终是政府的风险。以2018年的固定资产投资为例,按照国家统计局的口径,“民间投资”占62%,政府和国企占38%。但这个比例大大低估了政府的影响,很多私人投资是在政府产业政策的扶持之下才上马的。在房地产开发中,投资总额的四到五成是用来从政府手里买地的。这种投资结构所对应的风险自然主要由政府及其控制的金融机构承担。根据中国人民银行行长易纲的测算,2018年,我国金融资产中72%的风险由金融机构和政府承担。1995年和2007年,这个比例分别是74%和70%,多年来变化并不大。 4242 因此政府和国企主导投资与国有银行主导融资相辅相成,符合经济逻辑。这一体系在过去的经济增长中发挥过很大作用,但如果投资主体不变,权力不下放给市场,那想要构建降低政府和银行风险的直接融资体系、想让分散的投资者去承担风险,就不符合“谁决策谁担风险”的逻辑,自然进展缓慢。当然以直接融资为主的资本市场也不是万灵药,华尔街的奇迹和灾祸都不少。在我国将来的金融体系中,究竟间接和直接融资各占多大比重,国有金融企业和机构(包括政策性银行和社保基金等)在其中该扮演何种角色,都还是未知数。\n总的来看,我国债务风险的本质不是金融投机的风险,而是财政和资源分配机制的风险。这些机制不是新问题,但债务负担在这十年间迅速上升,主要是因为这一机制已经无法持续拉动GDP增长。无论是实际生产率的增长还是通货膨胀速度,都赶不上信贷或债务增长的速度,所以宏观上就造成了高投资挤压消费,部分工业产能过剩和部分地区房地产投资过剩,同时伴随着腐败和行政效率降低。这种经济增长方式无法持续。最近几年的改革力图扭转这种局面,让市场在资源配置中,尤其是在土地和资本等要素配置中起更大作用。\n结语 # 本章分析了我国债务的情况,聚焦企业和银行风险,结合前几章讨论过的政府和居民债务风险,希望能帮助读者理解债务风险的大概。债务问题不是简单的货币和金融问题,其根源在于我国经济发展的模式和结构,所以在降债务的过程中伴随着一系列深层次的结构性改革。然而导致目前债务问题的直接起因,却是2008年的全球金融危机和几年后的欧债危机。这两次危机对世界格局的影响,远超“9·11”事件。为应对巨大的外部冲击,我国迅速出台了“4万亿”计划,稳定了我国和世界经济,但同时也加剧了债务负担和产能过剩。\n产能过剩可以从三个角度去理解。第一是生产效率下降。宏观上表现为GDP增速放缓,低于债务增速,所以宏观债务负担加重。微观上表现为地方政府过度投资、不断为一些“僵尸企业”输血,扭曲了资源配置,加重了政府和企业的债务负担。而且地方政府的“土地财政”和“土地金融”模式过度依赖地价上涨和房地产繁荣,推升了房价和居民债务负担,也加大了银行风险。\n第二个角度是国际失衡。地方政府重视投资、生产和企业税收,相对忽视消费、民生和居民收入,造成经济结构失衡,分配体制偏向资本,劳动收入偏低,所以消费不足,必须向国外输出剩余产能。我国和韩国、日本等东亚邻居不同,体量巨大,所以对国际经济体系冲击巨大,贸易冲突由此而来。\n第三个角度是产业升级。因为产能过剩,我国制造业竞争激烈,价格和成本不断降低,不仅冲击了外国的中低端制造业,也冲击了本国同行。要想在国内市场上存活和保持优势,头部企业必须不断创新,进入附加值更高的环节。所以我国制造业的质量和技术含量在竞争中不断上升,在全球价值链上不断攀升,也带动了技术创新和基础科学的进步,进一步冲击了发达国家主导的国际分工体系。\n从第三章开始一直到本章结束,本书已经详细分析了第一个角度,也为理解第二和第三个角度打下了微观基础。下一章将展开讨论我国对国际经济体系的冲击,并且从国际冲突的角度出发,由外向内再度审视国内经济结构的失衡问题。\n扩展阅读 # 最近10年,从债务角度反思2008年全球金融危机的好书很多。普林斯顿大学迈恩和芝加哥大学苏非的著作《房债:为什么会出现大衰退,如何避免重蹈覆辙》(2015)是一本关于美国房地产及债务的通俗作品。对一般读者来说,该书可能关注面有些狭窄,细节也过于详尽;但对于经济学专业的学生,该书值得细读,可以学习如何从微观数据中清楚地解答重要的宏观问题。英国经济学家特纳的著作《债务和魔鬼:货币、信贷和全球金融体系重建》(2016)是针对债务问题更加全面的通俗作品,思路清楚,文笔流畅,可以结合英国央行前行长金关于银行和金融系统的杰作《金融炼金术的终结:货币、银行与全球经济的未来》(2016)一起阅读,会大有收获。经济史专家图兹的著作Crashed: How a Decade of Financial Crises Changed the World(2018)全面而细致地记录了2008年全球金融危机之后的10年间世界政治、经济格局的深刻变化,非常精彩。著名对冲基金——桥水基金的创始人达利欧(Dalio)对债务问题有多年的思考和实践,其著作《债务危机:我的应对原则》(2019)不受经济学理论框框的限制,更加简练直接。\n关于中国债务问题有很多讨论和研究,但大多是学术文献或行业报告,全面系统的普及性读物较少。南京大学-约翰斯·霍普金斯大学中美文化研究中心研究员包尔泰(Armstrong-Taylor)就中国的金融体制和债务写过一本简明通俗的书Debt and Distortion: Risks and Reforms in the Chinese Financial System(2016),是很好的入门读物。彭博的经济学家欧乐鹰(Orlik)最近出版了一本小书China:the Bubble that Never Pops(2020),标题很有趣,“中国,永不破裂的泡沫”。该书回顾了改革开放以来历次债务危机的前因后果和化解办法。作者坦言,中国经济发展史也是各种“中国崩溃论”的失败史。在别人忙着讥讽“水多加面,面多加水”的手忙脚乱时,作者问:馒头为什么越蒸越大?\n银行是金融系统的核心,也是金融危机的风暴眼。理解银行的风险需要深入了解其具体业务,这是近些年关于金融危机的研究中最有意思的部分。虽然从宏观角度分析危机也很有意思,但只有深入了解具体业务细节,才能真正对现实的复杂性和吊诡之处产生敬畏之心,避免夸夸其谈。虽然业务内容比较专业,但有两本书写的相对简明,是很好的入门读物。一本是耶鲁大学戈顿的《银行的秘密:现代金融生存启示录》(2011),另一本是英国经济学家米尔恩(Milne)的The Fall of the House of Credit: What Went Wrong in Banking and What Can be Done to Repair the Damage?(2009)。获过奥斯卡奖的电影《大空头》中也有很多金融业务细节,很精彩。本片虽然取材自真人真事,但主人公们其实不是真正从做空金融危机中赚到大钱的人,还差得远。如果想听听这次危机中“大钱”交易的故事,祖克曼的《史上最伟大的交易》(2018)是一部可以当小说看的杰作。\n关于我国银行系统的风险和改革,两位参与其中的投行经济学家沃尔特和豪伊写了一本很专业但不难理解的书,《红色资本:中国的非凡崛起和脆弱的金融基础》(2013),值得一读。高盛前总裁、美国前财政部长保尔森也参与和见证了我国金融改革,他的回忆录《与中国打交道:亲历一个新经济大国的崛起》(2016)中有很多轶事。人民银行副行长潘功胜的著作《大行蝶变:中国大型商业银行复兴之路》(2012)则从中国银行家的角度回顾和分析了大型商业银行的改革历程,也值得一读。\n11 详见哈佛大学福尔曼(Furman)和萨默斯(Summers)的论文(2020)。\n22 为了还债而低价变卖金融资产的行为,术语称为“fire sale”。读者可参考哈佛大学施莱弗(Shleifer)和芝加哥大学维什尼(Vishny)对这一现象的简明介绍(2011)。\n33 衡量美国房价的常用指标是Case-Shiller指数,2008年初大约为180,2009年跌穿150。根据南京大学包尔泰(Paul Armstrong-Taylor)著作(2016)中的数据,温州二手房的价格指数从2011年1月的120跌到2012年1月的85。\n44 爱尔兰数据来自英国经济学家特纳(Turner)的著作(2016)。\n55 这种“债务—通货紧缩—经济萧条”螺旋式下行的逻辑,被美国经济学家费雪(Fisher)称为“债务—通缩循环”,可以解释1929—1933年的世界经济大萧条。这种理论后来被日本经济学家辜朝明发展成为“资产负债表衰退理论”,用来解释日本20世纪90年代初开始的长期衰退以及美国次贷危机后的衰退(辜朝明,2016)。布朗大学埃格特松(Eggertsson)和诺贝尔奖得主克鲁格曼(Krugman)的文章(2012)系统地阐述了这一思路。\n66 普林斯顿大学迈恩和芝加哥大学苏非的著作(2015)详细介绍了美国居民的债务和消费情况。美联储旧金山分行的研究(Glick and Lansing, 2010)表明,欧美主要国家2008年之前10年的房价和居民负债高度正相关,而负债越多的国家,在危机中消费下降也越多。\n77 西班牙的例子来自普林斯顿大学迈恩和芝加哥大学苏非的著作(2015)。\n88 哈佛大学莱因哈特(Reinhart)和罗格夫(Rogoff)在其著作(2012)中统计了过去200年全球主要国家的银行危机次数,发现危机频率和国际资本流动规模高度正相关。\n99 关于银行风险,英国中央银行前行长默文·金(Mervyn King)的著作(2016)精彩而深刻。\n1010 根据英格兰银行的报告(Haldane, Brennan and Madouros,2010),2007年美国主要商业银行的杠杆率(总资产/一级核心资本)在20倍左右(如美洲银行将近21倍),投资银行则在30倍左右(如雷曼兄弟将近28倍)。而欧洲的德意志银行是52倍,瑞银是58倍。\n1111 这类资金中数额最大的一类是“回购”(repo),可以理解为一种短期抵押借款。耶鲁大学戈顿(Gorton)的著作(2011)对这项重要业务做了简明而精彩的介绍。\n1212 数据来自英国经济学家特纳的著作(2016)以及旧金山美联储霍尔达(Jordà)、德国波恩大学舒拉里克(Schularick)和戴维斯加州大学泰勒(Taylor)的合作研究(2016)。此外,三位德国经济学家的研究(Knoll, Schularick and Steger, 2017)指出,房价在20世纪70年代布雷顿森林体系解体后开始加速上涨,比人均GDP增速快得多,这可能跟金融放松管制后大量资金进入房地产市场有关。\n1313 爱尔兰的数据来自特纳的著作(2016)。银行信贷的顺周期特性也反映在利息变动中。一旦形势不好,利息也往往迅速升高。纽约大学的格特勒(Gertler)和吉尔克里斯特(Gilchrist)的论文(2018)描述了2008—2009年全球金融危机期间各种利息的变动和金融机构的行为。\n1414 数字来自英格兰银行的报告(Haldane, Simon and Madouros, 2010)。\n1515 纽约大学菲利蓬(Philippon)和弗吉尼亚大学雷谢夫(Reshef)的论文(2012)研究了美国金融部门对高学历人才的挤占。\n1616 国际清算行的研究人员分解了各大陆间的金融资本流动总量,而不是仅仅关注“净流量”(Avdjiev, McCauley and Shin, 2016)。跨大西洋金融机构间的紧密联系,有复杂的成因和后果,经济史专家图兹(Tooze)的著作(2018)对此有非常详尽和精彩的论述。\n1717 这个理论是针对布雷顿森林体系以及之前的金本位所提出的,能否直接套用到如今美国的贸易赤字问题上,尚有争议。不能据此认为,人民币想要成为国际货币,我国现在的贸易顺差就必须变成逆差。\n1818 普林斯顿大学迈恩(Mian)、哈佛大学斯特劳布(Straub)、芝加哥大学苏非(Sufi)等人的论文详细阐述了发达国家中收入不平等和债务上升之间的关联(2020a)。\n1919 收入和财富不平等的数据来自德国波恩大学库恩(Kuhn)、舒拉里克(Schularick)和施泰因(Steins)的论文(2020)。\n2020 富人的钱借给了穷人,逻辑上很好理解,但实际上并不容易证实,需要拨开金融中介的重重迷雾,搞清楚资金的来龙去脉。美国最富有的1%的人储蓄增加,对应着最穷的90%的人债务增加,是普林斯顿大学迈恩(Mian)、哈佛大学斯特劳布(Straub)、芝加哥大学苏非(Sufi)等人最近的发现(2020b)。\n2121 数据同样来自迈恩(Mian)、斯特劳布(Straub)和苏非(Sufi)的论文(2020a)。\n2222 两位英国经济学家的著作(Haskel and Westlake, 2018)指出:1990年至2014年,资本设备相对于当期产品和服务的价格下降了33%。该书把发达国家称为“没有资本的资本主义”(capitalism without capital),即更重视无形资产和人才的资本主义,并深入探讨了这种经济中的生产率停滞和收入不平等。\n2323 纽约大学菲利蓬(Philippon)写过一本精彩的小书(2019),分析美国经济越来越偏离自由市场,竞争力不断下降。\n2424 数据来自迈恩(Mian)、斯特劳布(Straub)和苏非(Sufi)的论文(2020a)。\n2525 数据来自库恩(Kuhn)、舒拉里克(Schularick)和施泰因(Steins)的论文(2020)。富人的九成资产都是股票,这一比例自1950年至今变化不大。\n2626 见易纲的文章(2019)。\n2727 关于“影子银行”,详见本章后文“银行风险”小节。\n2828 很多人认为地方政府债务不仅应包括政府的显性债务,也应包括其关联企业的债务,所以债务负担不止34%。但如果把这些隐性债务算到政府头上的话,就不能再重复算到企业头上,因此这类争议并不影响对总体债务水平的估算。\n2929 企业债务总额按图6-3中占GDP的比重推出。图中的企业不包括金融企业,这是计算宏观债务负担时的国际惯例。图中债务是指企业部门作为一个整体从外部(如银行)借债的总额,不包括企业间相互的债务债权关系(如业务往来产生的应收账款等)。我国非金融企业在境内股票融资的总额,来自中国人民银行发布的“社会融资规模存量统计表”。\n3030 这个比重与图6-3中企业债务占GDP的154%,不能直接相比。图中的企业债务是企业作为一个整体的对外债务总额,只包括贷款和债券;而此处的144%也包含了企业间的应付账款等。企业间的相互债务在计算企业总体对外债务负担时会互相抵消。但如果只计算部分企业比如国企的债务规模时,这些企业间的债务也应该算上。关于国企的宏观数据有两套口径,一套是国资委的,一套是财政部的,后一套涵盖范围更广,因为很多国企不归国资委管。本节只讨论非金融类企业,因为图6-3中的企业债务统计仅包括非金融企业。\n3131 与GDP最可比的企业数据应该是增加值,但国企整体增加值很难估算,各种估计方法都有不小缺陷。按张春霖的估计(2019),国企增加值占GDP的比重最近10年变化不大。\n3232 比如,2017年的所有国企资产中有45%被归类为“社会服务业”和“机关社团及其他”,而这两类企业的营业收入几乎可以忽略不计。财政部没有详细解释这些究竟是什么企业。再比如,按资产规模算,2017年至少24%的国企应被归类为基础设施企业,其中也包括地方政府融资平台。这类企业对经济的贡献显然不能只看自身的资产回报,还应该考虑它们对其他经济部门的带动作用和贡献,但这些贡献很难估计。\n3333 关于私营企业包括工业企业在“4万亿”计划之后的扩张和效率下降,可以参考清华大学白重恩、芝加哥大学谢长泰、香港中文大学宋铮的研究(Bai, Hsieh and Song,2016)。在房地产繁荣和房价高涨的刺激下,2007年至2015年,A股上市公司中的非房地产企业也大量购入了商业地产和住宅,总金额占其投资总额三成,这个数据来自香港浸会大学陈婷、北京大学周黎安和刘晓蕾、普林斯顿大学熊伟等人的研究(Chen et al.,2018)。\n3434 这种估计比较复杂,涉及房地产相关各行业的投入产出模型。此处的估计结果来自国家统计局许宪春等人的论文(2015)。\n3535 对上市房企成本构成的估计,来自恒大经济研究院任泽平、夏磊和熊柴的著作(2017)。\n3636 这里的75%与图6-3中企业债务占GDP的154%,不能直接相比。图中的企业债务只包括了贷款和债券,而这里的债务还包括房企的应付账款等其他债务。在我国会计制度下,预售房产生的收入,也就是在房子交接前所形成的预收房款,记为房企的负债。A股上市房企中,这部分预收款约占总负债的1/3。我国严格限制商业银行给房企贷款,所以在房企的负债中,国内银行贷款的比重一直维持在10%—15%。然而这种对资金渠道的限制很难影响资金最终流向,大量资金通过各种渠道包括“影子银行”流入了房企。\n3737 所谓“三道红线”,是要求房企在剔除预收款后的资产负债率不得高于70%、净负债率不得高于100%、现金短债比不小于1倍。\n3838 关于房企海外债的数据和监管,可参考《财新周刊》2019年第29期的文章《房企境外发债为何收紧》及2020年第37期的文章《房企降杠杆开始》。\n3939 上海高级金融学院朱宁的著作(2016)系统地阐释了这种所谓“刚性泡沫”现象。\n4040 本段中的数字根据民国著名金融家张嘉璈书中(2018)的统计数据计算得出。关于季羡林先生的故事,来自北京大学周其仁的回忆(2012)。\n4141 2019年末,易纲在《求是》发表文章,阐述了货币政策的目标和理念(2019)。\n4242 数据来源于易纲的论文(2020)。\n第七章 国内国际失衡 # 十多年前,我去过一次开曼群岛,那是当时我去过的最远的地方。我在一个岛上看到了一家中餐馆,印象很深,觉得不管哪里都有中国人在做生意。又过了两年,我在波多黎各的一家旅游纪念品商店门上看到一块告示:“本店不卖中国货”。我特地进去看了看,除了当地人的一些手工品之外,义乌货其实不少。\n早在2007年,美国就出了一本畅销书,叫《离开中国制造的一年》(A Year Without“Made in China”),讲美国一家人试着不用中国货的生活实验。书本身乏善可陈,但其中的一些情绪在美国普通百姓中颇具代表性。这些情绪在之后的十多年间慢慢发酵,民间反全球化倾向越来越明显。2018年6月,世界银行前首席经济学家巴苏(Basu)教授来复旦大学经济学院演讲,谈到中美贸易战时说:“我来自印度,过去的大半辈子,一直都是发达国家用各种手段打开发展中国家市场,要求贸易。没想到世界有一天会倒过来。”\n我国经济的崛起直接得益于全球化,但因为自身体量大,也给全球体系带来了巨大冲击。2001年加入WTO之后,我国迅速成为“世界工厂”。2010年,制造业增加值超过美国,成为全球第一。2019年,制造业增加值已占到全球的28%(图7-1)。我国出口的产品不仅数量巨大,技术含量也在不断提升。2019年出口产品中的三成可以归类为“高技术产品”,而在这类高技术产品的全球总出口中,我国约占四分之一。由于本土制造业体量巨大,全球产业链在向我国集聚,也带动了本土供应商越来越壮大。因此我国出口模式早已不是简单的“来料加工”,绝大部分出口价值均由本土创造。2005年,我国每出口100美元就有26美元是从海外进口的零部件价值,只有74美元的价值来自国内(包括在国内设厂的外资企业生产的价值)。2015年,来自海外供应链的价值从26%下降到了17%。 11 图7-1 各国制造业增加值占全球比重\n数据来源:世界银行。七国集团即英、美、日、德、法、意、加。\n这些巨大的成功背后,也隐藏着两重问题。第一是内部经济结构失衡:重生产、重投资,相对轻民生、轻消费,导致与巨大的产能相比,国内消费不足,而消化不了的产品只能对外输出。这就带来了第二个问题:国外需求的不稳定和贸易冲突。过去20年,世界制造业中我国的占比从5%上升到28%,对应的是“七国集团”占比从62%下降到37%,而所有其他国家占比几乎没有变化(图7-1)。这背后不仅是中国经济面貌翻天覆地的变化,也是发达国家经济结构的巨大变化。面对剧烈调整,出现贸易冲突甚至贸易战,一点也不奇怪。\n本章第一节分析国内经济结构的失衡问题,这与地方政府发展经济的模式直接相关,也影响了对外贸易失衡。第二节以中美贸易战为例,讨论中国经济对外国形成的冲击和反弹。在这些大背景下,2020年中央提出“推动形成以国内大循环为主体、国内国际双循环相互促进的新发展格局”。第三节分析这一格局所需要的条件和相关改革。\n第一节 低消费与产能过剩 # 我国经济结构失衡的最突出特征是消费不足。在2018年GDP中,居民最终消费占比只有44%,而美国这一比率将近70%,欧盟和日本也在55%左右。 22 从20世纪80年代到2010年,我国总消费(居民消费+政府消费)占GDP的比重从65%下降到了50%,下降了足足15个百分点,之后逐步反弹到了55%(图7-2)。居民消费占GDP的比重从80年代的54%一直下降到2010年的39%,下降了15个百分点。图中总消费和居民最终消费间的差距就是政府消费,一直比较稳定,占GDP的11%左右。\n图7-2 中国消费占GDP比重\n数据来源:《中国统计年鉴2020》。\n居民消费等于收入减去储蓄,下面这个简单的等式更加清楚地说明了这几个变量间的关系:\n所以当我们观察到消费占GDP的比重下降时,无非就是两种情况:或者GDP中可供老百姓支配的收入份额下降了,或者老百姓把更大一部分收入存了起来,储蓄率上升了。实际上这两种情况都发生了。在图7-3中可以看到,从20世纪90年代到2010年,居民可支配收入占GDP的比重从70%下降到了60%,下降了10个百分点,之后逐步反弹回65%。而居民储蓄率则从21世纪初的25%上升了10个百分点,最近几年才有所回落。这一降一升,都与地方政府推动经济发展的模式密切相关,对宏观经济影响很大。\n图7-3 居民可支配收入占GDP比重及储蓄占可支配收入比重\n数据来源:《中国统计年鉴2020》。\n居民高储蓄 # 我国居民储蓄率很高,20世纪90年代就达到了25%—30%。同期美国的储蓄率仅为6%—7%,欧洲主要国家比如德、法就是9%—10%。日本算是储蓄率高的,也不过12%—13%。国家之间储蓄率的差异,可以用文化、习惯甚至语言和潜意识来解释。可能中国人历来就是特别勤俭,舍不得花钱。前些年有一个很吸引眼球的研究,讲世界各地的语言与储蓄率之间的关系。很多语言(如英语)是有时态的,因此在讲到“过去”“现在”“未来”时,语法要改变,会让人产生一种“疏离感”,未来跟现在不是一回事,何必担心未来,活在当下就好。因此说这种语言的人储蓄率较低。很多语言(如汉语和德语)没有时态,“往日之我”“今日之我”“明日之我”绵延不断,因此人们储蓄率也较高。 33 天马行空的理论还有不少,但语言、文化、习惯等因素长期不变,解释不了我国储蓄率近些年的起起落落,所以还得从分析经济环境的变化入手。目前主流的解释是计划生育、政府民生支出不足、房价上涨三者的共同作用。 44 计划生育后,人口中的小孩占比迅速下降,工作年龄人口(14—65岁)占比上升,他们是储蓄主力,所以整体储蓄率从20世纪80年代就开始上升。孩子数量减少后,“养儿防老”的功效大打折扣,父母必须增加储蓄来养老。虽然父母会对仅有的一个孩子加大培养力度,增加相关支出尤其是教育支出,但从整体来看,孩子数量的减少还是降低了育儿支出,增加了居民储蓄。21世纪初,独生子女们开始陆续走上工作岗位,而随着城市化大潮、商品房改革和房价上涨,他们不仅要攒钱买房、结婚、培养下一代,还要开始分担多位父母甚至祖父母的养老和医疗支出,储蓄率于是再次攀升。 55 这一过程中的几个要素,都与地方政府有关。首先是房价上涨,这与地方政府以“土地财政”和“土地金融”推动城市化的模式密切相关(第二章和第五章)。在那些土地供应受限和房价上涨快的地区,居民要存钱付首付、还按揭,储蓄率自然上升,消费下降。虽然房价上涨会增加有房者的财富,理论上可能刺激消费,降低储蓄,但大多数房主只有一套房,变现能力有限,消费水平主要还是受制于收入,房价上升的“财富效应”并不明显。所以整体上看,房价上升拉低了消费,提高了储蓄。 66 其次,地方政府“重土地轻人”的发展模式将大量资源用在了基础设施建设和招商引资上,民生支出比如公立教育和卫生支出相对不足(第五章)。而且教育和医疗等领域由于体制原因,市场化供给受限,市场化服务价格偏高,所以家庭需要提高储蓄以应对相关支出。这也造成了一个比较独特的现象:我国老年人的储蓄率偏高。一般来讲,人在年轻时储蓄,年老时花钱,因此老年人储蓄率一般偏低。但我国老人的储蓄率也很高,因为要补贴儿女的住房支出和第三代的教育费用,还有自身的医疗费用等。此外,地方政府常年按照户籍人口规模来规划公共服务供给,满足不了没有户籍的常住人口的需要。这些人难以把妻儿老小接到身边安心生活,因此在耐用品消费、住房和教育消费等方面都偏低。他们提高了储蓄,把钱寄回了外地家里。这些外来人口数量庞大,也推高了整体储蓄率。 77 居民收入份额低 # 居民消费不足不仅是因为储蓄率高,能省,也是因为确实没钱。从21世纪初开始,在整个经济蛋糕的分配中,居民收入的份额就一直在下降,最多时下降了10个百分点,之后又反弹回来5个百分点(图7-3)。在经济发展过程中,这种先降后升的变化并不奇怪。在发展初期,工业化进程要求密集的资本投入,资本所得份额自然比在农业社会中高。与一把锄头一头牛的农业相比,一堆机器设备的工业更能提高劳动生产率和劳动收入水平,但劳动所得在总产出中的占比也会相对资本而下降。20世纪90年代中后期,我国工业化进程开始加速,大量农业劳动力转移到工业,因此劳动相对于资本的所得份额降低了。此外,在工业部门内部,与民营企业相比,国有企业有稳定就业和工资的任务,雇工人数更多,工资占比更大,因此90年代中后期的大规模国企改革也降低了经济中劳动收入所占的份额。 88 随着经济的发展,服务业逐渐兴起,劳动密集程度高于工业,又推动了劳动收入占比的回升。\n在这一结构转型过程中,地方政府推动工业化的方式加速了资本份额的上升和劳动份额的下降。第二至第四章介绍了地方招商引资和投融资模式,这是一个“重企业、重生产、重规模、重资产”的模式。地方政府愿意扶持“大项目”,会提供各种补贴,包括廉价土地、贷款贴息、税收优惠等,这都会刺激企业加大资本投入,相对压缩人力需求。虽然相对发达国家而言,我国工业整体上还是劳动密集型的,但相对我国庞大的劳动力规模而言,工业确实存在资本投入过度的扭曲现象。加入WTO之后,一方面,进口资本品关税下降,增加了企业的资本投入;另一方面,工业在东南沿海集聚引发大规模人口迁移,而与户籍和土地有关的政策抬高了房价和用工成本,不利于外来人口安居乐业,“用工荒”现象屡有发生,企业于是更加偏向资本投入。 99 当然,资本相对劳动价格下降后,企业是否会使用更多资本,还取决于生产过程中资本和劳动的可替代性。如今各种信息技术让机器变得越来越“聪明”,能做的事越来越多,对劳动的替代性比较高,所以机器相对劳动的价格下降后,的确挤出了劳动。 1010 举个例子,我国是世界上最大的工业机器人使用国,2016年就已占到了世界工业机器人市场的三成,一个重要原因就是用工成本上升。 1111 从收入角度看,国民经济分配中居民占比下降,政府和企业的占比就必然上升。同理,从支出角度看,居民消费占比下降,政府和企业支出占比就会上升,这些支出绝大多数用于了投资。也就是说,居民收入转移到了政府和企业手中,变成了公路和高铁等基础设施、厂房和机器设备等,而老百姓汽车和家电等消费品占比则相对降低。此外,总支出中还有一块是外国人的支出,也就是我国的出口。居民消费支出占比下降,不仅对应着投资占比上升,也对应着出口占比上升。因此在很长一段时间里,拉动我国GDP增长的主力是投资和出口,而国内消费则相对不振。\n该如何评价这种经济发展模式?首先要注意上文讲的都是相对份额,不是绝对数量。整个经济规模在急速膨胀,老百姓的收入占比虽然相对下降了,但水平在迅速上升。消费和投资水平也都在迅速上涨,只不过速度快慢有别罢了。\n从经济增长角度看,资本占比上升意味着人均资本数量增加,这是提高生产率和实现工业化的必经阶段。我国几十年内走完了西方几百年的工业化进程,必然要经历资本积累阶段。欧美和日韩也是如此。英国的“圈地运动”和马克思描述的“原始资本”积累过程,读者们想必耳熟能详。近些年兴起的“新资本主义史”,核心议题之一正是欧美资本积累过程中的“强制性”,比如欧洲列强对殖民地的压榨和美国的奴隶制等。 1212 而在“东亚奇迹”中,人民的勤奋、高储蓄、高投资和资本积累举世闻名。我国也不例外。除了人民吃苦耐劳之外,各种制度也在加快资本积累。比如计划经济时期的粮食“统购统销”、工农业产品价格“剪刀差”等,都是把剩余资源从农业向工业转移。而在城镇,为了降低企业使用资金的成本,刺激投资和工业化,银行压低了给企业的贷款利息。为了保证银行的运转和利差收入,银行给居民储蓄的利率就被压低了。这种“金融抑制”降低了居民的收入。而居民在低利率下为了攒足够的钱,也提高了储蓄率,降低了消费。 1313 若单纯从经济增长的逻辑出发,穷国底子薄,增长速度应该更快,而像美国这样的巨无霸,每年即便只增长1%—2%,从绝对数量上看也非常惊人,很不容易。假如穷国增长快而富国增长慢的话,久而久之,各国的经济发展水平应该趋同。但实际上并非如此——除了一个部门例外,那就是制造业。制造业生产率低的国家,生产率进步确实快,而制造业生产率高的国家,进步也的确慢。 1414 可见制造业的学习效应极强,是后发国家赶超的基石。久而久之,后发国家的制造业生产率就有机会与先进国家“趋同”。那为什么经济整体却没有“趋同”呢?最关键的原因,是很多国家无法组织和动员更多资源投入制造业,无法有效启动和持续推进工业化进程。\n因此,在经济发展初期,将更多资源从居民消费转为资本积累,变成基础设施和工厂,可以有效推动经济起飞和产业转型,提高生产率和收入。而且起步时百废待兴,基础设施和工业水平非常落后,绝大多数投资都有用,都有回报,关键是要加大投资,加速资本积累。而在资本市场和法律机制还不健全的情况下,以信用等级高的政府和国企来调动资源,主导基础设施和工业投资,是有效的方式。\n但当经济发展到一定阶段后,这种方式就不可持续了,会导致四个问题。第一,基础设施和工业体系已经比较完善,投资什么都有用的时代过去了,投资难度加大,因此投资决策和调配资源的体制需要改变,地方政府主导投资的局面需要改变。这方面前文已说过多次(第三章和第六章),不再赘述。第二,由于老百姓收入和消费不足,无法消化投资形成的产能,很多投资不能变成有效的收入,都浪费掉了,所以债务负担越积越重,带来了一系列风险(第六章),这种局面也必须改变。第三,劳动收入份额下降和资本收入份额上升,会扩大贫富差距。因为与劳动相比,资本掌握在少数人手中。贫富差距持续扩大会带来很多问题,社会对此的容忍度是有限的(第五章)。第四,由于消费不足和投资过剩,过剩产能必须向国外输出,而由于我国体量巨大,输出产能会加重全球贸易失衡,引发贸易冲突(见下节)。\n在这个大背景下,党的十九大报告将我国社会的主要矛盾修改为“人民日益增长的美好生活需要和不平衡不充分的发展之间的矛盾”。所谓“不平衡”,既包括城乡间和地区间不平衡以及贫富差距(第五章),也包括投资和消费等经济结构不平衡。而“不充分”的一个重要方面,就是指老百姓收入占比不高,“获得感”不够。\n针对居民收入占比过低的问题,党的十九大提出要“提高就业质量和人民收入水平”,并明确了如下原则:“破除妨碍劳动力、人才社会性流动的体制机制弊端,使人人都有通过辛勤劳动实现自身发展的机会。完善政府、工会、企业共同参与的协商协调机制,构建和谐劳动关系。坚持按劳分配原则,完善按要素分配的体制机制,促进收入分配更合理、更有序。鼓励勤劳守法致富,扩大中等收入群体,增加低收入者收入,调节过高收入,取缔非法收入。坚持在经济增长的同时实现居民收入同步增长、在劳动生产率提高的同时实现劳动报酬同步提高。拓宽居民劳动收入和财产性收入渠道。履行好政府再分配调节职能,加快推进基本公共服务均等化,缩小收入分配差距。”\n如果人们把收入中的固定比例用于消费,那要想提高消费占GDP的比重,只让居民收入增长与经济增长“同步”是不够的,必须让居民收入增长快于经济增长,居民收入份额才能提高,居民消费占GDP的比重也才能提高。2020年11月,国务院副总理刘鹤在《人民日报》发表题为“加快构建以国内大循环为主、国内国际双循环相互促进的新发展格局”的文章,其中就提到“要坚持共同富裕方向,改善收入分配格局,扩大中等收入群体,努力使居民收入增长快于经济增长”。\n要落实十九大提出的这些原则,需要很多具体改革。第二章介绍了公共支出方面的改革,要求地方政府加大民生支出。第三章介绍了官员评价体系的改革,要求地方官员重视民生支出和解决不平衡不充分的问题。第五章介绍了要素市场改革,试图提高劳动力收入,降低房价和居民债务负担,以增加消费。这里再举一例,即国有企业资本划转社保基金的改革。\n在国民收入分配中,居民收入份额的下降很大程度上对应着企业留存收入份额(即“企业储蓄”)的上升。要想增加居民收入,就要把这些企业留存资源转给居民。民营企业整体利润率比国企高,所以留存收入或“总储蓄”较多,但这些钱都用作了投资,还不够,所以“净储蓄”是负的,还要融资。而国企整体盈利和“总储蓄”比民营企业少,但“净储蓄”却是正的。“净储蓄”虽是正的,国企的平均分红率比民营企业要低。 1515 2017年,国务院提出将国有企业(中央和地方)包括金融机构的股权划归社保基金,划转比例统一为10%。2019年改革提速,要求央企在2019年完成划转,地方国企在2020年底基本完成划转。 1616 这项改革涉及数万亿元资金和盘根错节的利益,难度很大,但必须下决心完成。毕竟,在当初的社保改革中,国企退休老职工视同已经缴费,造成的社保基金收支缺口也理应由国企资产来填补。2019年底,央企1.3万亿元的划转已经完成。本章写作时的2020年初,地方国企的划转还在推进过程中。\n产能过剩、债务风险、外部失衡 # 在一个开放的世界中,内部失衡必然伴随着外部失衡。本国生产的东西若不能在本国消化,就只能对外输出。GDP由三大部分组成:消费、投资、净出口(出口减进口)。我国加入WTO之后,投资和净出口占比猛增(图7-4),消费占比自然锐减(图7-2)。这种经济结构比较脆弱,不可持续。一来外国需求受国外政治、经济变化影响很大,难以掌控;二来投资占比不可能一直保持在40%以上的高位。超出消费能力的投资会变成过剩产能,浪费严重。欧美发达国家投资占GDP的比重只有20%—23%。\n图7-4 净出口与投资占GDP比重\n数据来源:《中国统计年鉴2020》。“净出口”按支出法GDP计算。\n虽然从会计核算角度讲,投资确实可以提升当下的GDP数字,但若投资形成的资产不能提高生产率、带来更高的收入,不能成为未来更高的消费,这种投资就没有形成实质性的财富,就是浪费。假如政府借钱修了一条路,很多人都用,降低了通勤和物流成本,提高了生产率,那就是很好的投资。但若政府不断挖了修、修了再挖,或干脆把路修到人迹罕至之处,经济账就算不回来了。这些工程所带来的收入远远抵不上成本,结果就是债务越积越高。虽然修路时的GDP上升了,但实际资源是被浪费掉了。这种例子并不罕见。当下尚未将这些损失入账,但未来迟早会出现在账上。\n投资和消费失衡不是新问题。早在2005—2007年,我国家庭收入和消费占GDP的比重就已经下降到了低点(图7-2和图7-3)。当时政府已经意识到了这个问题,时任国务院总理温家宝在2007年就曾提出:“中国经济存在着巨大问题,依然是不稳定、不平衡、不协调、不可持续的结构性的问题”,比如“投资与消费者之间不协调,经济增长过多地依赖于投资和外贸出口”。 1717 但2008年全球金融危机爆发,我国出口锐减,不得已出台“4万亿”计划,加大投资力度,导致投资占GDP的比重从已然很高的40%进一步上升到47%(图7-4),虽然弥补了净出口下降造成的GDP缺口,稳定了经济增长,但也强化了结构失衡问题。2011年又逢欧债危机,所以始终没有机会切实调整经济结构。2007—2012年,消费占比、居民收入占比、居民储蓄率几乎没有变化(图7-2和图7-3)。由于国内居民收入和消费不足,国外需求也不足,所以企业投资实体产业的动力自然也就不足,导致大量投资流入了基础设施投资和房地产,带动了房价和地价飙升,提升了债务负担和风险(第三章到第六章)。直到2012年党的十八大之后,才开始逐步推行系统的“供给侧结构性改革”。\n因为我国消费占比过低,纵然极高的投资率也还是无法完全消纳所有产出,剩余必须对外出口。我国出口常年大于进口,也就意味着必然有其他国家的进口常年大于出口,其中主要是美国。由于我国体量巨大,对国际贸易的冲击也巨大,所带来的经济调整并不轻松。\n当然,国内和国际是一体两面,国内失衡会导致国际失衡,而国际失衡反过来也可以导致国内失衡。我国国内失衡,生产多消费少,必须向外输出剩余。但反过来看,美国人大手支出,高价向我国购买,我国的相应资源也会从本国消费者向出口生产企业转移,以满足外国需求,这就加剧了国内的消费和生产失衡。2001年“9·11”事件之后到全球金融危机之前,美国发动全球反恐战争,消耗了大量资源,同时国内房地产持续升温,老百姓财富升值,也加大了消费,这些需求中很大一部分都要靠从中国进口来满足。美国由此累积了巨大的对外债务,最大的债主之一就是中国,同时也加剧了我国内部的经济失衡。全球金融危机之后,中美两国都开始了艰难的调整和再平衡。我国的调整包括“供给侧结构性改革”、要素市场改革,以及提出“国内大循环为主、国际国内双循环相互促进”的发展战略,等等。在美国,这种调整伴随着政治极化、贸易保护主义兴起等现象。\n因此,贸易问题从来不是单纯的贸易问题,贸易冲突的根源也往往不在贸易本身。在一个开放的世界中,国内经济结构的重大调整,会直接影响到贸易总量。资源在居民、企业、政府间的不同分配格局,也会造成生产和投资相对消费的比重变化,进而影响经济的内外平衡。人们常说“外交是内政的延续”,从宏观角度看,对外贸易失衡也是内部结构失衡的延续。\n第二节 中美贸易冲突 # 各国内部经济结构的平衡程度,会反映到其国际收支状况中。我国国内产出没有被国内消费和投资完全消耗掉,因此出口大于进口,经常账户(可以简单理解为货物和服务进出口情况的总结)是顺差,对外净输出。美国的国内产出满足不了本国消费和投资需求,因此进口大于出口,经常账户是逆差,对外净输入。图7-5描绘了从20世纪90年代至今的国际收支失衡情况,有的国家顺差(黑线之上,大于零),有的国家逆差(黑线之下,小于零)。逻辑上,全球经常账户总差额在各国互相抵消后应该为零。但在现实统计数据中,由于运输时滞或因逃税而虚报等原因,这个差额约占全球GDP的0.3%。\n图7-5 经常账户差额占全球GDP比重\n数据来源:万得数据库。\n图7-5有两个显著的特点。第一,20世纪90年代的失衡情况不严重,约占全球GDP的0.5%以内。从21世纪初开始失衡加剧,在全球金融危机前达到顶峰,约占全球GDP的1.5%—2%。危机后,失衡情况有所缓解,下降到全球GDP的1%以内。第二,全球经常账户的逆差基本全部由美国构成,而顺差大都由中国、欧洲和中东构成。我国在加入WTO之后飞速发展,占全球顺差的份额扩大了不少,也带动了石油等大宗商品的“超级周期”,油价飞涨,中东地区顺差因此大增。金融危机后,美国消费支出降低,同时美国国内的页岩油气革命彻底改变了其天然气和石油依赖进口的局面,而转为世界上最重要的油气生产国和出口国,油气的国际价格因此大跌,既降低了美国国际收支的逆差,也降低了中东地区国际收支的顺差。2017年,中国超过加拿大,成为美国原油最大的进口国。 1818 美国可以吸纳其他国家的对外净输出,当然离不开美国的经济实力和美元的国际储备货币地位。美国每年进口都大于出口,相当于不断从国外“借入”资源,是世界最大的债务国。但这些外债几乎都以美元计价,原则上美国总可以“印美元还债”,不会违约。换句话说,只要全世界还信任美元的价值,美国就可以源源不断地用美元去换取他国实际的产品和资源,这是一种其他国家所没有的、实实在在的“挥霍的特权”(exorbitant privilege)。 1919 在美国的所有贸易逆差中,与中国的双边逆差所占比重不断加大,从21世纪头几年的四分之一上升到了最近五年的五成到六成。因此美国虽和多国都有贸易冲突,但一直视中国为最主要对手。 2020 就业与政治冲击 # 在中美贸易冲突中,美国政客和媒体最常提起的话题之一就是“中国制造抢走了美国工人的工作”。主要论据如下:20世纪90年代美国制造业就业占劳动人口的比重一直比较稳定,但在中国加入WTO之后,中国货冲击美国各地,工厂纷纷转移至海外,制造业就业占比大幅下滑。受中国货冲击越严重的地区,制造业就业下滑越多。 2121 从数据上看,似乎确实有这个现象。图7-6中两条黑线中间的部分显示:20世纪90年代,美国制造业就业占劳动人口的比重稳定在15%左右,从2001年开始加速下滑,2008年全球金融危机前下降到了11%。然而在两条黑线之外,更明显的现象是制造业就业从70年代开始就一直在下降,从26%一直下降到个位数。就算把21世纪初下滑的4个百分点全赖在和中国的贸易头上,美国学界和媒体所谓的“中国综合征”在这个大趋势里也无足轻重。此外,虽然制造业就业一直在下跌,但是从1970年到2013年,制造业创造的增加值占美国GDP的比重一直稳定在13%左右。 2222 人虽少了,但产出并没有减少,这是典型的技术进步和生产率提高的表现。机器替代了人工而已,并没什么特别之处。农业技术进步也曾让农民越来越少,但农业产出并没有降低。另一方面,从中国进口的产品价格低廉,降低了使用这些产品的部门的成本,刺激了其规模和就业扩张,其中既有制造业也有服务业。虽然确有部分工人因工厂关闭而失业,但美国整体就业情况并未因中美贸易而降低。 2323 图7-6 美国制造业就业占工作年龄人数比重\n数据来源:FRED数据库,美联储圣路易斯分行。\n注:横轴刻度为当年1月1日。\n然而在民粹主义和反全球化情绪爆发的年代,讲道理没人听。失业的原因有很多,技术进步、公司管理不善、市场需求变化等。但如今不少美国人,似乎普遍认为“全球化”才是祸根,“贸易保护”才是良方。最近的一个基于大规模网络民调的实验很能说明问题。实验人员给被试者看一则新闻,说一家美国公司做出了一些经营调整,既不说调整原因,也没说要裁员,但特朗普的支持者中就有接近两成的人建议“贸易保护”。如果调整一下这则新闻的内容,提到裁员,但明确说原因不是因为贸易冲击,而是因为经营不善或市场变化等其他因素,特朗普支持者中建议“贸易保护”的人会上升到将近三成。如果再调整一下,明确说裁员是因为贸易冲击,特朗普支持者中建议“贸易保护”的人将达到半数。而此时就算政治倾向偏中间,甚至偏克林顿的人,建议“贸易保护”的倾向也会大幅上升。这些倾向不只是说说而已,会直接影响投票结果。 2424 技术冲击 # 中国制造业崛起和中美贸易对美国的就业冲击其实不重要。相比之下,对美国的技术冲击和挑战更加实实在在,这也是中美贸易冲突和美国技术遏制可能会长期化的根本原因。虽然制造业占美国就业的比重已是个位数,但制造业依旧是科技创新之本,美国研发支出和公司专利数量的六七成均来自制造业企业。 2525 图7-7描绘了我国各项指标相对美国的变化。首先是制造业增加值。1997年,我国制造业增加值只相当于美国的0.14,但2010年就超过了美国,2018年已经相当于美国的1.76倍。其次是技术,衡量指标是国际专利的申请数量,数据来自世界知识产权组织(WIPO)的“专利合作条约”(PCT)系统。自1978年该系统运作以来,美国在2019年首次失去了世界第一的位置,被中国超越。再次是更加基础的科学,衡量指标是国际高水平论文的发表数量,即“自然指数”(Nature Index)。这项指数只包括各学科中国际公认的82本高质量学报上发表的论文,从中计算各国作者所占比例。2012年,我国的数量只相当于美国的0.24,略高于德国和日本,但2019年已经达到了美国的0.66,相当于德国的3倍,日本的4.4倍。\n图7-7 中美科技相对变化(美国各项指标设为1)\n数据来源:制造业增加值数据来自世界银行;国际专利申请数量来自世界知识产权组织;国际论文发表数量来自“自然指数”。\n这些数量指标当然不能完全代表质量。但在工业和科技领域,没有数量做基础,也就谈不上质量。此外,这些数据都是每年新增的流量,不是累积的存量。若论累积的科技家底,比如专利保有量和科研水平,中国还远远赶不上美国。这就好比一个年轻人,多年努力后年薪终于突破百万,赶上了公司高管的水平,但老资格的高管们早已年薪百万了几十年,累积的财富和家底自然要比年轻人厚实得多。但这个年薪百万的流量确实传递了一个强烈的信号:年轻人已非昔日吴下阿蒙,已经具备了挣钱的能力,势头很猛,未来可期,累积家底大约只是时间问题。如今人们对“中国制造”的产品质量的认可度远高于10年前,这个认知有个滞后的过程。对技术和科学,也是同样的道理。\n对站在科技前沿的国家来说,新技术的发明和应用一般从科学研究和实验室开始,再到技术应用和专利阶段,然后再到大规模工业量产。但对一个后起的发展中国家来说,很多时候顺序是反过来的:先从制造环节入手,边干边学,积累技术和经验,然后再慢慢根据自身需要改进技术,创造一些专利。产品销量逐步扩大、技术逐步向前沿靠拢之后,就有了更多资源投入研发,推进更基础、应用范围更广的科研项目。2010年,我国制造业增加值超过美国。又过了10年,2019年中国的国际专利申请数量超过美国。而按照目前的科学论文增长率,2025年左右中国就可能超过美国(图7-7)。\n所以对后发国家来说,工业制造是科技进步的基础。世界上没有哪个技术创新大国不是制造业大国(至少曾经是)。而从制造业环节切入全球产业链分工,也是非常正确的方式,因为制造业不仅有学习效应,还有很强的集聚效应和规模效应。最近十几年,我国制造业产业链的优势一直在自我强化,不断吸引供应链上的外国企业来中国设厂,而本国的上下游厂商也发展迅猛,产业链协同创新的效应也很强。我国出口产品中最大的一类是通信技术设备和相关电子产品(比如手机)。2005年,这类出口品中海外进口部件价值的占比高达43%,本土创造的价值只有57%。但到了2015年,来自海外的价值下降到了30%。 2626 我用苹果公司生产的iPhone来举个例子。多年前,媒体和分析家中流传一种说法:一台“中国制造”的iPhone,卖大几百美元,但中国大陆贡献的价值只不过是富士康区区二三美元的组装费。最近两年,仍然时不时还会看到有人引用这个数据,但这与事实相差太远。苹果公司每年都会公布前200家供应商名单,这些公司占了苹果公司原材料、制造和组装金额的98%。在2019年版的名单中,中国大陆和香港的企业一共有40家,其中大陆企业30家,包括多家上市公司。 2727 在A股市场上,早有所谓的“果链概念”,包括制造iPhone后盖的蓝思科技、摄像头模组的欧菲光、发声单元的歌尔股份、电池的德赛电池等上市公司。虽然很难估计在一台iPhone中,中国(含香港)产业链贡献的精确增加值,但从国内外一些“拆机报告”中估计的各种零部件价格看,中国(含香港)企业应该贡献了iPhone硬件价值的两成左右。\n从理论上说,中美贸易不一定会损害美国的科技创新。虽然一些实力较弱的企业在和中国的竞争中会丧失优势,利润减少,不得不压缩研发支出和创新活动,最终可能倒闭。但对于很多大公司来说,把制造环节搬到中国,靠近全球最大也是增长最快的市场,会多赚很多钱,再将这些利润投入位于美国的研发部门,不断创新和提升竞争优势,最终美国的整体创新能力不一定会受负面影响。 2828 但在美国政坛和媒体中,这些年保守心态占了上风,对华技术高压政策可能会持续下去。假如世界上最大的市场和最强的科创中心渐行渐远的话,对双方乃至全世界都会是很大的损失。毕竟我国在基础科研质量、科技成果转化效率等方面,还有很长的路要走,而美国要想在全球再找一个巨大的市场,也是天方夜谭。没有了市场,美国公司持续不断的高额研发支出很难持续,也就难以长久维持技术优势。同时,技术高压虽然可能让我国企业在短期内受挫,但很多相对落后的国产技术也因此获得了市场机会,可能提高市场份额和收入,进而增大研发力度,进入“市场—研发—迭代—更大市场”的良性循环,最终实现国产替代。但这一切的前提,是我国国内市场确实能继续壮大,国民消费能继续提升,能真正支撑起“国内大循环为主体”的“双循环”模式。\n第三节 再平衡与国内大循环 # 我国的经济发展很大程度得益于全球化,借助巨大的投资和出口,几十年内就成长为工业强国和世界第二大经济体。2019年,我国GDP相当于1960年全球GDP的总量(扣除物价因素后)。但过去的发展模式无法持续,经济结构内外失衡严重,而国际局势也日趋复杂,中央于是在2020年提出了“加快构建以国内大循环为主体、国内国际双循环相互促进的新发展格局”。这是一个发展战略上的转型。\n从本章的分析角度看,这一战略转型的关键是提高居民收入和消费。虽然政府目前仍然强调“供给侧结构性改革”,但所谓“供给”和“需求”,不是两件不同的事,只是看待同一件事的不同角度。比如从供给角度看是调节产能,从需求角度看就是调整投资支出;从供给角度看是产业升级,从需求角度看也就是收入水平和消费结构的升级。2020年12月的中央经济工作会议提出,“要紧紧扭住供给侧结构性改革这条主线,注重需求侧管理,打通堵点,补齐短板,贯通生产、分配、流通、消费各环节,形成需求牵引供给、供给创造需求的更高水平动态平衡”。\n要提高居民收入,就要继续推进城市化,让人口向城市尤其是大城市集聚。虽然制造业是生产率和科技进步的主要载体,但从目前的技术发展和发达国家的经验看,制造业的进一步发展吸纳不了更多就业。产业链全球化之后,标准化程度越来越高,大多数操作工序都由机器完成。比较高端的制造业,资本密集度极高,自动化车间里没有几个工人。美国制造业虽然一直很强大,但吸纳的就业越来越少(图7-6),这个过程不会逆转。所以解决就业和提高收入必须依靠服务业的大发展,而这只能发生在人口密集的城市中。不仅传统的商铺和餐馆需要人流支撑,新兴的网约车、快递、外卖等都离不开密集的人口。要继续推进城市化,必须为常住人口提供相应的公共服务,让他们在城市中安居乐业。这方面涉及的要素市场改革,包括户籍制度和土地制度的改革,第五章已经详细阐释过。\n要提高居民收入和消费,就要把更多资源从政府和企业手中转移出来,分配给居民。改革的关键是转变地方政府在经济中扮演的角色,遏制其投资冲动,降低其生产性支出,加大民生支出。这会带来四个方面的重要影响。其一,加大民生支出,能改变“重土地、轻人”的城市化模式,让城市“以人为本”,让居民安居乐业,才能降低储蓄和扩大消费。其二,加大民生支出,可以限制地方政府用于投资的生产性支出。在目前的经济发展阶段,实业投资已经变得非常复杂,以往的盲目投资所带来的浪费日趋严重,降低了居民部门可使用的实际资源。而且实业投资过程大多不可逆,所以地方政府一旦参与,就不容易退出(第三章)。即便本地企业没有竞争力,政府也可能不得不持续为其输血,挤占了资源,降低了全国统一市场的效率(第四章)。其三,推进国内大循环要求提升技术,攻克各类“卡脖子”的关键环节。而科技进步最核心的要素是“人”。因此地方政府加大教育、医疗等方面的民生支出,正是对“人力资本”的投资,长远看有利于科技进步和经济发展。其四,加大民生支出,遏制投资冲动,还可能降低地方政府对“土地财政”和“土地金融”发展模式的依赖,限制其利用土地加大杠杆,撬动信贷资源,降低对土地价格的依赖,有利于稳定房价,防止居民债务负担进一步加重而侵蚀消费(第五章)。\n要提高居民收入,还要扩宽居民的财产性收入,发展各种直接融资渠道,让更多人有机会分享经济增长的果实,这就涉及金融体系和资本市场的改革。但正如第六章所言,融资和投资是一体两面,如果投资决策的主体不改变,依然以地方政府和国企为主导,那融资体系也必然会把资源和风险向它们集中,难以实质性地推进有更广泛主体参与的直接融资体系。\n“双循环”战略在强调“再平衡”和扩大国内大市场的同时,也强调了要扩大对外开放。如果说出口创造了更多制造业就业和收入的话,那进口也可以创造更多服务业就业和收入,包括商贸、仓储、物流、运输、金融、售后服务等。随着我国生产率的提高,人民币从长期看还会继续升值,扩大进口可以增加老百姓的实际购买力,扩大消费选择,提升生活水平,也能继续增强我国市场在国际上的吸引力。\n世上从来没有抽象的、畅通无阻的市场。市场从建立到完善,其规模和效率都需要逐步提升,完善的市场本就是经济发展的结果,而不是前提。我国疆域广阔、人口众多,建立和打通全国统一的商品和要素市场,实现货物和人的互联互通,难度不亚于一次小型全球化,需要多年的建设和制度磨合。过去几十年,从铁路到互联网,我国各类基础设施发展极快,为全国统一大市场的发展打下了坚实基础,也冲击着一些旧有制度的藩篱。未来,只有继续推进各类要素的市场化改革,继续扩大开放,真正转变地方政府角色,从生产型政府转型为服务型政府,才能实现国内市场的巨大潜力,推动我国迈入中高收入国家行列。\n结语 # 本书介绍了我国地方政府推动经济发展的模式,从微观机制开始,到宏观现象结束。总结一下,这一模式有三大特点。第一个特点是城市化过程中“重土地、轻人”。第二个特点是招商引资竞争中“重规模、重扩张”。第三个特点是经济结构上“重投资、重生产、轻消费”。第五章和第六章分析了前两个特点的得失,并介绍了相关改革。本章则分析了第三个特点。其优点是能快速扩大投资和对外贸易,利用全球化的契机拉动经济快速增长,但缺点是经济结构失衡。对内,资源向企业和政府转移,居民收入和消费占比偏低,不利于经济长期发展;对外,国内无法消纳的产能向国外输出,加剧了贸易冲突。\n经济结构再平衡,从来不是一件容易的事,往往伴随着国内的痛苦调整和国际冲突。2008年全球金融危机之后,全球经济进入大调整期,而我国作为全球经济增长的火车头和第二大经济体,百年来首次成为世界经济的主角,对欧美主导的经济和技术体系造成了巨大冲击,也面临巨大反弹和调整。其实对于常年关注我国经济改革的人来说,过去的40年中没有几年是容易的,经历过几次大的挑战和危机。所以我常跟学生调侃说经济增长不是请客吃饭,是玩儿命的买卖。站在岸边只看到波澜壮阔,看不见暗潮汹涌。\n至于说落后的工业国在崛起过程中与先进国之间的种种冲突,历史上是常态。盖因落后国家的崛起,必然带有两大特征:一是对先进国的高效模仿和学习;二是结合本土实际,带有本国特色,发展路径与先进国有诸多不同之处。虽然第一个特征也常被先进国斥为“抄袭”,但第二个特征中所蕴含的不同体制以及与之伴生的不同思想和意识,先进国恐怕更难接受。 2929 未来不可知,对中国经济的观察者而言,真正重要的是培养出一种“发展”的观念。一方面,理解发展目的不等于发展过程,发达国家目前的做法不一定能解决我们发展中面临的问题;另一方面,情况在不断变化,我们过去的一些成功经验和发展模式也不可能一直有效。若不能继续改革,过去的成功经验就可能成为负担甚至陷阱。要始终坚持实事求是,坚持具体问题具体分析,抛开意识形态,不断去解决实践中所面临的问题,走一条适合自己的发展道路。下一章会展开讨论这些观点。\n扩展阅读 # 国际经济的力量深刻影响着国际关系和新闻中的天下大事,热闹而精彩。但国际经济学分析绕不开经常账户和汇率等基础知识,因此下文中的推荐阅读,可能需要些知识背景才能完全理解,但我尽量挑通俗而准确的读物,相信关心这些现象的读者能够读懂。\n国际经济现象一环扣一环,冲击和调整一波接一波。今天回看2008年全球金融危机后的10年,世界经济政治格局已经发生了深刻的变化,其背后的经济因素和逻辑,第六章曾推荐过的经济史专家图兹的杰作Crashed: How a Decade of Financial Crises Changed the World(2018)值得再次推荐。站在全球的角度再往前看,2008年的危机又是怎么来的呢?这就不得不说到另一件影响深远的大事:1997—1998年的亚洲金融危机。香港证监会原主席沈联涛的著作《十年轮回:从亚洲到全球的金融危机》(2015)阐述了1997—2008年间的全球经济金融变迁,是一本杰作。那从1997年再往前呢?回到风云变幻、自由市场思潮席卷全球的20世纪七八十年代,美联储前主席沃尔克和日本大藏省前副相行天丰雄合著的《时运变迁》(2016)也是一本杰作。他们亲历了石油危机、布雷顿森林体系解体、拉美债务危机、广场协议等一系列历史事件,思考深度和叙事细节,别人当然比不了。从更宏观的角度和更长的历史视角切入,伯克利加州大学埃森格林的杰作《资本全球化:一部国际货币体系史(原书第3版)》(2020)解释了国际货币和金融体系在过去百年间的演变,以及相关的各种政经大事。\n北京大学光华管理学院佩蒂斯的两本书从多个角度解释了国际不平衡的前因后果,通俗易懂:《大失衡:贸易、冲突和世界经济的危险前路》(2014)及Trade Wars are Class Wars: How Rising Inequality Distorts the Global Economy and Threatens International Peace(Klein and Pettis,2020)。虽然我并不认同其中的不少分析,但大多数是对“量”和“度”的分歧,我认为一些事情没有他强调的那么重要,但我很赞成他从多个角度解读国际收支失衡。2008年全球金融危机前,国际失衡程度到达顶峰,中国社会科学院余永定的文集《见证失衡:双顺差、人民币汇率和美元陷阱》(2010)正收录了他从1996年至2009年发表的各类评论和分析文章。这本书很好,但需要一定的知识储备才能看懂。与事后回顾类的文章相比,看事件发生当时的分析,情境感更强;而读者借助事后诸葛的帮助,也更能学习和领会到面对不可知的未来时,每个人思考和推理的局限性。\n日内瓦高级国际关系及发展学院鲍德温的著作《大合流:信息技术和新全球化》(2020)是一本关于全球化的好书,简明通俗。他把全球化分为三个阶段:货物的全球化、信息的全球化、人的全球化。其中对“全球价值链”的现状和发展有很多精彩的分析。全球化当然也冲击了各国的政治体系,哈佛大学罗德里克的《全球化的悖论》(2011)阐述了一个“三元悖论”:深度全球化、政策自主性、民主政治,三者之间不可兼得。其中不少论述对我很有启发。2019年获奥斯卡最佳纪录片奖的《美国工厂》,讲述了中国企业福耀玻璃在美国开工厂的故事,从中可以看到中国制造对美国的冲击,也能体会到制造业回流美国的难度。\n至于中国崛起对世界和美国的冲击,光是最近几年出版的著作都可以说是汗牛充栋了。从“中国统治世界”到“修昔底德陷阱”再到各种版本的“中国崩溃论”,各种身份的作者、各种角度的理论、各种可能的预测,眼花缭乱。这里谨推荐一本历史学家王赓武的杰作China Reconnects: Joining a Deep-rooted Past to a New World Order(Wang,2019)。王教授的人生经历是不可复制的。他是出生在海外的华裔,解放战争时在南京读书,“二战”后辗转东南亚、英国、澳大利亚等地工作居住,又在风云际会的20世纪八九十年代做了10年香港大学校长,最后回到新加坡。其一生不仅精研中国史,还在数个独特的岗位上亲历了各种政经大事。他能在2019年89岁高龄时出版这样一本小书,谈谈他的思考和观察,非常珍贵。其中见识,胜过无数东拼西凑的见闻。\n11 制造业和出口总量数据来自世界银行。出口品中来自海外的增加值占比,来自经济合作与发展组织(OECD)的TiVA(trade in value-added)数据库。\n22 此处使用“实际最终消费支出”,即考虑了各种转移支付之后的实际支出,要高于按GDP支出法直接计算的消费支出。\n33 参见洛杉矶加州大学行为经济学家陈(Chen)的论文(2013)。\n44 IMF的张龙梅等人的论文(Zhang et al., 2018)总结了解释中国储蓄率变化的各种研究。\n55 南加州大学伊莫若霍罗格鲁(Imrohoroglu)和康涅狄格大学赵开的论文(2018)及伦敦经济学院金刻羽等人的论文(Choukhmane, Coeurdacier and Jin, 2019)讨论了“养儿防老”和计划生育等因素对储蓄率的综合影响。\n66 中央财经大学陈斌开和北京大学杨汝岱的论文(2013)分析了各地土地供给和住房价格对城镇居民储蓄的影响,认为房价是储蓄上升的主要推手。西南财经大学万晓莉和严予若以及北京师范大学方芳的论文(2017)估计了房价上涨对消费影响的“财富效应”非常小,影响消费的主因还是收入。\n77 IMF的张龙梅等人(Zhang et al., 2018)对比了我国和其他国家在公共教育、医疗、养老等方面的支出差异。IMF的夏蒙(Chamon)和康奈尔大学的普拉萨德(Prasad)在一份研究中(2010)描绘了我国老年人的高储蓄率,认为城镇居民在教育和医疗上的高支出是推高储蓄率的主因。中央财经大学陈斌开、上海交通大学陆铭、同济大学钟宁桦(2010)分析了我国城市移民消费不足的问题。\n88 经济发展会导致产业结构变化,推动劳动收入份额起伏,可参考复旦大学罗长远、张军的论文(2009)与清华大学白重恩、钱震杰的论文(2009),后者也估计了国企改革的影响。\n99 上海交通大学陆铭的著作(2016)分析了这种“过度资本化”的制度成因。北京大学余淼杰和梁中华的论文(2014)指出,加入WTO后,企业引进资本品和技术的成本下降,刺激了企业用资本替换劳动。\n1010 有个经济学概念叫“资本对劳动的替代弹性”。该弹性若大于1,资本相对价格下降后,企业就会使用更多资本、更少劳动,导致收入分配中劳动的份额下降。复旦大学陈登科和陈诗一的论文(2018)指出上述替代弹性在我国工业企业中大于1。明尼苏达大学卡拉巴布尼斯(Karabarbounis)和芝加哥大学奈曼(Neiman)的论文(2014)指出,资本品价格相对下降引起的劳动份额占比下降,是个全球性的现象。\n1111 武汉大学陈虹和李丹丹,以及圣地亚哥加州大学贾瑞雪和斯坦福大学李宏斌等人的论文(Chen et al., 2019)介绍了我国工业机器人的应用情况。\n1212 哈佛大学史学家贝克特的著作(2019)是“新资本主义史”代表作之一,是一部杰作。但其中一些失实和夸大之处,也招致了经济史学家的批评,比如戴维斯加州大学奥姆斯特德(Olmstead)和密歇根大学罗德(Rhode)的精彩论文(2018)。\n1313 关于“亚洲奇迹”和“中国奇迹”这种“重积累、重投资”的模式(其实相当程度上是工业化的一般模式),有两本书作了系统、深入且生动通俗的描述和分析。一本来自史塔威尔(2014),另一本来自中国社会科学院的蔡昉、李周与北京大学的林毅夫(2014)。\n1414 哈佛大学罗德里克(Rodrik)的论文(2013)描述和分析了全球制造业生产率的“趋同”现象。\n1515 IMF的张龙梅等人(Zhang et al., 2018)估计了国企和民企的储蓄率和分红率。公司储蓄率或留存利润的上升,也是个全球性的现象,比如美国苹果公司账上的天量现金。这方面的研究很多,可参考明尼苏达大学卡拉巴布尼斯(Karabarbounis)和芝加哥大学奈曼(Neiman)近几年的论文(Chen, Karabarbounis and Neiman, 2017;Karabarbounis and Neiman, 2019)。\n1616 2017年,国务院印发《划转部分国有资本充实社保基金实施方案》。2019年,财政部、人力资源社会保障部、国资委、国家税务总局、证监会等五部门联合印发《关于全面推开划转部分国有资本充实社保基金工作的通知》。\n1717 参见十届全国人大五次会议闭幕后温家宝答中外记者问(2007年3月)。\n1818 国际石油市场的变化总是引人遐想,充斥着各种阴谋论和地缘政治分析。但这些起伏背后最重要的因素依然是市场供求。中化集团王能全的著作(2018)分析了最近几十年的石油市场起伏,事实清楚,数据翔实,是很好的参考读物。\n1919 美元特权的源起和影响,著述很多,可参考伯克利加州大学艾肯格林(Eichengreen)的通俗介绍(2019)。\n2020 美国贸易逆差和中美双边贸易差额的数据,来自美国的BEA和人口普查局(Census Bureau)。\n2121 麻省理工学院的奥托尔(Autor)等人的论文影响很大(Autor, Dorn and Hanson,2013)。\n2222 这是在调整完价格因素之后的比重,数据来自哈佛大学罗德里克(Rodrik)的论文(2016)。\n2323 从中国的进口刺激了很多部门的就业,尤其是使用中国货作为投入的部门。详细分析和证据来自乔治梅森大学王直和哥伦比亚大学魏尚进等人的研究(Wang et al., 2018)以及斯坦福大学布鲁姆(Bloom)等人的研究(2019)。\n2424 实验结果来自哈佛大学迪泰拉(Di Tella)和罗德里克(Rodrik)的研究(2020)。麻省理工学院的奥托尔(Autor)等人的论文(2020)指出,那些受贸易冲击较大的地区,投票中的政治倾向两级分化更为严重。\n2525 数据来自麻省理工学院的奥托尔及乔治亚理工学院舒翩等人的研究(Autor et al.,2019)。\n2626 数据来自经济合作与发展组织(OECD)的TiVA数据库。\n2727 公司的具体名单和简要介绍,可参考宁南山发表在其公众号的文章《从2019年苹果全球200大供应商看全球电子产业链变化》。\n2828 这方面的理论可参考哈佛大学阿吉翁(Aghion)等人的论文(2018)。\n2929 哈佛大学历史学家格申克龙(Gerschenkron)的杰作(2012)详细阐述了这两大特征所带来的冲突。\n第八章 总结:政府与经济发展 # 关于经济学家的笑话特别多,每个经济学学生都知道起码十个八个,编一本笑话集应该没问题。经济学家们也经常自嘲。有一段时间,美国经济学会年会还专门设置了脱口秀环节,供本专业人士吐槽。有个笑话是这么讲的。一个物理学家、一个化学家和一个经济学家漂流到孤岛上,饥肠辘辘。这时海面上漂来一个罐头。物理学家说:“我们可以用岩石对罐头施以动量,使其表层疲劳而断裂。”化学家说:“我们可以生火,然后把罐头加热,使它膨胀以至破裂。”经济学家则说:“假设我们有一个开罐头的起子……”\n任何理论当然都需要假设,否则说不清楚。有些假设不符合现实,但是否会削弱甚至推翻其理论,还要依据理论整体来评判。但一旦走出书斋,从理论思考走到现实应用和政策建议,就必须要符合实际,要考虑方案的可行性。所以在经济学理论研究与现实应用之间,常常存在着鸿沟。做过美联储副主席的普林斯顿大学经济学家艾伦·布林德(Alan Blinder)曾发明过一条“经济政策的墨菲定律”:在经济学家理解最透、共识最大的问题上,他们对政策的影响力最小;在经济学家理解最浅、分歧最大的问题上,他们对政策的影响力最大。\n依托市场经济的理论来研究中国经济,有个很大的好处,就是容易发现问题,觉察到各种各样的“扭曲”和“错配”。但从发现问题到提出解决方案之间,还有很长的路要走。不仅要摸清产生问题的历史和现实根源,还要深入了解各种可行方案的得失。现实世界中往往既没有皆大欢喜的改革,也没有一无是处的扭曲。得失利弊,各个不同。以假想的完善市场经济为思考和判断基准,不过是无数可能的基准之一,换一套“假想”和“标准”,思路可能完全不同。正如在本书开篇引用的哈佛大学经济史家格申克龙的话:“一套严格的概念框架无疑有助于厘清问题,但也经常让人错把问题当成答案。社会科学总渴望发现一套‘放之四海而皆准’的方法和规律,但这种心态需要成熟起来。不要低估经济现实的复杂性,也不要高估科学工具的质量。”\n经济落后的国家之所以落后,正是因为它缺乏发达国家的很多硬件或软件资源,缺乏完善的市场机制。所以在推进工业化和现代化的过程中,落后国家所采用的组织和动员资源的方式,注定与发达国家不同。落后国家能否赶超,关键在于能否找到一套适合国情的组织和动员资源的方式,持续不断地推动经济发展。所谓“使市场在资源配置中起决定性作用”,站在今天的角度向前看,是未来改革和发展的方向,但回过头往后看,市场经济今天的发展状况也是几十年来经济、政府、社会协同发展和建设的结果。毫无疑问,我国的经济发展和市场化改革是由政府强力推动的。但就算是最坚定的市场改革派,1980年的时候恐怕也想象不到今天我国市场经济的深度和广度。本书的主题就是介绍我国发展经济的一些具体做法,这显然不是一套照搬照抄欧美国家的模式。利弊得失,相信读者可以判断。\n作为一名发展经济学家,我理解市场和发展的复杂互动过程,不相信单向因果关系。有效的市场机制本身就是不断建设的结果,这一机制是否构成经济发展的前提条件,取决于发展阶段。在经济发展早期,市场机制缺失,政府在推动经济起飞和培育各项市场经济制度方面,发挥了主导作用。但随着经济的发展和市场经济体系的不断完善,政府的角色也需要继续调整。\n强调政府的作用,当然不是鼓吹计划经济。过去苏联式的计划经济有两大特征。第一是只有计划,否认市场和价格机制,也不允许其他非公有制成分存在。第二是封闭,很少参与国际贸易和全球化。如今这两个特点早已不复存在,硬谈中国为计划经济,离题万里。\n本章第一节总结和提炼本书的主题之一,即地方政府间招商引资的竞争。第二节讨论政府能力的建设和角色的转变,总结本书介绍的“生产型政府”的历史作用和局限,也解释向“服务型政府”转型的必要性。第三节总结本书的关键视角:要区分经济发展过程和发展目标。既不要高估发达国家经验的普适性,也不要高估自己过去的成功经验在未来的适用性。老话说回来,还是要坚持“实事求是”,坚持“具体问题具体分析”,在实践中不断探索和解决问题,一步一个脚印,继续推进改革。\n第一节 地区间竞争 # 经济发展的核心原则,就是优化资源配置,提高使用效率,尽量做到“人尽其才,物尽其用”。实现这一目标要依靠竞争。我国改革的起点是计划经济,政府不仅直接掌控大量资源,还能通过政策间接影响资源分配,这种状况在渐进性的市场化改革中会长期存在。所以要想提高整体经济的效率,就要将竞争机制引入政府。理论上有两种做法。第一种是以中央政府为主,按功能划分许多部委,以部委为基本单位在全国范围内调动资源。竞争主要体现在中央设定目标和规划过程中部委之间的博弈。比如在计划经济时期,中央主管工业的就有七八个部委(一机部、二机部等)。这种自上而下的“条条”式竞争模式源自苏联。第二种是以地方政府为主,在设定经济发展目标之后,放权给地方政府,让它们发挥积极性,因地制宜,在实际工作中去竞争资源。这是一种自下而上的“块块”式的竞争模式。 11 即使在计划经济时期,这两种模式也一直并存,中央集权和地方分权之间的平衡一直在变动和调整。毛泽东主席也并不信奉苏联模式,1956年在著名的《论十大关系》中他就说过:“我们的国家这样大,人口这样多,情况这样复杂,有中央和地方两个积极性,比只有一个积极性好得多。我们不能像苏联那样,把什么都集中到中央,把地方卡得死死的,一点机动权也没有。”\n改革开放以后,地方政府权力扩大,“属地管理”和“地方竞争”就构成了政府间竞争的基本模式。第一章到第四章详细介绍了这一模式。这种竞争不仅是资源的竞争,也是地方政策、营商环境、发展模式之间的竞争。“属地管理”有利于地区性的政策实验和创新,因为毕竟是地方性实验,成功了可以总结和推广经验,失败了也可以将代价和风险限制在当地,不至于影响大局。比如1980年设立第一批四个“经济特区”(深圳、珠海、汕头和厦门)时,政治阻力不小,所以才特意强调叫“经济特区”而不是“特区”,以确保只搞经济实验。当时邓小平对习仲勋说:“中央没有钱,可以给些政策,你们自己去搞,杀出一条血路来。” 22 在工业化进程中搞地方竞争,前提是大多数地区的工业基础不能相差太远,否则资源会迅速向占绝对优势的地区集聚,劣势地区很难发展起来。计划经济时期,中国的工业体系在地理分布上比较分散,为改革开放之初各地的工业发展和竞争奠定了基础。而导致这种分散分布的重要原因,是1964年开始的“三线建设”。当时国际局势紧张,为了备战,中央决定改变当时工业过于集中、资源都集中在大城市的局面,要求“一切新的建设项目应摆在三线,并按照分散、靠山、隐蔽的方针布点,不要集中在某几个城市,一线的重要工厂和重点高等院校、科研机构,要有计划地全部或部分搬迁到三线”。 33 在接下来的10年中,我国将所有工业投资中的四成投向了三线地区,即云贵川渝、宁夏、甘肃、陕南、赣西北、鄂西和湖南等地区。到了20世纪70年代末,三线地区的工业固定资产增加了4.3倍,职工人数增加了2.5倍,工业总产值增加了3.9倍。 44 “三线建设”既建设了工厂和研究机构,也建设了基础设施,在中西部省份建立了虽不发达但比较全面的工业生产体系,彻底改变了工业布局。这种分散在各地的工业知识和体系,为改革后当地乡镇企业和私营企业的发展创造了条件。乡镇企业不仅生产满足当地消费需求的轻工业品,而且借助与国企“联营”等各种方式进入了很多生产资料的制造环节,为整个工业体系配套生产,获取了更复杂的生产技术和知识。电视剧《大江大河》中,小雷家村的乡镇企业就通过与附近的国营企业合作,开办了铜厂和电缆厂等,这在当时是普遍现象。90年代中后期乡镇企业改制以后,各地区各行业中都涌现出了一大批民营工业企业,其技术基础很多都源于三线建设时期建设的国营工厂。 55 第四章曾解释过这种分散化的乡镇工业企业的另一个重要功能,即培训农民成为工人。“工业化”最核心的一环就是把农民变成工人。这不仅仅是工作的转变,也是思想观念和生活习惯的彻底转变。要让农民斩断和土地的联系,成为可靠的、守纪律的、能操作机械的工人,并不容易。不是说人多就能成为促进工业化的人口红利,一支合格的产业工人大军,在很多人口众多的落后国家,实际上非常稀缺。 66 正是因为有了在分散的工业体系和知识环境下孕育的乡镇企业,正是因为其工厂“离土不离乡”,才成了培训农民成为工人的绝佳场所。而且在销售本地工业品的过程中,农民不仅积累了商业经验,也扩大了与外界的接触。于是在20世纪90年代后期和21世纪初开始的工业加速发展中,我国才有了既熟悉工厂又愿意外出闯荡打工的大量劳动力。\n这种分散的体系,以一个全国整合的、运行良好的市场经济体系为标准来评价,是低效率的。但从发展的角度看,这个评价标准并不合适。我国疆域广阔、各地风俗文化差异很大。改革开放之初,基础设施不发达,经济落后而分散,只能走各地区独自发展再逐步整合的道路。在社会改革和变化过程中,人们需要时间调整和适应。变化速度的快慢,对身处其中的人而言,感受天差地别。一个稳定和持续的改革过程,必须为缓冲和适应留足时间和资源。若单纯从理论模型出发来认识经济效率,那么这些缓冲机制,无论是社会自发建立还是政府有意设计,都会被解读为“扭曲”或“资源错配”,因其未能实现提高效率所要求的“最优资源配置”。但这种“最优”往往不过是空中楼阁。虽然人人都知道工业比农业生产效率高得多,但要让几亿农民离开土地进入工厂,是个漫长的过程,需要几代人的磨合和冲突。激进改革多半欲速不达,以社会动乱收场。\n地方政府竞争中的关键一环,是“以经济建设为中心”来评价地方主官,并将这种评价纳入升迁考核。各地政府不仅要在市场上竞争,还要在官场上竞争。这种“官场+市场”体制,有三个特点。 77 第一,将官员晋升的政治激励和地区经济表现挂钩。虽然经济建设或GDP目标在官员升迁中的具体机制尚有争议(第三章),但无人否认经济发展是地方主官的工作重点和主要政绩。第二,以市场竞争约束官员行为。虽然地方主官和政府对企业影响极大,但企业的成败,最终还是由其在全国市场乃至全球市场中的竞争表现来决定。这些外部因素超出了当地政府的掌控范围。因此,要想在竞争中取胜,地方政府的决策和资源调配,也要考虑市场竞争,考虑效益和成本。此外,资本、技术、人才等生产要素可以在地区之间流动(虽然仍有障碍),如果地方政府恣意妄为,破坏营商环境,资源就可能流出,导致地方经济衰败。第三,当地的经济表现能为地方官员和政府工作提供及时的反馈。一方面,在“属地管理”体制中,更熟悉地方环境的当地政府在处理当地信息和反馈时,比上级政府或中央政府更有优势(第一章)。另一方面,当地发展经济的经验和教训也会随着地方官员的升迁而产生超越本地的影响。由于常年以经济建设作为政府主要工作目标,各级政府的主官在经济工作方面都积累了相当的经验。中央的主要领导绝大多数也都曾做过多地的主官,也有丰富的经济工作经验。这对一个政府掌控大量资源调配的经济体系而言,不无益处。\n“官场+市场”的竞争体制,可以帮助理解我国经济的整体增长,但这种体制的运行效果,各地差异很大。官员或政府间的竞争,毕竟不是市场竞争,核心差别有三。第一,缺乏真正的淘汰机制。地方政府就算不思进取,也不会像企业一样倒闭。政绩不佳的官员虽然晋升机会可能较少,但只要不违法乱纪,并不会因投资失败或经济低迷而承担个人损失。第二,绝大多数市场竞争是“正和博弈”,有合作共赢、共同做大蛋糕的可能。而官员升迁则是“零和博弈”,晋升位置有限,甲上去了,乙就上不去。所以在地区经济竞争中会产生地方保护主义,甚至出现“以邻为壑”的恶性竞争现象。第三,市场和公司间的竞争一般是长期竞争,延续性很强。但地方官员任期有限,必须在任期内干出政绩,且新官往往不理旧账,因此会刺激大干快上的投资冲动,拉动地区GDP数字快速上涨,不惜忽视长期风险和债务负担。\n这三大差别增加了地区间竞争所产生的代价,也可能滋生腐败(第三章)。此外,政府不是企业,不能以经济效益为单一目标,还要承担多重民生和社会服务职能。在工业化和城市化发展初期,经济增长是地方政府最重要的目标,与企业目标大体一致,可以共同推进经济发展。但在目前的发展阶段,政府需要承担起更加多元的职能,将更多资源投入教育、医疗、社会保障等民生领域,改变与市场和企业的互动方式,由“生产型政府”向“服务型政府”转型。\n第二节 政府的发展与转型 # 社会发展是个整体,不仅包括企业和市场的发展,也包括政府的发展,相辅相成。国家越富裕,政府在国民经济中所占的比重也往往越大,而不是越小,这一现象也被称为“瓦格纳法则”。因为随着国家越来越富裕,民众对政府服务的需求会越来越多,政府在公立教育、医疗、退休金、失业保险等方面的支出都会随之增加。而随着全球化的深入,各种外来冲击也大,所以政府要加强各种“保险”功能。 88 另一方面,当今很多贫穷落后国家的共同点之一就是政府太弱小,可能连社会治安都维持不了,更无法为经济发展创造稳定环境。经济富裕、社会安定、政府得力是国家繁荣的三大支柱,缺一不可。 99 就拿法治能力来说,虽然经济理论和所谓“华盛顿共识”都将产权保护视作发展市场经济的前提,但在现实中,保护产权的能力只能在经济和政府发展的过程中逐步提升。换句话说,对发达国家而言,保护好产权是经济进一步发展的前提;但对发展中国家而言,有效的产权保护更可能是发展的结果。把产权保护写成法律条文很容易,但假如社会上偷盗猖獗,政府抓捕和审判的能力都很弱,法条不过是一纸空文。再比如,处理商业纠纷需要大量专业的律师和法官,需要能追查或冻结财产的专业金融人士和基础设施,否则既难以审判,更难以执行。但这些软件和硬件资源都需要长期的投入和积累。第四章中讲过,复杂的产品和产业链涉及诸多交易主体和复杂商业关系,投资和交易金额往往巨大,所以对合同制订和执行的法制环境以及更广义的营商环境都有很高要求。2000年至2018年,我国出口商品的复杂程度从世界第39位上升到了第18位 1010 ,背后是我国营商环境的逐步改善。正如前述按照世界银行公布的“营商环境便利度”排名,我国已从2010年的世界第89位上升至2020年的第31位。\n对发展中国家而言,市场和政府的关系,不是简单的一进一退的问题,而是政府能否为市场运行打造出一个基本框架和空间的问题。这需要投入很多资源,一步一步建设。如果政府不去做这些事,市场经济和所谓“企业家精神”,不会像变戏法一样自动出现。\n在任何国家,正式法律体系之外还存在大量政府管制。“打官司”毕竟是一件费时费钱的事儿,不仅诉讼成本高昂,败诉方还可以不断上诉,可能旷日持久。不仅如此,修订法律也不是小事,需要很长时间。相比之下,政府的管制和规定有时更加灵活有效,可以作为法制的补充。比如19世纪末的美国,工业化和铁路建设突飞猛进,但也发生了大量工伤事故,死亡率高,官司不断。但败诉公司有权有势,不断上诉,最终约四成的案子干脆没有赔偿。就算有赔付,数额也不大,平均不超过8个月的工资。这种不公正刺激了政府管制的兴起。在事故造成伤害之前,在打官司之前,就可以依据政府管制和规定来进行各种安全检查,防范风险。 1111 有效的政府管制同样需要政府有足够的能力和资源。随着经济和社会的发展,管制和法制之间的相对重要性也会不断发展变化。一方面,全社会投入法治建设的资源不断增加,法治的基础设施不断完善,效率不断提高。另一方面,民众和公司也变得更加富有,可以承担更高的诉讼成本,对法治的有效需求也会增加。因此法制相对于管制会变得更重要。这是经济和政治整体发展的结果,不可能一蹴而就。\n从国防到社会治安,从基础设施到基本社会保障,都要花钱,所以有效的政府必须要有足够的收入。可收税从来都不容易,征税和稽查能力也需要长期建设,不断完善。就拿征收个人所得税来说,政府要有能力追踪每个人的各种收入,能核实可以抵扣的支出,能追查和惩处偷税漏税行为。这需要强大的信息追踪和处理能力。即便在以个人所得税为最主要税种的欧美发达国家,足额收税也是个难题。富人会利用各种手段避税。比如在2016年和2017年,身为富豪并入主白宫的特朗普,连续两年都只缴了750美元的联邦所得税。2018年特朗普税收改革之后,美国最富有的400个亿万富翁实际缴纳的所得税率只有约20%,甚至低于收入排在50%以后的美国人。就拿扎克伯格来说,坐拥脸书公司的两成股份,2018年脸书的利润是200亿美元,那扎氏的收入是否就是40亿美元呢?不是的。因为脸书不分红,只要扎氏不卖股权,他的“收入”几乎是零。公司还将利润大都转到了“避税天堂”开曼群岛,再加上种种财务运作,也避掉了很多公司所得税。 1212 正因为个人所得税不易征收,所以发展中国家的税制大都与发达国家不同。我国第一大税种是增值税,2019年占全国税入的40%;第二大是公司所得税,占24%。相比之下,个人所得税只占不到7%。与个人所得税相比,增值税的征收难度要小很多。一来有发票作为凭证,二来买家和卖家利益不一致,可以互相监督。理论上,卖家希望开票金额少一点甚至不开票,可以少缴税;而买家希望开票金额越大越好,可以多抵税。因此两套票据可以互相比对,降低造假风险。但在现实中,国人对虚开发票和假发票都不陌生。尤其是20世纪末和21世纪初,违规发票泛滥。2001年初,在全部参与稽核的进项发票中,涉嫌违规的发票比例高达8.5%。 1313 随着2003年“金税工程二期”的建设完成,增值税发票的防伪、认证、稽核、协查等系统全面电子化,才逐渐消除了假发票问题,之后的增值税收入大幅增长。 1414 目前,“金税工程三期”也已完成。2020年在手机上用“个人所得税App”进行过“综合所得年度汇算清缴”的读者,应该记得其中信息的详细和准确程度,也就不难理解这种“征税能力”需要长期建设。\n从以上例子可以看出,无论是政府服务的质量,还是政府收入的数量,都在不断发展和变化。“有为政府”和“有效市场”一样,都不是天然就存在的,需要不断建设和完善。市场经济的形式和表现,要受到政府资源和能力的制约,而政府的作用和角色,也需要不断变化,以适应不同发展阶段的不同要求。\n在经济发展早期,市场不完善甚至缺失,政府能力于是成了市场能力的补充或替代。经济落后的国家之所以落后,正是因为它缺乏先进国家完善的市场和高效的资源配置方式。这些本就是经济发展所需要达到的目标,而很难说是经济发展的前提。对落后国家而言,经济发展的关键在于能否在市场机制不完善的情况下,找到其他可行的动员和调配资源的方式,推动经济增长,在增长过程中获得更多资源和时间去建设和完善市场经济。比如说,发达国家有完善的资本市场和法律体系,可以把民间积累的大量财富引导到相对可靠的企业家手中,创造出更多财富。而在改革开放之初,我国资本市场和法律体系远远谈不上健全,民间财富也极为有限,社会风气也不信任甚至鄙视民营企业和个体户。这些条件都限制了当时推动经济发展的可行方式。\n因此落后国家在推进工业化和现代化的过程中,所采用的组织和动员资源的方式,必定与先进国家不同。所谓“举国体制”也好,“集中力量办大事”也罢,在很多方面并不是中国特色。今日的很多发达国家在历史上也曾是落后国家,大多也经历过政府主导资源调配的阶段。但各国由于历史、社会、政治情况不同,政府调配资源的方式、与市场互动和协调的方式也都不同。本书阐述的“地方分权竞争+中央协调”或“官场+市场”的模式,属于中国特色。\n当然,并不是所有的政府干预都能成功。以工业化进程中对“幼稚产业”的贸易保护为例。有的国家比如韩国,在抬高关税、保护本国工业企业的同时,积极提倡出口,以国际市场竞争来约束本国企业,迫使其提高效率,并且随着工业发展逐步降低乃至取消保护,最终培育出一批世界级的企业。但也有很多国家,比如拉美和东南亚的一些国家,对“幼稚产业”的保护难以“断奶”,形成了寻租的利益集团和低效的垄断,拖累了经济发展。在更加复杂的大国比如中国,两种状况都存在。既有在国际竞争中脱颖而出的杰出企业,也有各种骗补和寻租的低效企业。这种结果上的差异,源于各国和各地政商关系的差异。所谓强力政府,不仅在于它有能力和资源支持企业发展,也在于它有能力拒绝对企业提供帮助。 1515 经济发展,需要不断动员土地、劳动、资本等资源并将其投入生产,满足社会需要。计划经济体制下可以动员资源,但难以满足社会需要,无法形成供需良性互动的循环,生产率水平也很低。因此我国的市场化改革始于满足社会需要。1981年党的十一届六中全会提出“我国所要解决的主要矛盾”,就是“人民日益增长的物质文化需要同落后的社会生产之间的矛盾”。在改革过程中,由于各种市场都不完善,法制也不健全,私人部门很难克服各种协调困难和不确定性,政府和国企于是主导投资,深度介入了工业化和城市化的进程。这一模式的成就有目共睹,也推动了市场机制的建立和完善。\n但这种模式不能一成不变,过去的成功经验不见得能适应当下和未来的需要。所谓“政府能力”,不仅包括获取资源的能力,也包括政府随着经济发展而不断调整自身角色和作用方式的能力。当经济发展到一定阶段后,市场机制已经相对成熟,法治的基础设施也已经建立,民间的各种市场主体已经积累了大量资源,市场经济的观念也已经深入人心,此时若仍将资源继续向政府和国企集中,效率就会大打折扣。投资、融资、生产都需要更加分散化的决策。市场化改革要想更进一步,“生产型政府”就需要逐步向“服务型政府”转型。\n第七章讲过,要调整经济结构失衡,关键是将更多资源从政府和国企转到居民手中,在降低政府投资支出的同时加大其民生支出。经济发达国家,政府支出占GDP的比重往往也高,其中大部分是保障民生的支出。就拿经济合作与发展组织国家来说,在教育、医疗、社会保障、养老方面的政府平均支出占到GDP的24%,而我国只有13%。 1616 一方面,随着国家变富裕,民众对这类服务的需求会增加;另一方面,市场经济内在的不稳定和波动会产生失业和贫富差距等问题,需要政府和社会的力量去做缓冲。就拿贫富差距扩大来说,政府的再分配政策不仅包括对富人多征税,还包括为穷人多花钱,把支出真正花在民生上。\n城市化是一个不可逆的过程,目前的土地和户籍改革都承认了这种不可逆性。在发展过程中遭遇冲击,回到乡村可能是权宜之计,但不是真正有效的长期缓冲机制。还是要在城市中建立缓冲机制,加大教育、医疗、住房等支出,让人在城市中安居乐业。\n加大民生支出,也是顺应经济发展阶段的要求。随着工业升级和技术进步,工业会越来越多地使用机器,创造就业的能力会减弱,这个过程很难逆转。所以大多数就业都要依靠服务业的发展,而后者离不开城市化和人口密度。 1717 如果服务业占比越来越高,“生产投资型政府”就要向“服务型政府”转型,原因有二。其一,与重规模、标准化的工业生产相比,服务业规模通常较小,且更加灵活多变,要满足各种非标准化、本地化的需求。在这种行业中,政府“集中力量办大事”的投资和决策机制,没有多大优势。其二,“投资型”和“服务型”的区别并非泾渭分明。“服务型”政府实质上就是投资于“人”的政府。服务业(包括科技创新)的核心是人力资本,政府加大教育、医疗等民生支出,也就是在加大“人力资本”投资。但因为服务业更加灵活和市场化,政府在这个领域的投入是间接的、辅助性的,要投资和培育更一般化的人力资本,而非直接主导具体的项目。\n扩大民生支出的瓶颈是地方政府的收入。第一章分析了事权划分的逻辑,这些逻辑决定了民生支出的主力必然是地方政府而不是中央政府。2019年,政府在教育、医疗、社会保障的总支出中,地方占96%,中央只占4%。中央通过转移支付机制,有效地推动了地区间基本公共服务支出的均等化(第二章),但这并没有改变地方民生支出主要依靠地方政府的事实。在分税制改革、公司所得税改革、营改增改革之后(第二章),中国目前缺乏属于地方的主体税种。以往依托税收之外的“土地财政”和“土地金融”模式已经无法再持续下去,因此要想扩大民生支出,可能需要改革税制,将税入向地方倾斜。目前讨论的热点方向是开征房产税。虽然这肯定是个地方税种,但改革牵一发动全身,已经热议了多年,也做了试点,但仍未实质推进。\n第三节 发展目标与发展过程 # 主流的新古典经济学是一套研究市场和价格机制运行的理论。在很多核心议题上,这套理论并不考虑“国别”,抽象掉了政治、社会、历史等重要因素。但对于发展中国家而言,核心议题并不是良好的市场机制如何运行,而是如何逐步建立和完善市场经济体制。因此,发展中国家所采用的资源动员和配置方式,肯定与发达国家不同。诸多发展中国家所采用的具体方式和路径,当然也各不相同。\n经济发展的核心是提高生产率。对处于技术前沿的发达国家来说,提高生产率的关键是不断探索和创新。其相对完善的市场经济是一套分散化的决策体系,其中的竞争和价格机制有利于不断“试错”和筛选胜者。但对发展中国家来说,提高生产率的关键不是探索未知和创新,而是学习已知的技术和管理模式,将更多资源尽快组织和投入到学习过程中,以提高学习效率。这种“组织学习模式”与“探索创新模式”所需要的资源配置方式,并不一样。我国的经济学者早在20年前就已经讨论过这两种模式的不同。问题的核心在于:后进国家虽然有模仿和学习先进国家技术的“后发优势”,但其“组织学习模式”不可能一直持续下去。当技术和生产率提高到一定水平之后,旧有的模式若不能成功转型为“探索创新模式”,就可能会阻碍经济进一步发展,“后发优势”可能变成“后发劣势”。 1818 本书一直强调发展过程与发展目标不同。照搬发达国家的经验,解决不了我们发展中所面临的很多问题。但我们自己走过的路和过去的成功经验,也不一定就适用于未来,所以本书不仅介绍了过往模式的成就,也花了大量篇幅来介绍隐忧和改革。我个人相信,如果“组织学习模式”不止一种,“探索创新模式”自然也不止一种,欧美模式不一定就是最优的模式。\n不仅发展中国家和发达国家不同,发展中国家各自的发展模式也不同。 1919 从宏观角度看,很多成功的发展中国家有诸多相似之处,比如资本积累的方式、出口导向的发展战略、产业政策和汇率操控、金融抑制等。但在不同国家,贯彻和执行这些战略或政策的具体方式并不相同。行之有效的发展战略和政策,必须符合本国国情,受本国特殊历史和社会条件的制约。哪个国家也不是一张白纸,可以随便画美丽的图画。什么可以做,什么不可以做,每个国家不一样。本书阐述的我国政治经济体制,有三大必要组件:掌握大量资源并可以自主行动的地方政府,协调和控制能力强的中央政府,以及人力资本雄厚和组织完善的官僚体系。这三大“制度禀赋”源自我国特殊的历史,不是每个国家都有的。\n不仅国与国之间国情和发展路径有别,在中国这样一个大国内部,各个省的发展方式和路径也不尽相同。第一章开篇就提到,若单独计算经济体量,广东、浙江、江苏、山东、河南都是世界前20的经济体,都相当于一个中等欧洲国家的规模。如果这些欧洲国家的经济发展故事可以写很多本书和论文,我国各省独特的发展路径当然也值得单独研究和记录。 2020 可惜目前的经济学术潮流是追求“放之四海而皆准”的理论,国别和案例研究式微,被称为“轶事证据”(anecdotal evidence),听起来就很不“科学”,低人一等。我对这种风气不以为然。虽然我从抽象和一般化的发展经济学理论中学到了很多,但对具体的做法和模式更感兴趣,所以本书介绍了很多具体案例和政策。\n各国的政治和社会现实,决定了可行的经济发展政策的边界。就拿工业化和城市化来说,无疑是经济发展的关键。从表面看,这是个工业生产技术和基础设施建设的问题,各国看起来都差不多。但看深一层,这是个农民转变为工人和市民的问题,这个演变过程,各国差别就大了。在我国,可行的政策空间和演变路径受三大制度约束:农村集体所有制、城市土地公有制、户籍制度。所以中国的工业化才离不开乡镇企业的发展,城市化才离不开“土地财政”和“土地金融”。这些特殊的路径,我认为才是研究经济发展历程中最有意思的东西。\n可行的政策不仅受既有制度的约束,也受既有利益的约束。政策方案的设计,必须考虑到利益相关人和权力持有者的利益。既要提高经济效率,也要保证做决策的人或权力主体的利益不受巨大损害,否则政策就难以推行。 2121 可行的经济政策是各种利益妥协的结果,背后是各国特殊的政治体制和议程。在这个过程中,不仅激励相容的机制重要,文化的制约也重要。比如政治经济学中有个重要概念叫“精英俘获”(elite capture),一个例子就是地方政治精英被地方利益集团俘获,损害民众利益。在我国历史上,这一“山高皇帝远”的问题就长期存在,应对之道不仅有各类制度建设,也从来没离开过对官僚群体统一的意识形态和道德教化(第一章)。\n另一个例子是自由贸易和保护主义的冲突。支持自由贸易的概念和理论,几乎算是经济学中最强有力的逻辑,但往往也突破不了现实利益的枷锁。只要学过经济学,都知道比较优势和自由贸易能让国家整体得益。但整体得益不等于让每个人都得益。从理论上讲,即便有人受损,也该支持自由贸易,因为整体得益远大于部分损失,只要从受益方那里拿一点利益出来,就足够补偿受损方且有余。但在现实中,补偿多少?怎么补偿?往往涉及复杂的政治博弈。补偿可能迟迟落实不到位,最终是受益者得益越来越多,而受损者却屡遭打击。虽说平均值是变好了,但那些受损的人的生活不是理论上的平均数字,他们会为了自己的利益而反抗和行动,这是保护主义的根源。 2222 最后,与主要研究成熟市场的新古典经济学相比,研究发展过程的经济学还包括两大特殊议题,一是发展顺序,二是发展节奏。在现实中,这两个问题常常重合。但对研究者而言,第一个问题的重点是“结构”,第二个问题的重点是“稳定”或“渐进性”。\n改革方向和改革过程是两回事。就算每个人都对改革方向和目的有共识(事实上不可能),但对改革路径和步骤也会有分歧。什么事先办,什么事后办,不容易决定。每一步都有人受益、有人受损,拼命争取和拼命抵制的都大有人在。就算能看清对岸的风景,也不见得就能摸着石头成功过河,绊脚石或深坑比比皆是。20世纪中叶,“二战”刚刚结束,出现了大批新兴国家,推动了发展经济学的兴起。当时研究的重点就是发展顺序或结构转型问题。后来这一研究范式逐渐式微。最近10年,北京大学林毅夫教授领衔的研究中心开始重新重视结构转型问题,其理论称为“新结构经济学”,依托“比较优势”的基本逻辑来解释发展次序和结构转型,也称为“第三代发展经济学”。这一思路目前尚有很多争议,但无疑是非常重要的探索方向。 2323 经济发展必然要改变旧有的生活方式,重新分配利益,所以必然伴随着矛盾和冲突。政府的关键作用之一,就是调控改变速度的快慢。社会变化过程快慢之间,对身处其中的人而言,感受天差地别。对于环境的变化,人们需要时间去适应。人不是机器部件,不可能瞬间调整,也没有人能一直紧跟时代,所以稳定的改革过程要留下足够的时间和资源去缓冲。这种“渐进性改革”中的各种缓冲机制,往往会拖低效率,所以常常被解读为“扭曲”和“资源错配”。但任何成功的转型过程都离不开缓冲机制。\n经济发展是个连续的过程。当下最重要的问题不是我国的GDP总量哪年能超过美国,而是探讨我国是否具备了下一步发展的基础和条件:产业升级和科技进步还能继续齐头并进吗?还有几亿的农民能继续城市(镇)化吗?贫富差距能控制在社会可承受的范围内吗?在现有的基础上,下一步改革的重点和具体政策是什么?因此本书在每个重要议题之后,都尽量介绍了当下正在实施的政策和改革,以便读者了解政策制定者对现实的把握和施政思路。有经济史学家在研究美国崛起的过程时曾言:“在成功的经济体中,经济政策一定是务实的,不是意识形态化的。是具体的,不是抽象的。” 2424 结语 # 经济学是对经济现象的解读。现象复杂多变,偶然因素非常重要,过往并非必然,未来也不能确定。但经济学研究依然是有意义的。它能从过往事件的来龙去脉中提取一些因素,思考这些因素的不同组合,形成对事件的多种解读,给人启发。但什么是相关因素?怎么组合?又如何解读?这些都与所研究事件的所在环境密不可分。任何合格的理论当然都能自圆其说,但应用理论要跳出理论本身,才能审视其适用性和实用性,这种应用因时、因地、因人而异。\n对相关因素的提取和组合,本质上是对“何谓重要”这一问题的反复考量,其判断标准只能在比较中产生。这一“比较”的视野,要在空间和时间两个维度展开,既包括跨地区、跨国家的比较,也包括跨时期的比较。研究者不仅要深入了解本国现状和历史,也要了解所比较国家的现状和历史。比较数据和表面现象容易,但要比较数据产生的过程和现象发生的机制就难了,而这些往往更加有用。发展经济学的核心就是理解发展过程,因此必须理解初始条件和路径依赖,对“历史”的延续性和强大力量心存敬畏,对简单套用外来理论心存疑虑。\n无论如何,经济学的主要作用仍是发现和提出问题,而解决问题的具体方案只能在实践中摸索和产生。学术的这一“提问”作用不应被夸大,也不应被贬低。世事复杂,逻辑和理论之外的不可控因素太多,所以具体问题的解决方案,只能在实践中不断权衡、取舍、调整、改进。但发现和提出好的问题,是解决问题的第一步,且“提问”本身,往往已蕴含了对解决思路的探索。切中要害的问题,必然基于对现实情况的深刻理解。因此,无论是理论家还是实践者,“实事求是”和“具体问题具体分析”都是不会过时的精神。\n扩展阅读 # 培养“比较”视野需要大量阅读,这也是本书设立“扩展阅读”部分的初衷。我个人偏爱经济史,所以把最后这部分留给经济史。这个领域的大作很多,以下三本入门读物的共同点是简明通俗,篇幅虽不长,但介绍了很多重要现象,提出了不少重要问题:英国史学家艾伦的《全球经济史》(2015),乔治梅森大学戈德斯通的《为什么是欧洲?世界史视角下的西方崛起》(2010),哈佛大学弗里登的《20世纪全球资本主义的兴衰》(2017)。希望这些书能激发读者兴趣,之后去做深入了解。我个人也经常翻阅卡尔·波兰尼、亚历山大·格申克龙、艾瑞克·霍布斯鲍姆、乔尔·莫基尔等人的杰作,大都有中译本。都是些老书,常读常新。熟悉这些著作的读者应该能在本书的很多地方看到《经济落后的历史透视》(格申克龙,2012)和《大转型:我们时代的政治与经济起源》(波兰尼,2020)的影子。\n国内的经济学学生很了解美国的经济学理论,但不太了解美国经济发展的历史过程。我推荐两种读物。第一本是西北大学戈登的《美国增长的起落》(2018)。经济发展和科技进步会给生活带来翻天覆地的变化,本书从很长的时间线上对此做了生动细致的描述和分析,是本大部头,细节丰富,读者的印象和感受会很深。另一本是伯克利加州大学科恩(Cohen)和德隆(DeLong)合著的Concrete Economics:the Hamilton Approach to Economic Growth and Policy(2016),这本书着重强调政府在美国经济发展中的作用。该实行产业政策就实行产业政策、该保护贸易就保护贸易、该操控汇率就操控汇率,坚持务实精神,具体问题具体分析,才有美国的今天。借回顾历史之机,作者们批评了20世纪80年代之后席卷美国和全球的自由市场思潮。\n在写作本章的过程中,在东亚研究领域负有盛名的哈佛大学教授傅高义辞世。他的杰作《邓小平时代》清晰易懂,细致流畅,影响很大。改革开放是个伟大的时代,这本书记录了这个伟大开端,放在这里推荐,再合适不过。\n11 第一种竞争模式被称为“U型”(unitary),第二种被称为“M型”(multidivision),都是公司治理中常用的结构模式。“U型”公司按功能划分部门,比如生产、销售、采购等。而“M型”公司则分成几个子品牌或事业部,各成系统,彼此独立性很强。哈佛大学诺贝尔奖得主马斯金(Maskin)、清华大学钱颖一、香港大学许成钢的论文(Maskin, Qian and Xu, 2000)将这种公司治理结构的理论用于研究我国中央和地方政府关系。\n22 经济特区的故事详见傅高义的杰作(2013)。香港大学许成钢的论文(Xu, 2011)解释了地区竞争有利于地方性的政策创新和实验。\n33 1964年8月19日,李富春、罗瑞卿、薄一波向毛泽东、党中央提交的报告。\n44 见薄一波的著作(2008)以及华中师范大学严鹏的著作(2018)。\n55 宾夕法尼亚州立大学樊静霆和密歇根州立大学邹奔的论文(Fan and Zou, 2019)分析了“三线建设”对当地工业企业尤其是民营企业长期发展的积极影响。\n66 哈佛大学历史学家格申克龙在著作(2012)中指出,很多落后国家虽人口众多,却极度缺乏合格的产业工人,“创造一支名副其实的产业工人大军,是最困难和耗时的过程”。\n77 北京大学周黎安的论文(2018)详细阐述了“官场+市场”机制及其优缺点。下文内容取材于该文。\n88 哈佛大学罗德里克(Rodrik)的论文(1998)探讨了全球化与“大政府”之间的正向关系。\n99 伦敦政治经济学院贝斯利(Besley)和斯德哥尔摩大学佩尔松(Persson)的著作(2011)详细阐述了这三大支柱的理论联系,下文中关于税收能力和法制能力的内容受该书启发。\n1010 产品复杂度的度量来自哈佛大学国际发展中心的“The Atlas of Economic Complexity”项目。\n1111 关于美国政府管制的兴起和现状,以及与法制之间关系的研究,参见哈佛大学格莱泽(Glaser)和施莱弗(Shleifer)的论文(2003),以及芝加哥大学莫里根(Mulligan)和哈佛大学施莱弗(Shleifer)的论文(2005)。\n1212 美国富人税率数据和扎克伯格的例子,来自伯克利加州大学塞兹(Saez)和祖克曼(Zucman)的著作(2019)。\n1313 数据来自2002年国家税务总局局长金人庆在全国税务系统信息化建设工作会议上的讲话《统一思想 做好准备 大力推进税收信息化建设》。\n1414 关于“金税工程二期”对增值税收入影响的估计,来自复旦大学樊海潮、刘宇及美国西北大学钱楠筠等人的论文(Fan et al., 2020)。\n1515 伯克利加州大学巴尔丹(Bardhan)的论文(2016)总结和讨论了各国保护政策和产业政策的得失成败。他特别强调了“幼稚产业”保护承诺的“时间不一致”问题,也就是起初设计好了将来要“断奶”的保护,最终却迟迟无法“断奶”的问题。\n1616 数据来自IMF的张龙梅等人的论文(Zhang et al., 2018)。\n1717 服务业发展离不开人口密度,主要原因在于大多数服务(比如餐馆或理发店)都不能跨地区贸易,需要面对面交易。上海交通大学钟粤俊和陆铭以及复旦大学奚锡灿的论文(2020)分析了我国各地区人口密度和服务业发展之间的正相关关系。\n1818 关于“后发优势”和“后发劣势”的讨论,详见哥伦比亚萨克斯(Sachs)、戴维斯加州大学胡永泰、莫纳什大学杨小凯的研究以及林毅夫的论文(2003)。诺贝尔奖得主斯蒂格利茨和哥伦比亚大学格林沃尔德的著作(2017)系统地阐释了学习和经济发展的关系,在这个框架下讨论了一系列主流经济学中视为“扭曲”的政策的积极意义,包括产业政策和贸易保护等,是一部杰作。\n1919 哈佛大学罗德里克的著作(2009)系统地阐述了这一点。\n2020 其实何止是省,我国很多市的发展故事和模式也各具特色。这方面深入的研究并不多,感兴趣的读者可以参考如下著作,很有意思。复旦大学章奇和北京大学刘明兴关于浙江模式的著作(2016);复旦大学张军主编的关于深圳模式的论文集(2019)。再早一点,还有国家发展改革委张燕生团队关于佛山模式的研究报告(2001),浙江大学史晋川团队关于温州模式的研究报告(2002)。\n2121 清华大学钱颖一的论文集(Qian, 2017)详细阐述了这一点。\n2222 哈佛大学罗德里克的著作(2018)阐述了贸易理论和现实利益之间的冲突。\n2323 关于这一学说的基本框架,参见林毅夫的著作(2014),其中也包括了很多学者对这一理论的讨论以及林教授的回应。\n2424 参见伯克利加州大学科恩(Cohen)和德隆(DeLong)的著作(2016)。\n结束语 # 写书是需要幻觉的,我必须坚信这本书很重要,很有意义,我才能坚持写完它。但写完了,也就不再需要这种幻觉支撑了。中国经济这台热闹炫目的大戏,说不尽,这本书只是我的一点模糊认识,一鳞半爪都谈不上,盲人摸象更贴切些。凯恩斯在《论概率》中说过一段话,概括了我在写作本书过程中的心理状态:\n写这样一本书,若想说清观点,作者有时必须装得成竹在胸一点。想让自己的论述站得稳,便不能甫一下笔就顾虑重重。论述这些问题实非易事,我有时轻描淡写,斩钉截铁,但其实心中始终有所疑虑,也许读者能够体谅。\n过去40年,我国的名义GDP增长了242倍,大家从每个月挣二三十元变成了挣四五千元,动作稍微慢一点,就被时代甩在了后面。身在其中的风风火火、慌慌张张、大起大落、大喜大悲,其他国家的人无论有多少知识和理论,都没有切身感受。\n我出生于1980年,长在内蒙古的边陲小镇,在北京、大连、上海、深圳、武汉都长期待过,除了在美国读书和生活的六七年,没离开过这片滚滚红尘。虽然见过的问题和麻烦可以再写几本书,但经历和见闻让我对中国悲观不起来。我可以用很多理论来分析和阐述这种乐观,但从根本上讲,我的乐观并不需要这些头头是道的逻辑支撑,它就是一种朴素的信念:相信中国会更好。这种信念不是源于学术训练,而是源于司马迁、杜甫、苏轼,源于“一条大河波浪宽”,源于对中国人勤奋实干的钦佩。它影响了我看待问题的角度和处理信息的方式,我接受这种局限性,没有改变的打算。\n没人知道未来会怎样。哪怕只是五六十年,也是一个远超认知的时间跨度,信念因此重要。1912年,溥仪退位,旧制度天崩地裂,新时代风起云涌,直到改革开放,仿佛已经历了几个世纪,但实际不过66年。\n所以这本书没什么宏大的构思和框架,也没有预测,就是介绍些当下的情况,如果能帮助读者理解身边的一些事情,从热闹的政经新闻中看出些门道,从严肃的政府文件中觉察出些机会,争取改善一下生活,哪怕只是增加些谈资,也足够了。我是个经济学家,基于专业训练的朴素信念也有一个:生活过得好一点,比大多数宏伟更宏伟。\n参考文献 # 艾肯格林,巴里(2019),《嚣张的特权:美元的国际化之路及对中国的启示》,陈召强译,中信出版社。\n艾伦,罗伯特(2015),《全球经济史》,陆赟译,译林出版社。\n埃森格林,巴里(2020),《资本全球化:一部国际货币体系史(原书第3版)》,麻勇爱译,机械工业出版社。\n白重恩、钱震杰(2009),《国民收入的要素分配:统计数据背后的故事》,载《经济研究》第3期。\n鲍德温,理查德(2020),《大合流:信息技术和新全球化》,李志远、刘晓捷、罗长远译,格致出版社。\n保尔森,亨利(2016),《与中国打交道:亲历一个新经济大国的崛起》,王宇光等译,香港中文大学出版社。\n贝克特,斯文(2019),《棉花帝国:一部资本主义全球史》,徐轶杰、杨燕译,民主与建设出版社。\n编委会(2013),《国家开发银行史:1994—2012》,中国金融出版社。\n波兰尼,卡尔(2020),《大转型:我们时代的政治与经济起源》,冯钢、刘阳译,当代世界出版社。\n薄一波(2008),《若干重大决策与事件的回顾》,中共党史出版社。\n蔡昉、李周、林毅夫(2014),《中国的奇迹:发展战略与经济改革(增订版)》,格致出版社。\n陈斌开、李银银(2020),《再分配政策对农村收入分配的影响——基于税费体制改革的经验研究》,载《中国社会科学》第2期。\n陈斌开、陆铭、钟宁桦(2010),《户籍制约下的居民消费》,载《经济研究》增刊。\n陈斌开、杨汝岱(2013),《土地供给、住房价格与中国城镇居民储蓄》,载《经济研究》第1期。\n陈登科、陈诗一(2018),《资本劳动相对价格、替代弹性与劳动收入份额》,载《世界经济》第12期。\n陈硕、朱琳(2020),《市场转型与腐败治理:基于官员个体证据》,复旦大学经济学院工作论文。\n陈晓红、朱蕾、汪阳洁(2019),《驻地效应——来自国家土地督察的经验证据》,载《经济学(季刊)》第1期。\n达利欧,瑞(2019),《债务危机:我的应对原则》,赵灿等译,中信出版社。\n党均章、王庆华(2010),《地方政府融资平台贷款风险分析与思考》,载《银行家》第4期。\n德·索托,赫尔南多(2007),《资本的秘密》,于海生译,华夏出版社。\n范子英、李欣(2014),《部长的政治关联效应与财政转移支付分配》,载《经济研究》第6期。\n方红生、张军(2013),《攫取之手、援助之手与中国税收超GDP增长》,载《经济研究》第3期。\n冯军旗(2010),《中县干部》,北京大学博士学位论文。\n傅高义(2013),《邓小平时代》,冯克利译,生活·读书·新知三联书店。\n弗里登,杰弗里(2017),《20世纪全球资本主义的兴衰》,杨宇光译,上海人民出版社。\n福山,弗朗西斯(2014),《政治秩序的起源:从前人类时代到法国大革命》,毛俊杰译,广西师范大学出版社。\n傅勇、张晏(2007),《中国式分权与财政支出结构偏向:为增长而竞争的代价》,载《管理世界》第3期。\n甘犁、赵乃宝、孙永智(2018),《收入不平等、流动性约束与中国家庭储蓄率》,载《经济研究》第12期。\n高翔、龙小宁(2016),《省级行政区划造成的文化分割会影响区域经济么?》,载《经济学(季刊)》第2期。\n戈德斯通,杰克(2010),《为什么是欧洲?世界史视角下的西方崛起》,关永强译,浙江大学出版社。\n戈登,罗伯特(2018),《美国增长的起落》,张林山等译,中信出版集团。\n戈顿,加里(2011),《银行的秘密:现代金融生存启示录》,陈曦译,中信出版社。\n葛剑雄(2013),《统一与分裂:中国历史的启示》,商务印书馆。\n格申克龙,亚历山大(2012),《经济落后的历史透视》,张凤林译,商务印书馆。\n弓永峰、林劼(2020),《“逆全球化”难撼中国光伏产业链优势地位》,中信证券研报。\n辜朝明(2016),《大衰退:宏观经济学的圣杯》,喻海翔译,东方出版社。\n韩立彬、陆铭(2018),《供需错配:解开中国房价分化之谜》,载《世界经济》第10期。\n韩茂莉(2015),《中国历史地理十五讲》,北京大学出版社。\n洪正、张硕楠、张琳(2017),《经济结构、财政禀赋与地方政府控股城商行模式选择》,载《金融研究》第10期。\n华生(2014),《城市化转型与土地陷阱》,东方出版社。\n黄奇帆(2020),《分析与思考:黄奇帆的复旦经济课》,上海人民出版社。\n姜超、朱征星、杜佳(2018),《地方政府隐性债务规模有多大?》,海通证券研报。\n金,默文(2016),《金融炼金术的终结:货币、银行与全球经济的未来》,束宇译,中信出版社。\n金观涛、刘青峰(2010),《兴盛与危机:论中国社会超稳定结构》,法律出版社。\n景跃进、陈明明、肖滨(2016),《当代中国政府与政治》,中国人民大学出版社。\n克鲁格曼,保罗(2002),《地理和贸易》,张兆杰译,北京大学出版社。\n孔飞力(2014),《叫魂:1768年中国妖术大恐慌》,陈兼、刘昶译,生活·读书·新知三联书店。\n拉詹,拉古拉迈(2015),《断层线:全球经济潜在的危机》,李念等译,中信出版社。\n莱因哈特,卡门、肯尼斯·罗格夫(2012),《这次不一样:八百年金融危机史》,綦相译,机械工业出版社。\n李侃如(2010),《治理中国:从革命到改革》,胡国成、赵梅译,中国社会科学出版社。\n李萍(主编)(2010),《财政体制简明图解》,中国财政经济出版社。\n李实、岳希明(2015),《〈21世纪资本论〉到底发现了什么》,中国财政经济出版社。\n李实、朱梦冰(2018),《中国经济转型40年中居民收入差距的变动》,载《管理世界》第12期。\n李学文、卢新海、张蔚文(2012),《地方政府与预算外收入:中国经济增长模式问题》,载《世界经济》第8期。\n林毅夫(2003),《后发优势与后发劣势——与杨小凯教授商榷》,载《经济学(季刊)》第4期。\n林毅夫(2014),《新结构经济学:反思经济发展与政策的理论框架(增订版)》,北京大学出版社。\n林毅夫、巫和懋、邢亦青(2010),《“潮涌现象”与产能过剩的形成机制》,载《经济研究》第10期。\n刘克崮、贾康主编(2008),《中国财税改革三十年:亲历与回顾》,经济科学出版社。\n刘守英(2018),《土地制度与中国发展》,中国人民大学出版社。\n刘守英,杨继东(2019),《中国产业升级的演进与政策选择——基于产品空间的视角》,载《管理世界》第6期。\n楼继伟(2013),《中国政府间财政关系再思考》,中国财政经济出版社。\n楼继伟(2018),《事权与支出责任划分改革的有关问题》,载《比较》第4期。\n楼继伟、刘尚希(2019),《新中国财税发展70年》,人民出版社。\n路风(2016),《光变:一个企业及其工业史》,当代中国出版社。\n路风(2019),《走向自主创新:寻找中国力量的源泉》,中国人民大学出版社。\n路风(2020),《新火:走向自主创新2》,中国人民大学出版社。\n陆铭(2016),《大国大城:当代中国的统一、发展与平衡》,上海人民出版社。\n罗长远、张军(2009),《经济发展中的劳动收入占比:基于中国产业数据的实证研究》,载《中国社会科学》第4期。\n罗德里克,丹尼(2009),《相同的经济学,不同的政策处方:全球化、制度建设和经济增长》,张军扩、侯永志等译,中信出版社。\n罗德里克,丹尼(2011),《全球化的悖论》,廖丽华译,中国人民大学出版社。\n罗德里克,丹尼(2018),《贸易的真相:如何构建理性的世界经济》,卓贤译,中信出版社。\n马光荣、张凯强、吕冰洋(2019),《分税与地方财政支出结构》,载《金融研究》第8期。\n麦克劳,托马斯(1999),《现代资本主义:三次工业革命中的成功者》,赵文书、肖锁章译,江苏人民出版社。\n迈恩,阿蒂夫、阿米尔·苏非(2015),《房债:为什么会出现大衰退,如何避免重蹈覆辙》,何志强、邢增艺译,中信出版社。\n米兰诺维奇,布兰科(2019),《全球不平等》,熊金武、刘宣佑译,中信出版社。\n缪小林、王婷、高跃光(2017),《转移支付对城乡公共服务差距的影响——不同经济赶超省份的分组比较》,载《经济研究》第2期。\n诺顿,巴里(2020),《中国经济:适应与增长(第二版)》,安佳译,上海人民出版社。\n潘功胜(2012),《大行蝶变:中国大型商业银行复兴之路》,中国金融出版社。\n佩蒂斯,迈克尔(2014),《大失衡:贸易、冲突和世界经济的危险前路》,王璟译,译林出版社。\n皮凯蒂,托马斯(2014),《21世纪资本论》,巴曙松译,中信出版社。\n任泽平、夏磊、熊柴(2017),《房地产周期》,人民出版社。\n沙伊德尔,沃尔特(2019),《不平等社会:从石器时代到21世纪,人类如何应对不平等》,颜鹏飞等译,中信出版社。\n邵朝对、苏丹妮、包群(2018),《中国式分权下撤县设区的增长绩效评估》,载《世界经济》第10期。\n邵挺、田莉、陶然(2018),《中国城市二元土地制度与房地产调控长效机制:理论分析框架、政策效应评估与未来改革路径》,载《比较》第6期。\n沈联涛(2015),《十年轮回:从亚洲到全球的金融危机(第三版)》,杨宇光、刘敬国译,上海远东出版社。\n史晋川(等)(2002),《制度变迁与经济发展:温州模式研究》,浙江大学出版社。\n史塔威尔,乔(2014),《亚洲大趋势》,蒋宗强译,中信出版社。\n斯蒂格利茨,约瑟夫(2013),《不平等的代价》,张子源译,机械工业出版社。\n斯蒂格利茨,约瑟夫,布鲁斯·格林沃尔德(2017),《增长的方法:学习型社会与经济增长的新引擎》,陈宇欣译,中信出版社。\n谭之博、周黎安、赵岳(2015),《省管县改革、财政分权与民生——基于“倍差法”的估计》,载《经济学(季刊)》第3期。\n唐为(2019),《分权、外部性与边界效应》,载《经济研究》第3期。\n唐为、王媛(2015),《行政区划调整与人口城市化:来自撤县设区的经验证据》,载《经济研究》第9期。\n特纳,阿代尔(2016),《债务和魔鬼:货币、信贷和全球金融体系重建》,王胜邦、徐惊蛰、朱元倩译,中信出版社。\n田毅、赵旭(2008),《他乡之税:一个乡镇的三十年,一个国家的“隐秘”财政史》,中信出版社。\n万晓莉、严予若、方芳(2017),《房价变化、房屋资产与中国居民消费——基于总体和调研数据的证据》,载《经济学(季刊)》第2期。\n王能全(2018),《石油的时代》,中信出版社。\n王瑞民、陶然(2017),《中国财政转移支付的均等化效应:基于县级数据的评估》,载《世界经济》第12期。\n王绍光(1997),《分权的底限》,中国计划出版社。\n沃尔克,保罗、行天丰雄(2016),《时运变迁:世界货币、美元地位与人民币的未来》,于杰译,中信出版社。\n沃尔特,卡尔、弗雷泽·豪伊(2013),《红色资本:中国的非凡崛起和脆弱的金融基础》,祝捷、刘骏译,东方出版中心。\n吴军(2019),《浪潮之巅(第四版)》,人民邮电出版社。\n吴敏、周黎安(2018),《晋升激励与城市建设:公共品可视性的视角》,载《经济研究》第12期。\n吴毅(2018),《小镇喧嚣:一个乡镇政治运作的演绎与阐释》,生活·读书·新知三联书店。\n巫永平(2017),《谁创造的经济奇迹?》,生活·读书·新知三联书店。\n席鹏辉、梁若冰、谢贞发(2017),《税收分成调整、财政压力与工业污染》,载《世界经济》第10期。\n席鹏辉、梁若冰、谢贞发、苏国灿(2017),《财政压力、产能过剩与供给侧改革》,载《经济研究》第9期。\n许宪春、贾海、李皎、李俊波(2015),《房地产经济对中国国民经济增长的作用研究》,载《中国社会科学》第1期。\n徐业坤、马光源(2019),《地方官员变更与企业产能过剩》,载《经济研究》第5期。\n严鹏(2018),《简明中国工业史(1815—2015)》,电子工业出版社。\n杨海生、陈少凌、罗党论、佘国满(2014),《政策不稳定性与经济增长:来自中国地方官员变更的经验证据》,载《管理世界》第9期。\n姚洋、张牧扬(2013),《官员绩效与晋升锦标赛:来自城市数据的证据》,载《经济研究》第1期。\n叶恩华,布鲁斯·马科恩(2016),《创新驱动中国:中国经济转型升级的新引擎》,陈召强、段莉译,中信出版社。\n易纲(2019),《坚守币值稳定目标 实施稳健货币政策》,载《求是》第23期。\n易纲(2020),《再论中国金融资产结构及政策含义》,载《经济研究》第3期。\n尹恒、朱虹(2011),《县级财政生产性支出偏向研究》,载《中国社会科学》第1期。\n余淼杰、梁中华(2014),《贸易自由化与中国劳动收入份额——基于制造业贸易企业数据的实证分析》,载《管理世界》第7期。\n余永定(2010),《见证失衡:双顺差、人民币汇率和美元陷阱》,生活·读书·新知三联书店。\n袁健聪、徐涛、王喆、敖翀、李超(2020),《新材料行业面板材料系列报告》,中信证券研报。\n张川川、贾珅、杨汝岱(2016),《“鬼城”下的蜗居:收入不平等与房地产泡沫》,载《世界经济》第2期。\n张春霖(2019),《从数据看全球金融危机以来中国国有企业规模的加速增长》,载《比较》第6期。\n张嘉璈(2018),《通胀螺旋:中国货币经济全面崩溃的十年1939—1949》,中信出版社。\n张军、樊海潮、许志伟、周龙飞(2020),《GDP增速的结构性下调:官员考核机制的视角》,载《经济研究》第5期。\n张军(主编)(2019),《深圳奇迹》,东方出版社。\n章奇、刘明兴(2016),《权力结构、政治激励和经济增长:基于浙江民营经济发展经验的政治经济学分析》,格致出版社、上海人民出版社。\n张五常(2017),《中国的经济制度》,中信出版社。\n张五常(2019),《经济解释(2019增订版)》,中信出版社。\n张燕生(等)(2001),《政府与市场:中国经验》,中信出版社。\n赵婷、陈钊(2019),《比较优势与中央、地方的产业政策》,载《世界经济》第10期。\n郑思齐、孙伟增、吴璟、武赟(2014),《以地生财,以财养地——中国特色城市建设投融资模式研究》,载《经济研究》第8期。\n中国人民银行金融稳定分析小组(2019),《中国金融稳定报告2019》,中国金融出版社。\n中国人民银行调查统计司(2020),《中国城镇居民家庭资产负债调查报告》。\n钟粤俊、陆铭、奚锡灿(2020),《集聚与服务业发展——基于人口空间分布的视角》,载《管理世界》第11期。\n周飞舟(2012),《以利为利:财政关系与地方政府行为》,上海三联书店。\n周黎安(2016),《行政发包的组织边界:兼论“官吏分途”与“层级分流”现象》,载《社会》第1期。\n周黎安(2017),《转型中的地方政府:官员激励与治理(第二版)》,格致出版社、上海人民出版社。\n周黎安(2018),《“官场+市场”与中国增长故事》,载《社会》第2期。\n周其仁(2012),《货币的教训:汇率与货币系列评论》,北京大学出版社。\n周其仁(2017),《城乡中国(修订版)》,中信出版社。\n周雪光(2016),《从“官吏分途”与“层级分流”:帝国逻辑下的中国官僚人事制度》,载《社会》第1期。\n周振鹤(2014),《中国地方行政制度史》,上海人民出版社。\n朱宁(2016),《刚性泡沫》,中信出版社。\n朱玥(2019),《周期的力量,成长的锋芒:光伏产业15年复盘与展望》,兴业证券研报。\n祖克曼,格里高利(2018),《史上最伟大的交易》,施轶译,中国人民大学出版社。\nAcemoglu, Daron, Ufuk Akcigit, Douglas Hanley, and William Kerr 20162016 ,“Transition to Clean Technology,”Journal of Political Economy 124 11 :52-104.\nAghion, Philippe, Antonin Bergeaud, Matthieu Lequien, and Marc J. Melitz 20182018 ,“The Impact of Exports on Innovation: Theory and Evidence,”NBER Working Paper 24600.\nAghion, Philippe, Jing Cai, Mathias Dewatripont, Luosha Du, Ann Harrison,and Patrick Legros 20152015 ,“Industrial Policy and Competition,”American Economic Journal: Macroeconomics 7 44 : 1-32.\nAghion, Philippe, and Jean Tirole 19971997 ,“Formal and Real Authority in Organizations,”Journal of Political Economy 105 11 : 1-29.\nAkerlof, George A. 20202020 ,“Sins of Omission and the Practice of Economics,”Journal of Economic Literature 58 22 : 405-418.\nAlchian, Armen A. 19501950 ,“Uncertainty, Evolution, and Economic Theory,”Journal of Political Economy 58 33 : 211-221.\nAlesina, Alberto, and Enrico Spolaore 20032003 , The Size of Nations, MIT Press.\nAng, Yuen Yuen 20202020 , China\u0026rsquo;s Gilded Age: the Paradox of Economic Boom and Vast Corruption, Cambridge University Press.\nAppelbaum, Eileen, and Rosemary Batt 20142014 , Private Equity at Work: When Wall Street Manages Main Street, Russell Sage Foundation.\nArmstrong-Taylor, Paul 20162016 , Debt and Distortion: Risks and Reforms in the Chinese Financial System, Palgrave Macmillan.\nAutor, David, David Dorn, and Gordon Hanson 20132013 ,“The China Syndrome: Local Labor Market Effects of Import Competition in the United States,”American Economic Review 103 66 : 2121-2168.\nAutor, David, David Dorn, Gordon Hanson and Kaveh Majlesi 20202020 ,“Importing Political Polarization? The Electoral Consequences of Rising Trade Exposure,”American Economic Review 110 1010 : 3139-3183.\nAutor, David, David Dorn, Gordon H. Hanson, Gary Pisano, and Pian Shu 20202020 ,“Foreign Competition and Domestic Innovation: Evidence from US Patents,”American Economic Review: Insights, forthcoming.\nAvdjiev, Stefan, Robert N. McCauley, and Hyun Song Shin 20162016 ,“Breaking Free of the Triple Coincidence in International Finance,”Economic Policy 31 8787 : 409-451.\nBai, Chong-En, Chang-Tai Hsieh, and Zheng Song 20162016 ,“The Long Shadow of a Fiscal Expansion,”*Brookings Papers on Economic Activity,*Fall: 129-165.\nBardhan, Pranab 20162016 ,“State and Development: The Need for a Reappraisal of the Current Literature,”Journal of Economic Literature 54 33 : 862-892.\nBertrand, Marianne, and Adair Morse 20162016 ,“Trickle-down Consumption,”Review of Economics and Statistics 98 55 : 863-879.\nBesley, Timothy, and Torsten Persson 20112011 , Pillars of Prosperity: the Political Economics of Development Clusters, Princeton University Press.\nBloom, Nicholas 20142014 ,“Fluctuations in Uncertainty,”Journal of Economic Perspectives 28 22 : 153-176.\nBloom, Nicholas, Kyle Handley, Andre Kurman, and Phillip Luck 20192019 ,“The Impact of Chinese Trade on US Employment: The Good, The Bad, and The Debatable,”Working Paper.\nBrueckner, Jan K., Shihe Fu, Yizhen Gu, and Junfu Zhang 20172017 ,“Measuring the Stringency of Land Use Regulation: the Case of China\u0026rsquo;s Building Height Limits,”* Review of Economics and Statistics* 99, no. 4:663-677.\nCai, Hongbin, Yuyu Chen, and Qing Gong 20162016 ,“Polluting Thy Neighbor:Unintended Consequences of China\u0026rsquo;s Pollution Reduction Mandates,”Journal of Environmental Economics and Management 76: 86-104.\nChamon, Marcos D., and Eswar S. Prasad 20102010 ,“Why Are Saving Rates of Urban Households in China Rising?”*American Economic Journal:*Macroeconomics 2 11 : 93-130.\nChen, M. Keith 20132013 ,“The Effect of Language on Economic Behavior:Evidence from Savings Rates, Health Behaviors, and Retirement Assets,”American Economic Review 103 22 : 690-731.\nChen, Peter, Loukas Karabarbounis, and Brent Neiman 20172017 ,“The Global Rise of Corporate Saving,”Journal of Monetary Economics 89: 1-19.\nChen, Shuo, Xinyu Fan, Zhitao Zhu 20202020 ,“The Promotion Club,”Working Paper.\nChen, Ting, Laura Xiaolei Liu, Wei Xiong, and Li-An Zhou 20182018 ,“Real Estate Boom and Misallocation of Capital in China,”Working Paper.\nCheng, Hong, Ruixue Jia, Dandan Li, and Hongbin Li 20192019 ,“The Rise of Robots in China,”Journal of Economic Perspectives 33, no. 2: 71-88.\nCherif, Reda, and Fuad Hasanov 20192019 ,“The Return of the Policy that Shall Not Be Named: Principles of Industrial Policy,”IMF Working Paper.\nChetty, Raj, David Grusky, Maximilian Hell, Nathaniel Hendren, Robert Manduca, and Jimmy Narang 20172017 ,“The Fading American Dream:Trends in Absolute Income Mobility since 1940,”Science 356 63366336 :398-406.\nChetty, Raj, Nathaniel Hendren, Maggie R. Jones, and Sonya R. Porter 20202020 ,“Race and Economic Opportunity in the United States: An Intergenerational Perspective,”Quarterly Journal of Economics 135 22 :711-783.\nChoukhmane, Taha, Nicholas Coeurdacier, and Keyu Jin 20192019 ,“The Onechild Policy and Household Savings,”Working Paper.\nCohen, Stephen S., and J. Bradford DeLong 20162016 , Concrete Economics: The Hamilton Approach to Economic Growth and Policy, Harvard Business Review Press.\nCunningham, Edward, Tony Saich, and Jesse Turiel 20202020 ,“UnderstandingCCP Resilience: Surveying Chinese Public Opinion through Time,”Harvard Kennedy School Ash Center Policy Report.\nDi Tella, Rafael, and Dani Rodrik 20202020 ,“Labour Market Shocks and the Demand for Trade Protection: Evidence from Online Surveys,”Economic Journal 130 628628 : 1008-1030.\nEggertsson, Gauti B., and Paul Krugman 20122012 ,“Debt, Deleveraging, and the Liquidity Trap: A Fisher-Minsky-Koo Approach,”Quarterly Journal of Economics 127 33 : 1469-1513.\nFan, Haichao, Yu Liu, Nancy Qian, and Jaya Wen 20202020 ,“Computerizing VAT Invoices in China,”NBER Working Paper 24414.\nFan, Jingting, and Ben Zou 20192019 ,“Industrialization from Scratch: The‘Construction of Third Front’ and Local Economic Development in China\u0026rsquo;s Hinterland,”Working Paper.\nFan, Yi, Junjian Yi, and Junsen Zhang 20212021 ,“Rising Intergenerational Income Persistence in China,”*American Economic Journal: Economic Policy *13 11 : 202-230.\nFang, Hanming, Quanlin Gu, Wei Xiong, and Li-An Zhou 20152015 ,“Demystifying the Chinese Housing Boom,”NBER Macro Annual Vol.30Vol.30 : 105-166.\nFurman, Jason, and Lawrence Summers 20202020 ,“A Reconsideration of Fiscal Policy in the Era of Low Interest Rates,”Brookings Working Paper.\nGertler, Mark, and Simon Gilchrist 20182018 , “What Happened: Financial Factors in the Great Recession,”Journal of Economic Perspective 32 33 :3-30.\nGlaeser, Edward, and Joseph Gyourko 20182018 ,“The Economic Implications of Housing Supply,”Journal of Economic Perspectives 32 11 : 3-30.\nGlaeser, Edward, and Andrei Shleifer 20032003 ,“The Rise of the Regulatory State,”Journal of Economic Literature, 41 22 : 401-425.\nGlick, Reuven, and Kevin J. Lansing 20102010 ,“Global Household Leverage,House Prices, and Consumption,”Federal Reserve Bank of San Francisco Economic Letter.\nGomory, Ralph E., and William J. Baumol 20002000 , Global Trade and Conflicting National Interests, MIT Press.\nHaldane, Andrew, Simon Brennan, and Vasileios Madouros 20102010 ,“The Contribution of the Financial Sector: Miracle or Mirage?”A Technical Report at the London School of Economics.\nHart, Oliver 19951995 , Firms, Contracts, and Financial Structure, Clarendon Press.\nHart, Oliver, Andrei Shleifer, and Robert W. Vishny 19971997 ,“The Proper Scope of Government: Theory and an Application to Prisons,”Quarterly Journal of Economics 112 44 : 1127-1161.\nHaskel, Jonathan, and Stian Westlake 20182018 , Capitalism Without Capital: the Rise of the Intangible Economy, Princeton University Press.\nHavranek, Tomas, and Zuzana Irsova 20112011 ,“Estimating Vertical Spillovers from FDI: Why Results Vary and What the True Effect is,”Journal of International Economics 85 22 : 234-244.\nHe, Guojun, Shaoda Wang, and Bing Zhang 20202020 ,“Leveraging Political Incentives for Environmental Regulation: Evidence from Chinese Manufacturing Firms,”Quarterly Journal of Economics.\nHirschman, Albert O. 20132013 ,“The Changing Tolerance for Income Inequality in the Course of Economic Development,”The Essential Hirschman, Ed. by Jeremy Adelman: 74-101, Princeton University Press.\nHuang, Zhangkai, Lixing Li, Guangrong Ma, and Lixin Colin Xu 20172017 ,“Hayek, Local Information, and Commanding Heights: Decentralizing State-Owned Enterprises in China,”* American Economic Review* 107 88 :2455-2478.\nJia, Ruixue, Masayuki Kudamatsu, and David Seim 20152015 ,“Political Selection in China: the Complementary Roles of Connections and Performance,”Journal of the European Economic Association, 13 44 , 631-668.\nJin, Hehui, Yingyi Qian, and Barry R. Weingast 20052005 ,“Regional decentralization and fiscal incentives: Federalism, Chinese style,”Journal of Public Economics 89 9−109-10 : 1719-1742.\nJordà, Òscar, Moritz Schularick, and Alan M. Taylor 20162016 ,“The Great Mortgaging: Housing Finance, Crises and Business Cycles,”Economic Policy 31 8585 : 107-152.\nImrohoroǧlu, Ayşe, and Kai Zhao 20182018 ,“The Chinese Saving Rate: Long-Term Care Risks, Family Insurance, and Demographics,”Journal of Monetary Economics 96: 33-52.\nKarabarbounis, Loukas, and Brent Neiman 20142014 ,“The Global Decline of the Labor Share,”Quarterly Journal of Economics 129 11 : 61-103.\nKarabarbounis, Loukas, and Brent Neiman 20192019 ,“Accounting for Factorless Income,”NBER Macroeconomics Annual 33 11 : 167-228.\nKlein, Matthew C., and Michael Pettis 20202020 , Trade Wars are Class Wars:How Rising Inequality Distorts the Global Economy and Threatens International Peace, Yale University Press.\nKnoll, Katharina, Moritz Schularick, and Thomas Steger 20172017 ,“No Price Like Home: Global House Prices, 1870-2012,”American Economic Review 107 22 : 331-353.\nKreps, David 20182018 , The Motivation Toolkit: How to Align Your Employees’Interests with Your Own, Findaway World, LLC.\nKrugman, Paul 19871987 ,“The Narrow Moving Band, the Dutch Disease, and the Competitive Consequences of Mrs. Thatcher: Notes on Trade in the Presence of Dynamic Scale Economies,”Journal of Development Economics 27 1−21-2 : 41-55.\nKuhn, Moritz, Moritz Schularick, and Ulrike I. Steins 20202020 ,“Income and Wealth Inequality in America: 1949-2016,”Journal of Political Economy.\nKung, James Kai-Sing, and Lin Yi-min 20072007 ,“The Decline of Township-and-Village Enterprises in China\u0026rsquo;s Economic Transition,”World Development 35 44 : 569-584.\nLane, Nathan 20192019 ,“Manufacturing Revolutions: Industrial Policy and Industrialization in South Korea,”Working Paper.\nLevchenko, Andrei A 20072007 ,“Institutional Quality and International Trade,”Review of Economic Studies 74 33 : 791-819.\nLi, Pei, Yi Lu, and Jin Wang 20162016 ,“Does Flattening Government Improve Economic Performance? Evidence from China,”Journal of Development Economics 123: 18-37.\nLi, Xing, Chong Liu, Xi Weng, and Li-An Zhou 20192019 ,“Target Setting in Tournaments: Theory and Evidence from China,”Economic Journal 129 1010 : 2888-2915.\nLiu, Ernest 20192019 ,“Industrial Policies in Production Networks,”Quarterly Journal of Economics 134 44 : 1883-1948.\nMaskin, Eric, Yingyi Qian, and Chenggang Xu 20002000 ,“Incentives,Information, and Organizational Form,”Review of Economic Studies 67 22 : 359-378.\nMelitz, Marc J., and Daniel Trefler 20122012 ,“Gains from Trade when Firms Matter,”Journal of Economic Perspectives 26 22 : 91-118.\nMichalopoulos, Stelios 20122012 ,“The Origins of Ethnolinguistic Diversity,”American Economic Review 102 44 : 1508-1539.\nMian, Atif R., Ludwig Straub, and Amir Sufi 2020a2020a ,“Indebted Demand,”NBER Working Paper No. w26940.\nMian, Atif R., Ludwig Straub, and Amir Sufi 2020b2020b ,“The Saving Glut ofthe Rich and the Rise in Household Debt,”NBER Working Paper No.w26941.\nMilne, Alistair 20092009 , The Fall of the House of Credit: What Went Wrong in Banking and What Can be Done to Repair the Damage? Cambridge University Press.\nMulligan, Casey and Andrei Shleifer 20052005 ,“The Extent of the Market and the Supply of Regulation,”Quarterly Journal of Economics 120: 1445-1473.\nOlmstead, Alan L., and Paul W. Rhode 20182018 ,“Cotton, Slavery, and the New History of Capitalism,”Explorations in Economic History 67: 1-17.\nOrlik, Thomas 20202020 , China: the Bubble that Never Pops, Oxford University Press.\nPhilippon, Thomas, and Ariell Reshef 20122012 ,“Wages and human capital in the US finance industry: 1909-2006,”Quarterly Journal of Economics 127 44 : 1551-1609.\nPhilippon, Thomas 20192019 , The Great Reversal: How America Gave Up on Free Markets, Harvard University Press.\nPiketty, Thomas, Li Yang, and Gabriel Zucman 20192019 ,“Capital Accumulation, Private Property, and Rising Inequality in China, 1978-2015,”American Economic Review 109 77 : 2469-2496.\nPiketty, Thomas, and Gabriel Zucman 20142014 ,“Capital is Back: Wealth-Income Ratios in Rich Countries 1700-2010,”*Quarterly Journal of Economics *129: 1255-1310.\nPrendergast, Canice, and Robert Topel 19961996 ,“Favoritism in Organizations,”Journal of Political Economy 104 55 : 958-978.\nQian, Yingyi 20172017 , How Reform Worked in China: the Transition from Plan to Market, the MIT Press\nREN21 20202020 , Renewables 2020 Global Status Report, Renewable EnergyPolicy Network for the 21st Century.\nRodrik, Dani 19981998 ,“Why Do More Open Economies Have Bigger Governments?”Journal of Political Economy 106 55 : 997-1032.\nRodrik, Dani 20132013 ,“Unconditional Convergence in Manufacturing,”Quarterly Journal of Economics 128 11 : 165-204.\nRodrik, Dani 20162016 ,“Premature Deindustrialization,”Journal of Economic Growth 21 11 : 1-33.\nRyan-Collins, Josh, Toby Lioyd, and Laurie Macfarlane 20172017 , Rethinking the Economics of Land and Housing, Zed Books.\nSaez, Emmanuel, and Gabriel Zucman 20192019 , The Triumph of Injustice:How the Rich Dodge Taxes and How to Make Them Pay, W.W. Norton \u0026amp;Company.\nShiller, Robert J. 20202020 , Narrative Economics: How Stories Go Viral and Drive Major Economic Events, Princeton University Press.\nShleifer, Andrei, and Robert Vishny 20112011 ,“Fire Sales in Finance and Macroeconomics,”Journal of Economic Perspectives 25 11 : 29-48.\nSivaram, Varun 20182018 , Taming the Sun: Innovation to Harness Solar Energy and Power the Planet, MIT press.\nSpader, Jonathan, Daniel McCue, and Christopher Herbert 20162016 ,“Homeowner Households and the U.S. Homeownership Rate: Tenure Projections for 2015-2035,”Working Paper.\nTooze, Adam 20182018 , Crashed: How a Decade of Financial Crises Changed the World, Penguin.\nWallis, John J. 20062006 ,“The Concept of Systematic Corruption in American History,”Corruption and Reform: Lessons from America\u0026rsquo;s Economic History, University of Chicago Press: 23-62.\nWang, Gungwu 20192019 , China Reconnects: Joining a Deep-rooted Past to a New World Order, World Scientific.\nWang, Zhi, Shang-Jin Wei, Xinding Yu, Kunfu Zhu 20182018 ,“Re-examining the Effects of Trading With China on Local Labor Markets: A Supply Chain Perspective,”NBER Working Paper 24886.\nWang, Zhi, Qinghua Zhang, and Li-An Zhou 20202020 ,“Career Incentives of City Leaders and Urban Spatial Expansion in China,”*Review of Economics and Statistics *102 55 : 897-911.\nWorld Inequality Lab 20172017 , World Inequality Report 2018.\nXu, Chenggang 20112011 ,“The Fundamental Institutions of China\u0026rsquo;s Reforms and Development,”Journal of Economic Literature, 49 44 : 1076-1151.\nYergin, Deniel 20202020 The New Map: Energy, Climate, and the Clash of Nations, Penguin Press.\nYoung, Alwyn 19911991 ,“Learning by Doing and the Dynamic Effects of International Trade,”Quarterly Journal of Economics 106 22 : 369-405.\nZhang, Longmei, Ray Brooks, Ding Ding, Haiyan Ding, Hui He, Jing Lu,and Rui Mano 20182018 ,“China\u0026rsquo;s High Savings: Drivers, Prospects, and Policies,”IMF Working Papers.\nZhang, Bing, Xiaolan Chen, and Huanxiu Guo 20182018 ,“Does Central Supervision Enhance Local Environmental Enforcement? Quasi-experimental Evidence from China,”Journal of Public Economics 164:70-90.\nZhang, Zhiwei, and Yi Xiong 20192019 ,“Infrastructure Financing,”Working Paper.\n"},{"id":160,"href":"/zh/docs/culture/%E7%BD%AE%E8%BA%AB%E4%BA%8B%E5%86%85/%E4%B8%8A%E7%AF%87/","title":"上篇","section":"置身事内","content":"\n图书在版编目(CIP)数据\n置身事内:中国政府与经济发展/兰小欢著.—上海:上海人民出版社,2021\nISBN 978-7-208-17133-6\nⅠ.①置… Ⅱ.①兰… Ⅲ.①行政管理部门—关系—经济发展—研究—中国 Ⅳ.①D630.1②F124\n中国版本图书馆CIP数据核字(2021)第095010号\n书 名:置身事内:中国政府与经济发展\n作 者:兰小欢\n出品人:姚映然\n责任编辑:贾忠贤 曹迪辉\n转 码:欣博友\nISBN:978-7-208-17133-6/F·2693\n本书版权,为北京世纪文景文化传播有限责任公司所有,非经书面授权,不得在任何地区以任何方式进行编辑、翻印、仿制或节录。\n豆瓣小站:世纪文景\n新浪微博:@世纪文景\n微信号:shijiwenjing2002\n发邮件至wenjingduzhe@126.com订阅文景每月书情\n目 录 # 前言 从了解现状开始\n上篇 微观机制\n第一章 地方政府的权力与事务\n第一节 政府治理的特点\n第二节 外部性与规模经济\n第三节 复杂信息\n第四节 激励相容\n第五节 招商引资\n结语\n扩展阅读\n第二章 财税与政府行为\n第一节 分税制改革\n第二节 土地财政\n第三节 纵向不平衡与横向不平衡\n结语\n扩展阅读\n第三章 政府投融资与债务\n第一节 城投公司与土地金融\n第二节 地方政府债务\n第三节 招商引资中的地方官员\n结语\n扩展阅读\n第四章 工业化中的政府角色\n第一节 京东方与政府投资\n第二节 光伏发展与政府补贴\n第三节 政府产业引导基金\n结语\n扩展阅读\n下篇 宏观现象\n第五章 城市化与不平衡\n第一节 房价与居民债务\n第二节 不平衡与要素市场改革\n第三节 经济发展与贫富差距\n结语\n扩展阅读\n第六章 债务与风险\n第一节 债务与经济衰退\n第二节 债台为何高筑:欧美的教训\n第三节 中国的债务与风险\n第四节 化解债务风险\n结语\n扩展阅读\n第七章 国内国际失衡\n第一节 低消费与产能过剩\n第二节 中美贸易冲突\n第三节 再平衡与国内大循环\n结语\n扩展阅读\n第八章 总结:政府与经济发展\n第一节 地区间竞争\n第二节 政府的发展与转型\n第三节 发展目标与发展过程\n结语\n扩展阅读\n结束语\n参考文献\n献给我的父亲母亲\n事莫明于有效,论莫定于有证。\n——王充《论衡》\n社会进程本是整体,密不可分。所谓经济,不过是研究者从这洪流中人工提炼出的部分事实。何谓经济,本身已然是种抽象,而之后大脑还须经过若干抽象,方能复刻现实。没有什么事是纯粹经济的,其他维度永远存在,且往往更为重要。\n——约瑟夫·熊彼特《经济发展理论》\n一套严格的概念框架无疑有助于厘清问题,但也经常让人错把问题当成答案。社会科学总渴望发现一套“放之四海而皆准”的方法和规律,但这种心态需要成熟起来。不要低估经济现实的复杂性,也不要高估科学工具的质量。\n——亚历山大·格申克龙《经济落后的历史透视》\n前言 从了解现状开始 # 这本书讲的是我们国家的经济故事,其中有让我们骄傲的繁华,也有让我们梦碎的房价。这本书写给大学生和对经济话题感兴趣的读者,希望能帮他们理解身边的世界,从热闹的政经新闻中看出些门道,从乏味的政府文件中觉察出些机会。\n本书主角既不是微观的价格机制,也不是宏观的经济周期,而是政府和政策,内容脱胎于我在复旦大学和香港中文大学(深圳)的课程讲义。我剔除了技术细节,尽量用通俗的语言讲述核心的内容和观念:在我国,政府不但影响“蛋糕”的分配,也参与“蛋糕”的生产,所以我们不可能脱离政府谈经济。必须深入了解这一政治经济机体如何运作,才可能对其进行判断。我们生活在这个机体中,我们的发展有赖于对这个机体的认知。要避免把舶来的理论化成先入为主的判断——看到现实与理论不符,便直斥现实之非,进而把要了解的现象变成了讥讽的对象——否则就丧失了“同情的理解”的机会。\n我国的政治经济现象非常复杂,不同的理论和信息都只能反映现象的不同侧面,至于哪个侧面有用,由读者决定。对从事经济实务工作(如金融和投资)的读者,我希望能帮助他们了解日常业务之外的政治经济背景,这些背景的变化往往对行业有深远的影响。对经济学专业的大学生,由于他们所学的西方理论和中国现实之间脱节严重,我将中国政府作为本书分析的主角,希望可以帮助构建二者之间的桥梁。对非经济学专业的读者,我希望这本书能帮助他们读懂国家政经大事和新闻。\n本书注重描述现实,注重解释“是什么”和“为什么”。当不可避免涉及“怎么办”的时候,则注重解释当下正在实施的政策和改革。对读者来说,了解政府认为应该怎么办,比了解“我”认为应该怎么办,重要得多。\n本书结构与数据说明 # 本书以我国地方政府投融资为主线,分上下两篇。上篇解释微观机制,包括地方政府的基本事务、收入、支出、土地融资和开发、投资和债务等;下篇解释这些微观行为对宏观现象的影响,包括城市化和工业化、房价、地区差异、债务风险、国内经济结构失衡、国际贸易冲突等。最后一章提炼和总结全书内容。\n本书力求简明扼要,突出主要逻辑和重点事实,不会过多展开细节。有兴趣深究的读者可以参考每章末尾“扩展阅读”中推荐的读物。\n本书使用了很多数据,若处处标注来源,会影响阅读。所以对于常见数据,如直接来自《中国统计年鉴》或万得数据库中的数据,我没有标注来源,但读者应该很容易就能找到。只有那些非常用数据或转引自他人研究的数据,我才注明出处。\n本书虽为大众读者所写,但严格遵循学术规范,使用了大量前沿研究成果,可用作大学相关课程的参考资料。与各章节内容相匹配的课件,可通过扫描本书前勒口的二维码获取。\n感谢 # 本书使用的数据和文献,跨度很大。引用的260多种文献中,绝大多数发表于2010年之后。假如没有近些年本土经济学研究的飞速发展,没有海外对中国经济研究的日渐深入,我不可能整理出这么多素材。\n复旦大学经济学院是研究中国经济问题的重镇,向来重视制度和历史分析,也积极参与现实和政策讨论,我对中国经济的深入学习和研究,是在这里开始的。学院几乎每周都有十数场报告,既有前沿学术探讨和热点政策分析,也有与业界和政府的交流讨论,在这种氛围中,研究者自然而然会关注现实问题。本书几乎每一章的主题,复旦的同事都有研究和著述,我从他们那里学到了很多。在复旦工作的六七年中,我几乎每周都参加陈钊和陆铭等同仁组织的学习讨论小组,本书中的很多想法都源于这些讨论。\n2017—2018年,我做了大量实地调研,与很多企业家、投资人和政府官员交流,这些经历影响了本书的视角和框架。感谢在这个过程中帮助过我的很多领导和业界精英。\n本书涵盖的主题跨度很大,在写作和学习过程中,我请教了很多同事,他们给了我巨大的帮助和鼓励。尤其要感谢陈硕、陈婷、董丰、刘志阔、吴乐旻,他们仔细阅读了本书的初稿章节,提供了大量宝贵建议。感谢我的教学和研究助理拜敏旸、丁关祖、李嵩同学,他们帮我收集了很多数据。同样也感谢那些帮我审读书稿的聪慧可爱的同学,他们阅读了部分章节的初稿,在内容编排和文字上提出了很多宝贵建议,提高了本书的可读性。\n最后,感谢上海人民出版社和世纪文景的钱敏、贾忠贤、曹迪辉三位编辑老师。他们的专业素养是这本书质量的保证,他们的专业精神和对“出一本好书”的热情与执着,让我感动。\n本书的一切错漏之处都归我自己。希望读者批评指正,争取有机会再版时改正。\n上篇 微观机制 # 地方政府是经济发展中的关键一环,事务繁杂,自主权力很大。本篇第一章介绍决定地方事务范围的主要因素,这些因素不会经常变化,所以地方政府要办的事、要花的钱也不会有巨大变动。一旦收入发生大幅变动,收支矛盾就会改变政府行为。第二章介绍1994年分税制改革的前因后果。这次改革对地方政府影响深远,改变了地方政府发展经济的模式,催生了“土地财政”和“土地金融”,成为地方政府推动快速城市化和工业化的资金来源。第三章和第四章详细介绍其中的逻辑、机制、案例,同时解释地方政府的债务和风险,以及相关改革。这些内容是理解下篇宏观经济现象的微观基础。\n第一章 地方政府的权力与事务 # 中学时我听过一件事,一直记着:美国就算只把加州单拿出来,也是世界第六大经济体!当时我想,美国有50个州,那真是富强得难以想象。后来我才知道,加州的GDP占美国GDP总量的15%,是美国的第一经济大州,远超其他州。不过这种类比很好记,我现在也常在课堂上套用:广东和江苏相当于世界上第13和第14大经济体,超过西班牙和澳大利亚。山东、浙江、河南每一个单独计算都是世界前20大经济体,其中,河南仅次于荷兰。\n几年前,美国麻省理工学院的一个创业团队想进入中国市场。公司做汽车智能驾驶配件,小有规模,势头不错。两个创始人都是20多岁的小伙子,有全球眼光,想在新一轮融资时引入中国的战略投资者。当时我正为湖北省政府的投资基金做咨询,就给两人介绍了湖北总体及武汉的汽车产业情况。他们是头一次来中国,对湖北完全没概念。于是我就套用了上述类比:湖北的GDP总量与阿根廷相当,所以湖北省投资基金类似于阿根廷的主权基金。两人一听眼睛就亮了。几年时间一晃而过,2019年湖北的GDP已经接近瑞士,而阿根廷却再次陷入衰退,其GDP已不足湖北的七成。\n我国规模超大,人口、面积、经济总量都与一个大洲的体量相当,各省份的规模也大都抵得上一个中型国家,且相互之间差异极大:新疆的面积是海南的47倍;广东的人口是西藏的33倍,GDP总量是后者的62倍;北京的人均GDP是甘肃的5倍。这种经济发展水平的差异远大于美国各州。美国最富的纽约州人均GDP也不过是最穷的密西西比州的2.3倍。 11 不仅如此,我国各地风俗、地理、文化差异也大,仅方言就有上百种,治理难度可想而知。\n要理解政府治理和运作的模式,首先要了解权力和资源在政府体系中的分布规则,既包括上下级政府间的纵向分布,也包括同级政府间的横向分布。本章将介绍政府间事权划分的基本逻辑。第一节简要介绍行政体系的几个特点。第二节至第四节结合实际情况,详述事权划分三原则:外部性和受益范围原则、信息复杂性原则、激励相容原则。 22 第五节介绍地方政府的招商引资工作。发展经济是政府的核心任务,而招商引资需要调动各种资源和手段,所涉具体事务既深且广,远超主流经济学教科书中“公共服务”或“公共物品”的讨论范畴。了解招商引资,是理解地方政府深度融入经济发展过程的起点。\n第一节 政府治理的特点 # 图1-1描绘了中国的五级政府管理体系:中央—省—市—县区—乡镇。这一体系从历史上的“中央—省—郡县”三级体系演变而来。中华人民共和国成立后,在省以下设了“专区”或“地区”。20世纪50年代开始试行“以市管县”,但在改革开放之前,市的数目不足200个。随着工业化和城市化的发展,1983年开始,“以市管县”在全国推行,大多数“地区”都改成了“地级市”,城市数目大幅增加到600多个(图1-1中地级市与县级市之和)。 33 目前依然存在的“地区”,大都在地广人稀的边疆省份,面积很大,如内蒙古的锡林郭勒盟和新疆的阿克苏地区。在县乡一级,帝制时期的地方精英自治体制(所谓“皇权不下县”)随帝制瓦解而终结。民国至新中国初期,政权逐渐延伸到了县以下的乡镇和城市的街道。在乡以下的村落,则实行村民自治,因为行政能力毕竟有限,若村落也建制,那财政供养人口又要暴涨一个数量级。\n图1-1 中华人民共和国行政区划(2018年)\n注:括号中的数字为对应的行政单位数目(单位:个)。\n资料来源:民政部网站。\n现实情况当然远比简化的“五级”复杂。比如,同样都是地级市,省会城市与一般城市的政治地位与经济资源完全不同。再比如,同样都是县级单位,县级市、县、市辖区之间也有重大差别:在土地和经济事务上,县级市的权力比县大,而县的权力又比一般市辖区大。这五级体系也在不断改革,如近些年的“撤县设市”“撤县设区”“省直管县”“撤乡设镇”等。即使在省级层面上也时有重大变革,如1988年设立海南省、1997年设立重庆直辖市。而最近几年提出的“长三角一体化”“粤港澳大湾区”等国家战略,也会对现有行政区域内的权力和资源分配产生深远影响。\n我国政府体制有深厚的历史和文化渊源,且不断发展变化,非常复杂,研究专著汗牛充栋。本章结尾的“扩展阅读”会推荐几本相关读物,本节只简要介绍几个与经济发展密切相关的体制特点。\n**中央与地方政府。**央地关系历来是研究很多重大问题的主线。一方面,维持大一统的国家必然要求维护中央权威和统一领导;另一方面,中国之大又决定了政治体系的日常运作要以地方政府为主。历史上,央地间的权力平衡需要各种制度去维护,一旦失控,王朝就可能分裂甚至覆灭。小说《三国演义》以“话说天下大势,分久必合,合久必分”开头,正体现了这种平衡之难。按照历史学家葛剑雄的统计,从公元前221年秦统一六国到1911年清朝结束,我国“统一”(即基本恢复前朝疆域且保持中原地区相对太平)的时间不过950年,占这一历史阶段的45%,而分裂时间则占55%,可见维持大一统国家并不容易。 44 如今,央地关系的重要性也体现在宪法中。现行宪法的第一条和第二条规定了国体和政体,紧接着第三条便规定了央地关系的总原则:“中央和地方的国家机构职权的划分,遵循在中央的统一领导下,充分发挥地方的主动性、积极性的原则。”这是一条高度抽象和灵活的原则,之后的章节会结合具体内容来展开讨论。\n**党和政府。**中国共产党对政府的绝对领导是政治生活的主题。简单说来,党负责重大决策和人事任免,政府负责执行,但二者在组织上紧密交织、人员上高度重叠,很难严格区分。本书主题是经济发展,无须特别强调党政之分,原因有三。其一,地方经济发展依托地方政府。地方党委书记实质上依然是地方官,权力通常无法超越本地。 55 其二,制约政府间事权划分的因素,也制约着各级党委的分工。比如,信息沟通既是困扰上下级政府的难题,也是困扰上下级党委的难题。所以在讨论事权划分原理时,无须特别区分党和政府。其三,地方经济事务由政府部门推动和执行。虽然各部门都由党委领导,但地方上并无常设的专职党委机构来领导日常经济工作。假如本书主题是法制建设,那这种党政不分的分析框架就不准确,会遗漏关键党委机构的作用,比如政法委和纪委。 66 **条块分割,多重领导。**我国政治体系的一个鲜明特点是“层层复制”:中央的主要政治架构,即党委、政府、人大、政协等,省、市、县三级都完全复制,即所谓“四套班子”。中央政府的主要部委,除外交部等个别例外,在各级政府中均有对应部门,比如中央政府有财政部、省政府有财政厅、市县政府有财政局等。这种从上到下的部门垂直关系,被称为“条条”,而横向的以行政区划为界的政府,被称为“块块”。大多数地方部门都要同时接受“条条”和“块块”的双重领导。拿县教育局来说,既要接受市教育局的指导,又要服从县委、县政府的领导。通常情况下,“条条”关系是业务关系,“块块”关系才是领导关系,因为地方党委和政府可以决定人事任免。\n**上级领导与协调。**在复杂的行政体系中,权力高度分散在各部门,往往没有清晰的法律界限,所以一旦涉及跨部门或跨地区事务,办起来就比较复杂,常常理不清头绪,甚至面对相互矛盾的信息。部门之间也存在互相扯皮的问题,某件事只要有一个部门反对,就不容易办成。尤其当没有清楚的先例和流程时,办事人员会在部门之间“踢皮球”,或者干脆推给上级,所以权力与决策会自然而然向上集中。制度设计的一大任务就是要避免把过多决策推给上级,减轻上级负担,提高决策效率,所以体制内简化决策流程的原则之一,就是尽量在能达成共识的最低层级上解决问题。 77 若是部门事务,本部门领导就可以决定;若是经常性的跨部门事务,则设置上级“分管领导”甚至“领导小组”来协调推进。比如经济事务,常常需要财政、工商、税务、发改委等多部门配合,因为发展经济是核心任务,所以地方大都有分管经济的领导,级别通常较高,比如常务副市长(一般是市委常委)。\n**官僚体系。**所有规章制度都必须由人来执行和运作。同样的制度在不同的人手中,效果可能完全不同,所以无论是国家还是公司,人事制度都是组织机构的核心。我国是世界上第一个发展出完善、专业、复杂的官僚体系的国家。早在秦统一六国之前,各国就已开始通过军功和学问等渠道来吸纳人才,且官职不可继承,逐渐削弱由血缘关系决定的贵族统治体系。唐朝以后,以科举为基础、具有统一意识形态的庞大官僚体系,成为政治和社会稳定的支柱之一。 88 科举选拔出的官僚,既为政治领导,也为道德表率,不仅是政治体制的核心,也是维护国家和社会统一的文化与意识形态载体。这一体系的三大特点延续至今:官员必须学习和贯彻统一的意识形态;官员由上级任命;地方主官需要在多地轮换任职。在维持大一统的前提下,这些特点都是央地关系平衡在人事制度上的体现。\n总的来说,我国有一套立足于自身历史和文化的政治制度。像所有政治制度一样,实际的权力运作与纸面的规章制度并不完全一致,但也绝不是任性随意的。在任何体制下,权力运作都受到两种约束:做事的能力及做事的意愿。前者取决于掌握的资源,后者取决于各方的积极性和主动性。接下来我们就来讨论这些约束条件的影响。\n第二节 外部性与规模经济 # 地方政府权力的范围和边界,由行政区划决定。我国实行“属地管理”,地方事权与行政区划密不可分,所以我们先从行政区划角度来分析权力划分。影响行政区划的首要因素是“外部性”,这是个重要的经济学概念,简单来说就是人的行为影响到了别人。在公共场合抽烟,让别人吸二手烟,是负外部性;打流感疫苗,不仅自己受益,也降低了他人的感染风险,是正外部性。\n一件事情该不该由地方自主决定,可以从外部性的角度来考虑。若此事只影响本地,没有外部性,就该由本地全权处理;若还影响其他地方,那上级就该出面协调。比如市里建个小学,只招收本市学生,那市里就可以做决定。但如果本市工厂污染了其他城市,那排污就不能只由本市说了算,需要省里协调。如果污染还跨省,可能就需要中央来协调。因此行政区域大小应该跟政策影响范围一致。若因行政区域太小而导致影响外溢、需要上级协调的事情过多,本级政府也就失去了存在的意义。反过来讲,行政区划也限定了地方可调配的资源,限制了其政策的影响范围。\n公共物品和服务的边界 # 按照经典经济学的看法,政府的核心职能是提供公共物品和公共服务,比如国防和公园。这类物品一旦生产出来,大家都能用,用的人越多就越划算——因为建造和维护成本也分摊得越薄,这就是“规模经济”。但绝大部分公共物品只能服务有限人群。一个公园虽然免费,但人太多就会拥挤,服务质量会下降,且住得远的人来往不便,所以公园不能只建一个。一个城市总要划分成不同的区县,而行政边界的划分跟公共服务影响范围有关。一方面,因为规模经济,覆盖的人越多越划算,政区越大越好;另一方面,受制于人们获取这些服务的代价和意愿,政区不能无限扩大。 99 这道理看似不起眼,但可以帮助我们理解很多现象,小到学区划分、大到国家规模。比如,古代王朝搞军事扩张,朝廷就要考虑扩张的限度。即便有实力,是否就越大越好?政府职能会不会鞭长莫及?边远地区的人是否容易教化和统治?汉武帝时武功极盛,但对国家资源的消耗也大。等到其子昭帝继位,便召开了历史上著名的“盐铁会议”,辩论武帝时的种种国策,其会议记录就是著名的《盐铁论》。其中《地广第十六》中就有关于国土扩张的辩论,反对方说:“秦之用兵,可谓极矣,蒙恬斥境,可谓远矣。今踰蒙恬之塞,立郡县寇虏之地,地弥远而民滋劳……张骞通殊远,纳无用,府库之藏,流于外国……”意思是边远之地物产没什么用,人也野蛮,而且那么远,制度也不容易实施,实在没必要扩张。这些话颇有道理,支持方不易反驳,于是就开始了人身攻击。 1010 其实按照我们的理论,人身攻击大可不必,若想支持扩张,多说说规模经济的好处便是。美国独立战争结束后,13个州需要决定是否建立一个中央联邦政府。反对的人不少。毕竟刚打跑了英国主子,何必马上给自己立个新主子?所以赞同的人就得想办法说服民众,宣传联邦的好处,他们写了不少文章,这些小文章后来就成了美国的国民经典《联邦党人文集》。其中编号第13的文章出自汉密尔顿之手,正是讲一个大政府比13个小政府更省钱的道理,也就是规模经济。\n政府公共服务的覆盖范围也与技术和基础设施有关。比如《新闻联播》,是不是所有人都有电视或网络可以收看?是不是所有人都能听懂普通话?是不是所有人的教育水平都能听懂基本内容?这些硬件和软件的基础非常重要。所以秦统一六国后,立刻就进行了“车同轨、书同文”以及统一货币和度量衡的改革。\n以公共物品的规模经济和边界为切入点,也可以帮助理解中央和地方政府在分工上的一些差异。比如国防支出几乎全部归中央负担,因为国防体系覆盖全体国民,不能遗漏任何一个省。而中小学教育受制于校舍和老师等条件,规模经济较小,主要覆盖当地人,所以硬件和教师支出大都归地方负担。但教材内容却不受物理条件限制,而且外部性极强。如果大家都背诵李白、杜甫、司马迁的作品,不仅能提高自身素养,而且有助于彼此沟通,形成共同的国民意识,在一些基本问题和态度上达成共识。所以教育的日常支出虽由地方负责,但教材编制却由中央主导,教育部投入了很多资源。2019年底,教育部印发《中小学教材管理办法》,加强了国家统筹,对思想政治(道德与法治)、语文、历史课程教材,实行国家统一编写、统一审核、统一使用。\n假如各个市、各个县所提供的公共服务性质和内容都差不多,基础设施水平也没什么差异,那各地的行政区划面积是不是就该相等呢?当然也不是,还要取决于影响公共服务效果的其他因素。\n人口密度、地理与文化差异 # 第一个重要因素是人口密度。我国幅员辽阔,但人口分布极不平衡。如果从黑龙江的瑷珲(今黑河市南)到云南的腾冲之间画一条直线,把国土面积一分为二,东边占了43%的面积却住了94%的人口,而西边占了57%的面积却只住了6%的人口。 1111 西边人口密度比东边低得多,行政单位面积自然就大得多。面积最大的四个省级单位(新疆、西藏、内蒙古、青海)都在西边,合计占国土面积的一半。新疆有些地区的面积比东部一个省的面积还要大,但人口却尚不及东部一个县多。 1212 按人口密度划分行政区域的思路非常自然。提供公共物品和服务需要成本,人多,不仅税收收入多,而且成本能摊薄,实现规模收益。人口稠密的地方,在比较小的范围内就可以服务足够多的人,实现规模收益,因此行政区域面积可以小一些;而地广人稀的地方,行政区域就该大一些。中国历代最重要的基层单位是县,而县域的划分要依据人口密度,这是早在秦汉时期就定下的基本规则之一,所谓“民稠则减,稀则旷”(《汉书·百官公卿表》)。随着人口密度的增加,行政区域的面积应该越变越小,数目则应该越变越多。所以随着古代经济中心和人口从北方转移到南方,行政区划也就慢慢从“北密南稀”变成了“南密北稀”。以江西为例,西汉时辖19县,唐朝变成34县,南宋时更成为粮食主产区,达到68县,清朝进一步变成81县。 1313 第二个重要因素是地理条件。古代交通不便,山川河流也就成了行政管理的自然边界,历史地理学家称之为“随山川形变”,由唐朝开国后提出:“然天下初定,权置州郡颇多,太宗元年,始命并省,又因山川形便,分天下为十道”(《新唐书·地理志》)。所谓“十道”,基本沿长江、黄河、秦岭等自然边界划分。唐后期演化为40余方镇,很多也以山川为界,比如江西和湖南就以罗霄山脉为界,延续至今。现今省界中仍有不少自然边界:海南自不必说,山西、陕西以黄河为界,四川、云南、西藏则以长江(金沙江)为界,湖北、重庆以巫山为界,广东、广西则共属岭南。\n第三个重要因素是语言文化差异。汉语的方言间有差异,汉语与少数民族语言也有差异。若语言不通,政务管理或公共服务可能就需要差异化,成本会因此增加,规模收益降低,从而影响行政区域划分。当然,语言差异和地理差异高度相关。方言之形成,多因山川阻隔而交流有限。世界范围内,一国若地形或适耕土地分布变异大,人口分布就比较分散,国内的语言变异往往也就更丰富。 1414 我国各省间方言不同,影响了省界划分。而省内市县之间,口音也常有差异,这影响了省内的行政区划。浙江以吴语方言复杂多变闻名,而吴语方言的分布与省内地市的划分高度重合:台州属于吴语台州片,温州属于瓯江片,金华则属于婺州片。同一市内,语言文化分布也会影响到区县划分。杭州下辖的淳安和建德两县,属于皖南的徽文化和徽语区,而其他县及杭州市区,则属于吴语区的太湖片。感兴趣的读者可以对比行政区划地图与《中国语言地图集》,非常有意思。\n理解了这些因素,就能理解很多政策和改革。比如,随着经济活动和人口集聚,需要打破现有的行政边界,在更大范围内提供无缝对接的标准化公共服务,所以就有了各种都市圈的规划,有些甚至上升到了国家战略,比如长三角一体化、京津冀一体化、粤港澳大湾区等。再比如,地理阻隔不利沟通,但随着基础设施互联互通,行政区划也可以简化,比如撤县设区。此外,理解了方言和文化的多样性,也就理解了推广普通话和共同的文化历史教育对维护国家统一的重要性。\n当然,无论是人口密度、地理还是语言文化,都只是为理解行政区划勾勒了一个大致框架,无法涵盖所有复杂情况。其一,人口密度变化频繁,但行政区域的调整要缓慢得多。虽然一些人口流入地可以“撤县建区”来扩张城市,但人口流出地却很少因人口减少去裁撤行政单位,一般只是合并一些公共设施来降低成本,比如撤并农村中小学。其二,古代行政区划除“随山川形变”外,也遵循“犬牙交错”原则,即为了政治稳定需要,人为打破自然边界,不以天险为界来划分行政区,防止地方势力依天险制造分裂。元朝在这方面走了极端,设立的行省面积极大,几乎将主要天险完全消融在各行省内部,但效果并不好。其三,方言与文化区域经常被行政区划割裂。比如客家话虽是主要方言,但整个客家话大区被江西、福建、广东三省分割。再比如有名的苏南、苏北之分:苏州、无锡、常州本和浙江一样同属吴语区,却与讲江淮官话的苏北一道被划进了江苏省。\n行政交界地区的经济发展 # 我国经济中有个现象:处在行政交界(尤其是省交界处)的地区,经济发展普遍比较落后。省级的陆路交界线共66条,总长度5.2万公里,按边界两侧各15公里计算,总面积约156万平方公里,占国土面积的六分之一。然而,在2012年592个国家扶贫开发工作重点县中,却有超过一半位于省交界处,贫困发生率远高于非边界县。 1515 这一俗称“三不管地带”的现象,也可以用公共物品规模效应和边界的理论来解释。首先,一省之内以省会为政治经济中心,人口最为密集,公共物品的规模经济效应最为显著。但几乎所有省会(除南京和西宁外)无一临近省边界,这种地理距离限制了边界地区获取公共资源。其次,省边界的划分与地理条件相关。诸多省界县位于山区,坡度平均要比非省界县高35%,不利于经济发展,比如山西、河北边界的太行山区,江西、福建边界的武夷山区,湖北、河南、安徽边界的大别山区等。再次,省界划分虽与方言和地方文化有关,但并不完全重合。一省之内主流文化一般集中在省会周围,而省界地区往往是本省的非主流文化区,其方言也有可能与主流不同。比如江西、福建、广东交界处的客家话区,与三省主流的赣语、闽语、粤语都不相同。再比如安徽北部,基本属于河南、山东一脉的中原官话区,与省内主流的江淮官话不同。这些边界地区,在本省之内与主流文化隔阂,而与邻省同文化区的交流又被行政边界割裂,不利于经济发展。 1616 这些因素在民国时期已存在,所以“三不管地带”才为革命时期的中国共产党提供了广阔空间。家喻户晓的革命圣地井冈山,就位于湖南、江西交界处的罗霄山脉之中。其他很多著名的革命根据地也在省界处,比如陕甘宁边区、晋察冀边区、鄂豫皖边区、湘鄂赣边区等。红军长征中非常重要的“四渡赤水”,就发生在川黔滇边界的赤水河地区。 1717 从公共物品角度看,边界地区首先面临的是基础设施如道路网络的不足。20世纪八九十年代,省边界处的“断头路”并不罕见。1992年我从内蒙古乘车到北京,途经山西和河北,本来好好的路,到了省界处路况就变差,常常要绕小道。若是晚间,还有可能遇到“路霸”。即使到了2012年,路网交通中的“边界效应”(省界地区路网密度较低)依然存在,虽然比以前改善了很多。即使在排除了经济发展、人口密度、地形等因素之后,“边界效应”也还是存在的,不过只限于由省政府投资的高速公路和省道中,在由中央政府投资的国道和铁路中则不存在,可见省政府不会把有限的资源优先配置到边界地区。 1818 随着经济发展和我国基础设施建设的突飞猛进,如今省界处的交通已不再是大问题。\n另一个曾长期困扰边界公共治理的问题是环境污染,尤其是跨省的大江、大河、大湖,比如淮河、黄河、太湖等流域的污染。这是典型的跨区域外部性问题。直到中央在2003年提出“科学发展观”,并且在“十五”和“十一五”规划中明确了降低水污染的具体目标之后,水质才开始显著改善。但省界处的问题依然没有完全解决。一些省份把水污染严重的企业集中到了本省边缘的下游区域,虽然本省的平均污染水平降低了,下游省份的污染却加重了。 1919 跨区域外部性问题可以通过跨区域的共同上级来协调,这也是为什么行政区域不仅要做横向划分,也要做纵向的上下级划分。下级之间一旦出现了互相影响、难以单独决断的事务,就要诉诸上级决策。反过来看,各级政府的权力都是由上级赋予的,而下放哪些权力也和外部性有关。在外部性较小的事务上,下级一般会有更大决策权。虽然从原则上说,上级可以干预下级的所有事务,但在现实工作中,干预与否、干预到什么程度、能否达到干预效果,都受制于公共事务的外部性大小、规模经济、跨地区协调的难度等。\n行政边界影响经济发展,地方保护主义和市场分割现象今天依然存在,尤其在生产要素市场上,用地指标和户籍制度对土地和人口流动影响很大。从长期看,消除这种现象需要更深入的市场化改革。但在中短期内,调整行政区划、扩大城市规模乃至建设都市圈也能发挥作用。目前的行政区划继承自古代社会和计划经济时期,并不能完全适应工业与现代服务业急速的发展和集聚。而且在像中国这样一个地区差异极大的大国,建设产品和要素的全国统一大市场必然是个长期过程,难免要先经过区域性整合。\n区域性整合的基本单位是城市,但在城市内部,首先要整合城乡。在市管县体制下,随着城市化的发展,以工业和服务业为经济支柱的市区和以农业为主的县城之间,对公共服务需求的差别会越来越大。调和不同需求、利用好有限的公共资源,就成了一大难题。改革思路有二:一是加强县的独立性和自主性,弱化其与市区的联系。第二章将展开讨论这方面的改革,包括扩权强县、撤县设市、省直管县等。二是扩张城市,撤县设区。1983—2015年,共有92个地级市撤并了134个县或县级市。 2020 比如北京市原来就8个区,现在是16个,后来的8个都是由县改区,如通州区(原通县)和房山区(原房山县)。上海现有16个市辖区,青浦、奉贤、松江、金山等区也是撤县设区改革的结果。\n撤县设区扩张了城市面积,整合了本地人口,将县城很多农民转化为了市民,有利于充分利用已有的公共服务,发挥规模收益。很多撤县设区的城市还吸引了更多外来人口。 2121 这些新增人口扩大了市场规模,刺激了经济发展。撤县设区也整合了对城市发展至关重要的土地资源。随着区县合并,市郊县的大批农村土地被转为城市建设用地,为经济发展提供了更大空间。但在这个过程中,由于城乡土地制度大不相同,产生了很多矛盾和冲突,之后章节会详细讨论。\n第三节 复杂信息 # 中国有句老话叫“山高皇帝远”,常用来形容本地当权者恣意妄为、肆无忌惮,因为朝廷不知情,也就管不了,可见信息对权力的影响。行之有效的管理,必然要求掌握关键信息。然而信息复杂多变,持续地收集和分析信息需要投入大量资源,代价不小。所以有信息优势的一方,或者说能以更低代价获取信息的一方,自然就有决策优势。\n信息与权力 # 我国政府各层级之间的职能基本同构,上级领导下级。原则上,上级对下级的各项工作都有最终决策权,可以推翻下级所有决定。但上级不可能掌握和处理所有信息,所以很多事务实际上由下级全权处理。即使上级想干预,常常也不得不依赖下级提供的信息。比如上级视察工作,都要听取下级汇报,内容是否可靠,上级不见得知道。如果上级没有独立的信息来源,就可能被下级牵着鼻子走。\n所以上级虽然名义上有最终决定权,拥有“形式权威”,但由于信息复杂、不易处理,下级实际上自主性很大,拥有“实际权威”。维护两类权威的平衡是政府有效运作的关键。若下级有明显信息优势,且承担主要后果,那就该自主决策。若下级虽有信息优势,但决策后果对上级很重要,上级就可能多干预。但上级干预可能会降低下级的工作积极性,结果不一定对上级更有利。 2222 以国企改革为例。一家国企该由哪一级政府来监管?该是央企、省属国企,还是市属国企?虽然政府名义上既管辖本级国企,也管辖下级国企,但下级国企实际上主要由下级政府管辖。在国企分级改革中,获取信息的难易程度是重要的影响因素。如果企业离上级政府很远,交通不便,且企业间差异又很大,上级政府就很难有效处理相关信息,所以更可能下放管辖权。但如果企业有战略意义,对上级很重要,那无论地理位置如何,都由上级管辖。 2323 在实际工作中,“上级干预”和“下级自主”之间,没有黑白分明的区别,是个程度问题。工作总要下级来做,不可能没有一点自主性;下级也总要接受上级的监督和评价,不可能完全不理上级意见。但无论如何,信息优势始终是权力运作的关键要素。下级通常有信息优势,所以如果下级想办某件事,只要上级不明确反对,一般都能办,即使上级反对也可以变通着干,所谓“县官不如现管”;如果下级不想办某事,就可以拖一拖,或者干脆把皮球踢给上级,频繁请示,让没有信息优势的上级来面对决策的困难和风险,最终很可能就不了了之。即使是上级明确交代的事情,如果下级不想办,那办事的效果也会有很大的弹性,所谓“上有政策,下有对策”。\n实际权威来自信息优势,这一逻辑也适用于单位内部。单位领导虽有形式权威和最终决策权,但具体工作大都要求专业知识和经验,所以专职办事的人员实际权力很大。比如古代的官和吏,区别很大。唐朝以后,“官”基本都是科举出身的读书人,下派到地方任职几年,大多根本不熟悉地方事务,所以日常工作主要依靠当地的“吏”。这些生于斯长于斯的吏,实际权力大得很,是地方治理的支柱,不但不受官员调动的影响,甚至不受改朝换代的影响。清人朱克敬《瞑庵杂识》中有一位吏的自我定位如下:“凡属事者如客,部署如车,我辈如御,堂司官如骡,鞭之左右而已。”意思是说衙门就像车,来办事就像坐车,当官的是骡子,我们才是车把式,决定车的方向。 2424 信息复杂性和权力分配是个普遍性的问题,不是中国特色。在各国政府中,资深技术官僚都有信息优势,在诸多事务上比频繁更换的领导实权更大。比如英国的内阁部门长官随内阁选举换来换去,而各部中工作多年的常务次官(permanent secretary)往往更有实权。著名的英国政治喜剧《是,大臣》(Yes, Minister)正是讲述新上任的大臣被常务次官耍得团团转的故事。\n上节讨论过的限制公共服务范围的诸多因素,如人口密度、地理屏障、方言等,也可以视作收集信息的障碍。因此信息不仅可以帮助理解上下级的分权,也可以帮助理解平级间的分权。行政区划,不仅受公共服务规模经济的影响,也受获取信息比较优势的影响。\n信息获取与隐瞒 # 获取和传递信息需要花费大量时间精力,上级要不断向下传达,下级要不断向上汇报,平级要不断沟通,所以体制内工作的一大特点就是“文山会海”。作为信息载体的文件和会议也成了权力的载体之一,而一套复杂的文件和会议制度就成了权力运作不可或缺的部分。\n我国政府上下级之间与各部门之间的事权,大都没有明确的法律划分,主要依赖内部规章制度,也即各类文件。为了减少信息传递的失真和偏误,降低传递成本,文件类型有严格的区分,格式有严格的规范,报送有严格的流程。按照国务院2012年最新的《党政机关公文处理工作条例》(以下简称《条例》),公文共分15种,既有需要下级严格执行的“决定”和“命令”,也有可以相对灵活处理的“意见”和“通知”,还有信息含量较低的“函”和“纪要”等。每种公文的发文机关、主送机关、紧急程度以及密级,都有严格规定。为了防止信息泛滥,公文的发起和报送要遵循严格的流程。比如说,《条例》规定,“涉及多个部门职权范围内的事务,部门之间未协商一致的,不得向下行文”,这也是为了减少产生无法落实的空头文件。\n会议制度也很复杂。什么事项该上什么会,召集谁来开会,会议是以讨论为主还是需要做出决定,这些事项在各级政府中都有相应的制度。比如在中央层面,就有中央政治局常委会会议、中央政治局会议、中央工作会议、中央委员会全体会议、党的全国代表大会等。\n因为关键信息可能产生重大实际影响,所以也可能被利益相关方有意扭曲和隐瞒,比如地方的GDP数字。政府以经济建设为中心,国务院每年都有GDP增长目标,所以GDP增长率的高低也是衡量地方官员政绩的重要指标。 2525 绝大部分省份公布的增长目标都会高于中央,而绝大多数地市的增长目标又会高于本省。比如2014年中央提出的增长目标是7.5%,但所有省设定的目标均高于7.5%,平均值是9.7%。到了市一级,将近九成的市级目标高于本省,平均值上涨到10.6%。 2626 这种“层层加码”现象的背后,既有上级层层施压和摊派的因素,也有下级为争取表现而主动加压的因素。但这些目标真能实现么?2017—2018年两年,不少省份(如辽宁、内蒙古、天津等)主动给GDP数字“挤水分”,幅度惊人,屡见报端。\n因为下级可能扭曲和隐瞒信息,所以上级的监督和审计就非常必要,既要巡视督察工作,也要监督审查官员。但监督机制本身也受信息的制约。我举两个例子,第一个是国家土地督察制度。城市化过程中土地价值飙升,违法现象(越权批地、非法占用耕地等)层出不穷,且违法主体很多是地方政府或相关机构,其下属的土地管理部门根本无力防范和惩处。2006年,中央建立国家土地督察制度,在国土资源部(现改为自然资源部)设立国家土地总督察(现改为国家自然资源总督察),并向地方派驻国家土地监督局(现改为国家自然资源督察局)。这一督察机制总体上遏制了土地违法现象。但中央派驻地方的督察局只有9个,在督察局所驻城市,对土地违法的震慑和查处效果比其他城市更强,这种明显的“驻地效应”折射出督察机制受当地信息制约之影响。 2727 第二个例子是水污染治理。与GDP数字相比,水污染指标要简单得多,收集信息也不复杂,所以中央环保部门早在20世纪90年代就建立了“国家地表水环境监测系统”,在各主要河流和湖泊上设置了水质自动监测站,数据直报中央。但在20世纪90年代,经济发展目标远比环保重要,所以这些数据主要用于科研而非环保监督。2003年,中央提出“科学发展观”,并且在“十五”和“十一五”规划中明确了降低水污染的具体目标,地方必须保证达标。虽然数据直报系统杜绝了数据修改,但并不能完全消除信息扭曲。一个监测站只能监测上游下来的水,监测不到本站下游的水,所以地方政府只要重点降低监测站上游的企业排污,就可以改善上报的污染数据。结果与监测站下游的企业相比,上游企业的排放减少了近六成。虽然总体污染水平降低了,但污染的分布并不合理,上游企业承担了过度的环保成本,可能在短期内降低了其总体效益。 2828 正因为信息复杂多变,模糊不清的地方太多,而政府的繁杂事权又没有清楚的法律界定,所以体制内的实际权力和责任都高度个人化。我打个比方来说明规则模糊不清和权力个人化之间的关系。大学老师考核学生一般有两种方式:考试或写论文。若考卷都是标准化的选择题,那老师虽有出题的权力,但不能决定最后得分。但若考卷都是主观题,老师给分的自由度和权力就大一些。若是研究生毕业论文,不存在严格的客观判断标准,导师手中的权力就更大了,所以研究生称导师为“老板”,而不会称其他授课教师为“老板”。\n如果一件事的方方面面都非常清楚,有客观评价的标准,那权力分配就非常简单:参与各方立个约,权责利都协商清楚,照办即可。就像选择题的答题卡一样,机器批阅,没有模糊空间,学生考100分就是100分,老师即使不喜欢也没有办法。但大多数事情都不可能如此简单清楚,千头万绪的政府工作尤其如此:一件事该不该做?要做到什么程度?怎么样算做得好?做好了算谁的功劳?做砸了由谁负责?这些问题往往没有清楚的标准。一旦说不清楚,谁说了算?所谓权力,实质就是在说不清楚的情况下由谁来拍板决策的问题。 2929 如果这种说不清的情况很多,权力就一定会向个人集中,这也是各地区、各部门“一把手负责制”的根源之一,这种权力的自然集中可能会造成专权和腐败。\n因为信息复杂,不可信的信息比比皆是,而权力和责任又高度个人化,所以体制内的规章制度无法完全取代个人信任。上级在提拔下级时,除考虑工作能力外,关键岗位上都要尽量安排信得过的人。\n第四节 激励相容 # 如果一方想做的事,另一方既有意愿也有能力做好,就叫激励相容。政府内部不仅要求上下级间激励相容,也要求工作目标和官员自身利益之间激励相容。本节只讨论前者,第三章再讨论官员的激励。\n上级政府想做的事大概分两类,一类比较具体,规则和流程相对明确,成果也比较容易衡量和评价。另一类比较抽象和宽泛,比如经济增长和稳定就业,上级往往只有大致目标,需要下级发挥主动性和创造性调动资源去达成。对于这两类事务,事权划分是不同的。\n垂直管理 # 在专业性强、标准化程度高的部门,具体而明确的事务更多,更倾向于垂直化领导和管理。比如海关,主要受上级海关的垂直领导,所在地政府的影响力较小。这种权力划分符合激励相容原则:工作主要由系统内的上级安排,所以绩效也主要由上级评价,而无论是职业升迁还是日常福利,也都来自系统内部。\n还有一些部门,虽然工作性质也比较专业,但与地方经济密不可分,很多工作需要本地配合,如果完全实行垂直管理可能会有问题。比如工商局,在1999年的改革中,“人财物”收归省级工商部门统管,初衷是为了减少地方政府对工商部门的干扰,打破地方保护,促进统一市场形成。但随着市场经济的蓬勃发展和多元化,工商局的行政手段的效力一直在减弱,而垂直管理带来的激励不相容问题也越来越严重。工商工作与所在地区密不可分,但因为垂直管理,当地政府对工商系统的监督和约束都没有力度。在一系列事故尤其是2008年震动全国的“毒奶粉”事件之后,2011年中央再次改革,恢复省级以下工商部门的地方政府分级管理体制,经费和编制由地方负担,干部升迁改为地方与上级工商部门双重管理,以地方管理为主。 3030 2018年机构改革后,工商局并入市场监督管理局,由地方政府分级管理。\n所有面临双重领导的部门,都有一个根本的激励机制设计问题:到底谁是主要领导?工作应该向谁负责?假如所有领导的目标和利益都一样,激励机制就不重要。在计划经济时代,部门间没什么大的利益冲突,所以对干部进行意识形态教化相对有效,既能形成约束,也有利于交流和推进工作。但在市场经济改革之后,利益不仅大了,而且多元化了,部门之间、上下级之间的利益冲突时有发生,“统一思想”和“大局观”虽依然重要,但只讲这些就不够了,需要更加精细的激励机制。最起码,能评价和奖惩工作业绩的上级,能决定工作内容的上级,受下级工作影响最大的上级,应该尽量是同一上级。\n当上下级有冲突的时候,改革整个部门的管理体制只是解决方式之一,有时“微调”手段也很有效。拿环保来说,在很长一段时间内,上级虽重视环境质量,但下级担心环保对经济发展的负面影响。上下级间的激励不相容,导致政策推行不力,环境质量恶化。 3131 但随着技术进步,中央可以直接监控污染企业。2007年,国家环保总局把一些重污染企业纳入国家重点监控企业名单,包括3 115家废水排放企业,3 592家废气排放企业,以及658家污水处理厂。这些企业都要安装一套系统,自动记录实时排放数据并直接传送到国家环保监控网络。这套技术系统限制了数据造假,加强了监管效果,大幅降低了污染,但没有从根本上改变环保管理体制,日常执法依然由地方环保部门负责。 3232 随着中央越来越重视环保,跨地区协调的工作也越来越多,环保部门的权力也开始上收。2016年,省级以下环保机构调整为以省环保厅垂直领导为主,所在地政府的影响大大降低。这次调整吸取了工商行政管理体制改革中的一些教训。比如在工商部门垂直领导时期,不仅市级领导干部由省里负责,市级以下的领导也基本由省里负责,这就不利于市县上下级的沟通和制约。所以在环保体制改革中,县环保局调整为市局的派出分局,由市局直接管理,领导班子也由市局任免。 3333 地方管理 # 对于更宏观的工作,比如发展经济,涉及方方面面,需要地方调动各种资源。激励相容原则要求给地方放权:不仅要让地方负责,也要与地方分享发展成果;不仅要能激励地方努力做好,还要能约束地方不要搞砸,也不要努力过头。做任何事都有代价,最优的结果是让效果和代价匹配,而不是不计代价地达成目标。若不加约束,地方政府要实现短期经济高速增长目标并不难,可以尽情挥霍手中的资源,大肆借债、寅吃卯粮来推高增长数字,但这种结果显然不是最优的。\n激励相容原则首先要求明确地方的权利和责任。我国事权划分的一大特点是“属地管理”:一个地区谁主管谁负责,以行政区划为权责边界。这跟苏联式计划经济从上到下、以中央部委为主调动资源的方式不同。属地管理兼顾了公共服务边界问题和信息优势问题,同时也给了地方政府很大的权力,有利于调动其积极性。1956年,毛泽东在著名的《论十大关系》中论述“中央和地方的关系”时就提到了这一点:“我们的国家这样大,人口这样多,情况这样复杂,有中央和地方两个积极性,比只有一个积极性好得多。我们不能像苏联那样,把什么都集中到中央,把地方卡得死死的,一点机动权也没有。”\n其次是权力和资源的配置要制度化,不能朝令夕改。无论对上级还是对下级,制度都要可信,才能形成明确的预期。制度建设,一方面是靠行政体制改革(比如前文中的工商和环保部门改革)和法制建设,另一方面是靠财政体制改革。明确了收入和支出的划分,也就约束了谁能调用多少资源,不能花过头的钱,也不能随意借债,让预算约束“硬”起来。\n来自外部的竞争也可以约束地方政府。如果生产要素(人、财、物)自由流动,“用脚投票”,做得不好的地方就无法吸引资金和人才。虽然地方政府不是企业,不至于破产倒闭,但减少低效政府手中的资源,也可以提高整体效率。\n小结:事权划分三大原则 # 第二至第四节讨论了事权划分的三大原则:公共服务的规模经济、信息复杂性、激励相容。这三种视角从不同角度出发,揭示现象的不同侧面,但现象仍然是同一个现象,所以这三种视角并不冲突。比如行政区划,既与公共服务的规模有关,也和信息管理的复杂性有关,同时又为激励机制设定了权责边界。再比如基础设施建设,既能扩展公共服务的服务范围,又能提高信息沟通效率,还可以方便人、财、物流通,增强各地对资源的竞争,激励地方励精图治。\n三大原则的共同主题是处理不同群体的利益差别与冲突。从公共服务覆盖范围角度看,不同人对公共服务的评价不同,享受该服务的代价不同,所以要划分不同的行政区域。从信息复杂性角度看,掌握不同信息的人,看法和判断不同,要把决策权交给占据信息优势的一方。从激励相容角度看,上下级的目标和能力不同,所以要设立有效的机制去激励下级完成上级的目标。假如不同群体间完全没有差别和冲突,那事权如何划分就不重要,对结果影响不大。完全没有冲突当然不可能,但如果能让各个群体对利益和代价的看法趋同,也能消解很多矛盾,增强互信。所以国家对其公民都有基本的共同价值观教育,包括历史教育和国家观念教育。而对官员群体,我国自古以来就重视共同价值观的培养与教化,今天依然如此。\n上述三个原则虽不足以涵盖现实中所有的复杂情况,但可以为理解事权划分勾勒一个大致框架,帮助我们理解目前事权改革的方向。2013年,党的十八届三中全会通过了《中共中央关于全面深化改革若干重大问题的决定》,其中对事权改革方向的阐述就非常符合这些原则:“适度加强中央事权和支出责任,国防、外交、国家安全、关系全国统一市场规则和管理等作为中央事权;部分社会保障、跨区域重大项目建设维护等作为中央和地方共同事权,逐步理顺事权关系;区域性公共服务作为地方事权。” 3434 2016年,《国务院关于推进中央与地方财政事权和支出责任划分改革的指导意见》发布,将十八届三中全会的决定进一步细化,从中可以更清楚地看到本章讨论的三大原则:“要逐步将国防、外交、国家安全、出入境管理、国防公路、国界河湖治理、全国性重大传染病防治、全国性大通道、全国性战略性自然资源使用和保护等基本公共服务确定或上划为中央的财政事权……要逐步将社会治安、市政交通、农村公路、城乡社区事务等受益范围地域性强、信息较为复杂且主要与当地居民密切相关的基本公共服务确定为地方的财政事权……要逐步将义务教育、高等教育、科技研发、公共文化、基本养老保险、基本医疗和公共卫生、城乡居民基本医疗保险、就业、粮食安全、跨省(区、市)重大基础设施项目建设和环境保护与治理等体现中央战略意图、跨省(区、市)且具有地域管理信息优势的基本公共服务确定为中央与地方共同财政事权,并明确各承担主体的职责。”\n既然是改革的方向,也就意味着目前尚有诸多不完善之处。比如涉及国防和国家安全的事务,原则上都应该主要或完全由中央负责,但国际界河(主要在东北和西南)和海域的管理与治理目前仍主要由地方负责。再比如养老和医疗保险,对形成全国统一的劳动力市场非常重要,应由中央为主管辖,但目前的管理相当碎片化。而对于本该属于地方的事权,中央虽应保留介入的权力,但过分介入往往会造成地方退出甚至完全放手,效果不一定好。如何从制度上限制过度介入,真正理顺事权关系,也需要进一步改革。\n第五节 招商引资 # 地方政府的权力非常广泛。就发展经济而言,其所能调动的资源和采取的行动远远超过主流经济学强调的“公共服务”或“公共物品”范围。地方政府不仅可以为经济发展创造环境,它本身就是经济发展的深度参与者,这一点在招商引资过程中体现得淋漓尽致。招商引资不仅是招商局的部门职能,也是以经济建设为中心的地方政府的核心任务,是需要调动所有资源和手段去实现的目标。很多地方政府都采用“全民招商”策略,即几乎所有部门(包括教育和卫生部门)都要熟悉本地招商政策,要在工作和社交中注意招商机会。\n要招商,就要有工业园区或产业园区,这涉及土地开发、产业规划、项目运作等一系列工作,第二章至第四章会详细解释。这里只要了解:地方政府是城市土地的所有者,为了招商引资发展经济,会把工业用地以非常优惠的价格转让给企业使用,并负责对土地进行一系列初期开发,比如“七通一平”(通电、通路、通暖、通气、给水、排水、通信,以及平整场地)。\n对于规模较大的企业,地方通常会给予很多金融支持。比如以政府控制的投资平台入股,调动本地国企参与投资,通过各种方式协助企业获得银行贷款,等等。对一些业务比较复杂、所在行业管制较严的企业,地方也会提供法律和政策协助。比如一些新能源汽车企业,并没有生产汽车的牌照,而要获取牌照(无论是新发,还是收购已有牌照)很不容易,需要和工信部、发改委等中央部门打交道,这其中企业的很多工作都有地方政府的协助。与企业相比,地方政府更加熟悉部委人脉和流程。再比如近年兴起的网络安全和通信服务行业,都受国家管制,需要地方协助企业去获得各类许可。还有些行业对外商投资有准入限制,也需要地方政府去做很多协助落地的工作。\n地方政府还可以为企业提供补贴和税收优惠。补贴方式五花八门,比如研发补贴和出口补贴等。常见的税收优惠如企业所得税的“三免三减半”,即对新开业企业头三年免征所得税,之后三年减半征收。 3535 还有一些针对个人的税收优惠政策。比如对于规模很大的企业,地方政府常常对部分高管的个人收入所得税进行返还。我国高收入人群的所得税边际税率很高,年收入超过96万元的部分税率是45%,所以税收返还对高管个人来说有一定吸引力。对企业高管或特殊人才,若有需要,地方政府也会帮助安排子女入学、家人就医等。\n创造就业是地方经济工作的重点,也是维护社会稳定不可或缺的条件。对新设的大中型企业,地方政府会提供很多招工服务,比如协助建设职工宿舍、提供公共交通服务等。大多数城市还对高学历人才实行生活或住房补贴。\n总的来说,对企业至关重要的生产要素,地方政府几乎都有很强的干预能力。其中土地直接归政府所有,资金则大多来自国有银行主导的金融体系和政府控制的其他渠道,比如国有投融资平台。对于劳动力,政府控制着户口,也掌握着教育和医疗等基本服务的供给,还掌握着土地供应,直接影响住房分配。而生产中的科技投入,也有相当大一部分来自公立大学和科研院所。除此之外,地方政府还有财税政策、产业政策、进出口政策等工具,都可能对企业产生重大影响。\n这种“混合经济”体系,不是主流经济学教科书中所说的政府和市场的简单分工模式,即政府负责提供公共物品、市场主导其他资源配置;也不是简单的“政府搭台企业唱戏”模式。而是政府及其各类附属机构(国企、事业单位、大银行等)深度参与大多数生产和分配环节的模式。在我国,想脱离政府来了解经济,是不可能的。\n结语 # 本章讨论了事权划分的三种理论:公共服务的规模经济与边界、信息复杂性、激励相容。这些理论为理解政府职能分工勾勒了一个大致框架,虽各有侧重,但彼此相通。社会科学的理论,刻意追求标新立异没有意义。社会现象非常复杂,单一理论只能启示某个侧面,要从不同理论中看到共同之处,方能融会贯通。\n地方政府不止提供公共服务,也深度参与生产和分配。其间得失,之后的章节会结合具体情况展开讨论。若无视这种现实、直接套用主流经济学中“有限政府”的逻辑,容易在分析中国问题时产生扭曲和误解。不能脱离政府来谈经济,是理解中国经济的基本出发点。实事求是了解现状,才能依托现实提炼理论,避免用理论曲解现实,也才能真正深入思考政府在几十年来经济发展过程中扮演的角色。\n本章讨论的事权划分,是理解政府间资源分配的基础。决定了干哪些事,才能决定用哪些资源。所以下一章所讨论的政府财权和财力的划分,以本章的事权划分为基础。财权领域虽改革频频,但事权划分却相对稳定,因为其决定因素也相对稳定:地理和语言文化边界长期稳定,信息和激励问题也一直存在。\n扩展阅读 # 关于中国政府和政治,美国布鲁金斯学会资深专家李侃如的著作《治理中国:从革命到改革》(2010)是很好的入门读物。这本书介绍了中国政治的基本历史遗产及现状,阐释了其演变逻辑,可读性很强。但该书成书于1995年(英文原名为Governing China: From Revolution Through Reform),修订于2003年(2010年是中译本的出版年份),没有涉及最近十多年的重大改革。作为补充,清华大学景跃进、复旦大学陈明明、中山大学肖滨合编的《当代中国政府与政治》(2016)是同类教材中可读性较强的一部,内容比较全面,对党政关系、政法系统、宣传系统、军事系统都有介绍。\n我国政府的运作模式有深厚的历史渊源。复旦大学历史地理学家葛剑雄教授的著作《统一与分裂:中国历史的启示》(2013)深入浅出地描述和分析了中国历史上统一和分裂的现象,是很好的普及读物。本章阐释的所有理论在该书中都能找到有趣的佐证。已故哈佛大学历史学家孔飞力的杰作《叫魂》(2014)也与本章内容相关。该书讲述了乾隆盛世年间的一场荒诞事故:本是某些地方流民和乞丐的零星骗局,却被乾隆解读成了要颠覆朝廷的大阴谋,于是发动了全国大清查,造成了朝野和民间的大恐慌,最终却在无数冤案之后不了了之。该书很多史料来自御笔亲批的奏折,从中尤其可以看到信息之关键:诸多信息都在奏折的来往中被扭曲和误解,最终酿成大乱。\n11 此处略去人口极少的首都华盛顿特区。\n22 这三项原则的简单论述,见财政部前部长楼继伟的著作(2013)。\n33 1959年,人大常委会通过《关于直辖市和较大的市可以领导县、自治县的决定》,开始市领导县的改革。1982年,中央发布《改革地区体制,实行市领导县体制的通知》,1983年开始在全国试行。详情见清华大学景跃进、复旦大学陈明明、中山大学肖滨合编的教科书(2016)。\n44 数据来自复旦大学历史地理学家葛剑雄的著作(2013)。\n55 有一种情况例外,即一些重要地区的书记也是上级党委常委,如省会城市的书记也是省委常委。\n66 省委或市委的直属机构一般包括办公厅、纪委、政法委、组织部、宣传部、统战部、机关和政策研究部门。在组织人事、政法、教育宣传等领域内,党委有独立于政府的职能部门,但在财经领域,党政之分通常并不重要。当然,在中央层面有一些直属小组,负责领导主要经济政策的制定,比如中央财经领导小组。\n77 这项原则来自美国布鲁金斯学会李侃如的观察,他的著作(2010)对中国政治和政府的观察与分析很有见地。\n88 斯坦福大学政治学教授福山在其著作(2014)中阐述了现代政治秩序的三大基石:政府、法治、民主。其中的“政府”,也就是脱离血缘关系、由专门人才主导的管理机构,起源于中国。香港中文大学金观涛和刘青峰的著作(2010)曾用“超稳定结构”来描述历经王朝更迭的中国古代社会,这一结构由经济、政治、意识形态三大子系统组成。具有统一意识形态的官僚和儒生,是该结构日常运作的关键,也是在结构破裂和王朝崩溃之后修复机制的关键。世界历史上,王朝崩溃并不罕见,但只有中国能在崩溃后不断修复和延续,历经千年。\n99 本节依据的理论由哈佛大学经济学教授阿尔伯托·阿莱西纳(Alberto Alesina)及其合作者在一系列开创性论文中提出并完善。相关的数学模型、经验证据、历史和现实案例,都收入在他们的书中(Alesina and Spolaore, 2003)。在本节的写作过程中,阿莱西纳教授于2020年5月23日突发疾病离世,享年63岁。这是现代政治经济学研究的重大损失。\n1010 这段人身攻击有些名气,大约是古今中外某些当权者辱骂知识分子的通用套路,大意如下:你智商要真高,怎么做不了官?你财商要真高,怎么那么穷?你们不过是些夸夸其谈之辈,地位不高还爱质疑上司,穷成那样还说富人的坏话,样子清高实则卑鄙,妄发议论,哗众取宠。俸禄吃不饱,家里没余粮,破衣烂衫,也配谈论朝堂大事?何况拓边打仗之事呢!(原文为:“挟管仲之智者,非为厮役之使也。怀陶朱之虑者,不居贫困之处。文学能言而不能行,居下而讪上,处贫而非富,大言而不从,高厉而行卑,诽誉訾议,以要名采善于当世。夫禄不过秉握者,不足以言治,家不满檐石者,不足以计事。儒皆贫羸,衣冠不完,安知国家之政,县官之事乎?何斗辟造阳也!”)\n1111 这条线的提出者是华东师范大学已故地理学家胡焕庸先生,故也被称为“胡焕庸线”。\n1212 广东省人口数量超过百万的县级单位有几十个,任何一个都比新疆的阿勒泰或哈密地区的总人口多。\n1313 数据来自复旦大学历史地理学家周振鹤的著作(2014)。下一段内容也多取材自该书。\n1414 地理和语言多样性之间的关系,来自布朗大学米哈洛普洛斯(Michalopoulos)的研究(2012)。\n1515 地理数据来自北京大学周黎安的著作(2017),贫困县数据来自上海财经大学唐为的论文(2019)。\n1616 本段中坡度的数据来自上海财经大学唐为的论文(2019)。广东外语外贸大学的高翔与厦门大学的龙小宁(2016)则指出,文化与本省主流不同的省界地区,经济发展相对落后。\n1717 关于革命根据地的内容,来自北京大学韩茂莉的著作(2015)。\n1818 关于道路密度的研究来自上海财经大学唐为的论文(2019)。\n1919 工业水污染向本省下游区域集中这个现象,来自香港大学蔡洪斌、北京大学陈玉宇和北卡罗来纳大学宫晴等人的论文(Cai, Chen and Gong, 2016)。\n2020 数据来自南开大学邵朝对、苏丹妮、包群等人的论文(2018)。\n2121 上海财经大学唐为和华东师范大学王媛的论文(2015)发现撤县设区会增加外来人口。\n2222 “形式权威”(formal authority)和“实际权威”(real authority)的理论,来自哈佛大学阿吉翁(Aghion)与诺贝尔经济学奖得主图卢兹大学梯若尔(Tirole)的论文(1997)。\n2323 关于信息和国企分级的关系,来自清华大学黄张凯、北京大学李力行、中国人民大学马光荣与世界银行徐立新等人的论文(Huang et al., 2017)。\n2424 中国自魏晋以来出现“官吏分途”,即官吏虽同在官僚机构共生共事,但在录用、晋升、俸禄等方面相互隔绝。对这一制度流变的分析描述及对理解当今官僚体系的启示,读者可参考斯坦福大学周雪光(2016)与北京大学周黎安(2016)的精彩文章。\n2525 2020年,受新冠肺炎疫情影响,国务院没有设定GDP增长目标,属20余年来首次。\n2626 数据及关于GDP指标“层层加码”现象的详细讨论,见北京大学厉行、刘冲、翁翕、周黎安等人的论文(Li et al., 2019)。\n2727 9个驻地是:北京、沈阳、上海、南京、济南、广州、武汉、成都、西安。关于“驻地效应”的检验,来自湖南商学院的陈晓红、朱蕾和中南大学汪阳洁等人的论文(2018)。\n2828 关于水质监测站与临近企业排放行为的讨论,来自香港科技大学何国俊、芝加哥大学王绍达和南京大学张炳等人的论文(He, Wang and Zhang, 2020)。\n2929 从经济学的合同理论出发,合同不可能事先写清楚所有情况,所以权力的实质就是在这些不确定情况下的决定权,可以称为“剩余控制权”(residual control rights)。以这种视角来分析权力的理论始于诺贝尔经济学奖得主、哈佛大学教授奥利弗·哈特(Oliver Hart),详见其著作(1995)。他用“剩余控制权”的思路去理解“产权”的本质,即在合同说不清楚的情况下对财产的处置权。而更加广泛的权力或权威,可以视为在各种模糊情况下的决定权。\n3030 参见2011年发布的《国务院办公厅关于调整省级以下工商质监行政管理体制加强食品安全监管有关问题的通知》,此文件现已失效。\n3131 除环保之外,其他领域内也有类似冲突:上级重视质量而下级重视成本,下级为了降低成本会不惜损害质量。这种冲突并不总是因为双方信息不对称。即便没有信息问题,也有能力问题。只要上级没有能力完全取代下级,这种冲突就可能会发生。此时放权会降低质量,收权又会降低工作效率,就需要妥协和平衡。哈佛大学哈特(Hart)、施莱弗(Shleifer)和芝加哥大学维什尼(Vishny)的论文(1997)详细探讨了这类问题。\n3232 关于国家重点监控企业的研究,来自南京大学张炳、四川大学陈晓兰、南京审计大学郭焕修等人的论文(Zhang, Chen and Guo, 2018)。2016年,重点监控企业已经增加到14 312家。\n3333 详见中央和国务院于2016年9月联合印发的《关于省以下环保机构监测监察执法垂直管理制度改革试点工作的指导意见》。\n3434 引文中的强调格式是我在引用时加上的,本书余下部分涉及此类情况皆如此。\n3535 我国实行分税制,按照中央和省的分税比例,企业所得税六成归中央,剩余部分由省、市、区县来分。企业所得税减免,一般都是减免企业所在地的地方留存部分。但对一些国家支持的行业,比如集成电路,企业的全部所得税都可以“三免三减半”。\n第二章 财税与政府行为 # 我很喜欢两部国产电视剧,一部是《大明王朝1566》,一部是《走向共和》。这两部剧有个共同点:开场第一集中,那些历史上赫赫有名的大人物们,出场都没有半点慷慨激昂或阴险狡诈的样子,反倒都在做世上最乏味的事——算账。大明朝的阁老们在算国库的亏空和来年的预算,李鸿章、慈禧和光绪则在为建海军和修颐和园的费用伤脑筋。然而算着算着,观众就看到了刀光剑影,原来所有的政见冲突和人事谋略,都隐在这一两一两银子的账目之中。\n要真正理解政府行为,必然要了解财税。道理很朴素:办事要花钱,如果没钱,话说得再好听也难以落实。要想把握政府的真实意图和动向,不能光读文件,还要看政府资金的流向和数量,所以财政从来不是一个纯粹的经济问题。党的十八届三中全会通过了《中共中央关于全面深化改革若干重大问题的决定》,明确了财政的定位和功能:“财政是国家治理的基础和重要支柱,科学的财税体制是优化资源配置、维护市场统一、促进社会公平、实现国家长治久安的制度保障。”\n我对政府和财政一直非常有兴趣,在美国读博士期间,修习了一整年的“公共财政”课程。第一学期学习财政收入,即与各类税收有关的理论和实证;第二学期学习财政支出,即各类政府支出的设计和实施效果。与我同级的博士生中,美国和非美国同学各占一半,但只有我一个非美国人选修了这门课,可能是因为涉及大量美国制度细节,外国人理解起来比较吃力,兴趣也不大。这门课程对我理解美国有很大帮助,但后来我到复旦大学讲授研究生课程“公共经济学研究”,备课时却很吃力,因为在美国学过的东西大都不能直接拿来用,跟中国情况很不一样,美国的教科书也不好用,要自己准备授课讲义。关键在于中美政府在经济运行中扮演的角色不一样,所做的事情也不一样,而财政体制要为政府事务服务,因此不能直接拿美国的财政理论往中国硬套。何况中国几十年来一直在改革,政府事务也经历了很多重大变革,财税体制自然也在随之不断变革。而财税体制变革牵一发动全身,影响往往复杂深远,我花了好几年边讲边学,才多少摸到了些门道。\n上一章介绍了政府的事权划分。而事权必然要求相应的财力支持,否则事情就办不好。所以从花钱的角度看,“事权与财力匹配”或者说“事权与支出责任匹配”这个原则,争议不大。但从预算收入的角度看,地方政府是否也应该有与事权相适应的收钱的权力,让“事权与财权匹配”,这个问题争议就大了。暂先不管这些争议,实际情况是地方政府的支出和收入高度不匹配。从图2-1可以看出,自1994年实行分税制以来,地方财政预算支出就一直高于预算收入。近些年地方预算支出占全国预算支出的比重为85%,但收入的占比只有50%—55%,入不敷出的部分要通过中央转移支付来填补。\n图2-1 地方公共预算收支占全国收支的比重\n数据来源:万得数据库。\n1994年分税制改革对政府行为和经济发展影响深远。本章第一节介绍这次改革的背景和过程,加深我们对央地关系的理解。第二节分析改革对地方经济发展方式的影响,介绍地方政府为了应对财政压力而发展出的“土地财政”,这是理解城市化和债务问题的基础。第三节讨论分税制造成的基层财政压力与地区间不平衡,并介绍相关改革。\n第一节 分税制改革 # 财政乃国之根本,新中国成立以来经历了艰辛复杂的改革历程。这方面专著很多,本章结尾的“扩展阅读”会推荐几种读物。本节无意追溯完整的改革历程,只从1985年开始谈。1985—1993年,地方政府的收入和支出是比较匹配的(图2-1),这种“事权和财权匹配”的体制对经济发展影响很大,也造成很多不良后果,催生了1994年的分税制改革。\n“财政包干”及后果:1985—1993年 # 如果要用一个词来概括20世纪80年代中国经济的特点,非“承包”莫属:农村搞土地承包,城市搞企业承包,政府搞财政承包。改革开放之初,很多人一时还无法立刻接受“私有”的观念,毕竟之前搞了几十年的计划经济和公有制。我国的基本国策决定了不能对所有权做出根本性变革,只能对使用权和经营权实行承包制,以提高工作积极性。财政承包始于1980年,中央与省级财政之间对收入和支出进行包干,地方可以留下一部分增收。1980—1984年是财政包干体制的实验阶段,1985年以后全面推行,建立了“分灶吃饭”的财政体制。 11 既然是承包,当然要根据地方实际来确定承包形式和分账比例,所以财政包干形式五花八门,各地不同。比较流行的一种是“收入递增包干”。以1988年的北京为例,是以1987年的财政收入为基数,设定一个固定的年收入增长率4%,超过4%的增收部分都归北京,没超过的部分则和中央五五分成。假如北京1987年收入100亿元,1988年收入110亿元,增长10%,那超过了4%增长的6亿元都归北京,其余104亿元和中央五五分成。\n广东的包干形式更简单,1988年上解中央14亿元,以后每年在此基础上递增9%,剩余的都归自己。1988年,广东预算收入108亿元,上解的14亿元不过只占13%。而且广东预算收入的增长速度远高于9%(1989年比上年增加了27%),上解负担实际上越来越轻,正因如此,广东对后来的分税制改革一开始是反对的。相比之下,上海的负担就重多了。上海实行“定额上解”,每年雷打不动上缴中央105亿元。1988年,上海的预算收入是162亿元,上解105亿元,占比65%,财政压力很大。\n财政承包制下,交完了中央的,剩下的都是地方自己的,因此地方有动力扩大税收来源,大力发展经济。 22 一种做法就是大力兴办乡镇企业。乡镇企业可以为地方政府贡献两类收入。第一是交给县政府的增值税(增值税改革前也叫产品税)。企业只要开工生产,不管盈利与否都得交增值税,规模越大缴税越多,所以县政府有很强的动力做大、做多乡镇企业。20世纪80年代中期以后,乡镇企业数量和规模迅速扩大,纳税总额也急速增长。在其发展鼎盛期的1995年,乡镇企业雇工人数超过6 000万。乡镇企业为地方政府贡献的第二类收入是上缴的利润,主要交给乡镇政府和村集体作为预算外收入。当时乡镇企业享受税收优惠,所得税和利润税都很低,1980年的利润税仅为6%,1986年上升到20%,所以企业税后利润可观,给基层政府创造了不少收入。 33 20世纪80年代是改革开放的起步时期,在很多根本性制度尚未建立、观念尚未转变之前,各类承包制有利于调动全社会的积极性,推动社会整体走出僵化的计划经济,让人们切实感受到收入增长,逐渐转变观念。但也正是因为改革转型的特殊性,很多承包制包括财政包干制注定不能持久。财政包干造成了“两个比重”不断降低:中央财政预算收入占全国财政预算总收入的比重越来越低,而全国财政预算总收入占GDP的比重也越来越低(图2-2)。不仅中央变得越来越穷,财政整体也越来越穷。\n图2-2 “两个比重”的变化情况\n数据来源:万得数据库。\n中央占比降低很容易理解。地方经济增长快,20世纪80年代物价涨得也快,所以地方财政收入相比于跟中央约定的固定分成比例增长更快,中央收入占比自然不断下降。至于预算总收入占GDP比重不断降低,原因则比较复杂。一方面,这跟承包制本身的不稳定有关。央地分成比例每隔几年就要重新谈判一次,若地方税收收入增长很快,下次谈判时可能会处于不利地位,落得一个更高的上缴基数和更吃亏的分成比例。为避免“鞭打快牛”,地方政府有意不让预算收入增长太快。另一方面,这也跟当时盛行的预算外收入有关。虽然地方预算内的税收收入要和中央分成,但预算外收入则可以独享。如果给企业减免税,“藏富于企业”,再通过其他诸如行政收费、集资、摊派、赞助等手段收一些回来,就可以避免和中央分成,变成可以完全自由支配的预算外收入。地方政府因此经常给本地企业违规减税,企业偷税漏税也非常普遍,税收收入自然上不去,但预算外收入却迅猛增长。1982—1992年,地方预算外收入年均增长30%,远超过预算内收入年均19%的增速。1992年,地方预算外收入达到了预算内收入的86%,相当于“第二财政”了。 44 “两个比重”的下降严重削弱了国家财政能力,不利于推进改革。经济改革让很多人的利益受损,中央必须有足够的财力去补偿,才能保障改革的推行,比如国企改革后的职工安置、裁军后的退伍军人转业等。而且像我国这样的大国,改革后的地区间发展差异很大(东中西部差异、城乡差异等),要创造平稳的环境,就需要缩小地区间基本公共服务差异,也需要中央财政的大量投入,否则连推行和保障义务教育都有困难。如果中央没钱,甚至要向地方借钱,那也就谈不上宏观调控的能力。正如时任财政部部长的刘仲藜所言:\n毛主席说,“手里没把米,叫鸡都不来”。中央财政要是这样的状态,从政治上来说这是不利的,当时的财税体制是非改不可了。……\n……财政体制改革决定里有一个很重要的提法是“为了国家长治久安”。当时的理论界对我讲,财政是国家行政能力、国家办事的能力,你没有财力普及义务教育、救灾等,那就是空话。因此,“国家长治久安”这句话写得是有深意的。 55 分税制改革与央地博弈 # 1994年的分税制改革把税收分为三类:中央税(如关税)、地方税(如营业税)、共享税(如增值税)。同时分设国税、地税两套机构,与地方财政部门脱钩,省以下税务机关以垂直管理为主,由上级税务机构负责管理人员和工资。这种设置可以减少地方政府对税收的干扰,保障中央税收收入,但缺点也很明显:两套机构导致税务系统人员激增,提高了税收征管成本,而且企业需要应付两套人马和审查,纳税成本也高。2018年,分立了24年的国税与地税再次开始合并。\n分税制改革中最重要的税种是增值税,占全国税收收入的1/4。改革之前,增值税(即产品税)是最大的地方税,改革后变成共享税,中央拿走75%,留给地方25%。假如改革前的1993年,地方增值税收入为100亿元,1994年改革后增长为110亿元,那么按照新税制,地方拿25%,收入一下就从1993年的100亿元下降到了27.5亿元。为防止地方收入急剧下跌,中央设立了“税收返还”机制:保证改革后地方增值税收入与改革前一样,新增部分才和中央分。1994年,地方可以拿到102.5亿元,而不是27.5亿元。因此改革后增值税占地方税收收入的比重没有急速下跌,而是缓慢地逐年下跌(图2-3)。\n图2-3 地方税收收入中不同税种所占比重\n数据来源:万得数据库。\n分税制改革,地方阻力很大。比如在财政包干制下过得很舒服的广东省,就明确表示不同意分税制。与广东的谈判能否成功,关系到改革能否顺利推行。时任财政部长刘仲藜和后来的财政部长项怀诚的回忆,生动地再现了当时的激烈博弈:\n(项怀诚)分税制的实施远比制订方案要复杂,因为它涉及地方的利益。当时中央财政收入占整个财政收入的比重不到30%,我们改革以后,中央财政收入占整个国家财政收入的比重达到55%,多大的差别!所以说,分税制的改革,必须要有领导的支持。为了这项改革的展开,朱镕基总理 66 亲自带队,用两个多月的时间先后走了十几个省,面对面地算账,深入细致地做思想工作……为什么要花这么大的力气,一个省一个省去跑呢,为什么要由一个中央常委、国务院常务副总理带队,一个省一个省去谈呢?因为只有朱总理去才能够和第一把手省委书记、省长面对面地交谈,交换意见。有的时候,书记、省长都拿不了主意的,后面还有很多老同志、老省长、老省委书记啊。如果是我们去,可能连面都见不上。\n(刘仲藜)与地方谈的时候气氛很紧张,单靠财政部是不行的,得中央出面谈。在广东谈时,谢飞 77 同志不说话,其他的同志说一条,朱总理立即给驳回去。当时有个省委常委、组织部长叫符睿(音) 88 就说:“朱总理啊,你这样说我们就没法谈了,您是总理,我们没法说什么。”朱总理就说:“没错,我就得这样,不然,你们谢飞同志是政治局委员,他一说话,那刘仲藜他们说什么啊,他们有话说吗?!就得我来讲。”一下就给驳回去了。这个场面紧张生动,最后应该说谢飞同志不错,广东还是服从了大局,只提出了两个要求:以1993年为基数、减免税过渡。 99 这段故事我上课时经常讲,但很多学生不太理解为何谈判如此艰难:只要中央做了决策,地方不就只有照办的份儿吗?“00后”一代有这种观念,不难理解。一方面,经过分税制改革后多年的发展,今天的中央政府确实要比20世纪80年代末和90年代初更加强势;另一方面,公众所接触的信息和看到的现象,大都已经是博弈后的结果,而缺少社会阅历的学生容易把博弈结果错当成博弈过程。其实即使在今天,中央重大政策出台的背后,也要经过很多轮的征求意见、协商、修改,否则很难落地。成功的政策背后是成功的协商和妥协,而不是机械的命令与执行,所以理解利益冲突,理解协调和解决机制,是理解政策的基础。\n广东当年提的要求中有一条,“以1993年为基数”。这条看似不起眼,实则大有文章。地方能从“税收返还”中收到多少钱,取决于它在“基年”的增值税收入,所以这个“基年”究竟应该是哪一年,差别很大。中央与广东的谈判是在1993年9月,所以财政部很自然地想把“基年”定为1992年。时光不能倒流,地方做不了假。可一旦把“基年”定在1993年,那到年底还有三个多月,地方可能突击收税,甚至把明年的税都挪到今年来收,大大抬高税收基数,以增加未来的税收返还。所以财政部不同意广东的要求。但为了改革顺利推行,中央最终做了妥协,决定在全国范围内用1993年做基年。这个决定立刻引发了第四季度的收税狂潮,根据项怀诚和刘克崮的回忆:\n(项怀诚)实际上,9月份以后确实出现了这些情况。在那一年,拖欠了多年的欠税,都收上来了。一些地方党政领导亲自出马,贷款交税,造成了1993年后4个月财政收入大幅度增加。\n(刘克崮)……分别比上年同期增长60%、90%、110%和150%,带动全年地方税收增长了50%~60% 1010 。 1111 由于地方突击征税,图2-3中增值税占地方税收的比重在1993年出现了明显反常的尖峰。这让1994年的财政陷入了困境,中央承诺的税收返还因为数额剧增而无法到位,预算迟迟做不出来。这些问题又经过了很多协商和妥协才解决。但从图2-3可以看到,当2001年推行所得税分成改革时,突击征税现象再次出现。\n企业所得税是我国的第二大税种,2018年占全国税收收入的23%。2002年改革之前,企业所得税按行政隶属关系上缴:中央企业交中央,地方企业交地方。地方企业比中央企业多,所以六成以上的所得税交给了地方。地方政府自然就有动力创办价高利大的企业,比如烟厂和酒厂,这些都是创税大户。20世纪90年代,各地烟厂、酒厂越办越多,很多地方只抽本地牌子的烟、喝本地牌子的啤酒,这种严重的地方保护主义不利于形成全国统一市场,也不利于缩小地区间的经济差距。在2002年的所得税改革中,除一些特殊央企的所得税归中央外,所有企业的所得税中央和地方六四分成(仅2002年当年为五五分)。为防止地方收入下降,同样也设置了税收返还机制,并把2001年的所得税收入定为返还基数。所以2001年的最后两个月,地方集中征税做大基数,财政部和国务院办公厅不得不强调“地方各级人民政府要从讲政治的高度,进一步提高认识,严格依法治税,严禁弄虚作假。2002年1月国务院有关部门将组织专项检查,严厉查处作假账和人为抬高基数的行为。对采取弄虚作假手段虚增基数的地方,相应扣减中央对地方的基数返还,依法追究当地主要领导和有关责任人员的责任。” 1212 但从图2-3中可以看出,2001年不正常的企业所得税收入依然非常明显。 1313 分税制是20世纪90年代推行的根本性改革之一,也是最为成功的改革之一。改革扭转了“两个比重”不断下滑的趋势(图2-2):中央占全国预算收入的比重从改革前的22%一跃变成55%,并长期稳定在这一水平;国家预算收入占GDP的比重也从改革前的11%逐渐增加到了20%以上。改革大大增强了中央政府的宏观调控能力,为之后应付一系列重大冲击(1997年亚洲金融危机、2008年全球金融危机和汶川地震等)奠定了基础,也保障了一系列重大改革(如国企改革和国防现代化建设)和国家重点建设项目的顺利实施。分税制也从根本上改变了地方政府发展经济的模式。\n第二节 土地财政 # 分税制并没有改变地方政府以经济建设为中心的任务,却减少了其手头可支配的财政资源。虽然中央转移支付和税收返还可以填补预算内收支缺口,但发展经济所需的诸多额外支出,比如招商引资和土地开发等,就需要另筹资金了。一方面,地方可以努力增加税收规模。虽然需要和中央分成,但蛋糕做大后,自己分得的收入总量也会增加。另一方面,地方可以增加预算外收入,其中最重要的就是围绕土地出让和开发所产生的“土地财政”。\n招商引资与税收 # 给定税率的情况下,想要增加税收收入,要么靠扩大税源,要么靠加强征管。分税制改革之后,全国预算收入占GDP的比重逐步上升(参见图2-2),部分原因可以归结为加强了征管力度,但更重要的原因是扩大了税源。 1414 改革前,企业的大多数税收按隶属关系上缴,改革后则变成了在所在地上缴,这自然会刺激地方政府招商引资。地方政府尤其青睐重资产的制造业,一是因为投资规模大,对GDP的拉动作用明显;二是因为增值税在生产环节征收,跟生产规模直接挂钩;三是因为制造业不仅可以吸纳从农业部门转移出的低技能劳动力,也可以带动第三产业发展,增加相关税收。\n因为绝大多数税收征收自企业,且多在生产环节征收,所以地方政府重视企业而相对轻视民生,重视生产而相对轻视消费。以增值税为例,虽然企业可以层层抵扣,最终支付税金的一般是消费者(增值税发票上会分开记录货款和税额,消费者支付的是二者之和),但因为增值税在生产环节征收,所以地方政府更加关心企业所在地而不是消费者所在地。这种倚重生产的税制,刺激了各地竞相投资制造业、上马大项目,推动了制造业迅猛发展,加之充足高效的劳动力资源和全球产业链重整等内外因素,我国在短短二三十年内就成为世界第一制造业大国。当然,这也付出了相应的代价。比如说,地方为争夺税收和大工业项目,不惜放松环保监督,损害了生态环境,推高了过剩产能。2007—2014年,地方政府的工业税收收入中,一半来自过剩产能行业。而在那些财政压力较大的地区,工业污染水平也普遍较高。 1515 不仅九成的税收征收自企业,税收之外的其他政府收入基本也都征收自企业,比如土地转让费和国有资本经营收入等。社保费中个人缴纳的比例也低于企业缴纳的比例。所以在分税制改革后的头些年,地方政府在财政支出上向招商引资倾斜(如基础设施建设、企业补贴等),而民生支出(教育、医疗、环保等)相对不足。 1616 2002年,中央提出“科学发展观”,要求“统筹经济社会发展、统筹人与自然和谐发展”,要求更加重视民生支出。由于第一章中讨论过的规模经济、信息复杂性等原因,民生支出基本都由地方政府承担,所以地方支出占比从2002年开始快速增长,从70%一直增长到了85%(图2-1)。\n总的来看,分税制改革后,地方政府手中能用来发展经济的资源受到了几方面的挤压。首先,预算内财政支出从重点支持生产建设转向了重点支持公共服务和民生。20世纪90年代中后期,财政支出中“经济建设费”占40%,“社会文教费”(科教文卫及社会保障)只占26%。到了2018年,“社会文教费”支出占到了40%,“经济建设费”则下降了。 1717 其次,分税制改革前,企业不仅缴税,还要向地方政府缴纳很多费(行政收费、集资、摊派、赞助等),这部分预算外收入在改革后大大减少。90年代中后期,乡镇企业也纷纷改制,利润不再上缴,基层政府的预算外收入进一步减少。最后,2001年的税改中,中央政府又拿走了所得税收入的60%,加剧了地方财政压力。地方不得不另谋出路,寻找资金来源,轰轰烈烈的“土地财政”就此登场。\n初探土地财政 # 我国实行土地公有制,城市土地归国家所有,农村土地归集体所有。农地要转为建设用地,必须先经过征地变成国有土地,然后才可以用于发展工商业或建造住宅(2019年《中华人民共和国土地管理法》修正案通过,对此进行了改革,详见第三章),所以国有土地的价值远远高于农地。为什么会有这种城乡割裂的土地制度?追根溯源,其实也没有什么惊天动地的大道理和顶层设计,不过是从1982年宪法开始一步步演变成今天这样罢了。 1818 虽说每一步变化都有道理,针对的都是当时亟待解决的问题,但演变到今天,已经造成了巨大的城乡差别、飞涨的城市房价以及各种棘手问题。2020年,中共中央和国务院发布的《关于构建更加完善的要素市场化配置体制机制的意见》中,首先提到的就是“推进土地要素市场化配置”,而第一条改革意见就是打破城乡割裂的现状,“建立健全城乡统一的建设用地市场”。可见政策制定者非常清楚当前制度的积弊。第三章会详细讨论相关改革,此处不再赘述。\n1994年分税制改革时,国有土地转让的决定权和收益都留给了地方。当时这部分收益很少。一来虽然乡镇企业当时还很兴盛,但它们占用的都是农村集体建设用地,不是城市土地。二来虽然城市土地使用权当时就可以有偿转让,不必再像计划经济体制下那样无偿划拨,但各地为了招商引资(尤其是吸引外资),土地转让价格大都非常优惠,“卖地收入”并不多。\n1998年发生了两件大事,城市土地的真正价值才开始显现。第一是单位停止福利分房,逐步实行住房分配货币化,商品房和房地产时代的大幕拉开。1997—2002年,城镇住宅新开工面积年均增速为26%,五年增长了近4倍。第二是修订后的《中华人民共和国土地管理法》开始实施,基本上锁死了农村集体土地的非农建设通道,规定了农地要想转为建设用地,必须经过征地后变成国有土地,这也就确立了城市政府对土地建设的垄断权力。 1919 1999年和2000年这两年的国有土地转让收入并不高(图2-4),因为尚未普遍实行土地“招拍挂”(招标、拍卖、挂牌)制度。当时的土地转让过程相当不透明,基本靠开发商各显神通。比如有些开发商趁着国有企业改革,拿到了企业出让的土地,再从城市规划部门取得开发许可,只需支付国家规定的少量土地出让金,就可以搞房地产开发。这是个转手就能发家致富的买卖,其中的腐败可想而知。\n图2-4 国有土地转让收入占地方公共预算收入的比重\n数据来源:历年《中国国土资源统计年鉴》。\n2001年,为治理土地开发中的腐败和混乱,国务院提出“大力推行招标拍卖”。2002年,国土部明确四类经营用地(商业、旅游、娱乐、房地产)采用“招拍挂”制度。于是各地政府开始大量征收农民土地然后有偿转让,土地财政开始膨胀。土地出让收入从2001年开始激增,2003年就已经达到了地方公共预算收入的55%(图2-4)。2008年全球金融危机之后,在财政和信贷政策的共同刺激之下,土地转让收入再上一个台阶,2010年达到地方公共预算收入的68%。最近两年这一比重虽有所下降,但土地转让收入的绝对数额还在上涨,2018年达到62 910亿元,比2010年高2.3倍。\n所谓“土地财政”,不仅包括巨额的土地使用权转让收入,还包括与土地使用和开发有关的各种税收收入。其中大部分税收的税基是土地的价值而非面积,所以税收随着土地升值而猛增。这些税收分为两类,一类是直接和土地相关的税收,主要是土地增值税、城镇土地使用税、耕地占用税和契税,其收入百分之百归属地方政府。2018年,这四类税收共计15 081亿元,占地方公共预算收入的15%,相当可观。另一类税收则和房地产开发和建筑企业有关,主要是增值税和企业所得税。2018年,这两种税收中归属地方的部分(增值税五成,所得税四成)占地方公共预算收入的比重为9%。 2020 若把这些税收与土地转让收入加起来算作“土地财政”的总收入,2018年“土地财政”收入相当于地方公共预算收入的89%,是名副其实的“第二财政”。\n土地转让虽然能带来收入,但地方政府也要负担相关支出,包括征地拆迁补偿和“七通一平”等基础性土地开发支出。从近几年的数字看,跟土地转让有关的支出总体与收入相当,有时甚至比收入还高。2018年,国有土地使用权出让金收入为62 910亿元,支出则为68 167亿元。光看这一项,地方政府还入不敷出。当然地方政府本来也不是靠卖地赚钱,它真正要的是土地开发之后吸引来的工商业经济活动。\n从时间点上看,大规模的土地财政收入始于21世纪初。2001年所得税改革后,中央财政进一步集权,拿走了企业所得税的六成。从那以后,地方政府发展经济的方式就从之前的“工业化”变成了“工业化与城市化”两手抓:一方面继续低价供应大量工业用地,招商引资;另一方面限制商住用地供给,从不断攀升的地价中赚取土地垄断收益。这些年出让的城市土地中,工业用地面积约占一半,但出让价格极低:2000年每平方米是444元,2018年是820元,只涨了85%。而商业用地价格增长了4.6倍,住宅用地价格更是猛增了7.4倍(图2-5)。\n图2-5 100个重点城市土地出让季度平均成交价\n数据来源:万得数据库。\n所以商住用地虽然面积上只占出让土地的一半,但贡献了几乎所有的土地使用权转让收入。因此“土地财政”的实质是“房地产财政”。一方面,各地都补贴工业用地,大力招商引资,推动了制造业迅猛发展;另一方面,随着工业化和城市化的发展,大量新增人口涌入经济发达地区,而这些地方的住宅用地供给却不足,房价自然飞涨,带动地价飞涨,土地拍卖的天价“地王”频出。这其中的问题及改革之道,第三章会展开分析。\n税收、地租与地方政府竞争 # 让我们后退一步,看清楚地方政府究竟在干什么。所谓经济发展,无非就是提高资源使用效率,尽量做到“人尽其才,物尽其用”。而我国是一个自然资源相对贫乏的国家,在经济起步阶段,能利用的资源主要就是人力和土地。过去几十年的很多重大改革,大都和盘活这两项资源、提高其使用效率有关。与人力相比,土地更容易被资本化,将未来收益一股脑变成今天高升的地价,为地方政府所用。所以“土地财政”虽有种种弊端,但确实是过去数年城市化和工业化得以快速推进的重要资金来源。\n前文说过,地方在招商引资和城市化过程中,会利用手中一切资源,所以需要通盘考量税收和土地,平衡税收收入和土地使用权转让收入,以达到总体收入最大化。地方政府压低工业用地价格,因为工业对经济转型升级的带动作用强,能带来增值税和其他税收,还能创造就业。而且工业生产率提升空间大、学习效应强,既能帮助本地实现现代化,也能带动服务业的发展,拉动商住用地价格上涨。工业生产上下游链条长,产业集聚和规模经济效果显著,若能发展出特色产业集群(如佛山的陶瓷),也就有了长久的竞争优势和稳定的税收来源。此外,地方之间招商引资竞争非常激烈。虽说工业用地和商住用地都由地方政府垄断,但工业企业可以落地的地方很多,所以在招商引资竞争中地方政府很难抬高地价。商住用地则不同,主要服务本地居民,土地供应方的垄断力量更强,更容易抬高地价。\n经济学家张五常曾做过一个比喻:地方政府就像一家商场,招商引资就是引入商铺。商铺只要交一个低廉的入场费用(类似工业用地转让费),但营业收入要和商场分成(类似增值税,不管商铺是否盈利,只要有流水就要分成)。商场要追求总体收入最大化,所以既要考虑入门费和租金的平衡,也要考虑不同商铺间的平衡。一些商铺大名鼎鼎,能为商场带来更大客流,那商场不仅可以免除它们的入门费,还可以降低分成,甚至可以倒贴(类似地方给企业的各种补贴)。 2121 以行政区划为单位、以税收和土地为手段展开招商引资竞争,且在上下级政府间层层承包责任和分享收益,这一制度架构对分税制改革后经济的飞速发展,无疑有很强的解释力。但随着时代发展,这种模式的弊端和负面效果也越来越明显,需要改革。首先是地方政府的债务问题(见第三章)。土地的资本化运作,本质是把未来的收益抵押到今天去借钱,如果借来的钱投资质量很高,转化成了有价值的资产和未来更高的收入,那债务就不是大问题。但地方官员任期有限,难免会催生短视行为,寅吃卯粮,过度借债去搞大项目,搞“面子工程”,功是留在当代了,利是不是有千秋,就是下任领导的事了。如此一来,投资质量下降,收益不高,债务负担就越来越重。\n若仅仅只是债务问题,倒也不难缓解。最近几年实施了一系列财政和金融改革,实际上已经遏制住了债务的迅猛增长。但经济增速随之放缓,说明资源的使用效率仍然不高。就拿土地来说,虽然各地都有动力调配好手中的土地资源,平衡工业和商住用地供给,但在全国范围内,土地资源和建设用地分配却很难优化。地区间虽然搞竞争,但用地指标不能跨省流动到效率更高的地区。珠三角和长三角的经济突飞猛进,人口大量涌入,却没有足够的建设用地指标,工业和人口容量都遭遇了人为的限制。寸土寸金的上海,却保留着289.6万亩农田(2020年的数字),可以说相当不经济。同时,中西部却有大量闲置甚至荒废的产业园区。虽然地广人稀的西北本就有不少荒地,所以真实的浪费情况可能没有媒体宣扬的那么夸张,但这些用地指标本可以分给经济更发达的地区。如果竞争不能让资源转移到效率更高的地方,那这种竞争就和市场竞争不同,无法长久地提高整体效率。一旦投资放水的闸门收紧,经济增长的动力立刻不足。\n可是制度一直如此,为什么前些年问题似乎不大?因为经济发展阶段变了。在工业化和城市化初期,传统农业生产率低,只要把农地变成工商业用地,农业变成工商业,效率就会大大提升。但随着工业化的发展,市场竞争越来越激烈,技术要求越来越高,先进企业不仅需要土地,还需要产业集聚、研发投入、技术升级、物流和金融配套等,很多地方并不具备这些条件,徒有大量建设用地指标又有何用?改革的方向是清楚的。2020年,中共中央和国务院发布的《关于构建更加完善的要素市场化配置体制机制的意见》中,放在最前面的就是“推进土地要素市场化配置”。要求不仅要在省、市、县内部打破城乡建设用地之间的市场壁垒,建设一个统一的市场,盘活存量建设用地,而且要“探索建立全国性的建设用地、补充耕地指标跨区域交易机制”,以提高土地资源在全国范围内的配置效率。\n第三节 纵向不平衡与横向不平衡 # 分税制改革之后,中央拿走了收入的大头,但事情还是要地方办,所以支出的大头仍留在地方,地方收支差距由中央转移支付来填补。从全国总数来看,转移支付足够补上地方收支缺口。 2222 但总数能补上,不等于每级政府都能补上,也不等于每个地区都能补上。省里有钱,乡里不见得有钱;广州有钱,兰州不见得有钱。这种纵向和横向的不平衡,造成了不少矛盾和冲突,也催生了很多改革。\n基层财政困难 # 分税制改革之后,中央和省分成,省也要和市县分成。可因为上级权威高于下级,所以越往基层分到的钱往往越少,但分到的任务却越来越多,出现了“财权层层上收,事权层层下压”的局面。改革后没几年,基层财政就出现了严重的困难。20世纪90年代末有句顺口溜流行很广:“中央财政蒸蒸日上,省级财政稳稳当当,市级财政摇摇晃晃,县级财政哭爹叫娘,乡级财政精精光光。”\n从全国平均来看,地方财政预算收入(本级收入加上级转移支付)普遍仅够给财政供养人员发工资,但地区间差异很大。在东部沿海,随着工业化和城市化的大发展,可以从“土地财政”中获取大量额外收入,一手靠预算财政“吃饭”,一手靠土地财政“办事”。但在很多中西部县乡,土地并不值钱,财政收入可能连发工资都不够,和用于办事的钱相互挤占,连“吃饭财政”都不算,要算“讨饭财政”。 2323 基层政府一旦没钱,就会想办法增收,以保持正常运转。20世纪90年代末到21世纪初,农村基层各种乱收费层出不穷,农民的日子不好过,干群关系紧张,群体性事件频发。基层政府各种工程欠款(会转化为包工头拖欠农民工工资,引发讨薪事件)、拖欠工资、打白条等,层出不穷。2000年初,湖北监利县棋盘乡党委书记李昌平给时任国务院总理朱镕基写信,信中的一句话轰动全国:“农民真苦,农村真穷,农业真危险。”这个“三农问题”,就成了21世纪初政策和改革的焦点之一。\n20世纪90年代的财政改革及其他根本性改革(如国企改革和住房改革),激化了一些社会矛盾,这是党的十六大提出“和谐社会”与“科学发展观”的时代背景。与“科学发展观”对应的“五个统筹”原则中,第一条就是“统筹城乡发展”。 2424 从2000年开始,农村税费改革拉开帷幕,制止基层政府乱摊派和乱收费,陆续取消了“三提五统”和“两工”等。 2525 2006年1月1日,农业税彻底废止。这是一件具有历史意义的大事,终结了农民缴纳了千年的“皇粮国税”。这些税费改革不仅提高了农民收入,也降低了农村的贫富差距。 2626 之所以能推行这些改革,得益于我国加入世界贸易组织(WTO)之后飞速发展的工商业,使得国家财政不再依赖于农业税费。2000年至2007年,农业部门产值占GDP的比重从15%下降到了10%,而全国税收总收入却增加了3.6倍(未扣除物价因素)。\n农村税费改革降低了农民负担,但也让本就捉襟见肘的基层财政维持起来更加艰难,所以之后的改革就加大了上级的统筹和转移支付力度。\n其一,是把农村基本公共服务开支纳入国家公共财政保障范围,由中央和地方政府共同负担。比如2006年开始实施的农村义务教育经费保障机制改革,截至2011年,中央财政一共安排了3 300亿元农村义务教育改革资金,为约1.3亿名农村义务教育阶段的学生免除了学杂费和教科书费。 2727 再比如2003年开始的新型农村合作医疗制度(“新农合”)与2009年开始的新型农村社会养老保险制度(“新农保”)等,均有从中央到地方的各级财政资金参与。\n其二,是在转移支付制度中加入激励机制,鼓励基层政府达成特定目标,并给予奖励。比如2005年开始实施的“三奖一补”,就对精简机构和人员的县乡政府给予奖励。 2828 冗员过多一直是政府顽疾,分税制改革后建立的转移支付体系中,相当一部分转移支付是为了维持基层政府正常运转和保障人员工资。财政供养人员(即有编制的人员)越多,得到的转移支付越多,这自然会刺激地方政府扩编。从1994年到2005年,地方政府的财政供养人员(在职加退休)猛增了60%,从2 981万人增加到4 778万人。2005年实行“三奖一补”之后,2006年财政供养人口下降了318万。之后又开始缓慢上升,2008年达到4 631万。2009年后,财政供养人员的数据不再公布。 2929 其三,是把基层财政资源向上一级政府统筹,比如2003年开始试点的“乡财县管”改革。农村税费改革后,乡镇一级的财政收入规模和支出范围大大缩减,乡镇冗员问题、管理问题、债务问题就变得突出。通过预算共编、票据统管、县乡联网等手段,把乡镇财政支出的决定权上收到县,有利于规范乡镇行为,也有利于在县域范围内实现乡镇之间公共服务的均等化。根据财政部网站,截至2012年底,86%的乡镇都已经实施了“乡财县管”。\n让县政府去统筹乡镇财务,那县一级的财政紧张状况又该怎么办呢?在市管县的行政体制下,县的收入要和市里分账,可市财政支出和招商引资却一直偏向市区,“市压县,市刮县,市吃县”现象严重,城乡差距不断拉大。而且很多城市本身经济发展水平也不高,难以对下辖县产生拉动作用,所以在21世纪初,全国开始推行“扩权强县”和“财政省直管县”改革。前者给县里下放一些和市里等同的权限,比如土地审批、证照发放等;后者则让县财政和省财政直接发生关系,绕开市财政,在财政收支权力上做到县市平级。这些改革增加了县一级的财政资源,缩小了城乡差距。 3030 “乡财县管”和“省直管县”改革,实质上把我国五级的行政管理体制(中央—省—市—区县—乡镇)在财政管理体制上“拉平”了,变成了三级体制(中央—省—市县)。县里的财政实力固然是强了,但是否有利于长远经济发展,则不一定。“省直管县”这种做法源于浙江,20世纪90年代就在全省施行,效果很好。但浙江情况特殊,县域经济非常强劲,很多县乃至乡镇都有特色产业集群。2019年,浙江省53个县(含县级市)里,18个是全国百强县。但在其他一些省份,“省直管县”改革至少遭遇了两个困难。首先是省里管不过来。改革前,一个省平均管12个市,改革后平均管52个市县。钱和权给了县,但监管跟不上,县域出现了种种乱象,比如和土地有关的腐败盛行。其次,县市关系变动不一定有利于县的长远发展。以前县归市管,虽受一层“盘剥”,但跟市区通常还是合作大于竞争。但改革以后,很多情况下竞争就大于合作了,导致县域经济“孤岛化”比较严重。尤其在经济欠发达地区,市的实力本就不强,现在进一步分裂成区和县,更难以产生规模和集聚效应。经济弱市的“小马”本就拉不动下辖县的“大车”,但改革并没有把“小马”变成“大马”,反倒把“大车”劈成了一辆辆“小车”,结果是小城镇遍地开花,经济活动和人口不但没有向区域经济中心的市区集聚,反而越搞越散。从现有研究来看,省直管县之后,虽然县里有了更多资源,但人均GDP增速反而放缓了。 3131 总体来看,分税制改革后,基层财政出现了不少困难,引发了一系列后续改革,最终涉及了财税体制的层级问题。到底要不要搞扁平化,学发达国家搞三级财政?是不是每个省都应该搞?对于相关改革效果和未来方向,目前仍有争议。\n地区间不平等 # 我国地区间经济发展的差距从20世纪90年代中后期开始扩大。由于出口飞速增长,制造业自然向沿海省份集聚,以降低出口货运成本。这种地理分布符合经济规律,并非税收改革的后果。但随着产业集聚带来的优势,地区间经济发展水平和财力差距也越拉越大。公共财政的一个主要功能就是再分配财政资源,平衡地区间的人均公共服务水平(教育、医疗等),所以中央也开始对中西部地区进行大规模转移支付。1995年至2018年,转移支付总额从665亿元增加到了61 649亿元,增加了93倍,远高于地方财政收入的增长率,占GDP的比重也从1%升至7%。 3232 80%以上的转移支付都到了中西部地区,这保障了地区间人均财政支出的均等化。 3333 虽然目前东部和中西部的公共服务水平差异依然明显,但如果没有中央转移支付,地区差异可能更大。\n图2-6描绘了最富的3个省(江苏、浙江、广东)与最穷的3个省(云南、贵州、甘肃)之间人均财政收支的差距。以2018年为例,苏浙粤的人均财政收入和人均GDP是云贵甘的2.7倍,但由于中央的转移支付,这些省份的人均财政支出基本持平,人均财政支出的差距在过去20年中也一直远小于人均财政收入。自2005年起,地区间人均财政支出差距进一步收窄,这与上节提到的“三奖一补”政策有关。虽然省一级的人均财政支出基本均衡,但到了县一级,地区间差距就大了。以2009年为例,人均财政支出最高的1%的县,支出是最低的1%的县的19倍。 3434 这种基层间的差距和上节讨论过的纵向差距有关:越往基层分到的钱越少,省级的差距到了基层,就被层层放大了。\n图2-6 苏浙粤与云贵甘人均财力之比\n数据来源:万得数据库。\n注:江苏、浙江、广东的人均GDP最高,而云南、贵州、甘肃则最低。此处未包括4个直辖市和西藏自治区,这些地区和其他省份不太具有可比性。\n中央对地方的转移支付大概可以分为两类:一般性转移支付(2009年之后改称“均衡性转移支付”)和专项转移支付。 3535 简单来说,前者附加条件少,地方可自行决定用途,而后者必须专款专用。为什么要指定资金用途、不让地方自主决策呢?因为无条件的均衡性转移支付是为了拉平地区差距,所以越穷的地方拿到的钱越多,地方也就越缺乏增收动力。而且均衡性转移支付要保证政府运作和公务员工资,可能会刺激财政供养人员增加,恶化冗员问题。\n专项转移支付约占转移支付总额的四成,一般以“做项目”的形式来分配资金,专款专用,可以约束下级把钱花在上级指定的地方,但在实际操作中,这种转移支付加大了地区间的不平等。 3636 经济情况越好、财力越雄厚的地区,反而可能拿到更多的专项转移支付项目,原因有三。第一,上级分配项目时一般不会“撒胡椒面儿”,而是倾向于集中财力投资大项目,并且交给有能力和条件的地区来做,所谓“突出重点,择优支持”。第二,2015年之前,许多项目都要求地方政府提供配套资金,只有有能力配套的地方才有能力承接大项目,拿到更多转移支付。 3737 第三,项目审批过程中人情关系在所难免。很多专项资金是由财政部先拨款给各部委后再层层下拨,所以就有了“跑部钱进”的现象,而经济发达地区往往与中央部委的关系也更好。 3838 公共财政的重要功能是实现人均公共服务的均等化,虽然我国在这方面已取得了长足进展,但可改进的空间依然很大。从目前情况来看,东中西部省份之间、同一省份的城乡之间、同一城市的户籍人口和非户籍人口之间,公共服务的差别依然很大。第五章将会继续探讨这些地区间不均衡、人与人不平等的问题。\n结语 # 要深入了解政府,必须了解财税。本章介绍了1994年分税制改革的逻辑和后果。图2-7总结了本章各部分内容之间的关系。从中可以看到,制度改革必须不断适应新的情况和挑战。理解和评价改革,不能生搬硬套某种抽象的哲学或理论标准,而必须深入了解改革背景和约束条件,仔细考量在特定时空条件下所产生的改革效果。只有理解了分税制改革的必要性和成功经验,才能理解其中哪些元素已经不适应新情况,需要继续改革。\n图2-7 第二章内容小结\n分税制之后兴起的“土地财政”,为地方政府贡献了每年五六万亿的土地使用权转让收入,着实可观,但仍不足以撬动飞速的工业化和城市化。想想每年的基础设施建设投入,想想高铁从起步到普及不过区区十年,钱从哪里来?每个城市都在大搞建设,高楼、公园、道路、园区……日新月异,钱从哪里来?所以土地真正的力量还不在“土地财政”,而在以土地为抵押而撬动的银行信贷与其他各路资金。“土地财政”一旦嫁接了资本市场,加上了杠杆,就成了“土地金融”,能像滚雪球般越滚越大,推动经济飞速扩张,也造就了地方政府越滚越多的债务,引发了一系列宏观经济问题。“土地金融”究竟是怎么回事?政府究竟是如何融资和投资的?中外媒体和分析家们都很关心的地方政府债务,究竟是什么情况?这些是下一章的内容。\n扩展阅读 # 财政和税收本身就是一个专业,涉及内容繁多,很多大学都有专门的系所甚至学院。本章重点不是财税本身,而是以财税的视角去理解地方政府行为。北京大学周飞舟的著作《以利为利:财政关系与地方政府行为》(2012)与本章视角类似,但更加系统和全面,详细介绍了从新中国成立初期一直到21世纪初主要财政改革的前因后果,有不少一手调研资料,逻辑性和结构都很好,是一本优秀的入门读物。\n要深入了解城市的土地财政,就必须了解农村的土地制度,因为城市的新增建设用地大都是从农村征收来的。北京大学周其仁的著作《城乡中国》(2017年修订版)阐释了城乡土地制度,既追溯了过往,也剖析了当下,语言轻松,说理清楚。中国人民大学刘守英的著作《土地制度与中国发展》(2018)则更为全面和详细,适合进阶参考。\n财政和土地制度是国家大事,改变着每个人的生活。要想真正理解这些改革,需要深入基层,观察这些改革如何影响了官员、企业家和普通人的行为,如何改变了他们之间的关系。华中科技大学吴毅的著作《小镇喧嚣:一个乡镇政治运作的演绎与阐释》(2018年新版)是一份非常详细和生动的记录。这本社会学著作以50万字的篇幅记录了21世纪初中部某小镇上发生的很多事,大都围绕经济问题展开。故事本身以及作者的评论,都很精彩,能让人看到“上面”来的改革对基层个体的重大影响。在另一部类似的杰作《他乡之税:一个乡镇的三十年,一个国家的“隐秘”财政史》(2008)中,田毅和赵旭记录了一个北方小镇的故事。与吴毅之作不同,本书的叙事从1978年开始,记录了30年间财政变革给基层带来的种种变化。读者尤其可以了解分税制后基层财政的悬浮和空转状态,了解农业税费改革之前基层盛行的“买税”或“协税”现象。\n11 本小节重点参考了北京大学周飞舟的著作(2012),这是理解中华人民共和国成立之后财政改革历程和逻辑的极佳读物。\n22 这种激励效果的理论和证据,可参考金和辉、清华大学钱颖一、斯坦福大学温加斯特(Weingast)等人的合作研究(Jin, Qian and Weingast, 2005)。\n33 乡镇企业税收和雇工人数的数据来自香港科技大学龚启圣和林益民的研究(Kung and Lin,2007)。乡镇企业利润税率的数据来自圣地亚哥加州大学诺顿(Naughton)的著作(2020)。\n44 香港中文大学王绍光在其著作(1997)中讨论了央地之间囚徒困境式的博弈:地方政府预料到中央在重新谈判中可能“鞭打快牛”,所以不愿意努力征税。这个理论只是猜测,很难验证。而把预算内收入转成预算外收入的逻辑,有数据支持,参见来自华中科技大学李学文和卢新海、浙江大学张蔚文等人的合作研究(2012)。\n55 引文来自财政部财政科学研究所刘克崮和贾康主编的财政改革回忆录(2008)。\n66 1993年带队赴各省份做工作的朱镕基同志时任国务院副总理。\n77 时任广东省委书记,应为“谢非”。\n88 应该是傅锐。\n99 引文出自刘克崮和贾康(2008)。\n1010 此处回忆可能有偏差,1993年全年地方税收收入比1992年增长了38%。\n1111 引文参见刘克崮和贾康(2008)。\n1212 《国务院办公厅转发财政部关于2001年11月和12月上中旬地方企业所得税增长情况报告的紧急通知》。\n1313 图2-3中,企业所得税在2000年就开始大幅攀升,这可能是由于统计口径调整。2000年前的企业所得税统计只包括国有企业和集体企业,之后则包括了所有企业。有一种方法可以剔除统计口径调整所带来的影响,就是比较同一年中财政预算和决算两个数字。若无特殊情况,这两个数字应该差别不大。根据2002年《中国财政年鉴》,2001年地方企业所得税收入的预算数是1 049亿元,但决算数是1 686亿元,增长了61%。这种暴增在其他税种中是没有的。比如当时改革没有涉及的营业税,预算1 830亿元,决算1 849亿元;而早已经完成改革的增值税,预算是1 229亿元,决算是1 342亿元。再比如同样经历改革但没有“基数投机”冲动的中央企业所得税,预算是937亿元,决算是945亿元。\n1414 浙江大学方红生和复旦大学张军(2013)总结了分税制改革之后关于税收征管力度的研究。\n1515 地方税收压力恶化了工业污染,推升了过剩产能,相关证据来自中国社会科学院席鹏辉和厦门大学梁若冰、谢贞发(2017)以及苏国灿等人的研究(2017)。\n1616 分税制改革后,地方财政支出重生产而轻民生,证据很多。比如复旦大学傅勇和张晏对省级支出的研究(2007),中国人民大学马光荣和吕冰洋及中国社会科学院张凯强对地级市支出的研究(2019),北京师范大学尹恒和北京大学朱虹对县级支出的研究(2011)等。\n1717 2007年,我国重新调整了政府收支预算科目分类,所以没有办法直接比较2007年前后政府预算内支出中用于经济建设的比例,但无疑是下降了。\n1818 对新中国成立后土地产权制度演变过程和逻辑有兴趣的读者,可以参考本章结尾的“扩展阅读”。\n1919 虽然法律规定集体土地还可以用于乡镇企业建设,但随着乡镇企业纷纷开始所有制改革,真正的乡镇企业越来越少,因此这个规定意义不大。此外,1997年以后实行用地规模和指标审批管理制度,省级政府自然将紧缺的建设用地指标优先分配给省会和城市市区,给到县城的用地指标很少,而大部分集体建设土地位于县城。关于这方面更详细的介绍,可参考中国人民大学刘守英的著作(2018)第八章。本段中用到的数字也来自该书。\n2020 在2016年营业税改增值税以前,房地产开发和建筑企业缴纳的主要是营业税,百分之百归地方,不用和中央分成,所以占地方公共预算收入的比重甚至更高。以2013年为例,房地产开发和建筑企业缴纳的营业税和归属地方的所得税加起来相当于地方公共预算收入的16%。本段中的数字均出自历年的《中国税务年鉴》。\n2121 详细的阐释可参考张五常的著作(2017)。\n2222 从1994年分税制改革之后一直到2008年,每年中央转移支付总额都高于地方预算收支缺口,一般要高10%—20%。2009年“4万亿”财政金融刺激之后,地方可以通过发债来融资,收支缺口才开始大于中央转移支付(2015年新版预算法之后,省级政府才可以发债。但在2009年至2014年间,财政部可以代理省级政府发债)。\n2323 对基层财政的“悬浮”状态和政府运转中的种种困难,田毅和赵旭的著作(2008)以及吴毅的著作(2018)中都有生动的记录和深刻的分析。\n2424 2003年党的十六届三中全会提出了与“科学发展观”相适应的“五个统筹”:统筹城乡发展、统筹区域发展、统筹经济社会发展、统筹人与自然和谐发展、统筹国内发展和对外开放。\n2525 改革前的农村税费负担,大概可以分为“税”“费”“工”三类。税,即“农业五税”:农业税、农业特产税、屠宰税、涉农契税、耕地占用税。费,即所谓“三提五统”:村集体的三项提留费用(村干部管理费、村公积金、村公益金)和乡政府的五项统筹费用(教育附加、计划生育、优抚、民兵训练、乡村道路建设)。工,就是“两工”:农村义务工和劳动积累工,主要用于植树造林、防汛、修缮校舍等。\n2626 参见中央财经大学陈斌开和北京大学李银银的合作研究(2020)。\n2727 数字来自财政部前部长楼继伟和财政科学研究院院长刘尚希的合作著作(2019)。\n2828 “三奖一补”包括:对财政困难的县乡政府增加县乡税收收入、省市级政府增加对财政困难县财力性转移支付给予奖励;对县乡政府精简机构和人员给予奖励;对产粮大县给予奖励;对此前缓解县乡财政困难工作做得好的地方给予补助。\n2929 数据来自财政部预算司原司长李萍主编的读物(2010)。如果按照2006—2008年的年均增速1.8%推算,2018年地方财政供养人员应该在5 500万人左右。根据楼继伟(2013)的数据,全国的公务员中,地方占94%(2011年)。如果这个比例也适用于全部财政供养人员,那2018年全国财政供养人员总数(在职加退休)大概是5 850万。\n3030 关于“省直管县”改革的研究很多,有兴趣的读者可以参考复旦大学谭之博、北京大学周黎安、中国人民银行赵岳等人的合作研究(2015)。\n3131 “省直管县”改革引发的土地腐败和经济增速放缓,及改革前后省政府管理的行政单位数目,来自浙江大学李培、清华大学陆毅、香港科技大学王瑾的合作研究(Li, Lu and Wang, 2016)。\n3232 1995年的转移支付数据来自《1995年全国地市县财政统计资料》,2018年的数据来自财政部网站公布的《关于2018年中央决算的报告》。\n3333 根据财政部公布的《关于2018年中央决算的报告》,当年85%的转移支付用在了中西部地区。而根据云南财经大学缪小林和高跃光以及云南大学王婷等人的计算(2017),从1995年到2014年,80%以上的转移支付都分配到了中西部地区。\n3434 目前可得的县级财政支出数据只到2009年,来自《2009年全国地市县财政统计资料》。我在计算时仅包括了县和县级市,不包括市区,也没有包括4个直辖市和西藏自治区。\n3535 广义转移支付还应包括税收返还,但这部分钱本就属于地方,不包含在本段的统计中。\n3636 专项转移支付实际上增大了地方人均财力的差别,这方面证据很多,比如中国宏观经济研究院王瑞民和中国人民大学陶然的研究(2017)。\n3737 2015年2月,国务院发布《关于改革和完善中央对地方转移支付制度的意见》,明确“中央在安排专项转移支付时,不得要求地方政府承担配套资金”。\n3838 部委的人情关系在专项资金分配中有重要作用,参见上海财经大学范子英和华中科技大学李欣的研究(2014)。\n第三章 政府投融资与债务 # 暨南大学附近有个地方叫石牌村,是广州最大的城中村之一,很繁华。前两年有个美食节目探访村中一家卖鸭仔饭的小店,东西好吃还便宜,一份只卖12元。中年老板看上去非常朴实,主持人问他:“挣得很少吧?”他说:“挣得少,但是我生活得很开心。因为我自己……我告诉你,我也是有……不是很多钱啦,有10栋房子可以收租。”主持人一脸“怪不得”的样子对着镜头哈哈大笑起来:“为什么可以卖12元?因为他有10套房子可以收租!”老板平静地纠正他:“是10栋房哦,不是10套房哦,10栋房,一栋有7层。”主持人的大笑被这突如其来的70层楼拍扁在了脸上……\n很多人都有过幻想:要是老家的房子和地能搬到北、上、广、深就好了。都是地,人家的怎么就那么值钱?区区三尺土地,为什么一旦变成房本,别人就得拿大半辈子的收入来换?\n再穷的国家也有大片土地,土地本身并不值钱,值钱的是土地之上的经济活动。若土地只能用来种小麦,价值便有限,可若能吸引来工商企业和人才,价值想象的空间就会被打开,笨重的土地就会展现出无与伦比的优势:它不会移动也不会消失,天然适合做抵押,做各种资本交易的压舱标的,身价自然飙升。土地资本化的魔力,在于可以挣脱物理属性,在抽象的意义上交易承诺和希望,将过去的储蓄、现在的收入、未来的前途,统统汇聚和封存在一小片土地上,使其价值暴增。由此产生的能量不亚于科技进步,支撑起了工业化和城市化的巨大投资。经济发展的奥秘之一,正是把有形资产转变成为这种抽象资本,从而聚合跨越空间和时间的资源。 11 上一章介绍了城市政府如何平衡工业用地和商住用地供应,一手搞“工业化”,一手搞“城市化”,用土地使用权转让费撑起了“第二财政”。但这种一笔一笔的转让交易并不能完全体现土地的金融属性。地方政府还可以把与土地相关的未来收入资本化,去获取贷款和各类资金,将“土地财政”的规模成倍放大为“土地金融”。\n本章第一节用实例来解释这种“土地金融”与政府投资模式。第二节介绍这种模式的弊端之一,即地方政府不断加重的债务负担。与政府债务相关的各项改革中也涉及对官员评价和激励机制的改革,因此第三节将展开分析地方官员在政府投融资过程中的角色和行为。\n第一节 城投公司与土地金融 # 实业投资要比金融投资复杂得多,除了考虑时间、利率、风险等基本要素之外,还要处理现实中的种种复杂情况。基金经理从股票市场上买入千百个散户手中的股票,和地产开发商从一片土地上拆迁掉千百户人家的房子,虽然同样都是购买资产,都算投资,但操作难度完全不同。后者若没有政府介入,根本干不成。实业投资通常是个连续的过程,需要不断投入,每个阶段打交道的对象不同,所需的专业和资源不同,要处理的事务和关系也不同。任一阶段出了纰漏,都会影响整个项目。就拿盖一家商场来说,前期的土地拆迁、中期的开发建设、后期的招商运营,涉及不同的专业和事务,往往由不同的主体来投资和运作,既要考虑项目整体的连续性,也要处理每一阶段的特殊性。\n我国政府不但拥有城市土地,也掌控着金融系统,自然会以各种方式参与实业投资,不可能置身事外。但实业投资不是买卖股票,不能随时退出,且投资过程往往不可逆:未能完成或未能正常运转的项目,前期的投入可能血本无归。所以政府一旦下场就很难抽身,常常不得不深度干预。在很长一段时期内,中国GDP增长的主要动力来自投资,这种增长方式必然伴随着政府深度参与经济活动。这种方式是否有效率,取决于经济发展阶段,本书下篇将深入探讨。但我们首先要了解政府究竟是怎么做投资的。本节聚焦土地开发和基础设施投资,下一章再介绍工业投资。\n地方政府融资平台:从成都“宽窄巷子”说起 # 法律规定,地方政府不能从银行贷款,2015年之前也不允许发行债券,所以政府要想借钱投资,需要成立专门的公司。 22 这类公司大都是国有独资企业,一般统称为“地方政府融资平台”。这个名称突出了其融资和负债功能,所以经济学家和财经媒体在谈及这些公司时,总是和地方债务联系在一起。但这些公司的正式名称可不是“融资平台”,而大都有“建设投资”或“投资开发”等字样,突出自身的投资功能,因此也常被统称为“城投公司”。比如芜湖建设投资有限公司(奇瑞汽车大股东)和上海城市建设投资开发总公司(即上海城投集团),都是当地国资委的全资公司。还有一些公司专门开发旅游景点,名称中一般有“旅游发展”字样,比如成都文化旅游发展集团,也是成都市政府的全资公司,开发过著名景点“宽窄巷子”。\n“宽窄巷子”这个项目,投融资结构比较简单,从立项开发到运营管理,由政府和国企一手包办。“宽窄巷子”处于历史文化保护区域,开发过程涉及保护、拆迁、修缮、重建等复杂问题,且投资金额很大,周期很长,盈利前景也不明朗,民营企业难以处理。因此这个项目从2003年启动至今,一直由两家市属全资国企一手操办:2007年之前由成都城投集团负责,之后则由成都文旅集团接手。2008年景区开放,一边运营一边继续开发,直到2019年6月,首期开发才正式完成,整整花了16年。从文旅集团接手开始算,总共投入约6.5亿元,其中既有银行贷款和自有收入,也有政府补贴。\n成都文旅集团具有政府融资平台类公司的典型特征。\n第一,它持有从政府取得的大量土地使用权。这些资产价值不菲,再加上公司的运营收入和政府补贴,就可以撬动银行贷款和其他资金,实现快速扩张。2007年,公司刚成立时注册资本仅5亿元,主业就是开发“宽窄巷子”。2018年,注册资本已达31亿元,资产204亿元,下属23家子公司,项目很多。 33 第二,盈利状况依赖政府补贴。2015—2016年,成都文旅集团的净利润为6 600多万元,但从政府接收到的各类补贴总额超过2亿元。补贴种类五花八门,除税收返还之外,还有纳入公共预算的专项补贴。比如成都市公共预算内有一个旅游产业扶持基金,2012—2015年间,每年补贴文旅集团1亿元以上。政府还可以把土地使用权有偿转让给文旅集团,再以某种名义将转让费原数返还,作为补贴。 44 2015年新《预算法》要求清理各种补贴。2017—2018年,文旅集团的净利润近1.4亿元,补贴则下降到不足4 000万元。\n盈利状况依赖政府补贴,是否就是没效率?不能一概而论。融资平台公司投资的大多数项目都有基础设施属性,项目本身盈利能力不强,否则也就无需政府来做了。这类投资的回报不能只看项目本身,要算上它带动的经济效益和社会效益。但说归说,这些“大账”怎么算,争议很大。经济学的福利分析,作为一种推理逻辑有些用处,但从中估算出的具体数字并不可靠。2009年初,我第一次去“宽窄巷子”,当时还是个新景点,人并不多。2016年夏天,我第二次去,这里已经成了著名景点,人山人海。根据文旅集团近几年披露的财务数据,“宽窄巷子”每年接待游客2 000万人次,集团营收八九千万元,净利两三千万元,且增速很快,经济效益很好。但就算没有净利甚至亏损,这项目就不成功么?也难说。2 000万游客就算人均消费50元,每年10亿元的体量也是个相当不错的小经济群。而且还带动着周边商业、餐饮、交通的繁荣,社会效益也不错。对政府和老百姓来说,这也许比项目本身的盈利能力更加重要。\n第三,政府的隐性担保可以让企业大量借款。银行对成都文旅集团的授信额度为176亿元,而文旅集团发行的债券,评级也是AA+。该公司是否应有这么高的信用,见仁见智。但市场一般认为,融资平台公司背后有政府的隐性担保。所谓“隐性”,是因为《担保法》规定政府不能为融资平台提供担保。但其实政府不断为融资平台注入各类资产,市场自然认为这些公司不会破产,政府不会“见死不救”,所以风险很低。\n“宽窄巷子”这个项目比较特殊,大多数类似的城市休闲娱乐项目并不涉及大量历史建筑的修缮和保护,开发过程也没这么复杂,可以由民营企业完成。比如遍及全国的万达广场和著名的上海新天地(还有武汉天地、重庆天地等),都是政府一次性出让土地使用权,由民营企业(万达集团和瑞安集团)开发和运营。当地的融资平台公司一般只参与前期的拆迁和土地整理。用术语来说,一块划出来的“生地”,平整清理后才能成为向市场供应的“熟地”,这个过程称为“土地一级开发”。“一级开发”投入大、利润低,且涉及拆迁等复杂问题,一般由政府融资平台公司完成。之后的建设和运营称为“二级开发”,大都由房地产公司来做。\n工业园区开发:苏州工业园区vs华夏幸福 # 从运营模式上看,成都文旅集团有政府融资平台企业的显著特点,但大多数融资平台的主业不是旅游开发,而是工业园区开发和城市基础设施建设。工业园区或开发区在我国遍地开花。2018年,国家级开发区共552家,省级1 991家,省级以下不计其数。 55 苏州工业园区是规模最大也是最成功的国家级开发区之一,占地278平方公里。2019年,园区GDP为2 743亿元,公共财政预算收入370亿元,经济体量比很多地级市还大。 66 如此大规模园区的开发和运营,自然要比“宽窄巷子”复杂得多,参与公司众多,主力是两家国企:兆润集团负责土地整理和基础设施建设(土地一级开发),2019年底刚上市的中新集团负责建设、招商、运营(土地二级开发)。\n兆润集团(全称“苏州工业园区兆润投资控股集团有限公司”)就是一家典型的融资平台公司。这家国有企业由园区管委会持有100%股权,2019年注册资本169亿元,主营业务是典型的“土地金融”:管委会把土地以资本形式直接注入兆润,由它做拆迁及“九通一平”等基础开发,将“生地”变成可供使用的“熟地”,再由管委会回购,在土地市场上以招拍挂等形式出让,卖给中新集团这样的企业去招商和运营。兆润集团可以用政府注入的土地去抵押贷款,或用未来土地出让受益权去质押贷款,还可以发债,而还款来源就是管委会回购土地时支付的转让费及各种财政补贴。兆润集团手中土地很多,在高峰期的2014年末,其长期抵押借款超过200亿元,质押借款超过100亿元。 77 与成都宽窄巷子或上海新天地这样的商业项目相比,开发工业园区更像基础设施项目,投资金额大(因为面积大)、盈利低,大都由融资平台类国企主导开发,之后交给政府去招商引资。而招商引资能否成功,取决于地区经济发展水平和营商环境。像上海和苏州工业园区这种地方,优秀企业云集,所以招商的重点是“优中选优”,力争更好地聚合本地产业资源和比较优势。我去过苏州工业园区三次,每次都感叹其整洁和绿化环境,不像一个制造业企业云集的地方。2019年,园区进出口总额高达871亿美元。虽说其飞速发展借了长三角的东风,但运营水平如此之高,园区管委会和几家主要国企功不可没。\n而在很多中西部市县,招商就困难多了。地理位置不好,经济发展水平不高,政府财力和人力都有限,除了一些土地,没什么家底。因此有些地方干脆就划一片地出来,完全依托民营企业来开发产业园区,甚至连招商引资也一并委托给这些企业。\n这类民企的代表是华夏幸福,这家上市公司的核心经营模式是所谓的“产城结合”,即同时开发产业园区和房地产。简单来说,政府划一大片土地给华夏幸福,既有工业用地,也有商住用地,面积很大,常以“平方公里”为度量单位。华夏幸福不仅负责拆迁和平整,也负责二级开发。在让该公司声名大噪的河北固安高新区项目中,固安县政府签给华夏幸福的土地总面积超过170平方公里。2017年第11期《财新周刊》对华夏幸福做了深度报道,称其为“造城者”,不算夸张。\n工业园区开发很难盈利。招商引资本就困难,而想培育一个园区,需要引入一大批企业,过程更是旷日持久,所以华夏幸福赚钱主要靠开发房地产。所谓“产城结合”,“产”是旗帜,“城”是重点,需要用卖房赚到的快钱去支持产业园区运营。按照流程,政府委托华夏幸福做住宅用地的一级开发,之后这片“熟地”要还给政府,再以招拍挂等公开方式出让给中标的房地产企业。假如在这一环节中华夏幸福没能把地拿回来,也就赚不到房地产二级开发的大钱。但据《财新周刊》报道,在实际操作中,主导一级开发的华夏幸福是近水楼台,其他企业很难参与其产业园区中的房地产项目。\n用房地产的盈利去反哺产业园区,这听起来很像第二章所描述的政府“土地财政”:一手低价供应工业用地,招商引资,换取税收和就业;一手高价供应商住用地,取得卖地收入。但政府“亏本”招商引资,图的是税收和就业,可作为民企的华夏幸福,又能从工业园区发展中得到什么呢?答案是它也可以和政府分享税收收益。园区内企业缴纳的税收(地方留存部分),减去园区运营支出,华夏幸福和政府可按约定比例分成。按照法律,政府不能和企业直接分享税收,但可以购买企业服务,以产业发展服务费的名义来支付约定的分成。\n政府付费使用私营企业开发建设的基础设施(如产业园区),不算什么新鲜事。这种模式叫“政府和社会资本合作”(Public-Private Partnership, PPP),源于海外,不是中国的发明。如果非要说中国特色,可能有二。第一是项目多,规模大。截至2020年5月,全国入库的PPP项目共9 575个,总额近15万亿元,但真正开工建设的项目只有四成。第二个特色是“社会资本”大都不是民营企业,而是融资平台公司或其他国企,比如本节中提到的成都文旅集团、兆润集团、中新集团等。截至2019年末,在所有落地的PPP项目中,民营企业参与率不过三成,大都只做些独立项目,比如垃圾或污水处理。 88 像华夏幸福这样负责园区整体开发的民企并不多,它打造的河北固安高新区项目也是国家级PPP示范项目。最近两年,对房地产行业以及土地市场的限制越来越严,一些大型传统房企也开始探索这种“产城融合”模式,效果如何尚待观察。\n第二章提到,政府搞城市开发和招商引资,就像运营一个商场,需要用不同的代价引入不同的商铺,实现总体收入最大化。政府还可以把这一整套运作都“外包”给如华夏幸福之类的民营企业,让后者深度参与到招商引资的职能中来。\n诺贝尔经济学奖获得者罗纳德·科斯(Ronald Coase)早在20世纪30年代就问过:“企业和市场的边界在哪里?”“市场如果有效,为什么会有企业?”这些问题不容易回答。如果追问下去,企业和政府的边界又在哪里?从纸面定义看,各种实体似乎泾渭分明,但从实际业务和行为模式来看,融资平台类公司就是企业和政府的混合体,而民营企业如华夏幸福,又承担着政府的招商职能。现实世界中没有定义,只有现象,只有环环相扣的权责关系。或者按张五常的说法,只有一系列合约安排。 99 要想理解这些现象,需要深入调研当事人面临的各种约束,包括能力、资源、政策、信息等,简单的政府—市场二元观,没什么用。\n第二节 地方政府债务 # 图3-1总结了“土地财政”和“土地金融”的逻辑。1994年分税制改革后,中央拿走了大部分税收。但因为有税收返还和转移支付,地方政府维持运转问题不大。但地方还要发展经济,要招商引资,要投资,都需要钱。随着城市化和商品房改革,土地价值飙升,政府不仅靠土地使用权转让收入支撑起了“土地财政”,还将未来的土地收益资本化,从银行和其他渠道借入了天量资金,利用“土地金融”的巨力,推动了快速的工业化和城市化。但同时也积累了大量债务。这套模式的关键是土地价格。只要不断地投资和建设能带来持续的经济增长,城市就会扩张,地价就会上涨,就可以偿还连本带利越滚越多的债务。可经济增速一旦放缓,地价下跌,土地出让收入减少,累积的债务就会成为沉重的负担,可能压垮融资平台甚至地方政府。\n图3-1 “土地财政”与“土地金融”\n资料来源:清华大学郑思齐等人的合作研究(2014)。\n地方债的爆发始于2008—2009年。为应对从美国蔓延至全球的金融危机,我国当时迅速出台“4万亿”计划:中央政府投资1.18万亿元(包括汶川地震重建的财政拨款),地方政府投资2.82万亿元。为配合政策落地、帮助地方政府融资,中央也放宽了对地方融资平台和银行信贷的限制。2008年,全国共有融资平台公司3 000余家,2009年激增至8 000余家,其中六成左右是县一级政府融资平台。快速猛烈的经济刺激,对提振急速恶化的经济很有必要,但大水漫灌的结果必然是泥沙俱下。财政状况不佳的地方也能大量借钱,盈利前景堪忧的项目也能大量融资。短短三五年,地方政府就积累了天量债务。直到十年后的今天,这些债务依然没有完全化解,还存在不小的风险。\n为政府开发融资:国家开发银行与城投债 # 实业投资是个复杂而漫长的过程,不是只看着财务指标下注后各安天命,而需要在各个阶段和各个环节处理各种挑战,所以精心选择投资合作伙伴至关重要。我国大型项目的投资建设,无论是基础设施还是工业项目,大都有政府直接参与,主体五花八门:有投融资平台类国企,有相关行业国企,也有科研和设计院所等单位。在现有的金融体制下,有国企参与或有政府背书的项目,融资比较容易。本节聚焦城投公司的基础设施项目融资,第四章和第六章会讨论工业项目中的政府投融资,以及由政府信用催生的过度投资和债务风险。\n20世纪八九十年代,大部分城市建设经费要靠财政拨款。1994年分税制改革后,地方财力吃紧,城市化又在加速,城建经费非常紧张,如果继续依靠财政从牙缝里一点点抠,大规模城建恐怕遥遥无期。但要想在城市建设开发中引入银行资金,需要解决三个技术问题。第一,需要一个能借款的公司,因为政府不能直接从银行贷款;第二,城建开发项目繁复,包括自来水、道路、公园、防洪,等等,有的赚钱,有的赔钱,但缺了哪个都不行,所以不能以单个项目分头借款,最好捆绑在一起,以赚钱的项目带动不赚钱的项目;第三,仅靠财政预算收入不够还债,要能把跟土地有关的收益用起来。\n为解决这三个问题,城投公司就诞生了。发明这套模式的是国家开发银行。1998年,国家开发银行(以下简称“国开行”)和安徽芜湖市合作,把8个城市建设项目捆绑在一起,放入专门创立的城投公司芜湖建投,以该公司为单一借款人向国开行借款10.8亿元。这对当时的芜湖来说是笔大钱,为城市建设打下了基础。当时还不能用土地生财,只能靠市财政全面兜底,用预算安排的偿还基金做偿债来源。2002年,全国开始推行土地“招拍挂”,政府授权芜湖建投以土地出让收益做质押作为还款保证。2003年,在国开行和天津的合作中,开始允许以土地增值收益作为贷款还款来源。这些做法后来就成了全国城投公司的标准模式。 1010 国开行是世界上最大的开发性银行,2018年资产规模超过16万亿元人民币,约为世界银行的5倍。 1111 2008年之前,国开行是城投公司最主要的贷款来源。2008年“4万亿”财政金融刺激之后,各种商业银行包括“工农中建”四大行和城市商业银行(以下简称“城商行”),才开始大规模贷款给城投公司。2010年,在地方融资平台公司的所有贷款中,国开行约2万亿元,四大行2万亿元,城商行2.2万亿元,其他股份制银行与农村合作金融机构合计1万亿元,城商行已经和国开行、四大行平起平坐。 1212 城商行主要由地方政府控制。2015年,七成左右的城商行的第一股东是地方政府。 1313 在各地招商引资竞争中,金融资源和融资能力是核心竞争力之一,因此地方政府往往掌控至少一家银行,方便为融资平台公司和基础设施建设提供贷款。但城商行为融资平台贷款存在两大风险。其一,基础设施建设项目周期长,需要中长期贷款。国开行是政策性银行,有稳定的长期资金来源,适合提供中长期贷款。但商业银行的资金大都来自短期存款,与中长期贷款期限不匹配,容易产生风险。其二,四大行的存款来源庞大稳定,可以承受一定程度的期限错配。但城商行的存款来源并不稳定,自有资本也比较薄弱,所以经常需要在资本市场上融资,容易出现风险。以包商银行为例,2019年被监管机构接管,2020年提出破产申请,属银行业20年来首次。该行吸收的存款占其负债总额不足一半,剩余负债几乎全部来自银行同业业务。 1414 这个例子虽然极端,但在4万亿刺激后的10年中,全国中小城商行普遍高度依赖同业融资。流动性一旦收紧,就可能引发连锁反应。\n城投公司最主要的融资方式是银行贷款,其次是发行债券,即通常所说的城投债。与贷款相比,发行债券有两个理论上的好处。其一,把债券卖给广大投资者可以分散风险,而贷款风险都集中在银行系统;其二,债券可以交易,价格和利率时时变动,反映了市场对风险的看法。高风险债券价格更低,利率更高。灵活的价格机制可以把不同风险的债券分配给不同类型的投资者,提高了配置效率。\n但对城投债来说,这两个理论上的好处基本都不存在。第一,绝大多数城投债都在银行间市场发行,七八成都被商业银行持有,流动性差,风险依然集中在银行系统。第二,市场认为城投债有政府隐性担保,非常安全。缺钱的地方明明风险不小,但若发债时提高一点利率,也会受市场追捧。 1515 事实证明市场是理性的。城投债从2008年“4万亿”刺激后开始爆发,虽经历了大小数次改革和清理整顿,但整体违约率极低。这个低风险高收益的“怪胎”对债券市场发展影响很大,积累的风险其实不小。\n地方债务与风险 # 地方政府的债务究竟有多少,没人知道确切数字。账面上明确的“显性负债”不难算,麻烦主要在于各种“隐性负债”,其中融资平台公司的负债占大头。中外学术界和业界对中国的地方政府债务做了大量研究,所估计的地方债务总额在2015年到2017年间约为四五十万亿元,占GDP的五六成,其中三四成是隐性负债。 1616 地方债总体水平虽然不低,但也不算特别高。就算占GDP六成,再加上中央政府国债,政府债务总额占GDP的比重也不足八成。相比之下,2018年美国政府债务占GDP的比重为107%,日本更是高达237%。 1717 而且我国地方政府借来的钱,并没有多少用于政府运营性支出,也没有像一些欧洲国家如希腊那样去支付社会保障,而主要是投资在了基础设施项目上,形成了实实在在的资产。虽然这些投资项目的回报率很低,可能平均不到1%,但如果“算大账”,事实上也拉动了GDP,完善了基础设施,方便了民众生活,整体经济与社会效益可能比项目回报率高。 1818 此外,我国政府外债很少。根据国家外汇管理局《2019年中国国际收支报告》中的数据,2019年末广义政府(政府加央行)的外债余额为3 072亿美元,仅占GDP的2%。\n但是债务风险不能只看整体,因为欠债的不是整体而是个体。如果某人欠了1亿元,虽然理论上全国人民每人出几分钱就够还了,但实际上这笔债务足以压垮这个人。地方债也是一样的道理,不能用整体数字掩盖局部风险。纵向上看,层级越低的政府负担越重,风险越高。县级债务负担远高于省级,因为县级的经济发展水平更低,财政收入更少。横向上看,中西部的债务负担和风险远高于东部。 1919 虽然从经济分析的角度看,地方政府投资的项目有很多外溢的经济效益和社会效益,但在现实世界里,还债需要借款人手中实打实的现金,虚的效益没用。融资平台投资回报率低,收入就低,还债就有困难。由于有地方政府背后支持,这些公司只要能还上利息和到期的部分本金,就能靠借新还旧来滚动和延续其余债务。但大多数融资平台收入太少,就算是只还利息也要靠政府补贴。2017年,除了北京、上海、广东、福建、四川和安徽等六省市外,其他省份的融资平台公司的平均收入,若扣除政府补贴,都无法覆盖债务利息支出。 2020 但政府补贴的前提是政府有钱,这些钱主要来自和土地开发有关的各种收入。一旦经济遇冷,地价下跌,政府也背不起这沉重的债务。\n地方债的治理与改革 # 对地方债务的治理始于2010年,十年间兜兜转转,除了比较细节的监管措施,重要的改革大概有四项。第一项就是债务置换,从2015年新版《预算法》生效后开始,到2019年基本完成。简单来说,债务置换就是用地方政府发行的公债,替换一部分融资平台公司的银行贷款和城投债。这么做有三个好处。其一,利率从之前的7%—8%甚至更高,降低到了4%左右,大大减少了利息支出,缓解了偿付压力。低利率也有利于改善资本市场配置资金的效率。融资平台占用了大量银行贷款,也发行了大量城投债,因为有政府隐性担保,市场认为这些借款风险很低,但利率却高达7%—8%,银行(既是贷款主体,也是城投债主要买家)当然乐于做大这个低风险高收益的业务,不愿意冒险借钱给其他企业,市场平均利率和融资成本也因此被推高。这种情况严重削弱了利率调节资金和风险的功能,需要改革。其二,与融资平台贷款和城投债相比,政府公债的期限要长得多。因为基础设施投资的项目周期也很长,所以债务置换就为项目建设注入了长期资金,不用在短期债务到期后屡屡再融资,降低了期限错配和流动性风险。其三,至少从理论上说,政府信用要比融资平台信用更高,债务置换因此提升了信用级别。\n债务置换是为了限制债务增长,规范借债行为,所以地方政府不能无限制地发债去置换融资平台债务。中央对发债规模实行限额管理:总体限额由国务院确定并报全国人大或全国人大常委会批准,各地区限额则由财政部根据各地债务风险和财力测算决定,报国务院批准。这种数量管制的好处是限额不能突破,是硬约束;坏处是比较僵硬,不够灵活。经济发达地区可能有更多更好的项目,但因为超过了限额而无法融资,而欠发达地区一些不怎么样的项目,却因为在限额之内就能借到钱。\n第二项改革是推动融资平台转型,厘清与政府之间的关系,剥离其为政府融资的功能,同时破除政府对其形成的“隐性”担保。融资平台公司业务中一些公共服务性质强的城建或基建项目,可以剥离出来,让地方政府用债务置换的方式承接过去,也可以用PPP模式来继续建设。然而很多平台公司负债累累,地方政府有限的财力也只能置换一部分,剩余债务剥离不了,公司转型就很困难。而且要想转型为普通国企,公司的业务和治理架构都要改变,业务要能产生足够的现金流,公司领导也不能再由官员兼任。所以融资平台转型并不容易,目前还远未完成。在这种情况下要想遏制其债务继续增长,就要制止地方政府继续为其提供隐性担保。在近几年的多起法院判例中,地方政府提供的担保函均被判无效。2017年,财政部问责了多起违规担保。比如重庆黔江区财政局曾为当地的融资平台公司出具过融资产品本息承诺函,后黔江区政府、区财政局、融资平台公司的有关负责人均被处分。 2121 第三项改革是约束银行和各类金融机构,避免大量资金流入融资平台。这部分监管的难点不在银行本身,而在各类影子银行业务。第六章会详谈相关改革,包括2018年出台的“资管新规”。\n第四项改革就是问责官员,对过度负债的行为终身追责。这项改革从2016年开始。2018年,中共中央办公厅和国务院办公厅正式下发《地方政府隐性债务问责办法》,要求官员树立正确的政绩观,严控地方政府债务增量,终身问责,倒查责任。最近几年也确实问责了一些干部,案件类型主要集中在各类违规承诺,比如上文中提到的对重庆黔江区政府的问责。这种明显违规的操作容易查处,但更重要的是那些没有明显违规举债却把钱投资到了没效益的项目上的操作,这类行为难以确定和监管。深层次的改革,需要从根本上约束官员的投资冲动。那么,这种冲动的体制根源在哪里呢?\n第三节 招商引资中的地方官员 # 几年前,我参加中部某市的招商动员大会,有位招商业绩不错的干部分享心得:“要对招商机会有敏感度,要做一个执着的跟踪者,不能轻言放弃。要在招商中锻炼自己,做到‘铜头、铁嘴、顺风耳、橡皮腰、茶壶肚、兔子腿’。”铜头,是指敢闯、敢创造机会;铁嘴,是指能说会道,不怕磨破嘴皮;顺风耳和兔子腿,指消息灵通且行动敏捷;茶壶肚,指能喝酒、能社交。这些形容非常形象,容易理解。我当时不太懂什么是“橡皮腰”,后来听他解释:“要尊重客商,身段该软的时候要能弯得下腰,但在谈判过程中也不能随便让步,若涉及本市重要利益,该把腰挺起来的时候也要挺直了。”这些特点让我联想到了推销员。他接下来讲的话又让我想到了客服:“要关注礼仪,注重细节,做到四条。第一,要信守承诺;第二,要记得回电话,客商的电话、信息要及时回复;第三,遇到事情用最快的速度、最高的效率去处理;第四,要做个有心人,拜访客商要提前做好准备工作。”\n当然,台上做报告可以把话说得很漂亮,现实中可能是另一回事。后来我和该市的招商干部打了几次交道,他们确实非常主动,电话打得很勤,有的项目就算已经表示过不适合引入该地区,对方也还会反复联系,拿新的条件和方案不断试探,不会轻易放弃。在几次交流中我了解到,该市招商工作的流程设置得很好,相关激励机制也比较到位,虽然地区资源和条件有限,但招商工作的确做得有声有色。\n我国官僚体系庞大,官僚体系自古就是政治和社会支柱之一,而且一直有吸纳社会精英的传统,人力资源雄厚。根据2010年的第六次人口普查,25—59岁的城市人口中上过大学的(包括专科)约占22%,但在政府工作人员中上过大学的超过一半。在25—40岁的政府工作人员中,上过大学的超过七成,而同龄城市人口中上过大学的只有三成。 2222 如今社会虽然早已多元化了,优秀人才选择很多,但“学而优则仕”的传统和价值观一直都在,且政府依然是我国最有资源和影响力的部门,所以每年公务员考试都非常火爆,至少要达到大专以上文化程度才能报考,而录取比例也非常低。\n本节聚焦地方官员。从人数构成上看,地方官员是官僚体系的绝对主体。按公务员总人数算,中央公务员只占6%,若把各类事业单位也算上,中央只占4%。这在世界各主要国家中是个异数。美国中央政府公务员占比为19%,日本为14%,德国为11%,而经济合作与发展组织(OECD)成员国的平均值高达41%。 2323 官员政绩与激励机制 # 事在人为,人才的选拔和激励机制是官僚体制的核心,决定着政府运作的效果。所谓激励机制,简单来说就是“胡萝卜加大棒”:事情做好了对个人有什么好处?搞砸了有什么坏处?因为发展经济是地方政府的核心任务,所以激励机制需要将干部个人得失与本地经济发展情况紧密挂钩,既要激励地方主官,也要激励基层公务员。\n从“胡萝卜”角度看,经济发展是地方官的主要政绩,对其声望和升迁有重要影响。而对广大普通政府工作人员而言,职务晋升机会虽然很少,但实际收入与本地财政情况密切相关,也和本部门、本单位的绩效密切相关,这些又都取决于本地的经济发展。从“大棒”角度看,一方面有党纪国法的监督惩罚体系,另一方面也有地区间招商引资的激烈竞争。为防止投资和产业流失,地方官员需要改善本地营商环境,提高效率。若某部门为了部门利益而损害整体营商环境,或部门间扯皮降低了行政效率,上级出于政绩考虑也会进行干预。\n地方主官任期有限,要想在任内快速提升经济增长,往往只能加大投资力度,上马各种大工程、大项目。以市委书记和市长为例,在一个城市的平均任期不过三四年,而基础设施或工业项目最快也要两三年才能完成,所以“新官上任三把火”烧得又快又猛:上任头两年,基础设施投资、工业投资、财政支出往往都会快速上涨。而全国平均每年都有三成左右的地级市要更换市长或市委书记,所以各地的投资都热火朝天,“政治-投资周期”比较频繁。 2424 投资需要资金,需要土地财政和土地金融的支持。所以在官员上任的前几年,土地出让数量一般都会增加。而新增的土地供应大多位于城市周边郊区,所以城市发展就呈现出了一种“摊大饼”的态势:建设面积越扩越大,但普遍不够紧凑,通勤时间长、成本高,加重了拥挤程度,也不利于环保。 2525 虽然官员的晋升动机与促进经济增长目标之间不冲突,也对地区经济表现有相当的解释力,但这种偏重投资的增长模式会造成很多不良后果。 2626 2016年之前,官员升迁或调任后就无需再对任内的负债负责,而新官又通常不理旧账,会继续加大投资,所以政府债务不断攀升。在经济发展到一定阶段之后,低风险高收益的工业投资项目减少,基础设施和城市建设投资的经济效益也在减弱,继续加大投资会降低经济整体效率,助推产能过剩。此外,出于政绩考虑,地方官员在基础设施投资方面常常偏重“看得见”的工程建设,比如城市道路、桥梁、地铁、绿地等,相对忽视“看不见”的工程,比如地下管网。所以每逢暴雨,“看海”的城市就很多。 2727 因为官员政绩激励对地方政府投资有重要影响,所以近年来在“去杠杆、去库存、去产能”等供给侧结构性重大改革中,也包含了对地方官员政绩考核的改革。2013年,中组部发布《关于改进地方党政领导班子和领导干部政绩考核工作的通知》,特别强调:“不能仅仅把地区生产总值及增长率作为考核评价政绩的主要指标,不能搞地区生产总值及增长率排名。中央有关部门不能单纯以地区生产总值及增长率来衡量各省(自治区、直辖市)发展成效。地方各级党委政府不能简单以地区生产总值及增长率排名评定下一级领导班子和领导干部的政绩和考核等次。”明确了“选人用人不能简单以地区生产总值及增长率论英雄”这项通知之后,再加上一系列财政和金融改革措施,地方GDP增长率和固定资产投资增长率开始下降。 2828 2019年,中共中央办公厅印发《党政领导干部考核工作条例》,明确在考核地方党委和政府领导班子的工作实绩时,要看“全面工作”,“看推动本地区经济建设、政治建设、文化建设、社会建设、生态文明建设,解决发展不平衡不充分问题,满足人民日益增长的美好生活需要的情况和实际成效”。\n在官员考核和晋升中,政绩非常重要,但这不代表人情关系不重要。无论是公司还是政府,只要工作业绩不能百分百清楚地衡量(像送快递件数那样),那上级的主观评价就是重要的,与上级的人情关系就是重要的。人情和业绩之间可能互相促进:业绩突出容易受领导青睐,而领导支持也有助于做好工作。但如果某些领导为扩大自己的权力和影响,在选人用人中忽视工作业绩,任人唯亲,就可能打击下属的积极性。在这类问题突出的地区,官僚体系为了约束领导的“任性”,可能在晋升中搞论资排辈,因为年龄和工龄客观透明,不能随便修改。但如此一来,政府部门的工作效率和积极性都会降低。 2929 虽然地方官场的人情关系对于局部的政治经济生态会有影响,但是否重要到对整体经济现象有特殊的解释力,我持怀疑态度。一方面,地方之间有竞争关系,会限制地方官员恣意行事;另一方面,人情关系网依赖其中的关键人物,不确定性很大,有“一人得道鸡犬升天”,就有“树倒猢狲散”。但无论是张三得志还是李四倒霉,工作都还是一样要继续做,发展经济也一样还是地方政府工作的主题。\n政绩和晋升无疑对地方一把手和领导班子成员非常重要,却无法激励绝大多数公务员。他们的日常工作与政绩关系不大,晋升希望也十分渺茫。在庞大的政府工作人员群体中,“县处级”及以上的干部大约只占总人数的1%。平均来说,在一个县里所有的正科实职干部中,每年升副县级的概率也就1%,而从副县级干部到县委副书记,还要经历好几个岗位和台阶,动辄数年乃至数十年。 3030 因此绝大多数政府工作人员最在意的激励并不是晋升,而是实际收入以及一些工作福利,包括工资、奖金、补助、补贴、实惠的食堂、舒适的办公条件,等等。这些收入和福利都与本地经济发展和地区财政紧密相关,在地区之间甚至同一地区的部门之间,差异很大。大部分人在日常工作中可以感受到这种差异,知道自己能从本地发展和本单位发展中得到实惠。若有基层部门破坏营商环境,也会受到监督和制约。\n经济学家注重研究有形的“奖惩”,强调外部的激励机制和制度环境,但其实内心的情感驱动也非常重要。任何一个组织,无论是公司还是政府,都不可能只靠外部奖惩来激励员工。外部奖惩必然要求看得见的工作业绩,而绝大多数工作都不像送快递,没有清清楚楚且可以实时衡量的业绩,因此需要使命感、价值观、愿景等种种与内心感受相关的驱动机制。“不忘初心”“家国情怀”“为人民服务”等,都是潜在的精神力量。而“德才兼备、以德为先”的干部选拔原则,也正是强调了内在驱动和自我约束的重要性。 3131 腐败与反腐败 # 政府投资和土地金融的发展模式,一大弊端就是腐败严重。与土地有关的交易和投资往往金额巨大,且权力高度集中在个别官员手中,极易滋生腐败。近些年查处的大案要案大多与土地有关。在最高检《检察日报》从2008年到2013年报道的腐败案例中,近一半与土地开发有关。 3232 随着融资平台和各种融资渠道的兴起,涉嫌腐败的资金又嫁接上了资本市场和金融工具,变得更加隐秘和庞大。党的十八大以来,“反腐败”成为政治生活的主题之一,并一直保持了高压态势。截至2019年底,全国共立案审查县处级及以上干部15.6万人,包括中管干部414人和厅局级干部1.8万人。 3333 从经济发展的角度看,我国的腐败现象有两个显著特点。第一,腐败与经济高速增长长期并存。这与“腐败危害经济”这一过度简单化的主流观念冲突,以腐败为由唱空中国经济的预测屡屡落空。第二,随着改革的深入,政府和市场间关系在不断变化,腐败形式也在不断变化。20世纪80年代的腐败案件大多与价格双轨制下的“官倒”和各种“投机倒把”有关;90年代的案件则多与国企改革和国有资产流失有关;21世纪以来,与土地开发相关的案件成了主流。 3434 要理解腐败和经济发展之间的关系,关键是要理解不同腐败类型的不同影响。腐败大概可以分为两类。第一类是“掠夺式”腐败,比如对私营企业敲诈勒索、向老百姓索贿、盗用挪用公款等,这类腐败对经济增长和产权保护极其有害。随着我国各项制度和法制建设的不断完善、各种监督技术的不断进步,这类腐败已大大减少。比如在20世纪八九十年代,不规范的罚款和乱收费很多,常见的解决方式是私下给办事人员现金,以免去更高额的罚款或收费。如今这种情况少多了,罚款要有凭证,要到特定银行或通过手机缴纳,钱款来去清楚,很难贪腐。我国也基本没有南亚和非洲一些国家常见的“小偷小摸”式腐败,比如在机场过检时在护照里夹钱、被警察找茬要钱等。近些年,我国整体营商环境不断改善。按照世界银行公布的“营商环境便利度”排名,我国从2010年的全球第89位上升至2020年的第31位,而进入我国的外商直接投资近五年来也一直保持在每年1 300亿美元左右的高位。\n第二类腐败是“官商勾连共同发财式”腐败。比如官员利用职权把项目批给关系户企业,而企业不仅要完成项目、为官员贡献政绩,也要在私下给官员很多好处。这类腐败发生在招商引资过程中,而相关投资和建设可以促进经济短期增长,所以腐败在一段时期内可以和经济增长并存。但从经济长期健康发展来看,这类腐败会带来四大恶果。其一,长期偏重投资导致经济结构扭曲,资本收入占比高而劳动收入占比低,老百姓收入和消费增长速度偏慢。第七章会讨论这种扭曲。其二,扭曲投资和信贷资源配置,把大量资金浪费在效益不高的关系户项目上,推升债务负担和风险。第六章会讨论这种风险。其三,权钱交易扩大了贫富差距。第五章会分析不平等对经济发展的影响。其四,地方上可能形成利益集团,不仅可能限制市场竞争,也可能破坏政治生态,出现大面积的“塌方式腐败”。党的十八大以来,中央数次强调党内决不允许搞团团伙伙、拉帮结派、利益输送,强调构建新型政商关系,针对的就是这种情况。\n党的十八大以来的反腐运动,是更为广阔的系统性改革的一部分,其中既包括“去杠杆”等经济结构改革,也包括防范金融风险改革,还包括各类生产要素尤其是土地的市场化改革。这些改革的根本目的,是转变过去的经济发展模式,所以需要打破在旧有模式下形成的利益集团。在改革尚未完成之前,反腐败会长期保持高压态势。2020年,哈佛大学的研究人员公布了一项针对我国城乡居民独立民调的结果,这项调查从2003年开始,访谈人数超过3万人。调查结果显示,党的十八大以后的反腐成果得到了广泛的认可。2016年,约65%的受访者认为地方政府官员整体比较清廉,而2011年这一比例只有35%。 3535 居民对中央政府的满意度长期居于高位,按百分制计算约83分;对地方政府的满意度则低一些,省政府约78分,县乡政府约70分。\n但在尚未完成转型之前,习惯了旧有工作方式的地方官员在反腐高压之下难免会变得瞻前顾后、缩手缩脚。2016年,中央开始强调“庸政懒政怠政也是一种腐败”,要破除“为官不为”。2018年,中共中央办公厅印发《关于进一步激励广大干部新时代新担当新作为的意见》,强调“建立健全容错机制,宽容干部在改革创新中的失误错误,把干部在推进改革中因缺乏经验、先行先试出现的失误错误,同明知故犯的违纪违法行为区分开来;把尚无明确限制的探索性试验中的失误错误,同明令禁止后依然我行我素的违纪违法行为区分开来;把为推动发展的无意过失,同为谋取私利的违纪违法行为区分开来。”这些措施如何落到实处,还有待观察。\n改革开放40年以来,社会财富飞速增长,腐败现象在所难免。美国在19世纪末和20世纪初的所谓“镀金年代”中,各类腐败现象也非常猖獗,“裙带关系”愈演愈烈,经济腐化政治,政治又反过来腐化经济,形成了所谓的“系统性腐败”(systematic corruption)。之后经过了数十年的政治和法治建设,才逐步缓解。 3636 从长期来看,反腐败是国家治理能力建设的一部分,除了专门针对腐败的制度建设之外,更为根本的措施还是简政放权、转变政府角色。正如党的十九大报告所提出的,要“转变政府职能,深化简政放权,创新监管方式,增强政府公信力和执行力,建设人民满意的服务型政府”。\n结语 # 1994年分税制改革后,财权集中到了中央,但通过转移支付和税收返还,地方政府有足够的财力维持运转。但几乎所有省份,无论财政收入多寡,债务都在飞速扩张。可见政府债务问题根源不在收入不够,而在支出太多,因为承担了发展经济的任务,要扮演的角色太多。因此债务问题不是简单的预算“软约束”问题,也不是简单修改政府预算框架的问题,而是涉及政府角色的根本性问题。改革之道在于简政放权,从生产投资型政府向服务型政府逐步转型。\n算账要算两边,算完了负债,当然还要算算借债投资所形成的资产,既包括基础设施,也包括实体企业。给基础建设投资算账,不能只盯着项目本身的低回报,还要算给经济和社会带来的整体效益。但说归说,这笔“大账”怎么算并没有一致认可的标准,争议很大。然而无论怎么争,这笔账总归应该考虑人口密度和设施利用率。在小城市修地铁、在百万人口的城市规划建设几十万人口的新城、在远离供应链的地方建产业园区,再怎么吹得天花乱坠,也很难让人看到效益。至于实体企业,很多行业在资金“大水”漫灌之下盲目扩张,导致产能过剩和产品价格下跌。但同时也有很多行业在宽松的投资环境中迅速成长,跻身世界一流水准,为产业转型升级做出了卓越贡献,比如光电显示、光伏、高铁产业等。下一章就来讲讲它们的故事。\n扩展阅读 # 本章讨论的所有话题,包括拆迁、招商引资、地方债务、户籍与城市化等,都能在周浩导演的杰出纪录片《大同》(又名《中国市长》)中看到。该片记录了大同市原市长耿彦波重建这座城市的故事。2013年,耿彦波调离大同,至今已过去七年有余,如今网络上针对当年那场造城运动以及耿彦波本人的评论褒贬不一,对照影片中记录的各种当年的故事和冲突,引人深思。\n篇幅所限,本章没有展开分析官员行为对经济的各种影响。北京大学周黎安的杰作《转型中的地方政府:官员激励与治理(第二版)》(2017)全面、系统、深入地探讨了这个问题。冯军旗在北京大学的博士论文《中县干部》(2010)生动细致,是了解我国县域官场的上佳之作。密歇根大学洪源远的著作China\u0026rsquo;s Gilded Age: the Paradox of Economic Boom and Vast Corruption(Ang, 2020)讨论了我国近些年来的各类腐败现象,与美国过去及现在的腐败做了对比,解释了腐败为什么可以与经济增长共存。该书也对研究腐败的文献做了全面的梳理,有参考价值。\n如今的主流经济学教材中很少涉及“土地”。在生产和分配中,一般只讲劳动和资本两大要素,土地仅被视作资本的一种。而在古典经济学包括经典马克思主义经济学的传统中,土地和资本是分开的,地主和资本家也是两类人。这种变化与经济发展的阶段有关:在工业和服务业主导的现代经济中,农业的地位大不如前,所以农业最重要的资本投入——“土地”——也就慢慢被“资本”吞没了。然而土地和一般意义上的资本毕竟不同(供给量固定、没有折旧等),且如今房产和地产已成为国民财富中最重要的组成部分,所以应该重新把土地纳入主流微观和宏观经济学的框架,而不是仅将其归类到“城市经济学”或“房地产经济学”等分支。几位英国经济学家的著作Rethinking the Economics of Land and Housing(Ryan-Collins, Lloyd and Macfarlane, 2017)是一次有意义的尝试。\n11 秘鲁经济学家赫尔南多·德·索托(Hernando de Seto)的名著《资本的秘密》(2007)对资本的属性有极佳的论述。\n22 中国人民银行制定的《贷款通则》中对借款人资格做了严格限定,排除了地方政府。1995年版的《预算法》规定地方政府不得发行债券,2014年修订版则允许省级政府发债。\n33 相关数据来自公司发债时披露的信息和报表,读者可以到上海清算所网站下载。\n44 2009年末,成都市财政局把关于“宽窄巷子”历史文化保护区项目的一笔土地出让金3 769.82万元,以“补贴收入”的形式全额返还给了文旅集团,专项用于“宽窄巷子”项目的宣传推广。\n55 数据来自《2018年中国开发区审核公告目录》,由发改委和科技部等六部门联合发布。\n66 数字来自苏州工业园区管委会主页。我的家乡包头市,人口为290万人,2019年的GDP也只有2 715亿元,公共预算收入不过152亿元。\n77 数据来自兆润集团发债的募集说明书和相关评级公告。兆润集团的业务有很多,除开发园区土地之外,也开发房地产。\n88 与PPP相关的数据来自财政部“政府和社会资本合作中心”网站(www.cpppc.org)。\n99 张五常在其著作(2019)第四卷中深入探讨了关于合约选择的一般性理论。\n1010 关于“芜湖模式”的来龙去脉,可参考《国家开发银行史(1994—2012)》(编委会,2013)。\n1111 国开行和世界银行的资产规模来自各自年报。世界银行资产规模仅包括国际复兴开发银行(IBRD)和国际开发协会(IDA)。\n1212 数据来自中国邮政储蓄银行风险管理部党均章和王庆华的文章(2010)。\n1313 数据来自西南财经大学洪正、张硕楠、张琳等人的合作研究(2017)。\n1414 数据来自《财新周刊》2019年第21期的文章《央行银保监联合接管包商银行全纪录 首次有限打破同业刚兑》。\n1515 2010—2012年,中央连续出台政策,收紧了银行对融资平台的贷款,也收紧了信托等融资渠道。为绕开这些管制,融资平台开始大量发行城投债,不惜支付更高利息。\n1616 对隐性负债的估计有很多,数据来源差不多,结果大同小异。此处的数字参考了三种文献:清华大学白重恩、芝加哥大学谢长泰及香港中文大学宋铮的论文(Bai, Hsieh and Song, 2016);海通证券姜超、朱征星、杜佳的研报(2018);德意志银行张智威和熊奕的论文(Zhang and Xiong, 2019)。\n1717 美国和日本的数据来自国际货币基金组织(IMF)全球债务数据库,详见第六章图6-3。\n1818 德意志银行的张智威和熊奕(Zhang and Xiong, 2019)计算了1 109家地方政府融资平台公司的资产回报率。2016年,回报率的中位数只有0.8%。\n1919 越穷的省债务负担越重。上海交通大学的陆铭在著作中(2016)分析了这一关系。\n2020 德意志银行的张智威和熊奕(Zhang and Xiong, 2019)计算了1 109家地方政府融资平台公司的“利息覆盖率”,即公司收入除以利息支出得到的比值。如果这个比值大于1,就有能力付息。\n2121 详见《财新周刊》2017年第21期的封面文章《再查地方隐性负债》。\n2222 城市人口的教育数据来自国家统计局《中国2010年人口普查资料》。政府工作人员的教育数据来自2006—2013年的“中国社会综合调查”微观数据。\n2323 这些数字来自财政部前部长楼继伟的文章(2018)。\n2424 北京大学姚洋和上海财经大学张牧扬(2013)收集了1994—2008年间241个城市1 671名市长和市委书记的数据,发现他们在一个城市的平均任期是3.8年,中位数是3年。中山大学杨海生和罗党论等人(2014)则收集了1999—2013年间近400个地级市的市长和市委书记的资料,发现平均每年都有近三成的地级市中至少有1个人职务发生变更。大量研究显示,地区经济指标随地方主官任期变动,读者可参考北京大学周黎安著作(2017)第六章对这些研究的总结。\n2525 复旦大学王之、北京大学张庆华和周黎安等人的论文发现,市领导升迁和城市面积扩张之间有正向关系(Wang, Zhang and Zhou, 2020)。\n2626 关于省、市、县各级地方主官晋升和当地经济表现之间的关系,研究非常多,北京大学周黎安的著作(2017)对此进行了系统的梳理和总结。\n2727 辽宁大学徐业坤和马光源(2019)研究了官员变更和本地工业企业产能过剩之间的关系。对外经贸大学吴敏和北京大学周黎安(2018)研究了官员晋升和城市“可见”基础设施建设投入之间的关系。\n2828 2013年之后,省市GDP和投资增长率有所下降,这一现象及相关解释可以参考复旦大学张军和樊海潮等人的论文(2020)。\n2929 在我国官场晋升中,政绩和人情都重要,是互补关系,读者可参考圣地亚哥加州大学贾瑞雪、大阪大学下松真之和斯德哥尔摩大学大卫·塞姆(David Seim)等人的论文(Jia, Kudamatsu and Seim, 2015)。关于组织中人情关系和工作表现的基本经济学理论,可以参考芝加哥大学普伦德加斯特(Prendergast)和托佩尔(Topel)的论文(1996),以及复旦大学陈硕、长江商学院范昕宇、香港中文大学朱志韬的论文(Chen,Fan and Zhu, 2020)。\n3030 “县处级”干部占政府工作人员的比例,来自密歇根大学洪源远的著作(Ang,2020)。基层正科晋升副县级的概率,来自北京大学周黎安的著作(2017)。\n3131 斯坦福大学的克雷普斯(Kreps)教授是经济激励理论的大家,他写过一本关于“公司如何激励员工”的通俗小书(2018)。在这本书中,经济学强调的“外部激励”(incentive)只占一部分,而管理学更加重视的“内心驱动”(motivation)则占了大量篇幅。\n3232 数据来自复旦大学陈硕和中山大学朱琳的合作研究(2020)。\n3333 数据来自中央纪委国家监委网站上署名钟纪言的文章《把“严”的主基调长期坚持下去》。\n3434 改革开放以来各种腐败形式的详细数据和分析,可以参考复旦大学陈硕和中山大学朱琳的论文(2020)以及密歇根大学洪源远的著作(Ang, 2020)。\n3535 数据来自哈佛大学坎宁安(Cunningham)、赛什(Saich)和图列尔(Turiel)等人的研究报告(2020)。\n3636 关于美国这一时期的腐败和治理,马里兰大学经济史学家沃利斯(Wallis)的论文(2006)很精彩。\n第四章 工业化中的政府角色 # 2019年初,我访问台北,遇到一位美国企业的本地高管,他说:“你们大陆的经济学跟我在哈佛商学院学的不一样啊,市场竞争和供给需求嘛,你们企业背后都有政府补贴和支持,我们怎么竞争得过,企业都被搞死了哇,这么搞不行哇。”我说:“×总,这企业又不是人,哪有什么死活,就是资源重组嘛。台湾工程师现在在大陆的工资比以前高,产品质量比以前好,价格比以前便宜,不是挺好吗?贵公司去年在武汉落地的厂子,投资百亿元,跟地方政府要补贴和优惠的时候,那可是一点也不让步,一点也不‘市场经济’啊,哈哈。”他说:“补贴嘛,能拿还是要多拿。哎,你回去可别说不该给我们补贴呀!”\n现实世界没有黑白分明的“市场”和“政府”分界,只有利益关系环环相扣的各种组合。我国经济改革的起点是计划经济,所以地方政府掌握着大量资源(土地、金融、国企等),不可避免会介入实业投资。由于实业投资的连续性、复杂性和不可逆性(第三章),政府的介入必然也是深度的,与企业关系复杂而密切,不容易退出。\n在每个具体行业中,由于技术、资源、历史等因素,政企合作的方式各不相同。钢铁是一回事,芯片是另一回事。因此,讨论和分析政府干预和产业政策,不能脱离具体行业细节,否则易流于空泛。社会现象复杂多变,任何理论和逻辑都可以找到不少反例,因为逻辑之外还有天时、地利、人和,不确定性和人为因素对结果影响非常大,而结果又直接影响到对过程和理论的评判。成功了才是宝贵经验,失败了只有惨痛教训。产业政策有成功有失败,市场决策也有成功有失败,用一种成功去质疑另一种失败,或者用一种失败去推崇另一种成功,争论没有尽头。\n因此,本章的重点是具体案例。行业和企业如何借力政府来发展?实行了哪些具体政策?政府资金如何投入和退出,又如何影响行业兴衰和技术起落?首先要了解基本事实和经过,才能评判结果。经济学的数学模型和统计数据不是讲道理的唯一形式,也不一定是最优形式,具体的案例故事常常比抽象的道理更有力量,启发更大。 11 在行业或产业研究中,案例常常包含被模型忽视的大量重要信息,尤其是头部企业的案例。依赖企业财务数据的统计分析,通常强调行业平均值。但平均值信息有限,因为大多数行业“二八分化”严重,头部企业与中小企业基本没有可比性。财务数据也无法捕捉大企业的关键特征:大企业不仅是技术的汇聚点和创新平台,也是行业标准的制定者和产业链核心,与政府关系历来深厚复杂,在资本主义世界也是如此。\n本章前两节是两个行业案例:液晶显示和光伏。叙述的切入点依然是地方政府投融资。读者可以再次看到地方融资平台或城投公司、招商引资竞争、土地金融等,只不过这一次的投资对象不是基础设施和产业园区,而是具体的工业企业。第三节介绍近些年兴起的政府产业投资基金,这种基金不仅是一种新的招商引资方式和产业政策工具,也是一种以市场化方式使用财政资金的探索。\n第一节 京东方与政府投资 # 2020年“双11”期间,戴尔27寸高清液晶显示屏在天猫的售价为949元。2008年,戴尔27寸液晶显示器售价7 599元,还远达不到高清,不是窄边框,也没有护眼技术。2020年,3 000多元就可以买到70寸的高清液晶电视,各种国产品牌都有。而在2008年,只有三星和索尼能生产这么大的液晶电视,售价接近40万元,是今天价格的100倍,在当时相当于北京、上海的小半套房。\n惊人的价格下跌背后是技术进步和国产替代。显示屏和电视,硬件成本近八成来自液晶显示面板。2008年,面板行业由日韩和中国台湾企业主导,大陆企业的市场占有率可以忽略不计。2012年,我国进口显示面板总值高达500亿美元,仅次于集成电路、石油和铁矿石。到了2020年,大陆企业在全球市场的占有率已接近四成,成为世界第一,彻底摆脱了依赖进口的局面,涌现出了一批重量级企业,如京东方、华星光电、深天马、维信诺等。国产显示面板行业的崛起不仅推动了彩电和显示器等价格的直线下降,也推动了华为和小米等国产手机价格的下降,促成了使用液晶屏幕的各类国产消费电子品牌的崛起。\n在显示面板企业的发展过程中,地方政府的投资发挥了关键作用。以规模最大也最重要的公司京东方为例,其液晶显示面板在手机、平板电脑、笔记本电脑、电视等领域的销量近些年来一直居于全球首位。 22 根据其2020年第三季度的报告,前六大股东均是北京、合肥、重庆三地国资背景的投资公司,合计占股比例为23.8%。其中既有综合类国资集团(如北京国有资本经营管理中心),也有聚焦具体行业的国有控股集团(如北京电子控股),还有上一章讨论的地方城投公司(如合肥建投和重庆渝富)。投资方式既有直接股权投资,也有通过产业投资基金(见本章第三节)进行的投资。\n京东方和政府投资的故事 # 20世纪90年代末和21世纪初,我国大陆彩电行业的重头戏码是各种价格战。当时大陆的主流产品还是笨重的显像管(CRT)电视,建设了大量显像管工厂。但其时国际技术主流却已转向了平板液晶显示,彻底取代显像管之势不可逆转,而占液晶电视成本七八成的显示面板,大陆却没有相关技术,完全依赖进口。大陆花了近20年才让彩电工业价值链的95%实现了本土化,但由于没跟上液晶显示的技术变迁,一夜之间价值链的80%又需要依赖进口。 33 而主要的面板厂商都在日韩和中国台湾,他们常常联手操纵价格和供货量。2001年至2006年,三星、LG、奇美、友达、中华映管、瀚宇彩晶等六家主要企业,在韩国和中国台湾召开了共计53次“晶体会议”,协商作价和联合操纵市场,使得液晶面板一度占到电视机总成本的八成。2013年,发改委依照《价格法》(案发时候还没有《反垄断法》,后者自2008年起施行)中操纵市场价格的条款,罚了这六家企业3.5亿元。欧美也对如此恶劣的价格操纵行为做了处罚:欧盟罚了他们6.5亿欧元,美国罚了他们13亿美元。 44 在这一背景下,具有自主技术和研发能力的京东方逐渐进入了人们的视野。这家企业的前身是老国企“北京电子管厂”,经过不断改制和奋斗,21世纪初已经具备了生产小型液晶显示面板的能力。这些能力大多源自2005年在北京亦庄经济技术开发区建设的5代线,这是国内第二条5代线,当时非常先进,距离全球第一条5代线(韩国LG)的建成投产时间也不过三年。 55 这条生产线收购自韩国企业,投资规模很大。当时的融资计划是设立一家公司在中国香港上市,为项目建设融资,但这个上市计划失败了。可生产线已经开始建设,各种设备的订单也已经下了,于是在北京市政府与国开行的协调下,9家银行组成银团,由建设银行北京分行牵头,贷款给京东方7.4亿美元。北京市政府也提供了28亿元的借款,以国资委的全资公司北京工业发展投资管理有限公司为借款主体。这笔政府借款后来转为了股份,在二级市场套现后还赚了一笔。此外,在5代线建设运营期间,北京市政府还先后给予两次政策贴息共1.8亿元,市财政局也给了一笔专项补助资金5 327万元。 66 天有不测风云。京东方5代线的运气不好,在液晶面板大起大落的行业周期中,投在了波峰,产在了波谷。其主打产品即17寸显示屏的价格从动工建设时的每片300美元暴跌到了量产时的每片150美元。2005年和2006年两年,京东方亏损了33亿元,北京市政府无力救助。若银团贷款不能展期,就会有大麻烦。银团贷款展期必须所有参与的银行都同意,而9家银行中出资最少的1家小银行不同意,反复协调后才做通工作,但其中的风险和难度也让京东方从此改变了融资模式。其后数条生产线的建设都采用股权融资:先向项目所在地政府筹集足够的资本金,剩余部分再使用贷款。\n2008年,京东方决定在成都建设4.5代线,第一次试水新的融资模式。这条生产线总投资34亿元,其中向成都市两家城投公司定向增发股票18亿元,剩余16亿元采用银团贷款,由国开行牵头。两家城投公司分别是成都市政府的全资公司成都工业投资集团(现名成都产业投资集团)和成都高新区管委会的全资公司成都高新投资集团。这两家公司不仅有大量与土地开发和融资相关的业务(见第三章),也是当地国资最重要的产业投资平台。与北京5代线项目相比,成都4.5代线的资本金充足多了,京东方运气也好多了。这条以小屏幕产品为主的生产线,投产后正好赶上了智能手机的爆发,一直盈利,也为京东方布局手机屏幕领域占了先机。\n但当时最赚钱的市场还是电视。主流的27寸和32寸大屏幕电视,显示面板完全依赖进口。但建设一条可生产大屏幕的6代线(可生产18—37寸屏幕)所需投资超过百亿元,融资是个大问题。2005—2006年,国内彩电巨头TCL、创维、康佳、长虹等计划联手解决“卡脖子”问题,于是拉来了京东方,在深圳启动了“聚龙计划”,想借助财力雄厚的深圳市政府的投资,在当地建设6代线。但信息流出后,日本夏普开始游说深圳市政府,提出甩开技术落后的京东方,帮深圳建设一条投资280亿元的7.5代线。由于夏普的技术和经验远胜京东方,深圳市政府于是在2007年与夏普签署合作协议,京东方出局,“聚龙计划”流产。但仅一个多月之后,夏普就终止了与深圳的合作。当时上海的上广电(上海广电信息产业股份有限公司)也计划和京东方在昆山合作建设一条6代线,但夏普再次上门搅局,提出与上广电合作,将京东方踢出局。随后不久,夏普再次找借口退出了与上广电的合作。\n夏普的两次搅局推迟了我国高世代产线的建设,但也给了合肥一个与京东方合作的机会。2008年的合肥,财政预算收入301亿元,归属地方的只有161亿元,想建一条投资175亿元的6代线,非常困难,经济和政治决策风险都很大。但当时的合肥亟待产业升级、提振经济发展,领导班子下了很大决心,甚至传说一度要停了地铁项目来建设这条6代线。融资方案仍然采用京东方在成都项目中用过的股票定向增发,但因为投资金额太大、合肥政府财力不足,所以这次增发对象不限于政府,也面向社会资本。但合肥政府承诺出资60亿元,并承诺若社会资本参与不足、定向增发不顺利时,兜底出资90亿元,可以说是把家底押上了。在这个过程中,夏普又来搅局,但因为京东方已经吃过两次夏普的亏,所以在与合肥合作之初就曾问过市领导:如果夏普来了怎么办?领导曾表示过绝不动摇,所以这次搅局没有成功。\n上一章说过,在经济发展起步阶段,资本市场和信用机制都不完善,因此以信用级别高的政府为主体来融资和投资,更为可行。这不仅适用于与土地有关的债务融资,也适用于股权融资。在合肥6代线项目的股票定向增发上,市政府参与的主体又是两家城投公司,市政府的全资公司合肥建投和高新区管委会的全资公司合肥鑫城。 77 二者的参与带动了社会资本:2009年的这次定向增发一共融资120亿元,两家城投公司一共只出资了30亿元,其他8家社会投资机构出资90亿元。 88 与成都项目类似,定向增发之外,京东方再次利用了国开行牵头的银团贷款,金额高达75亿元。\n合肥6代线是我国第一条高世代生产线,也是新中国成立以来安徽省最大的一笔单体工业投资。这条生产线生产出了大陆第一台32寸液晶屏幕,让合肥一跃成为被关注的高技术制造业基地。不仅很多中央领导来视察,周边经济发达的江浙沪领导也都组团来考察,为合肥和安徽政府赢得了声誉。京东方后来又在合肥建设了8.5代(2014年投产)和10.5代生产线(2018年投产),吸引了大量上下游厂商落地合肥,形成了产业集群,使合肥成为我国光电显示产业的中心之一。\n2008年全球金融危机爆发和“4万亿”计划出台之后,京东方进入了快速扩张阶段。2009年初,中央首次将发展“新型显示器件”列入政策支持范围。 99 6月,合肥6代线开工建设。8月,京东方8.5代线的奠基仪式在北京亦庄经济技术开发区举行,彻底打破了韩日和中国台湾地区对大陆的技术和设厂封锁。接下来的一两个月内,坐不住的境外厂商开始迅速推进与大陆的实质性合作。夏普和南京的熊猫集团开始合资建线,LG和广州签约建设8代线,三星则和苏州签约建设7.5代线。中国台湾的面板厂商也开始呼吁台湾当局放开对大陆的技术限制,允许台商在大陆设厂。但这些合资项目并没有获得我国政府的快速批准,京东方赢得了一些发展时间。\n在这一快速扩张阶段,京东方的基本融资模式都是“扩充资本金+银团贷款”。地方政府投资平台既可以参与京东方股票定向增发来扩充其资本金,也可以用土地使用权收益入股。在鄂尔多斯生产线的建设过程中,地方政府甚至拿出了10亿吨煤矿的开采权。此外,地方城投公司也可以委托当地银行向京东方提供低息甚至免息委托贷款。比如在北京亦庄8.5代线的建设过程中,亦庄开发区的全资公司亦庄国投就曾委托北京银行向京东方贷款2亿元,年利率仅为0.01%。 1010 再比如,2015年京东方在成都高新区建设新的产线,高新区管委会的全资公司成都高投就先后向京东方提供委托贷款44亿元,年利率为4.95%,但所有利息都由高新区政府全额补贴。 1111 2014年,京东方做了最大的一笔股票定向增发,总额为449亿元,用于北京、重庆、合肥等地的产线建设。这笔增发的参与者中前三位都是当地的政府投资平台:北京约85亿元,重庆约62亿元,合肥约60亿元。 1212 2015年之后,随着新世代产线的投资规模越来越大,京东方基本上停止了新的股票定向增发,而让地方政府平台公司通过银团贷款或其他方式去筹集资金。比如2015年开工建设的合肥10.5代线项目,计划投资400亿元,项目资本金220亿元,银团贷款180亿元。在这220亿中,市政府通过本地最大的城投公司合肥建投筹集180亿,京东方自筹40亿。筹资过程中也利用了政府产业投资基金(如合肥芯屏产业投资基金)这一新的方式引入了外部资金(见本章第三节)。 1313 京东方的发展路径并非孤例。位列国内显示面板第二位的TCL华星光电虽然是民营企业,但同样也是在政府投资推动下发展的。2007年“聚龙计划”流产后,TCL集团的董事长李东生屡次尝试与外商合资引进高世代面板产线,均告失败,于是他与深圳市政府商议组建团队自主建设8.5代线。该项目计划投资245亿元,是深圳历史上最大的单体投资工业项目。首期出资100亿,TCL从社会上募集50亿,深圳市通过国资委旗下的投资公司深圳市投资控股有限公司出资50亿(具体由子公司深超投资执行)。这个项目风险很大,因为TCL和京东方不同,并没有相关技术储备和人才,基本依靠从台湾挖来的工程师团队。深圳市政府为降低风险,还将15%的股份卖给了三星。这些股份后来大部分被湖北省政府的投资基金收购,用于建设华星光电在武汉的生产线。2013—2017年,华星光电营业收入从155亿元涨到306亿元,净利润从3.2亿元涨到49亿元。正是因为有华星光电,在家电行业逐渐败退的TCL集团才成功转向面板生产,2019年正式更名为TCL科技。 1414 经济启示 # 现代工业的规模经济效应很强。显示面板行业一条生产线的投资动辄百亿,只有大量生产才能拉低平均成本。因此新企业的进入门槛极高,不仅投资额度大,还要面对先进入者已经累积的巨大成本和技术优势。若新企业成功实现大规模量产,不仅自身成本会降低,还会抢占旧企业的市场份额,削弱其规模经济,推高其生产成本,因此一定会遭遇旧企业的各种打压,比如三星可以打价格战,夏普也可以到处搅局。\n经济学教科书中关于市场竞争的理论一般都是讲国内市场,不涉及国际市场,所以新进入者可以寻求一切市场手段去打破在位者的优势,比如资本市场并购、挖对方技术团队等。若在位者的打压手段太过分,还可以诉诸《反垄断法》。但在国际市场上,由国界和政治因素造成的市场扭曲非常多。关税和各种非关税壁垒不过是常规手段,价格操控、技术封锁、并购审查等也是家常便饭。比如中国公司去海外溢价收购外国公司,标的公司闻风股价大涨,股东开心,皆大欢喜,但对方政府却不允许,市场经济的道理讲不通。若资源不能流动和重组,市场竞争、优胜劣汰及比较优势等传统经济学推理的有效性,都会受到挑战。\n行政手段造成的扭曲往往只有行政力量才能破解,但这并不意味着政府就一定该帮助国内企业进入某个行业,关键还要看国内市场规模。在一个只有几百万人口的小国,政府若投资和补贴国内企业,这些企业无法利用国内市场的规模经济来降低成本,必须依赖出口,那政府的投入实际上是在补贴外国消费者。但在我国,使用液晶屏幕的很多终端产品比如电视和手机,其全球最大的消费市场就在国内,所以液晶显示产业的外溢性极强。若本国企业能以更低的价格生产(不一定非要有技术优势,能够拉低国际厂商的漫天要价也可以),政府就可以考虑扶持本国企业进入,这不仅能打破国际市场的扭曲和垄断,还可以降低国内下游产业的成本,促进其发展。 1515 政府投资上游产业的同时也促进下游产业的发展,这种例子有不少。20世纪70年代初,美国在越南战争中失利,重新调整亚洲战略。尼克松宣布终止对其亚洲盟友的直接军事支持。时任韩国总统朴正熙相应地调整了产业发展战略,着力发展重工业,以夯实国防基础。自1973年起,韩国政府通过国家投资基金(National Investment Fund)和韩国产业银行(Korea Development Bank)将大量资金投入六大“战略行业”:钢铁、有色金属、造船、机械、电子、石化。这一产业发展战略在当时受到了很多质疑。1974年,世界银行在一份报告中明确表示,对韩国的产业目标能否实现持保留意见,认为这些产业不符合韩国的比较优势,并建议把纺织业这个资金和技术壁垒较低的行业作为工业化的突破口。 1616 韩国人没听他们的。后来不仅这些战略行业本身发展得很好,培育了世界一流的造船业以及浦项制铁和三星电子这样的世界顶尖企业,而且大大降低了下游产业投入品的价格,推动了下游产业如汽车行业的发展,培育出了现代集团这样的一流车企。1979年,朴正熙遇刺身亡,韩国产业政策开始转型,原有的很多扶持政策被废止。但这些产业的基础已经扎稳,后来长期保持着良好的发展。 1717 京东方和华星光电等企业的崛起,带动了整个光电显示产业链向我国集聚。这也是规模效应的体现,因为规模不够就吸引不到上下游企业向周围集聚。一旦行业集聚形成,企业自身的规模经济效应就会和行业整体的规模经济效应叠加,进一步降低运输和其他成本。光电显示面板产业规模大、链条长,目前很多上游环节(显示材料、生产设备等)依然由国外厂商主导,利润率高于面板制造环节。但京东方等国内面板生产企业的发展,拉动了众多国内企业进入其供应链,而其中用到的很多技术和材料,也可以用于其他产业(比如半导体),从而带动了我国很多相关行业的发展。不仅如此,无论是京东方的竞争对手还是合作伙伴,诸多海外企业纷纷在我国设厂,也带动了我国上游配套企业的发展。 1818 规模经济和产业集聚也会刺激技术创新。市场大,利润就大,就能支撑更大规模的研发投入。产业的集聚还会带来技术和知识的外溢,促进创新。根据世界知识产权组织(WIPO)每年的报告,从2016年到2019年,国际专利申请数量最多的全球十大公司中,每年都有京东方(还有华为)。\n创新当然是经济持续增长的源动力,但创新是买不来的,只能靠自己做。创新必须基于知识和经验的积累,所以只能自己动手“边做边学”,否则永远也学不会。只有自己动手,不是靠简单的模仿和引进,才能真正明白技术原理,才能和产业链上的厂商深入交流,才能学会修改设计以适应本土客户的要求,也才能逐步实现自主创新。若单纯依靠进口或引进,没有自己设厂和学习的机会,那本国的技术就难以进步,很多关键技术都会受制于人,这样的国际分工和贸易并不利于长期经济增长。 1919 很多关于我国工业发展的纪录片中都详细记录了我国各行业工人、工程师、科学家们在生产过程中的艰难摸索和自主创新,本章的“扩展阅读”中会推荐其中一些作品。这就好比学生学习写论文,不自己动手研究、动手做、动手写,只靠阅读别人的东西,理解永远只能停留在表面,停留在知识消费的层次,不可能产出新知。就算全天下的论文和书籍都摆在面前,一个人也不会自动成为科学家。\n强调自主创新不是提倡闭关锁国。当然没必要所有事情都亲力亲为,况且贸易开放也是学习的捷径,和独立自主并不矛盾。 2020 但在大多数工业化国家,相当大一部分研发支出和技术创新均来自本土的大型制造业(非自然资源类)企业。 2121 这也正是我们从京东方的发展故事中所看到的。像我国这样一个大国,需要掌握的核心技术及产品种类和数量,远远多过一些中小型国家。第七章会进一步讨论这个话题。\n“东亚经济奇迹”一个很重要的特点,就是政府帮助本土企业进入复杂度很高的行业,充分利用其中的学习效应、规模效应和技术外溢效应,迅速提升本土制造业的技术能力和国际竞争力。假如韩国按照其1970年显示出的“比较优势”来规划产业,就应该听从世界银行的建议去发展纺织业。但韩国没有这么做,而是一头扎进了本国根本没有的产业。到了1990年,韩国最具“比较优势”的十大类出口商品,比如轮船和电子产品,1970年时根本就不存在。 2222 可见“比较优势”具有很大的不确定性,是可以靠人为创造的。其实“比较优势”并不神秘,就是机会成本低的意思。而对于没干过的事情,事前其实无从准确判断机会成本,没干过怎么知道呢?\n中国也是如此。政府和私人部门合力进入很多复杂的、传统上没有比较优势的行业,但经过多年发展,其产品如今在国际上已经有了比较优势。 2323 从2000年到2018年,我国出口商品的复杂程度从世界第39位上升到了第18位。 2424 这不仅反映了技术能力和基础设施等硬件质量的提升,也反映了营商环境和法制环境等软件质量的提升。因为复杂的产品和产业链涉及诸多交易主体和复杂商业关系,投资和交易金额往往巨大,所以对合同的制订和执行、营商环境稳定性、合作伙伴间信任关系等都有很高要求。各国产品的复杂程度与本国法制和营商环境之间直接相关。 2525 而按照世界银行公布的“营商环境便利度”排名,我国已从2010年的世界第89位上升至2020年的第31位。\n地方政府竞争 # 在各地招商引资竞争中,地方政府为了吸引京东方落户本地,开出的条件十分优厚。上一章曾讨论过,城投公司的基础设施投资,不能只看项目本身的财务回报,还要看对当地经济的整体带动。这道理对产业类投资也适用。京东方不仅自身投资规模巨大,且带来的相关上下游企业的投资也很大,带动的GDP、税收及就业十分可观。曾有合肥市政府相关人士反驳外界对其投资京东方的质疑:“不要以为我们不会算账,政府是要算细账的。一个京东方生产线,从开始建就能拉动300亿元的工业投资,建成之后的年产值就是千亿级别。从开建到完全投产不到五年时间,五年打造一个千亿级别的高新技术产业,这种投资效率非常高了。” 2626 新兴制造业在地理上的集聚效应很强,因为扎堆生产可以节约原材料和中间投入的运输成本,而且同行聚集在一起有利于知识和技术交流,外溢效应很强。因此产业集群一旦形成,自身引力会不断加强,很难被外力打破。但在产业发展早期,究竟在哪个城市形成产业集群,却有很多偶然因素。 2727 大部分新兴制造业对自然条件要求不高,不会特别依赖先天自然资源,而且我国基础设施发达,物流成本低,所以一些内陆的中心城市虽然没有沿海城市便利,但条件也不是差很多。这些城市若能吸引一些行业龙头企业落户,就有可能带来一大片相关企业,在新兴产业的发展中占得一席之地,比如合肥的京东方和郑州的富士康等。\n由于京东方生产线投资巨大,很自然首先要谋求与财力雄厚的深圳或上海合作,但两次都被夏普搅局,就给了合肥和成都机会。2001年,中国加入WTO后,广东和江浙沪发展迅猛,而合肥、成都、武汉等内地中心城市则亟待产业转型,提振经济发展,这些城市为此愿意冒险,全力投资新兴产业。京东方在合肥先后投资建设了三条生产线,吸引了大量配套企业,使合肥成为我国光电显示产业的主要基地之一。已如前文所述,这一产业使用的很多技术又与其他产业直接相关,比如芯片和半导体,所以合肥政府利用和京东方合作的经验和产业基础,后来又吸引了兆易创新等半导体行业龙头企业,设立了合肥长鑫,成为我国内存(DRAM)制造产业的中心之一。2008年至2019年,合肥的实际GDP(扣除物价因素)上涨了3.4倍,高于全国GDP同期上涨幅度(2.3倍)。2020年,合肥GDP总量破万亿,新晋“万亿GDP城市”(2020年末共有23个城市)。\n这种发展效应自然会引发其他地区的模仿,不少城市都上马了液晶面板生产线,而政府扶持也吸引了一些并无技术实力和竞争力的小企业进入该行业,引发了对产能过剩的担忧。 2828 显示面板是一个周期性极强的行业:市场价格高涨时很多企业进入,供给快速增加,推动价格大跌,让不少企业倒闭,而低价又会刺激和创造出更多新的需求和应用场景,推动需求和价格再次上涨。这种周期性的产能过剩已经清洗掉了很多企业,行业中心也在一轮轮的清洗中从美国转到日本,再到韩国和我国台湾地区,再到大陆。也许在未来的世界,屏幕会无处不在,连房间的整面墙壁甚至窗户,都会是屏幕。但也有可能会有不可思议的“黑科技”出世,完全消灭掉现有显示技术,就像当年液晶技术消灭掉显像管技术一样。没人能够预知未来,但招商引资竞争所引发的重复建设确实屡见不鲜,尤其在那些技术门槛较低、投资额度较小的行业,比如曾经的光伏行业。\n第二节 光伏发展与政府补贴 # 光伏就是用太阳能发电。2012年前后,我国很多光伏企业倒闭,全行业进入寒冬。所以在很长一段时间里,无论在政府、学术界还是媒体眼中,光伏都是产业政策和政府补贴失败的“活靶子”。但假如有人在当年滔天的质疑声中悄悄买入一些光伏企业的股票,比如隆基股份,现在也有几十倍的收益了。实际上,经过当年的行业洗牌之后,我国的光伏产业已经成为全球龙头,国内企业(包括其海外工厂)的产能占全球八成。该产业的几乎全部关键环节,如多晶硅、硅片、电池、组件等,我国企业都居于主导地位。 2929 在规模经济和技术进步的驱动之下,光伏组件的价格在过去十年(2010—2019)下降了85%,同期的全球装机总量上升了16倍。我国国内市场也已成为全球最大的光伏市场,装机总量占全球的三分之一。 3030 光伏已经和高铁一样,成为“中国制造”的一张名片。\n光伏产业的故事 # 20世纪70年代,阿拉伯世界禁运石油,油价飙涨,“石油危机”爆发,刺激了美国政府扶持和发展新能源产业。卡特政府大量资助光伏技术研究,补贴产业发展。80年代初,美国光伏市场占全球市场的85%以上。但随后里根上台,油价回落,对光伏的支持和优惠政策大都废止。产业链开始向政府补贴更慷慨的德国和日本转移。这一时期,澳大利亚新南威尔士大学的马丁·格林(Martin Green)教授发展了很多新技术,极大提升了光伏发电的效率,被誉为“光伏之父”。他的不少学生后来都成了我国光伏产业的中坚,其中就包括施正荣博士。 3131 2001年,施正荣在无锡市政府的支持下创办了尚德,占股25%,无锡的三家政府投资平台(如无锡国联发展集团)和五家地方国企(如江苏小天鹅集团)共出资600万美元,占股75%。可以说无锡政府扮演了尚德“天使投资人”的角色。2005年,尚德成为中国首家在纽交所上市的“民营企业”,因为在上市前引入了高盛等外资,收购了全部国资股份。施本人的持股比例也达到46.8%,上市后一跃成为中国首富。这种造富的示范效应非常强烈,刺激各地政府纷纷上马光伏项目。2005年,在江西新余市政府的一系列扶持之下,赛维集团成立。2007年就成为江西首家在纽交所上市的公司,创始人彭小峰成为江西首富。2010年,在海内外上市的中国光伏企业已超过20家。 3232 2008年,各地加大了对基础设施和工业项目的投资,包括光伏。主要手段还是廉价土地、税收优惠、贴息贷款等。在刺激政策与地方政府的背书之下,尚德和赛维等龙头企业开始大规模负债扩张。2011年初,尚德规模已经不小,但无锡政府又提出“5年内再造一个尚德”,划拨几百亩土地,鼓励尚德再造一个5万人的工厂,并帮助其获得银行贷款。2011年,赛维已经成了新余财政的第一贡献大户,创造就业岗位2万个,纳税14亿元,相当于当年新余财政总收入的12%。 3333 与以满足国内需求为主的液晶显示面板行业不同,这一时期的光伏产品主要出口欧美市场,尤其是德国和西班牙,因为其发电成本远高于火电和水电,国内消费不起。2011年的光伏出口中,57%出口欧洲,15%出口美国。虽然国内从2009年起也陆续引入了一些扶持和补贴政策(如“金太阳工程”),补贴光伏装机,但总量并不大。2010年,国内市场只占我国光伏企业销量的6%。 3434 因为光伏发电成本远高于传统能源,所以光伏的海外需求也离不开政府补贴。欧洲的补贴尤其慷慨。德国不仅对装机有贷款贴息优惠,还在2000年就引入了后来被全球广泛借鉴的“标杆电价”补贴(feed-in tariff,FiT)。光伏要依靠太阳能,晚上无法发电,电力供应不稳定,会对电网造成压力,因此电网一般不愿意接入光伏电站。但在“标杆电价”制度下,电网必须以固定价格持续购买光伏电量,期限20年,该价格高于光伏发电成本。这种价格补贴会加到终端电价中,由最终消费者分摊。这个固定价格会逐渐下调,以刺激光伏企业技术进步,提高效率。但事实上,价格下降速度慢于光伏的技术进步和成本下降速度,所以投资光伏发电有利可图。可以说我国光伏产业不仅是国内地方政府扶持出来的,也得益于德国、西班牙、意大利等国政府的“扶持”。在欧美市场,我国企业借助规模效应、政府补贴以及产业集聚带来的成本优势,对其本土企业造成了不小冲击。\n2009年到2011年,美国金融危机和欧债危机相继爆发,欧洲各国大幅削减光伏补贴。同时,为应对我国企业的冲击,美国和欧盟从2011年底开始陆续对我国企业展开“反倾销,反补贴”调查,关税飙升。其实,这一时期我国专门针对光伏的补贴总量很有限,大部分补贴不过都是地方招商引资中的常规操作,比如土地优惠和贷款贴息,并非具体针对光伏。只有光伏产业集聚的江苏省在2009年率先推出了与德国类似的“标杆电价”补贴,确定2009年光伏电站入网电价为每度2.15元,远高于每度约0.4元的煤电上网电价。补贴资金源于向省内电力用户(不包括居民和农业生产用电)收取电价附加费,建立省光伏发电扶持专项资金。为鼓励企业提高效率、降低成本,江苏将2010年和2011年的“标杆电价”降为每度1.7元和1.4元。 3535 在2008年金融危机和“双反”调查前几年,我国光伏企业已经在急速扩张中积累了大量产能和债务,如今出口需求锐减,大量企业开始破产倒闭,包括曾经风光无限的尚德和赛维,光伏产业进入寒冬。在这种背景之下,光伏的主要市场开始逐渐向国内转移。\n2011年,中央政府开始分阶段对光伏施行“标杆电价”补贴,要求电网按固定价格(1.15元/度)全额购买光伏电量,并从2013年起实行地区差别定价。 3636 具体来说,是把全国分为三类资源区,Ⅰ类是西北光照强的地区,Ⅱ类是中西部,Ⅲ类是东部,每度电上网电价分别定为0.9/0.98/1元。与当时煤电的平均上网电价约0.4元相比,相当于每度电补贴0.6元。对分布式光伏则每度电补贴0.42元。在资金来源方面,是向电力终端用户征收“可再生能源电价附加”,上缴中央国库,进入“可再生能源发展基金”。除中央的电价补贴之外,很多省市也有地方电价补贴。比如上海就设立了“可再生能源和新能源发展专项资金”,对光伏电站实行每度电0.3元的固定补贴,资金来自本级财政预算和本市实行的差别电价电费收入。 3737 与世界各国一样,我国的电价补贴也随时间逐步下调,以引导光伏企业不断降低成本。2016—2017年,我国两次调低三类地区的“标杆电价”至每度电0.65/0.75/0.85元,下降幅度达到28%/23%/15%。实际上,企业的效率提升和成本降幅远快于补贴降幅,同期光伏组件价格每年的下降幅度均超过30%,所以投资光伏电站有利可图,装机规模因此快速上升。2016—2017年两年,我国光伏组件产量占全球产量的73%,而光伏装机量占全球的51%,不仅是全球最大的产地,也成了最大的市场。 3838 但装机量的急速上涨造成了补贴资金严重不足,拖欠补贴现象严重。如果把对风电的欠补也算上的话,2018年6月,可再生能源补贴的拖欠总额达到1 200亿元。很多光伏电站建在阳光充足且地价便宜的西部,但当地人口密度低、经济欠发达,用电量不足,消纳不了这么多电。跨省配电不仅成本高,且面临配电体系固有的很多制度扭曲,所以电力公司经常以未拿到政府拖欠的补贴为由,拒绝给光伏电厂结算,导致甘肃、新疆等西部省份的“弃光”现象严重。 3939 在这种大背景下,2018年5月31号“531新政”出台,大幅降低了补贴电价,也大幅缩减了享有补贴的新增装机总量,超过这个量的新增装机,不再能享受补贴指标。这个政策立即产生了巨大的行业冲击,影响不亚于当年欧美的“双反”。当年第四季度,政策重新转暖。9月,欧盟取消了对我国企业长达五六年的“双反”措施,光伏贸易恢复正常。欧盟的“双反”并未能挽救欧洲企业,除了在最上游的硅料环节,大多欧洲企业已经退出光伏产业。2019年,我国开始逐步退出固定电价的补贴方式,实行市场竞价。而由于多年的技术积累和规模经济,光伏度电成本已经逼近燃煤电价,正在迈入平价上网时代。2020年,海内外上市的中国光伏企业股价飞涨,反映了市场对光伏技术未来的乐观预期。\n经济启示 # 如果承认全球变暖事关人类存亡,那就必须发展可再生能源。即便不承认全球变暖,但承认我国传统能源严重依赖进口的局面构成了国家安全隐患,那也必须发展新能源。但传统能源已经积累了多年的技术和成本优势,新能源在刚进入市场时是没有竞争力的。就拿十几年前的光伏来说,度电成本是煤电的十几倍甚至几十倍,若只靠市场和价格机制,没人会用光伏。但新能源的技术升级和成本下降,只有在大规模的生产和市场应用中才能逐步发生,不可能只依靠实验室。实验技术再突破,若没有全产业链的工业化量产和技术创新,就不可能实现规模经济和成本下降。研发和创新从来不只是象牙塔里的活动,离不开现实市场,也离不开边干边学的企业。\n所以新能源技术必须在没有竞争优势的时候就进入市场,这时候只有两个办法:第一是对传统能源征收高额碳税或化石燃料税,增加其成本,为新能源的发展制造空间;第二是直接补贴新能源行业。第一种办法明显不够经济,因为在新能源发展早期,传统能源占据九成以上的市场,且成本低廉,对其征收重税会大大加重税收负担,造成巨大扭曲。所以更加合理的做法是直接补贴新能源,加速其技术进步和成本降低,待其市场份额不断扩大、成本逼近传统能源之后,再逐渐降低补贴,同时对传统能源征税,加速其退出。 4040 因此无论是欧美还是日韩,光伏的需求都是由政府补贴创造出来的。中国在开始进入这个行业时,面临的是一个“三头在外”的局面:需求和市场来自海外,关键技术和设备来自海外,关键原材料也来自海外。所以基本就是一个代工行业,处处受制于人。但当时光伏发电成本太高,国内市场用不起。在地方政府廉价的土地和信贷资源支持下,大量本土光伏企业在海外打“价格战”,用低价占领市场,并在这个过程中不断技术创新,逐步进入技术更复杂的产业链上游,以求在产能过剩导致的激烈竞争中占据优势。但由于最终市场在海外,所以一旦遭遇欧美“双反”,就从需求端打击了全行业,导致大量企业倒闭。\n但企业不是“人”,不会在“死”后一了百了,积累的技术、人才、行业知识和经验,并不会随企业破产而消失。一旦需求回暖,这些资源就又可以重新整合。2013年以后,国内市场需求打开,光伏发展进入新阶段。因为整条产业链都在国内,所以同行沟通成本更低,开始出现全产业链的自主和协同创新,各环节共同优化,加速了技术进步和成本下降。这又进一步扩大了我国企业的竞争优势,更好地打开了国外市场。2018年以后,不仅欧洲“双反”结束,低价高效的光伏技术也刺激了全球需求的扩张,全球市场遍地开花。我国企业当年开拓海外市场的经验和渠道优势,现在又成了它们竞争优势的一部分。\n从光伏产业的发展来看,政府的支持和补贴与企业成功不存在必然的因果关系。欧美日等先进国家不仅起步早、政府补贴早,而且企业占据技术、原料和设备优势,在和中国企业的竞争中还借助了“双反”等一系列贸易保护政策,但它们的企业最终衰落,纷纷退出市场。无论是补贴也好、贸易保护也罢,政策最多可以帮助企业降低一些财务风险和市场风险,但政府不能帮助企业克服最大的不确定性,即在不断变化的市场中发展出足够的能力和竞争优势。如果做不到这一点,保护和补贴政策最终会变成企业的寻租工具。这一点不仅对中国适用,对欧美也适用。但这个逻辑不能构成反对所有产业政策的理由。产业发展,无论政府是否介入,都没有必然的成功或失败。就新能源产业而言,补贴了虽然不见得会成功,但没有补贴这个行业就不可能存在,也就谈不上在发展过程中逐渐摆脱对补贴的依赖了。\n从光伏产业的发展中,我们还可以看到“东亚产业政策模式”的另一个特点:强调出口。当国内市场有限时,海外市场可以促进竞争,迫使企业创新。补贴和优惠政策难免会产生一些低效率的企业,但这些企业在面对挑剔的海外客户时,是无法过关的。而出口量大的公司,往往是效率相对高的公司,它们市场份额的扩大,会吸纳更多的行业资源,压缩国内低效率同行的生存空间,淘汰一些落后产能。 4141 当然,像我国这样的大国,要应对的国际局势变幻比小国更加复杂,所以不断扩大和稳定国内市场,才是行业长期发展的基础。另一方面,若地方政府利用行政手段阻碍落后企业破产,就会阻碍优胜劣汰和效率提升,加剧产能过剩的负面影响。\n地方政府竞争与重复建设 # 地方政府招商引资的优惠政策,会降低产业进入门槛,可能会带来重复投资和产能过剩。这是在关于我国产业政策的讨论中经常被批评的弊端,光伏也是常被提及的反面教材。过度投资和产能过剩本身并不是什么新鲜事,就算没有政府干预,也是市场运行的常态。因为投资面对的是不可知的未来,自由市场选择的投资水平不可能恰好适应未来需求。尤其产业投资具有很强的不可逆性,没下注的还可以驻足观望,但下了注的往往难以收手,所以投资水平常常不是过少就是过多。若市场乐观情绪弥漫,投资者往往一拥而上,导致产能过剩,产品价格下跌,淘汰一批企业,而价格下跌可能刺激新一轮需求上升,引发新的过剩投资。这种供需动态匹配和调整过程中周期性的产能过剩是市场经济的常态。但也正是因为这种产能过剩,企业才不得不在这场生存游戏中不断创新,增加竞争优势,加速优胜劣汰和技术进步。 4242 在我国,还有起码三个重要因素加剧了“重复投资”。首先,在发展中国家可以看到发达国家的发展过程,知道很多产品的市场需求几乎是确定的,也知道相关的生产技术是可以复制的。比如大家都知道中国老百姓有钱之后会买冰箱、彩电、洗衣机,需求巨大,也能引进现成的生产技术,而国内产能还没发展起来,人人都有机会,所以投资一拥而上。其次,地方政府招商引资的很多优惠和补贴,比如低价土地和贴息贷款,都发生在工厂建设阶段,且地方领导更换频繁,倘若谈好的项目不赶紧上马,时间拖久了优惠政策可能就没有了。虽然企业不能完全预料建成投产后的市场需求,但投产后市场若有变化,总是有办法通过调整产量去适应。但如果当下不开工建设,很多机会和资源就拱手让人了,所以要“大干快上”。再次,地方往往追随中央的产业政策。哪怕本地条件不够,也可能投资到中央指定的方向上,这也是会引发各地重复投资的因素之一。 4343 “重复投资”并不总是坏事。在经济发展早期,各地政府扶持下的工业“重复投资”至少有两个正面作用。首先,当地工厂不仅提供了就业,也为当地农民转变为工人提供了学习场所和途径。“工业化”最核心的一环是把农民变成工人,这不仅仅是工作的转变,也是思想观念和生活习惯的彻底转变。这个转变不会自动发生,需要学习和培训,而这种学习和培训只能在工厂中完成。在乡镇企业兴起的年代,统一的国内大市场尚未形成,各地都在政府扶持下重复建设各种小工厂,生产效率和技术水平都很低。但正是这种“离土不离乡”的工厂,让当地农民熟悉了工业和工厂,培养了大量工人,为后来我国加入WTO后真正利用劳动力优势成为世界工厂奠定了基础。从这个角度看,“工厂”承担了类似“学校”的教育功能,有很强的正外部性,应当予以扶持和补贴。\n“重复投资”的第二个好处是加剧竞争。蜂拥而上的低水平产能让“价格战”成为我国很多产品的竞争常态。所以在很长一段时间内,“成本创新”是本土创新的主流。虽然西方会将此讥讽为“仿造”和“山寨”,但其实成本创新和功能简化非常重要。因为很多在发达国家已经更新迭代了多年的产品,小到家电大到汽车,我国消费者都是第一次使用。这些复杂精密的产品价格高昂,让试用者望而却步。如果牺牲一些功能和质量能让价格大幅下降,就有利于产品推广。当消费者开始熟悉这些产品后,会逐步提升对质量的需求。正因如此,很多国产货都经历了所谓“山寨+价格战”的阶段。但行业正是在这种残酷的竞争中迅速洗牌,将资源和技术快速向头部企业集中,质量迅速提高。就拿家电行业来说,国产货从起步到质优价廉、服务可靠、设计精美,占领了大部分国内市场,也就是20年的时间。其他很多消费者熟悉的产品,也大都如此。 4444 所以不管有没有政府扶持,要害都不是“重复建设”,而是“保持竞争”。市场经济的根本优势不是决策优势。面对不可知的未来,谁也看不清,自由市场上,失败也比成功多得多。市场经济的根本优势是可以不断试错,在竞争中优胜劣汰。 4545 能保持竞争性的产业政策,与只扶持特定企业的政策相比,效果往往更好。 4646 但所谓“特定”,不好界定。就算中央政府提倡的产业政策是普惠全行业的,并不针对特定企业,但到了地方政府,政策终归要落实到“特定”的本地企业头上。若地方政府保护本地企业,哪怕是低效率的“僵尸企业”也要不断输血和挽救,做不到“劣汰”,竞争的效果就会大打折扣,导致资源的错配和浪费。这是很多经济学家反对产业政策的主要原因。尤其是,我国地方政府有强烈的“大项目”偏好,会刺激企业扩张投资。企业一旦做大,就涉及就业、稳定和方方面面的利益,不容易破产重组。这在曾经的光伏巨头——江西赛维的破产重整案中表现得淋漓尽致。\n如前所述,2011年,赛维已经成了新余财政的第一贡献大户,创造就业岗位2万个,纳税14亿元,相当于当年新余财政总收入的12%。在政府背书之下,赛维获得了大量银行授信,远超其资产规模。自2012年起,赛维的债务就开始违约。地方政府屡次注入资金,并动员包括国开行在内的数家银行以各种方式救助,结果却越陷越深。2016年,赛维总资产为137亿元,但负债高达516亿元,严重资不抵债。其破产重整方案由地方政府直接主导,损害了债权人利益。当受偿率太低的债权人无法接受重整方案时,地方法院又强制裁决,引发了媒体、法律和金融界的高度关注。 4747 所以产业政策要有退出机制,若效率低的企业不能退出,“竞争性”就是一句空话。“退出机制”有两层含义。第一是政策本身要设计退出机制。比如光伏的“标杆电价”补贴,一直在降低,所有企业都非常清楚补贴会逐渐退出,平价上网时代终会来临,所以有动力不断提升效率和降低成本。第二是低效企业破产退出的渠道要顺畅。这不仅涉及产业政策,也涉及更深层次的要素配置市场化改革。如果作为市场主体和生产要素载体的企业退出渠道不畅,要素配置的市场化改革也就难以深化。然而“破产难”一直是我国经济的顽疾。一方面,债权银行不愿走破产程序,因为会暴露不良贷款,无法再掩盖风险;另一方面,地方政府也不愿企业(尤其是大企业)走破产程序,否则职工安置和民间借贷等一系列矛盾会公开化。在东南沿海等市场化程度较高的地区,破产程序相对更加规范。同样是光伏企业,无锡尚德和上海超日的破产重整就更加市场化,债权人的受偿率要比江西赛维高很多,这两个案例均被最高人民法院列为了“2016年十大破产重整典型案例”。但总体看来,无论是破产重整还是破产清算,我国在企业退出方面的制度改革和建设还有很长的路要走。\n第三节 政府产业引导基金 # 最近几年,产业升级和科技创新是个热门话题。一讲到对高科技企业的资金支持,大多数人首先会想到硅谷风格的风险投资。然而美式的风险投资基金不可能直接大规模照搬到我国,而是在移植和适应我国的政治经济土壤的过程中,与地方政府的财政资金实现了嫁接,产生了政府产业引导基金。这种地方政府投资高新产业的方式,脱胎于地方政府投融资的传统模式。在地方债务高企和“去产能、去杠杆”等改革的大背景下,政府引导基金从2014年开始爆发式增长,规模在五年内翻了几番。根据清科的数据,截至2019年6月,国内共设立了1 686只政府引导基金,到位资金约4万亿元;而根据投中的数据,引导基金数量为1 311只,规模约2万亿元。 4848 政府产业引导基金既是一种招商引资的新方式和新的产业政策工具,也是一种以市场化方式使用财政资金的探索。理解这种基金不仅有助于理解我国的产业发展,也是深入了解“渐进性改革”的绝佳范例。引导基金和私募基金这种投资方式紧密结合,所以要了解引导基金,需要先从了解私募基金开始。\n私募基金与政府引导基金 # 私募基金,简单说来就是一群人把钱交给另一群人去管理和投资,分享投资收益。称其为“私募”,是为了和公众经常买卖的“公募”基金区别开。私募基金对投资人资格、募资和退出方式等都有特殊规定,不像公募基金的份额那样可以每天买卖。图4-1描绘了私募基金的基本运作方式。出钱的人叫“有限合伙人”(limited partner,以下简称LP),管钱和投资的人叫“普通合伙人”(general partner,以下简称GP)。LP把钱交给GP投资和运作,同时付给GP两种费用:一种是基本管理费。一般是投资总额的2%,无论亏赚,每年都要交。另一种是绩效提成,行话叫“carry”。若投资赚了钱,GP要先偿还LP的本金和事先约定的基本收益(一般为8%),若还有多余利润,GP可从中提成,一般为20%。\n图4-1 私募基金基本运作模式\n举个简化的例子。LP投资100万元,基金延续两年,GP每年从中收取2万元管理费。若两年后亏了50万,那GP就只能挣两年总共4万的管理费,把剩下的46万还给LP,LP认亏。若两年后挣了50万,GP先把本金100万还给LP,再给LP约定的每年8%的收益,也就是16万。GP自己拿4万元管理费,剩下30万元的利润,GP提成20%也就是6万,剩余24万归LP。最终,GP挣了4万元管理费和6万元提成,LP连本带利总共拿回140万元。\nGP的投资对象既可以是上市公司公开交易的股票(二级市场),也可以是未上市公司的股权(一级市场),还可以是上市公司的定向增发(一级半市场)。若投资未上市公司的股权,那最终的“退出”方式就有很多种,比如把公司包装上市后出售股权、把股权出售给公司管理层或其他投资者、把公司整体卖给另一家并购方等。\nLP和GP这种特殊的称呼和合作方式,法律上称为“有限合伙制”。与常见的股份制公司相比,“有限合伙”最大的特点是灵活。股份制公司一般要求“同股同权”和“同股同利”。无论持股多少,每一股附带的投票权和分红权是一样的,持有的股票数量越多,权利越多。但在“有限合伙”中,出钱的是LP,做投资决定的却是GP,LP的权利相当有限。不仅如此,若最后赚了钱,最初基本没出钱的GP也可以分享利润的20%。 4949 此外,股份公司在注册时默认是永续经营的,但私募基金却有固定存续期,一般是7—10年。在此期限内,基金要经历募资、投资、管理、退出等四个阶段(统称“募投管退”),到期后必须按照合伙协议分钱和散伙。 5050 在这种合作方式下,活跃在投资舞台镁光灯下的自然就是做具体决策的GP。很多投资业绩出众的GP管理机构和明星管理人大名鼎鼎。他们的投资组合不仅财务回报率高,而且包括了诸多家喻户晓的明星企业,行业影响力很大。这些明星GP受市场资金追捧,募集的基金规模动辄百亿元。\n相比之下,出钱的LP们反倒低调得多。国际上规模大的LP大都是机构投资者,比如美国最大的LP就包括加州公立系统雇员养老金(CalPERS)和宾州公立学校雇员退休金(PSERS)等。一些国家的主权投资机构也是声誉卓著的LP,比如新加坡的淡马锡和GIC、挪威主权财富基金(GPFG)等。而国内最大的一类LP就是政府产业引导基金,其中既有中央政府的基金比如规模庞大的国家集成电路产业投资基金(即著名的“大基金”),也有地方政府的基金,比如深圳市引导基金及其管理机构深圳创新投资基团(即著名的“深创投”)。\n与地方政府投资企业的传统方式相比,产业引导基金或投资基金有三个特点。第一,大多数引导基金不直接投资企业,而是做LP,把钱交给市场化的私募基金的GP去投资企业。一支私募基金的LP通常有多个,不止有政府引导基金,还有其他社会资本。因此通过投资一支私募基金,有限的政府基金就可以带动更多社会资本投资目标产业,故称为“产业引导”基金。同时,因为政府引导基金本身就是一支基金,投资对象又是各种私募基金,所以也被称为“基金中的基金”或“母基金”(fund of funds, FOF)。第二,把政府引导基金交给市场化的基金管理人运作,实质上是借用市场力量去使用财政资金,其中涉及诸多制度改革,也在实践中遭遇了各种困难(见下文)。第三,大多数引导基金的最终投向都是“战略新兴产业”,比如芯片和新能源汽车,而不允许投向基础设施和房地产,这有别于基础设施投资中常见的政府和社会资本合作的PPP模式(见第三章)。\n上一章介绍城投公司的时候解释过,政府不可以直接向银行借贷,所以需要设立城投公司。政府当然也不可以直接去资本市场上做股权投资,所以在设立引导基金之后,也需要成立专门的公司去管理和运营这支基金,通过这些公司把基金投资到其他私募基金手中。这些公司的运作模式大概分为三类。第一类与城投公司类似,是政府独资公司,如曾经投资过京东方的北京亦庄国投,就由北京经济技术开发区国有资产管理办公室持有100%股权。第二类是混合所有制公司。比如受托管理深圳市引导基金的深创投,其第一大股东是深圳市国资委,但持股占比只有28%左右。第三类则有点像上一章中介绍的华夏幸福。很多小城市的引导基金规模很小,政府没有能力也没有必要为其组建一家专业的基金管理公司,所以干脆把钱委托给市场化的母基金管理人去运营,比如盛世投资集团。\n政府引导基金的概念很容易理解。在国际上,作为机构投资者的LP早就有了多年的运作经验,组建了国际行业协会,与全球各种机构型LP分享投资与治理经验。 5151 但从我国实践来看,政府引导基金的发展,需要三个外部条件。首先是制度条件。要想让财政预算资金进入风险很大的股权投资领域,必须要有制度和政策指引,否则没人敢做。其次是资本市场的发育要比较成熟。政府基金要做LP,市场上最起码得有足够多的GP去管理这些资金,还要有足够大的股权交易市场和退出渠道,否则做不起来。再次是产业条件。产业引导基金最终要流向高技术、高风险的战略新兴行业,而只有经济发展到一定阶段后,这样的企业才会大批出现。\n政府引导基金兴起的制度条件 # 2005年,发改委和财政部等部门首次明确了国家与地方政府可以设立创业投资引导基金,通过参股和提供融资担保等方式扶持创投企业的设立与发展。 5252 2007年,新修订的《合伙企业法》施行,LP/GP式的基金运作模式正式有了法律保障。本土第一批有限合伙制人民币基金随后成立。2008年,国务院为设立引导基金提供了政策基础,明确其宗旨是“发挥财政资金的杠杆放大效应,增加创业投资资本的供给,克服单纯通过市场配置创业投资资本的市场失灵问题”。明确了政府引导基金可以按照“母基金”的方式运作,可以引入社会资本共同设立“子基金”,增加对创业企业的投资。同时要求引导基金按照“政府引导、市场运作、科学决策、防范风险”的原则进行市场化运作。这16个字成了各地引导基金设立和运作的基本原则。 5353 政府的钱以“股权”形式进入还未上市的企业之后,如果有一天企业上市,这些“国有股份”怎么办?要不要按照规定在IPO(首次公开募股)时将10%的股份划转给社保基金? 5454 如果要划转,那无论是地方政府还是其他政府出资人,恐怕都不愿意。因此,为提高国有资本从事创业投资的积极性,2010年财政部等部门规定:符合条件的国有创投机构和国有创投引导基金,可在IPO时申请豁免国有股转持义务。 5555 GP的收费也是个问题。虽然2%的管理费和20%的业绩提成是国际惯例,但如果掌管的是财政资金,也该收取这么高比例的提成么?2011年,财政部和发改委确认了财政资金与社会资本收益共享、风险共担的原则,明确了GP在收取管理费(一般按1.5%—2.5%)的基础上可以收取增值收益部分的20%,相当于承认了GP创造的价值,不再将GP仅仅视作投资“通道”。 5656 以上政策为政府产业引导基金奠定了制度基础,但其爆发式发展却是在2014年前后,最直接的“导火索”是围绕新版《预算法》的一系列改革。改革之前,地方政府经常利用预算内设立的各种专项基金去招商引资,为企业提供补贴(如第三章中介绍的成都市政府对成都文旅的补贴)。而在2014年改革后,国务院开始严格限制地方政府对企业的财政补贴。这些原本用于补贴和税收优惠的财政资金,就必须寻找新的载体和出路,不能趴在账上。因为新《预算法》规定,连续两年还没花出去的钱,可能将被收归同级或上级财政统筹使用。 5757 到了这个阶段,基本制度框架已经搭好,地方政府也需要为一大笔钱寻找出路,产业引导基金已是蓄势待发。但这毕竟是个新事物,还需要更详细的操作指南。自2015年起,财政部和发改委陆续出台了一系列针对政府引导基金的管理细则,为各地提供了行动指南。其中最重要的是两点。第一,再次明确“利益共享、风险共担”原则,允许使用财政资金的政府投资基金出现亏损。第二,明确了财政部门虽然出资,但“一般不参与基金日常管理事务”,并且明确要求各地财政部门配合,“积极营造政府投资基金支持产业发展的良好环境”,推动政府投资基金实现市场化运作。 5858 之后,政府引导基金就进入了爆发期。根据清科数据,2013年全国设立的政府引导基金已到位资金约400亿元,而2014年一年就暴增至2 122亿元,2015年3 773亿元,2016年超过了1万亿元。很多著名的产业引导基金都创办于这一阶段,比如2014年工信部设立的“国家集成电路产业投资基金”(即“大基金”),首期规模将近1 400亿元。大多数地方政府的引导基金也成立于这个阶段。\n政府引导基金兴起的金融和产业条件 # 引导基金大多采用“母基金”方式运行,与社会资本共同投资于市场化的私募基金,通过后者投资未上市公司的股权。这种模式的繁荣,需要三个条件:有大量的社会资本可以参与投资、有大量的私募基金管理人可以委托、有畅通的投资退出渠道。其中最重要的是畅通的资本市场退出渠道。\n21世纪头十年,为资本市场发展打下制度基础的是三项政策。第一,2003年党的十六届三中全会通过《中共中央关于完善社会主义市场经济体制若干问题的决定》,2004年国务院发布《关于推进资本市场改革开放和稳定发展的若干意见》,为建立多层次资本市场体系,完善资本市场结构和风险投资机制等奠定了制度基础。第二,2005年开始的股权分置改革,解决了非流通股上市流通的问题,是证券市场发展史上里程碑式的改革。 5959 第三,2006年新修订的《公司法》开始实施,正式把发起人股和风投基金持股区别对待。上市后发起人股仍实行3年禁售,但风投基金的禁售期可缩短至12个月,拓宽了退出渠道。同年,证监会以部门规章的形式确立了IPO的审核标准。 6060 这些政策出台前后,上海和深圳的交易所也做了很多改革,拓宽了上市渠道。2004年和2009年,中小企业板和创业板分别在深交所开板。2013年,新三板扩容全国。2019年,科创板在上交所开市,并试行注册制。国内上市渠道拓宽后,改变了过去股权投资机构“两头在外”(海外募资,海外上市、退出)的尴尬格局。2008年全球金融危机后,我国的股权投资基金开始由人民币基金主导,外币基金不再重要。\n至于可以与政府引导基金合作的“社会资本”,既包括大型企业的投资部门,也包括其他资本市场的机构投资者。后者也随着21世纪的各项改革而逐步“解放”,开始进入股权投资基金行业。比如,在2010年至2014年间,保监会的一系列规定让保险资金可以开始投资非上市公司股权以及创投基金。 6161 所以2006年至2014年,我国的股权投资基金发展很快,一大批优秀的市场化基金管理机构和人才开始涌现。2014年,境内IPO重启,股权投资市场开始加速发展。也是从这一年起,政府引导基金的发展趋势和股权投资基金整体的发展趋势开始合流,政府资金开始和社会资本融合,出现了以市场化方式运作财政资源的重要现象。政府引导基金也逐渐成为各类股权投资基金最为重要的LP之一。\n绝大多数政府引导基金最终都投向了战略性新兴产业(以下简称“战新产业”),这是由这类产业的三大特性决定的。首先,扶持和发展战新产业是国家战略,将财政预算资金形成的引导基金投向这些产业,符合政策要求,制度上有保障。从“十二五”规划到“十三五”规划,国务院都对发展战新产业做了专门的规划,将其视为产业政策的重中之重。要求2015年战新产业增加值占GDP的比重需达到8%(已实现);2020年达到15%;2030年,战新产业应该发展成推动我国经济持续健康发展的主导力量,使我国成为世界战新产业重要的制造中心和创新中心。在这两个五年规划中,都提出要加大和创新财税与金融政策对战新产业的支持,明确鼓励发挥财政资金引导作用,吸引社会资本,扩大投资规模,促进战新产业快速发展。\n其次,战新产业处于技术前沿,高度依赖研发和创新,不确定性很大,所以更需要能共担风险并能为企业解决各类问题的“实力派”股东。从企业角度看,引入政府基金作为战略投资者,不仅引入了资金,也引入了能帮企业解决困难的政府资源。而从政府角度看,股权投资最终需要退出,不像补贴那样有去无回。因此至少从理论上说,与不用偿还的补贴相比,产业基金对被投企业有更强的约束。\n再次,很多战新产业正处在发展早期,尚未形成明显的地理集聚,这让很多地方政府(如投资京东方的合肥、成都、武汉等)看到了在本地投资布局的机会。而“十三五”关于发展战新产业的规划也鼓励地方以产业链和创新协同发展为途径,发展特色产业集群,带动区域经济转型,形成创新经济集聚发展新格局。\n引导基金的成绩与困难 # 最近几年,在公众熟知的很多新技术领域,比如新能源、芯片、人工智能、生物医药、航空航天等,大多数知名企业和投资基金的背后都有政府引导基金的身影。2018年3月,美国贸易代表办公室(USTR)发布了针对我国产业和科技政策的“301调查报告”,其中专门用了一节来讲各类政府产业引导基金。 6262 调查发布后,该报告中提到的基金还收到了一些同行发来的“赞”:工作业绩突出啊,连美国人都知道你们了。\n引导基金成效究竟如何,当然取决于这些新兴产业未来的发展情况。成了,引导基金就是巨大的贡献;不成,就是巨大的浪费。投资的道理由结果决定,历来如此,当下言之尚早。从目前情况看,撇开投资方向和效益不论,引导基金的运营也面临多种困难和挑战。与上一章中的地方政府融资平台不同,这些困难不是因为地价下跌或债台高筑,而属于运用财政资金做风险投资的体制性困难。主要有四类。\n第一类是财政资金保值增值目标与风险投资可能亏钱之间的矛盾。虽然原则上引导基金可以亏钱,但对基金的经营管理者而言,亏了钱不容易向上级交待。当然,对大多数引导基金而言,只要不亏大钱,投资的财务回报率高低并非特别重要,关键还是招商引资,借助引导基金这个工具把产业带回本地,但这就带来了第二类困难。\n第二类困难源自财政资金的地域属性与资本无边界之间的矛盾。在成熟的资本市场上,机构类LP追求的就是财务回报,并不关心资金具体流向什么区域,哪里挣钱就去哪里。但地方政府引导基金源自地方财政,本质还是招商引资工具,所以不可能让投资流到外地去,一定要求把产业带到本地来。但前两章反复强调过,无论是土地还是税收优惠,都无法改变招商引资的根本决定因素,即本地的资源禀赋和经济发展前景。在长三角、珠三角以及一些中心城市,大企业云集,各种招商引资工具包括引导基金,在完成招商目标方面问题不大。但在其他地区,引导基金招商作用其实不大,反而造成了新的扭曲。有些地方为吸引企业,把本该是股权投资的引导基金变成了债权工具。比如说,引导基金投资一亿元,本应是股权投资,同赚同亏,但基金却和被投企业约定:若几年后赚了钱,企业可以低价回购这一亿元的股权,只要支付本金再加基本利率(2%—5%)就行;若企业亏了钱,可能也需要通过其他方式来偿还这一亿元本金。这就不是股权投资了,而是变相的低息贷款。再比如,引导基金为吸引其他社会资本一起投资,承诺未来可以收购这些社会资本的股权份额,相当于给这些资本托了底,消除了它们的投资风险,但同时也给本地政府增加了一笔隐性负债。这种“名股实债”的方式违背了股权投资的原则,也违背了“去杠杆”和解决地方政府债务问题的初衷,是中央明确禁止的。 6363 第三类困难源于资本市场。股权投资对市场和资金变化非常敏感,尤其在私募基金领域。在一支私募基金中,作为LP之一的政府引导基金出资份额一般不会超过20%。换句话说,若没有其他80%的社会资本,这支私募基金就可能募集失败。在2018年“资管新规”出台(见第六章)之后,各种社会资本急剧萎缩,大批私募基金管理机构倒闭,很多引导基金也独木难支,难有作为。\n第四类困难是激励机制。私募基金行业收入高,对人才要求也高。而引导基金的管理机构脱胎自政府和国企,一般没有市场化的薪酬,吸引不了很多专业人才,所以才采用“母基金”的运作方式,把钱交给市场化的私募基金GP去管理。但要想和GP有效沟通、监督其行为、完成产业投资目标,引导基金管理机构的业务水平也不能落伍,也需要吸引和留住人才,所以需要在体制内调整薪酬结构。各地做法差异很大。在市场化程度高、制度比较灵活的地方如深圳,薪酬也更加灵活一些。但在大部分地区,薪酬激励机制仍是很难突破的瓶颈。\n结语 # 经济发展是企业、政府、社会合力的结果,具体合作方式取决于各自占有的资源,而这些资源禀赋的分布格局由历史决定。我国的经济改革脱胎于计划经济,政府手中掌握大量对产业发展至关重要的资源,如土地、银行、大学和科研机构等,所以必然会以各种方式深度参与工业化进程。政府和市场间没有黑白分明的界限,几乎所有的重要现象,都是这两种组织和资源互动的结果。要想认识复杂的世界,需要小心避免政府和市场的二分法,下过于简化的判断。\n因此,本章尽量避免抽象地谈论产业发展和政府干预,着重介绍了两个具体行业的发展过程和一个特定产业政策工具的运作模式,希望帮助读者了解现象的复杂和多面性。大到经济发展模式、小到具体产业政策,不存在脱离了具体场景、放之四海而皆准的答案,必须具体问题具体分析,并根据现实变化不断调整。政策工具需要不断发展和变化,因为政府能力和市场条件也在不断发展和变化。在这个意义上,深入了解发达国家的真实发展历程,了解其经历的具体困难和脱困方式,比夸夸其谈的“华盛顿共识”更有启发。\n20世纪90年代中期至21世纪初期,基础设施不完善、法制环境不理想、资本市场和社会信用机制不健全,因此以信用级别高的地方政府和国企为主体、以土地为杠杆,可以撬动大量资源,加速投资进程,推动快速城市化和工业化。这种模式的成就有目共睹,但也会带来如下后果:与土地相关的腐败猖獗;城市化以“地”为本,忽略了“人”,民生支出不足,教育、医疗等公共服务供给滞后;房价飞涨,债务急升;经济过度依赖投资,既表现在民众收入不高所以消费不足,也表现在过剩产能无法被国内消化、向国际输出时又引起贸易失衡和冲突。这些都是近些年的热点问题,催生了诸多改革,本书下篇将逐一展开讨论。\n扩展阅读 # 工业生产离日常生活比较远,所以如果对这个话题感兴趣,最好还是先从感性认识入手。近些年中央电视台拍了很多关于我国工业的纪录片,其中《大国重器》《超级工程》《创新中国》《大国工匠》《军工记忆》等都值得一看。工业和技术的发展很不容易,有很多重要的非经济因素,如奋斗精神和家国情怀等,这些在上述影像记录中都能看到。\n关于行业和企业的研究著作,我首先推荐北京大学路风的《光变:一个企业及其工业史》(2016),讲的是京东方和光电显示行业的故事。这本大书充满了精彩的细节,虽然是单一行业和企业的故事,但如此深度和详细的记录,国内罕见。其中很多对技术工人和经理的访谈非常宝贵,有很多被传统分析和理论抽象掉的重要信息。路风教授研究其他行业的文章结集也都很好,比如《走向自主创新:寻找中国力量的源泉》(2019)和《新火:走向自主创新2》(2020),讲述了我国汽车、大飞机、核能、高铁等行业的发展故事。而对于新工业革命、信息技术、人工智能等领域中的企业和投资故事,吴军的《浪潮之巅》通俗精彩,已经出到了第四版(2019)。\n如果想从更宏大的历史背景和国家兴衰角度去看待工业投资和发展,我从诸多杰作中推荐三种读物。其共同点是“能大能小”,讲的是经济发展和历史大故事,但切入点还是具体产业和企业。哈佛商学院麦克劳的《现代资本主义:三次工业革命中的成功者》(1999)和哈佛大学史学家贝克特的《棉花帝国》(2019)都是杰作,书名解释了内容。史塔威尔的《亚洲大趋势》(2014)讲的是我们的近邻日韩以及中国自己的成功故事,也对比了一些东南亚的失败故事,思路和结构清楚,案例易懂。无论是制度也好、战略也罢,终究离不开人事关系。事在人为,理解这其中所蕴含的随机性,是理解所谓“entrepreneurship”的起点。相比于“企业家精神”,这个词更应该翻译为“进取精神”,不仅企业家,官员、科学家、社会各界都离不开这种精神。\n11 关于这个道理,更详细的阐述可以参考诺贝尔经济学奖得主乔治·阿克尔洛夫(George Akerlof)的文章(2020)和另一位诺奖得主罗伯特·希勒(Robert Shiller)的著作(2020)。\n22 数据来自中信证券袁健聪等人的行业分析报告(2020)。\n33 数据来自北京大学路风的企业史杰作(2016)。如无注明,本节关于京东方发展历程的介绍均来自该书。\n44 见财新网2013年1月4日报道《发改委就三星等垄断液晶面板价格案答问》,以及《中国贸易报》2016年8月2日报道《中企面临反垄断三大挑战四大风险》。\n55 简单来说,“X代线”中的X数字越高,产出的屏幕就越大。比如5代线的主打产品是17英寸屏,6代线的主打产品是32英寸屏。\n66 贷款和补贴的具体数字,来自财新网2007年4月16日的文章《疯狂的液晶》。\n77 合肥建投通过其全资子公司合肥蓝科投资有限公司参与了这次增发。\n88 数据来自京东方2009—2011年的年报。从京东方的角度看,大规模定向增发的募资其实不容易,需要找到足够多的机构投资者。倘若合肥政府财力雄厚的话,本不需要这么麻烦。\n99 2009年国务院发布《电子信息产业调整和振兴规划》。\n1010 见京东方2009年年报。\n1111 这部分贷款贴息由高新区政府支付给成都高投,算作这家城投公司的营业外收入,而不算在京东方的账上,虽然补贴的是京东方。这些财务细节来自成都高新投资集团在债券市场上的募集说明书,感兴趣的读者可以从上海清算所的网站上下载。\n1212 见京东方2014年年报。\n1313 关于合肥建投对10.5代线的资金投入细节,来自其公司网站上的“大事记”。\n1414 华星光电和TCL的数据来自《财新周刊》2019年第2期文章《李东生闯关》。\n1515 产业之间的联系紧密而复杂,犹如巨网。有些产业的外溢性极强,政府若扶持这些产业,会对整个经济产生正面影响。这方面研究很多,比如普林斯顿大学刘斯原的论文(Liu, 2019)。\n1616 关于世界银行的质疑,来自史塔威尔(Studwell)关于亚洲发展的著作(2014)。\n1717 关于韩国20世纪70年代的产业政策有很多研究,基本过程和事实是清楚的。但严谨的微观数据分析尤其是对上下游产业的价格和产出等影响的估计,最近才有,详见澳大利亚国立大学莱恩(Lane)的论文(2019)。当然,政府扶持不是产业发展的充分条件,还有很多其他的因素在发挥作用,但政府扶持和大量资金的投入无疑是这些产业高速发展的必要条件。\n1818 关于光电显示产业链国产化的分析报告很多,因为其中不少国内企业规模已经不小,成了上市公司,比如三利谱和精测电子等。外商直接投资(FDI)对本地供应链企业的正面拉动作用,有很多研究,几乎算国际经济学领域的定论了,读者可以参考哈夫拉内克(Havranek)和伊尔索娃(Irsova)的总结性论文(2011)。\n1919 国际贸易并不是无条件双赢的。在引入动态规模经济和学习效应之后,自由贸易可能会损害一国的经济增长和社会福利。读者可参考诺贝尔奖得主克鲁格曼(Krugman)的论文(1987)、伦敦政经学院阿温·杨(Alwyn Young)的论文(1991)或数学家戈莫里(Gomory)和经济学家鲍莫尔(Baumol)的著作(2000)。\n2020 关于自主创新,北京大学路风的著作(2016,2019,2020)中有很多精彩而独到的分析。\n2121 具体数据参见IMF两位经济学家的论文(Cherif and Hasanov, 2019)。\n2222 韩国的出口数据来自IMF两位经济学家的论文(Cherif and Hasanov, 2019)。北京大学林毅夫关于“比较优势”和产业升级的理论被称为“新结构经济学”,读者可以阅读他的著作(2014),其中也包括很多学者对这一理论的讨论以及林教授的回应。这些对话和争论非常精彩,可以帮助理解和澄清很多问题。\n2323 中国人民大学刘守英和杨继东(2019)统计了我国1 240种出口商品的“显性比较优势”,其中有196种商品2016年在国际上有比较优势,但1995年没有。这些新增产品很多来自复杂程度较高的行业,比如机械、电器、化工等。\n2424 产品复杂度的数据来自哈佛大学国际发展中心的“The Atlas of Economic Complexity”项目。\n2525 密歇根大学列夫琴科(Levchenko)的论文(2007)分析了制度质量和产品复杂性之间的关联。\n2626 来自2013年《环球企业家》的文章《烧钱机器京东方:国开行200亿融资背后的政商逻辑》。转引自搜狐财经频道https://m.sohu.com/n/364543762/。\n2727 在“新经济地理学”或“空间经济学”的理论中,产业一旦形成,经济力量就会加速地理集聚。但对集聚的具体位置而言,“初始条件”影响很大,对初始条件的微小干预就可能影响最终的产业地理格局。无论是中国还是美国,若追溯很多产业集聚地区的历史根源,都会发现一些偶然因素曾发挥过关键作用,比如在京东方的例子中是合肥时任领导的支持。克鲁格曼写过一本小册子(2002),讲述了这种偶然性和经济力量的结合对产业地理格局的影响。\n2828 见《财新周刊》2019年第44期的文章《面板产能过剩 地方国资投资冲动暗藏隐忧》。\n2929 我国光伏产业规模的数据来自中信证券弓永峰和林劼的研究报告(2020)。\n3030 全球光伏组件价格的降幅估算来自能源专家、普利策奖得主耶金(Yergin)的著作(2020)。全球和我国装机总量的数据来自全球可再生能源行业智库REN21的报告(2020)。\n3131 关于光伏产业的早期发展史,可以参考西瓦拉姆(Sivaram)的专著(2018)。\n3232 光伏上市公司数量来自《中国改革》2010年第4期文章《太阳能中国式跃进》。\n3333 数据来自《财新周刊》2017年第37期的文章《破产重整的赛维样本》。\n3434 光伏企业数量和出口的数据来自兴业证券朱玥的报告(2019)。国内市场销量占比数据来自西瓦拉姆(Sivaram)的著作(2018)。\n3535 参见《江苏省光伏发电推进意见》(苏政办发[2009]85号)。\n3636 2011年7月,发改委发布《关于完善太阳能光伏发电上网电价政策的通知》,核定上网电价为每度电1.15元。2013年8月,在《国家发展改革委关于发挥价格杠杆作用促进光伏产业健康发展的通知》发布后,开始实行分区上网电价。\n3737 上海对不同用电量实行差别定价。《上海市可再生能源和新能源发展专项资金扶持办法》中详细规定了对海上风电和光伏电站的补贴办法。\n3838 数据来自兴业证券朱玥的报告(2019)。\n3939 欠补总额的数据来自《财新周刊》2018年第25期的封面文章《巨额补贴难支 光伏断奶》。关于电网建设和消纳新能源电量之间的矛盾,相关报道很多,在此不一一列举。\n4040 关于传统能源向新能源转变的动态过程及其中最优的税收和补贴政策组合,可以参考麻省理工学院的阿西莫格鲁(Acemoglu)等人的论文(2016)。\n4141 进入全球市场会提升本国企业效率,不仅是由于基于比较优势的国际分工可以提升效率,也是由于更大规模的市场会提升高效企业的市场份额,压缩低效企业的生存空间,这便是经典贸易理论“Melitz模型”的核心思想。读者可以参考哈佛大学梅里兹(Melitz)和多伦多大学特雷夫莱(Trefler)的介绍性文章(2012)。\n4242 不确定性对投资行为和经济周期的影响,是经济学的重要议题之一,有很长的研究传统。读者可参考斯坦福大学布鲁姆(Bloom)对这个领域精彩且通俗的介绍(2014)。\n4343 第一个因素被称为“潮涌现象”,详见北京大学林毅夫、巫和懋和邢亦青的论文(2010)。第二个因素被称为“Oi-Hartman-Abel”效应,即企业可以通过扩张(此处指建厂)获得好处,同时可以通过收缩(此处指投产后调整产能)避免风险,详见斯坦福大学布鲁姆(Bloom)的论文(2014)。第三个因素,即各地产业扶持目标逐渐和中央产业政策趋同的现象,见复旦大学赵婷和陈钊的论文(2019)。\n4444 中欧商学院叶恩华(George Yip)和布鲁斯·马科恩(Bruce Mckern)的著作(2016)系统分析了我国企业成本创新的很多案例。\n4545 关于市场经济的核心不是决策优势而是优胜劣汰的思想,已故经济学家阿尔钦(Alchian)半个多世纪前的文章(1950)今天看依然精彩。\n4646 我国竞争性的产业政策,比如针对全行业的补贴、税收减免、低息贷款等,对提升行业技术水平和效率有正面作用,参见哈佛大学阿吉翁(Aghion)和马里兰大学蔡婧等人的论文(2015)。\n4747 详细情况可以参考《财新周刊》2017年第37期的报道《破产重整的赛维样本》。\n4848 清科和投中是两家研究私募基金的国内机构。私募基金的信息披露不完全、不透明,所以不同的估计差别很大,此处的数字引自《财新周刊》2020年第39期的封面报道《监管十万亿私募股权基金》。在私募基金行业,基金的目标规模通常并不重要,实际募资和到位资金一般远小于目标规模。媒体上经常看到的天文数字般的政府引导基金目标规模,没有太大意义。\n4949 在实际运作中,LP也会设计各种各样的机制来监督和激励GP,使其行事符合LP利益。比如出资较多的LP可能要求参与GP的投资决策,在GP的投资决策委员会中有一席投票权或否决权,或者派出观察员。再比如在组建基金时,GP通常也会象征性地投入一些钱,以示与LP利益绑定,一般就是LP出资总额的1%—2%。\n5050 私募基金兴起于美国,与资本市场上的“杠杆收购”(leverage buy-out)紧密相关。这种金融工具的兴起,背后有复杂的经济和社会背景,包括对公司角色认知的转变、全球化后劳资关系的转变、金融管制放松等。美国智库CEPR的阿佩尔鲍姆(Appelbaum)和康奈尔大学巴特(Batt)的著作(2014)对私募基金和杠杆收购的时代背景和逻辑做了精彩的分析和介绍。\n5151 如2002年在华盛顿成立的“机构类LP协会”(Institutional Limited Partner Association, ILPA)。\n5252 2005年,国家发展改革委与科技部、财政部、商务部、中国人民银行等多部委联合发布《创业投资企业管理暂行办法》。\n5353 2008年,国务院办公厅转发发展改革委等部门《关于创业投资引导基金规范设立与运作的指导意见》。\n5454 2002年财政部规定,海外上市的国有企业要把发行股数的10%划转给全国社保基金理事会持有。2009年,财政部、国资委、证监会、社保基金会联合印发《境内证券市场转持部分国有股充实全国社会保障基金实施办法》:凡在境内IPO的含国有股的股份有限公司,除国务院另有规定的,均须按IPO时实际发行股份数量的10%,将部分国有股转由社保基金会持有;国有股东持股数量少于应转持股份数量的,按实际持股数量转持。\n5555 2010年,财政部联合国资委和证监会、社保基金会发布《关于豁免国有创业投资机构和国有创业投资引导基金国有股转持义务有关问题的通知》。\n5656 2011年,财政部和发改委发布《新兴产业创投计划参股创业投资基金管理暂行办法》。\n5757 2014年,国务院发布《国务院关于清理规范税收等优惠政策的通知》,规定“未经国务院批准,各地区、各部门不得对企业规定财政优惠政策。对违法违规制定与企业及其投资者(或管理者)缴纳税收或非税收入挂钩的财政支出优惠政策,包括先征后返、列收列支、财政奖励或补贴,以代缴或给予补贴等形式减免土地出让收入等,坚决予以取消”。关于财政结余资金,新版《预算法》规定:“各级政府上一年预算的结转资金,应当在下一年用于结转项目的支出;连续两年未用完的结转资金,应当作为结余资金管理。”\n5858 2015年最后两个月,财政部连续发布《政府投资基金暂行管理办法》和《关于财政资金注资政府投资基金支持产业发展的指导意见》。\n5959 我国股市上曾经有三分之二的股权不能流通,这种“股”和“权”分置的状况让非流通股股东的权益受到严重限制,造成了很多扭曲。2005年4月,证监会发布《关于上市公司股权分置改革试点有关问题的通知》,股权分置改革开始。\n6060 2006年,证监会发布《首次公开发行股票上市管理办法》。\n6161 2010年,保监会发布《保险资金股权投资暂行办法》,允许符合条件的保险公司直接投资于非上市公司股权,或者将总资产的4%投资于股权投资基金。2014年,保监会发布《关于保险资金投资创业投资基金有关事项的通知》,为险资进入创投基金扫清了障碍。\n6262 见美国贸易代表办公室的“301调查报告”:“Findings of the Investigation into China\u0026rsquo;s Acts, Policies, and Practices Related to Technology Transfer, Intellectual Property, and Innovation”。\n6363 根据海通证券姜超、朱征星、杜佳等人的估计(2018),截至2017年底,由PPP和政府基金所形成的各类“名股实债”,总额约3万亿元左右。2017年初,发改委出台《政府出资产业投资基金管理暂行办法》,明确禁止“名股实债等变相增加政府债务的行为”。\n"},{"id":161,"href":"/zh/docs/life/20250103/","title":"随想","section":"生活","content":" 学习知识是我们理解世界的一种方式。当知识与现实不符时,你可以说是知识错了,也可以说是你理解错了,但这都不重要,重要的是要将思维,改成与现实世界相符的方向来理解,毕竟我们是生活在现实世界中,而不是理想中。穷则独善其身,达则兼济天下。 用科技解释现象是一种理解世界的方式,用阴阳五行命理也是一种。最重要的是要过的心安理得,有理可循。 "},{"id":162,"href":"/zh/docs/culture/%E4%B8%AD%E5%9B%BD%E9%80%9A%E5%8F%B2%E5%90%95%E6%80%9D%E5%8B%89/%E4%B8%8B%E7%AF%87-%E4%B8%AD%E5%9B%BD%E6%96%87%E5%8C%96%E5%8F%B2/","title":" 下编-中国文化史","section":"中国通史(吕思勉)","content":" 第三十七章 婚姻 # 《易经》的《序卦传》说:“有天地,然后有万物;有万物,然后有男女;有男女,然后有夫妇;有夫妇,然后有父子;有父子,然后有君臣。”这是古代哲学家所推想的社会起源。他们以为隆古的社会,亦像后世一般,以一夫一妇为基本,成立一个家庭,由此互相联结,成为更大的组织。此等推想,确乎和我们根据后世的制度,以推想古代的情形的脾胃相合。所以几千年来,会奉为不刊之典。然而事实是否如此,却大是一个疑问了。\n自有历史以来,不过几千年,社会的情形,却已大有改变了。设使我们把历史抹杀了,根据现在的情形,去臆测周、秦、汉、魏、唐、宋时的状况,那给研究过历史的人听了,一定是一场大笑话,何况邃古之事,去今业已几万年几十万年呢?不知古代的真相,而妄以己意推测,其结果,必将以为自古至今,不过如此,实系因缘起灭的现象,都将认为天经地义,不可变更。这就将发生许多无谓的争执,不必要的保守,而进化的前途被其阻碍了。所以近几十年来,史前史的发现,实在是学术上的一个大进步。而其在社会组织方面,影响尤大。\n据近代社会学家所研究:人类男女之间,本来是没有什么禁例的。其后社会渐有组织,依年龄的长幼,分别辈行。当此之时,同辈行之男女,可以为婚,异辈行则否。更进,乃于亲族之间,加以限制。最初是施诸同母的兄弟姊妹的。后来渐次扩充,至凡同母系的兄弟姊妹,都不准为婚,就成所谓氏族(Sib)了。此时异氏族之间,男女仍是成群的,此一群之男,人人可为彼一群之女之夫;彼一群之女,人人可为此一群之男之妻;绝无所谓个别的夫妇。其后禁例愈繁,不许相婚之人愈多。于是一个男子,有一个正妻;一个女子,有一个正夫。然除此之外,尚非不许与其他的男女发生关系,而夫妻亦不必同居,其关系尚极疏松。更进,则夫妻必须同居(一夫一妻,或一夫多妻),关系更为永久,遂渐成后世的家庭了。所以人类的婚姻,是以全无禁例始,逐渐发生加繁其禁例,即缩小其通婚的范围,而成为今日的形态的。以一夫一妻的家庭,为原始的男女关系,实属错误。\n主张一夫一妻的家庭,为男女原始关系的形态的,不过说:人类是从猿猴进化而来的,猿猴已有家庭,何况人类?然谓猿猴均有家庭,其观察本不正确(详见李安宅译《两性社会学》附录《近代人类学与阶级心理》第四节。商务印书馆本)。即舍此勿论,猿猴也是人类祖先的旁支,而非其正系。据生物学家之说,动物的聚居,有两种形式:一如猫虎等,雌雄同居,以传种之时为限;幼儿成长,即与父母分离,是为家庭动物。一如犬马等,其聚居除传种外,兼以互相保卫为目的;历时可以甚久,为数可以甚多,是为社群动物。人类无爪牙齿角以自卫,倘使其聚居亦以家庭为限,在隆古之世,断乎无以自存,而且语言也必不会发达。所以原始人类的状况,我们虽不得而知,其为社群而非家庭,则殆无疑义。猿类的进化不如人类,以生物界的趋势论,实渐走上衰亡之路,怕正以其群居本能,不如人类之故。而反说人类的邃初,必与猿猴一样,实未免武断偏见了。何况人类的性质,如妒忌及性的羞耻等,均非先天所固有(此观小孩便可知。动物两性聚居,只有一夫一妻,一夫多妻两种形式,人类独有一妻多夫,尤妒忌非先天性质之明证);母爱亦非专施诸子女等,足以证明其非家庭动物的,还很多呢。\n现代的家庭,与其说是源于人的本性,倒不如说是源于生活情形(道德不道德的观念,根于习惯;习惯源于生活)。据社会学家所考究:在先史时期,游猎的阶级极为普遍。游猎之民,都是喜欢掠夺的,而其时可供掠夺之物极少,女子遂成为掠夺的目的。其后虑遭报复,往往掠夺之后,遗留物件,以为交换。此时的掠夺,实已渐成为贸易。女子亦为交换品之一。是为掠夺的变相,亦开卖买的渊源。掠夺来的女子,是和部族中固有的女子,地位不同的。她是掠夺她的人的奴隶,须负担一切劳役。此既足以鼓励男子,使之从事于掠夺,又婚姻之禁例渐多,本部族中的女子,可以匹合者渐少,亦益迫令男子从事于向外掠夺。所以家庭的起源,是由于女子的奴役;而其需要,则是立在两性分工的经济原因上的。与满足性欲,实无多大关系。原始人除专属于他的女子以外,满足性欲的机会,正多着呢。游猎之民,渐进而为畜牧,其人之好战斗,喜掠夺,亦与游猎之民同(凡畜牧之民,大抵兼事田猎),而其力且加强(因其食物充足,能合大群;营养佳良,体格强壮之故),牧群须人照管,其重劳力愈甚,而掠夺之风亦益烈。只有农业是源于搜集的,最初本是女子之事。低级的农业,亦率由女子任其责。其后逐渐发达,成为生活所必资。此时经济的主权,操于女子之手。土田室屋及农具等,率为女子所有。部族中人,固不愿女子出嫁;女子势亦无从出嫁;男子与女子结婚者,不得不入居女子族中,其地位遂成为附属品。此时女子有组织,男子则无(或虽有之而不关重要),所以社会上有许多公务,其权皆操于女子之手(如参与部族会议,选举酋长等。此时之女子,亦未尝不从事于后世家务一类的事务,然其性质,亦为公务,与后世之家务,迥乎不同),实为女子的黄金时代。所谓服务婚的制度,即出现于此时。因为结婚不能徒手,而此时的男子,甚为贫乏,除劳力之外,实无可以为聘礼之物之故。其后农业更形重要,男子从事于此者益多。导致以男子为之主,而女子为之辅。于是经济的主权,再入男子之手。生活程度既高,财产渐有赢余,职业日形分化。如工商等业,亦皆为男子之事。个人私产渐兴,有财富者即有权力,不乐再向女子的氏族中作苦,乃以财物偿其部族的损失,而娶女以归。于是服务婚渐变为卖买婚,女子的地位,又形低落了。\n以上所述,都是社会学家的成说。返观我国的古事,也无乎不同。《白虎通义·三皇篇》说,古代的人,“知其母而不知其父”,这正是古代的婚姻,无所谓夫妇的证据。人类对于男女性交,毫无限制的时代,去今已远,在书本上不易找到证据。至于辈行婚的制度,则是很明白无疑的。《礼记·大传》说宗子合族之礼道:“同姓从宗合族属,异姓主名治际会。名著而男女有别。其夫属乎父道者,妻皆母道也;其夫属乎子道者,妻皆妇道也。谓弟之妻为妇者,是嫂亦可谓之母乎?名者,人治之大者也,可无慎乎?”这正是古代婚姻,但论辈行一个绝好的遗迹。这所谓同姓,是指父系时代本氏族里的人。用现在的话来说,就是老太爷、老爷、少爷们。异姓,郑《注》说:“谓来嫁者”,就是老太太、太太、少太太们。从宗,是要依着血系的枝分派别的,如先分为老大房、老二房、老三房,再各统率其所属的房分之类,参看下章自明。主名,郑《注》说:“主于妇与母之名耳。”谓但分别其辈行,而不复分别其枝派。质而言之,就是但分为老太太、太太、少太太,而不再问其孰为某之妻,孰为某之母。“谓弟之妻为妇者,是嫂亦可谓之母乎。”翻做现在的话,就是:“把弟媳妇称为少太太,算做儿媳妇一辈,那嫂嫂难道可称为老太太,算做母亲一辈么?”如此分别,就可以称为男女有别,可见古代婚姻,确有一个专论辈行的时代,在周代的宗法中,其遗迹还未尽泯。夏威夷人对于父、伯叔父、舅父,都用同一的称呼。中国人对于舅,虽有分别,父与伯叔父,母与伯叔母、从母,也是没有分别的。伯父只是大爷,叔父、季父,只是三爷、四爷罢了。再推而广之,则上一辈的人,总称为父兄,亦称父老。老与考为转注(《说文》),最初只是一语,而考为已死之父之称。下一辈则总称子弟。《公羊》何《注》说:“宋鲁之间,名结婚姻为兄弟。”(僖公二十五年)可见父母兄弟等,其初皆非专称。资本主义的社会学家说:这不是野蛮人不知道父与伯叔父、舅父之别,乃是知道了而对于他们,仍用同一的称呼。殊不知野蛮人的言语,总括的名词,虽比我们少,各别的名词却比我们多。略知训诂的人皆知之(如古鸟称雌雄,兽称牝牡,今则总称雌雄,即其一例)。既知父与伯叔父、舅父之别,而仍用同一的称呼,这在我们,实在想不出这个理由来。难者将说:父可以不知道,母总是可以知道的,为什么母字亦是通称呢?殊不知大同之世,“人不独亲其亲,不独子其子”,生物学上的母,虽止一个,社会学上的母,在上一辈中,是很普遍的。父母之恩,不在生而在养,生物学上的母,实在是无甚关系的,又何必特立专名呢?然则邃初所谓夫妇之制和家庭者安在?《尔雅·释亲》:兄弟之妻,“长妇谓稚妇为娣妇,娣妇谓长妇为姒妇”,这就是现在的妯娌。而女子同嫁一夫的,亦称先生者为姒,后生者为娣。这也是辈行婚的一个遗迹。\n社会之所以有组织,乃是用以应付环境的。其初,年龄间的区别,实在大于两性间的区别(后来受文化的影响,此等区别,才渐渐转变。《商君书·兵守篇》说,军队的组织,以壮男为一军,壮女为一军,男女之老弱者为一军,其视年龄的区别,仍重于两性的区别)。所以组织之始,是按年龄分辈分的。而婚姻的禁例,亦起于此。到后来,便渐渐依血统区别了。其禁例,大抵起于血缘亲近之人之间。违犯此等禁例者,俗语谓之“乱伦”,古语则谓之“鸟兽行”,亦谓之“禽兽行”。惩罚大抵是很严重的。至于扩而充之,对母方或父方有血缘关系之人,概不许结婚,即成同姓不婚之制(中国古代的姓,相当于现在社会学上所谓氏族,参看下章)。同姓不婚的理由,昔人说是“男女同姓,其生不蕃”(《左传》僖公二十三年郑叔詹说)。“美先尽矣,则相生疾。”(同上,昭公七年郑子产说)又说是同姓同德,异姓异德(《国语·晋语》司空季子说),好像很知道遗传及健康上的关系的。然(一)血族结婚,有害遗传,本是俗说,科学上并无证据。(二)而氏族时代所谓同姓,亦和血缘远近不符。(三)至谓其有害于健康,则更无此说。然则此等都是后来附会之说,并不是什么真正的理由。以实际言,此项禁例,所以能维持久远的,大概还是由于《礼记·郊特牲》所说的“所以附远厚别”。因为文化渐进,人和人之间,妒忌之心,渐次发达,争风吃醋的事渐多,同族之中,必有因争色而致斗乱的,于是逐渐加繁其禁例,最后,遂至一切禁断。而在古代,和亲的交际,限于血缘上有关系的人。异姓间的婚姻,虽然始于掠夺,其后则渐变为卖买,再变为聘娶,彼此之间,无复敌意,而且可以互相联络了。试看春秋战国之世,以结婚姻为外交手段者之多,便可知《郊特牲》附远二字之确。这是同姓不婚之制,所以逐渐普遍,益臻固定的理由。及其既经普遍固定之后,则制度的本身,就具有很大的威权,更不必要什么理由了。\n妒忌的感情,是何从而来的呢?前文不是说,妒忌不是人的本性么?然两性间的妒忌,虽非人之本性,而古人大率贫穷,物质上的缺乏,逼着他不能不生出产业上的嫉妒来。掠夺得来的女子,既是掠夺者的财产,自然不能不努力监视着她。其监视,固然是为着经济上的原因,然他男子设或与我的奴隶,发生性的关系,就很容易把她带走,于是占有之欲,自物而扩及于人,而和此等女子发生性的关系,亦非得其主人许可,或给以某种利益,以为交换不可了(如租凭、借贷、交换等。《左传》襄公二十八年,庆封与卢蒲嫳易内;昭公二十八年,祁胜与邬臧通室;现在有等地方,还有租妻之俗,就是这种制度的遗迹)。再进,产业上的妒忌,渐变成两性间的妒忌,而争风吃醋之事遂多。内婚的禁忌,就不得不加严,不得不加密了。所以外婚的兴起,和内婚的禁止,也是互为因果的。\n掠夺婚起于游猎时代,在中国古书上,也是确有证据的。《礼记·月令》《疏》引《世本》说:太昊始制嫁娶以俪皮为礼。托诸太昊,虽未必可信,而俪皮是两鹿皮,见《公羊》庄公二十二年何《注》,这确是猎人之物。古婚礼必用雁,其理由,怕亦不过如此。又婚礼必行之昏时,亦当和掠夺有关系。\n中国农业起于女子,捕鱼在古代,亦为女子之事,说见第四十七章。农渔之民,都是食物饶足,且居有定地的,畋猎对于社会的贡献比较少,男子在经济上的权力不大,所以服务婚之制,亦发生于此时。赘婿即其遗迹。《战国·秦策》说:太公望是齐之逐夫,当即赘婿。古代此等婚姻,在东方,怕很为普遍的。《汉书·地理志》说:齐襄公淫乱,姑姊妹不嫁。“于是下令国中:民家长女不得嫁,名曰巫儿,为家主祠,嫁者不利其家。民至今以为俗。”把此等风俗的原因,归诸人君的一道命令,其不足信,显而易见。其实齐襄公的姑姊妹不嫁,怕反系受这种风俗的影响罢?《公羊》桓公二年,有楚王妻媦之语(何《注》:媦,妹也)。可见在东南的民族,内婚制维持较久。《礼记·大传》说:“四世而缌,服之穷也。五世袒免,杀同姓也。六世亲属竭矣,其庶姓别于上(庶姓见下章),而戚单于下(单同殚),婚姻可以通乎?系之以姓而弗别,缀之以族而弗殊,虽百世而婚姻不通者,周道然也。”然则男系同族,永不通婚,只是周道。自殷以上,六世之后,婚姻就可以通的。殷也是东方之国。《汉书·地理志》又说燕国的风俗道:“初太子丹宾养勇士,不爱后宫美女,民化以为俗,至今犹然。宾客相过,以妇侍宿。嫁娶之夕,男女无别,反以为荣。后稍颇止,然终未改。”不知燕丹的举动,系受风俗的影响,反以为风俗源于燕丹,亦与其论齐襄公同病。而燕国对于性的共有制,维持较久,则于此可见。燕亦是滨海之地。然则自东南亘于东北,土性肥沃,水利丰饶,农渔二业兴盛之地,内婚制及母系氏族,都是维持较久的。父系氏族,当起于猎牧之民。此可见一切社会制度,皆以经济状况为其根本原因。\n人类对于父母亲族,总只能注意其一方,这是无可如何的。所以在母系氏族内,父方的亲族,并不禁止结婚;在父系氏族内,母方的亲族亦然;且有两个氏族,世为婚姻的。中国古代,似亦如此。所以夫之父与母之兄弟同称(舅)。夫之母与父之姊妹同称(姑)。可见母之兄弟,所娶者即父之姊妹(并非亲姊妹,不过同氏族的姊妹行而已)。而我之所嫁,亦即父之氏族中之男子,正和我之母与我之父结婚同。古代氏族,又有在氏族之中,再分支派的。如甲乙两部族,各分为一二两组。甲一之女,必与乙二之男结婚,生子则属于甲二。甲二之女,必与乙一之男结婚,生子则属于甲一。乙组的女子亦然(此系最简单之例,实际还可以更繁复)。如此,则祖孙为同族人,父子则否。中国古代,似亦如此。所以祭祀之礼:“孙可以为王父尸,子不可以为父尸。”(《礼记·曲礼》)。殇与无后者,必从祖祔食,而不从父祔食(《礼记·曾子问》)。\n近亲结婚,在法律上本有禁令的,并不限于父系。如《清津》:“娶己之姑舅两姨姊妹者,杖八十,并离异。”即是。然因此等风俗,根深柢固,法律就成为具文了。\n古代所谓同姓,是自认为出于同一始祖的(在父系氏族,则为男子。在母系氏族,则为女子),虽未必确实,他们固自以为如此。同姓与否,和血缘的远近,可谓实无关系。然他们认为同姓则同德,不可结婚,异姓则异德,可以结婚,理由虽不确实,办法尚觉一致。至后世所谓同姓,则并非同出于一源;而同出于一源的,却又不必同姓。如王莽,以姚、妫、陈、田皆黄、虞后,与己同姓,令元城王氏,勿得与四姓相嫁娶(《汉书·莽传》),而王、孙咸,以得姓不同,其女转嫁为莽妻(《汉书·传》),此等关系,后世都置诸不论了。所谓同姓异姓,只是以父系的姓,字面上的同异为据,在理论上,可谓并无理由,实属进退失据。此因同姓不婚之制,已无灵魂,仅剩躯壳之故。总而言之,现在的所谓姓氏,从各方面而论,都已毫无用处,不过是社会组织上的老废物罢了。参看下章自明。\n婚礼中的聘礼,即系卖买婚的遗迹,古礼称为“纳征”。《礼记·内则》说:“聘则为妻,奔则为妾”;《曲礼》说:“买妾不知其姓则卜之”;则买妾是真给身价的,聘妻虽具礼物,不过仅存形式,其意已不在于利益了。\n古代婚礼,传于后世的,为《仪礼》中的《士昏礼》。其节目有六:即(一)纳采(男氏遣使到女氏去求婚),(二)问名(女氏许婚之后,再请问许婚的是哪一位姑娘?因为纳采时只申明向女氏的氏族求婚,并未指明哪一个人之故),(三)纳吉(女氏说明许婚的系哪一位姑娘之后,男氏归卜之于庙。卜而得吉,再使告女氏),(四)纳征(亦谓之纳币。所纳者系玄束帛及俪皮),(五)请期(定吉日。吉日系男氏所定,三请于女氏,女氏不肯定,然后告之),(六)亲迎(新郎亲到女氏。执雁而入,揖让升堂,再拜奠雁。女父带着新娘出来,交结他。新郎带着新娘出门。新娘升车,新郎亲为之御。车轮三转之后,新郎下车,由御者代御。新郎先归,在门首等待。新娘车至,新郎揖之而入。如不亲迎的,则新郎三月后往见舅姑。亲迎之礼,儒家赞成,墨家是反对的,见《礼记·哀公问》、《墨子·非儒篇》),是为六礼。亲迎之夕,共牢而食,合卺而酳(古人的宴会,猪牛羊等,都是每人一份的。夫妻则两个人合一份,是谓同牢。把一个瓢破而为两,各用其半,以为酒器,是为合卺。这表示“合体,同尊卑”的意思)。其明天,“赞妇见于舅姑”。又明天,“舅姑共飨妇”。礼成之后,“舅姑先降自西阶(宾阶),妇降自阼阶。”(东阶,主人所行。古人说地道尊右,故让客人走西阶。)表明把家事传给他,自己变做客人的意思。此礼是限于适妇的,谓之“著代”,亦谓之“授室”。若舅姑不在,则三月而后庙见。《礼记·曾子问》说:“女未庙见而死,归葬于女氏之党,示未成妇。”诸侯嫁女,亦有致女之礼,于三月之后,遣大夫操礼而往,见《公羊》成公九年。何《注》说:“必三月者,取一时,足以别贞信。”然则古代的婚礼,是要在结婚三个月之后,才算真正成立的。若在三月之内分离,照礼意,还只算婚姻未完全成立,算不得离婚。这也可见得婚姻制度初期的疏松。\n礼经所说的婚礼,是家族制度全盛时的风俗,所以其立意,全是为家族打算的。《礼记·内则》说:“子甚宜其妻,父母不说,出。子不宜其妻,父母曰:是善事我,子行夫妇之礼焉,没身不衰。”可见家长权力之大。《昏义》说:“成妇礼,明妇顺,又申之以著代,所以重责妇顺焉也。妇顺也者,顺于舅姑,和于室人,而后当于夫,以成丝麻布帛之事,以审守委积盖藏。是故妇顺备而后内和理,内和理而后家可长久也,故圣王重之。”尤可见娶妇全为家族打算的情形。《曾子问》说:“嫁女之家,三夜不息烛,思相离也”,这是我们容易了解的。又说:“取妇之家,三日不举乐,思嗣亲也。”此意我们就不易了解了。原来现代的人,把结婚看作个人的事情,认为是结婚者的幸福,所以多有欢乐的意思。古人则把结婚看做为家族而举行的事情。儿子到长大能娶妻,父母就近于凋谢了,所以反有感伤的意思。《曲礼》说:“昏礼不贺,人之序也”,也是这个道理。此亦可见当时家族主义的昌盛,个人价值,全被埋没的一斑。\n当这时代,女子遂成为家族的奴隶,奴隶是需要忠实的,所以贞操就渐渐的被看重。“贞妇”二字,昉见于《礼记·丧服四制》。春秋时,鲁君的女儿,有一个嫁给宋国的,称为宋伯姬。一天晚上,宋国失火,伯姬说:“妇人夜出,必待傅姆。”(傅姆是老年的男女侍从。必待傅姆,是不独身夜行,以避嫌疑的意思)傅姆不至,不肯下堂,遂被火烧而死。《春秋》特书之,以示奖励(《公羊》襄公三十年)。此外儒家奖励贞节之说,还有许多,看刘向的《列女传》可知。刘向是治鲁诗的,《列女传》中,有许多是儒家相传的诗说。秦始皇会稽刻石说:“饰省宣义,有子而嫁,倍死不贞。防隔内外,禁止淫佚,男女洁诚。夫为寄豭,杀之无罪,男秉义程。妻为逃嫁,子不得母,咸化廉清。”按《管子·八观篇》说:“闾干无阖,外内交通,则男女无别矣。”又说:“食谷水,巷凿井;场圃接,树木茂;宫墙毁坏,门户不闭,外内交通;则男女之别,无自正矣。”(《汉书·地理志》说:郑国土陋而险,山居谷汲,男女亟聚会,故其俗淫)。这即是秦始皇所谓防隔内外。乃是把士大夫之家,“深宫固门,阍寺守之,男不入,女不出”的制度(见《礼记·内则》),推广到民间去。再嫁未必能有什么禁令,不过宣布其是倍死不贞,以示耻辱,正和奖励贞节,用意相同。寄豭是因奸通而寄居于女子之家的,杀之无罪;妻为逃嫁,则子不得母,其制裁却可谓严厉极了。压迫阶级所组织的国家,其政令,自然总是为压迫阶级张目的。\n虽然如此,罗马非一日之罗马,古代疏松的婚姻制度,到底非短期间所能使其十分严紧的。所以表显于古书上的婚姻,要比后世自由得多。《左传》昭公元年,载郑国徐吾犯之妹美,子南业经聘定了她,子皙又要强行纳聘。子皙是个强宗,国法奈何不得他。徐吾犯乃请使女自择,以资决定。这虽别有用意,然亦可见古代的婚嫁,男女本可自择。不过“男不亲求,女不亲许”(见《公羊》僖公十四年),必须要有个媒妁居间;又必须要“为酒食以召乡党僚友”(《礼记·典礼》),以资证明罢了。婚约的解除,也颇容易。前述三月成妇之制,在结婚三个月之后,两造的意见,觉得不合,仍可随意解除,这在今日,无论哪一国,实都无此自由。至于尚未同居,则自然更为容易。《礼记·曾子问》说:“昏礼:既纳币,有吉日,女之父母死,则如之何?孔子曰:婿使人吊。如婿之父母死,则女之家亦使人吊。婿已葬,婿之伯父,致命女氏曰:某之子有父母之丧,不得嗣为兄弟,使某致命。女氏许诺,而弗敢嫁,礼也。婿免丧,女之父母使人请,婿弗取而后嫁之,礼也。女之父母死,婿亦如之。”一方等待三年,一方反可随意解约,实属不近情理。迂儒因生种种曲说。其实这只是《礼记》文字的疏忽。孔子此等说法,自为一方遭丧而一方无意解约者言之。若其意欲解约,自然毫无限制。此乃当然之理,在当日恐亦为常行之事,其事无待论列,故孔子不之及。记者贸然下了“而弗敢嫁,礼也”六字,一似非等待不可的,就引起后人的误会了。离婚的条件,有所谓七出,亦谓之七弃(一、无子,二、淫佚,三、不事舅姑,四、口舌,五、盗窃,六、嫉妒,七、恶疾)。又有所谓三不去(一、尝更三年丧不去,二、贱取贵不去,三、有所受无所归不去)。与五不娶并列(一、丧妇长女,二、世有恶疾,三、世有刑人,四、乱家女,五、逆家女),见于《大戴礼记·本命篇》,和《公羊》庄公二十七年何《注》,皆从男子方面立说。此乃儒家斟酌习俗,认为义所当然,未必与当时的法律习惯密合。女子求去,自然也有种种条件,为法律习惯所认许的,不过无传于后罢了。观汉世妇人求去者尚甚多(如朱买臣之妻等),则知古人之于离婚,初不重视。夫死再嫁,则尤为恒事。这是到宋以后,理学盛行,士大夫之家,更看重名节,上流社会的女子,才少有再嫁的,前代并不如此。《礼记·郊特牲》说:“一与之齐,终身不改,故夫死不嫁。”这是现在讲究旧礼教的迂儒所乐道的。然一与之齐,终身不改,乃是说不得以妻为妾,并非说夫死不嫁。《白虎通义·嫁娶篇》引《郊特牲》,并无“故夫死不嫁”五字,郑《注》亦不及此义,可见此五字为后人所增。郑《注》又说:“齐或为醮”,这字也是后人所改的。不过郑氏所据之本,尚作齐字,即其所见改为醮字之本,亦尚未窜入“故夫死不嫁”五字罢了。此可见古书逐渐窜改之迹。\n后世男子的权利,愈行伸张,则其压迫女子愈甚。此可于其重视为女时的贞操,及其贱视再醮妇见之。女子的守贞,实为对于其夫之一种义务。以契约论,固然只在婚姻成立后,持续时为有效,以事实论,亦只须如此。所以野蛮社会的风俗,无不是如此的,而所谓文明社会,却有超过这限度的要求。此无他,不过压迫阶级的要求,更进一步而已。女子的离婚,在后世本较古代为难,因为古代的财产,带家族共有的意思多,一家中人,当然都有享受之份。所以除所谓有所受无所归者外,离婚的女子,都不怕穷无所归。后世的财产,渐益视为个人所有,对于已嫁大归之女,大都不愿加以扶养;而世俗又贱视再醮之妇,肯娶者少,弃妇的境遇,就更觉凄惨可怜了。法律上对于女子,亦未尝加以保护。如《清律》:“凡妻无应出及义绝之状而出之者,杖八十。虽犯七出,有三不去而出之者,减二等,追还完聚。”似乎是为无所归的女子特设的保护条文。然追还完聚之后,当如何设法保障,使其不为夫及夫之家族中人所虐待,则绝无办法。又说:“若夫妻不相和谐而两愿离者不坐。”不相和谐,即可离异,似极自由。然夫之虐待其妻者,大都榨取其妻之劳力以自利,安能得其愿离?离婚而必以两愿为条件,直使被虐待者永无脱离苦海之日。而背夫私逃之罪,则系“杖一百,从夫嫁卖”。被虐待的女子,又何以自全呢?彻底言之:现在所谓夫妇制度,本无维持之价值。然进化非一蹴所可几,即制度非旦夕所能改。以现在的立法论,在原则上当定:一、离婚之诉,自妻提出者无不许。二、其生有子女者,抚养归其母,费用则由其父负担。三、夫之财产中,其一部分,应视为其妻所应得,离婚后当给与其妻。四、夫妻异财者勿论。其同财者,嫁资应视为妻之私财,离婚时给还其妻;其业经销用者应赔偿。这固不是根本解决的办法,然在今日,立法上亦只得如此了。而在今日,立法上亦正该如此。\n古书中所载的礼,大抵是父系家庭时代的习惯风俗。后世社会组织,迄未改变,所以奉其说为天经地义。而因此等说法,被视为天经地义之故,亦有助于此制度之维持。天下事原总是互为因果的。但古书中的事实,足以表示家族主义形成前的制度的亦不少,此亦不可不注意。《礼记·礼运》:“合男女,颁爵位,必当年德。”《管子·幼官篇》,亦有“合男女”之文。合男女,即《周官》媒氏及《管子·入国篇》的合独之政。《周官》媒氏:“凡男女自成名以上,皆书年月日名焉。令男三十而娶,女二十而嫁。中春之月,令会男女。于是时也,奔者不禁(谓不备聘娶之礼,说见下)。司男女之无夫家者而会之。”合独为九惠之政之一。其文云:“取鳏寡而和合之,与田宅而家室之,三年然后事之。”此实男女妃合,不由家族主持,而由部族主持之遗迹。其初盖普遍如此。到家族发达之后,部族于通常之结婚,才置诸不管,而只干涉其违法者,而救济其不能婚嫁者了。当男女婚配由部族主持之世,结婚的年龄,和每年中结婚的季节,都是有一定的。婚年:儒家的主张,是男三十而娶,女二十而嫁。《礼记·曲礼》、《内则》等篇,都是如此。《大戴礼记·本命篇》说,这是中古之礼。太古五十而室,三十而嫁。《墨子》(《节用》)、《韩非子》(《外储说右下》),则说男二十而娶,女十五而嫁。结婚的年龄,当然不能斠若画一。王肃说:男十六而精通,女十四而能化,自此以往,便可结婚;所谓三十、二十等,乃系为之极限,使不可过。又所谓男三十,女二十,不过大致如此,并非必以三十之男,配二十之女,其说自通(见《诗·摽有梅疏》)。《大戴礼》说:三十而室,二十而嫁,天子庶人同礼。《左传》说:天子十五而生子;三十而室,乃庶人之礼(《五经异义》)。贵族生计,较庶人为宽裕,结婚年龄,可以提早,说亦可通。至《墨子》、《韩非子》之说,则系求蕃育人民之意,古代此等政令甚多,亦不足怪。所可怪者,人类生理,今古相同,婚配的要求,少壮之时,最为急切,太古时何以能迟至五十、三十?按罗维(Robert H.Lowie)所著的《初民社会》(吕叔湘译,商务印书馆本),说巴西的波洛洛人(Bororo),男女性交和结婚,并非一事。当其少年时,男女之间,早已发生性的关系,然常是过着浪漫的生活,并不专于一人。倒是年事较长,性欲较淡,彼此皆欲安居时,才择定配偶,相与同居。按人类的性质,本是多婚的。男女同居,乃为两性间的分工互助,实与性欲无甚关系。巴洛洛人的制度,实在是较为合理的。社会制度,往往早期的较后期的为合理(这是因已往的文化,多有病态,时期愈晚,病态愈深之故)。中国太古之世,婚年较晚的理由,也可以借鉴而明了。人类性欲的开始,实在二七、二八之年。自此以往,更阅数年,遂臻极盛(此系中国古说,见《素问·上古天真论》。《大戴礼记》、《韩诗外传》、《孔子家语》等说皆同),根于生理的欲念,宜宣泄不宜抑压。抑压之,往往为精神病的根源。然后世将经济上的自立,责之于既结婚的夫妇,则非十余龄的男女所及;又教养子女之责,专由父母任之,亦非十余龄的男女所能,遂不得不将结婚的年龄展缓。在近代,并有因生计艰难,而抱独身主义的。性欲受抑压而横溢,个人及社会两方面,均易招致不幸的结果。这亦是社会制度与人性不能调和的一端。倘使将经济及儿童教养的问题,和两性问题分开,就不至有此患了。所以目前的办法在以节育及儿童公育,以救济迟婚及独身问题。结婚的季节,《春秋繁露》说:“霜降逆女,冰泮杀止。”(《循天之道篇》)《荀子》同(《大略篇》)。王肃说:自九月至正月(见《诗·绸缪疏》)。其说良是。古人冬则居邑,春则居野(参看第四十二、第五十章)。结婚的月份,实在是和其聚居的时期相应的。仲春则婚时已过,至此而犹不克婚,则其贫不能备礼可知,所以奔者不禁了。\n多妻之源,起于男子的淫侈。生物界的事实,两性的数目,常大略相等。婚姻而无禁例,或虽有禁例而不严密则已,若既限定对于法定的配偶以外,不许发生性的关系,而又有若干人欲多占异性为己有,则有多占的人,即有无偶的人。所以古今中外,有夫妇之制的社会,必皆以一夫一妻为原则。但亦总有若干例外。古代贵族,妻以外发生性的关系的人有两种:一种是妻家带来的,谓之媵。一种是自己家里所固有的,谓之妾(后世媵之实消灭,故其名称亦消灭,但以妾为配偶以外发生性的关系之人之总称)。媵之义为送,即妻家送女的人(并限于女子,如伊尹为有莘氏媵臣是),与婿家跟着新郎去迎接新娘的御相同。媵御的原始,实犹今日结婚时之男女傧相,本无可发生性的关系的理由。后来有特权的男子,不止娶于一家,正妻以外的旁妻,无以名之,亦名之曰媵,媵遂有正妻以外之配偶之义。古代的婚姻,最致谨于辈行,而此规则,亦为有特权者所破坏。娶一妻者,不但兼及其娣,而且兼及其姪,于是有诸侯一娶九女之制。取一国则二国往媵,各以侄娣从。一娶九女之制,据《白虎通义·嫁娶篇》说,天子与诸侯同。亦有以为天子娶十二女的,如《春秋繁露·爵国篇》是。此恐系以天子与诸侯同礼为不安而改之。其实在古代,天子诸侯,在实际上,未必有多大的区别。《礼记·昏义》末节说:天子有一后,三夫人,九嫔,二十七世妇,八十一御妻。按《昏义》为《仪礼·士昏礼》之传,传文皆以释经,独《昏义》此节,与经无涉,文亦不类传体,其说在他处又无所见,而适与王莽立后,备和、嫔、美、御,和人三,嫔人九,美人二十七,御人八十一之制相合(见《汉书·莽传》),其为后人窜入,自无可疑。《冠义》说:“无大夫冠礼而有其昏礼?古者五十而后爵,何大夫冠礼之有?”五十而后娶,其为再娶可知。诸侯以一娶九女之故,不得再娶(《公羊》庄公十九年)。大夫若亦有媵,安得再娶?管氏有三归,孔子讥其不俭(《论语·八佾》:包咸云:三归,娶三姓女),即系讥其僭人君之礼。所以除人君以外,是决无媵的。至于妾,则为家中的女子,得与家主相接之义。家族主义发达的时代,门以内的事情,国法本不甚干涉。家主在家庭中的地位,亦无人可以制裁他。家中苟有女奴,家主要破坏她的贞操,自无从加以制裁。所以有妾与否,是个事实问题,在法律上,或者并无制限。然古代依身份而立别的习惯,是非常之多的,或有制限,亦未可知。后世等级渐平,依身份而立区别的习惯,大半消除,娶妾遂成为男子普遍的权利了。虽然如此,法律上仍有依身份之贵贱,而定妾之有无多寡的。如《唐书·百官志》:亲王有孺人二人,媵十人;二品媵八人;国公及三品媵六人;四品媵四人;五品媵三人。《明律》:民年四十以上无子者,方听娶妾,违者笞四十。但此等法律,多成具文,而在事实上,则多妻之权利,为富者所享受。适庶之别,古代颇严。因为古代等级,本来严峻,妻和妾一出于贵族,一出于贱族,其在社会上的身份,本相悬殊之故。后世等级既平,妻妾之身份,本来的相差,不如前代之甚,所以事实上贵贱之相差亦较微。仅在法律上、风俗上,因要维持家庭间的秩序,不得不略存区别而已。\n《颜氏家训》说:“江左不讳庶孽,丧室之后,多以妾媵终家事。河北鄙于侧室,不预人流,是以必须重娶,至于三四。”这是江左犹沿古代有媵不再娶的旧风,河北就荡然了。但以妾媵终家事,必本有妾媵而后能然。如其无之,自不能不再娶。再娶自不能视之为妾。《唐书·儒学传》说:“郑余庆庙有二妣,疑于袝祭,请于有司。博士韦公肃议曰:古诸侯一娶九女,故庙无二适。自秦以来有再娶,前娶后继皆适也,两袝无嫌。”自秦以来有再娶,即因封建破坏,无复一娶九女及三归等制度之故。韦公肃之议,为前娶后继,皆为适室礼文上的明据。但从礼意上说,同时不能有二嫡的,所以世俗所谓兼祧双娶,为法律所不许(大理院解释,以后娶者为妾)。\n人类的性质,本来是多婚的(男女皆然),虽由社会的势力,加以压迫,终不能改变其本性。所以压迫之力一弛,本性随即呈露。在现社会制度之下,最普遍而易见的,是为通奸与卖淫。通奸,因其为秘密之事,无从统计其多少。然就现社会和历史记载上观察,实可信其极为普遍。卖淫亦然。社会学家说:“凡是法律和习惯限制男女性交之处,即有卖淫之事,随之出现。”史家推原卖淫之始,多以为起于宗教卖淫。王书奴著《中国倡伎会》(生活书店本),亦力主此说。然原始宗教界中淫乱的现象,实未可称为卖淫。因为男女的交际,其初本极自由。后来强横的男子,虽把一部分女子占为己有,然只限于平时。至于众人集会之时,则仍须回复其故态。所以各个民族,往往大集会之时,即为男女混杂之际。如郑国之俗,三月上巳之日,于溱、洧两水之上,招魂续魄,拂除不祥,士女往观而相谑(《韩诗》说,据陈乔枞《三家诗遗说考》)。《史记·滑稽列传》载淳于髡说:“州闾之会,男女杂坐。行酒稽留,六博投壶,相引为曹。握手无罚,目眙不禁。前有堕珥,后有遗簪。”“日暮酒阑,合尊促坐。男女同席,履舄交错,杯盘狼籍。堂上烛灭,主人留髡而送客。罗襦襟解,微闻芗泽。”又如前文所引的燕国“嫁娶之夕,男女无别”都是。宗教上的寺院等,也是大众集会之地;而且是圣地;其地的习惯,是不易破坏的。《汉书·礼乐志》说:汉武帝立乐府,“采诗夜诵”。颜师古《注》说:“其言辞或秘,不可宣露,故于夜中歌诵。”按《后汉书·高句骊传》说:其俗淫。暮夜辄男女群聚为倡乐。高句骊是好祠鬼神的,而乐府之立,亦和祭礼有关。然则采诗夜诵,怕不仅因其言辞或秘罢?男女混杂之事,后世所谓邪教中,亦恒有之,正和邪有何标准?不过古代之俗,渐与后世不合,则被目为邪而已。然则宗教中初期的淫乱,实不可谓之卖淫。不过限制男女交际的自由,往往与私有财产制度,伴随而起。既有私有财产,自有所谓卖买;既有所谓卖买,淫亦自可为卖买的标的。在此情形之下,本非卖买之事,变为卖买的多了,亦不仅淫之一端。\n卖淫的根源,旧说以为起于齐之女闾。其事见于《战国策》的《东周策》。《东周策》载一个辩士的话道:“国必有诽誉。忠臣令诽在己,誉在上。齐桓公宫中七市,女闾七百,国人非之,管仲故为三归之家,以掩桓公非,自伤于民也。”则市与女闾,确为淫乐之地。《商君书·垦令篇》说:“令军市无有女子”;又说:“轻惰之民,不游军市,则农民不淫”,亦市为淫乐之地之一证。女闾则他处无文。按《太平御览》引《吴越春秋》说:“勾践输有过寡妇于山上,使士之忧思者游之,以娱其意”(今本无),亦即女闾之类。女闾,盖后世所谓女户者所聚居。女户以女为户主,可见其家中是没有壮男的。《周官》内宰:“凡建国,佐后立市”;《左传》昭公二十年,晏婴说:“内宠之妾,肆夺于市”,则古代的市,本由女子管理。所以到后来,聚居市中的女子还很多。市和女闾,都不过因其为女子聚居之所,遂成为纵淫之地罢了。其初,也未必是卖淫的。\n卖淫的又一来源,是为女乐。女乐是贵族家里的婢妾,擅长歌舞等事的,令其“执技以事上”。婢妾的贞操,本来是没有保障的,自不因其为音乐队员而有异。封建制度破坏,贵族的特权,为平民所僭者甚多,自将流布于民间。《史记·货殖列传》说:赵国的女子,“鼓鸣瑟,跕屣(现在的拖鞋,在古时为舞屣),游媚贵富,入后宫,遍诸侯。”“郑、卫俗与赵相类。”又说:“今夫赵女郑姬,设形容,揳鸣琴,揄长袂,蹑利屣,目挑心招,出不远千里,不择老少者,奔富厚也。”即其事。倡伎本来是对有技艺的人的称谓,并非专指女子。所以女子有此等技艺的,还特称为女伎。然其实是性的诱惑的成分多,欣赏其技艺的成分少。于是倡伎转变为女子卖淫者的称谓,其字也改从女旁了(即娼妓。男子之有技艺者,不复称倡伎)。为倡伎之女子,本系婢妾之流,故自古即可卖买。《战国·韩策》说:“韩卖美人,秦买之三千金”其证。后世当娼妓的,也都是经济上落伍的人,自然始终是可以买卖的了。资本的势力愈盛,遂并有买得女子,使操淫业以谋利的。古代的女伎,系婢妾所为,后世政治上还沿袭其遗制,是为乐户。系以罪人家属没入者为之。唐时,其籍属于太常。其额设的乐员,属于教坊司。此系国家的女乐队员,但因其本为贱族,贞操亦无保障,官员等皆可使之执技荐寝以自娱,是为官妓。军营中有时亦有随营的女子,则谓之营妓。民间女子卖淫的,谓之私娼。在本地的称土娼,在异乡的称流娼。清世祖顺治十六年,停止教坊女乐,改用内监。世宗雍正七年,改教坊司为和声署。是时各地方的乐户,亦皆除籍为民。于是在法律上除去一种贱族,亦无所谓官妓。但私娼在当时则是无从禁止的。律例虽有“举贡生员,宿娼者斥革”的条文,亦不过为管束举、贡、生员起见而已,并非禁娼。\n古代掠夺婚姻的习惯,仍有存于后世的。赵翼《陔余丛考》说:“村俗有以婚姻议财不谐,而纠众劫女成婚者,谓之抢亲。《北史·高昂传》:昂兄乾,求博陵崔圣念女为婚,崔不许。昂与兄往劫之。置女村外,谓兄曰:何不行礼?于是野合而归。是劫婚之事,古亦有之。然今俗劫婚,皆已经许字者,昂所劫则未字,固不同也。”按《清律》:“凡豪势之人,强夺良家妻女,奸占为妻妾者绞。配与子孙、弟侄、家人者,罪亦如之。”此指无婚姻契约而强抢的。又说:“应为婚者,虽已纳聘财,期未至,而男家强娶者,笞五十。”(指主婚人)“女家悔盟,男家不告官司强抢者,照强娶律减二等。”此即赵氏所谓已经许字之女,照法律亦有罪,但为习俗所囿,法律多不能实行。又有男女两家,因不能负担结婚时的费用,私相协议,令男家以强抢的形式出之的。则其表面为武力的,内容实为经济的了。抢孀等事,亦自古即有。《潜夫论·断讼篇》云:“贞洁寡妇,遭直不仁世叔、无义兄弟,或利其聘币,或贪其财贿,或私其儿子,则迫胁遣送,有自缢房中,饮药车上,绝命丧躯,孤捐童孩者。”又有“后夫多设人客,威力胁载者”。这其中,亦含有武力的经济的两种成分。\n卖买婚姻,则无其名而有其实。《断讼篇》又说:“诸女一许数家,虽生十子,更百赦,勿令得蒙一,还私家,则此奸绝矣。不则髡其夫妻,徙千里外剧县,乃可以毒其心而绝其后。”《抱朴子·弭讼篇》,述其姑子刘士由之论说:“末世举不修义,许而弗与。讼阋秽缛,烦塞官曹。今可使诸争婚者,未及同牢,皆听义绝,而倍还酒礼,归其币帛。其尝已再离,一倍裨聘(裨即现在赔偿的赔字)。其三绝者,再倍裨聘。如此,离者不生讼心,贪者无利重受。”葛洪又申说自己的意见道:“责裨聘倍,贫者所惮,丰于财者,则适其愿矣。后所许者,或能富殖,助其裨聘,必所甘心。然则先家拱默,不得有言,原情论之,能无怨叹乎?”葛洪之意,要令“女氏受聘,礼无丰约(谓不论聘财多少),皆以即日报版。又使时人署姓名于别版,必十人以上,以备远行及死亡。又令女之父兄若伯叔,答婿家书,必手书一纸。若有变悔而证据明者,女氏父母兄弟,皆加刑罚罪”。可见汉晋之世卖买婚姻之盛。后世契约效力较强,此等事无人敢做,但嫁女计较聘礼,娶妻觊觎妆奁,其内容还是一样的,此非经济制度改变,无法可以改良了。\n后世的婚姻,多全由父母做主,本人概不与闻,甚至有指腹为婚等恶习(见《南史·韦放传》。按《清律》,指腹为婚有禁),这诚然是很坏的。然论者遂以夫妇之道苦,概归咎于婚姻的不自由,则亦未必其然。人之性,本是多婚的,男女皆然,所以爱情很难持之永久。即使结婚之时,纯出两情爱慕,绝无别种作用,搀杂其间,尚难保其永久,何况现在的婚姻,有别种作用搀杂的,且居多数呢?欲救夫妇道苦之弊,与其审慎于结婚之时,不如宽大于离婚之际,因为爱情本有变动,结婚时无论如何审慎,也控制不住后来的变化的。习俗所以重视离婚,法律也尽力禁阻,不过是要维持家庭。然家庭制度,实不是怎么值得维持的东西,参看下章可明。\n统观两性关系,自氏族时代以后,即已渐失其正常。其理由:因女子在产育上,所负的责任,较男子为多。因而其斗争的力量,较男子为弱。不论在人类凭恃武力相斗争,或凭恃财力相斗争的时代,女子均渐沦于被保护的地位,失其独立,而附属于男子。社会的组织,宜于宽平坦荡,个个人与总体直接。若多设等级,使这一部分人,隶属于那一部分人,那不公平的制度就要逐渐发生,积久而其弊愈深了。近代女权的渐渐伸张,实因工业革命以来,女子渐加入社会的机构,非如昔日蛰居家庭之中,专做辅助男子的事情之故。女子在产育上多尽了责任,男子就该在别一方面多尽些义务,这是公道。乘此机会压迫女子,多占权利,是很不正当的。而欲实行公道,则必自铲除等级始。所以有人说:社群制度是女子之友,家庭制度是女子之敌。然则“女子回到家庭去”这口号,当然只有开倒车的人,才会去高呼了。人家都说现在的女学生坏了,不如从前旧式的女子,因其对于家政生疏了,且不耐烦。殊不知这正是现代女子进步之征兆。因为对于家政生疏,对于参与社会的工作,却熟练了。这正是小的、自私的、自利的组织,将逐渐破坏;大的、公平的、博爱的制度,将逐渐形成的征兆。贤母良妻,只是贤奴良隶。此等教育,亦只好落伍的国家去提倡。我们该教一切男女以天下为公的志愿,广大无边的组织。\n第三十八章 族制 # 人是非团结不能生存的。当用何法团结呢?过去的事情,已非我们所能尽知;将来的事情,又非我们所能预料。我们现在只能就我们所知道的,略加说述而已。\n在有史时期,血缘是人类团结的一个重要因素。人恒狃于其所见闻,遂以此为人类团结唯一的因素,在过去都是如此,在将来也非如此不可了。其实人类的团结,并非是专恃血缘的。极远之事且勿论,即上章所说的以年龄分阶层之世,亦大率是分为老、壮、幼三辈(间有分为四辈的,但以分做三辈为最普通。《礼记·礼运》说:“使老有所终,壮有所用,幼有所长”;《论语·雍也篇》说:“老者安之,朋友信之,少者怀之”,亦都是分为三辈),而不再问其人与人间的关系的。当此之时,哪有所谓夫妇、父子、兄弟之伦呢?《礼记·礼运》说:大同之世,“人不独亲其亲,不独子其子”,《左传》载富辰的话,也说“大上以德抚民,其次亲亲,以相及也”(僖公二十四年)。可见亲族关系,是后起的情形了。\n人类愈进步,则其分化愈甚,而其组织的方法亦愈多。于是有所谓血族团体。血族团体,其初必以女子为中心。因为夫妇之伦未立,父不可知;即使可知,而父子的关系,亦不如母子之密之故。如上章所述,人类实在是社群动物,而非家庭动物,所以其聚居,并不限于两代。母及同母之人以外,又有母的母,母的同母等。自己而下推,亦是如此。逐渐成为母系氏族。每一个母系氏族,都有一个名称,是即所谓姓。一姓总有一个始祖母的,如殷之简狄,周之姜嫄即是。简狄之子契,姜嫄之子稷,都是无父而生的。因为在传说中,此等始祖母,本来无夫之故。记载上又说她俩都是帝喾之妃,一定是后来附会的(契、稷皆无父而生,见《诗·玄鸟》、《生民》。《史记·殷周本纪》所载,即是《诗》说。据陈乔枞《三家诗遗说考》所考证,太史公是用鲁诗说的。姜嫄、简狄,皆帝喾之妃,见《大戴礼记·帝系篇》。《史记·五帝本纪》,亦用其说)。\n女系氏族的权力,亦有时在男子手中(参看下章),此即所谓舅权制。此等权力,大抵兄弟相传,而不父子相继。因为兄弟是同氏族人,父子则异氏族之故。我国商朝和春秋时的鲁国、吴国,都有兄弟相及的遗迹(鲁自庄公以前,都一代传子,一代传弟,见《史记·鲁世家》),这是由于东南一带,母系氏族消灭较晚之故,已见上章。\n由于生业的转变,财产和权力,都转入男子手中,婚姻非复男子入居女子的氏族,而为女子入居男子的氏族(见上章)。于是组织亦以男为主,而母系氏族,遂变为父系氏族。商周自契稷以后,即奉契、稷为始祖,便是这种转变的一件史实。\n族之组织,是根据于血缘的。血缘之制既兴,人类自将据亲等的远近,以别亲疏。一姓的人口渐繁,又行外婚之制,则同姓的人,血缘不必亲,异姓的人,血缘或转相接近。所谓族与姓,遂不得不分化为两种组织。族制,我们所知道的,是周代的九族:一、父姓五服以内。二、姑母和他的儿子。三、姊妹和他的儿子。四、女儿和他的儿子。是为父族四;五、母的父姓,即现在所谓外家。六、母的母姓,即母亲的外家。七、母的姊妹和她们的儿子。是为母族三;八、妻之父姓。九、妻之母姓。是为妻族二。这是汉代今文家之说,见于《五经异义》(《诗·葛藟疏》引),《白虎通·宗族篇》同。古文家说,以上自高祖,下至玄孙为九族,此乃秦汉时制,其事较晚,不如今文家所说之古了。然《白虎通义》又载或说,谓尧时父母妻之族各三,周贬妻族以附父族,则今文家所说,亦已非极古之制。《白虎通义》此段,文有脱误,尧时之九族,无从知其详。然观下文引《诗》“邢侯之姨”,则其中该有妻之姊妹。总而言之:族制是随时改变的,然总是血缘上相近的人,和后世称父之同姓为族人,混同姓与同族为一不同,则是周以前所同的。九族中人,都是有服的。其无服的,则谓之党(《礼记·奔丧》郑《注》),是为父党,母党,妻党。\n同姓的人,因人口众多,血缘渐见疏远,其团结,是否因此就松懈了呢?不。所谓九族者,除父姓外,血缘上虽然亲近,却不是同居的。同姓则虽疏远而仍同居,所以生活共同,利害亦共同。在同居之时,固有其紧密的组织;即到人口多了,不能不分居,而彼此之间,仍有一定的联结,此即所谓宗法。宗法和古代的社会组织,有极大的关系。今略述其制如下:\n(一)凡同宗的人,都同奉一个始祖(均系此始祖之后)。\n(二)始祖的嫡长子,为大宗宗子。自此以后,嫡长子代代承袭,为大宗宗子。凡始祖的后人,都要尊奉他,受他的治理。穷困的却亦可以受他的救济。大宗宗子和族人的关系,是不论亲疏远近,永远如此的,是谓大宗“百世不迁”。\n(三)始祖之众子(嫡长子以外之子),皆别为小宗宗子。其嫡长子为继祢小宗。继祢小宗的嫡长子为继祖小宗。继祖小宗的嫡长子为继曾祖小宗。继曾祖小宗的嫡长子为继高祖小宗。继祢小宗,亲兄弟宗事他(受他治理,亦受他救济)。继祖小宗,从兄弟宗事他。继曾祖小宗,再从兄弟宗事他。继高祖小宗,三从兄弟宗事他。至四从兄弟,则与继六世祖之小宗宗子,亲尽无服,不再宗事他。是为小宗“五世则迁”(以一人之身论,当宗事与我同高、曾、祖、考四代的小宗宗子及大宗宗子。故曰:“小宗四,与大宗凡五。”)\n(四)如此,则或有无宗可归的人。但大宗宗子,还是要管理他,救济他的。而同出于一始祖之人,设或殇与无后,大宗的宗子,亦都得祭祀他。所以有一大宗宗子,则活人的治理、救济,死人的祭祀问题,都解决了。所以小宗可绝,大宗不可绝。大宗宗子无后,族人都当绝后以后大宗。\n以上是周代宗法的大略,见于《礼记·大传》的。《大传》所说大宗的始祖,是国君的众子。因为古者诸侯不敢祖天子,大夫不敢祖诸侯(《礼记·郊特牲》谓不敢立其庙而祭之。其实大宗的始祖,非大宗宗子,亦不敢祭。所以诸侯和天子,大夫和诸侯,大宗宗子和小宗宗子,小宗宗子和非宗子,其关系是一样的),所以国君的众子,要别立一宗。郑《注》又推而广之,及于始适异国的大夫。据此,宗法之立,实缘同出一祖的人太多了,一个承袭始祖的地位的人,管理有所不及,乃不得不随其支派,立此节级的组织,以便管理。迁居异地的人,旧时的族长,事实上无从管理他。此等组织,自然更为必要了。观此,即知宗法与封建,大有关系。因为封建是要将本族的人,分一部分出去的。有宗法的组织,则封之者和所封者之间,就可保持着一种联结了。然则宗法确能把同姓中亲尽情疏的人,联结在一起。他在九族之中,虽只联结得父姓一族。然在父姓之中,所联结者,却远较九族之制为广。怕合九族的总数,还不足以敌他。而且都是同居的人,又有严密的组织。母系氏族中,不知是否有与此相类的制度。即使有之,其功用,怕亦不如父系氏族的显著。因为氏族从母系转变到父系,本是和斗争有关系的。父系氏族而有此广大严密的组织,自然更能发挥其斗争的力量。我们所知,宗法之制,以周代为最完备,周这个氏族,在斗争上,是得到胜利的。宗法的组织,或者也是其中的一个原因。\n有族制以团结血缘相近的人,又有宗法以团结同出一祖的人,人类因血族而来的团结,可谓臻于极盛了。然而当其极盛之时,即其将衰之候。这是什么原因呢?社会组织的变化,经济实为其中最重要的原因。当进化尚浅之时,人类的互助,几于有合作而无分工。其后虽有分工,亦不甚繁复。大家所做的事,既然大致相同,又何必把过多的人联结在一起?所以人类联结的广大,是随着分工的精密而进展的。分工既密之后,自能将毫不相干的人,联结在一起。此等互相倚赖的人,虽然彼此未必相知,然总必直接间接,互相接触。接触既繁,前此因不相了解而互相猜忌的感情,就因之消除了。所以商业的兴起,实能消除异部族间敌对的感情。分工使个性显著。有特殊才能的人,容易发挥其所长,获得致富的机会。氏族中有私财的人逐渐多,卖买婚即于此时成立。说见上章。于是父权家庭成立了。孟子说:当时农夫之家,是五口和八口。说者以为一夫上父母下妻子;农民有弟,则为余夫,要另行授田(《梁惠王》及《滕文公上篇》),可见其家庭,已和现在普通的家庭一样了。士大夫之家,《仪礼·丧服传》说大功同财,似乎比农民的家庭要大些。然又说当时兄弟之间的情形道:“有东宫,有西宫,有南宫,有北宫,异居而同财。有余则归之宗,不足则资之宗。”则业已各住一所屋子,各有各的财产,不过几房之中,还保有一笔公款而已。其联结,实在是很薄弱的,和农夫的家庭,也相去无几了。在当时,只有有广大封土的人,其家庭要大些。这因为(一)他的原始,是以一氏族征服异氏族,而食其租税以自养的,所以宜于聚族而居,常作战斗的戒备。只要看《礼记》的《文王世子》,就知道古代所谓公族者,是怎样一个组织了。后来时异势殊,这种组织,实已无存在的必要。然既已习为故常,就难于猝然改革。这是一切制度,都有这惰性的。(二)其收入既多,生活日趋淫侈,家庭中管事服役的奴仆,以及技术人员,非常众多,其家庭遂特别大。这只要看《周官》的《天官》,就可以知道其情形。然此等家庭,随着封建的消灭,而亦渐趋消灭了。虽不乏新兴阶级的富豪,其自奉养,亦与素封之家无异,但毕竟是少数。于是氏族崩溃,家庭代之而兴。家庭的组织,是经济上的一个单位,所以是尽相生相养之道的。相生相养之道,是老者需人奉养,幼者需人抚育。这些事,自氏族崩溃后,既已无人负责,而专为中间一辈所谓一夫一妇者的责任。自然家庭的组织,不能不以一夫上父母下妻子为范围了。几千年以来,社会的生活情形,未曾大变,所以此种组织,迄亦未曾改变。\n看以上所述,可见族制的变迁,实以生活为其背景;而生活的变迁,则以经济为其最重要的原因。因为经济是最广泛,和社会上个个人,都有关系;而且其关系,是永远持续,无时间断的。自然对于人的影响,异常深刻,各种上层组织,都不得不随其变迁而变迁;而精神现象,亦受其左右而不自知了。在氏族时代,分工未密,一个氏族,在经济上,就是一个自给自足的团体。生活既互相倚赖,感情自然容易密切。不但对于同时的人如此,即对于以往的人亦然。因为我所赖以生存的团体,是由前人留遗下来的。一切知识技术等,亦自前辈递传给后辈。这时候的人,其生活,实与时间上已经过去的人关系深,而与空间上并时存在的人关系浅。尊祖、崇古等观念,自会油然而生。此等观念,实在是生活情形所造成的。后人不知此理,以为这是伦理道德上的当然,而要据之以制定人的生活,那就和社会进化的趋势,背道而驰了。大家族、小家庭等字样,现在的人用来,意义颇为混淆。西洋人学术上的用语,称一夫一妇,包括未婚子女的为小家庭;超过于此的为大家庭。中国社会,(一)小家庭和(二)一夫上父母下妻子的家庭,同样普遍。(三)兄弟同居的,亦自不乏。(四)至于五世同居,九世同居,宗族百口等,则为罕有的现象了。赵翼《陔余丛考》,尝统计此等极大的家庭(第四种),见于正史孝义、孝友传的:《南史》三人,《北史》十二人,《唐书》三十八人,《五代史》二人,《宋史》五十人,《元史》五人,《明史》二十六人。自然有(一)不在孝义、孝友传,而散见于他篇的;(二)又有正史不载,而见于他书的;(三)或竟未见记载的。然以中国之大,历史上时间之长,此等极大的家庭,总之是极少数,则理有可信。此等虽或由于伦理道德的提倡(顾炎武《华阴王氏宗祠记》:“程朱诸子,卓然有见于遗经。金元之代,有志者多求其说于南方,以授学者。及乎有明之初,风俗淳厚,而爱亲敬长之道,达诸天下。其能以宗法训其家人,或累世同居,称为义门者,往往而有。”可见同居之盛,由于理学家的提倡者不少),恐仍以别有原因者居多(《日知录》:“杜氏《通典》言:北齐之代,瀛、冀诸刘,清河张、宋,并州王氏,濮阳侯族,诸如此辈,将近万室。《北史·薛胤传》:为河北太守,有韩马两姓,各二千余家。今日中原北方,虽号甲族,无有至千丁者。户口之寡,族姓之衰,与江南相去敻绝。”陈宏谋《与杨朴园书》:“今直省惟闽中、江西、湖南,皆聚族而居,族各有祠。”则聚居之风,古代北盛于南,近世南盛于北,似由北齐之世,丧乱频仍,民皆合族以自卫;而南方山岭崎岖之地进化较迟,土著者既与合族而居之时,相去未远;流移者亦须合族而居,互相保卫之故)。似可认为古代氏族的遗迹,或后世家族的变态。然氏族所以崩溃,正由家族潜滋暗长于其中。此等所谓义门,纵或有古代之遗,亦必衰颓已甚。况又有因环境的特别,而把分立的家庭,硬行联结起来的。形式是而精神非,其不能持久,自然无待于言了。《后汉书·樊宏传》,说他先代三世共财,有田三百余顷。自己的田地里,就有陂渠,可以互相灌注。又有池鱼,牧畜,有求必给。“营理产业,物无所弃(这是因其生产的种类较多之故)。课役童隶,各得其宜。”(分工之法)要造器物,则先种梓漆。简直是一个大规模的生产自给自足的团体。历代类乎氏族的大家族,多有此意。此岂不问环境所可强为?然社会的广大,到底非此等大家族所能与之相敌,所以愈到后世,愈到开化的地方,其数愈少。这是类乎氏族的大家族,所以崩溃的真原因,毕竟还在经济上。但在政治上,亦自有其原因。因为所谓氏族,不但尽相生相养之责,亦有治理其族众之权。在国家兴起以后,此项权力,实与国权相冲突。所以国家在伦理上,对于此等大家族,虽或加以褒扬,而在政治上,又不得不加以摧折。所谓强宗巨家,遂多因国家的干涉,而益趋于崩溃了。略大于小家庭的家庭(第二、第三种)表面上似为伦理道德的见解所维持(历代屡有禁民父母在别籍异财等诏令,可参看《日知录》卷十三《分居》条),实则亦为经济状况所限制。因为在经济上,合则力强,分则力弱,以昔时的生活程度论,一夫一妇,在生产和消费方面,实多不能自立的。儒者以此等家庭之多,夸奖某地方风俗之厚,或且自诩其教化之功,就大谬不然了。然经济上虽有此需要,而私产制度,业已深入人心,父子兄弟之间,亦不能无分彼此。于是一方面牵于旧见解,迫于经济情形,不能不合;另一方面,则受私有财产风气的影响,而要求分;暗斗明争,家庭遂成为苦海。试看旧时伦理道德上的教训,戒人好货财、私妻子。而薄父母兄弟之说之多,便知此项家庭制度之岌岌可危。制度果然自己站得住,何须如此扶持呢?所以到近代,除极迂腐的人外,亦都不主张维持大家庭。如李绂有《别籍异财议》,即其一证。至西洋文化输入,论者更其提倡小家庭,而排斥大家庭了。然小家庭又是值得提倡的么?\n不论何等组织,总得和实际的生活相应,才能持久。小家庭制度,是否和现代人的生活相应呢?历来有句俗话,叫做“养儿防老,积谷防饥”。可见所谓家庭,实以扶养老者、抚育儿童,为其天职。然在今日,此等责任,不但苦于知识之不足(如看护病人,抚养教育儿童,均须专门知识),实亦为其力量所不及(兼日力财力言之。如一主妇不易看顾多数儿童,兼操家政。又如医药、教育的费用,不易负担)。在古代,劳力重于资本,丁多即可致富,而在今日,则适成为穷困的原因。因为生产的机键,自家庭而移于社会了,多丁不能增加生产,反要增加消费(如纺织事业)。儿童的教育,年限加长了,不但不能如从前,稍长大即为家庭挣钱,反须支出教育费。而一切家务,合之则省力,分之则多费的(如烹调、浣濯)。又因家庭范围太小,而浪费物质及劳力。男子终岁劳动,所入尚不足以赡其家。女子忙得和奴隶一般,家事还不能措置得妥帖。于是独身、晚婚等现象,相继发生。这些都是舶来品,和中国旧俗,大相径庭,然不久,其思想即已普遍于中流社会了。凡事切于生活的,总是容易风行的,从今以后,穷乡僻壤的儿女,也未必死心塌地,甘做家庭的奴隶了。固然,个人是很难打破旧制度,自定办法的。而性欲出于天然,自能把许多可怜的儿女,牵入此陈旧组织之中。然亦不过使老者不得其养,幼者不遂其长,而仍以生子不举等人为淘汰之法为救济罢了。这种现象,固已持续数千年,然在今日,业经觉悟之后,又何能坐视其如此呢?况且家庭的成立,本是以妇女的奴役为其原因的。在今日个人主义抬头,人格要受尊重的时代,妇女又何能长此被压制呢?资本主义的学者,每说动物有雌雄两性,共同鞠育其幼儿,而其同居期限,亦因以延长的,以为家庭的组织,实根于人类的天性,而无可改变。姑无论其所说动物界的情形,并不确实。即使退一步,承认其确实,而人是人,动物是动物;人虽然亦是动物之一,到底是动物中的人;人类的现象,安能以动物界的现象为限?他姑弗论,动物雌雄协力求食,即足以哺育其幼儿,人,为什么有夫妇协力,尚不能养活其子女的呢?或种动物,爱情限于家庭,而人类的爱情,超出于此以外,这正是人之所以为人,人之所以异于动物。论者不知人之爱家,乃因社会先有家庭的组织,使人之爱,以此形式而出现,正犹水之因方而为圭,遇圆而成璧;而反以为人类先有爱家之心,然后造成家庭制度;若将家庭破坏,便要“疾病不养;老幼孤独,不得其所”(《礼记·乐记》:“强者胁弱,众者暴寡;知者诈愚,勇者苦怯;疾病不养,老幼孤独,不得其所,此大乱之道也”),这真是倒果为因。殊不知家庭之制,把人分为五口八口的小团体,明明是互相倚赖的,偏使之此疆彼界,处于半敌对的地位,这正是疾病之所以不养,老幼孤独之所以不得其所。无后是中国人所引为大戚的,论者每说,这是拘于“不孝有三,无后为大”之义(《孟子·离娄上篇》)。而其以无后为不孝,则是迷信“鬼犹求食”(见《左传》宣公四年),深虑祭祀之绝。殊不知此乃古人的迷信,今人谁还迷信鬼犹求食来?其所以深虑无后,不过不愿其家之绝;所以不愿其家之绝,则由于人总有尽力经营的一件事,不忍坐视其灭亡,而家是中国人所尽力经营的,所以如此。家族之制,固然使人各分畛域,造成互相敌对的情形,然此自制度之咎,以爱家者之心论:则不但(一)夫妇、父子、兄弟之间,互尽扶养之责。(二)且推及于凡与家族有关系的人(如宗族姻亲等)。(三)并且悬念已死的祖宗。(四)以及未来不知谁何的子孙。前人传给我的基业,我必不肯毁坏,必要保持之,光大之,以传给后人,这正是极端利他心的表现。利他心是无一定形式的,在何种制度之下,即表现为何种形式。然而我们为什么要拘制着他,一定只许他在这种制度中表现呢?\n以上论族制的变迁,大略已具。现再略论继承之法。一个团体,总有一个领袖。在血缘团体之内,所谓父或母,自然很容易处于领袖地位的。父母死后,亦当然有一个继承其地位的人。女系氏族,在中国历史上,可考的有两种继承之法:(一)是以女子承袭财产,掌管祭祀。前章所述齐国的巫儿,即其遗迹。这大约是平时的族长。(二)至于战时及带有政治性质的领袖,则大约由男子尸其责,而由弟兄相及。殷代继承之法,是其遗迹。男系氏族,则由父子相继。其法又有多端:(一)如《左传》文公元年所说:“楚国之举,恒在少者。”这大约因幼子恒与父母同居,所以承袭其遗产(蒙古人之遗产,即归幼子承袭。其幼子称斡赤斤,译言守灶)。(二)至于承袭其父之威权地位,则自以长子为宜,而事实上亦以长子为易。(三)又古代妻妾,在社会上之地位亦大异。妻多出于贵族,妾则出于贱族,或竟是无母家的。古重婚姻,强大的外家及妻家,对于个人,是强有力的外援(如郑庄公的大子忽,不婚于齐,后来以无外援失位);对于部族,亦是一个强有力的与国,所以立子又以嫡为宜。周人即系如此。以嫡为第一条件,长为第二条件。后来周代的文化,普行于全国,此项继承之法,遂为法律和习惯所共认了。然这只是承袭家长的地位,至于财产,则总是众子均分的(《清律》:分析家财、田产,不问妻、妾、婢生,但以子数均分。奸生之子,依子量与半分。无子立继者,与私生子均分)。所以中国的财产,不因遗产承袭,而生不均的问题。这是众子袭产,优于一子袭产之点。\n无后是人所不能免的,于是发生立后的问题。宗法盛行之世,有一大宗宗子,即生者的扶养,死者的祭祀,都可以不成问题,所以立后问题,容易解决。宗法既废,势非人人有后不可,就难了。在此情形之下,解决之法有三:(一)以女为后。(二)任立一人为后,不问其为同异姓。(三)在同姓中择立一人为后。(一)于情理最近,但宗祧继承,非徒承袭财产,亦兼掌管祭祀。以女为后,是和习惯相反的(春秋时,郑国以外孙为后,其外孙是莒国的儿子,《春秋》遂书“莒人灭郑”,见《公羊》襄公五、六年。按此实在是论国君承袭的,乃公法上的关系,然后世把经义普遍推行之于各方面,亦不管其为公法私法了)。既和习惯相反,则觊觎财产的人,势必群起而攻,官厅格于习俗,势必不能切实保护。本欲保其家的,或反因此而发生纠纷,所以势不能行。(二)即所谓养子,与家族主义的重视血统,而欲保其纯洁的趋势不合。于是只剩得第(三)的一途。法律欲维持传统观念,禁立异姓为后,在同姓中并禁乱昭穆之序(谓必辈行相当,如不得以弟为子等。其实此为古人所不禁,所谓“为人后者为之子”,见《公羊》成公十五年)。于是欲人人有后益难,清高宗时,乃立兼祧之法,以济其穷(一人可承数房之祀。生子多者,仍依次序,分承各房之后。依律例:大宗子兼祧小宗,小宗子兼祧大宗,皆以大宗为重,为大宗父母服三年,为小宗父母服期。小宗子兼祧小宗,以本生为重,为本生父母服三年,为兼祧父母服期。此所谓大宗,指长房,所谓小宗,指次房以下,与古所谓大宗小宗者异义。世俗有为本生父母及所兼祧之父母均服三年的,与律例不合)。宗祧继承之法,进化至此,可谓无遗憾了。然其间却有一难题。私有财产之世,法律理应保护个人的产权。他要给谁就给谁,要不给谁就不给谁。为后之子,既兼有承袭财产之权利,而法律上替他规定了种种条件,就不啻干涉其财产的传授了。于是传统的伦理观念,和私有财产制度,发生了冲突。到底传统的伦理观念是个陈旧不切实际的东西,表面上虽然像煞有介事,很有威权,实际上已和现代人的观念不合了。私有财产制度,乃现社会的秩序的根柢,谁能加以摇动?于是冲突之下,伦理观念,乃不得不败北而让步。法律上乃不得不承认所谓立爱,而且多方保护其产权(《清律例》:继子不得于所后之亲,听其告官别立。其或择立贤能,及所亲爱者,不许宗族以次序告争,并官司受理)。至于养子,法律虽禁其为嗣(实际上仍有之),亦不得不听其存在,且不得不听其酌给财产(亦见《清律例》)。因为国家到底是全国人民的国家,在可能范围内,必须兼顾全国人民各方面的要求,不能专代表家族的排外自私之念。在现制度之下,既不能无流离失所之人;家族主义者流,既勇于争袭遗产,而怯于收养同宗;有异姓的人肯收养他,国家其势说不出要禁止。不但说不出要禁止,在代表人道主义和维持治安的立场上说,无宁还是国家所希望的。既承认养子的存在,在事实上,自不得不听其酌给遗产了。这也是偏私的家族观念,对于公平的人道主义的让步,也可说是伦理观念的进步。\n假使宗祧继承的意思,而真是专于宗祧继承,则拥护同姓之男,排斥亲生之女,倒也还使人心服。因为立嗣之意,无非欲保其家,而家族的存在,是带着几分斗争性质的。在现制度之下,使男子从事于斗争,确较女子为适宜(这并非从个人的身心能力上言,乃是从社会关系上言),这也是事实。无如世俗争继的,口在宗祧,心存财产,都是前人所谓“其言蔼如,其心不可问”的。如此而霸占无子者的财产,排斥其亲生女,就未免使人不服了。所以有国民政府以来,废止宗祧继承,男女均分遗产的立法。这件事于理固当,而在短时间内,能否推行尽利,却是问题。旧律,遗产本是无男归女,无女入官的(近人笔记云:“宋初新定《刑统》,户绝资产下引《丧葬令》;诸身丧户绝者,所有部曲、客女、奴婢、店宅、资财,并令近亲转易货卖,将营葬事,及量营功德之外,余财并与女。无女均入以次近亲。无亲戚者,官为检校。若亡人在日,自有遗嘱处分,证验分明者,不用此令。此《丧葬令》乃《唐令》,知唐时所谓户绝,不必无近亲。虽有近亲,为营丧葬,不必立近亲为嗣子,而远亲不能争嗣,更无论矣。虽有近亲,为之处分,所余财产,仍传之亲女,而远亲不能争产,更无论矣。此盖先世相传之法,不始于唐。”按部曲、客女,见第四十章)。入官非人情所愿,强力推行,必多流弊,或至窒碍难行(如隐匿遗产,或近亲不易查明,以致事悬不决,其间更生他弊等)。归之亲女,最协人情。然从前的立嗣,除祭祀外,尚有一年老奉养的问题。而家族主义,是自私的。男系家族,尤其以男子为本位,而蔑视女子的人格。女子出嫁之后,更欲奉养其父母,势实有所为难。所以旧时论立嗣问题的人,都说最好是听其择立一人为嗣,主其奉养、丧葬、祭祀,而承袭其遗产。这不啻以本人的遗产,换得一个垂老的扶养,和死后的丧葬祭祀。今欲破除迷信,祭祀固无问题,对于奉养及丧葬,似亦不可无善法解决。不有遗产以为交易,在私有制度之下,谁肯顾及他人的生养死葬呢?所以有子者遗产男女均分,倒无问题,无子者财产全归于女,倒是有问题的。所以变法贵全变,革命要彻底。枝枝节节而为之,总只是头痛医头,脚痛医脚的对症疗法。\n姓氏的变迁,今亦须更一陈论。姓的起源,是氏族的称号,由女系易而为男系,说已见前。后来姓之外又有所谓氏。什么叫做氏呢?氏是所以表一姓之中的支派的。如后稷之后都姓姬,周公封于周,则以周为氏;其子伯禽封于鲁,则以鲁为氏(国君即以国为氏);鲁桓公的三子,又分为孟孙、叔孙、季孙三氏是。始祖之姓,谓之正姓,氏亦谓之庶姓。正姓是永远不改的,庶姓则随时可改。因为同出于一祖的人太多了,其支分派别,亦不可无专名以表之,而专名沿袭太久,则共此一名的人太多,所以又不得不改(改氏的原因甚多,此只举其要改的根本原理。此外如因避难故而改氏以示别族等,亦是改氏的一种原因)。《后汉书·羌传》说:羌人种姓中,出了一个豪健的人,便要改用他的名字做种姓。如爰剑之后,五世至研,豪健,其子孙改称研种;十三世至烧当,复豪健,其子孙又改称烧当种是。这正和我国古代的改氏,原理相同。假如我们在鲁国,遇见一个人,问他尊姓,他说姓姬。这固然足以表示他和鲁君是一家。然而鲁君一家的人太多了,鲁君未必能个个照顾到,这个人,就未必一定有势力,我们听了,也未必肃然起敬。假若问他贵氏,他说是季孙,我们就知道他是赫赫有名的正卿的一家。正卿的同族,较之国君的同姓,人数要少些,其和正卿的关系,必较密切,我们闻言之下,就觉得炙手可热,不敢轻慢于他了。这是举其一端,其余可以类推(如以技为官,以官为氏,问其氏,即既可知其官,又可知其技)。所以古人的氏,确是有用的。至于正姓,虽不若庶姓的亲切,然婚姻之可通与否,全论正姓的异同。所以也是有用的。顾炎武《原姓篇》说春秋以前,男子称氏,女子称姓(在室冠之以序,如叔隗、季隗之类。出嫁,更冠以其夫之氏族,如宋伯姬、赵姬、卢蒲姜之类。在其所适之族,不必举出自己的氏族来,则亦以其父之氏族冠之,如骊姬、梁嬴之类。又有冠之以谥的,如成风、敬姜之类),这不是男子不论姓,不过举氏则姓可知罢了。女子和社会上无甚关系,所以但称姓而不称其氏,这又可以见得氏的作用。\n贵族的世系,在古代是有史官为之记载的。此即《周官》小史之职。记载天子世系的,谓之帝系;记载诸侯卿大夫世系的,谓之世本。这不过是后来的异名,其初原是一物。又瞽矇之职,“讽诵诗,世奠系”(疑当作奠世系)。《注》引杜子春说:谓瞽矇“主诵诗,并诵世系”。世系而可诵,似乎除统绪之外,还有其性行事迹等。颇疑《大戴礼记》的《帝系姓》,原出于小史所记;《五帝德》则是原出于瞽矇所诵的(自然不是完全的),这是说贵族。至于平民,既无人代他记载,而他自己又不能记载,遂有昧于其所自出的。《礼记·曲礼》谓买妾不知其姓,即由于此。然而后世的士大夫,亦多不知其姓氏之所由来的。这因为谱牒掌于史官,封建政体的崩溃,国破家亡,谱牒散失,自然不能知其姓氏之所由来了。婚姻的可通与否,既不复论古代的姓,新造姓氏之事亦甚少。即有之,亦历久不改。阅一时焉,即不复能表示其切近的关系,而为大多数人之所共,与古之正姓同。姓遂成为无用的长物,不过以其为人人之所有,囿于习惯,不能废除罢了。然各地方的强宗巨家,姓氏之所由来,虽不可知,而其在实际上的势力自在。各地方的人,也还尊奉他。在秦汉之世,习为固然,不受众人的注意。汉末大乱,各地方的强宗巨家,开始播迁,到了一个新地方,还要表明其本系某地方的某姓;而此时的选举制度,又重视门阀。于是又看重家世,而有魏晋以来的谱学了。见第四十章。\n第三十九章 政体 # 社会发达到一定的程度,国家就出现了。在国家出现之前,人类团结的方法,只靠血缘,其时重要的组织,就是氏族,对内的治理,对外的防御,都靠着它。世运渐进,血缘相异的人,接触渐多,人类的组织,遂不复以血统相同为限,聚居一地方的,亦不限于血统相同的人。于是氏族进而为部落。统治者的资格,非复族长而为酋长。其统治亦兼论地域,开国家领土的先河了。\n从氏族变为部落,大概经过这样的情形。在氏族的内部,因职业的分化,家族渐渐兴起。氏族的本身,遂至崩溃。各家族非如其在氏族时代,绝对平等,而有贫富之分。财富即是权力,氏族平和的情形,遂渐渐破坏,贫者和富者之间,发生了矛盾,不得不用权力统治。其在异氏族之间,则战斗甚烈。胜者以败者为俘虏,使服劳役,是为奴隶。其但征收其贡赋的,则为农奴。农奴、奴隶和主人之间,自然有更大的矛盾,需要强力镇压。因此故,益促成征服氏族的本身,发生变化。征服氏族的全体,是为平民。其中掌握事权的若干人,形成贵族。贵族中如有一个最高的首领,即为君主的前身。其初是贵族与平民相去近,平民和农奴、奴隶相去远。其后血统相同的作用渐微,掌握政权与否之关系渐大,则平民与农奴、奴隶相去转近,而其与贵族相去转远(参看下章)。但平民总仍略有参政之权,农奴和奴隶则否。政权的决定,在名义上最后属于一人的,是为君主政体。属于较少数人的,是为贵族政体。属于较多数人的,是为民主政体。这种分类之法,是出于亚里斯多德(Aristotle)的。虽与今日情形不同,然以论古代的政体,则仍觉其适合。\n氏族与部落,在实际上,是不易严密区分的。因为进化到部落时代,其内部,总还保有若干氏族时代的意味。从理论上言,则其团结,由于血统相同(虽实际未必相同,然苟被收容于其团体之内,即亦和血统相同的人,一律看待),而其统治,亦全本于亲族关系的,则为氏族。其不然的,则为部落。因其二者杂糅,不易区别,我们亦可借用《辽史》上的名词,称之为部族(见《营卫志》)。至于古代所谓国家,其意义,全和现在不同。古所谓国,是指诸侯的私产言之。包括(一)其住居之所,(二)及其有收益的土地。大夫之所谓家者亦然(古书上所谓国,多指诸侯的都城言。都城的起源,即为诸侯的住所。诸侯的封域以内,以财产意义言,并非全属诸侯所私有。其一部分,还是要用以分封的。对于此等地方,诸侯仅能收其贡而不能收其税赋。其能直接收其税赋,以为财产上的收入的,亦限于诸侯的采地。《尚书大传》说:“古者诸侯始受封,必有采地。其后子孙虽有罪黜,其采地不黜,使子孙贤者守之世世,以祠其始受封之人,此之谓兴灭国,继绝世”,即指此。采地从财产上论,是应该包括于国字之内的。《礼记·礼运》说:“天子有田以处其子孙,诸侯有国以处其子孙。”乃所谓互言以相备。说天子有田,即见得诸侯亦有田;说诸侯有国,即见得天子亦有国。在此等用法之下,田字的意义,亦包括国,国字的意义,亦包括田。乃古人语法如此)。今之所谓国家,古无此语。必欲求其相近的,则为“社稷”二字或“邦”字。社是土神,稷是谷神,是住居于同一地方的人,所共同崇奉的。故说社稷沦亡,即有整个团体覆灭之意。邦和封是一语。封之义为累土。两个部族交界之处,把土堆高些,以为标识,则谓之封。引申起来,任用何种方法,以表示疆界,都可以谓之封(如掘土为沟,以示疆界,亦可谓之封。故今辽宁省内,有地名沟帮子。帮字即邦字,亦即封字。上海洋泾浜之浜字,亦当作封)。疆界所至之地,即谓之邦。古邦字和国字,意义本各不同。汉高祖名邦,汉人讳邦字,都改作国。于是国字和邦字的意义混淆了。现在古书中有若干国字,本来是当作邦字的。如《诗经》里的“日辟国百里”、“日蹙国百里”便是。封域可以时有赢缩,城郭是不能时时改造的(國与域同从或声,其初当亦系一语,则国亦有界域之意。然久已分化为两语了。古书中用国字域字,十之九,意义是不同的)。\n贵族政体和民主政体,在古书上,亦未尝无相类的制度。然以大体言之,则君权之在中国,极为发达。君主的第一个资格,是从氏族时代的族长,沿袭而来的,所以古书上总说君是民之父母。其二则为政治或军事上之首领。其三则兼为宗教上之首领。所以天子祭天地,诸侯祭社稷等(《礼记·王制》),均有代表其群下而为祭司之权,而《书经》上说:“天降下民,作之君,作之师”(《孟子·梁惠王下篇》引),君主又操有最高的教育之权。\n君主前身,既然是氏族的族长,所以他的继承法,亦即是氏族族长的继承法。已见前章。在母系社会,则为兄终弟及,在父系社会,则为父死子继。当其为氏族族长时,无甚权利可争,而其关系亦小,所以立法并不十分精密。《左传》昭公二十六年,王子朝告诸侯,说周朝的继承法,适庶相同则论年,“年钧以德,德钧则卜”。两个人同年,是很容易的事情,同月,同日,同时则甚难,何至辨不出长幼来,而要用德、卜等漫无标准的条件?可见旧法并不甚密。《公羊》隐公元年何《注》说:“礼:适夫人无子,立右媵。右媵无子,立左媵。左媵无子,立适姪娣。适姪娣无子,立右媵姪娣。右媵姪娣无子,立左媵姪娣。质家亲亲先立娣。文家尊尊先立姪(《春秋》以殷为质家,周为文家),适子有孙而死,质家亲亲先立弟,文家尊尊先立孙。其双生,质家据见立先生,文家据本意立后生。”定得非常严密。这是后人因国君的继承,关系重大而为之补充的,乃系学说而非事实。\n周厉王被逐,宣王未立,周召二公,共和行政,凡十四年。主权不属于一人,和欧洲的贵族政体,最为相像。按《左传》襄公十四年,卫献公出奔,卫人立公孙剽,孙林父、甯殖相之,以听命于诸侯,此虽有君,实权皆在二相,和周召的共和,实际也有些相像。但形式上还是有君的。至于鲁昭公出奔,则鲁国亦并未立君,季氏对于国政,决不能一人专断,和共和之治,相像更甚了。可见贵族政体,古代亦有其端倪,不过未曾发达而成为一种制度。\n至于民主政治,则其遗迹更多了。我们简直可以说:古代是确有这种制度,而后来才破坏掉的。《周官》有大询于众庶之法,乡大夫“各帅其乡之众寡而致于朝”,小司寇“摈以序进而问焉”。其事项:为询国危,询国迁,询立君。按《左传》定公八年,卫侯欲叛晋,朝国人,使王孙贾问焉。哀公元年,吴召陈怀公,怀公亦朝国人而问,此即所谓询国危;盘庚要迁都于殷,人民不肯,盘庚“命众悉造于庭”,反复晓谕。其言,即今《书经》里的《盘庚篇》。周太王要迁居于岐,“属其父老而告之”(《孟子·梁惠王下篇》),此即所谓询国迁;《左传》昭公二十四年,周朝的王子朝和敬王争立,晋侯使士景伯往问。士伯立于乾祭(城门名),而问于介众(介众,大众)。哀公二十六年,越人纳卫侯,卫人亦致众而问。此即所谓询立君。可见《周官》之言,系根据古代政治上的习惯,并非理想之谈。《书经·洪范》:“汝则有大疑,谋及乃心,谋及卿士,谋及庶人,谋及卜筮。汝则从,龟从,筮从,卿士从,庶民从,是之谓大同。身其康强,子孙其逢,吉。汝则从,龟从,筮从,卿士逆,庶民逆,吉。卿士从,龟从,筮从,汝则逆,庶民逆,吉。庶民从,龟从,筮从,汝则逆,卿士逆,吉。汝则从,龟从,筮逆,卿士逆,庶民逆,作内吉,作外凶。龟筮共违于人,用静吉,用作凶。”此以一君主,二卿士,三庶人,四龟,五筮,各占一权,而以其多少数定吉凶,亦必系一种会议之法。并非随意询问。至于随意询问之事,如《孟子》所谓“国人皆曰贤,然后察之,见贤焉,然后用之”,“国人皆曰不可,然后察之,见不可焉,然后去之”,“国人皆曰可杀,然后察之,见可杀焉,然后杀之”(《梁惠王下篇》),以及《管子》所谓啧室之议等(见《桓公问篇》),似乎不过是周谘博采,并无必从的义务。然其初怕亦不然。野蛮部落,内部和同,无甚矛盾,舆论自极忠实。有大事及疑难之事,会议时竟有须全体通过,然后能行,并无所谓多数决的。然则舆论到后来,虽然效力渐薄,竟有如郑人游于乡校,以议执政,而然明欲毁乡校之事(见《左传》襄公三十年)。然在古初,必能影响行政,使当局者不能不从,又理有可信了。原始的制度,总是民主的。到后来,各方面的利害、冲突既深;政治的性质,亦益复杂,才变而由少数人专断。这是普遍的现象,无足怀疑的。有人说:中国自古就是专制,国人的政治能力,实在不及西人,固然抹杀史实。有人举此等民权遗迹以自豪,也是可以不必的。\n以上所述,是各部族内部的情形。至于合全国而观之,则是时正在部族林立之世。从前的史家,率称统一以前为封建时代,此语颇须斟酌。学术上的用语,不该太拘于文字的初诂。封建二字,原不妨扩而充之,兼包列国并立的事实,不必泥定字面,要有一个封他的人。然列国本来并立,和有一个封他的人,二者之间,究应立一区别。我以为昔人所谓封建时代,应再分为(一)部族时代,或称先封建时代;(二)封建时代较妥。所谓封建,应指(甲)慑服异部族,使其表示服从;(乙)打破异部族,改立自己的人为酋长;(丙)使本部族移殖于外言之。\n中国以统一之早,闻于世界。然秦始皇的灭六国,事在民国纪元前2132年,自此上溯至有史之初,似尚不止此数,若更加以先史时期,则自秦至今的年代,几乎微末不足道了。所以历史上像中国这样的大国,实在是到很晚的时期才出现的。\n从部族时代,进而至于封建时代,是从无关系进到有关系,这是统一的第一步。更进而开拓荒地,互相兼并,这是统一的第二步。这其间的进展,全是文化上的关系。因为必先(一)国力充实,然后可以征服他国。(二)亦必先开拓疆土,人口渐多,经济渐有进步,国力方能充实。(三)又必开拓渐广,各国间壤地相接,然后有剧烈的斗争。(四)而交通便利,风俗渐次相同,便于统治等,尤为统一必要的条件。所以从分立而至于统一,全是一个文化上的进展。向来读史的人,都只注意于政治方面,实在是挂一漏万的。\n要知道封建各国的渐趋于统一,只要看其封土的扩大,便可知道。今文家说列国的封土,是天子之地方千里,公、侯皆方百里,伯七十里,子、男五十里,不满五十里的为附庸(《孟子·万章下篇》、《礼记·王制》)。古文家则说:公方五百里,侯四百里,伯三百里,子二百里,男百里(《周官》大司徒)。这固然是虚拟之辞,不是事实(不论今古文和诸子书,所说的制度,都是著书的人,以为该怎样办所拟的一个草案,并不全是古代的事实),然亦必以当时的情势为根据。《穀梁》说:“古者天子封诸侯,其地足以容其民,其民足以满城而自守也。”(襄公二十九年)这是古代封土,必须有一个制限,而不容任意扩大的原因。今古文异说,今文所代表的,常为早一时期的制度,古文所代表的则较晚。秦汉时的县,大率方百里(见《汉书·百官公卿表》),可见方百里实为古代的一个政治区域,此今文家大国之封所由来。其超过于此的,如《礼记·明堂位》说:“成王封周公于曲阜,地方七百里。”《史记·汉兴以来诸侯年表》说:“周封伯禽、康叔于鲁、卫,地各四百里;太公于齐,兼五侯地。”这都是后来开拓的结果,而说者误以为初封时的事实的。列国既开拓至此,谈封建制度的人,自然不能斫而小之,亦不必斫而小之,就有如古文家所说的制度了。以事实言之:今文家所说的大国,在东周时代,已是小国。古文家所说的大国,则为其时的次等国。至其时的所谓大国,则子产称其“地方数圻”(圻同畿,即方数千里,见《左传》襄公三十五年)。《孟子》说:“海内之国,方千里者九,齐集有其一。”(《梁惠王上篇》)惟晋、楚、齐、秦等足以当之。此等大国,从无受封于人的;即古文家心目中,以为当封建之国,亦不能如此其大,所以谈封建制度的不之及。\n此等大国,其实际,实即当时谈封建制度者之所谓王。《礼记》说:“天无二日,民无二王”(《曾子问》),这只是古人的一个希望,事实上并不能如此。事实上,当时的中国,是分为若干区域,每区域之中,各自有王的。所以春秋时吴、楚皆称王,战国时七国亦皆称王。公、侯、伯、子、男等,均系美称。论其实,则在一国之内,有最高主权的,皆称为君(《礼记·曲礼》:“九州之伯,入天子之国曰牧,于外曰侯,于其国曰君”)。其为一方所归往的,即为此一区域中的王。《管子·霸言》说:“强国众,则合强攻弱以图霸;强国少,则合小攻大以图王。”此为春秋时吴、楚等国均称王,而齐晋等国仅称霸的原因。因为南方草昧初开,声明文物之国少,肯承认吴、楚等国为王;北方鲁、卫、宋、郑等国,就未必肯承认齐、晋为王了。倒是周朝,虽然弱小,然其称王,是自古相沿下来的,未必有人定要反对它。而当时较大之国,其初大抵是它所封建,有同姓或亲戚的关系,提起它来,还多少有点好感;而在国际的秩序上,亦一时不好否认它,于是齐桓、晋文等,就有挟天子以令诸侯之举了。霸为伯的假借字。伯的本义为长。《礼记·王制》说:“千里之外设方伯。五国以为属,属有长。十国以为连,连有帅。三十国以为卒,卒有正。二百一十国以为州,州有伯。八州,八伯,五十六正,百六十八帅,三百三十六长。八伯各以其属,属于天子之老二人。分天下以为左右,曰二伯。”这又是虚拟的制度,然亦有事实做根据的。凡古书所说朝贡、巡守等制度,大抵是邦畿千里之内的规模(或者还更小于此。如《孟子·梁惠王下篇》说天子巡守的制度,是“春省耕而补不足,秋省敛而助不给”,这只是后世知县的劝农)。后人扩而充之,以为行之于如《禹贡》等书所说的九州之地,于理就不可通了(春天跑到泰山,夏天跑到衡山,秋天跑到华山,冬天跑到恒山,无论其为回了京城再出去,或者从东跑到南,从南跑到西,从西跑到北,总之来不及),然其说自有所本。《公羊》隐公五年说:“自陕以东,周公主之;自陕以西,召公主之”,此即二伯之说所由来。分《王制》的九州为左右,各立一伯,古无此事;就周初的封域,分而为二,使周公、召公各主其一,则不能谓无此事的。然则所谓八州、八伯,恐亦不过就王畿之内,再分为九,天子自治其一,而再命八个诸侯,各主一区而已。此项制度,扩而大之,则如《左传》僖公四年,管仲对楚使所说:“昔召康公命我先君太公曰:五侯九伯,女实征之,以夹辅周室。赐我先君履,东至于海,西至于河,南至于穆陵,北至于无棣。”等于《王制》中所说的一州之伯了。此自非周初的事实,然管仲之说,亦非凭空造作,亦仍以小规模的伯为根据。然则齐桓、晋文等,会盟征伐,所牵连而及的,要达于《王制》所说的数州之广,其规模虽又较大,而其霸主之称,还是根据于此等一州之伯的,又可推而知了。春秋时晋、楚、齐、秦等国,其封土,实大于殷周之初。其会盟征伐的规模,亦必较殷周之初,有过之无不及。特以强国较多,地丑德齐,莫能相尚,不能称王(吴、楚等虽称王,只是在一定区域之内,得其小国的承认)。至于战国时,就老实不客气,各自在其区域之中,建立王号了。然此时的局势,却又演进到诸王之上,要有一个共主,而更高于王的称号,从来是没有的。乃借用天神之名,而称之为帝。齐湣王和秦昭王,曾一度并称东西帝;其后秦围邯郸,魏王又使辛垣衍劝赵尊秦为帝,即其事。此时研究历史的人,就把三代以前的酋长,拣了五个人,称之为五帝(所以太昊、炎帝、黄帝、少昊、颛顼之称,是人神相同的)。后来又再推上去,在五帝以前,拣了三个酋长,以说明社会开化的次序。更欲立一专名以名之,这却真穷于辞了。乃据“始王天下”之义,加“自”字于“王”字之上,造成一个“皇”字,而有所谓三皇(见《说文》。皇王二字,形异音同,可知其实为一语)。至秦王政并天下,遂合此二字,以为自己的称号,自汉以后,相沿不改。\n列国渐相吞并,在大国之中,就建立起郡县制度来。《王制》说:“天子之县内诸侯,禄也;外诸侯,嗣也。”又说:“诸侯之大夫,不世爵禄。”可见内诸侯和大夫,法律上本来不该世袭的。事实上虽不能尽然,而亦不必尽不然,尤其是在君主权力扩张的时候。倘使天子在其畿内,大国的诸侯在其国内,能切实将此制推行,而于其所吞灭之国,亦能推行此制,封建就渐变为郡县了。(一)春秋战国时,灭国而以为县的很多,如楚之于陈、蔡即是。有些灭亡不见记载,然秦汉时的县名,和古国名相同的甚多,亦可推见其本为一国,没入大国之中,而为其一县。(二)还有卿大夫之地,发达而成为县的。如《左传》昭公二年,晋分祁氏之田以为七县,羊舌氏之田以为三县是。(三)又有因便于战守起见,有意设立起来的,如商君治秦,并小都、乡、邑,聚以为县是(见《史记·商君列传》)。至于郡,则其区域本较县为小,且为县所统属(《周书·作雒篇》:“千里百县,县有四郡”)。其与县分立的,则较县为荒陋(《左传》哀公二年,赵简子誓师之辞,说“克敌者上大夫受县,下大夫受郡”)。然此等与县分立之郡,因其在边地之故,其兵力反较县为充足,所以后来在军事上须要控扼之地,转多设立(甘茂谓秦王曰:“宜阳大县也,上党、南阳,积之久矣,名曰县,其实郡也。”春申君言于楚王曰:“淮北地边齐,其事急,请以为郡便。”皆见《史记》本传)。事实上以郡统制县,保护县,亦觉便利,而县遂转属于郡。战国时,列国的设郡,还是在沿边新开辟之地的(如楚之巫、黔中,赵之云中、雁门、代郡,燕之上谷、渔阳、右北平、辽西、辽东郡等)。到秦始皇灭六国后,觉得到处都有驻兵镇压的必要,就要分天下为三十六郡了。\n封建政体,沿袭了几千年,断无没有反动之力之理。所以秦灭六国未几,而反动即起。秦汉之间以及汉初的封建,是和后世不同的。在后世,像晋朝、明朝的封建,不过出于帝王自私之心。天下的人,大都不以为然。即封建之人,对于此制,亦未必敢有何等奢望,不过舍此别无他法,还想借此牵制异姓,使其不敢轻于篡夺而已。受封者亦知其与时势不宜,惴惴然不敢自安。所以唐太宗要封功臣,功臣竟不敢受(见《唐书·长孙无忌传》)。至于秦汉间人,则其见解大异。当时的人,盖实以封建为当然,视统一转为变局。所以皆视秦之灭六国为无道之举,称之为暴秦,为强虎狼之秦。然则前此为六国所灭之国如何呢?秦灭六国,当恢复原状,为六国所灭之国,岂不当—一兴灭继绝吗?倘使以此为难,论者自将无辞可对。然大多数人的见解,是不能以逻辑论,而其欲望之所在,亦是不可以口舌争的。所以秦亡之后,在戏下的诸侯,立即决定分封的方法。当时所封建的:是(一)六国之后,(二)亡秦有功之人。此时的封建,因汉高祖藉口于项王背约,夺其关中之地而起兵,汉代史家所记述,遂像煞是由项王一个人作主,其实至少是以会议的形式决定的。所以在《太史公自序》里,还无意间透露出一句真消息来,谓之“诸侯之相王”。当时的封爵,分为二等:大者王,小者侯,这是沿袭战国时代的故事的(战国时,列国封其臣者,或称侯,或称君,如穰侯、文信侯、孟尝君、望诸君等是。侯之爵较君为高,其地当亦较君为大。此时所封的国,大小无和战国之君相当的,故亦无君之称)。诸侯之大者皆称王,项羽以霸王为之长,而义帝以空名加于其上,也是取法于东周以后,实权皆在霸主,而天王仅存虚名的。以大体言,实不可谓之不惬当。然人的见解,常较时势为落后。人心虽以为允洽,而事势已不容许,总是不能维持的。所以不过五年,而天下复归于统一了。然而当时的人心,仍未觉悟,韩信始终不肯背汉,至后来死于吕后之手,读史者多以为至愚。其实韩信再老实些,也不会以汉高祖为可信。韩信当时的见解,必以为举天下而统属于一人,乃事理所必无。韩信非自信功高,以为汉终不夺其王,乃汉夺其王之事,为信当时所不能想象。此恐非独韩信如此,汉初的功臣,莫不如此。若使当时,韩信等预料奉汉王以皇帝的空名,汉王即能利用之把自己诛灭,又岂肯如此做?确实,汉高祖翦灭所封的异姓,也是一半靠阴谋,一半靠实力的,并非靠皇帝的虚名。若就法理而论,就自古相传列国间的习惯,当时的人心认为正义者论,皇帝对于当时的王,可否如此任意诛灭呢?也还是一个疑问。所以汉高祖的尽灭异姓之国(楚王韩信,梁王彭越,韩王信,淮南王英布,燕王臧荼、卢绾。惟长沙王吴芮仅存),虽然不动干戈,实在和其尽灭戏下所封诸国,是同样的一个奇迹。不但如此,汉高祖所封同姓诸国,后来酝酿成吴、楚七国这样的一个大乱,竟会在短期间戡定;戡定之后,景帝摧抑诸侯,使不得自治民补吏;武帝又用主父偃之策,令诸侯各以国邑,分封子弟,而汉初的封建,居然就名存而实亡,怕也是汉初的人所不能预料的。\n封建的元素,本有两个:一为爵禄,受封者与凡官吏同。一为君国子民,子孙世袭,则其为部落酋长时固有的权利,为受封者所独。后者有害于统一,前者则不然。汉世关内侯,有虚名而无土地,后来列侯亦有如此的(《文献通考·封建考》云:“秦、汉以来,所谓列侯者,非但食其邑入而已,可以臣吏民,可以布政令,若关内侯,则惟以虚名受廪禄而已。西都景、武而后,始令诸侯王不得治民,汉置内史治之。自是以后,虽诸侯王,亦无君国子民之实,不过食其所封之邑入,况列侯乎?然所谓侯者,尚裂土以封之也。至东都,始有未与国邑,先赐美名之例,如灵寿王、征羌侯之类是也。至明帝时,有四姓小侯,乃樊氏、郭氏、明氏、马氏诸外戚子弟,以少年获封者。又肃宗赐东平王苍列侯印十九枚,令王子五岁以上能趋拜者,皆令带之。此二者,皆是未有土地,先佩印,受俸廪。盖至此,则列侯有同于关内侯者矣。”),然尚须给以廪禄。唐宋以后,必食实封的,才给以禄,则并物质之耗费而亦除去之,封建至此,遂全然无碍于政治了。\n后世在中国境内,仍有封建之实的,为西南的土官。土官有两种:一是文的,如土知府、土知州、土知县之类。一是武的,凡以司名的,如宣抚司、招讨司、长官司之类皆是。听其名目,全与流官相同。其实所用的都是部族酋长,依其固有之法承袭。外夷归化中国,中国给以名号(或官或爵),本是各方面之所同,不但西南如此。但其距中国远的,实力不及,一至政教衰微之世,即行离叛而去,这正和三代以前的远国一样。惟西南诸土司,本在封域之内,历代对此的权力,渐形充足,其管理之法,亦即随之而加严。在平时,也有出贡赋,听征调的。这亦和古代诸侯对王朝,小国对大国的朝贡及从征役一样。至其(一)对中国犯顺;(二)或其部族之中,自相争阋;(三)诸部族之间,互相攻击;(四)又或暴虐其民等,中国往往加以讨伐。有机会,即废其酋长,改由中国政府派官治理,是谓“改土归流”,亦即古代之变封建为郡县。自秦至今,近二千二百年,此等土官,仍未尽绝,可见封建政体的铲除,是要随着社会文化的进步,不是政治单方面的事情了。\n封建之世,所谓朝代的兴亡,都是以诸侯革天子之命。此即以一强国,夺一强国的地位,或竟灭之而已。至统一之世,则朝代的革易,其形式有四:(一)为旧政权的递嬗。又分为(甲)中央权臣的篡窃,(乙)地方政权的入据。前者如王莽之于汉,后者如朱温之于唐。(二)为新政权的崛起,如汉之于秦。(三)为异族的入据,如前赵之于晋,金之于北宋,元之于南宋,清之于明。(四)为本族的恢复,如明之于元。而从全局观之,则(一)有仍为统一的,(二)有暂行分裂的。后者如三国、南北朝、五代都是。然这只是政权的分裂,社会文化久经统一,所以政权的分立,总是不能持久的。从前读史的人,每分政情为(一)内重,(二)外重,(三)内外俱轻三种。内重之世,每有权臣篡窃之变。外重之世,易招强藩割据之忧。内外俱轻之世,则草泽英雄,乘机崛起;或外夷乘机入犯。惟秦以过刚而折,为一个例外。\n政权当归诸一人,而大多数人,可以不必过问;甚或以为不当过问,此乃事势积重所致,断非论理之当然。所以不论哪一国,其原始的政治,必为民主。后来虽因事势的变迁,专制政治逐渐兴起,然民主政治,仍必久之而后消灭。观前文所述,可以见之。大抵民主政治的废坠:(一)由于地大人众,并代表会议而不能召集。(二)大众所议,总限于特殊的事务,其通常的事务,总是由少数主持常务的人执行的。久之,此少数人日形专擅,对于该问大众的特殊事务,亦复独断独行。(三)而大众因情势涣散,无从起而加以纠正。专制政治就渐渐形成了。这是形式上的变迁。若探求其所以然,则国家大了,政情随之复杂,大的、复杂的事情,普通人对之不感兴趣,亦不能措置。此实为制度转变的原因。\n然民主的制度,可以废坠,民主的原理,则终无灭绝之理。所以先秦诸子,持此议论的即很多。因后世儒术专行,儒家之书,传者独多,故其说见于儒家书中的亦独多,尤以《孟子》一书,为深入人心。其实孟子所诵述的,乃系孔门的书说,观其论尧、舜禅让之语,与伏生之《尚书大传》,互相出入可知(司马迁《五帝本纪》亦采儒家书说)。两汉之世,此义仍极昌明。汉文帝元年,有司请立太子。文帝诏云:“朕既不德,上帝神明未歆享;天下人民,未有慊志;今纵不能博求天下贤圣有德之人而禅天下焉,而曰豫建太子,是重吾不德也,谓天下何?”此虽系空言,然天下非一人一家所私有之义,则诏旨中也明白承认了。后来眭孟上书,请汉帝谁差天下(谁差,访求、简择之义),求索贤人,禅以帝位,而退自封百里,尤为历代所无。效忠一姓,汉代的儒家,实不视为天经地义。刘歆系极博通的人,且系汉朝的宗室,而反助王莽以篡汉;扬雄亦不反对王莽,即由于此。但此等高义,懂得的只有少数人,所以不久即湮晦,而君臣之义,反日益昌盛了。\n王与君,在古代是有分别的,说已见前。臣与民亦然。臣乃受君豢养的人,效忠于其一身,及其子嗣,尽力保卫其家族、财产,以及荣誉、地位的。盖起于(一)好战的酋长所豢养的武士,(二)及其特加宠任的仆役。其初,专以效忠于一人一家为主。后来(一)人道主义渐形发达。(二)又从利害经验上,知道要保一人一家的安全,或求其昌盛,亦非不顾万民所能。于是其所行者,渐须顾及一国的公益。有时虽违反君主一人一家的利益,而亦有所不能顾。是即大臣与小臣,社稷之臣与私暱嬖倖的区别。然其道,毕竟是从效忠于一人一家,进化而来的,终不能全免此项色彩。至民则绝无效忠于君的义务。两者区别,在古代本极明白,然至后世,却渐渐湮晦了。无官职的平民,亦竟有效忠一姓的,如不仕新朝之类。这在古人看起来,真要莫名其妙了(异民族当别论。民族兴亡之际,是全民族都有效忠的义务的。顾炎武《日知录·正始》条,分别亡国亡天下,所谓亡天下,即指民族兴亡言,古人早见及此了)。至于国君失政,应该诛杀改立之义,自更无人提及。\n剥极则复,到晚明之世,湮晦的古义,才再露一线的曙光。君主之制,其弊全在于世袭。以遗传论,一姓合法继承的人,本无代代皆贤之理。以教育论,继嗣之君,生来就居于优越的地位,志得意满;又和外间隔绝了,尤其易于不贤。此本显明之理,昔人断非不知,然既无可如何,则亦只好置诸不论不议之列了。君主的昏愚、淫乱、暴虐,无过于明朝之多。而时势危急,内之则流寇纵横,民生憔悴:外之则眼看异族侵入,好容易从胡元手里恢复过来的江山,又要沦于建夷之手。仁人君子,蒿目时艰,深求致祸之原,图穷而匕首见,自然要归结到政体上了。于是有黄宗羲的《明夷待访录》出现,其《原君》、《原臣》两篇,于“天下者天下之天下”之义,发挥得极为深切,正是晴空一个霹雳。但亦只是晴空一个霹雳而已。别种条件,未曾完具,当然不会见之于行动的。于是旁薄郁积的民主思想,遂仍潜伏着,以待时势的变化。\n近百年来的时势,四夷交侵,国家民族,都有绝续存亡的关系,可谓危急极了。这当然不是一个单纯的政治问题。但社会文化和政治的分野,政治力量的界限,昔人是不甚明白的。眼看着时势的危急,国事的败坏,当然要把其大部分的原因,都归到政治上去,当然要发动了政治上的力量来救济它,当然要拟议及于政体。于是从戊戌变法急转直下,而成为辛亥革命。中国的民主政治,虽然自己久有根基,而亲切的观感,则得之于现代的东西列强。代议政体,自然要继君主专制而起。但代议政体,在西洋自有其历史的条件,中国却无有。于是再急转直下,而成为现在的党治。\n中国古代,还有一个极高的理想,那便是孔子所谓大同,老子所谓郅治,许行所谓贤者与民并耕而食,饔飧而治。这是超出于政治范围之外的,因为国家总必有阶级,然后能成立,而孔、老、许行所想望的境界,则是没有阶级的。参看下两篇自明。\n第四十章 阶级 # 古代部族之间,互相争斗,胜者把败者作为俘虏,使之从事于劳役,是为奴隶;其但收取其赋税的,则为农奴。已见上章。古代奴婢之数,似乎并不甚多(见下)。最严重的问题,倒在征服者和农奴之间。国人和野人,这两个名词,我们在古书上遇见时,似不觉其间有何严重的区别。其实两者之间,是有征服和被征服的关系的。不过其时代较早,古书上的遗迹,不甚显著,所以我们看起来,不觉得其严重罢了。所谓国人,其初当系征服之族,择中央山险之地,筑城而居。野人则系被征服之族,在四面平夷之地,从事于耕耘。所以(一)古代的都城,都在山险之处。国内行畦田,国外行井田。(二)国人充任正式军队,野人则否。参看第四十四、第四十五、第五十三章自明。上章所讲大询于众庶之法,限于乡大夫之属。乡是王城以外之地,乡人即所谓国人。厉王的被逐,《国语》说:“国人莫敢言,道路以目。”然则参与国政,和起而为反抗举动的,都是国人。若野人,则有行仁政之君,即歌功颂德,襁负而归之;有行暴政之君,则“逝将去汝,适彼乐土”,在可能范围之内逃亡而已。所以一个国家,其初立国的基本,实在是靠国人的,即征服部族的本族。国人和野人之间,其初当有一个很严的界限;彼此之间,还当有很深的仇恨。后来此等界限,如何消灭?此等仇恨,如何淡忘呢?依我推想,大约因:(一)距离战争的年代远了,旧事渐被遗忘。(二)国人移居于野,野人亦有移居于国的,居地既近,婚姻互通。(三)征服部族,是要朘削被征服的部族以自肥的,在经济上,国人富裕而野人贫穷;又都邑多为工商及往来之人所聚会,在交通上,国人频繁而野人闭塞;所以国人的性质较文,野人的性质较质。然到后来,各地方逐渐发达,其性质,亦变而相近了。再到后来,(四)选举的权利,(五)兵役的义务,亦渐扩充推广,而及于野人,则国人和野人,在法律上亦无甚区别,其畛域就全化除了。参看第四十三、第四十五两章自明。\n征服之族和被征服之族的区别,可说全是政治上的原因。至于职业上的区别,则已带着经济上的原因了。古代职业的区别,是为士、农、工、商。士是战士的意思,又是政治上任事而未有爵者之称,可见古代的用人,专在战士中拔擢。至于工商,则专从事于生业。充当战士的人,虽不能全不务农,但有种专务耕种的农民,却是不服兵役的。所以《管子》上有士之乡和工商之乡(见《小匡篇》)。《左传》宣公十二年说,楚国之法,“荆尸而举(荆尸,该是一种组织军队的法令),商、农、工、贾,不败其业。”有些人误以为古代是全国皆兵,实在是错误的,参看第四十五章自明。士和卿大夫,本来该没有多大的区别,因为同是征服之族,服兵役,古代政权和军权,本是混合不分的。但在古代,不论什么职业,多是守之以世。所以《管子》又说:“士之子恒为士,农之子恒为农,工之子恒为工,商之子恒为商。”(《小匡》)政治上的地位,当然不是例外,世官之制既行,士和大夫之间,自然生出严重的区别来,农、工、商更不必说了。此等阶级,如何破坏呢?其在经济上,要维持此等阶级,必须能维持严密的职业组织。如欲使农之子恒为农,则井田制度,必须维持。欲使工之子恒为工,商之子恒为商,则工官和公家对于商业的管理规则,亦必须维持。然到后来,这种制度,都破坏了。农人要种田,你没有田给他种,岂能不许他从事别种职业?工官制度破坏了,所造之器,不足以给民用,民间有从事制造的人,你岂能禁止他?尤其是经济进步,交换之事日多,因而有居间卖买的人,又岂能加以禁止?私产制度既兴,获利的机会无限,人之趋利,如水就下,旧制度都成为新发展的障碍了,古代由社会制定的职业组织,如何能不破坏呢?在政治上:则因(一)贵族的骄淫矜夸,自趋灭亡,而不得不任用游士(参看第四十三章)。(二)又因有土者之间,互相争夺,败国亡家之事,史不绝书。一国败,则与此诸侯有关之人,都夷为平民。一家亡,则与此大夫有关的人,都失其地位。(三)又古代阶级,并未像喀斯德(caste)这样的严峻,彼此不许通婚。譬如《左传》定公九年,载齐侯攻晋夷仪,有一个战士,唤作敝无存,他的父亲,要替他娶亲,他就辞谢,说:“此役也,不死,反必娶于高、国。”(齐国的两个世卿之家)可见贵族与平民通婚,是容易的。婚姻互通,社会地位的变动,自然也容易了。这都是古代阶级所以渐次破坏的原因。\n奴隶的起源,由于以异族为俘虏。《周官》五隶:曰罪隶,曰蛮隶,曰闽隶,曰夷隶,曰貉隶。似乎后四者为异族,前一者为罪人。然罪人是后起的。当初本只以异族为奴隶,后来本族有罪的人,亦将他贬入异族群内,当他异族看待,才有以罪人为奴隶的事。参看第四十六章自明。经学中,今文家言,是“公家不畜刑人,大夫弗养;屏诸四夷,不及以政”(谓不使之当徭役。见《礼记·王制》);古文家言,则“墨者使守门,劓者使守关,宫者使守内,刖者使守囿”(《周官》秋官掌戮)。固然,因刑人多了,不能尽弃而不用,亦因今文所说的制度较早,初期的奴隶,多数是异族,仇恨未忘,所以不敢使用他了(《梁》襄公二十九年:礼,君不使无耻,不近刑人,不押敌,不迩怨)。不但如此,社会学家言:氏族时代的人,不惯和同族争斗,镇压本部族之职,有时不肯做,宁愿让异族人做的。《周官》蛮、闽、夷、貉四隶,各服其邦之服,执其邦之兵,以守王宫及野之厉禁,正是这个道理。这亦足以证明奴隶的源出于异族。女子为奴隶的谓之婢。《文选·司马子长报任安书》李《注》引韦昭云:“善人以婢为妻生子曰获,奴以善人为妻生子曰臧。齐之北鄙,燕之北郊,凡人男而归婢谓之臧,女而归奴谓之获。”可见奴婢有自相嫁娶,亦有和平民婚配的。所以良贱的界限,实亦不甚严峻。但一方面有脱离奴籍的奴隶,一方面又有沦为奴隶的平民,所以奴婢终不能尽绝。这是关系整个社会制度的了。奴隶的免除,有两种方法:一种是用法令。《左传》襄公三十二年,晋国的大夫栾盈造反。栾氏有力臣曰督戎,国人惧之。有一个奴隶,唤作斐豹的,和执政范宣子说道:“苟焚丹书,我杀督戎。”宣子喜欢道:你杀掉他,“所不请于君焚丹书者,有如日”。斐豹大约是因犯罪而为奴隶,丹书就是写他的罪状的。一种是以财赎。《吕氏春秋·察微篇》说:鲁国之法,“鲁人有为臣妾于诸侯者,赎之者取金于府。”这大约是俘虏一类。后世奴隶的免除,也不外乎这两种方法。\n以上是封建时代的事。封建社会的根柢,是“以力相君”。所以在政治上占优势的人,在社会上的地位,亦占优胜。到资本主义时代,就大不然了。《汉书·货殖列传》说:“昔先王之制:自天子、公、侯、卿、大夫、士,至于皂隶,抱关击柝者,其爵禄、奉养、宫室、车服、棺槨、祭祀、死生之制,各有差品,小不得僭大,贱不得逾贵。”又说:后来自诸侯大夫至于士庶人,“莫不离制而弃本。稼穑之民少,商旅之民多;谷不足而货有余。”(谷货,犹言食货。谷、食,本意指食物,引申起来,则包括一切直接供给消费之物。货和化是一语。把这样东西,变成那样,就是交换的行为。所以货是指一切商品)于是“富者木土被文锦,犬马余肉粟,而贫者短褐不完,唅粟饮水。其为编户齐民同列,而以财力相君,虽为仆隶,犹无愠色”。这几句话,最可代表从封建时代到资本主义时代的变迁。封建社会的根源,是以武力互相掠夺。人人都靠武力互相掠夺,则人人的生命财产,俱不可保。这未免太危险。所以社会逐渐进步,武力掠夺之事,总不能不悬为厉禁。到这时代,有钱的人,拿出钱来,就要看他愿否。于是有钱就是有权力。豪爽的武士,不能不俯首于狡猾悭吝的守财奴之前了。这是封建社会和资本主义社会转变的根源。平心而论:资本主义的惨酷,乃是积重以后的事。当其初兴之时,较之武力主义,公平多了,温和多了,自然是人所欢迎的。资本主义所以能取武力主义而代之,其根源即在于此。然前此社会的规则,都是根据武力优胜主义制定的,不是根据富力优胜主义制定的。武力优胜主义,固然也是阶级的偏私,且较富力优胜主义为更恶。然而人们,(一)谁肯放弃其阶级的偏私?(二)即有少数大公无我的人,亦不免为偏见所蔽,视其阶级之利益,即为社会全体的利益;以其阶级的主张,即为社会全体的公道,这是无可如何的事。所以资本主义的新秩序,用封建社会的旧眼光看起来,是很不入眼的;总想尽力打倒他,把旧秩序回复。商鞅相秦,“明尊卑爵秩等级。各以差次名田宅臣妾。衣服以家次。有功者显荣,无功者虽富无所纷华。”(《史记》本传)就是代表这种见解,想把富与贵不一致的情形,逆挽之,使其回复到富与贵相一致的时代的。然而这如何办得到呢?封建时代,统治者阶级的精神,最紧要的有两种:一是武勇,一是不好利。惟不好利,故富贵不能淫,贫贱不能移。惟能武勇,故威武不能屈。这是其所以能高居民上,维持其治者阶级的地位的原因。在当时原非幸致。然而这种精神,也不是从天降,从地出;或者如观念论者所说,在上者教化好,就可以致之的。人总是随着环境变迁的。假使人而不能随着环境变迁,则亦不能制驭环境,而为万物之灵了。在封建主义全盛时,治者阶级因其靠武力得来的地位的优胜,不但衣食无忧,且其生活,总较被治的人为优裕,自然可以不言利。讲到武勇,则因前此及其当时,他们的生命,是靠腕力维持的(取之于自然界者如田猎。取之于人者,则为战争和掠夺),自能养成其不怕死不怕苦痛的精神。到武力掠夺,悬为厉禁,被治者的生活,反较治者为优裕;人类维持生活最好的方法,不是靠腕力限之于自然界,或夺之于团体之外,而反是靠智力以剥削团体以内的人;则环境大变了。治者阶级的精神,如何能不随之转变呢?于是滔滔不可挽了。在当时,中坚阶级的人,因其性之所近,分为两派:近乎文者则为儒,近乎武者则为侠。古书多以儒侠并称,亦以儒墨并称,可见墨即是侠。儒和侠,不是孔、墨所创造的两种团体,倒是孔、墨就社会上固有的两种阶级,加以教化,加以改良的。在孔、墨当日,何尝不想把这两个阶级振兴起来,使之成为国家社会的中坚?然而滔滔者终于不可挽了。儒者只成为“贪饮食,惰作务”之徒(见《墨子·非儒篇》),侠者则成为“盗跖之居民间者”(《史记·游侠列传》)。质而言之,儒者都是现在志在衣食,大些则志在富贵的读书人。侠者则成为现在上海所谓白相人了。我们不否认,有少数不是这样的人,然而少数总只是少数。这其原理,因为在生物学上,人,大多数总是中庸的,而特别的好,和特别的坏,同为反常的现象。所以我们赞成改良制度,使大多数的中人,都可以做好人;不赞成认现社会的制度为天经地义,责成人在现制度之下做好人,陈义虽高,终成梦想。直到汉代,想维持此等阶级精神,以为国家社会的中坚的,还不乏其人。试看贾谊《陈政事疏》所说圣人有金城之义,董仲舒对策说食禄之家不该与民争利一段(均见《汉书》本传),便可见其大概。确实,汉朝亦还有此种人。如盖宽饶,“刚直高节,志在奉公。”儿子步行戍边,专务举发在位者的弊窦,又好犯颜直谏,这确是文臣的好模范。又如李广,终身除射箭外无他嗜好,绝不言利,而于封侯之赏,却看得很重。广为卫青所陷害而死,他的儿子敢,因此射伤卫青,又给霍去病杀掉,汉武帝都因其为外戚之故而为之讳,然李广的孙儿子陵,仍愿为武帝效忠。他敢以步卒五千,深入匈奴。而且“事亲孝,与士信,临财廉,取与义,分别有让,恭俭下人”(见《汉书·司马迁传》迁报任安书),这真是一个武士的好模范。还有那奋不顾身,立功绝域的傅介子、常惠、陈汤、班超等,亦都是这一种人。然而滔滔者终于不可挽了。在汉代,此等人已如凤毛麟角,魏晋以后,遂绝迹不可复见。岂无好人?然更不以封建时代忠臣和武士的性质出现了。过去者已去,如死灰之不可复燃。后人谈起这种封建时代的精神来,总觉得不胜惋惜。然而无足惜也。这实在不是什么好东西。当时文臣的见解,已不免于褊狭。武人则更其要不得。譬如李广,因闲居之时,灞陵尉得罪了他(如灞陵尉之意,真在于奉公守法,而不是有意与他为难,还不能算得罪他,而且是个好尉),到再起时,就请尉与俱,至军而斩之,这算什么行为?他做陇西太守时,诈杀降羌八百余人,岂非武士的耻辱?至于一班出使外国之徒,利于所带的物品,可以干没;还好带私货推销,因此争求奉使。到出使之后,又有许多粗鲁的行为,讹诈的举动,以致为国生事,引起兵端(见《史记·大宛列传》),这真是所谓浪人,真是要不得的东西。中国幸而这种人少,要是多,所引起的外患,怕还不止五胡之乱。\n封建时代的精神过去了。社会阶级,遂全依贫富而分。当时所谓富者,是(一)大地主,(二)大工商家,详见下章。晁错《贵粟疏》说:“今法律贱商人,商人已富贵矣;尊农夫,农夫已贫贱矣。俗之所贵,主之所贱;吏之所卑,法之所尊。上下相反,好恶乖迕,而欲国富法立,不可得也。”可见法律全然退处于无权了。\n因资本的跋扈,奴婢之数,遂大为增加。中国古代,虽有奴婢,似乎并不靠他做生产的主力。因为这时候,土地尚未私有,旧有的土地,都属于农民。君大夫有封地的,至多只能苛取其租税,强征其劳力(即役),至于夺农民的土地为己有,而使奴隶从事于耕种,那是不会有这件事的(因为如此,于经济只有不利。所以虽有淫暴之君,亦只会弃田以为苑囿。到暴力一过去,苑囿就又变做田了)。大规模的垦荒,或使奴隶从事于别种生产事业,那时候也不会有。其时的奴隶,只是在家庭中,以给使令,或从事于消费品的制造(如使女奴舂米、酿酒等),为经济的力量所限,其势自不能甚多。到资本主义兴起后,就不然了。(一)土地既已私有,原来的农奴,都随着土地,变成地主的奴隶。王莽行王田之制,称奴隶为“私属”,和田地都不得卖买。若非向来可以卖买,何必有此法令呢?这该是秦汉之世,奴婢增多的一大原因(所以奴婢是由俘虏、罪人两政治上的原因造成的少,由经济上的原因造成的多)。(二)农奴既变为奴隶,从事于大规模的垦荒的,自然可以购买奴隶,使其从事耕作。(三)还可以使之从事于别种事业。如《史记·货殖列传》说:刁閒收取桀黠奴,使之逐渔盐商贾之利。所以又说童手指千,比千乘之家。如此,奴婢越多越富,其数就无制限了。此时的奴婢,大抵是因贫穷而鬻卖的。因贫穷而卖身,自古久有其事。所以《孟子·万章上篇》,就有人说:百里奚自鬻于秦养牲者之家。然在古代,此等要不能甚多。至汉代,则贾谊说当时之民,岁恶不入,就要“请爵卖子”,成为经常的现象了。此等奴婢,徒以贫穷之故而卖身,和古代出于俘虏或犯罪的,大不相同,国家理应制止及救济。然当时的国家,非但不能如此,反亦因之以为利。如汉武帝,令民入奴婢,得以终身复;为郎的增秩。其时行算缗之法,遣使就郡国治隐匿不报的人的罪,没收其奴婢甚多,都把他们分配到各苑和各机关,使之从事于生产事业(见《史记·平准书》)。像汉武帝这种举动,固然是少有的,然使奴婢从事于生产事业者,必不限于汉武帝之世,则可推想而知,奴隶遂成为此时官私生产的要角了。汉末大乱,奴婢之数,更行增多。后汉光武一朝,用法令强迫释放奴婢很多(均见《后汉书》本纪)。然亦不过救一时之弊,终不能绝其根株。历代救济奴隶之法:(一)对于官奴婢,大抵以法令赦免。(二)对于私奴婢:则(甲)以法令强迫释放;(乙)官出资财,替他赎身;(丙)勒令以买直为佣资,计算做工的时期,足满工资之数,便把他放免。虽有此法,亦不过去其太甚而已。用外国人作奴婢,后世还是有的。但非如古代的出于俘虏,而亦出于鬻卖。《汉书·西南夷列传》和《货殖列传》,都有所谓“僰僮”,就是当时的商人,把他当作商品贩卖的。《北史·四裔传》亦说:当时的人,多买獠人作奴仆。因此,又引起政治上的侵略。梁武帝时,梁、益二州,岁岁伐獠以自利。周武帝平梁、益,亦命随近州镇,年年出兵伐獠,取其生口,以充贱隶。这在后世,却是少有的事,只有南北分立之世,财力困窘,政治又毫无规模,才会有之。至于贩卖,却是通常现象。如唐武后大足元年,敕北方缘边诸郡,不得畜突厥奴婢;穆宗长庆元年,诏禁登、莱州及缘海诸道,纵容海贼,掠卖新罗人为奴婢,就可见海陆两道,都有贩卖外国人口的了。南方的黑色人种,中国谓之昆仑。唐代小说中,多有昆仑奴的记载,更和欧洲人的贩卖黑奴相像。然中国人亦有自卖或被卖做外国人的奴隶的。宋太宗淳化二年,诏陕西缘边诸郡:先因岁饥,贫民以男女卖与戎人,官遣使者,与本道转运使,分以官财物赎,还其父母;真宗天禧三年,诏自今掠卖人口入契丹界者,首领并处死,诱至者同罪,未过界者,决杖黥配(均见《文献通考》),就是其事。\n后汉末年,天下大乱,又发生所谓部曲的一个阶级。部曲二字,本是军队中一个组织的名称(《续汉书·百官志》大将军营五部,部下有曲,曲下有屯)。丧乱之际,人民无家可归,属于将帅的兵士,没有战事的时候,还是跟着他生活。或者受他豢养或者替他工作。事实上遂发生隶属的状态。用其力以生产,在经济上是有利的,所以在不招兵的时候,将帅也要招人以为部曲了(《三国志·李典传》说:典有宗族部曲三千余家,就是战时的部曲,平时仍属于将帅之证。《卫觊传》说:觊镇关中时,四方流移之民,多有回关中的,诸将多引为部曲,就是虽不招兵之时,将帅亦招人为部曲之证)。平民因没有资本,或者需要保护,一时应他的招。久之,此等依赖关系,已成过去,而其身份,被人歧视,一时不能回复,遂成为另一阶级。部曲的女子,谓之客女。历代法律上,奴婢伤害良人,罪较平民互相伤害为重。良人伤害奴婢,则罪较平民互相伤害为轻。其部曲、客女,伤害平民的罪,较平民加重,较奴婢减轻;平民伤害部曲、客女的,亦较伤害奴婢加重,较其互相伤害减轻。所以部曲的地位,是介于良贱之间的。历魏、晋、南北朝至唐、宋,都有这一阶级。\n使平民在某种程度以内,隶属于他人,亦由来甚久。《商君书·竟内篇》说:“有爵者乞无爵者以为庶子。级乞一人。其无役事也(有爵者不当差徭,在自己家里的时候),庶子役其大夫,月六日。其役事也,随而养之。”(有爵者替公家当差徭时,庶子亦跟着他出去)这即是《荀子·议兵篇》所说秦人五甲首而隶五家之制。秦爵二十级(见《汉书·百官公卿表》)。级级都可乞人为役,则人民之互相隶属者甚多,所以鲁仲连要说秦人“虏使其民”了。晋武帝平吴以后,王公以下,都得荫人为衣食客及佃客。其租调及力役等,均入私家。此即汉世封君食邑户的遗法,其身份仍为良民。辽时有所谓二税户,把良民赐给僧寺,其税一半输官,一半输寺(金世宗时免之),亦是为此。此等使人对人直接征收,法律上虽限于某程度以下的物质或劳力,然久之,总易发生广泛的隶属关系,不如由国家征收,再行给与之为得。\n封建时代的阶级,亦是相沿很久的,岂有一废除即铲灭净尽之理?所以魏晋以后,又有所谓门阀的阶级。魏晋以后的门阀,旧时的议论,都把九品中正制度(见第四十三章),看作它很重要的原因,这是错误的。世界上哪有这种短时间的政治制度,能造成如此深根固柢的社会风尚之理?又有说:这是由于五胡乱华,衣冠之族,以血统与异族混淆为耻,所以有这风尚的。这也不对。当时的区别,明明注重于本族士庶之间。况且五胡乱华,至少在西晋的末年,声势才浩大的,而刘毅在晋初,已经说当时中正的品评,上品无寒门,下品无世族了。可见门阀之制,并非起源于魏晋之世。然则其缘起安在呢?论门阀制度的话,要算唐朝的柳芳,说得最为明白(见《唐书·柳冲传》)。据他的说法:则七国以前,封建时代的贵族,在秦汉之世,仍为强家。因为汉高祖起于徒步,用人不论家世,所以终两汉之世,他们在政治上,不占特别的势力。然其在社会上,势力仍在。到魏晋以后,政治上的势力,和社会上的势力合流,门阀制度,就渐渐固定了。这话是对的。当时政治上扶植门阀制度的,就是所谓九品中正(见第四十三章)。至于在社会上,则因汉末大乱,中原衣冠之族,开始播迁。一个世家大族,在本地方,是人人知其为世家大族的,用不着自行表暴。迁徙到别的地方,就不然了。琅邪王氏是世族,别地方的王氏则不然。博陵崔氏是世族,别地方的崔氏则不然。一处地方,就迁来一家姓王的,姓崔的,谁知道他是哪里的王?哪里的崔呢?如此,就不得不郑重声明,我是琅邪王而非别的王氏;是博陵崔而非别的崔氏了。这是讲门阀的所以要重视郡望的原因。到现在,我们旧式婚姻的简帖上,还残留着这个老废物。这时候,所谓门第的高下,大概是根据于:(一)本来门第的高下。这是相沿的事实,为本地方人所共认,未必有谱牒等物为据。因为古代谱牒,都是史官所记。随着封建的崩坏,久已散佚无存了。(二)秦汉以来,世家大族,似乎渐渐的都有谱牒(《隋书》著录,有家谱、家传两门。《世说新语》《注》,亦多引人家的家谱)。而其事较近,各家族中,有何等人物、事迹,亦多为众人所能知、所能记,在这时期以内,一个家族中,要多有名位显著的人,而切忌有叛逆等大恶的事。如此,历时稍久,即能受人承认,为其地之世家(历时不久的,虽有名位显著的人,人家还只认为暴发户,不大看得起他。至于历时究要多久,那自然没有明确的界限)。(三)谱牒切忌佚亡,事迹切忌湮没。傥使谱牒已亡;可以做世家的条件的事迹,又无人能记忆;或虽能记忆,而不能证明其出于我之家族中;换言之,即不能证明我为某世家大族或有名位之人之后;我的世族的资格,就要发生动摇了。要之,不要证据的事,要没人怀疑;要有证据的事,则人证物证,至少要有一件存在,这是当时判定世族资格的条件。谱牒等物,全由私家掌管,自然不免有散佚、伪造等事。政治总是跟着社会走的。为要维持此等门阀制度,官家就亦设立谱局,与私家的谱牒互相钩考,“有司选举,必稽谱籍而考其真伪”了(亦柳芳语)。\n当这时代,寒门世族,在仕途上优劣悬殊;甚至婚姻不通,在社交上的礼节,亦不容相并(可参考《陔余丛考·六朝重氏族》条)。此等界限,直至唐代犹存。《唐书·高士廉传》及《李义府传》说:太宗命士廉等修《氏族志》,分为九等,崔氏犹为第一,太宗列居第三。又说:魏大和中,定望族七姓,子孙迭为婚姻。唐初作《氏族志》,一切降之。后房玄龄、魏徵、李勣等,仍与为婚,故其望不减。义府为子求婚不得,乃奏禁焉。其后转益自贵,称禁婚家,男女潜相聘娶,天子不能禁。《杜羔传》说:文宗欲以公主降士族,曰:“民间婚姻,不计官品,而尚阀阅。我家二百年天子,反不若崔、卢邪?”可见唐朝中叶以后,此风尚未铲除。然此时的门阀,已只剩得一个空壳,经不起雨打风吹,所以一到五代时,就成“取士不问家世,婚姻不问阀阅”之局了(《通志·氏族略》)。这时候的门阀,为什么只剩一个空壳呢?(一)因自六朝以来,所谓世族,做事太无实力。这只要看《廿二史札记·江左诸帝皆出庶族》、《江左世族无功臣》、《南朝多以寒人掌机要》各条可见。(二)则世族多贪庶族之富,与之通婚;又有和他通谱,及把自己的家谱出卖的。看《廿二史札记·财昏》、《日知录·通谱》两条可见。(三)加以隋废九品中正,唐以后科举制度盛行,世族在选举上,亦复不占便宜。此时的门阀,就只靠相沿已久,有一种惰力性维持,一受到(四)唐末大乱、谱牒沦亡的打击,自然无以自存了。门阀制度,虽盛于魏晋以后,然其根源,实尚远在周秦以前,到门阀制度废除,自古相传的阶级,就荡然以尽了(指由封建势力所造成的阶级)。\n然本族的阶级虽平,而本族和异族之间,阶级复起。这就不能不叹息于我族自晋以后武力的衰微了。中国自汉武帝以后,民兵渐废。此时的兵役,多以罪人和奴隶充之,亦颇用异族人为兵。东汉以后,杂用异族之风更盛。至五胡乱华之世,遂习为故常(别见第四十五章)。此时的汉人和异族之间,自然不能不发生阶级。史称北齐神武帝,善于调和汉人和鲜卑。他对汉人则说:“鲜卑人是汝作客(犹今言雇工),得汝一斛粟,一匹绢,为汝击贼,令汝安宁,汝何为凌之?”对鲜卑人则说:“汉人是汝奴。夫为汝耕,妇为汝织,输汝粟帛,令汝温饱,汝何为疾之?”就俨然一为农奴,一为战士了。但此时期的异族,和自女真以后的异族,有一个大异点。自辽以前(契丹为鲜卑宇文氏别部,实仍系五胡的分支),外夷率以汉族为高贵而攀援之,并极仰慕其文化,不恤牺牲其民族性,而自愿同化于汉族。至金以后则不然。这只要看五胡除羯以外,无不冒托神明之胄(如拓跋氏自称黄帝之后,宇文氏自称炎帝之后是),金以后则无此事;北魏孝文帝,自愿消灭鲜卑语,奖励鲜卑人与汉人通婚,自然是一个极端的例子,然除此以外,亦未有拒绝汉族文化的。金世宗却极力保存女真旧风及其语言文字。这大约由于自辽以前的异族,附塞较久,濡染汉人文化较深,金、元、清则正相反之故。渤海与金、清同族,而极仰慕汉人的文化,似由其先本与契丹杂居营州,有以致之,即其一证。对于汉族的压制剥削,亦是从金朝以后,才深刻起来的。五胡虽占据中原,只是一部分政权,入于其手。其人民久与汉族杂居,并未闻至此时,在社会上,享有何等特别的权利(至少在法律上大致如此)。契丹是和汉人不杂居的。其国家的组织,分为部族和州县两部分,彼此各不相干(设官分南北面,北面以治部族,南面以治州县)。财赋之官,虽然多在南面,这是因汉族的经济,较其部族为发达之故,还不能算有意剥削汉人。到金朝,则把猛安谋克户迁入中原。用集团之制,与汉族杂居,以便镇压。因此故,其所耕之地,不得不连成片段。于是或藉口官地,强夺汉人的土地(如据梁王庄、太子务等名目,硬说其地是官地之类),或口称与汉人互换,而实系强夺。使多数人民流离失所。初迁入时,业已如此。元兵占据河北后,尽将军户(即猛安谋克户)迁于河南,又是这么一次。遂至和汉人结成骨仇血怨,酿成灭亡以后大屠戮的惨祸了(见《廿二史札记·金末种人被害之惨》条)。元朝则更为野蛮。太宗时,其将别迭,要把汉人杀尽,空其地为牧场,赖耶律楚材力争始止(见《元史·耶律楚材传》)。元朝分人为蒙古、色目(犹言诸色人等,包括蒙古及汉族以外的人。其种姓详见《辍耕录》)、汉人(灭金所得的中国人)、南人(灭宋所得的中国人)四种,一切权利,都不平等(如各官署的长官,必用蒙古人。又如学校及科举,汉人、南人的考试较难,而出身反劣)。汉人入奴籍的甚多(见《廿二史札记·元初诸将多掠人为私户》条)。明代奴仆之数骤增(见《日知录·奴仆》条),怕和此很有关系。清朝初入关时,亦圈地以给旗民。其官缺,则满、汉平分。又有蒙古、汉军、包衣(满洲人的奴仆)的专缺。刑法,则宗室、觉罗(显祖之后称宗室,自此以外称觉罗。宗室俗称黄带子,觉罗俗称红带子,因其常系红黄色的带子为饰。凡汉人杀伤红黄带子者,罪加一等。惟在茶坊酒肆中则否,以其自亵身份也)及旗人,审讯的机关都不同(宗室、觉罗,由宗人府审讯。与人民讼者,会同户、刑部。包衣由内务府慎刑司审讯。与人民讼者,会同地方官。旗人由将军、都统、副都统审讯),且都有换刑(宗室以罚养赡银代笞、杖,以板责、圈禁代徒、流、充军。雍正十二年,并推及觉罗。其死罪则多赐自尽。旗人以鞭责代笞、杖,枷号代徒、流、充军。死刑以斩立决为斩监候,斩监候为绞),都是显然的阶级制度。民族愈开化,则其自觉心愈显著,其斗争即愈尖锐。处于现在生存竞争的世界,一失足成千古恨,再回头是百年身,诚不可以不凛然了(近来有一派议论,以为满、蒙等族,现在既已与汉族合为一个国族了,从前互相争斗的事,就不该再提及,怕的是挑起恶感。甚至有人以为用汉族二字,是不甚妥当的。说这是外国人分化我们的手段,我们不该盲从。殊不知历史是历史,现局是现局。不论何国、何族,在以往,谁没有经过斗争来?现在谁还在这里算陈账?若虑挑起恶感,而于以往之事,多所顾忌而不敢谈,则全部历史,都只好拉杂摧烧之了。汉族二字不宜用,试问在清朝时代的满汉二字,民国初年的汉、满、蒙、回、藏五族共和等语,当改作何字?历史是一种学术,凡学术都贵真实。只要忠实从事,他自然会告诉你所以然的道理,指示你当遵循的途径。现在当和亲的道理,正可从从前的曾经斗争里看出来,正不必私智穿凿,多所顾虑)。总而言之:凡阶级的所以形成,其根源只有两种:一种是武力的,一种是经济的。至于种族之间,则其矛盾,倒是较浅的。近代的人,还有一种缪见,以为种族是一个很大的界限,同种间的斗争,只是一时的现象,事过之后,关系总要比较亲切些。殊不知为人类和亲的障碍的,乃是民族而非种族。种族的同异在体质上,民族的同异在文化上。体质上的同异,有形状可见,文化上的同异,无迹象可求。在寻常人想起来,总以为种族的同异,更难泯灭,这就是流俗之见,需要学术矫正之处。从古以来,和我们体质相异的人,如西域深目高鼻之民,南方卷发黑身之族,为什么彼我之间,没有造成严重的阶级呢?总而言之:社会的组织,未能尽善,则集团与集团之间,利害不能无冲突。“利惟近者为可争,害惟近者为尤切。”这是事实。至于体质异而利害无冲突,倒不会有什么剧烈的斗争的。这是古今中外的历史,都有很明白的证据的。所以把种族看做严重的问题,只是一个俗见。\n近代有一种贱民。其起源,或因民族的异同,或因政治上的措置,或则社会上积习相沿,骤难改易。遂至造成一种特别阶级。这在清朝时,法律上都曾予以解放。如雍正元年,于山、陕的乐户,绍兴的惰民;五年于徽州的伴档,宁国的世仆;八年于常熟、昭文的丐户,都令其解放同于平民。乾隆三十六年,又命广东的疍户,浙江的九姓渔户,及各省有似此者,均查照雍正元年成案办理。这自然是一件好事情。但社会上的歧视,往往非政治之力所能转移。所以此等阶级,现在仍未能完全消灭。这是有待于视压迫为耻辱的人,继续努力的了。\n阶级制度,在古昔是多少为法律所维持的。及文化进步,觉得人视人为不平等,不合于理,此等法律,遂逐渐取消。然社会上的区别,则不能骤泯。社会阶级的区别,显而易见的,是生活的不同。有形的如宫室、衣服等,无形的如语言、举动等。其间的界限,为社会所公认。彼此交际之间,上层阶级,会自视为优越,而对方亦承认其优越;下层阶级,会被认为低微,而其人亦自视为低微。此等阶级的区别,全由习惯相沿。而人之养成其某阶级的气质,则由于教育(广义的);维持其某阶级的地位,则由于职业。旧时社会所视为最高阶级的,乃读书做官的人,即所谓士。此种人,其物质的享受,亦无以逾于农工商。但所得的荣誉要多些。所以农工商还多希望改而为士,而士亦不肯轻弃其地位(旧时所谓书香之家,虽甚贫穷,不肯轻易改业,即由于此)。这还是封建残余的势力。此外则惟视其财力的厚薄,以判其地位的高低。所谓贫富,应以维持其所处的阶级的生活为标准。有余的谓之富,仅足的谓之中人,不足的谓之贫。此自非指一时的状况言,而当看其地位是否稳固。所谓稳固,包含三条件:即(一)财产收人,较劳力收入为稳固。(二)有保障的职业,较无保障的为稳固。(三)独立经营的职业,较待人雇用的为稳固。阶级的升降,全然视其财力。财力足以上升,即可升入上层阶级。财力不能维持,即将落入下层阶级。宫室衣服等,固然如此,即教育职业亦然。如农工商要改做士,则必须有力量能从师读书;又必须有力量能与士大夫交际,久之,其士大夫的气质,乃得养成。此系举其一端,其他可以类推。总之,除特别幸运的降临,凡社会上平流而进的,均必以经济上的地位为其基础。下层社会中人,总想升人上层的;上层社会中人,则想保持其地位。旧时的教育,如所谓奋勉以求上进,如所谓努力勿坠其家声等,无论其用意如何,其内容总不外乎此。至于(一)铲除阶级;(二)组织同阶级中人,以与异阶级相斗争,则昔时无此思想。此因(一)阶级间之相去,并不甚远;(二)而升降也还容易之故。新式产业兴起以后,情形就与从前不同。从前所谓富、中人、贫,相去实不甚远的,今则相去甚远(所谓中产阶级,当分新旧两种:旧的,如旧式的小企业等,势将逐渐为大企业所吞并。新的,如技术、管理人员等,则皆依附大资本家以自存。其生活形式,虽与上层阶级为侪,其经济地位的危险,实与劳工无异。既无上升之望,则终不免于坠落。所以所谓中间者,实不能成为阶级)。从下级升至上级,亦非徒恃才能,所能有济(昔时的小富,个人的能力及际遇,足以致之,今之大富豪则不然。现在文明之国,所谓实业领袖,多系富豪阶级中人,由别阶级升入的很少)。于是虽无世袭之名,而有世袭之实。上级的地位,既不易变动,下级的恶劣境遇,自然不易脱离。环境逼迫着人改变思想,阶级斗争之说,就要风靡一时了。铲除阶级,自是美事。但盲动则不免危险;且亦非专用激烈手段,所能有济,所以举措不可不极审慎。\n第四十一章 财产 # 要讲中国的经济制度,我们得把中国的历史,分为三大时期:有史以前为第一期。有史以后,讫于新室之末,为第二期。自新室亡后至现在,为第三期。自今以后,则将为第四期的开始。\n孔子作《春秋》,把二百四十二年,分为三世:第一期为乱世,第二期为升平世,第三期为太平世。这无疑是想把世运逆挽而上,自乱世进入升平,再进入太平的。然则所谓升平、太平,是否全是孔子的理想呢?我们试看,凡先秦诸子,无不认为邃古之世,有一个黄金时代,其后乃愈降而愈劣,即可知孔子之言,非尽理想,而必有其历史的背景。《礼记·礼运》所说的大同、小康,大约就是这个思想的背景罢?大同是孔子认为最古的时代,最好的,小康则渐降而劣,再降就入于乱世了。所谓升平,是想把乱世逆挽到小康,再进而达于大同,就是所谓太平了,这是无可疑的。然则所谓大同、小康,究竟是何时代呢?\n人是非劳动不能生存的,而非联合,则其劳动将归于无效,且亦无从劳动起,所以《荀子》说人不群则不能胜物(见《王制篇》。胜字读平声,作堪字解,即担当得起的意思。物字和事字通训。能胜物,即能担当得起事情的意思,并非谓与物争斗而胜之)。当这时代,人是“只有合力以对物,断无因物而相争”的,许多社会学家,都证明原始时代的人,没有个人观念。我且无有,尚何有于我之物?所以这时代,一切物都是公有的。有种东西,我们看起来,似乎是私有(如衣服及个人所用的器具之类),其实并不是私有,不过不属于这个人,则无用,所以常常附属于他罢了。以财产之承袭论,亦是如此(氏族时代,男子的遗物,多传于男子,女子的遗物,多传于女子,即由于此)。当这时代,人与人之间,既毫无间隔,如何不和亲康乐呢?人类经过原始共产时代、氏族共产时代,以入于家族集产时代,在氏族、家族时代,似已不免有此疆彼界之分,然其所含的公共性质还很多。孔子所向往的大同,无疑的,是在这一个时代以前。今试根据古书,想象其时的情形如下。\n这时代,无疑是个农业时代。耕作的方法,其初该是不分疆界的,其后则依家族之数,而将土地分配(所以孔子说“男有分,女有归”),此即所谓井田制度。井田的制度,是把一方里之地,分为九区。每区一百亩。中间的一区为公田,其外八区为私田。一方里住八家,各受私田百亩。中间的公田,除去二十亩,以为八家的庐舍(一家得二亩半),还有八十亩,由八家公共耕作。其收入,是全归公家的。私田的所入,亦即全归私家。此即所谓助法。如其田不分公私,每亩田上的收获,都酌提若干成归公,则谓之彻法。土田虽有分配,并不是私人所有的,所以有“还受”和“换主易居”之法(受,谓达到种田的年龄,则受田于公家。还,谓老了,达到无庸种田的年龄,则把田还给公家。因田非私人所有,故公家时时可重行分配,此即所谓“再分配”。三年一换主易居,即再分配法之一种)。在所种之田以外,大家另有一个聚居之所,是之谓邑。合九方里的居民,共营一邑,故一里七十二家(见《礼记·杂记》《注》引《王度记》。《公羊》何《注》举成数,故云八十家。邑中宅地,亦家得二亩半,合田间庐舍言之,则曰“五亩之宅”),八家共一巷。中间有一所公共的建筑,是为“校室”。春、夏、秋三季,百姓都在外种田,冬天则住在邑内。一邑之中,有两个老年的人做领袖。这两个领袖,后世的人,用当时的名称称呼他,谓之父老、里正。古代的建筑,在街的两头都有门,谓之闾。闾的旁边,有两间屋子,谓之塾。当大家要出去种田的时候,天亮透了,父老和里正,开了闾门,一个坐在左塾里,一个坐在右塾里,监督着出去的人。出去得太晚了;或者晚上回来时,不带着薪樵以预备做晚饭,都是要被诘责的。出入的时候,该大家互相照应。所带的东西轻了,该帮人家分拿些。带的东西重了,可以分给人家代携,不必客气。有年纪、头发花白的人,该让他安逸些,空手走回来。到冬天,则父老在校室里,教训邑中的小孩子,里正则催促人家“缉绩”。住在一条巷里的娘们,聚在一间屋子里织布,要织到半夜方休。以上所说的,是根据《公羊》宣公十五年何《注》、《汉书·食货志》,撮叙其大略。这虽是后来人传述的话,不全是古代的情形,然还可根据着他,想象一个古代农村社会的轮廓。\n农田以外的土地,古人总称为山泽。农田虽按户口分配,山泽是全然公有的。只要依据一定的规则,大家都可使用(如《孟子》所说的“数罟不入洿池”,“斧斤以时入山林”等。田猎的规则,见《礼记·王制》。《周官》有山虞、林衡、川衡、泽虞、迹人、卝人等官,还是管理此等地方,监督使用的人,必须遵守规则,而且指导他使用的方法的,并不封禁)。\n这时候,是无所谓工业的。简单的器具,人人会造,较繁复的,则有专司其事的人。但这等人,绝不是借此以营利的。这等人的生活资料,是由大家无条件供给他的,而他所制造的器具,也无条件供给大家用。这是后来工官之本。\n在本部族之内,因系公产,绝无所谓交易。交易只行于异部族之间。不过以剩余之品,互相交换,绝无新奇可喜之物。所以许行所主张的贸易,会简单到论量不论质(见《孟子·滕文公上篇》)。而《礼记·郊特牲》说:“四方年不顺成,八蜡不通。”(言举行蜡祭之时,不许因之举行定期贸易)蜡祭是在农功毕后举行的,年不顺成,就没有剩余之品可供交易了。此等交易,可想见其对于社会经济,影响甚浅。\n倘在特别情形之下,一部族中,缺少了甚么必要的东西,那就老实不客气,可以向人家讨,不必要有什么东西交换。后来国际间的乞籴,即原于此。如其遇见天灾人祸,一个部族的损失,实在太大了,自己无力回复,则诸部族会聚集起来,自动替他填补的。《春秋》襄公三十年,宋国遇到火灾,诸侯会于澶渊,以更宋所丧之财(更为继续之意,即现在的赓字),亦必是自古相沿的成法。帮助人家工作,也不算得什么事的。《孟子》说:“汤居亳,与葛为邻。葛伯放而不祀。汤使人问之曰:何为不祀?曰:无以供牺牲也。汤使遗之牛羊。葛伯食之,又不以祀。汤又使人问之曰:何为不祀?曰:无以供粢盛也。汤使亳众往为之耕。”(《滕文公下》)这件事,用后世人的眼光看起来,未免不近情理。然如齐桓公会诸侯而城杞(《春秋》僖公十四年),岂不亦是替人家白效劳么?然则古代必有代耕的习惯,才会有这传说。古代国际间有道义的举动还很多,据此推想,可以说:都是更古的部族之间留传下来的。此即孔子所谓“讲信修睦”。\n虽然部族和部族之间,有此好意,然在古代,部族乞助于人的事,总是很少的。因为他们的生活,是很有规范的,除非真有不可抗拒的灾祸,决不会沦于穷困。他们生活的规范,是怎样呢?《礼记·王制》说:冢宰“以三十年之通制国用,量入以为出。”“三年耕,必有一年之食。九年耕,必有三年之食。以三十年之通,虽有凶旱水溢,民无菜色。”这在后来,虽然成为冢宰的职责,然其根源,则必是农村固有的规范。不幸而遇到凶年饥馑,是要合全部族的人,共谋节省的。此即所谓凶荒札丧的变礼。在古代,礼是人人需要遵守的。其所谓礼,都是切于生活的实际规则,并不是什么虚文。所以《礼记·礼器》说:“年虽大杀,众不恇惧,则上之制礼也节矣。”\n一团体之中,如有老弱残废的人,众人即无条件养活他。《礼记·王制》说:孤、独、鳏、寡,“皆有常饩”。又说:“喑、聋、跛、躃、断者(骨节断的人)、侏儒(体格不及标准。该包括一切发育不完全的人),百工各以其器食之。”旧说:看他会做什么工,就叫他做什么工。这解释怕是错的。这一句和上句,乃是互言以相备。说对孤、独、鳏、寡供给食料,可见对此等残废的人,亦供给食料;说对此等残废的人,供给器用,可见对孤、独、鳏、寡亦供给器用。乃古人语法如此。《荀子·王制篇》作“五疾上收而养之”可证。\n此等规则都实行了,确可使匹夫、匹妇,无不得其所的;而在古代,社会内部无甚矛盾之世,我们亦可以相信其曾经实行过的。如此,又何怪后人视其时为黄金时代呢?视古代为黄金时代,不但中国,希腊人也有这样思想的。物质文明和社会组织,根本是两件事。讲物质文明,后世确是进步了。以社会组织论,断不能不承认是退步的。\n有许多遗迹,的确可使我们相信,在古代财产是公有的。《书经·酒诰篇》说:“群饮,汝勿佚,尽执拘以归于周,予其杀。”这是周朝在殷朝的旧土,施行酒禁时严厉的诰诫。施行酒禁不足怪,所可怪的,是当此酒禁严厉之时,何不在家独酌?何得还有群饮触犯禁令的人,致烦在上者之诰诫?然则其所好者,在于饮呢?还是在于群呢?不论什么事,根深柢固,就难于骤变了。汉时的赐酺,不也是许民群饮么?倘使人之所好,只在于饮而不在于群,赐酺还算得什么恩典?可见古人好群饮之习甚深。因其好群饮之习甚深,即可想见其在邃古时,曾有一个共食的习惯。家家做饭自己吃,已经是我们的耻辱了。《孟子》又引晏子说:“师行而粮食。”粮同量,谓留其自吃的部分,其余尽数充公。这在晏子时,变成虐政了,然推想其起源,则亦因储藏在人家的米,本非其所私有,不过借他的房屋储藏(更古则房屋亦非私有),所以公家仍可随意取去。\n以上所说,都是我们根据古籍所推想的大同时代的情形。虽然在古籍中,已经不是正式记载,而只是遗迹,然有迹则必有迹所自出之履,这是理无可疑的。然则到后来,此等制度,是如何破坏掉的呢?\n旷观大势,人类全部历史,不外自塞而趋于通。人是非不断和自然争斗,不能生存的。所联合的人愈多,则其对自然争斗的力愈强。所以文明的进步,无非是人类联合范围的扩大。然人类控制自然的力量进步了,控制自己的力量,却不能与之并进。于是天灾虽澹,而人祸复兴。\n人类的联合,有两种方法:一种是无分彼此,通力合作,一种则分出彼此的界限来。既分出彼此的界限,而又要享受他人劳动的结果,那就非于(甲)交易、(乙)掠夺两者之中,择行其一不可了。而在古代,掠夺的方法,且较交易为通行。在古代各种社会中,论文化,自以农业社会为最高;论富力,亦以农业社会为较厚,然却很容易被人征服。因为(一)农业社会,性质和平,不喜战斗。(二)资产笨重,难于迁移。(三)而猎牧社会,居无定所,去来飘忽,农业社会,即幸而战争获胜,亦很难犁庭扫穴,永绝后患。(四)他们既习于战斗,(五)又是以侵略为衣食饭碗的,得隙即来。农业社会,遂不得不于可以忍受的条件之下,承认纳贡而言和;久之,遂夷为农奴;再进一步,征服者与被征服者,关系愈益密切,遂合为一个社会,一为治人者,食于人者,一为治于人者,食人者了。封建时代阶级制度的成立,即缘于此(参看上章)。\n依情理推想,在此种阶级之下,治者对于被治者,似乎很容易为极端之剥削的。然(一)剥削者对于被剥削者,亦必须留有余地,乃能长保其剥削的资源。(二)剥削的宗旨,是在于享乐的,因而是懒惰的,能够达到剥削的目的就够了,何必干涉人家内部的事情?(三)而剥削者的权力,事实上亦或有所制限,被剥削者内部的事情,未必容其任意干涉。(四)况且两个社会相遇,武力或以进化较浅的社会为优强,组织必以进化较深的社会为坚凝。所以在军事上,或者进化较深的社会,反为进化较浅的社会所征服;在文化上,则总是进化较浅的社会,为进化较深的社会所同化的。职是故,被征服的社会,内部良好的组织,得以保存。一再传后,征服者或且为其所同化,而加入于其组织之中。古语说君者善群(这群字是动词,即组织之义),而其所以能群,则由于其能明分(见《荀子·王制》、《富国》两篇)。据此义,则征服之群之酋长,业已完全接受被征服之群之文化,依据其规则,负起组织的责任来了。当这时代,只有所谓君大夫,原来是征服之族者,拥有广大的封土,收入甚多,与平民相悬绝。此外,社会各方面的情形,还无甚变更。士,不过禄以代耕,其生活程度,与农夫相仿佛。农则井田之制仍存,工商亦仍无大利可牟。征服之族,要与被征服之族在经济上争利益者,亦有种种禁例,如“仕则不稼,田则不渔”之类(见《礼记·坊记》。《大学》:孟献子曰:“畜马乘,不察于鸡豚;伐冰之家,不畜牛羊。”董仲舒对策,说公仪子相鲁,之其家,见织帛,怒而出其妻;食于舍而茹葵,愠而拔其葵。曰:“吾已食禄,又夺园夫红女利乎?”此等,在后来为道德上的教条,在当初,疑有一种禁令)。然则社会的内部,还是和亲康乐的,不过在其上层,多养着一个寄生者罢了。虽然和寄生虫并存,还不至危及生命健康,总还算一个准健康体,夫是之谓小康。\n小康时代,又成过去,乱世就要来了。此其根源:(一)由初期的征服者,虽然凭恃武力,然其出身多在瘠苦之地,其生活本来是简陋的。凡人之习惯,大抵不易骤变,俭者之不易遽奢,犹奢者之不能复俭。所以开国之主,总是比较勤俭的。数传之后,嗣世之君,就都变成生于深宫之中,长于阿保之手的纨袴子弟了。其淫侈日甚,则其对于人民之剥削日重,社会上的良好规制,遂不免受其影响(如因政治不善,而人民对于公田耕作不热心,因此发生履亩而税的制度,使井田制度受其影响之类)。(二)则商业发达了,向来自行生产之物,可以不生产而求之于人;不甚生产之物,或反可多生产以与人交易。于是旧组织不复合理,而成为获利的障碍,就不免堕坏于无形了。旧的组织破坏了,新的组织,再不能受理性的支配,而一任事势的推迁。人就控制不住环境,而要受环境的支配了。\n当这时代,经济上的变迁,可以述其荦荦大端如下:\n一、因人口增加,土地渐感不足,而地代因之发生。在这情形之下,土地荒废了,觉得可惜,于是把向来田间的空地,留作道路和备蓄泄之用的,都加以垦辟,此即所谓“开阡陌”(开阡陌之开,即开垦之开。田间的陆地,总称阡陌。低地留作蓄水泄水之用的,总称沟洫。开阡陌时,自然把沟洫也填没了。参看朱子《开阡陌辨》)。这样一来,分地的标记没有了,自然可随意侵占,有土之君,利于租税之增加,自然也不加以禁止,或且加以倡导,此即孟子所谓“暴君污吏,必慢其经界”(《滕文公上篇》)。一方面靠暴力侵占,一方面靠财力收买,兼并的现象,就陆续发生了。\n二、山泽之地,向来作为公有的,先被有权力的封君封禁起来,后又逐渐入于私人之手(《史记·平准书》说:汉初山川、园池,自天子至于封君,皆各为私奉养。此即前代山泽之地。把向来公有的山泽,一旦作为私有,在汉初,决不会,也决不敢有这无理的措置,可见自秦以前,早已普遍加以封禁了。管子官山府海之论,虽然意在扩张国家的收入,非以供私人之用,然其将公有之地,加以封禁则同。《史记·货殖列传》所载诸大企业家,有从事于畜牧的,有从事于种树的,有从事于开矿的,都非占有山泽之地不行。这大约是从人君手里,以赏赐、租、买等方法取得的)。\n三、工业进化了,器用较昔时为进步,而工官的制造,未必随之进步。或且以人口增加而工官本身,未尝扩张,量的方面,亦发生问题。旧系家家自制之物,至此求之于市者,亦必逐渐增加。于是渐有从事于工业的人,其获利亦颇厚。\n四、商人,更为是时活跃的阶级。交换的事情多了,居间的商人,随之而增多,这是势所必至的。商业的性质,是最自利的。依据它的原理,必须以最低的价格(只要你肯卖)买进,最高的价格(只要你肯买)卖出。于是生产者、消费者同受剥削,而居间的阶级独肥。\n五、盈天地之间者皆物,本说不出什么是我的,什么是你的。所以分为我的、你的,乃因知道劳力的可贵,我花了劳力在上面的东西,就不肯白送给你。于是东西和东西,东西和劳力,劳力和劳力,都可以交换。于是发生了工资,发生了利息。在封建制度的初期,封君虽然霸占了许多财产,还颇能尽救济的责任,到后来,便要借此以博取利息了。孟子述晏子的话,说古代的巡狩,“春省耕而补不足,秋省敛而助不给”(《梁惠王下篇》)。而《战国策》载冯煖为孟尝君收债,尽焚其券以市义,就显示着这一个转变。较早的时代,只有封君是有钱的,所以也只有封君放债。后来私人有钱的渐多,困穷的亦渐众,自然放债取利的行为,渐渐的普遍了。\n六、在这时代,又有促进交易和放债的工具发生,是为货币的进步(别见《货币篇》)。货币愈进步,则其为用愈普遍,于是交易活泼,储蓄便利,就更增进人的贪欲(物过多则无用,所以在实物经济时代,往往有肯以之施济的。货币既兴,此物可以转变为他物,储蓄的亦只要储蓄其价值,就不容易觉得其过剩了)。\n在这种情形之下,就发生下列三种人:\n一、大地主。其中又分为(甲)田连阡陌及(乙)擅山泽之利的两种人。\n二、大工商家。古代的工业家,大抵自行贩卖,所以古人统称为商人。然从理论上剖析之,实包括工业家在内,如汉时所称之“盐铁”(谓制盐和鼓铸铁器的人)。其营业,即是侧重在制造方面的。\n三、子钱家。这是专以放债取息为营业的。要知道这时代的经济情形,最好是看《史记》的《货殖列传》。然《货殖列传》所载的,只是当时的大富豪。至于富力较逊,而性质相同的(小地主、小工商及小的高利贷者)那就书不胜书了。\n精神现象,总是随着生活环境而变迁的。人,是独力很难自立的,所以能够生存,无非是靠着互助。家族制度盛行,业已把人分成五口、八口的一个个的小单位。交易制度,普遍的代替了分配、互助之道,必以互相剥削之方法行之,遂更使人们的对立尖锐。人,在这种情形之下,要获得一个立足之地甚难,而要堕落下去则甚易。即使获得了一个立足之地,亦是非用强力,不易保持的。人们遂都汲汲惶惶,不可终日。董仲舒说:“天下攘攘,皆为利往;天下熙熙,皆为利来。”《史记·货殖列传》有一段,剖析当时所谓贤士、隐士、廉吏、廉贾、壮士、游侠、妓女、政客、打猎、赌博、方技、犯法的吏士、农、工、商贾,各种人的用心,断言他的内容,无一而非为利。而又总结之曰:“此有智尽能索耳,终不余力而让财矣。”《韩非子》说:无丰年旁入之利,而独以完给者,非力则俭。无饥寒疾病祸罪之殃,而独以贫穷者,非侈则惰。征敛于富人,以布施于贫家,是夺力俭而与侈惰(《显学篇》)。话似近情,然不知无丰年旁入之利,无饥寒疾病祸罪之殃的条件,成立甚难;而且侈惰亦是社会环境养成的。谁之罪?而独严切的责备不幸的人,这和“不独亲其亲,不独子其子”,“货恶其弃于地也,不必藏于己;力恶其不出于身也,不必为己”的精神,竟不像是同一种动物发出来的了。人心大变,此即所谓乱世。\n孔子所谓小康之世,大约从有史时代就开始的。因为我们有确实的历史,始于炎、黄之际,已经是一个干戈扰攘的世界了。至于乱世,其机缄,亦是早就潜伏的,而其大盛,则当在东周之后。因为封建制度,是自此以后,才大崩溃的(封建制度的崩溃,不是什么单纯的政治作用,实在是社会文化进步,而后政治作用随之的,已见第三十九章。新文化的进步,就是旧组织的崩溃)。然在东周以后,社会的旧组织,虽已崩溃,而人们心上,还都觉得这新成立的秩序为不安;认为它是变态,当有以矫正之。于是有两汉时代不断的社会改革运动。酝酿久之,到底有新室的大改革。这大改革失败了,人们才承认社会组织的不良,为与生俱来,无可如何之事,把病态认为常态了。所以我说小康的一期,当终于新室之末。\n汉代人的议论,我们要是肯细看,便可觉得他和后世的议论,绝不相同。后世的议论,都是把社会组织的缺陷,认为无可如何的事,至多只能去其太甚。汉代人的议论,则总是想彻底改革的。这个,只要看最著名的贾谊、董仲舒的议论,便可见得。若能细读《汉书》的《王贡两龚鲍》和《眭两夏侯京翼李传》,就更可明白了。但他们有一个通弊,就是不知道治者和被治者,根本上是两个对立的阶级。不知领导被压迫阶级,以图革命,而专想借压迫阶级之力,以为人民谋解放。他们误以为治者阶级,便是代表全社会的正义的,而不知道这只是治者阶级中的最少数。实际,政治上的治者阶级,便是经济上的压迫阶级,总是想榨取被治阶级(即经济上的被压迫阶级)以牟利的。治者阶级中最上层的少数人,只是立于两者之间,使此两阶级得以保持一个均衡,而实际上还是偏于治者一方面些。要想以它为发力机,鼓动了多数治者,为被治者谋幸福,真是缘木求鱼,在理论上决不容有这回事。理所可有,而不能实现之事多矣,理所必无,而能侥幸成功之事,未之前闻。这种错误,固然是时代为之,怪不得古人。然而不能有成之事,总是不能有成,则社会科学上的定律,和自然科学上的定律,一样固定,决不会有例外。\n在东周之世,社会上即已发生两种思潮:一是儒家,主张平均地权,其具体办法,是恢复井田制度。一是法家,主张节制资本,其具体办法,是(甲)大事业官营;(乙)大商业和民间的借贷,亦由公家加以干涉(见《管子·轻重》各篇)。汉代还是如此。汉代儒家的宗旨,也是要恢复井田的。因为事不易行,所以让步到“限民名田”。其议发于董仲舒。哀帝时,师丹辅政,业已定有办法,因为权戚所阻挠,未能实行。法家的主张,桑弘羊曾行之。其最重要的政策,是盐铁官卖及均输。均输是官营商业。令各地方,把商人所贩的出口货做贡赋,官贩卖之于别地方。弘羊的理论,略见《盐铁论》中。著《盐铁论》的桓宽,是反对桑弘羊的(《盐铁论》乃昭帝时弘羊和贤良文学辩论的话,桓宽把它整理记录下来的。贤良文学,都是治儒家之学的。弘羊则是法家,桓宽亦信儒家之学),其记录,未必会有利于弘羊,然而我们看其所记弘羊的话,仍觉得光焰万丈,可知历来以弘羊为言利之臣,专趋承武帝之意,替他搜括,实在是错误的。但弘羊虽有此种抱负,其筹款的目的是达到了,矫正社会经济的目的,则并未达到。汉朝所实行的政策,如减轻田租,重农抑商等,更其无实效可见了。直到汉末,王莽出来,才综合儒法两家的主张,行一断然的大改革。\n在中国经学史中,有一重公案,便是所谓今古文之争。今古文之争,固然自有其学术上的理由,然和政治的关系亦绝大。提倡古文学的刘歆、王莽,都是和政治很有关系的人。我们向来不大明白他们的理由,现在却全明白了。王莽是主张改革经济制度的人。他的改革,且要兼及于平均地权和节制资本两方面。今文经是只有平均地权的学说,而无节制资本的学说的。这时候,社会崇古的风气正盛。欲有所作为,不得不求其根据于古书。王莽要兼行节制资本的政策,自不得不有取于古文经了。这是旁文。我们现在且看王莽所行的政策:\n(一)他把天下的田,都名为王田(犹今言国有),奴婢名为私属,都不得卖买。男口不盈八,而田过一井的,分余田与九族乡党。\n(二)设立六筦之制:(甲)盐,(乙)酒,(丙)铁,(丁)山泽,(戊)五均赊贷,(己)铁布铜冶。其中五均赊贷一项,是控制商业及借贷的。余五项,系将广义的农业和工业,收归官营。\n(三)五均,《汉书·食货志》《注》引邓展,谓其出于河间献王所传的《乐语》、《乐元语》。臣瓒引其文云:“天子取诸侯之土,以立五均,则市无二贾,四民常均;强者不得困弱,富者不得要贫;则公家有余,恩及小民矣。”这是古代的官营商业。其为事实或法家的学说未可知,而要为王莽的政策所本。王莽的制度:是改长安东西市令,又于洛阳、邯郸、临淄、宛、成都五处,都设司市师(师是长官之意),各以四时仲月(二、五、八、十一月),定该区中货物的平价。货物实系有用而滞销的,照他的本钱买进。物价腾贵,超过平价一钱时(汉时钱价贵,故超过一钱,即为腾贵),则照平价出卖。又在司市师之下,设泉府丞(丞是副官的意思),经营各种事业的人,都要收税,名之为贡(其额按纯利十分之一)。泉府收了这一笔贡,用以借给困乏的人。因丧祭等事而借的,只还本,不取息,借以营利的,取年息十分之一。\n王莽的变法,成功的希望是不会有的,其理由已述于前。固然,王莽的行政手段很拙劣,但这只是枝节。即使手段很高强,亦不会有成功的希望。因为根本上注定要失败的事,决不是靠手段补救得来的。但是王莽的失败,不是王莽一个人的失败,乃是先秦以来言社会改革者公共的失败。因为王莽所行,并不是王莽一个人的意见,乃是先秦以来言社会改革者公共的意见。王莽只是集此等意见的大成。经过这一次改革失败之后,人遂群认根本改革为不可能,想把乱世逆挽之而至于小康的思想,从此告终了。中国的社会改革运动,至此遂告长期的停顿。\n虽然在停顿时期,枝节的改革,总还不能没有的。今亦略述其事如下:\n当这时代,最可纪念的,是平和的、不彻底的平均地权运动。激烈的井田政策既经绝望,平和的限民名田政策,还不能行,于是又有一种议论,说平均地权之策,当行之于大乱之后,地广人稀,土田无主之日。于是有晋朝的户调式,北魏的均田令,唐朝的租庸调法。这三法的要点是:(一)因年龄、属性之别,以定受田的多少。(二)在北魏的均田令中,有露田和桑田的区别。唐朝则名为口分田和世业田。桑田和世业田,是可以传世的,露田和口分田,则受之于官,仍要还之于官。(三)唐制又有宽狭乡之别。田亩之数,足以照法令授与的为宽乡,不足的为狭乡。狭乡授田,减宽乡之半。(四)有余田的乡,是要以给比连之乡的。州县亦是如此。(五)徙乡和贫无以葬的人,得卖世业田。自狭乡徙宽乡的,得并卖口分田(口分田非其所有,无可卖之理。这该是奖励人民从狭乡迁到宽乡去的意思。法律上的解释,等于官收其田而卖却之,而将卖田所得之款,发给为奖励费。许其自卖,只是手续简便些罢了)。(六)虽然如此,世业田仍有其一定制限,买进的不得超过此限度,在最小限度以内,亦不得再卖却。统观三法,立法之意,是不夺其私有之田,无田者则由官给,希冀减少反抗,以渐平均地权,其立法之意诚甚善。然其实行至何程度,则殊可疑(晋法定后,天下旋乱,曾否实行,论者甚至有怀疑的。北魏及唐,曾实行至何程度,历史上亦无明确的记载),即使实行了,而人总是有缓急的;缓急的时候,不能不希望通融,在私产制度之下,谁肯白借给你来?救济的事业,无论如何,是不能普遍的(救济事业之量,决不能等于社会上需要救济之量,这是有其理论上的根据的。因为救济人者,必先自觉有余,然后能斥其所余以救济人。然救济人者的生活程度,必高于所救济的人,因而他所拿出来的,均摊在众人头上,必不能使被救济者之生活程度,与救济之者相等。而人之觉得足不足,并不是物质上真有什么界限,而往往是和他人的生活状况相比较的。如此,故被救济者在心理上永无满足之时。又在现在的社会组织之下,一个人的财富,往往是从剥削他人得来的,而他的自觉有余必在先,斥其余以救济他人必在后。自剥削至于救济,其中必经过相当的时间。在此时间之中,被剥削者,必已负有很大的创伤,即使把所剥削去的全数都还了他,亦已不够回复,何况还不能全数还他呢),于是不得不有抵卖之品。而贫民是除田地之外,无物可以抵卖的。如此,地权即使一度平均,亦很难维持永久。何况并一度之平均而不可得呢?再者:要调剂土满和人满,总不能没有移民,而在现在的文化状况之下,移民又是很难实行的。所以此等平均地权的方法,不论事实,在理论上已是很难成立的了。据记载,唐朝当开元时,其法业已大坏。至德宗建中元年(民国纪元前1132年),杨炎为相,改租庸调法为两税法,人民有田无田,田多田少,就无人过问了。自晋武帝太康元年(民国纪元前1632年),平吴行户调法至此,前后适五百年。自此以后,国家遂无复平均地权的政策。间或丈量,不过为平均赋税起见,而亦多不能彻底澄清。兼并现象,依然如故,其中最厉害的,为南宋时浙西一带的兼并。因为这时候,建都在临安,浙西一带,阔人多了,竞以兼并为事。收租奇重。宋末,贾似道要筹款,就用低价硬买做官田。田主固然破产了。佃户自此要向官家交租,又非向私家交租时“额重纳轻”之比,人民已受了一次大害。到明初平张士诚,太祖恶其民为士诚守,对于苏松、嘉湖之田,又定以私租为官税。后来虽屡经减免,直到现在,这一带田赋之重,还甲于全国。兼并的影响,亦可谓深了。\n物价的高低,东汉以后,更无人能加以干涉。只有食粮,关系人民的利害太切了,国家还不能全然放任。安定谷价的理论,始于李悝。李俚说籴(谷价),甚贱伤农,甚贵伤民(此民字指谷之消费者,与农为谷之生产者立于对待的地位),主张当新谷登场时,国家收买其一部分,至青黄不接时卖出,以保持谷的平价。汉宣帝时,谷价大贱,大司农耿寿昌,于若干地方行其法,名其仓为常平仓。此法虽不为牟利起见,然卖出之价,必比买进之价略高,国家并无所费,而人民实受其益,实可称法良意美。然在古代,谷物卖买未盛则有效。至后世,谷物的市场日广,而官家的资本甚微,则即使实力奉行,亦难收控制市场之效;何况奉行者又多有名无实,甚或并其名而无之呢?所以常平仓在历代法令上,虽然是有的时候多,实际上并无效力。隋文帝时,工部尚书长孙平创义仓之法,令人民于收成之日,随意功课,即于当社立仓存贮。荒歉之时,用以救济。后周时有惠民仓。将杂配钱(一种杂税的名目)的几分之几,折收谷物,以供凶年平籴之用。宋时又有广惠仓。募人耕没入和户绝田,收其租以给郭内穷苦的人民。这都是救济性质。直到王安石出来,行青苗法,才推广之,以供借贷之用。青苗法是起于李参的。李参在陕西做官时,命百姓自度耕种的赢余,告贷于官。官贷之以钱。乃秋,随赋税交还。王安石推行其法于诸路。以常平、广惠仓所储的钱谷为贷本(仓本所以贮谷,后世因谷的储藏不便,亦且不能必得,遂有兼储钱的。需用时再以钱买谷,或竟发钱),当时反对者甚多,然其本意是好的,不过官不是推行此法的机关,不免有弊罢了(反对青苗的人,有的说它取息二分太重,这是胡说,当时民间利率,实远重于此。青苗之弊:在于(一)人民不敢与官交涉。(二)官亦不能与民直接,势必假手于吏胥,吏胥多数是要作弊的,人民更不敢与之交涉。(三)于是听其自然,即不能推行。(四)强要推行,即不免抑配。(五)借出之款,或不能偿还,势必引起追呼。(六)又有勒令邻保均赔的。(七)甚有无赖子弟,谩昧尊长,钱不入家。或他人冒名诈请,莫知为谁的。总而言之,是由于办理的机关的不适宜)。南宋孝宗乾道四年,建州大饥。朱子请于府,得常平仓粟六百石,以为贷本。人民夏天来借的,到冬加二归还。以后逐年如此。小荒则免其半息,大荒则全免其息。如此十四年,除将原本六百石还官外,并将余利,造成仓廒,得粟三千一百石,以为社仓。自此借贷就不再收息了。朱子此法,其以社为范围,与长孙平的义仓同。不但充平籴及救济,而兼供借贷,与王安石的青苗法同。以社为范围,则易于管理,易于监察,人民可以自司其事。如此,则有将死藏的仓谷出贷,化为有用的资本之利,而无青苗法与官交涉之弊。所以历来论者,都以为此法最善;有与其提倡常平、义仓,不如提倡社仓的倾向。义仓不如社仓,诚然无可争辩,这是后起者自然的进步。常平和社仓,则根本不是一件事。常平是官办的,是和粮食商人斗争的。义仓和社仓,都是农民互助的事。固然,农民真正充足了,商人将无所施其剥削,然使将现在社会上一切剥削农民之事,都铲除了,农民又何至于不足呢?固然,当时的常平仓,并没有控制市场之力;至多当饥荒之际,开办平籴,惠及城市之人。然此乃常平办理之不得其法,力量的不够,并不是其本质不好。依正义及经济政策论,国家扶助农民和消费者,铲除居间者的剥削,还是有这义务,而在政策上也是必要的。所以常平和社仓,至少该并行不废。再者,青苗法以官主其事,固然不好,社仓以人民主其事,也未必一定会好的。因为土豪劣绅,和贪官污吏,是同样要吮人膏血的,并无彼此之分。主张社仓的,说社仓范围小,十目所视,十手所指,管理的人,难于作弊。然而从来土豪劣绅,都是明中把持、攘夺,并不是暗中攫取的。义仓创办未几,即或因人民不能管理,而移之于县。社仓,据《文献通考》说:亦是“事久而弊,或主之者倚公以行私,或官司移用而无可给,或拘纳息米而未尝除,甚者拘催无异正赋”。以为非有“仁人君子,以公心推而行之”不为功。可见防止贪污土劣的侵渔,仍不能无藉于人民的自卫了。平抑粮食以外他种物价之事,东汉以后无之。只有宋神宗熙宁五年,曾立市易司,想平抑京师的物价,然其后事未能行。\n●卖田契\n借贷,亦始终是剥削的一种方法。最初只有封君之类是有钱的人,所以也只有他们能营高利贷的事业。后来事实虽然变换了,还有借他们出面的。如《汉书·谷永传》说:当时的掖庭狱,“为人起债(代人放债),分利受谢”是。亦有官自放债的。如隋初尝给内官以公廨钱,令其回易生利,这种公廨钱,就是可以放债的。其类乎封建财产的,则南北朝以后,僧寺颇多殷富,亦常为放债的机关。私人放债取利,较大的,多为商贾所兼营,如《后汉书·桓谭传》:谭上疏陈时政,说:“今富商大贾,多放钱货,中家子弟,为之保役”,则并有代他奔走的人了。《元史·耶律楚材传》说:当时的回鹘,多放羊羔利(利上起利)。回纥也是从西域到中国来经商的。这是因商人手中,多有流动资本,所以兼营此业最便。至于土豪劣绅之类,即在本地方营高利贷业的,其规模自然较此为小,然其数则甚多,而其手段亦极酷辣。《宋史·食货志》载司马光疏,说当时的农民,“幸而收成,公私之债,交争互夺;谷未离场,帛未下机,已非己有”;《陈舜俞传》说:当时放债的人,虽“约偿缗钱,而谷粟、布缕、鱼盐、薪蔌、耰锄、斧锜之属,皆杂取之”;便可见其一斑了。大抵借贷有对人信用和对物信用两种。对物信用,须能鉴别其物,知其时价;对人信用,则须调查其人之财产及行为,亦有一番事情,且须有相当知识。这在放债者方面,亦须有一种组织。所以逐渐发达,而成为近代的钱庄及当铺。\n中国历代,社会上的思想,都是主张均贫富的,这是其在近代所以易于接受社会主义的一个原因。然其宗旨虽善,而其所主张的方法,则有未善。这因历代学者,受传统思想的影响太深,而对于现实的观察太浅之故。在中国,思想界的权威,无疑是儒家。儒家对于社会经济的发展,认识本不如法家的深刻,所以只主张平均地权,而忽略了资本的作用。这在当时,还无怪其然(古代学问的发达,不能不为地域所限。儒学盛于鲁。法家之学,托诸管子,疑其初盛于齐。《史记·货殖列传》说:太公封于齐,地泻卤,人民寡,太公劝女工,极技巧,通鱼盐,人物归之,襁至而辐凑,齐冠带衣履天下。这或者出于附会。然齐鱼盐工商之业皆盛,则是不诬的。齐国在当时,资本必较发达,所以节制资本的思想,就起于其地了),然至后世,学者的眼光,仍限于这一个圈子里,就可怪了。如前述汉代儒家的议论,即其一证。宋学兴起,在中国思想界,是最有特色的。宋儒亦很留心于政治和社会问题。而纯粹的宋学家,亦只重视复井田为致太平之策,那又是其一证。然此犹其小者。至其大者,则未审国家的性质。不知国家是阶级时代的产物,治者阶级,总是要剥削被治者以牟利的。其中虽有少数大公无我的人,然而总只是少数。其力量,较诸大多数的通常人,远觉绵薄。即使这少数人而得位乘时,使其监督大多数人,不敢放手虐民,即所谓去其泰甚,已觉得异常吃力。至于根本上改变其性质,则其事必不可能。如此,所以历代所谓治世的政治,往往是趋于放任的;而一行干涉的政策,则往往召乱。然则但靠国家之力,如何能均平贫富呢?新莽以此失败了,而后世的人,还是这种思想。我们试看王安石的《度支副使厅壁题名记》,他说:“合天下之众者财,理天下之财者法,守天下之法者吏也。吏不良,则有法而莫守;法不善,则有财而莫理;有财而莫理,则阡陌闾巷之贱人,皆能私取予之势,擅万物之利,以与人主争黔首,而放其无穷之欲;非必贵强桀大而后能如是;而天子犹为不失其民者,盖特号而已耳。虽欲食蔬衣敝,憔悴其身,愁思其心,以幸天下之给足而安吾政,吾知其犹不得也。然则善吾法而择吏以守之,以理天下之财,虽上古尧、舜,犹不能毋以此为急务,而况于后世之纷纷乎?”他看得天下之物,是天下人所公有;当由一个代表正义的人,为之公平分配,而不当由自私自利的人,擅其利而私其取予,以役使众人;其意昭然若揭。然欲以此重任,责之于后世的所谓天子,云胡可得呢?中国读书人所以有这思想,是因为其受传统思想的影响太深,在传统思想上,说这本是君之责任故。然在极古的时代,君权大而其所治之国小;而且大同时代的规则,尚未尽废,或者可以做到几分。在后世,则虽甚神圣,亦苦无下手之处了。而中国讲改革的人,都希望着他,如何能不失败呢?龚自珍是近代最有思想的人。他的文集里,有一篇文章,标题为《平均篇》,畅发一切乱源,根本都在经济上分配的不平。最高的治法,是能使之平均。就其现象,与之相安,则不足道。其观察亦可谓极深刻。然问其方法,则仍是希望握政权者,审察各方面的情形,而有以措置之,则仍是一条不通的路而已。龚氏是距离现在不过百年的人,而其思想如此,可见旧日的学者,其思想,全然局限于这一个范围之中。这是时代为之,自然怪不得古人。然在今日,却亦不可不知道昔人所走的路,是一条不通的路,而再奉其思想为金科玉律。\n现代的经济情形,和从前又大不相同了。自从西力东侵以来,我们的经济,已非复闭关独立之世,而与世界息息相通。在工业革命以前,最活跃的是商人阶级。所以历代的议论,都主张重农抑商。自工业革命以后,则商人反成为工业家的附属,不过略沾其余润,所以中国推销洋货的人,即世所称为买办阶级者,在中国社会里,虽俨然是个富豪,而以世界眼光观之,则仍不免在小贫之列。在现代的经济状况之下,断不容我们固步自封。世界的经济情形,自从工业发达了,积集的资本遂多,而金融资本,又极跋扈。工业品是要寻求销路的,而且还要霸占资源,就是固定和流动的资本,也要输出国外,皆不得不以武力保其安全。于是资本主义发展而成为帝国主义。历代的劳资对立,资本家是在国内的,现在则资本家在国外。于是民生问题和民族问题,并为一谈,再不能分离解决了。我们现在,该如何审慎、勇敢、强毅,以应付这一个目前的大问题呢?\n第四十二章 官制 # 官制是政治制度中最繁复的一门。(一)历代设官既多,(二)而又时有变迁。(三)它的变迁,又不是审察事实和制度不合,而条理系统地改正的,而是听其迁流之所至。于是有有其名而无其实的,亦有有其实而无其名的。名实既不相符,循其名遂不能知其实。而各官的分职,亦多无理论可循。求明白其真相,就很不容易了。然官制毕竟是政治的纲领。因为国家要达其目的,必须有人以行之。这行之之人,就是所谓官。所以明于一时代所设之官,即能知其时所行之政。对于历代的官制,若能知其变迁,即亦能知其政治的变迁了。\n人的见解,总是较时代落后一些的。时代只有新的,而人之所知,却限于旧。对付未来的方法,总是根据既往的情形,斟酌而出之的。所以无论如何,不能全合。制度才定出来,即已不适于用。制度是拗不过事实的,(一)非格不能行,(二)即名存实亡,这是一切制度都如此的,而官制亦不能例外。我国的官制,大略可分为六期:(一)自周以前,为列国时代的制度。(二)而秦及汉初统一时代的制度,即孕育于其末期。(三)因其大体自列国时代蜕化而来,和统一时代不甚适合,不久即生变迁。各方面变迁的结果,极其错杂不整。直至唐朝,才整理之,成为一种有系统的制度。(四)然整理甫经就绪,又和事实不符。唐中叶以后,又生变迁,而宋朝沿袭之。(五)元以异族,入主中原,其设施自有特别之处。明朝却沿袭着它。清朝的制度,又大略沿袭明朝。然因实际情形的不同,三朝的制度,又自有其大相违异之处。(六)清朝末叶,因为政体改变,官制亦随之改变。然行之未久,成效不著。直至今日,仍在动荡不定之中。以上略举其变迁的大概,以下再略加说明。因为时间所限,亦只能揭举其大纲而已。\n官有内外之分。内官即中央政府之官,是分事而治的。全国的政务,都汇集于此,依其性质而分类,一官管理一类的事。又有综合全般状况,以决定施政的方针的,是即所谓宰相。外官则分地而治。在其地界以内,原则上各事都要管的。出于地界以外,则各事一概不管。地方区划,又依等级而分大小。上级大的区划,包含若干下级小的区划。在行政上,下级须听上级的指挥。这是历代官制的通则。\n列国并立之世,到春秋战国时代,已和统一时代的制度相近了。因为此时期,大国之中,业已包含若干郡县。但其本身,仍只等于后世一个最大的政治区域。列国官制:今文家常说三公、九卿、二十七大夫、八十一元士。但这只是爵,没有说出他的职守来。三公依今文家说,是司马、司徒、司空。九卿无明文。古文家说,以太师、太傅、太保为三公。少师、少傅、少保为三孤。冢宰(天官)、司徒(地官)、宗伯(春官)、司马(夏官)、司寇(秋官)、司空(冬官),为六卿(许慎《五经异义》)。按今文说的三公,以配天、地、人(司马主天,司徒主人,司空主地)。古文说的六卿,以配天、地、四时。此外还有以五官配五行等说法(见《左传》昭公十七年、二十九年。《春秋繁露·五行相胜篇》)。这不过取古代的官,随意拣几个,编排起来,以合于学说的条理而已。和古代的事实,未必尽合。古代重要的官,不尽于此;并非这几个官特别重要,不过这几个官,亦是重要的罢了。司马是管军事的,司徒是统辖人民的,司空是管建设事务的。古代穴居,是就地面上凿一个窟窿,所以谓之司空(空即现在所用的孔字)。《周官》冬官亡佚,后人以《考工记》补之(其实这句话也靠不住。性质既不相同,安可相补?不过《考工记》也是讲官制的。和《周官》性质相类,昔人视为同类之书,合编在一起,后人遂误以为补罢了)。《周官》说实未尝谓司空掌工事,后世摹仿《周官》而设六部,却以工部拟司空,这是后人之误,不可以说古事的。冢宰总统百官,兼管宫内的事务,其初该是群仆的领袖。所以大夫之家亦有宰。至于天子诸侯,则实际本来差不多的。天子和诸侯、大国和小国制度上的差异,不过被著书的人说得如此整齐,和实际亦未必尽合。宗伯掌典礼,和政治关系最少,然在古代迷信较深之世,祭祀等典礼,是看得颇为隆重的。司寇掌刑法,其初当是军事裁判(说详第四十六章)。三公坐而论道,三孤为之副,均无职事。按《礼记·曾子问》说:“古者男子,内有傅,外有慈母。”《内则》说:国君世子生,“择于诸母与可者,必求其宽裕慈惠,温良恭俭,慎而寡言者,使为子师,其次为慈母,其次为保母。”太师、太傅、太保,正和师、慈、保三母相当。古夫亦训傅,两字盖本系一语,不可以称妇人,故变文言慈。然则古文的三公,其初乃系天子私人的侍从,本与政事无关系,所以无职事可言。《周官》说坐而论道之文,乃采诸《考工记》,然《考工记》此语(“坐而论道,谓之王公”),是指人君言,不是指大臣言的,说《周官》者实误采。总而言之:今文古说,都系春秋战国时的学说,未必和古代的事实密合。然后世厘定制度的人,多以经说为蓝本。所以虽非古代的事实,却是后世制度的渊源。\n列国时代的地方区划,其大的,不过是后世的乡镇。亦有两种说法:《尚书大传》说:“古八家而为邻,三邻而为朋,三朋而为里(七十二家,参看上章),五里而为邑,十邑而为都,十都而为师,州十有二师焉。”这是今文说。《周官》则乡以五家为比,比有长。五比为闾,闾有胥。四闾为族,族有师。五族为党,党有正。五党为州,州有长。五州为乡,乡有大夫。遂以五家为邻,邻有长。五邻为里,里有宰。四里为酂,酂有长。五酂为鄙,鄙有师。五鄙为县,县有正。五县为遂,遂有大夫。这是古文说。这两种说法,前者和井田之制相合,后者和军队编制相合,在古代该都是有的。后来井田之制破坏,所以什伍之制犹存,今文家所说的组织,就不可见了。\n汉初的官制,是沿袭秦朝的。秦制则沿自列国时代。中央最高的官为丞相。秦有左、右,汉通常只设一丞相。丞相之副为御史大夫(中央之官,都是分事而治的。只有御史是皇帝的秘书,于事亦无所不预,所以在事实上成为丞相的副手。汉时丞相出缺,往往以御史大夫升补),武官通称为尉。中央最高的武官,谓之太尉。这是秦及汉初的制度。今文经说行后,改太尉为司马,丞相为司徒,御史大夫为司空,谓之三公,并称相职。又以太常(本名奉常,掌宗庙礼仪)、光禄勋(本名郎中令,掌宫、殿,掖门户)、卫尉(掌宫门卫屯兵)、太仆(掌舆马)、廷尉(掌刑辟,尝改为大理)、大鸿胪(本名典客,掌归义蛮夷)、宗正(掌亲属)、大司农(本名治粟内史,掌谷货)、少府(掌山海池泽之税),为九卿。这不过取应经说而已,并无他种意义。三公分部九卿(太常、光禄勋、卫尉属司马,太仆、廷尉、大鸿胪属司徒,宗正、大司农、少府属司空),亦无理论根据。有大事仍合议。后汉司马仍称太尉。司徒、司空,均去大字,余皆如故。\n外官:秦时以郡统县。又于各郡都设监御史。汉不遣监御史,丞相遣使分察州(按州字并非当时的区域名称,后人无以名之,乃名之为州。所以截至成帝改置州牧以前,州字只是口中的称呼,并非法律上的名词)。武帝时,置部刺史十三人,奉诏书六条,分察诸郡(一、条察强宗巨家。二、条察太守侵渔聚敛。三、条察失刑。四、条察选举不平。五、条察子弟不法,都是专属太守的。六、条察太守阿附豪强)。成帝时,以何武之言,改为州牧。哀帝时复为刺史。后又改为州牧。后汉仍为刺史,而止十二州,一州属司隶校尉(武帝置,以治巫蛊的,后遂命其分察一部分郡国)。按《礼记·王制》说:“天子使其大夫为三监,监于方伯之国,国三人”,这或者附会周初的三监,说未必确,然天子遣使监视诸侯(实即大国之君,遣使监视其所封或所属的小国),则事所可有。大夫之爵,固较方伯为低。秦代御史之长,爵不过大夫。汉刺史秩仅六百石,太守则两千石。以卑临尊,必非特创之制,必然有所受之。以事实论,监察官宜用年少新进的人,任事的官,则宜用有阅历有资望之士,其措置亦很适宜的。何武说:“古之为治者,以尊临卑,不以卑临尊”,不但不合事宜,亦且不明经义。旧制恢复,由于朱博,其议论具载《汉书》,较之何武,通达多了。太守,秦朝本单称守,汉景帝改名。秦又于各郡置尉,景帝亦改为都尉。京师之地,秦时为内史所治。汉武帝改称京兆尹,又分其地置左冯翊、右扶风,谓之三辅。诸王之国,设官本和汉朝略同。亦有内史以治民。七国乱后,景帝乃令诸侯王不得自治民,改其丞相之名为相,使之治民,和郡守一样。县的长官,其秩是以户数多少分高下的。民满万户以上称令,不满万户称长。这由于古代的政治,是属人主义,而非属地主义之故。侯国的等级,与县相同。皇太后、公主所食的县称为邑。县中兼有蛮夷的谓之道。这亦是封建制度和属人主义的色彩。\n●汉代官制\n秦汉时的县,就是古代的国,读第三十九章可见。县令就是古代的国君,只能总握政治的枢机,发踪指示,监督其下。要他直接办事,是做不到的。所以真正的民政,非靠地方自治不可。后世地方自治之制,日以废坠,所以百事俱废。秦汉时则还不然。据《汉书·百官公卿表》和《续汉书·百官志》:其时的制度系以十家为什,五家为伍,一里百家,有里魁检察善恶,以告监官。十里一亭,亭有长。十亭一乡,乡有三老,有秩啬夫、游徼。三老管教化,体制最尊。啬夫职听讼,收赋税,其权尤重。人民竟有知啬夫而不知有郡县的(见《后汉书·爰延传》),和后世绝不相同。\n以上所述,是秦及汉初的制度。行之未几,就起变迁了。汉代的丞相,体制颇尊,权限亦广。所谓尚书,乃系替天子管文书的,犹之管衣服的谓之尚衣,管食物的谓之尚食,不过是现在的管卷之流。其初本用士人,汉武帝游宴后庭,才改用宦官,谓之中书谒者令。武帝死后,此官本可废去,然自武帝以来,大将军成为武官中的高职。昭宣之世,霍光以大将军掌握政权。其时的丞相,都是无用或年老的人,政事悉从中出,沿袭未改。成帝时,才罢中书宦官,然尚书仍为政本,分曹渐广。后汉光武,要行督责之术。因为宰相都是位高望重的人,不便督责他,于是崇以虚名,而政事悉责成尚书。尚书之权遂更大。魏武帝握权,废三公,恢复丞相和御史大夫之职。此时相府复有大权,然只昙花一现。魏文帝篡汉后,丞相之官,遂废而不设。自魏晋至南北朝,大抵人臣将篡位时则一设之,已篡则又取消。此时的尚书,为政务所萃,然其亲近又不敌中书。中书是魏武帝为魏王时所设的秘书监,文帝篡位后改名的,常和天子面议机密。所以晋初荀勖从中书监迁尚书令,人家贺他,他就发怒道:“夺我凤皇池,诸君何贺焉”了。侍中是加官,在宫禁之中,伺候皇帝的。汉初多以名儒为之。从来贵戚子弟,多滥居其职。宋文帝自荆州入立,信任王府旧僚,都使之为侍中,与之谋诛徐羡之等,于是侍中亦参机要。至唐代,遂以中书、门下、尚书三省为相职。中书主取旨,门下主封驳,尚书承而行之。尚书诸曹,魏晋后增置愈广,皆有郎以办事。尚书亦有兼曹的。隋时,始以吏、户、礼、兵、刑、工六曹分统诸司。六曹皆置侍郎,诸司则但置郎,是为后世以六部分理全国政务之始。三公一类的官,魏晋后亦时有设置,都不与政事,然仍开府分曹,设置僚属。隋唐始仿《周官》,以太师、太傅、太保为三公,少师、少傅、少保为三孤,都不设官属。则真成一个虚名,于财政亦无所耗费了。九卿一类的官,以性质论,实在和六部重复的。然历代都相沿,未曾并废。御史大夫改为司空后,御史的机关仍在。其官且有增置。唐时分为三院:曰台院,侍御史属之。曰殿院,殿中侍御史属之。曰监院,监察御史属之。御史为天子耳目,历代专制君主,都要防臣下的壅蔽,所以其权日重。\n前汉的改刺史为州牧,为时甚暂。至后汉末年,情形就大不同了。后汉的改刺史为州牧,事在灵帝中平五年,因四方叛乱频仍,刘焉说由刺史望轻而起。普通的议论,都说自此以后,外权就重了。其实亦不尽然。在当时,并未将刺史尽行改作州牧(大抵资深者为牧,资浅者仍为刺史,亦有由刺史而升为牧的)。然无论其为刺史,为州牧,实际上都变成了郡的上级官,而非复监察之职。而且都有兵权,如此,自然要尾大不掉了。三国分离,刺史握兵之制,迄未尝改。其为乱源,在当时是人人知道的。所以晋武帝平吴后,立即罢州牧,省刺史的兵,去其行政之权,复还监察之职。这真是久安长治之规。惜乎“虽有其言,不卒其事。”(《续汉书·百官志》《注》语)。而后世论者,转以晋武帝的罢州郡兵备,为召乱的根源,真是徇名而不察其实了。东晋以后,五胡扰乱,人民到处流离播迁,这时候的政治,还是带有属人主义的。于是随处侨置州郡,州的疆域,遂愈缩愈小,浸至与郡无异了(汉朝只有十三州,梁朝的疆域,远小于汉,倒有一百零七州)。此时外权之重,则有所谓都督军事,有以一人而督数州的,亦有以一人而督十数州的。甚至有称都督中外诸军的。晋南北朝,都是如此。后周则称为总管。隋时,并州郡为一级(文帝开皇三年,罢郡,以州统县,职同郡守。炀帝改州为郡),并罢都督府。唐初,又有大总管、总管,后改称大都督、都督,后又罢之。分天下为若干道,设观察使等官,还于监察之旧。\n唐代的官制,乃系就东汉、魏、晋、南北朝的制度,整理而成的。其实未必尽合当时的时势。所以定制未几,变迁又起。三省长官,都不除人。但就他官加一同中书门下平章事等名目,就视为相职了。而此两省的长官,实亦仍合议于政事堂,并非事后审查封驳。都督虽经废去,然中叶以后,又有所谓节度使(参看第四十五章),所驻扎的地方,刺史多由其兼领。支郡的刺史,亦都被其压迫而失职。其专横,反较前代的刺史更甚。这两端,是变迁最大的。而中叶以后,立检校、试、摄、判、知等名目,用人多不依资格,又为宋朝以差遣治事的根源。\n宋朝设中书省于禁中。宰相称同平章事,次相称参知政事。自唐中叶以后,户部不能尽总天下的财赋,分属于度支、盐铁二使。宋朝即合户部、度支、盐铁为三司,各设使、副,分案办事。又设三司使副以总之,号为计相。枢密使,唐时以宦官为之,本主传达诏命。后因宦官握兵,遂变为参与兵谋之官。宋朝亦以枢密院主兵谋。指挥使,本藩镇手下的军官。梁太祖篡位后,未加改革,遂成天子亲军。宋朝的禁军,都隶属殿前司、侍卫马军亲军司、侍卫步军亲军司。各设指挥使,谓之三衙。宋初的官,仅以寓禄秩(即借以表明其官有多大,所食的俸禄有多少),而别以差遣治事。名为某官的人,该官的职守,都是与他无涉的。从表面上看来,可谓错乱已极。但差遣的存废、离合,都较官缺为自由,可以密合事情。所以康有为所著《官制议》,有《宋官制最善》一篇,极称道其制。宋朝的改革官制,事在神宗元丰中,以《唐六典》为模范,然卒不能尽行。以三省长官为相职之制,屡经变迁,卒仍复于一个同平章事,一个参知政事之旧;枢密主兵之制,本来未能革除;三衙之制,亦未能改,便可见其一斑。\n宋初惩藩镇的跋扈,悉召诸节镇入朝,赐第留之京师,而命朝臣出守列郡,谓之权知军州事。特设通判,以分其权。县令亦命京朝官出知,以削藩镇之权,而重亲民之选。特设的使官最多。其重要的,如转运使,总一路的财赋;发运使,漕淮、浙、江、湖六路之粟。他如常平茶盐、茶马、坑冶、市舶,亦都设立提举司,以集事权于中央。太宗命诸路转运使,各命常参官一人,纠察州军刑狱。真宗时,遂独立为一司,称为提点刑狱,简称提刑。是为司法事务,设司监察之始。南渡后,四川有总领财赋。三宣抚司罢后(见第四十五章),亦设总领以筹其饷。仍带专一报发御前军马文字衔,则参预并及于军政了。\n元朝以中书省为相职,枢密使主兵谋,御史台司纠察。尚书省之设,专以位置言利之臣。言利之臣败,省亦旋废。而六部仍存,为明清两朝制度所本。设宣政院于中央,以辖吐蕃之境,亦为清代理藩院之制所本。元代制度,关系最大的是行省。前代的尚书行台等,都是暂设的,以应付临时之事,事定即撤。元朝却于中原之地,设行中书省十,行御史台二,以统辖路府州县。明朝虽废之而设布政、按察两司,区域则仍元之旧。清朝又仍明之旧。虽然略有分析,还是庞大无伦,遂开施政粗疏,尾大不掉之渐了。唐初,惟京兆、河南称府设尹,后来梁州以为德宗所巡幸,亦升为兴元府。宋朝则大州皆升为府,几有无州不府之势。其监司所辖的区域则称为路。元于各路设宣慰司,以领府州县而上属于省。然府亦有不隶路而直隶于省的。州有隶于府的,亦有不隶于府,而直隶于路的,其制度殊为错杂。\n明清两朝的制度,大体相沿。其中关系最大的,在内为宰相的废罢,在外为省制的形成。明初本亦设中书省,以为相职。后因胡惟庸谋反,大祖遂废其官,并谕后世子孙,不得议设宰相。臣下有请设宰相的,处以极刑。于是由天子亲领六部。此非嗣世之主所能,其权遂渐入殿阁学士之手。清世宗时,又设立军机处。机要之事,均由军机处径行,事后才下内阁,内阁就渐渐的疏阔了。六部:历代皆以尚书为主,侍郎为副。清代尚侍皆满汉并置。吏、户、兵三部,又有管部大臣,以至权责不一。明废宰相后,政务本由六部直接处理。后虽见压于内阁,究竟权力还在。吏、兵二部,尤真有用人及指挥军事之权,清朝则内官五品,外官道府以上,全由内阁主持。筹边之权,全在军机。又明朝六部用人,多取少年新进,清朝则一循资格,内官迁转极难,非六七十不得至尚侍。管部又系兼差,不能负责。于是事事照例敷衍,行政全无生气了。\n御史一官,至明代而其权益重,改名为都察院。都御史、副都御史、佥都御史均分置左、右。又有分道的监察御史。在外则巡按清军、提督学校、巡漕、巡盐等事,一以委之,而巡按御史代天子巡狩,其权尤重。这即是汉朝刺史之职。既有巡按,本可不必再行遣使。即或有特别事务,非遣使不可,亦以少为佳。然后来所谓巡抚者,愈遣而愈频繁。因其与巡按御史不相统属,权限不免冲突,乃派都御史为之。其兼军务的加提督衔,辖多事重的,则称总督。清代总督均兼兵部尚书,右都御史、巡抚均兼兵部侍郎,右副都御史,又均有提督军务,兼理粮饷之衔,成为常设的官了。给事中一官,前代都隶门下省。明废门下省,而仍存给事中,独立为一官,分吏、户、礼、兵、刑、工六科,以司审查封驳。其所驳正,谓之科参,在明代是很有权威的,清世宗将给事中隶属于都察院,就将审查和纠察,混为一谈了。翰林在唐朝,为艺能之士如(书、画、弈棋等)待诏之所,称为杂流,与学士资望悬绝,玄宗时,命文学之士居翰林中,称为供奉。与集贤殿学士,分掌制诰。后改称为学士,别立学士院,即以翰林名之。中叶后颇参机密,王叔文要除宦官,即居翰林中,可见其地位的重要。宋代专以居文学之士,其望愈清。至明中叶后,则非进士不入翰林,非翰林不入内阁,六部长官,亦多自此而出。其重要,更非前代所及了。\n外官:明废行省,于府州之上,设布政、按察两司,分理民政及刑事,实仍为监司之官。监司之官,侵夺地方官权限,本来在所不免。清代督抚既成为常设之官,又明代布政司的参政参议,分守各道,按察司的副使佥事,分巡各道的,至清朝,亦失其本来的性质,而在司府之间,俨若别成为一级。以府州领县,为唐宋相沿之制。元时,令知州兼理附郭县事,明时遂省县入州,于是州无附郭县。又有不领县而隶属于府的,遂有直隶州与散州之别。清时,同知、通判有驻地的谓之厅,亦或属于府,或直达布政司,称为散厅及直隶厅。地方制度,既极错杂。而(一)督抚,(二)司,(三)道,(四)府、直隶州、厅,(五)县、散州、厅,实际成为五级。上级的威权愈大,下级的展布愈难。积弊之深,和末造中央威权的不振,虽有别种原因,官制的不善,是不能不尸其咎的。\n藩属之地,历代都不设官治理其民,而只设官监督其酋长,清朝还是如此的。奉天、吉林、黑龙江三省,清朝称为发祥之地。其实真属于满洲部落的,不过兴京一隅。此外奉天全省,即前代的辽东、西,本系中国之地。吉、黑二省,亦是分属许多部落的,并非满洲所有。此等人民,尚在部落时代,自不能治以郡县制度。清朝又立意封锁东三省,不许汉人移殖。所以其治理之法,不但不能进步,而反有趋于退步之势。奉天一省,只有奉天和锦州二府,其余均治以将军、副都统等军职。蒙古、新疆、西藏,亦都治以驻防之官。这个固然历代都是如此,然清朝适当西力东侵之时,就要情见势绌了。末年回乱平后,改新疆为行省。日俄战后,改东三省为行省。蒙古、西藏,亦图改省,而未能成功。藩属之地,骤图改省,是不易办到的。不但该地方的人民,感觉不安。即使侥幸成功,中国亦无治理其地的人才。蒙、藏的情形,和新疆、东三省是不同的。东三省汉人已占多数,新疆汉人亦较多,蒙、藏则异于是。自清末至民国初年,最好是将联邦之法,推行之于蒙、藏,中央操外交、军事、交通、币制之权,余则听其自治。清季既不审外藩情势,和内地的不同,操之过急,以致激而生变。民国初年,又不能改弦易辙,许其自治,以生其回面内向之心,杜绝强邻的觊觎。因循既久,收拾愈难,这真是贾生所说,可为痛哭、流涕、长太息的了。\n以上是中国的旧官制,中西交通以来,自然不能没有变动。其首先设立的,是总理各国事务衙门。实因咸丰八年,中英《天津条约》规定要就大学士、尚书中简定一员,和英国使臣接洽而起,不过迫于无可如何,并非有意改革。内乱平后,意欲振兴海军,乃设立海军衙门。后来却将其经费,移以修理颐和园,于是中日战后,海军衙门反而裁撤了。庚子以后,又因条约,改总理衙门为外务部,班列六部之前。其时举办新政,随事设立了许多部处。立宪议起,改革旧官制,增设新机关,共成外务、吏、民政(新设的巡警部改)、度支(户部改。新设的财政处、税务处并入)、礼(太常、光禄、鸿胪三寺并入)、学(新设的学务处改,国子监并入)、陆军(兵部改,太仆寺和新设的练兵处并入)、农工商(工部改,新设的商部并入)、邮传、理藩(理藩院改)、法(刑部改)十一部,除外务部有管理事务大臣、会办大臣各一人外,余均设尚书一人、侍郎二人,不分满汉。都察院亦改设都御史一人、副都御史二人(前此左都御史,满汉各一。左副都御史各二。右都御史、副都御史但为督抚兼衔)。大理寺改为院,以司最高审判。宣统二年,立责任内阁,设总协理大臣。裁军机处及新设的政务处及吏、礼二部(其事务并入内阁),而增设海军部及军谘府(今之参谋部)。改尚书为大臣,与总协理负连带责任。外官则仍以督抚为长官。于其下设布政、提法(按察司改)、提学、盐运、交涉五司,劝业、巡警二道,而裁分巡、分守道。此等制度,行之为日甚浅,初无功过可言。若从理论上评论:内官增设新官,将旧官删除归并,在行政系统上,自然较为分明,于事实亦较适切。若论外官,则清末之所以尾大不掉,行政粗疏,其症结实在于省制。当时论者,亦多加以攻击。然竟未能改革,相沿以迄于今,这一点不改革,就全部官制,都没有更新的精神了。\n民国成立,《临时政府组织大纲》定行政分五部,为外交、内务、财政、军务、交通。这是根据理论规定的,后修改此条。设陆军、海军、外交、司法、财政、内务、教育、实业、交通九部。其时采美国制,不设总理。孙文逊位后,袁世凯就职北京,《临时政府组织大纲》改为《临时约法》,设总理,分实业为农林、工商二部。三年,袁世凯召开约法会议修改《临时约法》为《中华民国约法》(即所谓《新约法》)。复废总理,设国务卿,并农林、工商二部为农商部。袁世凯死后,黎元洪为总统,复设总理。外官:民军起义时,掌握一省军权的称都督。管理民政的称民政长。废司,道,府,直隶州、厅及散州、厅的名称,但存县。袁世凯改都督为将军,民政长为巡按使,于其下设道尹。护国军起,掌军权的人,复称都督。黎元洪为总统,改将军、都督都称督军,巡按使称省长。其兼握几省兵权,或所管之地,跨及数省的,则称巡阅使。裁兵议起,又改称督理或督办军务善后事宜,然其尾大不掉如故。国民党秉政,在训政时期内,以党代人民行使政权,而以国民政府行使治权。其根本精神,和历代的官制,大不相同,其事又当别论。\n无官之名,而许多行政事务,实在倚以办理的为吏。凡行政,必须依照一定的手续。因此职司行政的人,必须有一定的技术。这种技术,高级官员往往不甚娴习,甚或不能彻底通晓,非有受过教育,经过实习的专门人员以辅助之不可。此等责任,从前即落在胥吏肩上。所以行政之权,亦有一部分操于其手。失去了他,事情即将无从进行的。吏之弊,在于只知照例。照例就是依旧,于是凡事都无革新的精神。照例的意思,在于但求无过,于是凡事都只重形式,而不问实际。甚至利用其专门智识以舞弊。所以历来论政的人,无不深恶痛绝于吏,尤以前清时代为甚,然其论亦有所蔽。因为非常之事,固然紧要,寻常政务,实更为紧要而不可一日停滞。专重形式,诚然不好,然设形式上的统一不能保持,政治必将大乱。此前清末年,所以诏裁胥吏,而卒不能行。其实从前所谓吏,即现在所谓公务员,其职实极重要,而其人亦实不能缺。从前制度的不善,在于(一)视其人太低,于是其人不思进取,亦不求名誉,而惟利是图。(二)又其人太无学识,所以只能办极呆板的事。公务员固以技术为要,然学识亦不可全无,必有相当的学识,然后对于所行之政,能够通知其原理,不至因过于呆板而反失原意。又行政的人,能通知政治的原理,则成法的缺点,必能被其发现。于立法的裨益,实非浅鲜。昔时之胥吏,是断不足以语此的。(三)其尤大的,则在于无任用之法,听其私相传授,交结把持。自民国以来,因为政治之革新,法律的亟变,已非复旧时的胥吏所能通晓,所以其人渐归自然淘汰,然现在公务员的任用、考核,亦尚未尽合法,这是行政的基础部分,断不可不力求改良的。\n古代官职的大小,是以朝位和命数来决定的。所谓命数,就是车服之类的殊异。古人所以看得此等区别,甚为严重。然因封建制度的破坏,此等区别,终于不能维持了。朝位和俸禄的多少,虽可分别高低,终嫌其不甚明显,于是有官品之别。官品起于南北朝以来。南朝陈分九品。北朝魏则九品之中,复分正从;四品以下,且有上中下阶,较为复杂。宋以后乃专以九品分正从。官品之外,封爵仍在。又有勋官、散官等,以处闲散无事的官员。此等乃国家酬庸之典,和官品的作用,各不相同的。\n官俸,历代更厚薄不同,而要以近代之薄为最甚。古代大夫以上,各有封地。家之贫富,视其封地之大小、善恶,与官职的高下无关。无封地的,给之禄以代耕,是即所谓官俸。古代官俸,多用谷物,货币盛行以后,则钱谷并给。又有实物之给,又有给以公田的。明初尚有此制,不知何时废坠,专以银为官俸。而银价折合甚高,清朝又沿袭其制,于是官吏多苦贫穷。内官如部曹等,靠印结等费以自活,外官则靠火耗及陋规。上级官不亲民的,则诛求于下属。京官又靠外官的馈赠。总而言之,都是非法。然以近代官俸之薄,非此断无以自给的。而有等机关,收取此等非法的款项,实亦以其一部分支给行政费用,并非全入私囊。所以官俸的问题,极为复杂。清世宗时,曾因官俸之薄,加给养廉银,然仍不足支持。现代的官俸,较之清代,已稍觉其厚。然究尚失之于薄,而下级的公务员尤甚。又司法界的俸禄,较之行政界,不免相形见绌,这亦是亟须加以注意的。\n第四十三章 选举 # 国家,因为要达其目的,设立许多机关,这许多机关,都是要有人主持的。主持这些机关的人,用何法取得呢?这便是选举问题。\n选举是和世袭对立的。世袭之法,一个位置出缺,便有一个合法继承的人,不容加以选择。选举之法则不然,它是毫无限制,可以任有选举权者,选举最适宜的人去担任的。这是就纯粹的选举和世袭说;亦有从两方面说,都不很纯粹的,如虽可选择,仍限于某一些人之内之类是。但即使是不纯粹的选举,也总比纯粹的世袭好些。西洋某史家曾把中国两汉时代的历史,和罗马相比较,他说:凡罗马衰亡的原因,中国都有的。却有一件事,为中国所有,罗马所无,那便是选举。观此,便知选举制度关系之重大了。\n选举制度,在三代以前,是与世袭并行的。俞正燮《癸巳类稿》,有一篇《乡兴贤能论》,说得最好,他说:古代的选举,是限于士以下的,大夫以上是世官。这是什么理由呢?第四十章已经说过:原始的政治,总是民主的,到后来,专制政治,才渐渐兴起,如其一个国家是以征服之族和被征服之族组成的,高级的位置自然不容被征服之族染指。即使原是一族,而专制政治既兴,掌握政权的人,也就渐渐的和群众离开了。所以选举仅限于士以下。\n士以下的选举,乃系古代部族,专制政治尚未兴起时的制度,留遗下来的。其遗迹略见于《周官》。据《周官》所载:凡是乡大夫的属官,都有考察其民德行道艺之责。三年大比,则举出其贤者能者,“献贤能之书于王”。《周官》说:“此之谓使民兴贤,入使治之;使民兴能,出使长之。”俞正燮说:入使治之,是用为乡吏(即比闾族党之长,见上章);出使长之,是用为伍长,这是不错的。比闾族党等,当系民主部族固有的组织,其首领,都是由大众公举的。专制政体兴起后,只是把一个强有力的组织,加于其上,而于此等团体固有的组织,并未加以破坏,所以其首领还是出于公举的,不过专制的政府,也要加以相当的参预干涉罢了(如虽由地方公举,然仍须献贤能之书于王)。\n在封建政体的初期,上级的君大夫等,其品性,或者比较优良,但到后来,就渐渐的腐化了。由于上级的腐化,和下级的进步(参看第四十章),主持国政者,为求政治整饬起见,不得不逐渐引用下级分子,乡间的贤能,渐有升用于朝廷的机会,那便是《礼记·王制》所说的制度。据《王制》说:是乡论秀士,升诸司徒,曰选士。司徒论选士之秀者,而升诸学,曰俊士。既升于学,则称造士。大乐正论造士之秀者,以告于王,而升诸司马,曰进士。司马辨论官材(官指各种机关,谓分别其材能,适宜于在何种机关中办事),论进士之贤者,以告于王,然后因其材而用之。按《周官》司士,掌群臣之版(名籍),以治其政令,岁登下其损益之数,也是司马的属官。《礼记·射义》说:古者“诸侯贡士于天子,天子试之于射宫。其容体比于礼,其节比于乐,而中多者,得与于祭。其容体不比于礼,其节不比于乐,而中少者,不得与于祭。”以中之多少,定得与于祭与否,可见射宫即在太庙之中。古代规制简陋,全国之中,只有一所讲究的屋子,谓之明堂。也就是宗庙,就是朝廷,就是君主所居的宫殿,而亦即是其讲学的学校,到后来,这许多机关才逐渐分离,而成为各别的建筑(详见第五十一章)。合观《周官》、《王制》、《射义》之文,可知在古代,各地方的贡士,是专讲武艺的。到后来,文治渐渐兴起,于是所取的人才,才不限于一途(所以司马要辨论官材,此时的司马,乃以武职兼司选举,并非以武事做选举的标准了)。此为选举之逐渐扩大,亦即世袭之渐被侵蚀。\n到战国之世,世变益亟,腐败的贵族,再也支持不了此刻的政治。而且古代的贵族,其地位,是与君主相逼的,起于孤寒之士则不然,君主要整顿政治,扩充自己的权力,都不得不用游士。而士人,也有怀抱利器,欲奋志于功名的。又有蒿目时艰,欲有所藉手,以救生民于涂炭的。于是君主和游士相合,以打击贵族,贵族中较有为的,亦不得不引用游士。选举之局益盛,世袭之制愈微。然这时候,游士还是要靠上级的人引用的。到秦末,豪杰起而亡秦,则政权全入下级社会之手,更无所谓贵族和游士的对立了。此为汉初布衣将相之局(《廿二史札记》有此一条,可参看),在此情势之下,用人自然不拘门第,世袭之局,乃于此告终。\n汉以后,选举之途,重要的,大概如下所述:\n(一)征召:这是天子仰慕某人的才德,特地指名,请他到京的。往往有聘礼等很恭敬的手续。\n(二)辟举:汉世相府等机关,僚属多由自用,谓之辟。所辟的人,并无一定的资格,做过高官的人,以至布衣均可。\n(三)荐举:其途甚广。做官的人,对于自己手下的属员,或虽未试用,而深知其可用的人,都可以荐举。就是不做官的布衣,深知什么人好,也未始不可以上书荐举的,并可上书求自试。此等在法律上都毫无制限,不过事实上甚少罢了。\n(四)吏员:此系先在各机关中服务,或因法律的规定,或由长官的保荐,由吏而变做官的。各机关中的吏,照法律上讲,都可以有出路。但其出路的好坏,是各时代不同的。大体古代优而后世劣。\n(五)任子:做到某级官吏,或由在上者的特恩,可以保荐他的儿子,得一个出身,在汉世谓之任子(亦可推及孙、弟、兄弟之子孙等)。任的本义为保,但其实,不过是一种恩典罢了,被保者设或犯罪,保之者,未必负何等责任的。任在后世谓之荫。明以后,又有荫子入监之例,即使其入国子监读书。国家既可施恩,又不令不学无术的人滥竽充选,立法之意,是很好的。惜乎入监读书,徒有其名罢了。\n(六)专门技术人员:此等人员,其迁转,是限于一途的。其技术,或由自习而国家擢用,或即在本机关中养成。如天文、历法、医学等官是(此制起源甚古。《王制》:“凡执技以事上者,不贰事,不移官”,即是)。\n(七)捐纳:这即是出钱买官做。古书中或称此为赀选,其实是不对的。赀选见《汉书·景帝本纪》后二年,乃因怕吏的贪赃,假定有钱的人,总要少贪些,于是限定有家赀若干,乃得为吏。这只是为吏的一个条件,与出钱买官做,全然无涉。又爵只是一个空名,所以卖爵也不能算做卖官的。暗中的卖官鬻爵,只是腐败的政治,并非法律所许,亦不能算做选举的一途(历代卖官之事见后)。\n以上都是入官之途。但就历代立法者的意思看起来,这些都只能得通常之才,其希望得非常之材的,则还在\n(八)学校\n(九)科举\n两途。学校别于第五十一章中详之。科举又可分为(甲)乡贡,(乙)制科。乡贡是导源于汉代的郡国选举的。以人口为比例,由守相岁举若干人。制科,则汉代往往下诏,标出一个科名,如贤良方正、直言极谏等类,令内外官吏荐举(何等官吏,有选举之权,亦无一定,由诏书临时指定),其科目并无限制。举行与否,亦无一定。到唐代,才特立制科之名。\n汉代的用人,是没有什么阶级之见的。唐柳芳论氏族,所谓“先王公卿之胄,才则用,不才弃之”(见《唐书·柳冲传》),但是(一)贵族的势力,本来潜伏着;(二)而是时的选举,弊窦又甚多,遂至激成九品中正之制,使贵族在选举上,气焰复张。这时候选举上的弊窦如何呢?自其表面言之,则(甲)如贵人的请托。如《后汉书·种暠传》说:河南尹田歆,外甥王谌名知人。歆谓之曰:“今当举六孝廉,多得贵戚书令,不宜相违。欲自用一名士,以报国家,尔助我求之。”便可见当时风纪之坏。然(乙)贵人的请托,实缘于士人的奔走。看《潜夫论》(《务本》、《论荣》、《贤难》、《考绩》、《本政》、《潜叹》、《实贡》、《交际》等篇)、《申鉴》(《时事》)、《中论》(《考伪》、《谴交》)、《抱朴子》(《审举》、《交际》、《名实》、《汉过》)诸书可知。汉代士人的出路,是或被征辟,或被郡县署用,或由公卿郡国举荐,但此等安坐不易得之。于是或矫激以立名;或则结为徒党,互相标榜,奔走运动。因其徒党众多,亦自成为一种势力,做官的人,也有些惧怕他;在积极方面,又结交之以谋进取。于是有荒废了政事,去酬应他们的。又有丰其饮食居处,厚其送迎,以敷衍他们的,官方因之大坏。究之人多缺少,奔走运动的人,还是有得有不得。有些人,因为白首无成,反把家资耗废了,无颜回家,遂至客死于外。这实在不成事体,实有制止他们在外浮游的必要。又因当时的选举,是注重品行的,而品行必须在本乡才看得出,于是举士必由乡里,而九品中正之制以生。\n九品中正之制,起于曹魏的吏部尚书陈群。于各州置大中正,各郡置中正。依据品行,将所管人物,分为上上、上中、上下、中上、中中、中下、下上、下中、下下九等。这是因历来论人,重视乡评,所以政治上有此措置。但(一)乡评的所谓好人,乃社会上的好人,只须有德,政治上所用的人,则兼须有才。所以做中正的人,即使个个都能秉公,他所以为好的人,也未必宜于政治。(二)何况做中正的人,未必都能公正,(甲)徇爱憎,(乙)快恩仇,(丙)慑势,(丁)畏祸等弊,不免继之而起呢?其结果,就酿成晋初刘毅所说的,“惟能知其阀阅,非复辨其贤愚”,以致“上品无寒门,下品无世族”了。因为世族是地方上有势力之家,不好得罪他,至于寒门,则是自安于卑贱的,得罪了他,亦不要紧。这是以本地人公开批评本地的人物,势必如此而后已的。九品中正,大家都知道是一种坏的制度。然直至隋文帝开皇年间才罢。前后历三百四五十年。这制度,是门阀阶级造成的,而其维持门阀阶级之力亦极大,因为有些制度后,无论在中央政府和地方政府,世族和寒门的进用,都绝对不同了(如后魏之制,士人品第有九,九品以外,小人之官,复有七等。又如蔡兴宗守会稽郡,举孔仲智子为望计,贾原平子为望孝。仲智高门,原平一邦至行,遂与相敌,当时亦以为异数)。\n九品中正之制既废,科举就渐渐的兴起了。科举之制,在取士上,是比较公平的、切实的,这是人人所承认的,为什么兴起如此之晚呢?用人的条件,第一是德,第二是才,第三才数到学识。这是理论上当然的结果,事实上也无人怀疑。考试之所觇,只是学识。这不是说才德可以不论,不过明知才德无从考校,与其因才德之无从考校,并其学识的试验而豁免之,尚不如就其学识而试验之,到底还有几分把握罢了。这种见解,是要积相当经验,才会有的。所以考试之制,必至唐宋之世,才会兴盛。考试之制,其起源是颇远的。西汉以前本无所谓考试(晁错、董仲舒等的对策,乃系以其人为有学问而请教之,并非疑其意存冒滥,加以考试。所以策否并无一定,一策意有未尽,可以至于再策三策,说见《文献通考》)。直至东汉顺帝之世,郡国所举的人,实在太不成话了。左雄为尚书令,乃建议“诸生试家法,文吏试笺奏”(家法,指所习的经学言),史称自是牧守莫敢轻举,察选清平,就可见得考试的效验了。但是自此以后,其法未曾认真推行。历魏晋南北朝至隋,仍以不试为原则。科举之制兴于唐,其科目甚多(秀才系最高科目,高宗永徽二年后停止。此外尚有俊士、明法、明字、明算、一史、三史、开元礼、道举、童子等科,均见《唐书·选举志》),常行的为明经和进士。进士科是始于隋的,其起源,历史记载,不甚清楚。据杨绾说:其初尚系试策,不知什么时候,改试了诗赋。到唐朝,此科的声光大好。这是社会上崇尚文辞的风气所造成的。唐时,进士科虽亦兼试经义及策,然所重的是诗赋。明经所重的是帖经、墨义。诗赋固然与政治无涉,经学在政治上,有用与否,自今日观之,亦成疑问。这话对从前的人,自然是无从说起,但像帖经、墨义所考的只是记诵(帖经、墨义之式,略见《文献通考》。其意,帖经是责人默写经文,墨义则责人默写传注,和今学校中专责背诵教科书的考试法一般),其无用,即在当日,亦是显而易见的。为什么会有这种奇异的考试法呢?这是因为把科举看做抡才大典,换言之,即在官吏登庸法上,看做惟一拔取人才之途,怕还是宋以后的事,在唐以前,至多只是取才的一途罢了。所以当时的进士,虽受俗人看重,然在政治上,则所取的人并不多,而其用之亦不重(唐时所取进士,不过二三十人,仍须应吏部释褐试,或被人荐举,方得入官,授官亦不过丞尉;见《日知录·中式额数》、《出身授官》两条)。可见科举初兴,不过沿前代之法而渐变,并非有什么隆重的意思,深厚的期望,存乎其间了。所以所试的不过是诗赋和帖经、墨义。帖经、墨义所试,大约是当时治经的成法,诗赋疑沿自隋朝。隋炀帝本好辞华,所设的进士科,或者不过是后汉灵帝的鸿都门学之类(聚集一班会做辞赋和写字的人,其中并有流品极杂的,见《后汉书》本纪及《蔡邕传》)。进土科的进而为抡才之路,正和翰林的始居杂流,后来变成清要一样。这是制度本身的变化,不能执后事以论其初制的。科举所试之物,虽不足取,然其取士之法,则确是进步而可纪念的。唐制,愿应举者皆“怀牒自列于州县”。州县先试之,而后送省(尚书省)。初由户部“集阅”,考功员外郎试之。玄宗开元时,因考功员外郎望轻,士子不服,乃移其事于礼部。宋太祖时,知贡举的人,有以不公被诉的,太祖乃在殿廷上自行复试。自此省试之外,又有殿试。前此的郡国选举,其权全操于选举之人。明明有被选举之才,而选举不之及,其人固无如之何。到投牒自列之制兴,则凡来投牒者,即使都为州县所不喜,亦不得不加以考试,而于其中取出若干人;而州县所私爱的人,苟无应试的能力,即虽欲举之而不得。操选举之权者,大受限制,被选举之权,即因此而扩大。此后白屋之士,可以平步青云;有权的人,不能把持地位,都是受此制度之赐。所以说其制度是大可纪念的。考试的规则逐渐加严,亦是助成选举制度的公平的。唐时,考官和士子交通,还在所不禁。考官采取声誉,士子托人游扬,或竟自怀所作文字投谒,都不算犯法的事。晚唐以后,规则逐渐加严,禁怀挟和糊名易书等制度,逐渐兴起。明清继之,考试关防,日益严密。此似于人格有损,但利禄之途,应试者和试之者,都要作弊,事实上亦是不得不然的。\n以上所说的,均系乡贡之制。至于制科,则由天子亲策,其科目系随时标出。举行与否,亦无一定。唐代故事,详见《文献通考·选举考》中。\n对于科举的重视,宋甚于唐,所以改革之声,亦至宋而后起。科举之弊有二:(一)学非所用,(二)所试者系一日之短长。从经验上证明:无学者亦可弋获,真有学问者,或反见遗。对于第一弊,只须改变其所试之物即可。对于第二弊,则非兼重学校不行。不然,一个来应试的人,究曾从事于学问与否,是无从调查的。仁宗时范仲淹的改革,便针对着这两种弊窦:(一)罢帖经、墨义,而将诗赋策论通考为去取(唐朝的进士,亦兼试帖经及策,明经亦兼试策,但人之才力有限,总只能专精一门,所以阅卷者亦只注重一种,其余的都不过敷衍了事。明清时代,应科举的人,只会做四书文,亦由于此)。(二)限定应试的人,必须在学三百日,曾经应试的人一百日。他的办法,很受时人反对,罢相未几其法即废。到神宗熙宁时,王安石为相,才大加以改革。安石之法:(一)罢诸科,独存进士。这是因社会上的风气,重进士而轻诸科起的。(二)进士罢试诗赋,改试论、策。其帖经、墨义,则改试大义(帖经专责记诵,大义是要说明义理,可以发抒意见的)。(三)别立新科明法,以待不能改业的士子。(四)安石是主张学校养士的,所以整顿太学,立三舍之法,以次递升。升至上舍生,则可免发解及礼部试,特赐之第。熙宁贡举法,亦为旧党所反对。他们的理由是:(一)诗赋声病易晓,策论汗漫难知,因此看卷子难了。这本不成理由。诗赋既是无用之学,即使去取公平,又有何益呢?(二)但他们又有如苏轼之说,谓以学问论,经义、策、论,似乎较诗赋为有用。以实际论,则诗赋与策、论、经义,同为无用。得人与否,全看君相有无知人之明。取士之法,如科举等,根本无甚关系,不过不能不有此一法罢了。这话也是不对的。科举诚不能皆得人,然立法之意,本不过说这是取士的一法,并没有说有此一法之后,任用时之衡鉴,任用后之考课,都可置诸不论。况且国家取士之途,别种都是注重经验的;或虽注重学识,而非常行之法,只有学校、科举,是培养、拔擢有学识的人的常法。有学识的人,固然未必就能办事,然办事需用学识的地方,究竟很多(大概应付人事,单靠学识无用,决定政策等,则全靠学识)。“人必先知其所事者为何事,然后有欲善其事之心”,所以学识和道德,亦有相当的关系。衡鉴之明,固然端赖君相,然君相决不能向全国人中,漫无标准,像淘沙般去觅取。终必先有一法,就全体之中,取出一部分人来,再于其中施以简择。此就全体之中取出其一部分人之法,惟有科举是注重学识的,如何能视之过轻?经义、策、论,固亦不过纸上空谈,然其与做官所需要的学识关系的疏密,岂能视之与诗赋同等?所以旧党的议论,其实是不通的。然在当时,既成为一种势力,即不能禁其不抬头。于是至元祐之世,而熙宁之法复废。熙宁贡举之法虽废,旧法却亦不能回复了。因为考试是从前读书人的出身之路,所试非其所习,习科举之业的人,是要反对的。熙宁变法时,反对者之多,其理由实亦在此。到元祐要回复旧法时,又有一班只习于新法的人,要加以反对了。于是折衷其间,分进士为诗赋、经义两科。南宋以后,遂成定制。连辽、金的制度,也受其影响(金诗赋、经义之外,又有律科。诗赋、经义称进士,律科称举人。又有女真进士科,则但试策论,系金世宗所立。辽、金科目,均须经过乡、府、省三试。省试由礼部主持,即明清的会试。元、明、清三代,都只有会试和本省的乡试)。\n近代科举之法,起于元而成于明。元代的科举,分蒙古、色目人和汉人、南人为两榜。蒙古、色目人考两场:首场经义,次场策论。汉人、南人考三场:首场经义,次场古赋和诏、诰、表,三场策论。这是(一)把经义、诗赋,并做一科了。(二)而诸经皆以宋人之说为主,以及(三)乡会试所试相同,亦皆为明清所沿袭。明制:首场试四书五经义,次场试论判,又于诏、诰、表内科一道,三场试策。清制:首场试四书义及诗一首,次场试五经义,三场亦试策。明清所试经义,其体裁是有一定的。(一)要代圣贤立言。(二)其文体系逐段相对,谓之八股(八股文体的性质,尽于此二语:(一)即文中的话不算自己所说,而算代圣贤说一篇较详尽的话。(二)则历来所谓对偶文字,系逐句相对,而此则系逐段相对,所以其体裁系特别的。又八股文长短亦有定限。在清代,是长不能过七百字,短不能不满三百字。此等规则,虽亦小有出入,但原则上是始终遵守的。因有(一)之条件,所以文中不能用后世事,这是清代学者,疏于史事的一个原因)。其式为明太祖及刘基所定,故亦谓之制义。其用意,大概是防士子之竞鹜新奇的(科举名额有定,而应试者多。如清末,江南乡试,连副贡取不满二百人,而应试者数逾二万。限于一定的题目,在几篇文字内,有学问者亦无所见其长。于是有将文字做得奇奇怪怪,以期动试官之目的,此弊在宋代已颇有)。明清时代科举之弊,在于士子只会做几篇四书义,其余全是敷衍了事,等于不试。士子遂至一物不知。此其弊,由于立法的未善。因为人之能力,总是有限的,一个人不过懂得一门两门。所以历代考试之法,无不分科,就其所习而试之。经义、诗赋的分科,就等于唐朝的明经进士。这二者,本来不易兼通。而自元以来,并二者为一。三场所试的策,绝无范围。所以元、明、清三朝的科举,若要实事求是,可说是无人能应。天下事,责人以其所不能为者,人将并其所能为者而亦不为,这是无可如何的事。明清科举致弊之原,即在于此。宋代改革科举之意,是废诗赋而存经义、策论,这个办法,被元、明、清三代的制度推翻了。其学校及科举并用之意,到明朝,却在形式上办到。明制,是非国子监生和府州县学生,不能应科举的(府州县学生应科举,是先须经过督学使者的试验的,谓之科考。科考录取的人,才得应乡试。但后来,除文字违式者外,大抵是无不录取的。非学生,明代间取一二,谓之“充场儒士”,其数极少)。所以《明史》谓其“学校储材,以待科举”。按科举所试,仅系一日之短长,故在事实上,并无学问,而年少气盛,善于作应试文字者,往往反易弋获,真有学问者反难。学校所授,无论如何浅近,苟使认真教学,学生终必在校肄习几年,必不能如科举之一时弋取。但课试等事,极易徒有其名,学问之事,亦即有名无实。毕业实毕年限之弊,实自古有之,并不自今日始。使二者相辅而行,确系一良好的制度,但制度是拗不过事实的。入学校应科举的人,其意既在于利禄,则学问仅系工具(所以从前应举的人,称应举所作文字为敲门砖),利禄才是目的。目的的达到,是愈速愈好的。(一)假使科举与学校并行,年少气盛的人,亦必愿应科举而不愿入学校。(二)况且应试所费,并来往程途计之,远者亦不过数月,平时仍可自谋生活,学校则不能然。所以士之贫者,亦能应科举而不能入学校。(三)何况学校出身,尚往往不及科举之美。职是故,明朝行学校储才以待科举之制后,就酿成这样的状况:(一)国子监是自有出身的,但其出身不如科举之美,则士之衰老无大志者都归之。(二)府州县学,既并无出身;住在学校里,又学不到什么,人家又何苦而来“坐学”?作教官的人,亦是以得禄为目的的。志既在于得禄,照经济学的原理讲,是要以最少的劳费,得最大的效果的。不教亦无碍于利禄,何苦而定要教人?于是府州县学,就全然有名无实了。明初对于国子监,看得极为隆重。所以后来虽然腐败,总还维持着一个不能全不到校的局面,到清朝,便几乎和府州县学一样了。\n制科在唐朝,名义上是极为隆重的。但因其非常行之典,所以对于社会的影响,不如乡贡的深刻。自宋以后,大抵用以拔取乡贡以外的人才,但所取者,亦不过长于辞章,或学问较博之士(设科本意,虽非如此,然事实上不过如此,看《宋史·选举志》可知)。清圣祖康熙十八年,高宗乾隆元年,曾两次举行博学鸿词科,其意还不过如此。德宗光绪二十五年,诏开经济特科,时值变法维新之际,颇有登用人才之意。政变以后,朝廷无复此意,直到二十九年,才就所举的人,加以考试,不过敷衍了事而已。\n科举在从前,实在是一种文官考试。所试的科目,理应切于做官之用。然而历代所试,都不是如此的。这真是一件极奇怪的事。要明白此弊之由来,则非略知历史上此制度的发展不可。古代的用人,本来只求有做官的智识技能(此智识两字,指循例办公的智识言,等于后世的幕友胥吏,不该括广泛的智识),别无所谓学问的。后来社会进化了,知道政治上的措置,必须通知原理,并非循例办事而已足。于是学问开始影响政治,讲学问的人,亦即搀入政治界中。秦朝的禁“以古非今”,只许学习“当代法令”,“欲学法令,以吏为师”,是和此趋势相反的。汉朝的任用儒生,则顺此趋势而行。这自然是一种进步。但既知此,则宜令做官的人,兼通学问,不应将做官的人,与学问之士,分为两途,同时并用。然汉朝却始终如此。只要看当时的议论,总是以儒生、文吏并举,便可知道。《续汉书·百官志》《注》引应劭《汉官仪》,载后汉光武帝的诏书,说“丞相故事,四科取士:(一)曰德行高妙,志节清白。(二)曰学通行修,经中博士。(三)曰明达法令,足以决疑,能案章覆问,文中御史。(四)曰刚毅多略,遭事不惑,明足以决,才任三辅令”。第一种是德行,第四种是才能,都是无从以文字考试的。第二种即系儒生,第三种即系文吏。左雄考试之法,所试的亦系这两科。以后学者的议论,如《抱朴子》的《审举篇》,极力主张考试制度,亦说律令可用试经之法试之。国家的制度,则唐时明法仍与明经并行,所沿袭的还系汉制。历千年而不知改变,已足惊奇。其后因流俗轻视诸科,把诸科概行废去,明法一科,亦随之而废,当官所需用的智识技能,在文官考试中,遂至全然不占地位。(一)政治上的制度,既难于改变;(二)而迂儒又有一种见解,以为只要经学通了,便一切事情,都可对付,法令等实用不着肄习,遂益使此制度固定了。历史上有许多制度,凭空揣度,是无从明白其所以然的。非考其事实,观其变迁不可。科举制度,只是其一端罢了。\n近代的科举制度,实成于明太祖之手。然太祖并非重视科举的人。太祖所最重的是荐举,次之则是学校。当时曾令内外大小臣工,皆得荐举,被荐而至的,又令其转荐,由布衣至大僚的,不可胜数。国子监中,优礼名师,规则极严,待诸生亦极厚,曾于一日之中,擢六十四人为布、按两司官。科举初设于洪武三年,旋复停办,至十五年乃复设。当时所谓三途并用,系指(一)荐举,(二)进士贡监,(三)吏员(见《日知录·通经为吏》条)。一再传后,荐举遂废,学校浸轻,而科举独重。此由荐举用人,近于破格,非中主所能行。学校办理不能认真,近于今所谓毕业即毕年限。\n●明清科举考试层级\n科举(一)者为习惯所重,(二)则究尚有一日之短长可凭,所以为社会所重视。此亦不能谓绝无理由。然凡事偏重即有弊,何况科举之本身,本无足取呢?明制:进士分为三甲。一甲三人,赐进士及第。二甲若干人,赐进士出身。三甲若干人,赐同进士出身。一甲第一人,授翰林院修撰。第二、第三人授编修。二、三甲均得选庶吉士。庶吉士本系进士观政在翰林院、承敕监等衙门者之称。明初,国子监学生,派至各衙门实习的,谓之历事。进士派至各衙门实习的,谓之观政。使其于学理之外,再经验实事,意本甚善。然后亦成为具文。庶吉士初本不专属翰林。成祖时,命于进士二甲以下,择取文理优者,为翰林院庶吉士,自此才为翰林所专。后复命就学文渊阁。选翰(翰林院)、詹(詹事府)官教习。三年学成,考试授官,谓之散馆。出身特为优异。清制:二、三甲进士,亦得考选庶吉士。其肄业之地,谓之庶常馆。选满汉学士各一人教习,视为储才之地。然其所习者,不过诗赋、小楷而已。乡举在宋朝还不过是会试之阶,并不能直接入官。明世始亦为入仕之途。举贡既特异于杂流,进士又特异于举贡。所谓三途并用者,遂成(一)进士,(二)举贡,(三)吏员(见《明史·选举志》)。在仕途中,举贡亦备遭轻视排挤,杂流更不必论了。清制以科目、贡监、荫生为正途,荐举、捐纳、吏员为异途,异途之受歧视亦殊甚。然及末造,捐纳大行,仕途拥挤,亦虽欲歧视而不可得了。\n卖官之制,起于汉武帝。《史记·平准书》所谓“入羊为郎”、“入财者得补郎”、“吏得入谷补官”、买武功爵者试补吏皆是。后世虽有秕政,然不为法令。明有纳粟入监之例,亦仍须入监读书。清则仅存虚名。实官捐,顺康时已屡开,嘉道后尤数,内官自郎中,外官自道府而下,皆可报捐。直至光绪二十七年才停,从学校、科举、吏员等出身之士,虽不必有学识,究不容一物不知,捐纳则更无制限,而其数又特多。既系出资买来,自然视同营业。清季仕途人员的拥塞,流品的冗杂,贪污的特盛,实在和捐纳之制,是大有关系的。\n元代各机关长官,多用蒙古人。清朝则官缺分为满、汉、包衣、汉军、蒙古,这实在是一种等级制度(已见第四十章)。满缺有一部分是专属于宗室的,其选举权在宗人府;包衣属内务府,均不属吏部。\n以上所说,大略都是取士之制,即从许多人民中,拔擢出一部分人来,给他以做官的资格。其就已有做官资格的人,再加选试,而授之以官,则普通称为“铨选”。其事于古当属司马,说已见前。汉朝,凡有做官的资格,而还未授官的,皆拜为郎,属于光禄勋,分属五官中郎将、左中郎将、右中郎将,谓之三署郎。光禄勋岁于其中举茂材四行。其选授之权,初属三公府,东西曹主其事。后来尚书的吏曹,渐起而攘夺其权。灵帝时,吕强上言:“旧典选举,委任三府。”“今但任尚书,或复敕用。”可见到后汉末,三公已不大能参预选举了。曹魏以后,既不设宰相,三公等官,亦不复参与政事,选权遂专归尚书。唐制:文选属于吏部,武选属于兵部。吏部选官,自六品以下,都先试其书、判,观其身、言。五品以上则不试,上其名于中书门下。宋初,选权分属中书、枢密及审官院,吏部惟注拟州县官。熙宁改制,才将选权还之吏部。神宗说古者文武不分途,不以文选属吏,武选属兵为然。于是文武选皆属吏部,由尚书、侍郎,分主其事。明清仍文选属吏,武选属兵。明代吏部颇有大权,高官及边任等,虽或由廷推,或由保举,然实多由吏部主其事。清代则内分于军机、内阁,外分于督、抚,吏部所司,真不过一吏之任而已。外官所用僚属,自南北朝以前,均由郡县长官,自行选用(其权属于功曹),所用多系本地人。隋文帝始废之,佐官皆由吏部选授。此与选法之重资格而轻衡鉴,同为一大变迁,而其原理是相同的,即不求有功,但求防弊。士大夫蔽于阶级意识,多以此等防弊之法为不然。然事实上,所谓官僚阶级,总是以自利为先,国事为后的。无以防之,势必至于泛滥不可收拾。所以防弊之法,论者虽不以为然,然事实上卒不能废,且只有日益严密。\n用人由用之者察度其才不才,谓之衡鉴。鉴是取譬于镜子,所以照见其好坏;衡则取喻于度量衡,所以定其程度的。用人若在某范围之中,用之者得以自由决定其取舍,不受何等法律的限制,则谓之有衡鉴之权。若事事须依成法办理,丝毫不能自由,即谓之依据资格。二者是正相反对的。资格用人,起于后魏崔亮的停年格,专以停解先后为断,是因胡灵后秉政,许武人入选,仕途拥挤,用此为手段,以资对付的。崔亮自己亦不以为然。北齐文襄帝做尚书时,就把它废掉了。唐开元时,裴光庭又创循资格。然自中叶以后,检校、试、摄、判、知之法大行,皆以资格不相当之人任事,遂开宋朝以差遣治事之端。明孙丕扬创掣签法。资格相同者,纳签于筒,在吏部堂上,由候选者亲掣(不到者由吏部堂官代掣)。当时亦系用以对付中人请托的(见于慎行《笔麈》),然其后卒不能废。大抵官吏可分为政务官和事务官。政务官以才识为重,自不能专论资格。事务官不过承上官之命,依据法律,执行政务。其事较少变化。用法能得法外意,虽是极好的事,然其事太无凭据,若都藉口学识,破弃资格,一定得才的希望少,徇私的弊窦多。所以破格用人,只可视为偶然之事,在常时必不能行,历来诋资格之论,都是凭臆为说,不察实际情形的。\n回避之法,亦是防弊的一端。此事古代亦无之。因为回避之法,不外两端:(一)系防止人与人间的关系,(二)则防止人与其所治的地方的关系。在世官制度之下,世家大族,左右总是姻亲;而地不过百里,东西南北,亦总系父母之邦,何从讲起回避?地方既小,政治之监察既易,舆论之指摘亦严,要防止弊窦,亦正无藉乎回避。所以回避之法,在封建制度下,是无从发生的。郡县制度的初期,还毫无形迹,如严助、朱买臣均以吴人而为会稽守,即其明证。东汉以后,此制渐渐发生。《后汉书·蔡邕传》说:时制婚姻之家,及两州人士,不得对相监临,因此有三互之法(《注》:三互,谓婚姻之家,及两州人不得交互为官也),是为回避之法之始。然其法尚不甚严。至近世乃大为严密。在清代,惟教职止避本府,余皆须兼避原籍、寄籍及邻省五百里以内。京官父子、祖孙,不得同在一署。外官则五服之内,母、妻之父及兄弟、女婿、外甥、儿女姻亲、师生,均不得互相统属(皆以卑避尊)。此等既以防弊,亦使其人免得为难,在事实上亦不得不然。惟近代省区太大,服官的离本籍太远,以致不悉民情风俗,甚至言语不通,无从为治。以私计论,来往川资,所费大巨,到任时已不易筹措,罢官后竟有不能归家的,未免迫人使入于贪污,亦是立法未善之处。\n选举之法,无论如何严密,总不过慎之于任用之初。(一)人之究有德行才识与否,有时非试之以事不能知;(二)亦且不能保其终不变节。(三)又监督严密,小人亦可为善,监督松弛,中人不免为非;所以考课之法,实较选举更为重要。然其事亦倍难。因为(一)考试之法,可将考者与被考者隔离;(二)且因其时间短,可用种种方法防弊;(三)不幸有弊,所试以文字为凭,亦易于复试磨勘,在考课则办不到。考课之法,最早见于书传的,是《书经》的三载考绩,三考黜陟(《尧典》,今本《舜典》)。《周官》太宰,以八柄诏王驭群臣(一曰爵,二曰禄,三曰予,四曰置,五曰生,六曰夺,七曰废,八曰诛),亦系此法。汉朝京房欲作考功课吏法,因此为石显所排。王符著《潜夫论》极称之,谓为致太平之甚(见《考绩篇》)。魏世刘劭,亦曾受命作都官考课及说略。今其所著《人物志》具存,论观人之法极精,盖远承《文王官人》之绪(《大戴礼记》篇名。《周书》亦有此篇,但称《官人》)。按京房尝受学焦延寿,延寿称“得我道以亡身者,京生也”。京房《易》学,虽涉荒怪,然汉世如此者甚多,何致有亡身之惧?疑《汉书》文不完具。京房课吏之法,实受诸延寿,得我道以亡身之说,实指课吏之法言之。如此,则考课之法,在古代亦系专门之业,而至后来乃渐失其传者了。后世无能讲究此学的。其权,则初属于相府,后移于尚书,而专属于吏部。虽有种种成法,皆不过奉行故事而已(吏部系总考课的大成的。各机关的属官,由其长官考察;下级机关,由上级机关考察,为历代所同。考课有一定年限。如明代,京官六年一考察,谓之京察。外官三年一考察,谓之外察,亦谓之大计,武职谓之军政。清朝均三年一行。考察有一定的项目,如清朝文官,以守、才、政、年为四格。武官又别有字样,按格分为三等。又文武官均以不谨、疲软、浮躁、才力不及、年老、有疾为六法。犯此者照例各有处分。然多不核其实,而人事的关系却颇多。高级的官,不由吏、兵部决定的,明有自陈,清有由部开列事实请旨之法,余皆由吏、兵部处理)。\n第四十四章 赋税 # 中国的赋税,合几千年的历史观之,可以分为两大类:其(一)以最大多数的农民所负担的田税、军赋、力役为基本,随时代变化,而成为种种形式。自亡清以前,始终被看做是最重要的赋税。其(二)自此以外的税,最初无有,后来逐渐发生,逐渐扩张,直至最近,才成为重要部分。\n租、税、赋等字样,在后世看起来,意义无甚区别,古代则不然。汉代的田税,古人称之为税,亦即后世所谓田赋。其收取,据孟子说,有贡、助、彻三法。夏后氏五十而贡,殷人七十而助,周人百亩而彻(五十、七十当系夏殷顷亩,较周为小,不然,孟子所说井田之制,就不可通了)。又引龙子的话,说“贡者,校数岁之中以为常”,即是取几年的平均额,以定一年的税额。乐岁不能多,凶年不能减。所以龙子诋为恶税。助法,据孟子说,是将一方里之地,分为九百亩。中百亩为公田,外八百亩为私田。一方里之地,住居八家。各受私田百亩。共耕公田。公田所入,全归公家;私田所入,亦全归私家,不再收税。彻则田不分公私,而按亩取其几分之几。按贡法当是施之被征服之族的。此时征服之族与被征服之族,尚未合并为一,截然是两个团体。征服之族,只责令被征服之族,每年交纳农作品若干。其余一切,概非所问(此时纳税的实系被征服之族之团体,而非其个人),所以有此奇异的制度。至于助、彻,该是平和部族中自有的制度,在田亩自氏族分配于家族时代发生的(参看第三十八、第四十一两章自明)。三者的税额,孟子说:“其实皆十一也。”这亦不过以大略言之。助法,照孟子所说,明明是九一,后儒说:公田之中,以二十亩为庐舍,八家各耕公田十亩,则又是十一分之一。古人言语粗略,计数更不精确,这是不足以为怀疑孟子的话而加以责难的根据。古代的田制有两种:一种是平正之地,可用正方形式分划,是为井田。一种是崎岖之地,面积大小,要用算法扯算的,是为畦田(即圭田)。古代征服之族,居于山险之地,其地是不能行井田的,所以孟子替滕文公规划,还说“请野九一而助,国中什一使自赋”。既说周朝行彻法,又说虽周亦助,也是这个道理(参看第四十章自明)。\n赋所出的,是人徒、车、辇、牛、马等,以供军用。今文家说:十井出兵车一乘(《公羊》宣公十年,昭公元年何《注》)。古文家据《司马法》,而《司马法》又有两说:一说以井十为通,通为匹马,三十家,出士一人,徒二人。通十为成,成十为终,终十为同,递加十倍(《周官》小司徒郑《注》引)。又一说以四井为邑,四邑为丘,有戎马一匹,牛三头。四丘为甸,出戎马四匹,兵车一乘,牛十二头,甲士三人,步卒七十二人(郑注《论语·学而篇》“道千乘之国”引之,见《小司徒疏》)。今文家所说的制度,常较古文家早一时期,说已见前。古文家所说的军赋,较今文家为轻,理亦由此(《司马法》实战国时书。战国时国大了,所以分担的军赋也轻)。\n役法,《礼记·王制》说:“用民之力,岁不过三日。”《周官·均人》说:丰年三日,中年二日,无年一日。《小司徒》说:“上地家七人,可任也者家三人。中地家六人,可任也者二家五人。下地家五人,可任也者家二人。凡起徒役,毋过家一人,以其余之羡。惟田与追胥竭作。”案田与追胥,是地方上固有的事,起徒役则是国家所要求于人民的。地方上固有的事,总是与人民利害相关的,国家所要求于人民的,则利害未必能一致,或且相反。所以法律上不得不分出轻重。然到后来,用兵多而差徭繁,能否尽守此规则,就不可知了。古代当兵亦是役的一种。《王制》说:“五十不从力政(政同征,即兵役外的力役),六十不与服戎。”《周官·乡大夫》说:“国中自七尺以及六十,野自六尺以及六十有五皆征之。”《疏》说七尺是二十岁,六尺是十五岁。六尺是未成年之称,其说大约是对的。然则后期的徭役,也比前期加重了。\n以上是古代普遍的赋税。至于山林川泽之地,则古代是公有的。手工业,简易的人人会做,艰难的由公家设官经营。商业亦是代表部族做的(说已见第四十一章),既无私有的性质,自然无所谓税。然到后来,也渐渐的有税了。《礼记·曲礼》:“问国君之富,数地以对,山泽之所出。”古田地字通用,田之外兼数山泽,可见汉世自天子至封君,将山川、园池、市井租税之入,皆作为私奉养,由来已久(参看第四十一章)。市井租税,即系商税。古代工商业的分别,不甚清楚,其中亦必包含工税。按《孟子·王制》,都说“市廛而不税,关讥而不征。”廛是民居区域之称。古代土地公有,什么地方可以造屋,什么地方可以开店,都要得公家允许的,不能乱做。所以《孟子·滕文公上篇》,记“许行自楚之滕,踵门而告文公曰:闻君行仁政,愿受一廛而为氓,文公与之处”。然则市廛而不税,即系给与开店的地方,而不收其税,这是指后世所谓“住税”而言,在都邑之内。关讥而不征,自然是指后世所谓“过税”而言。然则今文住税、过税俱无。而《周官》司市,必“凶荒札丧”,才“市无征而作布”(造货币);司关必凶荒才“无关、门之征”(门谓城门),则住税过税都有了。又《孟子·公孙丑下篇》说:“古之为市者”,“有司者治之耳。有贱丈夫焉,必求龙断而登之,以左右望而罔市利。人皆以为贱,故从而征之”,龙即陇字。龙断,谓陇之断者。一个人占据了,第二个人再不能走上去与之并处。罔即今网字。因为所居者高,所见者远,遥见主顾来了,可以设法招徕;而人家也容易望见他,自可把市利一网打尽了。这是在乡赶集的,而亦有税,可见商税的无孔不入了。此等山川、园池、市肆租税,都是由封建时代各地方的有土之君,各自征收的,所以很缺乏统一性。\n赋税的渐增,固由有土者的淫侈,战争的不息,然社会进化,政务因之扩张,支出随之巨大,亦是不可讳的。所以白圭说:“吾欲二十而取一。”孟子即说:“子之道,貉道也。”貉“无城郭,宫室,宗庙祭祀之礼。无诸侯币帛饔飧,无百官有司,故二十取一而足”。然则赋税的渐增,确亦出于事不获已。倘使当时的诸侯大夫,能审察情势,开辟利源,或增设新税,或就旧税之无害于人民者而增加其税额,原亦不足为病。无如当时的诸侯大夫,多数是不察情势,不顾人民的能否负担,而一味横征暴敛。于是田租则超过十一之额,而且有如鲁国的履亩而税(见《春秋》宣公十五年。此因人民不尽力于公田,所以税其私田),井田制度破坏尽了。力役亦加多日数,且不依时令,致妨害人民的生业。此等证据,更其举不胜举。无怪乎当时的仁人君子,都要痛心疾首了。然这还不算最恶的税。最恶的税,是一种无名的赋。古书中赋字有两义:一是上文所述的军赋,这是正当的。还有一种则是不论什么东西,都随时责之于民。所以《管子》说:“岁有凶穰,故谷有贵贱。令有缓急,故物有轻重。”(《国蓄篇》)轻就是价贱,重就是价贵。在上者需用某物,不管人民的有无,下令责其交纳,人民只得求之于市,其物的价格就腾贵,商人就要因此剥削平民了。《管子》又说:以室庑籍,以六畜籍,以田亩籍,以正人籍,以正户籍。籍即是取之之意。以室庑籍,当谓按户摊派。以田亩籍,则按田摊派。正人、正户,当系别于穷困疲羸的人户而言。六畜,谓畜有六畜之家,当较不养者为富(《山权数》云:“若岁凶旱水泆,民失本,则修宫室台榭,以前无狗后无彘者为庸。”此以家无孳畜为贫穷的证据),所以以之为摊派的标准。其苛细可谓已甚了。古代的封君,就是后世乡曲的地主。后世乡曲的地主,需要什么东西,都取之于佃户的,何况古代的封君,兼有政治上的权力呢?无定时、无定物、无定数,这是最恶的税。\n秦汉之世,去古未远,所以古代租税的系统,还觉分明。汉代的田租,就是古代的税,其取之甚轻。高祖时,十五税一。文帝从晁错之说,令民入粟拜爵,十三年,遂全除田租。至景帝十年,乃令民半出租,为三十而税一。后汉初年,尝行十一之税。天下已定,仍三十而税一。除灵帝曾按亩敛修宫钱外,始终无他横敛(修宫钱只是横敛,实不能算增加田租),可谓轻极了。但古代的田,是没有私租的,汉世则正税之外,还有私租,所以国家之所取虽薄,农民的负担,仍未见减轻,还只有加重(王莽行王田之制时,诏书说汉时的私租,“厥名三十,实十税五”,则合三十税一的官租,是三十分之十六了)。汉代的口钱,亦称算赋。民年十五至五十六,出钱百二十,以食天子。武帝又加三钱,以补车骑马。见《汉书·高帝纪》四年、《昭帝纪》元凤四年《注》引如淳说引《汉仪注》。按《周官》太宰九赋,郑《注》说赋是“口率出泉”。又说:“今之算泉,民或谓之赋,此其旧名与?”泉钱一字。观此,知汉代的算赋,所谓人出百二十钱以食天子者,乃古代横敛的赋所变。盖因其取之无定时、无定物、无定数,实在太暴虐了,乃变为总取钱若干,而其余一切豁免。这正和五代时的杂征敛,宋世变为沿纳;明时的加派,变为一条鞭一样(见下)。至于正当的赋,则本是供军用的,所以武帝又加三钱以补车骑马。汉代的钱价,远较后世为贵,人民对于口钱的负担,很觉其重。武帝令民生子三岁出口钱,民至于生子不举。元帝时,贡禹力言之。帝乃令民七岁乃出口钱。见《汉书·贡禹传》。役法:《高帝纪》二年《注》引如淳说,《律》:年二十三,傅之畴官,各从其父畴学之。畴之义为类。古行世业之法,子弟的职业,恒与父兄相同(所谓士之子恒为士,农之子恒为农,工之子恒为工,商之子恒为商。参看阶级章)。而每一类的人,都有其官长(《国语·周语》说“宣王要料民于太原,仲山父谏,说“古者不料民而知其多少。司民协孤终,司商协民姓,司徒协旅,司寇协奸,牧协职,工协革,场协入,廪协出,是则少多死生,出入往来,皆可知也。”这即是各官各知其所管的民数的证据),此即所谓畴官。傅之畴官,就是官有名籍,要负这一类中人所应负的义务了。这该是古制,汉代的人民,分类未必如古代之繁,因为世业之制破坏了。但法律条文,是陈旧的东西,事实虽变,条文未必随之而变。如淳所引的律文,只看作民年二十三,就役籍有名,该当一切差徭就够了。景帝二年,令民年二十始傅。又将其提早了三年。役法是征收人民的劳力的,有役法,则公家举办事业,不必要出钱雇工,所以在财政上,也是一笔很大的收入。\n财政的规模,既经扩张,自当创设新税。创设新税,自当用间接之法,避免直接取之于农民。此义在先秦时,只有法家最明白。《管子·海王篇》说,要直接向人民加赋,是人人要反对的。然盐是无人不吃的;铁器亦不论男女,人人要用,如针、釜、耒、耜之类。在盐铁上加些微之价,国家所得,已不少了。这是盐铁官卖或收税最古的理论。此等税或官卖,古代亦必有行之者。汉代郡国,有的有盐官、铁官、工官(收工物税)、都水官(收渔税),有的又没有,即由于此。当此之时,自应由中央统筹全局,定立税法;或由中央直接征收,或则归之于地方。但当时的人,不知出此。桑弘羊是治法家之学的,王莽实亦兼采法家之说(见第四十一章),所以弘羊柄用时,便筦盐铁、榷酒酤,并行均输、算缗之法(千钱为缗,估计资本所值之数,按之抽税),王莽亦行六筦之制(见第四十一章),然行之既未尽善,当时的人,又大多数不懂得此种理论。汲黯说:天子只该“食租衣税”。晋初定律,把关于酒税等的法令,都另编为令,出之于律之外,为的是律文不可时改,而此等税法,在当时,是认为不正当,天下太平之后,就要废去的(见《晋书·刑法志》)。看这两端,便知当时的人,对于间接税法,如何的不了解。因有此等陈旧的见解,遂令中国的税法,久之不能改良。\n田租、口赋两种项目,是从晋定《户调式》以后,才合并为一的。户调之法,实起源于后汉之末。魏武帝平河北,曾下令:田租之外,只许每户取绵绢若干,不准多收(见《三国魏志·武帝纪》建安九年《注》)。大约这时候,一、人民流离,田亩荒废,有能从事开垦的,方招徕之不暇,不便从田租上诛求。二、人民的得钱,是比较艰难的(这个历代情形都如此。所以租税征收谷帛,在前代,是有益于农民的。必欲收钱,在征收租税时,钱价就昂贵,谷帛的价,就相对下落了)。汉世钱价贵,丧乱之际,卖买停滞,又不能诛求其口钱。所以不如按户责令交纳布帛之类。这原是权宜之法。但到晋武帝平吴,制为定式之后,就成为定法了。户调之法,是与官授田并行的。当时男子一人,占田七十亩;女子三十亩。其外,丁男课田五十亩,丁女二十亩;次丁男半之,女则不课。丁男之户,岁输绢三匹,绵三斤。女及次丁男为户者半输。北魏孝文帝均田令,亦有授田之法(已见第四十一章)。唐时,丁男给田一顷,以二十亩为永业,余为口分。每年输粟三石,谓之租。看地方的出产,输绵及丝麻织品,谓之调。力役每年二十日,遇闰加两日,不役的纳绢三尺,谓之庸。立法之意,本是很好的。但到后来,田不能授,而赋税却是按户征收了。你实际没有田,人家说官话不承认。兼并的人,都是有势力的,也无人来整顿他。于是无田的人,反代有田的人出税。人皆托于宦、学、释、老,或诈称客户以自免。其弊遂至不可收拾。当这时代,要想整顿,(一)除非普加清厘,责令兼并的人,将多余的田退还,由官分给无田者。(二)次则置兼并者于不问,而以在官的闲田,补给无田的人。其事都不能行。(三)于是德宗时,杨炎为相,牺牲了社会政策的立法,专就财政上整顿,就有财产之人而收其税,令于夏秋两季交纳(夏输毋过六月,秋输毋过十一月),是为两税。两税法的精意,全在“户无主客,以见居为簿;从无丁中,以贫富为差”十八个字。社会立法之意,虽然牺牲了,以财政政策而论,是不能不称为良法的。\n“两税以资产为宗”,倘使就此加以研究改良,使有产者依其财产的多少,分别等第,负担赋税,而于无产者则加以豁免,则虽不能平均负赋,而在财政上,还不失公平之道,倒也是值得称许的。然后此的苛税,仍是向大多数农民剥削。据《宋史·食货志》所载,宋时的赋税:有田亩之赋和城郭之赋,这是把田和宅地分别征收的,颇可称为合理。又有丁口之赋,则仍是身税。又有杂变之赋,亦称为沿纳,是两税以外,苛取于民,而后遂变为常税的,在理论上就不可容恕了。但各地方的税率,本来轻重不一。苛捐杂税,到整理之时,还能定为常赋,可见在理论上虽说不过去,在事实上为害还是不很大的。其自晚唐以来,厉民最甚,直至明立一条鞭之法,为害才稍除的,则是役法。\n力役是征收人民的劳力的。人民所最缺乏的是钱,次之是物品。至于劳力,则农家本有余闲,但使用之不失其时,亦不过于苛重,即于私人无害,而于公家有益。所以役法行之得当,亦不失为一种良好的赋税(所以现行征工之法,限定可以征工的事项,在立法上是对的)。但是晚唐以后的役法,其厉民却是最甚的。其原因:由于此时之所以役民者,并非古代的力役之征,而是庶人在官之事。古代的力役之征,如筑城郭、宫室,修沟渠、道路等,都是人人所能为的;而且其事可以分割,一人只要应役几日,自然不虑其苛重了。至于在官的庶人,则可分为府、史、胥、徒四种。府是看守财物的。史是记事的。胥是才智之称,所做的,当系较高的杂务。“徒,众也”,是不须才智,而只要用众力之时所使用的,大概用以供奔走。古代事务简单,无甚技术关系,即府、史亦是多数人所能做,胥、徒更不必论了。但此等事务,是不能朝更暮改的。从事其间的,必须视为长久的职业,不能再从事于私人的事业,所以必须给之禄以代耕。后世社会进步了,凡事都有技术的关系,筑城郭、宫室,修沟渠、道路等事,亦有时非人人所能为,何况府、史、胥、徒呢(如徒,似乎是最易为的。然在后世,有追捕盗贼等事,亦非人人所能)?然晚唐以后,却渐根据“丁”、“资”,以定户等而役之。(一)所谓丁资,计算已难平允;(二)而其所以役之之事,又本非其所能为;(三)而官又不免加以虐使,于是有等职务,至于破产而不能给。人民遂有因此而不敢同居,不敢从事生产,甚至有自杀以免子孙之役的。真可谓之残酷无伦了。欲救此弊,莫如分别役的性质。可以役使人民的,依旧签差。不能役使人民的,则由公家出钱雇人充任。这本不过恢复古代力役之征,庶人在官,各不相涉的办法,无甚稀奇,然宋朝主张改革役法的王安石,亦未计及此。王安石所行的法,谓之免役。案宋代役法,原有签差雇募之分。雇役之法:(一)者成为有给职,其人不至因荒废私计而无以为生。(二)者有等事情,是有人会做,有人不会做的,不会做的人要赔累,会做的人则未必然。官出资雇募,应募的自然都是会做这事情的人,决不至于受累,所以雇役之法,远较差役为良。但当时行之,甚不普遍。安石行免役之法:使向来应役的人,出免役钱;不役的人,出助役钱,官以其钱募人充役。此法从我们看来,所失者,即在于未曾分别役的性质,将可以签差之事,仍留为力役之征,而一概出钱雇募。使(一)农民本可以劳力代实物或货币的,亦概须以实物或货币纳税。(二)而公家本可征收人民劳力的事,亦因力役的习惯亡失,动须出钱雇募。于是有许多事情,尤其是建设事务,因此废而不举。这亦是公家的一笔损失。但就雇役和差役两法而论,则雇役之法,胜于差役多了。而当时的旧党,固执成见。元祐时,司马光为相,竟废雇役而仍行差役。此后虽亦差雇并行,总是以差为主,民受其害者又数百年。\n田租、口赋、力役以外的赋税,昔人总称为杂税。看这名目,便有轻视它,不列为财政上重要收入的意思。这是前人见解的陈旧,说已见前。然历代当衰乱之际,此等赋税,还总是有的。如《隋书·食货志》说:晋过江后,货卖奴婢、马牛、田宅、价值万钱者,输钱四百,买者一百,卖者三百,谓之“散估”,此即今日的契税。又说:都东方山津、都西石头津,都有津主,以收荻、炭、鱼、薪之税,十取其一;淮北大市百余,小市十余,都置官司收税,此即商税中之过税及住税。北朝则北齐后主之世,有关、市、邸、店之税。北周宣帝时,有入市税。又酒坊、盐池、盐井,北周亦皆有禁。到隋文帝时,却把这些全数豁免,《文献通考·国用考》盛称之。然以现代财政学的眼光评论,则还是陈旧的见解。到唐中叶以后,藩镇擅土,有许多地方,赋税不入于中央;而此时税法又大坏,中央收入减少,乃不得不从杂税上设法。宋有天下以后,因养兵特多,此等赋税,不能裁撤,南渡以后,国用更窘,更要加意整顿。于是此等杂税,遂渐渐的附庸蔚为大国了。不论在政治上、社会上,制度的改变,总是由事实逼迫出来的多,在理论指导之下发明的少。这亦是政治家的一种耻辱。\n杂税之中,最重要的是盐税。其法,始于唐之第五琦,而备于刘晏。籍民制盐(免其役),谓之灶户,亦谓之亭户。制成之盐,卖之商人,听其所之,不复过问。后人称之为就场征税。宋朝则有(一)官鬻,(二)通商两法。而通商之中,又分为二:(甲)径售之于商人,(乙)则称为入边、入中。入边是“入边刍粟”的略称,入中则是“入中钱帛”的略称。其事,还和茶法及官卖香药、宝货有关系。茶税,起于唐德宗时。其初是和漆与竹木并税的。后曾裁撤,旋又恢复,且屡增其额。其法亦系籍民制造,谓之园户。园户制成的茶,由官收买,再行卖给商人。官买茶的钱,是预给园户的,谓之“本钱”。在江陵、真州、海州、汉阳军、无为军、蕲州的蕲口,设立六个榷货务。除淮南十三场所出的茶以外,都送到这六个榷货务出卖(惟川峡、广南,听其自卖,而禁出境)。京城亦有榷货务,则是只收钱帛而不给货的。宋初,以河东的盐,供给河北的边备。其卖盐之法:是令商人入刍粟于国家指定之处,由该地方的官吏点收,给与收据,估计其价若干,由商人持此据至国家卖盐之处,照价给之以盐,是为入边刍粟;其六榷货务出卖的茶,茶是在各榷货务取,钱帛是在京师榷货务付出的,是为入中钱帛,这是所以省运输之费,把漕运和官卖,合为一事办理的,实在是个良法。至于香药、宝货,则是当时对外贸易的进口货,有半官卖性质的。有时亦以补充入边入中的不足,谓之三说(此即今兑换之兑字。兑换之兑无义,乃脱换之省写,脱说古通用)。有时并益以缗钱,谓之四说。以盐供入边入中之用,其弊在于虚估。点收的官吏和商人串通了,将其所入之物,高抬价格,官物便变成贱价出卖,公家大受损失了。有一个时期,曾废除估价,官以实物卖出,再将所得的钱,辇至出刍粟之处买入(这不啻入边之法已废,仅以官卖某物之价,指定供给某处的边费而已)。但虚估之事,是商人和官吏,都有利益的,利之所在,自然政策易于摇动,不久其法复废。到蔡京出来,其办法却聪明了。他对于商人要贩卖官盐的,给之以引。引分为长、短。有若干引,则准做若干盐的卖买,而这引是要卖钱的。这不是卖盐,只是出卖贩盐的许可证了。茶,先已计算官给本钱所得的息,均摊之于园户,作为租税,而许其与商人直接卖买。至此亦行引法,谓之茶引。蔡京是个贪污奸佞的人,然其所立盐茶之法,是颇为简易的,所以其后遂遵行不变。但行之既久,弊窦又生。因为国家既把盐卖给大商人,不能不保证其销路。于是藉国家的权力,指定某处地方,为某处所产之盐行销之地,是为“引地”。其事起于元朝,至清代而其禁极严。盐的引额,是看销费量而定的,其引地则看水陆运道而定,两者都不能无变更,而盐法未必随之而变,商人恃有法律保护,高抬盐价,于是私盐盛行。因私盐盛行之故,不得不举办缉私,其费用亦极大,盐遂成为征收费极巨的赋税。宋朝入边入中之法,明朝还仿其意而行之。明初,取一部分的盐,专与商人输粮于边的相交易,谓之中盐。运粮至边方,国家固然困难,商人也是困难的。计算收买粮食,运至边方,还不如在边方开垦之有利,商人遂有自出资本,雇人到边上开垦的,谓之商屯。当时的开平卫,就是现在的多伦县一带,土地垦辟了许多。后来因户部改令商人交纳银两,作为库储,商屯才渐次撤废。按移民实边,是一件最难的事。有移殖能力的人,未必有移殖的财力。国家出资移民,又往往不能得有移殖能力的人,空耗财力,毫无成绩。商人重利,其经营,一定比官吏切实些。国家专卖之物,如能划出一部分,专和商人出资移民的相交易,一定能奖励私人出资移民的。国家只须设官管理,规定若干条法律,使资本家不至剥削农民就够了。这是前朝的成法,可以师其意而行之的。又明初用茶易西番之马,含有振兴中国马政,及制驭西番两种用意。因为内地无广大的牧场,亦且天时地利等,养马都不如西番的适宜,而西番马少,则不能为患。其用意,亦是很深远的。当时成绩极佳。后因官吏不良,多与西番私行交易,好马自私,驽马入官,而其法才坏。现在各民族都是一家,虽不必再存什么制驭之意,然藉此以振兴边方的畜牧,亦未尝不是善策。这又是前朝的成法,可以师其意而变通之的。\n酒:历代有禁时多,征榷时少。因为昔人认酒为糜谷,而其物人人能制,要收税或官卖,是极难的。历代收酒税认真的,莫如宋朝。其事亦起于唐中叶以后。宋时,诸州多置“务”自酿。县和镇乡,则有许民酿而收其税的。其收税,多用投标之法,认税最多的人,许其酿造,谓之“扑买”。承酿有一定年限。不及年限,而亏本停止,谓之“败阙”。官吏为维持税收起见,往往不许其停业。于是有勒令婚丧之家,买酒若干的;甚有均摊之于民户的,这变成强迫买酒了,如何可行?但酒税在北宋,只用为地方经费,如“酬奖役人”之类(当重难差徭的,以此调剂它)。到南宋,就列为中央经费了。官吏要维持收入,也是不得不然的。收酒税之法,最精明的,是赵开的“隔酿”,亦称为“隔槽”。行之于四川,由官辟酿酒的场所,备酿酒的器具,使凡要酿酒的,都自备原料,到这里来酿。出此范围之外,便一概是私酒。这是为便于缉私起见,其立法是较简易的,不过取民未免太苛罢了。\n阬冶:在唐朝,或属州郡,或隶盐铁使。宋朝,或官置盐、冶、场、务,或由民承买,而以分数中卖于官,皆属转运使。元朝矿税称为税课,年有定额。此外还有许多无定额的,总称为额外课(额外课中,通行全国的,为契税及历本两项)。\n商税是起于唐朝的藩镇的,宋朝相沿未废。分为住税和过税。住税千分之三十,过税千分之二十。州县多置“监”、“务”收取,关镇亦有设置的。其所税之物,随地不同。照法律都应揭示明白,但实际能否如此,就不可知了。唐宋时的商税,实际上是无甚关系的。关系重要的,倒要推对外的市舶司。\n市舶司起于唐朝。《文献通考》说:唐有市舶使,以右威卫中郎将周庆立为之。代宗广德元年,有广州市舶使吕太一。按庆立事见《新唐书·柳泽传》,吕太一事见《旧唐书·代宗本纪》。又《新书·卢怀慎传》说怀慎之子奂,“天宝初为南海太守,污吏敛手,中人之市舶者,亦不敢干其法。”合此数事观之,似乎唐时的市舶使,多用中人,关系还不甚重要。到宋朝就不然了。宋朝在杭州、明州、秀州、温州、泉州及密州的板桥镇(就是现在的青岛),均曾设立市舶司。海舶至,先十榷其一。其香药、宝货,又须先尽官买,官买足了,才得和人民交易。香药、宝货,为三说之一(已见前)。南宋时又用以称提关会(关子、会子系南宋时纸币之名。提高其价格,谓之称提),可见其和财政大有关系了。元明亦有市舶司。明朝的市舶司,意不在于收税,而在于管理外商。因为明初沿海已有倭寇之故。中叶以后,废司不设。中外互市,无人管理。奸商及各地方的势家,因而欺侮夷人,欠其货款不还,为激成倭寇肆扰原因之一。\n赋役之法,至近代又有变迁。《元史·食货志》说:元代的租税,取于内郡的,丁税、地税分为两,是法唐之租庸调的。取于江南的合为一,是法唐朝的两税的。这不过是名目上的异同,实际都是分两次征收,和两税之法无异。总而言之,从杨炎创两税以后,征收的时期,就都没有改变了。元朝又有所谓丝料、包银。丝料之中,又分二户丝和五户丝,二户丝入官,五户丝输于本位(后妃、公主、宗王、功臣的分地)。包银每户四两,二两收银,二两折收丝绢颜色。这该是所以代户役的,然他役仍不能免。按户役变成赋税,而仍责令人民应役;杂税变成正税,而后来需用杂物,又随时敛取于民,这是历代的通病,正不独元朝为然。明初的赋役,就立法言之,颇为整饬。其制度的根本,是黄册和鱼鳞册两种册籍。黄册以户为主,记各户所有的丁、粮(粮指所有的田),根据之以定赋役。鱼鳞册以田为主,记其地形、地味及所在,而注明其属于何人。黄册由里长管理,照例应有两本。一本存县官处,一本存里长处,半年一换。各户丁粮增减,里长应随时记入册内,半年交官,将存在官处的一本,收回改正。其立法是很精明的。但此等责任,是否里长所能尽?先是一个问题。况且赋役是弊窦很多的。一切恶势力,是否里长所能抗拒?里长是否即系此等黑幕中的一个人?亦是很难说的。所以后来,两册都失实了。明代的役法,分为力差和银差。力差还是征收其劳力的,银差则取其实物及货币。田税是有定额的,役法则向系量出为入。后来凡有需要,即取之于民,谓之加派。无定时,无定额,人民大困。役法向来是按人户的等第,以定其轻重、免否的。人户的等第,则根据丁口资产的多寡推定,是谓“人户物力”。其推定,是很难公平的。因为有些财产,不能隐匿,而所值转微(如牛及农具、桑树等);有些财产,易于隐匿,而所值转巨(如金帛等)。况且人户的规避,吏胥的任意出入,以及索诈、受贿等,都在所不免。历代讫无善策,以除其弊。于是发生专论丁粮和兼论一切资产的问题。论道理,自以兼论一切资产为公平。论手续,却以专论丁粮为简便。到底因为调查的手续太繁了,弊窦太多了,斟酌于二者之间,还是以牺牲理论的公平,而求手续的简便为有利,于是渐趋于专论丁粮之途。加派之弊,不但在其所取之多,尤在于其无定额、无定时,使百姓无从预计。于是有一条鞭之法。总算一州县每一年所需用之数,按阖境的丁粮均摊。自此以外,不得再有征收。而其所谓丁者,并非实际的丁口,乃系通计一州县所有的丁额,摊派之于有田之家,谓之“丁随粮行”。明朝五年一均役,清朝三年一编审,后亦改为五年,所做的都系此项工作。质而言之,乃因每隔几年,贫富的情形变换了,于是将丁额改派一次,和调查丁口,全不相干。役法变迁至此,可谓已行免役之法,亦可谓实已加重田赋而免其役了。加赋偏于田亩,是不合理的。因为没有专令农民负担的理由。然加农民之田赋而免其役,较之唐宋后之役法,犹为此善于彼。因为役事无法分割,负担难得公平。改为征其钱而免其役,就不然了。况且有丁负担赋税的能力小,有产负担赋税的能力大,将向来有丁的负担,转移于有粮之家,也是比较合理的。这是税法上自然的进化。一条鞭之法,起源于江西,后渐遍行于全国,其事在明神宗之世。从晚唐役法大坏至此,约历八百年左右,亦可谓之长久了。这是人类不能以理智支配事实,而听其自然迁流之弊。职是故,从前每州县的丁额,略有定数,不会增加。因为增丁就是增赋,当时推行,已觉困难;后来征收,更觉麻烦,做州县官的人,何苦无事讨事做?清圣祖明知其然,所以落得慷慨,下诏说,康熙五十年以后新生的人丁,永不加赋。到雍正时,就将丁银摊入地粮了。这是事势的自然,不论什么人,生在这时候,都会做的,并算不得什么仁政。从前的人,却一味歌功颂德。不但在清朝时候如此,民国时代,有些以遗老自居的人,也还是这样,这不是没有历史知识,就是别有用心了。清朝因有圣祖之诏,所以始终避免加赋之名。但后来田赋的附加很多,实在亦与加赋无异。又古代的赋税,所税者何物,所取者即系何物。及货币通行以后,渐有(一)径收货币,(二)或本收货物之税,亦改收货币的。(三)又因历代(甲)币制紊乱,(乙)或数量不足,(丙)又或官吏利于上下其手,有本收此物,而改收他物的。总之收税并非全收货币。明初,收本物的谓之“本色”,收货币的谓之“折色”。宣宗以后,纸币废而不行,铜钱又缺乏,赋税渐改征银。田赋在收本色时,本来有所谓耗。系因(子)改装,搬运时,不免有所损失;(丑)又收藏之后,或有腐败及虫蛀、鼠窃等,乃于收税之时,酌加若干。积少成多,于官吏颇有裨益。改收银两以后,因将碎银熔成整铤,经火亦有耗损,乃亦于收银时增加若干,谓之“火耗”。后来制钱充足,收赋时改而收钱,则因银钱的比价,并无一定,官吏亦可将银价抬高,其名目则仍谓之火耗,此亦为农民法外的负担。但从前州县官的行政经费,是不够的,非藉此等弥补不可,所以在币制改革以后,亦仍许征税的人,于税收中提取若干成,作为征收之费。\n近代田赋而外,税收发达的,当推关、盐两税。盐税自南宋以后,收入即逐渐增加。元明清三朝,均为次于田赋的重要赋税。关税起于明宣宗时。当时因纸币跌价,增设若干新税,并增加旧税税额,以收回钞票。后来此等新增的税目和税额,有仍复其旧的,有相沿未废的。关税亦为相沿未废者之一,故称为钞关。清朝称为常关。常关为数有限,然各关都有分关,合计之数亦不少。太平军兴之后,又有所谓厘金,属于布政司而不属于中央。于水陆要路设卡,以多为贵,全不顾交通上自然的形势,以致一种货物的运输,有重复收税,至于数次的,所税的货物及其税额,亦无一定,实为最恶的税法。新海关设于五口通商以后,当时未知关税的重要,贸然许外人以协定税率。庚子战后,因赔款的负担重了,《辛丑和约》我国要求增税。外人乃以裁厘为交换条件。厘不能裁,增税至百分之十二点五之议,亦不能行。民国时代,我国参加欧战,事后在美国所开太平洋会议中,提出关税自主案。外人仍只许我开关税会议,实行《辛丑条约》。十四年开会时,我国又提出关税自主案。许于十八年与裁厘同时并行,同时拟定七级税则,实际上得各国的承认。国民政府宣布关税自主,与各友邦或订关税条约,或于通商条约中订有关涉关税的条款。十八年,先将七级税实施。至二十年,将厘金裁撤后,乃将七级税废去,另订税则颁布。主权一经受损,其恢复之难如此,亦可为前车之鉴了。关、盐两税之外,清代较为重要的,是契税、当税、牙税。此等税意亦在于加以管理,不尽在增加收入。其到晚近才发达的,则有烟酒税、印花税、矿税、所得税。其重要的货物,如卷烟、麦粉、棉纱、火柴、水泥、薰烟、啤酒、洋酒等,则征收统税。国民政府将此等税和关税、盐税、牙税、当税,均列为中央收入。田赋划归地方,和契税、营业税,同为地方收入大宗。军兴以来,各地方有许多苛捐杂税,则下令努力加以废除。在理论上,赋税已渐上轨道,但在事实上,则还待逐渐加以整顿罢了。\n第四十五章 兵制 # 中国的兵制,约可分为八期。\n第一期,在古代,有征服之族和被征服之族的区别。征服之族,全体当兵,被征服之族则否,是为部分民兵制。\n第二期,后来战争剧烈了,动员的军队多,向来不服兵役的人民,亦都加入兵役,是为全体皆兵制。\n第三期,天下统一了,不但用不着全体皆兵,即一部分人当兵,亦觉其过剩。偶尔用兵,为顾恤民力起见,多用罪人及降服的异族。因此,人民疏于军事,遂招致降服的异族的叛乱,是即所谓五胡乱华。而中国在这时代,因乱事时起,地方政府擅权,中央政府不能驾驭,遂发生所谓州郡之兵。\n第四期,五胡乱华的末期,异族渐次和中国同化了,人数减少,而战斗顾甚剧烈,不得已,乃用汉人为兵。又因财政艰窘,不得不令其耕以自养。于是又发生一种部分民兵制,是为周、隋、唐的府兵。\n第五期,承平之世,兵力是不能不腐败的。府兵之制,因此废坏。而其时适值边方多事,遂发生所谓藩镇之兵。因此,引起内乱。内乱之后,藩镇遍于内地,唐室卒因之分裂。\n第六期,宋承唐、五代之后,竭力集权于中央。中央要有强大的常备军。又觑破兵民分业,在经济上的利益。于是有极端的募兵制。\n第七期,元以异族,入主中原,在军事上,自然另有一番措置。明朝却东施效颦。其结果,到底因淤滞而败。\n第八期,清亦以异族入主,然不久兵力即腐败。中叶曾因内乱,一度建立较强大的陆军。然值时局大变,此项军队,应付旧局面则有余,追随新时代则不足。对外屡次败北,而国内的军纪,却又久坏,遂酿成晚清以来的内乱。直至最近,始因外力的压迫,走上一条旷古未有的新途径。\n以上用鸟瞰之法,揭示一个大纲。以下再逐段加以说明。\n第一期的阶级制度,看第四十、第四十四两章,已可明白。从前的人,都说古代是寓兵于农的,寓兵于农,便是兵农合一,井田既废,兵农始分,这是一个重大的误解。寓兵于农,乃谓以农器为兵器,说见《六韬·农器篇》。古代兵器是铜做的,农器是铁做的。兵器都藏在公家,临战才发给(所谓授甲、授兵),也只能供给正式军队用,乡下保卫团一类的兵,是不能给与的。然敌兵打来,不能真个制梃以自卫。所以有如《六韬》之说,教其以某种农器,当某种兵器。古无称当兵的人为兵的,寓兵于农,如何能释为兵农合一呢?江永《群经补义》中有一段,驳正此说。他举《管子》的参国伍鄙,参国,即所谓制国以为二十一乡,工商之乡六,士乡十五,公和高子、国子,各帅五乡。伍鄙,即三十家为邑,十邑为卒,十卒为乡,三乡为县,十县为属,乃所以处农人(按所引《管子》,见《小匡篇》)。又引阳虎欲作乱,壬辰戒都车,令癸巳至(按见《左传》定公八年)。以证兵常近国都。其说可谓甚精。按《周官·夏宫·序官》:王六军,大国三军,次国二军,小国一军。《大司徒》,五家为比,五比为闾,四闾为族,五族为党,五党为州,五州为乡;《小司徒》,五人为伍,五伍为两,四两为卒,五卒为旅,五旅为师,五师为军,则六军适出六乡。六乡之外有六遂,郑《注》说:遂之军法如六乡。其实乡列出兵法,无田制,遂陈田制,无出兵法,郑《注》是错误的(说本朱大韶《实事求是斋经义》、《司马法非周制说》)。六乡出兵,六遂则否,亦兵在国中之证。这除用征服之族居国,被征服之旅居野,无可解释。或谓难道古代各国,都有征服和被征服的阶级吗?即谓都有此阶级,亦安能都用此治法,千篇一律呢?殊不知(一)古代之国,数逾千百,我们略知其情形的,不过十数,安知其千篇一律?(二)何况制度是可以互相模仿的。世既有黩武之国,即素尚平和之国,亦不得不肆力于军事组织以相应,既肆力于军事组织,其制度,自然可以相像的。所以虽非被征服之族,其中的军事领袖及武士,亦可以逐渐和民众相离,而与征服之族,同其位置。(三)又况战士必须讲守御,要讲守御,自不得不居险;而农业,势不能不向平原发展;有相同的环境,自可有相同的制度。(四)又况我们所知道的十余国,如求其根源,都是同一或极相接近的部族,又何怪其文化的相同呢?所以以古代为部分民兵制,实无疑义。\n古代之国,其兵数是不甚多的。说古代军队组织的,无人不引据《周官》。不过以《周官》之文,在群经中独为完具罢了。其实《周官》之制,是和他书不合的。按《诗经·鲁颂》:“公徒三万,”则万人为一军。《管子·小匡篇》说军队组织之法正如此(五人为伍,五十人为小戎,二百人为卒,二千人为旅,万人一军)。《白虎通义·三军篇》说:“虽有万人,犹谦让,自以为不足,故复加二千人”,亦以一军为本万人。《说文》以四千人为一军,则据既加二千人后立说。《梁》襄公十一年,“古者天子六师,诸侯一军”(这个军字,和师字同义。变换其字面,以免重复,古书有此文法),一师当得二千人。《公羊》隐公五年何《注》:“二千五百人称师,天子六师,方伯二师,诸侯一师”,“五百”二字必后人据《周官》说妄增。然则古文家所说的军队组织,较今文家扩充了,人数增多了。此亦今文家所说制度,代表较早的时期,古文家说,代表较晚的时期的一证。当兵的一部分人,居于山险之地,山险之地,是行畦田之制的,而《司马法》所述赋法,都以井田之制为基本,如此,当兵的义务,就扩及全国人了。《司马法》之说,已见第四十四章,兹不再引。按《司马法》以终十为同,同方百里,同十为封,封十为畿,畿方千里。如前一说:一封当得车千乘,士万人,徒二万人;一畿当得车万乘,士十万人,徒二十万人。后一说:一同百里,提封万井,除山川、沈斥、城池、邑居、园囿、术路外,定出赋的六千四百井,所以有戎马四百匹,兵车百乘。一封有戎马四千匹,兵车千乘。一畿有戎马四万匹,兵车万乘。见于《汉书·刑法志》。若计其人数,则一同七千五百,一封七万五千,一畿七十五万。《史记·周本纪》说:牧野之战,纣发卒七十万人,以拒武王;《孙子·用间篇》说:“内外骚动,殆于道路,不得操事者,七十万家”,都系本此以立说。《司马法》之说,固系学者所虚拟,亦必和实际的制度相近。春秋时,各国用兵,最多不过数万。至战国时,却阬降斩级,动以万计。此等记载,必不能全属子虚,新增的兵,从何处来呢?我们看《左传》成公二年,记齐顷公鞍战败北逃回去的时候,“见保者曰:勉之,齐师败矣”,可见其时正式的军队虽败于外,各地方守御之兵仍在。而《战国策》载苏秦说齐宣王之言,说“韩魏战而胜秦,则兵半折,四竟不守;战而不胜,国以危亡随其后”,可见各地方守御之兵,都已调出去,充作正式军队了。这是战国时兵数骤增之由。在中国历史上,真正全国皆兵的,怕莫此时若了。\n秦汉统一以后,全国皆兵之制,便开始破坏。《汉书·刑法志》说:“天下既定,踵秦而置材官于郡国。”《后汉书·光武纪》《注》引《汉官仪》(建武七年)说:“高祖令天下郡国,选能引关、蹶张、材力武猛者,以为轻车骑士、材官、楼船。常以立秋后讲肄课试。”则汉兵制实沿自秦。《汉书·高帝纪》《注》引《汉仪注》(二年)说:“民年二十三为正,一岁为卫士,一岁为材官骑士,习射御骑驰战陈,年五十六衰老,乃得免为庶民,就田里。”《昭帝纪》《注》引如淳说(元凤四年):“更有三品,有卒更,有践更,有过更。古者正卒无常,人皆当迭为之,是为卒更。贫者欲得雇更钱者,次直者出钱雇之,月二千,是为践更。天下人皆直戍边三日,亦名为更,律所谓繇戍也。不可人人自行三日戍,又行者不可往便还,因便住,一岁一更,诸不行者,出钱三百入官,官以给戍者,是为过更。”此为秦汉时人民服兵役及戍边之制。法虽如此,事实上已不能行。晁错说秦人谪发之制,先发吏有谪及赘婿、贾人,后以尝有市籍者,又后以大父母、父母尝有市籍者,后入闾取其左(见《汉书》本传),此即汉世所谓七科谪(见《汉书·武帝纪》天汉四年《注》引张晏说)。二世时,山东兵起,章邯亦将骊山徒免刑以击之。则用罪人为兵,实不自汉代始。汉自武帝初年以前,用郡国兵之时多,武帝中年以后,亦多用谪发。此其原因,乃为免得扰动平民起见。《贾子书·属远篇》说:“古者天子地方千里,中之而为都,输将繇使,远者不五百里而至。公侯地百里,中之而为都,输将繇使,远者不五十里而至。秦输将起海上,一钱之赋,十钱之费弗能致。”此为古制不能行的最大原因。封建时代,人民习于战争,征戍并非所惧。然路途太远,旷日持久,则生业尽废。又《史记·货殖传》说,七国兵起,长安中列侯封君行从军旅,赍贷子钱。则当时从军的人,所费川资亦甚巨。列侯不免借贷,何况平民?生业尽废,再重以路途往来之费,人民在经济上,就不堪负担了。这是物质上的原因。至于在精神上,小国寡民之时,国与民的利害,较相一致,至国家扩大时,即不能尽然,何况统一之后?王恢说战国时一代国之力,即可以制匈奴(见《汉书·韩安国传》)。而秦汉时骚动全国,究竟宣、元时匈奴之来朝,还是因其内乱之故,即由于此。在物质方面,人民的生计,不能不加以顾恤;在精神方面,当时的用兵,不免要招致怨恨,就不得不渐废郡国调发之制,而改用谪发、谪戍了。这在当时,亦有令农民得以专心耕种之益。然合前后而观之,则人民因此而忘却当兵的义务,而各地方的武备,也日益空虚了。所以在政治上,一时的利害,有时与永久的利害,是相反的。调剂于二者之间,就要看政治家的眼光和手腕了。\n民兵制度的破坏,形式上是在后汉光武之时的。建武六年,罢郡国都尉官。七年,罢轻车骑士、材官、楼船。自此各郡国遂无所谓兵备了(后来有些紧要的去处,亦复置都尉。又有因乱事临时设立的。然不是经常、普遍的制度),而外强中弱之机,亦于此时开始。汉武帝置七校尉,中有越骑、胡骑,及长水(见《汉书·百官公卿表》。长水,颜师古云:胡名)。其时用兵,亦兼用属国骑等,然不恃为主要的兵力。后汉光武的定天下,所靠的实在是上谷、渔阳的兵,边兵强而内地弱的机缄,肇见于此。安帝以后,羌乱频仍,凉州一隅,迄未宁静,该地方的羌、胡,尤强悍好斗。中国人好斗的性质,诚不能如此等浅演的降夷,然战争本不是单靠野蛮好杀的事。以当时中国之力,谓不足以制五胡的跳梁,决无此理。五胡乱华的原因,全由于中国的分裂。分裂之世,势必军人专权,专权的军人,初起时或者略有权谋,或则有些犷悍的性质。然到后来,年代积久了,则必入于骄奢淫逸。一骄奢淫逸,则政治紊乱,军纪腐败,有较强的外力加以压迫,即如山崩川溃,不可复止。西晋初年,君臣的苟安、奢侈,正是军阀擅权的结果,五胡扰乱的原因。五胡乱华之世,是不甚用中国人当兵的(说已见第四十章)。其时用汉兵的,除非所需兵数太多,异族人数不足,乃调发以充数。如石虎伐燕,苻秦寇晋诸役是。这种军队,自然不会有什么战斗力的(军队所靠的是训练。当时的五胡,既不用汉人做主力的军队,自然无所谓训练。《北齐书·高昂传》说:高祖讨尒朱兆于韩陵,昂自领乡人部曲三千人。高祖曰:“高都督纯将汉儿,恐不济事,今当割鲜卑兵千余人,共相参杂,于意如何?”昂对曰:“敖曹所将部曲,练习已久,前后战斗,不减鲜卑。今若杂之,情不相合。愿自领汉军,不烦更配。”高祖然之。及战,高祖不利,反借昂等以致克捷。可见军队只重训练,并非民族本有的强弱)。所以从刘、石倡乱以来,至于南北朝之末,北方的兵权,始终在异族手里。这是汉族难于恢复的大原因。不然,五胡可乘的机会,正多着呢?然则南方又何以不能乘机北伐?此则仍由军人专横,中央权力不能统一之故。试看晋朝东渡以后,荆、扬二州的相持,宋、齐、梁、陈之世,中央和地方政府互相争斗的情形,便可知道。\n北强南弱之势,是从东晋后养成的。三国以前,军事上的形势,是北以持重胜,南以剽悍胜。论军队素质的佳良,虽南优于北,论社会文明的程度,则北优于南,军事上胜败的原因,实在于此。后世论者,以为由于人民风气的强弱,实在是错误的(秦虽并六国,然刘邦起沛,项籍起吴,卒以亡秦,实在是秦亡于楚。所以当时的人,还乐道南公“亡秦必楚”之言,以为应验。刘项成败,原因在战略上,不关民气强弱,是显而易见的。吴、楚七国之乱,声势亦极煊赫,所以终于无成,则因当时天下安定,不容有变,而吴王又不知兵之故。孙策、孙权、周瑜、鲁肃、诸葛恪、陆逊、陆抗等,以十不逮一的土地人民,矫然与北方相抗,且有吞并中原之志,而魏亦竟无如之何,均可见南方风气的强悍)。东晋以后,文明的重心,转移于南,训卒厉兵,本可于短期间奏恢复之烈。所以终无成功,而南北分裂,竟达于269年之久,其结果且卒并于北,则全因是时,承袭汉末的余毒,(一)士大夫衰颓不振,(二)军人拥兵相猜,而南方的政权,顾全在此等北来的人手中之故。试设想:以孙吴的君臣,移而置之于东晋,究竟北方能否恢复?便可见得。“洒落君臣契,飞腾战伐名”,无怪杜甫要对吕蒙营而感慨了。经过这长时期的腐化,而南弱的形势遂成。而北方当是时,则因长期的战斗,而造成一武力重心。赵翼《廿二史札记》有一条,说周、隋、唐三代之祖,皆出武川,可见自南北朝末至唐初,武力的重心,实未曾变。按五胡之中,氐、羌、羯民族皆小,强悍而人数较多的,只有匈奴、鲜卑。匈奴久据中原之地,其形势实较鲜卑为佳。但其人太觉凶暴,羯亦然。被冉闵大加杀戮后,其势遂衰。此时北方之地,本来即可平靖。然自东晋以前,虎斗龙争,多在今河北、河南、山东、山西、陕西数省境内。辽宁、热、察、绥之地,是比较安静的。鲜卑人休养生息于此,转觉气完力厚。当时的鲜卑人,实在是乐于平和生活,不愿向中原侵略的。所以北魏平文帝、昭成帝两代,都因要南侵为其下所杀(见《魏书·序纪》)。然到道武帝,卒肆其凶暴,强迫其下,侵入中原(道武帝伐燕,大疫,群下咸思还北。帝曰:“四海之人,皆可与为国,在吾所以抚之耳,何恤乎无民?”群臣乃不敢复言,见《魏书·本纪》皇始二年。按《序纪》说:穆帝“明刑峻法,诸部民多以违命得罪。凡后期者,皆举部戮之。或有室家相携,而赴死所。人问何之?答曰:当往就诛。”其残酷如此。道武帝这话,已经给史家文饰得很温婉的了。若照他的原语,记录下来,那便是“你们要回去,我就要把你们全数杀掉”。所以群臣不敢复言了)。此时割据中原的异族,既已奄奄待毙,宋武帝又因内部矛盾深刻,不暇经略北方,北方遂为所据。然自孝文帝南迁以前,元魏立国的重心,仍在平城。属于南方的侵略,仅是发展问题,对于北方的防御,却是生死问题,所以要于平城附近设六镇,以武力为拱卫。南迁以后,因待遇的不平等,而酿成六镇之乱。因六镇之乱而造成一个尒朱氏。连高氏、贺拔氏、宇文氏等,一齐带入中原。龙争虎斗者,又历五六十年,然后统一于隋。隋唐先世,到底是汉族还是异族,近人多有辩论。然民族是论文化的,不是论血统的。近人所辩论的,都是血统问题,在民族斗争史上,实在无甚意义。至于隋唐的先世,曾经渐染胡化,也是武川一系中的人物,则无可讳言。所以自尒朱氏之起至唐初,实在是武川的武力,在政治舞台上活跃的时代。要到唐贞观以后,此项文化的色彩,才渐渐淡灭(唐初的隐太子、巢剌王、常山愍王等,还都系带有胡化色彩的人)。五胡乱华的已事,虽然已成过去,然在军事上,重用异族的风气,还有存留。试看唐朝用番将番兵之多,便可明白。论史者多以汉唐并称。论唐朝的武功,其成就,自较汉朝为尤大。然此乃世运为之(主要的是中外交通的进步)。若论军事上的实力,则唐朝何能和汉朝比?汉朝对外的征讨,十之八九是发本国兵出去打的,唐朝则多是以夷制夷。这以一时论,亦可使中国的人民,减轻负担,然通全局而观之,则亦足以养成异族强悍,汉族衰颓之势。安禄山之所以蓄意反叛,沙陀突厥之所以横行中原,都由于此。就是宋朝的始终不振,也和这有间接的关系。因为久已柔靡的风气,不易于短时期中训练之使其变为强悍。而唐朝府兵的废坏,亦和其搁置不用,很有关系的。\n府兵之制起于周。籍民为兵,蠲其租调,而令刺史以农隙教练。分为百府,每府以一郎将主之,而分属于二十四军(当时以一柱国主二大将,一将军统二开府,开府各领一军),其众合计不满五万。隋、唐皆沿其制,而分属于诸卫将军。唐制,诸府皆称折冲府。各置折冲都尉,而以左右果毅校尉副之。上府兵一千二百人,中府千人,下府八百人。民年二十服兵役,六十而免。全国六百三十四府,在关中的有二百六十一府,以为强干弱枝之计。府兵之制:平时耕以自养。战时调集,命将统之。师还则将上所佩印、兵各还其府。(一)无养兵之费,而有多兵之用。(二)兵皆有业之民,无无家可归之弊。(三)将帅又不能拥兵自重。这是与藩镇之兵及宋募兵之制相较的优点。从前的论者多称之。但兵不惟其名,当有其实。唐朝府兵制度存在之时,得其用者甚少。此固由于唐时征讨,多用番兵,然府兵恐亦未足大用。其故,乃当时的风气使之,而亦可谓时势及国家之政策使之。兵之精强,在于训练。主兵者之能勤于训练,则在预期其军队之有用。若时值承平,上下都不以军事为意,则精神不能不懈弛;精神一懈弛,训练自然随之而废了。所以唐代府兵制度的废坏,和唐初时局的承平,及唐代外攘,不甚调发大兵,都有关系。高宗、武后时,业已名存实亡。到玄宗时,就竟不能给宿卫了(唐时宿卫之兵,都由诸府调来,按期更换,谓之“番上”。番即现在的班字)。时相张说,知其无法整顿,乃请宿卫改用募兵,谓之骑,自此诸府更徒存虚籍了。\n唐初边兵屯戍的,大的称军,小的称城镇守捉,皆有使以主之。统属军、城镇守捉的曰道。道有大总管,后改称大都督。大都督带使持节的,人称之为节度使。睿宗后遂以为官名。唐初边兵甚少。武后时,国威陵替。北则突厥,东北则奚、契丹,西南则吐蕃皆跋扈。玄宗时,乃于边陲置节度使,以事经略。而自东北至西北边之兵尤强,天下遂成偏重之势。安禄山、史思明皆以胡人而怀野心,卒酿成天宝之乱。乱后藩镇遂遍于内地。其中安史余孽,唐朝不能彻底铲除,亦皆授以节度使。诸镇遂互相结约,以土地传子孙,不奉朝廷的命令。肃、代二世,皆姑息养痈。德宗思整顿之,而兵力不足,反召朱泚之叛。后虽削平朱泚,然河北、淮西,遂不能问。宪宗以九牛二虎之力,讨平淮西,河北亦闻风自服。然及穆宗时,河北即复叛。自此终唐之世,不能戡定了。唐朝藩镇,始终据土自专的,固然只有河北。然其余地方,亦不免时有变乱。且即在平时,朝廷指挥统驭之力,亦总不甚完全。所以肃、代以还,已隐伏分裂之势。至黄巢乱后,遂溃决不可收拾了。然藩镇固能梗命,而把持中央政府,使之不能振作的,则禁军之患,尤甚于藩镇。\n禁军是唐初从征的兵,无家可归的。政府给以渭北闲田,留为宿卫,号称元从禁军。此本国家施恩之意,并非仗以战斗。玄宗时破吐蕃,于临洮之西置神策军。安史之乱,军使成如璆遣将卫伯玉率千人入援,屯于陕州。后如璆死,神策军之地,陷于吐蕃,乃即以伯玉为神策军节度使,仍屯于陕,而中官鱼朝恩以观军容使监其军。伯玉死,军遂统于朝恩。代宗时,吐蕃陷长安,代宗奔陕,朝恩以神策军扈从还京。其后遂列为禁军,京西多为其防地。德宗自奉天归,怀疑朝臣,以中官统其军。其时边兵赏赐甚薄,而神策军颇为优厚,诸将遂多请遥隶神策军,军额扩充至十五万。中官之势,遂不可制。“自穆宗以来八世,而为宦官所立者七君。”(《唐书·僖宗纪》赞语。参看《廿二史札记·唐代宦官之祸》条)顺宗、文宗、昭宗皆以欲诛宦官,或遭废杀,或见幽囚。当时的宦官,已成非用兵力,不能铲除之势。然在宦官监制之下,朝廷又无从得有兵力(文宗时,郑注欲夺宦官之兵而败。昭宗欲自练兵以除宦官而败)。召外兵,则明知宦官除而政权将入除宦官者之手,所以其事始终无人敢为。然相持至于唐末,卒不得不出于此一途。于是宦官尽而唐亦为朱梁所篡了。宦官之祸,是历代多有的,拥兵为患的,却只是唐朝(后汉末,蹇硕欲图握兵,旋为何进所杀)。总之,政权根本之地,不可有拥兵自重的人,宦官亦不过其中之一罢了。\n禁兵把持于内,藩镇偃蹇于外,唐朝的政局,终已不可收拾,遂分裂而为五代十国。唐时的节度使,虽不听政府的命令,而亦不能节制其军队。军队不满意于节度使,往往哗变而杀之,而别立一人。政府无如之何,只得加以任命。狡黠的人,遂运动军士,杀军帅而拥戴自己。即其父子兄弟相继的,亦非厚加赏赐,以饵其军士不可。凡此者,殆已成为通常之局。所谓“地擅于将,将擅于兵”。五代十国,惟南平始终称王,余皆称帝,然论其实,则仍不过一节度使而已。宋太祖黄袍加身,即系唐时拥立节度使的故事,其余证据,不必列举。事势至此,固非大加整顿不可。所以宋太祖务要削弱藩镇,而加强中央之兵。\n宋朝的兵制:兵之种类有四:曰禁军,是为中央军,均属三衙。曰厢军,是为地方兵,属于诸州。曰乡兵,系民兵,仅保卫本地方,不出戍。曰番兵,则系异族团结为兵,而用乡兵之法的。太祖用周世宗之策,将厢军之强者,悉升为禁军,其留为厢军者,不甚教阅,仅堪给役而已。乡兵、番兵,本非国家正式的军队,可以弗论。所以武力的重心,实在禁军。全国须戍守的地方,乃遣禁军更番前往,谓之“番戍”。昔人议论宋朝兵制的,大都加以诋毁。甚至以为唐朝的所以强,宋朝的所以弱,即由于藩镇的存废。这真是瞽目之谈。唐朝强盛时,何尝有什么藩镇?到玄宗设立藩镇时,业已因国威陵替,改取守势了。从前对外之策,重在防患未然。必须如汉之设度辽将军、西域都护,唐之设诸都护府,对于降伏的部落,(一)监视其行动,(二)通达其情意,(三)并处理各部族间相互的关系。总而言之,不使其(一)互相并吞,(二)坐致强大,是为防患未然。其设置,是全然在夷狄境内,而不在中国境内的,此之谓“守在四夷”。是为上策。经营自己的边境,已落第二义了。然果其士马精强,障塞完固,中央的军令森严,边将亦奉令维谨,尚不失为中策。若如唐朝的藩镇擅土,则必自下策而入于无策的。因为军队最怕的是骄,骄则必不听命令,不能对外,而要内讧;内讧,势必引外力以为助,这是千古一辙的。以唐朝幽州兵之强,而不能制一契丹,使其坐大;藩镇遍于内地,而黄巢横行南北,如入无人之境,卒召沙陀之兵,然后把他打平;五代时,又因中央和藩镇的内讧,而引起契丹的侵入,都是铁一般强,山一般大的证据。藩镇的为祸为福,可无待于言了。宋朝的兵,是全出于招募的,和府兵之制相反,论者亦恒右唐而左宋,这亦是耳食之谈。募兵之制,虽有其劣点,然在经济上及政治上,亦自有其相当的价值。天下奸悍无赖之徒,必须有以销纳之,最好是能惩治之、感化之,使改变其性质,此辈在经济上,即是所谓“无赖”,又其性质,不能勤事生产,欲惩治之、感化之极难。只有营伍之中,规律最为森严,或可约束之,使之改变。此辈性行虽然不良,然苟能束之以纪律,其战斗力,不会较有身家的良民为差,或且较胜。利用养兵之费,销纳其一部分,既可救济此辈生活上的无赖,而饷项亦不为虚糜。假若一个募兵,在伍的年限,是十年到二十年,则其人已经过长期的训练;裁遣之日,年力就衰,大多数的性质,必已改变,可以从事于生产,变做一个良民了。以经济原理论,本来宜于分业,平民出饷以养兵,而于战阵之事,全不过问,从经济的立场论,是有益无损的。若谓行募兵之制,则民不知兵,则举国皆兵,实至今日乃有此需要。在昔日,兵苟真能御敌,平民原不须全体当兵。所以说募兵之制,在经济上和政治上,自有其相当的价值。宋代立法之时,亦自有深意。不过所行不能副其所期,遂至利未形而害已见罢了。宋朝兵制之弊在于:(一)兵力的逐渐腐败。(二)番戍之制:(甲)兵不知将,将不知兵,既不便于指挥统驭。(乙)而兵士居其地不久,既不熟习地形,又和当地的人民,没有联络。(丙)三年番代一次,道途之费,却等于三年一次出征。(三)而其尤大的,则在带兵的人,利于兵多,(子)既可缺额刻饷以自肥,(丑)又可役使之以图利。乞免者既不易得许;每逢水旱偏灾,又多以招兵为救荒之策,于是兵数递增。宋开国之时,不满二十万。太祖末年,已增至三十七万。太宗末年,增至六十六万。真宗末年,增至九十一万。仁宗时,西夏兵起,增至一百二十五万。后虽稍减,仍有一百一十六万。欧阳修说:“天下之财,近自淮甸,远至吴、楚,莫不尽取以归京师。晏然无事,而赋敛之重,至于不可复加。”养兵之多如此,即使能战,亦伏危机,何况并不能战,对辽、对夏,都是隐忍受侮;而西夏入寇时,仍驱乡兵以御敌呢?当时兵多之害,人人知之,然皆顾虑召变而不敢裁。直至王安石出,才大加淘汰。把不任禁军的降为厢军,不任厢军的免为民,兵额减至过半。又革去番戍之制,择要地使之屯驻,而置将以统之(以第一、第二为名,全国共九十一将)。安石在军事上,虽然无甚成就,然其裁兵的勇气,是值得称道的。惟其所行民兵之制,则无甚成绩,而且有弊端。\n王安石民兵之法,是和保伍之制连带的。他立保甲之法,以十家为一保,设保长。五十家为一大保,设大保长。五百家为一都保,设都保正、副。家有两丁的,以其一为保丁。其初日轮若干人儆盗。后乃教以武艺,籍为民兵。民兵成绩,新党亦颇自诩(如《宋史》载章惇之言,谓“仕宦及有力之家,子弟欣然趋赴,马上艺事,往往胜诸军”之类)。然据《宋史》所载司马光、王岩叟的奏疏,则其(一)有名无实,以及(二)保正长巡检使等的诛求,真是暗无天日。我们不敢说新党的话,全属子虚,然这怕是少数,其大多数,一定如旧党所说的。因为此等行政上的弊窦,随处可以发现。民兵之制,必要的条件有二:(一)为强敌压迫于外。如此,举国上下,才有忧勤惕厉的精神,民虽劳而不怨。(二)则行政上的监督,必须严密。官吏及保伍之长,才不敢倚势虐民。当时这两个条件,都是欠缺的,所以不免弊余于利。至于伍保之法,起源甚古。《周官》大司徒说:“令五家为比,使之相保。五比为闾,使之相受。四闾为族,使之相葬。五族为党,使之相救。五党为州,使之相赒。五州为乡,使之相宾。”这原和《孟子》“死徙无出乡,乡田同井,出入相友,守望相助,疾病相扶持”之意相同,乃使之互相救恤。商君令什伍相司(同伺)连坐,才使之互相稽查。前者为社会上固有的组织,后者则政治上之所要求。此惟乱时可以行之。在平时,则犯大恶者(如谋反叛逆之类)非极其秘密,即徒党众多,声势浩大(如江湖豪侠之类);或其人特别凶悍,为良民所畏(如土豪劣绅之类),必非人民所能检举。若使之检举小恶,则徒破坏社会伦理,而为官吏开敲诈之门,其事必不能行。所以自王安石创此法后,历代都只于乱时用以清除奸轨,在平时总是有名无实,或并其名而无之的(伍保之法,历代法律上本来都有,并不待王安石的保甲,然亦都不能行)。\n裁募兵,行民兵,是宋朝兵制的一变。自此募兵之数减少。元祐时,旧党执政,民兵之制又废。然募兵之额,亦迄未恢复。徽宗时,更利其缺额,封桩其饷,以充上供,于是募兵亦衰。至金人入犯,以陕西为著名多兵之地,种师道将以入援,仅得一万五千人而已。以兵多著名的北宋,而其结果至于如此,岂非奇谈?\n南渡之初,军旅寡弱。当时诸将之兵,多是靠招降群盗或招募,以资补充的。其中较为强大的,当推所谓御前五军。杨沂中为中军,总宿卫。张俊为前军,韩世忠为后军,岳飞为左军,刘光世为右军,皆屯驻于外,是为四大将。光世死,其军叛降伪齐(一部分不叛的,归于张俊),以四川吴玠之军补其缺。其时岳飞驻湖北,韩世忠驻淮东,张俊驻江东,皆立宣抚司。宗弼再入犯,秦桧决意言和,召三人入京,皆除枢密副使,罢三宣抚司,以副校统其兵,称为统制御前军马。驻扎之地仍旧,谓之某州驻扎御前诸军。四川之兵,亦以御前诸军为号。直达朝廷,帅臣不得节制。其饷,则特设总领以司之,不得自筹。其事略见《文献通考·兵考》。\n北族在历史上,是个侵略民族。这是地理条件所决定的。在地理上,(一)瘠土的民族,常向沃土的民族侵略。(二)但又必具有地形平坦,利于集合的条件。所以像天山南路,沙漠绵延,人所居住的,都是星罗棋布的泉地,像海中的岛屿一般;又或仰雪水灌溉,依天山之麓而建国;以至青海、西藏,山岭崎岖,交通太觉不便,则土虽瘠,亦不能成为侵略民族。历史上侵掠的事实,以蒙古高原为最多,而辽、吉二省间的女真,在近代,亦曾两度成为侵略民族。这是因为蒙古高原,地瘠而平,于侵掠的条件,最为完具。而辽吉二省,地形亦是比较平坦的,且与繁荣的地方相接近,亦有以引起其侵略之欲望。北族如匈奴、突厥等,虽然强悍,初未尝侵入中国。五胡虽占据中国的一部分,然久居塞内,等于中国的乱民,而其制度亦无足观。只有辽、金、元、清四朝,是以一个异民族的资格,侵入中国的;而其制度,亦和中国较有关系。今略述其事如下。\n四朝之中,辽和中国的关系最浅。辽的建国,系合部族及州县而成。部族是它的本族,和所征服的北方的游牧民族。州县则取自中国之地。其兵力,亦是以部族为基本的。部族的离合,及其所居之地,都系由政府指定,不能自由。其人民全体皆隶兵籍。当兵的素质,极为佳良。《辽史》称其“各安旧风,狃习劳事,不见纷华异物而迁。故能家给人足,戎备整完。卒之虎视四方,强朝弱附,部族实为之爪牙”,可谓不诬了。但辽立国虽以部族为基本,而其组织军队,亦非全不用汉人。世徒见辽时的五京乡丁,只保卫本地方,不出戍,以为辽朝全不用汉人做正式军队,其实不然。辽制有所谓宫卫军者,每帝即位,辄置之。出则扈从,入则居守,葬则因以守陵。计其丁数,凡有四十万八千,出骑兵十万一千。所谓不待调发州县部族,而十万之兵已具。这是辽朝很有力的常备军。然其置之也,则必“分州县,析部族”。又太祖征讨四方,皇后述律氏居守,亦摘蕃汉精锐三十万为属珊军。可见辽的军队中,亦非无汉人了。此外辽又有所谓大首领部族军,乃亲王大臣的私甲,亦可率之以从征。国家有事,亦可向其量借。又北方部族,服属于辽的,谓之属国,亦得向其量借兵粮。契丹的疆域颇大,兵亦颇多而强,但其组织不坚凝。所以天祚失道,金兵一临,就土崩瓦解。这不是辽的兵力不足以御金,乃是并没有从事于抵御。其立国本无根柢,所以土崩瓦解之后,亦就更无人从事于复国运动。耶律大石虽然有意于恢复,在旧地,亦竟不能自立了。\n金朝的情形,与辽又异。辽虽风气敦朴,然畜牧极盛,其人民并不贫穷的。金则起于瘠土,人民非常困穷。然亦因此而养成其耐劳而好侵掠的性质。《金史》说其“地狭产薄,无事苦耕,可致衣食;有事苦战,可致俘获”,可见其侵掠的动机了。金本系一小部族,其兵,全系集合女真诸部族而成。战时的统帅,即系平时的部长。在平时称为孛堇,战时则称为猛安谋克。猛安译言千夫长,谋克译言百夫长,这未必真是千夫和百夫,不过依其众寡,略分等级罢了。金朝的兵,其初战斗力是极强的,但迁入中原之后,腐败亦很速。看《廿二史札记·金用兵先后强弱不同》一条,便可知道。金朝因其部落的寡少,自伐宋以后,即参用汉兵。其初契丹、渤海、汉人等,投降金朝的,亦都授以猛安谋克。女真的猛安谋克户,杂居汉地的,亦听其与契丹、汉人相婚姻,以相固结。熙宗以后,渐想把兵柄收归本族。于是罢汉人和渤海人猛安谋克的承袭。移剌窝斡乱后,又将契丹户分散,隶属于诸猛安谋克。自世宗时,将猛安谋克户移入中原,其人既已腐败到既不能耕,又不能战,而宣宗南迁,仍倚为心腹,外不能抗敌,而内敛怨于民。金朝的速亡,实在是其自私本族,有以自召之的。总而言之:文明程度落后的民族,与文明程度较高的民族遇,是无法免于被同化的。像金朝、清朝这种用尽心机,而仍不免于灭亡,还不如像北魏孝文帝一般,自动同化于中国的好。\n元朝的兵制,也是以压制为政策的。其兵出于本部族的,谓之蒙古军。出于诸部族的,谓之探马赤军。既入中原后,取汉人为军,谓之汉军。其取兵之法,有以户论的,亦有以丁论的。兵事已定之后,曾经当过兵的人,即定入兵籍,子孙世代为兵。其贫穷的,将几户合并应役。甚贫或无后的人,则落其兵籍,别以民补。此外无他变动。其灭宋所得的兵,谓之新附军。带兵的人,“视兵数之多寡,为爵秩之崇卑”,名为万户、千户、百户。皆分上、中、下。初制,万户千户死阵者,子孙袭职,死于病者降一等。后来不论大小及身故的原因,一概袭职。所以元朝的军官,可视为一个特殊阶级。世祖和二三大臣定计:使宗王分镇边徼及襟喉之地。河、洛、山东,是他们所视为腹心之地,用蒙古军、探马赤军戍守。江南则用汉军及新附军,但其列城,亦有用万户、千户、百户戍守的。元朝的兵籍,汉人是不许阅看的。所以占据中国近百年,无人知其兵数。观其屯戍之制,是很有深心的。但到后来,其人亦都入洪炉而俱化。末叶兵起时,宗王和世袭的军官,并不能护卫它。\n●元·铜火铳(复制品)长43厘米,口径5.3厘米。为元代具有较大威力的火器。火器本由宋发明,后传至蒙古汗国。蒙军西征时,便是以火炮攻克中亚、欧洲的城堡,而火炮的西传,加速了欧洲封建时代的结束\n元朝以异族入据中国,此等猜防之法,固然无怪其然。明朝以本族人做本族的皇帝,却亦暗袭其法,那就很为无谓了。明制:以五千六百人为卫。一千一百十二人为千户所,一百十二人为百户所(什伍之长,历代都即在其什伍之人数内,明朝则在其外。每一百户所,有总旗二人,小旗十人,所以共为一百十二人)。卫设都指挥使,隶属于五军都督府。兵的来路有三种:第(一)种从征,是开国时固有的兵。第(二)种归附,是敌国兵投降的。第(三)种谪发,则是刑法上罚令当兵的,俗话谓之“充军”。从征和归附,固然是世代为兵,谪发亦然。身死之后,要调其继承人,继承人绝掉,还要调其亲族去补充的,谓之“句丁”。这明是以元朝的兵籍法为本,而加以补充的。五军都督府,多用明初勋臣的子孙,也是模仿元朝军官世袭之制。治天下不可以有私心。有私心,要把一群人团结为一党,互相护卫,以把持天下的权利,其结果,总是要自受其害的。军官世袭之制,后来腐败到无可挽救,即其一端。金朝和元朝,都是异族,他们社会进化的程度本浅,离封建之世未远,猛安谋克和万户千户百户,要行世袭之制,还无怪其然。明朝则明是本族人,却亦重视开国功臣的子孙,把他看做特别阶级,其私心就不可恕了。抱封建思想的人,总以为某一阶级的人,其特权和权利,既全靠我做皇帝才能维持,他们一定会拥护我。所以把这一阶级的人,看得特别亲密。殊不知这种特权阶级,到后来荒淫无度,知识志气,都没有了,何谓权利?怕他都不大明白。明白了,明白什么是自己的权利了,明白自己的权利,如何才得维持了,因其懦弱无用,眼看着他人抢夺他的权利,他亦无如之何。所谓贵戚世臣,理应与国同休戚的,却从来没有这回事,即由于此。武力是不能持久的。持久了,非腐败不可。这其原因,由于战争是社会的变态而非其常态。变态是有其原因的,原因消失了,变态亦即随之而消失。所以从历史上看来,从没有一支真正强盛到几十年的军队(因不遇强敌,甚或不遇战事,未至溃败决裂,是有的。然这只算是侥幸。极强大的军队,转瞬化为无用,这种事实,是举不胜举的。以宋武帝的兵力,而到文帝时即一蹶不振,即其一例。又如明末李成梁的兵力,亦是不堪一击的,侥幸他未与满洲兵相遇罢了。然而军事的败坏,其机实隐伏于成梁之时,这又是其一例。军队的腐败,其表现于外的,在精神方面,为士气的衰颓;在物质方面,则为积弊的深痼。虽有良将,亦无从整顿,非解散之而另造不可。世人不知其原理,往往想就军队本身设法整顿,其实这是无法可设的。因为军队是社会的一部分,不能不受广大社会的影响。在社会学上,较低的原理,是要受较高的原理的统驭的)。“兵可百年不用,不可一日无备”,这种思想,亦是以常识论则是,而经不起科学评判的。因为到有事时,预备着的军队,往往无用,而仍要临时更造。府兵和卫所,是很相类的制度。府兵到后来,全不能维持其兵额。明朝对于卫所的兵额,是努力维持的,所以其缺额不至如唐朝之甚。然以多数的兵力,对北边,始终只能维持守势(现在北边的长城,十分之九,都是明朝所造)。末年满洲兵进来,竟尔一败涂地,则其兵力亦有等于无。此皆特殊的武力不能持久之证。\n清朝太祖崛起,以八旗编制其民。太宗之世,蒙古和汉人归降的,亦都用同一的组织。这亦和金朝人以猛安谋克授渤海、汉人一样。中国平定之后,以八旗兵驻防各处,亦和金朝移猛安谋克户于中原,及元朝镇戍之制,用意相同。惟金代的猛安谋克户,系散居于民间;元朝万户分驻各处,和汉人往来,亦无禁限。清朝驻防的旗兵,则系和汉人分城而居的,所以其冲突不如金元之烈。但其人因此与汉人隔绝,和中国的社会,全无关系,到末造,要筹划旗民生计,就全无办法了。清代的汉兵,谓之绿旗,亦称绿营。中叶以前的用兵,是外征以八旗为主,内乱以绿营为主的。八旗兵在关外时,战斗之力颇强。中国军队强悍的,亦多只能取守势,野战总是失利时居多(洪承畴松山之战,是其一例)。然入关后腐败亦颇速。三藩乱时,八旗兵已不足用了。自此至太平天国兴起时,内地粗觉平安,对外亦无甚激烈的战斗。武功虽盛,实多侥天之幸。所以太平军一起,就势如破竹了。\n中国近代,历史上有两种潮流潜伏着,推波助澜,今犹未已,非通观前后,是不能觉悟出这种趋势来的。这两种潮流:其(一)是南方势力的兴起。南部数省,向来和大局无甚关系。自明桂王据云贵与清朝相抗,吴三桂举兵,虽然终于失败,亦能震荡中原;而西南一隅,始隐然为重于天下。其后太平军兴,征伐几遍全国。虽又以失败终,然自清末革命,至国民政府北伐之成功,始终以西南为根据。现在的抗战,还是以此为民族复兴的策源地的。其(二)是全国皆兵制的恢复。自秦朝统一以后,兵民渐渐分离,至后汉之初,而民兵之制遂废,至今已近二千年了。康有为说,中国当承平时代,是没有兵的。虽亦有称为兵的一种人,其实性质全与普通人民无异(见《欧洲十一国游记》)。此之谓有兵之名,无兵之实。旷观历代,都是当需要用兵时,则产生出一支真正的军队来;事过境迁,用兵的需要不存,此种军队,亦即凋谢,而只剩些有名无实的军队,充作仪仗之用了。此其原理,即由于上文所说的战争是社会的变态,原不足怪。但在今日,帝国主义跋扈之秋,非恢复全国皆兵之制,是断不足以自卫的。更无论扶助其他弱小民族了。这一个转变,自然是极艰难。但环境既已如此,决不容许我们的不变。当中国和欧美人初接触时,全未知道需要改变。所想出来的法子,如引诱他们上岸,而不和他在海面作战;如以灵活的小船,制他笨重的大船等,全是些闭着眼睛的妄论。到咸同间,外患更深了。所谓中兴将帅,(一)因经验较多,(二)与欧美人颇有相当的接触,才知道现在的局面,非复历史上所有。欲图适应,非有相当的改革不可。于是有造成一支军队以适应时势的思想。设船政局、制造局,以改良器械;陆军则改练洋操;亦曾成立过海军,都是这种思想的表现。即至清末,要想推行征兵制。其实所取的办法,离民兵之制尚远,还不过是这种思想。民国二十余年,兵制全未革新,且复演了历史上武人割据之局。然时代的潮流,奔腾澎湃,终不容我不卷入旋涡。抗战以来,我们就一步步的,走入举国皆兵之路了。这两种文化,现在还在演变的中途,我们很不容易看出其伟大。然在将来,作历史的人,一定要认此为划时代的大转变,是毫无可疑的。这两种文化,实在还只是一种。不过因为这种转变,强迫着我们,发生一种新组织,以与时代相适应,而时代之所以有此要求,则缘世界交通而起。在中国,受世界交通影响最早的是南部。和旧文化关系最浅的,亦是南部,受旧文化的影响较浅,正是迎受新文化的一个预备条件。所以近代改革的原动力,全出于南方;南方始终代表着一个开明的势力(太平天国,虽然不成气候,湘淮军诸首领,虽然颇有学问,然以新旧论,则太平天国,仍是代表新的,湘淮军人物,仍是代表旧的。不过新的还未成熟,旧的也还余力未尽罢了)。千回百折,似弱而卒底于有成。\n几千年以来,内部比较平安,外部亦无真正大敌。因此,养成中国(一)长期间无兵,只有需要时,才产生真正的军队;(二)而这军队,在全国人中,只占一极小部分。在今日,又渐渐的改变,而走上全国皆兵的路了。而亘古未曾开发的资源,今日亦正在开发。以此广大的资源,供此众多民族之用,今后世界的战争,不更将增加其惨酷的程度么?不,战争只是社会的变态。现在世界上战争的惨酷,都是帝国主义造成的,这亦是社会的一个变态,不过较诸既往,情形特别严重罢了。变态是决不能持久的。资本的帝国主义,已在开始崩溃了。我们虽有横绝一世的武力,大势所趋,决然要用之于打倒帝国主义之途,断不会加入帝国主义之内,而成为破坏世界和平的一分子。\n第四十六章 刑法 # 谈中国法律的,每喜考究成文法起于何时。其实这个问题,是无关紧要的。法律的来源有二:一为社会的风俗,一为国家对于人民的要求。前者即今所谓习惯,是不会著之于文字的。然其对于人民的关系,则远较后者为切。\n中国刑法之名,有可考者始于夏。《左传》昭公六年,载叔向写给郑子产的信,说:“夏有乱政而作《禹刑》,商有乱政而作《汤刑》,周有乱政而作《九刑》。”这三种刑法的内容,我们无从知其如何,然叔向这一封信,是因子产作刑书而起的。其性质,当和郑国的刑书相类。子产所作的刑书,我们亦无从知其如何,然昭公二十九年,《左传》又载晋国赵鞅铸刑鼎的事。杜《注》说:子产的刑书,也是铸在鼎上的。虽无确据,然士文伯讥其“火未出而作火以铸刑器”,其必著之金属物,殆无可疑。所能著者几何?而《书经·吕刑》说:“墨罚之属千;劓罚之属千;剕罚之属五百;宫罚之属三百;大辟之罚,其属二百;五刑之属三千。”请问如何写得下?然则《吕刑》所说,其必为习惯而非国家所定的法律,很明白可见了。个人在社会之中,必有其所当守的规则。此等规则,自人人如此言之,则曰俗。自一个人必须如此言之,则曰礼(故曰礼者,履也)。违礼,就是违反习惯,社会自将加以制裁,故曰:“出于礼者入于刑。”或疑三千条规则,过于麻烦,人如何能遵守?殊不知古人所说的礼,是极其琐碎的。一言一动之微,莫不有其当守的规则。这在我们今日,亦何尝不如此?我们试默数言语动作之间,所当遵守的规则,何减三千条?不过童而习之,不觉得其麻烦罢了。《礼记·礼器》说“曲礼三千”,《中庸》说“威仪三千”,而《吕刑》说“五刑之属三千”,其所谓刑,系施诸违礼者可知。古以三为多数。言千乃举成数之辞。以十言之而觉其少则曰百,以百言之而犹觉其少则曰千,墨劓之属各千,犹言其各居总数三分之一。剕罚之属五百,则言其居总数六分之一。还有六分之一,宫罚又当占其五分之三,大辟占其五分之二,则云宫罚之属三百,大辟之罚其属二百,这都是约略估计之辞。若真指法律条文,安得如此整齐呢?然则古代人民的生活,其全部,殆为习惯所支配是无疑义了。\n社会的习惯,是人人所知,所以无待于教。若有国有家的人所要求于人民的,人民初无从知,则自非明白晓谕不可。《周官》布宪,“掌宪邦之刑禁(‘宪谓表而县之’,见《周官·小宰注》),正月之吉,执邦之旌节,以宣布于四方。”而州长、党正、族师、闾胥,咸有属民读法之举。天、地、夏、秋四官,又有县法象魏之文。小宰、小司徒、小司寇、士师等,又有徇以木铎之说。这都是古代的成文法,用言语、文字或图画公布的。在当时,较文明之国,必无不如此。何从凿求其始于何时呢?无从自知之事,未尝有以教之,自不能以其违犯为罪,所以说“不教而诛谓之虐”(《论语·尧曰》)。而三宥、三赦之法,或曰不识,或曰遗忘,或曰老旄,或曰蠢愚(《周官·司刺》),亦都是体谅其不知的。后世的法律,和人民的生活,相去愈远;其为人民所不能了解,十百倍于古昔;初未尝有教之之举,而亦不以其不知为恕。其残酷,实远过于古代。即后世社会的习惯,责人以遵守的,亦远不如古代的合理。后人不自哀其所遭遇之不幸,而反以古代的法律为残酷,而自诩其文明,真所谓“溺人必笑”了。\n刑字有广狭二义:广义包括一切极轻微的制裁、惩戒、指摘、非笑而言。“出于礼者入于刑”,义即始此。曲礼三千,是非常琐碎的,何能一有违犯,即施以惩治呢?至于狭义之刑,则必以金属兵器,加伤害于人身,使其蒙不可恢复的创伤,方足当之。汉人说:“死者不可复生,刑者不可复属。”义即如此。此为刑字的初义,乃起于战阵,施诸敌人及间谍内奸的,并不施诸本族。所以司用刑之官曰士师(士是战士,士师谓战士之长),曰司寇。《周官》司徒的属官,都可以听狱讼,然所施之惩戒,至于圜土,嘉石而止(见下)。其附于刑者必归于士,这正和今日的司法机关和军法审判一般。因为施刑的器具(兵器),别的机关里,是没有的。刑之施及本族,当系俘异族之人,以为奴隶,其后本族犯罪的人,亦以为奴隶,而侪诸异族,乃即将异族的装饰,施诸其人之身。所以越族断发文身,而髠和黥,在我族都成为刑罪。后来有暴虐的人,把他推而广之,而伤残身体的刑罚,就日出不穷了。五刑之名,见于《书经·吕刑》。《吕刑》说:“苗民弗用灵,制以刑,惟作五虐之刑曰法。爰始淫为劓、刵、椓、黥。”劓、刵、椓、黥,欧阳、大小夏侯作膑、宫、劓、割头、庶勍(《虞书》标题下《疏》引)。膑即剕。割头即大辟。庶勍的庶字不可解,勍字即黥字,是无疑义的。然则今本的劓、刵、椓、黥是误字。《吕刑》的五刑,实苗民所创(苗民的民字乃贬辞,实指有苗之君,见《礼记·缁衣疏》引《吕刑》郑《注》)。《国语·鲁语》臧文仲说:“大刑用甲兵,其次用斧钺。中刑用刀锯,其次用钻窄。薄刑用鞭朴。大者陈之原野,小者肆之市、朝。”(是为“五服三次”。《尧典》说:“五刑有服,五服三就”,亦即此)大刑用甲兵,是指战阵。其次用斧钺,是指大辟。中刑用刀锯指劓、腓、宫。其次用钻窄指墨。薄刑用鞭朴,虽非金属兵器,然古人亦以林木为兵。(《吕览·荡兵》:“未有蚩尤之时,民固剥林木以战矣。”)《左传》僖公二十七年,楚子玉治兵,鞭七人,可见鞭亦军刑。《尧典》:“象以典刑,流宥五刑,鞭作官刑,朴作教刑。金作赎刑。”象以典刑,即《周官》的县法象魏。流宥五刑,当即《吕刑》所言之五刑。金作赎刑,亦即《吕刑》所言之法。所以必用金,是因古者以铜为兵器。可见所谓“亏体”之刑,全是源于兵争的。至于施诸本族的,则古语说“教笞不可废于家”,大约并鞭、朴亦不能用。最严重的,不过逐出本族之外,是即所谓流刑。《王制》的移郊、移逐、屏诸远方,即系其事。《周官》司寇有圜土、嘉石,皆役诸司空。圜土、嘉石,都是监禁;役诸司空,是罚做苦工,怕已是施诸奴隶的,未必施诸本族了。于此见残酷的刑罚,全是因战争而起的。五刑之中,妇人的宫刑,是闭于宫中(见《周官·司刑》郑《注》),其实并不亏体。其余是无不亏体的。《周官·司刑载》五刑之名,惟膑作刖,余皆与《吕刑》同。《尔雅·释言》及《说文》,均以、刖为一事。惟郑玄《驳五经异义》说:“皋陶改为膑为剕,周改剕为刖。”段玉裁《说文》髌字《注》说:膑是髌的俗字,乃去膝头骨,刖则汉人之斩止,其说殊不足据(髌乃生理名词,非刑名)。当从陈乔枞说,以为斩左趾,跀为并斩右趾为是(见《今文尚书·经说考》)。然则五刑自苗民创制以来,至作《周官》之时,迄未尝改。然古代亏体之刑,实并不止此。见于书传的,如斩(古称斩谓腰斩。后来战阵中之斩级,事与刑场上的割头异,无以名之,借用腰斩的斩字。再后来,斩字转指割头而言,腰斩必须要加一个腰字了)、磔(裂其肢体而杀之。《史记·李斯列传》作矺,即《周官·司戮》之辜)、膊(谓去衣磔之,亦见《周官·司戮》)、车裂(亦曰)、缢(《左传》哀公二年,“绞缢以戮”。绞乃用以缢杀人之绳,后遂以绞为缢杀)、焚(亦见《司戮》)、烹(见《公羊》庄公四年)、脯醢等都是。脯醢当系食人之族之俗,后变为刑法的。刵即馘(割耳),亦源于战争。《孟子》说文王之治岐也,罪人不孥(《梁惠王下篇》)。《左传》昭公二十二年引《康诰》,亦说父子兄弟,罪不相及。而《书经·甘誓》、《汤誓》,都有孥戮之文。可见没入家属为奴婢,其初亦是军法。这还不过没为奴隶而已,若所谓族诛之刑,则亲属都遭杀戮。这亦系以战阵之法,推之刑罚的。因为古代两族相争,本有杀戮俘虏之事。强宗巨家,一人被杀,其族人往往仍想报复,为预防后患起见,就不得不加以杀戮了。《史记·秦本纪》:文公二十年,“法初有三族之罪”(父母、兄弟、妻子),此法后相沿甚久。魏晋南北朝之世,政敌被杀的,往往牵及家属。甚至嫁出之女,亦不能免。可见战争的残酷了。\n古代的用法,其观念,有与后世大异的。那便是古代的“明刑”,乃所以“弼教”(“明于五刑,以弼五教”,见《书经·尧典》),而后世则但求维持形式上的互助。人和人的相处,所以能(一)平安无事,(二)而且还可以有进步,所靠的全是善意。苟使人对人,人对社会,所怀挟的全是善意,一定能彼此相安,还可以互相辅助,日进无疆,所做的事情,有无错误,倒是无关紧要的。若其彼此之间,都怀挟敌意,仅以慑于对方的实力、社会的制裁,有所惮而不敢为;而且进而作利人之事,以图互相交换,则无论其所行的事,如何有利于人,有利于社会,根本上总只是商业道德。商业道德,是决无以善其后的。人,本来是不分人我,不分群己的。然到后来,社会的组织复杂了,矛盾渐渐深刻,人我群己的利害,渐渐发生冲突,人,就有破坏他人或社会的利益以自利的。欲救此弊,非把社会阶级彻底铲除不可。古人不知此义,总想以教化来挽回世风。教化之力不足,则辅之以刑罚。所以其用法,完全注重于人的动机。所以说《春秋》断狱重志(《春秋繁露·精华篇》),所以说:“听讼吾犹人也,必也使无讼乎?无情者不得尽其辞,大畏民志,此谓知本。”(《大学》)此等希望,自然要终成泡影的。法律乃让步到不问人的动机,但要求其不破坏我所要维持的秩序为止。其用心如何,都置诸不问。法律至此,就失其弼教的初意,而只成为维持某种秩序的工具了。于是发生“说官话”的现象。明知其居心不可问,如其行为无可指摘,即亦无如之何。法律至此,乃自成为反社会之物。\n有一事,是后世较古代为进步的。古代氏族的界限,还未化除。国家的权力,不能侵入氏族团体之内,有时并不能制止其行动。(一)氏族员遂全处于其族长权力之下。此等风气在家族时代,还有存留。(二)而氏族与氏族间的争斗,亦往往靠实力解决。《左传》成公三年,知被楚国释放的时候,说“首(父),其请于寡君,而以戮于宗,亦死且不朽”。昭公二十一年,宋国的华费遂说:“吾有谗子而弗能杀。”可见在古代,父可专杀其子。《白虎通义·诛伐篇》却说“父杀其子当诛”了。《礼记》的《曲礼》、《檀弓》,均明著君父、兄弟、师长,交游报仇之礼。《周官》的调人,是专因报仇问题而设立的。亦不过令有仇者避之他处;审查报仇的合于义与否;禁止报仇不得超过相当限度而已,并不能根绝其事。报仇的风气,在后世虽相沿甚久,习俗上还视为义举,然在法律上,总是逐步遭遇到禁止的,这都是后世法律,较之古代进步之处。但家长或族长,到现在,还略有处置其家人或族众的权力,国家不能加以干涉,使人人都受到保护;而国家禁止私人复仇,而自己又不能真正替人民伸雪冤屈,也还是未尽善之处。\n法律是不能一天不用的,苟非文化大变,引用别一法系的法律,亦决不会有什么根本的改革。所以总是相承而渐变。中国最早的法典,是李悝的《法经》。据《晋书·刑法志》所载陈群《魏律序》,是悝为魏文侯相,撰次诸国法所为。魏文侯在位,据《史记·六国表》,是自周威烈王二年至安王十五年,即民国纪元前2336年至2298年。可谓很古老的了。撰次,便是选择排比。这一部书,在当时,大约所参考者颇博,且曾经过一番斟酌去取,依条理系统编排的,算做一部佳作。所以商君“取之以相秦”,没有重纂。这时候的趋势,是习惯之力(即社会制裁),渐渐的不足以维持社会,而要乞灵于法律。而法律还是谨守着古老的规模,所规定之事极少,渐觉其不够用,法经共分六篇:《魏律序》举其篇目,是(一)盗,(二)贼,(三)网,(四)捕,(五)杂,(六)又以一篇著其加减。盗是侵犯人的财产。贼是伤害人的身体。盗贼须网捕,所以有网捕两篇。其余的则并为杂律。古人著书,常将重要的事项,独立为篇,其余则并为一篇,总称为杂。一部自古相传的医书,号为出于张仲景的,分为伤寒、杂病两大部分(杂病或作卒病,乃误字),即其一证。网捕盗贼,分为四篇,其余事项,共为一篇,可见《法经》视盗贼独重,视其余诸事项都轻,断不足以应付进步的社会。汉高祖入关,却更做了一件违反进化趋势的事。他说:“吾与父老约法三章耳:杀人者死,伤人及盗抵罪。余悉除去秦法。”因为约法三章四字,给人家用惯了,很有些人误会:这是汉高祖与人民立约三条。其实据陈群《魏律序》,李悝《法经》的体例,是“集类为篇,结事为章”的。每一篇之中,包含着许多章。“吾与父老约:法,三章耳”,当以约字断句,法字再一读。就是说六篇之法,只取三章,其余五篇多,都把它废掉了。秦时的民不聊生,实由于政治太不安静。专就法律立论,则由于当时的狱吏,自成一种风气,用法务取严酷。和法律条文的多少,实在没有关系。但此理是无从和群众说起的。约法三章,余悉除去,在群众听起来,自然是欢欣鼓舞的了。这事不过是一时收买人心之术,无足深论。其事自亦不能持久。所以《汉书·刑法志》说:天下既定,“三章之法,不足以御奸”。萧何就把六篇之法恢复,且增益三篇;叔孙通又益以律所不及的旁章十八篇,共有二十七篇了。当时的趋势,是(一)法律内容要扩充,(二)既扩充了,自应依条理系统,加以编纂,使其不至杂乱。第一步,汉初已这么做了。武帝时,政治上事务繁多,自然需要更多的法律。于是张汤、赵禹又加增益,律共增至六十篇。又当时的命令,用甲、乙、丙、丁编次,通称谓之“令甲”,共有三百余篇。再加以断事的成案,即当时所谓比,共有九百零六卷。分量已经太多了,而编纂又极错乱。“盗律有贼伤之例,贼律有盗章之文。”引用既难,学者乃为之章句(章句二字,初指一种符号,后遂用以称注释,详见予所撰《章句论》。商务印书馆本),共有十余家。于是断罪所当由用者,合二万六千二百七十二条,七百七十三万二千二百余言。任何人不能遍览,奸吏因得上下其手,“所欲活者傅生议,所欲陷者予死比”。所以条理系统地编纂一部法典,实在是当时最紧要的事。汉宣帝时,郑昌即创其议。然终汉世,未能有成。魏篡汉后,才命陈群等从事于此。制成新律十八篇,未及颁行而亡。晋代魏后,又命贾充等复加订定,共为二十篇。于泰始四年,大赦天下颁行之,是为《晋律》。泰始四年,为民国纪元前1644年。\n《晋律》大概是将汉朝的律、令、比等,删除复重,加以去取,依条理系统编纂而成的。这不过是一个整理之业。但还有一件事可注意的,则儒家的宗旨,在此时必有许多,掺入法律之中,而成为条文。汉人每有援经义以折狱的。现代的人,都以为奇谈。其实这不过是广义的应用习惯。广义的习惯法,原可包括学说的。当时儒学盛行,儒家的学说,自然要被应用到法律上去了。《汉书》《注》引应劭说:董仲舒老病致仕。朝廷每有政议,数遣廷尉张汤至陋巷,问其得失。于是作《春秋折狱》二百三十二事。汉文帝除肉刑诏,所引用的就是《书》说(见下)。汉武帝亦使吕步舒(董仲舒弟子)治淮南狱。可见汉时的律、令、比中,掺入儒家学说处决不少。此等儒家学说,一定较法家为宽仁的。因为法家偏重伸张国家的权力,儒家则注重保存社会良好的习惯。章炳麟《太炎文录》里,有《五朝法律索隐》一篇,说《晋律》是极为文明的,但北魏以后,参用鲜卑法,反而改得野蛮了。如《晋律》,父母杀子同凡论,而北魏以后,都得减轻。又如走马城市杀人者,不得以过失论(依此,则现在马车、摩托,在市上杀人的,都当以故杀论。因为城市中行人众多,是行车者所预知的,而不特别小心,岂得谓之过失?难者将说:“如此,在城市中将不能行车了。文明愈进步,事机愈紧急,时间愈宝贵,处处顾及步行的人,将何以趋事赴功呢?”殊不知事机紧急,只是一个藉口。果有间不容发的事,如军事上的运输,外交上的使命,以及弭乱、救火、急救疾病等事,自可别立为法,然在今日,撞伤路人之事,由于此等原因者,共有几分之几呢?曾记在民国十至十二年之间,上海某外人,曾因嫌人力车夫走得慢,下车后不给车资,直向前行。车夫向其追讨,又被打伤。经领事判以监禁之罪。后其人延律师辩护,乃改为罚锾了事。问其起衅之由,则不过急欲赴某处宴会而已。从来鲜车怒马疾驰的人,真有紧急事情的,不知有百分之一否?真正紧要的事情,怕还是徒行或负重的人做的),部民杀长吏者同凡论,常人有罪不得赎等,都远胜于别一朝的法律。父杀其子当诛,明见于《白虎通义》,我们可以推想,父母杀子同凡论,渊源或出于儒家。又如法家,是最主张摧抑豪强的。城市走马杀人同凡论,或者是法家所制定。然则法律的改良,所得于各家的学说者当不少。学者虽然亦不免有阶级意识,究竟是为民请命之意居多。从前学者,所做的事情,所发的言论,我们看了,或不满意,此乃时代为之。近代的人,有时严责从前的学者,而反忽忘了当时的流俗,这就未免太不知社会的情形了。\n《晋律》订定以后,历代都大体相沿。宋、齐是未曾定律的。梁、陈虽各定律,大体仍沿《晋律》。即魏、周、齐亦然,不过略参以鲜卑法而已。《唐律》是现尚存在的,体例亦沿袭旧观。辽太祖时,定治契丹及诸夷之法,汉人则断以律令。太宗时,治渤海人亦依汉法。道宗时,以国法不可异施,将不合于律令者别存之。此所谓律令,还是唐朝之旧。金当熙宗时,始合女真旧制及隋、唐、辽、宋之法,定《皇统制》。然仍并用古律。章宗泰和时定律,《金史》谓其实在就是《唐律》。元初即用金律。世祖平宋以后,才有所谓《至元新格》、《大元通制》等,亦不过将新生的法令事例,加以编辑而已。明太祖定《大明律》,又是一准《唐律》的。《法律》又以《明律》为本。所以从《晋律》颁行以后,直至清末采用西法以前,中国的法律,实际无大改变。\n法律的性质,既如此陈旧,何以仍能适用呢?(一)由向来的法律,只规定较经久之事。如晋初定律,就说关于军事、田农、酤酒等,有权设其法,未合人心的,太平均当删除,所以不入于律,别以为令。又如北齐定律,亦有《新令》四十卷和《权令》二卷,与之并行。此等区别,历代都有。总之非极永久的部分,不以入律,律自然可少变动了。(二)则律只揭举大纲。(甲)较具体及(乙)变通的办法,都在令及比之中。《唐书·刑法志》说:“唐之刑书有四:曰律、令、格、式。令者,尊卑贵贱之等数,国家之制度也。格者,百官有司所常行之事也。式者,其所常守之法也(宋神宗说:‘设于此以待彼之谓格,使彼效之谓式。’见《宋史·刑法志》)。凡邦国之政,必从事于此三者。其有所违,及人之为恶而入于罪戾者,一断以律。”令、格、式三者,实不可谓之刑书。不过现代新生的事情,以及办事所当依据的手续,都在其中,所以不得不与律并举。律所载的事情,大约是很陈旧而不适宜于具体应用的,但为最高原理所自出,又不便加以废弃。所以宋神宗改律、令、格、式之名为敕、令、格、式,而“律恒存乎敕之外”。这即是实际的应用,全然以敕代律了。到近代,则又以例辅律。明孝宗弘治十三年,刑官上言:“中外巧法吏,或借例便私,律浸格不用。”于是下尚书,会九卿议,增历年问刑条例,经久可行者二百九十七条。自是以后,律例并行。清朝亦屡删定刑例。至乾隆以后,遂载入律内,名为《大清律例》。按例乃据成案编纂而成,成案即前世所谓比。律文仅举大纲,实际应用时,非有业经办理的事情,以资比附不可,此比之所以不能不用。然成案太多,随意援引,善意者亦嫌出入太大,恶意者则更不堪设想,所以又非加以限制不可。由官加以审定,把(一)重复者删除;(二)可用者留;(三)无用者废;(四)旧例之不适于用者,亦于同时加以废止。此为官修则例之所由来,不徒(一)杜绝弊端,(二)使办事者得所依据,(三)而(甲)社会上新生的事态,日出不穷;(乙)旧有之事,定律时不能无所遗漏;(丙)又或法律观念改易,社会情势变迁,旧办法不适于今;皆不可不加补正。有新修刑例以济之,此等问题,就都不足为患了。清制:刑例五年一小修,十年一大修(事属刑部,临时设馆),使新成分时时注入于法律之中;陈旧而不适用者,随时删除,不致壅积。借实际的经验,以改良法律,实在是很可取法的。\n刑法自汉至隋,起了一个大变化。刑字既引申为广义,其初义,即专指伤害人之身体,使其蒙不可恢复的创伤的,乃改称为“肉刑”。晚周以来,有一种象刑之论,说古代对于该受五刑的人,不须真加之以刑,只要异其冠服以为戮。此乃根据于《尧典》之“象以典刑”的,为儒家的书说。按象以典刑,恐非如此讲法(见前)。但儒家所说的象刑,在古代是确有其事的。《周官》有明刑(见司救)、明梏(见掌囚),乃是将其人的姓名罪状,明著之以示人。《论衡·四讳篇》说:当时“完城旦以下,冠带与俗人殊”,可见历代相沿,自有此事,不过在古代,风气诚朴,或以此示戒而已足,在后世则不能专恃此罢了。儒家乃根据此种习俗,附会《书经》象以典刑之文,反对肉刑的残酷。汉孝文帝十三年,齐太仓令淳于公有罪,当刑。防狱逮系长安。淳于公无男,有五女。会逮,骂其女曰:“生子不生男,缓急非有益也。”其少女缇萦,自伤悲泣。乃随其父至长安,上书愿没入为官婢,以赎父刑罪。书奏,天下怜悲其意。遂下令曰:“盖闻有虞氏之时,画衣冠异章服以为戮而民弗犯,何治之至也?今法有肉刑三,而奸不止,其咎安在?夫刑至断肢体,刻肌肤,终身不息,何其刑之痛而不德也?岂称为民父母之意哉?其除肉刑,有以易之。”于是有司议:当黥者髠钳为城旦舂。当劓者笞三百。当斩左趾者笞五百。当斩右趾者弃市。按诏书言今法有肉刑三,《注》引孟康曰:“黥、劓二,斩左右趾合一,凡三也。”而景帝元年诏,说孝文皇帝除宫刑。诏书下文刻肌肤指黥,断肢体指劓及斩趾,终身不息当指宫,则是时实并宫刑废之。惟系径废而未尝有以为代,故有司之议不之及。而史亦未尝明言。此自古人文字疏略,不足为怪。至景帝中元年,《纪》载“死罪欲腐者许之”,则系以之代死罪,其意仍主于宽恤。然宫刑自此复行,直至隋初方除。象刑之论,《荀子》极驳之。《汉书·刑法志》备载其说,自有相当的理由。然刑狱之繁,实有别种原因,并非专用酷刑可止。《庄子·则阳篇》说:“柏矩至齐,见辜人焉。推而强之。解朝服而幕之。号天而哭之。曰:子乎子乎?天下有大菑,子独先离之。曰:莫为盗,莫为杀人。荣辱立,然后睹所病,货财聚,然后睹所争,今立人之所病,聚人之所争,穷困人之身,使无休时,欲无至此,得乎。匿为物而愚不识,大为难而罪不敢,重为任而罚不胜,远其涂而诛不至。民知力竭,则以伪继之。日出多伪,士民安取不伪?夫力不足则伪,知不足则欺,财不足则盗。盗窃之行,于谁责而可乎?”这一段文字,见得所谓犯罪者,全系个人受社会的压迫,而无以自全;受社会的教育,以至不知善恶(日出多伪,士民安取不伪),其所能负的责任极微。更以严刑峻法压迫之,实属不合于理。即不论此,而“民不畏死,奈何以死惧之”(《老子》),于事亦属无益。所以“孟氏使阳肤为士师,问于曾子。曾子曰:上失其道,民散久矣。如得其情,则哀矜而勿喜”(《论语·子张》),这固然不是彻底的办法。然就事论事,操司法之权的,存心究当如此。司法上的判决,总不能无错误的。别种损失,总还可设法回复,惟有肉刑,是绝对无法的,所以古人视之甚重。这究不失为仁人君子的用心。后来反对废除肉刑的人,虽亦有其理由,然肉刑究竟是残酷的事,无人敢坚决主张,始终没有能够恢复。这其中,不知保全了多少人。孝文帝和缇萦,真是历史上可纪念的人物了。反对废除肉刑的理由安在呢?《文献通考》说:“汉文除肉刑,善矣,而以髠笞代之。髠法过轻,而略无惩创;笞法过重,而至于死亡。其后乃去笞而独用髠。减死罪一等,即止于髠钳;进髠钳一等,即入于死罪。而深文酷吏,务从重比,故死刑不胜其众。魏晋以来病之。然不知减笞数而使之不死,徒欲复肉刑以全其生,肉刑卒不可复,遂独以髠钳为生刑。所欲活者傅生议,于是伤人者或折肢体,而才翦其毛发。所欲陷者与死比,于是犯罪者既已刑杀,而复诛其宗亲。轻重失宜,莫此为甚。隋唐以来,始制五刑,曰笞、杖、徒、流、死。此五者,即有虞所谓鞭、朴、流、宅,虽圣人复起,不可偏废也。”按自肉刑废除之后,至于隋代制定五刑之前,刑法上的问题,在于刑罚的等级太少,用之不得其平。所以司法界中有经验的人士,间有主张恢复肉刑的。而读书偏重理论的人,则常加反对。恢复肉刑,到底是件残酷的事,无人敢坚决主张,所以肉刑终未能复。到隋朝制定五刑以后,刑罚的等级多了,自无恢复肉刑的必要,从此以后,也就无人提及了。自汉文帝废除肉刑至此,共历七百五十余年。一种制度的进化,可谓不易了。\n隋唐的五刑,是各有等级的。其中死刑分斩、绞两种。而除前代的枭首、裂等。元以异族入主中原,立法粗疏,且偏于暴虐。死刑有斩无绞。又有凌迟处死,以处恶逆。明清两代均沿之。明代将刑法军政,并为一谈。五刑之外,又有所谓充军。分附近、沿海、边远、烟瘴、极边五等(清分附近、近边、边远、极边、烟瘴五等)。有终身、永远两种。永远者身死之后,又勾摄其子孙;子孙绝者及其亲属(已见上章)。明制:“二死三流,同为一减。”太祖为求人民通晓法律起见,采辑官民过犯条文,颁行天下,谓之《大诰》。囚有《大诰》的,罪得减等。后来不问有无,一概作为有而减等。于是死刑减至流刑的,无不以《大诰》再减,流刑遂等于不用。而充军的却很多。清朝并不藉谪发维持军籍,然仍沿其制,为近代立法史上的一个污点。\n●唐律疏议\n刑法的改良,起于清末的改订旧律。其时改笞杖为罚金,以工作代徒流。后来定《新刑律》,才分主刑为死刑(用绞,于狱中行之)、无期徒刑、有期徒刑、拘役、罚金五种。从刑为没收,褫夺公权两种。\n审判机关,自古即与行政不分。此即《周官》地官所谓“地治者”。但属于秋官的官,如乡士(掌国中)、遂士(掌四郊)、县士(掌野)、方士(掌都家)等,亦皆以掌狱讼为职。地官、秋官,本当有行政官与军法审判之别,读前文可明,但到后来,这两者的区别,就渐渐的泯灭了。欧洲以司法独立为恤刑之法,中国则以(一)缩小下级官吏定罪的权限,如(二)增加审级,为恤刑之法。汉代太守便得专杀,然至近代,则府、厅、州、县,只能决徒以下的罪,流刑必须由按察司亲审,死刑要待御笔勾决了。行政、司法机关既不分,则行政官吏等级的增加,即为司法上审级的增加。而历代于固有的地方官吏以外,又多临时派官清理刑狱。越诉虽有制限,上诉是习惯上得直达皇帝为止的,即所谓叩阍。宋代初命转运使派官提点刑狱,后独立为一司,明朝继之,设按察司,与布政使并立,而监司之官,始有专司刑狱的。然及清代,其上级的督抚,亦都可受理上诉。自此以上,方为京控(刑部、都察院、提督,均可受理)。临时派官复审,明代尤多。其后朝审、秋审,遂沿为定制。清代秋审是由督抚会同两司举行的。决定后由刑部汇奏。再命三法司(见下)复审,然后御笔勾决,死刑乃得执行。在内的则由六部、大理寺、通政司、都察院会审,谓之朝审。此等办法,固得慎重刑狱之意。然审级太多,则事不易决。又路途遥远,加以旷日持久,人证物证,不易调齐,或且至于湮灭,审判仍未必公平,而人民反因狱事拖延受累。所以此等恤刑之法,亦是有利有弊的。\n司法虽不独立,然除特设的司法官吏而外,干涉审判之官,亦应以治民之官为限。如此,(一)系统方不紊乱。(二)亦且各种官吏,对于审判,未必内行,令其干涉,不免无益有损。然历代既非司法之官,又非治民之官,而参与审判之事者,亦在所不免。如御史,本系监察之官,不当干涉审判。所以弹劾之事,虽有涉及刑狱的,仍略去告诉人的姓名,谓之风闻。唐代此制始变,且命其参与推讯,至明,遂竟称为三法司之一了。而如通政司、翰林院、詹事府、五军都督等,无不可临时受命,与于会审之列,更属莫名其妙。又司法事务,最忌令军政机关参与。而历代每将维持治安及侦缉罪犯之责,付之军政机关。使其获得人犯之后,仍须交给治民之官,尚不易非理肆虐,而又往往令其自行治理,如汉代的司隶校尉,明代的锦衣卫、东厂等,尤为流毒无穷。\n审判之制,贵于速断速决,又必熟悉本地方的民情。所以以州县官专司审制,于事实嫌其不给。而后世的地方官,多非本地人,亦嫌其不悉民情。廉远堂高,官民隔膜,吏役等遂得乘机舞弊。司法事务的黑暗,至于书不胜书。人民遂以入公门为戒。官吏无如吏役何,亦只得劝民息讼。国家对于人民的义务,第一事,便在保障其安全及权利,设官本意,惟此为急。而官吏竟至劝人民不必诉讼,岂非奇谈?古代所谓“地治者”,本皆后世乡吏之类,汉代啬夫,还是有听讼之职的(《汉书·百官公卿表》)。爰延为外黄乡啬夫,民至不知有郡县(《后汉书》本传),其权力之大可知。然治者和被治者,既形成两个阶级,治者专以朘削被治者为生,则诉讼正是朘削的好机会,畀乡吏以听讼之权,流弊必至不可究诘。所以至隋世,遂禁止乡官听讼。《日知录·乡亭之职》一条说:“今代县门之前,多有榜曰:诬告加三等,越诉笞五十。此先朝之旧制。今人谓不经县官而上诉司府,谓之越诉,是不然。《太祖实录》:洪武二十七年,命有司择民间高年老人,公正可任事者,理其乡之辞讼。若户婚、田宅、斗殴者,则会里胥决之。事涉重者,始白于官。若不由里老处分,而径诉县官,此之谓越诉也。”则明太祖尝有意恢复乡官听讼之制。然《注》又引宣德七年陕西按察佥事林时之言,谓“洪武中,天下邑里,皆置申明,旌善二亭,民有善恶则书之,以示劝惩。凡户婚、田土、斗殴常事,里老于此剖决。今亭宇多废,善恶不书。小事不由里老,辄赴上司。狱讼之繁,皆由于此”。则其事不久即废。今乡官听讼之制,固不可行。然法院亦难遍设。民国十五年,各国所派的司法调查委员(见下),以通计四百万人乃有一第一审法院,为我国司法状况缺点之一。中国人每笑西洋人的健讼,说我国人无须警察、司法,亦能相安,足见道德优于西人。其实中国人的不愿诉讼,怕也是司法状况的黑暗逼迫而成的,并非美事。但全靠法院平定曲直,确亦非良好现象。不须多设法院,而社会上亦能发扬正义,抑强扶弱,不至如今日之豪暴横行;乡里平亭,权又操于土豪劣绅之手,是为最善。那就不得不有望于风俗的改良了。\n古代的法律,本来是属人主义的,中国疆域广大,所包含的民族极多。强要推行同一的法律,势必引起纠纷。所以自古即以“不求变俗”为治(《礼记·曲礼》),统一以后,和外国交通,亦系如此。《唐律》:化外人犯罪,就依其国法治之。必两化外人相犯,不能偏据一国的法律,才依据中国法律治理。这种办法,固然是事实相沿,然决定何者为罪的,根本上实在是习惯。两族的习惯相异,其所认为犯罪之事,即各不相同。“照异族的习惯看起来,虽确有犯罪的行为,然在其本人,则实无犯罪的意思。”在此情形之下,亦自以按其本族法律治理为公平。但此项办法,只能适用于往来稀少之时。到近代世界大通,交涉之事,日益繁密,其势就不能行了。中国初和外国订约时,是不甚了然于另一新局面的来临的。一切交涉,都根据于旧见解以为应付,遂贸然允许了领事裁判权。而司法界情形的黑暗(主要的是司法不独立,监狱的黑暗,滥施刑讯及拘押等),有以生西人的戒心,而为其所藉口,亦是无可讳言的(从事有领事裁判权的国家,如土耳其,有虐待异教徒的事实,我国则无之。若说因习惯的不同,则应彼此皆有)。中外条约中,首先获得领事裁判权的是英国,后来各国相继获得。其条文彼此互异,然因各国条约均有最惠国条款,可以互相援引,所以实际上并无甚异同。有领判权之国,英、美、意、挪威、日本,均在我国设立法院。上海的会审公廨,且进而涉及原被告均为华人的事件。其损害我国的主权,自然无待于言了。然各国亦同蒙其不利(最重要的,如领事不晓法律,各国相互之间,亦须各归其国的领事审判。一件事情,关涉几国人的,即须分别向各国起诉。又上诉相距太远,即在中国设有法院之国亦然,其他更不必论了)。且领事裁判权存在,中国决不能许外国人在内地杂居,外人因此自限制其权利于通商口岸,亦殊不值得。取消领事裁判权之议,亦起于《辛丑条约》。英、美、日三国商约,均有俟我法律及司法制度改良后,撤消领事裁判权的条文。太平洋会议,我国提出撤消领事裁判权案,与会各国,允共同派员,到中国来调查:(一)各国在我国的领事裁判权的现状,(二)我国的法律,(三)司法制度,(四)司法行政情形,再行决定。十五年,各国派员来华调查,草有报告书,仍主从缓。国民政府和意、丹、葡、西四国,订立十九年一月一日放弃领事裁判权的条约。比约则订明另定详细办法。倘详细办法尚未订定,而现有领事裁判权之国,过半数放弃,则比国亦放弃。中国在诸约中,订定(一)十九年一月一日以前,颁布民商法;(二)撤消领事裁判权之后,许外人内地杂居;(三)彼此侨民课税,不得高于他国人,或异于他国人,以为交换条件。然此约订定之后,迄今未能实行。惟墨西哥于十八年十一月,自动宣言放弃(德、奥、俄等国,欧战后即失其领事裁判权)。\n撤消领事裁判权,其实是不成问题的,只要我国司法,真能改良,自不怕不能实行。我国的司法改良,在于(一)彻底改良司法界的状况,(二)且推行之及于全国,此即所谓“司法革命”、“司法普及”。既须经费,又须人才,又须行政上的努力,自非易事。自前清末年订定四级三审制(初级、地方、高等三厅及大理院。初审起于初级厅的,上诉终于高等厅,起于地方厅的,终于大理院)至民国二十二年,改为三级三审(地方法院、高等法院、最高法院)。前此司法多由县知事兼理,虽订有种种章程,究竟行政司法,分划不清。二十四年起,司法部已令全国各地,遍设法院。这都是比较合理的。真能推行尽利,我国的司法自可焕然改观了。\n第四十七章 实业 # 农工商三者,并称实业,而三者之中,农为尤要。必有农,然后工商之技,乃可得而施。中国从前,称农为本业,工商为末业,若除去其轻视工商,几乎视为分利之意,而单就本末两字的本义立论,其见解是不错的。所以农业的发达,实在是人类划时代的进步。有农业,然后人类的食物,乃能为无限制的扩充,人口的增加,才无限制。人类才必须定居。一切物质文明,乃有基础。精神文化,亦就渐次发达了。人类至此,剩余的财产才多,成为掠夺的目的。劳力亦更形宝贵,相互间的战争,自此频繁,社会内部的组织,亦更形复杂了。世界上的文明,起源于几个特别肥沃的地点。比较正确的历史,亦是自此开始的。这和农业有极深切的关系,而中国亦是其中之一。\n在农业开始以前,游猎的阶段,很为普遍。在第三十七章中,业经提及。渔猎之民,视其所居之地,或进为畜牧,或进为农耕。中国古代,似乎是自渔猎径进于农耕的。传说中的三皇:燧人氏钻木取火,教民熟食,以避腥臊伤害肠胃,显然是渔猎时代的酋长。伏羲,亦作庖牺。皇甫谧《帝王世纪》,说为“取牺牲以供庖厨”(《礼记·月令疏》引),实为望文生义。《白虎通义·号篇》云:“下伏而化之,故谓之伏羲”,则羲字与化字同义,所称颂的乃其德业。至于其时的生业,则《易·系辞传》明言其“为网罟以田以渔”,其为渔猎时代的酋长,亦无疑义。伏羲之后为神农。“斫木为耜,揉木为耒”,就正式进入农业时代,我国文明的历史,从此开始了。三皇之后为五帝。颛顼、帝喾,可考的事迹很少。黄帝“教熊、羆、貔、貅、虎”,以与神农战,似乎是游牧部落的酋长。然这不过是一种荒怪的传说,《五帝本纪》同时亦言其“艺五种”,而除此之外,亦绝无黄帝为游牧民族的证据。《尧典》则有命羲和“历象日、月、星辰,敬授民时”之文。《尧典》固然是后人所作,并非当时史官的记录。然后人所作,亦不能谓其全无根据。殷周之祖,是略与尧舜同时的。《诗经》中的《生民》、《公刘》,乃周人自述其祖宗之事,当不致全属子虚。《书经》中的《无逸》,乃周公诰诫成王之语,述殷周的历史,亦必比较可信。《无逸》中述殷之祖甲云:“其在祖甲,不义惟王,旧为小人。作其即位,爰知小人之依。”(祖甲实即太甲。“不义惟王,旧为小人”,正指其为伊尹所放之事)述高宗云:“旧劳于外,爰暨小人。”皆显见其为农业时代的贤君。周之先世,如太王、王季、文王等,更不必论了。古书的记载,诚多未可偏信。然合全体而观之,自五帝以来,社会的组织,和政治上的斗争,必与较高度的文明相伴,而非游牧或渔猎部族所能有。然则自神农氏以后,我国久已成为农业发达的民族了。古史年代,虽难确考,然孟子说:“由尧舜至于汤,五百有余岁。由汤至于文王,五百有余岁。由文王至于孔子,五百有余岁。”(《尽心下篇》)和韩非子所谓殷周七百余岁,虞夏二千余岁(《显学篇》);乐毅《报燕惠王书》所谓“收八百岁之畜积”(谓齐自周初建国,至为昭王所破时),大致都相合的,决不会是臆造。然则自尧舜至周末,当略近二千年。自秦始皇统一天下至民国纪元,相距2132年。自尧舜追溯农业发达之时,亦必在千年左右。我国农业发达,总在距今五千年之前了。\n中国的农业,是如何进化的呢?一言以蔽之,曰:自粗耕进于精耕。古代有爰田之法。爰田即系换田。据《公羊》宣公十五年何《注》,是因为地有美恶,“肥饶不得独乐,硗确不得独苦”,所以“三年一换主易居”。据《周官》大司徒:则田有不易,一易,再易之分。不易之地,是年年可种的。一易之地,种一年要休耕一年。再易之地,种一年要休耕两年。授田时:不易之地,一家给一百亩。一易之地,给二百亩。再易之地,给三百亩。古代的田亩,固然较今日为小。然一夫百亩,实远较今日农夫所耕为大。而其成绩,则据《孟子》(《万章下篇》)和《礼记·王制》所说:是上农夫食九人,其次食八人,其次食七人,其次食六人,下农夫食五人。较诸现在,并不见得佳良,可见其耕作之法,不及今人了。汉朝有个大农业家赵过,能为代田之法。把一亩分做三个甽,播种于其中。甽以外的高处谓之陇。苗生叶以后,要勤除陇上之草,因而把陇上的土,倾颓下来,使其附着苗根。如此逐渐为之,到盛暑,则“陇尽而根深”,能够“耐风与旱”。甽和陇,是年年更换的,所以谓之代田(见《汉书·食货志》)。后来又有区田之法。把田分为一块一块的,谓之区。隔一区,种一区。其锄草和颓土,亦与代田相同。《齐民要术》(见下)极称之。后世言农业的人,亦多称道其法。但据近代研究农业的人说:则“代田区田之法,不外乎所耕者少,而耕作则精。近世江南的农耕,较诸古人所谓代田区田,其精勤实无多让。其田并不番休,而地力亦不见其竭。则其施肥及更换所种谷物之法,亦必有精意存乎其间。”这许多,都是农业自然的进步。总而言之:农业有大农制和小农制。大农制的长处,在于资本的节约,能够使用机械,及人工的分配得宜。小农制的长处,则在以人尽其劳,使地尽其力。所以就一个人的劳力,论其所得的多少,是大农制为长。就土地同一的面积,论其所得的多少,则小农制为胜。中国农夫的技能,在小农制中,总可算首屈一指了。这都是长时间自然的进化。\n中国农业进化的阻力,约有三端:(一)为讲究农学的人太少。即使有之,亦和农民隔绝,学问不能见诸实用。古代有许多教稼的官。如《周官》大司徒,“辨十有二壤之物而知其种”。司稼,“巡邦野之稼,而辨穜稑之种。周知其名与其所宜地,以为法而悬于邑闾”。这些事,都是后世所没有的。李兆洛《凤台县志》说,凤台县人所种的地,平均是一人十六亩。穷苦异常,往往不够本,一到荒年,就要无衣无食。县人有一个唤做郑念祖的,雇佣了一个兖州人。问他:你能种多少园地?他说两亩,还要雇一个人帮忙。问他要用多少肥料?他说一亩田的肥料,要值到两千个铜钱。间壁的农人听了大笑,说:我种十亩地,只花一千个铜钱的肥料,收获的结果,还往往不够本呢?郑念祖对于这个兖州人,也是将信将疑。且依着他的话试试看呢,因其用力之勤,施肥之厚,人家的作物,都没有成熟,他的先就成熟了,而且长得很好。争先入市,获利甚多。到人家蔬果等上市时,他和人家一块卖的,所得的都是赢利了。李兆洛据此一例,很想募江南的农民为农师,以开水田。这不过是一个例。其余类乎此的情形,不知凡几。使农民互相师,已可使农业获有很大的进步,何况益之以士大夫?何况使士大夫与农民互相师,以学理经验,交相补足呢?(二)古代土地公有,所以沟洫阡陌等,都井井有条。后世则不然。土地变为私有,寸寸割裂。凡水旱蓄泄等事,总是要费掉一部分土地的,谁肯牺牲?凡一切公共事业的规划,其根源,实即公共财产的规划。所以土地公有之世,不必讲地方自治,而自治自无不举。土地既已私有,公共的事务,先已无存。间有少数非联合不能举办的,则公益和私益,多少有些冲突。于是公益的举措,固有的荡然无存,当兴的阙而莫举;而违反公益之事,且日出不穷。如滥伐林木,破坏堤防,壅塞沟渠等都是。而农田遂大受其害。其最为显著的,就是水利。(三)土地既然私有了,人民谁不爱护其私产?但必使其俯仰有余,且勤劳所得,可以为其所有,农民才肯尽力。如其一饱且不可得,又偶有赢余,即为强有力者剥削以去,人民安得不苟偷呢?然封建势力和高利贷的巧取豪夺,则正是和这原则相反的。这也是农田的一个致命伤。职是故,农业有其进化的方面,而亦有其退化的方面。进退相消,遂成为现在的状况。\n中国现在农业上的出路,是要推行大农制。而要推行大农制,则必须先有大农制所使用的器具。民国十七年春,俄国国营农场经理马克维次(Markevich),有多余不用的机犁百架,召集附近村落的农民,许租给他们使用,而以他们所有的土地,共同耕种为条件。当时加入的农民,其耕地,共计九千余亩。到秋天,增至二万四千余亩。事为共产党所闻,于是增制机犁,并建造使用机犁的动力场。至明年,遂推行其法于全国。是为苏俄集合农场的起源(据张君劢《史泰林治下之苏俄》。再生杂志社本)。天下事口说不如实做。瘏口哓音,说了半天的话,人家还是不信。实在的行动当前,利害较然可见,就无待烦言了。普通的议论,都说农民是最顽固的、守旧的。其实这是农民的生活,使其如此。现在为机器时代。使用旧式的器具,决不足以与之相敌。而全国最多数的农民,因其生活,而滞留于私有制度下自私自利的思想,亦实为文化进步的障碍。感化之法,单靠空言启牖,是无用的。生活变则思想变;生产的方法变,则生活变。“牖民孔易”,制造出耕作用的机械来,便是化除农民私见的方法。并不是要待农民私见化除了,机械才可使用。\n中国的农学,最古的,自然是《汉书·艺文志》诸子略中的农家。其所著录先秦的农书,今已不存。先秦农家之说,存于今的,只有《管子》中的《地员》,《吕氏春秋》中的《任地》、《辨土》、《审时》数篇。汉代农家所著之书,亦俱亡佚。诸家征引,以氾胜之书为最多。据《周官》草人疏说,这是汉代农书中最佳的,未知信否。古人著述,流传到现在的,以后魏贾思勰的《齐民要术》为最早。后世官修的巨著,有如元代的《农桑辑要》,清代的《授时通考》;私家的巨著,有如元王桢的《农书》,明徐光启的《农政全书》等,均在子部农家中。此项农书,所包颇广。种植而外,蚕桑、苹果、树木、药草、孳畜等,都包括其中。田制、劝课、救荒之法,亦均论及,尚有茶经、酒史、食谱、花谱、相牛经、相马经等,前代亦隶农家,清四库书目改入谱录类。兽医之书,则属子部医家。这些,都是和农业有关系的。旧时种植之法,未必都能适用于今。然要研究农业历史的人,则不可以不读。\n蚕桑之业,起于黄帝元妃嫘祖,语出《淮南·蚕经》(《农政全书》引),自不足信。《易·系辞传》说:“黄帝、尧、舜,垂衣裳而天下治。”《疏》云:“以前衣皮,其制短小,今衣丝麻布帛,所作衣裳,其制长大,故云垂衣裳也。”亦近附会。但我国的蚕业,发达是极早的。孟子说:“五亩之宅,树之以桑,七十者可以衣帛矣。”(《梁惠王上篇》)久已成为农家妇女普遍的职业了。古代蚕利,盛于北方。《诗经》中说及蚕桑的地方就很多。《禹贡》兖州说桑土既蚕,青州说厥篚厂丝。厂是山桑,这就是现在的野蚕丝了。齐纨、鲁缟,汉世最为著名。南北朝、隋、唐货币都通用布帛。唐朝的调法,亦兼收丝麻织品。元朝还有五户丝及二户丝。可见北方蚕桑之业,在元代,尚非不振,然自明以后,其利就渐限于东南了。唐甄《潜书》说:“蚕桑之利,北不逾淞,南不逾浙,西不通湖,东不至海,不过方千里,外此则所居为邻,相隔一畔而无桑矣(此以盛衰言之,并非谓绝对无有,不可拘泥)。甚矣民之惰也。”大概中国文化,各地不齐,农民愚陋,只会蹈常习故。便是士和工商亦然。所以全国各地,风气有大相悬殊的。《日知录》说:“华阴王宏撰著议,以为延安一府,布帛之价,贵于西安数倍。”又引《盐铁论》说:“边民无桑麻之利,仰中国丝絮而后衣。夏不释褐,冬不离窟。”崔寔《政论》说:“仆前为五原太守,土俗不知缉绩。冬积草伏卧其中。若见吏,以草缠身,令人酸鼻。”顾氏说:“今大同人多是如此。妇人出草,则穿纸袴。”可见有许多地方,荒陋的情形,竟是古今一辙。此等情形,昔人多欲以补救之法,责之官吏,间亦有能行之的。如清乾隆时,陈宏谋做陕西巡抚。曾在西安、三原、凤翔设蚕馆、织局,招南方机匠为师。又教民种桑。桑叶、茧丝,官家都许收买,使民节节得利,可以踊跃从事,即其一例。但究不能普遍。今后交通便利,资本的流通,遍及穷乡僻壤,此等情形,必将渐渐改变了。\n林政:愈到后世而愈坏。古代的山林,本是公有的,使用有一定的规则,如《礼记·王制》说“草木黄落,然后入山林”是。亦或设官管理,如《周官》的林衡是。又古代列国并立,务于设险,平地也有人造的森林,如《周官》司险,设国之五沟、五涂,而树之林,以为阻固是。后世此等事都没有了。造林之事极少,只是靠天然的使用。所以愈开辟则林木愈少。如《汉书·地理志》说,天水、陇西,山多林木,人民都住在板屋里。又如近代,内地的木材,出于四川、江西、贵州,而吉、黑二省,为全国最大的森林区域,都是比较上少开辟的地方。林木的缺乏,积极方面,由于国家不知保护森林,更不知造林之法。如清朝梅曾亮,有《书棚民事》一篇。他说当他替安徽巡抚董文恪做行状时,遍览其奏议,见其请准棚民开山的奏折,说棚民能攻苦食淡于崇山峻岭,人迹不通之处,开种旱谷,有裨民食,和他告讦的人,都是溺于风水之说,至有以数百亩之田,保一棺之土的,其说必不可听。梅氏说:“予览其说而是之。”又说:“及予来宣城,问诸乡人,则说:未开之山,土坚石固,草树茂密,腐叶积数年,可二三寸。每天雨,从树至叶,从叶至土石,历石罅滴沥成泉,其下水也缓。又水缓而土不随其下。水缓,故低田受之不为灾;而半月不雨,高田犹受其灌溉。今以斤斧童其山,而以锄犁疏其土,一雨未毕,沙石随下,其情形就大不然了。”梅氏说:“予亦闻其说而是之。”又说:“利害之不能两全也久矣。由前之说,可以息事。由后之说,可以保利。若无失其利,而又不至于董公之所忧,则吾盖未得其术也。”此事之是非,在今日一言可决。而当时或不之知,或作依违之论。可见昔人对于森林的利益,知之不甚透澈。自然不知保护,更说不到造林;历代虽有课民种桑枣等法令,亦多成为具文了。消极方面,则最大的为兵燹的摧残,而如前述开垦时的滥伐,甚至有放火焚毁的,亦是其一部分的原因。\n渔猎畜牧,从农业兴起以后,就不被视为主要的事业。其中惟因猎,因和武事有关,还按时举行,藉为阅习之用。渔业,则被视为鄙事,为人君所弗亲。观《左传》隐公五年所载臧僖伯谏观渔之辞可见。牧业,如《周官》之牧人、牛人、充人等,所豢养的,亦仅以供祭祀之用。只有马,是和军事、交通,都有关系的,历代视之最重,常设“苑”、“监”等机关,择适宜之地,设官管理。其中如唐朝的张万岁等,亦颇有成绩。然能如此的殊不多。以上是就官营立论。至于民间,规模较大的,亦恒在缘边之地。如《史记·货殖列传》说,天水、陇西、北地、上郡,畜牧为天下饶。又如《后汉书·马援传》说,援亡命北地,因留畜牧,役属数百家。转游陇汉间,因处田牧,至有牛马羊数千头,谷数万斛是。内地民家,势不能有大规模的畜牧。然苟能家家畜养,其数亦必不少。如《史记·平准书》说,武帝初年,“众庶街巷有马,阡陌之间成群”。元朔六年,卫青、霍去病出塞,私负从马至十四万匹(《汉书·匈奴列传》。颜师古《注》:“私负衣装者,及私将马从者,皆非公家发与之限。”),实在是后世所少见的。民业虽由人民自营,然和国家的政令,亦有相当的关系。唐玄宗开元九年,诏“天下之有马者,州县皆先以邮递军旅之役,定户复缘以升之,百姓畏苦,乃多不畜马,故骑射之士减曩时”。元世祖至元二十三年,六月,括诸路马。凡色目人有马者,三取其二。汉民悉入官。敢匿与互市者罪之。《明实录》言:永乐元年,七月,上谕兵部臣曰:“比闻民间马价腾贵,盖禁民不得私畜故也。其榜谕天下,听军民畜马勿禁。”(据《日知录·马政》条)然则像汉朝,不但无畜马之禁,且有马复令者(有车骑马一匹者,复卒三人,见《食货志》),民间的畜牧,自然要兴盛了。但这只能藏富于民,大规模的畜牧,还是要在边地加以提倡的。《辽史·食货志》述太祖时畜牧之盛,“括富人马不加多,赐大小鹘军万余匹不加少”。又说:“自太宗及兴宗,垂二百年,群牧之盛如一日。天祚初年,马犹有数万群,群不下千匹。”此等盛况,各个北族盛时,怕都是这样的,不过不能都有翔实的记载罢了。此其缘由:(一)由于天时地利的适宜。(二)亦由其地尚未开辟,可充牧场之地较多。分业应根据地理。蒙、新、青、藏之地,在前代或系域外,今则都在邦域之中,如何设法振兴,不可不极端努力了。\n渔税,历代视之不甚重要,所以正史中关于渔业的记载亦较少。然古代庶人,实以鱼鳖为常食(见第四十九章)。《史记·货殖列传》说:太公封于齐,地潟卤,人民寡,太公实以通鱼盐为致富的一策。这或是后来人的托辞,然春秋战国时,齐国渔业的兴盛,则可想见了。《左传》昭公三年,晏子说陈氏厚施于国,“鱼盐蜃蛤,弗加于海”(谓不封禁或收其税)。汉耿寿昌为大司农,增加海租三倍(见《汉书·食货志》)。可见缘海河川,渔业皆自古即盛。此等盛况,盖历代皆然。不过“业渔者类为穷海、荒岛、河上、泽畔居民,任其自然为生。内地池畜鱼类,一池一沼,只供文人学士之倘佯,为诗酒闲谈之助。所以自秦汉至明,无兴革可言,亦无记述可见”罢了(采李士豪、屈若《中国渔业史》说,商务印书馆本)。然合沿海及河湖计之,赖此为生的,何止千万?组织渔业公司,以新法捕鱼,并团结渔民,加以指导保护等,均起于清季。国民政府对此尤为注意。并曾豁免渔税,然成效尚未大著。领海之内,时时受人侵渔。二十六年,中日战事起后,沿海多遭封锁,渔场受侵夺,渔业遭破坏的尤多。\n狭义的农业,但指种植而言。广义的,则凡一切取得物质的方法,都包括在内,矿业,无疑的也是广义农业的一部分了。《管子·地数篇》说:“葛卢之山,发而出水,金从之,蚩尤受而制之,以为剑、铠、矛、戟。”“雍狐之山,发而出水,金从之,蚩尤受而制之,以为雍狐之戟、芮戈。”我们据此,还可想见矿业初兴,所采取的,只是流露地表的自然金属。然《管子》又说:“上有丹砂者,下有黄金;上有慈石者,下有铜金;上有陵石者,下有铅、锡、赤铜;上有赭者下有铁,此山之见荣者也。”荣即今所谓矿苗,则作《管子》书时,已知道察勘矿苗之法了。近代机器发明以来,煤和铁同为生产的重要因素。在前世,则铁较重于煤。至古代,因为技术所限,铜尤要于铁。然在古代,铜的使用,除造兵器以外,多以造宝鼎等作为玩好奢侈之品,所以《淮南子·本经篇》说:“衰世镌山石,手金玉,擿蚌蜃,销铜铁,而万物不滋。”将铜铁和金玉、蚌蜃(谓采珠)同视。然社会进化,铁器遂日形重要。《左传》僖公十八年,“郑伯始朝于楚。楚子赐之金。既而悔之。与之盟,曰:无以铸兵。”可见是时的兵器,还以南方为利。兵器在后汉以前,多数是用铜造的(参看《日知录·铜》条)。然盐铁,《管子》书已并视为国家重要的财源(见第四十四章),而《汉书·地理志》说,江南之俗,还是“火耕水耨”。可见南方的农业,远不如北方的发达。古代矿业的发明,一定是南先于北。所以蚩尤尸作兵之名。然到后来,南方的文明程度,转落北方之后,则实以农业进步迟速之故。南方善造铜兵,北方重视铁铸的农器,正可为其代表。管子虽有盐铁国营之议,然铁矿和冶铸,仍入私人之手。只看汉世所谓“盐铁”者(此所谓盐铁,指经营盐铁事业的人而言),声势极盛,而自先秦时代残留下来的盐官、铁官,则奄奄无生气可知。后世也还是如此。国家自己开的矿,是很少的。民间所开,大抵以金属之矿为多。采珠南海有之。玉多来自西域。\n工业:在古代,简单的是人人能做的。其较繁难的,则有专司其事的人。此等人,大抵由于性之所近,有特别的技巧。后来承袭的人,则或由社会地位关系,或由其性之所近。《考工记》所谓“知者创物,巧者述之,守之世,谓之工”。此等专门技术,各部族的门类,各有不同。在这一部族,是普通的事,人人会做的,在别一部族,可以成为专门之技。所以《考工记》说:“粤无镈,燕无函,秦无庐,胡无弓车。”(谓无专制此物之人)又说:“粤之无镈也,非无镈也(言非无镈其物),夫人而能为镈也。”燕之函,秦之庐,胡之弓车说亦同。此等规模,该是古代公产部族,相传下来的。后世的国家沿袭之,则为工官。《考工记》的工官有两种:一种称某人,一种称某氏。称某人的,当是技术传习,不以氏族为限的,称某氏的则不然。工用高曾之规矩,古人传为美谈。此由(一)古人生活恬淡,不甚喜矜奇斗巧。(二)又古代社会,范围窄狭,一切知识技能,得之于并时观摩者少,得之于先世遗留者多,所以崇古之情,特别深厚。(三)到公产社会专司一事的人,变成国家的工官,则工业成为政治的一部分。政治不能废督责,督责只能以旧式为标准。司制造的人,遂事事依照程式,以求免过(《礼记·月令》说:“物勒工名,以考其成。”《中庸》说:“日省月试,饩廪称事,所以来百工也。”可见古代对于工业督责之严)。(四)封建时代,人的生活是有等级的,也是有规范的。竞造新奇之物,此二者均将被破坏。所以《礼记·月令》说:“毋或作为淫巧,以荡上心。”《荀子·王制》说:“雕琢文采,不敢造于家。”而《礼记·王制》竟说:“作奇技奇器以疑众者杀。”此等制度,后人必将议其阻碍工业的进步,然在保障生活的规范,使有权力和财力的人,不能任意享用,而使其余的人,(甲)看了起不平之念;(乙)或者不顾财力,互相追逐,致以社会之生活程度衡之,不免流于奢侈,是有相当价值的,亦不可以不知道。即谓专就技巧方面立论,此等制度阻碍进步也是冤枉的。为什么呢?\n社会的组织,暗中日日变迁,而人所设立的机关,不能与之相应,有用的逐渐变为无用,而逐渐破坏。这在各方面皆然,工官自亦非例外。(一)社会的情形变化了,而工官未曾扩充,则所造之物,或不足以给民用。(二)又或民间已发明新器,而工官则仍守旧规,则私家之业渐盛。(三)又封建制度破坏,被灭之国,被亡之家,所设立之机关,或随其国家之灭亡而被废,技术人员也流落了。如此,古代的工官制度,就破坏无余了。《史记·货殖列传》说:“用贫求富,农不如工,工不如商”;《汉书·地理志》所载,至汉代尚存的工官,寥寥无几,都代表这一事实。《汉书·宣帝纪赞》,称赞他“信赏必罚,综核名实”,“技巧工匠,自元成间鲜能及之”。陈寿《上诸葛氏集表》,亦称其“工械技巧,物究其极”(《三国蜀志·诸葛亮传》),实在只是一部分官制官用之物罢了,和广大的社会工业的进退,是没有关系的。当这时代,工业的进化安在呢?世人每举历史上几个特别智巧的人,几件特别奇异之器,指为工业的进化,其实是不相干的。公输子能削竹木以为,飞之三日不下(见《墨子·鲁门篇》、《淮南子·齐俗训》),这自然是瞎说,《论衡·儒增篇》,业经驳斥他了。然如后汉的张衡、曹魏的马钧、南齐的祖冲之、元朝的郭守敬(马钧事见《三国魏志·杜夔传》《注》,余皆见各史本传),则其事迹决不是瞎说的。他们所发明的东西安在呢?崇古的人说:“失传了。这只是后人的不克负荷,并非中国人的智巧,不及他国人。”喜新的人不服,用滑稽的语调说道:“我将来学问够了,要做一部中国学术失传史。”(见从前北京大学所出的《新潮杂志》)其实都不是这一回事。一种工艺的发达,是有其社会条件的。指南针,世界公认是中国人发明的。古代曾用以驾车,现在为什么没有?还有那且走且测量路线长短的记里鼓车,又到什么地方去了?诸葛亮改良连弩,马钧说:我还可以再改良,后来却不曾实行,连诸葛亮发明的木牛流马,不久也失传了。假使不在征战之世,诸葛亮的心思,也未必用之于连弩。假使当时魏蜀的争战,再剧烈些,别方面的势力,再均平些,竟要靠连弩以决胜负,魏国也未必有马钧而不用。假使魏晋以后,在商业上,有运巴蜀之粟,以给关中的必要,木牛流马,自然会大量制造,成为社会上的交通用具的。不然,谁会来保存它?同理:一时代著名的器物,如明朝宣德、成化,清朝康熙、雍正、乾隆年间的瓷器,为什么现在没有了?这都是工业发达的社会条件。还有技术方面,也不是能单独发达的。一器之成,必有互相连带的事物。如公输子以竹木为,飞之三日,固然是瞎说。王莽时用兵,募有奇技的人。有人自言能飞。试之,取大鸟翮为两翼,飞数百步而坠(见《汉书·王莽传》),却决不是瞎说的,其人亦不可谓之不巧。假使生在现在,断不能谓其不能发明飞机。然在当日,现今飞机上所用种种机械,一些没有,自然不能凭空造成飞行器具。所以社会条件不备具,技术的发展,而不依着一定的顺序,发明是不会凭空出现的。即使出现了,也只等于昙花一现。以为只要消费自由,重赏之下,必有勇夫,工艺自然会不断的进步,只是一个浅见。\n工官制度破坏后,中国工业的情形,大概是这样的:根于运输的情形,寻常日用的器具,往往合若干地方,自成一个供求的区域。各区域之间,制造的方法,和其所用的原料等,不必相同。所以各地方的物品,各有其特色。(一)此等工人,其智识,本来是蹈常习故的。(二)加以交换制度之下,商品的生产,实受销场的支配,而专司销售的商人,其见解,往往是陈旧的。因为旧的东西,销路若干,略有一定,新的就没有把握了。因此,商人不欢迎新的东西,工人亦愈无改良的机会。(三)社会上的风气,也是蹈常习故的人居其多数。所以其进步是比较迟滞的。至于特别著名的工业品,行销全国的,亦非没有。则或因(一)天产的特殊,而制造不能不限于其地。(二)或因运输的方便,别地方的出品,不能与之竞争。(三)亦或因历史上技术的流传,限于一地。如湖笔、徽墨、湘绣等,即其一例。\n●轮船招商总局\n近代的新式工业,是以机制品为主的。自非旧式的手工业所能与之竞争。经营新式工业,既须人才,又须资本,中外初通时的工商家,自不足以语此,自非赖官力提倡不可。然官家的提倡,亦殊不得法。同治初年,制造局、造船厂等的设立,全是为军事起见,不足以语于实业。光绪以后所办的开平煤矿、甘肃羊毛厂、湖北铁厂、纱厂等,亦因办理不得其法,成效甚少。外货既滔滔输入,外人又欲在通商口岸,设厂制造,利用我低廉的劳力,且省去运输之费。自咸丰戊午、庚申两约定后,各国次第与我订约,多提出此项要求。中国始终坚持未许。到光绪甲午,和日本战败,订立《马关条约》,才不得已而许之。我国工业所受的压迫,遂更深一层,想挣扎更难了。然中国的民智,却于甲午之后渐开,经营的能力,自亦随之而俱进。近数十年来,新兴的工业,亦非少数,惜乎兴起之初,未有通盘计划,而任企业之家,人自为战,大多数都偏于沿江沿海。二十六年,战事起后,被破坏的,竟达百分之七十。这亦是一个很大的创伤。然因此而(一)内地的宝藏,获得开发,交通逐渐便利。(二)全盘的企业,可获得一整个的计划,非复枝枝节节而为之。(三)而政治上对于实业的保障,如关税壁垒等,亦将于战后获得一条出路。因祸而为福,转败而为功,就要看我们怎样尽力奋斗了。\n商业当兴起时,和后来的情形,大不相同。《老子》说:“郅治之极,邻国相望,鸡犬之声相闻,民各甘其食,美其服,安其俗,乐其业,至老死不相往来。”这是古代各部族最初孤立的情形。到后来,文化逐渐进步,这种孤立状况,也就逐渐打破了。然此时的商人,并非各自将本求利,乃系为其部族做交易。部族是主人,商人只是夥友,盈亏都由部族担负,商人只是替公众服务而已。此时的生意,是很难做的。(一)我们所要的东西,哪一方面有?哪一方面价格低廉?(二)与人交换的东西,哪一方面要?哪一方面价格高昂?都非如后世的易于知道。(三)而重载往来,道途上且须负担危险。商人竭其智力,为公众服务,实在是很可敬佩的。而商人的才智,也特别高。如郑国的弦高,能却秦师,即其一证(《左传》僖公三十三年)。此等情形,直到东西周之世,还有留遗。《左传》昭公十六年,郑国的子产,对晋国的韩宣子说:“昔我先君桓公,与商人皆出自周。庸次比耦,以艾杀此地,斩之蓬蒿藜藿而共处之。”开国之初,所以要带着一个商人走,乃是因为草创之际,必要的物品,难免缺乏,庚财(见第四十一章)、乞籴,都是不可必得的。在这时候,就非有商人以济其穷不可了。卫为狄灭,文公立国之后,要注意于通商(《左传》闵公二年),亦同此理。此等商人,真正是消费者和生产者的朋友。然因社会组织的变迁,无形之中,却逐渐变做他们的敌人而不自知了。因为交换的日渐繁盛,各部族旧有的经济组织,遂不复合理,而逐渐的遭遇破坏。旧组织既破坏,而无新组织起而代之。人遂不复能更受社会的保障,其作业,亦非为社会而作,于是私产制度兴起了。在私产制度之下,各个人的生活,是要自己设法的。然必不能物物皆自为而后用之。要用他人所生产的东西,只有(一)掠夺和(二)交换两种方法。掠夺之法,是不可以久的。于是交易大盛。然此时的交易,非复如从前行之于团体与团体之间,而是行之于团体之内的。人人直接交易,未免不便,乃渐次产生居间的人。一方面买进,一方面卖出,遂成为现在的所谓商业。非交易不能生活,非藉居间的人不能交易,而商业遂隐操社会经济的机键。在私产制度之下,人人的损益,都是要自己打算的。各人尽量寻求自己的利益。而生产者要找消费者、消费者要找生产者极难,商人却处于可进可退的地位,得以最低价(只要生产者肯忍痛卖)买进,最高价(只要消费者能够忍痛买)卖出,生产者和消费者,都无如之何。所以在近代工业资本兴起以前,商人在社会上,始终是一个优胜的阶级。\n商业初兴之时,只有现在所谓定期贸易。《易经·系辞传》说:神农氏“日中为市,致天下之民,聚天下之货,交易而退,各得其所”,就指示这一事实的。此等定期贸易,大约行之于农隙之时,收成之后。所以《书经·酒诰》说:农功既毕,“肇牵车牛远服贾”。《礼记·郊特牲》说:“四方年不顺成,八蜡不通”;“顺成之方,其蜡乃通”(蜡祭是行于十二月的。因此,举行定期贸易)。然不久,经济愈形进步,交易益见频繁,就有常年设肆的必要了。此等商肆,大者设于国中,即《考工记》所说“匠人营国,面朝后市”。小者则在野田墟落之间,随意陈列货物求售,此即《公羊》何《注》所谓“因井田而为市”(宣公十五年)。《孟子》所谓“有贱丈夫焉,必求龙断而登之”,亦即此类,其说已见第四十四章了。《管子·乘马篇》说:“聚者有市,无市则民乏。”可见商业和人民的关系,已密接而不可分离了。古代的大商人,国家管理之颇严,《管子·揆度篇》说:“百乘之国,中而立市,东西南北,度五十里。”千乘之国,万乘之国,也是如此。这是规定设市的地点的。《礼记·王制》列举许多不鬻于市的东西。如(一)圭璧金璋,(二)命服命车,(三)宗庙之器,(四)牺牲,(五)锦文珠玉成器,是所以维持等级制度的。(六)奸色乱正色,(七)衣服饮食,是所以矫正人民的生活规范的。(八)布帛精粗不中度,幅广狭不中量,(九)五谷不时,(十)果实未熟,(十一)木不中伐,(十二)禽兽鱼鳖不中杀,是所以维持社会的经济制度,并保障消费人的利益的。总之,商人的交易,受着干涉的地方很多。《周官》司市以下各官,则是所以维持市面上的秩序的。我们可想见,在封建制度之下,商人并不十分自由。封建政体破坏了,此等规则,虽然不能维持,但市总还有一定的区域。像现在通衢僻巷,到处可以自由设肆的事,是没有的。北魏胡灵后时,税入市者人一钱,即其明证。《唐书·百官志》说:“市皆建标筑土为候。凡市日,击鼓三百以会众,日入前七刻,击钲三百而散。”则市之聚集,仍有定期,更无论非市区了。现在设肆并无定地,交易亦无定时,这种情形,大约是唐中叶以后,逐渐兴起的。看宋朝人所著的《东京梦华录》(孟元老著)、《武林旧事》(周密著)等书可见。到这地步,零售商逐渐增多,商业和人民生活的关系,亦就更形密切了。\n商业初兴时,所运销的,还多数是奢侈品,所以专与王公贵人为缘。子贡结驷连骑,束帛之币,以聘享诸侯(《史记·货殖列传》)。晁错说汉朝的商人,“交通王侯,力过吏势”(《汉书·食货志》),即由于此。此等商人,看似势力雄厚,其实和社会的关系,是比较浅的。其厕身民众之间,做屯积和贩卖的工作的,则看似低微,而其和社会的关系,反较密切。因为这才真正是社会经济的机键。至于古代的贱视商人,则(一)因封建时代的人,重视掠夺,而贱视平和的生产事业。(二)因当时的商业,多使贱人为之。如刁间收取桀黠奴,使之逐渔盐商贾之利是(《史记·货殖列传》)。此等风气,以两汉时代为最甚。后世社会阶级,渐渐平夷,轻视商人,亦就不如此之甚了。抑商则另是一事。轻商是贱视其人,抑商则敌视其业。因为古人视商业为末业,以为不能生利。又因其在社会上是剥削阶级,然抑商的政令,在事实上,并不能减削商人的势力。\n国际间的贸易,自古即极兴盛。因为两国或两民族,地理不同,生产技术不同,其需要交易,实较同国同族人为尤甚。试观《史记·货殖列传》所载,凡和异国异族接境之处,商务无不兴盛(如天水、陇西、北地、上郡、巴、蜀、上谷至辽东等),便可知道。汉朝尚绝未知西域为何地,而邛竹杖、蜀布,即已远至其地,商人的辗转贩运,其能力亦可惊异了。《货殖传》又说:番禺为珠玑、瑇瑁、果、布之凑。这许多,都是后来和外洋互市的商品(布当即棉布),可知海路的商业,发达亦极早。中国和西域的交通,当分海、陆两路。以陆路论:《汉书·西域传》载杜钦谏止遣使报送罽宾使者的话,说得西域的路,阻碍危险,不可胜言,而其商人,竟能冒险而来。以海路论:《汉书·地理志》载中国人当时的海外航线,系自广东的徐闻出发。所经历的地方,虽难悉考,其终点黄支国,据近人所考证,即系印度的建志补罗(冯承钧《中国南洋交通史》上编第一章)。其后大秦王安敦,自日南徼外,遣使通中国,为中欧正式交通之始。两晋南北朝之世,中国虽然丧乱,然河西、交、广,都使用金银。当时的中国,是并不以金银为货币的,独此两地,金银获有货币的资格,即由于与外国通商之故。可见当中国丧乱时,中外的贸易,依然维持着。承平之世,特别如唐朝、元朝等,疆域扩张,声威远播之时,更不必说了。但此时所贩运的总带有奢侈品性质(如香药、宝货便是,参看第四十四章),对于普通人民的生活,关系并不深切。到近代产业革命以后,情形就全不相同了。\n第四十八章 货币 # 交换是现社会重要的经济机构,货币则是交换所藉之以行的。所以货币制度的完善与否,和经济的发达、安定,都有很大的关系。中国的货币制度,是不甚完善的。这是因为(一)中国的经济学说,注重于生产消费,而不甚注重于交换,于此部分,缺乏研究。(二)又疆域广大,各地方习惯不同,而行政的力量甚薄,不能控制之故。\n中国古代,最普遍的货币,大约是贝。所以凡货财之类,字都从贝,这是捕渔的民族所用。亦有用皮的。所以国家以皮币行聘礼,婚礼的纳征,亦用鹿皮,这当是游猎民族所用。至农耕社会,才普遍使用粟帛。所以《诗经》说“握粟出卜”,又说“抱布贸丝”。珠玉金银铜等,都系贵族所需要。其中珠玉之价最贵,金银次之,铜又次之,所以《管子》说:“以珠玉为上币,黄金为中币,刀布为下币。”(《国蓄》)古代的铜价,是比较贵的。《史记·货殖列传》、《汉书·食货志》,说当时的粜价,都是每石自二十文至八十文。当时的衡量,都约当现代五分之一。即当时的五石,等于现在的一石(当时量法用斛,衡法称石,石与斛的量,大略相等),其价为一百文至四百文。汉宣帝时,谷石五钱,则现在的一石谷,只值二十五文。如此,零星贸易,如何能用钱?所以孟子问陈相:许行的衣冠械器,从何而来?陈相说:都是以粟易之(《滕文公上篇》)。而汉朝的贤良文学,说当时买肉吃的人,也还是“负粟而往,易肉而归”(《盐铁论·散不足篇》),可见自周至汉,铜钱的使用,并不十分普遍。观此,才知道古人所以有许多主张废除货币的。若古代的货币使用,其状况一如今日,则古人即使有这主张,亦必审慎考虑,定有详密的办法,然后提出,不能说得太容易了。自周至汉,尚且如此,何况夏殷以前?所以《说文》说:“古者货贝而宝龟,周而有泉,至秦废贝行钱。”《汉书·食货志》说货币的状况:“自夏殷以前,其详靡记”,实在最为确实。《史记·平准书》说:“虞夏之币,金为三品:或黄,或白,或赤,或钱,或布,或刀,或龟贝。”《平准书》本非《史记》原文,这数语又附著篇末,其为后人所窜入,不待言而可明了。《汉书·食货志》又说:“大公为周立九府圜法。黄金方寸而重一斤。钱圜函方(函即俗话钱眼的眼字),轻重以铢。布帛广二尺二寸为辐,长四丈为匹。大公退,又行之于齐。”按《史记·货殖列传》说:“管子设轻重九府。”《管晏列传》说:“吾读管氏《牧民》、《山高》、《乘马》、《轻重》、《九府》”,则所谓九府圜法,确系齐国的制度。但其事起于何时不可知。说是太公所立,已嫌附会,再说是太公为周所立,退而行之于齐,就更为无据了。古代的开化,东方本早于西方。齐国在东方,经济最称发达。较整齐的货币制度,似乎就是起于齐国的。《管子·轻重》诸篇,多讲货币、货物相权之理,可见其时货币的运用,已颇灵活。《管子》虽非管仲所著,却不能不说是齐国的书。《说文》说周而有泉,可见铜钱的铸造,是起于周朝,而逐渐普遍于各地方的。并非一有铜钱,即各处普遍使用。\n古代的铜钱,尚且价格很贵,而非普通所能使用,何况珠玉金银等呢?这许多东西,何以会与铜钱并称为货币?这是因为货币之始,乃是用之于远方,而与贵族交易的。《管子》说:“玉起于禺氏,金起于汝、汉,珠起于赤野。东西南北,距周七千八百里(《通典》引作七八千里),水绝壤断,舟车不能通。先王为其途之远,其至之难,故托用于其重。”(《国蓄》)又说:“汤七年旱,禹五年水,汤以庄山之金,禹以历山之金铸币,而赎人之无卖子者。”(《山权数》)此等大批的卖买,必须求之于贵族之家。因为当时,只有贵族,才会有大量的谷物存储(如《山权数篇》又言丁氏之家粟,可食三军之师)。于此,可悟古代商人,多与贵族交接之理,而珠玉金银等的使用,亦可无疑义了。珠玉金银等,价均太贵,不适宜于普通之用。只有铜,价格稍贱,而用途极广,是普通人所宝爱,而亦是其所能使用的。铜遂发达而成普通的货币,具有铸造的形式。其价值极贵的,则渐以黄金为主,而珠玉等都被淘汰。\n钱圜函方,一定是取象于贝的。所以钱的铸造,最初即具有货币的作用。其为国家因民间习用贝,又宝爱铜,而铸作此物,抑系民间自行制造不可知。观《汉书》轻重以铢四字,可见齐国的铜钱,轻重亦非一等。限制其轻重必合于铢的整数,正和限制布帛的长阔一样。则当时的钱,种类似颇复杂。观此,铜钱的铸造,其初似出于民间,若源出国家,则必自始就较整齐了。此亦可见国家自能发动的事情,实在很少,都不过因社会固有的事物,从而整齐之罢了。到货币广行以后,大量的铸造,自然是出于国家。因为非国家,不能有这大量的铜。但这只是事实如此。货币不可私铸之理,在古代,似乎不甚明白的。所以汉文帝还放民私铸。\n《汉书·食货志》说:“秦并天下,币为二等。黄金以镒为名,上币。铜钱质如周钱,文曰半两,重如其文。而珠玉龟贝银锡之属,为器饰宝藏,不为币。然各随时而轻重无常。”可见当时的社会,对于珠玉、龟贝、银锡等,都杂用为交易的媒介,而国家则于铜钱之外,只认黄金。这不可谓非币制的一进化。《食货志》又说,汉兴,以为秦钱重,难用,更令民铸荚钱。《高后本纪》:二年,行八铢钱。应劭曰:“本秦钱。质如周钱,文曰半两,重如其文。即八铢也。汉以其太重,更铸荚钱。今民间名榆荚钱是也。民患其太轻。至此复行八铢钱。”六年,行五分钱。应劭曰:“所谓荚钱者。文帝以五分钱太轻小,更作四铢钱。文亦曰半两。今民间半两钱最轻小者是也。”按既经铸造的铜钱,自与生铜不同。但几种货币杂行于市,民必信其重者,而疑其轻者;信其铸造精良者,而疑其铸造粗恶者,这是无可如何之事。古代货币,虽有多种并行,然其价格,随其大小而不齐,则彼此不会互相驱逐。今观《汉书·食货志》说:汉行荚钱之后,米至石万钱,马至匹百金。汉初虽有战争,并未至于白骨蔽野,千里无人烟,物价的昂贵,何得如此?况且物价不应同时并长。同时并长,即非物价之长,而为币价之跌,其理甚明。古一两重二十四铢。八铢之重,只得半两钱三分之二,四铢只得三分之一,而其文皆曰半两,似乎汉初货币,不管其实重若干,而强令其名价相等。据此推测,汉初以为秦钱重难用,似乎是一个藉口。其实是藉发行轻货,以为筹款之策的。所以物价因之增长。其时又不知货币不可私铸之理。文帝放民私铸,看《汉书》所载贾谊的奏疏,其遗害可谓甚烈。汉武帝即位后,初铸三铢钱,又铸赤仄,又将鹿皮造成皮币,又用银锡造作白金三等,纷扰者久之。后来乃将各种铜钱取消,专铸五铢钱。既禁民私铸,并不许郡国铸造,而专令上林三官铸(谓水衡都尉属官均官、钟官、辨铜三令丞),无形中暗合货币学理。币制至此,始获安定。直至唐初,才另铸开元通宝钱。自此以前,历朝所铸的钱,都以五铢为文。五铢始终是最得人民信用的钱。\n●五铢钱\n汉自武帝以后,币制是大略稳定的。其间惟王莽一度改变币制,为五物、六名、二十八品(金、银、龟、贝、钱、布为六名。钱布均用铜,故为五物。其值凡二十八等),然旋即过去。至后汉光武,仍恢复五铢钱。直至汉末,董卓坏五铢钱,更铸小钱,然后钱法渐坏。自此经魏、晋、南北朝,政治紊乱,币制迄未整饬。其中最坏的,如南朝的鹅眼、环钱,至于“入水不沉,随手破碎”。其时的交易,则多用实物做媒介。和外国通商之处,则或兼用金银。如《隋书·食货志》说:“梁初,只有京师及三吴、荆、郢、江、襄、梁、益用钱。其余州郡,则杂以谷帛。交、广全用金银。又说:陈亡之后,岭南诸州,多以钱米布交易。河西诸郡,或用西域金银之钱都是。直到唐初,铸开元通宝钱,币制才算复一整理。然不久私铸即起。\n用金属做货币,较之珠玉布帛等,固然有种种优点,但亦有两种劣点。其(一)是私销私铸的无法禁绝。私铸,旧说以“不爱铜不惜工”敌之。即是使铸造的成本高昂,私铸无利可图。但无严切的政令以辅之,则恶货币驱逐良货币,既为经济上不易的原则,不爱铜,不惜工,亦徒使国家增加一笔消耗而已。至于私销,则简直无法可禁。其(二)为钱之不足于用。社会经济,日有进步,交易必随之而盛。交易既盛,所需的筹码必多。然铜系天产物,开矿又极劳费,其数不能骤增。此系自然的原因。从人为方面论,历代亦从未注意于民间货币的足不足,而为之设法调剂,所以货币常感不足于用。南北朝时,杂用实物及外国货币,币制的紊乱,固然是其一因,货币数量的缺乏,怕亦未尝非其一因。此等现象,至唐代依然如故。玄宗开元二十二年,诏庄宅口马交易,并先用绢布绫罗丝棉等。其余市买,至一千以上,亦令钱物并用。违者科罪。便是一个证据。当这时代,纸币遂应运而生。\n纸币的前身是飞钱。《唐书·食货志》说:贞元时,商贾至京师,委钱诸道进奏院及诸军诸使富家,以轻装趋四方,合券乃取之,号飞钱。这固然是汇兑,不是纸币。然纸币就因之而产生了。《文献通考·钱币考》说:初蜀人以铁钱重,私为券,谓之交子,以便贸易。富人十六户主之。其后富人稍衰,不能偿所负,争讼数起。寇瑊尝守蜀,乞禁交子。薛田为转运使,议废交子则贸易不便。请官为置务,禁民私造。诏从其请。置交子务于益州。《宋史·薛田传》说:未报,寇瑊守益州,卒奏用其议。蜀人便之。《食货志》说:真宗时,张咏镇蜀。患蜀人铁钱重,不便贸易。设质剂之法。一交一缗,以三年为一界而换之。六十五年为二十二界。谓之交子。富民十六户主之。三说互歧,未知孰是。总之一交一缗,以三年为一界,总是事实。一交为一缗,则为数较小,人人可以使用。以三年为一界,则为时较长,在此期间,即具有货币的效用,真可谓之纸币,而非复汇兑券了。然云废交子则贸易不便,则其初,亦是以搬运困难,而图藉此以省费的。其用意,实与飞钱相类。所以说纸币,是从汇兑蜕化而出的。\n交子务既由官置,交子遂变为官发的纸币。神宗熙宁间,因河东苦铁钱,置务于潞州。后又行之于陕西。徽宗崇宁时,蔡京又推行之于各处。后改名为钱引。其时惟闽、浙、湖、广不行。推行的区域,已可谓之颇广了。此种纸币,系属兑换性质。必须可兑现钱,然后能有信用。然当时已有滥发之弊,徽宗时,遂跌至一缗仅值钱数十。幸其推行的范围虽广,数量尚不甚多,所以对于社会经济,不发生甚大的影响。南宋高宗绍兴元年,令榷货务造关子。二十九年,户部始造会子。仍以三年为一界。行至十八界为止。第十九界,贾似道仍改造关子。南宋的交子,有展限和两界并行之弊。因之各界价格不等。宁宗嘉定四年,遂令十七、十八两界,更不立限,永远行使。这很易至于跌价。然据《宋史·食货志》:度宗咸淳四年,以近颁关子,贯作七百七十文足。十八界会子,贯作二百五十七文足。三准关子一,同现钱行使。此时宋朝已近灭亡,关子仅打七七折,较诸金朝,成绩好得多了。\n金朝的行纸币,始于海陵庶人贞元二年。以一贯、二贯、三贯、五贯、十贯为大钞,一百、二百、三百、五百、七百为小钞。当时说是铜少的权制。但(一)开矿既非易事,括民间铜器以铸,禁民间私藏铜器及运铜器出境,都是苛扰的事。铸钱因此不易积极进行。(二)当时亦设有铸钱的监,乃多毁旧钱以铸。新钱虽然铸出,旧钱又没有了。(三)既然钱钞并行,循恶货币驱逐良货币的法则,人民势必将现钱收藏,新铸的钱,转瞬即行匿迹。因此,铜钱永无足时,纸币势必永远行使。然使发行得法,则纸币与铜钱并行,本来无害,而且是有益的,所以《金史·食货志》说:章宗即位之后,有人要罢钞法。有司说:“商旅利其致远,往往以钱买钞。公私俱便之事,岂可罢去?”这话自是事实。有司又说:“止因有厘革之限,不能无疑。乞削七年厘革之限,令民得常用。”(岁久字文磨灭,许于所在官库纳旧换新,或听便支钱)做《食货志》的人说:“自此收敛无术,出多入少,民浸轻之。”其实收敛和厘革,系属两事。苟能审察经济情形,不至滥发,虽无厘革之限何害?若要滥发,即有厘革之限,又何难扩充其每界印造之数,或数界并行呢?所以章宗时的有司,实在并没有错。而后来的有司,“以出钞为利,收钞为讳”,却是该负极大责任的。平时已苦钞多,宣宗南迁以后,更其印发无限。贞祐二年,据河东宣抚使胥鼎说,遂致每贯仅值一文。\n●交子\n钞法崩溃至此,业已无法挽救。铜钱则本苦其少,况经纸币驱逐,一时不能复出。银乃乘机而兴。按金银用为交易的媒介,由来已久,读前文所述可见。自经济进步以后,铜钱既苦其少,又苦运输的困难,当这时候,以金银与铜相辅而行,似极便利。然自金末以前,讫未有人想到这个法子,这是什么理由呢?原来货币是量物价的尺。尺是可有一,不可有二的。既以铜钱为货币,即不容铜钱之外,更有他种货币。(一)废铜钱而代以金银,固然无此情理。(二)将金银亦铸为货币,与铜钱严定比价,这是昔人想不到的。如此,金银自无可做货币的资格了。难者要说:从前的人,便没有专用铜钱。谷物布帛等,不都曾看做货币的代用品么?这话固然不错。然在当时,金银亦何尝不是货币的代用品。不过其为用,不如谷物布帛的普遍罢了。金银之用,为什么不如谷帛的普遍?须知价格的根源,生于价值,金银在现今,所以为大众所欢迎,是因其为交换之媒介,既广且久,大家对它,都有一种信心,拿出去,就什么东西可以换到。尤其是,现今世界各国,虽然都已用纸,而仍多用金银为准备。金银换到货币,最为容易,且有定价,自然为人所欢迎。这是货币制度,替金银造出的价值,并不是金银本身,自有价值。假使现在的货币,都不用金银做准备,人家看了金银,也不当它直接或间接的货币,而只当它货物。真要使用它的人,才觉得它有价值。如此,金银的价值必缩小;要它的人,亦必减少;金银的用途,就将大狭了。如此,便可知道自金末以前,为什么中国人想不到用金银做货币。因为价格生于价值,其物必先有人要,然后可做交易的媒介,而金银之为物,在从前是很少有人要的。因为其为物,对于多数人是无价值(金银本身之用,不过制器具,供玩好,二者都非通常人所急)。\n到金朝末年,经济的情形,又和前此不同了。前此货币紊乱之时,系以恶的硬币,驱逐良的硬币。此时则系以纸币驱逐硬币。汉时钱价甚昂,零星交易,并不用钱,已如前述。其后经济进步,交易渐繁,货币之数,势必随公铸私造而加多。货币之数既多,其价格必日跌。于是零星贸易,渐用货币。大宗支付,转用布帛。铜钱为纸币驱逐以尽,而纸币起码是一百文,则零星贸易,无物可用了。势不能再回到古代的以粟易之,而布帛又不可尺寸分裂,乃不得已而用银。所以银之起,乃是所以代铜钱,供零星贸易之用的,并非嫌铜钱质重值轻,用之以图储藏和运输之便。所以到清朝,因铸钱的劳费,上谕屡次劝人民兼用银两,人民总不肯听。这个无怪其然。因为他们心目之中,只认铜钱为货币。储藏了银两,银两对铜钱涨价,固然好了,对铜钱跌价,他们是要认为损失的。他们不愿做这投机事业。到清末,要以银为主币,铜为辅币,这个观念,和普通一般人说明,还是很难的。因为他们从不了解:有两种东西可以同时并认为货币。你对他说:以银为主币,铜为辅币,这个铜币,就不该把它看做铜,也不该把它看做铜币,而该看作银圆的几分之几,他们亦很难了解。这个,似乎是他们的愚笨,其实他们的意见是对的。因为既不看做铜,又不看做铜币,那么,为什么不找一种本无价值的东西,来做银圆的代表,而要找着铜币呢?铜的本身,是有价值的,因而是有价格的,维持主辅币的比价,虽属可能,究竟费力。何不用一张纸,写明铜钱若干文,派它去充个代表,来得直捷痛快呢?他们的意见是对的。他们而且已经实行了。那便是飞钱、交子等物。这一种事情,如能顺利发达,可使中国货币的进化,早了一千年。因为少数的交易用铜钱,多数的授受,嫌钱笨重的,则以纸做钱的代表,如此,怎样的巨数,亦可以变为轻赍,而伸缩又极自由,较之用金银,实在合理得许多。而惜乎给国家攫取其发行之权,以济财政之急,把这自然而合理的进化拗转了。\n于此,又可知道纸币之弊。黄金为什么不起而代之,而必代之以银。从前的人,都说古代的黄金是多的,后世却少了,而归咎于佛事的消耗(顾炎武《日知录》,赵翼《廿二史札记》、《陔余丛考》,都如此说),其实不然。王莽败亡时,省中黄金万斤为一匮者,尚有六十匮。其数为六十万斤。古权量当今五分之一,则得今十二万斤,即一百九十二万两。中国人数,号称四万万。女子当得半数,通常有金饰的,以女子为多。假使女子百人之中,有一人有金饰,其数尚不及一两。现在民间存金之数,何止如此?《齐书·东昏侯纪》,谓其“后宫服御,极选珍奇。府库旧物,不复周用。贵市民间。金银宝物,价皆数倍,京邑酒租,皆折使输金,以为金涂”。这几句话,很可说明历史记载,古代金多,后世金少的原因。古代人民生活程度低。又封建之世,服食器用,皆有等差。平民不能僭越。珠玉金银等,民间收藏必极少。这个不但金银如此,怕铜亦是如此。秦始皇的销兵,人人笑其愚笨。然汉世盗起,必劫库兵。后汉时羌人反叛,因归服久了,无复兵器,多执铜镜以象兵。可见当时民间兵器实不多。不但兵器不多,即铜亦不甚多。所以贾谊整理币制之策,是“收铜勿令布”。若铜器普遍于民间,亦和后世一样,用什么法子收之勿令布呢?铜尚且如此,何况金银?所以古代所谓金多,并非金真多于后世,乃是以聚而见其多。后世人民生活程度渐高;服食器用,等差渐破,以朝廷所聚之数,散之广大的民间,就自然不觉其多了。读史的人,恒不免为有明文的记载所蔽,而忽略于无字句处。我之此说,一定有人不信。因为古书明明记载汉时黄金的赏赐,动辄数十斤数百斤,甚且有至数千斤的,如何能不说古代的黄金,多于后世呢?但是我有一个证据,可以折服他。王莽时,黄金一斤直钱万,朱提银八两为一流,直钱一千五百八十,他银一流直钱千,则金价五倍于银。《日知录》述明洪武初,金一两等于银五两,则金银的比价,汉末与明初相同。我们既不见古书上有大量用银的记载,亦不闻佛法输入以后,银有大量的消耗,然则古书所载黄金大量使用之事,后世不见,并非黄金真少,只是以散而见其少,其事了然可见了。大概金银的比价,在前代,很少超过十倍的。然则在金朝末年,社会上白银固多,黄金亦不甚少。假使用银之故,是嫌铜币的笨重,而要代之以质小值巨之物,未尝不可舍银而取金,至少可以金银并用。然当时绝不如此。这明明由于银之起,乃所以代铜钱,而非以与铜钱相权,所以于金银二者之中,宁取其价之较低者。于此,可见以金银铜三品,或金银二品,或银铜二品为货币,并非事势之自然。自然之势,是铜钱嫌重,即走向纸币一条路的。\n金银二物,旧时亦皆铸有定形。《清文献通考》说:“古者金银皆有定式,必铸成币而后用之。颜师古注《汉书》,谓旧金虽以斤为名,而官有常形制。亦犹今时吉字金锭之类。武帝欲表祥瑞,故改铸为麟趾蹄之形,以易旧制。然则麟趾蹄,即当时金币式也。汉之白选与银货,亦即银币之式。《旧唐书》载内库出方圆银二千一百七十二两,是唐时银亦皆系铸成。”按金属货币之必须铸造,一以保证其成色,一亦所以省秤量之烦。古代金银虽有定形,然用之必仍以斤两计,似乎其分量的重轻,并无一定。而其分量大抵较重,尤不适于零星贸易之用。《金史·食货志》说:“旧例银每锭五十两,其直百贯。民间或有截凿之者,其价亦随低昂。”每锭百贯,其不能代铜钱可知。章宗承安二年,因钞法既敝,乃思乞灵于银。改铸银,名承安宝货。自一两至十两,分为五等,每两折钞二贯。公私同见钱行使,亦用以代钞本。后因私铸者多,杂以铜锡,浸不能行。五年,遂罢之。宣宗时,造贞祐宝券及兴定宝泉,亦皆与银相权。然民间但以银论价。于是限银一两,不得超过宝泉三百贯(按宝泉法价,每二贯等于银一两)。物价在三两以下者,不许用银。以上者三分为率,一分用银,二分用宝泉。此令既下,“商旅不行,市肆昼闭”,乃复取消。至哀宗正大间,民间遂全以银市易。《日知录》说:“此今日上下用银之始。”其时正值无钱可用的时候,其非用以与钱相权,而系以之代钱,显然可见了。\n元明两朝,当开国之初,都曾踌躇于用钱、用钞之间。因铜的缺乏,卒仍舍钱而用钞。元初有行用钞,其制无可考。世祖中统元年,始造交钞,以丝为本。是年十月,又造中统宝钞。分十、二十、三十、五十、一百、二百、五百、一贯、二贯(此据《食货志》。《王文统传》云:中统钞自十文至二贯凡十等。疑《食货志》夺三百一等)。每一贯同交钞一两,两贯同白银一两。又以文绫织为中统银货。分一两、二两、三两、五两、十两五等。每两同白银一两,未曾发行。至元十二年,添造厘钞。分一文、二文、三文三等。十五年,以不便于民罢。二十四年,造至元钞。自二贯至五文,凡十一等。每一贯当中统钞五贯,二贯等于银一两,二十贯等于金一两。武宗至大二年,以物重钞轻,改造至大银钞。自二两至二厘,共十三等。每一两准至元钞五贯,白银一两,赤金一钱。仁宗即位,以倍数太多,轻重失宜,罢至大银钞,其中统、至元二钞,则终元世常行。按元朝每改钞一次,辄准旧钞五倍,可见当其改钞之时,即系钞价跌至五分之一之时。货币跌价,自不免影响于民生。所以“实钞法”,实在是当时的一个大问题。元初以丝为钞本,丝价涨落太大,用作钞本,是不适宜的。求其价格变动较少的,自然还是金属。金属中的金银,都不适于零星贸易之用。厘钞及十文五文之钞,行用亦实不适宜。所以与其以金银为钞本,实不如以铜钱为钞本。元朝到顺帝至正年间,丞相脱脱,才有此议。下诏:以中统钞一贯,权铜钱一千,准至元钞二贯。铸至正通宝钱,与历代铜钱并用。这实在是一个贤明的办法。然因海内大乱,军储赏犒,每日印造,不可数计。遂至“交料散满人间”,“人视之若敝楮”了。明初,曾设局铸钱。至洪武七年,卒因铜之不给,罢铸钱局而行钞。大明宝钞,以千文准银一两,四贯准黄金一两。后因钞价下落,屡次鬻官物,或税收限定必纳宝钞以收钞。然终于不能维持。至宣宗宣德三年,遂停止造钞。其时增设新税,或加重旧税的税额,专收钞而焚之。钞法既平之后,有些新税取消,税额复旧,有的就相沿下去了。钞关即是其中之一。自此租税渐次普遍收银,银两真成为通用的货币了。\n主币可以用纸,辅币则必须用金属。因其授受繁,纸易敝坏,殊不经济。所以以铜钱与纸币并行,实最合于理想。元明两朝,当行钞之时,并不铸钱。明朝到后来,铸钱颇多,却又并不行钞了,清朝亦然。顺康雍乾四朝,颇能实行昔人不爱铜不惜工之论。按分厘在古代,本系度名而非衡名。衡法以十黍为累,十累为铢,二十四铢为两。因其非十进,不便计算,唐朝铸开元通宝钱,乃以一两的十分之一,即二铢四累,为其一个的重量。宋太宗淳化二年,乃改衡法。名一两的十分之一为一钱,一钱的十分之一为一分,一分的十分之一为一厘。钱即系以一个铜钱之重为名。分厘之名,则系借诸度法的。依照历朝的成法,一个铜钱,本来只要重一钱。顺康雍乾四朝所铸,其重量却都超过一钱以上,铸造亦颇精工。可谓有意于整顿币制了。惜乎于货币的原理未明,所以仍无成效可见。怎样说清朝的货币政策,不合货币原理呢?按(一)货币最宜举国一律。这不是像邮票一般,过了若干时间,就不复存在的。所以邮票可以花样翻新,货币则不宜然。此理在唐朝以前,本来明白。所以汉朝的五铢钱,最得人民信用,自隋以前,所铸的铜钱,即多称五铢。唐初改铸开元通宝,大约是因当时钱法大坏,想与民更始的,揣度当时的意思,或者想以开元为全国唯一通行的钱。所以后世所铸的钱,仍系开元通宝(高宗的乾封泉宝,肃宗的乾元重宝、重轮乾元等,虽都冠之以年号,然皆非小平钱,当时不认为正式的货币)。不过其统一的目的,未能达到罢了。宋以后才昧于此理,把历朝帝皇的年号,铸在铜钱之上。于是换一个皇帝,就可以有一种钱文(年号时有改变,则还可以不止一种)。货币形式的不统一,不是事实使然,竟是立法如此了。甚至像明朝世宗,不但铸嘉靖年号的铜钱,还补铸前此历朝未铸的年号。这不是把铜钱不看做全国的通货,而看做皇帝一个人的纪念品么?若使每朝所铸的,只附铸一个年号,以表明其铸造的年代,而其余一切,都是一律,这还可以说得过去。而历代又不能然。清朝亦是如此。且历朝所铸的铜钱,重量时有出入。这不是自己先造成不统一么?(二)虽然如此,但得所铸的钱,不至十分恶劣,则在专制时代,即但以本朝所铸之钱为限,而禁绝其余的恶薄者,亦未始不可以小康。此即明代分别制钱和古钱的办法(明天启、崇祯间,括古钱以充废铜,以统一币制论,实在是对的),但要行此法,有一先决问题,即必须先使货币之数足用。若货币之数,实在不足于用,交易之间,发生困难,就无论何等恶劣的货币,人民也要冒险使用,禁之不可胜禁,添出整理的阻力来了。自明废除纸币以后,直至清朝,要把铜钱铸到人民够用,是极不容易办到的。当此之时,最好将纸币和铜钱相权。而明清皆不知出此,听任银铜并行。又不知规定其主辅的关系。在明朝,租税主于收银,铜钱时有禁令,人民怀疑于铜钱之将废,不敢收受,大为铜钱流通之害。清朝则人民认铜钱为正货,不愿收受银两。而政府想要强迫使用,屡烦文告,而卒不能胜。而两种货币,同时并行,还生出种种弊窦(如租税征收等)。不明经济原理之害,真可谓生于其心,害于其政了。\n外国银钱的输入,并不始于近代。《隋书·食货志》说南北朝时河西、交、广的情形,已见前。《日知录》引唐韩愈《奏状》,说五岭卖买一以银。元稹奏状,说自岭以南,以金银为货币。张籍诗说:海国战骑象,蛮州市用银。《宋史·仁宗纪》:景祐二年,诏诸路岁输缗钱,福建、二广以银。《集释》说:顺治六、七年间,海禁未设,市井贸易,多以外国银钱。各省流行,所在多有。禁海之后,绝迹不见。这可见外国货币之侵入,必限于与外国通商之时,及与外国通商之地。前此中外交通,时有绝续,又多限于一隅,所以不能大量侵入。到五口通商以后,情形就大不相同了。外国铸造的货币,使用的便利,自胜于我国秤量的金银(其秤量之法,且不划一)。外国银圆,遂滔滔输入,而以西班牙、墨西哥两国的为多。中国的自铸,始于光绪十三年(广东总督张之洞所为)。重量形式,都模仿外国银圆,以便流通。此时铜钱之数,颇感不足。光绪二十七年,广东开铸铜元,因其名价远超于实价,获利颇多。于是各省竞铸铜元,以谋余利,物价为之暴腾。小平钱且为其驱逐以尽,民生大感困苦。光绪三十年,度支部奏厘定币制,以银圆为本位货币,民国初年仍之。其时孙文创用纸币之议,举国的人,多不解其理论,非难蜂起。直到最近,国民政府树立法币制度,才替中国的货币,画一个新纪元。\n●袁大头\n第四十九章 衣食 # 《礼记·礼运》说:“昔者先王未有宫室,冬则居营窟,夏则居橧巢。未有火化,食草木之实,鸟兽之肉,饮其血,茹其毛。未有麻丝,衣其羽皮。后圣有作,然后修火之利。笵金,合土,以为台榭、宫室、牖户。以炮,以燔,以亨,以炙,以为醴酪。治其麻丝,以为布帛。”这是古人总述衣食住的进化的。(一)古代虽无正确的历史,然其荦荦大端,应为记忆所能及。(二)又古人最重古。有许多典礼,虽在进化之后,已有新的、适用的事物,仍必保存其旧的、不适用的,以资纪念。如已有酒之后,还要保存未有酒时的明水(见下),即其一例。此等典礼的流传,亦使人容易记忆前代之事。所以《礼运》这一段文字,用以说明古代衣食住进化的情形,是有用的。\n据这一段文字,古人的食料共有两种:即(一)草木之实,(二)鸟兽之肉,(三)但还漏列了一种重要的鱼。古人以鱼鳖为常食。《礼记·王制》说:“国君无故不杀牛,大夫无故不杀羊,士无故不杀犬豕。”又说:“六十非肉不饱。”《孟子》说:“鸡、豚、狗、彘之畜,无失其时,七十者可以食肉矣。”(《梁惠王上篇》)则兽肉为贵者,老者之食。又说:“数罟不入洿池,鱼鳖不可胜食也”,与“不违农时,谷不可胜食也”并举。《诗经·无羊篇》:“牧人乃梦,众维鱼矣。大人占之,众惟鱼矣,实维丰年。”郑《笺》说:“鱼者,庶人之所以养也。今人众相与捕鱼,则是岁熟相供养之祥。”《公羊》宣公六年,晋灵公使勇士杀赵盾。窥其户,方食鱼飧。勇士曰:“嘻!子诚仁人也。为晋国重卿,而食鱼飧,是子之俭也。”均鱼为大众之食之征。此等习惯,亦必自隆古时代遗留下来的。我们可以说:古人主要的食料有三种:(一)在较寒冷或多山林的地方,从事于猎,食鸟兽之肉,饮其血,茹其毛,衣其羽皮。(二)在气候炎热、植物茂盛的地方,则食草木之实。衣的原料麻、丝,该也是这种地方发明的。(三)在河湖的近旁则食鱼。\n古代的食物虽有这三种,其中最主要的,怕还是第二种。因为植物的种类多,生长容易。《墨子·辞过篇》说:“古之民,素食而分处。”孙诒让《闲诂》说:“素食,谓食草木。素,疏之假字。疏,俗作蔬。”按古疏食两字有两义:(一)是谷物粗疏的,(二)指谷以外的植物。《礼记·杂记》:“孔子曰:吾食于少施氏而饱,少施氏食我以礼。吾祭,作而辞曰:疏食不足祭也。吾飧,作而辞曰:疏食也,不足以伤吾子。”《疏》曰:“疏粗之食,不可强饱,以致伤害。”是前一义。此所谓疏食,是后一义,因其一为谷物,一非谷物,后来乃加一草字头,以资区别。《礼记·月令》:仲冬之月,“山林薮泽,有能取蔬食,田猎禽兽者,野虞教道之。其有相侵夺者,罪之不赦”。《周官》太宰九职,八曰臣妾,聚敛疏材。《管子·七臣七主篇》云:“果蓏素食当十石”,《八观篇》云:“万家以下,则就山泽”。可见蔬食为古代重要的食料,到春秋战国时,还能养活很多的人口。至于动物,则其数量是比较少的。饮血茹毛,现在只当作形容野蛮人的话,其实在古代确是事实。《义疏》引“苏武以雪杂羊毛而食之”,即其确证。隆古时代,苏武在北海边上的状况,决不是常人所难于遭遇的。《诗经·豳风》:“九月筑场圃。”郑《笺》云:“耕治之以种菜茹。”《疏》云:“茹者,咀嚼之名,以为菜之别称,故书传谓菜为茹。”菜即今所谓蔬,乃前所释疏食中的第二义。后世的菜,亦是加以选择,然后种植的,吃起来并不费力。古代的疏食,则是向山林薮泽中,随意取得的野菜,其粗疏而有劳咀嚼,怕和鸟兽的毛,相去无几。此等事实,均逼着人向以人工生产食物的一条路上走。以人工生产食料,只有畜牧和耕种两法。畜牧须有适宜的环境,而中国无广大的草原(古代黄河流域平坦之地,亦沮洳多沼泽),就只有走向种植一路了。\n古人在疏食时代的状况,虽然艰苦,却替后人造下了很大的福利。因为所吃的东西多了,所以知道各种植物的性质。我国最古的药书,名为《神农本草经》。《淮南子·修务训》说:“神农尝百草之滋味,水泉之甘苦,一日而遇七十毒。”此乃附会之辞,古所谓神农,乃农业两字之义,并非指姜姓的炎帝其人。《礼记·月令》说“毋发令而待,以妨神农之事”,义即如此。《孟子·滕文公上篇》“有为神农之言者许行”,义亦如此。《神农本草经》,乃农家推源草木性味之书,断非一个人的功绩。此书为中国几千年来药物学的根本。其发明,全是由于古代的人们,所吃的植物,种类甚多之故。若照后世人的吃法,专于几种谷类和菜蔬、果品,便一万年,也不会发明什么《本草》的。\n一方面因所食之杂,而发现各种植物的性质;一方面即从各种植物中,淘汰其不适宜于为食料的,而栽培其宜于作食物的。其第(一)步,系从各种植物中,取出谷类,作为主食品。其第(二)步,则从谷类之中,再淘汰其粗的,而存留其精的。所以古人说百谷,后来便说九谷,再后来又说五谷。到现在,我们认为最适宜的主食品,只有稻和麦两种了。《墨子·辞过篇》说:“圣人作,诲男耕稼树艺,以为民食。其为食也,足以增气充虚,强体适腹而已矣。”《吕氏春秋·审时篇》说:“得时之稼,其臭香,其味甘,其气章。百日食之,耳目聪明,心意睿智,四卫变强(《注》:‘四卫,四肢也。’)气不入,身无苛殃。黄帝曰:四时之不正也,正五谷而已矣。”观此,便知农业的发明、进步,和人民的营养、健康,有如何重要的关系了。\n古人所豢养的动物,以马、牛、羊、鸡、犬、豕为最普通,是为六畜(《周官》职方氏,谓之六扰。名见郑《注》)。马牛都供交通耕种之用,故不甚用为食料。羊的畜牧,需要广大的草地,也是比较贵重的。鸡、犬、豕则较易畜养,所以视为常食。古人去渔猎时代近,男子畜犬的多。《管子·山权数》说:“若岁凶旱,水泆,民失本,则修宫室台榭,以前无狗,后无彘者为庸。”可见狗的畜养,和猪一样普遍。大概在古代,狗是男子所常畜,猪则是女子所畜的。家字从宀从豕,后世人不知古人的生活,则觉其难于解释。若知道古代的生活情形,则解释何难之有?猪是没有自卫能力的,放浪在外,必将为野兽所吞噬,所以不得不造屋子给它住。这种屋子,是女子所专有的。所以引申起来,就成为女子的住所的名称了。《仪礼·乡饮酒礼》记:“其牲狗”,《礼记·昏义》:“舅姑入室,妇以特豚馈”。可见狗是男子供给的肉食,猪是女子供给的肉食。后来肉食可以卖买,男子就有以屠狗为业的了。牛马要供给交通耕种之用,羊没有广大的草地,可资放牧,这种情形,后世还是和古代一样的,狗却因距离游猎时代远,畜养的人少了,猪就成为通常食用的兽。\n烹调方法的进步,也是食物进化中一种重要的现象。其根本,由于发明用火。而陶器制造的成功,也是很有关系的。《礼运》云:“夫礼之初,始诸饮食。其燔黍而捭豚,污尊而抔饮,蒉桴而土鼓,犹若可以致其敬于鬼神。”《注》云:“中古未有釜甑,释米,捭肉,加于烧石之上而食之耳。今北狄犹然。”此即今人所谓“石烹”。下文的《注》云:“炮,裹烧之也。燔,加于火上。亨,煮之镬也。炙,贯之火上。”其中只有烹,是陶器发明以后的方法。据社会学家说:陶器的发明,实因烧熟食物时,怕其枯焦,涂之以土,此正郑《注》所谓裹烧。到陶器发明以后,食物煮熟时,又可加之以水。有种质地,就更易融化。调味料亦可于取熟时同煮。烹调之法,就更易进行了。烹调之法,不但使(一)其味加美,亦能(二)杀死病菌,(三)使食物易于消化,于卫生是很有关系的。\n饮食的奢侈,亦是以渐而致的。《盐铁论·散不足篇》:贤良说:“古者燔黍食稗,而烨豚以相飨(烨当即捭字)。其后乡人饮酒,老者重豆,少者立食,一酱一肉,旅饮而已。及其后,宾昏相召,则豆羹白饭,綦脍熟肉。今民间酒食,殽旅重叠,燔炙满案。古者庶人粝食藜藿,非乡饮酒,腊,祭祀无酒肉。今闾巷县伯,阡陌屠沽,无故烹杀,相聚野外,负粟而往,挈肉而归。古者不粥絍(当作饪,熟食也),不市食。及其后,则有屠沽、沽酒、市脯、鱼盐而已。今熟食遍列,殽施成市。”可见汉代人的饮食,较古代为侈。然《论衡·讥日篇》说:“海内屠肆,六畜死者,日数千头。”怕只抵得现在的一个上海市。《隋书·地理志》说:梁州、汉中的人,“性嗜口腹,多事田渔。虽蓬室柴门,食必兼肉”。其生活程度,就又非汉人所及了。凡此,都可见得社会的生活程度,在无形中逐渐增高。然其不平均的程度,亦随之而加甚。《礼记·王制》说:“三年耕,必有一年之食,九年耕,必有三年之食。以三十年之通,虽有凶旱水溢,民无菜色,然后天子食,日举,以乐。”《玉藻》说:“至于八月不雨,君不举。”《曲礼》说:“岁凶,年不顺成,君膳不祭肺,马不食谷,大夫不食粱,士饮酒不乐。”这都是公产时代同甘共苦的遗规。然到战国时,孟子就以“庖有肥肉,厩有肥马,民有饥色,野有饿莩”,责备梁惠王了。我们试看《周官》的膳夫,《礼记》的《内则》,便知道当时的人君和士大夫的饮食,是如何奢侈。“朱门酒肉臭,路有冻死骨,荣枯咫尺异,惆怅难再述”,正不待盛唐的诗人,然后有这感慨了。\n《战国·魏策》说:“昔者帝女令仪狄作酒而美,进之禹。禹饮而甘之。遂疏仪狄,绝旨酒,曰:后世必有以酒亡其国者。”昔人据此,遂以仪狄为造酒的人。然仪狄只是作酒而美,并非发明造酒。古人所谓某事始于某人,大概如此。看《世本作篇》,便可知道。酒是要用谷类酿造的(《仪礼·聘礼》注:“凡酒,稻为上,黍次之,粟次之。”)其发明,必在农业兴起之后。《礼运》说:“污尊而抔饮。”郑《注》说:“污尊,凿地为尊也。抔饮,手掬之也。”这明明是喝的水。《仪礼·士昏礼疏》引此,谓其时未有酒醴,其说良是。《礼运》《疏》说凿地而盛酒,怕就未必然了。《明堂位》说:“夏后氏尚明水,殷人尚醴,周人尚酒。”凡祭祀所尚,都是现行的东西,前一时期的东西。据此,则酿酒的发明,还在夏后氏之先。醴之味较酒为醇,而殷人尚醴,周人尚酒;《周官》酒正,有五齐、三酒、四饮,四饮最薄,五齐次之,三酒最厚,而古人以五齐祭,三酒饮;可见酒味之日趋于厚。读《书经》的《酒诰》,《诗经》的《宾之初筵》等篇,可见古人酒德颇劣。现在的中国人,却没有酗酒之习,较之欧美人,好得多了。\n就古书看起来,古人的酒量颇大。《史记·滑稽列传》载淳于髡说:臣饮一斗亦醉,一石亦醉,固然是讽谕之辞,然《考工记》说:“食一豆肉,饮一豆酒,中人之食。”《五经异义》载《韩诗》说:古人的酒器:“一升曰爵,二升曰觚,三升曰觯,四升曰角,五升曰散。”古《周礼》说:“爵一升,觚三升,献以爵而酬以觚,一献而三酬,则一豆矣。”一豆就是一斗。即依《韩诗》说,亦得七升。古量法当今五分之一,普通人亦无此酒量。按《周官》浆人,六饮有凉。郑司农云:“以水和酒也。”此必古有此事,不然,断不能臆说的。窃疑古代献酬之礼,酒都是和着水喝的,所以酒量各人不同,而献酬所用的酒器,彼此若一。\n刺激品次于酒而兴起的为茶。茶之本字为荼。《尔雅·释木》:“槚,苦荼。”《注》云:“树小如栀子,冬生叶,可煮作羹饮。今呼早采者为茶,晚取者为茗,一名荈。蜀人名之苦荼。”按荼系苦菜之称。荼之味微苦。我们创造一句新的言语,是不容易的。遇有新事物须命名时,往往取旧事物和它相类的,小变其音,以为新名。在单音语盛行时,往往如此。而造字之法,亦即取旧字而增减改变其笔画,以为新字。如角甪、刀刁,及现在所造的乒乓等字皆其例。所以从荼字会孕育出茶的语言文字来(语言从鱼韵转入麻韵,文字减去一画)。茶是出产在四川,而流行于江南的。《三国吴志·韦曜传》说:孙皓强迫群臣饮酒时,常密赐茶荈以当酒。《世说新语》谓王濛好饮茶。客至,尝以是饷之。士大夫欲诣濛,辄曰:今日有水厄。即其证。《唐书·陆羽传》说:“羽嗜茶。著经三篇,言茶之源、之法、之具尤备。天下益知饮茶矣。其后尚茶成风,回纥入朝,始驱马市茶。”则茶之风行全国,浸至推及外国,是从唐朝起的。所以唐中叶后,始有茶税。然据《金史》说:金人因所需的茶,全要向宋朝购买,认为费国用而资敌。章宗承安四年,乃设坊自造,至泰和五年罢。明年,又定七品以上官方许食茶。据此,即知当时的茶,并不如今日的普遍。如其像现在一样,全国上下,几于无人不饮,这种禁令,如何能立呢?平话中《水浒传》的蓝本,是比较旧的。现行本虽经金圣叹改窜,究竟略存宋元时的旧面目。书中即不甚见饮茶,渴了只是找酒喝。此亦茶在宋元时还未如今日盛行的证据。《日知录》引唐綦毋《茶饮序》云:“释滞消壅,一日之利暂佳,瘠气侵精,终身之害斯大。”宋黄庭坚《茶赋》云:“寒中瘠气,莫甚于茶。”则在唐宋时,茶还带有药用的性质,其刺激性,似远较今日之茶为烈。古人之茶系煎饮,亦较今日的用水泡饮为烦。如此看来,茶的名目,虽今古相同,其实则大相殊异了。这该是由于茶的制法,今古不同,所以能减少其有害的性质,而成为普遍的饮料。这亦是饮食进化的一端。\n次于茶而兴起的为烟草。其物来自吕宋。名为菸,亦名淡巴菰(见《本草》),最初莆田人种之。王肱枕《蚓庵琐语》云:“烟叶出闽中,边上人寒疾,非此不治。关外至以一马易一觔。崇祯中,下令禁之。民间私种者问徒刑。利重法轻,民冒禁如故。寻下令:犯者皆斩。然不久,因军中病寒不治,遂弛其禁。予儿时尚不识烟为何物,崇祯末,三尺童子,莫不吃烟矣。”(据《陔余丛考》转引)据此,则烟草初行时,其禁令之严,几与现在的鸦片相等。烟草可治寒疾,说系子虚,在今日事极明白。军中病寒,不过弛禁的一藉口而已。予少时曾见某书,说明末北边的农夫,有因吸烟而醉倒田中的(此系予十余龄时所见,距今几四十年,不能忆其书名。藏书毁损大半,仅存者尚在游击区中,无从查检)。在今日,无论旱烟、水烟、卷烟,其性质之烈,均不能至此。则烟草的制法,亦和茶一般,大有改良了。然因此而引起抽吸大烟,则至今仍遗害甚烈。\n罂粟之名,始见于宋初的《开宝本草》。宋末杨士瀛的《直指方》,始云其壳可以治痢。明王玺《医林集要》,才知以竹刀刮出其津,置瓷器内阴干。每服用小豆一粒,空心温水化下,然皆以作药用。俞正燮《癸巳类稿》云:“明四译馆同文堂外国来文八册,有译出暹罗国来文,中有进皇帝鸦片二百斤,进皇后鸦片一百斤之语。又《大明会典》九十七、九十八,各国贡物,暹罗、爪哇、榜葛剌三国,俱有乌香,即鸦片。”则明时此物确系贡品。所以神宗皇帝,久不视朝,有疑其为此物所困的。然其说亦无确据。今人之用作嗜好品,则实由烟草引起。清黄玉圃《台海使槎录》云:“鸦片烟,用麻葛同雅士切丝,于铜铛内煎成鸦片拌烟。用竹筩,实以棕丝,群聚吸之。索直数倍于常烟。”《雍正朱批谕旨》:七年,福建巡抚刘世明,奏漳州知府李国治,拿得行户陈达私贩鸦片三十四斤,拟以军罪。臣提案亲讯。陈达供称鸦片原系药材,与害人之鸦片烟,并非同物。当传药商认验。佥称此系药材,为治痢必须之品,并不能害人。惟加入烟草同熬,始成鸦片烟。李国治妄以鸦片为鸦片烟,甚属乖谬,应照故入人罪例,具本题参。则其时的鸦片,尚未能离烟草而独立。后来不知如何,单独抽吸,其害反十百倍于烟草了。\n中国食物从外国输入的甚多。其中最重要的,自然当推蔗糖,其法系唐太宗时,得之于摩揭它的,见《唐书·西域传》。前此的饴,是用米麦制的。大徐《说文》新附字中,始有糖字。字仍从米,释以饴而不及蔗,可见宋初蔗糖尚未盛行。北宋末,王灼撰《糖霜谱》,始备详其产地及制法。到现在,蔗糖却远盛于饴糖了。此外菜类如苜蓿,果品如西瓜等,自外国输入的还很多。现在不及备考。\n中国人烹调之法,在世界上是首屈一指的。康有为《欧洲十一国游记》,言之最详。但调味之美,和营养之佳良,系属两事,不可不知。又就各项费用在全体消费中所占的成分看,中国人对于饮食,是奢侈的。康有为《物质救国论》说:国民的风气,侈居为上,侈衣次之,侈食为下。这亦是我国民不可不猛省的。\n衣服的进化,当分两方面讲:一是材料,一是裁制的方法。\n《礼运》说“未有麻丝,衣其羽皮”。这只是古人衣服材料的一种。还有一种,是用草的。《礼记·郊特牲》说:“黄衣黄冠而祭,息田夫也。野夫黄冠。黄冠,草服也。大罗氏,天子之掌鸟兽者也,诸侯贡属焉。草笠而至,尊野服也。”《诗经》:“彼都人士,台笠缁撮。”《毛传》:“台所以御暑,笠所以御雨也。”《郑笺》:“台,夫须也。都人之士,以台为笠。”《左传》襄公十四年,晋人数戎子驹支道:“乃祖吾离,被苫盖。”《注》:“盖,苫之别名。”《疏》云:“言无布帛可衣,惟衣草也。”《墨子·辞过》云:“古之民未知为衣服时,衣皮带茭。”孙诒让《闲诂》说:“带茭,疑即《丧服》之绞带,亦即《尚贤篇》所谓带索。”按《仪礼·丧服传》云:“绞带者,绳带也。”又《孟子·尽心上篇》:“舜视弃天下,犹弃敝屣也。”《注》云:“屣,草履。”《左传》僖公四年,“共其资粮屝屦。”《注》云:“屝,草屦。”可见古人衣服冠履,都有用草制的。大概古代渔猎之民,以皮为衣服的材料。所以《诗经·采菽》郑《笺》说黻道:“古者田渔而食,因衣其皮,先知蔽前,后知蔽后。”(参看下文)而后世的甲,还是用革制的。戴在头上的有皮弁,束在身上的有革带,穿在脚上的有皮屦(夏葛屦,冬皮屦,见《仪礼·士冠礼》、《士丧礼》,履以丝为之,见《方言》)。农耕之民,则以草为衣服的材料。所以《郊特牲》说黄衣黄冠是野服。《禹贡》:扬州岛夷卉服,冀州岛夷皮服(岛当作鸟,《疏》言伪孔读鸟为岛可见)。观野蛮人的生活,正可知道我族未进化时的情形。\n麻丝的使用,自然是一个大发明。丝的使用,起于黄帝元妃嫘祖,说不足信,已见上章。麻的发明,起于何时,亦无可考。知用麻丝之后,织法的发明,亦为一大进步。《淮南子·氾论训》说:“伯余之初作衣也,麻索缕,手经指挂,其成犹网罗。后世为之机杼胜复,以领其用,而民得以揜形御寒。”手经指挂,是断乎不能普遍的。织法的发明,真是造福无穷的了。但其始于何时,亦不可考。丝麻发明以后,皮和草的用途,自然渐渐的少了。皮的主要用途只是甲。至于裘,则其意不仅在于取暖,而兼在于美观。所以古人的著裘,都是把毛著在外面,和现在人的反着一样(《新序·杂事》:“虞人反裘而负薪,彼知惜其毛,不知皮尽而毛无所傅。”)外面罩着一件衣服,谓之裼衣。行礼时,有时解开裼衣,露出里面的裘来,有时又不解开,把它遮掩掉,前者谓之裼,后者谓之袭。藉此变化,以示美观(无裼衣谓之“表裘”为不敬。绤之上,亦必加以禅衣谓之袗)。穷人则着毛织品,谓之褐。褐倒是专为取暖起见的。现在畜牧和打猎的事业都衰了,丝棉较皮货为贱。古代则不然。裘是比较普遍的,丝棉更贵。二十可以衣裘帛(《礼记·内则》),五十非帛不暖(《礼记·王制》)。庶人亦得衣犬羊之裘,即其明证。丝棉新的好的谓之纩,陈旧的谓之絮。见《说文》。\n现在衣服材料,为用最广的是木棉。其普遍于全国,是很晚的。此物,《南史·林邑传》谓之吉贝,误为木本。《新唐书》作古贝,才知为草本。《南史》姚察门生送南布一端;白居易《布裘诗》:“桂布白似雪”,都是指棉布而言。但只限于交、广之域。宋谢枋得《谢刘纯父惠木棉诗》:“嘉树种木棉,天何厚八闽?”才推广到福建。《元史·世祖本纪》:至元二十六年,置浙江、江东西、湖广、福建木棉提举司,则推广到长江流域了。其所以能推广,和纺织方法,似乎很有关系的。《宋史·崔与之传》:琼州以吉贝织为衣衾,工作由妇人。陶宗仪《辍耕录》说:松江土田硗瘠,谋食不给,乃觅木棉种于闽、广。初无踏车椎弓之制。其功甚难。有黄道婆,自崖州来,教以纺织,人遂大获其利。未几,道婆卒,乃立祠祀之。木棉岭南久有,然直至宋元间才推行于北方,则因无纺织之法,其物即无从利用,无利之可言了。所以农、工两业,是互相倚赖,互相促进的(此节略据《陔余丛考》)。\n衣服裁制的方法:最早有的,当即后来所谓黻。亦作。此物在后来,是著在裳之外,以为美观的。但在邃初,则当系亲体的。除此之外,全身更无所有。所以《诗经·郑笺》说:“古者田渔而食,因衣其皮,先知蔽前,后知蔽后。”衣服的起源,从前多以为最重要的原因是御寒,次之是蔽体。其实不然。古人冬则穴居,并不藉衣服为御寒之具。至于裸露,则野蛮人绝不以为耻,社会学上证据甚多。衣服的缘起,多先于下体,次及上体;又多先知蔽前,后知蔽后,这是主张衣服缘起,由于以裸露为耻者最大的证据。据现在社会学家的研究,则非由于以裸露为耻,而转系籍装饰以相挑诱。因为裸露是人人所同,装饰则非人人所有,加以装饰,较诸任其自然,刺激性要重些。但蔽其前为韨,兼蔽其后即为裳了。裳而加以袴管(古人谓之),短的谓之裈,长的谓之袴,所以《说文》称袴为胫衣,昔人所谓贫无袴,裈还是有的,并非裸露。又古人的袴、裆都是不缝合的,其缝合的谓之穷袴,转系特别的。见《汉书·外戚传》。这可见裈和袴,都是从裳变化出来的,裳在先,裈和袴在后。裳幅前三后四,都正裁。吉服襞绩(打裥)无数,丧服三襞绩(《仪礼·丧服》郑《注》)。着在上半身的谓之衣。其在内的:短的谓之襦。长的,有着(装绵),谓之袍,无着谓之衫。古代袍、衫不能为礼服,其外必再加以短衣和裳。戴在头上的,最尊重的是冕。把木头做骨子,外面用布糊起来,上面是玄色,下面是朱色。戴在头上,前面是低一些的。前有旒,据说是把五彩的绳,穿了一块块的玉,垂在前面。其数,天子是十二,此外九旒、七旒等,以次减杀。两旁有纩,是用黄绵,大如丸,挂在冕上面的,垂下来,恰与两耳相当。后来以玉代黄绵,谓之瑱。冕,当系野蛮时代的装饰,留遗下来的。所以其形状,在我们看起来,甚为奇怪,古人却以为最尊之服。次于冕者为弁,以皮为之。其形状亦似冕。但无旒及等,戴起来前后平。冠是所以豢发的。其形状,同现在旧式丧礼中孝子戴的丧冠一样。中间有一个梁,阔两寸。又以布围发际,自前而后,谓之武。平居的冠,和武是连在一起的。否则分开,临时才把它合起来。又用两条组,连在武上,引至颐下,将它结合,是为缨。有余,就把它垂下来,当作一种装饰,谓之。冠不用簪,冕弁则用簪。簪即女子之笄,古人重露发,必先把“缁”套起来,结之为,然后固之以冠。冠用缨,冕弁则把一条组结在右笄上,垂下来,经过颐下,再绕上去,结在左笄上。冠是成人的服饰,亦是贵人的服饰,所以有罪要免冠。至于今之脱帽,则自免胄蜕化而来。胄是武人的帽子,因为怕受伤之故,下垂甚深,几于把脸都遮蔽掉了,看不见。所以要使人认识自己,必须将胄免去。《左传》哀公十六年,楚国白公作乱,国人专望叶公来救援。叶公走到北门,“或遇之,曰:君胡不胄?国人望君,如望慈父母焉。盗贼之矢若伤君,是绝民望也。若之何不胄?乃胄而进。又遇一人,曰:君胡胄?国人望君,如望岁焉,日日以几,若见君面,是得艾也。民知不死,其亦夫有奋心。犹将旌君以徇于国,而又掩面以绝民望,不亦甚乎?乃免胄而进。”可见胄的作用。现在的脱帽,是采用欧洲人的礼节。欧洲人在中古时代,战争是很剧烈的。免胄所以使人认识自己,握手所以表示没有兵器。后遂相沿,为寻常相见之礼。中国人模仿它,其实是无谓的。有人把脱帽写作免冠,那更和事实不合了。古代庶人是不冠的,只用巾。用以覆髻,则谓之帻。《后汉书·郭泰传》《注》引周迁《舆服杂事》说:“巾以葛为之,形如,本居士野人所服。”《玉篇》:“帽也。”《隋书·舆服志》:“帽,古野人之服。”则巾和帽是很相近的。著在脚上的谓之袜。其初亦以革为之。所以其字从韦作。袜之外为屦。古人升堂必脱屦。脱屦则践地者为袜,立久了,未免污湿,所以就坐又必解袜。见《左传》哀公二十五年。后世解袜与否无文,然脱屦之礼,则相沿甚久。所以剑履上殿,看做一种殊礼。《唐书》:棣王琰有两妾争宠。求巫者密置符于琰履中。或告琰厌魅,帝伺其朝,使人取其履验之,果然。则唐时入朝,已不脱履。然刘知幾以释奠皆衣冠乘马,奏言冠履只可配车,今袜而镫,跣而鞍,实不合于古。则祭祀还是要脱履的。大概跣礼之废,(一)由于靴之渐行,(二)由于席地而坐,渐变为高坐,参看后文及下章自明。古人亦有现在的绑腿,谓之逼,亦谓之邪幅,又谓之行縢。本是上路用的,然亦以之为饰。宋绵初《释服》说“解袜则见逼。《诗》云:邪幅在下,正是燕饮而跣以为欢之时”,则逼着在袜内。《日知录》说:“今之村民,往往行縢而不袜,古人之遗制也。吴贺邵美容止,常著袜,希见其足,则汉魏之世,不袜而见足者尚多。”又说袜字的从衣,始见于此,则渐变而成今日的袜了。窃疑袜本亦田猎之民之服,农耕之民,在古代本是跣足的。中国文化,本来起自南方,所以行礼时还必跣。\n衣服的初兴,虽非以蔽体为目的,然到后来,着衣服成了习惯,就要把身体的各部分,都遮蔽起来,以为恭敬了。所以《礼记》的《深衣篇》说:“短毋见肤。”作事以短衣为便,今古皆然。古代少者贱者,是多服劳役的。《礼记·曲礼》说:“童子不衣裘裳。”《内则》说:“十年,衣不帛,袴。”就是短衣,袴就是不裳。《左传》昭公二十五年,师己述童谣,说“鹆跦跦,公在乾侯,征褰与襦”。褰即是袴(《说文》)。此皆服劳役者不着裳之证。然襦袴在古人,不能算做礼服,外必加之以裳。既然如此,自以照现在人的样子,于襦袴之外,罩上一件长衫为便。然古人习于衣裳、袍衫之外,亦必加之以裳。于是从古代的衣裳,转变到现在的袍衫,其间必以深衣为过渡。深衣的意思,是和现在的女子所着的衣裙合一的衣服差不多的。形式上是上衣下裳,实则缝合在一起。裳分为十二幅,前后各六。中间四幅对开。边上两幅斜裁,成两三角形。尖端在上。所以其裳之下端与上端(腰间)是三与二之比。如此,则不须襞绩,自亦便于行动了。深衣是白布做的,却用绸镶边,谓之纯。无纯的谓之褴褛,尤为节俭(今通作蓝缕,其义为破,此是一义)。士以上别有朝祭之衣,庶人则即以深衣为吉服。未成年者亦然。所以戴德《丧服·变除》说:“童子当室(为父后),其服深衣不裳。”然自天子至于士,平居亦都是着一件深衣的。这正和现在的劳动者平时着短衣,行礼时着袍衫,士大夫阶级,平时着袍衫,行礼时别有礼服一样。然古人苟非极隆重的典礼,亦都可以着深衣去参与的。所以说“可以为文,可以为武,可以摈相,可以治军旅”(《礼记·深衣》)。民国以来,将平时所着的袍和马褂,定为常礼服。既省另制礼服之费,又省动辄更换之烦,实在是很合理的。\n《仪礼·士丧礼》疏,谓上下通直,不别衣裳者曰“通裁”,此为深衣改为长袍之始。然古人用之殊不广。后汉以后,始以袍为朝服。《续汉书·舆服志》说:若冠通天冠,则其服为深衣服。有袍,随五时色。刘昭《注》云:“今下至贱吏、小史,皆通制袍、禅衣、皂缘领袖为朝服。”《新唐书·车服志》:中书令马周上议:“礼无服衫之文。三代之制有深衣,请加襕袖褾襈,为士人上服。开胯者名曰缺胯,庶人服之。”据此,则深衣与袍衫之别,在于有缘无缘。其缺胯,就是现在的袍衫了。任大椿《深衣释例》说:“古以殊衣裳者为礼服,不殊衣裳者为燕服。后世自冕服外,以不殊衣裳者为礼服,以殊衣裳者为燕服。”此即所谓裙襦。妇人以深衣之制为礼服,不殊衣裳。然古乐府《陌上桑》云:“湘绮为下裳,紫绮为上襦”,则襦与裳亦各别。然仍没有不着裳的。隋唐以来,乃有所谓袴褶。(《急就篇》注云:“褶,其形若袍,短身广袖。”)天子亲征及中外戒严时,百官服之,实为戎服。\n曾三异《同话录》云:“近岁衣制,有一种长不过腰,两袖仅掩肘,名曰貉袖。起于御马院圉人。短前后襟者,坐鞍上不妨脱著,以其便于控驭也。”此即今之马褂。《陔余丛考》说:就是古代的半臂。《三国魏志·杨阜传》说:明帝著帽,披绫半袖,则其由来已久。《玉篇》说:裆,其一当胸,其一当背。《宋书·薛安都传》载他着绛衲两当衫,驰入贼阵。《隋书·舆服志》:诸将军侍从之服,有紫衫金玳瑁装裆甲,紫衫金装裆甲,绛衫银装裆甲。《宋史·舆服志》,范质议《开元礼》:武官陪立大仗,加螣蛇裆甲,《陔余丛考》说:就是今演剧时将帅所被金银甲。按现在我们所着,长不过腰,而无两袖的,北方谓之坎肩,南方有若干地方,谓之马甲。大概系因将帅服之之故。宋人谓之背子。见《石林燕语》。\n●古代服饰\n衣服不论在什么时代,总是大同小异的。强人人之所好,皆出于同,自然决无此理。何况各地方的气候,各种人的生活,还各有不同呢?但衣服既和社交有关,社会亦自有一种压力。少数的人,总要改从多数的。昔人所谓“十履而一跣,则跣者耻;十跣而一履,则履者耻”。其间别无他种理由可言。《礼记·王制》:“关执禁以讥,禁异服,察异言。”其意乃在盘诘形迹可疑的人,并不在于划一服饰。《周官》大司徒,以本俗六安万民,六曰同衣服,意亦在于禁奢,非强欲使服饰齐一。服饰本有一种社会压力,不会大相悬殊的。至于小小的异同,则无论何时,皆不能免。《礼记·儒行》:“鲁哀公问于孔子曰:夫子之服,其儒服与?孔子对曰:丘少居鲁,衣逢掖之衣。长居宋,冠章甫之冠。丘闻之也,君子之学也博,其服也乡。丘不知儒服。”观此数语,衣服因地方、阶级,小有异同,显然可见。降逮后世,叔孙通因高祖不喜儒者,改着短衣楚制(见《史记》本传)。《盐铁论》载桑弘羊之言,亦深讥文学之儒服(见《相刺篇》、《刺议篇》),可见其情形还是一样的。因为社会压力,不能施于异地方和异阶级的人。然及交通进步,各阶级的交往渐多,其压力,也就随之而增大了。所以到现代,全世界的服饰,且几有合同而化之观。日本变法以后,几于举国改着西装。中国当戊戌变法时,康有为亦有改服饰之议。因政变未成。后来自刻《戊戌奏稿》,深悔其议之孟浪,而自幸其未果行。在所著《欧洲十一国游记》中,尤极称中国服饰之美。其意是(一)中国的气候,备寒、温、热三带,所以其材料和制裁的方法,能适应多种气候,合于卫生。(二)丝织品的美观,为五洲所无。(三)脱穿容易。(四)贵族平民,服饰有异,为中西之所同。中国从前,平民是衣白色的。欧洲则衣黑色。革命时,欧人疾等级之不平,乃强迫全国上下,都着黑色。中国则不然。等级渐即平夷,采章遂遍及于氓庶。质而言之:西洋是强贵族服平民之服,中国则许平民服贵族之服。所以其美观与否,大相悬殊。这一点,西人亦有意见相同的。民国元年,议论服制时,曾有西人作论载诸报端,说西方的服饰,千篇一律,并无趣味,劝中国人不必摹仿。我以为合古今中外而观之,衣服不过南北两派。南派材料轻柔,裁制宽博。北派材料紧密,裁制狭窄。这两派的衣服,本应听其并行;且折衷于二者之间,去其极端之性的。欧洲衣服,本亦有南北两派。后来改革之时,偏重北派太甚了。中国则颇能折二者之中,保存南派的色彩较多。以中西的服饰相较,大体上,自以中国的服饰为较适宜。现在的崇尚西装,不过一时的风气罢了。\n中国的衣服,大体上可谓自行进化的。其仿自外国的,只有靴。《广韵》八戈引《释名》,说“靴本胡服,赵武灵王所服”。《北史》载慕容永被擒,居长安,夫妻卖靴以自活。北齐亡后,妃嫔入周的亦然。可见南北朝时,汉人能制靴者尚少,其不甚用靴可知。然唐中叶以后,朝会亦渐渐的着靴,朱文公《家礼》,并有襕衫带靴之制了。《说文》:“鞮,革履也。”《韵会》引下有“胡人履连胫,谓之络缇”九字。此非《说文》之文,必后人据靴制增入。然可悟靴所以广行之故。因为连胫,其束缚腿部较紧,可以省却行縢。而且靴用革制,亦较能抵御寒湿,且较绸布制者,要坚固些(此以初兴时论,后来靴亦不用革)。\n古代丧服,以布之精粗力度,不是讲究颜色的。素服则用白绢,见《诗经·棘人》疏。因为古代染色不甚发达,上下通服白色,所以颜色不足为吉凶之别。后世彩色之服,行用渐广,则忌白之见渐生。宋程大昌《演繁露》说:“《隋志》:宋齐之间,天子宴私著白高帽。隋时以白通为庆吊之服。国子生亦服白纱巾。晋人着白接篱,窦苹《酒谱》曰:接篱,巾也。南齐桓崇祖守寿春,着白纱帽,肩舆上城。今人必以为怪。古未有以白色为忌也。郭林宗遇雨垫巾,李贤《注》云:周迁《舆服杂事》曰,巾以葛为之,形如。本居士野人所服。魏武造,其巾乃废。今国子学生服焉,以白纱为之。是其制皆不忌白也。《乐府白纻歌》曰:质如轻云色如银,制以为袍余作巾。今世人丽妆,必不肯以白纻为衣。古今之变,不同如此。《唐六典》:天子服有白纱帽。其下服如裙、襦、袜皆以白。视朝听讼,燕见宾客,皆以进御。然其下注云:亦用乌纱。则知古制虽存,未必肯用,习见忌白久矣。”读此,便知忌白的由来。按染色之法,见于《周官》天官染人,地官染草,及《考工记》锺氏,其发明亦不可谓不早。但其能普遍于全社会,却是另一问题。绘绣之法,见《书经·皋陶谟》(今本《益稷》)《疏》。昔人误以绘为画。其实绘之本义,乃谓以各色之丝,织成织品。见于宋绵庄《释服》,其说是不错的。染色、印花等事,只要原料减贱,机器发明,制造容易,所费人工不多,便不得谓之奢侈。惟有手工,消费人工最多,总是奢侈的事。现在的刺绣,虽然是美术,其实是不值得提倡的。因为天下无衣无褐的人,正多着呢。\n第五十章 住行 # 住居,亦因气候地势的不同,而分为巢居、穴居两种。《礼运》说:“冬则居营窟,夏则居橧巢。”(见上章)《孟子》亦说:“下者为巢,上者为营窟。”(《滕文公下篇》)大抵温热之地为巢。干寒之地,则为营窟。巢居,现在的野蛮人,犹有其制。乃将大树的枝叶,接连起来,使其上可以容人,而将树干凿成一级一级的,以便上下。亦有会造梯的。人走过后,便将梯收藏起来。《淮南子·本经训》所谓“托婴儿于巢上”,当即如此。后来会把树木砍伐下来,随意植立,再于其上横架着许多木材,就成为屋子的骨干。穴居又分復穴两种:(一)最初当是就天然的洞窟,匿居其中的。(二)后来进步了,则能于地上凿成一个窟窿,而居其中,此之谓穴。古代管建设的官,名为司空,即由于此。(三)更进,能在地面上把土堆积起来,堆得像土窑一般,而于其上开一个窟窿,是之谓復,亦作复。再进化而能版筑,就成为墙的起源了。以栋梁为骨骼,以墙为肌肉,即成所谓宫室。所以直至现在,还称建筑为土木工程。\n中国民族,最初大约是湖居的。(一)水中可居之处称洲,人所聚居之地称州,州洲虽然异文,实为一语,显而易见(古州岛同音,洲字即岛字)。(二)古代有所谓明堂,其性质极为神秘。一切政令,都自此而出(读惠栋《明堂大道录》可见)。阮元说:这是由于古代简陋,一切典礼,皆行于天子之居,后乃礼备而地分(《揅经室集明堂说》),这是不错的。《史记·封禅书》载公玉带上《明堂图》,水环宫垣,上有楼,从西南入,名为昆仑,正是岛居的遗象。明堂即是大学,亦称辟雍。辟壁同字,正谓水环宫垣。雍即今之壅字,壅塞、培壅,都指土之增高而言,正像湖中岛屿。(三)《易经》泰卦上六爻辞,“城复于隍”。《尔雅·释言》:“隍,壑也。”壑乃无水的低地。意思还和环水是一样的。然则不但最初的建筑如明堂者,取法于湖居,即后来的造城,必环绕之以濠沟,还是从湖居的遗制,蜕化而出的。\n文化进步以后,不藉水为防卫,则能居于大陆之上。斯时藉山以为险阻。读第四十、第四十四、第四十五三章,可见。章炳麟《太炎文集》有《神权时代天子居山说》,可以参考。再进步,则城须造在较平坦之地,而藉其四周的山水以为卫,四周的山水,是不会周匝无缺的,乃用人工造成土墙,于其平夷无险之处,加以补足,是之谓郭。郭之专于一面的,即为长城。城是坚实可守的,郭则工程并不坚实,而且其占地太大,必不能守。所以古代只有守城,绝无守郭之事。即长城亦是如此。中国历代,修造长城,有几个时期。(一)为战国以前。齐国在其南边,造有长城,秦、赵、燕三国,亦在北边造有长城。后来秦始皇把它连接起来,就是俗话所称为万里长城的。此时南方的淮夷、北方的匈奴,都是小部落。到汉朝,匈奴强大了,入塞的动辄千骑万骑,断非长城所能御;而前后两呼韩邪以后,匈奴又宾服了,所以终两汉四百年,不闻修造长城。魏晋时,北方丧乱,自然讲不到什么远大的防御规模。拓跋魏时,则于北边设六镇,藉兵力以为防卫,亦没有修造长城的必要,(二)然至其末年,情形就大不相同了。隋代遂屡有修筑。此为修造长城的第二时期。隋末,突厥强大了,又非长城所能御。后来的回纥、契丹亦然。所以唐朝又无修筑长城之事。(三)契丹亡后,北方的游牧部族,不能统一,又成小小打抢的局面。所以金朝又要修造一道边墙,从静州起,迤逦东北行,达女真旧地。此为修造长城的第三时期。元朝自然无庸修造长城。(四)明时,既未能将蒙古征服,而蒙古一时亦不能统一。从元朝的汉统断绝以后,至达延汗兴起以前,蒙古对中国,并无侵犯,而只有盗塞的性质,所以明朝又修长城,以为防卫。现代的长城,大概是明朝遗留下来的。总而言之,小小的寇盗,屯兵防之,未免劳费,无以防之又不可。造长城,实在是最经济的方法。从前读史的人,有的称秦始皇造长城,能立万世夷夏之防,固然是梦话。有的议论他劳民伤财,也是胡说的。晁错说秦朝北攻胡貉,置塞河上,只是指秦始皇时使蒙恬新辟之土。至于其余的长城,因战国时秦、赵、燕三国之旧,缮修起来的,却并没有费什么工力。所以能在短时间之内,即行成功。不然,秦始皇再暴虐,也无法于短时间之内,造成延袤万余里的长城的。汉代的人,攻击秦朝暴虐的很多,未免言过其实,然亦很少提及长城的,就是一个证据。\n古代的房屋,有平民之居和士大夫之居两种。士大夫之居,前为堂,后为室。室之左右为房。堂只是行礼之地,人是居于室中的(室之户在东南,牖在西南,北面亦有牖,谓之北牖。室之西南隅,即牖下,地最深隐,尊者居之,谓之奥。西北隅为光线射入之地,谓之屋漏。东北隅称宦。宦养也,为饮食所藏。东南隅称窔,亦深隐之义。室之中央,谓之中霤,为雨水所溜入。此乃穴居时代,洞穴开口在上的遗象。古之牖即今之窗,是开在墙上的。其所谓窗,开在屋顶上,今人谓之天窗)。平民之居,据晁错《移民塞下疏》说:“古之徙远方以实广虚也,先为筑室。家有一堂二内。”《汉书》注引张晏曰:“二内,二房也。”此即今三开间的屋。据此,则平民之居,较之士大夫之居,就是少了一个堂。这个到现在还是如此。士大夫之家,前有厅事,即古人所谓堂。平民之家无有。以中间的一间屋,行礼待客,左右两间供住居,即是一堂二内之制。简而言之,就是以室为堂,以房为室罢了。古总称一所屋子谓之宫。《礼记·内则》说:“由命士以上,父子皆异宫”,则一对成年的夫妻,就有一所独立的屋子。后世则不然。一所屋子,往往包含着许多进的堂和内,而前面只有一个厅事。这就是许多房和室,合用一个堂,包含在一个宫内,较古代经济多了。这大约因为古代地旷人稀,地皮不甚值钱,后世则不然之故。又古代建筑,技术的关系浅,人人可以自为,士大夫之家,又可役民为之。后世则建筑日益专门,非雇人为之不可(《论衡·量知篇》:“能斫削柱梁,谓之木匠。能穿凿穴坎,谓之土匠。”则在汉代,民间建筑,亦已有专门的人)。这亦是造屋的人,要谋节省的一个原因。\n古人造楼的技术,似乎是很拙的。所以要求眺望之所,就只得于城阙之上。阙是门旁墙上的小屋。天子诸侯的宫门上,也是有的。因其可以登高眺远,所以亦谓之观。《礼记·礼运》:“昔者仲尼与于蜡宾,事毕,出游于观之上”,即指此。古所谓县法象魏者,亦即其地。魏与巍同字,大概因其建筑高,所以称之为魏。象字当本指法象言,与建筑无涉。因魏为县法之地,单音字变为复音词时,就称其地为象魏了。《尔雅·释宫》:“四方而高曰台。有木者谓之榭。陕而修曲曰楼。”(陕同狭)《注》云:“台,积土为之。”榭是在土台之上,再造四方的木屋。楼乃榭之别名,不过其形状有正方修曲之异而已,这都是供游观眺望之所,并不是可以住人的。《孟子·尽心下篇》:“孟子之滕,馆于上宫。”赵《注》说:“上宫,楼也。”这句话恐未必确。因为造楼之技甚拙,所以中国的建筑,是向平面发展,而不是向空中发展的。所谓大房屋,只是地盘大,屋子多,将许多屋连结而成,而两层、三层的高楼很少。这个和建筑所用的材料,亦有关系。因为中国的建筑,用石材很少,所用的全是土木,木的支持力固不大,土尤易于倾圮。炼熟的土,即砖瓦,要好些,然其发达似甚晚。《尔雅·释宫》:“瓴甋谓之甓。”“庙中路谓之唐。”甓即砖。《诗经·陈风》说“中唐有甓”,则砖仅用以铺路。其墙,大抵是用土造的。土墙不好看,所以富者要被以文锦。我们现在婚、丧、生日等事,以绸缎等物送人,谓之幛,还是这个遗俗;而纸糊墙壁,也是从此蜕化而来的。《晋书·赫连勃勃载记》说他蒸土以筑统万城,可见当时砖尚甚少。不然,何不用砖砌,而要临时蒸土呢?无怪古代的富者,造屋只能用土墙了。建筑材料,多用土木,和古代建筑的不能保存,也有关系。因为其不如石材的能持久。而用木材太多,又易于引起火患。前代的杭州,近代的汉口,即其殷鉴。\n建筑在中国,是算不得发达的。固然,研究起世界建筑史来,中国亦是其中的一系(东洋建筑,有三大系统:(一)中国,(二)印度,(三)回教,见伊东忠太《中国建筑史》,商务印书馆本)。历代著名的建筑,如秦之阿房宫,汉之建章宫,陈后主的临春、结绮、望春三阁,隋炀帝的西苑,宋徽宗的艮岳,清朝的圆明园、颐和园,以及私家的园林等,讲究的亦属不少。然以中国之大言之,究系沧海一粟。建筑的技术,详见宋朝的《营造法式》、明朝的《天工开物》等书。虽然亦有可观,然把别种文明比例起来,则亦无足称道。此其所以然:(一)因(甲)古代的造屋,乃系役民为之,滥用民力,是件暴虐的事。(乙)又古代最讲究礼,生活有一定的规范,苟非无道之君,即物力有余,亦不敢过于奢侈。所以政治上相传,以卑宫室为美谈,事土木为大戒。(二)崇闳壮丽的建筑,必与迷信为缘。中国人对于宗教的迷信,是不深的。祭神只是临时设坛或除地,根本便没有建筑。对于祖宗的祭祀,虽然看得隆重,然庙寝之制,大略相同。后世立家庙等,亦受古礼的限制,不能任意奢侈。佛教东来,是不受古礼限制的,而且其教义,很能诱致人,使其布施财物。道家因之,亦从事于模仿,寺观遂成为有名的建筑,印度的建筑术,亦因此而输入中国。南朝四百八十寺,多少楼台烟雨中,一时亦呈相当的盛况。然此等迷信,宋学兴起以后,又渐渐的淡了。现在佛寺、道观虽多,较之缅甸、日本等国,尚且不逮。十分崇闳壮丽的建筑,亦复很少,不过因其多在名山胜地,所以为人所赞赏罢了。(三)游乐之处,古代谓之苑囿。苑是只有草木的,囿是兼有禽兽的。均系将天然的地方,划出一区来,施以禁御,而于其中射猎以为娱,收其果实等以为利,根本没有什么建筑物。所以其大可至于方数十里(文王之囿,方七十里,齐宣王之囿,方四十里,见《孟子·梁惠王下篇》)。至于私家的园林,则其源起于园。园乃种果树之地,因于其间叠石穿池,造几间房屋,以资休憩,亦不是甚么奢侈的事。后来虽有踵事增华,刻意经营的人,究竟为数亦不多,而且其规模亦不大。以上均系中国建筑不甚发达的原因。揆厥由来,乃由于(一)政治的比较清明,(二)迷信的比较不深,(三)经济的比较平等。以物质文明言,固然较之别国,不免有愧色,以文化论,倒是足以自豪的。\n朱熹说:“教学者如扶醉人,扶得东来西又倒。”个人的为学如是,社会的文化亦然。奢侈之弊,中国虽比较好些,然又失之简陋了。《日知录·馆舍》条说:“读孙樵《书褒城驿壁》,乃知其有沼,有鱼,有舟。读杜子美《秦州杂诗》,又知其驿之有池,有林,有竹。今之驿舍,殆于隶人之垣矣。予见天下州之为唐旧治者,其城郭必皆宽广,街道必皆正直。廨舍之为唐旧创者,其基址必皆宏敞。宋以下所置,时弥近者制弥陋。”亭林的足迹,所至甚多,而且是极留心观察的人,其言当极可信。此等简陋苟且,是不能藉口于节俭的。其原因安在呢?亭林说:是由于“国家取州县之财,纤豪尽归之于上,而吏与民交困,遂无以为修举之资”。这固然是一个原因。我以为(一)役法渐废,公共的建筑,不能征工,而必须雇工。(二)唐以前古市政的规制犹存,宋以后逐渐破坏(如第四十七章所述,唐设市还有定地,开市还有定期,宋以后渐渐不然,亦其一证),亦是重要的原因。\n从西欧文明输入后,建筑之术,较之昔日,可谓大有进步了;所用的材料亦不同,这确是文明进步之赐。惟住居与衣食,关系民生,同样重要。处处须顾及大多数人的安适,而不容少数人恃其财力,任意横行,和别种事情,也是一样的。古代的居民,本来有一定的规划。《王制》所谓“司空执度以度地,居民山川沮泽(看地形),时四时(看气候)。”即其遗制。其大要,在于“地、邑、民居,必参相得”。地就是田。有多少田,要多少人种,就建筑多少人守卫所要的城邑,和居住所需的房屋。据此看来,现在大都市中的拥挤,就是一件无法度而不该放任的事情了。宫室的等级和限制,历代都是有的(可参看《明史·舆服志》所载宫室制度)。依等级而设限制,现在虽不容仿效,然限制还是该有的。对外的观瞻,也并不系于建筑的侈俭。若因外使来游,而拆毁贫民住居的房子,这种行为,就要成为隋炀帝第二了。\n讲宫室既毕,请再略讲室中的器用。室中的器用,最紧要的,就是桌椅床榻等。这亦是所以供人居处,与宫室同其功的。古人都席地而坐。其坐,略似今日的跪,不过腰不伸直。腰伸直便是跪,顿下便是坐。所以古礼跪而行之之时颇多。因为较直立反觉便利。其凭藉则用几,据阮谌《礼图》,长五尺,广一尺,高一尺二寸(《礼记·曾子问》《疏》引)。较现在的凳还低,寝则有床。所以《诗经》说:“乃生男子,载寝之床。”后来坐亦用床。所以《高士传》说:管宁居辽东,坐一木榻,五十余年,未尝箕股,其榻当膝处皆穿(《三国魏志》本传《注》引)。观此,知其坐亦是跪坐。现在的垂足而坐,是胡人之习,从西域输入的。所坐的床,亦谓之胡床。从胡床输入后,桌椅等物,就渐渐兴起了。古人室中,亦生火以取暖。《汉书·食货志》说:“冬民既入,妇人同巷相从夜绩。”“必相从者,所以省费燎火。”颜师古说:“燎所以为明,火所以为温也。”这种火,大约是煴火,是贫民之家的样子。《左传》昭公十年说,宋元公(为太子时),恶寺人柳,欲杀之。到元公的父亲死了,元公继位为君,柳伺候元公将到之处,先炽炭于位,将至则去之,到葬时,又有宠。又定公三年,说邾子自投于床,废于炉炭(《注》“废,堕也”),遂卒。则贵族室中取暖皆用炭,从没有用炕的。《日知录》说:“《旧唐书·东夷高丽传》:冬月皆作长坑,下然煴火以取暖,此即今之土炕也,但作坑字。”则此俗源于东北夷。大约随女真输入中国北方的,实不合于卫生。\n论居处及所用的器物既竟,还要略论历代的葬埋。古代的葬有两种:孟子所谓“其亲死,则举而委之于壑。”(《滕文公上篇》)盖田猎之民所行。《易经·系辞传》说:“古之葬者,厚衣之以薪,葬之中野”,则农耕之民之俗。一个贵族,有其公共的葬地。一个都邑,亦有其指定卜葬的区域。《周官》冢人掌公墓之地,墓大夫掌凡邦墓之地域是其制。后世的人说:古人重神不重形。其理由:是古不墓祭。然孟子说齐有东郭墦间之祭者(《离娄下篇》),即是墓祭。又说孔子死后,子贡“筑室于场,独居三年然后归”(《滕文公上篇》),此即后世之庐墓。《礼记·曲礼》:“大夫士去其国,止之曰:奈何去坟墓也?”《檀弓》:“去国则哭于墓而后行,反其国不哭,展墓而入。”又说:“大公封于营丘,比及五世,皆反葬于周。”则古人视坟墓,实不为不重。大概知识程度愈低,则愈相信虚无之事。愈高,则愈必耳闻目见,而后肯信。所以随着社会的开化,对于灵魂的迷信,日益动摇,对于体魄的重视,却日益加甚。《檀弓》说:“延陵季子适齐。比其反也,其长子死,葬于嬴博之间。”“既封,左袒,右还其封,且号者三,曰:骨肉归复于土,命也。若魂气,则无不之也,无不之也,而遂行。”这很足以表示重视精神,轻视体魄的见解,怕反是吴国开化较晚,才如此的。如此,富贵之家,有权力的,遂尽力于厚葬。厚葬之意,不徒爱护死者,又包含着一种夸耀生人的心思,而发掘坟墓之事,亦即随之而起。读《吕览·节丧》、《安死》两篇可知。当时墨家主张薄葬,儒家反对他,然儒家的葬礼,较之流俗,亦止可谓之薄葬了。学者的主张,到底不能挽回流俗的波靡。自汉以后,厚葬之事,还书不胜书。且将死者的葬埋,牵涉到生人的祸福,而有所谓风水的迷信。死者无终极(汉刘向《谏成帝起昌陵疏》语),人人要保存其棺槨,至于无穷,其势是决不能行的。佛教东来,火葬之俗,曾一时盛行(见《日知录·火葬》条),实在最为合理。惜乎宋以后,受理学的反对,又渐渐的式微了。现在有一部分地方,设立公墓。又有提倡深葬的。然公墓究仍不免占地,深葬费人力过多,似仍不如火葬之为得。不过风俗是守旧的,断非一时所能改变罢了。\n交通、通信,向来不加区别。其实二者是有区别的。交通是所以运输人身,通信则所以运输人的意思。自有通信的方法,而后人的意思,可以离其身体而独行,于精力和物力方面,有很大的节省。又自电报发明后,意思的传达,可以速于人身的运输,于时间方面,节省尤大。\n交通的发达,是要看地势的。水陆是其大别。水路之中,河川和海道不同。海道之中,沿海和远洋的航行,又有区别。即陆地,亦因其为山地、平地、沙漠等而有不同。野蛮时代,各部族之间,往往互相猜忌,不但不求交通的便利,反而有意阻塞交通,其时各部族所居之地,大概是颇险峻的。对外的通路,只有曲折崎岖的小路,异部族的人,很难于发现使用。《庄子·马蹄篇》说:古代“山无徯隧,泽无舟梁”。所指的,便是这时代。到人智稍进,能够降丘宅土,交通的情形,就渐和往昔不同了。\n中国的文化,是导源于东南,而发达于西北的。东南多水,所以水路的交通,南方较北方为发达。西北多陆,所以陆路的交通,很早就有可观。陆路交通的发达,主要的是由牛马的使用和车的发明。此二者,都是大可节省人力的。《易经·系辞传》说“服牛乘马,引重致远”,虽不能确定其在何时,然其文承黄帝、尧、舜垂衣裳而天下治之下,可想见黄帝、尧、舜时,车的使用,必已很为普遍了。车有两种:一种是大车,用牛牵曳的,用以运输。一种是小车,即兵车,人行亦乘之,驾以马。用人力推曳的谓之辇。《周官》乡师《注》引《司马法》,说夏时称为余车,共用二十人,殷时称胡奴车,用十人,周时称为辎辇,用十五人。这是供战时运输用的,所以其人甚多。《说文》:“辇,挽车也。从车。”\n训并行,虽不必定是两人,然其人数必不能甚多。这是民间运输用的。贵族在宫中,亦有时乘坐。《周官》巾车,王后的五路,有一种唤做辇车,即其物。此制在后世仍通行。\n道路:在都邑之中,颇为修整。《考工记》:匠人,国中经涂九轨。野涂亦九轨。环涂(环城之道)七轨。《礼记·王制》:“道路,男子由右,妇人由左,车从中央。”俱可见其宽广。古代的路,有一种是路面修得很平的,谓之驰道。非驰道则不能尽平。国中之道,应当都是驰道。野外则不然。古代田间之路,谓之阡陌,与沟洫相辅而行。所以《礼记·月令》《注》说:“古者沟上有路。”沟洫阡陌之制,照《周官》遂人等官所说,是占地颇多的。虽亦要因自然的地势,未必尽合乎准绳,然亦必较为平直。不过书上所说的,是理想中的制度,事实上未必尽能如此。《左传》成公五年,梁山崩,晋侯以传召伯宗。行辟重(使载重之车让路)。重人曰:“待我,不如捷之速也。”可见驿路上还不能并行两车。《仪礼·既夕礼》:“商祝执功布,以御柩执披。”《注》云:“道有低仰倾亏,则以布为左右抑扬之节,使引者执披者知之。”《礼记·曲礼》:“送葬不避涂潦”,可见其路之不尽平坦。后人夸称古代的道路,如何宽平,恐未必尽合于事实了。大抵古人修造路面的技术甚拙。其路面,皆如今日的路基,只是土路。所以时时要修治。不修治,就“道茀不可行”。\n水路:初有船的时候,只是现在所谓独木舟。《易经·系辞传》说“刳木为舟,剡木为楫”,《淮南子·说山训》说“古人见窾木而知舟”,所指的都是此物。稍进,乃知集板以为舟。《诗经》说:“就其深矣,方之舟之。”《疏》引《易经》云:“利涉大川,乘木舟虚。”又引《注》云:“舟谓集版,如今船,空大木为之曰虚,总名皆曰舟。”按方、旁、比、并等字,古代同音通用。名舟为方,正因其比木为之之故。此即后世的舫字。能聚集许多木板,以成一舟,其进步就容易了。渡水之法,大抵狭小的水,可以乘其浅落时架桥。桥亦以木为之。即《孟子》所说的“岁十一月徒杠成,十二月舆梁成”(《离娄下篇》)《尔雅·释宫》:“石杠谓之倚。”又说:“堤谓之梁。”《注》云:“即桥也。或曰:石绝水者为梁,见《诗传》。”则后来亦用石了。较阔的水,则接连了许多船渡过去。此即《尔雅》所说的“天子造舟”,后世谓之浮桥。亦有用船渡过去的,则《诗经》所说的“谁谓河广,一苇杭之”。然徒涉的仍不少。观《礼记·祭义》、谓孝子“道而不径,舟而不游”可见。航行的技术,南方是胜于北方的。观《左传》所载,北方只有僖公十三年,晋饥,乞粜于秦,秦输之粟,自雍及绛相继,命之曰泛舟之役,为自水路运输,此外泛舟之事极少。南方则吴楚屡有水战,而哀公十年,吴徐承且率舟师自海道伐齐。可见不但内河,就沿海交通,亦已经开始了。《禹贡》九州贡路,都有水道。《禹贡》当是战国时书,可以窥见当时交通的状况。\n从平地发展到山地,这是陆地交通的一个进步,可以骑马的发达为征。古书不甚见骑马之事。后人因谓古人不骑马,只用以驾车。《左传》昭公二十五年,“左师展将以公乘马而归。”《疏》引刘炫说,以为是骑马之渐。这是错误的。古书所以不甚见骑马,(一)因其所载多贵族之事,贵族多是乘车的。(二)则因其时的交通,仅及于平地。《日知录》说:“春秋之世,戎狄杂居中夏者,大抵在山谷之间,兵车之所不至。齐桓、晋文,仅攘而却之,不能深入其地者,用车故也。中行穆子之败狄于大卤,得之毁车崇卒。而智伯欲伐仇犹,遗之大钟以开其道,其不利于车可知矣。势不得不变而为骑。骑射,所以便山谷也。胡服,所以便骑射也。”此虽论兵事、交通的情形,亦可以借鉴而明。总而言之,交通所至之地愈广,而道路大抵失修,用车自不如乘马之便。骑乘的事,就日盛一日了。\n“水性使人通,山性使人塞。”水性是流动的,虽然能阻碍人,使其不得过去,你只要能利用它,它却可以帮你活动,节省你的劳力。山却不然,会多费你的抵抗力的。所以到后世,水路的交通,远较陆路交通为发达。长江流域的文明,本落黄河流域之后,后来却反超过其上,即由于此。唐朝的刘晏说:“天下诸津,舟航所聚,旁通巴汉,前指闽越,七泽十薮,三江五湖,控引河洛,兼包淮海,弘舸巨舰,千轴万艘,交贸往来,昧旦永日。”可以见其盛况了。《唐语林补遗》说:“凡东南都邑,无不通水。故天下货利,舟楫居多。舟船之盛,尽于江西。编蒲为帆,大者八十余幅。江湖语曰:水不载万。言大船不过八九千石。”明朝郑和航海的船,长四十四丈,宽十八丈,共有六十二只。可以见其规模的弘大了。\n因为水路交通利益之大,所以历代开凿的运河极多,长在一千里以下的运河,几乎数不着它。中国的大川,都是自西向东的,南北的水路交通,很觉得不便。大运河的开凿,多以弥补这缺憾为目的。《左传》哀公九年,“吴城邗,沟通江淮”。此即今日的淮南运河。《史记·河渠书》说:“荥阳下引河东南为鸿沟,以通宋、郑、陈、蔡、曹、卫,与济、汝、淮、泗会。”鸿沟的遗迹,虽不可悉考,然其性质,则极似现在的贾鲁河,乃是所以沟通河淮两流域的。至后汉明帝时:则有从荥阳通至千乘的汴渠。此因当时的富力,多在山东,所以急图东方运输的便利。南北朝以后,富力集中于江淮,则运输之路亦一变。隋开通济渠,自东都引谷洛二水入河。又自河入汴,自汴入淮,以接淮南的邗沟。自江以南,则自京口达余杭,开江南河八百里。此即今日的江南运河。唐朝江淮漕转;二月发扬州。四月,自淮入汴。六、七月到河口,八九月入洛。自此以往,因有三门之险,乃陆运以入于渭。宋朝建都汴京,有东西南北四河。东河通江淮(亦称里河),西河通怀、孟。南河通颍、寿(亦称外河。现在的惠民河,是其遗迹),北河通曹、濮。四河之中,东河之利最大。淮南、浙东西、荆湖南北之货,都自此入汴京。岭表的金银香药,亦陆运至虔州入江。陕西的货,有从西河入汴的,亦有出剑门,和四川的货,同至江陵入江的。宋史说东河所通,三分天下有其二,虽是靠江淮等自然的动脉,运河连接之功,亦不可没的。元朝建都北平,交通之目的又异。乃引汶水分流南北,而现在的大运河告成。\n海路的交通,已略见第四十七章。唐咸通时,用兵交阯,湖南、江西,运输甚苦,润州人陈磻石创议海运。从扬子江经闽、广到交阯。大船一艘,可运千石。军需赖以无缺。是为国家由海道运粮之始。元、明、清三代,虽有运河,仍与海运并行。海运所费,且较河运为省。近代轮船未行以前,南北海道的运输,亦是很盛的。就到现在,南如宁波,北如营口,帆船来往的仍甚多。\n水道的交通,虽极发达,陆路的交通,却是颇为腐败的。《日知录》说当时的情形:“涂潦遍于郊关,污秽钟于辇毂。”(《街道》条)又说:“古者列树以表道。”“下至隋唐之代,而官槐官柳,亦多见之诗篇。”“近代政废法弛,任人斫伐。周道如砥,若彼濯濯”(《官树》条)。“《唐六典》:凡天下造舟之梁四,石柱之梁四,木柱之梁三,巨梁十有一,皆国工修之。其余皆所管州县,随时营葺。其大津无梁,皆给船人,量其大小难易,以定其差等。今畿甸荒芜,桥梁废坏。雄莫之间,秋水时至,年年陷境。曳轮招舟,无赖之徒,藉以为利。潞河舟子,勒索客钱,至烦章劾。司空不修,长吏不问,亦已久矣。(《原注》:“成化八年,九月,丙申,顺天府府尹李裕言:本府津渡之处,每岁水涨,及天气寒冱,官司修造渡船,以便往来。近为无赖之徒,冒贵戚名色,私造渡船,勒取往来人财物,深为民害。乞敕巡按御史,严为禁止。从之。”)况于边陲之境,能望如赵充国治湟陿以西道桥七十所,令可至鲜水,从枕席上过师哉?”(《桥梁》条)观此,知路政之不修,亦以宋以后为甚。其原因,实与建筑之颓败相同。前清末年,才把北京道路,加以修理。前此是与顾氏所谓“涂潦遍于郊关,污秽钟于辇毂”,如出一辙的。全国除新开的商埠外,街道比较整齐宽阔的,没有几处。南方多走水道,北方旱路较多,亦无不崎岖倾仄。间有石路,亦多年久失修。路政之坏,无怪全国富庶之区,都偏于沿江沿海了。\n因路政之坏,交通乃不能利用动物之力而多用人力。《史记·夏本纪》:“山行乘车”,《河渠书》作“山行即桥”。按禹乘四载,又见《吕览·慎势》,《淮南·齐俗训》、《修务训》,《汉书·沟洫志》。又《史记集解》引《尸子》及徐广说,所作字皆互异。山行车与桥外,又作梮,作蔂,作樏,作欙,蔂、樏、欙,系一字,显而易见。梮字见《玉篇》,云“舆,食器也。又土轝也。”雷浚《说文外编》云:“土轝之字,《左传》作梮(按见襄公九年)。《汉书·五行志》引作车,《说文》:“车,大车驾马也。”按《孟子》:“反蘽梩而掩之。”《赵注》云:“梩,笼臿之属,可以取土者也。”蔂、樏、欙并即虆梩,与梮并为取土之器,驾马则称为车,亦以音借而作桥。后又为之专造轿字,则即淮南王《谏伐闽越书》所谓“舆轿而逾岭”。其物本亦车属,后因用诸山行,乃以人舁之。所以韦昭说:“梮木器,如今舆状,人举以行。”此物在古代只用诸山行,后乃渐用之平地。王安石终身不乘肩舆,可见北宋时用者尚少,南渡以后,临安街道,日益狭窄,乘坐的人,就渐渐的多了(《明史·舆服志》:宋中兴以后,以征伐道路险阻,诏百官乘轿,名曰竹轿子,亦曰竹舆)。\n行旅之人不论在路途上,以及到达地头之后,均须有歇宿之所。古代交通未盛,其事率由官营。《周官》野庐氏,“比国郊及野之道路,宿息,井树。”遗人,“凡国野之道:十里有庐,庐有饮食。三十里有宿,宿有路室,路室有委。五十里有市,市有候馆,候馆有积。”都是所以供给行旅的。到达之后,则“卿馆于大夫,大夫馆于士,士馆于工商”(《仪礼·觐礼》)。此即《礼记·曾子问》所谓“卿大夫之家曰私馆”。另有“公宫与公所为”,谓之公馆。当时的农民,大概无甚往来,所以只有卿士大夫和工商之家,从事于招待,但到后来,农民出外的也多了。新旅客的增加,必非旧式的招待所能普遍应付,就有借此以图利的,是为逆旅。《商君书·垦令篇》说:“废逆旅,则奸伪躁心私交疑农之民不行。逆旅之民,无所于食,则必农。”这只是陈旧的见解。《晋书·潘岳传》说,当时的人,以逆旅逐末废农,奸淫亡命之人,多所依凑。要把它废掉。十里置一官,使老弱贫户守之。差吏掌主,依客舍之例收钱。以逆旅为逐末废农,就是商君的见解。《左传》僖公二年,晋人假道于虞以伐虢,说“虢为不道,保于逆旅,以侵敝邑之南鄙”。可见晋初的人,说逆旅使奸淫亡命,多所依凑,也是有的。但以大体言之,则逆旅之设,实所以供商贾之用,乃是随商业之盛而兴起的。看潘岳的驳议,便可明白。无法废绝商业,就无法废除逆旅。若要改为官办,畀差主之吏以管理之权,一定要弊余于利的。潘岳之言,亦极有理。总而言之:(一)交通既已兴盛,必然无法遏绝,且亦不宜遏绝。(二)官吏经营事业,其秩序必尚不如私人。两句话,就足以说明逆旅兴起的原因了。汉代的亭,还是行人歇宿之所。甚至有因一时没有住屋,而借居其中的(见《汉书·息夫躬传》)。魏晋以后,私人所营的逆旅,日益兴盛,此等官家的事业,就愈益废坠,而浸至于灭绝了。\n接力赛跑,一定较诸独走长途,所至者要远些,此为邮驿设置的原理。《说文》:“邮,境上行书舍也。”是专以通信为职的。驿则所以供人物之往来。二者设置均甚早。人物的往来,并不真要靠驿站。官家的通信,却非有驿站不可。在邮政电报开办以前,官家公文的传递,实利赖之。其设置遍于全国。元代疆域广大,藩封之地,亦均设立,以与大汗直辖之地相连接。规模不可谓不大。惜乎历代邮驿之用,都止于投递公文,未能推广之以成今日的邮政。民间寄书,除遣专使外,就须展转托人,极为不便。到清代,人民乃有自营的信局。其事起于宁波,逐渐推广,几于遍及全国。而且推及南洋。其经营的能力,亦不可谓之不伟大了。\n铁路、轮船、摩托车、有线、无线电报的发明,诚足使交通、通信焕然改观。这个,诚然是文明进步之赐,然亦看用之如何。此等文明利器,能用以开发世界上尚未开发的地方,诚足为人类造福。若只在现社会机构之下,为私人所有,用以为其图利的手段,则其为祸为福,诚未易断言,现代的物质文明,有人歌诵他,有人咒诅它。其实物质文明的本身,是不会构祸的,就看我们用之是否得当。中国现在的开发西南、西北,在历史上,将会成为一大事。交通起于陆地,进及河川、沿海,再进及于大洋,回过来再到陆地,这是世界开发必然的程序。世界上最大最未开发的地方,就是亚洲的中央高原,其中又分为两区:(一)为蒙古、新疆等沙漠地带,(二)为西康、青海、西藏等高原。中国现在,开发西南、西北,就是触着这两大块未开辟之地。我们现在还不觉得,将来,这两件事的成功,会使世界焕然改观,成为另一个局面。\n第五十一章 教育 # 现在所谓教育,其意义,颇近乎从前所谓习。习是人处在环境中,于不知不觉之间,受其影响,不得不与之俱化的。所谓入芝兰之室,久而不闻其香;居鲍鱼之肆,久而不知其臭。所以古人教学者,必须慎其所习。孟母教子,要三次迁居,古训多重亲师取友,均系此意。因此,现代所谓教育,要替学者另行布置出一个环境来。此自非古人所及。古人所谓教,只是效法的意思。教人以当循之道谓之攴学;受教于人而效法之,则谓之学,略与现在狭义的教育相当。人的应付环境,不是靠生来的本能,而是靠相传的文化。所以必须将前人之所知所能,传给后人。其机关,一为人类所附属的团体,即社团或家庭,一为社会中专司保存智识的部分,即教会。\n读史的人,多说欧洲的教育学术,和宗教的关系深,中国的教育学术和宗教的关系浅。这话诚然不错。但只是后世如此。在古代,中国的教育学术和宗教的关系,也未尝不密切。这是因为司高等教育的,必为社会上保存智识的一部分,此一部分智识,即所谓学术,而古代的学术,总是和宗教有密切关系的缘故。古代的太学,名为辟雍,与明堂即系同物(见第四十三、第五十两章)。所以所谓太学,即系王宫的一部分。蔡邕《明堂论》引《易传》说:“太子旦入东学,昼入南学,暮入西学。在中央曰太学,天子之所自学也。”(脱北学一句)又引《礼记·保傅篇》说:“帝入东学,上亲而贵仁。入西学,上贤而贵德。入南学,上齿而贵信。入北学,上贵而尊爵。入太学,承师而问道。”所指的,都是此种王宫中的太学。后来文化进步,一切机关,都从王宫中分析出来,于是明堂之外,别有所谓太学。此即《礼记·王制》所说的“太学在郊”。《王制》又说“小学在公宫南之左”。按小学亦是从王宫中分化出来的。古代门旁边的屋子唤做塾。《礼记·学记》说:“古之教者家有塾。”可见贵族之家,子弟是居于门侧的。《周官》教国子的有师氏、保氏。师氏居虎门之左,保氏守王闱。蔡邕说南门称门,西门称闱。汉武帝时,公玉带上《明堂图》,水环宫垣,上有楼,从西南入(见第五十章)。可见古代的明堂,只西南两面有门,子弟即居于此(子弟居于门侧,似由最初使壮者任守卫之故)。后来师氏、保氏之居门闱,小学之在公宫南之左,地位方向,还是从古相沿下来的。师氏所教的为三德(一曰至德,以为道本。二曰敏德,以为行本。三曰孝德,以知逆恶。按至德,大概是古代宗教哲学上的训条,孝德是社会政治上的伦理训条)、三行(一曰孝行,以亲父母。二曰友行,以尊贤良。三曰顺行,以事师长),保氏所教的为六艺(一曰五礼,二曰六乐,三曰五射,四曰五御,五曰六书,六曰九数)、六仪(一曰祭祀之容,二曰宾客之容,三曰朝廷之容,四曰丧纪之容,五曰军旅之容,六曰车马之容),这是古代贵族所受的小学教育。至于太学,则《王制》说“春秋教以礼乐,冬夏教以诗书”。此所谓礼乐,自与保氏所教六艺中的礼乐不同,当是宗教中高等的仪式所用。诗即乐的歌辞。书当系教中的古典。古代本没有明确的历史,相沿的传说,都是和宗教夹杂的,印度即系如此。然则此等学校中,除迷信之外,究竟还有什么东西没有呢?有的:(一)为与宗教相混合的哲学。先秦诸子的哲学、见解,大概都自此而出,看第五十三章可明。(二)为涵养德性之地。梁启超是不信宗教的。当他到美洲去时,每逢星期日,却必须到教堂里去坐坐。意思并不是信他的教,而是看他们礼拜的秩序,听其音乐,以安定精神。这就是子夏说“学而优则仕,仕而优则学”之理(《论语·子张篇》)。仕与事相通,仕就是办事。办事有余力,就到学校中去涵养德性,一面涵养德性,一面仍应努力于当办之事,正是德育、智育并行不悖之理。管太学的官,据《王制》是大乐正,据《周官》是大司乐。俞正燮《癸巳类稿》有《君子小人学道是歌义》,说古代乐之外无所谓学,尤可见古代太学的性质。古代乡论秀士,升诸司徒,司徒升之于学,学中的大乐正,再升诸司马,然后授之以官。又诸侯贡士,天子试之于射宫。其容体比于礼,其节比于乐,而中多者,则得与于祭(均见第四十三章)。这两事的根源,是同一的。即人之用舍,皆决之于宗教之府。“出征执有罪,反释奠于学”(《礼记·王制》),这是最不可解的。为什么明明是用武之事,会牵涉到学校里来呢?可见学校的性质,决不是单纯的教育机关了。然则古代所以尊师重道,太学之礼,虽诏于天子,无北面(《礼记·学记》)。养老之礼,天子要袒而割牲,执酱而馈,执爵而酳(《礼记·乐记》)。亦非徒以其为道之所在,齿德俱尊,而因其人本为教中尊宿之故。凡此,均可见古代的太学和宗教关系的密切。贵族的小学教育,出于家庭,平民的小学教育,则仍操诸社团之手。《孟子》说:“夏曰校,殷曰序,周曰庠,学则三代共之。”学指太学言,校、序、庠都是民间的小学。第四十一章所述:平民住居之地,在其中间立一个校室,十月里农功完了,公推有年纪的人,在这里教育未成年的人,就是校的制度。所以《孟子》说“校者教也”。又说“序者射也,庠者养也”,这是行乡射和乡饮酒礼之地。孔子说:“君子无所争,必也射乎?揖让而升,下而饮,其争也君子。”(《论语·八佾篇》)。又说:一看乡饮酒礼,便知道明贵贱,辨隆杀,和乐而不流,弟长而无遗,安燕而不乱等道理。所以说:“吾观于乡,而知王道之易易也。”(《礼记·乡饮酒义》)然则庠序都是行礼之地,使人民看了,受其感化的。正和现在开一个运动会,使人看了,知道武勇、刚毅、仁侠、秩序等的精神,是一样的用意。行礼必作乐,古人称礼乐可以化民,其道即由于此。并非是后世的王礼,天子和百官,行之于庙堂之上,而百姓不闻不见的。汉朝人所谓庠序,还系如此。与现在所谓学校,偏重智识传授的,大不相同。古代平民的教育,是偏重于道德的。所以兴学必在生计问题既解决之后。孟子说庠序之制,必与制民之产并言(见《梁惠王·滕文公上篇》)。《王制》亦说:“食节事时,民咸安其居,乐事劝功,尊君亲上,然后兴学。”生计问题既解决之后,教化问题,却系属必要。所以又说:“饱食暖衣,逸居而无教,则近于禽兽。”(《孟子·滕文公上篇》)。又说:“君子如欲化民成俗,其必由学乎?”(《学记》)\n以上是古代社会,把其传统的所谓做人的道理,传给后辈的途径(贵族有贵族立身的方法,平民有平民立身的方法,其方法虽不同,其为立身之道则一)。至于实际的智识技能,则得之必由于实习。实习即在办理其事的机关里,古称为宦。《礼记·曲礼》说“宦学事师”,《疏》引熊氏云:“宦谓学仕官之事。”官就是机关,仕官,就是在机关里办事。学仕官之事,就是学习在机关里所办的事。这种学习,是即在该机关中行之的,和现在各机关里的实习生一般。《史记·秦始皇本纪》:昌平君发卒攻嫪毐,战咸阳,斩首数百,皆拜爵。及宦者皆在战中,亦拜爵一级。《吕不韦列传》:请客求宦为嫪毐舍人千余人。《汉书·惠帝纪》:即位后,爵五大夫,吏六百石以上,及宦皇帝而知名者,有罪当盗械者,皆颂系。此所谓宦,即系学仕于其家。因为古代卿大夫及皇太子之家,都系一个机关。嫪毐之家,食客求宦者至千余人,自然未必有正经的事情可办,亦未必有正经的事情可以学习。正式的机关则不然。九流之学,必出于王官者以此(参看第五十三章)。子路说:“有民人焉,有社稷焉,何必读书,然后为学?”(《论语·先进篇》)。就是主张人只要在机关里实习,不必再到教会所设的学校里,或者私家教授,而其宗旨与教会教育相同的地方去学习(《史记·孔子世家》说,孔子以诗、书、礼、乐教,可见孔子的教育,与古代学校中传统的教育相近)。并不是说不要学习,就可以办事。\n古代的平民教育,有其优点,亦有其劣点。优点是切于人的生活。劣点则但把传统的见解,传授给后生,而不授以较高的智识。如此,平民就只好照着传统的道理做人,而无从再研究其是非了。太学中的宗教哲学,虽然高深,却又去实际太远。所以必须到东周之世,各机关中的才智之士,将其(一)经验所得的智识,及(二)大学中相传的宗教哲学,合而为一,而学术才能开一新纪元。此时的学术,既非传统的见解所能限,亦非复学校及机关所能容,乃一变而为私家之学。求学问的,亦只得拜私人为师。于是教育之权,亦由官家移于私家,乃有先秦诸子聚徒讲学之事。\n社会上新旧两事物冲突,新的大概都是合理的。因为必旧的摇动了,然后新的会发生,而旧的所以要摇动,即由于其不合理。但此理是不易为昔人所承认的,于是有秦始皇和李斯的办法:“士则学习法令辟禁。”“欲学法令,以吏为师。”这是想恢复到政教合一之旧。所以要恢复政教合一,则因他们认为“人善其所私学,以非上之所建立”,是天下所以不治;而当时的人,所以要善私学以非上所建立,全是出于朋党之私,所谓“饰虚言以乱实”(《史记·秦始皇本纪》三十四年),这固然不无相当的理由。然古代社会,矛盾不深刻,政治所代表的,就是社会的公意,自然没有人出来说什么话。后世社会复杂了,各方面的矛盾,渐渐深刻,政治总只代表得一方面,其(一)反对方面,以及(二)虽非站在反对方面,而意在顾全公益的人,总不免有话说。这正是(一)有心求治者所乐闻,(二)即以手段而论,防民之口,甚于防川,亦是秉政者所应希望其宣泄的。而始皇、李斯不知“天下有道,则庶人不议”(《论语·季氏篇》)。误以为庶人不议,则天下有道;至少庶人不议,天下才可以走上有道的路,这就和时势相反了。人的智识,总不免于落后,这也无怪其然。但社会学的公例,是不因人之不知,而加以宽恕的,该失败的总是要失败,而秦遂因之倾覆(秦朝的灭亡,固非儒生所为,然人心之不平,实为其最大原因之一,而儒生亦是其中的一部分)。\n汉朝的设立学校,事在武帝建元五年。此时并未立学校之名,仅为五经博士置弟子。在内由太常择补。在外由县、道、邑的长官,上所属二千石,二千石察其可者,令与所遣上计之吏,同诣京师。这就是公孙弘所说的“因旧官而兴焉”(不另设新机关),但因博士弟子,都有出身,所以传业者浸盛(以上见《史记》、《汉书·儒林传》)。至后汉,则光武帝下车即营建太学。明、章二代,屡次驾幸。顺帝又增修校舍。至其末年,游学诸生,遂至三万余人,为至今未曾再有的盛况。按赵翼《陔余丛考》有一条,说两汉受学者都诣京师,其实亦不尽然。后汉所立,不过十四博士,而《汉书·儒林传》说:“大师众至千余人。”《汉书·儒林传》,不能证明其有后人增窜之迹,则此语至少当在东汉初年。可见民间传业,亦并非不盛。然汉代国家所设立的太学,较后世为盛;事实上比较的是学问的重心;则是不诬的。此因(一)当时社会,学问不如后世的广布,求学的自有走集学问中心地的必要。(二)则利禄使然,参看第四十三章自明。前汉时,博士弟子,虽有出路,究系平流而进。后汉则党人劫持选举,而太学为私党聚集,声气标榜之地。又此时学术在社会上渐占重要地位。功臣、外戚及官吏等,亦多遣子弟入学。于是纨袴子弟,搀杂其中,不能认真研究,而易与政治接近。就成《后汉书·儒林传》所说的:“章句渐疏,多以浮华相尚”了。汉末丧乱,既不能研究学问,而以朋党劫持选举的作用亦渐失。魏文帝所立的太学,遂成学生专为避役而来,博士并无学问可以教授的现状。详见《三国·魏志·王肃传》《注》引《魏略》。\n魏晋以后,学校仅为粉饰升平之具。所谓粉饰升平,并不是学校能积极的替政治上装饰出什么东西来,而是消极的,因为倘使连学校都没有,未免说不过去。所以苟非丧乱之时,总必有所谓学校。至其制度,则历代又略有不同。晋武帝咸宁二年,始立国子学。按今文经说,只有太学。大司乐合国之子弟,是出于《周官》的,是古文经说。两汉的政治制度,大抵是根据今文学说的。东汉之世,古学渐兴,魏晋以后,今文传授的统绪遂绝,所以此时的政治制度,亦渐采用古文学说了。自此以后,元魏国子、太学并置。周只有太学。齐只有国子学。隋时,始令国子学不隶太常,独立为一监。唐有国子学、太学、四门学、律学、书学、算学,都属国子监。后律学改隶详刑,书学改隶兰台,算学改隶秘阁。律学、书学、算学专研一种学问艺术,系专门学校性质。国子学、太学、四门学,则系普通性质。国子学、太学,都只收官吏子弟,只有四门学收一部分庶人,成为阶级性质了。这都是古文学说的流毒(四门学在历史上,有两种性质:有时以为小学。此时则模仿《礼记·王制》之说:王太子、王子、群后的太子、卿大夫元士的適子,都可以直接入学,庶人则须节级而升,因令其先入四门小学。然古代所谓学校,本非研究学问之地。乡论秀士,升诸司徒,司徒升之于学,大乐正再升诸司马,不过是选举的一途。贵族世袭之世,得此已算开明。后世则用人本无等级,学校为研究学问之地,庶人的学问,未必劣于贵族,而令其节级而升,未免不合于理。将庶人及皇亲、国戚、官吏子弟所入的学校分离,那更是造出等级来了)。又有弘文馆属门下省,是专收皇亲的。崇文馆属东宫,是收皇太后、皇后亲属兼及官吏子孙的。总之,学校只是政治上的一个机关,学生只是选举上的一条出路,和学术无甚关系(学校中未必真研究学术,要研究学术,亦不一定要入学)。\n把学校看作提倡学术,或兴起教化之具,其设立,是不能限于京师的。汉武帝时,虽兴起太学,尚未能注意地方。其时只有贤长官如文翁等,在其所治之地,自行提倡(见《汉书·循吏传》)。到元帝令郡国皆设五经百石卒史,才可算中央政府,以命令设立地方学校的权舆。但汉朝人眼光中,所谓庠序,还不是用以提倡学术,而是用以兴起教化的。所以元帝所为,在当时的人看起来,只能算是提倡经学,并不能算是设立地方学校。这个,只要看《汉书·礼乐志》的议论,便可知道。隋唐时,各州县都有学(隋文帝曾尽裁太学、四门学及州、县学,仅留国子生七十人。炀帝时恢复),然只法令如此。在唐时,大概只有一笔释奠之费,以祭孔子。事见《唐书·刘禹锡传》。按明清之世,亦正是如此。所谓府、州、县学,寻常人是不知其为学校,只知其为孔子庙的。所以有人疑惑:“为什么佛寺、道观,都大开了门,任人进去,独有孔子庙却门禁森严?”当变法维新之初,有人想把孔子抬出来,算做中国的教主,以和基督教相抗,还有主张把文庙开放,和教堂一样的。殊不知中国本无所谓孔子庙。孔子乃是学校里所祭的先圣或先师(《礼记·文王世子》:“凡入学,必释奠于先圣、先师。”先圣是发明家。先师是把发明家的学问,流传下来的人。此项风气,在中国流行颇广。凡百事业,都有其所崇奉的人,如药业崇奉神农,木匠崇奉鲁班,都是把他认作先圣,儒家是传孔子之道的,所以把孔子认作先圣,传经的人,认作先师。古文学说既行,认为孔子所传的,只是古圣王之道,尤其直接模范的是周公。周朝集古代治法的大成,而其治法的制定,皆由于周公。所以周公可以看作发明家的代表。于是以周公为先圣,孔子为先师。然孔子为中国所最尊的人,仅仅乎把他看做传述者,不足以餍足宗教心理。于是仍改奉孔子为先圣。自宋学兴起以后,所谓孔子之道者又一变。认为汉唐传经儒生,都不足以代表孔子之学。宋代诸儒,崛起于千载之后,乃能遥接其道统。于是将宋以后的理学家,认为先师。此即所谓从祀。汉至唐传经诸儒,除品行恶劣者外,亦不废黜。是为历代所谓先圣先师者的变迁)。寺庙可以公开,学校是办不到的。现在的学校,从前的书院、义塾,又何尝能大开其门,任人出入呢?然令流俗之人,有此误会,亦可见学校的有名无实了。\n魏晋以后,看重学校的有两个人:一个是王安石,一个是明太祖。王安石的意思,是人才要由国家养成的。科举只是取人才,不是养人才,不能以此为已足。照安石的意思,改革科举,只是暂时的事,论根本,是要归结到学校养士的。所以于太学立三舍之法。即外舍、内舍、上舍,学生依次而升。到升入上舍,则得免发解及礼部试,而特赐之以进士第。哲宗元符二年,令诸州行三舍法。岁贡其上舍生,附于外舍。徽宗遂特建外学,以受诸州贡士。并令太学内的外舍生,亦出居外学。遂令取士悉由学校升贡,其州郡发解及礼部试并停。后虽旋复,然在这一时期中的立法,亦可谓很重视学校了。按(一)凡事都由国家主持,只有国小而社会情形简单的时代,可以办到。国大而社会复杂,根本是不可能的,因为(甲)国家不但不能胜此繁重的职务,(乙)并不能尽知社会的需要。因(甲)则其所办之事,往往有名无实,甚或至于有弊。因(乙)则其所办之事,多不能与社会趋势相应,甚或顽固守旧,阻碍进步。所以积极的事情,根本是不宜于国家办的。现在政治学上,虽亦有此项主张,然其理论漏洞甚多,至多只可用以应急,施诸特殊的事务,断非可以遍行常行的道理。这话太长了,现在不能详论。然可用以批评宋时的学校,总是无疑的。所以当时的学校,根本不会办得好。(二)而况自亡清以前(学堂奖励章程废止以前),国家把学校、科举,都看作登庸官吏之法,入学者和应科举者一样,都是为利禄而来,又何以善其后呢?(其中固有少数才智之士。然亦如昔人论科举的话,“乃人才得科举,非科举得人才。”此等人在学校中,并不能视为学校所养成。)安石变科举法后,感慨道:“本欲变学究为秀才,不料变秀才为学究。”秀才是科举中最高的一科,学究则是最低的。熙宁贡举法所试,较诸旧法,不能不说是有用些。成绩之所以不良,则由学问的好坏,甚而至于可以说是有无,都判之于其真假,真就是有,假就是无。真假不是判之于其所研究的门类、材料,而是判之于其研究的态度、方法的。态度和方法,判之于其有无诚意。所以以利用为目的,以学习为手段,学到的,至多是技术,决不是学问。此其原理,在学校与科举中,并无二致。以得奖励为目的的学校,其结果,只能与科举一样。\n凡国家办的事,往往只能以社会上已通行的,即大众所公认的理论为根据。而这种理论,往往是已经过时的,至少是比较陈旧的。因为不如此,不会为大众所承认。其较新鲜的,方兴的,则其事必在逐渐萌芽,理论必未甚完全,事实亦不会有什么轰轰烈烈的,提给大众看,国家当然无从依据之以办事,所以政治所办理的事情,往往较社会上自然发生的事情为落后。教育事业,亦是如此。学问是不宜于孤独研究的。因为(一)在物质方面,供给不易完全;(二)在精神方面,亦不免孤陋寡闻之诮。所以研究学问的人,自然会结成一种团体。这个团体,就是学校。学校的起源,本是纯洁的,专为研究学问的;惜乎后来变为国家养成人才之所。国家养成人才,原是很好的事;但因(一)事实上,国家所代表的,总是业经通行、已占势力的理论。所以公家所立的学校,其内容,总要比较陈旧些。社会上新兴的,即在前途有真正需要,而并非在过去占有势力的学科,往往不能尽力提倡。(二)而且其本身,总不免因利禄关系而腐化。于是民间有一种研究学问的组织兴起来,这便是所谓书院。书院是起于唐、五代之间的。宋初,有所谓四大书院者,朝廷咸赐之额(曰白鹿,在庐山白鹿洞,为南唐升元中所建。曰石鼓,唐元和中衡州守李宽所建。曰应天,宋真宗时,府民曹诚所建。曰岳麓,宋开宝中,潭州守朱洞所建。此系据《通考》。《玉海》有嵩阳而无石鼓。嵩阳,在登封县大室山下,五代时所建)。此外赐额、赐田、赐书的还很多。但书院并不靠朝廷的奖励和补助。书院之设,大概由(一)有道德学问者所提倡,(二)或为好学者的集合,(三)或则有力者所兴办。他是无所为而为之的,所以能够真正研究学问,而且真能跟着风气走。在理学盛行时代,则为讲学的中心;在考据之学盛行的时代,亦有许多从事于此的书院,即其确证。新旧两势力,最好是能互相调和。以官办的学校,代表较旧的、传统的学术;以私立的学校,代表较新的、方兴的学术,实在是最好的办法。\n●岳麓书院\n●白鹿洞书院\n宋朝国势虽弱,然在文化上,不能说是没有进步的。文化既进步,自然觉得有多设学校的必要。元朝的立法,就受这风气的影响。元朝的国子监,本是蒙古、色目和汉人,都可以进的(蒙古人试法从宽,授官六品;色目人试法稍密,授官七品;汉人试法最密,授官从七品,则系阶级制度)。然在京师,又有蒙古国子学。诸路有蒙古字学。仁宗延祐元年,又立回回国子学,以肄习其文字。诸路、府、州、县皆有学。世祖至元二十八年,又令江南诸路学及各县学内设小学,选老成之士教之。或自愿招师,或自受家学于父兄者,亦从其便。其他先儒过化之地,名贤经行之所,与好事之家,出钱米赡学者,并立为书院。各省设提举二员,以提举学校之事。官家竭力提倡,而仍承认私家教育的重要,不可谓非较进步的立法。此项法令,能否真正实行,固未可知,然在立法上,总是明朝的前驱。\n明朝的学校,立法是很完密的。在昔时把学校看做培植人才(政治上的人才),登庸官吏的机关,而不视为提高文化,普及教育的工具,其立法不过如此而止,其扩充亦只能到这地步了。然而其法并不能实行,这可见法律的拗不过事实。明朝的太学,名为国子监。太祖看国子监,是极重的。所用的监官,都是名儒。规则极严,待诸生甚厚。又创历事之法,使在各机关中实习,曾于一日之间,擢用国子生六十余人为布、按两司官。其时国子诸生,扬历中外者甚众,可谓极看重学校的了。然一再传后,科举积重,学校积轻,举贡的选用,遂远不能与进士比。而自纳粟入监之例开后,且被视为异途。国子生本是从府、州、县学里来的。府、州、县学学生升入国子监的,谓之贡生。有岁贡(按年依定额升入)、选贡(选拔特优的)、恩贡(国家有庆典时,特许学生升入国学,即以当充岁贡者充之,而以其次一名充岁贡)、纳贡(府、州、县学生纳粟出学)之别。举人亦可入监。后又取副榜若干,令其入监读书。府、州、县学,府有教授,州有学正,县有教谕,其副概称训导。学生各有定额。初设的都由学校供给饮食,后来增广学额,则不能然。于是称初设的为廪膳生员,增广的为增广生员。后又推广其额,谓之附学生员。于是新取入学的,概称附学生员。依考试的成绩,递升为增广、廪膳。廪膳生资格深的,即充岁贡。入学和判定成绩的考试,并非由教谕训导举行,而是另行派员主持的。入学之试,初由巡按御史或布按两司及府、州、县官,后特置提督学政,巡历举行(僻远之地,为巡历所不能至者,或仍由巡按御史及分巡道)。学政任期三年。三年之中,考试所属府、州、县学生两次:一次称岁考,是用以判定成绩优劣的。一次称科考,在举行科场之年,择其优者,许应乡试。国子监生,毕业后可以入官的,府、州、县学生,则无所谓毕业。其出路:只有(一)应科举中式,(二)贡入国子监。如其不然,则始终只是一个学生。要到五十岁之后,方许其不应岁试。未满五十而不应岁试(试时亦可请假,但下届须补。清制,缺至三次者,即须斥革),其学籍,是要取消掉的。非府、州、县学生不能应科举,府、州、县学生除贡入太学外,亦非应科举不能得出路,这是实行宋以来学校科举相辅而行的理想的。在当时,确是较进步的立法。然而法律拗不过事实。事实上,国家所设的学校,一定要人来读书,除非(一)学校中真有学问,为在校外所学不到的。(二)法令严切,不真在校学习,即不能得到出路。但当时的学校,即使认真教授,其程度,亦不会超过民间的教育,而况并不教授?既然并不教授,自无从强迫学生在学。于是除国子监在京师首善之地,且沿明初认真办理之余,不能竟不到监,乃斤斤和监官计较“坐监”的日数外,府、州、县学,皆阒无其人,人家仍只目它为文庙。学校的有名无实,一方面,固表现政治的无力,一方面,也表示社会的进步。因为社会进步了,到处都有指导研究的人,供给研究的器,人家自然无庸到官立的学校里来了。我们现在,如其要读中国的旧书,并不一定要进学校。如其要研究新学问,有时非进学校不可,甚至有非到外国去不可的。就因为此种学术,在社会上还未广布。清朝的学制,是和明朝大同的。所不同的,则明朝国子监中的荫生,分为官生、恩生。官生是限以官品的(学生父兄的官品)。恩生则出自特恩,不拘品级。清制分为难荫及恩荫。恩荫即明代的官生。难荫谓父兄殉难的,其条件较恩荫为优。又清制,除恩副岁贡生外,又有优、拔两贡。优贡三岁一行。每一督学使者,岁科两试俱讫后,就教官所举优行生,加以考试,再择优送礼部考试,许其入国子监读书。拔贡十二年举行一次。合岁科两试优等生,钦命大臣,会同督抚复试。送吏部,再应廷试,一、二等录用,三等入监。但入监都是有名无实的。\n以上所述的,大体都是官办的学校,为政治制度的一部分,和选举制度有关。其非官办的,亦或具有学校的性质,如书院是。至于不具学校形式的。则有(一)私人的从师读书,(二)或延师于家教授。其教授的内容,亦分为两种:(一)是以应科举为目的的,可谓士人所受的教育。(二)又一种,但求粗知文义,为农、工、商家所受。前者既不足以语于学问,后者又不切于实用。这是因为从前对于教育,无人研究,不过模模糊糊,蹈常习故而行之而已。至清末,变法以来,才有所谓新式的教育,就是现行的制度。对于文化的关系,人所共知,不烦深论。学校初兴时,还有所谓奖励。大学毕业视进士,大学预科、高等学堂视举人。中等学校以下,分别视贡生及附生等。这还带有政治的性质。民国时代,把奖励章程废去,才全和科举绝缘。\n第五十二章 语文 # 语言文字的发明,是人类的一个大进步。(一)有语言,然后人类能有明晰的概念。(二)有语言,然后这一个人的意思,能够传达给那一个人。而(甲)不须人人自学,(乙)且可将个人的行为,化作团体的行为。单有语言,还嫌其空间太狭,时间太短,于是又有文字,赋语言以形,以扩充其作用。总之,文字语言,是在空间上和时间上,把人类联结为一的。人类是非团结不能进化的,团结的范围愈广,进化愈速,所以言语文字,实为文化进化中极重要的因素。\n以语言表示意思,以文字表示语言,这是在语言文字,发达到一定阶段之后看起来是如此。在语言文字萌芽之始,则并不是如此的。代表意思,多靠身势。其中最重要的是手势。中国文字中的“看”字,义为以手遮目,最能表示身势语的遗迹。与语言同表一种意象的,则有图画。图画简单化,即成象形文字。图画及初期的象形文字,都不是代表语言的。所以象形文字,最初未必有读音。图画更无论了。到后来,事物繁复,身势不够表示,语言乃被迫而增加。语言是可以增加的,(一)图画及象形文字,则不能为无限的增加,且其所能增加之数极为有限;(二)而凡意思皆用语言表示,业已成为习惯,于是又改用文字代表语言。文字既改为代表语言,自可用表示声音之法造成,而不必专于象形,文字就造的多了。\n中国文字的构造,旧有六书之说。即(一)象形,(二)指事,(三)会意,(四)形声,(五)转注,(六)假借。六者之中,第五种为文字增加的一例,第六种为文字减少的一例,只有前四种是造字之法。许慎《说文解字·序》说:“黄帝之史仓颉,见鸟兽蹄迒之迹,知分理之可相别异也,初造书契。”又说:“仓颉之初作书,盖依类象形,故谓之文。其后形声相益,即谓之字。”按许氏说仓颉造字,又说仓颉是黄帝之史,这话是错的。其余的话,则大概不错。字是用文拼成的,所以文在中国文字中,实具有字母的作用(旧说谓之偏旁)。象形、指事、会意、形声四种中,只有象形一种是文,余三种都是字。象形就是画成一种东西的形状,如此字须横看。,《说文》:“象臂胫之形。”按此所画系人的侧面,而又略去其头未画。,上系头,中系两臂,小孩不能自立,故下肢并而为一。,《说文》:“象人形。”按此系人的正面形,而亦略画其头。只有子字是连头画出的。按画人无不画其头之理,画人而不画其头,则已全失图画之意矣。于此,可悟象形文字和图画的区别)等字是。(一)天下的东西,不都有形可画。(二)有形可画的,其形亦往往相类。画得详细了,到足以表示其异点,就图画也不能如此其繁。于是不得不略之又略,至于仅足以略示其意而止。倘使不加说明,看了它的形状,是万不能知其所指的。即或可以猜测,亦必极其模糊。此为象形文字与图画的异点。象形文字所以能脱离图画而独立者以此。然如此,所造的字,决不能多。指事:旧说是指无形可象的事,如人类的动作等。这话是错的。指,就是指定其所在。事物二字,古代通用。指事,就是指示其物之所在。《说文》所举的例,是上下二字。卫恒《四体书势》说“在上为上,在下为下”,其语殊不可解。我们看《周官》保氏《疏》说“人在一上为上,人在一下为下”,才知道《四体书势》,实有脱文。《说文》中所载古文二字,乃系省略之形。其原形当如篆文作。一画的上下系人字,借人在一画之上,或一画之下,以表示上下的意思(这一画,并非一二的一字,只是一个界画。《说文》中此例甚多)。用此法,所造的字,亦不能多。会意的会训合。会意,就是合两个字的意思,以表示一个字的意思。如《说文》所举人言为信,止戈为武之类。此法所造的字,还是不能多的。只有形声字,原则上是用两个偏旁,一个表示意义,一个表示声音。凡是一句话,总自有其意义,亦自有其声音的。如此,造字的人,就不必多费心思,只要就本语的意义,本语的声音,各找一个偏旁来表示它就够了。造的人既容易,看的人也易于了解。而且其意义,反较象形、指事、会意为确实。所以有形声之法,而“文字之用,遂可以至于无穷”。转注:《说文》所举的例,是考老二字。声音相近,意义亦相近。其根源本是一句话,后来分化为两句的。语言的增加,循此例的很多。文字所以代表语言,自亦当跟着语言的分化而分化。这就是昔人的所谓转注(夥多两字,与考老同例)。假借则因语言之用,以声音为主。文字所以代表语言,亦当以声音为主。语文合一之世,文字不是靠眼睛看了明白的,还要读出声音来。耳朵听了(等于听语言),而明白其意义。如此,意义相异之语,只要声音相同,就可用相同的字形来代表它。于是(一)有些字,根本可以不造。(二)有些字,虽造了,仍废弃不用,而代以同音的字。此为文字之所以减少。若无此例,文字将繁至不可胜识了。六书之说,见于许《序》及《汉书·艺文志》(作象形、象事、象意、象声、转注、假借)、《周官》保氏《注》引郑司农之说(作象形、会意、转注、处事、假借、谐声)。昔人误以为造字之法,固属大谬。即以为保氏教国子之法,亦属不然。教学童以文字,只有使之识其形,明其音义,可以应用,断无涉及文字构造之理。以上所举六书之说,当系汉时研究文字学者之说。其说是至汉世才有的。《周官》保氏,教国子以六书,当与《汉书·艺文志》所说太史以六体试学童的六体是一,乃系字的六种写法,正和现在字的有行、草、篆、隶一样(《汉书·艺文志》说:“古者八岁入小学,故《周官》保氏,掌养国子,教之六书。谓象形、象事、象意、象声、转注、假借,造字之本也。汉兴,萧何草律,亦著其法,曰:太史论学童,能讽书九千字以上,乃得为史。又以六体试之。课最者以为尚书、御史、史书、令史。吏民上书,字或不正,辄举劾。六体者,古文、奇字、篆书、隶书、缪篆、虫书,皆所以通知古今文字,摹印章,书幡信也。”“谓象形、象事、象意、象声、转注、假借,造字之本也”十八字,定系后人窜入。惟保氏六书和太史六体是一,所以说亦著其法,若六书与六体是二,这亦字便不可通了)。以六书说中国文字的构造,其实是粗略的(读拙撰《字例略说》可明。商务印书馆本),然大体亦尚可应用。旧时学者的风气,本来是崇古的;一般人又误以六书为仓颉造字的六法。造字是昔时视为神圣事业的,更无人敢于置议。其说遂流传迄今。《荀子·解蔽篇》说:“故好书者众矣,而仓颉独传者,壹也。”可见仓颉只是一个会写字的人。然将长于某事的人,误认作创造其事的人,古人多有此误(如暴辛公善埙,苏成公善篪,《世本·作篇》即云:暴辛公作埙,苏成公作篪,谯周《古史考》已驳其缪。见《诗·何人斯》《疏》)。因此,生出仓颉造字之说。汉代纬书,皆认仓颉为古代的帝皇(见拙撰《中国文字变迁考》第二章,商务印书馆本)。又有一派,因《易经·系辞传》说“上古结绳而治,后世圣人易之以书契”,蒙上“黄帝、尧、舜,垂衣裳而天下治”,认为上古圣人,即是黄帝。司记事者为史官,因以仓颉为黄帝之史。其实二者都是无稽的。还有《尚书》伪孔安国《传序》,以三坟为三皇之书,五典为五帝之典,而以伏羲、神农、黄帝为三皇,就说文字起于伏羲时,那更是无据之谈了。\n文字有形、音、义三方面,都是有变迁的。形的变迁,又有改变其字的构造,和笔画形状之异两种,但除笔画形状之异一种外,其余都非寻常人所知(字之有古音古义,每为寻常人所不知。至于字形构造之变,则新形既行,旧形旋废,人并不知有此字)。所以世俗所谓文字变迁,大概是指笔画形状之异。其大别为篆书、隶书、真书、草书、行书五种。\n一、篆书是古代的文字,流传到秦汉之世的。其文字,大抵刻在简牍之上,所以谓之篆书(篆就是刻的意思)。又因其字体的不同,而分为(甲)古文,(乙)奇字,(丙)大篆,(丁)小篆四种。大篆,又称为籀文。《汉书·艺文志》,小学家有《史籀》十五篇。自注:“周宣王太史作。”《说文解字·序》:“《史籀》者,周时史官教学童书也。”又说:“《仓颉》七章者,秦丞相李斯所作也。《爰历》六章者,车府令赵高所作也。《博学》七章者,太史令胡毋敬所作也。文字多取《史籀篇》,而篆体复颇异,所谓秦篆者也。”然则大篆和小篆,大同小异。现在《说文》所录籀文二百二十余,该就是其相异的。其余则与小篆同。小篆是秦以后通行的字。大篆该是周以前通行的字。至于古文,则该是在大篆以前的。即自古流传的文字,不见于《史籀》十五篇中的。奇字即古文的一部分。所不同者,古文能说得出他字形构造之由,奇字则否。所谓古文,不过如此。《汉书·艺文志》、《景十三王传》、《楚元王传》载刘歆《移让太常博士书》,都说鲁恭王坏孔子宅,在壁中得到许多古文经传。其说本属可疑。因为(一)秦始皇焚书,事在三十四年。自此至秦亡,止有七年。即下距汉惠帝四年除挟书律,亦只有二十三年。孔壁藏书,规模颇大,度非一二人所为。不应其事遂无人知,而有待于鲁恭王从无意中发现。(二)假使果有此事,则在汉时实为一大事。何以仅见于《汉书》中这三处,而他书及《汉书》中这三处以外,绝无人提及其事(凡历史上较重大之事,总和别的事情有关系的,也总有人提及其事,所以其文很易散见于各处)。此三处:《鲁恭王传》,不将坏孔子宅之事,接叙于其好治宫室之下,而别为数语,缀于传末,其为作传时所无有(传成之后,再行加缀于末),显而易见。《移让太常博士》,本系刘歆所说的话。《艺文志》也是以刘歆所做的《七略》为本的。然则这两篇,根本上还是刘歆一个人的话。所以汉代得古文经一事,极为可疑。然自班固以前,还不过说是得古文经;古文经的本子、字句,有些和今文经不同而已,并没有说古文经的字,为当时的人所不识。到王充作《论衡》,其《正说篇》,才说鲁恭王得百篇《尚书》,武帝使使者取视,莫能读者。《尚书·伪孔安国传序》,则称孔壁中字为蝌蚪书。谓蝌蚪书废已久,时人无能知者。孔安国据伏生所传的《尚书》,考论文义(意谓先就伏生所传各篇,认识其字,然后再用此为根据,以读其余诸篇),才能多通得二十五篇。这纯是以意揣度的野言,古人并无此说。凡文字,总是大众合力,于无形中逐渐创造的,亦总是大众于无形之间,将其逐渐改变的。由一人制定文字,颁诸公众,令其照用,古无此事。亦不会两个时代中,有截然的异同,至于不能相识。\n二、篆书是圆笔,隶书是方笔。隶书的初起,因秦时“官狱多事”(《汉志》语。官指普通行政机关,狱指司法机关),“令隶人佐书”(《四体书势》语),故得此名。徒隶是不会写字的人,画在上面就算,所以笔画形状,因此变异了。然这种字写起来,比篆书简便得多,所以一经通行,遂不能废。初写隶书的人是徒隶,自然画在上面就算,不求美观。既经通行,写的人就不仅徒隶了。又渐求其美观。于是变成一种有挑法(亦谓之波磔)的隶书。当时的人,谓之八分书。带有美术性质的字,十之八九都用它。\n三、其实用的字,不求美观的,则仍无挑法,谓之章程书。就是我们现在所用的正书。所以八分书是隶书的新派,无挑法的系隶书的旧派。现在的正书,系承接旧派的,所以现在的正书,昔人皆称为隶书。王羲之,从来没有看见他写一个八分书,或者八分书以前的隶字,而《晋书》本传,却称其善隶书。\n四、正书,亦作真书,其名系对行草而立。草书的初起,其作用,当同于后来的行书,是供起草之用的。《史记·屈原列传》说:楚怀王使原造宪令,草藁未上,上官大夫见而欲夺之。所谓草藁,就是现在所谓起草。草藁是只求自己认得,不给别人看的,其字,自然可以写得将就些。这是大家都这样做的,本不能算创造一种字体,自更说不上是谁所创造。到后来,写的人,不求其疾速,而务求其美观。于是草书的字体,和真书相去渐远。导致只认得真书的人,不能认得草书。于是草书距实用亦渐远。然自张芝以前,总还是一个一个字分开的。到张芝出,乃“或以上字之下,为下字之上”,其字竟至不可认识了。后人称一个一个字分开的为章草,张芝所创的为狂草。\n五、狂草固不可用,即章草亦嫌其去正书稍远。(甲)学的人,几乎在正书之外,又要认识若干草字。(乙)偶然将草稿给人家看,不识草字的人,亦将无从看起(事务繁忙之后,给人家看的东西,未必一定能誊真的)。草书至此,乃全不适于实用。然起草之事,是决不能没有的。于是另有一种字,起而承其乏,此即所谓行书。行书之名,因“正书如立,行书如行”而起。其写法亦有两种:(子)写正书的人,把它写得潦草些,是为真行。(丑)写草书的人,把它写得凝重些,是为行草(见张怀瓘《书议》)。从实用上说,字是不能没有真草两种,而亦不能多于真草两种的。因为看要求其清楚,写要求其捷速;若多于真草两种,那又是浪费了(孟森说)。中国字现在书写之所以烦难,是由于都写真书。所以要都写正书,则由于草书无一定的体式。草书所以无一定的体式,则因字体的变迁,都因美术而起。美术是求其多变化的,所以字体愈写愈纷歧。这是因向来讲究写字的人,多数是有闲阶级;而但求应用的人,则根本无暇讲究写字之故。这亦是社会状况所规定。今后社会进化,使用文字的地方愈多。在实用上,断不能如昔日仅恃潦草的正书。所以制定草体,实为当务之急。有人说:草体离正书太远了,几乎又要认识一种字,不如用行书。这话,从认字方面论,固有相当的理由。但以书写而论,则行书较正书简便得没有多少。现在人所写潦草的正书,已与行书相去无几。若求书写的便利,至少该用行草。在正书中,无论笔画如何繁多的字,在草书里,很少超过五画的。现在求书写的便利,究竟该用行书,还该用草书,实在是一个有待研究的问题。至于简笔字,则是不值得提倡的。这真是徒使字体纷繁,而书写上仍简便得有限(书写的烦难,亦由于笔画形状的工整与流走,不尽由于笔画的多少)。\n中国现在古字可考的,仍以《说文》一书为大宗。此书所载,百分之九十几,系秦汉时通行的篆书。周以前文字极少。周以前的文字,多存于金石刻中(即昔人刻在金石上的文字),但其物不能全真,而后人的解释,亦不能保其没有错误。亡清光绪二十四五年间,河南安阳县北的小屯,发见龟甲、兽骨,其上有的刻有文字。据后人考证,其地即《史记·项羽本纪》所谓殷墟。认其字为殷代文字。现在收藏研究的人甚多。但自民国十七年中央研究院和河南省合作发掘以前所发现之品,伪造者极多(详见《安阳发掘报告书》第一期所载《民国十七年十月试掘安阳小屯报告书》,及《田野考古报告》第一期所载《安阳侯家庄出土之甲骨文字》。又吴县所出《国学论衡》某册所载章炳麟之言,及《制言杂志》第五十期章炳麟《答金祖同论甲骨文第二书》)。所以在中央研究院发掘所得者外,最好不必信据,以昭谨慎。\n古人多造单字,后世则单音语渐变为复音,所增非复单音的字,而是复音的辞。大抵春秋战国之时,为增造新字最多的时代。《论语·卫灵公篇》:子曰:“吾犹及史之阙文也”,“今亡已夫”!这就是说:从前写字的人,遇见写不出的字,还空着去请教人,现在却没有了,都杜造一个字写进去。依我推想起来,孔子这种见解,实未免失之于旧。因为前此所用的文字少,写来写去,总是这几个字。自己不知道,自然可问之他人。现在所用的字多了,口中的语言,向来没有文字代表它的,亦要写在纸上。既向无此字,问之于人何益?自然不得不杜造了。(一)此等新造的字,既彼此各不相谋。(二)就旧字也有(甲)讹,(乙)变。一时文字,遂颇呈纷歧之观。《说文解字·序》说七国之世,“文字异形”,既由于此。然(子)其字虽异,其造字之法仍同;(丑)而旧有习熟的字,亦决不会有改变,大体还是统一的。所以《中庸》又说:“今天下”,“书同文”。《史记·秦始皇本纪》:二十六年,“书同文字”。此即许《序》所说:“秦始皇帝初兼天下,丞相李斯乃奏同之,罢其不与秦文合者。”此项法令,并无效验。《汉书·艺文志》说:闾里书师,合《苍颉》、《爰历》、《博学》三篇,断六十四字以为一章,凡五十五章,并为《苍颉篇》。这似乎是把三书合而为一,大体上把重复之字除去。假定其全无复字,则秦时通行的字,共得三千三百。然此三书都是韵文,除尽复字,实际上怕不易办到,则尚不及此数。而《说文》成于后汉时,所载之字,共得九千九百一十三。其中固有籀文及古文、奇字,然其数实不多,而音义相同之字,则不胜枚举。可见李斯所奏罢的字,实未曾罢,如此下去,文字势必日形纷歧。这是一个很严重的问题。幸得语言从单音变为复音,把这种祸患,自然救止了。用一个字代表一个音,实在是最为简易之法。因为复音辞可以日增,单音字则只有此数。识字是最难的事,过时即不能学的。单音无甚变迁,单字既无甚增加,亦无甚改变。读古书的,研究高深文学的,所通晓的辞类及文法,虽较常人为多,所识的单字,则根本无甚相异。认识了几千个字,就能读自古至今的书,也就能通并时的各种文学,即由于此。所以以一字代表一音,实在是中国文字的一个进化。至此,文字才真正成了语言的代表。这亦是文字进化到相当程度,然后实现的。最初并非如此。《说文》:犙,三岁牛。马八岁。犙从参声,从八声,笔之于书,则有牛马旁,出之于口,与“三八”何异?听的人焉知道是什么话?然则犙决非读作参,决非读作八;犙两字,决非代表参八两个音,而系代表三岁牛,马八岁两句话。两句话只要写两个字,似乎简便了,然以一字代表一音纯一之例破坏,总是弊余于利的。所以宁忍书写之烦,而把此等字淘汰去。这可见自然的进化,总是合理的。新造的氱氮等字,若读一音,则人闻之而不能解,徒使语言与文字分离,若读两音,则把一字代表一音的条例破坏,得不偿失。这实在是退化的举动。所以私智穿凿,总是无益有损的。\n语言可由分歧而至统一,亦可由统一而至分歧。由分歧而至统一,系由各分立的部族,互相同化。由统一而至分歧,则由交通不便,语音逐渐讹变;新发生的事物,各自创造新名;旧事物也有改用新名的。所以(一)语音,(二)词类,都可以逐渐分歧。只有语法,是不容易变化的。中国语言,即在此等状况下统一,亦即在此等状况下分歧。所以语音、词类,各地方互有不同,语法则无问题。在崇古的时代,古训是不能不研究的。研究古训,须读古书。古书自无所谓不统一。古书读得多的人,下笔的时候,自然可即写古语。虽然古语不能尽达现代人的意思,然(一)大体用古语,而又依照古语的法则,增加一二俗语,(二)或者依据古语的法则,创造笔下有而口中无的语言,自亦不至为人所不能解。遂成文字统一,语言分歧的现象。论者多以此自豪。这在中国民族统一上,亦确曾收到相当的效果。然但能统一于纸上,而不能统一于口中,总是不够用的。因为(一)有些地方,到底不能以笔代口。(二)文字的进化,较语言为迟,总感觉其不够用。(三)文字总只有一部分人能通。于是发生(一)语言统一,(二)文言合一的两个问题。\n语言统一,是随着交通的进步而进步的。即(一)各地方的往来频繁。(二)(甲)大都会,(乙)大集团的逐渐发生。用学校教授的方法,收效必小。因为语言是实用之物,要天天在使用,才能够学得成功,成功了不致于忘掉。假使有一个人,生在穷乡僻壤,和非本地的人,永无交接,单用学校教授的形式,教他学国语,是断不会学得好,学好了,亦终于要忘掉的。所以这一个问题,断不能用人为的方法,希望其在短时间之内,有很大的成功。至于言文合一,则干脆的,只要把口中的语言,写在纸上就够了。这在一千年以来,语体文的逐渐流行,逐渐扩大,早已走上了这一条路。但还觉得其不够。而在近来,又发生一个文字难于认识的问题,于是有主张改用拼音字的。而其议论,遂摇动及于中国文字的本身。\n拼音字是将口中的语言,分析之而求其音素,将音素制成字母,再将字母拼成文字的。这种文字,只要识得字母,懂得拼法,识字是极容易的,自然觉得简便。但文字非自己发生,而学自先进民族的,可以用此法造字。文字由自己创造的民族,是决不会用此法的。因为当其有文字之初,尚非以之代表语言,安能分析语言而求其音素?到后来进化了,知道此理,而文字是前后相衔的,不能舍旧而从新,拼音文字,就无从在此等民族中使用了。印度是使用拼音文字的,中国和印度交通后,只采用其法于切音,而卒不能改造文字,即由于此。使用拼音文字于中国,最早的,当推基督教徒。他们鉴于中国字的不易认识,用拉丁字母,拼成中国语,以教贫民,颇有相当的效果。中国人自己提倡的,起于清末的劳乃宣。后来主张此项议论的,亦不乏人。以传统观念论,自不易废弃旧文字。于是由改用拼音字,变为用注音字注旧文字的读音。遂有教育部所颁布的注音符号。然其成效殊鲜。这是由于统一读音,和统一语音,根本是两件事。因语音统一,而影响到读音,至少是语体文的读音,收效或者快些。想靠读音的统一,以影响到语音,其事的可能性怕极少。因为语言是活物,只能用之于口中。写在纸上再去读,无论其文字如何通俗,总是读不成语调的。而语言之所以不同,并非语音规定语调,倒是语调规定语音。申言之:各地方人的语调不同,并非由其所发的一个个音不同,以至积而成句,积而成篇,成为不同的语调。倒是因其语调不同,一个个音,排在一篇一句之内,而其发音不得不如此。所以用教学的方法,传授一种语言,是可能的。用教学的方法,传授读音,希望其积渐而至于统一语言,则根本不会有这回事。果真要用人为的方法,促进语言的统一,只有将一地方的言语,定为标准语,即以这地方的人,作为教授的人,散布于各地方去教授,才可以有相当的效果。教授之时,宜专于语言,不必涉及读书。语言学会了,自会矫正读音,至于某程度。即使用教学的方法,矫正读音,其影响亦不过如是而止,决不会超过的。甚或两个问题,互相牵制,收效转难。注音符号,意欲据全国人所能发的音,制造成一种语言。这在现在,实际上是无此语言的。所以无论什么地方的话,总不能与国语密合。想靠注音符号等工具,及教学的方法,造成一种新语言,是不容易的。所以现在所谓能说国语的人,百分之九十九,总还夹杂土话。既然总不密合,何不拣一种最近于国语的言语,定为标准语,来得痛快些呢?\n至于把中国文字,改成拼音文字,则我以为在现在状况之下,听凭两种文字,同时并行,是最合理的。旧日的人,视新造的拼音文字,为洪水猛兽,以为将要破坏中国的旧文化,因而使中国人丧失其民族性;新的人,以为旧文字是阻碍中国进化的,也视其为洪水猛兽,都是一偏之见。认识单字,与年龄有极大的关系。超过一定年龄,普通的人,都极难学习。即使勉强学习,其程度,也很难相当的。所以中国的旧文字,决不能施之成人。即年龄未长,而受教育时间很短的人,也是难学的。因为几千个单字,到底不能于短时间之内认识。如平民千字识字课等,硬把文字之数减少,也是不适于用的。怀抱旧见解的人,以为新文字一行,即将把旧文化破坏净尽。且将使中国民族,丧失其统一性。殊不知旧文字本只有少数人通晓。兼用拼音字,这少数通晓旧文字的人,总还是有的。使用新文字的人,则本来都是不通旧文字的,他们所濡染的中国文化,本非从文字中得来,何至因此而破坏中国的旧文化,及民族的统一性?就实际情形,平心而论,中国旧文化,或反因此而得新工具,更容易推广,因之使中国的民族性,更易于统一呢?吴敬恒说:“中国的读书人,每拘于下笔千秋的思想,以为一张纸写出字来,即可以传之永久。”于是设想:用新文字写成的东西,亦将像现在的旧书一般,汗牛充栋,留待后人的研究,而中国的文化,就因之丧失统一性了。殊不知这种用新文字写成的东西,都和现在的传单报纸一般,阅过即弃,至于有永久性的著作,则必是受教育程度稍深的人,然后能为,而此种人,大都能识得旧文字。所以依我推想,即使听新旧文字同时并行,也决不会有多少书籍,堆积起来。而且只能学新文字的人,其生活,和文字本来是无缘的。现在虽然勉强教他以几个文字,他亦算勉强学会了几个文字,对于文字的关系,总还是很浅的。怕连供一时之用的宣传品等,还不会有多少呢。何能因此而破坏中国的文化和民族统一性?准此以谈,则知有等人说:中国现在,语言虽不统一,文字却是统一的。若拼音字不限于拼写国语,而许其拼写各地方的方言,将会有碍于中国语言的统一,也是一样的缪见。因为(一)现在文字虽然统一,决不能以此为工具,进而统一语言的。(二)而只能拼写方言的人,亦即不通国语的人,其语言,亦本来不曾统一。至于说一改用拼音文字,识字即会远较今日为易,因之文化即会突飞猛进,也是痴话。生活是最大的教育。除少数学者外,读书对于其人格的关系,是很少的。即使全国的人,都能读相当的书,亦未必其人的见解,就会有多大改变。何况识得几个字的人,还未必都会去读书呢?拼音文字,认识较旧文字为易是事实,其习熟则并无难易之分。习熟者的读书,是一眼望去便知道的,并不是一个个字拼着音去认识,且识且读的。且识且读,拼音文字,是便利得多了。然这只可偶一为之,岂能常常如此?若常常如此,则其烦苦莫甚,还有什么人肯读书?若一望而知,试问Book与书,有何区别?所以拼音文字在现在,只是供一时一地之用的。其最大的作用,亦即在此。既然如此,注音符号、罗马字母等等杂用,也是无妨的。并不值得争论。主张采用罗马字母的人,说如此,我们就可以采用世界各国的语言,扩大我国的语言,这也是痴话。采用外国的语言,与改变中国的文字何涉?中国和印度交通以来,佛教的语言,输入中国的何限?又何尝改用梵文呢?\n语言和中国不同,而采用中国文字的,共有三法:即(一)径用中国文,如朝鲜是。(二)用中国文字的偏旁,自行造字,如辽是。(三)用中国字而别造音符,如日本是。三法中,自以第三法为最便。第二法最为无谓。所以辽人又别有小字,出于回纥,以便应用。大抵文字非出于自造,而取自他族的,自以用拼音之法为便。所以如辽人造大字之法,毕竟不能通行。又文字所以代表语言,必不能强语言以就文字。所以如朝鲜人,所做华文,虽极纯粹,仍必另造谚文以应用(契丹文字,系用隶书之半,增损为之,见《五代史》。此系指契丹大字而言,据《辽史·太祖本纪》,事在神册五年。小字出于回纥,为迭剌所造,见《皇子表》)。\n满、蒙、回、藏四族,都是使用拼音文字的。回文:或说出于犹太,或说出于天主教徒,或说出于大食,未知孰是(见《元史·译文证补》)。藏文出于印度。是唐初吐蕃英主弃宗弄赞,派人到印度去留学,归国后所创制的(见《蒙古源流考》)。蒙古人初用回文,见《元史·塔塔统阿传》。《脱卜察安》(《元秘史》。元朝人最早自己所写的历史)即系用回文所写。后来世祖命八思巴造字,则是根据藏文的。满文系太祖时额尔德尼所造。太宗时,达海又加以圈点(一种符号),又以蒙文为根据。西南诸族,惟倮有文字,却是本于象形字的。于此,可见文字由于自造者,必始象形,借自他族者,必取拼音之理。\n文字的流传,必资印刷。所以文字的为用,必有印刷而后弘,正和语言之为用,必得文字而后大一样。古人文字,要保存永久的,则刻诸金石。此乃以其物之本身供众览,而非用以印刷,只能认为印刷的前身,不能即认为印刷事业。汉代的石经,还系如此。后来就此等金石刻,加以摹拓。摹拓既广,觉得所摹拓之物,不必以之供众览,只须用摹拓出来的东西供览即可。于是其雕刻,专为供印刷起见,就成为印刷术了。既如此,自然不必刻金石,而只要刻木。刻板之事,现在可考的起于隋。陆深《河汾燕闲录》说,隋文帝开皇十三年,敕废像遗经,悉令雕版。其时为民国纪元前1319年,西历593年。《敦煌石室书录》有《大隋永陀罗尼本经》,足见陆说之确。唐代雕本,宋人已没有著录的,惟江陵杨氏,藏有《开元杂报》七页。日本亦有永徽六年(唐高宗年号。民国纪元前1257年,西历655年)《阿毗达磨大毗婆娑论》。后唐明宗长兴三年(民国纪元前980年,西历932年),宰相冯道、李愚,请令判国子监田敏,校正九经,刻板印卖,是为官刻书之始。历二十七年始成(周太祖广顺三年)。宋代又续刻义疏及诸史。书贾因牟利,私人因爱好文艺而刻的亦日多。仁宗庆历中(民国纪元前871至前864年,西历1041至1048年),毕昇又造活字(系用泥制。元王祯始刻木为之。明无锡华氏始用铜。清武英殿活字亦用铜制)。于是印刷事业,突飞猛进,宋以后书籍,传于后世的,其数量,就远非唐以前所可比了(此节据孙毓修《中国雕板源流考》,其详可参考原书,商务印书馆本)。\n第五十三章 学术 # 学术思想,是一个民族的灵魂。看似虚悬无薄,实则前进的方向,全是受其指导。中国是一个学术发达的国家。几千年来,学术分门别类,各致其精。如欲详述之,将数十百万言而不能尽。现在所讲的,只是思想转变的大略,及其和整个文化的关系。依此讲,则中国的学术思想,可分为三大时期:\n一、自上古至汉魏之际。\n二、自佛学输入至亡清。其中又分为(甲)佛学时期,(乙)理学时期。\n三、自西学输入以后。\n现在研究先秦诸子的人,大都偏重于其哲学方面。这个实在是错误的。先秦诸子的学术,有两个来源:其(一)从古代的宗教哲学中,蜕化而出。其(二)从各个专门的官守中,孕育而成。前都偏重玄学方面,后者偏重政治社会方面。《汉书·艺文志》说诸子之学,其原皆出于王官。《淮南要略》说诸子之学,皆出于救时之弊。一个说其因,一个说其缘,都不及古代的哲学。尤可见先秦诸子之学,实以政治社会方面为重,玄学方面为轻。此意,近人中能见得的,只有章炳麟氏。\n从古代宗教中蜕化而出的哲学思想,大致是如此的:(一)因人有男女,鸟有雌雄,兽有牝牡,自然界又有天地日月等现象,而成立阴阳的概念。(二)古代的工业,或者是分做水、火、木、金、土五类的。实际的生活影响于哲学思想,遂分物质为五行。(三)思想进步,觉得五行之说,不甚合理,乃认万物的原质为一个,而名之曰气。(四)至此,遂并觉阴阳二力,还不是宇宙的根源(因为最后的总是唯一的,也只有唯一的能算最后的)。乃再成立一个唯一的概念,是即所谓太极。(五)又知质与力并非二物,于是所谓有无,只是隐显。(六)隐显由于变动,而宇宙的根源,遂成为一种动力。(七)这种动力,是颇为机械的。一发动之后,其方向即不易改变。所以有谨小、慎始诸义。(八)自然之力,是极其伟大的。只有随顺,不能抵抗。所以要法自然。所以贵因。(九)此种动力,其方向是循环的。所以有祸福倚伏之义。所以贵知白守黑,知雄守雌。(十)既然万物的原质,都是一个,而又变化不已,则万物根本只是一物。天地亦万物之一,所以惠施是提倡泛爱,说天地万物一体,而物论可齐(论同伦,类也)。(十一)因万物即是一物,所以就杂多的现象,仍可推出其总根源。所谓“穷理尽性,以至于命”。此等思想,影响于后来,极为深刻。历代的学术家,几乎都奉此为金科玉律。诚然,此等宽廓的说法,不易发现其误缪。而因其立说的宽廓,可以容受多方面的解释,即存其说为弘纲,似亦无妨。但有等错误的观念,业已不能适用的,亦不得不加以改正。如循环之说,古人大约由观察昼夜寒暑等现象得来。此说施诸自然界,虽未必就是,究竟还可应用。若移用于社会科学,就不免误缪了。明明是进化的,如何说是循环。\n先秦诸子,关于政治社会方面的意见,是各有所本的,而其所本亦分新旧。依我看来:(一)农家之所本最旧,这是隆古时代农业部族的思想。(二)道家次之,是游牧好侵略的社会的反动。(三)墨家又次之,所取法的是夏朝。(四)儒家及阴阳家又次之,这是综合自上古至西周的政治经验所发生的思想。(五)法家最新,是按切东周时的政治形势所发生的思想。以上五家,代表整个的时代变化,其关系最大。其余如名家,专讲高深玄远的理论。纵横家,兵家等,只效一节之用。其关系较轻。\n怎说农家是代表最古的思想的呢?这只要看许行的话,便可明白。许行之说有二:(一)君臣并耕,政府毫无威权。(二)物价论量不论质。如非根据于最古最简陋的社会的习俗,决不能有此思想(见《孟子·滕文公上篇》)。\n怎说道家所代表的,是游牧好侵略的社会的反动思想呢?汉人率以黄、老并称。今《列子》虽系伪书,然亦有其所本(此凡伪书皆然,不独《列子》。故伪书既知其伪之后,在相当条件下,其材料仍可利用)。此书《天瑞篇》有《黄帝书》两条,其一同《老子》。又有黄帝之言一条。《力命篇》有《黄帝书》一条,与《老子》亦极相类。《老子》书(一)多系三四言韵语。(二)所用名词,极为特别(如有雌雄牝牡而无男女字)。(三)又全书之义,女权皆优于男权。足证其时代之古。此必自古口耳相传之说,老子著之竹帛的,决非老子所自作。黄帝是个武功彪炳的人,该是一个好侵略的部族的酋长。侵略民族,大抵以过刚而折。如夷羿、殷纣等,都是其适例。所以思想上发生一种反动,要教之以守柔。《老子》书又主张无为。无为二字的意义,每为后人所误解。为训化。《礼记·杂记》,子曰:“张而不弛,文武不能也。弛而不张,文武不为也。”此系就农业立说。言弛而不张,虽文武亦不能使种子变化而成谷物。贾谊《谏放民私铸疏》:“奸钱日多,五谷不为”(今本作“五谷不为多”,多字系后人妄增),正是此义。野蛮部族往往好慕效文明,而其慕效文明,往往牺牲了多数人的幸福((一)因社会的组织,随之变迁。(二)因在上的人,务于淫侈,因此而刻剥其下)。所以有一种反动的思想,劝在上的人,不要领导着在下的人变化。在下的人,“化而欲作”,还该“镇之以无名之朴”。这正和现今人因噎废食,拒绝物质文明一样。\n怎样说墨家所代表的,是夏代的文化呢?《汉书·艺文志》说墨家之学,“茅屋采椽,是以贵俭(古人的礼,往往在文明既进步之后,仍保存简陋的样子,以资纪念。如既有酒,祭祀仍用水,便是其一例。汉武帝时,公玉带上明堂图,其上犹以茅盖,见《史记·封禅书》。可见《汉志》此说之确)。养三老五更,是以兼爱(三老五更,乃他人的父兄)。选士大射,是以尚贤(平民由此进用。参看第四十三章)。宗祀严父,是以右鬼(人死曰鬼)。顺四时而行,是以非命(命有前定之义。顺四时而行,即《月令》所载的政令。据《月令》说:政令有误,如孟春行夏令等,即有灾异。此乃天降之罚。然则天是有意志,随时监视着人的行动,而加以赏罚的。此为墨子天志之说所由来。他家之所谓命,多含前定之义,则近于机械论了)。以孝视天下(视同示)是以上同。”都显见得是明堂中的职守。所以《汉志》说他出于清庙之官(参看第五十一章)。《吕览·当染篇》说:“鲁惠公使宰让请郊庙之礼于天子。天子使史角往,惠公止之。其后在鲁,墨子学焉。”此为墨学出于清庙之官的确证。清庙中能保存较古之学说,于理是可有的。墨家最讲究实用,而《经》、《经说》、《大小取》等篇,讲高深的哲学,为名家所自出的,反在墨家书中,即由于此。但此非墨子所重。墨子的宗旨,主于兼爱。因为兼爱,所以要非攻。又墨子是取法乎夏的,夏时代较早,又值水灾之后,其生活较之殷、周,自然要简朴些,所以墨子的宗旨,在于贵俭。因为贵俭,所以要节用,要节葬,要非乐。又夏时代较早,迷信较深,所以墨子有天志、明鬼之说。要讲天志、明鬼,即不得不非命。墨家所行的,是凶荒札丧的变礼(参看第四十一章)。其所教导的,是沦落的武士(参看第四十章)。其实行的精神,最为丰富。\n怎样说儒家、阴阳家是西周时代所产生的思想呢?荀子说:“父子相传,以持王公,三代虽亡,治法犹存,官人百吏之所以取禄秩也。”(《荣辱篇》)国虽亡而治法犹存,这是极可能的事。然亦必其时代较近,而后所能保存的才多。又必其时的文化,较为发达,然后足为后人所取法。如此,其足供参考的,自然是夏、殷、周三代。所以儒家有通三统之说(封本朝以前两代之后以大国,使之保存其治法,以便与本朝之治,三者轮流更换。《史记·高祖本纪》赞所谓“三王之道若循环”,即是此义)。这正和阴阳家所谓五德终始一样。五德终始有两说:旧说以所克者相代。如秦以周为火德,自己是水德;汉又自以为土德是。前汉末年,改取相生之说。以周为木德,说秦朝是闰位,不承五行之运,而自以为是火德。后来魏朝又自以为是土德)。《汉书·严安传》:载安上书,引邹子之说,说“政教文质者,所以云救也。当时则用,过则舍之,有易则易之。”可见五德终始,乃系用五种治法,更迭交换。邹衍之学,所以要本所已知的历史,推论未知;本所已见的地理,推所未见,正是要博观众事,以求其公例。治法随时变换,不拘一格,不能不说是一种进步的思想。此非在西周以后,前代的治法,保存的已多,不能发生。阴阳家之说,缺佚已甚,其最高的蕲向如何,已无可考。儒家的理想,颇为高远。看第四十一章所述大同小康之说可见。《春秋》三世之义,据乱而作,进于升平,更进于太平,明是要将乱世逆挽到小康,再逆挽到大同。儒家所传的,多是小康之义。大同世之规模,从升平世进至太平世的方法,其详已不可得闻。几千年来,崇信儒家之学的,只认封建完整时代,即小康之世的治法,为最高之境,实堪惋惜。但儒家学术的规模,是大体尚可考见的。它有一种最高的理想,企图见之于人事。这种理想,是有其哲学上的立足点的。如何次第实行,亦定有一大体的方案。儒家之道,具于六经。六经之中,《诗》、《书》、《礼》、《乐》,乃古代大学的旧教科,说已见第五十一章。《易》、《春秋》则为孔门最高之道所在。《易》言原理,《春秋》言具体的方法,两者互相表里,所以说“《易》本隐以之显,《春秋》推见至隐”。儒家此等高义,既已隐晦。其盛行于世,而大有裨益于中国社会的,乃在个人修养部分。(一)在理智方面,其说最高的是中庸。其要,在审察环境的情形,随时随地,定一至当不易的办法。此项至当不易的办法,是随时随地,必有其一,而亦只能有一的,所以贵择之精而守之坚。(二)人之感情,与理智不能无冲突。放纵感情,固然要撞出大祸,抑压感情,也终于要溃决的,所以又有礼乐,以陶冶其感情。(三)无可如何之事,则劝人以安命。在这一点,儒家亦颇有宗教家的精神。(四)其待人之道,则为絜矩(两字见《大学》)。消极的“己所不欲,勿施于人”。积极的则“所求乎子以事父,所求乎臣以事君,所求乎弟以事兄,所求乎朋友先施之”。我们该怎样待人,只要想一想,我们希望他怎样待我即得,这是何等简而赅。怎样糊涂的人,对这话也可以懂得,而圣人行之,亦终身有所不能尽,这真是一个妙谛。至于(五)性善之说,(六)义利之辨,(七)知言养气之功,则孟子发挥,最为透彻,亦于修养之功,有极大关系。儒家之遗害于后世的,在于大同之义不传,所得的多是小康之义。小康之世的社会组织,较后世为专制。后人不知此为一时的组织,而认为天经地义,无可改变,欲强已进步的社会以就之,这等于以杞柳为杯棬,等于削足以适屦,所以引起纠纷,而儒学盛行,遂成为功罪不相掩之局。这只可说是后来的儒家不克负荷,怪不得创始的人。但亦不能一定怪后来的任何人。因儒学是在这种社会之中,逐渐发达的。凡学术,固有变化社会之功,同时亦必受社会的影响,而其本身自起变化。这亦是无可如何的事。\n怎样说法家之学,是按切东周时代的情形立说的呢?这时候,最要紧的,是(一)裁抑贵族,以铲除封建势力。(二)富国强兵,以统一天下。这两个条件,秦国行之,固未能全合乎理想,然在当时,毕竟是最能实行的,所以卒并天下。致秦国于富强的,前有商鞅,后有李斯,都是治法家之学的。法家之学的法字,是个大名。细别起来,则治民者谓之法,裁抑贵族者谓之术,见《韩非子·定法篇》。其富国强兵之策,则最重要的,是一民于农战。《商君书》发挥此理最透,而《管》、《韩》二子中,亦有其理论。法家是最主张审察现实,以定应付的方法的,所以最主张变法而反对守旧。这确是法家的特色。其学说之能最新,大约即得力于此。\n以上所述五家,是先秦诸子中和中国的学术思想及整个的文化,最有关系的。虽亦有其高远的哲学,然其所想解决的,都是人事问题。而人事问题,则以改良社会的组织为其基本。粗读诸子之书,似乎所注重的,都是政治问题。然古代的政治问题,不像后世单以维持秩序为主,而整个的社会问题,亦包括在内。所以古人说政治,亦就是说社会。\n诸家之学,并起争鸣,经过一个相当时期之后,总是要归于统一的。统一的路线有两条:(一)淘汰其无用,而存留其有用的。(二)将诸家之说,融合为一。在战国时,诸家之说皆不行,只有法家之说,秦用之以并天下,已可说是切于时务的兴,而不切于时务的亡了。但时异势殊,则学问的切于实用与否,亦随之而变。天下统一,则需要与民休息,民生安定,则需要兴起教化。这二者,是大家都会感觉到的。秦始皇坑儒时说:“吾前收天下书不中用者尽去之。悉召文学方术士甚众。欲以兴太平。方士欲练,以求奇药。”兴太平指文学士言。可见改正制度,兴起教化,始皇非无此志,不过天下初定,民心未服,不得不从事于镇压;又始皇对外,颇想立起一个开拓和防御的规模来,所以有所未遑罢了。秦灭汉兴,此等积极的愿望,暂时无从说起。最紧要的,是与民休息。所以道家之学,一时甚盛。然道家所谓无为而治,乃为正常的社会说法。社会本来正常的,所以劝在上的人,不要领导其变化;且须镇压之,使不变化,这在事实上虽不可能,在理论上亦未必尽是,然尚能自成一说。若汉时,则其社会久已变坏,一味因循,必且迁流更甚。所以改正制度,兴起教化,在当时,是无人不以为急务的。看贾谊、董仲舒的议论,便可明白。文帝亦曾听公孙臣的话,有意于兴作。后因新垣平诈觉,牵连作罢。这自是文帝脑筋的糊涂,作事的因循,不能改变当时的事势。到武帝,儒学遂终于兴起了。儒学的兴起,是有其必然之势的,并非偶然之事。因为改正制度,兴起教化,非儒家莫能为。论者多以为武帝一人之功,这就错了。武帝即位时,年仅十六,虽非昏愚之主,亦未闻其天亶夙成,成童未几,安知儒学为何事?所以与其说汉武帝提倡儒学,倒不如说儒学在当时自有兴盛之势,武帝特顺着潮流而行。\n儒学的兴起,虽由社会情势的要求,然其得政治上的助力,确亦不少。其中最紧要的,便是为五经博士置弟子。所谓“设科射策,劝以官禄”,自然来者就多了。儒学最初起的,是《史记·儒林传》所说的八家:言《诗》:于鲁,自申培公;于齐,自辕固生;于燕,自韩太傅。言《书》,自济南伏生。言《礼》,自鲁高堂生。言《易》,自菑川田生。言《春秋》:于齐、鲁,自胡毋生;于赵,自董仲舒。东汉立十四博士:《诗》齐、鲁、韩。《书》欧阳、大小夏侯。《礼》大小戴。《易》施、孟、梁丘、京。《春秋》严、颜(见《后汉书·儒林传》。《诗》齐鲁韩下衍毛字),大体仍是这八家之学(惟京氏《易》最可疑)。但是在当时,另有一种势力,足以促令学术变更。那便是第四十一章所说:在当时,急须改正的,是社会的经济制度。要改正社会经济制度,必须平均地权,节制资本。而在儒家,是只知道前一义的。后者之说,实在法家。当时儒家之学,业已成为一种权威,欲图改革,自以自托于儒家为便,儒家遂不得不广采异家之学以自助,于是有所谓古文之学。读第四十一章所述,已可明白了。但是学术的本身,亦有促令其自起变化的。那便是由专门而趋于通学。\n先秦学术,自其一方面论,可以说是最精的。因为他各专一门,都有很高的见解。自其又一方面说,亦可以说是最粗的。因为他只知道一门,对于他人的立场,全不了解。譬如墨子所主张,乃凶荒札丧的变礼,本不谓平世当然。而荀子力驳他,说天下治好了,财之不足,不足为患,岂非无的放矢?理论可以信口说,事实上,是办不到只顾一方面的。只顾一方面,一定行不通。所以先秦时已有所谓杂家之学。《汉志》说:杂家者流,出于议官。可见国家的施政,不得不兼顾到各方面了。情势如此,学术自然不得不受其影响,而渐趋于会通,古文之学初兴时,实系兼采异家之说,后来且自立新说,实亦受此趋势所驱使。倘使当时的人,痛痛快快,说儒家旧说,不够用了,我所以要兼采异说;儒家旧说,有所未安,我所以要别立新说,岂不直捷?无如当时的思想和风气,不容如此。于是一方面说儒家之学,别有古书,出于博士所传以外(其中最重要的,便是孔壁一案,参看第五十二章),一方面,自己研究所得,硬说是某某所传(如《毛诗》与《小序》为一家言。《小序》明明是卫宏等所作,而毛公之学,偏要自谓子夏所传),纠纷就来得多了。流俗眩于今古文之名,以为今古文经,文字必大有异同,其实不然。今古文经的异字,备见于《仪礼》郑《注》(从今文处,则出古文于注。从古文处,则出今文于注),如古文位作立,仪作义,义作谊之类,于意义毫无关系。他经度亦不过如此。有何关系之可言?今古文经的异同,实不在经文而在经说。其中重要问题,略见于许慎的《五经异义》。自大体言之:今文家说,都系师师相传。古文家说,则自由研究所得,不为古人的成说所囿,而自出心裁,从事研究,其方法似觉进步。但(一)其成绩并不甚佳。又(二)今文家言,有传讹而无臆造。传讹之说,略有其途径可寻,所以其说易于还原。一经还原,即可见古说的真相(其未曾传讹的,自然更不必说)。古文家言,则各人凭臆为说,其根源无可捉摸。所以把经学当做古史的材料看,亦以今文家言价值较高。\n然古学的流弊,亦可说仍自今学开之。一种学术,当其与名利无关时,治其学者,都系无所为而为之,只求有得于己,不欲炫耀于人,其学自无甚流弊。到成为名利之途则不然。治其学者,往往不知大体,而只斤斤计较于一枝一节之间。甚或理不可通,穿凿立说。或则广罗异说,以自炫其博。引人走入旁门,反致抛荒正义。从研究真理的立场上言,实于学术有害。但流俗的人,偏喜其新奇,以为博学。此等方法,遂成为哗世取宠之资。汉代此等风气,由来甚早。《汉书·夏侯胜传》说:“胜从父子建,师事胜及欧阳高,左右采获。又从五经诸儒问与《尚书》相出入者,牵引以次章句,具文饰说。胜非之曰:建所谓章句小儒,破碎大道。建亦非胜为学疏略,难以应敌。”专以应敌为务,真所谓徇外为人。此种风气既开,遂至专求闻见之博,不顾义理之安;甚且不知有事理。如郑玄,遍注群经,在汉朝,号称最博学的人,而其说经,支离灭裂,于理决不可通,以及自相矛盾之处,就不知凡几。此等风气既盛,治经者遂多变为无脑筋之徒。虽有耳目心思,都用诸琐屑无关大体之处。而于此种学问,所研究的,究属宇宙间何种现象?研究之究有何益?以及究应如何研究?一概无所闻见。学术走入此路,自然只成为有闲阶级,消耗日力精力之资,等于消闲遣兴,于国家民族的前途,了无关系了。此等风气,起于西汉中叶,至东汉而大盛,直至南北朝、隋唐而未改。汉代所谓章句,南北朝时所谓义疏,都系如此。读《后汉书》及《南北史》的《儒林传》,最可见得。古学既继今学而起,到汉末,又有所谓伪古文一派。据近代所考证:王肃为其中最重要的一个人。肃好与郑玄立异,而无以相胜。乃伪造《孔子家语》,将己说窜入其中,以折服异己,经学中最大的《伪古文尚书》一案,虽不能断为即肃之所造,然所谓《伪孔安国传》者,必系与肃同一学派之人所为,则无可疑(《伪古文尚书》及《伪孔安国传》之伪,至清阎若璩作《古人尚书疏证》而其论略定。伪之者为哪一派人,至清丁晏作《尚书余论》而其论略定)。此即由当时风气,专喜广搜证据,只要所搜集者博,其不合理,并无人能发觉,所以容得这一班人作伪。儒学至此,再无西汉学者经世致用的气概。然以当时学术界的形势论,儒学业已如日中天。治国安民之责,在政治上、在社会上,都以为惟儒家足以负之。这一班人,如何当得起这个责任?他们所想出来的方案,无非是泥古而不适于时,专事模仿古人的形式。这个如何足以为治?自然要激起有思想的人的反对了。于是魏晋玄学,乘机而起,成为儒佛之间的一个过渡。\n魏晋玄学,人多指为道家之学。其实不然。玄学乃儒道两家的混合,亦可说是儒学中注重原理的一派,与拘泥事迹的一派相对立。先秦诸子的哲学,都出自古代的宗教哲学,大体无甚异同,说已见前。儒家之书,专谈原理的是《易经》。《易》家亦有言理、言数两派。言理的,和先秦诸子的哲学,无甚异同。言数的,则与古代术数之学相出入。《易》之起源,当和术数相近;孔门言易,则当注重于其哲学,这是通观古代学术的全体,而可信其不诬的。今文《易》说,今已不传。古文《易》说,则无一非术数之谈。《汉书·艺文志》:易家有《淮南·道训》两篇。自注云:“淮南王安,聘明《易》者九人,号九师说。”此书,当即今《淮南子》中的《原道训》。今《淮南子》中,引《易》说的还有几条,都言理而不及数,当系今文《易》说之遗。然则儒家的哲学,原与道家无甚出入。不过因今文《易》说失传,其残存的,都被后人误认为道家之说罢了。如此说来,则魏晋玄学的兴起,并非从儒家转变到道家,只是儒家自己的转变。不过此种转变,和道家很为接近,所以其人多兼采道家之学。观魏晋以后的玄学家,史多称其善《易》、《老》可知。儒学的本体,乃以《易》言原理,《春秋》则据此原理,而施之人事。魏晋的玄学家,则专研原理,而于措之人事的方法,不甚讲求。所以实际上无甚功绩可见,并没有具体可见之施行的方案。然经此运动之后,拘泥古人形式之弊遂除。凡言法古的,都是师其意而不是回复其形式。泥古不通之弊,就除去了,这是他们摧陷廓清莫大的功绩(玄学家最重要的观念,为重道而遗迹。道即原理,迹即事物的形式)。\n从新莽改革失败以后,彻底改变社会的组织,业已无人敢谈。解决人生问题的,遂转而求之个人方面。又玄学家探求原理,进而益上,其机,殊与高深玄远的哲学相近。在这一点上,印度的学术,是超过于中国的。佛学遂在这种情势之下兴起。\n佛,最初系以宗教的资格,输入中国的。但到后来,则宗教之外,别有其学术方面。\n佛教,普通分为大小乘。依后来详细的判教,则小乘之下,尚有人天(专对人天说法,不足以语四圣。见下);大乘之中,又分权实。所谓判教,乃因一切经论(佛所说谓之经,菩萨以下所说谓之论。僧、尼、居士等所应守的规条谓之律。经、律、论谓之三藏),立说显有高低,所以加以区别,说佛说之异,乃因其所对待的人不同而然。则教外的人,不能因此而诋佛教的矛盾,教中的人,亦不必因此而起争辩了。依近来的研究:佛教在印度的兴起,并不在其哲学的高深,而实由其能示人以实行的标准。缘印度地处热带,生活宽裕,其人所究心的,实为宇宙究竟,人生归宿等问题,所以自古以来,哲学思想,即极发达。到佛出世时,各家之说(所谓外道),已极高深,而其派别亦极繁多了。群言淆乱,转使人无所适从。释迦牟尼出,乃截断无谓的辩论,而教人以实行修证的方法。从之者乃觉得所依归,而其精神乃觉安定。故佛非究竟真理的发现者(中国信佛的人,视佛如此),而为时代的圣者。佛灭后百年之内,其说无甚异同,近人称为原始佛教。百年之后而小乘兴,五六百年之后而大乘出,则其说已有改变附益,而非复佛说之旧了。然则佛教的输入中国,所以前后互异,亦因其本身的前后,本有不同,并非在彼业已一时具足,因我们接受的程度,先后不同,而彼乃按其深浅,先后输入的了。此等繁碎的考据,今可勿论。但论其与中国文化的关系。\n佛教把一切有情,分为十等:即(一)佛,(二)菩萨,(三)缘觉,(四)声闻,是为四圣。(五)天,(六)人,(七)阿修罗,(八)畜生,(九)饿鬼,(十)地狱,是为六凡。辗转于六凡之中,不得超出,谓之六道轮回。佛不可学,我们所能学的,至菩萨而止。在小乘中,缘觉、声闻,亦可成佛,大乘则非菩萨不能。所谓菩萨,系念念以利他为主,与我们念念都以自己为本位的,恰恰相反。至佛则并利他之念而亦无之,所以不可学了。缘觉、声闻鉴于人生不能离生、老、病、死诸苦,死后又要入轮回;我们幸得为人,尚可努力修持,一旦堕入他途便难了(畜生、饿鬼、地狱亦称三途,不必论了。阿修罗神通广大,易生嗔怒;诸天福德殊胜,亦因其享受优越,转易堕落,所以以修持论,皆不如人)。所以觉得生死事大,无常迅速,实可畏怖,生前不得不努力修持。其修持之功,固然艰苦卓绝,极可佩服,即其所守戒律,亦复极端利他。然根本观念,终不离乎自利,所以大乘斥其不足成佛。此为大小乘重要的异点。亦即大乘教理,较小乘为进化之处。又所谓佛在小乘,即指释迦牟尼其人。大乘则佛有三身:(一)佛陀其人,谓之报身,是他造了为人之因,所以在这世界上成为一个人的。生理心理等作用,一切和我们一样。不吃也要饿,不着也要冷,置诸传染病的环境中,也会害病;饿了,冷了,病重了,也是会死的。(二)至于有是而无非,威权极大。我们动一善念,动一恶念,他都无不知道。善有善报,恶有恶报,丝毫不得差错。是为佛之法身,实即自然力之象征。(三)一心信佛者,临死或在他种环境中,见有佛来接引拯救等事,是为佛之化身。佛在某种环境中,经历一定的时间,即可修成。所以过去已有无数的佛,将来还有无数的佛要成功,并不限于释迦牟尼一人。大乘的说法,当他宗教信,是很能使人感奋的。从哲学上说,其论亦圆满具足,无可非难。宗教的进化,可谓至斯而极。\n佛教的宇宙观,系以识为世界的根本。有眼、耳、鼻、舌、身、意,即有色、声、香、味、触、法。此为前六识,为人人所知。第七识为末那,第八识为阿赖耶,其义均不能译,故昔人惟译其音。七识之义,为“恒审思量,常执有我”。我们念念以自己为本位(一切现象,都以自己为本位而认识。一切利害,都以自己为本位而打算),即七识之作用。至八识则为第七识之所由生,为一切识的根本。必须将它灭尽,才得斩草除根。但所谓灭识,并不是将他铲除掉,至于空无所有。有无,佛教谓之色空。色空相对,只是凡夫之见。佛说则“色即是空,空即是色”(如在昼间,则昼为色,夜为空。然夜之必至,其确实性,并不减于昼之现存。所以当昼时,夜之现象,虽未实现,夜之原理,业已存在。凡原理存在者,即与其现象存在无异。已过去之事,为现在未来诸现象之因。因果原系一事。所以已过去的事,亦并未消灭)。所以所谓灭识,并非将识消灭,而系“转识成智”。善恶同体。佛说的譬喻,是如水与波。水为善,动而为波即成恶。按照现在科学之理,可以有一个更妙的譬喻,即生理与病理。病非别有其物,只是生理作用的异常。去病得健,亦非把病理作用的本体消灭,只是使他回复到生理作用。所以说“真如无明,同体不离”(真如为本体,无明为恶的起点)。行为的好坏,不是判之于其行为的形式的,而是判之于其用意。所以所争的只在迷悟。迷时所做的事,悟了还是可以做的。不过其用意不同,则形式犹是,而其内容却正相反,一为恶业,一为净业了。喻如母亲管束子女,其形式,有时与厂主管理童工是一样的。所以说:“共行只是人间路,得失谁知霄壤分。”佛教为什么如此重视迷悟呢?因为世界纷扰的起因,不外乎(一)怀挟恶意,(二)虽有善意,而失之愚昧。怀挟恶意的,不必论了。失之愚昧的,虽有善意,然所做的事,无一不错,亦必伏下将来的祸根。而愚昧之因,又皆因眼光只限于局部,而不能扩及全体(兼时间空间言)。所以佛说世俗之善,“如以少水而沃冰山,暂得融解,还增其厚。”此悟之所以重要。佛教的人生问题,依以上所说而得解答。至于你要追问宇宙问题的根源,如空间有无界限,时间有无起讫等,则佛教谓之“戏论”,置诸不答(外道以此为问,佛不答,见《金七十论》)。这因为:我们所认识的世界,完全是错误的。其所以错误,即因我们用现在的认识方法去认识之故。要把现在的认识方法放下,换一种方法去认识,自然不待言而可明。若要就现在的认识方法,替你说明,则非我的不肯说,仍系事之不可能。要怎样才能换用别种认识方法呢?非修到佛地位不可。佛所用的认识方法,是怎样的呢?固非我们所能知。要之是和我们现在所用,大不相同的。这个,我们名之曰证。所以佛教中最后的了义,“惟佛能知”;探求的方法,“惟证相应”。这不是用现在的方法,所能提证据给你看的。信不信只好由你。所以佛教说到最后,总还是一种宗教。\n佛教派别很多,然皆小小异同,现在不必一一论述。其中最有关系的,(一)为天台、惟识、华严三宗。惟识宗亦称相宗,乃就我们所认识的相,阐发万法惟识之义。天台亦称性宗,则系就识的本身,加以阐发。实为一说的两面。华严述菩萨行相,即具体的描写一个菩萨的样子给我们看,使我们照着他做。此三宗,都有很深的教理,谓之教下三家。(二)禅宗则不立文字,直指心源,专靠修证,谓之教外别传。(甲)佛教既不用我们的认识,求最后的解决,而要另换一种认识方法(所谓转识成智),则一切教理上的启发、辩论,都不过把人引上修证之路,均系手段而非目的。所以照佛教发达的趋势,终必至于诸宗皆衰,禅宗独盛为止。(乙)而社会上研究学问的风气,亦是时有转变的。佛教教理的探求,极为烦琐,实与儒家的义疏之学,途径异而性质相同。中唐以后,此等风气,渐渐衰息,诸宗就不得不衰,禅宗就不得不独盛了。(三)然(子)禅宗虽不在教义上为精深的探讨,烦琐的辩论,而所谓禅定,理论上也自有其相当的高深的。(丑)而修习禅定,亦非有优闲生活的人不能。所以仍为有闲阶级所专有。然佛教此时的声势,是非发达到普及各阶层不可的。于是适应大众的净土宗复兴。所谓净土宗,系说我们所住的世界,即娑婆世界的西方,另有一个世界,称为净土。诸佛之中,有一个唤做阿弥陀佛的,与娑婆世界特别有缘。曾发誓愿:有一心皈依他的,到临终之时,阿弥陀佛便来接引他,往生净土。往生净土有什么利益呢?原来成佛极难,而修行的人,不到得成佛,又终不免于退转。如此示人以难,未免使人灰心短气。然(A)成佛之难,(B)以及非成佛则不能不退转,又经前此的教义,说得固定了,无可动摇。于是不得不想出一个补救的方法,说我们所以易于退转,乃因环境不良使然。倘使环境优良,居于其中,徐徐修行,虽成佛依旧艰难,然可保证我们不致中途堕落。这不啻给与我们以成佛的保证,而且替我们祛除了沿路的一切危险、困难,实给意志薄弱的人以一个大安慰、大兴奋。而且净土之中,有种种乐,无种种苦,也不啻给与祈求福报的人以一个满足。所以净土宗之说,实在是把佛教中以前的某种说法取消掉了的。不过其取消之法很巧妙,能使人不觉得其立异罢了。其修持之法,亦变艰难而归简易。其法:为(A)观,(B)想,(C)持名,三者并行,谓之念佛。有一佛像当前,而我们一心注视着他,谓之观。没有时,心中仍想象其有,谓之想。口诵南无阿弥陀佛(自然心也要想着他),谓之持名。佛法贵止观双修。止就是心住于其所应住之处,不起妄念。观有种种方法。如(A)我们最怕死,乃设想尖刀直刺吾胸,血肉淋漓;又人谁不爱女人,乃设想其病时的丑恶,死后的腐朽,及现时外观虽美,而躯壳内种种污秽的情形,以克服我们的情意。(B)又世事因缘复杂,常人非茫无所知,即认识错误,必须仔细观察。如两人争斗,粗观之,似由于人有好斗之性。深观之,则知其实由教化的不善;而教化的不善,又由于生计的窘迫;生计的窘迫,又由于社会组织的不良。如此辗转推求,并无止境。要之观察得愈精细,措施愈不至有误。这是所以增长我们的智识的。止观双修,意义诚极该括,然亦断非愚柔者所能行,净土宗代之以念佛,方法简易,自然可普接利钝了。所以在佛教诸宗皆衰之后,禅宗尚存于上流社会中,净土宗则行于下流社会,到现在还有其遗迹。\n佛教教义的高深,是无可否认的事实。在它,亦有种种治国安民的理论,读《华严经》的五十三参可知。又佛学所争,惟在迷悟。既悟了,一切世俗的事情,仍无有不可做的,所以也不一定要出家。然佛教既视世法皆非了义,则终必至于舍弃世事而后止。以消灭社会为解决社会之法,断非社会所能接受。于是经过相当的期间,而反动又起。\n佛教反动,是为宋学。宋学的渊源,昔人多推诸唐之韩愈。然韩愈辟佛,其说甚粗,与宋学实无多大关系。宋学实至周张出而其说始精,二程继之而后光大,朱陆及王阳明又继之,而其义蕴始尽。\n哲学是不能直接应用的,然万事万物,必有其总根源。总根源变,则对于一切事情的观点,及其应付的方法,俱随之而变。所以风气大转变时,哲学必随之而变更。宋儒的哲学,改变佛学之处安在呢?那就是抹杀认识论不谈,而回到中国古代的宇宙论。中国古代的哲学,是很少谈到认识论的。佛学却不然,所注重的全在乎此。既注重于认识论,而又参以宗教上的悲观,则势必至于视世界为空虚而后止。此为佛教入于消极的真原因。宋学的反佛,其最重要的,就在此点。然从认识论上驳掉佛说,是不可能的。乃将认识论抹杀不谈,说佛教的谈认识论便是错。所以宋学反佛的口号,是“释氏本心,吾徒本天”。所谓本心,即是佛家万法惟识之论。所谓本天,则是承认外界的实在性。万事万物,其间都有一个定理,此即所谓天理。所以宋学的反佛,是以唯物论反对唯心论。\n宋学中自创一种宇宙观和人生观的,有周敦颐、张载、邵雍三人。周敦颐之说,具于《太极图说》及《通书》。他依据古说,假定宇宙的本体为太极。太极动而生阳,静而生阴。动极复静,静极复动。如此循环不已,因生水、火、木、金、土五种物质。此五种物质,是各有其性质的。人亦系此五种物质所构成,所以有智(水)、礼(火)、仁(木)、义(金)、信(土)五种性质。及其见诸实施,则不外乎仁义二者(所以配阴阳)。仁义的性质,都是好的,然用之不得其当,则皆可以变而为恶(如寒暑都是好的,不当寒而寒,不当暑而暑则为恶),所以要不离乎中正(所以配太极),不离乎中正谓之静。所以说:“圣人定之以仁义中正而主静,立人极焉。”张载之说,具于《正蒙》。其说:亦如古代,以气为万物的原质。气是动而不已的。因此而有聚散。有聚散则有疏密。密则为吾人所能知觉,疏则否,是为世俗所谓有无。其实则是隐显。隐显即是幽明。所以鬼神之与人物,同是一气。气之运动,自有其一定的法则。在某种情形之下,则两气相迎;在某种情形之下,则两气相距,是为人情好恶之所由来(此说将精神现象的根源,归诸物质,实为极彻底的一元论)。然此等自然的迎距,未必得当。好在人的精神,一方面受制于物质,一方面仍有其不受制于物质者存。所以言性,当分为气质之性(受制于物质的)与义理之性(不受制于物质的)。人之要务,为变化其气质,以求合乎义理。此为张氏修己之说。张氏又本其哲学上的见地,创万物一体之说,见于其所著的《西铭》。与惠施泛爱之说相近。邵雍之说,与周张相异。其说乃中国所谓术数之学。中国学术,是重于社会现象,而略于自然现象的。然亦有一部分人,喜欢研究自然现象。此等人,其视万物,皆为物质所构成。既为物质所构成,则其运动,必有其定律可求。人若能发现此定律,就能知道万物变化的公例了。所以此等人的愿望,亦可说是希冀发现世界的机械性的。世界广大,不可遍求,然他们既承认世界的规律性,则研究其一部分,即可推之于其余。所以此一派的学者,必重视数。他们的意思,原不过借此以资推测,并不敢谓所推之必确,安敢谓据此遂可以应付事物?然(一)既曾尽力于研求,终不免有时想见诸应用。(二)又此学的初兴,与天文历法,关系极密,古人迷信较深,不知世界的规律性,不易发现,竟有谓据此可以逆臆未来的。(三)而流俗之所震惊,亦恒在于逆臆未来,而不在乎推求定理。所以此派中亦多逆臆未来之论,遂被称为术数之学。此派学者,虽系少数,著名的亦有数家,邵雍为其中之最善者。雍之说,见于《观物内外篇》及《皇极经世书》。《观物篇》称天体为阴阳,地体为刚柔,又各分太少二者(日为太阳,月为太阴。星为少阳,辰为少阴。火为太刚,水为太柔。石为少刚,土为少柔。其说曰:阳燧取于日而得火,火与日相应也。方诸取于月而得水,水与月一体也。星陨为石;天无日月星之处为辰,地无山川之处为土;故以星与石,辰与土相配。其余一切物与阴阳刚柔相配,皆准此理),以说明万物的性质及变化。《皇极经世书》以十二万九千六百年为一元(日之数一为元。月之数十二为会。星之数三百六十为运。辰之数四千三百二十为世。一世三十年。以三十乘四千三百二十,得十二万九千六百)。他说:“一元在天地之间,犹一年也。”这和扬雄作《太玄》,想本一年间的变化,以窥测悠久的宇宙一样。邵雍的宗旨,在于以物观物。所谓以物观物,即系除尽主观的见解,以冀发现客观的真理,其立说精湛处甚多。但因术数之学,不为中国所重视,所以在宋学中不被视为正宗。\n经过周、张、邵诸家的推求,新宇宙观和新人生观可谓大致已定。二程以下,乃努力于实行的方法。大程名颢,他主张“识得此理,以诚敬存之”。但何以识得此理呢?其弟小程名颐,乃替他补充,说“涵养须用敬,进学在致知”。致知之功,在于格物。即万事而穷其理,以求一旦豁然贯通。这话骤听似乎不错的。人家驳他,说天下之物多着呢,如何格得尽?这话也是误解。因为宋儒的所求,并非今日物理学家之所谓物理,乃系吾人处事之法。如曾国藩所谓:“冠履不同位,凤皇鸱鸮不同栖,物所自具之分殊也。鲧湮洪水,舜殛之,禹郊之,物与我之分际殊也。”天下之物格不尽,吾人处事的方法,积之久,是可以知识日臻广博,操持日益纯熟的。所以有人以为格物是离开身心,只是一个误解。问题倒在(一)未经修养过的心,是否能够格物?(二)如要修养其心,其方法,是否以格物为最适宜?所以后来陆九渊出,以即物穷理为支离,要教人先发其本心之明,和赞成小程的朱熹,成为双峰并峙之局。王守仁出,而其说又有进。守仁以心之灵明为知。即人所以知善知恶,知是知非。此知非由学得,无论如何昏蔽,亦不能无存,所以谓之良知。知行即是一事。《大学》说“如恶恶息,如好好色”。知恶臭之恶,好色之好,是知一方面事。恶恶臭,好好色,是行一方面事。人们谁非闻恶臭即恶,见好色即好的?谁是闻恶臭之后,别立一心去恶?见好色之后,别立一心去好?然则“知而不行,只是未知”。然因良知无论如何昏蔽,总不能无存,所以我们不怕不能知善知恶,知是知非,只怕明知之而不肯遵照良心去做。如此,便要在良知上用一番洗除障翳的功夫,此即所谓致知。至于处事的方法,则虽圣人亦有所不能尽知。然苟得良知精明,毫无障翳,当学时,他自会指点你去学;当用人时,他自会指点你去求助于人,正不必以此为患。心之灵明谓之知,所知的自然有一物在。不成天下之物都无了,只剩一面镜子,还说这镜子能照。所以即物穷理,功夫亦仍是用在心上。而心当静止不动时,即使之静止不动,亦即是一种功夫。所以“静处体悟,事上磨炼”,两者均无所不可。程朱的涵养须用敬,进学在致知,固然把道德和知识,分成两截。陆九渊要先发人本心之明,亦不过是把用功的次序倒转了,并没有能把两者合而为一。王守仁之说,便大不相同了。所以理学从朱陆到王,实在是一个辩证法的进步。但人之性质,总是偏于一方面的,或喜逐事零碎用功夫,或喜先提挈一个大纲。所以王守仁之说,仍被认为近于陆九渊,并称为陆王。人的性质,有此两种,是一件事实,是一件无可变更的事实。有两种人自然该有两种方法给他们用,而他们亦自然会把事情做成两种样子。所以章学诚说:“朱陆为千古不可无之同异,亦为千古不能无之同异。”(见《文史通义·朱陆篇》),其说最通。\n以一种新文化,替代一种旧文化,此新文化,必已兼摄旧文化之长,此为辩证法的真理。宋学之于佛学,亦系如此。宋学兼摄佛学之长,最显著的有两点:(一)为其律己之严,(二)为其理论的彻底。论治必严王霸之辨,论人必严君子小人之分,都系由此而出。此等精严的理论,以之律己则可,以之论事,则不免多所窒碍。又宋学家虽反对佛教的遗弃世事,然其修养的方法,受佛教的影响太深了。如其说而行之,终不免偏于内心的修养,甚至学问亦被抛荒,事为更不必说,所以在宋代,宋学中的永嘉、永康两派,就对此而起反动(永嘉派以叶适、陈傅良为魁首。反对宋学的疏于事功,疏于实学的考究。永康派以陈亮为魁首,对于朱熹王霸之辨,持异议颇坚,亦是偏于事功的)。到清代,颜元、李塨一派,亦是对此力加攻击的。然永嘉、永康两派和朱陆,根本观念上,实无甚异同,所争的只是程度问题,无关宏指。颜李一派,则专讲实务,而反对在心上用功夫,几乎把宋学根本取消了。近来的人,因反对中国的学者,多尚空言,而不能实行,颇多称道颜李的。然颜、李的理论,实极浅薄,不足以自成一军。因为世界进步了,分工不得不精。一件事,合许多人而分工,或从事于研究,或从事于实行,和一个人幼学壮行,并无二致。研究便是实行的一部。颜、李之说,主张过甚,势必率天下人而闭目妄行。即使主张不甚,亦必变精深为浅薄。所以其说实不能成立。从理论上反对宋儒的,还有戴震。谓宋儒主张天理人欲之辨太过,以致(一)不顾人情。视斯民饮食男女之欲,为人生所不能无的,都以为毫无价值而不足恤。(二)而在上者皆得据理以责其下,下情却不能上达,遂致有名分而无是非,人与人相处之道,日流于残酷。此两端:其前一说,乃宋学末流之弊,并非宋学的本意。后一说则由宋学家承认封建时代的秩序,为社会合理的组织之故。戴氏攻之,似得其当。然戴氏亦不知其病根之所在,而说只要舍理而论情,人与人的相处,就可以无问题,其说更粗浅牵强了。在现在的文化下所表现出来的人情,只要率之而行,天下就可以太平无事么?戴氏不是盲目的,何以毫无所见?\n所以宋学衰敝以后,在主义上,能卓然自立,与宋学代兴的,实无其人。梁启超说:清代的学术,只是方法运动,不是主义运动(见所著《清代学术概论》),可谓知言了。质实言之,清代考证之学,不过是宋学的一支派。宋学中陆王一派,是不讲究读书的,程朱一派本不然。朱子就是一个读书极博的人。其后学如王应麟等,考据尤极精审。清学的先驱,是明末诸大儒。其中顾炎武与清代考证之学,关系尤密,也是程朱一派(其喜言经世,则颇近永嘉)。清代所谓纯汉学,实至乾嘉之世而后形成,前此还是兼采汉宋,择善而从的。其门径,和宋学并无区别。清学的功绩,在其研究之功渐深,而日益趋于客观。因务求古事的真相,觉得我们所根据的材料,很不完全,很不正确;材料的解释,又极困难。乃致力于校勘;致力于辑佚;对于解释古书的工具,即训诂,尤为尽心。其结果,古书已佚而复见的,古义已晦而复明的不少,而其解决问题的方法,亦因经验多了,知道凭臆判断,自以为得事理之平,远不如调查其来路,而凭以判断者之确。于是折衷汉宋,变为分别汉宋,其主意,亦从求是变而为求真了(非不求是,乃以求真为求是)。清学至此,其所至,已非复宋儒所能限,然仍是一种方法的转变,不足以自成一军。\n清学在宗旨上,渐能脱离宋学而自立,要到道咸时今文之学兴起以后。西汉经师之说,传自先秦,其时社会的组织,还和秦汉以后不同。有许多议论,都不是东汉以后人所能了解的。自今文传授绝后,久被搁置不提了。清儒因分别学派,发现汉儒亦自有派别,精心从事于搜剔,而其材料始渐发现,其意义亦渐明白。今学中有一派,专务材料的搜剔,不甚注意于义理。又一派则不然,常州的庄(存与)、刘(逢禄),号称此学的开山,已渐注意于汉儒的非常异义。龚(自珍)、魏(源)两氏继之,其立说弥以恢廓。到廖平、康有为出,就渐渐地引上经世一路,非复经生之业了。\n廖平晚岁的学说,颇涉荒怪。然其援据纬说,要把孔道的规模,扩充到无限大,也仍是受世变的影响的。但廖氏毕竟是个经生。其思想,虽亦受世变的影响,而其所立之说,和现代的情形,隔膜太甚。所以对于学术界,并不能发生多大的影响。康氏就不然了。康氏的经学远不如廖氏的精深。然其思想,较之廖氏,远觉阔大而有条理。他怀抱着一种见解,借古人之说为材料而说明之。以《春秋》三世之义,说明进化的原理,而表明中国现在的当改革,而以孔子托古改制之说辅之。其终极的目的,则为世界大同。恰和中国人向来怀抱的远大的思想相合,又和其目前急须改革的情形相应,所以其说能风靡一时。但康有为的学说,仍只成为现代学术思想转变的一个前驱。这是为什么呢?因为学术是利于通的,物穷则变,宋明以来的文化,现在也几乎走到尽头了。即使没有西学输入,我们的学术思想,也未必不变。但既与西洋学术相值,自然乐得借之以自助。何况现在西洋的科学方法,其精密,确非我们之所及呢?所以近数十年来,中国学术思想的变化,虽然靠几个大思想家做前驱,而一经发动之后,其第二步,即继之以西洋学术的输入。和中国学术思想的转变最有关系的人是梁启超。梁氏的长处:在其(一)对于新科学,多所了解。(二)最能适应社会的程度,从事于介绍。(三)且能引学说以批评事实,使多数人感觉兴味。即此趋势的说明。\n西洋学术输入以来,中国人对之之态度,亦经数变。(一)其初是指采用西法者为用夷变夏,而极力加以排斥的。(二)继则变为中学为体,西学为用。(三)再进一步,就要打倒孔家店,指旧礼教为吃人,欢迎德谟克拉西先生、赛因斯先生,并有主张全盘西化的了。其实都不是这么一回事。欧美近代,最初发达的是自然科学。因此引起的整个学术思想的变化(即对于一切事情的观点及其应付方法),较诸旧说,都不过是程度问题。后来推及社会科学亦然。现在文化前途的改变,乃是整个社会组织的改变,并非一枝一节的问题。这个问题,乃中国与西洋之所同,而非中国之所独。具体言之,即是中国与西洋,以及全世界的各民族,都要携手相将,走上一条新的径路。其间固无一个民族,能够守旧而不变,也断非哪一个民族,尽弃其所固有,而仿效别一个民族的问题。因为照现状,彼此都是坏的,而且坏得极相像。然则各种学术,能指示我们以前途,且成为各学之王,而使他种学术,奔走其下,各尽其一枝一节之用的,必然是社会学。一切现象,都是整个社会的一枝一节,其变化,都是受整个社会的规定的。惟有整个社会,能说明整个社会。亦惟有整个社会,能说明一枝一节的现象的所以然。人们向来不知,只是把一枝一节的现象,互相说明,就错了。这是因为从前的人,不知道整个社会,可成为研究的对象,所以如此。现在便不同了。所以只有最近成立的社会学,为前此之所无。亦只有整个的社会学,能够说明文化之所由来,而评判其得失,而指示我们以当走的路径。即如文明愈进步,则风俗愈薄恶,这是一件众所周知的事实,而亦是向来所视为无可如何的事实。毁弃文明,固不可,亦不能。任社会风俗之迁流,而日趋于薄恶,也不是一回事。提倡道德,改良政治等,则世界上无论哪一个文明国,都已经努力了几千年,而证明其无效的了。人道其将终穷乎?从社会学发明以来,才知道风俗的薄恶,全由于社会组织的不良,和文明进步,毫无关系。我们若能把社会组织,彻底改良,则文明进步,就只有增加人类的福利了。这是社会学指示给我们前途最大的光明。而社会学之所以能发明,则和现代各地方蛮人风俗的被重视,以及史前史的发现,有极大的关系。因此,我们才知道社会的组织,可以有多种。目前的组织,只是特种事实所造成,并非天经地义,必不可变。变的前途,实有无限的可能。变的方法,我们所知道的,亦与前人迥异了。\n以上论中国学术思想转变的大概,以下再略论中国的文学和史学。\n文学的发达,韵文必先于散文,中国古代,亦系如此。现存的先秦古书,都分明包含着两种文字:一种是辞句整齐而有韵的。一种则参差不齐,和我们的口语一样。前者是韵文,后者是散文。散文的发达,大约在东周之世,至西汉而达于极点。散文发达了,我们的意思,才能够尽量倾吐(因为到这时候,文字和语言,才真正一致),所以是文学的一个大进步。西汉末年,做文章的,渐渐求其美化。其所谓美是:(一)句多偶丽,(二)不用过长过短之句,(三)用字务求其足以引起美感。其结果,逐渐成汉魏体的骈文。汉魏体的骈文,只是字句修饰些,声调啴缓些,和散文相去,还不甚远。以后一直向这趋势发达,至齐梁时代,遂浮靡而不能达意了。此时供实用之文,别称为笔。然笔不过参用俗字俗语;用字眼、用典故,不及文来得多;其语调还和当时的文相近,与口语不合,还是不适于用。积重之势,已非大改革不可。改革有三条路可走:(一)径用口语。这在昔日文字为上中流社会所专有的时代,是不行的。(二)以古文为法。如苏绰的拟《大诰》是。这还是不能达意。只有第(三)条路,用古文的义法(即文字尚未浮靡时的语法),以运用今人的言语,是成功的。唐朝从韩、柳以后,才渐渐地走上这条路。散文虽兴,骈文仍自有其用,骈散自此遂分途。宋朝为散文发达的时代。其时的骈文,亦自成一格。谓之宋四六。气韵生动,论者称为骈文中的散文。\n诗歌另是一体。文是导源于语言,诗是导源于歌谣的。所以诗体,当其发生之时,即非口语之调。近人以随意写来的散文,亦称为诗(新诗),这至少要改变向来诗字的定义然后可。古代的诗,大抵可歌。传于后世的,便是《诗经》和《楚辞》。到汉朝,风尚变了。制氏雅乐虽存,不为人之所好。汉武帝立新声乐府,采赵、代、秦、楚之讴,使李延年协其律,司马相如等为之辞,是为汉代可歌的诗。古代的诗,则变为五言诗,成为只可吟诵之物。论者多以此为诗体的退化,这是为尊古之见所误。其实凡事都到愈后来愈分化。吟诵的诗,和合乐的诗的判而为二,正是诗体的进化。歌唱的音调,和听者的好尚的变迁,是无可如何的事。隋唐时,汉代的乐府,又不为人之所好,而其辞亦渐不能合乐了。听者的好尚,移于外国传来的燕乐。按其调而填词,谓之词。极盛于两宋之世。至元以后,又渐成为但可吟诵,不能协律之作,而可歌的限于南北曲。到清朝,按曲谱而填词的,又多可诵而不可歌了。中国的所谓诗,扩而充之,可以连乐府、词、曲,都包括在内。因为其起源,同是出于口中的歌的。一个民族的歌谣,不容易改变。试看现代的山歌,其音调,还与汉代的乐府一样,便可知道。所以现在,非有新音乐输入,诗体是不会变化的。现在万国交通,新音乐输入的机会正多。到我国人的口耳,与之相习,而能利用之以达自己的美感时,新诗体就可产生了。\n文学初兴之时,总是与语言相合的。但到后来,因(一)社会情形的复杂,受教育的程度,各有不同;(二)而时间积久了,人的语言,不能不变,写在纸上的字,却不能再变;言文就渐渐的分离了。合于口语的文字,是历代都有的。如(一)禅宗和宋儒的语录。(二)元代的诏令。(三)寒山、拾得的诗。(四)近代劝人为善的书都是。(五)而其用之,要以平话为最广。这是非此不可的。从前文言、白话,各有其分野,现在却把白话的范围推广了。这因(一)受教育的人渐多,不限于有闲阶级;而所受的教育,亦和从前不同,不能专力于文字。(二)世变既亟,语言跟着扩充、变化,文字来不及相随,乃不得不即用口语。此乃事势的自然,无人提倡,也会逐渐推广的。守旧的人,竭力排斥白话,固然是不达。好新的人,以此沾沾自喜,也是贪天之功,以为己力的。所谓古文,大部分系古代的言语。其中亦有一部分,系后人依据古代的语法所造的,从未宣诸唇吻,只是形之楮墨。然楮墨之用,亦系一种广义的语言。既有其用,自不能废。而况纸上的言语,有时亦可为口语所采用。所以排斥文言,也是偏激之论。\n文以载道,文贵有用之说,极为近人所诋毁。此说固未免于迂,然亦不能谓其全无道理。近人袭西洋文学的理论,贵纯文学而贱杂文学,这话固然不错。然以为说理论事之作,必是杂文学,必写景言情之作,而后可以谓之纯文学,则是皮相之谈。美的原质,论其根柢,实在还是社会性。社会性有从积极方面流露的,如屈原、杜甫的忠爱是。有从消极方面流露的,如王维、孟浩然的闲适是。积极的人人所解,消极的似乎适得其反,其实不然。积极的是想把社会改好,消极的则表示不合作。虽然无所作为,然(一)使人因此而悟现社会之坏,(二)至少亦使社会减少一部分恶势力,其功效也还是一样的。文字上的所谓美,表面上虽若流连风景,其暗中深处,都藏有这一个因素在内。诗词必须有寄托,才觉得有味,真正流连风景的,总觉得浅薄,就是为此。然则文字的美恶,以及其美的程度,即视此种性质之有无多寡以为衡,其借何种材料而表现,倒是没有关系的。忧国忧民,和风花雪月,正是一样。以说理论事,或写景言情,判别文学的为纯为杂,又是皮相之谈了。文以载道,文贵有用等说,固然不免于迂腐。然载道及有用之作,往往是富于社会性的,以此为第一等文字,实亦不为无见,不过抛荒其美的方面,而竟以载道和有用为目的,不免有语病罢了。\n中国的有史籍甚早。《礼记·玉藻》说:“动则左史书之,言则右史书之。”郑《注》说:“其书,《春秋》、《尚书》其存者。”(《汉书·艺文志》说“右史记事,左史记言”是错的。《礼记·祭统》说“史由君右,执策命之”,即右史记言之证)这大约是不错的。《周官》还有小史,记国君及卿大夫的世系,是为《帝系》及《世本》。我国最古的史籍《史记》,其本纪及世家,似系据《春秋》和《系》、《世》编纂而成。列传则源出记言之史。记言之史,称为《尚书》。乃因其为上世之书而得此名,其原名似称为语。语之本体,当系记人君的言语,如现在的训辞讲演之类。后来扩充之,则及于一切嘉言。嘉言的反面是莠言,间亦存之以昭炯戒。记录言语的,本可略述其起因及结果,以备本事。扩充之则及于一切懿行,而其反面即为恶行。此体后来附庸蔚为大国,名卿大夫,及学术界巨子,大抵都有此等记录,甚至帝王亦有之。其分国编纂的,则谓之《国语》。关于一人的言行,分类编纂的,则谓之《论语》。记载一人的大事的,则如《礼记·乐记》,述武王之事,谓之《牧野之语》都是。《史记》的列传,在他篇中提及,多称为语(如《秦本纪》述商鞅说孝公变法事曰:“其事在《商君语》中。”),可见其源出于语,推而广之,则不名为语的,其实亦系语体。如《晏子春秋》及《管子》中的《大》、《中》、《小匡》等是。八书是记典章经制的,其源当亦出于史官,不过不能知其为何官之史罢了。史官以外,还有民间的传述。有出于学士大夫之口的,如魏绛、伍员述少康、羿、浞之事是(见《左传》襄公四年、哀公元年,及《史记·吴太伯世家》)。亦有出于农夫野老之口的,如孟子斥咸丘蒙所述为齐东野人之语是。古史的来源,大略如此。秦始皇烧书,《史记·六国表》说“诸侯史记尤甚”,大约史官所记载,损失极多。流行民间之书,受其影响当较少。日耳相传,未著竹帛的,自然更不必说了。\n有历史的材料是一事,有史学上的见地,又系一事。古代史官,虽各有专职,然大体不过奉行故事。民间传达,或出惊奇之念,或出仰慕之忱(所谓多识前言往行,以蓄其德),亦说不上什么史学上的见地。到司马谈、迁父子出,才网罗当时所有的史料,编纂成一部大书。这时的中国,在当时人的眼光中,实已可谓之天下(因为所知者限于此。在所知的范围中,并没有屏斥异国或异族的史料不载)。所以《太史公书》(这是《史记》的本名。《汉书·艺文志》著录即如此。《史记》乃史籍通名,犹今言历史。《太史公书》,为史部中最早的著述,遂冒其一类的总名),实自国别史进于世界史,为史体一大进步。\n从此以后,国家亦渐知史籍的重要了。后汉以后,乃有诏兰台、东观中人述作之事。魏晋以后,国家遂特设专官。此时作史的,在物力上,已非倚赖国家不行(一因材料的保存及搜辑,一因编纂时之费用)。至于撰述,则因材料不多,还为私人之力所能及。所以自南北朝以前,大率由国家供给材料及助力,而司编撰之事的,则仍系一二人,为私家著述性质。唐以后史料更多,不徒保存、搜辑,即整理、排比,亦非私人之力所及,于是独力的著述,不得不变为集众纂修之局了。私家著述及集众纂修,昔人的议论,多偏袒前者,这亦是一偏之见。姑无论材料既多,运用为私人之力所不及。即舍此勿论,而昔时的正史,包括的门类很多,亦非一人所能兼通。所以即就学术方面论,二者亦各有长短。唐修《新晋书》(即今正史中的《晋书》),其志非前人所能及,即其一证。关于正史的历史,可参看《史通》的《六家》、《二体》、《古今正史》、《史官建置》各篇,及拙撰《史通评》中这几篇的评(商务印书馆本)。\n从前的历史,系偏重于政治方面的。而在政治方面,则所注重的,为理乱兴衰、典章经制两类。正史中的纪传,是所以详前者的,志则所以详后者。已见《绪论》中。编年史偏详前者。《通典》、《通考》一类的书,则偏详后者,都不如纪传表志体的完全。所以后来功令,独取纪传表志体为正史。然编年体和政书(《通典》、《通考》等),在观览上亦各有其便,所以其书仍并为学者所重。这是中国旧日所认为史部的重心的。纪传体以人为单位,编年史以时为系统,欲勾稽一事的始末,均觉不易。自袁枢因《通鉴》作《纪事本末》后,其体亦渐广行。\n中国的史学,在宋时,可谓有一大进步。(一)独力著成一史的,自唐以后,已无其事。宋则《新五代史》出欧阳修一人;《新唐书》虽出修及宋祁两人,亦有私家著述性质,事非易得。(二)编年之史,自三国以后,久已废阙。至宋则有司马光的《资治通鉴》,贯串古今。朱嘉的《通鉴纲目》,叙事虽不如《通鉴》的精,体例却较《通鉴》为善(《通鉴》有目无纲,检阅殊为不便。司马光因此,乃有《目录》之作,又有《举要》之作。《目录》既不与本书相附丽。《举要》则朱子《答潘正叔书》,讥其“详不能备首尾,略不可供检阅”,亦系实情。所以《纲目》之作,确足以改良《通鉴》的体例)。(三)讲典章经制的书,虽起于唐杜佑的《通典》,然宋马端临的《文献通考》,搜集尤备,分类亦愈精。又有会要一体,以存当代的掌故,并推其体例,以整理前代的史实。(四)郑樵《通志》之作,网罗古今。其书虽欠精审,亦见其魄力之大。(五)当代史料,搜辑綦详。如李焘《续资治通鉴长编》、李心传《建炎以来系年要录》、徐梦莘《三朝北盟会编》、王偁《东都纪略》等都是。(六)自周以前的古史,实系别一性质。至宋而研究加详。如刘恕《通鉴外纪》、金履祥《通鉴纲目前编》、苏辙《古史考》、胡宏《皇王大纪》、罗泌《路史》等都是。(七)研究外国史的,宋朝亦加多。如叶隆礼《契丹国志》、孟珙《蒙鞑备录》等是。(八)考古之学,亦起于宋时。如欧阳修的《集古录》、赵明诚的《金石录》等,始渐求材料于书籍之外。(九)倪思的《班马异同评》、吴缜的《新唐书纠谬》等,皆出于宋时。史事的考证,渐见精核。综此九端,可见宋代史学的突飞猛进。元明时代复渐衰。此因其时之学风,渐趋于空疏之故。但关于当代史料,明人尚能留心收拾。到清朝,文字之狱大兴,士不敢言当代的史事;又其时的学风,偏于考古,而略于致用;当代史料,就除官书、碑传之外,几乎一无所有了。但清代考据之学颇精。推其法以治史,能补正前人之处亦颇多。\n研究史法之作,专著颇少。其言之成理,而又有条理系统的,当推刘知幾的《史通》。《史通》是在大体上承认前人的史法为不误,而为之弥缝匡救的。其回到事实上,批评历代的史法,是否得当;以及研究今后作史之法当如何的,则当推章学诚。其识力实远出刘知幾之上。此亦时代为之。因为刘知幾之时,史料尚不甚多,不虑其不可遍览,即用前人的方法撰述已足。章学诚的时代,则情形大不同,所以迫得他不得不另觅新途径了。然章氏的识力,亦殊不易及。他知道史与史材非一物,保存史材,当务求其备,而作史则当加以去取;以及作史当重客观等(见《文史通义·史德篇》),实与现在的新史学,息息相通。不过其时无他种科学,以为辅助。所以其论不如现在新史学的精审罢了。然亦不过未达一间而已,其识力亦很可钦佩了。\n第五十四章 宗教 # 宗教的信仰,是不论哪一个民族都有的。在浅演之时固然,即演进较深之后,亦复如此。这是因为:学问之所研究,只是一部分的问题,而宗教之所欲解决,则为整个的人生问题。宗教的解决人生问题,亦不是全不顾知识方面的。它在感情方面,固然要与人以满足。在知识方面,对于当时的人所提出的疑问,亦要与以一个满意的解答。所以一种宗教,当其兴起之时,总是足以解决整个人生问题的。但既兴起之后,因其植基于信仰,其说往往不易改变;而其态度亦特别不宽容;经过一定时期之后,遂成为进化的障碍,而被人斥为迷信。\n宗教所给与人的,既是当下感情上和知识上的满足,其教义,自然要随时随地而异。一种宗教,名目未变,其教义,亦会因环境而变迁。原始的人,不知道自然界的法则。以为凡事都有一个像人一般的东西,有知识,有感情,有意志,在暗中发动主持着。既不知道自然界的法则,则视外界一切变化,皆属可能。所以其视环境,非常之可畏怖。而其视其所祈求的对象,能力之大,尤属不可思议。有形之物,虽亦为其所崇拜,然其所畏怖而祈求的,大概非其形而为寓于其中的精灵。无形可见之物,怎会令人深信不疑呢?原来古人不知道生物与无生物之别,更不知道动物与植物、人与动物之别,一切都看做和自己一样,而人所最易经验到而难于解释的,为梦与死。明明睡在这里没有动,却有所见,有所闻,有所作为;明明还是这个人,而顷刻之间,有知已变为无知了,安得不相信人身之中,别有一物以为之主?既以为人是如此,就推之一切物,以为都是如此了。这是我们现在,相信人有灵魂;相信天地、日月、山川等,都有神为之主;相信老树、怪石、狐狸、蛇等,都可以成为精怪的由来。虽然我们现在,已知道自然界的法则了;知道生物与无生物、动物与植物、人与其他动物之别了;然此等见解,根株仍未拔尽。\n●摩尼教石刻像 位于福建晋江草庵,该寺建于元顺帝至元五年(1339年)。石佛高1.52米,宽0.83米,佛龛直径1.98米\n人类所崇拜的灵界,其实是虚无缥缈的,都是人所想象造作出来的。所以所谓灵界,其实还是人间世界的反映。人类社会的组织变化了,灵界的组织,也是要跟着变化的。我们现在所看得到的,其第一步,便是从部族时代进于封建时代的变化。部族的神,大抵是保护一个部族的,和别一个部族,则处于敌对的地位。所以《左传》僖公十年说:“神不歆非类,民不祀非族。”孔子也说:“非其鬼而祭之,谄也。”(《论语·为政》)到封建时代,各个神灵之间,就要有一个联系。既要互相联系,其间自然要生出一个尊卑等级来。在此时代,宗教家所要做的工作就是:(一)把神灵分类。(二)确定每一类之中,及各类之间尊卑等级的关系。我们在古书上看得见的,便是《周官》大宗伯所分的(一)天神,(二)地祇,(三)人鬼,(四)物魁四类。四类相互之间,自然天神最尊,地祇次之,人鬼次之,物魁最下。天神包括日月、星辰、风雨等。地祇包括山岳、河海等。但又有一个总天神和总地祇。人鬼:最重要的,是自己的祖宗。其余一切有功劳、有德行的人,也都包括在内。物魁是列举不尽的。天神、地祇、人鬼等,都是善性居多。物魁则善恶无定。这是中国人最普通的思想,沿袭自几千年以前的。宗教发达到这一步,离一般人就渐渐地远了。“天子祭天地,诸侯祭其境内名山大川”(《礼记·王制》),和一般人是没有关系的。季氏旅于泰山,孔子就要讥其非礼了(《论语·八佾》),何况平民?昊天上帝之外,还有主四时化育的五帝:东方青帝灵威仰,主春生。南方赤帝赤熛怒,主夏长。西方白帝白招拒,主秋成。北方黑帝汁光纪,主冬藏。中央黄帝含枢纽,则兼主四时化育。每一朝天子的始祖,据说实在是上帝的儿子。譬如周朝的始祖后稷,他的母亲姜嫄,虽说是帝喾之妃,后稷却不是帝喾的儿子。有一次,姜嫄出去,见一个大的足印。姜嫄一只脚,还不如他一个拇指大。姜嫄见了,觉得奇怪。把自己的脚,在这足印里踏踏看呢。一踏上去,身体就觉得感动。从此有孕了。生了一个儿子,就是后稷。又如商朝的始祖契。他的母亲简狄,也是帝喾之妃,然而契也不是帝窖的儿子。简狄有一次,到河里去洗澡,有一只玄鸟,掉下一个卵来。简狄取来吞下去,因此有孕了。后来就生了契。这个谓之“感生”(见《诗·生民》及《玄鸟》。《史记·殷周本纪》述契、后稷之生,即系《诗说》。《周官》大宗伯,以禋祀祀昊天上帝。小宗伯,兆五帝于四郊。郑玄谓天有六,即五帝和昊天上帝耀魄宝。可看《礼记·祭法疏》,最简单明了。五帝之名,虽出纬候,然其说自系古说。所以《礼记·礼运》:“因名山以升中于天,因吉土以飨帝于郊”已经把天和帝分说了)。契、稷等因系上帝之子,所以其子孙得受命而为天子。按诸“神不歆非类,民不祀非族”之义,自然和平民无涉的,用不着平民去祭。其余如“山林、川谷、丘陵,能出云,为风雨,见怪物”,而且是“民所取材用”的(《礼记·祭法》),虽和人民有关系。然因尊卑等级,不可紊乱之故,也就轮不着人民去祭了。宗教发达到此,神的等级愈多,上级的神,威权愈大,其去一般人却愈远,正和由部族之长,发展到有诸侯,由列国并立的诸侯,进步到一统全国的君主,其地位愈尊,而其和人民相去却愈远一样。\n人,总是实际主义的。所敬畏的,只会是和自己切近而有关系的神。日本田崎仁义所著《中国古代经济思想及制度》说:古代宗教思想,多以生物之功,归之女性;又多视日为女神。中国古代,最隆重的是社祭(《礼记·郊特牲》说:“惟为社事,单出里。惟为社田,国人毕作。惟社,丘乘共粢盛。”单同殚)。而这所谓社,则只是一地方的土神(据《礼记·祭法》,王、诸侯、大夫等,均各自立社),并不是与天神相对的后土。《易经·说卦传》离为日,为中女。《山海经》和《淮南子》,以生日驭日的羲和为女神(《山海经·大荒南经》:“东南海之外,甘水之间,有羲和之国。有女子,名羲和,方浴日于甘渊。羲和者,帝俊之妻,生十日。”《淮南子·天文训》:“至于悲泉,爰止其女,爰息其马,是谓县车。”)而《礼记·郊特牲》说,郊之祭,乃所以迎“长日之至”。可见以郊祭为祭天,乃后起之事,其初只是祭日;而祭日与祭社,则同是所以报其生物之功。后来虽因哲学观念的发达,而有所谓苍苍者天,抟抟者地,然这整个的天神和整个的地神,就和人民关系不切了,虽没有政治上“天子祭天地”的禁令,怕也不会有什么人去祭它的。日月星辰风雨等,太多了,祭不胜祭;亦知道其所关涉者广,用不着一地方去祭它。只有一地方的土神,向来视为于己最亲的,其祭祀还相沿不废。所以历代以来,民间最隆重的典礼是社祭,最热闹的节场是作社。还有所谓八蜡之祭,是农功既毕之后,举凡与农事有关之神,一概祭飨它一次(见《郊特牲》)。又古代视万物皆有神,则有所谓中,有所谓门,有所谓行,有所谓户,有所谓灶(均见《祭法》),此等崇拜,倒也有残留到后世的。又如古代的司命,是主人的生死的(司命亦见《祭法》。《庄子·至乐》云:“庄子之楚,见髑髅而问之。夜半,髑髅见梦。庄子曰:吾使司命复生子形,为子骨肉肌肤”,知古谓人生死,皆司命主之)。后世则说南斗主生,北斗主死,所以南北斗去人虽远,倒也有人崇拜它。诸如此类,悉数难终。总之于人有切近的关系的,则有人崇拜,于人无切近的关系的,则位置虽高,人视之,常在若有若无之间。现在人的议论,都说:一神教比多神教进化,中国人所崇拜的对象太杂,所以其宗教,还是未甚进化的。其实不然。从前俄国在专制时代,人民捐一个钱到教堂里去,名义上也要以俄皇的命令允许的。这和佛教中的阿弥陀佛有一个人皈依他,到临死时,佛都自己来接引他到净土去一样。中国的皇帝,向来是不管小事的,所以反映着人间社会而成的灵界组织,最高的神,亦不亲细务。假使中国宗教上的灵界组织,是以一个大神,躬亲万事的,中国人也何尝不会专崇拜这一个神?然而崇拜北斗,希冀长生,和专念阿弥陀佛,希冀往生净土的,根本上有什么区别呢?若说一神教的所谓一神,只是一种自然力的象征,所以崇拜一神教的,其哲学上的见地,业已达于泛神论了,要比多神教高些。则崇拜一神教的,都是当他自然力的象征崇拜的么?老实说:泛神论与无神论,是一而二,二而一的。真懂得泛神论的,也就懂得无神的意义,不会再有现在宗教家的顽固见解了。\n较神的迷信进一步的,则为术。术数二字,古每连称,其实二者是不同的,已见上章。术之起源,由于因果的误认。如说做一个木人,或者束一个草人,把他当做某人,用箭去射他,就会使这个人受伤。又如把某人贴身之物,加以破坏,就能使这个人受影响之类。苌弘在周朝,把狸首象征不来的诸侯去射它,以致为晋人所杀(见《史记·封禅书》)。豫让为赵襄子所擒,请襄子之衣,拔剑三跃而击之,衣尽出血,襄子回车,车轮未周而亡。就是此等见解。凡厌胜咒诅之术,均自此而出。又有一种,以为此地的某种现象,与彼地的某种现象;现在的某种现象,和将来的某种现象,有连带关系的。因欲依据此时此地的现象,以测知彼时彼地的现象,是为占卜之术所自始。此等都是所谓术。更进一步则为数。《汉书·艺文志》说形法家之学道:“形人及六畜骨法之度数,器物之形容,以求其声气贵贱吉凶,犹律有长短,而各征其声,非有鬼神,数自然也。”全然根据于目可见、身可触的物质,以说明现象的原因,而否认目不可见的神秘之说,卓然是科学家的路径。惜乎这种学派中人,亦渐渐地枉其所信,而和术家混合为一了。《汉志·术数略》,共分六家:曰天文、曰历谱、曰五行、曰蓍龟、曰杂占、曰形法。蓍龟和杂占,纯粹是术家言。天文、历谱、五行、形法都饶有数的意味,和术家混合了,为后世星相之学所自出。\n中国古代所崇拜的对象,到后世,都合并起来,而被收容于道教之中。然所谓道教,除此之外,尚有一个元素,那便是神仙家。当春秋战国时,就有所谓方士者,以不死之说,诳惑人主。《左传》昭公二十年,齐景公问于晏子,说“古而无死,其乐何如”?古代无论哲学、宗教,都没有持不死之说的,可见景公所问,为受神仙家的诳惑了。此后齐威宣王、燕昭王,亦都相信它(见于《史记·封禅书》),而秦始皇、汉武帝信之尤笃,其事为人人所知,无烦赘述了。事必略有征验,然后能使人相信。说人可不死,是最无征验的。齐景公等都系有为之主,何以都为所蛊惑呢?以我推测,因燕齐一带,多有海市。古人明见空中有人物城郭宫室,而不知其理,对于神仙之说,自然深信不疑了。神仙家,《汉志》列于方技,与医经、经方、房中并列。今所传最古的医书《素问》,中亦多载方士之言。可见方士与医药,关系甚密。想藉修炼、服食、房中等术,以求长生,虽然误缪,要不能视为迷信。然此派在汉武时,就渐渐的和古代的宗教混合了。汉武时,所谓方士,实分两派:一派讲炼丹药,求神仙,以求长生。一派则从事祠祭以求福。其事具见于《史记·封禅书》、《汉书·郊祀志》。《郊祀志》所载各地方的山川,各有其当祭之神,即由献其说的方士主持。此乃古代各部族的宗教,遗留到后世的。《山海经》所载,某水某山有某神,当用何物祠祭,疑即此等方士所记载。此派至元帝后,多被废罢;求神仙一派,亦因其太无效验,不复为时主所信,乃转而诳惑人民。其中规模最大的,自然是张角。次之则是张鲁。他们也都讲祠祭。但因人民无求长生的奢望,亦无炼金丹等财力(依《抱朴子》讲,当时方士炼丹,所费甚巨。葛洪即自憾无此资财,未能从事),所以不讲求神仙,而变为以符咒治病了。符咒治病,即是祝由之术,亦古代医术中的一科。其牵合道家之学,则自张鲁使其下诵习《老子》五千言始。张鲁之道,与老子毫无干涉,何以会使人诵习《老子》呢?依我推测,大约因汉时以黄、老并称,神仙家自托于黄帝,而黄帝无书,所以牵率及于老子。张鲁等的宗教,有何理论可讲?不过有一部书,以资牵合附会就够了,管什么实际合不合呢?然未几,玄学大兴,《老子》变为时髦之学,神仙家诳惑上流社会的,亦渐借其哲理以自文。老子和所谓方士,所谓神仙家,就都生出不可分离的关系来了。此等杂多的迷信,旁薄郁积,毕竟要汇合为一的。享其成的,则为北魏时的寇谦之。谦之学张鲁之术,因得崔浩的尊信,言于魏明元帝而迎之,尊之为天师,道教乃成为国家所承认的宗教,俨然与儒释并列了。此事在民国纪元前1489年,公元423年(刘宋少帝景平元年,魏明元帝泰常八年)。后世谈起道教来,均奉张陵为始祖。陵乃鲁之祖父。据《后汉书》说:陵客蜀,学道于鹄鸣山中。受其道者,辄出米五斗,故谓之米贼,陵传子衡,衡传于鲁。然其事并无证据。据《三国志》《注》引《典略》,则为五斗米道的,实系张修。修乃与鲁并受命于刘焉,侵据汉中,后来鲁又袭杀修而并其众的。鲁行五斗米道于汉中,一时颇收小效。疑其本出于修,鲁因其有治效而沿袭之,却又讳其所自出,而自托之于父祖。历史,照例所传的,是成功一方面的人的话,张陵就此成为道教的始祖了。\n从外国输入的宗教,最有权威的,自然是佛教。佛教的输入,旧说都以为在后汉明帝之世。说明帝梦见金人,以问群臣,傅毅对以西方有圣人,乃遣郎中蔡愔、博士弟子秦景等使于天竺。得佛经四十二章,及释迦立像,与沙门摄摩腾、竺法兰,以白马负经而至。因立白马寺于洛城西。此乃因其说见于《魏书·释老志》,以为出于正史之故。梁启超作《佛教之初输入》,考此说出于西晋道士王浮的《老子化胡经》,其意乃欲援释入道,殊为妖妄。然《魏书》实未以金人入梦,为佛教入中国之始。据《魏书》之意,佛教输入,当分三期:(一)匈奴浑邪王降,中国得其金人,为佛教流通之渐。(二)张骞至大夏,知有身毒,行浮屠之教。哀帝元寿元年,博士弟子秦景宪,受大月氏使伊存口授浮屠经。(三)乃及明帝金人之梦。金人实与佛教无涉。大月氏使口授浮屠经,事若确实,当可称为佛教输入之始。元寿元年,为民国纪元前1913年,即西历公元前2年。然则佛教输入中国,实在基督诞生后两年了(基督降生,在纪元前4年。西人因纪年行用已久,遂未改正)。据《后汉书》所载,光武帝子楚王英,业已信佛,可见其输入必不在明帝之世。秦景宪与秦景,当即一人。此等传说中的人物,有无尚不可知,何况确定其名姓年代?但大月氏为佛教盛行之地;汉与西域,交通亦极频繁,佛教自此输入,理有可能。梁启超以南方佛像涂金;《后汉书·陶谦传》,说谦使笮融督广陵、下邳、彭城运粮,融遂断三郡委输,大起浮屠寺,作黄金屠像,疑佛教本自南方输入。然此说太近臆测。即谓其系事实,亦不能断定其输入在北方之先。梁氏此文,破斥旧说之功甚大,其所建立之说,则尚待研究。柳诒徵《梁氏佛教史评》,可以参看。佛教的特色:在于(一)其说轮回,把人的生命延长了,足以救济中国旧说,(甲)限善报于今世及其子孙,及(乙)神仙家飞升尸解等说的太无征验,而满足人的欲望。(二)又其宗旨偏于出世,只想以个人的修养,解脱苦痛,全不参加政治斗争。在此点,佛教与张角、张鲁等,大不相同。所以不为政治势力所摧残,而反为其所扶植。(三)中国是时,尚缺乏统一全国的大宗教。一地方一部族之神,既因其性质褊狭而不适于用,天子所祭的天地等,亦因其和人民相去远了,而在若无若有之间。张角、张鲁等的宗教运动,又因其带有政治斗争性质;且其教义怕太浅,而不足以餍上中流社会之望;并只适于秘密的结合,而不宜于平和的传布,不能通行。只有佛教,既有哲理,又说福报,是对于上中下流社会都适宜的。物我无间,冤亲平等,国界种界尚且不分,何况一国之中,各地方各民族等小小界限?其能风行全国,自然无待于言了。至佛教的哲理方面,及其重要宗派,上章已略言之,今不赘述。\n●敦煌壁画反弹琵琶\n把一个中空的瓶抛在水中,水即滔滔注入,使其中本有水,外面的水就不容易进去了。这是先入为主之理,一人如是,一国亦然。佛教输入时,中国的宗教界,尚觉贫乏,所以佛教能够盛行。佛教输入后,就不然了。所以其他外教,输入中国的虽多,都不能如佛教的风行无阻。其和中国文化的关系亦较浅。\n佛教以外,外国输入的宗教,自以回教为最大。此教缘起,人人知之,无待赘述。其教本名伊思兰,在中国则名清真,其寺称清真寺。其经典名《可兰》。原来为阿剌伯文,非其教中有学问的人不能读;而其译本及教中著述,流布于社会上的很少;所以在中国,除教徒外,罕有了解回教教义的。又回教教规,极为严肃。教徒生活,与普通人不甚相合。所以自元代盛行输入以来,已历七百年,仍不能与中国社会相融化。现在中国信奉回教的人,约有五千万。其中所包含的民族实甚多,然人皆称为回族,俨然因宗教而结合成一个民族了。因宗教而结合成一个民族,在中国,除回教之外,是没有的。\n中国人称伊思兰教为回教,乃因其为回纥人所信奉而然。然回纥在漠北,实本信摩尼教。其信伊思兰教,乃走入天山南路后事。摩尼教原出火教。火教为波斯国教,中国称为胡天。又造祆字,称为祆教(其字从示从天,读他烟切。或误为从夭,读作于兆切,就错了)。火教当南北朝时,传至葱岭以东,因而流入中国。然信奉它的,只有北朝的君主。唐朝时,波斯为大食所灭,中亚细亚亦为所据,火教徒颇有东行入中国的,亦未和中国社会,发生甚么影响。摩尼教则不然。唐朝安史乱后,回纥人多入中国,其教亦随之而入。自长安流行及于江淮。武宗时,回纥败亡,会昌五年(西历845年,民国纪元前1067年),中国乃加以禁断。然其教流行至南宋时仍不绝。其人自称为明教。教外人则谓之吃菜事魔,以其教徒均不肉食之故。按宗教虽似专给人以精神上的慰安,实则仍和现实生活有关系。现实生活,经济问题为大。流行于贫苦社会中的宗教,有教人团结以和现社会相斗争的,如太平天国所创的上帝教,实行均田和共同生活之法是。有教教徒自相救恤,对于现社会的组织,则取放任态度的,如张鲁在汉中,教人作义舍,置米肉其中,以便行人;令有小过者修路;禁酒,春夏禁杀;明教徒戒肉食,崇节俭,互相救恤是。入其教的,生活上既有实益,所以宋时屡加禁断,不能尽绝。然社会秩序未能转变时,与之斗争的,固然不免灭亡;即欲自成一团体,独立于现社会组织之外的,亦必因其和广大的社会秩序不能相容,而终遭覆灭。所以到元朝以后,明教也就默默无闻了。张鲁之治汉中,所以能经历数十年,乃因其政治尚有规模,人民能与之相安,并非由其教义,则明教的流行较久,亦未必和其教义有甚关系了。火教及摩尼教流行中国的历史,详见近人陈垣所撰《火祆教入中国考》。\n基督教入中国,事在民国纪元前1274年(公元638年,唐太宗贞观十二年)。波斯人阿罗本(Olopen),始赉其经典来长安。太宗许其建寺,称为波斯,玄宗因其教本出大秦,改寺名为大秦寺。其教在当时,称为景教。德宗时,寺僧景净,立《景教流行中国碑》,明末出土,可以考见其事的始末。蒙古时,基督教又行输入。其徒谓之也里可温。陈垣亦有考。元时,信奉基督教的,多是蒙古人。所以元亡而复绝。直到明中叶后,才从海路复行输入。近代基督教的输入,和中国冲突颇多。推其源,实出于政治上的误解。基督教的教义,如禁拜天、拜祖宗、拜孔子等,固然和中国的风俗,是冲突的。然前代的外教,教规亦何尝不和中国风俗有异同?况近代基督教初输入时,是并不禁拜天、拜祖宗、拜孔子的。明末相信基督教的,如徐光启、李之藻辈,并非不了解中国文化的人。假使基督教义和中国传统的风俗习惯,实不相容,他们岂肯因崇信科学之故,把民族国家,一齐牺牲了?当时反对西教的,莫如杨光先。试看他所著的《不得已书》。他说:他们“不婚不宦,则志不在小”。又说:“其制器精者,其兵械亦精。”又说:他们著书立说,谓中国人都是异教的子孙。万一他们蠢动起来,中国人和他相敌,岂非以子弟敌父兄?又说:“以数万里不朝不贡之人,来不稽其所从来,去不究其所从去;行不监押,止不关防。十三省山川形势,兵马钱粮,靡不收归图籍。百余年后,将有知余言之不得已者。”因而断言:“宁可使中国无好历法,不可使中国有西洋人。”原来中国历代,军政或者废弛,至于军械,则总是在外国之上的。到近代,西人的船坚炮利,中国才自愧弗如。而中国人迷信宗教,是不甚深的。西洋教士艰苦卓绝的精神,又非其所了解。自然要生出疑忌来了。这也是在当日情势之下,所不能免的,原不足以为怪,然攻击西教士的虽有,而主张优容的,亦不在少数。所以清圣祖初年,虽因光先的攻击,汤若望等一度获罪,然教禁旋复解除。康熙一朝,教士被任用者不少。于中国文化,裨益实非浅鲜。此亦可见基督教和中国文化,无甚冲突了。教禁之起,实由1704年(康熙四十三年),教皇听别派教士的话,以不禁中国教徒拜天、拜祖宗、拜孔子为不然,派多罗(Tourmon)到中国来禁止。此非但教义与中国相隔阂,亦且以在中国传教的教士,而受命于外国的教皇,亦非当时中国的见解,所能容许。于是有康熙五十六年重申教禁之事。世宗即位后,遂将教徒一律安置澳门;各省的天主堂,尽行改为公廨了。自此以后,至五口通商后教禁解除之前,基督教在中国,遂变为秘密传播的宗教。中国人既不知道它的真相,就把向来秘密教中的事情,附会到基督教身上:什么挖取死人的眼睛咧;聚集教堂中的妇女,本师投以药饵,使之雉鸣求牡咧。种种离奇怪诞之说,不一而足,都酿成于此时。五口通商以后,(一)中国人既怀战败之忿,视外国的传教,为藉兵力胁迫而成。(二)教民又恃教士的干涉词讼为护符,鱼肉乡里。(三)就是外国教士,也有倚势妄为,在中国实施其敲诈行为的(见严复译英人宓克所著《中国教案论》)。于是教案迭起,成为交涉上的大难题了。然自庚子事变以后,中国人悟盲目排外之无益,风气翻然一变,各省遂无甚教案。此亦可见中国人对于异教的宽容了。\n基督教原出犹太。犹太教亦曾输入中国。谓之一赐乐业教。实即以色列的异译。中国谓之挑筋教。今存于河南的开封。据其教中典籍所记,其教当五代汉时(民国纪元前965至962,公元947至950年),始离本土,至宋孝宗隆兴元年(民国纪元前749,公元1163年),始在中国建寺。清圣祖康熙四十一年,有教徒二三千人。宣宗道光末,存者止三百余。宣统元年二百余。民国八年,止有一百二十余人。初来时凡十七姓,清初,存者止有七姓了。详见陈垣《一赐乐业教考》。\n社会变乱之际,豪杰之士,想结合徒党,有所作为的,亦往往藉宗教为工具。如前代的张角、孙恩,近代的太平天国等都是。此特其荦荦大者,其较小的,则不胜枚举。此等宗教,大率即系其人所创造,多藉当时流行之说为资料。如张角讹言“苍天已死,黄天当立”(苍,疑当作赤,为汉人所讳改),系利用当时五行生胜之说;白莲教依托佛教;上帝教依托基督教是。然此实不过借为资料(利用其业已流行于社会),其教理,实与其所依附之说,大不相同。其支离灭裂,往往使稍有智识之人,闻之失笑。上帝教和义和团之说,因时代近,传者较多,稍一披览,便可见得。然非此不足以煽动下流社会中人。我们现在的社会,实截然分为两橛。一为上中流知识阶级,一为下流无知识阶级。我们所见,所闻,所想,实全与广大的为社会基础的下层阶级相隔绝。我们的工作,所以全是浮面的,没有真正的功效,不能改良社会,即由于此。不可不猛省。\n●北魏·山西大同市云冈石窟第1窟内景\n中国社会,迷信宗教,是不甚深的。此由孔教盛行,我人之所祈求,都在人间而不在别一世界之故。因此,教会之在中国,不能有很大的威权。因此,我们不以宗教问题和异族异国,起无谓的争执。此实中国文化的一个优点。现今世界文化进步,一日千里。宗教因其性质固定之故,往往成为进化的障碍。若与之争斗,则又招致无谓的牺牲,欧洲的已事,即其殷鉴。这似乎是文化前途一个很大的难题。然实际生活,总是顽强的观念论的强敌。世界上任何宗教,其教义,总有几分禁欲性的,事实上,却从没看见多数的教徒,真能脱离俗生活。文化愈进步,人的生活情形,变更得愈快。宗教阻碍进步之处,怕更不待以干戈口舌争之了。这也是史事无复演,不容以旧眼光推测新变局的一端。\n[1]为了照顾读者阅读习惯,出版者将原书上册改作下编,原书下册改作了上编。本序所引章节均为本书章节——出版者注。\n"},{"id":163,"href":"/zh/docs/culture/%E4%B8%AD%E5%9B%BD%E9%80%9A%E5%8F%B2%E5%90%95%E6%80%9D%E5%8B%89/%E4%B8%8A%E7%AF%87-%E4%B8%AD%E5%9B%BD%E6%94%BF%E6%B2%BB%E5%8F%B2/","title":"上编-中国政治史","section":"中国通史(吕思勉)","content":" 第一章 中国民族的由来 # 社会是整个的,作起文化史来,分门别类,不过是我们分从各方面观察,讲到最后的目的,原是要集合各方面,以说明一个社会的盛衰,即其循着曲线进化的状况的。但是这件事很不容易。史事亡失的多了,我们现在,对于各方面,所知道的多很模糊(不但古代史籍缺乏之时,即至后世,史籍号称完备,然我们所要知道的事,仍很缺乏而多伪误。用现代新史学的眼光看起来,现在人类对于过去的知识,实在是很贫乏的),贸贸然据不完不备的材料,来说明一时代的盛衰,往往易流于武断。而且从中学到大学,永远是以时为经、以事为纬的,将各时代的事情,复述一遍,虽然详略不同,而看法失之单纯,亦难于引起兴趣。所以我这部书,变换一个方法,下册依文化的项目,把历代的情形,加以叙述,这一册依据时代,略述历代的盛衰。读者在读这一册时,对于历代的社会状况,阅读下册就会略有所知,则涉及时措辞可以从略,不至有头绪纷繁之苦;而于历代盛衰的原因,亦更易于明了了。\n叙述历代的盛衰,此即向来所谓政治史。中国从前的历史,所以被人讥诮为帝王的家谱,为相斫书,都由其偏重这一方面之故。然而矫枉过正,以为这一方面,可以视为无足重轻,也是不对的。现在的人民,正和生物在进化的中途需要外骨保护一样。这话怎样说呢?世界尚未臻于大同之境,人类不能免于彼此对立,就不免要靠着武力或者别种力量互相剥削。在一个团体之内,虽然有更高的权力,以评判其是非曲直,而制止其不正当的竞争,在各个团体之间,却至今还没有,到被外力侵犯之时,即不得不以强力自卫,此团体即所谓国家。一个国家之中,总包含着许多目的简单,有意用人力组成的团体,如实业团体、文化团体等都是。此等团体,和别一个国家内性质相同的团体,是用不着分界限的,能合作固好,能合并则更好。无如世界上现在还有用强力压迫人家,掠夺人家的事情,我们没有组织,就要受到人家的压迫、掠夺,而浸至无以自存了。这是现今时代国家所以重要的原因。世界上的人多着呢?为什么有些人能合组一个国家,有些人却要分做两国呢?这个原因,最重要的,就是民族的异同,而民族的根柢,则为文化。世界文化的发达,其无形的目的,总是向着大同之路走的,但非一蹴可几。未能至于大同之时,则文化相同的人民可以结为一体,通力合作,以共御外侮;文化不相同的则不能然,此即民族国家形成的原理。在现今世界上,非民族的国家固多,然总不甚稳固。其内部能平和相处,强大民族承认弱小民族自决权利的还好,其不然的,往往演成极激烈的争斗;而一民族强被分割的,亦必出死力以求其合,这是世界史上数见不鲜的事。所以民族国家,在现今,实在是一个最重要的组织。若干人民,其文化能互相融和而成为一个民族,一个民族而能建立一个强固的国家,都是很不容易的事。苟其能之,则这一个国家,就是这一个民族在今日世界上所以自卫,而对世界的进化尽更大的责任的良好工具了。\n中国是世界上最大的一个民族国家,这是无待于言的。一个大民族,固然总是融合许多小民族而成,然其中亦必有一主体。为中国民族主体的,无疑是汉族了。汉族的由来,在从前是很少有人提及的。这是因为从前人地理知识的浅薄,不知道中国以外还有许多地方之故。至于记载邃古的时代,自然是没有的。后来虽然有了,然距邃古的时代业已很远,又为神话的外衣所蒙蔽。一个民族不能自知其最古的历史,正和一个人不能自知其极小时候的情形一样。如其开化较晚,而其邻近有先进的民族,这一个民族的古史,原可藉那一个民族而流传,中国却又无有。那么,中国民族最古的情形,自然无从知道了。直至最近,中国民族的由来,才有人加以考究,而其初还是西人,到后来,中国人才渐加注意。从前最占势力的是“西来说”,即说中国民族,自西方高地而来。其中尤被人相信的,为中国民族来自黄河上源昆仑山之说。此所谓黄河上源,乃指今新疆的于阗河;所谓昆仑山,即指于阗河上源之山。这是因为:一、中国的开化,起于黄河流域;二、汉武帝时,汉使穷河源,说河源出于于阗。《史记·大宛列传》说,天子案古图书,河源出于昆仑。后人因汉代去古未远,相信武帝所案,必非无据之故。其实黄河上源,明明不出于阗。若说于阗河伏流地下,南出而为黄河上源,则为地势所不容,明明是个曲说。而昆仑的地名,在古书里也是很神秘的,并不能实指其处,这只要看《楚辞》的《招魂》、《淮南子》的《地形训》和《山海经》便知。所以以汉族开化起于黄河流域,而疑其来自黄河上源,因此而信今新疆西南部的山为汉族发祥之地,根据实在很薄弱。这一说,在旧时诸说中,是最有故书雅记做根据的,而犹如此,其他更不必论了。\n●北京猿人复原图\n\\[Peking Man。按此名为安特生所名,协和医学院解剖学教授步达生(Davidson Black)名之为Sinanthropus Pekinensis,叶为耽名之曰震旦人,见所著《震旦人与周口店文化》,商务印书馆本\\]。据考古学家的研究,其时约距今四十万年。其和中国人有无关系,殊不可知,不过因此而知东方亦是很古的人类起源之地罢了。其和历史时代可以连接的,则为民国十年辽宁锦西沙锅屯,河南渑池仰韶村,及十二三年甘肃临夏、宁定、民勤,青海贵德及青海沿岸所发现的彩色陶器,和俄属土耳其斯单所发现的酷相似。考古家安特生(J.G.Andersson)因谓中国民族,实自中亚经新疆、甘肃而来。但彩陶起自巴比仑,事在公元前3500年,传至小亚细亚,约在公元前2500年至前2000年,传至古希腊,则在前2000年至前1000年,俄属土耳其斯单早有铜器,河南、甘肃、青海之初期则无之,其时必在公元2500年之前,何以传播能如是之速?制铜之术,又何以不与制陶并传?斯坦因(Sir Aurel Stein)在新疆考古,所得汉、唐遗物极多,而先秦之物,则绝无所得,可见中国文化在先秦世实尚未行于西北,安特生之说,似不足信了(此说据金兆梓《中国人种及文化由来》,见《东方杂志》第二十六卷第二期)。民国十九年以后,山东历城的城子崖,滕县的安上村,都发现了黑色陶器。江苏武进的奄城,金山的戚家墩,吴县的磨盘山、黄壁山,浙江杭县的古荡、良渚,吴兴的钱山漾,嘉兴的双栖,平湖的乍浦,海盐的澉浦,亦得有新石器时代的石器、陶器,其中杭县的黑陶,颇与山东相类。又河域所得陶器,皆为条纹及席纹;南京、江、浙和山东邹县,福建武平,辽宁金县貔子窝及香港的陶器,则其文理为几何形。又山东、辽宁有有孔石斧,朝鲜、日本有有孔石厨刀,福建厦门、武平有有沟石锛,南洋群岛有有沟石斧,大洋洲木器所刻动物形,有的和中国铜器上的动物相像。北美阿拉斯加的土器,也有和中国相像的。然则中国沿海一带,实自有其文化。据民国十七年以后中央研究院在河南所发掘,安阳的侯家庄,濬县的大赉店,兼有彩色、黑色两种陶器,而安阳县北的小屯村,即1898、1899年发现甲骨文字之处,世人称为殷墟的,亦有几何纹的陶器。又江、浙石器中,有戈、矛及钺,河域惟殷墟有之。鬲为中国所独有,为鼎之前身,辽东最多,仰韶亦有之,甘肃、青海,则至后期才有。然则中国文化,在有史以前,似分东、西两系。东系以黑陶为代表,西系以彩陶为代表,而河南为其交会之地。彩陶为西方文化东渐的,代表中国固有的文化的,实为黑陶。试以古代文化现象证之:一、“国君无故不杀牛,大夫无故不杀羊,士无故不杀犬豕”,而鱼鳖则为常食。二、衣服材料,以麻、丝为主,裁制极其宽博。三、古代的人民,是巢居或湖居的。四、其货币多用贝。五、在宗教上又颇敬畏龙蛇。皆足证其文化起于东南沿海之处;彩陶文化之为外铄,似无疑义了。在古代,亚洲东方的民族,似可分为三系,而其处置头发的方法,恰可为其代表,这是一件极有趣味的事,即北族辫发、南族断发、中原冠带。《尔雅·释言》说:“齐,中也。”《释地》说:“自齐州以南戴日为丹穴,北戴斗极为空同,东至日所出为大平,西至日所入为大蒙。”“齐”即今之“脐”字,本有中央之义。古代的民族,总是以自己所居之地为中心的,齐州为汉族发祥之地,可无疑义了。然则齐州究在何处呢?我们固不敢断言其即后来的齐国,然亦必与之相近。又《尔雅·释地》说“中有岱岳”,而泰山为古代祭天之处,亦必和我民族起源之地有关。文化的发展,总是起于大河下流的,埃及和小亚细亚即其明证。与其说中国文化起于黄河上流,不如说其起于黄河下流的切于事情了。近来有些人,窥见此中消息,却又不知中国和南族之别,甚有以为中国人即是南族的,这个也不对。南族的特征是断发文身,断发即我国古代的髡刑,文身则是古代的黥刑。以南族的装饰为刑,可见其曾与南族相争斗,而以其俘虏为奴隶。近代的考古学,证明长城以北的古物,可分为三类:一、打制石器,其遗迹西起新疆,东至东三省,而限于西辽河、松花江以北,环绕着沙漠。二、细石器,限于兴安岭以西。与之相伴的遗物,有类似北欧及西伯利亚的,亦有类似中欧及西南亚的,两者均系狩猎或畜牧民族所为。三、磨制石器,北至黑龙江昂昂溪,东至朝鲜北境,则系黄河流域的农耕民族所为,其遗物多与有孔石斧及类鬲的土器并存,与山东龙口所得的土器极相似。可见我国民族,自古即介居南北两民族之间,而为东方文化的主干了(步达生言仰韶村、沙锅屯的遗骸,与今华北人同,日本清野谦次亦谓貔子窝遗骸,与仰韶村遗骸极相似)。\n●彩陶盆 仰韶文化半坡类型,公元前5000年—公元前4000年,陕西西安半坡遗址出土,高16.4厘米、口径40厘米,内绘鱼、鱼和人面相结合的花纹,这是仰韶文化早期流行的纹饰。有观点认为,鱼便是仰韶文化半坡类型的图腾\n第二章 中国史的年代 # 讲历史要知道年代,正和讲地理要知道经纬线一般。有了经纬线,才知道某一地方在地球面上的某一点,和其余的地方距离如何,关系如何。有了年代,才知道某一件事发生在悠远年代中的某一时,当时各方面的情形如何,和其前后诸事件的关系如何。不然,就毫无意义了。\n正确的年代,原于(一)正确,(二)不断的记载。中国正确而又不断的记载,起于什么时候呢?那就是周朝厉、宣两王间的共和元年。下距民国纪元2752年,公历纪元841年,在世界各国中,要算是很早的了。但是比之于人类的历史,还如小巫之见大巫。世界之有人类,其正确的年代虽不可知,总得在四五十万年左右。历史确实的纪年,只有二千余年,正像人活了一百岁,只记得一年不到的事情,要做正确的年谱,就很难了。虽然历史无完整的记载,历史学家仍有推求之法。那便是据断片的记载,涉及天地现象的,用历法推算。中国用这方法的也很多。其中较为通行的,一为《汉书·律历志》所载刘歆之所推算,一为宋朝邵雍之所推算。刘歆所推算:周朝867年,殷朝629年,夏朝432年,虞舜在位五十年,唐尧在位七十年。周朝的灭亡,在民国纪元前2167年,公历纪元前256年,则唐尧的元年,在民国纪元前4215年,公历纪元前2305年。据邵雍所推算,则唐尧元年,在民国纪元前4268年,公历纪元前2357年。据历法推算,本是极可信据的,但前人的记载,未必尽确,后人的推算,也不能无误,所以也不可尽信。不过这所谓不可信,仅系不密合,论其大概,还是不误的。《孟子·公孙丑下篇》说:“由周而来,七百有余岁矣。”《尽心下篇》说:“由尧、舜至于汤,五百有余岁;由汤至于文王,五百有余岁;由文王至于孔子,五百有余岁。”乐毅报燕惠王书,称颂昭王破齐之功,说他“收八百岁之蓄积”。《韩非子·显学篇》说:“殷、周七百余岁,虞、夏二千余岁。”(此七百余岁但指周言)都和刘歆、邵雍所推算,相去不远。古人大略的记忆,十口相传,是不会大错的。然则我国历史上可知而不甚确实的年代,大约在四千年以上了。\n自此以上,连断片的记录,也都没有,则只能据发掘所得,推测其大略,是为先史时期。人类学家把人类所用的工具,分别它进化的阶段,最早的为旧石器时期,次之为新石器时期,都在有史以前,更次之为青铜器时期,更次之为铁器时期,就在有史以后了。我国近代发掘所得,据考古学家的推测:周口店的遗迹,约在旧石器前期之末,距今二万五千年至七万年。甘、青、河南遗迹,早的在新石器时期,在公历纪元前2600至3500年之间;\n晚的在青铜器时期,在公历纪元前1700至2600年之间。按古代南方铜器的发明,似较北方为早,则实际上,我国开化的年代,或许还在此以前。\n●旧石器时代·刮削器和尖状器山西省阳高县许家窑遗址出土。最长的5.6厘米,最短的2.7厘米。是10万年前的石器\n中国古书上,有的把古史的年代,说得极远而且极确实的,虽然不足为凭,然因其由来甚远,亦不可不一发其覆。按《续汉书律历志》载蔡邕议历法的话,说《元命苞》、《乾凿度》都以为自开辟至获麟(获麟是《春秋》的末一年,在公元前481年),二百七十六万岁。司马贞《补三皇本纪》,则说《春秋纬》称自开辟至获麟,凡三百二十七万六千岁,分为十纪。据《汉书·律历志》刘歆的三统历法,以十九年为一章,四章为一蔀,二十蔀为一纪,三纪为一元。二百七十五万九千二百八十年,乃是六百十三元之数。《汉书·王莽传》说:莽下三万六千岁历,三万六千被乘于九十一,就是三百二十七万六千年了。这都是乡壁虚造之谈,可谓毫无历史上的根据。\n●新石器时代·白陶山东维坊姚官庄出土。温酒器,高29.7厘米。是山东龙山文化遗存\n第三章 古代的开化 # 中国俗说,最早的帝王是盘古氏。古书有的说他和天地开辟并生,有的说他死后身体变化而成日月、山河、草木等。(徐整《三五历记》说:“天地混沌如鸡子,盘古生其中。万八千岁,天地开辟,阳清为天,阴浊为地,盘古在其中……天日高一丈,地日厚一丈,盘古日长一丈。如此万八千岁,天数极高,地数极深,盘古极长。”《五运历年记》说:“首生盘古,垂死化身:气成风云,声为雷霆,左眼为日,右眼为月,四肢、五体为四极、五岳,血液为江河,筋脉为地理,肌肉为田土,发髭为星辰,皮毛为草木,齿骨为金石,精髓为珠玉,汗流为雨,身之诸虫,因风所感,化为黎虻。”)这自然是附会之辞,不足为据。《后汉书·南蛮传》说:汉时长沙、武陵蛮(长沙、武陵,皆后汉郡名。长沙,治今湖南长沙县。武陵,治今湖南常德县)的祖宗,唤做盘瓠,乃是帝喾高辛氏的畜狗。当时有个犬戎国,为中国之患。高辛氏乃下令,说有能得犬戎吴将军的头的,赏他黄金万镒,还把自己的女儿嫁给他。令下之后,盘瓠衔了吴将军的头来。遂背了高辛氏的公主,走入南山,生了六男六女,自相夫妻,成为长沙、武陵蛮的祖宗。现在广西一带,还有祭祀盘古的。闽、浙的畲民,则奉盘瓠为始祖,其画像仍作狗形。有人说:盘古就是盘瓠,这话似乎很确。但是《后汉书》所记,只是长沙、武陵一支,而据古书所载,则盘古传说,分布之地极广,而且绝无为帝喾畜狗之说(据《路史》:会昌有盘古山,湘乡有盘古堡,雩都有盘古祠,成都、淮安、京兆亦皆有盘古庙。会昌,令江西会昌县。湘乡,今湖南湘乡县。雩都,今江西雩都县。成都,今四川成都县。淮安,今江苏淮安县。京兆,今西京),则盘古、盘瓠,究竟是一是二,还是一个疑问。如其是一,则盘古本非中国民族的始祖;如其是二,除荒渺的传说外,亦无事迹可考,只好置诸不论不议之列了。\n在盘古之后,而习惯上认为很早的帝王的,就是三皇、五帝。三皇、五帝之名,见于《周官》外史氏,并没说他是谁。后来异说甚多(三皇异说:《白虎通》或说,无遂人而有祝融。《礼记·曲礼正义》说:郑玄注《中候敕省图》引《运斗枢》,无遂人而有女娲。按《淮南子·天文训》、《览冥训》,《论衡·谈天》、《顺鼓》两篇,都说共工氏触不周之山,天柱折,地维缺,女娲炼五色石以补天,断鳌足以立四极。而司马贞《补三皇本纪》说系共工氏与祝融战,则女娲、祝融一人。祝融为火神,燧人是发明钻木取火的,可见其仍系一个部族。五帝异说:则汉代的古学家,于黄帝、颛顼之间,增加了一个少昊,于是五帝变成六人。郑玄注《中候敕省图》,乃谓德合五帝坐星,即可称帝,故“实六人而为五”。然总未免牵强。东晋晚出的《伪古文尚书》的《伪孔安国传序》,乃将三皇中的燧人除去,而将黄帝上升为三皇,于是六人为五的不通,给他弥缝过去了。《伪古文尚书》今已判明其为伪,人皆不之信,东汉古学家之说,则尚未显被推翻。但古学家此说,不过欲改五德终始说之相胜为相生,而又顾全汉朝之为火德,其作伪实无以异,而手段且更拙。按五德终始之说,创自邹衍,本依五行相胜的次序。依他的说法,是虞土、夏木、殷金、周火,所以秦始皇自以为水德,而汉初自以为土德。到刘向父子出,改五德的次序为五行相生,又以汉为尧后。而黄帝的称号为黄,黄为土色,其为土德,无可移易。如此,依五帝的旧次,颛顼金德,帝喾水德,尧是木德,与汉不同德了。于其间增一少昊为金德,则颛顼水德,帝喾木德,尧为火德,与汉相同;尧以后则虞土,夏金,殷水,周木,而汉以火德承之,秦人则被视为闰位,不算入五德相承次序。这是从前汉末年发生,至后汉而完成的一套五德终始的新说,其说明见于《后汉书·贾逵传》,其不能据以言古代帝王的统系,是毫无疑义的了),其较古的,还是《风俗通》引《含文嘉》,以燧人、伏羲、神农为三皇,《史记·五帝本纪》以黄帝、颛顼、帝喾、尧、舜为五帝之说。燧人、伏羲、神农,不是“身相接”的,五帝则有世系可考。\n据《史记·五帝本纪》及《大戴礼记·帝系篇》,其统系如下:\n按五帝之说,源于五德终始,五德终始之说,创自邹衍,邹衍是齐人,《周官》所述的制度,多和《管子》相合,疑亦是齐学。古代本没有一个天子是世代相承的;即一国的世系较为连贯的,亦必自夏以后。夏、殷两代,后世的史家,都认为是当时的共主,亦是陷于时代错误的。据《史记·夏本纪》、《史记·殷本纪》所载,明明还是盛则诸侯来朝,衰则诸侯不至,何况唐、虞以上?所以三皇、五帝,只是后人造成的一个古史系统,实际上怕全不是这么一回事。但自夏以后,一国的世系,既略有可考;而自黄帝以后,诸帝王之间,亦略有不很正确的世系,总可藉以推测古史的大略了。\n古代帝王的称号,有所谓德号及地号(服虔说,见《礼记·月令》、《疏》),德号是以其所做的事业为根据的,地号则以其所居之地为根据。按古代国名、地名,往往和部族之名相混,还可以随着部族而迁移,所以虽有地号,其部族究在何处,仍难断言。至于德号,更不过代表社会开化的某阶段;或者某一个部族,特长于某种事业;并其所在之地而不可知,其可考见的真相,就更少了。然既有这些传说,究可略据之以为推测之资。传说中的帝王,较早而可考见社会进化的迹象的,是有巢氏和燧人氏。有巢氏教民构木为巢,燧人氏教民钻木取火,见于《韩非子》的《五蠹篇》。稍后则为伏羲、神农。伏羲氏始画八卦,作结绳而为网罟,以佃以渔;神农氏斫木为耜,揉木为耒,日中为市,见于《易经》的《系辞传》。有巢、燧人、神农都是德号,显而易见。伏羲氏,《易传》作包牺氏,包伏一声之转。据《风俗通》引《含文嘉》,是“下伏而化之”之意,羲化亦是一声。他是始画八卦的,大约在宗教上很有权威,其为德号,亦无疑义。这些都不过代表社会进化的一个阶段,究有其人与否,殊不可知。但各部族的进化,不会同时,某一个部族,对于某一种文化,特别进步得早,是可能有的。如此,我们虽不能说在古代确有发明巢居、取火、佃渔、耕稼的帝王,却不能否认对于这些事业,有一个先进的部族。既然有这部族,其时、地就该设法推考了。伏羲古称为太昊氏,风姓,据《左传》僖公二十一年所载,任、宿、须句、颛臾四国,是其后裔。任在今山东的济宁县,宿和须句都在东平县,颛臾在费县。神农,《礼记·月令》《疏》引《春秋说》,称为大庭氏。《左传》昭公十八年,鲁有大庭氏之库。鲁国的都城,即今山东曲阜县(《帝王世纪》说伏羲都陈,乃因左氏有“陈太昊之墟”之语而附会,不足信,见下文。又说神农氏都陈徙鲁,则因其承伏羲之后而附会的)。然则伏羲、神农,都在今山东东南部,和第一章所推测的汉族古代的根据地,是颇为相合的了。\n神农亦称炎帝,炎帝之后为黄帝,炎、黄之际,是有一次战事可以考见的,古史的情形,就更较明白了。《史记·五帝本纪》说:神农氏世衰,诸侯相侵伐,弗能征,而蚩尤氏最为暴。“黄帝乃征师诸侯,与蚩尤战于涿鹿之野,遂擒杀蚩尤。”又说:“炎帝欲侵陵诸侯,诸侯咸归轩辕。”(《史记·五帝本纪》说黄帝名轩辕,他书亦有称为轩辕氏的。按古书所谓名,兼包一切称谓,不限于名字之名。)轩辕“与炎帝战于阪泉之野,三战,然后得其志”。其说有些矛盾。《史记》的《五帝本纪》,和《大戴礼记》的《五帝德》,是大同小异的,《大戴礼记》此处,却只有和炎帝战于阪泉,而并没有和蚩尤战于涿鹿之事。神农、蚩尤,都是姜姓。《周书·史记篇》说“阪泉氏徙居独鹿”,独鹿之即涿鹿,亦显而易见。然则蚩尤、炎帝,即是一人,涿鹿、阪泉,亦系一地。《太平御览·州郡部》引《帝王世纪》转引《世本》,说涿鹿在彭城南,彭城是今江苏的铜山县(服虔谓涿鹿为汉之涿郡,即今河北涿县。皇甫谧、张晏谓在上谷,则因汉上谷郡有涿鹿县而云然,皆据后世的地名附会,不足信。汉涿鹿县即今察哈尔涿鹿县)。《世本》是古书,是较可信据的,然则汉族是时的发展,仍和鲁东南不远了。黄帝之后是颛顼,颛顼之后是帝喾,这是五帝说的旧次序。后人于其间增一少昊,这是要改五德终始之说相胜的次序为相生,又要顾全汉朝是火德而云然,无足深论。但是有传于后,而被后人认为共主的部族,在古代总是较强大的,其事迹仍旧值得考据,则无疑义。《史记·周本纪正义》引《帝王世纪》说:炎帝、黄帝、少昊,都是都于曲阜的,而黄帝自穷桑登帝位,少昊氏邑于穷桑,颛顼则始都穷桑,后徙帝丘。它说“穷桑在鲁北,或云穷桑即曲阜也”。《帝王世纪》,向来认为不足信之书,但只是病其牵合附会,其中的材料,还是出于古书的,只要不轻信其结论,其材料仍可采用。《左传》定公四年说伯禽封于少昊之墟,昭公二十年说:“少昊氏有四叔,世不失职,遂济穷桑”,则穷桑近鲁,少昊氏都于鲁之说,都非无据。帝丘地在今河北濮阳县,为后来卫国的都城。颛顼徙帝丘之说,乃因《左传》昭公十七年“卫颛顼之虚”而附会,然《左传》此说,与“陈太昊之墟”,“宋大辰之虚”,“郑祝融之虚”并举,大辰,无论如何,不能说为人名或国名(近人或谓即《后汉书》朝鲜半岛的辰国,证据未免太乏),则太昊、祝融、颛顼,亦系天神,颛顼徙都帝丘之说,根本不足信了。《史记·五帝本纪》说:黄帝正妃“嫘祖生二子,其后皆有天下。其一曰玄嚣,是为青阳,青阳降居江水”,此即后人指为少昊的。“其二曰昌意,降居若水,生高阳。”高阳即帝颛顼。后人以今之金沙江释此文的江水,鸦龙江释此文的若水,此乃大误。古代南方之水皆称江。《史记·殷本纪》引《汤诰》,说“东为江,北为济,西为河,南为淮,四渎己修,万民乃有居”,其所说的江,即明明不是长江(淮、泗、汝皆不入江,而《孟子·滕文公上篇》说禹“决汝、汉,排淮、泗,而注之江”,亦由于此)。《吕览·古乐篇》说:“帝颛顼生自若水,实处空桑,乃登为帝。”可见若水实与空桑相近。《山海经·海内经》说:“南海之内,黑水、青水之间,有木焉,名曰若木,若水出焉。”《说文》桑字作,若水之若,实当作,仍系桑字,特加以象根形,后人认为若字实误。《楚辞》的若木,亦当作桑木,即神话中的扶桑,在日出之地(此据王筠说,见《说文释例》)。然则颛顼、帝喾,踪迹仍在东方了。\n继颛顼之后的是尧,继尧之后的是舜,继舜之后的是禹。尧、舜、禹的相继,据儒家的传说,是纯出于公心的,即所谓“禅让”,亦谓之“官天下”。但《庄子·盗跖篇》有尧杀长子之说,《吕览·去私》、《求人》两篇,都说尧有十子,而《孟子·万章上篇》和《淮南子·泰族训》,都说尧只有九子,很像尧的大子是被杀的(俞正燮即因此疑之,见所著《癸巳类稿·奡证》)。后来《竹书纪年》又有舜囚尧,并偃塞丹朱,使不与尧相见之说。刘知幾因之作《疑古篇》,把尧、舜、禹的相继,看作和后世的篡夺一样。其实都不是真相。古代君位与王位不同,在第三十九章中,业经说过。尧、舜、禹的相继,乃王位而非君位,这正和蒙古自成吉思汗以后的汗位一样。成吉思汗以后的大汗,也还是出于公举的(详见第二十七章)。前一个王老了,要指定一人替代,正可见得此时各部族之间,已有较密切的关系,所以共主之位,不容空缺。自夏以后,变为父子相传,古人谓之“家天下”,又可见得被举为王的一个部族,渐次强盛,可以久居王位了。\n尧、舜、禹之间,似乎还有一件大事,那便是汉族的开始西迁。古书中屡次说颛顼、帝喾、尧、舜、禹和共工、三苗的争斗(《淮南子·天文训》、《兵略训》,都说共工与颛顼争,《原道训》说共工与帝喾争。《周书·史记篇》说:共工亡于唐氏。《书经·尧典》说:舜流共工于幽州。《荀子·议兵篇》说:禹伐共工。《书经·尧典》又说:舜迁三苗于三危。《甫刑》说:“皇帝遏绝苗民,无世在下。”皇帝,《疏》引郑注以为颛顼,与《国语》、《楚语》相合。而《战国·魏策》,《墨子》的《兼爱》、《非攻》,《韩非子》的《五蠹》,亦均载禹征三苗之事)。共工、三苗都是姜姓之国,似乎姬、姜之争,历世不绝,而结果是姬姓胜利的。我的看法,却不是如此。《国语·周语》说:“共工欲壅防百川,堕高堙卑,鲧称遂共工之过,禹乃高高下下,疏川导滞。”似乎共工和鲧,治水都是失败的,至禹乃一变其法。然《礼记·祭法德》说“共工氏之霸九州也,其子曰后土,能平九州”,则共工氏治水之功,实与禹不相上下。后人说禹治水的功绩,和唐、虞、夏间的疆域,大抵根据《书经》中的《禹贡》,其实此篇所载,必非禹时实事。《书经》的《皋陶谟》载禹自述治水之功道:“予决九川,距四海,濬畎浍距川。”九川特极言其多。四海的海字,乃晦暗之义。古代交通不便,又各部族之间,多互相敌视,本部族以外的情形,就茫昧不明,所以夷、蛮、戎、狄,谓之四海(见《尔雅·释地》,中国西北两面均无海,而古称四海者以此)。州洲本系一字,亦即今之岛字,说见第五十章。《说文》川部:“州,水中可居者。昔尧遭洪水,民居水中高土,故曰九州。”此系唐、虞、夏间九州的真相,决非如《禹贡》所述,跨今黄河、长江两流域。同一时代的人,知识大抵相类,禹的治水,能否一变共工及鲧之法,实在是一个疑问。堙塞和疏导之法,在一个小区域之内,大约共工、鲧、禹,都不免要并用的。但区域既小,无论堙塞,即疏导,亦决不能挽回水灾的大势,所以我疑心共工、鲧、禹,虽然相继施功,实未能把水患解决,到禹的时代,汉族的一支,便开始西迁了。尧的都城,《汉书·地理志》说在晋阳,即今山西的太原县。郑玄《诗谱》说他后迁平阳,在今山西的临汾县。《帝王世纪》说舜都蒲阪,在今山西的永济县。又说禹都平阳,或于安邑,或于晋阳,安邑是今山西的夏县。这都是因后来的都邑而附会。《太平御览·州郡部》引《世本》说:尧之都后迁涿鹿;《孟子·离娄下篇》说:“舜生于诸冯,迁于负夏,卒于鸣条”。这都是较古之说。涿鹿在彭城说已见前。诸冯、负夏、鸣条皆难确考。然鸣条为后来汤放桀之处,桀当时是自西向东走的,则鸣条亦必在东方。而《周书·度邑解》说:“自洛汭延于伊汭,居易无固,其有夏之居。”这虽不就是禹的都城,然自禹的儿子启以后,就不闻有和共工、三苗争斗之事,则夏朝自禹以后,逐渐西迁,似无可疑。然则自黄帝至禹,对姜姓部族争斗的胜利,怕也只是姬姓部族自己夸张之辞,不过只有姬姓部族的传说,留遗下来,后人就认为事实罢了。为什么只有姬姓部族的传说,留遗于后呢?其中仍有个关键。大约当时东方的水患,是很烈的,而水利亦颇饶。因其水利颇饶,所以成为汉族发祥之地。因其水患很烈,所以共工、鲧、禹,相继施功而无可如何。禹的西迁,大约是为避水患的。当时西边的地方,必较东边为瘠,所以非到水久治无功时,不肯迁徙。然既迁徙之后,因地瘠不能不多用人力,文明程度转而因此进步,而留居故土的部族,反落其后了。这就是自夏以后,西方的历史传者较详,而东方较为茫昧之故。然则夏代的西迁,确是古史上的一个转折,而夏朝亦确是古史上的一个界划了。\n第四章 夏殷西周的事迹 # 夏代事迹,有传于后的,莫如太康失国少康中兴一事。这件事,据《左传》、《周书》、《墨子》、《楚辞》所载(《左传》襄公四年、哀公元年,《周书·尝麦解》,《墨子·非乐》,《楚辞·离骚》),大略是如此的。禹的儿子启,荒于音乐和饮食。死后,他的儿子太康兄弟五人,起而作乱,是为五观。太康因此失国,人民和政权,都入于有穷后羿之手。太康传弟仲康,仲康传子相(夏朝此时,失掉的是王位,并非君位,所以仍旧相传)。羿因荒于游畋,又为其巨寒浞所杀。寒浞占据了羿的妻妾,生了两个儿子:一个唤做浇,一个唤做豷。夏朝这时候,依靠他同姓之国斟灌和斟寻。寒浞使浇把他们都灭掉,又灭掉夏后相。使浇住在唤做过,豷住在唤做戈的地方。夏后相的皇后,是仍国的女儿,相被灭时,正有身孕,逃归母家,生了一个儿子,是为少康。做了仍国的牧正。寒浞听得他有才干,使浇去寻找他。少康逃到虞国。虞国的国君,把两个女儿嫁给他,又把唤做纶的地方封他。有一个唤做靡的,当羿死时,逃到有鬲氏,就从有鬲氏收合斟灌、斟寻的余众,把寒浞灭掉。少康灭掉了浇,少康的儿子杼又灭掉了豷。穷国就此灭亡。这件事,虽然带些神话和传说的性质,然其匡廓尚算明白,颇可据以推求夏代的情形。旧说的释地,是全不足据的。《左传》说“后羿自迁于穷石”,又说羿“因夏民以代夏政”,则穷石即非夏朝的都城,亦必和夏朝的都城相近。《路史》说安丰有穷谷、穷水,就是穷国所在,其地在今安徽霍邱县。《汉书·地理志》《注》引应劭说:有穷是偃姓之国,皋陶之后。据《史记·五帝本纪》,皋陶之后,都是封在安徽六安一带的。过不可考。戈,据《左传》,地在宋、郑之间(见《左传》哀公十二年)。《春秋》桓公五年,天王使仍叔之子来聘,仍,《榖梁》作任,地在今山东的济宁县。虞国当系虞舜之后,旧说在今河南的虞城县。《周书》称太康兄弟五人为“殷之五子”。又说:“皇天哀禹,赐以彭寿,思正夏略。”殷似即后来的亳殷,在今河南的偃师县(即下文所引《春秋繁露》说汤作官邑于下洛之阳的。官宫二字古通用,作官邑就是造房屋和城郭。商朝的都城所在,都称为亳,此地大约本名殷,商朝所以又称殷朝)。彭寿该是立国于彭城的。按《世本》说禹都阳城,地在今河南的登封县,西迁未必能如此之速。综观自太康至少康之事,似乎夏朝的根据地,本在安徽西部,而逐渐迁徙到河南去,入于上章所引《周书》所说的“自洛汭延于伊汭”这一个区城的。都阳城该是夏朝后代的事,而不是禹时的事。从六安到霍邱,地势比较高一些,从苏北鲁南避水患而迁于此,又因战争的激荡而西北走向河南,似乎于情事还合。\n●夏·乳钉纹平底爵高22.5厘米,流至尾长31.5厘米,1975年河南偃师二里头出土\n但在这时候,东方的势力,亦还不弱,所以后来夏朝卒亡于商。商朝的始祖名契,封于商。郑玄说地在大华之阳,即今陕西的商县,未免太远。《史记·殷本纪》说:“自契至于成汤八迁。”《世本》说契居蕃,契的儿子昭明居砥石,昭明的儿子相土居商丘,扬雄《兖州牧箴》说“成汤五徙,卒归于亳”,合之恰得八数。蕃当即汉朝的蕃县,为今山东的滕县。商丘,当即后来宋国的都城,为今河南的商丘县。五迁地难悉考。据《吕览·慎大》、《具备》两篇,则汤尝居郼,郼即韦,为今河南的滑县。《春秋繁露·三代改制质文篇》说“汤受命而王,作官邑于下洛之阳”,此当即亳殷之地。《诗·商颂》说:“韦,顾既伐,昆吾,夏桀。”顾在今山东的范县。昆吾,据《左传》昭公十二年《传》楚灵王说“昔我皇祖伯父昆吾,旧许是宅”,该在今河南的许昌县,而哀公十七年,又说卫国有昆吾之观,卫国这时候,在今河北的濮阳县,则昆吾似自河北迁于河南。《史记·殷本纪》说:“汤自把钺以伐昆吾,遂伐桀。”“桀败于有娀之虚,桀奔于鸣条。”《左传》昭公四年“夏桀为仍之会,有缗叛之”,《韩非子·十过篇》亦有这话,仍作娀,则有娀,即有仍。鸣条为舜卒处,已见上章。合观诸说,商朝似乎兴于今鲁、豫之间,汤先平定了河南的北境,然后向南攻桀,桀败后是反向东南逃走的。观桀之不向西走而向东逃,可见此时伊、洛以西之地,还未开辟。\n据《史记》的《夏本纪》、《殷本纪》,夏朝传国共十七代,商朝则三十代。商朝的世数所以多于夏,大约是因其兼行兄终弟及之制而然。后来的鲁国,自庄公以前,都是一生一及,吴国亦有兄终弟及之法,请见第三十八章,这亦足以证明商朝的起于东方。商朝的事迹,较夏朝传者略多。据《史记》:成汤以后,第四代大甲,第九代大戊,第十三代祖乙,第十九代盘庚,第二十二代武丁,都是贤君,而武丁之时,尤其强盛。商朝的都城,是屡次迁徙的。第十代仲丁迁于隞地,在今河南荥泽县(隞,《书序》作嚣,《书序》不一定可信,所以今从《史记》。隞的所在,亦有异说。但古书皆东周至汉的人所述,尤其大多数是汉朝人写下来的,所以用的大抵多是当时的地名,所以古书的释地,和东周、秦、汉时地名相近的,必较可信。如隞即敖,今之荥泽县,为秦汉间敖仓所在,以此释仲丁所迁之隞,确实性就较大些。这是治古史的通例,不能一一具说,特于此发其凡)。第十二代河亶甲居相,在今河南内黄县。第十三代祖乙迁于邢,在今河北邢台县。到盘庚才迁回成汤的旧居亳殷。第二十七代武乙,复去亳居河北。今河南安阳县北的小屯村,即发现龟甲兽骨之处,据史学家所考证,其地即《史记·项羽本纪》所谓殷墟,不知是否武乙时所都。至共第三十代即最后一个君主纣,则居于朝歌,在今河南淇县。综观商朝历代的都邑,都在今河南省里的黄河两岸,还是汤居郼,营下洛之阳的旧观。周朝的势力,却更深入西北部了。\n周朝的始祖名弃,是舜之时居稷官的,封于邰。历若干代,至不窋,失官,奔于戎狄之间。再传至公刘,居邠,仍从事于农业。又十传至古公亶父,复为狄所逼,徙岐山下。邰,旧说是今陕西的武功县。邠是今陕西的邠县,岐是今陕西的岐山县。近人钱穆说,《左传》昭公元年说金天氏之裔子台骀封于汾川,《周书·度邑篇》说武王升汾之阜以望商邑,汾即邠,邰则因台骀之封而得名,都在今山西境内。亶父逾梁山而至岐,梁山在今陕西韩城县,岐山亦当距梁山不远(见所著《周初地理考》)。据他这说法,则后来文王居丰,武王居镐,在今陕西鄠县界内的,不是东下,乃是西上了。河、汾下流和渭水流域,地味最为肥沃,周朝是农业部族,自此向西拓展,和事势是很合的。古公亶父亦称太王,周至其时始强盛。传幼子季历以及文王,《论语》说他“三分天下有其二”(见《泰伯下篇》)。文王之子武王,遂灭纣。文王时曾打破耆国,而殷人振恐,武王则渡孟津而与纣战,耆国,在今山西的黎城县,自此向朝歌,乃今出天井关南下的隘道,孟津在今河南孟县南,武王大约是出今潼关到此的,这又可以看出周初自西向东发展的方向。然武王虽胜纣,并未能把商朝灭掉,仍以纣地封其子武庚,而使其弟管叔、蔡叔监之。武王崩,子成王幼,武王弟周公摄政,管、蔡和武庚都叛。据《周书·作雒解》,是时叛者,又有徐、奄及熊、盈。徐即后来的徐国,地在泗水流域,奄即后来的鲁国,熊为楚国的氏族,盈即嬴,乃秦国的姓。可见东方诸侯,此时皆服商而不服周。然周朝此时,颇有新兴之气。周公自己东征,平定了武庚和管叔、蔡叔,灭掉奄国。又使其子伯禽平定了淮夷、徐戎。于是封周公于鲁,使伯禽就国,又封太公望于齐,又经营今洛阳之地为东都,东方的旧势力,就给西方的新势力压服了。周公平定东方之后,据说就制礼作乐,摄政共七年,而归政于成王。周公死后,据说又有所谓“雷风之变”。这件事情,见于《书经》的《金縢篇》。据旧说:武王病时,周公曾请以身代,把祝策藏在金縢之匮中。周公死,成王葬以人臣之礼。天大雷雨,又刮起大风,田禾都倒了,大木也拔了出来。成王大惧,开金縢之匮,才知道周公请代武王之事,乃改用王礼葬周公,这一场灾异,才告平息。据郑玄的说法,则武王死后三年,成王服满了,才称自己年纪小,求周公摄政。摄政之后,管叔、蔡叔散布谣言,说周公要不利于成王,周公乃避居东都。成王尽执周公的属党。遇见了雷风之变,才把周公请回来。周公乃重行摄政。此说颇不合情理,然亦不会全属子虚。《左传》昭公七年,昭公要到楚国去,梦见襄公和他送行。子服惠伯说:“先君未尝适楚,故周公祖以道之,襄公适楚矣,而祖以道君。”据此,周公曾到过楚国,而《史记·蒙恬列传》,亦有周公奔楚之说,我颇疑心周公奔楚及其属党被执,乃是归政后之事。后来不知如何,又回到周朝。周公是否是善终,亦颇有可疑,杀害了一个人,因迷信的关系,又去求媚于他,这是野蛮时代常有的事,不足为怪。如此,则两说可通为一。楚国封于丹阳,其地实在丹、淅两水的会口(宋翔凤说,见《过庭录·楚鬻熊居丹阳武王徙郢考》),正当自武关东南出之路,据周公奔楚一事,我们又可见得周初发展的一条路线了。\n成王和他的儿子康王之时,称为西周的盛世。康王的儿子昭王,“南巡守不返,卒于江上”(《史记·周本纪》文)。这一个江字,也是南方之水的通称。其实昭王是伐楚而败,淹死在汉水里的,所以后来齐桓公伐楚,还把这件事情去诘问楚国(见《左传》僖公四年)。周朝对外的威力,开始受挫了。昭王子穆王,西征犬戎。其时徐偃王强,《后汉书·东夷传》谓其“率九夷以伐宗周,西至河上”。《后汉书》此语,未知何据(《博物志》亦载徐偃王之事,但《后汉书》所据,并不就是《博物志》,该是同据某一种古说的)。《礼记·檀弓下篇》载徐国容居的话,说“昔我先君驹王,西讨济于河”。驹王疑即偃王,则《后汉书》之说亦非全属子虚,被压服的东方,又想恢复其旧势了。然穆王使楚伐徐,偃王走死,则仍为西方所压服。穆王是周朝的雄主,在位颇久,当其时,周朝的声势,是颇振起的,穆王死后,就无此盛况了。穆王五传至厉王,因暴虐,为国人所逐,居外十四年。周朝的卿士周公、召公当国行政,谓之共和。厉王死于外,才立其子宣王。宣王号称中兴,然其在位之三十九年,与姜氏之戎战于千,为其所败。千在今山西的介休县,则周朝对于隔河的地方,业经控制不住,西方戎狄的势力,也渐次抬头了。至子幽王,遂为犬戎和南阳地方的申国所灭。幽王灭亡的事情,《史记》所载的,恢诡有类平话,决不是真相。《左传》昭公二十六年,载周朝的王子朝告诸侯的话,说这时候“携王干命,诸侯替之,而建王嗣,用迁郏鄏”(即东都之地,见《左传》宣公三年)。则幽王死后,西畿之地,还有一个携王。周朝当时,似乎是有内忧兼有外患的。携王为诸侯所废,周朝对于西畿之地,就不能控制了。而且介休败了;出武关向丹、淅的路,又已不通,只有对于东畿,还保存着相当的势力。平王于是迁居洛阳,号称东周,其事在公元前770年。\n第五章 春秋战国的竞争和秦国的统一 # 文化是从一个中心点,逐渐向各方面发展的。西周以前所传的,只有后世认为共主之国一个国家的历史,其余各方面的情形,都很茫昧。固然,书阙有间,不能因我们之无所见而断言其无有,然果有文化十分发达的地方,其事实也决不会全然失传的,于此,就可见得当时的文明,还是限于一个小区域之内了。东周以后则不然,斯时所传者,以各强国和文化较发达的地方的事迹为多,所谓天子之国,转若在无足重轻之列。原来古代所谓中原之地,不过自泰岱以西,华岳以东,太行以南,淮、汉以北,为今河南、山东的大部分,河北、山西的小部分。渭水流域的开发,怕还是西周兴起以来数百年间之事。到春秋时代,情形就大不然了。当时号称大国的,有晋、楚、齐、秦,其兴起较晚的,则有吴、越,乃在今山西的西南境,山东的东北境,陕西的中部,甘肃的东部,及江苏、浙江、安徽之境。在向来所称为中原之地的鲁、卫、宋、郑、陈、蔡、曹、许等,反夷为二三等国了。这实在是一个惊人的文化扩张。其原因何在呢?居于边地之国,因为和异族接近,以竞争磨砺而强,而其疆域亦易于拓展,该是其中最主要的。\n“周之东迁,晋、郑焉依。”(见《左传》隐公六年)即此便可见得当时王室的衰弱。古代大国的疆域,大约方百里,至春秋时则夷为三等国,其次等国大约方五百里,一等国则必方千里以上,请见第三十九章。当西周之世,合东西两畿之地,优足当春秋时的一个大国而有余,东迁以后,西畿既不能恢复,东畿地方,又颇受列国的剥削,周朝自然要夷于鲁、卫了。古语说“天无二日,民无二王”,这只是当时的一个希望。事实上,所谓王者,亦不过限于一区域之内,并不是普天之下,都服从他的。当春秋时,大约吴、楚等国称雄的区域,原不在周朝所管辖的范围内,所以各自称王。周天子所管辖的区域,因强国不止一个,没有一国能尽数慑服各国,所以不敢称王,只得以诸侯之长,即所谓霸主自居,这话在第三十九章中,亦已说过。所以春秋时代,大局的变迁,系于几个霸国手里。春秋之世,首起而称霸的是齐桓公。当时异民族杂居内地的颇多,也有相当强盛的,同族中的小国,颇受其压迫。(一)本来古代列国之间,多有同姓或婚姻的关系。(二)其不然的,则大国受了小国的朝贡,亦有加以保护的义务。(三)到这时候,文化相同之国,被文化不同之国所压迫,而互相救援,那更有些甫有萌芽的微茫的民族主义在内了。所以攘夷狄一举,颇为当时之人所称道。在这一点上,齐桓公的功绩是颇大的。他曾却狄以存邢、卫,又尝伐山戎以救燕(这个燕该是南燕,在今河南的封丘县。《史记》说它就是战国时的北燕,在今河北蓟县,怕是弄错了的,因为春秋时单称为燕的,都是南燕。即北燕的初封,我疑其亦距封丘不远,后来才迁徙到今蓟县,但其事无可考)。而他对于列国,征伐所至亦颇广。曾南伐楚,西向干涉晋国内乱,晚年又曾经略东夷。古人说“五霸桓公为盛”,信非虚语了。齐桓公的在位,系自前685至643年。桓公死后,齐国内乱,霸业遽衰。宋襄公欲继之称霸。然宋国较小,实力不足,前638年,为楚人所败,襄公受伤而死,北方遂无霸主。前632年,晋文公败楚于城濮(今山东濮县),楚国的声势才一挫。此时的秦国,亦已尽取西周旧地,东境至河,为西方一强国,然尚未能干涉中原之事。秦穆公初和晋国竞争不胜,前624年,打败了晋国的兵,亦仅称霸于西戎。中原之地,遂成为晋、楚争霸之局。前597年,楚庄王败晋于邲(今河南郑县),称霸。前591年卒。此时齐顷公亦图与晋争霸。前589年,为晋所败。前575年,晋厉公又败楚于鄢陵(今河南鄢县)。然楚仍与晋兵争不息。至前561年,楚国放弃争郑,晋悼公才称复霸。前546年,宋大夫向戌,善于晋、楚的执政,出而合二国之成,为弭兵之会,晋、楚的兵争,至此才告休息。自城濮之战至此,凡八十七年。弭兵盟后,楚灵王强盛,北方诸侯多奔走往与其朝会。然灵王奢侈而好兵争,不顾民力,旋因内乱被弑。此时吴国日渐强盛,而楚国政治腐败,前506年,楚国的都城,为吴阖闾所破,楚昭王藉秦援,仅得复国,楚国一时陷于不振,然越国亦渐强,起而乘吴之后。前496年,阖闾伐越,受伤而死。前494年,阖闾子夫差破越。夫差自此骄侈,北伐齐、鲁,与晋争长于黄池(今河南封丘县),前473年,越勾践灭吴,越遂徙都琅邪,与齐、晋会于徐州(今山东诸城县),称为霸王。然根基因此不固,至前333年而为楚所灭。\n此时已入于战国之世了(春秋时代,始于周平王四十九年,即鲁隐公元年,为公元前722年,终于前481年,共242年。其明年为战国之始,算至前222年秦灭六国的前一年为止,共259年)。春秋之世,诸侯只想争霸,即争得二三等国的服从,一等国之间,直接的兵争较少,有之亦不过疆场细故,不甚剧烈。至战国时,则(一)北方诸侯,亦不复将周天子放在眼里,而先后称王。(二)二三等国,已全然无足重轻,日益削弱,而终至于夷灭,诸一等国间,遂无复缓冲之国。(三)而其土地又日广,人民又日多,兵甲亦益盛,战争遂更烈。始而要凌驾于诸王之上而称帝,再进一步,就要径图并吞,实现统一的欲望了。春秋时的一等国,有发展过速,而其内部的组织,还不甚完密的,至战国时,则臣强于君的,如齐国的田氏,竟废其君而代之;势成分裂的,如晋之赵、韩、魏三家,则索性分晋而独立。看似力分而弱,实则其力量反更充实了。边方诸国,发展的趋势,依旧进行不已,其成功较晚的为北燕。天下遂分为燕、齐、赵、韩、魏、秦、楚七国。六国都为秦所并,读史的人,往往以为一入战国,而秦即最强,这是错误了的。秦国之强,起于献公而成于孝公,献公之立,在公元前385年,是入战国后的九十六年,孝公之立,在公元前361年,是入战国后的一百二十年了。先是魏文侯任用吴起等贤臣,侵夺秦国河西之地。后来楚悼王用吴起,南平百越,北并陈、蔡,却三晋,西伐秦,亦称雄于一时。楚悼王死于公元前381年,恰是入战国后的一百年,于是楚衰而魏惠王起,曾攻拔赵国的邯郸(今河北邯郸县)。后又伐赵,为齐救兵所败,秦人乘机恢复河西,魏遂弃安邑,徙都大梁(今河南开封县)。秦人渡蒲津东出的路,就开通了。然前342年,魏为逢泽之会(在开封)。《战国·秦策》称其“乘夏车,称夏王(此夏字该是大字的意思),朝天子,天下皆从”,则仍处于霸主的地位。其明年,又为齐所败。于是魏衰而齐代起,宣王、湣王两代,俨然称霸东方,而湣王之时为尤盛。相传苏秦约六国,合纵以摈秦,即在湣王之时。战国七雄,韩、魏地都较小,又逼近秦,故其势遂紧急,燕、赵则较偏僻,国势最盛的,自然是齐、秦、楚三国。楚袭春秋以来的声势,其地位又处于中部,似乎声光更在齐、秦之上,所以此时,齐、秦二国似乎是合力以谋楚的。《战国策》说张仪替秦国去骗楚怀王:肯绝齐,则送他商於的地方六百里(即今商县之地)。楚怀王听了他,张仪却悔约,说所送的地方,只有六里。怀王大怒,兴兵伐秦。两次大败,失去汉中。后来秦国又去诱他讲和,前299年,怀王去和秦昭王相会,遂为秦人所诱执。这种类乎平话的传说,是全不足信的,事实上,该是齐、秦合力以谋楚。然而楚怀王入秦的明年,齐人即合韩、魏以伐秦,败其兵于函谷(在今河南灵宝县西南,此为自河南入陕西的隘道的东口,今之潼关为其西口)。前296年,怀王死于秦,齐又合诸侯以攻秦;则齐湣王似是合秦以谋楚,又以此为秦国之罪而伐之的,其手段亦可谓狡黠了。先是前314年,齐国乘燕内乱攻破燕国。宋王偃称强东方,前286年,又为齐、楚、魏所灭。此举名为三国瓜分,实亦是以齐为主的,地亦多入于齐。齐湣王至此时,可谓臻于极盛。然过刚者必折。前284年,燕昭王遂合诸侯,用乐毅为将,攻破齐国,湣王走死,齐仅存聊、莒、即墨三城(聊,今山东聊城县。莒,今山东莒县。即墨,今山东平度县)。后来虽藉田单之力,得以复国,然已失其称霸东方的资格了。东方诸国中,赵武灵王颇有才略。他不与中原诸国争衡,而专心向边地开拓。先灭中山(今河北定县),又向今大同一带发展,意欲自此经河套之地去袭秦。前295年,又因内乱而死。七国遂惟秦独强。秦人遂对诸侯施其猛烈的攻击。前279年,秦白起伐楚,取鄢、邓、西陵。明年,遂破楚都郢,楚东北徙都陈,后又迁居寿春(鄢,即鄢陵。邓,今河南邓县。西陵,今湖北宜昌县。郢,今湖北江陵县西北。吴阖庐所入之郢,尚不在江陵,但其地不可考,至此时之郢,则必在江陵,今人钱穆、童书业说皆如此),直逃到今安徽境内了。对于韩、魏,亦时加攻击。前260年,秦兵伐韩,取野王,上党路绝,降赵,秦大败赵兵于长平,坑降卒四十万(野王,今河南沁阳县。上党,今山西晋城县。长平,今山西长平县),遂取上党,北定太原。进围邯郸,为魏公子无忌合诸国之兵所败。前256年,周朝的末主赧王为秦所灭。前249年,又灭其所分封的东周君。前246年,秦始皇立。《史记·秦本纪》说,这时候,吕不韦为相国,招致宾客游士,欲以并天下。大概并吞之计,和吕不韦是很有关系的。后来吕不韦虽废死于蜀,然秦人仍守其政策不变。前230年,灭韩,前228年,灭赵。燕太子丹使荆轲刺秦王,不中,秦大发兵以攻燕。前226年,燕王喜夺辽东。前225年,秦人灭魏。前223年,灭楚。前222年,发兵攻辽东,灭燕。前221年,即以灭燕之兵南灭齐,而天下遂统一。\n秦朝的统一,决不全是兵力的关系。我们须注意:此时交通的便利,列国内部的发达,小国的被夷灭,郡县的渐次设立,在政治上、经济上、文化上,本有趋于统一之势,而秦人特收其成功。秦人所以能收成功之利,则(一)它地处西垂,开化较晚,风气较为诚朴。(二)三晋地狭人稠,秦地广人稀,秦人因招致三晋之民,使之任耕,而使自己之民任战。(三)又能奉行法家的政策,裁抑贵族的势力,使能尽力于农战的人民,有一个邀赏的机会。该是其最重要的原因。\n●秦始皇\n第六章 古代对于异族的同化 # 中国民族,以同化力的伟大闻于天下,究竟我们对于异族的同化,是怎样一回事呢?说到这一点,就不能不着眼于中国的地理。亚洲的东部,在世界上,是自成其为一个文化区域的。这一个区域,以黄河、长江两流域为其文化的中心。其北为蒙古高原,便于游牧民族的住居。其南的粤江、闽江两流域,则地势崎岖,气候炎热,开化虽甚早,进步却较迟。黄河、长江两流域,也不是没有山地的,但其下流,则包括淮水流域(以古地理言之,则江、河之间,包括淮、济二水。今黄河下流,为古济水入海之道,黄河则在今天津入海),扩展为一大平原,地味腴沃,气候适宜,这便是中国民族的文化最初函毓之处。汉族,很早的就是个农耕民族,惯居于平地。其所遇见的民族,就其所居之地言之,可以分为两种:一种是住在山地的,古代称为山戎,多数似亦以农为业,但其农业不及中国的进步。一种是住在平地,大约是广大的草原上,而以畜牧为业的,古人称为骑寇。春秋以前,我族所遇的,以山戎为多,战国以后,才开始和骑寇接触。\n夷、蛮、戎、狄,是按着方位分别之辞,并不能代表民族,但亦可见得一个大概。在古代,和中国民族争斗较烈的,似乎是戎狄。据《史记·五帝本纪》,黄帝就北逐獯粥,未知确否(如《史记》此说是正确的,则当时的獯粥,决不在后来的獯粥所在之地)。到周朝初年,则和所谓獯粥或称为猃狁,犬戎或称为昆夷、串夷的,争斗甚烈(猃狁亦作狁,犬戎亦作畎戎,戎又作夷。此犬或畎字乃译音,非贱视诋毁之辞,昆夷亦作混夷、绲夷,夷亦可作戎,和串夷亦都是犬字的异译,说见《诗经·皇矣正义》),而后来周朝卒亡于犬戎。犬戎在今陕西的中部,甘肃的东部,泾、渭二水流域间,东周以后,大约逐渐为秦人所征服。在其东方的,《春秋》所载,初但称狄,后分为赤狄、白狄。白狄在今陕西境内,向东蔓延到中山。赤狄在今山西、河北境内,大部为晋所并(据《左传》和杜预《注》,赤狄种类凡六:曰东山皋落氏,在今山西昔阳县。曰廧咎如,在今山西乐平县。曰潞氏,在今山西潞城县。曰甲氏,在今河北鸡泽县。曰留吁,在今山西屯留县。曰铎辰,在今山西长治县。白狄种类凡三:曰鲜虞,即战国时的中山。曰肥,在今河北藁城县。曰鼓,在今河北晋县。又晋国吕相绝秦,说“白狄及君同州”,则白狄亦有在陕西的)。在周朝的西面的,主要的是后世的氐、羌。氐人在今嘉陵江流域,即古所谓巴。羌人,汉时在今黄河、大通河流域(大通河,古湟水)。据《后汉书》所载,其初本在黄河之东,后来为秦人所攘斥,才逃到黄河以西去的。据《书经·牧誓》,羌人曾从武王伐纣。又《尚书大传》说:武王伐纣的兵,前歌后舞,《后汉书》说这就是汉时所谓巴氐的兵。这话大约是对的,因为汉世还有一种出于巴氐的巴渝舞,有事实为证。然则这两族,其初必不在今四川、甘肃境内,大约因汉族的开拓,而向西南方走去的。和巴连称的蜀,则和后世的贝字是一音之转,亦即近世之所谓暹。据《牧誓》,亦曾从武王伐纣。战国时,还在今汉中之境,南跨成都。后因和巴人相攻,为秦国所并。\n在东北方的民族,古称为貉。此族在后世,蔓衍于今朝鲜半岛之地,其文明程度是很高的。但《诗经》已说“王锡韩侯,其追其貉”(《韩奕》。追不可考),《周官》亦有貉隶,可见此族本在内地,箕子所封的朝鲜,决不在今朝鲜半岛境内,怕还在山海关以内呢!在后世,东北之族,还有肃慎,即今满洲人的祖宗。《左传》昭公九年,周朝人对晋国人说:“自武王克商以来,肃慎、燕、亳,吾北土也。”此燕当即南燕,亳疑即汤所居之郼,则肃慎亦在内地,后乃随中国的拓展而东北徙。《国语·晋语》说:成王会诸侯于岐阳,楚与鲜卑守燎,则鲜卑本是南族,后来不知如何,也迁向东北了。据《后汉书》说:鲜卑和乌丸,都是东胡之后。此二族风俗极相像,其本系一个部落,毫无可疑。东胡的风俗,虽少可考,然汉代历史,传者已较详,汉人说它是乌丸、鲜卑所自出,其说该不至误。南族断发,鲜卑婚姻时尚先髠头,即其源出南族之证。然则东胡也是从内地迁徙出去的了。\n在南方的有黎族,此即后世所谓俚。古称三苗为九黎之君,三苗系姜姓之国,九黎则系黎民(见《礼记·缁衣》《疏》引《书经·吕刑》郑《注》)。此即汉时之长沙武陵蛮,为南蛮的正宗。近世所云苗族,乃蛮字的转音,和古代的三苗之国无涉,有人将二者牵合为一,就错了。《史记》说三苗在江、淮、荆州(《史记·五帝本纪》),《战国·魏策》,吴起说三苗之国,在洞庭、彭蠡之间(《史记·吴起列传》同,又见《韩诗外传》)。则古代长江流域之地,主要的是为黎族所占据,楚国达到长江流域后,所开辟的,大约是这一族的居地。在沿海一带的,古称为越,亦作粤。此即现在的马来人,分布在亚洲大陆的沿岸,和南洋群岛,地理学上称为亚洲大陆的真沿边的。此族有断发文身和食人两种风俗,在后世犹然,古代沿海一带,亦到处有这风俗,可知其为同族。吴、越的初期,都是和此族杂居的。即淮水流域的淮夷、徐戎,山东半岛的莱夷,亦必和此族相杂(《礼记·王制》说:“东方曰夷,被发文身”,此被字为髲字之假错字,即断发,可见蛮夷之俗相同。《左传》僖公十九年,“宋公使邾文公用鄫子于次睢之社,欲以属东夷”,可见东夷亦有食人之俗。《续汉书·郡国志》:“临沂有丛亭。”《注》引《博物志》曰:“县东界次睢,有大丛社,民谓之食人社,即次睢之社。”临沂,今山东临沂县)。随着吴、越等国的进步,此族亦渐进于文明了。西南的大族为濮,此即现在的倮。其居地,本在今河南、湖北两省间(《国语·郑语》韦《注》:濮为南阳之国)。楚国从河南的西南部,发展向今湖北省的西部,所开辟的,大约是此族的居地。此族又从今湖北的西南境,向贵州、云南分布。战国时,楚国的庄,循牂牁江而上,直达滇国(今云南昆明县),所经的,也是这一族之地。庄到滇国之后,楚国的巴、黔中郡(巴郡,今四川江北县。黔中郡,今湖南沅陵县),为秦国所夺,庄不能来,就在滇国做了一个王。其地虽未正式收人中国的版图,亦已戴汉人为君了,和现在西南土司,以汉人为酋长的一样了。\n《礼记·王制》说:古代的疆域,“北不尽恒山”,此所谓恒山,当在今河北正定县附近,即汉朝恒山郡之地(后避文帝讳改常山)。自此以南的平地,为汉族所居,这一带山地,则山戎所处,必得把它开拓了,才会和北方骑寇相接,所以汉族和骑寇的接触,必在太原、中山和战国时北燕之地开辟以后。做这件事业的,就是燕、赵两国。赵武灵王开辟云中、雁门、代郡,燕国则开辟上谷、渔阳、右北平、辽西、辽东五郡(云中,今山西大同县。雁门,今山西右玉县。代郡,今山西代县。上谷,今察哈尔怀来县。渔阳,今河北密云县。右北平,今河北卢龙县。辽西,今河北抚宁县。辽东,今辽宁辽阳县),把现在热、察、绥、辽宁四省,一举而收入版图。\n综观以上所述,汉族恃其文化之高,把附近的民族,逐渐同化,而汉族的疆域,亦即随之拓展。和汉族接近的民族,当汉族开拓时,自然也有散向四方,即汉族的版图以外去的,然亦多少带了些中原的文化以俱去,这又是中国的文化扩展的路径。这便是在古代中国同化异民族的真相。\n第七章 古代社会的综述 # 周和秦,是从前读史的人看作古今的界线的。我们任意翻阅旧书,总可见到“三代以上”,“秦、汉以下”等辞句。前人的见解,固然不甚确实,也不会全属虚诬;而且既有这个见解,也总有一个来历。然则所谓三代以上,到底是怎样一个世界呢?\n●清源山老君造像刻于宋代,位于福建泉州,高5.63米,厚6.85米,宽8.01米,席地面积55平方米\n人,总是要维持其生命的;不但要维持生命,还要追求幸福,以扩大其生命的意义;这是人类的本性如此,无可怀疑。人类在生物史上,其互相团结,以谋生存,已不知其若干年了。所以其相亲相爱,看得他人的苦乐,和自己的苦乐一般;喜欢受到同类的嘉奖,而不愿意受到其批评;到人己利害不相容时,宁可牺牲自己,以保全他人;即古人之所谓仁心者,和其爱自己的心,一样的深刻。专指七尺之躯为我,或者专指一个极小的团体为我,实在是没有这回事的。人类为要维持生命,追求幸福,必得和自然斗争。和自然斗争,一个人的力量,自然是不够的,于是乎要合力;合力之道,必须分工,这都是自然的趋势。分工合力,自然是范围愈大,利益愈多,所以团体的范围,总是在日扩而大。但是人类的能力是有限的,在进行中,却不能不形成敌对的状态,这是为什么呢?皇古之世,因环境的限制,把人类分做许多小团体。在一个团体之中,个个人的利害,都是相同的,在团体以外却不然;又因物质的欲求,不能够都给足;团体和团体间就开始有争斗,有争斗就有胜败,有胜败就有征服者和被征服者之分。“人不可以害人的,害人的必自害。”这句话,看似迂腐,其实却是真理。你把迷信者流因果报应之说去解释这句话,自然是诬罔的,若肯博观事实,而平心推求其因果,那正见得其丝毫不爽。对内竞争和对外竞争,虽竞争的对象不同,其为竞争则一。既然把对物的争斗,移而用之于对人,自可将对外的争斗,移而用之于对内。一个团体之中,有征服者和被征服者之分,不必说了。即使无之,而当其争斗之时,基于分工的关系,自然有一部分人,专以战争为事,这一部分人,自将处于特殊的地位。前此团体之中,个个人利害相同的,至此则形成对立。前此公众的事情,是由公众决定的,至此,则当权的一个人或少数人,渐渐不容公众过问,渐渐要做违背公众利益的措置,公众自然不服,乃不得不用强力镇压,或者用手段对付。于是团体之中有了阶级,而形成现代的所谓国家。以上所述,是从政治上立论的。其变迁的根源,实由于团体和团体的互相争斗,而团体和团体的互相争斗,则由于有些团体,迫于环境,以掠夺为生产的手段。所以其真正的根源,还是在于经济上。经济的根柢是生产方法。在古代,主要的生业是农业,农业的生产方法,是由粗而趋于精,亦即由合而趋于分的,于是形成了井田制度,因而固定了五口、八口的小家族,使一个团体之中,再分为无数利害对立的小团体。从前在一个团体之内,利害即不再对立的氏族制度,因此而趋于崩溃了。氏族既已崩溃,则专门从事于制造,而以服务性质,无条件供给大众使用的工业制度,亦随之而崩溃。人,本来是非分工合力不能生存的,至此时,因生活程度的增高,其不能不互相倚赖愈甚,分配之法既废,交易之法乃起而代之,本行于团体与团体之间的商业,乃一变而行于团体之内人与人之间,使人人的利害,都处于对立的地位。于是乎人心大变。在从前,团体与团体之间,是互相嫉视的,在一个团体之内,是互视为一体的。至此时,团体之内,其互相嫉视日深。在团体与团体之间,却因生活的互相倚赖而往来日密,其互相了解的程度,即随之而日深,同情心亦即随之而扩大。又因其彼此互相仿效,以及受了外部的影响,而内部的组织,不得不随之而起变化,各地方的风俗亦日趋于统一。民族的同化作用,即缘此而进行。政治上的统一,不过是顺着这种趋势推进。再彻底些说,政治上的统一,只是在当时情况之下,完成统一的一个方法。并不是政治的本身,真有多大的力量。随着世运的进展,井田制度破坏了。连公用的山泽,亦为私人所占。工商业愈活跃,其剥削消费者愈深。在上的君主和贵族,亦因其日趋于腐败、奢侈,而其剥削人民愈甚。习久于战争就养成一种特别阶级,视战斗为壮快、征服为荣誉的心理,认为与其出汗,毋宁出血。此即孔子和其余的先秦诸子所身逢的乱世。追想前一个时期,列国之间,战争还不十分剧烈。一国之内,虽然已有阶级的对立,然前此利害共同时的旧组织,还有存留,而未至于破坏净尽。秩序还不算十分恶劣,人生其间的,也还不至于十分痛苦,好像带病延年的人,虽不能算健康,还可算一个准健康体,此即孔子所谓小康。再前一个时期,内部毫无矛盾,对外毫无竞争,则即所谓大同了。在大同之世,物质上的享受,或者远不如后来,然而人类最亲切的苦乐,其实不在于物质,而在于人与人间的关系,所以大同时代的境界,永存于人类记忆之中。不但孔子,即先秦诸子,亦无不如此(道家无论已,即最切实际的法家亦然。如《管子》亦将皇、帝、王、霸分别治法的高下;《史记·商君列传》亦载商君初说秦孝公以帝王之道,秦孝公不能用,乃说之以富国强兵之术都是)。这不是少数人的理想高尚,乃是受了大多数人的暗示而然的。人类生当此际,实应把其所以致此之由,彻底地加以检讨,明白其所以然之故,然后将现社会的组织,摧毁之而加以改造。这亦非古人所没有想到,先秦诸子,如儒、墨、道、法诸家,就同抱着这个志愿的,但其所主张的改革的方法,都不甚适合。道家空存想望,并没有具体实行的方案的,不必说了。墨家不讲平均分配,而专讲节制消费,也是不能行的。儒家希望恢复井田,法家希望制止大工商业的跋扈;把大事业收归官营;救济事业亦由国家办理,以制止富豪的重利盘剥,进步些了。然单讲平均地权,本不能解决社会的经济问题,兼讲节制资本,又苦于没有推行的机关。在政治上,因为民主政治,废坠的久了,诸家虽都以民为重,却想不出一个使人民参与政治的办法,而只希望在上者用温情主义来抚恤人民,尊重舆论,用督责手段,以制止臣下的虐民。在国与国之间,儒家则希望有一个明王出来,能够处理列国间的纷争,而监督其内政;法家因为兴起较后,渐抱统一的思想,然秦朝的统一,和贵族的被裁抑,都只是事势的迁流,并不能实行法家的理想,所以要自此再进一步,就没有办法了。在伦理上,诸家所希望的,同是使下级服从上级,臣民该服从君主,儿子要服从父亲,妇女要服从男子,少年该服从老人。他们以为上级和下级的人,各安其分,各尽其职,则天下自然太平,而不知道上级的人受不到制裁,决不会安其分而尽其职。总而言之:小康之世,所以向乱世发展,是有其深刻的原因的。世运只能向前进,要想改革,只能顺其前进的趋势而加以指导。先秦诸子中,只有法家最看得出社会前进的趋势,然其指导亦未能全然得法。他家则都是想把世运逆挽之,使其回到小康以前的时代的,所以都不能行。\n●孔子像\n虽然如此,人类生来是避苦求乐的,身受的苦痛,是不能使人不感觉的,既然感觉了,自然要求摆脱。求摆脱,总得有个办法,而人类凭空是想不出办法来的。世运只有日新,今天之后,只会有明天,而人所知道的,最新亦只是今日以前之事,于是乎想出来的办法,总不免失之于旧,这个在今日尚然,何况古代?最好的时代是过去了,但永存于人类想望记忆之中。虽回忆之,而并不知其真相如何,乃各以其所谓最好者当之。合众人的所谓最好者,而调和折衷,造成一个大略为众所共认的偶像,此即昔人所谓三代以前的世界。这个三代以前的世界,其不合实际,自然是无待于言的。这似乎只是一个历史上的误解,无甚关系,然奉此开倒车的办法为偶像而思实践之,就不徒不能达到希望,而且还要引起纠纷。\n第八章 秦朝治天下的政策 # 秦始皇尽灭六国,事在公元前221年,自此至公元189年,董卓行废立,东方州郡,起兵讨卓,海内扰乱分裂,共四百年,称为中国的盛世。在这一时期之中,中国的历史,情形是怎样呢?“英雄造时势”,只是一句夸大的话。事实上,英雄之所以成为英雄,正因其能顺着时势,进行之故。“时势造英雄”这句话倒是真的,因为他能决定英雄的趋向。然则在这一个时期之内,时势的要求,是怎样呢?依我们所见到的,可以分为对内对外两方面:对内方面,在列国竞争之时,不能注全力于内治;即使注意到,亦只是局部的问题,而不能概括全体,只是一时的应付,而不能策划永久。统一之后,就不然了。阻碍之力既去,有志于治平的,就可以行其理想。对外方面,当时的人看中国,已经是天下的一大部分了。未入版图的地方,较强悍的部落,虑其为中国之患,该有一个对策;较弱小的,虽然不足为患,然亦是平天下的一个遗憾,先知先觉的中国人,在力所能及的范围内,亦有其应尽的责任。所以在当日,我们所需要的是:一、对内建立一个久安长治的规模。二、对外把力所能及的地方,都收入中国版图之内,其未能的,则确立起一条防线来。\n秦始皇所行的,正顺着这种趋势。\n在古代,阻碍平天下最大的力量,自然是列国的纷争。所以秦并吞六国之后,决计不再行封建,“父兄有天下,而子弟为匹夫”。郡的设立,本来是军事上控扼之点,第三十九章中业经说过。六国新灭,遗民未曾心服,自然有在各地方设立据点的必要。所以秦灭六国,多以其地设郡。至六国尽灭之后,则更合全国的情形,加以调整,分天下为三十六郡。当时的郡守,就是一个不世袭的大国之君,自亦有防其专擅的必要。所以每郡又都派一个御史去监察他(当时还每郡都设立一个尉,但其权远在郡守之下,倒是不足重视的)。\n要人民不能反抗,第一步办法,自然是解除其武装。好在当时,金属铸成的兵器,为数有限,正和今日的枪械一般,大略可以收尽的。于是收天下之兵,聚之咸阳,铸以为金人和锺、(秦都咸阳,今陕西咸阳县)。\n最根本的,莫过于统一人民的心思了。原来古代社会,内部没有矛盾,在下者的意见,常和在上者一致,此即所谓“天下有道,则庶人不议”(《论语·季氏》)。后世阶级分化,内部的矛盾多了,有利于这方面的就不利于那方面。自然人民的意见,不能统一。处置之法,最好的,是使其利害相一致;次之则当求各方面的协调,使其都有发表意见的机会,此即今日社会主义和民主政治的原理。但当时的人,不知此理。他们不知道各方面的利害冲突了,所以有不同的见解,误以为许多方面,各有其不同的主张,以致人各有心,代表全国公益的在上者的政策,不能顺利进行。如此,自有统一全国人的心思的必要。所以在《管子·法禁》、《韩非子·问辨》两篇中,早有焚书的主张。秦始皇及李斯就把它实行了。把关涉到社会、政治问题的“诗、书、百家语”都烧掉,只留下关系技术作用的医药、卜筮、种树之书。涉及社会、政治问题的,所许学的,只有当代的法令;有权教授的人,即是当时的官吏。若认为始皇、李斯此举,不合时代潮流,他们是百口无以自解的,若认为有背于古,则实在冤枉。他们所想回复的,正是古代“政教合一,官师不分”之旧。古代的情形是如此,清朝的章学诚是发挥得十分透彻的(坑儒一举,乃因有人诽谤始皇而起,意非欲尽灭儒生,并不能与焚书之事并论)。\n以上是秦始皇对内的政策。至于对外,则北自阴山以南,南自五岭以南至海,秦始皇都认为应当收入版图。于是使蒙恬北逐匈奴,取河南之地(今之河套),把战国时秦、赵、燕三国北边的长城连接起来,东起现在朝鲜境内(秦长城起自乐浪郡遂城县,见《汉书·地理志》),西至现在甘肃的岷县,成立了一道新防线。南则略取现在广东、广西和越南之地,设立了桂林、南海、象三郡(大略桂林是今广西之地,南海是今广东之地,象郡是今越南之地),取今福建之地,设立了闽中郡。楚国庄所开辟的地方,虽未曾正式收入版图,亦有一部分曾和秦朝交通,秦于其地置吏。\n秦始皇,向来都说他是暴君,把他的好处一笔抹杀了,其实这是冤枉的。看以上所述,他的政治实在是抱有一种伟大的理想的。这亦非他一人所能为,大约是法家所定的政策,而他据以实行的。这只要看他用李斯为宰相,言听计从,焚诗书、废封建之议,都出于李斯而可知。政治是不能专凭理想,而要顾及实际的情形的,即不论实际的情形能行与否,亦还要顾到行之之手腕。秦始皇的政策虽好,行之却似过于急进。北筑长城,南收两越,除当时的征战外,还要发兵戍守;既然有兵戍守,就得运粮饷去供给;这样,人民业已不堪赋役的负担。他还沿着战国以前的旧习惯,虐民以自奉。造阿房宫,在骊山起坟茔(骊山,在今陕西临潼县),都穷极奢侈;还要到处去巡游。统一虽然是势所必至,然而人的见解,总是落后的,在当时的人,怕并不认为合理之举,甚而至于认为反常之态。况且不必论理,六国夷灭,总有一班失其地位的人,心上是不服的,满怀着报仇的愤恨,和复旧的希望;加以大多数人民的困于无告而易于煽动,一有机会,就要乘机而起了。\n第九章 秦汉间封建政体的反动 # 秦始皇帝以前210年,东巡死于沙丘(今河北邢台县)。他大的儿子,名唤扶苏,先已谪罚到上郡去(今陕西绥德县),做蒙恬军队中的监军了。从前政治上的惯例,太子是不出京城,不做军队中的事务的,苟其如此,就是表示不拟立他的意思。所以秦始皇的不立扶苏,是预定了的。《史记》说秦始皇的少子胡亥,宠幸宦者赵高,始皇死后,赵高替胡亥运动李斯,假造诏书,杀掉扶苏、蒙恬而立胡亥,这话是不足信的(《史记·李斯列传》所载的全是当时的传说,并非事实。秦汉间的史实,如此者甚多)。胡亥既立,是为二世皇帝。他诛戮群公子,又杀掉蒙恬的兄弟蒙毅。最后,连劳苦功高、资格很老的李斯都被杀掉。于是秦朝的政府,失其重心,再不能钳制天下了。皇帝的家庭之中,明争暗斗,向来是很多的,而于继承之际为尤甚。这个并不起于秦朝,但在天下统一之后,皇室所管辖的地方大了,因其内部有问题而牵动大局,使人民皆受其祸,其所牵涉的范围,也就更广大了。秦始皇之死,距其尽灭六国,不过十二年,而此祸遂作。\n秦始皇死的明年,戍卒陈胜、吴广起兵于蕲(今安徽宿县),北取陈。胜自立为王,号张楚。分兵四出徇地,郡县多杀其守令以应。六国之后,遂乘机并起。秦朝政治虽乱,兵力尚强;诸侯之兵,多是乌合之众;加以心力不齐,不肯互相救援;所以秦将章邯,倒也所向无敌。先镇压了陈胜、吴广,又打死了新立的魏王。战国时楚国的名将,即最后支持楚国而战死的项燕的儿子项梁,和其兄子项籍,起兵于吴,引兵渡江而西(今江苏之江南,古称江东。古所谓江南,指今之湖南)。以居巢人范增的游说,立楚怀王的后裔于盱眙(居巢,今安徽巢县。盱眙,今安徽盱眙县),仍称为楚怀王(以祖谥为生号)。项梁引兵而北,兵锋颇锐,连战皆胜,后亦为章邯所袭杀。章邯以为楚地兵不足忧,乃北围赵王于巨鹿(今河北平乡县)。北强南弱,乃是东晋以后逐渐转变成功的形势。自此以前,都是北方的军队,以节制胜,南方的军队,以剽悍胜的。尤其是吴、越之士,《汉书·地理志》上,还称其“轻死好用剑”。项梁既死,楚怀王分遣项籍北救赵,起兵于沛的刘邦即汉高祖西入关(沛,今江苏沛县)。项籍大破秦兵于巨鹿。汉高祖亦自武关而入。此时二世和赵高,不知如何又翻了脸,赵高弑二世,立其兄子婴,婴又刺杀高,正当纷乱之际,汉高祖的兵已到霸上(在今陕西长安县东),子婴只得投降,秦朝就此灭亡。此事在前206年。\n既称秦之灭六国为无道,斥为强虎狼,灭秦之后,自无一人专据称尊之理,自然要分封。但是分封之权,出于何人呢?读史的人,都以为是项籍。这是错了的。项籍纵使在实际上有支配之权,形式上决不能专断,况且实际上也未必能全由项籍一个人支配?项籍既破章邯之后,亦引兵西人关。汉高祖先已入关了,即遣将守关。项羽怒,把他攻破。进兵至鸿门(在今陕西临潼县),和高祖几乎开战。幸而有人居间调解,汉高祖自己去见项籍,解释了一番,战事得以未成。此时即议定了分封之事。这一件事,《史记》的《自序》称为“诸侯之相王”,可见形式上是取决于公议的。其所封的:为(一)六国之后,(二)亡秦有功之人,(三)而楚怀王则以空名尊为义帝,(四)实权则在称为西楚霸王的项籍(都彭城,当时称其地为西楚。江陵为南楚,吴为东楚)。这是摹仿东周以后,天子仅拥虚名,而实权在于霸主的。分封的办法,我们看《史记》所载,并不能说它不公平。汉朝人说:楚怀王遣诸将入关时,与之约:先入关者王之,所以汉高祖当王关中,项籍把他改封在巴、蜀、汉中为背约。姑无论这话的真假,即使是真的,楚怀王的命令,安能约束楚国以外的人呢?这且不必论它。前文业经说过:人的思想,总是落后的,观于秦、汉之间而益信。封建政体,既已不能维持,于是分封甫定,而叛乱即起于东方。项籍因为是霸王,有征讨的责任,用兵于齐。汉高祖乘机北定关中。又出关,合诸侯之兵,攻破彭城。项籍虽然还兵把他打败,然汉高祖坚守荥阳、成皋(荥阳,今河南荥泽县。成皋,今河南汜水县),得萧何镇守关中,继续供给兵员和粮饷。遣韩信渡河,北定赵、代,东破齐。彭越又直接扰乱项籍的后方。至前202年,项籍遂因兵少食尽,为汉所灭。从秦亡至此,不过五年。\n事实上,天下又已趋于统一了。然而当时的人,怕不是这样看法。当楚、汉相持之时,有一策士,名唤蒯彻,曾劝韩信以三分天下之计。汉高祖最后攻击项籍时,和韩信、彭越相约合力,而信、越的兵都不会,到后来,约定把齐地尽给韩信,梁地尽给彭越,二人才都引兵而来,这不是以君的资格分封其臣,乃是以对等的资格立分地之约。所以汉高祖的灭楚,以实在情形论,与其说是汉灭楚,毋宁说是许多诸侯,亦即许多支新崛起的军队,联合以灭楚,汉高祖不过是联军中的首领罢了。楚既灭,这联军中的首领,自然有享受一个较众为尊的名号的资格,于是共尊汉高祖为皇帝。然虽有此称号,在实际上,未必含有沿袭秦朝皇帝职权的意义。做了皇帝之后,就可以任意诛灭废置诸王侯,怕是当时的人所不能想象的,这是韩信等在当时所以肯尊汉高祖为皇帝之故。不然,怕就没有这么容易了。汉初异姓之王,有楚王韩信、梁王彭越、赵王张敖、韩王信、淮南王英布、燕王臧荼、长沙王吴芮。这都是事实上先已存在,不得不封的,并非是皇帝的意思所设置。汉高祖灭楚之后,即从娄敬、张良之说,西都关中,当时的理由,是关中地势险固,且面积较大,资源丰富,易于据守及用以临制诸侯,可见他原只想做列国中最强的一国。但是事势所趋,人自然会做出不被思想所拘束的事情来的。不数年间,而韩信、彭越,都以汉朝的诡谋被灭。张敖以罪见废。韩王信、英布、臧荼,都以反而败。臧荼之后,立了一个卢绾,是汉高祖生平第一个亲信人,亦因被谗而亡入匈奴。到前195年汉高祖死时,只剩得一个地小而且偏僻的长沙国了。天下至此,才真正可以算是姓刘的天下。其成功之速,可以说和汉高祖的灭楚,同是一个奇迹。这亦并不是汉高祖所能为,不过封建政体,到这时候业已自趋于没落罢了。\n以一个政府之力统治全国,秦始皇是有此魄力的,或亦可以说是有此公心,替天下废除封建,汉高祖却无有了。既猜忌异姓,就要大封同姓以自辅,于是随着异姓诸侯的灭亡,而同姓诸国次第建立。其中尤以高祖的长子齐王肥,封地既大,人民又多,且居东方形胜之地,为当时所重视(又有淮南王长,燕王建,赵王如意,梁王恢,代王恒,淮阳王友,皆高帝子。楚王交,高帝弟。吴王濞,高帝兄子)。宗法社会中,所信任的,不是同姓,便是外戚。汉初功臣韩信、彭越等,不过因其封地大,所以特别被猜忌,其余无封地,或者仅有小封土的,亦安能“与官同心”?汉高祖东征西讨,频年在外,中央政府所委任的,却是何人呢?幸而他的皇后吕氏是很有能力的。她的母家,大约亦是当时所谓豪杰之流;她的哥哥吕泽和吕释之,都跟随高祖带兵;妹夫樊哙,尤其是功臣中的佼佼者;所以在当时,亦自成为一种势力。高祖频年在外,京城里的事情,把持着的便是她,这只要看韩信、彭越都死在她手里,便可知道。所以高祖死后,嗣子惠帝,虽然懦弱,倒也安安稳稳地做了七年皇帝。惠帝死后,嗣子少帝,又做了四年。不知何故(吕后女鲁元公主,下嫁张敖,敖女为惠帝后。《史记》说他无子,佯为有身,取美人子,杀其母,名为己子。惠帝崩,立,既长,闻其事,口出怨言,为吕后所废。此非事实。张皇后之立,据《汉书》本纪,事在惠帝四年十月,至少帝四年仅七年,少帝至多不过七岁,安有知怨吕后之理),为吕后所废而立其弟。吕后临朝称制。又四年而死。吕后活着的时候,虽然封了几个母家的人为王,却都没有到国。吕后,其实并无推翻刘氏、重用吕氏的意思,所任用的,还是汉初的几个功臣,这班人究竟未免有些可怕,所以临死的时候,吩咐带北军的吕禄、南军的吕产(禄,释之之子。产,泽子),“据兵卫宫”,不要出去送丧,以防有人在京城里乘虚作乱。此时齐王肥已经死了,子襄继为齐王。其弟朱虚侯章在京城里,暗中派人去叫他起兵。汉朝派功臣灌婴去打他。灌婴到荥阳,和齐王连和,于是前敌形成了僵局。丞相陈平、太尉周勃等,乃派人运动吕禄,交出兵权。吕禄犹豫未决,周勃用诈术突入北军,运动军人,反对吕氏。把吕禄、吕产和其余吕氏的人都杀掉。于是阴谋说惠帝的儿子都不是惠帝所生的,就高帝现存的儿子中,择其最长的,迎立了代王恒,是为文帝。齐王一支人,自然是不服的。文帝乃运用手腕,即分齐地,封朱虚侯为城阳王,朱虚侯之弟东牟侯兴居为济北王(城阳治莒,今山东莒县。济北治卢,今山东长清县)。城阳王不久就死了。济北王以反被诛。汉初宗室、外戚、功臣的三角斗争,至此才告结束。\n●西汉·阳陵兵马俑 陕西咸阳市张家湾咸阳原出土。西汉景帝阳陵陪葬俑,相当于真人的二分之一大小\n当时的功臣,所以不敢推翻刘氏,和汉朝同姓分封之多,确实是有关系的,所以封建不能说没有一时之用。然而异姓功臣都灭亡后,所患的,却又在于同姓了。要铲除同姓诸侯尾大不掉之患,自不外乎贾谊“众建诸侯而少其力”一语。这话,当文帝时,其实是已经实行了的,齐王襄传子则,则死后没有儿子,文帝就把他的地方,分为齐、济北、济南、菑川、胶西、胶东六国(济南治东平陵,今山东历城县。菑川治剧,今山东寿光县。胶西治高苑,今山东桓台县。胶东治即墨,今山东即墨县),立了齐王肥的庶子六人。又把淮南之地,分成三国。但吴、楚仍是大国,吴王濞尤积有反心。晁错力劝文帝以法绳诸侯,文帝是个因循的人,没有能彻底实行。前157年,文帝死,子景帝立。晁错做了御史大夫,即实行其所主张。前154年,吴王联合楚、赵、胶西、胶东、菑川、济南造反,声势很盛。幸而吴王不懂得兵谋,“屯聚而西,无他奇道”,为周亚夫所败。于是景帝改定制度,诸侯王不得治民,令相代治其国。到武帝,又用主父偃之计,令诸侯得以其地,分封自己的子弟,在平和的手腕中,把“众建诸侯而少其力”一语,彻底实行了。封建政体反动的余波,至此才算解决。从秦二世元年六国复立起,到吴、楚之乱平定,共五十六年。\n第十章 汉武帝的内政外交 # 在第八章里所提出的对内对外两个问题,乃是统一以后自然存在着的问题,前文业经说明了。这个问题,自前206年秦灭汉兴,至前141年景帝之死,共六十六年,久被搁置着不提了。这是因为高帝、吕后时,忙于应付异姓功臣,文帝、景帝时,又存在着一个同姓诸王的问题;高帝本是无赖子,文、景二帝亦只是个寻常人,凡事都只会蹈常习故之故。当这时候,天下新离兵革之患,再没有像战国以前年年打仗的事情了。郡县长官,比起世袭的诸侯来,自然权力要小了许多,不敢虐民。诸侯王虽有荒淫昏暴的,比之战国以前,自然也差得远了。这时候的中央政府,又一事不办,和秦始皇的多所作为,要加重人民负担的,大不相同。在私有财产制度之下,人人都急于自谋,你只要不去扰累他,他自然会休养生息,日臻富厚。所以据《史记·平准书》说:在武帝的初年,海内是很为富庶的。但是如此就算了么?须知社会,并不是有了钱就没有问题的。况且当时所谓有钱,只是总算起来,富力有所增加,并不是人人都有饭吃,富的人富了,穷的人还是一样的穷,而且因贫富相形,使人心更感觉不平,感觉不足。而对外的问题,时势亦逼着我们不能闭关自守。汉武帝并不是真有什么本领的人,但是他的志愿,却较文、景二帝为大,不肯蹈常习故,一事不办,于是久经搁置的问题,又要重被提起了。\n●汉武帝像\n当时对内的问题,因海内已无反侧,用不到像秦始皇一般,注意于镇压,而可以谋一个长治久安之策。这个问题,在当时的人看起来,重要的有两方面:一个是生计,一个是教化,这是理论上当然的结果。衣食足而知荣辱,生计问题,自然在教化之先;而要解决生计问题,又不过平均地权、节制资本两途,这亦是理论上当然的结果。最能解决这两个问题的,是哪一家的学术呢?那么,言平均地权和教化者,莫如儒家,言节制资本者,莫如法家。汉武帝,大家称他是崇儒的人,其实他并不是真懂得儒家之道的。他所以崇儒,大约因为他的性质是夸大的,要做些表面上的事情,如改正朔、易服色等,而此等事情,只有儒家最为擅长之故。所以当时一个真正的儒家董仲舒,提出了限民名田的主张,他并不能行。他的功绩,最大的,只是替《五经》博士置弟子,设科射策,劝以官禄,使儒家之学,得国家的提倡而地位提高。但是照儒家之学,生计问题,本在教化问题之先;即以教化问题而论,地方上的庠序,亦重于京城里的大学,这只要看《汉书·礼志》上的议论,便可以知道。武帝当日,对于庠序,亦未能注意,即因其专做表面上的事情之故。至于法家,他用到了一个桑弘羊,行了些榷盐铁、酒酤、均输等政策。据《盐铁论》看来,桑弘羊是确有节制资本之意,并非专为筹款的。但是节制资本而藉官僚以行之,很难望其有利无弊,所以其结果,只达到了筹款的目的,节制资本,则徒成虚语,且因行政的腐败,转不免有使人民受累的地方。其余急不暇择的筹款方法,如算缗钱、舟车,令民生子三岁即出口钱,及令民入羊为郎、入谷补官等,更不必说了。因所行不顺民心,不得不用严切的手段,乃招致张汤、赵禹等,立了许多严切的法令,以压迫人民。秦以来的狱吏,本来是偏于残酷的,加以此等法律,其遗害自然更深了。他用此等方法,搜括了许多钱来,做些什么事呢?除对外的武功,有一部分,可以算是替国家开拓疆土、防御外患外,其余如封禅、巡幸、信用方士、大营宫室等,可以说全部是浪费。山东是当时诛求剥削的中心,以致末年民愁盗起,几至酿成大乱。\n武帝对外的武功,却是怎样呢?当时还威胁着中国边境的,自然还是匈奴。此外秦朝所开辟的桂林、南海、象三郡和闽中郡,秦末汉初,又已分离为南越、闽越、东瓯三国了。现在的西康、云、贵和四川、甘肃的边境,即汉人所谓西南夷,则秦时尚未正式开辟。东北境,虽然自战国以来,燕国人业已开辟了辽东,当时的辽东,且到现在朝鲜境内(汉初守燕国的旧疆,以水为界,则秦界尚在水以西。水,今大同江),然汉族的移殖,还不以此为限,自可更向外开拓。而从甘肃向西北入新疆,向西南到青海,也正随着国力的扩张,而可有互相交通之势。在这种情势之下,推动雄才大略之主,向外开拓的,有两种动机:其一,可说是代表国家和民族向外拓展的趋势,又其一则为君主个人的野心。匈奴,自秦末乘中国内乱、戍边者皆去,复入居河南。汉初,其雄主冒顿,把今蒙古东部的东胡,甘肃西北境的月氏,都征服了。到汉文帝时,他又征服了西域,西域,即今新疆之地(西域二字,义有广狭。《汉书·西域传》说西域之地,“南北有大山,中央有河,东则接汉,阨以玉门、阳关,西则限以葱岭。”北方的大山,即今天山,南方的大山,即沙漠以南的山脉,略为新疆与西藏之界。河系今塔里木河。玉门、阳关,都在今甘肃敦煌县西。此乃今天山南路之地。其后自此西出,凡交通所及之地,亦概称为西域,则其界限并无一定,就连欧洲也都包括在内)。汉时分为三十六国(后分至五十余)。其种有塞,有氐羌。塞人属于高加索种,都是居国,其文明程度,远在匈奴、氐、羌等游牧民族之上。匈奴设官以收其赋税。汉高祖曾出兵征伐匈奴,被围于平城(今山西大同县),七日乃解。此时中国初定,对内的问题还多,不能对外用兵,乃用娄敬之策,名家人子为长公主,嫁给冒顿,同他讲和,是为中国以公主下嫁外国君主结和亲之始。文、景两代,匈奴时有叛服,文、景不过发兵防之而已,并没建立一定的对策。到武帝,才大出兵以征匈奴,前127年,恢复河南之地,匈奴自此移于漠北。前119年,又派卫青、霍去病绝漠攻击,匈奴损折颇多。此外较小的战斗,还有多次,兵事连亘,前后共二十余年,匈奴因此又渐移向西北。汉武帝的用兵,是很不得法的,他不用功臣宿将,而专用卫青、霍去病等椒房之亲。纪律既不严明,对于军需,又不爱惜,以致士卒死伤很多,物质亦极浪费(如霍去病,《史记》称其少而侍中,贵不省士。其用兵,“既还,重车余弃粱肉,而士有饥者。在塞外,卒乏粮,或不能自振,而去病尚穿域蹋鞠,事多类此。”卫青、霍去病大出塞的一役,汉马死者至十余万匹,从此以马少则不能大举兵事。李广利再征大宛时,兵出敦煌的六万人,私人自愿从军的,还不在其内,马三万匹,回来时,进玉门关的只有一万多人,马一千多匹。史家说这一次并不乏食,战死的也不多,所以死亡如此,全由将吏不爱士卒之故。可见用人不守成法之害)。只因中国和匈奴,国力相去悬绝,所以终能得到胜利。然此乃国力的胜利,并非战略的胜利。至于其通西域,则更是动于侈心。他的初意,是听说月氏为匈奴所破,逃到今阿母河滨,要想报匈奴的仇,苦于无人和他合力,乃派张骞出使。张骞回来后,知道月氏已得沃土,无报仇之心,其目的已不能达到了。但武帝因此而知西域的广大,以为招致了他们来朝贡,实为自古所未有,于是动于侈心,要想招致西域各国。张骞在大夏时,看见邛竹杖、蜀布,问他从哪里来的?他们说从身毒买来(今印度)。于是臆想,从四川、云南,可通西域。派人前去寻求道路,都不能通(当时蜀物入印度,所走的路,当系今自四川经西康、云南入缅甸的路。自西南夷求通西域的使者,“传闻其西可千余里,有乘象国,名曰滇越,而蜀贾奸出物者或至焉”,即当今缅甸之地)。后来匈奴的浑邪王降汉,今甘肃西北部之地,收入中国版图,通西域的路,才正式开通。前104年,李广利伐大宛(大宛都贵山城,乃今之霍阐),不克。武帝又续发大兵,前101年,到底把它打下。大宛是离中国很远的国。西域诸国,因此慑于中国兵威,相率来朝。还有一个乌孙,也是游牧民族,当月氏在甘肃西北境时,乌孙为其所破,依匈奴以居。月氏为匈奴所破,是先逃到伊犁河流域的。乌孙借匈奴的助力,把它打败,月氏才逃到阿母河流域,乌孙即占据伊犁之地。浑邪王降汉时,汉朝尚无意开其地为郡县,张骞建议,招乌孙来居之。乌孙不肯来,而匈奴因其和中国交通,颇责怪它。乌孙恐惧,愿“婿汉氏以自亲”。于是汉朝把一个宗室女儿嫁给它。从此以后,乌孙和匈奴之间有问题,汉朝就不能置之不问,《汉书·西域传》说“汉用忧劳无宁岁”,很有怨怼的意思。按西域都是些小国,汉攻匈奴,并不能得它的助力,而因此劳费殊甚,所以当时人的议论,大都是反对的。但是史事复杂,利害很难就一时一地之事论断。(一)西域是西洋文明传布之地。西洋文明的中心古希腊、罗马等,距离中国很远,在古代只有海道的交通,交流不甚密切,西域则与中国陆地相接,自近代西力东渐以前,中西的文明,实在是恃此而交流的。(二)而且西域之地,设或为游牧民族所据,亦将成为中国之患,汉通西域之后,对于天山南北路,就有相当的防备,后来匈奴败亡后,未能侵入,这也未始非中国之福。所以汉通西域,不是没有益处的。但这只是史事自然的推迁,并非当时所能豫烛。当时的朝鲜:汉初燕人卫满走出塞,把箕子之后袭灭了,自王朝鲜。传子至孙,于前108年,为汉武帝所灭。将其地设置乐浪、临屯、真番、玄菟四郡(乐浪,今朝鲜平安南道及黄海、京畿两道之地。临屯为江原道地。玄菟为咸镜南道。真番跨鸭绿江上流。至前82年,罢真番、临屯,以并乐浪、玄菟)。朝鲜半岛的主要民族是貉族,自古即渐染汉族的文化,经此长时期的保育,其汉化的程度愈深,且因此而输入半岛南部的三韩(马韩,今忠清、全罗两道。弁韩、辰韩,今庆尚道)和海东的日本,实为中国文化在亚洲东北部最大的根据地。南方的东瓯,因为闽越所攻击,前138年,徙居江、淮间。南越和闽越,均于111年,为中国所灭。当时的西南夷:在今金沙江和黔江流域的,是夜郎、滇、邛都,在岷江和嘉陵江上源的,是徙、筰都、冉、白马。在今横断山脉和澜沧、金沙两江间的,是巂昆明(夜郎,今贵州桐梓县。滇,今云南昆明县。邛都,今西康西昌县。徙,今四川天全县。筰都,今西康汉源县。冉,今四川茂县。白马,今甘肃成县。巂昆明,在今昆明、大理之间,乃行国)。两越既平,亦即开辟为郡县,确立了中国西南部的疆域。今青海首府附近,即汉人称为河湟之地的,为羌人所据。这一支羌人,系属游牧民族,颇为中国之患。前112年,汉武帝把它打破,设护羌校尉管理它,开辟了今青海的东境。\n第十一章 前汉的衰亡 # 汉武帝死后,汉朝是经过一次政变的,这件事情的真相,未曾有传于后。武帝因迷信之故,方士神巫,多聚集京师,至其末年,遂有巫蛊之祸,皇后自杀。太子据发兵,把诬陷他和皇后的江充杀掉。武帝认为造反,亦发兵剿办。太子兵败出亡,后被发觉,自缢而死。当太子死时,武帝儿子存在的,还有燕王旦、广陵王胥、昌邑王髆,武帝迄未再立太子。前87年,武帝死,立赵婕妤所生幼子弗陵,是为昭帝。霍光、上官桀、桑弘羊、金日同受遗诏辅政。赵婕妤先以谴死。褚先生《补外戚世家》说:是武帝怕身死之后,嗣君年少,母后专权,先行把她除去的。《汉书·霍光传》又说:武帝看中了霍光,使画工画了一幅周公负成王朝诸侯的图赏给他。武帝临死时,霍光问当立谁?武帝说:“立少子,君行周公之事。”这话全出捏造。武帝生平溺于女色;他大约是个多血质的人,一生行事,全凭一时感情冲动,安能有深谋远虑,预割嬖爱?霍光乃左右近习之流,仅可以供驱使。上官桀是养马的。金日系匈奴休屠王之子,休屠王与浑邪王同守西边,因不肯降汉,为浑邪王所杀,乃系一个外国人,与中国又有杀父之仇。朝臣中即使无人,安得托孤于这几个人?当他们三个人以武帝遗诏封侯时,有一个侍卫,名唤王莽,他的儿子唤做王忽,扬言道:皇帝病时,我常在左右,哪里有这道诏书?霍光闻之,切责王莽,王莽只得把王忽杀掉。然则昭帝之立,究竟是怎样一回事,就可想而知了。昭帝既立,燕王谋反,不成而死。桑弘羊、上官桀都以同谋被杀。霍光的女儿,是上官桀的儿媳妇,其女即昭帝的皇后。上官桀大约因是霍光的亲戚而被引用,又因争权而翻脸的,殊不足论,桑弘羊却可惜了(金日于昭帝元年即死,故不与此次政变)。前74年,昭帝死,无子。霍光迎立昌邑王的儿子贺,旋又为光所废,而迎太子据之孙病已于民间,是为宣帝。昌邑王之废,表面上是无道,然当昌邑群臣二百余人被杀时,在市中号呼道“当断不断,反受其乱”,则昌邑王因何被废,又可想而知了。太子据败时,妻妾子女悉被害,只有一个宣帝系狱,此事在前91年。到前87年,即武帝死的一年,据说,有望气者说:“长安狱中有天子气。”武帝就派使者,“分条中都官狱系者,轻重皆杀之。”幸而有个丙吉,“拒闭使者”,宣帝才得保全,因而遇赦。按太子死后,武帝不久即自悔。凡和杀太子有关的人,都遭诛戮。太子系闭门自缢,脚蹋开门和解去他自缢的绳索的人都封侯。上书讼太子冤的田千秋,无德无能,竟用为丞相。武帝的举动如此,宣帝安得系狱五年不释?把各监狱中的罪人,不问罪名轻重,尽行杀掉,在中国历史上,是从来没有这回事的,这是和中国,至少是有史以来的中国的文化不相容的,武帝再老病昏乱些,也发不出这道命令。如其发出了,拒绝不肯执行的,又岂止一个丙吉?然则宣帝是否武帝的曾孙,又很有可疑了。今即舍此勿论,而昌邑王以有在国时的群臣,为其谋主,当断不断而败,宣帝起自民间,这一层自然无足为虑,这怕总是霍光所以迎立他的真原因了罢。宣帝即立,自然委政于光,立六年而光死,事权仍在霍氏手里。宣帝不动声色地,逐渐把他们的权柄夺去,任用自己的亲信。至前66年,而霍氏被诛灭。\n●东汉·绿釉陶楼通高130.2厘米。山东高唐县东固河出土\n霍光的事情,真相如此。因为汉时史料缺乏,后人遂认为他的废立是出于公心的,把他和向来崇拜的偶像伊尹联系在一起,称为伊、霍,史家的易欺,真堪惊叹了。当时朝廷之上,虽有这种争斗,影响却未及于民间。武帝在时,内行奢侈,外事四夷,实已民不堪命。霍光秉政,颇能轻徭薄赋,与民休息。宣帝起自民间,又能留意于吏治和刑狱。所以昭、宣二帝之世,即自前86至前49年凡三十八年之间,政治反较武帝时为清明,其时汉朝对于西域的声威,益形振起。前60年,设立西域都护,兼管南北两道。匈奴内乱,五单于并立,后并于呼韩邪。又有一个郅支单于,把呼韩邪打败。前51年,呼韩邪入朝于汉。郅支因汉拥护呼韩邪,遁走西域。前49年,宣帝崩,子元帝立。前36年,西域副都护陈汤矫诏发诸国兵袭杀郅支。汉朝国威之盛,至此亦达于极点。然有一事,系汉朝政治败坏的根源,其端实开自霍光秉政之时的,那便是宰相之权,移于尚书。汉朝的宰相,是颇有实权的。全国的政治,都以相府为总汇,皇帝的秘书御史,不过是他的助手,尚书乃皇帝手下的管卷,更其说不着了。自霍光秉政,自领尚书,宰相都用年老无气和自己的私人,政事悉由宫中而出,遂不能有正色立朝之臣。宣帝虽诛灭霍氏,于此却未能矫正。宦者弘恭、石显,当宣、元之世,相继在内用事。元帝时,士大夫如萧望之、刘向等,竭力和他们争斗,终不能胜。朝无重臣,遂至嬖得干相位,外戚得移朝祚,西汉的灭亡,相权的丧失,实在是一个重要的原因。而且其事不但关涉汉朝,历代的政治,实都受其影响,参看第四十二章自明。\n●过居庸关图内蒙古和林格尔县汉墓壁画,长132.4厘米,宽67.5厘米。绘有墓主路经居庸关情景,居庸关用山谷中的桥梁表示\n元帝以前33年死,子成帝立。成帝是个荒淫无度的人,喜欢了一个歌者赵飞燕,立为皇后,又立其女弟合德为婕妤。性又优柔寡断,事权遂入于外家王氏之手。前7年,成帝崩,哀帝立,颇想效法武、宣,振起威权。然宠爱嬖人董贤,用为宰相,朝政愈乱。此时王氏虽一时退避,然其势力仍在。哀帝任用其外家丁氏,祖母族傅氏,其中却并无人才,实力远非王氏之敌。前1年,哀帝崩,无子,王莽乘机复出,迎立平帝。诛灭丁、傅、董贤,旋弑平帝而立孺子婴(哀、平二帝皆元帝孙,孺子为宣帝曾孙)。王莽从居摄改称假皇帝,又从假皇帝变做真皇帝,改国号为新,而前汉遂亡。此事在公元9年。\n第十二章 新室的兴亡 # 前后汉之间,是中国历史的一个转变。在前汉之世,政治家的眼光,看了天下,认为不该就这么苟安下去的。后世的政治家,奉为金科玉律的思想,所谓“治天下不如安天下,安天下不如与天下安”,是这时候的人所没有的。他们看了社会,还是可用人力控制的,一切不合理的事,都该用人力去改变,此即所谓“拨乱世,反之正”。出来负这个责任的,当然是贤明的君主和一班贤明的政治家。当汉昭帝时,有一个儒者,唤做眭弘,因灾异,使其朋友上书,劝汉帝“求索贤人,禅以帝位,而退自封百里”。宣帝时,有个盖宽饶,上封事亦说:“五帝官天下,三王家天下,家以传子,官以传贤,四序之运,成功者退,不得其人,则不居其位。”这两个人,虽然都得罪而死,但眭弘,大约因霍光专政,怕人疑心他要篡位,所以牺牲了他,以资辨白的。况且霍光是个不学无术的人,根本不懂得什么改革大计。盖宽饶则因其刚直之性,既触犯君主,又为有权势的人所忌,以致遭祸,都不是反对这种理论,视为大逆不道。至于不关涉政体,而要在政务上举行较根本的改革的,则在宣帝时有王吉,因为宣帝是个实际的政治家,不能听他的话。元帝即位,却征用了王吉及和他志同道合的朋友贡禹。王吉年老,在路上死了。贡禹征至,官至御史大夫。听了他的话,改正了许多奢侈的制度,又行了许多宽恤民力的政事。其时又有个翼奉,劝元帝徙都成周。他说:长安的制度,已经坏了,因袭了这种制度,政治必不能改良,所以要迁都正本,与天下更始,则其规模更为阔大了。哀帝多病,而且无子,又有个李寻,保荐了一个贺良,陈说“汉历中衰,当更受命”,劝他改号为陈圣刘皇帝。陈字和田字同音,田地二字,古人通用,地就是土,陈圣刘皇帝,大约是说皇帝虽然姓刘,所行的却是土德。西汉人五德终始之说,还不是像后世专讲一些无关实际,有类迷信的空话的,既然要改变“行序”,同时就有一大套实际的政务,要跟着改变。这只要看贾谊说汉朝应当改革,虽然要“改正朔,易服色”,也要“法制度,定官名”,而他所草拟的具体方案,“为官名,悉更秦之故”,便可知道。五德终始,本来不是什么迷信,而是一套有系统的政治方案,这在第四十三章中,业经说过了。这种根本的大改革,要遭到不了解的人无意识的反对,和实际于他权利有损的人出死力的抵抗,自是当然之事。所以贺良再进一步,要想改革实际的政务,就遭遇反对而失败了。但改革的气势,既然如此其旁薄郁积,自然终必有起而行之之人,而这个人就是王莽。所以王莽是根本无所谓篡窃的。他只是代表时代潮流,出来实行改革的人。要实行改革,自然要取得政权;要取得政权,自然要推翻前朝的皇帝;而因实行改革而推翻前朝的皇帝,在当时的人看起来,毋宁是天理人情上当然的事。所以应天顺人(《易·鼎卦彖辞》:“汤武革命,顺乎天而应乎人”),在当时也并不是一句门面话。\n要大改革,第一步自然还是生计问题,王莽所实行的是:一、改名天下的田为王田,这即是现在的宣布土地国有,和附着于土地的奴隶,都不准卖买,而举当时所有的土田,按照新章,举行公平的分配。二、立六筦之法,将大事业收归官营。三、立司市、泉府,以平衡物价,使消费者、生产者、交换者,都不吃亏。收有职业的人的税,以供要生利而无资本的人,及有正当消费而一时周转不灵的人的借贷。其详见第四十一章。他的办法,颇能综合儒法两家,兼顾到平均地权和节制资本两方面,其规模可称阔大,思虑亦可谓周详。但是徒法不能自行,要举行这种大改革,必须民众有相当的觉悟,且能作出相当的行动,专靠在上者的操刀代斫,是不行的。因为真正为国为民的人,总只有少数,官僚阶级中的大多数人,其利害总是和人民相反的,非靠督责不行。以中国之大,古代交通的不便,一个中央政府,督责之力本来有所不及;而况当大改革之际,普通官吏,对于法令,也未必能了解,而作弊的机会却特多;所以推行不易,而监督更难。王莽当日所定的法令,有关实际的,怕没有一件能够真正推行,而达到目的,因此而生的流弊,则无一事不有,且无一事不厉害。其余无关实际,徒资纷扰的,更不必说了。王莽是个偏重立法的人,他又“锐思于制作”,而把眼前的政务搁起。尤其无谓的,是他的改革货币,麻烦而屡次改变,势不可行,把商业先破坏了。新分配之法,未曾成立,旧交易之法,先已破坏,遂使生计界的秩序大乱,全国的人,无一个不受到影响。王莽又是个拘泥理论、好求形式上的整齐的人。他要把全国的政治区划,依据地理,重行厘定,以制定封建和郡县制度。这固然是一种根本之图,然岂旦夕可致?遂至改革纷纭,名称屡变,吏弗能纪。他又要大改官制,一时亦不能成功,而官吏因制度未定,皆不得禄,自然贪求更甚了。对于域外,也是这么一套。如更改封号及印章等,无关实际、徒失交涉的圆滑,加以措置失宜,匈奴、西域、西南夷,遂至背叛。王莽对于西域,未曾用兵。西南夷则连年征讨,骚扰殊甚。对于匈奴,他更有一个分立许多小单于,而发大兵深入穷追,把其不服的赶到丁令地方去的一个大计划(此乃欲将匈奴驱入今西伯利亚之地,而将漠北空出)。这个计划,倒也是值得称赞的,然亦谈何容易?当时调兵运饷,牵动尤广,屯守连年,兵始终没有能够出,而内乱却已蔓延了。\n●帛画《升天图》\n莽末的内乱,是起于公元17年的。今山东地方,先行吃紧。湖北地方,亦有饥民屯聚。剿办连年弗能定。公元22年,藏匿在今当阳县绿林山中的兵,分出南阳和南郡(汉南阳郡,治宛,今河南南阳县。南郡,治江陵,今湖北江陵县)。入南阳的谓之新市兵,入南郡的谓之下江兵。又有起于今随县的平林乡的,谓之平林兵。汉朝的宗室刘玄,在平林兵中。刘、刘秀则起兵舂陵(今湖北枣阳县),和新市、平林兵合。刘玄初称更始将军,后遂被立为帝。入据宛。明年,王莽派大兵四十万去剿办,多而不整,大败于昆阳(今河南叶县)。莽遂失其控制之力,各地方叛者并起。更始分兵两支:一攻洛阳,一入武关。长安中叛者亦起。莽遂被杀。更始移居长安,然为新市、平林诸将所制,不能有为。此时海内大乱,而今河南、河北、山东一带更甚。刘为新市、平林诸将所杀。刘秀别为一军,出定河北。即帝位于鄗(改名高邑县),是为后汉光武皇帝。先打平了许多小股的流寇。其大股赤眉,因食尽西上,另立了一个汉朝的宗室刘盆子,攻入长安。更始兵败出降,旋被杀。光武初以河内为根据地(汉河内郡,治怀,在令河南武陟县),派兵留守,和服从更始的洛阳对峙。至此遂取得了洛阳,定都其地。派兵去攻关中,未能遽定,而赤眉又因食尽东走,光武自勒大兵,降之宜阳(今河南宜阳县)。此时东方还有汉朝的宗室刘永割据睢阳(今河南商丘县)。东方诸将,多与之合。又有秦丰、田戎等,割据今湖北沿江一带,亦被他次第打平。只有陇西的隗嚣,四川的公孙述,较有规模,到最后才平定。保据河西的窦融,则不烦兵力而自下。到公元36年,天下又算平定了。从公元17年东方及荆州兵起,算到这一年,其时间实四倍于秦末之乱;其破坏的程度,怕还不止这一个比例。光武平定天下之后,自然只好暂顾目前,说不上什么远大的计划了。而自王莽举行这样的大改革而失败后,政治家的眼光,亦为之一变。根本之计,再也没有人敢提及。社会渐被视为不可以人力控制之物,只能听其迁流所至。“治天下不如安天下,安天下不如与天下安”,遂被视为政治上的金科玉律了。所以说:这是中国历史上的一个大转变。\n●马踏飞燕\n第十三章 后汉的盛衰 # 后汉自公元25年光武帝即位起,至公元220年为魏所篡止,共计192年;若算到公元189年董卓行废立,东方起兵讨卓,实际分裂之时为止,则共得175年;其运祚略与前汉相等,然其国力的充实,则远不如前汉了。这是因为后汉移都洛阳,对于西、北两面的控制,不如前汉之便;又承大乱之后,海内凋敝已极,休养未几,而羌乱即起,其富力亦不如前汉之盛之故。两汉四百年,同称中国的盛世,实际上,后汉已渐露中衰之机了。光武帝是一个实际的政治家。他知道大乱之后,急于要休养生息,所以一味的减官省事。退功臣,进文吏。位高望重的三公,亦只崇其礼貌,而自己以严切之法,行督责之术,虽然有时不免失之过严,然颇得专制政治,“严以察吏,宽以驭民”的秘诀,所以其时的政治,颇为清明。公元57年,光武帝崩,子明帝立。亦能守其遗法。公元75年,明帝崩,子章帝立,政治虽渐见宽弛,然尚能蒙业而安。章帝以公元88年崩。自公元36年公孙述平定至此,共计52年,为东汉治平之世。匈奴呼韩邪单于约诸子以次继立。六传至呼都而尸单于,背约而杀其弟。前单于之子比,时领南边,不服。公元48年,自立为呼韩邪单于,来降。中国人处之于今绥远境内。匈奴自此分为南、北。北匈奴日益衰乱。公元89年,南单于上书求并北庭。时和帝新立,年幼,太后窦氏临朝。后见窦宪犯法,欲令其立功自赎,乃以宪为大将军,出兵击破匈奴。后年,又大破之于金微山(大约系今蒙古西北的阿尔泰山)。北匈奴自此远遁,不能为中国之患了。西域的东北部,是易受匈奴控制的。其西南部,则自脱离汉朝都护的管辖后,强国如莎车、于阗等,出而攻击诸国,意图并吞。后汉初兴,诸国多愿遣子入侍,请派都护。光武不许。明帝时,才遣班超出使。班超智勇足备,带了少数的人,留居西域,调发诸国的兵,征讨不服,至公元91年而西域平定。汉朝复设都护,以超为之。后汉之于域外,并没有出力经营,其成功,倒亦和前汉相仿佛,只可谓之适值天幸而已。\n●东汉·持戟青铜骑士 仪仗队武士高30厘米,马高40~42.5厘米,身长33~35厘米。甘肃武威市雷台汉墓出土\n后汉的乱源,共有好几个,其中最重要的,就是外戚和宦官。从前的皇室,其前身,本来是一个强大的氏族。氏族自有氏族的继承法。当族长逝世,合法继承人年幼时,从族中推出一个人来,暂操治理之权,谓之摄政。如由前族长之妻,现族长之母代理,则即所谓母后临朝。宗室分封于外,而中朝以外戚辅政,本来是前汉的一个政治习惯。虽然前汉系为外戚所篡,然当一种制度未至崩溃时,即有弊窦,人们总认为是人的不好,而不会归咎于制度的。如此,后汉屡有冲幼之君,自然产生不出皇族摄政的制度来,而只会由母后临朝;母后临朝,自然要任用外戚。君主之始,本来是和一个乡长或县长差不多的。他和人民是很为接近的。到后来,国家愈扩愈大,和原始的国家不知相差若干倍了,而君主的制度依然如故。他和人民,和比较低级的官吏,遂至因层次之多,而自然隔绝。又因其地位之高,而自成养尊处优之势,关系之重,而不得不深居简出。遂至和当朝的大臣,都不接近,而只是和些宦官宫妾习狎。这是历代的嬖近习,易于得志的原因,而也是政治败坏的一个原因。后汉外戚之祸,起于章帝时。章帝的皇后窦氏是没有儿子的。宋贵人生子庆,立为太子。梁贵人生子肇,窦后养为己子。后诬杀宋贵人,废庆为清河王,而立肇为太子。章帝崩,肇立,是为和帝。后兄窦宪专权。和帝既长,与宦者郑众谋诛之,是为后汉皇帝和宦官合谋以诛外戚之始。105年,和帝崩。据说和帝的皇子,屡次夭殇,所以生才百余日的殇帝,是寄养于民间的。皇后邓氏迎而立之。明年,复死。乃迎立清河王的儿子,是为安帝。邓太后临朝,凡十五年。太后崩后,安帝亲政,任用皇后的哥哥阎显,又宠信宦官和乳母王圣,政治甚为紊乱。阎皇后无子,后宫李氏生子保,立为太子。后谮杀李氏而废保。125年,安帝如宛,道崩。皇后秘丧驰归,迎立章帝之孙北乡侯懿。当年即死。宦者孙程等迎立废太子保,是为顺帝。程等十九人皆封列侯。然未久即多遭谴斥。顺帝任用皇后的父亲梁商,梁商为人还算谨慎。商死后,子冀继之,其骄淫纵恣,为前此所未有。144年,顺帝崩,子冲帝立。明年崩。梁冀迎立章帝的玄孙质帝。因年小聪明,为冀所弑。又迎立章帝的曾孙桓帝。桓帝立十三年后,才和宦者单超等五人合谋把梁冀诛戮,自此宦官又得势了。\n因宦官的得势,遂激成所谓党锢之祸。宦官和阉人,本来是两件事。宦字的初义,是在机关中学习,后来则变为在贵人家中专事伺候人的意思,第四十一章中,业经说过了。皇室的规模,自然较卿大夫更大,自亦有在宫中服事他的人,此即所谓宦官(据《汉书·本纪》,惠帝即位后,曾施恩于宦皇帝的人,此即是惠帝为太子时,在“太子家”中伺候他的人)。本不专用阉人,而且其初,宦官的等级远较阉人为高,怕是绝对不能用阉人的。但到后来,刑罚滥了,士大夫亦有受到宫刑的(如司马迁受宫刑后为中书谒者令,即其好例);又有生来天阉的人;又有贪慕权势,自宫以进的,不都是俘虏或罪人。于是其人的能力和品格,都渐渐提高,而可以用为宦官了。后汉邓太后临朝后,宫中有好几种官,如中常侍等,都改用阉人,宦官遂成为阉人所做的官的代名词。虽然阉人的地位实已提高,然其初既是俘虏和罪人,社会上自然总还将他当作另一种人看待,士大夫更瞧他不起。此时的士大夫和贵族,都是好名的,都是好交结的。这一者出于战国之世,贵族好养士,士人好奔走的习惯,一则出于此时选举上的需要,在第四十三章中,业经说过了。当时的宦官,多有子弟亲戚,或在外面做官暴虐,或则居乡恃势骄横。用法律裁制,或者激动舆论反对他,正是立名的好机会。士大夫和宦官,遂势成水火。这一班好名誉好交结的士大夫,自然也不免互相标榜,互相结托。京城里的太学,游学者众多,而且和政治接近,便自然成为他们聚集的中心。结党以营谋进身,牵引同类,淆乱是非,那是政治上的一个大忌。当时的士大夫,自不免有此嫌疑。而且用了这一个罪名,则一网可以打尽,这是多么便利,多么痛快的事!宦官遂指当时反对他们的名士为党人,劝桓帝加以禁锢,后因后父窦武进言,方才把他们赦免。167年,桓帝崩,无子,窦后和武定策禁中,迎立了章帝的玄孙灵帝。太后临朝。窦武是和名士接近的,有恩于窦氏的陈蕃,做了太傅,则其本身就是名士中人。谋诛弄权的宦官,反为所害。太后亦被迁抑郁而死。灵帝年长,不徒不知整顿,反更崇信宦官,听其把持朝政,浊乱四海。而又一味聚敛奢侈。此时乱源本已潜伏,再天天给他制造爆发的机会,遂成为不可收拾之局了。\n大伤后汉的元气的是羌乱。中国和外夷,其间本来总有边塞隔绝着的。论民族主义的真谛,先进民族本来有诱掖后进民族的责任,不该以隔绝为事。但是同化须行之以渐。在同化的进行未达相当程度时,彼此的界限是不能遽行撤废的。因为文化的不同就是生活的相异,不能使其生活从同,顾欲强使生活不同的人共同生活,自不免引起纠纷。这是五胡乱华的一个重要原因,而后汉时的羌乱,业已导其先路了。今青海省的东北境,在汉时本是羌人之地。王莽摄政时,讽羌人献地,设立了一个西海郡。既无实力开拓,边塞反因之撤废,羌人就侵入内地。后汉初年,屡有反叛,给中国征服了,又都把他们迁徙到内地来。于是降羌散居今甘肃之地者日多。安帝时,遂酿成大规模的叛乱。这时候,政治腐败,地方官无心守土,都把郡县迁徙到内地。人民不乐迁徙,则加以强迫驱遣,流离死亡,不可胜数。派兵剿办,将帅又腐败,历时十余年,用费达二百四十亿,才算勉强结束。顺帝时又叛,兵费又至八十余亿,桓帝任用段颎,大加诛戮,才算镇定下来。然而西北一方,凋敝已甚,将帅又渐形骄横,隐伏着一个很大的乱源了。\n遇事都诉之理性,这只是受过优良教育的人,在一定的范围中能够。其余大多数人,和这一部分人出于一定范围以外的行为,还是受习惯和传统思想的支配的。此种习惯和传统的思想,是没有理由可以解说的,若要仔细追究起来,往往和我们别一方面的知识冲突,所以人们都置诸不问,而无条件加以承认,此即所谓迷信。给迷信以一种力量的则为宗教。宗教鼓动人的力量是颇大的。当部族林立之世,宗教的教义,亦只限于一部族,而不足以吸引别部族人。到统一之后就不然了。各种小宗教,渐渐混合而产生大宗教的运动,在第五十四章中说过。在汉时,上下流社会,是各别进行的。在上流社会中,孔子渐被视为一个神人,看当时内学家(东汉时称纬为内学)尊崇孔子的话,便可见得。但在上流社会中,到底是受过良好教育,理性较为发达的,不容此等迷信之论控制,所以不久就被反对迷信的玄学打倒。在下流社会,则各种迷信,逐渐结合,而形成后世的道教。在汉时是其初步。其中最主要的是张角的太平道和张修的五斗米道。道教到北魏时的寇谦之,才全然和政府妥协,前此,则是很激烈地反对政府的。他们以符咒治病等,为煽动和结合的工具。张修造反,旋即平定。张鲁后来虽割据汉中,只是设立鬼卒等,闭关自守,实行其神权政治而已,于大局亦无甚关系。张角却声势浩大。以公元184年起事。他的徒党,遍于青、徐、幽、冀、荆、扬、兖、豫八州,即今江苏、安徽、浙江、江西、湖北、湖南、山东、河南、河北各省之地。但张角似是一个只会煽惑而并没有什么政治能力的人,所以不久即败。然此时的小乱事,则已到处蔓延,不易遏止了,而黄巾的余党亦难于肃清。于是改刺史为州牧,将两级制变成了三级制,便宜了一部分的野心家,即仍称刺史的人以及手中亦有兵权的郡守。分裂之势渐次形成,静待着一个机会爆发。\n●地动仪\n第十四章 后汉的分裂和三国 # 公元189年,灵帝崩。灵帝皇后何氏,生子辩。美人王氏,生子协。灵帝属意于协,未及定而崩,属协于宦者蹇硕。这蹇硕,大约是有些武略的。当黄巾贼起时,汉朝在京城里练兵,共设立八个校尉,蹇硕便是上军校尉,所以灵帝把废嫡立庶的事情付托他。然而这本是不合法的事,皇帝自己办起来,还不免遭人反对,何况在其死后?这自然不能用法律手段解决。蹇硕乃想伏兵把何皇后的哥哥大将军何进杀掉,然后举事。事机不密,被何进知道了,就拥兵不朝。蹇硕无可如何,而辩乃得即位,是为废帝。何进把蹇硕杀掉,因想尽诛宦官。而何氏家本寒微,向来是尊敬宦官的。何太后的母亲和何进的兄弟,又受了宦官的贿赂,替他们在太后面前说好话。太后因此坚持不肯。何进无奈,乃召外兵进京,欲以胁迫太后。宦官见事急,诱进入宫,把他杀掉。何进的官属,举兵尽诛宦官。京城大乱,而凉州将董卓适至,拥兵入京,大权遂尽入其手。董卓只是个强盗的材料。他把废帝废掉,而立协为皇帝,是为献帝。山东州郡起兵反对他,他乃移献帝于长安,接近自己的老家,以便负隅抵抗。东方州郡实在是人各有心的,都各占地盘,无意于进兵追讨。后来司徒王允,和董卓亲信的将官吕布相结,把董卓杀掉。董卓的将校李傕、郭汜,又回兵替董卓报仇。吕布出奔,王允被杀。李傕、郭汜又互相攻击,汉朝的中央政府就从此解纽,不再能号令全国了。\n●《历代帝王图》刘备像,阎立本绘\n各地方割据的:幽州有公孙瓒。冀州有袁绍。兖州有曹操。徐州始而是陶谦,后来成为刘备和吕布争夺之场。扬州,今寿县一带,为袁术所据,江东则入于孙策。荆州有刘表。益州有刘焉。这是较大而在中原之地的,其较小较偏僻的,则汉中有张鲁,凉州有马腾、韩遂,辽东有公孙度。当时政治的重心,是在山东的(古书所谓山东,系指华山以东,今之河南、山东,都包括在内)。袁绍击灭了公孙瓒,又占据了并州,地盘最大,而曹操最有雄才大略。献帝因不堪李傕、郭汜的压迫,逃归洛阳,贫弱不能自立,召曹操入卫,操移献帝于许昌,遂成挟天子以令诸侯之势。刘备为吕布所破,逃归曹操,曹操和他合力,击杀了吕布。袁术因荒淫无度,不能自立,想走归袁绍,曹操又使刘备邀击,术退走,旋死。刘备叛操,操又击破之。河南略定。公元200年,袁绍举大兵南下,与操相持于官渡(城名,在令河南中牟县北),为操所败。绍气愤死。公元205年,绍二子并为操所灭。于是北方无与操抗者。208年,操南征荆州。刘表适死,其幼子琮,以襄阳降(今湖北襄阳县,当时荆州治此)。刘备时在荆州,走江陵。操追败之。备奔刘表的长子琦于江夏(汉郡,后汉时,郡治在今湖北黄冈县境),和孙权合力,败操于赤壁(山名,在今湖北嘉鱼县)。于是刘备屯兵荆州,而孙权亦觊觎其地。后备乘刘焉的儿子刘璋暗弱,夺取益州。孙权想攻荆州,刘备同他讲和,把荆州之地平分了。时马腾的儿子马超和韩遂反叛,曹操击破之。又降伏了张鲁。刘备北取汉中。曹操自争之,不能克,只得退回。天下渐成三分之势。刘备初见诸葛亮时,诸葛亮替他计划,就是据有荆、益两州,天下有变,命将将荆州之兵以向宛、洛,而自率益州之众以出秦川的。这时的形势,颇合乎这个条件。备乃命关羽自荆州北伐,取襄阳,北方颇为震动,而孙权遣兵袭取江陵,羽还救,为权所杀。刘备忿怒,自将大兵攻权,又大败于猇亭(在今湖北宜都县西)。于是荆州全入于吴。备旋以惭愤而死,此事在公元223年。先是220年,曹操死,子丕篡汉自立,是为魏文帝。其明年,刘备称帝于蜀,是为蜀汉昭烈帝。孙权是到229年才称帝的,是为吴大帝。天下正式成为三分之局。蜀的地方最小,只有今四川一省,其云南、贵州,全是未开发之地。吴虽自江陵而下,全据长江以南,然其时江南的开化,亦远在北方之后。所以三国以魏为最强,吴、蜀二国,常合力以与之抗。\n三国的分裂,可以说是两种心理造成的。其一是封建的余习。人心是不能骤变的。在封建时代,本有各忠其君的心理,秦汉以后,虽然统一了,然此等见解,还未能全行破除。试看汉代的士大夫,仕于州郡的,都奉其长官为君,称其机关为本朝,有事为之尽忠,死则为之持服,便可知道。又其一则为南方风气的强悍。赤壁战时,孙权实在没有联合刘备抵抗曹操的必要。所以当时文人持重而顾大局的,如张昭等,都主张迎降。只有周瑜和鲁肃,主张抵抗,和孙权的意见相合。《三国志》载周瑜的话,说曹操名为汉相,实系汉贼,这是劫持众人的门面话,甚或竟是事后附会之谈。东吴的君臣,自始至终,所作所为,何曾有一件事有汉朝在心目之中?说这话要想欺谁?在当时东吴朝廷的空气中,这话何能发生效力?孙权一生,最赏识的是周瑜,次之则是鲁肃。孙权当称帝时,说鲁子敬早有此议,鲁肃如此,周瑜可知。为什么要拥戴孙权做皇帝?这个绝无理由,不过是一种倔犟之气,不甘为人下,孙权的自始便要想做皇帝,则更不过是一种不知分量的野心而已。赤壁之战,是天下三分的关键,其事在公元208年,至280年晋灭吴,天下才见统一,因这一种蛮悍的心理,使战祸延长了七十二年。\n●诸葛亮像\n刘备的嗣子愚弱,所以托孤于诸葛亮。诸葛亮是有志于恢复中原的;而且蜀之国势,非以攻为守,亦无以自立;所以自先主死后,诸葛亮即与吴弃衅言和,连年出兵伐魏。吴则除诸葛恪辅政之时外,多系疆场小战。曹操自赤壁败后,即改从今安徽方面经略东南。三国时,吴、魏用兵,亦都在这一带,彼此均无大成功。魏文帝本来无甚才略。死后,儿子明帝继立,荒淫奢侈,朝政更坏。其时司马懿屡次带兵在关中和诸葛亮相持,又平定了辽东。明帝死后,子齐王芳年幼,司马懿和曹爽同受遗诏辅政。其初大权为曹爽所专。司马懿托病不出,而暗中运用诡谋,到底把曹爽推翻,大权遂尽入其手。司马懿死后,他的儿子司马师、司马昭相继把持朝局。扬州方面,三次起兵反对司马氏,都无成。蜀自诸葛亮死后,蒋琬、费袆继之,不复能出兵北伐。费袆死后,姜维继之,频年出兵北伐而无功,民力颇为疲敝。后主又信任宦官,政局渐坏。司马昭乘此机会,于263年发兵灭蜀。司马昭死后,他的儿子司马炎继之,于265年篡魏,是为晋武帝。至280年而灭吴统一中国。\n第十五章 晋初的形势 # 吴、蜀灭亡,天下复归于统一了,然而乱源正潜伏着。这乱源是什么呢?\n自后汉以来,政治的纲纪久经废弛,试看第十三章所述可知。政治上的纲纪若要挽回,最紧要的是以严明之法行督责之术。魏武帝和诸葛亮都是以此而收暂时的效果的。然而一两个严明的政治家,挽不回社会上江河日下的风气,到魏、晋之世,纲纪又复颓败了。试看清谈之风,起于正始(魏齐王芳年号,自公元240年至248年),至晋初而更甚,直绵延至南朝之末可知。所谓清谈,所谈的就是玄学。玄学的内容,请见第五十三章。谈玄本不是坏事,以思想论,玄学要比汉代的儒学高明得多。不过学问是学问,事实是事实。因学问而忽视现实问题,在常人尚且不可,何况当时因谈玄而蔑视现实的,有许多是国家的官吏,所抛弃的是政治上的职务?\n汉朝人讲道家之学的所崇奉的是黄、老,所讲的是清静不扰,使人民得以各安其生的法术。魏晋以后的人所崇奉的是老、庄,其宗旨为委心任运。狡黠的讲求趋避之术,养成不负责任之风。懦弱的则逃避现实,以求解除痛苦。颓废的则索性蔑视精神,专求物质上的快乐。到底人是现实主义的多,物质容易使人沉溺,于是奢侈之风大盛。当曹爽执政时,曾引用一班名士。虽因政争失败,未能有所作为,然从零碎的材料看来,他们是有一种改革的计划,而其计划且颇为远大的(如夏侯玄有废郡之议,他指出郡已经是供镇压之用,而不是治民事的,从来讲官制的人,没有这么彻底注重民治的)。曹爽等的失败,我们固然很难知其原因所在,然而奢侈无疑的总是其原因之一。代曹爽而起的是司马氏,司马氏是武人,武人是不知义理,亦不知有法度的,一奢侈就可以毫无规范。何曾、石崇等人正是这一个时代的代表。\n●兰亭序。唐代冯承素摹\n封建时代用人本来是看重等级的。东周以后,世变日亟,游士渐起而夺贵族之席。秦国在七国中是最能任用游士的,读李斯《谏逐客书》可见。秦始皇灭六国后,仍保持这个政治习惯,所以李斯能做到宰相,得始皇的信任。汉高起自徒步,一时将相大臣,亦多刀笔吏或家贫无行者流,就更不必说了。汉武帝听了董仲舒的话,改革选法,博士、博士弟子、郡国上计之吏,和州郡所察举的秀才、孝廉,都从广大的地方和各种不同的阶层中来。其他擢用上书言事的人,以及朝廷和各机关的征辟,亦都是以人才为主的。虽或不免采取虚誉,及引用善于奔走运动的人,究与一阶级中人世据高位者不同。魏晋以降,门阀制度渐次形成,影响及于选举,高位多为贵族所盘踞,起自中下阶层中较有活气的人,参与政治的机会较少,政治自然不免腐败。如上章所述,三国时代,南方士大夫的风气,还是颇为剽悍的。自东晋之初,追溯后汉之末,不过百余年,周瑜、鲁肃、吕蒙、陆逊等人物,未必无有(晋初的周处,即系南人,还很有武烈之风)。倘使元帝东渡以后,晋朝能多引用这一班人,则除为国家戡乱以外,更加以民族的敌忾心,必有功效可见。然而大权始终为自北南迁的贵族所把持,使宋武帝一类的人物,直到晋末,才得出现于政治舞台之上,这也是一笔很大的损失。\n两汉时儒学盛行。儒学是封建时代的产物,颇笃于君臣之义的。两汉时,此项运动,亦颇收到相当的效果。汉末政治腐败,有兵权的将帅,始终不敢背叛朝廷(说本《后汉书·儒林传论》)。以魏武帝的功盖天下,亦始终只敢做周文王(参看《三国志·魏武帝纪》建安十五年《注》引是年十二月己亥令,这句句都是真话),就是为此。司马氏的成功是狡黠而不知义理的军阀得势(《晋书·宣帝纪》说:“明帝时,王导侍坐,帝问前世所以得天下,导乃陈帝创业之始,及文帝末高贵乡公事。明帝以面覆床曰:‘若如公言,晋祚复安得长远?’”司马氏之说可见),自此风气急变。宋、齐、梁、陈之君亦多是如此。加以运祚短促,自不足以致人忠诚之心。门阀用人之习既成,贵游子弟,出身便做好官,富贵吾所自有,朝代变换,这班人却并不更动,遂至“忠君之念已亡,保家之念弥切”(说本《南史·禇渊传论》)。中国人自视其国为天下,国家观念,本不甚发达;五胡乱华,虽然稍稍激起民族主义,尚未能发扬光大;政治上的纲纪,还要靠忠君之义维持,而其颓败又如此,政治自更奄奄无生气了。\n秦汉时虽有所谓都尉,调兵和统率之权,是属于太守的。其时所行的是民兵之制,平时并无军队屯聚;一郡的地方大小,亦不足以背叛中央,所以柳宗元说“有叛国而无叛郡”(见其所著《封建论》)。自刺史变为州牧,而地盘始大;即仍称刺史的,其实权亦与州牧无异;郡守亦有执掌兵权的,遂成尾大不掉之势。晋武帝深知其弊,平吴之后,就下令去刺史的兵权,回复其监察之职。然沿袭既久,人心一时难于骤变。平吴之后,不久内乱即起,中央政府,顾不到各地方,仍藉各州郡自行镇压,外重之势遂成,迄南朝不能尽革。\n自秦汉统一之后,国内的兵争既息,用不到人人当兵。若说外征,则因路途窎远,费时失业,人民在经济上的损失太大,于是多用谪发及谪戍。至后汉光武时,省郡国都尉,而民兵之制遂废。第四十五章中,业经说过了。国家的强弱,固不尽系乎兵,然若多数人民,都受过相当的军事训练,到缓急之际,所表现出来的抵抗力,是不可轻侮的。后汉以来,此条件业经丧失,反因贪一时便利之故,多用降伏的异族为兵,兵权倒持在异族手里,遂成为五胡扰乱的直接原因。\n●西晋·青瓷羊尊 长26厘米,江苏南京西岗果木农场墓葬出土\n晋初五胡的形势,是如此的:一、匈奴。散布在并州即今山西省境内。二、羯。史籍上说是匈奴的别种,以居于上党武乡的羯室而得名的(在今山西辽县)。按古书上的种字,不是现在所谓种族之义。古书所谓种或种姓,其意义,与姓氏或氏族相当。羯人有火葬之俗,与氐、羌同,疑系氐、羌与匈奴的混种,其成分且以氐、羌为多。羯室正以羯人居此得名,并非匈奴的一支,因居羯室之地而称羯。三、鲜卑。《后汉书》说东胡为匈奴所破,余众分保乌丸、鲜卑二山,因以为名。事实上,怕亦是山以部族名的。此二山,当在今蒙古东部苏克苏鲁、索岳尔济一带。乌桓在南,鲜卑在北。汉朝招致乌桓,居于上谷、渔阳、右北平、辽西、辽东塞上,以捍御匈奴。后汉时,北匈奴败亡,鲜卑徙居其地。其酋长檀石槐,曾一时控制今蒙古之地,东接夫余(与高句丽同属貉族。其都城,即今吉林的长春县),西至西域。所以乌丸和中国,较为接近,而鲜卑则据地较广。曹操和袁绍相争时,乌丸多附袁绍。袁氏既灭,曹操袭破之于柳城(汉县,今热河凌源县)。乌桓自此式微,而鲜卑则东起辽东,西至今甘肃境内,部族历历散布,成为五胡中人数最多、分布最广的一族。四、氐。氐人本来是居于武都的(即白马氐之地,今甘肃成县),魏武帝怕被蜀人所利用,把他迁徙到关中。五、羌。即后汉时叛乱之余。氐、羌都在泾、渭二水流域。当时的五胡大部分是居于塞内的,间或有在塞外的,亦和边塞很为接近。其人亦多散处民间,从事耕织,然犷悍之气未消,而其部族首领,又有野心勃勃,想乘时恢复故业的。一旦啸聚起来,“掩不备之人,收散野之积”(江统《徙戎论》语),其情势,自又非从塞外侵入之比。所以郭钦、江统等要想乘天下初定,用兵力将他们迁回故地。这虽不是民族问题根本解决之方,亦不失为政治上一时措置之策,而晋武帝因循不能用。\n●北燕·铜虎子 通长38厘米,高24厘米,辽宁北票县西官营子冯素弗墓出土\n第十六章 五胡之乱(上) # 五胡之乱,已经蓄势等待着了,而又有一个八王之乱(八王,谓汝南王亮、楚王玮、赵王伦、齐王冏、长沙王乂、成都王颖、河间王颙、东海王越),做它的导火线。封建亲戚以为屏藩之梦,此时尚未能醒。我们试看:魏武帝于建安十五年十二月己亥下令,说从前朝廷恩封我的几个儿子,我辞而不受,现在想起来,却又要受了,因为执掌政权年久,怕要谋害我的人多,想借此自全之故,就可见得这时候人的思想。魏虽亦有分封之制,但文帝当未做魏世子时,曾和他的兄弟争立,所以猜忌宗室诸王特甚,名为分藩,实同囚禁,绝不能牵掣晋朝的篡弑。晋人有鉴于此,所以得国之后,就大封同姓,体制颇为崇隆,而且各国都有卫兵。晋武帝是文帝的儿子,景帝之后,自然不甘退让。在武帝时,齐王攸颇有觊觎储位之意,似乎也有党附于他的人。然未能有成,惠帝卒立。惠帝是很昏愚的,其初太后父杨骏执政,皇后贾氏和楚王玮合谋,把他杀掉,而用汝南王亮,又把他杀掉,后又杀楚王,旋弑杨太后。太子遹非后所生,后亦把他废杀。赵王伦时总宿卫,因人心不服,弑后,遂废惠帝而自立。时齐王冏镇许昌,成都王颖镇邺(今河南临漳县),河间王颙镇关中,连兵攻杀伦。惠帝复位,齐王入洛专政。河间王颙和长沙王乂合谋攻杀之,又和成都王颖合谋,攻杀乂。东海王越合幽、并两州的兵,把河间、成都两王打败,遂弑惠帝而立怀帝。此等扰乱之事,在公元291至306的十六年间。\n匈奴单于,自后汉之末失位,入居中国。单于死后,中国分其部众为五,各立酋帅。其中左部最强,中国将其酋帅羁留在邺,以资驾驭,至晋初仍未释放。东海王之兵既起,刘渊说成都王回去合五部之众,来帮他的忙,成都王才释放了他。刘渊至并州,遂自立,是为十六国中的前赵。此时中原之地,盗贼蜂起,刘渊如能力征经营,很可以有所成就。然刘渊是个无甚才略的人,自立之后,遂安居不出。羯人石勒,才略却比较优长。东方群盗,尽为所并。名虽服从前赵,实则形同独立。东海王既定京师,出兵征讨,死于军中,其兵为石勒所追败。晋朝遂成坐困之势。310年,刘渊的族子刘曜攻破洛阳,怀帝被虏。明年,被弑。愍帝立于长安。316年,又被虏。明年,被弑。元帝时督扬州,从下邳迁徙到建业(下邳,今江苏邳县。建业,今南京。东晋后避愍帝讳,改曰建康),自立,是为东晋元帝。此时,在北方,只有幽州刺史王浚、并州刺史刘琨,崎岖和戎狄相持。南方则豫州刺史祖逖,从淮北经略今之豫东,颇有成绩。然王浚本是个狂妄的人,刘琨则窘困太甚,终于不能支持,为石勒所破灭。祖逖因中央和荆州互相猜忌,知道功不能成,愤慨而死,就无能抗拒石勒的人。328年,勒灭前赵。除割据凉州的前凉,辽东、西的前燕外,北方几尽入其手。\n●南齐·齐宣帝萧承之永安陵天禄 长2.95米,高2.75米,位于江苏丹阳胡桥乡狮子湾\n南方的情势,是荆州强于扬州。元帝即位之后,要想统一上流的事权,乃派王敦去都督荆州。王敦颇有才能,能把荆州的实权收归掌握,却又和中央互相猜忌。322年,终于决裂。王敦的兵,入据京城。元帝忧愤而死。子明帝立,颇有才略。乘王敦病死,把其余党讨平。然明帝在位仅三年。明帝崩,子成帝立,年幼,太后庾氏临朝,后兄庾亮执政,和历阳内史苏峻不协(今安徽和县)。苏峻举兵造反,亮奔温峤于寻阳(今江西九江县)。温峤是很公忠体国的,邀约荆州刺史陶侃,把苏峻打平。陶侃时已年老,故无跋扈之心。侃死后,庾亮出镇荆州。庾亮死后,其弟庾翼、庾冰继之。此时内外的大权,都在庾氏手里,所以成帝、康帝之世,相安无事。344年,康帝崩,子穆帝立。明年,庾翼死,表请以其子继任,宰相何充不听,而用了桓温。于是上下流之间,又成对立之势了。\n石勒死于333年。明年,勒从子虎杀勒子而自立。石虎是个淫暴无人理的,然兵力尚强。庾翼于342年出兵北伐,未能有功。349年,石虎死,诸子争立。汉人冉闵为虎养子,性颇勇悍,把石虎诸子尽行诛灭。闵下令道:“与官同心者住,不同心者各任所之。”于是“赵人百里内悉入城”,而“胡、羯去者填门”。闵知胡之不为己用,遂下令大诛胡、羯。单是一个邺中,死者就有二十多万。四方亦都承令执行。胡、羯经此打击,就不能再振了。先是鲜卑慕容廆,兴于辽西,兼并辽东。至其子皝,迁都龙城(今热河朝阳县)。慕容氏是远较前、后赵为文明的,地盘既广,兵力亦强。石虎死的前一年,慕容皝死,子儁立,乘北方丧乱,侵入中原。冉闵与战,为其所杀。于是河北之地,尽入于慕容氏。羌酋姚弋仲、氐酋苻洪,其初为后赵所压服的,至此亦乘机自立。苻洪死,子苻健入关。姚弋仲死,其子姚襄降晋,想借晋力以自图发展。晋朝因和桓温互相猜忌,引用了名士殷浩做宰相,想从下流去经略中原。殷浩亦不是没有才能的人,但扬州势成积弱,殷浩出而任事,又没有一个相当的时间以资准备,自然只得就固有的力量加以利用。于是即用姚襄为前锋,反为其所邀击,大败,军资丧失甚众。此事在354年。先是桓温已灭前蜀,至此,遂迫胁朝廷,废掉殷浩,他却出兵北伐,击破了姚襄,恢复洛阳,然亦未能再进。慕容儁死后,子慕容继之,虽年幼无知,然有慕容恪辅政,慕容垂带兵,仍有相当的力量。姚襄败后入关,为秦人所杀,弟苌以众降秦。秦苻健死后,子生无道,为苻坚所弑,自立,能任用王猛以修国政,其势尤张。此时的北方,已较难图,所以当后赵、冉闵纷纭争夺之时,晋朝实在坐失了一个恢复中原的机会。此时燕人频年出兵,以经略河南,洛阳又为所陷。369年,桓温出兵伐燕,大败于枋头(城名,今河南濬县)。桓温之意,本来要立些功业,再图篡夺的。至此,自顾北伐己无成功之望,乃于371年入朝,行废立之事(康帝崩,子穆帝立。崩,成帝子哀帝立。崩,弟海西公立。至是为桓温所废,而立元帝子简文帝)。温以禅让之意,讽示朝臣。谢安、王坦之当国,持之以静。373年,桓温死。他的兄弟桓冲,是个没有野心的人,把荆州让出,政局乃获暂安。\n第十七章 五胡之乱(下) # 东晋时的五胡十六国,实在并不成其为一个国家,所以其根基并不稳固。看似声势雄张,只是没有遇见强敌,一战而败,遂可以至于覆亡。枋头战后,慕容垂因被猜忌出奔。前秦乘机举兵,其明年,前燕竟为所灭。前秦又灭掉前凉,又有统一北方之势,然其根基亦并不是稳固的。此时北方的汉族,因为没有政府的领导,虽有强宗巨室和较有才力的人,能保据一隅,或者潜伏山泽,终产生不出一个强大的政权来,少数的五胡,遂得横行无忌。然他们亦是人各有心,而且野蛮成习,颇难于统驭的。五胡中苟有英明的酋长出来,亦只得希望汉族拥戴他,和他一心,要联合许多异族以制汉族,根本上是没有这回事的。若要专恃本族,而把汉族以外的异族铲除,则(一)因限于实力,(二)则汉族此时,并不肯替此等异族出死力,而此等异族,性本蛮悍,加以志在掠夺,用之为兵,似乎颇为适宜,所以习惯上都是靠它们做主力的军队,尽数剪除,未免削弱兵力,所以其势又办不到。苻坚的政策,是把氐人散布四方,行驻防政策,而将其余被征服的异族置之肘腋之下,以便监制。倘使他的威力,能够始终维持,原亦未为非计。然若一朝失足,则氐人散处四方,不能聚集,无复基本队伍,就糟了。所以当时,苻坚要想伐晋以图混一,他手下的稳健派,如王猛,如其兄弟苻融等,都是反对的。而苻坚志得意满,违众举兵,遂以383年大败于淝水。北方异族,乘机纷纷而起。而慕容垂据河北为后燕,姚苌据关中为后秦。苻坚于385年为姚苌所杀。子丕,族子登,相继自立,至394年,卒为姚苌之子姚兴所灭。此时侵入中原的五胡,已成强弩之末。因为频年攻战,死亡甚多,人口减少,而汉族的同化作用,仍在逐渐进行,战斗力也日益衰弱。其仍居塞外的,却比较气完力厚。此等情势,自公元四世纪末,夏及拓跋魏之兴,至六世纪前半尔朱氏、宇文氏等侵入中原,迄未曾变。自遭冉闵的大屠戮后,胡、羯之势,业已不能复振。只有匈奴铁弗氏,根据地在新兴(今山西忻县),还是一个比较完整的部落。拓跋氏自托于黄帝之后,说其初建国北荒,后来南迁大泽,因其地“昏冥沮洳”,乃再南迁至匈奴故地。自托于黄帝之后,自不足信,其起源发迹之地,该不是骗人的。它大约自西伯利亚迁徙到外蒙古,又逐渐迁徙到内蒙古的。晋初,其根据地在上谷之北,今滦河上源之西。刘琨藉其兵力以御匈奴,畀以雁门关以北之地。拓跋氏就据有平城,东至今察哈尔的西部。这时候,自辽东至今热河东部,都是慕容氏的势力范围。其西为宇文氏,再西就是拓跋氏。慕容氏盛时,宇文氏受其压迫,未能自强,拓跋氏却不然。拓跋氏和匈奴铁弗氏是世仇。苻坚时,拓跋氏内乱,铁弗的酋长刘卫辰引秦兵把他打破。苻坚即使刘卫辰和其族人刘库仁分管其部落。刘库仁是拓跋氏的女婿,反保护其遗裔拓跋珪。其时塞外,从阴山至贺兰山,零星部落极多,拓跋珪年长后,逐渐加以征服,势力复张。刘卫辰为其所灭,其子勃勃奔后秦,姚兴使其守御北边,勃勃遂叛后秦自立。后秦屡为所败,国势益衰。395年,慕容垂之子宝伐后魏,大败于参合陂(今山西阳高县)。明年,慕容垂自将伐魏。魏人退出平城,以避其锋。慕容垂入平城,而实无所得。还至参合陂,见前此战败时的尸骸,堆积如山,军中哭声振天,惭愤而死。慕容宝继立。拓跋珪大举来攻,势如排山倒海。慕容宝弃其都城中山,逃到龙城,被弑。少子盛定乱自立,旋亦被弑。弟熙立,因淫虐,为其将冯跋所篡,是为北燕。其宗族慕容德南走广固(今山东益都县西),自立,是为南燕。拓跋珪服寒食散,散发不能治事,不复出兵。北方形势,又暂告安静。\n●北魏·元邵墓陶俑 河南省洛阳市出土。前为镇墓兽,后为陶武士俑、陶骑马乐俑等。陶俑形体修长,挺拔劲健,眉目端丽,表现出特有的时代风貌\n南方当这时候,却产生出一种新势力来。晋朝从东渡以后,长江上流的形势,迄较下流为强,以致内外相持,坐视北方的丧乱而不能乘。当淝水战前六年,谢玄镇广陵(今江苏江都县),才创立一支北府兵,精锐无匹,而刘牢之为这一支军队的领袖。淝水之战,就是倚以制胜的。下流的形势,至此实已较上流为强。东晋孝武帝,是一个昏聩糊涂的人。始而信任琅邪王道子,后来又猜忌他,使王恭镇京口(今江苏镇江),殷仲堪镇江陵以防之。慕容垂死的一年,孝武帝也死了,子安帝立。398年,王恭、殷仲堪同时举兵。道子嗜酒昏愚,而其世子元显,年少有些才气。使人勾结刘牢之倒戈,王恭被杀。而上流之兵已逼,牢之不肯再战。殷仲堪并不会用兵,军事都是委任南郡相杨佺期的(南郡,治江陵)。而桓温的小儿子桓玄在荆州,仍有势力,此时亦在军中。晋朝乃以杨佺期刺雍州,桓玄刺江州,各给了一个地盘,上流之兵才退。后来殷仲堪和杨佺期,都给桓玄所并。402年,元显乘荆州饥馑,举兵伐玄,刘牢之又倒戈,桓玄入京城,元显和道子都被杀。桓玄是个狂妄的人,得志之后,夺掉了刘牢之的兵权,牢之谋反抗,而手下的人,不满他的屡次倒戈,不肯服从,牢之自缢而死。桓玄以为天下无事了,就废安帝自立。然刘牢之虽死,北府兵中人物尚多。404年,刘裕等起兵讨玄,玄遂败死。安帝复位。刘裕入居中央,掌握政权,一时的功臣,都分布州郡,南方的形势一变。\n●北齐·公牛与神兽图 山西省太原市王郭村北齐娄睿墓甬道壁画。公牛身躯壮健,作昂首前进状,造型比例准确,线条简练,前后各有二神兽围护\n409年,刘裕出兵灭南燕。想要停镇下邳,经营河、洛,而后方又有变故。先是399年,孙恩起兵会稽(今浙江绍兴),剽掠沿海。后为刘牢之及刘裕所破,入海岛而死。其党卢循袭据广州。桓玄不能讨,用为刺史。卢循又以其妹夫徐道覆为始兴相(今广东曲江县)。刘裕北伐时,卢循、徐道覆乘机北出,沿江而下,直逼京城。此时情势确甚危急。刘裕速回兵,以疲敝之众,守住京城。卢循、徐道覆不能克,退回上流,为裕所袭败。裕又遣兵从海道袭据广州,把他们打平。刘裕于是剪除异己。至417年,复大举以灭后秦。此时后魏正值中衰;凉州一隅,自前秦亡后,复四分五裂,然其中并无强大之国(氐酋吕光,为苻坚将,定西域。苻坚败后,据姑臧自立,是为后凉。后匈奴酋沮渠蒙逊据张掖叛之,为北凉。汉族李暠据敦煌,为西凉。鲜卑秃发乌孤据乐都为南凉。后凉之地遂分裂。又有鲜卑乞伏国仁,据陇右,为西秦。后凉为后秦所灭。西凉为北凉所灭。南凉为西秦所灭。西秦为夏所灭。北凉为后魏所灭。姑臧,今甘肃武威县。张掖、敦煌,今县皆同名。乐都,今碾伯县。西秦初居勇士川,在今甘肃金县后徙苑川,在今甘肃靖远县);夏虽有剽悍之气,究系偏隅小国;倘使刘裕能在关中驻扎几年,扩清扫荡之效,是可以预期的,则当南北朝分立之初,海内即可有统一之望,以后一百七十年的分裂之祸,可以免除了。然旧时的英雄,大抵未尝学问。个人权势意气之争,重于为国为民之念。以致同时并起,资望相等的人物,往往不能相容,而要互相剪灭,这个实在使人才受到一个很大的损失。刘裕亦是如此,到灭秦时,同起义兵诸人,都已被剪除尽了。手下虽有几个勇将,资格都是相等的,谁亦不能统率谁。而刘裕后方的机要事务,全是交给一个心腹刘穆之的,这时候,刘穆之忽然死了,刘裕放心不下,只得弃关中而归,留一个小儿子义真,以镇守长安。诸将心力不齐,长安遂为夏所陷。刘裕登城北望,流涕而已。内部的矛盾,影响到对外,真可谓深刻极了。420年,刘裕篡晋,是为宋武帝。三年而崩。子少帝立,为宰相徐羡之等所废,迎立其弟文帝。文帝亦是个中主,然无武略,而功臣宿将,亦垂垂向尽。自北府兵创立至此,不足五十年,南方新兴的一种中心势力,复见衰颓。北魏拓跋珪自立,是为道武帝。道武帝末年,势颇不振。子明元帝,亦仅谨守河北。明元帝死,子太武帝立,复强。公元431年,灭夏。436年,灭燕。凉州之地,亦皆为其所吞并。天下遂分为南北朝。\n第十八章 南北朝的始末 # 南北朝的对立,起于公元420年宋之代晋,终于公元589年隋之灭陈,共一百七十年。其间南北的强弱,以宋文帝的北伐失败及侯景的乱梁为两个重要关键。南朝的治世,只有宋文帝和梁武帝在位时,历时较久。北方的文野,以孝文的南迁为界限,其治乱则以尔朱氏的侵入为关键。自尔朱氏、宇文氏等相继失败后,五胡之族,都力尽而衰,中国就复见盛运了。\n宋文帝即位后,把参与废立之谋的徐羡之、傅亮、谢晦等都诛灭。初与其谋而后来反正的檀道济,后亦被杀。于是武帝手里的谋臣勇将,几于靡有孑遗了。历代开国之主,能够戡定大乱、抵御外患的,大抵在政治上、军事上,都有卓绝的天才,此即所谓文武兼资。而其所值的时局,难易各有不同。倘使大难能够及身戡定,则继世者但得守成之主,即可以蒙业而安。如其不然,则非更有文武兼资的人物不可。此等人固不易多得,然人之才力,相去不远,亦不能谓并时必无其人;尤其做一番大事业的人,必有与之相辅之士。倘使政治上无家天下的习惯,开国之主,正可就其中择贤而授,此即儒家禅让的理想,国事实受其益了。无如在政治上,为国为民之义,未能彻底明了,而自封建时代相沿下来的自私其子孙,以及徒效忠于豢养自己的主人的观念,未能打破,而君主时代所谓继承之法,遂因之而立。而权利和意气,都是人所不能不争的,尤其以英雄为甚。同干一番事业的人,遂至不能互相辅助,反要互相残杀,其成功的一个人,传之于其子孙,则都是生长于富贵之中的,好者仅得中主,坏的并不免荒淫昏暴,或者懦弱无用。前人的功业,遂至付诸流水,而国与民亦受其弊。这亦不能不说是文化上的一个病态了。宋初虽失关中,然现在的河南、山东,还是中国之地。宋武帝死后,魏人乘丧南伐,取青、兖、司、豫四州(时青州治广固,兖州治滑台,司州治虎牢,豫州治睢阳。滑台,今河南滑县。虎牢,今河南汜水县。睢阳,今河南商丘县)。此时的魏人,还是游牧民族性质,其文化殊不足观,然其新兴的剽悍之气,却亦未可轻视,而文帝失之于轻敌。430年,遣将北伐,魏人敛兵河北以避之,宋朝得了虎牢、滑台而不能继续进取,兵力并不足坚守。至冬,魏人大举南下,所得之地复失。文帝经营累年,至450年,又大举北伐。然兵皆白丁,将非材勇,甫进即退。魏太武帝反乘机南伐,至于瓜步(镇名,今江苏六合县),所过之处,赤地无余,至于燕归巢于林木,元嘉之世,本来称为南朝富庶的时代的,经此一役,就元气大伤了,而北强南弱之势,亦于是乎形成。\n公元453年,宋文帝为其子劭所弑。劭弟孝武帝,定乱自立。死后,子前废帝无道,为孝武弟明帝所废。孝武帝和明帝都很猜忌,专以屠戮宗室为务。明帝死后,大权遂为萧道成所窃。荆州的沈攸之,和宰相袁粲,先后谋诛之,都不克。明帝子后废帝及顺帝,都为其所废。479年,道成遂篡宋自立,是为齐高帝。在位四年。子武帝,在位十一年。高、武二帝,都很节俭,政治较称清明。武帝太子早卒,立大孙郁林王,为武帝兄子明帝所废。明帝大杀高、武二帝子孙。明帝死后,子东昏侯立。时梁武帝萧衍刺雍州,其兄萧懿刺豫州。梁武帝兄弟,本与齐明帝同党。其时江州刺史陈显达造反,东昏侯使宿将崔慧景讨平之。慧景还兵攻帝,势甚危急,萧懿发兵入援,把他打平。东昏侯反把萧懿杀掉,又想削掉萧衍。东昏侯之弟宝融,时镇荆州,东昏侯使就其长史萧颖胄图之。颖胄奉宝融举兵,以梁武帝为前锋。兵至京城,东昏侯为其下所弑。宝融立,是为和帝。旋传位于梁,此事在502年。\n梁武帝在位四十八年,其早年政治颇清明。自宋明帝时,和北魏交兵,尽失淮北之地。齐明帝时又失沔北。东昏侯时,因豫州刺史裴叔业降魏,并失淮南(时豫州治寿阳,今安徽寿县)。梁武帝时,大破魏兵于锺离(在今安徽凤阳县),恢复了豫州之地。对外的形势,也总算稳定。然梁武性好佛法,晚年刑政殊废弛。又因太子统早卒,不立嫡孙而立次子简文帝为太子,心不自安,使统诸子出刺大郡,又使自己的儿子出刺诸郡,以与之相参。彼此乖离,已经酝酿着一个不安的形势。而北方侯景之乱,又适于此时发作。\n北魏太武帝,虽因割据诸国的不振,南朝的无力恢复,侥幸占据了北方,然其根本之地,实在平城,其视中国,不过一片可以榨取利益之地而已。他还不能自视为和中国一体,所以也不再图南侵。因为其所有的,业已不易消化了。反之,平城附近,为其立国根本之地,却不可不严加维护。所以魏太武帝要出兵征伐柔然、高车,且于北边设立六镇(武川,今绥远武川县。抚冥,在武川东。怀朔,在今绥远五原县。怀荒,在今大同东北察哈尔境内。柔玄,在今察哈尔兴和县。御夷,在今察哈尔沽源县),盛简亲贤,配以高门子弟,以厚其兵力。孝文帝是后魏一个杰出人物。他仰慕中国的文化,一意要改革旧俗。但在平城,终觉得环境不甚适宜。乃于公元493年,迁都洛阳。断北语,改姓氏,禁胡服,奖励鲜卑人和汉人通婚,自此以后,鲜卑人就渐和汉人同化了。然其根本上的毛病,即以征服民族自居,视榨取被征服民族以供享用为当然之事,因而日入于骄奢淫逸,这是不能因文明程度的增进而改变的,而且因为环境的不同,其流于骄奢淫逸更易。论者因见历来的游牧民族同化于汉族之后,即要流于骄奢淫逸,以致失其战斗之力,以为这是中国的文明害了它,摹仿了中国的文明,同时亦传染了中国的文明病。其实它们骄奢淫逸的物质条件,是中国人供给它的,骄奢淫逸的意志,却是它们所自有;而这种意志,又是与其侵略事业,同时并存的,因为它们的侵略,就是它们的生产事业。如此,所以像金世宗等,要禁止他的本族人华化,根本是不可能的。因为不华化,就是要一切生活都照旧,那等于只生产而不消费,经济学上最后的目的安在呢?所以以骄奢淫逸而灭亡,殆为野蛮的侵略民族必然的命运。后魏当日,便是如此。孝文帝传子宣武帝至孝明帝。年幼,太后胡氏临朝。荒淫纵恣,把野蛮民族的病态,悉数现出。中原之民,苦于横征暴敛,群起叛乱。而六镇将士,因南迁以后,待遇不如旧时,魏朝又怕兵力衰颓,禁其浮游在外,亦激而生变。有一个部落酋长,唤作尔朱荣,起而加以镇定。尔朱氏是不曾侵入中原的部族,还保持着犷悍之风。胡太后初为其亲信元义等所囚,后和明帝合谋,把他们诛灭。又和明帝不协。明帝召尔朱荣入清君侧,已而又止之。胡太后惧,弑明帝。尔朱荣举兵入洛,杀胡太后而立孝庄帝。其部众既劲健,而其用兵亦颇有天才。中原的叛乱,都给他镇定了。然其人起于塞外,缺乏政治手腕,以为只要靠兵力屠杀,就可以把人压服。当其入洛之日,就想做皇帝,乃纵兵士围杀朝士二千余人。居民惊惧,逃入山中,洛阳只剩得一座空城。尔朱荣无可如何,只得退居晋阳,遥执朝权。然其篡谋仍不息。孝庄帝无拳无勇,乃利用宣传为防御的工具。当尔朱荣篡谋急时,孝庄帝就散布他要进京的消息,百姓就逃走一空,尔朱荣只得自止。到后来,看看终非此等手段所能有济了。530年,乃索性召他入朝。孝庄帝自藏兵器于衣内,把他刺死。其侄儿尔朱兆,举兵弑帝,别立一君。此时尔朱氏的宗族,分居重镇,其势力如日中天。然尔朱兆是个鲁莽之夫,其宗族中人,亦与之不协。532年,其将高欢起兵和尔朱氏相抗。两军相遇于韩陵(山名,在今河南安阳县),论兵力,尔朱氏是远过于高欢,然因其暴虐过甚,高欢手下的人都齐心死战,而尔朱氏却心力不齐,遂至大败。晋阳失陷,尔朱兆逃至秀容川(在今山西朔县),为高欢所掩杀。其余尔朱氏诸人亦都被扑灭。高欢入洛,废尔朱氏所立,而别立孝武帝。高欢身居晋阳,继承了尔朱荣的地位。孝武帝用贺拔岳为关中大行台,图与高欢相抗。高欢使其党秦州刺史侯莫陈悦杀岳(秦州,今甘肃天水县)。夏州刺史宇文泰攻杀悦(夏州,今陕西横山县),孝武帝即以泰继岳之任。534年,孝武帝举兵讨欢,高欢亦自晋阳南下,夹河而军,孝武帝不敢战,奔关中,为宇文泰所弑。于是高欢、宇文泰,各立一君,魏遂分为东、西。至550年,而东魏为高欢子洋所篡,是为北齐文宣帝。557年,西魏为宇文泰之子觉所篡,是为北周孝闵帝。\n●麦积山石窟佛像。高1.635米\n当东、西魏分裂后,高欢、字文泰曾剧战十余年,彼此都不能逞志,而其患顾中于梁。这时候,北方承剧战之后,兵力颇强,而南方武备久废弛,欲谋恢复,实非其时,而梁武帝年老昏耄,却想乘机侥幸,其祸就不可免了。高欢以547年死。其将侯景,是专管河南的,虽然野蛮粗鲁,在是时北方诸将中,已经算是狡黠的了。高欢死后,其子高澄,嗣为魏相。侯景不服,遂举其所管之地来降。梁武帝使子渊明往援,为魏所败,渊明被擒。侯景逃入梁境,袭据寿阳,梁朝不能制。旋又中魏人反间之计,想牺牲侯景,与魏言和。侯景遂反,进陷台城(南朝之宫城),梁武帝忧愤而崩,时为549年。子简文帝立,551年,为侯景所弑。武帝子湘东王绎即位于江陵,是为元帝。时陈武帝陈霸先自岭南起兵勤王。元帝使其与王僧辩分道东下,把侯景诛灭。先是元帝与诸王,互相攻击。郢州的邵陵王纶(郢州,今湖北武昌县。纶,武帝子),湘州的河东王誉(誉、詧皆昭明太子统之子),皆为所并。襄阳的岳阳王詧,则因求救于西魏而得免。至元帝即位后,武陵王纪亦称帝于成都(纪,武帝子),举兵东下。元帝亦求救于西魏,西魏袭陷成都。武陵王前后受敌,遂败死。而元帝又与西魏失和。554年,西魏陷江陵,元帝被害。魏人徙岳阳王詧于江陵,使之称帝,而对魏则称臣,是为西梁。王僧辩、陈霸先立元帝之子方智于建康,是为敬帝。而北齐又送渊明回国,王僧辩战败,遂迎立之。陈霸先讨杀僧辩,奉敬帝复位。557年,遂禅位于陈。这时候,梁朝骨肉相残,各引异族为助,南朝几至不国。幸得陈武帝智勇足备,卓然不屈,才得替汉族保存了江南之地。\n陈武帝即位后三年而崩。无子,传兄子文帝。文帝死后,弟宣帝,废其子废帝而代之。文、宣二帝,亦可称中主,但南方当丧乱之余,内部又多反侧,所以不能自振。北方则北齐文宣、武成二帝,均极荒淫。武成帝之子纬,尤为奢纵。而北周武帝,颇能励精图治。至577年,齐遂为周所灭。明年,武帝死,子宣帝立,又荒淫。传位于子静帝,大权遂入后父杨坚之手。581年,坚废静帝自立,是为隋文帝。高齐虽自称是汉族,然其性质实在是胡化了的。隋文帝则勤政恤民,俭于自奉,的确是代表了汉族的文化。自西晋覆亡以来,北方至此才复建立汉人统一的政权。此时南方的陈后主,亦极荒淫。589年,为隋所灭。西梁则前两年已被灭。天下复见统一。\n两晋、南北朝之世,是向来被看做黑暗时代的,其实亦不尽然。这一时代,只政治上稍形黑暗,社会的文化,还是依然如故。而且正因时局的动荡,而文化乃得为更大的发展。其中关系最大的,便是黄河流域文明程度最高的地方的民族,分向各方面迁移。《汉书·地理志》叙述楚地的生活情形,还说江南之俗,火耕水耨,果蓏蜯蛤,饮食还足,故呰窳婾生,而无积聚,而《宋书·孔季恭传》叙述荆、扬二州的富力,却是“膏腴上地,亩直一金,鄠、杜之间不能比”(鄠,今陕西鄠县;杜,在今陕西长安县南,汉时农业盛地价高之处);又说:“鱼、盐、杞、梓之利,充仞八方,丝棉、布帛之饶,覆衣天下”,成为全国富力的中心了。三国之世,南方的风气,还是很剽悍的,读第十四章所述可见。而自东晋以来,此种风气,亦潜移默化。谈玄学佛,成为全国文化的重心,这是最彰明较著的。其他东北至辽东,西南至交阯,莫不有中原民族的足迹,其有裨于增进当地的文化,亦决非浅鲜,不过不如长江流域的显著罢了。还有一层,陶潜的《桃花源诗》,大家当他是预言,其实这怕是实事。自东汉之末,至于南北朝之世,北方有所谓山胡,南方有所谓山越。听了胡、越之名,似乎是异族蛰居山地的,其实不然。试看他们一旦出山,便可和齐民杂居,服兵役,输赋税,绝无隔阂,便可知其实非异族,而系汉族避乱入山的。此等避乱入山的异族,为数既众,历时又久,山地为所开辟,异族为所同化的,不知凡几,真是拓殖史上的无名英雄了。以五胡论:固然有荒淫暴虐,如石虎,齐文宣、武成之流的,实亦以能服从汉族文化的居其多数。石勒在兵戈之际,已颇能引用士人,改良政治。苻坚更不必说。慕容氏兴于边徼,亦是能慕效中国的文明的。至北魏孝文帝,则已举其族而自化于汉族。北周用卢辩、苏绰,创立法制,且有为隋、唐所沿袭的。这时候的异族,除血统之外,几乎已经说不出其和汉族的异点了。一到隋唐时代,而所谓五胡,便已泯然无迹,良非偶然。\n第十九章 南北朝隋唐间塞外的形势 # 葱岭以东,西伯利亚以南,后印度半岛以东北,在历史上实自成其为一个区域。这一个区域中,以中国的产业和文化最为发达,自然成为史事的重心。自秦汉至南北朝,我们可以把它看做一个段落,隋唐以后,却又是一个新段落了。这一个新段落中,初期的形势,乃是从五胡侵入中原以后逐渐酝酿而成的,在隋唐兴起以前,实有加以一番检讨的必要。\n漠南北之地,对于中国是一个最大的威胁。继匈奴而居其地的为鲜卑。自五胡乱华以来,鲜卑纷纷侵入中国。依旧保持完整的只有一个拓跋氏,然亦不过在平城附近。自此以东,则有宇文氏的遗落奚、契丹,此时部落尚小。其余的地方都空虚了,铁勒乃乘机入据。铁勒,异译亦作敕勒,即汉时的丁令。其根据地,东起贝加尔湖,西沿西域之北,直抵里海。鲜卑侵入中原后,铁勒踵之而入漠北。后魏道武帝之兴,自阴山以西,漠南零星的部落,几于尽被吞并。只有一个柔然不服,为魏太武帝所破,逃至漠北,臣服铁勒,藉其众以抗魏。魏太武帝又出兵把它打破。将降伏的铁勒迁徙到漠南。这一支,历史上特称为高车,其余则仍称铁勒。南北朝末年,柔然又强了。东、西魏和周、齐都竭力敷衍它。后来阿尔泰山附近的突厥强盛。公元552年,柔然为其所破。突厥遂征服漠南北,继承了柔然的地位,依旧受着周、齐的敷衍。\n西域对中国,是无甚政治关系的,因为它不能侵略中国,而中国当丧乱之时,亦无暇经营域外之故。两晋、南北朝之世,只有苻坚,曾遣吕光去征伐过一次西域,其余都在平和的状态中。但彼此交通仍不绝。河西一带,商业亦盛,这只要看这一带兼用西域的金银钱可知。西域在这时期,脱离了中国和匈奴的干涉,所以所谓三十六国者,得以互相吞并。到隋唐时,只剩得高昌、焉耆、龟兹、于阗等几个大国。\n东北的文明,大略以辽东、西和汉平朝鲜后所设立的四郡为界线。自此以南,为饱受中国文明的貉族。自此以北,则为未开化的满族,汉时称为挹娄,南北朝、隋、唐时称为勿吉,亦作靺鞨。貉族的势力,在前汉时,曾发展到今吉林省的长春附近,建立一个夫余国。后汉时,屡通朝贡。晋初,为鲜卑慕容氏所破。自此渐归澌灭,而辽东、西以北,乃全入鲜卑和靺鞨之手。貉族则专向朝鲜半岛发展,其中一个部落,唤做高句丽的,自中国对东北实力渐衰,遂形成一个独立国。慕容氏侵入中原后,高句丽尽并辽东之地,侵略且及于辽西。其支族又于其南建立一个百济国。半岛南部的三韩,自秦时即有汉人杂居,谓之秦韩。后亦自立为国,谓之新罗。高句丽最强大,其初新罗、百济,尝联合以御之,后百济转附高句丽,新罗势孤,乃不得不乞援于中国,为隋唐时中国和高句丽、百济构衅的一个原因。\n南方海路的交通,益形发达。前后印度及南洋群岛,入贡于中国的很多。中国是时,方热心于佛学,高僧往印度求法,和彼土高僧来中国的亦不少。高句丽、百济,亦自海道通南朝。日本当后汉时,其大酋始自通于中国。至东晋以后,亦时向南朝通贡,传受了许多文明。侯景乱后,百济贡使到建康来,见城阙荒毁,至于号恸涕泣,可见东北诸国,对我感情的深厚了。据阿剌伯人所著的古旅行记,说公元1世纪后半,西亚的海船,才达到交阯。公元1世纪后半,为后汉光武帝至和帝之时。其后桓帝延熹九年,当公元166年,而大秦王安敦(Marcus Aurelius Antoninus,生于公元121年,即后汉安帝建光六年,没于180年,即后汉灵帝光和三年),遣使自日南徼外通中国,可见这记载的不诬。他又说:公元3世纪中叶,中国商船开始西向,从广州到槟榔屿,4世纪至锡兰,5世纪至亚丁,终至在波斯及美索不达米亚独占商权。到7世纪之末,阿剌伯人才与之代兴。3世纪中叶,当三国之末,7世纪之末,则当唐武后时。这四百五十年之中,可以说是中国人握有东西洋航权的时代了。至于偶尔的交通所及,则还不止此。据《梁书·诸夷传》:倭东北七千余里有文身国,文身国东五千余里有大汉国,大汉国东二万余里有扶桑国。这扶桑国或说它是现在的库页岛,或说它是美洲的墨西哥,以道里方向核之,似乎后说为近。据《梁书》所载:公元499年,其国有沙门慧深来至荆州,又晋时法显著《佛国记》,载其到印度求法之后,自锡兰东归,行三日而遇大风,十三日到一岛,又九十余日而到耶婆提,自耶婆提东北行,一月余,遇黑风暴雨,凡七十余日,折西北行,十二日而抵长广郡(今山东即墨县)。章炳麟作《法显发现西半球说》,说他九十余日的东行,实陷入太平洋中。耶婆提当在南美。自此向东,又被黑风吹入大西洋中,超过了中国海岸,折向西北,才得归来。衡以里程及时日,说亦可信。法显的东归,在公元416年,比哥伦布的发现美洲要早1077年了。此等偶然的漂泊,和史事是没有多大关系的,除非将来再有发现,知道美洲的开化,中国文化确占其中重要的成分。此时代的关系:在精神方面,自以印度的佛教为最大;在物质方面,则西南洋一带,香药、宝货和棉布等,输入中国的亦颇多。\n第二十章 隋朝和唐朝的盛世 # 北朝的君主,有荒淫暴虐的,也有能励精图治的,前一种代表了胡风,后一种代表了汉化。隋文帝是十足的后一种的典型。他勤于政事,又能躬行节俭。在位时,把北朝的苛捐杂税都除掉,而府库充实,仓储到处丰盈,国计的宽余,实为历代所未有。突厥狃于南北朝末年的积习,求索无厌。中国不能满其欲,则拥护高齐的遗族,和中国为难。文帝决然定计征伐,大破其兵。又离间其西方的达头可汗和其大可汗沙钵略构衅,突厥由是分为东、西。文帝又以宗女妻其东方的突利可汗。其大可汗都蓝怒,攻突利。突利逃奔中国,中国处之夏、胜二州之间(夏州,在今陕西横山县北。胜州,在今绥远鄂尔多斯左翼后旗黄河西岸),赐号为启民可汗。都蓝死,启民因隋援,尽有其众,臣服于隋。从南北朝末期以来畏服北狄的心理,至此一变。\n隋文帝时代,中国政局,确是好转了的。但是文化不能一时急转,所以还不能没有一些曲折。隋文帝的太子勇,是具有胡化的性质的。其次子炀帝,却又具有南朝君主荒淫猜忌的性质。太子因失欢于文帝后独孤氏被废。炀帝立,以洛阳为东都。开通济渠,使其连接邗沟及江南河。帝乘龙舟,往来于洛阳、江都之间。又使裴矩招致西域诸胡,所过之地,都要大营供帐。又诱西突厥献地,设立西海、河源、鄯善、且末四郡(西海郡,当系青海附近之地。河源郡该在其西南。鄯善、且末,皆汉时西域国名,郡当设于其故地。鄯善国在今罗布泊之南。且末国在车尔成河上),谪罪人以实之。又于611、613、614年,三次发兵伐高句丽,天下骚动,乱者四起。炀帝见中原已乱,无心北归,滞留江都,618年,为其下所弑。其时北方的群雄,以河北的窦建德、河南的李密为最大。而唐高祖李渊,以太原留守,于617年起兵,西据关中,又平定河西、陇右,形势最为完固。炀帝死后,其将王世充拥众北归,据洛阳。李密为其所败,降唐。又出关谋叛,为唐将所击斩。唐兵围洛阳,窦建德来救,唐兵大败擒之,世充亦降。南方割据的,以江陵的萧铣为最大,亦为唐所灭。江、淮之间,有陈稜、李子通、沈法兴、杜伏威等,纷纷而起,后皆并于杜伏威,伏威降唐。北边群雄,依附突厥的,亦次第破灭。隋亡后约十年,而天下复定。\n唐朝自称为西凉李暠之后,近人亦有疑其为胡族的,信否可不必论,民族的特征,乃文化而非血统。唐朝除太宗太子承乾具有胡化的性质,因和此时的文化不相容而被废外,其余指不出一些胡化的性质来,其当认为汉民族无疑了。唐朝开国之君虽为高祖,然其事业,实在大部分是太宗做的。天下既定之后,其哥哥太子建成,和兄弟齐王元吉,要想谋害他,为太宗所杀。高祖传位于太宗,遂开出公元627至649的二十三年间的“贞观之治”。历史上记载他的治绩,至于行千里者不赍粮,断死刑岁仅三十九人,这固然是粉饰之谈,然其时天下有丰乐之实,则必不诬的了。隋唐时的制度,如官制、选举、赋税、兵、刑等,亦都能将前代的制度加以整理,参看第四十二至第四十六章可明。\n对外的情势,此时亦开一新纪元。突厥因隋末之乱,复强盛,控弦之士至百万。北边崛起的群雄,都尊奉它,唐高祖初起时亦然,突厥益骄。天下既定,赠遗不能满其欲,就连年入寇,甚至一年三四入,北边几千里,无处不被其患。太宗因其饥馑和属部的离叛,于630年,发兵袭击,擒其颉利可汗。突厥的强盛,本来是靠铁勒归附的。此时铁勒诸部,以薛延陀、回纥为最强。突厥既亡,薛延陀继居其地。644年,太宗又乘其内乱加以剪灭。回纥徙居其地,事中国颇谨。在西域,则太宗曾用兵于高昌及焉耆、龟兹,以龟兹、于阗、焉耆、疏勒之地为四镇。在西南,则绥服了今青海地方的吐谷浑。西藏之地,隋时始有女国和中国往来。唐时,有一个部落,其先该是从印度迁徙到雅鲁藏布江流域的,是为吐蕃。其英主弃宗弄赞,太宗时始和中国交通,尚宗女文成公主,开西藏佛化的先声。太宗又通使于印度。适直其内乱,使者王玄策调吐蕃和泥婆罗的兵,把它打败。而南方海路交通,所至亦甚广。只有高句丽,太宗自将大兵去伐它,仍未能有功。此乃因自晋以来,东北过于空虚,劳师远攻不易之故。直至663、668两年,高宗才乘其内乱,把百济和高句丽先后灭掉。突厥西方的疆域,本来是很广的。其最西的可萨部,已和东罗马相接了。高宗亦因其内乱,把它戡定。分置两个都督府。其所辖的羁縻府、州,西至波斯。唐朝对外的声威,至此可谓达于最高峰了。因国威之遐畅,而我国的文化,和别国的文化,就起了交流互织的作用。东北一隅,自高句丽、百济平后,新罗即大注意于增进文化。日本亦屡遣通唐使,带了许多僧侣和留学生来。朝鲜半岛南部和日本的举国华化,实在此时。其余波且及于满族。公元7世纪末年,遂有渤海国的建立,一切制度,都以中国为模范。南方虽是佛化盛行之地,然安南在此时,仍为中国的郡县,替中国在南方留了一个文化的据点。西方则大食帝国勃兴于此时,其疆域东至葱岭。大食在文化上实在是继承古希腊,而为欧洲近世的再兴导其先路的。中国和大食,政治上无甚接触,而在文化上则彼此颇有关系。回教的经典和历数等知识,都早经输入中国。就是末尼教和基督教,也是受了回教的压迫,才传播到东方来的。而称为欧洲近世文明之源的印刷术、罗盘针、火药,亦都经中国人直接传入回教国,再经回教国人之手,传入欧洲。\n第二十一章 唐朝的中衰 # ●唐太宗像\n唐朝对外的威力,以高宗时为极盛,然其衰机亦肇于是时。高宗的性质,是失之于柔懦的。他即位之初,还能遵守太宗的成规,所以永徽之政,史称其比美贞观。公元655年,高宗惑于才人武氏,废皇后王氏而立之。武后本有政治上的才能,高宗又因风眩之故,委任于她,政权遂渐入其手。高句丽、百济及西突厥,虽于此时平定,而吐蕃渐强。吐谷浑为其所破,西域四镇,亦被其攻陷,唐朝的外患,于是开始。683年,高宗崩,子中宗立。明年,即为武后所废,徙之房州(今湖北竹山县),立其弟豫王旦(即后来的睿宗)。690年,又废之,改国号为周,自称则天皇帝。后以宰相狄仁杰之言,召回中宗,立为太子。705年,宰相张柬之等乘武后卧病,结宿卫将,奉中宗复位。自武后废中宗执掌政权至此,凡二十二年,若并其为皇后时计之,则达五十五年之久。武后虽有才能,可是宅心不正。她是一种只计维持自己的权势地位,而不顾大局的政治家。当其握有政权之时,滥用禄位,以收买人心;又任用酷吏,严刑峻法,以威吓异己的人,而防其反动;骄奢淫逸的事情,更不知凡几,以致政治大乱。突厥余众复强,其默啜可汗公然雄踞漠南北,和中国对抗。甚至大举入河北,残数十州县。契丹酋长李尽忠,亦一度入犯河北,中国不能讨,幸其为默啜所袭杀,乱乃定。因契丹的反叛,居于营州的靺鞨(营州,今热河朝阳县,为唐时管理东北异族的机关),就逃到东北,建立了一个渤海国。此为满族开化之始,中国对东北的声威,却因此失坠了。设在今朝鲜平壤地方的安东都护府,后亦因此不能维持,而移于辽东。高句丽、百济旧地,遂全入新罗之手。西南方面,西域四镇,虽经恢复,青海方面对吐蕃的战事,却屡次失利。中宗是个昏庸之主,他在房州,虽备尝艰苦,复位之后,却毫无觉悟,并不能铲除武后时的恶势力。皇后韦氏专权,和武后的侄儿子武三思私通,武氏因此复盛。张柬之等反遭贬谪而死。韦后的女儿安乐公主,中宗的婕妤上官婉儿,亦都干乱政治。政界情形的混浊,更甚于武后之时。710年,中宗为韦后所弑。相王旦之子临淄王隆基定乱而立相王,是为睿宗。立隆基为太子。武后的女儿太平公主仍干政,惮太子英明,要想摇动他。幸而未能有成,太平公主被谪,睿宗亦传位于太子,是为玄宗。玄宗用姚崇为相,廓清从武后以来的积弊。又用宋璟及张九龄,亦都称为能持正。自713至741年,史家称为开元之治。末年,突厥复衰乱,744年,乘机灭之;连年和吐蕃苦战,把中宗时所失的河西九曲之地亦收复,国威似乎复振。然自武后已来,荒淫奢侈之习,渐染已深。玄宗初年,虽能在政治上略加整顿,实亦堕入其中而不能自拔。中岁以后,遂渐即怠荒。宠爱杨贵妃,把政事都交给一个奸佞的李林甫。李林甫死后,又用一个善于夤缘的杨国忠。天宝之乱,就无可遏止了。一个团体,积弊深的,往往无可挽回,这大约是历时已久的皇室,必要被推翻的一个原因罢。\n唐朝的盛衰,以安史之乱为关键。安史之乱,皇室的腐败只是一个诱因,其根源是别有所在的。一、唐朝的武功从表面看,虽和汉朝相等,其声威所至,或且超过汉朝,但此乃世运进步使然,以经营域外的实力论,唐朝实非汉朝之比。汉武帝时,攻击匈奴,前后凡数十次;以至征伐大宛,救护乌孙,都是仗自己的实力去摧破强敌。唐朝的征服突厥、薛延陀等,则多因利乘便,且对外多用番兵。玄宗时,府兵制度业已废坏,而吐蕃、突厥都强,契丹势亦渐盛。欲图控制、守御,都不得不加重边兵,所谓藩镇,遂兴起于此时,天下势成偏重。二、胡字本是匈奴的专称,后渐移于一切北族。再后,又因文化的异同易泯,种族的外观难改,遂移为西域白种人的专称(详见拙著《胡考》,在《燕石札记》中,商务印书馆本)。西域人的文明程度,远较北族为高。他们和中国,没有直接的政治关系,所以不受注意。然虽无直接的政治关系,间接的政治关系却是有的,而且其作用颇大。从来北族的盛衰,往往和西胡有关涉。冉闵大诛胡、羯时,史称高鼻多须,颇有滥死,可见此时之胡,已非尽匈奴人。拓跋魏占据北方后,有一个盖吴,起而与之相抗,一时声势很盛,盖吴实在是个胡人(事在公元446年,即宋文帝元嘉二十三年,魏太武帝太平真君七年。见《魏书·本纪》和《宋书·索虏传》)。唐玄宗时,北边有康待宾、康愿子相继造反,牵动颇广(事在公元721、722年,即玄宗开元九年、十年),康亦是西域姓。突厥颉利的衰亡,史称其信任诸胡,疏远宗族,后来回纥的灭亡亦然,可见他们的沉溺于物质的享受,以致渐失其武健之风,还不尽由于中国的渐染。从反面看,就知道他们的进于盛强,如物质文明的进步,政治、军事组织的改良等,亦必有受教于西胡的了。唐朝对待被征服的异族,亦和汉朝不同。汉朝多使之入居塞内,唐朝则仍留之于塞外,而设立都护府或都督府去管理它。所以唐朝所征服的异族虽多,未曾引起像五胡乱华一般的杂居内地的异族之患。然环伺塞外的异族既多,当其种类昌炽,而中国政治力量减退时,就不免有被其侵入的危险了。唐末的沙陀,五代时的契丹,其侵入中国,实在都是这一种性质,而安史之乱,就是一个先期的警告。安禄山,《唐书》说他是营州柳城胡。他本姓康,随母嫁虏将安延偃,因冒姓安。安、康都是西域姓。史思明,《唐书》虽说他是突厥种,然其状貌,“鸢肩伛背,目侧鼻”,怕亦是一个混血儿。安禄山和史思明,都能通六番译,为互市郎,可见其兼具西胡和北族两种性质。任用番将,本是唐朝的习惯,安禄山遂以一身而兼做了范阳、平卢两镇的节度使(平卢军,治营州。范阳军,治幽州,今北平)。此时安禄山的主要任务,为镇压奚、契丹,他就收用其壮士,名之曰曳落河。其军队在当时藩镇之中,大约最为剽悍。目睹玄宗晚年政治腐败,内地守备空虚,遂起觊觎之念。并又求为河东节度使。755年,自范阳举兵反。不一月而河北失陷,河南继之,潼关亦不守,玄宗逃向成都。于路留太子讨贼,太子西北走向灵武(灵州,治今宁夏灵武县),即位,是为肃宗。安禄山虽有强兵,却无政治方略,诸将亦都有勇无谋,既得长安之后,不能再行进取。朔方节度使郭子仪(朔方军,治灵州),乃得先平河东,就借回纥的兵力,收复两京(长安、洛阳)。安禄山为其子庆绪所杀。九节度之师围庆绪于邺。因号令不一,久而无功。史思明既降复叛,自范阳来救,九节度之师大溃。思明杀庆绪,复陷东京。李光弼与之相持。思明又为其子朝义所杀。唐朝乃得再借回纥之力,将其打平。此事在762年。其时肃宗已死,是代宗的元年了。安史之乱首尾不过八年,然对外的威力自此大衰,内治亦陷于紊乱,唐朝就日入于衰运了。\n第二十二章 唐朝的衰亡和沙陀的侵入 # 自从公元755年安史之乱起,直到公元907年朱全忠篡位为止,唐朝一共还有了一百五十二年的天下。在这一个时期中,表面上还维持着统一,对外的威风亦未至于全然失坠,然而自大体言之,则终于日入于衰乱而不能够复振了。\n因安史之乱而直接引起的,是藩镇的跋扈。唐朝此时,兵力不足,平定安史,颇藉回纥的助力。铁勒仆骨部人仆固怀恩,于引用回纥颇有功劳,亦有相当的战功。军事是要威克厥爱的,一个战将,没有人能够使之畏服,便不免要流于骄横,何况他还是一个番将呢?他要养寇自重,于是昭义、成德、天雄、卢龙诸镇(昭义军,治相州,今河南安阳县。成德军,治恒州,今河北正定县。天雄军,治魏州,今河北大名县。卢龙军,即范阳军),均为安、史遗孽所据,名义上虽投降朝廷,实则不奉朝廷的命令。唐朝自己所设的节度使,也有想学他们的样子,而且有和他们互相结托的。次之则为外患的复兴。自玄宗再灭突厥后,回纥占据其地。因有助平安、史之功,骄横不堪。而吐蕃亦乘中国守备空虚,尽陷河西、陇右,患遂中于京畿。又云南的南诏(诏为蛮语王之称,当时,今云南、西康境有六诏:曰蒙巂诏,在今西康西昌县。曰越析诏,亦称磨些诏,在今云南丽江县。曰浪穹诏,在今云南洱源县。曰邆睒诏,在今云南邓川县。曰施浪诏,在洱源县之东。曰蒙舍诏,在今云南蒙化县。地居最南,亦称南诏。余五诏皆为所并),天宝时,杨国忠与之构兵,南诏遂投降吐蕃,共为边患,患又中于西川。\n公元779年,代宗崩,子德宗立,颇思振作。此时昭义已为天雄所并,卢龙亦因易帅恭顺朝廷,德宗遂因成德的不肯受代,发兵攻讨。成德和天雄、平卢连兵拒命。山南东道(治襄州,今湖北襄阳县)亦叛与相应,德宗命淮西军讨平之(淮西军,治蔡州,今河南汝南县)。攻三镇未克,而淮西、卢龙复叛,再发泾原兵东讨(泾原军,治泾州,今甘肃泾川县),过京师,因赏赐菲薄作乱。德宗出奔奉天(唐县,今山西武功县)。乱军奉朱泚为主,大举进攻。幸得浑瑊力战,河中李怀光入援(河中军,治蒲州,今山西永济县),奉天才未被攻破。而李怀光因和宰相卢杞不合,又反。德宗再逃到梁州(今陕西南郑县),听了陆贽的话,赦诸镇的罪,专讨朱泚,才得将京城收复。旋又打平了河中。然其余的事,就只好置诸不问了。德宗因屡遭叛变,不敢相信臣下。回京之后,使宦官带领神策军。这时候,神策军饷糈优厚,诸将多自愿隶属,兵数骤增至十五万,宦官就从此握权。805年,德宗崩,子顺宗立。顺宗在东宫时,即深知宦官之弊。即位后,用东宫旧臣王叔文等,想要除去宦官。然顺宗在位仅八个月,即传位于子宪宗,王叔文等都遭斥逐,其系为宦官所逼,不言而喻了。宪宗任用裴度,削平了淮西,河北三镇亦惧而听命,实为中央挽回威信的一个良机。然宪宗死后,穆宗即位,宰相以为河北已无问题,对善后事宜,失于措置,河北三镇,遂至复叛,终唐之世,不能削平了。穆宗崩,敬宗立,为宦官刘克明所弑。宦官王守澄讨贼而立文宗。文宗初用宋申锡为宰相,与之谋诛宦官,不克。后又不次擢用李训、郑注,把王守澄毒死。郑注出镇凤翔(凤翔军,治凤翔府,今陕西凤翔县),想选精兵进京送王守澄葬,因此把宦官尽数杀掉。不知何故,李训在京城里,又诈称某处有甘露降,想派宦官往看,因而杀掉他们。事机不密,反为宦官所杀。郑注在凤翔,亦被监军杀掉。文宗自此受制于宦官,几同傀儡。相传这时候,有一个翰林学士,唤做崔慎由,曾缘夜被召入宫,有一班宦官,以仇士良为首,诈传皇太后的意旨,要他拟废掉文宗的诏书。崔慎由誓死不肯,宦官默然良久,乃开了后门,把崔慎由引到一个小殿里。文宗正在殿上,宦官就当面数说他,文宗低头不敢开口。宦官道:“不是为了学士,你就不能再坐这宝位了。”于是放崔慎由出宫,叮嘱他不许泄漏,泄漏了是要祸及宗族的。崔慎由虽然不敢泄漏,却把这件事情密记下来,临死时交给他的儿子。他的儿子便是唐末的宰相崔胤。文宗死后,弟武宗靠着仇士良之力,杀太子而自立。武宗能任用李德裕,政治尚称清明。宣宗立,尤能勤于政事,人称之为小太宗。然于宦官,亦都无可如何。宣宗死后,子懿宗立。886年,徐、泗卒戍桂州者作乱(徐州,今江苏铜山县。泗州,今安徽泗县。桂州,今广西桂林县),用沙陀兵讨平之,沙陀入据中原之祸,遂于是乎开始。\n唐朝中叶后的外患,最严重的是回纥、吐蕃,次之则南诏。南诏的归服吐蕃,本出于不得已,吐蕃待之亦甚酷。9世纪初,韦皋为西川节度使,乃与之言和,共击吐蕃,西南的边患,才算解除(西川军,治成都,今四川成都县。后来南诏仍有犯西川之事,并曾侵犯安南,但其性质,不如和吐蕃结合时严重)。840年,回纥为黠戛斯所破,遽尔崩溃。吐蕃旋亦内乱。849年,中国遂克复河、湟,河西之地亦来归。三垂的外患,都算靠天幸解除了。然自身的纲纪不振,沙陀突厥遂至能以一个残破的部落而横行中国。\n沙陀是西突厥的别部,名为处月(朱邪,即处月之异译)。西突厥亡后,依北庭都护府以居(今新疆迪化县)。其地有大碛名沙陀,故称为沙陀突厥。河西、陇右既陷,安西、北庭(安西都护府,治龟兹),朝贡路绝,假道回纥,才得通到长安。回纥因此需索无厌。沙陀苦之,密引吐蕃陷北庭。久之,吐蕃又疑其暗通回纥,想把它迁到河外。沙陀乃又投奔中国。吐蕃追之,且战且走。三万部落之众,只剩得两千到灵州。节度使范希朝以闻,诏处其众于盐州(今宁夏盐池县北)。后来范希朝移镇河东(治太原府,今山西太原县),沙陀又随往,居于现在山阴县北的黄瓜堆。希朝简其精锐的为沙陀军。沙陀虽号称突厥,其形状,据史籍所载,亦是属于白种人的。既定徐、泗之乱,其酋长朱邪赤心,赐姓名为李国昌,镇守大同(治云州,令山西大同县),就有了一个地盘了。873年,懿宗崩,子僖宗立。年幼,信任宦官田令孜。时山东连年荒歉。875年,王仙芝起兵作乱,黄巢聚众应之。后来仙芝被杀,而黄巢到处流窜。从现在的河南打到湖北,沿江东下,经浙东入福建,到广东。再从湖南、江西、安徽打回河南,攻破潼关。田令孜挟僖宗走西川。黄巢遂入长安,时为880年。当黄巢横行时,藩镇都坐视不肯出兵剿讨。京城失陷之后,各路的援兵又不肯进攻。不得已,就只好再借重沙陀。先是李国昌移镇振武(治单于都护府。今绥远和林格尔县),其子李克用叛据大同,为幽州兵所败,父子都逃入鞑靼(居阴山)。这时候,国昌已死,朝廷乃赦李克用的罪,召他回来。打败黄巢,收复长安。李克用镇守河东,沙陀的根据地更深入腹地了。\n黄巢既败,东走攻蔡州。蔡州节度使秦宗权降之。后来黄巢被李克用追击,为其下所杀,而宗权转横,其残虐较黄巢为更甚。河南、山东被其剽掠之处,几于无复人烟。朝廷之上,宦官依然专横。关内一道,亦均为军人所盘踞。其中华州的韩建,邠州的王行瑜(镇国军,治华州,今陕西华县。邠宁军,治邠州,今陕西邠县),凤翔的李茂贞,尤为跋扈,动辄违抗命令,胁迫朝廷,遂更授沙陀以干涉的机会。\n在此情势之下,汉民族有一个英雄,能够和沙陀抵抗的,那便是朱全忠。全忠本名温,是黄巢的将,巢败后降唐,为宣武节度使(治汴州,今河南开封县)。初年兵力甚弱,而全忠智勇足备,先扑灭了秦宗权,渐并今河南,山东之地,又南取徐州。北服河北三镇。西并河中,取义武(义武军,治定州,今河北定县),又取泽、潞(泽州,今山西晋城县。潞州,今山西长子县)及邢、洺、磁诸州(邢州,今河北邢台县。洺州,今河北永年县。磁州,今河北磁县)。河东的形势,就处于其包围之中了。僖宗死于888年,弟昭宗立,颇为英武。然其时的事势,业已不能有为。此时朝廷为关内诸镇所逼,大都靠河东解围。然李克用是个无谋略的人,想不到挟天子以令诸侯。虽然击杀了一个王行瑜,关内的问题,还是不能解决。朱全忠其初是不问中央的事务,一味扩充自己的实力的。到10世纪初年,全忠的势力已经远超出乎李克用之上了。唐朝的宰相崔胤,乃结合了他,以谋宦官。宦官见事急,挟昭宗走凤翔。全忠围凤翔经年,李茂贞不能抗,只得把皇帝送出,同朱全忠讲和。昭宗回到京城,就把宦官悉行诛灭。唐朝中叶后的痼疾,不是藩镇,实在是宦官。因为唐朝的藩镇,并没有敢公然背叛,或者互相攻击,不过据土自专,更代之际,不听命令而已。而且始终如此的,还不过河北三镇。倘使朝廷能够振作,实在未尝不可削平。而唐朝中叶后的君主,如顺宗、文宗、武宗、宣宗、昭宗等,又都未尝不可与有为。其始终不能有为,则全是因被宦官把持之故。事势至此,已非用兵力铲除,不能有别的路走了。一个阶级,当其恶贯满盈,走向灭亡之路时,在它自己,亦是无法拔出泥淖的。\n宦官既亡,唐朝亦与之同尽。公元903年,朱全忠迁帝于洛阳,弑之而立其子昭宣帝。至907年,遂废之而自立,是为梁太祖。此时海内割据的:淮南有杨行密,是为吴。两浙有钱镠,是为吴越。湖南有马殷,是为楚。福建有王审知,是为闽。岭南有刘岩,是为南汉。剑南有王建,是为前蜀。遂入于五代十国之世。\n第二十三章 五代十国的兴亡和契丹的侵入 # 凡内争,是无有不引起外患的,沙陀的侵入,就是一个例。但沙陀是整个部族侵入中国的,正和五胡一样。过了几代之后,和汉族同化了,它的命运也就完了。若在中国境外,立有一国,以国家的资格侵入,侵入之后,其本国依然存在的,则其情形自又不同。自公元840年顷回纥崩溃后,漠南北遂无强部,约历七十年而契丹兴。契丹,大约是宇文氏的遗落。其居中国塞外,实已甚久。但当6世纪初,曾遭到北齐的一次袭击,休养生息,到隋时元气才渐复。7世纪末,又因李尽忠的反叛而大遭破坏。其后又和安禄山相斗争,虽然契丹也曾打过一二次胜仗,然其不得安息,总是实在的。唐朝管理东北方最重要的机关,是营州都督府,中叶后业已不能维持其威力,但契丹仍时时受到幽州的干涉,所以它要到唐末才能够兴起。契丹之众,是分为八部的。每部有一个大人。八个大人之中,公推一人司旗鼓。到年久了,或者国有疾疫而畜牧衰,则另推一个大人替代。它亦有一个共主,始而是大贺氏,后来是遥辇氏,似乎仅有一个虚名。它各部落间的连结,大概是很薄弱的,要遇到战斗的事情,才能互相结合,这或者也是它兴起较晚的一个原因。内乱是招引外族侵入中国的,又是驱逐本国人流移到外国去的。这种事情,在历史上已经不知有过若干次。大抵(一)外国的文明程度低而人数少,而我们移植的人数相当多时,可以把它们完全同化。(二)在人数上我们比较很少,而文明程度相去悬绝时,移殖的人民,就可在它们的部落中做蛮夷大长。(三)若它们亦有相当的程度,智识技术上,虽然要请教于我,政治和社会的组织,却决不容以客族侵入而握有权柄的,则我们移殖的人民,只能供它们之用,甚至造成了它们的强盛,而我们传授给它的智识技术,适成为其反噬之用。时间是进步的良友。一样的正史四裔传中的部族,名称未变,或者名称虽异而统系可寻,在后一代,总要比前一代进步些。所以在前代,中国人的移殖属于前两型的居多,到近世,就多属于后一种了,这是不可以不懔然的,而契丹就是一个适例。契丹太祖耶律阿保机,据《五代史》说,亦是八部大人之一。当公元十世纪之初,幽州刘守光暴虐,中国人逃出塞的很多。契丹太祖都把他招致了去,好好的抚慰他们,因而跟他们学得了许多知识,经济上和政治组织上,都有进步了。就以计诱杀八部大人,不再受代。公元916年,并废遥辇氏而自立。这时候,漠南北绝无强部,他遂得纵横如意。东北灭渤海,服室韦,西南服党项、吐谷浑,直至河西回纥。《辽史》中所列,他的属国,有四五十部之多。\n梁太祖的私德,是有些缺点的,所以从前的史家,对他的批评,多不大好。然而私德只是私德,社会的情形复杂了,论人的标准,自亦随之而复杂,政治和道德、伦理,岂能并为一谈?就篡弑,也是历代英雄的公罪,岂能偏责一人?老实说:当大局阽危之际,只要能保护国家、抗御外族、拯救人民的,就是有功的政治家。当一个政治家要尽他为国为民的责任,而前代的皇室成为其障碍物时,岂能守小信而忘大义?在唐、五代之际,梁太祖确是能定乱和恤民的,而历来论者,多视为罪大恶极,甚有反偏袒后唐的,那就未免不知民族的大义了。惜乎天不假年,梁太祖篡位后仅六年而遇弑。末帝定乱自立,柔懦无能,而李克用死后,其子存勖袭位,颇有英锐之气。梁、晋战争,梁多不利。河北三镇及义武,复入于晋。923年,两军相持于郓州(今山东东平县),晋人乘梁重兵都在河外,以奇兵径袭大梁,末帝自杀,梁亡。存勖是时已改国号为唐,于是定都洛阳,是为后唐庄宗。中原之地,遂为沙陀所占据。后唐庄宗,本来是个野蛮人,灭梁之后,自然志得意满。于是纵情声色,宠爱伶人,听信宦官,政治大乱。925年,使宰相郭崇韬傅其子魏王继岌伐前蜀,把前蜀灭掉。而刘皇后听了宦官的话,疑心郭崇韬要不利于魏王,自己下命令给魏王,叫他把郭崇韬杀掉。于是人心惶骇,谣言四起。天雄军据邺都作乱。庄宗派李克用的养子李嗣源去征伐。李嗣源的军队也反了,胁迫李嗣源进了邺城。嗣源用计,得以脱身而出。旋又听了女婿石敬瑭的话,举兵造反。庄宗为伶人所弑。嗣源立,是为明宗。明宗年事较长,经验亦较多,所以较为安静。933年,明宗死,养子从厚立,是为闵帝。时石敬瑭镇河东,明宗养子从珂镇凤翔,闵帝要把他们调动,从珂举兵反。闵帝派出去的兵,都倒戈投降。闵帝出奔被杀。从珂立,是为废帝。又要调动石敬瑭,敬瑭又反。废帝鉴于闵帝的失败,是预备了一个不倒戈的张敬达,然后发动的,就把石敬瑭围困起来。敬瑭乃派人到契丹去求救,许割燕、云十六州之地(幽州、云州已见前。蓟州,今河北蓟县。瀛洲,今河北河间县。莫州,今河北肃宁县。涿州,今河北涿县。檀州,今河北密云县。顺州,今河北顺义县。新州,今察哈尔涿鹿县。妫州,今察哈尔怀来县。儒州,今察哈尔延庆县。武州,今察哈尔宣化县。应州,今山西应县。寰州,今山西马邑县。朔州,今山西朔县。蔚州,今察哈尔蔚县)。他手下的刘知远劝他:只要赂以金帛,就可如愿,不可许割土地,以遗后患。敬瑭不听。此时契丹太祖已死,次子太宗在位,举兵南下,反把张敬达围困起来,废帝不能救。契丹太宗和石敬瑭南下,废帝自焚死。敬瑭定都于大梁,是为晋高祖,称臣割地于契丹。942年,晋高祖死,兄子重贵立,是为出帝。听了侍卫景延广的话,对契丹不复称臣,交涉亦改强硬态度。此时契丹已改国号为辽。辽兵南下,战事亦互有胜负。但石晋国力疲敝,而勾通外敌,觊觎大位之例已开,即不能禁人的不效尤。于是晋将杜重威降辽,辽人入大梁,执出帝而去,时在946年。辽太宗是个粗人,不懂得政治的。既入大梁,便派人到各地方搜括财帛,又多派他的亲信到各地方去做刺史,汉奸附之以虐民。辽人的行军,本来是不带粮饷的,大军中另有一支军队,随处剽掠以自给,谓之打草谷军,入中国后还是如此。于是反抗者四起。辽太宗无如之何,只得弃汴梁而去,未出中国境而死。太宗本太祖次子,因皇后述律氏的偏爱而立。其兄突欲(汉名倍),定渤海后封于其地,谓之东丹王。东丹王奔后唐,辽太宗入中国时,为晋人所杀,述律后第三子李胡,较太宗更为粗暴,辽人怕述律后要立他,就军中拥戴了东丹王的儿子,是为世宗。李胡兴兵拒战,败绩。世宗在位仅四年,太宗之子穆宗继立,沉湎于酒,政治大乱,北边的风云,遂暂告宁静。此时侵入中国的,幸而是辽太宗,倘使是辽太祖,怕就没有这么容易退出去了。\n契丹虽然退出,中原的政权,却仍落沙陀人之手。刘知远入大梁称帝,是为后汉高祖。未几而死,子隐帝立。950年,为郭威所篡,是为后周太祖。中原的政权,始复归于汉人。后汉高祖之弟旻,自立于太原,称侄于辽,是为北汉,亦称东汉。后周太祖立四年而死,养子世宗立。北汉乘丧来伐,世宗大败之于高平(今山西高平县)。先是吴杨行密之后,为其臣李昪所篡,改国号为唐,是为南唐。并有江西之地,疆域颇广。而后唐庄宗死后,西川节度使孟知祥攻并东川而自立,是为后蜀。李昪之子璟,乘闽、楚之衰,将其吞并,意颇自负;孟知祥之子昶,则是一个昏愚狂妄之人,都想交结契丹,以图中原,世宗要想恢复燕、云,就不得不先膺惩这两国。唐代藩镇之弊,总括起来,是“地擅于将,将擅于兵”八个字。一地方的兵甲、财赋,固为节度使所专,中央不能过问。节度使更代之际,也至少无全权过问,或竟全不能过问。然节度使对于其境内之事,亦未必能全权措置,至少是要顾到其将校的意见,或遵循其军中的习惯的。尤其当更代之际,无论是亲子弟,或是资格相当的人,也必须要得到军中的拥戴,否则就有被杀或被逐的危险。节度使如失众心,亦会为其下所杀。又有野心的人,煽动军队,饵以重赏,推翻节度使而代之的。此等军队,真乃所谓骄兵。凡兵骄,则对外必不能作战,而内部则被其把持,一事不可为,甚且纲纪全无,变乱时作。唐中叶以后的藩镇,所以坐视寇盗的纵横而不能出击;明知强邻的见逼,也只得束手坐待其吞并;一遇强敌,其军队即土崩瓦解,其最大的原因,实在于此。这是非加以彻底的整顿,不足以有为的。周世宗本就深知其弊,到高平之战,军队又有兵刃未接,而望风解甲的,乃益知其情势的危险。于是将禁军大加裁汰,又令诸州募兵,将精强的送至京师,其军队乃焕然改观,而其政治的清明,亦足以与之相配合,于是国势骤张。先伐败后蜀,又伐南唐,尽取江北之地。959年,遂举兵伐辽,恢复了瀛、莫、易三州,直逼幽州。此时正直契丹中衰之际,倘使周世宗不死,燕、云十六州,是很有恢复的希望的,以后的历史,就全然改观了。惜乎世宗在途中遇疾,只得还军,未几就死了。嗣子幼弱,明年,遂为宋太祖所篡。\n宋太祖的才略,亦和周世宗不相上下,或者还要稳健些。他大约知道契丹是大敌,燕、云一时不易取,即使取到了,也非有很重的兵力不能守的,而这时候割据诸国,非弱即乱,取之颇易,所以要先平定了国内,然后厚集其力以对外。从梁亡后,其将高季兴据荆、归、峡三州自立(荆州,今湖北江陵县。归州,今湖北秭归县。峡州,今湖北西陵县),是为南平。而楚虽为唐所灭,朗州亦旋即独立(朗州,今湖南常德县)。962年宋太祖因朗州和衡州相攻击(衡州,今湖南衡山县),遣人来求救,遣兵假道南平前往,把南平和朗州都破掉(衡州先已为朗州所破)。956年,遣兵灭后蜀。971年,遣兵灭南汉。975年,遣兵灭南唐。是年,太祖崩,弟太宗立。976年,吴越纳土归降。明年,太宗遂大举灭北汉。于是中国复见统一。自907年朱梁篡唐至此,共计72年。若从880年僖宗奔蜀,唐朝的中央政权实际崩溃算起,则适得一百年。\n●韩熙载夜宴图局部\n第二十四章 唐宋时代中国文化的转变 # 两个民族的竞争,不单是政治上的事。虽然前代的竞争,不像现代要动员全国的人力和物力,然一国政治上的趋向,无形中总是受整个社会文化的指导的。所以某一民族,在某一时代中,适宜于竞争与否,就要看这一个民族,在这一个时代中文化的趋向。\n在历史上,最威胁中国的是北族。它们和中国人的接触,始于公元前4世纪秦、赵、燕诸国与北方的骑寇相遇,至6世纪之末五胡全被中国同化而告终结,历时约一千年。其第二批和中国的交涉,起于4世纪后半铁勒侵入漠南北,至10世纪前半沙陀失却在中国的政权为止,历时约六百年。从此以后,塞外开发的气运,暂向东北,辽、金、元、清相继而兴。其事起于10世纪初契丹的盛强,终于1911年中国的革命。将来的史家,亦许要把它算到现在的东北问题实际解决时为止,然为期亦必不远了。这一期总算起来,为时亦历千余年。这三大批北族,其逐渐移入中国,而为中国人所同化,前后相同。惟第一二期,是以被征服的形式移入的,至第三期,则系以征服的形式侵入。\n经过五胡和沙陀之乱,中国也可谓受到相当的创痛了。但是以中国之大,安能不把这个看做很大的问题?在当时中国人的眼光里,北族的侵入,还只是治化的缺陷,只要从根本上把中国整顿好了,所谓夷狄,自然不成问题。这时代先知先觉者的眼光,还是全副注重于内部,民族的利害冲突,虽不能说没有感觉,民族主义却未能因此而发皇。\n●颜真卿《多宝塔碑》局部\n●柳公权《玄秘塔碑》局部\n虽然如此,在唐、宋之间,中国的文化,也确是有一个转变的。这个转变是怎样呢?\n中国的文化,截至近世受西洋文化的影响以前,可以分做三个时期:第一期为先秦、两汉时代的诸子之学。第二期为魏、晋、南北朝、隋、唐时代的玄学和佛学。第三期为宋、元、明时代的理学。这三期,恰是一个正、反、合。\n怎样说这三期的文化,是一个辩证法的进化呢?原来先秦时代的学术,是注重于矫正社会的病态的,所谓“拨乱世,反之正”,实不仅儒家,而为各家通有的思想。参看第四十一、第五十三两章自明。王莽变法失败以后,大家认为此路不通,而此等议论,渐趋消沉。魏晋以后,文化乃渐转向,不向整体而向分子方面求解决。他们所讨论的,不是社会的组织如何,使人生于其间,能够获得乐利,可以做个好人,而是人性究竟如何?是好的?是坏的?用何法,把坏人改做好人,使许多好人聚集,而好的社会得以实现?这种动机,确和佛教相契。在这一千年中,传统的儒家,仅仅从事于笺疏,较有思想的人,都走入玄学和佛学一路,就是其明证。但其结果却是怎样呢?显然的,从个人方面着想,所能改良的,只有极小一部分,合全体而观之,依然无济于事。而其改善个人之法,推求到深刻之处,就不能不偏重于内心。工夫用在内心上的多,用在外务上的,自然少了。他们既把社会看做各个分子所构成,社会的好坏,原因在于个人的好坏,而个人的好坏,则源于其内心的好坏;如此,社会上一切问题,自然都不是根本。而他们的所谓好,则实和此世界上的生活不相容,所以他们最彻底的思想,是要消灭这一个世界。明知此路不通,则又一转变而认为现在的世界就是佛国;只要心上觉悟,一切行为虽和俗人一样,也就是圣人。这么一来,社会已经是好的了,根本用不着改良。这两种见解,都是和常识不相容的,都是和生活不相合的。凡是和生活不相合的,凭你说得如何天花乱坠,总只是他们所谓“戏论”,总要给大多数在常识中生活的人所反对的,而事情一到和大多数人的生活相矛盾,就是它的致命伤。物极必反,到唐朝佛学极盛时,此项矛盾,业经开始发展了,于是有韩愈的辟佛。他的议论很粗浅,不过在常识范围中批评佛说而已,到宋儒,才在哲学上取得一个立足点。这话在第五十三章中,亦经说过。宋学从第十一世纪的中叶起,到第十七世纪的中叶止,支配中国的思想界,约六百年。他们仍把社会看做是各分子所构成的,仍以改良个人为改良社会之本;要改良个人,还是注重在内心上,这些和佛学并无疑异。所不同的,则佛家认世界的现状,根本是坏的,若其所谓好的世界而获实现,则现社会的组织,必彻底被破坏,宋学则认现社会的组织,根本是合理的,只因为人不能在此组织中,各处于其所当处的地位,各尽其所应尽的责任,以致不好。而其所认为合理的组织,则是一套封建社会和农业社会中的道德、伦理和政治制度。在商业兴起,广大的分工合作,日日在扩充,每一个地方自给自足的规模,业已破坏净尽,含有自给自足性质的大家族,亦不复存在之时,早已不复适宜了。宋儒还要根据这一个时代的道德、伦理和政治制度,略加修改,制成一种方案,而强人以实行,岂非削足适履?岂非等人性于杞柳,而欲以为杯?所以宋儒治心的方法,是有很大的价值的,而其治世的方法,则根本不可用。不过在当时,中国的思想界,只能在先秦诸子和玄学、佛学两种思想中抉择去取、融化改造,是只能有这个结果的,而文化进化的趋向,亦就不得不受其指导。在君主专制政体下,政治上的纲纪所恃以维持的,就是所谓君臣之义。这种纲纪,是要秩序安定,人心也随着安定,才能够维持的。到兵荒马乱,人人习惯于裂冠毁裳之日,就不免要动摇了。南北朝之世,因其君不足以为君,而有“殉国之感无因,保家之念宜切”的贵族,第十五章中,业经说过。到晚唐、五代之世,此种风气,又盛行了。于是既有历事五朝,而自称长乐老以鸣其得意的冯道,又有许多想借重异族,以自便私图的杜重威。由今之道,无变今之俗,如何可以一朝居?所以宋儒要竭力提倡气节。经宋儒提倡之后,士大夫的气节,确实是远胜于前代。但宋儒(一)因其修养的工夫,偏于内心,而处事多疏。(二)其持躬过于严整,而即欲以是律人,因此,其取人过于严格,而有才能之士,皆为其所排斥。(三)又其持论过高,往往不切于实际。(四)意气过甚,则易陷于党争。党争最易使人动于感情,失却理性,就使宅心公正,也不免有流弊,何况党争既启,哪有个个人都宅心公正之理?自然有一班好名好利、多方掩饰的伪君子,不恤决裂的真小人混进去。到争端扩大而无可收拾,是非淆乱而无从辨别时,就真有宅心公正、顾全大局的人,也苦于无从措手了。所以宋儒根本是不适宜于做政治事业的。若说在社会上做些自治事业,宋儒似乎很为相宜。宋儒有一个优点,他们是知道社会上要百废俱举,尽其相生相养之道,才能够养生送死无憾,使人人各得其所的。他们否认“治天下不如安天下,安天下不如与天下安”的苟简心理,这一点,的确是他们的长处。但他们所以能如此,乃是读了经书而然。而经书所述的,乃是古代自给自足,有互助而无矛盾的社会所留遗,到封建势力逐渐发展时,此等组织,就逐渐破坏了。宋儒不知其所主张的道德、伦理、政治制度,正和这一种规制相反,却要藉其所主张的道德、伦理和政治制度之力,以达到这一个目的。其极端的,遂至要恢复井田封建。平易一些的,亦视智愚贤不肖为自然不可泯的阶级。一切繁密的社会制度,还是要以士大夫去指导着实行,而其所谓组织,亦仍脱不了阶级的对立。所以其结果,还是打不倒土豪劣绅,而宋学家,特如其中关学一派,所草拟的极详密的计划,以极大的热心去推行,终于实现的寥若晨星,而且还是昙花一现。这时候,外有强敌的压迫,最主要的事务,就是富国强兵,而宋儒却不能以全力贯注于此。最需要的,是严肃的官僚政治,而宋学家好作诛心之论,而忽略形迹;又因党争而淆乱是非,则适与之相反。宋学是不适宜于竞争的,而从第十一世纪以来,中国的文化,却受其指导,那无怪其要迭招外侮了。\n●唐三藏西行求法图\n第二十五章 北宋的积弱 # 五代末年,偏方割据诸国,多微弱不振。契丹则是新兴之国,气完力厚的,颇不容易对付,所以宋太祖要厚集其力以对付它。契丹的立国,是合部族、州县、属国三部分而成的。属国仅有事时量借兵粮,州县亦仅有益于财赋(辽朝的汉兵,名为五京乡丁,只守卫地方,不出戍),只有部族,是契丹立国的根本,这才可以真正算是契丹的国民。它们都在指定的地方,从事于畜牧。举族皆兵,一闻令下,立刻聚集,而且一切战具,都系自备。马既多,而其行军又不带粮饷,到处剽掠自资(此即所谓“打草谷”),所以其兵多而行动极速。周世宗时,正是契丹中衰之会,此时却又兴盛了(辽惟穆宗最昏乱。969年,被弑,景宗立,即复安。983年,景宗死,圣宗立。年幼,太后萧氏同听政。圣宗至1030年乃死,子兴宗立,1054年死。圣宗时为辽全盛之世。兴宗时尚可蒙业而安,兴宗死,子道宗立,乃衰)。宋朝若要以力服契丹,非有几十万大兵,能够连年出征,攻下了城能够守,对于契丹地方,还要能加以破坏扰乱不可。这不是容易的事,所以宋太祖不肯轻举。而太宗失之轻敌,灭北汉后,不顾兵力的疲敝,立刻进攻。于是有高梁河之败(在北平西)。至公元985年,太宗又命将分道北伐,亦不利。而契丹反频岁南侵。自燕、云割弃后,山西方面,还有雁门关可守,河北方面,徒恃塘泺以限戎马,是可以御小敌,而不足以御大军的。契丹大举深入,便可直达汴梁对岸的大名,宋朝受威胁殊甚。1004年,辽圣宗奉其母入寇,至澶州(今河北濮阳县)。真宗听了宰相寇準的话,御驾亲征,才算把契丹吓退。然毕竟以岁币成和(银十万两,绢二十万匹)。宋朝开国未几,国势业已陷于不振了。\n假使言和之后,宋朝能够秣马厉兵,以伺其隙,契丹是个浅演之国,它的强盛必不能持久,亦未必无隙可乘。宋朝却怕契丹启衅,伪造天书,要想愚弄敌人(宋朝伪造天书之真意在此,见《宋史·真宗本纪论》)。敌人未必被愚弄,工于献媚和趁风打劫、经手侵渔的官僚,却因此活跃了。斋醮、宫观,因此大兴,财政反陷于竭蹶。而西夏之乱又起。唐朝的政策,虽和汉朝不同,不肯招致异族,入居塞内,然被征服的民族多了,乘机侵入,总是不免的。尤其西北一带,自一度沦陷后,尤为控制之力所不及。党项酋长拓跋氏(拓跋是鲜卑的民族,党项却系羌族,大约是鲜卑人入于羌部族而为其酋长的),于唐太宗时归化。其后裔拓跋思敬,以平黄巢有功,赐姓李氏。做了定难节度使,据有夏、银、绥、宥、静五州(夏州,今陕西怀远县。银州,今陕西米脂县。绥州,今陕西绥德县。宥州,今鄂尔多斯右翼后旗。静州,在米脂县西),传八世至继捧,于宋太宗的时候来降,而其弟继迁叛去。袭据银州和灵州,降于辽,宋朝未能平定。继迁传子德明,三十年未曾窥边,却征服了河西,拓地愈广。1022年,真宗崩,仁宗立。1034年,德明之子元昊反,兵锋颇锐。宋朝屯大兵数十万于陕西,还不能戢其侵寇。到1044年,才以岁赐成和(银、绢、茶、彩,共二十五万五千)。此时辽圣宗已死,兴宗在位,年少气盛,先两年,遣使来求关南之地(瓦桥关,在雄县。周世宗复瀛、莫后,与辽以此为界),宋朝亦增加了岁币(增银十万两,绢十万匹),然后和议得以维持。给付岁币的名义,《宋史》说是纳字,《辽史》却说是贡字,未知谁真谁假。然即使用纳字,亦已经不甚光荣了。仁宗在位岁久,政颇宽仁,然亦极因循腐败。兵多而不能战,财用竭蹶而不易支持,已成不能振作之势。1063年,仁崇崩,英宗立,在位仅四年。神宗继之,乃有用王安石变法之事。\n王安石的变法,旧史痛加诋毁,近来的史家,又有曲为辩护的,其实都未免有偏。王安石所行的政事,都是不错的。但行政有一要义,即所行之事,必须要达到目的,因此所引起的弊窦,必须减至极少。若弊窦在所不免,而目的仍不能达,就不免徒滋纷扰了。安石所行的政事,不能说他全无功效,然因此而引起的弊端极大,则亦不容为讳。他所行的政事,免役最是利余于弊的,青苗就未必能然。方田均税,在他手里推行得有限,后人踵而行之,则全是徒有其名。学校、贡举则并未能收作育人才之效。参看四十一、四十三、四十四三章自明。宋朝当日,相须最急的,是富国强兵。王安石改革的规模颇大,旧日史家的议论,则说他是专注意于富强的(尤其说王安石偏于理财。此因关于改革社会的行政,不为从前的政治家所了解之故)。他改革的规模,固不止此,于此确亦有相当的注意。其结果:裁汰冗兵,确是收到很大的效果的,所置的将兵,则未必精强,保甲尤有名无实,而且所引起的骚扰极大,参看第四十五章自明。安石为相仅七年,然终神宗之世,守其法未变。1085年,神宗崩,子哲宗立。神宗之母高氏临朝,起用旧臣,尽废新法。其死后,哲宗亲政,复行新法,谓之“绍述”。1100年,哲宗崩,徽宗立,太后向氏权同听政,想调和新旧之见,特改元为建中靖国。徽宗亲政后,仍倾向于新法。而其所用的蔡京,则是反复于新旧两党间的巧宦。徽宗性极奢侈,蔡京则搜括了各方面的钱,去供给他浪用,政治情形一落千丈。恢复燕、云和西北,可说是神宗和王安石一个很大的抱负,但因事势的不容许,只得先从事于其易。王安石为相时,曾用王韶征服自唐中叶以后杂居于今甘、青境内的蕃族,开其地为熙河路。这可说是进取西夏的一个预备。然神宗用兵于西夏却不利。哲宗时,继续筑寨,进占其地。夏人力不能支,请辽人居间讲和。宋因对辽有所顾忌,只得许之。徽宗时,宦者童贯,继续用兵西北,则徒招劳费而已。总之:宋朝此时的情势,业已岌岌难支,幸辽、夏亦已就衰,暂得无事,而塞外有一个新兴民族崛起,就要大祸临头了。\n金朝的先世,便是古代的所谓肃慎,南北朝、隋、唐时的靺鞨。宋以后则称为女真(女真二字,似即肃慎的异译。清人自称为满洲,据明人的书,实作满住,乃大酋之称,非部族之名。愚按靺鞨酋长之称为大莫弗瞒咄,瞒咄似即满住,而靺鞨二字,似亦仍系瞒咄的异译。至汉时又称为挹娄,据旧说:系今叶鲁二字的转音。而现在的索伦二字,又系女真的异译,此推测而确,则女真民族之名,自古迄今,实未曾变)。其主要的部落,在今松花江流域。在江南的系辽籍,称为熟女真,江北的不系籍,谓之生女真。女真的文明程度,是很低的,到渤海时代,才一度开化。金朝的始祖,名唤函普,是从高句丽旧地,入居生女真的完颜部,而为其酋长的。部众受其教导,渐次开化。其子孙又以渐征服诸部族,势力渐强。而辽自兴宗后,子道宗立,政治渐乱。道宗死,子天祚帝立,荒于游畋,竟把国事全然置诸不顾。女真本厌辽人的羁轭,天祚帝遣使到女真部族中去求名鹰,骚扰尤甚,遂致激起女真的叛变。金太祖完颜阿骨打,于1114年,起兵与辽相抗。契丹控制女真的要地黄龙府、咸州、宁江州(黄龙府,今吉林农安县。咸州,令辽宁铁岭县。宁江州,在吉林省城北),次第失陷。天祚帝自将大兵东征,因有内乱西归。旋和金人讲和,又迁延不定。东京先陷,上京及中、西两京继之(上京临潢府,在今热河开鲁县南。中京大定府,在今热河建昌县。东京辽阳府,今辽宁辽阳县。南京析津府,即幽州。西京大同府,即云州)。南京别立一君,意图自保,而宋人约金攻辽之事又起。先是童贯当权,闻金人攻辽屡胜,意图侥幸。遣使于金,求其破辽之后,将石晋所割之地,还给中国。金人约以彼此夹攻,得即有之。而童贯进兵屡败,乃又求助于金。金太祖自居庸关入,把南京攻下。太祖旋死,弟太宗立。天祚帝展转漠南,至1125年为金人所获,辽亡。\n●宋徽宗《听琴图》\n宋朝本约金夹攻的,此时南京之下,仍藉金人之力,自无坐享其成之理,乃输燕京代税钱一百万缗,并许给岁币,金人遂以石晋所割之地来归。女真本系小部族,此时吞并全辽,已觉消化不下,焉有余力经营中国的土地?这是其肯将石晋所割之地还给中国的理由。但女真此时,虽不以地狭为忧,却不免以土满为患。文明国民,生产能力高强的,自然尤为其所欢迎。于是军行所至,颇以掳掠人口为务。而汉奸亦已有献媚异族,进不可割地之议的。于是燕京的归还,仅系一个空城,尽掳其人民以去。而营、平、滦三州(平州,今河北卢龙县。滦州,今河北滦县),本非石晋所割让,宋朝向金要求时,又漏未提及,则不肯归还,且将平州建为南京,命辽降将张觉守之。燕京被掳的人民,流离道路,不胜其苦,过平州时,求张觉做主。张觉就据地来降。这是一件很重大的交涉。宋朝当时,应该抚恤其人民,而对于金朝,则另提出某种条件,以足其欲而平其愤。金朝此时,虽已有汉奸相辅,究未脱野蛮之习,且值草创之际,其交涉是并不十分难办的。如其处置得宜,不但无启衅之忧,营、平、滦三州,也未尝不可乘机收复。而宋朝贸然受之,一无措置。到金人来诘责,则又手忙脚乱,把张觉杀掉,函首以畀之。无益于金朝的责言,而反使降将解体,其手段真可谓拙劣极了。\n辽朝灭亡之年,金朝便举兵南下。宗翰自云州至太原,为张孝纯所阻,而宗望自平州直抵汴京。时徽宗已传位于钦宗。初任李纲守御,然救兵来的都不能解围。不得已,许割太原、中山、河间三镇(中山,今河北定县。河间,今河北河间县);宋主称金主为伯父;并输金五百万两,银五千万两,牛、马万头,表缎百万匹讲和。宗望的兵才退去。金朝此时,是不知什么国际的礼法的,宗翰听闻宗望得了赂,也使人来求赂。宋人不许。宗翰怒,攻破威胜军和隆德府(威胜军,今山西沁县。隆德府,今山西长治县)。宋人认为背盟,下诏三镇坚守。契丹遗臣萧仲恭来使,又给以蜡书,使招降契丹降将耶律余睹。于是宗翰、宗望再分道南下,两路都抵汴京。徽、钦二宗,遂于1127年北狩。金朝这时候,是断没有力量,再占据中国的土地的,所希望的,只是有一个傀儡,供其驱使而已。乃立宋臣张邦昌为楚帝,退兵而去。张邦昌自然是要靠金朝的兵力保护,然后能安其位的。金兵既去,只得自行退位。而宋朝是时,太子、后妃、宗室多已被掳,只得请哲宗的废后孟氏出来垂帘。“虽举族有北辕之衅,而敷天同左袒之心”(孟后立高宗诏语),这时候的民族主义,自然还要联系在忠君思想上,于是孟后下诏,命高宗在归德正位(今河南商丘县)。\n第二十六章 南宋恢复的无成 # 语云:“败军之气,累世而不复”,这话亦不尽然。“困兽犹斗”,反败为胜的事情,决不是没有的,只看奋斗的精神如何罢了。宋朝当南渡时,并没有什么完整的军队,而且群盗如毛,境内的治安,且岌岌不可保,似乎一时间决谈不到恢复之计。然以中国的广大,金朝人能有多大的兵力去占据?为宋朝计,是时理宜退守一个可守的据点,练兵筹饷,抚恤人民。被敌兵蹂躏之区,则奖励、指导其人民,使之团结自守,而用相当的正式军队,为之声援。如此相持,历时稍久,金人的气焰必渐折,恢复之谋,就可从此开展了。苦于当时并没有这种眼光远大的战略家。而且当此情势,做首领的,必须是一个文武兼资之才,既有作战的策略,又能统驭诸将,使其不敢骄横,遇敌不敢退缩,对内不敢干政,才能够悉力对外。而这时候,又没有这样一个长于统率的人物。金兵既退,宗泽招降群盗,以守汴京。高宗既不能听他的话还跸,又不能驻守关中或南阳,而南走扬州。公元1129年,金宗翰、宗望会师濮州(在今山东濮县),分遣娄室入陕西。其正兵南下,前锋直打到扬州。高宗奔杭州(今浙江杭县)。明年,金宗弼渡江,自独松关入(在今安徽广德县东),高宗奔明州(今浙江鄞县)。金兵再进迫,高宗逃入海。金兵亦入海追之,不及乃还。自此以后,金人亦以“士马疲敝,粮储未丰”(宗弼语),不能再行进取了。其西北一路,则宋朝任张浚为宣抚使,以拒娄室,而宗弼自江南还,亦往助娄室。浚战败于富平(今陕西兴平县),陕西遂陷。但浚能任赵开以理财,用刘子羽、吴玠、吴璘等为将,卒能保守全蜀。\n●北宋·李成、王晓《读碑窠石图》设色绢本立轴,126.3×104.9厘米,日本大阪市美术馆收藏\n利用傀儡,以图缓冲,使自己得少休息,这种希冀,金人在此时,还没有变。其时宗泽已死,汴京失陷,金人乃立宋降臣刘豫于汴,畀以河南、陕西之地。刘豫却想靠着异族的力量反噬,几次发兵入寇,却又都败北。在金人中,宗弼是公忠体国的,挞懒却骄恣腐败(金朝并无一定之继承法,故宗室中多有觊觎之心。其时握兵权者,宗望、宗弼皆太祖子,宗翰为太祖从子,挞懒则太祖从弟。宗翰即有不臣之心。挞懒最老寿,在熙宗时为尊属,故其觊觎尤甚。熙宗、海陵庶人、世宗,皆太祖孙)。秦桧是当金人立张邦昌时,率领朝官,力争立赵氏之后,被金人捉去的。后来以赐挞懒。秦桧从海路逃归。秦桧的意思,是偏重于对内的。因为当时,宋朝的将帅,颇为骄横。“廪稍惟其所赋,功勋惟其所奏。”“朝廷以转运使主馈饷,随意诛求,无复顾惜。”“使其浸成疽赘,则非特北方未易取,而南方亦未易定。”(叶适《论四大屯兵》语,详见《文献通考·兵考》)所以要对外言和,得一个整理内部的机会。当其南还之时,就说要“南人归南,北人归北”。高宗既无进取的雄才,自然意见与之相合,于是用为宰相。1137年,刘豫为宗弼所废。秦桧乘机,使人向挞懒要求,把河南、陕西之地,还给宋朝。挞懒允许了。明年,遂以其地来归。而金朝突起政变。1139年,宗弼回上京(今吉林阿城县)。挞懒南走。至燕京,为金人所追及,被杀。和议遂废。宗弼再向河南,娄室再向陕西。宋朝此时,兵力已较南渡之初稍强。宗弼前锋至顺昌(今安徽阜阳县),为刘锜所败。岳飞从湖北进兵,亦有郾城之捷(今河南偃城县)。吴璘亦出兵收复了陕西若干州郡。倘使内部没有矛盾,自可和金兵相持。而高宗、秦桧执意言和,把诸将召还,和金人成立和约:东以淮水,西以大散关为界(在陕西宝鸡县南);岁奉银、绢各二十五万两、匹;宋高宗称臣于金,可谓屈辱极了。于是罢三宣抚司,改其兵为某州驻扎御前诸军,而设总领以司其财赋,见第四十五章。\n金太宗死后,太祖之孙熙宗立,以嗜酒昏乱,为其从弟海陵庶人所弑,此事在1149年。海陵更为狂妄,迁都于燕,后又迁都于汴。1160年,遂大举南侵。以其暴虐过甚,兵甫动,就有人到辽阳去拥立世宗。海陵闻之,欲尽驱其众渡江,然后北还。至采石矶,为宋虞允文所败。改趋扬州,为其下所弑,金兵遂北还。1162年,高宗传位于孝宗。孝宗颇有志于恢复,任张浚以图进取。浚使李显忠进兵,至符离(集名,在今安徽宿县)大败。进取遂成画饼。1165年,以岁币各减五万,宋主称金主为伯父的条件成和。金世宗算是金朝的令主。他的民族成见,是最深的。他曾对其种人,屡称上京风俗之美,教他们保存旧风,不要汉化。臣下有说女真、汉人,已为一家的,他就板起脸说:“女真、汉人,其实是二。”这种尖锐的语调,决非前此的北族,所肯出之于口的,其存之于心的,自亦不至如世宗之甚了。然世宗的见解虽如此,而既不能放弃中国之地,就只得定都燕京。并因是时叛者蜂起,不得不将猛安、谋克户移入中原,以资镇压。夺民地以给之,替汉人和女真之间,留下了深刻的仇恨。而诸猛安谋克人,则惟酒是务,竟有一家百口,垅无一苗的,征服者的气质,丧失净尽了。自太祖崛起至此,不过六十年。\n公元1194年,孝宗传位于光宗。此时金世宗亦死,子章宗立,北边颇有叛乱,河南、山东,亦有荒歉之处,金朝的国势渐衰。宋光宗多病,皇后李氏又和太上皇不睦。1194年,孝宗崩,光宗不能出而持丧,人心颇为疑惑。宰相赵汝愚,因合门使韩侂胄,请于高宗后吴氏,扶嘉王扩内禅,是为宁宗。韩侂胄排去赵汝愚,代为宰相,颇为士流所攻击,想立恢复之功,以间执众口。1206年,遂贸然北伐。谁想金兵虽弱,宋兵亦不强。兵交之后,襄阳和淮东西州郡,次第失陷。韩侂胄又想谋和,而金人复书,要斩侂胄之首,和议复绝。皇后杨氏,本和韩侂胄有隙,使其兄次山,勾结侍郎史弥远,把韩侂胄杀掉,函首以畀金。1208年,以增加岁币为三十万两、匹的条件成和。韩侂胄固然是妄人,宋朝此举,也太不成话了。和议成后两年,金章宗死,世宗子卫绍王立。其明年,蒙古侵金,金人就一败涂地。可见金朝是时,业已势成弩末,宋朝并没有急于讲和的必要了。\n蒙古本室韦部落,但其后来和鞑靼混合,所以蒙人亦自称为鞑靼。其居地初在望建河,即今黑龙江上游之南,而后徙于不而罕山,即今外蒙古车臣、土谢图两部界上的布尔罕哈勒那都岭。自回纥灭亡以后,漠北久无强部,算到1167年成吉思汗做蒙古的酋长的时候,已经三百六十多年了,淘汰,酝酿,自然该有一个强部出来。成吉思汗少时,漠南北诸部错列,蒙古并不见得怎样强大。且其内部分裂,成吉思汗备受同族的龁。但他有雄才大略,收合部众,又与诸部落合纵连横,至1206年,而漠南北诸部,悉为所征服。这一年,诸部大会于斡难河源(今译作鄂诺,又作敖嫩),上他以成吉思汗的尊号。成吉思汗在此时,已非蒙古的汗,而为许多部族的大汗了。1210年,成吉思汗伐夏,夏人降。其明年,遂伐金。金人对于北方,所采取的,是一种防守政策。从河套斜向东北,直达女真旧地,筑有一道长城。汪古部居今归绥县之北,守其冲要之点。此时汪古通于蒙古,故蒙古得以安行而入长城。会河堡一战(会河堡,在察哈尔万全县西),金兵大败,蒙古遂入居庸关。留兵围燕京,分兵蹂躏山东、山西,东至辽西。金人弑卫绍王,立宣宗,与蒙古言和,而迁都于汴。蒙古又以为口实,发兵攻陷燕京。金人此时,尽迁河北的猛安、谋克户于河南,又夺汉人之地以给之。其民既不能耕,又不能战。势已旦夕待亡。幸1218年,成吉思汗用兵于西域,金人乃得少宽。这时候,宋朝亦罢金岁币。避强凌弱,国际上总是在所不免的;而此时金人,财政困难,对于岁币,亦不肯放弃,或者还希冀战胜了可以向宋人多胁取些,于是两国开了兵衅。又因疆场细故,与夏人失和,兵力益分而弱。1224年,宣宗死,哀宗立,才和夏人以兄弟之国成和(前此夏人称臣),而宋朝卒不许。其时成吉思汗亦已东归,蒙古人的兵锋,又转向中原了。1227年,成吉思汗围夏,未克而死。遗命秘不发丧,把夏人灭掉。1229年,太宗立。明年,复伐金。时金人已放弃河北,以精兵三十万,守邳县到潼关的一线。太宗使其弟拖雷假道于宋,宋人不许。拖雷就强行通过,自汉中、襄、郧而北,大败金人于三峰山(在河南禹县)。太宗亦自白坡渡河(在河南孟津县),使速不台围汴。十六昼夜不能克,乃退兵议和。旋金兵杀蒙古使者,和议复绝。金哀宗逃到蔡州。宋、元复联合以攻金。宋使孟珙、江海帅师会蒙古兵围蔡。1234年,金亡。\n约金攻辽,还为金灭,这是北宋的覆辙,宋人此时,似乎又不知鉴而蹈之了。所以读史的人,多以宋约元攻金为失策,这亦未必尽然。宋朝和金朝,是不共戴天之仇,不能不报的。若说保存金朝以为障蔽,则金人此时,岂能终御蒙古?不急进而与蒙古联合,恢复一些失地,坐视金人为蒙古所灭,岂不更糟?要知约金攻辽,亦并不算失策,其失策乃在灭辽之后,不能发愤自强,而又轻率启衅。约元灭金之后,弊亦仍在于此。金亡之前十年,宋宁宗崩,无子。史弥远援立理宗,仍专政。金亡前一年,史弥远死,贾似道继之。贾似道是表面上似有才气,而不能切实办事的人,如何当得这艰难的局面?金亡之后,宋朝人倡议收复三京(宋东京即大梁,南京即宋州,西京为洛阳,北京为大名),入汴、洛而不能守。蒙古反因此南侵,江、淮之地多陷。1241年,蒙古太宗死。1246年,定宗立。三年而死。1251年,宪宗方立。蒙古当此时,所致力的还是西域,而国内又有汗位继承之争,所以未能专力攻宋。至1258年,各方粗定,宪宗乃大举入蜀。忽必烈已平吐蕃、大理,亦东北上至鄂州(今湖北武昌县)。宋将王坚守合州(今四川合川县),宪宗受伤,死于城下。贾似道督大军援鄂,不敢战,使人求和,许称臣,划江为界。忽必烈亦急图自立,乃许之而北归。贾似道掩其事,以大捷闻于朝。自此蒙古使者来皆拘之,而借和议以图自强,而待敌人之弊的机会遂绝。忽必烈北还后,自立,是为元世祖。世祖在宪宗时,本来是分治漠南的,他手下又多西域人和中国人,于是以1264年定都燕京。蒙古的根据地,就移到中国来了。明年,理宗崩,子度宗立。宋将刘整叛降元,劝元人攻襄阳。自1268年至1273年,被围凡五年,宋人不能救,襄阳遂陷。明年,度宗崩,子恭帝立。伯颜自两湖长驱南下。1276年,临安不守,谢太后和恭帝都北狩。故相陈宜中立其弟益王于福州(今福建闽侯县),后来转徙,崩于州(在今广东吴川县海中)。其弟卫王昺立,迁于崖山(在今广东新会县海中)。1279年,汉奸张弘范来攻,宰相陆秀夫负帝赴海殉国。张世杰收兵图再举,到海陵山(在今广东海阳县海中),舟覆而死。宋亡。中国遂整个为北族所征服。\n宋朝的灭亡,可以说是我国民族的文化,一时未能急剧转变,以适应于竞争之故。原来游牧民族,以掠夺为生产,而其生活又极适宜于战斗,所以其势甚强,文明民族,往往为其所乘,罗马的见轭于蛮族,和中国的见轭于五胡和辽、金、元、清,正是一个道理。两国国力的强弱,不是以其所有的人力物力的多少而定,而是看其能利用于竞争的共有多少而定。旧时的政治组织,是不适宜于动员全民众的。其所恃以和异族抵抗的一部分,或者正是腐化分子的一个集团。试看宋朝南渡以后,军政的腐败,人民的困苦,而一部分士大夫反溺于晏安鸩毒、歌舞湖山可知。虽其一部分分子的腐化,招致了异族的压迫,却又因异族的压迫,而引起了全民族的觉醒,替民族主义,建立了一个深厚的根基,这也是祸福倚伏的道理。北宋时代,可以说是中国民族主义的萌蘖时期。南宋一代,则是其逐渐成长的时期。试读当时的主战派,如胡铨等一辈人的议论,至今犹觉其凛凛有生气可知(见《宋史》卷三七四)。固然,只论是非,不论利害,是无济于事的。然而事有一时的成功,有将来的成功。主张正义的议论,一时虽看似迂阔,隔若干年代后,往往收到很大的效果。民族主义的形成,即其一例。论是非是宗旨,论利害是手段。手段固不能不择,却不该因此牺牲了宗旨。历来外敌压迫时,总有一班唱高调的人,议论似属正大,居心实不可问,然不能因此而并没其真。所以自宋至明,一班好发议论的士大夫,也是要分别观之的。固不该盲从附和,也不该一笔抹杀。其要,在能分别真伪,看谁是有诚意的,谁是唱高调的,这就是大多数国民,在危急存亡之时,所当拭目辨别清楚的了。民族主义,不但在上流社会中,植下了根基,在下流社会中,亦立下了一个组织,看后文所述便知。\n第二十七章 蒙古大帝国的盛衰 # 蒙古是野蛮的侵略民族所建立的最大的帝国,它是适值幸运而成功的。\n\\[木剌夷(Mulahids),为天方教中之一派,在里海南岸\\],西域至此略定。东北一带,自高句丽、百济灭亡后,新罗亦渐衰。唐末,复分为高丽、后百济及新罗三国。石晋初,尽并于高丽王氏。北宋之世,高丽曾和契丹构兵,颇受其侵略,然尚无大关系。自高句丽灭亡后,朝鲜半岛的北部,新罗控制之力,不甚完全,高丽亦未能尽力经营,女真逐渐侵入其地,是为近世满族发达的一个原因,金朝即以此兴起。完颜部本曾朝贡于高丽,至后来,则高丽反为所胁服,称臣奉贡。金末,契丹遗族和女真人在今辽、吉境内扰乱,蒙古兵追击,始和高丽相遇,因此引起冲突,至太宗时乃成和。此后高丽内政,随时受蒙古人的干涉。有时甚至废其国号,而于其地立征东行省。元世祖时,中国既定,又要介高丽以招致日本。日本不听,世祖遂于1274、1281两年遣兵渡海东征。前一次损失还小,后一次因飓风将作,其将择坚舰先走,余众二十余万,尽为日本所虏,杀蒙古人、高丽人、汉人,而以南人为奴隶,其败绩可谓残酷了。世祖欲图再举,因有事于安南,遂不果。蒙古西南的侵略,是开始于宪宗时的。世祖自今青海之地入西藏,遂入云南,灭大理(即南诏)。自将北还,而留兵续向南方侵略。此时后印度半岛之地,安南已独立为国。其南,今柬埔寨之地为占城,蒲甘河附近则有缅国。元兵侵入安南和占城,其人都不服,1284、1285、1287三年,三次发兵南征,因天时地利的不宜,始终不甚得利。其在南洋,则曾一度用兵于爪哇。此外被招致来朝的共有十国,都是今南洋群岛和印度沿岸之地(《元史》云:当时海外诸国,以俱蓝、马八儿为纲维,这两国,该是诸国中最大的。马八儿,即今印度的马拉巴尔。俱蓝为其后障,当在马拉巴尔附近)。自成吉思汗崛起至世祖灭宋,共历一百一十二年,而蒙古的武功,臻于极盛。其人的勇于战斗,征服各地方后,亦颇长于统治(如不干涉各国的信教自由,即其一端),自有足称。但其大部分成功的原因,则仍在此时别些大国,都适值衰颓,而乏抵抗的能力,其中尤其主要的,就是中国和大食帝国;又有一部分人,反为其所用,如蒙古西征时附从的诸部族便是,所以我说它是适值天幸。\n中国和亚、欧、非三洲之交的地中海沿岸,是世界上两个重要的文明起源之地。这两个区域的文明,被亚洲中部和南部的山岭,和北方的荒凉阻隔住了。欧洲文明的东渐,大约以古希腊人的东迁为最早。汉通西域时所接触的西方文化,就都是古希腊人所传播、所留遗。其后罗马兴,东边的境界,仍为东西文化接触之地。至罗马之北境为蛮族所据而中衰。大食兴,在地理上,拥有超过罗马的大版图,在文化上,亦能继承希腊的遗绪。西方的文化,因此而东渐,东方的文化,因此而西行者不少。但主要的是由于海路。至蒙古兴,而欧西和东方的陆路才开通。其时西方的商人,有经中央亚细亚、天山南路到蒙古来的,亦有从西伯利亚南部经天山北路而来的。基督教国,亦派有使节东来。而意大利人马可波罗(Marco polo)居中国凡三十年,归而以其所见,著成游记,给与西方人以东方地理上较确实的知识,且引起其好奇心,亦为近世西力东侵的一个张本。\n\\[阿阔台之后称Km.of Ogotai,亦称Naiman(乃蛮)。察合台之后称Km.Of Tchagatai。拔都之后称Km.of Kiptchac,亦称Golden Horde。旭烈兀之后称Km.of Iran\\],而分裂即起于其间。蒙古的汗,本来是由诸部族公推的,到后来还是如此。每当大汗逝世之后,即由宗王、驸马和管兵的官,开一个大会(蒙古语为“忽力而台”),议定应继承汗位的人。太祖之妻孛儿帖,曾给蔑儿乞人掳去,后太祖联合与部,把她抢回,就生了朮赤。他的兄弟,心疑他是蔑儿乞种,有些歧视他,所以他西征之后,一去不归,实可称为蒙古的泰伯。太祖死时,曾有命太宗承继之说,所以大会未有异议。太宗死后,其后人和拖雷的后人,就有争夺之意。定宗幸获继立,而身弱多病,未久即死。拖雷之子宪宗被推戴。太宗后人,另谋拥戴失烈门,为宪宗所杀,并夺去太宗后王的兵柄。蒙古的内争,于是开始。宪宗死后,争夺复起于拖雷后人之间。宪宗时,曾命阿里不哥统治漠北,世祖统治漠南。宪宗死后,世祖不待大会的推戴而自立,阿里不哥亦自立于漠北,为世祖所败,而大宗之子海都自立于西北,察合台、钦察两汗国都附和他。伊儿汗国虽附世祖,却在地势上被隔绝了,终世祖之世不能定,直到1310年,海都之子才来归降。然自海都之叛,蒙古大汗的号令,就不能行于全帝国,此时亦不能恢复了。所以蒙古可说是至世祖时而臻于极盛,亦可说自世祖时而开始衰颓。\n第二十八章 汉族的光复事业 # ●成吉思汗\n辽、金、元三朝,立国的情形,各有不同。契丹虽然占据了中国的一部分,然其立国之本,始终寄于部族,和汉人并未发生深切的关系。金朝所侵占的重要之地,唯有中国。它的故土和它固有的部族、文化尚未发展,虽可藉其贫瘠而好掠夺的欲望,及因其进化之浅,社会组织简单,内部矛盾较少,因而以诚朴之气、勇敢之风,而崛起于一时,然究不能据女真之地,用女真之人,以建立一个大国。所以从海陵迁都以后,其国家的生命,已经寄托在它所侵占的中国的土地上了。所以它压迫汉人较甚,而其了解汉人,却亦较深。至蒙古,则所征服之地极广,中国不过是其一部分。虽然从元世祖以后,大帝国业已瓦解,所谓元朝者,其生命亦已寄托于中国,然自以为是一个极大的帝国,看了中国,不过是其所占据的地方的一部分的观念,始终未能改变。所以对于中国,并不能十分了解,试看元朝诸帝,多不通汉文及汉语可知。元朝诸帝,惟世祖较为聪明,所用的汉人和西域人较多,亦颇能厘定治法。此后则惟仁宗在位较久,政治亦较清明。其余诸帝,大抵荒淫愚昧。这个和其继嗣之争,亦颇有关系。因为元朝在世祖之时,北边尚颇紧急。成宗和武宗,都是统兵在北边防御,因而得立的。武宗即位之前,曾由仁宗摄位,所以即位之后,不得不立仁宗为太子。因此引起英宗之后泰定、天顺二帝间的争乱。文宗死后,又引起燕帖木儿的专权(时海都之乱未定,成宗和武宗都是统兵以防北边的。世祖之死,伯颜以宿将重臣,归附成宗,所以未有争议。成宗之死,皇后伯岳吾氏想立安西王。右丞相哈剌哈孙使迎仁宗监国,以待武宗之至。武宗至,弑伯岳吾后,杀安西王而自立。以仁宗为太子。仁宗既立,立英宗为太子,而出明宗于云南。其臣奉之奔阿尔泰山。英宗传子泰定帝,死于上都。子天顺帝,即在上都即位。签书枢密院事燕帖木儿,为武宗旧臣,胁大都百官,迎立武宗之子。因明宗在远,先迎文宗监国。发兵陷上都,天顺帝不知所终。明宗至漠南,即位。文宗入见,明宗暴死。文宗后来心上觉得不安,遗令必立明宗之子。而燕帖木儿不肯。文宗皇后翁吉剌氏,坚持文宗的遗命。于是迎立宁宗,数月而死。再迎顺帝。顺帝的年纪却比宁宗大些了,燕帖木儿又坚持,顺帝虽至,不得即位。会燕帖木儿死,问题乃得解决。顺帝既立,追治明宗死事,翁吉剌后和其子燕帖古思都被流放到高丽,死在路上。元入中国后的继嗣之争,大略如此)。中央的变乱频仍,自然说不到求治,而最后又得一个荒淫的顺帝,胡无百年之运,客星据坐,自然不能持久了。元世祖所创立的治法,是专以防制汉人为务的。试看其设立行省及行御史台;将边徼襟喉之地,分封诸王;遣蒙古军及探马赤军分守河、洛、山东;分派世袭的万户府,屯驻各处;及因重用蒙古、色目人而轻视汉人可知。这是从立法方面说。从行政方面说:则厚敛人民,以奉宗王、妃、主。纵容诸将,使其掠人为奴婢。选法混乱,贪黩公行。而且迷信喇嘛教,佛事所费,既已不赀,还要听其在民间骚扰。可谓无一善政(参看第三十九、第四十、第四十二、第四十三、第四十五各章),所以仍能占据中国数十年,则因中国社会,自有其深根宁极之理,并非政治现象,所能彻底扰乱,所以其以异族入据中原,虽为人心所不服,亦不得不隐忍以待时。到顺帝时,政治既乱,而又时有水旱偏灾,草泽的英雄,就要乘机而起了。\n“举世无人识,终年独自行。海中擎日出,天外唤风生。”(郑所南先生诗语。所南先生名思肖。工画兰。宋亡后,画兰皆不画土。人或问之。则曰:“土为番人夺去,汝不知耶?”著有《心史》,藏之铁函,明季乃于吴中承天寺井中得之。其书语语沉痛,为民族主义放出万丈的光焰。清朝的士大夫读之,不知自愧,反诬为伪造,真可谓全无心肝了。)表面上的平静,是靠不住的,爆发的种子,正潜伏在不见不闻之处。这不见不闻之处是哪里呢?这便在各人的心上。昔人说:“雪大耻,复大仇,皆以心之力。”(龚自珍文中语)。文官投降了,武官解甲了,大多数的人民,虽然不服,苦于不问政治久了,一时团结不起来。时乎时乎?七年之病,求三年之艾,乃将一颗革命的种子,广播潜藏于人民的唯一组织,即所谓江湖豪侠的社会之中,这是近世史上的一件大事。明亡以后之事,为众所周知,然其事实不始于明亡以后,不过年深月久,事迹已陈,这种社会中,又没有记载,其事遂在若存若亡之间罢了。元朝到顺帝之世,反抗政府的,就纷纷而起。其中较大的是:台州的方国珍(今浙江临海县),徐州的李二,湖北的徐寿辉,濠州的郭子兴(今安徽凤阳县),高邮的张士诚(后迁平江,今江苏吴县),而刘福通以白莲教徒,起于安丰(今安徽寿县),奉其教主之子韩林儿为主。白莲教是被近代的人看作邪教的,然其起始决非邪教,试看其在当时,首举北伐的义旗可知。元朝当日,政治紊乱。宰相脱脱之弟也先帖木儿,当征讨之任,连年无功,后来反大溃于沙河(今河南遂平、确山、泌阳境上的沙河店),军资丧失殆尽。脱脱觉得不好,自将大军出征,打破了李二,围张士诚,未克,而为异党排挤以去。南方群雄争持,元朝就不能过问。1358年,刘福通分兵三道:一军入山、陕,一军入山东,自奉韩林儿复开封。此时元朝方面,亦有两个人出来替其挣扎,那便是察罕帖木儿和李思齐。他们是在河南起兵帮助元朝的。此时因陕西行省的求援,先入陕解围。又移兵山东,把刘福通所派的兵,围困起来。刘福通的将遣人把察罕刺死。其子库库帖木儿代总其兵,才把刘福通军打败,刘福通和韩林儿,走回安丰,后为张士诚所灭。然其打山西的一支兵,还从上都直打到辽东(今多伦县,元世祖自立于此,建为上都,而称今北平为大都),然后被消灭。军行数千里,如入无人之境,亦可谓虽败犹荣了。\n首事的虽终于无成,然继起的则业已养成气力。明太祖初起时,本来是附随郭子兴的。后来别为一军,渡江取集庆(今南京,元集庆路)。时徐寿辉为其将陈友谅所杀,陈友谅据江西、湖北,势颇强盛(寿辉将明玉珍据四川自立,传子昇,为明太祖所灭),后为太祖所灭。太祖又降方国珍、破张士诚,几乎全据了长江流域。而元朝是时,复起内乱。其时库库帖木儿据冀宁(元冀宁路,治今山西阳曲县),孛罗帖木儿据大同,孛罗想兼据晋冀,以裕军食,二人因此相争。顺帝次后奇氏,高丽人,生子爱猷识理达腊,立为太子。太子和奇后,阴谋内禅。是时高丽人自宫到元朝来充当内监的很多,奇后宫中,自更不乏,而朴不花最得信任,宰相搠思监就是走朴不花的门路得位的。他和御史大夫老的沙不协,因太子言于顺帝,免其职。老的沙逃奔大同,托庇于孛罗。搠思监诬孛罗谋反。孛罗就真个反叛,举兵犯阙,把搠思监和朴不花都杀掉。太子投奔库库。库库兴兵送太子还京,孛罗已被顺帝遣人刺死。太子欲使库库以兵力胁迫顺帝内禅,库库不肯。时顺帝封库库为河南王,使其总统诸军,平定南方。李思齐因与察罕同起兵,不愿受库库节制,陕西参政张良弼,亦和库库不协,二人连兵攻库库。太子乘机叫顺帝下诏,削掉库库的官爵,使太子统兵讨之。北方大乱。“天道好还,中国有必伸之理,人心效顺,匹夫无不报之仇。”(太祖时讨胡檄中语)1368年,明太祖命徐达、常遇春两道北伐。徐达平河南,常遇春下山东,会师德州(今山东德县),北扼直沽。顺帝走上都。太祖使徐达下太原,乘胜定秦、陇,库库帖木儿奔和林(和林城,太宗所建,今之额尔德尼招,是其遗址)。常遇春攻上都,顺帝再奔应昌(城名,在达里泊傍,为元外戚翁吉剌氏之地)。1387年,顺帝死,明兵再出,爱猷识理达腊亦奔和林。不久便死,子脱古思帖木儿嗣。1387年,太祖使蓝玉平辽东,乘胜袭破脱古思帖木儿于捕鱼海(今达里泊)。脱古思帖木儿北走,为其下所杀。其后五传皆被弑,蒙古大汗的统系遂绝。元宗室分封在内地的亦多降,惟梁王把匝剌瓦尔密据云南不服。1381年,亦为太祖所灭。中原之地,就无元人的遗孽了。自1279年元朝灭宋,至1368年顺帝北走,凡八十九年。\n第二十九章 明朝的盛衰 # 明太祖起于草泽,而能铲除胡元,定群雄,其才不可谓不雄。他虽然起于草泽,亦颇能了解政治,所定的学校、科举、赋役之法,皆为清代所沿袭,行之凡六百年。卫所之制,后来虽不能无弊,然推原其立法之始,亦确是一种很完整的制度,能不烦民力而造成多而且强的军队。所以明朝开国的规模,并不能算不弘远。只可惜他私心太重。废宰相,使朝无重臣,至后世,权遂入于阉宦之手。重任公侯伯的子孙,开军政腐败之端。他用刑本来严酷,又立锦衣卫,使司侦缉事务,至后世,东厂、西厂、内厂,遂纷纷而起(东厂为成祖所设,西厂设于宪宗时,内厂设于武宗时,皆以内监领其事)。这都不能不归咎于诒谋之不臧。其封建诸子于各地,则直接引起了靖难之变。\n明初的边防,规模亦是颇为弘远的。俯瞰蒙古的开平卫,即设于元之上都。其后大宁路来降,又就其地设泰宁、朵颜、福余三卫。泰宁在今热河东部,朵颜在吉林之北,福余则在农安附近。所以明初对东北,威远瞻。其极盛时的奴儿干都司,设于黑龙江口,现在的库页岛,亦受管辖(《明会典》卷一〇九:永乐七年,设奴儿干都司于黑龙江口。清曹廷杰《西伯利亚东偏纪要》说,庙尔以上二百五十余里,混同江东岸特林地方,有两座碑:一刻《敕建永宁寺记》,一刻《宣德六年重建永宁寺记》,均系太监亦失哈述征服奴儿干和海中苦夷之事。苦夷即库页。宣德为宣宗年号,宣德六年为公元1431年)。但太祖建都南京,对于北边的控制,是不甚便利的。成祖既篡建文帝,即移都北京。对于北方的控制,本可更形便利。确实,他亦曾屡次出征,打破鞑靼和瓦剌。但当他初起兵时,怕节制三卫的宁王权要袭其后,把他诱执,而将大宁都司,自今平泉县境迁徙到保定。于是三卫之地,入于兀良哈,开平卫势孤。成祖死后,子仁宗立,仅一年而死。子宣宗继之。遂徙开平卫于独石口。从此以后,宣、大就成为极边了。距离明初的攻克开平,逐去元顺帝,不过六十年。明初的经略,还不仅对于北方。安南从五代时离中国独立,成祖于1406年,因其内乱,将其征服,于其地设立交趾布政使司,同于内地。他又遣中官郑和下南洋,前后凡七次。其事在1405至1433年之间,早于欧人的东航,有好几十年。据近人的考究:郑和当日的航路,实自南海入印度洋,达波斯湾及红海,且拂非洲的东北岸,其所至亦可谓远了。史家或说:成祖此举,是疑心建文帝亡匿海外,所以派人去寻求的。这话臆度而不中情实。建文帝即使亡匿海外,在当日的情势下,又何能为?试读《明史》的外国传,则见当太祖时,对于西域,使节所至即颇远。可见明初的外交,是有意沿袭元代的规模的。但是明朝立国的规模,和元朝不同。所以元亡明兴,西域人来者即渐少。又好勤远略,是和从前政治上的情势不相容的,所以虽有好大喜功之主,其事亦不能持久。从仁宗以后,就没有这种举动了。南方距中国远,该地方的货物,到中原即成为异物,价值很贵;又距离既远,为政府管束所不及,所以宦其地者率多贪污,这是历代如此的。明朝取安南后,还是如此。其时中官奉使的多,横暴太甚,安南屡次背叛。宣宗立,即弃之。此事在1427年,安南重隶中国的版图,不过二十二年而已。自郑和下南洋之后,中国对于南方的航行,更为熟悉,华人移殖海外的渐多。近代的南洋,华人实成为其地的主要民族,其发端实在此时。然此亦是社会自然的发展,得政治的助力很小。\n●朱元璋像 据传朱元璋长相极丑,又忌讳画师如实描绘,因此画师的画像各不相同,大都是似而非\n明代政治的败坏,实始于成祖时。其一为用刑的残酷,其二为宦官的专权,而两事亦互相依倚。太祖定制,内侍本不许读书。成祖反叛时,得内监为内应,始选官入内教习。又使在京营为监军,随诸将出镇。又设立东厂,使司侦缉之事。宦官之势骤盛。宣宗崩,英宗立,年幼,宠太监王振。其时瓦剌强,杀鞑靼酋长,又胁服兀良哈。1449年,其酋长也先入寇。王振贸然怂恿英宗亲征。至大同,知兵势不敌,还师。为敌军追及于土木堡,英宗北狩。朝臣徐有贞等主张迁都,于谦力主守御,奉英宗之弟景帝监国,旋即位。也先入寇,谦任总兵石亨等力战御之。也先攻京城,不能克,后屡寇边,又不得利,乃奉英宗归。大凡敌兵入寇,京城危急之时,迁都与否,要看情势而定。敌兵强,非坚守所能捍御,而中央政府,为一国政治的中心,失陷了,则全国的政治,一时要陷于混乱,则宜退守一可据的据点,徐图整顿。在这情势之下,误执古代国君死社稷之义,不肯迁都,是要误事的,崇祯的已事,是其殷鉴。若敌兵实不甚强,则坚守京城,可以振人心而作士气。一移动,一部分的国土,就要受敌兵蹂躏,损失多而事势亦扩大了。瓦剌在当日,形势实不甚强,所以于谦的主守,不能不谓之得计。然徐有贞因此内惭,石亨又以赏薄怨望,遂结内监曹吉祥等,乘景帝卧病,闯入宫中,迎英宗复辟,是为“夺门”之变。于谦被杀。英宗复辟后,亦无善政。传子宪宗,宠太监汪直。宪宗传孝宗,政治较称清明。孝宗传武宗,又宠太监刘瑾。这不能不说是成祖恶政的流毒了。明自中叶以后,又出了三个昏君。其一是武宗的荒淫,其二是世宗的昏聩,其三是神宗的怠荒,明事遂陷于不可收拾之局。武宗初宠刘瑾,后瑾伏诛,又宠大同游击江彬,导之出游北边。封于南昌的宁王宸濠,乘机作乱,为南赣巡抚王守仁所讨平,武宗又借以为名,出游江南而还。其时山东、畿南群盗大起,后来幸获敉平,只可算得侥幸。武宗无子,世宗以外藩入继。驭宦官颇严,内监的不敢恣肆,是无过于世宗时的。但其性质严而不明,中年又好神仙,日事斋醮,不问政事。严嵩因之,故激其怒,以入人罪,而窃握大权,政事遂至大坏。其时倭寇大起,沿海七省,无一不被其患,甚至沿江深入,直抵南京。北边自也先死后,瓦剌复衰,鞑靼部落,入据河套,谓之“套寇”。明朝迄无善策。至世宗时,成吉思汗后裔达延汗复兴,击败套寇,统一蒙古。达延汗四子,长子早死。达延汗自与其嫡孙卜赤徙牧近长城,称为插汉儿部,就是现在的察哈尔部。次子为套寇所杀。三子系征服套寇的。其有二子:一为今鄂尔多斯部之祖,亦早死。一为阿勒坦汗,《明史》称为俺荅,为土默特部之祖。第四子留居漠北,则为喀尔喀三部之祖(车臣,上谢图,札萨克图。其三音诺颜系清时增设)。自达延汗以后,蒙古遂成今日的形势了,所以达延汗亦可称为中兴蒙古的伟人。俺荅为边患,是最深的。世宗时,曾三次入犯京畿。有一次,京城外火光烛天,严嵩竟骗世宗,说是民家失火,其蒙蔽,亦可谓骇人听闻了。世宗崩,穆宗立,未久而死。神宗立,年幼,张居正为相。此为明朝中兴的一个好机会。当穆宗时,俺荅因其孙为中国所得,来降,受封为顺义王,不复为边患。插汉儿部强盛时,高拱为相,任李成梁守辽东,戚继光守蓟镇以敌之。成梁善战,继光善守,张居正相神宗,益推心任用此二人,东北边亦获安静。明朝政治,久苦因循。张居正则能行严肃的官僚政治。下一纸书,万里之外,无敢不奉行惟谨者,所以吏治大有起色。百孔千疮的财政,整理后亦见充实。惜乎居正为相,不过十年,死后神宗亲政,又复昏乱。他不视朝至于二十余年,群臣都结党相攻。其时无锡顾宪成,居东林书院讲学,喜欢议论时政,于是朝廷上的私党,和民间的清议,渐至纠结而不可分。神宗信任中官,使其到各省去开矿,名为开矿,实则藉此索诈。又在穷乡僻壤,设立税使,骚扰无所不至。日本丰臣秀吉犯朝鲜,明朝发大兵数十万以援之,相持凡七年,并不能却敌,到秀吉死,日本兵才自退。神宗死后,熹宗继之。信任宦官魏忠贤,其专横又为前此所未有。统计明朝之事,自武宗以后,即已大坏,而其中世宗、神宗,均在位甚久。武宗即位,在1506年,熹宗之死,在1627年,此一百二十二年之中,内忧外患,迭起交乘,明事已成不可收拾之局。思宗立,虽有志于振作,而已无能为力了。\n第三十章 明清的兴亡 # 文化是有传播的性质的,而其传播的路线,往往甚为纡曲。辽东、西自公元前4世纪,即成为中国的郡县,因其距中原较远,长驾远驭之力,有所不及,所以中国的政治势力,未能充分向北展拓,自吉林以东北,历代皆仅等诸羁縻。其地地质虽极肥沃,而稍苦寒;又北方扰攘时多,自河北经热河东北出之道,又往往为游牧民族所阻隔,所以中国民族,亦未能盛向东北拓殖。在这一个区域中,以松花江流域为最肥沃,其地距朝鲜甚近,中国的文化,乃从朝鲜绕了一个圈儿,以间接开化其地的女真民族。渤海、金、清的勃兴,都是如此。\n清朝的祖先,据其自己说,是什么天女所生的,这一望而知其为有意造作的神话。据近人所考证,明时女真之地,凡分三卫:曰海西卫,自今辽宁的西北境,延及吉林的西部。曰野人卫,地在吉、黑的东偏。曰建州卫,则在长白山附近。海西卫为清人所谓扈伦部,野人卫清人谓之东海部,建州卫则包括满洲长白山西部。清朝真正的祖先,所谓肇祖都督孟特穆,就是1412年受职为建州卫指挥使的猛哥帖木儿(明人所授指挥使,清人则称为都督。孟特穆为孟哥帖木儿异译),其初曾入贡受职于朝鲜的李朝的。后为七姓野人所杀。其时的建州卫,还在朝鲜会宁府河谷。弟凡察立,迁居佟家江。后猛哥帖木儿之子董山,出而与凡察争袭。明朝乃分建州为左右两卫,以董山为左卫指挥使,凡察为右卫指挥使。董山渐跋扈,明朝檄致广宁诛之。部下拥其子脱罗扰边(《清实录》作妥罗,为肇祖之孙。其弟曰锡宝斋篇古。锡宝斋篇古之子曰兴祖都督福满,即景祖之父),声称报仇,但未久即寂然。自此左卫衰而右卫盛。右卫酋长王杲,居宽甸附近。为李成梁所破,奔扈伦部的哈达(叶赫在吉林西南,明人称为北关。哈达在开原北,明人称为南关)。哈达执送成梁,成梁杀之。其子阿台,助叶赫攻哈达。满洲苏克苏浒部长尼堪外兰,为李成梁做向导,攻杀阿台。满洲酋长叫场,即清朝所谓景祖觉昌安,其子他失,则清朝所谓显祖塔克世,塔克世的儿子驽尔哈赤,就是清朝的太祖了。阿台系景祖孙婿,阿台败时,清景、显二祖亦死。清太祖仍受封于明,后来起兵攻破尼堪外兰。尼堪外兰逃奔明边。明朝非但不能保护,反把他执付清太祖。且开抚顺、清河、宽甸、叆阳四关,和它互市。自此满洲人得以沐浴中国的文化,且藉互市以润泽其经济,其势渐强。先服满洲诸部。扈伦、长白山诸部联合蒙古的科尔沁部来攻,清太祖败之,威声且达蒙古东部。又合叶赫灭哈达。至1616年,遂叛明。\n时值明神宗之世。以杨镐为经略,发大兵二十万,分四路东征,三路皆败。满洲遂陷铁岭,灭叶赫。明以熊廷弼为经略。延弼颇有才能,明顾旋罢之,代以袁应泰。应泰有吏才,无将略,辽、沈遂陷。清太祖初自今之长白县(清之兴京,其地本名赫图阿拉),迁居辽阳,后又迁居沈阳。明朝再起熊廷弼,又为广宁巡抚王化贞所掣肘。化贞兵败,辽西地多陷。明朝逮二人俱论死。旋得袁崇焕力守宁远。1626年,清太祖攻之,受伤而死。子太宗立,因朝鲜归心于明,屡犄满洲之后,太宗乃先把朝鲜征服了,还兵攻宁远、锦州,又大败。清人是时,正值方兴之势,自非一日可以削平,然其力亦并不能进取辽西。倘使明朝能任用如袁崇焕等人物,与之持久,辽东必可徐图恢复的,辽西更不必说了,若说要打进山海关,那简直是梦想。\n●西域图册·土尔扈特风情册(清)明福绘。纸本设色,每半开纵36.8厘米,横43.9厘米\n所谓流寇,是无一定的根据地,流窜到哪里,裹胁到哪里的。中国疆域广大,一部分的天灾人祸,影响不到全国,局部的动乱,势亦不能牵动全国,只有当社会极度不安时,才会酿成如火燎原之势,而明季便是其时了。明末的流寇,是以1628年起于陕西的,正值思宗的元年。旋流入山酉,又流入河北,蔓衍于四川、湖广之境。以李自成和张献忠为两个最大的首领。献忠系粗才,一味好杀,自成则颇有大略。清太宗既不得志于辽西,乃自喜峰口入长城,犯畿甸。袁崇焕闻之,亦兼程入援。两军相持,未分胜负。明思宗之为人,严而不明,果于诛杀。先是袁崇焕因皮岛守将毛文龙跋扈,将其诛戮(皮岛,今图作海洋岛),思宗疑之而未发。及是,遂信清人反间之计,把崇焕下狱杀掉,于是长城自坏。此事在1629年。至1640年,清人大举攻锦州。蓟辽总督洪承畴往援,战败,入松山固守。明年,松山陷,承畴降清。先是毛文龙死后,其将孔有德、耿仲明降清,引清兵攻陷广鹿岛(今图或作光禄岛),守将尚可喜亦降。清当太祖时,尚无意于入据中原,专发挥其仇视汉人的观念,得儒士辄杀,得平民则给满洲人为奴。太宗始变计抚用汉人,尤其优待一班降将。洪承畴等遂不恤背弃祖国,为之效力。于是政治军事的形势,又渐变了。但明兵坚守了山海关,清兵还无力攻陷。虽然屡次绕道长城各口,蹂躏畿甸,南及山东,毕竟不敢久留,不过明朝剿流寇的兵,时被其牵制而已。1643年,李自成陷西安。明年,在其地称帝。东陷太原,分兵出真定(今河北正定县),自陷大同、宣府,入居庸关。北京不守,思宗殉国于煤山。山海关守将吴三桂入援,至丰润,京城已陷。自成招三桂降,三桂业经允许了。旋闻爱妾陈沅被掠,大怒,遂走关外降清。“痛哭六军皆缟素,冲冠一怒为红颜”,民族战争时唯一重要的据点,竟因此兵不血刃而失陷,武人不知礼义的危险,真令人言之而色变了。\n时清太宗已死,子世祖继立,年幼,叔父睿亲王多尔衮摄政,正在关外略地,闻三桂来降,大喜,疾趋受之。李自成战败,奔回陕西,清人遂移都北京。明人立神宗之孙福王由崧于南京,是为弘光帝。清人这时候,原只望占据北京,并不敢想全吞中国,所以五月三日入京,四日下令强迫人民剃发,到二十四日,即又将此令取消。而其传檄南方,亦说“明朝嫡胤无遗,用移大清,宅此北土,其不忘明室,辅立贤藩,戮力同心,共保江左,理亦宜然,予所不禁”。但弘光帝之立,是靠着凤阳总督马士英的兵力做背景的。士英遂引阉党阮大铖入阁,排去史可法。弘光帝又荒淫无度。清朝乃先定河南、山东。又分兵两道入关,李自成走死湖北。清人即移兵以攻江南。明朝诸将,心力不齐,史可法殉国于扬州,南京不守,弘光帝遂北狩,时在1645年。清朝既定江南,乃下令强迫人民剃发。当时有“留头不留发,留发不留头”之谚,其执行的严厉可想。此举是所以摧挫中国的民气的,其用意极为深刻酷毒。缘中国地大而人众,政治向主放任,人民和当地的政府,关系已浅,和中央政府,则几于毫无直接关系,所以朝代的移易,往往刺激不动人民的感情。至于衣服装饰,虽然看似无关紧要,然而习俗相沿,就是一种文化的表征,用兵力侵略的异族,强使故有的民族,弃其旧有的服饰而仿效自己,就不啻摧毁其文化,而且强替其加上一种屈服的标识。这无怪当日的人民,要奋起而反抗了。但是人民无组织已久了,临时的集合,如何能敌得久经征战的军队?所以当日的江南民兵,大都不久即败。南都亡后,明之遗臣,或奉鲁王以海监国绍兴,或奉唐王聿键正位福州,是为隆武帝。清人遣吴三桂陷四川,张献忠败死。别一军下江南,鲁王败走舟山。清兵遂入福建,隆武帝亦殉国,时为1647年。\n西南之地,向来和大局是无甚关系的,龙拏虎攫,总在黄河、长江两流域。到明季,情形却又不同了。长江以南,以湘江流域开辟为最早。汉时杂居诸异族,即已大略同化。其资、沅、澧三水流域,则是隋、唐、北宋之世,逐渐开辟的。1413年,当明成祖之世,贵州之地,始列为布政司。其后水西的安氏,水东的宋氏,播州的杨氏(水西、水东,系分辖贵阳附近新土司的。播州,今遵义县),亦屡烦兵力,然后戡定。而广西桂林的古田,平乐的府江,浔州的大藤峡,梧州的岑溪,明朝亦费掉很大的兵力。云南地方,自唐时,大理独立为国,到元朝才把它灭掉。其时云南的学校,还不知崇祀孔子,而崇祀晋朝的王羲之,货币则所用的是海。全省大都用土官,就正印是流官的,亦必以土官为之副。但自元朝创立土司制度以来,而我族所以管理西南诸族的,又进一步。其制:异族酋长归顺的,我都授以某某司的名目,如宣慰司、招讨司之类,此之谓土司。有反叛、虐民,或自相攻击的,则用政治手腕或兵力戡定,改派中国人治理其地,此之谓改土归流。明朝一朝,西南诸省,逐渐改流的不少,政治势力和人民的拓殖,都大有进步。所以到明末,已可用为抗敌的根据地。隆武帝亡后,明人立其弟聿于广州,旋为叛将李成栋所破。神宗之孙桂王由榔即位肇庆,是为永历帝,亦为成栋所迫,退至桂林。清又使降将孔有德、尚可喜、耿仲明下湖南,金声桓下江西。声桓、成栋旋反正。明兵乘机复湖南,川南、川东亦来归附。桂王一时曾有两广、云、贵、江西、湖南、四川七省之地,然声桓、成栋都系反复之徒,并无能力,不久即败,湖南亦复失。清兵且进陷桂林,永历帝逃到南宁,遣使封张献忠的余党孙可望为秦王。可望虽不过流寇,然其军队久经战阵,战斗力毕竟要强些。可望乃使其党刘文秀攻四川,吴三桂败走汉中。李定国攻桂林,孔有德伏诛。清朝乃派洪承畴守长沙,尚可喜守广东,又派兵驻扎保宁,以守川北,无意于进取了。而永历帝因可望跋扈,密召李定国,可望攻定国,大败,复降清。洪承畴因之请大举。1658年,清兵分三道入滇。定国扼北盘江力战,不能敌,乃奉永历帝走腾越,而伏精兵,大败清之追兵于高黎贡山。清兵乃还。定国旋奉永历帝入缅甸。1661年,吴三桂发大兵十万出边,缅甸人乃奉永历帝入三桂军。明年,被弑,明亡。当永历帝入缅时,刘文秀已前卒。定国和其党白文选崎岖缅甸,欲图恢复,卒皆赍志以终。定国等虽初为寇盗,而其晚节能效忠于国家、民族如此,真可使洪承畴、吴三桂等一班人愧死了。\n汉族在大陆上虽已无根据地,然天南片土,还有保存着上国的衣冠的,是为郑成功。郑成功为郑芝龙的儿子。芝龙本系海盗,受明招安的。清兵入闽时,芝龙阴行通款,以致隆武帝败亡。成功却不肯叛国,退据厦门,练兵造船为兴复之计。鲁王被清兵所袭,失去舟山,也是到厦门去依靠他的。清兵入滇时,成功曾大举入江,直迫江宁。后从荷兰人之手,夺取台湾,务农、训兵、定法律、设学校,俨然独立国的规模。清朝平定西南,本来全靠降将之力,所以事定之后,清朝并不能直接统治,乃封尚可喜于广东,耿仲明之子继茂于福建,吴三桂于云南,是为三藩。三藩中,吴三桂功最高,兵亦最强。1673年,尚可喜因年老,将兵事交给其儿子之信,反为所制,请求撤藩,清人许之。三桂和耿继茂的儿子耿精忠不自安,亦请撤藩,以觇朝意。时清世祖已死,子圣祖在位,年少气盛,独断许之,三桂遂叛清。耿、尚二藩亦相继举兵。清朝在西南,本无实力,三桂一举兵,而贵州、湖南、四川、广西俱下。但三桂暮气不振,既不能弃滇北上,想自出应援陕西响应的兵,又不及,徒据湖南,和清兵相持;耿、尚二藩,本来是反复无常的,此时苦三桂征饷,又叛降清,三桂兵势遂日蹙。1678年,三桂称帝于衡州。旋死,诸将乖离,其孙世璠,遂于1681年为清人所灭。清平定西南,已经出于意外了,如何再有余力,觊觎东南海外之地?所以清朝是时,已有和郑氏言和,听其不剃发,不易衣冠之意。但又有降将作祟。先是郑成功以1662年卒,子经袭,初和耿氏相攻,曾略得漳、泉之地。后并失厦门,退归台湾。其将施琅降清,清人用为提督。1681年,郑经卒,内部乖离。1683年,施琅渡海入台湾,郑氏亡。\n第三十一章 清代的盛衰 # 清朝的猾夏,是远较辽、金、元为甚的。这是因为女真民族,在渤海和金朝时,业已经过两度的开化,所以清朝初兴时,较诸辽、金、元,其程度已觉稍高了。当太宗时,已能任用汉人,且能译读《金世宗本纪》,戒谕臣下,勿得沾染华风。入关之后,圈占民地,给旗人住居,这也和金朝将猛安谋克户迁入中原,是一样的政策。他又命旗兵驻防各省,但多和汉人分城而居,一以免其倚势欺凌,挑起汉人的恶感,一亦防其与汉人同化。其尤较金人为刻毒的,则为把关东三省都封锁起来,禁止汉人移殖。他又和蒙古人结婚姻,而且表面上装作信奉喇嘛教,以联络蒙古的感情,而把蒙古也封锁起来,不许汉人移殖,这可称之为“联蒙制汉”政策。他的对待汉人,为前代异族所不敢行的,则为明目张胆,摧折汉人的民族性。从来开国的君主,对于前代的叛臣,投降自己的,虽明知其为不忠不义之徒,然大抵把这一层抹杀不提,甚且还用些能知天命,志在救民等好看的话头,替他掩饰,这个可说是替降顺自己的人,留些面子。清朝则不然,对于投顺它的人,特立贰臣的名目,把他的假面具都剥光了。康、雍、乾三朝,大兴文字之狱,以摧挫士气。乾隆时开四库馆,编辑四库全书,却借此大烧其书。从公元1763到1782年二十年之中,共烧书二十四次,被烧掉的书有五百三十八种,一万三千八百六十二部之多。不但关涉清朝的,即和辽、金、元等有关涉的,亦莫不加以毁灭。其不能毁灭的,则加以改窜。他岂不知一手不能掩尽天下目?他所造作的东西,并不能使人相信?此等行为,更不能使人心服?不过肆其狠毒之气,一意孤行罢了。他又开博学鸿词科,设明史馆,以冀网罗明季的遗民。然被其招致的,全是二等以下的人物,真正有志节的,并没有入他彀中的啊!\n从前的人民,对于政权,实在疏隔得太厉害了。所以当异族侵入的时候,民心虽然不服,也只得隐忍以待时,清初又是这时候了。从1683年台湾郑氏灭亡起,到1793年白莲教徒起兵和清朝反抗为止,凡一百一十年,海内可说无大兵革。清圣祖的为人,颇为聪明,也颇能勤于政治;就世宗也还精明。他们是一个新兴的野蛮民族,其骄奢淫逸,比之历年已久的皇室,自然要好些。一切弊政,以明末为鉴,自然也有相当的改良。所以康、雍之世,政治还算清明,财政亦颇有余蓄。到乾隆时,虽然政治业已腐败,社会的元气,亦已暗中凋耗了,然表面上却还维持着一个盛况。\n武功是时会之适然。中国的国情,是不适宜于向外侵略的。所以自统一以后,除秦、汉两朝,袭战国之余风,君主有好大喜功的性质,社会上亦有一部分人,喜欢立功绝域外,其余都是守御之师。不过因为国力的充裕,所以只要(一)在我的政治相当清明,(二)在外又无方张的强敌,即足以因利乘便,威行万里。历代的武功,多是此种性质,而清朝亦又逢着这种幸运了。蒙古和西藏的民族,其先都是喜欢侵略的。自唐中叶后,喇嘛教输入吐蕃,而西藏人的性质遂渐变。明末,俺荅的两个儿子侵入青海,其结果,转为青海地方的喇嘛教所感化,喇嘛教因此推行于蒙古,连蒙古人的性质,也渐趋向平和,这可说是近数百年来塞外情形的一个大转变。在清代,塞外的侵略民族,只剩得一个卫拉特了。而其部落较小,侵略的力量不足,卒为清人所摧破。这是清朝人的武功,所以能够煊赫一时的大原因。卫拉特即明代的瓦剌。当土木之变时,其根据地,本在东方。自蒙古复强,它即渐徙而西北。到清时,共分为四部:曰和硕特,居乌鲁木齐。曰准噶尔,居伊犁。曰杜尔伯特,居额尔齐斯河。曰土尔扈特,居塔尔巴哈台。西藏黄教的僧侣,是不许娶妻的。所以其高僧,世世以“呼毕勒罕”主持教务。因西藏人信之甚笃,教权在名义上,遂出于政权之上。然所谓迷信,其实不过是这么一句话。从古以来,所谓神权政府,都是建立在大多数被麻醉的人信仰之上的,然教中的首领,其实并不迷信,试看其争权夺利,一切都和非神权的政府无异可知。达赖喇嘛,是黄教之主宗喀巴的第一个大弟子,他在喇嘛教里,位置算是最高,然并不能亲理政务,政务都在一个称为“第巴”的官的手里。清圣祖时,第巴桑结,招和硕特的固始汗入藏,击杀了红教的护法藏巴汗,而奉宗喀巴的第二大弟子班禅入居札什伦布,是为达赖、班禅分主前后藏之始。和硕特自此徙牧青海,干涉西藏政权,桑结又恶之,招致准噶尔噶尔丹入藏,击杀了固始汗的儿子达颜汗。准噶尔先已慑服杜尔伯特,逐去土尔扈特,至此其势大张。1688年,越阿尔泰山攻击喀尔喀,三汗部众数十万,同时溃走漠南。清圣祖为之出兵击破噶尔丹。噶尔丹因伊犁旧地,为其兄子策妄阿布坦所据无所归,自杀。阿尔泰山以东平。固始汗的曾孙拉藏汗杀掉桑结。策妄阿布坦派兵入藏,袭杀拉藏汗。圣祖又派兵将其击破。1722年,圣祖死,世宗立。固始汗之孙罗卜藏丹津煽动青海的喇嘛反叛,亦为清兵所破。此时卫拉特的乱势,可谓蔓延甚广,幸皆未获逞志,然清朝亦未能犁庭扫穴。直至1754年,策妄阿布坦之子噶尔丹策凌死,其部落内乱,清高宗才于1757年将其荡平。至于天山南路,则本系元朝察哈尔后王之地,为回教区域。元衰后,回教教主的后裔,有入居喀什噶尔的,后遂握有南路政教之权。准部既平,教主的后裔大小和卓木(大和卓木名布罗尼特,小和卓木名霍集占)和清朝反抗,亦于1759年为清所破灭。清朝的武功,以此时为极盛。天山南北路既定,葱岭以西之国,敖罕、哈萨克、布鲁特、乾竺特、博罗尔、巴达克山、布哈尔、阿富汗等,都朝贡于清,仿佛唐朝盛时的规模。1792年,清朝又用兵于廓尔喀,将其征服,则其兵力又为唐时所未至。对于西南一隅,则清朝的武功,是掩耳盗铃的。当明初,中国西南的疆域,实还包括今伊洛瓦底江流域和萨尔温、眉公两江上游(看《明史·西南土司传》可知)。但中国对于西南,实力并不充足,所以安南暂合而复离,而缅甸亦卒独立为国。中国实力所及,西不过腾冲,南不越普洱,遂成为今日的境界了。1767年,清高宗因缅甸犯边,发兵征之败没。1769年,又派大兵再举,亦仅因其请和,许之而还。这时候,暹罗为缅甸所灭。后其遗臣中国人郑昭,起兵复国,传其养子郑华,以1786年受封于中国,缅甸怕中国和暹罗夹攻它,对中国才渐恭顺。\n安南之王黎氏,明中叶后为其臣莫氏所篡。清初复国。颇得其臣阮氏之力,而其臣郑氏,以国戚执政,阮氏与之不协,乃南据顺化,形同独立。后为西贡豪族阮氏所灭,是为新阮,而顺化之阮氏,则称旧阮。新阮既灭旧阮,又入东京灭郑氏,并废黎氏。黎氏遗臣告难中国,高宗于1788年为之出兵,击破新阮,复立黎氏。然旋为新阮所袭败,乃因新阮的请降,封之为王。总而言之,中国用兵于后印度,天时地利,是不甚相宜的,所以历代都无大功,到清朝还是如此。清朝用兵域外,虽不得利,然其在湘西、云、贵、四川各省,则颇能竟前代所未竟之功。在今湖南、贵州间,则开辟永顺、乾州、凤皇、永绥、松桃各府、厅,在云南,则将乌蒙、乌撒、东川、镇雄各土官改流(乌蒙,今云南昭通县。乌撒,今贵州威宁县),在贵州,则平定以古州为中心的大苗疆(古州,今榕江县),这都是明朝未竟的余绪。四川西北的大小金川(大金川,今理番县的绥靖屯。小金川,今懋功县),用兵凡五年,糜饷至七千万,可谓劳费已甚,然综合全局看起来,则于西南的开拓,仍有裨益。\n●乾隆像\n清朝的衰机,可说是起于乾隆之世的。高宗性本奢侈,在位时六次南巡,耗费无艺。中岁后又任用和珅,贪渎为古今所无。官吏都不得不剥民以奉之,上司诛求于下属,下属虐取于人民,于是吏治大坏。清朝历代的皇帝,都是颇能自握魁柄,不肯授权于臣下的。它以异族入主中原,汉族真有大志的人,本来未必帮它的忙。加以其予智自雄,折辱大臣,摧挫言路,抑压士气,自然愈形孤立了。所以到乾、嘉之间,而局面遂一变。\n第三十二章 中西初期的交涉 # 世界是无一息不变的,人,因其感觉迟钝,或虽有感觉而行为濡滞之故,非到外界变动,积微成著,使其感觉困难时,不肯加以理会,设法应付,正和我们住的屋子,非到除夕不肯加以扫除,以致尘埃堆积,扫除时不得不大费其力一样。这话,在绪论中,业已说过了。中国自有信史以来,环境可说未曾大变。北方的游牧民族,凭恃武力,侵入我国的疆域之内是有的,但因其文化较落后,并不能改变我们的生活方式,而且它还不得不弃其生活方式而从我,所以经过若干年之后,即为我们所同化。当其未被同化之时,因其人数甚少,其暴横和掠夺,也是有一个限度的,而且为时不能甚久。所以我们未曾认为是极大的问题,而根本改变我们的生活方式以应之。至于外国的文明,输入中国的,亦非无有。其中最亲切的,自然是印度的宗教。次之则是古希腊文明,播布于东方的,从中国陆路和西域交通,海路和西南洋交通以后,即有输入。其后大食的文明,输入中国的亦不少。但宗教究竟是上层建筑,生活的基础不变,说一种宗教,对于全社会真会有什么大影响,是不确的。所以佛教输入中国之后,并未能使中国人的生活印度化,反而佛教的本身,倒起了变化,以适应我们的生活了。读第五十四章所述可见。其余的文明,无论其为物质的、精神的,对社会上所生的影响,更其“其细已甚”。所以中国虽然不断和外界接触,而其所受的外来的影响甚微。至近代欧西的文明,乃能改变生活的基础,而使我们的生活方式,不得不彻底起一个变化,我们应付的困难,就从此开始了。但前途放大光明、得大幸福的希望,亦即寄托在这个大变化上。\n西人的东来,有海、陆两路。而海路又分两路:一、自大西洋向东行,于公元1516年绕过好望角,自此而至南洋、印度及中国。二、自大西洋向西行,于1492年发现美洲,1519年环绕地球,其事都在明武宗之世。初期在海上占势力的是西、葡,后来英、荷继起,势力反驾乎其上。但其在中国,因葡萄牙人独占了澳门之故,势力仍能凌驾各国,这是明末的情形。清初,因与荷兰人有夹攻台湾郑氏之约,许其商船八年一到广东,然其势力,亦远非葡萄牙之敌。我们试将较旧的书翻阅,说及当时所谓洋务时,总是把“通商传教”四字并举的。的确,我们初期和西洋人的接触,不外乎这两件事。通商本两利之道,但这时候的输出入品,还带有奢侈性质,并非全国人所必需,而近世西人的东来,我们却自始对他存着畏忌的心理。这是为什么呢?其一、中国在军事上,是畏恶海盗的。因为从前的航海之术不精,对海盗不易倾覆其根据地,甚而至于不能发现其根据地。二、中国虽发明火药,却未能制成近世的枪炮。近世的枪炮,实在是西人制成的,而其船舶亦较我们的船舶为高大,军事上有不敌之势。三、西人东来的,自然都是些冒险家,不免有暴横的行为。而因传教,更增加了中国畏忌的心理。近代基督教的传布于东方,是由耶稣会(Jesuit)开始的。其教徒利玛窦(Matteo Ricci),以1581年始至澳门,时为明神宗万历五年。后入北京朝献,神宗许其建立天主堂。当时基督教士的传教,是以科学为先驱;而且顺从中国的风俗,不禁华人祭天、祭祖、崇拜孔子的。于是在中国的反应,发生两派:其一如徐光启、李之藻等,服膺其科学,因而亦信仰其宗教。其二则如清初的杨光先等,正因其人学艺之精,传教的热烈,而格外引起其猜忌之心。在当时,科学的价值,不易为一般人所认识,后一派的见解,自然容易得势。但是输入外国的文明,在中国亦由来已久了。在当时,即以历法疏舛,旧有的回回历法,不如西洋历法之精,已足使中国人引用教士,何况和满洲人战争甚烈,需要教士制造枪炮呢?所以1616年,基督教一度被禁止传播后,到1621年,即因命教士制造枪炮而复解禁。后更引用其人于历局。清初,汤若望(Joannes Adams Schall Von Bell)亦因历法而被任用。圣祖初年,为杨光先所攻击,一时失势。其后卒因旧法的疏舛,而南怀仁(Ferdinandus Verbiest)复见任用。圣祖是颇有科学上的兴趣的,在位时引用教士颇多,然他对于西洋人,根本上仍存着一种畏恶的心理。所以在他御制的文集里,曾说“西洋各国,千百年后,中国必受其累”。这在当时的情势下,亦是无怪其然的。在中国一方面,本有这种心理潜伏着,而在西方,适又有别一派教士,攻击利玛窦一派于教皇,说他们卖教求荣,容许中国的教徒崇拜偶像。于是教皇派多罗(Tourmon)到中国来禁止。这在当时的中国,如何能说得明白?于是圣祖大怒,将多罗押还澳门,令葡萄牙人看管,而令教士不守利玛窦遗法的都退出(教皇仍不变其主张,且处不从令的教士以破门之罚。教士传教中国者,遂不复能顺从中国人的习惯,此亦为中西隔阂之一因)。至1717年,碣石镇总兵陈昂说:“天主教在各省开堂聚众,广州城内外尤多,恐滋事端,请严旧例严禁”,许之。1723年,闽浙总督满保请除送京效力人员外,概行安置澳门;各省天主堂,一律改为公廨,亦许之。基督教自此遂被禁止传布,然其徒之秘密传布如故。中国社会上,本有一种所谓邪教,其内容仅得之于传说,是十分离奇的(以此观之,知历来所谓邪教者的传说,亦必多诬蔑之辞),至此,遂将其都附会到基督教身上去;再加以后来战败的耻辱,因战败而准许传教,有以兵力强迫传布的嫌疑,遂伏下了几十年教案之根。至于通商,在当时从政治上看起来,并没有维持的必要。既有畏恶外人的心理,就禁绝了,也未为不可的。但这是从推理上立说,事实上,一件事情的措置,总是受有实力的人的意见支配的。当时的通商,虽于国计民生无大关系,而在官和商,则都是大利之所在,如何肯禁止?既以其为私利所在而保存之,自然对于外人,不肯不剥削,就伏下了后来五口通商的祸根。海路的交通,在初期,不过是通商传教的关系,至陆路则自始即有政治关系。北方的侵略者,乃蒙古高原的民族,而非西伯利亚的民族,这是几千年以来,历史上持续不变的形势。但到近代欧洲的势力向外发展时,其情形也就变了。15世纪末叶,俄人脱离蒙古的羁绊而自立。其时可萨克族又附俄(Kazak,即哥萨克),为之东略。于是西伯利亚的广土,次第被占。至明末,遂达鄂霍次克海,骚扰且及于黑龙江。清初因国内未平,无暇顾及外攘,至三藩既平,圣祖乃对外用兵。其结果,乃有1688年的《尼布楚条约》,订定西以额尔古讷河,东自格尔必齐河以东,以外兴安岭为界,俄商得三年一至京师。此约中国得地极广,然俄人认为系用兵力迫胁而成,心怀不服,而中国对边陲,又不能实力经营,遂伏下咸丰时戊午、庚申两约的祸根。当《尼布楚条约》签订时,中、俄的边界问题,还只限于东北方面。其后外蒙古归降中国(前此外蒙古对清,虽曾通商,实仅羁縻而已),于是俄、蒙的界务,亦成为中、俄的界务。乃有1727年的《恰克图条约》,规定额尔古讷河以西的边界,至沙宾达巴哈为止。自此以西,仍属未定之界。至1755、1759两年,中国次第平定准部、回部,西北和俄国接界处尤多,其界线问题,亦延至咸丰时方才解决。\n●鸦片战争过程图\n近代欧人的到广东来求通商,事在1516年,下距五口通商时,业经三百余年了。但在五口通商以前,中国讫未觉得其处于另一个不同的世界中,还是一守其闭关独立之旧。清开海禁,事在1685年,于澳门、漳州、宁波、云台山设关四处。其后宁波的通商,移于定海,而贸易最盛于广东。当时在中国方面,贸易之权,操于公行之手,剥削外人颇深。外人心抱不平,乃舍粤而趋浙。1758年,清高宗又命把浙海关封闭,驱归广东,于是外人之不平更甚。英国曾于1792、1810年两次派遣使臣到中国,要求改良通商办法,均未获结果。其时中国官吏并不能管理外人,把其事都交给公行。官吏和外人的交涉,一切都系间接。自1781年以后,英国在中国的贸易,为东印度公司所专。其代理人,中国谓之大班,一切交涉,都是和他办的。1834年,公司的专利权被废止。中国说散商不便制驭,传令其再派大班。英人先后派商务监督和领事前来,中国都仍认为是大班,官厅不肯和他平等交涉。适会鸦片输入太甚,因输出入不相抵,银之输出甚多。银在清朝是用为货币的,银荒既甚,财政首受其影响。遂有1839年林则徐的烧烟,中、英因此酿成战衅,其结果,于1842年在南京订立条约。中国割香港,开广州、厦门、福州、宁波、上海五口通商。废除行商。中、英两国官员,规定了交际礼节。于是前此以天朝自居,英国人在陆上无根据地,及贸易上的制限都除去了。英约定后,法、美、瑞典,遂亦相继和中国立约。惟俄人仍不许在海口通商。中西积久的隔阂,自非用兵力迫胁,可以解除于一时。于是又有1857年的冲突。广州失陷,延及京、津。清文宗为之出奔热河。其结果,乃有1858年和1860年《天津》、《北京》两条约。此即所谓咸丰戊午、庚申之役。此两次的英、法条约,系将五口通商以后外人所得的权利,作一个总结束的。领事裁判,关税协定,内地通商及游历、传教,外国派遣使臣,都在此两约中规定。美国的《天津条约》,虽在平和中交换,然因各约都有最惠国条款,所以英、法所享的权利,美国亦不烦一兵而得享之。至于俄国,则自19世纪以还,渐以实力经营东方。至1850年顷,黑龙江北之地,实际殆已尽为所据。至1858年,遂迫胁黑龙江将军奕山,订立《瑷珲条约》,尽割黑龙江以北,而将乌苏里江以东之地,作为两国共管。1860年,又藉口调停英、法战事,再立《北京条约》,并割乌苏里江以东。而西北边界,应当如何分划,亦在此约中规定了一个大概。先是伊犁和塔尔巴哈台方面,已许俄国通商,至是再开喀什噶尔,而海口通商及传教之权,亦与各国一律。而且规定俄人得由恰克图经库伦、张家口进京。京城和恰克图间的公文,得由台站行走。于是蒙古、新疆的门户,亦洞开了。总而言之:自1838年林则徐被派到广东查办海口事件起,至1860年各国订立《北京条约》为止,中国初期与外国交涉的问题,告一结束。其所涉及的,为:(一)西人得在海口通商,(二)赴内地通商、游历、传教,(三)税则,(四)审判,(五)沿海航行,(六)中俄陆路通商,及(七)边界等问题。\n第三十三章 汉族的光复运动 # 一个民族,进步到达于某一程度之后,就决不会自忘其为一个独立的民族了。虽然进化的路径,是曲线的,有时不免暂为它族所压服。公元1729,即清世宗的雍正七年,曾有过这样一道上谕。他说:“从前康熙年间,各处奸徒窃发,辄以朱三太子为名,如一念和尚、朱一贵者,指不胜屈。近日尚有山东人张玉,假称朱姓,托于明之后裔,遇星士推算有帝王之命,以此希冀蛊惑愚民,现被步军统领拿获究问。从来异姓先后继统,前朝之宗姓,臣服于后代者甚多,否则隐匿姓名,伏处草野,从未有如本朝奸民,假称朱姓,摇惑人心,若此之众者。似此蔓延不息,则中国人君之子孙,遇继统之君,必至于无噍类而后已,岂非奸民迫之使然乎?”这一道上谕,是因曾静之事而发的。曾静是湖南人,读浙江吕留良之书,受着感动,使其徒张熙往说岳钟琪叛清,钟琪将其事举发。吕留良其时已死,因此遭到了剖棺戮尸之祸。曾静、张熙暂时免死拘禁,后亦被杀。这件事,向来被列为清朝的文字狱之一,其实乃是汉族图谋光复的实际行动,非徒文字狱而已。1729年,为亡清入关后之八十六年,表面上业已太平,而据清世宗上谕所说,则革命行动的连续不绝如此,可见一部分怀抱民族主义的人,始终未曾屈服了。怀抱民族主义的人,是中下流社会中都有的。中流社会中人的长处,在其知识较高,行动较有方策,且能把正确的历史知识,留传到后代,但直接行动的力量较弱。下流社会中人,直接行动的力量较强,但其人智识缺乏,行动起来,往往没有适当的方策,所以有时易陷于失败,甚至连正确的历史,都弄得缪悠了。清朝最大的会党,在北为哥老会,在南为天地会,其传说大致相同。天地会亦称三合会,有人说就是三点会,南方的清水、匕首、双刀等会,皆其支派。据它们的传说:福建莆田县九连山中,有一个少林寺。僧徒都有武艺,曾为清征服西鲁国,后为奸臣所谗,清主派兵去把他们剿灭。四面密布火种,缘夜举火,想把他们尽行烧死。有一位神道,唤做达尊,使其使者朱开、朱光,把十八个和尚引导出来。这十八个和尚,且战且走,十三个战死了。剩下来的五个,就是所谓前五祖。又得五勇士和后五祖为辅,矢志反复汨。就是清字,汨就是明字,乃会中所用的秘密符号。他们自称为洪家。把洪字拆开来则是三八二十一,他们亦即用为符号。洪字大约是用的明太祖开国的年号洪武;或者洪与红同音,红与朱同色,寓的明朝国姓的意思,亦未可知。据他们的传说:他们会的成立,在1674年。曾奉明思宗之裔举兵而无成,乃散而广结徒党,以图后举。此事见于日本平山周所著的《中国秘密社会史》(平山周为中山先生的革命同志,曾身入秘密社会,加以调查)。据他说:“后来三合会党的举事,连续不绝。其最著者,如1787,即清高宗乾隆五十二年台湾林爽文之变便是。1832,即宣宗道光十二年,两广、湖南的瑶乱,亦有三合会党在内。鸦片战争既起,三合会党尚有和海峡殖民地的政府接洽,图谋颠覆清朝的。”其反清复明之志,可谓终始不渝了。而北方的白莲教徒的反清,起于1793年,即乾隆五十八年,蔓延四川、湖北、河南、陕西四省,至1804年,即仁宗嘉庆九年而后平定,此即向来的史家称为川楚教匪,为清朝最大的内乱之始的,其所奉的王发生,亦诈称明朝后裔,可见北方的会党,反清复明之志,亦未尝变。后来到1813年,即嘉庆十八年,又有天理教首林清,图谋在京城中举事,至于内监亦为其内应,可见其势力之大。天理教亦白莲教的支派余裔,又可见反清复明之志,各党各派,殊途同归了。而其明目张胆,首传讨胡之檄的则为太平天国。\n太平天国天王洪秀全,系广东花县人,生于1812年,恰在民国纪元之前百年。结合下流社会,有时是不能不利用宗教做工具的。广东和外人交通早,所以天王所创的宗教,亦含有西教的意味。他称耶和华为天父,基督为天兄,而己为其弟。乘广西年饥盗起,地方上有身家的人所办的团练,和贫苦的客民冲突,以1850年,起事于桂平的金田村。明年,下永安,始建国号。又明年,自湖南出湖北,沿江东下。1853年,遂破江宁,建都其地,称为天京。当天国在永安时,有人劝其北出汉中,以图关中;及抵武、汉时,又有人劝其全军北上,天王都未能用。既据江宁,耽于声色货利,不免渐流于腐败。天王之为人,似只长于布教,而短于政治和军事。委政于东王杨秀清,尤骄恣非大器。始起诸王,遂至互相残杀。其北上之军,既因孤行无援,而为清人所消灭。溯江西上之兵,虽再据武、汉,然较有才能的石达开,亦因天京的政治混乱,而和中央脱离了关系。清朝却得曾国藩,训练湘军,以为新兴武力的中坚。后又得李鸿章,招募淮军,以为之辅。天国徒恃一后起之秀的李秀成,只身支柱其间,而其余的政治军事,一切都不能和他配合。虽然兵锋所至达十七省(内地十八省中,惟甘肃未到),前后共历十五年,也不得不陷于灭亡的悲运了。太平天国的失败,其责实不在于军事而在于政治。它的兵力,是够剽悍的。其扎实垒、打死仗的精神,似较之湘、淮军少逊,此乃政治不能与之配合之故,而不能悉归咎于军事。若再推究得深些,则其失败,亦可以说是在文化上。一、社会革命和政治革命,很不容易同时并行,而社会革命,尤其对社会组织,前因后果,要有深切的认识,断非头脑简单,手段灭裂的均贫富主义所能有济。中国的下流社会中人,是向来有均贫富的思想的,其宗旨虽然不错,其方策则决不能行。今观太平天国所定的把天下田亩,按口均分;二十五家立一国库,婚丧等费用,都取给国库,私用有余,亦须缴入国库等,全是极简单的思想,极灭裂的手段。知识浅陋如此,安能应付一切复杂的问题?其政治的不免于紊乱,自是势所必然了。二、满洲人入据中原,固然是中国人所反对,而是时西人对中国,开始用兵力压迫,亦为中国人所深恶的,尤其是传教一端,太平天国初起时,即发布讨胡之檄。“忍令上国衣冠,沦于夷狄?相率中原豪杰,还我河山”,读之亦使人气足神王。倘使他们有知识,知道外力的压迫,由于满清的失政,郑重提出这一点,固能得大多数人的赞成;即使专提讨胡,亦必能得一部分人的拥护。而他们后来,对此也模糊了,反而到处传播其不中不西的上帝教,使反对西教的士大夫,认他为文化上的大敌,反而走集于清朝的旗帜之下。这是太平天国替清朝做了掩蔽,而反以革命的对象自居,其不能成事,实无怪其然了。湘、淮军诸将,亦是一时人杰。并无一定要效忠于满清的理由,他们的甘为异族作伥,实在是太平天国的举动,不能招致豪杰,而反为渊殴鱼。所以我说它政治上的失败,还是文化上的落后。\n和太平天国同时的,北方又有捻党,本蔓延于苏、皖、鲁、豫四省之间。1864年,天国亡,余众多合于捻,而其声势乃大盛。分为东西两股。清朝任左宗棠、李鸿章以攻之。至1867、1868两年,然后先后平定。天国兵锋,侧重南方,到捻党起,则黄河流域各省,亦无不大被兵灾了,而回乱又起于西南,而延及西北。云南的回乱,起于1855年,至1872年而始平,前后共历十八年。西北回乱,则起于1862年,自陕西延及甘肃,并延及新疆。浩罕人借兵给和卓木的后裔,入据喀什喀尔。后浩罕之将阿古柏帕夏杀和卓木后裔而自立,意图在英、俄之间,建立一个独立国。英、俄都和他订结通商条约,且曾通使土耳其。英使且力为之请,欲清人以天山南北路之地封之。清人亦有以用兵劳费,持是议者。幸左宗棠力持不可。西捻既平之后,即出兵以攻叛回。自1875至1878,前后共历四年,而南北两路都平定。阿古柏帕夏自杀。当回乱时,俄人虽乘机占据伊犁,然事定之后,亦获返还。虽然划界时受损不少,西北疆域,大体总算得以保全。\n清朝的衰机,是潜伏于高宗,暴露于仁宗,而大溃于宣宗、文宗之世的。当是时,外有五口通商和咸丰戊午、庚申之役,内则有太平天国和捻、回的反抗,几于不可收拾了。其所以能奠定海宇,号称中兴,全是一班汉人,即所谓中兴诸将,替它效力的。清朝从道光以前,总督用汉人的很少,兵权全在满族手里。至太平天国兵起,则当重任的全是汉人。文宗避英法联军,逃奔热河,1861年,遂死于其地。其时清宗室中,载垣、端华、肃顺三人握权。载垣、端华亦是妄庸之徒,肃顺则颇有才具,力赞文宗任用汉人,当时内乱的得以削平,其根基实定于此。文宗死,子穆宗立。载垣、端华、肃顺等均受遗诏,为赞襄政务大臣。文宗之弟恭亲王奕,时留守京师,至热河,肃顺等隔绝之,不许其和文宗的皇后钮钴禄氏和穆宗的生母叶赫那拉氏相见。后来不知如何,奕终得和她们相见了,密定回銮之计。到京,就把载垣、端华、肃顺都杀掉。于是钮钴禄氏和叶赫那拉氏同时垂帘听政(钮钴禄氏称母后皇太后,谥孝贞。叶赫那拉氏称圣母皇太后,死谥孝钦。世称孝贞为东宫太后,孝钦为西宫太后),钮钴禄氏是不懂得什么的,大权都在叶赫那拉氏手里。叶赫那拉氏和肃顺虽系政敌,对于任用汉人一点,却亦守其政策不变,所以终能削平大难。然自此以后,清朝的中央政府即无能为,一切内政、外交的大任,多是湘、淮军中人物,以疆臣的资格决策或身当其冲。军机及内阁中,汉人的势力亦渐扩张。所以在这个时候,满洲的政权,在实际上已经覆亡了,只因汉人一方面,一时未有便利把它推倒,所以名义又维持了好几十年。\n第三十四章 清朝的衰乱 # 太平天国既亡,捻、回之乱复定,清朝一时号称中兴。的确,遭遇如此大难,而一个皇室,还能维持其政权于不敝的,在历史上亦很少见。然清室的气运,并不能自此好转,仍陵夷衰微以至于覆亡,这又是何故呢?这是世变为之。从西力东侵以后,中国人所遭遇到的,是一个旷古未有的局面,决非任何旧方法所能对付。孝钦皇后,自亦有其相当的才具,然她的思想是很陈旧的。试看她晚年的言论,还时时流露出道、咸时代人的思想来可知。大约她自入宫以后,就和外边隔绝了,时局的真相如何,她是不得而知的。她的思想,比较所谓中兴名臣,还要落后许多。当时应付太平天国,应付捻、回,所用的都是旧手段,她是足以应付的。内乱既定之后,要进而发愤自强,以御外患,就非她所能及了。不但如此,即当时所谓中兴名臣,要应付这时候的时局,也远觉不够。他们不过任事久了,经验丰富些,知道当时的一种迂阔之论不足用,他们亦觉得中国所遭遇的,非复历史上所有的旧局面,但他们所感觉到的,只是军事。因军事而牵及于制造,因制造而牵及于学术,如此而已。后来的人所说的“西人自有其立国之本,非仅在械器之末”,断非这时候的人所能见得到的,这亦无怪其然。不但如此,在当时中兴诸将中,如其有一个首领,像晋末的宋武帝一般,入据中央,大权在握,而清朝的皇帝,仅保存一个名义,这一个中央政府,又要有生气些。而无如中兴诸将,地丑德齐,没有这样的一个人物。而且他们多数是读书人,既有些顾虑君臣的名义,又有些顾虑到身家、名誉,不敢不急流勇退。清朝对于汉人,自然也不敢任之过重。所以当时主持中枢的,都是些智识不足、软弱无力,甚至毫无所知之人。士大夫的风气,在清时本是近于阘茸而好利的。湘军的中坚人物,一时曾以坚贞任事的精神为倡。然少数人的提倡,挽回不过积重的风气来,所以大乱平定未久,而此种精神,即已迅速堕落。官方士习,败坏如故。在同、光之世,曾产生一批所谓清流,喜唱高调,而于事实茫无所知,几于又蹈宋、明人的覆辙。幸而当时的情势,不容这一种人物发荣滋长,法越之役,其人有身当其冲而失败的,遂亦销声匿迹了,而士大夫仍成为一奄奄无气的社会。政府和士大夫阶级,其不振既如此,而宫廷之间,又发生了变故。清穆宗虽系孝钦后所生,顾与孝钦不协。立后之时,孝贞、孝钦,各有所主。穆宗顺从了孝贞的意思。孝钦大怒,禁其与后同居。穆宗郁郁,遂为微行,致疾而死。醇亲王奕之妻,为孝钦后之妹,孝钦因违众议立其子载湉,是为德宗。年方四岁,两宫再临朝。后孝贞后忽无故而死,孝钦后益无忌惮。宠任宦官,骄淫奢侈,卖官鬻爵,无所不为。德宗亲政之后,颇有意于振作,而为孝钦所扼,母子之间,嫌隙日深,就伏下戊戌政变的根源了。\n内政的陵夷如此,外交的情势顾日急。中国历代所谓藩属,本来不过是一个空名,实际上得不到什么利益的。所以论政之家,多以疲民力、勤远略为戒。但到西力东侵以来,情形却不同了。所谓藩属,都是屏蔽于国境之外的,倘使能够保存,敌国的疆域,即不和我国直接,自然无所肆其侵略。所以历来仅有空名的藩属,到这时候,倒确有藩卫的作用了。但以中国外交上的习惯和国家的实力,这时候,如何说得上保存藩属?于是到19世纪,而朝贡于中国之国,遂悉为列强所吞噬。我们现在先从西面说起:哈萨克和布鲁特,都于公元1840年顷,降伏于俄。布哈尔、基华,以1873年,沦为俄国的保护国。浩罕以1876年为俄所灭。巴达克山以1877年受英保护。乾竺特名为两属,实际上我亦无权过问。于是自葱岭以西朝贡之国尽了。其西南,则哲孟雄,当英法联军入北京之年,英人即在其境内获得铁路敷设权。缅甸更早在1826和1851年,和英人启衅战败,先后割让阿萨密、阿剌干、地那悉林及白古,沿海菁华之地都尽。安南旧阮失国后,曾介教士乞援于法。后来乘新阮之衰,借暹罗之助复国,仍受封于中国,改号为越南。当越南复国时,法国其实并没给与多大的助力。然法人的势力,却自此而侵入,交涉屡有葛藤。至1874年,法人遂和越南立约,认其为自主之国。我国虽不承认,法国亦置诸不理。甚至新兴的日本,亦于1879年将自明清以来受册封于中国的琉球灭掉。重大的交涉,在西北,则有1881年的《伊犁条约》。当回乱时,伊犁为俄国所据,中国向其交涉,俄人说:不过代中国保守,事定即行交还的。及是,中国派了一个昏聩糊涂的崇厚去,只收回了一个伊犁城,土地割弃既多,别种权利,丧失尤巨。中国将崇厚治罪,改派了曾纪泽,才算把地界多收回了些,别种条件,亦略有改正。然新疆全境,都准无税通商;肃州、吐鲁番,亦准设立领事;西北的门户,自此洞开了。在西南,则英国屡求派员自印度经云南入西藏探测,中国不能拒,许之。1857年,英人自印度实行派员入滇,其公使又遣其参赞,自上海至云南迎接。至腾越,为野人所杀。其从印度来的人员,亦被人持械击阻。这件事,云贵总督岑毓英,实有指使的嫌疑,几至酿成重大的交涉。次年,乃在芝罘订立条约:允许滇、缅通商,并开宜昌、芜湖、温州、北海为商埠。许英国派员驻扎重庆,察看商务情形,俟轮船能开抵时,再议开埠事宜。此为西人势力侵入西南之始。至1882年,而法、越的战事起。我兵初自云南、广西入越的都不利,海军亦败于福州。然后来冯子材有镇南关之捷,乘势恢复谅山。法人是时的情形,亦未能以全力作战,实为我国在外交上可以坚持的一个机会,但亦未能充分利用。其结果,于1885年,订立条约,承认法国并越,并许在边界上开放两处通商(后订开龙州、蒙自、蛮耗。1895年之约,又订以河口代蛮耗,增开思茅)。英人乘机,于1885年灭缅甸,中国亦只得于其明年立约承认。先是《芝罘条约》中,仍有许英人派员入藏的条款,至是,中国乘机于《缅约》中将此款取消。然及1888年,英、藏又在哲孟雄境内冲突,至1890年,中国和英人订立《藏印条约》,遂承认哲孟雄归英保护。1893年,续议条约,复订开亚东关为商埠,而藏人不肯履行,又伏下将来的祸根。\n对外交涉的历次失败,至1894年中日之战而达于极点。中、日两国,同立国于东方,在历史上的关系,极为深切,当西力东侵之际,本有合作御侮的可能。但这时候,中国人对外情太觉隔阂,一切都不免以猜疑的态度出之,而日方则褊狭性成,专务侵略,自始即不希望和中国合作。中、日的订立条约,事在1871年。领判权彼此皆有。进口货物,按照海关税则完纳,税则未定的,则值百抽五,亦彼此所同。内地通商,则明定禁止。在中国当日,未始不想借此为基本,树立一改良条约之基,然未能将此意开诚布公,和日本说明。日本则本不想和中国合作,而自始即打侵略的主意,于是心怀不忿。至1874年,因台湾生番杀害日本漂流的人民,径自派兵前往攻击。1879年,又灭琉球。交涉屡有葛藤,而衰微不振的朝鲜,适为日本踏上大陆的第一步,遂成为中、日两国权利冲突的焦点。1894年,日人预备充足,蓄意挑衅,卒至以兵戎相见。我国战败之后,于其明年,订立《马关条约》。除承认朝鲜自主外,又割台湾和辽东半岛,赔款至二万万两。改订通商条约,悉以中国和泰西各国所定的约章为准,而开辟沙市、重庆、苏州、杭州为商埠,日人得在通商口岸从事于制造,则又是泰西各国所求之历年,而中国不肯允许的。此约既定之后,俄国联合德、法,加以干涉,日人乃加索赔款三千万两,而将辽东还我。因此而引起1896年的《中俄密约》,中国许俄国将西伯利亚铁路经过黑、吉两省而达到海参崴。当时传闻,俄国还有租借胶州湾的密约,于是引起德国的强占胶州湾,而迫我立九十九年租借之约,并获得建造胶济铁路之权。俄人因此而租借旅、大,并许其将东省铁路,展筑一支线。英人则租借威海卫,法人又租借广州湾。我国沿海业经经营的军港,就都被占据了。其在西南:则法国因干涉还辽之事,向我要索报酬。于1895年订立《续议界务商务专条》,云南、两广开矿时,许先和法人商办。越南已成或拟设的铁路,得接至中国境内。并将前此允许英国不割让他国的孟连、江洪的土地,割去一部分。于是英国再向我国要求,于1897年,订立《中缅条约附款》。云南铁路,允与缅甸连接,而开放三水、梧州和江根墟。外人的势力,侵入西南益深了。又自俄、德两国,在我国获得铁路敷设权以来,各国亦遂互相争夺。俄人初借比国人出面,获得芦汉铁路的敷设权。英人因此要求津镇、河南到山东、九广、浦信、苏杭甬诸路。俄国则要求山海关以北铁路,由其承造。英国又捷足先得,和中国订定了承造牛庄至北京铁路的合同。英、俄旋自相协议,英认长城以北的铁路,归俄承造,俄人则承认长江流域的铁路,归英承造。英、德又自行商议,英认山西及自山西展筑一路至江域外,黄河流域的铁路归德,德认长江流域的铁路归英。凡铁路所至之处,开矿之权利亦随之。各国遂沿用分割非洲时的手段,指我国之某处,为属于某国的势力范围,而要求我以条约或宣言,承认其地不得割让给别国。于是瓜分之论,盛极一时,而我国人亦于其时警醒了。\n第三十五章 清朝的覆亡 # 自西力东侵,而中国人遭遇到旷古未有的变局。值旷古未有的变局,自必有非常的手段,然后足以应付之,此等手段,自非本来执掌政权的阶级所有,然则新机从何处发生呢?其一起自中等阶级,以旧有的文化为根柢的,是为戊戌维新。其二以流传于下级社会中固有的革命思想为渊源,采取西洋文化,而建立成一种方案的,则为辛亥革命。戊戌变法,康有为是其原动力。康有为的学问,是承袭清代经学家今文之学的余绪,而又融合佛学及宋、明理学而成的。一、因为他能承受今文之学的“非常异义”,所以能和西洋的民主主义接近。二、因为他能承受宋学家彻底改革的精神,所以他的论治,主于彻底改革,主张设治详密,反对向来“治天下不如安天下,安天下不如与天下安”的苟简放任政策。三、主张以中坚阶级为政治的重心,则士大夫本该有以天下为己任的大志,有互相团结的精神。宋、明人的讲学,颇有此种风概。入清以来,内鉴于讲学的流弊,外慑于异族的淫威,此等风气,久成过去了。康有为生当清代威力已衰,政令不复有力之时,到处都以讲学为事。他的门下,亦确有一班英多磊落之才。所以康有为的学问及行为,可以说是中国旧文化的复活。他当甲午战前,即已上书言事。到乙未之岁,中、日议和的时候,他又联合入京会试的举人,上书主张迁都续战,因陈变法自强之计。书未得达。和议成后,他立强学会于北京,想联合士大夫,共谋救国。会被封禁,其弟子梁启超走上海,主持《时务报》旬刊,畅论变法自强之义。此报一出,风行海内,而变法维新,遂成为一时的舆论。康有为又上书两次。德占胶州湾时,又入京陈救急之计。于是康有为共上书五次,只一次得达。德宗阅之,颇以为然。岁戊戌,即1898年,遂擢用有为等以谋变法。康有为的宗旨,在于大变和速变。大变所以谋全盘的改革,速变则所以应事机而振精神。他以为变法的阻力,都是由于有权力的大臣,欲固其禄位之私,于是劝德宗勿去旧衙门,但设新差使。他以为如此即可减少阻力。但阻碍变法的,固非尽出于保存禄位之私;即以保存禄位论,权已去,利亦终不可保,此固不足以安其心。何况德宗和孝钦后素有嫌隙,德宗又向来无权?于是有戊戌的政变。政变以后,德宗被幽,有为走海外,立保皇党,以推翻孝钦后,扶德宗亲政相号召。然无拳无勇,复何能为?而孝钦后以欲捕康、梁不得;欲废德宗,又为公使所反对,迁怒及于外人。其时孝钦后立端郡王载漪之子溥儁为大阿哥,载漪因急欲其子正位,宗戚中亦有附和其事,冀立拥戴之功的。而极陈旧的,“只要中国人齐心,即可将外国人尽行逐去,回复到闭关时代之旧”的思想,尚未尽去。加以下层社会中人,身受教案切肤之痛,益以洋人之强惟在枪炮,而神力可以御枪炮之说,遂至酿成1900年间义和团之乱。亲贵及顽固大臣,因欲加以利用,乃有纵容其在京、津间杀教士,焚教堂,拆铁路,倒电杆,见新物则毁,见用洋货的人则杀的怪剧。并伪造外人的要求条件,以恐吓孝钦后,而迫其与各国同时宣战。意欲于乱中取利,废德宗而立溥儁。其结果,八国联军入京城,德宗及孝钦后走西安。1901年的和约,赔款至四百五十兆。京城通至海口路上的炮台,尽行拆去。且许各国于其通路上驻兵。又划定使馆区域,许其自行治理、防守。权利之丧失既多,体面亦可谓丧失净尽了。是时东南诸督抚,和上海各领事订立互保之约,不奉北京的伪令。虽得将战祸范围缩小,然中央的命令,自此更不行于地方了。而黑龙江将军又贸然向俄人启衅,致东三省尽为俄人所占。各国与中国议和时,俄人说东三省系特别事件,不肯并入和约之中讨论,幸保完整的土地,仍有不免于破碎之势。庚子一役闯出的大祸如此。而孝钦后自回銮以后,排外变而为媚外;前此之力阻变革者,至此则变为貌行新政,以敷衍国民。宫廷之中,骄奢淫逸,朝廷之上,昏庸泄沓如故。满清政府至此,遂无可维持,而中国国民,乃不得不自起而谋政治的解决。\n19世纪之末,瓜分之论,盛极一时,已见上章。1899年,美国国务卿海约翰氏(John Hay)乃通牒英、俄、法、德、意、日六国,提出门户开放主义。其内容为:一、各国对于中国所获得的利益范围或租借地域,或他项既得权利,彼此不相干涉。二、各国范围内各港,对他国入港商品,都遵守中国现行海关税率,课税由中国征收。三、各国范围内各港,对他国船舶所课入口税,不得较其本国船舶为高,铁路运费亦然。这无非要保全其在条约上既得的权利。既要保全条约上的权利,自然要连带而及于领土保全,因为领土设或变更,既成的条约,在该被变更的领土上,自然无效了。六国都覆牒承认。然在此时,俄国实为侵略者,逮东三省被占而均势之局寝破。此时英国方有事于南非,无暇顾及东方,乃和德国订约,申明门户开放、领土保全之旨。各国都无异议。惟俄人主张其适用限于英、德的势力范围。英国力持反对。德国和东方,关系究竟较浅,就承认俄国人的主张了。于是英国觉得在东方,要和俄国相抗,非有更强力的外援不可,乃有1902年的英、日同盟。俄国亦联合法国,发表宣言,说如因第三国的侵略或中国的扰乱,两国利益受到侵害时,应当协力防卫。这时候,日本对于我国东北的利害,自然最为关切,然尚未敢贸然与俄国开战,乃有满、韩交换之论。大体上,日本承认俄国在东三省的权利,而俄人承认日本在韩国的权利。而俄人此时甚骄,并此尚不肯承认,其结果,乃有1904年的日俄战争。俄国战败,在美国的朴资茅斯,订立和约。俄人放弃在韩国的权利,割库页岛北纬五十度以南之地与日。除租借地外,两国在东三省的军队都撤退,将其地交还中国。在中国承认的条件之下,将旅顺、大连湾转租与日,并将东省铁路支线,自长春以下,让给日本。清廷如何能不承认?乃和日本订立《会议东三省事宜协议》,除承认《朴资茅斯条约》中有关中国的款项外,并在三省开放商埠多处。军用的安奉铁路,许日人改为商用铁路。且许合资开采鸭绿江左岸材木。于是东北交涉的葛藤,纷纷继起,侵略者的资格,在此而不在彼了。当日俄战争时,英国乘机派兵入藏,达赖出奔。英人和班禅立约,开江孜、噶大克为商埠。非经英国许可,西藏的土地不得租、卖给外国人。铁路、道路、电线、矿产,不得许给外国或外国人。一切入款、银钱、货物,不得抵押给外国或外国人。一切事情,都不受外国干涉,亦不许外国派官驻扎和驻兵。中国得报大惊,然与英人交涉无效,不得已,乃于1906年,订立《英藏续约》,承认《英藏条约》为附约,但声明所谓外国或外国人者,不包括中国或中国人在内而止。在东北方面,中国拟借英款敷设新法铁路,日人指为南满铁路的平行线(东省铁路支线,俄人让给日本的,日人改其名为南满路),中国不得已,作罢,但要求建造锦齐铁路时,日不反对。中国因欲借英、美的款项,将锦齐铁路延长至瑷珲。日人又唆使俄人出而反抗,于是美国人有满洲铁路中立的提议。其办法:系由各国共同借款给中国,由中国将东三省铁路赎回。在借款未还清前,由各国共同管理,禁止政治上、军事上的使用。议既出,日、俄两国均提出抗议。这时候,因英、美两国欲伸张势力于东北而无所成,其结果反促成日、俄的联合。两国因此订立协约,声明维持满洲现状,现状被迫时,彼此互相商议。据说此约别有密约,俄国承认日本并韩,而日本承认俄国在蒙、新方面的行动。此约立于1910年。果然,日本于其年即并韩,而俄人对蒙、新方面,亦于其明年提出强硬的要求,且用哀的美敦书迫胁中国承认了。\n外力的冯凌,实为清季国民最关心的事项。清朝对于疆土的侵削,权利的丧失,既皆熟视而无可如何,且有许多自作孽的事情,以引进外力的深入。国民对于清政府,遂更无希望,且觉难于容忍。在庚子以前,还希冀清朝变法图强的,至庚子以后,则更无此念,激烈的主张革命,平和的也主张立宪,所要改革的,不是政务而是政体了。革命的领导者孙中山先生,是生于中国的南部,能承袭明季以来的民族革命思想,且能接受西方的民治主义的。他当1885年,即已决定颠覆清朝,创建民国。1892年在澳门立兴中会。其后漫游欧、美,复决定兼采民生主义,而三民主义,于是完成。自1892年以来,孙中山屡举革命之帜。其时所利用的武力,主要的为会党,次之则想运动防军。然防军思想多腐败,会党的思想和组织力,亦嫌其不足用,是以屡举而无成。自戊戌政变以后,新机大启,中国人士赴外国留学者渐多,以地近费省之故,到日本去的尤多。以对朝政的失望,革命、立宪之论,盛极一时。1905年,中山先生乃赴日本,将兴中会改组为同盟会。革命团体至此,始有中流以上的人士参加。中山先生说:“我至此,才希望革命之事,可以及身见其有成。”中流以上的人士,直接行动的能力,虽似不如下流社会,然因其素居领导的地位,在宣传方面的力量,却和下流社会中人,相去不可以道里计,革命的思潮,不久就弥漫全国了。素主保皇的康有为,在此时,则仍主张君主立宪。其弟子梁启超,是历年办报,在言论界最有权威的。初主革命,后亦改从其师的主张,在所办的《新民丛报》内,发挥其意见,和同盟会所出的《民报》,互相辩论,于是立宪、革命成为政治上的两大潮流。因对于清朝的失望,即内外臣工中,亦有主张立宪的。日俄战争而后,利用日以立宪而胜,俄以专制而败为口实,其议论一时尤盛。清朝这时候,自己是并无主张的。于是于1906年,下诏预备立宪。俟数年后,察看情形,以定实行的期限。人民仍不满足。1908年,下诏定实行立宪之期为九年。这一年冬天,德宗和孝钦后相继而死。德宗弟醇亲王载沣之子溥仪立。年幼,载沣摄政,性甚昏庸。其弟载洵、载涛则恣意妄为。居政府首席的庆亲王奕劻,则老耄而好贿,政局更形黑暗。人民屡请即行立宪,不许。1910年,号称为国会预备的资政院,亦以为请,乃勉许缩短期限,于三年后设立国会。然以当时的政局,眼见得即使召集国会,亦无改善的希望,人民仍觉得灰心短气。而又因铁路国有问题,和人民大起冲突。此时的新军,其知识,已非旧时军队之比;其纪律和战斗力,自亦远较会党为强。因革命党人的热心运动,多有赞成革命的。1911年10月10日,即旧历辛亥八月十九日,革命军起事于武昌。清朝本无与立,在无事时,亲贵虽欲专权,至危急时,仍不得不起用袁世凯。袁世凯亦非有诚意扶持清朝的,清人力尽势穷,遂不得不于其明年即中华民国元年二月十二日退位。沦陷了二百六十八年的中华,至此光复;且将数千年来的君主专制政体,一举而加以颠覆。自五口通商,我国民感觉时局的严重,奋起而图改革,至此不过七十年,而有如此的大成就,其成功,亦不可谓之不速了。\n●袁世凯像\n第三十六章 革命途中的中国 # 语云:“大器晚成”,一人尚然,而况一国?中华民国的建立,虽已三十年,然至今仍在革命的途中,亦无怪其然了。策励将来,端在检讨以往,我现在,且把这三十年来的经过,述其大略如下:\n●孙中山像\n民国的成立,虽说是由于人心的效顺,然以数千年来专制的积重,说真能一朝涤除净尽,自然是无此理的。大约当时最易为大众所了解的,是民族革命,所以清朝立见颠覆。而袁世凯则仍有运用阴谋,图遂其个人野心的余地。民党当日亦知道袁世凯之不足信,但为避免战祸,且急图推翻清朝起见,遂亦暂时加以利用。孙中山先生辞临时大总统之职,推荐袁世凯于参议院。于是袁世凯被举为临时大总统。民党因南方的空气,较为清新,要其到南京来就职。袁世凯自然不肯来,乃唆使京、津、保定的兵哗变。民党乃只得听其在北京就职。此时同盟会已改组为国民党,自秘密的革命团体变成公开的政党。孙中山先生知道政局暂难措手,主张国民党退居在野的地位,而自己则专办实业。然是时的国民党员,不能服从党纪,不听。二年四月八日,国会既开,国民党议员,乃欲藉国会和内阁的权力,从法律上来限制袁氏。这如何会有效?酝酿复酝酿,到底有二年七月间的二次革命。二次革命失败后,孙中山先生在海外组织中华革命党。鉴于前此以纪律不严而败,所以此次以服从党魁为重要的条件。然一时亦未能为有效的活动。而袁氏在国内,则从解散国民党,进而停止国会议员的职务,又解散省议会,停办地方自治,召开约法会议,擅将宪法未成以前的根本大法《临时约法》修改为《中华民国约法》,世称为《新约法》,而称《临时约法》为《旧约法》。又立参议院,令其代行立法权。共和政体,不绝如缕。至四年底,卒有伪造民意帝制自为之举。于是护国军起于云南。贵州、两广、浙江、四川、湖南,先后响应。山东、陕西境内,亦有反对帝制的军队。袁氏派兵攻击,因人心不顺,无效,而外交方面,又不顺利,乃于五年三月间下令将帝制取消,商请南方停战。南方要求袁氏退位,奉副总统黎元洪为大总统。事势陷于僵持。未久,袁氏逝世,黎氏代行职权,恢复《临时约法》和国会,问题乃得自然解决。然为大局之梗者,实并非袁氏一人。袁氏虽非忠贞,然当其未至溃败决裂时,北洋系军人,究尚有一个首领。到袁氏身败名裂之后,野心军人,就更想乘机弄权。当南方要求袁氏退位而袁氏不肯时,江苏将军就主张联合未独立各省,公议办法。通电说:“四省若违众论,固当视同公敌;政府若有异议,亦当一致争持”,这已经不成话了。后来他们又组织了一个各省区联合会,更形成了一种非法的势力。六年二月,因德国宣布无限制潜艇战争,我国与德绝交。国务总理段祺瑞进而谋对德参战。议案被国会搁置。各省、区督军、都统,遂分呈总统和国务总理,藉口反对宪法草案,要求解散国会。黎总统旋免段祺瑞之职。安徽遂首先宣告和中央脱离关系。直隶、山东、山西、河南、陕西、奉天、黑龙江、浙江、福建等省继之。在天津设立军务总参谋处。通电说:“出兵各省,意在另订根本大法,设立临时政府和临时议会”,这更显然是谋叛了。黎总统无可如何,召安徽督军张勋进京共商国是。张勋至天津,迫胁黎总统解散国会而后入。七月一日,竟挟废帝溥仪在京复辟。黎总统走使馆,令副总统冯国璋代行职权,以段祺瑞为国务总理。祺瑞誓师马厂,十二日,克复京城。张勋所扶翼的清朝亡。流了无量数有名无名的先烈的血,然后造成的中华民国,因军人、政客私意的交争,而几至于倾覆,论理,同为中华民国的人民,应该可以悔过了。然而社会现象,哪有如此简单?北方的军人、政客,仍不能和南方合作。乃藉口于复辟之时,中华民国业经中断,可仿民国元年之例,重行召集参议院,不知当复辟的十一天中,所谓溥仪者,号令只行于一城;我们即使退一百步,承认当时督军团中的督军,可以受令于溥仪,而西南诸省固自在,中华民国,何尝一日中断来?然而这句话何从向当时的政客说起?于是云南、两广,当国会解散时,宣布径行禀承元首,不受非法内阁干涉的,仍不能和北方合作。国会开非常会议于广州,议决《军政府组织大纲》,在《临时约法》未恢复前,以大元帅任行政权,对外代表中华民国,举孙中山为大元帅。后又改为总裁制,以政务总裁七人,组织政务会议,由各部长所组织之政务院赞襄之,以行使军政府之行政权。北方则召集参政院,修改选举法,另行召集新国会,举徐世昌为总统,于七年十月十日就职。中华民国遂成南北分裂之局。而南北的内部,尚不免于战争。九年七月,北方的吴佩孚,自衡阳撤防回直隶,和段祺瑞所统率的定国军作战。定国军败,段氏退职,是为皖、直之战。南方亦因心力不齐,总裁中如孙中山等均离粤。是年十月,以粤军而驻扎于福建漳州的陈炯明回粤,政务总裁岑春煊等宣布取消自主。徐世昌据之,下令接收,孙中山等通电否认,回粤再开政务会议。十年四月,国会再开非常会议,选举孙中山为大总统,于五月五日就职,陈炯明遂进兵平定广西。\n是时北方:曹锟为直、鲁、豫巡阅使,吴佩孚为副。王占元为两湖巡阅使,张作霖为东三省巡阅使,兼蒙疆经略使。湖南军队进攻湖北,王占元败走。旋为吴佩孚所败,进陷岳州,川军东下,亦为佩孚所败。十一年四五月间,奉军在近畿和直军冲突,奉军败退出关。孙中山本在广西筹备北伐。是年四月间,将大本营移至韶关。陈炯明走惠州。五月,北伐军入江西。六月,徐世昌辞职。曹锟等电黎元洪请复位。元洪复电,要求各巡阅使、督军先释兵柄,旋复许先行入都。撤消六年六月解散国会之令,国会再开。孙中山宣言:直系诸将,应将所部半数,先行改为工兵,余则留待与全国军队同时以次改编,方能饬所部罢兵。未几,广西粤军回粤,围攻总统府。孙中山走上海。岁杪,滇、桂军在粤的及粤军的一部分讨陈,陈炯明再走惠州。十二年二月,孙中山乃再回广州,以大元帅的名义主持政务。然滇、桂军并不肯进取东江,在广东方面的军事,遂成相持之局。此时北方各督军中,惟浙江卢永祥通电说,冯国璋代理的期限既满,就是黎元洪法定的任期告终,不肯承认黎元洪之复职为合法。东三省则自奉、直战后,即由三省省议会公举张作霖为联省自治保安总司令,而以吉、黑两省督军为之副,不奉北京的命令。其余则悉集于直系旗帜之下。南方如陈炯明及四川省内的军人,亦多与之相结的。直系的势力,可谓如日中天,而祸患即起于其本身。十二年六月间,北京军、警围总统府索饷,黎元洪走天津,旋走南方。至十月,曹锟遂以贿选为大总统,于十月就职,同时公布宪法。浙江宣布与北京断绝关系,云南及东三省皆通电讨曹,然亦未能出兵。十三年九月,江、浙战起,奉、直之战继之,直系孙传芳自福建入浙,卢永祥败走。北方则冯玉祥自古北口回军,自称国民第一军。胡景翼、孙岳应之,称国民第二、第三军。吴佩孚方与张作霖相持于山海关,因后路被截,浮海溯江,南走湖北。奉军入关,张作霖与冯玉祥相会,共推段祺瑞为临时执政,段祺瑞邀孙中山入京,共商国是。孙中山主开国民会议,解决国是。段祺瑞不能用。段祺瑞亦主开善后会议,先解决时局纠纷,再开国民代表会议,解决根本问题。孙中山以其所谓会议者,人民团体无一得与,诫国民党员勿得加入。于是会商仍无结果。是年三月十二日,孙中山先生卒于北京。\n是时北方:张作霖为东北边防督办,冯玉祥为西北边防督办。胡景翼督办河南军务善后事宜,孙岳为省长。后胡景翼卒,孙岳改为督办陕西军务事宜。卢永祥为苏、皖、赣宣抚使,以奉军南下,齐燮元走海外。直隶李景林、山东张宗昌、江苏杨宇霆、安徽姜登选,均属奉系人物。直系残余势力,惟萧耀南仍踞湖北,孙传芳仍据浙江,吴佩孚亦仍居鸡公山。十四年十月,孙传芳入江苏。杨宇霆、姜登选皆退走。孙军北上至徐州。十一月,吴佩孚亦起于汉口。奉军驻扎关内的郭松龄出关攻张作霖,以为日本人所阻,败死。冯玉祥攻李景林,李景林走济南,与张宗昌合。吴佩孚初称讨奉,后又与张作霖合攻冯玉祥,冯军撤退西北。段祺瑞出走。北方遂无复首领。大局的奠定,不得不有望于南方的北伐。\n先是孙中山以八年十月,改中华革命党为中国国民党。十二年十一月,又加改组。十三年一月十二日,始开全国代表大会于广州,将大元帅府改组为国民政府。十四年四月,国民政府平东江。还军平定滇、桂军之叛。广西亦来附。改组政府为委员制。十五年一月,开全国代表第二次大会。六月,中央执行委员会召集临时会,通过迅速北伐案。七月,克长沙。九月,下汉阳、汉口,围武昌,至十月而下。十一月,平江西。冯玉祥之国民军,亦以是月入陕,十二月,达潼关。东江之国民军,先以十月入福建。明年,国民军之在湖南者北入河南,与冯玉祥之军合。在福建者入浙江。在江西者分江左、江右两军,沿江而下,合浙江之兵克南京。旋因清党事起,宁、汉分裂,至七月间乃复合作。明年,一月,再北伐。至五月入济南,而五三惨案作。国民军绕道德州北伐。张作霖于六月三日出关,四日,至皇姑屯,遇炸死。其子张学良继任。至十二月九日,通电服从国民政府,而统一之业告成。\n中国革命前途重要的问题,毕竟不在对内而在对外。军人的跋扈,看似扰乱了中国好几十年,然这一班并无大略,至少是思想落伍,不识现代潮流的人,在今日的情势之下,复何能为?他们的难于措置,至少是有些外交上的因素牵涉在内的。而在今日,国内既无问题之后,对外的难关,仍成为我们生死存亡的大问题。所以中国既处于今日之世界,非努力打退侵略的恶势力,决无可以自存之理。外交上最大的压力,来自东北方。当前清末年,曾向英、美、德、法四国借款,以改革币制及振兴东三省的实业,以新课盐税和东三省的烟酒、生产、消费税为抵。因革命军起,事未有成。至民国时代,四国银行团加入日、俄,变为六国,旋美国又退出,变为五国,所借的款项,则变为善后大借款,这是最可痛心的事。至欧战起,乃有日本和英国合兵攻下胶州湾之举。日本因此而提出五号二十一条的要求。其后复因胶济沿路的撤兵,和青岛及潍县、济南日人所施民政的撤废,而有《济顺高徐借款预备契约》及胶济铁路日方归中、日合办的照会,由于复文有“欣然同意”字样,致伏巴黎和会失败之根。其后虽有华盛顿会议的《九国公约》,列举四原则,其第一条,即为尊重中国的主权独立和领土及行政的完整,然迄今未获实现。自欧战以后,与德、奥、俄所订的条约,均为平等。国民政府成立以来,努力于外交的改进。废除不平等条约,已定有办法。关税业已自主。取消领事裁判权,亦已有实行之期,租借地威海卫已交还。租界亦有交还的。然在今日情势之下,此等又都成为微末的问题。我们当前的大问题,若能得到解决,则这些都不成问题;在大问题还没解决之前,这些又都无从说起了。在经济上,我们非解除外力的压迫,更无生息的余地,资源虽富,怕我们更无余沥可沾。在文化上,我们非解除外力的压迫,亦断无自由发展的余地,甚至当前的意见,亦非此无以调和。总之:我们今日一切问题,都在于对外而不在于对内。\n我们现在,所处的境界,诚极沉闷,却不可无一百二十分的自信心。岂有数万万的大族,数千年的大国、古国,而没有前途之理?悲观主义者流:“君歌且休听我歌,我歌今与君殊科。”我请诵近代大史学家梁任公先生所译英国大文豪拜伦的诗,以结吾书。\n希腊啊!你本是平和时代的爱娇,你本是战争时代的天骄。撒芷波,歌声高,女诗人,热情好。更有那德罗士、菲波士荣光常照。此地是艺文旧垒,技术中潮。祇今在否?算除却太阳光线,万般没了。\n马拉顿前啊!山容缥缈。马拉顿后啊!海门环绕。如此好河山,也应有自由回照。我向那波斯军墓门凭眺。难道我为奴为隶,今生便了?不信我为奴为隶,今生便了。\n卅·九·一八于孤岛\n"},{"id":164,"href":"/zh/docs/culture/%E4%B8%AD%E5%9B%BD%E9%80%9A%E5%8F%B2%E5%90%95%E6%80%9D%E5%8B%89/%E5%B0%81%E9%9D%A2-%E7%89%88%E6%9D%83-%E8%AF%BB%E5%90%8E-%E8%87%AA%E5%BA%8F/","title":"封面-版权-读后-自序","section":"中国通史(吕思勉)","content":" 版权信息 # 中国通史\n作 者:吕思勉\n责任编辑:周 宏\n特约编辑:邱承辉\n装帧设计:利 锐\n本书由天津博集新媒科技有限公司授权亚马逊全球范围发行\n**目录 **\n版权信息\n吕思勉先生的史识与史德——《中国通史》读后\n自序 绪论\n上编 中国政治史 第一章 中国民族的由来\n第二章 中国史的年代\n第三章 古代的开化\n第四章 夏殷西周的事迹\n第五章 春秋战国的竞争和秦国的统一\n第六章 古代对于异族的同化\n第七章 古代社会的综述\n第八章 秦朝治天下的政策\n第九章 秦汉间封建政体的反动\n第十章 汉武帝的内政外交\n第十一章 前汉的衰亡\n第十二章 新室的兴亡\n第十三章 后汉的盛衰\n第十四章 后汉的分裂和三国\n第十五章 晋初的形势\n第十六章 五胡之乱(上)\n第十七章 五胡之乱(下)\n第十八章 南北朝的始末\n第十九章 南北朝隋唐间塞外的形势\n第二十章 隋朝和唐朝的盛世\n第二十一章 唐朝的中衰\n第二十二章 唐朝的衰亡和沙陀的侵入\n第二十三章 五代十国的兴亡和契丹的侵入\n第二十四章 唐宋时代中国文化的转变\n第二十五章 北宋的积弱\n第二十六章 南宋恢复的无成\n第二十七章 蒙古大帝国的盛衰\n第二十八章 汉族的光复事业\n第二十九章 明朝的盛衰\n第三十章 明清的兴亡\n第三十一章 清代的盛衰\n第三十二章 中西初期的交涉\n第三十三章 汉族的光复运动\n第三十四章 清朝的衰乱\n第三十五章 清朝的覆亡\n第三十六章 革命途中的中国\n下编 中国文化史 第三十七章 婚姻\n第三十八章 族制\n第三十九章 政体\n第四十章 阶级\n第四十一章 财产\n第四十二章 官制\n第四十三章 选举\n第四十四章 赋税\n第四十五章 兵制\n第四十六章 刑法\n第四十七章 实业\n第四十八章 货币\n第四十九章 衣食\n第五十章 住行\n第五十一章 教育\n第五十二章 语文\n第五十三章 学术\n第五十四章 宗教\n吕思勉先生的史识与史德——《中国通史》读后 # 章立凡\n身处浮躁的互联网时代,阅读讲求“吃快餐”,太长的文字没人看。记忆学原理中有一条:付费的知识不易遗忘,价格越高越记得住。上网浏览毕竟有别于捧书阅读,好书还得买来读。在书号成为稀缺资源的国度,出版者不得不计算成本和利润,总喜欢出厚一点的畅销书。上世纪90年代以前那种要言不烦的小册子,如今已日见稀少,盖因其性价比往往仅适于读者而非商家。\n不时有朋友问我:最便捷地阅读了解五千年来的中国史,读哪种通史好?就我的阅读经验而言,排除掉《史记》、《资治通鉴》、《纲鉴易知录》那样的文言大部头,读过的新式中国通史中,范文澜、蔡美彪先生主编的有10册,白寿彝先生主编的有22册,翦伯赞先生的《中国史纲》是两册,均为1949年后的版本,或多或少都有“以论带史”的特色;相形之下,吕思勉、钱穆、黄现璠先生的通史类著作,比较简约精要,且鲜有政治烙印。\n一、广博的视野和独特的视角\n吕思勉先生(1884—1957),字诚之,江苏常州人,出身书香之家。他幼承家学,次第入塾入县学,旧学根基深厚,基本上是自学成才,未接受新式大学教育。1926年起,任上海光华大学国文系教授,后任历史系教授兼系主任。若按当今只重学历不重才识的官式教育制度,他连执教资格都不具备。民国时代学术重镇在北京大学,以他的学术地位和影响力,前往任教不成问题,他却选择了留在私立光华大学(上世纪50年代并入华东师范大学),直到逝世。吕先生的学历、学术旨趣,与当时西方教育背景的学术精英不甚合拍,而坚守“私学”传统,不愿涉足官办的公立大学,恐怕也是原因之一。\n吕先生是一位通博之才,一生著有两部中国通史、四部断代史、五部专门史以及大量史学札记,共有八九百万字。这部《中国通史》原书分为上下两册,“上册以文化现象为题目,下册乃依时代加以连结”。1在上册中,他将《史记》“八书”体例加以细化,分解为十八个门类,分别加以论述。下册从民族起源、古代社会始,按时序叙述历朝历代史事直至民国开创。以人文史为纬,以政治史为经,表述分明,议论风发,浓缩中国五千年以上的历史于一书,仅用了三十八万字,其功力非同一般。\n应该用怎样的视角和立场,去回顾和审视历史?我认为至少应做到两点:一、先有宏观视野,后有微观视角,随时穿越时空,不断调整焦距;二、保持平常心,不预设立场,审视距离放在目标时段的一百年至五百年之后。通史写作需要具备穿越时空的视野和高屋建瓴的史识,否则很难驾驭海量的史料。吕著《中国通史》不仅继承了司马迁以来的史学传统,同时采用了清末梁启超“新史学”所开辟的学术视野,将中国史作为一个民族国家的历史,放到世界史的时空中观察研究,并对梁先生的酷锐视角有所调整,与政治保持了适当距离。\n二、对儒、法两家经济思想的评述\n我在阅读中最感兴趣的部分,是吕先生建立在旧学底蕴和新学高度上的历史观。原书由私域扩展到公域,自初民的社会生活始,从婚姻、族制、政体、阶级、财产而及官制、选举、赋税、兵制、刑法,从实业、货币到衣食、住行,从教育、语文到学术、宗教,解析社会制度、经济、文化的演变。各章节的排序及内容的表述丝丝入扣,具有内在的历史逻辑关系。\n吕先生治学的严谨,不仅在于具有宏观的视野,同时也关注到历史的细节。他从经济制度上把中国的历史分为三大时期:“有史以前为第一期。有史以后,讫于新室之末,为第二期。自新室亡后至现在,为第三期。自今以后,则将为第四期的开始。”\n他注意到:“在东周之世,社会上即已发生两种思潮:一是儒家,主张平均地权,其具体办法,是恢复井田制度。一是法家,主张节制资本,其具体办法,是(甲)大事业官营;(乙)大商业和民间的借贷,亦由公家加以干涉。”法家在统治技术(治术)方面,懂得“创设新税,自当用间接之法,避免直接取之于农民”。在与百姓日用相关的盐铁上“加些微之价,国家所得,已不少了”。汉代法家桑弘羊的盐铁官卖及均输政策,“筹款的目的是达到了,矫正社会经济的目的,则并未达到。汉朝所实行的政策,如减轻田租、重农抑商等,更其无实效可见了。直到汉末,王莽出来,才综合儒法两家的主张行一断然的大改革”。(第四十一章财产)\n吕先生认为:“王莽的失败,不是王莽一个人的失败,乃是先秦以来言社会改革者公共的失败。”王莽失败后,中国的社会改革运动长期停顿,仅出现过“平和的、不彻底的平均地权运动”,如晋朝的户调式、北魏的均田令、唐朝的租庸调法,至唐德宗朝改为两税制后,“国家遂无复平均地权的政策”。宋朝王安石变法,关注点已转移到粮价,推行青苗法用意虽良,但在商品交换及市民社会尚未充分发育的年代,权力无法监督,改革最终沦为秕政。他总结说:\n中国历代,社会上的思想,都是主张均贫富的,这是其在近代所以易于接受社会主义的一个原因。然其宗旨虽善,而其所主张的方法,则有未善。这因历代学者,受传统思想的影响太深,而对于现实的观察太浅之故。在中国,思想界的权威,无疑是儒家。儒家对于社会经济的发展,认识本不如法家的深刻,所以只主张平均地权,而忽略了资本的作用。(第四十一章财产)\n这段论述是相当公允的,肯定了改革者的历史地位,而较之“文革”中为政治需要生造出的“儒法斗争史”,又不知高明凡几。\n三、对文化与制度的思考\n吕先生在解析财产制度由公有制向私有制演变时,提出“人类的联合,有两种方法:一种是无分彼此,通力合作,一种则分出彼此的界限来。既分出彼此的界限,而又要享受他人劳动的结果,那就非于(甲)交易、(乙)掠夺两者之中择行其一不可了。而在古代,掠夺的方法,且较交易为通行。在古代各种社会中,论文化,自以农业社会为最高;论富力,亦以农业社会为较厚,然却很容易被人征服”。而征服者在建立统治之后,就得考虑统治(或曰剥削)的可持续性,不随意干涉原有的社会组织,甚至同化于比自身更先进的社会文化:\n(一)剥削者对于被剥削者,亦必须留有余地,乃能长保其剥削的资源。(二)剥削的宗旨,是在于享乐的,因而是懒惰的,能够达到剥削的目的就够了,何必干涉人家内部的事情?(三)而剥削者的权力,事实上亦或有所制限,被剥削者内部的事情,未必容其任意干涉。(四)况且两个社会相遇,武力或以进化较浅的社会为优强,组织必以进化较深的社会为坚凝。所以在军事上,或者进化较深的社会,反为进化较浅的社会所征服;在文化上,则总是进化较浅的社会,为进化较深的社会所同化的。(第四十一章财产)\n对于从封建时代到资本主义时代的文明嬗替,吕先生认为:\n封建社会的根源,是以武力互相掠夺。人人都靠武力互相掠夺,则人人的生命财产,俱不可保。这未免太危险。所以社会逐渐进步,武力掠夺之事,总不能不悬为厉禁。到这时代,有钱的人,拿出钱来,就要看他愿否。于是有钱就是有权力。豪爽的武士,不能不俯首于狡猾悭吝的守财奴之前了。这是封建社会和资本主义社会转变的根源。平心而论:资本主义的惨酷,乃是积重以后的事。当其初兴之时,较之武力主义,公平多了,温和多了,自然是人所欢迎的。(第四十章阶级)\n在工业文明东渐之前,中国的农业文明曾是一种强势文明。吕先生指出,游牧民族入侵后,被中国文化所同化;同为农业文明的佛教文化输入中国后,“并未能使中国人的生活印度化,反而佛教的本身,倒起了变化,以适应我们的生活了”;“中国虽然不断和外界接触,而其所受的外来的影响甚微”;“至近代欧西的文明,乃能改变生活的基础,而使我们的生活方式,不得不彻底起一个变化,我们应付的困难,就从此开始了。但前途放大光明、得大幸福的希望,亦即寄托在这个大变化上”。(第三十二章中西初期的交涉)生活方式的改变是最彻底的改变,这些表述,揭示了工业文明取代农业文明,成为主流文明的历史必然。\n文化与制度的关系,是一个争执已久的话题。对于改造西方宗教为本土教门的太平天国革命,吕先生分析其失败之原因“实不在于军事而在于政治”,“若再推究得深些,则其失败,亦可以说是在文化上”。他指出:“社会革命和政治革命,很不容易同时并行,而社会革命,尤其对社会组织,前因后果,要有深切的认识,断非简单、手段灭裂的均贫富主义所能有济。”(第三十三章汉族的光复运动)吕先生这一分析十分精到,近代以来中国人对革命的误解,恰恰在于混淆了政治革命与社会革命的区别,将两者同时并行。\n离现实越近的历史越难评判,吕先生在分析清朝的覆亡时,除缕陈戊戌维新失败的权力斗争背景外,也指出文化上的守旧愚昧:“只要中国人齐心,即可将外国人尽行逐去,回复到闭关时代之旧”的思想,是酿成蒙昧主义排外运动的重要原因。而革命超越改良的原因则在于:“孝钦后自回銮以后,排外变而为媚外;前此之力阻变革者,至此则变为貌行新政,以敷衍国民。宫廷之中,骄奢淫逸,朝廷之上,昏庸泄沓如故。满清政府至此,遂无可维持,而中国国民,乃不得不自起而谋政治的解决”。(第三十五章清朝的覆亡)腐朽的政治、滞后的改革和媚外的外交,最终导致了革命爆发和王朝倾覆。\n四、余论\n上述种种,仅系阅读中的一点心得体会,无法尽述吕先生的博大精深。\n严耕望先生将陈寅恪、钱穆、陈垣、吕思勉并称为前辈史学“四大家”,其他三家都令名远扬,惟吕先生相形落寞,直到近年“国学热”兴起,才重新“被出土”。1949年鼎革之际,钱先生出走香江,不与新政权合作;陈(寅恪)先生走到半途滞留羊城,成为非主流代表人物;陈(垣)先生留京痛悔前非,为新主流所接纳。如此看来,功名可“正取”也可“逆取”,有心无心的“政治正确”或“不正确”,皆足以扬名立万。\n“学成文武艺,货与帝王家”,中国士大夫历来有当“帝王师”的冲动,统治者想干点好事或坏事,往往摆出“以史为鉴”的身段向史家求教。其实在主子心目中,这些人大多是备用的“两脚书橱”或歌功颂德的词臣。治学如不能与政治保持距离,学者很容易失身入彀沦为政客,吕先生毕生潜心治学不求闻达,坚持做学界隐者,尤为难能可贵。\n清人章学诚在其名著《文史通义》中指出:“能具史识者,必具史德。”正史出于胜利者,而信史出于旁观者,从这部叙事心平气和、解析鞭辟入里的中国通史中,不仅能窥见作者的史德与史识,也可洞悉中国历代王朝兴替的周期律,令后来者鉴之,祈勿使后人复哀后人也。\n2010年3月14日风雨读书楼\n自序 # 我在上海光华大学,讲过十几年的本国史。其初系讲通史。后来文学院长钱子泉先生说:讲通史易与中学以下的本国史重复,不如讲文化史。于是改讲文化史。民国二十七年,教育部颁行大学课程;其初以中国文化史为各院系一年级必修科,后改为通史,而注明须注重于文化。大约因政治方面,亦不可缺,怕定名为文化史,则此方面太被忽略之故。用意诚甚周详。然通史讲授,共止一百二十小时,若编制仍与中学以下之书相同,恐终不免于犯复。所以我现在讲授,把它分为两部分:上册以文化现象为题目,下册乃依时代加以联结,以便两面兼顾(今本书已将政治史移作上册,文化史改作下册——出版者注)。此意在本书绪论中,业经述及了。此册系居孤岛上所编,参考书籍,十不备一;而时间甚为匆促。其不能完善,自无待言。但就文化的各方面加以探讨,以说明其变迁之故,而推求现状之所由来;此等书籍,现在似尚不多,或亦足供参考。故上册写成,即付排印,以代抄写。不完不备之处,当于将来大加订补。此书之意,欲求中国人于现状之所由来,多所了解。故叙述力求扼要,行文亦力求浅显。又多引各种社会科学成说,以资说明。亦颇可作一般读物;单取上册,又可供文化史教科或参考之用。其浅陋误缪之处,务望当代通人,加以教正。\n民国二十八年九月二十八日,吕思勉识。\n绪论 # 历史,究竟是怎样一种学问?研究了它,究竟有什么用处呢?\n这个问题,在略知学问的人,都会毫不迟疑地作答道:历史是前车之鉴。什么叫做前车之鉴呢?他们又会毫不迟疑地回答道:昔人所为而得,我可以奉为模范;如其失策,便当设法避免,这就是所谓“法戒”。这话骤听似是,细想就知道不然。世界上哪有真正相同的事情?所谓相同,都是察之不精,误以不同之事为同罢了。远者且勿论。欧人东来以后,我们应付他的方法,何尝不本于历史上的经验?其结果却是如何呢?然则历史是无用了么?而不知往事,一意孤行的人,又未尝不败。然则究竟如何是好呢?\n历史虽是记事之书,我们之所探求,则为理而非事。理是概括众事的,事则只是一事。天下事既没有两件真正相同的,执应付此事的方法,以应付彼事,自然要失败。根据于包含众事之理,以应付事实,就不至于此了。然而理是因事而见的,舍事而求理,无有是处。所以我们求学,不能不顾事实,又不该死记事实。\n要应付一件事情,必须明白它的性质。明白之后,应付之术,就不求而自得了。而要明白一件事情的性质,又非先知其既往不可。一个人,为什么会成为这样子的一个人?譬如久于官场的人,就有些官僚气;世代经商的人,就有些市侩气;向来读书的人,就有些迂腐气。难道他是生来如此的么?无疑,是数十年的做官、经商、读书养成的。然则一个国家,一个社会,亦是如此了。中国的社会,为什么不同于欧洲?欧洲的社会,为什么不同于日本?习焉不察,则不以为意,细加推考,自然知其原因极为深远复杂了。然则往事如何好不研究呢?然而以往的事情多着呢,安能尽记?社会上每天所发生的事情,报纸所记载的,奚啻亿兆京垓分之一。一天的报纸,业已不可遍览,何况积而至于十年、百年、千年、万年呢?然则如何是好?\n须知我们要知道一个人,并不要把他以往的事情,通统都知道了,记牢了。我,为什么成为这样一个我?反躬自省,总是容易明白的,又何尝能把自己以往的事,通统记牢呢?然则要明白社会的所以然,也正不必把以往的事,全数记得,只要知道“使现社会成为现社会的事”就够了。然而这又难了。\n任何一事一物,要询问它的起源,我们现在,不知所对的很多。其所能对答的,又十有八九靠不住。然则我们安能本于既往,以说明现在呢?\n这正是我们所以愚昧的原因,而史学之所求,亦即在此。史学之所求,不外乎(一)搜求既往的事实,(二)加以解释,(三)用以说明现社会,(四)因以推测未来,而指示我们以进行的途径。\n往昔的历史,是否能肩起这种任务呢?观于借鉴于历史以应付事实导致失败者之多,无疑的是不能的。其失败的原因安在呢?列举起来,也可以有多端,其中最重要的,自然是偏重于政治的。翻开二十五史来一看(从前都说二十四史,这是清朝时候,功令上所定为正史的。民国时代,柯劭忞所著的《新元史》,业经奉徐世昌总统令,加入正史之中,所以现在该称二十五史了),所记的,全是些战争攻伐,在庙堂上的人所发的政令,以及这些人的传记世系。昔人称《左传》为相斫书;近代的人称二十四史为帝王的家谱,说虽过当,也不能谓其全无理由了。单看了这些事,能明白社会的所以然么?从前的历史,为什么会有这种毛病呢?这是由于历史是文明时代之物,而在文明时代,国家业已出现,并成为活动的中心,常人只从表面上看,就认为政治可以概括一切,至少是社会现象中最重要的一项了。其实政治只是表面上的事情。政治的活动,全靠社会做根底。社会,实在政治的背后,做了无数更广大更根本的事情。不明白社会,是断不能明白政治的。所以现在讲历史的人,都不但着重于政治,而要着重于文化。\n●史记 司马迁著,文笔优美,生动翔实,是我国正史的开山之作\n何谓文化?向来狭义的解释,只指学术技艺而言,其为不当,自无待论。说得广的,又把一切人为的事,都包括于文化之中,然则动物何以没有文化呢?须知文化,正是人之所以异于动物的。其异点安在呢?凡动物,多能对外界的刺激而起反应,亦多能与外界相调适。然其与外界相调适,大抵出于本能,其力量极有限,而且永远不过如此。人则不然。所以人所处的世界,与动物所处的世界,大不相同。人之所以能如此,(一)由其有特异的脑筋,能想出种种法子;(二)而其手和足的全然分开,能制造种种工具,以遂行其计划;(三)又有语言以互相交通,而其扩大的即为文字。此人之所知,所能,可以传之于彼;前人之所知,所能,并可以传之于后。因而人的工作,不是个个从头做起的,乃是互相接续着做的。不像赛跑的人,从同一地点出发,却像驿站上的驿夫,一个个连接着,向目的地进行。其所走的路线自然长,而后人所达到的,自非前人所能知了。然则文化,是因人有特异的禀赋,良好的交通工具,所成就的控制环境的共业。动物也有进化的,但它的进化,除非改变其机体,以求与外界相适应,这是要靠遗传上变异淘汰等作用,才能达到其目的的,自然非常迟慢。人则只须改变其所用的工具,和其对付事物的方法。我们身体的构造,绝无以异于野蛮人,而其控制环境的成绩,却大不相同,即由其一为生物进化,一为文化进化之故。人类学上,证明自冰期以后,人的体质,无大变化。埃及的尸体解剖,亦证明其身体构造,与现今的人相同。可见人类的进化,全是文化进化。恒人每以文化状况,与民族能力,并为一谈,实在是一个重大的错误。遗传学家,论社会的进化,过于重视个体的先天能力,也不免为此等俗见所累。至于有意夸张种族能力的,那更不啻自承其所谓进化,将返于生物进化了。从理论上说,人的行为,也有许多来自机体,和动物无以异的,然亦无不披上文化的色彩。如饮食男女之事,即其最显明之例。所以在理论上,虽不能将人类一切行为,都称为文化行为,在事实上,则人类一切行为,几无不与文化有关系。可见文化范围的广大。能了解文化,自然就能了解社会了(人类的行为,源于机体的,只是能力。其如何发挥此能力,则全因文化而定其形式)。\n全世界的文化,到底是一元的?还是多元的?这个问题,还非今日所能解决。研究历史的人,即暂把这问题置诸不论不议之列亦得。因为目前分明放着多种不同的文化,有待于我们的各别研究。话虽如此说,研究一种文化的人,专埋头于这一种文化,而于其余的文化,概无所见,也是不对的。因为(一)各别的文化,其中仍有共同的原理存;(二)而世界上各种文化,交流互织,彼此互有关系,也确是事实。文化本是人类控制环境的工具,环境不同,文化自因之而异。及其兴起以后,因其能改造环境之故,愈使环境不同。人类遂在更不相同的环境中进化。其文化,自然也更不相同了。文化有传播的性质,这是毫无疑义的。此其原理,实因人类生而有求善之性(智)与相爱之情(仁),所以文化优的,常思推行其文化于文化相异之群,以冀改良其生活,共谋人类的幸福(其中固有自以为善而实不然的,强力推行,反致引起纠纷,甚或酿成大祸,宗教之传布,即其一例。但此自误于愚昧,不害其本意之善)。而其劣的,亦恒欣然接受(其深闭固拒的,皆别有原因,当视为例外)。这是世界上的文化所以交流互织的原因。而人类的本性,原是相同的。所以在相类的环境中,能有相类的文化。即使环境不同,亦只能改变其形式,而不能改变其原理(正因原理之同,形式不能不异;即因形式之异,可见原理之同,昔人夏葛冬裘之喻最妙)。此又不同的文化,所以有共同原理的原因。以理言之如此。以事实言,则自塞趋通,殆为进化无疑的轨辙。试观我国,自古代林立的部族,进而为较大的国家;再进而为更大的国家;再进而臻于统一;更进而与域外交通,开疆拓土,同化异民族,无非受这原理的支配。转观外国的历史,亦系如此。今者世界大通,前此各别的文化,当合流而生一新文化,更是毫无疑义的了。然则一提起文化,就该是世界的文化,而世界各国的历史,亦将可融合为一。为什么又有所谓国别史,以研究各别的文化呢?这是因为研究的方法,要合之而见其大,必先分之而致其精。况且研究的人,各有其立场。居中国而言中国,欲策将来的进步,自必先了解既往的情形。即以迎受外来的文化而论,亦必有其预备条件。不先明白自己的情形,是无从定其迎拒的方针的。所以我们在今日,欲了解中国史,固非兼通外国史不行,而中国史亦自有其特殊研究的必要。\n人类以往的社会,似乎是一动一静的。我们试看,任何一个社会,在以往,大都有个突飞猛进的时期。隔着一个时期,就停滞不进了。再阅若干时,又可以突飞猛进起来。已而复归于停滞。如此更互不已。这是什么理由?解释的人,说节奏是人生的定律。个人如此,社会亦然。只能在遇见困难时,奋起而图功,到认为满足时,就要停滞下来了。社会在这时期就会本身无所发明;对于外来的,亦非消极的不肯接受,即积极地加以抗拒。世界是无一息不变的(不论自然的和人为的,都系如此)。人,因其感觉迟钝,或虽有感觉,而行为濡滞之故,非到外界变动,积微成著,使其感觉困难时,不肯加以理会,设法应付。正和我们住的屋子,非到除夕,不肯加以扫除,以致尘埃堆积,扫除时不得不大费其力一样。这是世界所以一治一乱的真原因。倘使当其渐变之时,随时加以审察,加以修正,自然不至于此了。人之所以不能如此,昔时的人,都以为这是限于一动一静的定律,无可如何的。我则以为不然。这种说法,是由于把机体所生的现象,和超机现象,并为一谈,致有此误。须知就一个人而论,劳动之后,需要休息若干时;少年好动,老年好静,都是无可如何之事。社会则不然。个体有老少之殊,而社会无之。个体活动之后,必继之以休息,社会则可以这一部分动,那一部分静。然则人因限于机体之故,对于外界,不能自强不息地为不断的应付,正可藉社会的协力,以弥补其缺憾。然则从前感觉的迟钝,行为的濡滞,只是社会的病态(如因教育制度不良,致社会中人,不知远虑,不能预烛祸患;又如因阶级对立尖锐,致寄生阶级不顾大局的利害,不愿改革等,都只可说是社会的病态)。我们能矫正其病态,一治一乱的现象,自然可以不复存,而世界遂臻于郅治了。这是我们研究历史的人最大的希望。\n马端临的《文献通考·序》,把历史上的事实分为两大类:一为理乱兴亡,一为典章经制。这种说法,颇可代表从前史学家的见解。一部二十五史,拆开来,所谓纪传,大部分是记载理乱兴亡一类的事实的,志则以记载典章经制为主(表二者都有)理乱兴亡一类的事实,是随时发生的,今天不能逆料明天。典章经制,则为人预设之以待将来的,其性质较为持久。所以前者可称为动的史实,后者可称为静的史实。史实确乎不外这两类,但限其范围于政治以内,则未免太狭了。须知文化的范围,广大无边。两间的现象,除(一)属于自然的;(二)或虽出于生物,而纯导原于机体的,一切都当包括在内。它综合有形无形的事物,不但限制人的行为,而且陶铸人的思想。在一种文化中的人,其所作所为,断不能出于这个文化模式以外,所以要讲文化史,非把昔时的史料,大加扩充不可。教育部所定大学课程草案,各学院共同必修科,本有文化史而无通史。后又改为通史,而注明当注重于文化。大约因为政治的现象,亦不可略,怕改为文化史之后,讲授的人全忽略了政治事项之故,用意固甚周详。然大学的中国通史,讲授的时间,实在不多。若其编制仍与中学以下同,所讲授者,势必不免于重复。所以我现在,换一个体例。先就文化现象,分篇叙述,然后按时代加以综合。我这一部书,取材颇经拣择,说明亦力求显豁。颇希望读了的人,对于中国历史上重要的文化现象,略有所知,因而略知现状的所以然;对于前途,可以预加推测;因而对于我们的行为,可以有所启示。以我之浅学,而所希望者如此,自不免操豚蹄而祸篝车之诮,但总是我的一个希望罢了。\n"},{"id":165,"href":"/zh/docs/technology/Markdown/_SuperTutorial_/","title":"Markdown超级教程","section":"技术","content":" 全篇转载自 https://forum-zh.obsidian.md/t/topic/435\n作者obsidian论坛名-yikelee 成雙醬,感谢作者无私分享!\n"},{"id":166,"href":"/zh/docs/test/mytest/","title":"test","section":"测试","content":"askdfjkasjdf asdfkajskdf asdfjaskdfj sdk 哈哈哈这是要给测试 as jd afk\nsdafkjasjdfkjk\nfdasfdft.\n"},{"id":167,"href":"/zh/docs/technology/Linux/_TheLinuxCommandsHandbook_/","title":"_TheLinuxCommandsHandbook_","section":"Linux","content":" 《TheLinuxCommandsHandbook》结合视频 https://www.youtube.com/watch?v=ZtqBQ68cfJc 看的\nCommand意义 # 更快,自动化,在任何Linux上工作,有些工作的基本需求\n系统-Unix和Windows # 绿色-开源\n红色-闭源\n黄色-混合\n图片中的Linux只是类Unix,而不是真正的Unix\nFreeSoftware,开源 # GNU与Linux\nLinux只是一个操作系统内核而已,而GNU提供了大量的自由软件来丰富在其之上各种应用程序。\n绝大多数基于Linux内核的操作系统使用了大量的GNU软件,包括了一个shell程序、工具、程序库、编译器及工具,还有许多其他程序 我们常说的Linux,准确地来讲,应该是叫“GNU/Linux”。虽然,我们没有为GNU和Linux的开发做出什么贡献,但是我们可以为GNU和Linux的宣传和应用做出微薄的努力,至少我们能够准确地去向其他人解释清楚GNU、Linux以及GNU/Linux之间的区别。让我们一起为GNU/Linux的推广贡献出自己的力量!\n内核,用来连接硬件和软件的\nTrueUNIX # Unix一开始是收费的,后面出现Unix-like(类Unix),和Unix标准兼容。\nLinux不是真正的Unix,而是类Unix。\nLinux本身只是一个内核,连接硬件和软件\nLinuxDistributions,Linux发行版(1000多种)\nLinux内核是一些GUN工具,文档,包管理器,桌面环境窗口管理,和系统一些其他东西组成的一个系统\n有开源的和不开源的,Linux(LinuxGUN)完全开源\nshell # windows(powershell)\n把命令交给系统\nterminal(最古老时是一个硬件)\u0026ndash;屏幕+带键盘的物理设备,如今是一个软件\n默认情况下,Ubuntu和大多数Linux发行版是bashshell,还有zsh\nsetup and installing # 如果有Mac或者其他Linux发行版,则不需要额外操作。(作者在Mac里装了Ubuntu虚拟机)\nWindowsSubsystem # wsl --install\n默认是Ubuntu\nThe Linux Handbook(电子书内容) # Linux 手册\nPreface # 前言\nThe Linux Handbook follows the 80/20 rule: learn in 20% of the time the 80% of a topic.\nLinux 手册遵循 80/20 规则:用 20% 的时间学习某个主题的 80%。\nIn particular, the goal is to get you up to speed quickly with Linux.\n具体来说,我们的目标是让您快速熟悉 Linux。\nThis book is written by Flavio. I publish programming tutorials on my blog flaviocopes.com and I organize a yearly bootcamp at bootcamp.dev.\n这本书的作者是弗拉维奥。我在博客 flaviocopes.com上发布编程教程,并在 bootcamp.dev组织年度训练营。\nYou can reach me on Twitter @flaviocopes.\n您可以通过 Twitter @flaviocopes联系我。\nEnjoy!\n享受!\nIntroduction to Linux # Linux简介\nLinux is an operating system, like macOS or Windows.\nLinux 是一个操作系统,就像 macOS 或 Windows 一样。\nIt is also the most popular Open Source and free, as in freedom, operating system.\n它也是最流行的开源和免费操作系统。\nIt powers the vast majority of the servers that compose the Internet. It\u0026rsquo;s the base upon which everything is built upon. But not just that. Android is based on amodifiedversionofa modified version of Linux.\n它为组成互联网的绝大多数服务器提供动力。它是一切事物建立的基础。但不仅如此。 Android 基于 Linux(的修改版本)。\nThe Linux \u0026ldquo;core\u0026rdquo; called∗kernel∗called *kernel* was born in 1991 in Finland, and it went a really long way from its humble beginnings. It went on to be the kernel of the GNU Operating System, creating the duo GNU/Linux.\nLinux“核心”(称为kernel )于 1991 年在芬兰诞生,从最初的卑微开始,它已经走过了漫长的道路。它后来成为 GNU 操作系统的内核,创造了 GNU/Linux 双核。\nThere\u0026rsquo;s one thing about Linux that corporations like Microsoft and Apple, or Google, will never be able to offer: the freedom to do whatever you want with your computer.\nLinux 有一点是 Microsoft、Apple 或 Google 等公司永远无法提供的:用计算机做任何你想做的事情的自由。\nThey\u0026rsquo;re actually going in the opposite direction, building walled gardens, especially on the mobile side.\n他们实际上正在朝相反的方向前进,建造围墙花园,尤其是在移动端。\nLinux is the ultimate freedom.\nLinux 是终极的自由。\nIt is developed by volunteers, some paid by companies that rely on it, some independently, but there\u0026rsquo;s no single commercial company that can dictate what goes into Linux, or the project priorities.\n它是由志愿者开发的,有些是由依赖它的公司付费的,有些是独立开发的,但没有任何一家商业公司可以决定 Linux 的内容或项目的优先级。\nLinux can also be used as your day to day computer. I use macOS because I really enjoy the applications, the design and I also used to be an iOS and Mac apps developer, but before using it I used Linux as my main computer Operating System.\nLinux 也可以用作您的日常计算机。我使用 macOS 是因为我真的很喜欢它的应用程序和设计,而且我也曾经是一名 iOS 和 Mac 应用程序开发人员,但在使用它之前我使用 Linux 作为我的主要计算机操作系统。\nNo one can dictate which apps you can run, or \u0026ldquo;call home\u0026rdquo; with apps that track you, your position, and more.\n没有人可以规定您可以运行哪些应用程序,或者使用跟踪您、您的位置等的应用程序“打电话回家”。\nLinux is also special because there\u0026rsquo;s not just \u0026ldquo;one Linux\u0026rdquo;, like it happens on Windows or macOS. Instead, we have distributions.\nLinux 也很特别,因为它不像 Windows 或 macOS 那样只有“一个 Linux”。相反,我们有发行版。\nA \u0026ldquo;distro\u0026rdquo; is made by a company or organization and packages the Linux core with additional programs and tooling.\n“发行版”由公司或组织制作,并将 Linux 核心与附加程序和工具打包在一起。\nFor example you have Debian, Red Hat, and Ubuntu, probably the most popular.\n例如,您有 Debian、Red Hat 和 Ubuntu,它们可能是最受欢迎的。\nMany, many more exist. You can create your own distribution, too. But most likely you\u0026rsquo;ll use a popular one, one that has lots of users and a community of people around it, so you can do what you need to do without losing too much time reinventing the wheel and figuring out answers to common problems.\n还存在很多很多。您也可以创建自己的发行版。但您很可能会使用一种流行的产品,它拥有大量用户和周围的人员社区,因此您可以做您需要做的事情,而不会浪费太多时间重新发明轮子并找出常见问题的答案。\nSome desktop computers and laptops ship with Linux preinstalled. Or you can install it on your Windows-based computer, or on a Mac.\n一些台式计算机和笔记本电脑预装了 Linux。或者您可以将其安装在基于 Windows 的计算机或 Mac 上。\nBut you don\u0026rsquo;t need to disrupt your existing computer just to get an idea of how Linux works.\n但您不需要仅仅为了了解 Linux 的工作原理而破坏现有的计算机。\nI don\u0026rsquo;t have a Linux computer.\n我没有 Linux 计算机。\nIf you use a Mac you need to know that under the hood macOS is a UNIX Operating System, and it shares a lot of the same ideas and software that a GNU/Linux system uses, because GNU/Linux is a free alternative to UNIX.\n如果您使用 Mac,您需要知道 macOS 在本质上是一个 UNIX 操作系统,它与 GNU/Linux 系统使用许多相同的想法和软件,因为 GNU/Linux 是 UNIX 的免费替代品。\nUNIX is an umbrella term that groups many operating systems used in big corporations and institutions, starting from the 70\u0026rsquo;s\nUNIX是一个涵盖性术语,涵盖了从 70 年代开始在大公司和机构中使用的许多操作系统\nThe macOS terminal gives you access to the same exact commands I\u0026rsquo;ll describe in the rest of this handbook.\nmacOS 终端使您可以访问我将在本手册的其余部分中描述的相同命令。\nMicrosoft has an official Windows Subsystem for Linux which you can andshould ⁣and should\\! install on Windows. This will give you the ability to run Linux in a very easy way on your PC.\nMicrosoft 有一个 适用于 Linux 的官方 Windows 子系统,您可以(并且应该!)将其安装在 Windows 上。这将使您能够在 PC 上以非常简单的方式运行 Linux。\nBut the vast majority of the time you will run a Linux computer in the cloud via a VPS VirtualPrivateServerVirtual Private Server like DigitalOcean.\n但绝大多数时候,您将通过 DigitalOcean 等 VPS(虚拟专用服务器)在云中运行 Linux 计算机。\nA shell is a command interpreter that exposes to the user an interface to work with the underlying operating system.\nshell 是一个命令解释器,它向用户公开一个与底层操作系统一起工作的界面。\nIt allows you to execute operations using text and commands, and it provides users advanced features like being able to create scripts.\n它允许您使用文本和命令执行操作,并为用户提供高级功能,例如能够创建脚本。\nThis is important: shells let you perform things in a more optimized way than a GUI GraphicalUserInterfaceGraphical User Interface could ever possibly let you do. Command line tools can offer many different configuration options without being too complex to use.\n这很重要:shell 可以让您以比 GUI(图形用户界面)更优化的方式执行操作。命令行工具可以提供许多不同的配置选项,而且不会太复杂而无法使用。\nThere are many different kind of shells. This post focuses on Unix shells, the ones that you will find commonly on Linux and macOS computers.\n有许多不同种类的贝壳。本文重点介绍 Unix shell,即 Linux 和 macOS 计算机上常见的 shell。\nMany different kind of shells were created for those systems over time, and a few of them dominate the space: Bash, Csh, Zsh, Fish and many more!\n随着时间的推移,为这些系统创建了许多不同类型的 shell,其中一些占据了主导地位:Bash、Csh、Zsh、Fish 等等!\nAll shells originate from the Bourne Shell, called sh. \u0026ldquo;Bourne\u0026rdquo; because its creator was Steve Bourne.\n所有 shell 都源自 Bourne Shell,称为sh 。 “伯恩”是因为它的创造者是史蒂夫·伯恩。\nBash means Bourne-again shell. sh was proprietary and not open source, and Bash was created in 1989 to create a free alternative for the GNU project and the Free Software Foundation. Since projects had to pay to use the Bourne shell, Bash became very popular.\nBash 的意思是Bourne-again shell 。 sh是专有的而不是开源的,Bash 于 1989 年创建,旨在为 GNU 项目和自由软件基金会创建免费的替代方案。由于项目必须付费才能使用 Bourne shell,因此 Bash 变得非常流行。\nIf you use a Mac, try opening your Mac terminal. That by default is running ZSH. or,pre−Catalina,Bashor, pre-Catalina, Bash 如果您使用 Mac,请尝试打开 Mac 终端。默认情况下运行的是 ZSH。 (或者,Catalina 之前的 Bash)\nYou can set up your system to run any kind of shell, for example I use the Fish shell.\n您可以将系统设置为运行任何类型的 shell,例如我使用 Fish shell。\nEach single shell has its own unique features and advanced usage, but they all share a common functionality: they can let you execute programs, and they can be programmed.\n每个 shell 都有自己独特的功能和高级用法,但它们都有一个共同的功能:它们可以让您执行程序,并且可以进行编程。\nIn the rest of this handbook we\u0026rsquo;ll see in detail the most common commands you will use.\n在本手册的其余部分,我们将详细介绍您将使用的最常用命令。\nman # The first command I want to introduce is a command that will help you understand all the other commands.\n我想介绍的第一个命令将帮助您理解所有其他命令。\nEvery time I don\u0026rsquo;t know how to use a command, I type man \u0026lt;command\u0026gt; to get the manual:\n每次我不知道如何使用命令时,我都会输入man \u0026lt;command\u0026gt;来获取手册:\nThis is a man from∗manual∗from *manual* page. Man pages are an essential tool to learn, as a developer. They contain so much information that sometimes it\u0026rsquo;s almost too much.\n这是一个 man(来自手册)页面。手册页是开发人员学习的重要工具。它们包含太多信息,有时几乎太多了。\nThe above screenshot is just 1 of 14 screens of explanation for the ls command.\n上面的屏幕截图只是ls命令解释的 14 个屏幕之一。\nMost of the times when I\u0026rsquo;m in need to learn a command quickly I use this site called tldr pages: https://tldr.sh/. It\u0026rsquo;s a command you can install, then you run it like this: tldr \u0026lt;command\u0026gt;, which gives you a very quick overview of a command, with some handy examples of common usage scenarios:\n大多数时候,当我需要快速学习命令时,我会使用这个名为tldr 页面的网站: https://tldr.sh/ 。这是一个可以安装的命令,然后像这样运行它: tldr \u0026lt;command\u0026gt; ,它可以让您快速了解命令,并提供一些常见使用场景的方便示例:\nThis is not a substitute for man, but a handy tool to avoid losing yourself in the huge amount of information present in a man page. Then you can use the man page to explore all the different options and parameters you can use on a command.\n这并不是man的替代品,而是一个方便的工具,可以避免在手册页中的大量信息中迷失方向。然后,您可以使用手册页来探索可在命令上使用的所有不同选项和参数。\nls # Inside a folder you can list all the files that the folder contains using the ls command:\n在文件夹内,您可以使用ls命令列出该文件夹包含的所有文件:\nls If you add a folder name or path, it will print that folder contents:\n如果您添加文件夹名称或路径,它将打印该文件夹内容:\nls /bin ls accepts a lot of options. One of my favorite options combinations is -al. Try it:\nls接受很多选项。我最喜欢的选项组合之一是-al 。尝试一下:\nls -al /bin compared to the plain ls, this returns much more information.\n与普通的ls相比,这会返回更多信息。\nYou have, from left to right:\n你有,从左到右:\nthe file permissions andifyoursystemsupportsACLs,yougetanACLflagaswelland if your system supports ACLs, you get an ACL flag as well 文件权限(如果您的系统支持 ACL,您也会获得 ACL 标志) the number of links to that file\n该文件的链接数 the owner of the file\n文件的所有者 the group of the file\n文件组 the file size in bytes\n文件大小(以字节为单位) the file modified datetime\n文件修改日期时间 the file name\n文件名 This set of data is generated by the l option. The a option instead also shows the hidden files.\n这组数据是由l选项生成的。 a选项还显示隐藏文件。\nHidden files are files that start with a dot ‘.‘`.` .\n隐藏文件是以点 ‘.‘ `.` 开头的文件。\ncd # Once you have a folder, you can move into it using the cd command. cd means change directory. You invoke it specifying a folder to move into. You can specify a folder name, or an entire path.\n有了文件夹后,您可以使用cd命令进入该文件夹。 cd表示更改****目录。您可以调用它并指定要移入的文件夹。您可以指定文件夹名称或整个路径。\nExample:\n例子:\nmkdir fruits cd fruits Now you are into the fruits folder.\n现在您已进入fruits文件夹。\nYou can use the .. special path to indicate the parent folder:\n您可以使用..特殊路径来指示父文件夹:\ncd .. #back to the home folder The # character indicates the start of the comment, which lasts for the entire line after it\u0026rsquo;s found.\n# 字符表示注释的开始,它在找到后持续整行。\nYou can use it to form a path:\n您可以使用它来形成路径:\nmkdir fruits mkdir cars cd fruits cd ../cars There is another special path indicator which is ., and indicates the current folder.\n还有另一个特殊的路径指示器是. ,并表示当前文件夹。\nYou can also use absolute paths, which start from the root folder /:\n您还可以使用绝对路径,从根文件夹/开始:\ncd /etc This command works on Linux, macOS, WSL, and anywhere you have a UNIX environment\n此命令适用于 Linux、macOS、WSL 以及任何拥有 UNIX 环境的地方\npwd # Whenever you feel lost in the filesystem, call the pwd command to know where you are:\n每当您在文件系统中感到迷失时,请调用pwd命令来了解您所在的位置:\npwd It will print the current folder path.\n它将打印当前文件夹路径。\nmkdir # You create folders using the mkdir command:\n您可以使用mkdir命令创建文件夹:\nmkdir fruits You can create multiple folders with one command:\n您可以使用一个命令创建多个文件夹:\nmkdir dogs cars You can also create multiple nested folders by adding the -p option:\n您还可以通过添加-p选项来创建多个嵌套文件夹:\nmkdir -p fruits/apples Options in UNIX commands commonly take this form. You add them right after the command name, and they change how the command behaves. You can often combine multiple options, too.\nUNIX 命令中的选项通常采用这种形式。您可以将它们添加到命令名称之后,它们会更改命令的行为方式。您通常也可以组合多个选项。\nYou can find which options a command supports by typing man \u0026lt;commandname\u0026gt;. Try now with man mkdir for example pressthe‘q‘keytoescthemanpagepress the `q` key to esc the man page . Man pages are the amazing built-in help for UNIX.\n您可以通过键入man \u0026lt;commandname\u0026gt;来查找命令支持哪些选项。例如,现在尝试使用man mkdir (按q键退出手册页)。手册页是 UNIX 令人惊叹的内置帮助。\nrmdir # Just as you can create a folder using mkdir, you can delete a folder using rmdir:\n正如您可以使用mkdir创建文件夹一样,您可以使用rmdir删除文件夹:\nmkdir fruits rmdir fruits You can also delete multiple folders at once:\n您还可以一次删除多个文件夹:\nmkdir fruits cars rmdir fruits cars The folder you delete must be empty.\n您删除的文件夹必须是空的。\nTo delete folders with files in them, we\u0026rsquo;ll use the more generic rm command which deletes files and folders, using the -rf options:\n要删除其中包含文件的文件夹,我们将使用更通用的rm命令来删除文件和文件夹,并使用-rf选项:\nrm -rf fruits cars Be careful as this command does not ask for confirmation and it will immediately remove anything you ask it to remove.\n请小心,因为此命令不会要求确认,并且它会立即删除您要求其删除的任何内容。\nThere is no bin when removing files from the command line, and recovering lost files can be hard.\n从命令行删除文件时没有bin ,并且恢复丢失的文件可能很困难。\nmv # Once you have a file, you can move it around using the mv command. You specify the file current path, and its new path:\n一旦有了文件,就可以使用mv命令移动它。您指定文件的当前路径及其新路径:\ntouch pear mv pear new_pear The pear file is now moved to new_pear. This is how you rename files and folders.\npear文件现在已移至new_pear 。这就是重命名文件和文件夹的方法。\nIf the last parameter is a folder, the file located at the first parameter path is going to be moved into that folder. In this case, you can specify a list of files and they will all be moved in the folder path identified by the last parameter:\n如果最后一个参数是文件夹,则位于第一个参数路径的文件将被移动到该文件夹​​中。在这种情况下,您可以指定文件列表,它们将全部移动到最后一个参数标识的文件夹路径中:\ntouch pear touch apple mkdir fruits mv pear apple fruits #pear and apple moved to the fruits folder cp # You can copy a file using the cp command:\n您可以使用cp命令复制文件:\ntouch test cp apple another_apple To copy folders you need to add the -r option to recursively copy the whole folder contents:\n要复制文件夹,您需要添加-r选项以递归复制整个文件夹内容:\nmkdir fruits cp -r fruits cars open # The open command lets you open a file using this syntax:\nopen命令允许您使用以下语法打开文件:\nopen \u0026lt;filename\u0026gt; You can also open a directory, which on macOS opens the Finder app with the current directory open:\n您还可以打开一个目录,在 macOS 上,该目录会打开 Finder 应用程序并打开当前目录:\nopen \u0026lt;directory name\u0026gt; I use it all the time to open the current directory:\n我一直用它来打开当前目录:\nopen . The special . symbol points to the current directory, as .. points to the parent directory\n特别的.符号指向当前目录,as ..指向父目录\nThe same command can also be be used to run an application:\n相同的命令也可用于运行应用程序:\nopen \u0026lt;application name\u0026gt; touch # You can create an empty file using the touch command:\n您可以使用touch命令创建一个空文件:\ntouch apple If the file already exists, it opens the file in write mode, and the timestamp of the file is updated.\n如果文件已存在,则以写入模式打开文件,并更新文件的时间戳。\nfind # The find command can be used to find files or folders matching a particular search pattern. It searches recursively.\nfind命令可用于查找与特定搜索模式匹配的文件或文件夹。它递归地搜索。\nLet\u0026rsquo;s learn it by example.\n让我们通过例子来学习一下。\nFind all the files under the current tree that have the .js extension and print the relative path of each file matching:\n查找当前树下所有具有.js扩展名的文件,并打印每个匹配文件的相对路径:\nfind . -name '*.js' It\u0026rsquo;s important to use quotes around special characters like * to avoid the shell interpreting them.\n在*等特殊字符周围使用引号很重要,以避免 shell 解释它们。\nFind directories under the current tree matching the name \u0026ldquo;src\u0026rdquo;:\n在当前树下查找与名称“src”匹配的目录:\nfind . - type d -name src Use -type f to search only files, or -type l to only search symbolic links.\n使用-type f仅搜索文件,或使用-type l仅搜索符号链接。\n-name is case sensitive. use -iname to perform a case-insensitive search.\n-name区分大小写。使用-iname执行不区分大小写的搜索。\nYou can search under multiple root trees:\n您可以在多个根树下搜索:\nfind folder1 folder2 -name filename.txt Find directories under the current tree matching the name \u0026ldquo;node_modules\u0026rdquo; or \u0026lsquo;public\u0026rsquo;:\n在当前树下查找与名称“node_modules”或“public”匹配的目录:\nfind . - type d -name node_modules -or -name public You can also exclude a path, using -not -path:\n您还可以使用-not -path排除路径:\nfind . - type d -name '*.md' -not -path 'node_modules/*' You can search files that have more than 100 characters bytesbytes in them:\n您可以搜索包含超过 100 个字符(字节)的文件:\nfind . - type f -size +100c Search files bigger than 100KB but smaller than 1MB:\n搜索大于 100KB 但小于 1MB 的文件:\nfind . - type f -size +100k -size -1M Search files edited more than 3 days ago\n搜索 3 天前编辑的文件\nfind . - type f -mtime +3 Search files edited in the last 24 hours\n搜索过去 24 小时内编辑的文件\nfind . - type f -mtime -1 You can delete all the files matching a search by adding the -delete option. This deletes all the files edited in the last 24 hours:\n您可以通过添加-delete选项来删除与搜索匹配的所有文件。这将删除过去 24 小时内编辑的所有文件:\nfind . - type f -mtime -1 -delete You can execute a command on each result of the search. In this example we run cat to print the file content:\n您可以对每个搜索结果执行命令。在此示例中,我们运行cat来打印文件内容:\nfind . - type f - exec cat {} \\; notice the terminating \\;. {} is filled with the file name at execution time.\n注意终止\\; 。 {}填充执行时的文件名。\nln # The ln command is part of the Linux file system commands.\nln命令是 Linux 文件系统命令的一部分。\nIt\u0026rsquo;s used to create links. What is a link? It\u0026rsquo;s like a pointer to another file. A file that points to another file. You might be familiar with Windows shortcuts. They\u0026rsquo;re similar.\n它用于创建链接。什么是链接?它就像一个指向另一个文件的指针。一个文件指向另一个文件。您可能熟悉 Windows 快捷方式。他们很相似。\nWe have 2 types of links: hard links and soft links.\n我们有两种类型的链接:硬链接和软链接。\nHard links are rarely used. They have a few limitations: you can\u0026rsquo;t link to directories, and you can\u0026rsquo;t link to external filesystems disksdisks .\n硬链接很少使用。它们有一些限制:您不能链接到目录,也不能链接到外部文件系统(磁盘)。\nA hard link is created using\n使用以下命令创建硬链接\nln \u0026lt;original\u0026gt; \u0026lt;link\u0026gt; For example, say you have a file called recipes.txt. You can create a hard link to it using:\n例如,假设您有一个名为recipes.txt 的文件。您可以使用以下方法创建指向它的硬链接:\nln recipes.txt newrecipes.txt The new hard link you created is indistinguishable from a regular file:\n您创建的新硬链接与常规文件没有区别:\nNow any time you edit any of those files, the content will be updated for both.\n现在,每当您编辑这些文件中的任何一个时,这两个文件的内容都会更新。\nIf you delete the original file, the link will still contain the original file content, as that\u0026rsquo;s not removed until there is one hard link pointing to it.\n如果您删除原始文件,该链接仍将包含原始文件内容,因为只有在有一个硬链接指向它时,该链接才会被删除。\nSoft links are different. They are more powerful as you can link to other filesystems and to directories, but when the original is removed, the link will be broken.\n软链接则不同。它们更强大,因为您可以链接到其他文件系统和目录,但是当删除原始文件系统和目录时,链接将被破坏。\nYou create soft links using the -s option of ln:\n您可以使用ln的-s选项创建软链接:\nln -s \u0026lt;original\u0026gt; \u0026lt;link\u0026gt; For example, say you have a file called recipes.txt. You can create a soft link to it using:\n例如,假设您有一个名为recipes.txt 的文件。您可以使用以下方法创建指向它的软链接:\nln -s recipes.txt newrecipes.txt In this case you can see there\u0026rsquo;s a special l flag when you list the file using ls -al, and the file name has a @ at the end, and it\u0026rsquo;s colored differently if you have colors enabled:\n在这种情况下,当您使用ls -al列出文件时,您可以看到有一个特殊的l标志,并且文件名末尾有一个@ ,如果启用了颜色,则其颜色会有所不同:\nNow if you delete the original file, the links will be broken, and the shell will tell you \u0026ldquo;No such file or directory\u0026rdquo; if you try to access it:\n现在,如果您删除原始文件,链接将被破坏,并且如果您尝试访问它,shell 会告诉您“没有这样的文件或目录”:\ngzip # You can compress a file using the gzip compression protocol named LZ77 using the gzip command.\n您可以使用gzip命令使用名为 LZ77的 gzip 压缩协议来压缩文件。\nHere\u0026rsquo;s the simplest usage:\n这是最简单的用法:\ngzip filename This will compress the file, and append a .gz extension to it. The original file is deleted. To prevent this, you can use the -c option and use output redirection to write the output to the filename.gz file:\n这将压缩该文件,并为其附加.gz扩展名。原始文件被删除。为了防止这种情况,您可以使用-c选项并使用输出重定向将输出写入filename.gz文件:\ngzip -c filename \u0026gt; filename.gz The -c option specifies that output will go to the standard output stream, leaving the original file intact\n-c选项指定输出将转到标准输出流,保持原始文件不变\nOr you can use the -k option:\n或者您可以使用-k选项:\ngzip -k filename There are various levels of compression. The more the compression, the longer it will take to compress anddecompressand decompress . Levels range from 1 fastest,worstcompressionfastest, worst compression to 9 slowest,bettercompressionslowest, better compression , and the default is 6.\n压缩有多种级别。压缩越多,压缩(和解压缩)所需的时间就越长。级别范围从 1(最快、最差压缩)到 9(最慢、更好压缩),默认值为 6。\nYou can choose a specific level with the -\u0026lt;NUMBER\u0026gt; option:\n您可以使用-\u0026lt;NUMBER\u0026gt;选项选择特定级别:\ngzip -1 filename You can compress multiple files by listing them:\n您可以通过列出多个文件来压缩它们:\ngzip filename1 filename2 You can compress all the files in a directory, recursively, using the -r option:\n您可以使用-r选项递归地压缩目录中的所有文件:\ngzip -r a_folder The -v option prints the compression percentage information. Here\u0026rsquo;s an example of it being used along with the -k keepkeep option:\n-v选项打印压缩百分比信息。下面是它与-k (保留)选项一起使用的示例:\ngzip can also be used to decompress a file, using the -d option:\ngzip还可以用于解压缩文件,使用-d选项:\ngzip -d filename.gz gunzip # The gunzip command is basically equivalent to the gzip command, except the -d option is always enabled by default.\ngunzip命令基本上等同于gzip命令,只是默认情况下始终启用-d选项。\nThe command can be invoked in this way:\n该命令可以通过以下方式调用:\ngunzip filename.gz This will gunzip and will remove the .gz extension, putting the result in the filename file. If that file exists, it will overwrite that.\n这将进行gunzip并删除.gz扩展名,将结果放入filename文件中。如果该文件存在,它将覆盖该文件。\nYou can extract to a different filename using output redirection using the -c option:\n您可以使用-c选项使用输出重定向来提取到不同的文件名:\ngunzip -c filename.gz \u0026gt; anotherfilename tar # The tar command is used to create an archive, grouping multiple files in a single file.\ntar命令用于创建存档,将多个文件分组到一个文件中。\nIts name comes from the past and means tape archive. Back when archives were stored on tapes.\n它的名字来源于过去,意思是磁带存档。回到档案存储在磁带上的时代。\nThis command creates an archive named archive.tar with the content of file1 and file2:\n此命令创建一个名为archive.tar的存档,其中包含file1和file2的内容:\ntar -cf archive.tar file1 file2 The c option stands for create. The f option is used to write to file the archive.\nc选项代表create 。 f选项用于写入存档。\nTo extract files from an archive in the current folder, use:\n要从当前文件夹中的存档中提取文件,请使用:\ntar -xf archive.tar the x option stands for extract\nx选项代表提取\nand to extract them to a specific directory, use:\n并将它们提取到特定目录,请使用:\ntar -xf archive.tar -C directory You can also just list the files contained in an archive:\n您还可以只列出存档中包含的文件:\ntar is often used to create a compressed archive, gzipping the archive.\ntar通常用于创建压缩档案,对档案进行 gzip 压缩。\nThis is done using the z option:\n这是使用z选项完成的:\ntar -czf archive.tar.gz file1 file2 This is just like creating a tar archive, and then running gzip on it.\n这就像创建一个 tar 存档,然后在其上运行gzip一样。\nTo unarchive a gzipped archive, you can use gunzip, or gzip -d, and then unarchive it, but tar -xf will recognize it\u0026rsquo;s a gzipped archive, and do it for you:\n要取消归档 gzip 压缩档案,您可以使用gunzip或gzip -d ,然后将其取消归档,但tar -xf会识别出它是 gzip 压缩档案,并为您执行此操作:\ntar -xf archive.tar.gz alias # It\u0026rsquo;s common to always run a program with a set of options you like using.\n总是使用一组您喜欢使用的选项来运行程序是很常见的。\nFor example, take the ls command. By default it prints very little information:\n例如,使用ls命令。默认情况下它打印很少的信息:\nwhile using the -al option it will print something more useful, including the file modification date, the size, the owner, and the permissions, also listing hidden files (files starting with a .:\n使用-al选项时,它将打印更有用的内容,包括文件修改日期、大小、所有者和权限,还列出隐藏文件(以.开头的文件:\nYou can create a new command, for example I like to call it ll, that is an alias to ls -al.\n您可以创建一个新命令,例如我喜欢将其称为ll ,这是ls -al的别名。\nYou do it in this way:\n你可以这样做:\nalias ll= 'ls -al' Once you do, you can call ll just like it was a regular UNIX command:\n完成后,您可以像调用常规 UNIX 命令一样调用ll :\nNow calling alias without any option will list the aliases defined:\n现在不带任何选项调用alias将列出定义的别名:\nThe alias will work until the terminal session is closed.\n该别名将一直有效,直到终端会话关闭。\nTo make it permanent, you need to add it to the shell configuration, which could be ~/.bashrc or ~/.profile or ~/.bash_profile if you use the Bash shell, depending on the use case.\n为了使其永久化,您需要将其添加到 shell 配置中,如果您使用 Bash shell,则可以是~/.bashrc或~/.profile或~/.bash_profile ,具体取决于用例。\nBe careful with quotes if you have variables in the command: using double quotes the variable is resolved at definition time, using single quotes it\u0026rsquo;s resolved at invocation time. Those 2 are different:\n如果命令中有变量,请小心使用引号:使用双引号,变量在定义时解析,使用单引号,变量在调用时解析。这两个是不同的:\nalias lsthis= \u0026quot;ls $PWD \u0026quot; alias lscurrent= 'ls $PWD' $PWD refers to the current folder the shell is into. If you now navigate away to a new folder, lscurrent lists the files in the new folder, lsthis still lists the files in the folder you were when you defined the alias.\n$PWD 指 shell 所在的当前文件夹。如果您现在导航到新文件夹, lscurrent会列出新文件夹中的文件, lsthis仍会列出您定义别名时所在文件夹中的文件。\ncat # Similar to tail in some way, we have cat. Except cat can also add content to a file, and this makes it super powerful.\n在某种程度上与tail类似,我们有cat 。除了cat还可以向文件添加内容,这使得它超级强大。\nIn its simplest usage, cat prints a file\u0026rsquo;s content to the standard output:\n在最简单的用法中, cat将文件的内容打印到标准输出:\ncat file You can print the content of multiple files:\n您可以打印多个文件的内容:\ncat file1 file2 and using the output redirection operator \u0026gt; you can concatenate the content of multiple files into a new file:\n并使用输出重定向运算符\u0026gt;您可以将多个文件的内容连接到一个新文件中:\ncat file1 file2 \u0026gt; file3 Using \u0026gt;\u0026gt; you can append the content of multiple files into a new file, creating it if it does not exist:\n使用\u0026gt;\u0026gt;您可以将多个文件的内容附加到一个新文件中,如果它不存在则创建它:\ncat file1 file2 \u0026gt;\u0026gt; file3 When watching source code files it\u0026rsquo;s great to see the line numbers, and you can have cat print them using the -n option:\n当观看源代码文件时,很高兴看到行号,并且您可以使用-n选项让cat打印它们:\ncat -n file1 You can only add a number to non-blank lines using -b, or you can also remove all the multiple empty lines using -s.\n您只能使用-b将数字添加到非空行,也可以使用-s删除所有多个空行。\ncat is often used in combination with the pipe operator | to feed a file content as input to another command: cat file1 | anothercommand.\ncat通常与管道运算符|结合使用将文件内容作为另一个命令的输入: cat file1 | anothercommand 。\nless # The less command is one I use a lot. It shows you the content stored inside a file, in a nice and interactive UI.\nless命令是我经常使用的命令。它以漂亮的交互式用户界面向您显示文件中存储的内容。\nUsage: less \u0026lt;filename\u0026gt;.\n用法: less \u0026lt;filename\u0026gt; 。\nOnce you are inside a less session, you can quit by pressing q.\n一旦进入less会话,您可以按q退出。\nYou can navigate the file contents using the up and down keys, or using the space bar and b to navigate page by page. You can also jump to the end of the file pressing G and jump back to the start pressing g.\n您可以使用up和down键导航文件内容,或使用space bar和b逐页导航。您还可以按G跳转到文件末尾,然后按g跳回开头。\nYou can search contents inside the file by pressing / and typing a word to search. This searches forward. You can search backwards using the ? symbol and typing a word.\n您可以通过按/并键入要搜索的单词来搜索文件内的内容。这向前搜索。您可以使用?向后搜索符号并输入一个单词。\nThis command just visualises the file\u0026rsquo;s content. You can directly open an editor by pressing v. It will use the system editor, which in most cases is vim.\n该命令只是可视化文件的内容。您可以通过按v直接打开编辑器。它将使用系统编辑器,在大多数情况下是vim 。\nPressing the F key enters follow mode, or watch mode. When the file is changed by someone else, like from another program, you get to see the changes live. By default this is not happening, and you only see the file version at the time you opened it. You need to press ctrl-C to quit this mode. In this case the behaviour is similar to running the tail -f \u0026lt;filename\u0026gt; command.\n按F键进入跟随模式或监视模式。当其他人(例如从另一个程序)更改文件时,您可以实时看到更改。默认情况下,这种情况不会发生,您只能看到打开文件时的文件版本。您需要按ctrl-C退出此模式。在这种情况下,行为类似于运行tail -f \u0026lt;filename\u0026gt;命令。\nYou can open multiple files, and navigate through them using :n togotothenextfileto go to the next file and :p togotothepreviousto go to the previous .\n您可以打开多个文件,并使用:n (转到下一个文件)和:p (转到上一个文件)浏览它们。\ntail # The best use case of tail in my opinion is when called with the -f option. It opens the file at the end, and watches for file changes. Any time there is new content in the file, it is printed in the window. This is great for watching log files, for example:\n我认为 tail 的最佳用例是使用-f选项调用时。它在末尾打开文件,并监视文件更改。每当文件中有新内容时,都会将其打印在窗口中。这对于查看日志文件非常有用,例如:\ntail -f /var/ log /system.log To exit, press ctrl-C.\n要退出,请按ctrl-C 。\nYou can print the last 10 lines in a file:\n您可以打印文件中的最后 10 行:\ntail -n 10 \u0026lt;filename\u0026gt; You can print the whole file content starting from a specific line using + before the line number:\n您可以在行号之前使用+打印从特定行开始的整个文件内容:\ntail -n +10 \u0026lt;filename\u0026gt; tail can do much more and as always my advice is to check man tail.\ntail可以做更多事情,一如既往,我的建议是检查man tail 。\nwc # The wc command gives us useful information about a file or input it receives via pipes.\nwc命令为我们提供有关文件或通过管道接收的输入的有用信息。\necho test \u0026gt;\u0026gt; test.txt wc test.txt 1 1 5 test.txt Example via pipes, we can count the output of running the ls -al command:\n通过管道示例,我们可以计算运行ls -al命令的输出:\nls -al | wc 6 47 284 The first column returned is the number of lines. The second is the number of words. The third is the number of bytes.\n返回的第一列是行数。第二是字数。第三个是字节数。\nWe can tell it to just count the lines:\n我们可以告诉它只计算行数:\nwc -l test.txt or just the words:\n或者只是这样的话:\nwc -w test.txt or just the bytes:\n或者只是字节:\nwc -c test.txt Bytes in ASCII charsets equate to characters, but with non-ASCII charsets, the number of characters might differ because some characters might take multiple bytes, for example this happens in Unicode.\nASCII 字符集中的字节等同于字符,但对于非 ASCII 字符集,字符数可能会有所不同,因为某些字符可能占用多个字节,例如在 Unicode 中就会发生这种情况。\nIn this case the -m flag will help getting the correct value:\n在这种情况下, -m标志将有助于获取正确的值:\nwc -m test.txt grep # The grep command is a very useful tool, that when you master will help you tremendously in your day to day.\ngrep命令是一个非常有用的工具,当你掌握它时,它将对你的日常工作有很大帮助。\nIf you\u0026rsquo;re wondering, grep stands for global regular expression print\n如果您想知道, grep代表全局正则表达式打印\nYou can use grep to search in files, or combine it with pipes to filter the output of another command.\n您可以使用grep在文件中搜索,或将其与管道结合起来过滤另一个命令的输出。\nFor example here\u0026rsquo;s how we can find the occurences of the document.getElementById line in the index.md file:\n例如,我们如何在index.md文件中查找document.getElementById行的出现:\ngrep document.getElementById index.md Using the -n option it will show the line numbers:\n使用-n选项它将显示行号:\ngrep -n document.getElementById index.md One very useful thing is to tell grep to print 2 lines before, and 2 lines after the matched line, to give us more context. That\u0026rsquo;s done using the -C option, which accepts a number of lines:\n一件非常有用的事情是告诉 grep 在匹配行之前打印 2 行,在匹配行之后打印 2 行,以便为我们提供更多上下文。这是使用-C选项完成的,它接受多行:\ngrep -nC 2 document.getElementById index.md Search is case sensitive by default. Use the -i flag to make it insensitive.\n默认情况下,搜索区分大小写。使用-i标志使其不敏感。\nAs mentioned, you can use grep to filter the output of another command. We can replicate the same functionality as above using:\n如前所述,您可以使用 grep 来过滤另一个命令的输出。我们可以使用以下方法复制与上面相同的功能:\nless index.md | grep -n document.getElementById The search string can be a regular expression, and this makes grep very powerful.\n搜索字符串可以是正则表达式,这使得grep非常强大。\nAnother thing you might find very useful is to invert the result, excluding the lines that match a particular string, using the -v option:\n您可能会发现非常有用的另一件事是使用-v选项反转结果,排除与特定字符串匹配的行:\nsort # Suppose you have a text file which contains the names of dogs:\n假设您有一个包含狗的名字的文本文件:\nThis list is unordered.\n该列表是无序的。\nThe sort command helps us sorting them by name:\nsort命令帮助我们按名称对它们进行排序:\nUse the r option to reverse the order:\n使用r选项反转顺序:\nSorting by default is case sensitive, and alphabetic. Use the --ignore-case option to sort case insensitive, and the -n option to sort using a numeric order.\n默认情况下排序区分大小写并按字母顺序。使用--ignore-case选项不区分大小写进行排序,使用-n选项按数字顺序排序。\nIf the file contains duplicate lines:\n如果文件包含重复行:\nYou can use the -u option to remove them:\n您可以使用-u选项来删除它们:\nsort does not just works on files, as many UNIX commands it also works with pipes, so you can use on the output of another command, for example you can order the files returned by ls with:\nsort不仅仅适用于文件,因为许多 UNIX 命令也适用于管道,因此您可以在另一个命令的输出上使用,例如您可以使用以下命令对ls返回的文件进行排序:\nls | sort sort is very powerful and has lots more options, which you can explore calling man sort.\nsort非常强大,并且有更多选项,您可以调用man sort来探索。\nuniq # uniq is a command useful to sort lines of text.\nuniq是一个用于对文本行进行排序的命令。\nYou can get those lines from a file, or using pipes from the output of another command:\n您可以从文件中获取这些行,或使用管道从另一个命令的输出中获取这些行:\nuniq dogs.txt ls | uniq You need to consider this key thing: uniq will only detect adjacent duplicate lines.\n您需要考虑这一关键事项: uniq只会检测相邻的重复行。\nThis implies that you will most likely use it along with sort:\n这意味着您很可能将它与sort一起使用:\nsort dogs.txt | uniq The sort command has its own way to remove duplicates with the -u ∗unique∗*unique* option. But uniq has more power.\nsort命令有自己的方法来使用-u ∗unique∗ *unique* 选项删除重复项。但uniq的力量更大。\nBy default it removes duplicate lines:\n默认情况下它会删除重复的行:\nYou can tell it to only display duplicate lines, for example, with the -d option:\n您可以告诉它只显示重复的行,例如,使用-d选项:\nsort dogs.txt | uniq -d You can use the -u option to only display non-duplicate lines:\n您可以使用-u选项仅显示非重复行:\nYou can count the occurrences of each line with the -c option:\n您可以使用-c选项计算每行的出现次数:\nUse the special combination:\n使用特殊组合:\nsort dogs.txt | uniq -c | sort -nr to then sort those lines by most frequent:\n然后按最常见的顺序对这些行进行排序:\ndiff # diff is a handy command. Suppose you have 2 files, which contain almost the same information, but you can\u0026rsquo;t find the difference between the two.\ndiff是一个方便的命令。假设你有2个文件,它们包含几乎相同的信息,但你找不到两者之间的差异。\ndiff will process the files and will tell you what\u0026rsquo;s the difference.\ndiff将处理文件并告诉您有什么区别。\nSuppose you have 2 files: dogs.txt and moredogs.txt. The difference is that moredogs.txt contains one more dog name:\n假设您有 2 个文件: dogs.txt和moredogs.txt 。不同之处在于moredogs.txt多了一个狗的名字:\ndiff dogs.txt moredogs.txt will tell you the second file has one more line, line 3 with the line Vanille:\ndiff dogs.txt moredogs.txt会告诉你第二个文件还有一行,第 3 行带有Vanille行:\nIf you invert the order of the files, it will tell you that the second file is missing line 3, whose content is Vanille:\n如果你颠倒文件的顺序,它会告诉你第二个文件缺少第3行,其内容是Vanille :\nUsing the -y option will compare the 2 files line by line:\n使用-y选项将逐行比较两个文件:\nThe -u option however will be more familiar to you, because that\u0026rsquo;s the same used by the Git version control system to display differences between versions:\n不过,您会更熟悉-u选项,因为 Git 版本控制系统使用该选项来显示版本之间的差异:\nComparing directories works in the same way. You must use the -r option to compare recursively goingintosubdirectoriesgoing into subdirectories :\n比较目录的工作方式相同。您必须使用-r选项进行递归比较(进入子目录):\nIn case you\u0026rsquo;re interested in which files differ, rather than the content, use the r and q options:\n如果您对哪些文件不同而不是内容感兴趣,请使用r和q选项:\nThere are many more options you can explore in the man page running man diff:\n您可以在运行man diff手册页中探索更多选项:\necho # The echo command does one simple job: it prints to the output the argument passed to it.\necho命令执行一项简单的工作:它将传递给它的参数打印到输出。\nThis example:\n这个例子:\necho \u0026quot;hello\u0026quot; will print hello to the terminal.\n将向终端打印hello 。\nWe can append the output to a file:\n我们可以将输出附加到文件中:\necho \u0026quot;hello\u0026quot; \u0026gt;\u0026gt; output.txt We can interpolate environment variables:\n我们可以插入环境变量:\necho \u0026quot;The path variable is $PATH \u0026quot; Beware that special characters need to be escaped with a backslash \\. $ for example:\n请注意,特殊字符需要使用反斜杠\\进行转义。 $例如:\nThis is just the start. We can do some nice things when it comes to interacting with the shell features.\n这只是开始。在与 shell 功能交互时,我们可以做一些不错的事情。\nWe can echo the files in the current folder:\n我们可以回显当前文件夹中的文件:\necho * We can echo the files in the current folder that start with the letter o:\n我们可以回显当前文件夹中以字母o开头的文件:\necho o* Any valid Bash oranyshellyouareusingor any shell you are using command and feature can be used here.\n任何有效的 Bash(或您正在使用的任何 shell)命令和功能都可以在此处使用。\nYou can print your home folder path:\n您可以打印您的主文件夹路径:\necho ~ You can also execute commands, and print the result to the standard output ortofile,asyousawor to file, as you saw :\n您还可以执行命令,并将结果打印到标准输出(或打印到文件,如您所见):\necho $(ls -al) Note that whitespace is not preserved by default. You need to wrap the command in double quotes to do so:\n请注意,默认情况下不保留空格。您需要将命令用双引号括起来才能执行此操作:\nYou can generate a list of strings, for example ranges:\n您可以生成字符串列表,例如范围:\necho {1..5} chown # Every file/directory in an Operating System like Linux or macOS andeveryUNIXsystemsingeneraland every UNIX systems in general has an owner.\nLinux 或 macOS(以及一般的每个 UNIX 系统)等操作系统中的每个文件/目录都有一个所有者。\nThe owner of a file can do everything with it. It can decide the fate of that file.\n文件的所有者可以用它做任何事情。它可以决定该文件的命运。\nThe owner andthe‘root‘userand the `root` user can change the owner to another user, too, using the chown command:\n所有者(和root用户)也可以使用chown命令将所有者更改为其他用户:\nchown \u0026lt;owner\u0026gt; \u0026lt;file\u0026gt; Like this:\n像这样:\nchown flavio test.txt For example if you have a file that\u0026rsquo;s owned by root, you can\u0026rsquo;t write to it as another user:\n例如,如果您有一个归root所有的文件,则无法以其他用户身份写入该文件:\nYou can use chown to transfer the ownership to you:\n您可以使用chown将所有权转移给您:\nIt\u0026rsquo;s rather common to have the need to change the ownership of a directory, and recursively all the files contained, plus all the subdirectories and the files contained in them, too.\n需要更改目录的所有权以及递归地更改其中包含的所有文件以及所有子目录和其中包含的文件的所有权是相当常见的。\nYou can do so using the -R flag:\n您可以使用-R标志来执行此操作:\nchown -R \u0026lt;owner\u0026gt; \u0026lt;file\u0026gt; Files/directories don\u0026rsquo;t just have an owner, they also have a group. Through this command you can change that simultaneously while you change the owner:\n文件/目录不仅有一个所有者,它们还有一个组。通过此命令,您可以在更改所有者的同时更改它:\nchown \u0026lt;owner\u0026gt;:\u0026lt;group\u0026gt; \u0026lt;file\u0026gt; Example:\n例子:\nchown flavio:users test.txt You can also just change the group of a file using the chgrp command:\n您还可以使用chgrp命令更改文件组:\nchgrp \u0026lt;group\u0026gt; \u0026lt;filename\u0026gt; chmod # Every file in the Linux / macOS Operating Systems andUNIXsystemsingeneraland UNIX systems in general has 3 permissions: Read, write, execute.\nLinux / macOS 操作系统(以及一般的 UNIX 系统)中的每个文件都有 3 个权限:读、写、执行。\nGo into a folder, and run the ls -al command.\n进入文件夹,然后运行ls -al命令。\nThe weird strings you see on each file line, like drwxr-xr-x, define the permissions of the file or folder.\n您在每个文件行上看到的奇怪字符串(例如drwxr-xr-x )定义了文件或文件夹的权限。\nLet\u0026rsquo;s dissect it.\n我们来剖析一下。\nThe first letter indicates the type of file:\n第一个字母表示文件类型:\n- means it\u0026rsquo;s a normal file\n-表示这是一个普通文件 d means it\u0026rsquo;s a directory\nd表示它是一个目录 l means it\u0026rsquo;s a link\nl表示这是一个链接 Then you have 3 sets of values:\n然后你有 3 组值:\nThe first set represents the permissions of the owner of the file\n第一组代表文件所有者的权限 The second set represents the permissions of the members of the group the file is associated to\n第二组表示文件关联的组成员的权限 The third set represents the permissions of the everyone else\n第三组代表其他人的权限 Those sets are composed by 3 values. rwx means that specific persona has read, write and execution access. Anything that is removed is swapped with a -, which lets you form various combinations of values and relative permissions: rw-, r--, r-x, and so on.\n这些集合由 3 个值组成。 rwx表示特定角色具有读、写和执行访问权限。任何删除的内容都会与-交换,这使您可以形成值和相对权限的各种组合: rw- 、 r-- 、 rx等。\nYou can change the permissions given to a file using the chmod command.\n您可以使用chmod命令更改赋予文件的权限。\nchmod can be used in 2 ways. The first is using symbolic arguments, the second is using numeric arguments. Let\u0026rsquo;s start with symbols first, which is more intuitive.\nchmod有两种使用方式。第一个是使用符号参数,第二个是使用数字参数。我们先从符号开始,这样更直观。\nYou type chmod followed by a space, and a letter:\n您输入chmod后跟一个空格和一个字母:\na stands for all\na代表全部 u stands for user\nu代表用户 g stands for group\ng代表组 o stands for others\no代表其他 Then you type either + or - to add a permission, or to remove it. Then you enter one or more permissions symbols ‘r‘,‘w‘,‘x‘`r`, `w`, `x` .\n然后输入+或-来添加或删除权限。然后输入一个或多个权限符号( r 、 w 、 x )。\nAll followed by the file or folder name.\n全部后跟文件或文件夹名称。\nHere are some examples:\n以下是一些示例:\nchmod a+r filename #everyone can now read chmod a+rw filename #everyone can now read and write chmod o-rwx filename #others (not the owner, not in the same group of the file) cannot read, write or execute the file You can apply the same permissions to multiple personas by adding multiple letters before the +/-:\n您可以通过- +之前添加多个字母来将相同的权限应用于多个角色:\nchmod og-r filename #other and group can't read any more In case you are editing a folder, you can apply the permissions to every file contained in that folder using the -r recursiverecursive flag.\n如果您正在编辑文件夹,则可以使用-r (递归)标志将权限应用于该文件夹中包含的每个文件。\nNumeric arguments are faster but I find them hard to remember when you are not using them day to day. You use a digit that represents the permissions of the persona. This number value can be a maximum of 7, and it\u0026rsquo;s calculated in this way:\n数字参数速度更快,但我发现当你不每天使用它们时很难记住它们。您使用代表角色权限的数字。该数值最大可为 7,计算方法如下:\n1 if has execution permission\n1是否有执行权限 2 if has write permission\n2是否有写权限 4 if has read permission\n4是否有读权限 This gives us 4 combinations:\n这给了我们 4 种组合:\n0 no permissions\n0无权限 1 can execute\n1可以执行 2 can write\n2可以写 3 can write, execute\n3可以写入、执行 4 can read\n4可以阅读 5 can read, execute\n5可以读取、执行 6 can read, write\n6可以读、写 7 can read, write and execute\n7可以读、写、执行 We use them in pairs of 3, to set the permissions of all the 3 groups altogether:\n我们将它们 3 个成对使用,以总共设置所有 3 个组的权限:\nchmod 777 filename chmod 755 filename chmod 644 filename umask # When you create a file, you don\u0026rsquo;t have to decide permissions up front. Permissions have defaults.\n创建文件时,您不必预先决定权限。权限有默认值。\nThose defaults can be controlled and modified using the umask command.\n可以使用umask命令控制和修改这些默认值。\nTyping umask with no arguments will show you the current umask, in this case 0022:\n不带参数输入umask将显示当前的 umask,在本例中为0022 :\nWhat does 0022 mean? That\u0026rsquo;s an octal value that represent the permissions.\n0022是什么意思?这是代表权限的八进制值。\nAnother common value is 0002.\n另一个常见的值是0002 。\nUse umask -S to see a human-readable notation:\n使用umask -S查看人类可读的符号:\nIn this case, the user ‘u‘`u` , owner of the file, has read, write and execution permissions on files.\n在这种情况下,文件的所有者用户 ‘u‘ `u` 拥有文件的读、写和执行权限。\nOther users belonging to the same group ‘g‘`g` have read and execution permission, same as all the other users ‘o‘`o` .\n属于同一组的其他用户 ‘g‘ `g` 具有读取和执行权限,与所有其他用户 ‘o‘ `o` 相同。\nIn the numeric notation, we typically change the last 3 digits.\n在数字表示法中,我们通常更改最后 3 位数字。\nHere\u0026rsquo;s a list that gives a meaning to the number:\n这是一个给出数字含义的列表:\n0 read, write, execute\n0读、写、执行 1 read and write\n1读写 2 read and execute\n2读取并执行 3 read only\n3只读 4 write and execute\n4写入并执行 5 write only\n5只写 6 execute only\n6只执行 7 no permissions\n7无权限 Note that this numeric notation differs from the one we use in chmod.\n请注意,此数字表示法与我们在chmod中使用的数字表示法不同。\nWe can set a new value for the mask setting the value in numeric format:\n我们可以为掩码设置一个新值,以数字格式设置该值:\numask 002 or you can change a specific role\u0026rsquo;s permission:\n或者您可以更改特定角色的权限:\numask g+r du # The du command will calculate the size of a directory as a whole:\ndu命令将计算整个目录的大小:\ndu The 32 number here is a value expressed in bytes.\n这里的32数字是一个以字节表示的值。\nRunning du * will calculate the size of each file individually:\n运行du *将单独计算每个文件的大小:\nYou can set du to display values in MegaBytes using du -m, and GigaBytes using du -g.\n您可以使用du -m将du设置为以兆字节为单位显示值,并使用du -g将 du 设置为以千兆字节为单位显示值。\nThe -h option will show a human-readable notation for sizes, adapting to the size:\n-h选项将显示人类可读的大小符号,以适应大小:\nAdding the -a option will print the size of each file in the directories, too:\n添加-a选项也会打印目录中每个文件的大小:\nA handy thing is to sort the directories by size:\n一个方便的事情是按大小对目录进行排序:\ndu -h \u0026lt;directory\u0026gt; | sort -nr and then piping to head to only get the first 10 results:\n然后通过管道连接到head只获取前 10 个结果:\ndf # The df command is used to get disk usage information.\ndf命令用于获取磁盘使用信息。\nIts basic form will print information about the volumes mounted:\n其基本形式将打印有关已安装卷的信息:\nUsing the -h option ‘df−h‘`df -h` will show those values in a human-readable format:\n使用-h选项 ‘df−h‘ `df -h` 将以人类可读的格式显示这些值:\nYou can also specify a file or directory name to get information about the specific volume it lives on:\n您还可以指定文件或目录名称来获取有关其所在特定卷的信息:\nbasename # Suppose you have a path to a file, for example /Users/flavio/test.txt.\n假设您有一个文件的路径,例如/Users/flavio/test.txt 。\nRunning\n跑步\nbasename /Users/flavio/test.txt will return the test.txt string:\n将返回test.txt字符串:\nIf you run basename on a path string that points to a directory, you will get the last segment of the path. In this example, /Users/flavio is a directory:\n如果您在指向目录的路径字符串上运行basename ,您将获得路径的最后一段。在此示例中, /Users/flavio是一个目录:\ndirname # Suppose you have a path to a file, for example /Users/flavio/test.txt.\n假设您有一个文件的路径,例如/Users/flavio/test.txt 。\nRunning\n跑步\ndirname /Users/flavio/test.txt will return the /Users/flavio string:\n将返回/Users/flavio字符串:\nps # Your computer is running, at all times, tons of different processes.\n您的计算机始终运行着大量不同的进程。\nYou can inspect them all using the ps command:\n您可以使用ps命令检查它们:\nThis is the list of user-initiated processes currently running in the current session.\n这是当前会话中当前运行的用户启动进程的列表。\nHere I have a few fish shell instances, mostly opened by VS Code inside the editor, and an instances of Hugo running the development preview of a site.\n这里我有一些fish shell 实例,大部分是通过编辑器内的 VS Code 打开的,还有一个运行网站开发预览的 Hugo 实例。\nThose are just the commands assigned to the current user. To list all processes we need to pass some options to ps.\n这些只是分配给当前用户的命令。要列出所有进程,我们需要将一些选项传递给ps 。\nThe most common I use is ps ax:\n我最常用的是ps ax :\nThe a option is used to also list other users processes, not just our own. x shows processes not linked to any terminal notinitiatedbyusersthroughaterminalnot initiated by users through a terminal .\na选项还用于列出其他用户进程,而不仅仅是我们自己的进程。 x显示未链接到任何终端的进程(不是由用户通过终端启动的)。\nAs you can see, the longer commands are cut. Use the command ps axww to continue the command listing on a new line instead of cutting it:\n正如您所看到的,较长的命令被删除了。使用命令ps axww在新行上继续列出命令而不是剪切它:\nWe need to specify w 2 times to apply this setting, it\u0026rsquo;s not a typo.\n我们需要指定w 2 次才能应用此设置,这不是拼写错误。\nYou can search for a specific process combining grep with a pipe, like this:\n您可以将grep与管道结合起来搜索特定进程,如下所示:\nps axww | grep \u0026quot;Visual Studio Code\u0026quot; The columns returned by ps represent some key information.\nps返回的列代表一些关键信息。\nThe first information is PID, the process ID. This is key when you want to reference this process in another command, for example to kill it.\n第一个信息是PID ,即进程 ID。当您想在另一个命令中引用此进程(例如杀死它)时,这是关键。\nThen we have TT that tells us the terminal id used.\n然后我们有TT告诉我们所使用的终端 ID。\nThen STAT tells us the state of the process:\n然后STAT告诉我们进程的状态:\nI a process that is idle sleepingforlongerthanabout20secondssleeping for longer than about 20 seconds R a runnable process S a process that is sleeping for less than about 20 seconds T a stopped process U a process in uninterruptible wait Z a dead process a∗zombie∗a *zombie* I一个空闲进程(休眠时间超过约 20 秒) R一个可运行的进程 S睡眠时间少于 20 秒的进程 T已停止的进程 U进程处于不间断等待状态 Z死进程(僵尸)\nIf you have more than one letter, the second represents further information, which can be very technical.\n如果您有多个字母,第二个字母代表更多信息,这可能非常技术性。\nIt\u0026rsquo;s common to have + which indicates the process is in the foreground in its terminal. s means the process is a session leader.\n通常有+表示该进程位于终端的前台。 s表示该进程是 会话领导者。\nTIME tells us how long the process has been running.\nTIME告诉我们该进程已经运行了多长时间。\ntop # A quick guide to the top command, used to list the processes running in real time\ntop命令快速指南,用于列出实时运行的进程\nThe top command is used to display dynamic real-time information about running processes in the system.\ntop命令用于显示系统中正在运行的进程的动态实时信息。\nIt\u0026rsquo;s really handy to understand what is going on.\n了解正在发生的事情真的很方便。\nIts usage is simple, you just type top, and the terminal will be fully immersed in this new view:\n它的用法很简单,你只需输入top ,终端就会完全沉浸在这个新视图中:\nThe process is long-running. To quit, you can type the q letter or ctrl-C.\n该过程是长期运行的。要退出,您可以输入q字母或ctrl-C 。\nThere\u0026rsquo;s a lot of information being given to us: the number of processes, how many are running or sleeping, the system load, the CPU usage, and a lot more.\n我们获得了很多信息:进程数、正在运行或休眠的进程数、系统负载、CPU 使用率等等。\nBelow, the list of processes taking the most memory and CPU is constantly updated.\n下面,占用最多内存和 CPU 的进程列表不断更新。\nBy default, as you can see from the %CPU column highlighted, they are sorted by the CPU used.\n默认情况下,正如您从突出显示的%CPU列中看到的那样,它们按使用的 CPU 排序。\nYou can add a flag to sort processes by memory utilized:\n您可以添加一个标志来按内存使用情况对进程进行排序:\ntop -o mem kill # Linux processes can receive signals and react to them.\nLinux 进程可以接收信号并对信号做出反应。\nThat\u0026rsquo;s one way we can interact with running programs.\n这是我们与正在运行的程序交互的一种方式。\nThe kill program can send a variety of signals to a program.\nkill程序可以向程序发送各种信号。\nIt\u0026rsquo;s not just used to terminate a program, like the name would suggest, but that\u0026rsquo;s its main job.\n正如其名称所暗示的那样,它不仅仅用于终止程序,但这才是它的主要工作。\nWe use it in this way:\n我们这样使用它:\nkill \u0026lt;PID\u0026gt; By default, this sends the TERM signal to the process id specified.\n默认情况下,这会将TERM信号发送到指定的进程 ID。\nWe can use flags to send other signals, including:\n我们可以使用标志来发送其他信号,包括:\nkill -HUP \u0026lt;PID\u0026gt; kill -INT \u0026lt;PID\u0026gt; kill -KILL \u0026lt;PID\u0026gt; kill -TERM \u0026lt;PID\u0026gt; kill -CONT \u0026lt;PID\u0026gt; kill -STOP \u0026lt;PID\u0026gt; HUP means hang up. It\u0026rsquo;s sent automatically when a terminal window that started a process is closed before terminating the process.\nHUP意思是挂断。当启动进程的终端窗口在终止进程之前关闭时,它会自动发送。\nINT means interrupt, and it sends the same signal used when we press ctrl-C in the terminal, which usually terminates the process.\nINT表示中断,它发送的信号与我们在终端中按ctrl-C时使用的信号相同,这通常会终止进程。\nKILL is not sent to the process, but to the operating system kernel, which immediately stops and terminates the process.\nKILL不是发送给进程,而是发送给操作系统内核,操作系统内核会立即停止并终止进程。\nTERM means terminate. The process will receive it and terminate itself. It\u0026rsquo;s the default signal sent by kill.\nTERM意思是终止。该进程将收到它并自行终止。这是kill发送的默认信号。\nCONT means continue. It can be used to resume a stopped process.\nCONT表示继续。它可用于恢复停止的进程。\nSTOP is not sent to the process, but to the operating system kernel, which immediately stops butdoesnotterminatebut does not terminate the process.\nSTOP不会发送到进程,而是发送到操作系统内核,操作系统内核会立即停止(但不会终止)进程。\nYou might see numbers used instead, like kill -1 \u0026lt;PID\u0026gt;. In this case,\n您可能会看到使用数字,例如kill -1 \u0026lt;PID\u0026gt; 。在这种情况下,\n1 corresponds to HUP. 2 corresponds to INT. 9 corresponds to KILL. 15 corresponds to TERM. 18 corresponds to CONT. 15 corresponds to STOP.\n1对应于HUP 。 2对应于INT 。 9对应于KILL 。 15对应于TERM 。 18对应于CONT 。 15对应于STOP 。\nkillall # Similar to the kill command, killall instead of sending a signal to a specific process id will send the signal to multiple processes at once.\n与kill命令类似, killall不是向特定进程id发送信号,而是一次向多个进程发送信号。\nThis is the syntax:\n这是语法:\nkillall \u0026lt;name\u0026gt; where name is the name of a program. For example you can have multiple instances of the top program running, and killall top will terminate them all.\n其中name是程序的名称。例如,您可以运行多个top程序实例, killall top将终止它们。\nYou can specify the signal, like with kill andcheckthe‘kill‘tutorialtoreadmoreaboutthespecifickindsofsignalswecansendand check the `kill` tutorial to read more about the specific kinds of signals we can send , for example:\n您可以指定信号,就像使用kill一样(并查看kill教程以了解有关我们可以发送的特定类型信号的更多信息),例如:\nkillall -HUP top jobs # When we run a command in Linux / macOS, we can set it to run in the background using the \u0026amp; symbol after the command. For example we can run top in the background:\n当我们在Linux / macOS中运行命令时,我们可以使用命令后面的\u0026amp;符号将其设置为在后台运行。例如我们可以在后台运行top :\ntop \u0026amp; This is very handy for long-running programs.\n这对于长时间运行的程序非常方便。\nWe can get back to that program using the fg command. This works fine if we just have one job in the background, otherwise we need to use the job number: fg 1, fg 2 and so on. To get the job number, we use the jobs command.\n我们可以使用fg命令返回该程序。如果我们在后台只有一项作业,那么这很好用,否则我们需要使用作业编号: fg 1 、 fg 2等等。要获取作业编号,我们使用jobs命令。\nSay we run top \u0026amp; and then top -o mem \u0026amp;, so we have 2 top instances running. jobs will tell us this:\n假设我们运行top \u0026amp;然后运行top -o mem \u0026amp; ,所以我们有 2 个 top 实例正在运行。 jobs会告诉我们这一点:\nNow we can switch back to one of those using fg \u0026lt;jobid\u0026gt;. To stop the program again we can hit cmd-Z.\n现在我们可以切换回使用fg \u0026lt;jobid\u0026gt;的其中之一。要再次停止程序,我们可以点击cmd-Z 。\nRunning jobs -l will also print the process id of each job.\n运行jobs -l还将打印每个作业的进程 ID。\nbg # When a command is running you can suspend it using ctrl-Z.\n当命令正在运行时,您可以使用ctrl-Z暂停它。\nThe command will immediately stop, and you get back to the shell terminal.\n该命令将立即停止,您将返回到 shell 终端。\nYou can resume the execution of the command in the background, so it will keep running but it will not prevent you from doing other work in the terminal.\n您可以在后台恢复该命令的执行,因此它将继续运行,但不会阻止您在终端中执行其他工作。\nIn this example I have 2 commands stopped:\n在此示例中,我停止了 2 个命令:\nI can run bg 1 to resume in the background the execution of the job #1.\n我可以运行bg 1在后台恢复作业 #1 的执行。\nI could have also said bg without any option, as the default is to pick the job #1 in the list.\n我也可以说bg而不带任何选项,因为默认是选择列表中的作业#1。\nfg # When a command is running in the background, because you started it with \u0026amp; at the end (example: top \u0026amp; or because you put it in the background with the bg command, you can put it to the foreground using fg.\n当命令在后台运行时,因为您以\u0026amp;结尾(例如: top \u0026amp;或因为您使用bg命令将其置于后台,所以可以使用fg将其置于前台。\nRunning\n跑步\nfg will resume to the foreground the last job that was suspended.\n将恢复到前台上次暂停的作业。\nYou can also specify which job you want to resume to the foreground passing the job number, which you can get using the jobs command.\n您还可以通过作业编号指定要恢复到前台的作业,可以使用jobs命令获取作业编号。\nRunning fg 2 will resume job #2:\n运行fg 2将恢复作业#2:\ntype # A command can be one of those 4 types:\n命令可以是以下 4 种类型之一:\nan executable\n一个可执行文件 a shell built-in program\nshell 内置程序 a shell function\n一个外壳函数 an alias\n别名 The type command can help figure out this, in case we want to know or we\u0026rsquo;re just curious. It will tell you how the command will be interpreted.\ntype命令可以帮助弄清楚这一点,以防我们想知道或者只是好奇。它会告诉您如何解释该命令。\nThe output will depend on the shell used. This is Bash:\n输出将取决于所使用的 shell。这是巴什:\nThis is Zsh:\n这是 Zsh:\nThis is Fish:\n这是鱼:\nOne of the most interesting things here is that for aliases it will tell you what is aliasing to. You can see the ll alias, in the case of Bash and Zsh, but Fish provides it by default, so it will tell you it\u0026rsquo;s a built-in shell function.\n这里最有趣的事情之一是,对于别名,它会告诉您别名是什么。在 Bash 和 Zsh 中,您可以看到ll别名,但 Fish 默认提供它,因此它会告诉您这是一个内置的 shell 函数。\nwhich # Suppose you have a command you can execute, because it\u0026rsquo;s in the shell path, but you want to know where it is located.\n假设您有一个可以执行的命令,因为它位于 shell 路径中,但您想知道它所在的位置。\nYou can do so using which. The command will return the path to the command specified:\n您可以使用which来执行此操作。该命令将返回指定命令的路径:\nwhich will only work for executables stored on disk, not aliases or built-in shell functions.\nwhich适用于存储在磁盘上的可执行文件,不适用于别名或内置 shell 函数。\nnohup # Sometimes you have to run a long-lived process on a remote machine, and then you need to disconnect.\n有时您必须在远程计算机上运行长期进程,然后需要断开连接。\nOr you simply want to prevent the command to be halted if there\u0026rsquo;s any network issue between you and the server.\n或者您只是想防止命令在您和服务器之间出现任何网络问题时停止。\nThe way to make a command run even after you log out or close the session to a server is to use the nohup command.\n即使在注销或关闭服务器会话后仍运行命令的方法是使用nohup命令。\nUse nohup \u0026lt;command\u0026gt; to let the process continue working even after you log out.\n使用nohup \u0026lt;command\u0026gt;让进程在您注销后继续工作。\nxargs # The xargs command is used in a UNIX shell to convert input from standard input into arguments to a command.\nxargs命令在 UNIX shell 中用于将输入从标准输入转换为命令的参数。\nIn other words, through the use of xargs the output of a command is used as the input of another command.\n换句话说,通过使用xargs一个命令的输出被用作另一个命令的输入。\nHere\u0026rsquo;s the syntax you will use:\n这是您将使用的语法:\ncommand1 | xargs command2 We use a pipe ‘∣‘`|` to pass the output to xargs. That will take care of running the command2 command, using the output of command1 as its argument ss .\n我们使用管道 ‘∣‘ `|` 将输出传递给xargs 。这将负责运行command2命令,并使用command1的输出作为其参数。\nLet\u0026rsquo;s do a simple example. You want to remove some specific files from a directory. Those files are listed inside a text file.\n我们来做一个简单的例子。您想要从目录中删除某些特定文件。这些文件列在文本文件中。\nWe have 3 files: file1, file2, file3.\n我们有 3 个文件: file1 、 file2 、 file3 。\nIn todelete.txt we have a list of files we want to delete, in this example file1 and file3:\n在todelete.txt中,我们有一个要删除的文件列表,在本例中为file1和file3 :\nWe will channel the output of cat todelete.txt to the rm command, through xargs.\n我们将通过xargs将cat todelete.txt的输出引导到rm命令。\nIn this way:\n这样:\ncat todelete.txt | xargs rm That\u0026rsquo;s the result, the files we listed are now deleted:\n这就是结果,我们列出的文件现在已被删除:\nThe way it works is that xargs will run rm 2 times, one for each line returned by cat.\n它的工作方式是xargs将运行rm 2 次, cat返回的每一行运行一次。\nThis is the simplest usage of xargs. There are several options we can use.\n这是xargs最简单的用法。我们可以使用多种选项。\nOne of the most useful in my opinion, especially when starting to learn xargs, is -p. Using this option will make xargs print a confirmation prompt with the action it\u0026rsquo;s going to take:\n我认为最有用的之一是-p ,尤其是在开始学习xargs时。使用此选项将使xargs打印一条确认提示,其中包含将要采取的操作:\nThe -n option lets you tell xargs to perform one iteration at a time, so you can individually confirm them with -p. Here we tell xargs to perform one iteration at a time with -n1:\n-n选项允许您告诉xargs一次执行一次迭代,因此您可以使用-p单独确认它们。在这里,我们告诉xargs使用-n1一次执行一次迭代:\nThe -I option is another widely used one. It allows you to get the output into a placeholder, and then you can do various things.\n-I选项是另一个广泛使用的选项。它允许您将输出放入占位符中,然后您可以执行各种操作。\nOne of them is to run multiple commands:\n其中之一是运行多个命令:\ncommand1 | xargs -I % /bin/bash -c 'command2 %; command3 %' You can swap the % symbol I used above with anything else, it\u0026rsquo;s a variable\n您可以将我上面使用的%符号替换为其他符号,它是一个变量\nvim # vim is a very popular file editor, especially among programmers. It\u0026rsquo;s actively developed and frequently updated, and there\u0026rsquo;s a very big community around it. There\u0026rsquo;s even a Vim conference!\nvim是一种非常流行的文件编辑器,尤其是在程序员中。它得到了积极的开发和频繁的更新,并且有一个非常大的社区。甚至还有 Vim 会议!\nvi in modern systems is just an alias to vim, which means vi improved.\n现代系统中的vi只是vim的别名,这意味着vi i m被证明。\nYou start it by running vi on the command line.\n您可以通过在命令行上运行vi来启动它。\nYou can specify a filename at invocation time to edit that specific file:\n您可以在调用时指定文件名来编辑该特定文件:\nvi test.txt You have to know that Vim has 2 main modes:\n你要知道 Vim 有 2 个主要模式:\ncommand or∗normal∗or *normal* mode\n命令(或正常)模式 insert mode\n插入模式 When you start the editor, you are in command mode. You can\u0026rsquo;t enter text like you expect from a GUI-based editor. You have to enter insert mode. You can do this by pressing the i key. Once you do so, the -- INSERT -- word appear at the bottom of the editor:\n当您启动编辑器时,您处于命令模式。您无法像在基于 GUI 的编辑器中那样输入文本。您必须进入插入模式。您可以通过按i键来执行此操作。一旦你这样做了, -- INSERT --这个词就会出现在编辑器的底部:\nNow you can start typing and filling the screen with the file contents:\n现在您可以开始输入文件内容并在屏幕上填充:\nYou can move around the file with the arrow keys, or using the h - j - k - l keys. h-l for left-right, j-k for down-up.\n您可以使用箭头键或使用h - j - k - l键在文件中移动。 hl代表左-右, jk代表下-上。\nOnce you are done editing you can press the esc key to exit insert mode, and go back to command mode.\n完成编辑后,您可以按esc键退出插入模式,然后返回命令模式。\nAt this point you can navigate the file, but you can\u0026rsquo;t add content to it andbecarefulwhichkeysyoupressastheymightbecommandsand be careful which keys you press as they might be commands .\n此时,您可以导航该文件,但无法向其中添加内容(并且要小心按下的键,因为它们可能是命令)。\nOne thing you might want to do now is saving the file. You can do so by pressing : coloncolon , then w.\n您现在可能想做的一件事是保存文件。您可以通过按:冒号),然后w来执行此操作。\nYou can save and quit pressing : then w and q: :wq\n您可以**保存并退出,**按:然后按w和q : :wq\nYou can quit without saving, pressing : then q and !: :q!\n您可以退出而不保存,按:然后按q和! :: :q!\nYou can undo and edit by going to command mode and pressing u. You can redo cancelanundocancel an undo by pressing ctrl-r.\n您可以通过进入命令模式并按u来撤消和编辑。您可以按ctrl-r重做(取消撤消)。\nThose are the basics of working with Vim. From here starts a rabbit hole we can\u0026rsquo;t go into in this little introduction.\n这些是使用 Vim 的基础知识。从这里开始,我们无法在这个小介绍中进入一个兔子洞。\nI will only mention those commands that will get you started editing with Vim:\n我只会提到那些可以让你开始使用 Vim 进行编辑的命令:\npressing the x key deletes the character currently highlighted\n按x键删除当前突出显示的字符 pressing A goes at the end of the currently selected line\n按A转到当前所选行的末尾 press 0 to go to the start of the line\n按0转到行首 go to the first character of a word and press d followed by w to delete that word. If you follow it with e instead of w, the white space before the next word is preserved\n转到单词的第一个字符,然后按d然后按w删除该单词。如果您使用e而不是w ,则保留下一个单词之前的空格 use a number between d and w to delete more than 1 word, for example use d3w to delete 3 words forward\n使用d和w之间的数字删除 1 个以上的单词,例如使用d3w向前删除 3 个单词 press d followed by d to delete a whole entire line. Press d followed by $ to delete the entire line from where the cursor is, until the end\n按d然后按d可删除整行。按d后按$可删除从光标所在位置开始的整行,直到末尾 To find out more about Vim I can recommend the Vim FAQ and especially running the vimtutor command, which should already be installed in your system and will greatly help you start your vim explorations.\n要了解有关 Vim 的更多信息,我可以推荐 Vim FAQ ,特别是运行vimtutor命令,该命令应该已经安装在您的系统中,并将极大地帮助您开始您的vim探索。\nemacs # emacs is an awesome editor and it\u0026rsquo;s historically regarded as the editor for UNIX systems. Famously vi vs emacs flame wars and heated discussions caused many unproductive hours for developers around the world.\nemacs是一个很棒的编辑器,历来被认为是 UNIX 系统的编辑器。众所周知, vi与emacs的激烈争论和激烈的讨论导致世界各地的开发人员花费了很多时间,毫无成效。\nemacs is very powerful. Some people use it all day long as a kind of operating system [https://news.ycombinator.com/item?id=19127258](https://news.ycombinator.com/item?id=19127258)[https://news.ycombinator.com/item?id=19127258](https://news.ycombinator.com/item?id=19127258) . We\u0026rsquo;ll just talk about the basics here.\nemacs非常强大。有些人整天把它当作一种操作系统来使用( https://news.ycombinator.com/item?id=19127258 )。我们在这里只讨论基础知识。\nYou can open a new emacs session simply by invoking emacs:\n您只需调用emacs即可打开新的 emacs 会话:\nmacOS users, stop a second now. If you are on Linux there are no problems, but macOS does not ship applications using GPLv3, and every built-in UNIX command that has been updated to GPLv3 has not been updated. While there is a little problem with the commands I listed up to now, in this case using an emacs version from 2007 is not exactly the same as using a version with 12 years of improvements and change. This is not a problem with Vim, which is up to date. To fix this, run brew install emacs and running emacs will use the new version from Homebrew makesureyouhaveHomebrewinstalledmake sure you have Homebrew installed macOS 用户,请停下来。如果您使用的是 Linux,则没有问题,但 macOS 不提供使用 GPLv3 的应用程序,并且已更新到 GPLv3 的每个内置 UNIX 命令尚未更新。虽然我到目前为止列出的命令存在一些问题,但在这种情况下,使用 2007 年的 emacs 版本与使用经过 12 年改进和更改的版本并不完全相同。这对于 Vim 来说不是问题,它是最新的。要解决此问题,请运行brew install emacs ,并且运行emacs将使用Homebrew的新版本(确保您已安装Homebrew )\nYou can also edit an existing file calling emacs \u0026lt;filename\u0026gt;:\n您还可以编辑调用emacs \u0026lt;filename\u0026gt;的现有文件:\nYou can start editing and once you are done, press ctrl-x followed by ctrl-w. You confirm the folder:\n您可以开始编辑,完成后,按ctrl-x ,然后按ctrl-w 。您确认该文件夹:\nand Emacs tell you the file exists, asking you if it should overwrite it:\nEmacs 会告诉您该文件存在,并询问您是否应该覆盖它:\nAnswer y, and you get a confirmation of success:\n回答y ,您将收到成功确认信息:\nYou can exit Emacs pressing ctrl-x followed by ctrl-c. Or ctrl-x followed by c keep‘ctrl‘pressedkeep `ctrl` pressed .\n您可以按ctrl-x然后按ctrl-c退出 Emacs。或者按ctrl-x后跟c (按住ctrl不放)。\nThere is a lot to know about Emacs. More than I am able to write in this little introduction. I encourage you to open Emacs and press ctrl-h r to open the built-in manual and ctrl-h t to open the official tutorial.\n关于 Emacs 有很多东西需要了解。我在这个小小的介绍中无法写出更多内容。我鼓励你打开 Emacs 并按ctrl-h r打开内置手册,按ctrl-h t打开官方教程。\nnano # nano is a beginner friendly editor.\nnano是一个适合初学者的编辑器。\nRun it using nano \u0026lt;filename\u0026gt;.\n使用nano \u0026lt;filename\u0026gt;运行它。\nYou can directly type characters into the file without worrying about modes.\n您可以直接在文件中键入字符,而不必担心模式。\nYou can quit without editing using ctrl-X. If you edited the file buffer, the editor will ask you for confirmation and you can save the edits, or discard them. The help at the bottom shows you the keyboard commands that let you work with the file:\n您可以使用ctrl-X退出而不进行编辑。如果您编辑了文件缓冲区,编辑器将要求您确认,您可以保存编辑或放弃它们。底部的帮助显示了可让您使用该文件的键盘命令:\npico is more or less the same, although nano is the GNU version of pico which at some point in history was not open source and the nano clone was made to satisfy the GNU operating system license requirements.\npico或多或少是相同的,尽管nano是pico的 GNU 版本,在历史上的某个时刻它不是开源的,并且nano克隆是为了满足 GNU 操作系统许可要求而制作的。\nwhoami # Type whoami to print the user name currently logged in to the terminal session:\n输入whoami以打印当前登录到终端会话的用户名:\nNote: this is different from the who am i command, which prints more information\n注意:这与who am i命令不同,后者打印更多信息\nwho # The who command displays the users logged in to the system.\nwho命令显示登录到系统的用户。\nUnless you\u0026rsquo;re using a server multiple people have access to, chances are you will be the only user logged in, multiple times:\n除非您使用的服务器可供多人访问,否则您很可能是唯一多次登录的用户:\nWhy multiple times? Because each shell opened will count as an access.\n为什么要多次?因为每次打开的shell都会算作一次访问。\nYou can see the name of the terminal used, and the time/day the session was started.\n您可以看到所使用的终端的名称以及会话开始的时间/日期。\nThe -aH flags will tell who to display more information, including the idle time and the process ID of the terminal:\n-aH标志将告诉who显示更多信息,包括空闲时间和终端的进程 ID:\nThe special who am i command will list the current terminal session details:\n特殊的who am i命令将列出当前终端会话详细信息:\nsu # While you\u0026rsquo;re logged in to the terminal shell with one user, you might have the need to switch to another user.\n当您使用一个用户登录到终端 shell 时,您可能需要切换到另一用户。\nFor example you\u0026rsquo;re logged in as root to perform some maintenance, but then you want to switch to a user account.\n例如,您以 root 身份登录来执行一些维护,但随后您想要切换到用户帐户。\nYou can do so with the su command:\n您可以使用su命令来执行此操作:\nsu \u0026lt;username\u0026gt; For example: su flavio.\n例如: su flavio 。\nIf you\u0026rsquo;re logged in as a user, running su without anything else will prompt to enter the root user password, as that\u0026rsquo;s the default behavior.\n如果您以用户身份登录,则运行su而不执行任何其他操作将提示输入root用户密码,因为这是默认行为。\nsu will start a new shell as another user.\nsu将以另一个用户身份启动一个新的 shell。\nWhen you\u0026rsquo;re done, typing exit in the shell will close that shell, and will return back to the current user\u0026rsquo;s shell.\n完成后,在 shell 中键入exit将关闭该 shell,并返回到当前用户的 shell。\nsudo # sudo is commonly used to run a command as root.\nsudo通常用于以 root 身份运行命令。\nYou must be enabled to use sudo, and once you do, you can run commands as root by entering your user\u0026rsquo;s password ∗not∗therootuserpassword*not* the root user password .\n您必须能够使用sudo ,一旦启用,您就可以通过输入用户密码(而不是root 用户密码)以 root 身份运行命令。\nThe permissions are highly configurable, which is great especially in a multi-user server environment, and some users can be granted access to running specific commands through sudo.\n权限是高度可配置的,这在多用户服务器环境中尤其有用,并且可以通过sudo授予某些用户运行特定命令的访问权限。\nFor example you can edit a system configuration file:\n例如,您可以编辑系统配置文件:\nsudo nano /etc/hosts which would otherwise fail to save since you don\u0026rsquo;t have the permissions for it.\n否则将无法保存,因为您没有权限。\nYou can run sudo -i to start a shell as root:\n您可以运行sudo -i以 root 身份启动 shell:\nYou can use sudo to run commands as any user. root is the default, but use the -u option to specify another user:\n您可以使用sudo以任何用户身份运行命令。 root是默认用户,但使用-u选项指定另一个用户:\nsudo -u flavio ls /Users/flavio passwd # Users in Linux have a password assigned. You can change the password using the passwd command.\nLinux 中的用户已分配一个密码。您可以使用passwd命令更改密码。\nThere are two situations here.\n这里有两种情况。\nThe first is when you want to change your password. In this case you type:\n第一个是当您想要更改密码时。在这种情况下,您输入:\npasswd and an interactive prompt will ask you for the old password, then it will ask you for the new one:\n交互式提示会要求您输入旧密码,然后会要求您输入新密码:\nWhen you\u0026rsquo;re root orhavesuperuserprivilegesor have superuser privileges you can set the username of which you want to change the password:\n当您是root (或具有超级用户权限)时,您可以设置要更改密码的用户名:\npasswd \u0026lt;username\u0026gt; \u0026lt;new password\u0026gt; In this case you don\u0026rsquo;t need to enter the old one.\n在这种情况下,您无需输入旧的。\nping # The ping command pings a specific network host, on the local network or on the Internet.\nping命令对本地网络或 Internet 上的特定网络主机执行 ping 操作。\nYou use it with the syntax ping \u0026lt;host\u0026gt; where \u0026lt;host\u0026gt; could be a domain name, or an IP address.\n您可以使用语法ping \u0026lt;host\u0026gt;来使用它,其中\u0026lt;host\u0026gt;可以是域名或 IP 地址。\nHere\u0026rsquo;s an example pinging google.com:\n以下是 ping google.com示例:\nThe commands sends a request to the server, and the server returns a response.\n命令向服务器发送请求,服务器返回响应。\nping keep sending the request every second, by default, and will keep running until you stop it with ctrl-C, unless you pass the number of times you want to try with the -c option: ping -c 2 google.com.\n默认情况下, ping每秒都会发送请求,并且将继续运行,直到您使用ctrl-C停止它为止,除非您通过-c选项传递了您想要尝试的次数: ping -c 2 google.com 。\nOnce ping is stopped, it will print some statistics about the results: the percentage of packages lost, and statistics about the network performance.\n一旦ping停止,它将打印一些有关结果的统计信息:丢失包的百分比以及有关网络性能的统计信息。\nAs you can see the screen prints the host IP address, and the time that it took to get the response back.\n正如您所看到的,屏幕打印了主机 IP 地址以及获取响应所需的时间。\nNot all servers support pinging, in case the requests times out:\n并非所有服务器都支持 ping,以防请求超时:\nSometimes this is done on purpose, to \u0026ldquo;hide\u0026rdquo; the server, or just to reduce the load. The ping packets can also be filtered by firewalls.\n有时这是故意这样做的,以“隐藏”服务器,或者只是为了减少负载。 ping 数据包也可以被防火墙过滤。\nping works using the ICMP protocol ∗InternetControlMessageProtocol∗*Internet Control Message Protocol* , a network layer protocol just like TCP or UDP.\nping使用ICMP 协议(​​互联网控制消息协议)进行工作,这是一种网络层协议,就像 TCP 或 UDP 一样。\nThe request sends a packet to the server with the ECHO_REQUEST message, and the server returns a ECHO_REPLY message. I won\u0026rsquo;t go into details, but this is the basic concept.\n请求向服务器发送带有ECHO_REQUEST消息的数据包,服务器返回ECHO_REPLY消息。我不会详细说明,但这是基本概念。\nPinging a host is useful to know if the host is reachable supposingitimplementspingsupposing it implements ping , and how distant it is in terms of how long it takes to get back to you. Usually the nearest the server is geographically, the less time it will take to return back to you, for simple physical laws that cause a longer distance to introduce more delay in the cables.\n对主机执行 Ping 操作有助于了解该主机是否可达(假设它实现了 ping),以及与您联系所需的时间有多远。通常,服务器在地理位置上越近,返回给您所需的时间就越短,因为简单的物理定律会导致距离较长,从而在电缆中引入更多延迟。\ntraceroute # When you try to reach a host on the Internet, you go through your home router, then you reach your ISP network, which in turn goes through its own upstream network router, and so on, until you finally reach the host.\n当您尝试访问互联网上的主机时,您将通过家庭路由器,然后到达 ISP 网络,ISP 网络又通过其自己的上游网络路由器,依此类推,直到您最终到达主机。\nHave you ever wanted to know what are the steps that your packets go through to do that?\n您是否想知道您的数据包要经过哪些步骤才能做到这一点?\nThe traceroute command is made for this.\ntraceroute命令就是为此而设计的。\nYou invoke\n你调用\ntraceroute \u0026lt;host\u0026gt; and it will slowlyslowly gather all the information while the packet travels.\n它会在数据包传输过程中(慢慢地)收集所有信息。\nIn this example I tried reaching for my blog with traceroute flaviocopes.com:\n在此示例中,我尝试使用traceroute flaviocopes.com访问我的博客:\nNot every router travelled returns us information. In this case, traceroute prints * * *. Otherwise, we can see the hostname, the IP address, and some performance indicator.\n并非每个经过的路由器都会向我们返回信息。在这种情况下, traceroute会打印* * * 。否则,我们可以看到主机名、IP 地址和一些性能指标。\nFor every router we can see 3 samples, which means traceroute tries by default 3 times to get you a good indication of the time needed to reach it. This is why it takes this long to execute traceroute compared to simply doing a ping to that host.\n对于每个路由器,我们可以看到 3 个样本,这意味着默认情况下,traceroute 会尝试 3 次,以便您很好地了解到达该路由器所需的时间。这就是为什么与简单地对该主机执行ping相比,执行traceroute需要这么长时间。\nYou can customize this number with the -q option:\n您可以使用-q选项自定义此数字:\ntraceroute -q 1 flaviocopes.com clear # Type clear to clear all the previous commands that were ran in the current terminal.\n键入clear以清除当前终端中运行的所有先前命令。\nThe screen will clear and you will just see the prompt at the top:\n屏幕将清除,您只会在顶部看到提示:\nNote: this command has a handy shortcut: ctrl-L\n注意:此命令有一个方便的快捷键: ctrl-L\nOnce you do that, you will lose access to scrolling to see the output of the previous commands entered.\n一旦执行此操作,您将无法滚动查看先前输入的命令的输出。\nSo you might want to use clear -x instead, which still clears the screen, but lets you go back to see the previous work by scrolling up.\n因此,您可能想使用clear -x ,它仍然会清除屏幕,但可以让您通过向上滚动返回查看之前的工作。\nhistory # Every time we run a command, that\u0026rsquo;s memorized in the history.\n每次我们运行命令时,它都会被记录在历史记录中。\nYou can display all the history using:\n您可以使用以下命令显示所有历史记录:\nhistory This shows the history with numbers:\n这用数字显示了历史:\nYou can use the syntax !\u0026lt;command number\u0026gt; to repeat a command stored in the history, in the above example typing !121 will repeat the ls -al | wc -l command.\n您可以使用语法!\u0026lt;command number\u0026gt;来重复存储在历史记录中的命令,在上面的示例中键入!121将重复ls -al | wc -l命令。\nTypically the last 500 commands are stored in the history.\n通常,最后 500 个命令存储在历史记录中。\nYou can combine this with grep to find a command you ran:\n您可以将其与grep结合起来查找您运行的命令:\nhistory | grep docker To clear the history, run history -c\n要清除历史记录,请运行history -c\nexport # The export command is used to export variables to child processes.\nexport命令用于将变量导出到子进程。\nWhat does this mean?\n这意味着什么?\nSuppose you have a variable TEST defined in this way:\n假设您有一个这样定义的变量 TEST:\nTEST= \u0026quot;test\u0026quot; You can print its value using echo $TEST:\n您可以使用echo $TEST打印其值:\nBut if you try defining a Bash script in a file script.sh with the above command:\n但是,如果您尝试使用上述命令在script.sh文件中定义 Bash 脚本:\nThen you set chmod u+x script.sh and you execute this script with ./script.sh, the echo $TEST line will print nothing!\n然后设置chmod u+x script.sh并使用./script.sh执行此脚本, echo $TEST行将不打印任何内容!\nThis is because in Bash the TEST variable was defined local to the shell. When executing a shell script or another command, a subshell is launched to execute it, which does not contain the current shell local variables.\n这是因为在 Bash 中, TEST变量是在 shell 本地定义的。当执行 shell 脚本或其他命令时,会启动一个子 shell 来执行它,该子 shell 不包含当前 shell 局部变量。\nTo make the variable available there we need to define TEST not in this way:\n为了使变量在那里可用,我们需要不以这种方式定义TEST :\nTEST= \u0026quot;test\u0026quot; but in this way:\n但这样:\nexport TEST= \u0026quot;test\u0026quot; Try that, and running ./script.sh now should print \u0026ldquo;test\u0026rdquo;:\n尝试一下,现在运行./script.sh应该打印“test”:\nSometimes you need to append something to a variable. It\u0026rsquo;s often done with the PATH variable. You use this syntax:\n有时您需要向变量附加一些内容。通常通过PATH变量来完成。您使用以下语法:\nexport PATH= $PATH :/new/path It\u0026rsquo;s common to use export when you create new variables in this way, but also when you create variables in the .bash_profile or .bashrc configuration files with Bash, or in .zshenv with Zsh.\n当您以这种方式创建新变量时,以及当您使用 Bash 在.bash_profile或.bashrc配置文件中创建变量,或者使用 Zsh 在.zshenv中创建变量时,通常会使用export 。\nTo remove a variable, use the -n option:\n要删除变量,请使用-n选项:\nexport -n TEST Calling export without any option will list all the exported variables.\n不带任何选项调用export将列出所有导出的变量。\ncrontab # Cron jobs are jobs that are scheduled to run at specific intervals. You might have a command perform something every hour, or every day, or every 2 weeks. Or on weekends. They are very powerful, especially on servers to perform maintenance and automations.\nCron 作业是计划以特定时间间隔运行的作业。您可能有一个命令每小时、每天或每两周执行一些操作。或者在周末。它们非常强大,特别是在服务器上执行维护和自动化。\nThe crontab command is the entry point to work with cron jobs.\ncrontab命令是使用 cron 作业的入口点。\nThe first thing you can do is to explore which cron jobs are defined by you:\n您可以做的第一件事是探索您定义了哪些 cron 作业:\ncrontab -l You might have none, like me:\n你可能没有,就像我一样:\nRun\n跑步\ncrontab -e to edit the cron jobs, and add new ones.\n编辑 cron 作业并添加新作业。\nBy default this opens with the default editor, which is usually vim. I like nano more, you can use this line to use a different editor:\n默认情况下,它会使用默认编辑器打开,通常是vim 。我更喜欢nano ,你可以使用这一行来使用不同的编辑器:\nEDITOR=nano crontab -e Now you can add one line for each cron job.\n现在您可以为每个 cron 作业添加一行。\nThe syntax to define cron jobs is kind of scary. This is why I usually use a website to help me generate it without errors: https://crontab-generator.org/\n定义 cron 作业的语法有点可怕。这就是为什么我通常使用一个网站来帮助我生成它而不会出现错误: https ://crontab-generator.org/\nYou pick a time interval for the cron job, and you type the command to execute.\n您为 cron 作业选择一个时间间隔,然后键入要执行的命令。\nI chose to run a script located in /Users/flavio/test.sh every 12 hours. This is the crontab line I need to run:\n我选择每 12 小时运行一次位于/Users/flavio/test.sh中的脚本。这是我需要运行的 crontab 行:\n* */12 * * * /Users/flavio/test.sh \u0026gt;/dev/null 2\u0026gt;\u0026amp;1 I run crontab -e:\n我运行crontab -e :\nEDITOR=nano crontab -e and I add that line, then I press ctrl-X and press y to save.\n我添加该行,然后按ctrl-X并按y保存。\nIf all goes well, the cron job is set up:\n如果一切顺利,则 cron 作业已设置:\nOnce this is done, you can see the list of active cron jobs by running:\n完成此操作后,您可以通过运行以下命令查看活动 cron 作业的列表:\ncrontab -l You can remove a cron job running crontab -e again, removing the line and exiting the editor:\n您可以删除再次运行crontab -e cron 作业,删除该行并退出编辑器:\nuname # Calling uname without any options will return the Operating System codename:\n不带任何选项调用uname将返回操作系统代号:\nThe m option shows the hardware name ‘x8664‘inthisexample`x86_64` in this example and the p option prints the processor architecture name ‘i386‘inthisexample`i386` in this example :\nm选项显示硬件名称(本例中为x86_64 ), p选项打印处理器架构名称(本例中为i386 ):\nThe s option prints the Operating System name. r prints the release, v prints the version:\ns选项打印操作系统名称。 r打印版本, v打印版本:\nThe n option prints the node network name:\nn选项打印节点网络名称:\nThe a option prints all the information available:\na选项打印所有可用的信息:\nOn macOS you can also use the sw_vers command to print more information about the macOS Operating System. Note that this differs from the Darwin theKernelthe Kernel version, which above is 19.6.0.\n在 macOS 上,您还可以使用sw_vers命令打印有关 macOS 操作系统的更多信息。请注意,这与 Darwin(内核)版本不同,上面是19.6.0 。\nDarwin is the name of the kernel of macOS. The kernel is the \u0026ldquo;core\u0026rdquo; of the Operating System, while the Operating System as a whole is called macOS. In Linux, Linux is the kernel, GNU/Linux would be the Operating System name, although we all refer to it as \u0026ldquo;Linux\u0026rdquo;\nDarwin 是 macOS 内核的名称。内核是操作系统的“核心”,而整个操作系统称为 macOS。在 Linux 中,Linux 是内核,GNU/Linux 是操作系统名称,尽管我们都将其称为“Linux”\nenv # The env command can be used to pass environment variables without setting them on the outer environment thecurrentshellthe current shell .\nenv命令可用于传递环境变量,而无需在外部环境(当前 shell)上设置它们。\nSuppose you want to run a Node.js app and set the USER variable to it.\n假设您要运行 Node.js 应用程序并将USER变量设置为其。\nYou can run\n你可以运行\nenv USER=flavio node app.js and the USER environment variable will be accessible from the Node.js app via the Node process.env interface.\n并且可以通过 Node.js 应用程序通过 Node process.env接口访问USER环境变量。\nYou can also run the command clearing all the environment variables already set, using the -i option:\n您还可以使用-i选项运行命令清除已设置的所有环境变量:\nenv -i node app.js In this case you will get an error saying env: node: No such file or directory because the node command is not reachable, as the PATH variable used by the shell to look up commands in the common paths is unset.\n在这种情况下,您会收到一条错误消息 env: node: No such file or directory 因为node命令不可访问,因为 shell 用于在公共路径中查找命令的PATH变量未设置。\nSo you need to pass the full path to the node program:\n因此需要将完整路径传递给node程序:\nenv -i /usr/ local /bin/node app.js Try with a simple app.js file with this content:\n尝试使用包含以下内容的简单app.js文件:\nconsole .log(process.env.NAME) console .log(process.env.PATH) You will see the output being\n你会看到输出是\nundefined undefined You can pass an env variable:\n您可以传递一个环境变量:\nenv -i NAME=flavio node app.js and the output will be\n输出将是\nflavio undefined Removing the -i option will make PATH available again inside the program:\n删除-i选项将使PATH在程序内再次可用:\nThe env command can also be used to print out all the environment variables, if ran with no options:\n如果运行时没有选项, env命令还可以用于打印所有环境变量:\nenv it will return a list of the environment variables set, for example:\n它将返回环境变量集的列表,例如:\nHOME=/Users/flavio LOGNAME=flavio PATH=/usr/local/bin:/usr/bin:/bin:/usr/sbin:/sbin:/Library/Apple/usr/bin PWD=/Users/flavio SHELL=/usr/local/bin/fish You can also make a variable inaccessible inside the program you run, using the -u option, for example this code removes the HOME variable from the command environment:\n您还可以使用-u选项使变量在运行的程序内不可访问,例如以下代码从命令环境中删除HOME变量:\nenv -u HOME node app.js printenv # A quick guide to the printenv command, used to print the values of environment variables\nprintenv命令快速指南,用于打印环境变量的值\nIn any shell there are a good number of environment variables, set either by the system, or by your own shell scripts and configuration.\n在任何 shell 中都有大量的环境变量,这些变量可以由系统设置,也可以由您自己的 shell 脚本和配置设置。\nYou can print them all to the terminal using the printenv command. The output will be something like this:\n您可以使用printenv命令将它们全部打印到终端。输出将是这样的:\nHOME=/Users/flavio LOGNAME=flavio PATH=/usr/local/bin:/usr/bin:/bin:/usr/sbin:/sbin:/Library/Apple/usr/bin PWD=/Users/flavio SHELL=/usr/local/bin/fish with a few more lines, usually.\n通常还有几行。\nYou can append a variable name as a parameter, to only show that variable value:\n您可以附加变量名称作为参数,以仅显示该变量值:\nprintenv PATH Conclusion # 结论\nThanks a lot for reading this book.\n非常感谢您阅读这本书。\nFor more, head over to flaviocopes.com.\n如需了解更多信息,请访问 flaviocopes.com 。\nSend any feedback, errata or opinions at flavio@flaviocopes.com\n请将任何反馈、勘误表或意见发送至flavio@flaviocopes.com\n"},{"id":168,"href":"/zh/docs/culture/%E8%AE%BA%E8%AF%AD/%E8%AE%BA%E8%AF%AD%E7%9A%84%E7%94%9F%E6%B4%BB%E6%99%BA%E6%85%A7/01%E5%AD%A6%E5%A4%A9%E7%AC%AC%E4%B8%80/","title":"01学天第一","section":"论语的生活智慧","content":" # # "},{"id":169,"href":"/zh/docs/culture/%E8%AE%BA%E8%AF%AD/%E8%AE%BA%E8%AF%AD%E7%9A%84%E7%94%9F%E6%B4%BB%E6%99%BA%E6%85%A7/02%E4%B8%BA%E6%94%BF%E7%AC%AC%E4%BA%8C/","title":"02为政第二","section":"论语的生活智慧","content":"\n"},{"id":170,"href":"/zh/docs/culture/%E8%AE%BA%E8%AF%AD/%E8%AE%BA%E8%AF%AD%E7%9A%84%E7%94%9F%E6%B4%BB%E6%99%BA%E6%85%A7/03%E5%85%AB%E4%BD%BE%E7%AC%AC%E4%B8%89/","title":"03八佾第三","section":"论语的生活智慧","content":"\n"},{"id":171,"href":"/zh/docs/culture/%E8%AE%BA%E8%AF%AD/%E8%AE%BA%E8%AF%AD%E7%9A%84%E7%94%9F%E6%B4%BB%E6%99%BA%E6%85%A7/04%E9%87%8C%E4%BB%81%E7%AC%AC%E5%9B%9B/","title":"04里仁第四","section":"论语的生活智慧","content":"\n"},{"id":172,"href":"/zh/docs/culture/%E8%AE%BA%E8%AF%AD/%E8%AE%BA%E8%AF%AD%E7%9A%84%E7%94%9F%E6%B4%BB%E6%99%BA%E6%85%A7/05%E5%85%AC%E5%86%B6%E9%95%BF%E7%AC%AC%E4%BA%94/","title":"05公治长第五","section":"论语的生活智慧","content":"\n"},{"id":173,"href":"/zh/docs/culture/%E8%AE%BA%E8%AF%AD/%E8%AE%BA%E8%AF%AD%E7%9A%84%E7%94%9F%E6%B4%BB%E6%99%BA%E6%85%A7/06%E9%9B%8D%E4%B9%9F%E7%AC%AC%E5%85%AD/","title":"06雍也第六","section":"论语的生活智慧","content":"\n"},{"id":174,"href":"/zh/docs/culture/%E8%AE%BA%E8%AF%AD/%E8%AE%BA%E8%AF%AD%E7%9A%84%E7%94%9F%E6%B4%BB%E6%99%BA%E6%85%A7/07%E8%BF%B0%E8%80%8C%E7%AC%AC%E4%B8%83/","title":"07述而第七","section":"论语的生活智慧","content":"\n"},{"id":175,"href":"/zh/docs/culture/%E8%AE%BA%E8%AF%AD/%E8%AE%BA%E8%AF%AD%E7%9A%84%E7%94%9F%E6%B4%BB%E6%99%BA%E6%85%A7/08%E6%B3%B0%E4%BC%AF%E7%AC%AC%E5%85%AB/","title":"08泰伯第八","section":"论语的生活智慧","content":"\n"},{"id":176,"href":"/zh/docs/culture/%E8%AE%BA%E8%AF%AD/%E8%AE%BA%E8%AF%AD%E7%9A%84%E7%94%9F%E6%B4%BB%E6%99%BA%E6%85%A7/09%E5%AD%90%E7%BD%95%E7%AC%AC%E4%B9%9D/","title":"09子罕第九","section":"论语的生活智慧","content":"\n"},{"id":177,"href":"/zh/docs/culture/%E8%AE%BA%E8%AF%AD/%E8%AE%BA%E8%AF%AD%E7%9A%84%E7%94%9F%E6%B4%BB%E6%99%BA%E6%85%A7/10%E4%B9%A1%E5%85%9A%E7%AC%AC%E5%8D%81/","title":"10乡党第十","section":"论语的生活智慧","content":"\n"},{"id":178,"href":"/zh/docs/culture/%E8%AE%BA%E8%AF%AD/%E8%AE%BA%E8%AF%AD%E7%9A%84%E7%94%9F%E6%B4%BB%E6%99%BA%E6%85%A7/11%E5%85%88%E8%BF%9B%E7%AC%AC%E5%8D%81%E4%B8%80/","title":"11先进第十一","section":"论语的生活智慧","content":"\n"},{"id":179,"href":"/zh/docs/culture/%E8%AE%BA%E8%AF%AD/%E8%AE%BA%E8%AF%AD%E7%9A%84%E7%94%9F%E6%B4%BB%E6%99%BA%E6%85%A7/12%E9%A2%9C%E6%B8%8A%E7%AC%AC%E5%8D%81%E4%BA%8C/","title":"12颜渊第十二","section":"论语的生活智慧","content":"\n"},{"id":180,"href":"/zh/docs/culture/%E8%AE%BA%E8%AF%AD/%E8%AE%BA%E8%AF%AD%E7%9A%84%E7%94%9F%E6%B4%BB%E6%99%BA%E6%85%A7/13%E5%AD%90%E8%B7%AF%E7%AC%AC%E5%8D%81%E4%B8%89/","title":"13子路第十三","section":"论语的生活智慧","content":"\n"},{"id":181,"href":"/zh/docs/culture/%E8%AE%BA%E8%AF%AD/%E8%AE%BA%E8%AF%AD%E7%9A%84%E7%94%9F%E6%B4%BB%E6%99%BA%E6%85%A7/14%E5%AE%AA%E9%97%AE%E7%AC%AC%E5%8D%81%E5%9B%9B/","title":"14宪问第十四","section":"论语的生活智慧","content":"\n"},{"id":182,"href":"/zh/docs/culture/%E8%AE%BA%E8%AF%AD/%E8%AE%BA%E8%AF%AD%E7%9A%84%E7%94%9F%E6%B4%BB%E6%99%BA%E6%85%A7/15%E5%8D%AB%E7%81%B5%E5%85%AC%E7%AC%AC%E5%8D%81%E4%BA%94/","title":"15卫灵公第十五","section":"论语的生活智慧","content":"\n"},{"id":183,"href":"/zh/docs/culture/%E8%AE%BA%E8%AF%AD/%E8%AE%BA%E8%AF%AD%E7%9A%84%E7%94%9F%E6%B4%BB%E6%99%BA%E6%85%A7/16%E5%AD%A3%E6%B0%8F%E7%AC%AC%E5%8D%81%E5%85%AD/","title":"16季氏第十六","section":"论语的生活智慧","content":"\n"},{"id":184,"href":"/zh/docs/culture/%E8%AE%BA%E8%AF%AD/%E8%AE%BA%E8%AF%AD%E7%9A%84%E7%94%9F%E6%B4%BB%E6%99%BA%E6%85%A7/17%E9%98%B3%E8%B4%A7%E7%AC%AC%E5%8D%81%E4%B8%83/","title":"17阳货第十七","section":"论语的生活智慧","content":"\n"},{"id":185,"href":"/zh/docs/culture/%E8%AE%BA%E8%AF%AD/%E8%AE%BA%E8%AF%AD%E7%9A%84%E7%94%9F%E6%B4%BB%E6%99%BA%E6%85%A7/18%E5%BE%AE%E5%AD%90%E7%AC%AC%E5%8D%81%E5%85%AB/","title":"18微子第十八","section":"论语的生活智慧","content":"\n"},{"id":186,"href":"/zh/docs/culture/%E8%AE%BA%E8%AF%AD/%E8%AE%BA%E8%AF%AD%E7%9A%84%E7%94%9F%E6%B4%BB%E6%99%BA%E6%85%A7/19%E5%AD%90%E5%BC%A0%E7%AC%AC%E5%8D%81%E4%B9%9D/","title":"19子张第十九","section":"论语的生活智慧","content":"\n"},{"id":187,"href":"/zh/docs/culture/%E8%AE%BA%E8%AF%AD/%E8%AE%BA%E8%AF%AD%E7%9A%84%E7%94%9F%E6%B4%BB%E6%99%BA%E6%85%A7/20%E5%B0%A7%E6%97%A5%E7%AC%AC%E4%BA%8C%E5%8D%81/","title":"20尧日第二十","section":"论语的生活智慧","content":"\n"},{"id":188,"href":"/zh/docs/culture/%E8%B5%84%E6%B2%BB%E9%80%9A%E9%89%B4/%E5%91%A8%E7%BA%AA/%E5%9F%BA%E6%9C%AC%E7%9F%A5%E8%AF%86/","title":"基本知识","section":"周纪","content":" 周朝君主世系图 # 资治通鉴中 # 周威烈王始\n完整世系图 # 《资治通鉴》从周威烈王(图里是32,这个第几任好像各家的算法不一致,不必深究)开始\n"},{"id":189,"href":"/zh/docs/culture/%E8%B5%84%E6%B2%BB%E9%80%9A%E9%89%B4/%E5%91%A8%E7%BA%AA/001%E5%91%A8%E7%BA%AA%E4%B8%80/","title":"001周纪一","section":"周纪","content":"資治通鑑卷第一\n朝散大夫右諫議大夫權御史中丞充理檢使上護軍賜紫金魚袋臣\n司马光 奉敕编集\n後 學 天 台\n胡三省 音 註\n周紀一 起著雍攝提格(戊寅),盡玄黓困敦(壬子),凡三十五年。\n爾雅:太歲在甲曰閼逢,在乙曰旃zhān蒙,在丙曰柔兆,在丁曰強圉,在戊曰著雍,在己曰屠維,在庚曰上章,在辛曰重光,在壬曰玄黓yì,在癸曰昭陽,是為歲陽。在寅曰攝提格,在卯曰單閼,在辰曰執徐,在巳曰大荒落,在午曰敦牂,在未曰協洽,在申曰涒tūn灘,在酉曰作噩,在戌曰掩茂,在亥曰大淵獻,在子曰困敦,在丑曰赤奮若,是為歲名。周紀分註「起著雍攝提格」,起戊寅也。「盡玄黓困敦」,盡壬子也。閼,讀如字;史記作「焉」,於乾翻。著,陳如翻。雍,于容翻。黓,逸職翻。單閼,上音丹,又特連翻;下烏葛翻,又于連翻。牂,作郎翻。涒,吐魂翻。灘,吐丹翻。困敦,音頓。杜預 世族譜曰:周,黃帝之苗裔,姬姓。后稷之後,封于邰;及夏衰,稷子不窋zhú竄於西戎。至十二代孫太王,避狄遷岐;至孫文王受命,武王克商而有天下。自武王至平王凡十三世,自平王至威烈王又十八世,自威烈王至赧王又五世。張守節曰:因太王居周原,國號曰周。地理志云:右扶風美陽縣岐山西北中水鄉,周太王所邑。括地志云:故周城一名美陽城,在雍州武功縣西北二十五里。紀,理也,統理眾事而系之年月。溫公系年用春秋之法,因史、漢本紀而謂之紀。邰,湯來翻。夏,戶雅翻。窋,竹律翻。在雍,於用翻。\n威烈王 # 名午,考王之子。諡法:猛以剛果曰威;有功安民曰烈。沈約曰:諸複諡,有諡人,無諡法。\n二十三年(戊寅,前四零三) # 上距春秋獲麟七十八年,距左傳趙襄子惎jì智伯事七十一年。惎,毒也,音其冀翻。\n1初命晉‹府新田,山西侯马›大夫魏 ‹府安邑,山西夏县›斯、趙‹府晋阳,山西太原›籍、韓‹府平阳,山西临汾›虔為諸侯。此溫公書法所由始也。魏之先,畢公高後,與周同姓;其苗裔曰畢萬,始封于魏,至魏舒,始為晉正卿;三世至斯。趙之先,造父後;至叔帶,始自周適晉;至趙夙,始封于耿。至趙盾,始為晉正卿,六世至籍。韓之先,出於周武王,至韓武子事晉,封于韓原。至韓厥,為晉正卿;六世至虔。三家者,世為晉大夫,於周則陪臣也。周室既衰,晉主夏盟,以尊王室,故命之為伯。三卿竊晉之權,暴蔑其君,剖分其國,此王法所必誅也。威烈王不惟不能誅之,又命之為諸侯,是崇獎奸名犯分之臣也,通鑑始於此,其所以謹名分歟!\n〖译文〗 [1]周威烈王姬午初次分封晋国大夫魏斯、赵籍、韩虔为诸侯国君。\n臣光曰:臣聞天子之職莫大於禮,禮莫大於分,分莫大於名。 分,扶問翻;下同。何謂禮?紀綱是也。何謂分?君、臣是也。何謂名?公、侯、卿、大夫是也。\n〖译文〗 臣司马光曰:我知道天子的职责中最重要的是维护礼教,礼教中最重要的是区分地位,区分地位中最重要的是匡正名分。什么是礼教?就是法纪。什么是区分地位?就是君臣有别。什么是名分?就是公、侯、卿、大夫等官爵。\n夫以四海之廣, 夫以,音扶。兆民之眾,受制於一人,雖有絕倫之力,高世之智,莫【章︰十二行本「莫」下有「敢」字;乙十一行本同;孔本同。】不奔走而服役者,豈非以禮為之紀綱【章︰十二行本,二字互乙;乙十一行本同;孔本同。】哉!是故天子統三公, 統,他綜翻。三公率諸侯,諸侯制卿大夫,卿大夫治士庶人。 治,直之翻。貴以臨賤,賤以承貴。上之使下猶心腹之運手足,根本之制支葉,下之事上猶手足之衛心腹,支葉之庇本根,然後能上下相保而國家治安。 治,直吏翻。故曰天子之職莫大於禮也。\n〖译文〗 四海之广,亿民之众,都受制于天子一人。尽管是才能超群、智慧绝伦的人,也不能不在天子足下为他奔走服务,这难道不是以礼作为礼纪朝纲的作用吗!所以,天子统率三公,三公督率诸侯国君,诸侯国君节制卿、大夫官员,卿、大夫官员又统治士人百姓。权贵支配贱民,贱民服从权贵。上层指挥下层就好像人的心腹控制四肢行动,树木的根和干支配枝和叶;下层服侍上层就好像人的四肢卫护心腹,树木的枝和叶遮护根和干,这样才能上下层互相保护,从而使国家得到长治久安。所以说,天子的职责没有比维护礼制更重要的了。\n文王序易,以乾、坤為首。孔子系之曰:「天尊地卑,乾坤定矣。 卑高以陳,貴賤位矣。」 系,戶計翻。言君臣之位猶天地之不可易也。春秋抑諸侯,尊王【章︰十二行本「王」作「周」;乙十一行本同;孔本同;退齋校同。】室,王人雖微,序於諸侯之上,以是見聖人於君臣之際未嘗不惓惓也。惓,逵員翻。漢劉向傳:忠臣畎quǎn畝,猶不忘君惓惓之義也。惓惓,猶言勤勤也。非有桀 ‹姒履癸›、紂‹子受辛›之暴,湯‹子天乙›、武‹姬发›之仁,人歸之,天命之,君臣之分當守節伏死而已矣。是故以微子 ‹子启›而代紂‹子受辛›則成湯‹子天乙›配天矣,史記:商帝乙生三子:長曰微子啟,次曰中衍,季曰紂。紂之母為后。帝乙欲立啟為太子,太史據法爭之曰:「有妻之子,不可立妾之子。」乃立紂。紂卒以暴虐亡殷國。孔鄭玄義曰:物之大者莫若於天;推父比天,與之相配,行孝之大,莫大於此;所謂「嚴父莫大於配天」也。又孔氏曰:禮記稱萬物本乎天,人本乎祖。俱為其本,可以相配,故王者皆以祖配天。諡法:除殘去虐曰湯。然諡法起于周;蓋殷人先有此號,周人遂引以為諡法。分,扶問翻。長,知兩翻。卒,子恤翻。以季札而君吳則太伯血食矣, 吳王壽夢有子四人:長曰諸樊,次曰餘祭,次曰餘昩,次曰季札。季札賢,壽夢欲立之,季札讓不可,於是立諸樊。諸樊卒,以授餘祭,欲兄弟以次相傳,必致國于季札;季札終讓而逃之。其後諸樊之子光與餘昩之子僚爭國,至於夫差,吳遂以亡。宗廟之祭用牲,故曰血食。太伯,吳立國之君。范寧曰:太者,善大之稱;伯者,長也。周太王之元子,故曰太伯。陸德明曰:壽夢,莫公翻。餘祭,側介翻。餘昩,音末。然二子寧亡國而不為者,誠以禮之大節不可亂也。故曰禮莫大於分也。\n〖译文〗 周文王演绎排列《易经》,以乾、坤为首位。孔子解释说:“天尊贵,地卑微,阳阴于是确定。由低至高排列有序,贵贱也就各得其位。”这是说君主和臣子之间的上下关系就像天和地一样不能互易。《春秋》一书贬低诸侯,尊崇周王室,尽管周王室的官吏地位不高,在书中排列顺序仍在诸侯国君之上,由此可见孔圣人对于君臣关系的关注。如果不是夏桀、商纣那样的暴虐昏君,对手又遇上商汤、周武王这样的仁德明主,使人民归心、上天赐命的话,君臣之间的名分只能是作臣子的恪守臣节,矢死不渝。所以如果商朝立贤明的微子为国君来取代纣王,成汤创立的商朝就可以永配上天;而吴国如果以仁德的季札做君主,开国之君太伯也可以永享祭祀。然而微子、季札二人宁肯国家灭亡也不愿做君主,实在是因为礼教的大节绝不可因此破坏。所以说,礼教中最重要的就是地位高下的区分。\n夫禮,辨貴賤,序親疏,裁群物,制庶事,非名不著,非器不形;名以命之,器以別之, 夫,音扶。別,彼列翻。然後上下粲然有倫,此禮之大經也。名器既亡,則禮安得獨在哉!昔仲叔于奚有功于衛,辭邑而請繁纓,孔子以為不如多與之邑。惟名與器,不可以假人,君之所司也;政亡則國家從之。 左傳:衛孫桓子帥師與齊師戰於新築‹河北大名南›,衛師敗績。新築人仲叔于奚救孫桓子,桓子是以免。既而衛人賞之邑,辭;請曲縣、繁纓以朝,許之。孔子聞之曰:「不如多與之邑,惟名與器不可以假人。」繁纓,馬飾也。繁,馬鬣上飾;纓,馬膺前飾。晉志註曰:纓在馬膺如索帬。繁,音蒲官翻。纓,伊盈翻。索,昔各翻。 衛君待孔子而為政,孔子欲先正名,以為名不正則民無所措手足。 見論語。夫繁纓,小物也,而孔子惜之;正名,細務也,而孔子先之: 先,悉薦翻。誠以名器既亂則上下無以相保故也。夫事未有不生於微而成于著,聖人之慮遠,故能謹其微而治之。 治,直之翻;下同。眾人之識近,故必待其著而後救之;治其微則用力寡而功多,救其著則竭力而不能及也。易曰:「履霜堅冰至,」坤初六爻辭。象曰:「履霜堅冰,陰始凝也。馴致其道,至堅冰也。」書曰:「一日二日萬幾,」皋陶謨之辭。孔安國註曰:幾,微也。言當戒懼萬事之微。幾,居依翻。謂此類也。故曰分莫大於名也。 分,扶問翻。\n〖译文〗 所谓礼教,在于分辨贵贱,排比亲疏,裁决万物,处理日常事物。没有一定的名位,就不能显扬;没有器物,就不能表现。只有用名位来分别称呼,用器物来分别标志,然后上下才能井然有序。这就是礼教的根本所在。如果名位、器物都没有了,那么礼教又怎么能单独存在呢!当年仲叔于奚为卫国建立了大功,他谢绝了赏赐的封地,却请求允许他享用贵族才应有的马饰。孔子认为不如多赏赐他一些封地,惟独名位和器物,绝不能假与他人,这是君王的职权象征;处理政事不坚持原则,国家也就会随着走向危亡。卫国国君期待孔子为他崐处理政事,孔子却先要确立名位,认为名位不正则百姓无所是从。马饰,是一种小器物,而孔子却珍惜它的价值;正名位,是一件小事情,而孔子却要先从它做起,就是因为名位、器物一紊乱,国家上下就无法相安互保。没有一件事情不是从微小之处产生而逐渐发展显著的,圣贤考虑久远,所以能够谨慎对待微小的变故及时予以处理;常人见识短浅,所以必等弊端闹大才来设法挽救。矫正初起的小错,用力小而收效大;挽救已明显的大害,往往是竭尽了全力 也不能成功。《易经》说:“行于霜上而知严寒冰冻将至。”《尚书》说:“先王每天都要兢兢业业地处理成千上万件事情。”就是指这类防微杜渐的例子。所以说,区分地位高下最重要的是匡正各个等级的名分。\n嗚呼!幽‹姬宫湦shēng›、厲‹姬胡›失德,周道日衰,綱紀散壞,下陵上替,諸侯專征, 謂齊桓公,晉文公至悼公以及楚莊王、吳夫差之類。大夫擅政,謂晉六卿、魯三家、齊田氏之類。禮之大體什喪七八矣,喪,息浪翻。然文‹姬昌›、武‹姬发›之祀猶綿綿相屬者,屬,聯屬也,音之欲翻。凡聯屬之屬皆同音。蓋以周之子孫尚能守其名分故也。何以言之?昔晉文公‹姬重耳›有大功於王室,請隧於襄王‹姬郑›,襄王不許,曰:「王章也。未有代德而有二王,亦叔父之所惡也。不然,叔父有地而隧,又何請焉!」文公於是懼而不敢違。太叔帶之難,襄王出居於氾fàn‹河南襄城›。晉文公帥師納王,殺太叔帶。既定襄王於郟jiá,王勞之以地,辭;請隧焉,王弗許云云。杜預曰:闕地通路曰隧,此乃王者葬禮也。諸侯皆縣柩而下。王章者,章顯王者異於諸侯。古者天子謂同姓諸侯為伯父、叔父。隧,音遂。惡,烏路翻。難,乃旦翻。氾,音泛。勞,力到翻。闕,其月翻。縣,音玄。柩,其久翻。是故以周之地則不大於曹‹山東定陶›、滕‹山東滕州›,以周之民則不眾於邾‹山东邹县›、莒‹山東莒縣›,曹、滕、邾、莒,春秋時小國。莒,居許翻。然歷數百年,宗主天下,雖以晉、楚、齊、秦之強不敢加者,何哉?徒以名分尚存故也。至於季氏之於魯,田常之于齊,白公之于楚,智伯之于晉,魯大夫季氏,自季友以來,世執魯國之政。季平子逐昭公,季康子逐哀公,然終身北面,不敢篡國。田常,即陳恒。田氏本陳氏;溫公避國諱,改「恒」曰「常」。陳成子得齊國之政,殺闞止,弑簡公,而亦不敢自立。史記世家以陳敬仲完為田敬仲完,陳成子恒為田常,故通鑑因以為據。白公勝殺楚令尹子西、司馬子期,石乞曰:「焚庫弑王,不然不濟!」白公曰:「弑王不祥,焚庫無聚。」智伯當晉之衰,專其國政,侵伐鄰國,于晉大夫為最強;攻晉出公,出公道死。智伯欲并晉而不敢,乃奉哀公驕立之。其勢皆足以逐君而自為,然而卒不敢者,卒,子恤翻,終也。豈其力不足而心不忍哉,乃畏奸名犯分而天下共誅之也。奸,居寒翻,亦犯也。分,扶問翻。今晉大夫暴蔑其君,剖分晉國,史記六國年表:定王十六年,趙、魏、韓滅智伯,遂三分晉國。天子既不能討,又寵秩之,使列於諸侯,是區區之名分復不能守而并棄之也。陸德明經典釋文:凡復字,其義訓又者,并音扶又翻。先王之禮於斯盡矣!\n〖译文〗 呜呼!周幽王、周厉王丧失君德,周朝的气数每况愈下。礼纪朝纲土崩瓦解;下欺凌、上衰败;诸侯国君恣意征讨他人;士大夫擅自干预朝政;礼教从总体上已经有十之七八沦丧了。然而周文王、周武王开创的政权还能绵绵不断地延续下来,就是因为周王朝的子孙后裔尚能守定名位。为什么这样说呢?当年晋文公为周朝建立了大功,于是向周襄王请求允许他死后享用王室的隧葬礼制,周襄王没有准许,说:“周王制度明显。没有改朝换代而有两个天子,这也是作为叔父辈的晋文公您所反对的。不然的话,叔父您有地,愿意隧葬,又何必请示我呢?”晋文公于是感到畏惧而没有敢违反礼制。因此,周王室的地盘并不比曹国、滕国大,管辖的臣民也不比邾国、莒国多,然而经过几百年,仍然是天下的宗主,即使是晋、楚、齐、秦那样的强国也还不敢凌驾于其上,这是为什么呢?只是由于周王还保有天子的名分。再看看鲁国的大夫季氏、齐国的田常、楚国的白公胜、晋国的智伯,他们的势力都大得足以驱逐国君而自立,然而他们到底不敢这样做,难道是他们力量不足或是于心不忍吗?只不过是害怕奸夺名位僭犯身分而招致天下的讨伐罢了。现在晋国的三家大夫欺凌蔑视国君,瓜分了晋国,作为天子的周王不能派兵征讨,反而对他们加封赐爵,让他们列位于诸侯国君之中,这样做就使周王朝仅有的一点名分不能再守定而全部放弃了。周朝先王的礼教到此丧失干净!\n或者以為當是之時,周室微弱,三晉強盛,三家分晉國,時因謂之「三晉」,猶後之三秦、三齊也。雖欲勿許,其可得乎!是大不然。夫三晉雖強,苟不顧天下之誅而犯義侵禮,則不請於天子而自立矣。不請於天子而自立,則為悖逆之臣,夫,音扶。悖,蒲內翻,又蒲沒翻。天下苟有桓、文之君,必奉禮義而征之。今請於天子而天子許之,是受天子之命而為諸侯也,誰得而討之!故三晉之列于諸侯,非三晉之壞禮,乃天子自壞之也。壞,音怪,人毀之也。\n〖译文〗 有人认为当时周王室已经衰微,而晋国三家力量强盛,就算周王不想承认他们,又怎么能做得到呢!这种说法是完全错误的。晋国三家虽然强悍,但他们如果打算不顾天下的指责而公然侵犯礼义的话,就不会来请求周天子的批准,而是去自立为君了。不向天子请封而自立为国君,那就是叛逆之臣,天下如果有像齐桓公、晋文公那样的贤德诸侯,一定会尊奉礼义对他们进行征讨。现在晋国三家向天子请封,天子又批准了。他们就是奉天子命令而成为诸侯的,谁又能对他们加以讨伐呢!所以晋国三家大夫成为诸侯,并不是晋国三家破坏了礼教,正是周天子自已破坏了周朝的礼教啊!\n烏呼!君臣之禮既壞矣,此壞,其義為成壞之壞,讀如字。則天下以智力相雄長,長,知兩翻。遂使聖賢之後為諸侯者,社稷無不泯絕,謂齊、宋亡于田氏,魯、陳、越亡于楚,鄭亡于韓也。泯,彌忍翻,盡也,又彌鄰翻。毛晃曰:沒也,滅也。生民之類糜滅幾盡,說文曰:糜,糝sǎn也;取糜爛之義,音忙皮翻。幾,居依翻,又渠希翻,近也。豈不哀哉!\n〖译文〗 呜呼!君臣之间的礼纪既然崩坏,于是天下便开始以智慧、武力互相争雄,使当年受周先王分封而成为诸侯国君的圣贤后裔,江山相继沦亡,周朝先民的子孙灭亡殆尽,岂不哀伤!\n2初,智宣子將以瑤為後,智果曰:「不如宵也。韋昭曰:智宣子,晉卿荀躒之子申也,瑤,宣子之子智伯也,諡曰襄子。智果,智氏之族也。宵,宣子之庶子也。按諡法:聖善周聞曰宣。智氏溢美也。瑤之賢於人者五,其不逮者一也。韋昭曰:不仁也。美鬢長大則賢,通鑑俗傳寫者多作「美鬚」,非也。國語作「美鬢」,今從之。【章︰十二行本正作「鬢」;孔本同。乙十一行本作「鬚」。】射御足力則賢,伎藝畢給則賢,巧文辯惠則賢,韋昭曰:給,足也。巧文,巧於文辭。伎,渠綺翻。強毅果敢則賢;如是而甚不仁。夫以其五賢陵人而以不仁行之,其誰能待之?韋昭曰:待,猶假也。若果立瑤也,智宗必滅。」弗聽。智果別族於太史,為輔氏。此事見國語。按左傳哀公二十三年,晉荀瑤伐齊,始見於傳。哀二十三年,史記元王五年也。荀躒,智文子也。定十四年,智文子猶見於傳。智宣子之事,傳無所考。立瑤之議,當在元王五年之前。韋昭曰:太史掌氏姓,周禮春官之屬;小史掌定世系,辨昭穆。鄭司農註云:史官主書,故韓宣子聘魯,觀書于太史。世系謂帝系、世本之屬是也;小史主定之。賈公彥疏曰,註引太史證之者,太史史官之長,共其事故也。蓋周之制,小史定姓氏,其書則太史掌之。智果欲避智氏之禍,故於太史別族。宋祁國語補音:別,彼列翻;又如字。\n〖译文〗 [2]当初,晋国的智宣子想以智瑶为继承人,族人智果说:“他不如智宵。智瑶有超越他人的五项长处,只有一项短处。美发高大是长处,精于骑射是长处,才艺双全是长处,能写善辩是长处,坚毅果敢是长处。虽然如此却很不仁厚。如果他以五项长处来制服别人而做不仁不义的恶事,谁能和他和睦相处?要是真的立智瑶为继承人,那么智氏宗族一定灭亡。”智宣子置之不理。智果便向太史请求脱离智族姓氏,另立为辅氏。\n趙簡子之子,長曰伯魯,幼曰無恤。趙簡子,文子之孫鞅也。諡法:一德不懈曰簡。白虎通曰:子,孳zī也,孳孳無已也。趙岐曰:子者,男子之通稱也。長,知兩翻。將置後,不知所立,乃書訓戒之辭于二簡,孔穎達曰:書者,舒也。書緯璇璣鈐qián云:書者,如也。則書者,寫其言如其意,得展舒也。世本曰:沮誦、蒼頡作書。釋文名曰:書,庶也,紀庶物也;亦言著也,著之簡紙,求不滅也。簡,竹策也。以授二子曰:「謹識之!」識,職吏翻,記也。三年而問之,伯魯不能舉其辭;求其簡,已失之矣。問無恤,誦其辭甚習;習,熟也。求其簡,出諸袖中而奏之。毛晃曰:奏,進上也。於是簡子以無恤為賢,立以為後。\n〖译文〗 赵国的大夫赵简子的儿子,长子叫伯鲁,幼子叫无恤。赵简子想确定继承人,不知立哪位好,于是把他的日常训诫言词写在两块竹简上,分别交给两个儿子,嘱咐说:“好好记住!”过了三年,赵简子问起两个儿子,大儿子伯鲁说不出竹简上的话;再问他的竹简,已丢失了。又问小儿子无恤,竟然背诵竹简训词很熟习;追问竹简,他便从袖子中取出献上。于是,赵简子认为无恤十分贤德,便立他为继承人。\n簡子使尹鐸為晉陽‹山西太原›,姓譜:尹,少昊之子,封于尹城,子孫因為氏。韋昭曰:晉陽,趙氏邑。為,治也。班志曰:晉陽,故詩唐國。周成王滅唐,封弟叔虞。龍山在西,晉水所出,東入汾。臣瓚曰:所謂唐,今河東永安縣是也,去晉四百里。括地志曰:晉陽故城,今名晉城,在蒲州虞鄉縣西。今按水經註:晉水出晉陽縣西龍山。昔智伯遏晉水以灌晉陽,其水分為二流,北瀆即智氏故渠也。同過水出沾縣北山,西過榆次縣南,又西到晉陽縣南。榆次縣南水側有鑿臺,戰國策所謂「智伯死于鑿臺之下」,即此處也。參而考之,晉陽故城恐不在蒲州。水經註又云:叔虞封于唐,縣有晉水,故改名為晉。子夏序詩,「此晉也而謂之唐」,是也,與班志合。瓚說及括地志未知何據。請曰:「以為繭絲乎?抑為保障乎?」簡子曰:「保障哉!」繭絲,謂浚民之膏澤,如抽繭之緒,不盡則不止。保障,謂厚民之生,如築堡以自障,愈培則愈厚。宋祁曰:障,之亮翻,又音章。尹鐸損其戶數。韋昭曰:損其戶,則民優而稅少。簡子謂無恤曰:「晉國有難,而無以尹鐸為少,而,汝也。難,乃旦翻,患也,厄也。少,音多少之少。重之為多,輕之為少。無以晉陽為遠,必以為歸。」\n〖译文〗 赵简子派尹铎去晋阳,临行前尹铎请示说:“您是打算让我去抽丝剥茧般地搜刮财富呢,还是作为保障之地?”赵简子说:“作为保障。”尹铎便少算居民户数,减轻赋税。赵简子又对儿子赵无恤说:“一旦晋国发生危难,你不要嫌尹铎地位不高,不要怕晋阳路途遥远,一定要以那里作为归宿。”\n及智宣子卒,卒,子恤翻。智襄子為政,諡法:有勞定國曰襄。為政,為晉國之政。與韓康子、魏桓子宴于藍台。韓康子,韓宣子之曾孫莊子之子虔虎也。魏桓子,魏獻子之子曼多之孫駒也。諡法:溫柔好樂曰康;辟土服遠曰桓。爾雅:四方而高曰台。智伯戲康子而侮段規。姓譜:段,鄭共叔段之後。智國聞之,諫曰:「主不備難,【章︰十二行本無「難」字;乙十一行本同。】難必至矣!」春秋以來,大夫之家臣謂大夫曰主。難,乃旦翻;下同。智伯曰:「難將由我。我不為難,誰敢興之!」對曰:「不然。夏書有之:『一人三失,怨豈在明,不見是圖。』書五子之歌之辭。夏,戶雅翻。見,賢遍翻,發見也,著也,形也。夫君子能勤小物,故無大患。今主一宴而恥人之君相,夫,音扶。段規,韓康子之相也。相,息醬翻;下同。又弗備:曰『不敢興難』,無乃不可乎!蜹ruì、蟻、蜂、蠆,皆能害人,宋祁曰:蜹,如銳翻;又字林:人劣翻。秦人謂蚊為蜹。今按:蜹,小蟲,日中群集人之肌膚而嘬zuō其血,蚊之類也。蜂,細腰而能螫人。蠆亦毒蟲,長尾,音丑邁翻。況君相乎!」弗聽。\n〖译文〗 等到智宣子去世,智襄子智瑶当政,他与韩康子、魏桓子在蓝台饮宴,席间智瑶戏弄韩康子,又侮辱他的家相段规。智瑶的家臣智国听说此事,就告诫说:“主公您不提防招来灾祸,灾祸就一定会来了!”智瑶说:“人的生死灾祸都取决于我。我不给他们降临灾祸,谁还敢兴风作浪!”智国又说:“这话可不妥。《夏书》中说:‘一个人屡次三番犯错误,结下的仇怨岂能在明处,应该在它没有表现时就提防。’贤德的人能够谨慎地处理小事,所以不会招致大祸。现在主公一次宴会就开罪了人家的主君和臣相,又不戒备,说:‘不敢兴风作浪。’这种态度恐怕不行吧。蚊子、蚂蚁、蜜蜂、蝎子,都能害人,何况是国君、国相呢!”智瑶不听。\n智伯請地于韓康子,康子欲弗與。段規曰:「智伯好利而愎,不與,將伐我;不如與之。彼狃niǔ於得地,好,呼到翻。愎,弼力翻,狠也。狃,女九翻,驕忲tài也,又相狎也。必請於他人;他人不與,必向之以兵,然後【章︰十二行本「後」作「則」;乙十一行本同。】我得免於患而待事之變矣。」康子曰:「善。」使使者致萬家之邑于智伯。毛晃曰:邑,都邑。四井為邑,四邑為丘;邑方二里,丘方四里。載師以公邑之田任甸地,以家邑之田任稍地。註:公邑,謂六遂餘地。家邑,大夫之采地。此又與四井之邑不同。又都,國都;邑,縣也。左傳:凡邑有先君宗廟之主曰都,無曰邑。邑曰築,都曰城。此謂大縣邑也。杜預引周禮「四縣為都,四井為邑」,恐誤。四井之邑方二里,豈能容宗廟城郭!如論語「十室之邑」,西都賦「都都相望,邑邑相屬」,則是四縣四井之都邑也。若千室之邑、萬家之邑,則非井邑矣。項安世曰:小司徒井牧田野,以四井為邑,凡三十六家;除公田四夫,凡三十二家;遂大夫會為邑者之政,以里為邑,凡二十五家。遂大夫蓋論里井之制,二十五家共一里門,即六鄉之二十五家為一閭也;小司徒蓋論溝洫之制,四井為邑,共用一溝,即匠人所謂「井間廣四尺深四尺謂之溝」也。居則度人之眾寡,溝則度水之眾寡,此其所以異歟!毛、項二說皆明周制,參而考之,戰國之所謂邑非周制矣。致,送至也。智伯悅。又求地于魏桓子,桓子欲弗與。任章曰:「何故弗與?」任章,魏桓子之相也。姓譜:黃帝二十五子,十二人各以德為姓,第一曰任氏。又任為風姓之國,實太昊之後,主濟祀,今濟州任城即其地。任,市林翻。桓子曰:「無故索地,故弗與。」任章曰:「無故索地,諸大夫必懼;索,山客翻,求也,吾與之地,智伯必驕。彼驕而輕敵,此懼而相親。以相親之兵待輕敵之人,智氏之命必不長矣。周書曰:『將欲敗之,必姑輔之。將欲取之,必姑與之。』逸書也。敗,補邁翻。主不如與之,以驕智伯,然後可以擇交而圖智氏矣,奈何獨以吾為智氏質乎!」質,脂利翻,物相綴當也。又質讀如字,亦通。質,謂椹質也,質的也。椹質受斧,質的受矢。言智伯怒魏桓子,必加兵于魏,如椹質之受斧,質的之受矢也。桓子曰:「善。」復與之萬家之邑一。復,扶又翻。\n〖译文〗 智瑶向韩康子要地,韩康子想不给。段规说:“智瑶贪财好利,又刚愎自用,如果不给,一定讨伐我们,不如姑且给他。他拿到地更加狂妄,一定又会向别人索要;别人不给,他必定向人动武用兵,这样我们就可以免于祸患而伺机行动了。”韩康子说:“好主意。”便派了使臣去送上有万户居民的领地。智瑶大喜,果然又向魏桓子提出索地要求,魏桓子想不给。家相任章问:“为崐什么不给呢?”魏桓子说:“无缘无故来要地,所以不给。”任章说:“智瑶无缘无故强索他人领地,一定会引起其他大夫官员的警惧;我们给智瑶地,他一定会骄傲。他骄傲而轻敌,我们警惧而互相亲善;用精诚团结之兵来对付狂妄轻敌的智瑶,智家的命运一定不会长久了。《周书》说:‘要打败敌人,必须暂时听从他;要夺取敌人利益,必须先给他一些好处。’主公不如先答应智瑶的要求,让他骄傲自大,然后我们可以选择盟友共同图谋,又何必单独以我们作智瑶的靶子呢!”魏桓子说:“对。”也交给智瑶一个有万户的封地。\n智伯又求蔡‹藺,山西离石西›、皋狼‹山西離石縣西北›之地于趙襄子,康曰:皋,姑勞切;狼,盧當切;春秋蔡地,後為趙邑。余據春秋之時,晉、楚爭盟,晉不能越鄭而服蔡。三家分晉,韓得成皋,因以并鄭,時蔡已為楚所滅,鄭之南境亦入于楚,就使皋狼為蔡地,趙襄子安得而有之!漢書地理志西河郡有皋狼縣,又有藺縣。漢之西河,春秋以來皆為晉境,而古文「藺」字與「蔡」字近,或者「蔡」字其「藺」字之訛也。襄子弗與。智伯怒,帥韓、魏之甲以攻趙氏。帥,讀曰率。襄子將出,曰:「吾何走乎?」走,則豆翻,疾趨之也。趨,七喻翻。從者曰:「長子‹山西长子›近,且城厚完。」從,才用翻。長子縣,周史辛伯所封邑。班志屬上黨郡。陸德明曰:長子之長,丁丈翻。顏師古曰:長,讀為短長之長;今讀為長幼之長,非也。崔豹古今註曰:城,盛也,所以盛受民物也。淮南子曰:鯀作城。盛,時征翻。襄子曰:「民罷力以完之,罷,讀曰疲。又斃死以守之,其誰與我!」韋昭曰:謂誰與我同力也。從者曰:「邯鄲‹河北邯鄲›之倉庫實。」邯鄲,即春秋邯鄲午之邑也。班志,邯鄲縣屬趙國。張晏曰:邯鄲山在東城下。單,盡也。城郭從邑,故旁加邑。宋白曰:邯鄲本衛地,後屬晉;七國時為趙都,趙敬侯自晉陽始都邯鄲。余按史記六國年表,周安王之十六年,趙敬侯之元年;烈王之二年,趙成侯之元年。成侯二十二年,魏克邯鄲,是年顯王之十六年也。二十四年,魏歸邯鄲。若敬侯已都邯鄲,魏克其國都而趙不亡,何也?至顯王二十二年,公子范襲邯鄲,不勝而死,是年肅侯之三年也。意此時趙方都邯鄲,蓋肅侯徙都,非敬侯也。邯,音寒。鄲,音丹,康多寒切。襄子曰:「浚民之膏澤以實之,韋昭曰:浚,煎也,讀曰醮jiào。宋祁曰:浚,蘇俊翻;醮,子召翻;余謂浚讀當如宋音。浚者,疏瀹yuè也,淘也,深也。又因而殺之,其誰與我!其晉陽‹山西太原›乎,先主之所屬也,古者諸侯之大夫,其家之臣子皆稱之曰主,死則曰先主,考左傳可見已。屬,陟玉翻。尹鐸之所寬也,民必和矣。」乃走晉陽。\n〖译文〗 智瑶又向赵襄子要蔡和皋狼的地方。赵襄子拒绝不给。智瑶勃然大怒,率领韩、魏两家甲兵前去攻打赵家。赵襄子准备出逃。问:“我到哪里去呢?”随从说:“长子城最近,而且城墙坚厚又完整。”赵襄子说:“百姓精疲力尽地修完城墙,又要他们舍生入死地为我守城,谁能和我同心?”随从又说:“邯郸城里仓库充实。”赵襄子说:“搜刮民脂民膏才使仓库充实,现在又因战争让他们送命,谁会和我同心。还是投奔晋阳吧,那是先主的地盘,尹铎又待百姓宽厚,人民一定能同我们和衷共济。”于是前往晋阳。\n三家以國人圍而灌之,城不浸者三版;高二尺為一版;三版,六尺。沈竈產鼃wā,民無叛意。沈,持林翻。顏師古漢書音義曰,鼃,黽měng也,似蝦蟆而長腳,其色青。史遊急就章曰:蛙,蝦蟆。陸佃埤雅曰;鼃,似蝦蟆而長踦,瞋目如怒。鼃,與蛙同,音下媧翻。智伯行水,據經典釋文,凡巡行之行,音下孟翻;後仿此。魏桓子御,韓康子驂乘。兵車,尊者居左,執弓矢;御者居中;有力者居右,持矛以備傾側,所謂車右是也。韓、魏畏智氏之強,一為之御,一為之右。驂,與參同,參者,三也。三人同車則曰驂乘,四人同車則曰駟乘。左傳:齊伐晉,燭庸之越駟乘。杜預註曰:四人共乘者殿車。乘,石證翻。智伯曰:「吾乃今知水可以亡人國也。」桓子肘康子:康子履桓子之跗,以汾水可以灌安邑‹山西夏縣›,絳水可以灌平陽‹山西臨汾›也。跗,音夫,足趾也。班志:汾水出汾陽北山。汾陽縣屬太原郡,安邑縣屬河東郡。史記正義曰:安邑故城在絳州夏縣東北十五里。應劭shào曰:絳水出河東絳縣西南。平陽縣亦屬河東郡。安邑,魏絳始居邑。平陽,韓武子玄孫貞子始居之。桓、康二子之肘足接,蓋各為都邑慮也。水經註曰:絳水出絳縣西南,蓋以故絳為言,其水出絳山東,西北流而合於澮huì,猶在絳縣界中。智伯所謂「汾水可以灌安邑」,或亦有之;「絳水可以灌平陽」,未識所由。余謂自春秋之季至於元魏,歷年滋多,郡縣之離合,川谷之遷改,有不可以一時所睹為據者。史記正義曰:韓初都平陽,今晉州也。括地志曰:絳水一名白,今名沸泉,源出絳山,飛泉奮湧,揚波注縣,積壑三十餘丈,望之極為奇觀,可接引北灌平陽城。酈道元父范,歷仕三齊,少長齊地,熟其山川,後入關死於道,未嘗至河東也。此蓋因耳學而致疑。括地志成于唐之魏王泰,泰者,太宗之愛子,羅致天下一時名儒以作此書,其考據宜詳,當取以為據。絺chī疵謂智伯曰:「韓、魏必反矣。」智伯曰:「子何以知之?」絺疵曰:「以人事知之。夫從韓、魏之兵以攻趙,趙亡,難必及韓、魏矣。夫,音扶。難,乃旦翻。今約勝趙而三分其地,城不沒者三版,人馬相食,城降有日,而二子無喜志,有憂色,是非反而何?」明日,智伯以絺疵之言告二子,二子曰:「此夫讒人欲為趙氏遊說,使主疑於二家而懈于攻趙氏也。不然,夫二家豈不利朝夕分趙氏之田,而欲為危難不可成之事乎!」二子出,絺疵入曰:「主何以臣之言告二子也?」智伯曰:「子何以知之?」對曰:「臣見其視臣端而趨疾,知臣得其情故也。」智伯不悛quān。絺疵請使于齊。夫,音扶;餘并同。難,乃旦翻。降,戶江翻,下也,服也。說,輸芮翻。懈,居隘翻,怠也。危難,如字。悛,丑緣翻,改也,止也。絺,抽遲翻,姓也。康曰:「絺」當作「郗」,姓譜諸書未有從絲者,疑借字。余按姓譜:絺姓,周蘇忿生支子,封於絺,因氏焉。為趙之為,音於偽翻。使,疏吏翻。疵請出使以避禍也。\n〖译文〗 智瑶、韩康子、魏桓子三家围住晋阳,引水灌城。城墙头只差三版的地方没有被淹没,锅灶都被泡塌,青蛙孳生,人民仍是没有背叛之意。智瑶巡视水势,魏桓子为他驾车,韩康子站在右边护卫。智瑶说:“我今天才知道水可以让人亡国。”魏桓子用胳膊肘碰了一下韩康子,韩康子也踩了一下魏桓子脚。因为汾水可以灌魏国都城安邑,绛水也可以灌韩国都城平阳。智家的谋士疵对智瑶说:“韩、魏两家肯定会反叛。”智瑶问:“你何以知道?”疵说:“以人之常情而论。我们调集韩、魏两家的军队来围攻赵家,赵家覆亡,下次灾难一定是连及韩、魏两家了。现在我们约定灭掉赵家后三家分割其地,晋阳城仅差三版就被水淹没,城内宰马为食,破城已是指日可待。然而韩康子、魏桓子两人没有高兴的心情,反倒面有忧色,这不是必反又是什么?”第二天,智瑶把疵的话告诉了韩、魏二人,二人说:“这一定是离间小人想为赵家游说,让主公您怀疑我们韩、魏两家而放松对赵家的进攻。不然的话,我们两家岂不是放着早晚就分到手的赵家田土不要,而要去干那危险必不可成的事吗?”两人出去,疵进来说:“主公为什么把臣下我的话告诉他们两人呢?”智瑶惊奇地反问:“你怎么知道的?”回答说:“我见他们认真看我而匆忙离去,因为他们知道我看穿了他们的心思。”智瑶不改。于是疵请求让他出使齐国。\n趙襄子使張孟談潛出見二子,曰:「臣聞唇亡則齒寒。今智伯帥韓、魏以攻趙,趙亡則韓、魏為之次矣。」帥,讀曰率。二子曰:「我心知其然也;恐事未遂而謀泄,則禍立至矣。」張孟談曰:「謀出二主之口,入臣之耳,何傷也!」二子乃潛與張孟談約,為之期日而遣之。姓譜:張氏本自軒轅第五子揮,始造弦,寔shí張網羅,世掌其職,後因氏焉。風俗傳云:張、王、李、趙,黃帝所賜姓也。又晉有解張,字張侯,自此晉國有張氏。唐姓氏譜:張氏出自姬姓,黃帝子少昊青陽氏第五子揮正始制弓矢,子孫賜姓張。周宣王卿士張仲,其後裔事晉為大夫。襄子夜使人殺守堤之吏,而決水灌智伯軍。智伯軍救水而亂,韓、魏翼而擊之,襄子將卒犯其前,將,即亮翻,又音如字。將,領也。卒,臧沒翻。說文:吏人給事者衣為卒,卒衣有題識;其字從「衣」從「十」。大敗智伯之眾,以此敗彼曰敗。敗,比邁翻。遂殺智伯,盡滅智氏之族。史記六國年表,三晉滅智氏在周定王十六年,上距獲麟二十七年。皇甫謐曰:元王十一年癸未,三晉滅智伯。唯輔果在。以別族也。\n〖译文〗 赵襄子派张孟谈秘密出城来见韩、魏二人,说:“我听说唇亡齿寒。现在智瑶率领韩、魏两家来围攻赵家,赵家灭亡就该轮到韩、魏了。”韩康子、魏崐桓子也说:“我们心里也知道会这样,只怕事情还未办好而计谋先泄露出去,就会马上大祸临头。”张孟谈又说:“计谋出自二位主公之口,进入我一人耳朵,有何伤害呢?”于是两人秘密地与张孟谈商议,约好起事日期后送他回城了。夜里,赵襄子派人杀掉智军守堤官吏,使大水决口反灌智瑶军营。智瑶军队为救水淹而大乱,韩、魏两家军队乘机从两翼夹击,赵襄子率士兵从正面迎头痛击,大败智家军,于是杀死智瑶,又将智家族人尽行诛灭。只有辅果得以幸免。\n臣光曰:智伯之亡也,才勝德也。夫才與德異,而世俗莫之能辨,夫,音扶。通謂之賢,此其所以失人也。夫聰察強毅之謂才,正直中和之謂德。才者,德之資也,德者,才之帥也。夫,音扶。帥,所類翻。雲夢‹湖北安陆南›之竹,天下之勁也;書禹貢:雲土夢作乂yì。孔安國註云:雲夢之澤在江南。左傳:楚王以鄭伯田江南之夢。杜預註云:楚之雲夢跨江南北。班志:雲夢澤在南郡華容縣南。祝穆曰:據左傳鄖夫人棄子文於夢中,言夢而不言雲,楚子避吳入於雲中,言雲而不言夢,則知雲、夢二澤也。漢陽志:雲在江之北,夢在江之南。又安陸有雲夢澤,枝江有雲夢城。蓋古之雲夢澤甚廣,而後世悉為邑居聚落,故地之以雲夢得名者非一處。竹箭之產,荊楚為良;雲夢,楚之地也。夢,如字,又莫公翻。然而不矯揉,不羽括,則不能以入堅。矯,舉夭翻。揉,如久翻。康曰:揉曲為矯,揉所以橈曲而使之直也。羽者,箭翎。括者,箭窟受弦處。括,音聒,通作「筈」。kuò棠溪‹河南西平西北›之金,天下之利也;左傳:楚封吳夫概王於棠溪。戰國之時,其地屬韓,出金甚精利。劉昭郡國志:汝南郡吳房縣有棠溪亭。杜佑通典曰:棠溪在今汝州郾城縣界。九域志:蔡州有冶爐城,韓國鑄劍之地。然而不鎔范,不砥礪,則不能以擊強。毛晃曰:鎔,銷也,鑄也;說文:鑄器法也。董仲舒傳:猶金在鎔。註:鎔,謂鑄器之模范。范,法也,式也。禮運:范金合土。砥,軫氏翻,柔石也。礪,力制翻,䃺也。是故才德全盡謂之「聖人」,才德兼亡謂之「愚人」;德勝才謂之「君子」,才勝德謂之「小人」。凡取人之術,苟不得聖人、君子而與之,與其得小人,不若得愚人。何則?君子挾才以為善,小人挾才以為惡。挾才以為善者,善無不至矣;挾才以為惡者,惡亦無不至矣。挾,檄頰翻。愚者雖欲為不善,智不能周,力不能勝,譬如乳狗搏人,人得而制之。挾,戶頰翻。朱元晦曰:挾者,兼有而恃之之稱。勝,音升。乳,儒遇翻,乳育也。乳狗,育子之狗也。搏,伯各翻。小人智足以遂其奸,勇足以決其暴,是虎而翼者也,其為害豈不多哉!虎而傅翼,其為害也愈甚。夫德者人之所嚴,嚴,敬也。而才者人之所愛;愛者易親,嚴者易疏,易,以豉翻。是以察者多蔽於才而遺於德。自古昔以來,國之亂臣,家之敗子,才有餘而德不足,以至於顛覆者多矣,豈特智伯哉!故為國為家者苟能審於才德之分而知所先後,先,悉薦翻。後,戶遘翻。又何失人之足患哉!\n〖译文〗 臣司马光曰:智瑶的灭亡,在于才胜过德。才与德是不同的两回事,而世俗之人往往分不清,一概而论之曰贤明,于是就看错了人。所谓才,是指聪明、明察、坚强、果毅;所谓德,是指正直、公道、平和待人。才,是德的辅助;德,是才的统帅。云梦地方的竹子,天下都称为刚劲,然而如果不矫正其曲,不配上羽毛,就不能作为利箭穿透坚物。棠地方出产的铜材,天下都称为精利,然而如果不经熔烧铸造,不锻打出锋,就不能作为兵器击穿硬甲。所以,德才兼备称之为圣人;无德无才称之为愚人;德胜过才称之为君子;才胜过德称之为小人。挑选人才的方法,如果找不到圣人、君子而委任,与其得到小人,不如得到愚人。原因何在?因为君子持有才干把它用到善事上;而小人持有才干用来作恶。持有才干作善事,能处处行善;而凭借才干作恶,就无恶不作了。愚人尽管想作恶,因为智慧不济,气力不胜任,好像小狗扑人,人还能制服它。而小人既有足够的阴谋诡计来发挥邪恶,又有足够的力量来逞凶施暴,就如恶虎生翼,他的危害难道不大吗!有德的人令人尊敬,有才的人使人喜爱;对喜爱的人容易宠信专任,对尊敬的人容易疏远,所以察选人才者经常被人的才干所蒙蔽而忘记了考察他的品德。自古至今,国家的乱臣奸佞,家族的败家浪子,因为才有余而德不足,导致家国覆亡的多了,又何止智瑶呢!所以治国治家者如果能审察才与德两种不同的标准,知道选择的先后,又何必担心失去人才呢!\n3三家分智氏之田。趙襄子漆智伯之頭,以為飲器。說文:桼qī,木汁可以䰍qī物,下從水,象桼如水滴而下也。漢書張騫傳:匈奴破月氏王,以其頭為飲器。韋昭註曰:飲器,椑pí榼kē也。晉灼曰:飲器,虎子屬也。或曰,飲酒之器也。師古曰:匈奴嘗以月氏王頭與漢使歃血盟,然則飲酒之器是也。韋云椑榼,晉云虎子,皆非也。椑榼,即今之偏榼,所以盛酒耳,非用飲者也。虎子,褻器,所以溲便者。椑,音鼙。榼,克合翻。氏,音支。使,疏吏翻。歃,色甲翻。盛,時征翻。褻,息列翻。溲,疏鳩翻。便,毗連翻。智伯之臣豫讓欲為之報仇,豫,姓也。讓,名也。戰國之時又有豫且,不知其同時否也。為,音於偽翻;下同。乃詐為刑人,挾匕首,入襄子宮中塗廁。挾,持也。劉向曰:匕首,短劍。鹽鐵論曰:匕首長尺八寸;頭類匕,故云匕首。匕,音比。廁,初吏翻,圊qīng也。長,直亮翻。襄子如廁心動,索之,獲豫讓。索,山客翻。左右欲殺之,襄子曰:「智伯死無後,而此人欲為報仇,真義士也,吾謹避之耳。」乃舍之。舍,讀曰捨。豫讓又漆身為癩,吞炭為啞。癩,落蓋翻,惡疾也。啞,倚下翻,瘖yīn也。行乞於市,神農日中為市,致天下之民,聚天下之貨,交易而退,此立市之始也,鄭氏周禮註曰:市,雜聚之處。其妻不識也。行見其友,其友識之,為之泣曰:「以子之才,臣事趙孟,必得近幸,自春秋之時,趙宣子謂之宣孟,趙文子謂之趙孟,其後遂襲而呼為趙孟。孟,長也。子乃為所欲為,顧不易邪?易,以豉翻。何乃自苦如此?求以報仇,不亦難乎!」豫讓曰:【章︰十二行本「曰」下有「不可」二字;乙十一行本同;孔本同;張校同;退齋校同。】「既已委質為臣,經典釋文曰:質,職日翻。委質,委其體以事君也。後漢書註:委質,屈膝。而又求殺之,是二心也。凡吾所為者,極難耳。然所以為此者,將以愧天下後世之為人臣懷二心者也。」襄子出,豫讓伏於橋下。襄子至橋,馬驚;索之,得豫讓,遂殺之。自智宣子立瑤,至豫讓報仇,其事皆在威烈王二十三年之前,故先以「初」字發之。溫公之意,蓋以天下莫大于名分,觀命三大夫為諸侯之事,則知周之所以益微,七雄之所以益盛;莫重于宗社,觀智、趙立後之事,則知智宣子之所以失,趙簡子之所以得;君臣之義當守節伏死而已,觀豫讓之事,則知策名委質者必有霣yǔn而無貳。其為後世之鑒,豈不昭昭也哉!\n〖译文〗 [3]赵、韩、魏三家瓜分智家的田土,赵襄子把智瑶的头骨涂上漆,作为饮具。智瑶的家臣豫让想为主公报仇,就化装为罪人,怀揣匕首,混到赵襄子的宫室中打扫厕所。赵襄子上厕所时,忽然心动不安,令人搜索,抓获了豫让。左右随从要将他杀死,赵襄子说:“智瑶已死无后人,而此人还要为他报仇,真是一个义士,我小心躲避他好了。”于是释放豫让。豫让用漆涂身,弄成一个癞疮病人,又吞下火炭,弄哑嗓音。在街市上乞讨,连结发妻子见面也认不出来。路上遇到朋友,朋友认出他,为他垂泪道:“以你的才干,如果投靠赵家,一定会成为亲信,那时你就为所欲为,不是易如反掌吗?何苦自残形体崐以至于此?这样来图谋报仇,不是太困难了吗!”豫让说:“我要是委身于赵家为臣,再去刺杀他,就是怀有二心。我现在这种做法,是极困难的。然而之所以还要这样做,就是为了让天下与后世做人臣子而怀有二心的人感到羞愧。”赵襄子乘车出行,豫让潜伏在桥下。赵襄子到了桥前,马突然受惊,进行搜索,捕获豫让,于是杀死他。\n襄子為伯魯之不立也,有子五人,不肯置後。封伯魯之子于代‹河北蔚縣›,代國在夏屋句注之北,趙襄子滅之。班志有代郡代縣。為,於偽翻。夏,戶雅翻。曰代成君,早卒;成,諡也。諡法:安民立政曰成。立其子浣為趙氏後。浣,戶管翻。襄子卒,弟桓子逐浣而自立;史記六國表,威烈王元年,襄子卒;二年,趙桓子元年,卒;明年,國人立獻侯浣。「浣」,索隱作「晚」。卒,子恤翻;下同。一年卒。趙氏之人曰:「桓子立非襄主意。」乃共殺其子,復迎浣而立之,是為獻子。復,扶又翻;又音如字。獻子,即獻侯。六國表:威烈王三年,獻侯之元年。蓋分晉之後,三晉僭jiàn侯久矣。諡法:知質有聖曰獻。獻子生籍,是為烈侯。諡法:有功安民曰烈,秉德尊業曰烈。魏斯者,魏桓子之孫也,是為文侯。諡法:學勤好問曰文;慈惠安民曰文。韓康子生武子;武子生虔,是為景侯。諡法;克定禍亂曰武;布義行剛曰景。六國表:威烈王二年,魏文侯斯元年;十八年,韓景侯虔元年。蓋其在國僭爵已久,不敢以通王室;威烈王遂因而命之,識者重為周惜。通鑑於此序三家之世也。\n〖译文〗 赵襄子因为赵简子没有立哥哥伯鲁为继承人,自己虽然有五个儿子,也不肯立为继承人。他封赵伯鲁的儿子于代国,称代成君,早逝;又立其子赵浣为赵家的继承人。赵襄子死后,弟弟赵桓子就驱逐赵浣,自立为国君,继位一年也死了。赵家的族人说:“赵桓子做国君本来就不是赵襄子的主意。”大家一起杀死了赵桓子的儿子,再迎回赵浣,拥立为国君,这就是赵献子。赵献子生子名赵籍,就是赵烈侯。魏斯,是魏桓子的孙子,就是魏文侯。韩康子生子名韩武子,武子又生韩虔,被封为韩景侯。\n魏文侯以卜子夏、田子方為師。卜,以官為氏。田本出於陳,陳敬仲以陳為田氏。徐廣曰:始食埰地,由是改姓田氏。索隱曰:陳、田二聲相近,遂為田氏。夏,戶雅翻。每過段干木之廬必式。過,工禾翻。唐人志氏族曰:李耳,字伯陽,一字聃;其後有李宗,魏封于段,為干木大夫,是以段為氏也。余按:通鑑赧王四十二年,魏有段干子,則段干,複姓也。書:武王式商容閭。註云:式其閭巷,以禮賢。記曲禮:國君撫式,士下之。註云:升車必正立,據式小俛fǔ,崇敬也。師古曰:式,車前橫木。古者立乘;凡言式車者,謂俛首撫式,以禮敬人。孔穎達曰:式,謂俯下頭也。古者車箱長四尺四寸而三分,前一後二,橫一木,下去車牀三尺三寸,謂之為式;又於式上二尺二寸橫一木,謂之較,較去車牀凡五尺五寸。於時立乘,若平常則憑較,故詩云「倚重較兮」是也。若應為敬,則落隱下式,而頭得俯俛,故記云「式視馬尾」是也。較,訖嶽翻。四方賢士多歸之。\n〖译文〗 魏文侯魏斯以卜子夏、田子方为国师,他每次经过名士段干木的住宅,都要在车上俯首行礼。四方贤才德士很多前来归附他。\n文侯與群臣飲酒,樂,而天雨,命駕將適野。左右曰:「今日飲酒樂,天又雨,君將安之?」文侯曰:「吾與虞人期獵,雖樂,豈可無一會期哉?」乃往,身自罷之。周禮有山虞、澤虞,以掌山澤。註云:虞,度也,度知山林之大小及其所生。身自罷之者,身往告之,以雨而罷獵也。樂,音洛。\n〖译文〗 魏文侯与群臣饮酒,奏乐间,下起了大雨,魏文侯却下令备车前往山野之中。左右侍臣问:“今天饮酒正乐,外面又下着大雨,国君打算到哪里去呢?”魏文侯说:“我与山野村长约好了去打猎,虽然这里很快乐,也不能不遵守约定!”于是前去,亲自告诉停猎。\n韓借師于魏以伐趙,文侯曰:「寡人與趙,兄弟也,不敢聞命。」趙借師于魏以伐韓,文侯應之亦然。二國皆怒而去。已而知文侯以講於己也,講,和也。皆朝于魏。朝,直遙翻。魏於是始大於三晉,諸侯莫能與之爭。\n〖译文〗 韩国邀请魏国出兵攻打赵国。魏文侯说:“我与赵国,是兄弟之邦,不敢从命。”赵国也来向魏国借兵讨伐韩国,魏文侯仍然用同样的理由拒绝了。两国使者都怒气冲冲地离去。后来两国得知魏文侯对自己的和睦态度,都前来朝拜魏国。魏国于是开始成为魏、赵、韩三国之首,各诸侯国都不能和它争雄。\n使樂羊伐中山‹都顾城,河北定州›,克之;樂,姓也。本自有殷微子之後。宋戴公四世孫樂呂為大司寇。中山,春秋之鮮虞也,漢為中山郡。宋白曰:唐定州,春秋白狄鮮虞之地。隋圖經曰:中山城在今唐昌縣東北三十一里,中山故城是也。杜佑曰:城中有山,故曰中山。以封其子擊。文侯問於群臣曰:「我何如主?」皆曰:「仁君。」任座曰:「君得中山,不以封君之弟而以封君之子,何謂仁君!」文侯怒,任座趨出。任座亦習見當時鄰國之事而為是言耳。任音壬,「座」一作「痤」,音才戈翻。次問翟璜,翟,姓也,音直格翻,又音狄。姓譜:翟為晉所滅,子孫以國為氏。今人多讀從上音。璜,戶光翻。對曰:「仁君。」文侯曰:「何以知之?」對曰:「臣聞君仁則臣直。嚮者任座之言直,臣是以知之。」文侯悅,使翟璜召任座而反之,親下堂迎之,以為上客。\n〖译文〗 魏文侯派乐羊攻打中山国,予以攻克,封给自己的儿子魏击。魏文侯问群臣:“我是什么样的君主?”大家都说:“您是仁德的君主!”只有任座说:“国君您得了中山国,不用来封您的弟弟,却封给自己的儿子,这算什么仁德君主!”魏文侯勃然大怒,任座快步离开。魏文侯又问翟璜,翟璜回答说:“您是仁德君主。”魏文侯问:“你何以知道?”回答说:“臣下我听说国君仁德,他的臣子就敢直言。刚才任座的话很耿直,于是我知道您是仁德君主。”魏文侯大喜,派翟璜去追任座回来,还亲自下殿堂去迎接,奉为上客。\n文侯與田子方飲,文侯曰:「鐘聲不比乎?比,音毗。不比,言不和也。左高。」此蓋編鐘之懸,左高,故其聲不和。田子方笑。文侯曰:「何笑?」子方曰:「臣聞之,君明樂官,不明樂音,今君審於音,臣恐其聾於官也。」明樂官,知其才不才;明樂音,知其和不和。五聲合和,然後成音。詩大序曰:聲成文,謂之音。文侯曰:「善。」\n〖译文〗 魏文侯与田子方饮酒,文侯说:“编钟的乐声不协调吗?左边高。”田子方笑了,魏文侯问:“你笑什么?”田子方说:“臣下我听说,国君懂得任用乐官,不必懂得乐音。现在国君您精通音乐,我担心您会疏忽了任用官员的职责。”魏文侯说:“对。”\n子擊出,遭田子方於道,下車伏謁。古文史考曰:黃帝作車,引重致遠;少昊氏加牛;禹時奚仲加馬。釋名曰:車,居也。韋昭曰:古唯尺遮翻,自漢以來,始有「居」音。蕭子顯曰:三皇氏乘祇zhī車出谷口,車之始也。祇,翹移翻。子方不為禮。子擊怒,謂子方曰:「富貴者驕人乎?貧賤者驕人乎?」子方曰:「亦貧賤者驕人耳,富貴者安敢驕人!國君而驕人則失其國,大夫而驕人則失其家。失其國者未聞有以國待之者也,失其家者未聞有以家待之者也。夫士貧賤者,言不用,行不合則納履而去耳,安往而不得貧賤哉!」子擊乃謝之。夫,音扶。行,下孟翻。\n〖译文〗 魏文侯的公子魏击出行,途中遇见国师田子方,下车伏拜行礼。田子方却不作回礼。魏击怒气冲冲地对田子方说:“富贵的人能对人骄傲呢,还是贫贱的人能对人骄傲?”田子方说:“当然是贫贱的人能对人骄傲啦,富贵的人哪里敢对人骄傲呢!国君对人骄傲就将亡国,大夫对人骄傲就将失去采地。失去国家的人,没有听说有以国主对待他的;失去采地的人,也没有听说有以家主对待他的。贫贱的游士呢,话不听,行为不合意,就穿上鞋子告辞了,到哪里得不到贫贱呢!”魏击于是谢罪。\n文侯謂李克曰:「先生嘗有言曰:『家貧思良妻,國亂思良相。』今所置非成則璜,二子何如?」李氏出自顓頊曾孫皋陶,為堯大理,以官命族為理氏。商紂時,裔孫利貞逃難,食木子得全,改為李氏。置,言置相也。相,息亮翻。難,乃旦翻。對曰:「卑不謀尊,疏不謀戚。臣在闕門之外,不敢當命。」在闕門之外,謂疏遠也。文侯曰:「先生臨事勿讓!」克曰:「君弗察故也。居視其所親,富視其所與,達視其所舉,窮視其所不為,貧視其所不取,五者足以定之矣,何待克哉!」文侯曰:「先生就舍,吾之相定矣。」相,息亮翻。李克出,見翟璜。翟璜曰:「今者聞君召先生而卜相,果誰為之?」克曰:「魏成。」翟璜忿然作色曰:「西河‹黃河西岸,陝西東部›守吳起,臣所進也。班志;魏地,其界自高陵以東,盡河東、河內。高陵縣,漢屬馮翊,其地在河西,所謂「西河之外」者也。魏初使吳起守之,秦兵不敢東向。至惠王時,秦使衛鞅擊虜其將公子卬,遂獻西河之外于秦。吳,以國為姓。相,息亮翻,守,式又翻。君內以鄴‹河北临漳西南邺镇›為憂,臣進西門豹。班志,鄴縣屬魏郡。西門豹為鄴令,鑿渠以利民。王符潛夫論姓氏篇曰:如有東門、西郭、南宮、北郭,皆因居以為姓。西門蓋亦此類。鄴,魚怯翻。君欲伐中山,臣進樂羊。中山已拔,無使守之,臣進先生。君之子無傅,臣進屈侯鮒。傅者,傅之以德義,因以為官名。傅,芳遇翻。屈,九勿翻,姓也。余按屈,晉地,時屬魏;鮒蓋魏封屈侯也。鮒,音符遇翻。以耳目之所睹記,臣何負于魏成!」不勝為負。李克曰:「子【章︰十二行本「子」下有「之」字;乙十一行本同;孔本同。】言克於子之君者,豈將比周以求大官哉?比,毗至翻。阿黨為比。君問相於克,克之對如是。李克自敍其答魏文侯之言也。所以知君之必相魏成者,魏成食祿千鐘,孔穎達曰:祿者,穀也。故鄭註司祿云:祿也言穀,年穀豐然後制祿。援神契云:祿者,錄也。白虎通曰:上以收錄接下,下以名錄謹以事上是也。六斛四斗為一鐘。什九在外,什一在內;是以東得卜子夏、田子方、段干木。夏,戶雅翻。此三人者,君皆師之:子所進五人者,君皆臣之。子惡得與魏成比也!」惡,讀曰烏,何也。翟璜逡巡再拜曰:「璜,鄙人也,失對,願卒為弟子!」逡,七倫翻。逡巡,卻退貌。卒,子恤翻,終也。孔穎達曰:先生,師也。謂師為先生者,言彼先己而生,其德多厚也。自稱為弟子者,言己自處如弟子,則尊其師如父兄也。\n〖译文〗 魏文侯问李克:“先生曾经说过:‘家贫思良妻,国乱思良相。’现在我选相不是魏成就是翟璜,这两人怎么样?”李克回答说:“下属不参与尊长的事,外人不过问亲戚的事。臣子我在朝外任职,不敢接受命令。”魏文侯说:“先生不要临事推让!”李克说道:“国君您没有仔细观察呀!看人,平时看他所亲近的,富贵时看他所交往的,显赫时看他所推荐的,穷困时看他所不做的,贫贱时看他所不取的。仅此五条,就足以去断定人,又何必要等我指明呢!”魏文侯说:“先生请回府吧,我的国相已经选定了。”李克离去,遇到翟璜。翟璜问:“听说今天国君召您去征求宰相人选,到底定了谁呢?”李克说:“魏成。”翟璜立刻忿忿不平地变了脸色,说:“西河守令吴起,是我推荐的。国君担心内地的邺县,我推荐西门豹。国君想征伐中山国,我推荐乐羊。中山国攻克之后,没有人去镇守,我推荐了先生您。国君的公子没有老师,我推荐了屈侯鲋。凭耳闻目睹的这些事实,我哪点儿比魏成差!”李克说:“你把我介绍给你的国君,难道是为了结党以谋求高官吗?国君问我宰相的人选,我说了刚才那一番话。我所以推断国君肯定会选中魏成为相,是因为魏成享有千钟的傣禄,十分之九都用在外面,只有十分之一留作家用,所以向东得到了卜子夏、田子方、段干木。这三个人,国君都奉他们为老师;而你所举荐的五人,国君都任用为臣属。你怎么能和魏成比呢!”翟璜听罢徘徊不敢进前,一再行礼说:“我翟璜,真是个粗人,失礼了,愿终身为您的弟子!”\n吳起者,衛‹府濮阳,河南濮阳›人,仕于魯‹府曲阜,山东曲阜›。齊人伐魯,魯人欲以為將,起取齊‹府临淄,山东淄博东临淄镇›女為妻,將,即亮翻;下同。取,讀曰娶。孔穎達曰:妻之為言齊也;以禮見問,得與夫敵體也。魯人疑之,起殺妻以求將,大破齊師。或譖之魯侯曰:「起始事曾參,世本曰:曾姓出自鄫國。陸德明曰:參,所金翻,一音七南翻。母死不奔喪,曾參絕之;今又殺妻以求為君將。起,殘忍薄行人也!行,下孟翻。且以魯國區區而有勝敵之名,則諸侯圖魯矣。」起恐得罪,聞魏文侯賢,乃往歸之。文侯問諸李克,李克曰:「起貪而好色;好,呼到翻。然用兵,司馬穰苴弗能過也。」司馬,官名。穰苴本齊田姓,仕齊為是官,故以稱之;齊景公之賢將也。穰,如羊翻。苴,子餘翻。於是文侯以為將,擊秦,拔五城。\n〖译文〗 吴起,卫国人,在鲁国任官。齐国攻打鲁国,鲁国想任用吴起为将,但吴起娶的妻子是齐国人,鲁国猜疑吴起。于是,吴起杀死了自己的妻子,求得大将,大破齐国军队。有人在鲁国国君面前攻击他说:“吴起当初曾师事曾参,母亲死了也不回去治丧,曾参与他断绝关系。现在他又杀死妻子来求得您的大将职位。吴起,真是一个残忍缺德的人!况且,以我们小小的鲁国能有战胜齐国的名气,各个国家都要来算计鲁国了。”吴起恐怕鲁国治他的罪,又听说魏文侯贤明,于是就前去投奔。魏文侯征求李克的意见,李克说:“吴起为人贪婪而好色,然而他的用兵之道,连齐国的名将司马穰苴也超不过他。”于是魏文侯崐任命吴起为大将,攻击秦国,攻占五座城。\n起之為將,與士卒最下者同衣食,臥不設席,行不騎乘,騎馬為騎,乘車為乘,言起與士卒同其勞苦,行不用車馬也。親裹贏糧,師古曰:贏,擔也。此言起親裹士卒所齎jī擔之糧。贏,怡成翻。與士卒分勞苦。卒有病疽者,起為吮之。疽,七餘翻,癰也。吮,徐兗翻;說文:嗽也,康所角切。卒母聞而哭之。人曰:「子,卒也,而將軍自吮其疽,何哭為?」母曰:「非然也。往年吳公吮其父疽,【章︰十二行本無「疽」字;乙十一行本同;孔本同。】其父戰不旋踵,遂死於敵。吳公今又吮其子,妾不知其死所矣,是以哭之。」\n〖译文〗 吴起做大将,与最下等的士兵同样穿衣吃饭,睡觉不铺席子,行军也不骑马,亲自挑上士兵的粮食,与士兵们分担疾苦。有个士兵患了毒疮,吴起为他吸吮毒汁。士兵的母亲听说后却痛哭。有人奇怪地问:“你的儿子是个士兵,而吴起将军亲自为他吸吮毒疮,你为什么哭?”士兵母亲答道:“不是这样啊!当年吴将军为孩子的父亲吸过毒疮,他父亲作战从不后退,就战死在敌阵中了。吴将军现在又为我儿子吸毒疮,我不知道他该死在哪里了,所以哭他。”\n4燕‹府蓟城,北京›湣公薨,子僖公立。燕自召公奭受封于北燕,其地則唐幽州薊縣故城是也。自召公至湣公三十二世。燕,因肩翻。湣,讀與閔同。諡法:使民悲傷曰閔;小心畏忌曰僖。\n〖译文〗 [4]燕国燕公去世,其子燕僖公即位。\n二十四年(己卯,前四零二) # 1王‹姬午›崩,子安王驕立。\n〖译文〗 [1]周威烈王驾崩,其子姬骄即位,是为周安王。\n2盜殺楚‹都郢都,湖北江陵›聲王‹芈当›,國人立其子悼王‹芈疑›。周成王封熊繹于楚,姓羋氏,居丹陽,今枝江縣故丹陽城是也。括地志曰:歸州秭歸縣丹陽城,熊繹之始國。其後強大,北封畛zhěn于汝,南并吳、越,地方五千里。自熊繹至聲王三十世。索隱曰:聲王,名當。悼王,名疑。諡法:不生其國曰聲。註云:生於外家。年中早夭曰悼。註云:年不稱志。又云:恐懼從處曰悼。註云:從處,言險圮pǐ也。\n〖译文〗 [2]盗匪杀死楚国楚声王,国中贵族拥立其子楚悼王即位。\n安王諡法,好和不爭曰安。 # 元年(庚辰,前四零一) # 1秦‹府雍县,陕西凤翔›伐魏‹府安邑,山西夏县›,至陽孤‹山西垣曲东南›。周孝王邑非子于秦。徐廣曰:今隴西縣秦亭是也。括地志曰:秦州清水縣本名秦。十三州志曰:秦亭,秦谷是也。至襄公取周地,穆公霸西戎,日以強大。是年,秦簡公之十四年也。自非子至簡公二十八世。「陽孤」,史記作「陽狐」。【章︰乙十一行本正作「狐」。】正義引括地志曰:陽狐郭在魏州元城縣東北三十里。余按此時西河之外皆為魏境,若秦兵至元城,則是越魏都安邑而東矣。水經註:河東垣縣有陽壺城。九域志:絳州有陽壺城。姑識之以廣異聞,且俟知者。\n〖译文〗 [1]秦国攻打魏国,直至阳孤。\n二年(辛巳,前四零零) # 1魏‹府安邑,山西夏县›、韓‹府平阳,山西临汾›、趙‹府晋阳,山西太原›伐楚‹都郢都,湖北江陵›,至桑丘。水經註:澺yì水自葛陂東南逕新蔡縣故城東,而東南流注于汝水;又東南逕下桑里,左迤為橫塘陂。史記作「乘丘」‹山东兖州西北›。正義:地理志,乘丘故城在兗州瑕丘縣西北三十五里。當從之。\n〖译文〗 [1]韩国、魏国、赵国联合攻打楚国,直至桑丘。\n2鄭‹府新郑,河南新郑›圍韓陽翟‹河南禹州›。周宣王封其弟友于鄭。杜預世族譜曰:封于咸林,今京兆鄭邑是也。幽王無道,友徙其人於虢、鄶kuài之間,遂有其地,今河南新鄭是也。友,諡桓公。是年,鄭繻公駘tái之二十三年。自桓公至繻公二十二世。班志,陽翟縣屬潁川郡。索隱曰:翟,音狄,溫公類篇音萇伯切。繻,詢趨翻。駘,堂來翻。\n〖译文〗 [2]郑国围攻韩国阳翟城。\n3韓景侯‹虔›薨,子烈侯取立。\n〖译文〗 [3]韩国韩景侯去世,其子韩取即位,是为韩烈侯。\n4趙烈侯‹籍›薨,國人立其弟武侯。\n〖译文〗 [4]赵国赵烈侯去世,国中贵族拥立其弟即位,是为赵武侯。\n5秦‹府雍县,陕西凤翔›簡公‹悼子›薨,子惠公立。諡法:愛民好與曰惠。\n〖译文〗 [5]秦国秦简公去世,其子即位,是为秦惠公。\n三年(壬午,前三九九) # 1王子定奔晉‹府新田,山西侯马›。\n〖译文〗 [1]周朝王子姬定出奔晋国。\n2虢山‹河南三门峡西›崩,壅河。徐廣曰:虢山在陝。裴駰曰:弘農陝縣,故虢國。北虢在大陽,東虢在滎陽。括地志曰:虢山在陜州陝縣,西臨黃河;今臨河有岡阜,似是頹山之餘。水經註曰:陜城西北帶河,水湧起方數十丈。父老云:石虎載銅翁仲至此沈沒,水所以湧。洪河巨瀆,宜不為金狄梗流,蓋魏文侯時虢山崩壅河所致耳。陝,失冉翻。\n〖译文〗 [2]虢山崩塌,泥石壅塞黄河。\n四年(癸未,前三九八) # 1楚‹都郢都,湖北江陵›圍鄭‹河南新郑›。鄭人殺其相駟子陽。鄭穆公之子騑,字子駟;古者以王父之字為氏,子陽其後也。相,息亮翻。騑,芳菲翻。\n〖译文〗 [1]楚国围攻郑国。郑国人杀死国相驷子阳。\n五年(甲申,前三九七) # 1日有食之。杜預曰:日行遲,一歲一周天。月行速,一月一周天;一歲凡十二交會。然日、月,動物,雖行度有大量,不能不小有贏縮,故有雖交會而不食者,或有頻交而食者。孔穎達曰:日月交會,謂朔也。周天三百六十五度四分度之一。日月皆右行於天,一晝一夜,日行一度,月行十三度十九分度之七,二十九日日有餘,而月行天一周,追及於日而與之會。交會而日月同道則食;月或在日道表,或在日道里,則不食矣。又曆家為交食之法,大率以一百七十有三日有奇為限。然月先在里,則依限而食者多;若月在表,則依限而食者少。杜預見其參差,乃云「雖行度有大量,不能不小有贏縮,故有雖交會而不食者,或有頻交而食者」,此得之矣。蘇氏曰:交當朔則日食,然亦有交而不食者。交而食,陽微而陰乘之也;交而不食,陽盛而陰不能揜yǎn也。朱元晦曰:此則系乎人事之感。蓋臣子背君父,妾婦乘其夫,小人陵君子,夷狄侵中國,所感如是,則陰盛陽微而日為之食矣。是以聖人于春秋,每食必書,而詩人亦以為醜也。今此書年而不書月與晦、朔,史失之也。釋名曰:日、月虧曰食;稍小侵虧,如蟲食草木之葉也。亦作「蝕」。\n〖译文〗 [1]出现日食。\n2三月,盜殺韓‹府平阳,山西临汾›相俠累。俠累與濮陽‹河南濮陽›嚴仲子有惡。仲子聞軹zhǐ‹河南濟源東南›人聶政之勇,以黃金百溢為政母壽,欲因以報仇。相,息亮翻。俠,戶頰翻。累,力追翻。濮陽,春秋之帝丘,漢為濮陽縣,屬東郡。應劭曰:濮水南入鉅野。水北為陽。濮,博木翻。惡,如字,不善也;康烏故切,非。軹,春秋原邑,晉文公所圍者;漢為軹縣,屬河內郡;音只。姓譜曰:楚大夫食采于聶,因以為氏。聶,尼輒翻。溢,夷質翻。二十四兩為溢。政不受,曰:「老母在,政身未敢以許人也!」及母卒,仲子乃使政刺俠累。卒,子恤翻。刺,七亦翻,又如字。俠累方坐府上,兵衛甚眾,聶政直入上階,上,時掌翻。刺殺俠累,因自皮面決【章︰乙十一行本作「抉」。】眼,自屠出腸。韓人暴其尸於市,暴,步木翻,又音如字,露也。購問,莫能識。其姊嫈聞而往,哭之曰:「是軹深井里‹河南济源东南十五公里›聶政也!史記正義曰:深井里在懷州濟源縣南三十里。以妾尚在之故,重自刑以絕從。妾奈何畏歿身之誅,終滅賢弟之名!」遂死於政尸之旁。皮面,以刀剺lí面而去其皮。懸賞以募告者曰購。購,古侯翻。嫈,烏莖翻。絕從之從,讀曰蹤,謂自絕其蹤跡。或曰:從,讀如字,謂絕其從坐之罪也。\n〖译文〗 [2]三月,盗匪杀死韩国国相侠累。侠累与濮阳人严仲子有仇,严仲子听说轵地人聂政很勇敢,便拿出一百镒黄金为聂政母亲祝寿,想让聂政为他报仇。聂政却不接受,说:“我的老母亲还健在,我不敢为别人去献身!”等到他的母亲去世,严仲子便派聂政去行刺侠累。侠累正端坐府中,有许多护卫兵丁,聂政一直冲上厅阶,把侠累刺死。然后划破自己的面皮,挖出双眼,割出肚肠而死。韩国人把聂政的尸体放在集市中暴尸。并悬赏查找,但无人知晓。聂政的姐姐聂听说此事前往,哭着说:“这是轵地深井里的聂政啊!他因为我还在,就自毁面容不使连累。我怎么能怕杀身之祸,最终埋没我弟弟的英名呢!”于是自尽死在聂政的尸体旁边。\n六年(乙酉,前三九六) # 1鄭‹府新郑,河南新郑›駟子陽之黨弑繻公‹贻›,繻者,諡法所不載。史記註:「繻」,或作「繚」。繻,詢趨翻。而立其弟乙,白虎通曰:弟,悌也,心順、行篤也。行,下孟翻。是為康公。\n〖译文〗 [1]郑国宰相驷子阳的余党杀死国君郑公,改立他的弟弟姬乙,是为郑康公。\n2宋‹府睢阳,河南商丘›悼公薨,子休公田立。武王封微子啟于宋,唐宋州之睢陽縣是也。自微子二十七世至悼公,名購由。休,亦諡法所不載。\n〖译文〗 [2]宋国宋悼公去世,其子宋田即位,是为宋休公。\n八年(丁亥,前三九四) # 1齊‹府临淄,山东淄博东临淄镇›伐魯‹府曲阜,山东曲阜›,取最‹山東曲阜東南›。【章︰十二行本「最」下有「韓救魯」三字;乙十一行本同;孔本同;張校同;退齋校同。】武王封太公于齊,唐青州之臨淄是也。括地志曰:天齊水在臨淄東南十五里。封禪書曰:齊之所以為齊者,以天齊。是年,康公貸之十一年。自太公至康公二十九世。成王封伯禽于魯,唐兗州之曲阜是也。是年,穆公之十六年。自伯禽至穆公凡二十八世。\n〖译文〗 [1]齐国攻打鲁国,攻占最地。\n2鄭‹府新郑,河南新郑›負黍‹河南登封西南›叛,復歸韓‹府平阳,山西临汾›。據史記,繻公之十六年,敗韓於負黍,蓋以此時取之,而今復叛歸韓也。劉昭郡國志:潁川郡陽城縣有負黍聚。古今地名云;負黍山在陽城縣西南二十七里,或云在西南三十五里。\n〖译文〗 [2]郑国的负黍地方反叛,复归顺韩国。\n九年(戊子,前三九三年) # 1魏‹府安邑,山西夏县›伐鄭‹府新郑,河南新郑›。\n〖译文〗 [1]魏国攻打郑国。\n2晉‹府新田,山西侯马›烈公‹止›薨,子孝公傾立。周成王封弟叔虞于唐。括地志曰:故唐城在并州晉陽縣北二里,堯所築也。都城記曰:唐叔虞之子燮xiè父徙居晉水旁,今并州理故唐城,即燮父初徙之處;其城南半入州城中。毛詩譜曰:燮父以堯墟南有晉水,改曰晉侯。自唐叔至烈公三十七世。烈公,名止。諡法:慈惠愛親曰孝。\n〖译文〗 [2]晋国晋烈公去世,其子姬倾即位,是为晋孝公。\n十一年(庚寅,前三九一年) # 1秦‹府雍县,陕西凤翔›伐韓‹府平阳,山西临汾›宜陽‹河南宜陽›,取六邑。班志,宜陽縣屬弘農郡。史記正義曰:宜陽縣故城,在河南府福昌縣東十四里,故韓城是也。此邑即周禮「四井為邑」之邑。\n〖译文〗 [1]秦国攻打韩国宜阳地方,夺取六个村邑。\n2初,田常生襄子盤,盤生莊子白,白生太公和。此序齊田氏之世也。田常,即左傳陳成子恒也。溫公避仁廟諱,改「恒」曰「常」。自陳公子完奔齊,五世至常得政。諡法:勝敵志強曰莊。是歲,齊‹府临淄,山东淄博东临淄镇›田和遷齊康公於海上,使食一城,以奉其先祀。\n〖译文〗 [2]起初,齐国田常生襄子田盘,田盘生庄子田白,田白再生太公田和。这年,田和把国君齐康公流放到海边,让他保有一个城的赋税收入,以承继祖先祭祀。\n十二年(辛卯,前三九零年) # 1秦‹府雍县,陕西凤翔›、晉‹府新田,山西侯马›戰于武城‹陝西华县东›。此非魯之武城。左傳:晉陰飴甥會秦伯,盟于王城。杜預曰:馮翊臨晉縣東有王城,今名武鄉。括地志:故武城,一名武平城,在華州鄭縣東北十三里。\n〖译文〗 [1]秦国与晋国大战于武城。\n2齊伐魏,取襄陽‹襄陵,河南睢县›。「陽」,當作「陵」。徐廣曰:今之南平陽也。余據晉志,南平陽縣屬山陽郡。班志,陳留郡有襄邑縣。師古曰:圈稱云:襄邑,宋地,本承匡襄陵鄉也,宋襄公所葬,故曰襄陵。秦始皇以承匡卑濕,徙縣襄陵,因曰襄邑。\n〖译文〗 [2]齐国攻打魏国,夺取襄阳。\n3魯‹府曲阜,山东曲阜›敗齊師于平陸‹山東汶上縣西北›。班志,東平國有東平陸縣,戰國時之平陸也。史記正義曰:平陸,兗州縣,即古厥國。宋白曰:鄆yùn州中都縣,漢為平陸縣,史記「魯敗齊師于平陸」是也。敗,補邁翻。\n〖译文〗 [3]鲁国在平陆击败齐国军队。\n十三年(壬辰,前三八九年) # 1秦‹府雍县,陕西凤翔›侵晉‹府新田,山西侯马›。\n〖译文〗 [1]秦国入侵晋国。\n2齊‹府临淄,山东淄博东临淄镇›田和會魏文侯、楚人、衛‹府濮阳,河南濮阳›人於濁澤‹河南新郑西南›,康曰,濁,水名;漢志:濁水出齊郡廣縣媯山。余謂康說誤矣。徐廣史記註曰:長社有濁澤。水經註曰:皇陂水出胡城西北。胡城,潁陰之狐人亭也。皇陂,古長社之濁澤也。記:諸侯相見于郤xì地曰會。孔穎達曰:諸侯未及期而相見曰遇。會者,謂及期之禮,既及期,又至所期之地。求為諸侯。魏文侯為之請于王及諸侯,王許之。為之之為,於偽翻。\n〖译文〗 [2]齐国田和在浊泽约会魏文侯及楚国、卫国贵族,要求作诸侯。魏文侯替他向周安王及各国诸侯申请,周安王准许。\n十五年(甲午,前三八七年) # 1秦‹府雍县,陕西凤翔›伐蜀‹府成都,四川成都›,取南鄭‹陝西汉中›。譜記普疑衍云:蜀之先,肇自人皇之際。黃帝子昌意娶蜀山氏女,生帝俈kù。既立,封其支庶於蜀,歷虞、夏、商、周。周衰,先稱王者蠶叢。余據武王伐紂,庸、蜀諸國皆會於牧野。孔安國曰:蜀,叟也,春秋之時不與中國通。班志,南鄭縣屬漢中郡,唐為梁州治所。「俈」,通作「嚳」,音括沃翻。\n〖译文〗 [1]秦国攻打蜀地,夺取南郑。\n2魏‹府安邑,山西夏县›文侯‹斯›薨,太子擊立,王者以嫡長子為太子,謂之國儲副君。諸侯曰世子。周衰,率上僭。孔穎達曰:太者,大中之大也。上,時掌翻。長,知兩翻。是為武侯。\n〖译文〗 [2]魏国魏文侯去世,太子魏击即位,是为魏武侯。\n武侯浮西河而下,西河,即禹貢之「龍門西河」。中流顧謂吳起曰:「美哉山河之固,此魏國之寶也!」對曰:「在德不在險。昔三苗氏,左洞庭,右彭蠡‹鄱阳湖›,德義不修,禹滅之。武陵、長沙、零、桂之水,匯為洞庭,周七百里。彭蠡澤在漢豫章郡彭澤縣西。書:有苗弗率,汝徂cú征。三苗所居,蓋今江南西道之地。蠡,里弟翻。夏桀之居,左河、濟,右泰華‹陕西华阴南华山›,伊闕‹洛阳›在其南,羊腸‹山西平顺东南太行山中›在其北;修政不仁,湯放之。濟水出河東垣縣王屋山,南流貫河而南,合於滎瀆。禹貢所謂「導沇水,東流為濟,溢為滎」者也。自漢築滎陽石門,而濟與河合流而注於海,不入滎瀆。禹貢所謂「導沇水,東流為濟,入於河」。桀都安邑‹山西夏縣›,蓋恃以為險。泰華山在京兆華陰縣南。水經:伊水出南陽縣西荀渠山,東北流至河南新城縣,又東南過伊闕中,大禹所鑿也。兩山相對,望之若闕。左傳「女寬守闕塞」,即其地。括地志:伊闕山在洛州南十九里。班志,上党壺關縣有羊腸阪‹山西平顺东南太行山中›。此安邑四履所憑,山河之固也。書曰:成湯放桀于南巢。濟,子禮翻。華,戶化翻。商紂之國,左孟門‹山西吉县西›,右太行,常山‹恒山,河北曲阳北›在其北,大河經其南;修政不德,武王殺之。水經註:孟門在河東北屈縣西,即龍門上口也。淮南子曰:龍門未辟,呂梁未鑿,河出孟門之上,溢而逆流,無有丘陵,名曰洪水。太行山在河內野王縣西北。常山在常山郡上曲陽縣西北。河水自孟門南抵華陰,屈而東流;紂都朝歌,河經其南。北屈之孟門在朝歌西北,恐不可言「左」。索隱曰:孟門別一山,在朝歌東邊。此特左、右二字之差而誤耳。春秋說題辭:河之為言荷也;荷精分佈,懷陰引度也。釋名:河,下也,隨地下處而通流也。書曰:武王勝殷,殺紂。太行之行,戶剛翻。北屈,陸求忽翻,顏居勿翻。由此觀之,在德不在險。若君不修德,舟中之人皆敵國也!」武侯曰:「善。」\n〖译文〗 魏武侯顺黄河而下,在中游对吴起说:“稳固的山河真美啊!这是魏国的宝啊!”吴起回答说:“国宝在于德政而不在于地势险要。当初三苗氏部落,左面有洞庭湖,右面有彭蠡湖,但他们不修德义,被禹消灭了。夏朝君王桀的居住之地,左边是黄河、济水,右边是泰华山,伊阙山在其南面,羊肠阪在其北面,但因朝政不仁,也被商朝汤王驱逐了。商朝纣王的都城,左边是孟门,右边是太行山,常山在其北面,黄河经过其南面,因他施政不德,被周武王杀了。由此可见,国宝在于德政而不在于地势险要。如果君主您不修德政,恐怕就是这条船上的人,也要成为您的敌人。”魏武侯听罢说道:“对。”\n魏置相,相田文。相,息亮翻。此田文非齊之田文。吳起不悅,謂田文曰:「請與子論功可乎?」田文曰:「可。」起曰:「將三軍,使士卒樂死,敵國不敢謀,子孰與起?」文曰:「不如子。」將,即亮翻。樂,音洛。起曰:「治百官,親萬民,實府庫,子孰與起?」文曰:「不如子。」治,直之翻。起曰:「守西河,秦兵不敢東鄉,韓‹府平阳,山西临汾›、趙‹府晋阳,山西太原›賓從,子孰與起?」文曰:「不如子。」鄉,讀曰嚮。賓從,猶言賓服也。起曰:「此三者子皆出吾下,而位居吾上,何也?」文曰:「主少國疑,大臣未附,百姓不信,方是之時,屬之子乎,屬之我乎?」少,詩照翻。屬,子之欲翻。起默然良久曰:「屬之子矣!」\n〖译文〗 魏国设置国相,任命田文为相。吴起不高兴,对田文说:“我和你比较功劳如何?”田文说:“可以。”吴起便说:“统率三军,使士兵乐于战死,敌国不敢谋算,你比我吴起如何?”田文说:“我不如你。”吴起又问:“整治百官,亲善百姓,使仓库充实,你比我吴起如何?”田文说:“我不如你。”吴起再问:“镇守西河,使秦兵不敢向东侵犯,韩国、赵国依附听命,你比我吴起如何?”田文还是说:“我不如你。”吴起质问道:“这三条你都在我之下,而职位却在我之上,是什么道理?”田文说:“如今国君年幼,国多疑难,大臣们不能齐心归附,老百姓不能信服,在这个时候,是嘱托给你呢,还是嘱托给我呢?”吴起默默不语想了一会儿,说:“嘱托给你啊!”\n久之,魏相公叔尚【章︰十二行本「尚」下有「魏公」二字;乙十一行本同;孔本同;張校同;退齋校同。】主而害吳起。如淳曰:天子嫁女于諸侯,必使諸侯同姓者主之,故謂之公主。帝姊妹曰長公主,諸王女曰翁主。師古曰:如說得之。天子不親主婚,故謂之公主;諸王則自主婚,故其女曰翁主。翁者,父也,言父主其婚也;亦曰王主,言王自主其婚也。揚雄方言云:周、晉、秦、隴謂父曰翁。而臣瓚,王楙,或云「公者比於上爵」,或云「主者婦人尊稱」,皆失之。劉貢父曰:予謂公主之稱本出秦舊,男為公子,女為公主。古者大夫妻稱主,故以公配之。若謂同姓主之,故謂之公主,則周之事,秦不知用也。古之嫁女,禮當如周使大夫主之,何不謂之夫主乎?然則謂之王主者,猶言王子也;謂之公主者,緣公而生耳。毛晃曰:尚,崇也,高也,貴也,飾也,加也,尊也。娶公主謂之尚,言帝王之女尊而尚之,不敢言娶也。相,息亮翻。公叔之僕曰:「起易去也。起為人剛勁自喜。易,以豉翻。去,起呂翻。師古曰:喜,許吏翻。子先言於君曰:『吳起,賢人也,而君之國小,臣恐起之無留心也。君盍試延以女,起無留心,則必辭矣。』子因與起歸而使公主辱子,起見公主之賤子也,必辭,則子之計中矣。」中,竹仲翻。公叔從之,吳起果辭公主。魏武侯疑之而未信,起懼誅,遂奔楚。\n〖译文〗 过了很久,魏国国相公叔娶公主为妻而以吴起为忌。他的仆人献计说:“吴起容易去掉,吴起为人刚劲而沾沾自喜。您可以先对国君说:‘吴起是个杰出人才,但君主您的国家小,我担心他没有长留的心思。国君您何不试着要把女儿嫁给他,如果吴起没有久留之心,一定会辞谢的。’主人您再与吴起一起回去,让公主羞辱您,吴起看到公主如此轻视您,一定会辞谢国君的婚事,这样您的计谋就实现了。”公叔照此去做,吴起果然辞谢了与公主的婚事。魏武侯疑忌他,不敢信任,吴起害怕被诛杀,于是投奔了楚国。\n楚悼王素聞其賢,至則任之為相。起明灋審令,相,息亮翻。灋,古法字。捐不急之官,廢公族疏遠者,以撫養戰斗之士,要在強兵,破遊說之言從橫者。捐,餘專翻,棄也,除去也。漢書音義曰:以利合曰從,以威力相脅曰橫。或曰:南北曰從,從者,連南北為一,西鄉以擯秦。東西曰橫,橫者,離山東之交,使之西鄉以事秦。說,式芮翻。從,即容翻。「橫」,亦作「衡」,音同。於是南平百越‹东南沿海的浙江、福建、广东›,韋昭曰:越有百邑。北卻三晉,西伐秦,諸侯皆患楚之強;而楚之貴戚大臣多怨吳起者。\n〖译文〗 楚悼王平素听说吴起是个人才,到了便任命他为国相。吴起严明法纪号令,裁减一些不重要的闲官,废除了王族中远亲疏戚,用来安抚奖励征战之士,大力增强军队、破除合纵连横游说言论。于是楚国向南平定百越,向北抵挡住韩、魏、赵三国的扩张,向西征讨秦国,各诸侯国都害怕楚国的强大,而楚国的王亲贵戚、权臣显要中却有很多人怨恨吴起。\n3秦惠公薨,子出公立。出,非諡也;以其失國出死,故曰出公。\n〖译文〗 [3]秦国秦惠公去世,其子即位,是为秦出公。\n4趙武侯薨,國人復立烈侯之太子章,是為敬侯。諡法:夙夜警戒曰敬。\n〖译文〗 [4]赵国赵武侯去世,国中贵族又拥立赵烈侯的太子赵章即位,是为赵敬侯。\n5韓烈侯薨,子文侯立。\n〖译文〗 [5]韩国韩烈侯去世,其子即位,是为韩文侯。\n十六年(乙未,前三八六年) # 1初命齊‹府临淄,山东淄博东临淄镇›大夫田和為諸侯。田氏自此遂有齊國。田和是為太公。\n〖译文〗 [1]周王朝开始任命齐国大夫田和为诸侯国君。\n2趙‹府晋阳,山西太原›公子朝作亂,【章︰乙十一行本「亂」下有「出」字;孔本同;退齋校同;此處百衲本缺。】奔魏‹府安邑,山西夏县›;與魏襲邯鄲‹河北邯郸›,不克。邯,音寒。鄲,音丹。\n〖译文〗 [2]赵国公子赵朝作乱,出奔魏国,与魏国军队一起进袭赵国邯郸,未能攻克。\n十七年(丙申,前三八五年) # 1秦‹府雍县,陕西凤翔›庶長改逆獻公於河西而立之;殺出子及其母,沈之淵旁。後秦制爵,一級曰公士,二上造,三簪zān裊niǎo,四不更,五大夫,六官大夫,七公大夫,八公乘,九五大夫,十左庶長,十一右庶長,十二左更,十三中更,十四右更,十五少上造,十六大上造,十七駟車庶長,十八大庶長,十九關內侯,二十徹侯。師古曰:庶長,言眾列之長。註又詳見下卷顯王十年前。據史記:威烈王十一年秦靈公卒,子獻公師隰不得立,立靈公季父悼子,是為簡公。出子,簡公之孫也。今庶長改迎獻公而殺出子。正義曰:西者,秦州西縣,秦之舊地。時獻公在西縣,故迎立之。余謂此言河西,非西縣也。靈公之卒,獻公不得立,出居河西;河西者,黃河之西,蓋漢涼州之地。「裊」,當作「褭」,乃了翻。更,工衡翻。乘,繩證翻。長,知丈翻。\n〖译文〗 [1]秦国名叫改的庶长在河西迎接秦献公,立为国君;把秦出公和他的母亲杀死,沉在河里。\n2齊‹府临淄,山东淄博东临淄镇›伐魯‹府曲阜,山东曲阜›。\n〖译文〗 [2]齐国攻打鲁国。\n3韓‹府平阳,山西临汾›伐鄭‹府新郑,河南新郑›,取陽城‹河南登封告城镇›;漢陽城縣屬潁川郡;是為地中,成周於此以土圭測日景。伐宋‹府睢阳,河南商丘›,執宋公。\n〖译文〗 [3]韩国攻打郑国,夺取阳城。又攻打宋国,捉住宋国国君。\n4齊太公薨,子桓公午立。\n〖译文〗 [4]齐国太公田和去世,其子田午即位,是为齐桓公。\n十九年(戊戌,前三八三年) # 1魏‹府安邑,山西夏县›敗趙‹府晋阳,山西太原›師於兔台。史記趙世家曰:魏敗我兔台,築剛平。正義曰:兔台、剛平,并在河北。敗,補邁翻。\n〖译文〗 [1]魏国在兔台击败赵国军队。\n二十年(己亥,前三八二年) # 1日有食之,既。既,盡也\n〖译文〗 [1]出现日全食。\n二十一年(庚子,前三八一年) # 1楚‹都郢都,湖北江陵›悼王薨。貴戚大臣作亂,攻吳起;起走之王尸而伏之。之,往也,往赴王尸而伏其側。擊起之徒因射刺起,并中王尸。射,而亦翻。刺,七亦翻。中,竹仲翻。既葬,肅王即位,諡法:剛德克就曰肅;執心決斷曰肅。使令尹盡誅為亂者;令尹,楚相也。坐起夷宗者七十餘家。夷,殺也;夷宗者,殺其同宗也。\n〖译文〗 [1]楚悼王去世。贵族国戚和大臣作乱,攻打吴起,吴起逃到悼王尸体边,伏在上面。攻击吴起的暴徒用箭射吴起,并射中了悼王的尸体。办完葬事,楚肃王即位,命令楚相全数翦灭作乱者,因射吴起之事而被灭族的多达七十余家。\n二十二年(辛丑,前三八零年) # 1齊伐燕‹府蓟城,北京›,取桑丘‹河北徐水縣›。魏、韓、趙伐齊,至桑丘。此桑丘,非二年所書楚之桑丘。括地志曰:桑丘故城,俗名敬城,在易州遂城縣,蓋燕之南界也。\n〖译文〗 [1]齐国攻打燕国,夺取桑兵。魏、韩、赵三国攻打齐国,兵至桑丘。\n二十三年(壬寅,前三七九年) # 1趙‹府邯郸,河北邯郸›襲衛‹府濮阳,河南濮阳›,不克。成王封康叔于衛,居河、淇之間,故殷墟也。至懿公為狄所滅,東徙度河。文公徙居楚丘,遂國于濮陽。是年,慎公頹之三十五年。自康叔至慎公凡三十二世。\n〖译文〗 [1]赵国袭击卫国,未能攻克。\n2齊康公‹贷›薨,無子,田氏遂并齊而有之。姜氏至此滅矣。\n〖译文〗 [2]流放的齐康公去世,没有儿子。田氏家族于是把姜氏的齐国全部兼并了。\n是歲,齊桓公亦薨,子威王因齊立。諡法:強毅訅qiú正曰威,訅,渠留翻。齊桓公,田午。訅,謀也。\n〖译文〗 当年,齐桓公也去世,其子田因齐即位,是为齐威王。\n二十四年(癸卯,前三七八年) # 1狄‹山西北部›敗魏‹府安邑,山西夏县›師於澮kuài‹山西翼城南浍河›。漢之中山、上黨、西河、上郡,自春秋以來,狄皆居之,此亦其種也。水經:澮水出河東絳縣東澮山,西過絳縣南,又西南過虒sī祁宮南,又西南至王橋,入汾水。括地志:澮山在絳州翼城縣東北。敗,補邁翻。澮,古外翻。\n〖译文〗 [1]北方狄族在浍山击败魏国军队。\n2魏、韓‹府平阳,山西临汾›、趙‹府邯郸,河北邯郸›伐齊‹府临淄,山东淄博东临淄镇›,至靈丘‹山東茌平›。史記正義曰:靈丘,河東蔚州縣。余按蔚州之靈丘,即漢代郡之靈丘,此時齊境安能至代北邪!此即孟子謂蚳chí鼃wā辭靈丘請士師之地。班志曰:齊地北有千乘、清河以南。漢清河郡有靈縣,清河北接趙、魏之境,此為近之。蚳,音遲。鼃,烏花翻。\n〖译文〗 [2]魏、韩、赵三国攻打齐国,兵至灵丘。\n3晉‹府新田,山西侯马›孝公薨,子靖公俱酒立。諡法:柔眾安民曰靖;又,恭己鮮言曰靖。\n〖译文〗 [3]晋国晋孝公去世,其子姬俱酒即位,是为晋靖公。\n二十五年(甲辰,前三七七年) # 1蜀‹府成都,四川成都›伐楚‹都郢都,湖北江陵›,取茲方‹四川奉節›。據史記:蜀伐楚,取茲方,楚為扞關以拒之。則茲方之地在扞關之西。劉昭志:巴郡魚復縣有扞關。\n〖译文〗 [1]蜀人攻打楚国,夺取兹方。\n2子思言苟變于衛侯曰:「其才可將五百乘。」古者兵車一乘,甲士三人,步卒七十二人;五百乘,三萬七千五百人。國語曰:苟本自黃帝之子。將,即亮翻;下同。乘,繩證翻。公曰:「吾知其可將;然變也嘗為吏,賦於民而食人二雞子,故弗用也。」子思曰:「夫聖人之官人,猶匠之用木也,夫,音扶。取其所長,棄其所短;故杞梓連抱而有數尺之朽,良工不棄。今君處戰國之世,處,昌呂翻。選爪牙之士,而以二卵棄干城之將,詩:赳赳武夫,公侯干城。毛氏傳曰:干,捍也;音戶旦翻。鄭氏箋曰:干也,城也,皆所以禦難也。干,讀如字。此不可使聞於鄰國也。」公再拜曰:「謹受教矣!」\n〖译文〗 [2]孔,字子思,向卫国国君提起苟变说:“他的才能可统领五百辆车。”卫侯说:“我知道他是个将才,然而苟变做官吏的时候,有次征税吃了老百姓两个鸡蛋,所以我不用他。”孔说:“圣人选人任官,就好比木匠使用木料,取其所长,弃其所短;因此一根合抱的良木,只有几尺朽烂处,高明的工匠是不会扔掉它的。现在国君您处在战国纷争之世,正要收罗锋爪利牙的人才,却因为两个鸡蛋而舍弃了一员可守一城的大将,这事可不能让邻国知道啊!”卫侯一再拜谢说:“我接受你的指教。”\n衛侯言計非是,而群臣和者如出一口。和,戶臥翻。子思曰:「以吾觀衛,所謂『君不君,臣不臣』者也!」「君不君,臣不臣,」論語載齊景公之言。公丘懿子曰:「何乃若是?」公丘,複姓。諡法:溫柔賢善曰懿。子思曰:「人主自臧,則眾謀不進。臧,善也。事是而臧之,猶卻眾謀,況和非以長惡乎!和,戶臥翻。長,知丈翻。夫不察事之是非而悅人贊己,暗莫甚焉;不度理之所在而阿諛求容,諂莫甚焉。度,徒洛翻。君暗臣諂,以居百姓之上,民不與也。若此不已,國無類矣!」\n〖译文〗 卫侯提出了一项不正确的计划,而大臣们却附和如出一口。孔说:“我看卫国,真是‘君不像君,臣不像臣’呀!”公丘懿子问道:“为什么竟会这样?”孔说:“君主自以为是,大家便不提出自己的意见。即使事情处理对了没有听取众议,也是排斥了众人的意见,更何况现在众人都附和错误见解而助长邪恶之风呢!不考察事情的是非而乐于让别人赞扬,是无比的昏暗;不判断事情是否有道理而一味阿谀奉承,是无比的谄媚。君主昏暗而臣下谄媚,这样居于百姓之上,老百姓是不会同意的。长期这样不改,国家就不象国家了。”\n子思言于衛侯曰:「君之國事將日非矣!」公曰:「何故?」對曰:「有由然焉。君出言自以為是,而卿大夫莫敢矯其非;卿大夫出言亦自以為是,而士庶人莫敢矯其非。君臣既自賢矣,白虎通曰:君,群也,群下之所歸心也。臣,堅也,厲志自堅也。而群下同聲賢之,賢之則順而有福,矯之則逆而有禍,如此則善安從生!詩曰:『具曰予聖,誰知烏之雌雄?』詩正月之辭。毛氏傳曰:君臣俱自謂聖也。鄭氏箋曰:時君臣賢愚適同,如烏之雌雄相似,誰能別異之乎?又曰:烏鳥之雌雄不可別者,以翼知之,右掩左,雄,左掩右,雌,陰陽相下之義也。抑亦似君之君臣乎!」\n〖译文〗 孔对卫侯说:“你的国家将要一天不如一天了。”卫侯问:“为什么?”回答说:“事出有因。国君你说话自以为是,卿大夫等官员没有人敢改正你的错误;于是他们也说话自以为是,士人百姓也不敢改正其误。君臣都自以为贤能,下属又同声称贤,称赞贤能则和顺而有福,指出错误则忤逆而有祸,这样,怎么会有好的结果!《诗经》说:‘都称道自己是圣贤,乌鸦雌雄谁能辨?’不也像你们这些君臣吗?”\n3魯‹府曲阜,山东曲阜›穆公薨,子共公奮立。諡法:布德就義曰穆;中情見貌曰穆;尊賢敬讓曰共;既過能改曰共;執事堅固曰共。共,讀曰恭。考異曰:司馬遷史記六國表:周威烈王十九年甲戌,魯穆公元年。烈王元年丙午,共公元年。顯王十七年己巳,康公元年。二十六年戊寅,景公元年。赧王元年丁未,平公元年。二十年丙寅,文公元年。四十三年己丑,頃公元年。五十九年乙巳,周亡。秦莊襄王元年壬子,楚滅魯。按魯世家,穆公三十三年卒,若元甲戌,終乙巳,則是三十二年也。共公二十二年卒,若元丙午,終戊辰,則是二十三年也。康公九年卒,景公二十五年卒,平公二十二年卒,若元丁未,終乙丑,則是十九年也。文公二十三年卒,頃公二十四年楚滅魯。班固漢書律曆志「文公」作「緡公」;其在位之年與世家異者,惟平公二十年耳。本志自魯僖公五年正月辛亥朔旦冬至推之,至成公十二年正月庚寅朔旦冬至,定公七年正月己巳朔旦冬至,元公四年正月戊申朔旦冬至,康公四年正月丁亥朔旦冬至,緡公二十二年正月丙寅朔旦冬至,漢高祖八年十一月乙巳朔旦冬至,武帝元朔六年十一月甲申朔旦冬至,元帝初元二年十一月癸亥朔旦冬至,其間相距皆七十六年,此最為得實,又與魯世家註、皇甫謐所紀歲次皆合,今從之。六國表差謬,難可盡據也。余按考異「自魯僖公五年至漢元帝初元二年六百餘年間,十二月朔旦冬至,相距皆七十六年,此最為得實,又與魯世家註、皇甫謐所紀歲次皆合」,蓋謂劉彝叟長曆也。且言「史記六國表差謬,難可盡據」。又按通鑑目錄編年用劉彝叟長曆。漢武帝太初元年,初用夏正定曆,史記曆書是年書閼逢攝提格,目錄書強圉赤奮若。閼逢攝提格,甲寅也,強圉赤奮若,丁丑也,有二十四年之差,溫公用彝叟曆,邵康節皇極經世書亦用彝叟曆。康節少自雄其才,既學,力慕高遠,一見李之才,遂從而受學,廬於共城百源,冬不爐,夏不扇,夜不就席者數年,覃思於易經也。皇極經世書不能違彝叟曆。及其來居於洛,而溫公亦奉祠以書局在洛,相過從稔,又夙所敬者也。余意其講明之間必嘗及此,而決於用彝叟曆。讀考異此一段,辭意可見。\n〖译文〗 [3]鲁国鲁穆公去世,其子姬奋即位,是为鲁共公。\n4韓文侯薨,子哀侯立。\n〖译文〗 [4]韩国韩文侯去世,其子即位,是为韩哀侯。\n二十六年(乙巳,前三七六年) # 1‹周,都洛阳,河南洛阳东白马寺东›王‹姬骄›崩,子烈王喜立。\n〖译文〗 [1]周安王去世,其子姬喜即位,是为周烈王。\n2魏‹府安邑,山西夏县›、韓‹府平阳,山西临汾›、趙‹府邯郸,河北邯郸›共廢晉靖公為家人而分其地‹新田,山西侯马›。唐叔不祀矣。\n〖译文〗 [2]魏、韩、赵三国一同把晋靖公废黜为平民,瓜分了他的残余领地。\n烈王名喜,安王之子。 # 元年(丙午,前三七五年) # 1日有食之。\n〖译文〗 [1]出现日食。\n2韓滅鄭,因徙都之。韓本都平陽‹山西臨汾›,其地屬漢之河東郡;中間徙都陽翟‹河南禹州›。鄭都新鄭,其地屬漢之河南郡。鄭桓公始封于鄭,其地屬漢之京兆;後滅虢、鄶而國於溱、洧之間,故曰新鄭,左傳鄭莊公所謂「吾先君新邑於此」是也。今韓既滅鄭,自陽翟徙都之。韓既都鄭,故時人亦謂韓王為鄭王,考之戰國策、韓非子可見。\n〖译文〗 [2]韩国灭掉郑国,于是把国都迁到新郑。\n3趙敬侯薨,子成侯種立。種,章勇翻。\n〖译文〗 [3]赵国赵敬侯去世,其子赵种即位,是为赵成侯。\n三年(戊申,前三七三年) # 1燕‹府蓟城,北京›敗齊師于林狐。敗,補邁翻。\n〖译文〗 [1]燕国在林狐击败齐国军队。\n魯伐齊,入陽關‹山東泰安东南›。徐廣曰:陽關在鉅平。班志,鉅平縣屬泰山郡。括地志:陽關故城在兗州博城縣南二十九里,其城之西臨汶水。汶,音問。\n〖译文〗 鲁国攻打齐国,进入阳关。\n魏伐齊,至博陵‹山東茌平西北›。史記正義曰:博陵在濟州西界。宋白曰:史記,齊威王伐晉至博陵。徐廣曰:東郡之博平,漢為縣。\n〖译文〗 魏国攻打齐国,抵达博陵。\n2燕僖公薨,子桓公立。\n〖译文〗 [2]燕国燕僖公去世,其子即位,是为燕桓公。\n3宋‹府睢阳,河南商丘›休公薨,子辟公立。辟亦諡法之所不載。\n〖译文〗 [3]宋国宋休公去世,其子即位,是为宋辟公。\n4衛‹府濮阳,河南濮阳›慎公薨,子聲公訓立。諡法:敏以敬曰慎。戴記:思慮深遠曰慎。\n〖译文〗 [4]卫国卫慎公去世,其子卫训即位,是为卫声公。\n四年(己酉,前三七二年) # 1趙‹府邯郸,河北邯郸›伐衛,取都鄙七十三。周禮:太宰以八則治都鄙。註云:都之所居曰鄙。都鄙,卿大夫之采邑。蓋周之制,四縣為都,方四十里,一千六百井,積一萬四千四百夫;五酇zàn為鄙,鄙五百家也。此時衛國褊小,若都鄙七十三,以成周之制率之,其地廣矣,盡衛之提封,未必能及此數也。更俟博考。\n〖译文〗 [1]赵国攻打卫国,夺取七十三个村镇。\n2魏‹府安邑,山西夏县›敗趙師于北藺‹山西離石›。班志,西河郡有藺縣。史記正義曰:在石州。其地于趙為西北,故曰北藺。藺,離進翻。\n〖译文〗 [2]魏国在北蔺击败赵国军队。\n五年(庚戌,前三七一年) # 1魏伐楚,取魯陽‹河南魯山縣›。左傳所謂「劉累遷于魯縣」,即魯陽也。班志,魯陽縣屬南陽郡。史記正義曰:今汝州魯山縣。\n〖译文〗 [1]魏国攻打楚国,夺取鲁阳。\n2韓‹府新郑,河南新郑›嚴遂弑哀侯,國人立其子懿侯。初,哀侯以韓廆wěi為相而愛嚴遂,二人甚相害也。嚴遂令人刺韓廆於朝,廆走哀侯,哀侯抱之;人刺韓廆,兼及哀侯。戰國策以聶政刺韓相事及并中哀侯為一事;此從史記。蜀本註曰:按太史公年表及韓世家,于韓烈侯三年皆書「聶政殺韓相俠累」,于哀侯六年又皆書「嚴遂弑哀侯」。以刺客傳考之,聶政殺俠累事在哀侯時;以戰國策考之亦然。從傳與戰國策,則是年表,世家于烈侯三年書「盜殺俠累」誤矣。通鑑於烈侯三年載聶政殺俠累事,又于哀侯六年載嚴遂殺其君哀侯,是從年表、世家所書。蓋刺客傳初不言并殺哀侯,止戰國策言之,通鑑豈以此疑之歟!故載并刺哀侯,不書聶政,止曰「使人」。以此求之,則通鑑之意不以嚴仲子為嚴遂,亦不以俠累為韓廆,止從年表、世家而不信其傳也。余按溫公與劉道原書,亦疑此事。廆,戶賄翻。相,息亮翻。刺,七亦翻。朝,直遙翻。走,音奏。\n〖译文〗 [2]韩国严遂杀死韩哀侯,国中贵族立哀侯之子,是为韩懿侯。当初,韩哀侯曾任命韩为国相却宠爱严遂,两人互相仇恨至深。严遂派人在朝廷行刺韩,韩逃到韩哀侯身边,韩哀侯抱住他,刺客刺韩,连带韩哀侯也被刺死。\n3魏武侯薨,不立太子,子罃與公中緩爭立,國內亂。罃,於耕翻。中,讀曰仲。\n〖译文〗 [3]魏国魏武侯去世,没有立太子,他的儿子魏与公中缓争位,国内大乱。\n六年(辛亥,前三七零年) # 1齊‹府临淄,山东淄博东临淄镇›威王來朝。是時周室微弱,諸侯莫朝,而齊獨朝之,天下以此益賢威王。朝,直遙翻。\n〖译文〗 [1]齐威王朝拜周烈王。当时周王室已十分衰弱,各诸侯国都不来朝拜,唯独齐王仍来朝拜,因此天下人愈发称赞齐威王贤德。\n2趙伐齊,至鄄‹山东鄄城›。班志,濟陰郡有鄄城縣。鄄,工掾翻。\n〖译文〗 [2]赵国攻打齐国,直至鄄地。\n3魏敗趙師于懷‹河南武陟西南›。班志,河內郡有懷縣。魏收地形志,懷州武德郡有懷縣,縣管內有懷城。敗,補邁翻。\n〖译文〗 [3]魏国在怀地击败赵国军队。\n4齊威王召即墨‹山東平度东南›大夫,語之曰:「自子之居即墨也,毀言日至。班志,即墨縣屬膠東國。括地志:即墨故城,在萊州膠水縣南六十里。宋白曰:城臨墨水,故曰即墨。語,牛倨翻,下同。然吾使人視即墨,田野辟,辟,讀曰闢;下同。人民給,官無事,東方以寧;是子不事吾左右以求助也!」封之萬家。召阿‹山東东阿›大夫,語之曰:「自子守阿,譽言日至。阿,即東阿縣;班志屬東郡。譽,音余,稱其美也。吾使人視阿,田野不辟,人民貧餒。昔日趙攻鄄,子不救;鄄,工掾翻。衛取薛陵‹山東陽谷東北›,子不知;薛陵,春秋薛國之墟也。班志,薛縣屬魯國,而衛國在漢東郡陳留界。薛陵屬齊而近于衛,故為所取。齊後封田嬰於此。是子厚幣事吾左右以求譽也!」是日,烹阿大夫及左右嘗譽者。於是群臣聳懼,莫敢飾詐,務盡其情,齊國大治,強於天下。譽,音余。治,直吏翻。\n〖译文〗 [4]齐威王召见即墨大夫,对他说:“自从你到即墨任官,每天都有指责你的话传来。然而我派人去即墨察看,却是田土开辟整治,百姓丰足,官府无事,东方因而十分安定。于是我知道这是你不巴结我的左右内臣谋求内援的缘故。”便封赐即墨大夫享用一万户的俸禄。齐威王又召见阿地大夫,对他说:“自从你到阿地镇守,每天都有称赞你的好话传来。但我派人前去察看阿地,只见田地荒芜,百姓贫困饥饿。当初赵国攻打鄄地,你不救;卫国夺取薛陵,你不知道;于是我知道你用重金来买通我的左右近臣以求替你说好话!”当天,齐威王下令烹死阿地大夫及替他说好话的左右近臣。于是臣僚们毛骨耸然,不敢再弄虚假,都尽力做实事,齐国因此大治,成为天下最强盛的国家。\n5楚肅王薨,無子,立其弟良夫,是為宣王。\n〖译文〗 [5]楚国楚肃王去世,他没有儿子,弟弟良夫即位,是为楚宣王。\n6宋辟公薨,子剔成立。剔,他曆翻。\n〖译文〗 [6]宋国宋辟公去世,其子宋剔成即位。\n七年(壬子,前三六九年) # 1日有食之。\n〖译文〗 [1]出现日食。\n2王崩,弟扁立,據班書古今人表師古註:扁,音篇。是為顯王。\n〖译文〗 [2]周烈王去世,弟弟姬扁即位,是为周显王。\n3魏大夫王錯出奔韓。姓譜:王氏之所自出非一。出太原、琅邪者,周靈王太子晉之後。北海、陳留,齊王田和之後。東海出自姬姓。高平、京兆,魏信陵君之後。天水、東平、新蔡、新野、山陽、中山、章武、東萊、河東者,殷王子比干為紂所害,子孫以王者之後,號曰王氏。余謂此皆後世以諸郡著姓言之耳。春秋之時自有王姓,莫能審其所自出。公孫頎qí謂韓懿侯曰:「魏亂,可取也。」公孫,姓也。黃帝,公孫氏。頎,渠希翻。懿侯乃與趙成侯合兵伐魏,戰於濁澤‹山西永济西›,大破之,遂圍魏。史記正義曰:徐廣以為長社濁澤,非也。括地志云:濁水源出蒲州解縣東北平地,爾時魏都安邑,韓、趙伐魏,豈至河南長社邪!解縣濁水近於魏都,當是也。成侯曰:「殺罃,立公中緩,割地而退,我二國之利也。」懿侯曰:「不可。殺魏君,暴也;割地而退,貪也。不如兩分之。魏分為兩,不強于宋、衛,則我終無魏患矣。」趙人不聽。懿侯不悅,以其兵夜去。趙成侯亦去。罃遂殺公中緩而立,中,讀曰仲。是為惠王。\n〖译文〗 [3]魏国大夫王错逃奔韩国。公孙颀对韩懿侯说:“魏国内乱,可以乘机攻取。”韩懿侯于是与赵成侯联合出兵攻打魏国,在浊泽地方交战,大败魏军,包围了魏国都城。赵成侯说:“杀掉魏,立公中缓为魏国国君,然后割地退兵,这对我们两国是有利的作法。”韩懿侯说:“不妥。杀死魏国国君,是强暴;割地后才退兵,是贪婪。不如让两人分别治理魏国,魏国分为两半,比宋国、卫国还不如,我们就再也不用担心魏国的威胁了。”赵成侯不同意。韩懿侯不高兴,率领他的军队乘夜离去。赵成侯也只好退兵归国。魏于是杀死公中缓即位,是为魏惠王。\n太史公曰:魏惠王所以身不死、國不分者,二國之謀不和也。若從一家之謀,魏必分矣。故曰:「君終,無適子,其國可破也。」索隱曰:蓋古人之言及俗說,故云「故曰」。適,讀曰嫡。\n〖译文〗 太史公司马迁曰:魏惠王之所以能自身不死,国家不被瓜分,是由于韩、赵两国意见不和。如果按照其中一家的办法去做,魏国一定会被瓜分。所以说:“国君死时,无继承人,国家就会被击破。”\n"},{"id":190,"href":"/zh/docs/technology/Linux/SHELLlearnLinuxTV_/16-18/","title":"16-18","section":"SHELL编程(learnLinuxTV)_","content":" 向bash脚本添加参数 # basic # ─ ~/shellTest ly@vmmin 10:37:24 ╰─❯ cat ./16myscript_cls.sh #!/bin/bash echo \u0026#34;You entered the argument: $1,$2,$3, and $4.\u0026#34; 结果\n╭─ ~/shellTest 16s ly@vmmin 10:37:18 ╰─❯ ./16myscript_cls.sh Linux1 Linux2 You entered the argument: Linux1,Linux2,, and . 示例1 # ╭─ ~/shellTest ly@vmmin 10:41:45 ╰─❯ cat ./16myscript_cls.sh #!/bin/bash ls -lh $1 #echo \u0026#34;You entered the argument: $1,$2,$3, and $4.\u0026#34; ╭─ ~/shellTest ly@vmmin 10:41:28 ╰─❯ ./16myscript_cls.sh /etc total 792K -rw-r--r-- 1 root root 3.0K May 25 2023 adduser.conf -rw-r--r-- 1 root root 44 Dec 17 15:26 adjtime -rw-r--r-- 1 root root 194 Dec 23 22:38 aliases drwxr-xr-x 2 root root 4.0K Dec 23 22:38 alternatives drwxr-xr-x 2 root root 4.0K Dec 17 15:24 apparmor drwxr-xr-x 8 root root 4.0K Dec 17 15:25 apparmor.d drwxr-xr-x 9 root root 4.0K Dec 17 15:30 apt -rw-r----- 1 root daemon 144 Oct 16 2022 at.deny -rw-r--r-- 1 root root 2.0K Mar 30 2024 bash.bashrc 示例2 # #!/bin/bash lines=$(ls -lh $1 | wc -l) #行计数 echo \u0026#34;You hava $(($lines-1)) objects in the $1 directory.\u0026#34; #$(($lines-1))这里用到了子shell #echo \u0026#34;You entered the argument: $1,$2,$3, and $4.\u0026#34; ╭─ ~/shellTest ly@vmmin 10:48:06 ╰─❯ ls -lh logfiles total 12K -rw-r--r-- 1 ly ly 0 Dec 22 23:07 a.log -rw-r--r-- 1 ly ly 120 Dec 22 23:17 a.log.tar.gz -rw-r--r-- 1 ly ly 0 Dec 22 23:07 b.log -rw-r--r-- 1 ly ly 121 Dec 22 23:17 b.log.tar.gz -rw-r--r-- 1 ly ly 0 Dec 22 23:07 c.log -rw-r--r-- 1 ly ly 121 Dec 22 23:17 c.log.tar.gz -rw-r--r-- 1 ly ly 0 Dec 22 23:15 xx.txt -rw-r--r-- 1 ly ly 0 Dec 22 23:15 y.txt ╭─ ~/shellTest ly@vmmin 10:48:10 ╰─❯ ./16myscript_cls.sh logfiles You hava 8 objects in the logfiles directory. head,表示前十行,可以看出total这些被算作一行了,所以上面的shell中-1\n─ ~/shellTest ly@vmmin 10:57:19 ╰─❯ ls -l /etc | head total 792 -rw-r--r-- 1 root root 3040 May 25 2023 adduser.conf -rw-r--r-- 1 root root 44 Dec 17 15:26 adjtime -rw-r--r-- 1 root root 194 Dec 23 22:38 aliases drwxr-xr-x 2 root root 4096 Dec 23 22:38 alternatives drwxr-xr-x 2 root root 4096 Dec 17 15:24 apparmor drwxr-xr-x 8 root root 4096 Dec 17 15:25 apparmor.d drwxr-xr-x 9 root root 4096 Dec 17 15:30 apt -rw-r----- 1 root daemon 144 Oct 16 2022 at.deny -rw-r--r-- 1 root root 1994 Mar 30 2024 bash.bashrc 不输入参数的情形 # 可以看出,其实就是应用到当前文件夹了\n╭─ ~/shellTest ly@vmmin 11:02:35 ╰─❯ ./16myscript_cls.sh logfiles You hava 8 objects in the logfiles directory. ╭─ ~/shellTest ly@vmmin 11:02:39 ╰─❯ ./16myscript_cls.sh You hava 29 objects in the directory. ╭─ ~/shellTest ly@vmmin 11:02:44 ╰─❯ ls -l | wc -l 30 参数判断 # #!/bin/bash # $#表示用户传到脚本中的参数数量 if [ $# -ne 1 ] #[]左右两边都一定要有空格 then echo \u0026#34;This script requires xxxxone directory path passed to it.\u0026#34; echo \u0026#34;Please try again.\u0026#34; exit 1 fi lines=$(ls -lh $1 | wc -l) echo \u0026#34;You hava $(($lines-1)) objects in the $1 directory.\u0026#34; #echo \u0026#34;You entered the argument: $1,$2,$3, and $4.\u0026#34; 执行\n╭─ ~/shellTest 4m 3s ly@vmmin 11:09:41 ╰─❯ ./16myscript_cls.sh This script requires xxxxone directory path passed to it. Please try again. ╭─ ~/shellTest ly@vmmin 11:09:45 ╰─❯ ./16myscript_cls.sh logfiles You hava 8 objects in the logfiles directory. ╭─ ~/shellTest ly@vmmin 11:09:55 ╰─❯ ./16myscript_cls.sh logfiles x b This script requires xxxxone directory path passed to it. Please try again. 创建备份脚本 # ╭─ ~/shellTest ly@vmmin 12:41:11 ╰─❯ cat ./17myscript_cls.sh #!/bin/bash #如果参数个数不是2则退出,并指定exitCode为1 if [ $# -ne 2 ] then echo \u0026#34;Usage: backup.sh \u0026lt;source_directory\u0026gt; \u0026lt;target_directory\u0026gt;\u0026#34; echo \u0026#34;Please try again.\u0026#34; exit 1 fi # check rsync installed #发送标准错误和标准输出到/dev/null #command -v rsync \u0026gt; /dev/null 2\u0026gt;\u0026amp;1 这条命令,若rsync存在则返回零(真),否则返回非零(假) if ! command -v rsync \u0026gt; /dev/null 2\u0026gt;\u0026amp;1 then echo \u0026#34;This script requires rsync to be installed.\u0026#34; echo \u0026#34;Please use your distribution\u0026#39;s package manager to install it and try again.\u0026#34; #指定exitCode exit 2 fi #格式化date输出,即YYYY-MM-DD current_date=$(date +%Y-%m-%d) # -a 保留所有元数据,权限等 # -v 详细显示输出 #-b、--backup参数指定在删除或更新目标目录已经存在的文件时,将该文件更名后进行备份,默认行为是删除。更名规则是添加由--suffix参数指定的文件后缀名,默认是~。 #--backup-dir参数指定文件备份时存放的目录,比如--backup-dir=/path/to/backups # --delete 确保目标目录是源目录的克隆(完全克隆,不多不少) # --dry-run 尝试执行操作 rsync_options=\u0026#34;-avb --backup-dir $2/$current_date --delete --dry-run\u0026#34; #rsync_options=\u0026#34;-avb --backup-dir $2/$current_date --delete \u0026#34; # $1是源目录 $(which rsync) $rsync_options $1 $2/current \u0026gt;\u0026gt; backup_$current_date.log 运行脚本 # 会提示rsync还没有安装,需要安装\n╭─ ~/shellTest ly@vmmin 14:41:04 ╰─❯ nano 17myscript_cls.sh ╭─ ~/shellTest 27s ly@vmmin 14:41:35 ╰─❯ ./17myscript_cls.sh logfiles backup ╭─ ~/shellTest ly@vmmin 14:41:45 ╰─❯ ./17myscript_cls.sh logfiles/ backup ╭─ ~/shellTest ly@vmmin 14:41:49 ╰─❯ cat backup_2024-12-24.log sending incremental file list created directory backup/current logfiles/ logfiles/a.log logfiles/a.log.tar.gz logfiles/b.log logfiles/b.log.tar.gz logfiles/c.log logfiles/c.log.tar.gz logfiles/xx.txt logfiles/y.txt sent 279 bytes received 81 bytes 720.00 bytes/sec total size is 362 speedup is 1.01 (DRY RUN) sending incremental file list created directory backup/current ./ a.log a.log.tar.gz b.log b.log.tar.gz c.log c.log.tar.gz xx.txt y.txt sent 254 bytes received 80 bytes 668.00 bytes/sec total size is 362 speedup is 1.08 (DRY RUN) backup是空白,因为这只是试运行\n./17myscript_cls.sh logfiles backup和./17myscript_cls.sh logfiles/ backup的区别,后者备份文件夹logfiles下所有文件,而前者备份logfiles(包括文件夹自身)整个文件夹\n把 --dry-run去掉后运行 # 文件查看\n╭─ ~/shellTest ly@vmmin 14:42:53 ╰─❯ ls backup ╭─ ~/shellTest ly@vmmin 14:43:03 ╰─❯ nano 17myscript_cls.sh #第一次备份 ╭─ ~/shellTest 8s ly@vmmin 14:43:17 ╰─❯ ./17myscript_cls.sh logfiles/ backup ╭─ ~/shellTest ly@vmmin 14:43:28 ╰─❯ ls backup current ╭─ ~/shellTest ly@vmmin 14:43:42 ╰─❯ ls backup/current a.log a.log.tar.gz b.log b.log.tar.gz c.log c.log.tar.gz xx.txt y.txt 日志查看\n╭─ ~/shellTest ly@vmmin 14:43:46 ╰─❯ cat backup_2024-12-24.log sending incremental file list created directory backup/current logfiles/ logfiles/a.log logfiles/a.log.tar.gz logfiles/b.log logfiles/b.log.tar.gz logfiles/c.log logfiles/c.log.tar.gz logfiles/xx.txt logfiles/y.txt sent 279 bytes received 81 bytes 720.00 bytes/sec total size is 362 speedup is 1.01 (DRY RUN) sending incremental file list created directory backup/current ./ a.log a.log.tar.gz b.log b.log.tar.gz c.log c.log.tar.gz xx.txt y.txt sent 254 bytes received 80 bytes 668.00 bytes/sec total size is 362 speedup is 1.08 (DRY RUN) sending incremental file list created directory backup/current ./ a.log a.log.tar.gz b.log b.log.tar.gz c.log c.log.tar.gz xx.txt y.txt sent 916 bytes received 208 bytes 2,248.00 bytes/sec total size is 362 speedup is 0.32 此时在logfiles里新建一个文件以及更新一个文件\n╭─ ~/shellTest ly@vmmin 14:43:50 ╰─❯ touch logfiles/testfile.txt ╭─ ~/shellTest ly@vmmin 14:46:48 ╰─❯ touch logfiles/a.log ╭─ ~/shellTest ly@vmmin 14:47:20 ╰─❯ rm backup_2024-12-24.log #第二次备份 ╭─ ~/shellTest ly@vmmin 14:48:22 ╰─❯ ./17myscript_cls.sh logfiles/ backup ╭─ ~/shellTest ly@vmmin 14:49:16 ╰─❯ cat backup_2024-12-24.log sending incremental file list ./ a.log testfile.txt sent 339 bytes received 57 bytes 792.00 bytes/sec total size is 362 speedup is 0.91 查看此时真实目录\n╭─ ~/shellTest ly@vmmin 14:54:58 ╰─❯ ls 10_1myscript_cls.sh 17myscript_cls.sh 62myscript_cls.sh 91myscript_cls.sh 11_1myscript_cls.sh 2myscript_cls.sh 63myscript_cls.sh 92myscript_cls.sh 11_2myscript_cls.sh 31myscript_cls.sh 64myscript_cls.sh backup 12myscript_cls.sh 32myscript_cls.sh 65myscript_cls.sh backup_2024-12-24.log 13myscript_cls.sh 51myscript_cls.sh 71myscript_cls.sh logfiles 14myscript_cls.sh 52myscript_cls.sh 72myscript_cls.sh package_install_results.log 15myscript_cls.sh 53myscript_cls.sh 81myscript_cls.sh package_isntall_failure.log 16myscript_cls.sh 61myscript_cls.sh 82myscript_cls.sh ╭─ ~/shellTest ly@vmmin 14:55:37 ╰─❯ ls backup/current a.log backup b.log.tar.gz c.log.tar.gz xx.txt a.log.tar.gz b.log c.log testfile.txt y.txt #这里会发现,他在替换成新文件前,把旧的文件拷贝到备份文件夹中了 ╭─ ~/shellTest ly@vmmin 14:55:42 ╰─❯ ls backup/current/backup/2024-12-24 a.log 我又修改了一次a.log,变成了这样(深层次)\n╭─ ~/shellTest ly@vmmin 14:55:53 ╰─❯ touch logfiles/a.log ╭─ ~/shellTest ly@vmmin 14:57:52 ╰─❯ ./17myscript_cls.sh logfiles/ backup ╭─ ~/shellTest ly@vmmin 14:57:57 ╰─❯ cat backup_2024-12-24.log sending incremental file list ./ a.log testfile.txt sent 336 bytes received 57 bytes 786.00 bytes/sec total size is 362 speedup is 0.92 sending incremental file list deleting backup/2024-12-24/a.log cannot delete non-empty directory: backup/2024-12-24 cannot delete non-empty directory: backup a.log sent 295 bytes received 165 bytes 920.00 bytes/sec total size is 362 speedup is 0.79 ╭─ ~/shellTest ly@vmmin 14:58:00 ╰─❯ ls backup/current/backup/2024-12-24 a.log backup #注意,这里对a.log又进行了backup备份 ╭─ ~/shellTest ly@vmmin 14:58:17 ╰─❯ ls backup/current/backup/2024-12-24/backup 2024-12-24 ╭─ ~/shellTest ly@vmmin 14:58:23 ╰─❯ ls backup/current/backup/2024-12-24/backup/2024-12-24 a.log 继续Linux的学习 # https://ubuntuserverbook.com/ 作者写的书 https://www.youtube.com/c/LearnLinuxTV 作者的y2b https://learnlinux.tv 作者的网站 "},{"id":191,"href":"/zh/docs/technology/Linux/SHELLlearnLinuxTV_/12-15/","title":"12-15","section":"SHELL编程(learnLinuxTV)_","content":" functions 函数 # 以update这个脚本为基础编改\n作用\n减少重复代码 #!/bin/bash release_file=/etc/os-release logfile=/var/log/updater.log errorlog=/var/log/updater_errors.log check_exit_status(){ if [ $? -ne 0 ] then echo \u0026#34;An error occured,please check the $errorlog file.\u0026#34; fi } if grep -q \u0026#34;Arch\u0026#34; $release_file then sudo pacman -Syu 1\u0026gt;\u0026gt;$logfile 2\u0026gt;\u0026gt;$errorlog check_exit_status fi if grep -q \u0026#34;Ubuntu\u0026#34; $release_file || grep -q \u0026#34;Debian\u0026#34; $release_file then sudo apt update 1\u0026gt;\u0026gt;$logfile 2\u0026gt;\u0026gt;$errorlog check_exit_status #默认yes sudo apt dist-upgrade -y 1\u0026gt;\u0026gt;$logfile 2\u0026gt;\u0026gt;$errorlog check_exit_status fi CaseStatements # 脚本 # ╭─ ~/shellTest ly@vmmin 22:32:52 ╰─❯ cat ./13myscript_cls.sh #!/bin/bash finished=0 while [ $finished -ne 1 ] do echo \u0026#34;What is your favorite Linux distribution?\u0026#34; echo \u0026#34;1 - Arch\u0026#34; echo \u0026#34;2 - CentOS\u0026#34; echo \u0026#34;3 - Debian\u0026#34; echo \u0026#34;4 - Mint\u0026#34; echo \u0026#34;5 - Something else..\u0026#34; echo \u0026#34;6 - exit\u0026#34; read distro; case $distro in 1) echo \u0026#34;Arch is xxx\u0026#34;;; 2) echo \u0026#34;CentOS is xbxxx\u0026#34;;; 3) echo \u0026#34;Debian is bbbxx\u0026#34;;; 4) echo \u0026#34;Mint is xxxxsss\u0026#34;;; 5) echo \u0026#34;Something els.xxxxx\u0026#34;;; 6) finished=1 echo \u0026#34;now will exit\u0026#34; ;; *) echo \u0026#34;you didn\u0026#39;t enter an xxxx choice.\u0026#34; esac done echo \u0026#34;Thanks for using this script.\u0026#34; 脚本执行 # ╭─ ~/shellTest ly@vmmin 22:32:11 ╰─❯ ./13myscript_cls.sh What is your favorite Linux distribution? 1 - Arch 2 - CentOS 3 - Debian 4 - Mint 5 - Something else.. 6 - exit 3 Debian is bbbxx What is your favorite Linux distribution? 1 - Arch 2 - CentOS 3 - Debian 4 - Mint 5 - Something else.. 6 - exit u you didn\u0026#39;t enter an xxxx choice. What is your favorite Linux distribution? 1 - Arch 2 - CentOS 3 - Debian 4 - Mint 5 - Something else.. 6 - exit 6 now will exit Thanks for using this script. ScheduleJobs # 作用 # 脚本在特定时间运行\n安装 # ─ ~/shellTest ly@vmmin 22:37:20 ╰─❯ which at at not found ╭─ ~/shellTest ly@vmmin 22:37:23 ╰─❯ sudo apt install at 查看 # ─ ~/shellTest 28s ly@vmmin 22:38:59 ╰─❯ which at /usr/bin/at 示例 # ╰─❯ cat 14myscript_cls.sh #!/bin/bash logfile=job_results.log echo \u0026#34;The script ran at the following time: $(date)\u0026#34; \u0026gt; $logfile ╭─ ~/shellTest ly@vmmin 23:06:05 ╰─❯ date Mon Dec 23 11:06:06 PM CST 2024 ╭─ ~/shellTest ly@vmmin 23:06:06 ╰─❯ at 23:07 -f /home/ly/shellTest/14myscript_cls.sh warning: commands will be executed using /bin/sh job 1 at Mon Dec 23 23:07:00 2024 ╭─ ~/shellTest ly@vmmin 23:06:34 ╰─❯ cat job_results.log The script ran at the following time: Mon Dec 23 11:01:23 PM CST 2024 ╭─ ~/shellTest ly@vmmin 23:06:46 ╰─❯ cat job_results.log The script ran at the following time: Mon Dec 23 11:07:00 PM CST 2024 ╭─ ~/shellTest ly@vmmin 23:07:03 ╰─❯ date Mon Dec 23 11:07:10 PM CST 2024 解释 # at 23:07 -f /home/ly/shellTest/14myscript_cls.sh 23:07没给日期说明是今天,-f表示运行的是一个文件\n查看待运行任务 # ╭─ ~/shellTest ly@vmmin 23:10:53 ╰─❯ at 23:12 -f ./14myscript_cls.sh warning: commands will be executed using /bin/sh job 2 at Mon Dec 23 23:12:00 2024 ╭─ ~/shellTest ly@vmmin 23:11:02 ╰─❯ atq 2\tMon Dec 23 23:12:00 2024 a ly ╭─ ~/shellTest ly@vmmin 23:11:04 ╰─❯ at 23:13 -f ./14myscript_cls.sh warning: commands will be executed using /bin/sh job 3 at Mon Dec 23 23:13:00 2024 ╭─ ~/shellTest ly@vmmin 23:11:12 ╰─❯ atq 3\tMon Dec 23 23:13:00 2024 a ly 2\tMon Dec 23 23:12:00 2024 a ly 删除作业 # ╭─ ~/shellTest ly@vmmin 23:13:09 ╰─❯ at 23:15 -f ./14myscript_cls.sh warning: commands will be executed using /bin/sh job 4 at Mon Dec 23 23:15:00 2024 ╭─ ~/shellTest ly@vmmin 23:13:14 ╰─❯ at 23:16 -f ./14myscript_cls.sh warning: commands will be executed using /bin/sh job 5 at Mon Dec 23 23:16:00 2024 ╭─ ~/shellTest ly@vmmin 23:13:20 ╰─❯ atq 5\tMon Dec 23 23:16:00 2024 a ly 4\tMon Dec 23 23:15:00 2024 a ly ╭─ ~/shellTest ly@vmmin 23:13:24 ╰─❯ atrm 4 ╭─ ~/shellTest ly@vmmin 23:13:31 ╰─❯ atq 5\tMon Dec 23 23:16:00 2024 a ly 日期 # ╭─ ~/shellTest ly@vmmin 23:13:33 ╰─❯ atq 5\tMon Dec 23 23:16:00 2024 a ly ╭─ ~/shellTest ly@vmmin 23:14:52 ╰─❯ at 23:16 122424 -f ./14myscript_cls.sh warning: commands will be executed using /bin/sh job 6 at Tue Dec 24 23:16:00 2024 ╭─ ~/shellTest ly@vmmin 23:15:08 ╰─❯ atq 5\tMon Dec 23 23:16:00 2024 a ly 6\tTue Dec 24 23:16:00 2024 a ly CronJobs # 命令的完整路径 # 安排你的bash脚本,在将来的某个时间执行\n下面使用完全限定名\n╭─ ~/shellTest 19s ly@vmmin 09:08:02 ╰─❯ cat 15myscript_cls.sh #!/bin/bash logfile=job_results.log /usr/bin/echo \u0026#34;The script ran at the following time: $(/uar/bin/date)\u0026#34; \u0026gt; $logfile ╭─ ~/shellTest ly@vmmin 09:08:06 ╰─❯ which查看命令\nly@vmmin:~/shellTest$ which echo /usr/bin/echo ly@vmmin:~/shellTest$ which date /usr/bin/date 能够保持安全性,还有涉及到路径变量(没法找到,或者找错)\n编辑任务 # ╭─ ~/shellTest ly@vmmin 09:17:35 ╰─❯ crontab -e no crontab for ly - using an empty one Select an editor. To change later, run \u0026#39;select-editor\u0026#39;. 1. /bin/nano \u0026lt;---- easiest 2. /usr/bin/vim.basic 3. /usr/bin/vim.tiny Choose 1-3 [1]: 1 #之后会进入nano编辑器并编辑/tmp/crontab.I0AOMk/crontab这个文件(用户自己的,不会干扰其他用户)(crontab.I0AOMk这个文件夹每次都不确定,每次运行crontab -e都是不同文件夹,不过crontab文件内容会跟上次的一样) 编辑内容\n# For more information see the manual pages of cro\u0026gt; # # m h dom mon dow command 30 1 * * 5 /home/ly/shellTest/15myscript_cls.sh 30分钟时执行(每一个小时,即0:30,1:30,2:30),后面两个星号,表示一个月中几号,每年的哪个月,最后这个5表示每星期几(这里是星期五,0跟7都代表星期日)。 这个脚本的意思,每周五凌晨1点30分运行\n30 1 10 7 4 /home/ly/shellTest/15myscript_cls.sh,如果改成这样,则运行的概率极低。即 每年7月10号且星期四,那天里每一个小时到达30分时执行 #用root用户为某个用户创建任务,不存在则创建新文件并编辑任务,存在则继续编辑之前的任务文件 sudo crontab -u ly -e "},{"id":192,"href":"/zh/docs/technology/Linux/SHELLlearnLinuxTV_/11DataStreams/","title":"11DataStreams","section":"SHELL编程(learnLinuxTV)_","content":" 下面的输出中,涉及到标准输出的,有十几行的那些,只列举了其中四五行\n概念 # 标准输入,标准输出,标准错误 标准输出:打印到屏幕上的输出 ╭─ ~ ly@vmmin 12:31:44 ╰─❯ ls content.zh index.html myfile dufs.log install.sh shellTest ╭─ ~ ly@vmmin 12:32:21 ╰─❯ echo $? 0 标准错误 ╭─ ~ ly@vmmin 12:30:27 ╰─❯ ls /notexist ls: cannot access \u0026#39;/notexist\u0026#39;: No such file or directory ╭─ ~ ly@vmmin 12:30:59 ╰─❯ echo $? 2 标准输出和标准错误 # 部分重定向 # 标准错误重定向 2\u0026gt; # find,文件系统\nfind /etc -type f\n## 下面是附加知识,最后没用到 #新建一个用户,-m 让用户具有默认主目录,-d指定目录,-s指定用户登入后所使用的shell sudo useradd -d /home/ly1 -s /bin/bash -m ly1 #设置密码 sudo passwd ly1 为了演示错误,先创建几个文件\nroot@vmmin:/home/ly# mkdir a \u0026amp;\u0026amp; touch a/a1.txt a/a2.txt root@vmmin:/home/ly# mkdir b \u0026amp;\u0026amp; touch b/b1.txt b/b2.txt #去除所在组和其他人的所有权限 root@vmmin:/home/ly# chmod 700 a root@vmmin:/home/ly# chmod 700 b ╭─ ~ ly@vmmin 17:02:36 ╰─❯ ls -l total 80 drwx------ 2 root root 4096 Dec 23 16:13 a -rw-r--r-- 1 ly ly 17006 Dec 23 16:04 abc.txt drwx------ 2 root root 4096 Dec 23 16:13 b drwxr-xr-x 3 ly ly 4096 Dec 18 17:33 content.zh -rw-r--r-- 1 ly ly 90 Dec 20 11:20 dufs.log -rw-r--r-- 1 ly ly 19786 Dec 17 23:54 index.html -rw-r--r-- 1 ly ly 18369 Dec 19 15:01 install.sh -rw-r--r-- 1 ly ly 0 Dec 20 22:27 myfile drwxr-xr-x 3 ly ly 4096 Dec 23 10:34 shellTest 用root账号,在/home/ly下面创建了a,b文件夹,以及a1.txt,a2.txt,b1.txt,b2.txt\n,a,b文件夹的权限均为700\n使用find查找,会出现Permission错误\n这里使用-not -path \u0026quot;/home/ly/.**\u0026quot;忽略点开头的文件\n╭─ ~ ly@vmmin 17:02:38 ╰─❯ find ~ -type f -not -path \u0026#34;/home/ly/.**\u0026#34; find: ‘/home/ly/a’: Permission denied /home/ly/dufs.log find: ‘/home/ly/b’: Permission denied /home/ly/index.html /home/ly/abc.txt /home/ly/shellTest/2myscript_cls.sh /home/ly/shellTest/82myscript_cls.sh /home/ly/shellTest/62myscript_cls.sh /home/ly/shellTest/31myscript_cls.sh /home/ly/shellTest/92myscript_cls.sh 忽略显示错误的信息\n╭─ ~ ly@vmmin 17:05:37 ╰─❯ find ~ -type f -not -path \u0026#34;/home/ly/.**\u0026#34; 2\u0026gt; /dev/null /home/ly/dufs.log /home/ly/index.html /home/ly/abc.txt /home/ly/shellTest/2myscript_cls.sh /home/ly/shellTest/82myscript_cls.sh /home/ly/shellTest/62myscript_cls.sh /home/ly/shellTest/31myscript_cls.sh /home/ly/shellTest/92myscript_cls.sh /home/ly/shellTest/61myscript_cls.sh /home/ly/shellTest/32myscript_cls.sh /home/ly/shellTest/64myscript_cls.sh /home/ly/shellTest/65myscript_cls.sh /home/ly/shellTest/53myscript_cls.sh /home/ly/shellTest/72myscript_cls.sh /home/ly/shellTest/52myscript_cls.sh /home/ly/shellTest/81myscript_cls.sh ╭─ ~ ly@vmmin 17:05:47 ╰─❯ echo $? 1 echo $?结果为1说明其实出错了,但是没有显示。\n\u0026gt;号用来重定向 /dev/null dev null\n构成错误-标准错误的每一行将被发送到Dev null而不是屏幕\n标准输出重定向1\u0026gt;或\u0026gt; # ╭─ ~ ly@vmmin 17:12:43 ╰─❯ find ~ -type f -not -path \u0026#34;/home/ly/.**\u0026#34; \u0026gt; /dev/null find: ‘/home/ly/a’: Permission denied find: ‘/home/ly/b’: Permission denied 1\u0026gt; 和 \u0026gt; 是一样的结果,都是重定向标准输出\n╭─ ~ ly@vmmin 17:12:43 ╰─❯ find ~ -type f -not -path \u0026#34;/home/ly/.**\u0026#34; 1\u0026gt; /dev/null find: ‘/home/ly/a’: Permission denied find: ‘/home/ly/b’: Permission denied 重定向到文件 # ╭─ ~ ly@vmmin 17:15:07 ╰─❯ find ~ -type f -not -path \u0026#34;/home/ly/.**\u0026#34; 1\u0026gt; file.txt find: ‘/home/ly/a’: Permission denied find: ‘/home/ly/b’: Permission denied ╭─ ~ ly@vmmin 17:16:35 ╰─❯ cat file.txt /home/ly/dufs.log /home/ly/index.html /home/ly/file.txt /home/ly/abc.txt /home/ly/shellTest/2myscript_cls.sh /home/ly/shellTest/82myscript_cls.sh /home/ly/shellTest/62myscript_cls.sh /home/ly/shellTest/31myscript_cls.sh /home/ly/shellTest/92myscript_cls.sh /home/ly/shellTest/61myscript_cls.sh /home/ly/shellTest/32myscript_cls.sh /home/ly/shellTest/64myscript_cls.sh /home/ly/shellTest/65myscript_cls.sh /home/ly/shellTest/53myscript_cls.sh 同时重定向标准输出和标准错误 # 同时重定向到一个 # 在Shell中,标准错误写法为 2\u0026gt;, 标准输出为 1\u0026gt; 或者 \u0026gt;。如要要将标准输出和标准错误合二为一,都重定向到同一个文件,可以使用下面两种方式:\n\u0026amp;\u0026gt; # ╭─ ~ ly@vmmin 17:22:07 ╰─❯ find ~ -type f -not -path \u0026#34;/home/ly/.**\u0026#34; \u0026amp;\u0026gt; file.txt ╭─ ~ ly@vmmin 17:22:14 ╰─❯ cat file.txt find: ‘/home/ly/a’: Permission denied /home/ly/dufs.log find: ‘/home/ly/b’: Permission denied /home/ly/index.html /home/ly/file.txt /home/ly/abc.txt /home/ly/shellTest/2myscript_cls.sh /home/ly/shellTest/82myscript_cls.sh /home/ly/shellTest/62myscript_cls.sh /home/ly/shellTest/31myscript_cls.sh /home/ly/shellTest/92myscript_cls.sh /home/ly/shellTest/61myscript_cls.sh 2\u0026gt;\u0026amp;1 # ╭─ ~ ly@vmmin 17:24:41 ╰─❯ find ~ -type f -not -path \u0026#34;/home/ly/.**\u0026#34; \u0026gt; file.txt 2\u0026gt;\u0026amp;1 ╭─ ~ ly@vmmin 17:24:57 ╰─❯ cat file.txt find: ‘/home/ly/a’: Permission denied /home/ly/dufs.log find: ‘/home/ly/b’: Permission denied /home/ly/index.html /home/ly/file.txt /home/ly/abc.txt /home/ly/shellTest/2myscript_cls.sh /home/ly/shellTest/82myscript_cls.sh /home/ly/shellTest/62myscript_cls.sh /home/ly/shellTest/31myscript_cls.sh /home/ly/shellTest/92myscript_cls.sh 一条语句分别重定向到多个 # ╭─ ~ ly@vmmin 17:30:48 ╰─❯ find ~ -type f -not -path \u0026#34;/home/ly/.**\u0026#34; 1\u0026gt;find_results.txt 2\u0026gt;find_errors.txt ╭─ ~ ly@vmmin 17:31:06 ╰─❯ cat find_results.txt /home/ly/dufs.log /home/ly/index.html /home/ly/file.txt /home/ly/abc.txt /home/ly/shellTest/2myscript_cls.sh /home/ly/shellTest/82myscript_cls.sh /home/ly/shellTest/62myscript_cls.sh /home/ly/shellTest/31myscript_cls.sh /home/ly/shellTest/92myscript_cls.sh /home/ly/shellTest/61myscript_cls.sh /home/ly/shellTest/32myscript_cls.sh /home/ly/shellTest/64myscript_cls.sh ╭─ ~ ly@vmmin 17:31:24 ╰─❯ cat find_errors.txt find: ‘/home/ly/a’: Permission denied find: ‘/home/ly/b’: Permission denied 也可以使用find ~ -type f -not -path \u0026quot;/home/ly/.**\u0026quot; \u0026gt;find_results.txt 2\u0026gt;find_errors.txt\nupdate脚本(之前的) # ╭─ ~ ly@vmmin 17:36:08 ╰─❯ cat /usr/local/bin/update #!/bin/bash release_file=/etc/os-release if grep -q \u0026#34;Arch\u0026#34; $release_file then sudo pacman -Syu fi if grep -q \u0026#34;Ubuntu\u0026#34; $release_file || grep -q \u0026#34;Debian\u0026#34; $release_file then sudo apt update sudo apt dist-upgrade fi 修改\n╭─ ~/shellTest ly@vmmin 17:46:55 ╰─❯ cat 11_1_1myscript_cls.sh #!/bin/bash release_file=/etc/os-release logfile=/var/log/updater.log errorlog=/var/log/updater_errors.log if grep -q \u0026#34;Arch\u0026#34; $release_file then sudo pacman -Syu 1\u0026gt;\u0026gt;$logfile 2\u0026gt;\u0026gt;$errorlog if [ $? -ne 0 ] then echo \u0026#34;An error occured,please check the $errorlog file.\u0026#34; fi fi if grep -q \u0026#34;Ubuntu\u0026#34; $release_file || grep -q \u0026#34;Debian\u0026#34; $release_file then sudo apt update 1\u0026gt;\u0026gt;$logfile 2\u0026gt;\u0026gt;$errorlog if [ $? -ne 0 ] then echo \u0026#34;An error occured,please check the $errorlog file.\u0026#34; fi sudo apt dist-upgrade -y 1\u0026gt;\u0026gt;$logfile 2\u0026gt;\u0026gt;$errorlog if [ $? -ne 0 ] then echo \u0026#34;An error occured,please check the $errorlog file.\u0026#34; fi fi 切换到root用户并执行\n╭─ ~/shellTest ly@vmmin 17:50:25 ╰─❯ su root - Password: root@vmmin:/home/ly/shellTest# ./11_1_1myscript_cls.sh 查看\n#root用户下 root@vmmin:/home/ly/shellTest# cat /var/log/updater.log Hit:1 https://mirrors.tuna.tsinghua.edu.cn/debian bookworm InRelease Hit:2 https://mirrors.tuna.tsinghua.edu.cn/debian bookworm-updates InRelease Hit:3 https://mirrors.tuna.tsinghua.edu.cn/debian bookworm-backports InRelease Hit:4 https://mirrors.tuna.tsinghua.edu.cn/debian-security bookworm-security InRelease Hit:5 https://security.debian.org/debian-security bookworm-security InRelease Reading package lists... Building dependency tree... Reading state information... All packages are up to date. Reading package lists... Building dependency tree... Reading state information... Calculating upgrade... 0 upgraded, 0 newly installed, 0 to remove and 0 not upgraded. root@vmmin:/home/ly/shellTest# cat var/log/updater_errors.log cat: var/log/updater_errors.log: No such file or directory #这里没有出错,所以甚至连错误文件都没有 附加知识,监听文本文件\n╭─ ~ ly@vmmin 18:00:17 ╰─❯ sudo tail -f /var/log/updater.log Hit:5 https://security.debian.org/debian-security bookworm-security InRelease Reading package lists... Building dependency tree... Reading state information... All packages are up to date. Reading package lists... Building dependency tree... Reading state information... Calculating upgrade... 0 upgraded, 0 newly installed, 0 to remove and 0 not upgraded. 标准输入 # ╭─ ~/shellTest 5s ly@vmmin 18:07:53 ╰─❯ cat 11_2myscript_cls.sh #!/bin/bash echo \u0026#34;Please enter your name:\u0026#34; read myname echo \u0026#34;Your name is: $myname\u0026#34; ╭─ ~/shellTest 1m 6s ly@vmmin 18:07:42 ╰─❯ ./11_2myscript_cls.sh Please enter your name: JayH Your name is: JayH "},{"id":193,"href":"/zh/docs/technology/Linux/SHELLlearnLinuxTV_/07-10/","title":"07-10","section":"SHELL编程(learnLinuxTV)_","content":" WhileLoops # 范例 # #!/bin/bash myvar=1 #小于或者等于10 while [ $myvar -le 10 ] do echo $myvar myvar=$(( $myvar + 1 )) sleep 0.5 done 运行\n╭─ ~/shellTest ≡ ly@vmmin 12:10:33 ╰─❯ ./71myscript_cls.sh 1 2 3 4 5 6 7 8 9 10 数字会每隔0.5s就输出一次\n对于myvar=$(( $myvar + 1 )) ,$((expression))形式表示算数运算,而且其中的空格是可以省略的\n范例2 # #!/bin/bash while [ -f ~/testfile ] do echo \u0026#34;As of $(date),the test file exists.\u0026#34; sleep 5 done echo \u0026#34;As of $(date), the test ....has gone missing.\u0026#34; 用来测试文件是否存在,运行前先新建一下文件touch ~/testfile 运行一会后把文件删除,如图\ndate命令包含在子shell中,因此date命令将在后台运行并将该命令的输出替换$(date)这部分\n更新相关的脚本 # 基本概念 # upgrade:系统将现有的Package升级,如果有相依性的问题,而此相依性需要安装其它新的Package或影响到其它Package的相依性时,此Package就不会被升级,会保留下来。\ndist-upgrade:可以聪明的解决相依性的问题,如果有相依性问题,需要安装/移除新的Package,就会试着去安装/移除它。\ngrep -q,安静模式,不打印任何标准输出。如果有匹配的内容则立即返回状态值0\nshell中,零为真,非零为假\n#!/bin/bash release_file=/etc/os-release #这里没有使用[]测试命令,而是使用Linux命令 # #号用来注释,除了第一行shebang比较特殊 if grep -q \u0026#34;Arch\u0026#34; $release_file then sudo pacman -Syu fi # ||或者,\u0026amp;\u0026amp; 与, if grep -q \u0026#34;Ubuntu\u0026#34; $release_file || grep -q \u0026#34;Debian\u0026#34; $release_file then sudo apt update sudo apt dist-upgrade fi for语句 # #!/bin/bash for current_number in 1 2 3 4 5 6 7 8 9 10 do echo $current_number sleep 1 done echo \u0026#34;This is outside of the for loop.\u0026#34; for语句进入do语句前,current_number指向1,1的do结束后current_number指向2\n─ ~/shellTest ly@vmmin 21:55:34 ╰─❯ ./9myscript_cls.sh 1 2 3 4 5 6 7 8 9 10 This is outside of the for loop. 简化\n#!/bin/bash for current_number in {1..10} #for current_number in {a..z} #字母也行 do echo $current_number sleep 1 done echo \u0026#34;This is outside of the for loop.\u0026#34; #!/bin/bash for n in {1..10} #for n in {a..z} #字母也行 do echo $n sleep 1 done echo \u0026#34;This is outside of the for loop.\u0026#34; 文件遍历 # ─ ~/shellTest ly@vmmin 23:15:41 ╰─❯ ls logfiles a.log b.log c.log xx.txt y.txt 脚本:\n#!/bin/bash for file in logfiles/*.log do tar -czvf $file.tar.gz $file done tar命令,tar -czvf c : create,z : zip,v: view,f: file\n结果:\n╭─ ~/shellTest ly@vmmin 23:26:15 ╰─❯ ls logfiles a.log b.log c.log xx.txt a.log.tar.gz b.log.tar.gz c.log.tar.gz y.txt 可以用来循环发送日志文件(提到,没例子) # 脚本保存位置 # 主要讨论脚本应该放在哪个公共位置才可以让所有人都可以访问\n为需要的人提供脚本\nfile system hierarchy standard,文件系统层次结构标准,简称FHS 这个东西存在的目的,\u0026ldquo;所有Linux发行版上都可以找到的每个典型目录\u0026rdquo;。\nFHS指出了与本地安装的程序一起使用的用户本地目录(给系统管理员使用),bin目录也位于用户本地,我们将在其中放置脚本\n─ ~/shellTest ly@vmmin 10:34:13 ╰─❯ sudo mv 10_1myscript_cls.sh /usr/local/bin/update ╭─ ~/shellTest 3s ly@vmmin 10:28:43 ╰─❯ ls -l /usr/local/bin total 77876 -rwxr-xr-x 1 root root 4488672 Dec 17 16:28 dufs -rwxr-xr-x 1 root root 75247968 Dec 17 16:44 hugo -rwxr-xr-x 1 root root 231 Dec 23 10:28 update ╭─ ~/shellTest ly@vmmin 10:34:58 ╰─❯ ls -l /usr/local/bin total 77876 -rwxr-xr-x 1 root root 4488672 Dec 17 16:28 dufs -rwxr-xr-x 1 root root 75247968 Dec 17 16:44 hugo -rwxr-xr-x 1 ly ly 231 Dec 23 10:33 update 现在需要让这个脚本由root拥有,以确保有人需要pseudo privileges 伪权限或者root permissions root权限才能修改该脚本,不能让(普通)用户修改\n╭─ ~/shellTest ly@vmmin 10:35:05 ╰─❯ sudo chown root:root /usr/local/bin/update ╭─ ~/shellTest ly@vmmin 10:40:32 ╰─❯ ls -l /usr/local/bin total 77876 -rwxr-xr-x 1 root root 4488672 Dec 17 16:28 dufs -rwxr-xr-x 1 root root 75247968 Dec 17 16:44 hugo -rwxr-xr-x 1 root root 231 Dec 23 10:33 update Linux中任何脚本其实都不需要后缀的,所以这里删除了 .sh 。\n因为第一行shebang已经指明了需要使用到什么解释器\n使用 # ╭─ ~ ly@vmmin 11:39:04 ╰─❯ ls content.zh dufs.log index.html install.sh myfile shellTest ╭─ ~ ly@vmmin 11:39:05 ╰─❯ update Hit:1 https://mirrors.tuna.tsinghua.edu.cn/debian bookworm InRelease Hit:2 https://mirrors.tuna.tsinghua.edu.cn/debian bookworm-updates InRelease Hit:3 https://mirrors.tuna.tsinghua.edu.cn/debian bookworm-backports InRelease Hit:4 https://mirrors.tuna.tsinghua.edu.cn/debian-security bookworm-security InRelease Hit:5 https://security.debian.org/debian-security bookworm-security InRelease Reading package lists... Done Building dependency tree... Done Reading state information... Done All packages are up to date. Reading package lists... Done Building dependency tree... Done Reading state information... Done Calculating upgrade... Done 0 upgraded, 0 newly installed, 0 to remove and 0 not upgraded. ╭─ ~ 13s ly@vmmin 11:39:19 ╰─❯ which update /usr/local/bin/update 运行update命令的时候,是需要sudo权限的\n且不需要指定具体完整路径,就可以使用update文件\n有一个系统变量,告诉shell将在其中查找所有的目录\n全大写表示系统变量\n─ ~ ly@vmmin 11:44:21 ╰─❯ echo $PATH /usr/local/bin:/usr/bin:/bin:/usr/games 系统变量查看\n╭─ ~ ly@vmmin 11:45:48 ╰─❯ env USER=ly LOGNAME=ly HOME=/home/ly PATH=/usr/local/bin:/usr/bin:/bin:/usr/games SHELL=/usr/bin/zsh TERM=xterm DISPLAY=localhost:11.0 XDG_SESSION_ID=99 XDG_RUNTIME_DIR=/run/user/1000 DBUS_SESSION_BUS_ADDRESS=unix:path=/run/user/1000/bus XDG_SESSION_TYPE=tty XDG_SESSION_CLASS=user MOTD_SHOWN=pam LANG=en_US.UTF-8 SSH_CLIENT=192.168.1.201 52599 22 SSH_CONNECTION=192.168.1.201 52599 192.168.1.206 22 SSH_TTY=/dev/pts/2 SHLVL=1 PWD=/home/ly OLDPWD=/home/ly/shellTest P9K_TTY=old _P9K_TTY=/dev/pts/2 ZSH=/home/ly/.oh-my-zsh PAGER=less LESS=-R LSCOLORS=Gxfxcxdxbxegedabagacad LS_COLORS=rs=0:di=01;34:ln=01;36:mh=00:pi=40;33:so=01;35:do=01;35:bd=40;33;01:cd=40;33;01:or=40;31;01:mi=00:su=37;41:sg=30;43:ca=00:tw=30;42:ow=34;42:st=37;44:ex=01;32:*.tar=01;31:*.tgz=01;31:*.arc=01;31:*.arj=01;31:*.taz=01;31:*.lha=01;31:*.lz4=01;31:*.lzh=01;31:*.lzma=01;31:*.tlz=01;31:*.txz=01;31:*.tzo=01;31:*.t7z=01;31:*.zip=01;31:*.z=01;31:*.dz=01;31:*.gz=01;31:*.lrz=01;31:*.lz=01;31:*.lzo=01;31:*.xz=01;31:*.zst=01;31:*.tzst=01;31:*.bz2=01;31:*.bz=01;31:*.tbz=01;31:*.tbz2=01;31:*.tz=01;31:*.deb=01;31:*.rpm=01;31:*.jar=01;31:*.war=01;31:*.ear=01;31:*.sar=01;31:*.rar=01;31:*.alz=01;31:*.ace=01;31:*.zoo=01;31:*.cpio=01;31:*.7z=01;31:*.rz=01;31:*.cab=01;31:*.wim=01;31:*.swm=01;31:*.dwm=01;31:*.esd=01;31:*.avif=01;35:*.jpg=01;35:*.jpeg=01;35:*.mjpg=01;35:*.mjpeg=01;35:*.gif=01;35:*.bmp=01;35:*.pbm=01;35:*.pgm=01;35:*.ppm=01;35:*.tga=01;35:*.xbm=01;35:*.xpm=01;35:*.tif=01;35:*.tiff=01;35:*.png=01;35:*.svg=01;35:*.svgz=01;35:*.mng=01;35:*.pcx=01;35:*.mov=01;35:*.mpg=01;35:*.mpeg=01;35:*.m2v=01;35:*.mkv=01;35:*.webm=01;35:*.webp=01;35:*.ogm=01;35:*.mp4=01;35:*.m4v=01;35:*.mp4v=01;35:*.vob=01;35:*.qt=01;35:*.nuv=01;35:*.wmv=01;35:*.asf=01;35:*.rm=01;35:*.rmvb=01;35:*.flc=01;35:*.avi=01;35:*.fli=01;35:*.flv=01;35:*.gl=01;35:*.dl=01;35:*.xcf=01;35:*.xwd=01;35:*.yuv=01;35:*.cgm=01;35:*.emf=01;35:*.ogv=01;35:*.ogx=01;35:*.aac=00;36:*.au=00;36:*.flac=00;36:*.m4a=00;36:*.mid=00;36:*.midi=00;36:*.mka=00;36:*.mp3=00;36:*.mpc=00;36:*.ogg=00;36:*.ra=00;36:*.wav=00;36:*.oga=00;36:*.opus=00;36:*.spx=00;36:*.xspf=00;36:*~=00;90:*#=00;90:*.bak=00;90:*.old=00;90:*.orig=00;90:*.part=00;90:*.rej=00;90:*.swp=00;90:*.tmp=00;90:*.dpkg-dist=00;90:*.dpkg-old=00;90:*.ucf-dist=00;90:*.ucf-new=00;90:*.ucf-old=00;90:*.rpmnew=00;90:*.rpmorig=00;90:*.rpmsave=00;90: P9K_SSH=1 _P9K_SSH_TTY=/dev/pts/2 _=/usr/bin/env 如果想要修改与路径变量PATH不同的目录\n#如果/usr/bin/local/bin默认没有添加到path里面的情况 export PATH=/usr/bin/local/bin:$PATH "},{"id":194,"href":"/zh/docs/life/dailyExcerpt/","title":"每日摘抄","section":"生活","content":" 欲买桂花同载酒,终不似,少年游。 君埋泉下泥销骨,我寄人间雪满头。 吾不识青天高,黄地厚。唯见月寒日暖,来煎人寿。 \u0026ldquo;老妈看不到你变老的样子了\u0026rdquo; 我也曾闪亮如星,而非没入尘埃。 我并非一直无人问津,也曾有人对我寄予厚望。 \u0026ldquo;你要好好读书,将来让他们都有工作。\u0026rdquo; 人道洛阳花似锦,偏我来时不逢春。 "},{"id":195,"href":"/zh/docs/technology/Linux/SHELLlearnLinuxTV_/06ExitCode/","title":"06ExitCode","section":"SHELL编程(learnLinuxTV)_","content":" 意义 # 用来确定代码是否执行成功\n例子 # ls -l /misc echo $? #输出2 ls -l ~ echo $? #输出0 $?用来显示最近一个命令的状态,零表示成功,非零表示失败\n#!/bin/bash #这个例子之前,作者用 sudo apt remove htop 命令把htop删除了 package=htop sudo apt install $package echo \u0026#34;The exit code for ....is $?\u0026#34; 安装完毕后显示返回0\n另一个示例\n#!/bin/bash package=notexist sudo apt install $package echo \u0026#34;The exit code for ....is $?\u0026#34; #执行后显示 #Reading package lists... Done #Building dependency tree... Done #Reading state information... Done #E: Unable to locate package notexist #The exit code for ....is 100 配合if语句 # 基本功能 # #!/bin/bash package=htop sudo apt install $package if [ $? -eq 0 ] then echo \u0026#34;The installation of $package success...\u0026#34; echo \u0026#34;new comman here:\u0026#34; which $package else echo \u0026#34;$package failed ...\u0026#34; fi 之前前作者用sudo apt remove htop又把htop删除了,不过其实不删除也是走的 echo \u0026quot;The installation of .....\u0026quot;这个分支\n结果:\nxxxxxx.... kB] Fetched 152 kB in 1s (292 kB/s) Selecting previously unselected package htop. (Reading database ... 38811 files and directories currently installed.) Preparing to unpack .../htop_3.2.2-2_amd64.deb ... Unpacking htop (3.2.2-2) ... Setting up htop (3.2.2-2) ... Processing triggers for mailcap (3.70+nmu1) ... Processing triggers for man-db (2.11.2-2) ... The installation of htop success... new comman here: /usr/bin/htop 修改后:\n#!/bin/bash package=notexit sudo apt install $package if [ $? -eq 0 ] then echo \u0026#34;The installation of $package success...\u0026#34; echo \u0026#34;new comman here:\u0026#34; which $package else echo \u0026#34;$package failed ...\u0026#34; fi 再次运行的结果:\nReading package lists... Done Building dependency tree... Done Reading state information... Done E: Unable to locate package notexit notexit failed ... 重定向到文件 # 先把htop再次提前卸载sudo apt remove htop 成功 # #!/bin/bash package=htop sudo apt install $package \u0026gt;\u0026gt; package_install_results.log if [ $? -eq 0 ] then echo \u0026#34;The installation of $package success...\u0026#34; echo \u0026#34;new comman here:\u0026#34; which $package else echo \u0026#34;$package failed ...\u0026#34; \u0026gt;\u0026gt; package_isntall_failure.log fi 结果:\n╭─ ~/shellTest ≡ ly@vmmin 11:14:08 ╰─❯ ./63myscript_cls.sh WARNING: apt does not have a stable CLI interface. Use with caution in scripts. The installation of htop success... new comman here: /usr/bin/htop 查看文件:\n─ ~/shellTest ≡ ly@vmmin 11:15:06 ╰─❯ cat package_install_results.log Reading package lists... Building dependency tree... Reading state information... Suggested packages: lm-sensors strace The following NEW packages will be installed: htop 0 upgraded, 1 newly installed, 0 to remove and 0 not upgraded. Need to get 152 kB of archives. After this operation, 387 kB of additional disk space will be used. Get:1 https://mirrors.tuna.tsinghua.edu.cn/debian bookworm/main amd64 htop amd64 3.2.2-2 [152 kB] Fetched 152 kB in 1s (202 kB/s) Selecting previously unselected package htop. (Reading database ... 38811 files and directories currently installed.) Preparing to unpack .../htop_3.2.2-2_amd64.deb ... Unpacking htop (3.2.2-2) ... Setting up htop (3.2.2-2) ... Processing triggers for mailcap (3.70+nmu1) ... Processing triggers for man-db (2.11.2-2) ... 失败 # #!/bin/bash package=notexit sudo apt install $package \u0026gt;\u0026gt; package_install_results.log if [ $? -eq 0 ] then echo \u0026#34;The installation of $package success...\u0026#34; echo \u0026#34;new comman here:\u0026#34; which $package else echo \u0026#34;$package failed ...\u0026#34; \u0026gt;\u0026gt; package_isntall_failure.log fi 结果:\n─ ~/shellTest ≡ ly@vmmin 11:17:23 ╰─❯ ./63myscript_cls.sh WARNING: apt does not have a stable CLI interface. Use with caution in scripts. E: Unable to locate package notexit 查看文件\n╭─ ~/shellTest ≡ ly@vmmin 11:17:26 ╰─❯ cat *fail* notexit failed ... 退出代码的测试 # 代码\n#!/bin/bash directory=/notexist if [ -d $directory ] then echo $? #测试失败,是非0 echo \u0026#34;The directory $directory exists.\u0026#34; else echo $? #测试失败,是非0 echo \u0026#34;The directory $directory doesn\u0026#39;t exist.\u0026#34; fi #最近一个命令是echo,echo确实正确执行并输出了,所以上一个指令执行成功,返回0 echo \u0026#34;The exit code ....is $?\u0026#34; 结果\n╭─ ~/shellTest ≡ ly@vmmin 11:43:24 ╰─❯ ./64myscript_cls.sh 1 The directory /notexist doesn\u0026#39;t exist. The exit code ....is 0 控制退出代码的结果 # ─ ~/shellTest ≡ ly@vmmin 11:50:29 ╰─❯ cat ./65myscript_cls.sh #!/bin/bash echo \u0026#34;Hello world\u0026#34; exit 199 #这行代码永远都不会执行 echo \u0026#34;never exec\u0026#34; 结果:\n╭─ ~/shellTest ✘ STOP 1m 16s ≡ ly@vmmin 11:50:16 ╰─❯ ./65myscript_cls.sh Hello world ╭─ ~/shellTest ✘ 199 ≡ ly@vmmin 11:50:23 ╰─❯ echo $? 199 执行失败 # 以最后一次exit返回的code为最终结果\n虽然执行失败了,但是返回值还是以我们给出的为结果\n╭─ ~/shellTest ✘ INT ≡ ly@vmmin 11:53:24 ╰─❯ cat 65myscript_cls.sh #!/bin/bash sudo apt install notexist exit 0 #这行代码永远都不会执行 echo \u0026#34;never exec\u0026#34; 结果:\n╭─ ~/shellTest ✘ STOP 8s ≡ ly@vmmin 11:55:04 ╰─❯ ./65myscript_cls.sh Reading package lists... Done Building dependency tree... Done Reading state information... Done E: Unable to locate package notexist ╭─ ~/shellTest ≡ ly@vmmin 11:55:07 ╰─❯ echo $? 0 exit最本质含义 # !/bin/bash directory=/notexist if [ -d $directory ] then echo \u0026#34;The directory $directory exists.\u0026#34; exit 0 else echo \u0026#34;The directory $directory doesn\u0026#39;t exist.\u0026#34; exit 1 fi #下面这三行永远不会执行,因为上面的任何一个分支都会导致退出程序执行 echo \u0026#34;The exit code for this ....is: $?\u0026#34; echo \u0026#34;You didn\u0026#39;t ..see this..\u0026#34; echo \u0026#34;You won\u0026#39;t see ...\u0026#34; "},{"id":196,"href":"/zh/docs/technology/Linux/SHELLlearnLinuxTV_/05If/","title":"05If","section":"SHELL编程(learnLinuxTV)_","content":" 在shell中,零为真,非零为假。\nif then fi # mynum=200 #[和]前后都要有空格 if [ $mynum -eq 200 ] then echo \u0026#34;The condition is true.\u0026#34; fi 编辑之后,按ctrl + O 保存文件\nctrl + T + Z 保持在后台,fg+回车 恢复\n#!/bin/bash mynum=200 #[和]前后都要有空格 if [ $mynum -eq 200 ] then echo \u0026#34;The condition is true.\u0026#34; fi if [ $mynum -eq 300 ] then echo \u0026#34;The variable does not equal 200.\u0026#34; fi else if # #!/bin/bash mynum=300 #[和]前后都要有空格 if [ $mynum -eq 200 ] then echo \u0026#34;The condition is true.\u0026#34; else echo \u0026#34;The variable does not equal\u0026gt; fi ! # #!/bin/bash mynum=300 #[和]前后都要有空格 #!用来反转条件 if [ ! $mynum -eq 200 ] then echo \u0026#34;The condition is true.\u0026#34; else echo \u0026#34;The variable does not equal 200.\u0026#34; fi ne # #!/bin/bash mynum=300 #[和]前后都要有空格 if [ $mynum -ne 200 ] then echo \u0026#34;The condition is true.\u0026#34; else echo \u0026#34;The variable does not equal 200.\u0026#34; fi 其他 # -gt 大于\n-f 文件是否存在 # #!/bin/bash if [ -f ~/myfile ] then echo \u0026#34;The file exists.\u0026#34; else echo \u0026#34;The file does not exists.\u0026#34; fi touch # 文件不存在则创建文件;存在则更新修改时间\n-d查看是否存在某个目录\n配合install # which查看是否存在应用程序\n先是which htop,结果是空 说明暂时没有安装该程序\n编辑程序\n#!/bin/bash command=/usr/bin/htop #查看程序文件是否存在 if [ -f $command ] then echo \u0026#34;$command is available,let\u0026#39;s run it ...\u0026#34; else #不存在则进行安装 echo \u0026#34;$command is NOT available, installing it...\u0026#34; sudo apt update \u0026amp;\u0026amp; sudo apt install -y htop fi $command 首先apt update只是用来更新软件包列表,与镜像存储库同步,找出实际可用的软件包,并不会实际更新软件。这就是为什么上面要先更新列表之后再安装。 其次,经常有时候要apt update之后apt upgrade(这个命令才实际更新了软件) \u0026amp;\u0026amp;用来命令链接,如果第一个命令成功,将立即运行第二个命令。失败则不运行。-y表示不要确认提示,只需继续运行即可(-y:当安装过程提示选择全部为\u0026quot;yes\u0026quot; ) 还有一点,在此之前我已经将我该用户ly添加进了sudoer组,即使用root用户运行 sudo usermod -aG sudo ly 命令(sudo deluser ly sudo 移出sudo组)。解释:-a 参数表示附加,只和 -G 参数一同使用,表示将用户增加到组中;即将ly添加到sudo组中。 简化\n#!/bin/bash command=htop #这里删除了[],因为command本身就是一个测试命令 if command -v $command then echo \u0026#34;$command is available,let\u0026#39;s run it ...\u0026#34; else echo \u0026#34;$command is NOT available, installing it...\u0026#34; sudo apt update \u0026amp;\u0026amp; sudo apt install -y $command fi $command man # man test 补充 # 我经常用的是[[ ]] 这个命令,感觉比较直观,很多运算符都能用上。[]这个命令有些运算符没法用\n"},{"id":197,"href":"/zh/docs/technology/Linux/SHELLlearnLinuxTV_/01-04/","title":"01-04","section":"SHELL编程(learnLinuxTV)_","content":" 意义 # 执行一系列命令\n视频框架 # 介绍,欢迎 HelloWorld 变量 数学函数 if语句 退出代码 while循环 更新脚本,保持服务器最新状态 for循环 脚本应该存储在文件系统哪个位置 数据流,标准输入、标准输出、标准错误输出 函数 case语句 调度作业(SchedulingJobs)Part1 调度作业(SchedulingJobs)Part2 传递参数 备份脚本 准备 # 需要一台运行Linux系统的计算机(或虚拟机)\n一些基本操作 # 新建或编辑脚本 # nano myscript.sh 内容 # ctrl + o 保存,ctrl + x 退出\n如何执行脚本 # 权限 # #给脚本赋予执行的权限 sudo chmod +x myscript.sh 执行 # 执行前查看权限 # 运行 # ./myscript.sh 查看脚本 # cat myscript.sh 更多语句的脚本 # ls pwd 输出\nshebang # 告诉系统哪个解释器准备运行脚本(不特别指定的情况),比如bash ./myscript.sh就特别指明了用bash运行脚本,所以这里指的是./myscript.sh这种情况使用的哪个默认解释器\n#!/bin/bash echo \u0026#34;Hello World!\u0026#34; echo \u0026#34;My current working directory is:\u0026#34; #结果中pwd会另取一行跟这里的显式换行没关系, 我猜是echo在最末尾加了\\n换行符 pwd 关于echo行末换行符 # echo -n abc;echo c 这里使用-n禁止输出默认换行符,所以两个c连接上了\n变量 # 变量左右两侧都不允许有空格!! # nano快捷键 # ctrl + k ,删除当前行\n基本使用 # #!/bin/bash myname=\u0026#34;Jay\u0026#34; #myage=\u0026#34;40\u0026#34; my=\u0026#34;xxx\u0026#34; myage=\u0026#34;40\u0026#34; #\u0026#34;\u0026#34;和\u0026#39;\u0026#39;的区别 echo \u0026#39;Hello, my name is $myname.\u0026#39; echo \u0026#34;Hello, my name is $myname.\u0026#34; #注意下面这句,不会去找变量m,my或者mya(以word字符为界,即字母或下划线为开头,直到字母或数字或下划线终止) echo \u0026#34;I\u0026#39;m $myage years old.\u0026#34; #下面这句,将单引号进行了转义 #视频中的方法有点问题,这里貌似只能通过 #下面这种分段的方法 echo \u0026#39;I\u0026#39;\\\u0026#39;\u0026#39;m $myage years old.\u0026#39; 减少重复操作 # # myscript.sh #!/bin/bash word=\u0026#34;fun\u0026#34; echo \u0026#34;Linux is $word\u0026#34; echo \u0026#34;Vediogames are $word\u0026#34; echo \u0026#34;Sunny days are $word\u0026#34; 存储临时值 # now=$(date) echo \u0026#34;The system time and date is:\u0026#34; echo $now 系统环境变量(默认变量) # 视频中的 # 输出\n自己测试 # 系统变量字母全是大写英文 # #查看系统变量 env 数学函数 # 运算符左右两边都要有空格!! # shell中执行算术运算 # expr 3 + 3 expr 30 - 10 expr 30 / 10 乘法*号是通配符 # 反斜杠转义星号\nexpr 100 \\* 4 变量运算 # "},{"id":198,"href":"/zh/docs/technology/RegExp/baseCoreySchafer_/base/","title":"基础","section":"基础(CoreySchafer)_","content":" 环境 # 使用视频作者给出的示例,https://github.com/CoreyMSchafer/code_snippets/tree/master/Regular-Expressions 使用sublimeText打开的文件,ctrl+f时要确认勾选正则及区分大小写\nsimple.txt-基础操作 # 直接搜索 # 任意字符 # 这里默认不会显示所有,点击findAll才会出来\n有些字符需要加反斜杠转义,比如 . (点)以及 \\ (斜杠本身) # /////,从左到右,和书写方向一致的叫做(正)斜杠。\n反之,叫做反斜杠 \\\n一些元字符 # . - Any Character Except New Line 除了换行符的任意字符 \\d - Digit (0-9) 数字 \\D - Not a Digit (0-9) 非数字 \\w - Word Character (a-z, A-Z, 0-9, _) 单词字符,大小写字母+数字+下划线 \\W - Not a Word Character 非单词字符 \\s - Whitespace (space, tab, newline) 空白字符,空格+tab+换行符 \\S - Not Whitespace (space, tab, newline) 非空白字符 \\b - Word Boundary 边界字符-单词边界 \\B - Not a Word Boundary 非单词边界(没有单词边界) ^ - Beginning of a String $ - End of a String [] - Matches Characters in brackets [^ ] - Matches Characters NOT in brackets | - Either Or ( ) - Group Quantifiers: * - 0 or More + - 1 or More ? - 0 or One {3} - Exact Number {3,4} - Range of Numbers (Minimum, Maximum) #### Sample Regexs #### [a-zA-Z0-9_.+-]+@[a-zA-Z0-9-]+\\.[a-zA-Z0-9-.]+ 边界字符 # 非边界字符 # 事例 # 数字 # 方括号,或者 # 破折号是有特殊意义的(表示范围),比如1-9,a-z,但是处于方括号中的开头或者结尾,就是普通的破折号\n范围 # 任意的小写字母或者大写字母\n尖叫符号表示非,排除,否定 # 匹配多次(大括号,数字) # (|)组 或者关系,?出现或不出现 ,* 出现几次都行 # 综合事例 # 邮箱1 # 邮箱2 # URL # 匹配 # 分组并且反向引用 # 这里有个没展示,$0 表示匹配的内容,这里指的是从http一直到结束\n"},{"id":199,"href":"/zh/docs/test/hello2/","title":"pdfTest","section":"测试","content":"sd44sdf\ns2345df\nsssdfadf\n111\n"},{"id":200,"href":"/zh/docs/technology/Hugo/themes/PaperMod/01/","title":"使用PaperMode","section":"主题","content":" 地址 # 官方: https://github.com/adityatelange/hugo-PaperMod/wiki/Installation (有些东西没有同hugo官方同步) 非官方: https://github.com/vanitysys28/hugo-papermod-wiki/blob/master/Home.md (与hugo官方更同步)\n安装 # hugo new site blog.source --format yaml cd blog.source git init git submodule add --depth=1 https://github.com/adityatelange/hugo-PaperMod.git themes/PaperMod git submodule update --init --recursive # needed when you reclone your repo (submodules may not get cloned automatically) git submodule update --remote --merge "},{"id":201,"href":"/zh/docs/technology/Hugo/GiraffeAcademy_/advanced20-23/","title":"hugo进阶学习20-23","section":"基础(Giraffe学院)_","content":"\nDateFiles # {% raw %} { \u0026#34;classA\u0026#34;:\u0026#34;json位置: data\\\\classes.json\u0026#34;, \u0026#34;classA\u0026#34;:{ \u0026#34;master\u0026#34;:\u0026#34;xiaoLi\u0026#34;, \u0026#34;number\u0026#34;:\u0026#34;05\u0026#34; }, \u0026#34;classB\u0026#34;:{ \u0026#34;master\u0026#34;:\u0026#34;aXiang\u0026#34;, \u0026#34;number\u0026#34;:\u0026#34;15\u0026#34; }, \u0026#34;classC\u0026#34;:{ \u0026#34;master\u0026#34;:\u0026#34;BaoCeng\u0026#34;, \u0026#34;number\u0026#34;:\u0026#34;20\u0026#34; } } {% endraw %} 模板代码\n{% raw %} {{/* layouts\\_default\\single.html */}} {{ define \u0026#34;main\u0026#34; }} {{ range .Site.Data.classes }} master:{{.master}}==number:{{.number}}\u0026lt;br\u0026gt; {{end}} {{end}} {% endraw %} PartialTemplates # 传递全局范围 # {% raw %} {{/*layouts\\partials\\header.html*/}} \u0026lt;h1\u0026gt;{{.Title}}\u0026lt;/h1\u0026gt; \u0026lt;p\u0026gt;{{.Date}}\u0026lt;/p\u0026gt; {% endraw %} {% raw %} {{/*layouts\\_default\\single.html*/}} {{ define \u0026#34;main\u0026#34; }} {{ partial \u0026#34;header\u0026#34; . }} {{/*点.传递了当前文件的范围,代表了所有的范围,所有可以访问的变量*/}} \u0026lt;hr\u0026gt; {{end}} {% endraw %} 预览:\n传递字典 # {% raw %} {{/* layouts\\partials\\header.html */}} {{ partial \u0026#34;header\u0026#34; (dict \u0026#34;myTitle\u0026#34; \u0026#34;myCustomTitle\u0026#34; \u0026#34;myDate\u0026#34; \u0026#34;myCustomDate\u0026#34; ) }} {{/* partial \u0026#34;header\u0026#34; . 同一个partial只能在一个地方出现一次?这里会报错,不知道为啥*/}} \u0026lt;hr\u0026gt; {% endraw %} 使用:\n{% raw %} {{/*layouts\\partials\\header.html*/}} \u0026lt;h1\u0026gt;{{.myTitle}}\u0026lt;/h1\u0026gt; \u0026lt;p\u0026gt;{{.myDate}}\u0026lt;/p\u0026gt; {% endraw %} 效果:\nShortCodeTemplate # 效果图 # 记得先在a相关的template把 .Content 补上 # 代码片段的使用 # {% raw %} --- title: \u0026#34;This is A\u0026#39;s title\u0026#34; date: 2004-12-04T12:42:49+08:00 draft: true author: \u0026#34;Mike\u0026#34; color: \u0026#34;blue\u0026#34; --- This is A. {{\u0026lt; myshortcode color=\u0026#34;blue\u0026#34; \u0026gt;}} {{\u0026lt; myshortcode2 red \u0026gt;}} {{\u0026lt; myshortcode-p \u0026gt;}} This is the test inside the shortcode tags.. sdf d---end {{\u0026lt; /myshortcode-p \u0026gt;}} 下面没有被渲染: {{\u0026lt; myshortcode-p \u0026gt;}} **bold text** {{\u0026lt; /myshortcode-p \u0026gt;}} 下面被渲染了,但是没有被片段处理: {{% myshortcode-p %}} **bold text**xxx {{% /myshortcode-p %}} {%/* endraw */%} 代码片段的编写 # 等号键值对 # {% raw %} \u0026lt;!--layouts\\shortcodes\\myshortcode.html--\u0026gt; \u0026lt;p style=\u0026#34;color:{{.Get `color`}}\u0026#34;\u0026gt;This is my shortcode text\u0026lt;/p\u0026gt; {% endraw %} 直接写值 # {% raw %} \u0026lt;!--layouts\\shortcodes\\myshortcode2.html--\u0026gt; \u0026lt;p style=\u0026#34;color:{{.Get 0}}\u0026#34;\u0026gt;This is my shortcode text\u0026lt;/p\u0026gt; {% endraw %} 获取多行大量文字 # {% raw %} \u0026lt;!--layouts\\shortcodes\\myshortcode-p.html--\u0026gt; \u0026lt;p style=\u0026#34;background-color: yellow;\u0026#34;\u0026gt;{{.Inner}}\u0026lt;/p\u0026gt; {% endraw %} 如何构建网站及托管 # 使用hugo server运行并打开网站(平常测试) 使用hugo生成静态网页文件夹/public/ 把上面的/public/下的所有文件传到网络服务器即可 进行第三步之前,得先把原先传到网络服务器上的/public/的内容清空 "},{"id":202,"href":"/zh/docs/technology/Hugo/GiraffeAcademy_/advanced17-19/","title":"hugo进阶学习17-19","section":"基础(Giraffe学院)_","content":"\nVariable # 文件结构 # 实战 # {% raw %} {{/*layouts\\_default\\single.html*/}} {{ define \u0026#34;main\u0026#34; }} This is the single template\u0026lt;br\u0026gt; {{/* 常见变量 */}} title: {{ .Params.title }}\u0026lt;br\u0026gt; title: {{ .Title }}\u0026lt;br\u0026gt; date: {{ .Date }}\u0026lt;br\u0026gt; url: {{ .URL }}\u0026lt;br\u0026gt; myvar: {{ .Params.myVar }}\u0026lt;br\u0026gt; {{/* 定义变量 */}} {{ $myVarname := \u0026#34;aString\u0026#34; }} myVarname:{{ $myVarname }}\u0026lt;br\u0026gt; \u0026lt;h1 style=\u0026#34;color: {{ .Params.color }} ;\u0026#34; \u0026gt;Single Template\u0026lt;/h1\u0026gt; {{ end }} {% endraw %} {% raw %} --- title: \u0026#34;E-title\u0026#34; date: 2024-12-07T12:43:21+08:00 draft: true myVar: \u0026#34;myvalue\u0026#34; color: \u0026#34;red\u0026#34; --- This is dir3/e.md {% endraw %} 其他两个文件效果\n{% raw %} --- title: \u0026#34;F\u0026#34; date: 2024-12-07T12:43:21+08:00 draft: true color: \u0026#34;green\u0026#34; --- This is dir3/f.md {% endraw %} {% raw %} --- title: \u0026#34;This is A\u0026#39;s title\u0026#34; date: 2004-12-04T12:42:49+08:00 draft: true author: \u0026#34;Mike\u0026#34; color: \u0026#34;blue\u0026#34; --- This is A,/a. {% endraw %} 效果:\n官网详细默认变量 # hugo variables。\nFunctions函数 # 文件结构 # 代码(模板) # 注意,下面全是dir1下的模板,只对/dir1/及其下文件有效\nbaseof.html\n{% raw %} {{/*layouts\\_default\\baseof.html*/}} \u0026lt;html lang=\u0026#34;en\u0026#34;\u0026gt; \u0026lt;head\u0026gt; \u0026lt;meta charset=\u0026#34;UTF-8\u0026#34;\u0026gt; \u0026lt;meta name=\u0026#34;viewport\u0026#34; content=\u0026#34;width=device-width, initial-scale=1.0\u0026#34;\u0026gt; \u0026lt;title\u0026gt;Document\u0026lt;/title\u0026gt; \u0026lt;/head\u0026gt; \u0026lt;body\u0026gt; {{ block \u0026#34;main\u0026#34; . }} 33 {{ end }} \u0026lt;/body\u0026gt; \u0026lt;/html\u0026gt; {% endraw %} single.html\n{% raw %} {{/*layouts\\dir1\\single.html*/}} \u0026lt;html\u0026gt; \u0026lt;head\u0026gt; \u0026lt;meta charset=\u0026#34;UTF-8\u0026#34;\u0026gt; \u0026lt;meta name=\u0026#34;viewport\u0026#34; content=\u0026#34;width=device-width, initial-scale=1.0\u0026#34;\u0026gt; \u0026lt;meta http-equiv=\u0026#34;X-UA-Compatible\u0026#34; content=\u0026#34;ie=edge\u0026#34;\u0026gt; \u0026lt;title\u0026gt;Document111\u0026lt;/title\u0026gt; \u0026lt;/head\u0026gt; \u0026lt;body\u0026gt; Dir1Template,see! \u0026lt;h1\u0026gt;Header\u0026lt;/h1\u0026gt; \u0026lt;h3\u0026gt;{{.Title}}\u0026lt;/h3\u0026gt; \u0026lt;h4\u0026gt;{{.Date}}\u0026lt;/h4\u0026gt; \u0026lt;!--特殊项--\u0026gt; {{.Content}} \u0026lt;h1\u0026gt;Footer\u0026lt;/h1\u0026gt; \u0026lt;hr\u0026gt; {{ truncate 10 \u0026#34;This is a really long string\u0026#34;}}\u0026lt;br\u0026gt; {{ add 1 5 }}\u0026lt;br\u0026gt; {{ sub 1 5 }}\u0026lt;br\u0026gt; {{ singularize \u0026#34;dogs\u0026#34; }} \u0026lt;br\u0026gt; {{/*下面完全没有输出,因为不是list page*/}} {{ range .Pages }} {{ .Title }}\u0026lt;br\u0026gt; {{ end }} \u0026lt;/body\u0026gt; \u0026lt;/html\u0026gt; {% endraw %} 对于上面的single.html生成的html源码:\n{% raw %} \u0026lt;html\u0026gt; \u0026lt;head\u0026gt; \u0026lt;meta charset=\u0026#34;UTF-8\u0026#34;\u0026gt; \u0026lt;meta name=\u0026#34;viewport\u0026#34; content=\u0026#34;width=device-width, initial-scale=1.0\u0026#34;\u0026gt; \u0026lt;meta http-equiv=\u0026#34;X-UA-Compatible\u0026#34; content=\u0026#34;ie=edge\u0026#34;\u0026gt; \u0026lt;title\u0026gt;Document111\u0026lt;/title\u0026gt; \u0026lt;!--注意这里,说明完全使用layouts\\dir1\\single.html作为模板,跟baseof.html无关--\u0026gt; \u0026lt;/head\u0026gt; \u0026lt;body\u0026gt; Dir1Template,see! \u0026lt;h1\u0026gt;Header\u0026lt;/h1\u0026gt; \u0026lt;h3\u0026gt;B-title\u0026lt;/h3\u0026gt; \u0026lt;h4\u0026gt;2024-12-07 12:43:21 \u0026amp;#43;0800 CST\u0026lt;/h4\u0026gt; \u0026lt;p\u0026gt;This is dir1/b.md\u0026lt;/p\u0026gt; \u0026lt;h1\u0026gt;Footer\u0026lt;/h1\u0026gt; \u0026lt;hr\u0026gt; This is a …\u0026lt;br\u0026gt; 6\u0026lt;br\u0026gt; -4\u0026lt;br\u0026gt; dog \u0026lt;br\u0026gt; \u0026lt;script data-no-instant\u0026gt;document.write(\u0026#39;\u0026lt;script src=\u0026#34;/livereload.js?port=1313\u0026amp;mindelay=10\u0026#34;\u0026gt;\u0026lt;/\u0026#39; + \u0026#39;script\u0026gt;\u0026#39;)\u0026lt;/script\u0026gt;\u0026lt;/body\u0026gt; \u0026lt;/html\u0026gt; {% endraw %} list.html\n{% raw %} {{/* layouts\\dir1\\list.html */}} {{ define \u0026#34;main\u0026#34; }} This is the listTemplate for dir1;\u0026lt;br\u0026gt; {{/*下面只输出了dir1下的所有文件(包括子文件夹)*/}} {{ range .Pages }} {{ .Title }}\u0026lt;br\u0026gt; {{ end }} {{ end }} {% endraw %} IfStatements # 文件结构 # if代码演示 # {% raw %} {{/* layouts\\_default\\single.html */}} {{ define \u0026#34;main\u0026#34; }} This is the listTemplate\u0026lt;br\u0026gt; {{ $var1 := \u0026#34;dog\u0026#34; }} {{ $var2 := \u0026#34;cat\u0026#34; }} {{ if ge $var1 $var2 }} True {{ else }} False {{ end }} \u0026lt;br\u0026gt; {{ $var3 := 6 }} {{ $var4 := 4 }} {{ $var5 := 1 }} {{ if and (le $var3 $var4) (lt $var3 $var5) }} var3 is minist {{ else if and (le $var4 $var3) (lt $var4 $var5)}} var4 is minist {{ else }} var5 is minist {{ end }} \u0026lt;br\u0026gt; {{ end }} {% endraw %} 其他代码展示 # {% raw %} \u0026lt;!--layouts\\dir1\\single.html--\u0026gt; \u0026lt;html\u0026gt; \u0026lt;head\u0026gt; \u0026lt;meta charset=\u0026#34;UTF-8\u0026#34;\u0026gt; \u0026lt;meta name=\u0026#34;viewport\u0026#34; content=\u0026#34;width=device-width, initial-scale=1.0\u0026#34;\u0026gt; \u0026lt;meta http-equiv=\u0026#34;X-UA-Compatible\u0026#34; content=\u0026#34;ie=edge\u0026#34;\u0026gt; \u0026lt;title\u0026gt;Document111\u0026lt;/title\u0026gt; \u0026lt;/head\u0026gt; \u0026lt;body\u0026gt; {{ $title := .Title }} {{/* 注意,这里遍历的是整个网站(.Site)的文件 */}} {{ range .Site.Pages }} \u0026lt;a href=\u0026#34;{{.URL}}\u0026#34; style=\u0026#34; {{ if eq .Title $title }} background-color: red; {{ end }} \u0026#34;\u0026gt;{{.Title}}\u0026lt;/a\u0026gt; {{ end }} \u0026lt;hr\u0026gt; \u0026lt;/body\u0026gt; \u0026lt;/html\u0026gt; {% endraw %} "},{"id":203,"href":"/zh/docs/technology/Hugo/GiraffeAcademy_/advanced11-16/","title":"hugo进阶学习11-15","section":"基础(Giraffe学院)_","content":" 这里使用的版本是v0.26(很久之前的版本)\ntemplate basic # 模板分为list template和single template\n文件夹结构 # content目录结构\nlist template (列表模板) # single template (单页模板) # 特点 # 所有的列表之间都是长一样的(页眉,页脚,及内容(都是列表))\n所有的单页之间都是长一样的(一样的页眉页脚,一样的内容布局)\n部分代码解释 # 单页探索 # list page templates # 文件夹结构 # 文件内容 # #content/_index --- title: \u0026#34;_Index\u0026#34; --- This is the home page #content/dir1/_index --- title: \u0026#34;_Index\u0026#34; --- This is the landing page for dir1 当前效果 # 原因 # {% raw %} \u0026lt;!--themes\\ga-hugo-theme\\layouts\\_default\\list.html--\u0026gt; \u0026lt;html\u0026gt; \u0026lt;head\u0026gt; \u0026lt;meta charset=\u0026#34;UTF-8\u0026#34;\u0026gt; \u0026lt;meta name=\u0026#34;viewport\u0026#34; content=\u0026#34;width=device-width, initial-scale=1.0\u0026#34;\u0026gt; \u0026lt;meta http-equiv=\u0026#34;X-UA-Compatible\u0026#34; content=\u0026#34;ie=edge\u0026#34;\u0026gt; \u0026lt;title\u0026gt;Document\u0026lt;/title\u0026gt; \u0026lt;/head\u0026gt; \u0026lt;body\u0026gt; {{ partial \u0026#34;header\u0026#34; (dict \u0026#34;Kind\u0026#34; .Kind \u0026#34;Template\u0026#34; \u0026#34;List\u0026#34;) }} {{.Content}} {{ range .Pages }} \u0026lt;div style=\u0026#34;border: 1px solid black; margin:10px; padding:10px; \u0026#34;\u0026gt; \u0026lt;div style=\u0026#34;font-size:20px;\u0026#34;\u0026gt; \u0026lt;a href=\u0026#34;{{.URL}}\u0026#34;\u0026gt;{{.Title}}\u0026lt;/a\u0026gt; \u0026lt;/div\u0026gt; \u0026lt;div style=\u0026#34;color:grey; font-size:16px;\u0026#34;\u0026gt;{{ dateFormat \u0026#34;Monday, Jan 2, 2006\u0026#34; .Date }}\u0026lt;/div\u0026gt; \u0026lt;div style=\u0026#34;color:grey; font-size:16px;\u0026#34;\u0026gt;{{ if .Params.tags }}\u0026lt;strong\u0026gt;Tags:\u0026lt;/strong\u0026gt; {{range .Params.tags}}\u0026lt;a href=\u0026#34;{{ \u0026#34;/tags/\u0026#34; | relLangURL }}{{ . | urlize }}\u0026#34;\u0026gt;{{ . }}\u0026lt;/a\u0026gt; {{end}}{{end}}\u0026lt;/div\u0026gt; \u0026lt;div style=\u0026#34;color:grey; font-size:16px;\u0026#34;\u0026gt;{{ if .Params.categories }}\u0026lt;strong\u0026gt;Categories:\u0026lt;/strong\u0026gt; {{range .Params.categories}}\u0026lt;a href=\u0026#34;{{ \u0026#34;/categories/\u0026#34; | relLangURL }}{{ . | urlize }}\u0026#34;\u0026gt;{{ . }}\u0026lt;/a\u0026gt; {{end}}{{end}}\u0026lt;/div\u0026gt; \u0026lt;div style=\u0026#34;color:grey; font-size:16px;\u0026#34;\u0026gt;{{ if .Params.moods }}\u0026lt;strong\u0026gt;Moods:\u0026lt;/strong\u0026gt; {{range .Params.moods}}\u0026lt;a href=\u0026#34;{{ \u0026#34;/moods/\u0026#34; | relLangURL }}{{ . | urlize }}\u0026#34;\u0026gt;{{ . }}\u0026lt;/a\u0026gt; {{end}}{{end}}\u0026lt;/div\u0026gt; \u0026lt;p style=\u0026#34;font-size:18px;\u0026#34;\u0026gt;{{.Summary}}\u0026lt;/p\u0026gt; \u0026lt;/div\u0026gt; {{ end }} {{ partial \u0026#34;footer\u0026#34; . }} \u0026lt;/body\u0026gt; \u0026lt;/html\u0026gt; {% endraw %} 覆盖默认的list template # 编辑文件并保存\n{% raw %} \u0026lt;!--layouts\\_default\\list.html--\u0026gt; \u0026lt;html\u0026gt; \u0026lt;head\u0026gt; \u0026lt;meta charset=\u0026#34;UTF-8\u0026#34;\u0026gt; \u0026lt;meta name=\u0026#34;viewport\u0026#34; content=\u0026#34;width=device-width, initial-scale=1.0\u0026#34;\u0026gt; \u0026lt;meta http-equiv=\u0026#34;X-UA-Compatible\u0026#34; content=\u0026#34;ie=edge\u0026#34;\u0026gt; \u0026lt;title\u0026gt;Document\u0026lt;/title\u0026gt; \u0026lt;/head\u0026gt; \u0026lt;body\u0026gt; {{.Content}} \u0026lt;!--显示对应的目录下的_index.md内容--\u0026gt; {{ range .Pages }} \u0026lt;!--枚举对应目录下所有页面(.md)--\u0026gt; \u0026lt;ul\u0026gt; \u0026lt;!--.URL 文件路径,类似 /a或者/dir1/b--\u0026gt; \u0026lt;!--.Title md中的前言-title字段--\u0026gt; \u0026lt;li\u0026gt;\u0026lt;a href=\u0026#34;{{.URL}}\u0026#34;\u0026gt;{{.Title}}\u0026lt;/a\u0026gt;\u0026lt;/li\u0026gt; \u0026lt;/ul\u0026gt; {{end}} \u0026lt;/body\u0026gt; \u0026lt;/html\u0026gt; {% endraw %} 效果 # list template简易版\nsingle template # 当前效果 # 主题默认代码 # {% raw %} \u0026lt;!-- themes\\ga-hugo-theme\\layouts\\_default\\single.html --\u0026gt; \u0026lt;html\u0026gt; \u0026lt;head\u0026gt; \u0026lt;meta charset=\u0026#34;UTF-8\u0026#34;\u0026gt; \u0026lt;meta name=\u0026#34;viewport\u0026#34; content=\u0026#34;width=device-width, initial-scale=1.0\u0026#34;\u0026gt; \u0026lt;meta http-equiv=\u0026#34;X-UA-Compatible\u0026#34; content=\u0026#34;ie=edge\u0026#34;\u0026gt; \u0026lt;title\u0026gt;Document\u0026lt;/title\u0026gt; \u0026lt;/head\u0026gt; \u0026lt;body\u0026gt; {{ partial \u0026#34;header\u0026#34; (dict \u0026#34;Kind\u0026#34; .Kind \u0026#34;Template\u0026#34; \u0026#34;Single\u0026#34;) }} \u0026lt;p\u0026gt;Test test\u0026lt;/p\u0026gt; \u0026lt;div style=\u0026#34;margin:25px;\u0026#34;\u0026gt; \u0026lt;h1\u0026gt;{{.Title}}\u0026lt;/h1\u0026gt; \u0026lt;div style=\u0026#34;color:grey; font-size:16px;\u0026#34;\u0026gt;{{ dateFormat \u0026#34;Monday, Jan 2, 2006\u0026#34; .Date }}\u0026lt;/div\u0026gt; \u0026lt;div style=\u0026#34;color:grey; font-size:16px;\u0026#34;\u0026gt;{{if .Params.author}}Author: {{.Params.Author}}{{end}}\u0026lt;/div\u0026gt; \u0026lt;div style=\u0026#34;font-size:18px;\u0026#34;\u0026gt;{{.Content}}\u0026lt;/div\u0026gt; \u0026lt;/div\u0026gt; {{ partial \u0026#34;footer\u0026#34; . }} \u0026lt;/body\u0026gt; \u0026lt;/html\u0026gt; {% endraw %} 改编 # {% raw %} \u0026lt;!--layouts\\_default\\single.html--\u0026gt; \u0026lt;html\u0026gt; \u0026lt;head\u0026gt; \u0026lt;meta charset=\u0026#34;UTF-8\u0026#34;\u0026gt; \u0026lt;meta name=\u0026#34;viewport\u0026#34; content=\u0026#34;width=device-width, initial-scale=1.0\u0026#34;\u0026gt; \u0026lt;meta http-equiv=\u0026#34;X-UA-Compatible\u0026#34; content=\u0026#34;ie=edge\u0026#34;\u0026gt; \u0026lt;title\u0026gt;Document\u0026lt;/title\u0026gt; \u0026lt;/head\u0026gt; \u0026lt;body\u0026gt; \u0026lt;h1\u0026gt;Header\u0026lt;/h1\u0026gt; \u0026lt;h3\u0026gt;{{.Title}}\u0026lt;/h3\u0026gt; \u0026lt;h4\u0026gt;{{.Date}}\u0026lt;/h4\u0026gt; \u0026lt;!--特殊项--\u0026gt; {{.Content}} \u0026lt;h1\u0026gt;Footer\u0026lt;/h1\u0026gt; \u0026lt;/body\u0026gt; \u0026lt;/html\u0026gt; {% endraw %} 效果\nhome template # 是什么 # 前面学到,页面分为“列表页面list page”和“单页页面”。其实再细分还有一种“主页页面home page”。 主页,即 localhost:1313 是先使用homepage,找不到的情况,才会使用list page 目录结构 # 当前效果 # 修改文件代码 # {% raw %} \u0026lt;!--layouts\\index.html--\u0026gt; Home Page Template {% endraw %} 效果 # SectionTemplate # 当前目录结构 # 目的 # 不用理会a.md使用哪个当single template。而dir1文件夹下的所有md,都是用同一个single template。\n目前content下所有md文件详情:a.md使用layouts/index.html当模板(没有的话则找layouts/_default/index.html当模板)。b.md和c.md、e.md、d.md、f.md均使用layouts/_default/index.html当模板\n代码\n{% raw %} \u0026lt;!--layouts\\dir1\\single.html--\u0026gt; \u0026lt;html\u0026gt; \u0026lt;head\u0026gt; \u0026lt;meta charset=\u0026#34;UTF-8\u0026#34;\u0026gt; \u0026lt;meta name=\u0026#34;viewport\u0026#34; content=\u0026#34;width=device-width, initial-scale=1.0\u0026#34;\u0026gt; \u0026lt;meta http-equiv=\u0026#34;X-UA-Compatible\u0026#34; content=\u0026#34;ie=edge\u0026#34;\u0026gt; \u0026lt;title\u0026gt;Document\u0026lt;/title\u0026gt; \u0026lt;/head\u0026gt; \u0026lt;body\u0026gt; Dir1Template,see! \u0026lt;h1\u0026gt;Header\u0026lt;/h1\u0026gt; \u0026lt;h3\u0026gt;{{.Title}}\u0026lt;/h3\u0026gt; \u0026lt;h4\u0026gt;{{.Date}}\u0026lt;/h4\u0026gt; \u0026lt;!--特殊项--\u0026gt; {{.Content}} \u0026lt;h1\u0026gt;Footer\u0026lt;/h1\u0026gt; \u0026lt;/body\u0026gt; \u0026lt;/html\u0026gt; {% endraw %} 结果 # 其他的走默认模板 layouts\\_default\\single.html\nBase Templates \u0026amp;\u0026amp; Blocks Hugo # 是什么 # BaseTemplate就是这个网站的总体模板\n案例 # 目录结构 # 编辑文件 # baseof.html\n{% raw %} \u0026lt;!--layouts\\_default\\baseof.html--\u0026gt; \u0026lt;html lang=\u0026#34;en\u0026#34;\u0026gt; \u0026lt;head\u0026gt; \u0026lt;meta charset=\u0026#34;UTF-8\u0026#34;\u0026gt; \u0026lt;meta name=\u0026#34;viewport\u0026#34; content=\u0026#34;width=device-width, initial-scale=1.0\u0026#34;\u0026gt; \u0026lt;title\u0026gt;Document\u0026lt;/title\u0026gt; \u0026lt;/head\u0026gt; \u0026lt;body\u0026gt; \u0026lt;!--Hugo实体快,Block--\u0026gt; {{ block \u0026#34;main\u0026#34; . }} {{end}} \u0026lt;/body\u0026gt; \u0026lt;/html\u0026gt; {% endraw %} single.html\n不要用html5的\u0026lt;!----!\u0026gt;注释,会出问题\n{% raw %} {{ define \u0026#34;main\u0026#34; }} This is the single template {{ end }} {% endraw %} list.html\n{% raw %} {{/* layouts\\_default\\single.html */}} {{ define \u0026#34;main\u0026#34; }} This is the listTemplate {{ end }} {% endraw %} 效果\n"},{"id":204,"href":"/zh/docs/technology/Hugo/GiraffeAcademy_/advanced01-10/","title":"hugo进阶学习01-10","section":"基础(Giraffe学院)_","content":" 系列视频地址介绍\nhttps://www.youtube.com/watch?v=qtIqKaDlqXo\u0026list=PLLAZ4kZ9dFpOnyRlyS-liKL5ReHDcj4G3\n介绍 # hugo是用来构建静态网站的 但是也可以稍微做点动态生成的事 这里使用的版本是v0.26(很久之前的版本) 备注:标题短代码之前(不包括短代码这篇)的笔记是回溯的,所以没有复制源代码下来,直接在视频再次截图的\n在Windows上安装hugo # 到github release下载,然后放到某个文件夹中\n设置环境变量\n验证环境变量\n最后验证hugo版本 hugo version 创建一个新的网站 # 使用代码生成 hugo new site 文件夹结构\n使用主题 # 这里是https://themes.gohugo.io\n这里使用的是ga-hugo-theme(github中查找),并放到themes文件夹中\n之后在config.toml中使用主题\nbaseURL = \u0026#34;http://example.org/\u0026#34; languageCode = \u0026#34;en-us\u0026#34; title = \u0026#34;My New Hugo Site\u0026#34; theme = \u0026#34;ga-hugo-theme\u0026#34; #添加这句话 启动博客\nhugo serve 地址\nlocalhost:1313 创建md文件 # 使用hugo new a.md把文件创建在content/a.md或者hugo new dir2/d.md把文件创建在content/dir2.md下,这讲创建后的结构目录为\n总共5个文件,可以使用localhost:1313访问博客(默认列举所有(包括子文件夹)文件 可以使用 localhost:1313/dir3访问dir3下所有文件列表(list),localhost:1313/dir1访问dir1下所有文件列表 (都是content的直接子文件夹) 如果没有dir1/dir2/_index.md这个文件 ,则不能直接使用localhost:1313/dir1/dir2访问dir1/dir2下所有文件 查看dir1/dir2/index.md文件及效果\nfrontmatter (前言) # 可以使用YAML,TOML,或者JSON md编码及效果\narchetypes(原型) # 默认的原型文件 # archetypes/default.md\n{% raw %} --- title: \u0026#34;{{ replace .TranslationBaseName \u0026#34;-\u0026#34; \u0026#34; \u0026#34; | title }}\u0026#34; date: {{ .Date }} draft: true author: \u0026#34;Mike\u0026#34; --- {% endraw %} 使用命令行hugo new b.md结果\n和文件夹结构相关的原型文件 # 使用命令行hugo new dir1/c.md结果\n如果hugo new dir1/c.md时archetypes/dir1.md不存在,则才会去找archetypes/default.md当模板创建文件\nshortcodes 短代码 # 代码 # 放到markdown文件中(这个youtube是官方支持的内嵌的)\n{% raw %} {{/*\u0026lt; youtube w7Ft2ymGmfc \u0026gt;*/}} {% endraw %} 效果 # taxonomies(分类法) # 默认的两个分类 # 比如修改了总共三个文件 (隐去其他前言数据)\n{% raw %} --- # a.md title: \u0026#34;A\u0026#34; tags: [\u0026#34;tag1\u0026#34;,\u0026#34;tag2\u0026#34;,\u0026#34;tag3\u0026#34;] categories: [\u0026#34;cat1\u0026#34;] --- # b.md --- title: \u0026#34;B\u0026#34; tags: [\u0026#34;tag2\u0026#34; ] categories: [\u0026#34;cat2\u0026#34;] --- # c.md --- title: \u0026#34;C\u0026#34; tags: [\u0026#34;tag3\u0026#34;] categories: [\u0026#34;cat2\u0026#34;] --- {% endraw %} 效果:\n点击tag2时效果\n点击cat1时的效果\n自定义分类 # {% raw %} # a.md添加最后一行,最后代码(忽略其他属性) --- title: \u0026#34;A tags: [\u0026#34;tag1\u0026#34;,\u0026#34;tag2\u0026#34;,\u0026#34;tag3\u0026#34;] categories: [\u0026#34;cat1\u0026#34;] moods: [\u0026#34;Happy\u0026#34;,\u0026#34;Upbeat\u0026#34;] --- {% endraw %} 以及修改config.toml文件\n{% raw %} baseURL = \u0026#34;http://example.org/\u0026#34; languageCode = \u0026#34;en-us\u0026#34; title = \u0026#34;My New Hugo Site\u0026#34; theme = \u0026#34;ga-hugo-theme\u0026#34; [taxonomies] #添加这行及以下三行 tag = \u0026#34;tags\u0026#34; category = \u0026#34;categories\u0026#34; mood = \u0026#34;moods\u0026#34; {% endraw %} 效果:\n"},{"id":205,"href":"/zh/docs/technology/Obsidian/border-theme/","title":"border-theme背景图片问题","section":"Obsidian","content":" svg格式作为背景图片(简单图片可行) # 以下面这张图片为例\n最简单的方式,用记事本/文本编辑器,打开svg图片,全选,复制,即\n\u0026lt;svg xmlns=\u0026#34;http://www.w3.org/2000/svg\u0026#34; viewBox=\u0026#34;0 0 1920 1080\u0026#34;\u0026gt;\u0026lt;g transform=\u0026#34; rotate(0 960 540) translate(-0 -0) scale(1) \u0026#34;\u0026gt;\u0026lt;rect width=\u0026#34;1920\u0026#34; height=\u0026#34;1080\u0026#34; fill=\u0026#34;rgb(184, 171, 255)\u0026#34;\u0026gt;\u0026lt;/rect\u0026gt;\u0026lt;g transform=\u0026#34;translate(0, 0)\u0026#34;\u0026gt;\u0026lt;path fill=\u0026#34;rgb(131, 114, 218)\u0026#34; fill-opacity=\u0026#34;1\u0026#34; d=\u0026#34;M0,352.943L45.714,350.075C91.429,347.207,182.857,341.471,274.286,340.581C365.714,339.692,457.143,343.65,548.571,344.095C640,344.54,731.429,341.472,822.857,303.183C914.286,264.894,1005.714,191.383,1097.143,185.175C1188.571,178.967,1280,240.06,1371.429,221.336C1462.857,202.612,1554.286,104.069,1645.714,98.48C1737.143,92.892,1828.571,180.258,1874.286,223.941L1920,267.624L1920,1080L1874.286,1080C1828.571,1080,1737.143,1080,1645.714,1080C1554.286,1080,1462.857,1080,1371.429,1080C1280,1080,1188.571,1080,1097.143,1080C1005.714,1080,914.286,1080,822.857,1080C731.429,1080,640,1080,548.571,1080C457.143,1080,365.714,1080,274.286,1080C182.857,1080,91.429,1080,45.714,1080L0,1080Z\u0026#34;\u0026gt;\u0026lt;/path\u0026gt;\u0026lt;/g\u0026gt;\u0026lt;g transform=\u0026#34;translate(0, 360)\u0026#34;\u0026gt;\u0026lt;path fill=\u0026#34;rgb(79, 57, 180)\u0026#34; fill-opacity=\u0026#34;1\u0026#34; d=\u0026#34;M0,136.093L45.714,117.434C91.429,98.774,182.857,61.455,274.286,80.719C365.714,99.983,457.143,175.829,548.571,189.505C640,203.181,731.429,154.687,822.857,130.414C914.286,106.141,1005.714,106.09,1097.143,141.274C1188.571,176.458,1280,246.877,1371.429,284.697C1462.857,322.517,1554.286,327.739,1645.714,284.675C1737.143,241.611,1828.571,150.263,1874.286,104.589L1920,58.914L1920,720L1874.286,720C1828.571,720,1737.143,720,1645.714,720C1554.286,720,1462.857,720,1371.429,720C1280,720,1188.571,720,1097.143,720C1005.714,720,914.286,720,822.857,720C731.429,720,640,720,548.571,720C457.143,720,365.714,720,274.286,720C182.857,720,91.429,720,45.714,720L0,720Z\u0026#34;\u0026gt;\u0026lt;/path\u0026gt;\u0026lt;/g\u0026gt;\u0026lt;g transform=\u0026#34;translate(0, 720)\u0026#34;\u0026gt;\u0026lt;path fill=\u0026#34;rgb(26, 0, 143)\u0026#34; fill-opacity=\u0026#34;1\u0026#34; d=\u0026#34;M0,107.121L45.714,134.307C91.429,161.493,182.857,215.866,274.286,254.33C365.714,292.794,457.143,315.35,548.571,300.514C640,285.679,731.429,233.452,822.857,180.313C914.286,127.174,1005.714,73.123,1097.143,43.365C1188.571,13.606,1280,8.141,1371.429,41.079C1462.857,74.017,1554.286,145.358,1645.714,167.782C1737.143,190.206,1828.571,163.713,1874.286,150.467L1920,137.221L1920,360L1874.286,360C1828.571,360,1737.143,360,1645.714,360C1554.286,360,1462.857,360,1371.429,360C1280,360,1188.571,360,1097.143,360C1005.714,360,914.286,360,822.857,360C731.429,360,640,360,548.571,360C457.143,360,365.714,360,274.286,360C182.857,360,91.429,360,45.714,360L0,360Z\u0026#34;\u0026gt;\u0026lt;/path\u0026gt;\u0026lt;/g\u0026gt;\u0026lt;/g\u0026gt;\u0026lt;/svg\u0026gt; 之后打开https://codepen.io/yoksel/details/MWKeKK 网站,在 Insert your SVG中粘贴,得到\n最后把url(\u0026quot;\u0026quot;) 这块复制【没有分号】,即\nurl(\u0026#39;data:image/svg+xml,%3Csvg xmlns=\u0026#34;http://www.w3.org/2000/svg\u0026#34; viewBox=\u0026#34;0 0 1920 1080\u0026#34;%3E%3Cg transform=\u0026#34; rotate(0 960 540) translate(-0 -0) scale(1) \u0026#34;%3E%3Crect width=\u0026#34;1920\u0026#34; height=\u0026#34;1080\u0026#34; fill=\u0026#34;rgb(184, 171, 255)\u0026#34;%3E%3C/rect%3E%3Cg transform=\u0026#34;translate(0, 0)\u0026#34;%3E%3Cpath fill=\u0026#34;rgb(131, 114, 218)\u0026#34; fill-opacity=\u0026#34;1\u0026#34; d=\u0026#34;M0,352.943L45.714,350.075C91.429,347.207,182.857,341.471,274.286,340.581C365.714,339.692,457.143,343.65,548.571,344.095C640,344.54,731.429,341.472,822.857,303.183C914.286,264.894,1005.714,191.383,1097.143,185.175C1188.571,178.967,1280,240.06,1371.429,221.336C1462.857,202.612,1554.286,104.069,1645.714,98.48C1737.143,92.892,1828.571,180.258,1874.286,223.941L1920,267.624L1920,1080L1874.286,1080C1828.571,1080,1737.143,1080,1645.714,1080C1554.286,1080,1462.857,1080,1371.429,1080C1280,1080,1188.571,1080,1097.143,1080C1005.714,1080,914.286,1080,822.857,1080C731.429,1080,640,1080,548.571,1080C457.143,1080,365.714,1080,274.286,1080C182.857,1080,91.429,1080,45.714,1080L0,1080Z\u0026#34;%3E%3C/path%3E%3C/g%3E%3Cg transform=\u0026#34;translate(0, 360)\u0026#34;%3E%3Cpath fill=\u0026#34;rgb(79, 57, 180)\u0026#34; fill-opacity=\u0026#34;1\u0026#34; d=\u0026#34;M0,136.093L45.714,117.434C91.429,98.774,182.857,61.455,274.286,80.719C365.714,99.983,457.143,175.829,548.571,189.505C640,203.181,731.429,154.687,822.857,130.414C914.286,106.141,1005.714,106.09,1097.143,141.274C1188.571,176.458,1280,246.877,1371.429,284.697C1462.857,322.517,1554.286,327.739,1645.714,284.675C1737.143,241.611,1828.571,150.263,1874.286,104.589L1920,58.914L1920,720L1874.286,720C1828.571,720,1737.143,720,1645.714,720C1554.286,720,1462.857,720,1371.429,720C1280,720,1188.571,720,1097.143,720C1005.714,720,914.286,720,822.857,720C731.429,720,640,720,548.571,720C457.143,720,365.714,720,274.286,720C182.857,720,91.429,720,45.714,720L0,720Z\u0026#34;%3E%3C/path%3E%3C/g%3E%3Cg transform=\u0026#34;translate(0, 720)\u0026#34;%3E%3Cpath fill=\u0026#34;rgb(26, 0, 143)\u0026#34; fill-opacity=\u0026#34;1\u0026#34; d=\u0026#34;M0,107.121L45.714,134.307C91.429,161.493,182.857,215.866,274.286,254.33C365.714,292.794,457.143,315.35,548.571,300.514C640,285.679,731.429,233.452,822.857,180.313C914.286,127.174,1005.714,73.123,1097.143,43.365C1188.571,13.606,1280,8.141,1371.429,41.079C1462.857,74.017,1554.286,145.358,1645.714,167.782C1737.143,190.206,1828.571,163.713,1874.286,150.467L1920,137.221L1920,360L1874.286,360C1828.571,360,1737.143,360,1645.714,360C1554.286,360,1462.857,360,1371.429,360C1280,360,1188.571,360,1097.143,360C1005.714,360,914.286,360,822.857,360C731.429,360,640,360,548.571,360C457.143,360,365.714,360,274.286,360C182.857,360,91.429,360,45.714,360L0,360Z\u0026#34;%3E%3C/path%3E%3C/g%3E%3C/g%3E%3C/svg%3E\u0026#39;) 加上\ntop/cover no-repeat fixed 即\nurl(\u0026#39;data:image/svg+xml,%3Csvg xmlns=\u0026#34;http://www.w3.org/2000/svg\u0026#34; viewBox=\u0026#34;0 0 1920 1080\u0026#34;%3E%3Cg transform=\u0026#34; rotate(0 960 540) translate(-0 -0) scale(1) \u0026#34;%3E%3Crect width=\u0026#34;1920\u0026#34; height=\u0026#34;1080\u0026#34; fill=\u0026#34;rgb(184, 171, 255)\u0026#34;%3E%3C/rect%3E%3Cg transform=\u0026#34;translate(0, 0)\u0026#34;%3E%3Cpath fill=\u0026#34;rgb(131, 114, 218)\u0026#34; fill-opacity=\u0026#34;1\u0026#34; d=\u0026#34;M0,352.943L45.714,350.075C91.429,347.207,182.857,341.471,274.286,340.581C365.714,339.692,457.143,343.65,548.571,344.095C640,344.54,731.429,341.472,822.857,303.183C914.286,264.894,1005.714,191.383,1097.143,185.175C1188.571,178.967,1280,240.06,1371.429,221.336C1462.857,202.612,1554.286,104.069,1645.714,98.48C1737.143,92.892,1828.571,180.258,1874.286,223.941L1920,267.624L1920,1080L1874.286,1080C1828.571,1080,1737.143,1080,1645.714,1080C1554.286,1080,1462.857,1080,1371.429,1080C1280,1080,1188.571,1080,1097.143,1080C1005.714,1080,914.286,1080,822.857,1080C731.429,1080,640,1080,548.571,1080C457.143,1080,365.714,1080,274.286,1080C182.857,1080,91.429,1080,45.714,1080L0,1080Z\u0026#34;%3E%3C/path%3E%3C/g%3E%3Cg transform=\u0026#34;translate(0, 360)\u0026#34;%3E%3Cpath fill=\u0026#34;rgb(79, 57, 180)\u0026#34; fill-opacity=\u0026#34;1\u0026#34; d=\u0026#34;M0,136.093L45.714,117.434C91.429,98.774,182.857,61.455,274.286,80.719C365.714,99.983,457.143,175.829,548.571,189.505C640,203.181,731.429,154.687,822.857,130.414C914.286,106.141,1005.714,106.09,1097.143,141.274C1188.571,176.458,1280,246.877,1371.429,284.697C1462.857,322.517,1554.286,327.739,1645.714,284.675C1737.143,241.611,1828.571,150.263,1874.286,104.589L1920,58.914L1920,720L1874.286,720C1828.571,720,1737.143,720,1645.714,720C1554.286,720,1462.857,720,1371.429,720C1280,720,1188.571,720,1097.143,720C1005.714,720,914.286,720,822.857,720C731.429,720,640,720,548.571,720C457.143,720,365.714,720,274.286,720C182.857,720,91.429,720,45.714,720L0,720Z\u0026#34;%3E%3C/path%3E%3C/g%3E%3Cg transform=\u0026#34;translate(0, 720)\u0026#34;%3E%3Cpath fill=\u0026#34;rgb(26, 0, 143)\u0026#34; fill-opacity=\u0026#34;1\u0026#34; d=\u0026#34;M0,107.121L45.714,134.307C91.429,161.493,182.857,215.866,274.286,254.33C365.714,292.794,457.143,315.35,548.571,300.514C640,285.679,731.429,233.452,822.857,180.313C914.286,127.174,1005.714,73.123,1097.143,43.365C1188.571,13.606,1280,8.141,1371.429,41.079C1462.857,74.017,1554.286,145.358,1645.714,167.782C1737.143,190.206,1828.571,163.713,1874.286,150.467L1920,137.221L1920,360L1874.286,360C1828.571,360,1737.143,360,1645.714,360C1554.286,360,1462.857,360,1371.429,360C1280,360,1188.571,360,1097.143,360C1005.714,360,914.286,360,822.857,360C731.429,360,640,360,548.571,360C457.143,360,365.714,360,274.286,360C182.857,360,91.429,360,45.714,360L0,360Z\u0026#34;%3E%3C/path%3E%3C/g%3E%3C/g%3E%3C/svg%3E\u0026#39;) top/cover no-repeat fixed 之后粘贴即可\n通过对比发现:\n就是把下面的内容\nurl(\u0026#39;data:image/svg+xml,\u0026lt;svg xmlns=\u0026#34;http://www.w3.org/2000/svg\u0026#34; viewBox=\u0026#34;0 0 1920 1080\u0026#34;\u0026gt;\u0026lt;g transform=\u0026#34; rotate(0 960 540) translate(-0 -0) scale(1) \u0026#34;\u0026gt;\u0026lt;rect width=\u0026#34;1920\u0026#34; height=\u0026#34;1080\u0026#34; fill=\u0026#34;rgb(184, 171, 255)\u0026#34;\u0026gt;\u0026lt;/rect\u0026gt;\u0026lt;g transform=\u0026#34;translate(0, 0)\u0026#34;\u0026gt;\u0026lt;path fill=\u0026#34;rgb(131, 114, 218)\u0026#34; fill-opacity=\u0026#34;1\u0026#34; d=\u0026#34;M0,352.943L45.714,350.075C91.429,347.207,182.857,341.471,274.286,340.581C365.714,339.692,457.143,343.65,548.571,344.095C640,344.54,731.429,341.472,822.857,303.183C914.286,264.894,1005.714,191.383,1097.143,185.175C1188.571,178.967,1280,240.06,1371.429,221.336C1462.857,202.612,1554.286,104.069,1645.714,98.48C1737.143,92.892,1828.571,180.258,1874.286,223.941L1920,267.624L1920,1080L1874.286,1080C1828.571,1080,1737.143,1080,1645.714,1080C1554.286,1080,1462.857,1080,1371.429,1080C1280,1080,1188.571,1080,1097.143,1080C1005.714,1080,914.286,1080,822.857,1080C731.429,1080,640,1080,548.571,1080C457.143,1080,365.714,1080,274.286,1080C182.857,1080,91.429,1080,45.714,1080L0,1080Z\u0026#34;\u0026gt;\u0026lt;/path\u0026gt;\u0026lt;/g\u0026gt;\u0026lt;g transform=\u0026#34;translate(0, 360)\u0026#34;\u0026gt;\u0026lt;path fill=\u0026#34;rgb(79, 57, 180)\u0026#34; fill-opacity=\u0026#34;1\u0026#34; d=\u0026#34;M0,136.093L45.714,117.434C91.429,98.774,182.857,61.455,274.286,80.719C365.714,99.983,457.143,175.829,548.571,189.505C640,203.181,731.429,154.687,822.857,130.414C914.286,106.141,1005.714,106.09,1097.143,141.274C1188.571,176.458,1280,246.877,1371.429,284.697C1462.857,322.517,1554.286,327.739,1645.714,284.675C1737.143,241.611,1828.571,150.263,1874.286,104.589L1920,58.914L1920,720L1874.286,720C1828.571,720,1737.143,720,1645.714,720C1554.286,720,1462.857,720,1371.429,720C1280,720,1188.571,720,1097.143,720C1005.714,720,914.286,720,822.857,720C731.429,720,640,720,548.571,720C457.143,720,365.714,720,274.286,720C182.857,720,91.429,720,45.714,720L0,720Z\u0026#34;\u0026gt;\u0026lt;/path\u0026gt;\u0026lt;/g\u0026gt;\u0026lt;g transform=\u0026#34;translate(0, 720)\u0026#34;\u0026gt;\u0026lt;path fill=\u0026#34;rgb(26, 0, 143)\u0026#34; fill-opacity=\u0026#34;1\u0026#34; d=\u0026#34;M0,107.121L45.714,134.307C91.429,161.493,182.857,215.866,274.286,254.33C365.714,292.794,457.143,315.35,548.571,300.514C640,285.679,731.429,233.452,822.857,180.313C914.286,127.174,1005.714,73.123,1097.143,43.365C1188.571,13.606,1280,8.141,1371.429,41.079C1462.857,74.017,1554.286,145.358,1645.714,167.782C1737.143,190.206,1828.571,163.713,1874.286,150.467L1920,137.221L1920,360L1874.286,360C1828.571,360,1737.143,360,1645.714,360C1554.286,360,1462.857,360,1371.429,360C1280,360,1188.571,360,1097.143,360C1005.714,360,914.286,360,822.857,360C731.429,360,640,360,548.571,360C457.143,360,365.714,360,274.286,360C182.857,360,91.429,360,45.714,360L0,360Z\u0026#34;\u0026gt;\u0026lt;/path\u0026gt;\u0026lt;/g\u0026gt;\u0026lt;/g\u0026gt;\u0026lt;/svg\u0026gt;\u0026#39;) top/cover no-repeat fixed ,左尖括号 \u0026lt; 替换成 %3C,把 \u0026lt; 替换成 %3E (补充下,如果是其他图片可能不止这几个,但是这张图只替换了这几个。不太了解前端到底需要转哪些特殊字符,因为我发现有些空格 / \u0026quot; 也都没有转),即\nurl(\u0026#39;data:image/svg+xml,%3Csvg xmlns=\u0026#34;http://www.w3.org/2000/svg\u0026#34; viewBox=\u0026#34;0 0 1920 1080\u0026#34;%3E%3Cg transform=\u0026#34; rotate(0 960 540) translate(-0 -0) scale(1) \u0026#34;%3E%3Crect width=\u0026#34;1920\u0026#34; height=\u0026#34;1080\u0026#34; fill=\u0026#34;rgb(184, 171, 255)\u0026#34;%3E%3C/rect%3E%3Cg transform=\u0026#34;translate(0, 0)\u0026#34;%3E%3Cpath fill=\u0026#34;rgb(131, 114, 218)\u0026#34; fill-opacity=\u0026#34;1\u0026#34; d=\u0026#34;M0,352.943L45.714,350.075C91.429,347.207,182.857,341.471,274.286,340.581C365.714,339.692,457.143,343.65,548.571,344.095C640,344.54,731.429,341.472,822.857,303.183C914.286,264.894,1005.714,191.383,1097.143,185.175C1188.571,178.967,1280,240.06,1371.429,221.336C1462.857,202.612,1554.286,104.069,1645.714,98.48C1737.143,92.892,1828.571,180.258,1874.286,223.941L1920,267.624L1920,1080L1874.286,1080C1828.571,1080,1737.143,1080,1645.714,1080C1554.286,1080,1462.857,1080,1371.429,1080C1280,1080,1188.571,1080,1097.143,1080C1005.714,1080,914.286,1080,822.857,1080C731.429,1080,640,1080,548.571,1080C457.143,1080,365.714,1080,274.286,1080C182.857,1080,91.429,1080,45.714,1080L0,1080Z\u0026#34;%3E%3C/path%3E%3C/g%3E%3Cg transform=\u0026#34;translate(0, 360)\u0026#34;%3E%3Cpath fill=\u0026#34;rgb(79, 57, 180)\u0026#34; fill-opacity=\u0026#34;1\u0026#34; d=\u0026#34;M0,136.093L45.714,117.434C91.429,98.774,182.857,61.455,274.286,80.719C365.714,99.983,457.143,175.829,548.571,189.505C640,203.181,731.429,154.687,822.857,130.414C914.286,106.141,1005.714,106.09,1097.143,141.274C1188.571,176.458,1280,246.877,1371.429,284.697C1462.857,322.517,1554.286,327.739,1645.714,284.675C1737.143,241.611,1828.571,150.263,1874.286,104.589L1920,58.914L1920,720L1874.286,720C1828.571,720,1737.143,720,1645.714,720C1554.286,720,1462.857,720,1371.429,720C1280,720,1188.571,720,1097.143,720C1005.714,720,914.286,720,822.857,720C731.429,720,640,720,548.571,720C457.143,720,365.714,720,274.286,720C182.857,720,91.429,720,45.714,720L0,720Z\u0026#34;%3E%3C/path%3E%3C/g%3E%3Cg transform=\u0026#34;translate(0, 720)\u0026#34;%3E%3Cpath fill=\u0026#34;rgb(26, 0, 143)\u0026#34; fill-opacity=\u0026#34;1\u0026#34; d=\u0026#34;M0,107.121L45.714,134.307C91.429,161.493,182.857,215.866,274.286,254.33C365.714,292.794,457.143,315.35,548.571,300.514C640,285.679,731.429,233.452,822.857,180.313C914.286,127.174,1005.714,73.123,1097.143,43.365C1188.571,13.606,1280,8.141,1371.429,41.079C1462.857,74.017,1554.286,145.358,1645.714,167.782C1737.143,190.206,1828.571,163.713,1874.286,150.467L1920,137.221L1920,360L1874.286,360C1828.571,360,1737.143,360,1645.714,360C1554.286,360,1462.857,360,1371.429,360C1280,360,1188.571,360,1097.143,360C1005.714,360,914.286,360,822.857,360C731.429,360,640,360,548.571,360C457.143,360,365.714,360,274.286,360C182.857,360,91.429,360,45.714,360L0,360Z\u0026#34;%3E%3C/path%3E%3C/g%3E%3C/g%3E%3C/svg%3E\u0026#39;) top/cover no-repeat fixed PNG格式作为背景图片(不可行) # 试着将这张图片用base64编码\n这里之所以不放字符了,是因为我放了之后obsidian卡死了(30多万个字符),所以突然联想到一个问题,这就是为什么我把这个base64放到StyleSettings里面的时候,没效果而且卡死的原因把,哈哈,之前一直没注意\n试着把png先转为svg,之后再按照svg作为背景的方法做一遍。\n转换后的svg图片(不知道为什么图片方向变了,我第一次下载这张图【png】确实是这个方向,后面我调整正了,现在又歪了)\n有两百多万多个字符。。。照样没效果,不过这次没卡死了 用了个纯色png转svg,有效果了,但是图片变了一些。我觉得可能是转换的问题?不过可以确定的一点就是,png没效果的一个原因是字符太多\n最后尝试用一张纯色png,转base64,并用url(\u0026quot;\u0026quot;)的形式放入设置,照样没效果。不知道是不支持png,还是我的设置方法错了,这次只有9000个字符,但是卡顿了,tab界面花屏,如图\n文章中用到的(可能用到的)url如下\nhttps://codepen.io/yoksel/details/MWKeKK svg-\u0026gt;encode-\u0026gt;css https://stackoverflow.com/questions/41405884/svg-data-image-as-css-background 关于svg转base64并嵌入css的做法(可能可行,没试过,本文中采用稍微简单的办法) https://meyerweb.com/eric/tools/dencoder/ url encode(没用上,因为发现他把所有字符都转码了,实际上只转了 \u0026lt; 和 \u0026gt; https://www.base64-image.de/ 图片转base64\nhttps://tool.chinaz.com/tools/urlencode.aspx url编码解码\nhttps://www.asciim.cn/m/tools/convert_ascii_to_string.html ascaii与字符串的转换\nhttps://forever-z-133.github.io/demos/single/svg-to-base64.html svg转base64\nhttps://products.aspose.app/pdf/zh/conversion/png-to-svg png转svg\nhttps://github.com/Akifyss/obsidian-border/issues/251 obsidian-border主题,issue中作者的相关回复\n"},{"id":206,"href":"/zh/docs/technology/Obsidian/obsidian-theme/","title":"obsidian-theme","section":"Obsidian","content":" 主题推荐 # Neumorphism-dark.json\nSunset-base64.json ✔ Obsidian-default-dark-alt ✔ 4. Obsidian-default-light-alt Neumorphism.json eyefriendly ✔ boundy ✔ flexoki-light Borderless-light 关于obsidian主题border的背景图片设置 # 配合StyleSettings,在StyleSettings的这里设置\n暂不明确 # background中貌似存在转换规则,不是直接用url(\u0026quot;\u0026quot;)这个形式把图片base64放进来就可以了,目前觉得可能的转换规则\n%3c 48+12=60 \u0026lt; %3e 48+14=62 \u0026gt; %23 32+3=35 # #下面的好像没用到,也不确定 %2b 32+11=43 + %3b ; %2c , 后续见另一篇文章\nborder-theme {% post_link \u0026lsquo;study/obsidian/border-theme\u0026rsquo; \u0026lsquo;helo\u0026rsquo; %}\n"},{"id":207,"href":"/zh/docs/technology/Obsidian/plugin/","title":"plugin","section":"Obsidian","content":" obsidian-custom-attachment-location v.28.1文件批量重命名有效,再往上都是无效的\n"},{"id":208,"href":"/zh/docs/technology/Redis/rsync-use/","title":"rsync使用","section":"Redis","content":"其实就是linux的cp功能(带增量复制) #推送到rsync-k40 rsync -avz \u0026ndash;progress \u0026ndash;delete source-rsync -e \u0026lsquo;ssh -p 8022\u0026rsquo; ly@192.168.1.101:/storage/emulated/0/000Ly/git/blog.source/source/attachments/rsync-k40 #推送到rsync-tabs8 rsync -avz \u0026ndash;progress \u0026ndash;delete source-rsync -e \u0026lsquo;ssh -p 8022\u0026rsquo; ly@192.168.1.106:/storage/emulated/0/000Ly/git/blog.source/source/attachments/rsync-tabs8 #推送到rsync-pc rsync -avz \u0026ndash;progress \u0026ndash;delete source-rsync -e \u0026lsquo;ssh -p 22\u0026rsquo; ly@192.168.1.206:/mnt/hgfs/gitRepo/blog.source/source/attachments/rsync-pc\n#从手机上拉取 rsync -avz \u0026ndash;progress \u0026ndash;delete -e \u0026lsquo;ssh -p 8022\u0026rsquo; ly@192.168.1.101:/storage/emulated/0/000Ly/git/blog.source/source/attachments/rsync-k40 source-rsync\n"},{"id":209,"href":"/zh/docs/life/20240626/","title":"知命不惧 日日自新","section":"生活","content":"视频分享\n"},{"id":210,"href":"/zh/docs/problem/Other/01/","title":"如何搜索","section":"Other","content":" 原则 # 搜索的时候,要简约,且尽量把关键词分散,不要有\u0026quot;的\u0026quot;,\u0026ldquo;地\u0026rdquo;,或者其他动词什么的,尽量是名词。 关键词之间用空格隔开 比如想要看一部电影,那么关键词有\u0026quot;电影名\u0026quot;,\u0026ldquo;bt\u0026rdquo;,\u0026ldquo;迅雷\u0026rdquo;,\u0026ldquo;阿里云盘\u0026rdquo;,\u0026ldquo;百度网盘\u0026rdquo;\n解释一下,这里来源(迅雷|阿里云盘|百度网盘),资源类型(bt)也是关键词\n所以搜索就是**\u0026ldquo;孤注一掷 bt\u0026rdquo;,\u0026ldquo;孤注一掷 阿里云盘\u0026rdquo;,\u0026ldquo;孤注一掷 百度网盘\u0026rdquo;,\u0026ldquo;孤注一掷 迅雷\u0026rdquo;,注意,中间都有空格**\n例子 # bt种子形式 # 点进来之后,滑到最下面(一般链接都是以浅蓝色标识,点过一次后就变成暗红色)\nbt文件一般要用\u0026quot;迅雷\u0026quot;这个软件下载,上面随便点击一个,迅雷这个软件就会跳出来\n然后选择好目录点击确认就可以下载了\n阿里云盘形式 # 第一个链接有人提出质疑了,我们点下面那个,这是进入之后的画面:\n再点击\u0026quot;阿里xxxxxxxxxx\u0026quot;这个链接,进入阿里云盘:\n点进来看视频文件还在不在,在的话,保存就可以了\n之后到自己的阿里云盘下载就行了\n百度云盘形式 # 百度云盘被限速了,不得已的情况下,不要用百度云盘,基本上前面两种形式的资源没找到的话,百度云盘大概率也不会有\n"},{"id":211,"href":"/zh/docs/life/20231227/","title":"起床临感","section":"生活","content":"所谓贵人,并不是封建迷信,而是指对你成长有帮助的人,不单单是直观的好。\n在你蒸蒸日上的时候打压你,让你有所收敛;在你颓废堕落的时候鼓励你,使你积极向上。他们都是贵人,一阴一阳之谓道,如是而已。\n"},{"id":212,"href":"/zh/docs/life/20231101/","title":"20231101","section":"生活","content":" 附(20231102)\n融入我中华文化的,才是自己人。想消灭我中华文化的,即使占有了这片土地,也不能称之“功臣”。\n当一个民族的文化被摧毁的时候,那个民族就是彻底灭亡了。 不过,我觉得,只要是在中国这片土地上(地理),无论谁来,都会产生这样的文化,无例外。(地理决定论) "},{"id":213,"href":"/zh/docs/life/archive/20231026/","title":"成就","section":"往日归档","content":" 任何事情的成功,都没有什么可骄傲的,不过是一物降一物,无他尔。 人生最大的问题,是不想,而不是不能。 "},{"id":214,"href":"/zh/docs/life/archive/20231013/","title":"沉没","section":"往日归档","content":" 努力不一定有用,但是虚度光阴难道就是对的吗? 即使你一时找不到正确的路,但是你应该能一眼看出哪些是错的,及时避开。 "},{"id":215,"href":"/zh/docs/problem/Linux/20230919/","title":"Linux操作符问题","section":"Linux","content":" 函数退出 # 函数退出状态:0(成功),非零(非正常,失败)\n引号 # 双引号中使用转义字符可以防止展开\n这意味着单词分割(空格制表换行分割单词)、路径名展开(*星号)、波浪线展开和花括号展开都将失效,然而参数展开、 算术展开和命令替换仍然执行\necho \u0026#34;text ~/*.txt {a,b} $(echo foo) $((2+2)) $USER\u0026#34; #禁止部分 text ~/*.txt {a,b} foo 4 me echo \u0026#39;text ~/*.txt {a,b} $(echo foo) $((2+2)) $USER\u0026#39; #全部禁止 text ~/*.txt {a,b} $(echo foo) $((2+2)) $USER 各种操作符 # [ expression ] / test [[ expression ]] $(( expression )) $var $( termi ) 文件表达式 -e file,字符串表达式 -n string,整数表达式 integer1 -eq integer2 test增强,增加 [ str =~ regex ],增加 == [[ $FILE == foo.* ]] 整数加减乘除取余 取变量 执行命令/函数 termi取变量$必加,里面被看作命令参数,\u0026lt; \u0026gt; ( ) 必须转义 否则 小于号 \u0026lt; 大于号\u0026gt;被认为重定向 与[ ] 一致 取变量$可加可不加 termi取变量$必加 if [ -x \u0026#34;$FILE\u0026#34; ] #引号可以防止空参数,空值。而\u0026#34;\u0026#34;被解释成空字符串 "},{"id":216,"href":"/zh/docs/life/archive/20230913/","title":"鲇鱼后思","section":"往日归档","content":" 鲇鱼事件其实出来很久了,一直没有太大关注,这几天突发兴致(某乎评论提到),就去了解了下。可能我对“官”这种东西,从小到大就定了性,所以如果查出个大清官,省吃俭用破衣烂衫,倒可算得上新闻。 对于其言论,确实听了未尝不免义愤填膺。于是我就花了大半个小时义愤填膺\u0026hellip; 一代人只能做一代人的事\u0026mdash;《走向共和》,官如此,民亦如此。如果作为普通老百姓,不能够跻身仕途,那就是先老老实实做好自己的本分\u0026ndash;照顾父母,照顾自己,照顾妻子,照顾儿女。总有人会替天行道,如果不是你,那就做好自己,教育好自己的子女,足以。不要三心二意,事物发展有其必然规律,有盛必有衰,自古皆如此。穷则独善其身,达则兼济天下。 没有必要把自己带入高高在上的角色。也许自己到了那个地位,贪得更凶。真小人好过伪君子,伪君子往往会迷失自己,既做不了君子,又成不了小人。 "},{"id":217,"href":"/zh/docs/life/archive/20230912/","title":"病愈 有感","section":"往日归档","content":" 一个人只有真正意识到事情的发展,是自己的错误导致,才会真正改过自新。否则就会怨天尤人,甚至掩耳盗铃。 世界万事万物,有优有劣。劣并不代表罪恶,不过是事物发展的某个过程,如同生病的头疼脑热,“现象”,不过是提醒世人罢了。切勿以过程盖棺定论,自暴自弃,及时止损即可。 "},{"id":218,"href":"/zh/docs/problem/Linux/20230819/","title":"Debian问题处理3","section":"Linux","content":" fcitx配合各种软件出现的问题 # 本文章中出现的引号都是英文状态下的引号,切记!\n安装完毕后环境变量设置 # /etc/profile 和/etc/enviroment 均可,profile针对用户,environment针对系统。一般都是放profile里面\n不行的话 # 如果修改profile无效,则在/etc/enviroment添加修改\n#/etc/enviroment 末尾添加 fcitx \u0026amp; #这行要添加 export XIM_PROGRAM=fcitx export XIM=fcitx export GTK_IM_MODULE=fcitx export QT_IM_MODULE=fcitx export XMODIFIERS=\u0026#34;@im=fcitx\u0026#34; export LANG=zh_CN.UTF-8 source后再重启一下哦\n装了zsh后(从终端打开)idea等各种软件不出现fcitx输入法的问题 # 在/.zshrc最后添加\nexport XIM_PROGRAM=fcitx export XIM=fcitx export GTK_IM_MODULE=fcitx export QT_IM_MODULE=fcitx export XMODIFIERS=\u0026#34;@im=fcitx\u0026#34; export LANG=zh_CN.UTF-8 export LC_MESSAGES=en_US.UTF-8 #让终端报错时,显示英文 而不是中文 也可以不在/.zshrc中追加这些,而是直接追加 source /etc/profile或者/etc/enviroment即可\n如果还有问题,就要在idea的配置文件idea.vmoptions添加\n-Drecreate.x11.input.method=true 如果使用系统默认终端的情况下出的问题 # 可以在 ~/.bashrc最后添加这段话,重启试试\nexport XIM_PROGRAM=fcitx export XIM=fcitx export GTK_IM_MODULE=fcitx export QT_IM_MODULE=fcitx export XMODIFIERS=\u0026#34;@im=fcitx\u0026#34; export LANG=zh_CN.UTF-8 各个文件的解释 # /etc/profile //用户级,所有用户登陆时才会执行 对于fcitx没效果(firefox无效)\n/etc/enviroment //系统级,一般不修改 这里有效果\n~/.bashrc //系统默认终端打开时执行 ~/.zshrc //zsh使用前执行\nsource命令是一个内置的shell命令,用于从当前shell会话中的文件读取和执行命令。source命令通常用于保留、更改当前shell中的环境变量。简而言之,source一个脚本,将会在当前shell中运行execute命令。 source命令可用于:\n刷新当前的shell环境 在当前环境使用source执行Shell脚本 从脚本中导入环境中一个Shell函数 从另一个Shell脚本中读取变量\nzsh卸载后账号无法登录 # 参考https://lwmfjc.github.io/2023/05/23/problem/linux/20230523/ 这篇文章\n如果不是root用户就简单多了,直接\nvim /etc/passwd # xx(账户名)......zsh,中/bin/zsh,改为/bin/bash 即可 xfce4的安装及gnome卸载 # gnome完全卸载\naptitude purge `dpkg --get-selections | grep gnome | cut -f 1` aptitude -f install aptitude purge `dpkg --get-selections | grep deinstall | cut -f 1` aptitude -f install xfce4安装\nsudo apt install task-xfce-desktop 蓝牙问题 # 最后是装了blueman 连接蓝牙耳机出现这个问题,为了连接装了这个。之后想用扬声器发现用不了,拔了耳机可以了却发现破音了\u0026hellip;.\n最后解决方案是把这两个删了,而且此时蓝牙耳机也可以连上了\u0026hellip;原因不明\n备份 # 如果是vm下学习linux,要多利用vmware,养成习惯,每进行一次大操作之前,都要进行vmware的快照备份。避免大操作导致出问题\n"},{"id":219,"href":"/zh/docs/problem/Linux/20230817/","title":"Debian问题处理2","section":"Linux","content":" 代理 # Vmware里面的debian,连接外面物理机的v2ray。\n对于浏览器 # 无论是firefox还是chromium,都可以直接通过v2ray允许局域网,然后使用ProxySwitchOmege代理访问\n对于命令 # 可以使用proxychains,直接用apt-get 安装即可,注意事项\n作用范围 # 对tcp生效,ping是不生效的,不要白费力气\n需要修改两个地方 # libproxychains.so.3 提示不存在 ly\nwhereis libproxychains.so.3 #libproxychains.so.3: /usr/lib/x86_64-linux-gnu/libproxychains.so.3 #修改/usr/bin/proxychains #export LD_PRELOAD = libproxychains.so.3 修改为: export LD_PRELOAD = /usr/lib/x86_64-linux-gnu/libproxychains.so.3 l\u0026rsquo;y配置修改\n#修改文件/etc/proxychains.conf,在最后一行添加 socks5 192.168.1.201 1082 使用\nproxychains git pull #直接在命令最前面输入proxychains即可 直接网络(gui)配置代理 # 这个对于终端不生效\nzsh安装 # proxychains wget https://raw.githubusercontent.com/ohmyzsh/ohmyzsh/master/tools/install.sh proxychains sh install.sh zsh主题安装 # proxychains git clone --depth=1 https://github.com/romkatv/powerlevel10k.git ${ZSH_CUSTOM:-$HOME/.oh-my-zsh/custom}/themes/powerlevel10k #修改 vim ~/.zshrc ZSH_THEME=\u0026#34;powerlevel10k/powerlevel10k\u0026#34; 重新配置 p10k configure\n程序环境设置 # 环境变量\n#java环境变量 export JAVA_HOME=/usr/local/jdk1.8.0_281 export CLASSPATH=$:CLASSPATH:$JAVA_HOME/lib/ export PATH=$PATH:$JAVA_HOME/bin #maven环境变量 export MAVEN_HOME=/usr/local/apache-maven-3.3.9 export PATH=$PATH:$MAVEN_HOME/bin typora破解 # 声明:破解可耻,尊重正版。这里仅以学习为目的\n来自文章 https://ccalt.cn/2023/04/07/%5BWindows%7CLinux%5DTypora%E6%9C%80%E6%96%B0%E7%89%88%E9%80%9A%E7%94%A8%E7%A0%B4%E8%A7%A3-%E8%87%B3%E4%BB%8A%E5%8F%AF%E7%94%A8/\n程序 # https://github.com/DiamondHunters/NodeInject_Hook_example/actions/runs/4180836116\n我自己fork了一份,不知道哪天就没了\nhttps://github.com/lwmfjc/NodeInject_Hook_example/actions/runs/5888943386\n步骤 # 将linux版本的文件,按下面的结构解压放入Typora文件夹中\n这里盗(借)用文章图片说明,不想截图了\n之后先运行node_inject,后运行license-gen ,即可得到序列号\n字体 # 很多程序都偏小,系统字体基本正常。\nfirefox中,要设置最小字体。 Typor没找到。\npicgo问题 # 没有什么特别注意的,基本问题搜索引擎都有。对了,把windows下的picgo卸载了,只留下了picgo-core,还安装了 super-prefix,自定义上传路径及文件名\n参考文章 https://connor-sun.github.io/posts/38835.html\n#自定义文件夹及文件名 picgo install super-prefix #super-prefix地址 https://github.com/gclove/picgo-plugin-super-prefix picgo-core 配置手册 https://picgo.github.io/PicGo-Core-Doc/zh/guide/config.html\n#插件配置 ~/.picgo/config.json ,在根结构里面添加 \u0026#34;picgoPlugins\u0026#34;: { \u0026#34;picgo-plugin-super-prefix\u0026#34;: true }, \u0026#34;picgo-plugin-super-prefix\u0026#34;: { \u0026#34;prefixFormat\u0026#34;: \u0026#34;YYYY/MM/DD/\u0026#34;, \u0026#34;fileFormat\u0026#34;: \u0026#34;YYYYMMDD-HHmmss\u0026#34; } Typora中文件上传对picgo-core的设置 # 自定义命令格式:picgo upload,windows中可以使用绝对路径(加双引号)\n截图工具 # apt install flameshot 使用flameshot gui 启动\n"},{"id":220,"href":"/zh/docs/problem/Linux/20230815/","title":"Debian问题处理1","section":"Linux","content":" 清华源设置 # vim /etc/apt/sources.list #注释掉原来的,并添加 # 默认注释了源码镜像以提高 apt update 速度,如有需要可自行取消注释 deb https://mirrors.tuna.tsinghua.edu.cn/debian/ bookworm main contrib non-free non-free-firmware # deb-src https://mirrors.tuna.tsinghua.edu.cn/debian/ bookworm main contrib non-free non-free-firmware deb https://mirrors.tuna.tsinghua.edu.cn/debian/ bookworm-updates main contrib non-free non-free-firmware # deb-src https://mirrors.tuna.tsinghua.edu.cn/debian/ bookworm-updates main contrib non-free non-free-firmware deb https://mirrors.tuna.tsinghua.edu.cn/debian/ bookworm-backports main contrib non-free non-free-firmware # deb-src https://mirrors.tuna.tsinghua.edu.cn/debian/ bookworm-backports main contrib non-free non-free-firmware deb https://mirrors.tuna.tsinghua.edu.cn/debian-security bookworm-security main contrib non-free non-free-firmware # deb-src https://mirrors.tuna.tsinghua.edu.cn/debian-security bookworm-security main contrib non-free non-free-firmware deb https://security.debian.org/debian-security bookworm-security main contrib non-free non-free-firmware # deb-src https://security.debian.org/debian-security bookworm-security main contrib non-free non-free-firmware 中文环境 # su sudo apt-get install locales #配置中文环境 1.选择zh开头的 2 后面选择en(cn也行,不影响输入法) sudo dpkg-reconfigure locales #设置上海时区 sudo timedatectl set-timezone Asia/Shanghai 中文输入法 # #清除旧的环境 apt-get remove ibus #不兼容问题 apt-get remove fcitx5 fcitx5-chinese-addons apt-get autoremove ly # gnome-shell-extension-kimpanel sudo apt install fcitx5 fcitx5-chinese-addons fcitx5-frontend-gtk4 fcitx5-frontend-gtk3 fcitx5-frontend-gtk2 fcitx5-frontend-qt5 im-config #配置使用fcitx5 #环境变量添加 export XMODIFIERS=@im=fcitx export GTK_IM_MODULE=fcitx export QT_IM_MODULE=fcitx #退出root用户权限,使用普通用户权限再终端 fcitx5-configtool #配置中文输入法即可 #附加组件-经典用户界面--这里可以修改字体及大小 其他 # 应用程序-优化 修改默认字体大小\n桌面任务栏\u0026ndash; https://extensions.gnome.org/extension/1160/dash-to-panel\n参考文章 https://itsfoss.com/gnome-shell-extensions/\n之后设置一下任务栏的位置即可\n参考文章 # https://zhuanlan.zhihu.com/p/508797663\n"},{"id":221,"href":"/zh/docs/problem/Linux/20230803/","title":"安卓手机及平板安装linuxDeploy的问题简记","section":"Linux","content":" 为什么是简记呢,因为这几天折腾这些太累了,等以后回过头来重新操作再详细记载\n前言 # 初衷 # 一开始的初衷是为了在平板上使用idea,之前看了一篇docker使用idea的文章,心血来潮。所以想直接在平板的termux安装docker然后使用,结果一堆问题。后面妥协了,在手机上装,然后开远程吧\n这年头机在人在,所以装手机还是平板,还真没有很大的问题。后面使用情况证明:手机不需要开热点的情况(开热点是为了保证网络联通,在同一局域网),其实不怎么发热也不怎么耗电的。\n平板上 # 本来想在tab s8平板上通过termux安装linux(无root权限),但是总会遇到一堆问题\u0026ndash;连系统都装不上。因为root会有两个问题,所以一开始没有考虑使用linuxDeploy(需要root)\n保修失效 无法通过系统直接更新(需要线刷) 手机上(root) # 配置 # 后面尝试在root过的手机上安装linuxDeploy,照样有一堆问题,这里配上能使用的配置(能进系统):\n我用的时候ssh端口改了一下,不过不影响,第一次用的22端口也是能连上的。初始用户写的root,这里也是设置的root。\n最好挂载一下\n问题 # 用其他桌面环境,可能会导致图标没有(应该是就没有那个应用,比如浏览器),不过我这个配置完也没有浏览器,不过好在图标也没,不用自己再去移除了。\n装完之后vns有出错过一次,突然就蹦了,死活连不上。后面我直接重装系统了(linux deploy),没有再出现问题。装完之后需要在etc/rc.local添加:\n#删除vns临时文件,保证每次启动都是使用端口:5901 #(linux上显示:1,连接使用时要+5900,即使用5901端口) rm -rf /tmp/.X[1-9]-lock rm -rf /tmp/.X11-unix/X[1-9] #保证系统每次启动后都自动启动vncserver vncserver 电脑上随便找了个VNCServer 绿色免安装程序可以连上\n平板上使用AVNC,电脑不方便截图,就不截了.. 类似长这样\n#常用命令(也不常,这两天用的最多的) vncserver -kill :1 #强制关闭端口1 vncserver #启动 安装idea,也不用安装,就是去官网下载解压即可。问题:需要jdk11以上才能打开(疑惑,貌似之前在windows安装的时候没这要求,反正后面我妥协了,装了11,之后就是配置环境变量什么的)\n一开始linuxDeploy的Ubuntu,然后..发现openjdk11装完之后,java -version显示的10,一脸蒙圈,搞得后面又重装了Debian(中途还试了centos)\n装完没有中文输入法,系统装完就是要用的,如果随便打打命令倒是不需要中文输入法,但是如果打点代码写点注解,那蹩脚英语就\u0026hellip;总不能句句good good study,day day up..真是one day day de\u0026hellip;\n问题处理 # 其实解决方案前面好像都说了,输入法单独开一块吧,比较恶心,主要是让我意识到了自己水平有多菜\u0026hellip;\n某些机器(平板)省电模式下,默认的那个用户会断网,原因不明 # 所以那个用户就别用了,再创建一个新的\nuseradd -d /home/ly -s /bin/bash -m ly 然后设置下密码\npasswd ly 配置中文环境 # sudo dpkg-reconfigure locales #前面选英文和中文,后面选英文 #设置时区 sudo timedatectl set-timezone Asia/Shanghai 中文字体安装 # apt-get install ttf-wqy-zenhei apt-get install xfonts-intl-chinese wqy* 输入法相关安装 # #fcitx安装 apt install fcitx -y #输入法安装 apt install fcitx-googlepinyin fcitx-sunpinyin fcitx-pinyin #中文字体包,简体繁体 apt install fonts-arphic-bsmi00lp fonts-arphic-gbsn00lp fonts-arphic-gkai00mp apt install fcitx-table* 输入法bug # 网上一堆教程,找了很多,最后的解决方案 fcitx+googlepinyin\n没用fcitx5和sougou输入法,因为尝试了很多次实在装不上,不知道是arm64的架构问题还是什么,装完老是输入法状态栏闪啊闪\u0026hellip;bug?玄学?\n这个网上都有教程(抄袭),就不写(抄)了。写下重要的问题\n输入法在terminal终端、idea中不能切换出来、切换后打字不能上去 # tabs8没有这个bug,手机的miui系统有这个bug,不知道为啥\n这个问题其实在wiki里面有说到,不过我是google之后才定位到这里的\nhttps://wiki.archlinux.org/title/fcitx\n需要修改 ~/.xinitrc文件\nfcitx \u0026amp; #add export GTK_IM_MODULE=fcitx #add export QT_IM_MODULE=fcitx #add export XMODIFIERS=@im=fcitx #add XAUTHORITY=$HOME/.Xauthority export XAUTHORITY LANG_MESSAGE=zh_CN.UTF-8 #add LANG=en_US.UTF-8 #add export LANG #add export LANG_MESSAGE #add echo $$ \u0026gt; /tmp/xsession.pid . $HOME/.xsession 其他的不要改,安装系统原来怎么样就怎么样就行\u0026hellip;\n在这之前什么中文字体、还有区域设置都要先搞定,前面的设置选择en_US.UTF-8+zh_CN所有(有四五个),后面的设置选择系统默认语言(中英文都可,我选的英文,方便我这样的菜鸟报错时google查解决方案,只能选一个)\n突然想起来souhupinyin闪啊闪可能跟这里设置了fcitx有关系?以后有空再研究\n还有这个地方要改\nvim /etc/locale.conf #LANG=zh_CN.UTF-8 #LC_MESSAGES=en_US.UTF-8 LANG=en_US.UTF-8 LC_MESSAGES=zh_CN.UTF-8 之前我还改了idea.*vmoptions的配置,不过可能跟这个没有太大关系\nidea中输入法位置会出现在左下角 # 看了fcitx官方的issue,说是不关它的事\u0026hellip;.\n博客 # Obsidian # 到官方github下载即可\nhttps://github.com/obsidianmd/obsidian-releases/releases\n下载其中一个就行,不过都要以 \u0026ndash;no-sandbox方式才能运行\n这期间会有很多问题,需要安装下列软件\napt-get install zlib1g-dev apt-get install fuse libfuse2 apt install libnss3 ./obsidian --no-sandbox #以这种方式运行 Typora # linux 的arm64只存在于1.0以上版本,众所周知\u0026hellip;\n以下链接,仅供学习\nhttps://www.cnblogs.com/youngyajun/p/16661980.html \u0026ndash;1.0.3有效,不过图片上传有点问题,直接拖曳进去是能上传的,复制后粘贴不行(1.6可以,不过未授权,不太好弄)\npython安装过程也挺曲折,不要装python2,直接装3。pip也是直接装版本3即可。记得使用清华源/或者阿里源,要不得下载半天\npicgo # 这里配合的是picgo-core,用官方教程安装即可\n直接picgo不行,没有找到对应平台的安装包\nPicHoro # 最后放弃在linux上写博客的想法了,勉强能用,不过实在是勉强。\n后面采用直接在平板安装obsidian+pichoro的办法\nhttps://github.com/Kuingsmile/PicHoro\n还行。不过2.1.2报毒,不知道为啥,下完被自动删除了。使用的是2.1.1\n浏览器 # chromium # 直接apt安装就好了,装完之后ui那个图标是打不开的,同样的问题,在terminal终端那里,使用命令chromium --no-sandbox即可打开。有一些问题,比如没声音啦、一些pdf插件(博客的)显示不出来之类。目前没需求,暂不处理了\nps: 还有q的问题,目前也没需求。暂不处理\n参考链接 # https://cloud.tencent.com/developer/article/1159972\nhttps://blog.csdn.net/qysh123/article/details/117288055\nhttps://blog.csdn.net/weixin_42122432/article/details/116703457\nhttps://www.cnblogs.com/jtianlin/p/4230527.html\nhttps://blog.csdn.net/sinat_42483341/article/details/104104441\nhttps://blog.csdn.net/ma726518972/article/details/121034994?ydreferer=aHR0cHM6Ly93d3cuZ29vZ2xlLmNvbS8%3D\nhttps://www.cnblogs.com/shanhubei/p/17517381.html\nhttps://blog.csdn.net/sandonz/article/details/106877555\nhttps://archlinuxarm.org/packages/aarch64/vim\nhttps://bbs.archlinuxcn.org/viewtopic.php?id=12685\nhttps://soft.zol.com.cn/126/1262460.html\nhttps://bbs.archlinuxcn.org/viewtopic.php?id=10498\nhttps://wiki.archlinux.org/title/fcitx\nhttps://www.reddit.com/r/archlinux/comments/rl1ncw/fcitx_input_in_terminal_window_doesnt_work/\nhttps://stackoverflow.com/questions/20705089/why-can-not-i-use-fcitx-input-method-in-gnome-terminal\nhttps://blog.csdn.net/u011166277/article/details/106287587/\nhttps://www.reddit.com/r/swaywm/comments/t09udp/anyone_using_the_input_method_fcitx5rime_cant_get/\nhttps://github.com/fcitx/fcitx5/issues/79\n其实不止这些,不过有用的可能就这些\n成果 # 疑惑 # 其实科学这种东西,在你不精通的情况下,也会出现玄而又玄的事。那传统意义上所谓的玄学,是否是因为自己不精通/失传导致的呢\u0026hellip;人类是退化还是进化..不过有一点是肯定的,所有的事情都是人为的 \u0026ndash; 自作自受。\n用window系统打完了这篇博客真的爽,没有各种莫名其妙的问题。果然,跌到谷底的时候,怎么走都是向上。\n"},{"id":222,"href":"/zh/docs/problem/JVM/20230526/","title":"JDK代理和CGLIB代理","section":"Jvm","content":" 完全转载自https://juejin.cn/post/7011357346018361375 ,以防丢失故作备份 。\n一、什么是代理模式 # 代理模式(Proxy Pattern)给某一个对象提供一个代理,并由代理对象控制原对象的引用。代理对象在客户端和目标对象之间起到中介作用。\n代理模式是常用的结构型设计模式之一,当直接访问某些对象存在问题时可以通过一个代理对象来间接访问,为了保证客户端使用的透明性,所访问的真实对象与代理对象需要实现相同的接口。代理模式属于结构型设计模式,属于GOF23种设计模式之一。\n代理模式可以分为静态代理和动态代理两种类型,而动态代理中又分为JDK动态代理和CGLIB代理两种。 代理模式包含如下角色:\nSubject (抽象主题角色) 抽象主题角色声明了真实主题和代理主题的共同接口,这样一来在任何使用真实主题 的地方都可以使用代理主题。客户端需要针对抽象主题角色进行编程。 Proxy (代理主题角色) 代理主题角色内部包含对真实主题的引用,从而可以在任何时候操作真实主题对象。 在代理主题角色中提供一个与真实主题角色相同的接口,以便在任何时候都可以替代真实 主体。代理主题角色还可以控制对真实主题的使用,负责在需要的时候创建和删除真实主 题对象,并对真实主题对象的使用加以约束。代理角色通常在客户端调用所引用的真实主 题操作之前或之后还需要执行其他操作,而不仅仅是单纯的调用真实主题对象中的操作。 RealSubject (真实主题 角色) 真实主题角色定义了代理角色所代表的真实对象,在真实主题角色中实现了真实的业 务操作,客户端可以通过代理主题角色间接调用真实主题角色中定义的方法。 代理模式的优点 # 代理模式能将代理对象与真实被调用的目标对象分离。 一定程度上降低了系统的耦合度,扩展性好。 可以起到保护目标对象的作用。 可以对目标对象的功能增强。 代理模式的缺点 # 代理模式会造成系统设计中类的数量增加。 在客户端和目标对象增加一个代理对象,会造成请求处理速度变慢。 二、JDK动态代理 # 在java的动态代理机制中,有两个重要的类或接口,一个是 InvocationHandler(Interface)、另一个则是 Proxy(Class),这一个类和接口是实现我们动态代理所必须用到的。\nInvocationHandler # 每一个动态代理类都必须要实现InvocationHandler这个接口,并且每个代理类的实例都关联了一个handler,当我们通过代理对象调用一个方法的时候,这个方法的调用就会被转发为由InvocationHandler这个接口的 invoke 方法来进行调用。\nInvocationHandler这个接口的唯一一个方法 invoke 方法:\njava 复制代码Object invoke(Object proxy, Method method, Object[] args) throws Throwable 这个方法一共接受三个参数,那么这三个参数分别代表如下:\nproxy:指代JDK动态生成的最终代理对象 method:指代的是我们所要调用真实对象的某个方法的Method对象 args:指代的是调用真实对象某个方法时接受的参数 Proxy # Proxy这个类的作用就是用来动态创建一个代理对象的类,它提供了许多的方法,但是我们用的最多的就是newProxyInstance 这个方法:\njava 复制代码public static Object newProxyInstance(ClassLoader loader, Class\u0026lt;?\u0026gt;[] interfaces, InvocationHandler handler) throws IllegalArgumentException 这个方法的作用就是得到一个动态的代理对象,其接收三个参数,我们来看看这三个参数所代表的含义:\nloader:ClassLoader对象,定义了由哪个ClassLoader来对生成的代理对象进行加载,即代理类的类加载器。 interfaces:Interface对象的数组,表示的是我将要给我需要代理的对象提供一组什么接口,如果我提供了一组接口给它,那么这个代理对象就宣称实现了该接口(多态),这样我就能调用这组接口中的方法了。 Handler:InvocationHandler对象,表示的是当我这个动态代理对象在调用方法的时候,会关联到哪一个InvocationHandler对象上。 所以我们所说的DynamicProxy(动态代理类)是这样一种class:它是在运行时生成的class,在生成它时你必须提供一组interface给它,然后该class就宣称它实现了这些 interface。这个DynamicProxy其实就是一个Proxy,它不会做实质性的工作,在生成它的实例时你必须提供一个handler,由它接管实际的工作。\nJDK动态代理实例 # 创建接口类\njava复制代码public interface HelloInterface { void sayHello(); } 创建被代理类,实现接口\njava复制代码/** * 被代理类 */ public class HelloImpl implements HelloInterface{ @Override public void sayHello() { System.out.println(\u0026#34;hello\u0026#34;); } } 创建InvocationHandler实现类\njava复制代码/** * 每次生成动态代理类对象时都需要指定一个实现了InvocationHandler接口的调用处理器对象 */ public class ProxyHandler implements InvocationHandler{ private Object subject; // 这个就是我们要代理的真实对象,也就是真正执行业务逻辑的类 public ProxyHandler(Object subject) {// 通过构造方法传入这个被代理对象 this.subject = subject; } /** *当代理对象调用真实对象的方法时,其会自动的跳转到代理对象关联的handler对象的invoke方法来进行调用 */ @Override public Object invoke(Object obj, Method method, Object[] objs) throws Throwable { Object result = null; System.out.println(\u0026#34;可以在调用实际方法前做一些事情\u0026#34;); System.out.println(\u0026#34;当前调用的方法是\u0026#34; + method.getName()); result = method.invoke(subject, objs);// 需要指定被代理对象和传入参数 System.out.println(method.getName() + \u0026#34;方法的返回值是\u0026#34; + result); System.out.println(\u0026#34;可以在调用实际方法后做一些事情\u0026#34;); System.out.println(\u0026#34;------------------------\u0026#34;); return result;// 返回method方法执行后的返回值 } } 测试\njava复制代码public class Mytest { public static void main(String[] args) { //第一步:创建被代理对象 HelloImpl hello = new HelloImpl(); //第二步:创建handler,传入真实对象 ProxyHandler handler = new ProxyHandler(hello); //第三步:创建代理对象,传入类加载器、接口、handler HelloInterface helloProxy = (HelloInterface) Proxy.newProxyInstance( HelloInterface.class.getClassLoader(), new Class[]{HelloInterface.class}, handler); //第四步:调用方法 helloProxy.sayHello(); } } 结果\nmarkdown复制代码可以在调用实际方法前做一些事情 当前调用的方法是sayHello hello sayHello方法的返回值是null 可以在调用实际方法后做一些事情 ------------------------ JDK动态代理步骤 # JDK动态代理分为以下几步:\n拿到被代理对象的引用,并且通过反射获取到它的所有的接口。 通过JDK Proxy类重新生成一个新的类,同时新的类要实现被代理类所实现的所有的接口。 动态生成 Java 代码,把新加的业务逻辑方法由一定的逻辑代码去调用。 编译新生成的 Java 代码.class。 将新生成的Class文件重新加载到 JVM 中运行。 所以说JDK动态代理的核心是通过重写被代理对象所实现的接口中的方法来重新生成代理类来实现的,那么假如被代理对象没有实现接口呢?那么这时候就需要CGLIB动态代理了。\n三、CGLIB动态代理 # JDK动态代理是通过重写被代理对象实现的接口中的方法来实现,而CGLIB是通过继承被代理对象来实现,和JDK动态代理需要实现指定接口一样,CGLIB也要求代理对象必须要实现MethodInterceptor接口,并重写其唯一的方法intercept。\nCGLib采用了非常底层的字节码技术,其原理是通过字节码技术为一个类创建子类,并在子类中采用方法拦截的技术拦截所有父类方法的调用,顺势织入横切逻辑。(利用ASM开源包,对代理对象类的class文件加载进来,通过修改其字节码生成子类来处理)\n注意:因为CGLIB是通过继承目标类来重写其方法来实现的,故而如果是final和private方法则无法被重写,也就是无法被代理。\nxml复制代码\u0026lt;dependency\u0026gt; \u0026lt;groupId\u0026gt;cglib\u0026lt;/groupId\u0026gt; \u0026lt;artifactId\u0026gt;cglib-nodep\u0026lt;/artifactId\u0026gt; \u0026lt;version\u0026gt;2.2\u0026lt;/version\u0026gt; \u0026lt;/dependency\u0026gt; CGLib核心类 # 1、 net.sf.cglib.proxy.Enhancer:主要增强类,通过字节码技术动态创建委托类的子类实例;\nEnhancer可能是CGLIB中最常用的一个类,和Java1.3动态代理中引入的Proxy类差不多。和Proxy不同的是,Enhancer既能够代理普通的class,也能够代理接口。Enhancer创建一个被代理对象的子类并且拦截所有的方法调用(包括从Object中继承的toString和hashCode方法)。Enhancer不能够拦截final方法,例如Object.getClass()方法,这是由于Java final方法语义决定的。基于同样的道理,Enhancer也不能对fianl类进行代理操作。这也是Hibernate为什么不能持久化final class的原因。\n2、net.sf.cglib.proxy.MethodInterceptor:常用的方法拦截器接口,需要实现intercept方法,实现具体拦截处理;\njava复制代码 public java.lang.Object intercept(java.lang.Object obj, java.lang.reflect.Method method, java.lang.Object[] args, MethodProxy proxy) throws java.lang.Throwable{} obj:动态生成的代理对象 method:实际调用的方法 args:调用方法入参 net.sf.cglib.proxy.MethodProxy:java Method类的代理类,可以实现委托类对象的方法的调用;常用方法:methodProxy.invokeSuper(proxy, args);在拦截方法内可以调用多次。 CGLib代理实例 # 创建被代理类\njava复制代码public class SayHello { public void say(){ System.out.println(\u0026#34;hello\u0026#34;); } } 创建代理类\njava复制代码/** *代理类 */ public class ProxyCglib implements MethodInterceptor{ private Enhancer enhancer = new Enhancer(); public Object getProxy(Class clazz){ //设置需要创建子类的类 enhancer.setSuperclass(clazz); enhancer.setCallback(this); //通过字节码技术动态创建子类实例 return enhancer.create(); } //实现MethodInterceptor接口方法 public Object intercept(Object obj, Method method, Object[] args, MethodProxy proxy) throws Throwable { System.out.println(\u0026#34;可以在调用实际方法前做一些事情\u0026#34;); //通过代理类调用父类中的方法 Object result = proxy.invokeSuper(obj, args); System.out.println(\u0026#34;可以在调用实际方法后做一些事情\u0026#34;); return result; } } 测试\njava复制代码public class Mytest { public static void main(String[] args) { ProxyCglib proxy = new ProxyCglib(); //通过生成子类的方式创建代理类 SayHello proxyImp = (SayHello)proxy.getProxy(SayHello.class); proxyImp.say(); } } 结果\n复制代码可以在调用实际方法前做一些事情 hello 可以在调用实际方法后做一些事情 CGLIB动态代理实现分析 # CGLib动态代理采用了FastClass机制,其分别为代理类和被代理类各生成一个FastClass,这个FastClass类会为代理类或被代理类的方法分配一个 index(int类型)。这个index当做一个入参,FastClass 就可以直接定位要调用的方法直接进行调用,这样省去了反射调用,所以调用效率比 JDK 动态代理通过反射调用更高。\n但是我们看上面的源码也可以明显看到,JDK动态代理只生成一个文件,而CGLIB生成了三个文件,所以生成代理对象的过程会更复杂。\n四、JDK和CGLib动态代理对比 # JDK 动态代理是实现了被代理对象所实现的接口,CGLib是继承了被代理对象。 JDK和CGLib 都是在运行期生成字节码,JDK是直接写Class字节码,CGLib 使用 ASM 框架写Class字节码,Cglib代理实现更复杂,生成代理类的效率比JDK代理低。\nJDK 调用代理方法,是通过反射机制调用,CGLib 是通过FastClass机制直接调用方法,CGLib 执行效率更高。\n原理区别: # java动态代理是利用反射机制生成一个实现代理接口的匿名类,在调用具体方法前调用InvokeHandler来处理。核心是实现InvocationHandler接口,使用invoke()方法进行面向切面的处理,调用相应的通知。\n而cglib动态代理是利用asm开源包,对代理对象类的class文件加载进来,通过修改其字节码生成子类来处理。核心是实现MethodInterceptor接口,使用intercept()方法进行面向切面的处理,调用相应的通知。\n1、如果目标对象实现了接口,默认情况下会采用JDK的动态代理实现AOP\n2、如果目标对象实现了接口,可以强制使用CGLIB实现AOP\n3、如果目标对象没有实现了接口,必须采用CGLIB库,spring会自动在JDK动态代理和CGLIB之间转换\n性能区别: # 1、CGLib底层采用ASM字节码生成框架,使用字节码技术生成代理类,在jdk6之前比使用Java反射效率要高。唯一需要注意的是,CGLib不能对声明为final的方法进行代理,因为CGLib原理是动态生成被代理类的子类。\n2、在jdk6、jdk7、jdk8逐步对JDK动态代理优化之后,在调用次数较少的情况下,JDK代理效率高于CGLIB代理效率,只有当进行大量调用的时候,jdk6和jdk7比CGLIB代理效率低一点,但是到jdk8的时候,jdk代理效率高于CGLIB代理。\n各自局限: # 1、JDK的动态代理机制只能代理实现了接口的类,而不能实现接口的类就不能实现JDK的动态代理。\n2、cglib是针对类来实现代理的,他的原理是对指定的目标类生成一个子类,并覆盖其中方法实现增强,但因为采用的是继承,所以不能对final修饰的类进行代理。\n类型 机制 回调方式 适用场景 效率 JDK动态代理 委托机制,代理类和目标类都实现了同样的接口,InvocationHandler持有目标类,代理类委托InvocationHandler去调用目标类的原始方法 反射 目标类是接口类 效率瓶颈在反射调用稍慢 CGLIB动态代理 继承机制,代理类继承了目标类并重写了目标方法,通过回调函数MethodInterceptor调用父类方法执行原始逻辑 通过FastClass方法索引调用 非接口类、非final类,非final方法 第一次调用因为要生成多个Class对象,比JDK方式慢。多次调用因为有方法索引比反射快,如果方法过多,switch case过多其效率还需测试 五、静态代理和动态的本质区别 # 静态代理只能通过手动完成代理操作,如果被代理类增加新的方法,代理类需要同步新增,违背开闭原则。 动态代理采用在运行时动态生成代码的方式,取消了对被代理类的扩展限制,遵循开闭原则。 若动态代理要对目标类的增强逻辑扩展,结合策略模式,只需要新增策略类便可完成,无需修改代理类的代码。 作者:ycf 链接:https://juejin.cn/post/7011357346018361375 来源:稀土掘金 著作权归作者所有。商业转载请联系作者获得授权,非商业转载请注明出处。\n"},{"id":223,"href":"/zh/docs/technology/JVM/_understanding_the_jvm_/03/","title":"03垃圾收集器与内存分配策略","section":"_深入理解Java虚拟机_","content":" 学习《深入理解Java虚拟机》,感谢作者!\n代码清单3-9 -XX:MaxTenuringThreshod=1说明 # Eden[8M] Survivor1[1M] Survivor2[1M] Old {10M} 初始 allocation1[0.25M],allocation2[4MB] 3执行时gc导致的变化 +allocation1[0.25M] +allocation2[4MB] 3执行后 +allocation3[4MB] +allocation1[0.25M] +allocation2[4MB] 5执行时gc导致的变化 allocation2[4MB],+allocation1[0.25M] 5执行后 +allocation3[4MB] allocation2[4MB],+allocation1[0.25M] 代码清单3-9 -XX:MaxTenuringThreshod=15说明 # Eden[8M] Survivor1[1M] Survivor2[1M] Old {10M} 初始 allocation1[0.25M],allocation2[4MB] 3执行时gc导致的变化 +allocation1[0.25M] +allocation2[4MB] 3执行后 +allocation3[4MB] +allocation1[0.25M] +allocation2[4MB] 5执行时gc导致的变化 +allocation1[0.25M] allocation2[4MB] 5执行后 +allocation3[4MB] +allocation1[0.25M] allocation2[4MB],+allocation1[0.25M] 代码清单3-10 说明 # Eden[8M] Survivor1[1M] Survivor2[1M] Old {10M} 初始 allocation1[0.25M],allocation2[[0.25M],allocation3[4M] 4执行时gc导致的变化 +allocation1[0.25M],+allocation2[[0.25M], +allocation3[4MB] 4执行后 +allocation4[4MB] +allocation1[0.25M],+allocation2[[0.25M], +allocation3[4MB] 6执行时gc导致的变化 allocation3[4MB],+allocation1[0.25M],+allocation2[[0.25M], 6执行后 +allocation4[4MB] allocation3[4MB],+allocation1[0.25M],+allocation2[[0.25M], 代码清单3-11 说明 # -XX:-HandlePromotionFailure 关 # Eden[8M] Survivor1[1M] Survivor2[1M] Old {10M} 初始 allocation1[2M],allocation2[2M],allocation3[2M]allocation1[null],allocation4[2M] 5执行时gc导致的变化 +allocation2[2M],+allocation3[2M] //总共4M 5执行后 +allocation4[2M] +allocation2[2M],+allocation3[2M] //总共4M 6-\u0026gt;11 allocation4[2M]+allocation5[2M],+allocation6[2M] allocation2[2M],allocation3[2M] //总共4M,此时老年代连续可用空间在6M(或者说小于6M) 11执行时gc导致的变化 allocation3[4MB],+allocation1[0.25M],+allocation2[[0.25M], 11执行后 +allocation7[2MB] allocation3[4MB],+allocation1[0.25M],+allocation2[[0.25M], 说明 # 书籍版权归著者和出版社所有\n本PDF来自于各个广泛的信息平台,经过整理而成\n本PDF仅限用于非商业用途或者个人交流研究学习使用\n本PDF获得者不得在互联网上以任何目的进行传播,违规者造成的法律责任和后果,违规者自负\n如果觉得书籍内容很赞,请一定购买正版实体书,多多支持编写高质量的图书的作者和相应的出版社!当然,如果图书内容不堪入目,质量低下,你也可以选择狠狠滴撕裂本PDF\n技术类书籍是拿来获取知识的,不是拿来收藏的,你得到了书籍不意味着你得到了知识,所以请不要得到书籍后就觉得沾沾自喜,要经常翻阅!!经常翻阅\n请于下载PDF后24小时内研究使用并删掉本PDF\n"},{"id":224,"href":"/zh/docs/problem/JVM/2023052302/","title":"linux中调试open jdk","section":"Jvm","content":" 完全转载自https://lin1997.github.io/2020/07/19/debug-openjdk-on-ubuntu.html ,以防丢失故作备份,目前还没看懂。\n在Ubuntu中编译和调试OpenJDK # OpenJDK\nUbuntu\nCLion\n2020年 07月19日\n构建编译环境 # 安装GCC编译器:\nsudo apt install build-essential 安装OpenJDK依赖库:\n工具 库名称 安装命令 FreeType The FreeType Project sudo apt install libfreetype6-dev CUPS Common UNIX Printing System sudo apt install libcups2-dev X11 X Window System sudo apt install libx11-dev libxext-dev libxrender-dev libxrandr-dev libxtst-dev libxt-dev ALSA Advanced Linux Sound Architecture sudo apt install libasound2-dev libffi Portable Foreign Function Interface sudo apt install libffi-dev Autoconf Extensible Package of M4 Macros sudo apt install autoconf zip/unzip unzip sudo apt install zip unzip fontconfig fontconfig sudo apt install libfontconfig1-dev 假设要编译大版本号为N的JDK,我们还要安装一个大版本号至少为N-1的、已经编译好的JDK作为“Bootstrap JDK”:\nsudo apt install openjdk-11-jdk 获取源码 # 可以直接访问准备下载的JDK版本的仓库页面(譬如本例中OpenJDK 11的页面为https://hg.openjdk.java.net/jdk-updates/jdk11u/),然后点击左边菜单中的“Browse”,再点击左边的“zip”链接即可下载当前版本打包好的源码,到本地直接解压即可。\n也可以从Github的镜像Repositories中获取(https://github.com/openjdk),进入所需版本的JDK的页面,点击Clone按钮下的Download ZIP按钮下载打包好的源码,到本地直接解压即可。\n进行编译 # 首先进入解压后的源代码目录,本例解压到的目录为~/openjdk/:\ncd ~/openjdk 要想带着调试、定制化的目的去编译,就要使用OpenJDK提供的编译参数,可以使用bash configure --help查看. 本例要编译SlowDebug版、仅含Server模式的HotSpot虚拟机,同时我们还可以禁止压缩生成的调试符号信息,方便gdb调试获取当前正在执行的源代码和行号等调试信息. 对应命令如下:\nbash configure --with-debug-level=slowdebug --with-jvm-variants=server --disable-zip-debug-info 对于版本较低的OpenJDK,编译过程中可能会出现了源码deprecated的错误,这是因为\u0026gt;=2.24版本的glibc中 ,readdir_r等方法被标记为deprecated。若读者也出现了该问题,请在configure命令加上--disable-warnings-as-errors参数,如下:\nbash configure --with-debug-level=slowdebug --with-jvm-variants=server --disable-zip-debug-info --disable-warnings-as-errors 此外,若要重新编译,请先执行make dist-clean\n执行make命令进行编译:\nmake 生成的JDK在build/配置名称/jdk中,测试一下,如:\ncd build/linux-x86_64-normal-server-slowdebug/jdk/bin ./java -version 生成Compilation Database # CLion可以通过Compilation Database来导入项目. 在OpenJDK 11u及之后版本中,OpenJDK官方提供了对于IDE的支持,可以使用make compile-commands命令生成Compilation Database:\nmake compile-commands 对于版本较低的OpenJDK,可以使用一些工具来生成Compilation Database,比如:\nBear scan-build compiled 然后检查一下build/配置名称/下是否生成了compile_commands.json.\ncd build/linux-x86_64-normal-server-slowdebug ls -l 导入项目至CLion # 优化CLion索引速度 # 提高Inotify监视文件句柄上限,以优化CLion索引速度:\n在/etc/sysctl.conf中或 /etc/sysctl.d/目录下新建一个*.conf文件,添加以下内容:\nfs.inotify.max_user_watches = 524288 应用更改:\nsudo sysctl -p --system 重新启动CLion\n导入项目 # 打开CLion,选择Open Or Import,选择上文生成的build/配置名称/compile_commands.json文件,弹出框选择Open as Project,等待文件索引完成.\n接着,修改项目的根目录,通过Tools -\u0026gt; Compilation Database -\u0026gt; Change Project Root功能,选中你的源码目录.\n为了减少CLion索引文件数,提高CLion效率,建议将非必要的文件夹排除:Mark Directory as -\u0026gt; Excluded. 大部分情况下,我们只需要索引以下文件夹下的源码:\nsrc/hotspot src/java.base 配置调试选项 # 创建自定义Build Target # 点击File菜单栏,Settings -\u0026gt; Build, Execution, Deployment -\u0026gt; Custom Build Targets,点击+新建一个Target,配置如下:\nName:Target的名字,之后在创建Run/Debug配置的时候会看到这个名字\n点击Build或者Clean右边的三点,弹出框中点击+新建两个External Tool配置如下:\n# 第一个配置如下,用来指定构建指令 # Program 和 Arguments 共同构成了所要执行的命令 \u0026#34;make\u0026#34; Name: make Program: make Arguments: Working directory: {项目的根目录} # 第二个配置如下,用来清理构建输出 # Program 和 Arguments 共同构成了所要执行的命令 \u0026#34;make clean\u0026#34; Name: make clean Program: make Arguments: clean Working directory: {项目的根目录} ToolChain选择Default;Build选择make(上面创建的第一个External Tool);Clean选择make clean(上面创建的第二个External Tool)\n创建自定义的Run/Debug configuration # 点击Run菜单栏,Edit Configurations, 点击+,选择Custom Build Application,配置如下:\n# Executable 和 Program arguments 可以根据需要调试的信息自行选择 # Name:Configure 的名称 Name: OpenJDK # Target:选择上一步创建的 “Custom Build Target” Target: {上一步创建的 “Custom Build Target”} # Executable:程序执行入口,也就是需要调试的程序 Executable: 这里我们调试`java`,选择`{source_root}/build/{build_name}/jdk/bin/java`。 # Program arguments: 与 “Executable” 配合使用,指定其参数 Program arguments: 这里我们选择`-version`,简单打印一下`java`版本。 # Before luanch:这个下面的Build可去可不去,去掉就不会每次执行都去Build,节省时间,但其实OpenJDK增量编译的方式,每次Build都很快,所以就看个人选择了。 配置GDB # 由于HotSpot JVM内部使用了SEGV等信号来实现一些功能(如NullPointerException、safepoints等),所以调试过程中,GDB可能会误报Signal: SIGSEGV (Segmentation fault). 解决办法是,在用户目录下创建.gdbinit,让GDB捕获SEGV等信号:\nvi ~/.gdbinit 将以下内容追加到文件中并保存:\nhandle SIGSEGV nostop noprint pass 开始调试 # 使用CLion调试C++层面的代码 # 完成以上配置之后,一个可修改、编译、调试的HotSpot工程就完全建立起来了。HotSpot虚拟机启动器的执行入口是${source_root}/src/java.base/share/native/libjli/java.c的JavaMain()方法,读者可以设置断点后点击Debug可开始调试.\n使用GDB调试汇编层面的代码 # 这里提供两个方法,一个是使用-XX:StopInterpreterAt=\u0026lt;n\u0026gt;虚拟机参数来实现中断,缺点是需要找到你所感兴趣的字节码在程序中的序号;第二个方法是直接去寻找记录生成的机器指令的入口(EntryPoint)的表,即Interpreter::_normal_table,在对应的字节码入口地址打断点,但是这需要读者对模板解释器有一定了解。\n使用虚拟机参数进行中断 # 对于汇编级别的调试,我们可以手动使用GDB进行调试:\ngdb build/linux-x86_64-normal-server-slowdebug/jdk/bin/java 由于目前HotSpot在主流的操作系统上,都采用模板解释器来执行字节码,它与即时编译器一样,最终执行的汇编代码都是运行期间产生的,无法直接设置断点,所以HotSpot增加了一些参数来方便开发人员调试解释器。\n我们可以先使用参数-XX:+TraceBytecodes,打印并找出你所感兴趣的字节码位置,中途可以使用Ctrl + C退出:\nset args -XX:+TraceBytecodes run 然后,再使用参数-XX:StopInterpreterAt=\u0026lt;n\u0026gt;,当遇到程序的第n条字节码指令时,便会进入${source_root}/src/os/linux/vm/os_linux.cpp中的空函数breakpoint():\nset args -XX:+TraceBytecodes -XX:StopInterpreterAt=\u0026lt;n\u0026gt; 再通过GDB在${source_root}/src/hotspot/os/linux/os_linux.cpp中的breakpoint()函数上打上断点:\nbreak breakpoint 为什么要将断点打在这里?\n去看${source_root}/src/hotspot/share/interpreter/templateInterpreterGenerator.cpp里,函数TemplateInterpreterGenerator::generate_and_dispatch中对stop_interpreter_at()的调用就知道了.\n接着我们开始运行hotspot:\nrun 当命中断点时,我们再跳出breakpoint()函数:\nfinish 这样就会返回到真正的字节码的执行了。\n不过,我们还要跳过函数TemplateInterpreterGenerator::generate_and_dispatch中插入到字节码真正逻辑前的一些用于debug的逻辑:\nif (PrintBytecodeHistogram) histogram_bytecode(t); // debugging code if (CountBytecodes || TraceBytecodes || StopInterpreterAt \u0026gt; 0) count_bytecode(); if (PrintBytecodePairHistogram) histogram_bytecode_pair(t); if (TraceBytecodes) trace_bytecode(t); if (StopInterpreterAt \u0026gt; 0) stop_interpreter_at(); 比如开启了参数-XX:+TraceBytecodes和-XX:StopInterpreterAt=\u0026lt;n\u0026gt;,应该跳过的指令如下:\n# count_bytecode()对应指令: 0x7fffe07e8261:\tincl 0x16901039(%rip) # 0x7ffff70e92a0 \u0026lt;BytecodeCounter::_counter_value\u0026gt; # trace_bytecode(t)对应指令: 0x7fffe07e8267:\tmov %rsp,%r12 0x7fffe07e826a:\tand $0xfffffffffffffff0,%rsp 0x7fffe07e826e:\tcallq 0x7fffe07c5edf 0x7fffe07e8273:\tmov %r12,%rsp 0x7fffe07e8276:\txor %r12,%r12 # stop_interpreter_at()对应指令: 0x7fffe07e8279:\tcmpl $0x66,0x1690101d(%rip) # 0x7ffff70e92a0 \u0026lt;BytecodeCounter::_counter_value\u0026gt; 0x7fffe07e8283:\tjne 0x7fffe07e828e 0x7fffe07e8289:\tcallq 0x7ffff606281a \u0026lt;os::breakpoint()\u0026gt; #\t......................... #\t......真正的字节码逻辑...... #\t......................... # dispatch_epilog(tos_out, step)对应指令,用来取下一条指令执行... 进入真正的字节码逻辑后,我们就可以使用指令级别的stepi, nexti命令来进行跟踪调试了。(由于汇编代码都是运行期产生的,GDB中没有与源代码的对应符号信息,所以不能用C++源码行级命令step以及next)\n寻找字节码机器指令的入口手动打断点 # 关于模板解释器相关知识,可以阅读: JVM之模板解释器.\n还是一样,运行GDB:\ngdb build/linux-x86_64-normal-server-slowdebug/jdk/bin/java start break JavaMain continue 我们先在${source_root}/src/hotspot/share/interpreter/templateInterpreter.cpp的DispatchTable::set_entry(...)函数上打条件断点,条件是函数实参i == \u0026lt;字节码对应十六进制\u0026gt;,字节码对应的十六进制见:${source_root}/src/hotspot/share/interpreter/bytecodes.hpp的Bytecodes::Code.\nbreak DispatchTable::set_entry if i==\u0026lt;字节码对应十六进制\u0026gt; 然后继续运行\ncontinue 命中断点后,查看函数实参entry所指向的内存地址\nprint entry 在这个地址上打断点。\nbreak *\u0026lt;内存地址\u0026gt; 然后继续运行\ncontinue 命中断点后,就跟前一个方法一样可以直接使用指令级别的stepi, nexti命令来进行跟踪调试了。\n配置IDEA # 为项目的绑定JDK源码路径 # 打开IDEA,新建一个项目。然后选择File -\u0026gt; Project Structure,选到SDKs选项,新添加上自己刚刚编译生成的JDK,JDK home path为${source_root}/build/配置名称/jdk. 然后在Sourcepath下移除原本的源码路径(如果有),并添加为前面的源代码,如${source_root}/src/java.base/share/classes等. 这样以来,我们就可以在IDEA中编辑JDK的JAVA代码,添加自己的注释了。\n重新编译JDK的JAVA代码 # 在添加中文注释后,再编译JDK时会报错:\nerror: unmappable character (0x??) for encoding ascii\n我们可以在${source_root}/make/common/SetupJavaCompilers.gmk中,修改两处编码方式的设置,替换原内容:\n-encoding ascii 为:\n-encoding utf-8 这样编译就不会报错了。\n而且,如果我们只修改了JAVA代码,无需使用make命令重新编译整个OpenJDK,而只需要使用以下命令仅编译JAVA模块:\nmake java 使用IDEA的Step Into跟踪调试源码 # 我们发现,在IDEA调试JDK源码时,无法使用Step Into(F7)跟进JDK中的相关函数,这是因为IDEA默认设置不步入这些内置的源码。可以在File -\u0026gt; Settings -\u0026gt; Build, Execution, Deployment -\u0026gt; Debugger -\u0026gt; Stepping中,取消勾选Do not step into the classes来取消限制。\n参考文章 # Tips \u0026amp; Tricks: Develop OpenJDK in CLion with Pleasure OpenJDK 编译调试指南(Ubuntu 16.04 + MacOS 10.15) JVM-在MacOS系统上使用CLion编译并调试OpenJDK12 深入理解Java虚拟机:JVM高级特性与最佳实践(第3版) 编译JDK源码踩坑纪实 How to to debug the HotSpot interpreter JVM之模板解释器 "},{"id":225,"href":"/zh/docs/problem/Linux/20230523/","title":"zsh卸载后root无法登录及vm扩容centos7报错处理","section":"Linux","content":" zsh卸载后root无法登录 # 主要参考文档 https://blog.csdn.net/Scoful/article/details/119746150\n重启,开机引导进入下面的那个,按e进入编辑模式 移动光标,找到ro crashkernel=auto,修改为 rw init=sysroot/bin/sh\n按ctrl+x进入单用户模式界面\n输入chroot /sysroot 获取权限 vim /etc/passwd 第一行 ,root \u0026hellip;\u0026hellip;zsh,中/bin/zsh,改为/bin/bash 用touch /.autorelabel更新SELinux信息 两次exit 推出chroot reboot 重启:需要一定时间,耐心等待 vm扩容centos7 # 这里是因为我在vm手动扩容后,进入centos7系统\u0026mdash;用了 可视化界面中的disk软件直接扩容,发生错误(具体错误我没注意,一闪而过了),后面呢我再使用命令resize2fs /dev/sda3的时候,发现总是提示 busy\n解决办法 # 按照上面的办法,进入到第3步结束之后(按ctrl+x进入单用户模式界面 要做)\n输入 umount /dev/sda3 进行卸载\n然后输入下面进行修复(极为重要),然后出现问题是否修复一直按\u0026rsquo;y\u0026rsquo;即可\nxfs_repair /dev/sda4 注:如果你当前文件系统是ext4,可以执行fsck.ext4 /dev/sda4 然后输入 mount /dev/sda3 / 进行挂载(这步可能不需要)\n最后 reboot 重启 重启之后,再执行 resize2fs /dev/sda3 即可\n"},{"id":226,"href":"/zh/docs/problem/Hexo/01/","title":"hexo在线查看pdf","section":"Hexo","content":" 场景 # 由于在看《mysql是如何运行的》,做md文件笔记时,发现好多都是按pdf一字不漏打出来。所以想着能不能直接本地编辑pdf,然后博客上支持在线查看。\n事后觉得这个方式有待斟酌,电脑上/平板上查看没啥问题,手机上查看字有点小,但也还能接受。==\u0026gt;待斟酌\n不过下面的方案是可行的。\n准备 # 需要到官网下载 pdf.js\nhttps://github.com/mozilla/pdf.js/releases ,这里选择 v3.4.120中的 pdfjs-3.4.120-dist.zip ,最新版本好像有问题\n操作 # pdfjs处理 # 在source/下创建myjs/pdfjs文件夹,并解压到这个文件夹下\n修改pdfjs/web/viewer.js\nif (fileOrigin !== viewerOrigin) {//1563行左右 throw new Error(\u0026#34;file origin does not match viewer\u0026#39;s\u0026#34;); } //注释掉,为了处理跨域问题,注释掉后允许在线访问其他网站的pdf // if (fileOrigin !== viewerOrigin) { //\tthrow new Error(\u0026#34;file origin does not match viewer\u0026#39;s\u0026#34;); //} hexo配置修改 # # 找到# Directory下的skip_render项,添加忽略渲染的文件夹 skip_render: [\u0026#39;myjs/pdfjs/**/*\u0026#39;] 清理hexo中public及其他缓存文件 # hexo clean \u0026amp; hexo g 文件预览测试 # 本地文件 # 我们在hexo的source文件夹下,放置这样一个文件: source/pdf/my.pdf\nMD文件修改 # \u0026lt;iframe src=\u0026#39;/myjs/pdfjs/web/viewer.html?file=/pdf/my.pdf\u0026#39; style=\u0026#34;padding: 0;width:100%;\u0026#34; marginwidth=\u0026#34;0\u0026#34; frameborder=\u0026#34;no\u0026#34; scrolling=\u0026#34;no\u0026#34; height=\u0026#34;2000px\u0026#34;\u0026gt;\u0026lt;/iframe\u0026gt; 操作并查看 # hexo g \u0026amp; hexo s 远程文件 # ‘\n也就是在我的账号(lwmfjc)下,创建一个仓库(仓库名 pdfs),然后创建一个文件夹及文件 temp/01.pdf ,这个地址是 https://raw.githubusercontent.com/lwmfjc/pdfs/main/temp/01.pdf\n注意修改账号名及仓库名 :lwmfjc/pdfs/\n文件夹及文件:temp/01.pdf\nMD文件修改 # \u0026lt;iframe src=\u0026#39;/myjs/pdfjs/web/viewer.html?file=https://raw.githubusercontent.com/lwmfjc/pdfs/main/mysql/01.pdf\u0026#39; style=\u0026#34;padding: 0;width:100%;\u0026#34; marginwidth=\u0026#34;0\u0026#34; frameborder=\u0026#34;no\u0026#34; scrolling=\u0026#34;no\u0026#34; height=\u0026#34;2000px\u0026#34;\u0026gt;\u0026lt;/iframe\u0026gt; 操作并查看 # hexo g \u0026amp; hexo s "},{"id":227,"href":"/zh/docs/technology/MySQL/_how_mysql_run_/07/","title":"07B+数索引的使用","section":"_MySQL是怎样运行的_","content":" 学习《MySQL是怎样运行的》,感谢作者!\nInnoDB存储引擎的B+树索引:结论 # 每个索引对应一颗B+树。B+树有好多层,最下边一层是叶子节点,其余是内节点。所有用户记录都存在B+树的叶子节点,所有目录项记录都存在内节点 InnoDB 存储引擎会自动为主键建立聚簇索引(如果没有显式指定主键或者没有声明不允许存储NULL的UNIQUE 键,它会自动添加主键) , 聚簇索引的叶子节点包含完整的用户记录 我们可以为感兴趣的列建立二级索引,二级索引的叶子节点包含的用户记录由索引列 和主键组成。如果想通过二级索引查找完整的用户记录,需要执行回表操作, 也就是在通过二级索引找到主键值之后,再到聚簇索引中查找完整的用户记录 B+ 树中的每层节点都按照索引列的值从小到大的顺序排序组成了双向链表,而且每个页内的记录(无论是用户记录还是目录项记录)都按照索引列的值从小到大的顺序形成了一个单向链表。如果是联合索引, 则页面和记录 先按照索引列中前面的列的值排序:如果该列的值相同,再按照索引列中后面的列的值排序。比如, 我们对列c2 和c3建立了联合索引 idx_c2_c3(c2, c3),那么该索引中的页面和记录就先按照c2 列的值进行排序;如果c2 列的值相同, 再按照c3 列的值排序 通过索引查找记录时,是从B+ 树的根节点开始一层一层向下搜索的。由于每个页面(无论是内节点页面还是叶子节点页面〉中的记录都划分成了若干个组, 每个组中索引列值最大的记录在页内的偏移量会被当作槽依次存放在页目录中(当然, 规定Supremum 记录比任何用户记录都大) ,因此可以在页目录中通过二分法快速定位到索引列等于某个值的记录 如果大家在阅读上述结论时哪怕有点疑惑, 那么下面的内容就不适合你,请回过头去反复阅读前面的章节\nB+树索引示意图的简化 # #创建新表 mysql\u0026gt; CREATE TABLE single_table( id INT NOT NULL AUTO_INCREMENT, key1 VARCHAR(100), key2 INT, key3 VARCHAR(100), key_part1 VARCHAR(100), key_part2 VARCHAR(100), key_part3 VARCHAR(100), common_field VARCHAR(100), PRIMARY KEY (id), KEY idx_key1(key1), UNIQUE KEY uk_key2(key2), KEY idx_key3(key3), KEY idx_key_part(key_part1,key_part2,key_part3) ) Engine=InnoDB CHARSET = utf8; 如上,建立了1个聚簇索引,4个二级索引\n为id列建立的聚簇索引 为key1列建立的idx_key1二级索引 为key2列建立的uk_key2二级索引,而且该索引是唯一二级索引 为key3列建立的idx_key3二级索引 为key_part1、key_part2、key_part3列建立的idx_key_part二级索引,是一个联合索引 接下来为这个表插入10,000行记录\n除了id,其余的列取随机值:该表后面会频繁用到\n需要用程序写,这里暂时跳过(不会\u0026hellip;,书上也没写)\n回顾:B+树包括内节点和叶子节点,以及各个节点中的记录。B+树其实是一个矮矮的大胖子,能够利用B+树快速地定位记录,下面简化一下B+树的示意图:\n忽略页结构,直接把所有叶子节点中的记录放一起 为了方便,把聚簇索引叶子节点的记录称为聚簇索引记录,把二级索引叶子节点称为二级索引记录 回顾一下:\n核心要点:把下一层每一页的最小值,放到上一级的目录项记录,以key值+页号这样的组合存在\n精简:\n如上,聚簇索引记录是按照主键值由小到大的顺序排列的 如下图,通过B+树定位到id值为1438的记录\n二级索引idx_key1对应的B+树中保留了叶子结点的记录。以key1排序,如果key1相同,则按照id列排序\n为了方便,把聚簇索引叶子节点的记录称为聚簇索引记录,把二级索引叶子节点称为二级索引记录\n如果要查找key1值等于某个值的二级索引记录,通过idx_key1对应的B+树,可以很容易定位到第一条key1列的值等于某个值的二级索引记录,然后沿着单向链表向后扫描即可。\n索引的代价 # 空间上的代价 # 每建立一个索引,都要为他建立一颗B+树。每一颗B+树的每一个节点都是一个数据页(一个数据页默认占用16KB),而一颗很大的B+树由许多数据页组成,这将占用很大的片存储空间\n时间上的代价 # 每当对表中数据进行增上改查时,都要修改各个B+树索引 执行查询语句前,都要生成一个执行计划。一般情况下,一条查询语句在执行过程中最多用到一个二级索引(有例外,10章),在生成执行计划时需要计算使用不同索引执行查询时所需要的成本,最后选取成本最低的那个索引执行查询(12章:如何计算查询成本)==\u0026gt; 索引太多导致分析时间过长 总结 # 索引越多,存储空间越多,增删改记录或者生成执行计划时性能越差\n为了建立又好又少的索引,得先了解索引在查询执行期间到底是如何发挥作用的\n应用B+树索引 # 对于某个查询来说,最简单粗暴的执行方案就是扫描表中的所有记录。判断每一条记录是否符合搜索条件。如果符合,就将其发送到客户端,否则就跳过该记录。这种执行方案也称为全表扫描。\n对于使用 InnoDB 存储引擎的表来说,全表扫描意味着从聚簇索引第一个叶子节点的第一条记录开始,沿着记录所在的单向链表向后扫描 直到最后一个叶子节点的最后一条记录(叶子节点:页,16KB;即页内最后一条)。虽然全表扫描是一种很笨的执行方案,但却是一种万能的执行方案,所有的查询都可以使用这种方案来执行。\n扫描区间和边界条件 # 可以利用B+树查找索引值等于某个值的记录=\u0026gt;减少需要扫描的记录数量。由于*B+树叶子节点中的记录是按照索引列值由小到大的顺序排序的,所以只扫描某个区间或者某些区间中的记录也可以明显减少需要扫描的记录数量。\n简单例子 # 例子1(聚簇索引) # 例子:SELECT * FROM single_table WHERE id\u0026gt;=2 AND id \u0026lt;=100\n这个语句其实是要找id值在**[2,100]区间中的所有聚簇索引**记录。\n可以通过聚簇索引对应的B+树快速地定位到id值为2的那条聚簇索引记录,然后沿着记录所在的单向链表向后扫描,直到某条聚簇索引记录的id值不在[2,100]区间中为止(即id不再符合id\u0026lt;=100条件) 与扫描全部的聚簇索引记录相比,扫描id 值在**[2,100]** 区间中的记录已经很大程度地减少了需要扫描的记录数量, 所以提升了查询效率。简便起见,我们把这个例子中待扫描记录的id 值所在的区间称为扫描区间,把形成这个扫描区间的搜索条件(也就是id \u0026gt;= 2AND \u0026gt; id \u0026lt;= 100 ) 称为形成这个扫描区间的边界条件. 对于全表扫描来说,相当于扫描id在**(-∞,+∞)** 区间中的记录,也就是说全表扫描对应的扫描区间是**(-∞,+∞)**\n例子2(二级索引) # SELECT * FROM single_table WHERE key2 IN (1438,6328 ) OR (key2 \u0026gt;=38 AND key2 \u0026lt;=79) 可以直接使用全表扫描的方式执行该查询。\n但是我们发现该查询的搜索条件涉及key2列,而我们又正好为key2列建立了uk_key2索引。如果使用uk_key2索引执行这个查询,则相当于从下面的3个扫描区间中获取二级索引记录:\n[1438,1438] :对应的边界条件就是key2 IN (1438) [6328,6328]:对应的边界条件就是key2 IN (6328) [38,79]:对应的边界条件就是key2 \u0026gt;= 38 AND key2 \u0026lt;= 79 这些扫描区间对应到数轴上时,如图\n方便起见,我们把像[1438,1438]、[6328, 6328] 这样只包含一个值的扫描区间称为单点扫描区间, 把[38, 79] 这样包含多个值的扫描区间称为范围扫描区间。另外,由于我们的查询列表是 * ,也就是需要读取完整的用户记录,所以从上述扫描区间中每获取一条二级索引记录, 就需要根据该二级索引记录id列的值执行回表操作,也就是到聚簇索引中找到相应的聚簇索引记录。\n其实我们不仅仅可以使用uk_key2 执行上述查询, 还可以使用idx_key1、idx_key3 、idx_key_part 执行上述查询。以idx_key_1 为例,很显然无法通过搜索条件形成合适的扫描区间来减少需要扫描的idx_key1 二级索引记录的数量,只能扫描idx_keyl 的全部二级索引记录。针对获取到的每一条二级索引记录,都需要执行回表操作来获取完整的用户记录.。我们也可以说,使用idx_key1 执行查询时对应的扫描区间就是**(-∞,+∞)** 这样虽然行得通,但我们图啥呢,最简单粗暴的全表扫描方式已经需要扫描全部的聚簇索引记录, 这里除了需要访问全部的聚簇索引记录,还要扫描全部的idx_key1二级索 引记录,这不是费力不讨好么。可见, 在这个过程中并没有减少需要扫描的记录数量,效 率反而比全表扫描差。所以如果想使用某个索引来执行查询,但是又无法通过搜索条件 形成合适的扫描区间来减少需要扫描的记录数量时, 则不考虑使用这个索引执行查询 例3 不是索引的搜索条件都可以成为边界条件 # SELECT * FROM single_table WHERE key1 \u0026lt; \u0026#39;a\u0026#39; AND key3 \u0026gt; \u0026#39;z\u0026#39; AND common_field = \u0026#39;abc\u0026#39; 如果使用idx_key1 执行查询,那么相应的扫描区间就是(-∞,\u0026lsquo;a\u0026rsquo;),形成该扫描区间的边界条件就是key1 \u0026lt; \u0026lsquo;a\u0026rsquo;。而 key3 \u0026gt; \u0026lsquo;z\u0026rsquo; AND common_field = \u0026lsquo;abc\u0026rsquo;就是普通的搜索条件,这些普通的搜索条件需要在获取到idx_key1的二级索引记录后,再执行回表操作,在获取到完整的用户记录后才能去判断它们是否成立 而如果使用idx_key3 执行查询,那么相应的扫描区间就是\u0026rsquo;z\u0026rsquo;,形成该扫描区间的边界条件就是key3\u0026gt;\u0026lsquo;z\u0026rsquo;。而key1\u0026lt;\u0026lsquo;a\u0026rsquo; AND common_field=\u0026lsquo;abc\u0026rsquo;就是普通的搜索条件,这些普通的搜索条件需要在获取到idx_key3的二级索引记录后,再执行回表操作,在获取到完整的用户记录后才能去判断它们是否成立 总结 # 在使用某个索引执行查询时,关键的问题就是通过搜索条件找出合适的扫描区间,然后再到对应的B+ 树中扫描索引列值在这些扫描区间的记录。对于每个扫描区间来说,仅需要通过B+ 树定位到该扫描区间中的第一条记录,然后就可以沿着记录所在的单向链表向后扫描,直到某条记录不符合形成该扫描区间的边界条件为止。其实对于B+ 树索引来说,只要索引列和常数使用**=、\u0026lt;=\u0026gt;、lN、NOT IN、IS NULL、IS NOT NULL、\u0026gt; 、\u0026lt;、=、\u0026lt;=、BETWEEN 、! = (也可以写成\u0026lt; \u0026gt;)或者LIKE 操作符连接起来,就可以产生所谓的扫描区间**。不过有下面几点需要注意:\nlN操作符的语义与若干个等值匹配操作符( =)之间用OR 连接起来的语义是一样的,都会产生多个单点扫描区间。比如下面这两个语句的语义效果是一样的:\nSELECT * FROM single_table WHERE key2 IN (1438,6328); #与上面的语义效果一样 SELECT * FROM single_table WHERE key2 = 1438 OR key2 = 6328 != 产生的扫描区间比较有趣,如:\nSELECT * FROM single_table key1 != \u0026#39;a\u0026#39;; 此时idx_key1执行查询时对应的扫描区间就是(-∞,\u0026lsquo;a\u0026rsquo;) 和(\u0026lsquo;a\u0026rsquo;,+∞)\nLIKE操作符比较特殊,只有在匹配完整的字符串或者匹配字符串前缀时才产生合适的扫描区间\n比较字符串的大小,其实就相当于一次比较每个字符的大小。字符串的比较过程如下所示:\n先比较字符串的第一个字符:第一个字符小的那个字符串就比较小 如果两个字符串的第一个字符相同,再比较第二个字符;第二个字符比较小的那个字符串就比较小 如果两个字符串的前两个字符都相同,那么就接着比较第三个字符:依此类推 对于某个索引列来说,字符串前缀相同的记录在由记录组成的单向链表中肯定是相邻的。\n比如我们有一个搜索条件是key1 LIKE \u0026lsquo;a%\u0026rsquo;。 对于二级索引 idx_key1 来说,所有字符串前缀为\u0026rsquo;a\u0026rsquo;的二级索引记录肯定是相邻的。这也就意味着我们只要定位 key1 值的字符串前缀为\u0026rsquo;a\u0026rsquo; 的第一条记录,就可以沿着记录所在的单向链表向后扫描, 直到某条二级索引记录的字符串前缀不为\u0026rsquo;a\u0026rsquo; 为止,如图7-7 所示。很显然 key1 LIKE \u0026lsquo;a%\u0026rsquo; 形成的扫描区间相当于**[\u0026lsquo;a\u0026rsquo;,\u0026lsquo;b\u0026rsquo;)** 稍复杂例子 # 日常工作中,一个查询语句中的WHERE子句可能有多个小的搜索条件,这些搜索条件使用AND 或者OR 操作符连接起来。虽然大家都知道这两个操作符的作用,但这里还是要再强调一遍:\ncond1 AND cond2 只有当cond1和cond2都为TRUE 时,整个表达式才为TRUE cond1 OR cond2 , 只要cond1 或者cond2 中有一个为TRUE, 整个表达式就为TRUE 在我们执行一个查询语句时,首先需要找出所有可用的索引以及使用它们时对应的扫描区间。下面我们来看一下怎么从包含若干个AND 或OR 的复杂搜索条件中提取出正确的扫描区间:\n所有搜索条件都可以生成合适的扫描区间的情况 # AND结合 # SELECT * FROM single_table WHERE key2 \u0026gt; 100 AND key2 \u0026gt; 200; 其中,每个小的搜索条件都可以生成一个合适的扫描区间来减少需要扫描的记录数量,最终的扫描区间就是对这两个小的搜索条件形成的扫描区间取交集后的结果,取交集的过程:\n上面查询语句使用uk_key2索引执行查询时对应的扫描区间就是**(200,+∞),形成该扫描区间的边界条件就是key2 \u0026gt; 200**\nOR结合 # 使用OR操作符将多个搜索条件连接在一起:\nSELECT * FROM single_table WHERE key2 \u0026gt; 100 OR key2 \u0026gt; 200 OR意味着需要取各个扫描区间的并集,取并集的过程如图所示:\n即,上面的查询语句在使用uk_key2索引执行查询时,对应的扫描区间就是**(100,+∞),形成扫描区间的边界条件就是key2 \u0026gt; 100**\n有的搜索条件不能生成合适的扫描区间的情况 # AND情况 # 有的搜索条件不能生成合适的扫描区间来减少需要扫描的记录数量\nSELECT * FROM single_table WHERE key2 \u0026gt; 100 AND common_field = \u0026#39;abc\u0026#39; 分析:使用uk_key2执行查询时,搜索条件key2\u0026gt;100可以形成扫描区间(100,+∞)。但是由于uk_key2的二级索引并不按照common_field列进行排序(uk_key2二级索引记录中压根儿不包含common_field列),所以仅凭搜索条件common_field = \u0026lsquo;abc\u0026rsquo;并不能减少需要扫描的二级索引记录数量。即该搜索条件生成的扫描区间其实是**(-∞,+∞)。由于这两个小的搜索条件是使用AND操作符连接,所以对(100,+∞)** 和 (-∞,+∞)这两个搜索区间取交集后得到的结果自然是**(100,+∞)。即使用uk_key2执行上述查询,最终对应的扫描区间就是(100,+∞),形成该扫描区间的条件就是key2\u0026gt;100\n简化:使用uk_key2执行查询时,在寻找对应的扫描区间**的过程中,搜索条件 common_field = \u0026lsquo;abc\u0026rsquo;没起到任何作用,我们可以直接把 common_field = \u0026lsquo;abc\u0026rsquo; 搜索条件替换为TRUE,(TRUE对应的扫描区间也是(-∞,+∞)),如下:\nSELECT * FROM single_table WHERE key2 \u0026gt; 100 AND TRUE # 简化之后 SELECT * FROM single_table WHERE key2 \u0026gt; 100 即上面的查询语句使用uk_key2执行查询时对应的扫描区间是**(100,+∞)**\nOR情况 # SELECT * FROM single_table WHERE key2 \u0026gt; 100 OR common_field = \u0026#39;abc\u0026#39; #替换之后 SELECT * FROM single_table WHERE key2 \u0026gt; 100 OR TRUE 所以,如果强制使用uk_key2执行查询,由于这两个小的搜索条件是使用OR操作符连接,所以对**(100,+∞)** 和 (-∞,+∞)这两个搜索区间取并集后得到的结果自然是**(-∞,+∞)。也就是需要扫描uk_key2的全部二级索引记录**,并且对于获取到的每一条二级索引记录,都需要执行回表操作。这个代价比执行全表扫描的代价都大。这种情况下,不考虑使用uk_key2来执行查询\n从复杂的搜索条件中找出扫描区间 # SELECT * FROM single_table WHERE (key1 \u0026gt; \u0026#39;xyz\u0026#39; AND key2 =748) OR (key1\u0026lt;\u0026#39;abc\u0026#39; AND key1 \u0026gt; \u0026#39;lmn\u0026#39;) OR (key1 LIKE \u0026#39;%suf\u0026#39; AND key1 \u0026gt; \u0026#39;zzz\u0026#39; AND (key2 \u0026lt; 8000 OR common_field = \u0026#39;abc\u0026#39;)) 分析:\n涉及到的列,以及为哪些列建立了索引\n设计key1,key2,common_field这三个列,其中key1列有普通二级索引idx_key1,key2列有唯一二级索引uk_key2 对于可能用到的索引,分析它们的扫描区间 假设使用idx_key1执行查询 # 把不能形成合适扫描区间的搜索条件暂时移除掉:直接替换为TRUE\n除了有关key2和common_field列的搜索条件不能形成合适的扫描区间,还有key1 LIKE \u0026lsquo;%suf\u0026rsquo;形成的扫描区间是(-∞,+∞),所以也需要替换成TRUE,这些不能形成合适扫描区间的搜索条件替换成TRUE之后,搜索条件如下所示:\nSELECT * FROM single_table WHERE (key1 \u0026gt; \u0026#39;xyz\u0026#39; AND TRUE) OR (key1\u0026lt;\u0026#39;abc\u0026#39; AND key1 \u0026gt; \u0026#39;lmn\u0026#39;) OR (TRUE AND key1 \u0026gt; \u0026#39;zzz\u0026#39; AND (TRUE OR TRUE)) #简化 SELECT * FROM single_table WHERE (key1 \u0026gt; \u0026#39;xyz\u0026#39; ) OR (key1\u0026lt;\u0026#39;abc\u0026#39; AND key1 \u0026gt; \u0026#39;lmn\u0026#39;) OR (key1 \u0026gt; \u0026#39;zzz\u0026#39; AND (TRUE OR TRUE) ) #进一步简化 SELECT * FROM single_table WHERE (key1 \u0026gt; \u0026#39;xyz\u0026#39; ) OR (key1\u0026lt;\u0026#39;abc\u0026#39; AND key1 \u0026gt; \u0026#39;lmn\u0026#39;) OR (key1 \u0026gt; \u0026#39;zzz\u0026#39;) #由于key1\u0026lt;\u0026#39;abc\u0026#39; AND key1 \u0026gt;\u0026#39;lmn\u0026#39; 永远为FALSE,所以进一步简化 SELECT * FROM single_table WHERE (key1 \u0026gt; \u0026#39;xyz\u0026#39; ) OR (key1 \u0026gt; \u0026#39;zzz\u0026#39;) #继续简化(取范围大的,即并集) SELECT * FROM single_table WHERE key1 \u0026gt; \u0026#39;xyz\u0026#39; 即如果使用idx_key1索引执行查询,则对应扫描区间为(\u0026lsquo;xyz\u0026rsquo;,+∞)。\n也就是需要把所有满足key1\u0026gt;\u0026lsquo;xyz\u0026rsquo;条件的所有二级索引记录都取出来,针对获取到的每一条二级索引记录,都要用它的主键值再执行回表操作,在得到完整的用户记录之后再使用其他的搜索条件进行过滤\n使用idx_key1执行上述查询时,搜索条件key1 LIKE %suf比较特殊,虽然不能作为形成扫描区间的边界条件,但是idx_key1的二级索引记录是包括key1列的,因此可以*先判断获取到的二级索引记录是否符合这个条件,如果符合再执行回表操作,如果不符合则不执行回表操作。这可减少因回表操作带来的性能损耗,这种优化方式称为索引条件下推\n假设使用idx_key2执行查询 # 对于:\nSELECT * FROM single_table WHERE (key1 \u0026gt; \u0026#39;xyz\u0026#39; AND key2 =748) OR (key1\u0026lt;\u0026#39;abc\u0026#39; AND key1 \u0026gt; \u0026#39;lmn\u0026#39;) OR (key1 LIKE \u0026#39;%suf\u0026#39; AND key1 \u0026gt; \u0026#39;zzz\u0026#39; AND (key2 \u0026lt; 8000 OR common_field = \u0026#39;abc\u0026#39;)) 简化\nSELECT * FROM single_table WHERE (TRUE AND key2 =748) OR (TRUE AND TRUE) OR (TRUE AND TRUE AND (key2 \u0026lt; 8000 OR TRUE)) #简化 key2 = 748 OR TRUE #进一步简化 TRUE 意味着如果要使用uk_key2索引执行查询,则对应的扫描区间就是**(-∞,+∞),即需要扫描uk_key2的全部二级索引记录,针对每一条二级索引记录还需要回表**,所以这种情况下不会使用uk_key2索引\n使用联合索引执行查询时对应的扫描区间 # 联合索引的索引包含多个列,B+树中的每一层页面以及每个页面中采用的排序规则较为复杂,以single_table表的idx_key_part联合索引为例,采用的排序规则如下所示:\n先按照key_part1列的值进行排序 在key_part1列的值相同的情况下,再按照key_part2列的值进行排序 在key_part1列和key_part2列值都相同的情况下,再按照key_part3列的值进行排序,画一下idx_key_part索引的示意图,如图所示:\n对于查询语句Q1(单条件) # SELECT * FROM single_table WHERE key_part1 = \u0026#39;a\u0026#39;; 由于二级索引记录是先按照key_part1列排序的,所以符合key_part1=\u0026lsquo;a\u0026rsquo;条件的所有记录肯定是相邻的。我们可以定位到符合key_part1=\u0026lsquo;a\u0026rsquo;条件的第一条记录,然后沿着记录所在的单向链表向后扫描(如果本页面中的记录扫描完了,就根据叶子节点的双向链表找到下一个页面中的第一条记录,继续沿着记录所在的单向链表向后扫描。我们之后就不强调叶子节点的双向链表了),直到某条记录不符合key_part=\u0026lsquo;a\u0026rsquo;条件为止(当然,对于获取到的每一条二级索引记录都要执行回表操作)。过程如图所示\n也就是说,如果使用idx_key_part索引执行查询语句Q1,对应的扫描区间是**[\u0026lsquo;a\u0026rsquo;,\u0026lsquo;a\u0026rsquo;],形成这个扫描区间的边界条件**就是key_part=\u0026lsquo;a\u0026rsquo;\n对于查询条件Q2(顺序2条件) # SELECT * FROM single_table WHERE key_part1=\u0026#39;a\u0026#39; AND key_part2=\u0026#39;b\u0026#39;; 由于二级索引记录是先按照key_part1列的值排序的, 在key_part1列的值相等的情况下再按照key_part2列进行排序,所以符合key_part1=\u0026lsquo;a\u0026rsquo; AND key_part2=\u0026lsquo;b\u0026rsquo;条件的二级索引记录肯定是相邻的。 我们可以定位到符合key_part1=\u0026lsquo;a\u0026rsquo; AND key_part2=\u0026lsquo;b\u0026rsquo;条件的第一条记录,然后沿着记录所在的单向链表向后扫描,直到某条记录不符合key_part1=\u0026lsquo;a\u0026rsquo;条件或者key_part2=\u0026lsquo;b\u0026rsquo;条件为止(当然,对于获取到的每一条二级索引记录都要执行回表操作,这里就不展示了) ,如图7-12 所示。也就是说,如果使用idx_key_part索引执行查询语句Q2 ,可以形成扫描区间**[(\u0026lsquo;a\u0026rsquo;,\u0026lsquo;b\u0026rsquo;),(\u0026lsquo;a\u0026rsquo;,\u0026lsquo;b\u0026rsquo;)]**,形成这个扫描区间的边界条件就是 key_part1=\u0026lsquo;a\u0026rsquo; AND key_part2=\u0026lsquo;b\u0026rsquo;\n[(\u0026lsquo;a\u0026rsquo;,\u0026lsquo;b\u0026rsquo;),(\u0026lsquo;a\u0026rsquo;,\u0026lsquo;b\u0026rsquo;)] 代表在idx_key_part索引中,从第一条符合key_part1=\u0026lsquo;a\u0026rsquo; AND key_part2=\u0026lsquo;b\u0026rsquo; 条件的记录开始,到最后一条符合key_part1=\u0026lsquo;a\u0026rsquo; AND key_part2=\u0026lsquo;b\u0026rsquo;条件的记录为止的所有二级索引记录。\n对于查询条件Q3(顺序3条件) # SELECT * FROM single_table WHERE key_part1=\u0026#39;a\u0026#39; AND key_part2=\u0026#39;b\u0026#39; AND key_part3=\u0026#39;c\u0026#39;; 由于二级索引记录是先按照 key_part1列的值排序的,在key_part1列的值相等的情况下再按照key_part2列进行排序:在key_part1和key_part2列的值都相等的情况下, 再按照key_part3列进行排序,所以符合key_part1=\u0026lsquo;a\u0026rsquo; AND key_part2=\u0026lsquo;b\u0026rsquo; AND key_part3=\u0026lsquo;c\u0026rsquo;条件的二级索引记录肯定是相邻的。\n我们可以定位到符合key_part1=\u0026lsquo;a\u0026rsquo; AND key_part2 = \u0026lsquo;b\u0026rsquo; AND key_part3=\u0026lsquo;c\u0026rsquo;条件的第一条记录,然后沿着记录所在的单向链表向后扫描,直到某条记录不符合key_part1=\u0026lsquo;a\u0026rsquo;条件或者key_part2=\u0026lsquo;b\u0026rsquo;条件或者key_part3条件为止(当然,对于获取到的每一条二级索引记录都要执行回表操作)。这里就不再画示意图了。\n如果使用idx_key_part索引执行查询语句Q3 ,可以形成扫描区间**[(\u0026lsquo;a\u0026rsquo;,\u0026lsquo;b\u0026rsquo;,\u0026lsquo;c\u0026rsquo;),(\u0026lsquo;a\u0026rsquo;,\u0026lsquo;b\u0026rsquo;,\u0026lsquo;c\u0026rsquo;)]** ,形成这个扫描区间的边界条件就是key_part1=\u0026lsquo;a\u0026rsquo; AND key_part2 = \u0026lsquo;b\u0026rsquo; AND key_part3=\u0026lsquo;c\u0026rsquo;\n对于查询语句Q4(单条件范围) # SELECT * FROM single_table WHERE key_part1 \u0026lt; \u0026#39;a\u0026#39;; 由于二级索引记录是先按照key_part1列的值进行排序的,所以符合key_part1\u0026lt;\u0026lsquo;a\u0026rsquo;条件的所有记录肯定是相邻的。我们可以定位到符合key_part1\u0026lt;\u0026lsquo;a\u0026rsquo;条件的第一条记录(其实就是idx_key_part 索引第一个叶子节点的第一条记录) ,然后沿着记录所在的单向链表向后扫描,直到某条记录不符合key_part1\u0026lt; \u0026lsquo;a\u0026rsquo; 条件为止(当然,对于获取到的每一条二级索引记录都要执行回表操作,这里就不展示了) ,如图7- 13 所示\n也就是说,如果使用idx_key_part索引执行查询语句Q4,可以形成扫描区间(-∞,\u0026lsquo;a\u0026rsquo;),形成这个扫描区间的边界条件就是key_part1\u0026lt;\u0026lsquo;a\u0026rsquo;\n查询语句Q5(条件1等值,条件2范围) # SELECT * FROM single_table WHERE key_part1=\u0026#39;a\u0026#39; AND key_part2 \u0026gt; \u0026#39;a\u0026#39; AND key_part2 \u0026lt; \u0026#39;d\u0026#39;; 由于二级索引记录是先按照key_part1列的值进行排序的,在key_part1列的值相等的情况下再按照key_part2列进行排序。也就是说,在符合key_part1=\u0026lsquo;a\u0026rsquo;条件的二级索引记录中,这些记录是按照key_part2 列的值排序的, 那么此时符合key_part1=\u0026lsquo;a\u0026rsquo; AND key_part2\u0026gt;\u0026lsquo;a\u0026rsquo; AND key_part2 \u0026lt; \u0026rsquo;d\u0026rsquo;条件的二级索引记录肯定是相邻的。我们可以定位到符合 key_part1=\u0026lsquo;a\u0026rsquo; AND key_part2\u0026gt;\u0026lsquo;a\u0026rsquo; AND key_part2 \u0026lt; \u0026rsquo;d\u0026rsquo;条件的第-条记录(其实第一条就是key_part1=\u0026lsquo;a\u0026rsquo; AND key_part2 = \u0026lsquo;a\u0026rsquo; ), 然后沿着记录所在的单向链表向后扫描, 直到某条记录不符合key_part1=\u0026lsquo;a\u0026rsquo; 或者key_part2\u0026gt;\u0026lsquo;a\u0026rsquo; 或者**key_part2 \u0026lt; \u0026rsquo;d\u0026rsquo;**条件为止(当然,对于获取到的每一条二级索引记录都要执行回表操作, 这里就不展示了) ,如图7- 1 4 所示\n也就是说,如果使用idx_key_part索引执行查询语句Q5,可以形成扫描区间**((\u0026lsquo;a\u0026rsquo;,\u0026lsquo;a\u0026rsquo;),(\u0026lsquo;a\u0026rsquo;,\u0026rsquo;d\u0026rsquo;)),形成这个扫描区间的边界条件就是key_part1=\u0026lsquo;a\u0026rsquo; AND key_part2\u0026gt;\u0026lsquo;a\u0026rsquo; AND key_part2\u0026lt;\u0026rsquo;d\u0026rsquo;**。\n查询语句Q6(条件2等值=\u0026gt;用不上索引) # 由于二级索引记录不是直接按照key_part2列的值排序的,所以符合key_part2列的二级索引记录可能并不相邻,也就意味着我们不能通过这个key_part2=\u0026lsquo;a\u0026rsquo; 搜索条件来减少需要扫描的记录数量。在这种情况下,我们是不会使用idx_key_part 索引执行查询的\n查询语句Q7(条件1等值,条件3等值=\u0026gt;只有前面的条件是边界条件) # SELECT * FROM single_table WHERE key_part=\u0026#39;a\u0026#39; AND key_part3=\u0026#39;c\u0026#39; 由于二级索引记录是先按照key_part1列的值排序的,所以符合key_part1=\u0026lsquo;a\u0026rsquo;条件的二级索引记录肯定是相邻的。但是对于符合key_part3 =\u0026lsquo;c\u0026rsquo;条件的二级索引记录来说,并不是直接按照key_part3列进行排序的,也就是说我们不能根据搜索条件key_part3=\u0026lsquo;c\u0026rsquo;来进一步减少需要扫描的记录数量。那么,如果使用idx_key_part 索引执行查询,可以定位到符合key_part1 = \u0026lsquo;a\u0026rsquo;条件的第一条记录,然后沿着记录所在的单向链表向后扫描,直到某条记录不符合key_part1 = \u0026lsquo;a\u0026rsquo;条件为止。所以在使用idx_key_part索引执行查询语句Q7 的过程中,对应的扫描区间其实是**[\u0026lsquo;a\u0026rsquo;,\u0026lsquo;a\u0026rsquo;],形成该扫描区间的边界条件是 key_part1=\u0026lsquo;a\u0026rsquo;,与key_part3=\u0026lsquo;c\u0026rsquo;**无关。\n索引条件下推特性,MySQL5.6中引入,默认开启\n针对获取到的每一条二级索引记录,如果没有开启索引条件下推特性,则必须先执行回表操作,在获取到完整的用户记录后再判断key_part3=\u0026lsquo;c\u0026rsquo;条件是否成立。如呆开启了索引条件下推特性,可以立即判断该二级索引记录是否符合key_part3=\u0026lsquo;c\u0026rsquo;条件。如果符合该条件,则再执行回表操作;如果不符合则不执行回农操作,直接跳到下一条二级索引记录。\n查询语句Q8(条件1范围,条件2等值=\u0026gt;只有前面的条件是边界条件) # SELECT * FROM single_table WHERE key_part \u0026lt; \u0026#39;b\u0026#39; AND key_part2=\u0026#39;a\u0026#39; 由于二级索引记录是先按照key_part1列的值排序的,所以符合key_part1\u0026lt;\u0026lsquo;b\u0026rsquo;条件的二级索引记录肯定是相邻的。但是对于符合key_part2 =\u0026lsquo;a\u0026rsquo;条件的二级索引记录来说,并不是直接按照key_part2列进行排序的,也就是说我们不能根据搜索条件key_part2=\u0026lsquo;a\u0026rsquo;来进一步减少需要扫描的记录数量。那么,如果使用idx_key_part 索引执行查询,可以定位到符合key_part1 \u0026lt; \u0026lsquo;b\u0026rsquo;\u0026lsquo;条件的第一条记录(其实就是idx_key_part索引的第一个叶子节点的第一条记录),然后沿着记录所在的单向链表向后扫描,直到某条记录不符合key_part1\u0026lt;\u0026lsquo;b\u0026rsquo;条件为止。如图:\n所以在使用idx_key_part索引执行查询语句Q8 的过程中,对应的扫锚区间其实是[- ∞,\u0026lsquo;b\u0026rsquo;],形成该扫描区间的边界条件是key_part1 \u0026lt; \u0026lsquo;b\u0026rsquo; , 与 key_part2=\u0026lsquo;a\u0026rsquo;无关\n查询语句Q9(条件1范围(包括等号),条件2等值) # SELECT * FROM single_table WHERE key_part1 \u0026lt;= \u0026#39;b\u0026#39; AND key_part2=\u0026#39;a\u0026#39; Q8和Q9很像,但是在涉及key_part1条件时,Q8中的条件是key_part1\u0026lt;\u0026lsquo;b\u0026rsquo;,Q9中的条件是key_part1\u0026lt;=\u0026lsquo;b\u0026rsquo;。很显然,符合key_part1=\u0026lsquo;b\u0026rsquo;条件的二级索引记录是相邻的。但是对于符合key_part1\u0026lt;=\u0026lsquo;b\u0026rsquo;条件的二级索引记录来说,并不是直接按照key_part2列排序的。但是,对于符合key_part1=\u0026lsquo;b\u0026rsquo;的二级索引记录来说,是按照key_part2列的值排序的。那么在确定需要扫描的二级索引记录的范围时,当二级索引记录的key_part1列值为\u0026rsquo;b\u0026rsquo; 时,也可以通过key_part2=\u0026lsquo;b\u0026rsquo; 条件减少需要扫描的二级索引记录范围。也就是说, 当扫描到不符合key_part1=\u0026lsquo;b\u0026rsquo; AND key_part2=\u0026lsquo;a\u0026rsquo; 条件的第一条记录时,就可以结束扫描,而不需要将所有key_part1列值为\u0026rsquo;b\u0026rsquo;的记录扫描完。\n注意,当扫描到记录的列key_part1值为b时,不能直接定位到**key_part2=\u0026lsquo;a\u0026rsquo;的数据了,但是可以扫描到key_part2=\u0026lsquo;a\u0026rsquo;**停止\n也就是说,如果使用idx_key_part索引执行查询语句Q9,可以形成扫描区间((-∞,-∞),(\u0026lsquo;b\u0026rsquo;,\u0026lsquo;a\u0026rsquo;)),形成这个扫描区间的边界条件就是key_part1\u0026lt;=\u0026lsquo;b\u0026rsquo; AND key_part2=\u0026lsquo;a\u0026rsquo;。而在执行查询语句Q8时,我们必须将所有符合**key_part1\u0026lt;\u0026lsquo;b\u0026rsquo;**的记录都扫描完,**key_part2=\u0026lsquo;a\u0026rsquo;**条件在查询语句Q8中并不能起到减少需要扫描的二级索引范围的作用\n注意,对于Q9,key_part1\u0026lt;\u0026lsquo;b\u0026rsquo;的记录也是要扫描完的。这里仅仅对key_part1=\u0026lsquo;b\u0026rsquo;起了减少扫描二级索引范围的作用。\n索引用于排序 # 我们在编写查询语句时,经常需要使用ORDERBY子句对查询出来的记录按照某种规则进行排序。在一般情况下,我们只能把记录加载到内存中,然后再用一些排序算法在内存中对这些记录进行排序。有时查询的结果集可能太大以至于无法在内存中进行排序,此时就需要暂时借助磁盘的空间来存放中间结果,在排序操作完成后再把排好序的结果集返回客户端。在MySQL 中,这种在内存或者磁盘中进行排序的方式统称为文件排序(fìlesort)。但是,如果ORDERBY子句中使用了索引列,就有可能省去在内存或磁盘中排序的步骤。\n举例:\nSELECT * FROM single_table ORDER BY key_part1,key_part2,key_part3 LIMIT 10; 这个查询语句的结果集需要先按照key_part1 值排序。如果记录的key_part1 值相同,再按照key_part2值排序,如果记录的key_part1 和key_part2值都相同,再按照key_part3 值排序。大家可以回过头去看看图7-10。\n该二级索引的记录本身就是按照上述规则排好序的,所以我们可 以从第一条idx_key_part二级索引记录开始,沿着记录所在的单向链表向后扫描,取10 条二级索引记录即可。当然,针对获取到的每一条二级索引记录都执行一次回表操作,在获取到完整的用户记录之后发送给客户端就好了。这样是不是就变得简单多了,还省去了我们给10000条记录排序的时间\u0026ndash;索引就是这么厉害!\n关于回表操作: 请注意,本例的查询语句中加了LIMIT 子句,这是因为如果不限制需要获取的记录数量,会导致为大量二级索引记录执行回表操作,这样会影响整体的查询性能。关于回表操作造成的影响,我们稍后再详细唠叨\n使用联合索引进行排序时的注意事项 # ORDER BY子句后面的列顺序也必须按照索引列的顺序给出\n如果给出ORDER BY key_part3,key_part2,key_part1的顺序,则无法使用B+树索引。\n如果是ORDER BY key_part1 DESC,key_part2 DESC,key_part3 DESC ,那么应该是可以的,也就是ORDER BY key_part1,key_part2,key_part3的全反序\n之所以颠倒的排序列顺序不能使用索引,原因还是联合索引中页面和记录的排序规则是固定的,也就是先按照key_part1值排序,如果key_part1值相同,再按照key_part2值排序;如果key_part1和key_part2值都相同,再按照key_part2值排序。\n如果ORDER BY子句的内容是ORDER BY key_part3 , key_part2 , key_part,那就要求先要key_part3值排序(升序),如果key_part3相同,再按key_part2升序,如果key_part3和key_part3都相同,再按照key_part1升序\n同理,这些仅对联合索引的索引列中左边连续的列进行排序的形式(如ORDER BY key_part1和ORDER BY key_part1,key_part2),也是可以利用B+树索引的。另外,当连续索引的索引列左边连续的列为常量时,也可以使用联合索引对右边的列进行排序\nSELECT * FROM single_table WHERE key_part1=\u0026#39;a\u0026#39; AND key_part2=\u0026#39;b\u0026#39; ORDER BY key_part3 LIMIT 10 能使用联合索引排序,原因是key_part1值为\u0026rsquo;a\u0026rsquo;、key_part2值为\u0026rsquo;b\u0026rsquo;的二级索引记录本身就是按照key_part3列的值进行排序的\n不能使用索引进行排序的几种情况 # ASC、DESC混用 # 我们要求各个排序列的排序顺序规则是一致的,要么各个列都是按照ASC(升序),要么都是按照DESC(降序)规则排序\n为什么呢:\nidx_key_part联合索引中的二级索引记录的排序规则:先key_part1升序,key_part1相同则key_part2升序,如果都相同则key_part3升序\n如果ORDER BY key_part1,key_part2 LIMIT10,那么直接从联合索引最左边的二级索引记录开始,向右读取10条即可\n如果ORDER BY key_part1 DESC,key_part2 DESC LIMIT 10,可以从联合索引最右边的那条二级索引记录开始,向左读10条\n注意,这里没有key_part3,也可以的。可以理解成,key_part3不要求排序。而按照key_part1 DESC,key_part2DESC顺序的记录一定是连续的\n如果是先key_part1列升序,再key_part2列降序,如:\nSELECT * FROM single_table ORDER BY key_part1,key_part2 DESC LIMIT 10; 此时联合索引的查询过程如下,算法较为复杂,不能高效地使用索引,所以这种情况下是不会使用联合索引执行排序操作的\nMySQL8.0引入了称为Descending Index的特性,支持ORDER BY 子句中ASC、DESC混用的情况\n排序列包含非同一个索引的列,这种情况也不能使用索引进行排序 # SELECT * FROM single_table ORDER BY key1,key2 LIMIT 10 对于idx_key1的二级索引来说,只按照key1列排序。且key1值相同的情况下是不按照key2列的值进行排序的,所以不能使用idx_key1索引执行上述查询\n排序列是某个联合索引的索引列,但是这些排序列再联合索引中并不连续 # SELECT * FROM single_table ORDER BY key_part1,key_part3 LIMIT 10; key_part1值相同的记录并不按照key_part3排序,所以不能使用idx_key_part执行上述查询\n用来形成扫描区间的索引列与排序列不同 # SELECT * FROM single_table WHERE key1=\u0026#39;a\u0026#39; ORDER BY key2 LIMIT 10; 如果使用key1=\u0026lsquo;1\u0026rsquo;作为边界条件来形成扫描区间,也就是再使用idx_key1执行该查询,仅需要扫描key1值为\u0026rsquo;a\u0026rsquo;的二级索引记录。此时无法使用uk_key2执行上述查询\n5:排序列不是以单独列名的形式出现在ORDER BY 子句中\n要想使用索引排序,必须保证索引列是以单独列名的形式(而不是修饰过):\nSELECT * FROM single_table ORDER BY UPPER(key1) LIMIT 10; 因为key1列以UPPER(key1)函数调用的形式出现在ORDER BY子句,所以不能使用idx_key1执行上述查询\n索引用于分组 # 为了方便统计,会把表中记录按照某些列进行分组,如:\nSELECT key_part1,key_part2,key_part3,COUNT(*) FROM single_table GROUP BY key_part1,key_part2,key_part3; 对这些小分组进行统计,上面的查询,即统计每个小小分组包含的记录条数。\n如果没有idx_key_part索引,就得建立一个用于统计的临时表,在扫描聚簇索引的记录时将统计的中间结果填入这个临时表。当扫描完记录后, 再把临时表中的结果作为结果集发送给客户端。 如果有了索引idx_key_part ,恰巧这个分组顺序又与idx_key_part 的索引列的顺序是一致的,而idx_key_part 的二级索引记录又是按照索引列的值排好序的,这就正好了。所以可以直接使用idx_key_part 索引进行分组,而不用再建立临时表了 与使用B+ 树索引进行排序差不多, 分组列的顺序也需要与索引列的顺序一致,也可以只使用索引列中左边连续的列迸行分组\n如上,就是统计 (\u0026lsquo;0\u0026rsquo;,\u0026lsquo;0\u0026rsquo;,\u0026lsquo;0\u0026rsquo;)的有几条, (\u0026lsquo;0\u0026rsquo;,\u0026lsquo;a\u0026rsquo;,\u0026lsquo;a\u0026rsquo;)的有几条, (\u0026lsquo;0\u0026rsquo;,\u0026lsquo;a\u0026rsquo;,\u0026lsquo;b\u0026rsquo;)的有几条等\n回表的代价 # SELECT * FROM single_table WHERE key1 \u0026gt; 'a' AND key1 \u0026lt; 'c'\n有两种方式来执行上面语句\n以全表扫描的方式 # 直接扫描全部的聚簇索引记录, 针对每一条聚簇索引记录,都判断搜索条件是否成立, 如果成立则发送到客户端, 否则跳过该记录.\n使用idx_key1执行该查询 # 可以根据搜索条件key1 \u0026gt; \u0026lsquo;a\u0026rsquo; AND key1 \u0026lt; \u0026lsquo;c\u0026rsquo; 得到对应的扫描区间( \u0026lsquo;a\u0026rsquo;,\u0026lsquo;c\u0026rsquo; ),然后扫描该扫描区间中的二级索引记录。由于idx_key1索引的叶子节点存储的是不完整的用户记录,仅包含key1 、id 这两个列,而查询列表是*, 这意味着我们需要获取每条二级索引记录对应的聚簇索引记录, 也就是执行回表操作,在获取到完整的用户记录后再发送到客户端。\n分析 # 对于使用InnoDB 存储引擎的表来说, 索引中的数据页都必须存放在磁盘中, 等到需要时再加载到内存中使用。这些数据页会被存放到磁盘中的一个或者多个文件中, 页面的页号对应着该页在磁盘文件中的偏移量。以16KB大小的页面为例,页号为0 的页面对应着这些文件中偏移量为0 的位置,页号为1的页面对应着这些文件中偏移量为16KB 的位置。前面章节讲过, B+ 树的每层节点会使用双向链表连接起来, 上一个节点和下一个节点的页号可以不必相邻。\n不过在实际实现中, 设计Inno DB 的大叔还是尽量让同一个索引的叶子节点的页号按照顺序排列,这一点会在稍后讨论表空间时再详细嘴叨\n也就是说,idx_key1在扫描区间( \u0026lsquo;a\u0026rsquo;, \u0026lsquo;c\u0026rsquo; )中的二级索引记录所在的页面的页号会尽可能相邻\n即使这些页面的页号不相邻, 但起码一个页可以存放很多记录,也就是说在执行完一次页面I/O 后,就可以把很多二级索引记录从磁盘加载到内存中。 总而言之,就是读取在扫描区间( \u0026lsquo;a\u0026rsquo;, \u0026lsquo;c\u0026rsquo; ) 中 的二级索引记录时,所付出的代价还是较小的。不过扫描区间( \u0026lsquo;a\u0026rsquo;, \u0026lsquo;c\u0026rsquo; )中的二级索引记录对应 的id 值的大小是毫无规律的, 我们每读取一条二级索引记录,就需要根据该二级索引记录的id 值到聚簇索引中执行回表操作。如果对应的聚簇索引记录所在的页面不在内存中,就需要将该 页面从磁盘加载到内存中.。由于要读取很多id 值并不连续的聚簇索引记录,而且这些聚簇索引 记录分布在不同的数据页中, 这些数据页的页号也毫无规律,因此会造成大量的随机I/O . 需要执行回表操作的记录越多, 使用二级索引进行查询的性能也就越低,某些查询宁愿使 用全表扫描也不使用二级索引。比如, 假设key1值在\u0026rsquo;a\u0026rsquo;~\u0026lsquo;c\u0026rsquo; 之间的用户记录数量****占全部记录** 数量的99%** 以上,如果使用idx_key1索引,则会有99% 以上的id 值需要执行回表操作。这 不是吃力不讨好么, 还不如直接执行全表扫描\n什么时候采用全表扫描, 什么时候使用二级索引+回表的方式 # 这是查询优化器应该做的工作:\n查询优化器会事先针对表中的记录计算一些统计数据,然后再利用这些统计数据或者访问表中的少量记录来计算需要执行回表操作的记录数,如果需要执行回表操作的记录数越多,就越倾向于使用全表扫描, 反之则倾向于使用二级索引+回表的方式。当然,查询优化器所做的分析工作没有这么简单, 但大致上是这样一个过程。\n一般情况下,可以给查询语句指定LIMIT 子句来限制查询返回的记录数, 这可能会让查 询优化器倾向于选择使用二级索引+回表的方式进行查询, 原因是回表的记录越少, 性能提升 就越高。比如,上面的查询语句可以改写成下面这样\nSELECT * FROM single_table WHERE key1 \u0026gt; 'a' AND key1\u0026lt;'c' LIMIT 10\n添加了LIMlT10 子句后的查询语句更容易让查询优化器采用二级索引+回表的方式来执行。 对于需要对结果进行排序的查询,如果在采用二级索引执行查询时需要执行回表操作的记 录特别多,也倾向于使用全表扫描+文件排序的方式执行查询。比如下面这个查询语句 SELECT * FROM single_table ORDER BY key1 由于查询列表是 *,如果使用二级索引进行排序,则需要对所有二级索引记录执行回表操作. 这样操作的成本还不如直接遍历聚簇索引然后再进行文件排序低, 所以查询优化器会倾向于使 用全表扫描的方式执行查询。如果添加了LIMIT子句,比如下面这个查询语句:\nSELECT * FROM single_table ORDER BY key1 LIMIT 10; 这个查询语句需要执行回表操作的记录特别少,查询优化器就会倾向于使用二级索引+回表的 方式来执行\n更好地创建和使用索引 # 只为用于搜索、排序或分组的列创建索引 # SELECT common_field,key_part3 FROM single_table WHERE key1= \u0026#39;a\u0026#39;; 没必要为common_field,key_part3创建索引\n考虑索引列中不重复值的个数 # 前文在唠叨回表的知识时提提到, 在通过二级索引+回表的方式执行查询时,某个扫描区间中包含的二级索引记录数量越多, 就会导致回表操作的代价越大。我们在为某个列创建索引时,需要考虑该列中不重复值的个数占全部记录条数的比例。如果比例太低,则说明该列包含 过多重复值,那么在通过二级索引+回表的方式执行查询时,就有可能执行太多次回表操作\n索引列的类型尽量小 # 在定义表结构时,要显式地指定列的类型 以整数类型为例, 有 TINIINT、MEDIUMINT、INT、BIGINT这几种,它们占用的存储空间的大小依次递增。下面所说的类型大小指的就是该类型占用的存储空间的大小。刚才提到的这几个整数类型,它们能表示的整数范围当然也是依次递增。如果想要对某个整数类型的列建立索引,在表示的整数范围允许的情况下,尽量让索引列使用较小的类型,比如能使用INT就不要使用BIGINT。 能使用MEDIUMINT 就不要使用的INT。 因为数据类型越小, 索引占用的存储空间就越少,在一个数据页内就可以存放更多的记录,磁盘1/0 带来的性能损耗也就越小(一次页面I/O 可以将更多的记录加载到内存中) 读写效率也就越高 这个建议对于表的主键来说更加适用,因为不仅聚簇索引会存储主键值,其他所有的二级索引的节点都会存储一份记录的主键值。如果主键使用更小的数据类型,也就意味着能节省更多的存储空间\n为列前缀建立索引 # 我们知道,一个字符串其实是由若干个字符组成的。如果在MySQL 中使用utf8 字符集存储字符串,则需要1 - 3 字节来编码一个字符。假如字符串很长,那么在存储这个字符串时就需要占用很大的存储空间。在需要为这个字符串所在的列建立索引时,就意味着在对应的B+ 树中的记录中, 需要把该列的完整字符串存储起来。字符串越长,在索引中占用的存储空间越大。 前文说过, 索引列的字符串前缀其实也是排好序的,所以索引的设计人员提出了一个方案。 只将字符串的前几个字符存放到索引中,也就是说在二级索引的记录中只保留字符串的前几个字符。比如我们可以这样修改idx_key1索引,让索引中只保留字符串的前10个字符:\nALTER TABLE single_table DROP INDEX idx_key1; ALTER TABLE single_table ADD INDEX idx_key1(key1(10)); 再执行下面的语句\nSELECT * FROM single_table WHERE key1= \u0026#39;abcdefghijklmn\u0026#39; 由于在idx_key1 的二级索引记录中只保留字符串的前10 个字符,所以我们只能定位到前缀为\u0026rsquo;abcdefghij\u0026rsquo; 的二级索引记录,在扫描这些二级索引记录时再判断它们是否满足key1=\u0026lsquo;abcdefghijklmn\u0026rsquo; 条件。当列中存储的字符串包含的字符较多时,这种为列前缀建立索引的方式可以明显减少索引大小。\n注意,上面说的是扫描这些二级索引记录,是“些”。 可以减少索引大小,但不一样减少索引数量。如果有重复的照样会在索引中出现,因为不是UNIQUE约束。二级索引值大小相同时,会按照聚簇索引大小排列 不过,在只对列前缀建立索引的情况下, 下面这个查询语句就不能使用索引来完成排序需求了: SELECT * FROM single_table ORDER BY key1 LIMIT 10;\n因为二级索引idx_key1中不包含完整的key1列信息,所以在仅使用idx_key1索引执行查询时,无法对key1 列前10 个字符相同但其余字符不同的记录进行排序。也就是说,只为列前缀建立索引的方式无法支持使用索引进行排序的需求。上述查询语句只好乖乖地使用全表扫描+文件排序的方式来执行了。\n只为列前缀创建索引的过程我们就介绍完了,还是将idx_key1 改回原来的样式:\nALTER TABLE single_table DROP INDEX idx_key1; ALTER TABLE single_table ADD INDEX idx_key1(key1); 覆盖索引 # 为了彻底告别回表操作带来的性能损耗,建议最好在查询列表中只包含索引列,比如这个查询语句:\nSELECT key1,id FROM single_table WHERE key1 \u0026gt; 'a' AND key1 \u0026lt; 'c'\n由于只查询key1列和id列的值,这里使用idx_key1索引来扫描(\u0026lsquo;a\u0026rsquo;,\u0026lsquo;c\u0026rsquo;)区间的二级索引记录时,可以直接从获取到的二级索引记录中读出key1列和id列的值,不需要通过id值到聚簇索引执行回表,省去回表操作带来的性能损耗。\n把这种已经包含所有需要读取的列的查询方式称为覆盖索引\n排序操作也优先使用覆盖索引进行查询:\nSELECT * FROM single_table ORDER BY key1 虽然这个查询语句中没有LIMIT子旬,但是由于可以采用覆盖索引,所以查询优化器会直接使用idx_key1索引进行排序 而不需要执行回表操作。 当然,如果业务需要查询索引列以外的列,那还是以保证业务需求为重。如无必要, 最好仅把业务中需要的列放在查询列表中,而不是简单地以*替代\n让索引列以列名的形式在搜索条件中单独出现 # 注意,是单独\n如下面两个语义一样的搜索条件\nSELECT * FROM single_table WHERE key2 * 2 \u0026lt; 4; SELECT * FROM single_table WHERE key2 \u0026lt; 4/2; 在第一个查询语句的搜索条件中, key2列并不是以单独列名的形式出现的,而是以key2 * 2这样的表达式的形式出现的。 MySQL 并不会尝试简化key2*2\u0026lt;4 表达式,而是直接认为这个搜索条件不能形成合适的扫描区间来减少需要扫描的记录数量,所以该查询语句只能以全表扫锚的方式来执行。 在第二个查询语句的搜索条件中, key2 列是以单独列名的形式出现的, MySQL 可以分析出如果使用uk_key2 执行查询,对应的扫描区间就是(-∞,2) ,这可以减少需要扫描的记录数量。 所以MySQL 可能使用uk_key2 来执行查询。 所以,如果想让某个查询使用索引来执行,请让索引列以列名的形式单独出现在搜索条件中 新插入记录时主键大小对效率的影响 # 我们知道,对于一个使用lnnoDB 存储引擎的表来说,在没有显式创建索引时, 表中的数据实际上存储在聚簇索引的叶子节点中,而且B+ 树的每一层数据页以及页面中的记录都是按照主键值从小到大的顺序排序的。如果新插入记录的主键值是依次增大的话,则每插满一个数据页就换到下一个数据页继续插入。如果新插入记录的主键值忽大忽小,就比较麻烦了\n假设某个数据页存储的聚簇索引记录已经满了, 它存储的主键值在1 - 100之间,如图:\n此时,如果再插入一条主键值为8的记录,则它插入的位置如图:\n可这个数据页已经满了啊, 新记录该插入到哪里呢?我们需要把当前页面分裂成两个页面, 把本页中的一些记录移动到新创建的页中。页面分裂意味着什么?意味着性能损耗!所以, 如果想尽量避免这种无谓的性能损耗,最好让插入记录的主键值依次递增。就像single_table的主键id 列具有AUTO_INCREMENT 属性那样。 MySQL 会自动为新插入的记录生成递增的主键值\n冗余和重复索引 # 针对single_table 表, 可以单独针对key_part1列建立一个idx_key_part1索引\nALTER TABLE single_table ADD INDEX idx_key_part(key_part1); 其实现在我们已经有了一个针对key_part1、key_part2 、key_part3列建立的联合索引idx_key_part。idx_key_part索引的二级索引记录本身就是按照key_part1 列的值排序的, 此时再单独为key_part1列建立一个索引其实是没有必要的。我们可以把这个新建的idx_key_part1索引看作是一个冗余索引, 该冗余索引是没有必要的\n有时,我们可能会对同一个列创建多个索引,比如这两个添加索引的语句:\nALTER TABLE single_table ADD UNIQUE KEY uk_id(id); ALTER TABLE single_table ADD INDEX idx_id(id); 我们针对id 列又建立了一个唯一二级索引uk_id,. 还建立了一个普通二级索引idx_id。 可是id 列本身就是single_table 表的主键, InnoDB 自动为该列建立了聚簇索引, 此时uk_id 和idx_id 就是重复的,这种重复索引应该避免\n"},{"id":228,"href":"/zh/docs/technology/MySQL/_how_mysql_run_/06/","title":"06B+树索引","section":"_MySQL是怎样运行的_","content":" 学习《MySQL是怎样运行的》,感谢作者!\n概述 # 数据页由7个组成部分,各个数据页可以组成一个双向链表,每个数据页中的记录会按照主键值从小到大的顺序组成一个单向链表。每个数据页都会为它里面的记录生成一个页目录,在通过主键查找某条记录的时候可以在页目录中使用二分法快速定位到对应的槽,然后再遍历该槽对应分组中的记录即可快速找到指定的记录。页和记录的关系\n页a,页b 可以不在物理结构上相连,只要通过双向链表相关联即可\n没有索引时进行查找 # 假设我们要搜索某个列等于某个常数的情况:\nSELECT [查询列表] FROM 表名 WHERE 列名 = xxx\n在一个页中查找 # 假设记录极少,所有记录可以存放到一个页中\n以主键位搜索条件:页目录中使用二分法快速定位到对应的槽,然后在遍历槽对应分组中的记录,即可快速找到指定记录 其他列作为搜索条件:对于非主键,数据页没有为非主键列建立所谓的页目录,所以无法通过二分法快速定位相应的槽。只能从Infimum依次遍历单向链表中的每条记录,然后对比,效率极低 在很多页中查找 # 两个步骤:\n定位到记录所在的页 从所在页内查找相应的记录 没有索引情况下,不能快速定位到所在页,只能从第一页沿着双向链表一直往下找,而如果是主键,每一页则可以在页目录二分查找。\n不过由于要遍历所有页,所以超级耗时\n索引 # #例子 mysql\u0026gt; CREATE TABLE index_demo( c1 INT, c2 INT, c3 CHAR(1), PRIMARY KEY(c1) ) ROW_FORMAT=COMPACT; 完整的行格式\n简化的行格式\nrecord_type:记录头信息的一项属性,表示记录的类型。0:普通记录,2:Infimum记录,3:Supremum记录,1还没用过等会再说 next_record:记录头信息的一项属性,表示从当前记录的真实数据到下一条记录真实数据的距离 各个列的值:这里只展示在index_demo表中的3个列,分别是c1、c2、c3 其他信息:包括隐藏列及记录的额外信息 改为竖着查看:\n上面图6-4的箭头其实有一点点出入,应该是指向z真实数据第1列那个位置,如下 一个简单的索引方案 # 思考:在根据某个条件查找一些记录,为什么要遍历所有的数据页呢?因为各个页中的记录没有规律,不知道搜索条件会匹配哪些页\n思路:为快速定位记录所在的数据页而建立一个别的目录\n有序 # 下一个数据页中用户记录的主键值必须大于上一页用户记录的主键值\n假设一页只能存放3条记录\n#插入3条记录 mysql\u0026gt; INSERT INTO index_demo VALUES(1,4,\u0026#39;u\u0026#39;),(3,9,\u0026#39;d\u0026#39;),(5,3,\u0026#39;y\u0026#39;); Query OK, 3 rows affected (0.02 sec) Records: 3 Duplicates: 0 Warnings: 0 此时页的情况\n记录组成了单链表\n再插入一条记录\nmysql\u0026gt; INSERT INTO index_demo VALUES(4,4,\u0026#39;a\u0026#39;); Query OK, 1 row affected (0.01 sec) (注意,页之间可能不是连续的)\n由于页10中最大记录是5,而页28中有一条记录是4,因为5\u0026gt;4,不符合下一个数据页中用户记录的主键值必须大于上一页中用户记录的主键值,所以在插入主键值为4的记录时需要伴随着一次记录移动,也就是把5的记录移动到页28中,再把主键值4的记录插入到页10中\n这个过程表明,在对页中的记录进行增删改操作的过程中,我们必须通过一些诸如记录移动的操作来始终保证这个状态一直成立:下一个数据页中用户记录的主键值必须大于上一个页中用户记录的主键值。这个过程也可以称为页分裂。\n给所有的页建立一个目录项 # 前提:index_demo表中有多条记录的效果\n1页有16KB,这些页在磁盘上可能并不连续,要想从这么多页中根据主键值快速定位某些记录所在的页,需要给他们编制一个目录,每个页对应一个目录项,每个目录项包括两部分:\n页的用户记录中最小的主键值,用key表示 页号,page_no表示 假设我们此时把目录项在物理存储器上连续存储,比如放到数组中,此时就可以根据主键值快速查找某条记录\n先用二分法快速定位主键20的记录在目录项3中(因为12\u0026lt;20\u0026lt;209),对应页是9\n根据前面说的方式在页9中定位具体记录\n先在页目录中二分查找,找到对应的组后沿链表遍历\n这个目录项的别名:索引\nInnoDB中的索引方案 # 上述方案的问题 # InnoDB使用页作为管理存储空间的基本单位,即只能保证16KB的连续存储空间,如果记录非常多,则需要的连续存储空间就非常大 增删改是很频繁的,如果页28的记录全部移除,那么目录项2就没有出现的必要,即要删除目录项2,那么所有的目录项都需要左移/或者不移动,作为冗余放到目录项列表中,浪费空间 方案 # 复用之前存储用户记录的数据页来存储目录项,用了和用户记录进行区分,把这些用来表示目录项的记录称为目录项记录。如何区分一条记录是普通用户记录,还是目录项记录:使用记录头信息中的record_type属性\n0:普通用户记录 1:目录项记录 2:Infimum记录 3:Supremum记录 将目录项放到数据页中\n新分配了一个编号为30的页来专门存储目录项记录\n目录项记录和普通的用户记录的不同点\n目录项记录的record_type值为1,普通用户记录record_type值为0\n目录项记录只有主键值和页的编号这两个列,而普通用户记录的列是用户自己定义的,可能包含许多列,另外还有InnoDB自己添加的隐藏列\n记录头信息中有一个名为min_rec_flag的属性,只有目录项记录的min_rec_flag属性才可能为1,普通记录的min_rec_flag属性都是0\nB+ 树中每层非叶子节点中的最小的目录项记录都会添加该标记\n其他:\n它们用的是一样的数据页(页面类型都是Ox45BF ,这个属性在File Header 中);页的组成结构也是一样的〈就是我们前面介绍过的7 个部分);都会为主键值生成Page Directory(页目录)从而在按照主键值进行查找时可以使用 二分法来加快查询速度。\n举例 # 单个 目录项记录页 # 其中,页30中存储的主键值分别为1,5,12,209\n假设我们现在要查找主键值为20的记录:\n先到存储目录项记录的页(这里是页30)中,(由于有页目录)通过二分法快速定位到对应的目录项记录,因为12\u0026lt;20\u0026lt;209,所以定位到对应的用户记录所在的页就是页9 再到存储用户记录的页9中根据二分法(由于有页目录))快速定位到主键值为20的用户记录 目录项记录中只存储主键值和对应的页号,存储空间极小,但一个页只有16KB,存放的目录项记录有限。如果表中数据太多(页太多),以至于一个数据页不足以存放所有的目录项记录\n多个 目录项记录的页 # 解决方案:新增一个存储目录项记录的页\n此时再进行查找\n确定存储目录项记录的页\n现在存储目录项记录的页有2个,即页30和页32。又因为页30表示的目录项记录主键值范围是**[1,320),页32表示的目录项记录主键值范围 \u0026gt; 320。所以确定主键值为20的记录对应的目录项记录**在页30中 按照单个 目录项记录页的方案查找 多个目录项记录页 # 如果数据再增加,则再生成存储更高级目录项记录的数据页\n无论是存放用户记录的数据页,还是存放目录项记录的数据页,都放到B+树数据结构中,我们也将这些数据页称为B+树的节点\n如图,我们真正的用户记录其实都存放在B+树最底层的节点上,这些节点也称为叶子节点或页节点,其余用来存放目录项记录的节点称为非叶子节点或者内节点,其中B+树最上边的那个节点也称为根节点 这里我们规定,最下面那层(存放用户记录的那层)为0层,之后层级依次往上加。\n这里我们假设所有存放用户记录的叶子节点所代表的数据页可以存放100条用户记录(16KB=16 * 1024 ≈10000 字节,差不多一条记录100字节),假设所有存放目录项记录的内节点所代表的数据页可以存放1000条目录项记录(10000字节,假设1个目录项10字节),那么如果\n如果B+树有1层,那么只有一个用于存放用户记录的节点,那么能存放100条用户记录(1百) 如果B+树有2层,那么能存放 1000 * 100=100,000条用户记录(10万) 如果B+树有3层,那么能存放 1000 * 1000 * 100=100,000,000条用户记录(1亿) 如果B+树有4层,那么能存放 1000 * 1000 * 1000 * 100=100,000,000,000条用户记录 (1000亿) 所以一般情况下,我们用到的B+树不会超过4层。\n当我们要通过主键值查找**某条记录 **\n最多只需要进行4个页面内的查找(查找3个存储目录项记录的页和1个存储用户记录的页) 每个页面内存在PageDirectory(页目录),所以在页面内也可以通过二分法快速定位记录 PageHeader中,有一个名为PAGE_LEVEL的属性,代表着这个数据页作为节点在B+树中的层级\n聚簇索引 # 前面介绍的B+树本身就是一个记录,或者说本身就是一个索引,有以下两个特点\n使用记录主键值的大小进行记录和页的排序 页(包括叶子节点和内节点)内的记录,按照主键大小顺序排成一个单向链表,页内的记录被划分成若干个组,每个组中主键值最大的记录在页内的偏移量会被当作槽一次存放在页目录中(Supremum记录比任何用户记录都大)之后可以在页目录中通过二分法快速定位到主键列等于某个值的记录 各个存放用户记录的页也是根据页中用户记录的主键大小顺序排成一个双向链表 存放目录项记录的页分为不同的层级,在同一层级中也是根据页目录项记录的主键大小顺序排成一个双向链表 B+树的叶子节点存储的是完整的用户记录(指的是这个记录中存储了所有列的值(包括隐藏列)) 具有上面两个特点的B+树称为聚簇索引。所有完整的用户记录都存放在这个聚簇索引的叶子节点处。这种聚簇索引,不需要我们在MySQL语句中显示使用INDEX语句去创建,InnoDB会自动为我们创建聚簇索引\nInnoDB存储引擎中,聚簇索引就是数据的存储方式(所有的用户记录都存储在了叶子节点)。索引即数据,数据即索引\n二级索引 # 聚簇索引只能在搜索条件是主键值时才发挥作用,如果要以别的列作为搜索条件,可以多建几颗B+树,而且不同B+树中的数据,采用不同的排序规则\n比如用c2列的大小作为数据页、页中记录的排序规则,再建一颗B+树\n\u0026ldquo;前言:c1已经是主键了\u0026rdquo;\n#例子 mysql\u0026gt; CREATE TABLE index_demo( c1 INT, c2 INT, c3 CHAR(1), PRIMARY KEY(c1) ) ROW_FORMAT=COMPACT; 下面是聚簇索引特点:\n二级索引说明:\n使用记录c2列的大小进行记录和页的排序 页(包括叶子节点和内节点)内的记录,按照c2列大小顺序排成一个单向链表,页内的记录被划分成若干个组,每个组中c2列值最大的记录在页内的偏移量会被当作槽一次存放在页目录中(Supremum记录比任何用户记录都大)之后可以在页目录中通过二分法快速定位到c2列值等于某个值的记录 各个存放用户记录的页也是根据页中用户记录的c2列大小顺序排成一个双向链表 存放目录项记录的页分为不同的层级,在同一层级中也是根据页目录项记录的c2列大小顺序排成一个双向链表 B+树的叶子节点存储的是并不是完整的用户记录,而只是c2列+主键这两个列的值 目录项记录中不再是主键+页号的匹配,而变成了c2列+页号的搭配 B+树如下\n举例 # 假设要查找c2=4的记录,可以使用上面的B+树。由于c2没有唯一性约束,所以可能会有很多条:我们只需要在该B+树的叶子节点处定位到第一条满足搜索条件c2=4的那条,然后由记录组成的单向链表一直向后扫描即可。另外,各个叶子节点组成了双向链表,搜索完了本页面的记录后可以顺利跳到下一个页面中的第一条记录,然后沿着记录组成的单向链表向后扫描\n查找过程 # 确定第一条符合c2=4条件的目录项记录所在的页\n根据**根页面(44)**可以快速定位到第一条符合c2=4条件的目录项记录所在页为页42(因为2\u0026lt;4\u0026lt;9)\n通过第一条符合c2=4条件的目录项记录所在的页面确定第一条符合c2=4条件的用户记录所在的页\n根据页42可以快速定位(通过页目录)到第一条符合条件的用户记录所在页为34或35,因为2\u0026lt;4\u0026lt;=4\n在真正存储第一条符合c2=4条件的用户记录的页中定位到具体的记录\n页34和页35中定位到具体的用户记录(如果页34使用页目录定位到第一条符合条件的用户记录,就不需要再到35中去再定位,因为直接一直往后查找到不等的记录即可)\n由于这个B+树的叶子节点的记录只存储了c2和c1(即主键)两个列。在叶子节点定位到第一条符合条件的那条用户记录之后,我们需要根据该纪录中的主键信息,到聚簇索引中查找到完整的用户记录,这个通过携带主键信息到聚簇索引中重新定位完整的用户记录的过程也称为回表 。\n然后再返回到这棵B+ 树的叶子节点处,找到刚才定位到的符合条件的那条用户记录,并沿着记录组成的单向链表向后继续搜索其他也满足c2=4的记录**,每找到一条的话就继续进行回表操作。重复这个过程,直到下一条记录不满足c2 =4**的这个条件为止.\n如果把完整的用户记录放到叶子节点是可以不用回表,但是太占地方了\n因为这种以非主键列的大小为排序规则而建立的B+ 树需要执行回表操作才可以定位到完整的用户记录,所以这种B+ 树也称为二级索引(Secondary Index) 或辅助索引。由于我们是以c2 列的大小作为B+ 树的排序规则,所以我们也称这棵B+ 树为为c2 列建立的索引,把c2列称为索引列。二级索引记录和聚簇索引记录使用的是一样的记录行格式,只不过二级索引记录存储的列不像聚簇索引记录那么完整。\n把聚簇索引或者二级索引的叶子节点中的记录称为用户记录。为了区分,也把聚簇索引叶子节点中的记录称为完整的用户记录,把二级索引叶子节点中的记录称为不完整的用户记录\n如果为一个存储字符串的列建立索引,别忘了前面说的字符集和比较规则,字符串也是可以比较大小的\n联合索引 # 同时以多个列的大小作为排序规则,也就是同时为多个列建立索引,含义:\n先把各个记录和页按照c2列进行排序 记录的c2列相同的情况下,再采用c3进行排序 每条目录项记录都由c2列、c3列、页号这3个部分组成。各条记录先按照c2列的值进行排序。如果记录的c2列相同,则按照c3列的值进行排序\n这里说的是极特殊的情况,也就是c2列相同的记录有很多很多条,导致好几个页都有c2 = x的记录,而且其中c3列的值还不同,那么就会出现目录项记录页中的目录项c2相同而c3不相同\nB+树叶子节点处的用户记录由c2列、c3列、和主键c1列组成\n以c2 和c3 列的大小为排序规则建立的B+ 树称为联合索引,也称为复合索 引或多列索引。它本质上也是一个二级索引,它的索引列包括c2、c3.需要注意的是\u0026quot;以c2和c3列的大小为排序规则建立联合索引\u0026ldquo;和\u0026rdquo;分别为c2和d 列建立索引\u0026quot; 的表述是不同的, 不同点如下:\n建立联合索引只会建立如图6-15 所示的一棵B+ 树 为c2 和c3 列分别建立索引时,则会分别以c2 和c3 列的大小为排序规则建立两棵B+ 树 Inno中B+树索引的注意事项 # 根页面万年不动窝 # 前面为了理解方便,我们先把存储用户记录的叶子节点都画出来,然后再画出存储目录项记录的内节点。而实际上是这样的:\n每当为某个表创建一个B+树索引(聚簇索引不是人为创建的,默认就存在)时,都会为这个索引创建一个根节点页面。\n一开始表中没有数据的时候,每个B+树索引对应的根节点中既没有用户记录,也没有目录项记录 随后向表中插入用户记录时,先把用户记录存储到这个根节点中 当根节点可用空间用完时,继续插入记录,此时会将根节点中的所有记录复制到一个新分配的页(比如页a)中,然后对这个新页进行页分裂操作,得到另一个新页(比如页b)[因为一个页放不下,所以还要这个新页]。这时新插入的记录会根据键值(也就是聚簇索引中的主键值,或者二级索引中对应的索引列的值)的大小分配到页a或者页b。根节点此时,便升级为存储目录项记录的页,也就需要把页a和页b对应的目录项记录插入到根节点中 在这个过程中,需要特别注意的是, 一个B+ 树索引的根节点自创建之日起便不会再移动(也就是页号不再改变)。\n由于这个特性,只要我们对某个表建立一个索引,那么它的根节点的页号便会被记录到某个地方,后续凡是InnoDB引擎需要用到这个索引,会从那个固定的地方取出根节点的页号,从而访问这个索引\n\u0026ldquo;存储某个索引的根节点在哪个页面中\u0026rdquo;,就是传说中的数据字典中的一项信息\n这里还有一个问题,书上没说,就是根节点作为a,b页的存储目录项记录的页,一旦后面页越来越多,根节点放不下了,接下来\n我猜是这样的,也是再新分配一个页X,然后对页X页分裂,得到页Y。把根节点此时的所有目录项全复制到页X,然后新插入的目录项记录根据键值分配到页X,或Y,然后根节点又变为存储目录项记录的页\n内节点中目录项记录的唯一性 # 目前为止,我们说B+树索引的内节点中,目录项记录的内容是索引列加页号的搭配,但是这个搭配对二级索引来说有点儿不太严谨。以下面这个表为例(c1是主键,c2是二级索引)\nc1 c2 c3 1 1 \u0026lsquo;u\u0026rsquo; 3 1 \u0026rsquo;d' 5 1 \u0026lsquo;y\u0026rsquo; 7 1 \u0026lsquo;a\u0026rsquo; 如果二级索引中,目录项记录的内容只是索引列+页号的匹配,那么为c2列建立索引后的B+树如下图6-16\n如果此时再插入一条记录 c1=9,c2=1,c3=\u0026lsquo;c\u0026rsquo;,那么在修改为c2列建立的二级索引对应的B+树时:由于页3中存储的目录项记录由c2列+页号构成,页3中两条目录项记录对应的c2列都是1,而新插入的这条记录中,c2列也是1,那么这条新插入的记录应该放在页4还是页5?\n为了保证B+树同一层内节点的目录项记录除了页号这个字段以外是唯一,所以二级索引的内节点的目录项记录内容实际上由3部分构成: 索引列的值,主键值,页号 ,如上图6-17\n插入记录(9,1,’c\u0026rsquo;)时,由于页3 中存储的目录项记录是由c2 列+ 主键+页号构成的, 因此可以先把新记录的c2 列的值和页3 中各目录项记录的c2 列的值进行比较, 如果c2 列的值相同,可以接着比较主键值。因为B+ 树同一层中不同目录项记录的c2 列+主键的值肯定是不一样的,所以最后肯定能定位到唯一的一条目录项记录。 在本例中, 最后确定新记录应该插入到页5 中\n对于二级索引,先按照二级索引列的值进行排序,如果相同,再按照主键值进行排序。所以,为c2列建立索引,相当于为(c2,c1)列建立了一个联合索引。另外,对于唯一二级索引来说(当我们为某个列或列组合声明UNIQUE属性时,便会为这个列或组合建立唯一索引),也可能出现多条记录键值相同的情况(1. 声明为UNIQUE的列可能存储多个NULL 2. 后面要讲的MVCC服务),唯一二级索引的内节点的目录项记录也会包含记录的主键值\n注意,书上没有讲到删除的情况,也就是假设有一种情形:索引值1的行被删了,后面又重新添加了。我的理解是不会出现两条索引值一样的记录在树上(根据前面记录行的delete_flag,有可能重复,但是我猜会覆盖掉,所以这里没讲到那个情况,暂时没找到资料证明)\n一个页面至少容纳2条记录 # 如果一个大的目录中只存放一个子目录,那么目录层级会非常多,而且最后那个存放真正数据的目录中只能存放一条记录\n如果让B+树的叶子节点只存储一条记录,让内节点存储多条记录,也还是可以发挥B+树作用的。为了避免B+树的层级增长过高,要求所有数据页都至少可以容纳2条记录(也就是说,会极力避免因为列值过大、或者过多导致容纳不了2条记录)\nInnoDB对列的数量有所限制,而如果在最大限制下,结合04章的结论:\n如果一条记录的某个列中存储的数据占用字节数非常多,导致一个页没有办法存储两条记录,该列就可能会成为溢出列\nMyISAM中的索引方案简介 # 为了内容完整性,介绍一下MyISAM存储引擎中的索引方案\nInnoDB中,索引即数据,也就是聚簇索引的那颗B+树的叶子节点中包含了完整的用户记录。MyISAM虽然也是树形,但是索引和数据是分开的\n数据文件 # 表中的记录按照记录的插入顺序单独存储在一个文件中(称之为数据文件)\n该文件不划分若干个数据页,有多少记录就往文件中塞多少。通过行号快速访问到一条记录\nMyISAM记录需要记录头信息来存储额外数据,以index_demo表为例,看一下这个表在使用MyISAM作为存储引擎时,它的记录如何在存储空间表示\n由于是按插入顺序,没有按主键大小排序,所以不能在这些数据上使用二分法\n索引 # MyISAM存储引擎会把索引信息单独存储到另一个文件中(即索引文件)\nMyISAM会为表的主键单独创建一个索引,只不过在索引的叶子节点中存储的不是完整用户记录,而是主键值与行号的结合。即先通过索引找到对应的行号,再通过行号去找对应的记录\n与InnoDB不同,InnoDB存储隐情中,只需根据主键值对聚簇索引进行依次查找就能找到对应记录,而MyISAM中却需要进行一次回表操作。意味着MyISAM中建立的索引相当于都是二级索引\n其他索引 # 可以为其他列分别建立索引或者建立联合索引,原理与InnoDB差不多,只不过叶子节点存储的是相应的列+行号(InnoDB中存储的则是主键)。这些索引也都是二级索引。\nMyISAM行格式有定长记录格式Static、变长记录格式Dynamic、压缩记录格式 Compressed。图6-18就是定长记录格式,即一条记录占用的存储空间是固定的,这样就可以使用行号轻松算出某条记录在数据文件中的地址偏移量,但是变长记录格式就不行乐,MyISAM会直接在索引叶子节点处存储该记录在数据文件中的偏移量。==\u0026gt; MyISAM回表快速,因为是拿着地址偏移量直接到文件中取数据。而InnoDB则是获取主键后,再去从聚簇索引中查找。\n总结 # InnoDB:索引即数据\nMyISAM:索引是索引,数据是数据\nMySQL中创建和删除索引的语句 # InnoDB会自动为主键或者**带有UNIQUE **属性的列建立索引\nInnoDB不会自动为每个列创建索引,因为每建立一个索引都会建立一颗B+树,且增删改都要维护各个记录、数据页的排序关系,费性能和存储空间\n#语法 CREATE TABLE 表名( 各个列的信息..., (KEY|INDEX) 索引名 (需要被索引的单个列或多个列); ) #修改表结构 ALTER TABLE 表名 ADD (INDEX|KEY) 索引名 (需要被索引的单个列或多个列); #修改表结构的时候删除索引 ALTER TABLE 表名 DROP (INDEX|KEY) 索引名; 实例:\nmysql\u0026gt; CREATE TABLE index_demo( c1 INT, c2 INT, c3 CHAR(1), PRIMARY KEY (c1), INDEX idx_c2_c3 (c2,c3) ); #查看建表语句 mysql\u0026gt; SHOW CREATE TABLE index_demo; +------------+---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------+ | Table | Create Table | +------------+---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------+ | index_demo | CREATE TABLE `index_demo` ( `c1` int(11) NOT NULL, `c2` int(11) DEFAULT NULL, `c3` char(1) DEFAULT NULL, PRIMARY KEY (`c1`), KEY `idx_c2_c3` (`c2`,`c3`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8 | +------------+---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------+ 1 row in set (0.00 sec) "},{"id":229,"href":"/zh/docs/technology/MySQL/_how_mysql_run_/05/","title":"05InnoDB数据页结构","section":"_MySQL是怎样运行的_","content":" 学习《MySQL是怎样运行的》,感谢作者!\n不同类型的页简介 # 页是InnoDB管理存储空间的基本单位,1个页的大小一般是16KB\nInnoDB为了不同目的设计多种不同类型的页,包括存放表空间头部信息 的页、存放Change Buffer 信息的页、存放INODE信息的页、存放undo 日志信息的页\n这里说的是存放表中记录的那种类型的页,这种存放记录的页称为索引页(INDEX页)\n暂时称之为数据页\n数据页结构快览 # 1个页有16KB,这部分存储空间被划分为了多个部分(7部分),不同部分有不同的功能\n名称 中文名 占用空间 大小 File Header 文件头部 38 字节 页的一些通用信息 Page Header 页面头部 56 字节 数据页专有的一些信息 Infimum + Supremum 页面中的最小记录和最大记录 26 字节 两个虚拟的记录 User Records 用户记录 不确定 用户存储的记录内容 Free Space 空闲空间 不确定 页中尚未使用的空间 Page Directory 页目录 不确定 某些记录的相对位置 File Trailer 文件尾部 8 字节 校验页是否完整 记录在页中的存储 # 每插入一条记录,从Free Space申请一个记录大小的空间,并将这个空间划分到UserRecords部分。当FreeSpace部分的空间全部被UserRecords部分替代掉后,意味着该页用完。如果再插入,就需要申请新的页\n记录头信息的秘密 # mysql\u0026gt; CREATE TABLE page_demo( c1 INT, c2 INT, c3 VARCHAR(10000), PRIMARY KEY(c1) ) CHARSET=ascii ROW_FORMAT=COMPACT; Query OK, 0 rows affected (0.03 sec) 名称 大小(比特) 描述 预留位1 1 没有使用 预留位2 1 没有使用 deleted_flag 1 标志该记录是否被删除 min_rec_flag 1 B+ 树中每层非叶子节点中的最小的目录项记录都会添加该标记 n_owned 4 一个页面中的记录会被分成若干个组,每个组中有一个记录是\u0026quot;带头大哥“,其余的记录都是\u0026quot;小弟\u0026quot;。带头大哥\u0026quot;记录的n_owned值代表该组中所有的记录条数,\u0026ldquo;小弟\u0026quot;记录的n_owned值都为0 heap_no 13 表示当前记录在页面堆中的相对位置 record_type 3 表示当前记录的类型,0表示普通记录. 1 表示B+ 树非叶节点的目录项记录. 2 表示Infimum 记录. 3 表示Supremum 记录 next_record 16 表示下一条记录的相对位置 简化一下(忽略其他非讲解的部分信息)\n#插入4条记录 mysql\u0026gt; INSERT INTO page_demo VALUES(1,100,\u0026#39;aaaa\u0026#39;),(2,200,\u0026#39;bbbb\u0026#39;),(3,300,\u0026#39;cccc\u0026#39;),(4,400,\u0026#39;dddd\u0026#39;); Query OK, 4 rows affected (0.01 sec) Records: 4 Duplicates: 0 Warnings: 0 UserRecords部分的存储结构\ndeleted_flag # 标记当前记录是否删除:0表示没有被删除,1表示记录被删除\n被删除的记录不从磁盘溢出,因为移除后还需要在磁盘上重新排列其他的记录,带来性能消耗\n被删除掉的记录会组成一个垃圾链表,记录在这个链表中占用的空间称为可重用空间,如果之后有新纪录插入到表中,就可能覆盖掉被删除的记录所占用的存储空间\ndelete_flag设置为1和将被删除的记录加入到垃圾链表其实是两个阶段,后面介绍undo日志会详细讲解删除操作的详细执行过程\nmin_rec_flag # B+树每层非叶子节点中的最小的目录项记录都会添加该标记\nn_onwed # heap_no # 记录一条一条亲密无间排列的结构称之为堆(heap)。把一条记录在堆中的相对位置称之为heap_no\n为了管理这个堆,每一条记录在堆中的相对位置称为heap_no。\n页面前面的记录heap_no比后面的小,且每新申请一条记录的存储空间,该条记录比物理位置在它前面的那条记录的heap_no大1\n由上可知,4条记录的heap_no为2,3,4,5\nInnoDB的设计者自动给每个页添加了两条记录(称之伪记录或虚拟记录)。一条代表页面中的最小记录(也称Infimum记录美 [ɪn'faɪməm]),一条代表页面中的最大记录(也称Supremumsu'pri: m en)。这两条伪记录也算作堆的一部分\n比较完整记录的大小就是比较主键的大小\n规定,用户的任何记录都比Infimum记录大,比supremum记录小\nInfimum和Supremum记录 # 单独放在一个称为Infimum和Supremum的部分\n堆中记录的heap_no值在分配之后就不会发生改动了(即使删除了堆中某条记录)\nrecord_type # 表示当前记录的类型,0表示普通记录(上面自己插入的记录是),1表示B+树非叶节点的目录项记录(后面索引会讲到),2表示Infimum记录,3表示Supremum记录\nnext_record # 表示从当前记录的真实数据到下一条记录的真实数据的距离\n如果该属性值为正数, 说明当前记录的下一条记录在当前记录的后面: 如果该属性值为负数,说明当前记录的下一条记录在当前记录的前面\n下一条记录,指的是按照主键值由小到大的顺序排列的下一条记录\nInfimum的下一条记录是本页中主键值最小的用户记录,本页中主键值最大的用户记录的下一条记录就是Supremum记录\n如上,记录按照主键从小到大的顺序形成了一个单向链表\nSupremum记录的next_record值为0,即没有下一条记录了,如果删除其中一条记录\nSupremum记录的n_owned由5变成了4\nInnoDB始终维护记录的一个单向链表,链表中的各个节点是按照主键值由小到大的顺序链接起来的\n为啥next_record是指向记录头信息和真实数据之间的位置,而不是整条记录的开头。\n这个位置刚好,向左是记录头信息,向右是真实数据 由于变长字段长度列表、NULL值列表中的信息都是逆序存放,这样可以使记录中靠前的字段和他们对应的字段长度信息在内存中的距离更近,提高高速缓存命中率 如果第2条记录被重新插入\nPageDirectory(页目录) # 解释 # 直接遍历的话,时间复杂度太高\n说明: 将所有记录(包括Infimum和Supremum记录,不包括已经移除到垃圾链表的记录划分为几个组\n每个组的最后一条记录(组内最大的那条记录)相当于带头大哥,其余记录相当于小弟。\n带头大哥记录的头信息中的n_owned属性表示改组内共有几条记录\n操作:\n将每个组中最后一条记录(组内最大记录)在页面中的地址偏移量(该记录的真实数据与页面中第0个字节之间的距离)单独提取出来,按顺序存储倒靠近页尾部的地方(这个地方就是PageDirectory)\n页目录的偏移地址称为槽(Slot),每个槽占用2字节,页目录由多个槽组成\n1页有16KB,即16384字节,而2字节可以表示的地址偏移量为2^16-1=65535 \u0026gt;16384,所以用2字节表示一个槽足够了\n举例 # 假设page_demo表中有6条记录(包括Infimum和Supremum)\n注意,Infimum记录的n_owned值为1,Supremum记录的n_owned值为5\n且槽对应的记录(值)越小,越靠近FileTrailer\n用指针形式表示\n划分依据\n规定:对于Infimum记录所在的分组只能有1条记录,Supremum记录所在分组记录数在18条之间,剩下的分组中记录的条数范围只能是48条 简化:\n步骤:\n初始情况,数据页中只有Infimum和Supremum两条记录,分属两个分组\n页目录也只有两个槽:分别代表Infimum记录和Supremum记录在页中的偏移量\n之后每插入一条记录,都会从页目录中找到对应记录的主键值比待插入记录的主键值大并且差值最小的槽(从本质上看,槽是一个组内最大那条记录在页面中的地址偏移量,通过槽可以快速找到对应的记录的主键值)。然后把该槽对应的记录的n_owned值加1,表示本组内又添加了一条记录,直到该组中的记录数等于8\n当一个组中的记录数等于8后,再插入一条记录,会将组中的记录拆分成两个组,其中一个组中4条记录,另一个5条记录。且会在页目录中新增一个槽,记录这个新增分组中最大的那条记录的偏移量\n为了演示快速查找,再添加12条记录 ,总共16条\n一个槽占用2个字节,且槽之间是挨着的,每个槽代表的主键值都是从小到大排序的,所以可以使用二分法快速查找\n这里给槽编号:0,1,2,3,4。最低的槽就是low=0,最高的槽就是high=4\n假设我们要查找主键值为6的记录\n(0+4)/2=2,槽2代表的主键值8\u0026gt;6,所以high=2,low不变=0 (0+2)/2=1,槽1代表的主键值4\u0026lt;6,所以low=1,high不变=2 high-low=1,又因为槽记录的是最大值,所以不在槽1中,而是在槽2中\n沿着单项列表遍历槽2中的记录:如何遍历,先找到槽1的地址,然后它的下一条记录就是槽2中的最小记录 值为5,从值5的记录出发遍历即可(由于一个组中包含的记录条数最多是8,所以代价极小 总结\n通过二分法确定槽,找到槽所在分组中主键值最小的那条记录\n然后通过记录的next_record属性遍历该槽所在记录的各个记录\nPageHeader(页面头部) # 页结构的第2部分,占用固定的56字节,专门存储各种状态信息\nPageHeader的结构及描述\n状态名称 占用空间大小 描述 PAGE_N_DlR SLOTS 2字节 在页目录中的槽数量 PAGE_HEAP_TOP 2字节 还未使用的空间最小地址, 也就是说从该地址之后就是FreeSpace PAGE_N_HEAP 2字节 第1位表示本记录是否为紧凑型的记录, 剩余的15 位表示本页的堆中记录的数量(包括lnfimum 和Supremum 记录以及标记为\u0026quot;己删除\u0026quot;的记录) PAGE_FREE 2字节 各个己删除的记录通过next_record 组成一个单向链表,这个单向链表中的记录所占用的存储空间可以被重新利用;PAGE FREE 表示该链表头节点对应记录在页面中的偏移量 PAGE_GARBAGE 2字节 己删除记录占用的字节数 PAGE_LAST_INSERT 2字节 最后插入记录的位置 PAGE_DIRECTION 2字节 最后一条记录插入的方向 PAGE_N_DIRECTION 2字节 一个方向连续插入的记录数量 PAGE_N_RECS 2字节 该页中用户记录的数量〈不包括Infimum 和Supremum记录以及被删除的记录) PAGE_MAX_TRX_ID 8字节 修改当前页的最大事务id. 该值仅在二级索引页面中定义 PAGE_LEVEL 2字节 当前页在B+ 树中所处的层级 PAGE_INDEX_ID 8字节 索引ID, 表示当前页属于哪个索引 PAGE_BTR_SEG_LEAF 10字节 B+ 树叶子节点段的头部信息,仅在B+ 树的根页面中定义 PAGE_BTR_SEG_TOP 10字节 B+ 树非叶子节点段的头部信息,仅在B+ 树的根页面中定义 PAGE_N_DlR SLOTS - PAGE_N_RECS 的作用应该是清除的,这里有两个解释一下:\nPAGE_DIRECTION:加入新插入的一条记录的主键值比上一条记录的主键值大,我们说这条记录的插入方向是右边,反之则是左边。用来表示最后一条记录插入方向的状态就是PAGE_DIRECTION\nPAGE_N_DIRECTION:假设连续插入新记录的方向都是一致,InnoDB会把沿着同一个方向插入记录的条数记下来,用PAGE_N_DIRECTION表示。如果最后一条记录的插入方向发生了改变,这个状态的值会被清零后重新统计\n其他的暂时不讨论\nFileHeader(文件头部) # PageHeader专门针对的是数据页记录的各种状态信息,比如页有多少条记录,多少个槽。\nFileHeader通用于各种类型的页,描述了一些通用于各种页的信息,比如这个页的编号是多少,它的上一个页和下一个页是谁,固定占用38字节\n校验和(checksum):对于很长的字节串,通过某种算法计算出比较短的值来代编这个字节串,比较之前先比较这个字节串。省去了直接比较这两个长字节串的时间损耗\nInnoDB通过页号来唯一定位一个页\n页号(第n个号),4字节,2^(4*8)=2^32次方位 =4294967296 个页\n4294967296 * (16KB/页) =64T,这也是InnoDB 单表限制的大小\n页有好几种类型,前面介绍的是存储记录的数据页,还有其他类型的页\n存放记录的数据页的类型其实是FIL_PAGE_INDEX,也就是索引页\n前面说记录的存储结构时,所说的溢出页是FIL_PAGE_TYPE_BLOB\n对于FIL_PAGE_PREV和FIL_PAGE_NEXT:当占用空间非常大时,无法一次性为这么多数据分配一个非常大的存储空间,如果分散到多个不连续的页中存储,则需要把这些页关联起来。FIL_PAGE_PREV和FIL_PAGE_NEXT就分别代表本数据页的上一个页和下一个页的页号。不是所有类型的页都有上一个页和下一个页属性的,不过数据页(FIL_PAGE_INDEX的页)有这两个属性,所以存储记录的数据页其实可以组成一个双向链表\nFileTrailer(文件尾部) # InnoDB存储引擎会把数据存储倒磁盘,但磁盘速度太慢,需要以页为单位把数据加载到内存中处理\n如果在该页中的数据在内存中修改了,在修改后某个时间还需要把数据刷新到磁盘中,但在刷新还没结束的时候断电了怎么办。为了检测一个页是否完整(判断刷新时有没有之刷新了一部分),为每个页尾部添加一个FileTriler部分,由8个字节组成,又分两小部分\n前4 字节代表页的校验和。这个部分与File Header 中的校验和相对应。每当一个页面在内存中发生修改时,在刷新之前就要把页面的校验和算出来。因为File Header 在页面的前边,所以File Header 中的校验和会被首先刷新到磁盘,当完全写完后,校验和也会被写到页的尾部。如果页面刷新成功,则页首和页尾的校验和应该是一致的。如果刷新了一部分后断电了,那么File Header 中的校验和就代表着己经修改过的页,而File Trailer 中的校验和代表着原先的页(因为断电了,所以没有完全写完),二者不同则意味着刷新期间发生了错误. 后4 字节代表页面被最后修改时对应的LSN 的后4 字节,正常情况下应该与FileHeader 部分的FIL_PAGE_LSN的后4 字节相同。这个部分也是用于校验页的完整性,不过我们目前还没说LSN 是什么意思,所以大家可以先不用管这个属性。 这个File Trailer 与File Header 类似,都通用于所有类型的页\n"},{"id":230,"href":"/zh/docs/technology/MySQL/_how_mysql_run_/04/","title":"04InnoDB记录存储结构","section":"_MySQL是怎样运行的_","content":" 学习《MySQL是怎样运行的》,感谢作者!\n问题 # 表数据存在哪,以什么格式存放,MySQL以什么方式来访问\n存储引擎:对表中数据进行存储和写入\nInnoDB是MySQL默认的存储引擎,这章主要讲InnoDB存储引擎的记录存储结构\nInnoDB页简介 # 注意,是简介\nInnoDB:将表中的数据存储到磁盘上\n真正处理数据的过程:内存中。所以需要把磁盘中数据加载到内存中,如果是写入或修改请求,还需要把内存中的内容刷新到磁盘上\n获取记录:不是一条条从磁盘读,InnoDB将数据划分为若干个页,以页作为磁盘和内存之间交互的基本单位。页大小-\u0026gt; 一般是16KB\n一般情况:一次最少从磁盘读取16KB的内容到内存中,一次最少把内存中的16KB内容刷新到磁盘中\nmysql\u0026gt; SHOW VARIABLES LIKE \u0026#39;innodb_page_size\u0026#39;; +------------------+-------+ | Variable_name | Value | +------------------+-------+ | innodb_page_size | 16384 | +------------------+-------+ 1 row in set (0.00 sec) 只能在第一次初始化MySQL数据目录时指定,之后再也不能更改(通过mysqld \u0026ndash;initialize初始化数据目录[旧版本])\nInnoDB行格式 # 以记录为单位向表中插入数据,而这些记录在磁盘上的存放形式也被称为行格式或者记录格式\n目前有4中不同类型的行格式:COMPACT、REDUNDANT、DYNAMIC和COMPRESSED\ncompact [kəmˈpækt]契约\nredundant[rɪˈdʌndənt] 冗余的\ndynamic[daɪˈnæmɪk]动态的\ncompressed [kəmˈprest] 压缩的\n指定行格式的语法 # CREATE TABLE 表名(列的信息) ROW_FORMAT=行格式名称\nALTER TABLE 表名 ROW_FORMATE=行格式名称\n如下,在数据库xiaohaizi下创建一个表\nCREATE TABLE record_format_demo( c1 VARCHAR(10), c2 VARCHAR(10) NOT NULL, c3 CHAR(10), c4 VARCHAR(10) ) CHARSET=ascii ROW_FORMAT=COMPACT; #回顾:ascii每个字符1字节即可表示,且只有空格标点数字字母不可见字符 #插入两条数据 INSERT INTO record_format_demo(c1,c2,c3,c4) VALUES(\u0026#39;aaaa\u0026#39;,\u0026#39;bbb\u0026#39;,\u0026#39;cc\u0026#39;,\u0026#39;d\u0026#39;),(\u0026#39;eeee\u0026#39;,\u0026#39;fff\u0026#39;,NULL,NULL); 查询\n#查询 mysql\u0026gt; SELECT * FROM record_format_demo; +------+-----+------+------+ | c1 | c2 | c3 | c4 | +------+-----+------+------+ | aaaa | bbb | cc | d | | eeee | fff | NULL | NULL | +------+-----+------+------+ 2 rows in set (0.01 sec) COMPACT行格式 # [kəmˈpækt]契约\n额外信息 # 包括变长字段长度列表、NULL值列表、记录头信息\n记录的真实数据 # REDUNDANT行格式 # [rɪˈdʌndənt] 冗余的 MySQL5.0之前使用的一种行格式(古老)\n如图\n下面主要和COMPACT行格式做比较\n字段长度偏移列表 # 记录了所有列 偏移,即不是直接记录,而是通过加减 同样是逆序,如第一条记录\n06 0C 13 17 1A 24 25,则\n第1列(RD_ROW_ID):6字节\n第2列(DB_TRX_ID):6字节 0C-06=6 第3列(DB_ROLL_POINTER):7字节 13-0C=7 第4列(c1):4字节\n第5列(c2):3字节\n第6列(c3):10字节\n第7列(c4):1字节\n记录头信息 # 相比COMPACT行格式,多出了2个,少了一个\n没有了record_type这个属性 多了n_field和1byte_offs_flag这两个属性:\n#查询 mysql\u0026gt; SELECT * FROM record_format_demo; +------+-----+------+------+ | c1 | c2 | c3 | c4 | +------+-----+------+------+ | aaaa | bbb | cc | d | | eeee | fff | NULL | NULL | +------+-----+------+------+ 2 rows in set (0.01 sec) 第一条记录的头信息为:00 00 10 0F 00 BC\n即:00000000 00000000 00010000 00001111 00000000 1011 1100\n前面2字节都是0,即预留位1,预留位2,deleted_flag,min_rec_flag,n_owned都是0\nheap_no前面8位是0,再取5位:即 00000000 0001 0,即0x02\nn_field:000 0000111,即0x07\n1byte_offs_flag:0x01\nnext_record:00000000 1011 1100,即0xBC\n记录头信息中的1byte_offs_flag的值是怎么选择的 # 字段长度偏移列表存储的偏移量指的是每个列的值占用的空间在记录的真 实数据处结束的位置\n如上,0x06代表第一列(DB_ROW_ID)在真实数据的第6字节处结束;0x0C 代表第二列(DB_TRX_ID)在真实数据的第12字节处结束\u0026hellip;.\n讨论:每个列对应的偏移量可以使用1字节或2字节来存储,那么什么时候1什么时候2\n根据REDUNDANT行格式记录的真实数据占用的总大小来判断\n如果真实数据占用的字节数不大于127时,每个列对应的偏移量占用1字节**[注意,这里只用到了1字节的7位,即max=01111111]**\n如果大于127但不大于32767 (2^15-1,也就是15位的最大表示)时,使用2字节。\n如果超过32767,则本页中只保留前768字节和20字节的溢出页面地址(20字节还有别的信息)。这种情况下只是用2字节存储每个列对应的偏移量即可(127\u0026lt;768\u0026lt;=32767)\n在头信息中放置了一个1byte_offs_flag属性,值为1时表明使用1字节存储偏移量;值为0时表明使用2字节存储偏移量\nREDUNDANT行格式中NULL值的处理 # REDUNDANT行格式并没有NULL值列表\n将列对应的偏移量值的第一个比特位,作为是否为NULL的依据,也称之为NULL比特位 不论是1字节还是2字节,都要使用第1个比特位来标记该列值是否为NULL\n对于NULL列来说,该列的类型是否为变长类型决定了该列在记录的真实数据处的存储方式。\n分析第2条数据\n字段长度偏移列表-\u0026gt;按照列的顺序排放:06 0C 13 17 1A A4 A4\nc3=NULL,且c3类型-\u0026gt;CHAR(10) ==\u0026gt;真实数据部分占用10字节,0x00\nc3 原偏移量为36=32+4 = 00100100-\u0026gt;0x24,由于为NULL,所以首位(比特)为1,所以(真实)偏移量为10100100,0xA4\nc2偏移量为0x1A,则c2字节数为0x24-0x1A=36-26=10\n如果存储NULL值的字段为变长数据类型,则不在记录的真实数据部分占用任何存储空间\n所以c4的偏移量应该和c3相同,都是00100100,且由于是NULL,所以首位为1-\u0026gt;10100100,0xA4\n从结果往回推理,c4也是0xA4,和c3相同,说明c4和c3一样都是NULL\nCOMPACT行格式的记录占用的空间更少\nCHAR(m)列的存储格式 # COMPACT中,当定长类型CHAR(M)的字符集的每个字符占用字节不固定时,才会记录CHAR列的长度;而REDUNDANT行格式中,该列真实数据占用的存储空间大小,就是该字符集表示一个字符最多需要的字节数和M的乘积:utf8的CHAR(10)类型的列,真实数据占用存储空间大小始终为30字节;使用gbk字符集的CHAR(10),始终20字节\n溢出列 # 溢出列 # #举例 mysql\u0026gt; CREATE TABLE off_page_demo( c VARCHAR(65532) ) CHARSET=ascii ROW_FORMAT=COMPACT; #插入一条数据 mysql\u0026gt; INSERT INTO off_page_demo(c) VALUES(REPEAT(\u0026#39;a\u0026#39;,65532)); Query OK, 1 row affected (0.06 sec) ascii字符集中1字符占用1字节,REPEAT(\u0026lsquo;a\u0026rsquo;,65532)生成一个把字符\u0026rsquo;a\u0026rsquo;重复65532次数的字符串\n1页有16kb=1024*16=16384字节,65532字节远超1页大小\nCOMPACT和REDUNDANT行格式中,对于存储空间占用特别多的列,真实数据处只会存储该列一部分数据,剩余数据存储在几个其他的页中,在记录的真实数据处用20字节存储指向这些页的地址(当然,这20字节还包括分散在其他页面中的数据所占用的字节数)\n原书加了括号里的话,不是很理解,我的理解是:这20字节指向的页中,包括了溢出的那部分数据\n如上,如果列数据非常大,只会存储该列前768字节的数据以及一个指向其他页的地址(存疑,应该不止一个,有时候1个是放不下所有溢出页数据的吧?)\n简化:\n例子中列c的数据需要使用溢出页来存储,我们把这个列称为溢出列,不止VARCHAR(M),TEXT和BLOB也可能成为溢出列\n产生溢出列的临界点 # MySQL中规定一个页至少存放2条记录\n16KB=16384字节\n每个页除了记录,还有额外信息,这些额外信息需要132字节。\n每个记录需要27字节,包括\n针对下面的表\nmysql\u0026gt; CREATE TABLE off_page_demo( c VARCHAR(65532) ) CHARSET=ascii ROW_FORMAT=COMPACT;\n注意,是COMPACT行格式\n对于每一行记录 存储真实数据长度(2字节)\n存储列是否为NULL值(1字节)\n5字节大小的头信息\n6字节的row_id列\n6字节的row_id列\n7字节的roll_pointer列\n132+2*(27+n) \u0026lt;16384\n至于为社么不能等于,这是MySQL设计时规定的,未知。\n正常记录的页和溢出页是两种不同的页,没有规定一个溢出页页面中至少存放两条记录\n对于该表,得出的解是n\u0026lt;8099,也就是如果一个列存储的数据小于8099,就不会成为溢出页\n结论 # 如果一条记录的某个列中存储的数据占用字节数非常多,导致一个页没有办法存储两条记录,该列就可能会成为溢出列\nDYNAMIC行格式和COMPRESSED行格式 # 这两个与COMPACT行记录挺像,对于处理溢出列的数据有分歧:\n他们不会在记录真实处存储真实数据的前768字节,而是把该列所有真实数据都存储到溢出页,只在真实记录处存储20字节(指向溢出页的地址)。COMPRESSED行格式不同于DYNAMIC行格式的一点:COMPRESSED行格式会采用压缩算法对页面进行压缩\nMySQL5.7默认使用DYNAMIC行记录\n总结 # REDUNDANT是一个比较原始的行格式,较为紧凑;而COMPACT、DYNAMIC以及COMPRESSED行格式是较新的行格式,它们是紧凑的(占用存储空间更少)\n"},{"id":231,"href":"/zh/docs/technology/MySQL/_how_mysql_run_/03/","title":"03字符集和比较规则","section":"_MySQL是怎样运行的_","content":" 学习《MySQL是怎样运行的》,感谢作者!\n字符集 # 把哪些字符映射成二进制数据:字符范围\n怎么映射:字符-\u0026gt;二进制数据,编码;二进制-\u0026gt;字符,解码\n字符集:某个字符范围的编码规则\n同一种字符集可以有多种比较规则\n重要的字符集 # ASCAII字符集:128个,包括空格标点数字大小写及不可见字符,使用一个字节编码\nISO 8859-1字符集:256个,ASCAII基础扩充128个西欧常用字符(包括德法),使用1个字节,别名Latin1\nGB2312字符集:收录部分汉字,兼容ASCAII字符集,如果字符在ASCAII字符集中则采用1字节,否则两字节。即变长编码方式\n区分某个字节,代表一个单独字符,还是某个字符的一部分。\n比如0xB0AE75,由于是16进制,所有两个代表1个字节。所以这里有三个字节,其中最后那个字节为7*16+5=117 \u0026lt; 127 所以代表一个单独字符。而AE=10 * 16 +15=175 \u0026gt;127 ,所以是某个字符的一部分\nGBK字符集:对GB2312字符集扩充,编码方式兼容GB2312\nUTF-8字符集:几乎收录所有字符,且不断扩充,兼容ASCAII字符集。变长:采用14字节\nL-\u0026gt;0x4C 1字节,啊-\u0026gt;0xE5958A,两字节\nUTF-8是Unicode字符集的一种编码方案,Unicode字符集有三种方案:UTF-8(14字节编码一个字符),UTF-16(2或4字节编码一个字符),UTF-32(4字节编码一个字符)\n对于**“我”**,ASCLL中没有,UTF-8中采用3字节编码,GB22312采用2字节编码\nMySQL中支持的字符集和比较规则 # MySQL中,区分utf8mb3和utf8mb4,前者只是用13字节表示字符;后者使用14字节表示字符。MySQL中,utf8代表utf8mb3。\n#查看当前MySQL支持的字符集(注意,是字符集,名称都是小写) #Default collation 默认比较规则 mysql\u0026gt; SHOW CHARSET; +----------+---------------------------------+---------------------+--------+ | Charset | Description | Default collation | Maxlen | +----------+---------------------------------+---------------------+--------+ | big5 | Big5 Traditional Chinese | big5_chinese_ci | 2 | | dec8 | DEC West European | dec8_swedish_ci | 1 | | cp850 | DOS West European | cp850_general_ci | 1 | | hp8 | HP West European | hp8_english_ci | 1 | | koi8r | KOI8-R Relcom Russian | koi8r_general_ci | 1 | | latin1 | cp1252 West European | latin1_swedish_ci | 1 | \u0026lt;--- | latin2 | ISO 8859-2 Central European | latin2_general_ci | 1 | \u0026lt;--- | swe7 | 7bit Swedish | swe7_swedish_ci | 1 | | ascii | US ASCII | ascii_general_ci | 1 | \u0026lt;--- | ujis | EUC-JP Japanese | ujis_japanese_ci | 3 | | sjis | Shift-JIS Japanese | sjis_japanese_ci | 2 | | hebrew | ISO 8859-8 Hebrew | hebrew_general_ci | 1 | | tis620 | TIS620 Thai | tis620_thai_ci | 1 | | euckr | EUC-KR Korean | euckr_korean_ci | 2 | | koi8u | KOI8-U Ukrainian | koi8u_general_ci | 1 | | gb2312 | GB2312 Simplified Chinese | gb2312_chinese_ci | 2 | \u0026lt;--- | greek | ISO 8859-7 Greek | greek_general_ci | 1 | | cp1250 | Windows Central European | cp1250_general_ci | 1 | | gbk | GBK Simplified Chinese | gbk_chinese_ci | 2 | \u0026lt;--- | latin5 | ISO 8859-9 Turkish | latin5_turkish_ci | 1 | | armscii8 | ARMSCII-8 Armenian | armscii8_general_ci | 1 | | utf8 | UTF-8 Unicode | utf8_general_ci | 3 | \u0026lt;--- | ucs2 | UCS-2 Unicode | ucs2_general_ci | 2 | | cp866 | DOS Russian | cp866_general_ci | 1 | | keybcs2 | DOS Kamenicky Czech-Slovak | keybcs2_general_ci | 1 | | macce | Mac Central European | macce_general_ci | 1 | | macroman | Mac West European | macroman_general_ci | 1 | | cp852 | DOS Central European | cp852_general_ci | 1 | | latin7 | ISO 8859-13 Baltic | latin7_general_ci | 1 | | utf8mb4 | UTF-8 Unicode | utf8mb4_general_ci | 4 | \u0026lt;--- | cp1251 | Windows Cyrillic | cp1251_general_ci | 1 | | utf16 | UTF-16 Unicode | utf16_general_ci | 4 | \u0026lt;--- | utf16le | UTF-16LE Unicode | utf16le_general_ci | 4 | | cp1256 | Windows Arabic | cp1256_general_ci | 1 | | cp1257 | Windows Baltic | cp1257_general_ci | 1 | | utf32 | UTF-32 Unicode | utf32_general_ci | 4 | \u0026lt;--- | binary | Binary pseudo charset | binary | 1 | | geostd8 | GEOSTD8 Georgian | geostd8_general_ci | 1 | | cp932 | SJIS for Windows Japanese | cp932_japanese_ci | 2 | | eucjpms | UJIS for Windows Japanese | eucjpms_japanese_ci | 3 | | gb18030 | China National Standard GB18030 | gb18030_chinese_ci | 4 | +----------+---------------------------------+---------------------+--------+ 41 rows in set (0.00 sec) 字符集的比较规则(这里先看utf8的)\nmysql\u0026gt; SHOW COLLATION LIKE \u0026#39;utf8\\_%\u0026#39;; +--------------------------+---------+-----+---------+----------+---------+ | Collation | Charset | Id | Default | Compiled | Sortlen | +--------------------------+---------+-----+---------+----------+---------+ | utf8_general_ci | utf8 | 33 | Yes | Yes | 1 | | utf8_bin | utf8 | 83 | | Yes | 1 | | utf8_unicode_ci | utf8 | 192 | | Yes | 8 | | utf8_icelandic_ci | utf8 | 193 | | Yes | 8 | | utf8_latvian_ci | utf8 | 194 | | Yes | 8 | | utf8_romanian_ci | utf8 | 195 | | Yes | 8 | | utf8_slovenian_ci | utf8 | 196 | | Yes | 8 | | utf8_polish_ci | utf8 | 197 | | Yes | 8 | | utf8_estonian_ci | utf8 | 198 | | Yes | 8 | | utf8_spanish_ci | utf8 | 199 | | Yes | 8 | | utf8_swedish_ci | utf8 | 200 | | Yes | 8 | | utf8_turkish_ci | utf8 | 201 | | Yes | 8 | | utf8_czech_ci | utf8 | 202 | | Yes | 8 | | utf8_danish_ci | utf8 | 203 | | Yes | 8 | | utf8_lithuanian_ci | utf8 | 204 | | Yes | 8 | | utf8_slovak_ci | utf8 | 205 | | Yes | 8 | | utf8_spanish2_ci | utf8 | 206 | | Yes | 8 | | utf8_roman_ci | utf8 | 207 | | Yes | 8 | | utf8_persian_ci | utf8 | 208 | | Yes | 8 | | utf8_esperanto_ci | utf8 | 209 | | Yes | 8 | | utf8_hungarian_ci | utf8 | 210 | | Yes | 8 | | utf8_sinhala_ci | utf8 | 211 | | Yes | 8 | | utf8_german2_ci | utf8 | 212 | | Yes | 8 | | utf8_croatian_ci | utf8 | 213 | | Yes | 8 | | utf8_unicode_520_ci | utf8 | 214 | | Yes | 8 | | utf8_vietnamese_ci | utf8 | 215 | | Yes | 8 | | utf8_general_mysql500_ci | utf8 | 223 | | Yes | 1 | +--------------------------+---------+-----+---------+----------+---------+ 27 rows in set (0.00 sec) utf8_polish_ci 波兰语比较规则; utf8_spanish_ci班牙语的比较规则;utf8_general_ci一种通用的比较规则 (utf8的默认比较规则)\n一些后缀的解释:\n字符集和比较规则的应用 # MySQL有4个级别的字符集和比较规则:服务器级别、数据库级别、表级别、列级别\n服务器级别 # mysql\u0026gt; SHOW VARIABLES LIKE \u0026#39;character_set_server\u0026#39;; +----------------------+--------+ | Variable_name | Value | +----------------------+--------+ | character_set_server | latin1 | +----------------------+--------+ 1 row in set (0.00 sec) mysql\u0026gt; SHOW VARIABLES LIKE \u0026#39;collation_server\u0026#39;; +------------------+-------------------+ | Variable_name | Value | +------------------+-------------------+ | collation_server | latin1_swedish_ci | +------------------+------------------- 1 row in set (0.00 sec) #centos7(英语语言)默认情况下如上 #所以比较时,是不区分大小写的 mysql\u0026gt; select * from test; +-------+ | name | +-------+ | hello | | Hello | +-------+ 2 rows in set (0.00 sec) mysql\u0026gt; select * from test where name = \u0026#39;hello\u0026#39;; +-------+ | name | +-------+ | hello | | Hello | +-------+ 修改为utf8\nvim /etc/my.cnf #新增 [server] character_set_server=utf8 collation_server=utf8_general_ci #重启并查看 systemctl restart mysqld; mysql\u0026gt; SHOW VARIABLES LIKE \u0026#39;character_set_server\u0026#39;; +----------------------+-------+ | Variable_name | Value | +----------------------+-------+ | character_set_server | utf8 | +----------------------+-------+ 1 row in set (0.00 sec) mysql\u0026gt; SHOW VARIABLES LIKE \u0026#39;character_set_server\u0026#39;;^C mysql\u0026gt; SHOW VARIABLES LIKE \u0026#39;collation_server\u0026#39;; +------------------+-----------------+ | Variable_name | Value | +------------------+-----------------+ | collation_server | utf8_general_ci | +------------------+-----------------+ 1 row in set (0.00 sec) 数据库级别 # #创建数据库并指定字符集及比较规则 #如果不设置,则使用上级(服务器级别)的字符集和比较规则作为数据库的字符集和比较规则 CREATE DATABASE db_test CHARACTER SET gb2312 COLLATE gb2312_chinese_ci; #此时切换到db_test数据库 #再查看,发现变了 mysql\u0026gt; use db_test; Database changed mysql\u0026gt; SHOW VARIABLES LIKE \u0026#39;character_set_database\u0026#39;; +------------------------+--------+ | Variable_name | Value | +------------------------+--------+ | character_set_database | gb2312 | +------------------------+--------+ 1 row in set (0.00 sec) mysql\u0026gt; SHOW VARIABLES LIKE \u0026#39;collation_database\u0026#39;; +--------------------+-------------------+ | Variable_name | Value | +--------------------+-------------------+ | collation_database | gb2312_chinese_ci | +--------------------+-------------------+ 1 row in set (0.00 sec) 表级别 # mysql\u0026gt; CREATE TABLE t(col VARCHAR(10)) CHARACTER SET utf8 COLLATE utf8_general_ci; Query OK, 0 rows affected (0.06 sec) mysql\u0026gt; SHOW CREATE TABLE t; +-------+------------------------------------------------------------------------------------------+ | Table | Create Table | +-------+------------------------------------------------------------------------------------------+ | t | CREATE TABLE `t` ( `col` varchar(10) DEFAULT NULL ) ENGINE=InnoDB DEFAULT CHARSET=utf8 | +-------+------------------------------------------------------------------------------------------+ 1 row in set (0.00 sec) 如果不指定,那么将继承所在数据库的字符集和比较规则\n列级别 # mysql\u0026gt; ALTER TABLE t MODIFY col VARCHAR(10) CHARACTER SET gbk COLLATE gbk_chinese_ci; ## 修改为字符集gbk和对应的排序规则,如果不指定,则使用表的字符集及表的排序规则 其他 # 如果仅修改字符集,不修改比较规则,则比较规则会设置为默认该字符集的比较规则;\n如果仅修改比较规则,则字符集会设置为该比较规则对应的字符集\n#查看当前服务器对应规则 mysql\u0026gt; SHOW VARIABLES LIKE \u0026#39;character_set_server\u0026#39;; +----------------------+-------+ | Variable_name | Value | +----------------------+-------+ | character_set_server | utf8 | +----------------------+-------+ 1 row in set (0.00 sec) mysql\u0026gt; SHOW VARIABLES LIKE \u0026#39;collation_server\u0026#39;; +------------------+-----------------+ | Variable_name | Value | +------------------+-----------------+ | collation_server | utf8_general_ci | +------------------+-----------------+ 1 row in set (0.00 sec) #只修改字符集 mysql\u0026gt; SET character_set_server = gb2312; Query OK, 0 rows affected (0.00 sec) mysql\u0026gt; SHOW VARIABLES LIKE \u0026#39;collation_server\u0026#39;; +------------------+-------------------+ | Variable_name | Value | +------------------+-------------------+ | collation_server | gb2312_chinese_ci | +------------------+-------------------+ 1 row in set (0.00 sec) #仅修改比较规则 mysql\u0026gt; SET collation_server = utf8_general_ci; Query OK, 0 rows affected (0.00 sec) mysql\u0026gt; SHOW VARIABLES LIKE \u0026#39;character_set_server\u0026#39;; +----------------------+-------+ | Variable_name | Value | +----------------------+-------+ | character_set_server | utf8 | +----------------------+-------+ 1 row in set (0.00 sec) 根据各个列的字符集和比较规则是什么,从而根据这个列的类型来确认每个列存储的实际数据所占用的存储空间大小\n#例子 mysql\u0026gt; describe t; +-------+-------------+------+-----+---------+-------+ | Field | Type | Null | Key | Default | Extra | +-------+-------------+------+-----+---------+-------+ | col | varchar(10) | YES | | NULL | | +-------+-------------+------+-----+---------+-------+ 1 row in set (0.01 sec) mysql\u0026gt; INSERT INTO t(col) VALUES(\u0026#39;我我\u0026#39;); Query OK, 1 row affected (0.01 sec) ## 如果列col使用的字符集是gbk,则每个字符占用2字节,两个字符占用4字节;如果列col使用字符集为utf8,则两个字符实际占用的存储空间为6字节 客户端和服务端通信过程中使用的字符集 # 编码和解码使用的字符集不一样 # 字符集转换的概念 # “我\u0026quot; utf8\u0026ndash;\u0026gt; 编码成0xE68891\n接收到0xE68891后,对它解码,然后又按照GBK字符集编码,编码后是0xCED2。过程称为**”字符集的转换“**\nMySQL中字符集转换过程 # 从用户角度,客户发送请求和服务器返回响应都是字符串;从机器角度,客户端发送的请求和服务端返回的响应本质上就是一个字节序列,这个过程经历了多次的字符集转换\n客户端发送请求 # #查看当前系统使用的字符集 #linux #以第一个优先,没有依次往下取 ▶ echo $LC_ALL root@centos7101:~ ▶ echo $LC_CTYPE root@centos7101:~ ▶ echo $LANG zh_CN.UTF-8 #window C:\\Users\\ly\u0026gt;chcp 活动代码页: 936 -\u0026gt; GBK windows中,使用mysql客户端时,可以使用mysql --default-character-set=utf8,客户端将以UTF-8字符集对请求的字符串进行编码\n服务端接收请求 # 服务端接收到的应该是一个字节序列,是系统变量character_set_client代表的字符集进行编码后的字节序列\n每个客户端与服务端建立连接后,服务器会为该客户端维护一个独立的character_set_client变量,是SESSION级别的 客户端在编码请求字符串时实际使用的字符集,与服务器在收到一个字节序列后认为该序列采用的编码字符集,是两个独立的字符集。要尽量保证这两个字符集是一致的 例子:如果客户端用的UTF8编码\u0026quot;我\u0026quot;为0xE68891,并发给服务端,而服务端的character_set_client=latin1,则服务端会理解为\n如果character_set_client对应的字符集不能解释请求的字节序列,那么服务器发生警告\nmysql\u0026gt; SET character_set_client =ascii; Query OK, 0 rows affected (0.00 sec) mysql\u0026gt; select \u0026#39;我\u0026#39;; #utf8将它编码成了0xE68891发给服务端,而服务端以ASCII字符集解码 +-----+ | ??? | +-----+ | ??? | +-----+ 1 row in set, 1 warning (0.00 sec) mysql\u0026gt; SHOW WARNINGS\\G *************************** 1. row *************************** Level: Warning Code: 1300 Message: Invalid ascii character string: \u0026#39;\\xE6\\x88\\x91\u0026#39; 1 row in set (0.00 sec) 服务器处理请求 # 服务端会将请求的字节序列当作采用character_set_client对应的字符集进行编码,不过真正处理请求时会将其转换为SESSION级别的系统变量character_set_connection对应的字符集进行编码的字符序列\n假设character_set_client=utf8,character_set_connection=gbk,则对于\u0026quot;我\u0026quot;\u0026ndash;\u0026gt;0xE68891\u0026ndash;\u0026gt;0xCED2\n情形1 # 例子: SELECT 'a' = 'A'\n#默认情况 mysql\u0026gt; SHOW VARIABLES LIKE \u0026#39;character_connection\u0026#39;; Empty set (0.00 sec) mysql\u0026gt; SHOW VARIABLES LIKE \u0026#39;collation_connection\u0026#39;; +----------------------+-----------------+ | Variable_name | Value | +----------------------+-----------------+ | collation_connection | utf8_general_ci | +----------------------+-----------------+ 1 row in set (0.00 sec) mysql\u0026gt; SELECT \u0026#39;A\u0026#39;=\u0026#39;a\u0026#39;; +---------+ | \u0026#39;A\u0026#39;=\u0026#39;a\u0026#39; | +---------+ | 1 | +---------+ 1 row in set (0.00 sec) 其他情况:\nmysql\u0026gt; SET character_set_connection = gbk; Query OK, 0 rows affected (0.00 sec) mysql\u0026gt; SET collation_connection=gbk_chinese_ci; Query OK, 0 rows affected (0.00 sec) mysql\u0026gt; SELECT \u0026#39;a\u0026#39;=\u0026#39;A\u0026#39;; +---------+ | \u0026#39;a\u0026#39;=\u0026#39;A\u0026#39; | +---------+ | 1 | +---------+ 1 row in set (0.00 sec) 另一种情况:\nmysql\u0026gt; SET character_set_connection = gbk; Query OK, 0 rows affected (0.00 sec) mysql\u0026gt; SET collation_connection=gbk_bin; Query OK, 0 rows affected (0.00 sec) mysql\u0026gt; SELECT \u0026#39;a\u0026#39;=\u0026#39;A\u0026#39;; +---------+ | \u0026#39;a\u0026#39;=\u0026#39;A\u0026#39; | +---------+ | 0 | +---------+ 1 row in set (0.00 sec) gbk_bin 简体中文, 二进制 gbk_chinese_ci 简体中文, 不区分大小写\n情形2 # #创建一个表 CREATE TABLE tt(c VARCHAR(100)) ENGINE=INNODB CHARSET=utf8; #先改回来 mysql\u0026gt; SET character_set_connection = utf8; Query OK, 0 rows affected (0.00 sec) mysql\u0026gt; SHOW VARIABLES LIKE \u0026#39;collation_connection\u0026#39;; +----------------------+-----------------+ | Variable_name | Value | +----------------------+-----------------+ | collation_connection | utf8_general_ci | +----------------------+-----------------+ 1 row in set (0.00 sec) #插入一条记录 INSERT INTO tt(c) VALUES(\u0026#39;我\u0026#39;); 当前数据库、表、列使用的是utf8,现在把collection改为gbk:\nmysql\u0026gt; SET character_set_connection = gbk; Query OK, 0 rows affected (0.00 sec) mysql\u0026gt; SET collation_connection=gbk_chinese_ci; Query OK, 0 rows affected (0.00 sec) mysql\u0026gt; SELECT * FROM tt WHERE c=\u0026#39;我\u0026#39;; +------+ | c | +------+ | 我 | +------+ 1 row in set (0.00 sec) 此时SELECT * FROM tt WHERE c='我';中,\u0026lsquo;我\u0026rsquo;是使用gbk字符集进行编码的,比较规则是gbk_chinese_ci;而列c使用utf8字符集编码,比较规则为utf8_general_ci。这种情况下列的字符集和排序规则的优先级更高,会将gbk字符集转换成utf8字符集,然后使用列c的比较规则utf8_general_ci进行比较\n服务器生成响应 # 继续以最近的上面例子为例SELECT * FROM tt,是否是直接将0xE68891读出来发送到客户端呢?不是的,取决于SESSION级别的系统变量character_set_result的值\nSET character_set_results=gbk; 服务器会将字符串\u0026rsquo;我\u0026rsquo;从utf8字符集编码的0xE68891转换为character_set_results系统变量对应的字符集编码后的字节序列,再发给客户端\n此时传给客户端的响应中,字符串\u0026rsquo;我\u0026rsquo;对应的就是字节序列0xCED2(\u0026lsquo;我\u0026rsquo;的gbk编码字节序列)\n总结 # 每个客户端在服务器建立连接后,服务器都会为这个连接维护这3个变量\n每个MySQL客户端都维护着一个客户端默认字符集,客户端在启动时会自动检测所在系统(是客户端所在系统)当前使用的字符集,并按照一定规则映射成(最接近)MySQL支持的字符集,然后将该字符集作为客户端默认的字符集。\n如果启动MySQL客户端时设置了default-character-set 则忽略操作系统当前使用的字符集,直接将default-character-set启动选项中指定的值作为客户端默认字符集\n连接服务器时,客户端将默认的字符集信息与用户密码等发送给服务端,服务端在收到后会将character_set_client、character_set_connection、character_set_results这3个系统变量的值初始化为客户端的默认字符集\n客户端连接到服务器之后,可以使用SET分别修改character_set_client、character_set_connection、character_set_results这3个系统变量的值(或者使用SET NAMES charset_name一次性修改)\n不会改变客户端在编码请求字符串时使用的字符集,也不会修改客户端的默认字符集\n客户端接收到请求 # 客户端收到的响应其实也是一个字节序列\n对于类UNIX系统,收到的字节序列相当于写到黑框框中,再由黑框框将这个字节序列解释为人类能看懂的字符(用操作系统当前使用的字符集来解释);对于Windows,客户端使用客户端的默认字符集来解释\n也就是说,如果在linux下指定的default-character-set和系统不一致,就会导致乱码\n整个过程,五件事:\n客户端发送的请求字节序列是采用哪种字符集进行编码的 [由客户端启动选项\u0026ndash;default-character-set/其次是系统默认的(linux可能是utf-8/windows可能是gbk)] 服务端接收到请求字节序列后会认为它是采用哪种字符集进行编码的[由character_set_client决定,而character_set_client[还有character_set_connection、character_set_results这2个系统变量]的值初始化为客户端的默认字符集,也就是上面1中的] 服务器在运行过程中会把请求的字符序列转换为以哪种字符集编码的字节序列[character_set_connection] 服务器在向客户端返回字节序列时,是采用哪种字符集进行编码的[character_set_results] 上面的character_set_clien、character_set_connection、character_set_results这3个系统变量]的值在客户端连接到服务端之后,都是可以修改的,且都是SESSION级别的 客户端在接收到响应字节序列后,是怎么把他们写到黑框框中的[windows中由default-character-set/其次是系统默认] 比较规则的应用 # 通常用来比较大小和排序\n例子:\nmysql\u0026gt; CREATE TABLE t(col VARCHAR(100)) ENGINE=INNODB CHARSET=gbk; Query OK, 0 rows affected (0.02 sec) mysql\u0026gt; INSERT INTO t(col) VALUES(\u0026#39;a\u0026#39;),(\u0026#39;b\u0026#39;),(\u0026#39;A\u0026#39;),(\u0026#39;B\u0026#39;); Query OK, 4 rows affected (0.01 sec) Records: 4 Duplicates: 0 Warnings: 0 mysql\u0026gt; SELECT * FROM t ORDER BY col; +------+ | col | +------+ | a | | A | | b | | B | | 我 | +------+ 5 rows in set (0.00 sec) # 如上,排序规则gbk_chinese_ci是不区分大小写的 # 修改为gbk_bin; mysql\u0026gt; ALTER TABLE t MODIFY col VARCHAR(10) COLLATE gbk_bin; Query OK, 5 rows affected (0.06 sec) Records: 5 Duplicates: 0 Warnings: 0 mysql\u0026gt; SELECT * FROM t ORDER BY col; +------+ | col | +------+ | A | | B | | a | | b | | 我 | +------+ 5 rows in set (0.00 sec) 解释:\n列col各个字符在使用gbk字符集编码后对应的数字如下:\n\u0026lsquo;A\u0026rsquo; -\u0026gt; 65\n\u0026lsquo;B\u0026rsquo;-\u0026gt;66\n\u0026lsquo;a\u0026rsquo;-\u0026gt;97\n\u0026lsquo;b\u0026rsquo;-\u0026gt;98\n\u0026lsquo;我\u0026rsquo;-\u0026gt;52946\n"},{"id":232,"href":"/zh/docs/technology/MySQL/_how_mysql_run_/02/","title":"02启动选项和系统变量","section":"_MySQL是怎样运行的_","content":" 学习《MySQL是怎样运行的》,感谢作者!\n启动选项和配置文件 # 在程序启动时指定的设置项,也称之为启动选项startup option(可以在命令行中/配置文件中 指定)\n由于在centos7中使用systemctl start mysqld启动mysql,所以好像没法用命令行指定启动选项了\n程序(可能有些程序新版本已经没有了)的对应类别和能读取的组:\n这里讲配置文件的方式设置启动选项:\n#添加配置 vim /etc/my.cnf [server] skip-networking #禁止tcp网络连接 default-storage-engine=MyISAM #建表默认使用M有ISAM存储引擎 #效果 ▶ mysql -h127.0.0.1 -uroot -p Enter password: ERROR 2003 (HY000): Can\u0026#39;t connect to MySQL server on \u0026#39;127.0.0.1\u0026#39; (111) #去除tcp网络连接限制后新建一个表 ▶ mysql -h127.0.0.1 -uroot -p #可以连接上 mysql\u0026gt; create table default_storage_engine_demo(i int); Query OK, 0 rows affected (0.01 sec) mysql\u0026gt; show create table default_storage_engine_demo; +-----------------------------+----------------------------------------------------------------------------------------------------------------+ | Table | Create Table | +-----------------------------+----------------------------------------------------------------------------------------------------------------+ | default_storage_engine_demo | CREATE TABLE `default_storage_engine_demo` ( `i` int(11) DEFAULT NULL ) ENGINE=MyISAM DEFAULT CHARSET=latin1 | 如果多个配置文件都配置了某个选项,如/etc/my.cnf /etc/mysql/my.cnf /usr/etc/my.cnf ~/.my.cnf都配置了,则以最后一个配置的为主\n如果同一个配置文件,比如[server]组和[mysqld]组都出现了default-storage-engine配置,则以后出现的组中的配置为准\n如果一个启动选项既在命令行中出现,又在配置文件中配置,则以命令行中的为准\n系统变量 # 查看系统变量\nmysql\u0026gt; SHOW VARIABLES LIKE \u0026#39;default_storage_engine\u0026#39;; +------------------------+--------+ | Variable_name | Value | +------------------------+--------+ | default_storage_engine | InnoDB | +------------------------+--------+ 1 row in set (0.00 sec) mysql\u0026gt; SHOW VARIABLES LIKE \u0026#39;default%\u0026#39;; +-------------------------------+-----------------------+ | Variable_name | Value | +-------------------------------+-----------------------+ | default_authentication_plugin | mysql_native_password | | default_password_lifetime | 0 | | default_storage_engine | InnoDB | | default_tmp_storage_engine | InnoDB | | default_week_format | 0 | +-------------------------------+-----------------------+ 5 rows in set (0.00 sec) mysql\u0026gt; SHOW VARIABLES LIKE \u0026#39;max_connections\u0026#39;; +-----------------+-------+ | Variable_name | Value | +-----------------+-------+ | max_connections | 151 | +-----------------+-------+ 1 row in set (0.00 sec) 大部分系统变量,可以在服务器程序运行过程中动态修改而无须停止并重启服务器\n不同作用范围的系统变量\nGLOBAL(全局范围):影响服务器的整体操作\nSESSION(会话范围):影响某个客户端连接的操作\n让之后新连接到服务器的客户端都用MyISAM作为默认的存储引擎\n#不会对这之前已连接的客户端产生影响 SET GLOBAL default_storage_engine=MyISAM; #systemctl restart mysqld时候,该配置就失效了 只对本客户端使用\nSET SESSION default_storage_engine=MyISAM;#或 SET default_storage_engine=MyISAM; 查看系统变量\nSHOW [GLOBAL|SESSION] VARIABLES [LIKE \u0026#39;匹配的模式\u0026#39;]; 状态变量 # 状态变量用来显示服务器程序运行状态\n状态变量也分GLOBAL|SESSION\nSHOW [GLOBAL|SESSION] STATUS [LIKE 匹配的模式];\nmysql\u0026gt; SHOW STATUS LIKE \u0026#39;thread%\u0026#39;; +-------------------+-------+ | Variable_name | Value | +-------------------+-------+ | Threads_cached | 0 | | Threads_connected | 2 | | Threads_created | 2 | | Threads_running | 1 | +-------------------+-------+ "},{"id":233,"href":"/zh/docs/technology/MySQL/_how_mysql_run_/01/","title":"01初识MySQL","section":"_MySQL是怎样运行的_","content":" 学习《MySQL是怎样运行的》,感谢作者!\n原文 # 下载与安装 # 环境Centos7\n添加MySQL5.7仓库\nsudo rpm -ivh https://dev.mysql.com/get/mysql57-community-release-el7-11.noarch.rpm 解决证书问题\nrpm --import https://repo.mysql.com/RPM-GPG-KEY-mysql-2022 查看是否添加成功\nsudo yum repolist all | grep mysql | grep 启用 mysql-connectors-community/x86_64 MySQL Connectors Community 启用: 213 mysql-tools-community/x86_64 MySQL Tools Community 启用: 96 mysql57-community/x86_64 MySQL 5.7 Community Server 启用: 642 MySQL安装\nsudo yum -y install mysql-community-server 运行与密码修改 # Centos7中安装目录查看,在/usr/bin中,与Max有所不同\nwhereis mysql mysql: /usr/bin/mysql /usr/lib64/mysql /usr/share/mysql /usr/share/man/man1/mysql.1.gz ls /usr/bin |grep mysql mysql mysqladmin mysqlbinlog mysqlcheck mysql_config_editor mysqld_pre_systemd mysqldump mysqldumpslow mysqlimport mysql_install_db mysql_plugin mysqlpump mysql_secure_installation mysqlshow mysqlslap mysql_ssl_rsa_setup mysql_tzinfo_to_sql mysql_upgrade 添加mysqld目录到环境变量中(这里可省略,因为mysqld默认在/usr/bin中了\n启动MySQL(和书上说的启动方式有点不一样,查资料得知,从5.7.6起,不再支持mysql_safe的启动方式)\n# 启动MySQL root@centos7101:~ ▶ systemctl start mysqld # 查看MySQL状态 root@centos7101:~ ▶ systemctl status mysqld ● mysqld.service - MySQL Server Loaded: loaded (/usr/lib/systemd/system/mysqld.service; enabled; vendor preset: disabled) Active: active (running) since 一 2023-04-17 11:43:42 CST; 19s ago Docs: man:mysqld(8) http://dev.mysql.com/doc/refman/en/using-systemd.html Main PID: 2182 (mysqld) CGroup: /system.slice/mysqld.service └─2182 /usr/sbin/mysqld --daemonize --pid-file=/var/run/mysqld/mysqld.pid 4月 17 11:43:37 centos7101 systemd[1]: Starting MySQL Server... 4月 17 11:43:42 centos7101 systemd[1]: Started MySQL Server. # 设置为开机启动 root@centos7101:~ ▶ systemctl enable mysqld 查看MySQL默认密码\ncat /var/log/mysqld.log |grep -i \u0026#39;temporary password\u0026#39; 2023-04-17T03:43:38.995935Z 1 [Note] A temporary password is generated for root@localhost: ampddi9+fpyQ 连接\nmysql -uroot -p123456 #或者 mysql -uroot -p #或者 mysql -hlocalhost -uroot -p123456 为了方便起见,修改密码为123456\n# 修改密码强度 set global validate_password_policy=LOW; #修改密码长度 set global validate_password_length=6; #修改密码 ALTER USER \u0026#39;root\u0026#39;@\u0026#39;localhost\u0026#39; IDENTIFIED BY \u0026#39;123456\u0026#39;; #刷新权限 flush privileges; 退出\nquit #或者 exit #或者 \\q 客户端与服务端连接过程 # 采用TCP作为服务端和客户端之间的网络通信协议\n远程连接前提\n#添加一个远程用户 CREATE USER \u0026#39;root\u0026#39;@\u0026#39;%\u0026#39; IDENTIFIED BY \u0026#39;123456.\u0026#39;; grant all on *.* to \u0026#39;root\u0026#39;@\u0026#39;%\u0026#39; identified by \u0026#34;123456.\u0026#34; with grant option; #修改用户密码 SET PASSWORD FOR \u0026#39;root\u0026#39;@\u0026#39;host\u0026#39; = password(\u0026#39;123456.\u0026#39;); 端口号修改与远程连接\n#修改MySQL启动的端口 vim /etc/my.cnf [mysqld] port=33062 #新增该行即可 #重启 systemctl restart mysqld #查看状态 systemctl status mysqld #查看服务是否启动 netstat -lntup |grep mysql tcp6 0 0 :::33062 :::* LISTEN 4612/mysqld #远程连接 mysql -hnode2 -uroot -P33062 -p 处理客户端请求\n常用存储引擎:Innodb和MyISAM\n查看当前服务器支持的存储引擎\n只有InnoDB是支持事务的且支持分布式事务、部分回滚\n存储引擎是负责对表中数据进行读取和写入的\n-- 创建表时指定存储引擎 CREATE TABLE engine_demo_table(i int) ENGINE = MyISAM -- 查看建表语句 mysql\u0026gt; SHOW CREATE TABLE engine_demo_table \\G *************************** 1. row *************************** Table: engine_demo_table Create Table: CREATE TABLE `engine_demo_table` ( `i` int(11) DEFAULT NULL ) ENGINE=MyISAM DEFAULT CHARSET=latin1 1 row in set (0.00 sec) -- 修改建表时指定的存储引擎 ALTER TABLE engine_demo_table ENGINE=InnoDB -- 修改编码 ALTER TABLE engine_demo_table CHARSET=UTF8 "},{"id":234,"href":"/zh/docs/technology/Redis/redis-cluster/","title":"redis集群搭建","section":"Redis","content":" 转载自https://www.cnblogs.com/Yunya-Cnblogs/p/14608937.html(添加小部分笔记)感谢作者!\n部分参考自 https://www.cnblogs.com/ysocean/p/12328088.html\n基本准备 # 架构 # 采用Centos7,Redis版本为6.2,架构如下:\nhosts修改 # vim /etc/hosts #添加 192.168.1.101 node1 192.168.1.102 node2 192.168.1.103 node3 集群准备 # 对每个节点 # 下载redis并解压到 /usr/local/redis-cluster中\ncd /usr/local mkdir redis-cluster tar -zxvf redis* -C /usr/local/redis* 进入redis根目录\nmake make install 安装完毕\nhosts修改\nvim /etc/hosts #添加 192.168.1.101 node1 192.168.1.102 node2 192.168.1.103 node3 配置文件修改(6个节点中的每一个) # 创建多级目录\nmkdir -p /usr/local/redis_cluster/redis_63{79,80}/{conf,pid,logs} 编写配置文件\nvim /usr/local/redis_cluster/redis_6379/conf/redis.conf # 命令行状态下输入 :%d 回车,清空文件 # 再输入 :set paste 处理多出的行带#的问题 # 再输入i ####内容##### # 快速修改::%s/6379/6380/g # 守护进行模式启动 daemonize yes # 设置数据库数量,默认数据库为0 databases 16 # 绑定地址,需要修改 # bind 192.168.1.101 bind node1 # 绑定端口,需要修改 port 6379 # pid文件存储位置,文件名需要修改 pidfile /usr/local/redis_cluster/redis_6379/pid/redis_6379.pid # log文件存储位置,文件名需要修改 logfile /usr/local/redis_cluster/redis_6379/logs/redis_6379.log # RDB快照备份文件名,文件名需要修改 dbfilename redis_6379.rdb # 本地数据库存储目录,需要修改 dir /usr/local/redis_cluster/redis_6379 # 集群相关配置 # 是否以集群模式启动 cluster-enabled yes # 集群节点回应最长时间,超过该时间被认为下线 cluster-node-timeout 15000 # 生成的集群节点配置文件名,文件名需要修改 cluster-config-file nodes_6379.conf 复制粘贴配置文件\ncp /usr/local/redis_cluster/redis_6379/conf/redis.conf /usr/local/redis_cluster/redis_6380/conf/redis.conf vim /usr/local/redis_cluster/redis_6380/conf/redis.conf #命令行模式下 :%s/6379/6380/g 查看文件夹当前情况\n运行 # 查看端口是否运行\nnetstat -lntup | grep 6379 运行\nredis-server /usr/local/redis_cluster/redis_6379/conf/redis.conf \u0026amp; redis-server /usr/local/redis_cluster/redis_6380/conf/redis.conf \u0026amp; 结果\nnetstat -lntup |grep 6379 tcp 0 0 192.168.1.101:6379 0.0.0.0:* LISTEN 6538/redis-server 1 tcp 0 0 192.168.1.101:16379 0.0.0.0:* LISTEN 6538/redis-server 1 #+10000端口出现,说明集群各个节点之间可以互相通信 结果\ncat *6379/pid/*pid* 6538 ##就是上面的进程id cat *6379/logs/*log 6538:C 14 Apr 2023 16:37:04.893 # oO0OoO0OoO0Oo Redis is starting oO0OoO0OoO0Oo 6538:C 14 Apr 2023 16:37:04.893 # Redis version=6.2.6, bits=64, commit=00000000, modified=0, pid=6538, just started 6538:C 14 Apr 2023 16:37:04.893 # Configuration loaded 6538:M 14 Apr 2023 16:37:04.895 * Increased maximum number of open files to 10032 (it was originally set to 1024). 6538:M 14 Apr 2023 16:37:04.895 * monotonic clock: POSIX clock_gettime 6538:M 14 Apr 2023 16:37:04.898 * No cluster configuration found, I\u0026#39;m e13c04818944108ee3b0690d836466b4c0eb69fd 6538:M 14 Apr 2023 16:37:04.929 * Running mode=cluster, port=6379. 6538:M 14 Apr 2023 16:37:04.929 # WARNING: The TCP backlog setting of 511 cannot be enforced because /proc/sys/net/core/somaxconn is set to the lower value of 128. 6538:M 14 Apr 2023 16:37:04.929 # Server initialized 6538:M 14 Apr 2023 16:37:04.929 # WARNING overcommit_memory is set to 0! Background save may fail under low memory condition. To fix this issue add \u0026#39;vm.overcommit_memory = 1\u0026#39; to /etc/sysctl.conf and then reboot or run the command \u0026#39;sysctl vm.overcommit_memory=1\u0026#39; for this to take effect. 6538:M 14 Apr 2023 16:37:04.930 * Ready to accept connections 集群节点配置文件,会发现生成了一组集群信息\n# 第一段信息是这个Redis服务作为集群节点的一个身份编码 # 别名为集群的node-id\ncat *6379/*nodes*conf e13c04818944108ee3b0690d836466b4c0eb69fd :0@0 myself,master - 0 0 0 connected vars currentEpoch 0 lastVoteEpoch 0 ## 当后续所有节点都连接上时,内容会变成: ls conf logs nodes_6379.conf pid redis_6379.rdb root@centos7101:local/redis_cluster/redis_6379 cat nodes_6379.conf f6cf3978d3397582c87480f8c335297675d4354a 192.168.1.103:6380@16380 master - 1681470597368 1681470597337 6 connected 0-5461 f1151c2350820b35e117d3c32b59b64917688745 192.168.1.103:6379@16379 master - 1681470597369 1681470597337 5 connected 10923-16383 83bfb30e39e3040397d995a7b1f560e8fb53c6a9 192.168.1.102:6380@16380 slave f1151c2350820b35e117d3c32b59b64917688745 1681470597369 1681470597337 5 connected 4e9452afe7a8f53dc546b0436109bef570e03888 192.168.1.101:6380@16380 slave 95b2dcd681674398d22817728af08c31d4bd4872 1681470597370 1681470597337 4 connected 95b2dcd681674398d22817728af08c31d4bd4872 192.168.1.102:6379@16379 master - 1681470597370 1681470597337 4 connected 5462-10922 fd6acb4af8afa5ddd31cf559ee2c80ffcbea456f 192.168.1.101:6379@16379 myself,slave f6cf3978d3397582c87480f8c335297675d4354a 0 1681470597337 6 connected vars currentEpoch 6 lastVoteEpoch 0 集群搭建 # 手动搭建集群 # 加入集群 # 在node1:6379 查看当前cluster\nredis-cli -h node1 -p 6379 node1:6379\u0026gt; cluster nodes e13c04818944108ee3b0690d836466b4c0eb69fd :6379@16379 myself,master - 0 0 0 connected node1:6379\u0026gt; cluster meet 192.168.1.102 6379 OK node1:6379\u0026gt; cluster nodes e13c04818944108ee3b0690d836466b4c0eb69fd 192.168.1.101:6379@16379 myself,master - 0 0 1 connected fbe66448ee1baefa6e9fbd55e778c1d09054b59a 192.168.1.102:6379@16379 master - 0 1681464479300 0 connected node1:6379\u0026gt; 此时在node2:6379查看当前cluster\nredis-cli -h node2 -p 6379 node2:6379\u0026gt; cluster nodes fbe66448ee1baefa6e9fbd55e778c1d09054b59a 192.168.1.102:6379@16379 myself,master - 0 0 0 connected e13c04818944108ee3b0690d836466b4c0eb69fd 192.168.1.101:6379@16379 master - 0 1681464547007 1 connected 切回node1:6379,将剩下的节点meet上\nnode1:6379\u0026gt; cluster meet 192.168.1.103 6379 OK node1:6379\u0026gt; cluster meet 192.168.1.101 6380 OK node1:6379\u0026gt; cluster meet 192.168.1.102 6380 OK node1:6379\u0026gt; cluster meet 192.168.1.103 6380 OK node1:6379\u0026gt; clear node1:6379\u0026gt; cluster nodes 84384230f256fae73ab5bbaf34b0479b67602d6e 192.168.1.102:6380@16380 master - 0 1681464635860 4 connected e13c04818944108ee3b0690d836466b4c0eb69fd 192.168.1.101:6379@16379 myself,master - 0 1681464633000 1 connected fbe66448ee1baefa6e9fbd55e778c1d09054b59a 192.168.1.102:6379@16379 master - 0 1681464636894 0 connected 43cdb0cd626a0341cf0c9fa31832735c5341a89b 192.168.1.103:6380@16380 master - 0 1681464635000 5 connected a20b6da956145cfa06ed55159456de8259d9f246 192.168.1.103:6379@16379 master - 0 1681464637923 2 connected 4aeeaa0d87b91712576c6e995b355fe4a87b24e0 192.168.1.101:6380@16380 master - 0 1681464637000 3 connected 主从配置 # 上面发现的node-id\nhostname 节点 node-id node1 192.168.1.101:6379 e13c04818944108ee3b0690d836466b4c0eb69fd node2 192.168.1.102:6379 fbe66448ee1baefa6e9fbd55e778c1d09054b59a node3 192.168.1.103:6379 a20b6da956145cfa06ed55159456de8259d9f246 主从配置\n#node1:6380-\u0026gt;node2:6379 node1:6380\u0026gt; cluster replicate 95b2dcd681674398d22817728af08c31d4bd4872 OK #node2:6380-\u0026gt;node3:6379 node2:6380\u0026gt; cluster replicate f1151c2350820b35e117d3c32b59b64917688745 OK #node3:6380-\u0026gt;node1:6379 node3:6380\u0026gt; cluster replicate fd6acb4af8afa5ddd31cf559ee2c80ffcbea456f OK 再一次查看节点信息,出现了master,slave\nnode3:6380\u0026gt; cluster nodes 4aeeaa0d87b91712576c6e995b355fe4a87b24e0 192.168.1.101:6380@16380 slave fbe66448ee1baefa6e9fbd55e778c1d09054b59a 0 1681465221000 0 connected 43cdb0cd626a0341cf0c9fa31832735c5341a89b 192.168.1.103:6380@16380 myself,slave e13c04818944108ee3b0690d836466b4c0eb69fd 0 1681465222000 1 connected fbe66448ee1baefa6e9fbd55e778c1d09054b59a 192.168.1.102:6379@16379 master - 0 1681465223000 0 connected e13c04818944108ee3b0690d836466b4c0eb69fd 192.168.1.101:6379@16379 master - 0 1681465222000 1 connected a20b6da956145cfa06ed55159456de8259d9f246 192.168.1.103:6379@16379 master - 0 1681465221814 2 connected 84384230f256fae73ab5bbaf34b0479b67602d6e 192.168.1.102:6380@16380 slave a20b6da956145cfa06ed55159456de8259d9f246 0 1681465223880 2 connected 分配槽位 # 只对主库分配,从库不进行分配\nexpr 16384 / 3 5461\n下面平均分配到3个master中,其中:\n节点 槽位数量 node1:6379 0 - 5461 【多分配了一个】 node2:6379 5461 - 10922 node3:6379 10922 - 16383 redis-cli -h node1 -p 6379 cluster addslots {0..5461} redis-cli -h node2 -p 6379 cluster addslots {5462..10922} redis-cli -h node3 -p 6379 cluster addslots {10923..16383} redis-cli -h node3 -p 6379 node1:6379\u0026gt; cluster nodes 84384230f256fae73ab5bbaf34b0479b67602d6e 192.168.1.102:6380@16380 slave a20b6da956145cfa06ed55159456de8259d9f246 0 1681467951000 2 connected e13c04818944108ee3b0690d836466b4c0eb69fd 192.168.1.101:6379@16379 myself,master - 0 1681467948000 1 connected 0-5461 fbe66448ee1baefa6e9fbd55e778c1d09054b59a 192.168.1.102:6379@16379 master - 0 1681467949000 0 connected 5462-10922 43cdb0cd626a0341cf0c9fa31832735c5341a89b 192.168.1.103:6380@16380 slave e13c04818944108ee3b0690d836466b4c0eb69fd 0 1681467949690 1 connected a20b6da956145cfa06ed55159456de8259d9f246 192.168.1.103:6379@16379 master - 0 1681467947626 2 connected 10923-16383 4aeeaa0d87b91712576c6e995b355fe4a87b24e0 192.168.1.101:6380@16380 slave fbe66448ee1baefa6e9fbd55e778c1d09054b59a 0 1681467951745 0 connected 检查集群状态是否OK\nnode1:6379\u0026gt; cluster info cluster_state:ok cluster_slots_assigned:16384 cluster_slots_ok:16384 cluster_slots_pfail:0 cluster_slots_fail:0 cluster_known_nodes:6 cluster_size:3 cluster_current_epoch:5 cluster_my_epoch:1 cluster_stats_messages_ping_sent:3461 cluster_stats_messages_pong_sent:3530 cluster_stats_messages_meet_sent:5 cluster_stats_messages_sent:6996 cluster_stats_messages_ping_received:3530 cluster_stats_messages_pong_received:3466 cluster_stats_messages_received:6996 自动集群搭建 # 假设所有的节点都已经重置过,没有主从状态,也未加入任何集群。\nRedis5之前使用redis-trib.rb脚本搭建\nredis-trib.rb脚本使用ruby语言编写,所以想要运行次脚本,我们必须安装Ruby环境。安装命令如下:\nyum -y install centos-release-scl-rh yum -y install rh-ruby23 scl enable rh-ruby23 bash gem install redis 安装完成后,我们可以使用 ruby -v 查看版本信息。\nRuby环境安装完成后。运行如下命令:\nredis-trib.rb create --replicas 1 192.168.14.101:6379 192.168.14.102:6380 192.168.14.103:6381 192.168.14.101:6382 192.168.14.102:6383 192.168.14.103:6384\n前面我们就说过,redis5.0之后已经将redis-trib.rb 脚本的功能全部集成到redis-cli中了,所以我们直接使用如下命令即可:\nredis-cli -h node3 -p 6379 cluster reset hard\n此时所有节点都是master且已经在运行中\n此时运行\n# redis-cli -a ${password} --cluster create 192.168.1.101:6379 192.168.1.102:6379 192.168.1.103:6379 192.168.1.103:6380 192.168.1.101:6380 192.168.1.102:6380 --cluster-replicas 1 # 如果有密码,一般情况下集群下的所有节点使用同样的密码 redis-cli --cluster create 192.168.1.101:6379 192.168.1.102:6379 192.168.1.103:6379 192.168.1.103:6380 192.168.1.101:6380 192.168.1.102:6380 --cluster-replicas 1 \u0026gt;\u0026gt;\u0026gt; Performing hash slots allocation on 6 nodes... Master[0] -\u0026gt; Slots 0 - 5460 Master[1] -\u0026gt; Slots 5461 - 10922 Master[2] -\u0026gt; Slots 10923 - 16383 Adding replica 192.168.1.102:6380 to 192.168.1.101:6379 Adding replica 192.168.1.103:6380 to 192.168.1.102:6379 Adding replica 192.168.1.101:6380 to 192.168.1.103:6379 M: 24ea7569f0a433eb9706d991f21ae49ec21e48cf 192.168.1.101:6379 slots:[0-5460] (5461 slots) master M: 518fc32f556b10d4b8f83bda420d01aaeeb25f51 192.168.1.102:6379 slots:[5461-10922] (5462 slots) master M: c021bdbaf1c3a476616781c25dbc2b3042ed6f10 192.168.1.103:6379 slots:[10923-16383] (5461 slots) master S: 25e44d3ff2d94400b3c53d66993fc99332adffe4 192.168.1.103:6380 replicates 518fc32f556b10d4b8f83bda420d01aaeeb25f51 S: a6159c5dda95017ba5433f597ea4d18780868dfc 192.168.1.101:6380 replicates c021bdbaf1c3a476616781c25dbc2b3042ed6f10 S: a0e986c4cfb914f34efc8f6ea07cb9b72b615593 192.168.1.102:6380 replicates 24ea7569f0a433eb9706d991f21ae49ec21e48cf Can I set the above configuration? (type \u0026#39;yes\u0026#39; to accept): yes \u0026gt;\u0026gt;\u0026gt; Nodes configuration updated \u0026gt;\u0026gt;\u0026gt; Assign a different config epoch to each node \u0026gt;\u0026gt;\u0026gt; Sending CLUSTER MEET messages to join the cluster Waiting for the cluster to join . \u0026gt;\u0026gt;\u0026gt; Performing Cluster Check (using node 192.168.1.101:6379) M: 24ea7569f0a433eb9706d991f21ae49ec21e48cf 192.168.1.101:6379 slots:[0-5460] (5461 slots) master 1 additional replica(s) S: a0e986c4cfb914f34efc8f6ea07cb9b72b615593 192.168.1.102:6380 slots: (0 slots) slave replicates 24ea7569f0a433eb9706d991f21ae49ec21e48cf S: 25e44d3ff2d94400b3c53d66993fc99332adffe4 192.168.1.103:6380 slots: (0 slots) slave replicates 518fc32f556b10d4b8f83bda420d01aaeeb25f51 M: c021bdbaf1c3a476616781c25dbc2b3042ed6f10 192.168.1.103:6379 slots:[10923-16383] (5461 slots) master 1 additional replica(s) S: a6159c5dda95017ba5433f597ea4d18780868dfc 192.168.1.101:6380 slots: (0 slots) slave replicates c021bdbaf1c3a476616781c25dbc2b3042ed6f10 M: 518fc32f556b10d4b8f83bda420d01aaeeb25f51 192.168.1.102:6379 slots:[5461-10922] (5462 slots) master 1 additional replica(s) [OK] All nodes agree about slots configuration. \u0026gt;\u0026gt;\u0026gt; Check for open slots... \u0026gt;\u0026gt;\u0026gt; Check slots coverage... [OK] All 16384 slots covered. #所有槽位都分配成功 随便使用一个节点查询:\nredis-cli -h node1 -p 6380 cluster nodes 25e44d3ff2d94400b3c53d66993fc99332adffe4 192.168.1.103:6380@16380 slave 518fc32f556b10d4b8f83bda420d01aaeeb25f51 0 1681482908718 2 connected 518fc32f556b10d4b8f83bda420d01aaeeb25f51 192.168.1.102:6379@16379 master - 0 1681482906668 2 connected 5461-10922 24ea7569f0a433eb9706d991f21ae49ec21e48cf 192.168.1.101:6379@16379 master - 0 1681482908000 1 connected 0-5460 c021bdbaf1c3a476616781c25dbc2b3042ed6f10 192.168.1.103:6379@16379 master - 0 1681482907000 3 connected 10923-16383 a0e986c4cfb914f34efc8f6ea07cb9b72b615593 192.168.1.102:6380@16380 slave 24ea7569f0a433eb9706d991f21ae49ec21e48cf 0 1681482909743 1 connected a6159c5dda95017ba5433f597ea4d18780868dfc 192.168.1.101:6380@16380 myself,slave c021bdbaf1c3a476616781c25dbc2b3042ed6f10 0 1681482908000 3 connected 如上,槽位都已经平均分配完,且主从关系也配置好了\n弊端:通过该方式创建的带有从节点的机器不能够自己手动指定主节点,所以如果需要指定的话,需要自己手动指定\na: 先使用redis-cli --cluster create 192.168.163.132:6379 192.168.163.132:6380 192.168.163.132:6381\nb:或```redis-cli \u0026ndash;cluster add-node 192.168.163.132:6382 192.168.163.132:6379 说明:b:为一个指定集群添加节点,需要先连到该集群的任意一个节点IP(192.168.163.132:6379),再把新节点加入。该2个参数的顺序有要求:新加入的节点放前面 通过redis-cli --cluster add-node 192.168.163.132:6382 192.168.163.132:6379 --cluster-slave --cluster-master-id 117457eab5071954faab5e81c3170600d5192270来处理。 说明:把6382节点加入到6379节点的集群中,并且当做node_id为 117457eab5071954faab5e81c3170600d5192270 的从节点。如果不指定 \u0026ndash;cluster-master-id 会随机分配到任意一个主节点。 总结:也就是先创建主节点,再创建从节点就是了 MOVED重定向 # redis-cli -h node1 -p 6379 node1:6379\u0026gt; set k1 \u0026#34;v1\u0026#34; (error) MOVED 12706 192.168.1.103:6379 node1:6379\u0026gt; get k1 (error) MOVED 12706 192.168.1.103:6379 //上面没有设置成功,(连接时)使用下面的命令,Redis集群会自动进行MOVED重定向\nredis-cli -c -h node1 -p 6379 node1:6379\u0026gt; get k1 -\u0026gt; Redirected to slot [12706] located at 192.168.1.103:6379 (nil) 192.168.1.103:6379\u0026gt; set k1 \u0026#34;v1\u0026#34; OK 192.168.1.103:6379\u0026gt; get k1 \u0026#34;v1\u0026#34; #如上,会自动给你切换到slot对应的机器上 //在master3的slave3上查找数据\nredis-cli -h node2 -p 6380 -c node2:6380\u0026gt; keys * 1) \u0026#34;k1\u0026#34; node2:6380\u0026gt; get k1 -\u0026gt; Redirected to slot [12706] located at 192.168.1.103:6379 \u0026#34;v1\u0026#34; ## 1 只有master分配了槽位,所以会重定向到master3去取数据 ## 2 同一个槽位不能同时分配给2个节点 ## 3 在redis的官方文档中,对redis-cluster架构上,有这样的说明:在cluster架构下,默认的,一般redis-master用于接收读写,而redis-slave则用于备份,当有请求是在向slave发起时,会直接重定向到对应key所在的master来处理。但如果不介意读取的是redis-cluster中有可能过期的数据并且对写请求不感兴趣时,则亦可通过readonly命令,将slave设置成可读,然后通过slave获取相关的key,达到读写分离。 # readOnly设置 redis-cli -h node2 -p 6380 node2:6380\u0026gt; get k1 (error) MOVED 12706 192.168.1.103:6379 node2:6380\u0026gt; keys * 1) \u0026#34;k1\u0026#34; node2:6380\u0026gt; readonly OK node2:6380\u0026gt; keys * 1) \u0026#34;k1\u0026#34; node2:6380\u0026gt; get k1 \u0026#34;v1\u0026#34; ## 重置Readonly node2:6380\u0026gt; readwrite OK node2:6380\u0026gt; get k1 (error) MOVED 12706 192.168.1.103:6379 故障转移 # 关闭前\nnode2:6379\u0026gt; cluster nodes f6cf3978d3397582c87480f8c335297675d4354a 192.168.1.103:6380@16380 slave fd6acb4af8afa5ddd31cf559ee2c80ffcbea456f 0 1681470413429 0 connected fd6acb4af8afa5ddd31cf559ee2c80ffcbea456f 192.168.1.101:6379@16379 master - 0 1681470414459 0 connected 0-5461 95b2dcd681674398d22817728af08c31d4bd4872 192.168.1.102:6379@16379 myself,master - 0 1681470413000 4 connected 5462-10922 f1151c2350820b35e117d3c32b59b64917688745 192.168.1.103:6379@16379 master - 0 1681470412000 5 connected 10923-16383 83bfb30e39e3040397d995a7b1f560e8fb53c6a9 192.168.1.102:6380@16380 slave f1151c2350820b35e117d3c32b59b64917688745 0 1681470412397 5 connected 4e9452afe7a8f53dc546b0436109bef570e03888 192.168.1.101:6380@16380 slave 95b2dcd681674398d22817728af08c31d4bd4872 0 1681470411371 4 connected 关闭node1 master\nredis-cli -h node1 -p 6379 shutdown 如下,node3的slave变成了master\nredis-cli -h node1 -p 6380 node1:6380\u0026gt; cluster nodes f6cf3978d3397582c87480f8c335297675d4354a 192.168.1.103:6380@16380 master - 0 1681470479000 6 connected 0-5461 ###这里升级成了master,槽位也转移了 83bfb30e39e3040397d995a7b1f560e8fb53c6a9 192.168.1.102:6380@16380 slave f1151c2350820b35e117d3c32b59b64917688745 0 1681470479664 5 connected 4e9452afe7a8f53dc546b0436109bef570e03888 192.168.1.101:6380@16380 myself,slave 95b2dcd681674398d22817728af08c31d4bd4872 0 1681470479000 4 connected fd6acb4af8afa5ddd31cf559ee2c80ffcbea456f 192.168.1.101:6379@16379 master,fail - 1681470463058 1681470459947 0 disconnected 95b2dcd681674398d22817728af08c31d4bd4872 192.168.1.102:6379@16379 master - 0 1681470478616 4 connected 5462-10922 f1151c2350820b35e117d3c32b59b64917688745 192.168.1.103:6379@16379 master - 0 1681470480698 5 connected 10923-16383 此时将6379再次上线\nredis-server /usr/local/redis_cluster/redis_6379/conf/redis.conf ## 此时node1的6379变成了node3的6380的从库 node1:6379\u0026gt; cluster nodes f6cf3978d3397582c87480f8c335297675d4354a 192.168.1.103:6380@16380 master - 0 1681470625000 6 connected 0-5461 f1151c2350820b35e117d3c32b59b64917688745 192.168.1.103:6379@16379 master - 0 1681470628003 5 connected 10923-16383 83bfb30e39e3040397d995a7b1f560e8fb53c6a9 192.168.1.102:6380@16380 slave f1151c2350820b35e117d3c32b59b64917688745 0 1681470626979 5 connected 4e9452afe7a8f53dc546b0436109bef570e03888 192.168.1.101:6380@16380 slave 95b2dcd681674398d22817728af08c31d4bd4872 0 1681470627000 4 connected 95b2dcd681674398d22817728af08c31d4bd4872 192.168.1.102:6379@16379 master - 0 1681470627000 4 connected 5462-10922 fd6acb4af8afa5ddd31cf559ee2c80ffcbea456f 192.168.1.101:6379@16379 myself,slave f6cf3978d3397582c87480f8c335297675d4354a 0 1681470625000 6 connected 集群扩容 # 当前集群状态 # ▶ redis-cli -h node1 -p 6379 cluster nodes f635a8cdaa48e04f2531d28c103bea9dc2d8f48d 192.168.1.102:6380@16380 slave f9d707317348314a7306fdaf91da2d153590140e 0 1681527313557 5 connected f49300c718a7e0baf6d3e8ba4bf7e9915e8051cc 192.168.1.101:6380@16380 slave 9e9613cec2fdd48000509e9c3723d157263edd87 0 1681527313000 4 connected 9ea59136c61207347657503fd7a78349f57e919e 192.168.1.103:6380@16380 slave fff7298fa77799434bc8ef6c74c974c21ebc47b4 0 1681527314000 0 connected fff7298fa77799434bc8ef6c74c974c21ebc47b4 192.168.1.101:6379@16379 myself,master - 0 1681527313000 0 connected 0-5461 9e9613cec2fdd48000509e9c3723d157263edd87 192.168.1.102:6379@16379 master - 0 1681527314579 4 connected 5462-10922 f9d707317348314a7306fdaf91da2d153590140e 192.168.1.103:6379@16379 master - 0 1681527312000 5 connected 10923-16383 新增节点配置并启动 # 准备 # 假设在node3新增两个端口{6390,6391},作为新节点\n且 node3:6391 replicate node3:6390\n步骤:通过mkdir -p /usr/local/redis_cluster/redis_63{91,90}/{conf,pid,logs}创建文件夹,然后再conf目录下配置集群配置文件\n# 守护进行模式启动 daemonize yes # 设置数据库数量,默认数据库为0 databases 16 # 绑定地址,需要修改 bind node3 # 绑定端口,需要修改 port 6390 # pid文件存储位置,文件名需要修改 pidfile /usr/local/redis_cluster/redis_6390/pid/redis_6390.pid # log文件存储位置,文件名需要修改 logfile /usr/local/redis_cluster/redis_6390/logs/redis_6390.log # RDB快照备份文件名,文件名需要修改 dbfilename redis_6390.rdb # 本地数据库存储目录,需要修改 dir /usr/local/redis_cluster/redis_6390 # 集群相关配置 # 是否以集群模式启动 cluster-enabled yes # 集群节点回应最长时间,超过该时间被认为下线 cluster-node-timeout 15000 # 生成的集群节点配置文件名,文件名需要修改 cluster-config-file nodes_6390.conf 目录结构\nroot@centos7103:/usr/local/redis_cluster ▶ ls redis6 redis_6379 redis_6380 redis_6390 redis_6391 ▶ tree *90 redis_6390 ├── conf │ └── redis.conf ├── logs └── pid 3 directories, 1 file 启动节点\n# 两个孤儿节点 root@centos7103:/usr/local/redis_cluster ⍉ ▶ redis-server /usr/local/redis_cluster/redis_6390/conf/redis.conf root@centos7103:/usr/local/redis_cluster ▶ redis-server /usr/local/redis_cluster/redis_6391/conf/redis.conf root@centos7103:/usr/local/redis_cluster ▶ netstat -lntup |grep redis tcp 0 0 192.168.1.103:6379 0.0.0.0:* LISTEN 3484/redis-server n tcp 0 0 192.168.1.103:6380 0.0.0.0:* LISTEN 3507/redis-server n tcp 0 0 192.168.1.103:6390 0.0.0.0:* LISTEN 5590/redis-server n tcp 0 0 192.168.1.103:6391 0.0.0.0:* LISTEN 5616/redis-server n tcp 0 0 192.168.1.103:16379 0.0.0.0:* LISTEN 3484/redis-server n tcp 0 0 192.168.1.103:16380 0.0.0.0:* LISTEN 3507/redis-server n tcp 0 0 192.168.1.103:16390 0.0.0.0:* LISTEN 5590/redis-server n tcp 0 0 192.168.1.103:16391 0.0.0.0:* LISTEN 5616/redis-server n 添加主节点 # 将新节点加入到node1:6379 [0,5460]所在的集群中\n加入前\nredis-cli -h node3 -p 6390 node3:6390\u0026gt; cluster nodes b014cfbeff6f9668ec9592cbc8aa874bda2d8d6b :6390@16390 myself,master - 0 0 0 connected 加入\n# 在node1客户端操作,将103:6390添加到101:6379所在的集群中 redis-cli -h node1 -p 6379 --cluster add-node 192.168.1.103:6390 192.168.1.101:6379 \u0026gt;\u0026gt;\u0026gt; Adding node 192.168.1.103:6390 to cluster 192.168.1.101:6379 \u0026gt;\u0026gt;\u0026gt; Performing Cluster Check (using node 192.168.1.101:6379) M: fff7298fa77799434bc8ef6c74c974c21ebc47b4 192.168.1.101:6379 slots:[0-5461] (5462 slots) master 1 additional replica(s) S: f635a8cdaa48e04f2531d28c103bea9dc2d8f48d 192.168.1.102:6380 slots: (0 slots) slave replicates f9d707317348314a7306fdaf91da2d153590140e S: f49300c718a7e0baf6d3e8ba4bf7e9915e8051cc 192.168.1.101:6380 slots: (0 slots) slave replicates 9e9613cec2fdd48000509e9c3723d157263edd87 S: 9ea59136c61207347657503fd7a78349f57e919e 192.168.1.103:6380 slots: (0 slots) slave replicates fff7298fa77799434bc8ef6c74c974c21ebc47b4 M: 9e9613cec2fdd48000509e9c3723d157263edd87 192.168.1.102:6379 slots:[5462-10922] (5461 slots) master 1 additional replica(s) M: f9d707317348314a7306fdaf91da2d153590140e 192.168.1.103:6379 slots:[10923-16383] (5461 slots) master 1 additional replica(s) [OK] All nodes agree about slots configuration. \u0026gt;\u0026gt;\u0026gt; Check for open slots... \u0026gt;\u0026gt;\u0026gt; Check slots coverage... [OK] All 16384 slots covered. \u0026gt;\u0026gt;\u0026gt; Send CLUSTER MEET to node 192.168.1.103:6390 to make it join the cluster. [OK] New node added correctly. 加入后\n▶ redis-cli -h node1 -p 6379 cluster nodes b014cfbeff6f9668ec9592cbc8aa874bda2d8d6b 192.168.1.103:6390@16390 master - 0 1681527533967 6 connected f635a8cdaa48e04f2531d28c103bea9dc2d8f48d 192.168.1.102:6380@16380 slave f9d707317348314a7306fdaf91da2d153590140e 0 1681527534990 5 connected f49300c718a7e0baf6d3e8ba4bf7e9915e8051cc 192.168.1.101:6380@16380 slave 9e9613cec2fdd48000509e9c3723d157263edd87 0 1681527533000 4 connected 9ea59136c61207347657503fd7a78349f57e919e 192.168.1.103:6380@16380 slave fff7298fa77799434bc8ef6c74c974c21ebc47b4 0 1681527533000 0 connected fff7298fa77799434bc8ef6c74c974c21ebc47b4 192.168.1.101:6379@16379 myself,master - 0 1681527529000 0 connected 0-5461 9e9613cec2fdd48000509e9c3723d157263edd87 192.168.1.102:6379@16379 master - 0 1681527534000 4 connected 5462-10922 f9d707317348314a7306fdaf91da2d153590140e 192.168.1.103:6379@16379 master - 0 1681527533000 5 connected 10923-16383 为他分配槽位\n# 最后一个参数,表示原来集群中任意一个节点,这里会将源节点所在集群的一部分分给新增节点 redis-cli -h node1 -p 6379 --cluster reshard 192.168.1.101:6379 ##过程 #后面的2000表示分配2000个槽位给新增节点 How many slots do you want to move (from 1 to 16384)? 2000 #输入 #表示接受节点的NodeId,填新增节点6390的 What is the receiving node ID? b014cfbeff6f9668ec9592cbc8aa874bda2d8d6b #输入 #这里填槽的来源,要么填all,表示所有master节点都拿出一部分槽位分配给新增节点; #要么填某个原有NodeId,表示这个节点拿出一部分槽位给新增节点 Please enter all the source node IDs. Type \u0026#39;all\u0026#39; to use all the nodes as source nodes for the hash slots. Type \u0026#39;done\u0026#39; once you entered all the source nodes IDs. Source node #1: 7e900adc7f977cfcccef12d48c7a29b64c4344c2 Source node #2: done # 这里把node1:6379 拿出了2000个槽位给新节点 结果:\n? redis-cli -h node1 -p 6380 cluster nodes 259b65d7f3d1eac2716f7ae00cc6c1db27a55b27 192.168.1.103:6379@16379 master - 0 1681530641000 2 connected 10923-16383 429ed631dbf09ba846a5371b707defe17b9f8c8e 192.168.1.101:6380@16380 myself,slave 9355d72df6e9dc2643ac1c819cd2e496fb1aed60 0 1681530643000 4 connected 9355d72df6e9dc2643ac1c819cd2e496fb1aed60 192.168.1.102:6379@16379 master - 0 1681530641000 4 connected 5462-10922 81e1e03230ed7700028fa56155e9531b48791164 192.168.1.103:6390@16390 master - 0 1681530644122 6 connected 0-1999 a04347e1af8930324dab7ae85f912449475a487f 192.168.1.102:6380@16380 slave 259b65d7f3d1eac2716f7ae00cc6c1db27a55b27 0 1681530643093 2 connected 92a9d6b988dcf8a219de0247975d8e341072134d 192.168.1.103:6380@16380 slave 7e900adc7f977cfcccef12d48c7a29b64c4344c2 0 1681530643000 1 connected 7e900adc7f977cfcccef12d48c7a29b64c4344c2 192.168.1.101:6379@16379 master - 0 1681530642063 1 connected 2000-5461 添加从节点 # 将节点添加到集群中\n▶ redis-cli -h node1 -p 6379 --cluster add-node 192.168.1.103:6391 192.168.1.101:6379 \u0026gt;\u0026gt;\u0026gt; Adding node 192.168.1.103:6391 to cluster 192.168.1.101:6379 \u0026gt;\u0026gt;\u0026gt; Performing Cluster Check (using node 192.168.1.101:6379) M: 7e900adc7f977cfcccef12d48c7a29b64c4344c2 192.168.1.101:6379 slots:[2000-5461] (3462 slots) master 1 additional replica(s) M: 9355d72df6e9dc2643ac1c819cd2e496fb1aed60 192.168.1.102:6379 slots:[5462-10922] (5461 slots) master 1 additional replica(s) S: a04347e1af8930324dab7ae85f912449475a487f 192.168.1.102:6380 slots: (0 slots) slave replicates 259b65d7f3d1eac2716f7ae00cc6c1db27a55b27 M: 259b65d7f3d1eac2716f7ae00cc6c1db27a55b27 192.168.1.103:6379 slots:[10923-16383] (5461 slots) master 1 additional replica(s) M: 81e1e03230ed7700028fa56155e9531b48791164 192.168.1.103:6390 slots:[0-1999] (2000 slots) master S: 429ed631dbf09ba846a5371b707defe17b9f8c8e 192.168.1.101:6380 slots: (0 slots) slave replicates 9355d72df6e9dc2643ac1c819cd2e496fb1aed60 S: 92a9d6b988dcf8a219de0247975d8e341072134d 192.168.1.103:6380 slots: (0 slots) slave replicates 7e900adc7f977cfcccef12d48c7a29b64c4344c2 [OK] All nodes agree about slots configuration. \u0026gt;\u0026gt;\u0026gt; Check for open slots... \u0026gt;\u0026gt;\u0026gt; Check slots coverage... [OK] All 16384 slots covered. \u0026gt;\u0026gt;\u0026gt; Send CLUSTER MEET to node 192.168.1.103:6391 to make it join the cluster. [OK] New node added correctly. 建立主从关系\n▶ redis-cli -h node1 -p 6380 cluster nodes 9babc7adc86da25ba501bd5bc007300dc04743a9 192.168.1.103:6391@16391 master - 0 1681530812000 0 connected 259b65d7f3d1eac2716f7ae00cc6c1db27a55b27 192.168.1.103:6379@16379 master - 0 1681530808000 2 connected 10923-16383 429ed631dbf09ba846a5371b707defe17b9f8c8e 192.168.1.101:6380@16380 myself,slave 9355d72df6e9dc2643ac1c819cd2e496fb1aed60 0 1681530811000 4 connected 9355d72df6e9dc2643ac1c819cd2e496fb1aed60 192.168.1.102:6379@16379 master - 0 1681530810000 4 connected 5462-10922 81e1e03230ed7700028fa56155e9531b48791164 192.168.1.103:6390@16390 master - 0 1681530810000 6 connected 0-1999 a04347e1af8930324dab7ae85f912449475a487f 192.168.1.102:6380@16380 slave 259b65d7f3d1eac2716f7ae00cc6c1db27a55b27 0 1681530811246 2 connected 92a9d6b988dcf8a219de0247975d8e341072134d 192.168.1.103:6380@16380 slave 7e900adc7f977cfcccef12d48c7a29b64c4344c2 0 1681530812275 1 connected 7e900adc7f977cfcccef12d48c7a29b64c4344c2 192.168.1.101:6379@16379 master - 0 1681530809182 1 connected 2000-5461 root@centos7101:/usr/local/redis_cluster ▶ redis-cli -h node3 -p 6391 cluster replicate 81e1e03230ed7700028fa56155e9531b48791164 OK root@centos7101:/usr/local/redis_cluster # 验证 ▶ redis-cli -h node1 -p 6380 cluster nodes 9babc7adc86da25ba501bd5bc007300dc04743a9 192.168.1.103:6391@16391 slave 81e1e03230ed7700028fa56155e9531b48791164 0 1681530870000 6 connected 259b65d7f3d1eac2716f7ae00cc6c1db27a55b27 192.168.1.103:6379@16379 master - 0 1681530868642 2 connected 10923-16383 429ed631dbf09ba846a5371b707defe17b9f8c8e 192.168.1.101:6380@16380 myself,slave 9355d72df6e9dc2643ac1c819cd2e496fb1aed60 0 1681530867000 4 connected 9355d72df6e9dc2643ac1c819cd2e496fb1aed60 192.168.1.102:6379@16379 master - 0 1681530871715 4 connected 5462-10922 81e1e03230ed7700028fa56155e9531b48791164 192.168.1.103:6390@16390 master - 0 1681530868000 6 connected 0-1999 a04347e1af8930324dab7ae85f912449475a487f 192.168.1.102:6380@16380 slave 259b65d7f3d1eac2716f7ae00cc6c1db27a55b27 0 1681530869000 2 connected 92a9d6b988dcf8a219de0247975d8e341072134d 192.168.1.103:6380@16380 slave 7e900adc7f977cfcccef12d48c7a29b64c4344c2 0 1681530870000 1 connected 7e900adc7f977cfcccef12d48c7a29b64c4344c2 192.168.1.101:6379@16379 master - 0 1681530870693 1 connected 2000-5461 测试\nnode1:6380\u0026gt; set 18 a -\u0026gt; Redirected to slot [511] located at 192.168.1.103:6390 OK 192.168.1.103:6390\u0026gt; get 18 \u0026#34;a\u0026#34; #在node3:6391上尝试-\u0026gt;说明从机上是有数据的 ▶ redis-cli -h node3 -p 6391 node3:6391\u0026gt; get 18 (error) MOVED 511 192.168.1.103:6390 node3:6391\u0026gt; readonly OK node3:6391\u0026gt; get 18 \u0026#34;a\u0026#34; node3:6391\u0026gt; keys * 1) \u0026#34;18\u0026#34; 集群收缩 # 迁移待移除节点的槽位 # #当前节点信息 429ed631dbf09ba846a5371b707defe17b9f8c8e 192.168.1.101:6380@16380 slave 9355d72df6e9dc2643ac1c819cd2e496fb1aed60 0 1681531264142 4 connected 7e900adc7f977cfcccef12d48c7a29b64c4344c2 192.168.1.101:6379@16379 master - 0 1681531260000 1 connected 2000-5461 9355d72df6e9dc2643ac1c819cd2e496fb1aed60 192.168.1.102:6379@16379 myself,master - 0 1681531261000 4 connected 5462-10922 259b65d7f3d1eac2716f7ae00cc6c1db27a55b27 192.168.1.103:6379@16379 master - 0 1681531262088 2 connected 10923-16383 9babc7adc86da25ba501bd5bc007300dc04743a9 192.168.1.103:6391@16391 slave 81e1e03230ed7700028fa56155e9531b48791164 0 1681531265170 6 connected 81e1e03230ed7700028fa56155e9531b48791164 192.168.1.103:6390@16390 master - 0 1681531264000 6 connected 0-1999 92a9d6b988dcf8a219de0247975d8e341072134d 192.168.1.103:6380@16380 slave 7e900adc7f977cfcccef12d48c7a29b64c4344c2 0 1681531263115 1 connected a04347e1af8930324dab7ae85f912449475a487f 192.168.1.102:6380@16380 slave 259b65d7f3d1eac2716f7ae00cc6c1db27a55b27 0 1681531260038 2 connected 移除并将槽位分配给其他节点\nredis-cli -p 6379 -h node1 --cluster reshard --cluster-from bee9c03b1c4592119695a17472847736128c8603 --cluster-to 644b722eb996aeb392a8190b29cfdbe95536af9a --cluster-slots 2000 192.168.1.101:6379 # 用哪个客户端,最后的ip:host-\u0026gt;对该ip host所在集群的from和to操作,进行转移 # 结果 redis-cli -h node1 -p 6380 cluster nodes bee9c03b1c4592119695a17472847736128c8603 192.168.1.103:6390@16390 master - 0 1681532501000 6 connected f525c38c1a78e997a96315ca982f969c51500e86 192.168.1.102:6379@16379 master - 0 1681532501000 0 connected 5462-10922 2b905b7e2480d80bb7c7aa47940e9636697a7d4c 192.168.1.103:6379@16379 master - 0 1681532503071 2 connected 10923-16383 644b722eb996aeb392a8190b29cfdbe95536af9a 192.168.1.101:6379@16379 master - 0 1681532502000 8 connected 0-5461 180113f8ceeba0b17b4a122caa62d36e99141225 192.168.1.103:6391@16391 slave 644b722eb996aeb392a8190b29cfdbe95536af9a 0 1681532503000 8 connected 576e15ed8ac1f4632e5f0917c43d41f7e26dc1e0 192.168.1.101:6380@16380 myself,slave f525c38c1a78e997a96315ca982f969c51500e86 0 1681532500000 0 connected 7ff6ce4b934027c1cdb8720169873f8e97474885 192.168.1.102:6380@16380 slave 2b905b7e2480d80bb7c7aa47940e9636697a7d4c 0 1681532504083 2 connected 75f8df2756a83c121b5637e3a381fa8ebfb9204d 192.168.1.103:6380@16380 slave 644b722eb996aeb392a8190b29cfdbe95536af9a 0 1681532501053 8 connected ## 查看 ▶ redis-cli -h node1 -p 6379 node1:6379\u0026gt; get 18 \u0026#34;a\u0026#34; node1:6379\u0026gt; keys * 1) \u0026#34;18\u0026#34; node1:6379\u0026gt; exit ## 看看还在不在103:6390上 redis-cli -h node3 -p 6390 node3:6390\u0026gt; keys * (empty array) node3:6390\u0026gt; get 18 (error) MOVED 511 192.168.1.101:6379 槽位调整成功\n注意,node3:6391原本replicate node3:6390,但是node3:6390没有槽位了,所以他就跟到槽位所在的node上了,即:\n644b722eb996aeb392a8190b29cfdbe95536af9a 192.168.1.101:6379@16379 master - 0 1681532502000 8 connected 0-5461 180113f8ceeba0b17b4a122caa62d36e99141225 192.168.1.103:6391@16391 slave 644b722eb996aeb392a8190b29cfdbe95536af9a 0 1681532503000 8 connected 移除待删除的主从节点 # 先移除从节点,再移除主节点,防止触发集群故障转移(如上,这里可能并不会,因为已经没有节点replicate node3:6390了)\nredis-cli -p 6379 -h node1 --cluster del-node 192.168.1.102:6380 180113f8ceeba0b17b4a122caa62d36e99141225 #ip+port :哪个节点所在的集群 #nodeId \u0026gt;\u0026gt;\u0026gt; Removing node 180113f8ceeba0b17b4a122caa62d36e99141225 from cluster 192.168.1.102:6380 \u0026gt;\u0026gt;\u0026gt; Sending CLUSTER FORGET messages to the cluster... \u0026gt;\u0026gt;\u0026gt; Sending CLUSTER RESET SOFT to the deleted node. 移除主节点\nredis-cli -p 6379 -h node1 --cluster del-node 192.168.1.102:6380 bee9c03b1c4592119695a17472847736128c8603 \u0026gt;\u0026gt;\u0026gt; Removing node bee9c03b1c4592119695a17472847736128c8603 from cluster 192.168.1.102:6380 \u0026gt;\u0026gt;\u0026gt; Sending CLUSTER FORGET messages to the cluster... \u0026gt;\u0026gt;\u0026gt; Sending CLUSTER RESET SOFT to the deleted node. 查看状态(移除成功)\n▶ redis-cli -h node1 -p 6379 cluster nodes 644b722eb996aeb392a8190b29cfdbe95536af9a 192.168.1.101:6379@16379 myself,master - 0 1681533116000 8 connected 0-5461 75f8df2756a83c121b5637e3a381fa8ebfb9204d 192.168.1.103:6380@16380 slave 644b722eb996aeb392a8190b29cfdbe95536af9a 0 1681533119000 8 connected f525c38c1a78e997a96315ca982f969c51500e86 192.168.1.102:6379@16379 master - 0 1681533120514 0 connected 5462-10922 2b905b7e2480d80bb7c7aa47940e9636697a7d4c 192.168.1.103:6379@16379 master - 0 1681533119492 2 connected 10923-16383 576e15ed8ac1f4632e5f0917c43d41f7e26dc1e0 192.168.1.101:6380@16380 slave f525c38c1a78e997a96315ca982f969c51500e86 0 1681533118463 0 connected 7ff6ce4b934027c1cdb8720169873f8e97474885 192.168.1.102:6380@16380 slave 2b905b7e2480d80bb7c7aa47940e9636697a7d4c 0 1681533118000 2 connected cluster命令 # 以下是集群中常用的可执行命令,命令执行格式为:\ncluster 下表命令 命令如下,未全,如果想了解更多请执行cluster help操作:\n命令 描述 INFO 返回当前集群信息 MEET \u0026lt;ip\u0026gt; \u0026lt;port\u0026gt; [\u0026lt;bus-port\u0026gt;] 添加一个节点至当前集群 MYID 返回当前节点集群ID NODES 返回当前节点的集群信息 REPLICATE \u0026lt;node-id\u0026gt; 将当前节点作为某一集群节点的从库 FAILOVER [FORCE|TAKEOVER] 将当前从库升级为主库 RESET [HARD|SOFT] 重置当前节点信息 ADDSLOTS \u0026lt;slot\u0026gt; [\u0026lt;slot\u0026gt; ...] 为当前集群节点增加一个或多个插槽位,推荐在bash shell中执行,可通过{int..int}指定多个插槽位 DELSLOTS \u0026lt;slot\u0026gt; [\u0026lt;slot\u0026gt; ...] 为当前集群节点删除一个或多个插槽位,推荐在bash shell中执行,可通过{int..int}指定多个插槽位 FLUSHSLOTS 删除当前节点中所有的插槽信息 FORGET \u0026lt;node-id\u0026gt; 从集群中删除某一节点 COUNT-FAILURE-REPORTS \u0026lt;node-id\u0026gt; 返回当前集群节点的故障报告数量 COUNTKEYSINSLOT \u0026lt;slot\u0026gt; 返回某一插槽中的键的数量 GETKEYSINSLOT \u0026lt;slot\u0026gt; \u0026lt;count\u0026gt; 返回当前节点存储在插槽中的key名称。 KEYSLOT \u0026lt;key\u0026gt; 返回该key的哈希槽位 SAVECONFIG 保存当前集群配置,进行落盘操作 SLOTS 返回该插槽的信息 SpringBoot+RedisCluster # 依赖\n\u0026lt;dependency\u0026gt; \u0026lt;groupId\u0026gt;org.springframework.boot\u0026lt;/groupId\u0026gt; \u0026lt;artifactId\u0026gt;spring-boot-starter-data-redis\u0026lt;/artifactId\u0026gt; \u0026lt;/dependency\u0026gt; 配置文件yml\nspring: redis: # 如果是redis-cluster 不能用这种形式,否则会报错,只适合单机 #Error in execution; nested exception is io.lettuce.core. #RedisCommandExecutionException: MOVED 15307 192.168.1.103:6379 #host: 192.168.1.102 #port: 6380 # 下面的配置,nodes写一个或者多个都行 cluster: nodes: # - 192.168.1.101:6379 # - 192.168.1.102:6379 # - 192.168.1.101:6380 - 192.168.1.102:6380 # - 192.168.1.103:6379 # - 192.168.1.103:6380 序列化处理\n@Configuration public class RedisConfig { @Bean public RedisTemplate\u0026lt;String, Object\u0026gt; redisTemplate(RedisConnectionFactory redisConnectionFactory) { RedisTemplate\u0026lt;String, Object\u0026gt; template = new RedisTemplate\u0026lt;\u0026gt;(); RedisSerializer\u0026lt;String\u0026gt; redisSerializer = new StringRedisSerializer(); template.setConnectionFactory(redisConnectionFactory); //key序列化方式 template.setKeySerializer(redisSerializer); //value序列化 template.setValueSerializer(redisSerializer); //value hashmap序列化 template.setHashValueSerializer(redisSerializer); //key haspmap序列化 template.setHashKeySerializer(redisSerializer); // return template; } } 使用\n@Autowired private RedisTemplate\u0026lt;String, Object\u0026gt; redisTemplate; @RequestMapping(\u0026#34;/redisTest\u0026#34;) public String redisTest(){ redisTemplate.opsForValue().set(\u0026#34;190\u0026#34;,\u0026#34;hello,world\u0026#34;+new Date().getTime()); Object hello = redisTemplate.opsForValue().get(\u0026#34;190\u0026#34;); return hello.toString(); } RedisCluster架构原理分析 # 基础架构(数据分片) # 集群分片原理 # 如果有任意1个槽位没有被分配,则集群创建不成功。\n启动集群原理 # 集群通信原理 # "},{"id":235,"href":"/zh/docs/technology/Other/kaoshi/","title":"科目","section":"其他","content":" 科目 # 1022/9:00-11:30\n00024 普通逻辑 2010 02197 概率论与数理统计(二)2018 02318 计算机组成原理 2016 02324 离散数学 2014 02331 数据结构 2012 03709 马克思主义基本原理概论 2018 04747 Java语言程序设计(一) 2019 1022/14:30-17:00\n00023 高等数学(工本) 2019 00342 高级语言程序设计(一)2017 02326 操作系统 2017 04730 电子技术基础(三) 2006 04735 数据库系统原理 2018 1023/09:00-11:30\n02325 计算机系统结构 2012 03708 中国近现代史纲要 2018 04737 C++程序设计 2019 1023/14:30-17:00\n0015 英语(二)2012 02333 软件工程 2011 04741 计算机网络原理 2018 "},{"id":236,"href":"/zh/docs/technology/Linux/basic/","title":"基本操作","section":"Linux","content":" yum源替换成阿里云 # yum install -y wget ## 备份 mv /etc/yum.repos.d/CentOS-Base.repo /etc/yum.repos.d/CentOS-Base.repo.bak ## 下载 wget -O /etc/yum.repos.d/CentOS-Base.repo http://mirrors.aliyun.com/repo/Centos-7.repo ## 重建缓存 yum clean all yum makecache Java环境搭建 # yum search java | grep jdk yum install -y java-1.8.0-openjdk-devel.x86_64 # java -version 正常 # javac -version 正常 解压相关 # -zxvf\ntar -zxvf redis* -C /usr/local/redis* # z :表示 tar 包是被 gzip 压缩过的 (后缀是.tar.gz),所以解压时需要用 gunzip 解压 (.tar不需要) # x :表示 从 tar 包中把文件提取出来 # v :表示 显示打包过程详细信息 # f :指定被处理的文件是什么 # 适用于参数分开使用的情况,连续无分隔参数不应该再使用(所以上面的命令不标准), # 应该是 tar zxvf redis* -C /usr/local/redis* 主题修改 # oh my zsh\n在线 # Method Command curl sh -c \u0026quot;$(curl -fsSL https://raw.githubusercontent.com/ohmyzsh/ohmyzsh/master/tools/install.sh)\u0026quot; wget sh -c \u0026quot;$(wget -O- https://raw.githubusercontent.com/ohmyzsh/ohmyzsh/master/tools/install.sh)\u0026quot; fetch sh -c \u0026quot;$(fetch -o - https://raw.githubusercontent.com/ohmyzsh/ohmyzsh/master/tools/install.sh)\u0026quot; 手动安装 # yum install -y zsh #一定要先装 sh -c \u0026#34;$(wget https://gitee.com/liu_yi_er/ohmyzsh/raw/master/tools/install.sh -O -)\u0026#34; #自己的gitee目录,从官网下载 sh install.sh 修改主题 # //该主题样式如下\n$ vi ~/.zshrc # 找到这一行,修改为自己喜欢的主题名称 # ZSH_THEME=\u0026#34;ys\u0026#34; ZSH_THEME=\u0026#34;avit\u0026#34; # 修改保存后,使配置生效 $ source ~/.zshrc zsh home和end失效,可以改用ctrl+a / ctrl+e 代替\nVim的使用快捷使用 # # 清空文件--命令模式下输入 :%d 回车 # 处理粘贴时多出的行带#的问题-- 命令模式下输入 :set paste 再输入i进行粘贴 #快速修改-- 命令模式下输入 :%s/6379/6380/g (将文件中所有6379替换成6380) 基本网络工具安装 # yum install -y net-tools 查看端口监听情况\nnetstat -lntup | grep redis 解释\n-a (all)显示所有选项,默认不显示LISTEN相关 -t (tcp)仅显示tcp相关选项 -u (udp)仅显示udp相关选项 -n 拒绝显示别名,能显示数字的全部转化成数字。 -l 仅列出有在 Listen (监听) 的服務状态\n-p 显示建立相关链接的程序名 -r 显示路由信息,路由表 -e 显示扩展信息,例如uid等 -s 按各个协议进行统计 -c 每隔一个固定时间,执行该netstat命令。\n提示:LISTEN和LISTENING的状态只有用-a或者-l才能看到\nps命令 # ps -ef //-e表示全部进程 ,-f表示全部的列\n树形结构查看文件夹 # yum install -y tree\n快捷键 # ctrl+w 快速删除光标前的整个单词\nctrl+a 光标移到行首 [xshell]\nctrl+e 光标移到行尾 [xshell]\n创建多级目录 # mkdir -p /usr/local/redis_cluster/redis_63{79,80}/{conf,pid,logs}\n"},{"id":237,"href":"/zh/docs/technology/Linux/create_clone/","title":"vmware上linux主机的安装和克隆","section":"Linux","content":" 安装 # 虚拟机向导 # 典型\u0026mdash;稍后安装\u0026ndash;linux\u0026ndash;RedhatEnterpriseLinux7 64 虚拟机名称rheCentos700 接下来都默认即可(20G硬盘,2G内存,网络适配器(桥接模式)) 安装界面 # 日期\u0026ndash;亚洲上海,键盘\u0026ndash;汉语,语言支持\u0026ndash;简体中文(中国)\n软件安装\n最小安装\u0026mdash;\u0026gt; 兼容性程序库+开发工具\n其他存储选项\u0026ndash;配置分区\n/boot 1G 标准分区,文件系统ext4 swap 2G 标准分区 ,文件系统swap / 17G 标准分区,文件系统ext4 网络和主机名\n打开网络+设置主机名(rheCentos700)\n完成\u0026mdash;过程中配置密码 默认用户root+其他用户ly\n安装完成后修改ip及网关 # Centos # vi /etc/sysconfig/network-scripts/ifcfg-ens**\n修改部分键值对\nBOOTPROTO=\u0026#34;static\u0026#34; IPADDR=192.168.1.100 NETMASK=255.255.255.0 GATEWAY=192.168.1.1 DNS1=223.5.5.5 DNS2=223.6.6.6 systemctl restart network\nDebian # 查看当前网卡\nip link #1: lo: \u0026lt;LOOPBACK,UP,LOWER_UP\u0026gt; mtu 65536 qdisc noqueue state UNKNOWN mode DEFAULT group default qlen 1000 # link/loopback 00:00:00:00:00:00 brd 00:00:00:00:00:00 #2: ens33: \u0026lt;BROADCAST,MULTICAST,UP,LOWER_UP\u0026gt; mtu 1500 qdisc fq_codel state UP mode DEFAULT group default qlen 1000 # link/ether 00:0c:29:ed:95:f5 brd ff:ff:ff:ff:ff:ff # altname enp2s1 得知网卡名为ens33\nvim /etc/network/interfaces 添加内容,为网卡(ens33)设置静态ip\n#ly-update auto ens33 iface ens33 inet static address 192.168.1.206 netmask 255.255.255.0 gateway 192.168.1.1 dns-nameservers 223.5.5.5 223.6.6.6 重启网络\nsudo service networking restart 查看ip\nip a #---------------------结果显示 1: lo: \u0026lt;LOOPBACK,UP,LOWER_UP\u0026gt; mtu 65536 qdisc noqueue state UNKNOWN group default qlen 1000 link/loopback 00:00:00:00:00:00 brd 00:00:00:00:00:00 inet 127.0.0.1/8 scope host lo valid_lft forever preferred_lft forever inet6 ::1/128 scope host noprefixroute valid_lft forever preferred_lft forever 2: ens33: \u0026lt;BROADCAST,MULTICAST,UP,LOWER_UP\u0026gt; mtu 1500 qdisc fq_codel state UP group default qlen 1000 link/ether 00:0c:xx:xx:23:f5 brd ff:ff:ff:ff:ff:ff altname enp2s1 inet 192.168.1.206/24 brd 192.168.1.255 scope global ens33 valid_lft forever preferred_lft forever inet6 xxxx::20c:29ff:feed:xxxx/64 scope link valid_lft forever preferred_lft forever 克隆虚拟机 # 右键\u0026ndash;管理\u0026ndash;克隆\u0026ndash;创建完整克隆\n修改MAC、主机名、ip、uuid\n右键\u0026ndash;设置\u0026ndash;网络适配器\u0026ndash;高级\u0026ndash;MAC地址-\u0026gt;生成\nvi /etc/hostname修改主机名\nreboot\nvi /etc/sysconfig/network-scripts/ifcfg-ens**修改ip及uuid\nuuid自动生成\n常用操作 # 常用命令安装\nyum install -y wget yum阿里云源切换\n备份mv /etc/yum.repos.d/CentOS-Base.repo /etc/yum.repos.d/CentOS-Base.repo.bak 下载并切换wget -O /etc/yum.repos.d/CentOS-Base.repo http://mirrors.aliyun.com/repo/Centos-7.repo 清理yum clean all 缓存处理yum makecache "},{"id":238,"href":"/zh/docs/technology/Review/java_guide/database/MySQL/ly0606lymysql-query-execution-plan/","title":"mysql执行计划","section":"MySQL","content":" 转载自https://github.com/Snailclimb/JavaGuide(添加小部分笔记)感谢作者!\n本文来自公号 MySQL 技术,JavaGuide 对其做了补充完善。原文地址:https://mp.weixin.qq.com/s/d5OowNLtXBGEAbT31sSH4g\n优化 SQL 的第一步应该是读懂 SQL 的执行计划。本篇文章,我们一起来学习下 MySQL EXPLAIN 执行计划相关知识。\n什么是执行计划? # 执行计划 是指一条 SQL 语句在经过 MySQL 查询优化器 的优化会后,具体的执行方式。\n执行计划通常用于 SQL 性能分析、优化等场景。通过 EXPLAIN 的结果,可以了解到如数据表的查询顺序、数据查询操作的操作类型、哪些索引可以被命中、哪些索引实际会命中、每个数据表有多少行记录被查询等信息。\n如何获取执行计划? # -- 提交准备数据 SET NAMES utf8mb4; SET FOREIGN_KEY_CHECKS = 0; -- ---------------------------- -- Table structure for dept_emp -- ---------------------------- DROP TABLE IF EXISTS `dept_emp`; CREATE TABLE `dept_emp` ( `id` int(0) NOT NULL, `emp_no` varchar(128) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NULL DEFAULT NULL, `other1` varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NULL DEFAULT NULL, `other2` varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NULL DEFAULT NULL, PRIMARY KEY (`id`) USING BTREE, INDEX `index_emp_no`(`emp_no`) USING BTREE ) ENGINE = InnoDB CHARACTER SET = utf8mb4 COLLATE = utf8mb4_0900_ai_ci ROW_FORMAT = Dynamic; -- ---------------------------- -- Records of dept_emp -- ---------------------------- INSERT INTO `dept_emp` VALUES (1, \u0026#39;a1\u0026#39;, \u0026#39;o11\u0026#39;, \u0026#39;012\u0026#39;); INSERT INTO `dept_emp` VALUES (2, \u0026#39;a2\u0026#39;, \u0026#39;o21\u0026#39;, \u0026#39;o22\u0026#39;); INSERT INTO `dept_emp` VALUES (3, \u0026#39;a3\u0026#39;, \u0026#39;o31\u0026#39;, \u0026#39;o32\u0026#39;); INSERT INTO `dept_emp` VALUES (4, \u0026#39;a4\u0026#39;, \u0026#39;o41\u0026#39;, \u0026#39;o42\u0026#39;); INSERT INTO `dept_emp` VALUES (5, \u0026#39;a5\u0026#39;, \u0026#39;o51\u0026#39;, \u0026#39;o52\u0026#39;); SET FOREIGN_KEY_CHECKS = 1; MySQL 为我们提供了 EXPLAIN 命令,来获取执行计划的相关信息。\n需要注意的是,EXPLAIN 语句并不会真的去执行相关的语句,而是通过查询优化器对语句进行分析,找出最优的查询方案,并显示对应的信息。\nEXPLAIN 执行计划支持 SELECT、DELETE、INSERT、REPLACE 以及 UPDATE 语句。我们一般多用于分析 SELECT 查询语句,使用起来非常简单,语法如下:\nEXPLAIN + SELECT 查询语句; 我们简单来看下一条查询语句的执行计划:\nmysql\u0026gt; explain SELECT * FROM dept_emp WHERE emp_no IN (SELECT emp_no FROM dept_emp GROUP BY emp_no HAVING COUNT(emp_no)\u0026gt;1); +----+-------------+----------+------------+-------+-----------------+---------+---------+------+--------+----------+-------------+ | id | select_type | table | partitions | type | possible_keys | key | key_len | ref | rows | filtered | Extra | +----+-------------+----------+------------+-------+-----------------+---------+---------+------+--------+----------+-------------+ | 1 | PRIMARY | dept_emp | NULL | ALL | NULL | NULL | NULL | NULL | 331143 | 100.00 | Using where | | 2 | SUBQUERY | dept_emp | NULL | index | PRIMARY,dept_no | PRIMARY | 16 | NULL | 331143 | 100.00 | Using index | +----+-------------+----------+------------+-------+-----------------+---------+---------+------+--------+----------+-------------+ 可以看到,执行计划结果中共有 12 列,各列代表的含义总结如下表:\n列名 含义 id SELECT查询的序列标识符 select_type SELECT关键字对应的查询类型 table 用到的表名 partitions 匹配的分区,对于未分区的表,值为 NULL type 表的访问方法 possible_keys 可能用到的索引 key 实际用到的索引 key_len 所选索引的长度 ref 当使用索引等值查询时,与索引作比较的列或常量 rows 预计要读取的行数 filtered 按表条件过滤后,留存的记录数的百分比 Extra 附加信息 如何分析 EXPLAIN 结果? # 为了分析 EXPLAIN 语句的执行结果,我们需要搞懂执行计划中的重要字段。\nid # SELECT 标识符,是查询中 SELECT 的序号,用来标识整个查询中 SELELCT 语句的顺序。\nid 如果相同,从上往下依次执行。id 不同,id 值越大,执行优先级越高,如果行引用其他行的并集结果,则该值可以为 NULL。\nselect_type # 查询的类型,主要用于区分普通查询、联合查询、子查询等复杂的查询,常见的值有:\nSIMPLE:简单查询,不包含 UNION 或者子查询。 PRIMARY:查询中如果包含子查询或其他部分,外层的 SELECT 将被标记为 PRIMARY。 SUBQUERY:子查询中的第一个 SELECT。 UNION:在 UNION 语句中,UNION 之后出现的 SELECT。 DERIVED:在 FROM 中出现的子查询将被标记为 DERIVED。 UNION RESULT:UNION 查询的结果。 table # 查询用到的表名,每行都有对应的表名,表名除了正常的表之外,也可能是以下列出的值:\n\u0026lt;unionM,N\u0026gt; : 本行引用了 id 为 M 和 N 的行的 UNION 结果; \u0026lt;derivedN\u0026gt; : 本行引用了 id 为 N 的表所产生的的派生表结果。派生表有可能产生自 FROM 语句中的子查询。 -\u0026lt;subqueryN\u0026gt; : 本行引用了 id 为 N 的表所产生的的物化子查询结果。 type(重要) # 查询执行的类型,描述了查询是如何执行的。所有值的顺序从最优到最差排序为:system \u0026gt; const \u0026gt; eq_ref \u0026gt; ref \u0026gt; fulltext \u0026gt; ref_or_null \u0026gt; index_merge \u0026gt; unique_subquery \u0026gt; index_subquery \u0026gt; range \u0026gt; index \u0026gt; ALL\n常见的几种类型具体含义如下:\nsystem:如果表使用的引擎对于表行数统计是精确的(如:MyISAM),且表中只有一行记录的情况下,访问方法是 system ,是 const 的一种特例。 const:表中最多只有一行匹配的记录,一次查询就可以找到,常用于使用主键或唯一索引的所有字段作为查询条件。 eq_ref:当连表查询时,前一张表的行在当前这张表中只有一行与之对应。是除了 system 与 const 之外最好的 join 方式,常用于使用主键或唯一索引的所有字段作为连表条件。 ref:使用普通索引作为查询条件,查询结果可能找到多个符合条件的行。 index_merge:当查询条件使用了多个索引时,表示开启了 Index Merge 优化,此时执行计划中的 key 列列出了使用到的索引。 range:对索引列进行范围查询,执行计划中的 key 列表示哪个索引被使用了。 index:查询遍历了整棵索引树,与 ALL 类似,只不过扫描的是索引,而索引一般在内存中,速度更快。 ALL:全表扫描。 possible_keys # possible_keys 列表示 MySQL 执行查询时可能用到的索引。如果这一列为 NULL ,则表示没有可能用到的索引;这种情况下,需要检查 WHERE 语句中所使用的的列,看是否可以通过给这些列中某个或多个添加索引的方法来提高查询性能。\nkey(重要) # key 列表示 MySQL 实际使用到的索引。如果为 NULL,则表示未用到索引。\nkey_len # key_len 列表示 MySQL 实际使用的索引的最大长度;当使用到联合索引时,有可能是多个列的长度和。在满足需求的前提下越短越好。如果 key 列显示 NULL ,则 key_len 列也显示 NULL 。\nrows # rows 列表示根据表统计信息及选用情况,大致估算出找到所需的记录或所需读取的行数,数值越小越好。\nExtra(重要) # 这列包含了 MySQL 解析查询的额外信息,通过这些信息,可以更准确的理解 MySQL 到底是如何执行查询的。常见的值如下:\nUsing filesort:在排序时使用了外部的索引排序,没有用到表内索引进行排序。 Using temporary:MySQL 需要创建临时表来存储查询的结果,常见于 ORDER BY 和 GROUP BY。 Using index:表明查询使用了覆盖索引,不用回表,查询效率非常高。 Using index condition:表示查询优化器选择使用了索引条件下推这个特性。 Using where:表明查询使用了 WHERE 子句进行条件过滤。一般在没有使用到索引的时候会出现。 Using join buffer (Block Nested Loop):连表查询的方式,表示当被驱动表的没有使用索引的时候,MySQL 会先将驱动表读出来放到 join buffer 中,再遍历被驱动表与驱动表进行查询。 这里提醒下,当 Extra 列包含 Using filesort 或 Using temporary 时,MySQL 的性能可能会存在问题,需要尽可能避免。\n参考 # https://dev.mysql.com/doc/refman/5.7/en/explain-output.html https://juejin.cn/post/6953444668973514789 "},{"id":239,"href":"/zh/docs/technology/Review/java_guide/database/ly0503lysql-question-01/","title":"sql常见面试题总结01","section":"数据库","content":" 转载自https://github.com/Snailclimb/JavaGuide(添加小部分笔记)感谢作者!\n题目来源于: 牛客题霸 - SQL 必知必会\n检索数据 # select 用于从数据库中查询数据。\n从 Customers 表中检索所有的 ID # 现有表 Customers 如下:\ncust_id A B C 编写 SQL 语句,从 Customers 表中检索所有的 cust_id。\n答案:\nselect cust_id from Customers; 检索并列出已订购产品的清单 # 表 OrderItems 含有非空的列 prod_id 代表商品 id,包含了所有已订购的商品(有些已被订购多次)。\nprod_id a1 a2 a3 a4 a5 a6 a7 编写 SQL 语句,检索并列出所有已订购商品(prod_id)的去重后的清单。\n答案:\nselect distinct prod_id from OrderItems; 知识点:distinct 用于返回列中的唯一不同值。\n检索所有列 # 现在有 Customers 表(表中含有列 cust_id 代表客户 id,cust_name 代表客户姓名)\ncust_id cust_name a1 andy a2 ben a3 tony a4 tom a5 an a6 lee a7 hex 需要编写 SQL 语句,检索所有列。\n答案:\nselect cust_id, cust_name from Customers; 排序检索数据 # order by 用于对结果集按照一个列或者多个列进行排序。默认按照升序对记录进行排序,如果需要按照降序对记录进行排序,可以使用 desc 关键字。\n检索顾客名称并且排序 # 有表 Customers,cust_id 代表客户 id,cust_name 代表客户姓名。\ncust_id cust_name a1 andy a2 ben a3 tony a4 tom a5 an a6 lee a7 hex 从 Customers 中检索所有的顾客名称(cust_name),并按从 Z 到 A 的顺序显示结果。\n答案:\nselect cust_name from Customers order by cust_name desc 对顾客 ID 和日期排序 # 有 Orders 表:\ncust_id order_num order_date andy aaaa 2021-01-01 00:00:00 andy bbbb 2021-01-01 12:00:00 bob cccc 2021-01-10 12:00:00 dick dddd 2021-01-11 00:00:00 编写 SQL 语句,从 Orders 表中检索顾客 ID(cust_id)和订单号(order_num),并先按顾客 ID 对结果进行排序,再按订单日期倒序排列。\n答案:\n# 根据列名排序 # 注意:是 order_date 降序,而不是 order_num select cust_id, order_num from Orders order by cust_id, order_date desc; 知识点:order by 对多列排序的时候,先排序的列放前面,后排序的列放后面。并且,不同的列可以有不同的排序规则。\n按照数量和价格排序 # 假设有一个 OrderItems 表:\nquantity item_price 1 100 10 1003 2 500 编写 SQL 语句,显示 OrderItems 表中的数量(quantity)和价格(item_price),并按数量由多到少、价格由高到低排序。\n答案:\nselect quantity, item_price from OrderItems order by quantity desc, item_price desc; 检查 SQL 语句 # 有 Vendors 表:\nvend_name 海底捞 小龙坎 大龙燚 下面的 SQL 语句有问题吗?尝试将它改正确,使之能够正确运行,并且返回结果根据vend_name 逆序排列。\nSELECT vend_name, FROM Vendors ORDER vend_name DESC; 改正后:\nselect vend_name from Vendors order by vend_name desc; 知识点:\n逗号作用是用来隔开列与列之间的。 order by 是有 by 的,需要撰写完整,且位置正确。 过滤数据 # where 可以过滤返回的数据。\n下面的运算符可以在 where 子句中使用:\n运算符 描述 = 等于 \u0026lt;\u0026gt; 不等于。**注释:**在 SQL 的一些版本中,该操作符可被写成 != \u0026gt; 大于 \u0026lt; 小于 \u0026gt;= 大于等于 \u0026lt;= 小于等于 BETWEEN 在某个范围内 LIKE 搜索某种模式 IN 指定针对某个列的多个可能值 返回固定价格的产品 # 有表 Products :\nprod_id prod_name prod_price a0018 sockets 9.49 a0019 iphone13 600 b0018 gucci t-shirts 1000 【问题】从 Products 表中检索产品 ID(prod_id)和产品名称(prod_name),只返回价格为 9.49 美元的产品。\n答案:\nselect prod_id, prod_name from Products where prod_price = 9.49; 返回更高价格的产品 # 有表 Products :\nprod_id prod_name prod_price a0018 sockets 9.49 a0019 iphone13 600 b0019 gucci t-shirts 1000 【问题】编写 SQL 语句,从 Products 表中检索产品 ID(prod_id)和产品名称(prod_name),只返回价格为 9 美元或更高的产品。\n答案:\nselect prod_id, prod_name from Products where prod_price \u0026gt;= 9; 返回产品并且按照价格排序 # 有表 Products :\nprod_id prod_name prod_price a0011 egg 3 a0019 sockets 4 b0019 coffee 15 【问题】编写 SQL 语句,返回 Products 表中所有价格在 3 美元到 6 美元之间的产品的名称(prod_name)和价格(prod_price),然后按价格对结果进行排序。\n答案:\nselect prod_name, prod_price from Products where prod_price between 3 and 6 order by prod_price; # 或者 select prod_name, prod_price from Products where prod_price \u0026gt;= 3 and prod_price \u0026lt;= 6 order by prod_price; 返回更多的产品 # OrderItems 表含有:订单号 order_num,quantity产品数量\norder_num quantity a1 105 a2 1100 a2 200 a4 1121 a5 10 a2 19 a7 5 【问题】从 OrderItems 表中检索出所有不同且不重复的订单号(order_num),其中每个订单都要包含 100 个或更多的产品。\n答案:\nselect distinct order_num from OrderItems where quantity \u0026gt;= 100; 高级数据过滤 # and 和 or 运算符用于基于一个以上的条件对记录进行过滤,两者可以结合使用。and 必须 2 个条件都成立,or只要 2 个条件中的一个成立即可。\n检索供应商名称 # Vendors 表有字段供应商名称(vend_name)、供应商国家(vend_country)、供应商州(vend_state)\nvend_name vend_country vend_state apple USA CA vivo CNA shenzhen huawei CNA xian 【问题】编写 SQL 语句,从 Vendors 表中检索供应商名称(vend_name),仅返回加利福尼亚州的供应商(这需要按国家[USA]和州[CA]进行过滤,没准其他国家也存在一个 CA)\n答案:\nselect vend_name from Vendors where vend_country = \u0026#39;USA\u0026#39; and vend_state = \u0026#39;CA\u0026#39;; 检索并列出已订购产品的清单 # OrderItems 表包含了所有已订购的产品(有些已被订购多次)。\nprod_id order_num quantity BR01 a1 105 BR02 a2 1100 BR02 a2 200 BR03 a4 1121 BR017 a5 10 BR02 a2 19 BR017 a7 5 【问题】编写 SQL 语句,查找所有订购了数量至少 100 个的 BR01、BR02 或 BR03 的订单。你需要返回 OrderItems 表的订单号(order_num)、产品 ID(prod_id)和数量(quantity),并按产品 ID 和数量进行过滤。\n答案:\nselect order_num, prod_id, quantity from OrderItems where quantity \u0026gt;= 100 and prod_id in(\u0026#39;BR01\u0026#39;, \u0026#39;BR02\u0026#39;, \u0026#39;BR03\u0026#39;); 返回所有价格在 3 美元到 6 美元之间的产品的名称和价格 # 有表 Products:\nprod_id prod_name prod_price a0011 egg 3 a0019 sockets 4 b0019 coffee 15 【问题】编写 SQL 语句,返回所有价格在 3 美元到 6 美元之间的产品的名称(prod_name)和价格(prod_price),使用 AND 操作符,然后按价格对结果进行升序排序。\n答案:\nselect prod_name, prod_price from Products where prod_price between 3 and 6 order by prod_price; 检查 SQL 语句 # 供应商表 Vendors 有字段供应商名称 vend_name、供应商国家 vend_country、供应商省份 vend_state\nvend_name vend_country vend_state apple USA CA vivo CNA shenzhen huawei CNA xian 【问题】修改正确下面 sql,使之正确返回。\nSELECT vend_name FROM Vendors ORDER BY vend_name WHERE vend_country = \u0026#39;USA\u0026#39; AND vend_state = \u0026#39;CA\u0026#39;; 修改后:\nselect vend_name from Vendors where vend_country = \u0026#39;USA\u0026#39; and vend_state = \u0026#39;CA\u0026#39; order by vend_name; order by 语句必须放在 where 之后。\n用通配符进行过滤 # SQL 通配符必须与 LIKE 运算符一起使用\n在 SQL 中,可使用以下通配符:\n通配符 描述 % 代表零个或多个字符 _ 仅替代一个字符 [charlist] 字符列中的任何单一字符 [^charlist] 或者 [!charlist] 不在字符列中的任何单一字符 检索产品名称和描述(一) # Products 表如下:\nprod_name prod_desc a0011 usb a0019 iphone13 b0019 gucci t-shirts c0019 gucci toy d0019 lego toy 【问题】编写 SQL 语句,从 Products 表中检索产品名称(prod_name)和描述(prod_desc),仅返回描述中包含 toy 一词的产品名称。\n答案:\nselect prod_name, prod_desc from Products where prod_desc like \u0026#39;%toy%\u0026#39;; 检索产品名称和描述(二) # Products 表如下:\nprod_name prod_desc a0011 usb a0019 iphone13 b0019 gucci t-shirts c0019 gucci toy d0019 lego toy 【问题】编写 SQL 语句,从 Products 表中检索产品名称(prod_name)和描述(prod_desc),仅返回描述中未出现 toy 一词的产品,最后按”产品名称“对结果进行排序。\n答案:\nselect prod_name, prod_desc from Products where prod_desc not like \u0026#39;%toy%\u0026#39; order by prod_name; 检索产品名称和描述(三) # Products 表如下:\nprod_name prod_desc a0011 usb a0019 iphone13 b0019 gucci t-shirts c0019 gucci toy d0019 lego carrots toy 【问题】编写 SQL 语句,从 Products 表中检索产品名称(prod_name)和描述(prod_desc),仅返回描述中同时出现 toy 和 carrots 的产品。有好几种方法可以执行此操作,但对于这个挑战题,请使用 AND 和两个 LIKE 比较。\n答案:\nselect prod_name, prod_desc from Products where prod_desc like \u0026#39;%toy%\u0026#39; and prod_desc like \u0026#34;%carrots%\u0026#34;; 检索产品名称和描述(四) # Products 表如下:\nprod_name prod_desc a0011 usb a0019 iphone13 b0019 gucci t-shirts c0019 gucci toy d0019 lego toy carrots 【问题】编写 SQL 语句,从 Products 表中检索产品名称(prod_name)和描述(prod_desc),仅返回在描述中以先后顺序同时出现 toy 和 carrots 的产品。提示:只需要用带有三个 % 符号的 LIKE 即可。\n答案:\nselect prod_name, prod_desc from Products where prod_desc like \u0026#39;%toy%carrots%\u0026#39;; 创建计算字段 # 别名 # 别名的常见用法是在检索出的结果中重命名表的列字段(为了符合特定的报表要求或客户需求)。有表 Vendors 代表供应商信息,vend_id 供应商 id、vend_name 供应商名称、vend_address 供应商地址、vend_city 供应商城市。\nvend_id vend_name vend_address vend_city a001 tencent cloud address1 shenzhen a002 huawei cloud address2 dongguan a003 aliyun cloud address3 hangzhou a003 netease cloud address4 guangzhou 【问题】编写 SQL 语句,从 Vendors 表中检索 vend_id、vend_name、vend_address 和 vend_city,将 vend_name 重命名为 vname,将 vend_city 重命名为 vcity,将 vend_address 重命名为 vaddress,按供应商名称对结果进行升序排序。\n答案:\nselect vend_id, vend_name as vname, vend_address as vaddress, vend_city as vcity from Vendors order by vname; # as 可以省略 select vend_id, vend_name vname, vend_address vaddress, vend_city vcity from Vendors order by vname; 打折 # 我们的示例商店正在进行打折促销,所有产品均降价 10%。Products 表包含 prod_id 产品 id、prod_price 产品价格。\n【问题】编写 SQL 语句,从 Products 表中返回 prod_id、prod_price 和 sale_price。sale_price 是一个包含促销价格的计算字段。提示:可以乘以 0.9,得到原价的 90%(即 10%的折扣)。\n答案:\nselect prod_id, prod_price, prod_price * 0.9 as sale_price from Products; 注意:sale_price 是对计算结果的命名,而不是原有的列名。\n使用函数处理数据 # 顾客登录名 # 我们的商店已经上线了,正在创建顾客账户。所有用户都需要登录名,默认登录名是其名称和所在城市的组合。\n给出 Customers 表 如下:\ncust_id cust_name cust_contact cust_city a1 Andy Li Andy Li Oak Park a2 Ben Liu Ben Liu Oak Park a3 Tony Dai Tony Dai Oak Park a4 Tom Chen Tom Chen Oak Park a5 An Li An Li Oak Park a6 Lee Chen Lee Chen Oak Park a7 Hex Liu Hex Liu Oak Park 【问题】编写 SQL 语句,返回顾客 ID(cust_id)、顾客名称(cust_name)和登录名(user_login),其中登录名全部为大写字母,并由顾客联系人的前两个字符(cust_contact)和其所在城市的前三个字符(cust_city)组成。提示:需要使用函数、拼接和别名。\n答案:\nselect cust_id, cust_name, upper(concat(substring(cust_contact, 1, 2), substring(cust_city, 1, 3))) as user_login from Customers; 知识点:\n截取函数substring():截取字符串,substring(str ,n ,m):返回字符串 str 从第 n 个字符截取到第 m 个字符(左闭右闭);\n返回字符串 str 从第 n 个字符截取 m 个字符(左闭右闭)\n拼接函数concat():将两个或多个字符串连接成一个字符串,select concat(A,B) :连接字符串 A 和 B。\n大写函数 upper():将指定字符串转换为大写。\n返回 2020 年 1 月的所有订单的订单号和订单日期 # Orders 订单表如下:\norder_num order_date a0001 2020-01-01 00:00:00 a0002 2020-01-02 00:00:00 a0003 2020-01-01 12:00:00 a0004 2020-02-01 00:00:00 a0005 2020-03-01 00:00:00 【问题】编写 SQL 语句,返回 2020 年 1 月的所有订单的订单号(order_num)和订单日期(order_date),并按订单日期升序排序\n答案:\nselect order_num, order_date from Orders where month(order_date) = \u0026#39;01\u0026#39; and year(order_date) = \u0026#39;2020\u0026#39; order by order_date; 也可以用通配符来做:\nselect order_num, order_date from Orders where order_date like \u0026#39;2020-01%\u0026#39; order by order_date; 知识点:\n日期格式:YYYY-MM-DD 时间格式:HH:MM:SS 日期和时间处理相关的常用函数:\n函 数 说 明 adddate() 增加一个日期(天、周等) addtime() 增加一个时间(时、分等) curdate() 返回当前日期 curtime() 返回当前时间 date() 返回日期时间的日期部分 datediff() 计算两个日期之差 date_format() 返回一个格式化的日期或时间串 day() 返回一个日期的天数部分 dayofweek() 对于一个日期,返回对应的星期几 hour() 返回一个时间的小时部分 minute() 返回一个时间的分钟部分 month() 返回一个日期的月份部分 now() 返回当前日期和时间 second() 返回一个时间的秒部分 time() 返回一个日期时间的时间部分 year() 返回一个日期的年份部分 汇总数据 # 汇总数据相关的函数:\n函 数 说 明 avg() 返回某列的平均值 count() 返回某列的行数 max() 返回某列的最大值 min() 返回某列的最小值 sum() 返回某列值之和 确定已售出产品的总数 # OrderItems 表代表售出的产品,quantity 代表售出商品数量。\nquantity 10 100 1000 10001 2 15 【问题】编写 SQL 语句,确定已售出产品的总数。\n答案:\nselect sum(quantity) as items_ordered from OrderItems; 确定已售出产品项 BR01 的总数 # OrderItems 表代表售出的产品,quantity 代表售出商品数量,产品项为 prod_id。\nquantity prod_id 10 AR01 100 AR10 1000 BR01 10001 BR010 【问题】修改创建的语句,确定已售出产品项(prod_id)为\u0026quot;BR01\u0026quot;的总数。\n答案:\nselect sum(quantity) as items_ordered from OrderItems where prod_id = \u0026#39;BR01\u0026#39;; 确定 Products 表中价格不超过 10 美元的最贵产品的价格 # Products 表如下,prod_price 代表商品的价格。\nprod_price 9.49 600 1000 【问题】编写 SQL 语句,确定 Products 表中价格不超过 10 美元的最贵产品的价格(prod_price)。将计算所得的字段命名为 max_price。\n答案:\nselect max(prod_price) as max_price from Products where prod_price \u0026lt;= 10; 分组数据 # group by :\ngroup by 子句将记录分组到汇总行中。 group by 为每个组返回一个记录。 group by 通常还涉及聚合count,max,sum,avg 等。 group by 可以按一列或多列进行分组。 group by 按分组字段进行排序后,order by 可以以汇总字段来进行排序。 having:\nhaving 用于对汇总的 group by 结果进行过滤。 having 必须要与 group by 连用。 where 和 having 可以在相同的查询中。 having vs where:\nwhere:过滤过滤指定的行,后面不能加聚合函数(分组函数)。 having:过滤分组,必须要与 group by 连用,不能单独使用。 返回每个订单号各有多少行数 # OrderItems 表包含每个订单的每个产品\norder_num a002 a002 a002 a004 a007 【问题】编写 SQL 语句,返回每个订单号(order_num)各有多少行数(order_lines),并按 order_lines 对结果进行升序排序。\n答案:\nselect order_num, count(order_num) as order_lines from OrderItems group by order_num order by order_lines; 知识点:\ncount(*),count(列名)都可以,区别在于,count(列名)是统计非 NULL 的行数; order by 最后执行,所以可以使用列别名; 分组聚合一定不要忘记加上 group by ,不然只会有一行结果。 每个供应商成本最低的产品 # 有 Products 表,含有字段 prod_price 代表产品价格,vend_id 代表供应商 id\nvend_id prod_price a0011 100 a0019 0.1 b0019 1000 b0019 6980 b0019 20 【问题】编写 SQL 语句,返回名为 cheapest_item 的字段,该字段包含每个供应商成本最低的产品(使用 Products 表中的 prod_price),然后从最低成本到最高成本对结果进行升序排序。\n答案:\nselect vend_id, min(prod_price) as cheapest_item from Products group by vend_id order by cheapest_item; 返回订单数量总和不小于 100 的所有订单的订单号 # OrderItems 代表订单商品表,包括:订单号 order_num 和订单数量 quantity。\norder_num quantity a1 105 a2 1100 a2 200 a4 1121 a5 10 a2 19 a7 5 【问题】请编写 SQL 语句,返回订单数量总和不小于 100 的所有订单号,最后结果按照订单号升序排序。\n答案:\n# 直接聚合 select order_num from OrderItems group by order_num having sum(quantity) \u0026gt;= 100 order by order_num; # 子查询 select order_num from (select order_num, sum(quantity) as sum_num from OrderItems group by order_num having sum_num \u0026gt;= 100 ) a order by order_num; 知识点:\nwhere:过滤过滤指定的行,后面不能加聚合函数(分组函数)。 having:过滤分组,与 group by 连用,不能单独使用。 计算总和 # OrderItems 表代表订单信息,包括字段:订单号 order_num 和 item_price 商品售出价格、quantity 商品数量。\norder_num item_price quantity a1 10 105 a2 1 1100 a2 1 200 a4 2 1121 a5 5 10 a2 1 19 a7 7 5 【问题】编写 SQL 语句,根据订单号聚合,返回订单总价不小于 1000 的所有订单号,最后的结果按订单号进行升序排序。\n提示:总价 = item_price 乘以 quantity\n答案:\nselect order_num, sum(item_price * quantity) as total_price from OrderItems group by order_num having total_price \u0026gt;= 1000 order by order_num; 检查 SQL 语句 # OrderItems 表含有 order_num 订单号\norder_num a002 a002 a002 a004 a007 【问题】将下面代码修改正确后执行\nSELECT order_num, COUNT(*) AS items FROM OrderItems GROUP BY items HAVING COUNT(*) \u0026gt;= 3 ORDER BY items, order_num; 修改后:\nSELECT order_num, COUNT(*) AS items FROM OrderItems GROUP BY order_num HAVING items \u0026gt;= 3 ORDER BY items, order_num; 使用子查询 # 子查询是嵌套在较大查询中的 SQL 查询,也称内部查询或内部选择,包含子查询的语句也称为外部查询或外部选择。简单来说,子查询就是指将一个 select 查询(子查询)的结果作为另一个 SQL 语句(主查询)的数据来源或者判断条件。\n子查询可以嵌入 select、insert、update 和 delete 语句中,也可以和 =、\u0026lt;、\u0026gt;、in、between、exists 等运算符一起使用。\n子查询常用在 where 子句和 from 子句后边:\n当用于 where 子句时,根据不同的运算符,子查询可以返回单行单列、多行单列、单行多列数据。子查询就是要返回能够作为 WHERE 子句查询条件的值。 当用于 from 子句时,一般返回多行多列数据,相当于返回一张临时表,这样才符合 from 后面是表的规则。这种做法能够实现多表联合查询。 注意:MySQL 数据库从 4.1 版本才开始支持子查询,早期版本是不支持的。\n用于 where 子句的子查询的基本语法如下:\nselect column_name [, column_name ] from table1 [, table2 ] where column_name operator (select column_name [, column_name ] from table1 [, table2 ] [where]) 子查询需要放在括号( )内。 operator 表示用于 where 子句的运算符。 用于 from 子句的子查询的基本语法如下:\nselect column_name [, column_name ] from (select column_name [, column_name ] from table1 [, table2 ] [where]) as temp_table_name where condition 用于 from 的子查询返回的结果相当于一张临时表,所以需要使用 AS 关键字为该临时表起一个名字。\n返回购买价格为 10 美元或以上产品的顾客列表 # OrderItems` 表示订单商品表,含有字段 订单号:`order_num`、 订单价格:`item_price`; `Orders` 表代表订单信息表,含有 顾客 `id:cust_id` 和 订单号:`order_num OrderItems 表:\norder_num item_price a1 10 a2 1 a2 1 a4 2 a5 5 a2 1 a7 7 Orders 表:\norder_num cust_id a1 cust10 a2 cust1 a2 cust1 a4 cust2 a5 cust5 a2 cust1 a7 cust7 【问题】使用子查询,返回购买价格为 10 美元或以上产品的顾客列表,结果无需排序。\n答案:\nselect cust_id from Orders where order_num in ( select order_num from OrderItems group by order_num having sum(item_price) \u0026gt;= 10 ); 确定哪些订单购买了 prod_id 为 BR01 的产品(一) # 表 OrderItems 代表订单商品信息表,prod_id 为产品 id;Orders 表代表订单表有 cust_id 代表顾客 id 和订单日期 order_date\nOrderItems 表:\nprod_id order_num BR01 a0001 BR01 a0002 BR02 a0003 BR02 a0013 Orders 表:\norder_num cust_id order_date a0001 cust10 2022-01-01 00:00:00 a0002 cust1 2022-01-01 00:01:00 a0003 cust1 2022-01-02 00:00:00 a0013 cust2 2022-01-01 00:20:00 【问题】\n编写 SQL 语句,使用子查询来确定哪些订单(在 OrderItems 中)购买了 prod_id 为 \u0026ldquo;BR01\u0026rdquo; 的产品,然后从 Orders 表中返回每个产品对应的顾客 ID(cust_id)和订单日期(order_date),按订购日期对结果进行升序排序。\n答案:\n# 写法 1:子查询 select cust_id, order_date from Orders where order_num in ( select order_num from OrderItems where prod_id = \u0026#39;BR01\u0026#39; ) order by order_date; # 写法 2: 连接表 select b.cust_id, b.order_date from OrderItems a, Orders b where a.order_num = b.order_num and a.prod_id = \u0026#39;BR01\u0026#39; order by order_date; 返回购买 prod_id 为 BR01 的产品的所有顾客的电子邮件(一) # 你想知道订购 BR01 产品的日期,有表 OrderItems 代表订单商品信息表,prod_id 为产品 id;Orders 表代表订单表有 cust_id 代表顾客 id 和订单日期 order_date;Customers 表含有 cust_email 顾客邮件和 cust_id 顾客 id\nOrderItems 表:\nprod_id order_num BR01 a0001 BR01 a0002 BR02 a0003 BR02 a0013 Orders 表:\norder_num cust_id order_date a0001 cust10 2022-01-01 00:00:00 a0002 cust1 2022-01-01 00:01:00 a0003 cust1 2022-01-02 00:00:00 a0013 cust2 2022-01-01 00:20:00 Customers 表代表顾客信息,cust_id 为顾客 id,cust_email 为顾客 email\ncust_id cust_email cust10 cust10@cust.com cust1 cust1@cust.com cust2 cust2@cust.com 【问题】返回购买 prod_id 为 BR01 的产品的所有顾客的电子邮件(Customers 表中的 cust_email),结果无需排序。\n提示:这涉及 SELECT 语句,最内层的从 OrderItems 表返回 order_num,中间的从 Customers 表返回 cust_id。\n答案:\n# 写法 1:子查询 select cust_email from Customers where cust_id in ( select cust_id from Orders where order_num in ( select order_num from OrderItems where prod_id = \u0026#39;BR01\u0026#39; ) ); # 写法 2: 连接表(inner join) select c.cust_email from OrderItems a, Orders b, Customers c where a.order_num = b.order_num and b.cust_id = c.cust_id and a.prod_id = \u0026#39;BR01\u0026#39;; # 写法 3:连接表(left join) select c.cust_email from Orders a left join OrderItems b on a.order_num = b.order_num left join Customers c on a.cust_id = c.cust_id where b.prod_id = \u0026#39;BR01\u0026#39;; 返回每个顾客不同订单的总金额 # 我们需要一个顾客 ID 列表,其中包含他们已订购的总金额。\nOrderItems 表代表订单信息,OrderItems 表有订单号:order_num 和商品售出价格:item_price、商品数量:quantity。\norder_num item_price quantity a0001 10 105 a0002 1 1100 a0002 1 200 a0013 2 1121 a0003 5 10 a0003 1 19 a0003 7 5 Orders` 表订单号:`order_num`、顾客 id:`cust_id order_num cust_id a0001 cust10 a0002 cust1 a0003 cust1 a0013 cust2 【问题】\n编写 SQL 语句,返回顾客 ID(Orders 表中的 cust_id),并使用子查询返回 total_ordered 以便返回每个顾客的订单总数,将结果按金额从大到小排序。\n答案:\n# 写法 1:子查询 SELECT o.cust_id cust_id, tb.total_ordered total_ordered FROM ( SELECT order_num, SUM(item_price * quantity) total_ordered FROM OrderItems GROUP BY order_num ) as tb, Orders o WHERE tb.order_num = o.order_num ORDER BY total_ordered DESC; # 写法 2:连接表 select b.cust_id, sum(a.quantity * a.item_price) as total_ordered from OrderItems a, Orders b where a.order_num = b.order_num group by cust_id order by total_ordered desc; 从 Products 表中检索所有的产品名称以及对应的销售总数 # Products` 表中检索所有的产品名称:`prod_name`、产品 id:`prod_id prod_id prod_name a0001 egg a0002 sockets a0013 coffee a0003 cola OrderItems` 代表订单商品表,订单产品:`prod_id`、售出数量:`quantity prod_id quantity a0001 105 a0002 1100 a0002 200 a0013 1121 a0003 10 a0003 19 a0003 5 【问题】\n编写 SQL 语句,从 Products 表中检索所有的产品名称(prod_name),以及名为 quant_sold 的计算列,其中包含所售产品的总数(在 OrderItems 表上使用子查询和 SUM(quantity) 检索)。\n答案:\n# 写法 1:子查询 select p.prod_name, tb.quant_sold from ( select prod_id, sum(quantity) as quant_sold from OrderItems group by prod_id ) as tb, Products p where tb.prod_id = p.prod_id; # 写法 2:连接表 select p.prod_name, sum(o.quantity) as quant_sold from Products p, OrderItems o where p.prod_id = o.prod_id group by p.prod_name;(这里不能用 p.prod_id,会报错) 连接表 # JOIN 是“连接”的意思,顾名思义,SQL JOIN 子句用于将两个或者多个表联合起来进行查询。\n连接表时需要在每个表中选择一个字段,并对这些字段的值进行比较,值相同的两条记录将合并为一条。连接表的本质就是将不同表的记录合并起来,形成一张新表。当然,这张新表只是临时的,它仅存在于本次查询期间。\n使用 JOIN 连接两个表的基本语法如下:\nselect table1.column1, table2.column2... from table1 join table2 on table1.common_column1 = table2.common_column2; table1.common_column1 = table2.common_column2 是连接条件,只有满足此条件的记录才会合并为一行。您可以使用多个运算符来连接表,例如 =、\u0026gt;、\u0026lt;、\u0026lt;\u0026gt;、\u0026lt;=、\u0026gt;=、!=、between、like 或者 not,但是最常见的是使用 =。\n当两个表中有同名的字段时,为了帮助数据库引擎区分是哪个表的字段,在书写同名字段名时需要加上表名。当然,如果书写的字段名在两个表中是唯一的,也可以不使用以上格式,只写字段名即可。\n另外,如果两张表的关联字段名相同,也可以使用 USING子句来代替 ON,举个例子:\n# join....on select c.cust_name, o.order_num from Customers c inner join Orders o on c.cust_id = o.cust_id order by c.cust_name; # 如果两张表的关联字段名相同,也可以使用USING子句:join....using() select c.cust_name, o.order_num from Customers c inner join Orders o using(cust_id) order by c.cust_name; ON 和 WHERE 的区别:\n连接表时,SQL 会根据连接条件生成一张新的临时表。ON 就是连接条件,它决定临时表的生成。 WHERE 是在临时表生成以后,再对临时表中的数据进行过滤,生成最终的结果集,这个时候已经没有 JOIN-ON 了。 所以总结来说就是:SQL 先根据 ON 生成一张临时表,然后再根据 WHERE 对临时表进行筛选。\nSQL 允许在 JOIN 左边加上一些修饰性的关键词,从而形成不同类型的连接,如下表所示:\n连接类型 说明 INNER JOIN 内连接 (默认连接方式)只有当两个表都存在满足条件的记录时才会返回行。 LEFT JOIN / LEFT OUTER JOIN 左(外)连接 返回左表中的所有行,即使右表中没有满足条件的行也是如此。 RIGHT JOIN / RIGHT OUTER JOIN 右(外)连接 返回右表中的所有行,即使左表中没有满足条件的行也是如此。 FULL JOIN / FULL OUTER JOIN 全(外)连接 只要其中有一个表存在满足条件的记录,就返回行。 SELF JOIN 将一个表连接到自身,就像该表是两个表一样。为了区分两个表,在 SQL 语句中需要至少重命名一个表。 CROSS JOIN 交叉连接,从两个或者多个连接表中返回记录集的笛卡尔积。 下图展示了 LEFT JOIN、RIGHT JOIN、INNER JOIN、OUTER JOIN 相关的 7 种用法。\n如果不加任何修饰词,只写 JOIN,那么默认为 INNER JOIN\n对于 INNER JOIN 来说,还有一种隐式的写法,称为 “隐式内连接”,也就是没有 INNER JOIN 关键字,使用 WHERE 语句实现内连接的功能\n# 隐式内连接 select c.cust_name, o.order_num from Customers c, Orders o where c.cust_id = o.cust_id order by c.cust_name; # 显式内连接 select c.cust_name, o.order_num from Customers c inner join Orders o using(cust_id) order by c.cust_name; 返回顾客名称和相关订单号 # Customers` 表有字段顾客名称 `cust_name`、顾客 id `cust_id cust_id cust_name cust10 andy cust1 ben cust2 tony cust22 tom cust221 an cust2217 hex Orders 订单信息表,含有字段 order_num 订单号、cust_id 顾客 id\norder_num cust_id a1 cust10 a2 cust1 a3 cust2 a4 cust22 a5 cust221 a7 cust2217 【问题】编写 SQL 语句,返回 Customers 表中的顾客名称(cust_name)和 Orders 表中的相关订单号(order_num),并按顾客名称再按订单号对结果进行升序排序。你可以尝试用两个不同的写法,一个使用简单的等连接语法,另外一个使用 INNER JOIN。\n答案:\n# 隐式内连接 select c.cust_name, o.order_num from Customers c, Orders o where c.cust_id = o.cust_id order by c.cust_name; # 显式内连接 select c.cust_name, o.order_num from Customers c inner join Orders o using(cust_id) order by c.cust_name; 返回顾客名称和相关订单号以及每个订单的总价 # Customers` 表有字段,顾客名称:`cust_name`、顾客 id:`cust_id cust_id cust_name cust10 andy cust1 ben cust2 tony cust22 tom cust221 an cust2217 hex Orders` 订单信息表,含有字段,订单号:`order_num`、顾客 id:`cust_id order_num cust_id a1 cust10 a2 cust1 a3 cust2 a4 cust22 a5 cust221 a7 cust2217 OrderItems` 表有字段,商品订单号:`order_num`、商品数量:`quantity`、商品价格:`item_price order_num quantity item_price a1 1000 10 a2 200 10 a3 10 15 a4 25 50 a5 15 25 a7 7 7 【问题】除了返回顾客名称和订单号,返回 Customers 表中的顾客名称(cust_name)和 Orders 表中的相关订单号(order_num),添加第三列 OrderTotal,其中包含每个订单的总价,并按顾客名称再按订单号对结果进行升序排序。\n# 简单的等连接语法 select c.cust_name, o.order_num, sum(quantity * item_price) as OrderTotal from Customers c, Orders o, OrderItems oi where c.cust_id = o.cust_id and o.order_num = oi.order_num group by c.cust_name, o.order_num order by c.cust_name, o.order_num; 注意,可能有小伙伴会这样写:\nselect c.cust_name, o.order_num, sum(quantity * item_price) as OrderTotal from Customers c, Orders o, OrderItems oi where c.cust_id = o.cust_id and o.order_num = oi.order_num group by c.cust_name order by c.cust_name, o.order_num; 这是错误的!只对 cust_name 进行聚类确实符合题意,但是不符合 group by 的语法。\nselect 语句中,如果没有 group by 语句,那么 cust_name、order_num 会返回若干个值,而 sum(quantity _ item_price) 只返回一个值,通过 group by cust_name 可以让 cust_name 和 sum(quantity _ item_price) 一一对应起来,或者说聚类,所以同样的,也要对 order_num 进行聚类。\n一句话,select 中的字段要么都聚类,要么都不聚类\n确定哪些订单购买了 prod_id 为 BR01 的产品(二) # 表 OrderItems 代表订单商品信息表,prod_id 为产品 id;Orders 表代表订单表有 cust_id 代表顾客 id 和订单日期 order_date\nOrderItems 表:\nprod_id order_num BR01 a0001 BR01 a0002 BR02 a0003 BR02 a0013 Orders 表:\norder_num cust_id order_date a0001 cust10 2022-01-01 00:00:00 a0002 cust1 2022-01-01 00:01:00 a0003 cust1 2022-01-02 00:00:00 a0013 cust2 2022-01-01 00:20:00 【问题】\n编写 SQL 语句,使用子查询来确定哪些订单(在 OrderItems 中)购买了 prod_id 为 \u0026ldquo;BR01\u0026rdquo; 的产品,然后从 Orders 表中返回每个产品对应的顾客 ID(cust_id)和订单日期(order_date),按订购日期对结果进行升序排序。\n提示:这一次使用连接和简单的等连接语法。\n# 写法 1:子查询 select cust_id, order_date from Orders where order_num in ( select order_num from OrderItems where prod_id = \u0026#39;BR01\u0026#39; ) order by order_date; # 写法 2:连接表 inner join select cust_id, order_date from Orders o inner join ( select order_num from OrderItems where prod_id = \u0026#39;BR01\u0026#39; ) tb on o.order_num = tb.order_num order by order_date; # 写法 3:写法 2 的简化版 select cust_id, order_date from Orders inner join OrderItems using(order_num) where OrderItems.prod_id = \u0026#39;BR01\u0026#39; order by order_date; 返回购买 prod_id 为 BR01 的产品的所有顾客的电子邮件(二) # 有表 OrderItems 代表订单商品信息表,prod_id 为产品 id;Orders 表代表订单表有 cust_id 代表顾客 id 和订单日期 order_date;Customers 表含有 cust_email 顾客邮件和 cust_id 顾客 id\nOrderItems 表:\nprod_id order_num BR01 a0001 BR01 a0002 BR02 a0003 BR02 a0013 Orders 表:\norder_num cust_id order_date a0001 cust10 2022-01-01 00:00:00 a0002 cust1 2022-01-01 00:01:00 a0003 cust1 2022-01-02 00:00:00 a0013 cust2 2022-01-01 00:20:00 Customers 表代表顾客信息,cust_id 为顾客 id,cust_email 为顾客 email\ncust_id cust_email cust10 cust10@cust.com cust1 cust1@cust.com cust2 cust2@cust.com 【问题】返回购买 prod_id 为 BR01 的产品的所有顾客的电子邮件(Customers 表中的 cust_email),结果无需排序。\n提示:涉及到 SELECT 语句,最内层的从 OrderItems 表返回 order_num,中间的从 Customers 表返回 cust_id,但是必须使用 INNER JOIN 语法。\nselect cust_email from Customers inner join Orders using(cust_id) inner join OrderItems using(order_num) where OrderItems.prod_id = \u0026#39;BR01\u0026#39;; 确定最佳顾客的另一种方式(二) # OrderItems 表代表订单信息,确定最佳顾客的另一种方式是看他们花了多少钱,OrderItems 表有订单号 order_num 和 item_price 商品售出价格、quantity 商品数量\norder_num item_price quantity a1 10 105 a2 1 1100 a2 1 200 a4 2 1121 a5 5 10 a2 1 19 a7 7 5 Orders 表含有字段 order_num 订单号、cust_id 顾客 id\norder_num cust_id a1 cust10 a2 cust1 a3 cust2 a4 cust22 a5 cust221 a7 cust2217 顾客表 Customers 有字段 cust_id 客户 id、cust_name 客户姓名\ncust_id cust_name cust10 andy cust1 ben cust2 tony cust22 tom cust221 an cust2217 hex 【问题】编写 SQL 语句,返回订单总价不小于 1000 的客户名称和总额(OrderItems 表中的 order_num)。\n提示:需要计算总和(item_price 乘以 quantity)。按总额对结果进行排序,请使用 INNER JOIN 语法。\nselect cust_name, sum(item_price * quantity) as total_price from Customers inner join Orders using(cust_id) inner join OrderItems using(order_num) group by cust_name having total_price \u0026gt;= 1000 order by total_price; 创建高级连接 # 检索每个顾客的名称和所有的订单号(一) # Customers` 表代表顾客信息含有顾客 id `cust_id` 和 顾客名称 `cust_name cust_id cust_name cust10 andy cust1 ben cust2 tony cust22 tom cust221 an cust2217 hex Orders` 表代表订单信息含有订单号 `order_num` 和顾客 id `cust_id order_num cust_id a1 cust10 a2 cust1 a3 cust2 a4 cust22 a5 cust221 a7 cust2217 【问题】使用 INNER JOIN 编写 SQL 语句,检索每个顾客的名称(Customers 表中的 cust_name)和所有的订单号(Orders 表中的 order_num),最后根据顾客姓名 cust_name 升序返回。\nselect cust_name, order_num from Customers inner join Orders using(cust_id) order by cust_name; 检索每个顾客的名称和所有的订单号(二) # Orders` 表代表订单信息含有订单号 `order_num` 和顾客 id `cust_id order_num cust_id a1 cust10 a2 cust1 a3 cust2 a4 cust22 a5 cust221 a7 cust2217 Customers` 表代表顾客信息含有顾客 id `cust_id` 和 顾客名称 `cust_name cust_id cust_name cust10 andy cust1 ben cust2 tony cust22 tom cust221 an cust2217 hex cust40 ace 【问题】检索每个顾客的名称(Customers 表中的 cust_name)和所有的订单号(Orders 表中的 order_num),列出所有的顾客,即使他们没有下过订单。最后根据顾客姓名 cust_name 升序返回。\nselect cust_name, order_num from Customers left join Orders using(cust_id) order by cust_name; 返回产品名称和与之相关的订单号 # Products 表为产品信息表含有字段 prod_id 产品 id、prod_name 产品名称\nprod_id prod_name a0001 egg a0002 sockets a0013 coffee a0003 cola a0023 soda OrderItems` 表为订单信息表含有字段 `order_num` 订单号和产品 id `prod_id prod_id order_num a0001 a105 a0002 a1100 a0002 a200 a0013 a1121 a0003 a10 a0003 a19 a0003 a5 【问题】使用外连接(left join、 right join、full join)联结 Products 表和 OrderItems 表,返回产品名称(prod_name)和与之相关的订单号(order_num)的列表,并按照产品名称升序排序。\nselect prod_name, order_num from Products left join OrderItems using(prod_id) order by prod_name; 返回产品名称和每一项产品的总订单数 # Products 表为产品信息表含有字段 prod_id 产品 id、prod_name 产品名称\nprod_id prod_name a0001 egg a0002 sockets a0013 coffee a0003 cola a0023 soda OrderItems` 表为订单信息表含有字段 `order_num` 订单号和产品 id `prod_id prod_id order_num a0001 a105 a0002 a1100 a0002 a200 a0013 a1121 a0003 a10 a0003 a19 a0003 a5 【问题】\n使用 OUTER JOIN 联结 Products 表和 OrderItems 表,返回产品名称(prod_name)和每一项产品的总订单数(不是订单号),并按产品名称升序排序。\nselect prod_name, count(order_num) as orders from Products left join OrderItems using(prod_id) group by prod_name order by prod_name; 列出供应商及其可供产品的数量 # 有 Vendors 表含有 vend_id (供应商 id)\nvend_id a0002 a0013 a0003 a0010 有 Products 表含有 vend_id(供应商 id)和 prod_id(供应产品 id)\nvend_id prod_id a0001 egg a0002 prod_id_iphone a00113 prod_id_tea a0003 prod_id_vivo phone a0010 prod_id_huawei phone 【问题】列出供应商(Vendors 表中的 vend_id)及其可供产品的数量,包括没有产品的供应商。你需要使用 OUTER JOIN 和 COUNT()聚合函数来计算 Products 表中每种产品的数量,最后根据 vend_id 升序排序。\n注意:vend_id 列会显示在多个表中,因此在每次引用它时都需要完全限定它。\nselect vend_id, count(prod_id) as prod_id from Vendors left join Products using(vend_id) group by vend_id order by vend_id; 组合查询 # UNION 运算符将两个或更多查询的结果组合起来,并生成一个结果集,其中包含来自 UNION 中参与查询的提取行。\nUNION 基本规则:\n所有查询的列数和列顺序必须相同。 每个查询中涉及表的列的数据类型必须相同或兼容。 通常返回的列名取自第一个查询。 默认地,UNION 操作符选取不同的值。如果允许重复的值,请使用 UNION ALL。\nSELECT column_name(s) FROM table1 UNION ALL SELECT column_name(s) FROM table2; UNION 结果集中的列名总是等于 UNION 中第一个 SELECT 语句中的列名。\nJOIN vs UNION:\nJOIN 中连接表的列可能不同,但在 UNION 中,所有查询的列数和列顺序必须相同。 UNION 将查询之后的行放在一起(垂直放置),但 JOIN 将查询之后的列放在一起(水平放置),即它构成一个笛卡尔积。 将两个 SELECT 语句结合起来(一) # 表 OrderItems 包含订单产品信息,字段 prod_id 代表产品 id、quantity 代表产品数量\nprod_id quantity a0001 105 a0002 100 a0002 200 a0013 1121 a0003 10 a0003 19 a0003 5 BNBG 10002 【问题】将两个 SELECT 语句结合起来,以便从 OrderItems 表中检索产品 id(prod_id)和 quantity。其中,一个 SELECT 语句过滤数量为 100 的行,另一个 SELECT 语句过滤 id 以 BNBG 开头的产品,最后按产品 id 对结果进行升序排序。\nselect prod_id, quantity from OrderItems where quantity = 100 union select prod_id, quantity from OrderItems where prod_id like \u0026#39;BNBG%\u0026#39;; 将两个 SELECT 语句结合起来(二) # 表 OrderItems 包含订单产品信息,字段 prod_id 代表产品 id、quantity 代表产品数量。\nprod_id quantity a0001 105 a0002 100 a0002 200 a0013 1121 a0003 10 a0003 19 a0003 5 BNBG 10002 【问题】将两个 SELECT 语句结合起来,以便从 OrderItems 表中检索产品 id(prod_id)和 quantity。其中,一个 SELECT 语句过滤数量为 100 的行,另一个 SELECT 语句过滤 id 以 BNBG 开头的产品,最后按产品 id 对结果进行升序排序。 注意:这次仅使用单个 SELECT 语句。\n答案:\n要求只用一条 select 语句,那就用 or 不用 union 了。\nselect prod_id, quantity from OrderItems where quantity = 100 or prod_id like \u0026#39;BNBG%\u0026#39;; 组合 Products 表中的产品名称和 Customers 表中的顾客名称 # Products 表含有字段 prod_name 代表产品名称\nprod_name flower rice ring umbrella Customers 表代表顾客信息,cust_name 代表顾客名称\ncust_name andy ben tony tom an lee hex 【问题】编写 SQL 语句,组合 Products 表中的产品名称(prod_name)和 Customers 表中的顾客名称(cust_name)并返回,然后按产品名称对结果进行升序排序。\n# UNION 结果集中的列名总是等于 UNION 中第一个 SELECT 语句中的列名。 select prod_name from Products union select cust_name from Customers order by prod_name; 检查 SQL 语句 # 表 Customers 含有字段 cust_name 顾客名、cust_contact 顾客联系方式、cust_state 顾客州、cust_email 顾客 email\ncust_name cust_contact cust_state cust_email cust10 8695192 MI cust10@cust.com cust1 8695193 MI cust1@cust.com cust2 8695194 IL cust2@cust.com 【问题】修正下面错误的 SQL\nSELECT cust_name, cust_contact, cust_email FROM Customers WHERE cust_state = \u0026#39;MI\u0026#39; ORDER BY cust_name; UNION SELECT cust_name, cust_contact, cust_email FROM Customers WHERE cust_state = \u0026#39;IL\u0026#39;ORDER BY cust_name; 修正后:\nSELECT cust_name, cust_contact, cust_email FROM Customers WHERE cust_state = \u0026#39;MI\u0026#39; UNION SELECT cust_name, cust_contact, cust_email FROM Customers WHERE cust_state = \u0026#39;IL\u0026#39; ORDER BY cust_name; 使用 union 组合查询时,只能使用一条 order by 字句,他必须位于最后一条 select 语句之后\n或者直接用 or 来做:\nSELECT cust_name, cust_contact, cust_email FROM Customers WHERE cust_state = \u0026#39;MI\u0026#39; or cust_state = \u0026#39;IL\u0026#39; ORDER BY cust_name; "},{"id":240,"href":"/zh/docs/technology/Review/java_guide/database/ly0504lysql-syntax-summary/","title":"sql语法基础知识总结","section":"数据库","content":" 转载自https://github.com/Snailclimb/JavaGuide(添加小部分笔记)感谢作者!\n本文整理完善自下面这两份资料:\nSQL 语法速成手册 MySQL 超全教程 基本概念 # 数据库术语 # 数据库(database) - 保存有组织的数据的容器(通常是一个文件或一组文件)。 数据表(table) - 某种特定类型数据的结构化清单。 模式(schema) - 关于数据库和表的布局及特性的信息。模式定义了数据在表中如何存储,包含存储什么样的数据,数据如何分解,各部分信息如何命名等信息。数据库和表都有模式。 列(column) - 表中的一个字段。所有表都是由一个或多个列组成的。 行(row) - 表中的一个记录。 主键(primary key) - 一列(或一组列),其值能够唯一标识表中每一行。 SQL 语法 # SQL(Structured Query Language),标准 SQL 由 ANSI 标准委员会管理,从而称为 ANSI SQL。各个 DBMS 都有自己的实现,如 PL/SQL、Transact-SQL 等。\nSQL 语法结构 # SQL 语法结构包括:\n子句 - 是语句和查询的组成成分。(在某些情况下,这些都是可选的。) 表达式 - 可以产生任何标量值,或由列和行的数据库表 谓词 - 给需要评估的 SQL 三值逻辑(3VL)(true/false/unknown)或布尔真值指定条件,并限制语句和查询的效果,或改变程序流程。 查询 - 基于特定条件检索数据。这是 SQL 的一个重要组成部分。 语句 - 可以持久地影响纲要和数据,也可以控制数据库事务、程序流程、连接、会话或诊断。 SQL 语法要点 # SQL 语句不区分大小写,但是数据库表名、列名和值是否区分,依赖于具体的 DBMS 以及配置。例如:SELECT 与 select 、Select 是相同的。 多条 SQL 语句必须以分号(;)分隔。 处理 SQL 语句时,所有空格都被忽略。 SQL 语句可以写成一行,也可以分写为多行。\n-- 一行 SQL 语句 UPDATE user SET username=\u0026#39;robot\u0026#39;, password=\u0026#39;robot\u0026#39; WHERE username = \u0026#39;root\u0026#39;; -- 多行 SQL 语句 UPDATE user SET username=\u0026#39;robot\u0026#39;, password=\u0026#39;robot\u0026#39; WHERE username = \u0026#39;root\u0026#39;; SQL 支持三种注释:\n## 注释1 -- 注释2 /* 注释3 */ SQL 分类 # 数据定义语言(DDL) # 数据定义语言(Data Definition Language,DDL)是 SQL 语言集中负责数据结构定义与数据库对象定义的语言。\nDDL 的主要功能是定义数据库对象。\nDDL 的核心指令是 CREATE、ALTER、DROP。\n数据操纵语言(DML) # 数据操纵语言(Data Manipulation Language, DML)是用于数据库操作,对数据库其中的对象和数据运行访问工作的编程语句。\nDML 的主要功能是 访问数据,因此其语法都是以读写数据库为主。\nDML 的核心指令是 INSERT、UPDATE、DELETE、SELECT。这四个指令合称 CRUD(Create, Read, Update, Delete),即增删改查。\n事务控制语言(TCL) # 事务控制语言 (Transaction Control Language, TCL) 用于管理数据库中的事务。这些用于管理由 DML 语句所做的更改。它还允许将语句分组为逻辑事务。\nTCL 的核心指令是 COMMIT、ROLLBACK。\n数据控制语言(DCL) # 数据控制语言 (Data Control Language, DCL) 是一种可对数据访问权进行控制的指令,它可以控制特定用户账户对数据表、查看表、预存程序、用户自定义函数等数据库对象的控制权。\nDCL 的核心指令是 GRANT、REVOKE。\nDCL 以控制用户的访问权限为主,因此其指令作法并不复杂,可利用 DCL 控制的权限有:CONNECT、SELECT、INSERT、UPDATE、DELETE、EXECUTE、USAGE、REFERENCES。\n根据不同的 DBMS 以及不同的安全性实体,其支持的权限控制也有所不同。\n我们先来介绍 DML 语句用法。 DML 的主要功能是读写数据库实现增删改查。\n增删改查 # 增删改查,又称为 CRUD,数据库基本操作中的基本操作。\n插入数据 # INSERT INTO 语句用于向表中插入新记录。\n插入完整的行\n# 插入一行 INSERT INTO user VALUES (10, \u0026#39;root\u0026#39;, \u0026#39;root\u0026#39;, \u0026#39;xxxx@163.com\u0026#39;); # 插入多行 INSERT INTO user VALUES (10, \u0026#39;root\u0026#39;, \u0026#39;root\u0026#39;, \u0026#39;xxxx@163.com\u0026#39;), (12, \u0026#39;user1\u0026#39;, \u0026#39;user1\u0026#39;, \u0026#39;xxxx@163.com\u0026#39;), (18, \u0026#39;user2\u0026#39;, \u0026#39;user2\u0026#39;, \u0026#39;xxxx@163.com\u0026#39;); 插入行的一部分\nINSERT INTO user(username, password, email) VALUES (\u0026#39;admin\u0026#39;, \u0026#39;admin\u0026#39;, \u0026#39;xxxx@163.com\u0026#39;); 插入查询出来的数据\nINSERT INTO user(username) SELECT name FROM account; 更新数据 # UPDATE 语句用于更新表中的记录。\nUPDATE user SET username=\u0026#39;robot\u0026#39;, password=\u0026#39;robot\u0026#39; WHERE username = \u0026#39;root\u0026#39;; 删除数据 # DELETE 语句用于删除表中的记录。 TRUNCATE TABLE 可以清空表,也就是删除所有行。 删除表中的指定数据\nDELETE FROM user WHERE username = \u0026#39;robot\u0026#39;; 清空表中的数据\nTRUNCATE TABLE user; 查询数据 # SELECT 语句用于从数据库中查询数据。\nDISTINCT 用于返回唯一不同的值。它作用于所有列,也就是说所有列的值都相同才算相同。\nLIMIT 限制返回的行数。可以有两个参数,第一个参数为起始行,从 0 开始;第二个参数为返回的总行数。\nASC :升序(默认) DESC :降序 查询单列\nSELECT prod_name FROM products; 查询多列\nSELECT prod_id, prod_name, prod_price FROM products; 查询所有列\nELECT * FROM products; 查询不同的值\nSELECT DISTINCT vend_id FROM products; 限制查询结果\n-- 返回前 5 行 SELECT * FROM mytable LIMIT 5; SELECT * FROM mytable LIMIT 0, 5; -- 返回第 3 ~ 5 行 SELECT * FROM mytable LIMIT 2, 3; 排序 # order by 用于对结果集按照一个列或者多个列进行排序。默认按照升序对记录进行排序,如果需要按照降序对记录进行排序,可以使用 desc 关键字。\norder by 对多列排序的时候,先排序的列放前面,后排序的列放后面。并且,不同的列可以有不同的排序规则。\nSELECT * FROM products ORDER BY prod_price DESC, prod_name ASC; 分组 # group by :\ngroup by 子句将记录分组到汇总行中。 group by 为每个组返回一个记录。 group by 通常还涉及聚合count,max,sum,avg 等。 group by 可以按一列或多列进行分组。 group by 按分组字段进行排序后,order by 可以以汇总字段来进行排序。 分组\nSELECT cust_name, COUNT(cust_address) AS addr_num FROM Customers GROUP BY cust_name; 分组后排序\nSELECT cust_name, COUNT(cust_address) AS addr_num FROM Customers GROUP BY cust_name ORDER BY cust_name DESC; having:\nhaving 用于对汇总的 group by 结果进行过滤。 having 一般都是和 group by 连用。 where 和 having 可以在相同的查询中。 使用 WHERE 和 HAVING 过滤数据\nSELECT cust_name, COUNT(*) AS num FROM Customers WHERE cust_email IS NOT NULL GROUP BY cust_name HAVING COUNT(*) \u0026gt;= 1; having vs where :\nwhere:过滤过滤指定的行,后面不能加聚合函数(分组函数)。where 在group by 前。 having:过滤分组,一般都是和 group by 连用,不能单独使用。having 在 group by 之后。 子查询 # 子查询是嵌套在较大查询中的 SQL 查询,也称内部查询或内部选择,包含子查询的语句也称为外部查询或外部选择。简单来说,子查询就是指将一个 select 查询(子查询)的结果作为另一个 SQL 语句(主查询)的数据来源或者判断条件。\n子查询可以嵌入 SELECT、INSERT、UPDATE 和 DELETE 语句中,也可以和 =、\u0026lt;、\u0026gt;、IN、BETWEEN、EXISTS 等运算符一起使用。\n子查询常用在 WHERE 子句和 FROM 子句后边:\n当用于 WHERE 子句时,根据不同的运算符,子查询可以返回单行单列、多行单列、单行多列数据。子查询就是要返回能够作为 WHERE 子句查询条件的值。 当用于 FROM 子句时,一般返回多行多列数据,相当于返回一张临时表,这样才符合 FROM 后面是表的规则。这种做法能够实现多表联合查询。 注意:MYSQL 数据库从 4.1 版本才开始支持子查询,早期版本是不支持的。\n用于 WHERE 子句的子查询的基本语法如下:\nselect column_name [, column_name ] from table1 [, table2 ] where column_name operator (select column_name [, column_name ] from table1 [, table2 ] [where]) 子查询需要放在括号( )内。 operator 表示用于 where 子句的运算符。 用于 FROM 子句的子查询的基本语法如下:\nselect column_name [, column_name ] from (select column_name [, column_name ] from table1 [, table2 ] [where]) as temp_table_name where condition 用于 FROM 的子查询返回的结果相当于一张临时表,所以需要使用 AS 关键字为该临时表起一个名字。\n子查询的子查询\nSELECT cust_name, cust_contact FROM customers WHERE cust_id IN (SELECT cust_id FROM orders WHERE order_num IN (SELECT order_num FROM orderitems WHERE prod_id = \u0026#39;RGAN01\u0026#39;)); 内部查询首先在其父查询之前执行,以便可以将内部查询的结果传递给外部查询。执行过程可以参考下图:\nWHERE # WHERE 子句用于过滤记录,即缩小访问数据的范围。 WHERE 后跟一个返回 true 或 false 的条件。 WHERE 可以与 SELECT,UPDATE 和 DELETE 一起使用。 可以在 WHERE 子句中使用的操作符。 运算符 描述 = 等于 \u0026lt;\u0026gt; 不等于。注释:在 SQL 的一些版本中,该操作符可被写成 != \u0026gt; 大于 \u0026lt; 小于 \u0026gt;= 大于等于 \u0026lt;= 小于等于 BETWEEN 在某个范围内 LIKE 搜索某种模式 IN 指定针对某个列的多个可能值 SELECT 语句中的 WHERE 子句\nSELECT * FROM Customers WHERE cust_name = \u0026#39;Kids Place\u0026#39;; UPDATE 语句中的 WHERE 子句\nUPDATE Customers SET cust_name = \u0026#39;Jack Jones\u0026#39; WHERE cust_name = \u0026#39;Kids Place\u0026#39;; DELETE 语句中的 WHERE 子句\nDELETE FROM Customers WHERE cust_name = \u0026#39;Kids Place\u0026#39;; IN 和 BETWEEN # IN 操作符在 WHERE 子句中使用,作用是在指定的几个特定值中任选一个值。 BETWEEN 操作符在 WHERE 子句中使用,作用是选取介于某个范围内的值。 IN 示例\nSELECT * FROM products WHERE vend_id IN (\u0026#39;DLL01\u0026#39;, \u0026#39;BRS01\u0026#39;); BETWEEN 示例\nSELECT * FROM products WHERE prod_price BETWEEN 3 AND 5; AND、OR、NOT # AND、OR、NOT 是用于对过滤条件的逻辑处理指令。 AND 优先级高于 OR,为了明确处理顺序,可以使用 ()。 AND 操作符表示左右条件都要满足。 OR 操作符表示左右条件满足任意一个即可。 NOT 操作符用于否定一个条件。 AND 示例\nSELECT prod_id, prod_name, prod_price FROM products WHERE vend_id = \u0026#39;DLL01\u0026#39; AND prod_price \u0026lt;= 4; OR 示例\nSELECT prod_id, prod_name, prod_price FROM products WHERE vend_id = \u0026#39;DLL01\u0026#39; OR vend_id = \u0026#39;BRS01\u0026#39;; NOT 示例\nSELECT * FROM products WHERE prod_price NOT BETWEEN 3 AND 5; LIKE # LIKE 操作符在 WHERE 子句中使用,作用是确定字符串是否匹配模式。 只有字段是文本值时才使用 LIKE。 LIKE 支持两个通配符匹配选项:% 和 _。 不要滥用通配符,通配符位于开头处匹配会非常慢。 % 表示任何字符出现任意次数。 _ 表示任何字符出现一次。 % 示例\nSELECT prod_id, prod_name, prod_price FROM products WHERE prod_name LIKE \u0026#39;%bean bag%\u0026#39;; _ 示例\nSELECT prod_id, prod_name, prod_price FROM products WHERE prod_name LIKE \u0026#39;__ inch teddy bear\u0026#39;; 连接 # JOIN 是“连接”的意思,顾名思义,SQL JOIN 子句用于将两个或者多个表联合起来进行查询。\n连接表时需要在每个表中选择一个字段,并对这些字段的值进行比较,值相同的两条记录将合并为一条。连接表的本质就是将不同表的记录合并起来,形成一张新表。当然,这张新表只是临时的,它仅存在于本次查询期间。\n使用 JOIN 连接两个表的基本语法如下:\nselect table1.column1, table2.column2... from table1 join table2 on table1.common_column1 = table2.common_column2; table1.common_column1 = table2.common_column2 是连接条件,只有满足此条件的记录才会合并为一行。您可以使用多个运算符来连接表,例如 =、\u0026gt;、\u0026lt;、\u0026lt;\u0026gt;、\u0026lt;=、\u0026gt;=、!=、between、like 或者 not,但是最常见的是使用 =。\n当两个表中有同名的字段时,为了帮助数据库引擎区分是哪个表的字段,在书写同名字段名时需要加上表名。当然,如果书写的字段名在两个表中是唯一的,也可以不使用以上格式,只写字段名即可。\n另外,如果两张表的关联字段名相同,也可以使用 USING子句来代替 ON,举个例子:\n# join....on select c.cust_name, o.order_num from Customers c inner join Orders o on c.cust_id = o.cust_id order by c.cust_name; # 如果两张表的关联字段名相同,也可以使用USING子句:join....using() select c.cust_name, o.order_num from Customers c inner join Orders o using(cust_id) order by c.cust_name; ON 和 WHERE 的区别:\n连接表时,SQL 会根据连接条件生成一张新的临时表。ON 就是连接条件,它决定临时表的生成。 WHERE 是在临时表生成以后,再对临时表中的数据进行过滤,生成最终的结果集,这个时候已经没有 JOIN-ON 了。 所以总结来说就是:SQL 先根据 ON 生成一张临时表,然后再根据 WHERE 对临时表进行筛选。\nSQL 允许在 JOIN 左边加上一些修饰性的关键词,从而形成不同类型的连接,如下表所示:\n连接类型 说明 INNER JOIN 内连接 (默认连接方式)只有当两个表都存在满足条件的记录时才会返回行。 LEFT JOIN / LEFT OUTER JOIN 左(外)连接 返回左表中的所有行,即使右表中没有满足条件的行也是如此。 RIGHT JOIN / RIGHT OUTER JOIN 右(外)连接 返回右表中的所有行,即使左表中没有满足条件的行也是如此。 FULL JOIN / FULL OUTER JOIN 全(外)连接 只要其中有一个表存在满足条件的记录,就返回行。 SELF JOIN 将一个表连接到自身,就像该表是两个表一样。为了区分两个表,在 SQL 语句中需要至少重命名一个表。 CROSS JOIN 交叉连接,从两个或者多个连接表中返回记录集的笛卡尔积。 下图展示了 LEFT JOIN、RIGHT JOIN、INNER JOIN、OUTER JOIN 相关的 7 种用法。\n如果不加任何修饰词,只写 JOIN,那么默认为 INNER JOIIN\n对于 INNER JOIIN 来说,还有一种隐式的写法,称为 “隐式内连接”,也就是没有 INNER JOIIN 关键字,使用 WHERE 语句实现内连接的功能\n# 隐式内连接 select c.cust_name, o.order_num from Customers c, Orders o where c.cust_id = o.cust_id order by c.cust_name; # 显式内连接 select c.cust_name, o.order_num from Customers c inner join Orders o using(cust_id) order by c.cust_name; 组合 # UNION 运算符将两个或更多查询的结果组合起来,并生成一个结果集,其中包含来自 UNION 中参与查询的提取行。\nUNION 基本规则:\n所有查询的列数和列顺序必须相同。 每个查询中涉及表的列的数据类型必须相同或兼容。 通常返回的列名取自第一个查询。 默认地,UNION 操作符选取不同的值。如果允许重复的值,请使用 UNION ALL。\nSELECT column_name(s) FROM table1 UNION ALL SELECT column_name(s) FROM table2; UNION 结果集中的列名总是等于 UNION 中第一个 SELECT 语句中的列名。\nJOIN vs UNION:\nJOIN 中连接表的列可能不同,但在 UNION 中,所有查询的列数和列顺序必须相同。 UNION 将查询之后的行放在一起(垂直放置),但 JOIN 将查询之后的列放在一起(水平放置),即它构成一个笛卡尔积。 函数 # 不同数据库的函数往往各不相同,因此不可移植。本节主要以 MysSQL 的函数为例。\n文本处理 # 函数 说明 LEFT()、RIGHT() 左边或者右边的字符 LOWER()、UPPER() 转换为小写或者大写 LTRIM()、RTIM() 去除左边或者右边的空格 LENGTH() 长度 SOUNDEX() 转换为语音值 其中, SOUNDEX() 可以将一个字符串转换为描述其语音表示的字母数字模式。\nSELECT * FROM mytable WHERE SOUNDEX(col1) = SOUNDEX(\u0026#39;apple\u0026#39;) 日期和时间处理 # 日期格式:YYYY-MM-DD 时间格式:HH:MM:SS 函 数 说 明 AddDate() 增加一个日期(天、周等) AddTime() 增加一个时间(时、分等) CurDate() 返回当前日期 CurTime() 返回当前时间 Date() 返回日期时间的日期部分 DateDiff() 计算两个日期之差 Date_Add() 高度灵活的日期运算函数 Date_Format() 返回一个格式化的日期或时间串 Day() 返回一个日期的天数部分 DayOfWeek() 对于一个日期,返回对应的星期几 Hour() 返回一个时间的小时部分 Minute() 返回一个时间的分钟部分 Month() 返回一个日期的月份部分 Now() 返回当前日期和时间 Second() 返回一个时间的秒部分 Time() 返回一个日期时间的时间部分 Year() 返回一个日期的年份部分 数值处理 # 函数 说明 SIN() 正弦 COS() 余弦 TAN() 正切 ABS() 绝对值 SQRT() 平方根 MOD() 余数 EXP() 指数 PI() 圆周率 RAND() 随机数 汇总 # 函 数 说 明 AVG() 返回某列的平均值 COUNT() 返回某列的行数 MAX() 返回某列的最大值 MIN() 返回某列的最小值 SUM() 返回某列值之和 AVG() 会忽略 NULL 行。\n使用 DISTINCT 可以让汇总函数值汇总不同的值。\nSELECT AVG(DISTINCT col1) AS avg_col FROM mytable 接下来,我们来介绍 DDL 语句用法。DDL 的主要功能是定义数据库对象(如:数据库、数据表、视图、索引等)\n数据定义 # 数据库(DATABASE) # 创建数据库 # CREATE DATABASE test; 删除数据库 # DROP DATABASE test; 选择数据库 # USE test; 数据表(TABLE) # 创建数据表 # 普通创建\nCREATE TABLE user ( id int(10) unsigned NOT NULL COMMENT \u0026#39;Id\u0026#39;, username varchar(64) NOT NULL DEFAULT \u0026#39;default\u0026#39; COMMENT \u0026#39;用户名\u0026#39;, password varchar(64) NOT NULL DEFAULT \u0026#39;default\u0026#39; COMMENT \u0026#39;密码\u0026#39;, email varchar(64) NOT NULL DEFAULT \u0026#39;default\u0026#39; COMMENT \u0026#39;邮箱\u0026#39; ) COMMENT=\u0026#39;用户表\u0026#39;; 根据已有的表创建新表\nCREATE TABLE vip_user AS SELECT * FROM user; 删除数据表 # DROP TABLE user; 修改数据表 # 添加列\nALTER TABLE user ADD age int(3); 删除列\nALTER TABLE user DROP COLUMN age; 修改列\nALTER TABLE `user` MODIFY COLUMN age tinyint; 添加主键\nALTER TABLE user ADD PRIMARY KEY (id); 删除主键\nALTER TABLE user DROP PRIMARY KEY; 视图(VIEW) # 定义:\n视图是基于 SQL 语句的结果集的可视化的表。 视图是虚拟的表,本身不包含数据,也就不能对其进行索引操作。对视图的操作和对普通表的操作一样。 作用:\n简化复杂的 SQL 操作,比如复杂的联结; 只使用实际表的一部分数据; 通过只给用户访问视图的权限,保证数据的安全性; 更改数据格式和表示。 创建视图 # CREATE VIEW top_10_user_view AS SELECT id, username FROM user WHERE id \u0026lt; 10; 删除视图 # DROP VIEW top_10_user_view; 索引(INDEX) # 索引是一种用于快速查询和检索数据的数据结构,其本质可以看成是一种排序好的数据结构。\n索引的作用就相当于书的目录。打个比方: 我们在查字典的时候,如果没有目录,那我们就只能一页一页的去找我们需要查的那个字,速度很慢。如果有目录了,我们只需要先去目录里查找字的位置,然后直接翻到那一页就行了。\n优点 :\n使用索引可以大大加快 数据的检索速度(大大减少检索的数据量), 这也是创建索引的最主要的原因。 通过创建唯一性索引,可以保证数据库表中每一行数据的唯一性。 缺点 :\n创建索引和维护索引需要耗费许多时间。当对表中的数据进行增删改的时候,如果数据有索引,那么索引也需要动态的修改,会降低 SQL 执行效率。 索引需要使用物理文件存储,也会耗费一定空间。 但是,使用索引一定能提高查询性能吗?\n大多数情况下,索引查询都是比全表扫描要快的。但是如果数据库的数据量不大,那么使用索引也不一定能够带来很大提升。\n关于索引的详细介绍,请看我写的 MySQL 索引详解 这篇文章。\n创建索引 # CREATE INDEX user_index ON user (id); 添加索引 # ALTER table user ADD INDEX user_index(id) 创建唯一索引 # CREATE UNIQUE INDEX user_index ON user (id); 删除索引 # ALTER TABLE user DROP INDEX user_index; 约束 # SQL 约束用于规定表中的数据规则。\n如果存在违反约束的数据行为,行为会被约束终止。\n约束可以在创建表时规定(通过 CREATE TABLE 语句),或者在表创建之后规定(通过 ALTER TABLE 语句)。\n约束类型:\nNOT NULL - 指示某列不能存储 NULL 值。 UNIQUE - 保证某列的每行必须有唯一的值。 PRIMARY KEY - NOT NULL 和 UNIQUE 的结合。确保某列(或两个列多个列的结合)有唯一标识,有助于更容易更快速地找到表中的一个特定的记录。 FOREIGN KEY - 保证一个表中的数据匹配另一个表中的值的参照完整性。 CHECK - 保证列中的值符合指定的条件。 DEFAULT - 规定没有给列赋值时的默认值。 创建表时使用约束条件:\nCREATE TABLE Users ( Id INT(10) UNSIGNED NOT NULL AUTO_INCREMENT COMMENT \u0026#39;自增Id\u0026#39;, Username VARCHAR(64) NOT NULL UNIQUE DEFAULT \u0026#39;default\u0026#39; COMMENT \u0026#39;用户名\u0026#39;, Password VARCHAR(64) NOT NULL DEFAULT \u0026#39;default\u0026#39; COMMENT \u0026#39;密码\u0026#39;, Email VARCHAR(64) NOT NULL DEFAULT \u0026#39;default\u0026#39; COMMENT \u0026#39;邮箱地址\u0026#39;, Enabled TINYINT(4) DEFAULT NULL COMMENT \u0026#39;是否有效\u0026#39;, PRIMARY KEY (Id) ) ENGINE=InnoDB AUTO_INCREMENT=2 DEFAULT CHARSET=utf8mb4 COMMENT=\u0026#39;用户表\u0026#39;; 接下来,我们来介绍 TCL 语句用法。TCL 的主要功能是管理数据库中的事务。\n事务处理 # 不能回退 SELECT 语句,回退 SELECT 语句也没意义;也不能回退 CREATE 和 DROP 语句。\nMySQL 默认是隐式提交,每执行一条语句就把这条语句当成一个事务然后进行提交。当出现 START TRANSACTION 语句时,会关闭隐式提交;当 COMMIT 或 ROLLBACK 语句执行后,事务会自动关闭,重新恢复隐式提交。\n通过 set autocommit=0 可以取消自动提交,直到 set autocommit=1 才会提交;autocommit 标记是针对每个连接而不是针对服务器的。\n指令:\nSTART TRANSACTION - 指令用于标记事务的起始点。 SAVEPOINT - 指令用于创建保留点。 ROLLBACK TO - 指令用于回滚到指定的保留点;如果没有设置保留点,则回退到 START TRANSACTION 语句处。 COMMIT - 提交事务。 -- 开始事务 START TRANSACTION; -- 插入操作 A INSERT INTO `user` VALUES (1, \u0026#39;root1\u0026#39;, \u0026#39;root1\u0026#39;, \u0026#39;xxxx@163.com\u0026#39;); -- 创建保留点 updateA SAVEPOINT updateA; -- 插入操作 B INSERT INTO `user` VALUES (2, \u0026#39;root2\u0026#39;, \u0026#39;root2\u0026#39;, \u0026#39;xxxx@163.com\u0026#39;); -- 回滚到保留点 updateA ROLLBACK TO updateA; -- 提交事务,只有操作 A 生效 COMMIT; 接下来,我们来介绍 DCL 语句用法。DCL 的主要功能是控制用户的访问权限。\n权限控制 # 要授予用户帐户权限,可以用GRANT命令。有撤销用户的权限,可以用REVOKE命令。这里以 MySQl 为例,介绍权限控制实际应用。\nGRANT授予权限语法:\nGRANT privilege,[privilege],.. ON privilege_level TO user [IDENTIFIED BY password] [REQUIRE tsl_option] [WITH [GRANT_OPTION | resource_option]]; 简单解释一下:\n在GRANT关键字后指定一个或多个权限。如果授予用户多个权限,则每个权限由逗号分隔。 ON privilege_level 确定权限应用级别。MySQL 支持 global(*.*),database(database.*),table(database.table)和列级别。如果使用列权限级别,则必须在每个权限之后指定一个或逗号分隔列的列表。 user 是要授予权限的用户。如果用户已存在,则GRANT语句将修改其权限。否则,GRANT语句将创建一个新用户。可选子句IDENTIFIED BY允许您为用户设置新的密码。 REQUIRE tsl_option指定用户是否必须通过 SSL,X059 等安全连接连接到数据库服务器。 可选 WITH GRANT OPTION 子句允许您授予其他用户或从其他用户中删除您拥有的权限。此外,您可以使用WITH子句分配 MySQL 数据库服务器的资源,例如,设置用户每小时可以使用的连接数或语句数。这在 MySQL 共享托管等共享环境中非常有用。 REVOKE 撤销权限语法:\nREVOKE privilege_type [(column_list)] [, priv_type [(column_list)]]... ON [object_type] privilege_level FROM user [, user]... 简单解释一下:\n在 REVOKE 关键字后面指定要从用户撤消的权限列表。您需要用逗号分隔权限。 指定在 ON 子句中撤销特权的特权级别。 指定要撤消 FROM 子句中的权限的用户帐户。 GRANT 和 REVOKE 可在几个层次上控制访问权限:\n整个服务器,使用 GRANT ALL 和 REVOKE ALL; 整个数据库,使用 ON database.*; 特定的表,使用 ON database.table; 特定的列; 特定的存储过程。 新创建的账户没有任何权限。账户用 username@host 的形式定义,username@% 使用的是默认主机名。MySQL 的账户信息保存在 mysql 这个数据库中。\nUSE mysql; SELECT user FROM user; 下表说明了可用于GRANT和REVOKE语句的所有允许权限:\n特权 说明 级别 全局 数据库 表 列 程序 代理 ALL [PRIVILEGES] 授予除 GRANT OPTION 之外的指定访问级别的所有权限 ALTER 允许用户使用 ALTER TABLE 语句 X X X ALTER ROUTINE 允许用户更改或删除存储的例程 X X X CREATE 允许用户创建数据库和表 X X X CREATE ROUTINE 允许用户创建存储的例程 X X CREATE TABLESPACE 允许用户创建,更改或删除表空间和日志文件组 X CREATE TEMPORARY TABLES 允许用户使用 CREATE TEMPORARY TABLE 创建临时表 X X CREATE USER 允许用户使用 CREATE USER,DROP USER,RENAME USER 和 REVOKE ALL PRIVILEGES 语句。 X CREATE VIEW 允许用户创建或修改视图。 X X X DELETE 允许用户使用 DELETE X X X DROP 允许用户删除数据库,表和视图 X X X EVENT 启用事件计划程序的事件使用。 X X EXECUTE 允许用户执行存储的例程 X X X FILE 允许用户读取数据库目录中的任何文件。 X GRANT OPTION 允许用户拥有授予或撤消其他帐户权限的权限。 X X X X X INDEX 允许用户创建或删除索引。 X X X INSERT 允许用户使用 INSERT 语句 X X X X LOCK TABLES 允许用户对具有 SELECT 权限的表使用 LOCK TABLES X X PROCESS 允许用户使用 SHOW PROCESSLIST 语句查看所有进程。 X PROXY 启用用户代理。 REFERENCES 允许用户创建外键 X X X X RELOAD 允许用户使用 FLUSH 操作 X REPLICATION CLIENT 允许用户查询以查看主服务器或从属服务器的位置 X REPLICATION SLAVE 允许用户使用复制从属从主服务器读取二进制日志事件。 X SELECT 允许用户使用 SELECT 语句 X X X X SHOW DATABASES 允许用户显示所有数据库 X SHOW VIEW 允许用户使用 SHOW CREATE VIEW 语句 X X X SHUTDOWN 允许用户使用 mysqladmin shutdown 命令 X SUPER 允许用户使用其他管理操作,例如 CHANGE MASTER TO,KILL,PURGE BINARY LOGS,SET GLOBAL 和 mysqladmin 命令 X TRIGGER 允许用户使用 TRIGGER 操作。 X X X UPDATE 允许用户使用 UPDATE 语句 X X X X USAGE 相当于“没有特权” 创建账户 # CREATE USER myuser IDENTIFIED BY \u0026#39;mypassword\u0026#39;; 修改账户名 # UPDATE user SET user=\u0026#39;newuser\u0026#39; WHERE user=\u0026#39;myuser\u0026#39;; FLUSH PRIVILEGES; 删除账户 # DROP USER myuser; 查看权限 # SHOW GRANTS FOR myuser; 授予权限 # GRANT SELECT, INSERT ON *.* TO myuser; 删除权限 # REVOKE SELECT, INSERT ON *.* FROM myuser; 更改密码 # SET PASSWORD FOR myuser = \u0026#39;mypass\u0026#39;; 存储过程 # 存储过程可以看成是对一系列 SQL 操作的批处理。存储过程可以由触发器,其他存储过程以及 Java, Python,PHP 等应用程序调用。\n使用存储过程的好处:\n代码封装,保证了一定的安全性; 代码复用; 由于是预先编译,因此具有很高的性能。 创建存储过程:\n命令行中创建存储过程需要自定义分隔符,因为命令行是以 ; 为结束符,而存储过程中也包含了分号,因此会错误把这部分分号当成是结束符,造成语法错误。 包含 in、out 和 inout 三种参数。 给变量赋值都需要用 select into 语句。 每次只能给一个变量赋值,不支持集合的操作。 需要注意的是:阿里巴巴《Java 开发手册》强制禁止使用存储过程。因为存储过程难以调试和扩展,更没有移植性。\n至于到底要不要在项目中使用,还是要看项目实际需求,权衡好利弊即可!\n创建存储过程 # DROP PROCEDURE IF EXISTS `proc_adder`; DELIMITER ;; CREATE DEFINER=`root`@`localhost` PROCEDURE `proc_adder`(IN a int, IN b int, OUT sum int) BEGIN DECLARE c int; if a is null then set a = 0; end if; if b is null then set b = 0; end if; set sum = a + b; END ;; DELIMITER ; 使用存储过程 # set @b=5; call proc_adder(2,@b,@s); select @s as sum; 游标 # 游标(cursor)是一个存储在 DBMS 服务器上的数据库查询,它不是一条 SELECT 语句,而是被该语句检索出来的结果集。\n在存储过程中使用游标可以对一个结果集进行移动遍历。\n游标主要用于交互式应用,其中用户需要滚动屏幕上的数据,并对数据进行浏览或做出更改。\n使用游标的几个明确步骤:\n在使用游标前,必须声明(定义)它。这个过程实际上没有检索数据, 它只是定义要使用的 SELECT 语句和游标选项。\n一旦声明,就必须打开游标以供使用。这个过程用前面定义的 SELECT 语句把数据实际检索出来。\n对于填有数据的游标,根据需要取出(检索)各行。\n在结束游标使用时,必须关闭游标,可能的话,释放游标(有赖于具\n体的 DBMS)。\nDELIMITER $ CREATE PROCEDURE getTotal() BEGIN DECLARE total INT; -- 创建接收游标数据的变量 DECLARE sid INT; DECLARE sname VARCHAR(10); -- 创建总数变量 DECLARE sage INT; -- 创建结束标志变量 DECLARE done INT DEFAULT false; -- 创建游标 DECLARE cur CURSOR FOR SELECT id,name,age from cursor_table where age\u0026gt;30; -- 指定游标循环结束时的返回值 DECLARE CONTINUE HANDLER FOR NOT FOUND SET done = true; SET total = 0; OPEN cur; FETCH cur INTO sid, sname, sage; WHILE(NOT done) DO SET total = total + 1; FETCH cur INTO sid, sname, sage; END WHILE; CLOSE cur; SELECT total; END $ DELIMITER ; -- 调用存储过程 call getTotal(); 触发器 # 触发器是一种与表操作有关的数据库对象,当触发器所在表上出现指定事件时,将调用该对象,即表的操作事件触发表上的触发器的执行。\n我们可以使用触发器来进行审计跟踪,把修改记录到另外一张表中。\n使用触发器的优点:\nSQL 触发器提供了另一种检查数据完整性的方法。 SQL 触发器可以捕获数据库层中业务逻辑中的错误。 SQL 触发器提供了另一种运行计划任务的方法。通过使用 SQL 触发器,您不必等待运行计划任务,因为在对表中的数据进行更改之前或之后会自动调用触发器。 SQL 触发器对于审计表中数据的更改非常有用。 使用触发器的缺点:\nSQL 触发器只能提供扩展验证,并且不能替换所有验证。必须在应用程序层中完成一些简单的验证。例如,您可以使用 JavaScript 在客户端验证用户的输入,或者使用服务器端脚本语言(如 JSP,PHP,ASP.NET,Perl)在服务器端验证用户的输入。 从客户端应用程序调用和执行 SQL 触发器是不可见的,因此很难弄清楚数据库层中发生了什么。 SQL 触发器可能会增加数据库服务器的开销。 MySQL 不允许在触发器中使用 CALL 语句 ,也就是不能调用存储过程。\n注意:在 MySQL 中,分号 ; 是语句结束的标识符,遇到分号表示该段语句已经结束,MySQL 可以开始执行了。因此,解释器遇到触发器执行动作中的分号后就开始执行,然后会报错,因为没有找到和 BEGIN 匹配的 END。\n这时就会用到 DELIMITER 命令(DELIMITER 是定界符,分隔符的意思)。它是一条命令,不需要语句结束标识,语法为:DELIMITER new_delemiter。new_delemiter 可以设为 1 个或多个长度的符号,默认的是分号 ;,我们可以把它修改为其他符号,如 $ - DELIMITER $ 。在这之后的语句,以分号结束,解释器不会有什么反应,只有遇到了 $,才认为是语句结束。注意,使用完之后,我们还应该记得把它给修改回来。\n在 MySQL 5.7.2 版之前,可以为每个表定义最多六个触发器。\nBEFORE INSERT - 在将数据插入表格之前激活。 AFTER INSERT - 将数据插入表格后激活。 BEFORE UPDATE - 在更新表中的数据之前激活。 AFTER UPDATE - 更新表中的数据后激活。 BEFORE DELETE - 在从表中删除数据之前激活。 AFTER DELETE - 从表中删除数据后激活。 但是,从 MySQL 版本 5.7.2+开始,可以为同一触发事件和操作时间定义多个触发器。\nNEW 和 OLD :\nMySQL 中定义了 NEW 和 OLD 关键字,用来表示触发器的所在表中,触发了触发器的那一行数据。 在 INSERT 型触发器中,NEW 用来表示将要(BEFORE)或已经(AFTER)插入的新数据; 在 UPDATE 型触发器中,OLD 用来表示将要或已经被修改的原数据,NEW 用来表示将要或已经修改为的新数据; 在 DELETE 型触发器中,OLD 用来表示将要或已经被删除的原数据; 使用方法: NEW.columnName (columnName 为相应数据表某一列名) 创建触发器 # 提示:为了理解触发器的要点,有必要先了解一下创建触发器的指令。\nCREATE TRIGGER 指令用于创建触发器。\n语法:\nCREATE TRIGGER trigger_name trigger_time trigger_event ON table_name FOR EACH ROW BEGIN trigger_statements END; 说明:\ntrigger_name :触发器名 trigger_time : 触发器的触发时机。取值为 BEFORE 或 AFTER。 trigger_event : 触发器的监听事件。取值为 INSERT、UPDATE 或 DELETE。 table_name : 触发器的监听目标。指定在哪张表上建立触发器。 FOR EACH ROW: 行级监视,Mysql 固定写法,其他 DBMS 不同。 trigger_statements: 触发器执行动作。是一条或多条 SQL 语句的列表,列表内的每条语句都必须用分号 ; 来结尾。 当触发器的触发条件满足时,将会执行 BEGIN 和 END 之间的触发器执行动作。\n示例:\nDELIMITER $ CREATE TRIGGER `trigger_insert_user` AFTER INSERT ON `user` FOR EACH ROW BEGIN INSERT INTO `user_history`(user_id, operate_type, operate_time) VALUES (NEW.id, \u0026#39;add a user\u0026#39;, now()); END $ DELIMITER ; 查看触发器 # SHOW TRIGGERS; 删除触发器 # DROP TRIGGER IF EXISTS trigger_insert_user; 文章推荐 # 后端程序员必备:SQL高性能优化指南!35+条优化建议立马GET! 后端程序员必备:书写高质量SQL的30条建议 "},{"id":241,"href":"/zh/docs/technology/Review/java_guide/database/Redis/diagram/","title":"redis问题图解","section":"Redis","content":" 转载自https://github.com/Snailclimb/JavaGuide(添加小部分笔记)感谢作者!\n主从复制原理\n哨兵模式(简单)\n哨兵模式详解\n先配置主从模式,再配置哨兵模式\n所有的哨兵 sentinel.conf 都是配置为监听master\u0026ndash;\u0026gt; 192.168.14.101,如果主机宕机,sentinel.conf 中的配置也会自动更改为选举后的\nJava客户端连接原理\n客户端是和Sentinel来进行交互的,通过Sentinel来获取真正的Redis节点信息,然后来操作.实际工作时,Sentinel 内部维护了一个主题队列,用来保存Redis的节点信息,并实时更新,客户端订阅了这个主题,然后实时的去获取这个队列的Redis节点信息.\n/** 代码相对比较简单 **/ //1.设置sentinel 各个节点集合 Set\u0026lt;String\u0026gt; sentinelSet = new HashSet\u0026lt;\u0026gt;(); sentinelSet.add(\u0026#34;192.168.14.101:26379\u0026#34;); sentinelSet.add(\u0026#34;192.168.14.102:26380\u0026#34;); sentinelSet.add(\u0026#34;192.168.14.103:26381\u0026#34;); //2.设置jedispool 连接池配置文件 JedisPoolConfig config = new JedisPoolConfig(); config.setMaxTotal(10); config.setMaxWaitMillis(1000); //3.设置mastername,sentinelNode集合,配置文件,Redis登录密码 JedisSentinelPool jedisSentinelPool = new JedisSentinelPool(\u0026#34;mymaster\u0026#34;,sentinelSet,config,\u0026#34;123\u0026#34;); Jedis jedis = null; try { jedis = jedisSentinelPool.getResource(); //获取Redis中key=hello的值 String value = jedis.get(\u0026#34;hello\u0026#34;); System.out.println(value); } catch (Exception e) { e.printStackTrace(); } finally { if(jedis != null){ jedis.close(); } } 哨兵工作原理\n主观宕机:sentinel自认为redis不可用\n客观宕机:sentinel集群认为redis不可用\n故障转移\n"},{"id":242,"href":"/zh/docs/technology/Review/java_guide/database/Redis/ly0703ly3-commonly-used-cache-read-and-write-strategies/","title":"3种常用的缓存读写策略详解","section":"Redis","content":" 转载自https://github.com/Snailclimb/JavaGuide(添加小部分笔记)感谢作者!\n看到很多小伙伴简历上写了“熟练使用缓存”,但是被我问到“缓存常用的3种读写策略”的时候却一脸懵逼。\n在我看来,造成这个问题的原因是我们在学习 Redis 的时候,可能只是简单了写一些 Demo,并没有去关注缓存的读写策略,或者说压根不知道这回事。\n但是,搞懂3种常见的缓存读写策略对于实际工作中使用缓存以及面试中被问到缓存都是非常有帮助的!\n下面介绍到的三种模式各有优劣,不存在最佳模式,根据具体的业务场景选择适合自己的缓存读写模式。\nCache Aside Pattern(旁路缓存模式) # Cache Aside Pattern 是我们平时使用比较多的一个缓存读写模式,比较适合读请求比较多的场景。\nCache Aside Pattern 中服务端需要同时维系 db 和 cache,并且是以 db 的结果为准。\n下面我们来看一下这个策略模式下的缓存读写步骤。\n写 :\n先更新 db 然后直接删除 cache 。 简单画了一张图帮助大家理解写的步骤。\n读 :\n从 cache 中读取数据,读取到就直接返回 cache 中读取不到的话,就从 db 中读取数据返回 再把数据放到 cache 中。 简单画了一张图帮助大家理解读的步骤。\n你仅仅了解了上面这些内容的话是远远不够的,我们还要搞懂其中的原理。\n比如说面试官很可能会追问:“在写数据的过程中,可以先删除 cache ,后更新 db 么?”\n答案: 那肯定是不行的!因为这样可能会造成 数据库(db)和缓存(Cache)数据不一致的问题。\n举例:请求 1 先写数据 A,请求 2 随后读数据 A 的话,就很有可能产生数据不一致性的问题。\n这个过程可以简单描述为:\n请求 1 先把 cache 中的 A 数据删除 -\u0026gt; 请求 2 从 db 中读取数据【此时请求2把脏数据(对于请求1来说是)更新到缓存去了】-\u0026gt;请求 1 再把 db 中的 A 数据更新,即请求1的操作非原子\n当你这样回答之后,面试官可能会紧接着就追问:“在写数据的过程中,先更新 db,后删除 cache 就没有问题了么?”\n答案: 理论上来说还是可能会出现数据不一致性的问题,不过概率非常小,因为缓存的写入速度是比数据库的写入速度快很多。\n举例:请求 1 先读数据 A,请求 2 随后写数据 A,并且数据 A 在请求 1 请求之前不在缓存中的话,也有可能产生数据不一致性的问题。\n这个过程可以简单描述为:\n请求 1 从 db 读数据 A-\u0026gt; 请求 2 更新 db 中的数据 A(此时缓存中无数据 A ,故不用执行删除缓存操作 ) -\u0026gt; 请求 1 将数据 A 写入 cache\n现在我们再来分析一下 Cache Aside Pattern 的缺陷。\n缺陷 1:首次请求数据一定不在 cache 的问题\n解决办法:可以将热点数据可以提前放入 cache 中。\n缺陷 2:写操作比较频繁的话导致 cache 中的数据会被频繁被删除,这样会影响缓存命中率 。\n解决办法:\n数据库和缓存数据强一致场景 :更新 db 的时候同样更新 cache,不过我们需要加一个锁/分布式锁来保证更新 cache 的时候不存在线程安全问题。 可以短暂地允许数据库和缓存数据不一致的场景 :更新 db 的时候同样更新 cache,但是给缓存加一个比较短的过期时间,这样的话就可以保证即使数据不一致的话影响也比较小。 Read/Write Through Pattern(读写穿透) # Read/Write Through Pattern 中服务端把 cache 视为主要数据存储,从中读取数据并将数据写入其中。cache 服务负责将此数据读取和写入 db,从而减轻了应用程序的职责。\n这种缓存读写策略小伙伴们应该也发现了在平时在开发过程中非常少见。抛去性能方面的影响,大概率是因为我们经常使用的分布式缓存 Redis 并没有提供 cache 将数据写入 db 的功能。\n写(Write Through):\n先查 cache,cache 中不存在,直接更新 db。 cache 中存在,则先更新 cache,然后 cache 服务自己更新 db(同步更新 cache 和 db)。 简单画了一张图帮助大家理解写的步骤。\n读(Read Through):\n从 cache 中读取数据,读取到就直接返回 。 读取不到的话,先从 db 加载,写入到 cache 后返回响应。 简单画了一张图帮助大家理解读的步骤。\nRead-Through Pattern 实际只是在 Cache-Aside Pattern 之上进行了封装。在 Cache-Aside Pattern 下,发生读请求的时候,如果 cache 中不存在对应的数据,是由客户端自己负责把数据写入 cache,而 Read Through Pattern 则是 cache 服务自己来写入缓存的,这对客户端是透明的。\n和 Cache Aside Pattern 一样, Read-Through Pattern 也有首次请求数据一定不再 cache 的问题,对于热点数据可以提前放入缓存中。\nWrite Behind Pattern(异步缓存写入) # Write Behind Pattern 和 Read/Write Through Pattern 很相似,两者都是由 cache 服务来负责 cache 和 db 的读写。\n但是,两个又有很大的不同:Read/Write Through 是同步更新 cache 和 db,而 Write Behind 则是只更新缓存,不直接更新 db,而是改为异步批量的方式来更新 db。\n很明显,这种方式对数据一致性带来了更大的挑战,比如 cache 数据可能还没异步更新 db 的话,cache 服务可能就就挂掉了。\n这种策略在我们平时开发过程中也非常非常少见,但是不代表它的应用场景少,比如消息队列中消息的异步写入磁盘、MySQL 的 Innodb Buffer Pool 机制都用到了这种策略。\nWrite Behind Pattern 下 db 的写性能非常高,非常适合一些数据经常变化又对数据一致性要求没那么高的场景,比如浏览量、点赞量。\n"},{"id":243,"href":"/zh/docs/technology/Review/java_guide/database/Redis/ly0704lyredis-memory-fragmentation/","title":"redis内存碎片","section":"Redis","content":" 转载自https://github.com/Snailclimb/JavaGuide(添加小部分笔记)感谢作者!\n什么是内存碎片? # 你可以将内存碎片简单地理解为那些不可用的空闲内存。\n举个例子:操作系统为你分配了 32 字节的连续内存空间,而你存储数据实际只需要使用 24 字节内存空间,那这多余出来的 8 字节内存空间如果后续没办法再被分配存储其他数据的话,就可以被称为内存碎片。\nRedis 内存碎片虽然不会影响 Redis 性能,但是会增加内存消耗。\n为什么会有 Redis 内存碎片? # Redis 内存碎片产生比较常见的 2 个原因:\n1、Redis 存储存储数据的时候向操作系统申请的内存空间可能会大于数据实际需要的存储空间。\n以下是这段 Redis 官方的原话:\nTo store user keys, Redis allocates at most as much memory as the maxmemory setting enables (however there are small extra allocations possible).\nRedis 使用 zmalloc 方法(Redis 自己实现的内存分配方法)进行内存分配的时候,除了要分配 size 大小的内存之外,还会多分配 PREFIX_SIZE 大小的内存。\nzmalloc 方法源码如下(源码地址:https://github.com/antirez/redis-tools/blob/master/zmalloc.c):\nvoid *zmalloc(size_t size) { // 分配指定大小的内存 void *ptr = malloc(size+PREFIX_SIZE); if (!ptr) zmalloc_oom_handler(size); #ifdef HAVE_MALLOC_SIZE update_zmalloc_stat_alloc(zmalloc_size(ptr)); return ptr; #else *((size_t*)ptr) = size; update_zmalloc_stat_alloc(size+PREFIX_SIZE); return (char*)ptr+PREFIX_SIZE; #endif } 另外,Redis 可以使用多种内存分配器来分配内存( libc、jemalloc、tcmalloc),默认使用 jemalloc,而 jemalloc 按照一系列固定的大小(8 字节、16 字节、32 字节\u0026hellip;\u0026hellip;)来分配内存的。jemalloc 划分的内存单元如下图所示:\n当程序申请的内存最接近某个固定值时,jemalloc 会给它分配相应大小的空间,就比如说程序需要申请 17 字节的内存,jemalloc 会直接给它分配 32 字节的内存,这样会导致有 15 字节内存的浪费。不过,jemalloc 专门针对内存碎片问题做了优化,一般不会存在过度碎片化的问题。\n2、频繁修改 Redis 中的数据也会产生内存碎片。\n当 Redis 中的某个数据删除时,Redis 通常不会轻易释放内存给操作系统。\n这个在 Redis 官方文档中也有对应的原话:\n文档地址:https://redis.io/topics/memory-optimization 。\n如何查看 Redis 内存碎片的信息? # 使用 info memory 命令即可查看 Redis 内存相关的信息。下图中每个参数具体的含义,Redis 官方文档有详细的介绍:https://redis.io/commands/INFO 。\n]\nRedis 内存碎片率的计算公式:mem_fragmentation_ratio (内存碎片率)= used_memory_rss (操作系统实际分配给 Redis 的物理内存空间大小)/ used_memory(Redis 内存分配器为了存储数据实际申请使用的内存空间大小)\n也就是说,mem_fragmentation_ratio (内存碎片率)的值越大代表内存碎片率越严重。\n一定不要误认为used_memory_rss 减去 used_memory值就是内存碎片的大小!!!这不仅包括内存碎片,还包括其他进程开销,以及共享库、堆栈等的开销。\n很多小伙伴可能要问了:“多大的内存碎片率才是需要清理呢?”。\n通常情况下,我们认为 mem_fragmentation_ratio \u0026gt; 1.5 的话才需要清理内存碎片。 mem_fragmentation_ratio \u0026gt; 1.5 意味着你使用 Redis 存储实际大小 2G 的数据需要使用大于 3G 的内存。\n如果想要快速查看内存碎片率的话,你还可以通过下面这个命令:\n\u0026gt; redis-cli -p 6379 info | grep mem_fragmentation_ratio 另外,内存碎片率可能存在小于 1 的情况。这种情况我在日常使用中还没有遇到过,感兴趣的小伙伴可以看看这篇文章 故障分析 | Redis 内存碎片率太低该怎么办?- 爱可生开源社区 。\n如何清理 Redis 内存碎片? # Redis4.0-RC3 版本以后自带了内存整理,可以避免内存碎片率过大的问题。\n直接通过 config set 命令将 activedefrag 配置项设置为 yes 即可。\nconfig set activedefrag yes 具体什么时候清理需要通过下面两个参数控制:\n# 内存碎片占用空间达到 500mb 的时候开始清理 config set active-defrag-ignore-bytes 500mb # 内存碎片率大于 1.5 的时候开始清理 config set active-defrag-threshold-lower 50 通过 Redis 自动内存碎片清理机制可能会对 Redis 的性能产生影响,我们可以通过下面两个参数来减少对 Redis 性能的影响:\n# 内存碎片清理所占用 CPU 时间的比例不低于 20% config set active-defrag-cycle-min 20 # 内存碎片清理所占用 CPU 时间的比例不高于 50% config set active-defrag-cycle-max 50 另外,重启节点可以做到内存碎片重新整理。如果你采用的是高可用架构的 Redis 集群的话,你可以将碎片率过高的主节点转换为从节点,以便进行安全重启。\n参考 # Redis 官方文档:https://redis.io/topics/memory-optimization Redis 核心技术与实战 - 极客时间 - 删除数据后,为什么内存占用率还是很高?:https://time.geekbang.org/column/article/289140 Redis 源码解析——内存分配: https://shinerio.cc/2020/05/17/redis/Redis%E6%BA%90%E7%A0%81%E8%A7%A3%E6%9E%90%E2%80%94%E2%80%94%E5%86%85%E5%AD%98%E7%AE%A1%E7%90%86/ "},{"id":244,"href":"/zh/docs/technology/Review/java_guide/database/Redis/ly0702lyredis-spec-data-structure/","title":"redis特殊数据结构","section":"Redis","content":" 转载自https://github.com/Snailclimb/JavaGuide(添加小部分笔记)感谢作者!\n除了 5 种基本的数据结构之外,Redis 还支持 3 种特殊的数据结构 :Bitmap、HyperLogLog、GEO。\nBitmap # 介绍 # Bitmap 存储的是连续的二进制数字(0 和 1),通过 Bitmap, 只需要一个 bit 位来表示某个元素对应的值或者状态,key 就是对应元素本身 。我们知道 8 个 bit 可以组成一个 byte,所以 Bitmap 本身会极大的节省储存空间。\n你可以将 Bitmap 看作是一个存储二进制数字(0 和 1)的数组,数组中每个元素的下标叫做 offset(偏移量)。\n常用命令 # 命令 介绍 SETBIT key offset value 设置指定 offset 位置的值 GETBIT key offset 获取指定 offset 位置的值 BITCOUNT key start end 获取 start 和 end 之前值为 1 的元素个数 BITOP operation destkey key1 key2 \u0026hellip; 对一个或多个 Bitmap 进行运算,可用运算符有 AND, OR, XOR 以及 NOT Bitmap 基本操作演示 :\n# SETBIT 会返回之前位的值(默认是 0)这里会生成 7 个位 \u0026gt; SETBIT mykey 7 1 (integer) 0 \u0026gt; SETBIT mykey 7 0 (integer) 1 \u0026gt; GETBIT mykey 7 (integer) 0 \u0026gt; SETBIT mykey 6 1 (integer) 0 \u0026gt; SETBIT mykey 8 1 (integer) 0 # 通过 bitcount 统计被被设置为 1 的位的数量。 \u0026gt; BITCOUNT mykey (integer) 2 应用场景 # 需要保存状态信息(0/1 即可表示)的场景\n举例 :用户签到情况、活跃用户情况、用户行为统计(比如是否点赞过某个视频)。 相关命令 :SETBIT、GETBIT、BITCOUNT、BITOP。 HyperLogLog # 介绍 # HyperLogLog 是一种有名的基数计数概率算法 ,基于 LogLog Counting(LLC)优化改进得来,并不是 Redis 特有的,Redis 只是实现了这个算法并提供了一些开箱即用的 API。\nRedis 提供的 HyperLogLog 占用空间非常非常小,只需要 12k 的空间就能存储接近2^64个不同元素。这是真的厉害,这就是数学的魅力么!并且,Redis 对 HyperLogLog 的存储结构做了优化,采用两种方式计数:\n稀疏矩阵 :计数较少的时候,占用空间很小。 稠密矩阵 :计数达到某个阈值的时候,占用 12k 的空间。 Redis 官方文档中有对应的详细说明:\n基数计数概率算法为了节省内存并不会直接存储元数据,而是通过一定的概率统计方法预估基数值(集合中包含元素的个数)。因此, HyperLogLog 的计数结果并不是一个精确值,存在一定的误差(标准误差为 0.81% 。)。\nHyperLogLog 的使用非常简单,但原理非常复杂。HyperLogLog 的原理以及在 Redis 中的实现可以看这篇文章: HyperLogLog 算法的原理讲解以及 Redis 是如何应用它的 。\n再推荐一个可以帮助理解 HyperLogLog 原理的工具: Sketch of the Day: HyperLogLog — Cornerstone of a Big Data Infrastructure 。\n常用命令 # HyperLogLog 相关的命令非常少,最常用的也就 3 个。\n命令 介绍 PFADD key element1 element2 \u0026hellip; 添加一个或多个元素到 HyperLogLog 中 PFCOUNT key1 key2 获取一个或者多个 HyperLogLog 的唯一计数。 PFMERGE destkey sourcekey1 sourcekey2 \u0026hellip; 将多个 HyperLogLog 合并到 destkey 中,destkey 会结合多个源,算出对应的唯一计数。 HyperLogLog 基本操作演示 :\n\u0026gt; PFADD hll foo bar zap (integer) 1 \u0026gt; PFADD hll zap zap zap (integer) 0 \u0026gt; PFADD hll foo bar (integer) 0 \u0026gt; PFCOUNT hll (integer) 3 \u0026gt; PFADD some-other-hll 1 2 3 (integer) 1 \u0026gt; PFCOUNT hll some-other-hll (integer) 6 \u0026gt; PFMERGE desthll hll some-other-hll \u0026#34;OK\u0026#34; \u0026gt; PFCOUNT desthll (integer) 6 应用场景 # 数量量巨大(百万、千万级别以上)的计数场景\n举例 :热门网站每日/每周/每月访问 ip 数统计、热门帖子 uv 统计、 相关命令 :PFADD、PFCOUNT 。 Geospatial index # 介绍 # Geospatial index(地理空间索引,简称 GEO) 主要用于存储地理位置信息,基于 Sorted Set 实现。\n通过 GEO 我们可以轻松实现两个位置距离的计算、获取指定位置附近的元素等功能。\n常用命令 # 命令 介绍 GEOADD key longitude1 latitude1 member1 \u0026hellip; 添加一个或多个元素对应的经纬度信息到 GEO 中 GEOPOS key member1 member2 \u0026hellip; 返回给定元素的经纬度信息 GEODIST key member1 member2 M/KM/FT/MI 返回两个给定元素之间的距离 GEORADIUS key longitude latitude radius distance 获取指定位置附近 distance 范围内的其他元素,支持 ASC(由近到远)、DESC(由远到近)、Count(数量) 等参数 GEORADIUSBYMEMBER key member radius distance 类似于 GEORADIUS 命令,只是参照的中心点是 GEO 中的元素 基本操作 :\n\u0026gt; GEOADD personLocation 116.33 39.89 user1 116.34 39.90 user2 116.35 39.88 user3 3 \u0026gt; GEOPOS personLocation user1 116.3299986720085144 39.89000061669732844 \u0026gt; GEODIST personLocation user1 user2 km 1.4018 通过 Redis 可视化工具查看 personLocation ,果不其然,底层就是 Sorted Set。\nGEO 中存储的地理位置信息的经纬度数据通过 GeoHash 算法转换成了一个整数,这个整数作为 Sorted Set 的 score(权重参数)使用。\n获取指定位置范围内的其他元素 :\n\u0026gt; GEORADIUS personLocation 116.33 39.87 3 km user3 user1 \u0026gt; GEORADIUS personLocation 116.33 39.87 2 km \u0026gt; GEORADIUS personLocation 116.33 39.87 5 km user3 user1 user2 \u0026gt; GEORADIUSBYMEMBER personLocation user1 5 km user3 user1 user2 \u0026gt; GEORADIUSBYMEMBER personLocation user1 2 km user1 user2 GEORADIUS 命令的底层原理解析可以看看阿里的这篇文章: Redis 到底是怎么实现“附近的人”这个功能的呢? 。\n移除元素 :\nGEO 底层是 Sorted Set ,你可以对 GEO 使用 Sorted Set 相关的命令。\n\u0026gt; ZREM personLocation user1 1 \u0026gt; ZRANGE personLocation 0 -1 user3 user2 \u0026gt; ZSCORE personLocation user2 4069879562983946 应用场景 # 需要管理使用地理空间数据的场景\n举例:附近的人。 相关命令: GEOADD、GEORADIUS、GEORADIUSBYMEMBER 。 参考 # Redis Data Structures :https://redis.com/redis-enterprise/data-structures/ 。 《Redis 深度历险:核心原理与应用实践》1.6 四两拨千斤——HyperLogLog 布隆过滤器,位图,HyperLogLog:https://hogwartsrico.github.io/2020/06/08/BloomFilter-HyperLogLog-BitMap/index.html "},{"id":245,"href":"/zh/docs/technology/Review/java_guide/database/Redis/ly0701lyredis-base-data-structures/","title":"redis基本数据结构","section":"Redis","content":" 转载自https://github.com/Snailclimb/JavaGuide(添加小部分笔记)感谢作者!\nRedis 共有 5 种基本数据结构:String(字符串)、List(列表)、Set(集合)、Hash(散列)、Zset(有序集合)。\n这 5 种数据结构是直接提供给用户使用的,是数据的保存形式,其底层实现主要依赖这 8 种数据结构:简单动态字符串(SDS)、LinkedList(双向链表)、Hash Table(哈希表)、SkipList(跳跃表)、Intset(整数集合)、ZipList(压缩列表)、QuickList(快速列表)。\nRedis 基本数据结构的底层数据结构实现如下:\nString List Hash Set Zset SDS LinkedList/ZipList/QuickList Hash Table、ZipList ZipList、Intset ZipList、SkipList Redis 3.2 之前,List 底层实现是 LinkedList 或者 ZipList。 Redis 3.2 之后,引入了 LinkedList 和 ZipList 的结合 QuickList,List 的底层实现变为 QuickList。\n你可以在 Redis 官网上找到 Redis 数据结构非常详细的介绍:\nRedis Data Structures Redis Data types tutorial 未来随着 Redis 新版本的发布,可能会有新的数据结构出现,通过查阅 Redis 官网对应的介绍,你总能获取到最靠谱的信息。\nString(字符串) # 介绍 # String 是 Redis 中最简单同时也是最常用的一个数据结构。\nString 是一种二进制安全的数据结构,可以用来存储任何类型的数据比如字符串、整数、浮点数、图片(图片的 base64 编码或者解码或者图片的路径)、序列化后的对象。\n虽然 Redis 是用 C 语言写的,但是 Redis 并没有使用 C 的字符串表示,而是自己构建了一种 简单动态字符串(Simple Dynamic String,SDS)。相比于 C 的原生字符串,Redis 的 SDS 不光可以保存文本数据还可以保存二进制数据,并且获取字符串长度复杂度为 O(1)(C 字符串为 O(N)),除此之外,Redis 的 SDS API 是安全的,不会造成缓冲区溢出。\n常用命令 # 命令 介绍 SET key value 设置指定 key 的值 SETNX key value 只有在 key 不存在时设置 key 的值 GET key 获取指定 key 的值 MSET key1 value1 key2 value2 … 设置一个或多个指定 key 的值 MGET key1 key2 \u0026hellip; 获取一个或多个指定 key 的值 STRLEN key 返回 key 所储存的字符串值的长度 INCR key 将 key 中储存的数字值增一 DECR key 将 key 中储存的数字值减一 EXISTS key 判断指定 key 是否存在 DEL key(通用) 删除指定的 key EXPIRE key seconds(通用) 给指定 key 设置过期时间 更多 Redis String 命令以及详细使用指南,请查看 Redis 官网对应的介绍:https://redis.io/commands/?group=string 。\n基本操作 :\n\u0026gt; SET key value OK \u0026gt; GET key \u0026#34;value\u0026#34; \u0026gt; EXISTS key (integer) 1 \u0026gt; STRLEN key (integer) 5 \u0026gt; DEL key (integer) 1 \u0026gt; GET key (nil) 批量设置 :\n\u0026gt; MSET key1 value1 key2 value2 OK \u0026gt; MGET key1 key2 # 批量获取多个 key 对应的 value 1) \u0026#34;value1\u0026#34; 2) \u0026#34;value2\u0026#34; 计数器(字符串的内容为整数的时候可以使用):\n\u0026gt; SET number 1 OK \u0026gt; INCR number # 将 key 中储存的数字值增一 (integer) 2 \u0026gt; GET number \u0026#34;2\u0026#34; \u0026gt; DECR number # 将 key 中储存的数字值减一 (integer) 1 \u0026gt; GET number \u0026#34;1\u0026#34; 设置过期时间(默认为永不过期):\n\u0026gt; EXPIRE key 60 (integer) 1 \u0026gt; SETNX key 60 value # 设置值并设置过期时间 OK \u0026gt; TTL key (integer) 56 应用场景 # 需要存储常规数据的场景\n举例 :缓存 session、token、图片地址、序列化后的对象(相比较于 Hash 存储更节省内存)。 相关命令 : SET、GET。 需要计数的场景\n举例 :用户单位时间的请求数(简单限流可以用到)、页面单位时间的访问数。 相关命令 :SET、GET、 INCR、DECR 。 分布式锁\n利用 SETNX key value 命令可以实现一个最简易的分布式锁(存在一些缺陷,通常不建议这样实现分布式锁)。\nList(列表) # 介绍 # Redis 中的 List 其实就是链表数据结构的实现。我在 线性数据结构 :数组、链表、栈、队列 这篇文章中详细介绍了链表这种数据结构,我这里就不多做介绍了。\n许多高级编程语言都内置了链表的实现比如 Java 中的 LinkedList,但是 C 语言并没有实现链表,所以 Redis 实现了自己的链表数据结构。Redis 的 List 的实现为一个 双向链表,即可以支持反向查找和遍历,更方便操作,不过带来了部分额外的内存开销。\n常用命令 # 命令 介绍 RPUSH key value1 value2 \u0026hellip; 在指定列表的尾部(右边)添加一个或多个元素 LPUSH key value1 value2 \u0026hellip; 在指定列表的头部(左边)添加一个或多个元素 LSET key index value 将指定列表索引 index 位置的值设置为 value LPOP key 移除并获取指定列表的第一个元素(最左边) RPOP key 移除并获取指定列表的最后一个元素(最右边) LLEN key 获取列表元素数量 LRANGE key start end 获取列表 start 和 end 之间 的元素 更多 Redis List 命令以及详细使用指南,请查看 Redis 官网对应的介绍:https://redis.io/commands/?group=list 。\n通过 RPUSH/LPOP 或者 LPUSH/RPOP实现队列 :\n\u0026gt; RPUSH myList value1 (integer) 1 \u0026gt; RPUSH myList value2 value3 (integer) 3 \u0026gt; LPOP myList \u0026#34;value1\u0026#34; \u0026gt; LRANGE myList 0 1 1) \u0026#34;value2\u0026#34; 2) \u0026#34;value3\u0026#34; \u0026gt; LRANGE myList 0 -1 1) \u0026#34;value2\u0026#34; 2) \u0026#34;value3\u0026#34; 通过 RPUSH/RPOP或者LPUSH/LPOP 实现栈 :\n\u0026gt; RPUSH myList2 value1 value2 value3 (integer) 3 \u0026gt; RPOP myList2 # 将 list的头部(最右边)元素取出 \u0026#34;value3\u0026#34; 我专门画了一个图方便大家理解 RPUSH , LPOP , lpush , RPOP 命令:\n通过 LRANGE 查看对应下标范围的列表元素 :\n\u0026gt; RPUSH myList value1 value2 value3 (integer) 3 \u0026gt; LRANGE myList 0 1 1) \u0026#34;value1\u0026#34; 2) \u0026#34;value2\u0026#34; \u0026gt; LRANGE myList 0 -1 1) \u0026#34;value1\u0026#34; 2) \u0026#34;value2\u0026#34; 3) \u0026#34;value3\u0026#34; 通过 LRANGE 命令,你可以基于 List 实现分页查询,性能非常高!\n通过 LLEN 查看链表长度 :\n\u0026gt; LLEN myList (integer) 3 应用场景 # 信息流展示\n举例 :最新文章、最新动态。 相关命令 : LPUSH、LRANGE。 消息队列\nRedis List 数据结构可以用来做消息队列,只是功能过于简单且存在很多缺陷,不建议这样做。\n相对来说,Redis 5.0 新增加的一个数据结构 Stream 更适合做消息队列一些,只是功能依然非常简陋。和专业的消息队列相比,还是有很多欠缺的地方比如消息丢失和堆积问题不好解决。\nHash(哈希) # 介绍 # Redis 中的 Hash 是一个 String 类型的 field-value(键值对) 的映射表,特别适合用于存储对象,后续操作的时候,你可以直接修改这个对象中的某些字段的值。\nHash 类似于 JDK1.8 前的 HashMap,内部实现也差不多(数组 + 链表)。不过,Redis 的 Hash 做了更多优化。\n常用命令 # 命令 介绍 HSET key field value 设置指定哈希表中指定字段的值 HSETNX key field value 只有指定字段不存在时设置指定字段的值 HMSET key field1 value1 field2 value2 \u0026hellip; 同时将一个或多个 field-value (域-值)对设置到指定哈希表中 HGET key field 获取指定哈希表中指定字段的值 HMGET key field1 field2 \u0026hellip; 获取指定哈希表中一个或者多个指定字段的值 HGETALL key 获取指定哈希表中所有的键值对 HEXISTS key field 查看指定哈希表中指定的字段是否存在 HDEL key field1 field2 \u0026hellip; 删除一个或多个哈希表字段 HLEN key 获取指定哈希表中字段的数量 HINCRBY key field increment 对指定哈希中的指定字段做运算操作(正数为加,负数为减) 更多 Redis Hash 命令以及详细使用指南,请查看 Redis 官网对应的介绍:https://redis.io/commands/?group=hash 。\n模拟对象数据存储 :\n\u0026gt; HMSET userInfoKey name \u0026#34;guide\u0026#34; description \u0026#34;dev\u0026#34; age 24 OK \u0026gt; HEXISTS userInfoKey name # 查看 key 对应的 value中指定的字段是否存在。 (integer) 1 \u0026gt; HGET userInfoKey name # 获取存储在哈希表中指定字段的值。 \u0026#34;guide\u0026#34; \u0026gt; HGET userInfoKey age \u0026#34;24\u0026#34; \u0026gt; HGETALL userInfoKey # 获取在哈希表中指定 key 的所有字段和值 1) \u0026#34;name\u0026#34; 2) \u0026#34;guide\u0026#34; 3) \u0026#34;description\u0026#34; 4) \u0026#34;dev\u0026#34; 5) \u0026#34;age\u0026#34; 6) \u0026#34;24\u0026#34; \u0026gt; HSET userInfoKey name \u0026#34;GuideGeGe\u0026#34; \u0026gt; HGET userInfoKey name \u0026#34;GuideGeGe\u0026#34; \u0026gt; HINCRBY userInfoKey age 2 (integer) 26 应用场景 # 对象数据存储场景\n举例 :用户信息、商品信息、文章信息、购物车信息。 相关命令 :HSET (设置单个字段的值)、HMSET(设置多个字段的值)、HGET(获取单个字段的值)、HMGET(获取多个字段的值)。 Set(集合) # 介绍 # Redis 中的 Set 类型是一种无序集合,集合中的元素没有先后顺序但都唯一,有点类似于 Java 中的 HashSet 。当你需要存储一个列表数据,又不希望出现重复数据时,Set 是一个很好的选择,并且 Set 提供了判断某个元素是否在一个 Set 集合内的重要接口,这个也是 List 所不能提供的。\n你可以基于 Set 轻易实现交集、并集、差集的操作,比如你可以将一个用户所有的关注人存在一个集合中,将其所有粉丝存在一个集合。这样的话,Set 可以非常方便的实现如共同关注、共同粉丝、共同喜好等功能。这个过程也就是求交集的过程。\n常用命令 # 命令 介绍 SADD key member1 member2 \u0026hellip; 向指定集合添加一个或多个元素 SMEMBERS key 获取指定集合中的所有元素 SCARD key 获取指定集合的元素数量 SISMEMBER key member 判断指定元素是否在指定集合中 SINTER key1 key2 \u0026hellip; 获取给定所有集合的交集 SINTERSTORE destination key1 key2 \u0026hellip; 将给定所有集合的交集存储在 destination 中 SUNION key1 key2 \u0026hellip; 获取给定所有集合的并集 SUNIONSTORE destination key1 key2 \u0026hellip; 将给定所有集合的并集存储在 destination 中 SDIFF key1 key2 \u0026hellip; 获取给定所有集合的差集 SDIFFSTORE destination key1 key2 \u0026hellip; 将给定所有集合的差集存储在 destination 中 SPOP key count 随机移除并获取指定集合中一个或多个元素 SRANDMEMBER key count 随机获取指定集合中指定数量的元素 更多 Redis Set 命令以及详细使用指南,请查看 Redis 官网对应的介绍:https://redis.io/commands/?group=set 。\n基本操作 :\n\u0026gt; SADD mySet value1 value2 (integer) 2 \u0026gt; SADD mySet value1 # 不允许有重复元素,因此添加失败 (integer) 0 \u0026gt; SMEMBERS mySet 1) \u0026#34;value1\u0026#34; 2) \u0026#34;value2\u0026#34; \u0026gt; SCARD mySet (integer) 2 \u0026gt; SISMEMBER mySet value1 (integer) 1 \u0026gt; SADD mySet2 value2 value3 (integer) 2 mySet : value1、value2 。 mySet2 : value2 、value3 。 求交集 :\n\u0026gt; SINTERSTORE mySet3 mySet mySet2 (integer) 1 \u0026gt; SMEMBERS mySet3 1) \u0026#34;value2\u0026#34; 求并集 :\n\u0026gt; SUNION mySet mySet2 1) \u0026#34;value3\u0026#34; 2) \u0026#34;value2\u0026#34; 3) \u0026#34;value1\u0026#34; 求差集 :\n\u0026gt; SDIFF mySet mySet2 # 差集是由所有属于 mySet 但不属于 A 的元素组成的集合 1) \u0026#34;value1\u0026#34; 应用场景 # 需要存放的数据不能重复的场景\n举例:网站 UV 统计(数据量巨大的场景还是 HyperLogLog更适合一些)、文章点赞、动态点赞等场景。 相关命令:SCARD(获取集合数量) 。 需要获取多个数据源交集、并集和差集的场景\n举例 :**共同好友(**交集)、共同粉丝(交集)、共同关注(交集)、好友推荐(差集)、音乐推荐(差集) 、订阅号推荐(差集+交集) 等场景。 相关命令:SINTER(交集)、SINTERSTORE (交集)、SUNION (并集)、SUNIONSTORE(并集)、SDIFF(差集)、SDIFFSTORE (差集)。 需要随机获取数据源中的元素的场景\n举例 :抽奖系统、随机。 相关命令:SPOP(随机获取集合中的元素并移除,适合不允许重复中奖的场景)、SRANDMEMBER(随机获取集合中的元素,适合允许重复中奖的场景)。 Sorted Set(有序集合) # 介绍 # Sorted Set 类似于 Set,但和 Set 相比,Sorted Set 增加了一个权重参数 score,使得集合中的元素能够按 score 进行有序排列,还可以通过 score 的范围来获取元素的列表。有点像是 Java 中 HashMap 和 TreeSet 的结合体。\n常用命令 # 命令 介绍 ZADD key score1 member1 score2 member2 \u0026hellip; 向指定有序集合添加一个或多个元素 ZCARD KEY 获取指定有序集合的元素数量 ZSCORE key member 获取指定有序集合中指定元素的 score 值 ZINTERSTORE destination numkeys key1 key2 \u0026hellip; 将给定所有有序集合的交集存储在 destination 中,对相同元素对应的 score 值进行 SUM 聚合操作,numkeys 为集合数量 ZUNIONSTORE destination numkeys key1 key2 \u0026hellip; 求并集,其它和 ZINTERSTORE 类似 ZDIFF destination numkeys key1 key2 \u0026hellip; 求差集,其它和 ZINTERSTORE 类似 ZRANGE key start end 获取指定有序集合 start 和 end 之间的元素(score 从低到高) ZREVRANGE key start end 获取指定有序集合 start 和 end 之间的元素(score 从高到底) ZREVRANK key member 获取指定有序集合中指定元素的排名(score 从大到小排序) 更多 Redis Sorted Set 命令以及详细使用指南,请查看 Redis 官网对应的介绍:https://redis.io/commands/?group=sorted-set 。\n基本操作 :\n\u0026gt; ZADD myZset 2.0 value1 1.0 value2 (integer) 2 \u0026gt; ZCARD myZset 2 \u0026gt; ZSCORE myZset value1 2.0 \u0026gt; ZRANGE myZset 0 1 1) \u0026#34;value2\u0026#34; 2) \u0026#34;value1\u0026#34; \u0026gt; ZREVRANGE myZset 0 1 1) \u0026#34;value1\u0026#34; 2) \u0026#34;value2\u0026#34; \u0026gt; ZADD myZset2 4.0 value2 3.0 value3 (integer) 2 myZset : value1(2.0)、value2(1.0) 。 myZset2 : value2 (4.0)、value3(3.0) 。 获取指定元素的排名 :\n\u0026gt; ZREVRANK myZset value1 0 \u0026gt; ZREVRANK myZset value2 1 求交集 :\n\u0026gt; ZINTERSTORE myZset3 2 myZset myZset2 1 \u0026gt; ZRANGE myZset3 0 1 WITHSCORES value2 5 求并集 :\n\u0026gt; ZUNIONSTORE myZset4 2 myZset myZset2 3 \u0026gt; ZRANGE myZset4 0 2 WITHSCORES value1 2 value3 3 value2 5 求差集 :\n\u0026gt; ZDIFF 2 myZset myZset2 WITHSCORES value1 2 应用场景 # 需要随机获取数据源中的元素根据某个权重进行排序的场景\n举例 :各种排行榜比如直播间送礼物的排行榜、朋友圈的微信步数排行榜、王者荣耀中的段位排行榜、话题热度排行榜等等。 相关命令 :ZRANGE (从小到大排序) 、 ZREVRANGE (从大到小排序)、ZREVRANK (指定元素排名)。 《Java 面试指北》 的「技术面试题篇」就有一篇文章详细介绍如何使用 Sorted Set 来设计制作一个排行榜。\n需要存储的数据有优先级或者重要程度的场景 比如优先级任务队列。\n举例 :优先级任务队列。 相关命令 :ZRANGE (从小到大排序) 、 ZREVRANGE (从大到小排序)、ZREVRANK (指定元素排名)。 参考 # Redis Data Structures :https://redis.com/redis-enterprise/data-structures/ 。 Redis Commands : https://redis.io/commands/ 。 Redis Data types tutorial:https://redis.io/docs/manual/data-types/data-types-tutorial/ 。 Redis 存储对象信息是用 Hash 还是 String : https://segmentfault.com/a/1190000040032006 "},{"id":246,"href":"/zh/docs/technology/Review/java_guide/database/Redis/ly0706lyredis-questions-02/","title":"redis面试题02","section":"Redis","content":" 转载自https://github.com/Snailclimb/JavaGuide(添加小部分笔记)感谢作者!\nRedis 事务 # 如何使用 Redis 事务? # Redis 可以通过 MULTI,EXEC,DISCARD 和 WATCH 等命令来实现事务(transaction)功能。\n\u0026gt; MULTI OK \u0026gt; SET PROJECT \u0026#34;JavaGuide\u0026#34; QUEUED \u0026gt; GET PROJECT QUEUED \u0026gt; EXEC 1) OK 2) \u0026#34;JavaGuide\u0026#34; MULTI 命令后可以输入多个命令,Redis 不会立即执行这些命令,而是将它们放到队列,当调用了 EXEC 命令后,再执行所有的命令。\n这个过程是这样的:\n开始事务(MULTI); 命令入队(批量操作 Redis 的命令,先进先出(FIFO)的顺序执行); 执行事务(EXEC)。 你也可以通过 DISCARD 命令取消一个事务,它会清空事务队列中保存的所有命令。\n\u0026gt; MULTI OK \u0026gt; SET PROJECT \u0026#34;JavaGuide\u0026#34; QUEUED \u0026gt; GET PROJECT QUEUED \u0026gt; DISCARD OK 你可以通过 WATCH 命令监听指定的 Key,当调用 EXEC 命令执行事务时,如果一个被 WATCH 命令监视的 Key 被 其他客户端/Session 修改的话,整个事务都不会被执行。\n# 客户端 1 \u0026gt; SET PROJECT \u0026#34;RustGuide\u0026#34; OK \u0026gt; WATCH PROJECT OK \u0026gt; MULTI OK \u0026gt; SET PROJECT \u0026#34;JavaGuide\u0026#34; QUEUED # 客户端 2 # 在客户端 1 执行 EXEC 命令提交事务之前修改 PROJECT 的值 \u0026gt; SET PROJECT \u0026#34;GoGuide\u0026#34; # 客户端 1 # 修改失败,因为 PROJECT 的值被客户端2修改了 \u0026gt; EXEC (nil) \u0026gt; GET PROJECT \u0026#34;GoGuide\u0026#34; 不过,如果 WATCH 与 事务 在同一个 Session 里,并且被 WATCH 监视的 Key 被修改的操作发生在事务内部,这个事务是可以被执行成功的(相关 issue : WATCH 命令碰到 MULTI 命令时的不同效果)。\n事务内部修改 WATCH 监视的 Key:\n\u0026gt; SET PROJECT \u0026#34;JavaGuide\u0026#34; OK \u0026gt; WATCH PROJECT OK \u0026gt; MULTI OK \u0026gt; SET PROJECT \u0026#34;JavaGuide1\u0026#34; QUEUED \u0026gt; SET PROJECT \u0026#34;JavaGuide2\u0026#34; QUEUED \u0026gt; SET PROJECT \u0026#34;JavaGuide3\u0026#34; QUEUED \u0026gt; EXEC 1) OK 2) OK 3) OK 127.0.0.1:6379\u0026gt; GET PROJECT \u0026#34;JavaGuide3\u0026#34; 事务外部修改 WATCH 监视的 Key:\n\u0026gt; SET PROJECT \u0026#34;JavaGuide\u0026#34; OK \u0026gt; WATCH PROJECT OK \u0026gt; SET PROJECT \u0026#34;JavaGuide2\u0026#34; OK \u0026gt; MULTI OK \u0026gt; GET USER QUEUED \u0026gt; EXEC (nil) Redis 官网相关介绍 https://redis.io/topics/transactions 如下:\nRedis 支持原子性吗? # Redis 的事务和我们平时理解的关系型数据库的事务不同。我们知道事务具有四大特性: 1. 原子性,2. 隔离性,3. 持久性,4. 一致性。\n原子性(Atomicity): 事务是最小的执行单位,不允许分割。事务的原子性确保动作要么全部完成,要么完全不起作用; 隔离性(Isolation): 并发访问数据库时,一个用户的事务不被其他事务所干扰,各并发事务之间数据库是独立的; 持久性(Durability): 一个事务被提交之后。它对数据库中数据的改变是持久的,即使数据库发生故障也不应该对其有任何影响。 一致性(Consistency): 执行事务前后,数据保持一致,多个事务对同一个数据读取的结果是相同的; Redis 事务在运行错误的情况下,除了执行过程中出现错误的命令外,其他命令都能正常执行。并且,Redis 是不支持回滚(roll back)操作的。因此,Redis 事务其实是不满足原子性的(而且不满足持久性)。\nRedis 官网也解释了自己为啥不支持回滚。简单来说就是 Redis 开发者们觉得没必要支持回滚,这样更简单便捷并且性能更好。Redis 开发者觉得即使命令执行错误也应该在开发过程中就被发现而不是生产过程中。\n你可以将 Redis 中的事务就理解为 :Redis 事务提供了一种将多个命令请求打包的功能。然后,再按顺序执行打包的所有命令,并且不会被中途打断。\n除了不满足原子性之外,事务中的每条命令都会与 Redis 服务器进行网络交互,这是比较浪费资源的行为。明明一次批量执行多个命令就可以了,这种操作实在是看不懂。\n因此,Redis 事务是不建议在日常开发中使用的。\n相关 issue :\nissue452: 关于 Redis 事务不满足原子性的问题 。 Issue491:关于 redis 没有事务回滚? 如何解决 Redis 事务的缺陷? # Redis 从 2.6 版本开始支持执行 Lua 脚本,它的功能和事务非常类似。我们可以利用 Lua 脚本来批量执行多条 Redis 命令,这些 Redis 命令会被提交到 Redis 服务器一次性执行完成,大幅减小了网络开销。\n一段 Lua 脚本可以视作一条命令执行,一段 Lua 脚本执行过程中不会有其他脚本或 Redis 命令同时执行,保证了操作不会被其他指令插入或打扰。\n如果 Lua 脚本运行时出错并中途结束,出错之后的命令是不会被执行的。并且,出错之前执行的命令是无法被撤销的。因此,严格来说,通过 Lua 脚本来批量执行 Redis 命令也是不满足原子性的。\n另外,Redis 7.0 新增了 Redis functions 特性,你可以将 Redis functions 看作是比 Lua 更强大的脚本。\nRedis 性能优化 # Redis bigkey # 什么是 bigkey? # 简单来说,如果一个 key 对应的 value 所占用的内存比较大,那这个 key 就可以看作是 bigkey。具体多大才算大呢?有一个不是特别精确的参考标准:string 类型的 value 超过 10 kb,复合类型的 value 包含的元素超过 5000 个(对于复合类型的 value 来说,不一定包含的元素越多,占用的内存就越多)。\nbigkey 有什么危害? # 除了会消耗更多的内存空间,bigkey 对性能也会有比较大的影响。\n因此,我们应该尽量避免写入 bigkey!\n如何发现 bigkey? # 1、使用 Redis 自带的 --bigkeys 参数来查找。\n# redis-cli -p 6379 --bigkeys # Scanning the entire keyspace to find biggest keys as well as # average sizes per key type. You can use -i 0.1 to sleep 0.1 sec # per 100 SCAN commands (not usually needed). [00.00%] Biggest string found so far \u0026#39;\u0026#34;ballcat:oauth:refresh_auth:f6cdb384-9a9d-4f2f-af01-dc3f28057c20\u0026#34;\u0026#39; with 4437 bytes [00.00%] Biggest list found so far \u0026#39;\u0026#34;my-list\u0026#34;\u0026#39; with 17 items -------- summary ------- Sampled 5 keys in the keyspace! Total key length in bytes is 264 (avg len 52.80) Biggest list found \u0026#39;\u0026#34;my-list\u0026#34;\u0026#39; has 17 items Biggest string found \u0026#39;\u0026#34;ballcat:oauth:refresh_auth:f6cdb384-9a9d-4f2f-af01-dc3f28057c20\u0026#34;\u0026#39; has 4437 bytes 1 lists with 17 items (20.00% of keys, avg size 17.00) 0 hashs with 0 fields (00.00% of keys, avg size 0.00) 4 strings with 4831 bytes (80.00% of keys, avg size 1207.75) 0 streams with 0 entries (00.00% of keys, avg size 0.00) 0 sets with 0 members (00.00% of keys, avg size 0.00) 0 zsets with 0 members (00.00% of keys, avg size 0.00 从这个命令的运行结果,我们可以看出:这个命令会扫描(Scan) Redis 中的所有 key ,会对 Redis 的性能有一点影响。并且,这种方式只能找出每种数据结构 top 1 bigkey(占用内存最大的 string 数据类型,包含元素最多的复合数据类型)。\n2、分析 RDB 文件\n通过分析 RDB 文件来找出 big key。这种方案的前提是你的 Redis 采用的是 RDB 持久化。\n网上有现成的代码/工具可以直接拿来使用:\nredis-rdb-tools :Python 语言写的用来分析 Redis 的 RDB 快照文件用的工具 rdb_bigkeys : Go 语言写的用来分析 Redis 的 RDB 快照文件用的工具,性能更好。 大量 key 集中过期问题 # 我在上面提到过:对于过期 key,Redis 采用的是 定期删除+惰性/懒汉式删除 策略。\n定期删除执行过程中,如果突然遇到大量过期 key 的话,客户端请求必须等待定期清理过期 key 任务线程执行完成,因为这个这个定期任务线程是在 Redis 主线程中执行的。这就导致客户端请求没办法被及时处理,响应速度会比较慢。\n如何解决呢?下面是两种常见的方法:\n给 key 设置随机过期时间。 开启 lazy-free(惰性删除/延迟释放) 。lazy-free 特性是 Redis 4.0 开始引入的,指的是让 Redis 采用异步方式延迟释放 key 使用的内存,将该操作交给单独的子线程处理,避免阻塞主线程。 个人建议不管是否开启 lazy-free,我们都尽量给 key 设置随机过期时间。\nRedis 生产问题 # 缓存穿透 # 什么是缓存穿透? # 缓存穿透说简单点就是大量请求的 key 是不合理的,根本不存在于缓存中,也不存在于数据库中 。这就导致这些请求直接到了数据库上,根本没有经过缓存这一层,对数据库造成了巨大的压力,可能直接就被这么多请求弄宕机了。\n举个例子:某个黑客故意制造一些非法的 key 发起大量请求,导致大量请求落到数据库,结果数据库上也没有查到对应的数据。也就是说这些请求最终都落到了数据库上,对数据库造成了巨大的压力。\n有哪些解决办法? # 最基本的就是首先做好参数校验,一些不合法的参数请求直接抛出异常信息返回给客户端。比如查询的数据库 id 不能小于 0、传入的邮箱格式不对的时候直接返回错误消息给客户端等等。\n1)缓存无效 key\n如果缓存和数据库都查不到某个 key 的数据就写一个到 Redis 中去并设置过期时间,具体命令如下: SET key value EX 10086 。这种方式可以解决请求的 key 变化不频繁的情况,如果黑客恶意攻击,每次构建不同的请求 key,会导致 Redis 中缓存大量无效的 key 。很明显,这种方案并不能从根本上解决此问题。如果非要用这种方式来解决穿透问题的话,尽量将无效的 key 的过期时间设置短一点比如 1 分钟。\n另外,这里多说一嘴,一般情况下我们是这样设计 key 的: 表名:列名:主键名:主键值 。\n如果用 Java 代码展示的话,差不多是下面这样的:\npublic Object getObjectInclNullById(Integer id) { // 从缓存中获取数据 Object cacheValue = cache.get(id); // 缓存为空 if (cacheValue == null) { // 从数据库中获取 Object storageValue = storage.get(key); // 缓存空对象 cache.set(key, storageValue); // 如果存储数据为空,需要设置一个过期时间(300秒) if (storageValue == null) { // 必须设置过期时间,否则有被攻击的风险 cache.expire(key, 60 * 5); } return storageValue; } return cacheValue; } 2)布隆过滤器\n布隆过滤器是一个非常神奇的数据结构,通过它我们可以非常方便地判断一个给定数据是否存在于海量数据中。我们需要的就是判断 key 是否合法,有没有感觉布隆过滤器就是我们想要找的那个“人”。\n具体是这样做的:把所有可能存在的请求的值都存放在布隆过滤器中,当用户请求过来,先判断用户发来的请求的值是否存在于布隆过滤器中。不存在的话,直接返回请求参数错误信息给客户端,存在的话才会走下面的流程。\n加入布隆过滤器之后的缓存处理流程图如下。\n但是,需要注意的是布隆过滤器可能会存在误判的情况。总结来说就是: 布隆过滤器说某个元素存在,小概率会误判。布隆过滤器说某个元素不在,那么这个元素一定不在。\n为什么会出现误判的情况呢? 我们还要从布隆过滤器的原理来说!\n我们先来看一下,当一个元素加入布隆过滤器中的时候,会进行哪些操作:\n使用布隆过滤器中的哈希函数对元素值进行计算,得到哈希值(有几个哈希函数得到几个哈希值)。 根据得到的哈希值,在位数组中把对应下标的值置为 1。 我们再来看一下,当我们需要判断一个元素是否存在于布隆过滤器的时候,会进行哪些操作:\n对给定元素再次进行相同的哈希计算; 得到值之后判断位数组中的每个元素是否都为 1,如果值都为 1,那么说明这个值在布隆过滤器中,如果存在一个值不为 1,说明该元素不在布隆过滤器中。 然后,一定会出现这样一种情况:不同的字符串可能哈希出来的位置相同。 (可以适当增加位数组大小或者调整我们的哈希函数来降低概率)\n更多关于布隆过滤器的内容可以看我的这篇原创: 《不了解布隆过滤器?一文给你整的明明白白!》 ,强烈推荐,个人感觉网上应该找不到总结的这么明明白白的文章了。\n缓存击穿 # 什么是缓存击穿? # 缓存击穿中,请求的 key 对应的是 热点数据 ,该数据 存在于数据库中,但不存在于缓存中(通常是因为缓存中的那份数据已经过期) 。这就可能会导致瞬时大量的请求直接打到了数据库上,对数据库造成了巨大的压力,可能直接就被这么多请求弄宕机了。\n举个例子 :秒杀进行过程中,缓存中的某个秒杀商品的数据突然过期,这就导致瞬时大量对该商品的请求直接落到数据库上,对数据库造成了巨大的压力。\n有哪些解决办法? # 设置热点数据永不过期或者过期时间比较长。 针对热点数据提前预热,将其存入缓存中并设置合理的过期时间比如秒杀场景下的数据在秒杀结束之前不过期。 请求数据库写数据到缓存之前,先获取互斥锁,保证只有一个请求会落到数据库上,减少数据库的压力。 缓存穿透和缓存击穿有什么区别? # 缓存穿透中,请求的 key 既不存在于缓存中,也不存在于数据库中。\n缓存击穿中,请求的 key 对应的是 热点数据 ,该数据 存在于数据库中,但不存在于缓存中(通常是因为缓存中的那份数据已经过期) 。\n缓存雪崩 # 什么是缓存雪崩? # 我发现缓存雪崩这名字起的有点意思,哈哈。\n实际上,缓存雪崩描述的就是这样一个简单的场景:缓存在同一时间大面积的失效,导致大量的请求都直接落到了数据库上,对数据库造成了巨大的压力。 这就好比雪崩一样,摧枯拉朽之势,数据库的压力可想而知,可能直接就被这么多请求弄宕机了。\n另外,缓存服务宕机也会导致缓存雪崩现象,导致所有的请求都落到了数据库上。\n举个例子 :数据库中的大量数据在同一时间过期,这个时候突然有大量的请求需要访问这些过期的数据。这就导致大量的请求直接落到数据库上,对数据库造成了巨大的压力。\n有哪些解决办法? # 针对 Redis 服务不可用的情况:\n采用 Redis 集群,避免单机出现问题整个缓存服务都没办法使用。 限流,避免同时处理大量的请求。 针对热点缓存失效的情况:\n设置不同的失效时间比如随机设置缓存的失效时间。 缓存永不失效(不太推荐,实用性太差)。 设置二级缓存。 缓存雪崩和缓存击穿有什么区别? # 缓存雪崩和缓存击穿比较像,但缓存雪崩导致的原因是缓存中的大量或者所有数据失效,缓存击穿导致的原因主要是某个热点数据不存在于缓存中(通常是因为缓存中的那份数据已经过期)。\n如何保证缓存和数据库数据的一致性? # 细说的话可以扯很多,但是我觉得其实没太大必要(小声 BB:很多解决方案我也没太弄明白)。我个人觉得引入缓存之后,如果为了短时间的不一致性问题,选择让系统设计变得更加复杂的话,完全没必要。\n下面单独对 Cache Aside Pattern(旁路缓存模式) 来聊聊。\nCache Aside Pattern 中遇到写请求是这样的:更新 DB,然后直接删除 cache 。\n如果更新数据库成功,而删除缓存这一步失败的情况的话,简单说两个解决方案:\n缓存失效时间变短(不推荐,治标不治本) :我们让缓存数据的过期时间变短,这样的话缓存就会从数据库中加载数据。另外,这种解决办法对于先操作缓存后操作数据库的场景不适用。 增加 cache 更新重试机制(常用): 如果 cache 服务当前不可用导致缓存删除失败的话,我们就隔一段时间进行重试,重试次数可以自己定。如果多次重试还是失败的话,我们可以把当前更新失败的 key 存入队列中,等缓存服务可用之后,再将缓存中对应的 key 删除即可。 相关文章推荐: 缓存和数据库一致性问题,看这篇就够了 - 水滴与银弹\nRedis 集群 # Redis Sentinel :\n什么是 Sentinel? 有什么用? Sentinel 如何检测节点是否下线?主观下线与客观下线的区别? Sentinel 是如何实现故障转移的? 为什么建议部署多个 sentinel 节点(哨兵集群)? Sentinel 如何选择出新的 master(选举机制)? 如何从 Sentinel 集群中选择出 Leader ? Sentinel 可以防止脑裂吗? Redis Cluster :\n为什么需要 Redis Cluster?解决了什么问题?有什么优势? Redis Cluster 是如何分片的? 为什么 Redis Cluster 的哈希槽是 16384 个? 如何确定给定 key 的应该分布到哪个哈希槽中? Redis Cluster 支持重新分配哈希槽吗? Redis Cluster 扩容缩容期间可以提供服务吗? Redis Cluster 中的节点是怎么进行通信的? 参考答案 : Redis 集群详解(付费)。\n参考 # 《Redis 开发与运维》 《Redis 设计与实现》 Redis Transactions : https://redis.io/docs/manual/transactions/ 。 "},{"id":247,"href":"/zh/docs/technology/Review/java_guide/database/Redis/ly0705lyredis-questions-01/","title":"redis面试题01","section":"Redis","content":" 转载自https://github.com/Snailclimb/JavaGuide(添加小部分笔记)感谢作者!\nRedis 基础 # 什么是 Redis? # Redis 是一个基于 C 语言开发的开源数据库(BSD 许可),与传统数据库不同的是 Redis 的数据是存在内存中的(内存数据库),读写速度非常快,被广泛应用于缓存方向。并且,Redis 存储的是 KV 键值对数据。\n为了满足不同的业务场景,Redis 内置了多种数据类型实现(比如 String、Hash、【List、Set、】Sorted Set、Bitmap)。并且,Redis 还支持事务 、持久化、Lua 脚本、多种开箱即用的集群方案(Redis Sentinel、Redis Cluster)。\nRedis 没有外部依赖,Linux 和 OS X 是 Redis 开发和测试最多的两个操作系统,官方推荐生产环境使用 Linux 部署 Redis。\n个人学习的话,你可以自己本机安装 Redis 或者通过 Redis 官网提供的 在线 Redis 环境来实际体验 Redis。\n全世界有非常多的网站使用到了 Redis , techstacks.io 专门维护了一个 使用 Redis 的热门站点列表 ,感兴趣的话可以看看。\nRedis 为什么这么快? # Redis 内部做了非常多的性能优化,比较重要的主要有下面 3 点:\nRedis 基于内存,内存的访问速度是磁盘的上千倍; Redis 基于 Reactor 模式设计开发了一套高效的事件处理模型,主要是单线程事件循环和 IO 多路复用(Redis 线程模式后面会详细介绍到); Redis 内置了多种优化过后的数据结构实现,性能非常高。 下面这张图片总结的挺不错的,分享一下,出自 Why is Redis so fast? 。\n分布式缓存常见的技术选型方案有哪些? # 分布式缓存的话,比较老牌同时也是使用的比较多的还是 Memcached 和 Redis。不过,现在基本没有看过还有项目使用 Memcached 来做缓存,都是直接用 Redis。\nMemcached 是分布式缓存最开始兴起的那会,比较常用的。后来,随着 Redis 的发展,大家慢慢都转而使用更加强大的 Redis 了。\n另外,腾讯也开源了一款类似于 Redis 的分布式高性能 KV 存储数据库,基于知名的开源项目 RocksDB 作为存储引擎 ,100% 兼容 Redis 协议和 Redis4.0 所有数据模型,名为 Tendis (腾讯的)。\n关于 Redis 和 Tendis 的对比,腾讯官方曾经发过一篇文章: Redis vs Tendis:冷热混合存储版架构揭秘 ,可以简单参考一下。\n从这个项目的 Github 提交记录可以看出,Tendis 开源版几乎已经没有被维护更新了,加上其关注度并不高,使用的公司也比较少。因此,不建议你使用 Tendis 来实现分布式缓存。\n说一下 Redis 和 Memcached 的区别和共同点 # 现在公司一般都是用 Redis 来实现缓存,而且 Redis 自身也越来越强大了!不过,了解 Redis 和 Memcached 的区别和共同点,有助于我们在做相应的技术选型的时候,能够做到有理有据!\n共同点 :\n都是基于内存的数据库,一般都用来当做缓存使用。 都有过期策略。 两者的性能都非常高。 区别 :\nRedis 支持更丰富的数据类型(支持更复杂的应用场景)。Redis 不仅仅支持简单(string)的 k/v 类型的数据,同时还提供 list,set,zset,hash 等数据结构的存储。Memcached 只支持最简单的 k/v 数据类型。\nRedis 支持数据的持久化,可以将内存中的数据保持在磁盘中,重启的时候可以再次加载进行使用,而 Memcached 把数据全部存在内存之中。\nRedis 有灾难恢复机制。 因为可以把缓存中的数据持久化到磁盘上。\nRedis 在服务器内存使用完之后,可以将不用的数据放到磁盘上。但是,Memcached 在服务器内存使用完之后,就会直接报异常。\nMemcached 没有原生的集群模式,需要依靠客户端来实现往集群中分片写入数据;但是 Redis 目前是原生支持 cluster 模式的。\nMemcached 是多线程,非阻塞 IO 复用的网络模型;Redis 使用单线程的多路 IO 复用模型。 (Redis 6.0 引入了多线程 IO )\n非阻塞的 IO复用\n单线程的多路IO复用\nRedis 支持发布订阅模型、Lua 脚本、事务等功能,而 Memcached 不支持。并且,Redis 支持更多的编程语言。\nMemcached 过期数据的删除策略只用了惰性删除,而 Redis 同时使用了惰性删除与定期删除。\n相信看了上面的对比之后,我们已经没有什么理由可以选择使用 Memcached 来作为自己项目的分布式缓存了。\n为什么要用 Redis/为什么要用缓存? # 下面我们主要从“高性能”和“高并发”这两点来回答这个问题。\n高性能\n假如用户第一次访问数据库中的某些数据的话,这个过程是比较慢,毕竟是从硬盘中读取的。但是,如果说,用户访问的数据属于高频数据并且不会经常改变的话,那么我们就可以很放心地将该用户访问的数据存在缓存中。\n这样有什么好处呢? 那就是保证用户下一次再访问这些数据的时候就可以直接从缓存中获取了。操作缓存就是直接操作内存,所以速度相当快。\n高并发\n一般像 MySQL 这类的数据库的 QPS 大概都在 1w 左右(4 核 8g) ,但是使用 Redis 缓存之后很容易达到 10w+,甚至最高能达到 30w+(就单机 Redis 的情况,Redis 集群的话会更高)。\nQPS(Query Per Second):服务器每秒可以执行的查询次数;\n由此可见,直接操作缓存能够承受的数据库请求数量是远远大于直接访问数据库的,所以我们可以考虑把数据库中的部分数据转移到缓存中去,这样用户的一部分请求会直接到缓存这里而不用经过数据库。进而,我们也就提高了系统整体的并发。\nRedis 除了做缓存,还能做什么? # 分布式锁 : 通过 Redis 来做分布式锁是一种比较常见的方式。通常情况下,我们都是基于 Redisson 来实现分布式锁。关于 Redis 实现分布式锁的详细介绍,可以看我写的这篇文章: 分布式锁详解 。 限流 :一般是通过 Redis + Lua 脚本的方式来实现限流。相关阅读: 《我司用了 6 年的 Redis 分布式限流器,可以说是非常厉害了!》。 消息队列 :Redis 自带的 list 数据结构可以作为一个简单的队列使用。Redis 5.0 中增加的 Stream 类型的数据结构更加适合用来做消息队列。它比较类似于 Kafka,有主题和消费组的概念,支持消息持久化以及 ACK 机制。 复杂业务场景 :通过 Redis 以及 Redis 扩展(比如 Redisson)提供的数据结构,我们可以很方便地完成很多复杂的业务场景比如通过 bitmap 统计活跃用户、通过 sorted set 维护排行榜。 \u0026hellip;\u0026hellip; Redis 可以做消息队列么? # Redis 5.0 新增加的一个数据结构 Stream 可以用来做消息队列,Stream 支持:\n发布 / 订阅模式 按照消费者组进行消费 消息持久化( RDB 和 AOF) 不过,和专业的消息队列相比,还是有很多欠缺的地方比如消息丢失和堆积问题不好解决。因此,我们通常建议是不使用 Redis 来做消息队列的,你完全可以选择市面上比较成熟的一些消息队列比如 RocketMQ、Kafka。\n相关文章推荐: Redis 消息队列的三种方案(List、Streams、Pub/Sub)。\n如何基于 Redis 实现分布式锁? # 关于 Redis 实现分布式锁的详细介绍,可以看我写的这篇文章: 分布式锁详解 。\nRedis 数据结构 # Redis 常用的数据结构有哪些? # 5 种基础数据结构 :String(字符串)、List(列表)、Set(集合)、Hash(散列)、Zset(有序集合)。 3 种特殊数据结构 :HyperLogLogs(基数统计)、Bitmap (位存储)、Geospatial (地理位置)。 关于 5 种基础数据结构的详细介绍请看这篇文章: Redis 5 种基本数据结构详解。\n关于 3 种特殊数据结构的详细介绍请看这篇文章: Redis 3 种特殊数据结构详解。\nString 的应用场景有哪些? # 常规数据(比如 session、token、、序列化后的对象)的缓存; 计数比如用户单位时间的请求数(简单限流可以用到)、页面单位时间的访问数; 分布式锁(利用 SETNX key value 命令可以实现一个最简易的分布式锁); \u0026hellip;\u0026hellip; 关于 String 的详细介绍请看这篇文章: Redis 5 种基本数据结构详解。\nString 还是 Hash 存储对象数据更好呢? # String 存储的是序列化后的对象数据,存放的是整个对象。Hash 是对对象的每个字段单独存储,可以获取部分字段的信息,也可以修改或者添加部分字段,节省网络流量。如果对象中某些字段需要经常变动或者经常需要单独查询对象中的个别字段信息,Hash 就非常适合。 String 存储相对来说更加节省内存,缓存相同数量的对象数据,String 消耗的内存约是 Hash 的一半。并且,存储具有多层嵌套的对象时也方便很多。如果系统对性能和资源消耗非常敏感的话,String 就非常适合。 在绝大部分情况,我们建议使用 String 来存储对象数据即可!\nString 的底层实现是什么? # Redis 是基于 C 语言编写的,但 Redis 的 String 类型的底层实现并不是 C 语言中的字符串(即以空字符 \\0 结尾的字符数组),而是自己编写了 SDS(Simple Dynamic String,简单动态字符串) 来作为底层实现。\n[daɪˈnæmɪk] 动态的\nSDS 最早是 Redis 作者为日常 C 语言开发而设计的 C 字符串,后来被应用到了 Redis 上,并经过了大量的修改完善以适合高性能操作。\nRedis7.0 的 SDS 的部分源码如下(https://github.com/redis/redis/blob/7.0/src/sds.h):\n/* Note: sdshdr5 is never used, we just access the flags byte directly. * However is here to document the layout of type 5 SDS strings. */ struct __attribute__ ((__packed__)) sdshdr5 { unsigned char flags; /* 3 lsb of type, and 5 msb of string length */ char buf[]; }; struct __attribute__ ((__packed__)) sdshdr8 { uint8_t len; /* used */ uint8_t alloc; /* excluding the header and null terminator */ unsigned char flags; /* 3 lsb of type, 5 unused bits */ char buf[]; }; struct __attribute__ ((__packed__)) sdshdr16 { uint16_t len; /* used */ uint16_t alloc; /* excluding the header and null terminator */ unsigned char flags; /* 3 lsb of type, 5 unused bits */ char buf[]; }; struct __attribute__ ((__packed__)) sdshdr32 { uint32_t len; /* used */ uint32_t alloc; /* excluding the header and null terminator */ unsigned char flags; /* 3 lsb of type, 5 unused bits */ char buf[]; }; struct __attribute__ ((__packed__)) sdshdr64 { uint64_t len; /* used */ uint64_t alloc; /* excluding the header and null terminator */ unsigned char flags; /* 3 lsb of type, 5 unused bits */ char buf[]; }; 通过源码可以看出,SDS 共有五种实现方式 SDS_TYPE_5(并未用到)、SDS_TYPE_8、SDS_TYPE_16、SDS_TYPE_32、SDS_TYPE_64,其中只有后四种实际用到。Redis 会根据初始化的长度决定使用哪种类型,从而减少内存的使用。\n类型 字节 位 sdshdr5 \u0026lt; 1 \u0026lt;8 sdshdr8 1 8 sdshdr16 2 16 sdshdr32 4 32 sdshdr64 8 64 对于后四种实现都包含了下面这 4 个属性:\nlen :字符串的长度也就是已经使用的字节数 alloc:总共可用的字符空间大小,alloc-len 就是 SDS 剩余的空间大小 buf[] :实际存储字符串的数组 flags :低三位保存类型标志 SDS 相比于 C 语言中的字符串有如下提升:\n可以避免缓冲区溢出 :C 语言中的字符串被修改(比如拼接)时,一旦没有分配足够长度的内存空间,就会造成缓冲区溢出。SDS 被修改时,会先根据 len 属性检查空间大小是否满足要求,如果不满足,则先扩展至所需大小再进行修改操作。 获取字符串长度的复杂度较低 : C 语言中的字符串的长度通常是经过遍历计数来实现的,时间复杂度为 O(n)。SDS 的长度获取直接读取 len 属性即可,时间复杂度为 O(1)。 减少内存分配次数 : 为了避免修改(增加/减少)字符串时,每次都需要重新分配内存(C 语言的字符串是这样的),SDS 实现了空间预分配和惰性空间释放两种优化策略。当 SDS 需要增加字符串时,Redis 会为 SDS 分配好内存,并且根据特定的算法分配多余的内存,这样可以减少连续执行字符串增长操作所需的内存重分配次数。当 SDS 需要减少字符串时,这部分内存不会立即被回收,会被记录下来,等待后续使用(支持手动释放,有对应的 API)。 二进制安全 :C 语言中的字符串以空字符 \\0 作为字符串结束的标识,这存在一些问题,像一些二进制文件(比如图片、视频、音频)就可能包括空字符,C 字符串无法正确保存。SDS 使用 len 属性判断字符串是否结束,不存在这个问题。 多提一嘴,很多文章里 SDS 的定义是下面这样的:\nstruct sdshdr { unsigned int len; unsigned int free; char buf[]; }; 这个也没错,Redis 3.2 之前就是这样定义的。后来,由于这种方式的定义存在问题,len 和 free 的定义用了 4 个字节,造成了浪费。Redis 3.2 之后,Redis 改进了 SDS 的定义,将其划分为了现在的 5 种类型。\n购物车信息用 String 还是 Hash 存储更好呢? # 由于购物车中的商品频繁修改和变动,购物车信息建议使用 Hash 存储:\n用户 id 为 key 商品 id 为 field,商品数量为 value 那用户购物车信息的维护具体应该怎么操作呢?\n用户添加商品就是往 Hash 里面增加新的 field 与 value; 查询购物车信息就是遍历对应的 Hash; 更改商品数量直接修改对应的 value 值(直接 set 或者做运算皆可); 删除商品就是删除 Hash 中对应的 field; 清空购物车直接删除对应的 key 即可。 这里只是以业务比较简单的购物车场景举例,实际电商场景下,field 只保存一个商品 id 是没办法满足需求的。\n使用 Redis 实现一个排行榜怎么做? # Redis 中有一个叫做 sorted set 的数据结构经常被用在各种排行榜的场景,比如直播间送礼物的排行榜、朋友圈的微信步数排行榜、王者荣耀中的段位排行榜、话题热度排行榜等等。\n相关的一些 Redis 命令: ZRANGE (从小到大排序) 、 ZREVRANGE (从大到小排序)、ZREVRANK (指定元素排名)。\n《Java 面试指北》\n使用 Set 实现抽奖系统需要用到什么命令? # SPOP key count : 随机移除并获取指定集合中一个或多个元素,适合不允许重复中奖的场景。\nSRANDMEMBER key count : 随机获取指定集合中指定数量的元素,适合允许重复中奖的场景。\n重复中奖,这里说的是第一次中的是a,第二次可能也是a。而不是说一次中将的人有两个a\n使用 Bitmap 统计活跃用户怎么做? # 使用日期(精确到天)作为 key,然后用户 ID 为 offset,如果当日活跃过就设置为 1。\n初始化数据:\n\u0026gt; SETBIT 20210308 1 1 (integer) 0 \u0026gt; SETBIT 20210308 2 1 (integer) 0 \u0026gt; SETBIT 20210309 1 1 (integer) 0 统计 20210308~20210309 总活跃用户数:\n\u0026gt; BITOP and desk1 20210308 20210309 (integer) 1 \u0026gt; BITCOUNT desk1 (integer) 1 统计 20210308~20210309 在线活跃用户数:\n\u0026gt; BITOP or desk2 20210308 20210309 (integer) 1 \u0026gt; BITCOUNT desk2 (integer) 2 使用 HyperLogLog 统计页面 UV 怎么做? # Unique Visitor,即有多少个用户访问了我们的网站\n1、将访问指定页面的每个用户 ID 添加到 HyperLogLog 中。\nPFADD PAGE_1:UV USER1 USER2 ...... USERn 2、统计指定页面的 UV。\nPFCOUNT PAGE_1:UV #会自动扣除重复的 Redis 线程模型 # 对于读写命令来说,Redis 一直是单线程模型。不过,在 Redis 4.0 版本之后引入了多线程来执行一些大键值对的异步删除操作, Redis 6.0 版本之后引入了多线程来处理网络请求(提高网络 IO 读写性能)。\nRedis 单线程模型了解吗? # Redis 基于 Reactor 模式设计开发了一套高效的事件处理模型 (Netty 的线程模型也基于 Reactor 模式,Reactor 模式不愧是高性能 IO 的基石),这套事件处理模型对应的是 Redis 中的文件事件处理器(file event handler)。由于**文件事件处理器(file event handler)**是单线程方式运行的,所以我们一般都说 Redis 是单线程模型。\n《Redis 设计与实现》有一段话是如是介绍文件事件处理器的,我觉得写得挺不错。\nRedis 基于 Reactor 模式开发了自己的网络事件处理器:这个处理器被称为文件事件处理器(file event handler)。\n文件事件处理器使用 I/O 多路复用(multiplexing)程序来同时监听多个套接字,并根据套接字目前执行的任务来为套接字关联不同的事件处理器。 当被监听的套接字准备好执行连接应答(accept)、读取(read)、写入(write)、关 闭(close)等操作时,与操作相对应的文件事件就会产生,这时文件事件处理器就会调用套接字之前关联好的事件处理器来处理这些事件。 虽然文件事件处理器以单线程方式运行,但通过使用 I/O 多路复用程序来监听多个套接字,文件事件处理器既实现了高性能的网络通信模型,又可以很好地与 Redis 服务器中其他同样以单线程方式运行的模块进行对接,这保持了 Redis 内部单线程设计的简单性。\n既然是单线程,那怎么监听大量的客户端连接呢?\nRedis 通过 IO 多路复用程序 来监听来自客户端的大量连接(或者说是监听多个 socket),它会将感兴趣的事件及类型(读、写)注册到内核中并监听每个事件是否发生。\n这样的好处非常明显: I/O 多路复用技术的使用让 Redis 不需要额外创建多余的线程来监听客户端的大量连接,降低了资源的消耗(和 NIO 中的 Selector 组件很像)。\n文件事件处理器(file event handler)主要是包含 4 个部分:\n多个 socket(客户端连接)\nIO 多路复用程序(支持多个客户端连接的关键)\n文件事件分派器(将 socket 关联到相应的事件处理器)\n事件处理器(连接应答处理器、命令请求处理器、命令回复处理器)\n相关阅读: Redis 事件机制详解 。\nRedis6.0 之前为什么不使用多线程? # 虽然说 Redis 是单线程模型,但是,实际上,Redis 在 4.0 之后的版本中就已经加入了对多线程的支持。\n不过,Redis 4.0 增加的多线程主要是针对一些大键值对的删除操作的命令,使用这些命令就会使用主线程之外的其他线程来“异步处理”。\n为此,Redis 4.0 之后新增了**UNLINK(可以看作是 DEL 的异步版本)、FLUSHALL ASYNC(清空所有数据库的所有 key,不仅仅是当前 SELECT 的数据库)、FLUSHDB ASYNC**(清空当前 SELECT 数据库中的所有 key)等异步命令。\n大体上来说,Redis 6.0 之前主要还是单线程处理。\n那 Redis6.0 之前为什么不使用多线程? 我觉得主要原因有 3 点:\n单线程编程容易并且更容易维护; Redis 的性能瓶颈不在 CPU ,主要在内存和网络; 多线程就会存在死锁、线程上下文切换等问题,甚至会影响性能。 相关阅读: 为什么 Redis 选择单线程模型 。\nRedis6.0 之后为何引入了多线程? # Redis6.0 引入多线程主要是为了提高网络 IO 读写性能,因为这个算是 Redis 中的一个性能瓶颈(Redis 的瓶颈主要受限于内存和网络)。\n虽然,Redis6.0 引入了多线程,但是 Redis 的多线程只是在网络数据的读写这类耗时操作上使用了,执行命令仍然是单线程顺序执行。因此,你也不需要担心线程安全问题。\nRedis6.0 的多线程默认是禁用的,只使用主线程。如需开启需要设置IO线程数 \u0026gt; 1,需要修改 redis 配置文件 redis.conf :\nio-threads 4 #设置1的话只会开启主线程,官网建议4核的机器建议设置为2或3个线程,8核的建议设置为6个线程 另外:\nio-threads的个数一旦设置,不能通过config动态设置 当设置ssl后,io-threads将不工作 开启多线程后,默认只会使用多线程进行IO写入writes,即发送数据给客户端,如果需要开启多线程IO读取reads,同样需要修改 redis 配置文件 redis.conf :\nio-threads-do-reads yes 但是官网描述开启多线程读并不能有太大提升,因此一般情况下并不建议开启\n相关阅读:\nRedis 6.0 新特性-多线程连环 13 问! Redis 多线程网络模型全面揭秘(推荐) Redis 内存管理 # Redis 给缓存数据设置过期时间有啥用? # 一般情况下,我们设置保存的缓存数据的时候都会设置一个过期时间。为什么呢?\n因为内存是有限的,如果缓存中的所有数据都是一直保存的话,分分钟直接 Out of memory。\nRedis 自带了给缓存数据设置过期时间的功能,比如:\n127.0.0.1:6379\u0026gt; expire key 60 # 数据在 60s 后过期 (integer) 1 127.0.0.1:6379\u0026gt; setex key 60 value # 数据在 60s 后过期 (setex:[set] + [ex]pire) OK 127.0.0.1:6379\u0026gt; ttl key # 查看数据还有多久过期 (integer) 56 注意:Redis 中除了字符串类型有自己独有设置过期时间的命令 setex 外,其他方法都需要依靠 expire 命令来设置过期时间 。另外, persist 命令可以移除一个键的过期时间。\n过期时间除了有助于缓解内存的消耗,还有什么其他用么?\n很多时候,我们的业务场景就是需要某个数据只在某一时间段内存在,比如我们的短信验证码可能只在 1 分钟内有效,用户登录的 token 可能只在 1 天内有效。\n如果使用传统的数据库来处理的话,一般都是自己判断过期,这样更麻烦并且性能要差很多。\nRedis 是如何判断数据是否过期的呢? # Redis 通过一个叫做过期字典(可以看作是 hash 表)来保存数据过期的时间。过期字典的键指向 Redis 数据库中的某个 key(键),过期字典的值是一个 long long 类型的整数,这个整数保存了 key 所指向的数据库键的过期时间(毫秒精度的 UNIX 时间戳)。\n过期字典是存储在 redisDb 这个结构里的:\ntypedef struct redisDb { ... dict *dict; //数据库键空间,保存着数据库中所有键值对 dict *expires // 过期字典,保存着键的过期时间 ... } redisDb; 过期的数据的删除策略了解么? # 如果假设你设置了一批 key 只能存活 1 分钟,那么 1 分钟后,Redis 是怎么对这批 key 进行删除的呢?\n常用的过期数据的删除策略就两个(重要!自己造缓存轮子的时候需要格外考虑的东西):\n惰性删除 :只会在取出 key 的时候才对数据进行过期检查。这样对 CPU 最友好,但是可能会造成太多过期 key 没有被删除。 定期删除 : 每隔一段时间抽取一批 key 执行删除过期 key 操作。并且,Redis 底层会通过限制删除操作执行的时长和频率来减少删除操作对 CPU 时间的影响。 定期删除对内存更加友好,惰性删除对 CPU 更加友好。两者各有千秋,所以 Redis 采用的是 定期删除+惰性/懒汉式删除 。\n但是,仅仅通过给 key 设置过期时间还是有问题的。因为还是可能存在定期删除和惰性删除漏掉了很多过期 key 的情况。这样就导致大量过期 key 堆积在内存里,然后就 Out of memory 了。\n怎么解决这个问题呢?答案就是:Redis 内存淘汰机制。\nRedis 内存淘汰机制了解么? # 相关问题:MySQL 里有 2000w 数据,Redis 中只存 20w 的数据,如何保证 Redis 中的数据都是热点数据?\n当缓存数据越来越多,Redis 不可避免的会被写满,这时候就涉及到 Redis 的内存淘汰机制了\nRedis 提供 6 种数据淘汰策略:\nvolatile-lru(least recently used):从已设置过期时间的数据集(server.db[i].expires)中挑选最近最少使用的数据淘汰 volatile-ttl:从已设置过期时间的数据集(server.db[i].expires)中挑选将要过期的数据淘汰 volatile-random:从已设置过期时间的数据集(server.db[i].expires)中任意选择数据淘汰 allkeys-lru(least recently used):当内存不足以容纳新写入数据时,在键空间中,移除最近最少使用的 key(这个是最常用的) allkeys-random:从数据集(server.db[i].dict)中任意选择数据淘汰 no-eviction:禁止驱逐数据,也就是说当内存不足以容纳新写入数据时,新写入操作会报错。这个应该没人使用吧! 4.0 版本后增加以下两种:\nvolatile-lfu(least frequently used):从已设置过期时间的数据集(server.db[i].expires)中挑选最不经常使用的数据淘汰 allkeys-lfu(least frequently used):当内存不足以容纳新写入数据时,在键空间中,移除最不经常使用的 key 关于最近最少使用:\n链表尾部的数据会被丢弃\n长期不被使用的数据,在未来被用到的几率也不大。因此,当数据所占 内存达到一定阈值时,要移除掉最近最少使用的数据。\n关于翻译问题:least,程度最轻的。recently,最近的。其实翻译应该是“非最近的,越远越要淘汰”\njava算法实现\npublic class LRUCache { class DLinkedNode { int key; int value; DLinkedNode prev; DLinkedNode next; public DLinkedNode() {} public DLinkedNode(int _key, int _value) {key = _key; value = _value;} } private Map\u0026lt;Integer, DLinkedNode\u0026gt; cache = new HashMap\u0026lt;Integer, DLinkedNode\u0026gt;(); private int size; private int capacity; private DLinkedNode head, tail; public LRUCache(int capacity) { this.size = 0; this.capacity = capacity; // 使用伪头部和伪尾部节点 head = new DLinkedNode(); tail = new DLinkedNode(); head.next = tail; tail.prev = head; } public int get(int key) { DLinkedNode node = cache.get(key); if (node == null) { return -1; } // 如果 key 存在,先通过哈希表定位,再移到头部 moveToHead(node); return node.value; } public void put(int key, int value) { DLinkedNode node = cache.get(key); if (node == null) { // 如果 key 不存在,创建一个新的节点 DLinkedNode newNode = new DLinkedNode(key, value); // 添加进哈希表 cache.put(key, newNode); // 添加至双向链表的头部 addToHead(newNode); ++size; if (size \u0026gt; capacity) { // 如果超出容量,删除双向链表的尾部节点 DLinkedNode tail = removeTail(); // 删除哈希表中对应的项 cache.remove(tail.key); --size; } } else { // 如果 key 存在,先通过哈希表定位,再修改 value,并移到头部 node.value = value; moveToHead(node); } } private void addToHead(DLinkedNode node) { node.prev = head; node.next = head.next; head.next.prev = node; head.next = node; } private void removeNode(DLinkedNode node) { node.prev.next = node.next; node.next.prev = node.prev; } private void moveToHead(DLinkedNode node) { removeNode(node); addToHead(node); } private DLinkedNode removeTail() { DLinkedNode res = tail.prev; removeNode(res); return res; } } Redis 持久化机制 # 怎么保证 Redis 挂掉之后再重启数据可以进行恢复? # 很多时候我们需要持久化数据也就是将内存中的数据写入到硬盘里面,大部分原因是为了之后重用数据(比如重启机器、机器故障之后恢复数据),或者是为了防止系统故障而将数据备份到一个远程位置。\nRedis 不同于 Memcached 的很重要一点就是,Redis 支持持久化,而且支持两种不同的持久化操作。Redis 的一种持久化方式叫快照(snapshotting,RDB),另一种方式是只追加文件(append-only file, AOF)。这两种方法各有千秋,下面我会详细这两种持久化方法是什么,怎么用,如何选择适合自己的持久化方法。\n什么是 RDB 持久化? # Redis 可以通过创建快照来获得存储在内存里面的数据在某个时间点上的副本。Redis 创建快照之后,可以对快照进行备份,可以将快照复制到其他服务器从而创建具有相同数据的服务器副本(Redis 主从结构,主要用来提高 Redis 性能),还可以将快照留在原地以便重启服务器的时候使用。\n快照持久化是 Redis 默认采用的持久化方式,在 redis.conf 配置文件中默认有此下配置:\nsave 900 1 #在900秒(15分钟)之后,如果至少有1个key发生变化,Redis就会自动触发bgsave命令创建快照。 save 300 10 #在300秒(5分钟)之后,如果至少有10个key发生变化,Redis就会自动触发bgsave命令创建快照。 save 60 10000 #在60秒(1分钟)之后,如果至少有10000个key发生变化,Redis就会自动触发bgsave命令创建快照。 RDB 创建快照时会阻塞主线程吗? # Redis 提供了两个命令来生成 RDB 快照文件:\nsave : 主线程执行,会阻塞主线程; bgsave : 子线程执行,不会阻塞主线程,默认选项。 什么是 AOF 持久化? # 与快照持久化相比,AOF 持久化的实时性更好,因此已成为主流的持久化方案。默认情况下 Redis 没有开启 AOF(append only file)方式的持久化,可以通过 appendonly 参数开启:\nappendonly yes 开启 AOF 持久化后每执行一条会更改 Redis 中的数据的命令,Redis 就会将该命令写入到内存缓存 server.aof_buf 中,然后再根据 appendfsync 配置来决定何时将其同步到硬盘中的 AOF 文件。\nAOF 文件的保存位置和 RDB 文件的位置相同,都是通过 dir 参数设置的,默认的文件名是 appendonly.aof。\n在 Redis 的配置文件中存在三种不同的 AOF 持久化方式,它们分别是:\nappendfsync always #每次有数据修改发生时都会写入AOF文件,这样会严重降低Redis的速度 appendfsync everysec #每秒钟同步一次,显式地将多个写命令同步到硬盘 appendfsync no #让操作系统决定何时进行同步 为了兼顾数据和写入性能,用户可以考虑 appendfsync everysec 选项 ,让 Redis 每秒同步一次 AOF 文件,Redis 性能几乎没受到任何影响。而且这样即使出现系统崩溃,用户最多只会丢失一秒之内产生的数据。当硬盘忙于执行写入操作的时候,Redis 还会优雅的放慢自己的速度以便适应硬盘的最大写入速度。\n相关 issue :\nRedis 的 AOF 方式 #783 Redis AOF 重写描述不准确 #1439 AOF 日志是如何实现的? # 关系型数据库(如 MySQL)通常都是执行命令之前记录日志(方便故障恢复),而 Redis AOF 持久化机制是在执行完命令之后再记录日志。\n为什么是在执行完命令之后记录日志呢?\n避免额外的检查开销,AOF 记录日志不会对命令进行语法检查; 在命令执行完之后再记录,不会阻塞当前的命令执行。 这样也带来了风险(我在前面介绍 AOF 持久化的时候也提到过):\n如果刚执行完命令 Redis 就宕机会导致对应的修改丢失; 可能会阻塞后续其他命令的执行(AOF 记录日志是在 Redis 主线程中进行的)。 AOF 重写了解吗? # 当 AOF 变得太大时,Redis 能够在后台自动重写 AOF 产生一个新的 AOF 文件,这个新的 AOF 文件和原有的 AOF 文件所保存的数据库状态一样,但体积更小。\nAOF 重写是一个有歧义的名字,该功能是通过读取数据库中的键值对来实现的,程序无须对现有 AOF 文件进行任何读入、分析或者写入操作。\n在执行 BGREWRITEAOF 命令时,Redis 服务器会维护一个 AOF 重写缓冲区,该缓冲区会在子进程创建新 AOF 文件期间,记录服务器执行的所有写命令。当子进程完成创建新 AOF 文件的工作之后,服务器会将重写缓冲区中的所有内容追加到新 AOF 文件的末尾,使得新的 AOF 文件保存的数据库状态与现有的数据库状态一致。最后,服务器用新的 AOF 文件替换旧的 AOF 文件,以此来完成 AOF 文件重写操作。\nRedis 7.0 版本之前,如果在重写期间有写入命令,AOF 可能会使用大量内存,重写期间到达的所有写入命令都会写入磁盘两次。\naof 文件重写是将 redis 中的数据转换为 写命令同步更新到 aof 文件的过程。\n重写 aof 后 为什么么可以变小\n清除了一些无效命令 eg. del srem 进程内超时的数据不再写入 aof 文件 多条写命令可以合并为批量写命令 eg. lpush list v1 lpush list v2 lpush list v3 合并为一条写入命令 lpush list v1 v2 v3 如何选择 RDB 和 AOF? # 关于 RDB 和 AOF 的优缺点,官网上面也给了比较详细的说明 Redis persistence,这里结合自己的理解简单总结一下。\nRDB 比 AOF 优秀的地方 :\nRDB 文件存储的内容是经过压缩的二进制数据, 保存着某个时间点的数据集,文件很小,适合做数据的备份,灾难恢复。AOF 文件存储的是每一次写命令,类似于 MySQL 的 binlog 日志,通常会必 RDB 文件大很多。当 AOF 变得太大时,Redis 能够在后台自动重写 AOF。新的 AOF 文件和原有的 AOF 文件所保存的数据库状态一样,但体积更小。不过, Redis 7.0 版本之前,如果在重写期间有写入命令,AOF 可能会使用大量内存,重写期间到达的所有写入命令都会写入磁盘两次。 使用 RDB 文件恢复数据,直接解析还原数据即可,不需要一条一条地执行命令,速度非常快。而 AOF 则需要依次执行每个写命令,速度非常慢。也就是说,与 AOF 相比,恢复大数据集的时候,RDB 速度更快。 AOF 比 RDB 优秀的地方 :\nRDB 的数据安全性不如 AOF,没有办法实时或者秒级持久化数据。生成 RDB 文件的过程是比繁重的, 虽然 BGSAVE 子进程写入 RDB 文件的工作不会阻塞主线程,但会对机器的 CPU 资源和内存资源产生影响,严重的情况下甚至会直接把 Redis 服务干宕机。AOF 支持秒级数据丢失(取决 fsync 策略,如果是 everysec,最多丢失 1 秒的数据),仅仅是追加命令到 AOF 文件,操作轻量。 RDB 文件是以特定的二进制格式保存的,并且在 Redis 版本演进中有多个版本的 RDB,所以存在老版本的 Redis 服务不兼容新版本的 RDB 格式的问题。 AOF 以一种易于理解和解析的格式包含所有操作的日志。你可以轻松地导出 AOF 文件进行分析,你也可以直接操作 AOF 文件来解决一些问题。比如,如果执行FLUSHALL命令意外地刷新了所有内容后,只要 AOF 文件没有被重写,删除最新命令并重启即可恢复之前的状态。 Redis 4.0 对于持久化机制做了什么优化? # 由于 RDB 和 AOF 各有优势,于是,Redis 4.0 开始支持 RDB 和 AOF 的混合持久化(默认关闭,可以通过配置项 aof-use-rdb-preamble 开启)。\n如果把混合持久化打开,AOF 重写的时候就直接把 RDB 的内容写到 AOF 文件开头。这样做的好处是可以结合 RDB 和 AOF 的优点, 快速加载同时避免丢失过多的数据。当然缺点也是有的, AOF 里面的 RDB 部分是压缩格式不再是 AOF 格式,可读性较差。\n官方文档地址:https://redis.io/topics/persistence\n参考 # 《Redis 开发与运维》 《Redis 设计与实现》 Redis 命令手册:https://www.redis.com.cn/commands.html WHY Redis choose single thread (vs multi threads): https://medium.com/@jychen7/sharing-redis-single-thread-vs-multi-threads-5870bd44d153 The difference between AOF and RDB persistence:https://www.sobyte.net/post/2022-04/redis-rdb-and-aof/ "},{"id":248,"href":"/zh/docs/technology/Review/java_guide/lycly_system-design/web-real-time-message-push/","title":"web-real-time-message-push","section":"系统设计","content":" 转载自https://github.com/Snailclimb/JavaGuide(添加小部分笔记)感谢作者!\n原文地址:https://juejin.cn/post/7122014462181113887,JavaGuide 对本文进行了完善总结。\n我有一个朋友做了一个小破站,现在要实现一个站内信 Web 消息推送的功能,对,就是下图这个小红点,一个很常用的功能。\n不过他还没想好用什么方式做,这里我帮他整理了一下几种方案,并简单做了实现。\n# 什么是消息推送? # 推送的场景比较多,比如有人关注我的公众号,这时我就会收到一条推送消息,以此来吸引我点击打开应用。\n消息推送通常是指网站的运营工作等人员,通过某种工具对用户当前网页或移动设备 APP 进行的主动消息推送。\n消息推送一般又分为 Web 端消息推送和移动端消息推送。\n移动端消息推送示例 :\nWeb 端消息推送示例:\n在具体实现之前,咱们再来分析一下前边的需求,其实功能很简单,只要触发某个事件(主动分享了资源或者后台主动推送消息),Web 页面的通知小红点就会实时的 +1 就可以了。\n通常在服务端会有若干张消息推送表,用来记录用户触发不同事件所推送不同类型的消息,前端主动查询(拉)或者被动接收(推)用户所有未读的消息数。\n消息推送无非是推(push)和拉(pull)两种形式,下边我们逐个了解下。\n# 消息推送常见方案 # # 短轮询 # 轮询(polling) 应该是实现消息推送方案中最简单的一种,这里我们暂且将轮询分为短轮询和长轮询。\n短轮询很好理解,指定的时间间隔,由浏览器向服务器发出 HTTP 请求,服务器实时返回未读消息数据给客户端,浏览器再做渲染显示。\n一个简单的 JS 定时器就可以搞定,每秒钟请求一次未读消息数接口,返回的数据展示即可。\nsetInterval(() =\u0026gt; { // 方法请求 messageCount().then((res) =\u0026gt; { if (res.code === 200) { this.messageCount = res.data } }) }, 1000); 效果还是可以的,短轮询实现固然简单,缺点也是显而易见,由于推送数据并不会频繁变更,无论后端此时是否有新的消息产生,客户端都会进行请求,势必会对服务端造成很大压力,浪费带宽和服务器资源。\n# 长轮询 # 长轮询是对上边短轮询的一种改进版本,在尽可能减少对服务器资源浪费的同时,保证消息的相对实时性。长轮询在中间件中应用的很广泛,比如 Nacos 和 Apollo 配置中心,消息队列 Kafka、RocketMQ 中都有用到长轮询。\nNacos 配置中心交互模型是 push 还是 pull?open in new window一文中我详细介绍过 Nacos 长轮询的实现原理,感兴趣的小伙伴可以瞅瞅。\n长轮询其实原理跟轮询差不多,都是采用轮询的方式。不过,如果服务端的数据没有发生变更,会 一直 hold 住请求,直到服务端的数据发生变化,或者等待一定时间超时才会返回。返回后,客户端又会立即再次发起下一次长轮询。\n这次我使用 Apollo 配置中心实现长轮询的方式,应用了一个类DeferredResult,它是在 Servelet3.0 后经过 Spring 封装提供的一种异步请求机制,直意就是延迟结果。\nDeferredResult可以允许容器线程快速释放占用的资源,不阻塞请求线程,以此接受更多的请求提升系统的吞吐量,然后启动异步工作线程处理真正的业务逻辑,处理完成调用DeferredResult.setResult(200)提交响应结果。\n下边我们用长轮询来实现消息推送。\n因为一个 ID 可能会被多个长轮询请求监听,所以我采用了 Guava 包提供的Multimap结构存放长轮询,一个 key 可以对应多个 value。一旦监听到 key 发生变化,对应的所有长轮询都会响应。前端得到非请求超时的状态码,知晓数据变更,主动查询未读消息数接口,更新页面数据。\n@Controller @RequestMapping(\u0026#34;/polling\u0026#34;) public class PollingController { // 存放监听某个Id的长轮询集合 // 线程同步结构 public static Multimap\u0026lt;String, DeferredResult\u0026lt;String\u0026gt;\u0026gt; watchRequests = Multimaps.synchronizedMultimap(HashMultimap.create()); /** * 设置监听 */ @GetMapping(path = \u0026#34;watch/{id}\u0026#34;) @ResponseBody public DeferredResult\u0026lt;String\u0026gt; watch(@PathVariable String id) { // 延迟对象设置超时时间 DeferredResult\u0026lt;String\u0026gt; deferredResult = new DeferredResult\u0026lt;\u0026gt;(TIME_OUT); // 异步请求完成时移除 key,防止内存溢出 deferredResult.onCompletion(() -\u0026gt; { watchRequests.remove(id, deferredResult); }); // 注册长轮询请求 watchRequests.put(id, deferredResult); return deferredResult; } /** * 变更数据 */ @GetMapping(path = \u0026#34;publish/{id}\u0026#34;) @ResponseBody public String publish(@PathVariable String id) { // 数据变更 取出监听ID的所有长轮询请求,并一一响应处理 if (watchRequests.containsKey(id)) { Collection\u0026lt;DeferredResult\u0026lt;String\u0026gt;\u0026gt; deferredResults = watchRequests.get(id); for (DeferredResult\u0026lt;String\u0026gt; deferredResult : deferredResults) { deferredResult.setResult(\u0026#34;我更新了\u0026#34; + new Date()); } } return \u0026#34;success\u0026#34;; } 当请求超过设置的超时时间,会抛出AsyncRequestTimeoutException异常,这里直接用@ControllerAdvice全局捕获统一返回即可,前端获取约定好的状态码后再次发起长轮询请求,如此往复调用。\n@ControllerAdvice public class AsyncRequestTimeoutHandler { @ResponseStatus(HttpStatus.NOT_MODIFIED) @ResponseBody @ExceptionHandler(AsyncRequestTimeoutException.class) public String asyncRequestTimeoutHandler(AsyncRequestTimeoutException e) { System.out.println(\u0026#34;异步请求超时\u0026#34;); return \u0026#34;304\u0026#34;; } } 我们来测试一下,首先页面发起长轮询请求/polling/watch/10086监听消息更变,请求被挂起,不变更数据直至超时,再次发起了长轮询请求;紧接着手动变更数据/polling/publish/10086,长轮询得到响应,前端处理业务逻辑完成后再次发起请求,如此循环往复。\n长轮询相比于短轮询在性能上提升了很多,但依然会产生较多的请求,这是它的一点不完美的地方。\n# iframe 流 # iframe 流就是在页面中插入一个隐藏的\u0026lt;iframe\u0026gt;标签,通过在src中请求消息数量 API 接口,由此在服务端和客户端之间创建一条长连接,服务端持续向iframe传输数据。\n传输的数据通常是 HTML、或是内嵌的JavaScript 脚本,来达到实时更新页面的效果。\n这种方式实现简单,前端只要一个\u0026lt;iframe\u0026gt;标签搞定了\n\u0026lt;iframe src=\u0026#34;/iframe/message\u0026#34; style=\u0026#34;display:none\u0026#34;\u0026gt;\u0026lt;/iframe\u0026gt; 服务端直接组装 HTML、JS 脚本数据向 response 写入就行了\n@Controller @RequestMapping(\u0026#34;/iframe\u0026#34;) public class IframeController { @GetMapping(path = \u0026#34;message\u0026#34;) public void message(HttpServletResponse response) throws IOException, InterruptedException { while (true) { response.setHeader(\u0026#34;Pragma\u0026#34;, \u0026#34;no-cache\u0026#34;); response.setDateHeader(\u0026#34;Expires\u0026#34;, 0); response.setHeader(\u0026#34;Cache-Control\u0026#34;, \u0026#34;no-cache,no-store\u0026#34;); response.setStatus(HttpServletResponse.SC_OK); response.getWriter().print(\u0026#34; \u0026lt;script type=\\\u0026#34;text/javascript\\\u0026#34;\u0026gt;\\n\u0026#34; + \u0026#34;parent.document.getElementById(\u0026#39;clock\u0026#39;).innerHTML = \\\u0026#34;\u0026#34; + count.get() + \u0026#34;\\\u0026#34;;\u0026#34; + \u0026#34;parent.document.getElementById(\u0026#39;count\u0026#39;).innerHTML = \\\u0026#34;\u0026#34; + count.get() + \u0026#34;\\\u0026#34;;\u0026#34; + \u0026#34;\u0026lt;/script\u0026gt;\u0026#34;); } } } iframe 流的服务器开销很大,而且IE、Chrome等浏览器一直会处于 loading 状态,图标会不停旋转,简直是强迫症杀手。\niframe 流非常不友好,强烈不推荐。\n# SSE (我的方式) # 很多人可能不知道,服务端向客户端推送消息,其实除了可以用WebSocket这种耳熟能详的机制外,还有一种服务器发送事件(Server-Sent Events),简称 SSE。这是一种服务器端到客户端(浏览器)的单向消息推送。\nSSE 基于 HTTP 协议的,我们知道一般意义上的 HTTP 协议是无法做到服务端主动向客户端推送消息的,但 SSE 是个例外,它变换了一种思路。\nSSE 在服务器和客户端之间打开一个单向通道,服务端响应的不再是一次性的数据包而是text/event-stream类型的数据流信息,在有数据变更时从服务器流式传输到客户端。\n整体的实现思路有点类似于在线视频播放,视频流会连续不断的推送到浏览器,你也可以理解成,客户端在完成一次用时很长(网络不畅)的下载。\nSSE 与 WebSocket 作用相似,都可以建立服务端与浏览器之间的通信,实现服务端向客户端推送消息,但还是有些许不同:\nSSE 是基于 HTTP 协议的,它们不需要特殊的协议或服务器实现即可工作;WebSocket 需单独服务器来处理协议。 SSE 单向通信,只能由服务端向客户端单向通信;WebSocket 全双工通信,即通信的双方可以同时发送和接受信息。 SSE 实现简单开发成本低,无需引入其他组件;WebSocket 传输数据需做二次解析,开发门槛高一些。 SSE 默认支持断线重连;WebSocket 则需要自己实现。 SSE 只能传送文本消息,二进制数据需要经过编码后传送;WebSocket 默认支持传送二进制数据。 SSE 与 WebSocket 该如何选择?\n技术并没有好坏之分,只有哪个更合适\nSSE 好像一直不被大家所熟知,一部分原因是出现了 WebSocket,这个提供了更丰富的协议来执行双向、全双工通信。对于游戏、即时通信以及需要双向近乎实时更新的场景,拥有双向通道更具吸引力。\n但是,在某些情况下,不需要从客户端发送数据。而你只需要一些服务器操作的更新。比如:站内信、未读消息数、状态更新、股票行情、监控数量等场景,SEE 不管是从实现的难易和成本上都更加有优势。此外,SSE 具有 WebSocket 在设计上缺乏的多种功能,例如:自动重新连接、事件 ID 和发送任意事件的能力。\n前端只需进行一次 HTTP 请求,带上唯一 ID,打开事件流,监听服务端推送的事件就可以了\n\u0026lt;script\u0026gt; let source = null; let userId = 7777 if (window.EventSource) { // 建立连接 source = new EventSource(\u0026#39;http://localhost:7777/sse/sub/\u0026#39;+userId); setMessageInnerHTML(\u0026#34;连接用户=\u0026#34; + userId); /** * 连接一旦建立,就会触发open事件 * 另一种写法:source.onopen = function (event) {} */ source.addEventListener(\u0026#39;open\u0026#39;, function (e) { setMessageInnerHTML(\u0026#34;建立连接。。。\u0026#34;); }, false); /** * 客户端收到服务器发来的数据 * 另一种写法:source.onmessage = function (event) {} */ source.addEventListener(\u0026#39;message\u0026#39;, function (e) { setMessageInnerHTML(e.data); }); } else { setMessageInnerHTML(\u0026#34;你的浏览器不支持SSE\u0026#34;); } \u0026lt;/script\u0026gt; 服务端的实现更简单,创建一个SseEmitter对象放入sseEmitterMap进行管理\nprivate static Map\u0026lt;String, SseEmitter\u0026gt; sseEmitterMap = new ConcurrentHashMap\u0026lt;\u0026gt;(); /** * 创建连接 */ public static SseEmitter connect(String userId) { try { // 设置超时时间,0表示不过期。默认30秒 SseEmitter sseEmitter = new SseEmitter(0L); // 注册回调 sseEmitter.onCompletion(completionCallBack(userId)); sseEmitter.onError(errorCallBack(userId)); sseEmitter.onTimeout(timeoutCallBack(userId)); sseEmitterMap.put(userId, sseEmitter); count.getAndIncrement(); return sseEmitter; } catch (Exception e) { log.info(\u0026#34;创建新的sse连接异常,当前用户:{}\u0026#34;, userId); } return null; } /** * 给指定用户发送消息 */ public static void sendMessage(String userId, String message) { if (sseEmitterMap.containsKey(userId)) { try { sseEmitterMap.get(userId).send(message); } catch (IOException e) { log.error(\u0026#34;用户[{}]推送异常:{}\u0026#34;, userId, e.getMessage()); removeUser(userId); } } } 注意: SSE 不支持 IE 浏览器,对其他主流浏览器兼容性做的还不错。\n# Websocket # Websocket 应该是大家都比较熟悉的一种实现消息推送的方式,上边我们在讲 SSE 的时候也和 Websocket 进行过比较。\n是一种在 TCP 连接上进行全双工通信的协议,建立客户端和服务器之间的通信渠道。浏览器和服务器仅需一次握手,两者之间就直接可以创建持久性的连接,并进行双向数据传输。\nSpringBoot 整合 Websocket,先引入 Websocket 相关的工具包,和 SSE 相比额外的开发成本。\n\u0026lt;!-- 引入websocket --\u0026gt; \u0026lt;dependency\u0026gt; \u0026lt;groupId\u0026gt;org.springframework.boot\u0026lt;/groupId\u0026gt; \u0026lt;artifactId\u0026gt;spring-boot-starter-websocket\u0026lt;/artifactId\u0026gt; \u0026lt;/dependency\u0026gt; \u0026lt;!--可能需要排除springboot自带的tomcat--\u0026gt; 服务端使用@ServerEndpoint注解标注当前类为一个 WebSocket 服务器,客户端可以通过ws://localhost:7777/webSocket/10086来连接到 WebSocket 服务器端。\n@Component @Slf4j @ServerEndpoint(\u0026#34;/websocket/{userId}\u0026#34;) public class MyWebSocket { //与某个客户端的连接会话,需要通过它来给客户端发送数据 private Session session; private static final CopyOnWriteArraySet\u0026lt;MyWebSocket\u0026gt; webSockets = new CopyOnWriteArraySet\u0026lt;\u0026gt;(); // 用来存在线连接数 private static final Map\u0026lt;String, Session\u0026gt; sessionPool = new HashMap\u0026lt;String, Session\u0026gt;(); /** * 链接成功调用的方法 */ @OnOpen public void onOpen(Session session, @PathParam(value = \u0026#34;userId\u0026#34;) String userId) { try { this.session = session; webSockets.add(this); sessionPool.put(userId, session); log.info(\u0026#34;websocket消息: 有新的连接,总数为:\u0026#34; + webSockets.size()); } catch (Exception e) { } } /** * 收到客户端消息后调用的方法 */ @OnMessage public void onMessage(String message) { log.info(\u0026#34;websocket消息: 收到客户端消息:\u0026#34; + message); } /** * 此为单点消息 */ public void sendOneMessage(String userId, String message) { Session session = sessionPool.get(userId); if (session != null \u0026amp;\u0026amp; session.isOpen()) { try { log.info(\u0026#34;websocket消: 单点消息:\u0026#34; + message); session.getAsyncRemote().sendText(message); } catch (Exception e) { e.printStackTrace(); } } } } 前端初始化打开 WebSocket 连接,并监听连接状态,接收服务端数据或向服务端发送数据。\n\u0026lt;html\u0026gt; \u0026lt;head\u0026gt; \u0026lt;title\u0026gt;websocket测试\u0026lt;/title\u0026gt; \u0026lt;meta charset=\u0026#34;utf-8\u0026#34; /\u0026gt; \u0026lt;script src=\u0026#34;https://code.jquery.com/jquery-3.1.1.min.js\u0026#34;\u0026gt;\u0026lt;/script\u0026gt; \u0026lt;/head\u0026gt; \u0026lt;body\u0026gt; \u0026lt;input type=\u0026#34;text\u0026#34; id=\u0026#34;message\u0026#34;\u0026gt; \u0026lt;button onclick=\u0026#34;sendMessage()\u0026#34;\u0026gt;测试\u0026lt;/button\u0026gt; \u0026lt;script\u0026gt; //注意,地址不要填错了 var ws = new WebSocket(\u0026#39;ws://localhost:8089/websocket/10086\u0026#39;); // 获取连接状态 console.log(\u0026#39;ws连接状态:\u0026#39; + ws.readyState); //监听是否连接成功 ws.onopen = function () { console.log(\u0026#39;ws连接状态:\u0026#39; + ws.readyState); //连接成功则发送一个数据 ws.send(\u0026#39;test1\u0026#39;); } // 接听服务器发回的信息并处理展示 ws.onmessage = function (data) { console.log(\u0026#39;接收到来自服务器的消息:\u0026#39;); console.log(data); //完成通信后关闭WebSocket连接(这里不要关闭,让他持续发) //ws.close(); } // 监听连接关闭事件 ws.onclose = function () { // 监听整个过程中websocket的状态 console.log(\u0026#39;ws连接状态:\u0026#39; + ws.readyState); } // 监听并处理error事件 ws.onerror = function (error) { console.log(error); } //如果新开一个窗口,可以手动访问http://192.168.2.26:8089/socket/publish?userId=10086\u0026amp;message=abcde,那么就可以发送消息啦(原窗口可以继续接收消息) function sendMessage() { var content = $(\u0026#34;#message\u0026#34;).val(); //这里需要后台提供/socket/publish接口 $.ajax({ url: \u0026#39;http://192.168.2.26:8089/socket/publish?userId=10086\u0026amp;message=\u0026#39; + content, type: \u0026#39;GET\u0026#39;, data: { \u0026#34;id\u0026#34;: \u0026#34;7777\u0026#34;, \u0026#34;content\u0026#34;: content }, success: function (data) { console.log(data) } }) } \u0026lt;/script\u0026gt; \u0026lt;/body\u0026gt; \u0026lt;/html\u0026gt; 页面初始化建立 WebSocket 连接,之后就可以进行双向通信了,效果还不错。\n# MQTT # 什么是 MQTT 协议?\nMQTT (Message Queue Telemetry Transport)是一种基于发布/订阅(publish/subscribe)模式的轻量级通讯协议,通过订阅相应的主题来获取消息,是物联网(Internet of Thing)中的一个标准传输协议。\n该协议将消息的发布者(publisher)与订阅者(subscriber)进行分离,因此可以在不可靠的网络环境中,为远程连接的设备提供可靠的消息服务,使用方式与传统的 MQ 有点类似。\nTCP 协议位于传输层,MQTT 协议位于应用层,MQTT 协议构建于 TCP/IP 协议上,也就是说只要支持 TCP/IP 协议栈的地方,都可以使用 MQTT 协议。\n为什么要用 MQTT 协议?\nMQTT 协议为什么在物联网(IOT)中如此受偏爱?而不是其它协议,比如我们更为熟悉的 HTTP 协议呢?\n首先 HTTP 协议它是一种同步协议,客户端请求后需要等待服务器的响应。而在物联网(IOT)环境中,设备会很受制于环境的影响,比如带宽低、网络延迟高、网络通信不稳定等,显然异步消息协议更为适合 IOT 应用程序。 HTTP 是单向的,如果要获取消息客户端必须发起连接,而在物联网(IOT)应用程序中,设备或传感器往往都是客户端,这意味着它们无法被动地接收来自网络的命令。 通常需要将一条命令或者消息,发送到网络上的所有设备上。HTTP 要实现这样的功能不但很困难,而且成本极高。 具体的 MQTT 协议介绍和实践,这里我就不再赘述了,大家可以参考我之前的两篇文章,里边写的也都很详细了。\nMQTT 协议的介绍: 我也没想到 SpringBoot + RabbitMQ 做智能家居,会这么简单open in new window MQTT 实现消息推送: 未读消息(小红点),前端 与 RabbitMQ 实时消息推送实践,贼简单~open in new window # 总结 # 以下内容为 JavaGuide 补充\n介绍 优点 缺点 短轮询 客户端定时向服务端发送请求,服务端直接返回响应数据(即使没有数据更新) 简单、易理解、易实现 实时性太差,无效请求太多,频繁建立连接太耗费资源 长轮询 与短轮询不同是,长轮询接收到客户端请求之后等到有数据更新才返回请求 减少了无效请求 挂起请求会导致资源浪费 iframe 流 服务端和客户端之间创建一条长连接,服务端持续向iframe传输数据。 简单、易理解、易实现 维护一个长连接会增加开销,效果太差(图标会不停旋转) SSE 一种服务器端到客户端(浏览器)的单向消息推送。 简单、易实现,功能丰富 不支持双向通信 WebSocket 除了最初建立连接时用 HTTP 协议,其他时候都是直接基于 TCP 协议进行通信的,可以实现客户端和服务端的全双工通信。 性能高、开销小 对开发人员要求更高,实现相对复杂一些 MQTT 基于发布/订阅(publish/subscribe)模式的轻量级通讯协议,通过订阅相应的主题来获取消息。 成熟稳定,轻量级 对开发人员要求更高,实现相对复杂一些 著作权归所有 原文链接:https://javaguide.cn/system-design/web-real-time-message-push.html\n"},{"id":249,"href":"/zh/docs/technology/Review/java_guide/lycly_system-design/schedule-task/","title":"Java定时任务详解","section":"系统设计","content":" 转载自https://github.com/Snailclimb/JavaGuide(添加小部分笔记)感谢作者!\n为什么需要定时任务? # 我们来看一下几个非常常见的业务场景:\n某系统凌晨要进行数据备份。 某电商平台,用户下单半个小时未支付的情况下需要自动取消订单。 某媒体聚合平台,每 10 分钟动态抓取某某网站的数据为自己所用。 某博客平台,支持定时发送文章。 某基金平台,每晚定时计算用户当日收益情况并推送给用户最新的数据。 \u0026hellip;\u0026hellip; 这些场景往往都要求我们在某个特定的时间去做某个事情。\n单机定时任务技术选型 # Timer # java.util.Timer是 JDK 1.3 开始就已经支持的一种定时任务的实现方式。\nTimer 内部使用一个叫做 TaskQueue 的类存放定时任务,它是一个基于最小堆实现的优先级队列。TaskQueue 会按照任务距离下一次执行时间的大小将任务排序,保证在堆顶的任务最先执行。这样在需要执行任务时,每次只需要取出堆顶的任务运行即可!\nTimer 使用起来比较简单,通过下面的方式我们就能创建一个 1s 之后执行的定时任务。\n// 示例代码: TimerTask task = new TimerTask() { public void run() { System.out.println(\u0026#34;当前时间: \u0026#34; + new Date() + \u0026#34;n\u0026#34; + \u0026#34;线程名称: \u0026#34; + Thread.currentThread().getName()); } }; System.out.println(\u0026#34;当前时间: \u0026#34; + new Date() + \u0026#34;n\u0026#34; + \u0026#34;线程名称: \u0026#34; + Thread.currentThread().getName()); Timer timer = new Timer(\u0026#34;Timer\u0026#34;); long delay = 1000L; timer.schedule(task, delay); //输出: 当前时间: Fri May 28 15:18:47 CST 2021n线程名称: main 当前时间: Fri May 28 15:18:48 CST 2021n线程名称: Timer 不过其缺陷较多,比如一个 Timer 一个线程,这就导致 Timer 的任务的执行只能串行执行,一个任务执行时间过长的话会影响其他任务(性能非常差),再比如发生异常时任务直接停止(Timer 只捕获了 InterruptedException )。\nTimer 类上的有一段注释是这样写的:\n* This class does not offer real-time guarantees: it schedules * tasks using the \u0026lt;tt\u0026gt;Object.wait(long)\u0026lt;/tt\u0026gt; method. *Java 5.0 introduced the {@code java.util.concurrent} package and * one of the concurrency utilities therein is the {@link * java.util.concurrent.ScheduledThreadPoolExecutor * ScheduledThreadPoolExecutor} which is a thread pool for repeatedly * executing tasks at a given rate or delay. It is effectively a more * versatile replacement for the {@code Timer}/{@code TimerTask} * combination, as it allows multiple service threads, accepts various * time units, and doesn\u0026#39;t require subclassing {@code TimerTask} (just * implement {@code Runnable}). Configuring {@code * ScheduledThreadPoolExecutor} with one thread makes it equivalent to * {@code Timer}. 大概的意思就是: ScheduledThreadPoolExecutor 支持多线程执行定时任务并且功能更强大,是 Timer 的替代品。\nScheduledExecutorService # ScheduledExecutorService 是一个接口,有多个实现类,比较常用的是 ScheduledThreadPoolExecutor 。\nScheduledThreadPoolExecutor 本身就是一个线程池,支持任务并发执行。并且,其内部使用 DelayedWorkQueue 作为任务队列。\n// 示例代码: TimerTask repeatedTask = new TimerTask() { @SneakyThrows public void run() { System.out.println(\u0026#34;当前时间: \u0026#34; + new Date() + \u0026#34;n\u0026#34; + \u0026#34;线程名称: \u0026#34; + Thread.currentThread().getName()); } }; System.out.println(\u0026#34;当前时间: \u0026#34; + new Date() + \u0026#34;n\u0026#34; + \u0026#34;线程名称: \u0026#34; + Thread.currentThread().getName()); ScheduledExecutorService executor = Executors.newScheduledThreadPool(3); long delay = 1000L; long period = 1000L; executor.scheduleAtFixedRate(repeatedTask, delay, period, TimeUnit.MILLISECONDS); Thread.sleep(delay + period * 5); executor.shutdown(); //输出: 当前时间: Fri May 28 15:40:46 CST 2021n线程名称: main 当前时间: Fri May 28 15:40:47 CST 2021n线程名称: pool-1-thread-1 当前时间: Fri May 28 15:40:48 CST 2021n线程名称: pool-1-thread-1 当前时间: Fri May 28 15:40:49 CST 2021n线程名称: pool-1-thread-2 当前时间: Fri May 28 15:40:50 CST 2021n线程名称: pool-1-thread-2 当前时间: Fri May 28 15:40:51 CST 2021n线程名称: pool-1-thread-2 当前时间: Fri May 28 15:40:52 CST 2021n线程名称: pool-1-thread-2 不论是使用 Timer 还是 ScheduledExecutorService 都无法使用 Cron 表达式指定任务执行的具体时间。\nSpring Task # [krɑn] cron\n我们直接通过 Spring 提供的 @Scheduled 注解即可定义定时任务,非常方便!\n/** * cron:使用Cron表达式。 每分钟的1,2秒运行 */ @Scheduled(cron = \u0026#34;1-2 * * * * ? \u0026#34;) public void reportCurrentTimeWithCronExpression() { log.info(\u0026#34;Cron Expression: The time is now {}\u0026#34;, dateFormat.format(new Date())); } 我在大学那会做的一个 SSM 的企业级项目,就是用的 Spring Task 来做的定时任务。\n并且,Spring Task 还是支持 Cron 表达式 的。Cron 表达式主要用于定时作业(定时任务)系统定义执行时间或执行频率的表达式,非常厉害,你可以通过 Cron 表达式进行设置定时任务每天或者每个月什么时候执行等等操作。咱们要学习定时任务的话,Cron 表达式是一定是要重点关注的。推荐一个在线 Cron 表达式生成器:http://cron.qqe2.com/ 。\n但是,Spring 自带的定时调度只支持单机,并且提供的功能比较单一。之前写过一篇文章: 《5 分钟搞懂如何在 Spring Boot 中 Schedule Tasks》 ,不了解的小伙伴可以参考一下。\nSpring Task 底层是基于 JDK 的 ScheduledThreadPoolExecutor 线程池来实现的。\n优缺点总结:\n优点: 简单,轻量,支持 Cron 表达式 缺点 :功能单一 时间轮 # Kafka、Dubbo、ZooKeeper、Netty 、Caffeine 、Akka 中都有对时间轮的实现。\n时间轮简单来说就是一个环形的队列(底层一般基于数组实现),队列中的每一个元素(时间格)都可以存放一个定时任务列表。\n时间轮中的每个时间格代表了时间轮的基本时间跨度或者说时间精度,加入时间一秒走一个时间格的话,那么这个时间轮的最高精度就是 1 秒(也就是说 3 s 和 3.9s 会在同一个时间格中)。\n下图是一个有 12 个时间格的时间轮,转完一圈需要 12 s。当我们需要新建一个 3s 后执行的定时任务,只需要将定时任务放在下标为 3 的时间格中即可。当我们需要新建一个 9s 后执行的定时任务,只需要将定时任务放在下标为 9 的时间格中即可。\n那当我们需要创建一个 13s 后执行的定时任务怎么办呢?这个时候可以引入一叫做 圈数/轮数 的概念,也就是说这个任务还是放在下标为 3 的时间格中, 不过它的圈数为 2 。\n除了增加圈数这种方法之外,还有一种 多层次时间轮 (类似手表),Kafka 采用的就是这种方案。\n针对下图的时间轮,我来举一个例子便于大家理解。\n上图的时间轮,第 1 层的时间精度为 1 ,第 2 层的时间精度为 20 ,第 3 层的时间精度为 400。假如我们需要添加一个 350s 后执行的任务 A 的话(当前时间是 0s),这个任务会被放在第 2 层(因为第二层的时间跨度为 20*20=400\u0026gt;350)的第 350/20=17 个时间格子。\n当第一层转了 17 圈之后,时间过去了 340s ,第 2 层的指针此时来到第 17 个时间格子。此时,第 2 层第 17 个格子的任务会被移动到第 1 层。\n任务 A 当前是 10s 之后执行,因此它会被移动到第 1 层的第 10 个时间格子。\n这里在层与层之间的移动也叫做时间轮的升降级。参考手表来理解就好!\n时间轮比较适合任务数量比较多的定时任务场景,它的任务写入和执行的时间复杂度都是 0(1)。\n分布式定时任务技术选型 # 上面提到的一些定时任务的解决方案都是在单机下执行的,适用于比较简单的定时任务场景比如每天凌晨备份一次数据。\n如果我们需要一些高级特性比如支持任务在分布式场景下的分片和高可用的话,我们就需要用到分布式任务调度框架了。\n通常情况下,一个定时任务的执行往往涉及到下面这些角色:\n任务 : 首先肯定是要执行的任务,这个任务就是具体的业务逻辑比如定时发送文章。 调度器 :其次是调度中心,调度中心主要负责任务管理,会分配任务给执行器。 执行器 : 最后就是执行器,执行器接收调度器分派的任务并执行。 Quartz # 一个很火的开源任务调度框架,完全由Java写成。Quartz 可以说是 Java 定时任务领域的老大哥或者说参考标准,其他的任务调度框架基本都是基于 Quartz 开发的,比如当当网的elastic-job就是基于quartz二次开发之后的分布式调度解决方案。\n使用 Quartz 可以很方便地与 Spring 集成,并且支持动态添加任务和集群。但是,Quartz 使用起来也比较麻烦,API 繁琐。\n并且,Quzrtz 并没有内置 UI 管理控制台,不过你可以使用 quartzui 这个开源项目来解决这个问题。\n另外,Quartz 虽然也支持分布式任务。但是,它是在数据库层面,通过数据库的锁机制做的,有非常多的弊端比如系统侵入性严重、节点负载不均衡。有点伪分布式的味道。\n优缺点总结:\n优点: 可以与 Spring 集成,并且支持动态添加任务和集群。 缺点 :分布式支持不友好,没有内置 UI 管理控制台、使用麻烦(相比于其他同类型框架来说) Elastic-Job # Elastic-Job 是当当网开源的一个基于Quartz和ZooKeeper的分布式调度解决方案,由两个相互独立的子项目 Elastic-Job-Lite 和 Elastic-Job-Cloud 组成,一般我们只要使用 Elastic-Job-Lite 就好。\nElasticJob 支持任务在分布式场景下的分片和高可用、任务可视化管理等功能。\nElasticJob-Lite 的架构设计如下图所示:\n从上图可以看出,Elastic-Job 没有调度中心这一概念,而是使用 ZooKeeper 作为注册中心,注册中心负责协调分配任务到不同的节点上。\nElastic-Job 中的定时调度都是由执行器自行触发,这种设计也被称为去中心化设计(调度和处理都是执行器单独完成)。\n@Component @ElasticJobConf(name = \u0026#34;dayJob\u0026#34;, cron = \u0026#34;0/10 * * * * ?\u0026#34;, shardingTotalCount = 2, shardingItemParameters = \u0026#34;0=AAAA,1=BBBB\u0026#34;, description = \u0026#34;简单任务\u0026#34;, failover = true) public class TestJob implements SimpleJob { @Override public void execute(ShardingContext shardingContext) { log.info(\u0026#34;TestJob任务名:【{}】, 片数:【{}】, param=【{}】\u0026#34;, shardingContext.getJobName(), shardingContext.getShardingTotalCount(), shardingContext.getShardingParameter()); } } 相关地址:\nGithub 地址:https://github.com/apache/shardingsphere-elasticjob。 官方网站:https://shardingsphere.apache.org/elasticjob/index_zh.html 。 优缺点总结:\n优点 :可以与 Spring 集成、支持分布式、支持集群、性能不错 缺点 :依赖了额外的中间件比如 Zookeeper(复杂度增加,可靠性降低、维护成本变高) XXL-JOB # XXL-JOB 于 2015 年开源,是一款优秀的轻量级分布式任务调度框架,支持任务可视化管理、弹性扩容缩容、任务失败重试和告警、任务分片等功能,\n根据 XXL-JOB 官网介绍,其解决了很多 Quartz 的不足。\nXXL-JOB 的架构设计如下图所示:\n从上图可以看出,XXL-JOB 由 调度中心 和 执行器 两大部分组成。调度中心主要负责任务管理、执行器管理以及日志管理。执行器主要是接收调度信号并处理。另外,调度中心进行任务调度时,是通过自研 RPC 来实现的。\n不同于 Elastic-Job 的去中心化设计, XXL-JOB 的这种设计也被称为中心化设计(调度中心调度多个执行器执行任务)。\n和 Quzrtz 类似 XXL-JOB 也是基于数据库锁调度任务,存在性能瓶颈。不过,一般在任务量不是特别大的情况下,没有什么影响的,可以满足绝大部分公司的要求。\n不要被 XXL-JOB 的架构图给吓着了,实际上,我们要用 XXL-JOB 的话,只需要重写 IJobHandler 自定义任务执行逻辑就可以了,非常易用!\n@JobHandler(value=\u0026#34;myApiJobHandler\u0026#34;) @Component public class MyApiJobHandler extends IJobHandler { @Override public ReturnT\u0026lt;String\u0026gt; execute(String param) throws Exception { //...... return ReturnT.SUCCESS; } } 还可以直接基于注解定义任务。\n@XxlJob(\u0026#34;myAnnotationJobHandler\u0026#34;) public ReturnT\u0026lt;String\u0026gt; myAnnotationJobHandler(String param) throws Exception { //...... return ReturnT.SUCCESS; } 相关地址:\nGithub 地址:https://github.com/xuxueli/xxl-job/。 官方介绍:https://www.xuxueli.com/xxl-job/ 。 优缺点总结:\n优点:开箱即用(学习成本比较低)、与 Spring 集成、支持分布式、支持集群、内置了 UI 管理控制台。 缺点:不支持动态添加任务(如果一定想要动态创建任务也是支持的,参见: xxl-job issue277)。 PowerJob # 非常值得关注的一个分布式任务调度框架,分布式任务调度领域的新星。目前,已经有很多公司接入比如 OPPO、京东、中通、思科。\n这个框架的诞生也挺有意思的,PowerJob 的作者当时在阿里巴巴实习过,阿里巴巴那会使用的是内部自研的 SchedulerX(阿里云付费产品)。实习期满之后,PowerJob 的作者离开了阿里巴巴。想着说自研一个 SchedulerX,防止哪天 SchedulerX 满足不了需求,于是 PowerJob 就诞生了。\n更多关于 PowerJob 的故事,小伙伴们可以去看看 PowerJob 作者的视频 《我和我的任务调度中间件》。简单点概括就是:“游戏没啥意思了,我要扛起了新一代分布式任务调度与计算框架的大旗!”。\n由于 SchedulerX 属于人民币产品,我这里就不过多介绍。PowerJob 官方也对比过其和 QuartZ、XXL-JOB 以及 SchedulerX。\n总结 # 这篇文章中,我主要介绍了:\n定时任务的相关概念 :为什么需要定时任务、定时任务中的核心角色、分布式定时任务。 定时任务的技术选型 : XXL-JOB 2015 年推出,已经经过了很多年的考验。XXL-JOB 轻量级,并且使用起来非常简单。虽然存在性能瓶颈,但是,在绝大多数情况下,对于企业的基本需求来说是没有影响的。PowerJob 属于分布式任务调度领域里的新星,其稳定性还有待继续考察。ElasticJob 由于在架构设计上是基于 Zookeeper ,而 XXL-JOB 是基于数据库,性能方面的话,ElasticJob 略胜一筹。 这篇文章并没有介绍到实际使用,但是,并不代表实际使用不重要。我在写这篇文章之前,已经动手写过相应的 Demo。像 Quartz,我在大学那会就用过。不过,当时用的是 Spring 。为了能够更好地体验,我自己又在 Spring Boot 上实际体验了一下。如果你并没有实际使用某个框架,就直接说它并不好用的话,是站不住脚的。\n最后,这篇文章要感谢艿艿的帮助,写这篇文章的时候向艿艿询问过一些问题。推荐一篇艿艿写的偏实战类型的硬核文章: 《Spring Job?Quartz?XXL-Job?年轻人才做选择,艿艿全莽~》 。\n"},{"id":250,"href":"/zh/docs/technology/Review/java_guide/lycly_system-design/security/ly06ly_sentive-words-filter/","title":"敏感词过滤方案总结","section":"安全","content":" 转载自https://github.com/Snailclimb/JavaGuide(添加小部分笔记)感谢作者!\n系统需要对用户输入的文本进行敏感词过滤如色情、政治、暴力相关的词汇。\n敏感词过滤用的使用比较多的 Trie 树算法 和 DFA 算法。\n算法实现 # Trie 树 # Trie 树 也称为字典树、单词查找树,哈系树(这里是不是写错了,哈希树?)的一种变种,通常被用于字符串匹配,用来解决在一组字符串集合中快速查找某个字符串的问题。像浏览器搜索的关键词提示一般就是基于 Trie 树来做的。\n假如我们的敏感词库中有以下敏感词:\n高清有码 高清 AV 东京冷 东京热 我们构造出来的敏感词 Trie 树就是下面这样的:\n当我们要查找对应的字符串“东京热”的话,我们会把这个字符串切割成单个的字符“东”、“京”、“热”,然后我们从 Trie 树的根节点开始匹配。\n可以看出, Trie 树的核心原理其实很简单,就是通过公共前缀来提高字符串匹配效率。\nApache Commons Collecions 这个库中就有 Trie 树实现:\nTrie\u0026lt;String, String\u0026gt; trie = new PatriciaTrie\u0026lt;\u0026gt;(); trie.put(\u0026#34;Abigail\u0026#34;, \u0026#34;student\u0026#34;); trie.put(\u0026#34;Abi\u0026#34;, \u0026#34;doctor\u0026#34;); trie.put(\u0026#34;Annabel\u0026#34;, \u0026#34;teacher\u0026#34;); trie.put(\u0026#34;Christina\u0026#34;, \u0026#34;student\u0026#34;); trie.put(\u0026#34;Chris\u0026#34;, \u0026#34;doctor\u0026#34;); Assertions.assertTrue(trie.containsKey(\u0026#34;Abigail\u0026#34;)); assertEquals(\u0026#34;{Abi=doctor, Abigail=student}\u0026#34;, trie.prefixMap(\u0026#34;Abi\u0026#34;).toString()); assertEquals(\u0026#34;{Chris=doctor, Christina=student}\u0026#34;, trie.prefixMap(\u0026#34;Chr\u0026#34;).toString()); Aho-Corasick(AC)自动机是一种建立在 Trie 树上的一种改进算法,是一种多模式匹配算法,由贝尔实验室的研究人员 Alfred V. Aho 和 Margaret J.Corasick 发明。\nAC 自动机算法使用 Trie 树来存放模式串的前缀,通过失败匹配指针(失配指针)来处理匹配失败的跳转。\n相关阅读: 地铁十分钟 | AC 自动机\nDFA # DFA(Deterministic Finite Automata)即确定有穷自动机,与之对应的是 NFA(Non-Deterministic Finite Automata,有穷自动机)。\n关于 DFA 的详细介绍可以看这篇文章: 有穷自动机 DFA\u0026amp;NFA (学习笔记) - 小蜗牛的文章 - 知乎 。\nHutool 提供了 DFA 算法的实现:\nWordTree wordTree = new WordTree(); wordTree.addWord(\u0026#34;大\u0026#34;); wordTree.addWord(\u0026#34;大憨憨\u0026#34;); wordTree.addWord(\u0026#34;憨憨\u0026#34;); String text = \u0026#34;那人真是个大憨憨!\u0026#34;; // 获得第一个匹配的关键字 String matchStr = wordTree.match(text); System.out.println(matchStr); // 标准匹配,匹配到最短关键词,并跳过已经匹配的关键词 List\u0026lt;String\u0026gt; matchStrList = wordTree.matchAll(text, -1, false, false); System.out.println(matchStrList); //匹配到最长关键词,跳过已经匹配的关键词 List\u0026lt;String\u0026gt; matchStrList2 = wordTree.matchAll(text, -1, false, true); System.out.println(matchStrList2); 输出:\n大 [大, 憨憨] [大, 大憨憨] 开源项目 # ToolGood.Words :一款高性能敏感词(非法词/脏字)检测过滤组件,附带繁体简体互换,支持全角半角互换,汉字转拼音,模糊搜索等功能。 sensitive-words-filter :敏感词过滤项目,提供 TTMP、DFA、DAT、hash bucket、Tire 算法支持过滤。可以支持文本的高亮、过滤、判词、替换的接口支持。 论文 # 一种敏感词自动过滤管理系统 一种网络游戏中敏感词过滤方法及系统 "},{"id":251,"href":"/zh/docs/technology/Review/java_guide/lycly_system-design/security/ly05ly_design-of-authority-system/","title":"权限系统设计详解","section":"安全","content":" 转载自https://github.com/Snailclimb/JavaGuide(添加小部分笔记)感谢作者!\n作者:转转技术团队\n原文:https://mp.weixin.qq.com/s/ONMuELjdHYa0yQceTj01Iw\nly:比较繁琐,大概看了前面的部分\n老权限系统的问题与现状 # 转转公司在过去并没有一个统一的权限管理系统,权限管理由各业务自行研发或是使用其他业务的权限系统,权限管理的不统一带来了不少问题:\n各业务重复造轮子,维护成本高 各系统只解决部分场景问题,方案不够通用,新项目选型时没有可靠的权限管理方案 缺乏统一的日志管理与审批流程,在授权信息追溯上十分困难 基于上述问题,去年底公司启动建设转转统一权限系统,目标是开发一套灵活、易用、安全的权限管理系统,供各业务使用。\n业界权限系统的设计方式 # 目前业界主流的权限模型有两种,下面分别介绍下:\n基于角色的访问控制(RBAC) 基于属性的访问控制(ABAC) RBAC 模型 # 基于角色的访问控制(Role-Based Access Control,简称 RBAC) 指的是通过用户的角色(Role)授权其相关权限,实现了灵活的访问控制,相比直接授予用户权限,要更加简单、高效、可扩展。\n一个用户可以拥有若干角色,每一个角色又可以被分配若干权限这样,就构造成“用户-角色-权限” 的授权模型。在这种模型中,用户与角色、角色与权限之间构成了多对多的关系。\n用一个图来描述如下:\n当使用 RBAC模型 时,通过分析用户的实际情况,基于共同的职责和需求,授予他们不同角色。这种 用户 -\u0026gt; 角色 -\u0026gt; 权限 间的关系,让我们可以不用再单独管理单个用户权限,用户从授予的角色里面获取所需的权限。\n以一个简单的场景(Gitlab 的权限系统)为例,用户系统中有 Admin、Maintainer、Operator 三种角色,这三种角色分别具备不同的权限,比如只有 Admin 具备创建代码仓库、删除代码仓库的权限,其他的角色都不具备。我们授予某个用户 Admin 这个角色,他就具备了 创建代码仓库 和 删除代码仓库 这两个权限。\n通过 RBAC模型 ,当存在多个用户拥有相同权限时,我们只需要创建好拥有该权限的角色,然后给不同的用户分配不同的角色,后续只需要修改角色的权限,就能自动修改角色内所有用户的权限。\nABAC 模型 # 基于属性的访问控制(Attribute-Based Access Control,简称 ABAC) 是一种比 RBAC模型 更加灵活的授权模型,它的原理是通过各种属性来动态判断一个操作是否可以被允许。这个模型在云系统中使用的比较多,比如 AWS,阿里云等。\n考虑下面这些场景的权限控制:\n授权某个人具体某本书的编辑权限 当一个文档的所属部门跟用户的部门相同时,用户可以访问这个文档 当用户是一个文档的拥有者并且文档的状态是草稿,用户可以编辑这个文档 早上九点前禁止 A 部门的人访问 B 系统 在除了上海以外的地方禁止以管理员身份访问 A 系统 用户对 2022-06-07 之前创建的订单有操作权限 可以发现上述的场景通过 RBAC模型 很难去实现,因为 RBAC模型 仅仅描述了用户可以做什么操作,但是操作的条件,以及操作的数据,RBAC模型 本身是没有这些限制的。但这恰恰是 ABAC模型 的长处,ABAC模型 的思想是基于用户、访问的数据的属性、以及各种环境因素去动态计算用户是否有权限进行操作。\nABAC 模型的原理 # 在 ABAC模型 中,一个操作是否被允许是基于对象、资源、操作和环境信息共同动态计算决定的。\n对象:对象是当前请求访问资源的用户。用户的属性包括 ID,个人资源,角色,部门和组织成员身份等 资源:资源是当前用户要访问的资产或对象,例如文件,数据,服务器,甚至 API 操作:操作是用户试图对资源进行的操作。常见的操作包括“读取”,“写入”,“编辑”,“复制”和“删除” 环境:环境是每个访问请求的上下文。环境属性包含访问的时间和位置,对象的设备,通信协议和加密强度等 在 ABAC模型 的决策语句的执行过程中,决策引擎会根据定义好的决策语句,结合对象、资源、操作、环境等因素动态计算出决策结果。每当发生访问请求时,ABAC模型 决策系统都会分析属性值是否与已建立的策略匹配。如果有匹配的策略,访问请求就会被通过。\n新权限系统的设计思想 # 结合转转的业务现状,RBAC模型 满足了转转绝大部分业务场景,并且开发成本远低于 ABAC模型 的权限系统,所以新权限系统选择了基于 RBAC模型 来实现。对于实在无法满足的业务系统,我们选择了暂时性不支持,这样可以保障新权限系统的快速落地,更快的让业务使用起来。\n标准的 RBAC模型 是完全遵守 用户 -\u0026gt; 角色 -\u0026gt; 权限 这个链路的,也就是用户的权限完全由他所拥有的角色来控制,但是这样会有一个缺点,就是给用户加权限必须新增一个角色,导致实际操作起来效率比较低。所以我们在 RBAC模型 的基础上,新增了给用户直接增加权限的能力,也就是说既可以给用户添加角色,也可以给用户直接添加权限。最终用户的权限是由拥有的角色和权限点组合而成。\n新权限系统的权限模型:用户最终权限 = 用户拥有的角色带来的权限 + 用户独立配置的权限,两者取并集。\n新权限系统方案如下图 :\n首先,将集团所有的用户(包括外部用户),通过 统一登录与注册 功能实现了统一管理,同时与公司的组织架构信息模块打通,实现了同一个人员在所有系统中信息的一致,这也为后续基于组织架构进行权限管理提供了可行性。 其次,因为新权限系统需要服务集团所有业务,所以需要支持多系统权限管理。用户进行权限管理前,需要先选择相应的系统,然后配置该系统的 菜单权限 和 数据权限 信息,建立好系统的各个权限点。PS:菜单权限和数据权限的具体说明,下文会详细介绍。 最后,创建该系统下的不同角色,给不同角色配置好权限点。比如店长角色,拥有店员操作权限、本店数据查看权限等,配置好这个角色后,后续只需要给店长增加这个角色,就可以让他拥有对应的权限。 完成上述配置后,就可以进行用户的权限管理了。有两种方式可以给用户加权限:\n先选用户,然后添加权限。该方式可以给用户添加任意角色或是菜单/数据权限点。 先选择角色,然后关联用户。该方式只可给用户添加角色,不能单独添加菜单/数据权限点。 这两种方式的具体设计方案,后文会详细说明。\n权限系统自身的权限管理 # 对于权限系统来说,首先需要设计好系统自身的权限管理,也就是需要管理好 ”谁可以进入权限系统,谁可以管理其他系统的权限“,对于权限系统自身的用户,会分为三类:\n超级管理员:拥有权限系统的全部操作权限,可以进行系统自身的任何操作,也可以管理接入权限的应用系统的管理操作。 权限操作用户:拥有至少一个已接入的应用系统的超级管理员角色的用户。该用户能进行的操作限定在所拥有的应用系统权限范围内。权限操作用户是一种身份,无需分配,而是根据规则自动获得的。 普通用户:普通用户也可以认为是一种身份,除去上述 2 类人,其余的都为普通用户。他们只能申请接入系统以及访问权限申请页面。 权限类型的定义 # 新权限系统中,我们把权限分为两大类,分别是 :\n菜单功能权限:包括系统的目录导航、菜单的访问权限,以及按钮和 API 操作的权限 数据权限:包括定义数据的查询范围权限,在不同系统中,通常叫做 “组织”、”站点“等,在新权限系统中,统一称作 ”组织“ 来管理数据权限 默认角色的分类 # 每个系统中设计了三个默认角色,用来满足基本的权限管理需求,分别如下:\n超级管理员:该角色拥有该系统的全部权限,可以修改系统的角色权限等配置,可以给其他用户授权。 系统管理员:该角色拥有给其他用户授权以及修改系统的角色权限等配置能力,但角色本身不具有任何权限。 授权管理员:该角色拥有给其他用户授权的能力。但是授权的范围不超出自己所拥有的权限。 举个栗子:授权管理员 A 可以给 B 用户添加权限,但添加的范围 小于等于 A 用户已拥有的权限。\n经过这么区分,把 拥有权限 和 拥有授权能力 ,这两部分给分隔开来,可以满足所有的权限控制的场景。\n新权限系统的核心模块设计 # 上面介绍了新权限系统的整体设计思想,接下来分别介绍下核心模块的设计\n系统/菜单/数据权限管理 # 把一个新系统接入权限系统有下列步骤:\n创建系统 配置菜单功能权限 配置数据权限(可选) 创建系统的角色 其中,1、2、3 的步骤,都是在系统管理模块完成,具体流程如下图:\n用户可以对系统的基本信息进行增删改查的操作,不同系统之间通过 系统编码 作为唯一区分。同时 系统编码 也会用作于菜单和数据权限编码的前缀,通过这样的设计保证权限编码全局唯一性。\n例如系统的编码为 test_online,那么该系统的菜单编码格式便为 test_online:m_xxx。\n系统管理界面设计如下:\n菜单管理 # 新权限系统首先对菜单进行了分类,分别是 目录、菜单 和 操作,示意如下图\n它们分别代表的含义是:\n目录 :指的是应用系统中最顶部的一级目录,通常在系统 Logo 的右边 菜单 :指的是应用系统左侧的多层级菜单,通常在系统 Logo 的下面,也是最常用的菜单结构 操作 :指页面中的按钮、接口等一系列可以定义为操作或页面元素的部分。 菜单管理界面设计如下: 菜单权限数据的使用,也提供两种方式:\n动态菜单模式 :这种模式下,菜单的增删完全由权限系统接管。也就是说在权限系统增加菜单,应用系统会同步增加。这种模式好处是修改菜单无需项目上线。 静态菜单模式 :菜单的增删由应用系统的前端控制,权限系统只控制访问权限。这种模式下,权限系统只能标识出用户是否拥有当前菜单的权限,而具体的显示控制是由前端根据权限数据来决定。 角色与用户管理 # 角色与用户管理都是可以直接改变用户权限的核心模块,整个设计思路如下图:\n这个模块设计重点是需要考虑到批量操作。无论是通过角色关联用户,还是给用户批量增加/删除/重置权限,批量操作的场景都是系统需要设计好的。\n权限申请 # 除了给其他用户添加权限外,新权限系统同时支持了用户自主申请权限。这个模块除了常规的审批流(申请、审批、查看)等,有一个比较特别的功能,就是如何让用户能选对自己要的权限。所以在该模块的设计上,除了直接选择角色外,还支持通过菜单/数据权限点,反向选择角色,如下图:\n操作日志 # 系统操作日志会分为两大类:\n操作流水日志 :用户可看、可查的关键操作日志 服务 Log 日志 :系统服务运行过程中产生的 Log 日志,其中,服务 Log 日志信息量大于操作流水日志,但是不方便搜索查看。所以权限系统需要提供操作流水日志功能。 在新权限系统中,用户所有的操作可以分为三类,分别为新增、更新、删除。所有的模块也可枚举,例如用户管理、角色管理、菜单管理等。明确这些信息后,那么一条日志就可以抽象为:什么人(Who)在什么时间(When)对哪些人(Target)的哪些模块做了哪些操作。 这样把所有的记录都入库,就可以方便的进行日志的查看和筛选了。\n总结与展望 # 至此,新权限系统的核心设计思路与模块都已介绍完成,新系统在转转内部有大量的业务接入使用,权限管理相比以前方便了许多。权限系统作为每家公司的一个基础系统,灵活且完备的设计可以助力日后业务的发展更加高效。\n后续两篇:\n转转统一权限系统的设计与实现(后端实现篇) 转转统一权限系统的设计与实现(前端实现篇) 参考 # 选择合适的权限模型:https://docs.authing.cn/v2/guides/access-control/choose-the-right-access-control-model.html "},{"id":252,"href":"/zh/docs/technology/Review/java_guide/lycly_system-design/security/ly04ly_sso-intro/","title":"sso单点登录","section":"安全","content":" 转载自https://github.com/Snailclimb/JavaGuide(添加小部分笔记)感谢作者!\n本文授权转载自 : https://ken.io/note/sso-design-implement 作者:ken.io\nSSO 介绍 # 什么是 SSO? # SSO 英文全称 Single Sign On,单点登录。SSO 是在多个应用系统中,用户只需要登录一次就可以访问所有相互信任的应用系统。\n例如你登录网易账号中心(https://reg.163.com/ )之后访问以下站点都是登录状态。\n网易直播 https://v.163.com 网易博客 https://blog.163.com 网易花田 https://love.163.com 网易考拉 https://www.kaola.com 网易 Lofter http://www.lofter.com SSO 有什么好处? # 用户角度 :用户能够做到一次登录多次使用,无需记录多套用户名和密码,省心。 系统管理员角度 : 管理员只需维护好一个统一的账号中心就可以了,方便。 新系统开发角度: 新系统开发时只需直接对接统一的账号中心即可,简化开发流程,省时。 SSO 设计与实现 # 本篇文章也主要是为了探讨如何设计\u0026amp;实现一个 SSO 系统\n以下为需要实现的核心功能:\n单点登录 单点登出 支持跨域单点登录 支持跨域单点登出 核心应用与依赖 # 应用/模块/对象 说明 前台站点 需要登录的站点 SSO 站点-登录 提供登录的页面 SSO 站点-登出 提供注销登录的入口 SSO 服务-登录 提供登录服务 SSO 服务-登录状态 提供登录状态校验/登录信息查询的服务 SSO 服务-登出 提供用户注销登录的服务 数据库 存储用户账户信息 缓存 存储用户的登录信息,通常使用 Redis 用户登录状态的存储与校验 # 常见的 Web 框架对于 Session 的实现都是生成一个 SessionId 存储在浏览器 Cookie 中。然后将 Session 内容存储在服务器端内存中,这个 ken.io 在之前 Session 工作原理中也提到过。整体也是借鉴这个思路。\n用户登录成功之后,生成 AuthToken 交给客户端保存。如果是浏览器,就保存在 Cookie 中。如果是手机 App 就保存在 App 本地缓存中。本篇主要探讨基于 Web 站点的 SSO。\n用户在浏览需要登录的页面时,客户端将 AuthToken 提交给 SSO 服务校验登录状态/获取用户登录信息\n对于登录信息的存储,建议采用 Redis,使用 Redis 集群来存储登录信息,既可以保证高可用,又可以线性扩充。同时也可以让 SSO 服务满足负载均衡/可伸缩的需求。\n对象 说明 AuthToken 直接使用 UUID/GUID 即可,如果有验证 AuthToken 合法性需求,可以将 UserName+时间戳加密生成,服务端解密之后验证合法性 登录信息 通常是将 UserId,UserName 缓存起来 用户登录/登录校验 # 登录时序图\n按照上图,用户登录后 AuthToken 保存在 Cookie 中。 domain=test.com 浏览器会将 domain 设置成 .test.com,\n这样访问所有 .test.com 的 web 站点,都会将 AuthToken 携带到服务器端*。 然后通过 SSO 服务,完成对用户状态的校验/用户登录信息的获取\n登录信息获取/登录状态校验\n用户登出 # 用户登出时要做的事情很简单:\n服务端清除缓存(Redis)中的登录状态 客户端清除存储的 AuthToken 登出时序图\n跨域登录、登出 # 前面提到过,核心思路是客户端存储 AuthToken,服务器端通过 Redis 存储登录信息。由于客户端是将 AuthToken 存储在 Cookie 中的。所以跨域要解决的问题,就是如何解决 Cookie 的跨域读写问题。\n解决跨域的核心思路就是:\n登录完成之后通过回调的方式,将 AuthToken 传递给主域名之外的站点,该站点自行将 AuthToken 保存在当前域下的 Cookie 中。 登出完成之后通过回调的方式,调用非主域名站点的登出页面,完成设置 Cookie 中的 AuthToken 过期的操作。(过期:先让主域名过期,再让非主域名过期[token失效]) 跨域登录(主域名已登录) 跨域登录(主域名未登录)\n跨域登出\n说明 # 关于方案 :这次设计方案更多是提供实现思路。如果涉及到 APP 用户登录等情况,在访问 SSO 服务时,增加对 APP 的签名验证就好了。当然,如果有无线网关,验证签名不是问题。 关于时序图:时序图中并没有包含所有场景,只列举了核心/主要场景,另外对于一些不影响理解思路的消息能省就省了。 "},{"id":253,"href":"/zh/docs/technology/Review/java_guide/lycly_system-design/security/ly03ly_jwt-advantages-disadvantages/","title":"jwt身份认证优缺点","section":"安全","content":" 转载自https://github.com/Snailclimb/JavaGuide(添加小部分笔记)感谢作者!\n在 JWT 基本概念详解这篇文章中,我介绍了:\n什么是 JWT? JWT 由哪些部分组成? 如何基于 JWT 进行身份验证? JWT 如何防止 Token 被篡改? 如何加强 JWT 的安全性? 这篇文章,我们一起探讨一下 JWT 身份认证的优缺点以及常见问题的解决办法。\nJWT 的优势 # 相比于 Session 认证的方式来说,使用 JWT 进行身份认证主要有下面 4 个优势。\n无状态 # JWT 自身包含了身份验证所需要的所有信息,因此,我们的服务器不需要存储 Session 信息。这显然增加了系统的可用性和伸缩性,大大减轻了服务端的压力。\n不过,也正是由于 JWT 的无状态,也导致了它最大的缺点:不可控!\n就比如说,我们想要在 JWT 有效期内废弃一个 JWT 或者更改它的权限的话,并不会立即生效,通常需要等到有效期过后才可以。再比如说,当用户 Logout 的话,JWT 也还有效。除非,我们在后端增加额外的处理逻辑比如将失效的 JWT 存储起来,后端先验证 JWT 是否有效再进行处理。具体的解决办法,我们会在后面的内容中详细介绍到,这里只是简单提一下。\n有效避免了 CSRF 攻击 # [ˈfɔːdʒəri] forgery 伪造\nCSRF(Cross Site Request Forgery) 一般被翻译为 跨站请求伪造,属于网络攻击领域范围。相比于 SQL 脚本注入、XSS 等安全攻击方式,CSRF 的知名度并没有它们高。但是,它的确是我们开发系统时必须要考虑的安全隐患。就连业内技术标杆 Google 的产品 Gmail 也曾在 2007 年的时候爆出过 CSRF 漏洞,这给 Gmail 的用户造成了很大的损失。\n那么究竟什么是跨站请求伪造呢? 简单来说就是用你的身份去做一些不好的事情(发送一些对你不友好的请求比如恶意转账)。\n举个简单的例子:小壮登录了某网上银行,他来到了网上银行的帖子区,看到一个帖子下面有一个链接写着“科学理财,年盈利率过万”,小壮好奇的点开了这个链接,结果发现自己的账户少了 10000 元。这是这么回事呢?原来黑客在链接中藏了一个请求,这个请求直接利用小壮的身份给银行发送了一个转账请求,也就是通过你的 Cookie 向银行发出请求。\n\u0026lt;a src=\u0026#34;http://www.mybank.com/Transfer?bankId=11\u0026amp;money=10000\u0026#34;\u0026gt;科学理财,年盈利率过万\u0026lt;/a\u0026gt; CSRF 攻击需要依赖 Cookie ,Session 认证中 Cookie 中的 SessionID 是由浏览器发送到服务端的,只要发出请求,Cookie 就会被携带。借助这个特性,即使黑客无法获取你的 SessionID,只要让你误点攻击链接,就可以达到攻击效果。\n另外,并不是必须点击链接才可以达到攻击效果,很多时候,只要你打开了某个页面,CSRF 攻击就会发生。\n\u0026lt;img src=\u0026#34;http://www.mybank.com/Transfer?bankId=11\u0026amp;money=10000\u0026#34; /\u0026gt; 那为什么 JWT 不会存在这种问题呢?\n一般情况下我们使用 JWT 的话,在我们登录成功获得 JWT 之后,一般会选择存放在 localStorage 中。前端的每一个请求后续都会附带上这个 JWT,整个过程压根不会涉及到 Cookie。因此,即使你点击了非法链接发送了请求到服务端,这个非法请求也是不会携带 JWT 的,所以这个请求将是非法的。\n总结来说就一句话:使用 JWT 进行身份验证不需要依赖 Cookie ,因此可以避免 CSRF 攻击。\n不过,这样也会存在 XSS 攻击的风险。为了避免 XSS 攻击,你可以选择将 JWT 存储在标记为httpOnly 的 Cookie 中。但是,这样又导致了你必须自己提供 CSRF 保护,因此,实际项目中我们通常也不会这么做。\nXSS攻击又称为跨站脚本,XSS的重点不在于跨站点,而是在于脚本的执行。XSS是一种经常出现在Web应用程序中的计算机安全漏洞,是由于Web应用程序对用户的输入过滤不足而产生的,它允许恶意web用户将代码植入到提供给其它用户使用的页面中。\n常见的避免 XSS 攻击的方式是过滤掉请求中存在 XSS 攻击风险的可疑字符串。\n在 Spring 项目中,我们一般是通过创建 XSS 过滤器来实现的。\n@Component @Order(Ordered.HIGHEST_PRECEDENCE) public class XSSFilter implements Filter { @Override public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain) throws IOException, ServletException { XSSRequestWrapper wrappedRequest = new XSSRequestWrapper((HttpServletRequest) request); chain.doFilter(wrappedRequest, response); } // other methods } 适合移动端应用 # 使用 Session 进行身份认证的话,需要保存一份信息在服务器端,而且这种方式会依赖到 Cookie(需要 Cookie 保存 SessionId),所以不适合移动端。\n但是,使用 JWT 进行身份认证就不会存在这种问题,因为只要 JWT 可以被客户端存储就能够使用,而且 JWT 还可以跨语言使用。\n单点登录友好 # 使用 Session 进行身份认证的话,实现单点登录,需要我们把用户的 Session 信息保存在一台电脑上,并且还会遇到常见的 Cookie 跨域的问题。但是,使用 JWT 进行认证的话, JWT 被保存在客户端,不会存在这些问题。\nJWT 身份认证常见问题及解决办法 # 注销登录等场景下 JWT 还有效 # 与之类似的具体相关场景有:\n退出登录; 修改密码; 服务端修改了某个用户具有的权限或者角色; 用户的帐户被封禁/删除; 用户被服务端强制注销; 用户被踢下线; \u0026hellip;\u0026hellip; 这个问题不存在于 Session 认证方式中,因为在 Session 认证方式中,遇到这种情况的话服务端删除对应的 Session 记录即可。但是,使用 JWT 认证的方式就不好解决了。我们也说过了,JWT 一旦派发出去,如果后端不增加其他逻辑的话,它在失效之前都是有效的。\n那我们如何解决这个问题呢?查阅了很多资料,我简单总结了下面 4 种方案:\n1、将 JWT 存入内存数据库\n将 JWT 存入 DB 中,Redis 内存数据库在这里是不错的选择。如果需要让某个 JWT 失效就直接从 Redis 中删除这个 JWT 即可。但是,这样会导致每次使用 JWT 发送请求都要先从 DB 中查询 JWT 是否存在的步骤,而且违背了 JWT 的无状态原则。\n2、黑名单机制\n和上面的方式类似,使用内存数据库比如 Redis 维护一个黑名单,如果想让某个 JWT 失效的话就直接将这个 JWT 加入到 黑名单 即可。然后,每次使用 JWT 进行请求的话都会先判断这个 JWT 是否存在于黑名单中。\n前两种方案的核心在于将有效的 JWT 存储起来或者将指定的 JWT 拉入黑名单。\n虽然这两种方案都违背了 JWT 的无状态原则,但是一般实际项目中我们通常还是会使用这两种方案。\n3、修改密钥 (Secret) :\n我们为每个用户都创建一个专属密钥,如果我们想让某个 JWT 失效,我们直接修改对应用户的密钥即可。但是,这样相比于前两种引入内存数据库带来了危害更大:\n如果服务是分布式的,则每次发出新的 JWT 时都必须在多台机器同步密钥。为此,你需要将密钥存储在数据库或其他外部服务中,这样和 Session 认证就没太大区别了。 如果用户同时在两个浏览器打开系统,或者在手机端也打开了系统,如果它从一个地方将账号退出,那么其他地方都要重新进行登录,这是不可取的。 4、保持令牌的有效期限短并经常轮换\n很简单的一种方式。但是,会导致用户登录状态不会被持久记录,而且需要用户经常登录。\n另外,对于修改密码后 JWT 还有效问题的解决还是比较容易的。说一种我觉得比较好的方式:使用用户的密码的哈希值对 JWT 进行签名。因此,如果密码更改,则任何先前的令牌将自动无法验证。\nJWT 的续签问题 # JWT 有效期一般都建议设置的不太长,那么 JWT 过期后如何认证,如何实现动态刷新 JWT,避免用户经常需要重新登录?\n我们先来看看在 Session 认证中一般的做法:假如 Session 的有效期 30 分钟,如果 30 分钟内用户有访问,就把 Session 有效期延长 30 分钟。\nJWT 认证的话,我们应该如何解决续签问题呢?查阅了很多资料,我简单总结了下面 4 种方案:\n1、类似于 Session 认证中的做法\n这种方案满足于大部分场景。假设服务端给的 JWT 有效期设置为 30 分钟,服务端每次进行校验时,如果发现 JWT 的有效期马上快过期了,服务端就重新生成 JWT 给客户端。客户端每次请求都检查新旧 JWT,如果不一致,则更新本地的 JWT。这种做法的问题是仅仅在快过期的时候请求才会更新 JWT ,对客户端不是很友好。\n2、每次请求都返回新 JWT\n这种方案的的思路很简单,但是,开销会比较大,尤其是在服务端要存储维护 JWT 的情况下。\n3、JWT 有效期设置到半夜\n这种方案是一种折衷的方案,保证了大部分用户白天可以正常登录,适用于对安全性要求不高的系统。\n4、用户登录返回两个 JWT\n第一个是 accessJWT ,它的过期时间 JWT 本身的过期时间比如半个小时,另外一个是 refreshJWT 它的过期时间更长一点比如为 1 天。客户端登录后,将 accessJWT 和 refreshJWT 保存在本地,每次访问将 accessJWT 传给服务端。服务端校验 accessJWT 的有效性,如果过期的话,就将 refreshJWT 传给服务端。如果有效,服务端就生成新的 accessJWT 给客户端。否则,客户端就重新登录即可。\n这种方案的不足是:\n需要客户端来配合;\n用户注销的时候需要同时保证两个 JWT 都无效;\n重新请求获取 JWT 的过程中会有短暂 JWT 不可用的情况(可以通过在客户端设置定时器,当 accessJWT 快过期的时候,提前去通过 refreshJWT 获取新的 accessJWT);\n这里说的短暂,就是accessJWT失效而refreshJWT成功的那种情况\n存在安全问题,只要拿到了未过期的 refreshJWT 就一直可以获取到 accessJWT。\n总结 # JWT 其中一个很重要的优势是无状态,但实际上,我们想要在实际项目中合理使用 JWT 的话,也还是需要保存 JWT 信息。\nJWT 也不是银弹,也有很多缺陷,具体是选择 JWT 还是 Session 方案还是要看项目的具体需求。万万不可尬吹 JWT,而看不起其他身份认证方案。\n另外,不用 JWT 直接使用普通的 Token(随机生成,不包含具体的信息) 结合 Redis 来做身份认证也是可以的。我在 「优质开源项目推荐」的第 8 期推荐过的 Sa-Token 这个项目是一个比较完善的 基于 JWT 的身份认证解决方案,支持自动续签、踢人下线、账号封禁、同端互斥登录等功能,感兴趣的朋友可以看看。\n参考 # JWT 超详细分析:https://learnku.com/articles/17883 How to log out when using JWT:https://medium.com/devgorilla/how-to-log-out-when-using-jwt-a8c7823e8a6 CSRF protection with JSON Web JWTs:https://medium.com/@agungsantoso/csrf-protection-with-json-web-JWTs-83e0f2fcbcc Invalidating JSON Web JWTs:https://stackoverflow.com/questions/21978658/invalidating-json-web-JWTs "},{"id":254,"href":"/zh/docs/technology/Review/java_guide/lycly_system-design/security/ly02ly_jwt-intro/","title":"jwt-intro","section":"安全","content":" 转载自https://github.com/Snailclimb/JavaGuide(添加小部分笔记)感谢作者!\n什么是 JWT? # JWT (JSON Web Token) 是目前最流行的跨域认证解决方案,是一种基于 Token 的认证授权机制。 从 JWT 的全称可以看出,JWT 本身也是 Token,一种规范化之后的 JSON 结构的 Token。\n跨域认证的问题\n互联网服务离不开用户认证。一般流程是下面这样。\n这种模式的问题在于,扩展性(scaling)不好。单机当然没有问题,如果是服务器集群,或者是跨域的服务导向架构,就要求 session 数据共享,每台服务器都能够读取 session。\n举例来说,A 网站和 B 网站是同一家公司的关联服务。现在要求,用户只要在其中一个网站登录,再访问另一个网站就会自动登录,请问怎么实现?\n一种解决方案是 session 数据持久化,写入数据库或别的持久层。各种服务收到请求后,都向持久层请求数据。这种方案的优点是架构清晰,缺点是工程量比较大。另外,持久层万一挂了,就会单点失败。\n另一种方案是服务器索性不保存 session 数据了,所有数据都保存在客户端,每次请求都发回服务器。JWT 就是这种方案的一个代表。\nJWT 自身包含了身份验证所需要的所有信息,因此,我们的服务器不需要存储 Session 信息。这显然增加了系统的可用性和伸缩性,大大减轻了服务端的压力。\nly:我觉得这里的重点就是,服务器不存储Session以维护\u0026quot;用户\u0026quot;和cookie(session id)的关系了\n可以看出,JWT 更符合设计 RESTful API 时的「Stateless(无状态)」原则 。\n并且, 使用 JWT 认证可以有效避免 CSRF 攻击,因为 JWT 一般是存在在 localStorage 中,使用 JWT 进行身份验证的过程中是不会涉及到 Cookie 的。\n我在 JWT 优缺点分析这篇文章中有详细介绍到使用 JWT 做身份认证的优势和劣势。\n下面是 RFC 7519 对 JWT 做的较为正式的定义。\nJSON Web Token (JWT) is a compact, URL-safe means of representing claims to be transferred between two parties. The claims in a JWT are encoded as a JSON object that is used as the payload of a JSON Web Signature (JWS) structure or as the plaintext of a JSON Web Encryption (JWE) structure, enabling the claims to be digitally signed or integrity protected with a Message Authentication Code (MAC) and/or encrypted. —— JSON Web Token (JWT)\nJWT 由哪些部分组成? # JWT 本质上就是一组字串,通过(.)切分成三个为 Base64 编码的部分:\nHeader : 描述 JWT 的元数据,定义了生成签名的算法以及 Token 的类型。 Payload : 用来存放实际需要传递的数据 Signature(签名) :服务器通过 Payload、Header 和**一个密钥(Secret)**使用 Header 里面指定的签名算法(默认是 HMAC SHA256)生成。 JWT 通常是这样的:xxxxx.yyyyy.zzzzz。\n示例:\neyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9. eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiaWF0IjoxNTE2MjM5MDIyfQ. SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c 你可以在 jwt.io 这个网站上对其 JWT 进行解码,解码之后得到的就是 Header、Payload、Signature 这三部分。\nHeader 和 Payload 都是 JSON 格式的数据,Signature 由 Payload、Header 和 **Secret(密钥)**通过特定的计算公式和加密算法得到。\nHeader # Header 通常由两部分组成:\ntyp(Type):令牌类型,也就是 JWT。 alg(Algorithm) :签名算法,比如 HS256。 示例:\n{ \u0026#34;alg\u0026#34;: \u0026#34;HS256\u0026#34;, \u0026#34;typ\u0026#34;: \u0026#34;JWT\u0026#34; } JSON 形式的 Header 被转换成 Base64 编码,成为 JWT 的第一部分。\nPayload # Payload 也是 JSON 格式数据,其中包含了 Claims(声明,包含 JWT 的相关信息)。\nClaims 分为三种类型:\nRegistered Claims(注册声明) :预定义的一些声明,建议使用,但不是强制性的。 Public Claims(公有声明) :JWT 签发方可以自定义的声明,但是为了避免冲突,应该在 IANA JSON Web Token Registry 中定义它们。 Private Claims(私有声明) :JWT 签发方因为项目需要而自定义的声明,更符合实际项目场景使用。 下面是一些常见的注册声明:\niss(issuer):JWT 签发方。 iat(issued at time):JWT 签发时间。 sub(subject):JWT 主题。 aud(audience):JWT 接收方。 exp(expiration time):JWT 的过期时间。 nbf(not before time):JWT 生效时间,早于该定义的时间的 JWT 不能被接受处理。 jti(JWT ID):JWT 唯一标识。 示例:\n{ \u0026#34;uid\u0026#34;: \u0026#34;ff1212f5-d8d1-4496-bf41-d2dda73de19a\u0026#34;, \u0026#34;sub\u0026#34;: \u0026#34;1234567890\u0026#34;, \u0026#34;name\u0026#34;: \u0026#34;John Doe\u0026#34;, \u0026#34;exp\u0026#34;: 15323232, \u0026#34;iat\u0026#34;: 1516239022, \u0026#34;scope\u0026#34;: [\u0026#34;admin\u0026#34;, \u0026#34;user\u0026#34;] } Payload 部分默认是不加密的,一定不要将隐私信息存放在 Payload 当中!!!\nJSON 形式的 Payload 被转换成 Base64 编码,成为 JWT 的第二部分。\nSignature # Signature 部分是对前两部分的签名,作用是防止 JWT(主要是 payload) 被篡改。\n这个签名的生成需要用到:\nHeader + Payload。 存放在服务端的密钥(一定不要泄露出去)。 签名算法。 签名的计算公式如下:\nHMACSHA256( base64UrlEncode(header) + \u0026#34;.\u0026#34; + base64UrlEncode(payload), secret) 算出签名以后,把 Header、Payload、Signature 三个部分拼成一个字符串,每个部分之间用\u0026quot;点\u0026quot;(.)分隔,这个字符串就是 JWT 。\n如何基于 JWT 进行身份验证? # 在基于 JWT 进行身份验证的的应用程序中,服务器通过 Payload、Header 和 Secret(密钥)创建 JWT 并将 JWT 发送给客户端。客户端接收到 JWT 之后,会将其保存在 Cookie 或者 localStorage 里面,以后客户端发出的所有请求都会携带这个令牌。\n简化后的步骤如下:\n用户向服务器发送用户名、密码以及验证码用于登陆系统。\n如果用户用户名、密码以及验证码校验正确的话,服务端会返回已经签名的 Token,也就是 JWT。\n注意,很重要,★★ JWT是服务器生成的!\n用户以后每次向后端发请求都在 Header 中带上这个 JWT 。\n服务端检查 JWT 并从中获取用户相关信息。\n两点建议:\n建议将 JWT 存放在 localStorage 中,放在 Cookie 中会有 CSRF 风险。 请求服务端并携带 JWT 的常见做法是将其放在 HTTP Header 的 Authorization 字段中(Authorization: Bearer Token)。 spring-security-jwt-guide 就是一个基于 JWT 来做身份认证的简单案例,感兴趣的可以看看。\n如何防止 JWT 被篡改? # 有了签名之后,即使 JWT 被泄露或者截获,黑客也没办法同时篡改 Signature 、Header 、Payload。\n这是为什么呢?因为服务端拿到 JWT 之后,会解析出其中包含的 Header、Payload 以及 Signature 。服务端会根据 Header、Payload、密钥再次生成一个 Signature。拿新生成的 Signature 和 JWT 中的 Signature 作对比,如果一样就说明 Header 和 Payload 没有被修改。\n不过,如果服务端的秘钥也被泄露的话,黑客就可以同时篡改 Signature 、Header 、Payload 了。黑客直接修改了 Header 和 Payload 之后,再重新生成一个 Signature 就可以了。\n密钥一定保管好,一定不要泄露出去。JWT 安全的核心在于签名,签名安全的核心在密钥。\n如何加强 JWT 的安全性? # 使用安全系数高的加密算法。 使用成熟的开源库,没必要造轮子。 JWT 存放在 localStorage 中而不是 Cookie 中,避免 CSRF 风险。 一定不要将隐私信息存放在 Payload 当中。 密钥一定保管好,一定不要泄露出去。JWT 安全的核心在于签名,签名安全的核心在密钥。 Payload 要加入 exp (JWT 的过期时间),永久有效的 JWT 不合理。并且,JWT 的过期时间不易过长。 \u0026hellip;\u0026hellip; "},{"id":255,"href":"/zh/docs/technology/Review/java_guide/lycly_system-design/security/ly01ly_basis-of-authority-certification/","title":"认证授权基础概念详解","section":"安全","content":" 转载自https://github.com/Snailclimb/JavaGuide(添加小部分笔记)感谢作者!\n认证 (Authentication) 和授权 (Authorization)的区别是什么? # 这是一个绝大多数人都会混淆的问题。首先先从读音上来认识这两个名词,很多人都会把它俩的读音搞混,所以我建议你先先去查一查这两个单词到底该怎么读,他们的具体含义是什么。\n说简单点就是:\n认证 (Authentication): 你是谁。[ɔːˌθentɪˈkeɪʃn] 身份验证 授权 (Authorization): 你有权限干什么。[ˌɔːθəraɪˈzeɪʃn] 授权 稍微正式点(啰嗦点)的说法就是 :\nAuthentication(认证) 是验证您的身份的凭据(例如用户名/用户 ID 和密码),通过这个凭据,系统得以知道你就是你,也就是说系统存在你这个用户。所以,Authentication 被称为身份/用户验证。 Authorization(授权) 发生在 Authentication(认证) 之后。授权嘛,光看意思大家应该就明白,它主要掌管我们访问系统的权限。比如有些特定资源只能具有特定权限的人才能访问比如 admin,有些对系统资源操作比如删除、添加、更新只能特定人才具有。 认证 :\n授权:\n这两个一般在我们的系统中被结合在一起使用,目的就是为了保护我们系统的安全性。\nRBAC 模型了解吗? # 系统权限控制最常采用的访问控制模型就是 RBAC 模型 。\n什么是 RBAC 呢?\nRBAC 即基于角色的权限访问控制(Role-Based Access Control)。这是一种通过角色关联权限,角色同时又关联用户的授权的方式。\n简单地说:一个用户可以拥有若干角色,每一个角色又可以被分配若干权限,这样就构造成“用户-角色-权限” 的授权模型。在这种模型中,用户与角色、角色与权限之间构成了多对多的关系,如下图\n在 RBAC 中,权限与角色相关联,用户通过成为适当角色的成员而得到这些角色的权限。这就极大地简化了权限的管理。\n本系统的权限设计相关的表如下(一共 5 张表,2 张用户建立表之间的联系):\n通过这个权限模型,我们可以创建不同的角色并为不同的角色分配不同的权限范围(菜单)。\n通常来说,如果系统对于权限控制要求比较严格的话,一般都会选择使用 RBAC 模型来做权限控制。\n什么是 Cookie ? Cookie 的作用是什么? # ly:如上,可以看出 cookie的附属是域名\nCookie 和 Session 都是用来跟踪浏览器用户身份的会话方式,但是两者的应用场景不太一样。\n维基百科是这样定义 Cookie 的:\nCookies 是某些网站为了辨别用户身份而储存在用户本地终端上的数据(通常经过加密)。\n简单来说: Cookie 存放在客户端,一般用来保存用户信息。\n下面是 Cookie 的一些应用案例:\n我们在 Cookie 中保存已经登录过的用户信息,下次访问网站的时候页面可以自动帮你登录的一些基本信息给填了。除此之外,Cookie 还能保存用户首选项,主题和其他设置信息。\n使用 Cookie 保存 SessionId 或者 Token ,向后端发送请求的时候带上 Cookie,这样后端就能取到 Session 或者 Token 了。这样就能记录用户当前的状态了,因为 HTTP 协议是无状态的。\n无状态的意思就是通过http发的多次请求,没有特殊处理的话我们是不知道是否是同一个用户发的,也就是没有用户状态。\nCookie 还可以用来记录和分析用户行为。举个简单的例子你在网上购物的时候,因为 HTTP 协议是没有状态的,如果服务器想要获取你在某个页面的停留状态或者看了哪些商品,一种常用的实现方式就是将这些信息存放在 Cookie\n\u0026hellip;\u0026hellip;\n如何在项目中使用 Cookie 呢? # 我这里以 Spring Boot 项目为例。\n1)设置 Cookie 返回给客户端\n@GetMapping(\u0026#34;/change-username\u0026#34;) public String setCookie(HttpServletResponse response) { // 创建一个 cookie Cookie cookie = new Cookie(\u0026#34;username\u0026#34;, \u0026#34;Jovan\u0026#34;); //设置 cookie过期时间 cookie.setMaxAge(7 * 24 * 60 * 60); // expires in 7 days //添加到 response 中 response.addCookie(cookie); return \u0026#34;Username is changed!\u0026#34;; } 2) 使用 Spring 框架提供的 @CookieValue 注解获取特定的 cookie 的值\n@GetMapping(\u0026#34;/\u0026#34;) public String readCookie(@CookieValue(value = \u0026#34;username\u0026#34;, defaultValue = \u0026#34;Atta\u0026#34;) String username) { return \u0026#34;Hey! My username is \u0026#34; + username; } 3) 读取所有的 Cookie 值\n@GetMapping(\u0026#34;/all-cookies\u0026#34;) public String readAllCookies(HttpServletRequest request) { Cookie[] cookies = request.getCookies(); if (cookies != null) { return Arrays.stream(cookies) .map(c -\u0026gt; c.getName() + \u0026#34;=\u0026#34; + c.getValue()).collect(Collectors.joining(\u0026#34;, \u0026#34;)); } return \u0026#34;No cookies\u0026#34;; } 更多关于如何在 Spring Boot 中使用 Cookie 的内容可以查看这篇文章: How to use cookies in Spring Boot 。\nCookie 和 Session 有什么区别? # Session 的主要作用就是通过服务端记录用户的状态。 典型的场景是购物车,当你要添加商品到购物车的时候,★★重要:系统不知道是哪个用户操作的,因为 HTTP 协议是无状态的。服务端给特定的用户创建特定的 Session 之后就可以标识这个用户并且跟踪这个用户了。\nCookie 数据保存在客户端(浏览器端),Session 数据保存在服务器端。相对来说 Session 安全性更高。如果使用 Cookie 的一些敏感信息不要写入 Cookie 中,最好能将 Cookie 信息加密然后使用到的时候再去服务器端解密。\n那么,如何使用 Session 进行身份验证?\n如何使用 Session-Cookie 方案进行身份验证? # 很多时候我们都是通过 SessionID 来实现特定的用户,SessionID 一般会选择存放在 Redis 中。举个例子:\n用户成功登陆系统,然后返回给客户端具有 SessionID 的 Cookie 。 当用户向后端发起请求的时候会把 SessionID 带上,这样后端就知道你的身份状态了。 关于这种认证方式更详细的过程如下:\n用户向服务器发送用户名、密码、验证码用于登陆系统。 服务器验证通过后,服务器为用户创建一个 Session,并将 Session 信息存储起来(一般是Redis)。 服务器向用户返回一个 SessionID,写入用户的 Cookie。 当用户保持登录状态时,Cookie 将与每个后续请求一起被发送出去。 服务器可以将存储在 Cookie 上的 SessionID 与存储在内存中或者数据库中的 Session 信息进行比较,以验证用户的身份,返回给用户客户端响应信息的时候会附带用户当前的状态。 使用 Session 的时候需要注意下面几个点:\n依赖 Session 的关键业务一定要确保客户端开启了 Cookie。 注意 Session 的过期时间。 另外,Spring Session 提供了一种跨多个应用程序或实例管理用户会话信息的机制。如果想详细了解可以查看下面几篇很不错的文章:\nGetting Started with Spring Session Guide to Spring Session Sticky Sessions with Spring Session \u0026amp; Redis 多服务器节点下 Session-Cookie 方案如何做? # Session-Cookie 方案在单体环境是一个非常好的身份认证方案。但是,当服务器水平拓展成多节点时,Session-Cookie 方案就要面临挑战了。\n举个例子:假如我们部署了两份相同的服务 A,B,用户第一次登陆的时候 ,Nginx 通过负载均衡机制将用户请求转发到 A 服务器,此时用户的 Session 信息保存在 A 服务器。结果,用户第二次访问的时候 Nginx 将请求路由到 B 服务器,由于 B 服务器没有保存 用户的 Session 信息,导致用户需要重新进行登陆。\n我们应该如何避免上面这种情况的出现呢?\n有几个方案可供大家参考:\n某个用户的所有请求都通过特性的哈希策略分配给同一个服务器处理。这样的话,每个服务器都保存了一部分用户的 Session 信息。服务器宕机,其保存的所有 Session 信息就完全丢失了。 每一个服务器保存的 Session 信息都是互相同步的,也就是说每一个服务器都保存了全量的 Session 信息。每当一个服务器的 Session 信息发生变化,我们就将其同步到其他服务器。这种方案成本太大,并且,节点越多时,同步成本也越高。 单独使用一个所有服务器都能访问到的数据节点(比如缓存)来存放 Session 信息。为了保证高可用,数据节点尽量要避免是单点。 如果没有 Cookie 的话 Session 还能用吗? # 这是一道经典的面试题!\n一般是通过 Cookie 来保存 SessionID ,假如你使用了 Cookie 保存 SessionID 的方案的话, 如果客户端禁用了 Cookie,那么 Session 就无法正常工作。\n但是,并不是没有 Cookie 之后就不能用 Session 了,比如你可以将 SessionID 放在请求的 url 里面https://javaguide.cn/?Session_id=xxx 。这种方案的话可行,但是安全性和用户体验感降低。当然,为了你也可以对 SessionID 进行一次加密之后再传入后端。\n个人觉得localstorage也是可以的\n为什么 Cookie 无法防止 CSRF 攻击,而 Token 可以? # CSRF(Cross Site Request Forgery) 一般被翻译为 跨站请求伪造 。那么什么是 跨站请求伪造 呢?说简单用你的身份去发送一些对你不友好的请求。举个简单的例子:\n小壮登录了某网上银行,他来到了网上银行的帖子区,看到一个帖子下面有一个链接写着“科学理财,年盈利率过万”,小壮好奇的点开了这个链接,结果发现自己的账户少了 10000 元。这是这么回事呢?原来黑客在链接中藏了一个请求,这个请求直接利用小壮的身份给银行发送了一个转账请求,也就是通过你的 Cookie 向银行发出请求。\n\u0026lt;a src=http://www.mybank.com/Transfer?bankId=11\u0026amp;money=10000\u0026gt;科学理财,年盈利率过万\u0026lt;/\u0026gt; 上面也提到过,进行 Session 认证的时候,我们一般使用 Cookie 来存储 SessionId,当我们登陆后后端生成一个 SessionId 放在 Cookie 中返回给客户端,服务端通过 Redis 或者其他存储工具记录保存着这个 SessionId,客户端登录以后每次请求都会带上这个 SessionId,服务端通过这个 SessionId 来标示你这个人。如果别人通过 Cookie 拿到了 SessionId 后就可以代替你的身份访问系统了。\nSession 认证中 Cookie 中的 SessionId 是由浏览器发送到服务端的,借助这个特性,攻击者就可以通过让用户误点攻击链接,达到攻击效果。\n但是,我们使用 Token 的话就不会存在这个问题,在我们登录成功获得 Token 之后,一般会选择存放在 localStorage (浏览器本地存储)中。然后我们在前端通过某些方式会给每个发到后端的请求加上这个 Token,这样就不会出现 CSRF 漏洞的问题。因为,即使有个你点击了非法链接发送了请求到服务端,这个非法请求是不会携带 Token 的,所以这个请求将是非法的。\n需要注意的是:不论是 Cookie 还是 Token 都无法避免 跨站脚本攻击(Cross Site Scripting)XSS 。\n跨站脚本攻击(Cross Site Scripting)缩写为 CSS 但这会与层叠样式表(Cascading Style Sheets,CSS)的缩写混淆。因此,有人将跨站脚本攻击缩写为 XSS。\nXSS 中攻击者会用各种方式将恶意代码注入到其他用户的页面中。就可以通过脚本盗用信息比如 Cookie 。\n推荐阅读: 如何防止 CSRF 攻击?—美团技术团队\n什么是 JWT?JWT 由哪些部分组成? # JWT 基础概念详解\n如何基于 JWT 进行身份验证? 如何防止 JWT 被篡改? # JWT 基础概念详解\n什么是 SSO? # SSO(Single Sign On)即单点登录说的是用户登陆多个子系统的其中一个就有权访问与其相关的其他系统。举个例子我们在登陆了京东金融之后,我们同时也成功登陆京东的京东超市、京东国际、京东生鲜等子系统。\nSSO 有什么好处? # 用户角度 :用户能够做到一次登录多次使用,无需记录多套用户名和密码,省心。 系统管理员角度 : 管理员只需维护好一个统一的账号中心就可以了,方便。 新系统开发角度: 新系统开发时只需直接对接统一的账号中心即可,简化开发流程,省时。 如何设计实现一个 SSO 系统? # SSO 单点登录详解\n什么是 OAuth 2.0? # OAuth 是一个行业的标准授权协议,主要用来授权第三方应用获取有限的权限。而 OAuth 2.0 是对 OAuth 1.0 的完全重新设计,OAuth 2.0 更快,更容易实现,OAuth 1.0 已经被废弃。详情请见: rfc6749。\n实际上它就是一种授权机制,它的最终目的是为第三方应用颁发一个有时效性的令牌 Token,使得第三方应用能够通过该令牌获取相关的资源。\nOAuth 2.0 比较常用的场景就是第三方登录,当你的网站接入了第三方登录的时候一般就是使用的 OAuth 2.0 协议。\n另外,现在 OAuth 2.0 也常见于支付场景(微信支付、支付宝支付)和开发平台(微信开放平台、阿里开放平台等等)。\n下图是 Slack OAuth 2.0 第三方登录的示意图:\n推荐阅读:\nOAuth 2.0 的一个简单解释 10 分钟理解什么是 OAuth 2.0 协议 OAuth 2.0 的四种方式 GitHub OAuth 第三方登录示例教程 参考 # 不要用 JWT 替代 session 管理(上):全面了解 Token,JWT,OAuth,SAML,SSO:https://zhuanlan.zhihu.com/p/38942172 Introduction to JSON Web Tokens:https://jwt.io/introduction JSON Web Token Claims:https://auth0.com/docs/secure/tokens/json-web-tokens/json-web-token-claims "},{"id":256,"href":"/zh/docs/technology/Review/java_guide/lycly_system-design/basis/unit-test/","title":"单元测试","section":"基础","content":" 转载自https://github.com/Snailclimb/JavaGuide(添加小部分笔记)感谢作者!\n何谓单元测试? # 维基百科是这样介绍单元测试的:\n在计算机编程中,单元测试(Unit Testing)是针对程序模块(软件设计的最小单位)进行的正确性检验测试工作。\n程序单元是应用的 最小可测试部件 。在过程化编程中,一个单元就是单个程序、函数、过程等;对于面向对象编程,最小单元就是方法,包括基类(超类)、抽象类、或者派生类(子类)中的方法。\n由于每个单元有独立的逻辑,在做单元测试时,为了隔离外部依赖,确保这些依赖不影响验证逻辑,我们经常会用到 Fake、Stub 与 Mock 。\n关于 Fake、Mock 与 Stub 这几个概念的解读,可以看看这篇文章: 测试中 Fakes、Mocks 以及 Stubs 概念明晰 - 王下邀月熊 - 2018 。\n为什么需要单元测试? # 为重构保驾护航 # 我在 重构这篇文章中这样写到:\n单元测试可以为重构提供信心,降低重构的成本。我们要像重视生产代码那样,重视单元测试。\n每个开发者都会经历重构,重构后把代码改坏了的情况并不少见,很可能你只是修改了一个很简单的方法就导致系统出现了一个比较严重的错误。\n如果有了单元测试的话,就不会存在这个隐患了。写完一个类,把单元测试写了,确保这个类逻辑正确;写第二个类,单元测试\u0026hellip;..写 100 个类,道理一样,每个类做到第一点“保证逻辑正确性”,100 个类拼在一起肯定不出问题。你大可以放心一边重构,一边运行 APP;而不是整体重构完,提心吊胆地 run。\n提高代码质量 # 由于每个单元有独立的逻辑,做单元测试时需要隔离外部依赖,确保这些依赖不影响验证逻辑。因为要把各种依赖分离,单元测试会促进工程进行组件拆分,整理工程依赖关系,更大程度减少代码耦合。这样写出来的代码,更好维护,更好扩展,从而提高代码质量。\n减少 bug # 一个机器,由各种细小的零件组成,如果其中某件零件坏了,机器运行故障。必须保证每个零件都按设计图要求的规格,机器才能正常运行。\n一个可单元测试的工程,会把业务、功能分割成规模更小、有独立的逻辑部件,称为单元。单元测试的目标,就是保证各个单元的逻辑正确性。单元测试保障工程各个“零件”按“规格”(需求)执行,从而保证整个“机器”(项目)运行正确,最大限度减少 bug。\n快速定位 bug # 如果程序有 bug,我们运行一次全部单元测试,找到不通过的测试,可以很快地定位对应的执行代码。修复代码后,运行对应的单元测试;如还不通过,继续修改,运行测试\u0026hellip;..直到测试通过。\n持续集成依赖单元测试 # 持续集成需要依赖单元测试,当持续集成服务自动构建新代码之后,会自动运行单元测试来发现代码错误。\n谁逼你写单元测试? # 领导要求 # 有些经验丰富的领导,或多或少都会要求团队写单元测试。对于有一定工作经验的队友,这要求挺合理;对于经验尚浅的、毕业生,恐怕要死要活了,连代码都写不好,还要写单元测试,are you kidding me?\n培训新人单元测试用法,是一项艰巨的任务。新人代码风格未形成,也不知道单元测试多重要,强制单元测试会让他们感到困惑,没办法按自己思路写代码。\n大牛都写单元测试 # 国外很多家喻户晓的开源项目,都有大量单元测试。例如, retrofit、 okhttp、 butterknife\u0026hellip;. 国外大牛都写单元测试,我们也写吧!\n很多读者都有这种想法,一开始满腔热血。当真要对自己项目单元测试时,便困难重重,很大原因是项目对单元测试不友好。最后只能对一些不痛不痒的工具类做单元测试,久而久之,当初美好愿望也不了了之。\n保住面子 # 都是有些许年经验的老鸟,还天天被测试同学追 bug,好意思么?花多一点时间写单元测试,确保没低级 bug,还能彰显大牛风范,何乐而不为?\n心虚 # 笔者也是个不太相信自己代码的人,总觉得哪里会突然冒出莫名其妙的 bug,也怕别人不小心改了自己的代码(被害妄想症),新版本上线提心吊胆\u0026hellip;\u0026hellip;花点时间写单元测试,有事没事跑一下测试,确保原逻辑没问题,至少能睡安稳一点。\nTDD 测试驱动开发 # 何谓 TDD? # TDD 即 Test-Driven Development( 测试驱动开发),这是敏捷开发的一项核心实践和技术,也是一种设计方法论。\nTDD 原理是开发功能代码之前,先编写测试用例代码,然后针对测试用例编写功能代码,使其能够通过。\nTDD 的节奏:“红 - 绿 - 重构”。\n由于 TDD 对开发人员要求非常高,跟传统开发思维不一样,因此实施起来相当困难。\nTDD 在很多人眼中是不实用的,一来他们并不理解测试“驱动”开发的含义,但更重要的是,他们很少会做任务分解。而任务分解是做好 TDD 的关键点。只有把任务分解到可以测试的地步,才能够有针对性地写测试。\nTDD 优缺点分析 # 测试驱动开发有好处也有坏处。因为每个测试用例都是根据需求来的,或者说把一个大需求分解成若干小需求编写测试用例,所以测试用例写出来后,开发者写的执行代码,必须满足测试用例。如果测试不通过,则修改执行代码,直到测试用例通过。\n优点 :\n帮你整理需求,梳理思路; 帮你设计出更合理的接口(空想的话很容易设计出屎); 减小代码出现 bug 的概率; 提高开发效率(前提是正确且熟练使用 TDD)。 缺点 :\n能用好 TDD 的人非常少,看似简单,实则门槛很高; 投入开发资源(时间和精力)通常会更多; 由于测试用例在未进行代码设计前写;很有可能限制开发者对代码整体设计; 可能引起开发人员不满情绪,我觉得这点很严重,毕竟不是人人都喜欢单元测试,尽管单元测试会带给我们相当多的好处。 相关阅读: 如何用正确的姿势打开 TDD? - 陈天 - 2017 。\n总结 # 单元测试确实会带给你相当多的好处,但不是立刻体验出来。正如买重疾保险,交了很多保费,没病没痛,十几年甚至几十年都用不上,最好就是一辈子用不上理赔,身体健康最重要。单元测试也一样,写了可以买个放心,对代码的一种保障,有 bug 尽快测出来,没 bug 就最好,总不能说“写那么多单元测试,结果测不出 bug,浪费时间”吧?\n以下是个人对单元测试一些建议:\n越重要的代码,越要写单元测试; 代码做不到单元测试,多思考如何改进,而不是放弃; 边写业务代码,边写单元测试,而不是完成整个新功能后再写; 多思考如何改进、简化测试代码。 测试代码需要随着生产代码的演进而重构或者修改,如果测试不能保持整洁,只会越来越难修改。 作为一名经验丰富的程序员,写单元测试更多的是对自己的代码负责。有测试用例的代码,别人更容易看懂,以后别人接手你的代码时,也可能放心做改动。\n多敲代码实践,多跟有单元测试经验的工程师交流,你会发现写单元测试获得的收益会更多。\n"},{"id":257,"href":"/zh/docs/technology/Review/java_guide/lycly_system-design/basis/refactoring/","title":"代码重构指南","section":"基础","content":" 转载自https://github.com/Snailclimb/JavaGuide(添加小部分笔记)感谢作者!\n前段时间重读了 《重构:改善代码既有设计》,收货颇多。于是,简单写了一篇文章来聊聊我对重构的看法。\n何谓重构? # 学习重构必看的一本神书《重构:改善代码既有设计》从两个角度给出了重构的定义:\n重构(名词):对软件内部结构的一种调整,目的是在不改变软件可观察行为的前提下,提高其可理解性,降低其修改成本。 重构(动词):使用一系列重构手法,在不改变软件可观察行为的前提下,调整其结构。 用更贴近工程师的语言来说: 重构就是利用设计模式(如组合模式、策略模式、责任链模式)、软件设计原则(如 SOLID 原则、YAGNI 原则、KISS 原则)和重构手段(如封装、继承、构建测试体系)来让代码更容易理解,更易于修改。\n软件设计原则指导着我们组织和规范代码,同时,重构也是为了能够尽量设计出尽量满足软件设计原则的软件。\n正确重构的核心在于 步子一定要小,每一步的重构都不会影响软件的正常运行,可以随时停止重构。\n常见的设计模式如下 :\n更全面的设计模式总结,可以看 java-design-patterns 这个开源项目。\n常见的软件设计原则如下 :\n更全面的设计原则总结,可以看 java-design-patterns 和 hacker-laws-zh 这两个开源项目。\n为什么要重构? # 在上面介绍重构定义的时候,我从比较抽象的角度介绍了重构的好处:重构的主要目的主要是提升代码\u0026amp;架构的灵活性/可扩展性以及复用性。\n如果对应到一个真实的项目,重构具体能为我们带来什么好处呢?\n让代码更容易理解 : 通过添加注释、命名规范、逻辑优化等手段可以让我们的代码更容易被理解; 避免代码腐化 :通过重构干掉坏味道代码; 加深对代码的理解 :重构代码的过程会加深你对某部分代码的理解; 发现潜在 bug :是这样的,很多潜在的 bug ,都是我们在重构的过程中发现的; \u0026hellip;\u0026hellip; 看了上面介绍的关于重构带来的好处之后,你会发现重构的最终目标是 提高软件开发速度和质量 。\n重构并不会减慢软件开发速度,相反,如果代码质量和软件设计较差,当我们想要添加新功能的话,开发速度会越来越慢。到了最后,甚至都有想要重写整个系统的冲动。\n[\n《重构:改善代码既有设计》这本书中这样说:\n重构的唯一目的就是让我们开发更快,用更少的工作量创造更大的价值。\n何时进行重构? # 重构在是开发过程中随时可以进行的,见机行事即可,并不需要单独分配一两天的时间专门用来重构。\n提交代码之前 # 《重构:改善代码既有设计》这本书介绍了一个 营地法则 的概念:\n编程时,需要遵循营地法则:保证你离开时的代码库一定比来时更健康。\n这个概念表达的核心思想其实很简单:在你提交代码的之前,花一会时间想一想,我这次的提交是让项目代码变得更健康了,还是更腐化了,或者说没什么变化?\n项目团队的每一个人只有保证自己的提交没有让项目代码变得更腐化,项目代码才会朝着健康的方向发展。\n当我们离开营地(项目代码)的时候,请不要留下垃圾(代码坏味道)!尽量确保营地变得更干净了!\n开发一个新功能之后\u0026amp;之前 # 在开发一个新功能之后,我们应该回过头看看是不是有可以改进的地方。在添加一个新功能之前,我们可以思考一下自己是否可以重构代码以让新功能的开发更容易。\n一个新功能的开发不应该仅仅只有功能验证通过那么简单,我们还应该尽量保证代码质量。\n有一个两顶帽子的比喻:在我开发新功能之前,我发现重构可以让新功能的开发更容易,于是我戴上了重构的帽子。重构之后,我换回原来的帽子,继续开发新能功能。新功能开发完成之后,我又发现自己的代码难以理解,于是我又戴上了重构帽子。比较好的开发状态就是就是这样在重构和开发新功能之间来回切换。\nCode Review 之后 # Code Review 可以非常有效提高代码的整体质量,它会帮助我们发现代码中的坏味道以及可能存在问题的地方。并且, Code Review 可以帮助项目团队其他程序员理解你负责的业务模块,有效避免人员方面的单点风险。\n经历一次 Code Review ,你的代码可能会收到很多改进建议。\n捡垃圾式重构 # 当我们发现坏味道代码(垃圾)的时候,如果我们不想停下手头自己正在做的工作,但又不想放着垃圾不管,我们可以这样做:\n如果这个垃圾很容易重构的话,我们可以立即重构它。 如果这个垃圾不太容易重构的话,我们可以先记录下来,当****重构它。 阅读理解代码的时候 # 搞开发的小伙伴应该非常有体会:我们经常需要阅读项目团队中其他人写的代码,也经常需要阅读自己过去写的代码。阅读代码的时候,通常要比我们写代码的时间还要多很多。\n我们在阅读理解代码的时候,如果发现一些坏味道的话,我们就可以对其进行重构。\n就比如说你在阅读张三写的某段代码的时候,你发现这段代码逻辑过于复杂难以理解,你有更好的写法,那你就可以对张三的这段代码逻辑进行重构。\n重构有哪些注意事项? # 单元测试是重构的保护网 # 单元测试可以为重构提供信心,降低重构的成本。我们要像重视生产代码那样,重视单元测试。\n另外,多提一句:持续集成也要依赖单元测试,当持续集成服务自动构建新代码之后,会自动运行单元测试来发现代码错误。\n怎样才能算单元测试呢? 网上的定义很多,很抽象,很容易把人给看迷糊了。我觉得对于单元测试的定义主要取决于你的项目,一个函数甚至是一个类都可以看作是一个单元。就比如说我们写了一个计算个人股票收益率的方法,我们为了验证它的正确性专门为它写了一个单元测试。再比如说我们代码有一个类专门负责数据脱敏,我们为了验证脱敏是否符合预期专门为这个类写了一个单元测试。\n单元测试也是需要重构或者修改的。 《代码整洁之道:敏捷软件开发手册》这本书这样写到:\n测试代码需要随着生产代码的演进而修改,如果测试不能保持整洁,只会越来越难修改。\n不要为了重构而重构 # 重构一定是要为项目带来价值的! 某些情况下我们不应该进行重构:\n学习了某个设计模式/工程实践之后,不顾项目实际情况,刻意使用在项目上(避免货物崇拜编程); 项目进展比较急的时候,重构项目调用的某个 API 的底层代码(重构之后对项目调用这个 API 并没有带来什么价值); 重写比重构更容易更省事; \u0026hellip;\u0026hellip; 遵循方法 # 《重构:改善代码既有设计》这本书中列举除了代码常见的一些坏味道(比如重复代码、过长函数)和重构手段(如提炼函数、提炼变量、提炼类)。我们应该花时间去学习这些重构相关的理论知识,并在代码中去实践这些重构理论。\n如何练习重构? # 除了可以在重构项目代码的过程中练习精进重构之外,你还可以有下面这些手段:\n重构实战练习 :通过几个小案例一步一步带你学习重构! 设计模式+重构学习网站 :免费在线学习代码重构、 设计模式、 SOLID 原则 (单一职责、 开闭原则、 里氏替换、 接口隔离以及依赖反转) 。 IDEA 官方文档的代码重构教程 : 教你如何使用 IDEA 进行重构。 参考 # 再读《重构》- ThoughtWorks 洞见 - 2020 :详细介绍了重构的要点比如小步重构、捡垃圾式的重构,主要是重构概念相关的介绍。 常见代码重构技巧 - VectorJin - 2021 :从软件设计原则、设计模式、代码分层、命名规范等角度介绍了如何进行重构,比较偏实战。 "},{"id":258,"href":"/zh/docs/technology/Review/java_guide/lycly_system-design/basis/naming/","title":"代码命名指南","section":"基础","content":" 转载自https://github.com/Snailclimb/JavaGuide(添加小部分笔记)感谢作者!\n我还记得我刚工作那一段时间, 项目 Code Review 的时候,我经常因为变量命名不规范而被 “diss”!\n究其原因还是自己那会经验不足,而且,大学那会写项目的时候不太注意这些问题,想着只要把功能实现出来就行了。\n但是,工作中就不一样,为了代码的可读性、可维护性,项目组对于代码质量的要求还是很高的!\n前段时间,项目组新来的一个实习生也经常在 Code Review 因为变量命名不规范而被 “diss”,这让我想到自己刚到公司写代码那会的日子。\n于是,我就简单写了这篇关于变量命名规范的文章,希望能对同样有此困扰的小伙伴提供一些帮助。\n确实,编程过程中,有太多太多让我们头疼的事情了,比如命名、维护其他人的代码、写测试、与其他人沟通交流等等。\n据说之前在 Quora 网站,由接近 5000 名程序员票选出来的最难的事情就是“命名”。\n大名鼎鼎的《重构》的作者老马(Martin Fowler)曾经在 TwoHardThings这篇文章中提到过CS 领域有两大最难的事情:一是 缓存失效 ,一是 程序命名 。\n这个句话实际上也是老马引用别人的,类似的表达还有很多。比如分布式系统领域有两大最难的事情:一是 保证消息顺序 ,一是 严格一次传递 。\n今天咱们就单独拎出 “命名” 来聊聊!\n这篇文章配合我之前发的 《编码 5 分钟,命名 2 小时?史上最全的 Java 命名规范参考!》 这篇文章阅读效果更佳哦!\n为什么需要重视命名? # 咱们需要先搞懂为什么要重视编程中的命名这一行为,它对于我们的编码工作有着什么意义。\n为什么命名很重要呢? 这是因为 好的命名即是注释,别人一看到你的命名就知道你的变量、方法或者类是做什么的!\n简单来说就是 别人根据你的命名就能知道你的代码要表达的意思 (不过,前提这个人也要有基本的英语知识,对于一些编程中常见的单词比较熟悉)。\n简单举个例子说明一下命名的重要性。\n《Clean Code》这本书明确指出:\n好的代码本身就是注释,我们要尽量规范和美化自己的代码来减少不必要的注释。\n若编程语言足够有表达力,就不需要注释,尽量通过代码来阐述。\n举个例子:\n去掉下面复杂的注释,只需要创建一个与注释所言同一事物的函数即可\n// check to see if the employee is eligible for full benefits if ((employee.flags \u0026amp; HOURLY_FLAG) \u0026amp;\u0026amp; (employee.age \u0026gt; 65)) 应替换为\nif (employee.isEligibleForFullBenefits()) 常见命名规则以及适用场景 # 这里只介绍 3 种最常见的命名规范。\n驼峰命名法(CamelCase) # 驼峰命名法应该我们最常见的一个,这种命名方式使用大小写混合的格式来区别各个单词,并且单词之间不使用空格隔开或者连接字符连接的命名方式\n大驼峰命名法(UpperCamelCase) # 类名需要使用大驼峰命名法(UpperCamelCase)\n正例:\nServiceDiscovery、ServiceInstance、LruCacheFactory 反例:\nserviceDiscovery、Serviceinstance、LRUCacheFactory 小驼峰命名法(lowerCamelCase) # 方法名、参数名、成员变量、局部变量需要使用小驼峰命名法(lowerCamelCase)。\n正例:\ngetUserInfo() createCustomThreadPool() setNameFormat(String nameFormat) Uservice userService; 反例:\nGetUserInfo()、CreateCustomThreadPool()、setNameFormat(String NameFormat) Uservice user_service 蛇形命名法(snake_case) # 测试方法名、常量、枚举名称需要使用蛇形命名法(snake_case)\n在蛇形命名法中,各个单词之间通过**下划线“_”**连接,比如should_get_200_status_code_when_request_is_valid、CLIENT_CONNECT_SERVER_FAILURE。\n蛇形命名法的优势是命名所需要的单词比较多的时候,比如我把上面的命名通过小驼峰命名法给大家看一下:“shouldGet200StatusCodeWhenRequestIsValid”。\n感觉如何? 相比于使用蛇形命名法(snake_case)来说是不是不那么易读?\n正例:\n@Test void should_get_200_status_code_when_request_is_valid() { ...... } 反例:\n@Test void shouldGet200StatusCodeWhenRequestIsValid() { ...... } 串式命名法(kebab-case) # 在串式命名法中,各个单词之间通过连接符“-”连接,比如dubbo-registry。\n建议项目文件夹名称使用串式命名法(kebab-case),比如 dubbo 项目的各个模块的命名是下面这样的。\n常见命名规范 # Java 语言基本命名规范 # 1、类名需要使用大驼峰命名法(UpperCamelCase)风格。方法名、参数名、成员变量、局部变量需要使用小驼峰命名法(lowerCamelCase)。\n2、测试方法名、常量、枚举名称需要使用蛇形命名法(snake_case),比如should_get_200_status_code_when_request_is_valid、CLIENT_CONNECT_SERVER_FAILURE。并且,测试方法名称要求全部小写,常量以及枚举名称需要全部大写。\n3、项目文件夹名称使用串式命名法(kebab-case),比如dubbo-registry。\n4、包名统一使用小写,尽量使用单个名词作为包名,各个单词通过 \u0026ldquo;.\u0026rdquo; 分隔符连接,并且各个单词必须为单数。\n正例: org.apache.dubbo.common.threadlocal\n反例: org.apache_dubbo.Common.threadLocals\n5、抽象类命名使用 Abstract 开头。\n//为远程传输部分抽象出来的一个抽象类(出处:Dubbo源码) public abstract class AbstractClient extends AbstractEndpoint implements Client { } 6、异常类命名使用 Exception 结尾。\n//自定义的 NoSuchMethodException(出处:Dubbo源码) public class NoSuchMethodException extends RuntimeException { private static final long serialVersionUID = -2725364246023268766L; public NoSuchMethodException() { super(); } public NoSuchMethodException(String msg) { super(msg); } } 7、测试类命名以它要测试的类的名称开始,以 Test 结尾。\n//为 AnnotationUtils 类写的测试类(出处:Dubbo源码) public class AnnotationUtilsTest { ...... } POJO 类中布尔类型的变量,都不要加 is 前缀,否则部分框架解析会引起序列化错误。\n如果模块、接口、类、方法使用了设计模式,在命名时需体现出具体模式。\n命名易读性规范 # 1、为了能让命名更加易懂和易读,尽量不要缩写/简写单词,除非这些单词已经被公认可以被这样缩写/简写。比如 CustomThreadFactory 不可以被写成 ~~CustomTF 。\n2、命名不像函数一样要尽量追求短,可读性强的名字优先于简短的名字,虽然可读性强的名字会比较长一点。 这个对应我们上面说的第 1 点。\n3、避免无意义的命名,你起的每一个名字都要能表明意思。\n正例:UserService userService; int userCount;\n反例: UserService service int count\n4、避免命名过长(50 个字符以内最好),过长的命名难以阅读并且丑陋。\n5、不要使用拼音,更不要使用中文。 不过像 alibaba 、wuhan、taobao 这种国际通用名词可以当做英文来看待。\n正例:discount\n反例:dazhe\nCodelf:变量命名神器? # 这是一个由国人开发的网站,网上有很多人称其为变量命名神器, 我在实际使用了几天之后感觉没那么好用。小伙伴们可以自行体验一下,然后再给出自己的判断。\nCodelf 提供了在线网站版本,网址:https://unbug.github.io/codelf/,具体使用情况如下:\n我选择了 Java 编程语言,然后搜索了“序列化”这个关键词,然后它就返回了很多关于序列化的命名。\n并且,Codelf 还提供了 VS code 插件,看这个评价,看来大家还是很喜欢这款命名工具的。\n相关阅读推荐 # 《阿里巴巴 Java 开发手册》 《Clean Code》 Google Java 代码指南:https://google.github.io/styleguide/javaguide.html 告别编码5分钟,命名2小时!史上最全的Java命名规范参考:https://www.cnblogs.com/liqiangchn/p/12000361.html 总结 # 作为一个合格的程序员,小伙伴们应该都知道代码表义的重要性。想要写出高质量代码,好的命名就是第一步!\n好的命名对于其他人(包括你自己)理解你的代码有着很大的帮助!你的代码越容易被理解,可维护性就越强,侧面也就说明你的代码设计的也就越好!\n在日常编码过程中,我们需要谨记常见命名规范比如类名需要使用大驼峰命名法、不要使用拼音,更不要使用中文\u0026hellip;\u0026hellip;。\n另外,国人开发的一个叫做 Codelf 的网站被很多人称为“变量命名神器”,当你为命名而头疼的时候,你可以去参考一下上面提供的一些命名示例。\n最后,祝愿大家都不用再为命名而困扰!\n"},{"id":259,"href":"/zh/docs/technology/Review/java_guide/lycly_system-design/basis/software-engineering/","title":"软件工程简明教程","section":"基础","content":" 转载自https://github.com/Snailclimb/JavaGuide(添加小部分笔记)感谢作者!\n大部分软件开发从业者,都会忽略软件开发中的一些最基础、最底层的一些概念。但是,这些软件开发的概念对于软件开发来说非常重要,就像是软件开发的基石一样。这也是我写这篇文章的原因。\n何为软件工程? # 1968 年 NATO(北大西洋公约组织)提出了软件危机(Software crisis)一词。同年,为了解决软件危机问题,“软件工程”的概念诞生了。一门叫做软件工程的学科也就应运而生。\n随着时间的推移,软件工程这门学科也经历了一轮又一轮的完善,其中的一些核心内容比如软件开发模型越来越丰富实用!\n什么是软件危机呢?\n简单来说,软件危机描述了当时软件开发的一个痛点:我们很难高效地开发出质量高的软件。\nDijkstra(Dijkstra算法的作者) 在 1972年图灵奖获奖感言中也提高过软件危机,他是这样说的:“导致软件危机的主要原因是机器变得功能强大了几个数量级!坦率地说:只要没有机器,编程就完全没有问题。当我们有一些弱小的计算机时,编程成为一个温和的问题,而现在我们有了庞大的计算机,编程也同样成为一个巨大的问题”。\n说了这么多,到底什么是软件工程呢?\n工程是为了解决实际的问题将理论应用于实践。软件工程指的就是将工程思想应用于软件开发。\n上面是我对软件工程的定义,我们再来看看比较权威的定义。IEEE 软件工程汇刊给出的定义是这样的: (1)将系统化的、规范的、可量化的方法应用到软件的开发、运行及维护中,即将工程化方法应用于软件。 (2)在(1)中所述方法的研究。\n总之,软件工程的终极目标就是:在更少资源消耗的情况下,创造出更好、更容易维护的软件。\n软件开发过程 # 维基百科是这样定义软件开发过程的:\n软件开发过程(英语:software development process),或软件过程(英语:software process),是软件开发的开发生命周期(software development life cycle),其各个阶段实现了软件的需求定义与分析、设计、实现、测试、交付和维护。软件过程是在开发与构建系统时应遵循的步骤,是软件开发的路线图。\n需求分析 :分析用户的需求,建立逻辑模型。 软件设计 : 根据需求分析的结果对软件架构进行设计。 编码 :编写程序运行的源代码。 测试 : 确定测试用例,编写测试报告。 交付 :将做好的软件交付给客户。 维护 :对软件进行维护比如解决 bug,完善功能。 软件开发过程只是比较笼统的层面上,一定义了一个软件开发可能涉及到的一些流程。\n软件开发模型更具体地定义了软件开发过程,对开发过程提供了强有力的理论支持。\n软件开发模型 # 软件开发模型有很多种,比如瀑布模型(Waterfall Model)、快速原型模型(Rapid Prototype Model)、V模型(V-model)、W模型(W-model)、敏捷开发模型。其中最具有代表性的还是 瀑布模型 和 敏捷开发 。\n瀑布模型 定义了一套完成的软件开发周期,完整地展示了一个软件的的生命周期。\n敏捷开发模型 是目前使用的最多的一种软件开发模型。 MBA智库百科对敏捷开发的描述是这样的:\n敏捷开发 是一种以人为核心、迭代、循序渐进的开发方法。在敏捷开发中,软件项目的构建被切分成多个子项目,各个子项目的成果都经过测试,具备集成和可运行的特征。换言之,就是把一个大项目分为多个相互联系,但也可独立运行的小项目,并分别完成,在此过程中软件一直处于可使用状态。\n像现在比较常见的一些概念比如 持续集成 、重构 、小版本发布 、低文档 、站会 、结对编程 、测试驱动开发 都是敏捷开发的核心。\n软件开发的基本策略 # 软件复用 # 我们在构建一个新的软件的时候,不需要从零开始,通过复用已有的一些轮子(框架、第三方库等)、设计模式、设计原则等等现成的物料,我们可以更快地构建出一个满足要求的软件。\n像我们平时接触的开源项目就是最好的例子。我想,如果不是开源,我们构建出一个满足要求的软件,耗费的精力和时间要比现在多的多!\n分而治之 # 构建软件的过程中,我们会遇到很多问题。我们可以将一些比较复杂的问题拆解为一些小问题,然后,一一攻克。\n我结合现在比较火的软件设计方法—**领域驱动设计(Domain Driven Design,简称 DDD)**来说说。\n在领域驱动设计中,很重要的一个概念就是领域(Domain),它就是我们要解决的问题。在领域驱动设计中,我们要做的就是把比较大的领域(问题)拆解为若干的小领域(子域)。\n除此之外,分而治之也是一个比较常用的算法思想,对应的就是分治算法。如果你想了解分治算法的话,推荐你看一下北大的 《算法设计与分析 Design and Analysis of Algorithms》。\n逐步演进 # 软件开发是一个逐步演进的过程,我们需要不断进行迭代式增量开发,最终交付符合客户价值的产品。\n这里补充一个在软件开发领域,非常重要的概念:MVP(Minimum Viable Product,最小可行产品)。\n这个最小可行产品,可以理解为刚好能够满足客户需求的产品。下面这张图片把这个思想展示的非常精髓。\n利用最小可行产品,我们可以也可以提早进行市场分析,这对于我们在探索产品不确定性的道路上非常有帮助。可以非常有效地指导我们下一步该往哪里走。\n优化折中 # 软件开发是一个不断优化改进的过程。任何软件都有很多可以优化的点,不可能完美。我们需要不断改进和提升软件的质量。\n但是,也不要陷入这个怪圈。要学会折中,在有限的投入内,以最有效的方式提高现有软件的质量。\n参考 # 软件工程的基本概念-清华大学软件学院 刘强:https://www.xuetangx.com/course/THU08091000367 软件开发过程-维基百科 :https://zh.wikipedia.org/wiki/软件开发过程 "},{"id":260,"href":"/zh/docs/technology/Review/java_guide/lycly_system-design/basis/restful/","title":"restFul","section":"基础","content":" 转载自https://github.com/Snailclimb/JavaGuide(添加小部分笔记)感谢作者!\n这篇文章简单聊聊后端程序员必备的 RESTful API 相关的知识。\n开始正式介绍 RESTful API 之前,我们需要首先搞清 :API 到底是什么?\n# 何为 API? # API(Application Programming Interface) 翻译过来是应用程序编程接口的意思。\n我们在进行后端开发的时候,主要的工作就是为前端或者其他后端服务提供 API 比如查询用户数据的 API 。\n但是, API 不仅仅代表后端系统暴露的接口,像框架中提供的方法也属于 API 的范畴。\n为了方便大家理解,我再列举几个例子 🌰:\n你通过某电商网站搜索某某商品,电商网站的前端就调用了后端提供了搜索商品相关的 API。 你使用 JDK 开发 Java 程序,想要读取用户的输入的话,你就需要使用 JDK 提供的 IO 相关的 API。 \u0026hellip;\u0026hellip; 你可以把 API 理解为程序与程序之间通信的桥梁,其本质就是一个函数而已。另外,API 的使用也不是没有章法的,它的规则由(比如数据输入和输出的格式)API 提供方制定。\n# 何为 RESTful API? # RESTful API 经常也被叫做 REST API,它是基于 REST 构建的 API。这个 REST 到底是什么,我们后文在讲,涉及到的概念比较多。\n如果你看 RESTful API 相关的文章的话一般都比较晦涩难懂,主要是因为 REST 涉及到的一些概念比较难以理解。但是,实际上,我们平时开发用到的 RESTful API 的知识非常简单也很容易概括!\n举个例子,如果我给你下面两个 API 你是不是立马能知道它们是干什么用的!这就是 RESTful API 的强大之处!\nGET /classes:列出所有班级 POST /classes:新建一个班级 RESTful API 可以让你看到 URL+Http Method 就知道这个 URL 是干什么的,让你看到了 HTTP 状态码(status code)就知道请求结果如何。\n像咱们在开发过程中设计 API 的时候也应该至少要满足 RESTful API 的最基本的要求(比如接口中尽量使用名词,使用 POST 请求创建资源,DELETE 请求删除资源等等,示例:GET /notes/id:获取某个指定 id 的笔记的信息)。\n# 解读 REST # REST 是 REpresentational State Transfer 的缩写。这个词组的翻译过来就是“表现层状态转化”。\n这样理解起来甚是晦涩,实际上 REST 的全称是 Resource Representational State Transfer ,直白地翻译过来就是 “资源”在网络传输中以某种“表现形式”进行“状态转移” 。如果还是不能继续理解,请继续往下看,相信下面的讲解一定能让你理解到底啥是 REST 。\n我们分别对上面涉及到的概念进行解读,以便加深理解,实际上你不需要搞懂下面这些概念,也能看懂我下一部分要介绍到的内容。不过,为了更好地能跟别人扯扯 “RESTful API”我建议你还是要好好理解一下!\n资源(Resource) :我们可以把真实的对象数据称为资源。一个资源既可以是一个集合,也可以是单个个体。比如我们的班级 classes 是代表一个集合形式的资源,而特定的 class 代表单个个体资源。每一种资源都有特定的 URI(统一资源标识符)与之对应,如果我们需要获取这个资源,访问这个 URI 就可以了,比如获取特定的班级:/class/12。另外,资源也可以包含子资源,比如 /classes/classId/teachers:列出某个指定班级的所有老师的信息 表现形式(Representational):\u0026ldquo;资源\u0026quot;是一种信息实体,它可以有多种外在表现形式。我们把\u0026quot;资源\u0026quot;具体呈现出来的形式比如 json,xml,image,txt 等等叫做它的**\u0026ldquo;表现层/表现形式\u0026rdquo;**。 状态转移(State Transfer) :大家第一眼看到这个词语一定会很懵逼?内心 BB:这尼玛是啥啊? 大白话来说 REST 中的状态转移更多地描述的服务器端资源的状态,比如你通过增删改查(通过 HTTP 动词实现)引起资源状态的改变。ps:互联网通信协议 HTTP 协议,是一个无状态协议,所有的资源状态都保存在服务器端。 综合上面的解释,我们总结一下什么是 RESTful 架构:\n每一个 URI 代表一种资源; 客户端和服务器之间,传递这种资源的某种表现形式比如 json,xml,image,txt 等等; 客户端通过特定的 HTTP 动词,对服务器端资源进行操作,实现**\u0026ldquo;表现层状态转化\u0026rdquo;**。 # RESTful API 规范 # # 动作 # GET:请求从服务器获取特定资源。举个例子:GET /classes(获取所有班级) POST :在服务器上创建一个新的资源。举个例子:POST /classes(创建班级) PUT :更新服务器上的资源(客户端提供更新后的整个资源)。举个例子:PUT /classes/12(更新编号为 12 的班级) DELETE :从服务器删除特定的资源。举个例子:DELETE /classes/12(删除编号为 12 的班级) PATCH :更新服务器上的资源(客户端提供更改的属性,可以看做作是部分更新),使用的比较少,这里就不举例子了。 # 路径(接口命名) # 路径又称\u0026quot;终点\u0026rdquo;(endpoint),表示 API 的具体网址。实际开发中常见的规范如下:\n网址中不能有动词,只能有名词,API 中的名词也应该使用复数。 因为 REST 中的资源往往和数据库中的表对应,而数据库中的表都是同种记录的\u0026quot;集合\u0026quot;(collection)。如果 API 调用并不涉及资源(如计算,翻译等操作)的话,可以用动词。比如:GET /calculate?param1=11\u0026amp;param2=33 。 不用大写字母,建议用中杠 - 不用下杠 _ 。比如邀请码写成 invitation-code而不是 invitation_code 。 善用版本化 API。当我们的 API 发生了重大改变而不兼容前期版本的时候,我们可以通过 URL 来实现版本化,比如 http://api.example.com/v1、http://apiv1.example.com 。版本不必非要是数字,只是数字用的最多,日期、季节都可以作为版本标识符,项目团队达成共识就可。 接口尽量使用名词,避免使用动词。 RESTful API 操作(HTTP Method)的是资源(名词)而不是动作(动词)。 Talk is cheap!来举个实际的例子来说明一下吧!现在有这样一个 API 提供班级(class)的信息,还包括班级中的学生和教师的信息,则它的路径应该设计成下面这样。\nGET /classes:列出所有班级 POST /classes:新建一个班级 GET /classes/{classId}:获取某个指定班级的信息 PUT /classes/{classId}:更新某个指定班级的信息(一般倾向整体更新) PATCH /classes/{classId}:更新某个指定班级的信息(一般倾向部分更新) DELETE /classes/{classId}:删除某个班级 GET /classes/{classId}/teachers:列出某个指定班级的所有老师的信息 GET /classes/{classId}/students:列出某个指定班级的所有学生的信息 DELETE /classes/{classId}/teachers/{ID}:删除某个指定班级下的指定的老师的信息 反例:\n/getAllclasses /createNewclass /deleteAllActiveclasses 理清资源的层次结构,比如业务针对的范围是学校,那么学校会是一级资源:/schools,老师: /schools/teachers,学生: /schools/students 就是二级资源。\n# 过滤信息(Filtering) # 如果我们在查询的时候需要添加特定条件的话,建议使用 url 参数的形式。比如我们要查询 state 状态为 active 并且 name 为 guidegege 的班级:\nGET /classes?state=active\u0026amp;name=guidegege 比如我们要实现分页查询:\nGET /classes?page=1\u0026amp;size=10 //指定第1页,每页10个数据 # 状态码(Status Codes) # 状态码范围:\n2xx:成功 3xx:重定向 4xx:客户端错误 5xx:服务器错误 200 成功 301 永久重定向 400 错误请求 500 服务器错误 201 创建 304 资源未修改 401 未授权 502 网关错误 403 禁止访问 504 网关超时 404 未找到 405 请求方法不对 # RESTful 的极致 HATEOAS # RESTful 的极致是 hateoas ,但是这个基本不会在实际项目中用到。\n上面是 RESTful API 最基本的东西,也是我们平时开发过程中最容易实践到的。实际上,RESTful API 最好做到 Hypermedia,即返回结果中提供链接,连向其他 API 方法,使得用户不查文档,也知道下一步应该做什么。\n比如,当用户向 api.example.com 的根目录发出请求,会得到这样一个返回结果\n{\u0026#34;link\u0026#34;: { \u0026#34;rel\u0026#34;: \u0026#34;collection https://www.example.com/classes\u0026#34;, \u0026#34;href\u0026#34;: \u0026#34;https://api.example.com/classes\u0026#34;, \u0026#34;title\u0026#34;: \u0026#34;List of classes\u0026#34;, \u0026#34;type\u0026#34;: \u0026#34;application/vnd.yourformat+json\u0026#34; }} 上面代码表示,文档中有一个 link 属性,用户读取这个属性就知道下一步该调用什么 API 了。rel 表示这个 API 与当前网址的关系(collection 关系,并给出该 collection 的网址),href 表示 API 的路径,title 表示 API 的标题,type 表示返回类型 Hypermedia API 的设计被称为 HATEOASopen in new window。\n在 Spring 中有一个叫做 HATEOAS 的 API 库,通过它我们可以更轻松的创建出符合 HATEOAS 设计的 API。相关文章:\n在 Spring Boot 中使用 HATEOASopen in new window Building REST services with Springopen in new window (Spring 官网 ) An Intro to Spring HATEOASopen in new window spring-hateoas-examplesopen in new window Spring HATEOASopen in new window (Spring 官网 ) # 参考 # https://RESTfulapi.net/ https://www.ruanyifeng.com/blog/2014/05/restful_api.html https://juejin.im/entry/59e460c951882542f578f2f0 https://phauer.com/2016/testing-RESTful-services-java-best-practices/ https://www.seobility.net/en/wiki/REST_API https://dev.to/duomly/rest-api-vs-graphql-comparison-3j6g 著作权归所有 原文链接:https://javaguide.cn/system-design/basis/RESTfulAPI.html\n"},{"id":261,"href":"/zh/docs/technology/Review/java_guide/lyfly_high-availability/ly05ly_performance-test/","title":"性能测试入门","section":"高可用","content":" 转载自https://github.com/Snailclimb/JavaGuide(添加小部分笔记)感谢作者!\n性能测试一般情况下都是由测试这个职位去做的,那还需要我们开发学这个干嘛呢?了解性能测试的指标、分类以及工具等知识有助于我们更好地去写出性能更好的程序,另外作为开发这个角色,如果你会性能测试的话,相信也会为你的履历加分不少。\n这篇文章是我会结合自己的实际经历以及在测试这里取的经所得,除此之外,我还借鉴了一些优秀书籍,希望对你有帮助。\n本文思维导图:\n# 一 不同角色看网站性能 # # 1.1 用户 # 当用户打开一个网站的时候,最关注的是什么?当然是网站响应速度的快慢。比如我们点击了淘宝的主页,淘宝需要多久将首页的内容呈现在我的面前,我点击了提交订单按钮需要多久返回结果等等。\n所以,用户在体验我们系统的时候往往根据你的响应速度的快慢来评判你的网站的性能。\n# 1.2 开发人员 # 用户与开发人员都关注速度,这个速度实际上就是我们的系统处理用户请求的速度。\n开发人员一般情况下很难直观的去评判自己网站的性能,我们往往会根据网站当前的架构以及基础设施情况给一个大概的值,比如:\n项目架构是分布式的吗? 用到了缓存和消息队列没有? 高并发的业务有没有特殊处理? 数据库设计是否合理? 系统用到的算法是否还需要优化? 系统是否存在内存泄露的问题? 项目使用的 Redis 缓存多大?服务器性能如何?用的是机械硬盘还是固态硬盘? \u0026hellip;\u0026hellip; # 1.3 测试人员 # 测试人员一般会根据性能测试工具来测试,然后一般会做出一个表格。这个表格可能会涵盖下面这些重要的内容:\n响应时间; 请求成功率; 吞吐量; \u0026hellip;\u0026hellip; # 1.4 运维人员 # 运维人员会倾向于根据基础设施和资源的利用率来判断网站的性能,比如我们的服务器资源使用是否合理、数据库资源是否存在滥用的情况、当然,这是传统的运维人员,现在 Devpos 火起来后,单纯干运维的很少了。我们这里暂且还保留有这个角色。\n# 二 性能测试需要注意的点 # 几乎没有文章在讲性能测试的时候提到这个问题,大家都会讲如何去性能测试,有哪些性能测试指标这些东西。\n# 2.1 了解系统的业务场景 # 性能测试之前更需要你了解当前的系统的业务场景。 对系统业务了解的不够深刻,我们很容易犯测试方向偏执的错误,从而导致我们忽略了对系统某些更需要性能测试的地方进行测试。比如我们的系统可以为用户提供发送邮件的功能,用户配置成功邮箱后只需输入相应的邮箱之后就能发送,系统每天大概能处理上万次发邮件的请求。很多人看到这个可能就直接开始使用相关工具测试邮箱发送接口,但是,发送邮件这个场景可能不是当前系统的性能瓶颈,这么多人用我们的系统发邮件, 还可能有很多人一起发邮件,单单这个场景就这么人用,那用户管理可能才是性能瓶颈吧!\n# 2.2 历史数据非常有用 # 当前系统所留下的历史数据非常重要,一般情况下,我们可以通过相应的些历史数据初步判定这个系统哪些接口调用的比较多、哪些 service 承受的压力最大,这样的话,我们就可以针对这些地方进行更细致的性能测试与分析。\n另外,这些地方也就像这个系统的一个短板一样,优化好了这些地方会为我们的系统带来质的提升。\n# 三 性能测试的指标 # # 3.1 响应时间 # 响应时间就是用户发出请求到用户收到系统处理结果所需要的时间。 重要吗?实在太重要!\n比较出名的 2-5-8 原则是这样描述的:通常来说,2到5秒,页面体验会比较好,5到8秒还可以接受,8秒以上基本就很难接受了。另外,据统计当网站慢一秒就会流失十分之一的客户。\n但是,在某些场景下我们也并不需要太看重 2-5-8 原则 ,比如我觉得系统导出导入大数据量这种就不需要,系统生成系统报告这种也不需要。\n# 3.2 并发数 # 并发数是系统能同时处理请求的数目即同时提交请求的用户数目。\n不得不说,高并发是现在后端架构中非常非常火热的一个词了,这个与当前的互联网环境以及中国整体的互联网用户量都有很大关系。一般情况下,你的系统并发量越大,说明你的产品做的就越大。但是,并不是每个系统都需要达到像淘宝、12306 这种亿级并发量的。\n# 3.3 吞吐量 # 吞吐量指的是系统单位时间内系统处理的请求数量。衡量吞吐量有几个重要的参数:QPS(TPS)、并发数、响应时间。\nQPS(Query Per Second):服务器每秒可以执行的查询次数; TPS(Transaction Per Second):服务器每秒处理的事务数(这里的一个事务可以理解为客户发出请求到收到服务器的过程); 并发数;系统能同时处理请求的数目即同时提交请求的用户数目。 响应时间: 一般取多次请求的平均响应时间 理清他们的概念,就很容易搞清楚他们之间的关系了。\nQPS(TPS) = 并发数/平均响应时间 并发数 = QPS*平均响应时间 书中是这样描述 QPS 和 TPS 的区别的。\nQPS vs TPS:QPS 基本类似于 TPS,但是不同的是,对于一个页面的一次访问,形成一个TPS;但一次页面请求,可能产生多次对服务器的请求,服务器对这些请求,就可计入“QPS”之中。如,访问一个页面会请求服务器2次,一次访问,产生一个“T”,产生2个“Q”。\n# 3.4 性能计数器 # 性能计数器是描述服务器或者操作系统的一些数据指标如内存使用、CPU使用、磁盘与网络I/O等情况。\n# 四 几种常见的性能测试 # # 性能测试 # 性能测试方法是通过测试工具模拟用户请求系统,目的主要是为了测试系统的性能是否满足要求。通俗地说,这种方法就是要在特定的运行条件下验证系统的能力状态。\n性能测试是你在对系统性能已经有了解的前提之后进行的,并且有明确的性能指标。\n# 负载测试 # 对被测试的系统继续加大请求压力,直到服务器的某个资源已经达到饱和了,比如系统的缓存已经不够用了或者系统的响应时间已经不满足要求了。\n负载测试说白点就是测试系统的上限。\n# 压力测试 # 不去管系统资源的使用情况,对系统继续加大请求压力,直到服务器崩溃无法再继续提供服务。\n# 稳定性测试 # 模拟真实场景,给系统一定压力,看看业务是否能稳定运行。\n# 五 常用性能测试工具 # 这里就不多扩展了,有时间的话会单独拎一个熟悉的说一下。\n# 5.1 后端常用 # 没记错的话,除了 LoadRunner 其他几款性能测试工具都是开源免费的。\nJmeter :Apache JMeter 是 JAVA 开发的性能测试工具。 LoadRunner:一款商业的性能测试工具。 Galtling :一款基于Scala 开发的高性能服务器性能测试工具。 ab :全称为 Apache Bench 。Apache 旗下的一款测试工具,非常实用。 # 5.2 前端常用 # Fiddler:抓包工具,它可以修改请求的数据,甚至可以修改服务器返回的数据,功能非常强大,是Web 调试的利器。 HttpWatch: 可用于录制HTTP请求信息的工具。 # 六 常见的性能优化策略 # 性能优化之前我们需要对请求经历的各个环节进行分析,排查出可能出现性能瓶颈的地方,定位问题。\n下面是一些性能优化时,我经常拿来自问的一些问题:\n系统是否需要缓存? 系统架构本身是不是就有问题? 系统是否存在死锁的地方? 系统是否存在内存泄漏?(Java 的自动回收内存虽然很方便,但是,有时候代码写的不好真的会造成内存泄漏) 数据库索引使用是否合理? \u0026hellip;\u0026hellip; 著作权归所有 原文链接:https://javaguide.cn/high-availability/performance-test.html\n"},{"id":262,"href":"/zh/docs/technology/Review/java_guide/lyfly_high-availability/ly04ly_timout-and-retry/","title":"超时\u0026重试详解","section":"高可用","content":" 转载自https://github.com/Snailclimb/JavaGuide(添加小部分笔记)感谢作者!\n由于网络问题、系统或者服务内部的 Bug、服务器宕机、操作系统崩溃等问题的不确定性,我们的系统或者服务永远不可能保证时刻都是可用的状态。\n为了最大限度的减小系统或者服务出现故障之后带来的影响,我们需要用到的 超时(Timeout) 和 重试(Retry) 机制。\n想要把超时和重试机制讲清楚其实很简单,因为它俩本身就不是什么高深的概念。\n虽然超时和重试机制的思想很简单,但是它俩是真的非常实用。你平时接触到的绝大部分涉及到远程调用的系统或者服务都会应用超时和重试机制。尤其是对于微服务系统来说,正确设置超时和重试非常重要。单体服务通常只涉及数据库、缓存、第三方 API、中间件等的网络调用,而微服务系统内部各个服务之间还存在着网络调用。\n# 超时机制 # # 什么是超时机制? # 超时机制说的是当一个请求超过指定的时间(比如 1s)还没有被处理的话,这个请求就会直接被取消并抛出指定的异常或者错误(比如 504 Gateway Timeout)。\n我们平时接触到的超时可以简单分为下面 2 种:\n连接超时(ConnectTimeout) :客户端与服务端建立连接的最长等待时间。 读取超时(ReadTimeout) :客户端和服务端已经建立连接,客户端等待服务端处理完请求的最长时间。实际项目中,我们关注比较多的还是读取超时。 一些连接池客户端框架中可能还会有获取连接超时和空闲连接清理超时**。\n如果没有设置超时的话,就可能会导致服务端连接数爆炸和大量请求堆积的问题。\n这些堆积的连接和请求会消耗系统资源,影响新收到的请求的处理。严重的情况下,甚至会拖垮整个系统或者服务。\n我之前在实际项目就遇到过类似的问题,整个网站无法正常处理请求,服务器负载直接快被拉满。后面发现原因是项目超时设置错误加上客户端请求处理异常,导致服务端连接数直接接近 40w+,这么多堆积的连接直接把系统干趴了。\n# 超时时间应该如何设置? # 超时到底设置多长时间是一个难题!超时值设置太高或者太低都有风险。如果设置太高的话,会降低超时机制的有效性,比如你设置超时为 10s 的话,那设置超时就没啥意义了,系统依然可能会出现大量慢请求堆积的问题。如果设置太低的话,就可能会导致在系统或者服务在某些处理请求速度变慢的情况下(比如请求突然增多),大量请求重试(超时通常会结合重试)继续加重系统或者服务的压力,进而导致整个系统或者服务被拖垮的问题。\n通常情况下,我们建议读取超时设置为 1500ms ,这是一个比较普适的值。如果你的系统或者服务对于延迟比较敏感的话,那读取超时值可以适当在 1500ms 的基础上进行缩短。反之,读取超时值也可以在 1500ms 的基础上进行加长,不过,尽量还是不要超过 1500ms 。连接超时可以适当设置长一些,建议在 1000ms ~ 5000ms 之内。\n没有银弹!超时值具体该设置多大,还是要根据实际项目的需求和情况慢慢调整优化得到。\n更上一层,参考 美团的Java线程池参数动态配置open in new window思想,我们也可以将超时弄成可配置化的参数而不是固定的,比较简单的一种办法就是将超时的值放在配置中心中。这样的话,我们就可以根据系统或者服务的状态动态调整超时值了。\n# 重试机制 # # 什么是重试机制? # 重试机制一般配合超时机制一起使用,指的是多次发送相同的请求来避免瞬态故障和偶然性故障。\n瞬态故障可以简单理解为某一瞬间系统偶然出现的故障,并不会持久。偶然性故障可以理解为哪些在某些情况下偶尔出现的故障,频率通常较低。\n重试的核心思想是通过消耗服务器的资源来尽可能获得请求更大概率被成功处理。由于瞬态故障和偶然性故障是很少发生的,因此,重试对于服务器的资源消耗几乎是可以被忽略的。\n# 重试的次数如何设置? # 重试的次数不宜过多,否则依然会对系统负载造成比较大的压力。\n重试的次数通常建议设为 3 次。并且,我们通常还会设置重试的间隔,比如说我们要重试 3 次的话,第 1 次请求失败后,等待 1 秒再进行重试,第 2 次请求失败后,等待 2 秒再进行重试,第 3 次请求失败后,等待 3 秒再进行重试。\n# 重试幂等 # 超时和重试机制在实际项目中使用的话,需要注意保证同一个请求没有被多次执行。\n这里说的同一个请求,指的是\u0026quot;\u0026ldquo;业务上的概念\u0026rdquo;\u0026quot;\n什么情况下会出现一个请求被多次执行呢?客户端等待服务端完成请求完成超时但此时服务端已经执行了请求,只是由于短暂的网络波动导致响应在发送给客户端的过程中延迟了。\n举个例子:用户支付购买某个课程,结果用户支付的请求由于重试的问题导致用户购买同一门课程支付了两次。对于这种情况,我们在执行用户购买课程的请求的时候需要判断一下用户是否已经购买过。这样的话,就不会因为重试的问题导致重复购买了。\n# 参考 # 微服务之间调用超时的设置治理:https://www.infoq.cn/article/eyrslar53l6hjm5yjgyx 超时、重试和抖动回退:https://aws.amazon.com/cn/builders-library/timeouts-retries-and-backoff-with-jitter/ 著作权归所有 原文链接:https://javaguide.cn/high-availability/timeout-and-retry.html\n"},{"id":263,"href":"/zh/docs/technology/Review/java_guide/lyfly_high-availability/ly03ly_limit-request/","title":"服务限流详解","section":"高可用","content":" 转载自https://github.com/Snailclimb/JavaGuide(添加小部分笔记)感谢作者!\n针对软件系统来说,限流就是对请求的速率进行限制,避免瞬时的大量请求击垮软件系统。毕竟,软件系统的处理能力是有限的。如果说超过了其处理能力的范围,软件系统可能直接就挂掉了。\n限流可能会导致用户的请求无法被正确处理,不过,这往往也是权衡了软件系统的稳定性之后得到的最优解。\n现实生活中,处处都有限流的实际应用,就比如排队买票是为了避免大量用户涌入购票而导致售票员无法处理。\n常见限流算法有哪些? # 简单介绍 4 种非常好理解并且容易实现的限流算法!\n图片来源于 InfoQ 的一篇文章 《分布式服务限流实战,已经为你排好坑了》。\n固定窗口计数器算法 # 固定窗口其实就是时间窗口。固定窗口计数器算法 规定了我们单位时间处理的请求数量。\n假如我们规定系统中某个接口 1 分钟只能访问 33 次的话,使用固定窗口计数器算法的实现思路如下:\n给定一个变量 counter 来记录当前接口处理的请求数量,初始值为 0(代表接口当前 1 分钟内还未处理请求)。 1 分钟之内每处理一个请求之后就将 counter+1 ,当 counter=33 之后(也就是说在这 1 分钟内接口已经被访问 33 次的话),后续的请求就会被全部拒绝。 等到 1 分钟结束后,将 counter 重置 0,重新开始计数。 这种限流算法无法保证限流速率,因而无法保证突然激增的流量。\n就比如说我们限制某个接口 1 分钟只能访问 1000 次,该接口的 QPS 为 500,前 55s 这个接口 1 个请求没有接收,后 1s 突然接收了 1000 个请求。然后,在当前场景下,这 1000 个请求在 1s 内是没办法被处理的,系统直接就被瞬时的大量请求给击垮了。\n滑动窗口计数器算法 # 滑动窗口计数器算法 算的上是固定窗口计数器算法的升级版。\n滑动窗口计数器算法相比于固定窗口计数器算法的优化在于:它把时间以一定比例分片(即分片后应用\u0026quot;固定窗口计数器\u0026quot;算法) 。\n例如我们的接口限流每分钟处理 60 个请求,我们可以把 1 分钟分为 60 个窗口(每个窗口时1秒)。每隔 1 秒移动一次,每个窗口一秒只能处理 不大于 60(请求数)/60(窗口数) 的请求, 如果当前窗口的请求计数总和超过了限制的数量的话就不再处理其他请求。\n感觉上面的例子和图没匹配上,应该是\n例如我们的接口限流每分钟处理 300 个请求,我们可以把 1 分钟分为 60 个窗口(每个窗口时1秒)。每隔 1 秒移动一次,每个窗口一秒只能处理 不大于 300(请求数)/60(窗口数) 的请求, 如果当前窗口的请求计数总和超过了限制的数量的话就不再处理其他请求。\n很显然, 当滑动窗口的格子划分的越多,滑动窗口的滚动就越平滑,限流的统计就会越精确。\n]\n漏桶算法 # 我们可以把发请求的动作比作成注水到桶中,我们处理请求的过程可以比喻为漏桶漏水。我们往桶中以任意速率流入水,以一定速率流出水。当水超过桶流量则丢弃,因为桶容量是不变的,保证了整体的速率。\n如果想要实现这个算法的话也很简单,准备一个队列用来保存请求,然后我们定期从队列中拿请求来执行就好了(和消息队列削峰/限流的思想是一样的)。\n令牌桶算法 # 令牌桶算法也比较简单。和漏桶算法算法一样,我们的主角还是桶(这限流算法和桶过不去啊)。不过现在桶里装的是令牌了,请求在被处理之前需要拿到一个令牌,请求处理完毕之后将这个令牌丢弃(删除)。我们根据限流大小,按照一定的速率往桶里添加令牌。如果桶装满了,就不能继续往里面继续添加令牌了。\n单机限流怎么做? # 单机限流针对的是单体架构应用。\n单机限流可以直接使用 Google Guava 自带的限流工具类 RateLimiter 。 RateLimiter 基于令牌桶算法,可以应对突发流量。\nGuava 地址:https://github.com/google/guava\n除了最基本的令牌桶算法(平滑突发限流)实现之外,Guava 的RateLimiter还提供了 平滑预热限流 的算法实现。\n平滑突发限流就是按照指定的速率放令牌到桶里,而平滑预热限流会有一段预热时间,预热时间之内,速率会逐渐提升到配置的速率。\n我们下面通过两个简单的小例子来详细了解吧!\n我们直接在项目中引入 Guava 相关的依赖即可使用。\n\u0026lt;dependency\u0026gt; \u0026lt;groupId\u0026gt;com.google.guava\u0026lt;/groupId\u0026gt; \u0026lt;artifactId\u0026gt;guava\u0026lt;/artifactId\u0026gt; \u0026lt;version\u0026gt;31.0.1-jre\u0026lt;/version\u0026gt; \u0026lt;/dependency\u0026gt; 下面是一个简单的 Guava 平滑突发限流的 Demo。\nimport com.google.common.util.concurrent.RateLimiter; /** * 微信搜 JavaGuide 回复\u0026#34;面试突击\u0026#34;即可免费领取个人原创的 Java 面试手册 * * @author Guide哥 * @date 2021/10/08 19:12 **/ public class RateLimiterDemo { public static void main(String[] args) { // 1s 放 5 个令牌到桶里也就是 0.2s 放 1个令牌到桶里 //也就是1s放几个 //public static RateLimiter create(double permitsPerSecond) {} RateLimiter rateLimiter = RateLimiter.create(5); for (int i = 0; i \u0026lt; 10; i++) { //阻塞直到得到一个令牌 ,会返回阻塞的时间 double sleepingTime = rateLimiter.acquire(1); System.out.printf(\u0026#34;get 1 tokens: %ss%n\u0026#34;, sleepingTime); } } } //源码 /** * Creates a {@code RateLimiter} with the specified stable throughput, given as \u0026#34;permits per * second\u0026#34; (commonly referred to as \u0026lt;i\u0026gt;QPS\u0026lt;/i\u0026gt;, queries per second). * * \u0026lt;p\u0026gt;The returned {@code RateLimiter} ensures that on average no more than {@code * permitsPerSecond} are issued during any given second, with sustained requests being smoothly * spread over each second. When the incoming request rate exceeds {@code permitsPerSecond} the * rate limiter will release one permit every {@code (1.0 / permitsPerSecond)} seconds. When the * rate limiter is unused, bursts of up to {@code permitsPerSecond} permits will be allowed, with * subsequent requests being smoothly limited at the stable rate of {@code permitsPerSecond}. * * @param permitsPerSecond the rate of the returned {@code RateLimiter}, measured in how many * permits become available per second * @throws IllegalArgumentException if {@code permitsPerSecond} is negative or zero */ // TODO(user): \u0026#34;This is equivalent to // {@code createWithCapacity(permitsPerSecond, 1, TimeUnit.SECONDS)}\u0026#34;. // public static RateLimiter create(double permitsPerSecond) {} //源码 /** * Acquires the given number of permits from this {@code RateLimiter}, blocking until the request * can be granted. Tells the amount of time slept, if any. * * @param permits the number of permits to acquire * @return time spent sleeping to enforce rate, in seconds; 0.0 if not rate-limited * @throws IllegalArgumentException if the requested number of permits is negative or zero * @since 16.0 (present in 13.0 with {@code void} return type}) */ /* @CanIgnoreReturnValue public double acquire(int permits) { long microsToWait = reserve(permits); stopwatch.sleepMicrosUninterruptibly(microsToWait); return 1.0 * microsToWait / SECONDS.toMicros(1L); } */ 输出:\nget 1 tokens: 0.0s get 1 tokens: 0.188413s get 1 tokens: 0.197811s get 1 tokens: 0.198316s get 1 tokens: 0.19864s get 1 tokens: 0.199363s get 1 tokens: 0.193997s get 1 tokens: 0.199623s get 1 tokens: 0.199357s get 1 tokens: 0.195676s 下面是一个简单的 Guava 平滑预热限流的 Demo。\nimport com.google.common.util.concurrent.RateLimiter; import java.util.concurrent.TimeUnit; /** * 微信搜 JavaGuide 回复\u0026#34;面试突击\u0026#34;即可免费领取个人原创的 Java 面试手册 * * @author Guide哥 * @date 2021/10/08 19:12 **/ public class RateLimiterDemo { public static void main(String[] args) { // 1s 放 5 个令牌到桶里也就是 0.2s 放 1个令牌到桶里 // 预热时间为3s,也就说刚开始的 3s 内发牌速率会逐渐提升到 0.2s 放 1 个令牌到桶里(所以前面的速率比较小,获取需要的时间比较长) RateLimiter rateLimiter = RateLimiter.create(5, 3, TimeUnit.SECONDS); for (int i = 0; i \u0026lt; 20; i++) { double sleepingTime = rateLimiter.acquire(1); System.out.printf(\u0026#34;get 1 tokens: %sds%n\u0026#34;, sleepingTime); } } } 输出:\nget 1 tokens: 0.0s get 1 tokens: 0.561919s get 1 tokens: 0.516931s get 1 tokens: 0.463798s get 1 tokens: 0.41286s get 1 tokens: 0.356172s get 1 tokens: 0.300489s get 1 tokens: 0.252545s get 1 tokens: 0.203996s get 1 tokens: 0.198359s 另外,Bucket4j 是一个非常不错的基于令牌/漏桶算法的限流库。\nBucket4j 地址:https://github.com/vladimir-bukhtoyarov/bucket4j\n相对于,Guava 的限流工具类来说,Bucket4j 提供的限流功能更加全面。不仅支持单机限流和分布式限流,还可以集成监控,搭配 Prometheus 和 Grafana 使用。\n不过,毕竟 Guava 也只是一个功能全面的工具类库,其提供的开箱即用的限流功能在很多单机场景下还是比较实用的。\nSpring Cloud Gateway 中自带的单机限流的早期版本就是基于 Bucket4j 实现的。后来,替换成了 Resilience4j。\nResilience4j 是一个轻量级的容错组件,其灵感来自于 Hystrix。自 Netflix 宣布不再积极开发 Hystrix 之后,Spring 官方和 Netflix 都更推荐使用 Resilience4j 来做限流熔断。\nResilience4j 地址: https://github.com/resilience4j/resilience4j\n一般情况下,为了保证系统的高可用,项目的限流和熔断都是要一起做的。\nResilience4j 不仅提供限流,还提供了熔断、负载保护、自动重试等保障系统高可用开箱即用的功能。并且,Resilience4j 的生态也更好,很多网关都使用 Resilience4j 来做限流熔断的。\n因此,在绝大部分场景下 Resilience4j 或许会是更好的选择。如果是一些比较简单的限流场景的话,Guava 或者 Bucket4j 也是不错的选择。\n分布式限流怎么做? # 分布式限流针对的分布式/微服务应用架构应用,在这种架构下,单机限流就不适用了,因为会存在多种服务,并且一种服务也可能会被部署多份。\n分布式限流常见的方案:\n借助中间件架限流 :可以借助 Sentinel 或者使用 Redis 来自己实现对应的限流逻辑。 网关层限流 :比较常用的一种方案,直接在网关层把限流给安排上了。不过,通常网关层限流通常也需要借助到中间件/框架。就比如 Spring Cloud Gateway 的分布式限流实现**RedisRateLimiter**就是基于 Redis+Lua 来实现的,再比如 Spring Cloud Gateway 还可以整合 Sentinel 来做限流。 如果你要基于 Redis 来手动实现限流逻辑的话,建议配合 Lua 脚本来做。\n为什么建议 Redis+Lua 的方式? 主要有两点原因:\n减少了网络开销 :我们可以利用 Lua 脚本来批量执行多条 Redis 命令,这些 Redis 命令会被提交到 Redis 服务器一次性执行完成,大幅减小了网络开销。 原子性 :一段 Lua 脚本可以视作一条命令执行,一段 Lua 脚本执行过程中不会有其他脚本或 Redis 命令同时执行,保证了操作不会被其他指令插入或打扰。 我这里就不放具体的限流脚本代码了,网上也有很多现成的优秀的限流脚本供你参考,就比如 Apache 网关项目 ShenYu 的 RateLimiter 限流插件就基于 Redis + Lua 实现了令牌桶算法/并发令牌桶算法、漏桶算法、滑动窗口算法。\nShenYu 地址: https://github.com/apache/incubator-shenyu\n服务治理之轻量级熔断框架 Resilience4j :https://xie.infoq.cn/article/14786e571c1a4143ad1ef8f19 超详细的 Guava RateLimiter 限流原理解析:https://cloud.tencent.com/developer/article/1408819 实战 Spring Cloud Gateway 之限流篇 👍:https://www.aneasystone.com/archives/2020/08/spring-cloud-gateway-current-limiting.html "},{"id":264,"href":"/zh/docs/technology/Review/java_guide/lyfly_high-availability/ly02ly_redundancy/","title":"冗余设计","section":"高可用","content":" 转载自https://github.com/Snailclimb/JavaGuide(添加小部分笔记)感谢作者!\ntitle category 冗余设计详解 高可用 冗余设计是保证系统和数据高可用的最常的手段。\n对于服务来说,冗余的思想就是相同的服务部署多份,如果正在使用的服务突然挂掉的话,系统可以很快切换到备份服务上,大大减少系统的不可用时间,提高系统的可用性。\n对于数据来说,冗余的思想就是相同的数据备份多份,这样就可以很简单地提高数据的安全性。\n实际上,日常生活中就有非常多的冗余思想的应用。\n拿我自己来说,我对于重要文件的保存方法就是冗余思想的应用。我日常所使用的重要文件都会同步一份在 Github 以及个人云盘上,这样就可以保证即使电脑硬盘损坏,我也可以通过 Github 或者个人云盘找回自己的重要文件。\n高可用集群(High Availability Cluster,简称 HA Cluster)、同城灾备、异地灾备、同城多活和异地多活是冗余思想在高可用系统设计中最典型的应用。\n高可用集群 : 同一份服务部署两份或者多份,当正在使用的服务突然挂掉的话,可以切换到另外一台服务,从而保证服务的高可用。 同城灾备 :一整个集群可以部署在同一个机房,而同城灾备中相同服务部署在同一个城市的不同机房中。并且,备用服务不处理请求。这样可以避免机房出现意外情况比如停电、火灾。 异地灾备 :类似于同城灾备,不同的是,相同服务部署在异地(通常距离较远,甚至是在不同的城市或者国家)的不同机房中 同城多活 :类似于同城灾备,但备用服务可以处理请求,这样可以充分利用系统资源,提高系统的并发。 异地多活 : 将服务部署在异地的不同机房中,并且,它们可以同时对外提供服务。 高可用集群单纯是服务的冗余,并没有强调地域。同城灾备、异地灾备、同城多活和异地多活实现了地域上的冗余。\n同城和异地的主要区别在于机房之间的距离。异地通常距离较远,甚至是在不同的城市或者国家。\n和传统的灾备设计相比,同城多活和异地多活最明显的改变在于**“多活”,即所有站点都是同时在对外提供服务的。异地多活是为了应对突发状况比如火灾**、地震等自然或者人为灾害。\n光做好冗余还不够,必须要配合上 故障转移 才可以! 所谓故障转移,简单来说就是实现不可用服务快速且自动地切换到可用服务,整个过程不需要人为干涉。\n举个例子:哨兵模式的 Redis 集群中,如果 Sentinel(哨兵) 检测到 master 节点出现故障的话, 它就会帮助我们实现故障转移,自动将某一台 slave 升级为 master,确保整个 Redis 系统的可用性。整个过程完全自动,不需要人工介入。我在 《Java 面试指北》的「技术面试题篇」中的数据库部分详细介绍了 Redis 集群相关的知识点\u0026amp;面试题,感兴趣的小伙伴可以看看。\n再举个例子:Nginx 可以结合 Keepalived 来实现高可用。如果 Nginx 主服务器宕机的话,Keepalived 可以自动进行故障转移,备用 Nginx 主服务器升级为主服务。并且,这个切换对外是透明的,因为使用的虚拟 IP,虚拟 IP 不会改变。我在 《Java 面试指北》的「技术面试题篇」中的「服务器」部分详细介绍了 Nginx 相关的知识点\u0026amp;面试题,感兴趣的小伙伴可以看看。\n异地多活架构实施起来非常难,需要考虑的因素非常多。本人不才,实际项目中并没有实践过异地多活架构,我对其了解还停留在书本知识。\n如果你想要深入学习异地多活相关的知识,我这里推荐几篇我觉得还不错的文章:\n搞懂异地多活,看这篇就够了- 水滴与银弹 - 2021 四步构建异地多活 《从零开始学架构》— 28 | 业务高可用的保障:异地多活架构 不过,这些文章大多也都是在介绍概念知识。目前,网上还缺少真正介绍具体要如何去实践落地异地多活架构的资料。\n灾备 = 容灾+备份。\n备份 : 将系统所产生的的所有重要数据多备份几份。 容灾 : 在异地建立两个完全相同的系统。当某个地方的系统突然挂掉,整个应用系统可以切换到另一个,这样系统就可以正常提供服务了。 异地多活 描述的是将服务部署在异地并且服务同时对外提供服务。和传统的灾备设计的最主要区别在于“多活”,即所有站点都是同时在对外提供服务的。异地多活是为了应对突发状况比如火灾、地震等自然或者人为灾害。\n"},{"id":265,"href":"/zh/docs/technology/Review/java_guide/lyfly_high-availability/ly01ly_high-availability-system-design/","title":"高可用系统设计指南","section":"高可用","content":" 转载自https://github.com/Snailclimb/JavaGuide(添加小部分笔记)感谢作者!\n什么是高可用?可用性的判断标准是啥? # 高可用描述的是一个系统在大部分时间都是可用的,可以为我们提供服务的。高可用代表系统即使在发生硬件故障或者系统升级的时候,服务仍然是可用的。\n一般情况下,我们使用多少个 9 来评判一个系统的可用性,比如 99.9999% 就是代表该系统在所有的运行时间中只有 0.0001% 的时间是不可用的,这样的系统就是非常非常高可用的了!当然,也会有系统如果可用性不太好的话,可能连 9 都上不了。\n除此之外,系统的可用性还可以用某功能的失败次数与总的请求次数之比来衡量,比如对网站请求 1000 次,其中有 10 次请求失败,那么可用性就是 99%。\n哪些情况会导致系统不可用? # 黑客攻击; 硬件故障,比如服务器坏掉。 并发量/用户请求量激增导致整个服务宕掉或者部分服务不可用。 代码中的坏味道导致内存泄漏或者其他问题导致程序挂掉。 网站架构某个重要的角色比如 Nginx 或者数据库突然不可用。 自然灾害或者人为破坏。 \u0026hellip;\u0026hellip; 有哪些提高系统可用性的方法? # 注重代码质量,测试严格把关 # 我觉得这个是最最最重要的,代码质量有问题比如比较常见的内存泄漏、循环依赖都是对系统可用性极大的损害。大家都喜欢谈限流、降级、熔断,但是我觉得从代码质量这个源头把关是首先要做好的一件很重要的事情。如何提高代码质量?比较实际可用的就是 CodeReview,不要在乎每天多花的那 1 个小时左右的时间,作用可大着呢!\n另外,安利几个对提高代码质量有实际效果的神器:\nSonarqube; Alibaba 开源的 Java 诊断工具 Arthas; 阿里巴巴 Java 代码规范(Alibaba Java Code Guidelines); IDEA 自带的代码分析等工具。 使用集群,减少单点故障 # 先拿常用的 Redis 举个例子!我们如何保证我们的 Redis 缓存高可用呢?答案就是使用集群,避免单点故障。当我们使用一个 Redis 实例作为缓存的时候,这个 Redis 实例挂了之后,整个缓存服务可能就挂了。使用了集群之后,即使一台 Redis 实例挂了,不到一秒就会有另外一台 Redis 实例顶上。\n限流 # 流量控制(flow control),其原理是监控应用流量的 QPS 或并发线程数等指标,当达到指定的阈值时对流量进行控制,以避免被瞬时的流量高峰冲垮,从而保障应用的高可用性。——来自 alibaba-Sentinel 的 wiki。\n超时和重试机制设置 # 一旦用户请求超过某个时间的得不到响应,就抛出异常。这个是非常重要的,很多线上系统故障都是因为没有进行超时设置或者超时设置的方式不对导致的。我们在读取第三方服务的时候,尤其适合设置超时和重试机制。一般我们使用一些 RPC 框架的时候,这些框架都自带的超时重试的配置。如果不进行超时设置可能会导致请求响应速度慢,甚至导致请求堆积进而让系统无法再处理请求。重试的次数一般设为 3 次,再多次的重试没有好处,反而会加重服务器压力(部分场景使用失败重试机制会不太适合)。\n熔断机制 # 超时和重试机制设置之外,熔断机制也是很重要的。 熔断机制说的是系统自动收集所依赖服务的资源使用情况和性能指标,当所依赖的服务恶化或者调用失败次数达到某个阈值的时候就迅速失败,让当前系统立即切换依赖其他备用服务。 比较常用的流量控制和熔断降级框架是 Netflix 的 Hystrix 和 alibaba 的 Sentinel。\n异步调用 # 异步调用的话我们不需要关心最后的结果,这样我们就可以用户请求完成之后就立即返回结果,具体处理我们可以后续再做,秒杀场景用这个还是蛮多的。但是,使用异步之后我们可能需要 适当修改业务流程进行配合,比如用户在提交订单之后,不能立即返回用户订单提交成功,需要在消息队列的订单消费者进程真正处理完该订单之后,甚至出库后,再通过电子邮件或短信通知用户订单成功。除了可以在程序中实现异步之外,我们常常还使用消息队列,消息队列可以通过异步处理提高系统性能(削峰、减少响应所需时间)并且可以降低系统耦合性。\n使用缓存 # 如果我们的系统属于并发量比较高的话,如果我们单纯使用数据库的话,当大量请求直接落到数据库可能数据库就会直接挂掉。使用缓存缓存热点数据,因为缓存存储在内存中,所以速度相当地快!\n其他 # 核心应用和服务优先使用更好的硬件 监控系统资源使用情况增加报警设置。 注意备份,必要时候回滚。 灰度发布: 将服务器集群分成若干部分,每天只发布一部分机器,观察运行稳定没有故障,第二天继续发布一部分机器,持续几天才把整个集群全部发布完毕,期间如果发现问题,只需要回滚已发布的一部分服务器即可 定期检查/更换硬件: 如果不是购买的云服务的话,定期还是需要对硬件进行一波检查的,对于一些需要更换或者升级的硬件,要及时更换或者升级。 \u0026hellip;.. "},{"id":266,"href":"/zh/docs/technology/Review/java_guide/lyely_high-performance/message-mq/rocketmq-questions/","title":"rocketmq常见面试题","section":"RocketMQ","content":" 转载自https://github.com/Snailclimb/JavaGuide(添加小部分笔记)感谢作者!\n本文来自读者 PR。\n主要是rocket mq的几个问题\n1 单机版消息中心 # 一个消息中心,最基本的需要支持多生产者、多消费者,例如下:\nclass Scratch { public static void main(String[] args) { // 实际中会有 nameserver 服务来找到 broker 具体位置以及 broker 主从信息 Broker broker = new Broker(); Producer producer1 = new Producer(); producer1.connectBroker(broker); Producer producer2 = new Producer(); producer2.connectBroker(broker); Consumer consumer1 = new Consumer(); consumer1.connectBroker(broker); Consumer consumer2 = new Consumer(); consumer2.connectBroker(broker); for (int i = 0; i \u0026lt; 2; i++) { producer1.asyncSendMsg(\u0026#34;producer1 send msg\u0026#34; + i); producer2.asyncSendMsg(\u0026#34;producer2 send msg\u0026#34; + i); } System.out.println(\u0026#34;broker has msg:\u0026#34; + broker.getAllMagByDisk()); for (int i = 0; i \u0026lt; 1; i++) { System.out.println(\u0026#34;consumer1 consume msg:\u0026#34; + consumer1.syncPullMsg()); } for (int i = 0; i \u0026lt; 3; i++) { System.out.println(\u0026#34;consumer2 consume msg:\u0026#34; + consumer2.syncPullMsg()); } } } class Producer { private Broker broker; public void connectBroker(Broker broker) { this.broker = broker; } public void asyncSendMsg(String msg) { if (broker == null) { throw new RuntimeException(\u0026#34;please connect broker first\u0026#34;); } new Thread(() -\u0026gt; { broker.sendMsg(msg); }).start(); } } class Consumer { private Broker broker; public void connectBroker(Broker broker) { this.broker = broker; } public String syncPullMsg() { return broker.getMsg(); } } class Broker { // 对应 RocketMQ 中 MessageQueue,默认情况下 1 个 Topic 包含 4 个 MessageQueue private LinkedBlockingQueue\u0026lt;String\u0026gt; messageQueue = new LinkedBlockingQueue(Integer.MAX_VALUE); // 实际发送消息到 broker 服务器使用 Netty 发送 public void sendMsg(String msg) { try { messageQueue.put(msg); // 实际会同步或异步落盘,异步落盘使用的定时任务定时扫描落盘 } catch (InterruptedException e) { } } public String getMsg() { try { return messageQueue.take(); } catch (InterruptedException e) { } return null; } public String getAllMagByDisk() { StringBuilder sb = new StringBuilder(\u0026#34;\\n\u0026#34;); messageQueue.iterator().forEachRemaining((msg) -\u0026gt; { sb.append(msg + \u0026#34;\\n\u0026#34;); }); return sb.toString(); } } 问题:\n没有实现真正执行消息存储落盘 没有实现 NameServer 去作为注册中心,定位服务 使用 LinkedBlockingQueue 作为消息队列,注意,参数是无限大,在真正 RocketMQ 也是如此是无限大,理论上不会出现对进来的数据进行抛弃,但是会有内存泄漏问题(阿里巴巴开发手册也因为这个问题,建议我们使用自制线程池) 没有使用多个队列(即多个 LinkedBlockingQueue),RocketMQ 的顺序消息是通过生产者和消费者同时使用同一个 MessageQueue 来实现,但是如果我们只有一个 MessageQueue,那我们天然就支持顺序消息 没有使用 MappedByteBuffer 来实现文件映射从而使消息数据落盘非常的快(实际 RocketMQ 使用的是 FileChannel+DirectBuffer) 2 分布式消息中心 # 2.1 问题与解决 # 2.1.1 消息丢失的问题 # 当你系统需要保证百分百消息不丢失,你可以使用生产者每发送一个消息,Broker 同步返回一个消息发送成功的反馈消息 即每发送一个消息,同步落盘后才返回生产者消息发送成功,这样只要生产者得到了消息发送生成的返回,事后除了硬盘损坏,都可以保证不会消息丢失 但是这同时引入了一个问题,同步落盘怎么才能快? 2.1.2 同步落盘怎么才能快 # 使用 FileChannel + DirectBuffer 池,使用堆外内存,加快内存拷贝 使用数据和索引分离,当消息需要写入时,使用 commitlog 文件顺序写,当需要定位某个消息时,查询index 文件来定位,从而减少文件IO随机读写的性能损耗 2.1.3 消息堆积的问题 # 后台定时任务每隔72小时,删除旧的没有使用过的消息信息 根据不同的业务实现不同的丢弃任务,具体参考线程池的 AbortPolicy,例如FIFO/LRU等(RocketMQ没有此策略) 消息定时转移,或者对某些重要的 TAG 型(支付型)消息真正落库 2.1.4 定时消息的实现 # 实际 RocketMQ 没有实现任意精度的定时消息,它只支持某些特定的时间精度的定时消息 实现定时消息的原理是:创建特定时间精度的 MessageQueue,例如生产者需要定时1s之后被消费者消费,你只需要将此消息发送到特定的 Topic,例如:MessageQueue-1 表示这个 MessageQueue 里面的消息都会延迟一秒被消费,然后 Broker 会在 1s 后发送到消费者消费此消息,使用 newSingleThreadScheduledExecutor 实现 2.1.5 顺序消息的实现 # 与定时消息同原理,生产者生产消息时指定特定的 MessageQueue ,消费者消费消息时,消费特定的 MessageQueue,其实单机版的消息中心在一个 MessageQueue 就天然支持了顺序消息 注意:同一个 MessageQueue 保证里面的消息是顺序消费的前提是:消费者是串行的消费该 MessageQueue,因为就算 MessageQueue 是顺序的,但是当并行消费时,还是会有顺序问题,但是串行消费也同时引入了两个问题: 引入锁来实现串行 前一个消费阻塞时后面都会被阻塞 2.1.6 分布式消息的实现 # 需要前置知识:2PC RocketMQ4.3 起支持,原理为2PC,即两阶段提交,prepared-\u0026gt;commit/rollback 生产者发送事务消息,假设该事务消息 Topic 为 Topic1-Trans,Broker 得到后首先更改该消息的 Topic 为 Topic1-Prepared,该 Topic1-Prepared 对消费者不可见。然后定时回调生产者的本地事务A执行状态,根据本地事务A执行状态,来是否将该消息修改为 Topic1-Commit 或 Topic1-Rollback,消费者就可以正常找到该事务消息或者不执行等 注意,就算是事务消息最后回滚了也不会物理删除,只会逻辑删除该消息\n2.1.7 消息的 push 实现 # 注意,RocketMQ 已经说了自己会有低延迟问题,其中就包括这个消息的 push 延迟问题 因为这并不是真正的将消息主动的推送到消费者,而是 Broker 定时任务每5s将消息推送到消费者 pull模式需要我们手动调用consumer拉消息,而push模式则只需要我们提供一个listener即可实现对消息的监听,而实际上,RocketMQ的push模式是基于pull模式实现的,它没有实现真正的push。 push方式里,consumer把轮询过程封装了,并注册MessageListener监听器,取到消息后,唤醒MessageListener的consumeMessage()来消费,对用户而言,感觉消息是被推送过来的。 2.1.8 消息重复发送的避免 # RocketMQ 会出现消息重复发送的问题,因为在网络延迟的情况下,这种问题不可避免的发生,如果非要实现消息不可重复发送,那基本太难,因为网络环境无法预知,还会使程序复杂度加大,因此默认允许消息重复发送 RocketMQ 让使用者在消费者端去解决该问题,即需要消费者端在消费消息时支持幂等性的去消费消息 最简单的解决方案是每条消费记录有个消费状态字段,根据这个消费状态字段来判断是否消费或者使用一个集中式的表,来存储所有消息的消费状态,从而避免重复消费 具体实现可以查询关于消息幂等消费的解决方案 2.1.9 广播消费与集群消费 # 消息消费区别:广播消费,订阅该 Topic 的消息者们都会消费每个消息。集群消费,订阅该 Topic 的消息者们只会有一个去消费某个消息 消息落盘区别:具体表现在消息消费进度的保存上。广播消费,由于每个消费者都独立的去消费每个消息,因此每个消费者各自保存自己的消息消费进度。而集群消费下,订阅了某个 Topic,而旗下又有多个 MessageQueue,每个消费者都可能会去消费不同的 MessageQueue,因此总体的消费进度保存在 Broker 上集中的管理 2.1.10 RocketMQ 不使用 ZooKeeper 作为注册中心的原因,以及自制的 NameServer 优缺点? # ZooKeeper 作为支持顺序一致性的中间件,在某些情况下,它为了满足一致性,会丢失一定时间内的可用性,RocketMQ 需要注册中心只是为了发现组件地址,在某些情况下,RocketMQ 的注册中心可以出现数据不一致性,这同时也是 NameServer 的缺点,因为 NameServer 集群间互不通信,它们之间的注册信息可能会不一致 另外,当有新的服务器加入时,NameServer 并不会立马通知到 Producer,而是由 Producer 定时去请求 NameServer 获取最新的 Broker/Consumer 信息(这种情况是通过 Producer 发送消息时,负载均衡解决) 2.1.11 其它 # 加分项咯\n包括组件通信间使用 Netty 的自定义协议 消息重试负载均衡策略(具体参考 Dubbo 负载均衡策略) 消息过滤器(Producer 发送消息到 Broker,Broker 存储消息信息,Consumer 消费时请求 Broker 端从磁盘文件查询消息文件时,在 Broker 端就使用过滤服务器进行过滤) Broker 同步双写和异步双写中 Master 和 Slave 的交互 Broker 在 4.5.0 版本更新中引入了基于 Raft 协议的多副本选举,之前这是商业版才有的特性 ISSUE-1046 3 参考 # 《RocketMQ技术内幕》:https://blog.csdn.net/prestigeding/article/details/85233529 关于 RocketMQ 对 MappedByteBuffer 的一点优化: https://lishoubo.github.io/2017/09/27/MappedByteBuffer%E7%9A%84%E4%B8%80%E7%82%B9%E4%BC%98%E5%8C%96/ 十分钟入门RocketMQ:https://developer.aliyun.com/article/66101 分布式事务的种类以及 RocketMQ 支持的分布式消息:https://www.infoq.cn/article/2018/08/rocketmq-4.3-release 滴滴出行基于RocketMQ构建企业级消息队列服务的实践:https://yq.aliyun.com/articles/664608 基于《RocketMQ技术内幕》源码注释:https://github.com/LiWenGu/awesome-rocketmq "},{"id":267,"href":"/zh/docs/technology/Review/java_guide/lyely_high-performance/message-mq/rocketmq-intro/","title":"rocketmq介绍","section":"RocketMQ","content":" 转载自https://github.com/Snailclimb/JavaGuide(添加小部分笔记)感谢作者!\n消息队列扫盲 # 消息队列顾名思义就是存放消息的队列,队列我就不解释了,别告诉我你连队列都不知道是啥吧?\n所以问题并不是消息队列是什么,而是 消息队列为什么会出现?消息队列能用来干什么?用它来干这些事会带来什么好处?消息队列会带来副作用吗?\n# 消息队列为什么会出现? # 消息队列算是作为后端程序员的一个必备技能吧,因为分布式应用必定涉及到各个系统之间的通信问题,这个时候消息队列也应运而生了。可以说分布式的产生是消息队列的基础,而分布式怕是一个很古老的概念了吧,所以消息队列也是一个很古老的中间件了。\n# 消息队列能用来干什么? # # 异步 # 你可能会反驳我,应用之间的通信又不是只能由消息队列解决,好好的通信为什么中间非要插一个消息队列呢?我不能直接进行通信吗?\n很好👍,你又提出了一个概念,同步通信。就比如现在业界使用比较多的 Dubbo 就是一个适用于各个系统之间同步通信的 RPC 框架。\n我来举个🌰吧,比如我们有一个购票系统,需求是用户在购买完之后能接收到购买完成的短信。\n我们省略中间的网络通信时间消耗,假如购票系统处理需要 150ms ,短信系统处理需要 200ms ,那么整个处理流程的时间消耗就是 150ms + 200ms = 350ms。\n当然,乍看没什么问题。可是仔细一想你就感觉有点问题,我用户购票在购票系统的时候其实就已经完成了购买,而我现在通过同步调用非要让整个请求拉长时间,而短信系统这玩意又不是很有必要,它仅仅是一个辅助功能增强用户体验感而已。我现在整个调用流程就有点 头重脚轻 的感觉了,购票是一个不太耗时的流程,而我现在因为同步调用,非要等待发送短信这个比较耗时的操作才返回结果。那我如果再加一个发送邮件呢?\n这样整个系统的调用链又变长了,整个时间就变成了550ms。\n当我们在学生时代需要在食堂排队的时候,我们和食堂大妈就是一个同步的模型。\n我们需要告诉食堂大妈:“姐姐,给我加个鸡腿,再加个酸辣土豆丝,帮我浇点汁上去,多打点饭哦😋😋😋” 咦~~~ 为了多吃点,真恶心。\n然后大妈帮我们打饭配菜,我们看着大妈那颤抖的手和掉落的土豆丝不禁咽了咽口水。\n最终我们从大妈手中接过饭菜然后去寻找座位了\u0026hellip;\n回想一下,我们在给大妈发送需要的信息之后我们是 同步等待大妈给我配好饭菜 的,上面我们只是加了鸡腿和土豆丝,万一我再加一个番茄牛腩,韭菜鸡蛋,这样是不是大妈打饭配菜的流程就会变长,我们等待的时间也会相应的变长。\n那后来,我们工作赚钱了有钱去饭店吃饭了,我们告诉服务员来一碗牛肉面加个荷包蛋 (传达一个消息) ,然后我们就可以在饭桌上安心的玩手机了 (干自己其他事情) ,等到我们的牛肉面上了我们就可以吃了。这其中我们也就传达了一个消息,然后我们又转过头干其他事情了。这其中虽然做面的时间没有变短,但是我们只需要传达一个消息就可以干其他事情了,这是一个 异步 的概念。\n所以,为了解决这一个问题,聪明的程序员在中间也加了个类似于服务员的中间件——消息队列。这个时候我们就可以把模型给改造了。\n这样,我们在将消息存入消息队列之后我们就可以直接返回了(我们告诉服务员我们要吃什么然后玩手机),所以整个耗时只是 150ms + 10ms = 160ms。\n但是你需要注意的是,整个流程的时长是没变的,就像你仅仅告诉服务员要吃什么是不会影响到做面的速度的。\n# 解耦 # 回到最初同步调用的过程,我们写个伪代码简单概括一下。\n那么第二步,我们又添加了一个发送邮件,我们就得重新去修改代码,如果我们又加一个需求:用户购买完还需要给他加积分,这个时候我们是不是又得改代码?\n如果你觉得还行,那么我这个时候不要发邮件这个服务了呢,我是不是又得改代码,又得重启应用?\n这样改来改去是不是很麻烦,那么 此时我们就用一个消息队列在中间进行解耦 。你需要注意的是,我们后面的发送短信、发送邮件、添加积分等一些操作都依赖于上面的 result ,这东西抽象出来就是购票的处理结果呀,比如订单号,用户账号等等,也就是说我们后面的一系列服务都是需要同样的消息来进行处理。既然这样,我们是不是可以通过 “广播消息” 来实现。\n我上面所讲的“广播”并不是真正的广播,而是接下来的系统作为消费者去 订阅 特定的主题。比如我们这里的主题就可以叫做 订票 ,我们购买系统作为一个生产者去生产这条消息放入消息队列,然后消费者订阅了这个主题,会从消息队列中拉取消息并消费。就比如我们刚刚画的那张图,你会发现,在生产者这边我们只需要关注 生产消息到指定主题中 ,而 消费者只需要关注从指定主题中拉取消息 就行了。\n如果没有消息队列,每当一个新的业务接入,我们都要在主系统调用新接口、或者当我们取消某些业务,我们也得在主系统删除某些接口调用。有了消息队列,我们只需要关心消息是否送达了队列,至于谁希望订阅,接下来收到消息如何处理,是下游的事情,无疑极大地减少了开发和联调的工作量。\n# 削峰(xue 1) # 我们再次回到一开始我们使用同步调用系统的情况,并且思考一下,如果此时有大量用户请求购票整个系统会变成什么样?\n如果,此时有一万的请求进入购票系统,我们知道运行我们主业务的服务器配置一般会比较好,所以这里我们假设购票系统能承受这一万的用户请求,那么也就意味着我们同时也会出现一万调用发短信服务的请求。而对于短信系统来说并不是我们的主要业务,所以我们配备的硬件资源并不会太高,那么你觉得现在这个短信系统能承受这一万的峰值么,且不说能不能承受,系统会不会 直接崩溃 了?\n短信业务又不是我们的主业务,我们能不能 折中处理 呢?如果我们把购买完成的信息发送到消息队列中,而短信系统 尽自己所能地去消息队列中取消息和消费消息 ,即使处理速度慢一点也无所谓,只要我们的系统没有崩溃就行了。\n留得江山在,还怕没柴烧?你敢说每次发送验证码的时候是一发你就收到了的么?\n# 消息队列能带来什么好处? # 其实上面我已经说了。异步、解耦、削峰。 哪怕你上面的都没看懂也千万要记住这六个字,因为他不仅是消息队列的精华,更是编程和架构的精华。\n# 消息队列会带来副作用吗? # 没有哪一门技术是“银弹”,消息队列也有它的副作用。\n比如,本来好好的两个系统之间的调用,我中间加了个消息队列,如果消息队列挂了怎么办呢?是不是 降低了系统的可用性 ?\n那这样是不是要保证HA(高可用)?是不是要搞集群?那么我 整个系统的复杂度是不是上升了 ?\n抛开上面的问题不讲,万一我发送方发送失败了,然后执行重试,这样就可能产生重复的消息。\n或者我消费端处理失败了,请求重发,这样也会产生重复的消息。\n对于一些微服务来说,消费重复消息会带来更大的麻烦,比如增加积分,这个时候我加了多次是不是对其他用户不公平?\n那么,又 如何解决重复消费消息的问题 呢?\n如果我们此时的消息需要保证严格的顺序性怎么办呢?比如生产者生产了一系列的有序消息(对一个id为1的记录进行删除增加修改),但是我们知道在发布订阅模型中,对于主题是无顺序的,那么这个时候就会导致对于消费者消费消息的时候没有按照生产者的发送顺序消费,比如这个时候我们消费的顺序为修改删除增加,如果该记录涉及到金额的话是不是会出大事情?\n那么,又 如何解决消息的顺序消费问题 呢?\n就拿我们上面所讲的分布式系统来说,用户购票完成之后是不是需要增加账户积分?在同一个系统中我们一般会使用事务来进行解决,如果用 Spring 的话我们在上面伪代码中加入 @Transactional 注解就好了。但是在不同系统中如何保证事务呢?总不能这个系统我扣钱成功了你那积分系统积分没加吧?或者说我这扣钱明明失败了,你那积分系统给我加了积分。\n那么,又如何 解决分布式事务问题 呢?\n我们刚刚说了,消息队列可以进行削峰操作,那如果我的消费者如果消费很慢或者生产者生产消息很快,这样是不是会将消息堆积在消息队列中?\n那么,又如何 解决消息堆积的问题 呢?\n可用性降低,复杂度上升,又带来一系列的重复消费,顺序消费,分布式事务,消息堆积的问题,这消息队列还怎么用啊😵?\n别急,办法总是有的。\n# RocketMQ是什么? # 原理 来源: https://www.bilibili.com/video/BV1GY4y1F7og\n哇,你个混蛋!上面给我抛出那么多问题,你现在又讲 RocketMQ ,还让不让人活了?!🤬\n别急别急,话说你现在清楚 MQ 的构造吗,我还没讲呢,我们先搞明白 MQ 的内部构造,再来看看如何解决上面的一系列问题吧,不过你最好带着问题去阅读和了解喔。\nRocketMQ 是一个 队列模型 的消息中间件,具有高性能、高可靠、高实时、分布式 的特点。它是一个采用 Java 语言开发的分布式的消息系统,由阿里巴巴团队开发,在2016年底贡献给 Apache,成为了 Apache 的一个顶级项目。 在阿里内部,RocketMQ 很好地服务了集团大大小小上千个应用,在每年的双十一当天,更有不可思议的万亿级消息通过 RocketMQ 流转。\n废话不多说,想要了解 RocketMQ 历史的同学可以自己去搜寻资料。听完上面的介绍,你只要知道 RocketMQ 很快、很牛、而且经历过双十一的实践就行了!\n# 队列模型和主题模型 # 在谈 RocketMQ 的技术架构之前,我们先来了解一下两个名词概念——队列模型 和 主题模型 。\n首先我问一个问题,消息队列为什么要叫消息队列?\n你可能觉得很弱智,这玩意不就是存放消息的队列嘛?不叫消息队列叫什么?\n的确,早期的消息中间件是通过 队列 这一模型来实现的,可能是历史原因,我们都习惯把消息中间件称为消息队列。\n但是,如今例如 RocketMQ 、Kafka 这些优秀的消息中间件不仅仅是通过一个 队列 来实现消息存储的。\n# 队列模型 # 就像我们理解队列一样,消息中间件的队列模型就真的只是一个队列。。。我画一张图给大家理解。\n在一开始我跟你提到了一个 “广播” 的概念,也就是说如果我们此时我们需要将一个消息发送给多个消费者(比如此时我需要将信息发送给短信系统和邮件系统),这个时候单个队列即不能满足需求了。\n当然你可以让 Producer 生产消息放入多个队列中,然后每个队列去对应每一个消费者。问题是可以解决,创建多个队列并且复制多份消息是会很影响资源和性能的。而且,这样子就会导致生产者需要知道具体消费者个数然后去复制对应数量的消息队列,这就违背我们消息中间件的 解耦 这一原则。\n# 主题模型 # 那么有没有好的方法去解决这一个问题呢?有,那就是 主题模型 或者可以称为 发布订阅模型 。\n感兴趣的同学可以去了解一下设计模式里面的观察者模式并且手动实现一下,我相信你会有所收获的。\n在主题模型中,消息的生产者称为 发布者(Publisher) ,消息的消费者称为 订阅者(Subscriber) ,存放消息的容器称为 主题(Topic) 。\n其中,发布者将消息发送到指定主题中,订阅者需要 提前订阅主题 才能接受特定主题的消息。\n# RocketMQ中的消息模型 # RocketMQ 中的消息模型就是按照 主题模型 所实现的。你可能会好奇这个 主题 到底是怎么实现的呢?你上面也没有讲到呀!\n其实对于主题模型的实现来说每个消息中间件的底层设计都是不一样的,就比如 Kafka 中的 分区 ,RocketMQ 中的 队列 ,RabbitMQ 中的 Exchange 。我们可以理解为 主题模型/发布订阅模型 就是一个标准,那些中间件只不过照着这个标准去实现而已。\n所以,RocketMQ 中的 主题模型 到底是如何实现的呢?首先我画一张图,大家尝试着去理解一下。\n我们可以看到在整个图中有 Producer Group 、Topic 、Consumer Group 三个角色,我来分别介绍一下他们。\nProducer Group 生产者组: 代表某一类的生产者,比如我们有多个秒杀系统作为生产者,这多个合在一起就是一个 Producer Group 生产者组,它们一般生产相同的消息。 Consumer Group 消费者组: 代表某一类的消费者,比如我们有多个短信系统作为消费者,这多个合在一起就是一个 Consumer Group 消费者组,它们一般消费相同的消息。 Topic 主题: 代表一类消息,比如订单消息,物流消息等等。 你可以看到图中生产者组中的生产者会向主题发送消息,而 主题中存在多个队列,生产者每次生产消息之后是指定主题中的某个队列发送消息的。\nly:我的理解是,一般情况下,同一个生产者组生产的消息,会发到同一个topic中\n每个主题中都有多个队列(这里还不涉及到 Broker),集群消费模式下,一个消费者集群多台机器共同消费一个 topic 的多个队列,一个队列只会被一个消费者消费。如果某个消费者挂掉,分组内其它消费者会接替挂掉的消费者继续消费。就像上图中 Consumer1 和 Consumer2 分别对应着两个队列,而 Consumer3 是没有队列对应的,所以一般来讲要控制 消费者组中的消费者个数和主题中队列个数相同 。\n当然也可以消费者个数小于队列个数,只不过不太建议。如下图。\n每个消费组在每个队列上维护一个消费位置 ,为什么呢?\n因为我们刚刚画的仅仅是一个消费者组,我们知道在发布订阅模式中一般会涉及到多个消费者组,而每个消费者组在每个队列中的消费位置都是不同的。如果此时有多个消费者组,那么消息被一个消费者组消费完之后是不会删除的(因为其它消费者组也需要呀 【注意重点,是消费完之后】),它仅仅是为每个消费者组维护一个 消费位移(offset) ,每次消费者组消费完会返回一个成功的响应,然后队列再把维护的消费位移加一,这样就不会出现刚刚消费过的消息再一次被消费了。\n可能你还有一个问题,为什么一个主题中需要维护多个队列 ?\n答案是 提高并发能力 。的确,每个主题中只存在一个队列也是可行的。你想一下,如果每个主题中只存在一个队列,这个队列中也维护着每个消费者组的消费位置,这样也可以做到 发布订阅模式 。如下图。\n但是,这样我生产者是不是只能向一个队列发送消息?又因为需要维护消费位置所以一个队列只能对应一个消费者组中的消费者,这样是不是其他的 Consumer 就没有用武之地了?从这两个角度来讲,并发度一下子就小了很多。\n所以总结来说,RocketMQ 通过使用在一个 Topic 中配置多个队列并且每个队列维护每个消费者组的消费位置 实现了 主题模式/发布订阅模式 。\n# RocketMQ的架构图 # 讲完了消息模型,我们理解起 RocketMQ 的技术架构起来就容易多了。\nRocketMQ 技术架构中有四大角色 NameServer 、Broker 、Producer 、Consumer 。我来向大家分别解释一下这四个角色是干啥的。\nBroker: 主要负责消息的存储、投递和查询以及服务高可用保证。说白了就是消息队列服务器嘛,生产者生产消息到 Broker ,消费者从 Broker 拉取消息并消费。\n这里,我还得普及一下关于 Broker 、Topic 和 队列的关系。上面我讲解了 Topic 和队列的关系——一个 Topic 中存在多个队列,那么这个 Topic 和队列存放在哪呢?\n一个 Topic 分布在多个 Broker上,一个 Broker 可以配置多个 Topic ,它们是多对多的关系。\n如果某个 Topic 消息量很大,应该给它多配置几个队列(上文中提到了提高并发能力),并且 尽量多分布在不同 Broker 上,以减轻某个 Broker 的压力 。\nTopic 消息量都比较均匀的情况下,如果某个 broker 上的队列越多,则该 broker 压力越大。\n感觉下面这个图有一点点误解,就是Queue 重复(比如多个Queue),我觉得同一个Queue不会分布在多个Topic上面的\n所以说我们需要配置多个Broker。\nNameServer: 不知道你们有没有接触过 ZooKeeper 和 Spring Cloud 中的 Eureka ,它其实也是一个 注册中心 ,主要提供两个功能:Broker管理 和 路由信息管理 。说白了就是 Broker 会将自己的信息注册到 NameServer 中,此时 NameServer 就存放了很多 Broker 的信息(Broker的路由表),消费者和生产者就从 NameServer 中获取路由表然后照着路由表的信息和对应的 Broker 进行通信(生产者和消费者定期会向 NameServer 去查询相关的 Broker 的信息)。\nProducer: 消息发布的角色,支持分布式集群方式部署。说白了就是生产者。\nConsumer: 消息消费的角色,支持分布式集群方式部署。支持以push推,pull拉两种模式对消息进行消费。同时也支持集群方式和广播方式的消费,它提供实时消息订阅机制。说白了就是消费者。\n听完了上面的解释你可能会觉得,这玩意好简单。不就是这样的么?\n嗯?你可能会发现一个问题,这老家伙 NameServer 干啥用的,这不多余吗?直接 Producer 、Consumer 和 Broker 直接进行生产消息,消费消息不就好了么?\n但是,我们上文提到过 Broker 是需要保证高可用的,如果整个系统仅仅靠着一个 Broker 来维持的话,那么这个 Broker 的压力会不会很大?所以我们需要使用多个 Broker 来保证 负载均衡 。\n如果说,我们的消费者和生产者直接和多个 Broker 相连,那么当 Broker 修改的时候必定会牵连着每个生产者和消费者,这样就会产生耦合问题,而 NameServer 注册中心就是用来解决这个问题的。\n如果还不是很理解的话,可以去看我介绍 Spring Cloud 的那篇文章,其中介绍了 Eureka 注册中心。\n当然,RocketMQ 中的技术架构肯定不止前面那么简单,因为上面图中的四个角色都是需要做集群的。我给出一张官网的架构图,大家尝试理解一下。\n其实和我们最开始画的那张乞丐版的架构图也没什么区别,主要是一些细节上的差别。听我细细道来🤨。\n第一、我们的 Broker 做了集群并且还进行了主从部署 ,由于消息分布在各个 Broker 上,一旦某个 Broker 宕机,则该Broker 上的消息读写都会受到影响。所以 Rocketmq 提供了 master/slave 的结构, salve 定时从 master 同步数据(同步刷盘或者异步刷盘),如果 master 宕机,则 slave 提供消费服务,但是不能写入消息 (后面我还会提到哦)。\n第二、为了保证 HA ,我们的 NameServer 也做了集群部署,但是请注意它是 去中心化 的。也就意味着它没有主节点,你可以很明显地看出 NameServer 的所有节点是没有进行 Info Replicate 的,在 RocketMQ 中是通过 单个Broker和所有NameServer保持长连接 ,并且在每隔30秒 Broker 会向所有 Nameserver 发送心跳,心跳包含了自身的 Topic 配置信息,这个步骤就对应这上面的 Routing Info 。\n第三、在生产者需要向 Broker 发送消息的时候,需要先从 NameServer 获取关于 Broker 的路由信息,然后通过 轮询 的方法去向每个队列中生产数据以达到 负载均衡 的效果。\n第四、消费者通过 NameServer 获取所有 Broker 的路由信息后,向 Broker 发送 Pull 请求来获取消息数据。Consumer 可以以两种模式启动—— 广播(Broadcast)和集群(Cluster)。广播模式下,一条消息会发送给 同一个消费组中的所有消费者 ,集群模式下消息只会发送给一个消费者。\n# 如何解决 顺序消费、重复消费 # 其实,这些东西都是我在介绍消息队列带来的一些副作用的时候提到的,也就是说,这些问题不仅仅挂钩于 RocketMQ ,而是应该每个消息中间件都需要去解决的。\n在上面我介绍 RocketMQ 的技术架构的时候我已经向你展示了 它是如何保证高可用的 ,这里不涉及运维方面的搭建,如果你感兴趣可以自己去官网上照着例子搭建属于你自己的 RocketMQ 集群。\n其实 Kafka 的架构基本和 RocketMQ 类似,只是它注册中心使用了 Zookeeper 、它的 分区 就相当于 RocketMQ 中的 队列 。还有一些小细节不同会在后面提到。\n# 顺序消费 # 在上面的技术架构介绍中,我们已经知道了 RocketMQ 在主题上是无序的、它只有在队列层面才是保证有序 的。\n这又扯到两个概念——普通顺序 和 严格顺序 。\n所谓普通顺序是指 消费者通过 同一个消费队列收到的消息是有顺序的 ,不同消息队列收到的消息则可能是无顺序的。普通顺序消息在 Broker 重启情况下不会保证消息顺序性 (短暂时间) 。\n所谓严格顺序是指 消费者收到的 所有消息 均是有顺序的。严格顺序消息 即使在异常情况下也会保证消息的顺序性 。\n但是,严格顺序看起来虽好,实现它可会付出巨大的代价。如果你使用严格顺序模式,Broker 集群中只要有一台机器不可用,则整个集群都不可用。你还用啥?现在主要场景也就在 binlog 同步。\n一般而言,我们的 MQ 都是能容忍短暂的乱序,所以推荐使用普通顺序模式。\n那么,我们现在使用了 普通顺序模式 ,我们从上面学习知道了在 Producer 生产消息的时候会进行轮询(取决你的负载均衡策略)来向同一主题的不同消息队列发送消息。那么如果此时我有几个消息分别是同一个订单的创建、支付、发货,在轮询的策略下这 三个消息会被发送到不同队列 ,因为在不同的队列此时就无法使用 RocketMQ 带来的队列有序特性来保证消息有序性了。\n那么,怎么解决呢?\n其实很简单,我们需要处理的仅仅是将同一语义下的消息放入同一个队列(比如这里是同一个订单),那我们就可以使用 Hash取模法 来保证同一个订单在同一个队列中就行了。\n# 重复消费 # emmm,就两个字—— 幂等 。在编程中一个幂等 操作的特点是其任意多次执行所产生的影响均与一次执行的影响相同。比如说,这个时候我们有一个订单的处理积分的系统,每当来一个消息的时候它就负责为创建这个订单的用户的积分加上相应的数值。可是有一次,消息队列发送给订单系统 FrancisQ 的订单信息,其要求是给 FrancisQ 的积分加上 500。但是积分系统在收到 FrancisQ 的订单信息处理完成之后返回给消息队列处理成功的信息的时候出现了网络波动(当然还有很多种情况,比如Broker意外重启等等),这条回应没有发送成功。\n那么,消息队列没收到积分系统的回应会不会尝试重发这个消息?问题就来了,我再发这个消息,万一它又给 FrancisQ 的账户加上 500 积分怎么办呢?\n所以我们需要给我们的消费者实现 幂等 ,也就是对同一个消息的处理结果,执行多少次都不变。\n那么如何给业务实现幂等呢?这个还是需要结合具体的业务的。你可以使用 写入 Redis 来保证,因为 Redis 的 key 和 value 就是天然支持幂等的(如果mq处理过就给redis设置值,而后每次mq处理之前查询一下redis才知道mq是否已经处理过)。当然还有使用 数据库插入法 ,基于数据库的唯一键来保证重复数据不会被插入多条。\n不过最主要的还是需要 根据特定场景使用特定的解决方案 ,你要知道你的消息消费是否是完全不可重复消费还是可以忍受重复消费的,然后再选择强校验和弱校验的方式。毕竟在 CS 领域还是很少有技术银弹的说法。\n而在整个互联网领域,幂等不仅仅适用于消息队列的重复消费问题,这些实现幂等的方法,也同样适用于,在其他场景中来解决重复请求或者重复调用的问题 。比如将HTTP服务设计成幂等的,解决前端或者APP重复提交表单数据的问题 ,也可以将一个微服务设计成幂等的,解决 RPC 框架自动重试导致的 重复调用问题 。\n# 分布式事务 # 如何解释分布式事务呢?事务大家都知道吧?要么都执行要么都不执行 。在同一个系统中我们可以轻松地实现事务,但是在分布式架构中,我们有很多服务是部署在不同系统之间的,而不同服务之间又需要进行调用。比如此时我下订单然后增加积分,如果保证不了分布式事务的话,就会出现A系统下了订单,但是B系统增加积分失败或者A系统没有下订单,B系统却增加了积分。前者对用户不友好,后者对运营商不利,这是我们都不愿意见到的。\n那么,如何去解决这个问题呢?\n如今比较常见的分布式事务实现有 2PC、TCC 和事务消息(half 半消息机制)。每一种实现都有其特定的使用场景,但是也有各自的问题,都不是完美的解决方案。\n在 RocketMQ 中使用的是 事务消息加上事务反查机制 来解决分布式事务问题的。我画了张图,大家可以对照着图进行理解。\n在第一步发送的 half 消息 ,它的意思是 在事务提交之前,对于消费者来说,这个消息是不可见的 。\n那么,如何做到写入消息但是对用户不可见呢?RocketMQ事务消息的做法是:如果消息是half消息,将备份原消息的主题与消息消费队列,然后 改变主题 为RMQ_SYS_TRANS_HALF_TOPIC。由于消费组未订阅该主题,故消费端无法消费half类型的消息,然后RocketMQ会开启一个定时任务,从Topic为RMQ_SYS_TRANS_HALF_TOPIC中拉取消息进行消费,根据生产者组获取一个服务提供者发送回查事务状态请求,根据事务状态来决定是提交或回滚消息。\n你可以试想一下,如果没有从第5步开始的 事务反查机制 ,如果出现网路波动第4步没有发送成功,这样就会产生 MQ 不知道是不是需要给消费者消费的问题,他就像一个无头苍蝇一样。在 RocketMQ 中就是使用的上述的事务反查来解决的,而在 Kafka 中通常是直接抛出一个异常让用户来自行解决。\n你还需要注意的是,在 MQ Server 指向系统B的操作已经和系统A不相关了,也就是说在消息队列中的分布式事务是——本地事务和存储消息到消息队列才是同一个事务。这样也就产生了事务的最终一致性,因为整个过程是异步的,每个系统只要保证它自己那一部分的事务就行了。 # 消息堆积问题 # 在上面我们提到了消息队列一个很重要的功能——削峰 。那么如果这个峰值太大了导致消息堆积在队列中怎么办呢?\n其实这个问题可以将它广义化,因为产生消息堆积的根源其实就只有两个——生产者生产太快或者消费者消费太慢。\n我们可以从多个角度去思考解决这个问题,当流量到峰值的时候是因为生产者生产太快,我们可以使用一些 限流降级 的方法,当然你也可以增加多个消费者实例去水平扩展增加消费能力来匹配生产的激增。如果消费者消费过慢的话,我们可以先检查 是否是消费者出现了大量的消费错误 ,或者打印一下日志查看是否是哪一个线程卡死,出现了锁资源不释放等等的问题。\n当然,最快速解决消息堆积问题的方法还是增加消费者实例,不过 同时你还需要增加每个主题的队列数量 。\n别忘了在 RocketMQ 中,一个队列只会被一个消费者消费 ,如果你仅仅是增加消费者实例就会出现我一开始给你画架构图的那种情况。\n# 回溯消费 # 回溯消费是指 Consumer 已经消费成功的消息,由于业务上需求需要重新消费,在RocketMQ 中, Broker 在向Consumer 投递成功消息后,消息仍然需要保留 。并且重新消费一般是按照时间维度,例如由于 Consumer 系统故障,恢复后需要重新消费1小时前的数据,那么 Broker 要提供一种机制,可以按照时间维度来回退消费进度。RocketMQ 支持按照时间回溯消费,时间维度精确到毫秒。\n这是官方文档的解释,我直接照搬过来就当科普了😁😁😁。\n# RocketMQ 的刷盘机制 # 上面我讲了那么多的 RocketMQ 的架构和设计原理,你有没有好奇\n在 Topic 中的 队列是以什么样的形式存在的?\n队列中的消息又是如何进行存储持久化的呢?\n我在上文中提到的 同步刷盘 和 异步刷盘 又是什么呢?它们会给持久化带来什么样的影响呢?\n下面我将给你们一一解释。\n# 同步刷盘和异步刷盘 # 如上图所示,在同步刷盘中需要等待一个刷盘成功的 ACK ,同步刷盘对 MQ 消息可靠性来说是一种不错的保障,但是 性能上会有较大影响 ,一般地适用于金融等特定业务场景。\n而异步刷盘往往是开启一个线程去异步地执行刷盘操作。消息刷盘采用后台异步线程提交的方式进行, 降低了读写延迟 ,提高了 MQ 的性能和吞吐量,一般适用于如发验证码等对于消息保证要求不太高的业务场景。\n一般地,异步刷盘只有在 Broker 意外宕机的时候会丢失部分数据,你可以设置 Broker 的参数 FlushDiskType 来调整你的刷盘策略(ASYNC_FLUSH 或者 SYNC_FLUSH)。\n# 同步复制和异步复制 # 上面的同步刷盘和异步刷盘是在单个结点层面的,而同步复制和异步复制主要是指的 Borker 主从模式下,主节点返回消息给客户端的时候是否需要同步从节点。\n同步复制: 也叫 “同步双写”,也就是说,只有消息同步双写到主从节点上时才返回写入成功 。 异步复制: 消息写入主节点之后就直接返回写入成功 。 然而,很多事情是没有完美的方案的,就比如我们进行消息写入的节点越多就更能保证消息的可靠性,但是随之的性能也会下降,所以需要程序员根据特定业务场景去选择适应的主从复制方案。\n那么,异步复制会不会也像异步刷盘那样影响消息的可靠性呢?\n答案是不会的,因为两者就是不同的概念,对于消息可靠性是通过不同的刷盘策略保证的,而像异步同步复制策略仅仅是影响到了 可用性 。为什么呢?其主要原因是 RocketMQ 是不支持自动主从切换的,当主节点挂掉之后,生产者就不能再给这个主节点生产消息了。\n比如这个时候采用异步复制的方式,在主节点还未发送完需要同步的消息的时候主节点挂掉了,这个时候从节点就少了一部分消息。但是此时生产者无法再给主节点生产消息了,消费者可以自动切换到从节点进行消费(仅仅是消费),所以在主节点挂掉的时间只会产生主从结点短暂的消息不一致的情况,降低了可用性,而当主节点重启之后,从节点那部分未来得及复制的消息还会继续复制。\n在单主从架构中,如果一个主节点挂掉了,那么也就意味着整个系统不能再生产了。那么这个可用性的问题能否解决呢?一个主从不行那就多个主从的呗,别忘了在我们最初的架构图中,每个 Topic 是分布在不同 Broker 中的。\n但是这种复制方式同样也会带来一个问题,那就是无法保证 严格顺序 。在上文中我们提到了如何保证的消息顺序性是通过将一个语义的消息发送在同一个队列中,使用 Topic 下的队列来保证顺序性的。如果此时我们主节点A负责的是订单A的一系列语义消息,然后它挂了,这样其他节点是无法代替主节点A的,如果我们任意节点都可以存入任何消息,那就没有顺序性可言了。\n而在 RocketMQ 中采用了 Dledger 解决这个问题。他要求在写入消息的时候,要求至少消息复制到半数以上的节点之后,才给客⼾端返回写⼊成功,并且它是⽀持通过选举来动态切换主节点的。这里我就不展开说明了,读者可以自己去了解。\n也不是说 Dledger 是个完美的方案,至少在 Dledger 选举过程中是无法提供服务的,而且他必须要使用三个节点或以上,如果多数节点同时挂掉他也是无法保证可用性的,而且要求消息复制半数以上节点的效率和直接异步复制还是有一定的差距的。\n# 存储机制 # ly:详细的有点复杂,暂时跳过。大概就是有三个东西,CommitLog(实际存储消息的东西),ConsumeQueue(相当于CommitLog的索引)\n还记得上面我们一开始的三个问题吗?到这里第三个问题已经解决了。\n但是,在 Topic 中的 队列是以什么样的形式存在的?队列中的消息又是如何进行存储持久化的呢? 还未解决,其实这里涉及到了 RocketMQ 是如何设计它的存储结构了。我首先想大家介绍 RocketMQ 消息存储架构中的三大角色——CommitLog 、ConsumeQueue 和 IndexFile 。\nCommitLog: 消息主体以及元数据的存储主体,存储 Producer 端写入的消息主体内容,消息内容不是定长的。单个文件大小默认1G ,文件名长度为20位,左边补零,剩余为起始偏移量,比如00000000000000000000代表了第一个文件,起始偏移量为0,文件大小为1G=1073741824;当第一个文件写满了,第二个文件为00000000001073741824,起始偏移量为1073741824,以此类推。消息主要是顺序写入日志文件,当文件满了,写入下一个文件。\nConsumeQueue: 消息消费队列,引入的目的主要是提高消息消费的性能(我们再前面也讲了),由于RocketMQ 是基于主题 Topic 的订阅模式,消息消费是针对主题进行的,如果要遍历 commitlog 文件中根据 Topic 检索消息是非常低效的。Consumer 即可根据 ConsumeQueue 来查找待消费的消息。其中,ConsumeQueue(逻辑消费队列)作为消费消息的索引,保存了指定 Topic 下的队列消息在 CommitLog 中的起始物理偏移量 offset *,消息大小 size 和消息 Tag 的 HashCode 值。*consumequeue 文件可以看成是基于 topic 的 commitlog 索引文件,故 consumequeue 文件夹的组织方式如下:topic/queue/file三层组织结构,具体存储路径为:$HOME/store/consumequeue/{topic}/{queueId}/{fileName}。同样 consumequeue 文件采取定长设计,每一个条目共20个字节,分别为8字节的 commitlog 物理偏移量、4字节的消息长度、8字节tag hashcode,单个文件由30W个条目组成,可以像数组一样随机访问每一个条目,每个 ConsumeQueue文件大小约5.72M;\n保存了指定 Topic 下的队列消息在 CommitLog 中的**起始物理偏移量 offset **,消息大小 size 和消息 Tag 的 HashCode 值\nIndexFile: IndexFile(索引文件)提供了一种可以通过key或时间区间来查询消息的方法。这里只做科普不做详细介绍。\n总结来说,整个消息存储的结构,最主要的就是 CommitLoq 和 ConsumeQueue 。而 ConsumeQueue 你可以大概理解为 Topic 中的队列。\n我的理解是,通过ConsumeQueue files去查询CommitLog\nRocketMQ 采用的是 混合型的存储结构 ,即为 Broker 单个实例下所有的队列共用一个日志数据文件来存储消息。有意思的是在同样高并发的 Kafka 中会为每个 Topic 分配一个存储文件。这就有点类似于我们有一大堆书需要装上书架,RockeMQ 是不分书的种类直接成批的塞上去的,而 Kafka 是将书本放入指定的分类区域的。\n而 RocketMQ 为什么要这么做呢?原因是 提高数据的写入效率 ,不分 Topic 意味着我们有更大的几率获取 成批 的消息进行数据写入,但也会带来一个麻烦就是读取消息的时候需要遍历整个大文件,这是非常耗时的。\n所以,在 RocketMQ 中又使用了 ConsumeQueue 作为每个队列的索引文件来 提升读取消息的效率。我们可以直接根据队列的消息序号,计算出索引的全局位置(索引序号*索引固定⻓度20),然后直接读取这条索引,再根据索引中记录的消息的全局位置,找到消息。\n讲到这里,你可能对 RockeMQ 的存储架构还有些模糊,没事,我们结合着图来理解一下。\nemmm,是不是有一点复杂🤣,看英文图片和英文文档的时候就不要怂,硬着头皮往下看就行。\n如果上面没看懂的读者一定要认真看下面的流程分析!\n首先,在最上面的那一块就是我刚刚讲的你现在可以直接 把 ConsumerQueue 理解为 Queue。\n在图中最左边说明了红色方块代表被写入的消息,虚线方块代表等待被写入的。左边的生产者发送消息会指定 Topic 、QueueId 和具体消息内容,而在 Broker 中管你是哪门子消息,他直接 全部顺序存储到了 CommitLog。而根据生产者指定的 Topic 和 QueueId 将这条消息本身在 CommitLog 的偏移(offset),消息本身大小,和tag的hash值存入对应的 ConsumeQueue 索引文件中。而在每个队列中都保存了 ConsumeOffset 即每个消费者组的消费位置(我在架构那里提到了,忘了的同学可以回去看一下),而消费者拉取消息进行消费的时候只需要根据 ConsumeOffset 获取下一个未被消费的消息就行了。\n上述就是我对于整个消息存储架构的大概理解(这里不涉及到一些细节讨论,比如稀疏索引等等问题),希望对你有帮助。\n因为有一个知识点因为写嗨了忘讲了,想想在哪里加也不好,所以我留给大家去思考🤔🤔一下吧。\n为什么 CommitLog 文件要设计成固定大小的长度呢?提醒:内存映射机制。\n# 总结 # 总算把这篇博客写完了。我讲的你们还记得吗😅?\n这篇文章中我主要想大家介绍了\n消息队列出现的原因 消息队列的作用(异步,解耦,削峰) 消息队列带来的一系列问题(消息堆积、重复消费、顺序消费、分布式事务等等) 消息队列的两种消息模型——队列和主题模式 分析了 RocketMQ 的技术架构(NameServer 、Broker 、Producer 、Comsumer) 结合 RocketMQ 回答了消息队列副作用的解决方案 介绍了 RocketMQ 的存储机制和刷盘策略。 等等。。。\n如果喜欢可以点赞哟👍👍👍。\n著作权归所有 原文链接:https://javaguide.cn/high-performance/message-queue/rocketmq-intro.html\n"},{"id":268,"href":"/zh/docs/technology/Review/java_guide/lyely_high-performance/message-mq/base/","title":"message-queue","section":"RocketMQ","content":" 转载自https://github.com/Snailclimb/JavaGuide(添加小部分笔记)感谢作者!\n“RabbitMQ?”“Kafka?”“RocketMQ?”\u0026hellip;在日常学习与开发过程中,我们常常听到消息队列这个关键词。我也在我的多篇文章中提到了这个概念。可能你是熟练使用消息队列的老手,又或者你是不懂消息队列的新手,不论你了不了解消息队列,本文都将带你搞懂消息队列的一些基本理论。\n如果你是老手,你可能从本文学到你之前不曾注意的一些关于消息队列的重要概念,如果你是新手,相信本文将是你打开消息队列大门的一板砖。\n什么是消息队列? # 我们可以把消息队列看作是一个存放消息的容器,当我们需要使用消息的时候,直接从容器中取出消息供自己使用即可。由于队列 Queue 是一种先进先出的数据结构,所以消费消息时也是按照顺序来消费的。\n参与消息传递的双方称为生产者和消费者,生产者负责发送消息,消费者负责处理消息。\n我们知道操作系统中的进程通信的一种很重要的方式就是消息队列。我们这里提到的消息队列稍微有点区别,更多指的是各个服务以及系统内部各个组件/模块之前的通信,属于一种中间件。\n随着分布式和微服务系统的发展,消息队列在系统设计中有了更大的发挥空间,使用消息队列可以降低系统耦合性、实现任务异步、有效地进行流量削峰,是分布式和微服务系统中重要的组件之一。\n消息队列有什么用? # 通常来说,使用消息队列能为我们的系统带来下面三点好处:\n通过异步处理提高系统性能(减少响应所需时间)。 削峰/限流 降低系统耦合性。 如果在面试的时候你被面试官问到这个问题的话,一般情况是你在你的简历上涉及到消息队列这方面的内容,这个时候推荐你结合你自己的项目来回答。\n通过异步处理提高系统性能(减少响应所需时间) # 将用户的请求数据存储到消息队列之后就立即返回结果。随后,系统再对消息进行消费。\n因为用户请求数据写入消息队列之后就立即返回给用户了,但是请求数据在后续的业务校验、写数据库等操作中可能失败。因此,使用消息队列进行异步处理之后,需要适当修改业务流程进行配合,比如用户在提交订单之后,订单数据写入消息队列,不能立即返回用户订单提交成功,需要在消息队列的订单消费者进程真正处理完该订单之后,甚至出库后,再通过电子邮件或短信通知用户订单成功,以免交易纠纷。这就类似我们平时手机订火车票和电影票。\n削峰/限流 # 先将短时间高并发产生的事务消息存储在消息队列中,然后后端服务再慢慢根据自己的能力去消费这些消息,这样就避免直接把后端服务打垮掉。\n举例:在电子商务一些秒杀、促销活动中,合理使用消息队列可以有效抵御促销活动刚开始大量订单涌入对系统的冲击。如下图所示:\n降低系统耦合性 # 使用消息队列还可以降低系统耦合性。我们知道如果模块之间不存在直接调用,那么新增模块或者修改模块就对其他模块影响较小,这样系统的可扩展性无疑更好一些。还是直接上图吧:\n生产者(客户端)发送消息到消息队列中去,接受者(服务端)处理消息,需要消费的系统直接去消息队列取消息进行消费即可而不需要和其他系统有耦合,这显然也提高了系统的扩展性。\n消息队列使用发布-订阅模式工作,消息发送者(生产者)发布消息,一个或多个消息接受者(消费者)订阅消息。 从上图可以看到消息发送者(生产者)和消息接受者(消费者)之间没有直接耦合,消息发送者将消息发送至分布式消息队列即结束对消息的处理,消息接受者从分布式消息队列获取该消息后进行后续处理,并不需要知道该消息从何而来。对新增业务,只要对该类消息感兴趣,即可订阅该消息,对原有系统和业务没有任何影响,从而实现网站业务的可扩展性设计。\n消息接受者对消息进行过滤、处理、包装后,构造成一个新的消息类型,将消息继续发送出去,等待其他消息接受者订阅该消息。因此基于事件(消息对象)驱动的业务架构可以是一系列流程。\n另外,为了避免消息队列服务器宕机造成消息丢失,会将成功发送到消息队列的消息存储在消息生产者服务器上,等消息真正被消费者服务器处理后才删除消息。在消息队列服务器宕机后,生产者服务器会选择分布式消息队列服务器集群中的其他服务器发布消息。\n备注: 不要认为消息队列只能利用发布-订阅模式工作,只不过在解耦这个特定业务环境下是使用发布-订阅模式的。除了发布-订阅模式,还有点对点订阅模式(一个消息只有一个消费者),我们比较常用的是发布-订阅模式。另外,这两种消息模型是 JMS 提供的,AMQP 协议还提供了 5 种消息模型。\n使用消息队列哪些问题? # 系统可用性降低: 系统可用性在某种程度上降低,为什么这样说呢?在加入 MQ 之前,你不用考虑消息丢失或者说 MQ 挂掉等等的情况,但是,引入 MQ 之后你就需要去考虑了! 系统复杂性提高: 加入 MQ 之后,你需要保证消息没有被重复消费、处理消息丢失的情况、保证消息传递的顺序性等等问题! 一致性问题: 我上面讲了消息队列可以实现异步,消息队列带来的异步确实可以提高系统响应速度。但是,万一消息的真正消费者并没有正确消费消息怎么办?这样就会导致数据不一致的情况了! JMS 和 AMQP # JMS 是什么? # JMS(JAVA Message Service,java 消息服务)是 java 的消息服务,JMS 的客户端之间可以通过 JMS 服务进行异步的消息传输。JMS(JAVA Message Service,Java 消息服务)API 是一个消息服务的标准或者说是规范,允许应用程序组件基于 JavaEE 平台创建、发送、接收和读取消息。它使分布式通信耦合度更低,消息服务更加可靠以及异步性。\nJMS 定义了五种不同的消息正文格式以及调用的消息类型,允许你发送并接收以一些不同形式的数据:\nStreamMessage:Java 原始值的数据流 MapMessage:一套名称-值对 TextMessage:一个字符串对象 ObjectMessage:一个序列化的 Java 对象 BytesMessage:一个字节的数据流 ActiveMQ(已被淘汰) 就是基于 JMS 规范实现的。\nJMS 两种消息模型 # 点到点(P2P)模型 # 使用**队列(Queue)*作为消息通信载体;满足*生产者与消费者模式,一条消息只能被一个消费者使用,未被消费的消息在队列中保留直到被消费或超时。比如:我们生产者发送 100 条消息的话,两个消费者来消费一般情况下两个消费者会按照消息发送的顺序各自消费一半(也就是你一个我一个的消费。)\n发布/订阅(Pub/Sub)模型 # 发布订阅模型(Pub/Sub) 使用**主题(Topic)*作为消息通信载体,类似于*广播模式;发布者发布一条消息,该消息通过主题传递给所有的订阅者,在一条消息广播之后才订阅的用户则是收不到该条消息的。\nAMQP 是什么? # AMQP,即 Advanced Message Queuing Protocol,一个提供统一消息服务的应用层标准 高级消息队列协议(二进制应用层协议),是应用层协议的一个开放标准,为面向消息的中间件设计,兼容 JMS。基于此协议的客户端与消息中间件可传递消息,并不受客户端/中间件同产品,不同的开发语言等条件的限制。\nRabbitMQ 就是基于 AMQP 协议实现的。\nJMS vs AMQP # 对比方向 JMS AMQP 定义 Java API 协议 跨语言 否 是 跨平台 否 是 支持消息类型 提供两种消息模型:①Peer-2-Peer;②Pub/sub 提供了五种消息模型:①direct exchange;②fanout exchange;③topic change;④headers exchange;⑤system exchange。本质来讲,后四种和 JMS 的 pub/sub 模型没有太大差别,仅是在路由机制上做了更详细的划分; 支持消息类型 支持多种消息类型 ,我们在上面提到过 byte[](二进制) 总结:\nAMQP 为消息定义了线路层(wire-level protocol)的协议,而 JMS 所定义的是 API 规范。在 Java 体系中,多个 client 均可以通过 JMS 进行交互,不需要应用修改代码,但是其对跨平台的支持较差。而 AMQP 天然具有跨平台、跨语言特性。 JMS 支持 TextMessage、MapMessage 等复杂的消息类型;而 AMQP 仅支持 byte[] 消息类型(复杂的类型可序列化后发送)。 由于 Exchange 提供的路由算法,AMQP 可以提供多样化的路由方式来传递消息到消息队列,而 JMS 仅支持 队列 和 主题/订阅 方式两种。 消息队列技术选型 # 常见的消息队列有哪些? # Kafka # Kafka 是 LinkedIn 开源的一个分布式流式处理平台,已经成为 Apache 顶级项目,早期被用来用于处理海量的日志,后面才慢慢发展成了一款功能全面的高性能消息队列。\n流式处理平台具有三个关键功能:\n消息队列:发布和订阅消息流,这个功能类似于消息队列,这也是 Kafka 也被归类为消息队列的原因。 容错的持久方式存储记录消息流: Kafka 会把消息持久化到磁盘,有效避免了消息丢失的风险。 流式处理平台: 在消息发布的时候进行处理,Kafka 提供了一个完整的流式处理类库。 Kafka 是一个分布式系统,由通过高性能 TCP 网络协议进行通信的服务器和客户端组成,可以部署在在本地和云环境中的裸机硬件、虚拟机和容器上。\n在 Kafka 2.8 之前,Kafka 最被大家诟病的就是其重度依赖于 Zookeeper 做元数据管理和集群的高可用。在 Kafka 2.8 之后,引入了基于 Raft 协议的 KRaft 模式,不再依赖 Zookeeper,大大简化了 Kafka 的架构,让你可以以一种轻量级的方式来使用 Kafka。\n不过,要提示一下:如果要使用 KRaft 模式的话,建议选择较高版本的 Kafka,因为这个功能还在持续完善优化中。Kafka 3.3.1 版本是第一个将 KRaft(Kafka Raft)共识协议标记为生产就绪的版本。\nKafka 官网:http://kafka.apache.org/\nKafka 更新记录(可以直观看到项目是否还在维护):https://kafka.apache.org/downloads\nRocketMQ # RocketMQ 是阿里开源的一款云原生“消息、事件、流”实时数据处理平台,借鉴了 Kafka,已经成为 Apache 顶级项目。\nRocketMQ 的核心特性(摘自 RocketMQ 官网):\n云原生:生与云,长与云,无限弹性扩缩,K8s 友好 高吞吐:万亿级吞吐保证,同时满足微服务与大数据场景。 流处理:提供轻量、高扩展、高性能和丰富功能的流计算引擎。 金融级:金融级的稳定性,广泛用于交易核心链路。 架构极简:零外部依赖,Shared-nothing 架构。 生态友好:无缝对接微服务、实时计算、数据湖等周边生态。 根据官网介绍:\nApache RocketMQ 自诞生以来,因其架构简单、业务功能丰富、具备极强可扩展性等特点被众多企业开发者以及云厂商广泛采用。历经十余年的大规模场景打磨,RocketMQ 已经成为业内共识的金融级可靠业务消息首选方案,被广泛应用于互联网、大数据、移动互联网、物联网等领域的业务场景。\nRocketMQ 官网:https://rocketmq.apache.org/ (文档很详细,推荐阅读)\nRocketMQ 更新记录(可以直观看到项目是否还在维护):https://github.com/apache/rocketmq/releases\nRabbitMQ # RabbitMQ 是采用 Erlang 语言实现 AMQP(Advanced Message Queuing Protocol,高级消息队列协议)的消息中间件,它最初起源于金融系统,用于在分布式系统中存储转发消息。\nRabbitMQ 发展到今天,被越来越多的人认可,这和它在易用性、扩展性、可靠性和高可用性等方面的卓著表现是分不开的。RabbitMQ 的具体特点可以概括为以下几点:\n可靠性: RabbitMQ 使用一些机制来保证消息的可靠性,如持久化、传输确认及发布确认等。 灵活的路由: 在消息进入队列之前,通过交换器来路由消息。对于典型的路由功能,RabbitMQ 己经提供了一些内置的交换器来实现。针对更复杂的路由功能,可以将多个交换器绑定在一起,也可以通过插件机制来实现自己的交换器。这个后面会在我们讲 RabbitMQ 核心概念的时候详细介绍到。 扩展性: 多个 RabbitMQ 节点可以组成一个集群,也可以根据实际业务情况动态地扩展集群中节点。 高可用性: 队列可以在集群中的机器上设置镜像,使得在部分节点出现问题的情况下队列仍然可用。 支持多种协议: RabbitMQ 除了原生支持 AMQP 协议,还支持 STOMP、MQTT 等多种消息中间件协议。 多语言客户端: RabbitMQ 几乎支持所有常用语言,比如 Java、Python、Ruby、PHP、C#、JavaScript 等。 易用的管理界面: RabbitMQ 提供了一个易用的用户界面,使得用户可以监控和管理消息、集群中的节点等。在安装 RabbitMQ 的时候会介绍到,安装好 RabbitMQ 就自带管理界面。 插件机制: RabbitMQ 提供了许多插件,以实现从多方面进行扩展,当然也可以编写自己的插件。感觉这个有点类似 Dubbo 的 SPI 机制 RabbitMQ 官网:https://www.rabbitmq.com/ 。\nRabbitMQ 更新记录(可以直观看到项目是否还在维护):https://www.rabbitmq.com/news.html\nPulsar # Pulsar 是下一代云原生分布式消息流平台,最初由 Yahoo 开发 ,已经成为 Apache 顶级项目。\nPulsar 集消息、存储、轻量化函数式计算为一体,采用计算与存储分离架构设计,支持多租户、持久化存储、多机房跨区域数据复制,具有强一致性、高吞吐、低延时及高可扩展性等流数据存储特性,被看作是云原生时代实时消息流传输、存储和计算最佳解决方案。\nPulsar 的关键特性如下(摘自官网):\n是下一代云原生分布式消息流平台。 Pulsar 的单个实例原生支持多个集群,可跨机房在集群间无缝地完成消息复制。 极低的发布延迟和端到端延迟。 可无缝扩展到超过一百万个 topic。 简单的客户端 API,支持 Java、Go、Python 和 C++。 主题的多种订阅模式(独占、共享和故障转移)。 通过 Apache BookKeeper 提供的持久化消息存储机制保证消息传递 。 由轻量级的 serverless 计算框架 Pulsar Functions 实现流原生的数据处理。 基于 Pulsar Functions 的 serverless connector 框架 Pulsar IO 使得数据更易移入、移出 Apache Pulsar。 分层式存储可在数据陈旧时,将数据从热存储卸载到冷/长期存储(如 S3、GCS)中。 Pulsar 官网:https://pulsar.apache.org/\nPulsar 更新记录(可以直观看到项目是否还在维护):https://github.com/apache/pulsar/releases\nActiveMQ # 目前已经被淘汰,不推荐使用,不建议学习。\n如何选择? # 参考《Java 工程师面试突击第 1 季-中华石杉老师》\n对比方向 概要 吞吐量 万级的 ActiveMQ 和 RabbitMQ 的吞吐量(ActiveMQ 的性能最差)要比十万级甚至是百万级的 RocketMQ 和 Kafka 低一个数量级。 可用性 都可以实现高可用。ActiveMQ 和 RabbitMQ 都是基于主从架构实现高可用性。RocketMQ 基于分布式架构。 Kafka 也是分布式的,一个数据多个副本,少数机器宕机,不会丢失数据,不会导致不可用 时效性 RabbitMQ 基于 Erlang 开发,所以并发能力很强,性能极其好,延时很低,达到微秒级,其他几个都是 ms 级。 功能支持 Pulsar 的功能更全面,支持多租户、多种消费模式和持久性模式等功能,是下一代云原生分布式消息流平台。 消息丢失 ActiveMQ 和 RabbitMQ 丢失的可能性非常低, Kafka、RocketMQ 和 Pulsar 理论上可以做到 0 丢失。 总结:\nActiveMQ 的社区算是比较成熟,但是较目前来说,ActiveMQ 的性能比较差,而且版本迭代很慢,不推荐使用,已经被淘汰了。 RabbitMQ 在吞吐量方面虽然稍逊于 Kafka 、RocketMQ 和 Pulsar,但是由于它基于 Erlang 开发,所以并发能力很强,性能极其好,延时很低,达到微秒级。但是也因为 RabbitMQ 基于 Erlang 开发,所以国内很少有公司有实力做 Erlang 源码级别的研究和定制。如果业务场景对并发量要求不是太高(十万级、百万级),那这几种消息队列中,RabbitMQ 或许是你的首选。 RocketMQ 和 Pulsar 支持强一致性,对消息一致性要求比较高的场景可以使用。 RocketMQ 阿里出品,Java 系开源项目,源代码我们可以直接阅读,然后可以定制自己公司的 MQ,并且 RocketMQ 有阿里巴巴的实际业务场景的实战考验。 Kafka 的特点其实很明显,就是仅仅提供较少的核心功能,但是提供超高的吞吐量,ms 级的延迟,极高的可用性以及可靠性,而且分布式可以任意扩展。同时 kafka 最好是支撑较少的 topic 数量即可,保证其超高吞吐量。Kafka 唯一的一点劣势是有可能消息重复消费,那么对数据准确性会造成极其轻微的影响,在大数据领域中以及日志采集中,这点轻微影响可以忽略这个特性天然适合大数据实时计算以及日志收集。如果是大数据领域的实时计算、日志采集等场景,用 Kafka 是业内标准的,绝对没问题,社区活跃度很高,绝对不会黄,何况几乎是全世界这个领域的事实性规范。 参考 # KRaft: Apache Kafka Without ZooKeeper:https://developer.confluent.io/learn/kraft/ "},{"id":269,"href":"/zh/docs/technology/Review/java_guide/lyely_high-performance/ly02ly_cdn/","title":"cdn","section":"高性能","content":" 转载自https://github.com/Snailclimb/JavaGuide(添加小部分笔记)感谢作者!\n什么是 CDN ? # CDN 全称是 Content Delivery Network/Content Distribution Network,翻译过的意思是 内容分发网络 。\n我们可以将内容分发网络拆开来看:\n内容 :指的是静态资源比如图片、视频、文档、JS、CSS、HTML。 分发网络 :指的是将这些静态资源分发到位于多个不同的地理位置机房中的服务器上,这样,就可以实现静态资源的就近访问比如北京的用户直接访问北京机房的数据。 所以,简单来说,CDN 就是将静态资源分发到多个不同的地方以实现就近访问,进而加快静态资源的访问速度,减轻服务器以及带宽的负担。\n类似于京东建立的庞大的仓储运输体系,京东物流在全国拥有非常多的仓库,仓储网络几乎覆盖全国所有区县。这样的话,用户下单的第一时间,商品就从距离用户最近的仓库,直接发往对应的配送站,再由京东小哥送到你家。\n你可以将 CDN 看作是服务上一层的特殊缓存服务,分布在全国各地,主要用来处理静态资源的请求。\n我们经常拿全站加速和内容分发网络做对比,不要把两者搞混了!全站加速(不同云服务商叫法不同,腾讯云叫 ECDN、阿里云叫 DCDN)既可以加速静态资源又可以加速动态资源,**内容分发网络(CDN)**主要针对的是 静态资源 。\n绝大部分公司都会在项目开发中交使用 CDN 服务,但很少会有自建 CDN 服务的公司。基于成本、稳定性和易用性考虑,建议直接选择专业的云厂商(比如阿里云、腾讯云、华为云、青云)或者 CDN 厂商(比如网宿、蓝汛)提供的开箱即用的 CDN 服务。\n很多朋友可能要问了:既然是就近访问,为什么不直接将服务部署在多个不同的地方呢?\n成本太高,需要部署多份相同的服务。 静态资源通常占用空间比较大且经常会被访问到,如果直接使用服务器或者缓存来处理静态资源请求的话,对系统资源消耗非常大,可能会影响到系统其他服务的正常运行。 同一个服务在在多个不同的地方部署多份(比如同城灾备、异地灾备、同城多活、异地多活)是为了实现系统的高可用而不是就近访问。\nCDN 工作原理是什么? # 搞懂下面 3 个问题也就搞懂了 CDN 的工作原理:\n静态资源是如何被缓存到 CDN 节点中的? 如何找到最合适的 CDN 节点? 如何防止静态资源被盗用? 静态资源是如何被缓存到 CDN 节点中的? # 你可以通过预热的方式将源站的资源同步到 CDN 的节点中。这样的话,用户首次请求资源可以直接从 CDN 节点中取,无需回源。这样可以降低源站压力,提升用户体验。\n如果不预热的话,你访问的资源可能不再 CDN 节点中,这个时候 CDN 节点将请求源站获取资源,这个过程是大家经常说的 回源。\n命中率 和 回源率 是衡量 CDN 服务质量两个重要指标。命中率越高越好,回源率越低越好。\n如果资源有更新的话,你也可以对其 刷新 ,删除 CDN 节点上缓存的资源,当用户访问对应的资源时直接回源获取最新的资源,并重新缓存。\n如何找到最合适的 CDN 节点? # **GSLB (Global Server Load Balance,全局负载均衡)**是 CDN 的大脑,负责多个CDN节点之间相互协作,最常用的是基于 DNS 的 GSLB。\nCDN 会通过 GSLB 找到最合适的 CDN 节点,更具体点来说是下面这样的:\n浏览器向 DNS 服务器发送域名请求; DNS 服务器向根据 CNAME( Canonical Name ) 别名记录向 GSLB 发送请求; GSLB 返回性能最好(通常距离请求地址最近)的 CDN 节点(边缘服务器,真正缓存内容的地方)的地址给浏览器; 浏览器直接访问指定的 CDN 节点。 为了方便理解,上图其实做了一点简化。GSLB 内部可以看作是 CDN 专用 DNS 服务器和负载均衡系统组合。CDN 专用 DNS 服务器会返回负载均衡系统 IP 地址给浏览器,浏览器使用 IP 地址请求负载均衡系统进而找到对应的 CDN 节点。\nGSLB 是如何选择出最合适的 CDN 节点呢? GSLB 会根据请求的 IP 地址、CDN 节点状态(比如负载情况、性能、响应时间、带宽)等指标来综合判断具体返回哪一个 CDN 节点的地址。\n如何防止资源被盗刷? # 如果我们的资源被其他用户或者网站非法盗刷的话,将会是一笔不小的开支。\n解决这个问题最常用最简单的办法设置 Referer 防盗链,具体来说就是根据 HTTP 请求的头信息里面的 Referer 字段对请求进行限制。我们可以通过 Referer 字段获取到当前请求页面的来源页面的网站地址,这样我们就能确定请求是否来自合法的网站。\nCDN 服务提供商几乎都提供了这种比较基础的防盗链机制。\n不过,如果站点的防盗链配置允许 Referer 为空的话,通过隐藏 Referer,可以直接绕开防盗链。\n通常情况下,我们会配合其他机制来确保静态资源被盗用,一种常用的机制是 时间戳防盗链 。相比之下,时间戳防盗链 的安全性更强一些。时间戳防盗链加密的 URL 具有时效性,过期之后就无法再被允许访问。\n时间戳防盗链的 URL 通常会有两个参数一个是签名字符串,一个是过期时间。签名字符串一般是通过对用户设定的加密字符串、请求路径、过期时间通过 MD5 哈希算法取哈希的方式获得。\n注意,这个用户设定的加密字符串,是在后台配置的,而签名是后台给前端的\n(1)用户管理员在七牛 CDN 控制台 配置 key ,并将 key配置进业务服务器。 (2)当客户端请求资源时,将原始 url 发送至业务服务器。 (3)业务服务器根据 计算逻辑,将带有时间戳签名的 url 返回至客户端。 (4)客户端使用带有时间戳签名的 url 请求资源。 (5)CDN 检查 url 签名的合法性。\n时间戳防盗链 URL示例:\nhttp://cdn.wangsu.com/4/123.mp3? wsSecret=79aead3bd7b5db4adeffb93a010298b5\u0026amp;wsTime=1601026312 wsSecret :签名字符串。 wsTime: 过期时间。 时间戳防盗链的实现也比较简单,并且可靠性较高,推荐使用。并且,绝大部分 CDN 服务提供商都提供了开箱即用的时间戳防盗链机制。\n除了 Referer 防盗链和时间戳防盗链之外,你还可以 IP 黑白名单配置、IP 访问限频配置等机制来防盗刷。\n总结 # CDN 就是将静态资源分发到多个不同的地方以实现就近访问,进而加快静态资源的访问速度,减轻服务器以及带宽的负担。 基于成本、稳定性和易用性考虑,建议直接选择专业的云厂商(比如阿里云、腾讯云、华为云、青云)或者 CDN 厂商(比如网宿、蓝汛)提供的开箱即用的 CDN 服务。 GSLB (Global Server Load Balance,全局负载均衡)是 CDN 的大脑,负责多个 CDN 节点之间相互协作,最常用的是基于 DNS 的 GSLB。CDN 会通过 GSLB 找到最合适的 CDN 节点。 为了防止静态资源被盗用,我们可以利用 Referer 防盗链 + 时间戳防盗链 。 参考 # 时间戳防盗链 - 七牛云 CDN:https://developer.qiniu.com/fusion/kb/1670/timestamp-hotlinking-prevention CDN是个啥玩意?一文说个明白:https://mp.weixin.qq.com/s/Pp0C8ALUXsmYCUkM5QnkQw 《透视 HTTP 协议》- 37 | CDN:加速我们的网络服务:http://gk.link/a/11yOG "},{"id":270,"href":"/zh/docs/technology/Review/java_guide/lyely_high-performance/ly01ly_read-and-write-separation-and-library-subtable/","title":"数据库读写分离\u0026分库分表详解","section":"高性能","content":" 转载自https://github.com/Snailclimb/JavaGuide(添加小部分笔记)感谢作者!\n读写分离 # 什么是读写分离? # 见名思意,根据读写分离的名字,我们就可以知道:读写分离主要是为了将对数据库的读写操作分散到不同的数据库节点上。 这样的话,就能够小幅提升写性能,大幅提升读性能。\n我简单画了一张图来帮助不太清楚读写分离的小伙伴理解。\n一般情况下,我们都会选择一主多从,也就是一台主数据库负责写,其他的从数据库负责读。主库和从库之间会进行数据同步,以保证从库中数据的准确性。这样的架构实现起来比较简单,并且也符合系统的写少读多的特点。\n读写分离会带来什么问题?如何解决? # 读写分离对于提升数据库的并发非常有效,但是,同时也会引来一个问题:主库和从库的数据存在延迟,比如你写完主库之后,主库的数据同步到从库是需要时间的,这个时间差就导致了主库和从库的数据不一致性问题。这也就是我们经常说的 主从同步延迟 。\n主从同步延迟问题的解决,没有特别好的一种方案(可能是我太菜了,欢迎评论区补充)。你可以根据自己的业务场景,参考一下下面几种解决办法。\n1.强制将读请求路由到主库处理。\n既然你从库的数据过期了,那我就直接从主库读取嘛!这种方案虽然会增加主库的压力,但是,实现起来比较简单,也是我了解到的使用最多的一种方式。\n比如 Sharding-JDBC 就是采用的这种方案。通过使用 Sharding-JDBC 的 HintManager 分片键值管理器,我们可以强制使用主库。\nHintManager hintManager = HintManager.getInstance(); hintManager.setMasterRouteOnly(); // 继续JDBC操作 对于这种方案,你可以将那些必须获取最新数据的读请求都交给主库处理。\n2.延迟读取。\n还有一些朋友肯定会想既然主从同步存在延迟,那我就在延迟之后读取啊,比如主从同步延迟 0.5s,那我就 1s 之后再读取数据。这样多方便啊!方便是方便,但是也很扯淡。\n不过,如果你是这样设计业务流程就会好很多:对于一些对数据比较敏感的场景,你可以在完成写请求之后,避免立即进行请求操作。比如你支付成功之后,跳转到一个支付成功的页面,当你点击返回之后才返回自己的账户。\n另外, 《MySQL 实战 45 讲》这个专栏中的 《读写分离有哪些坑?》这篇文章还介绍了很多其他比较实际的解决办法,感兴趣的小伙伴可以自行研究一下。\n如何实现读写分离? # 不论是使用哪一种读写分离具体的实现方案,想要实现读写分离一般包含如下几步:\n部署多台数据库,选择其中的一台作为主数据库,其他的一台或者多台作为从数据库。 保证主数据库和从数据库之间的数据是实时同步的,这个过程也就是我们常说的主从复制。 系统将写请求交给主数据库处理,读请求交给从数据库处理。[ 使用上 ] 落实到项目本身的话,常用的方式有两种:\n1.代理方式\n我们可以在应用和数据中间加了一个代理层。应用程序所有的数据请求都交给代理层处理,代理层负责分离读写请求,将它们路由到对应的数据库中。\n提供类似功能的中间件有 MySQL Router(官方)、Atlas(基于 MySQL Proxy)、Maxscale、MyCat。\n2.组件方式\n在这种方式中,我们可以通过引入第三方组件来帮助我们读写请求。\n这也是我比较推荐的一种方式。这种方式目前在各种互联网公司中用的最多的,相关的实际的案例也非常多。如果你要采用这种方式的话,推荐使用 sharding-jdbc ,直接引入 jar 包即可使用,非常方便。同时,也节省了很多运维的成本。\n你可以在 shardingsphere 官方找到 sharding-jdbc 关于读写分离的操作。\n主从复制原理是什么? # MySQL binlog(binary log 即二进制日志文件) 主要记录了 MySQL 数据库中数据的所有变化(数据库执行的所有 DDL 和 DML 语句)。因此,我们根据主库的 MySQL binlog 日志就能够将主库的数据同步到从库中。\n更具体和详细的过程是这个样子的(图片来自于: 《MySQL Master-Slave Replication on the Same Machine》):\n主库将数据库中数据的变化写入到 binlog 从库连接主库 从库会创建一个 I/O 线程向主库请求更新的 binlog 主库会创建一个 binlog dump 线程来发送 binlog ,从库中的 I/O 线程负责接收 从库的 I/O 线程将接收的 binlog 写入到 relay log 中。 从库的 SQL 线程读取 relay log 同步数据本地(也就是再执行一遍 SQL )。 怎么样?看了我对主从复制这个过程的讲解,你应该搞明白了吧!\n你一般看到 binlog 就要想到主从复制。当然,除了主从复制之外,binlog 还能帮助我们实现数据恢复。\n🌈 拓展一下:\n不知道大家有没有使用过阿里开源的一个叫做 canal 的工具。这个工具可以帮助我们实现 MySQL 和其他数据源比如 Elasticsearch 或者另外一台 MySQL 数据库之间的数据同步。很显然,这个工具的底层原理肯定也是依赖 binlog。canal 的原理就是模拟 MySQL 主从复制的过程,解析 binlog 将数据同步到其他的数据源。\n另外,像咱们常用的分布式缓存组件 Redis 也是通过主从复制实现的读写分离。\n🌕 简单总结一下:\nMySQL 主从复制是依赖于 binlog 。另外,常见的一些同步 MySQL 数据到其他数据源的工具(比如 canal)的底层一般也是依赖 binlog 。\n分库分表 # 读写分离主要应对的是数据库读并发,没有解决数据库存储问题。试想一下:如果 MySQL 一张表的数据量过大怎么办?\n换言之,我们该如何解决 MySQL 的存储压力呢?\n答案之一就是 分库分表。\n什么是分库? # 分库 就是将数据库中的数据分散到不同的数据库上,可以垂直分库,也可以水平分库。\n垂直分库 就是把单一数据库按照业务进行划分,不同的业务使用不同的数据库,进而将一个数据库的压力分担到多个数据库。\n举个例子:说你将数据库中的用户表、订单表和商品表分别单独拆分为用户数据库、订单数据库和商品数据库。\n水平分库 是把同一个表按一定规则拆分到不同的数据库中,每个库可以位于不同的服务器上,这样就实现了水平扩展,解决了单表的存储和性能瓶颈的问题。\n举个例子:订单表数据量太大,你对订单表进行了水平切分(水平分表),然后将切分后的 2 张订单表分别放在两个不同的数据库。\n什么是分表? # 分表 就是对单表的数据进行拆分,可以是垂直拆分,也可以是水平拆分。\n垂直分表 是对数据表列的拆分,把一张列比较多的表拆分为多张表。\n举个例子:我们可以将用户信息表中的一些列单独抽出来作为一个表。\n水平分表 是对数据表行的拆分,把一张行比较多的表拆分为多张表,可以解决单一表数据量过大的问题。\n举个例子:我们可以将用户信息表拆分成多个用户信息表,这样就可以避免单一表数据量过大对性能造成影响。\n水平拆分只能解决单表数据量大的问题,为了提升性能,我们通常会选择将拆分后的多张表放在不同的数据库中。也就是说,水平分表通常和水平分库同时出现。\n什么情况下需要分库分表? # 遇到下面几种场景可以考虑分库分表:\n单表的数据达到千万级别以上,数据库读写速度比较缓慢。 数据库中的数据占用的空间越来越大,备份时间越来越长。 应用的并发量太大。 常见的分片算法有哪些? # 分片算法主要解决了数据被水平分片之后,数据究竟该存放在哪个表的问题。\n哈希分片 :求指定 key(比如 id) 的哈希,然后根据哈希值确定数据应被放置在哪个表中。哈希分片比较适合随机读写的场景,不太适合经常需要范围查询的场景。 范围分片 :按照特性的范围区间(比如时间区间、ID区间)来分配数据,比如 将 id 为 1~299999 的记录分到第一个库, 300000~599999 的分到第二个库。范围分片适合需要经常进行范围查找的场景,不太适合随机读写的场景(数据未被分散,容易出现热点数据的问题)。 地理位置分片 :很多 NewSQL 数据库都支持地理位置分片算法,也就是根据地理位置(如城市、地域)来分配数据。 融合算法 :灵活组合多种分片算法,比如将哈希分片和范围分片组合。 \u0026hellip;\u0026hellip; 分库分表会带来什么问题呢? # 记住,你在公司做的任何技术决策,不光是要考虑这个技术能不能满足我们的要求,是否适合当前业务场景,还要重点考虑其带来的成本。\n引入分库分表之后,会给系统带来什么挑战呢?\njoin 操作 : 同一个数据库中的表分布在了不同的数据库中,导致无法使用 join 操作。这样就导致我们需要手动进行数据的封装,比如你在一个数据库中查询到一个数据之后,再根据这个数据去另外一个数据库中找对应的数据。 事务问题 :同一个数据库中的表分布在了不同的数据库中,如果单个操作涉及到多个数据库,那么数据库自带的事务就无法满足我们的要求了。 分布式 id :分库之后, 数据遍布在不同服务器上的数据库,数据库的自增主键已经没办法满足生成的主键唯一了。我们如何为不同的数据节点生成全局唯一主键呢?这个时候,我们就需要为我们的系统引入分布式 id 了。 \u0026hellip;\u0026hellip; 另外,引入分库分表之后,一般需要 DBA 的参与,同时还需要更多的数据库服务器,这些都属于成本。\n分库分表有没有什么比较推荐的方案? # ShardingSphere 项目(包括 Sharding-JDBC、Sharding-Proxy 和 Sharding-Sidecar)是当当捐入 Apache 的,目前主要由京东数科的一些巨佬维护。\nShardingSphere 绝对可以说是当前分库分表的首选!ShardingSphere 的功能完善,除了支持读写分离和分库分表,还提供分布式事务、数据库治理等功能。\n另外,ShardingSphere 的生态体系完善,社区活跃,文档完善,更新和发布比较频繁。\n艿艿之前写了一篇分库分表的实战文章,各位朋友可以看看: 《芋道 Spring Boot 分库分表入门》 。\n分库分表后,数据怎么迁移呢? # 分库分表之后,我们如何将老库(单库单表)的数据迁移到新库(分库分表后的数据库系统)呢?\n比较简单同时也是非常常用的方案就是停机迁移,写个脚本老库的数据写到新库中。比如你在凌晨 2 点,系统使用的人数非常少的时候,挂一个公告说系统要维护升级预计 1 小时。然后,你写一个脚本将老库的数据都同步到新库中。\n如果你不想停机迁移数据的话,也可以考虑双写方案。双写方案是针对那种不能停机迁移的场景,实现起来要稍微麻烦一些。具体原理是这样的:\n我们对老库的更新操作(增删改),同时也要写入新库(双写)。如果操作的数据不存在于新库的话,需要插入到新库中。 这样就能保证,咱们新库里的数据是最新的。 在迁移过程,双写只会让被更新操作过的老库中的数据同步到新库,我们还需要自己写脚本将老库中的数据(这里说的就是原来老库中的数据但是没有设计更新操作)和新库的数据做比对。如果新库中没有,那咱们就把数据插入到新库。如果新库有,旧库没有,就把新库对应的数据删除(冗余数据清理)。 重复上一步的操作,直到老库和新库的数据一致为止。 想要在项目中实施双写还是比较麻烦的,很容易会出现问题。我们可以借助上面提到的数据库同步工具 Canal 做增量数据迁移(还是依赖 binlog,开发和维护成本较低)。\n总结 # 读写分离主要是为了将对数据库的读写操作分散到不同的数据库节点上。 这样的话,就能够小幅提升写性能,大幅提升读性能。 读写分离基于主从复制,MySQL 主从复制是依赖于 binlog 。 分库 就是将数据库中的数据分散到不同的数据库上。分表 就是对单表的数据进行拆分,可以是垂直拆分,也可以是水平拆分。 引入分库分表之后,需要系统解决事务、分布式 id、无法 join 操作问题。 ShardingSphere 绝对可以说是当前分库分表的首选!ShardingSphere 的功能完善,除了支持读写分离和分库分表,还提供分布式事务、数据库治理等功能。另外,ShardingSphere 的生态体系完善,社区活跃,文档完善,更新和发布比较频繁。 "},{"id":271,"href":"/zh/docs/technology/Review/java_guide/lydly_distributed_system/ly08ly_zookeeper-in-action/","title":"zookeeper实战","section":"分布式系统","content":" 转载自https://github.com/Snailclimb/JavaGuide(添加小部分笔记)感谢作者!\n1. 前言 # 这篇文章简单给演示一下 ZooKeeper 常见命令的使用以及 ZooKeeper Java客户端 Curator 的基本使用。介绍到的内容都是最基本的操作,能满足日常工作的基本需要。\n如果文章有任何需要改善和完善的地方,欢迎在评论区指出,共同进步!\n2. ZooKeeper 安装和使用 # 2.1. 使用Docker 安装 zookeeper # a.使用 Docker 下载 ZooKeeper\ndocker pull zookeeper:3.5.8 b.运行 ZooKeeper\ndocker run -d --name zookeeper -p 2181:2181 zookeeper:3.5.8 2.2. 连接 ZooKeeper 服务 # a.进入ZooKeeper容器中\n先使用 docker ps 查看 ZooKeeper 的 ContainerID,然后使用 docker exec -it ContainerID /bin/bash 命令进入容器中。\nb.先进入 bin 目录,然后通过 ./zkCli.sh -server 127.0.0.1:2181命令连接ZooKeeper 服务\nroot@eaf70fc620cb:/apache-zookeeper-3.5.8-bin# cd bin 如果你看到控制台成功打印出如下信息的话,说明你已经成功连接 ZooKeeper 服务。\n2.3. 常用命令演示 # 2.3.1. 查看常用命令(help 命令) # 通过 help 命令查看 ZooKeeper 常用命令\n2.3.2. 创建节点(create 命令) # 通过 create 命令在根目录创建了 node1 节点,与它关联的字符串是\u0026quot;node1\u0026quot;\n[zk: 127.0.0.1:2181(CONNECTED) 34] create /node1 “node1” 通过 create 命令在根目录创建了 node1 节点,与它关联的内容是数字 123\n这个是不是写错了,应该是在node1目录下 ,创建了 node1.1节点\n[zk: 127.0.0.1:2181(CONNECTED) 1] create /node1/node1.1 123 Created /node1/node1.1 2.3.3. 更新节点数据内容(set 命令) # [zk: 127.0.0.1:2181(CONNECTED) 11] set /node1 \u0026#34;set node1\u0026#34; 2.3.4. 获取节点的数据(get 命令) # get 命令可以获取指定节点的数据内容和节点的状态,可以看出我们通过 set 命令已经将节点数据内容改为 \u0026ldquo;set node1\u0026rdquo;。\nset node1 cZxid = 0x47 ctime = Sun Jan 20 10:22:59 CST 2019 mZxid = 0x4b mtime = Sun Jan 20 10:41:10 CST 2019 pZxid = 0x4a cversion = 1 dataVersion = 1 aclVersion = 0 ephemeralOwner = 0x0 dataLength = 9 numChildren = 1 2.3.5. 查看某个目录下的子节点(ls 命令) # 通过 ls 命令查看根目录下的节点\n[zk: 127.0.0.1:2181(CONNECTED) 37] ls / [dubbo, ZooKeeper, node1] 通过 ls 命令查看 node1 目录下的节点\n[zk: 127.0.0.1:2181(CONNECTED) 5] ls /node1 [node1.1] ZooKeeper 中的 ls 命令和 linux 命令中的 ls 类似, 这个命令将列出绝对路径 path 下的所有子节点信息(列出 1 级,并不递归)\n2.3.6. 查看节点状态(stat 命令) # 通过 stat 命令查看节点状态\n[zk: 127.0.0.1:2181(CONNECTED) 10] stat /node1 cZxid = 0x47 ctime = Sun Jan 20 10:22:59 CST 2019 mZxid = 0x47 mtime = Sun Jan 20 10:22:59 CST 2019 pZxid = 0x4a cversion = 1 dataVersion = 0 aclVersion = 0 ephemeralOwner = 0x0 dataLength = 11 numChildren = 1 上面显示的一些信息比如 cversion、aclVersion、numChildren 等等,我在上面 “ ZooKeeper 相关概念总结(入门)” 这篇文章中已经介绍到。\n2.3.7. 查看节点信息和状态(ls2 命令) # ls2 命令更像是 ls 命令和 stat 命令的结合。 ls2 命令返回的信息包括 2 部分:\n子节点列表 当前节点的 stat 信息。 [zk: 127.0.0.1:2181(CONNECTED) 7] ls2 /node1 [node1.1] cZxid = 0x47 ctime = Sun Jan 20 10:22:59 CST 2019 mZxid = 0x47 mtime = Sun Jan 20 10:22:59 CST 2019 pZxid = 0x4a cversion = 1 dataVersion = 0 aclVersion = 0 ephemeralOwner = 0x0 dataLength = 11 numChildren = 1 2.3.8. 删除节点(delete 命令) # 这个命令很简单,但是需要注意的一点是如果你要删除某一个节点,那么这个节点必须无子节点才行。\n[zk: 127.0.0.1:2181(CONNECTED) 3] delete /node1/node1.1 在后面我会介绍到 Java 客户端 API 的使用以及开源 ZooKeeper 客户端 ZkClient 和 Curator 的使用。\n3. ZooKeeper Java客户端 Curator简单使用 # Curator 是Netflix公司开源的一套 ZooKeeper Java客户端框架,相比于 Zookeeper 自带的客户端 zookeeper 来说,Curator 的封装更加完善,各种 API 都可以比较方便地使用。\n下面我们就来简单地演示一下 Curator 的使用吧!\nCurator4.0+版本对ZooKeeper 3.5.x支持比较好。开始之前,请先将下面的依赖添加进你的项目。\n\u0026lt;dependency\u0026gt; \u0026lt;groupId\u0026gt;org.apache.curator\u0026lt;/groupId\u0026gt; \u0026lt;artifactId\u0026gt;curator-framework\u0026lt;/artifactId\u0026gt; \u0026lt;version\u0026gt;4.2.0\u0026lt;/version\u0026gt; \u0026lt;/dependency\u0026gt; \u0026lt;dependency\u0026gt; \u0026lt;groupId\u0026gt;org.apache.curator\u0026lt;/groupId\u0026gt; \u0026lt;artifactId\u0026gt;curator-recipes\u0026lt;/artifactId\u0026gt; \u0026lt;version\u0026gt;4.2.0\u0026lt;/version\u0026gt; \u0026lt;/dependency\u0026gt; 3.1. 连接 ZooKeeper 客户端 # 通过 CuratorFrameworkFactory 创建 CuratorFramework 对象,然后再调用 CuratorFramework 对象的 start() 方法即可!\nprivate static final int BASE_SLEEP_TIME = 1000; private static final int MAX_RETRIES = 3; // Retry strategy. Retry 3 times, and will increase the sleep time between retries. RetryPolicy retryPolicy = new ExponentialBackoffRetry(BASE_SLEEP_TIME, MAX_RETRIES); CuratorFramework zkClient = CuratorFrameworkFactory.builder() // the server to connect to (can be a server list) .connectString(\u0026#34;127.0.0.1:2181\u0026#34;). .retryPolicy(retryPolicy) .build(); zkClient.start(); 对于一些基本参数的说明:\nbaseSleepTimeMs:重试之间等待的初始时间 maxRetries :最大重试次数 connectString :要连接的服务器列表 retryPolicy :重试策略 3.2. 数据节点的增删改查 # 3.2.1. 创建节点 # 我们在 ZooKeeper常见概念解读 中介绍到,我们通常是将 znode 分为 4 大类:\n持久(PERSISTENT)节点 :一旦创建就一直存在即使 ZooKeeper 集群宕机,直到将其删除。 临时(EPHEMERAL)节点 :临时节点的生命周期是与 客户端会话(session) 绑定的,会话消失则节点消失 。并且,临时节点 只能做叶子节点 ,不能创建子节点。 持久顺序(PERSISTENT_SEQUENTIAL)节点 :除了具有持久(PERSISTENT)节点的特性之外, 子节点的名称还具有顺序性。比如 /node1/app0000000001 、/node1/app0000000002 。 临时顺序(EPHEMERAL_SEQUENTIAL)节点 :除了具备临时(EPHEMERAL)节点的特性之外,子节点的名称还具有顺序性。 你在使用的ZooKeeper 的时候,会发现 CreateMode 类中实际有 7种 znode 类型 ,但是用的最多的还是上面介绍的 4 种。\na.创建持久化节点\n你可以通过下面两种方式创建持久化的节点。\n//注意:下面的代码会报错,下文说了具体原因 zkClient.create().forPath(\u0026#34;/node1/00001\u0026#34;); zkClient.create().withMode(CreateMode.PERSISTENT).forPath(\u0026#34;/node1/00002\u0026#34;); 但是,你运行上面的代码会报错,这是因为的父节点node1还未创建。\n你可以先创建父节点 node1 ,然后再执行上面的代码就不会报错了。\nzkClient.create().forPath(\u0026#34;/node1\u0026#34;); 更推荐的方式是通过下面这行代码, creatingParentsIfNeeded() 可以保证父节点不存在的时候自动创建父节点,这是非常有用的。\nzkClient.create().creatingParentsIfNeeded().withMode(CreateMode.PERSISTENT).forPath(\u0026#34;/node1/00001\u0026#34;); b.创建临时节点\nzkClient.create().creatingParentsIfNeeded().withMode(CreateMode.EPHEMERAL).forPath(\u0026#34;/node1/00001\u0026#34;); c.创建节点并指定数据内容\nzkClient.create().creatingParentsIfNeeded().withMode(CreateMode.EPHEMERAL).forPath(\u0026#34;/node1/00001\u0026#34;,\u0026#34;java\u0026#34;.getBytes()); zkClient.getData().forPath(\u0026#34;/node1/00001\u0026#34;);//获取节点的数据内容,获取到的是 byte数组 d.检测节点是否创建成功\nzkClient.checkExists().forPath(\u0026#34;/node1/00001\u0026#34;);//不为null的话,说明节点创建成功 3.2.2. 删除节点 # a.删除一个子节点\nzkClient.delete().forPath(\u0026#34;/node1/00001\u0026#34;); b.删除一个节点以及其下的所有子节点\nzkClient.delete().deletingChildrenIfNeeded().forPath(\u0026#34;/node1\u0026#34;); 3.2.3. 获取/更新节点数据内容 # zkClient.create().creatingParentsIfNeeded().withMode(CreateMode.EPHEMERAL).forPath(\u0026#34;/node1/00001\u0026#34;,\u0026#34;java\u0026#34;.getBytes()); zkClient.getData().forPath(\u0026#34;/node1/00001\u0026#34;);//获取节点的数据内容 zkClient.setData().forPath(\u0026#34;/node1/00001\u0026#34;,\u0026#34;c++\u0026#34;.getBytes());//更新节点数据内容 3.2.4. 获取某个节点的所有子节点路径 # List\u0026lt;String\u0026gt; childrenPaths = zkClient.getChildren().forPath(\u0026#34;/node1\u0026#34;); "},{"id":272,"href":"/zh/docs/technology/Review/java_guide/lydly_distributed_system/ly07ly_zookeeper-plus/","title":"zookeeper进阶","section":"分布式系统","content":" 转载自https://github.com/Snailclimb/JavaGuide(添加小部分笔记)感谢作者!\nFrancisQ 投稿。\n1. 好久不见 # 离上一篇文章的发布也快一个月了,想想已经快一个月没写东西了,其中可能有期末考试、课程设计和驾照考试,但这都不是借口!\n一到冬天就懒的不行,望广大掘友督促我🙄🙄✍️✍️。\n文章很长,先赞后看,养成习惯。❤️ 🧡 💛 💚 💙 💜\n2. 什么是ZooKeeper # ZooKeeper 由 Yahoo 开发,后来捐赠给了 Apache ,现已成为 Apache 顶级项目。ZooKeeper 是一个开源的分布式应用程序协调服务器,其为分布式系统提供一致性服务。其一致性是通过基于 Paxos 算法的 ZAB 协议完成的。其主要功能包括:配置维护、分布式同步、集群管理、分布式事务等。\n简单来说, ZooKeeper 是一个 分布式协调服务框架 。分布式?协调服务?这啥玩意?🤔🤔\n其实解释到分布式这个概念的时候,我发现有些同学并不是能把 分布式和集群 这两个概念很好的理解透。前段时间有同学和我探讨起分布式的东西,他说分布式不就是加机器吗?一台机器不够用再加一台抗压呗。当然加机器这种说法也无可厚非,你一个分布式系统必定涉及到多个机器,但是你别忘了,计算机学科中还有一个相似的概念—— Cluster ,集群不也是加机器吗?但是 集群 和 分布式 其实就是两个完全不同的概念。\n比如,我现在有一个秒杀服务,并发量太大单机系统承受不住,那我加几台服务器也 一样 提供秒杀服务,这个时候就是 Cluster 集群 。\n但是,我现在换一种方式,我将一个秒杀服务 拆分成多个子服务 ,比如创建订单服务,增加积分服务,扣优惠券服务等等,然后我将这些子服务都部署在不同的服务器上 ,这个时候就是 Distributed 分布式 。\n而我为什么反驳同学所说的分布式就是加机器呢?因为我认为加机器更加适用于构建集群,因为它真是只有加机器。而对于分布式来说,你首先需要将业务进行拆分,然后再加机器(不仅仅是加机器那么简单),同时你还要去解决分布式带来的一系列问题。\n比如各个分布式组件如何协调起来,如何减少各个系统之间的耦合度,分布式事务的处理,如何去配置整个分布式系统等等。ZooKeeper 主要就是解决这些问题的。\n3. 一致性问题 # 设计一个分布式系统必定会遇到一个问题—— 因为分区容忍性(partition tolerance)的存在,就必定要求我们需要在系统可用性(availability)和数据一致性(consistency)中做出权衡 。这就是著名的 CAP 定理。\n理解起来其实很简单,比如说把一个班级作为整个系统,而学生是系统中的一个个独立的子系统。这个时候班里的小红小明偷偷谈恋爱被班里的大嘴巴小花发现了,小花欣喜若狂告诉了周围的人,然后小红小明谈恋爱的消息在班级里传播起来了。当在消息的传播(散布)过程中,你抓到一个同学问他们的情况,如果回答你不知道(或者说的是他们的前男/女朋友,也就是消息不一致),那么说明整个班级系统出现了数据不一致的问题(因为小花已经知道这个消息了)。而如果他直接不回答你,因为整个班级有消息在进行传播(为了保证一致性,需要所有人都知道才可提供服务),这个时候就出现了系统的可用性问题。\n而上述前者就是 Eureka 的处理方式,它保证了AP(可用性),后者就是我们今天所要讲的 ZooKeeper 的处理方式,它保证了CP(数据一致性(即同步期间不可用))。\n【ly总结】也就是说两台机器同步期间,如果要保证可用性,那么必然会出现一致性问题;而如果要保证一致性,那必然要等到完全同步完(也就是期间会让请求不可用)\n4. 一致性协议和算法 # 而为了解决数据一致性问题,在科学家和程序员的不断探索中,就出现了很多的一致性协议和算法。比如 2PC(两阶段提交),3PC(三阶段提交),Paxos算法等等。\n这时候请你思考一个问题,同学之间如果采用传纸条的方式去传播消息,那么就会出现一个问题——我咋知道我的小纸条有没有传到我想要传递的那个人手中呢?万一被哪个小家伙给劫持篡改了呢,对吧?\n这个时候就引申出一个概念—— 拜占庭将军问题 。它意指 在不可靠信道上试图通过消息传递的方式达到一致性是不可能的, 所以所有的一致性算法的 必要前提 就是安全可靠的消息通道。\n而为什么要去解决数据一致性的问题?你想想,如果一个秒杀系统将服务拆分成了下订单和加积分服务,这两个服务部署在不同的机器上了,万一在消息的传播过程中积分系统宕机了,总不能你这边下了订单却没加积分吧?你总得保证两边的数据需要一致吧?\n4.1. 2PC(两阶段提交) # 两阶段提交是一种保证分布式系统数据一致性的协议,现在很多数据库都是采用的两阶段提交协议来完成 分布式事务 的处理。\n在介绍2PC之前,我们先来想想分布式事务到底有什么问题呢?\n还拿秒杀系统的下订单和加积分两个系统来举例吧(我想你们可能都吐了🤮🤮🤮),我们此时下完订单会发个消息给积分系统告诉它下面该增加积分了。如果我们仅仅是发送一个消息也不收回复,那么我们的订单系统怎么能知道积分系统的收到消息的情况呢?如果我们增加一个收回复的过程,那么当积分系统收到消息后返回给订单系统一个 Response ,但在中间出现了网络波动,那个回复消息没有发送成功,订单系统是不是以为积分系统消息接收失败了?它是不是会回滚事务?但此时(实际情况:)积分系统是成功收到消息的,它就会去处理消息然后给用户增加积分,这个时候就会出现积分加了但是订单没下成功。\n所以我们所需要解决的是在分布式系统中,整个调用链中,我们所有服务的数据处理要么都成功要么都失败,即所有服务的 原子性问题 。\n在两阶段提交中,主要涉及到两个角色,分别是协调者和参与者。\n第一阶段:当要执行一个分布式事务的时候,事务发起者首先向协调者发起事务请求,然后协调者会给所有参与者发送 prepare 请求(其中包括事务内容)告诉参与者你们需要执行事务了,如果能执行我发的事务内容那么就先执行但不提交,执行后请给我回复。然后参与者收到 prepare 消息后,他们会开始执行事务(但不提交),并将 Undo 和 Redo 信息记入事务日志中,之后参与者就向协调者反馈是否准备好了。\n第二阶段:第二阶段主要是协调者根据参与者反馈的情况来决定接下来是否可以进行事务的提交操作,即提交事务或者回滚事务。\n比如这个时候 所有的参与者 都返回了准备好了的消息,这个时候就进行事务的提交,协调者此时会给所有的参与者发送 Commit 请求 ,当参与者收到 Commit 请求的时候会执行前面执行的事务的 提交操作 ,提交完毕之后将给协调者发送提交成功的响应。\n而如果在第一阶段并不是所有参与者都返回了准备好了的消息,那么此时协调者将会给所有参与者发送 回滚事务的 rollback 请求,参与者收到之后将会 回滚它在第一阶段所做的事务处理 ,然后再将处理情况返回给协调者,最终协调者收到响应后便给事务发起者返回处理失败的结果。\n个人觉得 2PC 实现得还是比较鸡肋的,因为事实上它只解决了各个事务的原子性问题,随之也带来了很多的问题。\n单点故障问题,如果协调者挂了那么整个系统都处于不可用的状态了。 阻塞问题,即当协调者发送 prepare 请求,参与者收到之后如果能处理那么它将会进行事务的处理但并不提交,这个时候会一直占用着资源不释放,如果此时协调者挂了,那么这些资源都不会再释放了,这会极大影响性能。 数据不一致问题,比如当第二阶段,协调者只发送了一部分的 commit 请求就挂了,那么也就意味着,收到消息的参与者会进行事务的提交,而后面没收到的则不会进行事务提交,那么这时候就会产生数据不一致性问题。 4.2. 3PC(三阶段提交) # 因为2PC存在的一系列问题,比如单点,容错机制缺陷等等,从而产生了 3PC(三阶段提交) 。那么这三阶段又分别是什么呢?\n千万不要吧PC理解成个人电脑了,其实他们是 phase-commit 的缩写,即阶段提交。\nCanCommit阶段:协调者向所有参与者发送 CanCommit 请求,参与者收到请求后会根据自身情况查看是否能执行事务,如果可以则返回 YES 响应并进入预备状态,否则返回 NO 。 PreCommit阶段:协调者根据参与者返回的响应来决定是否可以进行下面的 PreCommit 操作。如果上面参与者返回的都是 YES,那么协调者将向所有参与者发送 PreCommit 预提交请求,参与者收到预提交请求后,会进行事务的执行操作,并将 Undo 和 Redo 信息写入事务日志中 ,最后如果参与者顺利执行了事务则给协调者返回成功的响应。如果在第一阶段协调者收到了 任何一个 NO 的信息,或者 在一定时间内 并没有收到全部的参与者的响应,那么就会中断事务,它会向所有参与者发送中断请求(abort),参与者收到中断请求之后会立即中断事务,或者在一定时间内没有收到协调者的请求,它也会中断事务。 DoCommit阶段:这个阶段其实和 2PC 的第二阶段差不多,如果协调者收到了所有参与者在 PreCommit 阶段的 YES 响应,那么协调者将会给所有参与者发送 DoCommit 请求,参与者收到 DoCommit 请求后则会进行事务的提交工作,完成后则会给协调者返回响应,协调者收到所有参与者返回的事务提交成功的响应之后则完成事务。若协调者在 PreCommit 阶段 收到了任何一个 NO 或者在一定时间内没有收到所有参与者的响应 ,那么就会进行中断请求的发送,参与者收到中断请求后则会 通过上面记录的回滚日志 来进行事务的回滚操作,并向协调者反馈回滚状况,协调者收到参与者返回的消息后,中断事务。 这里是 3PC 在成功的环境下的流程图,你可以看到 3PC 在很多地方进行了超时中断的处理,比如协调者在指定时间内为收到全部的确认消息则进行事务中断的处理,这样能 减少同步阻塞的时间 。还有需要注意的是,3PC 在 DoCommit 阶段参与者如未收到协调者发送的提交事务的请求,它会在一定时间内进行事务的提交。为什么这么做呢?是因为这个时候我们肯定保证了在第一阶段所有的协调者全部返回了可以执行事务的响应,这个时候我们有理由相信其他系统都能进行事务的执行和提交,所以不管协调者有没有发消息给参与者,进入第三阶段参与者都会进行事务的提交操作。\n总之,3PC 通过一系列的超时机制很好的缓解了阻塞问题,但是最重要的一致性并没有得到根本的解决,比如在 PreCommit 阶段,当一个参与者收到了请求之后其他参与者和协调者挂了或者出现了网络分区,这个时候收到消息的参与者都会进行事务提交,这就会出现数据不一致性问题。\n所以,要解决一致性问题还需要靠 Paxos 算法⭐️ ⭐️ ⭐️ 。\n4.3. Paxos 算法 [ly:看不懂,跳过] # Paxos 算法是基于消息传递且具有高度容错特性的一致性算法,是目前公认的解决分布式一致性问题最有效的算法之一,其解决的问题就是在分布式系统中如何就某个值(决议)达成一致 。\n在 Paxos 中主要有三个角色,分别为 Proposer提案者、Acceptor表决者、Learner学习者。Paxos 算法和 2PC 一样,也有两个阶段,分别为 Prepare 和 accept 阶段。\n4.3.1. prepare 阶段 # Proposer提案者:负责提出 proposal,每个提案者在提出提案时都会首先获取到一个 具有全局唯一性的、递增的提案编号N,即在整个集群中是唯一的编号 N,然后将该编号赋予其要提出的提案,在第一阶段是只将提案编号发送给所有的表决者。 Acceptor表决者:每个表决者在 accept 某提案后,会将该提案编号N记录在本地,这样每个表决者中保存的已经被 accept 的提案中会存在一个编号最大的提案,其编号假设为 maxN。每个表决者仅会 accept 编号大于自己本地 maxN 的提案,在批准提案时表决者会将以前接受过的最大编号的提案作为响应反馈给 Proposer 。 下面是 prepare 阶段的流程图,你可以对照着参考一下。\n4.3.2. accept 阶段 # 当一个提案被 Proposer 提出后,如果 Proposer 收到了超过半数的 Acceptor 的批准(Proposer 本身同意),那么此时 Proposer 会给所有的 Acceptor 发送真正的提案(你可以理解为第一阶段为试探),这个时候 Proposer 就会发送提案的内容和提案编号。\n表决者收到提案请求后会再次比较本身已经批准过的最大提案编号和该提案编号,如果该提案编号 大于等于 已经批准过的最大提案编号,那么就 accept 该提案(此时执行提案内容但不提交),随后将情况返回给 Proposer 。如果不满足则不回应或者返回 NO 。\n当 Proposer 收到超过半数的 accept ,那么它这个时候会向所有的 acceptor 发送提案的提交请求。需要注意的是,因为上述仅仅是超过半数的 acceptor 批准执行了该提案内容,其他没有批准的并没有执行该提案内容,所以这个时候需要向未批准的 acceptor 发送提案内容和提案编号并让它无条件执行和提交,而对于前面已经批准过该提案的 acceptor 来说 仅仅需要发送该提案的编号 ,让 acceptor 执行提交就行了。\n而如果 Proposer 如果没有收到超过半数的 accept 那么它将会将 递增 该 Proposal 的编号,然后 重新进入 Prepare 阶段 。\n对于 Learner 来说如何去学习 Acceptor 批准的提案内容,这有很多方式,读者可以自己去了解一下,这里不做过多解释。\n4.3.3. paxos 算法的死循环问题 # 其实就有点类似于两个人吵架,小明说我是对的,小红说我才是对的,两个人据理力争的谁也不让谁🤬🤬。\n比如说,此时提案者 P1 提出一个方案 M1,完成了 Prepare 阶段的工作,这个时候 acceptor 则批准了 M1,但是此时提案者 P2 同时也提出了一个方案 M2,它也完成了 Prepare 阶段的工作。然后 P1 的方案已经不能在第二阶段被批准了(因为 acceptor 已经批准了比 M1 更大的 M2),所以 P1 自增方案变为 M3 重新进入 Prepare 阶段,然后 acceptor ,又批准了新的 M3 方案,它又不能批准 M2 了,这个时候 M2 又自增进入 Prepare 阶段。。。\n就这样无休无止的永远提案下去,这就是 paxos 算法的死循环问题。\n那么如何解决呢?很简单,人多了容易吵架,我现在 就允许一个能提案 就行了。\n5. 引出 ZAB # 5.1. Zookeeper 架构 # 作为一个优秀高效且可靠的分布式协调框架,ZooKeeper 在解决分布式数据一致性问题时并没有直接使用 Paxos ,而是专门定制了一致性协议叫做 ZAB(ZooKeeper Atomic Broadcast) 原子广播协议,该协议能够很好地支持 崩溃恢复 。\n5.2. ZAB 中的三个角色 # 和介绍 Paxos 一样,在介绍 ZAB 协议之前,我们首先来了解一下在 ZAB 中三个主要的角色,Leader 领导者、Follower跟随者、Observer观察者 。\nLeader :集群中 唯一的写请求处理者 ,能够发起投票(投票也是为了进行写请求)。 Follower:能够接收客户端的请求,如果是读请求则可以自己处理,如果是写请求则要转发给 Leader 。在选举过程中会参与投票,有选举权和被选举权 。 Observer :就是没有选举权和被选举权的 Follower 。 观察者[əbˈzɜːvə(r)] 在 ZAB 协议中对 zkServer(即上面我们说的三个角色的总称) 还有两种模式的定义,分别是 消息广播 和 崩溃恢复 。\n5.3. 消息广播模式 # 说白了就是 ZAB 协议是如何处理写请求的,上面我们不是说只有 Leader 能处理写请求嘛?那么我们的 Follower 和 Observer 是不是也需要 同步更新数据 呢?总不能数据只在 Leader 中更新了,其他角色都没有得到更新吧?\n不就是 在整个集群中保持数据的一致性 嘛?如果是你,你会怎么做呢?\n废话,第一步肯定需要 Leader 将写请求 广播 出去呀,让 Leader 问问 Followers 是否同意更新,如果超过半数以上的同意那么就进行 Follower 和 Observer 的更新(和 Paxos 一样)。当然这么说有点虚,画张图理解一下。\n嗯。。。看起来很简单,貌似懂了🤥🤥🤥。这两个 Queue 哪冒出来的?答案是 ZAB 需要让 Follower 和 Observer 保证顺序性 。何为顺序性,比如我现在有一个写请求A,此时 Leader 将请求A广播出去,因为只需要半数同意就行,所以可能这个时候有一个 Follower F1因为网络原因没有收到,而 Leader 又广播了一个请求B,因为网络原因,F1竟然先收到了请求B然后才收到了请求A,这个时候请求处理的顺序不同就会导致数据的不同,从而 产生数据不一致问题 。\n所以在 Leader 这端,它为每个其他的 zkServer 准备了一个 队列 ,采用先进先出的方式发送消息。由于协议是 通过 TCP 来进行网络通信的,保证了消息的发送顺序性,接受顺序性也得到了保证。\n队列的先进先出+tcp的发送顺序性,保证了接收顺序的一致性\n除此之外,在 ZAB 中还定义了一个 全局单调递增的事务ID ZXID ,它是一个64位long型,其中高32位表示 epoch 年代,低32位表示事务id。epoch 是会根据 Leader 的变化而变化的,当一个 Leader 挂了,新的 Leader 上位的时候,年代(epoch)就变了。而低32位可以简单理解为递增的事务id。\n定义这个的原因也是为了顺序性,每个 proposal 在 Leader 中生成后需要 通过其 ZXID 来进行排序 ,才能得到处理。\n5.4. 崩溃恢复模式 # 说到崩溃恢复我们首先要提到 ZAB 中的 Leader 选举算法,当系统出现崩溃影响最大应该是 Leader 的崩溃,因为我们只有一个 Leader ,所以当 Leader 出现问题的时候我们势必需要重新选举 Leader 。\nLeader 选举可以分为两个不同的阶段,第一个是我们提到的 Leader 宕机需要重新选举,第二则是当 Zookeeper 启动时需要进行系统的 Leader 初始化选举。下面我先来介绍一下 ZAB 是如何进行初始化选举的。\n假设我们集群中有3台机器,那也就意味着我们需要两台以上同意(超过半数)。比如这个时候我们启动了 server1 ,它会首先 投票给自己 ,投票内容为服务器的 myid 和 ZXID ,因为初始化所以 ZXID 都为0,此时 server1 发出的投票为 (1,0)。但此时 server1 的投票仅为1,所以不能作为 Leader ,此时还在选举阶段所以整个集群处于 Looking 状态。\n接着 server2 启动了,它首先也会将投票选给自己(2,0),并将投票信息广播出去(server1也会,只是它那时没有其他的服务器了),server1 在收到 server2 的投票信息后会将投票信息与自己的作比较。首先它会比较 ZXID ,ZXID 大的优先为 Leader,如果相同则比较 myid,myid 大的优先作为 Leader。所以此时server1 发现 server2 更适合做 Leader,它就会将自己的投票信息更改为(2,0)然后再广播出去,之后server2 收到之后发现和自己的一样无需做更改,并且自己的 投票已经超过半数 ,则 确定 server2 为 Leader,server1 也会将自己服务器设置为 Following 变为 Follower。整个服务器就从 Looking 变为了正常状态。\n当 server3 启动发现集群没有处于 Looking 状态时,它会直接以 Follower 的身份加入集群。\n还是前面三个 server 的例子,如果在整个集群运行的过程中 server2 挂了,那么整个集群会如何重新选举 Leader 呢?其实和初始化选举差不多。\n首先毫无疑问的是剩下的两个 Follower 会将自己的状态 从 Following 变为 Looking 状态 ,然后每个 server 会向初始化投票一样首先给自己投票(这不过这里的 zxid 可能不是0了,这里为了方便随便取个数字)。\n假设 server1 给自己投票为(1,99),然后广播给其他 server,server3 首先也会给自己投票(3,95),然后也广播给其他 server。server1 和 server3 此时会收到彼此的投票信息,和一开始选举一样,他们也会比较自己的投票和收到的投票(zxid 大的优先,如果相同那么就 myid 大的优先)。这个时候 server1 收到了 server3 的投票发现没自己的合适故不变,server3 收到 server1 的投票结果后发现比自己的合适于是更改投票为(1,99)然后广播出去,最后 server1 收到了发现自己的投票已经超过半数就把自己设为 Leader,server3 也随之变为 Follower。\n请注意 ZooKeeper 为什么要设置奇数个结点?比如这里我们是三个,挂了一个我们还能正常工作,挂了两个我们就不能正常工作了(已经没有超过半数的节点数了,所以无法进行投票等操作了)。而假设我们现在有四个,挂了一个也能工作,但是挂了两个也不能正常工作了,这是和三个一样的,而三个比四个还少一个,带来的效益是一样的,所以 Zookeeper 推荐奇数个 server 。\n那么说完了 ZAB 中的 Leader 选举方式之后我们再来了解一下 崩溃恢复 是什么玩意?\n其实主要就是 当集群中有机器挂了,我们整个集群如何保证数据一致性?\n如果只是 Follower 挂了,而且挂的(总数)没超过半数的时候,因为我们一开始讲了在 Leader 中会维护队列,所以不用担心后面的数据没接收到导致数据不一致性。\n如果 Leader 挂了那就麻烦了,我们肯定需要先暂停服务变为 Looking 状态然后进行 Leader 的重新选举(上面我讲过了),但这个就要分为两种情况了,分别是 确保已经被Leader提交的提案最终能够被所有的Follower提交 和 跳过那些已经被丢弃的提案 。\n确保已经被Leader提交的提案最终能够被所有的Follower提交是什么意思呢?\n假设 Leader (server2) 发送 commit 请求(忘了请看上面的消息广播模式),他发送给了 server3,然后要发给 server1 的时候突然挂了。这个时候重新选举的时候我们如果把 server1 作为 Leader 的话,那么肯定会产生数据不一致性,因为 server3 肯定会提交刚刚 server2 发送的 commit 请求的提案,而 server1 根本没收到所以会丢弃。\n那怎么解决呢?\n聪明的同学肯定会质疑,这个时候 server1 已经不可能成为 Leader 了,因为 server1 和 server3 进行投票选举的时候会比较 ZXID ,而此时 server3 的 ZXID 肯定比 server1 的大了。(不理解可以看前面的选举算法)\n那么跳过那些已经被丢弃的提案又是什么意思呢?\n假设 Leader (server2) 此时同意了提案N1,自身提交了这个事务并且要发送给所有 Follower 要 commit 的请求,却在这个时候挂了,此时肯定要重新进行 Leader 的选举,比如说此时选 server1 为 Leader (这无所谓)。但是过了一会,这个 挂掉的 Leader 又重新恢复了 ,此时它肯定会作为 Follower 的身份进入集群中,需要注意的是刚刚 server2 已经同意提交了提案N1,但其他 server 并没有收到它的 commit 信息,所以其他 server 不可能再提交这个提案N1了,这样就会出现数据不一致性问题了,所以 该提案N1最终需要被抛弃掉 。\n6. Zookeeper的几个理论知识 # 了解了 ZAB 协议还不够,它仅仅是 Zookeeper 内部实现的一种方式,而我们如何通过 Zookeeper 去做一些典型的应用场景呢?比如说集群管理,分布式锁,Master 选举等等。\n这就涉及到如何使用 Zookeeper 了,但在使用之前我们还需要掌握几个概念。比如 Zookeeper 的 数据模型 、会话机制、ACL、Watcher机制 等等。\n6.1. 数据模型 # zookeeper 数据存储结构与标准的 Unix 文件系统非常相似,都是在根节点下挂很多子节点(树型)。但是 zookeeper 中没有文件系统中目录与文件的概念,而是 使用了 znode 作为数据节点 。znode 是 zookeeper 中的最小数据单元,每个 znode 上都可以保存数据,同时还可以挂载子节点,形成一个树形化命名空间。\n每个 znode 都有自己所属的 节点类型 和 节点状态。\n其中节点类型可以分为 持久节点、持久顺序节点、临时节点 和 临时顺序节点。\n持久节点:一旦创建就一直存在,直到将其删除。 持久顺序节点:一个父节点可以为其子节点 维护一个创建的先后顺序 ,这个顺序体现在 节点名称 上,是节点名称后自动添加一个由 10 位数字组成的数字串,从 0 开始计数。 临时节点:临时节点的生命周期是与 客户端会话 绑定的,会话消失则节点消失 。临时节点 只能做叶子节点 ,不能创建子节点。 临时顺序节点:父节点可以创建一个维持了顺序的临时节点(和前面的持久顺序性节点一样)。 节点状态中包含了很多节点的属性比如 czxid 、mzxid 等等,在 zookeeper 中是使用 Stat 这个类来维护的。下面我列举一些属性解释。\nczxid:Created ZXID,该数据节点被 创建 时的事务ID。 mzxid:Modified ZXID,节点 最后一次被更新时 的事务ID。 ctime:Created Time,该节点被创建的时间。 mtime: Modified Time,该节点最后一次被修改的时间。 version:节点的版本号。 cversion:子节点 的版本号。 aversion:节点的 ACL 版本号。 ephemeralOwner:创建该节点的会话的 sessionID ,如果该节点为持久节点,该值为0。 dataLength:节点数据内容的长度。 numChildre:该节点的子节点个数,如果为临时节点为0。 pzxid:该节点子节点列表最后一次被修改时的事务ID,注意是子节点的 列表 ,不是内容。 6.2. 会话 # 我想这个对于后端开发的朋友肯定不陌生,不就是 session 吗?只不过 zk 客户端和服务端是通过 TCP 长连接 维持的会话机制,其实对于会话来说你可以理解为 保持连接状态 。\n在 zookeeper 中,会话还有对应的事件,比如 CONNECTION_LOSS 连接丢失事件 、SESSION_MOVED 会话转移事件 、SESSION_EXPIRED 会话超时失效事件 。\n6.3. ACL # ACL 为 Access Control Lists ,它是一种权限控制。在 zookeeper 中定义了5种权限,它们分别为:\nCREATE :创建子节点的权限。 READ:获取节点数据和子节点列表的权限。 WRITE:更新节点数据的权限。 DELETE:删除子节点的权限。 ADMIN:设置节点 ACL 的权限。 6.4. Watcher机制 # Watcher 为事件监听器,是 zk 非常重要的一个特性,很多功能都依赖于它,它有点类似于订阅的方式,即客户端向服务端 注册 指定的 watcher ,当服务端符合了 watcher 的某些事件或要求则会 向客户端发送事件通知 ,客户端收到通知后找到自己定义的 Watcher 然后 执行相应的回调方法 。\n7. Zookeeper的几个典型应用场景 # 前面说了这么多的理论知识,你可能听得一头雾水,这些玩意有啥用?能干啥事?别急,听我慢慢道来。\n7.1. 选主 # 还记得上面我们的所说的临时节点吗?因为 Zookeeper 的强一致性,能够很好地在保证 在高并发的情况下保证节点创建的全局唯一性 (即无法重复创建同样的节点)。\n利用这个特性,我们可以 让多个客户端创建一个指定的节点 ,创建成功的就是 master。\n但是,如果这个 master 挂了怎么办???\n你想想为什么我们要创建临时节点?还记得临时节点的生命周期吗?master 挂了是不是代表会话断了?会话断了是不是意味着这个节点没了?还记得 watcher 吗?我们是不是可以 让其他不是 master 的节点监听节点的状态 ,比如说我们监听这个临时节点的父节点,如果子节点个数变了就代表 master 挂了,这个时候我们 触发回调函数进行重新选举 ,或者我们直接监听节点的状态,我们可以通过节点是否已经失去连接来判断 master 是否挂了等等。\n总的来说,我们可以完全 利用 临时节点、节点状态 和 watcher 来实现选主的功能,临时节点主要用来选举,节点状态和**watcher** 可以用来判断 master 的活性和进行重新选举。\n7.2. 分布式锁 # 分布式锁的实现方式有很多种,比如 Redis 、数据库 、zookeeper 等。个人认为 zookeeper 在实现分布式锁这方面是非常非常简单的。\n上面我们已经提到过了 zk在高并发的情况下保证节点创建的全局唯一性,这玩意一看就知道能干啥了。实现互斥锁呗,又因为能在分布式的情况下,所以能实现分布式锁呗。\n如何实现呢?这玩意其实跟选主基本一样,我们也可以利用临时节点的创建来实现。\n首先肯定是如何获取锁,因为创建节点的唯一性,我们可以让多个客户端同时创建一个临时节点,创建成功的就说明获取到了锁 。然后没有获取到锁的客户端也像上面选主的非主节点创建一个 watcher 进行节点状态的监听,如果这个互斥锁被释放了(可能获取锁的客户端宕机了,或者那个客户端主动释放了锁)可以调用回调函数重新获得锁。\nzk 中不需要向 redis 那样考虑锁得不到释放的问题了,因为当客户端挂了,节点也挂了,锁也释放了。是不是很简单?\n那能不能使用 zookeeper 同时实现 共享锁和独占锁 呢?答案是可以的,不过稍微有点复杂而已。\n还记得 有序的节点 吗?\n这个时候我规定所有创建节点必须有序,当你是读请求(要获取共享锁)的话,如果 没有比自己更小的节点,或比自己小的节点都是读请求 ,则可以获取到读锁,然后就可以开始读了。若比自己小的节点中有写请求 ,则当前客户端无法获取到读锁,只能等待前面的写请求完成。\n如果你是写请求(获取独占锁),若 没有比自己更小的节点 ,则表示当前客户端可以直接获取到写锁,对数据进行修改。若发现 有比自己更小的节点,无论是读操作还是写操作,当前客户端都无法获取到写锁 ,等待所有前面的操作完成。\n这就很好地同时实现了共享锁和独占锁,当然还有优化的地方,比如当一个锁得到释放它会通知所有等待的客户端从而造成 羊群效应 。此时你可以通过让等待的节点只监听他们前面的节点。\n具体怎么做呢?其实也很简单,你可以让 读请求监听比自己小的最后一个写请求节点,写请求只监听比自己小的最后一个节点 ,感兴趣的小伙伴可以自己去研究一下。\n7.3. 命名服务 # 如何给一个对象设置ID,大家可能都会想到 UUID,但是 UUID 最大的问题就在于它太长了。。。(太长不一定是好事,嘿嘿嘿)。那么在条件允许的情况下,我们能不能使用 zookeeper 来实现呢?\n我们之前提到过 zookeeper 是通过 树形结构 来存储数据节点的,那也就是说,对于每个节点的 全路径,它必定是唯一的,我们可以使用节点的全路径作为命名方式了。而且更重要的是,路径是我们可以自己定义的,这对于我们对有些有语意的对象的ID设置可以更加便于理解。\n7.4. 集群管理和注册中心 # 看到这里是不是觉得 zookeeper 实在是太强大了,它怎么能这么能干!\n别急,它能干的事情还很多呢。可能我们会有这样的需求,我们需要了解整个集群中有多少机器在工作,我们想对集群中的每台机器的运行时状态进行数据采集,对集群中机器进行上下线操作等等。\n而 zookeeper 天然支持的 watcher 和 临时节点能很好的实现这些需求。我们可以为每条机器创建临时节点,并监控其父节点,如果子节点列表有变动(我们可能创建删除了临时节点),那么我们可以使用在其父节点绑定的 watcher 进行状态监控和回调。\n至于注册中心也很简单,我们同样也是让 服务提供者 在 zookeeper 中创建一个临时节点并且将自己的 ip、port、调用方式 写入节点,当 服务消费者 需要进行调用的时候会 通过注册中心找到相应的服务的地址列表(IP端口什么的) ,并缓存到本地(方便以后调用),当消费者调用服务时,不会再去请求注册中心,而是直接通过负载均衡算法从地址列表中取一个服务提供者的服务器调用服务。\n当服务提供者的某台服务器宕机或下线时,相应的地址会从服务提供者地址列表中移除。同时,注册中心会将新的服务地址列表发送给服务消费者的机器并缓存在消费者本机(当然你可以让消费者进行节点监听,我记得 Eureka 会先试错,然后再更新)。\n8. 总结 # 看到这里的同学实在是太有耐心了👍👍👍,如果觉得我写得不错的话点个赞哈。\n不知道大家是否还记得我讲了什么😒。\n这篇文章中我带大家入门了 zookeeper 这个强大的分布式协调框架。现在我们来简单梳理一下整篇文章的内容。\n分布式与集群的区别\n2PC 、3PC 以及 paxos 算法这些一致性框架的原理和实现。\nzookeeper 专门的一致性算法 ZAB 原子广播协议的内容(Leader 选举、崩溃恢复、消息广播)。\nzookeeper 中的一些基本概念,比如 ACL,数据节点,会话,watcher机制等等。\nzookeeper 的典型应用场景,比如选主,注册中心等等。\n如果忘了可以回去看看再次理解一下,如果有疑问和建议欢迎提出🤝🤝🤝。\n"},{"id":273,"href":"/zh/docs/technology/Review/java_guide/lydly_distributed_system/ly06ly_zookeeper-intro/","title":"zookeeper介绍","section":"分布式系统","content":" 转载自https://github.com/Snailclimb/JavaGuide(添加小部分笔记)感谢作者!\n1. 前言 # 相信大家对 ZooKeeper 应该不算陌生。但是你真的了解 ZooKeeper 到底有啥用不?如果别人/面试官让你给他讲讲对于 ZooKeeper 的认识,你能回答到什么地步呢?\n拿我自己来说吧!我本人曾经使用 Dubbo 来做分布式项目的时候,使用了 ZooKeeper 作为注册中心。为了保证分布式系统能够同步访问某个资源,我还使用 ZooKeeper 做过分布式锁。另外,我在学习 Kafka 的时候,知道 Kafka 很多功能的实现依赖了 ZooKeeper。\n前几天,总结项目经验的时候,我突然问自己 ZooKeeper 到底是个什么东西?想了半天,脑海中只是简单的能浮现出几句话:\nZooKeeper 可以被用作注册中心、分布式锁; ZooKeeper 是 Hadoop 生态系统的一员; 构建 ZooKeeper 集群的时候,使用的服务器最好是奇数台。 由此可见,我对于 ZooKeeper 的理解仅仅是停留在了表面。\n所以,通过本文,希望带大家稍微详细的了解一下 ZooKeeper 。如果没有学过 ZooKeeper ,那么本文将会是你进入 ZooKeeper 大门的垫脚砖。如果你已经接触过 ZooKeeper ,那么本文将带你回顾一下 ZooKeeper 的一些基础概念。\n另外,本文不光会涉及到 ZooKeeper 的一些概念,后面的文章会介绍到 ZooKeeper 常见命令的使用以及使用 Apache Curator 作为 ZooKeeper 的客户端。\n如果文章有任何需要改善和完善的地方,欢迎在评论区指出,共同进步!\n2. ZooKeeper 介绍 # 2.1. ZooKeeper 由来 # 正式介绍 ZooKeeper 之前,我们先来看看 ZooKeeper 的由来,还挺有意思的。\n下面这段内容摘自《从 Paxos 到 ZooKeeper 》第四章第一节,推荐大家阅读一下:\nZooKeeper 最早起源于雅虎研究院的一个研究小组。在当时,研究人员发现,在雅虎内部很多大型系统基本都需要依赖一个类似的系统来进行分布式协调,但是这些系统往往都存在分布式单点问题。所以,雅虎的开发人员就试图开发一个通用的无单点问题的分布式协调框架,以便让开发人员将精力集中在处理业务逻辑上。\n关于“ZooKeeper”这个项目的名字,其实也有一段趣闻。在立项初期,考虑到之前内部很多项目都是使用动物的名字来命名的(例如著名的 Pig 项目),雅虎的工程师希望给这个项目也取一个动物的名字。时任研究院的首席科学家 RaghuRamakrishnan 开玩笑地说:“在这样下去,我们这儿就变成动物园了!”此话一出,大家纷纷表示就叫动物园管理员吧一一一因为各个以动物命名的分布式组件放在一起,雅虎的整个分布式系统看上去就像一个大型的动物园了,而 ZooKeeper 正好要用来进行分布式环境的协调一一于是,ZooKeeper 的名字也就由此诞生了。\n2.2. ZooKeeper 概览 # ZooKeeper 是一个开源的分布式协调服务,它的设计目标是将那些复杂且容易出错的分布式一致性服务封装起来,构成一个高效可靠的原语集,并以一系列简单易用的接口提供给用户使用。\n原语: 操作系统或计算机网络用语范畴。是由若干条指令组成的,用于完成一定功能的一个过程。具有不可分割性·即原语的执行必须是连续的,在执行过程中不允许被中断。\nZooKeeper 为我们提供了高可用、高性能、稳定的分布式数据一致性解决方案,通常被用于实现诸如数据发布/订阅、负载均衡、命名服务、分布式协调/通知、集群管理、Master 选举、分布式锁和分布式队列等功能。\n另外,ZooKeeper 将数据保存在内存中,性能是非常棒的。 在“读”多于“写”的应用程序中尤其地高性能,因为“写”会导致所有的服务器间同步状态。(“读”多于“写”是协调服务的典型场景)。\n2.3. ZooKeeper 特点 # 顺序一致性: 从同一客户端发起的事务请求,最终将会严格地按照顺序被应用到 ZooKeeper 中去。 原子性: 所有事务请求的处理结果在整个集群中所有机器上的应用情况是一致的,也就是说,要么整个集群中所有的机器都成功应用了某一个事务,要么都没有应用。 单一系统映像 : 无论客户端连到哪一个 ZooKeeper 服务器上,其看到的服务端数据模型都是一致的。 可靠性: 一旦一次更改请求被应用,更改的结果就会被持久化,直到被下一次更改覆盖。 2.4. ZooKeeper 典型应用场景 # ZooKeeper 概览中,我们介绍到使用其通常被用于实现诸如数据发布/订阅、负载均衡、命名服务、分布式协调/通知、集群管理、Master 选举、分布式锁和分布式队列等功能。\n下面选 3 个典型的应用场景来专门说说:\n分布式锁 : 通过创建唯一节点获得分布式锁,当获得锁的一方执行完相关代码或者是挂掉之后就释放锁。 命名服务 :可以通过 ZooKeeper 的顺序节点生成全局唯一 ID 数据发布/订阅 :通过 Watcher 机制 可以很方便地实现数据发布/订阅。当你将数据发布到 ZooKeeper 被监听的节点上,其他机器可通过监听 ZooKeeper 上节点的变化来实现配置的动态更新。 实际上,这些功能的实现基本都得益于 ZooKeeper 可以保存数据的功能,但是 ZooKeeper 不适合保存大量数据,这一点需要注意。\n2.5. 有哪些著名的开源项目用到了 ZooKeeper? # Kafka : ZooKeeper 主要为 Kafka 提供 Broker 和 Topic 的注册以及多个 Partition 的负载均衡等功能。 Hbase : ZooKeeper 为 Hbase 提供确保整个集群只有一个 Master 以及保存和提供 regionserver 状态信息(是否在线)等功能。 Hadoop : ZooKeeper 为 Namenode 提供高可用支持。 3. ZooKeeper 重要概念解读 # 破音:拿出小本本,下面的内容非常重要哦!\n3.1. Data model(数据模型) # ZooKeeper 数据模型采用层次化的多叉树形结构,每个节点上都可以存储数据,这些数据可以是数字、字符串或者是二级制序列。并且。每个节点还可以拥有 N 个子节点,最上层是根节点以“/”来代表。每个数据节点在 ZooKeeper 中被称为 znode,它是 ZooKeeper 中数据的最小单元。并且,每个 znode 都一个唯一的路径标识。\n强调一句:ZooKeeper 主要是用来协调服务的,而不是用来存储业务数据的,所以不要放比较大的数据在 znode 上,ZooKeeper 给出的上限是每个结点的数据大小最大是 1M。\n从下图可以更直观地看出:ZooKeeper 节点路径标识方式和 Unix 文件系统路径非常相似,都是由一系列使用斜杠\u0026quot;/\u0026ldquo;进行分割的路径表示,开发人员可以向这个节点中写入数据,也可以在节点下面创建子节点。这些操作我们后面都会介绍到。\n3.2. znode(数据节点) # 介绍了 ZooKeeper 树形数据模型之后,我们知道每个数据节点在 ZooKeeper 中被称为 znode,它是 ZooKeeper 中数据的最小单元。你要存放的数据就放在上面,是你使用 ZooKeeper 过程中经常需要接触到的一个概念。\n3.2.1. znode 4 种类型 # 我们通常是将 znode 分为 4 大类:\n持久(PERSISTENT)节点 :一旦创建就一直存在即使 ZooKeeper 集群宕机,直到将其删除。 临时(EPHEMERAL)节点 :临时节点的生命周期是与 客户端会话(session) 绑定的,会话消失则节点消失 。并且,临时节点只能做叶子节点 ,不能创建子节点。 持久顺序(PERSISTENT_SEQUENTIAL)节点 :除了具有持久(PERSISTENT)节点的特性之外, 子节点的名称还具有顺序性。比如 /node1/app0000000001 、/node1/app0000000002 。 临时顺序(EPHEMERAL_SEQUENTIAL)节点 :除了具备临时(EPHEMERAL)节点的特性之外,子节点的名称还具有顺序性。 3.2.2. znode 数据结构 # 每个 znode 由 2 部分组成:\nstat :状态信息 data : 节点存放的数据的具体内容 如下所示,我通过 get 命令来获取 根目录下的 dubbo 节点的内容。(get 命令在下面会介绍到)。\n[zk: 127.0.0.1:2181(CONNECTED) 6] get /dubbo # 该数据节点关联的数据内容为空 null # 下面是该数据节点的一些状态信息,其实就是 Stat 对象的格式化输出 cZxid = 0x2 ctime = Tue Nov 27 11:05:34 CST 2018 mZxid = 0x2 mtime = Tue Nov 27 11:05:34 CST 2018 pZxid = 0x3 cversion = 1 dataVersion = 0 aclVersion = 0 ephemeralOwner = 0x0 dataLength = 0 numChildren = 1 Stat 类中包含了一个数据节点的所有状态信息的字段,包括事务 ID-cZxid、节点创建时间-ctime 和子节点个数-numChildren 等等。\n下面我们来看一下每个 znode 状态信息究竟代表的是什么吧!(下面的内容来源于《从 Paxos 到 ZooKeeper 分布式一致性原理与实践》,因为 Guide 确实也不是特别清楚,要学会参考资料的嘛! ) :\nznode 状态信息 解释 cZxid create ZXID,即该数据节点被创建时的事务 id ctime create time,即该节点的创建时间 mZxid modified ZXID,即该节点最终一次更新时的事务 id mtime modified time,即该节点最后一次的更新时间 pZxid 该节点的子节点列表最后一次修改时的事务 id,只有子节点列表变更才会更新 pZxid,子节点内容变更不会更新 cversion 子节点版本号,当前节点的子节点每次变化时值增加 1 dataVersion 数据节点内容版本号,节点创建时为 0,每更新一次节点内容(不管内容有无变化)该版本号的值增加 1 aclVersion 节点的 ACL 版本号,表示该节点 ACL 信息变更次数 ephemeralOwner 创建该临时节点的会话的 sessionId;如果当前节点为持久节点,则 ephemeralOwner=0 dataLength 数据节点内容长度 numChildren 当前节点的子节点个数 3.3. 版本(version) # 在前面我们已经提到,对应于每个 znode,ZooKeeper 都会为其维护一个叫作 Stat 的数据结构,Stat 中记录了这个 znode 的三个相关的版本:\ndataVersion :当前 znode 节点的版本号 cversion : 当前 znode 子节点的版本 aclVersion : 当前 znode 的 ACL 的版本。 3.4. ACL(权限控制) # ZooKeeper 采用 ACL(AccessControlLists)策略来进行权限控制,类似于 UNIX 文件系统的权限控制。\n对于 znode 操作的权限,ZooKeeper 提供了以下 5 种:\nCREATE : 能创建子节点 READ :能获取节点数据和列出其子节点 WRITE : 能设置/更新节点数据 DELETE : 能删除子节点 ADMIN : 能设置节点 ACL 的权限 其中尤其需要注意的是,CREATE 和 DELETE 这两种权限都是针对 子节点 的权限控制。\n对于身份认证,提供了以下几种方式:\nworld : 默认方式,所有用户都可无条件访问。 auth :不使用任何 id,代表任何已认证的用户。 digest :用户名:密码认证方式: username:password 。 ip : 对指定 ip 进行限制。 3.5. Watcher(事件监听器) # Watcher(事件监听器),是 ZooKeeper 中的一个很重要的特性。ZooKeeper 允许用户在指定节点上注册一些 Watcher,并且在一些特定事件触发的时候,ZooKeeper 服务端会将事件通知到感兴趣的客户端上去,该机制是 ZooKeeper 实现分布式协调服务的重要特性。\n破音:非常有用的一个特性,都拿出小本本记好了,后面用到 ZooKeeper 基本离不开 Watcher(事件监听器)机制。\n3.6. 会话(Session) # Session 可以看作是 ZooKeeper 服务器与客户端的之间的一个 TCP 长连接,通过这个连接,客户端能够通过心跳检测与服务器保持有效的会话,也能够向 ZooKeeper 服务器发送请求并接受响应,同时还能够通过该连接接收来自服务器的 Watcher 事件通知。\nSession 有一个属性叫做:sessionTimeout ,sessionTimeout 代表会话的超时时间。当由于服务器压力太大、网络故障或是客户端主动断开连接等各种原因导致客户端连接断开时,只要在**sessionTimeout规定的时间内能够重新连接上集群中任意一台服务器,那么之前创建的会话仍然有效**。\n另外,在为客户端创建会话之前,服务端首先会为每个客户端都分配一个 sessionID。由于 sessionID是 ZooKeeper 会话的一个重要标识,许多与会话相关的运行机制都是基于这个 sessionID 的,因此,无论是哪台服务器为客户端分配的 sessionID,都务必保证全局唯一。\n4. ZooKeeper 集群 # 为了保证高可用,最好是以集群形态来部署 ZooKeeper,这样只要集群中大部分机器是可用的(能够容忍一定的机器故障),那么 ZooKeeper 本身仍然是可用的。通常 3 台服务器就可以构成一个 ZooKeeper 集群了。ZooKeeper 官方提供的架构图就是一个 ZooKeeper 集群整体对外提供服务。\n[!\n上图中每一个 Server 代表一个安装 ZooKeeper 服务的服务器。组成 ZooKeeper 服务的服务器都会在内存中维护当前的服务器状态,并且每台服务器之间都互相保持着通信。集群间通过 **ZAB 协议(ZooKeeper Atomic Broadcast)**来保持数据的一致性。\n[ zookeeper中不是使用这个 ]\n最典型集群模式: Master/Slave 模式(主备模式)。在这种模式中,通常 Master 服务器作为主服务器提供写服务,其他的 Slave 服务器从服务器通过异步复制的方式获取 Master 服务器最新的数据提供读服务。\n4.1. ZooKeeper 集群角色 # 但是,在 ZooKeeper 中没有选择传统的 Master/Slave 概念,而是引入了 Leader、Follower 和 Observer 三种角色。如下图所示\n[!\nZooKeeper 集群中的所有机器通过一个 Leader 选举过程 来选定一台称为 “Leader” 的机器,Leader 既可以为客户端提供写服务又能提供读服务。除了 Leader 外,Follower 和 Observer 都只能提供读服务。Follower 和 Observer 唯一的区别在于 Observer 机器不参与 Leader 的选举过程,也不参与写操作的“过半写成功”策略,因此 Observer 机器可以在不影响写性能的情况下提升集群的读性能。\n角色 说明 Leader 为客户端提供读和写的服务,负责投票的发起和决议,更新系统状态。 Follower 为客户端提供读服务,如果是写服务则转发给 Leader。参与选举过程中的投票。 Observer 为客户端提供读服务,如果是写服务则转发给 Leader。不参与选举过程中的投票,也**不参与“过半写成功”**策略。在不影响写性能的情况下提升集群的读性能。此角色于 ZooKeeper3.3 系列新增的角色。 当 Leader 服务器出现网络中断、崩溃退出与重启等异常情况时,就会进入 Leader 选举过程,这个过程会选举产生新的 Leader 服务器。\n这个过程大致是这样的:\nLeader election(选举阶段):节点在一开始都处于选举阶段,只要有一个节点得到超半数节点的票数,它就可以当选准 leader。 Discovery(发现阶段) :在这个阶段,followers 跟准 leader 进行通信,同步 followers 最近接收的事务提议。 Synchronization(同步阶段) :同步阶段主要是利用 leader 前一阶段获得的最新提议历史,同步集群中所有的副本。同步完成之后 准 leader 才会成为真正的 leader。 Broadcast(广播阶段) :到了这个阶段,ZooKeeper 集群才能正式对外提供事务服务,并且 leader 可以进行消息广播。同时如果有新的节点加入,还需要对新节点进行同步。 4.2. ZooKeeper 集群中的服务器状态 # LOOKING :寻找 Leader。 LEADING :Leader 状态,对应的节点为 Leader。 FOLLOWING :Follower 状态,对应的节点为 Follower。 OBSERVING :Observer 状态,对应节点为 Observer,该节点不参与 Leader 选举。 4.3. ZooKeeper 集群为啥最好奇数台? # ZooKeeper 集群在宕掉几个 ZooKeeper 服务器之后,如果剩下的 ZooKeeper 服务器个数大于宕掉的个数的话整个 ZooKeeper 才依然可用。假如我们的集群中有 n 台 ZooKeeper 服务器,那么也就是剩下的服务数必须大于 n/2。\n有点绕,换句话就是说最多的宕机数必须小于一半(等于也不行),那么如果是奇数x,那他只能小于x/2 即为除之后的整数部分,就算再加一台,也最多只能宕机(x+1)/2 -1(等于奇数 (x+1)/2 -1),所以没必要再多一台,并不能增加可宕机数 先说一下结论,2n 和 2n-1 的容忍度是一样的,都是 n-1,大家可以先自己仔细想一想,这应该是一个很简单的数学问题了。 比如假如我们有 3 台,那么最大允许宕掉 1 台 ZooKeeper 服务器,如果我们有 4 台的的时候也同样只允许宕掉 1 台。 假如我们有 5 台,那么最大允许宕掉 2 台 ZooKeeper 服务器,如果我们有 6 台的的时候也同样只允许宕掉 2 台。\n综上,何必增加那一个不必要的 ZooKeeper 呢?\n4.4. ZooKeeper 选举的过半机制防止脑裂 # 何为集群脑裂?\n对于一个集群,通常多台机器会部署在不同机房,来提高这个集群的可用性。保证可用性的同时,会发生一种机房间网络线路故障,导致机房间网络不通,而集群被割裂成几个小集群。这时候子集群各自选主导致“脑裂”的情况。\n举例说明:比如现在有一个由 6 台服务器所组成的一个集群,部署在了 2 个机房,每个机房 3 台。正常情况下只有 1 个 leader,但是当两个机房中间网络断开的时候,每个机房的 3 台服务器都会认为另一个机房的 3 台服务器下线,而选出自己的 leader 并对外提供服务。若没有过半机制,当网络恢复的时候会发现有 2 个 leader。仿佛是 1 个大脑(leader)分散成了 2 个大脑,这就发生了脑裂现象。脑裂期间 2 个大脑都可能对外提供了服务,这将会带来数据一致性等问题。\n过半机制是如何防止脑裂现象产生的?\nZooKeeper 的过半机制导致不可能产生 2 个 leader,因为少于等于一半是不可能产生 leader 的,这就使得不论机房的机器如何分配都不可能发生脑裂。\n5. ZAB 协议和 Paxos 算法 # Paxos 算法应该可以说是 ZooKeeper 的灵魂了。但是,ZooKeeper 并没有完全采用 Paxos 算法 ,而是使用 ZAB 协议作为其保证数据一致性的核心算法。另外,在 ZooKeeper 的官方文档中也指出,ZAB 协议并不像 Paxos 算法那样,是一种通用的分布式一致性算法,它是一种特别为 Zookeeper 设计的崩溃可恢复的原子消息广播算法。\n5.1. ZAB 协议介绍 # ZAB(ZooKeeper Atomic Broadcast 原子广播) 协议是为分布式协调服务 ZooKeeper 专门设计的一种支持崩溃恢复的原子广播协议。 在 ZooKeeper 中,主要依赖 ZAB 协议来实现分布式数据一致性,基于该协议,ZooKeeper 实现了一种主备模式的系统架构来保持集群中各个副本之间的数据一致性。\n5.2. ZAB 协议两种基本的模式:崩溃恢复和消息广播 # ZAB 协议包括两种基本的模式,分别是\n崩溃恢复 :当整个服务框架在启动过程中,或是当 Leader 服务器出现网络中断、崩溃退出与重启等异常情况时,ZAB 协议就会进入恢复模式并选举产生新的 Leader 服务器。当选举产生了新的 Leader 服务器,同时集群中已经有过半的机器与该 Leader 服务器完成了状态同步之后,ZAB 协议就会退出恢复模式。其中,所谓的状态同步是指数据同步,用来保证集群中存在过半的机器能够和 Leader 服务器的数据状态保持一致。 消息广播 :当集群中已经有过半的 Follower 服务器完成了和 Leader 服务器的状态同步,那么整个服务框架就可以进入消息广播模式了。 当一台同样遵守 ZAB 协议的服务器启动后加入到集群中时,如果此时集群中已经存在一个 Leader 服务器在负责进行消息广播,那么新加入的服务器就会自觉地进入数据恢复模式:找到 Leader 所在的服务器,并与其进行数据同步,然后一起参与到消息广播流程中去。 关于 ZAB 协议\u0026amp;Paxos 算法 需要讲和理解的东西太多了,具体可以看下面这两篇文章:\n图解 Paxos 一致性协议 Zookeeper ZAB 协议分析 6. 总结 # ZooKeeper 本身就是一个分布式程序(只要半数以上节点存活,ZooKeeper 就能正常服务)。 为了保证高可用,最好是以集群形态来部署 ZooKeeper,这样只要集群中大部分机器是可用的(能够容忍一定的机器故障),那么 ZooKeeper 本身仍然是可用的。 ZooKeeper 将数据保存在内存中,这也就保证了 高吞吐量和低延迟(但是内存限制了能够存储的容量不太大,此限制也是保持 znode 中存储的数据量较小的进一步原因)。 ZooKeeper 是高性能的。 在“读”多于“写”的应用程序中尤其地明显,因为“写”会导致所有的服务器间同步状态。(“读”多于“写”是协调服务的典型场景。) ZooKeeper 有临时节点的概念。 当创建临时节点的客户端会话一直保持活动,瞬时节点就一直存在。而当会话终结时,瞬时节点被删除。持久节点是指一旦这个 znode 被创建了,除非主动进行 znode 的移除操作,否则这个 znode 将一直保存在 ZooKeeper 上。 ZooKeeper 底层其实只提供了两个功能:① 管理(存储、读取)用户程序提交的数据;② 为用户程序提供数据节点监听服务。 7. 参考 # 《从 Paxos 到 ZooKeeper 分布式一致性原理与实践》 "},{"id":274,"href":"/zh/docs/technology/Review/java_guide/lydly_distributed_system/ly04ly_rpc-http/","title":"rpc_http","section":"分布式系统","content":" 转载自https://github.com/Snailclimb/JavaGuide(添加小部分笔记)感谢作者!\n我正在参与掘金技术社区创作者签约计划招募活动, 点击链接报名投稿。\n我想起了我刚工作的时候,第一次接触RPC协议,当时就很懵,我HTTP协议用的好好的,为什么还要用RPC协议?\n于是就到网上去搜。\n不少解释显得非常官方,我相信大家在各种平台上也都看到过,解释了又好像没解释,都在用一个我们不认识的概念去解释另外一个我们不认识的概念,懂的人不需要看,不懂的人看了还是不懂。\n这种看了,又好像没看的感觉,云里雾里的很难受,我懂。\n为了避免大家有强烈的审丑疲劳,今天我们来尝试重新换个方式讲一讲。\n从TCP聊起 # 作为一个程序员,假设我们需要在A电脑的进程发一段数据到B电脑的进程,我们一般会在代码里使用socket进行编程。\n这时候,我们可选项一般也就TCP和UDP二选一。TCP可靠,UDP不可靠。 除非是马总这种神级程序员(早期QQ大量使用UDP),否则,只要稍微对可靠性有些要求,普通人一般无脑选TCP就对了。\n类似下面这样。\nfd = socket(AF_INET,SOCK_STREAM,0); 复制代码 其中SOCK_STREAM,是指使用字节流传输数据,说白了就是TCP协议。\n在定义了socket之后,我们就可以愉快的对这个socket进行操作,比如用bind()绑定IP端口,用connect()发起建连。\n在连接建立之后,我们就可以使用send()发送数据,recv()接收数据。\n光这样一个纯裸的TCP连接,就可以做到收发数据了,那是不是就够了?\n不行,这么用会有问题。\n使用纯裸TCP会有什么问题 # 八股文常背,TCP是有三个特点,面向连接、可靠、基于字节流。\n这三个特点真的概括的非常精辟,这个八股文我们没白背。\n每个特点展开都能聊一篇文章,而今天我们需要关注的是基于字节流这一点。\n字节流可以理解为一个双向的通道里流淌的数据,这个数据其实就是我们常说的二进制数据,简单来说就是一大堆 01 串。纯裸TCP收发的这些 01 串之间是没有任何边界的,你根本不知道到哪个地方才算一条完整消息。 正因为这个没有任何边界的特点,所以当我们选择使用TCP发送 \u0026ldquo;夏洛\u0026quot;和\u0026quot;特烦恼\u0026rdquo; 的时候,接收端收到的就是 \u0026ldquo;夏洛特烦恼\u0026rdquo; ,这时候接收端没发区分你是想要表达 \u0026ldquo;夏洛\u0026rdquo;+\u0026ldquo;特烦恼\u0026rdquo; 还是 \u0026ldquo;夏洛特\u0026rdquo;+\u0026ldquo;烦恼\u0026rdquo; 。\n这就是所谓的粘包问题,之前也写过一篇专门的 文章聊过这个问题。\n说这个的目的是为了告诉大家,纯裸TCP是不能直接拿来用的,你需要在这个基础上加入一些自定义的规则,用于区分消息边界。\n于是我们会把每条要发送的数据都包装一下,比如加入消息头,消息头里写清楚一个完整的包长度是多少,根据这个长度可以继续接收数据,截取出来后它们就是我们真正要传输的消息体。\n而这里头提到的消息头,还可以放各种东西,比如消息体是否被压缩过和消息体格式之类的,只要上下游都约定好了,互相都认就可以了,这就是所谓的协议。\n每个使用TCP的项目都可能会定义一套类似这样的协议解析标准,他们可能有区别,但原理都类似。\n于是基于TCP,就衍生了非常多的协议,比如HTTP和RPC。\nHTTP和RPC # 我们回过头来看网络的分层图。\nTCP是传输层的协议,而基于TCP造出来的HTTP和各类RPC协议,它们都只是定义了不同消息格式的应用层协议而已。\nHTTP协议(Hyper Text Transfer Protocol),又叫做超文本传输协议。我们用的比较多,平时上网在浏览器上敲个网址就能访问网页,这里用到的就是HTTP协议。\n而RPC(Remote Procedure Call),又叫做远程过程调用。它本身并不是一个具体的协议,而是一种调用方式。\n举个例子,我们平时调用一个本地方法就像下面这样。\nres = localFunc(req) 复制代码 如果现在这不是个本地方法,而是个远端服务器暴露出来的一个方法remoteFunc,如果我们还能像调用本地方法那样去调用它,这样就可以屏蔽掉一些网络细节,用起来更方便,岂不美哉?\nres = remoteFunc(req) 复制代码 基于这个思路,大佬们造出了非常多款式的RPC协议,比如比较有名的gRPC,thrift。\n值得注意的是,虽然大部分RPC协议底层使用TCP,但实际上它们不一定非得使用TCP,改用UDP或者HTTP,其实也可以做到类似的功能。\n到这里,我们回到文章标题的问题。\n既然有HTTP协议,为什么还要有RPC?\n其实,TCP是70年代出来的协议,而HTTP是90年代才开始流行的。而直接使用裸TCP会有问题,可想而知,这中间这么多年有多少自定义的协议,而这里面就有80年代出来的RPC。\n所以我们该问的不是既然有HTTP协议为什么要有RPC,而是为什么有RPC还要有HTTP协议。\n那既然有RPC了,为什么还要有HTTP呢? # 现在电脑上装的各种联网软件,比如xx管家,xx卫士,它们都作为客户端(client) 需要跟服务端(server) 建立连接收发消息,此时都会用到应用层协议,在这种client/server (c/s) 架构下,它们可以使用自家造的RPC协议,因为它只管连自己公司的服务器就ok了。\n但有个软件不同,浏览器(browser) ,不管是chrome还是IE,它们不仅要能访问自家公司的服务器(server) ,还需要访问其他公司的网站服务器,因此它们需要有个统一的标准,不然大家没法交流。于是,HTTP就是那个时代用于统一 browser/server (b/s) 的协议。\n也就是说在多年以前,HTTP主要用于b/s架构,而RPC更多用于c/s架构。但现在其实已经没分那么清了,b/s和c/s在慢慢融合。 很多软件同时支持多端,比如某度云盘,既要支持网页版,还要支持手机端和pc端,如果通信协议都用HTTP的话,那服务器只用同一套就够了。而RPC就开始退居幕后,一般用于公司内部集群里,各个微服务之间的通讯。\n那这么说的话,都用HTTP得了,还用什么RPC?\n仿佛又回到了文章开头的样子,那这就要从它们之间的区别开始说起。\nHTTP和RPC有什么区别 # 我们来看看RPC和HTTP区别比较明显的几个点。\n服务发现 # 首先要向某个服务器发起请求,你得先建立连接,而建立连接的前提是,你得知道IP地址和端口。这个找到服务对应的IP端口的过程,其实就是服务发现。\n在HTTP中,你知道服务的域名,就可以通过DNS服务去解析得到它背后的IP地址,默认80端口。\n而RPC的话,就有些区别,一般会有专门的中间服务去保存服务名和IP信息,比如consul或者etcd,甚至是redis。想要访问某个服务,就去这些中间服务去获得IP和端口信息。由于dns也是服务发现的一种,所以也有基于dns去做服务发现的组件,比如CoreDNS。\n可以看出服务发现这一块,两者是有些区别,但不太能分高低。\n底层连接形式 # 以主流的HTTP1.1协议为例,其默认在建立底层TCP连接之后会一直保持这个连接(keep alive),之后的请求和响应都会复用这条连接。\n而RPC协议,也跟HTTP类似,也是通过建立TCP长链接进行数据交互,但不同的地方在于,RPC协议一般还会再建个连接池,在请求量大的时候,建立多条连接放在池内,要发数据的时候就从池里取一条连接出来,用完放回去,下次再复用,可以说非常环保。\n由于连接池有利于提升网络请求性能,所以不少编程语言的网络库里都会给HTTP加个连接池,比如go就是这么干的。\n可以看出这一块两者也没太大区别,所以也不是关键。\n传输的内容 # 基于TCP传输的消息,说到底,无非都是消息头header和消息体body。\nheader是用于标记一些特殊信息,其中最重要的是消息体长度。\nbody则是放我们真正需要传输的内容,而这些内容只能是二进制01串,毕竟计算机只认识这玩意。所以TCP传字符串和数字都问题不大,因为字符串可以转成编码再变成01串,而数字本身也能直接转为二进制。但结构体呢,我们得想个办法将它也转为二进制01串,这样的方案现在也有很多现成的,比如json,protobuf。\n这个将结构体转为二进制数组的过程就叫序列化,反过来将二进制数组复原成结构体的过程叫反序列化。\n对于主流的HTTP1.1,虽然它现在叫超文本协议,支持音频视频,但HTTP设计初是用于做网页文本展示的,所以它传的内容以字符串为主。header和body都是如此。在body这块,它使用json来序列化结构体数据。\n我们可以随便截个图直观看下。\n可以看到这里面的内容非常多的冗余,显得非常啰嗦。最明显的,像header里的那些信息,其实如果我们约定好头部的第几位是content-type,就不需要每次都真的把\u0026quot;content-type\u0026quot;这个字段都传过来,类似的情况其实在body的json结构里也特别明显。\n而RPC,因为它定制化程度更高,可以采用体积更小的protobuf或其他序列化协议去保存结构体数据,同时也不需要像HTTP那样考虑各种浏览器行为,比如302重定向跳转啥的。因此性能也会更好一些,这也是在公司内部微服务中抛弃HTTP,选择使用RPC的最主要原因。\n====缺一张图片====\n当然上面说的HTTP,其实特指的是现在主流使用的HTTP1.1,HTTP2在前者的基础上做了很多改进,所以性能可能比很多RPC协议还要好,甚至连gRPC底层都直接用的HTTP2。\n那么问题又来了。\n为什么既然有了HTTP2,还要有RPC协议? # 这个是由于HTTP2是2015年出来的。那时候很多公司内部的RPC协议都已经跑了好些年了,基于历史原因,一般也没必要去换了。\n总结 # 纯裸TCP是能收发数据,但它是个无边界的数据流,上层需要定义消息格式用于定义消息边界。于是就有了各种协议,HTTP和各类RPC协议就是在TCP之上定义的应用层协议。 RPC本质上不算是协议,而是一种调用方式,而像gRPC和thrift这样的具体实现,才是协议,它们是实现了RPC调用的协议。目的是希望程序员能像调用本地方法那样去调用远端的服务方法。同时RPC有很多种实现方式,不一定非得基于TCP协议。 从发展历史来说,HTTP主要用于b/s架构,而RPC更多用于c/s架构。但现在其实已经没分那么清了,b/s和c/s在慢慢融合。 很多软件同时支持多端,所以对外一般用HTTP协议,而内部集群的微服务之间则采用RPC协议进行通讯。 RPC其实比HTTP出现的要早,且比目前主流的HTTP1.1性能要更好,所以大部分公司内部都还在使用RPC。 HTTP2.0在HTTP1.1的基础上做了优化,性能可能比很多RPC协议都要好,但由于是这几年才出来的,所以也不太可能取代掉RPC。 最后留个问题吧,大家有没有发现,不管是HTTP还是RPC,它们都有个特点,那就是消息都是客户端请求,服务端响应。客户端没问,服务端肯定就不答,这就有点僵了,但现实中肯定有需要下游主动发送消息给上游的场景,比如打个网页游戏,站在那啥也不操作,怪也会主动攻击我,这种情况该怎么办呢?\n最后 # 按照惯例,我应该在这里唯唯诺诺的求大家叫我两声靓仔的。\n但还是算了。因为我最近一直在想一个问题,希望兄弟们能在评论区告诉我答案。\n最近手机借给别人玩了一下午,现在老是给我推荐练习时长两年半的练习生视频。\n每个视频都在声嘶力竭的告诉我,鸡你太美。\n所以我很想问,兄弟们。\n鸡,到底美不美?\n头疼。\n右下角的点赞和再看还是可以走一波的。\n先这样。\n我是小白,我们下期见。\n别说了,一起在知识的海洋里呛水吧 # "},{"id":275,"href":"/zh/docs/technology/Review/java_guide/lydly_distributed_system/ly05ly_rpc-intro/","title":"rpc基础及面试题","section":"分布式系统","content":" 转载自https://github.com/Snailclimb/JavaGuide(添加小部分笔记)感谢作者!\n简单介绍一下 RPC 相关的基础概念。\n何为 RPC? # RPC(Remote Procedure Call) 即远程过程调用,通过名字我们就能看出 RPC 关注的是远程调用而非本地调用。\n为什么要 RPC ? 因为,两个不同的服务器上的服务提供的方法不在一个内存空间,所以,需要通过网络编程才能传递方法调用所需要的参数。并且,方法调用的结果也需要通过网络编程来接收。但是,如果我们自己手动网络编程来实现这个调用过程的话工作量是非常大的,因为,我们需要考虑底层传输方式(TCP还是UDP)、序列化方式等等方面。\nRPC 能帮助我们做什么呢? 简单来说,通过 RPC 可以帮助我们调用远程计算机上某个服务的方法,这个过程就像调用本地方法一样简单。并且!我们不需要了解底层网络编程的具体细节。\n举个例子:两个不同的服务 A、B 部署在两台不同的机器上,服务 A 如果想要调用服务 B 中的某个方法的话就可以通过 RPC 来做。\n一言蔽之:RPC 的出现就是为了让你调用远程方法像调用本地方法一样简单。\nRPC 的原理是什么? # 为了能够帮助小伙伴们理解 RPC 原理,我们可以将整个 RPC的 核心功能看作是下面👇 5 个部分实现的:\n客户端(服务消费端) :调用远程方法的一端。 客户端 Stub(桩) : 这其实就是一代理类。代理类主要做的事情很简单,就是把你调用方法、类、方法参数等信息传递到服务端。 网络传输 : 网络传输就是你要把你调用的方法的信息比如说参数啊这些东西传输到服务端,然后服务端执行完之后再把返回结果通过网络传输给你传输回来。网络传输的实现方式有很多种比如最近基本的 Socket或者性能以及封装更加优秀的 Netty(推荐)。 服务端 Stub(桩) :这个桩就不是代理类了。我觉得理解为桩实际不太好,大家注意一下就好。这里的服务端 Stub 实际指的就是接收到客户端执行方法的请求后,去指定对应的方法然后返回结果给客户端的类。 服务端(服务提供端) :提供远程方法的一端。 具体原理图如下,后面我会串起来将整个RPC的过程给大家说一下。\n服务消费端(client)以本地调用的方式调用远程服务; 客户端 Stub(client stub) 接收到调用后负责将方法、参数等组装成能够进行网络传输的消息体(序列化):RpcRequest; 客户端 Stub(client stub) 找到远程服务的地址,并将消息发送到服务提供端; 服务端 Stub(桩)收到消息将消息反序列化为Java对象: RpcRequest; 服务端 Stub(桩)根据RpcRequest中的类、方法、方法参数等信息调用本地的方法; 服务端 Stub(桩)得到方法执行结果并将组装成能够进行网络传输的消息体:RpcResponse(序列化)发送至消费方; 客户端 Stub(client stub)接收到消息并将消息反序列化为Java对象:RpcResponse ,这样也就得到了最终结果。over! 相信小伙伴们看完上面的讲解之后,已经了解了 RPC 的原理。\n虽然篇幅不多,但是基本把 RPC 框架的核心原理讲清楚了!另外,对于上面的技术细节,我会在后面的章节介绍到。\n最后,对于 RPC 的原理,希望小伙伴不单单要理解,还要能够自己画出来并且能够给别人讲出来。因为,在面试中这个问题在面试官问到 RPC 相关内容的时候基本都会碰到。\n有哪些常见的 RPC 框架? # 我们这里说的 RPC 框架指的是可以让客户端直接调用服务端方法,就像调用本地方法一样简单的框架,比如我下面介绍的 Dubbo、Motan、gRPC这些。 如果需要和 HTTP 协议打交道,解析和封装 HTTP 请求和响应。这类框架并不能算是“RPC 框架”,比如Feign。\nDubbo # Apache Dubbo 是一款微服务框架,为大规模微服务实践提供高性能 RPC 通信、流量治理、可观测性等解决方案, 涵盖 Java、Golang 等多种语言 SDK 实现。\nDubbo 提供了从服务定义、服务发现、服务通信到流量管控等几乎所有的服务治理能力,支持 Triple 协议(基于 HTTP/2 之上定义的下一代 RPC 通信协议)、应用级服务发现、Dubbo Mesh (Dubbo3 赋予了很多云原生友好的新特性)等特性。\nDubbo 是由阿里开源,后来加入了 Apache 。正是由于 Dubbo 的出现,才使得越来越多的公司开始使用以及接受分布式架构。\nDubbo 算的是比较优秀的国产开源项目了,它的源码也是非常值得学习和阅读的!\nGithub :https://github.com/apache/incubator-dubbo 官网:https://dubbo.apache.org/zh/ Motan # Motan 是新浪微博开源的一款 RPC 框架,据说在新浪微博正支撑着千亿次调用。不过笔者倒是很少看到有公司使用,而且网上的资料也比较少。\n很多人喜欢拿 Motan 和 Dubbo 作比较,毕竟都是国内大公司开源的。笔者在查阅了很多资料,以及简单查看了其源码之后发现:Motan 更像是一个精简版的 Dubbo,可能是借鉴了 Dubbo 的思想,Motan 的设计更加精简,功能更加纯粹。\n不过,我不推荐你在实际项目中使用 Motan。如果你要是公司实际使用的话,还是推荐 Dubbo ,其社区活跃度以及生态都要好很多。\n从 Motan 看 RPC 框架设计:http://kriszhang.com/motan-rpc-impl/ Motan 中文文档:https://github.com/weibocom/motan/wiki/zh_overview gRPC # gRPC 是 Google 开源的一个高性能、通用的开源 RPC 框架。其由主要面向移动应用开发并基于 HTTP/2 协议标准而设计(支持双向流、消息头压缩等功能,更加节省带宽),基于 ProtoBuf 序列化协议开发,并且支持众多开发语言。\n何谓 ProtoBuf? ProtoBuf( Protocol Buffer) 是一种更加灵活、高效的数据格式,可用于通讯协议、数据存储等领域,基本支持所有主流编程语言且与平台无关。不过,通过 ProtoBuf 定义接口和数据类型还挺繁琐的,这是一个小问题。\n不得不说,gRPC 的通信层的设计还是非常优秀的, Dubbo-go 3.0 的通信层改进主要借鉴了 gRPC。\n不过,gRPC 的设计导致其几乎没有服务治理能力。如果你想要解决这个问题的话,就需要依赖其他组件比如腾讯的 PolarisMesh(北极星)了。\nGithub:https://github.com/grpc/grpc 官网:https://grpc.io/ Thrift # Apache Thrift 是 Facebook 开源的跨语言的 RPC 通信框架,目前已经捐献给 Apache 基金会管理,由于其跨语言特性和出色的性能,在很多互联网公司得到应用,有能力的公司甚至会基于 thrift 研发一套分布式服务框架,增加诸如服务注册、服务发现等功能。\nThrift支持多种不同的编程语言,包括C++、Java、Python、PHP、Ruby等(相比于 gRPC 支持的语言更多 )。\n官网:https://thrift.apache.org/ Thrift 简单介绍:https://www.jianshu.com/p/8f25d057a5a9 总结 # gRPC 和 Thrift 虽然支持跨语言的 RPC 调用,但是它们只提供了最基本的 RPC 框架功能,缺乏一系列配套的服务化组件和服务治理功能的支撑。\nDubbo 不论是从功能完善程度、生态系统还是社区活跃度来说都是最优秀的。而且,Dubbo在国内有很多成功的案例比如当当网、滴滴等等,是一款经得起生产考验的成熟稳定的 RPC 框架。最重要的是你还能找到非常多的 Dubbo 参考资料,学习成本相对也较低。\n下图展示了 Dubbo 的生态系统。\nDubbo 也是 Spring Cloud Alibaba 里面的一个组件。\n但是,Dubbo 和 Motan 主要是给 Java 语言使用。虽然,Dubbo 和 Motan 目前也能兼容部分语言,但是不太推荐。如果需要跨多种语言调用的话,可以考虑使用 gRPC。\n综上,如果是 Java 后端技术栈,并且你在纠结选择哪一种 RPC 框架的话,我推荐你考虑一下 Dubbo。\n如何设计并实现一个 RPC 框架? # 《手写 RPC 框架》 是我的 知识星球的一个内部小册,我写了 12 篇文章来讲解如何从零开始基于 Netty+Kyro+Zookeeper 实现一个简易的 RPC 框架。\n麻雀虽小五脏俱全,项目代码注释详细,结构清晰,并且集成了 Check Style 规范代码结构,非常适合阅读和学习。\n内容概览 :\n既然有了 HTTP 协议,为什么还要有 RPC ? # HTTP 和 RPC 详细对比 。\n"},{"id":276,"href":"/zh/docs/technology/Review/java_guide/lydly_distributed_system/ly03ly_distributed-lock/","title":"分布式锁","section":"分布式系统","content":" 转载自https://github.com/Snailclimb/JavaGuide(添加小部分笔记)感谢作者!\n网上有很多分布式锁相关的文章,写了一个相对简洁易懂的版本,针对面试和工作应该够用了。\n什么是分布式锁? # 对于单机多线程来说,在 Java 中,我们通常使用 ReetrantLock 类、synchronized 关键字这类 JDK 自带的 本地锁 来控制一个 JVM 进程内的多个线程对本地共享资源的访问。\n下面是我对本地锁画的一张示意图。\n从图中可以看出,这些线程访问共享资源是互斥的,同一时刻只有一个线程可以获取到本地锁访问共享资源。\n分布式系统下,不同的服务/客户端通常运行在独立的 JVM 进程上。如果多个 JVM 进程共享同一份资源的话,使用本地锁就没办法实现资源的互斥访问了。于是,分布式锁 就诞生了。\n举个例子:系统的订单服务一共部署了 3 份,都对外提供服务。用户下订单之前需要检查库存,为了防止超卖,这里需要加锁以实现对检查库存操作的同步访问。由于订单服务位于不同的 JVM 进程中,本地锁在这种情况下就没办法正常工作了。我们需要用到分布式锁,这样的话,即使多个线程不在同一个 JVM 进程中也能获取到同一把锁,进而实现共享资源的互斥访问。\n下面是我对分布式锁画的一张示意图。\n从图中可以看出,这些独立的进程中的线程访问共享资源是互斥的,同一时刻只有一个线程可以获取到分布式锁访问共享资源。\n一个最基本的分布式锁需要满足:\n互斥 :任意一个时刻,锁只能被一个线程持有; 高可用 :锁服务是高可用的。并且,即使客户端的释放锁的代码逻辑出现问题(这里说的是异常,不是说代码写的有问题),锁最终一定还是会被释放,不会影响其他线程对共享资源的访问。 可重入:(同)一个节点获取了锁之后,还可以再次获取锁。 通常情况下,我们一般会选择基于 Redis 或者 ZooKeeper 实现分布式锁,Redis 用的要更多一点,我这里也以 Redis 为例介绍分布式锁的实现。\n基于 Redis 实现分布式锁 # 如何基于 Redis 实现一个最简易的分布式锁? # 不论是实现锁(本地)还是分布式锁,核心都在于**“互斥”**。\n在 Redis 中, SETNX 命令是可以帮助我们实现互斥。SETNX 即 SET if Not eXists (对应 Java 中的 setIfAbsent 方法),如果 key 不存在的话,才会设置 key 的值。如果 key 已经存在, SETNX 啥也不做。\n\u0026gt; SETNX lockKey uniqueValue (integer) 1 \u0026gt; SETNX lockKey uniqueValue (integer) 0 #如上成功为1,失败为0 释放锁的话,直接通过 DEL 命令删除对应的 key 即可。\n\u0026gt; DEL lockKey (integer) 1 # 成功为1 为了误删到其他的锁,这里我们建议使用 Lua 脚本通过 key 对应的 value(唯一值)来判断。\n选用 Lua 脚本是为了保证解锁操作的原子性。因为 Redis 在执行 Lua 脚本时,可以以原子性的方式执行,从而保证了锁释放操作的原子性。\n// 释放锁时,先比较锁对应的 value 值是否相等,避免锁的误释放 if redis.call(\u0026#34;get\u0026#34;,KEYS[1]) == ARGV[1] then return redis.call(\u0026#34;del\u0026#34;,KEYS[1]) else return 0 end 这是一种最简易的 Redis 分布式锁实现,实现方式比较简单,性能也很高效。不过,这种方式实现分布式锁存在一些问题。就比如应用程序遇到一些问题比如释放锁的逻辑突然挂掉,可能会导致锁无法被释放,进而造成共享资源无法再被其他线程/进程访问。\n为什么要给锁设置一个过期时间? # 为了避免锁无法被释放,我们可以想到的一个解决办法就是: 给这个 key(也就是锁) 设置一个过期时间 。\n127.0.0.1:6379\u0026gt; SET lockKey uniqueValue EX 3 NX OK lockKey :加锁的锁名; uniqueValue :能够唯一标示锁的随机字符串; NX :只有当 lockKey 对应的 key 值不存在的时候才能 SET 成功; EX :过期时间设置(秒为单位)EX 3 标示这个锁有一个 3 秒的自动过期时间。与 EX 对应的是 PX(毫秒为单位),这两个都是过期时间设置。 一定要保证设置指定 key 的值和过期时间是一个原子操作!!! 不然的话,依然可能会出现锁无法被释放的问题。\n这样确实可以解决问题,不过,这种解决办法同样存在漏洞:如果操作共享资源的时间大于过期时间,就会出现锁提前过期的问题,进而导致分布式锁直接失效。如果锁的超时时间设置过长,又会影响到性能。\n你或许在想: 如果操作共享资源的操作还未完成,锁过期时间能够自己续期就好了!\n如何实现锁的优雅续期? # 对于 Java 开发的小伙伴来说,已经有了现成的解决方案: Redisson 。其他语言的解决方案,可以在 Redis 官方文档中找到,地址:https://redis.io/topics/distlock 。\nRedisson 是一个开源的 Java 语言 Redis 客户端,提供了很多开箱即用的功能,不仅仅包括多种分布式锁的实现。并且,Redisson 还支持 Redis 单机、Redis Sentinel 、Redis Cluster 等多种部署架构。\nRedisson 中的分布式锁自带自动续期机制,使用起来非常简单,原理也比较简单,其提供了一个专门用来监控和续期锁的 Watch Dog( 看门狗),如果操作共享资源的线程还未执行完成的话,Watch Dog 会不断地延长锁的过期时间,进而保证锁不会因为超时而被释放。\n如图,续期之前也是要检测是否为持锁线程\n看门狗名字的由来于 getLockWatchdogTimeout() 方法,这个方法返回的是看门狗给锁续期的过期时间,默认为 30 秒( redisson-3.17.6)。\n//默认 30秒,支持修改 private long lockWatchdogTimeout = 30 * 1000; public Config setLockWatchdogTimeout(long lockWatchdogTimeout) { this.lockWatchdogTimeout = lockWatchdogTimeout; return this; } public long getLockWatchdogTimeout() { return lockWatchdogTimeout; } renewExpiration() 方法包含了看门狗的主要逻辑:\nprivate void renewExpiration() { //...... Timeout task = commandExecutor.getConnectionManager().newTimeout(new TimerTask() { @Override public void run(Timeout timeout) throws Exception { //...... // 异步续期,基于 Lua 脚本(ly:我觉得是为了保证原子性所以用了Lua脚本) CompletionStage\u0026lt;Boolean\u0026gt; future = renewExpirationAsync(threadId); future.whenComplete((res, e) -\u0026gt; { if (e != null) { // 无法续期 log.error(\u0026#34;Can\u0026#39;t update lock \u0026#34; + getRawName() + \u0026#34; expiration\u0026#34;, e); EXPIRATION_RENEWAL_MAP.remove(getEntryName()); return; } if (res) { // 递归调用实现续期 renewExpiration(); } else { // 取消续期 cancelExpirationRenewal(null); } }); } // 延迟 internalLockLeaseTime/3(默认 10s,也就是 30/3) 再调用 }, internalLockLeaseTime / 3, TimeUnit.MILLISECONDS); ee.setTimeout(task); } 默认情况下,每过 10 秒,看门狗就会执行续期操作,将锁的超时时间设置为 30 秒。看门狗续期前也会先判断是否需要执行续期操作,需要才会执行续期,否则取消续期操作。\nWatch Dog 通过调用 renewExpirationAsync() 方法实现锁的异步续期:\nprotected CompletionStage\u0026lt;Boolean\u0026gt; renewExpirationAsync(long threadId) { return evalWriteAsync(getRawName(), LongCodec.INSTANCE, RedisCommands.EVAL_BOOLEAN, // 判断是否为持锁线程,如果是就执行续期操作,就锁的过期时间设置为 30s(默认) \u0026#34;if (redis.call(\u0026#39;hexists\u0026#39;, KEYS[1], ARGV[2]) == 1) then \u0026#34; + \u0026#34;redis.call(\u0026#39;pexpire\u0026#39;, KEYS[1], ARGV[1]); \u0026#34; + \u0026#34;return 1; \u0026#34; + \u0026#34;end; \u0026#34; + \u0026#34;return 0;\u0026#34;, Collections.singletonList(getRawName()), internalLockLeaseTime, getLockName(threadId)); } 可以看出, renewExpirationAsync 方法其实是调用 Lua 脚本实现的续期,这样做主要是为了保证续期操作的原子性。\n我这里以 Redisson 的分布式可重入锁 RLock 为例来说明如何使用 Redisson 实现分布式锁:\n// 1.获取指定的分布式锁对象 RLock lock = redisson.getLock(\u0026#34;lock\u0026#34;); // 2.拿锁且不设置锁超时时间,具备 Watch Dog 自动续期机制 lock.lock(); // 3.执行业务 ... // 4.释放锁 lock.unlock(); 只有未指定锁超时时间,才会使用到 Watch Dog 自动续期机制。\n// 手动给锁设置过期时间,不具备 Watch Dog 自动续期机制 lock.lock(10, TimeUnit.SECONDS); 如果使用 Redis 来实现分布式锁的话,还是比较推荐直接基于 Redisson 来做的。\n如何实现可重入锁? # 所谓可重入锁指的是在一个线程中可以多次获取同一把锁,比如一个线程在执行一个带锁的方法,该方法中又调用了另一个需要相同锁的方法,则该线程可以直接执行调用的方法即可重入 ,而无需重新获得锁。像 Java 中的 synchronized 和 ReentrantLock 都属于可重入锁。\n不可重入的分布式锁基本可以满足绝大部分业务场景了,一些特殊的场景可能会需要使用可重入的分布式锁。\n可重入分布式锁的实现核心思路是线程在获取锁的时候判断是否为自己的锁,如果是的话,就不用再重新获取了。为此,我们可以为每个锁关联一个可重入计数器和一个占有它的线程。当可重入计数器大于 0 时,则锁被占有,需要判断占有该锁的线程和请求获取锁的线程是否为同一个。\n实际项目中,我们不需要自己手动实现,推荐使用我们上面提到的 Redisson ,其内置了多种类型的锁比如可重入锁(Reentrant Lock)、自旋锁(Spin Lock)、公平锁(Fair Lock)、多重锁(MultiLock)、 红锁(RedLock)、 读写锁(ReadWriteLock)。\nRedis 如何解决集群情况下分布式锁的可靠性? # 为了避免单点故障(也就是只部署在一台机器,导致一台机器挂了服务就无法运行并提供功能),生产环境下的 Redis 服务通常是集群化部署的。\nRedis 集群下,上面介绍到的分布式锁的实现会存在一些问题。由于 Redis 集群数据同步到各个节点时是异步的,如果在 Redis 主节点获取到锁后,在没有同步到其他节点时,Redis 主节点宕机了,此时新的 Redis 主节点依然可以获取锁,所以多个应用服务就可以同时获取到锁。\n针对这个问题,Redis 之父 antirez 设计了 Redlock 算法 来解决。\nRedlock 算法的思想是让客户端向 Redis 集群中的多个独立的 Redis 实例 依次请求申请加锁,如果客户端能够和半数以上的实例成功地完成加锁操作,那么我们就认为,客户端成功地获得分布式锁,否则加锁失败。\n即使部分 Redis 节点出现问题,只要保证 Redis 集群中有半数以上的 Redis 节点可用,分布式锁服务就是正常的。\nRedlock 是直接操作 Redis 节点的,并不是通过 Redis 集群操作的,这样才可以避免 Redis 集群主从切换导致的锁丢失问题。\n注意,不是通过Redis集群做的哦\nRedlock 实现比较复杂,性能比较差,发生时钟变迁的情况下还存在安全性隐患。《数据密集型应用系统设计》一书的作者 Martin Kleppmann 曾经专门发文( How to do distributed locking - Martin Kleppmann - 2016)怼过 Redlock,他认为这是一个很差的分布式锁实现。感兴趣的朋友可以看看 Redis 锁从面试连环炮聊到神仙打架这篇文章,有详细介绍到 antirez 和 Martin Kleppmann 关于 Redlock 的激烈辩论。\n实际项目中不建议使用 Redlock 算法,成本和收益不成正比。\n如果不是非要实现绝对可靠的分布式锁的话,其实单机版 Redis 就完全够了,实现简单,性能也非常高。如果你必须要实现一个绝对可靠的分布式锁的话,可以基于 Zookeeper 来做,只是性能会差一些。\n"},{"id":277,"href":"/zh/docs/technology/Review/java_guide/lydly_distributed_system/ly02ly_distributed-id/","title":"分布式id","section":"分布式系统","content":" 转载自https://github.com/Snailclimb/JavaGuide(添加小部分笔记)感谢作者!\n分布式 ID 介绍 # 什么是 ID? # 日常开发中,我们需要对系统中的各种数据使用 ID 唯一表示,比如用户 ID 对应且仅对应一个人,商品 ID 对应且仅对应一件商品,订单 ID 对应且仅对应一个订单。\n我们现实生活中也有各种 ID,比如身份证 ID 对应且仅对应一个人、地址 ID 对应且仅对应\n简单来说,ID 就是数据的唯一标识。\n什么是分布式 ID? # 分布式 ID 是分布式系统下的 ID。分布式 ID 不存在与现实生活中(属于技术上的问题,跟业务无关),属于计算机系统中的一个概念。\n我简单举一个分库分表的例子。\n我司的一个项目,使用的是单机 MySQL 。但是,没想到的是,项目上线一个月之后,随着使用人数越来越多,整个系统的数据量将越来越大。单机 MySQL 已经没办法支撑了,需要进行分库分表(推荐 Sharding-JDBC)。\n在分库之后, 数据遍布在不同服务器上的数据库,数据库的自增主键已经没办法满足生成的主键唯一了。我们如何为不同的数据节点生成全局唯一主键呢?\n这个时候就需要生成分布式 ID了。\n分布式 ID 需要满足哪些要求? # 分布式 ID 作为分布式系统中必不可少的一环,很多地方都要用到分布式 ID。\n一个最基本的分布式 ID 需要满足下面这些要求:\n全局唯一 :ID 的全局唯一性肯定是首先要满足的! 高性能 : 分布式 ID 的生成速度要快,对本地资源消耗要小。 高可用 :生成分布式 ID 的服务要保证可用性无限接近于 100%。 方便易用 :拿来即用,使用方便,快速接入! 除了这些之外,一个比较好的分布式 ID 还应保证:\n安全 :ID 中不包含敏感信息。 有序递增 :如果要把 ID 存放在数据库的话,ID 的有序性可以提升数据库写入速度。并且,很多时候 ,我们还很有可能会直接通过 ID 来进行排序。 有具体的业务含义 :生成的 ID 如果能有具体的业务含义,可以让定位问题以及开发更透明化(通过 ID 就能确定是哪个业务)。 独立部署 :也就是分布式系统单独有一个发号器服务,专门用来生成分布式 ID。这样就生成 ID 的服务可以和业务相关的服务解耦。不过,这样同样带来了网络调用消耗增加的问题。总的来说,如果需要用到分布式 ID 的场景比较多的话,独立部署的发号器服务还是很有必要的。 分布式 ID 常见解决方案 # 这里说的是如何获取到一个分布式ID,而不是具体分布式ID的使用\n数据库 # 数据库主键自增 # 这种方式就比较简单直白了,就是通过关系型数据库的自增主键产生来唯一的 ID。\n以 MySQL 举例,我们通过下面的方式即可。\n1.创建一个数据库表。\nCREATE TABLE `sequence_id` ( `id` bigint(20) unsigned NOT NULL AUTO_INCREMENT, `stub` char(10) NOT NULL DEFAULT \u0026#39;\u0026#39;, PRIMARY KEY (`id`), UNIQUE KEY `stub` (`stub`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; stub 字段无意义,只是为了占位,便于我们插入或者修改数据。并且,给 stub 字段创建了唯一索引,保证其唯一性。\n2.通过 replace into 来插入数据。\nBEGIN; REPLACE INTO sequence_id (stub) VALUES (\u0026#39;stub\u0026#39;); SELECT LAST_INSERT_ID(); COMMIT; 插入数据这里,我们没有使用 insert into 而是使用 replace into 来插入数据,具体步骤是这样的:\n1)第一步: 尝试把数据插入到表中。\n2)第二步: 如果主键或唯一索引字段出现重复数据错误而插入失败时,先从表中删除含有重复关键字值的冲突行,然后再次尝试把数据插入到表中。\n使用replace只是用来删除行,没有什么特殊含义\n这种方式的优缺点也比较明显:\n优点 :实现起来比较简单、ID 有序递增、存储消耗空间小 缺点 : 支持的并发量不大、存在数据库单点问题(可以使用数据库集群解决,不过增加了复杂度)、ID 没有具体业务含义、安全问题(比如根据订单 ID 的递增规律就能推算出每天的订单量,商业机密啊! )、每次获取 ID 都要访问一次数据库(增加了对数据库的压力,获取速度也慢) 数据库号段模式 # 数据库主键自增这种模式,每次获取 ID 都要访问一次数据库,ID 需求比较大的时候,肯定是不行的。\n如果我们可以批量获取,然后存在在内存里面,需要用到的时候,直接从内存里面拿就舒服了!这也就是我们说的 基于数据库的号段模式来生成分布式 ID。\n数据库的号段模式也是目前比较主流的一种分布式 ID 生成方式。像滴滴开源的 Tinyid 就是基于这种方式来做的。不过,TinyId 使用了双号段缓存、增加多 db 支持等方式来进一步优化。\n以 MySQL 举例,我们通过下面的方式即可。\n1.创建一个数据库表。\nCREATE TABLE `sequence_id_generator` ( `id` int(10) NOT NULL, `current_max_id` bigint(20) NOT NULL COMMENT \u0026#39;当前最大id\u0026#39;, `step` int(10) NOT NULL COMMENT \u0026#39;号段的长度\u0026#39;, `version` int(20) NOT NULL COMMENT \u0026#39;版本号\u0026#39;, `biz_type` int(20) NOT NULL COMMENT \u0026#39;业务类型\u0026#39;, PRIMARY KEY (`id`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; current_max_id 字段和step字段主要用于获取批量 ID,获取的批量 id 为: current_max_id ~ current_max_id+step。\nversion 字段主要用于解决并发问题(乐观锁),biz_type 主要用于表示业务类型。\n2.先插入一行数据。\nINSERT INTO `sequence_id_generator` (`id`, `current_max_id`, `step`, `version`, `biz_type`) VALUES (1, 0, 100, 0, 101); 3.通过 SELECT 获取指定业务下的批量唯一 ID\nSELECT `current_max_id`, `step`,`version` FROM `sequence_id_generator` where `biz_type` = 101 结果:\nid\tcurrent_max_id\tstep\tversion\tbiz_type 1\t0\t100\t0\t101 4.不够用的话,更新之后重新 SELECT 即可。\nUPDATE sequence_id_generator SET current_max_id = 0+100, version=version+1 WHERE version = 0 AND `biz_type` = 101 SELECT `current_max_id`, `step`,`version` FROM `sequence_id_generator` where `biz_type` = 101 结果:\nid\tcurrent_max_id\tstep\tversion\tbiz_type 1\t100\t100\t1\t101 相比于数据库主键自增的方式,数据库的号段模式对于数据库的访问次数更少,数据库压力更小。\n另外,为了避免单点问题,你可以从使用主从模式来提高可用性。\n数据库号段模式的优缺点:\n优点 :ID 有序递增、存储消耗空间小 缺点 :存在数据库单点问题(可以使用数据库集群解决,不过增加了复杂度)、ID 没有具体业务含义、安全问题(比如根据订单 ID 的递增规律就能推算出每天的订单量,商业机密啊! ) NoSQL # 一般情况下,NoSQL 方案使用 Redis 多一些。我们通过 Redis 的 incr 命令即可实现对 id 原子顺序递增。\n127.0.0.1:6379\u0026gt; set sequence_id_biz_type 1 OK 127.0.0.1:6379\u0026gt; incr sequence_id_biz_type (integer) 2 127.0.0.1:6379\u0026gt; get sequence_id_biz_type \u0026#34;2\u0026#34; 为了提高可用性和并发,我们可以使用 Redis Cluster。Redis Cluster 是 Redis 官方提供的 Redis 集群解决方案(3.0+版本)。\n除了 Redis Cluster 之外,你也可以使用开源的 Redis 集群方案 Codis (大规模集群比如上百个节点的时候比较推荐)。\n除了高可用和并发之外,我们知道 Redis 基于内存,我们需要持久化数据,避免重启机器或者机器故障后数据丢失。Redis 支持两种不同的持久化方式:快照(snapshotting,RDB)、只追加文件(append-only file, AOF)。 并且,Redis 4.0 开始支持 RDB 和 AOF 的混合持久化(默认关闭,可以通过配置项 aof-use-rdb-preamble 开启)。\n关于 Redis 持久化,我这里就不过多介绍。不了解这部分内容的小伙伴,可以看看 JavaGuide 对于 Redis 知识点的总结。\nRedis 方案的优缺点:\n优点 : 性能不错并且生成的 ID 是有序递增的 缺点 : 和数据库主键自增方案的缺点类似 除了 Redis 之外,MongoDB ObjectId 经常也会被拿来当做分布式 ID 的解决方案。\nMongoDB ObjectId 一共需要 12 个字节存储:\n0~3:时间戳 3~6: 代表机器 ID 7~8:机器进程 ID 9~11 :自增值 MongoDB 方案的优缺点:\n优点 : 性能不错并且生成的 ID 是有序递增的 缺点 : 需要解决重复 ID 问题(当机器时间不对的情况下,可能导致会产生重复 ID) 、有安全性问题(ID 生成有规律性) 算法 # UUID # UUID 是 Universally Unique Identifier(通用唯一标识符) 的缩写。UUID 包含 32 个 16 进制数字(8-4-4-4-12)。\nJDK 就提供了现成的生成 UUID 的方法,一行代码就行了。\n//输出示例:cb4a9ede-fa5e-4585-b9bb-d60bce986eaa UUID.randomUUID() RFC 4122 中关于 UUID 的示例是这样的:\n我们这里重点关注一下这个 Version(版本),不同的版本对应的 UUID 的生成规则是不同的。\n5 种不同的 Version(版本)值分别对应的含义(参考 维基百科对于 UUID 的介绍):\n版本 1 : UUID 是根据时间和节点 ID(通常是 MAC 地址)生成; 版本 2 : UUID 是根据标识符(通常是组或用户 ID)、时间和节点 ID 生成; 版本 3、版本 5 : 版本 5 - 确定性 UUID 通过散列(hashing)名字空间(namespace)标识符和名称生成; 版本 4 : UUID 使用 随机性或 伪随机性生成。 下面是 Version 1 版本下生成的 UUID 的示例:\nJDK 中通过 UUID 的 randomUUID() 方法生成的 UUID 的版本默认为 4。\nUUID uuid = UUID.randomUUID(); int version = uuid.version();// 4 另外,Variant(变体)也有 4 种不同的值,这种值分别对应不同的含义。这里就不介绍了,貌似平时也不怎么需要关注。\n需要用到的时候,去看看维基百科对于 UUID 的 Variant(变体) 相关的介绍即可。\n从上面的介绍中可以看出,UUID 可以保证唯一性,因为其生成规则包括 MAC 地址、时间戳、名字空间(Namespace)、随机或伪随机数、时序等元素,计算机基于这些规则生成的 UUID 是肯定不会重复的。\n虽然,UUID 可以做到全局唯一性,但是,我们一般很少会使用它。\n比如使用 UUID 作为 MySQL 数据库主键的时候就非常不合适:\n数据库主键要尽量越短越好,而 UUID 的消耗的存储空间比较大(32 个字符串,128 位)。 UUID 是无顺序的,InnoDB 引擎下,数据库主键的无序性会严重影响数据库性能。 最后,我们再简单分析一下 UUID 的优缺点 (面试的时候可能会被问到的哦!) :\n优点 :生成速度比较快、简单易用 缺点 : 存储消耗空间大(32 个字符串,128 位) 、 不安全(基于 MAC 地址生成 UUID 的算法会造成 MAC 地址泄露)、无序(非自增)、没有具体业务含义、需要解决重复 ID 问题(当机器时间不对的情况下,可能导致会产生重复 ID) Snowflake(雪花算法) # Snowflake 是 Twitter 开源的分布式 ID 生成算法。Snowflake 由 64 bit 的二进制数字组成,这 64bit 的二进制被分成了几部分,每一部分存储的数据都有特定的含义:\n第 0 位: 符号位(标识正负),始终为 0,没有用,不用管。 第 1~41 位 :一共 41 位,用来表示时间戳,单位是毫秒,可以支撑 2 ^41 毫秒(约 69 年) 第 42~52 位 :一共 10 位,一般来说,前 5 位表示机房 ID,后 5 位表示机器 ID(实际项目中可以根据实际情况调整)。这样就可以区分不同集群/机房的节点。 第 53~64 位 :一共 12 位,用来表示序列号。 序列号为自增值,代表单台机器每毫秒能够产生的最大 ID 数(2^12 = 4096),也就是说单台机器每毫秒最多可以生成 4096 个 唯一 ID。 如果你想要使用 Snowflake 算法的话,一般不需要你自己再造轮子。有很多基于 Snowflake 算法的开源实现比如美团 的 Leaf、百度的 UidGenerator,并且这些开源实现对原有的 Snowflake 算法进行了优化。\n另外,在实际项目中,我们一般也会对 Snowflake 算法进行改造,最常见的就是在 Snowflake 算法生成的 ID 中加入业务类型信息。\n我们再来看看 Snowflake 算法的优缺点 :\n优点 :生成速度比较快、生成的 ID 有序递增、比较灵活(可以对 Snowflake 算法进行简单的改造比如加入业务 ID) 缺点 : 需要解决重复 ID 问题(依赖时间,当机器时间不对的情况下,可能导致会产生重复 ID)。 开源框架 # UidGenerator(百度) # UidGenerator 是百度开源的一款基于 Snowflake(雪花算法)的唯一 ID 生成器。\n不过,UidGenerator 对 Snowflake(雪花算法)进行了改进,生成的唯一 ID 组成如下。\n可以看出,和原始 Snowflake(雪花算法)生成的唯一 ID 的组成不太一样。并且,上面这些参数我们都可以自定义。\nUidGenerator 官方文档中的介绍如下:\n自 18 年后,UidGenerator 就基本没有再维护了,我这里也不过多介绍。想要进一步了解的朋友,可以看看 UidGenerator 的官方介绍。\nLeaf(美团) # Leaf 是美团开源的一个分布式 ID 解决方案 。这个项目的名字 Leaf(树叶) 起源于德国哲学家、数学家莱布尼茨的一句话: “There are no two identical leaves in the world”(世界上没有两片相同的树叶) 。这名字起得真心挺不错的,有点文艺青年那味了!\nLeaf 提供了 号段模式 和 Snowflake(雪花算法) 这两种模式来生成分布式 ID。并且,它支持双号段,还解决了雪花 ID 系统时钟回拨问题。不过,时钟问题的解决需要弱依赖于 Zookeeper 。\nLeaf 的诞生主要是为了解决美团各个业务线生成分布式 ID 的方法多种多样以及不可靠的问题。\nLeaf 对原有的号段模式进行改进,比如它这里增加了双号段避免获取 DB 在获取号段的时候阻塞请求获取 ID 的线程。简单来说,就是我一个号段还没用完之前,我自己就主动提前去获取下一个号段(图片来自于美团官方文章: 《Leaf——美团点评分布式 ID 生成系统》)。\n根据项目 README 介绍,在 4C8G VM 基础上,通过公司 RPC 方式调用,QPS 压测结果近 5w/s,TP999 1ms。\nTinyid(滴滴) # Tinyid 是滴滴开源的一款基于数据库号段模式的唯一 ID 生成器。\n数据库号段模式的原理我们在上面已经介绍过了。Tinyid 有哪些亮点呢?\n为了搞清楚这个问题,我们先来看看基于数据库号段模式的简单架构方案。(图片来自于 Tinyid 的官方 wiki: 《Tinyid 原理介绍》)\n在这种架构模式下,我们通过 HTTP 请求向发号器服务申请唯一 ID。负载均衡 router 会把我们的请求送往其中的一台 tinyid-server。\n这种方案有什么问题呢?在我看来(Tinyid 官方 wiki 也有介绍到),主要由下面这 2 个问题:\n获取新号段的情况下,程序获取唯一 ID 的速度比较慢。 需要保证 DB 高可用,这个是比较麻烦且耗费资源的。 除此之外,HTTP 调用也存在网络开销。\nTinyid 的原理比较简单,其架构如下图所示:\n相比于基于数据库号段模式的简单架构方案,Tinyid 方案主要做了下面这些优化:\n双号段缓存 :为了避免在获取新号段的情况下,程序获取唯一 ID 的速度比较慢。 Tinyid 中的号段在用到一定程度的时候,就会去异步加载下一个号段,保证内存中始终有可用号段。 增加多 db 支持 :支持多个 DB,并且,每个 DB 都能生成唯一 ID,提高了可用性。 增加 tinyid-client :纯本地操作,无 HTTP 请求消耗,性能和可用性都有很大提升。 Tinyid 的优缺点这里就不分析了,结合数据库号段模式的优缺点和 Tinyid 的原理就能知道。\n总结 # 通过这篇文章,我基本上已经把最常见的分布式 ID 生成方案都总结了一波。\n除了上面介绍的方式之外,像 ZooKeeper 这类中间件也可以帮助我们生成唯一 ID。没有银弹,一定要结合实际项目来选择最适合自己的方案。\n"},{"id":278,"href":"/zh/docs/technology/Review/java_guide/lydly_distributed_system/ly01ly_api-gateway/","title":"api网关","section":"分布式系统","content":" 转载自https://github.com/Snailclimb/JavaGuide(添加小部分笔记)感谢作者!\n什么是网关?有什么用? # 微服务背景下,一个系统被拆分为多个服务,但是像安全认证,流量控制,日志,监控等功能是每个服务都需要的,没有网关的话,我们就需要在每个服务中单独实现,这使得我们做了很多重复的事情并且没有一个全局的视图来统一管理这些功能。\n一般情况下,网关可以为我们提供请求转发、安全认证(身份/权限认证)、流量控制、负载均衡、降级熔断、日志、监控等功能。\n上面介绍了这么多功能,实际上,网关主要做了一件事情:请求过滤 。\n有哪些常见的网关系统? # Netflix Zuul # Zuul 是 Netflix 开发的一款提供动态路由、监控、弹性、安全的网关服务。\nZuul 主要通过过滤器(类似于 AOP)来过滤请求,从而实现网关必备的各种功能。\n我们可以自定义过滤器来处理请求,并且,Zuul 生态本身就有很多现成的过滤器供我们使用。就比如限流可以直接用国外朋友写的 spring-cloud-zuul-ratelimit (这里只是举例说明,一般是配合 hystrix 来做限流):\n\u0026lt;dependency\u0026gt; \u0026lt;groupId\u0026gt;org.springframework.cloud\u0026lt;/groupId\u0026gt; \u0026lt;artifactId\u0026gt;spring-cloud-starter-netflix-zuul\u0026lt;/artifactId\u0026gt; \u0026lt;/dependency\u0026gt; \u0026lt;dependency\u0026gt; \u0026lt;groupId\u0026gt;com.marcosbarbero.cloud\u0026lt;/groupId\u0026gt; \u0026lt;artifactId\u0026gt;spring-cloud-zuul-ratelimit\u0026lt;/artifactId\u0026gt; \u0026lt;version\u0026gt;2.2.0.RELEASE\u0026lt;/version\u0026gt; \u0026lt;/dependency\u0026gt; Zuul 1.x 基于同步 IO,性能较差。Zuul 2.x 基于 Netty 实现了异步 IO,性能得到了大幅改进。\nGithub 地址 : https://github.com/Netflix/zuul 官方 Wiki : https://github.com/Netflix/zuul/wiki Spring Cloud Gateway # SpringCloud Gateway 属于 Spring Cloud 生态系统中的网关,其诞生的目标是为了替代老牌网关 **Zuul **。准确点来说,应该是 Zuul 1.x。SpringCloud Gateway 起步要比 Zuul 2.x 更早。\n为了提升网关的性能,SpringCloud Gateway 基于 Spring WebFlux 。Spring WebFlux 使用 Reactor 库来实现响应式编程模型,底层基于 Netty 实现异步 IO。\nSpring Cloud Gateway 的目标,不仅提供统一的路由方式,并且基于 Filter 链的方式提供了网关基本的功能,例如:安全,监控/指标,和限流。\nSpring Cloud Gateway 和 Zuul 2.x 的差别不大,也是通过过滤器来处理请求。不过,目前更加推荐使用 Spring Cloud Gateway 而非 Zuul,Spring Cloud 生态对其支持更加友好。\nGithub 地址 : https://github.com/spring-cloud/spring-cloud-gateway 官网 : https://spring.io/projects/spring-cloud-gateway Kong # Kong 是一款基于 OpenResty 的高性能、云原生、可扩展的网关系统。\nOpenResty 是一个基于 Nginx 与 Lua 的高性能 Web 平台,其内部集成了大量精良的 Lua 库、第三方模块以及大多数的依赖项。用于方便地搭建能够处理超高并发、扩展性极高的动态 Web 应用、Web 服务和动态网关。\nKong 提供了插件机制来扩展其功能。比如、在服务上启用 Zipkin 插件\n$ curl -X POST http://kong:8001/services/{service}/plugins \\ --data \u0026#34;name=zipkin\u0026#34; \\ --data \u0026#34;config.http_endpoint=http://your.zipkin.collector:9411/api/v2/spans\u0026#34; \\ --data \u0026#34;config.sample_ratio=0.001\u0026#34; Github 地址: https://github.com/Kong/kong 官网地址 : https://konghq.com/kong APISIX # APISIX 是一款基于 Nginx 和 etcd 的高性能、云原生、可扩展的网关系统。\netcd是使用 Go 语言开发的一个开源的、高可用的分布式 key-value 存储系统,使用 Raft 协议做分布式共识。\n与传统 API 网关相比,APISIX 具有动态路由和插件热加载,特别适合微服务系统下的 API 管理。并且,APISIX 与 SkyWalking(分布式链路追踪系统)、Zipkin(分布式链路追踪系统)、Prometheus(监控系统) 等 DevOps 生态工具对接都十分方便。\n作为 NGINX 和 Kong 的替代项目,APISIX 目前已经是 Apache 顶级开源项目,并且是最快毕业的国产开源项目。国内目前已经有很多知名企业(比如金山、有赞、爱奇艺、腾讯、贝壳)使用 APISIX 处理核心的业务流量。\n根据官网介绍:“APISIX 已经生产可用,功能、性能、架构全面优于 Kong”。\nGithub 地址 :https://github.com/apache/apisix 官网地址: https://apisix.apache.org/zh/ 相关阅读:\n有了 NGINX 和 Kong,为什么还需要 Apache APISIX APISIX 技术博客 APISIX 用户案例 Shenyu # Shenyu 是一款基于 WebFlux 的可扩展、高性能、响应式网关,Apache 顶级开源项目。\nShenyu 通过插件扩展功能,插件是 ShenYu 的灵魂,并且插件也是可扩展和热插拔的。不同的插件实现不同的功能。Shenyu 自带了诸如限流、熔断、转发 、重写、重定向、和路由监控等插件。\nGithub 地址: https://github.com/apache/incubator-shenyu 官网地址 : https://shenyu.apache.org/ "},{"id":279,"href":"/zh/docs/technology/Review/java_guide/lydly_distributed_system/base/raft-algorithm/","title":"raft算法","section":"基础","content":" 转载自https://github.com/Snailclimb/JavaGuide(添加小部分笔记)感谢作者!\n1 背景 # 当今的数据中心和应用程序在高度动态的环境中运行,为了应对高度动态的环境,它们通过额外的服务器进行横向扩展,并且根据需求进行扩展和收缩。同时,服务器和网络故障也很常见。\n因此,系统必须在正常操作期间处理服务器的上下线。它们必须对变故做出反应并在几秒钟内自动适应;对客户来说的话,明显的中断通常是不可接受的。\n幸运的是,分布式共识可以帮助应对这些挑战。\n1.1 拜占庭将军 # 在介绍共识算法之前,先介绍一个简化版拜占庭将军的例子来帮助理解共识算法。\n假设多位拜占庭将军中没有叛军,信使的信息可靠但有可能被暗杀的情况下,将军们如何达成是否要进攻的一致性决定?\n解决方案大致可以理解成:先在所有的将军中选出一个大将军,用来做出所有的决定。\n举例如下:假如现在一共有 3 个将军 A,B 和 C,每个将军都有一个随机时间的倒计时器,倒计时一结束,这个将军就把自己当成大将军候选人,然后派信使传递选举投票的信息给将军 B 和 C,如果将军 B 和 C 还没有把自己当作候选人(自己的倒计时还没有结束),并且没有把选举票投给其他人,它们就会把票投给将军 A,信使回到将军 A 时,将军 A 知道自己收到了足够的票数,成为大将军。在有了大将军之后,是否需要进攻就由大将军 A 决定,然后再去派信使通知另外两个将军,自己已经成为了大将军。如果一段时间还没收到将军 B 和 C 的回复(信使可能会被暗杀),那就再重派一个信使,直到收到回复。\n1.2 共识算法 # 共识是可容错系统中的一个基本问题:即使面对故障,服务器也可以在共享状态上达成一致。\n共识算法允许一组节点像一个整体一样一起工作,即使其中的一些节点出现故障也能够继续工作下去,其正确性主要是源于复制状态机的性质:一组Server的状态机计算相同状态的副本,即使有一部分的Server宕机了它们仍然能够继续运行。\n图-1 复制状态机架构 一般通过使用复制日志来实现复制状态机。每个Server存储着一份包括命令序列的日志文件,状态机会按顺序执行这些命令。因为每个日志包含相同的命令,并且顺序也相同,所以每个状态机处理相同的命令序列。由于状态机是确定性的,所以处理相同的状态,得到相同的输出。\n因此共识算法的工作就是保持复制日志的一致性。服务器上的共识模块从客户端接收命令并将它们添加到日志中。它与其他服务器上的共识模块通信,以确保即使某些服务器发生故障。每个日志最终包含相同顺序的请求。一旦命令被正确地复制,它们就被称为已提交。每个服务器的状态机按照日志顺序处理已提交的命令,并将输出返回给客户端,因此,这些服务器形成了一个单一的、高度可靠的状态机。\n适用于实际系统的共识算法通常具有以下特性:\n安全。确保在非拜占庭条件(也就是上文中提到的简易版拜占庭)下的安全性,包括网络延迟、分区、包丢失、复制和重新排序。 高可用。只要大多数服务器都是可操作的,并且可以相互通信,也可以与客户端进行通信,那么这些服务器就可以看作完全功能可用的。因此,一个典型的由五台服务器组成的集群可以容忍任何两台服务器端故障。假设服务器因停止而发生故障;它们稍后可能会从稳定存储上的状态中恢复并重新加入集群。 一致性不依赖时序。错误的时钟和极端的消息延迟,在最坏的情况下也只会造成可用性问题,而不会产生一致性问题。 在集群中大多数服务器响应,命令就可以完成,不会被少数运行缓慢的服务器来影响整体系统性能。 2 基础 # 2.1 节点类型 # 一个 Raft 集群包括若干服务器,以典型的 5 服务器集群举例。在任意的时间,每个服务器一定会处于以下三个状态中的一个:\nLeader:负责发起心跳,响应客户端,创建日志,同步日志。 Candidate:Leader 选举过程中的临时角色,由 Follower 转化而来,发起投票参与竞选。 Follower:接受 Leader 的心跳和日志同步数据,投票给 Candidate。 在正常的情况下,只有一个服务器是 Leader,剩下的服务器是 Follower。Follower 是被动的,它们不会发送任何请求,只是响应来自 Leader 和 Candidate 的请求。\n图-2:服务器的状态 2.2 任期 # 图-3:任期 如图 3 所示,raft 算法将时间划分为任意长度的任期(term),任期用连续的数字表示,看作当前 term 号。每一个任期的开始都是一次选举,在选举开始时,一个或多个 Candidate 会尝试成为 Leader。如果一个 Candidate 赢得了选举,它就会在该任期内担任 Leader。如果没有选出 Leader,将会开启另一个任期,并立刻开始下一次选举。raft 算法保证在给定的一个任期最少要有一个 Leader。\n每个节点都会存储当前的 term 号,当服务器之间进行通信时会交换当前的 term 号;如果有服务器发现自己的 term 号比其他人小,那么他会更新到较大的 term 值。如果一个 Candidate 或者 Leader 发现自己的 term 过期了,他会立即退回成 Follower。如果一台服务器收到的请求的 term 号是过期的,那么它会拒绝此次请求。\n2.3 日志 # entry:每一个事件成为 entry,只有 Leader 可以创建 entry。entry 的内容为\u0026lt;term,index,cmd\u0026gt;其中 cmd 是可以应用到状态机的操作。 log:由 entry 构成的数组,每一个 entry 都有一个表明自己在 log 中的 index。只有 Leader 才可以改变其他节点的 log。entry 总是先被 Leader 添加到自己的 log 数组中,然后再发起共识请求,获得同意后才会被 Leader 提交给状态机。Follower 只能从 Leader 获取新日志和当前的 commitIndex,然后把对应的 entry 应用到自己的状态机中。 3 领导人选举 # raft 使用心跳机制来触发 Leader 的选举。\n如果一台服务器能够收到来自 Leader 或者 Candidate 的有效信息,那么它会一直保持为 Follower 状态,并且刷新自己的 electionElapsed,重新计时。\nLeader 会向所有的 Follower 周期性发送心跳来保证自己的 Leader 地位。如果一个 Follower 在一个周期内没有收到心跳信息,就叫做选举超时,然后它就会认为此时没有可用的 Leader,并且开始进行一次选举以选出一个新的 Leader。\n为了开始新的选举,Follower 会自增自己的 term 号并且转换状态为 Candidate。然后他会向所有节点发起 RequestVoteRPC 请求, Candidate 的状态会持续到以下情况发生:\n赢得选举 其他节点赢得选举 一轮选举结束,无人胜出 赢得选举的条件是:一个 Candidate 在一个任期内收到了来自集群内的多数选票(N/2+1),就可以成为 Leader。\n在 Candidate 等待选票的时候,它可能收到其他节点声明自己是 Leader 的心跳,此时有两种情况:\n该 Leader 的 term 号大于等于自己的 term 号,说明对方已经成为 Leader,则自己回退为 Follower。 该 Leader 的 term 号小于自己的 term 号,那么会拒绝该请求并让该节点更新 term。 由于可能同一时刻出现多个 Candidate,导致没有 Candidate 获得大多数选票,如果没有其他手段来重新分配选票的话,那么可能会无限重复下去。\nraft 使用了随机的选举超时时间来避免上述情况。每一个 Candidate 在发起选举后,都会随机化一个新的枚举超时时间,这种机制使得各个服务器能够分散开来,在大多数情况下只有一个服务器会率先超时;它会在其他服务器超时之前赢得选举。\n4 日志复制 # 一旦选出了 Leader,它就开始接受客户端的请求。每一个客户端的请求都包含一条需要被复制状态机(Replicated State Mechine)执行的命令。\nLeader 收到客户端请求后,会生成一个 entry,包含\u0026lt;index,term,cmd\u0026gt;,再将这个 entry 添加到自己的日志末尾后,向所有的节点广播该 entry,要求其他服务器复制这条 entry。\n如果 Follower 接受该 entry,则会将 entry 添加到自己的日志后面,同时返回给 Leader 同意。\n如果 Leader 收到了多数的成功响应,Leader 会将这个 entry 应用到自己的状态机中,之后可以成为这个 entry 是 committed 的,并且向客户端返回执行结果。\nraft 保证以下两个性质:\n在两个日志里,有两个 entry 拥有相同的 index 和 term,那么它们一定有相同的 cmd 在两个日志里,有两个 entry 拥有相同的 index 和 term,那么它们前面的 entry 也一定相同 通过“仅有 Leader 可以生存 entry”来保证第一个性质,第二个性质需要一致性检查来进行保证。\n一般情况下,Leader 和 Follower 的日志保持一致,然后,Leader 的崩溃会导致日志不一样,这样一致性检查会产生失败。Leader 通过强制 Follower 复制自己的日志来处理日志的不一致。这就意味着,在 Follower 上的冲突日志会被领导者的日志覆盖。\n为了使得 Follower 的日志和自己的日志一致,Leader 需要找到 Follower 与它日志一致的地方,然后删除 Follower 在该位置之后的日志,接着把这之后的日志发送给 Follower。\nLeader 给每一个Follower 维护了一个 nextIndex,它表示 Leader 将要发送给该追随者的下一条日志条目的索引。当一个 Leader 开始掌权时,它会将 nextIndex 初始化为它的最新的日志条目索引数+1。如果一个 Follower 的日志和 Leader 的不一致,AppendEntries 一致性检查会在下一次 AppendEntries RPC 时返回失败。在失败之后,Leader 会将 nextIndex 递减然后重试 AppendEntries RPC。最终 nextIndex 会达到一个 Leader 和 Follower 日志一致的地方。这时,AppendEntries 会返回成功,Follower 中冲突的日志条目都被移除了,并且添加所缺少的上了 Leader 的日志条目。一旦 AppendEntries 返回成功,Follower 和 Leader 的日志就一致了,这样的状态会保持到该任期结束。\n5 安全性 # 5.1 选举限制 # Leader 需要保证自己存储全部已经提交的日志条目。这样才可以使日志条目只有一个流向:从 Leader 流向 Follower,Leader 永远不会覆盖已经存在的日志条目。\n每个 Candidate 发送 RequestVoteRPC 时,都会带上最后一个 entry 的信息。所有节点收到投票信息时,会对该 entry 进行比较,如果发现自己的更新,则拒绝投票给该 Candidate。\n判断日志新旧的方式:如果两个日志的 term 不同,term 大的更新;如果 term 相同,更长的 index 更新。\n5.2 节点崩溃 # 如果 Leader 崩溃,集群中的节点在 electionTimeout 时间内没有收到 Leader 的心跳信息就会触发新一轮的选主,在选主期间整个集群对外是不可用的。\n如果 Follower 和 Candidate 崩溃,处理方式会简单很多。之后发送给它的 RequestVoteRPC 和 AppendEntriesRPC 会失败。由于 raft 的所有请求都是幂等的,所以失败的话会无限的重试。如果崩溃恢复后,就可以收到新的请求,然后选择追加或者拒绝 entry。\n5.3 时间与可用性 # raft 的要求之一就是安全性不依赖于时间:系统不能仅仅因为一些事件发生的比预想的快一些或者慢一些就产生错误。为了保证上述要求,最好能满足以下的时间条件:\nbroadcastTime \u0026lt;\u0026lt; electionTimeout \u0026lt;\u0026lt; MTBF broadcastTime:向其他节点并发发送消息的平均响应时间; electionTimeout:选举超时时间; MTBF(mean time between failures):单台机器的平均健康时间; broadcastTime应该比electionTimeout小一个数量级,为的是使Leader能够持续发送心跳信息(heartbeat)来阻止Follower开始选举;\nelectionTimeout也要比MTBF小几个数量级,为的是使得系统稳定运行。当Leader崩溃时,大约会在整个electionTimeout的时间内不可用;我们希望这种情况仅占全部时间的很小一部分。\n由于broadcastTime和MTBF是由系统决定的属性,因此需要决定electionTimeout的时间。\n一般来说,broadcastTime 一般为 0.5~20ms,electionTimeout 可以设置为 10~500ms,MTBF 一般为一两个月。\n6 参考 # https://tanxinyu.work/raft/ https://github.com/OneSizeFitsQuorum/raft-thesis-zh_cn/blob/master/raft-thesis-zh_cn.md https://github.com/ongardie/dissertation/blob/master/stanford.pdf https://knowledge-sharing.gitbooks.io/raft/content/chapter5.html "},{"id":280,"href":"/zh/docs/technology/Review/java_guide/lydly_distributed_system/base/paxos-algorithm/","title":"paxos算法","section":"基础","content":" 转载自https://github.com/Snailclimb/JavaGuide(添加小部分笔记)感谢作者!\n背景 # Paxos 算法是 Leslie Lamport( 莱斯利·兰伯特)在 1990 年提出了一种分布式系统 共识 算法。这也是第一个被证明完备的共识算法(前提是不存在拜占庭将军问题,也就是没有恶意节点)。\n为了介绍 Paxos 算法,兰伯特专门写了一篇幽默风趣的论文。在这篇论文中,他虚拟了一个叫做 Paxos 的希腊城邦来更形象化地介绍 Paxos 算法。\n不过,审稿人并不认可这篇论文的幽默。于是,他们就给兰伯特说:“如果你想要成功发表这篇论文的话,必须删除所有 Paxos 相关的故事背景”。兰伯特一听就不开心了:“我凭什么修改啊,你们这些审稿人就是缺乏幽默细胞,发不了就不发了呗!”。\n于是乎,提出 Paxos 算法的那篇论文在当时并没有被成功发表。\n直到 1998 年,系统研究中心 (Systems Research Center,SRC)的两个技术研究员需要找一些合适的分布式算法来服务他们正在构建的分布式系统,Paxos 算法刚好可以解决他们的部分需求。因此,兰伯特就把论文发给了他们。在看了论文之后,这俩大佬觉得论文还是挺不错的。于是,兰伯特在 1998 年重新发表论文 《The Part-Time Parliament》。\n论文发表之后,各路学者直呼看不懂,言语中还略显调侃之意。这谁忍得了,在 2001 年的时候,兰伯特专门又写了一篇 《Paxos Made Simple》 的论文来简化对 Paxos 的介绍,主要讲述两阶段共识协议部分,顺便还不忘嘲讽一下这群学者。\n《Paxos Made Simple》这篇论文就 14 页,相比于 《The Part-Time Parliament》的 33 页精简了不少。最关键的是这篇论文的摘要就一句话:\nThe Paxos algorithm, when presented in plain English, is very simple.\n翻译过来的意思大概就是:当我用无修饰的英文来描述时,Paxos 算法真心简单!\n有没有感觉到来自兰伯特大佬满满地嘲讽的味道?\n介绍 # Paxos 算法是第一个被证明完备的分布式系统共识算法。共识算法的作用是让分布式系统中的多个节点之间对某个提案(Proposal)达成一致的看法。提案的含义在分布式系统中十分宽泛,像哪一个节点是 Leader 节点、多个事件发生的顺序等等都可以是一个提案。\n兰伯特当时提出的 Paxos 算法主要包含 2 个部分:\nBasic Paxos 算法 : 描述的是多节点之间如何就某个值(提案 Value)达成共识。 Multi-Paxos 思想 : 描述的是执行多个 Basic Paxos 实例,就一系列值达成共识。Multi-Paxos 说白了就是执行多次 Basic Paxos ,核心还是 Basic Paxos 。 由于 Paxos 算法在国际上被公认的非常难以理解和实现,因此不断有人尝试简化这一算法。到了 2013 年才诞生了一个比 Paxos 算法更易理解和实现的共识算法— Raft 算法 。更具体点来说,Raft 是 Multi-Paxos 的一个变种,其简化了 Multi-Paxos 的思想,变得更容易被理解以及工程实现。\n针对没有恶意节点的情况,除了 Raft 算法之外,当前最常用的一些共识算法比如 ZAB 协议 、 Fast Paxos 算法都是基于 Paxos 算法改进的。\n针对存在恶意节点的情况,一般使用的是 工作量证明(POW,Proof-of-Work) 、 权益证明(PoS,Proof-of-Stake ) 等共识算法。这类共识算法最典型的应用就是区块链,就比如说前段时间以太坊官方宣布其共识机制正在从工作量证明(PoW)转变为权益证明(PoS)。\n区块链系统使用的共识算法需要解决的核心问题是 拜占庭将军问题 ,这和我们日常接触到的 ZooKeeper、Etcd、Consul 等分布式中间件不太一样。\n下面我们来对 Paxos 算法的定义做一个总结:\nPaxos 算法是兰伯特在 1990 年提出了一种分布式系统共识算法。 兰伯特当时提出的 Paxos 算法主要包含 2 个部分: Basic Paxos 算法和 Multi-Paxos 思想。 Raft 算法、ZAB 协议、 Fast Paxos 算法都是基于 Paxos 算法改进而来。 Basic Paxos 算法 # Basic Paxos 中存在 3 个重要的角色:\n提议者(Proposer):也可以叫做协调者(coordinator),提议者负责接受客户端的请求并发起提案。提案信息通常包括提案编号 (Proposal ID) 和提议的值 (Value)。 接受者(Acceptor):也可以叫做投票员(voter),负责对提议者的提案进行投票,同时需要记住自己的投票历史; 学习者(Learner):如果有超过半数接受者就某个提议达成了共识,那么学习者就需要接受这个提议,并就该提议作出运算,然后将运算结果返回给客户端。 为了减少实现该算法所需的节点数,一个节点可以身兼多个角色。并且,一个提案被选定需要被半数以上的 Acceptor 接受。这样的话,Basic Paxos 算法还具备容错性,在少于一半的节点出现故障时,集群仍能正常工作。\nMulti Paxos 思想 # Basic Paxos 算法的仅能就单个值达成共识,为了能够对一系列的值达成共识,我们需要用到 Basic Paxos 思想。\n⚠️注意 : Multi-Paxos 只是一种思想,这种思想的核心就是通过多个 Basic Paxos 实例就一系列值达成共识。也就是说,Basic Paxos 是 Multi-Paxos 思想的核心,Multi-Paxos 就是多执行几次 Basic Paxos。\n由于兰伯特提到的 Multi-Paxos 思想缺少代码实现的必要细节(比如怎么选举领导者),所以在理解和实现上比较困难。\n不过,也不需要担心,我们并不需要自己实现基于 Multi-Paxos 思想的共识算法,业界已经有了比较出名的实现。像 Raft 算法就是 Multi-Paxos 的一个变种,其简化了 Multi-Paxos 的思想,变得更容易被理解以及工程实现,实际项目中可以优先考虑 Raft 算法。\n参考 # https://zh.wikipedia.org/wiki/Paxos 分布式系统中的一致性与共识算法:http://www.xuyasong.com/?p=1970 "},{"id":281,"href":"/zh/docs/technology/Review/java_guide/lydly_distributed_system/base/cap_base-theorem/","title":"CAP\u0026BASE 理论","section":"基础","content":" 转载自https://github.com/Snailclimb/JavaGuide(添加小部分笔记)感谢作者!\n经历过技术面试的小伙伴想必对 CAP \u0026amp; BASE 这个两个理论已经再熟悉不过了!\n我当年参加面试的时候,不夸张地说,只要问到分布式相关的内容,面试官几乎是必定会问这两个分布式相关的理论。一是因为这两个分布式基础理论是学习分布式知识的必备前置基础,二是因为很多面试官自己比较熟悉这两个理论(方便提问)。\n我们非常有必要将这两个理论搞懂,并且能够用自己的理解给别人讲出来。\nCAP 理论 # CAP 理论/定理起源于 2000 年,由加州大学伯克利分校的 Eric Brewer 教授在分布式计算原理研讨会(PODC)上提出,因此 CAP 定理又被称作 布鲁尔定理(Brewer’s theorem)\n2 年后,麻省理工学院的 Seth Gilbert 和 Nancy Lynch 发表了布鲁尔猜想的证明,CAP 理论正式成为分布式领域的定理。\n简介 # [kənˈsɪstənsi] consistency 一致性\n[əˌveɪlə'bɪləti] availability 可用性 ,\n[pɑːˈtɪʃn] 分割 [ˈtɒlərəns] 容忍, CAP 也就是 Consistency(一致性)、Availability(可用性)、Partition Tolerance(分区容错性) 这三个单词首字母组合。\nCAP 理论的提出者布鲁尔在提出 CAP 猜想的时候,并没有详细定义 Consistency、Availability、Partition Tolerance 三个单词的明确定义。\n因此,对于 CAP 的民间解读有很多,一般比较被大家推荐的是下面 👇 这种版本的解读。\n在理论计算机科学中,CAP 定理(CAP theorem)指出对于一个分布式系统来说,当设计读写操作时,只能同时满足以下三点中的两个:\n一致性(Consistency) : 所有节点访问同一份最新的数据副本 可用性(Availability): 非故障的节点在合理的时间内返回合理的响应(不是错误或者超时的响应)。 分区容错性(Partition Tolerance) : 分布式系统出现网络分区的时候,仍然能够对外提供服务。 什么是网络分区?\n分布式系统中,多个节点之前的网络本来是连通的,但是因为某些故障(比如部分节点网络出了问题)某些节点之间不连通了,整个网络就分成了几块区域,这就叫 网络分区。\n不是所谓的“3 选 2” # 大部分人解释这一定律时,常常简单的表述为:“一致性、可用性、分区容忍性三者你只能同时达到其中两个,不可能同时达到”。实际上这是一个非常具有误导性质的说法,而且在 CAP 理论诞生 12 年之后,CAP 之父也在 2012 年重写了之前的论文。\n当发生网络分区的时候,如果我们要继续服务(也就是P),那么强一致性和可用性只能 2 选 1。也就是说当网络分区之后 P 是前提,决定了 P 之后才有 C 和 A 的选择。也就是说分区容错性(Partition tolerance)我们是必须要实现的。\n简而言之就是:CAP 理论中分区容错性 P 是一定要满足的,在此基础上,只能满足可用性 A 或者一致性 C。\n因此,分布式系统理论上不可能选择 CA 架构,只能选择 CP 或者 AP 架构。 比如 ZooKeeper、HBase 就是 CP 架构,Cassandra、Eureka 就是 AP 架构,Nacos 不仅支持 CP 架构也支持 AP 架构。\nP,分区容错性,就是一定要保证能提供服务\n为啥不可能选择 CA 架构呢? 举个例子:若系统出现“分区”,系统中的某个节点在进行写操作。为了保证 C, 必须要禁止其他节点的读写操作,这就和 A 发生冲突了。如果为了保证 A,其他节点的读写操作正常的话,那就和 C 发生冲突了。\n选择 CP 还是 AP 的关键在于当前的业务场景,没有定论,比如对于需要确保强一致性的场景如银行一般会选择保证 CP 。\n另外,需要补充说明的一点是: 如果网络分区正常的话(系统在绝大部分时候所处的状态),也就说不需要保证 P 的时候,C 和 A 能够同时保证。\nCAP 实际应用案例 # 我这里以注册中心来探讨一下 CAP 的实际应用。考虑到很多小伙伴不知道注册中心是干嘛的,这里简单以 Dubbo 为例说一说。\n下图是 Dubbo 的架构图。注册中心 Registry 在其中扮演了什么角色呢?提供了什么服务呢?\n注册中心负责服务地址的注册与查找,相当于目录服务,服务提供者和消费者只在启动时与注册中心交互,注册中心不转发请求,压力较小。\n]( https://camo.gith\n常见的可以作为注册中心的组件有:ZooKeeper、Eureka、Nacos\u0026hellip;。\nZooKeeper 保证的是 CP。 任何时刻对 ZooKeeper 的读请求都能得到一致性的结果,但是, ZooKeeper 不保证每次请求的可用性比如在 Leader 选举过程中或者半数以上的机器不可用的时候服务就是不可用的。 Eureka 保证的则是 AP。 Eureka 在设计的时候就是优先保证 A (可用性)。在 Eureka 中不存在什么 Leader 节点,每个节点都是一样的、平等的。因此 Eureka 不会像 ZooKeeper 那样出现选举过程中或者半数以上的机器不可用的时候服务就是不可用的情况。 Eureka 保证即使大部分节点挂掉也不会影响正常提供服务,只要有一个节点是可用的就行了。只不过这个节点上的数据可能并不是最新的。 Nacos 不仅支持 CP 也支持 AP。 总结 # 在进行分布式系统设计和开发时,我们不应该仅仅局限在 CAP 问题上,还要关注系统的扩展性、可用性等等\n在系统发生“分区”的情况下,CAP 理论只能满足 CP 或者 AP。要注意的是,这里的前提是系统发生了“分区”\n如果系统没有发生“分区”的话,节点间的网络连接通信正常的话,也就不存在 P 了。这个时候,我们就可以同时保证 C 和 A 了。\n总结:如果系统发生“分区”,我们要考虑选择 CP 还是 AP。如果系统没有发生“分区”的话,我们要思考如何保证 CA 。\n推荐阅读 # CAP 定理简化 (英文,有趣的案例) 神一样的 CAP 理论被应用在何方 (中文,列举了很多实际的例子) 请停止呼叫数据库 CP 或 AP (英文,带给你不一样的思考) BASE 理论 # BASE 理论起源于 2008 年, 由 eBay 的架构师 Dan Pritchett 在 ACM 上发表。\n简介 # BASE 是 Basically Available(基本可用) 、Soft-state(软状态) 和 Eventually Consistent(最终一致性) 三个短语的缩写。BASE 理论是对 CAP 中一致性 C 和可用性 A 权衡的结果,其来源于对大规模互联网系统分布式实践的总结,是基于 CAP 定理逐步演化而来的,它大大降低了我们对系统的要求。\nBASE 理论的核心思想 # 即使无法做到强一致性,但每个应用都可以根据自身业务特点,采用适当的方式来使系统达到最终一致性。\n也就是牺牲数据的一致性来满足系统的高可用性,系统中一部分数据不可用或者不一致时,仍需要保持系统整体“主要可用”。\nBASE 理论本质上是对 CAP 的延伸和补充,更具体地说,是对 CAP 中 AP 方案的一个补充。\n为什么这样说呢?\nCAP 理论这节我们也说过了:\n如果系统没有发生“分区”的话,节点间的网络连接通信正常的话,也就不存在 P 了。这个时候,我们就可以同时保证 C 和 A 了。因此,如果系统发生“分区”,我们要考虑选择 CP 还是 AP。如果系统没有发生“分区”的话,我们要思考如何保证 CA 。\n因此,AP 方案只是在系统发生分区的时候放弃一致性,而不是永远放弃一致性。在分区故障恢复后,系统应该达到最终一致性。这一点其实就是 BASE 理论延伸的地方。\nBASE 理论三要素 # 基本可用 # 基本可用是指分布式系统在出现不可预知故障的时候,允许损失部分可用性。但是,这绝不等价于系统不可用。\n什么叫允许损失部分可用性呢?\n响应时间上的损失: 正常情况下,处理用户请求需要 0.5s 返回结果,但是由于系统出现故障,处理用户请求的时间变为 3 s。 系统功能上的损失:正常情况下,用户可以使用系统的全部功能,但是由于系统访问量突然剧增,系统的部分非核心功能无法使用。 软状态 # 软状态指允许系统中的数据存在中间状态(CAP 理论中的数据不一致),并认为该中间状态的存在不会影响系统的整体可用性,即允许系统在不同节点的数据副本之间进行数据同步的过程存在延时。\n最终一致性 # 最终一致性强调的是系统中所有的数据副本,在经过一段时间的同步后,最终能够达到一个一致的状态。因此,最终一致性的本质是需要系统保证最终数据能够达到一致,而不需要实时保证系统数据的强一致性。\n分布式一致性的 3 种级别:\n强一致性 :系统写入了什么,读出来的就是什么。 弱一致性 :不一定可以读取到最新写入的值,也不保证多少时间之后读取到的数据是最新的,只是会尽量保证某个时刻达到数据一致的状态。 最终一致性 :弱一致性的升级版,系统会保证在一定时间内达到数据一致的状态。 业界比较推崇是最终一致性级别,但是某些对数据一致要求十分严格的场景比如银行转账还是要保证强一致性。\n那实现最终一致性的具体方式是什么呢? 《分布式协议与算法实战》 中是这样介绍:\n读时修复 : 在读取数据时,检测数据的不一致,进行修复。比如 Cassandra 的 Read Repair 实现,具体来说,在向 Cassandra 系统查询数据的时候,如果检测到不同节点的副本数据不一致,系统就自动修复数据。 写时修复 : 在写入数据,检测数据的不一致时,进行修复。比如 Cassandra 的 Hinted Handoff 实现。具体来说,Cassandra 集群的节点之间远程写数据的时候,如果写失败 就将数据缓存下来,然后定时重传,修复数据的不一致性。 异步修复 : 这个是最常用的方式,通过定时对账检测副本数据的一致性,并修复。 比较推荐 写时修复,这种方式对性能消耗比较低。\n总结 # ACID 是数据库事务完整性的理论,CAP 是分布式系统设计理论,BASE 是 CAP 理论中 AP 方案的延伸。\n"},{"id":282,"href":"/zh/docs/technology/Review/java_guide/lybly_framework/MyBatis/principle/mybatis-principle3/","title":"Mybatis原理系列(3)","section":"原理","content":" 转载自https://www.jianshu.com/p/4e268828db48(添加小部分笔记)感谢作者!\n还没看完\n在上篇文章中,我们讲解了MyBatis的启动流程,以及启动过程中涉及到的组件,在本篇文中,我们继续探索SqlSession,SqlSessionFactory,SqlSessionFactoryBuilder的关系。SqlSession作为MyBatis的核心组件,可以说MyBatis的所有操作都是围绕SqlSession来展开的。对SqlSession理解透彻,才能全面掌握MyBatis。\n1. SqlSession初识 # SqlSession在一开始就介绍过是高级接口,类似于JDBC操作的connection对象,它包装了数据库连接,通过这个接口我们可以实现增删改查,提交/回滚事物,关闭连接,获取代理类等操作。SqlSession是个接口,其默认实现是DefaultSqlSession。SqlSession是线程不安全的,每个线程都会有自己唯一的SqlSession,不同线程间调用同一个SqlSession会出现问题,因此在使用完后需要close掉。\nSqlSession的方法\n2. SqlSession的创建 # SqlSessionFactoryBuilder的build()方法使用建造者模式创建了SqlSessionFactory接口对象,SqlSessionFactory接口的默认实现是DefaultSqlSessionFactory。SqlSessionFactory使用实例工厂模式来创建SqlSession对象。SqlSession,SqlSessionFactory,SqlSessionFactoryBuilder的关系如下(图画得有点丑\u0026hellip;):\n类图\nDefaultSqlSessionFactory中openSession是有两种方法一种是openSessionFromDataSource,另一种是openSessionFromConnection。这两种是什么区别呢?从字面意义上将,一种是从数据源中获取SqlSession对象,一种是由已有连接获取SqlSession。SqlSession实际是对数据库连接的一层包装,数据库连接是个珍贵的资源,如果频繁的创建销毁将会影响吞吐量,因此使用数据库连接池化技术就可以复用数据库连接了。因此openSessionFromDataSource会从数据库连接池中获取一个连接,然后包装成一个SqlSession对像。openSessionFromConnection则是直接包装已有的连接并返回SqlSession对像。\nopenSessionFromDataSource 主要经历了以下几步:\n从获取configuration中获取Environment对象,Environment包含了数据库配置 从Environment获取DataSource数据源 从DataSource数据源中获取Connection连接对象 从DataSource数据源中获取TransactionFactory事物工厂 从TransactionFactory中创建事物Transaction对象 创建Executor对象 包装configuration和Executor对象成DefaultSqlSession对象 private SqlSession openSessionFromDataSource(ExecutorType execType, TransactionIsolationLevel level, boolean autoCommit) { Transaction tx = null; try { final Environment environment = configuration.getEnvironment(); final TransactionFactory transactionFactory = getTransactionFactoryFromEnvironment(environment); tx = transactionFactory.newTransaction(environment.getDataSource(), level, autoCommit); final Executor executor = configuration.newExecutor(tx, execType); return new DefaultSqlSession(configuration, executor, autoCommit); } catch (Exception e) { closeTransaction(tx); // may have fetched a connection so lets call close() throw ExceptionFactory.wrapException(\u0026#34;Error opening session. Cause: \u0026#34; + e, e); } finally { ErrorContext.instance().reset(); } } private SqlSession openSessionFromConnection(ExecutorType execType, Connection connection) { try { boolean autoCommit; try { autoCommit = connection.getAutoCommit(); } catch (SQLException e) { // Failover to true, as most poor drivers // or databases won\u0026#39;t support transactions autoCommit = true; } final Environment environment = configuration.getEnvironment(); final TransactionFactory transactionFactory = getTransactionFactoryFromEnvironment(environment); final Transaction tx = transactionFactory.newTransaction(connection); final Executor executor = configuration.newExecutor(tx, execType); return new DefaultSqlSession(configuration, executor, autoCommit); } catch (Exception e) { throw ExceptionFactory.wrapException(\u0026#34;Error opening session. Cause: \u0026#34; + e, e); } finally { ErrorContext.instance().reset(); } } 3. SqlSession的使用 # SqlSession 获取成功后,我们就可以使用其中的方法了,比如直接使用SqlSession发送sql语句,或者通过mapper映射文件的方式来使用,在上两篇文章中我们都是通过mapper映射文件来使用的,接下来就介绍第一种,直接使用SqlSession发送sql语句。\npublic static void main(String[] args){ try { // 1. 读取配置 InputStream inputStream = Resources.getResourceAsStream(\u0026#34;mybatis-config.xml\u0026#34;); // 2. 获取SqlSessionFactory对象 SqlSessionFactory sqlSessionFactory = new SqlSessionFactoryBuilder().build(inputStream); // 3. 获取SqlSession对象 SqlSession sqlSession = sqlSessionFactory.openSession(); // 4. 执行sql TTestUser user = sqlSession.selectOne(\u0026#34;com.example.demo.dao.TTestUserMapper.selectByPrimaryKey\u0026#34;, 13L); log.info(\u0026#34;user = [{}]\u0026#34;, JSONUtil.toJsonStr(user)); // 5. 关闭连接 sqlSession.close(); inputStream.close(); } catch (Exception e){ log.error(\u0026#34;errMsg = [{}]\u0026#34;, e.getMessage(), e); } } 其中com.example.demo.dao.TTestUserMapper.selectByPrimaryKey指定了TTestUserMapper中selectByPrimaryKey这个方法,在对应的mapper/TTestUserMapper.xml我们定义了id一致的sql语句\n\u0026lt;select id=\u0026#34;selectByPrimaryKey\u0026#34; parameterType=\u0026#34;java.lang.Long\u0026#34; resultMap=\u0026#34;BaseResultMap\u0026#34;\u0026gt; select \u0026lt;include refid=\u0026#34;Base_Column_List\u0026#34; /\u0026gt; from t_test_user where id = #{id,jdbcType=BIGINT} \u0026lt;/select\u0026gt; Mybatis会在一开始加载的时候将每个标签中的sql语句包装成MappedStatement对象,并以类全路径名+方法名为key,MappedStatement为value缓存在内存中。在执行对应的方法时,就会根据这个唯一路径找到TTestUserMapper.xml这条sql语句并且执行返回结果。\n4. SqlSession的执行原理 # 4. 1 SqlSession的selectOne的执行原理 # SqlSession的selectOne代码如下,其实是调用selectList()方法获取第一条数据的。其中参数statement就是statement的id,parameter就是参数。\npublic \u0026lt;T\u0026gt; T selectOne(String statement, Object parameter) { List\u0026lt;T\u0026gt; list = this.selectList(statement, parameter); if (list.size() == 1) { return list.get(0); } else if (list.size() \u0026gt; 1) { throw new TooManyResultsException(\u0026#34;Expected one result (or null) to be returned by selectOne(), but found: \u0026#34; + list.size()); } else { return null; } } RowBounds 对象是分页对象,主要拼接sql中的start,limit条件。并且可以看到两个重要步骤:\n从configuration的成员变量mappedStatements中获取MappedStatement对象。mappedStatements是Map\u0026lt;String, MappedStatement\u0026gt;类型的缓存结构,其中key就是mapper接口全类名+方法名,MappedStatement就是对标签中配置的sql一个包装 使用executor成员变量来执行查询并且指定结果处理器,并且返回结果。Executor也是mybatis的一个重要的组件。sql的执行都是由Executor对象来操作的。 public \u0026lt;E\u0026gt; List\u0026lt;E\u0026gt; selectList(String statement, Object parameter, RowBounds rowBounds) { List var5; try { MappedStatement ms = this.configuration.getMappedStatement(statement); var5 = this.executor.query(ms, this.wrapCollection(parameter), rowBounds, Executor.NO_RESULT_HANDLER); } catch (Exception var9) { throw ExceptionFactory.wrapException(\u0026#34;Error querying database. Cause: \u0026#34; + var9, var9); } finally { ErrorContext.instance().reset(); } return var5; } MappedStatement对象的具体内容和Executor对象的类型,我们将在其它文章中详述。\n4. 2 SqlSession的通过mapper对象使用的执行原理 # 在启动流程那篇文章中,我们大致了解了sqlSession.getMapper返回的其实是个代理类MapperProxy,然后调mapper接口的方法其实都是调用MapperProxy的invoke方法,进而调用MapperMethod的execute方法。\npublic static void main(String[] args) { try { // 1. 读取配置 InputStream inputStream = Resources.getResourceAsStream(\u0026#34;mybatis-config.xml\u0026#34;); // 2. 创建SqlSessionFactory工厂 SqlSessionFactory sqlSessionFactory = new SqlSessionFactoryBuilder().build(inputStream); // 3. 获取sqlSession SqlSession sqlSession = sqlSessionFactory.openSession(); // 4. 获取Mapper TTestUserMapper userMapper = sqlSession.getMapper(TTestUserMapper.class); // 5. 执行接口方法 TTestUser userInfo = userMapper.selectByPrimaryKey(16L); System.out.println(\u0026#34;userInfo = \u0026#34; + JSONUtil.toJsonStr(userInfo)); // 6. 提交事物 sqlSession.commit(); // 7. 关闭资源 sqlSession.close(); inputStream.close(); } catch (Exception e){ log.error(e.getMessage(), e); } } MapperMethod的execute方法中使用命令模式进行增删改查操作,其实也是调用了sqlSession的增删改查方法。\npublic Object execute(SqlSession sqlSession, Object[] args) { Object result; switch (command.getType()) { case INSERT: { Object param = method.convertArgsToSqlCommandParam(args); result = rowCountResult(sqlSession.insert(command.getName(), param)); break; } case UPDATE: { Object param = method.convertArgsToSqlCommandParam(args); result = rowCountResult(sqlSession.update(command.getName(), param)); break; } case DELETE: { Object param = method.convertArgsToSqlCommandParam(args); result = rowCountResult(sqlSession.delete(command.getName(), param)); break; } case SELECT: if (method.returnsVoid() \u0026amp;\u0026amp; method.hasResultHandler()) { executeWithResultHandler(sqlSession, args); result = null; } else if (method.returnsMany()) { result = executeForMany(sqlSession, args); } else if (method.returnsMap()) { result = executeForMap(sqlSession, args); } else if (method.returnsCursor()) { result = executeForCursor(sqlSession, args); } else { Object param = method.convertArgsToSqlCommandParam(args); result = sqlSession.selectOne(command.getName(), param); if (method.returnsOptional() \u0026amp;\u0026amp; (result == null || !method.getReturnType().equals(result.getClass()))) { result = Optional.ofNullable(result); } } break; case FLUSH: result = sqlSession.flushStatements(); break; default: throw new BindingException(\u0026#34;Unknown execution method for: \u0026#34; + command.getName()); } if (result == null \u0026amp;\u0026amp; method.getReturnType().isPrimitive() \u0026amp;\u0026amp; !method.returnsVoid()) { throw new BindingException(\u0026#34;Mapper method \u0026#39;\u0026#34; + command.getName() + \u0026#34; attempted to return null from a method with a primitive return type (\u0026#34; + method.getReturnType() + \u0026#34;).\u0026#34;); } return result; } 总结 # 在这篇文章中我们详细介绍了SqlSession的作用,创建过程,使用方法,以及执行原理等,对SqlSession已经有了比较全面的了解。其中涉及到的Executor对象,MappedStatement对象,ResultHandler我们将在其它文章中讲解。欢迎在评论区中讨论指正,一起进步。\n"},{"id":283,"href":"/zh/docs/technology/Review/java_guide/lybly_framework/MyBatis/principle/mybatis-principle2/","title":"Mybatis原理系列(2)","section":"原理","content":" 转载自https://www.jianshu.com/p/7d6b891180a3(添加小部分笔记)感谢作者!\n在上篇文章中,我们举了一个例子如何使用MyBatis,但是对其中dao层,entity层,mapper层间的关系不得而知,从此篇文章开始,笔者将从MyBatis的启动流程着手,真正的开始研究MyBatis源码了。\n1. MyBatis启动代码示例 # 在上篇文章中,介绍了MyBatis的相关配置和各层代码编写,本文将以下代码展开描述和介绍MyBatis的启动流程,并简略的介绍各个模块的作用,各个模块的细节部分将在其它文章中呈现。\n回顾下上文中使用mybatis的部分代码,包括七步。每步虽然都是一行代码,但是隐藏了很多细节。接下来我们将围绕这起步展开了解。\n@Slf4j public class MyBatisBootStrap { public static void main(String[] args) { try { // 1. 读取配置 InputStream inputStream = Resources.getResourceAsStream(\u0026#34;mybatis-config.xml\u0026#34;); // 2. 创建SqlSessionFactory工厂 SqlSessionFactory sqlSessionFactory = new SqlSessionFactoryBuilder().build(inputStream); // 3. 获取sqlSession SqlSession sqlSession = sqlSessionFactory.openSession(); // 4. 获取Mapper TTestUserMapper userMapper = sqlSession.getMapper(TTestUserMapper.class); // 5. 执行接口方法 TTestUser userInfo = userMapper.selectByPrimaryKey(16L); System.out.println(\u0026#34;userInfo = \u0026#34; + JSONUtil.toJsonStr(userInfo)); // 6. 提交事物 sqlSession.commit(); // 7. 关闭资源 sqlSession.close(); inputStream.close(); } catch (Exception e){ log.error(e.getMessage(), e); } } } 2. 读取配置 # // 1. 读取配置 InputStream inputStream = Resources.getResourceAsStream(\u0026#34;mybatis-config.xml\u0026#34;); 在mybatis-config.xml中我们配置了属性,环境,映射文件路径等,其实不仅可以配置以上内容,还可以配置插件,反射工厂,类型处理器等等其它内容。在启动流程中的第一步我们就需要读取这个配置文件,并获取一个输入流为下一步解析配置文件作准备。\nmybatis-config.xml 内容如下\n\u0026lt;?xml version=\u0026#34;1.0\u0026#34; encoding=\u0026#34;UTF-8\u0026#34; ?\u0026gt; \u0026lt;!DOCTYPE configuration PUBLIC \u0026#34;-//mybatis.org//DTD Config 3.0//EN\u0026#34; \u0026#34;http://mybatis.org/dtd/mybatis-3-config.dtd\u0026#34;\u0026gt; \u0026lt;configuration\u0026gt; \u0026lt;!--一些重要的全局配置--\u0026gt; \u0026lt;settings\u0026gt; \u0026lt;setting name=\u0026#34;cacheEnabled\u0026#34; value=\u0026#34;true\u0026#34;/\u0026gt; \u0026lt;!--\u0026lt;setting name=\u0026#34;lazyLoadingEnabled\u0026#34; value=\u0026#34;true\u0026#34;/\u0026gt;--\u0026gt; \u0026lt;!--\u0026lt;setting name=\u0026#34;multipleResultSetsEnabled\u0026#34; value=\u0026#34;true\u0026#34;/\u0026gt;--\u0026gt; \u0026lt;!--\u0026lt;setting name=\u0026#34;useColumnLabel\u0026#34; value=\u0026#34;true\u0026#34;/\u0026gt;--\u0026gt; \u0026lt;!--\u0026lt;setting name=\u0026#34;useGeneratedKeys\u0026#34; value=\u0026#34;false\u0026#34;/\u0026gt;--\u0026gt; \u0026lt;!--\u0026lt;setting name=\u0026#34;autoMappingBehavior\u0026#34; value=\u0026#34;PARTIAL\u0026#34;/\u0026gt;--\u0026gt; \u0026lt;!--\u0026lt;setting name=\u0026#34;autoMappingUnknownColumnBehavior\u0026#34; value=\u0026#34;WARNING\u0026#34;/\u0026gt;--\u0026gt; \u0026lt;!--\u0026lt;setting name=\u0026#34;defaultExecutorType\u0026#34; value=\u0026#34;SIMPLE\u0026#34;/\u0026gt;--\u0026gt; \u0026lt;!--\u0026lt;setting name=\u0026#34;defaultStatementTimeout\u0026#34; value=\u0026#34;25\u0026#34;/\u0026gt;--\u0026gt; \u0026lt;!--\u0026lt;setting name=\u0026#34;defaultFetchSize\u0026#34; value=\u0026#34;100\u0026#34;/\u0026gt;--\u0026gt; \u0026lt;!--\u0026lt;setting name=\u0026#34;safeRowBoundsEnabled\u0026#34; value=\u0026#34;false\u0026#34;/\u0026gt;--\u0026gt; \u0026lt;!--\u0026lt;setting name=\u0026#34;mapUnderscoreToCamelCase\u0026#34; value=\u0026#34;false\u0026#34;/\u0026gt;--\u0026gt; \u0026lt;!--\u0026lt;setting name=\u0026#34;localCacheScope\u0026#34; value=\u0026#34;STATEMENT\u0026#34;/\u0026gt;--\u0026gt; \u0026lt;!--\u0026lt;setting name=\u0026#34;jdbcTypeForNull\u0026#34; value=\u0026#34;OTHER\u0026#34;/\u0026gt;--\u0026gt; \u0026lt;!--\u0026lt;setting name=\u0026#34;lazyLoadTriggerMethods\u0026#34; value=\u0026#34;equals,clone,hashCode,toString\u0026#34;/\u0026gt;--\u0026gt; \u0026lt;!--\u0026lt;setting name=\u0026#34;logImpl\u0026#34; value=\u0026#34;STDOUT_LOGGING\u0026#34; /\u0026gt;--\u0026gt; \u0026lt;/settings\u0026gt; \u0026lt;environments default=\u0026#34;development\u0026#34;\u0026gt; \u0026lt;environment id=\u0026#34;development\u0026#34;\u0026gt; \u0026lt;transactionManager type=\u0026#34;JDBC\u0026#34;/\u0026gt; \u0026lt;dataSource type=\u0026#34;POOLED\u0026#34;\u0026gt; \u0026lt;property name=\u0026#34;driver\u0026#34; value=\u0026#34;com.mysql.cj.jdbc.Driver\u0026#34;/\u0026gt; \u0026lt;property name=\u0026#34;url\u0026#34; value=\u0026#34;jdbc:mysql://10.255.0.50:3306/volvo_bev?useUnicode=true\u0026#34;/\u0026gt; \u0026lt;property name=\u0026#34;username\u0026#34; value=\u0026#34;appdev\u0026#34;/\u0026gt; \u0026lt;property name=\u0026#34;password\u0026#34; value=\u0026#34;FEGwo3EzsdDYS9ooYKGCjRQepkwG\u0026#34;/\u0026gt; \u0026lt;/dataSource\u0026gt; \u0026lt;/environment\u0026gt; \u0026lt;/environments\u0026gt; \u0026lt;mappers\u0026gt; \u0026lt;!--这边可以使用package和resource两种方式加载mapper--\u0026gt; \u0026lt;!--\u0026lt;package name=\u0026#34;包名\u0026#34;/\u0026gt;--\u0026gt; \u0026lt;!--\u0026lt;mapper resource=\u0026#34;./mappers/SysUserMapper.xml\u0026#34;/\u0026gt; \u0026lt;package name=\u0026#34;com.example.demo.dao\u0026#34;/\u0026gt; --\u0026gt; \u0026lt;mapper resource=\u0026#34;./mapper/TTestUserMapper.xml\u0026#34;/\u0026gt; \u0026lt;/mappers\u0026gt; \u0026lt;/configuration\u0026gt; 3. 创建SqlSessionFactory工厂 # SqlSessionFactory sqlSessionFactory = new SqlSessionFactoryBuilder().build(inputStream); 我们在学习Java的设计模式时,会学到工厂模式,工厂模式又分为简单工厂模式,工厂方法模式,抽象工厂模式等等。工厂模式就是为了创建对象提供接口,并将创建对象的具体细节屏蔽起来,从而可以提高灵活性。\npublic interface SqlSessionFactory { SqlSession openSession(); SqlSession openSession(boolean autoCommit); SqlSession openSession(Connection connection); SqlSession openSession(TransactionIsolationLevel level); SqlSession openSession(ExecutorType execType); SqlSession openSession(ExecutorType execType, boolean autoCommit); SqlSession openSession(ExecutorType execType, TransactionIsolationLevel level); SqlSession openSession(ExecutorType execType, Connection connection); Configuration getConfiguration(); } 由此可知SqlSessionFactory工厂是为了创建一个对象而生的,其产出的对象就是SqlSession对象。SqlSession是MyBatis面向数据库的高级接口,其提供了执行查询sql,更新sql,提交事物,回滚事物,**获取映射代理类(也就是Mapper)**等等方法。\n在此笔者列出了主要方法,一些重载的方法就过滤掉了。\npublic interface SqlSession extends Closeable { /** * 查询一个结果对象 **/ \u0026lt;T\u0026gt; T selectOne(String statement, Object parameter); /** * 查询一个结果集合 **/ \u0026lt;E\u0026gt; List\u0026lt;E\u0026gt; selectList(String statement, Object parameter, RowBounds rowBounds); /** * 查询一个map **/ \u0026lt;K, V\u0026gt; Map\u0026lt;K, V\u0026gt; selectMap(String statement, Object parameter, String mapKey, RowBounds rowBounds); /** * 查询游标 **/ \u0026lt;T\u0026gt; Cursor\u0026lt;T\u0026gt; selectCursor(String statement, Object parameter, RowBounds rowBounds); void select(String statement, Object parameter, RowBounds rowBounds, ResultHandler handler); /** * 插入 **/ int insert(String statement, Object parameter); /** * 修改 **/ int update(String statement, Object parameter); /** * 删除 **/ int delete(String statement, Object parameter); /** * 提交事物 **/ void commit(boolean force); /** * 回滚事物 **/ void rollback(boolean force); List\u0026lt;BatchResult\u0026gt; flushStatements(); void close(); void clearCache(); Configuration getConfiguration(); /** * 获取映射代理类 **/ \u0026lt;T\u0026gt; T getMapper(Class\u0026lt;T\u0026gt; type); /** * 获取数据库连接 **/ Connection getConnection(); } 回到开始,SqlSessionFactory工厂是怎么创建的出来的呢?SqlSessionFactoryBuilder就是创建者,以Builder结尾我们很容易想到了Java设计模式中的建造者模式,一个对象的创建是由众多复杂对象组成的,建造者模式就是一个创建复杂对象的选择,它与工厂模式相比,建造者模式更加关注零件装配的顺序。\npublic class SqlSessionFactoryBuilder { public SqlSessionFactory build(InputStream inputStream, String environment, Properties properties) { try { XMLConfigBuilder parser = new XMLConfigBuilder(inputStream, environment, properties); return build(parser.parse()); } catch (Exception e) { throw ExceptionFactory.wrapException(\u0026#34;Error building SqlSession.\u0026#34;, e); } finally { ErrorContext.instance().reset(); try { inputStream.close(); } catch (IOException e) { // Intentionally ignore. Prefer previous error. } } } } 其中XMLConfigBuilder就是解析mybatis-config.xml中每个标签的内容,parse()方法返回的就是一个Configuration对象.Configuration也是MyBatis中一个很重要的组件,包括插件,对象工厂,反射工厂,映射文件,类型解析器等等都存储在Configuration对象中。\npublic Configuration parse() { if (parsed) { throw new BuilderException(\u0026#34;Each XMLConfigBuilder can only be used once.\u0026#34;); } parsed = true; parseConfiguration(parser.evalNode(\u0026#34;/configuration\u0026#34;)); return configuration; } private void parseConfiguration(XNode root) { try { // issue #117 read properties first // 解析properties节点 propertiesElement(root.evalNode(\u0026#34;properties\u0026#34;)); Properties settings = settingsAsProperties(root.evalNode(\u0026#34;settings\u0026#34;)); loadCustomVfs(settings); loadCustomLogImpl(settings); typeAliasesElement(root.evalNode(\u0026#34;typeAliases\u0026#34;)); pluginElement(root.evalNode(\u0026#34;plugins\u0026#34;)); objectFactoryElement(root.evalNode(\u0026#34;objectFactory\u0026#34;)); objectWrapperFactoryElement(root.evalNode(\u0026#34;objectWrapperFactory\u0026#34;)); reflectorFactoryElement(root.evalNode(\u0026#34;reflectorFactory\u0026#34;)); settingsElement(settings); // read it after objectFactory and objectWrapperFactory issue #631 environmentsElement(root.evalNode(\u0026#34;environments\u0026#34;)); databaseIdProviderElement(root.evalNode(\u0026#34;databaseIdProvider\u0026#34;)); typeHandlerElement(root.evalNode(\u0026#34;typeHandlers\u0026#34;)); mapperElement(root.evalNode(\u0026#34;mappers\u0026#34;)); } catch (Exception e) { throw new BuilderException(\u0026#34;Error parsing SQL Mapper Configuration. Cause: \u0026#34; + e, e); } } 在获取到Configuration对象后,SqlSessionFactoryBuilder就会创建一个DefaultSqlSessionFactory对象,DefaultSqlSessionFactory是SqlSessionFactory的一个默认实现,还有一个实现是SqlSessionManager。\npublic SqlSessionFactory build(Configuration config) { return new DefaultSqlSessionFactory(config); } 4. 获取sqlSession # // 3. 获取sqlSession SqlSession sqlSession = sqlSessionFactory.openSession(); 在前面我们讲到,sqlSession是操作数据库的高级接口,我们操作数据库都是通过这个接口操作的。获取sqlSession有两种方式,一种是从数据源中获取的,还有一种是从连接中获取。\n貌似默认是从数据源获取\n获取到的都是DefaultSqlSession对象,也就是sqlSession的默认实现。\n注意,过程中有个Executor\u0026mdash;执行器\nprivate SqlSession openSessionFromDataSource(ExecutorType execType, TransactionIsolationLevel level, boolean autoCommit) { Transaction tx = null; try { final Environment environment = configuration.getEnvironment(); final TransactionFactory transactionFactory = getTransactionFactoryFromEnvironment(environment); tx = transactionFactory.newTransaction(environment.getDataSource(), level, autoCommit); final Executor executor = configuration.newExecutor(tx, execType); return new DefaultSqlSession(configuration, executor, autoCommit); } catch (Exception e) { closeTransaction(tx); // may have fetched a connection so lets call close() throw ExceptionFactory.wrapException(\u0026#34;Error opening session. Cause: \u0026#34; + e, e); } finally { ErrorContext.instance().reset(); } } private SqlSession openSessionFromConnection(ExecutorType execType, Connection connection) { try { boolean autoCommit; try { autoCommit = connection.getAutoCommit(); } catch (SQLException e) { // Failover to true, as most poor drivers // or databases won\u0026#39;t support transactions autoCommit = true; } final Environment environment = configuration.getEnvironment(); final TransactionFactory transactionFactory = getTransactionFactoryFromEnvironment(environment); final Transaction tx = transactionFactory.newTransaction(connection); final Executor executor = configuration.newExecutor(tx, execType); return new DefaultSqlSession(configuration, executor, autoCommit); } catch (Exception e) { throw ExceptionFactory.wrapException(\u0026#34;Error opening session. Cause: \u0026#34; + e, e); } finally { ErrorContext.instance().reset(); } } 获取SqlSession步骤\n5. 获取Mapper代理类 # 在上一步获取到sqlSession后,我们接下来就获取到了mapper代理类。\n// 4. 获取Mapper TTestUserMapper userMapper = sqlSession.getMapper(TTestUserMapper.class); 这个getMapper方法,我们看看DefaultSqlSession是怎么做的\nDefaultSqlSession 的 getMapper 方法\npublic \u0026lt;T\u0026gt; T getMapper(Class\u0026lt;T\u0026gt; type) { return this.configuration.getMapper(type, this); } Configuration 的 getMapper 方法\npublic \u0026lt;T\u0026gt; T getMapper(Class\u0026lt;T\u0026gt; type, SqlSession sqlSession) { return this.mapperRegistry.getMapper(type, sqlSession); } MapperRegistry 中有个getMapper方法,实际上是从成员变量knownMappers中获取的,这个knownMappers是个key-value形式的缓存,key是mapper接口的class对象,value是MapperProxyFactory代理工厂,这个工厂就是用来创建MapperProxy代理类的。\npublic class MapperRegistry { private final Configuration config; private final Map\u0026lt;Class\u0026lt;?\u0026gt;, MapperProxyFactory\u0026lt;?\u0026gt;\u0026gt; knownMappers = new HashMap(); public MapperRegistry(Configuration config) { this.config = config; } public \u0026lt;T\u0026gt; T getMapper(Class\u0026lt;T\u0026gt; type, SqlSession sqlSession) { MapperProxyFactory\u0026lt;T\u0026gt; mapperProxyFactory = (MapperProxyFactory)this.knownMappers.get(type); if (mapperProxyFactory == null) { throw new BindingException(\u0026#34;Type \u0026#34; + type + \u0026#34; is not known to the MapperRegistry.\u0026#34;); } else { try { return mapperProxyFactory.newInstance(sqlSession); } catch (Exception var5) { throw new BindingException(\u0026#34;Error getting mapper instance. Cause: \u0026#34; + var5, var5); } } } } 如果对java动态代理了解的同学就知道,Proxy.newProxyInstance()方法可以创建出一个目标对象一个代理对象。由此可知每次调用getMapper方法都会创建出一个代理类出来。\npublic class MapperProxyFactory\u0026lt;T\u0026gt; { private final Class\u0026lt;T\u0026gt; mapperInterface; private final Map\u0026lt;Method, MapperMethod\u0026gt; methodCache = new ConcurrentHashMap(); public MapperProxyFactory(Class\u0026lt;T\u0026gt; mapperInterface) { this.mapperInterface = mapperInterface; } public Class\u0026lt;T\u0026gt; getMapperInterface() { return this.mapperInterface; } public Map\u0026lt;Method, MapperMethod\u0026gt; getMethodCache() { return this.methodCache; } protected T newInstance(MapperProxy\u0026lt;T\u0026gt; mapperProxy) { return Proxy.newProxyInstance(this.mapperInterface.getClassLoader(), new Class[]{this.mapperInterface}, mapperProxy); } public T newInstance(SqlSession sqlSession) { MapperProxy\u0026lt;T\u0026gt; mapperProxy = new MapperProxy(sqlSession, this.mapperInterface, this.methodCache); return this.newInstance(mapperProxy); } } 回到上面,那这个MapperProxyFactory是怎么加载到MapperRegistry的knownMappers缓存中的呢?\n在上面的Configuration类的parseConfiguration方法中,我们会解析 mappers标签,mapperElement方法就会解析mapper接口。\nprivate void parseConfiguration(XNode root) { try { // issue #117 read properties first // 解析properties节点 propertiesElement(root.evalNode(\u0026#34;properties\u0026#34;)); Properties settings = settingsAsProperties(root.evalNode(\u0026#34;settings\u0026#34;)); loadCustomVfs(settings); loadCustomLogImpl(settings); typeAliasesElement(root.evalNode(\u0026#34;typeAliases\u0026#34;)); pluginElement(root.evalNode(\u0026#34;plugins\u0026#34;)); objectFactoryElement(root.evalNode(\u0026#34;objectFactory\u0026#34;)); objectWrapperFactoryElement(root.evalNode(\u0026#34;objectWrapperFactory\u0026#34;)); reflectorFactoryElement(root.evalNode(\u0026#34;reflectorFactory\u0026#34;)); settingsElement(settings); // read it after objectFactory and objectWrapperFactory issue #631 environmentsElement(root.evalNode(\u0026#34;environments\u0026#34;)); databaseIdProviderElement(root.evalNode(\u0026#34;databaseIdProvider\u0026#34;)); typeHandlerElement(root.evalNode(\u0026#34;typeHandlers\u0026#34;)); mapperElement(root.evalNode(\u0026#34;mappers\u0026#34;)); } catch (Exception e) { throw new BuilderException(\u0026#34;Error parsing SQL Mapper Configuration. Cause: \u0026#34; + e, e); } } private void mapperElement(XNode parent) throws Exception { if (parent != null) { for (XNode child : parent.getChildren()) { if (\u0026#34;package\u0026#34;.equals(child.getName())) { String mapperPackage = child.getStringAttribute(\u0026#34;name\u0026#34;); configuration.addMappers(mapperPackage); } else { String resource = child.getStringAttribute(\u0026#34;resource\u0026#34;); String url = child.getStringAttribute(\u0026#34;url\u0026#34;); String mapperClass = child.getStringAttribute(\u0026#34;class\u0026#34;); if (resource != null \u0026amp;\u0026amp; url == null \u0026amp;\u0026amp; mapperClass == null) { ErrorContext.instance().resource(resource); InputStream inputStream = Resources.getResourceAsStream(resource); XMLMapperBuilder mapperParser = new XMLMapperBuilder(inputStream, configuration, resource, configuration.getSqlFragments()); mapperParser.parse(); } else if (resource == null \u0026amp;\u0026amp; url != null \u0026amp;\u0026amp; mapperClass == null) { ErrorContext.instance().resource(url); InputStream inputStream = Resources.getUrlAsStream(url); XMLMapperBuilder mapperParser = new XMLMapperBuilder(inputStream, configuration, url, configuration.getSqlFragments()); mapperParser.parse(); } else if (resource == null \u0026amp;\u0026amp; url == null \u0026amp;\u0026amp; mapperClass != null) { Class\u0026lt;?\u0026gt; mapperInterface = Resources.classForName(mapperClass); configuration.addMapper(mapperInterface); } else { throw new BuilderException(\u0026#34;A mapper element may only specify a url, resource or class, but not more than one.\u0026#34;); } } } } } 解析完后,就将这个mapper接口加到 mapperRegistry中,\nconfiguration.addMapper(mapperInterface); Configuration的addMapper方法\npublic \u0026lt;T\u0026gt; void addMapper(Class\u0026lt;T\u0026gt; type) { mapperRegistry.addMapper(type); } 最后还是加载到了MapperRegistry的knownMappers中去了\npublic \u0026lt;T\u0026gt; void addMapper(Class\u0026lt;T\u0026gt; type) { if (type.isInterface()) { if (hasMapper(type)) { throw new BindingException(\u0026#34;Type \u0026#34; + type + \u0026#34; is already known to the MapperRegistry.\u0026#34;); } boolean loadCompleted = false; try { knownMappers.put(type, new MapperProxyFactory\u0026lt;\u0026gt;(type)); // It\u0026#39;s important that the type is added before the parser is run // otherwise the binding may automatically be attempted by the // mapper parser. If the type is already known, it won\u0026#39;t try. MapperAnnotationBuilder parser = new MapperAnnotationBuilder(config, type); parser.parse(); loadCompleted = true; } finally { if (!loadCompleted) { knownMappers.remove(type); } } } } 获取mapper代理类过程\n6. 执行mapper接口方法 # // 5. 执行接口方法 TTestUser userInfo = userMapper.selectByPrimaryKey(16L); selectByPrimaryKey是TTestUserMapper接口中定义的一个方法,但是我们没有编写TTestUserMapper接口的的实现类,那么Mybatis是怎么帮我们执行的呢?前面讲到,获取mapper对象时,是会获取到一个MapperProxyFactory工厂类,并创建一个MapperProxy代理类,在执行Mapper接口的方法时,会调用MapperProxy的invoke方法。\n@Override public Object invoke(Object proxy, Method method, Object[] args) throws Throwable { try { if (Object.class.equals(method.getDeclaringClass())) { return method.invoke(this, args); } else { return cachedInvoker(method).invoke(proxy, method, args, sqlSession); } } catch (Throwable t) { throw ExceptionUtil.unwrapThrowable(t); } } 如果是Object的方法就直接执行,否则执行cachedInvoker(method).invoke(proxy, method, args, sqlSession); 这行代码,到这里,想必有部分同学已经头晕了吧。怎么又来了个invoke方法。 cachedInvoker 是返回缓存的MapperMethodInvoker对象,MapperMethodInvoker的invoke方法会执行MapperMethod的execute方法。\npublic class MapperMethod { private final SqlCommand command; private final MethodSignature method; public MapperMethod(Class\u0026lt;?\u0026gt; mapperInterface, Method method, Configuration config) { this.command = new SqlCommand(config, mapperInterface, method); this.method = new MethodSignature(config, mapperInterface, method); } public Object execute(SqlSession sqlSession, Object[] args) { Object result; switch (command.getType()) { case INSERT: { Object param = method.convertArgsToSqlCommandParam(args); result = rowCountResult(sqlSession.insert(command.getName(), param)); break; } case UPDATE: { Object param = method.convertArgsToSqlCommandParam(args); result = rowCountResult(sqlSession.update(command.getName(), param)); break; } case DELETE: { Object param = method.convertArgsToSqlCommandParam(args); result = rowCountResult(sqlSession.delete(command.getName(), param)); break; } case SELECT: if (method.returnsVoid() \u0026amp;\u0026amp; method.hasResultHandler()) { executeWithResultHandler(sqlSession, args); result = null; } else if (method.returnsMany()) { result = executeForMany(sqlSession, args); } else if (method.returnsMap()) { result = executeForMap(sqlSession, args); } else if (method.returnsCursor()) { result = executeForCursor(sqlSession, args); } else { Object param = method.convertArgsToSqlCommandParam(args); result = sqlSession.selectOne(command.getName(), param); if (method.returnsOptional() \u0026amp;\u0026amp; (result == null || !method.getReturnType().equals(result.getClass()))) { result = Optional.ofNullable(result); } } break; case FLUSH: result = sqlSession.flushStatements(); break; default: throw new BindingException(\u0026#34;Unknown execution method for: \u0026#34; + command.getName()); } if (result == null \u0026amp;\u0026amp; method.getReturnType().isPrimitive() \u0026amp;\u0026amp; !method.returnsVoid()) { throw new BindingException(\u0026#34;Mapper method \u0026#39;\u0026#34; + command.getName() + \u0026#34; attempted to return null from a method with a primitive return type (\u0026#34; + method.getReturnType() + \u0026#34;).\u0026#34;); } return result; } } 然后根据执行的接口找到mapper.xml中配置的sql,并处理参数,然后执行返回结果处理结果等步骤。\n7. 提交事务 # // 6. 提交事务 sqlSession.commit(); 事务就是将若干数据库操作看成一个单元,要么全部成功,要么全部失败,如果失败了,则会执行执行回滚操作,恢复到开始执行的数据库状态。\n8. 关闭资源 # // 7. 关闭资源 sqlSession.close(); inputStream.close(); sqlSession是种共用资源,用完了要返回到池子中,以供其它地方使用。\n9. 总结 # 至此我们已经大致了解了Mybatis启动时的大致流程,很多细节都还没有详细介绍,这是因为涉及到的层面又深又广,如果在一篇文章中介绍,反而会让读者如置云里雾里,不知所云。因此,在接下来我将每个模块的详细介绍。如果文章有什么错误或者需要改进的,希望同学们指出来,希望对大家有帮助。\n"},{"id":284,"href":"/zh/docs/technology/Review/java_guide/lybly_framework/MyBatis/principle/mybatis-principle1/","title":"Mybatis原理系列(1)","section":"原理","content":" 转载自https://www.jianshu.com/p/ada025f97a07(添加小部分笔记)感谢作者!\n作为Java码农,无论在面试中,还是在工作中都会遇到MyBatis的相关问题。笔者从大学开始就接触MyBatis,到现在为止都是会用,知道怎么配置,怎么编写xml,但是不知道Mybatis核心原理,一遇到问题就复制错误信息百度解决。为了改变这种境地,鼓起勇气开始下定决心阅读MyBatis源码,并开始记录阅读过程,希望和大家分享。\n1. 初识MyBatis # 还记得当初接触MyBatis时,觉得要配置很多,而且sql要单独写在xml中,相比Hibernate来说简直不太友好,直到后来出现了复杂的业务需求,需要编写相应的复杂的sql,此时用Hibernate反而更加麻烦了,用MyBatis是真香了。因此笔者对MyBatis的第一印象就是将业务关注的sql和java代码进行了解耦,在业务复杂变化的时候,相应的数据库操作需要相应进行修改,如果通过java代码构建操作数据逻辑,这不断变动的需求对程序员的耐心是极大的考验。如果将sql统一的维护在一个文件里,java代码用接口定义,在需求变动时,只用改相应的sql,从而减少了修改量,提高开发效率。以上也是经常在面试中经常问到的Hibernate和MyBatis间的区别一点。\n切到正题,Mybatis是什么呢?\nMybatis SQL 映射框架使得一个面向对象构建的应用程序去访问一个关系型数据库变得更容易。MyBatis使用XML描述符或注解将对象与存储过程或SQL语句耦合。与对象关系映射工具相比,简单性是MyBatis数据映射器的最大优势。\n以上是Mybatis的官方解释,其中“映射”,“面向对象”,“关系型”,“xml”等等都是Mybatis的关键词,也是我们了解了Mybatis原理后,会恍然大悟的地方。笔者现在不详述这些概念,在最后总结的时候再进行详述。我们只要知道Mybatis为我们操作数据库提供了很大的便捷。\n2. 源码下载 # 这里建议使用maven即可,在pom.xml添加以下依赖\n\u0026lt;dependencies\u0026gt; \u0026lt;!-- https://mvnrepository.com/artifact/mysql/mysql-connector-java --\u0026gt; \u0026lt;dependency\u0026gt; \u0026lt;groupId\u0026gt;mysql\u0026lt;/groupId\u0026gt; \u0026lt;artifactId\u0026gt;mysql-connector-java\u0026lt;/artifactId\u0026gt; \u0026lt;version\u0026gt;8.0.32\u0026lt;/version\u0026gt; \u0026lt;/dependency\u0026gt; \u0026lt;dependency\u0026gt; \u0026lt;groupId\u0026gt;org.mybatis\u0026lt;/groupId\u0026gt; \u0026lt;artifactId\u0026gt;mybatis\u0026lt;/artifactId\u0026gt; \u0026lt;version\u0026gt;3.5.6\u0026lt;/version\u0026gt; \u0026lt;/dependency\u0026gt; \u0026lt;!--这里还添加了一些辅助的依赖--\u0026gt; \u0026lt;!--lombok--\u0026gt; \u0026lt;dependency\u0026gt; \u0026lt;groupId\u0026gt;org.projectlombok\u0026lt;/groupId\u0026gt; \u0026lt;artifactId\u0026gt;lombok\u0026lt;/artifactId\u0026gt; \u0026lt;version\u0026gt;1.18.8\u0026lt;/version\u0026gt; \u0026lt;/dependency\u0026gt; \u0026lt;!--日志模块--\u0026gt; \u0026lt;dependency\u0026gt; \u0026lt;groupId\u0026gt;org.apache.logging.log4j\u0026lt;/groupId\u0026gt; \u0026lt;artifactId\u0026gt;log4j-api\u0026lt;/artifactId\u0026gt; \u0026lt;version\u0026gt;2.17.1\u0026lt;/version\u0026gt; \u0026lt;/dependency\u0026gt; \u0026lt;/dependencies\u0026gt; 然后在ExternalLibraries 的mybatis:3.5.6里找到,就能看到目录结构 ,随便找一个进去 idea右上角会出现DownloadSource之类的字样 ,点击即可\n我们首先要从github上下载源码, 仓库地址,然后在IDEA中clone代码\n在打开中的IDEA中,选择vsc -\u0026gt; get from version control -\u0026gt; 复制刚才的地址\nimage.png\n点击clone即可\nimage.png\n经过漫长的等待后,代码会全部下载下来,项目结果如下,框起来的就是我们要关注的核心代码了。\nimage.png\n每个包就是MyBatis的一个模块,每个包的作用如下:\n3. 一个简单的栗子 # 不知道现在还有没有同学知道怎么使用原生的JDBC进行数据库操作,现在框架太方便了,为我们考虑了很多,也隐藏了很多细节,因此会让我们处于一个云里雾里的境地,为什么这么设计,这样设计解决了什么问题,我们是不得而知的,为了了解其中奥秘,还是需要我们从头开始了解。\n接下来笔者将以两个栗子来分别讲讲如何用原生的JDBC操作数据库,以及如何使用MyBatis框架来实现相同的功能,并比较两者的区别。\n首先创建数据库 test\n3.1 创建表 # 在此我们建了两张表,一张是t_test_user用户信息主表,一张是t_test_user_info用户信息副表,两张表通过member_id进行关联。\nDROP TABLE IF EXISTS `t_test_user`; CREATE TABLE `t_test_user` ( `id` bigint(20) NOT NULL AUTO_INCREMENT COMMENT \u0026#39;id\u0026#39;, `member_id` bigint(20) NOT NULL COMMENT \u0026#39;会员id\u0026#39;, `real_name` varchar(255) CHARACTER SET utf8 DEFAULT NULL COMMENT \u0026#39;真实姓名\u0026#39;, `nickname` varchar(255) CHARACTER SET utf8mb4 DEFAULT NULL COMMENT \u0026#39;会员昵称\u0026#39;, `date_create` datetime DEFAULT CURRENT_TIMESTAMP COMMENT \u0026#39;创建时间\u0026#39;, `date_update` datetime DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT \u0026#39;更新时间\u0026#39;, `deleted` bigint(20) DEFAULT \u0026#39;0\u0026#39; COMMENT \u0026#39;删除标识,0未删除,时间戳-删除时间\u0026#39;, PRIMARY KEY (`id`) ) ENGINE=InnoDB AUTO_INCREMENT=42013 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT=\u0026#39;测试表\u0026#39;; DROP TABLE IF EXISTS `t_test_user_info`; CREATE TABLE `t_test_user_info` ( `id` bigint(20) NOT NULL AUTO_INCREMENT COMMENT \u0026#39;id\u0026#39;, `member_id` bigint(20) NOT NULL COMMENT \u0026#39;会员id\u0026#39;, `member_phone` varchar(50) CHARACTER SET utf8mb4 DEFAULT NULL COMMENT \u0026#39;电话\u0026#39;, `member_province` varchar(50) CHARACTER SET utf8mb4 DEFAULT NULL COMMENT \u0026#39;省\u0026#39;, `member_city` varchar(50) CHARACTER SET utf8mb4 DEFAULT NULL COMMENT \u0026#39;市\u0026#39;, `member_county` varchar(50) CHARACTER SET utf8mb4 DEFAULT NULL COMMENT \u0026#39;区\u0026#39;, `date_create` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT \u0026#39;创建时间\u0026#39;, `date_update` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT \u0026#39;更新时间\u0026#39;, `deleted` bigint(20) NOT NULL DEFAULT \u0026#39;0\u0026#39; COMMENT \u0026#39;删除标识,0未删除,时间戳-删除时间\u0026#39;, PRIMARY KEY (`id`) ) ENGINE=InnoDB AUTO_INCREMENT=17 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT=\u0026#39;用户信息测试表\u0026#39;; 3.2 使用Java JDBC进行操作数据库 # JDBC(Java Database Connectivity,简称JDBC)是Java中用来规范客户端程序如何来访问数据库的应用程序接口,提供了诸如查询和更新数据库中数据的方法。使用JDBC操作数据库,一般包含7步,代码如下。\npublic class JDBCTest { /** * 数据库地址 替换成本地的地址 */ private static final String url = \u0026#34;jdbc:mysql://localhost:3306/test?useUnicode=true\u0026#34;; /** * 数据库用户名 */ private static final String username = \u0026#34;test\u0026#34;; /** * 密码 */ private static final String password = \u0026#34;test\u0026#34;; public static void main(String[] args) { try { // 1. 加载数据库驱动 Class.forName(\u0026#34;com.mysql.jdbc.Driver\u0026#34;); // 2. 获得连接 Connection connection = DriverManager.getConnection(url, username, password); // 3. 创建sql语句 String sql = \u0026#34;select * from t_test_user\u0026#34;; Statement statement = connection.createStatement(); // 4. 执行sql ResultSet result = statement.executeQuery(sql); // 5. 处理结果 while(result.next()){ System.out.println(\u0026#34;result = \u0026#34; + result.getString(1)); } // 6. 关闭连接 result.close(); connection.close(); } catch (Exception e){ System.out.println(e); } } } 3.3 使用Mybatis进行操作数据库 # 3.3.1 新增mybatis-config.xml配置 # 在路径src/main/resources/mybatis-config.xml新增配置,配置内容如下\n\u0026lt;?xml version=\u0026#34;1.0\u0026#34; encoding=\u0026#34;UTF-8\u0026#34; ?\u0026gt; \u0026lt;!DOCTYPE configuration PUBLIC \u0026#34;-//mybatis.org//DTD Config 3.0//EN\u0026#34; \u0026#34;http://mybatis.org/dtd/mybatis-3-config.dtd\u0026#34;\u0026gt; \u0026lt;configuration\u0026gt; \u0026lt;!--一些重要的全局配置--\u0026gt; \u0026lt;settings\u0026gt; \u0026lt;setting name=\u0026#34;cacheEnabled\u0026#34; value=\u0026#34;true\u0026#34;/\u0026gt; \u0026lt;!--\u0026lt;setting name=\u0026#34;lazyLoadingEnabled\u0026#34; value=\u0026#34;true\u0026#34;/\u0026gt;--\u0026gt; \u0026lt;!--\u0026lt;setting name=\u0026#34;multipleResultSetsEnabled\u0026#34; value=\u0026#34;true\u0026#34;/\u0026gt;--\u0026gt; \u0026lt;!--\u0026lt;setting name=\u0026#34;useColumnLabel\u0026#34; value=\u0026#34;true\u0026#34;/\u0026gt;--\u0026gt; \u0026lt;!--\u0026lt;setting name=\u0026#34;useGeneratedKeys\u0026#34; value=\u0026#34;false\u0026#34;/\u0026gt;--\u0026gt; \u0026lt;!--\u0026lt;setting name=\u0026#34;autoMappingBehavior\u0026#34; value=\u0026#34;PARTIAL\u0026#34;/\u0026gt;--\u0026gt; \u0026lt;!--\u0026lt;setting name=\u0026#34;autoMappingUnknownColumnBehavior\u0026#34; value=\u0026#34;WARNING\u0026#34;/\u0026gt;--\u0026gt; \u0026lt;!--\u0026lt;setting name=\u0026#34;defaultExecutorType\u0026#34; value=\u0026#34;SIMPLE\u0026#34;/\u0026gt;--\u0026gt; \u0026lt;!--\u0026lt;setting name=\u0026#34;defaultStatementTimeout\u0026#34; value=\u0026#34;25\u0026#34;/\u0026gt;--\u0026gt; \u0026lt;!--\u0026lt;setting name=\u0026#34;defaultFetchSize\u0026#34; value=\u0026#34;100\u0026#34;/\u0026gt;--\u0026gt; \u0026lt;!--\u0026lt;setting name=\u0026#34;safeRowBoundsEnabled\u0026#34; value=\u0026#34;false\u0026#34;/\u0026gt;--\u0026gt; \u0026lt;!--\u0026lt;setting name=\u0026#34;mapUnderscoreToCamelCase\u0026#34; value=\u0026#34;false\u0026#34;/\u0026gt;--\u0026gt; \u0026lt;!--\u0026lt;setting name=\u0026#34;localCacheScope\u0026#34; value=\u0026#34;STATEMENT\u0026#34;/\u0026gt;--\u0026gt; \u0026lt;!--\u0026lt;setting name=\u0026#34;jdbcTypeForNull\u0026#34; value=\u0026#34;OTHER\u0026#34;/\u0026gt;--\u0026gt; \u0026lt;!--\u0026lt;setting name=\u0026#34;lazyLoadTriggerMethods\u0026#34; value=\u0026#34;equals,clone,hashCode,toString\u0026#34;/\u0026gt;--\u0026gt; \u0026lt;!--\u0026lt;setting name=\u0026#34;logImpl\u0026#34; value=\u0026#34;STDOUT_LOGGING\u0026#34; /\u0026gt;--\u0026gt; \u0026lt;/settings\u0026gt; \u0026lt;environments default=\u0026#34;development\u0026#34;\u0026gt; \u0026lt;environment id=\u0026#34;development\u0026#34;\u0026gt; \u0026lt;transactionManager type=\u0026#34;JDBC\u0026#34;/\u0026gt; \u0026lt;dataSource type=\u0026#34;POOLED\u0026#34;\u0026gt; \u0026lt;property name=\u0026#34;driver\u0026#34; value=\u0026#34;com.mysql.cj.jdbc.Driver\u0026#34;/\u0026gt; \u0026lt;property name=\u0026#34;url\u0026#34; value=\u0026#34;jdbc:mysql://localhost:3306/test?useUnicode=true\u0026#34;/\u0026gt; \u0026lt;property name=\u0026#34;username\u0026#34; value=\u0026#34;root\u0026#34;/\u0026gt; \u0026lt;property name=\u0026#34;password\u0026#34; value=\u0026#34;123456\u0026#34;/\u0026gt; \u0026lt;/dataSource\u0026gt; \u0026lt;/environment\u0026gt; \u0026lt;/environments\u0026gt; \u0026lt;mappers\u0026gt; \u0026lt;!--这边可以使用package或resource两种方式加载mapper--\u0026gt; \u0026lt;!--\u0026lt;package name=\u0026#34;包名\u0026#34;/\u0026gt;--\u0026gt; \u0026lt;!--如果这里使用了包名, 那么resource下 的Mapper.xml文件的层级,一定要和Mapper类的全类名一样,即com/example/demo/dao/TTestUserMapper.xml--\u0026gt; \u0026lt;!--\u0026lt;mapper resource=\u0026#34;具体的Mapper.xml地址\u0026#34; /\u0026gt;--\u0026gt; \u0026lt;mapper resource=\u0026#34;mapper/TTestUserMapper.xml\u0026#34; /\u0026gt; \u0026lt;!--\u0026lt;package name=\u0026#34;com.example.demo.dao\u0026#34;/\u0026gt;--\u0026gt; \u0026lt;/mappers\u0026gt; \u0026lt;/configuration\u0026gt; 3.3.2 新增mapper接口 # 新增src/main/java/com/example/demo/dao/TTestUserMapper.java 接口\npackage com.example.demo.dao; import com.example.demo.entity.TTestUser; import org.apache.ibatis.annotations.Mapper; import java.util.List; @Mapper public interface TTestUserMapper { /** * This method was generated by MyBatis Generator. * This method corresponds to the database table t_test_user * * @mbggenerated */ int deleteByPrimaryKey(Long id); /** * This method was generated by MyBatis Generator. * This method corresponds to the database table t_test_user * * @mbggenerated */ int insert(TTestUser record); /** * This method was generated by MyBatis Generator. * This method corresponds to the database table t_test_user * * @mbggenerated */ int insertSelective(TTestUser record); int batchInsert(List\u0026lt;TTestUser\u0026gt; records); /** * This method was generated by MyBatis Generator. * This method corresponds to the database table t_test_user * * @mbggenerated */ TTestUser selectByPrimaryKey(Long id); /** * This method was generated by MyBatis Generator. * This method corresponds to the database table t_test_user * * @mbggenerated */ int updateByPrimaryKeySelective(TTestUser record); /** * This method was generated by MyBatis Generator. * This method corresponds to the database table t_test_user * * @mbggenerated */ int updateByPrimaryKey(TTestUser record); } 3.3.3 新增映射配置文件 # src/main/resources/mapper/TTestUserMapper.xml 新增映射配置文件\n\u0026lt;?xml version=\u0026#34;1.0\u0026#34; encoding=\u0026#34;UTF-8\u0026#34;?\u0026gt; \u0026lt;!DOCTYPE mapper PUBLIC \u0026#34;-//mybatis.org//DTD Mapper 3.0//EN\u0026#34; \u0026#34;http://mybatis.org/dtd/mybatis-3-mapper.dtd\u0026#34;\u0026gt; \u0026lt;mapper namespace=\u0026#34;com.example.demo.dao.TTestUserMapper\u0026#34;\u0026gt; \u0026lt;resultMap id=\u0026#34;BaseResultMap\u0026#34; type=\u0026#34;com.example.demo.entity.TTestUser\u0026#34;\u0026gt; \u0026lt;!-- WARNING - @mbggenerated This element is automatically generated by MyBatis Generator, do not modify. --\u0026gt; \u0026lt;id column=\u0026#34;id\u0026#34; jdbcType=\u0026#34;BIGINT\u0026#34; property=\u0026#34;id\u0026#34; /\u0026gt; \u0026lt;result column=\u0026#34;member_id\u0026#34; jdbcType=\u0026#34;BIGINT\u0026#34; property=\u0026#34;memberId\u0026#34; /\u0026gt; \u0026lt;result column=\u0026#34;real_name\u0026#34; jdbcType=\u0026#34;VARCHAR\u0026#34; property=\u0026#34;realName\u0026#34; /\u0026gt; \u0026lt;result column=\u0026#34;nickname\u0026#34; jdbcType=\u0026#34;VARCHAR\u0026#34; property=\u0026#34;nickname\u0026#34; /\u0026gt; \u0026lt;result column=\u0026#34;date_create\u0026#34; jdbcType=\u0026#34;TIMESTAMP\u0026#34; property=\u0026#34;dateCreate\u0026#34; /\u0026gt; \u0026lt;result column=\u0026#34;date_update\u0026#34; jdbcType=\u0026#34;TIMESTAMP\u0026#34; property=\u0026#34;dateUpdate\u0026#34; /\u0026gt; \u0026lt;result column=\u0026#34;deleted\u0026#34; jdbcType=\u0026#34;BIGINT\u0026#34; property=\u0026#34;deleted\u0026#34; /\u0026gt; \u0026lt;/resultMap\u0026gt; \u0026lt;sql id=\u0026#34;Base_Column_List\u0026#34;\u0026gt; \u0026lt;!-- WARNING - @mbggenerated This element is automatically generated by MyBatis Generator, do not modify. --\u0026gt; id, member_id, real_name, nickname, date_create, date_update, deleted \u0026lt;/sql\u0026gt; \u0026lt;select id=\u0026#34;selectByPrimaryKey\u0026#34; parameterType=\u0026#34;java.lang.Long\u0026#34; resultMap=\u0026#34;BaseResultMap\u0026#34;\u0026gt; \u0026lt;!-- WARNING - @mbggenerated This element is automatically generated by MyBatis Generator, do not modify. --\u0026gt; select \u0026lt;include refid=\u0026#34;Base_Column_List\u0026#34; /\u0026gt; from t_test_user where id = #{id,jdbcType=BIGINT} \u0026lt;/select\u0026gt; \u0026lt;delete id=\u0026#34;deleteByPrimaryKey\u0026#34; parameterType=\u0026#34;java.lang.Long\u0026#34;\u0026gt; \u0026lt;!-- WARNING - @mbggenerated This element is automatically generated by MyBatis Generator, do not modify. --\u0026gt; delete from t_test_user where id = #{id,jdbcType=BIGINT} \u0026lt;/delete\u0026gt; \u0026lt;insert id=\u0026#34;insert\u0026#34; parameterType=\u0026#34;com.example.demo.entity.TTestUser\u0026#34;\u0026gt; \u0026lt;!-- WARNING - @mbggenerated This element is automatically generated by MyBatis Generator, do not modify. --\u0026gt; insert into t_test_user (id, member_id, real_name, nickname, date_create, date_update, deleted) values (#{id,jdbcType=BIGINT}, #{memberId,jdbcType=BIGINT}, #{realName,jdbcType=VARCHAR}, #{nickname,jdbcType=VARCHAR}, #{dateCreate,jdbcType=TIMESTAMP}, #{dateUpdate,jdbcType=TIMESTAMP}, #{deleted,jdbcType=BIGINT}) \u0026lt;/insert\u0026gt; \u0026lt;insert id=\u0026#34;insertSelective\u0026#34; parameterType=\u0026#34;com.example.demo.entity.TTestUser\u0026#34;\u0026gt; \u0026lt;!-- WARNING - @mbggenerated This element is automatically generated by MyBatis Generator, do not modify. --\u0026gt; insert into t_test_user \u0026lt;trim prefix=\u0026#34;(\u0026#34; suffix=\u0026#34;)\u0026#34; suffixOverrides=\u0026#34;,\u0026#34;\u0026gt; \u0026lt;if test=\u0026#34;id != null\u0026#34;\u0026gt; id, \u0026lt;/if\u0026gt; \u0026lt;if test=\u0026#34;memberId != null\u0026#34;\u0026gt; member_id, \u0026lt;/if\u0026gt; \u0026lt;if test=\u0026#34;realName != null\u0026#34;\u0026gt; real_name, \u0026lt;/if\u0026gt; \u0026lt;if test=\u0026#34;nickname != null\u0026#34;\u0026gt; nickname, \u0026lt;/if\u0026gt; \u0026lt;if test=\u0026#34;dateCreate != null\u0026#34;\u0026gt; date_create, \u0026lt;/if\u0026gt; \u0026lt;if test=\u0026#34;dateUpdate != null\u0026#34;\u0026gt; date_update, \u0026lt;/if\u0026gt; \u0026lt;if test=\u0026#34;deleted != null\u0026#34;\u0026gt; deleted, \u0026lt;/if\u0026gt; \u0026lt;/trim\u0026gt; \u0026lt;trim prefix=\u0026#34;values (\u0026#34; suffix=\u0026#34;)\u0026#34; suffixOverrides=\u0026#34;,\u0026#34;\u0026gt; \u0026lt;if test=\u0026#34;id != null\u0026#34;\u0026gt; #{id,jdbcType=BIGINT}, \u0026lt;/if\u0026gt; \u0026lt;if test=\u0026#34;memberId != null\u0026#34;\u0026gt; #{memberId,jdbcType=BIGINT}, \u0026lt;/if\u0026gt; \u0026lt;if test=\u0026#34;realName != null\u0026#34;\u0026gt; #{realName,jdbcType=VARCHAR}, \u0026lt;/if\u0026gt; \u0026lt;if test=\u0026#34;nickname != null\u0026#34;\u0026gt; #{nickname,jdbcType=VARCHAR}, \u0026lt;/if\u0026gt; \u0026lt;if test=\u0026#34;dateCreate != null\u0026#34;\u0026gt; #{dateCreate,jdbcType=TIMESTAMP}, \u0026lt;/if\u0026gt; \u0026lt;if test=\u0026#34;dateUpdate != null\u0026#34;\u0026gt; #{dateUpdate,jdbcType=TIMESTAMP}, \u0026lt;/if\u0026gt; \u0026lt;if test=\u0026#34;deleted != null\u0026#34;\u0026gt; #{deleted,jdbcType=BIGINT}, \u0026lt;/if\u0026gt; \u0026lt;/trim\u0026gt; \u0026lt;/insert\u0026gt; \u0026lt;update id=\u0026#34;updateByPrimaryKeySelective\u0026#34; parameterType=\u0026#34;com.example.demo.entity.TTestUser\u0026#34;\u0026gt; \u0026lt;!-- WARNING - @mbggenerated This element is automatically generated by MyBatis Generator, do not modify. --\u0026gt; update t_test_user \u0026lt;set\u0026gt; \u0026lt;if test=\u0026#34;memberId != null\u0026#34;\u0026gt; member_id = #{memberId,jdbcType=BIGINT}, \u0026lt;/if\u0026gt; \u0026lt;if test=\u0026#34;realName != null\u0026#34;\u0026gt; real_name = #{realName,jdbcType=VARCHAR}, \u0026lt;/if\u0026gt; \u0026lt;if test=\u0026#34;nickname != null\u0026#34;\u0026gt; nickname = #{nickname,jdbcType=VARCHAR}, \u0026lt;/if\u0026gt; \u0026lt;if test=\u0026#34;dateCreate != null\u0026#34;\u0026gt; date_create = #{dateCreate,jdbcType=TIMESTAMP}, \u0026lt;/if\u0026gt; \u0026lt;if test=\u0026#34;dateUpdate != null\u0026#34;\u0026gt; date_update = #{dateUpdate,jdbcType=TIMESTAMP}, \u0026lt;/if\u0026gt; \u0026lt;if test=\u0026#34;deleted != null\u0026#34;\u0026gt; deleted = #{deleted,jdbcType=BIGINT}, \u0026lt;/if\u0026gt; \u0026lt;/set\u0026gt; where id = #{id,jdbcType=BIGINT} \u0026lt;/update\u0026gt; \u0026lt;update id=\u0026#34;updateByPrimaryKey\u0026#34; parameterType=\u0026#34;com.example.demo.entity.TTestUser\u0026#34;\u0026gt; \u0026lt;!-- WARNING - @mbggenerated This element is automatically generated by MyBatis Generator, do not modify. --\u0026gt; update t_test_user set member_id = #{memberId,jdbcType=BIGINT}, real_name = #{realName,jdbcType=VARCHAR}, nickname = #{nickname,jdbcType=VARCHAR}, date_create = #{dateCreate,jdbcType=TIMESTAMP}, date_update = #{dateUpdate,jdbcType=TIMESTAMP}, deleted = #{deleted,jdbcType=BIGINT} where id = #{id,jdbcType=BIGINT} \u0026lt;/update\u0026gt; \u0026lt;/mapper\u0026gt; 3.3.5 新增实体类 # package com.example.demo.entity; import java.io.Serializable; import java.util.Date; public class TTestUser implements Serializable { /** * This field was generated by MyBatis Generator. * This field corresponds to the database column t_test_user.id * * @mbggenerated */ private Long id; /** * This field was generated by MyBatis Generator. * This field corresponds to the database column t_test_user.member_id * * @mbggenerated */ private Long memberId; /** * This field was generated by MyBatis Generator. * This field corresponds to the database column t_test_user.real_name * * @mbggenerated */ private String realName; /** * This field was generated by MyBatis Generator. * This field corresponds to the database column t_test_user.nickname * * @mbggenerated */ private String nickname; /** * This field was generated by MyBatis Generator. * This field corresponds to the database column t_test_user.date_create * * @mbggenerated */ private Date dateCreate; /** * This field was generated by MyBatis Generator. * This field corresponds to the database column t_test_user.date_update * * @mbggenerated */ private Date dateUpdate; /** * This field was generated by MyBatis Generator. * This field corresponds to the database column t_test_user.deleted * * @mbggenerated */ private Long deleted; /** * This field was generated by MyBatis Generator. * This field corresponds to the database table t_test_user * * @mbggenerated */ private static final long serialVersionUID = 1L; /** * This method was generated by MyBatis Generator. * This method returns the value of the database column t_test_user.id * * @return the value of t_test_user.id * * @mbggenerated */ public Long getId() { return id; } /** * This method was generated by MyBatis Generator. * This method sets the value of the database column t_test_user.id * * @param id the value for t_test_user.id * * @mbggenerated */ public void setId(Long id) { this.id = id; } /** * This method was generated by MyBatis Generator. * This method returns the value of the database column t_test_user.member_id * * @return the value of t_test_user.member_id * * @mbggenerated */ public Long getMemberId() { return memberId; } /** * This method was generated by MyBatis Generator. * This method sets the value of the database column t_test_user.member_id * * @param memberId the value for t_test_user.member_id * * @mbggenerated */ public void setMemberId(Long memberId) { this.memberId = memberId; } /** * This method was generated by MyBatis Generator. * This method returns the value of the database column t_test_user.real_name * * @return the value of t_test_user.real_name * * @mbggenerated */ public String getRealName() { return realName; } /** * This method was generated by MyBatis Generator. * This method sets the value of the database column t_test_user.real_name * * @param realName the value for t_test_user.real_name * * @mbggenerated */ public void setRealName(String realName) { this.realName = realName == null ? null : realName.trim(); } /** * This method was generated by MyBatis Generator. * This method returns the value of the database column t_test_user.nickname * * @return the value of t_test_user.nickname * * @mbggenerated */ public String getNickname() { return nickname; } /** * This method was generated by MyBatis Generator. * This method sets the value of the database column t_test_user.nickname * * @param nickname the value for t_test_user.nickname * * @mbggenerated */ public void setNickname(String nickname) { this.nickname = nickname == null ? null : nickname.trim(); } /** * This method was generated by MyBatis Generator. * This method returns the value of the database column t_test_user.date_create * * @return the value of t_test_user.date_create * * @mbggenerated */ public Date getDateCreate() { return dateCreate; } /** * This method was generated by MyBatis Generator. * This method sets the value of the database column t_test_user.date_create * * @param dateCreate the value for t_test_user.date_create * * @mbggenerated */ public void setDateCreate(Date dateCreate) { this.dateCreate = dateCreate; } /** * This method was generated by MyBatis Generator. * This method returns the value of the database column t_test_user.date_update * * @return the value of t_test_user.date_update * * @mbggenerated */ public Date getDateUpdate() { return dateUpdate; } /** * This method was generated by MyBatis Generator. * This method sets the value of the database column t_test_user.date_update * * @param dateUpdate the value for t_test_user.date_update * * @mbggenerated */ public void setDateUpdate(Date dateUpdate) { this.dateUpdate = dateUpdate; } /** * This method was generated by MyBatis Generator. * This method returns the value of the database column t_test_user.deleted * * @return the value of t_test_user.deleted * * @mbggenerated */ public Long getDeleted() { return deleted; } /** * This method was generated by MyBatis Generator. * This method sets the value of the database column t_test_user.deleted * * @param deleted the value for t_test_user.deleted * * @mbggenerated */ public void setDeleted(Long deleted) { this.deleted = deleted; } /** * This method was generated by MyBatis Generator. * This method corresponds to the database table t_test_user * * @mbggenerated */ @Override public String toString() { StringBuilder sb = new StringBuilder(); sb.append(getClass().getSimpleName()); sb.append(\u0026#34; [\u0026#34;); sb.append(\u0026#34;Hash = \u0026#34;).append(hashCode()); sb.append(\u0026#34;, id=\u0026#34;).append(id); sb.append(\u0026#34;, memberId=\u0026#34;).append(memberId); sb.append(\u0026#34;, realName=\u0026#34;).append(realName); sb.append(\u0026#34;, nickname=\u0026#34;).append(nickname); sb.append(\u0026#34;, dateCreate=\u0026#34;).append(dateCreate); sb.append(\u0026#34;, dateUpdate=\u0026#34;).append(dateUpdate); sb.append(\u0026#34;, deleted=\u0026#34;).append(deleted); sb.append(\u0026#34;, serialVersionUID=\u0026#34;).append(serialVersionUID); sb.append(\u0026#34;]\u0026#34;); return sb.toString(); } } 3.3.6 执行查询 # @Slf4j public class MyBatisBootStrap { public static void main(String[] args) { try { // 1. 读取配置 InputStream inputStream = Resources.getResourceAsStream(\u0026#34;mybatis-config.xml\u0026#34;); // 2. 创建SqlSessionFactory工厂 SqlSessionFactory sqlSessionFactory = new SqlSessionFactoryBuilder().build(inputStream); // 3. 获取sqlSession SqlSession sqlSession = sqlSessionFactory.openSession(); // 4. 获取Mapper TTestUserMapper userMapper = sqlSession.getMapper(TTestUserMapper.class); // 5. 执行接口方法 TTestUser userInfo = userMapper.selectByPrimaryKey(16L); System.out.println(\u0026#34;userInfo = \u0026#34; + JSONUtil.toJsonStr(userInfo)); // 6. 提交事务 sqlSession.commit(); // 7. 关闭资源 sqlSession.close(); inputStream.close(); } catch (Exception e){ log.error(e.getMessage(), e); } } } 3.4 区别 # 发现没有在写MyBatis的时候,新增了dao, mapper.xml, entity, mybatis-config.xml等很多东西,工作量反而增大了。但是dao, mapper.xml, entity都是可以根据插件mybatis-generator生成的,我们也不用一一去创建,而且我们没有涉及到原生JDBC中加载驱动,创建连接,处理结果集,关闭连接等等这些操作,这些都是MyBatis帮我们做了,我们只用关心提供的查询接口和sql编写即可。\n如果使用原生的JDBC进行数据库操作,我们需要关心如何加载驱动,如何获取连接关闭连接,如何获取结果集等等与业务无关的地方,而MyBatis通过**“映射”这个核心概念将sql和java接口关联起来,我们调用java接口就相当于可以直接执行sql**,并且将结果映射为java pojo对象,这也是我们开头说的**“映射”,“面向对象的”**的原因了。\n4. 总结 # 这篇文章简单的介绍了下MyBatis的基本概念,并提供了简单的栗子,接下来几篇文章打算写下Mybatis的启动流程,让我们更好的了解下mybatis的各模块协作。\n"},{"id":285,"href":"/zh/docs/technology/Review/java_guide/lybly_framework/MyBatis/MyBatis-interview/","title":"Mybatis面试","section":"MyBatis","content":" 转载自https://github.com/Snailclimb/JavaGuide(添加小部分笔记)感谢作者!\n部分疑问参考自 https://blog.csdn.net/Gherbirthday0916 感谢作者!\n#{} 和 ${} 的区别是什么? # 注:这道题是面试官面试我同事的。\n答:\n${}是 Properties 文件中的变量占位符,它可以用于标签属性值和 sql 内部,属于静态文本替换,比如${driver}会被静态替换为com.mysql.jdbc. Driver。 #{}是 sql 的参数占位符,MyBatis 会将 sql 中的#{}替换为? 号,在 sql 执行前会使用 PreparedStatement 的参数设置方法,按序给 sql 的? 号占位符设置参数值,比如 ps.setInt(0, parameterValue),#{item.name} 的取值方式为使用反射从参数对象中获取 item 对象的 name 属性值,相当于 param.getItem().getName()。 [这里用到了反射] 在底层构造完整SQL语句时,MyBatis的两种传参方式所采取的方式不同。#{Parameter}采用预编译的方式构造SQL,避免了 SQL注入 的产生。而**${Parameter}采用拼接的方式构造SQL,在对用户输入过滤不严格**的前提下,此处很可能存在SQL注入\nxml 映射文件中,除了常见的 select、insert、update、delete 标签之外,还有哪些标签? # 注:这道题是京东面试官面试我时问的。\n答:还有很多其他的标签, \u0026lt;resultMap\u0026gt; 、 \u0026lt;parameterMap\u0026gt; 、 \u0026lt;sql\u0026gt; 、 \u0026lt;include\u0026gt; 、 \u0026lt;selectKey\u0026gt; ,加上动态 sql 的 9 个标签, trim|where|set|foreach|if|choose|when|otherwise|bind 等,其中 \u0026lt;sql\u0026gt; 为 sql 片段标签,通过 \u0026lt;include\u0026gt; 标签引入 sql 片段, \u0026lt;selectKey\u0026gt; 为不支持自增的主键生成策略标签。\nset标签,是update用的\n\u0026lt;update id=\u0026#34;updateUserById\u0026#34; parameterType=\u0026#34;user\u0026#34;\u0026gt; update user \u0026lt;set\u0026gt; \u0026lt;if test=\u0026#34;uid!=null\u0026#34;\u0026gt; uid=#{uid} \u0026lt;/if\u0026gt; \u0026lt;/set\u0026gt; \u0026lt;/update\u0026gt; ResultMap(结果集映射):\n假设我们的数据库字段和映射pojo类的属性字段不一致,那么查询结果,不一致的字段值会为null\n这时可以使用Mybatis ResultMap 结果集映射\nparameterMap(参数类型映射):\n很少使用,基本都用parameterType替代\nparameterMap标签可以用来定义参数组,可以为参数组指定ID、参数类型\n例如有一个bean是这样的:\npublic class ArgBean { private String name; private int age; // 忽略 getter 和 setter } 下面使用 \u0026lt;parameterMap\u0026gt; 将参数 ArgBean 对象进行映射\n\u0026lt;parameterMap id=\u0026#34;PARAM_MAP\u0026#34; type=\u0026#34;com.hxstrive.mybatis.parameter.demo2.ArgBean\u0026#34;\u0026gt; \u0026lt;parameter property=\u0026#34;age\u0026#34; javaType=\u0026#34;integer\u0026#34; /\u0026gt; \u0026lt;parameter property=\u0026#34;name\u0026#34; javaType=\u0026#34;String\u0026#34; /\u0026gt; \u0026lt;/parameterMap\u0026gt; sql(sql片段标签)/include(片段插入标签): 重复的SQL预计永远不可避免,\u0026lt;sql\u0026gt;标签就是用来解决这个问题的\n其中 \u0026lt;sql\u0026gt; 为 sql 片段标签,通过 \u0026lt;include\u0026gt; 标签引入 sql 片段\n例如:\n\u0026lt;mapper namespace=\u0026#34;com.klza.dao.UserMapper\u0026#34;\u0026gt; \u0026lt;sql id=\u0026#34;sqlUserParameter\u0026#34;\u0026gt;id,username,password\u0026lt;/sql\u0026gt; \u0026lt;select id=\u0026#34;getUserList\u0026#34; resultType=\u0026#34;user\u0026#34;\u0026gt; select \u0026lt;include refid=\u0026#34;sqlUserParameter\u0026#34;/\u0026gt; from test.user \u0026lt;/select\u0026gt; \u0026lt;/mapper\u0026gt; \u0026lt;selectKey\u0026gt; 为不支持自增的主键生成策略标签\n\u0026lt;insert id=\u0026#34;insert\u0026#34; parameterType=\u0026#34;com.pinyougou.pojo.TbGoods\u0026#34; \u0026gt; \u0026lt;selectKey resultType=\u0026#34;java.lang.Long\u0026#34; order=\u0026#34;AFTER\u0026#34; keyProperty=\u0026#34;id\u0026#34;\u0026gt; SELECT LAST_INSERT_ID() AS id \u0026lt;/selectKey\u0026gt; insert into tb_goods (id, seller_id ) values (#{id,jdbcType=BIGINT}, #{sellerId,jdbcType=VARCHAR} \u0026lt;/insert\u0026gt; Dao 接口的工作原理是什么?Dao 接口里的方法,参数不同时,方法能重载吗? # 注:这道题也是京东面试官面试我被问的。\n答:最佳实践中,通常一个 xml 映射文件,都会写一个 Dao 接口与之对应。Dao 接口就是人们常说的 Mapper 接口,接口的全限名,就是映射文件中的 namespace 的值,接口的方法名,就是映射文件中 MappedStatement 的 id 值,接口方法内的参数,就是传递给 sql 的参数。 Mapper 接口是没有实现类的,当调用接口方法时,接口全限名+方法名拼接字符串作为 key 值,可唯一定位一个 MappedStatement ,举例: com.mybatis3.mappers. StudentDao.findStudentById ,可以唯一找到 namespace 为 com.mybatis3.mappers. StudentDao 下面 id = findStudentById 的 MappedStatement 。在 MyBatis 中,每一个 \u0026lt;select\u0026gt; 、 \u0026lt;insert\u0026gt; 、 \u0026lt;update\u0026gt; 、 \u0026lt;delete\u0026gt; 标签,都会被解析为一个 MappedStatement 对象。\nDao 接口里的方法,是不能重载的,因为是全限名+方法名的保存和寻找策略。\nDao 接口里的方法可以重载,但是 Mybatis 的 xml 里面的 ID 不允许重复。\nMybatis 版本 3.3.0,亲测如下:\n/** * Mapper接口里面方法重载 */ public interface StuMapper { List\u0026lt;Student\u0026gt; getAllStu(); List\u0026lt;Student\u0026gt; getAllStu(@Param(\u0026#34;id\u0026#34;) Integer id); } 然后在 StuMapper.xml 中利用 Mybatis 的动态 sql 就可以实现。\n\u0026lt;select id=\u0026#34;getAllStu\u0026#34; resultType=\u0026#34;com.pojo.Student\u0026#34;\u0026gt; select * from student \u0026lt;where\u0026gt; \u0026lt;if test=\u0026#34;id != null\u0026#34;\u0026gt; id = #{id} \u0026lt;/if\u0026gt; \u0026lt;/where\u0026gt; \u0026lt;/select\u0026gt; 能正常运行,并能得到相应的结果,这样就实现了在 Dao 接口中写重载方法。\nMybatis 的 Dao 接口可以有多个重载方法,但是多个接口对应的映射必须只有一个,否则启动会报错。\n相关 issue : 更正:Dao 接口里的方法可以重载,但是 Mybatis 的 xml 里面的 ID 不允许重复!。\nDao 接口的工作原理是 JDK 动态代理,MyBatis 运行时会使用 JDK 动态代理为 Dao 接口生成代理 proxy 对象,代理对象 proxy 会拦截接口方法,转而执行 MappedStatement 所代表的 sql,然后将 sql 执行结果返回。\n补充 :\nDao 接口方法可以重载,但是需要满足以下条件:\n仅有一个无参方法和一个有参方法 (多个参数)的方法中,参数数量必须(和xml中的)一致。且使用相同的 @Param ,或者使用 param1 这种 测试如下 :\nPersonDao.java Person queryById(); Person queryById(@Param(\u0026#34;id\u0026#34;) Long id); Person queryById(@Param(\u0026#34;id\u0026#34;) Long id, @Param(\u0026#34;name\u0026#34;) String name); PersonMapper.xml \u0026lt;select id=\u0026#34;queryById\u0026#34; resultMap=\u0026#34;PersonMap\u0026#34;\u0026gt; select id, name, age, address from person \u0026lt;where\u0026gt; \u0026lt;if test=\u0026#34;id != null\u0026#34;\u0026gt; id = #{id} \u0026lt;/if\u0026gt; \u0026lt;if test=\u0026#34;name != null and name != \u0026#39;\u0026#39;\u0026#34;\u0026gt; and name = #{name} \u0026lt;/if\u0026gt; \u0026lt;/where\u0026gt; limit 1 \u0026lt;/select\u0026gt; org.apache.ibatis.scripting.xmltags. DynamicContext. ContextAccessor#getProperty 方法用于获取 \u0026lt;if\u0026gt; 标签中的条件值\nContextAccessor 这个修饰符为默认(同一个包内)\npublic Object getProperty(Map context, Object target, Object name) { Map map = (Map) target; Object result = map.get(name); if (map.containsKey(name) || result != null) { return result; } Object parameterObject = map.get(PARAMETER_OBJECT_KEY); if (parameterObject instanceof Map) { return ((Map)parameterObject).get(name); } return null; } parameterObject 为 map,存放的是 Dao 接口中参数相关信息。\n((Map)parameterObject).get(name) 方法如下\npublic V get(Object key) { if (!super.containsKey(key)) { throw new BindingException(\u0026#34;Parameter \u0026#39;\u0026#34; + key + \u0026#34;\u0026#39; not found. Available parameters are \u0026#34; + keySet()); } return super.get(key); } queryById()方法执行时,parameterObject为 null,getProperty方法返回 null 值,\u0026lt;if\u0026gt;标签获取的所有条件值都为 null,所有条件不成立,动态 sql 可以正常执行。 queryById(1L)方法执行时,parameterObject为 map,包含了**id和param1**两个 key 值。当获取\u0026lt;if\u0026gt;标签中name的属性值时,进入((Map)parameterObject).get(name)方法中,map 中 key 不包含name,所以抛出异常。 queryById(1L,\u0026quot;1\u0026quot;)方法执行时,parameterObject中包含id,param1,name,param2四个 key 值,id和name属性都可以获取到,动态 sql 正常执行。 也就是说,if的test一定是会进行判断的(除非整个parameterObject为null)。但是如果这里面的param 不存在,那么就会抛异常 (BindingException)\nMyBatis 是如何进行分页的?分页插件的原理是什么? # 注:我出的。\n答:(1) MyBatis 使用 RowBounds 对象进行分页,它是针对 ResultSet 结果集执行的内存分页,而非物理分页;\n//使用 //Mapper中 List\u0026lt;User\u0026gt; getUserListLimit(RowBounds rowBounds); //Mapper.xml定义 (不变) \u0026lt;select id=\u0026#34;getUserListLimit\u0026#34; resultType=\u0026#34;user\u0026#34;\u0026gt; select * from test.user \u0026lt;/select\u0026gt; //使用, 将会从index为0的记录开始,取两条记录 List\u0026lt;User\u0026gt; userListLimit = userMapper.getUserListLimit(new RowBounds(0, 2)); for (User user : userListLimit) { System.out.println(user); } 想要使用mybatis日志,只要加上日志模块的依赖即可\n\u0026lt;dependency\u0026gt; \u0026lt;groupId\u0026gt;org.slf4j\u0026lt;/groupId\u0026gt; \u0026lt;artifactId\u0026gt;slf4j-api\u0026lt;/artifactId\u0026gt; \u0026lt;version\u0026gt;1.7.30\u0026lt;/version\u0026gt; \u0026lt;/dependency\u0026gt; \u0026lt;!-- https://mvnrepository.com/artifact/ch.qos.logback/logback-classic --\u0026gt; \u0026lt;dependency\u0026gt; \u0026lt;groupId\u0026gt;ch.qos.logback\u0026lt;/groupId\u0026gt; \u0026lt;artifactId\u0026gt;logback-classic\u0026lt;/artifactId\u0026gt; \u0026lt;version\u0026gt;1.2.3\u0026lt;/version\u0026gt; \u0026lt;/dependency\u0026gt; 查看上面的日志可以发现,实际查找的是全部的数据(没有使用物理分页)\n14:28:14.938 [main] DEBUG org.mybatis.example.BlogMapper.selectBlog - ==\u0026gt; Preparing: select * from Blog 14:28:14.996 [main] DEBUG org.mybatis.example.BlogMapper.selectBlog - ==\u0026gt; Parameters: [Blog{id=2, name=\u0026#39;n2\u0026#39;, age=20}, Blog{id=3, name=\u0026#39;n3\u0026#39;, age=30}] (2) 可以在 sql 内直接书写带有物理分页的参数来完成物理分页功能\n(3) 也可以使用分页插件来完成物理分页\n分页插件的基本原理是使用 MyBatis 提供的插件接口,实现自定义插件,在插件的拦截方法内拦截待执行的 sql,然后重写 sql,根据 dialect 方言,添加对应的物理分页语句和物理分页参数。\n举例: select _ from student ,拦截 sql 后重写为: select t._ from (select \\* from student)t limit 0,10\n分页插件的使用\n接下来介绍PageHelper插件的使用:\n第一步,引入依赖:\n\u0026lt;!-- https://mvnrepository.com/artifact/com.github.pagehelper/pagehelper --\u0026gt; \u0026lt;dependency\u0026gt; \u0026lt;groupId\u0026gt;com.github.pagehelper\u0026lt;/groupId\u0026gt; \u0026lt;artifactId\u0026gt;pagehelper\u0026lt;/artifactId\u0026gt; \u0026lt;version\u0026gt;5.3.2\u0026lt;/version\u0026gt; \u0026lt;/dependency\u0026gt; 第二步,mybatis-config.xml配置拦截器:\n\u0026lt;!-- 配置pageHelper拦截器 --\u0026gt; \u0026lt;plugins\u0026gt; \u0026lt;plugin interceptor=\u0026#34;com.github.pagehelper.PageInterceptor\u0026#34;/\u0026gt; \u0026lt;/plugins\u0026gt; 第三步,代码编写:\nMapper接口类:\nList\u0026lt;User\u0026gt; getUserListByPageHelper(); Mapper.xml:\n\u0026lt;select id=\u0026#34;getUserListByPageHelper\u0026#34; resultType=\u0026#34;user\u0026#34;\u0026gt; select * from test.user \u0026lt;/select\u0026gt; 测试程序:\n// 开启分页功能 int pageNum = 1; // 当前页码 int pageSize = 2; // 每页的记录数 PageHelper.startPage(pageNum, pageSize); List\u0026lt;User\u0026gt; userListByPageHelper = userMapper.getUserListByPageHelper(); userListByPageHelper.forEach(System.out::println); 第四步:获取pageInfo信息:\npageHelper真正强大的地方在于它的pageInfo功能,它可以为我们提供详细的分页数据:\n例如:\n// 开启分页功能 int pageNum = 2; // 当前页码 int pageSize = 5; // 每页的记录数 PageHelper.startPage(pageNum, pageSize); List\u0026lt;User\u0026gt; userListByPageHelper = userMapper.getUserListByPageHelper(); // 设置导航的卡片数为3 PageInfo\u0026lt;User\u0026gt; userPageInfo = new PageInfo\u0026lt;\u0026gt;(userListByPageHelper, 3); System.out.println(userPageInfo); /* * PageInfo{pageNum=2, pageSize=5, size=5, startRow=6, endRow=10, total=1004, pages=201, * list=Page{count=true, pageNum=2, pageSize=5, startRow=5, endRow=10, total=1004, pages=201, reasonable=false, pageSizeZero=false} * [User(id=6, username=Cheng Zhennan, password=Jx3SLGXeS4), User(id=7, username=Thelma Hernandez, password=VxVO6dEgym), User(id=8, username=Emma Wood, password=XljUnUrnFZ), User(id=9, username=Kikuchi Akina, password=IgditeatR7), User(id=10, username=Miura Kenta, password=2CbmTGczZv)], * prePage=1, nextPage=3, isFirstPage=false, isLastPage=false, hasPreviousPage=true, hasNextPage=true, navigatePages=3, navigateFirstPage=1, navigateLastPage=3, navigatepageNums=[1, 2, 3]} 简述 MyBatis 的插件运行原理,以及如何编写一个插件。 # 注:我出的。\n答:MyBatis 仅可以编写针对 ParameterHandler 、 ResultSetHandler 、 StatementHandler 、 Executor 这 4 种接口的插件,MyBatis 使用 JDK 的动态代理,为需要拦截的接口生成代理对象以实现接口方法拦截功能,每当执行这 4 种接口对象的方法时,就会进入拦截方法,具体就是 InvocationHandler 的 invoke() 方法,当然,只会拦截那些你指定需要拦截的方法。\n实现 MyBatis 的 Interceptor 接口并复写 intercept() 方法,然后在给插件编写注解,指定要拦截哪一个接口的哪些方法即可,记住,别忘了在配置文件中配置你编写的插件。\nMyBatis 执行批量插入,能返回数据库主键列表吗? # 注:我出的。\n答:能,JDBC 都能,MyBatis 当然也能。\nMyBatis 动态 sql 是做什么的?都有哪些动态 sql?能简述一下动态 sql 的执行原理不? # 注:我出的。\n答:MyBatis 动态 sql 可以让我们在 xml 映射文件内,以标签的形式编写动态 sql,完成逻辑判断和动态拼接 sql 的功能。其执行原理为,使用 OGNL 从 sql 参数对象中计算表达式的值,根据表达式的值动态拼接 sql,以此来完成动态 sql 的功能。\nMyBatis 提供了 9 种动态 sql 标签:\n\u0026lt;if\u0026gt;\u0026lt;/if\u0026gt; \u0026lt;where\u0026gt;\u0026lt;/where\u0026gt;(trim,set) \u0026lt;choose\u0026gt;\u0026lt;/choose\u0026gt;(when, otherwise) \u0026lt;foreach\u0026gt;\u0026lt;/foreach\u0026gt; \u0026lt;bind/\u0026gt; 关于 MyBatis 动态 SQL 的详细介绍,请看这篇文章: Mybatis 系列全解(八):Mybatis 的 9 大动态 SQL 标签你知道几个? 。\n关于这些动态 SQL 的具体使用方法,请看这篇文章: Mybatis【13】\u0026ndash; Mybatis 动态 sql 标签怎么使用?\nMyBatis 是如何将 sql 执行结果封装为目标对象并返回的?都有哪些映射形式? # 注:我出的。\n答:第一种是使用 \u0026lt;resultMap\u0026gt; 标签,逐一定义列名和对象属性名之间的映射关系。第二种是使用 sql 列的别名功能,将列别名书写为对象属性名,比如 T_NAME AS NAME,对象属性名一般是 name,小写,但是列名不区分大小写,MyBatis 会忽略列名大小写,智能找到与之对应对象属性名,你甚至可以写成 T_NAME AS NaMe,MyBatis 一样可以正常工作。\n有了列名与属性名的映射关系后,MyBatis 通过反射创建对象,同时使用反射 给对象的属性逐一赋值并返回,那些找不到映射关系的属性,是无法完成赋值的。\nMyBatis 能执行一对一、一对多的关联查询吗?都有哪些实现方式,以及它们之间的区别。【实际没有用过】 # 注:我出的。\n答:能,MyBatis 不仅可以执行一对一、一对多的关联查询,还可以执行多对一,多对多的关联查询,多对一查询,其实就是一对一查询,只需要把 selectOne() 修改为 selectList() 即可;多对多查询,其实就是一对多查询,只需要把 selectOne() 修改为 selectList() 即可。\n关联对象查询,有两种实现方式,一种是单独发送一个 sql 去查询关联对象,赋给主对象,然后返回主对象。另一种是使用嵌套查询,嵌套查询的含义为使用 join 查询,一部分列是 A 对象的属性值,另外一部分列是关联对象 B 的属性值,好处是只发一个 sql 查询,就可以把主对象和其关联对象查出来。\n那么问题来了,join 查询出来 100 条记录,如何确定主对象是 5 个,而不是 100 个?其去重复的原理是 \u0026lt;resultMap\u0026gt; 标签内的 \u0026lt;id\u0026gt; 子标签,指定了唯一确定一条记录的 id 列,MyBatis 根据 \u0026lt;id\u0026gt; 列值来完成 100 条记录的去重复功能, \u0026lt;id\u0026gt; 可以有多个,代表了联合主键的语意。\n同样主对象的关联对象,也是根据这个原理去重复的,尽管一般情况下,只有主对象会有重复记录,关联对象一般不会重复。\n举例:下面 join 查询出来 6 条记录,一、二列是 Teacher 对象列,第三列为 Student 对象列,MyBatis 去重复处理后,结果为 1 个老师 6 个学生,而不是 6 个老师 6 个学生。\nt_id t_name s_id 1 teacher 38 1 teacher 39 1 teacher 40 1 teacher 41 1 teacher 42 1 teacher 43 MyBatis 是否支持延迟加载?如果支持,它的实现原理是什么? # 注:我出的。\n答:MyBatis 仅支持 association 关联对象和 collection 关联集合对象的延迟加载,association 指的就是一对一,collection 指的就是一对多查询。在 MyBatis 配置文件中,可以配置是否启用延迟加载 lazyLoadingEnabled=true|false。\n它的原理是,使用 CGLIB 创建目标对象的代理对象,当调用目标方法时,进入拦截器方法,比如调用 a.getB().getName() ,拦截器 invoke() 方法发现 a.getB() 是 null 值,那么就会单独发送事先保存好的查询关联 B 对象的 sql,把 B 查询上来,然后调用 a.setB(b),于是 a 的对象 b 属性就有值了,接着完成 a.getB().getName() 方法的调用。这就是延迟加载的基本原理。\n当然了,不光是 MyBatis,几乎所有的包括 Hibernate,支持延迟加载的原理都是一样的。\nMyBatis 的 xml 映射文件中,不同的 xml 映射文件,id 是否可以重复? # 注:我出的。\n答:不同的 xml 映射文件,如果配置了 namespace,那么 id 可以重复;如果没有配置 namespace,那么 id 不能重复;毕竟 namespace 不是必须的,只是最佳实践而已。\n原因就是 namespace+id 是作为 Map\u0026lt;String, MappedStatement\u0026gt; 的 key 使用的,如果没有 namespace,就剩下 id,那么,id 重复会导致数据互相覆盖。有了 namespace,自然 id 就可以重复,namespace 不同,namespace+id 自然也就不同。\nMyBatis 中如何执行批处理? # 注:我出的。\n答:使用 BatchExecutor 完成批处理。\nMyBatis 都有哪些 Executor 执行器?它们之间的区别是什么? # 注:我出的\n答:MyBatis 有三种基本的 Executor 执行器:\nSimpleExecutor: 每执行一次 update 或 select,就开启一个 Statement 对象,用完立刻关闭 Statement 对象。 ReuseExecutor: 执行 update 或 select,以 sql 作为 key 查找 Statement 对象,存在就使用,不存在就创建,用完后,不关闭 Statement 对象,而是放置于 Map\u0026lt;String, Statement\u0026gt;内,供下一次使用。简言之,就是重复使用 Statement 对象。 BatchExecutor :执行 update(没有 select,JDBC 批处理不支持 select),将所有 sql 都添加到批处理中(addBatch()),等待统一执行(executeBatch()),它缓存了多个 Statement 对象,每个 Statement 对象都是 addBatch()完毕后,等待逐一执行 executeBatch()批处理。与 JDBC 批处理相同。 作用范围:Executor 的这些特点,都严格限制在 SqlSession 生命周期范围内。\nMyBatis 中如何指定使用哪一种 Executor 执行器? # 注:我出的\n答:在 MyBatis 配置文件中,可以指定默认的 ExecutorType 执行器类型,也可以手动给 DefaultSqlSessionFactory 的创建 SqlSession 的方法传递 ExecutorType 类型参数。\nMyBatis 是否可以映射 Enum 枚举类? # 注:我出的\n答:MyBatis 可以映射枚举类,不单可以映射枚举类,MyBatis 可以映射任何对象到表的一列上。映射方式为自定义一个 TypeHandler ,实现 TypeHandler 的 setParameter() 和 getResult() 接口方法。 TypeHandler 有两个作用:\n一是完成从 javaType 至 jdbcType 的转换; 二是完成 jdbcType 至 javaType 的转换,体现为 setParameter() 和 getResult() 两个方法,分别代表设置 sql 问号占位符参数和获取列查询结果。 MyBatis 映射文件中,如果 A 标签通过 include 引用了 B 标签的内容,请问,B 标签能否定义在 A 标签的后面,还是说必须定义在 A 标签的前面? # 注:我出的\n答:虽然 MyBatis 解析 xml 映射文件是按照顺序解析的,但是,被引用的 B 标签依然可以定义在任何地方,MyBatis 都可以正确识别。\n原理是,MyBatis 解析 A 标签,发现 A 标签引用了 B 标签,但是 B 标签尚未解析到,尚不存在,此时,MyBatis 会将 A 标签标记为未解析状态,然后继续解析余下的标签,包含 B 标签,待所有标签解析完毕,MyBatis 会重新解析那些被标记为未解析的标签,此时再解析 A 标签时,B 标签已经存在,A 标签也就可以正常解析完成了。\n简述 MyBatis 的 xml 映射文件和 MyBatis 内部数据结构之间的映射关系?[不懂] # 注:我出的\n答:MyBatis 将所有 xml 配置信息都封装到 All-In-One 重量级对象 Configuration 内部。在 xml 映射文件中, \u0026lt;parameterMap\u0026gt; 标签会被解析为 ParameterMap 对象,其每个子元素会被解析为 ParameterMapping 对象。 \u0026lt;resultMap\u0026gt; 标签会被解析为 ResultMap 对象,其每个子元素会被解析为 ResultMapping 对象。每一个 \u0026lt;select\u0026gt;、\u0026lt;insert\u0026gt;、\u0026lt;update\u0026gt;、\u0026lt;delete\u0026gt; 标签均会被解析为 MappedStatement 对象,标签内的 sql 会被解析为 BoundSql 对象。\n为什么说 MyBatis 是半自动 ORM 映射工具?它与全自动的区别在哪里? # 注:我出的\n答:Hibernate 属于全自动 ORM 映射工具,使用 Hibernate 查询关联对象或者关联集合对象时,可以根据对象关系模型直接获取,所以它是全自动的。而 MyBatis 在查询关联对象或关联集合对象时,需要手动编写 sql 来完成,所以,称之为半自动 ORM 映射工具。\n面试题看似都很简单,但是想要能正确回答上来,必定是研究过源码且深入的人,而不是仅会使用的人或者用的很熟的人,以上所有面试题及其答案所涉及的内容,在我的 MyBatis 系列博客中都有详细讲解和原理分析。\n"},{"id":286,"href":"/zh/docs/technology/Review/java_guide/lybly_framework/conditional_on_class/","title":"ConditionalOnClass实践","section":"框架","content":" 两个测试方向 # 方向1:两个maven项目 # 详见git上的 conditional_on_class_main 项目以及 conditional_on_class2 项目\n基础maven项目 conditional_on_class2\npom文件\n\u0026lt;?xml version=\u0026#34;1.0\u0026#34; encoding=\u0026#34;UTF-8\u0026#34;?\u0026gt; \u0026lt;project xmlns=\u0026#34;http://maven.apache.org/POM/4.0.0\u0026#34; xmlns:xsi=\u0026#34;http://www.w3.org/2001/XMLSchema-instance\u0026#34; xsi:schemaLocation=\u0026#34;http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd\u0026#34;\u0026gt; \u0026lt;modelVersion\u0026gt;4.0.0\u0026lt;/modelVersion\u0026gt; \u0026lt;groupId\u0026gt;org.example\u0026lt;/groupId\u0026gt; \u0026lt;artifactId\u0026gt;conditional_on_class_2\u0026lt;/artifactId\u0026gt; \u0026lt;version\u0026gt;1.0-SNAPSHOT\u0026lt;/version\u0026gt; \u0026lt;/project\u0026gt; java类\npackage com; public class LyReferenceImpl { public String sayWord() { return \u0026#34;hello one\u0026#34;; } } 简单的SpringBoot项目 conditional_on_class_main\n\u0026lt;!--pom文件--\u0026gt; \u0026lt;?xml version=\u0026#34;1.0\u0026#34; encoding=\u0026#34;UTF-8\u0026#34;?\u0026gt; \u0026lt;project xmlns=\u0026#34;http://maven.apache.org/POM/4.0.0\u0026#34; xmlns:xsi=\u0026#34;http://www.w3.org/2001/XMLSchema-instance\u0026#34; xsi:schemaLocation=\u0026#34;http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd\u0026#34;\u0026gt; \u0026lt;modelVersion\u0026gt;4.0.0\u0026lt;/modelVersion\u0026gt; \u0026lt;groupId\u0026gt;org.example\u0026lt;/groupId\u0026gt; \u0026lt;artifactId\u0026gt;conditional_on_class_main\u0026lt;/artifactId\u0026gt; \u0026lt;version\u0026gt;1.0-SNAPSHOT\u0026lt;/version\u0026gt; \u0026lt;parent\u0026gt; \u0026lt;groupId\u0026gt;org.springframework.boot\u0026lt;/groupId\u0026gt; \u0026lt;artifactId\u0026gt;spring-boot-starter-parent\u0026lt;/artifactId\u0026gt; \u0026lt;version\u0026gt;2.7.8\u0026lt;/version\u0026gt; \u0026lt;/parent\u0026gt; \u0026lt;dependencies\u0026gt; \u0026lt;!--把1配置的bean引用进来--\u0026gt; \u0026lt;dependency\u0026gt; \u0026lt;groupId\u0026gt;org.example\u0026lt;/groupId\u0026gt; \u0026lt;artifactId\u0026gt;conditional_on_class_2\u0026lt;/artifactId\u0026gt; \u0026lt;version\u0026gt;1.0-SNAPSHOT\u0026lt;/version\u0026gt; \u0026lt;scope\u0026gt;provided\u0026lt;/scope\u0026gt; \u0026lt;optional\u0026gt;true\u0026lt;/optional\u0026gt; \u0026lt;/dependency\u0026gt; \u0026lt;dependency\u0026gt; \u0026lt;groupId\u0026gt;org.springframework.boot\u0026lt;/groupId\u0026gt; \u0026lt;artifactId\u0026gt;spring-boot-starter-web\u0026lt;/artifactId\u0026gt; \u0026lt;/dependency\u0026gt; \u0026lt;/dependencies\u0026gt; \u0026lt;build\u0026gt; \u0026lt;plugins\u0026gt; \u0026lt;plugin\u0026gt; \u0026lt;groupId\u0026gt;org.springframework.boot\u0026lt;/groupId\u0026gt; \u0026lt;artifactId\u0026gt;spring-boot-maven-plugin\u0026lt;/artifactId\u0026gt; \u0026lt;configuration\u0026gt; \u0026lt;excludes\u0026gt; \u0026lt;!-- 默认会将conditional_on_class_2 打包进去,现在会配置SayExist 如果放开注释,那么会配置SayNotExist--\u0026gt; \u0026lt;!--\u0026lt;dependency\u0026gt; \u0026lt;groupId\u0026gt;org.example\u0026lt;/groupId\u0026gt; \u0026lt;artifactId\u0026gt;conditional_on_class_2\u0026lt;/artifactId\u0026gt; \u0026lt;/dependency\u0026gt;--\u0026gt; \u0026lt;/excludes\u0026gt; \u0026lt;jvmArguments\u0026gt;-Dfile.encoding=UTF-8\u0026lt;/jvmArguments\u0026gt; \u0026lt;/configuration\u0026gt; \u0026lt;executions\u0026gt; \u0026lt;execution\u0026gt; \u0026lt;goals\u0026gt; \u0026lt;goal\u0026gt;repackage\u0026lt;/goal\u0026gt;\u0026lt;!--可以把依赖的包都打包到生成的Jar包中 --\u0026gt; \u0026lt;/goals\u0026gt; \u0026lt;/execution\u0026gt; \u0026lt;/executions\u0026gt; \u0026lt;/plugin\u0026gt; \u0026lt;/plugins\u0026gt; \u0026lt;/build\u0026gt; \u0026lt;/project\u0026gt; //两个配置类 //配置类1 package com.config; import com.service.ISay; import com.service.SayExist; import org.springframework.boot.autoconfigure.condition.ConditionalOnClass; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; @Configuration //不要放在方法里面,否则会报错\u0026#34;java.lang.ArrayStoreException: sun.reflect.annotation.TypeNotPresentExceptionProxy\u0026#34; @ConditionalOnClass(value = com.LyReferenceImpl.class) public class ExistConfiguration { @Bean public ISay getISay1(){ return new SayExist(); } } //配置类2 package com.config; import com.service.ISay; import com.service.SayNotExist; import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingClass; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; @Configuration @ConditionalOnMissingClass(\u0026#34;com.LyReferenceImpl\u0026#34;) public class NotExistConfiguration { @Bean public ISay getISay1(){ return new SayNotExist(); } } 方向2:3个maven项目(建议用这个理解) # 注意,这里可能还漏了一个问题,那就是 这个conditional_on_class1 的configuration之所以能够被自动装配,是因为和 conditional_on_class_main1的Application类是同一个包,所以不用特殊处理。如果是其他包名的话,那么是需要用到spring boot的自动装配机制的:在conditional_on_class1 工程的 resources 包下创建META-INF/spring.factories,并写上Config类的全类名\n详见 git上的 conditional_on_class_main1, conditional_on_class1 项目以及 conditional_on_class2 项目\n基础 conditional_on_class2\npom文件\n\u0026lt;?xml version=\u0026#34;1.0\u0026#34; encoding=\u0026#34;UTF-8\u0026#34;?\u0026gt; \u0026lt;project xmlns=\u0026#34;http://maven.apache.org/POM/4.0.0\u0026#34; xmlns:xsi=\u0026#34;http://www.w3.org/2001/XMLSchema-instance\u0026#34; xsi:schemaLocation=\u0026#34;http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd\u0026#34;\u0026gt; \u0026lt;modelVersion\u0026gt;4.0.0\u0026lt;/modelVersion\u0026gt; \u0026lt;groupId\u0026gt;org.example\u0026lt;/groupId\u0026gt; \u0026lt;artifactId\u0026gt;conditional_on_class_2\u0026lt;/artifactId\u0026gt; \u0026lt;version\u0026gt;1.0-SNAPSHOT\u0026lt;/version\u0026gt; \u0026lt;/project\u0026gt; java类\npackage com; public class LyReferenceImpl { public String sayWord() { return \u0026#34;hello one\u0026#34;; } } 以LyReferenceImpl.class存不存在,决定创建哪个bean\nconditional_on_class_1 pom.xml\n\u0026lt;?xml version=\u0026#34;1.0\u0026#34; encoding=\u0026#34;UTF-8\u0026#34;?\u0026gt; \u0026lt;project xmlns=\u0026#34;http://maven.apache.org/POM/4.0.0\u0026#34; xmlns:xsi=\u0026#34;http://www.w3.org/2001/XMLSchema-instance\u0026#34; xsi:schemaLocation=\u0026#34;http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd\u0026#34;\u0026gt; \u0026lt;modelVersion\u0026gt;4.0.0\u0026lt;/modelVersion\u0026gt; \u0026lt;groupId\u0026gt;org.example\u0026lt;/groupId\u0026gt; \u0026lt;artifactId\u0026gt;conditional_on_class_1\u0026lt;/artifactId\u0026gt; \u0026lt;version\u0026gt;1.0-SNAPSHOT\u0026lt;/version\u0026gt; \u0026lt;dependencies\u0026gt; \u0026lt;!--引入被引用的类,只在编译期存在--\u0026gt; \u0026lt;dependency\u0026gt; \u0026lt;groupId\u0026gt;org.example\u0026lt;/groupId\u0026gt; \u0026lt;artifactId\u0026gt;conditional_on_class_2\u0026lt;/artifactId\u0026gt; \u0026lt;version\u0026gt;1.0-SNAPSHOT\u0026lt;/version\u0026gt; \u0026lt;scope\u0026gt;provided\u0026lt;/scope\u0026gt; \u0026lt;optional\u0026gt;true\u0026lt;/optional\u0026gt; \u0026lt;/dependency\u0026gt; \u0026lt;!-- https://mvnrepository.com/artifact/org.springframework.boot/spring-boot-autoconfigure --\u0026gt; \u0026lt;dependency\u0026gt; \u0026lt;groupId\u0026gt;org.springframework.boot\u0026lt;/groupId\u0026gt; \u0026lt;artifactId\u0026gt;spring-boot-autoconfigure\u0026lt;/artifactId\u0026gt; \u0026lt;version\u0026gt;2.7.8\u0026lt;/version\u0026gt; \u0026lt;/dependency\u0026gt; \u0026lt;/dependencies\u0026gt; \u0026lt;/project\u0026gt; //根据是否存在class_2中的类,进行自动装配 @Configuration //不要放在方法里面,否则会报错\u0026#34;java.lang.ArrayStoreException: sun.reflect.annotation.TypeNotPresentExceptionProxy\u0026#34; @ConditionalOnClass(value = com.LyReferenceImpl.class) public class ExistConfiguration { @Bean public LyEntity lyEntity1(){ return new LyEntity(\u0026#34;存在\u0026#34;); } } @Configuration @ConditionalOnMissingClass(\u0026#34;com.LyReferenceImpl\u0026#34;) public class NotExistConfiguration { @Bean public LyEntity lyEntity1(){ return new LyEntity(\u0026#34;不存在\u0026#34;); } } //基础类 public class LyEntity { private String name; private Integer age; public LyEntity(String name) { this.name = name; } public String getName() { return name; } public void setName(String name) { this.name = name; } public Integer getAge() { return age; } public void setAge(Integer age) { this.age = age; } } 使用 在_main项目中 pom.xml\n\u0026lt;parent\u0026gt; \u0026lt;groupId\u0026gt;org.springframework.boot\u0026lt;/groupId\u0026gt; \u0026lt;artifactId\u0026gt;spring-boot-starter-parent\u0026lt;/artifactId\u0026gt; \u0026lt;version\u0026gt;2.7.8\u0026lt;/version\u0026gt; \u0026lt;/parent\u0026gt; \u0026lt;dependencies\u0026gt; \u0026lt;!--把1配置的bean引用进来--\u0026gt; \u0026lt;dependency\u0026gt; \u0026lt;groupId\u0026gt;org.example\u0026lt;/groupId\u0026gt; \u0026lt;artifactId\u0026gt;conditional_on_class_1\u0026lt;/artifactId\u0026gt; \u0026lt;version\u0026gt;1.0-SNAPSHOT\u0026lt;/version\u0026gt; \u0026lt;/dependency\u0026gt; \u0026lt;!--加上这个则会提示存在--\u0026gt; \u0026lt;!-- \u0026lt;dependency\u0026gt; \u0026lt;groupId\u0026gt;org.example\u0026lt;/groupId\u0026gt; \u0026lt;artifactId\u0026gt;conditional_on_class_2\u0026lt;/artifactId\u0026gt; \u0026lt;version\u0026gt;1.0-SNAPSHOT\u0026lt;/version\u0026gt; \u0026lt;/dependency\u0026gt; --\u0026gt; \u0026lt;dependency\u0026gt; \u0026lt;groupId\u0026gt;org.springframework.boot\u0026lt;/groupId\u0026gt; \u0026lt;artifactId\u0026gt;spring-boot-starter-web\u0026lt;/artifactId\u0026gt; \u0026lt;/dependency\u0026gt; \u0026lt;/dependencies\u0026gt; 如果不存在class_2中的类,则提示不存在;如果存在则提示存在\n@SpringBootApplication @RestController public class MyApplication { @Autowired private LyEntity lyEntity; @RequestMapping(\u0026#34;hello\u0026#34;) public String hello(){ return lyEntity.getName(); } public static void main(String[] args) { SpringApplication.run(MyApplication.class,args); } } "},{"id":287,"href":"/zh/docs/technology/Review/java_guide/lybly_framework/ly05ly_springboot-auto-assembly/","title":"SpringBoot自动装配原理","section":"框架","content":" 转载自https://github.com/Snailclimb/JavaGuide(添加小部分笔记)感谢作者!\n每次问到 Spring Boot, 面试官非常喜欢问这个问题:“讲述一下 SpringBoot 自动装配原理?”。\n我觉得我们可以从以下几个方面回答:\n什么是 SpringBoot 自动装配? SpringBoot 是如何实现自动装配的?如何实现按需加载? 如何实现一个 Starter? 篇幅问题,这篇文章并没有深入,小伙伴们也可以直接使用 debug 的方式去看看 SpringBoot 自动装配部分的源代码。\n前言 # 使用过 Spring 的小伙伴,一定有被 XML 配置统治的恐惧。即使 Spring 后面引入了基于注解的配置,我们在开启某些 Spring 特性或者引入第三方依赖的时候,还是需要用 XML 或 Java 进行显式配置。\n举个例子。没有 Spring Boot 的时候,我们写一个 RestFul Web 服务,还首先需要进行如下配置。\n@Configuration public class RESTConfiguration { @Bean public View jsonTemplate() { MappingJackson2JsonView view = new MappingJackson2JsonView(); view.setPrettyPrint(true); return view; } @Bean public ViewResolver viewResolver() { return new BeanNameViewResolver(); } } spring-servlet.xml \u0026lt;beans xmlns=\u0026#34;http://www.springframework.org/schema/beans\u0026#34; xmlns:xsi=\u0026#34;http://www.w3.org/2001/XMLSchema-instance\u0026#34; xmlns:context=\u0026#34;http://www.springframework.org/schema/context\u0026#34; xmlns:mvc=\u0026#34;http://www.springframework.org/schema/mvc\u0026#34; xsi:schemaLocation=\u0026#34;http://www.springframework.org/schema/beans http://www.springframework.org/schema/beans/spring-beans.xsd http://www.springframework.org/schema/context/ http://www.springframework.org/schema/context/spring-context.xsd http://www.springframework.org/schema/mvc/ http://www.springframework.org/schema/mvc/spring-mvc.xsd\u0026#34;\u0026gt; \u0026lt;context:component-scan base-package=\u0026#34;com.howtodoinjava.demo\u0026#34; /\u0026gt; \u0026lt;mvc:annotation-driven /\u0026gt; \u0026lt;!-- JSON Support --\u0026gt; \u0026lt;bean name=\u0026#34;viewResolver\u0026#34; class=\u0026#34;org.springframework.web.servlet.view.BeanNameViewResolver\u0026#34;/\u0026gt; \u0026lt;bean name=\u0026#34;jsonTemplate\u0026#34; class=\u0026#34;org.springframework.web.servlet.view.json.MappingJackson2JsonView\u0026#34;/\u0026gt; \u0026lt;/beans\u0026gt; 但是,Spring Boot 项目,我们只需要添加相关依赖,无需配置,通过启动下面的 main 方法即可。\n@SpringBootApplication public class DemoApplication { public static void main(String[] args) { SpringApplication.run(DemoApplication.class, args); } } 并且,我们通过 Spring Boot 的全局配置文件 application.properties或application.yml即可对项目进行设置比如更换端口号,配置 JPA 属性等等。\n为什么 Spring Boot 使用起来这么酸爽呢? 这得益于其自动装配。自动装配可以说是 Spring Boot 的核心,那究竟什么是自动装配呢?\n什么是 SpringBoot 自动装配? # 我们现在提到自动装配的时候,一般会和 Spring Boot 联系在一起。但是,实际上 Spring Framework 早就实现了这个功能。Spring Boot 只是在其基础上,通过 SPI 的方式,做了进一步优化。\nSpringBoot 定义了一套接口规范,这套规范规定:SpringBoot 在启动时会扫描外部引用 jar 包中的META-INF/spring.factories文件,将文件中配置的类型信息加载到 Spring 容器(此处涉及到 JVM 类加载机制与 Spring 的容器知识),并执行类中定义的各种操作。对于外部 jar 来说,只需要按照 SpringBoot 定义的标准,就能将自己的功能装置进 SpringBoot。\n没有 Spring Boot 的情况下,如果我们需要引入第三方依赖,需要手动配置,非常麻烦。但是,Spring Boot 中,我们直接引入一个 starter 即可。比如你想要在项目中使用 redis 的话,直接在项目中引入对应的 starter 即可。\n\u0026lt;dependency\u0026gt; \u0026lt;groupId\u0026gt;org.springframework.boot\u0026lt;/groupId\u0026gt; \u0026lt;artifactId\u0026gt;spring-boot-starter-data-redis\u0026lt;/artifactId\u0026gt; \u0026lt;/dependency\u0026gt; 引入 starter 之后,我们通过少量注解和一些简单的配置就能使用第三方组件提供的功能了。\n在我看来,自动装配可以简单理解为:通过注解或者一些简单的配置就能在 Spring Boot 的帮助下实现某块功能。\nSpringBoot 是如何实现自动装配的? # 我们先看一下 SpringBoot 的核心注解 SpringBootApplication 。\n@Target({ElementType.TYPE}) @Retention(RetentionPolicy.RUNTIME) @Documented @Inherited \u0026lt;1.\u0026gt;@SpringBootConfiguration \u0026lt;2.\u0026gt;@ComponentScan \u0026lt;3.\u0026gt;@EnableAutoConfiguration public @interface SpringBootApplication { } @Target({ElementType.TYPE}) @Retention(RetentionPolicy.RUNTIME) @Documented @Configuration //实际上它也是一个配置类 public @interface SpringBootConfiguration { } 大概可以把 @SpringBootApplication看作是 @Configuration、@EnableAutoConfiguration、@ComponentScan 注解的集合。根据 SpringBoot 官网,这三个注解的作用分别是:\n@EnableAutoConfiguration:启用 SpringBoot 的自动配置机制\n@Configuration:允许在上下文中注册额外的 bean 或导入其他配置类\n@ComponentScan: 扫描被@Component (@Service,@Controller)注解的 bean,注解默认会扫描启动类所在的包下所有的类 ,可以自定义不扫描某些 bean。如下图所示,容器中将排除TypeExcludeFilter和AutoConfigurationExcludeFilter。\n@EnableAutoConfiguration 是实现自动装配的重要注解,我们以这个注解入手。\n@EnableAutoConfiguration:实现自动装配的核心注解 # EnableAutoConfiguration 只是一个简单地注解,自动装配核心功能的实现实际是通过 **AutoConfigurationImportSelector**类。\n@Target({ElementType.TYPE}) @Retention(RetentionPolicy.RUNTIME) @Documented @Inherited @AutoConfigurationPackage //作用:将main包下的所有组件注册到容器中 @Import({AutoConfigurationImportSelector.class}) //加载自动装配类 xxxAutoconfiguration public @interface EnableAutoConfiguration { String ENABLED_OVERRIDE_PROPERTY = \u0026#34;spring.boot.enableautoconfiguration\u0026#34;; Class\u0026lt;?\u0026gt;[] exclude() default {}; String[] excludeName() default {}; } 我们现在重点分析下**AutoConfigurationImportSelector** 类到底做了什么?\nAutoConfigurationImportSelector:加载自动装配类 # AutoConfigurationImportSelector类的继承体系如下:\npublic class AutoConfigurationImportSelector implements DeferredImportSelector, BeanClassLoaderAware, ResourceLoaderAware, BeanFactoryAware, EnvironmentAware, Ordered { } public interface DeferredImportSelector extends ImportSelector { } public interface ImportSelector { String[] selectImports(AnnotationMetadata var1); } 可以看出,AutoConfigurationImportSelector 类实现了 ImportSelector接口,也就实现了这个接口中的 selectImports方法,该方法主要用于获取所有符合条件的类的全限定类名,这些类需要被加载到 IoC 容器中。\nprivate static final String[] NO_IMPORTS = new String[0]; public String[] selectImports(AnnotationMetadata annotationMetadata) { // \u0026lt;1\u0026gt;.判断自动装配开关是否打开 if (!this.isEnabled(annotationMetadata)) { return NO_IMPORTS; } else { //\u0026lt;2\u0026gt;.获取所有需要装配的bean AutoConfigurationMetadata autoConfigurationMetadata = AutoConfigurationMetadataLoader.loadMetadata(this.beanClassLoader); AutoConfigurationImportSelector.AutoConfigurationEntry autoConfigurationEntry = this.getAutoConfigurationEntry(autoConfigurationMetadata, annotationMetadata); return StringUtils.toStringArray(autoConfigurationEntry.getConfigurations()); } } 这里我们需要重点关注一下getAutoConfigurationEntry()方法,这个方法主要负责加载自动配置类的。\n该方法调用链如下:\n现在我们结合getAutoConfigurationEntry()的源码来详细分析一下:\nprivate static final AutoConfigurationEntry EMPTY_ENTRY = new AutoConfigurationEntry(); AutoConfigurationEntry getAutoConfigurationEntry(AutoConfigurationMetadata autoConfigurationMetadata, AnnotationMetadata annotationMetadata) { //\u0026lt;1\u0026gt;. if (!this.isEnabled(annotationMetadata)) { return EMPTY_ENTRY; } else { //\u0026lt;2\u0026gt;. AnnotationAttributes attributes = this.getAttributes(annotationMetadata); //\u0026lt;3\u0026gt;. List\u0026lt;String\u0026gt; configurations = this.getCandidateConfigurations(annotationMetadata, attributes); //\u0026lt;4\u0026gt;. configurations = this.removeDuplicates(configurations); Set\u0026lt;String\u0026gt; exclusions = this.getExclusions(annotationMetadata, attributes); this.checkExcludedClasses(configurations, exclusions); configurations.removeAll(exclusions); configurations = this.filter(configurations, autoConfigurationMetadata); this.fireAutoConfigurationImportEvents(configurations, exclusions); return new AutoConfigurationImportSelector.AutoConfigurationEntry(configurations, exclusions); } } 第 1 步:\n判断自动装配开关是否打开。默认spring.boot.enableautoconfiguration=true,可在 application.properties 或 application.yml 中设置\n第 2 步 :\n用于获取EnableAutoConfiguration注解中的 exclude 和 excludeName。\n第 3 步\n获取需要自动装配的所有配置类,读取META-INF/spring.factories\nspring-boot/spring-boot-project/spring-boot-autoconfigure/src/main/resources/META-INF/spring.factories 从下图可以看到这个文件的配置内容都被我们读取到了。XXXAutoConfiguration的作用就是按需加载组件。\n[\n不光是这个依赖下的META-INF/spring.factories被读取到,所有 Spring Boot Starter 下的META-INF/spring.factories都会被读取到。\n所以,你可以清楚滴看到, druid 数据库连接池的 Spring Boot Starter 就创建了META-INF/spring.factories文件。\n如果,我们自己要创建一个 Spring Boot Starter,这一步是必不可少的。\n第 4 步 :\n到这里可能面试官会问你:“spring.factories中这么多配置,每次启动都要全部加载么?”。\n很明显,这是不现实的。我们 debug 到后面你会发现,configurations 的值变小了。\n因为,这一步有经历了一遍筛选,@ConditionalOnXXX 中的所有条件都满足,该类才会生效。\n@Configuration // 检查相关的类:RabbitTemplate 和 Channel是否存在 // 存在才会加载 @ConditionalOnClass({ RabbitTemplate.class, Channel.class }) @EnableConfigurationProperties(RabbitProperties.class) @Import(RabbitAnnotationDrivenConfiguration.class) public class RabbitAutoConfiguration { } 有兴趣的童鞋可以详细了解下 Spring Boot 提供的条件注解\n@ConditionalOnBean:当容器里有指定 Bean 的条件下 @ConditionalOnMissingBean:当容器里没有指定 Bean 的情况下 @ConditionalOnSingleCandidate:当指定 Bean 在容器中只有一个,或者虽然有多个但是指定首选 Bean @ConditionalOnClass:当类路径下有指定类的条件下(这个极其重要) @ConditionalOnMissingClass:当类路径下没有指定类的条件下 @ConditionalOnProperty:指定的属性是否有指定的值 @ConditionalOnResource:类路径是否有指定的值 @ConditionalOnExpression:基于 SpEL 表达式作为判断条件 @ConditionalOnJava:基于 Java 版本作为判断条件 @ConditionalOnJndi:在 JNDI 存在的条件下差在指定的位置 @ConditionalOnNotWebApplication:当前项目不是 Web 项目的条件下 @ConditionalOnWebApplication:当前项目是 Web 项 目的条件下 如何实现一个 Starter # 光说不练假把式,现在就来撸一个 starter,实现自定义线程池\n第一步,创建threadpool-spring-boot-starter工程\n第二步,引入 Spring Boot 相关依赖\n[\n第三步,创建ThreadPoolAutoConfiguration\n第四步,在threadpool-spring-boot-starter工程的 resources 包下创建META-INF/spring.factories文件\n最后新建工程引入threadpool-spring-boot-starter\n测试通过!!!\n[\n总结 # Spring Boot 通过**@EnableAutoConfiguration开启自动装配,通过 SpringFactoriesLoader 最终加载META-INF/spring.factories中的自动配置类实现自动装配**,自动配置类其实就是通过@Conditional按需加载的配置类,想要其生效必须引入**spring-boot-starter-xxx包实现起步依赖**\n"},{"id":288,"href":"/zh/docs/technology/Review/java_guide/lybly_framework/ly04ly_spring-design-patterns/","title":"spring 设计模式","section":"框架","content":" 转载自https://github.com/Snailclimb/JavaGuide(添加小部分笔记)感谢作者!\n“JDK 中用到了哪些设计模式? Spring 中用到了哪些设计模式? ”这两个问题,在面试中比较常见。\n我在网上搜索了一下关于 Spring 中设计模式的讲解几乎都是千篇一律,而且大部分都年代久远。所以,花了几天时间自己总结了一下。\n由于我的个人能力有限,文中如有任何错误各位都可以指出。另外,文章篇幅有限,对于设计模式以及一些源码的解读我只是一笔带过,这篇文章的主要目的是回顾一下 Spring 中的设计模式。\n控制反转(IoC)和依赖注入(DI) # IoC(Inversion of Control,控制反转) 是 Spring 中一个非常非常重要的概念,它不是什么技术,而是一种解耦的设计思想。IoC 的主要目的是借助于“第三方”(Spring 中的 IoC 容器) 实现具有依赖关系的对象之间的解耦(IOC 容器管理对象,你只管使用即可),从而降低代码之间的耦合度。\nIoC 是一个原则,而不是一个模式,以下模式(但不限于)实现了 IoC 原则。\nSpring IoC 容器就像是一个工厂一样,当我们需要创建一个对象的时候,只需要配置好配置文件/注解即可,完全不用考虑对象是如何被创建出来的。 IoC 容器负责创建对象,将对象连接在一起,配置这些对象,并从创建中处理这些对象的整个生命周期,直到它们被完全销毁。\n在实际项目中一个 Service 类如果有几百甚至上千个类作为它的底层,我们需要实例化这个 Service,你可能要每次都要搞清这个 Service 所有底层类的构造函数,这可能会把人逼疯。如果利用 IOC 的话,你只需要配置好,然后在需要的地方引用就行了,这大大增加了项目的可维护性且降低了开发难度。\n关于 Spring IOC 的理解,推荐看这一下知乎的一个回答:https://www.zhihu.com/question/23277575/answer/169698662 ,非常不错。\n控制反转怎么理解呢? 举个例子:\u0026quot;对象 a 依赖了对象 b,当对象 a 需要使用 对象 b 的时候必须自己去创建。但是当系统引入了 IOC 容器后, 对象 a 和对象 b 之前就失去了直接的联系。这个时候,当对象 a 需要使用 对象 b 的时候, 我们可以指定 IOC 容器去创建一个对象 b 注入到对象 a 中\u0026quot;。 对象 a 获得依赖对象 b 的过程,由主动行为变为了被动行为,控制权反转,这就是控制反转名字的由来。\nDI(Dependecy Inject,依赖注入)是实现控制反转的一种设计模式,依赖注入就是将实例变量传入到一个对象中去。\n工厂设计模式 # //方式一 spring3.1后,XmlBeanFactory被弃用 XmlBeanFactory factory = new XmlBeanFactory (new ClassPathResource(\u0026#34;beans.xml\u0026#34;)); User bean = factory.getBean(User.class); System.out.println(bean); /* //方式二 ApplicationContext context=new ClassPathXmlApplicationContext(\u0026#34;beans.xml\u0026#34;); User bean = context.getBean(User.class); System.out.println(bean);*/ Spring 使用工厂模式可以通过 BeanFactory 或 ApplicationContext 创建 bean 对象。\nApplicationContext继承了ListableBeanFactory,ListableBeanFactory继承了BeanFactory\n两者对比:\nBeanFactory :延迟注入(使用到某个 bean 的时候才会注入),相比于ApplicationContext 来说会占用更少的内存,程序启动速度更快。 ApplicationContext :容器启动的时候,不管你用没用到,一次性创建所有 bean 。BeanFactory 仅提供了最基本的依赖注入支持,ApplicationContext 扩展了 BeanFactory ,除了有BeanFactory的功能还有额外更多功能,所以一般开发人员使用ApplicationContext会更多。 ApplicationContext 的三个实现类:\nClassPathXmlApplication:把上下文文件当成类路径资源。 FileSystemXmlApplication:从文件系统中的 XML 文件载入上下文定义信息。 XmlWebApplicationContext:从 Web 系统中的 XML 文件载入上下文定义信息。 Example:\nimport org.springframework.context.ApplicationContext; import org.springframework.context.support.FileSystemXmlApplicationContext; public class App { public static void main(String[] args) { ApplicationContext context = new FileSystemXmlApplicationContext( \u0026#34;C:/work/IOC Containers/springframework.applicationcontext/src/main/resources/bean-factory-config.xml\u0026#34;); HelloApplicationContext obj = (HelloApplicationContext) context.getBean(\u0026#34;helloApplicationContext\u0026#34;); obj.getMsg(); } } 单例设计模式 # 在我们的系统中,有一些对象其实我们只需要一个,比如说:线程池、缓存、对话框、注册表、日志对象、充当打印机、显卡等设备驱动程序的对象。事实上,这一类对象只能有一个实例,如果制造出多个实例就可能会导致一些问题的产生,比如:程序的行为异常、资源使用过量、或者不一致性的结果。\n使用单例模式的好处 :\n对于频繁使用的对象,可以省略创建对象所花费的时间,这对于那些重量级对象而言,是非常可观的一笔系统开销; 由于 new 操作的次数减少,因而对系统内存的使用频率也会降低,这将减轻 GC 压力,缩短 GC 停顿时间。 Spring 中 bean 的默认作用域就是 singleton(单例)的。 除了 singleton 作用域,Spring 中 bean 还有下面几种作用域:\nprototype : 每次获取都会创建一个新的 bean 实例。也就是说,连续 getBean() 两次,得到的是不同的 Bean 实例。 request (仅 Web 应用可用): 每一次 HTTP 请求都会产生一个新的 bean(请求 bean),该 bean 仅在当前 HTTP request 内有效。 session (仅 Web 应用可用) : 每一次来自新 session 的 HTTP 请求都会产生一个新的 bean(会话 bean),该 bean 仅在当前 HTTP session 内有效。 application/global-session (仅 Web 应用可用): 每个 Web 应用在启动时创建一个 Bean(应用 Bean),,该 bean 仅在当前应用启动时间内有效。 websocket (仅 Web 应用可用):每一次 WebSocket 会话产生一个新的 bean。 Spring 通过 ConcurrentHashMap 实现单例注册表的特殊方式实现单例模式。\nSpring 实现单例的核心代码如下:\n// 通过 ConcurrentHashMap(线程安全) 实现单例注册表 private final Map\u0026lt;String, Object\u0026gt; singletonObjects = new ConcurrentHashMap\u0026lt;String, Object\u0026gt;(64); public Object getSingleton(String beanName, ObjectFactory\u0026lt;?\u0026gt; singletonFactory) { Assert.notNull(beanName, \u0026#34;\u0026#39;beanName\u0026#39; must not be null\u0026#34;); synchronized (this.singletonObjects) { // 检查缓存中是否存在实例 Object singletonObject = this.singletonObjects.get(beanName); if (singletonObject == null) { //...省略了很多代码 try { singletonObject = singletonFactory.getObject(); } //...省略了很多代码 // 如果实例对象在不存在,我们注册到单例注册表中。 addSingleton(beanName, singletonObject); } return (singletonObject != NULL_OBJECT ? singletonObject : null); } } //将对象添加到单例注册表 protected void addSingleton(String beanName, Object singletonObject) { synchronized (this.singletonObjects) { this.singletonObjects.put(beanName, (singletonObject != null ? singletonObject : NULL_OBJECT)); } } } 单例 Bean 存在线程安全问题吗?\n大部分时候我们并没有在项目中使用多线程,所以很少有人会关注这个问题。单例 Bean 存在线程问题,主要是因为当多个线程操作同一个对象的时候是存在资源竞争的。\n常见的有两种解决办法:\n在 Bean 中尽量避免定义可变的成员变量。 在类中定义一个 ThreadLocal 成员变量,将需要的可变成员变量保存在 ThreadLocal 中(推荐的一种方式)。 不过,大部分 Bean 实际都是无状态(没有实例变量)的(比如 Dao、Service),这种情况下, Bean 是线程安全的。\n代理设计模式 # 代理模式在 AOP 中的应用 # AOP(Aspect-Oriented Programming,面向切面编程) 能够将那些与业务无关,却为业务模块所共同调用的逻辑或责任(例如事务处理、日志管理、权限控制等)封装起来,便于减少系统的重复代码,降低模块间的耦合度,并有利于未来的可拓展性和可维护性。\nSpring AOP 就是基于动态代理的,如果要代理的对象,实现了某个接口,那么 Spring AOP 会使用 JDK Proxy 去创建代理对象,而对于没有实现接口的对象,就无法使用 JDK Proxy 去进行代理了,这时候 Spring AOP 会使用 Cglib 生成一个被代理对象的子类来作为代理,如下图所示:\n当然,你也可以使用 AspectJ ,Spring AOP 已经集成了 AspectJ ,AspectJ 应该算的上是 Java 生态系统中最完整的 AOP 框架了。\n使用 AOP 之后我们可以把一些通用功能抽象出来,在需要用到的地方直接使用即可,这样大大简化了代码量。我们需要增加新功能时也方便,这样也提高了系统扩展性。日志功能、事务管理等等场景都用到了 AOP 。\nSpring AOP 和 AspectJ AOP 有什么区别? # Spring AOP 属于运行时增强,而 AspectJ 是编译时增强。 Spring AOP 基于代理(Proxying),而 AspectJ 基于字节码操作(Bytecode Manipulation)。\nSpring AOP 已经集成了 AspectJ ,AspectJ 应该算的上是 Java 生态系统中最完整的 AOP 框架了。AspectJ 相比于 Spring AOP 功能更加强大,但是 Spring AOP 相对来说更简单,\n如果我们的切面比较少,那么两者性能差异不大。但是,当切面太多的话,最好选择 AspectJ ,它比 Spring AOP 快很多。\n模板方法 # 模板方法模式是一种行为设计模式,它定义一个操作中的算法的骨架,而将一些步骤延迟到子类中。 模板方法使得子类可以不改变一个算法的结构即可重定义该算法的某些特定步骤的实现方式。\npublic abstract class Template { //这是我们的模板方法 public final void TemplateMethod(){ PrimitiveOperation1(); PrimitiveOperation2(); PrimitiveOperation3(); } protected void PrimitiveOperation1(){ //当前类实现 } //被子类实现的方法 protected abstract void PrimitiveOperation2(); protected abstract void PrimitiveOperation3(); } public class TemplateImpl extends Template { @Override public void PrimitiveOperation2() { //当前类实现 } @Override public void PrimitiveOperation3() { //当前类实现 } } Spring 中 JdbcTemplate、HibernateTemplate 等以 Template 结尾的对数据库操作的类,它们就使用到了模板模式。一般情况下,我们都是使用继承的方式来实现模板模式,但是 Spring 并没有使用这种方式,而是使用 Callback 模式与模板方法模式配合,既达到了代码复用的效果,同时增加了灵活性。\n什么是Callback模式\n//纯模板方法模式 public abstract class JdbcTemplate { public final Object execute(String sql){ Connection con=null; Statement stmt=null; try { con=getConnection(); stmt=con.createStatement(); Object retValue=executeWithStatement(stmt,sql); return retValue; } catch(SQLException e){ ... } finally { closeStatement(stmt); releaseConnection(con); } } protected abstract Object executeWithStatement(Statement stmt, String sql); } Callback类\npublic interface StatementCallback{ Object doWithStatement(Statement stmt); } 使用\n//结合Callback模式 public class JdbcTemplate { //主要是传入了一个类 public final Object execute(StatementCallback callback){ Connection con=null; Statement stmt=null; try { con=getConnection(); stmt=con.createStatement(); Object retValue=callback.doWithStatement(stmt); return retValue; } catch(SQLException e){ ... } finally { closeStatement(stmt); releaseConnection(con); } } ...//其它方法定义 } JdbcTemplate jdbcTemplate=...; final String sql=...; StatementCallback callback=new StatementCallback(){ public Object=doWithStatement(Statement stmt){ return ...; } } jdbcTemplate.execute(callback); 观察者模式 # 观察者模式是一种对象行为型模式。它表示的是一种对象与对象之间具有依赖关系,当一个对象发生改变的时候,这个对象所依赖的对象也会做出反应。Spring 事件驱动模型就是观察者模式很经典的一个应用。Spring 事件驱动模型非常有用,在很多场景都可以解耦我们的代码。比如我们每次添加商品的时候都需要重新更新商品索引,这个时候就可以利用观察者模式来解决这个问题。\nSpring 事件驱动模型中的三种角色 # 事件角色:是一种属性(物品)\n事件监听者:(注册到事件发布者上)\n事件发布者:当发布的时候,通知指定的监听者(观察者)\n事件角色 # ApplicationEvent (org.springframework.context包下)充当事件的角色,这是一个抽象类,它继承了java.util.EventObject并实现了 java.io.Serializable接口。\nSpring 中默认存在以下事件,他们都是对 ApplicationContextEvent 的实现(继承自ApplicationContextEvent):\nContextStartedEvent:ApplicationContext 启动后触发的事件; ContextStoppedEvent:ApplicationContext 停止后触发的事件; ContextRefreshedEvent:ApplicationContext 初始化或刷新完成后触发的事件; ContextClosedEvent:ApplicationContext 关闭后触发的事件。 事件监听者角色 # ApplicationListener 充当了事件监听者角色,它是一个接口,里面只定义了一个 onApplicationEvent()方法来处理ApplicationEvent。ApplicationListener接口类源码如下,可以看出接口定义看出接口中的事件只要实现了 ApplicationEvent就可以了。所以,在 Spring 中我们只要实现 ApplicationListener 接口的 onApplicationEvent() 方法即可完成监听事件\n注意代码,E extends ApplicationEvent , 对某类事件进行监听\npackage org.springframework.context; import java.util.EventListener; @FunctionalInterface public interface ApplicationListener\u0026lt;E extends ApplicationEvent\u0026gt; extends EventListener { void onApplicationEvent(E var1); } 事件发布者角色 # ApplicationEventPublisher 充当了事件的发布者,它也是一个接口。\n@FunctionalInterface public interface ApplicationEventPublisher { default void publishEvent(ApplicationEvent event) { this.publishEvent((Object)event); } void publishEvent(Object var1); } ApplicationEventPublisher 接口的publishEvent()这个方法在AbstractApplicationContext类中被实现(这个类继承了ApplicationEventPublisher),阅读这个方法的实现,你会发现实际上事件真正是通过ApplicationEventMulticaster来广播出去的。具体内容过多,就不在这里分析了,后面可能会单独写一篇文章提到。\nSpring 的事件流程总结 # 定义一个事件: 实现一个继承自 ApplicationEvent,并且写相应的构造函数; 定义一个事件监听者:实现 ApplicationListener 接口,重写 onApplicationEvent() 方法; 使用事件发布者发布消息: 可以通过 ApplicationEventPublisher 的 publishEvent() 方法发布消息。 Example:\n// 定义一个事件,继承自ApplicationEvent并且写相应的构造函数 public class DemoEvent extends ApplicationEvent{ private static final long serialVersionUID = 1L; private String message; public DemoEvent(Object source,String message){ super(source); this.message = message; } public String getMessage() { return message; } } // 定义一个事件监听者,实现ApplicationListener接口,重写 onApplicationEvent() 方法; @Component public class DemoListener implements ApplicationListener\u0026lt;DemoEvent\u0026gt;{ //使用onApplicationEvent接收消息 @Override public void onApplicationEvent(DemoEvent event) { String msg = event.getMessage(); System.out.println(\u0026#34;接收到的信息是:\u0026#34;+msg); } } // 发布事件,可以通过ApplicationEventPublisher 的 publishEvent() 方法发布消息。 @Component public class DemoPublisher { @Autowired ApplicationContext applicationContext; public void publish(String message){ //发布事件 applicationContext.publishEvent(new DemoEvent(this, message)); } } 当调用 DemoPublisher 的 publish() 方法的时候,比如 demoPublisher.publish(\u0026quot;你好\u0026quot;) ,控制台就会打印出:接收到的信息是:你好 。\n适配器模式 # 适配器模式(Adapter Pattern) 将一个接口转换成客户希望的另一个接口,适配器模式使接口不兼容的那些类可以一起工作。\nSpring AOP 中的适配器模式 # 我们知道 Spring AOP 的实现是基于代理模式,但是 Spring AOP 的增强或通知(Advice)使用到了适配器模式,与之相关的接口是AdvisorAdapter 。\nAdvice 常用的类型有:BeforeAdvice(目标方法调用前,前置通知)、AfterAdvice(目标方法调用后,后置通知)、AfterReturningAdvice(目标方法执行结束后,return 之前)等等。每个类型 Advice(通知)都有对应的拦截器:MethodBeforeAdviceInterceptor、AfterReturningAdviceInterceptor、ThrowsAdviceInterceptor 等等。\nSpring 预定义的通知要通过对应的适配器,适配成 MethodInterceptor 接口(方法拦截器)类型的对象(如:MethodBeforeAdviceAdapter 通过调用 getInterceptor 方法,将 MethodBeforeAdvice 适配成 MethodBeforeAdviceInterceptor )。\nSpring MVC 中的适配器模式 # 在 Spring MVC 中,DispatcherServlet 根据请求信息调用 HandlerMapping,解析请求对应的 Handler。解析到对应的 Handler(也就是我们平常说的 Controller 控制器)后,开始由HandlerAdapter 适配器处理。HandlerAdapter 作为期望接口,具体的适配器实现类用于对目标类进行适配,Controller 作为需要适配的类。\n为什么要在 Spring MVC 中使用适配器模式?\nSpring MVC 中的 Controller 种类众多,不同类型的 Controller 通过不同的方法来对请求进行处理。如果不利用适配器模式的话,DispatcherServlet 直接获取对应类型的 Controller,需要的自行来判断,像下面这段代码一样:\nif(mappedHandler.getHandler() instanceof MultiActionController){ ((MultiActionController)mappedHandler.getHandler()).xxx }else if(mappedHandler.getHandler() instanceof XXX){ ... }else if(...){ ... } 假如我们再增加一个 Controller类型就要在上面代码中再加入一行 判断语句,这种形式就使得程序难以维护,也违反了设计模式中的开闭原则 – 对扩展开放,对修改关闭。\n装饰者模式 # 装饰者模式可以动态地给对象添加一些额外的属性或行为。相比于使用继承,装饰者模式更加灵活。简单点儿说就是当我们需要修改原有的功能,但我们又不愿直接去修改原有的代码时,设计一个 Decorator 套在原有代码外面。其实在 JDK 中就有很多地方用到了装饰者模式,比如 InputStream家族,InputStream 类下有 FileInputStream (读取文件)、BufferedInputStream (增加缓存,使读取文件速度大大提升)等子类都在不修改InputStream 代码的情况下扩展了它的功能。\nSpring 中配置 DataSource 的时候,DataSource 可能是不同的数据库和数据源。我们能否根据客户的需求在少修改原有类的代码下动态切换不同的数据源?这个时候就要用到装饰者模式(这一点我自己还没太理解具体原理)。Spring 中用到的包装器模式在类名上含有 Wrapper或者 Decorator。这些类基本上都是动态地给一个对象添加一些额外的职责\n总结 # Spring 框架中用到了哪些设计模式?\n工厂设计模式 : Spring 使用工厂模式通过 BeanFactory、ApplicationContext 创建 bean 对象。 代理设计模式 : Spring AOP 功能的实现。 单例设计模式 : Spring 中的 Bean 默认都是单例的。 模板方法模式 : Spring 中 jdbcTemplate、hibernateTemplate 等以 Template 结尾的对数据库操作的类,它们就使用到了模板模式。 包装器设计模式 : 我们的项目需要连接多个数据库,而且不同的客户在每次访问中根据需要会去访问不同的数据库。这种模式让我们可以根据客户的需求能够动态切换不同的数据源。 观察者模式: Spring 事件驱动模型就是观察者模式很经典的一个应用。 适配器模式 :Spring AOP 的增强或通知(Advice)使用到了适配器模式、spring MVC 中也是用到了适配器模式适配Controller。 \u0026hellip;\u0026hellip; 参考 # 《Spring 技术内幕》 https://blog.eduonix.com/java-programming-2/learn-design-patterns-used-spring-framework/ http://blog.yeamin.top/2018/03/27/单例模式-Spring单例实现原理分析/ https://www.tutorialsteacher.com/ioc/inversion-of-control https://design-patterns.readthedocs.io/zh_CN/latest/behavioral_patterns/observer.html https://juejin.im/post/5a8eb261f265da4e9e307230 https://juejin.im/post/5ba28986f265da0abc2b6084 "},{"id":289,"href":"/zh/docs/technology/Review/java_guide/lybly_framework/ly03ly_spring-transaction/","title":"Spring事务详情","section":"框架","content":" 转载自https://github.com/Snailclimb/JavaGuide(添加小部分笔记)感谢作者!\n前段时间答应读者的 Spring 事务 分析总结终于来了。这部分内容比较重要,不论是对于工作还是面试,但是网上比较好的参考资料比较少。\n什么是事务? # 事务是逻辑上的一组操作,要么都执行,要么都不执行。\n相信大家应该都能背上面这句话了,下面我结合我们日常的真实开发来谈一谈。\n我们系统的每个业务方法可能包括了多个原子性的数据库操作,比如下面的 savePerson() 方法中就有两个原子性的数据库操作。这些原子性的数据库操作是有依赖的,它们要么都执行,要不就都不执行。\npublic void savePerson() { personDao.save(person); personDetailDao.save(personDetail); } 另外,需要格外注意的是:事务能否生效数据库引擎是否支持事务是关键。比如常用的 MySQL 数据库默认使用支持事务的 innodb引擎。但是,如果把数据库引擎变为 myisam,那么程序也就不再支持事务了!\n事务最经典也经常被拿出来说例子就是转账了。假如小明要给小红转账 1000 元,这个转账会涉及到两个关键操作就是:\n将小明的余额减少 1000 元。 将小红的余额增加 1000 元。 万一在这两个操作之间突然出现错误比如银行系统崩溃或者网络故障,导致小明余额减少而小红的余额没有增加,这样就不对了。事务就是保证这两个关键操作要么都成功,要么都要失败。\npublic class OrdersService { private AccountDao accountDao; public void setOrdersDao(AccountDao accountDao) { this.accountDao = accountDao; } @Transactional(propagation = Propagation.REQUIRED, isolation = Isolation.DEFAULT, readOnly = false, timeout = -1) public void accountMoney() { //小红账户多1000 accountDao.addMoney(1000,xiaohong); //模拟突然出现的异常,比如银行中可能为突然停电等等 //如果没有配置事务管理的话会造成,小红账户多了1000而小明账户没有少钱 int i = 10 / 0; //小王账户少1000 accountDao.reduceMoney(1000,xiaoming); } } 另外,数据库事务的 ACID 四大特性是事务的基础,下面简单来了解一下。\n事务的特性(ACID)了解么? AID -\u0026gt; C # 原子性(Atomicity): 一个事务(transaction)中的所有操作,或者全部完成,或者全部不完成,不会结束在中间某个环节。事务在执行过程中发生错误,会被回滚(Rollback)到事务开始前的状态,就像这个事务从来没有执行过一样。即,事务不可分割、不可约简。 一致性(Consistency): 在事务开始之前和事务结束以后,数据库的完整性没有被破坏。这表示写入的资料必须完全符合所有的预设约束、触发器、级联回滚等。 隔离性(Isolation): 数据库允许多个并发事务同时对其数据进行读写和修改的能力,隔离性可以防止多个事务并发执行时由于交叉执行而导致数据的不一致。事务隔离分为不同级别,包括未提交读(Read uncommitted)、提交读(read committed)、可重复读(repeatable read)和串行化(Serializable)。 持久性(Durability): 事务处理结束后,对数据的修改就是永久的,即便系统故障也不会丢失。 参考 :https://zh.wikipedia.org/wiki/ACID 。\n详谈 Spring 对事务的支持 # ⚠️ 再提醒一次:你的程序是否支持事务首先取决于数据库 ,比如使用 MySQL 的话,如果你选择的是 innodb 引擎,那么恭喜你,是可以支持事务的。但是,如果你的 MySQL 数据库使用的是 myisam 引擎的话,那不好意思,从根上就是不支持事务的。\n这里再多提一下一个非常重要的知识点: MySQL 怎么保证原子性的?\n我们知道如果想要保证事务的原子性,就需要在异常发生时,对已经执行的操作进行回滚,在 MySQL 中,恢复机制是通过 回滚日志(undo log) 实现的,所有事务进行的修改都会先记录到这个回滚日志中,然后再执行相关的操作。如果执行过程中遇到异常的话,我们直接利用 回滚日志 中的信息将数据回滚到修改之前的样子即可!并且,回滚日志会先于数据持久化到磁盘上。这样就保证了即使遇到数据库突然宕机等情况,当用户再次启动数据库的时候,数据库还能够通过查询回滚日志来回滚之前未完成的事务。\nSpring 支持两种方式的事务管理 # 编程式事务管理 # 通过 **TransactionTemplate或者TransactionManager**手动管理事务,实际应用中很少使用,但是对于你理解 Spring 事务管理原理有帮助。\n使用TransactionTemplate 进行编程式事务管理的示例代码如下:\n@Autowired private TransactionTemplate transactionTemplate; public void testTransaction() { transactionTemplate.execute(new TransactionCallbackWithoutResult() { @Override protected void doInTransactionWithoutResult(TransactionStatus transactionStatus) { try { // .... 业务代码 } catch (Exception e){ //回滚 transactionStatus.setRollbackOnly(); } } }); } 使用 TransactionManager 进行编程式事务管理的示例代码如下:\n@Autowired private PlatformTransactionManager transactionManager; public void testTransaction() { TransactionStatus status = transactionManager.getTransaction(new DefaultTransactionDefinition()); try { // .... 业务代码 transactionManager.commit(status); } catch (Exception e) { transactionManager.rollback(status); } } 声明式事务管理 # 推荐使用(代码侵入性最小),实际是通过 AOP 实现(基于@Transactional 的全注解方式使用最多)。\n使用 @Transactional注解进行事务管理的示例代码如下:\n@Transactional(propagation = Propagation.REQUIRED) public void aMethod { //do something B b = new B(); C c = new C(); b.bMethod(); c.cMethod(); } Spring 事务管理接口介绍 # Spring 框架中,事务管理相关最重要的 3 个接口如下:\nPlatformTransactionManager: (平台)事务管理器,Spring 事务策略的核心。 TransactionDefinition: 事务定义信息(事务隔离级别、传播行为、超时、只读、回滚规则)。 TransactionStatus: 事务运行状态。 我们可以把 PlatformTransactionManager 接口可以被看作是事务上层的管理者,而 TransactionDefinition 和 TransactionStatus 这两个接口可以看作是事务的描述。\nPlatformTransactionManager 会根据 TransactionDefinition 的定义比如事务超时时间、隔离级别、传播行为等来进行事务管理 ,而 TransactionStatus 接口则提供了一些方法来获取事务相应的状态比如是否新事务、是否可以回滚等等。\nPlatformTransactionManager:事务管理接口 # Spring 并不直接管理事务,而是提供了多种事务管理器 。Spring 事务管理器的接口是: PlatformTransactionManager 。\n通过这个接口,Spring 为各个平台如:JDBC(DataSourceTransactionManager)、Hibernate(HibernateTransactionManager)、JPA(JpaTransactionManager)等都提供了对应的事务管理器,但是具体的实现就是各个平台自己的事情了。\nPlatformTransactionManager 接口的具体实现如下:\nPlatformTransactionManager接口中定义了三个方法:\npackage org.springframework.transaction; import org.springframework.lang.Nullable; public interface PlatformTransactionManager { //获得事务 TransactionStatus getTransaction(@Nullable TransactionDefinition var1) throws TransactionException; //提交事务 void commit(TransactionStatus var1) throws TransactionException; //回滚事务 void rollback(TransactionStatus var1) throws TransactionException; } 这里多插一嘴。为什么要定义或者说抽象出来PlatformTransactionManager这个接口呢?\n主要是因为要将事务管理行为抽象出来,然后不同的平台去实现它,这样我们可以保证提供给外部的行为(提供给程序员)不变,方便我们扩展。\n我前段时间在我的 知识星球分享过:“为什么我们要用接口?” 。\n《设计模式》(GOF 那本)这本书在很多年前都提到过说要基于接口而非实现编程,你真的知道为什么要基于接口编程么?\n纵观开源框架和项目的源码,接口是它们不可或缺的重要组成部分。要理解为什么要用接口,首先要搞懂接口提供了什么功能。我们可以把接口理解为提供了一系列功能列表的约定,接口本身不提供功能,它只定义行为。但是谁要用,就要先实现我,遵守我的约定,然后再自己去实现我定义的要实现的功能。\n举个例子,我上个项目有发送短信的需求,为此,我们定了一个接口,接口只有两个方法:\n1.发送短信 2.处理发送结果的方法。\n刚开始我们用的是阿里云短信服务,然后我们实现这个接口完成了一个阿里云短信的服务。后来,我们突然又换到了别的短信服务平台,我们这个时候只需要再实现这个接口即可。这样保证了我们提供给外部的行为不变。几乎不需要改变什么代码,我们就轻松完成了需求的转变,提高了代码的灵活性和可扩展性。\n什么时候用接口?当你要实现的功能模块设计抽象行为的时候,比如发送短信的服务,图床的存储服务等等。\nTransactionDefinition:事务属性 # 事务管理器接口 PlatformTransactionManager 通过 getTransaction(TransactionDefinition definition) 方法来得到一个事务,这个方法里面的参数是 TransactionDefinition 类 ,这个类就定义了一些基本的事务属性。\n什么是事务属性呢? 事务属性可以理解成事务的一些基本配置,描述了事务策略如何应用到方法上。\n事务属性包含了 5 个方面:\n隔离级别 传播行为 回滚规则 是否只读 事务超时 TransactionDefinition 接口中定义了 5 个方法以及一些表示事务属性的常量比如隔离级别、传播行为等等。\npackage org.springframework.transaction; import org.springframework.lang.Nullable; public interface TransactionDefinition { int PROPAGATION_REQUIRED = 0; int PROPAGATION_SUPPORTS = 1; int PROPAGATION_MANDATORY = 2; int PROPAGATION_REQUIRES_NEW = 3; int PROPAGATION_NOT_SUPPORTED = 4; int PROPAGATION_NEVER = 5; int PROPAGATION_NESTED = 6; int ISOLATION_DEFAULT = -1; int ISOLATION_READ_UNCOMMITTED = 1; int ISOLATION_READ_COMMITTED = 2; int ISOLATION_REPEATABLE_READ = 4; int ISOLATION_SERIALIZABLE = 8; int TIMEOUT_DEFAULT = -1; // 返回事务的传播行为,默认值为 REQUIRED。 int getPropagationBehavior(); //返回事务的隔离级别,默认值是 DEFAULT int getIsolationLevel(); // 返回事务的超时时间,默认值为-1。如果超过该时间限制但事务还没有完成,则自动回滚事务。 int getTimeout(); // 返回是否为只读事务,默认值为 false boolean isReadOnly(); @Nullable String getName(); } TransactionStatus:事务状态 # TransactionStatus接口用来记录事务的状态 该接口定义了一组方法,用来获取或判断 事务的相应状态信息。\nPlatformTransactionManager.getTransaction(…)方法返回一个 TransactionStatus 对象。\nTransactionStatus 接口内容如下:\npublic interface TransactionStatus{ boolean isNewTransaction(); // 是否是新的事务 boolean hasSavepoint(); // 是否有恢复点 void setRollbackOnly(); // 设置为只回滚 boolean isRollbackOnly(); // 是否为只回滚 boolean isCompleted; // 是否已完成 } 事务属性详解 # 实际业务开发中,大家一般都是使用 @Transactional 注解来开启事务,但很多人并不清楚这个注解里面的参数是什么意思,有什么用。为了更好的在项目中使用事务管理,强烈推荐好好阅读一下下面的内容。\n事务传播行为 # 事务传播行为是为了解决业务层方法之间互相调用的事务问题。\n当事务方法被另一个事务方法调用时,必须指定事务应该如何传播。例如:方法可能继续在现有事务中运行,也可能开启一个新事务,并在自己的事务中运行。\n举个例子:我们在 A 类的aMethod()方法中调用了 B 类的 bMethod() 方法。这个时候就涉及到业务层方法之间互相调用的事务问题。如果我们的 bMethod()如果发生异常需要回滚,如何配置事务传播行为才能让 aMethod()也跟着回滚呢?这个时候就需要事务传播行为的知识了,如果你不知道的话一定要好好看一下。\n@Service Class A { @Autowired B b; @Transactional(propagation = Propagation.xxx) public void aMethod { //do something b.bMethod(); } } @Service Class B { @Transactional(propagation = Propagation.xxx) public void bMethod { //do something } } 在TransactionDefinition定义中包括了如下几个表示传播行为的常量:\npublic interface TransactionDefinition { int PROPAGATION_REQUIRED = 0; int PROPAGATION_SUPPORTS = 1; int PROPAGATION_MANDATORY = 2; int PROPAGATION_REQUIRES_NEW = 3; int PROPAGATION_NOT_SUPPORTED = 4; int PROPAGATION_NEVER = 5; int PROPAGATION_NESTED = 6; ...... } 不过,为了方便使用,Spring 相应地定义了一个枚举类:Propagation\npackage org.springframework.transaction.annotation; import org.springframework.transaction.TransactionDefinition; public enum Propagation { REQUIRED(TransactionDefinition.PROPAGATION_REQUIRED), SUPPORTS(TransactionDefinition.PROPAGATION_SUPPORTS), MANDATORY(TransactionDefinition.PROPAGATION_MANDATORY), REQUIRES_NEW(TransactionDefinition.PROPAGATION_REQUIRES_NEW), NOT_SUPPORTED(TransactionDefinition.PROPAGATION_NOT_SUPPORTED), NEVER(TransactionDefinition.PROPAGATION_NEVER), NESTED(TransactionDefinition.PROPAGATION_NESTED); private final int value; Propagation(int value) { this.value = value; } public int value() { return this.value; } } 正确的事务传播行为可能的值如下 :\n1.TransactionDefinition.PROPAGATION_REQUIRED\n使用的最多的一个事务传播行为,我们平时经常使用的@Transactional注解默认使用就是这个事务传播行为。如果当前存在事务,则加入该事务;如果当前没有事务,则创建一个新的事务。也就是说:\n如果外部方法没有开启事务的话,Propagation.REQUIRED修饰的内部方法会新开启自己的事务,且开启的事务相互独立,互不干扰。 如果外部方法开启事务并且被Propagation.REQUIRED的话,所有Propagation.REQUIRED修饰的内部方法和外部方法均属于同一事务 ,只要一个方法回滚,整个事务均回滚。 举个例子:如果我们上面的aMethod()和bMethod()使用的都是PROPAGATION_REQUIRED传播行为的话,两者使用的就是同一个事务,只要其中一个方法回滚,整个事务均回滚。\n@Service Class A { @Autowired B b; @Transactional(propagation = Propagation.REQUIRED) public void aMethod { //do something b.bMethod(); } } @Service Class B { @Transactional(propagation = Propagation.REQUIRED) public void bMethod { //do something } } 2.TransactionDefinition.PROPAGATION_REQUIRES_NEW\n创建一个新的事务,如果当前存在事务,则把当前事务挂起。也就是说不管外部方法是否开启事务,Propagation.REQUIRES_NEW修饰的内部方法会新开启自己的事务,且开启的事务相互独立,互不干扰。\n举个例子:如果我们上面的bMethod()使用PROPAGATION_REQUIRES_NEW事务传播行为修饰,aMethod还是用PROPAGATION_REQUIRED修饰的话。如果aMethod()发生异常回滚,bMethod()不会跟着回滚,因为 bMethod()开启了独立的事务。但是,如果 bMethod()抛出了未被捕获的异常并且这个异常满足事务回滚规则的话,aMethod()同样也会回滚,因为这个异常被 aMethod()的事务管理机制检测到了。\n@Service Class A { @Autowired B b; @Transactional(propagation = Propagation.REQUIRED) public void aMethod { //do something b.bMethod(); } } @Service Class B { @Transactional(propagation = Propagation.REQUIRES_NEW) public void bMethod { //do something } } 3.TransactionDefinition.PROPAGATION_NESTED:\n如果当前存在事务,就在嵌套事务内执行;如果当前没有事务,就执行与TransactionDefinition.PROPAGATION_REQUIRED类似的操作。也就是说:\n在外部方法开启事务的情况下,在内部开启一个新的事务,作为嵌套事务存在。 如果外部方法无事务,则单独开启一个事务,与 PROPAGATION_REQUIRED 类似。 这里还是简单举个例子:如果 bMethod() 回滚的话,aMethod()也会回滚。\n如果aMethod()回滚的话,bMethod()也会回滚\n@Service Class A { @Autowired B b; @Transactional(propagation = Propagation.REQUIRED) public void aMethod { //do something b.bMethod(); } } @Service Class B { @Transactional(propagation = Propagation.NESTED) public void bMethod { //do something } } 4.TransactionDefinition.PROPAGATION_MANDATORY\n如果当前存在事务,则加入该事务;如果当前没有事务,则抛出异常。(mandatory:强制性)\n这个使用的很少,就不举例子来说了。\n若是错误的配置以下 3 种事务传播行为,事务将不会发生回滚,这里不对照案例讲解了,使用的很少。\nTransactionDefinition.PROPAGATION_SUPPORTS: 如果当前存在事务,则加入该事务;如果当前没有事务,则以非事务的方式继续运行。 TransactionDefinition.PROPAGATION_NOT_SUPPORTED: 以非事务方式运行,如果当前存在事务,则把当前事务挂起。 TransactionDefinition.PROPAGATION_NEVER: 以非事务方式运行,如果当前存在事务,则抛出异常。 更多关于事务传播行为的内容请看这篇文章: 《太难了~面试官让我结合案例讲讲自己对 Spring 事务传播行为的理解。》\n事务隔离级别 # TransactionDefinition 接口中定义了五个表示隔离级别的常量:\npublic interface TransactionDefinition { ...... int ISOLATION_DEFAULT = -1; int ISOLATION_READ_UNCOMMITTED = 1; int ISOLATION_READ_COMMITTED = 2; int ISOLATION_REPEATABLE_READ = 4; int ISOLATION_SERIALIZABLE = 8; ...... } 和事务传播行为那块一样,为了方便使用,Spring 也相应地定义了一个枚举类:Isolation\npublic enum Isolation { DEFAULT(TransactionDefinition.ISOLATION_DEFAULT), READ_UNCOMMITTED(TransactionDefinition.ISOLATION_READ_UNCOMMITTED), READ_COMMITTED(TransactionDefinition.ISOLATION_READ_COMMITTED), REPEATABLE_READ(TransactionDefinition.ISOLATION_REPEATABLE_READ), SERIALIZABLE(TransactionDefinition.ISOLATION_SERIALIZABLE); private final int value; Isolation(int value) { this.value = value; } public int value() { return this.value; } } 下面我依次对每一种事务隔离级别进行介绍:\nTransactionDefinition.ISOLATION_DEFAULT :使用后端数据库默认的隔离级别,MySQL 默认采用的 REPEATABLE_READ 隔离级别 Oracle 默认采用的 READ_COMMITTED 隔离级别. TransactionDefinition.ISOLATION_READ_UNCOMMITTED :最低的隔离级别,使用这个隔离级别很少,因为它允许读取尚未提交的数据变更,可能会导致脏读、幻读或不可重复读 TransactionDefinition.ISOLATION_READ_COMMITTED : 允许读取并发事务已经提交的数据,可以阻止脏读,但是幻读或不可重复读仍有可能发生 TransactionDefinition.ISOLATION_REPEATABLE_READ : 对同一字段的多次读取结果都是一致的,除非数据是被本身事务自己所修改,可以阻止脏读和不可重复读,但幻读仍有可能发生。 TransactionDefinition.ISOLATION_SERIALIZABLE : 最高的隔离级别,完全服从 ACID 的隔离级别。所有的事务依次逐个执行,这样事务之间就完全不可能产生干扰,也就是说,该级别可以防止脏读、不可重复读以及幻读。但是这将严重影响程序的性能。通常情况下也不会用到该级别。 相关阅读: MySQL事务隔离级别详解。\n事务超时属性 # 所谓事务超时,就是指一个事务所允许执行的最长时间,如果超过该时间限制但事务还没有完成,则自动回滚事务。在 TransactionDefinition 中以 int 的值来表示超时时间,其单位是秒,默认值为-1,这表示事务的超时时间取决于底层事务系统或者没有超时时间。\n事务只读属性 # package org.springframework.transaction; import org.springframework.lang.Nullable; public interface TransactionDefinition { ...... // 返回是否为只读事务,默认值为 false boolean isReadOnly(); } 对于只有读取数据查询的事务,可以指定事务类型为 readonly,即只读事务。只读事务不涉及数据的修改,数据库会提供一些优化手段,适合用在有多条数据库查询操作的方法中。\n很多人就会疑问了,为什么我一个数据查询操作还要启用事务支持呢?\n拿 MySQL 的 innodb 举例子,根据官网 https://dev.mysql.com/doc/refman/5.7/en/innodb-autocommit-commit-rollback.html 描述:\nMySQL 默认对每一个新建立的连接都启用了autocommit模式。在该模式下,每一个发送到 MySQL 服务器的sql语句都会在一个单独的事务中进行处理,执行结束后会自动提交事务,并开启一个新的事务(才会开启一个新的事务)。\n但是,如果你给方法加上了Transactional注解的话,这个方法执行的所有sql会被放在一个事务中。如果声明了只读事务的话,数据库就会去优化它的执行,并不会带来其他的什么收益。\n如果不加Transactional,每条sql会开启一个单独的事务,中间被其它事务改了数据,都会实时读取到最新值。\n分享一下关于事务只读属性,其他人的解答:\n如果你一次执行单条查询语句,则没有必要启用事务支持,数据库默认支持 SQL 执行期间的读一致性; 如果你一次执行多条查询语句,例如统计查询,报表查询,在这种场景下,多条查询 SQL 必须保证整体的读一致性,否则,在前条 SQL 查询之后,后条 SQL 查询之前,数据被其他用户改变,则该次整体的统计查询将会出现读数据不一致的状态,此时,应该启用事务支持(避免多次查询结果不一致) 事务回滚规则 # 这些规则定义了哪些异常会导致事务回滚而哪些不会。默认情况下,事务只有遇到运行期异常(RuntimeException 的子类)时才会回滚,Error 也会导致事务回滚,但是,在遇到检查型(Checked)异常时不会回滚。[ **这里说的是默认情况下 **]\n如果你想要回滚你定义的特定的异常类型的话,可以这样:\n@Transactional(rollbackFor= MyException.class) @Transactional 注解使用详解 # @Transactional 的作用范围 # 方法 :推荐将注解使用于方法上,不过需要注意的是:该注解只能应用到 public 方法上,否则不生效。 类 :如果这个注解使用在类上的话,表明该注解对该类中所有的 public 方法都生效。 接口 :不推荐在接口上使用。 @Transactional 的常用配置参数 # @Transactional注解源码如下,里面包含了基本事务属性的配置:\n@Target({ElementType.TYPE, ElementType.METHOD}) @Retention(RetentionPolicy.RUNTIME) @Inherited @Documented public @interface Transactional { @AliasFor(\u0026#34;transactionManager\u0026#34;) String value() default \u0026#34;\u0026#34;; @AliasFor(\u0026#34;value\u0026#34;) String transactionManager() default \u0026#34;\u0026#34;; Propagation propagation() default Propagation.REQUIRED; Isolation isolation() default Isolation.DEFAULT; int timeout() default TransactionDefinition.TIMEOUT_DEFAULT; boolean readOnly() default false; Class\u0026lt;? extends Throwable\u0026gt;[] rollbackFor() default {}; String[] rollbackForClassName() default {}; Class\u0026lt;? extends Throwable\u0026gt;[] noRollbackFor() default {}; String[] noRollbackForClassName() default {}; } @Transactional 的常用配置参数总结(只列出了 5 个我平时比较常用的):\n属性名 说明 propagation 事务的传播行为,默认值为 REQUIRED,可选的值在上面介绍过 isolation 事务的隔离级别,默认值采用 DEFAULT,可选的值在上面介绍过 timeout 事务的超时时间,默认值为-1(不会超时)。如果超过该时间限制但事务还没有完成,则自动回滚事务。 readOnly 指定事务是否为只读事务,默认值为 false。 rollbackFor 用于指定能够触发事务回滚的异常类型,并且可以指定多个异常类型。 @Transactional 事务注解原理 # 面试中在问 AOP 的时候可能会被问到的一个问题。简单说下吧!\n我们知道,@Transactional 的工作机制是基于 AOP 实现的,AOP 又是使用动态代理实现的。如果目标对象实现了接口,默认情况下会采用 JDK 的动态代理,如果目标对象没有实现了接口,会使用 CGLIB 动态代理。\n多提一嘴:createAopProxy() 方法 决定了是使用 JDK 还是 Cglib 来做动态代理,源码如下:\npublic class DefaultAopProxyFactory implements AopProxyFactory, Serializable { @Override public AopProxy createAopProxy(AdvisedSupport config) throws AopConfigException { if (config.isOptimize() || config.isProxyTargetClass() || hasNoUserSuppliedProxyInterfaces(config)) { Class\u0026lt;?\u0026gt; targetClass = config.getTargetClass(); if (targetClass == null) { throw new AopConfigException(\u0026#34;TargetSource cannot determine target class: \u0026#34; + \u0026#34;Either an interface or a target is required for proxy creation.\u0026#34;); } if (targetClass.isInterface() || Proxy.isProxyClass(targetClass)) { return new JdkDynamicAopProxy(config); } return new ObjenesisCglibAopProxy(config); } else { return new JdkDynamicAopProxy(config); } } ....... } 如果一个类或者一个类中的 public 方法上被标注@Transactional 注解的话,Spring 容器就会在启动的时候为其创建一个代理类,在调用被@Transactional 注解的 public 方法的时候,实际调用的是,TransactionInterceptor 类中的 invoke()方法。这个方法的作用就是在目标方法之前开启事务,方法执行过程中如果遇到异常的时候回滚事务,方法调用完成之后提交事务。\nTransactionInterceptor 类中的 invoke()方法内部实际调用的是 TransactionAspectSupport 类的 invokeWithinTransaction()方法。由于新版本的 Spring 对这部分重写很大,而且用到了很多响应式编程的知识,这里就不列源码了。\nSpring AOP 自调用问题 # 若同一类中的其他没有 @Transactional 注解的方法内部调用有 @Transactional 注解的方法,有@Transactional 注解的方法的事务会失效。\n这是由于Spring AOP代理的原因造成的,因为只有当 @Transactional 注解的方法在类以外被调用的时候,Spring 事务管理才生效。\n因为这里使用了this,而内部调用(使用this)第二个方法其实是使用了原始类,而非代理类\nMyService 类中的method1()调用method2()就会导致method2()的事务失效。\n@Service public class MyService { private void method1() { method2(); //...... } @Transactional public void method2() { //...... } } 解决办法就是避免同一类中自调用或者使用 AspectJ 取代 Spring AOP 代理。\n@Transactional 的使用注意事项总结 # @Transactional 注解只有作用到 public 方法上事务才生效,不推荐在接口上使用; 避免同一个类中调用 @Transactional 注解的方法,这样会导致事务失效; 正确的设置 @Transactional 的 rollbackFor 和 propagation 属性,否则事务可能会回滚失败; 被 @Transactional 注解的方法所在的类必须被 Spring 管理,否则不生效; 底层使用的数据库必须支持事务机制,否则不生效; \u0026hellip;\u0026hellip; 参考 # [总结]Spring 事务管理中@Transactional 的参数: http://www.mobabel.net/spring 事务管理中 transactional 的参数/ Spring 官方文档:https://docs.spring.io/spring/docs/4.2.x/spring-framework-reference/html/transaction.html 《Spring5 高级编程》 透彻的掌握 Spring 中@transactional 的使用: https://www.ibm.com/developerworks/cn/java/j-master-spring-transactional-use/index.html Spring 事务的传播特性: https://github.com/love-somnus/Spring/wiki/Spring 事务的传播特性 Spring 事务传播行为详解 :https://segmentfault.com/a/1190000013341344 全面分析 Spring 的编程式事务管理及声明式事务管理:https://www.ibm.com/developerworks/cn/education/opensource/os-cn-spring-trans/index.html "},{"id":290,"href":"/zh/docs/technology/Review/java_guide/lybly_framework/ly02ly_spring-annotations/","title":"Spring/SpringBoot常用注解","section":"框架","content":" 转载自https://github.com/Snailclimb/JavaGuide(添加小部分笔记)感谢作者!\n0.前言 # 可以毫不夸张地说,这篇文章介绍的 Spring/SpringBoot 常用注解基本已经涵盖你工作中遇到的大部分常用的场景。对于每一个注解我都说了具体用法,掌握搞懂,使用 SpringBoot 来开发项目基本没啥大问题了!\n为什么要写这篇文章?\n最近看到网上有一篇关于 SpringBoot 常用注解的文章被转载的比较多,我看了文章内容之后属实觉得质量有点低,并且有点会误导没有太多实际使用经验的人(这些人又占据了大多数)。所以,自己索性花了大概 两天时间简单总结一下了。\n因为我个人的能力和精力有限,如果有任何不对或者需要完善的地方,请帮忙指出!Guide 哥感激不尽!\n1. @SpringBootApplication # 这里先单独拎出@SpringBootApplication 注解说一下,虽然我们一般不会主动去使用它。\nGuide 哥:这个注解是 Spring Boot 项目的基石,创建 SpringBoot 项目之后会默认在主类加上。\n@SpringBootApplication public class SpringSecurityJwtGuideApplication { public static void main(java.lang.String[] args) { SpringApplication.run(SpringSecurityJwtGuideApplication.class, args); } } 我们可以把 @SpringBootApplication看作是 @Configuration、@EnableAutoConfiguration、@ComponentScan 注解的集合。\npackage org.springframework.boot.autoconfigure; @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 { ...... } package org.springframework.boot; @Target(ElementType.TYPE) @Retention(RetentionPolicy.RUNTIME) @Documented @Configuration public @interface SpringBootConfiguration { } 根据 SpringBoot 官网,这三个注解的作用分别是:\n@EnableAutoConfiguration:启用 SpringBoot 的自动配置机制 (这个是必须的,另外两个可以不要) @ComponentScan: 扫描被@Component (@Repository,@Service,@Controller)注解的 bean,注解默认会扫描该类所在的包下所有的类。 @Configuration:允许在 Spring 上下文中注册额外的 bean 或导入其他配置类 2. Spring Bean 相关 # 2.1. @Autowired # 自动导入对象到类中,被注入进的类同样要被 Spring 容器管理比如:Service 类注入到 Controller 类中。\n@Service public class UserService { ...... } @RestController @RequestMapping(\u0026#34;/users\u0026#34;) public class UserController { @Autowired private UserService userService; ...... } 2.2. @Component,@Repository,@Service, @Controller # 我们一般使用 @Autowired 注解让 Spring 容器帮我们自动装配 bean。要想把类标识成可用于 @Autowired 注解自动装配的 bean 的类,可以采用以下注解实现:\n@Component :通用的注解,可标注任意类为 Spring 组件。如果一个 Bean 不知道属于哪个层,可以使用@Component 注解标注。 @Repository : 对应持久层即 Dao 层,主要用于数据库相关操作。 @Service : 对应服务层,主要涉及一些复杂的逻辑,需要用到 Dao 层。 @Controller : 对应 Spring MVC 控制层,主要用于接受用户请求并调用 Service 层返回数据给前端页面。 2.3. @RestController # @RestController注解是@Controller和@ResponseBody的合集,表示这是个控制器 bean,并且是将函数的返回值直接填入 HTTP 响应体中,是 REST 风格的控制器。\nGuide 哥:现在都是前后端分离,说实话我已经很久没有用过@Controller。如果你的项目太老了的话,就当我没说。\n单独使用 @Controller 不加 @ResponseBody的话一般是用在要返回一个视图的情况,这种情况属于比较传统的 Spring MVC 的应用,对应于前后端不分离的情况。@Controller +@ResponseBody 返回 JSON 或 XML 形式数据\n关于@RestController 和 @Controller的对比,请看这篇文章: @RestController vs @Controller。\n2.4. @Scope # 声明 Spring Bean 的作用域,使用方法:\n@Bean @Scope(\u0026#34;singleton\u0026#34;) public Person personSingleton() { return new Person(); } 四种常见的 Spring Bean 的作用域:\nsingleton : 唯一 bean 实例,Spring 中的 bean 默认都是单例的。 prototype : 每次请求都会创建一个新的 bean 实例。 request : 每一次 HTTP 请求都会产生一个新的 bean,该 bean 仅在当前 HTTP request 内有效。 session : 每一个 HTTP Session 会产生一个新的 bean,该 bean 仅在当前 HTTP session 内有效。 2.5. @Configuration # 一般用来声明配置类,可以使用 @Component注解替代,不过使用@Configuration注解声明配置类更加语义化。(还有就是配置第三方库)\n@Configuration public class AppConfig { @Bean public TransferService transferService() { return new TransferServiceImpl(); } } 3. 处理常见的 HTTP 请求类型 # 5 种常见的请求类型:\nGET :请求从服务器获取特定资源。举个例子:GET /users(获取所有学生) POST :在服务器上创建一个新的资源。举个例子:POST /users(创建学生) PUT :更新服务器上的资源(客户端提供更新后的整个资源)。举个例子:PUT /users/12(更新编号为 12 的学生) DELETE :从服务器删除特定的资源。举个例子:DELETE /users/12(删除编号为 12 的学生) PATCH :更新服务器上的资源(客户端提供更改的属性,可以看做作是部分更新),使用的比较少,这里就不举例子了。 3.1. GET 请求 # @GetMapping(\u0026#34;users\u0026#34;)` 等价于`@RequestMapping(value=\u0026#34;/users\u0026#34;,method=RequestMethod.GET) @GetMapping(\u0026#34;/users\u0026#34;) public ResponseEntity\u0026lt;List\u0026lt;User\u0026gt;\u0026gt; getAllUsers() { return userRepository.findAll(); } 3.2. POST 请求 # @PostMapping(\u0026#34;users\u0026#34;)` 等价于`@RequestMapping(value=\u0026#34;/users\u0026#34;,method=RequestMethod.POST) 关于@RequestBody注解的使用,在下面的“前后端传值”这块会讲到。\n@PostMapping(\u0026#34;/users\u0026#34;) public ResponseEntity\u0026lt;User\u0026gt; createUser(@Valid @RequestBody UserCreateRequest userCreateRequest) { return userRespository.save(userCreateRequest); } 3.3. PUT 请求 # @PutMapping(\u0026#34;/users/{userId}\u0026#34;)` 等价于`@RequestMapping(value=\u0026#34;/users/{userId}\u0026#34;,method=RequestMethod.PUT) @PutMapping(\u0026#34;/users/{userId}\u0026#34;) public ResponseEntity\u0026lt;User\u0026gt; updateUser(@PathVariable(value = \u0026#34;userId\u0026#34;) Long userId, @Valid @RequestBody UserUpdateRequest userUpdateRequest) { ...... } 3.4. DELETE 请求 # @DeleteMapping(\u0026#34;/users/{userId}\u0026#34;)`等价于`@RequestMapping(value=\u0026#34;/users/{userId}\u0026#34;,method=RequestMethod.DELETE) @DeleteMapping(\u0026#34;/users/{userId}\u0026#34;) public ResponseEntity deleteUser(@PathVariable(value = \u0026#34;userId\u0026#34;) Long userId){ ...... } 3.5. PATCH 请求 # 一般实际项目中,我们都是 PUT 不够用了之后才用 PATCH 请求去更新数据。\n@PatchMapping(\u0026#34;/profile\u0026#34;) public ResponseEntity updateStudent(@RequestBody StudentUpdateRequest studentUpdateRequest) { studentRepository.updateDetail(studentUpdateRequest); return ResponseEntity.ok().build(); } 4. 前后端传值 # 掌握前后端传值的正确姿势,是你开始 CRUD 的第一步!\n4.1. @PathVariable 和 @RequestParam # @PathVariable用于获取路径参数,@RequestParam用于获取查询参数。\n举个简单的例子:\n@GetMapping(\u0026#34;/klasses/{klassId}/teachers\u0026#34;) public List\u0026lt;Teacher\u0026gt; getKlassRelatedTeachers( @PathVariable(\u0026#34;klassId\u0026#34;) Long klassId, @RequestParam(value = \u0026#34;type\u0026#34;, required = false) String type ) { ... } 如果我们请求的 url 是:/klasses/123456/teachers?type=web\n那么我们服务获取到的数据就是:klassId=123456,type=web。\n4.2. @RequestBody # 用于读取 Request 请求(可能是 POST,PUT,DELETE,GET 请求)的 body 部分并且Content-Type 为 application/json 格式的数据,接收到数据之后会自动将数据绑定到 Java 对象上去。系统会使用HttpMessageConverter或者自定义的HttpMessageConverter将请求的 body 中的 json 字符串转换为 java 对象。\n我用一个简单的例子来给演示一下基本使用!\n我们有一个注册的接口:\n@PostMapping(\u0026#34;/sign-up\u0026#34;) public ResponseEntity signUp(@RequestBody @Valid UserRegisterRequest userRegisterRequest) { userService.save(userRegisterRequest); return ResponseEntity.ok().build(); } UserRegisterRequest对象:\n@Data @AllArgsConstructor @NoArgsConstructor public class UserRegisterRequest { @NotBlank private String userName; @NotBlank private String password; @NotBlank private String fullName; } 我们发送 post 请求到这个接口,并且 body 携带 JSON 数据:\n{\u0026#34;userName\u0026#34;:\u0026#34;coder\u0026#34;,\u0026#34;fullName\u0026#34;:\u0026#34;shuangkou\u0026#34;,\u0026#34;password\u0026#34;:\u0026#34;123456\u0026#34;} 这样我们的后端就可以直接把 json 格式的数据映射到我们的 UserRegisterRequest 类上。\n👉 需要注意的是:一个请求方法只可以有一个@RequestBody,但是可以有多个@RequestParam和@PathVariable。 如果你的方法必须要用两个 @RequestBody来接受数据的话,大概率是你的数据库设计或者系统设计出问题了!\n5. 读取配置信息 # 很多时候我们需要将一些常用的配置信息比如阿里云 oss、发送短信、微信认证的相关配置信息等等放到配置文件中。\n下面我们来看一下 Spring 为我们提供了哪些方式帮助我们从配置文件中读取这些配置信息。\n我们的数据源application.yml内容如下:\nwuhan2020: 2020年初武汉爆发了新型冠状病毒,疫情严重,但是,我相信一切都会过去!武汉加油!中国加油! my-profile: name: Guide哥 email: koushuangbwcx@163.com library: location: 湖北武汉加油中国加油 books: - name: 天才基本法 description: 二十二岁的林朝夕在父亲确诊阿尔茨海默病这天,得知自己暗恋多年的校园男神裴之即将出国深造的消息——对方考取的学校,恰是父亲当年为她放弃的那所。 - name: 时间的秩序 description: 为什么我们记得过去,而非未来?时间“流逝”意味着什么?是我们存在于时间之内,还是时间存在于我们之中?卡洛·罗韦利用诗意的文字,邀请我们思考这一亘古难题——时间的本质。 - name: 了不起的我 description: 如何养成一个新习惯?如何让心智变得更成熟?如何拥有高质量的关系? 如何走出人生的艰难时刻? 5.1. @Value(常用) # 使用 @Value(\u0026quot;${property}\u0026quot;) 读取比较简单的配置信息:\n@Value(\u0026#34;${wuhan2020}\u0026#34;) String wuhan2020; 5.2. @ConfigurationProperties(常用) # 通过@ConfigurationProperties读取配置信息并与 bean 绑定。\n@Component @ConfigurationProperties(prefix = \u0026#34;library\u0026#34;) class LibraryProperties { @NotEmpty private String location; private List\u0026lt;Book\u0026gt; books; @Setter @Getter @ToString static class Book { String name; String description; } 省略getter/setter ...... } 你可以像使用普通的 Spring bean 一样,将其注入到类中使用。\n5.3. @PropertySource(不常用) # @PropertySource读取指定 properties 文件\n@Component @PropertySource(\u0026#34;classpath:website.properties\u0026#34;) class WebSite { @Value(\u0026#34;${url}\u0026#34;) private String url; 省略getter/setter ...... } 更多内容请查看我的这篇文章: 《10 分钟搞定 SpringBoot 如何优雅读取配置文件?》 。\n6. 参数校验 # 数据的校验的重要性就不用说了,即使在前端对数据进行校验的情况下,我们还是要对传入后端的数据再进行一遍校验,避免用户绕过浏览器直接通过一些 HTTP 工具直接向后端请求一些违法数据。\nJSR(Java Specification Requests) 是一套 JavaBean 参数校验的标准,它定义了很多常用的校验注解,我们可以直接将这些注解加在我们 JavaBean 的属性上面,这样就可以在需要校验的时候进行校验了,非常方便!\n校验的时候我们实际用的是 Hibernate Validator 框架。Hibernate Validator 是 Hibernate 团队最初的数据校验框架,Hibernate Validator 4.x 是 Bean Validation 1.0(JSR 303)的参考实现,Hibernate Validator 5.x 是 Bean Validation 1.1(JSR 349)的参考实现,目前最新版的 Hibernate Validator 6.x 是 Bean Validation 2.0(JSR 380)的参考实现。\nSpringBoot 项目的 spring-boot-starter-web 依赖中已经有 hibernate-validator 包,不需要引用相关依赖。如下图所示(通过 idea 插件—Maven Helper 生成):\n注:更新版本的 spring-boot-starter-web 依赖中不再有 hibernate-validator 包(如2.3.11.RELEASE),需要自己引入 spring-boot-starter-validation 依赖。\n非 SpringBoot 项目需要自行引入相关依赖包,这里不多做讲解,具体可以查看我的这篇文章:《 如何在 Spring/Spring Boot 中做参数校验?你需要了解的都在这里!》。\n👉 需要注意的是: 所有的注解,推荐使用 JSR 注解,即javax.validation.constraints,而不是org.hibernate.validator.constraints\n6.1. 一些常用的字段验证的注解 # @NotEmpty 被注释的字符串的不能为 null 也不能为空 @NotBlank 被注释的字符串非 null,并且必须包含一个非空白字符 @Null 被注释的元素必须为 null @NotNull 被注释的元素必须不为 null @AssertTrue 被注释的元素必须为 true @AssertFalse 被注释的元素必须为 false @Pattern(regex=,flag=)被注释的元素必须符合指定的正则表达式 @Email 被注释的元素必须是 Email 格式。 @Min(value)被注释的元素必须是一个数字,其值必须大于等于指定的最小值 @Max(value)被注释的元素必须是一个数字,其值必须小于等于指定的最大值 @DecimalMin(value)被注释的元素必须是一个数字,其值必须大于等于指定的最小值 @DecimalMax(value) 被注释的元素必须是一个数字,其值必须小于等于指定的最大值 @Size(max=, min=)被注释的元素的大小必须在指定的范围内 @Digits(integer, fraction)被注释的元素必须是一个数字,其值必须在可接受的范围内 @Past被注释的元素必须是一个过去的日期 @Future 被注释的元素必须是一个将来的日期 \u0026hellip;\u0026hellip; 6.2. 验证请求体(RequestBody) # @Data @AllArgsConstructor @NoArgsConstructor public class Person { @NotNull(message = \u0026#34;classId 不能为空\u0026#34;) private String classId; @Size(max = 33) @NotNull(message = \u0026#34;name 不能为空\u0026#34;) private String name; @Pattern(regexp = \u0026#34;((^Man$|^Woman$|^UGM$))\u0026#34;, message = \u0026#34;sex 值不在可选范围\u0026#34;) @NotNull(message = \u0026#34;sex 不能为空\u0026#34;) private String sex; @Email(message = \u0026#34;email 格式不正确\u0026#34;) @NotNull(message = \u0026#34;email 不能为空\u0026#34;) private String email; } 我们在需要验证的参数上加上了@Valid注解,如果验证失败,它将抛出MethodArgumentNotValidException。\n@RestController @RequestMapping(\u0026#34;/api\u0026#34;) public class PersonController { @PostMapping(\u0026#34;/person\u0026#34;) public ResponseEntity\u0026lt;Person\u0026gt; getPerson(@RequestBody @Valid Person person) { return ResponseEntity.ok().body(person); } } 6.3. 验证请求参数(Path Variables 和 Request Parameters) # 一定一定不要忘记在类上加上 @Validated 注解了,这个参数可以告诉 Spring 去校验方法参数。\n@RestController @RequestMapping(\u0026#34;/api\u0026#34;) @Validated public class PersonController { @GetMapping(\u0026#34;/person/{id}\u0026#34;) public ResponseEntity\u0026lt;Integer\u0026gt; getPersonByID(@Valid @PathVariable(\u0026#34;id\u0026#34;) @Max(value = 5,message = \u0026#34;超过 id 的范围了\u0026#34;) Integer id) { return ResponseEntity.ok().body(id); } } 更多关于如何在 Spring 项目中进行参数校验的内容,请看《 如何在 Spring/Spring Boot 中做参数校验?你需要了解的都在这里!》这篇文章。\n7. 全局处理 Controller 层异常 # 介绍一下我们 Spring 项目必备的全局处理 Controller 层异常。\n相关注解:\n@ControllerAdvice :注解定义全局异常处理类 @ExceptionHandler :注解声明异常处理方法 如何使用呢?拿我们在第 5 节参数校验这块来举例子。如果方法参数不对的话就会抛出MethodArgumentNotValidException,我们来处理这个异常。\n@ControllerAdvice @ResponseBody public class GlobalExceptionHandler { /** * 请求参数异常处理 */ @ExceptionHandler(MethodArgumentNotValidException.class) public ResponseEntity\u0026lt;?\u0026gt; handleMethodArgumentNotValidException(MethodArgumentNotValidException ex, HttpServletRequest request) { ...... } } 更多关于 Spring Boot 异常处理的内容,请看我的这两篇文章:\nSpringBoot 处理异常的几种常见姿势 使用枚举简单封装一个优雅的 Spring Boot 全局异常处理! 8. JPA 相关 # 8.1. 创建表 # @Entity声明一个类对应一个数据库实体。\n@Table 设置表名\n@Entity @Table(name = \u0026#34;role\u0026#34;) public class Role { @Id @GeneratedValue(strategy = GenerationType.IDENTITY) private Long id; private String name; private String description; 省略getter/setter...... } 8.2. 创建主键 # @Id :声明一个字段为主键。\n使用@Id声明之后,我们还需要定义主键的生成策略。我们可以使用 @GeneratedValue 指定主键生成策略。\n1.通过 @GeneratedValue直接使用 JPA 内置提供的四种主键生成策略来指定主键生成策略。\n@Id @GeneratedValue(strategy = GenerationType.IDENTITY) private Long id; JPA 使用枚举定义了 4 种常见的主键生成策略,如下:\nGuide 哥:枚举替代常量的一种用法\npublic enum GenerationType { /** * 使用一个特定的数据库表格来保存主键 * 持久化引擎通过关系数据库的一张特定的表格来生成主键, */ TABLE, /** *在某些数据库中,不支持主键自增长,比如Oracle、PostgreSQL其提供了一种叫做\u0026#34;序列(sequence)\u0026#34;的机制生成主键 */ SEQUENCE, /** * 主键自增长 */ IDENTITY, /** *把主键生成策略交给持久化引擎(persistence engine), *持久化引擎会根据数据库在以上三种主键生成 策略中选择其中一种 */ AUTO } @GeneratedValue`注解默认使用的策略是`GenerationType.AUTO public @interface GeneratedValue { GenerationType strategy() default AUTO; String generator() default \u0026#34;\u0026#34;; } 一般使用 MySQL 数据库的话,使用GenerationType.IDENTITY策略比较普遍一点(分布式系统的话需要另外考虑使用分布式 ID)。\n2.通过 @GenericGenerator声明一个主键策略,然后 @GeneratedValue使用这个策略\n@Id @GeneratedValue(generator = \u0026#34;IdentityIdGenerator\u0026#34;) @GenericGenerator(name = \u0026#34;IdentityIdGenerator\u0026#34;, strategy = \u0026#34;identity\u0026#34;) private Long id; 等价于:\n@Id @GeneratedValue(strategy = GenerationType.IDENTITY) private Long id; jpa 提供的主键生成策略有如下几种:\npublic class DefaultIdentifierGeneratorFactory implements MutableIdentifierGeneratorFactory, Serializable, ServiceRegistryAwareService { @SuppressWarnings(\u0026#34;deprecation\u0026#34;) public DefaultIdentifierGeneratorFactory() { register( \u0026#34;uuid2\u0026#34;, UUIDGenerator.class ); register( \u0026#34;guid\u0026#34;, GUIDGenerator.class );\t// can be done with UUIDGenerator + strategy register( \u0026#34;uuid\u0026#34;, UUIDHexGenerator.class );\t// \u0026#34;deprecated\u0026#34; for new use register( \u0026#34;uuid.hex\u0026#34;, UUIDHexGenerator.class ); // uuid.hex is deprecated register( \u0026#34;assigned\u0026#34;, Assigned.class ); register( \u0026#34;identity\u0026#34;, IdentityGenerator.class ); register( \u0026#34;select\u0026#34;, SelectGenerator.class ); register( \u0026#34;sequence\u0026#34;, SequenceStyleGenerator.class ); register( \u0026#34;seqhilo\u0026#34;, SequenceHiLoGenerator.class ); register( \u0026#34;increment\u0026#34;, IncrementGenerator.class ); register( \u0026#34;foreign\u0026#34;, ForeignGenerator.class ); register( \u0026#34;sequence-identity\u0026#34;, SequenceIdentityGenerator.class ); register( \u0026#34;enhanced-sequence\u0026#34;, SequenceStyleGenerator.class ); register( \u0026#34;enhanced-table\u0026#34;, TableGenerator.class ); } public void register(String strategy, Class generatorClass) { LOG.debugf( \u0026#34;Registering IdentifierGenerator strategy [%s] -\u0026gt; [%s]\u0026#34;, strategy, generatorClass.getName() ); final Class previous = generatorStrategyToClassNameMap.put( strategy, generatorClass ); if ( previous != null ) { LOG.debugf( \u0026#34; - overriding [%s]\u0026#34;, previous.getName() ); } } } 8.3. 设置字段类型 # @Column 声明字段。\n示例:\n设置属性 userName 对应的数据库字段名为 user_name,长度为 32,非空\n@Column(name = \u0026#34;user_name\u0026#34;, nullable = false, length=32) private String userName; 设置字段类型并且加默认值,这个还是挺常用的。\n@Column(columnDefinition = \u0026#34;tinyint(1) default 1\u0026#34;) private Boolean enabled; 8.4. 指定不持久化特定字段 # @Transient :声明不需要与数据库映射的字段,在保存的时候不需要保存进数据库 。\n如果我们想让secrect 这个字段不被持久化,可以使用 @Transient关键字声明。\n@Entity(name=\u0026#34;USER\u0026#34;) public class User { ...... @Transient private String secrect; // not persistent because of @Transient } 除了 @Transient关键字声明, 还可以采用下面几种方法:\nstatic String secrect; // not persistent because of static final String secrect = \u0026#34;Satish\u0026#34;; // not persistent because of final transient String secrect; // not persistent because of transient 一般使用注解的方式比较多。\n8.5. 声明大字段 # @Lob:声明某个字段为大字段。\n@Lob private String content; 更详细的声明:\n@Lob //指定 Lob 类型数据的获取策略, FetchType.EAGER 表示非延迟加载,而 FetchType.LAZY 表示延迟加载 ; @Basic(fetch = FetchType.EAGER) //columnDefinition 属性指定数据表对应的 Lob 字段类型 @Column(name = \u0026#34;content\u0026#34;, columnDefinition = \u0026#34;LONGTEXT NOT NULL\u0026#34;) private String content; 8.6. 创建枚举类型的字段 # 可以使用枚举类型的字段,不过枚举字段要用@Enumerated注解修饰。\npublic enum Gender { MALE(\u0026#34;男性\u0026#34;), FEMALE(\u0026#34;女性\u0026#34;); private String value; Gender(String str){ value=str; } } @Entity @Table(name = \u0026#34;role\u0026#34;) public class Role { @Id @GeneratedValue(strategy = GenerationType.IDENTITY) private Long id; private String name; private String description; @Enumerated(EnumType.STRING) private Gender gender; 省略getter/setter...... } 数据库里面对应存储的是 MALE/FEMALE。\n8.7. 增加审计功能 # 只要继承了 AbstractAuditBase的类都会默认加上下面四个字段。\n@Data @AllArgsConstructor @NoArgsConstructor @MappedSuperclass @EntityListeners(value = AuditingEntityListener.class) public abstract class AbstractAuditBase { @CreatedDate @Column(updatable = false) @JsonIgnore private Instant createdAt; @LastModifiedDate @JsonIgnore private Instant updatedAt; @CreatedBy @Column(updatable = false) @JsonIgnore private String createdBy; @LastModifiedBy @JsonIgnore private String updatedBy; } 我们对应的审计功能对应地配置类可能是下面这样的(Spring Security 项目):\n@Configuration @EnableJpaAuditing public class AuditSecurityConfiguration { @Bean AuditorAware\u0026lt;String\u0026gt; auditorAware() { return () -\u0026gt; Optional.ofNullable(SecurityContextHolder.getContext()) .map(SecurityContext::getAuthentication) .filter(Authentication::isAuthenticated) .map(Authentication::getName); } } 简单介绍一下上面涉及到的一些注解:\n@CreatedDate: 表示该字段为创建时间字段,在这个实体被 insert 的时候,会设置值\n@CreatedBy :表示该字段为创建人,在这个实体被 insert 的时候,会设置值\n@LastModifiedDate、@LastModifiedBy同理。\n@EnableJpaAuditing:开启 JPA 审计功能。\n8.8. 删除/修改数据 # @Modifying 注解提示 JPA 该操作是修改操作,注意还要配合@Transactional注解使用。\n@Repository public interface UserRepository extends JpaRepository\u0026lt;User, Integer\u0026gt; { @Modifying @Transactional(rollbackFor = Exception.class) void deleteByUserName(String userName); } 8.9. 关联关系 # @OneToOne 声明一对一关系 @OneToMany 声明一对多关系 @ManyToOne 声明多对一关系 @ManyToMany 声明多对多关系 更多关于 Spring Boot JPA 的文章请看我的这篇文章: 一文搞懂如何在 Spring Boot 正确中使用 JPA 。\n9. 事务 @Transactional # 在要开启事务的方法上使用@Transactional注解即可!\n@Transactional(rollbackFor = Exception.class) public void save() { ...... } 我们知道 Exception 分为运行时异常 RuntimeException 和非运行时异常。在@Transactional注解中如果不配置rollbackFor属性,那么事务只会在遇到RuntimeException的时候才会回滚,加上rollbackFor=Exception.class,可以让事务在遇到非运行时异常时也回滚。\n@Transactional 注解一般可以作用在类或者方法上。\n作用于类:当把@Transactional 注解放在类上时,表示所有该类的 public 方法都配置相同的事务属性信息。 作用于方法:当类配置了@Transactional,方法也配置了@Transactional,方法的事务会覆盖 类的事务配置信息。 更多关于 Spring 事务的内容请查看我的这篇文章: 可能是最漂亮的 Spring 事务管理详解 。\n10. json 数据处理 # 10.1. 过滤 json 数据 # @JsonIgnoreProperties 作用在类上用于过滤掉特定字段不返回或者不解析。\n//生成json时将userRoles属性过滤 @JsonIgnoreProperties({\u0026#34;userRoles\u0026#34;}) public class User { private String userName; private String fullName; private String password; private List\u0026lt;UserRole\u0026gt; userRoles = new ArrayList\u0026lt;\u0026gt;(); } @JsonIgnore一般用于类的属性上,作用和上面的@JsonIgnoreProperties 一样。\npublic class User { private String userName; private String fullName; private String password; //生成json时将userRoles属性过滤 @JsonIgnore private List\u0026lt;UserRole\u0026gt; userRoles = new ArrayList\u0026lt;\u0026gt;(); } 10.2. 格式化 json 数据 # @JsonFormat一般用来格式化 json 数据。\n比如:\n@JsonFormat(shape=JsonFormat.Shape.STRING, pattern=\u0026#34;yyyy-MM-dd\u0026#39;T\u0026#39;HH:mm:ss.SSS\u0026#39;Z\u0026#39;\u0026#34;, timezone=\u0026#34;GMT\u0026#34;) private Date date; 10.3. 扁平化对象 # @Getter @Setter @ToString public class Account { private Location location; private PersonInfo personInfo; @Getter @Setter @ToString public static class Location { private String provinceName; private String countyName; } @Getter @Setter @ToString public static class PersonInfo { private String userName; private String fullName; } } 未扁平化之前:\n{ \u0026#34;location\u0026#34;: { \u0026#34;provinceName\u0026#34;:\u0026#34;湖北\u0026#34;, \u0026#34;countyName\u0026#34;:\u0026#34;武汉\u0026#34; }, \u0026#34;personInfo\u0026#34;: { \u0026#34;userName\u0026#34;: \u0026#34;coder1234\u0026#34;, \u0026#34;fullName\u0026#34;: \u0026#34;shaungkou\u0026#34; } } 使用@JsonUnwrapped **扁平对象(外面那层没了)**之后:\n@Getter @Setter @ToString public class Account { @JsonUnwrapped private Location location; @JsonUnwrapped private PersonInfo personInfo; ...... } { \u0026#34;provinceName\u0026#34;:\u0026#34;湖北\u0026#34;, \u0026#34;countyName\u0026#34;:\u0026#34;武汉\u0026#34;, \u0026#34;userName\u0026#34;: \u0026#34;coder1234\u0026#34;, \u0026#34;fullName\u0026#34;: \u0026#34;shaungkou\u0026#34; } 11. 测试相关 # @ActiveProfiles一般作用于测试类上, 用于声明生效的 Spring 配置文件。\n@SpringBootTest(webEnvironment = RANDOM_PORT) @ActiveProfiles(\u0026#34;test\u0026#34;) @Slf4j public abstract class TestBase { ...... } @Test声明一个方法为测试方法\n@Transactional被声明的测试方法的数据会回滚,避免污染测试数据。\n@WithMockUser Spring Security 提供的,用来模拟一个真实用户,并且可以赋予权限。\n@Test @Transactional @WithMockUser(username = \u0026#34;user-id-18163138155\u0026#34;, authorities = \u0026#34;ROLE_TEACHER\u0026#34;) void should_import_student_success() throws Exception { ...... } 暂时总结到这里吧!虽然花了挺长时间才写完,不过可能还是会一些常用的注解的被漏掉,所以,我将文章也同步到了 Github 上去,Github 地址: 欢迎完善!\n本文已经收录进我的 75K Star 的 Java 开源项目 JavaGuide:https://github.com/Snailclimb/JavaGuide。\n"},{"id":291,"href":"/zh/docs/technology/Review/java_guide/lybly_framework/ly01ly_spring-knowledge-and-questions-summary/","title":"spring 常见面试题总结","section":"框架","content":" 转载自https://github.com/Snailclimb/JavaGuide(添加小部分笔记)感谢作者!\n这篇文章主要是想通过一些问题,加深大家对于 Spring 的理解,所以不会涉及太多的代码!\n下面的很多问题我自己在使用 Spring 的过程中也并没有注意,自己也是临时查阅了很多资料和书籍补上的。网上也有一些很多关于 Spring 常见问题/面试题整理的文章,我感觉大部分都是互相 copy,而且很多问题也不是很好,有些回答也存在问题。所以,自己花了一周的业余时间整理了一下,希望对大家有帮助。\nSpring 基础 # 什么是 Spring 框架? # Spring 是一款开源的轻量级 Java 开发框架,旨在提高开发人员的开发效率以及系统的可维护性。\n我们一般说 Spring 框架指的都是 Spring Framework,它是很多模块的集合,使用这些模块可以很方便地协助我们进行开发,比如说 Spring 支持 IoC(Inversion of Control:控制反转) 和 AOP(Aspect-Oriented Programming:面向切面编程)、可以很方便地对数据库进行访问、可以很方便地集成第三方组件(电子邮件,任务,调度,缓存等等)、对单元测试支持比较好、支持 RESTful Java 应用程序的开发。\n[\nSpring 最核心的思想就是不重新造轮子,开箱即用,提高开发效率。\nSpring 翻译过来就是春天的意思,可见其目标和使命就是为 Java 程序员带来春天啊!感动!\n🤐 多提一嘴 : 语言的流行通常需要一个杀手级的应用,Spring 就是 Java 生态的一个杀手级的应用框架。\nSpring 提供的核心功能主要是 IoC 和 AOP。学习 Spring ,一定要把 IoC 和 AOP 的核心思想搞懂!\nSpring 官网:https://spring.io/ Github 地址: https://github.com/spring-projects/spring-framework Spring 包含的模块有哪些? # Spring4.x 版本 :\nSpring5.x 版本 :\nSpring5.x 版本中 Web 模块的 Sertlet (应该是Servlet 吧)组件已经被废弃掉,同时增加了用于异步响应式处理的 WebFlux 组件。\nSpring 各个模块的依赖关系如下: Core Container # Spring 框架的核心模块,也可以说是基础模块,主要提供 IoC 依赖注入功能的支持。Spring 其他所有的功能基本都需要依赖于该模块,我们从上面那张 Spring 各个模块的依赖关系图就可以看出来。\nspring-core :Spring 框架基本的核心工具类。 spring-beans :提供对 bean 的创建、配置和管理等功能的支持。 spring-context :提供对国际化、事件传播、资源加载等功能的支持。 spring-expression :提供对表达式语言(Spring Expression Language) SpEL 的支持,只依赖于 core 模块,不依赖于其他模块,可以单独使用。 AOP # spring-aspects :该模块为与 AspectJ 的集成提供支持。 spring-aop :提供了面向切面的编程实现。 spring-instrument :提供了为 JVM 添加代理(agent)的功能。 具体来讲,它为 Tomcat 提供了一个织入代理,能够为 Tomcat 传递类文 件,就像这些文件是被类加载器加载的一样。没有理解也没关系,这个模块的使用场景非常有限。 Data Access/Integration # spring-jdbc :提供了对数据库访问的抽象 JDBC。不同的数据库都有自己独立的 API 用于操作数据库,而 Java 程序只需要和 JDBC API 交互,这样就屏蔽了数据库的影响。 spring-tx :提供对事务的支持。 spring-orm : 提供对 Hibernate、JPA 、iBatis 等 ORM 框架的支持。 spring-oxm :提供一个抽象层支撑 OXM(Object-to-XML-Mapping),例如:JAXB、Castor、XMLBeans、JiBX 和 XStream 等。 spring-jms : 消息服务。自 Spring Framework 4.1 以后,它还提供了对 spring-messaging 模块的继承。 Spring Web # spring-web :对 Web 功能的实现提供一些最基础的支持。 spring-webmvc : 提供对 Spring MVC 的实现。 spring-websocket : 提供了对 WebSocket 的支持,WebSocket 可以让客户端和服务端进行双向通信。 spring-webflux :提供对 WebFlux 的支持。WebFlux 是 Spring Framework 5.0 中引入的新的响应式框架。与 Spring MVC 不同,它不需要 Servlet API,是完全异步。 Messaging # spring-messaging 是从 Spring4.0 开始新加入的一个模块,主要职责是为 Spring 框架集成一些基础的报文传送应用。\nSpring Test # Spring 团队提倡测试驱动开发(TDD)。有了控制反转 (IoC)的帮助,单元测试和集成测试变得更简单。\nSpring 的测试模块对 JUnit(单元测试框架)、TestNG(类似 JUnit)、Mockito(主要用来 Mock 对象)、PowerMock(解决 Mockito 的问题比如无法模拟 final, static, private 方法)等等常用的测试框架支持的都比较好。\nSpring,Spring MVC,Spring Boot 之间什么关系? # 很多人对 Spring,Spring MVC,Spring Boot 这三者傻傻分不清楚!这里简单介绍一下这三者,其实很简单,没有什么高深的东西。\nSpring 包含了多个功能模块(上面刚刚提到过),其中最重要的是 Spring-Core(主要提供 IoC 依赖注入功能的支持) 模块, Spring 中的其他模块(比如 Spring MVC)的功能实现基本都需要依赖于该模块。\n下图对应的是 Spring4.x 版本。目前最新的 5.x 版本中 Web 模块的 Portlet 组件已经被废弃掉,同时增加了用于异步响应式处理的 WebFlux 组件。\nSpring MVC 是 Spring 中的一个很重要的模块,主要赋予 Spring 快速构建 MVC 架构的 Web 程序的能力。MVC 是模型(Model)、视图(View)、控制器(Controller)的简写,其核心思想是通过将业务逻辑、数据、显示分离来组织代码。\n使用 Spring 进行开发各种配置过于麻烦比如开启某些 Spring 特性时,需要用 XML 或 Java 进行显式配置。于是,Spring Boot 诞生了!\nSpring 旨在简化 J2EE 企业应用程序开发。Spring Boot 旨在简化 Spring 开发(减少配置文件,开箱即用!)。\nSpring Boot 只是简化了配置,如果你需要构建 MVC 架构的 Web 程序,你还是需要使用 Spring MVC 作为 MVC 框架,只是说 Spring Boot 帮你简化了 Spring MVC 的很多配置,真正做到开箱即用!\nSpring IoC # 谈谈自己对于 Spring IoC 的了解 # IoC(Inversion of Control:控制反转) 是一种设计思想,而不是一个具体的技术实现。IoC 的思想就是将原本在程序中手动创建对象的控制权,交由 Spring 框架来管理。不过, IoC 并非 Spring 特有,在其他语言中也有应用。\n为什么叫控制反转?\n控制 :指的是对象创建(实例化、管理)的权力 反转 :控制权交给外部环境(Spring 框架、IoC 容器) 将对象之间的相互依赖关系交给 IoC 容器来管理,并由 IoC 容器完成对象的注入。这样可以很大程度上简化应用的开发,把应用从复杂的依赖关系中解放出来。 IoC 容器就像是一个工厂一样,当我们需要创建一个对象的时候,只需要配置好配置文件/注解即可,完全不用考虑对象是如何被创建出来的。\n在实际项目中一个 Service 类可能依赖了很多其他的类,假如我们需要实例化这个 Service,你可能要每次都要搞清这个 Service 所有底层类的构造函数,这可能会把人逼疯。如果利用 IoC 的话,你只需要配置好,然后在需要的地方引用就行了,这大大增加了项目的可维护性且降低了开发难度。\n在 Spring 中, IoC 容器是 Spring 用来实现 IoC 的载体, IoC 容器实际上就是个 Map(key,value),Map 中存放的是各种对象。\nSpring 时代我们一般通过 XML 文件来配置 Bean,后来开发人员觉得 XML 文件来配置不太好,于是 SpringBoot 注解配置就慢慢开始流行起来。\n相关阅读:\nIoC 源码阅读 面试被问了几百遍的 IoC 和 AOP ,还在傻傻搞不清楚? 什么是 Spring Bean? # 简单来说,Bean 代指的就是那些被 IoC 容器所管理的对象。\n我们需要告诉 IoC 容器帮助我们管理哪些对象,这个是通过配置元数据来定义的。配置元数据可以是 XML 文件、注解或者 Java 配置类。\n\u0026lt;!-- Constructor-arg with \u0026#39;value\u0026#39; attribute --\u0026gt; \u0026lt;bean id=\u0026#34;...\u0026#34; class=\u0026#34;...\u0026#34;\u0026gt; \u0026lt;constructor-arg value=\u0026#34;...\u0026#34;/\u0026gt; \u0026lt;/bean\u0026gt; 下图简单地展示了 IoC 容器如何使用配置元数据来管理对象。\norg.springframework.beans和 org.springframework.context 这两个包是 IoC 实现的基础,如果想要研究 IoC 相关的源码的话,可以去看看\n将一个类声明为 Bean 的注解有哪些? # @Component :通用的注解,可标注任意类为 Spring 组件。如果一个 Bean 不知道属于哪个层,可以使用@Component 注解标注。 @Repository : 对应持久层即 Dao 层,主要用于数据库相关操作。 @Service : 对应服务层,主要涉及一些复杂的逻辑,需要用到 Dao 层。 @Controller : 对应 Spring MVC 控制层,主要用户接受用户请求并调用 Service 层返回数据给前端页面。 @Component 和 @Bean 的区别是什么? # @Component 注解作用于类,而@Bean注解作用于方法。 @Component通常是通过类路径扫描来自动侦测以及自动装配到 Spring 容器中(我们可以使用 @ComponentScan 注解定义要扫描的路径从中找出标识了需要装配的类自动装配到 Spring 的 bean 容器中)。@Bean 注解通常是我们在标有该注解的方法中定义产生这个 bean,@Bean告诉了 Spring 这是某个类的实例,当我需要用它的时候还给我。 @Bean 注解比 @Component 注解的自定义性更强,而且很多地方我们只能通过 @Bean 注解来注册 bean。比如当我们引用第三方库中的类需要装配到 Spring容器时,则只能通过 @Bean来实现。 @Bean注解使用示例:\n@Configuration public class AppConfig { @Bean public TransferService transferService() { return new TransferServiceImpl(); } } 上面的代码相当于下面的 xml 配置\n\u0026lt;beans\u0026gt; \u0026lt;bean id=\u0026#34;transferService\u0026#34; class=\u0026#34;com.acme.TransferServiceImpl\u0026#34;/\u0026gt; \u0026lt;/beans\u0026gt; 下面这个例子是通过 @Component 无法实现的。(带有逻辑)\n@Bean public OneService getService(status) { case (status) { when 1: return new serviceImpl1(); when 2: return new serviceImpl2(); when 3: return new serviceImpl3(); } } 注入 Bean 的注解有哪些? # Spring 内置的 @Autowired 以及 JDK 内置的 @Resource 和 @Inject 都可以用于注入 Bean。\nAnnotaion Package Source @Autowired org.springframework.bean.factory Spring 2.5+ @Resource javax.annotation Java JSR-250 @Inject javax.inject Java JSR-330 @Autowired 和@Resource使用的比较多一些。\n@Autowired 和 @Resource 的区别是什么? # Autowired 属于 Spring 内置的注解,默认的注入方式为byType(根据类型进行匹配),也就是说会优先根据接口类型去匹配并注入 Bean (接口的实现类)。\n这会有什么问题呢? 当一个接口存在多个实现类的话,byType这种方式就无法正确注入对象了,因为这个时候 Spring 会同时找到多个满足条件的选择,默认情况下它自己不知道选择哪一个。\n这种情况下,注入方式会变为 byName(根据名称进行匹配),这个名称通常就是类名(首字母小写)。就比如说下面代码中的 smsService 就是我这里所说的名称,这样应该比较好理解了吧。\n// smsService 就是我们上面所说的名称 @Autowired private SmsService smsService; 举个例子,SmsService 接口有两个实现类: SmsServiceImpl1和 SmsServiceImpl2,且它们都已经被 Spring 容器所管理。\n// 报错,byName 和 byType 都无法匹配到 bean @Autowired private SmsService smsService; // 正确注入 SmsServiceImpl1 对象对应的 bean @Autowired private SmsService smsServiceImpl1; // 正确注入 SmsServiceImpl1 对象对应的 bean // smsServiceImpl1 就是我们上面所说的名称 @Autowired @Qualifier(value = \u0026#34;smsServiceImpl1\u0026#34;) private SmsService smsService; 我们还是建议通过 @Qualifier 注解来显式指定名称而不是依赖变量的名称。\n@Resource属于 JDK 提供的注解,默认注入方式为 byName。如果无法通过名称匹配到对应的 Bean 的话,注入方式会变为byType。\n@Resource 有两个比较重要且日常开发常用的属性:name(名称)、type(类型)。\npublic @interface Resource { String name() default \u0026#34;\u0026#34;; Class\u0026lt;?\u0026gt; type() default Object.class; } 如果仅指定 name 属性则注入方式为byName,如果仅指定type属性则注入方式为byType,如果同时指定name 和type属性(不建议这么做)则注入方式为byType+byName。\n// 报错,byName 和 byType 都无法匹配到 bean @Resource private SmsService smsService; // 正确注入 SmsServiceImpl1 对象对应的 bean @Resource private SmsService smsServiceImpl1; // 正确注入 SmsServiceImpl1 对象对应的 bean(比较推荐这种方式) @Resource(name = \u0026#34;smsServiceImpl1\u0026#34;) private SmsService smsService; 简单总结一下:\n@Autowired 是 Spring 提供的注解,@Resource 是 JDK 提供的注解。 Autowired 默认的注入方式为**byType(根据类型进行匹配)**,@Resource默认注入方式为 byName(根据名称进行匹配)。 当一个接口存在多个实现类的情况下,@Autowired 和@Resource都需要通过名称才能正确匹配到对应的 Bean。Autowired 可以通过 @Qualifier 注解来显式指定名称,@Resource可以通过 name 属性来显式指定名称。 Bean 的作用域有哪些? # Spring 中 Bean 的作用域通常有下面几种:\nsingleton : IoC 容器中只有唯一的 bean 实例。Spring 中的 bean 默认都是单例的,是对单例设计模式的应用。 prototype : 每次获取都会创建一个新的 bean 实例。也就是说,连续 getBean() 两次,得到的是不同的 Bean 实例。 request (仅 Web 应用可用): 每一次 HTTP 请求都会产生一个新的 bean(请求 bean),该 bean 仅在当前 HTTP request 内有效。 session (仅 Web 应用可用) : 每一次来自新 session 的 HTTP 请求都会产生一个新的 bean(会话 bean),该 bean 仅在当前 HTTP session 内有效。 application/global-session (仅 Web 应用可用): 每个 Web 应用在启动时创建一个 Bean(应用 Bean),该 bean 仅在当前应用启动时间内有效。 websocket (仅 Web 应用可用):每一次 WebSocket 会话产生一个新的 bean。 如何配置 bean 的作用域呢?\nxml 方式:\n\u0026lt;bean id=\u0026#34;...\u0026#34; class=\u0026#34;...\u0026#34; scope=\u0026#34;singleton\u0026#34;\u0026gt;\u0026lt;/bean\u0026gt; 注解方式:\n@Bean @Scope(value = ConfigurableBeanFactory.SCOPE_PROTOTYPE) public Person personPrototype() { return new Person(); } 单例 Bean 的线程安全问题了解吗? # 大部分时候我们并没有在项目中使用多线程,所以很少有人会关注这个问题。单例 Bean 存在线程问题,主要是因为当多个线程操作同一个对象的时候是存在资源竞争的。\n常见的有两种解决办法:\n在 Bean 中尽量避免定义可变的成员变量。 在类中定义一个 ThreadLocal 成员变量,将需要的可变成员变量保存在 ThreadLocal 中(推荐的一种方式)。 不过,大部分 Bean 实际都是无状态(没有实例变量)的(比如 Dao、Service),这种情况下, Bean 是线程安全的。\nBean 的生命周期了解么? # 下面的内容整理自:https://yemengying.com/2016/07/14/spring-bean-life-cycle/ ,除了这篇文章,再推荐一篇很不错的文章 :https://www.cnblogs.com/zrtqsk/p/3735273.html 。\nBean 容器找到配置文件中 Spring Bean 的定义。 Bean 容器利用 Java Reflection API 创建一个 Bean 的实例。【反射】 如果涉及到一些属性值 利用 set()方法设置一些属性值。 aware 英[əˈweə(r)] adj. 意识到的,发觉,发现`\n如果 Bean 实现了 BeanNameAware 接口,调用 setBeanName()方法,传入 Bean 的名字。 如果 Bean 实现了 BeanClassLoaderAware 接口,调用 setBeanClassLoader()方法,传入 ClassLoader对象的实例。 如果 Bean 实现了 BeanFactoryAware 接口,调用 setBeanFactory()方法,传入 BeanFactory对象的实例。 与上面的类似,如果实现了其他 *.Aware接口,就调用相应的方法。 如果有和加载这个 Bean 的 Spring 容器相关的 BeanPostProcessor 对象,执行postProcessBeforeInitialization() 方法 如果 Bean 实现了**InitializingBean接口,执行afterPropertiesSet()**方法。 如果 Bean 在配置文件中的定义包含 init-method 属性,执行指定的方法。 如果有和加载这个 Bean 的 Spring 容器相关的 BeanPostProcessor 对象,执行postProcessAfterInitialization() 方法 当要销毁 Bean 的时候,如果 Bean 实现了 DisposableBean 接口,执行 destroy() 方法。 当要销毁 Bean 的时候,如果 Bean 在配置文件中的定义包含 destroy-method 属性,执行指定的方法。 图示:\n与之比较类似的中文版本:\nSpring AoP # 谈谈自己对于 AOP 的了解 # aspect 英[ˈæspekt] 方位 n.\noriented 英[ˈɔːrientɪd] 朝向 v.\nAOP(Aspect-Oriented Programming:面向切面编程)能够将那些与业务无关,却为业务模块所共同调用的逻辑或责任(例如事务处理、日志管理、权限控制等)封装起来,便于减少系统的重复代码,降低模块间的耦合度,并有利于未来的可拓展性和可维护性。\nSpring AOP 就是基于动态代理的,如果要代理的对象,实现了某个接口,那么 Spring AOP 会使用 JDK Proxy,去创建代理对象,而对于没有实现接口的对象,就无法使用 JDK Proxy 去进行代理了,这时候 Spring AOP 会使用 Cglib 生成一个被代理对象的子类来作为代理,如下图所示:\n当然你也可以使用 AspectJ !Spring AOP 已经集成了 AspectJ ,AspectJ 应该算的上是 Java 生态系统中最完整的 AOP 框架了。\nAOP 切面编程设计到的一些专业术语:\n术语 含义 目标(Target) 被通知的对象 代理(Proxy) 向目标对象应用通知之后创建的代理对象 连接点(JoinPoint) 目标对象的所属类中,定义的所有方法均为连接点 切入点(Pointcut) 被切面拦截 / 增强的连接点(切入点一定是连接点,连接点不一定是切入点) 通知(Advice) 增强的逻辑 / 代码,也即拦截到目标对象的连接点之后要做的事情 切面(Aspect) 切入点(Pointcut)+通知(Advice) Weaving(织入) 将通知应用到目标对象,进而生成代理对象的过程动作 Spring AOP 和 AspectJ AOP 有什么区别? # Spring AOP 属于运行时增强,而 AspectJ 是编译时增强。 Spring AOP 基于代理(Proxying),而 AspectJ 基于字节码操作(Bytecode Manipulation)。\nSpring AOP 已经集成了 AspectJ ,AspectJ 应该算的上是 Java 生态系统中最完整的 AOP 框架了。AspectJ 相比于 Spring AOP 功能更加强大,但是 Spring AOP 相对来说更简单,\n如果我们的切面比较少,那么两者性能差异不大。但是,当切面太多的话,最好选择 AspectJ ,它比 Spring AOP 快很多。\nAspectJ 定义的通知类型有哪些? # Before(前置通知):目标对象的方法调用之前触发 After (后置通知):目标对象的方法调用之后触发 AfterReturning(返回通知):目标对象的方法调用完成,在返回结果值之后触发 AfterThrowing(异常通知) :目标对象的方法运行中抛出 / 触发异常后触发。AfterReturning 和 AfterThrowing 两者互斥。如果方法调用成功无异常,则会有返回值;如果方法抛出了异常,则不会有返回值。 Around (环绕通知):编程式控制目标对象的方法调用。环绕通知是所有通知类型中可操作范围最大的一种,因为它可以直接拿到目标对象,以及要执行的方法,所以环绕通知可以任意的在目标对象的方法调用前后搞事,甚至不调用目标对象的方法 多个切面的执行顺序如何控制? # 1、通常使用**@Order 注解**直接定义切面顺序\n// 值越小优先级越高 @Order(3) @Component @Aspect public class LoggingAspect implements Ordered { 2、实现Ordered 接口重写 getOrder 方法。\n@Component @Aspect public class LoggingAspect implements Ordered { // .... @Override public int getOrder() { // 返回值越小优先级越高 return 1; } } Spring MVC # 说说自己对于 Spring MVC 了解? # MVC 是模型(Model)、视图(View)、控制器(Controller)的简写,其核心思想是通过将业务逻辑、数据、显示分离来组织代码。\n网上有很多人说 MVC 不是设计模式,只是软件设计规范,我个人更倾向于 MVC 同样是众多设计模式中的一种。 java-design-patterns 项目中就有关于 MVC 的相关介绍。\n想要真正理解 Spring MVC,我们先来看看 Model 1 和 Model 2 这两个没有 Spring MVC 的时代。\nModel 1 时代\n很多学 Java 后端比较晚的朋友可能并没有接触过 Model 1 时代下的 JavaWeb 应用开发。在 Model1 模式下,整个 Web 应用几乎全部用 JSP 页面组成,只用少量的 JavaBean 来处理数据库连接、访问等操作。\n这个模式下 JSP 即是控制层(Controller)又是表现层(View)。显而易见,这种模式存在很多问题。比如控制逻辑和表现逻辑混杂在一起,导致代码重用率极低;再比如前端和后端相互依赖,难以进行测试维护并且开发效率极低。\nModel 2 时代\n学过 Servlet 并做过相关 Demo 的朋友应该了解“Java Bean(Model)+ JSP(View)+Servlet(Controller) ”这种开发模式,这就是早期的 JavaWeb MVC 开发模式。\nModel:系统涉及的数据,也就是 dao 和 bean。 View:展示模型中的数据,只是用来展示。 Controller:处理用户请求都发送给 Servlet,返回数据给 JSP 并展示给用户。 Model2 模式下还存在很多问题,Model2 的抽象和封装程度还远远不够,使用 Model2 进行开发时不可避免地会重复造轮子,这就大大降低了程序的可维护性和复用性。\n于是,很多 JavaWeb 开发相关的 MVC 框架应运而生比如 Struts2,但是 Struts2 比较笨重。\nSpring MVC 时代\n随着 Spring 轻量级开发框架的流行,Spring 生态圈出现了 Spring MVC 框架, Spring MVC 是当前最优秀的 MVC 框架。相比于 Struts2 , Spring MVC 使用更加简单和方便,开发效率更高,并且 Spring MVC 运行速度更快。\nMVC 是一种设计模式,Spring MVC 是一款很优秀的 MVC 框架。Spring MVC 可以帮助我们进行更简洁的 Web 层的开发,并且它天生与 Spring 框架集成。Spring MVC 下我们一般把后端项目分为 Service 层(处理业务)、Dao 层(数据库操作)、Entity 层(实体类)、Controller 层(控制层,返回数据给前台页面)。\nSpring MVC 的核心组件有哪些? # 记住了下面这些组件,也就记住了 SpringMVC 的工作原理。\nDispatcherServlet :核心的中央处理器,负责接收请求、分发,并给予客户端响应。 HandlerMapping :处理器映射器,根据 uri 去匹配查找能处理的 Handler ,并会将请求涉及到的拦截器和 Handler 一起封装。 HandlerAdapter :处理器适配器,根据 HandlerMapping 找到的 Handler ,适配执行对应的 Handler; Handler :请求处理器,处理实际请求的处理器。 ViewResolver :视图解析器,根据 Handler 返回的逻辑视图 / 视图,解析并渲染真正的视图,并传递给 DispatcherServlet 响应客户端 SpringMVC 工作原理了解吗? # Spring MVC 原理如下图所示:\nSpringMVC 工作原理的图解我没有自己画,直接图省事在网上找了一个非常清晰直观的,原出处不明。\n流程说明(重要):\n客户端(浏览器)发送请求, DispatcherServlet拦截请求。 DispatcherServlet 根据请求信息调用 HandlerMapping 。HandlerMapping 根据 uri 去匹配查找能处理的 Handler(也就是我们平常说的 Controller 控制器) ,并会将请求涉及到的拦截器和 Handler 一起封装。 DispatcherServlet 调用 **HandlerAdapter**适配执行 Handler 。 Handler 完成对用户请求的处理后,会返回一个 ModelAndView 对象给DispatcherServlet,ModelAndView 顾名思义,包含了数据模型以及相应的视图的信息。Model 是返回的数据对象,View 是个逻辑上的 View。 ViewResolver 会根据逻辑 View 查找实际的 View。 DispaterServlet 把返回的 Model 传给 View(视图渲染)。 把 View 返回给请求者(浏览器)\n统一异常处理怎么做? # 推荐使用注解的方式统一异常处理,具体会使用到 @ControllerAdvice + @ExceptionHandler 这两个注解 。\n@ControllerAdvice @ResponseBody public class GlobalExceptionHandler { @ExceptionHandler(BaseException.class) public ResponseEntity\u0026lt;?\u0026gt; handleAppException(BaseException ex, HttpServletRequest request) { //...... } @ExceptionHandler(value = ResourceNotFoundException.class) public ResponseEntity\u0026lt;ErrorReponse\u0026gt; handleResourceNotFoundException(ResourceNotFoundException ex, HttpServletRequest request) { //...... } } 这种异常处理方式下,会给所有或者指定的 Controller 织入异常处理的逻辑(AOP),当 Controller 中的方法抛出异常的时候,由被@ExceptionHandler 注解修饰的方法进行处理。\nExceptionHandlerMethodResolver 中 getMappedMethod 方法决定了异常具体被哪个被 @ExceptionHandler 注解修饰的方法处理异常。【这个是框架里的源码,不是自己写的】\n@Nullable private Method getMappedMethod(Class\u0026lt;? extends Throwable\u0026gt; exceptionType) { List\u0026lt;Class\u0026lt;? extends Throwable\u0026gt;\u0026gt; matches = new ArrayList\u0026lt;\u0026gt;(); //找到可以处理的所有异常信息。mappedMethods 中存放了异常和处理异常的方法的对应关系 for (Class\u0026lt;? extends Throwable\u0026gt; mappedException : this.mappedMethods.keySet()) { if (mappedException.isAssignableFrom(exceptionType)) { matches.add(mappedException); } } // 不为空说明有方法处理异常 if (!matches.isEmpty()) { // 按照匹配程度从小到大排序 matches.sort(new ExceptionDepthComparator(exceptionType)); // 返回处理异常的方法 return this.mappedMethods.get(matches.get(0)); } else { return null; } } 从源代码看出: getMappedMethod()会首先找到可以匹配处理异常的所有方法信息,然后对其进行从小到大的排序,最后取最小的那一个匹配的方法(即匹配度最高的那个)。\nSpring 框架中用到了哪些设计模式? # 关于下面这些设计模式的详细介绍,可以看我写的 Spring 中的设计模式详解 这篇文章。\n工厂设计模式 : Spring 使用工厂模式通过 BeanFactory、ApplicationContext 创建 bean 对象。 代理设计模式 : Spring AOP 功能的实现。 单例设计模式 : Spring 中的 Bean 默认都是单例的。 模板方法模式 : Spring 中 jdbcTemplate、hibernateTemplate 等以 Template 结尾的对数据库操作的类,它们就使用到了模板模式。 包装器设计模式 : 我们的项目需要连接多个数据库,而且不同的客户在每次访问中根据需要会去访问不同的数据库。这种模式让我们可以根据客户的需求能够动态切换不同的数据源。 观察者模式: Spring 事件驱动模型就是观察者模式很经典的一个应用。 适配器模式 : Spring AOP 的增强或通知(Advice)使用到了适配器模式、spring MVC 中也是用到了适配器模式适配Controller。 \u0026hellip;\u0026hellip; Spring 事务 # 关于 Spring 事务的详细介绍,可以看我写的 Spring 事务详解 这篇文章。\nSpring 管理事务的方式有几种? # 编程式事务 : 在代码中硬编码(不推荐使用) : 通过 **TransactionTemplate**或者 TransactionManager 手动管理事务,实际应用中很少使用,但是对于你理解 Spring 事务管理原理有帮助。 声明式事务 : 在 XML 配置文件中配置或者直接基于注解(推荐使用) : 实际是通过 AOP 实现(基于@**Transactional** 的全注解方式使用最多) Spring事务失效的几种情况(非javaguide) # 1.spring事务实现方式及原理 # Spring 事务的本质其实就是数据库对事务的支持,没有数据库的事务支持,spring 是无法提供事务功能的。真正的数据库层的事务提交和回滚是在binlog提交之后进行提交的 通过 redo log 来重做, undo log来回滚。\n一般我们在程序里面使用的都是在方法上面加@Transactional 注解,这种属于声明式事务。\n声明式事务本质是通过 AOP 功能,对方法前后进行拦截,将事务处理的功能编织到拦截的方法中,也就是在目标方法开始之前加入一个事务,在执行完目标方法之后根据执行情况提交或者回滚事务。\n2.数据库本身不支持事务 # 这里以 MySQL 为例,其 MyISAM 引擎是不支持事务操作的,InnoDB 才是支持事务的引擎,一般要支持事务都会使用 InnoDB\n3.当前类的调用 # @Service public class UserServiceImpl implements UserService { public void update(User user) { updateUser(user); } @Transactional(rollbackFor = Exception.class) public void updateUser(User user) { // update user } } 复制代码 上面的这种情况下是不会有事务管理操作的。\n通过看声明式事务的原理可知,spring使用的是AOP切面的方式,本质上使用的是动态代理来达到事务管理的目的,当前类调用的方法上面加@Transactional 这个是没有任何作用的,因为调用这个方法的是this.\nOK, 我们在看下面的一种例子。\n@Service public class UserServiceImpl implements UserService { @Transactional(rollbackFor = Exception.class) public void update(User user) { updateUser(user); } @Transactional(propagation = Propagation.REQUIRES_NEW) public void updateUser(User user) { // update user } } 复制代码 这次在 update 方法上加了 @Transactional,updateUser 加了 REQUIRES_NEW 新开启一个事务,那么新开的事务管用么?\n答案是:不管用!\n因为它们发生了自身调用,就调该类自己的方法,而没有经过 Spring 的代理类,默认只有在外部调用事务才会生效,这也是老生常谈的经典问题了。\n4.方法不是public的 # @Service public class UserServiceImpl implements UserService { @Transactional(rollbackFor = Exception.class) private void updateUser(User user) { // update user } } 复制代码 private 方法是不会被spring代理的,因此是不会有事务产生的,这种做法是无效的。\n5.没有被spring管理 # //@Service public class UserServiceImpl implements UserService { @Transactional(rollbackFor = Exception.class) public void updateUser(User user) { // update user } } 复制代码 没有被spring管理的bean, spring连代理对象都无法生成,当然无效咯。\n6.配置的事务传播性有问题 # @Service public class UserServiceImpl implements UserService { @Transactional(propagation = Propagation.NOT_SUPPORTED) public void update(User user) { // update user } } 复制代码 回顾一下spring的事务传播行为\nSpring 事务的传播行为说的是,当多个事务同时存在的时候, Spring 如何处理这些事务的行为。\nPROPAGATION_REQUIRED:如果当前没有事务,就创建一个新事务,如果当前存在事务,就加入该事务,该设置是最常用的设置。 PROPAGATION_SUPPORTS:支持当前事务,如果当前存在事务,就加入该事务,如果当前不存在事务,就以非事务执行 PROPAGATION_MANDATORY:支持当前事务,如果当前存在事务,就加入该事务,如果当前不存在事务,就抛出异常。 PROPAGATION_REQUIRES_NEW:创建新事务,无论当前存不存在事务,都创建新事务。 PROPAGATION_NOT_SUPPORTED:以非事务方式执行操作,如果当前存在事务,就把当前事务挂起。 PROPAGATION_NEVER: 以非事务方式执行,如果当前存在事务,则抛出异常。 PROPAGATION_NESTED:如果当前存在事务,则在嵌套事务内执行。如果当前没有事务,则按 REQUIRED 属性执行 当传播行为设置了PROPAGATION_NOT_SUPPORTED,PROPAGATION_NEVER,PROPAGATION_SUPPORTS这三种时,就有可能存在事务不生效\n7.异常被你 \u0026ldquo;抓住\u0026quot;了 # @Service public class UserServiceImpl implements UserService { @Transactional(rollbackFor = Exception.class) public void update(User user) { try{ // update user }catch(Execption e){ log.error(\u0026#34;异常\u0026#34;,e) } } } 复制代码 异常被抓了,这样子代理类就没办法知道你到底有没有错误,需不需要回滚,所以这种情况也是没办法回滚的哦。\n8.接口层声明式事务使用cglib代理 # 注意,这是个前后关系,说的是:如果在接口层使用了声明式事务,结果用的是cglib代理,那么事务就不会生效\npublic interface UserService { @Transactional(rollbackFor = Exception.class) public void update(User user) } 复制代码 @Service public class UserServiceImpl implements UserService { public void update(User user) { // update user } } 复制代码 通过元素的 \u0026ldquo;proxy-target-class\u0026rdquo; 属性值来控制是基于接口的还是基于类的代理被创建。如果 \u0026ldquo;proxy-target-class\u0026rdquo; 属值被设置为 \u0026ldquo;true\u0026rdquo;,那么基于类的代理将起作用(这时需要CGLIB库cglib.jar在CLASSPATH中)。如果 \u0026ldquo;proxy-target-class\u0026rdquo; 属值被设置为 \u0026ldquo;false\u0026rdquo; 或者这个属性被省略,那么标准的JDK基于接口的代理将起作用\n注解@Transactional cglib与java动态代理最大区别是代理目标对象不用实现接口,那么注解要是写到接口方法上,要是使用cglib代理,这时注解事务就失效了,为了保持兼容注解最好都写到实现类方法上。\n9.rollbackFor异常指定错误 # @Service public class UserServiceImpl implements UserService { @Transactional public void update(User user) { // update user } } 复制代码 上面这种没有指定回滚异常,这个时候默认的回滚异常是RuntimeException ,如果出现其他异常那么就不会回滚事务\nSpring 事务中哪几种事务传播行为? # 事务传播行为是为了解决业务层方法之间互相调用的事务问题。\n当事务方法被另一个事务方法(也可能非事务)调用时,必须指定事务应该如何传播。例如:方法可能继续在现有事务中运行,也可能开启一个新事务,并在自己的事务中运行。\n注意几点,下面这个值都是内方法上的注解的值,且两个方法必须属于不同类\n@Service public class MyClassServiceImpl extends ServiceImpl\u0026lt;MyClassMapper, MyClass\u0026gt; implements MyClassService { @Autowired private UserService userService; //外方法 @Override public void methodOuter() throws Exception { //新增一条记录 MyClass myClass=new MyClass(); myClass.setName(\u0026#34;class_name\u0026#34;); this.saveOrUpdate(myClass); //调用内方法 userService.methodInner(); //抛出异常 //throw new Exception(\u0026#34;hello\u0026#34;); } } @Service public class UserServiceImpl extends ServiceImpl\u0026lt;UserMapper, User\u0026gt; implements UserService { //内方法 @Transactional( rollbackFor = Exception.class ,propagation = Propagation.REQUIRED ) @Override public void methodInner() throws Exception { //新增一条记录 User user = new User(); user.setName(\u0026#34;outer_name\u0026#34;); this.saveOrUpdate(user); //抛出异常 //throw new Exception(\u0026#34;hello\u0026#34;); } } 正确的事务传播行为可能的值如下:\n注:如果外方法不存在事务,则内外方法完全独立,自己(方法内)抛异常不影响另一方法\n1.TransactionDefinition.PROPAGATION_REQUIRED\n使用的最多的一个事务传播行为,我们平时经常使用的@Transactional注解默认使用就是这个事务传播行为。如果当前存在事务,则加入该事务;如果当前没有事务,则创建一个新的事务。\n如果外方法存在事务,则不论 外方法或内方法抛出异常,都会导致外内所在事务(同一个)回滚\n2.TransactionDefinition.PROPAGATION_REQUIRES_NEW\n创建一个新的事务,如果当前存在事务,则把当前事务挂起。也就是说不管外部方法是否开启事务,Propagation.REQUIRES_NEW修饰的内部方法会新开启自己的事务,且开启的事务相互独立,互不干扰。\n如果外方法存在事务,如果仅内方法抛异常,会导致外方法回滚;如果仅外方法抛异常,则不会回滚内方法\n3.TransactionDefinition.PROPAGATION_NESTED\n如果当前存在事务,则创建一个事务作为当前事务的嵌套事务来运行;如果当前没有事务,则该取值等价于TransactionDefinition.PROPAGATION_REQUIRED。\n如果外方法存在事务,(效果和1一样), 不论 外方法或内方法抛出异常,都会导致外内所在事务(和1唯一不同的是,他们是不同事务)回滚\n4.TransactionDefinition.PROPAGATION_MANDATORY\n如果当前存在事务,则加入该事务;如果当前没有事务,则抛出异常。(mandatory:强制性)\n这个使用的很少。\n如果外方法存在事务,(效果和1一样), 不论 外方法或内方法抛出异常,都会导致外内所在事务(和1唯一不同的是,如果外方法不存在事务,调用该方法前就直接抛异常)回滚\n若是错误的配置以下 3 种事务传播行为,事务将不会发生回滚:\nTransactionDefinition.PROPAGATION_SUPPORTS: 如果当前存在事务,则加入该事务;如果当前没有事务,则以非事务的方式继续运行。 TransactionDefinition.PROPAGATION_NOT_SUPPORTED: 以非事务方式运行,如果当前存在事务,则把当前事务挂起。 TransactionDefinition.PROPAGATION_NEVER: 以非事务方式运行,如果当前存在事务,则抛出异常。 Spring 事务中的隔离级别有哪几种? # //这个注解应该是用来修改session级别的隔离级别\n和事务传播行为这块一样,为了方便使用,Spring 也相应地定义了一个枚举类:Isolation\npublic enum Isolation { DEFAULT(TransactionDefinition.ISOLATION_DEFAULT), READ_UNCOMMITTED(TransactionDefinition.ISOLATION_READ_UNCOMMITTED), READ_COMMITTED(TransactionDefinition.ISOLATION_READ_COMMITTED), REPEATABLE_READ(TransactionDefinition.ISOLATION_REPEATABLE_READ), SERIALIZABLE(TransactionDefinition.ISOLATION_SERIALIZABLE); private final int value; Isolation(int value) { this.value = value; } public int value() { return this.value; } } 下面我依次对每一种事务隔离级别进行介绍:\nTransactionDefinition.ISOLATION_DEFAULT :使用后端数据库默认的隔离级别,MySQL 默认采用的 REPEATABLE_READ 隔离级别 Oracle 默认采用的 READ_COMMITTED 隔离级别. TransactionDefinition.ISOLATION_READ_UNCOMMITTED :最低的隔离级别,使用这个隔离级别很少,因为它允许读取尚未提交的数据变更,可能会导致脏读、幻读或不可重复读 TransactionDefinition.ISOLATION_READ_COMMITTED : 允许读取并发事务已经提交的数据,可以阻止脏读,但是幻读或不可重复读仍有可能发生 TransactionDefinition.ISOLATION_REPEATABLE_READ : 对同一字段的多次读取结果都是一致的,除非数据是被本身事务自己所修改,可以阻止脏读和不可重复读,但幻读仍有可能发生。 TransactionDefinition.ISOLATION_SERIALIZABLE : 最高的隔离级别,完全服从 ACID 的隔离级别。所有的事务依次逐个执行,这样事务之间就完全不可能产生干扰,也就是说,该级别可以防止脏读、不可重复读以及幻读。但是这将严重影响程序的性能。通常情况下也不会用到该级别。 注意,这个注解的使用方法,下面写了两个方法分别模拟两个不同的线程操作(供不同的controller使用)\n@Transactional( isolation = Isolation.READ_COMMITTED ) public User isolation1() { //读取userid=1的值 User byId = this.getById(1L); return byId; } @Transactional public User isolation2() throws InterruptedException { //10s后修改 TimeUnit.SECONDS.sleep(10); User user=new User(); user.setId(1L); user.setName(\u0026#34;1被修改了\u0026#34;); this.saveOrUpdate(user); //10s后提交 TimeUnit.SECONDS.sleep(10); return null; } 当isolation1为读已提交时,只要isolation2方法没有执行完毕(没有提交),那么isolation1只会读取到未修改的值; 当isolation1为读为提交时,即使isolation2方法没有执行完毕(没有提交),那么isolation1也会立马读取到最新的值; @Transactional(rollbackFor = Exception.class)注解了解吗? # Exception 分为运行时异常 RuntimeException 和非运行时异常。事务管理对于企业应用来说是至关重要的,即使出现异常情况,它也可以保证数据的一致性。\n当 @Transactional 注解作用于类上时,该类的所有 public 方法将都具有该类型的事务属性,同时,我们也可以在方法级别使用该标注来覆盖类级别的定义。如果类或者方法加了这个注解,那么这个类里面的方法抛出异常,就会回滚,数据库里面的数据也会回滚。\n在 @Transactional 注解中如果不配置rollbackFor属性,那么事务只会在遇到**RuntimeException的时候才会回滚,加上 rollbackFor=Exception.class,可以让事务在遇到非运行时异常时**也回滚。\nSpring Data JPA # JPA 重要的是实战,这里仅对小部分知识点进行总结。\n如何使用 JPA 在数据库中非持久化一个字段? # 假如我们有下面一个类:\n@Entity(name=\u0026#34;USER\u0026#34;) public class User { @Id @GeneratedValue(strategy = GenerationType.AUTO) @Column(name = \u0026#34;ID\u0026#34;) private Long id; @Column(name=\u0026#34;USER_NAME\u0026#34;) private String userName; @Column(name=\u0026#34;PASSWORD\u0026#34;) private String password; private String secrect; } 如果我们想让secrect 这个字段不被持久化,也就是不被数据库存储怎么办?我们可以采用下面几种方法:\nstatic String transient1; // not persistent because of static final String transient2 = \u0026#34;Satish\u0026#34;; // not persistent because of final transient String transient3; // not persistent because of transient @Transient String transient4; // not persistent because of @Transient 一般使用后面两种方式比较多,我个人使用注解的方式比较多。\nJPA 的审计功能是做什么的?有什么用? # 审计功能主要是帮助我们记录数据库操作的具体行为比如某条记录是谁创建的、什么时间创建的、最后修改人是谁、最后修改时间是什么时候。\n@Data @AllArgsConstructor @NoArgsConstructor @MappedSuperclass @EntityListeners(value = AuditingEntityListener.class) public abstract class AbstractAuditBase { @CreatedDate @Column(updatable = false) @JsonIgnore private Instant createdAt; @LastModifiedDate @JsonIgnore private Instant updatedAt; @CreatedBy @Column(updatable = false) @JsonIgnore private String createdBy; @LastModifiedBy @JsonIgnore private String updatedBy; } @CreatedDate: 表示该字段为创建时间字段,在这个实体被 insert 的时候,会设置值\n@CreatedBy :表示该字段为创建人,在这个实体被 insert 的时候,会设置值\n@LastModifiedDate、@LastModifiedBy同理。\n实体之间的关联关系注解有哪些? # @OneToOne : 一对一。 @ManyToMany :多对多。 @OneToMany : 一对多。 @ManyToOne :多对一。 利用 @ManyToOne 和 @OneToMany 也可以表达多对多的关联关系。\nSpring Security # Spring Security 重要的是实战,这里仅对小部分知识点进行总结。\n有哪些控制请求访问权限的方法? # permitAll() :无条件允许任何形式访问,不管你登录还是没有登录。 anonymous() :允许匿名访问,也就是没有登录才可以访问。 denyAll() :无条件决绝任何形式的访问。 authenticated():只允许已认证的用户访问。 fullyAuthenticated() :只允许已经登录或者通过 remember-me 登录的用户访问。 hasRole(String) : 只允许指定的角色访问。 hasAnyRole(String)\t: 指定一个或者多个角色,满足其一的用户即可访问。 hasAuthority(String) :只允许具有指定权限的用户访问 hasAnyAuthority(String) :指定一个或者多个权限,满足其一的用户即可访问。 hasIpAddress(String) : 只允许指定 ip 的用户访问。 hasRole 和 hasAuthority 有区别吗? # 可以看看松哥的这篇文章: Spring Security 中的 hasRole 和 hasAuthority 有区别吗?,介绍的比较详细。\n如何对密码进行加密? # 如果我们需要保存密码这类敏感数据到数据库的话,需要先加密再保存。\nSpring Security 提供了多种加密算法的实现,开箱即用,非常方便。这些加密算法实现类的父类是 PasswordEncoder ,如果你想要自己实现一个加密算法的话,也需要继承 PasswordEncoder。\nPasswordEncoder 接口一共也就 3 个必须实现的方法。\npublic interface PasswordEncoder { // 加密也就是对原始密码进行编码 String encode(CharSequence var1); // 比对原始密码和数据库中保存的密码 boolean matches(CharSequence var1, String var2); // 判断加密密码是否需要再次进行加密,默认返回 false default boolean upgradeEncoding(String encodedPassword) { return false; } } 官方推荐使用基于 bcrypt 强哈希函数的加密算法实现类。\n如何优雅更换系统使用的加密算法? # 如果我们在开发过程中,突然发现现有的加密算法无法满足我们的需求,需要更换成另外一个加密算法,这个时候应该怎么办呢?\n推荐的做法是通过 DelegatingPasswordEncoder 兼容多种不同的密码加密方案,以适应不同的业务需求。\n从名字也能看出来,DelegatingPasswordEncoder 其实就是一个代理类,并非是一种全新的加密算法,它做的事情就是代理上面提到的加密算法实现类。在 Spring Security 5.0之后,默认就是基于 DelegatingPasswordEncoder 进行密码加密的。\n参考 # 《Spring 技术内幕》 《从零开始深入学习 Spring》:https://juejin.cn/book/6857911863016390663 http://www.cnblogs.com/wmyskxz/p/8820371.html https://www.journaldev.com/2696/spring-interview-questions-and-answers https://www.edureka.co/blog/interview-questions/spring-interview-questions/ https://www.cnblogs.com/clwydjgs/p/9317849.html https://howtodoinjava.com/interview-questions/top-spring-interview-questions-with-answers/ http://www.tomaszezula.com/2014/02/09/spring-series-part-5-component-vs-bean/ https://stackoverflow.com/questions/34172888/difference-between-bean-and-autowired "},{"id":292,"href":"/zh/docs/technology/Review/java_guide/lyaly_dev_tools/git/","title":"git","section":"开发工具","content":" 转载自https://github.com/Snailclimb/JavaGuide(添加小部分笔记)感谢作者!\n版本控制 # 什么是版本控制 # 版本控制是一种记录一个或若干文件内容变化,以便将来查阅特定版本修订情况的系统。 除了项目源代码,你还可以对任何类型的文件进行版本控制。\n为什么要版本控制 # 有了它你就可以将某个文件回溯到之前的状态,甚至将整个项目都回退到过去某个时间点的状态,你可以比较文件的变化细节,查出最后是谁修改了哪个地方,从而找出导致怪异问题出现的原因,又是谁在何时报告了某个功能缺陷等等。\n本地版本控制系统 # 许多人习惯用复制整个项目目录的方式来保存不同的版本,或许还会改名加上备份时间以示区别。 这么做唯一的好处就是简单,但是特别容易犯错。 有时候会混淆所在的工作目录,一不小心会写错文件或者覆盖意想外的文件。\n为了解决这个问题,人们很久以前就开发了许多种本地版本控制系统,大多都是采用某种简单的数据库来记录文件的历次更新差异。\n集中化的版本控制系统 # 接下来人们又遇到一个问题,如何让在不同系统上的开发者协同工作? 于是,集中化的版本控制系统(Centralized Version Control Systems,简称 CVCS)应运而生。\n集中化的版本控制系统都有一个单一的集中管理的服务器,保存所有文件的修订版本,而协同工作的人们都通过客户端连到这台服务器,取出最新的文件或者提交更新。\n这么做虽然解决了本地版本控制系统无法让在不同系统上的开发者协同工作的诟病,但也还是存在下面的问题:\n单点故障: 中央服务器宕机,则其他人无法使用;如果中心数据库磁盘损坏又没有进行备份,你将丢失所有数据。本地版本控制系统也存在类似问题,只要整个项目的历史记录被保存在单一位置,就有丢失所有历史更新记录的风险。 必须联网才能工作: 受网络状况、带宽影响。 分布式版本控制系统 # 于是分布式版本控制系统(Distributed Version Control System,简称 DVCS)面世了。 Git 就是一个典型的分布式版本控制系统。\n这类系统,客户端并不只提取最新版本的文件快照,而是把代码仓库完整地镜像下来。 这么一来,任何一处协同工作用的服务器发生故障,事后都可以用任何一个镜像出来的本地仓库恢复。 因为每一次的克隆操作,实际上都是一次对代码仓库的完整备份。\n分布式版本控制系统可以不用联网就可以工作,因为每个人的电脑上都是完整的版本库,当你修改了某个文件后,你只需要将自己的修改推送给别人就可以了。但是,在实际使用分布式版本控制系统的时候,很少会直接进行推送修改,而是使用一台充当“中央服务器”的东西。这个服务器的作用仅仅是用来方便“交换”大家的修改,没有它大家也一样干活,只是交换修改不方便而已。\n分布式版本控制系统的优势不单是不必联网这么简单,后面我们还会看到 Git 极其强大的分支管理等功能。\n认识 Git # Git 简史 # Linux 内核项目组当时使用分布式版本控制系统 BitKeeper 来管理和维护代码。但是,后来开发 BitKeeper 的商业公司同 Linux 内核开源社区的合作关系结束,他们收回了 Linux 内核社区免费使用 BitKeeper 的权力。 Linux 开源社区(特别是 Linux 的缔造者 Linus Torvalds)基于使用 BitKeeper 时的经验教训,开发出自己的版本系统,而且对新的版本控制系统做了很多改进。\nGit 与其他版本管理系统的主要区别 # Git 在保存和对待各种信息的时候与其它版本控制系统有很大差异,尽管操作起来的命令形式非常相近,理解这些差异将有助于防止你使用中的困惑。\n下面我们主要说一个关于 Git 与其他版本管理系统的主要差别:对待数据的方式。\nGit采用的是直接记录快照的方式,而非差异比较。我后面会详细介绍这两种方式的差别。\n大部分版本控制系统(CVS、Subversion、Perforce、Bazaar 等等)都是以文件变更列表的方式存储信息,这类系统将它们保存的信息看作是一组基本文件和每个文件随时间逐步累积的差异。\n具体原理如下图所示,理解起来其实很简单,每当我们提交更新一个文件之后,系统都会记录这个文件做了哪些更新,以增量符号Δ(Delta)表示。\n我们怎样才能得到一个文件的最终版本呢?\n很简单,高中数学的基本知识,我们只需要将这些原文件和这些增加进行相加就行了。\n这种方式有什么问题呢?\n比如我们的增量特别特别多的话,如果我们要得到最终的文件是不是会耗费时间和性能。\nGit 不按照以上方式对待或保存数据。 反之,Git 更像是把数据看作是对小型文件系统的一组快照。 每次你提交更新,或在 Git 中保存项目状态时,它主要对当时的全部文件制作一个快照并保存这个快照的索引。 为了高效,如果文件没有修改,Git 不再重新存储该文件,而是只保留一个链接指向之前存储的文件。 Git 对待数据更像是一个 快照流。\nGit 的三种状态 # Git 有三种状态,你的文件可能处于其中之一:\n已提交(committed):数据已经安全的保存在本地数据库中。 已修改(modified):已修改表示修改了文件,但还没保存到数据库中。 已暂存(staged):表示对一个已修改文件的当前版本做了标记,使之包含在下次提交的快照中。 由此引入 Git 项目的三个工作区域的概念:Git 仓库(.git directory)、工作目录(Working Directory) 以及 暂存区域(Staging Area) 。\n基本的 Git 工作流程如下:\n在工作目录中修改文件。 暂存文件,将文件的快照放入暂存区域。 提交更新,找到暂存区域的文件,将快照永久性存储到 Git 仓库目录。 Git 使用快速入门 # 获取 Git 仓库 # 有两种取得 Git 项目仓库的方法。\n在现有目录中初始化仓库: 进入项目目录运行 git init 命令,该命令将创建一个名为 .git 的子目录。 从一个服务器克隆一个现有的 Git 仓库: git clone [url] 自定义本地仓库的名字: git clone [url] directoryname 记录每次更新到仓库 # 检测当前文件状态 : git status 提出更改(把它们添加到暂存区):git add filename (针对特定文件)、git add *(所有文件)、git add *.txt(支持通配符,所有 .txt 文件) 忽略文件:.gitignore 文件 提交更新: git commit -m \u0026quot;代码提交信息\u0026quot; (每次准备提交前,先用 git status 看下,是不是都已暂存起来了, 然后再运行提交命令 git commit) 跳过使用暂存区域更新的方式 : git commit -a -m \u0026quot;代码提交信息\u0026quot;。 git commit 加上 -a 选项,Git 就会自动把所有已经跟踪过的文件暂存起来一并提交,从而跳过 git add 步骤。 移除文件 :git rm filename (从暂存区域移除,然后提交。) 对文件重命名 :git mv README.md README(这个命令相当于mv README.md README、git rm README.md、git add README 这三条命令的集合) 一个好的 Git 提交消息 # 一个好的 Git 提交消息如下:\n标题行:用这一行来描述和解释你的这次提交 主体部分可以是很少的几行,来加入更多的细节来解释提交,最好是能给出一些相关的背景或者解释这个提交能修复和解决什么问题。 主体部分当然也可以有几段,但是一定要注意换行和句子不要太长。因为这样在使用 \u0026#34;git log\u0026#34; 的时候会有缩进比较好看。 提交的标题行描述应该尽量的清晰和尽量的一句话概括。这样就方便相关的 Git 日志查看工具显示和其他人的阅读。\n推送改动到远程仓库 # 如果你还没有克隆现有仓库,并欲将你的仓库连接到某个远程服务器,你可以使用如下命令添加:git remote add origin \u0026lt;server\u0026gt; ,比如我们要让本地的一个仓库和 Github 上创建的一个仓库关联可以这样**git remote add origin https://github.com/Snailclimb/test.git**\n将这些改动提交到远端仓库:git push origin master (可以把 master 换成你想要推送的任何分支)\n如此你就能够将你的改动推送到所添加的服务器上去了。\n远程仓库的移除与重命名 # 将 test 重命名为 test1:git remote rename test test1 移除远程仓库 test1:git remote rm test1 查看提交历史 # 在提交了若干更新,又或者克隆了某个项目之后,你也许想回顾下提交历史。 完成这个任务最简单而又有效的工具是 git log 命令。git log 会按提交时间列出所有的更新,最近的更新排在最上面。\n可以添加一些参数来查看自己希望看到的内容:\n只看某个人的提交记录:\ngit log --author=bob 撤销操作 # 有时候我们提交完了才发现漏掉了几个文件没有添加,或者提交信息写错了。 此时,可以运行带有 --amend 选项的提交命令尝试重新提交:\ngit commit --amend 取消暂存的文件\ngit reset filename 撤消对文件的修改:\ngit checkout -- filename 假如你想丢弃你在本地的所有改动与提交,可以到服务器上获取最新的版本历史,并将你本地主分支指向它:\ngit fetch origin git reset --hard origin/master 分支 # 分支是用来将特性开发绝缘开来的。在你创建仓库的时候,master 是“默认”的分支。在其他分支上进行开发,完成后再将它们合并到主分支上。\n我们通常在开发新功能、修复一个紧急 bug 等等时候会选择创建分支。单分支开发好还是多分支开发好,还是要看具体场景来说。\n创建一个名字叫做 test 的分支\ngit branch test 切换当前分支到 test(当你切换分支的时候,Git 会重置你的工作目录,使其看起来像回到了你在那个分支上最后一次提交的样子。 Git 会自动添加、删除、修改文件以确保此时你的工作目录和这个分支最后一次提交时的样子一模一样)\ngit checkout test 你也可以直接这样创建分支并切换过去(上面两条命令的合写)\ngit checkout -b feature_x 切换到主分支\ngit checkout master 合并分支(可能会有冲突)\ngit merge test 把新建的分支删掉\ngit branch -d feature_x 将分支推送到远端仓库(推送成功后其他人可见):\ngit push origin 推荐 # 在线演示学习工具:\n「补充,来自 issue729」Learn Git Branching https://oschina.gitee.io/learn-git-branching/ 。该网站可以方便的演示基本的git操作,讲解得明明白白。每一个基本命令的作用和结果。\n推荐阅读:\nGit - 简明指南 图解Git 猴子都能懂得Git入门 https://git-scm.com/book/en/v2 Generating a new SSH key and adding it to the ssh-agent 一个好的 Git 提交消息,出自 Linus 之手 "},{"id":293,"href":"/zh/docs/technology/Review/java_guide/lyaly_dev_tools/maven/","title":"maven","section":"开发工具","content":" 转载自https://github.com/Snailclimb/JavaGuide(添加小部分笔记)感谢作者!\n这部分内容主要根据 Maven 官方文档整理,做了对应的删减,主要保留比较重要的部分,不涉及实战,主要是一些重要概念的介绍。\nMaven 介绍 # Maven 官方文档是这样介绍的 Maven 的:\nApache Maven is a software project management and comprehension tool. Based on the concept of a project object model (POM), Maven can manage a project\u0026rsquo;s build, reporting and documentation from a central piece of information.\nApache Maven 的本质是一个软件项目管理和理解工具。基于项目对象模型 (Project Object Model,POM) 的概念,Maven 可以从一条中心信息 管理项目的构建、报告和文档。\n什么是 POM? 每一个 Maven 工程都有一个 pom.xml 文件,位于根目录中,包含项目构建生命周期的详细信息。通过 pom.xml 文件,我们可以定义项目的坐标、项目依赖、项目信息、插件信息等等配置。\n对于开发者来说,Maven 的主要作用主要有 3 个:\n项目构建 :提供标准的、跨平台的自动化项目构建方式。 依赖管理 :方便快捷的管理项目依赖的资源(jar 包),避免资源间的版本冲突问题。 统一开发结构 :提供标准的、统一的项目结构。 关于 Maven 的基本使用这里就不介绍了,建议看看官网的 5 分钟上手 Maven 的教程: Maven in 5 Minutes 。\nMaven 坐标 # 项目中依赖的第三方库以及插件可统称为构件。每一个构件都可以使用 Maven 坐标 唯一标识,坐标元素包括:\ngoupId(必须): 定义了当前 Maven 项目隶属的组织或公司。groupId 一般分为多段,通常情况下,第一段为域,第二段为公司名称。域又分为 org、com、cn 等,其中 org 为非营利组织,com 为商业组织,cn 表示中国。以 apache 开源社区的 tomcat 项目为例,这个项目的 groupId 是 org.apache,它的域是 org(因为 tomcat 是非营利项目),公司名称是 apache,artifactId 是 tomcat。 artifactId(必须):定义了当前 Maven 项目的名称,项目的唯一的标识符,对应项目根目录的名称。 version(必须): 定义了 Maven 项目当前所处版本。 packaging(可选):定义了 Maven 项目的打包方式(比如 jar,war\u0026hellip;),默认使用 jar。 classifier(可选):常用于区分从同一 POM 构建的具有不同内容的构件,可以是任意的字符串,附加在版本号之后。 只要你提供正确的坐标,就能从 Maven 仓库中找到相应的构件供我们使用。\n举个例子(引入阿里巴巴开源的 EasyExcel) :\n\u0026lt;dependency\u0026gt; \u0026lt;groupId\u0026gt;com.alibaba\u0026lt;/groupId\u0026gt; \u0026lt;artifactId\u0026gt;easyexcel\u0026lt;/artifactId\u0026gt; \u0026lt;version\u0026gt;3.1.1\u0026lt;/version\u0026gt; \u0026lt;/dependency\u0026gt; 你可以在 https://mvnrepository.com/ 这个网站上找到几乎所有可用的构件,如果你的项目使用的是 Maven 作为构建工具,那这个网站你一定会经常接触。\nMaven 依赖 # 如果使用 Maven 构建产生的构件(例如 Jar 文件)被其他的项目引用,那么该构件就是其他项目的依赖。\n依赖配置 # 配置信息示例 :\n\u0026lt;project\u0026gt; \u0026lt;dependencies\u0026gt; \u0026lt;dependency\u0026gt; \u0026lt;groupId\u0026gt;\u0026lt;/groupId\u0026gt; \u0026lt;artifactId\u0026gt;\u0026lt;/artifactId\u0026gt; \u0026lt;version\u0026gt;\u0026lt;/version\u0026gt; \u0026lt;type\u0026gt;...\u0026lt;/type\u0026gt; \u0026lt;scope\u0026gt;...\u0026lt;/scope\u0026gt; \u0026lt;optional\u0026gt;...\u0026lt;/optional\u0026gt; \u0026lt;exclusions\u0026gt; \u0026lt;exclusion\u0026gt; \u0026lt;groupId\u0026gt;...\u0026lt;/groupId\u0026gt; \u0026lt;artifactId\u0026gt;...\u0026lt;/artifactId\u0026gt; \u0026lt;/exclusion\u0026gt; \u0026lt;/exclusions\u0026gt; \u0026lt;/dependency\u0026gt; \u0026lt;/dependencies\u0026gt; \u0026lt;/project\u0026gt; 配置说明 :\ndependencies: 一个 pom.xml 文件中只能存在一个这样的标签,是用来管理依赖的总标签。 dependency:包含在 dependencies 标签中,可以有多个,每一个表示项目的一个依赖。 groupId,artifactId,version(必要):依赖的基本坐标,对于任何一个依赖来说,基本坐标是最重要的,Maven 根据坐标才能找到需要的依赖。我们在上面解释过这些元素的具体意思,这里就不重复提了。 type(可选):依赖的类型,对应于项目坐标定义的 packaging。大部分情况下,该元素不必声明,其默认值是 jar。 scope(可选):依赖的范围,默认值是 compile。 optional(可选): 标记依赖是否可选 exclusions(可选):用来排除传递性依赖,例如 jar 包冲突 依赖范围 # classpath 用于指定 .class 文件存放的位置,类加载器会从该路径中加载所需的 .class 文件到内存中。\nMaven 在编译、执行测试、实际运行有着三套不同的 classpath:\n编译 classpath :编译主代码有效 测试 classpath :编译、运行测试代码有效 运行 classpath :项目运行时有效 Maven 的依赖范围如下:\ncompile:编译依赖范围(默认),使用此依赖范围对于编译、测试、运行三种都有效,即在编译、测试和运行的时候都要使用该依赖 Jar 包。 test:测试依赖范围,从字面意思就可以知道此依赖范围只能用于测试,而在编译和运行项目时无法使用此类依赖,典型的是 JUnit,它只用于编译 测试代码和运行测试代码的时候才需要。 provided :此依赖范围,对于编译和测试有效,而对运行时无效。比如 servlet-api.jar 在 Tomcat 中已经提供了,我们只需要的是编译期提供而已。 runtime:运行时依赖范围,对于测试和运行有效,但是在编译主代码时无效,典型的就是 JDBC 驱动实现。 system:系统依赖范围,使用 system 范围的依赖时必须通过 systemPath 元素显示地指定依赖文件的路径,不依赖 Maven 仓库解析,所以可能会造成建构的不可移植。 传递依赖性 # 依赖冲突 # 1、对于 Maven 而言,同一个 groupId 同一个 artifactId 下,只能使用一个 version。\n\u0026lt;dependency\u0026gt; \u0026lt;groupId\u0026gt;in.hocg.boot\u0026lt;/groupId\u0026gt; \u0026lt;artifactId\u0026gt;mybatis-plus-spring-boot-starter\u0026lt;/artifactId\u0026gt; \u0026lt;version\u0026gt;1.0.48\u0026lt;/version\u0026gt; \u0026lt;/dependency\u0026gt; \u0026lt;!-- 只会使用 1.0.49 这个版本的依赖 --\u0026gt; \u0026lt;dependency\u0026gt; \u0026lt;groupId\u0026gt;in.hocg.boot\u0026lt;/groupId\u0026gt; \u0026lt;artifactId\u0026gt;mybatis-plus-spring-boot-starter\u0026lt;/artifactId\u0026gt; \u0026lt;version\u0026gt;1.0.49\u0026lt;/version\u0026gt; \u0026lt;/dependency\u0026gt; 若相同类型但版本不同的依赖存在于同一个 pom 文件,只会引入后一个声明的依赖。\n2、项目的两个依赖同时引入了某个依赖。\n举个例子,项目存在下面这样的依赖关系:\n依赖链路一:A -\u0026gt; B -\u0026gt; C -\u0026gt; X(1.0) 依赖链路二:A -\u0026gt; D -\u0026gt; X(2.0) 这两条依赖路径上有两个版本的 X,为了避免依赖重复,Maven 只会选择其中的一个进行解析。\n哪个版本的 X 会被 Maven 解析使用呢?\nMaven 在遇到这种问题的时候,会遵循 路径最短优先 和 声明顺序优先 两大原则。解决这个问题的过程也被称为 Maven 依赖调解 。\n路径最短优先\n依赖链路一:A -\u0026gt; B -\u0026gt; C -\u0026gt; X(1.0) // dist = 3 依赖链路二:A -\u0026gt; D -\u0026gt; X(2.0) // dist = 2 依赖链路二的路径最短,因此,X(2.0)会被解析使用。\n不过,你也可以发现。路径最短优先原则并不是通用的,像下面这种路径长度相等的情况就不能单单通过其解决了:\n依赖链路一:A -\u0026gt; B -\u0026gt; X(1.0) // dist = 3 依赖链路二:A -\u0026gt; D -\u0026gt; X(2.0) // dist = 2 因此,Maven 又定义了声明顺序优先原则。\n依赖调解第一原则不能解决所有问题,比如这样的依赖关系:A-\u0026gt;B-\u0026gt;Y(1.0)、A-\u0026gt; C-\u0026gt;Y(2.0),Y(1.0)和 Y(2.0)的依赖路径长度是一样的,都为 2。Maven 定义了依赖调解的第二原则:\n声明顺序优先\n在依赖路径长度相等的前提下,在 pom.xml 中依赖声明的顺序决定了谁会被解析使用,顺序最前的那个依赖优胜。该例中,如果 B 的依赖声明在 D 之前,那么 X (1.0)就会被解析使用。\n\u0026lt;!-- A pom.xml --\u0026gt; \u0026lt;dependencies\u0026gt; ... dependency B ... dependency D \u0026lt;/dependencies\u0026gt; 排除依赖 # 单纯依赖 Maven 来进行依赖调解,在很多情况下是不适用的,需要我们手动排除依赖。\n举个例子,当前项目存在下面这样的依赖关系:\n依赖链路一:A -\u0026gt; B -\u0026gt; C -\u0026gt; X(1.5) // dist = 3 依赖链路二:A -\u0026gt; D -\u0026gt; X(1.0) // dist = 2 根据路径最短优先原则,X(1.0) 会被解析使用,也就是说实际用的是 1.0 版本的 X。\n但是!!!这会一些问题:如果 D 依赖用到了 1.5 版本的 X 中才有的一个类,运行项目就会报NoClassDefFoundError错误。如果 D 依赖用到了 1.5 版本的 X 中才有的一个方法,运行项目就会报NoSuchMethodError错误。\n现在知道为什么你的 Maven 项目总是会报**NoClassDefFoundError和NoSuchMethodError**错误了吧?\n如何解决呢? 我们可以通过exclusive标签手动将 X(1.0) 给排除。\n\u0026lt;dependencyB\u0026gt; ...... \u0026lt;exclusions\u0026gt; \u0026lt;exclusion\u0026gt; \u0026lt;artifactId\u0026gt;x\u0026lt;/artifactId\u0026gt; \u0026lt;groupId\u0026gt;org.apache.x\u0026lt;/groupId\u0026gt; \u0026lt;/exclusion\u0026gt; \u0026lt;/exclusions\u0026gt; \u0026lt;/dependency\u0026gt; 一般我们在解决依赖冲突的时候,都会优先保留版本较高的。这是因为大部分 jar 在升级的时候都会做到向下兼容。\n如果高版本修改了低版本的一些类或者方法的话,这个时候就能直接保留高版本了,而是应该考虑优化上层依赖,比如升级上层依赖的版本。\n还是上面的例子:\n依赖链路一:A -\u0026gt; B -\u0026gt; C -\u0026gt; X(1.5) // dist = 3 依赖链路二:A -\u0026gt; D -\u0026gt; X(1.0) // dist = 2 我们保留了 1.5 版本的 X,但是这个版本的 X 删除了 1.0 版本中的某些类。这个时候,我们可以考虑升级 D 的版本到一个 X 兼容的版本。\nMaven 仓库 # 在 Maven 世界中,任何一个依赖、插件或者项目构建的输出,都可以称为 构件 。\n坐标和依赖是构件在 Maven 世界中的逻辑表示方式,构件的物理表示方式是文件,Maven 通过仓库来统一管理这些文件。 任何一个构件都有一组坐标唯一标识。有了仓库之后,无需手动引入构件,我们直接给定构件的坐标即可在 Maven 仓库中找到该构件。\nMaven 仓库分为:\n本地仓库 :运行 Maven 的计算机上的一个目录,它缓存远程下载的构件并包含尚未发布的临时构件。settings.xml 文件中可以看到 Maven 的本地仓库路径配置,默认本地仓库路径是在 ${user.home}/.m2/repository。 远程仓库 :官方或者其他组织维护的 Maven 仓库。 Maven 远程仓库可以分为:\n中央仓库 :这个仓库是由 Maven 社区来维护的,里面存放了绝大多数开源软件的包,并且是作为 Maven 的默认配置,不需要开发者额外配置。另外为了方便查询,还提供了一个 查询地址,开发者可以通过这个地址更快的搜索需要构件的坐标。 私服 :私服是一种特殊的远程 Maven 仓库,它是架设在局域网内的仓库服务,私服一般被配置为互联网远程仓库的镜像,供局域网内的 Maven 用户使用。 其他的公共仓库 :有一些公共仓库是未来加速访问(比如阿里云 Maven 镜像仓库)或者部分构件不存在于中央仓库中。 Maven 依赖包寻找顺序:\n先去本地仓库找寻,有的话,直接使用。 本地仓库没有找到的话,会去远程仓库找寻,下载包到本地仓库。 远程仓库没有找到的话,会报错。 Maven 生命周期 # Maven 的生命周期就是为了对所有的构建过程进行抽象和统一,包含了项目的清理、初始化、编译、测试、打包、集成测试、验证、部署和站点生成等几乎所有构建步骤。\nMaven 定义了 3 个生命周期META-INF/plexus/components.xml:\ndefault 生命周期 clean生命周期 site生命周期 这些生命周期是相互独立的,每个生命周期包含多个阶段(phase)。并且,这些阶段是有序的,也就是说,后面的阶段依赖于前面的阶段。当执行某个阶段的时候,会先执行它前面的阶段。\n执行 Maven 生命周期的命令格式如下:\nmvn 阶段 [阶段2] ...[阶段n] default 生命周期 # default生命周期是在没有任何关联插件的情况下定义的,是 Maven 的主要生命周期,用于构建应用程序,共包含 23 个阶段。\n\u0026lt;phases\u0026gt; \u0026lt;!-- 验证项目是否正确,并且所有必要的信息可用于完成构建过程 --\u0026gt; \u0026lt;phase\u0026gt;validate\u0026lt;/phase\u0026gt; \u0026lt;!-- 建立初始化状态,例如设置属性 --\u0026gt; \u0026lt;phase\u0026gt;initialize\u0026lt;/phase\u0026gt; \u0026lt;!-- 生成要包含在编译阶段的源代码 --\u0026gt; \u0026lt;phase\u0026gt;generate-sources\u0026lt;/phase\u0026gt; \u0026lt;!-- 处理源代码 --\u0026gt; \u0026lt;phase\u0026gt;process-sources\u0026lt;/phase\u0026gt; \u0026lt;!-- 生成要包含在包中的资源 --\u0026gt; \u0026lt;phase\u0026gt;generate-resources\u0026lt;/phase\u0026gt; \u0026lt;!-- 将资源复制并处理到目标目录中,为打包阶段做好准备。 --\u0026gt; \u0026lt;phase\u0026gt;process-resources\u0026lt;/phase\u0026gt; \u0026lt;!-- 编译项目的源代码 --\u0026gt; \u0026lt;phase\u0026gt;compile\u0026lt;/phase\u0026gt; \u0026lt;!-- 对编译生成的文件进行后处理,例如对 Java 类进行字节码增强/优化 --\u0026gt; \u0026lt;phase\u0026gt;process-classes\u0026lt;/phase\u0026gt; \u0026lt;!-- 生成要包含在编译阶段的任何测试源代码 --\u0026gt; \u0026lt;phase\u0026gt;generate-test-sources\u0026lt;/phase\u0026gt; \u0026lt;!-- 处理测试源代码 --\u0026gt; \u0026lt;phase\u0026gt;process-test-sources\u0026lt;/phase\u0026gt; \u0026lt;!-- 生成要包含在编译阶段的测试源代码 --\u0026gt; \u0026lt;phase\u0026gt;generate-test-resources\u0026lt;/phase\u0026gt; \u0026lt;!-- 处理从测试代码文件编译生成的文件 --\u0026gt; \u0026lt;phase\u0026gt;process-test-resources\u0026lt;/phase\u0026gt; \u0026lt;!-- 编译测试源代码 --\u0026gt; \u0026lt;phase\u0026gt;test-compile\u0026lt;/phase\u0026gt; \u0026lt;!-- 处理从测试代码文件编译生成的文件 --\u0026gt; \u0026lt;phase\u0026gt;process-test-classes\u0026lt;/phase\u0026gt; \u0026lt;!-- 使用合适的单元测试框架(Junit 就是其中之一)运行测试 --\u0026gt; \u0026lt;phase\u0026gt;test\u0026lt;/phase\u0026gt; \u0026lt;!-- 在实际打包之前,执行任何的必要的操作为打包做准备 --\u0026gt; \u0026lt;phase\u0026gt;prepare-package\u0026lt;/phase\u0026gt; \u0026lt;!-- 获取已编译的代码并将其打包成可分发的格式,例如 JAR、WAR 或 EAR 文件 --\u0026gt; \u0026lt;phase\u0026gt;package\u0026lt;/phase\u0026gt; \u0026lt;!-- 在执行集成测试之前执行所需的操作。 例如,设置所需的环境 --\u0026gt; \u0026lt;phase\u0026gt;pre-integration-test\u0026lt;/phase\u0026gt; \u0026lt;!-- 处理并在必要时部署软件包到集成测试可以运行的环境 --\u0026gt; \u0026lt;phase\u0026gt;integration-test\u0026lt;/phase\u0026gt; \u0026lt;!-- 执行集成测试后执行所需的操作。 例如,清理环境 --\u0026gt; \u0026lt;phase\u0026gt;post-integration-test\u0026lt;/phase\u0026gt; \u0026lt;!-- 运行任何检查以验证打的包是否有效并符合质量标准。 --\u0026gt; \u0026lt;phase\u0026gt;verify\u0026lt;/phase\u0026gt; \u0026lt;!-- 将包安装到本地仓库中,可以作为本地其他项目的依赖 --\u0026gt; \u0026lt;phase\u0026gt;install\u0026lt;/phase\u0026gt; \u0026lt;!-- 将最终的项目包复制到远程仓库中与其他开发者和项目共享 --\u0026gt; \u0026lt;phase\u0026gt;deploy\u0026lt;/phase\u0026gt; \u0026lt;/phases\u0026gt; 根据前面提到的阶段间依赖关系理论,当我们执行 mvn test命令的时候,会执行从 validate 到 test 的所有阶段,这也就解释了为什么执行测试的时候,项目的代码能够自动编译。\nclean 生命周期 # clean 生命周期的目的是清理项目,共包含 3 个阶段:\npre-clean clean post-clean \u0026lt;phases\u0026gt; \u0026lt;!-- 执行一些需要在clean之前完成的工作 --\u0026gt; \u0026lt;phase\u0026gt;pre-clean\u0026lt;/phase\u0026gt; \u0026lt;!-- 移除所有上一次构建生成的文件 --\u0026gt; \u0026lt;phase\u0026gt;clean\u0026lt;/phase\u0026gt; \u0026lt;!-- 执行一些需要在clean之后立刻完成的工作 --\u0026gt; \u0026lt;phase\u0026gt;post-clean\u0026lt;/phase\u0026gt; \u0026lt;/phases\u0026gt; \u0026lt;default-phases\u0026gt; \u0026lt;clean\u0026gt; org.apache.maven.plugins:maven-clean-plugin:2.5:clean \u0026lt;/clean\u0026gt; \u0026lt;/default-phases\u0026gt; 根据前面提到的阶段间依赖关系理论,当我们执行 mvn clean 的时候,会执行 clean 生命周期中的 pre-clean 和 clean 阶段。\nsite 生命周期 # site 生命周期的目的是建立和发布项目站点,共包含 4 个阶段:\npre-site site post-site site-deploy \u0026lt;phases\u0026gt; \u0026lt;!-- 执行一些需要在生成站点文档之前完成的工作 --\u0026gt; \u0026lt;phase\u0026gt;pre-site\u0026lt;/phase\u0026gt; \u0026lt;!-- 生成项目的站点文档作 --\u0026gt; \u0026lt;phase\u0026gt;site\u0026lt;/phase\u0026gt; \u0026lt;!-- 执行一些需要在生成站点文档之后完成的工作,并且为部署做准备 --\u0026gt; \u0026lt;phase\u0026gt;post-site\u0026lt;/phase\u0026gt; \u0026lt;!-- 将生成的站点文档部署到特定的服务器上 --\u0026gt; \u0026lt;phase\u0026gt;site-deploy\u0026lt;/phase\u0026gt; \u0026lt;/phases\u0026gt; \u0026lt;default-phases\u0026gt; \u0026lt;site\u0026gt; org.apache.maven.plugins:maven-site-plugin:3.3:site \u0026lt;/site\u0026gt; \u0026lt;site-deploy\u0026gt; org.apache.maven.plugins:maven-site-plugin:3.3:deploy \u0026lt;/site-deploy\u0026gt; \u0026lt;/default-phases\u0026gt; Maven 能够基于 pom.xml 所包含的信息,自动生成一个友好的站点,方便团队交流和发布项目信息。\nMaven 插件 # Maven 本质上是一个插件执行框架,所有的执行过程,都是由一个一个插件独立完成的。像咱们日常使用到的 install、clean、deploy 等命令,其实底层都是一个一个的 Maven 插件。关于 Maven 的核心插件可以参考官方的这篇文档:https://maven.apache.org/plugins/index.html 。\n除了 Maven 自带的插件之外,还有一些三方提供的插件比如单测覆盖率插件 jacoco-maven-plugin、帮助开发检测代码中不合规范的地方的插件 maven-checkstyle-plugin、分析代码质量的 sonar-maven-plugin。并且,我们还可以自定义插件来满足自己的需求。\njacoco-maven-plugin 使用示例:\n\u0026lt;build\u0026gt; \u0026lt;plugins\u0026gt; \u0026lt;plugin\u0026gt; \u0026lt;groupId\u0026gt;org.jacoco\u0026lt;/groupId\u0026gt; \u0026lt;artifactId\u0026gt;jacoco-maven-plugin\u0026lt;/artifactId\u0026gt; \u0026lt;version\u0026gt;0.8.8\u0026lt;/version\u0026gt; \u0026lt;executions\u0026gt; \u0026lt;execution\u0026gt; \u0026lt;goals\u0026gt; \u0026lt;goal\u0026gt;prepare-agent\u0026lt;/goal\u0026gt; \u0026lt;/goals\u0026gt; \u0026lt;/execution\u0026gt; \u0026lt;execution\u0026gt; \u0026lt;id\u0026gt;generate-code-coverage-report\u0026lt;/id\u0026gt; \u0026lt;phase\u0026gt;test\u0026lt;/phase\u0026gt; \u0026lt;goals\u0026gt; \u0026lt;goal\u0026gt;report\u0026lt;/goal\u0026gt; \u0026lt;/goals\u0026gt; \u0026lt;/execution\u0026gt; \u0026lt;/executions\u0026gt; \u0026lt;/plugin\u0026gt; \u0026lt;/plugins\u0026gt; \u0026lt;/build\u0026gt; 你可以将 Maven 插件理解为一组任务的集合,用户可以通过命令行直接运行指定插件的任务,也可以将插件任务挂载到构建生命周期,随着生命周期运行。\nMaven 插件被分为下面两种类型:\nBuild plugins :在构建时执行。 Reporting plugins:在网站生成过程中执行。 Maven 多模块管理 # 多模块管理简单地来说就是将一个项目分为多个模块,每个模块只负责单一的功能实现。直观的表现就是一个 Maven 项目中不止有一个 pom.xml 文件,会在不同的目录中有多个 pom.xml 文件,进而实现多模块管理。\n多模块管理除了可以更加便于项目开发和管理,还有如下好处:\n降低代码之间的耦合性(从类级别的耦合提升到 jar 包级别的耦合); 减少重复,提升复用性; 每个模块都可以是自解释的(通过模块名或者模块文档); 模块还规范了代码边界的划分,开发者很容易通过模块确定自己所负责的内容。 多模块管理下,会有一个父模块,其他的都是子模块。父模块通常只有一个 pom.xml,没有其他内容。父模块的 pom.xml 一般只定义了各个依赖的版本号、包含哪些子模块以及插件有哪些。不过,要注意的是,如果依赖只在某个子项目中使用,则可以在子项目的 pom.xml 中直接引入,防止父 pom 的过于臃肿。\n如下图所示,Dubbo 项目就被分成了多个子模块比如 dubbo-common(公共逻辑模块)、dubbo-remoting(远程通讯模块)、dubbo-rpc(远程调用模块)。\n文章推荐 # 安全同学讲 Maven 间接依赖场景的仲裁机制 - 阿里开发者 - 2022 高效使用 Java 构建工具| Maven 篇 - 阿里开发者 - 2022 安全同学讲 Maven 重打包的故事 - 阿里开发者 - 2022 参考 # 《Maven 实战》 Introduction to Repositories - Maven 官方文档:https://maven.apache.org/guides/introduction/introduction-to-repositories.html Introduction to the Build Lifecycle - Maven 官方文档:https://maven.apache.org/guides/introduction/introduction-to-the-lifecycle.html#Lifecycle_Reference Maven 依赖范围:http://www.mvnbook.com/maven-dependency.html 解决 maven 依赖冲突,这篇就够了!:https://www.cnblogs.com/qdhxhz/p/16363532.html Multi-Module Project with Maven:https://www.baeldung.com/maven-multi-module "},{"id":294,"href":"/zh/docs/technology/Review/java_guide/java/Concurrent/ly030301lyatomicpre/","title":"Atomic预备知识","section":"并发","content":" Java实现CAS的原理[非javaguide] # i是非线程安全的,因为**i不是原子操作;可以使用synchronized和CAS实现加锁**\nsynchronized是悲观锁,一旦获得锁,其他线程进入后就会阻塞等待锁;而CAS是乐观锁,执行时不会加锁,假设没有冲突,如果因为冲突失败了就重试,直到成功\n乐观锁和悲观锁\n这是一种分类方式 悲观锁,总是认为每次访问共享资源会发生冲突,所以必须对每次数据操作加锁,以保证临界区的程序同一时间只能有一个线程在执行 乐观锁,又称**“无锁”**,假设对共享资源访问没有冲突,线程可以不停的执行,无需加锁无需等待;一旦发生冲突,通常是使用一种称为CAS的技术保证线程执行安全 无锁没有锁的存在,因此不可能发生死锁,即乐观锁天生免疫死锁 乐观锁用于**“读多写少”的环境,避免加锁频繁影响性能;悲观锁用于“写多读少”,避免频繁失败及重试**影响性能 CAS概念,即CompareAndSwap ,比较和交换,CAS中,有三个值(概念上)\nV:要更新的变量(var);E:期望值(expected);N:新值(new) 判断V是否等于E,如果等于,将V的值设置为N;如果不等,说明已经有其它线程更新了V,则当前线程放弃更新,什么都不做。 一般来说,预期值E本质上指的是“旧值”(判断是否修改了)\n如果有一个多个线程共享的变量i原本等于5,我现在在线程A中,想把它设置为新的值6; 我们使用CAS来做这个事情; (首先要把原来的值5在线程中保存起来) 接下来是原子操作:首先我们用(现在的i)去与5对比,发现它等于5,说明没有被其它线程改过,那我就把它设置为新的值6,此次CAS成功,i的值被设置成了6; 如果不等于5,说明i被其它线程改过了(比如现在i的值为2),那么我就什么也不做,此次CAS失败,i的值仍然为2。 其中i为V,5为E,6为N\nCAS是一种原子操作,它是一种系统原语,是一条CPU原子指令,从CPU层面保证它的原子性(不可能出现说,判断了对比了i为5之后,正准备更新它的值,此时该值被其他线程改了)\n当多个线程同时使用CAS操作一个变量时,只有一个会胜出,并成功更新,其余均会失败,但失败的线程并不会被挂起,仅是被告知失败,并且允许再次尝试,当然也允许失败的线程放弃操作。\nJava实现CAS的原理 - Unsafe类\n在Java中,如果一个方法是native的,那Java就不负责具体实现它,而是交给底层的JVM使用c或者c++去实现\nJava中有一个Unsafe类,在sun.misc包中,里面有一些native方法,其中包括:\nboolean compareAndSwapObject(Object o, long offset,Object expected, Object x); boolean compareAndSwapInt(Object o, long offset,int expected,int x); boolean compareAndSwapLong(Object o, long offset,long expected,long x); //------\u0026gt;AtomicInteger.class public class AtomicInteger extends Number implements java.io.Serializable { private static final long serialVersionUID = 6214790243416807050L; // setup to use Unsafe.compareAndSwapInt for updates private static final Unsafe unsafe = Unsafe.getUnsafe(); private static final long valueOffset; static { try { valueOffset = unsafe.objectFieldOffset (AtomicInteger.class.getDeclaredField(\u0026#34;value\u0026#34;)); } catch (Exception ex) { throw new Error(ex); } } private volatile int value; public final int getAndIncrement() { return unsafe.getAndAddInt(this, valueOffset, 1); } }\nUnsafe中对CAS的实现是C++写的,它的具体实现和操作系统、CPU都有关系。Linux的X86中主要通过cmpxchgl这个指令在CPU级完成CAS操作,如果是多处理器则必须使用lock指令加锁\nUnsafe类中还有park(线程挂起)和unpark(线程恢复),LockSupport底层则调用了该方法;还有支持反射操作的allocateInstance()\n原子操作- AtomicInteger类源码简析 JDK提供了一些原子操作的类,在java.util.concurrent.atomic包下面,JDK11中有如下17个类 包括 原子更新基本类型,原子更新数组,原子更新引用,原子更新字段(属性)\n其中,AtomicInteger类的getAndAdd(int data)\npublic final int getAndAdd(int delta) { return unsafe.getAndAddInt(this, valueOffset, delta); } //unsafe字段 private static final jdk.internal.misc.Unsafe U = jdk.internal.misc.Unsafe.getUnsafe(); //上面方法实际调用 @HotSpotIntrinsicCandidate public final int getAndAddInt(Object o, long offset, int delta) { int v; do { v = getIntVolatile(o, offset); } while (!weakCompareAndSetInt(o, offset, v, v + delta)); return v; } //对于offset,这是一个对象偏移量,用于获取某个字段相对Java对象的起始地址的偏移量 /* 一个java对象可以看成是一段内存,各个字段都得按照一定的顺序放在这段内存里,同时考虑到对齐要求,可能这些字段不是连续放置的, 用这个方法能准确地告诉你某个字段相对于对象的起始内存地址的字节偏移量,因为是相对偏移量,所以它其实跟某个具体对象又没什么太大关系,跟class的定义和虚拟机的内存模型的实现细节更相关。 */ public class AtomicInteger extends Number implements java.io.Serializable { private static final long serialVersionUID = 6214790243416807050L; // setup to use Unsafe.compareAndSwapInt for updates private static final Unsafe unsafe = Unsafe.getUnsafe(); private static final long valueOffset; static { try { valueOffset = unsafe.objectFieldOffset (AtomicInteger.class.getDeclaredField(\u0026#34;value\u0026#34;)); } catch (Exception ex) { throw new Error(ex); } } private volatile int value; public final int getAndIncrement() { return unsafe.getAndAddInt(this, valueOffset, 1); } } 再重新看这段代码\n@HotSpotIntrinsicCandidate public final int getAndAddInt(Object o, long offset, int delta) { int v; do { v = getIntVolatile(o, offset); } while (!weakCompareAndSetInt(o, offset, v, v + delta)); return v; } 这里声明了v,即要返回的值,即不论如何都会返回原来的值(更新成功前的值),然后新的值为v+delta\n使用do-while保证所有循环至少执行一遍\n循环体的条件是一个CAS方法:\npublic final boolean weakCompareAndSetInt(Object o, long offset, int expected, int x) { return compareAndSetInt(o, offset, expected, x); } public final native boolean compareAndSetInt(Object o, long offset, int expected, int x); 最终调用了native方法:compareAndSetInt方法\n为甚么要经过一层weakCompareAndSetInt,在JDK 8及之前的版本,这两个方法是一样的。\n而在JDK 9开始,这两个方法上面增加了@HotSpotIntrinsicCandidate注解。这个注解允许HotSpot VM自己来写汇编或IR编译器来实现该方法以提供性能。也就是说虽然外面看到的在JDK9中weakCompareAndSet和compareAndSet底层依旧是调用了一样的代码,但是不排除HotSpot VM会手动来实现weakCompareAndSet真正含义的功能的可能性。\n简单来说,weakCompareAndSet操作仅保留了volatile自身变量的特性,而除去了happens-before规则带来的内存语义。也就是说,weakCompareAndSet**无法保证处理操作目标的volatile变量外的其他变量的执行顺序( 编译器和处理器为了优化程序性能而对指令序列进行重新排序 ),同时也无法保证这些变量的可见性。**这在一定程度上可以提高性能。(没看懂)\nCAS如果旧值V不等于预期值E,它就会更新失败。说明旧的值发生了变化。那我们当然需要返回的是被其他线程改变之后的旧值了,因此放在了do循环体内\nCAS实现原子操作的三大问题\nABA问题\n就是一个值原来是A,变成了B,又变回了A。这个时候使用CAS是检查不出变化的,但实际上却被更新了两次\n在变量前面追加上版本号或者时间戳。从JDK 1.5开始,JDK的atomic包里提供了一个类AtomicStampedReference类来解决ABA问题\nAtomicStampedReference类的compareAndSet方法的作用是首先检查当前引用是否等于预期引用,并且检查当前标志是否等于预期标志,如果二者都相等,才使用CAS设置为新的值和标志。\npublic boolean compareAndSet(V expectedReference, V newReference, int expectedStamp, int newStamp) { Pair\u0026lt;V\u0026gt; current = pair; return expectedReference == current.reference \u0026amp;\u0026amp; expectedStamp == current.stamp \u0026amp;\u0026amp; ((newReference == current.reference \u0026amp;\u0026amp; newStamp == current.stamp) || casPair(current, Pair.of(newReference, newStamp))); } 循环时间长开销大\nCAS多与自旋结合,如果自旋CAS长时间不成功,则会占用大量CPU资源,解决思路是让JVM支持处理器提供的pause指令\npause指令能让自旋失败时cpu睡眠一小段时间再继续自旋,从而使得读操作的频率低很多,为解决内存顺序冲突而导致的CPU流水线重排的代价也会小很多。\n限制次数(如果可以放弃操作的话)\n只能保证一个共享变量的原子操作\n使用JDK 1.5开始就提供的AtomicReference类保证对象之间的原子性,把多个变量放到一个对象里面进行CAS操作; 使用锁。锁内的临界区代码可以保证只有当前线程能操作。 AtomicInteger的使用[非javaguide] # //AtomicInteger类常用方法(下面的自增,都使用了CAS,是同步安全的) ublic final int get() //获取当前的值 public final int getAndSet(int newValue)//获取当前的值,并设置新的值 public final int getAndIncrement()//获取当前的值,并自增 public final int getAndDecrement() //获取当前的值,并自减 public final int getAndAdd(int delta) //获取当前的值,并加上预期的值 boolean compareAndSet(int expect, int update) //如果输入的数值等于预期值,则以原子方式将该值设置为输入值(update) public final void lazySet(int newValue)//最终设置为newValue,使用 lazySet 设置之后可能导致其他线程在之后的一小段时间内还是可以读到旧的值。 ------ //使用如下 class AtomicIntegerTest { private AtomicInteger count = new AtomicInteger(); //使用AtomicInteger之后,不需要对该方法加锁,也可以实现线程安全。 public void increment() { count.incrementAndGet(); } public int getCount() { return count.get(); } } 浅谈AtomicInteger实现原理[非javaguide] # 位于Java.util.concurrent.atomic包下,对int封装,提供原子性的访问和更新操作,其原子性操作的实现基于CAS(CompareAndSet)\nCAS,比较并交换,Java并发中lock-free机制的基础,调用Sun的Unsafe的CompareAndSwapInt完成,为native方法,基于CPU的CAS指令来实现的,即无阻塞;且为CAS原语\nCAS:三个参数,1. 当前内存值V 2.旧的预期值 3.即将更新的值,当且仅当预期值A和内存值相同时,将内存值改为 8 并返回true;否则返回false 在JAVA中,CAS通过调用C++库实现,由C++库再去调用CPU指令集。\nCAS确定\nABA 问题 如果期间发生了 A -\u0026gt; B -\u0026gt; A 的更新,仅仅判断数值是 A,可能导致不合理的修改操作;为此,提供了AtomicStampedReference 工具类,为引用建立类似版本号stamp的方式\n循环时间长,开销大。CAS适用于竞争情况短暂的情况,有需要的时候要限制自旋次数,以免过度消耗CPU\n只能保证一个共享变量的原子操作 对多个共享变量操作时,循环CAS就无法保证操作的原子性,这个时候就可以用锁;或者取巧一下,比如 i = 2 , j = a ,合并后为 ij = 2a ,然后cas操作2a\nJava1.5开始JDK提供了AtomicReference类来保证引用对象之间的原子性,你可以把多个变量放在一个对象里来进行CAS操作,例子如下: 如图,它是同时更新了两个变量,而这两个变量都在新的对象上,所以就能解决多个共享变量的问题,即“将问题转换成,如果变量更新了,则更换一个对象”\nAtomicInteger原理浅析\n一些公共属性:\npublic class AtomicInteger extends Number implements java.io.Serializable { private static final long serialVersionUID = 6214790243416807050L; // setup to use Unsafe.compareAndSwapInt for updates private static final Unsafe unsafe = Unsafe.getUnsafe(); private static final long valueOffset; static { try { valueOffset = unsafe.objectFieldOffset (AtomicInteger.class.getDeclaredField(\u0026#34;value\u0026#34;)); } catch (Exception ex) { throw new Error(ex); } } private volatile int value; } AtomicInteger,根据valueOffset代表的该变量值,在内存中的偏移地址,从而获取数据;且value用volatile修饰,保证多线程之间的可见性\npublic final int getAndIncrement() { return unsafe.getAndAddInt(this, valueOffset, 1); } //unsafe.getAndAddInt public final int getAndAddInt(Object var1, long var2, int var4) { int var5; do { var5 = this.getIntVolatile(var1, var2); } while(!this.compareAndSwapInt(var1, var2, var5, var5 + var4));//先获取var1对象的偏移量为var2的内存地址上的值【现在的实际值】 //如果此刻还是var5,+1并赋值,否则重新获取 return var5; } 假设线程1和线程2通过getIntVolatile拿到value的值都为1,线程1被挂起,线程2继续执行 (这里是非原子的哦) 线程2在compareAndSwapInt操作中由于预期值和内存值都为1,因此成功将内存值更新为2 线程1继续执行,在compareAndSwapInt操作中,预期值是1,而当前的内存值为2,CAS操作失败,什么都不做,返回false 线程1重新通过getIntVolatile拿到最新的value为2,再进行一次compareAndSwapInt操作,这次操作成功,内存值更新为3 原子操作的实现原理\nJava中的CAS操作正是利用了处理器提供的CMPXCHG指令实现的。自旋CAS实现的基本思路就是循环进行CAS操作直到操作成功为止。 在CAS中有三个操作数:分别是内存地址(在Java中可以简单理解为变量的内存地址,用V表示,要获取实时值)、旧的预期值(用A表示,[操作之前保存的])和新值(用B表示)。CAS指令执行时,当且仅当V符合旧的预期值A时,处理器才会用新值B更新V的值,否则他就不执行更新,但无论是否更新了V的值,都会返回V的旧值。(这里说的三个值,指的是逻辑概念,而不是实际概念) "},{"id":295,"href":"/zh/docs/technology/Review/java_guide/database/MySQL/ly0611lymysql-high-performance-optimization-specification-recommendations/","title":"MySQL高性能优化规范建议总结","section":"MySQL","content":" 转载自https://github.com/Snailclimb/JavaGuide(添加小部分笔记)感谢作者!\n索引优化相关\nin 代替 or not exist 代替 not in 数据库命名规范 # 所有数据库对象名称必须使用小写字母并用下划线分割 所有数据库对象名称禁止使用 MySQL 保留关键字(如果表名中包含关键字查询时,需要将其用单引号括起来) 数据库对象的命名要能做到见名识意,并且最好不要超过 32 个字符 临时库表必须以 tmp_ 为前缀并以日期为后缀,备份表必须以 bak_ 为前缀并以日期 (时间戳) 为后缀 所有存储相同数据的列名和列类型必须一致(一般作为关联列,如果查询时关联列类型不一致会自动进行数据类型隐式转换,会造成列上的索引失效,导致查询效率降低) 数据库基本设计规范 # 所有表必须使用InnoDB存储引擎 # 没有特殊要求(即 InnoDB 无法满足的功能如:列存储,存储空间数据等)的情况下,所有表必须使用 InnoDB 存储引擎(MySQL5.5 之前默认使用 Myisam,5.6 以后默认的为 InnoDB)。 InnoDB 支持事务,支持行级锁,更好的恢复性,高并发下性能更好 数据库和表的字符集统一使用UTF-8 # 兼容性更好,统一字符集可以避免由于字符集转换产生的乱码,不同的字符集进行比较前需要进行转换会造成索引失效,如果数据库中有存储 emoji 表情的需要,字符集需要采用 utf8mb4 字符集。\n参考文章:\nMySQL 字符集不一致导致索引失效的一个真实案例open in new window [MySQL 字符集详解 所有表和字段都需要添加注释 # 使用 comment 从句添加表和列的备注,从一开始就进行数据字典的维护\n尽量控制单表数据量的大小,建议控制在500万以内 # 500 万并不是 MySQL 数据库的限制,过大会造成修改表结构,备份,恢复都会有很大的问题。\n可以用历史数据归档(应用于日志数据),**分库分表(应用于业务数据)**等手段来控制数据量大小\n谨慎使用MySQL分区表 # 分区表在物理上表现为多个文件,在逻辑上表现为一个表;\n谨慎选择分区键,跨分区查询效率可能更低;\n建议采用物理分表的方式管理大数据。\n经常一起使用的列放到一个表中 # 避免更多的关联操作。\n禁止在表中建立预留字段 # 预留字段的命名很难做到见名识义。 预留字段无法确认存储的数据类型,所以无法选择合适的类型。 对预留字段类型的修改,会对表进行锁定 禁止在数据库中存储文本(比如图片)这类大的二进制数据 # 在数据库中存储文件会严重影响数据库性能,消耗过多存储空间。\n文件(比如图片)这类大的二进制数据通常存储于文件服务器,数据库只存储文件地址信息。\n不要被数据库范式所束缚 # 一般来说,设计关系数据库时需要满足第三范式,但为了满足第三范式,我们可能会拆分出多张表。而在进行查询时需要对多张表进行关联查询,有时为了提高查询效率,会降低范式的要求,在表中保存一定的冗余信息,也叫做反范式。但要注意反范式一定要适度。\n禁止在线上做数据库压力测试 # 禁止从开发环境、测试环境,直接连接生产环境数据库 # 安全隐患极大,要对生产环境抱有敬畏之心!\n数据库字段设计规范 # 优先选择符合存储需要的最小数据类型 # Byte:字节\n存储字节越小,占用也就空间越小,性能也越好。\na.某些字符串可以转换成数字类型存储比如可以将 IP 地址转换成整型数据。\n数字是连续的,性能更好,占用空间也更小。\nMySQL 提供了两个方法来处理 ip 地址\nINET_ATON() : 把 ip 转为无符号整型 (4-8 位) INET_NTOA() :把整型的 ip 转为地址 插入数据前,先用 INET_ATON() 把 ip 地址转为整型,显示数据时,使用 INET_NTOA() 把整型的 ip 地址转为地址显示即可。\nb.对于非负型的数据 (如自增 ID,整型 IP,年龄) 来说,要优先使用无符号整型来存储。\n无符号相对于有符号可以多出一倍的存储空间\nSIGNED INT -2147483648~2147483647 UNSIGNED INT 0~4294967295 c.小数值类型(比如年龄、状态表示如 0/1)优先使用 TINYINT 类型。\n避免使用TEXT、BLOB数据类型,最常见的TEXT类型可以存储64k的数据 # a. 建议把 BLOB 或是 TEXT 列分离到单独的扩展表中。\nMySQL 内存临时表不支持 TEXT、BLOB 这样的大数据类型,如果查询中包含这样的数据,在排序等操作时,就不能使用内存临时表,必须使用磁盘临时表进行。而且对于这种数据,MySQL 还是要进行二次查询,会使 sql 性能变得很差,但是不是说一定不能使用这样的数据类型。\n如果一定要使用,建议把 BLOB 或是 TEXT 列分离到单独的扩展表中,查询时一定不要使用 select *而只需要取出必要的列,不需要 TEXT 列的数据时不要对该列进行查询。\n2、TEXT 或 BLOB 类型只能使用前缀索引\n因为 MySQL 对索引字段长度是有限制的,所以 TEXT 类型只能使用前缀索引,并且 TEXT 列上是不能有默认值的\n避免使用ENUM类型 # 原因:\n修改 ENUM 值需要使用 ALTER 语句; ENUM 类型的 ORDER BY 操作效率低,需要额外操作; ENUM 数据类型存在一些限制比如建议不要使用数值作为 ENUM 的枚举值。 相关阅读: 是否推荐使用 MySQL 的 enum 类型? - 架构文摘 - 知乎open in new window\n尽可能把所有的列定义为NOT NULL # 除非有特别的原因使用 NULL 值,应该总是让字段保持 NOT NULL。\n索引 NULL 列需要额外的空间来保存,所以要占用更多的空间;\n该位指示了该行数据中是否有NULL值,有则使用1来表示,该部分占的字节大小大概为1字节,比如当NULL标志位为06时,06转换为二进制为110,表示第二列和第三列为NULL。\n进行比较和计算时要对 NULL 值做特别的处理。\n相关阅读: 技术分享 | MySQL 默认值选型(是空,还是 NULL)open in new window 。\n使用TIMESTAMP(4个字节)或DATETIME类型(8个字节)存储时间 # TIMESTAMP 存储的时间范围 1970-01-01 00:00:01 ~ 2038-01-19-03:14:07\nTIMESTAMP 占用 4 字节和 INT 相同,但比 INT 可读性高\n超出 TIMESTAMP 取值范围的使用 DATETIME 类型存储\n反之, 经常会有人用字符串存储日期型的数据**(不正确的做法)**\n缺点 1:无法用日期函数进行计算和比较 缺点 2:用字符串存储日期要占用更多的空间 同财务相关的金额类数据必须使用decimal类型 # 非精准浮点 :float,double 精准浮点 :decimal decimal 类型为精准浮点数,在计算时不会丢失精度。占用空间由定义的宽度决定,每 4 个字节可以存储 9 位数字,并且小数点要占用一个字节。并且,decimal 可用于存储比 bigint 更大的整型数据\n不过, 由于 decimal 需要额外的空间和计算开销,应该尽量只在需要对数据进行精确计算时才使用 decimal 。\n单表不要包含过多字段 # 如果一个表包含过多字段的话,可以考虑将其分解成多个表,必要时增加中间表进行关联。\n索引设计规范 # 限制每张表上的索引数量,建议单张表索引不超过5个 # 索引并不是越多越好!索引可以提高效率同样可以降低效率。\n索引可以增加查询效率,但同样也会降低插入和更新的效率,甚至有些情况下会降低查询效率。\n因为 MySQL 优化器在选择如何优化查询时,会根据统一信息,对每一个可以用到的索引来进行评估,以生成出一个最好的执行计划,如果同时有很多个索引都可以用于查询,就会增加 MySQL 优化器生成执行计划的时间,同样会降低查询性能。\n禁止使用全文索引 # 全文索引不适用于 OLTP 场景。\nOn-Line Transaction Processing联机事务处理过程(OLTP),也称为面向交易的处理过程\n禁止给表中的每一列都建立单独的索引 # 5.6 版本之前,一个 sql 只能使用到一个表中的一个索引,5.6 以后,虽然有了合并索引的优化方式,但是还是远远没有使用一个联合索引的查询方式好\n尽量使用联合索引\n每个InnoDB表必须有个主键 # InnoDB 是一种索引组织表:数据的存储的逻辑顺序和索引的顺序是相同的。每个表都可以有多个索引,但是表的存储顺序只能有一种。 InnoDB 是按照主键索引的顺序来组织表的 不要使用更新频繁的列作为主键,不适用(使用)多列主键(相当于联合索引) 不要使用 UUID,MD5,HASH,字符串列作为主键(无法保证数据的顺序增长) 主键建议使用自增 ID 值 常见索引列建议 # 出现在 SELECT、UPDATE、DELETE 语句的 WHERE 从句中的列\n包含在 ORDER BY、GROUP BY、DISTINCT 中的字段\n并不要将符合 1 和 2 中的字段的列都建立一个索引, 通常将 1、2 中的字段建立联合索引效果更好\n多表 join 的关联列\n如何选择索引列的顺序 # 建立索引的目的是:希望通过索引进行数据查找,减少随机 IO,增加查询性能 ,索引能过滤出越少的数据,则从磁盘中读入的数据也就越少。\n区分度最高的放在联合索引的最左侧(区分度=列中不同值的数量/列的总行数) 尽量把字段长度小的列放在联合索引的最左侧(因为字段长度越小,一页能存储的数据量越大,IO 性能也就越好) 使用最频繁的列放到联合索引的左侧(这样可以比较少的建立一些索引) 避免建立冗余索引和重复索引(增加了查询优化器生成执行计划的时间) # 重复索引示例:primary key(id)、index(id)、unique index(id) 冗余索引示例:index(a,b,c)、index(a,b)、index(a) 对于频繁的查询有优先考虑使用覆盖索引 # 覆盖索引:就是包含了所有查询字段 (where,select,order by,group by 包含的字段) 的索引\n覆盖索引的好处:\n避免 InnoDB 表进行索引的二次查询: InnoDB 是以聚集索引的顺序来存储的,对于 InnoDB 来说,二级索引在叶子节点中所保存的是行的主键信息,如果是用二级索引查询数据的话,在查找到相应的键值后,还要通过主键进行二次查询才能获取我们真实所需要的数据。而在覆盖索引中,二级索引的键值中可以获取所有的数据,避免了对主键的二次查询 ,减少了 IO 操作,提升了查询效率。 可以把随机 IO 变成顺序 IO 加快查询效率: 由于覆盖索引是按键值的顺序存储的,对于 IO 密集型的范围查找来说,对比随机从磁盘读取每一行的数据 IO 要少的多,因此利用覆盖索引在访问时也可以把磁盘的随机读取的 IO 转变成索引查找的顺序 IO。 索引SET规范 # 尽量避免使用外键约束\n不建议使用外键约束(foreign key),但一定要在表与表之间的关联键上建立索引 外键可用于保证数据的参照完整性,但建议在业务端实现 外键会影响父表和子表的写操作从而降低性能 数据库SQL开发规范 # 优化对性能影响较大的SQL语句 # 要找到最需要优化的 SQL 语句。要么是使用最频繁的语句,要么是优化后提高最明显的语句,可以通过查询 MySQL 的慢查询日志来发现需要进行优化的 SQL 语句;\n充分利用表上已经存在的索引 # 避免使用双%号的查询条件。如:a like '%123%',(如果无前置%,只有后置%,是可以用到列上的索引的)\n一个 SQL 只能利用到复合索引中的一列进行范围查询。如:有 a,b,c 列的联合索引,在查询条件中有 a 列的范围查询,则在 b,c 列上的索引将不会被用到。\nhttps://blog.csdn.net/qq_33589510/article/details/123038988\n(a=1 b=1 c=1) (a=1 b=2 c=1) (a=1 b=2 c=3)\n(a=2 b=2 c=3) (a=2 b=2 c=5) (a=2 b=5 c=1) (a=2 b=5 c=2)\n(a=3 b=0 c=1) (a=3 b=3 c=5) (a=3 b=8 c=6)\n假设有一条SQL为select a,b,c from table where a = 2 and b \u0026gt;1 and c = 2,那么索引c就用不到了,因为有可能b查找后c是无序的了\n在定义联合索引时,如果 a 列要用到范围查找的话,就要把 a 列放到联合索引的右侧,使用 left join 或 not exists 来优化 not in 操作,因为 not in 也通常会使用索引失效。\n这个的意思是,如果有两个列,b,a都是索引\nSELECT * FROM table WHERE a \u0026gt; 1 and b = 2;\n对于上面这句,如果建立(a,b),那么只有a会用得到。而如果建立(b,a),则都能用上 (如果没有b= 2,那么(b,a)索引就用不上了)\n禁止使用SELECT * 必须使用SELECT \u0026lt;字段列表\u0026gt; 查询 # SELECT * 消耗更多的 CPU 和 IO 以网络带宽资源 SELECT * 无法使用覆盖索引 SELECT \u0026lt;字段列表\u0026gt; 可减少表结构变更带来的影响 禁止使用不含字段列表的INSERT语句 # 如:\ninsert into t values (\u0026#39;a\u0026#39;,\u0026#39;b\u0026#39;,\u0026#39;c\u0026#39;); 应使用:\ninsert into t(c1,c2,c3) values (\u0026#39;a\u0026#39;,\u0026#39;b\u0026#39;,\u0026#39;c\u0026#39;); 建议使用预编译语句进行数据库操作 # (这里应该是针对jdbc,不是指mybatis的情况)\n例子:\nMySQL执行预编译分为如三步:\n执行预编译语句,例如:prepare myfun from \u0026lsquo;select * from t_book where bid=?\u0026rsquo;\n设置变量,例如:set @str=\u0026lsquo;b1\u0026rsquo;\n执行语句,例如:execute myfun using @str\n如果需要再次执行myfun,那么就不再需要第一步,即不需要再编译语句了:\n设置变量,例如:set @str=\u0026lsquo;b2\u0026rsquo; 执行语句,例如:execute myfun using @str 通过查看MySQL日志可以看到执行的过程:\n使用Statement执行预编译\n**使用Statement执行预编译就是把上面的SQL语句执行一次。 **\nConnection con = JdbcUtils.getConnection(); Statement stmt = con.createStatement(); stmt.executeUpdate(\u0026#34;prepare myfun from \u0026#39;select * from t_book where bid=?\u0026#39;\u0026#34;); stmt.executeUpdate(\u0026#34;set @str=\u0026#39;b1\u0026#39;\u0026#34;); ResultSet rs = stmt.executeQuery(\u0026#34;execute myfun using @str\u0026#34;); while(rs.next()) { System.out.print(rs.getString(1) + \u0026#34;, \u0026#34;); System.out.print(rs.getString(2) + \u0026#34;, \u0026#34;); System.out.print(rs.getString(3) + \u0026#34;, \u0026#34;); System.out.println(rs.getString(4)); } stmt.executeUpdate(\u0026#34;set @str=\u0026#39;b2\u0026#39;\u0026#34;); rs = stmt.executeQuery(\u0026#34;execute myfun using @str\u0026#34;); while(rs.next()) { System.out.print(rs.getString(1) + \u0026#34;, \u0026#34;); System.out.print(rs.getString(2) + \u0026#34;, \u0026#34;); System.out.print(rs.getString(3) + \u0026#34;, \u0026#34;); System.out.println(rs.getString(4)); } rs.close(); stmt.close(); con.close(); useServerPrepStmts参数\n默认使用PreparedStatement是不能执行预编译的,这需要在url中给出useServerPrepStmts=true参数(MySQL Server 4.1之前的版本是不支持预编译的,而Connector/J在5.0.5以后的版本,默认是没有开启预编译功能的)。\n例如:jdbc:mysql://localhost:3306/test?useServerPrepStmts=true\n预编译语句可以重复使用这些计划,减少 SQL 编译所需要的时间,还可以解决动态 SQL 所带来的 SQL 注入的问题。\n只传参数,比传递 SQL 语句更高效。\n相同语句可以一次解析,多次使用,提高处理效率。\n避免数据类型的隐式转换 # 隐式转换会导致索引失效如: 这里id应该不是字符型(但是这个好像是例外,如果字段是数字,而查询的是字符,索引还是有效的)\nselect name,phone from customer where id = \u0026#39;111\u0026#39;; 详细解读可以看: MySQL 中的隐式转换造成的索引失效 这篇文章\n避免使用子查询,可以把子查询优化为join操作 # 通常子查询在 in 子句中,且子查询中为简单 SQL(不包含 union、group by、order by、limit 从句) 时,才可以把子查询转化为关联查询进行优化。\n子查询性能差的原因: 子查询的结果集无法使用索引,通常子查询的结果集会被存储到临时表中,不论是内存临时表还是磁盘临时表都不会存在索引,所以查询性能会受到一定的影响。特别是对于返回结果集比较大的子查询,其对查询性能的影响也就越大。由于子查询会产生大量的临时表也没有索引,所以会消耗过多的 CPU 和 IO 资源,产生大量的慢查询。\n避免JOIN关联太多的表 # 对于 MySQL 来说,是存在关联缓存的,缓存的大小可以由 join_buffer_size 参数进行设置。\n在 MySQL 中,对于同一个 SQL 多关联(join)一个表,就会多分配一个关联缓存,如果在一个 SQL 中关联的表越多,所占用的内存也就越大。\n如果程序中大量的使用了多表关联的操作,同时 join_buffer_size 设置的也不合理的情况下,就容易造成服务器内存溢出的情况,就会影响到服务器数据库性能的稳定性。\n同时对于关联操作来说,会产生临时表操作,影响查询效率,MySQL 最多允许关联 61 个表,建议不超过 5 个。\n减少同数据库的交互次数 # 数据库更适合处理批量操作,合并多个相同的操作到一起,可以提高处理效率。\n对应同一列进行or判断时,使用in代替or # in 的值不要超过 500 个,in 操作可以更有效的利用索引,or 大多数情况下很少能利用到索引。\n禁止使用order by rand() 进行随机排序 # order by rand() 会把表中所有符合条件的数据装载到内存中,然后在内存中对所有数据根据随机生成的值进行排序,并且可能会对每一行都生成一个随机值,如果满足条件的数据集非常大,就会消耗大量的 CPU 和 IO 及内存资源。\n推荐在程序中获取一个随机值,然后从数据库中获取数据的方式。\nWHERE从句中禁止对列进行函数转换和计算 # 对列进行函数转换或计算时会导致无法使用索引\n不推荐:\nwhere date(create_time)=\u0026#39;20190101\u0026#39; 推荐:\nwhere create_time \u0026gt;= \u0026#39;20190101\u0026#39; and create_time \u0026lt; \u0026#39;20190102\u0026#39; 在明显不会有重复值时使用UNION ALL 而不是 UNION # UNION 会把两个结果集的所有数据放到临时表中后再进行去重操作 UNION ALL 不会再对结果集进行去重操作 拆分复杂的大SQL为多个小SQL # 大 SQL 逻辑上比较复杂,需要占用大量 CPU 进行计算的 SQL MySQL 中,一个 SQL 只能使用一个 CPU 进行计算 SQL 拆分后可以通过并行执行来提高处理效率 程序连接不同的数据库使用不同的账号,禁止跨库查询 # 为数据库迁移和分库分表留出余地 降低业务耦合度 避免权限过大而产生的安全风险 数据库操作行为规范 # 超 100 万行的批量写 (UPDATE,DELETE,INSERT) 操作,要分批多次进行操作 # 大批量操作可能会造成严重的主从延迟\n主从环境中,大批量操作可能会造成严重的主从延迟,大批量的写操作一般都需要执行一定长的时间,而只有当主库上执行完成后,才会在其他从库上执行,所以会造成主库与从库长时间的延迟情况\nbinlog 日志为 row 格式时会产生大量的日志\n大批量写操作会产生大量日志,特别是对于 row 格式二进制数据而言,由于在 row 格式中会记录每一行数据的修改,我们一次修改的数据越多,产生的日志量也就会越多,日志的传输和恢复所需要的时间也就越长,这也是造成主从延迟的一个原因\n避免产生大事务操作\n大批量修改数据,一定是在一个事务中进行的,这就会造成表中大批量数据进行锁定,从而导致大量的阻塞,阻塞会对 MySQL 的性能产生非常大的影响。\n特别是长时间的阻塞会占满所有数据库的可用连接,这会使生产环境中的其他应用无法连接到数据库,因此一定要注意大批量写操作要进行分批\n对于大表使用 pt-online-schema-change 修改表结构 # 避免大表修改产生的主从延迟 避免在对表字段进行修改时进行锁表 对大表数据结构的修改一定要谨慎,会造成严重的锁表操作,尤其是生产环境,是不能容忍的。\npt-online-schema-change 它会首先建立一个与原表结构相同的新表,并且在新表上进行表结构的修改,然后再把原表中的数据复制到新表中,并在原表中增加一些触发器。把原表中新增的数据也复制到新表中,在行所有数据复制完成之后,把新表命名成原表,并把原来的表删除掉。\n把原来一个 DDL 操作,分解成多个小的批次进行。\n禁止为程序使用的账号赋予 super 权限 # 当达到最大连接数限制时,还运行 1 个有 super 权限的用户连接 super 权限只能留给 DBA 处理问题的账号使用 对于程序连接数据库账号,遵循权限最小原则 # 程序使用数据库账号只能在一个 DB 下使用,不准跨库 程序使用的账号原则上不准有 drop 权限 "},{"id":296,"href":"/zh/docs/technology/Review/java_guide/database/MySQL/ly0610lymysql-questions-01/","title":"MySQL常见面试题总结","section":"MySQL","content":" 转载自https://github.com/Snailclimb/JavaGuide(添加小部分笔记)感谢作者!====\nMySQL基础 # 关系型数据库介绍 # 关系型数据库,建立在关系模型的基础上的数据库。表明数据库中所存储的数据之间的联系(一对一、一对多、多对多) 关系型数据库中,我们的数据都被存放在各种表中(比如用户表),表中的每一行存放着一条数据(比如一个用户的信息) 大部分关系型数据库都使用SQL来操作数据库中的数据,并且大部分关系型数据库都支持事务的四大特性(ACID) 常见的关系型数据库\nMySQL、PostgreSQL、Oracle、SQL Server、SQLite(微信本地的聊天记录的存储就是用的 SQLite) \u0026hellip;\u0026hellip;\nMySQL介绍 # MySQL是一种关系型数据库,主要用于持久化存储我们系统中的一些数据比如用户信息\n由于 MySQL 是开源免费并且比较成熟的数据库,因此,MySQL 被大量使用在各种系统中。任何人都可以在 GPL(General Public License 通用性公开许可证) 的许可下下载并根据个性化的需要对其进行修改。MySQL 的默认端口号是3306。\nMySQL基础架构 # MySQL的一个简要机构图,客户端的一条SQL语句在MySQL内部如何执行 MySQL主要由几部分构成 连接器:身份认证和权限相关(登录MySQL的时候) 查询缓存:执行查询语句的时候,会先查询缓存(MySQL8.0版本后移除,因为这个功能不太实用) 分析器:没有命中缓存的话,SQL语句就会经过分析器,分析器说白了就是要先看你的SQL语句要干嘛,再检查你的SQL语句语法是否正确 优化器:按照MySQL认为最优的方案去执行 执行器:执行语句,然后从存储引擎返回数据。执行语句之前会先判断是否有权限,如果没有权限,就会报错 插件式存储引擎:主要负责数据的存储和读取,采用的是插件式架构,支持InnoDB、MyISAM、Memory等多种存储引擎 MySQL存储引擎 # MySQL核心在于存储引擎\nMySQL支持哪些存储引擎?默认使用哪个? # MySQL支持多种存储引擎,可以通过show engines命令来查看MySQL支持的所有存储引擎 默认存储引擎为InnoDB,并且,所有存储引擎中只有InnoDB是事务性存储引擎,也就是说只有InnoDB支持事务\n这里使用MySQL 8.x MySQL 5.5.5之前,MyISAM是MySQL的默认存储引擎;5.5.5之后,InnoDB是MySQL的默认存储引擎,可以通过select version()命令查看你的MySQL版本\nmysql\u0026gt; select version(); +-----------+ | version() | +-----------+ | 8.0.27 | +-----------+ 1 row in set (0.00 sec) 使用show variables like %storage_engine%命令直接查看MySQL当前默认的存储引擎 如果只想查看数据库中某个表使用的存储引擎的话,可以使用show table status from db_name where name = 'table_name'命令\n如果你想要深入了解每个存储引擎以及它们之间的区别,推荐你去阅读以下 MySQL 官方文档对应的介绍(面试不会问这么细,了解即可):\nInnoDB 存储引擎详细介绍:https://dev.mysql.com/doc/refman/8.0/en/innodb-storage-engine.html 。 其他存储引擎详细介绍:https://dev.mysql.com/doc/refman/8.0/en/storage-engines.html 。 MySQL存储引擎架构了解吗? # MySQL 存储引擎采用的是插件式架构,支持多种存储引擎,我们甚至可以为不同的数据库表设置不同的存储引擎以适应不同场景的需要。存储引擎是基于表的,而不是数据库 可以根据 MySQL 定义的存储引擎实现标准接口来编写一个属于自己的存储引擎。这些非官方提供的存储引擎可以称为第三方存储引擎,区别于官方存储引擎 像目前最常用的 InnoDB 其实刚开始就是一个第三方存储引擎,后面由于过于优秀,其被 Oracle 直接收购了。\nMySQL 官方文档也有介绍到如何编写一个自定义存储引擎,地址:https://dev.mysql.com/doc/internals/en/custom-engine.html\nMyISAM和InnoDB的区别是什么? # ISAM全称:Indexed Sequential Access Method(索引 顺序 访问 方法) 虽然,MyISAM 的性能还行,各种特性也还不错(比如全文索引、压缩、空间函数等)。但是,MyISAM 不支持事务和行级锁,而且最大的缺陷就是崩溃后无法安全恢复 是否支持行级锁 MyISAM 只有表级锁(table-level locking),而 InnoDB 支持行级锁(row-level locking)和表级锁,默认为行级锁。\nMyISAM 一锁就是锁住了整张表,这在并发写的情况下是多么滴憨憨啊!这也是为什么 InnoDB 在并发写的时候,性能更牛皮了!\n是否支持事务\nMyISAM不支持事务,InnoDB提供事务支持\nInnoDB实现了SQL标准,定义了四个隔离级别,具有提交(commit)和回滚(rollback)事务的能力 InnoDB默认使用的REPEATABLE-READ(可重复读)隔离级别是可以解决幻读问题发生的(部分幻读),基于MVCC和Next-Key Lock(间隙锁) 详细可以查看MySQL 事务隔离级别详解\n是否支持外键\nMyISAM不支持,而InnoDB支持\n外键对于维护数据一致性非常有帮助,但是对性能有一定的损耗。因此,通常情况下,我们是不建议在实际生产项目中使用外键的,在业务代码中进行约束即可!\n阿里的《Java 开发手册》也是明确规定禁止使用外键的。\n不过,在代码中进行约束的话,对程序员的能力要求更高,具体是否要采用外键还是要根据你的项目实际情况而定\n一般我们也是不建议在数据库层面使用外键的,应用层面可以解决。不过,这样会对数据的一致性造成威胁。具体要不要使用外键还是要根据你的项目来决定 是否支持数据库异常崩溃后的安全恢复 MyISAM 不支持,而 InnoDB 支持。\n使用 InnoDB 的数据库在异常崩溃后,数据库重新启动的时候会保证数据库恢复到崩溃前的状态。这个恢复的过程依赖于 redo log 是否支持MVCC MyISAM 不支持,而 InnoDB 支持。\nMyISAM 连行级锁都不支持。MVCC 可以看作是行级锁的一个升级,可以有效减少加锁操作,提高性能。 索引实现不一样\n虽然 MyISAM 引擎和 InnoDB 引擎都是使用 B+Tree 作为索引结构,但是两者的实现方式不太一样。 InnoDB 引擎中,其数据文件本身就是索引文件。而 MyISAM中,索引文件和数据文件是分离的 InnoDB引擎中,表数据文件本身就是按 B+Tree 组织的一个索引结构,树的叶节点 data 域保存了完整的数据记录。 详细区别,推荐 : MySQL 索引详解\nMyISAM和InnoDB 如何选择 # 大多数时候我们使用的都是 InnoDB 存储引擎,在某些读密集的情况下,使用 MyISAM 也是合适的。不过,前提是你的项目不介意 MyISAM 不支持事务、崩溃恢复等缺点(可是~我们一般都会介意啊!)\n《MySQL 高性能》上面有一句话这样写到:\n不要轻易相信“MyISAM 比 InnoDB 快”之类的经验之谈,这个结论往往不是绝对的。在很多我们已知场景中,InnoDB 的速度都可以让 MyISAM 望尘莫及,尤其是用到了聚簇索引,或者需要访问的数据都可以放入内存的应用。\n一般情况下我们选择 InnoDB 都是没有问题的,但是某些情况下你并不在乎可扩展能力和并发能力,也不需要事务支持,也不在乎崩溃后的安全恢复问题的话,选择 MyISAM 也是一个不错的选择。但是一般情况下,我们都是需要考虑到这些问题的。\n对于咱们日常开发的业务系统来说,你几乎找不到什么理由再使用 MyISAM 作为自己的 MySQL 数据库的存储引擎\nMySQL 索引 # MySQL 索引相关的问题比较多,对于面试和工作都比较重要,于是,我单独抽了一篇文章专门来总结 MySQL 索引相关的知识点和问题: MySQL 索引详解]\nMySQL查询缓存 # 执行查询语句的时候,会先查询缓存。不过**,MySQL 8.0 版本后移除**,因为这个功能不太实用\nmy.cnf 加入以下配置,重启 MySQL 开启查询缓存\nquery_cache_type=1 query_cache_size=600000 执行以下命令也可以开启查询缓存\nset global query_cache_type=1; set global query_cache_size=600000; 开启查询缓存后在同样的查询条件以及数据情况下,会直接在缓存中返回结果:\n查询条件包括查询本身、当前要查询的数据库、客户端协议版本号等一些可能影响结果的信息\n查询缓存不命中的情况:\n任何两个查询在任何字符上的不同都会导致缓存不命中 如果查询中包含任何用户自定义函数、存储函数、用户变量、临时表、MySQL 库中的系统表,其查询结果也不会被缓存 缓存建立之后,MySQL 的查询缓存系统会跟踪查询中涉及的每张表,如果这些表(数据或结构)发生变化,那么和这张表相关的所有缓存数据都将失效 缓存虽然能够提升数据库的查询性能,但是缓存同时也带来了额外的开销,每次查询后都要做一次缓存操作,失效后还要销毁。 因此,开启查询缓存要谨慎,尤其对于写密集的应用来说更是如此。如果开启,要注意合理控制缓存空间大小,一般来说其大小设置为几十 MB 比较合适。此外,**还可以通过 sql_cache 和 sql_no_cache 来控制某个查询语句是否需要缓存\nselect sql_no_cache count(*) from usr; MySQL事务 # 何谓事务 # 我们设想一个场景,这个场景中我们需要插入多条相关联的数据到数据库,不幸的是,这个过程可能会遇到下面这些问题:\n数据库中途突然因为某些原因挂掉了。 客户端突然因为网络原因连接不上数据库了。 并发访问数据库时,多个线程同时写入数据库,覆盖了彼此的更改。 \u0026hellip;\u0026hellip; 上面的任何一个问题都可能会导致数据的不一致性。为了保证数据的一致性,系统必须能够处理这些问题。事务就是我们抽象出来简化这些问题的首选机制。事务的概念起源于数据库,目前,已经成为一个比较广泛的概念\n事务是逻辑上的一组操作,要么都执行,要么都不执行\n最经典的就是转账,假如小明要给小红转账1000元,这个转账涉及到两个关键操作,这两个操作必须都成功或者都失败\n将小明的余额减少1000元 将小红的余额增加1000元 事务会把两个操作看成逻辑上的一个整体,这个整体包含的操作要么都成功,要么都失败。这样就不会出现小明余额减少而小红余额却没有增加的情况\n何谓数据库事务 # 多数情况下,我们谈论事务的时候,如果没有特指分布式事务,往往指的是数据库事务\n数据库事务在日常开发中接触最多,如果项目属于单体架构,接触的往往就是数据库事务\n数据库事务的作用\n可以保证多个对数据库的操作(也就是SQL语句)构成一个逻辑上的整体,构成这个逻辑上整体的这些数据库操作遵循:要么全部执行成功,要么全部不执行\n# 开启一个事务 START TRANSACTION; # 多条 SQL 语句 SQL1,SQL2... ## 提交事务 COMMIT; 关系型数据库(比如MySQL、SQLServer、Oracle等)事务都有ACID特性\n原子性(Atomicity) : 事务是最小的执行单位,不允许分割。事务的原子性确保动作要么全部完成,要么完全不起作用; 一致性(Consistency): 执行事务前后,数据保持一致,例如转账业务中,无论事务是否成功,转账者和收款人的总额应该是不变的;(其实一致性是结果) 隔离性(Isolation): 并发访问数据库时,一个用户的事务不被其他事务所干扰,各并发事务之间数据库是独立的; 持久性(Durability): 一个事务被提交之后。它对数据库中数据的改变是持久的,即使数据库发生故障也不应该对其有任何影响。 只有保证了事务的持久性、原子性、隔离性之后,一致性才能得到保障。也就是说 A、I、D 是手段,C 是目的! 想必大家也和我一样,被 ACID 这个概念被误导了很久! 我也是看周志明老师的公开课 《周志明的软件架构课》open in new window才搞清楚的(多看好书!!)\n另外,DDIA 也就是 《Designing Data-Intensive Application(数据密集型应用系统设计)》open in new window 的作者在他的这本书中如是说:\nAtomicity, isolation, and durability are properties of the database, whereas consis‐ tency (in the ACID sense) is a property of the application. The application may rely on the database’s atomicity and isolation properties in order to achieve consistency, but it’s not up to the database alone.\n翻译过来的意思是:原子性,隔离性和持久性是数据库的属性,而一致性(在 ACID 意义上)是应用程序的属性。应用可能依赖数据库的原子性和隔离属性来实现一致性,但这并不仅取决于数据库。因此,字母 C 不属于 ACID 《Designing Data-Intensive Application(数据密集型应用系统设计)》这本书强推一波,值得读很多遍!豆瓣有接近 90% 的人看了这本书之后给了五星好评。另外,中文翻译版本已经在 Github 开源,地址: https://github.com/Vonng/ddiaopen in new window\n并发事务带来了哪些问题 # 典型应用程序中,多个事务并发运行,经常会操作相同数据来完成各自任务(多个用户对统一数据进行操作)。并发虽然是必须的,但是会导致一下的问题\n脏读(Dirty read) **\n一个事务读取数据并且对数据进行了修改,这个修改对其他事务来说是可见的(其实就是读未提交),即使当前事务没有提交。这时另外一个事务读取了这个还未提交的数据,但第一个事务突然回滚,导致数据并没有被提交到数据库,那第二个事务读取到的就是脏数据**,这也就是脏读的由来。\n例如:事务 1 读取某表中的数据 A=20,事务 1 修改 A=A-1,事务 2 读取到 A = 19,事务 1 回滚导致对 A 的修改并为提交到数据库, A 的值还是 20\n丢失修改(Lost to modify) 在一个事务读取一个数据时,另外一个事务也访问了该数据,那么在第一个事务中修改了这个数据后,第二个事务也修改了这个数据。这样第一个事务内的修改结果就被丢失,因此称为丢失修改。\n例如:事务 1 读取某表中的数据 A=20,事务 2 也读取 A=20,事务 1 先修改 A=A-1,事务 2 后来也修改 A=A-1,最终结果 A=19,事务 1 的修改被丢失。 (这里例子举得不好,用事务2进行了A = A - 2 操作会比较明显) 不可重复读(Unrepeatable read)\n指在一个事务内多次读同一数据。在这个事务还没有结束时,另一个事务也访问该数据。那么,在第一个事务中的两次读数据之间,由于第二个事务的修改导致第一个事务两次读取的数据可能不太一样。这就发生了在一个事务内两次读到的数据是不一样的情况,因此称为不可重复读。\n例如:事务 1 读取某表中的数据 A=20,事务 2 也读取 A=20,事务 1 修改 A=A-1,事务 2 再次读取 A =19,此时读取的结果和第一次读取的结果不同。\n幻读\n幻读与不可重复读类似。它发生在一个事务读取了几行数据,接着另一个并发事务插入了一些数据时。在随后的查询中,第一个事务就会发现多了一些原本不存在的记录,就好像发生了幻觉一样,所以称为幻读。\n例如:事务 2 读取某个范围的数据,事务 1 在这个范围插入了新的数据,事务 1 再次读取这个范围的数据发现相比于第一次读取的结果多了新的数据。\n不可重复读和幻读有什么区别 # 不可重复读的重点是内容修改或者记录减少。比如多次读取一条记录发现其中某些记录的值被修改;\n幻读的重点在于记录新增比如多次执行同一条查询语句(DQL)时,发现查到的记录增加了。\n幻读其实可以看作是不可重复读的一种特殊情况,单独把区分幻读的原因主要是解决幻读和不可重复读的方案不一样。\n举个例子:执行 delete 和 update 操作的时候,可以直接对记录加锁,保证事务安全。而执行 insert 操作的时候,由于记录锁(Record Lock)只能锁住已经存在的记录,为了避免插入新记录,需要依赖间隙锁(Gap Lock)。也就是说执行 insert 操作的时候需要依赖 Next-Key Lock(Record Lock+Gap Lock) 进行加锁来保证不出现幻读。 (这里说的是完全解决幻读,其实也可以依靠MVCC部分解决幻读) 使用MVCC机制(只在事务第一次select的时候生成ReadView解决不可重复读的问题) SQL标准定义了哪些事务隔离级别 # SQL标准定义了**四个隔离级别 **\nREAD-UNCOMMITTED(读取未提交) : 最低的隔离级别,允许读取尚未提交的数据变更,可能会导致脏读、幻读或不可重复读。 READ-COMMITTED(读取已提交) : 允许读取并发事务已经提交的数据,可以阻止脏读,但是幻读或不可重复读仍有可能发生。 REPEATABLE-READ(可重复读) : 对同一字段的多次读取结果都是一致的,除非数据是被本身事务自己所修改,可以阻止脏读和不可重复读,但幻读仍有可能发生。 SERIALIZABLE(可串行化) : 最高的隔离级别,完全服从 ACID 的隔离级别。所有的事务依次逐个执行,这样事务之间就完全不可能产生干扰,也就是说,该级别可以防止脏读、不可重复读以及幻读。 隔离级别 脏读 不可重复读 幻读 READ-UNCOMMITTED √ √ √ READ-COMMITTED × √ √ REPEATABLE-READ × × √ SERIALIZABLE × × × MySQL的隔离级别是基于锁实现的吗 # MySQL 的隔离级别基于锁和 MVCC 机制共同实现的。\nSERIALIZABLE 隔离级别,是通过锁来实现的。除了 SERIALIZABLE 隔离级别,其他的隔离级别都是基于 MVCC 实现。 不过, SERIALIZABLE 之外的其他隔离级别可能也需要用到锁机制,就比如 REPEATABLE-READ 在当前读情况下需要使用加锁读来保证不会出现幻读(这就是MVCC不能解决幻读的例外之一)。 上述总结 # MySQL的默认隔离级别是什么 # MySQL InnoDB 存储引擎的默认支持的隔离级别是 REPEATABLE-READ(可重读)。我们可以通过**SELECT @@tx_isolation;**命令来查看,MySQL 8.0 该命令改为SELECT @@transaction_isolation;\nmysql\u0026gt; SELECT @@tx_isolation; +-----------------+ | @@tx_isolation | +-----------------+ | REPEATABLE-READ | +-----------------+ ------ 关于 MySQL 事务隔离级别的详细介绍,可以看看我写的这篇文章: MySQL 事务隔离级别详解\nMySQL锁 # 表级锁和行级锁了解吗?有什么区别 # MyISAM 仅仅支持表级锁(table-level locking),一锁就锁整张表,这在并发写的情况下性非常差。 InnoDB 不光支持表级锁(table-level locking),还支持行级锁(row-level locking),默认为行级锁。行级锁的粒度更小,仅对相关的记录上锁即可(对一行或者多行记录加锁),所以对于并发写入操作来说, InnoDB 的性能更高。 表级锁和行级锁对比 :\n表级锁: MySQL 中锁定粒度最大的一种锁(全局锁除外),是针对非索引字段加的锁,对当前操作的整张表加锁,实现简单,资源消耗也比较少,加锁快,不会出现死锁。其锁定粒度最大,触发锁冲突的概率最高,并发度最低,MyISAM 和 InnoDB 引擎都支持表级锁。\n行级锁: MySQL 中锁定粒度最小的一种锁,是针对索引字段加的锁,只针对当前操作的行记录进行加锁。 行级锁能大大减少数据库操作的冲突。其加锁粒度最小,并发度高,但加锁的开销也最大,加锁慢,会出现死锁。\n行级锁的使用有什么注意事项 # InnoDB 的行锁是针对索引字段加的锁,表级锁是针对非索引字段加的锁。\n当我们执行 UPDATE、DELETE 语句时,如果 WHERE条件中字段没有命中唯一索引或者索引失效的话,就会导致扫描全表对表中的所有行记录进行加锁。这个在我们日常工作开发中经常会遇到,一定要多多注意!!!\n不过,很多时候即使用了索引也有可能会走全表扫描,这是因为 MySQL 优化器的原因。\n共享锁和排他锁 # 不论是表级锁还是行级锁,都存在**共享锁(Share Lock,S 锁)和排他锁(Exclusive Lock,X 锁)**这两类\n共享锁(S 锁) :又称读锁,事务在读取记录的时候获取共享锁,允许多个事务同时获取(锁兼容)。 排他锁(X 锁) :又称写锁/独占锁,事务在修改记录的时候获取排他锁,不允许多个事务同时获取。如果一个记录已经被加了排他锁,那其他事务不能再对这条事务加任何类型的锁**(锁不兼容)**。 排他锁与任何的锁都不兼容,共享锁仅和共享锁兼容。\nS 锁 X 锁 S 锁 不冲突 冲突 X 锁 冲突 冲突 由于 MVCC 的存在,对于一般的 SELECT 语句,InnoDB 不会加任何锁。不过, 你可以通过以下语句显式加共享锁或排他锁:\n# 共享锁 SELECT ... LOCK IN SHARE MODE; # 排他锁 SELECT ... FOR UPDATE; 意向锁有什么作用 # ★★ 重点 :如果需要用到表锁的话,如何判断表中的记录没有行锁呢?一行一行遍历肯定是不行,性能太差。我们需要用到一个叫做意向锁的东东来快速判断是否可以对某个表使用表锁。\n意向锁是表级锁(这句话很重要,意向锁是描述某个表的某个属性(这个表是否有记录加了共享锁/或者排他锁)),共有两种:\n意向共享锁(Intention Shared Lock,IS 锁):事务有意向对表中的某些记录加共享锁(S 锁),加共享锁前必须先取得该表的 IS 锁。\n意向排他锁(Intention Exclusive Lock,IX 锁):事务有意向对表中的某些记录加排他锁(X 锁),加排他锁之前必须先取得该表的 IX 锁。\n意向锁是有数据引擎自己维护的,用户无法手动操作意向锁,在为数据行加共享 / 排他锁之前,InooDB 会先获取(如果获取到了,其实就是“加了锁”)该数据行所在在数据表的对应意向锁。\n意向锁之间是互相兼容的 :\n理由很简单,表里某一条记录加了排他锁(即这个表加了意向排他锁),不代表不能操作其他记录\nIS 锁 IX 锁 IS 锁 兼容 兼容 IX 锁 兼容 兼容 意向锁和共享锁和排它锁互斥(这里指的是表级别的共享锁和排他锁,意向锁不会与行级的共享锁和排他锁互斥,★★括号里这句话极其重要,要不然就看不懂下面的表了)。\nIS 锁 IX 锁 S 锁 兼容 互斥 X 锁 互斥 互斥 《MySQL 技术内幕 InnoDB 存储引擎》这本书对应的描述应该是笔误了。\nInnoDB 有哪几类行锁 # MySQL InnoDB 支持三种行锁定方式:\n记录锁(Record Lock) :也被称为记录锁,属于单个行记录上的锁。 间隙锁(Gap Lock) :锁定一个范围,不包括记录本身。 临键锁(Next-key Lock) :Record Lock+Gap Lock,锁定一个范围,包含记录本身。记录锁只能锁住已经存在的记录,为了避免插入新记录,需要依赖间隙锁。 InnoDB 的默认隔离级别 RR(可重读)是可以解决幻读问题发生的,主要有下面两种情况:\n快照读(一致性非锁定读) :由 MVCC 机制来保证不出现幻读。 当前读 (一致性锁定读): 使用 Next-Key Lock 进行加锁来保证不出现幻读。 当前读和快照读有什么区别 # 快照读(一致性非锁定读)就是单纯的 SELECT 语句,但不包括下面这两类 SELECT 语句:\nSELECT ... FOR UPDATE SELECT ... LOCK IN SHARE MODE 快照即记录的历史版本,每行记录可能存在多个历史版本(多版本技术)。\n快照读的情况下,如果读取的记录正在执行 UPDATE/DELETE 操作,读取操作不会因此去等待记录上 X 锁的释放,而是会去读取行的一个快照。\n只有在事务隔离级别 RC(读取已提交,ReadCommit) 和 **RR(可重读,RepeatableCommit)**下,InnoDB 才会使用一致性非锁定读:\n在 RC 级别下,对于快照数据,一致性非锁定读总是读取被锁定行的最新一份(可见)快照数据。 在 RR 级别下,对于快照数据,一致性非锁定读总是读取本事务开始时的行数据版本。 快照读比较适合对于数据一致性要求不是特别高且追求极致性能的业务场景。\n当前读 (一致性锁定读)就是给行记录加 X 锁或 S 锁。(使用当前读的话在RR级别下就无法解决幻读)\n当前读的一些常见 SQL 语句类型如下:\n# 对读的记录加一个X锁 SELECT...FOR UPDATE # 对读的记录加一个S锁 SELECT...LOCK IN SHARE MODE # 对修改的记录加一个X锁 INSERT... UPDATE... DELETE... MySQL 性能优化 # 关于 MySQL 性能优化的建议总结,请看这篇文章: MySQL 高性能优化规范建议总结\n能用MySQL直接存储文件(比如图片)吗 # 可以是可以,直接存储文件对应的二进制数据即可。不过,还是建议不要在数据库中存储文件,会严重影响数据库性能,消耗过多存储空间。\n数据库只存储文件地址信息,文件由文件存储服务负责存储。\n可以选择使用云服务厂商提供的开箱即用的文件存储服务,成熟稳定,价格也比较低。 也可以选择自建文件存储服务,实现起来也不难,基于 FastDFS、MinIO(推荐) 等开源项目就可以实现分布式文件服务。\n相关阅读: Spring Boot 整合 MinIO 实现分布式文件服务\nMySQL如何存储IP 地址 # 可以将 IP 地址转换成整形数据存储,性能更好,占用空间也更小。\nMySQL 提供了两个方法来处理 ip 地址\nINET_ATON() : 把 ip 转为无符号整型 (4-8 位) INET_NTOA() :把整型的 ip 转为地址 插入数据前,先用 INET_ATON() 把 ip 地址转为整型,显示数据时,使用 INET_NTOA() 把整型的 ip 地址转为地址显示即可\n有哪些常见的SQL优化手段吗 # 《Java 面试指北》open in new window 的「技术面试题篇」有一篇文章详细介绍了常见的 SQL 优化手段,非常全面,清晰易懂!\n"},{"id":297,"href":"/zh/docs/technology/Review/java_guide/database/MySQL/ly0609lyindex-invalidation-caused-by-implicit-conversion/","title":"MySQL中的隐式转换造成的索引失效","section":"MySQL","content":" 转载自https://github.com/Snailclimb/JavaGuide(添加小部分笔记)感谢作者!\n本篇文章基于MySQL 5.7.26,原文:https://www.guitu18.com/post/2019/11/24/61.html\n前言 # 关于数据库优化,最常见的莫过于索引失效,数据量多的时候比较明显,处理不及时会造成雪球效应,最终导致数据库卡死甚至瘫痪。 这里说的是隐式转换造成的索引失效 数据准备 # -- 创建测试数据表 DROP TABLE IF EXISTS test1; CREATE TABLE `test1` ( `id` int(11) NOT NULL, `num1` int(11) NOT NULL DEFAULT \u0026#39;0\u0026#39;, `num2` varchar(11) NOT NULL DEFAULT \u0026#39;\u0026#39;, `type1` int(4) NOT NULL DEFAULT \u0026#39;0\u0026#39;, `type2` int(4) NOT NULL DEFAULT \u0026#39;0\u0026#39;, `str1` varchar(100) NOT NULL DEFAULT \u0026#39;\u0026#39;, `str2` varchar(100) DEFAULT NULL, PRIMARY KEY (`id`), KEY `num1` (`num1`), KEY `num2` (`num2`), KEY `type1` (`type1`), KEY `str1` (`str1`), KEY `str2` (`str2`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8; -- 创建存储过程 DROP PROCEDURE IF EXISTS pre_test1; DELIMITER // CREATE PROCEDURE `pre_test1`() BEGIN DECLARE i INT DEFAULT 0; SET autocommit = 0; WHILE i \u0026lt; 10000000 DO SET i = i + 1; SET @str1 = SUBSTRING(MD5(RAND()),1,20); -- 每100条数据str2产生一个null值 IF i % 100 = 0 THEN SET @str2 = NULL; ELSE SET @str2 = @str1; END IF; INSERT INTO test1 (`id`, `num1`, `num2`, `type1`, `type2`, `str1`, `str2`) VALUES (CONCAT(\u0026#39;\u0026#39;, i), CONCAT(\u0026#39;\u0026#39;, i), CONCAT(\u0026#39;\u0026#39;, i), i%5, i%5, @str1, @str2); -- 事务优化,每一万条数据提交一次事务 IF i % 10000 = 0 THEN COMMIT; END IF; END WHILE; END; // DELIMITER ; -- 执行存储过程 CALL pre_test1(); 其中,七个字段,首先使用存储过程生成 1000 万条测试数据, 测试表一共建立了 7 个字段(包括主键),num1和num2保存的是和ID一样的顺序数字,其中num2是字符串类型。 type1和type2保存的都是主键对 5 的取模,目的是模拟实际应用中常用类似 type 类型的数据,但是**type2是没有建立索引的。 str1和str2都是保存了一个 20 位长度的随机字符串,str1不能为NULL,str2允许为NULL,相应的生成测试数据的时候我也会在str2字段生产少量NULL值**(每 100 条数据产生一个NULL值)。\n数据量比较大,还涉及使用MD5生成随机字符串,所以速度有点慢,稍安勿躁,耐心等待即可。\n1000 万条数据,我用了 33 分钟才跑完(实际时间跟你电脑硬件配置有关)。这里贴几条生成的数据,大致长这样。 数据如下所示:\nSQL测试 # 注:num1是int类型,num2是varchar类型。\n1: SELECT * FROM `test1` WHERE num1 = 10000; 2: SELECT * FROM `test1` WHERE num1 = \u0026#39;10000\u0026#39;; 3: SELECT * FROM `test1` WHERE num2 = 10000; 4: SELECT * FROM `test1` WHERE num2 = \u0026#39;10000\u0026#39;; 这四条 SQL 都是有针对性写的,12 查询的字段是 int 类型,34 查询的字段是varchar类型。12 或 34 查询的字段虽然都相同,但是一个条件是数字,一个条件是用引号引起来的字符串。这样做有什么区别呢?先不看下边的测试结果你能猜出这四条 SQL 的效率顺序吗?\n经测试这四条 SQL 最后的执行结果却相差很大,其中 124 三条 SQL 基本都是瞬间出结果,大概在 0.0010.005 秒,在千万级的数据量下这样的结果可以判定这三条 SQL 性能基本没差别了。但是第三条 SQL,多次测试耗时基本在 4.54.8 秒之间\n也就是说 左int 右字符不影响效率;而左字符右int则影响效率,后面会解释\n下面看1234的执行计划\n可以看到,124 三条 SQL 都能使用到索引,连接类型都为ref,扫描行数都为 1,所以效率非常高。再看看第三条 SQL,没有用上索引,所以为全表扫描,rows直接到达 1000 万了,所以性能差别才那么大\n34 两条 SQL 查询的字段num2是varchar类型的,查询条件等号右边加引号的第 4 条 SQL 是用到索引的,那么是查询的数据类型和字段数据类型不一致造成的吗?如果是这样那 12 两条 SQL 查询的字段num1是int类型,但是第 2 条 SQL 查询条件右边加了引号为什么还能用上索引呢。 官方文档: 12.2 Type Conversion in Expression Evaluationopen in new window\n当操作符与不同类型的操作数一起使用时,会发生类型转换以使操作数兼容。某些转换是隐式发生的。例如,MySQL 会根据需要自动将字符串转换为数字,反之亦然。以下规则描述了比较操作的转换方式:\n两个参数至少有一个是NULL时,比较的结果也是NULL,特殊的情况是使用\u0026lt;=\u0026gt;对两个NULL做比较时会返回1,这两种情况都不需要做类型转换 两个参数都是字符串,会按照字符串来比较,不做类型转换 两个参数都是整数,按照整数来比较,不做类型转换 十六进制的值和非数字做比较时,会被当做二进制串 有一个参数是TIMESTAMP或DATETIME,并且另外一个参数是常量,常量会被转换为timestamp 有一个参数是decimal类型,如果另外一个参数是decimal或者整数,会将整数转换为decimal后进行比较,如果另外一个参数是浮点数,则会把decimal转换为浮点数进行比较 所有其他情况下,两个参数都会被转换为浮点数再进行比较 根据官方文档的描述,我们的第 23 两条 SQL 都发生了隐式转换,第 2 条 SQL 的查询条件num1 = '10000',左边是int类型右边是字符串,第 3 条 SQL 相反,那么根据官方转换规则第 7 条,左右两边都会转换为浮点数再进行比较\n★★\n先看第 2 条 SQL:SELECT * FROMtest1WHERE num1 = '10000';左边为 int 类型10000,转换为浮点数还是10000,右边字符串类型'10000',转换为浮点数也是10000。两边的转换结果都是唯一确定的,所以不影响使用索引\n也就是说,这个sql是要找到索引num1的值为浮点数10000的行,所以能用上索引\n第 3 条 SQL:SELECT * FROMtest1WHERE num2 = 10000;左边是字符串类型'10000',转浮点数为 10000 是唯一的,右边int类型10000转换结果也是唯一的。但是,因为左边是检索条件,'10000'转到10000虽然是唯一,但是其他字符串也可以转换为10000,比如'10000a','010000','10000'等等都能转为浮点数10000,这样的情况下,是不能用到索引的。\n也就是说,如果我把10000当作索引去查,是不行的。因为正确结果应该是把 \u0026lsquo;10000a\u0026rsquo;,\u0026lsquo;10000-\u0026lsquo;这种都查出来。而如果使用索引,也只能查出'10000\u0026rsquo;,结果不对。所以肯定会用上全表扫描\n也就是说,这个sql是要找到索引num2的值(字符串)转换后是'10000\u0026rsquo;的行,因为10000a,10000b转换后也都是10000,所以用不上索引\n对第二点的后半部分再做解释\n关于这个隐式转换我们可以通过查询测试验证一下,先插入几条数据,其中num2='10000a'、'010000'和'10000':\nINSERT INTO `test1` (`id`, `num1`, `num2`, `type1`, `type2`, `str1`, `str2`) VALUES (\u0026#39;10000001\u0026#39;, \u0026#39;10000\u0026#39;, \u0026#39;10000a\u0026#39;, \u0026#39;0\u0026#39;, \u0026#39;0\u0026#39;, \u0026#39;2df3d9465ty2e4hd523\u0026#39;, \u0026#39;2df3d9465ty2e4hd523\u0026#39;); INSERT INTO `test1` (`id`, `num1`, `num2`, `type1`, `type2`, `str1`, `str2`) VALUES (\u0026#39;10000002\u0026#39;, \u0026#39;10000\u0026#39;, \u0026#39;010000\u0026#39;, \u0026#39;0\u0026#39;, \u0026#39;0\u0026#39;, \u0026#39;2df3d9465ty2e4hd523\u0026#39;, \u0026#39;2df3d9465ty2e4hd523\u0026#39;); INSERT INTO `test1` (`id`, `num1`, `num2`, `type1`, `type2`, `str1`, `str2`) VALUES (\u0026#39;10000003\u0026#39;, \u0026#39;10000\u0026#39;, \u0026#39; 10000\u0026#39;, \u0026#39;0\u0026#39;, \u0026#39;0\u0026#39;, \u0026#39;2df3d9465ty2e4hd523\u0026#39;, \u0026#39;2df3d9465ty2e4hd523\u0026#39;); 然后使用第三条 SQL 语句SELECT * FROMtest1WHERE num2 = 10000;进行查询:\n从结果可以看到,后面插入的三条数据也都匹配上了。那么这个字符串隐式转换的规则是什么呢?为什么num2='10000a'、'010000'和'10000'这三种情形都能匹配上呢?查阅相关资料发现规则如下:\n不以数字开头的字符串都将转换为0。如'abc'、'a123bc'、'abc123'都会转化为0; 以数字开头的字符串转换时会进行截取,从第一个字符截取到第一个非数字内容为止。比如'123abc'会转换为123,'012abc'会转换为012也就是12,'5.3a66b78c'会转换为5.3,其他同理。 现对以上规则做如下测试验证:\n如此也就印证了之前的查询结果了\n再次写一条 SQL 查询 str1 字段:SELECT * FROMtest1WHERE str1 = 1234;\n分析和总结 # 通过上面的测试我们发现 MySQL 使用操作符的一些特性:\n当操作符左右两边的数据类型不一致时,会发生隐式转换。 当 where 查询操作符左边为数值类型时发生了隐式转换,那么对效率影响不大,但还是不推荐这么做。 当 where 查询操作符左边为字符类型时发生了隐式转换,那么会导致索引失效,造成全表扫描效率极低。 字符串转换为数值类型时,非数字开头的字符串会转化为0,以数字开头的字符串会截取从第一个字符到第一个非数字内容为止的值为转化结果。 所以,我们在写 SQL 时一定要养成良好的习惯,查询的字段是什么类型,等号右边的条件就写成对应的类型。特别当查询的字段是字符串时,等号右边的条件一定要用引号引起来标明这是一个字符串,否则会造成索引失效触发全表扫描\n"},{"id":298,"href":"/zh/docs/technology/Review/java_guide/database/MySQL/ly0608lysome-thoughts-on-database-storage-time/","title":"MySQL数据库时间类型数据存储建议","section":"MySQL","content":" 转载自https://github.com/Snailclimb/JavaGuide(添加小部分笔记)感谢作者!\n不要用字符串存储日期 # 优点:简单直白 缺点 字符串占有的空间更大 字符串存储的日期效率比较低(逐个字符进行比较),无法用日期相关的API进行计算和比较 Datetime和Timestamp之间抉择 # Datetime 和 Timestamp 是 MySQL 提供的两种比较相似的保存时间的数据类型。他们两者究竟该如何选择呢?\n通常我们都会首选 Timestamp\nDatetime类型没有时区信息 # DateTime 类型是没有时区信息的(时区无关) ,DateTime 类型保存的时间都是当前会话所设置的时区对应的时间。这样就会有什么问题呢?当你的时区更换之后,比如你的服务器更换地址或者更换客户端连接时区设置的话,就会导致你从数据库中读出的时间错误。不要小看这个问题,很多系统就是因为这个问题闹出了很多笑话。 Timestamp 和时区有关。Timestamp 类型字段的值会随着服务器时区的变化而变化,自动换算成相应的时间,说简单点就是在不同时区,查询到同一个条记录此字段的值会不一样 案例\n-- 建表 CREATE TABLE `time_zone_test` ( `id` bigint(20) NOT NULL AUTO_INCREMENT, `date_time` datetime DEFAULT NULL, `time_stamp` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, PRIMARY KEY (`id`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8; -- 插入数据 INSERT INTO time_zone_test(date_time,time_stamp) VALUES(NOW(),NOW()); -- 查看数据 select date_time,time_stamp from time_zone_test; -- 结果 /* +---------------------+---------------------+ | date_time | time_stamp | +---------------------+---------------------+ | 2020-01-11 09:53:32 | 2020-01-11 09:53:32 | +---------------------+---------------------+ ------ */ 修改时区并查看数据\nset time_zone=\u0026#39;+8:00\u0026#39;; /* +---------------------+---------------------+ | date_time | time_stamp | +---------------------+---------------------+ | 2020-01-11 09:53:32 | 2020-01-11 17:53:32 | +---------------------+---------------------+ ------ */ 关于MySQL时区设置的一个常用sql命令\n# 查看当前会话时区 SELECT @@session.time_zone; # 设置当前会话时区 SET time_zone = \u0026#39;Europe/Helsinki\u0026#39;; SET time_zone = \u0026#34;+00:00\u0026#34;; # 数据库全局时区设置 SELECT @@global.time_zone; # 设置全局时区 SET GLOBAL time_zone = \u0026#39;+8:00\u0026#39;; SET GLOBAL time_zone = \u0026#39;Europe/Helsinki\u0026#39;; DateTime类型耗费空间更大 # Timestamp 只需要使用 4 个字节的存储空间,但是 DateTime 需要耗费 8 个字节的存储空间。但是,这样同样造成了一个问题,Timestamp 表示的时间范围更小。\nDateTime :1000-01-01 00:00:00 ~ 9999-12-31 23:59:59 Timestamp: 1970-01-01 00:00:01 ~ 2037-12-31 23:59:59 Timestamp 在不同版本的 MySQL 中有细微差别。\n再看MySQL日期类型存储空间 # MySQL 5.6 版本中日期类型所占的存储空间 可以看出 5.6.4 之后的 MySQL 多出了一个需要 0 ~ 3 字节的小数位。DateTime 和 Timestamp 会有几种不同的存储空间占用。 为了方便,本文我们还是默认 Timestamp 只需要使用 4 个字节的存储空间,但是 DateTime 需要耗费 8 个字节的存储空间 数值型时间戳是更好的选择吗 # 使用int或者bigint类型数值,即时间戳来表示时间\n优点:使用它进行日期排序以及对比等操作效率更高,跨系统也方便 缺点:可读性差 时间戳的定义\n时间戳的定义是从一个基准时间开始算起,这个基准时间是「1970-1-1 00:00:00 +0:00」,从这个时间开始,用整数表示,以秒计时,随着时间的流逝这个时间整数不断增加。这样一来,我只需要一个数值,就可以完美地表示时间了,而且这个数值是一个绝对数值,即无论的身处地球的任何角落,这个表示时间的时间戳,都是一样的,生成的数值都是一样的,并且没有时区的概念,所以在系统的中时间的传输中,都不需要进行额外的转换了,只有在显示给用户的时候,才转换为字符串格式的本地时间\n实际操作\nmysql\u0026gt; select UNIX_TIMESTAMP(\u0026#39;2020-01-11 09:53:32\u0026#39;); +---------------------------------------+ | UNIX_TIMESTAMP(\u0026#39;2020-01-11 09:53:32\u0026#39;) | +---------------------------------------+ | 1578707612 | +---------------------------------------+ 1 row in set (0.00 sec) mysql\u0026gt; select FROM_UNIXTIME(1578707612); +---------------------------+ | FROM_UNIXTIME(1578707612) | +---------------------------+ | 2020-01-11 09:53:32 | +---------------------------+ 1 row in set (0.01 sec) 总结 # 推荐使用《高性能MySQL》\n对比\n"},{"id":299,"href":"/zh/docs/technology/Review/java_guide/database/MySQL/ly0605lyhow-sql-executed-in-mysql/","title":"SQL语句在MySQL中的执行过程","section":"MySQL","content":" 转载自https://github.com/Snailclimb/JavaGuide(添加小部分笔记)感谢作者!\n原文 https://github.com/kinglaw1204 感谢作者\n本篇文章会分析一个SQL语句在MySQL的执行流程,包括SQL的查询在MySQL内部会怎么流转,SQL语句的更新是怎么完成的 分析之前先看看MySQL的基础架构,知道了MySQL由哪些组件组成以及这些组件的作用是什么,可以帮助我们理解和解决这些问题 MySQL基础架构分析 # MySQL基本架构概览 # 下图是MySQL的简要架构图,从下图可以看到用户的SQL语句在MySQL内部是如何执行的 先简单介绍一个下图涉及的一些组件的基本作用 连接器: 身份认证和权限相关(登录MySQL的时候) 查询缓存:执行查询语句的时候,会先查询缓存(MySQL8.0版本后移除,因为这个功能不太实用) 分析器:没有命中缓存的话,SQL语句就会经过分析器,分析器说白了就是要先看你的SQL语句干嘛,再检查你的SQL语句语法是否正确 优化器:按照MySQL认为最优的方案去执行 执行器:执行语句,然后从存储引擎返回数据 简单来说 MySQL 主要分为 Server 层和存储引擎层: Server 层:主要包括连接器、查询缓存、分析器、优化器、执行器等,所有跨存储引擎的功能都在这一层实现,比如存储过程、触发器、视图,函数等,还有一个通用的日志模块 binlog 日志模块。 存储引擎: 主要负责数据的存储和读取,采用可以替换的插件式架构,支持 InnoDB、MyISAM、Memory 等多个存储引擎,其中 InnoDB 引擎有自有的日志模块 redolog 模块。现在最常用的存储引擎是 InnoDB,它从 MySQL 5.5 版本开始就被当做默认存储引擎了 Server层基本组件介绍 # 连接器 连接器主要和身份认证和权限相关的功能相关,就好比一个级别很高的门卫一样\n主要负责用户登录数据库,进行用户的身份认证,包括校验账户密码,权限等操作,如果用户账户密码已通过,连接器会到权限表中查询该用户的所有权限,之后在这个连接里的权限逻辑判断都是会依赖此时读取到的权限数据,也就是说,后续只要这个连接不断开,即使管理员修改了该用户的权限,该用户也是不受影响的。\n查询缓存(MySQL8.0 版本后移除)\n查询缓存主要用来缓存我们所执行的 SELECT 语句以及该语句的结果集。\n连接建立后,执行查询语句的时候,会先查询缓存,MySQL 会先校验这个 SQL 是否执行过,以 Key-Value 的形式缓存在内存中,Key 是查询预计,Value 是结果集。如果缓存 key 被命中,就会直接返回给客户端,如果没有命中,就会执行后续的操作,完成后也会把结果缓存起来,方便下一次调用。当然在真正执行缓存查询的时候还是会校验用户的权限,是否有该表的查询条件。\nMySQL 查询不建议使用缓存,因为查询缓存失效在实际业务场景中可能会非常频繁,假如你对一个表更新的话,这个表上的所有的查询缓存都会被清空。对于不经常更新的数据来说,使用缓存还是可以的。所以,一般在大多数情况下我们都是不推荐去使用查询缓存的。\nMySQL 8.0 版本后删除了缓存的功能,官方也是认为该功能在实际的应用场景比较少,所以干脆直接删掉了\n分析器MySQL 没有命中缓存,那么就会进入分析器,分析器主要是用来分析 SQL 语句是来干嘛的,分析器也会分为几步:\n第一步,词法分析,一条 SQL 语句有多个字符串组成,首先要提取关键字,比如 select,提出查询的表,提出字段名,提出查询条件等等。做完这些操作后,就会进入第二步。\n第二步,语法分析,主要就是判断你输入的 SQL 是否正确,是否符合 MySQL 的语法。\n完成这 2 步之后,MySQL 就准备开始执行了,但是如何执行,怎么执行是最好的结果呢?这个时候就需要优化器上场了。\n优化器\n优化器的作用就是它认为的最优的执行方案去执行(有时候可能也不是最优,这篇文章涉及对这部分知识的深入讲解),比如多个索引的时候该如何选择索引,多表查询的时候如何选择关联顺序等。\n可以说,经过了优化器之后可以说这个语句具体该如何执行就已经定下来\n执行器\n当选择了执行方案后,MySQL 就准备开始执行了,首先执行前会校验该用户有没有权限,如果没有权限,就会返回错误信息,如果有权限,就会去调用引擎的接口,返回接口执行的结果\n语句分析 # SQL分为两种,一种是查询,一种是更新(增加、修改、删除)\n查询语句 # select * from tb_student A where A.age='18' and A.name=' 张三 ';\n结合上面说明,分析下面这个语句的执行流程:\n先检查该语句是否有权限,如果没有权限,直接返回错误信息,如果有权限,在 MySQL8.0 版本以前,会先查询缓存,以这条 SQL 语句为 key 在内存中查询是否有结果,如果有直接缓存,如果没有,执行下一步。\n通过分析器进行词法分析,提取 SQL 语句的关键元素,比如提取上面这个语句是查询 select,提取需要查询的表名为 tb_student,需要查询所有的列,查询条件是这个表的 id=\u0026lsquo;1\u0026rsquo;。然后判断这个 SQL 语句是否有语法错误,比如关键词是否正确等等,如果检查没问题就执行下一步。\n接下来就是优化器进行确定执行方案,上面的 SQL 语句,可以有两种执行方案:\na.先查询学生表中姓名为“张三”的学生,然后判断是否年龄是 18。 b.先找出学生中年龄 18 岁的学生,然后再查询姓名为“张三”的学生。 那么优化器根据自己的优化算法进行选择执行效率最好的一个方案(优化器认为,有时候不一定最好)。那么确认了执行计划后就准备开始执行了\n进行权限校验,如果没有权限就会返回错误信息,如果有权限就会调用数据库引擎接口,返回引擎的执行结果\n更新语句 # 以上就是一条查询 SQL 的执行流程,那么接下来我们看看一条更新语句如何执行的呢?SQL 语句如下:\nupdate tb_student A set A.age='19' where A.name=' 张三 ';\n我们来给张三修改下年龄,在实际数据库肯定不会设置年龄这个字段的,不然要被技术负责人打的 (因为可能有生日,年龄是不可人为手动修改) 其实这条语句也基本上会沿着上一个查询的流程走,只不过执行更新的时候肯定要记录日志啦,这就会引入日志模块了,MySQL 自带的日志模块是 binlog(归档日志) ,所有的存储引擎都可以使用,我们常用的 InnoDB 引擎还自带了一个日志模块 redo log(重做日志),我们就以 InnoDB 模式下来探讨这个语句的执行流程 先查询到张三这一条数据,如果有缓存,也是会用到缓存。 然后拿到查询的语句,把 age 改为 19,然后调用引擎 API 接口,写入这一行数据,InnoDB 引擎把数据保存在内存中,同时记录 redo log,此时 redo log 进入 prepare 状态,然后告诉执行器,执行完成了,随时可以提交。 执行器收到通知后记录 binlog,然后调用引擎接口,提交 redo log 为提交状态。 更新完成 这里肯定有同学会问,为什么要用两个日志模块,用一个日志模块不行吗?\n这是因为最开始 MySQL 并没有 InnoDB 引擎(InnoDB 引擎是其他公司以插件形式插入 MySQL 的),MySQL 自带的引擎是 MyISAM,但是我们知道 redo log 是 InnoDB 引擎特有的,其他存储引擎都没有,这就导致会没有 crash-safe 的能力(crash-safe 的能力即使数据库发生异常重启,之前提交的记录都不会丢失),binlog 日志只能用来归档。\n并不是说只用一个日志模块不可以,只是 InnoDB 引擎就是通过 redo log 来支持事务的。那么,又会有同学问,我用两个日志模块,但是不要这么复杂行不行,为什么 redo log 要引入 prepare 预提交状态?这里我们用反证法来说明下为什么要这么做?\n先写 redo log 直接提交,然后写 binlog,假设写完 redo log 后,机器挂了,binlog 日志没有被写入,那么机器重启后,这台机器会通过 redo log 恢复数据,但是这个时候 binlog 并没有记录该数据,**后续进行机器备份(从机)**的时候,就会丢失这一条数据,同时主从同步也会丢失这一条数据。 先写 binlog,然后写 redo log,假设写完了 binlog,机器异常重启了,由于没有 redo log,本机是无法恢复这一条记录的,但是 binlog 又有记录,那么和上面同样的道理,就会产生数据不一致的情况。 如果采用 redo log 两阶段提交的方式就不一样了,写完 binlog 后,然后再提交 redo log 就会防止出现上述的问题,从而保证了数据的一致性。那么问题来了,有没有一个极端的情况呢?假设 redo log 处于预提交状态,binlog 也已经写完了,这个时候发生了异常重启会怎么样呢? 这个就要依赖于 MySQL 的处理机制了,MySQL 的处理过程如下:\n判断 redo log 是否完整,如果判断是完整的,就立即提交。 如果 redo log 只是预提交但不是 commit 状态,这个时候就会去判断 binlog 是否完整,如果完整就提交 redo log, 不完整就回滚事务。 这样就解决了数据一致性的问题\n总结 # MySQL 主要分为 Server 层和引擎层,Server 层主要包括连接器、查询缓存、分析器、优化器、执行器,同时还有一个日志模块(binlog),这个日志模块所有执行引擎都可以共用,redolog 只有 InnoDB 有。\n引擎层是插件式的,目前主要包括,MyISAM,InnoDB,Memory 等。\n查询语句的执行流程如下:权限校验(如果命中缓存)\u0026mdash;\u0026gt;查询缓存\u0026mdash;\u0026gt;分析器\u0026mdash;\u0026gt;优化器\u0026mdash;\u0026gt;权限校验\u0026mdash;\u0026gt;执行器\u0026mdash;\u0026gt;引擎\n更新语句执行流程如下:分析器\u0026mdash;-\u0026gt;权限校验\u0026mdash;-\u0026gt;执行器\u0026mdash;\u0026gt;引擎\u0026mdash;redo log(prepare 状态)\u0026mdash;\u0026gt;binlog\u0026mdash;\u0026gt;redo log(commit状态\n"},{"id":300,"href":"/zh/docs/technology/Review/java_guide/database/MySQL/ly0604lyinnodb-implementation-of-mvcc/","title":"innodb引擎对MVCC的实现","section":"MySQL","content":" 转载自https://github.com/Snailclimb/JavaGuide(添加小部分笔记)感谢作者!\n一致性非锁定读和锁定读 # 一致性非锁定读 # ★★非锁定★★\n对于一致性非锁定读(Consistent Nonlocking Reads)的实现,通常做法是加一个版本号或者时间戳字段,在更新数据的同时版本号+1或者更新时间戳。查询时,将当前可见的版本号与对应记录的版本号进行比对,如果记录的版本小于可见版本,则表示该记录可见 InnoDB存储引擎中,多版本控制(multi versioning)即是非锁定读的实现。如果读取的行正在执行DELETE或UPDATE操作,这时读取操作不会去等待行上 锁的释放.相反地,Inn哦DB存储引擎会去读取行的一个快照数据,对于这种读取历史数据的方式,我们叫它快照读(snapshot read)。 在 Repeatable Read 和 Read Committed 两个隔离级别下,如果是执行普通的 select 语句(不包括 select ... lock in share mode ,select ... for update)则会使用 一致性非锁定读(MVCC)。并且在 Repeatable Read 下 MVCC 实现了可重复读和防止部分幻读 锁定读 # 如果执行的是下列语句,就是锁定读(Locking Reads)\nselect ... lock in share\nselect ... for update\ninsert 、upate、delete\n锁定读下,读取的是数据的最新版本,这种读也被称为当前读current read。锁定读会对读取到的记录加锁\nselect ... lock in share mode :对(读取到的)记录加S锁,其他事务也可以加S锁,如果加X锁则会被阻塞\nselect ... for update、insert、update、delete:对记录加X锁,且其他事务不能加任何锁\n在一致性非锁定读下,即使读取的记录已被其他事务加上X锁,这时记录也是可以被读取的,即读取的快照数据。\n在RepeatableRead下MVCC防止了部分幻读,这边的“部分”是指在一致性非锁定读情况下,只能读取到第一次查询之前所插入的数据(根据ReadView判断数据可见性,ReadView在第一次查询时生成),但如果是当前读,每次读取的都是最新数据,这时如果两次查询中间有其他事务插入数据,就会产生幻读 所以,InnoDB在实现RepeatableRead时,如果执行的是当前读,则会对读取的记录使用Next-key Lock,来防止其他事务在间隙间插入数据。 RR产生幻读的另一个场景\n假设有这样一张表\n事务 A 执行查询 id = 5 的记录,此时表中是没有该记录的,所以查询不出来。\n# 事务 A mysql\u0026gt; begin; Query OK, 0 rows affected (0.00 sec) mysql\u0026gt; select * from t_stu where id = 5; Empty set (0.01 sec) 然后事务 B 插入一条 id = 5 的记录,并且提交了事务。\n# 事务 B mysql\u0026gt; begin; Query OK, 0 rows affected (0.00 sec) mysql\u0026gt; insert into t_stu values(5, \u0026#39;小美\u0026#39;, 18); Query OK, 1 row affected (0.00 sec) mysql\u0026gt; commit; Query OK, 0 rows affected (0.00 sec) 此时,事务 A 更新 id = 5 这条记录,对没错,事务 A 看不到 id = 5 这条记录,但是他去更新了这条记录,这场景确实很违和,然后再次查询 id = 5 的记录,事务 A 就能看到事务 B 插入的纪录了,幻读就是发生在这种违和的场景。\n# 事务 A mysql\u0026gt; update t_stu set name = \u0026#39;小林coding\u0026#39; where id = 5; Query OK, 1 row affected (0.01 sec) Rows matched: 1 Changed: 1 Warnings: 0 mysql\u0026gt; select * from t_stu where id = 5; +----+--------------+------+ | id | name | age | +----+--------------+------+ | 5 | 小林coding | 18 | +----+--------------+------+ 1 row in set (0.00 sec) 时序图如下\n在可重复读隔离级别下,事务 A 第一次执行普通的 select 语句时生成了一个 ReadView,之后事务 B 向表中新插入了一条 id = 5 的记录并提交。接着,事务 A 对 id = 5 这条记录进行了更新操作,在这个时刻,这条新记录的 trx_id 隐藏列的值就变成了事务 A 的事务 id,之后事务 A 再使用普通 select 语句去查询这条记录时就可以看到这条记录了,于是就发生了幻读。\n因为这种特殊现象的存在,所以我们认为 MySQL Innodb 中的 MVCC 并不能完全避免幻读现象。\nInnoDB对MVCC的实现 # MVCC的实现依赖于:隐藏字段(每条记录的)、ReadView(当前事务生成的)、undo log(当前事务执行时,为每个操作(记录)生成的) 内部实现中,InnoDB通过数据行的DB_TRX_ID和Read View来判断数据的可见性,如不可见,则通过数据行的DB_ROLL_PTR找到undo log中的历史版本。因此,每个事务读到的数据版本可能是不一样的,在同一个事务中,用户只能看到该事务创建ReadView之前**(其实这个说法不太准确,m_up_limit_id不一定大于当前事务id)已经提交的修改和该事务本身做的修改** 隐藏字段 # 内部,InnoDB存储引擎为每行数据添加了三个隐藏字段: DB_TRX_ID(6字节):表示最后一次插入或更新该行的事务id。此外,delete操作在内部被视为更新,只不过会在记录头Record header中的deleted_flag字段将其标记为已删除 DB_ROLL_PTR(7字节):回滚指针,指向该行的undo log。如果该行未被更新,则为空 DB_ROW_ID(6字节):如果没有设置主键且该表没有唯一非空索引时,InnoDB会使用该id来生成聚簇索引 ReadView # class ReadView { /* ... */ private: trx_id_t m_low_limit_id; /* 大于等于这个 ID 的事务均不可见 */ trx_id_t m_up_limit_id; /* 小于这个 ID 的事务均可见 */ trx_id_t m_creator_trx_id; /* 创建该 Read View 的事务ID */ trx_id_t m_low_limit_no; /* 事务 Number, 小于该 Number 的 Undo Logs 均可以被 Purge */ ids_t m_ids; /* 创建 Read View 时的活跃事务列表 */ m_closed; /* 标记 Read View 是否 close */ } Read View 主要是用来做可见性判断,里面保存了 “当前对本事务不可见的其他活跃事务”\nReadView主要有以下字段\nm_low_limit_id:目前出现过的最大的事务 ID+1,即下一个将被分配的事务 ID。大于等于这个 ID 的数据版本均不可见 m_up_limit_id:活跃事务列表 m_ids 中最小的事务 ID,如果 m_ids 为空,则 m_up_limit_id 为 m_low_limit_id。小于这个 ID 的数据版本均可见 m_ids:Read View 创建时其他未提交的活跃事务 ID 列表。创建 Read View时,将当前未提交事务 ID 记录下来,后续即使它们修改了记录行的值,对于当前事务也是不可见的。m_ids 不包括当前事务自己和已提交的事务(正在内存中) m_creator_trx_id:创建该 Read View 的事务 ID 事务可见性示意图(这个图容易理解):\n为什么不是分大于m_low_limit_id和在小于m_low_limit_id里过滤存在于活跃事务列表,应该和算法有关吧\nundo-log # undo log主要有两个作用\n当事务回滚时用于将数据恢复到修改前的样子 用于MVCC,读取记录时,若该记录被其他事务占用或当前版本对该事务不可见,则可以通过undo log 读取之前的版本数据,以此实现非锁定读 InnoDB存储引擎中undo log分为两种:insert undo log和update undo log\ninsert undo log:指在insert操作中产生的undo log,因为insert操作的记录只对事务本身可见,对其他事务不可见,故该undo log可以在事务提交后直接删除。不需要进行purge操作(purge:清洗)\ninsert时的数据初始状态:(DB_ROLL_PTR为空)\nupdate undo log:undate或delete操作产生的undo log。该undo log 可能需要提供给MVCC机制,因此不能在事务提交时就进行删除。提交时放入undo log链表,等待purge线程进行最后的删除\n数据第一次修改时\n数据第二次被修改时\n不同事务或者相同事务的对同一记录行的修改,会使该记录行的 undo log 成为一条链表,链首就是最新的记录,链尾就是最早的旧记录。\n数据可见性算法 # 在 InnoDB 存储引擎中,创建一个新事务后,执行每个 select 语句前(RC下是),都会创建一个快照(Read View),快照中保存了当前数据库系统中正处于活跃(没有 commit)的事务的 ID 号。其实简单的说保存的是系统中当前不应该被本事务看到的其他事务 ID 列表(即 m_ids)。当用户在这个事务中要读取某个记录行的时候,InnoDB 会将该记录行的 DB_TRX_ID 与 Read View 中的一些变量及当前事务 ID 进行比较,判断是否满足可见性条件\n具体的比较算法\n如果记录 DB_TRX_ID \u0026lt; m_up_limit_id,那么表明最新修改该行的事务(DB_TRX_ID)在当前事务创建快照之前就提交了,所以该记录行的值对当前事务是可见的 如果 DB_TRX_ID \u0026gt;= m_low_limit_id,那么表明最新修改该行的事务(DB_TRX_ID)在当前事务创建快照之后才修改该行,所以该记录行的值对当前事务不可见。跳到步骤 5 m_ids 为空[那就不用排除啦,只要小于m_low_limit_id都可见](且DB_TRX_ID \u0026lt; m_low_limit_id),则表明在当前事务创建快照之前,修改该行的事务就已经提交了,所以该记录行的值对当前事务是可见的 如果 m_up_limit_id \u0026lt;= DB_TRX_ID \u0026lt; m_low_limit_id,表明最新修改该行的事务(DB_TRX_ID)在当前事务创建快照的时候可能处于“活动状态”或者“已提交状态”;所以就要对活跃事务列表 m_ids 进行查找(源码中是用的二分查找,因为是有序的) 如果在活跃事务列表 m_ids 中能找到 DB_TRX_ID,表明:① 在当前事务创建快照前,该记录行的值被事务 ID 为 DB_TRX_ID 的事务修改了,但没有提交;或者 ② 在当前事务创建快照后,该记录行的值被事务 ID 为 DB_TRX_ID 的事务修改了且提交了(可重复读)。这些情况下,这个记录行的值对当前事务都是不可见的。跳到步骤 5 在活跃事务列表中找不到,则表明“id 为 trx_id 的事务”在修改“该记录行的值”后,在**“当前事务”创建快照前就已经提交**了,所以记录行对当前事务可见 在该记录行的 DB_ROLL_PTR 指针所指向的 undo log 取出快照记录,用快照记录的 DB_TRX_ID 跳到步骤 1 重新开始判断,直到找到满足的快照版本或返回空 RC 和 RR 隔离级别下 MVCC 的差异 # 在事务隔离级别 RC 和 RR (InnoDB 存储引擎的默认事务隔离级别)下,InnoDB 存储引擎使用 MVCC(非锁定一致性读),但它们生成 Read View 的时机却不同 【RC:Read Commit 读已提交,RR:Repeatable Read 可重复读】\n在 RC 隔离级别下的 每次select 查询前都生成一个Read View (m_ids 列表) 在 RR 隔离级别下只在事务开始后 第一次select 数据前生成一个Read View(m_ids 列表) MVCC解决不可重复读问题 # 虽然 RC 和 RR 都通过 MVCC 来读取快照数据,但由于 生成 Read View 时机不同,从而在 RR 级别下实现可重复读\n举例: (Tn 表示时间线)\n在RC下ReadView生成情况 # 1. 假设时间线来到 T4 ,那么此时数据行 id = 1 的版本链为:\n由于 RC 级别下每次查询都会生成Read View ,并且事务 101、102 并未提交,此时 103 事务生成的 Read View 中活跃的事务 m_ids 为:[101,102] ,m_low_limit_id为:104,m_up_limit_id为:101,m_creator_trx_id 为:103\n此时最新记录的 DB_TRX_ID 为 101,m_up_limit_id \u0026lt;= 101 \u0026lt; m_low_limit_id,所以要在 m_ids 列表中查找,发现 DB_TRX_ID 存在列表中,那么这个记录不可见 根据 DB_ROLL_PTR 找到 undo log 中的上一版本记录,上一条记录的 DB_TRX_ID 还是 101,不可见 继续找上一条 DB_TRX_ID为 1,满足 1 \u0026lt; m_up_limit_id,可见,所以事务 103 查询到数据为 name = 菜花 2. 时间线来到 T6 ,数据的版本链为:\n因为在 RC 级别下,重新生成 Read View,这时事务 101 已经提交,102 并未提交,所以此时 Read View 中活跃的事务 m_ids:[102] ,m_low_limit_id为:104,m_up_limit_id为:102,m_creator_trx_id为:103\n此时最新记录的 DB_TRX_ID 为 102,m_up_limit_id \u0026lt;= 102 \u0026lt; m_low_limit_id,所以要在 m_ids 列表中查找,发现 DB_TRX_ID 存在列表中,那么这个记录不可见 根据 DB_ROLL_PTR 找到 undo log 中的上一版本记录,上一条记录的 DB_TRX_ID 为 101,满足 101 \u0026lt; m_up_limit_id,记录可见,所以在 T6 时间点查询到数据为 name = 李四,与时间 T4 查询到的结果不一致,不可重复读! 3. 时间线来到 T9 ,数据的版本链为:\n重新生成 Read View, 这时事务 101 和 102 都已经提交,所以 m_ids 为空,则 m_up_limit_id = m_low_limit_id = 104,最新版本事务 ID 为 102,满足 102 \u0026lt; m_low_limit_id,可见,查询结果为 name = 赵六\n总结: 在 RC 隔离级别下,事务在每次查询开始时都会生成并设置新的 Read View,所以导致不可重复读\n在RR下ReadView生成情况 # 在可重复读级别下,只会在事务开始后第一次读取数据时生成一个 Read View(m_ids 列表)\n1. 在 T4 情况下的版本链为:\n在当前执行 select 语句时生成一个 Read View,此时 m_ids:[101,102] ,m_low_limit_id为:104,m_up_limit_id为:101,m_creator_trx_id 为:103\n此时和 RC 级别下一样:\n最新记录的 DB_TRX_ID 为 101,m_up_limit_id \u0026lt;= 101 \u0026lt; m_low_limit_id,所以要在 m_ids 列表中查找,发现 DB_TRX_ID 存在列表中,那么这个记录不可见 根据 DB_ROLL_PTR 找到 undo log 中的上一版本记录,上一条记录的 DB_TRX_ID 还是 101,不可见 继续找上一条 DB_TRX_ID为 1,满足 1 \u0026lt; m_up_limit_id,可见,所以事务 103 查询到数据为 name = 菜花 2. 时间点 T6 情况下:\n在 RR 级别下只会生成一次Read View,所以此时依然沿用 m_ids :[101,102] ,m_low_limit_id为:104,m_up_limit_id为:101,m_creator_trx_id 为:103\n最新记录的 DB_TRX_ID 为 102,m_up_limit_id \u0026lt;= 102 \u0026lt; m_low_limit_id,所以要在 m_ids 列表中查找,发现 DB_TRX_ID 存在列表中,那么这个记录不可见 根据 DB_ROLL_PTR 找到 undo log 中的上一版本记录,上一条记录的 DB_TRX_ID 为 101,不可见 【从这步开始就跟T4一样了】 继续根据 DB_ROLL_PTR 找到 undo log 中的上一版本记录,上一条记录的 DB_TRX_ID 还是 101,不可见 继续找上一条 DB_TRX_ID为 1,满足 1 \u0026lt; m_up_limit_id,可见,所以事务 103 查询到数据为 name = 菜花 3. 时间点 T9 情况下:\n此时情况跟 T6 完全一样,由于已经生成了 Read View,此时依然沿用 m_ids :[101,102] ,所以查询结果依然是 name = 菜花\nMVCC+Next-key -Lock防止幻读 # InnoDB存储引擎在 RR 级别下通过 MVCC和 Next-key Lock 来解决幻读问题:\n1、执行普通 select,此时会以 MVCC 快照读的方式读取数据\n在快照读的情况下,RR 隔离级别只会在事务开启后的第一次查询生成 Read View ,并使用至事务提交。所以在生成 Read View 之后其它事务所做的更新、插入记录版本对当前事务并不可见,实现了可重复读和防止快照读下的 “幻读”\n2、执行 select\u0026hellip;for update/lock in share mode、insert、update、delete 等当前读\n在当前读下,读取的都是最新的数据,如果其它事务有插入新的记录,并且刚好在当前事务查询范围内,就会产生幻读!\nInnoDB 使用 Next-key Lockopen in new window 来防止这种情况。当执行当前读时,会锁定读取到的记录的同时,锁定它们的间隙,防止其它事务在查询范围内插入数据。只要我不让你插入,就不会发生幻读\nNext-Key* Lock(临键锁) 是Record Lock(记录锁) 和Gap Lock(间隙锁) 的结合 间隙锁是(左,右] ,即左开右闭。 "},{"id":301,"href":"/zh/docs/technology/Review/java_guide/database/MySQL/ly0603lytransaction-isolation-level/","title":"MySQL事务隔离级别详解","section":"MySQL","content":" 转载自https://github.com/Snailclimb/JavaGuide(添加小部分笔记)感谢作者!\n事务隔离级别总结 # SQL标准定义了四个隔离级别\nREAD-UNCOMMITTED(读取未提交):最低的隔离级别,允许读取尚未提交的数据变更,可能会导致脏读、幻读或不可重复读 READ-COMMITED(读取已提交):允许读取并发事务 已经提交的数据,可以阻止脏读,但是幻读或不可重复读仍有可能发生 REPEATABLE-READ(可重复读):对同一字段的多次读取结果都是一致的,除非数据是被本身事务自己所修改,可以阻止脏读和不可重复读,但幻读仍有可能发生 SERIALIZABLE(可串行化):最高的隔离级别,完全服从ACID的隔离级别。所有的事务依次逐个执行,这样事务之间就完全不可能产生干扰,也就是说,该级别可以防止脏读、不可重复读以及幻读。 隔离级别 脏读 不可重复读 幻读 READ-UNCOMMITTED √ √ √ READ-COMMITTED × √ √ REPEATABLE-READ × × √ SERIALIZABLE × × × MySQL InnoDB 存储引擎的默认支持的隔离级别是 REPEATABLE-READ(可重读)\n使用命令查看,通过SELECT @@tx_isolation;。\nMySQL 8.0 该命令改为SELECT @@transaction_isolation;\nMySQL\u0026gt; SELECT @@tx_isolation; +-----------------+ | @@tx_isolation | +-----------------+ | REPEATABLE-READ | +-----------------+ 从上面对SQL标准定义了四个隔离级别的介绍可以看出,标准的SQL隔离级别里,REPEATABLE-READ(可重复读)是不可以防止幻读的。但是,InnoDB实现的REPEATABLE-READ 隔离级别其实是可以解决幻读问题发生的,分两种情况\n快照读:由MVCC机制来保证不出现幻读 当前读:使用Next-Key Lock进行加锁来保证不出现幻读,Next-Key Lock是行锁(Record Lock )和间隙锁(Gap Lock)的结合,行锁只能锁住已经存在的行,为了避免插入新行,需要依赖间隙锁 (只用间隙锁不行,因为间隙锁是 \u0026gt; 或 \u0026lt; ,不包括等于,所以再可重复读下原记录可能会被删掉) 因为隔离级别越低,事务请求的锁越少,所以大部分数据库系统的隔离级别都是 READ-COMMITTED ,但是你要知道的是 InnoDB 存储引擎默认使用 REPEATABLE-READ 并不会有任何性能损失。\nInnoDB 存储引擎在分布式事务的情况下一般会用到 SERIALIZABLE 隔离级别\nInnoDB 存储引擎提供了对 XA 事务的支持,并通过 XA 事务来支持分布式事务的实现。 分布式事务指的是允许多个独立的事务资源(transactional resources)参与到一个全局的事务中。事务资源通常是关系型数据库系统,但也可以是其他类型的资源。全局事务要求在其中的所有参与的事务要么都提交,要么都回滚,这对于事务原有的 ACID 要求又有了提高。 在使用分布式事务时,InnoDB 存储引擎的事务隔离级别必须设置为 SERIALIZABLE。 实际情况演示 # 下面会使用2个命令行MySQL,模拟多线程(多事务)对同一份数据的(脏读等)问题\nMySQL 命令行的默认配置中事务都是自动提交的,即执行 SQL 语句后就会马上执行 COMMIT 操作。如果要显式地开启一个事务需要使用命令:START TRANSACTION\n通过下面的命令来设置隔离级别 session :更改只有本次会话有效;global:更改在所有会话都有效,且不会影响已开启的session\nSET [SESSION|GLOBAL] TRANSACTION ISOLATION LEVEL [READ UNCOMMITTED|READ COMMITTED|REPEATABLE READ|SERIALIZABLE] 实际操作中使用到的一些并发控制的语句\nSTART TRANSACTION | BEGIN:显示地开启一个事务 (begin也能开启一个事务) COMMIT:提交事务,使得对数据库做的所有修改成为永久性 ROLLBACK:回滚,会结束用户的事务,并撤销正在进行的所有未提交的修改 脏读(读未提交) # 事务1 设置为读未提交级别 SET SESSION TRANSACTION ISOLATION LEVEL READ UNCOMMITTED;\n事务1开启事务并查看数据\nSTART TRANSACTION; SELECT salary FROM employ WHERE id = 1; # 结果 +--------+ | salary | +--------+ | 5000 | +--------+ 开启新连接,事务2 开启事务并更新数据\nSTART TRANSACTION; UPDATE employ SET salary = 4500 ; 事务1查看 SELECT salary FROM employ WHERE id = 1;\n+--------+ | salary | +--------+ | 4500 | +--------+ 此时事务2 进行回滚 ROLLBACK; 使用事务1再次查看 SELECT salary FROM employ WHERE id = 1;\n+--------+ | salary | +--------+ | 5000 | +--------+ 事务二进行了回滚,但是之前事务1却读取到了4500(是个脏数据)\n避免脏读(读已提交) # 不要在上面的连接里继续\n事务1 设置为读已提交SET SESSION TRANSACTION ISOLATION LEVEL READ COMMITTED;\n事务1 开启事务并查询数据\nSTART TRANSACTION; SELECT salary FROM employ WHERE id = 1; # 结果 +--------+ | salary | +--------+ | 5000 | +--------+ 事务2 开启并修改数据(未提交)\nSTART TRANSACTION; UPDATE employ SET salary = 4500 ; 事务1查看数据 SELECT salary FROM employ WHERE id = 1; 因为事务隔离级别为读已提交,所以不会发生脏读\n# 结果 +--------+ | salary | +--------+ | 5000 | +--------+ 事务2提交 COMMIT;后,事务1再次读取数据\nSELECT salary FROM employ WHERE id = 1; +--------+ | salary | +--------+ | 4500 | +--------+ 不可重复读 # 还是刚才读已提交的那些步骤,重复操作可以知道 虽然避免了读未提交,但是出现了,一个事务还没结束,就发生了不可重复读问题\n同一个数据,在同一事务内读取多次但值不一样\n可重复读 # 断开连接后重新连接MySQL,默认就是REPEATABLE-READ 可重复读\n事务1查看当前事务隔离级别 select @@tx_isolation;\n+-----------------+ | @@tx_isolation | +-----------------+ | REPEATABLE-READ | +-----------------+ 事务1 开启事务并查询数据\nSTART TRANSACTION; SELECT salary FROM employ WHERE id = 1; # 结果 +--------+ | salary | +--------+ | 5000 | +--------+ 事务2 开启事务并更新数据\nSTART TRANSACTION; UPDATE employ SET salary = 4500 WHERE id = 1; 事务1 读取数据(结果仍不变,避免了读未提交的问题)\nSELECT salary FROM employ WHERE id = 1; +--------+ | salary | +--------+ | 5000 | +--------+ 事务2提交事务 COMMIT ;\n提交后事务1再次读取\nSELECT salary FROM employ WHERE id = 1; +--------+ | salary | +--------+ | 5000 | +--------+ 与MySQL建立新连接并查询数据(发现数据确实是已经更新了的)\nSELECT salary FROM employ WHERE id = 1; +--------+ | salary | +--------+ | 4500 | +--------+ 幻读 # 接下来测试一下该隔离策略下是否幻读 这里是在可重复读下\n先查看一下当前数据库表的数据\nSELECT * FROM test; +----+--------+ | id | salary | +----+--------+ | 1 | 8000 | | 6 | 500 | +----+--------+ use lydb; \u0026mdash;\u0026gt; 事务1和事务2都开启事务 START TRANSACTION;\n事务2插入一条薪资为500的数据并提交\nINSERT INTO test(salary) values (500); COMMIT; #此时数据库已经有两条500的数据了(事务2) select * from test; +----+--------+ | id | salary | +----+--------+ | 1 | 8000 | | 6 | 500 | | 10 | 500 | +----+--------+ 事务1查询500的数据(★★如果在事务2提交之前查询 SELECT * FROM test WHERE salary = 500; 或者 SELECT * FROM test; 那么这里[快照读]就只会查出一条,但是不管怎么样 [当前读]都会查出两条)\n#---------------- # 快照读------------------ SELECT * FROM test WHERE salary = 500; +----+--------+ | id | salary | +----+--------+ | 6 | 500 | +----+--------+ #----------------# 当前读------------------ SELECT * FROM test WHERE salary = 500 FOR UPDATE; +----+--------+ | id | salary | +----+--------+ | 6 | 500 | | 11 | 500 | +----+--------+ SQL 事务1 在第一次查询工资为 500 的记录时只有一条,SQL 事务2 插入了一条工资为 500 的记录,提交之后;SQL 事务1 在同一个事务中再次使用当前读查询发现出现了两条工资为 500 的记录这种就是幻读。\n这里说明一下当前读和快照读:\nMySQL 里除了普通查询是快照读,其他都是当前读,比如 update、insert、delete,这些语句执行前都会查询最新版本的数据,然后再做进一步的操作 【为什么上面要先进行查询的原因】可重复读隔离级是由 MVCC(多版本并发控制)实现的,实现的方式是开始事务后(执行 begin 语句后),在执行第一个查询语句后,会创建一个 Read View,后续的查询语句利用这个 Read View,通过这个 Read View 就可以在 undo log 版本链找到事务开始时的数据,所以事务过程中每次查询的数据都是一样的,即使中途有其他事务插入了新纪录,是查询不出来这条数据的,所以就很好了避免幻读问题。 解决幻读的方法 # 解决幻读的方式有很多,但是它们的核心思想就是一个事务在操作某张表数据的时候,另外一个事务不允许新增或者删除这张表中的数据了。解决幻读的方式主要有以下几种:(由重到轻)\n将事务隔离级别调整为 SERIALIZABLE 。 在可重复读的事务级别下,给事务操作的这张表添加表锁。 在可重复读的事务级别下,给事务操作的这张表添加 Next-key Lock(Record Lock+Gap Lock)。 "},{"id":302,"href":"/zh/docs/technology/Review/java_guide/database/MySQL/ly0602lymysql-logs/","title":"日志","section":"MySQL","content":" 转载自https://github.com/Snailclimb/JavaGuide(添加小部分笔记)感谢作者!\n前言 # 首先要了解一个东西 :WAL,全称 Write-Ahead Logging,它的关键点就是先写日志,再写磁盘\n在概念上,innodb通过***force log at commit***机制实现事务的持久性,即在事务提交的时候,必须先将该事务的所有事务日志写入到磁盘上的redo log file和undo log file中进行持久化\nWAL 机制的原理也很简单:修改并不直接写入到数据库文件中,而是写入到另外一个称为 WAL 的文件中;如果事务失败,WAL 中的记录会被忽略,撤销修改;如果事务成功,它将在随后的某个时间被写回到数据库文件中,提交修改\n使用 WAL 的数据库系统不会再每新增一条 WAL 日志就将其刷入数据库文件中,一般积累一定的量然后批量写入,通常使用页为单位,这是磁盘的写入单位。 同步 WAL 文件和数据库文件的行为被称为 checkpoint(检查点),一般在 WAL 文件积累到一定页数修改的时候;当然,有些系统也可以手动执行 checkpoint。执行 checkpoint 之后,WAL 文件可以被清空,这样可以保证 WAL 文件不会因为太大而性能下降。\n有些数据库系统读取请求也可以使用 WAL,通过读取 WAL 最新日志就可以获取到数据的最新状态\n关于checkpoint:https://www.cnblogs.com/chenpingzhao/p/5107480.html思考一下这个场景:如果重做日志可以无限地增大,同时缓冲池也足够大 ,那么是不需要将缓冲池中页的新版本刷新回磁盘。因为当发生宕机时,完全可以通过重做日志来恢复整个数据库系统中的数据到宕机发生的时刻。但是这需要两个前提条件:1、缓冲池可以缓存数据库中所有的数据;2、重做日志可以无限增大\n因此Checkpoint(检查点)技术就诞生了,目的是解决以下几个问题:1、缩短数据库的恢复时间;2、缓冲池不够用时,将脏页刷新到磁盘;3、重做日志不可用时,刷新脏页。\n当数据库发生宕机时,数据库不需要重做所有的日志,因为Checkpoint之前的页都已经刷新回磁盘。数据库只需对Checkpoint后的重做日志进行恢复,这样就大大缩短了恢复的时间。 当缓冲池不够用时,根据LRU算法会溢出最近最少使用的页,若此页为脏页,那么需要强制执行Checkpoint,将脏页也就是页的新版本刷回磁盘。 当重做日志出现不可用时,因为当前事务数据库系统对重做日志的设计都是循环使用的,并不是让其无限增大的,重做日志可以被重用的部分是指这些重做日志已经不再需要,当数据库发生宕机时,数据库恢复操作不需要这部分的重做日志,因此这部分就可以被覆盖重用。如果重做日志还需要使用,那么必须强制Checkpoint,将缓冲池中的页至少刷新到当前重做日志的位置。 mysql 的 WAL,大家可能都比较熟悉。mysql 通过 redo、undo 日志实现 WAL。redo log 称为重做日志,每当有操作时,在数据变更之前将操作写入 redo log,这样当发生掉电之类的情况时系统可以在重启后继续操作。undo log 称为撤销日志,当一些变更执行到一半无法完成时,可以根据撤销日志恢复到变更之间的状态。mysql 中用 redo log 来在系统 Crash 重启之类的情况时修复数据(事务的持久性),而 undo log 来保证事务的原子性。\nMySQL 日志 主要包括错误日志、查询日志、慢查询日志、事务日志、二进制日志几大类\nmysql执行\n总结\n比较重要的\n二进制日志: binlog(归档日志)【server层】 事务日志:redo log(重做日志)和undo log(回滚日志) 【引擎层】 redo log是记录物理上的改变;\nundo log是从逻辑上恢复,产生时机:事务开始之前 MySQL InnoDB 引擎使用 redo log(重做日志) 保证事务的持久性,使用 undo log(回滚日志) 来保证事务的原子性。 redo log # redo log(重做日志)是InnoDB存储引擎独有的,它让MySQL拥有了崩溃恢复的能力\n比如 MySQL 实例挂了或宕机了,重启时,InnoDB存储引擎会使用redo log恢复数据,保证数据的持久性与完整性。\n再具体点:防止在发生故障的时间点,尚有脏页未写入磁盘,在重启mysql服务的时候,根据redo log进行重做,从而达到事务的持久性这一特性\nMySQL中数据是以页(这个很重要,重点是针对页)为单位,你查询一条记录,会从硬盘把一页的数据加载出来,加载出来的数据叫数据页,会放到Buffer Pool中 (这个时候 如果更新,buffer pool 中的数据页就与磁盘上的数据页内容不一致,我们称 buffer pool 的数据页为 dirty page 脏数据)\n以页为单位:\n页是InnoDB 管理存储空间的基本单位,一个页的大小一般是16KB 。可以理解为创建一个表时,会创建一个大小为16KB大小的空间,也就是数据页。新增数据时会往该页中User Records中添加数据,如果页的大小不够使用了继续创建新的页。也就是说一般情况下一次最少从磁盘读取16kb的内容到内存,一次最少把16kb的内容刷新到磁盘中,其作用有点缓存行的意思 原文链接:https://blog.csdn.net/qq_31142237/article/details/125447413\n后续的查询都是先从 Buffer Pool 中找,没有命中再去硬盘加载,减少硬盘 IO 开销,提升性能。\n更新表数据的时候,也是如此,发现 Buffer Pool 里存在要更新的数据,就直接在 Buffer Pool 里更新\n把“在某个数据页上做了什么修改”记录到重做日志缓存(redo log buffer)里,接着刷盘到 redo log 文件里\n即 从 硬盘上db数据文件 \u0026ndash;\u0026gt; BufferPool \u0026ndash;\u0026gt; redo log buffer \u0026ndash;\u0026gt; redo log 理想情况,事务一提交就会进行刷盘操作,但实际上,刷盘的时机是根据策略\n每条redo记录由**”表空间号+数据页号+偏移量+修改数据长度+具体修改的数据“**组成\n刷盘时机 # InnoDB 存储引擎为 redo log 的刷盘策略提供了 innodb_flush_log_at_trx_commit 参数,它支持三种策略\n0:设置为0时,表示每次事务提交时不进行刷盘操作\n1:设置为1时,表示每次事务提交时都将进行刷盘操作(默认值)\n2:设置为2时,表示每次事务提交时都只把redo log buffer内容写入page cache(系统缓存)\ninnodb_flush_log_at_trx_commit 参数默认为 1 ,也就是说当事务提交时会调用 fsync 对 redo log 进行刷盘\nInnoDB 存储引擎有一个后台线程,每隔1 秒,就会把 redo log buffer 中的内容写到文件系统缓存(page cache),然后调用 fsync 刷盘。(★★重要★★即使没有提交事务的redo log记录,也有可能会刷盘,因为在事务执行过程 redo log 记录是会写入redo log buffer 中,这些 redo log 记录会被后台线程刷盘。)\n除了后台线程每秒1次的轮询操作,还有一种情况,当 redo log buffer 占用的空间即将达到 innodb_log_buffer_size 一半的时候,后台线程会主动刷盘\n不同刷盘策略的流程图\ninnodb_flush_log_at_trx_commit=0(不对是否刷盘做出处理) # 为0时,如果MySQL挂了或宕机可能会有1秒数据的丢失。\n(由于事务提交成功也不会主动写入page cache,所以即使只有MySQL 挂了,没有宕机,也会丢失。)\ninnodb_flush_log_at_trx_commit=1 # 为1时, 只要事务提交成功,redo log记录就一定在硬盘里,不会有任何数据丢失。如果事务执行期间MySQL挂了或宕机,这部分日志丢了,但是事务并没有提交,所以日志丢了也不会有损失。\ninnodb_flush_log_at_trx_commit=2 # 为2时, 只要事务提交成功,redo log buffer中的内容只写入文件系统缓存(page cache)。\n如果仅仅只是MySQL挂了不会有任何数据丢失,但是宕机可能会有1秒数据的丢失。\n日志文件组 # 硬盘上存储的 redo log 日志文件不只一个,而是以一个日志文件组的形式出现的,每个的redo日志文件大小都是一样的\n比如可以配置为一组**4个文件**,每个文件的大小是 1GB,整个 redo log 日志文件组可以记录**4G**的内容\n它采用的是环形数组形式,从头开始写,写到末尾又回到头循环写,如下图所示\n在一个日志文件组中还有两个重要的属性,分别是 write pos、checkpoint\nwrite pos 是当前记录的位置,一边写一边后移 checkpoint 是当前要擦除的位置,也是往后推移 write pos 和 checkpoint 之间的还空着的部分可以用来写入新的 redo log 记录。 ly: 我的理解是有个缓冲带\n如果 write pos 追上 checkpoint (ly: 没有可以擦除的地方了),表示日志文件组满了,这时候不能再写入新的 redo log 记录,MySQL 得停下来,清空一些记录,把 checkpoint 推进一下。\nredo log 小结 # ★★这里有个很重要的问题,就是为什么允许擦除★★\n因为redo log记录的是数据页上的修改,如果Buffer Pool中数据页已经刷磁盘(这里说的磁盘是数据库数据吧)后,那这些记录就失效了,新日志会将这些失效的记录进行覆盖擦除。 redo log日志满了,在擦除之前,需要确保这些要被擦除记录对应在内存中的数据页都已经刷到磁盘中了。擦除旧记录腾出新空间这段期间,是不能再接收新的更新请求的,此刻MySQL的性能会下降。所以在并发量大的情况下,合理调整redo log的文件大小非常重要。 那为什么要绕这么一圈呢,只要每次把修改后的数据页直接刷盘不就好了,还有 redo log 什么事?\n1 Byte = 8bit 1 KB = 1024 Byte 1 MB = 1024 KB 1 GB = 1024 MB 1 TB = 1024 GB 实际上,数据页是16KB,刷盘比较耗时,有时候可能就修改了数据页里的几Byte数据,有必要把完整的数据页刷盘吗\n数据页刷盘是随机写,因为一个数据页对应的位置可能在硬盘文件的随机位置,所以性能是很差\n一个数据页对应的位置可能在硬盘文件的随机位置,即1页是16KB,这16KB,可能是在某个硬盘文件的某个偏移量到某个偏移量之间\n如果是写 redo log,一行记录可能就占几十 Byte,只包含表空间号、数据页号、磁盘文件偏移 量、更新值,再加上是顺序写,所以刷盘速度很快。\n其实内存的数据页在一定时机也会刷盘,我们把这称为页合并,讲 Buffer Pool的时候会对这块细说\nbinlog # redo log是物理日志,记录内容是**“在某个数据页上做了什么修改”,属于InnoDB 存储引擎**;而bin log是逻辑日志,记录内容是语句的原始逻辑,类似于 “给ID = 2 这一行的 c 字段加1”,属于MYSQL Server层\n无论用什么存储引擎,只要发生了表数据更新,都会产生于binlog 日志\nMySQL的数据库的数据备份、主备、主主、主从都离不开binlog,需要依靠binlog来同步数据,保证数据一致性。 binlog会记录所有涉及更新数据的逻辑操作,而且是顺序写\n记录格式 # binlog 日志有三种格式,可以通过**binlog_format**参数指定。 statement row mixed 指定**statement,记录的内容是SQL语句原文**,比如执行一条update T set update_time=now() where id=1,记录的内容如下 同步数据时会执行记录的SQL语句,但有个问题,update_time = now() 会获取当前系统时间,直接执行会导致与原库的数据不一致\n为了解决上面问题,需要指定row,记录的不是简单的SQL语句,还包括操作的具体数据,记录内容如下\nrow格式的记录内容看不到详细信息,需要用mysqlbinlog工具解析出来 update_time=now()变成了具体的时间update_time=1627112756247,条件后面的@1、@2、@3 都是该行数据第 1 个~3 个字段的原始值(假设这张表只有 3 个字段) 这样就能保证同步数据的一致性,通常情况下都是指定row,可以为数据库的恢复与同步带来更好的可靠性\n但是由于row需要更大的容量来记录,比较占用空间,恢复与同步更消耗IO资源,影响执行速度。 折中方案,指定为mixed,记录内容为两者混合:MySQL会判断这条SQL语句是否引起数据不一致,如果是就用row格式,否则就使用statement格式\n写入机制 # binlog的写入时机:事务执行过程中,先把日志写到binlog cache,事务提交的时候(这个很重要,他不像redo log,binlog只有提交的时候才会刷盘),再把binlog cache写到binlog文件中\n因为一个事务的**binlog不能被拆开**,无论这个事务多大,也要确保一次性写入,所以系统会给每个线程分配一个块内存作为binlog cache\n我们可以通过binlog_cache_size参数控制单个线程 binlog cache 大小,如果存储内容超过了这个参数,就要暂存到磁盘(Swap):\nbinlog日志刷盘流程如下\n上图的 write,是指把日志写入到文件系统的 page cache,并没有把数据持久化到磁盘,所以速度比较快 上图的 fsync,才是将数据持久化到磁盘的操作 write和fsync的时机,由sync_binlog控制,默认为0\n为0时,表示每次提交的事务都只write,由系统自行判断什么时候执行fsync 虽然性能会提升,但是如果机器宕机,page cache里面的binlog会丢失\n设置为1,表示每次提交事务都会fsync ,就如同redo log日志刷盘流程 一样\n折中,可以设置为N\n在出现IO瓶颈的场景里,将sync_binlog设置成一个较大的值,可以提升性能\n同理,如果机器宕机,会丢失最近N个事务的binlog日志\n两阶段提交 # redo log(重做日志)让InnoDB存储引擎拥有了崩溃恢复的能力 binlog(归档日志)保证了MySQL集群架构的数据一致性 两者都属于持久性的保证,但侧重点不同\n更新语句过程,会记录redo log和binlog两块日志,以基本的事务为单位\nredo log在事务执行过程中可以不断地写入,而binlog只有在提交事务时才写入,所以redo log和binlog写入时机不一样\nredo log与binlog 两份日志之间的逻辑不一样,会出现什么问题?\n以update语句为例,假设id=2的记录,字段c值是0,把字段c值更新成1,SQL语句为update T set c=1 where id= 2\n假设执行过程中写完redo log日志后,binlog日志写期间发生了异常,会出现什么情况 由于binlog没写完就异常,这时候**binlog里面没有对应的修改记录**。因此,之后用**binlog日志恢复(备库)数据时,就会少这一次更新,恢复出来的这一行c值是0,而原库因为redo log日志恢复,这一行c值是1,最终数据不一致**。\n为了解决两份日志之间的逻辑一致问题,InnoDB存储引擎使用两阶段提交方案 即将redo log的写入拆成了两个步骤prepare和commit,这就是两阶段提交(其实就是等binlog正式写入后redo log才正式提交) 使用两阶段提交后,写入binlog时发生异常也不会有影响,因为**MySQL根据redo log日志恢复数据时,发现redo log还处于prepare阶段(也就是下图的非commit阶段),并且没有对应binlog日志**,就会回滚该事务。\n其实下图中,是否存在对应的binlog,就是想知道binlog是否是完整的,如果完整的话 redolog就可以提交 (箭头前面是否commit阶段,是的话就表示binlog写入期间没有出错,即binlog完整) 还有个问题,redo log设置commit阶段发生异常,那会不会回滚事务呢? 并不会回滚事务,它会执行上图框住的逻辑,虽然redo log是处于prepare阶段,但是能通过事务id找到对应的binlog日志,所以**MySQL认为(binlog)是完整的**,就会提交事务恢复数据。\nundo log # 如果想要保证事务的原子性,就需要在异常发生时,对已经执行的操作进行回滚,在 MySQL 中,恢复机制是通过 回滚日志(undo log) 实现的,所有事务进行的修改都会先记录到这个回滚日志中,然后再执行相关的操作 如果执行过程中遇到异常的话,我们直接利用 回滚日志 中的信息将数据回滚到修改之前的样子即可! 回滚日志会先于数据(数据库数据)持久化到磁盘上。这样就保证了即使遇到数据库突然宕机等情况,当用户再次启动数据库的时候,数据库还能够通过查询回滚日志来回滚将之前未完成的事务。 关于undo log:\n参考https://blog.csdn.net/Weixiaohuai/article/details/117867353\nundo log是逻辑日志,而且记录的是相反的语句\nundo log日志里面不仅存放着数据更新前的记录,还记录着RowID、事务ID、回滚指针。其中事务ID每次递增,回滚指针第一次如果是insert语句的话,回滚指针为NULL**,第二次update之后的undo log的回滚指针就会指向刚刚那一条undo log日志**,依次类推,就会形成一条undo log的回滚链,方便找到该条记录的历史版本\n更新数据之前,MySQL会提前生成undo log日志,当事务提交的时候,并不会立即删除undo log,因为后面可能需要进行回滚操作,要执行回滚(rollback)操作时,从缓存中读取数据。undo log日志的删除是通过通过后台purge线程进行回收处理的。\n举例\n假设有A、B两个数据,值分别为1,2。\nA. 事务开始\nB. 记录A=1到undo log中\nC. 修改A=3\nD. 记录B=2到undo log中\nE. 修改B=4\nF. 将undo log写到磁盘 \u0026mdash;\u0026mdash;-undo log持久化\nG. 将数据写到磁盘 \u0026mdash;\u0026mdash;-数据持久化\nH. 事务提交 \u0026mdash;\u0026mdash;-提交事务\n由于以下特点,所以能保证原子性和持久化\n更新数据前记录undo log。 为了保证持久性,必须将数据在事务提交前写到磁盘,只要事务成功提交,数据必然已经持久化到磁盘。 undo log必须先于数据持久化到磁盘。如果在G,H之间发生系统崩溃,undo log是完整的,可以用来回滚。 如果在A - F之间发生系统崩溃,因为数据没有持久化到磁盘,所以磁盘上的数据还是保持在事务开始前的状态。 参考https://developer.aliyun.com/article/1009683\nhttps://www.cnblogs.com/defectfixer/p/15835714.html\nMySQL 的 InnoDB 存储引擎使用“Write-Ahead Log”日志方案实现本地事务的原子性、持久性。\n“提前写入”(Write-Ahead),就是在事务提交之前,允许将变动数据写入磁盘。与“提前写入”相反的就是,在事务提交之前,不允许将变动数据写入磁盘,而是等到事务提交之后再写入。\n“提前写入”的好处是:有利于利用空闲 I/O 资源。但“提前写入”同时也引入了新的问题:在事务提交之前就有部分变动数据被写入磁盘,那么如果事务要回滚,或者发生了崩溃,这些提前写入的变动数据就都成了错误。“Write-Ahead Log”日志方案给出的解决办法是:增加了一种被称为 Undo Log 的日志,用于进行事务回滚。\n变动数据写入磁盘前,必须先记录 Undo Log,Undo Log 中存储了回滚需要的数据。在事务回滚或者崩溃恢复时,根据 Undo Log 中的信息对提前写入的数据变动进行擦除。\n更新一条语句的执行过程(ly:根据多方资料验证,这个是对的,事务提交前并不会持久化到db磁盘数据库文件中)\n回答题主的问题,对MySQL数据库来说,事务提交之前,操作的数据存储在数据库在内存区域中的缓冲池中,即写的是内存缓冲池中的页(page cache),同时会在缓冲池中写undolog(用于回滚)和redolog、binlog(用于故障恢复,保证数据持久化的一致性),事务提交后,有数据变更的页,即脏页,会被持久化到物理磁盘。\n作者:王同学 链接:https://www.zhihu.com/question/278643174/answer/1998207141 来源:知乎 著作权归作者所有。商业转载请联系作者获得授权,非商业转载请注明出处。\n执行后的几个步骤\n事务开始 申请加锁:表锁、MDL 锁、行锁、索引区间锁(看情况加哪几种锁) 执行器找存储引擎取数据。 如果记录所在的数据页本来就在内存(innodb_buffer_cache)中,存储引擎就直接返回给执行器; 否则,存储引擎需要先将该数据页从磁盘读取到内存,然后再返回给执行器。 执行器拿到存储引擎给的行数据,进行更新操作后,再调用存储引擎接口写入这行新数据(6 - 9)。 存储引擎将回滚需要的数据记录到 Undo Log,并将这个更新操作记录到 Redo Log,此时 Redo Log 处于 prepare 状态。并将这行新数据更新到内存(innodb_buffer_cache)中。同时,然后告知执行器执行完成了,随时可以提交事务。 手动事务 commit:执行器生成这个操作的 Binary Log,并把 Binary Log 写入磁盘。 执行器调用存储引擎的提交事务接口,存储引擎把刚刚写入的 Redo Log 改成 commit 状态。 事务结束 MVCC # MVCC 的实现依赖于:隐藏字段、Read View、undo log。\n内部实现中,InnoDB 通过数据行的 DB_TRX_ID 和 Read View 来判断数据的可见性,如不可见,则通过数据行的 DB_ROLL_PTR 找到 undo log 中的历史版本。\n每个事务读到的数据版本可能是不一样的,在同一个事务中,用户只能看到该事务创建 Read View 之前已经提交的修改和该事务本身做的修改\n总结 # MySQL InnoDB 引擎使用 redo log(重做日志) 保证事务的持久性,使用 undo log(回滚日志) 来保证事务的原子性。 MySQL数据库的数据备份、主备、主主、主从都离不开binlog,需要依靠binlog来同步数据,保证数据一致性。 三大日志大概的流程 "},{"id":303,"href":"/zh/docs/technology/Review/java_guide/database/MySQL/ly0601lymysql-index/","title":"索引","section":"MySQL","content":" 转载自https://github.com/Snailclimb/JavaGuide(添加小部分笔记)感谢作者!\n补充索引基础知识(引自b站sgg视频) # 存储引擎,数据的基本单位是页,如果数据很少,只有一页,那就简单,是直接二分查找(不涉及磁盘IO);如果数据很多,有好几个页,那么需要对页建立一种数据结构,能够最快定位到哪一页,然后减少磁盘IO 索引介绍 # 索引是一种用于快速查询和检索数据的数据结构,其本质可以看成是一种排序好的数据结构\n索引的作用就相当于书的目录。打个比方: 我们在查字典的时候,如果没有目录,那我们就只能一页一页的去找我们需要查的那个字,速度很慢。如果有目录了,我们只需要先去目录里查找字的位置,然后直接翻到那一页就行了\n索引底层数据结构存在很多种类型,常见的索引结构有:B树,B+树和Hash、红黑树。在MySQL中,无论是Innodb还是MyIsam,都使用了B+树作为索引结构\n索引的优缺点 # 优点:\n使用索引可以大大加快 数据的检索速度(大大减少检索的数据量), 这也是创建索引的最主要的原因。 通过创建唯一性索引,可以保证数据库表中每一行数据的唯一性。 缺点:\n创建索引和维护索引需要耗费许多时间。当对表中的数据进行增删改的时候,如果数据有索引,那么索引也需要动态的修改,会降低 SQL 执行效率。 索引需要使用物理文件存储,也会耗费一定空间 索引一定会提高查询性能吗\n多数情况下,索引查询都是比全表扫描要快的。但是如果数据库的数据量不大,那么使用索引也不一定能够带来很大提升 索引的底层数据结构 # Hash表 # 哈希表是键值对的集合,通过键(key)即可快速取出对应的值(value),因此哈希表可以快速检索数据(接近O(1))\n为何能够通过key快速取出value呢?原因在于哈希算法(也叫散列算法)。通过哈希算法,我们可以快速找到key对应的index,找到了index也就找到了对应的value\nhash = hashfunc(key) index = hash % array_size 注意,图中keys[天蓝色]是字符串,不是什么莫名其妙的人 哈希算法有个 Hash 冲突 问题,也就是说多个不同的 key 最后得到的 index 相同。通常情况下,我们常用的解决办法是 链地址法。链地址法就是将哈希冲突数据存放在链表中。就比如 JDK1.8 之前 HashMap 就是通过链地址法来解决哈希冲突的。不过,JDK1.8 以后HashMap为了减少链表过长的时候搜索时间过长引入了红黑树。\n为了减少 Hash 冲突的发生,一个好的哈希函数应该**“均匀地”将数据分布**在整个可能的哈希值集合中\n由于Hash索引不支持顺序和范围查询,假如要对表中的数据进行排序或者进行范围查询,那Hash索引就不行了,并且,每次IO只能取一个\n例如: SELECT * FROM tb1 WHERE id \u0026lt; 500 ; 这种范围查询中,B+树 优势非常大 直接遍历比500小的叶子节点即可 如果使用Hash索引,由于Hash索引是根据hash算法来定位的,难不成把1 ~499 (小于500)的数据都进行一次hash计算来定位吗?这就是Hash最大的缺点 这里其实说的是已经找到了索引,但是索引没有数据的情形。要么通过hash一个个取数据,要么利用B+树的特性(叶子节点有完整数据)\nB树\u0026amp; B+ 树 # B树也称B-树,全称为多路平衡查找树,B+树是B树的一种变体\nB树和B+树中的B是Balanced(平衡)的意思\n目前大部分数据库以及文件系统都采用B-Tree或者其变种B+Tree作为索引结构\nB树\u0026amp;B+树两者有何异同呢\nB树的所有结点既存放键(key)也存放数据(data),而B+树只有叶子结点存放key和data,其他内节点只存放key B树的叶子节点都是独立的;B+树的叶子节点有一条引用链指向与它相邻的叶子节点 B树的检索的过程相当于对范围内的每个结点的关键字做二分查找,可能还没有到达叶子节点,检索就结束了。而B+树的检索效率比较稳定,任何查找都是从根节点到叶子节点的过程,叶子结点的顺序检索很明显 B树中某个子节点,他都包括了父节点的某个节点 如图 在MySQL中,MyISAM引擎和InnoDB引擎都是使用B+Tree作为索引结构,但是,两者的实现方式有点不太一样\nMyISAM 引擎中,B+Tree 叶节点的 data 域存放的是数据记录的地址。在索引检索的时候,首先按照 B+Tree 搜索算法搜索索引,如果指定的 Key 存在,则取出其 data 域的值,然后以 data 域的值为地址读取相应的数据记录。这被称为“非聚簇索引(非聚集索引)”。【反例,B+ 树非叶子节点没有存储数据记录的地址/数据记录本身】 InnoDB 引擎中,其数据文件本身就是索引文件。 MyISAM 的 索引文件和数据文件是分离的,而InnoDB引擎中其表数据文件本身就是按 B+Tree 组织的一个索引结构,树的叶节点 data 域保存了完整的数据记录。这个索引的 key 是数据表的主键(而非地址),因此 InnoDB 表数据文件本身就是主索引。这被称为“聚簇索引(聚集索引)”,而其余的索引都作为 辅助索引 ,辅助索引的 data 域存储相应记录主键的值而不是地址,这也是和 MyISAM 不同的地方。在根据主索引搜索时,直接找到 key 所在的节点即可取出数据;在根据辅助索引查找时,则需要先取出主键的值,再走一遍主索引。 在设计表的时候,不建议使用过长的字段作为主键,也不建议使用非单调的字段作为主键,这样会造成主索引频繁分裂。\n原因: InnoDB的辅助索引data域存储相应记录主键的值而不是地址。所以不建议使用过长的字段作为主键,因为所有辅助索引都引用主索引,过长的主索引会令辅助索引变得过大 (不建议使用过长的字段作为主键) InnoDB数据文件本身是一颗B+Tree,非单调的主键会造成在插入新记录时数据文件为了维持B+Tree的特性而频繁的分裂调整,十分低效。(不建议使用非单调的字段作为主键) MySQL底层数据结构总结 # 索引类型总结 # 按照数据结构维度划分:\nBTree 索引:MySQL 里默认和最常用的索引类型。只有叶子节点存储 value,非叶子节点只有指针和 key。存储引擎 MyISAM 和 InnoDB 实现 BTree 索引都是使用 B+Tree,但二者实现方式不一样(前面已经介绍了)。 哈希索引:类似键值对的形式,一次即可定位。 RTree 索引:一般不会使用,仅支持 geometry 数据类型,优势在于范围查找,效率较低,通常使用搜索引擎如 ElasticSearch 代替。 全文索引:对文本的内容进行分词,进行搜索。目前只有 CHAR、VARCHAR ,TEXT 列上可以创建全文索引。一般不会使用,效率较低,通常使用搜索引擎如 ElasticSearch 代替。 按照底层存储方式角度划分:\n聚簇索引(聚集索引):索引结构和数据一起存放的索引,InnoDB 中的主键索引就属于聚簇索引。 非聚簇索引(非聚集索引):索引结构和数据分开存放的索引,**二级索引(辅助索引)**就属于非聚簇索引。MySQL 的 MyISAM 引擎,不管主键还是非主键,使用的都是非聚簇索引。 按照应用维度划分:\n主键索引:加速查询 + 列值唯一(不可以有 NULL)+ 表中只有一个。 普通索引:仅加速查询。 唯一索引:加速查询 + 列值唯一(可以有 NULL)。 覆盖索引:一个索引包含(或者说覆盖)所有需要查询的字段的值。 联合索引:多列值组成一个索引,专门用于组合搜索,其效率大于索引合并。 全文索引:对文本的内容进行分词,进行搜索。目前只有 CHAR、VARCHAR ,TEXT 列上可以创建全文索引。一般不会使用,效率较低,通常使用搜索引擎如 ElasticSearch 代替。 MySQL 8.x 中实现的索引新特性:\n隐藏索引:也称为不可见索引,不会被优化器使用,但是仍然需要维护,通常会软删除和灰度发布的场景中使用。主键不能设置为隐藏(包括显式设置或隐式设置)。 降序索引:之前的版本就支持通过 desc 来指定索引为降序,但实际上创建的仍然是常规的升序索引。直到 MySQL 8.x 版本才开始真正支持降序索引。另外,在 MySQL 8.x 版本中,不再对 GROUP BY 语句进行隐式排序。 函数索引:从 MySQL 8.0.13 版本开始支持在索引中使用函数或者表达式的值,也就是在索引中可以包含函数或者表达式。 索引类型 # 主键索引(Primary Key) # 数据表的主键列,使用的就是主键索引 一张数据表只能有一个主键,并且主键不能为null,不能重复 在 MySQL 的 InnoDB 的表中,当没有显示的指定表的主键时,InnoDB 会自动先检查表中是否有唯一索引且不允许存在 null 值的字段,如果有,则选择该字段为默认的主键,否则 InnoDB 将会自动创建一个 6Byte 的自增主键 如图 二级索引(辅助索引) # 二级索引又称为辅助索引,是因为二级索引的叶子结点存储的数据是主键。也就是说,通过二级索引,可以定位主键的位置(还有值)\n唯一索引、普通索引、前缀索引等索引都属于二级索引\n唯一索引 Unique Key:是一种约束,该索引的属性列不能出现重复的数据,但是允许数据为NULL,一张表允许创建多个唯一索引。建立唯一索引的目的多是为了该属性列的数据的唯一性,而不是为了查询效率\n普通索引 Index:普通索引的唯一作用就是为了快速查询数据,一张表允许创建多个普通索引,并允许数据重复和NULL\n前缀索引 Prefix:前缀索引只适用于字符串类型的数据。前缀索引是对文本的前几个字符创建索引,相比普通索引建立的数据更小,因为只取前几个字符\n全文索引Full Text:全文索引主要是为了检索大文本数据中的关键字的信息,是目前搜索引擎数据库使用的一种技术。\nMysql5.6 之前只有 MYISAM 引擎支持全文索引,5.6 之后 InnoDB 也支持了全文索引。\n二级索引: 聚簇索引与非聚簇索引 # 聚簇索引(聚集索引) # 聚簇索引介绍 聚簇索引即索引结构和数据一起存放的索引,并不是一种单独的索引类型。InnoDB中的主键索引就属于聚簇索引 MySQL中InnoDB引擎的表的**.ibd 文件就包含了该表的索引和数据**,对于InnoDB引擎表来说,该表的索引(B+树)的每个非叶子节点存储索引(和页地址),叶子结点存储索引和索引对应的数据 聚簇索引的优缺点 优点 查询速度非常快:聚簇索引的查询速度非常的快,因为整个B+树本身就是一颗多差平衡树,叶子节点也都是有序的,定位到索引的节点,就相当于定位到了数据。相比于非聚簇索引,聚簇索引少了一次读取数据的IO操作 对排序查找和范围查找优化:聚簇索引对于逐渐的排序查找和范围查找速度非常快 缺点 依赖于有序的数据:因为B+树是多路平衡树,如果索引的数据不是有序的,那么就需要在插入时排序。如果数据是整型还好,否则类似于字符串或UUID这种又长有难比较的数据,插入或查找的速度较慢 更新代价大:如果对索引列的数据被修改时,那么对应的索引也将会被修改,而且聚簇索引的叶子节点还存放着数据,修改代价肯定是较大的,所以对于主键索引来说,主键一般都是不可被修改的 非聚簇索引(非聚集索引) # 优点:\n更新代价比聚簇索引要小。因为非聚簇索引的叶子节点是不存放数据的\n缺点:\n依赖于有序数据:跟聚簇索引一样,非聚簇索引也依赖于有序数据 可能会二次查询(回表):这应该是非聚簇索引最大的缺点了。 当查到索引对应的指针或主键后,可能还需要根据指针或主键再到数据文件或表中查询 MySQL的表的文件截图: 聚簇索引和非聚簇索引:\n聚簇索引一定回表查询吗(覆盖索引)\n非聚簇索引不一定回表查询\n试想一种情况,用户准备使用 SQL 查询用户名,而用户名字段正好建立了索引。\nSELECT name FROM table WHERE name=\u0026#39;guang19\u0026#39;; 那么这个索引的 key 本身就是 name,查到对应的 name 直接返回就行了,无需回表查询。\n即使是 MYISAM 也是这样,虽然 MYISAM 的主键索引确实需要回表,因为它的主键索引的叶子节点存放的是指针。但是!如果 SQL 查的就是主键(本身)呢?\nSELECT id FROM table WHERE id=1; 主键索引本身的 key 就是主键,查到返回就行了。这种情况就称之为覆盖索引了\n覆盖索引和联合索引 # 覆盖索引 # 如果一个索引包含(或者说覆盖)所有需要查询的字段的值,我们就称之为“覆盖索引”。(也就是不用回表)\n我们知道在 InnoDB 存储引擎中,如果不是主键索引(叶子节点存储的是主键+列值),最终还是要“回表”,也就是要通过主键再查找一次,这样就会比较慢。覆盖索引就是把要查询出的列和索引是对应的,不做回表操作!\n覆盖索引即需要查询的字段正好事索引的字段,那么直接根据该索引,就可以查到数据了,而无需回表查询\n如主键索引,如果一条 SQL 需要查询主键,那么正好根据主键索引就可以查到主键。\n再如普通索引,如果一条 SQL 需要查询 name,name 字段正好有索引, 那么直接根据这个索引就可以查到数据,也无需回表。\n我觉得覆盖索引要在联合索引上体现的话功能会比较突出\n联合索引 # 使用表中的多个字段创建索引,也就是联合索引,也叫组合索引,或复合索引\n最左前缀匹配原则 # 最左前缀匹配原则指的是,在使用联合索引时,MySQL 会根据联合索引中的字段顺序,从左到右依次到查询条件中去匹配,如果查询条件中存在与联合索引中最左侧字段相匹配的字段,则就会使用该字段过滤一批数据,直至联合索引中全部字段匹配完成,或者在执行过程中遇到范围查询,如 \u0026gt;、\u0026lt;、between 和 以%开头的like查询 等条件,才会停止匹配。 所以,我们在使用联合索引时,可以将区分度高的字段放在最左边,这也可以过滤更多数据 索引下推 # 索引下推(Index Condition Pushdown) 是 MySQL 5.6 版本中提供的一项索引优化功能,可以在非聚簇索引遍历过程中,对(即能用索引先用索引)索引中包含的字段先做判断,过滤掉不符合条件的记录,减少回表次数。\n例子:\n对于SELECT * from user where name like '陈%' and age=20这条语句 其中主要几个字段有:id、name、age、address。建立联合索引(name,age)\n最关键的一点: 组合索引满足最左匹配,但是遇到非等值判断时匹配停止。 name like \u0026lsquo;陈%\u0026rsquo; 不是等值匹配,所以 age = 20 这里就用不上 (name,age) 组合索引了。如果没有索引下推,组合索引只能用到 name,age 的判定就需要回表才能做了。5.6之后有了索引下推,age = 20 可以直接在组合索引里判定。\n5.6之前的版本是没有索引下推这个优化的,会忽略age这个字段,直接通过name进行查询,在(name,age)这课树上查找到了两个结果,id分别为2,1,然后拿着取到的id值一次次的回表查询,因此这个过程需要回表两次 5.6版本添加了索引下推这个优化 InnoDB并没有忽略age这个字段,而是在索引内部就判断了age是否等于20,对于不等于20的记录直接跳过,因此在(name,age)这棵索引树中只匹配到了一个记录,此时拿着这个id去主键索引树中回表查询全部数据,这个过程只需要回表一次 争取使用索引的一些建议 # 选择合适的字段创建索引 # 不为 NULL 的字段 :索引字段的数据应该尽量不为 NULL,因为对于数据为 NULL 的字段,数据库较难优化。如果字段频繁被查询,但又避免不了为 NULL,建议使用 0,1,true,false 这样语义较为清晰的短值或短字符作为替代。 被频繁查询的字段 :我们创建索引的字段应该是查询操作非常频繁的字段。 被作为条件查询的字段 :被作为 WHERE 条件查询的字段,应该被考虑建立索引。 频繁需要排序的字段 :索引已经排序,这样查询可以利用索引的排序,加快排序查询时间。 被经常频繁用于连接的字段 :经常用于连接的字段可能是一些外键列,对于外键列并不一定要建立外键,只是说该列涉及到表与表的关系。对于频繁被连接查询的字段,可以考虑建立索引,提高多表连接查询的效率 被频繁更新的字段应该慎重建索引 # 虽然索引能带来查询上的效率,但是维护索引的成本也是不小的。 如果一个字段不被经常查询,反而被经常修改,那么就更不应该在这种字段上建立索引了。\n尽可能地考虑建立联合索引而不是单列索引 # 因为索引是需要占用磁盘空间的,可以简单理解为每个索引都对应着一颗 B+树。如果一个表的字段过多,索引过多,那么当这个表的数据达到一个体量后,索引占用的空间也是很多的,且修改索引时,耗费的时间也是较多的。如果是联合索引,多个字段在一个索引上,那么将会节约很大磁盘空间,且修改数据的操作效率也会提升。\n注意避免冗余索引 # 冗余索引指的是索引的功能相同,能够命中索引(a, b)就肯定能命中索引(a) ,那么索引(a)就是冗余索引\n(name,city )和(name )这两个索引就是冗余索引,能够命中前者的查询肯定是能够命中后者的 在大多数情况下,都应该尽量扩展已有的索引而不是创建新索引\n考虑在字符串类型的字段上使用前缀索引代替普通索引 # 前缀索引仅限于字符串类型,较普通索引会占用更小的空间,所以可以考虑使用前缀索引带替普通索引。\n避免索引失效 # 使用 SELECT * 进行查询;\n创建了组合索引,但查询条件未准守最左匹配原则;\n在索引列上进行计算、函数、类型转换等操作;\n以 % 开头的 LIKE 查询比如 like '%abc';\n%在左边,即使有索引,也会失效 只有当%在右边时,才会生效 查询条件中使用 or,且 or 的前后条件中有一个列没有索引,涉及的索引都不会被使用到(也就是说,反正都是要全表扫描,所以就不用索引了)\n删除长期未使用的索引 # 删除长期未使用的索引,不用的索引的存在会造成不必要的性能损耗\nMySQL 5.7 可以通过查询 sys 库的 schema_unused_indexes 视图来查询哪些索引从未被使用\n"},{"id":304,"href":"/zh/docs/technology/Review/java_guide/database/ly0502lycharactor-set/","title":"字符集详解","section":"数据库","content":" 转载自https://github.com/Snailclimb/JavaGuide(添加小部分笔记)感谢作者!\n图示总结\nMySQL字符编码集有两套UTF-8编码实现:utf-8 和 utf8mb4\n而其中,utf-8 不支持存储emoji符号和一些比较复杂的汉字、繁体字,会出错 何为字符集 # 字符是各种文字和符号的统称,包括各个国家文字、标点符号、表情、数字等等\n字符集就是一系列字符的集合,字符集的种类较多,每个字符集可以表示的字符范围通常不同,就比如说有些字符集无法表示汉字 计算机只能存储二进制的数据,那英文、汉字、表情等字符应该如何存储呢\n我们要将这些字符和二进制的数据一一对应起来,比如说字符“a”对应“01100001”,反之,“01100001”对应 “a”。我们将字符对应二进制数据的过程称为\u0026quot;字符编码\u0026quot;,反之,二进制数据解析成字符的过程称为“字符解码”。\n有哪些常见的字符集 # 常见的字符集有ASCLL、GB2312、GBK、UTF-8 不同的字符集的主要区别在于 可以表示的字符范围 编码方式 ASCLL # ASCII (American Standard Code for Information Interchange,美国信息交换标准代码) 是一套主要用于现代美国英语的字符集(这也是 ASCII 字符集的局限性所在)\n为什么 ASCII 字符集没有考虑到中文等其他字符呢? 因为计算机是美国人发明的,当时,计算机的发展还处于比较雏形的时代,还未在其他国家大规模使用。因此,美国发布 ASCII 字符集的时候没有考虑兼容其他国家的语言\nASCII 字符集至今为止共定义了 128 个字符,其中有 33 个控制字符(比如回车、删除)无法显示\n一个 ASCII 码长度是一个字节也就是 8 个 bit,比如“a”对应的 ASCII 码是“01100001”。不过,最高位是 0 仅仅作为校验位,其余 7 位使用 0 和 1 进行组合,所以,ASCII 字符集可以定义 128(2^7)个字符\n由于,ASCII 码可以表示的字符实在是太少了。后来,人们对其进行了扩展得到了 ASCII 扩展字符集 。ASCII 扩展字符集使用 8 位(bits)表示一个字符,所以,ASCII 扩展字符集可以定义 256(2^8)个字符\n总共128个,下面少了33个无法显示的控制字符 GB2312 # 我们上面说了,ASCII 字符集是一种现代美国英语适用的字符集。因此,很多国家都捣鼓了一个适合自己国家语言的字符集。\nGB2312 字符集是一种对汉字比较友好的字符集,共收录 6700 多个汉字,基本涵盖了绝大部分常用汉字。不过,GB2312 字符集不支持绝大部分的生僻字和繁体字 (对于中英文字符,使用的字节数不一样 ( 1和2 ) )对于英语字符,GB2312 编码和 ASCII 码是相同的,1 字节编码即可。对于非英字符,需要 2 字节编码。 GBK # GBK 字符集可以看作是 GB2312 字符集的扩展,兼容 GB2312 字符集,共收录了 20000 多个汉字。\nGBK 中 K 是汉语拼音 Kuo Zhan(扩展)中的“Kuo”的首字母\nGB18030 # GB18030 完全兼容 GB2312 和 GBK 字符集,纳入中国国内少数民族的文字,且收录了日韩汉字,是目前为止最全面的汉字字符集,共收录汉字 70000 多个\nBIG5 # BIG5 主要针对的是繁体中文,收录了 13000 多个汉字。\nUnicode \u0026amp; UTF-8编码 # 了更加适合本国语言,诞生了很多种字符集。\n我们上面也说了不同的字符集可以表示的字符范围以及编码规则存在差异。这就导致了一个非常严重的问题:使用错误的编码方式查看一个包含字符的文件就会产生乱码现象。 就比如说你使用 UTF-8 编码方式打开 GB2312 编码格式的文件就会出现乱码。示例:“牛”这个汉字 GB2312 编码后的十六进制数值为 “C5A3”,而 “C5A3” 用 UTF-8 解码之后得到的却是 “ţ”。\n你可以通过这个网站在线进行编码和解码:https://www.haomeili.net/HanZi/ZiFuBianMaZhuanHuan 乱码的本质:编码和解码时用了不同或者不兼容的字符集\n如果我们能够有一种字符集将世界上所有的字符都纳入其中就好了,于是Unicode带着这个使命诞生了。\nUnicode 字符集中包含了世界上几乎所有已知的字符。不过,Unicode 字符集并没有规定如何存储这些字符(也就是如何使用二进制数据表示这些字符) 于是有了 UTF-8(8-bit Unicode Transformation Format)。类似的还有 UTF-16、 UTF-32\n其中,UTF-8 使用1-4个字节为每个字符编码,UTF-16使用2或4个字节为每个字符编码,UTF-32固定使用4个字节为每个字符编码\nUTF-8 可以根据不同的符号自动选择编码的长短,像英文字符只需要 1 个字节就够了,这一点 ASCII 字符集一样 。因此,对于英语字符,UTF-8 编码和 ASCII 码是相同的\nUTF-32 的规则最简单,不过缺陷也比较明显,对于英文字母这类字符消耗的空间是 UTF-8 的 4 倍之多。\nUTF-8 是目前使用最广的一种字符编码 MySQL字符集 # MySQL支持很多字符编码的方式,比如UTF-8,GB2312,GBK,BIG5\n使用SHOW CHARSET命令查看 通常情况下,我们建议使用UTF-8作为默认的字符编码方式\n然而,MySQL字符编码中有两套UTF-8编码实现\nutf-8:utf8编码只支持1-3个字节 。 在 utf8 编码中,中文是占 3 个字节,其他数字、英文、符号占一个字节。但 emoji 符号占 4 个字节,一些较复杂的文字、繁体字也是 4 个字节 utf8mb4 : UTF-8 的完整实现,正版!最多支持使用 4 个字节表示字符,因此,可以用来存储 emoji 符号 为何会有两套UTF-8编码实现,原因如下 因此,如果你需要存储emoji类型的数据或者一些比较复杂的文字、繁体字到 MySQL 数据库的话,数据库的编码一定要指定为utf8mb4 而不是utf8 ,要不然存储的时候就会报错了。 测试:\n环境,MySQL 5.7 + 建表语句: ,这里指定数据库CHARSET为utf8\nCREATE TABLE `user` ( `id` varchar(66) NOT NULL, `name` varchar(33) NOT NULL, `phone` varchar(33) DEFAULT NULL, `password` varchar(100) DEFAULT NULL ) ENGINE=InnoDB DEFAULT CHARSET=utf8; CREATE TABLE `user` ( `id` varchar(66) CHARACTER SET utf8mb4 NOT NULL, `name` varchar(33) CHARACTER SET utf8mb4 NOT NULL, `phone` varchar(33) CHARACTER SET utf8mb4 DEFAULT NULL, `password` varchar(100) CHARACTER SET utf8mb4 DEFAULT NULL ) ENGINE=InnoDB DEFAULT CHARSET=utf8; ------ 这边应该是写错了,如果是这个sql,是可以插入成功的 著作权归所有 原文链接:https://javaguide.cn/database/character-set.html 插入\nINSERT INTO `user` (`id`, `name`, `phone`, `password`) VALUES (\u0026#39;A00003\u0026#39;, \u0026#39;guide哥😘😘😘\u0026#39;, \u0026#39;181631312312\u0026#39;, \u0026#39;123456\u0026#39;); -- 报错 Incorrect string value: \u0026#39;\\xF0\\x9F\\x98\\x98\\xF0\\x9F...\u0026#39; for column \u0026#39;name\u0026#39; at row 1 "},{"id":305,"href":"/zh/docs/technology/Review/java_guide/cs_basics/data-structure/tree/","title":"树","section":"数据结构","content":" 转载自https://github.com/Snailclimb/JavaGuide(添加小部分笔记)感谢作者!\n树是一种类似现实生活中的树的数据结构(倒置的树)\n任何一颗非空树只有一个根节点\n一棵树具有以下特点:\n一棵树中的任何两个节点有且仅有唯一的一条路相通 (因为每个结点只会有一个父节点) 一棵树如果有n个节点,那么它一定恰好有n-1条边 一棵树不包括回路 下面是一颗二叉树 深度和高度是对应的;根节点所在层为1层\n常用概念\n节点:树中每个元素都可以统称为节点\n根节点:顶层节点,或者说没有父节点的节点。上图中A节点就是根节点\n父节点:若一个节点含有子节点,则这个节点称为其子节点的父节点。上图中的 B 节点是 D 节点、E 节点的父节点\n兄弟节点:具有相同父节点的节点互称为兄弟节点。上图中 D 节点、E 节点的共同父节点是 B 节点,故 D 和 E 为兄弟节点。\n叶子节点:没有子节点的节点。上图中的 D、F、H、I 都是叶子节点\n节点的高度**(跟叶子节点有关,同一层不一定一样):该节点到叶子节点的最长路径所包含的边数。\n节点的深度**(跟根节点有关,同一层是一样的):根节点到该节点的路径所包含的边数**\n节点的层数:节点的深度+1\n树的高度:根节点的高度\n二叉树的分类 # **二叉树(Binary tree)**是每个节点最多只有两个分支(即不存在分支度大于2的节点)的树结构 二叉树的分支,通常被称为左子树或右子树,并且,二叉树的分支具有左右次序,不能随意颠倒 二叉树的第i层至多拥有2^(i-1) 个节点\n深度为k的二叉树至多总共有 2^(k+1) -1 个节点 (深度为k,最多k + 1 层,最多为满二叉树的情况)\n至少有2^(k) 个节点,即 深度为k-1的二叉树的最多的节点再加1 (关于节点的深度的定义国内争议比较多,我个人比较认可维基百科对 节点深度的定义open in new window)。 满二叉树 # 一个二叉树,如果每一个层的结点数都达到最大值,则这个二叉树就是 满二叉树。也就是说,如果一个二叉树的层数为 K,且结点总数是(2^k) -1 ,则它就是 满二叉树。 完全二叉树 # 定义:除最后一层外,若其余层都是满的,并且最后一层或者是满的,或者是在右边缺少连续若干节点,则这个二叉树就是 完全二叉树 。\n大家可以想象为一棵树从根结点开始扩展,扩展完左子节点才能开始扩展右子节点,每扩展完一层,才能继续扩展下一层。如下图所示:\n从左到右,从上到下:\n完全二叉树的性质:父结点和子节点的序号有着对应关系\n细心的小伙伴可能发现了,当根节点的值为 1 的情况下,若父结点的序号是 i,那么左子节点的序号就是 2i,右子节点的序号是 2i+1。这个性质使得完全二叉树利用数组存储时可以极大地节省空间,以及利用序号找到某个节点的父结点和子节点,后续二叉树的存储会详细介绍。\n平衡二叉树 # 平衡二叉树是一颗二叉排序树,且具有以下性质\n可以是一棵空树 如果不是空树,那么左右两个子树的高度差的绝对值不超过1,并且左右两个子树都是一棵平衡二叉树 平衡二叉树的常用实现方法有 红黑树、AVL 树、替罪羊树、加权平衡树、伸展树 等。\n下面看一颗不太正常的树 这玩意儿还真叫树,只不过这棵树已经退化为一个链表了,我们管它叫 斜树。\n二叉树相比于链表,由于父子节点以及兄弟节点之间往往具有某种特殊的关系,这种关系使得我们在树中对数据进行搜索和修改时,相对于链表更加快捷便利。 如果二叉树退化为一个链表了,那么那么树所具有的优秀性质就难以表现出来,效率也会大打折,为了避免这样的情况,我们希望每个做 “家长”(父结点) 的,都 一碗水端平,分给左儿子和分给右儿子的尽可能一样多,相差最多不超过一层,如下图所示: 二叉树的存储 # 二叉树的存储主要分为链式存储和顺序存储 链式存储 # 和链表类似,二叉树的链式存储依靠指针将各个结点串联起来,不需要连续的存储空间 每个节点包括三个属性 数据data data不一定是单一的数据,根据情况不同,可以是多个具有不同类型的数据 左节点指针 left 右节点指针 right Java没有指针,而是直接引用对象 顺序存储 # 就是利用数组进行存储,数组中每一个位置仅存储结点的data,不存储左右子节点的指针,子节点的索引通过数组下标完成(类似堆) 根节点的序号为1,对于每个节点 Node,假设它存储在数组中下标为 i 的位置,那么它的左子节点就存储在 2i 的位置,它的右子节点存储在下标为 2i+1 的位置。 如图 存储如下数组,会发现问题:如果要存储的二叉树不是完全二叉树,在数组中就会出现空隙,导致内存利用率降低 二叉树的遍历 # 先序遍历 # 定义:先输出根节点,再遍历左子树,最后遍历右子树。\u0026lt;遍历左子树和右子树的时候,同样遵循先序遍历的规则\u0026gt;。也就是说,可以使用递归实现先序遍历\npublic void preOrder(TreeNode root){ if(root == null){ return; } system.out.println(root.data); preOrder(root.left); preOrder(root.right); } 中序遍历 # 定义:先递归中序遍历左子树,再输出根结点的值,再递归中序遍历右子树,大家可以想象成一巴掌把树压扁,父结点被拍到了左子节点和右子节点的中间(倒影、映射)\npublic void inOrder(TreeNode root){ if(root == null){ return; } inOrder(root.left); system.out.println(root.data); inOrder(root.right); } 如图所示 后续遍历 # 定义:先递归后序遍历左子树,再递归后序遍历右子树,最后输出根结点的值\n代码\npublic void postOrder(TreeNode root){ if(root == null){ return; } postOrder(root.left); postOrder(root.right); system.out.println(root.data); } 如图\n"},{"id":306,"href":"/zh/docs/technology/Review/java_guide/cs_basics/data-structure/heap/","title":"堆","section":"数据结构","content":" 转载自https://github.com/Snailclimb/JavaGuide(添加小部分笔记)感谢作者!\n什么是堆 # 堆是满足以下条件的树 堆中每一个节点值都大于等于(或小于等于)子树中所有节点。或者说,任意一个节点的值**都大于等于(或小于等于)**所有子节点的值\n大家可以把堆(最大堆)理解为一个公司,这个公司很公平,谁能力强谁就当老大,不存在弱的人当老大,老大手底下的人一定不会比他强。这样有助于理解后续堆的操作。\n堆不一定是完全二叉树,为了方便存储和索引,我们通常用完全二叉树的形式来表示堆\n广为人知的斐波那契堆和二项堆就不是完全二叉树,它们甚至都不是二叉树 (二叉)堆是一个数组,它可以被看成是一个近似的完全二叉树 下面给出的图是否是堆(通过定义)\n1,2是。 3不是。 堆的用途 # 当我们只关心所有数据中的最大值或者最小值,存在多次获取最大值或者最小值,多次插入或删除数据时,就可以使用堆。\n有小伙伴可能会想到用有序数组,初始化一个有序数组时间复杂度是 O(nlog(n))[也就是将一堆数字乱序排序,最快是O(nlog(n))],查找最大值或者最小值时间复杂度都是 O(1),但是,涉及到更新(插入或删除)数据时,时间复杂度为 O(n),即使是使用复杂度为 O(log(n)) 的二分法找到要插入或者删除的数据,在移动数据时也需要 O(n) 的时间复杂度。\n相对于有序数组而言,堆的主要优势在于更新数据效率较高\n堆的初始化时间复杂度为O(nlog(n)),堆可以做到O(1)的时间复杂度取出最大值或者最小值,O(log(n))的时间复杂度插入或者删除数据 堆的分类 # 堆分为最大堆和最小堆,二者的区别在于节点的排序方式 最大堆:堆中的每一个节点的值都大于子树中所有节点的值 最小堆:堆中的每一个节点的值都小于子树中所有节点的值 如图,图1是最大堆,图2是最小堆 堆的存储 # 由于完全二叉树的优秀性质,利用数组存储二叉树即节省空间,又方便索引(若根结点的序号为1,那么对于树中任意节点i,其左子节点序号为 2*i,右子节点序号为 2*i+1)。 为了方便存储和索引,(二叉)堆可以用完全二叉树的形式进行存储。存储的方式如下图所示 堆的操作 # 堆的更新操作主要包括两种:插入元素和删除堆顶元素\n堆是一个公平的公司,有能力的人自然会走到与他能力所匹配的位置\n插入元素 # 将要插入的元素放到最后 从底向上,如果父节点比该元素小,则该节点和父节点交换(其实就是一棵树有3个(最多)节点,与树上最大的节点比较) 直到无法交换(已经与根节点比较过) 删除堆顶元素 # 根据堆的性质可知,最大堆的堆盯元素为所有元素中最大的,最小堆的堆顶元素是所有元素中最小的\n当我们需要多次查找最大元素或者最小元素的时候,可以利用堆来实现\n删除堆顶元素后,为了保持堆的性质,需要对堆的结构进行调整,我们可以将这个过程称之为堆化\n自底向上的堆化,上述的插入元素所使用的,就是自顶向上的堆化,元素从最底部向上移动 自顶向下的堆化,元素由顶部向下移动。在讲解删除堆顶元素的方法时,我将阐述这两种操作的过程 自底向上堆化\n在堆这个公司中,会出现老大离职的现象,老大离职之后,它的位置就空出来了\n首先删除堆顶元素,使得数组中下标为1的位置空出 那么他的位置由谁来接替呢,当然是他的直接下属了,谁能力强就让谁上\n比较根节点(当前节点)的左子节点和右子节点,也就是下标为 2 ,3 的数组元素,将较大的元素填充到**根节点(下标为1)(当前遍历节点)**的位置 此时又空出一个位置了,老规矩,谁有能力谁上\n一直循环比较空出位置的左右子节点,并将较大者移至空位,直到堆的最底部 此时已经完成自顶向上的堆化,没有元素可以填补空缺。但会发现数组中出现了”气泡”,导致存户空间的浪费。\n解决办法:自顶向下堆化\n自顶向下堆化 自顶向下的堆化用一个词形容就是“石沉大海”\n第一件事情,就是把石头抬起来,从海面扔下去。这个石头就是堆的最后一个元素,我们将最后一个元素移动到堆顶。 将这个石头沉入海底,不停的与左右子节点的值进行比较,和较大的子节点交换位置,直到无法交换位置 结果 堆的操作总结 # 插入元素:先将元素放置数组末尾,再自底向上堆化,将末尾元素上浮\n删除堆顶元素:删除堆顶元素,将末尾元素放置堆顶,再自顶向下堆化,将堆顶元素下沉。\n也可以自底向上堆化,但是会产生气泡,浪费存储空间。不建议\n堆排序 # 堆排序的过程分两步\n建堆,将一个无序的数组,建立成堆 排序,[ 将堆顶元素取出,然后对剩下的元素堆化 ]。 反复迭代,直到所有元素被取出 建堆 # 也就是对所有非叶子结点进行自顶向下\n如图,红色区域分别是堆的情况下。对于T,如果只自顶向下到P、L这层,被换到了这层的那个元素是不一定就比其他树大的,所以还是要依次自顶向下\n这个构建堆操作的时间复杂度为O(n) 首先要了解哪些是非叶节点,最后一个结点的父节点及它(这个父节点)之前的元素,都是非叶节点。也就是说,如果节点个数为n,那么我们需要对n/2到1的节点进行自顶向下(沉底)堆化\n如图 首先将初始的无序数组抽象为一棵树,图中的节点个数为6,所以4,5,6是叶子节点,1,2,3节点为非叶节点 对1,2,3节点进行**自顶向下(沉底)**堆化,注意,顺序是从后往前堆化,从3号开始,一直到1号节点。 3号节点堆化结果\n2号节点堆化结果 1号节点堆化结果 排序 # 方法:由于堆顶元素是所有元素中最大的,所以我们重复取出堆顶元素,将这个最大的堆顶元素放至数组末尾,并对剩下的元素进行堆化即可 现在思考两个问题: 删除堆顶元素后需要执行**自顶向下(沉底)堆化还是自底向上(上浮)**堆化? 取出的堆顶元素存在哪,新建一个数组存? 答案 需要使用自顶向下(沉底)堆化,这个堆化一开始要将末尾元素移动至堆顶。由于这个时候末尾的位置已经空出来了由于堆中元素已经减小,这个位置不会再被使用,所以我们可以将取出的元素放在末尾。 其实是做了一次交换操作,将堆顶和末尾元素调换位置,从而将取出堆顶元素和**堆化的第一步(将末尾元素放至根结点位置)**进行合并 步骤 取出第一个元素并堆化 取出第2个元素并堆化 取出第3个元素并堆化 取出第4个元素并堆化 取出第5个元素并堆化 取出第6个元素并堆化 排序完成 "},{"id":307,"href":"/zh/docs/technology/Review/java_guide/cs_basics/data-structure/graph/","title":"图","section":"数据结构","content":" 转载自https://github.com/Snailclimb/JavaGuide(添加小部分笔记)感谢作者!\n图是一种较为复杂的非线性结构 线性数据结构的元素满足唯一的线性关系,每个元素(除第一个和最后一个外)只有一个直接前驱和一个直接后继 树形数据结构的元素之间有着明显的层级关系 图形结构的元素之间的关系是任意的 图就是由顶点的有穷非空集合和顶点之间的边组成的集合,通常表示为:G(V,E),其中,G表示一个图,V表示顶点的集合,E表示边的集合 下面显示的即图这种数据结构,而且还是一张有向图 图的基本概念 # 顶点 # 图中的数据元素,我们称之为顶点,图至少有一个顶点(有穷非空集合) 对应到好友关系图,每一个用户就代表一个顶点 边 # 顶点之间的关系用边表示 对应到好友关系图,两个用户是好友的话,那两者之间就存在一条边 度 # 度表示一个顶点包含多少条边 有向图中,分为出度和入度,出度表示从该顶点出去的边的条数,入度表示从进入该顶点的边的条数 对应到好友关系图,度就代表了某个人的好友数量 无向图和有向图 # 边表示的是顶点之间的关系,有的关系是双向的,比如同学关系,A是B的同学,那么B也肯定是A的同学,那么在表示A和B的关系时,就不用关注方向,用不带箭头的边表示,这样的图就是无向图。\n有的关系是有方向的,比如父子关系,师生关系,微博的关注关系,A是B的爸爸,但B肯定不是A的爸爸,A关注B,B不一定关注A。在这种情况下,我们就用带箭头的边表示二者的关系,这样的图就是有向图。\n无权图和带权图 # 对于一个关系,如果我们只关心关系的有无,而不关心关系有多强,那么就可以用无权图表示二者的关系。\n对于一个关系,如果我们既关心关系的有无,也关心关系的强度,比如描述地图上两个城市的关系,需要用到距离,那么就用带权图来表示,带权图中的每一条边一个数值表示权值,代表关系的强度。\n下图就是一个带权有向图。\n图的存储 # 邻接矩阵存储 # 邻接矩阵将图用二维矩阵存储,是一种比较直观的表示方式 如果第i个顶点和第j个顶点有关系,且关系权值为n,则A[i] [j] = n 在无向图中,我们只关心关系的有无,所以当顶点i和顶点j有关系时,A[i] [j]=1 ; 当顶点i和顶点j没有关系时,A[i] [j] = 0 ,如下图所示\n无向图的邻接矩阵是一个对称矩阵,因为在无向图中,顶点i和顶点j有关系,则顶点j和顶点i必有关系 有向图的邻接矩阵存储 邻接矩阵存储的方式优点是简单直接(直接使用一个二维数组即可),并且在获取两个顶点之间的关系的时候也非常高效*直接获取指定位置的数组元素。但是这种存储方式的确定啊也比较明显即 比较浪费空间 邻接表存储 # 针对上面邻接矩阵比较浪费内存空间的问题,诞生了图的另一种存储方法\u0026ndash;邻接表\n邻接链表使用一个链表来存储某个顶点的所有后继相邻顶点。对于图中每个顶点Vi ,把所有邻接于Vi 的顶点Vj 链接成一个单链表\n无向图的邻接表存储 有向图的邻接表存储 邻接表中存储的元素的个数(顶点数)以及图中边的条数\n无向图中,邻接表的元素个数等于边的条数的两倍,如下图 7条边,邻接表存储的元素个数为14 (即每条边存储了两次)\n有向图中,邻接表元素个数等于边的条数,如图所示的有向图中,边的条数为8,邻接表 图的搜索 # 广度优先搜索 # 广度优先搜索:像水面上的波纹一样,一层一层向外扩展,如图 具体实现方式,用到了队列,过程如下\n初始状态:将要搜索的源顶点放入队列 取出队首节点,输出0,将0的后继顶点(全部)(未访问过的)放入队列 取出队首节点,输出1,将1的后继顶点(所有)(未访问过的)放入队列 截止到第3步就很清楚了,就是输出最近的一个结点的全部关系节点\n取出队首节点,输出4,将4的后继顶点(未访问过的)放入队列 取出队首节点,输出2,将2的后继顶点(未访问过的)放入队列 取出队首节点,输出3,将3的后继顶点(未访问过的)放入队列,队列为空,结束 总结 先初始化首结点,之后不断从队列取出并将这个结点的有关系的结点 依次放入队列\n深度优先搜索 # 深度优先,即一条路走到黑。从源顶点开始,一直走到后继节点,才回溯到上一顶点,然后继续一条路走到黑 和广度优先搜索类似,深度优先搜索的具体实现,用到了另一种线性数据结构\u0026mdash;栈 初始状态,将要搜索的源顶点放入栈中 取出栈顶元素,输出0,将0的后继顶点(未访问过的)放入栈中 取出栈顶元素,输出4(因为后进先出),将4的后继顶点(未访问过的)放入栈中 取出栈顶元素,输出3,将3的后继顶点(未访问过的)放入栈中 其实到这部就非常明显了,即 前面元素的关系元素,大多都是被一直压在栈底的,会一直走走到 源顶点的直系关系顶点没有了,再往回走\n取出栈顶元素,输出2,将2的后继顶点(为访问过的)放入栈中 取出栈顶元素,输出1,将1的后继顶点(未访问过的)放入栈中,栈为空,结束 "},{"id":308,"href":"/zh/docs/technology/Review/java_guide/cs_basics/data-structure/linear-data-structure/","title":"线性数据结构","section":"数据结构","content":" 转载自https://github.com/Snailclimb/JavaGuide(添加小部分笔记)感谢作者!\n数组 # 数组(Array)是一种常见数据结构,由相同类型的元素(element)组成,并且是使用一块连续的内存来存储 直接可以利用元素的**索引(index)**可以计算出该元素对应的存储地址 数组的特点是:提供随机访问并且容量有限 假设数组长度为n:\n访问:O(1) //访问特定位置的元素\n插入:O(n) //最坏的情况插入在数组的首部并需要移动所有元素时\n删除:O(n) //最坏的情况发生在删除数组的开头并需要移动第一元素后面所有的元素时\n链表 # 链表简介 # 链表(LinkedList)虽然是一种线性表,但是并不会按线性的顺序存储数据,使用的不是连续的内存空间来存储数据\n链表的插入和删除操作的复杂度为O(1),只需要直到目标位置元素的上一个元素即可。但是,在查找一个节点或者访问特定位置的节点的时候复杂度为O(n)\n使用链表结构可以克服数组需要预先知道数据大小的缺点,链表结构可以充分利用计算机内存空间,实现灵活的内存动态管理\n但链表不会节省空间,相比于数组会占用更多空间,因为链表中每个节点存放的还有指向其他节点的指针。除此之外,链表不具有数组随机读取的优点\n链表分类 # 单链表、双向链表、循环链表、双向循环链表\n假设链表中有n个元素\n访问:O(n) //访问特地给位置的元素\n插入删除:O(1) //必须要知道插入元素的位置\n单链表 # 单链表只有一个方向,结点只有一个后继指针next指向后面的节点。因此,链表这种数据结构通常在物理内存上是不连续的 我们习惯性地把第一个结点叫做头结点,链表通常有一个不保存任何值的head节点(头结点),通过头结点我们可以遍历整个链表,尾结点通常指向null 如下图 循环链表 # 循环链表是一种特殊的单链表,和单链表不同的是循环链表的尾结点不是指向null,而是指向链表的头结点 如图 双向链表 # 双向链表包含两个指针,一个prev指向前一个节点,另一个next指向 如图 双向循环链表 # 双向循环链表的最后一个节点的next指向head,而head的prev指向最后一个节点,构成一个环\n应用场景 # 如果需要支持随机访问的话,链表无法做到 如果需要存储的数据元素个数不确定,并且需要经常添加和删除数据的话,使用链表比较合适 如果需要存储的数据元素的个数确定,并且不需要经常添加和删除数据的话,使用数组比较合适 数组 vs 链表 # 数组支持随机访问,链表不支持 数组使用的是连续内存空间 对CPU缓存机制友好,链表则相反 数组的大小固定,而链表则天然支持动态扩容。如果生命的数组过小,需要另外申请一个更大的内存空间存放数组元素,然后将原数组拷贝进去,这个操作比较耗时 栈 # 栈简介 # 栈(stack)只允许在有序的线性数据集合的一端(称为栈顶top)进行加入数据(push)和移除数据(pop)。因而按照**后进先出(LIFO,Last In First Out)**的原理运作。 栈中,push和pop的操作都发生在栈顶 栈常用一维数组或链表来实现,用数组实现的叫顺序栈,用链表实现的叫做链式栈 假设堆栈中有n个元素。 访问:O(n)//最坏情况 插入删除:O(1)//顶端插入和删除元素\n如图:\n栈的常见应用场景 # 当我们要处理的数据,只涉及在一端插入和删除数据,并且满足后进先出(LIFO,LastInFirstOut)的特性时,我们就可以使用栈这个数据结构。\n实现浏览器的回退和前进功能 # 我们只需要使用两个栈(Stack1 和 Stack2)和就能实现这个功能。比如你按顺序查看了 1,2,3,4 这四个页面,我们依次把 1,2,3,4 这四个页面压入 Stack1 中。当你想回头看 2 这个页面的时候,你点击回退按钮,我们依次把 4,3 这两个页面从 Stack1 弹出,然后压入 Stack2 中。假如你又想回到页面 3,你点击前进按钮,我们将 3 页面从 Stack2 弹出,然后压入到 Stack1 中。示例图如下\n检查符号是否承兑出现 # 给定一个只包括 '(',')','{','}','[',']' 的字符串,判断该字符串是否有效。\n有效字符串需满足:\n左括号必须用相同类型的右括号闭合。 左括号必须以正确的顺序闭合。 比如 \u0026ldquo;()\u0026quot;、\u0026rdquo;()[]{}\u0026quot;、\u0026quot;{[]}\u0026quot; 都是有效字符串,而 \u0026ldquo;(]\u0026rdquo; 、\u0026quot;([)]\u0026quot; 则不是。\n这个问题实际是 Leetcode 的一道题目,我们可以利用栈 Stack 来解决这个问题。\n首先我们将括号间的对应规则存放在 Map 中,这一点应该毋容置疑; 创建一个栈。遍历字符串,如果字符是左括号就直接加入stack中,否则将stack 的栈顶元素与这个括号做比较,如果不相等就直接返回 false。遍历结束,如果stack为空,返回 true。 public boolean isValid(String s){ // 括号之间的对应规则 HashMap\u0026lt;Character, Character\u0026gt; mappings = new HashMap\u0026lt;Character, Character\u0026gt;(); mappings.put(\u0026#39;)\u0026#39;, \u0026#39;(\u0026#39;); mappings.put(\u0026#39;}\u0026#39;, \u0026#39;{\u0026#39;); mappings.put(\u0026#39;]\u0026#39;, \u0026#39;[\u0026#39;); Stack\u0026lt;Character\u0026gt; stack = new Stack\u0026lt;Character\u0026gt;(); char[] chars = s.toCharArray(); for (int i = 0; i \u0026lt; chars.length; i++) { if (mappings.containsKey(chars[i])) { char topElement = stack.empty() ? \u0026#39;#\u0026#39; : stack.pop(); if (topElement != mappings.get(chars[i])) { return false; } } else { stack.push(chars[i]); } } return stack.isEmpty(); } 反转字符串 # 将字符串中的每个字符先入栈再出栈就可以了。\n维护函数调用 # 最后一个被调用的函数必须先完成执行,符合栈的 后进先出(LIFO, Last In First Out) 特性。\n栈的实现 # 栈既可以通过数组实现,也可以通过链表实现。两种情况下,入栈、出栈的时间复杂度均为O(1)\n下面使用数组下实现栈,具有push()、pop() (返回栈顶元素并出栈)、peek() (返回栈顶元素不出栈)、isEmpty() 、size() 这些基本的方法\n每次入栈前先判断栈容量是否够用,如果不够用就用Arrays.copyOf() 进行扩容\npublic class MyStack { private int[] storage;//存放栈中元素的数组 private int capacity;//栈的容量 private int count;//栈中元素数量 private static final int GROW_FACTOR = 2; //不带初始容量的构造方法。默认容量为8 public MyStack() { this.capacity = 8; this.storage=new int[8]; this.count = 0; } //带初始容量的构造方法 public MyStack(int initialCapacity) { if (initialCapacity \u0026lt; 1) throw new IllegalArgumentException(\u0026#34;Capacity too small.\u0026#34;); this.capacity = initialCapacity; this.storage = new int[initialCapacity]; this.count = 0; } //入栈 public void push(int value) { if (count == capacity) { ensureCapacity(); } storage[count++] = value; } //确保容量大小 private void ensureCapacity() { int newCapacity = capacity * GROW_FACTOR; storage = Arrays.copyOf(storage, newCapacity); capacity = newCapacity; } //返回栈顶元素并出栈 private int pop() { if (count == 0) throw new IllegalArgumentException(\u0026#34;Stack is empty.\u0026#34;); count--; return storage[count]; } //返回栈顶元素不出栈 private int peek() { if (count == 0){ throw new IllegalArgumentException(\u0026#34;Stack is empty.\u0026#34;); }else { return storage[count-1]; } } //判断栈是否为空 private boolean isEmpty() { return count == 0; } //返回栈中元素的个数 private int size() { return count; } } /*---- MyStack myStack = new MyStack(3); myStack.push(1); myStack.push(2); myStack.push(3); myStack.push(4); myStack.push(5); myStack.push(6); myStack.push(7); myStack.push(8); System.out.println(myStack.peek());//8 System.out.println(myStack.size());//8 for (int i = 0; i \u0026lt; 8; i++) { System.out.println(myStack.pop()); } System.out.println(myStack.isEmpty());//true myStack.pop();//报错:java.lang.IllegalArgumentException: Stack is empty. */ 队列 # 队列简介 # 队列是**先进先出(FIFO,First In,First Out)**的线性表\n通常用链表或数组来实现,用数组实现的队列叫做顺序队列,用链表实现的队列叫做链式队列。\n队列只允许在后端(rear)进行插入操作也就是入队enqueue,在前端(front)进行删除操作也就是出队 dequeue\n队列的操作方式和堆栈类似,唯一的区别在于队列只允许新数据在后端进行添加(不允许在后端删除)\n假设队列中有n个元素。 访问:O(n)//最坏情况 插入删除:O(1)//后端插入前端删除元素\n队列分类 # 单队列 # 这是常见的队列,每次添加元素时,都是添加到队尾。单队列又分为顺序队列(数组实现)和链式队列(链表实现)\n顺序队列存在假溢出:即明明有位置却不能添加\n假设下图是一个顺序队列,我们将前两个元素 1,2 出队,并入队两个元素 7,8。当进行入队、出队操作的时候,front 和 rear 都会持续往后移动,当 rear 移动到最后的时候,我们无法再往队列中添加数据,即使数组中还有空余空间,这种现象就是 ”假溢出“ 。除了假溢出问题之外,如下图所示,当添加元素 8 的时候,rear 指针移动到数组之外(越界)\n为了避免当只有一个元素的时候,队头和队尾重合使处理变得麻烦,所以引入两个指针,front 指针指向对头元素(不是头结点),rear 指针指向队列最后一个元素的下一个位置,这样当 front 等于 rear 时,此队列不是还剩一个元素,而是空队列。——From 《大话数据结构》\n(当只有一个元素时,front 指向0,rear指向1)\n循环队列 # 循环队列可以解决顺序队列的假溢出和越界问题。解决办法就是:从头开始,这样也就会形成头尾相接的循环,这也就是循环队列名字的由来。 (超出的时候,将rear指向0下标)。之后再添加时,向后移动即可\n顺序队列中,我们说 front==rear 的时候队列为空,循环队列中则不一样,也可能为满,如上图所示。解决办法有两种: 可以设置一个标志变量 flag,当 front==rear 并且 flag=0 的时候队列为空,当front==rear 并且 flag=1 的时候队列为满。 队列为空的时候就是 front==rear ,队列满的时候,我们保证数组还有一个空闲的位置,rear 就指向这个空闲位置,如下图所示,那么现在判断队列是否为满的条件就是: (rear+1) % QueueSize= front 。 其实也就是换一个定义罢了 常见应用场景 # 当我们需要按照一定顺序来处理数据的时候可以考虑使用队列这个数据结构\n阻塞队列: 阻塞队列可以看成在队列基础上加了阻塞操作的队列。当队列为空的时候,出队操作阻塞,当队列满的时候,入队操作阻塞。使用阻塞队列我们可以很容易**实现“生产者 - 消费者“**模型 线程池中的请求/任务队列: 线程池中没有空闲线程时,新的任务请求线程资源时,线程池该如何处理呢?答案是将这些请求放在队列中,当有空闲线程的时候,会循环中反复从队列中获取任务来执行。队列分为无界队列(基于链表)和有界队列(基于数组)。无界队列的特点就是可以一直入列,除非系统资源耗尽,比如 :FixedThreadPool 使用无界队列 LinkedBlockingQueue。但是有界队列就不一样了,当队列满的话后面再有任务/请求就会拒绝,在 Java 中的体现就是会抛出**java.util.concurrent.RejectedExecutionException** 异常。 Linux 内核进程队列(按优先级排队) 现实生活中的派对,播放器上的播放列表; 消息队列 等等\u0026hellip;\u0026hellip; "},{"id":309,"href":"/zh/docs/technology/Review/java_guide/database/ly0501lybasis/","title":"数据库基础","section":"数据库","content":" 转载自https://github.com/Snailclimb/JavaGuide(添加小部分笔记)感谢作者!\n这部分内容由于涉及太多概念性内容,所以参考了维基百科和百度百科相应的介绍。\n什么是数据库,数据库管理系统,数据库系统,数据库管理员 # 数据库:数据库(DataBase 简称DB)就是信息的集合或者说数据库管理系统管理的数据的集合。 数据库管理系统:数据库管理系统(Database Management System 简称DBMS)是一种操纵和管理数据库的大型软件,通常用于建立、使用和维护 数据库。 数据库系统(范围最大):数据库系统(Data Base System,简称DBS)通常由**软件、数据和数据管理员(DBA)**组成。 数据库管理员:数据库管理员(Database Adminitrator,简称DBA)负责全面管理和控制数据库系统 (是一个人) 数据库系统基本构成如下图所示\n什么是元组,码,候选码,主码,外码,主属性,非主属性 # 元组:元组(tuple)是关系数据库中的基本概念,关系是一张表,表中的每行(即数据库中的每条记录)就是一个元组,每列就是一个属性。在二维表里,元组也成为行 码:码就是能唯一标识实体的属性,对应表中的列 候选码:若关系中的某一属性或属性组的值能唯一的标识一个元组,而其任何、子集都不能再标识,则称该属性组为候选码。例如:在学生实体中,“学号”是能唯一的区分学生实体的,同时又假设“姓名”、“班级”的属性组合足以区分学生实体,那么**{学号}和{姓名,班级}都是候选码**。 主码:主码也叫主键,主码是从候选码中选出来的。一个实体集中只能有一个主码,但可以有多个候选码 外码:外码也叫外键。如果一个关系中的一个属性是另外一个关系中的主码则这个属性为外码。 主属性 : 候选码中出现过的属性称为主属性(这里强调单个)。比如关系 工人(工号,身份证号,姓名,性别,部门). 显然工号和身份证号都能够唯一标示这个关系,所以都是候选码。工号、身份证号这两个属性就是主属性。如果主码是一个属性组,那么属性组中的属性都是主属性。 非主属性: 不包含在任何一个候选码中的属性称为非主属性。比如在关系——学生(学号,姓名,年龄,性别,班级)中,主码是“学号”,那么其他的“姓名”、“年龄”、“性别”、“班级”就都可以称为非主属性。 主键和外键有什么区别 # 主键(主码) :主键用于唯一标识一个元组,不能有重复,不允许为空。一个表只能有一个主键。 外键(外码) :外键用来和其他表建立联系用,外键是另一表的主键,外键是可以有重复的,可以是空值。一个表可以有多个外键 为什么不推荐使用外键与级联 # 对于外键和级联,阿里巴巴开发手册这样说道\n【强制】不得使用外键与级联,一切外键概念必须在应用层解决。\n说明: 以学生和成绩的关系为例,学生表中的 student_id 是主键,那么成绩表中的 student_id 则为外键。如果更新学生表中的 student_id,同时触发成绩表中的 student_id 更新,即为级联更新。\n缺点: 外键与级联更新适用于单机低并发,不适合分布式、高并发集群; 级联更新是强阻塞,存在数据库更新风暴的风 险; 外键影响数据库的插入速度\n为什么不要使用外键\n增加了复杂性\na. 每次做DELETE 或者UPDATE都必须考虑外键约束,会导致开发的时候很痛苦, 测试数据极为不方便; b. 外键的主从关系是定的,假如那天需求有变化,数据库中的这个字段根本不需要和其他表有关联的话就会增加很多麻烦。\n增加了额外操作\n数据库需要增加维护外键的工作,比如当我们做一些涉及外键字段的增,删,更新操作之后,需要触发相关操作去检查,保证数据的的一致性和正确性,这样会不得不消耗资源;(个人觉得这个不是不用外键的原因,因为即使你不使用外键,你在应用层面也还是要保证的。所以,我觉得这个影响可以忽略不计。)\n对分库分表很不友好:因为分库分表下外键无法生效\n\u0026hellip;\n外键的一些好处\n保证了数据库数据的一致性和完整性; 级联操作方便,减轻了程序代码量; \u0026hellip;\u0026hellip; 如果系统不涉及分库分表,并发量不是很高的情况还是可以考虑使用外键的\n什么是ER图 # 做一个项目的时候一定要试着画 ER 图来捋清数据库设计,这个也是面试官问你项目的时候经常会被问道的。\nE-R图,也称 实体-联系图(Entity Relationship Diagram),提供表示实体类型、属性和关系,用来描述现实世界的概念模型。它是描述现实世界关系概念模型的有效方法,是表示概念关系模型的一种方式 下图是一个学生选课的 ER 图,每个学生可以选若干门课程,同一门课程也可以被若干人选择,所以它们之间的关系是多对多(M: N)。另外,还有其他两种关系是:1 对 1(1:1)、1 对多(1: N) 将ER图转换成数据库实际的关系模型(实际设计中,我们通常会将任课教师也作为一个实体来处理)\n数据库范式了解吗 # 1NF(第一范式) 属性(对应于表中的字段)不能再被分割,也就是这个字段只能是一个值,不能再分为多个其他的字段了。1NF 是所有关系型数据库的最基本要求 ,也就是说关系型数据库中创建的表一定满足第一范式。\n2NF(第二范式) 2NF 在 1NF 的基础之上,消除了非主属性对于码的部分函数依赖。如下图所示,展示了第一范式到第二范式的过渡。第二范式在第一范式的基础上增加了一个列,这个列称为主键,非主属性都依赖于主键。\n第二范式要求,在满足第二范式的基础上,还要满足数据表里得每一条数据记录,都是可唯一标识的。而且所有非主键字段,都必须完全依赖主键,不能只依赖主键的一部分,如下,主键为商品名称、供应商名称,是主码是属性组。而供应商电话只依赖于供应商id,商品价格只依赖于价格。所以不满足第二范式\n3NF(第三范式)3NF 在 2NF 的基础之上,消除了非主属性对于码的传递函数依赖 。符合 3NF 要求的数据库设计,基本上解决了数据冗余过大,插入异常,修改异常,删除异常的问题。比如在关系 R(学号 , 姓名, 系名,系主任)中,学号 → 系名,系名 → 系主任,所以存在非主属性系主任对于学号的传递函数依赖,所以该表的设计,不符合 3NF 的要求。\n确保数据表中的每一个非主键字段都和主键字段相关,也就是说,要求数据表中的所有非主键字段不能依赖于其他非主键字段。(即,不能存在非主属性A依赖于非主属性B,非主属性B依赖于主键C的情况,即存在A \u0026ndash;\u0026gt; B \u0026ndash;\u0026gt; C 的决定关系),规则的意思是所有非主键属性之间不能有依赖关系,必须互相独立\n简单举例:\n部门信息表:每个部门有部门编号(dept_id)、部门名称、部门简介等消息\n员工信息表:每个员工有员工编号、姓名、部门编号。(注意,列出部门编号就不能再将部门名称、部门简介等部门相关的信息再加入员工信息表中,否则将不满足第3范式(但其实是满足第二范式的))\n总结\n1NF:属性不可再分。 2NF:1NF 的基础之上,消除了非主属性对于码的部分函数依赖。 3NF:3NF 在 2NF 的基础之上,消除了非主属性对于码的传递函数依赖 。 一些概念:\n函数依赖(functional dependency) :若在一张表中,在属性(或属性组)X 的值确定的情况下,必定能确定属性 Y 的值,那么就可以说 Y 函数依赖于 X,写作 X → Y。 部分函数依赖(partial functional dependency) :如果 X→Y,并且存在 X 的一个真子集 X0,使得 X0→Y,则称 Y 对 X 部分函数依赖。比如学生基本信息表 R 中(学号,身份证号,姓名)当然学号属性取值是唯一的,在 R 关系中,(学号,身份证号)-\u0026gt;(姓名),(学号)-\u0026gt;(姓名),(身份证号)-\u0026gt;(姓名);所以姓名部分函数依赖与(学号,身份证号);(感觉这个例子虽然是对的,但是不利于理解第二范式) 完全函数依赖(Full functional dependency) :在一个关系中,若某个非主属性数据项依赖于全部关键字称之为完全函数依赖。比如学生基本信息表 R(学号,班级,姓名)假设不同的班级学号有相同的,班级内学号不能相同,在 R 关系中,(学号,班级)-\u0026gt;(姓名),但是(学号)-\u0026gt;(姓名)不成立,(班级)-\u0026gt;(姓名)不成立,所以姓名完全函数依赖与(学号,班级); 传递函数依赖 : 在关系模式 R(U)中,设 X,Y,Z 是 U 的不同的属性子集,如果 X 确定 Y、Y 确定 Z,且有 X 不包含 Y,Y 不确定 X,(X∪Y)∩Z=空集合,则称 Z 传递函数依赖(transitive functional dependency) 于 X。传递函数依赖会导致数据冗余和异常。传递函数依赖的 Y 和 Z 子集往往同属于某一个事物,因此可将其合并放到一个表中。比如在关系 R(学号 , 姓名, 系名,系主任)中,学号 → 系名,系名 → 系主任,所以存在非主属性系主任对于学号的传递函数依赖。。 什么是存储过程 # 作用:我们可以把存储过程看成是一些 SQL 语句的集合,中间加了点逻辑控制语句。存储过程在业务比较复杂的时候是非常实用的,比如很多时候我们完成一个操作可能需要写一大串 SQL 语句,这时候我们就可以写有一个存储过程,这样也方便了我们下一次的调用。存储过程一旦调试完成通过后就能稳定运行,另外,使用存储过程比单纯 SQL 语句执行要快,因为存储过程是预编译过的。\n存储过程在互联网公司应用不多,因为存储过程难以调试和扩展,而且没有移植性,还会消耗数据库资源\n阿里巴巴Java开发手册要求禁止使用存储过程 drop、delete与truncate区别 # 用法不同 # drop(丢弃数据): drop table 表名 ,直接将表都删除掉,在删除表的时候使用。 truncate (清空数据) : truncate table 表名 ,只删除表中的数据,再插入数据的时候自增长 id 又从 1 开始,在清空表中数据的时候使用。 delete(删除数据) : delete from 表名 where 列名=值,删除某一行的数据,如果不加 where 子句和truncate table 表名作用类似。 truncate 和不带 where 子句的 delete、以及 drop 都会删除表内的数据,但是 truncate 和 delete 只删除数据不删除表的结构(定义),执行 drop 语句,此表的结构也会删除,也就是执行 drop 之后对应的表不复存在。\n属于不同的数据库语言 # truncate 和 drop 属于 DDL(数据定义语言)语句,操作立即生效,原数据不放到 rollback segment 中,不能回滚,操作不触发 trigger。而 **delete 语句是 DML (数据库操作语言)**语句,这个操作会放到 rollback segement 中,事务提交之后才生效。 DML语句和DDL语句区别 DML 是**数据库操作语言(Data Manipulation Language)**的缩写,是指对数据库中表记录的操作,主要包括表记录的插入(insert)、更新(update)、删除(delete)和查询(select),是开发人员日常使用最频繁的操作。 **DDL (Data Definition Language)**是数据定义语言的缩写,简单来说,就是对数据库内部的对象进行创建、删除、修改的操作语言。它和 DML 语言的最大区别是 DML 只是对表内部数据的操作,而不涉及到表的定义、结构的修改,更不会涉及到其他对象。DDL 语句更多的被数据库管理员(DBA)所使用,一般的开发人员很少使用。 由于select不会对表进行破坏,所以有的地方也会把select单独区分开叫做数据库查询语言DQL(Data Query Language) 执行速度不同 # 一般来说:drop \u0026gt; truncate \u0026gt; delete(这个我没有设计测试过)\ndelete命令执行的时候会产生数据库的binlog日志,而日志记录是需要消耗时间的,但是也有个好处方便数据回滚恢复。\ntruncate命令执行的时候不会产生数据库日志,因此比delete要快。除此之外,还会把表的自增值重置和索引恢复到初始大小等。\ndrop命令会把表占用的空间全部释放掉。\nTips:你应该更多地关注在使用场景上,而不是执行效率。\n数据库设计通常分为哪几步 # 需求分析 : 分析用户的需求,包括数据、功能和性能需求。 概念结构设计 : 主要采用 E-R 模型进行设计,包括画 E-R 图。 逻辑结构设计 : 通过将 E-R 图转换成表,实现从 E-R 模型到关系模型的转换。 物理结构设计 : 主要是为所设计的数据库选择合适的存储结构和存取路径。 数据库实施 : 包括编程、测试和试运行 数据库的运行和维护 : 系统的运行与数据库的日常维护。 "},{"id":310,"href":"/zh/docs/technology/Review/java_guide/java/JVM/ly0408lyjdk-monitoring-and-troubleshooting-tools/","title":"jvm监控和故障处理工具 总结","section":"Java基础","content":" 转载自https://github.com/Snailclimb/JavaGuide(添加小部分笔记)感谢作者!\nJDK 命令行工具 # 这些命令在 JDK 安装目录下的 bin 目录下:\njps (JVM Process Status): 类似 UNIX 的 ps 命令。用于查看所有 Java 进程的启动类、传入参数和 Java 虚拟机参数等信息; jstat(JVM Statistics Monitoring Tool): 用于收集 HotSpot 虚拟机各方面的运行数据; jinfo (Configuration Info for Java) : Configuration Info for Java,显示虚拟机配置信息; jmap (Memory Map for Java) : 生成堆转储快照; jhat (JVM Heap Dump Browser) : 用于分析 heapdump 文件,它会建立一个 HTTP/HTML 服务器,让用户可以在浏览器上查看分析结果; jstack (Stack Trace for Java) : 生成虚拟机当前时刻的线程快照,线程快照就是当前虚拟机内每一条线程正在执行的方法堆栈的集合。 jps: 查看所有 Java 进程 # jps(JVM Process Status) 命令类似 UNIX 的 ps 命令。\njps:显示虚拟机执行主类名称以及这些进程的本地虚拟机唯一 ID(Local Virtual Machine Identifier,LVMID)。jps -q :只输出进程的本地虚拟机唯一 ID。\nC:\\Users\\SnailClimb\u0026gt;jps 7360 NettyClient2 17396 7972 Launcher 16504 Jps 17340 NettyServer jps -l:输出主类的全名,如果进程执行的是 Jar 包,输出 Jar 路径。\nC:\\Users\\SnailClimb\u0026gt;jps -l 7360 firstNettyDemo.NettyClient2 17396 7972 org.jetbrains.jps.cmdline.Launcher 16492 sun.tools.jps.Jps 17340 firstNettyDemo.NettyServer jps -v:输出虚拟机进程启动时 JVM 参数。\njps -m:输出传递给 Java 进程 main() 函数的参数。\njstat:监视虚拟机各种运行状态信息 # jstat ( JVM Statistics Monitoring Tool ) 使用于监视虚拟机各种运行状态信息的命令行工具。\n可以显示本地或者远程(需要远程主机提供RMI支持)虚拟机进程中的类信息、内存、垃圾收集、JIT编译等运行数据,在没有GUI,只提供了纯文本控制台环境的服务器上,它将是运行期间定位虚拟机性能问题的首选工具\njstat 命令使用格式\njstat -\u0026lt;option\u0026gt; [-t] [-h\u0026lt;lines\u0026gt;] \u0026lt;vmid\u0026gt; [\u0026lt;interval\u0026gt; [\u0026lt;count\u0026gt;]] 比如 jstat -gc -h3 31736 1000 10表示分析进程 id 为 31736 的 gc 情况,每隔 1000ms 打印一次记录,打印 10 次停止,每 3 行后打印指标头部。\nλ jstat -gc -h3 12224 1000 10 常见的option如下 , 下面的vmid,即vm的id (id值)\njstat -class vmid :显示 ClassLoader 的相关信息; jstat -compiler vmid :显示 JIT 编译的相关信息; jstat -gc vmid :显示与 GC 相关的堆信息; jstat -gccapacity vmid :显示各个代的容量及使用情况; jstat -gcnew vmid :显示新生代信息; jstat -gcnewcapcacity vmid :显示新生代大小与使用情况; jstat -gcold vmid :显示老年代和永久代的行为统计,从jdk1.8开始,该选项仅表示老年代,因为永久代被移除了; jstat -gcoldcapacity vmid :显示老年代的大小; jstat -gcpermcapacity vmid :显示永久代大小,从jdk1.8开始,该选项不存在了,因为永久代被移除了; jstat -gcutil vmid :显示垃圾收集信息 使用jstat -gcutil -h3 12224 1000 10\n另外,加上 -t参数可以在输出信息上加一个 Timestamp 列,显示程序的运行时间。 各个参数的含义\njinfo:实时地查看和调整虚拟机各项参数 # jinfo vmid :输出当前 jvm 进程的全部参数和系统属性 (第一部分是系统的属性,第二部分是 JVM 的参数)。 如下图: jinfo -flag name vmid :输出对应名称的参数的具体值。比如输出 MaxHeapSize、查看当前 jvm 进程是否开启打印 GC 日志 ( -XX:PrintGCDetails :详细 GC 日志模式,这两个都是默认关闭的)。\nC:\\Users\\SnailClimb\u0026gt;jinfo -flag MaxHeapSize 17340 -XX:MaxHeapSize=2124414976 C:\\Users\\SnailClimb\u0026gt;jinfo -flag PrintGC 17340 -XX:-PrintGC 使用 jinfo 可以在不重启虚拟机的情况下,可以动态的修改 jvm 的参数。尤其在线上的环境特别有用,请看下面的例子: 使用```jinfo -flag [+|-]name vmid 开启或者关闭对应名称的参数:\nC:\\Users\\SnailClimb\u0026gt;jinfo -flag PrintGC 17340 -XX:-PrintGC C:\\Users\\SnailClimb\u0026gt;jinfo -flag +PrintGC 17340 C:\\Users\\SnailClimb\u0026gt;jinfo -flag PrintGC 17340 -XX:+PrintGC jmap:生成堆转储快照 # jmap(Memory Map for Java )命令用于生成堆转储快照。如果不使用jmap命令,要想获取java堆转储,可以使用-XX:+HeapDumpOutOfMemoryError参数,可以让虚拟机在OOM异常出现之后,自动生成dump文件,Linux命令下通过kill -3发送进程推出信号也能拿到dump文件\njmap 的作用并不仅仅是为了获取 dump 文件,它还可以查询 finalizer 执行队列、Java 堆和永久代的详细信息,如空间使用率、当前使用的是哪种收集器等。和jinfo一样,jmap有不少功能在 Windows 平台下也是受限制的。\n将指定应用程序的堆 快照输出到桌面,后面可以通过jhat、Visual VM等工具分析该堆文件\nC:\\Users\\SnailClimb\u0026gt;jmap -dump:format=b,file=C:\\Users\\SnailClimb\\Desktop\\heap.hprof 17340 Dumping heap to C:\\Users\\SnailClimb\\Desktop\\heap.hprof ... Heap dump file created jhat:分析heapdump文件 # jhat 用于分析 heapdump 文件,它会建立一个 HTTP/HTML 服务器,让用户可以在浏览器上查看分析结果。\nC:\\Users\\SnailClimb\u0026gt;jhat C:\\Users\\SnailClimb\\Desktop\\heap.hprof Reading from C:\\Users\\SnailClimb\\Desktop\\heap.hprof... Dump file created Sat May 04 12:30:31 CST 2019 Snapshot read, resolving... Resolving 131419 objects... Chasing references, expect 26 dots.......................... Eliminating duplicate references.......................... Snapshot resolved. Started HTTP server on port 7000 Server is ready. 之后访问 http://localhost:7000/ 即可,如下: 进入/histo 会发现,有这个东西 这个对象创建了9次,因为我是在第9次循环后dump堆快照的\n//测试代码如下 public class MyMain { private byte[] x = new byte[10 * 1024 * 1024];//10M public static void main(String[] args) throws InterruptedException { System.out.println(\u0026#34;开始循环--\u0026#34;); int i=0; while (++i\u0026gt;0) { String a=new Date().toString(); MyMain myMain = new MyMain(); System.out.println(i+\u0026#34;循环中---\u0026#34; + new Date()); TimeUnit.SECONDS.sleep(10); } } } jstack: 生成虚拟机当前时刻的线程快照 # jstack (Stack Trace for Java ) 命令用于生成虚拟机当前时刻的线程快照。线程快照就是当前虚拟机内每一条线程正在执行的方法堆栈的集合\n生成线程快照的目的主要是定位线程长时间出现停顿的原因,如线程间死锁、死循环、请求外部资源导致的长时间等待等都是导致线程长时间停顿的原因。线程出现停顿的时候通过jstack来查看各个线程的调用堆栈,就可以知道没有响应的线程到底在后台做些什么事情,或者在等待些什么资源。\n线程死锁的代码,通过jstack 命令进行死锁检查,输出死锁信息,找到发生死锁的线程\npackage com.jvm; public class DeadLockDemo { private static Object resource1 = new Object();//资源 1 private static Object resource2 = new Object();//资源 2 public static void main(String[] args) { new Thread(() -\u0026gt; { synchronized (resource1) { System.out.println(Thread.currentThread() + \u0026#34;get resource1\u0026#34;); try { Thread.sleep(1000); } catch (InterruptedException e) { e.printStackTrace(); } System.out.println(Thread.currentThread() + \u0026#34;waiting get resource2\u0026#34;); synchronized (resource2) { System.out.println(Thread.currentThread() + \u0026#34;get resource2\u0026#34;); } } }, \u0026#34;线程 1\u0026#34;).start(); new Thread(() -\u0026gt; { synchronized (resource2) { System.out.println(Thread.currentThread() + \u0026#34;get resource2\u0026#34;); try { Thread.sleep(1000); } catch (InterruptedException e) { e.printStackTrace(); } System.out.println(Thread.currentThread() + \u0026#34;waiting get resource1\u0026#34;); synchronized (resource1) { System.out.println(Thread.currentThread() + \u0026#34;get resource1\u0026#34;); } } }, \u0026#34;线程 2\u0026#34;).start(); } } /*------ Thread[线程 1,5,main]get resource1 Thread[线程 2,5,main]get resource2 Thread[线程 2,5,main]waiting get resource1 Thread[线程 1,5,main]waiting get resource2 */ 分析 线程 A 通过 synchronized (resource1) 获得 resource1 的监视器锁,然后通过 Thread.sleep(1000);让线程 A 休眠 1s 为的是让线程 B 得到执行然后获取到 resource2 的监视器锁。线程 A 和线程 B 休眠结束了都开始企图请求获取对方的资源,然后这两个线程就会陷入互相等待的状态,这也就产生了死锁。\n通过jstack 命令分析\n# 先使用jps 找到思索地那个类 C:\\Users\\SnailClimb\u0026gt;jps 13792 KotlinCompileDaemon 7360 NettyClient2 17396 7972 Launcher 8932 Launcher 9256 DeadLockDemo 10764 Jps 17340 NettyServer ## 然后使用jstack命令分析 C:\\Users\\SnailClimb\u0026gt;jstack 9256 输出的部分如下\nFound one Java-level deadlock: ============================= \u0026#34;线程 2\u0026#34;: waiting to lock monitor 0x000000000333e668 (object 0x00000000d5efe1c0, a java.lang.Object), which is held by \u0026#34;线程 1\u0026#34; \u0026#34;线程 1\u0026#34;: waiting to lock monitor 0x000000000333be88 (object 0x00000000d5efe1d0, a java.lang.Object), which is held by \u0026#34;线程 2\u0026#34; Java stack information for the threads listed above: =================================================== \u0026#34;线程 2\u0026#34;: at DeadLockDemo.lambda$main$1(DeadLockDemo.java:31) - waiting to lock \u0026lt;0x00000000d5efe1c0\u0026gt; (a java.lang.Object) - locked \u0026lt;0x00000000d5efe1d0\u0026gt; (a java.lang.Object) at DeadLockDemo$$Lambda$2/1078694789.run(Unknown Source) at java.lang.Thread.run(Thread.java:748) \u0026#34;线程 1\u0026#34;: at DeadLockDemo.lambda$main$0(DeadLockDemo.java:16) - waiting to lock \u0026lt;0x00000000d5efe1d0\u0026gt; (a java.lang.Object) - locked \u0026lt;0x00000000d5efe1c0\u0026gt; (a java.lang.Object) at DeadLockDemo$$Lambda$1/1324119927.run(Unknown Source) at java.lang.Thread.run(Thread.java:748) Found 1 deadlock. 找到了发生死锁的线程的具体信息\nJDK可视化分析工具 # JConsole:Java监视与管理控制台 # JConsole 是基于 JMX 的可视化监视、管理工具。可以很方便的监视本地及远程服务器的 java 进程的内存使用情况。你可以在控制台输出**console命令启动或者在 JDK 目录下的 bin 目录找到jconsole.exe然后双击启动**. 对于远程连接\n在启动方\n-Djava.rmi.server.hostname=外网访问 ip 地址 -Dcom.sun.management.jmxremote.port=60001 //监控的端口号 -Dcom.sun.management.jmxremote.authenticate=false //关闭认证 -Dcom.sun.management.jmxremote.ssl=false 实例:\njava -Djava.rmi.server.hostname=192.168.200.200 -Dcom.sun.management.jmxremote -Dcom.sun.management.jmxremote.port=60001 -Dcom.sun.management.jmxremote.ssl=false -Dcom.sun.management.jmxremote.authenticate=false com.jvm.DeadLockDemo # 其中 192.168.200.200 为启动该类的机器的ip,而不是谁要连接 在使用 JConsole 连接时,远程进程地址如下:\n外网访问 ip 地址:60001 注意,虚拟机中(这里ip xxx.200是虚拟机ip),需要开放的端口不只是60001,还要通过 netstat -nltp开放另外两个端口 centos中使用\nfirewall-cmd --zone=public --add-port=45443/tcp --permanent firewall-cmd --zone=public --add-port=36521/tcp --permanent firewall-cmd --zone=public --add-port=60001/tcp --permanent firewall-cmd --reload #重启firewall 之后才能连接上\n内存监控 # JConsole 可以显示当前内存的详细信息。不仅包括堆内存/非堆内存的整体信息,还可以细化到 eden 区、survivor 区等的使用情况,如下图所示。\n点击右边的“执行 GC(G)”按钮可以强制应用程序执行一个 Full GC。\n新生代 GC(Minor GC):指发生新生代的的垃圾收集动作,Minor GC 非常频繁,回收速度一般也比较快。\n老年代 GC(Major GC/Full GC):指发生在老年代的 GC,出现了 Major GC 经常会伴随至少一次的 Minor GC(并非绝对),Major GC 的速度一般会比 Minor GC 的慢 10 倍以上。\n线程监控 # 类似我们前面讲的 jstack 命令,不过这个是可视化的。\n最下面有一个\u0026quot;检测死锁 (D)\u0026ldquo;按钮,点击这个按钮可以自动为你找到发生死锁的线程以及它们的详细信息 。 VisualVM: 多合一故障处理工具 # VisualVM 提供在 Java 虚拟机 (Java Virutal Machine, JVM) 上运行的 Java 应用程序的详细信息。在 VisualVM 的图形用户界面中,您可以方便、快捷地查看多个 Java 应用程序的相关信息。Visual VM 官网: https://visualvm.github.io/open in new window 。Visual VM 中文文档: https://visualvm.github.io/documentation.htmlopen in new window。\n下面这段话摘自《深入理解 Java 虚拟机》。\nVisualVM(All-in-One Java Troubleshooting Tool)是到目前为止随 JDK 发布的功能最强大的运行监视和故障处理程序,官方在 VisualVM 的软件说明中写上了“All-in-One”的描述字样,预示着他除了运行监视、故障处理外,还提供了很多其他方面的功能,如性能分析(Profiling)。VisualVM 的性能分析功能甚至比起 JProfiler、YourKit 等专业且收费的 Profiling 工具都不会逊色多少,而且 VisualVM 还有一个很大的优点:不需要被监视的程序基于特殊 Agent 运行,因此他对应用程序的实际性能的影响很小,使得他可以直接应用在生产环境中。这个优点是 JProfiler、YourKit 等工具无法与之媲美的。\nVisualVM 基于 NetBeans 平台开发,因此他一开始就具备了插件扩展功能的特性,通过插件扩展支持,VisualVM 可以做到:\n显示虚拟机进程以及进程的配置、环境信息(jps、jinfo)。 监视应用程序的 CPU、GC、堆、方法区以及线程的信息(jstat、jstack)。 dump 以及分析堆转储快照(jmap、jhat)。 方法级的程序运行性能分析,找到被调用最多、运行时间最长的方法。 离线程序快照:收集程序的运行时配置、线程 dump、内存 dump 等信息建立一个快照,可以将快照发送开发者处进行 Bug 反馈。 其他 plugins 的无限的可能性\u0026hellip;\u0026hellip; 这里就不具体介绍 VisualVM 的使用,如果想了解的话可以看:\nhttps://visualvm.github.io/documentation.htmlopen in new window https://www.ibm.com/developerworks/cn/java/j-lo-visualvm/index.html "},{"id":311,"href":"/zh/docs/technology/Review/java_guide/java/JVM/ly0406lyjvm-params/","title":"jvm参数","section":"Java基础","content":" 转载自https://github.com/Snailclimb/JavaGuide(添加小部分笔记)感谢作者!\n本文由 JavaGuide 翻译自 https://www.baeldung.com/jvm-parametersopen in new window,并对文章进行了大量的完善补充。翻译不易,如需转载请注明出处,作者: baeldungopen in new window 。\n概述 # 本篇文章中,将掌握最常用的JVM参数配置。下面提到了一些概念,堆、方法区、垃圾回收等。\n堆内存相关 # Java 虚拟机所管理的内存中最大的一块,Java 堆是所有线程共享的一块内存区域,在虚拟机启动时创建。此内存区域的唯一目的就是存放对象实例,几乎 所有的对象实例以及数组都在这里分配内存。\n显式指定堆内存-Xms和-Xmx # 与性能相关的最常见实践之一是根据应用程序要求初始化堆内存。\n如果我们需要指定最小和最大堆大小(推荐显示指定大小):\n-Xms\u0026lt;heap size\u0026gt;[unit] -Xmx\u0026lt;heap size\u0026gt;[unit] heap size 表示要初始化内存的具体大小。 unit 表示要初始化内存的单位。单位为***“ g”*** (GB) 、“ m”(MB)、“ k”(KB)。 举例,为JVM分配最小2GB和最大5GB的堆内存大小\n-Xms2G -Xmx5G 显示新生代内存(Young Generation) # 在堆总可用内存配置完成之后,第二大影响因素是为 Young Generation 在堆内存所占的比例。默认情况下,YG 的最小大小为 1310 MB,最大大小为无限制。\n两种指定 新生代内存(Young Generation) 大小的方法\n通过 -XX:NewSize 和 -XX:MaxNewSize -XX:NewSize=\u0026lt;young size\u0026gt;[unit] -XX:MaxNewSize=\u0026lt;young size\u0026gt;[unit] 如,为新生代分配最小256m的内存,最大1024m的内存我们的参数为:\n-XX:NewSize=256m -XX:MaxNewSize=1024m 通过-Xmn\u0026lt;young size\u0026gt;[unit] 指定 举例,为新生代分配256m的内存(NewSize与MaxNewSize设为一致) -Xmn256m 将新对象预留在新生代,由于 Full GC 的成本远高于 Minor GC,因此尽可能将对象分配在新生代是明智的做法,实际项目中根据 GC 日志分析新生代空间大小分配是否合理,适当通过“-Xmn”命令调节新生代大小,最大限度降低新对象直接进入老年代的情况。\n另外,你还可以通过 -XX:NewRatio=\u0026lt;int\u0026gt; 来设置老年代与新生代内存的比值。\n下面的参数,设置老年代与新生代内存的比例为1,即 老年代:新生代 = 1:1,新生代占整个堆栈的1/2 -XX:NewRadio=1\n显示指定永久代/元空间的大小 # 从Java 8开始,如果我们没有指定 Metaspace(元空间) 的大小,随着更多类的创建,虚拟机会耗尽所有可用的系统内存(永久代并不会出现这种情况)\nJDK 1.8 之前永久代还没被彻底移除的时候通常通过下面这些参数来调节方法区大小\n-XX:PermSize=N //方法区 (永久代) 初始大小 -XX:MaxPermSize=N //方法区 (永久代) 最大大小,超过这个值将会抛出 OutOfMemoryError 异常:java.lang.OutOfMemoryError: PermGen 相对而言,垃圾收集行为在这个区域是比较少出现的,但**并非数据进入方法区后就“永久存在”**了。\nJDK 1.8 的时候,方法区(HotSpot 的永久代)被彻底移除了(JDK1.7 就已经开始了),取而代之是元空间,元空间使用的是本地内存\n-XX:MetaspaceSize=N //设置 Metaspace 的初始(和最小大小) -XX:MaxMetaspaceSize=N //设置 Metaspace 的最大大小,如果不指定大小的话,随着更多类的创建,虚拟机会耗尽所有可用的系统内存。 垃圾收集相关 # 垃圾回收器 # 为了提高应用程序的稳定性,选择正确的垃圾收集算法至关重要\nJVM具有四种类型的GC实现:\n串行垃圾收集器 并行垃圾收集器 CMS垃圾收集器(并发) G1垃圾收集器(并发) 使用下列参数实现:\n-XX:+UseSerialGC -XX:+UseParallelGC -XX:+UseParNewGC -XX:+UseG1GC GC记录 # 为了严格监控应用程序的运行状况,应该始终检查JVM的垃圾回收性能。最简单的方法是以人类可读的格式记录GC活动\n通过以下参数\n-XX:+UseGCLogFileRotation -XX:NumberOfGCLogFiles=\u0026lt; number of log files \u0026gt; -XX:GCLogFileSize=\u0026lt; file size \u0026gt;[ unit ] -Xloggc:/path/to/gc.log "},{"id":312,"href":"/zh/docs/technology/Review/java_guide/java/JVM/ly0403lyclass-structure/","title":"类文件结构","section":"Java基础","content":" 转载自https://github.com/Snailclimb/JavaGuide(添加小部分笔记)感谢作者!\n概述 # Java中,JVM可以理解的代码就叫做字节码(即扩展名为.class的文件),它不面向任何特定的处理器,只面向虚拟机 Java语言通过字节码的方式,在一定程度上解决了传统解释型语言执行效率低的问题,同时又保留了解释型语言可移植的特点。所以Java程序运行时效率极高,且由于字节码并不针对一种特定的机器。因此,Java程序无需重新编译便可在多种不通操作系统的计算机运行 Clojure(Lisp 语言的一种方言)、Groovy、Scala 等语言都是运行在 Java 虚拟机之上。下图展示了不同的语言被不同的编译器编译成.class文件最终运行在 Java 虚拟机之上。.class文件的二进制格式可以使用 WinHexopen in new window 查看。 .class文件是不同语言在Java虚拟机之间的重要桥梁,同时也是支持Java跨平台很重要的一个原因\nClass文件结构总结 # 根据Java虚拟机规范,Class文件通过ClassFile定义,有点类似C语言的结构体\nClassFile的结构如下:\nClassFile { u4 magic; //Class 文件的标志 u2 minor_version;//Class 的小版本号 u2 major_version;//Class 的大版本号 u2 constant_pool_count;//常量池的数量 cp_info constant_pool[constant_pool_count-1];//常量池 u2 access_flags;//Class 的访问标记 u2 this_class;//当前类 u2 super_class;//父类 u2 interfaces_count;//接口 u2 interfaces[interfaces_count];//一个类可以实现多个接口 u2 fields_count;//Class 文件的字段属性 field_info fields[fields_count];//一个类可以有多个字段 u2 methods_count;//Class 文件的方法数量 method_info methods[methods_count];//一个类可以有个多个方法 u2 attributes_count;//此类的属性表中的属性数 attribute_info attributes[attributes_count];//属性表集合 } 通过IDEA插件jclasslib查看,可以直观看到Class 文件结构\n使用jclasslib不光能直观地查看某个类对应的字节码文件,还可以查看类的基本信息、常量池、接口、属性、函数等信息\n下面介绍一下Class文件结构涉及到的一些组件\n魔数(Magic Number) # u4 magic; //Class 文件的标志 每个Class文件的头4个字节称为魔数(Magic Number),它的唯一作用是确定这个文件是否为一个能被虚拟机接收的Class文件\n程序设计者很多时候都喜欢用一些特殊的数字表示固定的文件类型或者其它特殊的含义。\n这里前两个字节是cafe 英[ˈkæfeɪ],后两个字节 babe 英[beɪb]\nJAVA为 CA FE BA BE,十六进制(一个英文字母[这里说的是字母,不是英文中文之分]代表4位,即2个英文字母为1字节)\nClass文件版本号(Minor\u0026amp;Major Version) # u2 minor_version;//Class 的小版本号 u2 major_version;//Class 的大版本号 前4个字节存储Class 文件的版本号:第5位和第6位是次版本号,第7位和第8位是主版本号。 比如Java1.8 为00 00 00 34 JDK1.8 = 52 JDK1.7 = 51 JDK1.6 = 50 JDK1.5 = 49 JDK1.4 = 48 如图,下图是在java8中编译的,使用javap -v 查看 每当Java发布大版本(比如Java8 ,Java9 )的时候,主版本号都会+1\n注:高版本的 Java 虚拟机可以执行低版本编译器生成的 Class 文件,但是低版本的 Java 虚拟机不能执行高版本编译器生成的 Class 文件。所以,我们在实际开发的时候要确保开发的的 JDK 版本和生产环境的 JDK 版本保持一致\n常量池(Constant Pool) # u2 constant_pool_count;//常量池的数量 cp_info constant_pool[constant_pool_count-1];//常量池 主次版本号之后的是常量池,常量池实际数量为constant_pool_count -1 (常量池计数器是从 1 开始计数的,将第 0 项常量空出来是有特殊考虑的,索引值为 0 代表“不引用任何一个常量池项”)\n常量池主要包括两大常量:字面量和符号引用。\n字面量比较接近于Java语言层面的常量概念,如文本字符串、声明为final的常量值等\n注意,非常量是不会在这里的, 没有找到3\n符号引用则属于编译原理方面的概念,包括下面三类常量\n类和接口的全限定名 字段的名称和描述符 方法的名称和描述符 常量池中的每一项常量都是一个表,这14种表有一个共同特点:开始第一位是一个u1类型的标志位 -tag 来标识常量的类型,代表当前这个常量属于哪种常量类型\n.class 文件可以通过javap -v class类名 指令来看一下其常量池中的信息(javap -v class类名-\u0026gt; temp.txt :将结果输出到 temp.txt 文件)。\n访问标志(Access Flag) # 常量池结束后,紧接着两个字节代表访问标志,这个标志用于识别一些类或者接口 层次的访问信息,包括\n这个Class是类还是接口,是否为public或者abstract类型,如果是类的话是否声明为final等等\n类访问和属性修饰符\n【这里好像漏了一个0x0002 ,private 】\n上图转自: https://www.cnblogs.com/qdhxhz/p/10676337.html\n其实是所有值相加,所以对于 public interface A ,是0x601 ,即 0x200 + 0x400 + 0x001\n对于 public final class MyEntity extends MyInterface即0x31:0x0001 + 0x0010 + 0x0020\n再举个例子:\npackage top.snailclimb.bean; public class Employee { ... } 通过 javap -v class类名指令来看一下类的访问标志\n当前类(This Class)、父类(Super Class)、接口(Interfaces)索引集合 # u2 this_class;//当前类 u2 super_class;//父类 u2 interfaces_count;//接口 u2 interfaces[interfaces_count];//一个类可以实现多个接口 类索引用于确定这个类的全限定名,父类索引用于确定这个类的父类的全限定名,由于 Java 语言的单继承,所以父类索引只有一个,除了 java.lang.Object 之外,所有的 java 类都有父类,因此除了 java.lang.Object 外,所有 Java 类的父类索引都不为 0。 接口索引集合用来描述这个类实现了那些接口,这些被实现的接口将按 implements (如果这个类本身是接口的话则是extends) 后的接口顺序从左到右排列在接口索引集合中。 字段表集合 (Fields) # u2 fields_count;//Class 文件的字段的个数 field_info fields[fields_count];//一个类可以有多个字段 字段表(filed info)用于描述接口或类中声明的变量\n字段包括类级变量以及实例变量,但不包括在方法内部声明的局部变量 filed info(字段表)的结构:\naccess_flag:字段的作用域(public、private、protected修饰符)、是实例变量还是类变量(static修饰符)、可否被序列化(transient修饰符)、可变性(final)、可见性(volatile修饰符,是否强制从主内存读写) name_index:对常量池的引用,表示的字段的名称 descriptor_index:对常量池的引用,表示字段和方法的描述符 attributes_count:一个字段还会拥有额外的属性,attributes_count 存放属性的个数 attributes[attriutes_count]: 存放具体属性具体内容 上述这些信息中,各个修饰符都是布尔值,要么有某个修饰符,要么没有,很适合使用标志位来表示。而字段叫什么名字、字段被定义为什么数据类型这些都是无法固定的,只能引用常量池中常量来描述。\n方法表集合(Methods) # u2 methods_count;//Class 文件的方法的数量 method_info methods[methods_count];//一个类可以有个多个方法 methods_count 表示方法的数量,而 method_info 表示方法表。-\nClass 文件存储格式中对方法的描述与对字段的描述几乎采用了完全一致的方式。方法表的结构如同字段表一样,依次包括了访问标志、名称索引、描述符索引、属性表集合几项。\nmethod_info(方法表的)结构\n方法表的 access_flag 取值: 注意:因为volatile修饰符和transient修饰符不可以修饰方法,所以方法表的访问标志中没有这两个对应的标志,但是增加了synchronized、native、abstract等关键字修饰方法,所以也就多了这些关键字对应的标志。\n属性表集合(Attributes) # 如上,字段和方法都拥有属性 属性大概就是这种 u2 attributes_count;//此类的属性表中的属性数 attribute_info attributes[attributes_count];//属性表集合 在 Class 文件,字段表,方法表中都可以携带自己的属性表集合,以用于描述某些场景专有的信息 与 Class 文件中其它的数据项目要求的顺序、长度和内容不同,属性表集合的限制稍微宽松一些,不再要求各个属性表具有严格的顺序,并且只要不与已有的属性名重复,任何人实现的编译器都可以向属性表中写 入自己定义的属性信息,Java 虚拟机运行时会忽略掉它不认识的属性 "},{"id":313,"href":"/zh/docs/technology/Review/java_guide/java/JVM/ly0405lyclassloader-detail/","title":"类加载器详解","section":"Java基础","content":" 转载自https://github.com/Snailclimb/JavaGuide(添加小部分笔记)感谢作者!\n回顾一下类加载过程 # 开始介绍类加载器和双亲委派模型之前,简单回顾一下类加载过程。\n类加载过程:加载-\u0026gt;连接-\u0026gt;初始化。 连接过程又可分为三步:验证-\u0026gt;准备-\u0026gt;解析。 [\n加载是类加载过程的第一步,主要完成下面 3 件事情:\n通过全类名获取定义此类的二进制字节流 将字节流所代表的静态存储结构转换为方法区的运行时数据结构 在内存中生成一个代表该类的 Class 对象,作为方法区这些数据的访问入口 类加载器 # 类加载器介绍 # 类加载器从 JDK 1.0 就出现了,最初只是为了满足 Java Applet(已经被淘汰) 的需要。后来,慢慢成为 Java 程序中的一个重要组成部分,赋予了 Java 类可以被动态加载到 JVM 中并执行的能力。\n根据官方 API 文档的介绍:\nA class loader is an object that is responsible for loading classes. The class ClassLoader is an abstract class. Given the binary name of a class, a class loader should attempt to locate or generate data that constitutes a definition for the class. A typical strategy is to transform the name into a file name and then read a \u0026ldquo;class file\u0026rdquo; of that name from a file system.\nEvery Class object contains a reference to the ClassLoader that defined it.\nClass objects for array classes are not created by class loaders, but are created automatically as required by the Java runtime. The class loader for an array class, as returned by Class.getClassLoader() is the same as the class loader for its element type; if the element type is a primitive type, then the array class has no class loader.\n翻译过来大概的意思是:\n类加载器是一个负责加载类的对象。ClassLoader 是一个抽象类。给定类的二进制名称,类加载器应尝试定位或生成构成类定义的数据。典型的策略是将名称转换为文件名,然后从文件系统中读取该名称的“类文件”。\n每个 Java 类都有一个引用指向加载它的 ClassLoader。不过,数组类不是通过 ClassLoader 创建的,而是 JVM 在需要的时候自动创建的,数组类通过getClassLoader()方法获取 ClassLoader 的时候和该数组的元素类型的 ClassLoader 是一致的。\n从上面的介绍可以看出:\n类加载器是一个负责加载类的对象,用于实现类加载过程中的加载这一步。 每个 Java 类都有一个引用指向加载它的 ClassLoader。 数组类不是通过 ClassLoader 创建的(数组类没有对应的二进制字节流),是由 JVM 直接生成的。 class Class\u0026lt;T\u0026gt; { ... private final ClassLoader classLoader; @CallerSensitive public ClassLoader getClassLoader() { //... } ... } 简单来说,类加载器的主要作用就是加载 Java 类的字节码( .class 文件)到 JVM 中(在内存中生成一个代表该类的 Class 对象)。 字节码可以是 Java 源程序(.java文件)经过 javac 编译得来,也可以是通过工具动态生成或者通过网络下载得来。\n其实除了加载类之外,类加载器还可以加载 Java 应用所需的资源如文本、图像、配置文件、视频等等文件资源。本文只讨论其核心功能:加载类。\n类加载器加载规则 # JVM 启动的时候,并不会一次性加载所有的类,而是根据需要去动态加载。也就是说,大部分类在具体用到的时候才会去加载,这样对内存更加友好。\n对于已经加载的类会被放在 ClassLoader 中。在类加载的时候,系统会首先判断当前类是否被加载过。已经被加载的类会直接返回,否则才会尝试加载。也就是说,对于一个类加载器来说,相同二进制名称的类只会被加载一次。\npublic abstract class ClassLoader { ... private final ClassLoader parent; // 由这个类加载器加载的类。 private final Vector\u0026lt;Class\u0026lt;?\u0026gt;\u0026gt; classes = new Vector\u0026lt;\u0026gt;(); // 由VM调用,用此类加载器记录每个已加载类。 void addClass(Class\u0026lt;?\u0026gt; c) { classes.addElement(c); } ... } 类加载器总结 # JVM 中内置了三个重要的 ClassLoader:\nBootstrapClassLoader(启动类加载器) :最顶层的加载类,由 C++实现,通常表示为 null(意思是如果用代码来get,会得到null),并且没有父级,主要用来加载 JDK 内部的核心类库( **%JAVA_HOME%/lib**目录下的 rt.jar 、resources.jar 、charsets.jar等 jar 包和类)以及被 -Xbootclasspath参数指定的路径下的所有类。 ExtensionClassLoader(扩展类加载器) :主要负责加载 %JRE_HOME%/lib/ext 目录下的 jar 包和类以及被 java.ext.dirs 系统变量所指定的路径下的所有类。 AppClassLoader(应用程序类加载器) :面向我们用户的加载器,负责加载当前应用 classpath 下的所有 jar 包和类。 🌈 拓展一下:\nrt.jar : rt 代表“RunTime”,rt.jar是Java基础类库,包含Java doc里面看到的所有的类的类文件。也就是说,我们常用内置库 java.xxx.* 都在里面,比如java.util.*、java.io.*、java.nio.*、java.lang.*、java.sql.*、java.math.*。 Java 9 引入了模块系统,并且略微更改了上述的类加载器。扩展类加载器被改名为平台类加载器(platform class loader)。Java SE 中除了少数几个关键模块,比如说 java.base 是由启动类加载器加载之外,其他的模块均由平台类加载器所加载。 除了这三种类加载器之外,用户还可以加入自定义的类加载器来进行拓展,以满足自己的特殊需求。就比如说,我们可以对 Java 类的字节码( .class 文件)进行加密,加载时再利用自定义的类加载器对其解密。\n除了 BootstrapClassLoader 是 JVM 自身的一部分之外,其他所有的类加载器都是在 JVM 外部实现的,并且全都继承自 ClassLoader抽象类。这样做的好处是用户可以自定义类加载器,以便让应用程序自己决定如何去获取所需的类。\n每个 ClassLoader 可以通过**getParent()获取其父 ClassLoader,如果获取到 ClassLoader 为null**的话,那么该类是通过 BootstrapClassLoader 加载的。\npublic abstract class ClassLoader { ... // 父加载器 private final ClassLoader parent; @CallerSensitive public final ClassLoader getParent() { //... } ... } 为什么 获取到 ClassLoader 为null就是 BootstrapClassLoader 加载的呢? 这是因为BootstrapClassLoader 由 C++ 实现,由于这个 C++ 实现的类加载器在 Java 中是没有与之对应的类的,所以拿到的结果是 null。\n下面我们来看一个获取 ClassLoader 的小案例:\npublic class PrintClassLoaderTree { public static void main(String[] args) { ClassLoader classLoader = PrintClassLoaderTree.class.getClassLoader(); StringBuilder split = new StringBuilder(\u0026#34;|--\u0026#34;); boolean needContinue = true; while (needContinue){ System.out.println(split.toString() + classLoader); if(classLoader == null){ needContinue = false; }else{ classLoader = classLoader.getParent(); split.insert(0, \u0026#34;\\t\u0026#34;); } } } } 输出结果(JDK 8 ):\n|--sun.misc.Launcher$AppClassLoader@18b4aac2 |--sun.misc.Launcher$ExtClassLoader@53bd815b |--null 从输出结果可以看出:\n我们编写的 Java 类 PrintClassLoaderTree 的 ClassLoader 是AppClassLoader; AppClassLoader的父 ClassLoader 是ExtClassLoader; ExtClassLoader的父ClassLoader是Bootstrap ClassLoader,因此输出结果为 null。 自定义类加载器 # 我们前面也说说了,除了 BootstrapClassLoader 其他类加载器均由 Java 实现且全部继承自java.lang.ClassLoader。如果我们要自定义自己的类加载器,很明显需要继承 ClassLoader抽象类。\nClassLoader 类有两个关键的方法:\nprotected Class loadClass(String name, boolean resolve):加载指定二进制名称的类**,实现了双亲委派机制** 。name 为类的二进制名称,resove 如果为 true,在加载时调用 resolveClass(Class\u0026lt;?\u0026gt; c) 方法解析该类。 protected Class findClass(String name):根据类的二进制名称来查找类,默认实现是空方法。 官方 API 文档中写到:\nSubclasses of ClassLoader are encouraged to override findClass(String name), rather than this method.\n建议 ClassLoader的子类重写 findClass(String name)方法而不是loadClass(String name, boolean resolve) 方法。\n如果我们不想打破双亲委派模型,就重写 ClassLoader 类中的 findClass() 方法即可,无法被父类加载器加载的类最终会通过这个方法被加载。但是,如果想打破双亲委派模型则需要重写 loadClass() 方法。\n双亲委派模型 # 双亲委派模型介绍 # 类加载器有很多种,当我们想要加载一个类的时候,具体是哪个类加载器加载呢?这就需要提到双亲委派模型了。\n根据官网介绍:\nThe ClassLoader class uses a delegation model to search for classes and resources. Each instance of ClassLoader has an associated parent class loader. When requested to find a class or resource, a ClassLoader instance will delegate the search for the class or resource to its parent class loader before attempting to find the class or resource itself. The virtual machine\u0026rsquo;s built-in class loader, called the \u0026ldquo;bootstrap class loader\u0026rdquo;, does not itself have a parent but may serve as the parent of a ClassLoader instance.\n翻译过来大概的意思是:\nClassLoader 类使用委托模型来搜索类和资源。每个 ClassLoader 实例都有一个相关的父类加载器。需要查找类或资源时,ClassLoader 实例会在试图亲自查找类或资源之前,将搜索类或资源的任务委托给其父类加载器。 虚拟机中被称为 \u0026ldquo;bootstrap class loader\u0026quot;的内置类加载器本身没有父类加载器,但是可以作为 ClassLoader 实例的父类加载器。\n从上面的介绍可以看出:\nClassLoader 类使用委托模型来搜索类和资源。 双亲委派模型要求除了顶层的启动类加载器外,其余的类加载器都应有自己的父类加载器。 ClassLoader 实例会在试图亲自查找类或资源之前,将搜索类或资源的任务委托给其父类加载器。 下图展示的各种类加载器之间的层次关系被称为类加载器的“双亲委派模型(Parents Delegation Model)”。\n注意⚠️:双亲委派模型并不是一种强制性的约束,只是 JDK 官方推荐的一种方式。如果我们因为某些特殊需求想要打破双亲委派模型,也是可以的,后文会介绍具体的方法。\n其实这个双亲翻译的容易让别人误解,我们一般理解的双亲都是父母,这里的双亲更多地表达的是“父母这一辈”的人而已,并不是说真的有一个 MotherClassLoader 和一个FatherClassLoader 。个人觉得翻译成单亲委派模型更好一些,不过,国内既然翻译成了双亲委派模型并流传了,按照这个来也没问题,不要被误解了就好。\n另外,类加载器之间的父子关系一般不是以继承的关系来实现的,而是通常使用组合关系来复用父加载器的代码。\npublic abstract class ClassLoader { ... // 组合 private final ClassLoader parent; protected ClassLoader(ClassLoader parent) { this(checkCreateClassLoader(), parent); } ... } 在面向对象编程中,有一条非常经典的设计原则: 组合优于继承,多用组合少用继承。\n双亲委派模型的执行流程 # 双亲委派模型的实现代码非常简单,逻辑非常清晰,都集中在 java.lang.ClassLoader 的 loadClass() 中,相关代码如下所示。\nprivate final ClassLoader parent; protected Class\u0026lt;?\u0026gt; loadClass(String name, boolean resolve) throws ClassNotFoundException { synchronized (getClassLoadingLock(name)) { // 首先,检查请求的类是否已经被加载过 Class\u0026lt;?\u0026gt; c = findLoadedClass(name); if (c == null) { //如果 c 为 null,则说明该类没有被加载过 long t0 = System.nanoTime(); try { if (parent != null) {//父加载器不为空,调用父加载器loadClass()方法处理 //注意,这里是一层层抛上去,有点类似把方法放进栈,然后如果BootstrapClassLoader加载不了,就会抛异常,由自己加载(如果自己加载不了,还是会抛异常,然后再次加载权回到子类) c = parent.loadClass(name, false); } else {//父加载器为空,使用启动类加载器 BootstrapClassLoader 加载 c = findBootstrapClassOrNull(name); } } catch (ClassNotFoundException e) { //抛出异常说明父类加载器无法完成加载请求 } if (c == null) { //当父类加载器无法加载时,则调用findClass方法来加载该类 //用户可通过覆写该方法,来自定义类加载器 long t1 = System.nanoTime(); //自己尝试加载 c = findClass(name); //用于统计类加载器相关的信息 // this is the defining class loader; record the stats sun.misc.PerfCounter.getParentDelegationTime().addTime(t1 - t0); sun.misc.PerfCounter.getFindClassTime().addElapsedTimeFrom(t1); sun.misc.PerfCounter.getFindClasses().increment(); } } if (resolve) { //对类进行link操作 resolveClass(c); } return c; } } 每当一个类加载器接收到加载请求时,它会先将请求转发给父类加载器。在父类加载器没有找到所请求的类的情况下,该类加载器才会尝试去加载。\n结合上面的源码,简单总结一下双亲委派模型的执行流程:\n在类加载的时候,系统会首先判断当前类是否被加载过。已经被加载的类会直接返回,否则才会尝试加载(每个父类加载器都会走一遍这个流程)。 类加载器在进行类加载的时候,它首先不会自己去尝试加载这个类,而是把这个请求委派给父类加载器去完成(调用父加载器 loadClass()方法来加载类)。这样的话,所有的请求最终都会传送到顶层的启动类加载器 BootstrapClassLoader 中。 只有当父加载器反馈自己无法完成这个加载请求(它的搜索范围中没有找到所需的类)时,子加载器才会尝试自己去加载(调用自己的 findClass() 方法来加载类)。 🌈 拓展一下:\nJVM 判定两个 Java 类是否相同的具体规则 :JVM 不仅要看类的全名是否相同,还要看加载此类的类加载器是否一样。只有两者都相同的情况,才认为两个类是相同的。即使两个类来源于同一个 Class 文件,被同一个虚拟机加载,只要加载它们的类加载器不同,那这两个类就必定不相同。\n双亲委派模型的好处 # 双亲委派模型保证了 Java 程序的稳定运行,可以避免类的重复加载(JVM 区分不同类的方式不仅仅根据类名,相同的类文件被不同的类加载器加载产生的是两个不同的类),也保证了 Java 的核心 API 不被篡改。\n如果没有使用双亲委派模型,而是每个类加载器加载自己的话就会出现一些问题,比如我们编写一个称为 java.lang.Object 类的话,那么程序运行的时候,系统就会出现两个不同的 Object 类。双亲委派模型可以保证加载的是 JRE 里的那个 Object 类,而不是你写的 Object 类。这是因为 AppClassLoader 在加载你的 Object 类时,会委托给 ExtClassLoader 去加载,而 ExtClassLoader 又会委托给 BootstrapClassLoader,BootstrapClassLoader 发现自己已经加载过了 Object 类,会直接返回,不会去加载你写的 Object 类。\n打破双亲委派模型方法 # 为了避免双亲委托机制,我们可以自己定义一个类加载器,然后重写 loadClass() 即可。\n🐛 修正(参见: issue871 ) :自定义加载器的话,需要继承 ClassLoader 。如果我们不想打破双亲委派模型,就重写 ClassLoader 类中的 findClass() 方法即可,无法被父类加载器加载的类最终会通过这个方法被加载。但是,如果想打破双亲委派模型则需要重写 loadClass() 方法。\n为什么是重写 loadClass() 方法打破双亲委派模型呢?双亲委派模型的执行流程已经解释了:\n类加载器在进行类加载的时候,它首先不会自己去尝试加载这个类,而是把这个请求委派给父类加载器去完成(调用父加载器 loadClass()方法来加载类)。\n我们比较熟悉的 Tomcat 服务器为了能够优先加载 Web 应用目录下的类,然后再加载其他目录下的类,就自定义了类加载器 WebAppClassLoader 来打破双亲委托机制。这也是 Tomcat 下 Web 应用之间的类实现隔离的具体原理。\nTomcat 的类加载器的层次结构如下:\n感兴趣的小伙伴可以自行研究一下 Tomcat 类加载器的层次结构,这有助于我们搞懂 Tomcat 隔离 Web 应用的原理,推荐资料是 《深入拆解 Tomcat \u0026amp; Jetty》。\n推荐阅读 # 《深入拆解 Java 虚拟机》 深入分析 Java ClassLoader 原理:https://blog.csdn.net/xyang81/article/details/7292380 Java 类加载器(ClassLoader):http://gityuan.com/2016/01/24/java-classloader/ Class Loaders in Java:https://www.baeldung.com/java-classloaders Class ClassLoader - Oracle 官方文档:https://docs.oracle.com/javase/8/docs/api/java/lang/ClassLoader.html 老大难的 Java ClassLoader 再不理解就老了:https://zhuanlan.zhihu.com/p/51374915 "},{"id":314,"href":"/zh/docs/technology/Review/java_guide/java/JVM/ly0404lyclassloader-process/","title":"类加载过程","section":"Java基础","content":" 转载自https://github.com/Snailclimb/JavaGuide(添加小部分笔记)感谢作者!\n类的声明周期 # 类加载过程 # Class文件,需要加载到虚拟机中之后才能运行和使用,那么虚拟机是如何加载这些Class文件呢 系统加载Class类文件需要三步:加载-\u0026gt;连接-\u0026gt;初始化。连接过程又分为三步:验证-\u0026gt;准备-\u0026gt;解析\n加载 # 类加载的第一步,主要完成3件事情\n构造与类相关联的方法表\n通过全类名获取定义此类的二进制字节流 将字节流所代表的静态存储结构,转换为方法区的运行时数据结构 在内存中生成一个该类的Class对象,作为方法区这些数据的访问入口 虚拟机规范对上面3点不具体,比较灵活\n对于1 没有具体指明从哪里获取、怎样获取。可以从ZIP包读取 (JAR/EAR/WAR格式的基础)、其他文件生成(JSP)等 非数组类的加载阶段(加载阶段获取类的二进制字节流的动作)是可控性最强的阶段,这一步我们可以去完成还可以自定义类加载器去控制字节流的获取方式(重写一个类加载器的**loadClass()**方法 数组类型不通过类加载器创建,它由Java虚拟机直接创建 加载阶段和连接阶段的部分内容是交叉执行的,即加载阶段尚未结束,连接阶段就可能已经开始了\n验证 # 验证是连接阶段的第一步,这一阶段的目的是确保 Class 文件的字节流中包含的信息符合《Java 虚拟机规范》的全部约束要求,保证这些信息被当作代码运行后不会危害虚拟机自身的安全。\n验证阶段主要由四个检验阶段组成:\n文件格式验证(Class 文件格式检查) 元数据验证(字节码语义检查) 字节码验证(程序语义检查) 符号引用验证(类的正确性检查) 准备 # 准备阶段是正式为类变量分配内存并设置类变量初始值的阶段,这些内存都将在方法区中分配,注意:\n这时候进行内存分配的仅包括类变量(ClassVariables,即静态变量:被static关键字修饰的变量,只与类相关,因此被称为类变量),而不包括实例变量。\n实例变量会在对象实例化时,随着对象一块分配到Java堆中\n从概念上讲,类变量所使用的内存都应当在 方法区 中进行分配。不过有一点需要注意的是:JDK 7 之前,HotSpot 使用永久代来实现方法区的时候,实现是完全符合这种逻辑概念的。 而在 JDK 7 及之后,HotSpot 已经把原本放在永久代的字符串常量池、静态变量等移动到堆中,这个时候类变量则会随着 **Class 对象(上面有提到,内存区生成Class对象)**一起存放在 Java 堆中\n这里所设置的初始值**\u0026ldquo;通常情况\u0026rdquo;下是数据类型默认的零值(如 0、0L、null、false 等**),比如我们定义了**public static int value=111** ,那么 value 变量在准备阶段的初始值就是 0 而不是 111(初始化阶段才会赋值)。特殊情况:比如给 value 变量加上了 final 关键字public static final int value=111 ,那么准备阶段 value 的值就被赋值为 111\n基本数据类型的零值 解析 # 解析阶段是虚拟机将常量池内的符号引用替换为直接引用的过程\n解析动作主要针对类或接口、字段、类方法、接口方法、方法类型、方法句柄和调用限定符7类符号引用进行\n符号引用就是一组符号来描述目标,可以是任何字面量。直接引用就是直接指向目标的指针、相对偏移量或一个间接定位到目标的句柄\n程序实际运行时,只有符号引用是不够的。 在程序执行方法时,系统需要明确知道这个方法所在的位置 Java虚拟机为每个类都准备了一张方法表来存放类中所有的方法。当需要调用一个类的方法的时候,只要知道这个方法在方法表中的偏移量就可以直接调用该方法了(针对其他类X或者当前类的方法)\n通过解析操作符号引用就可以直接转变为目标方法在类中方法表的位置,从而使得方法可以被调用。(将当前类中代码转为 上面说的类的偏移量)\n对下面的内容简化一下就是,编译后的class文件中,以 [类数组] 的方式,保存了类中的方法表的位置(偏移量)(通过得到每个数组元素可以得到方法的信息)。而这里我们只能知道偏移量,但是当正式加载到方法区之后,我们就能根据偏移量,计算出具体的 [内存地址] 了。\n具体详情https://blog.csdn.net/luanlouis/article/details/41113695 ,这里涉及到几个概念,一个是方法表。通过 javap -v xxx查看反编译的信息(class文件的信息)\nclass文件是这样的结构,里面有个方法表的概念\n如下,可能会有好几个方法,所以方法表,其实是一个类数组结构,而每个方法信息(method_info)呢,\n进一步,对于每个method_info结构体的定义\n方法表的结构体由:访问标志(*access_flags*)、名称索引(*name_index*)、描述索引(*descriptor_index*)、属性表(*attribute_info*)集合组成。\n而对于属性表,(其中:属性表集合\u0026ndash;用来记录方法的机器指令和抛出异常等信息)\nJava之所以能够运行,就是从Code属性中,取出的机器码\n解析阶段是虚拟机将常量池内的符号引用替换为直接引用的过程,也就是得到类或者字段、方法在内存中的指针或者偏移量。(因为此时那些class文件已经早就加载到方法区之中了,所以可以改成指向方法区的某个内存地址\n如下,我的理解是,把下面的 com/test/Student.a ()V 修改成了直接的内存地址 类似的意思\n初始化 # 初始化阶段,是执行初始化方法clinit()方法的过程,是类加载的最后一步,这一步JVM才开始真正执行类中定义的Java程序代码(字节码)\nclinit()方法是编译之后自动生成的\n对于clinit () 方法的调用,虚拟机会自己确保其在多线程环境中的安全性。因为 clinit () 方法是带锁线程安全,所以在多线程环境下进行类初始化的话可能会引起多个线程阻塞,并且这种阻塞很难被发现。\n对于初始化阶段,虚拟机严格规范了有且只有 5 种情况下,必须对类进行初始化(只有主动去使用类才会初始化类):\n当遇到 new 、 getstatic、putstatic 或 invokestatic 这 4 条直接码指令时,比如 new 一个类,读取一个静态字段(未被 final 修饰)、或调用一个类的静态方法时。\n当 jvm 执行 new 指令时会初始化类。即当程序创建一个类的实例对象。 当 jvm 执行 getstatic 指令时会初始化类。即程序访问类的静态变量(不是静态常量,常量会被加载到运行时常量池)。 当 jvm 执行 putstatic 指令时会初始化类。即程序给类的静态变量赋值。 当 jvm 执行 invokestatic 指令时会初始化类。即程序调用类的静态方法。 使用 java.lang.reflect 包的方法对类进行反射调用时如 Class.forname(\u0026quot;...\u0026quot;), newInstance() 等等。如果类没初始化,需要触发其初始化。\n初始化一个类,如果其父类还未初始化,则先触发该父类的初始化。\n当虚拟机启动时,用户需要定义一个要执行的主类 (包含 main 方法的那个类),虚拟机会先初始化这个类。\nMethodHandle 和 VarHandle 可以看作是轻量级的反射调用机制,而要想使用这 2 个调用, 就必须先使用 findStaticVarHandle 来初始化要调用的类。\n「补充,来自 issue745open in new window」 当一个接口中定义了 JDK8 新加入的默认方法(被 default 关键字修饰的接口方法)时,如果有这个接口的实现类发生了初始化,那该接口要在其之前被初始化。\n卸载 # 卸载类即该类的 Class 对象被 GC。\n卸载类需要满足 3 个要求:\n该类的所有的实例对象都已被 GC,也就是说堆不存在该类的实例对象。 该类没有在其他任何地方被引用 该类的类加载器的实例已被 GC JVM的生命周期内,由jvm自带的类加载器的类是不会被卸载的,而由我们自定义的类加载器加载的类是可能被卸载的\n只要想通一点就好了,jdk 自带的 BootstrapClassLoader, ExtClassLoader, AppClassLoader 负责加载 jdk 提供的类,所以它们(类加载器的实例)肯定不会被回收。而我们自定义的类加载器的实例是可以被回收的,所以使用我们自定义加载器加载的类是可以被卸载掉的。\n"},{"id":315,"href":"/zh/docs/technology/Review/java_guide/java/JVM/ly0402lygarbage-collection/","title":"java垃圾回收","section":"Java基础","content":" 转载自https://github.com/Snailclimb/JavaGuide(添加小部分笔记)感谢作者!\n前言 # 当需要排查各种内存溢出问题、当垃圾收集成为系统达到更高并发的瓶颈时,我们就需要对这些**“自动化”的技术实施必要的监控和调节**\n堆空间的基本结构 # Java的自动内存管理主要是针对对象内存的回收和对象内存的分配。且Java自动内存管理最核心的功能是堆内存中的对象分配和回收\nJava堆是垃圾收集器管理的主要区域,因此也被称作GC堆(Garbage Collected Heap)\n从垃圾回收的角度来说,由于现在收集器基本都采用分代垃圾收集算法,所以Java堆被划分为了几个不同的区域,这样我们就可以根据各个区域的特点选择合适的垃圾收集算法\nJDK7版本及JDK7版本之前,堆内存被通常分为下面三部分:\n新生代内存(Young Generation) 老生代(Old Generation) 永久代(Permanent Generation) JDK8版本之后PermGen(永久)已被Metaspace(元空间)取代,且已经不在堆里面了,元空间使用的是直接内存。\n内存分配和回收原则 # 对象优先在Eden区分配 # 多数情况下,对象在新生代中Eden区分配。当Eden区没有足够空间进行分配时,会触发一次MinorGC 首先,先添加一下参数打印GC详情:-XX:+PrintGCDetails\npublic class GCTest { public static void main(String[] args) { byte[] allocation1, allocation2; allocation1 = new byte[30900*1024];//会用掉3万多K } } 运行后的结果(这里应该是配过xms和xmx了,即堆内存大小) 如上,Eden区内存几乎被分配完全(即使程序什么都不做,新生代也会使用2000多K)\n注: PSYoungGen 为 38400K ,= 33280K + 5120K (Survivor区总会有一个是空的,所以只加了一个5120K )\n假如我们再为allocation2分配内存会怎么样(不处理的话,年轻代会溢出)\nallocation2 = new byte[900 * 1024]; 在给allocation2分配内存之前,Eden区内存几乎已经被分配完。所以当Eden区没有足够空间进行分配时,虚拟机将发起一次MinorGC。GC期间虚拟机又发现allocation1无法存入空间,所以只好通过分配担保机制,把新生代的对象,提前转移到老年代去,老年代的空间足够存放allocation1,所以不会出现Full GC(这里可能是之前的说法,可能只是要表达老年代的GC,而不是Full GC(整堆GC) ) 执行MinorGC后,后面分配的对象如果能够存在Eden区的话,还是会在Eden区分配内存\n执行如下代码验证:\npublic class GCTest { public static void main(String[] args) { byte[] allocation1, allocation2,allocation3,allocation4,allocation5; allocation1 = new byte[32000*1024]; allocation2 = new byte[1000*1024]; allocation3 = new byte[1000*1024]; allocation4 = new byte[1000*1024]; allocation5 = new byte[1000*1024]; } } 大对象直接进入老年代 # 大对象就是需要连续空间的对象(字符串、数组等) 大对象直接进入老年代,主要是为了避免为大对象分配内存时,由于分配担保机制(这好像跟分配担保机制没有太大关系)带来的复制而降低效率。 假设大对象最后会晋升老年代,而新生代是基于复制算法来回收垃圾的,由两个Survivor区域配合完成复制算法,如果新生代中出现大对象且能屡次躲过GC,那这个对象就会在两个Survivor区域中来回复制,直至最后升入老年代,而大对象在内存里来回复制移动,就会消耗更多的时间。\n假设大对象最后不会晋升老年代,新生代空间是有限的,在新生代里的对象大部分都是朝生夕死的,如果让一个大对象占据了新生代空间,那么相比起正常的对象被分配在新生代,大对象无疑会让新生代GC提早发生,因为内存空间会更快不够用,如果这个大对象因为业务原因,并不会马上被GC回收,那么这个对象就会进入到Survivor区域,默认情况下,Survivor区域本来就不会被分配的很大,那此时被大对象占据了大部分空间,很可能会导致之后的新生代GC后,存活下来的对象,Survivor区域空间不够放不下,导致大部分对象进入老年代,这就加快了老年代GC发生的时间,而老年代GC对系统性能的负面影响则远远大于新生代GC了。\n长期存活的对象进入老年代 # 内存回收时必须能够识别,哪些对象放在新生代,哪些对象放在老年代\u0026mdash;\u0026gt; 因此,虚拟机给每个对象一个**对象年龄(Age)**计数器\n\u0026lt;流程\u0026gt; : 大部分情况下,对象都会首先在Eden区域分配。如果对象在Eden出生并经过第一次MinorGC后仍然能够存活,并且能被Survivor容纳的话,将被移动到Survivor空间(S0或S1)中,并将对象年龄设为1(Eden区 \u0026ndash;\u0026gt; Survivor区后对象初始年龄变为1 )\n后续,对象在Survivor区中每熬过一次MinorGC,年龄就增加1岁,当年龄增加到一定程序(默认为15岁),就会被晋升到老年代中。对象晋升到老年代的年龄阈值,可以通过参数**-XX:MaxTenuringThreshold**来设置 ★★修正: “Hotspot 遍历所有对象时,按照年龄从小到大对其所占用的大小进行累积,当累积的某个年龄大小超过了 survivor 区的 50% 时(默认值是 50%,可以通过 -XX:TargetSurvivorRatio=percent 来设置,参见 issue1199open in new window ),取这个年龄和 MaxTenuringThreshold 中更小的一个值,作为新的晋升年龄阈值”。 动态年龄计算的代码:\nuint ageTable::compute_tenuring_threshold(size_t survivor_capacity) { //survivor_capacity是survivor空间的大小 size_t desired_survivor_size = (size_t)((((double)survivor_capacity)*TargetSurvivorRatio)/100); size_t total = 0; uint age = 1; while (age \u0026lt; table_size) { //sizes数组是每个年龄段对象大小 total += sizes[age]; if (total \u0026gt; desired_survivor_size) { break; } age++; //注意这里,age是递增的,最终是去某个值,而不是区间的值计算 } uint result = age \u0026lt; MaxTenuringThreshold ? age : MaxTenuringThreshold; ... } 例子: 如**对象年龄5的占30%,年龄6的占36%,年龄7的占34%,加入某个年龄段(如例子中的年龄6)**后,总占用超过Survivor空间*TargetSurvivorRatio的时候,从该年龄段开始及大于的年龄对象就要进入老年代(即例子中的年龄6对象,就是年龄6和年龄7晋升到老年代),这时候无需等到MaxTenuringThreshold中要求的15\n关于默认的晋升年龄是 15,这个说法的来源大部分都是《深入理解 Java 虚拟机》这本书。 如果你去 Oracle 的官网阅读 相关的虚拟机参数open in new window,你会发现-XX:MaxTenuringThreshold=threshold这里有个说明\nSets the maximum tenuring threshold for use in adaptive GC sizing. The largest value is 15. The default value is 15 for the parallel (throughput) collector, and 6 for the CMS collector.默认晋升年龄并不都是 15,这个是要区分垃圾收集器的,CMS 就是 6.\n主要进行gc的区域 # 如图:(太长跳过了,直接看下面的总结)\n总结:\n针对HotSpotVM的实现,它里面的GC准确分类只有两大种:\n部分收集(Partial GC) 新生代收集(Minor GC/ Young GC ):只对新生代进行垃圾收集 老年代(Major GC / Old GC ):只对老年代进行垃圾收集。★★:注意,MajorGC在有的语境中也用于指代整堆收集 混合收集(Mixed GC):对整个新生代和部分老年代进行垃圾收集 整堆收集(Full GC):收集整个Java堆和方法区 空间分配担保 # 为了确保在MinorGC之前老年代本身还有容纳新生代所有对象的剩余空间\n《深入理解Java虚拟机》第三章对于空间分配担保的描述如下:\nJDK 6 Update 24 之前,在发生 Minor GC 之前,虚拟机必须先检查老年代最大可用的连续空间是否大于新生代所有对象总空间,如果这个条件成立,那这一次 Minor GC 可以确保是安全的。如果不成立,则虚拟机会先查看 -XX:HandlePromotionFailure 参数的设置值是否允许担保失败(Handle Promotion Failure);如果允许,那会继续检查老年代最大可用的连续空间是否大于历次晋升到老年代对象的平均大小,如果大于,将尝试进行一次 Minor GC,尽管这次 Minor GC 是有风险的;如果小于,或者 -XX: HandlePromotionFailure 设置不允许冒险,那这时就要改为进行一次 Full GC。\nJDK6 Update24之后,规则变为只要老年代的连续空间大于新生代对象总大小,或者历次晋升的平均大小,就会进行MinorGC,否则将进行Full GC\n死亡对象判断方法 # 堆中几乎放着所有的对象实例,对堆垃圾回收前的第一步就是要判断哪些对象已经死亡(即不能再被任何途径使用的对象)\n引用计数法 # 给对象中添加一个引用计数器\n每当有一个地方引用它,计数器就加1 当引用失效,计数器就减1 任何时候计数器为0的对象就是不可能再被使用的 这个方法实现简单,效率高,但是目前主流的虚拟机中并没有选择这个算法来管理内存,其最主要的原因是它很难解决对象之间相互循环引用的问题。\n除了对象 objA 和 objB 相互引用着对方之外,这两个对象之间再无任何引用。但是他们因为互相引用对方,导致它们的引用计数器都不为 0,于是引用计数算法无法通知 GC 回收器回收他们\n★其实我觉得只跟相互有关,跟是不是循环关系不会太大\nly 改:相互在语言逻辑上也可以理解成**“循环”**\npublic class ReferenceCountingGc { Object instance = null; public static void main(String[] args) { ReferenceCountingGc objA = new ReferenceCountingGc(); ReferenceCountingGc objB = new ReferenceCountingGc(); objA.instance = objB; objB.instance = objA; objA = null; objB = null; } } 可达性分析算法 # 该算法的基本思想就是通过一系列称为**“GC Roots\u0026quot;的对象作为起点,从这些节点开始向下搜索**,节点所走过的路径 称为引用链,当一个对象到GC Roots没有任何引用链相连的话,证明该对象不可用,需要被回收 下图中由于Object 6 ~ Object 10之间有引用关系,但它们到GC不可达,所以需要被回收 哪些对象可以作为GC Roots呢\n虚拟机栈(栈帧中的本地变量表)中引用的对象 本地方法栈(Native方法)中引用的对象 方法区中类静态属性引用的对象 (Class 的static变量) 方法区中常量引用的变量(Class 的final static变量) 所有被同步锁持有的对象 (synchronized(obj)) 对象可以被回收,就代码一定会被回收吗 即使在可达性分析法中不可达的对象,也并非是“非死不可”的,这时候它们暂时处于“缓刑阶段”,要真正宣告一个对象死亡,至少要经历两次标记过程:\n可达性分析中不可达的对象被第一次标记并且进行一次筛选:筛选的条件是此对象是否有必要执行finalize方法(有必要则放入)\n当对象没有覆盖finalize方法,或finalize方法已经被虚拟机调用过,则虚拟机将两种情况视为没有必要执行,该对象会被直接回收\n如果这个对象被判定为有必要执行finalize()方法,那么这个对象将会被放置在一个叫做F-Queue的队列中,然后由Finalizer线程去执行。GC将会对F-Queue中的对象进行第二次标记,如果对象在finalize()方法中重新与引用链上的任何一个对象建立关联,那么在第二次标记时将会被移除“即将回收”的集合,否则该对象将会被回收。\n(比如:把自己(this关键字)赋值给某个类变量(static修饰)或者对象的成员变量(在finalize方法中) )\nObject 类中的 finalize 方法一直被认为是一个糟糕的设计,成为了 Java 语言的负担,影响了 Java 语言的安全和 GC 的性能。JDK9 版本及后续版本中各个类中的 finalize 方法会被逐渐弃用移除。忘掉它的存在吧!\n引用类型总结 # 不论是通过引用计数法判断对象引用数量,还是通过可达性分析法判断对象的引用链是否可达,判定对象的存活都与**”引用“**有关 JDK1.2 之前,Java中引用的定义很传统:如果reference类型的数据存储的数值代表的是另一块内存的起始地址,就称这块内存代表一个引用 JDK1.2 之后,Java对引用的概念进行了扩充,将引用(具体)分为强引用、软引用、弱引用、虚引用四种(引用强度逐渐减弱) 强引用(Strong Reference)\n大部分引用实际上是强引用。如果对象具有强引用,那么类似于生活中必不可少,垃圾回收器绝不会回收它 内存空间不足时,宁愿抛出OutOfMemoryErro错误,使程序异常终止,也不会回收强引用对象解决对象内存不足 软引用(SoftReference)\n如果对象只具有软引用,那就类似可有可无的生活用品。 内存够则不会回收;内存不足则回收这些对象。只要垃圾回收器没有回收,那么对象就可以被程序使用。 软引用可用来实现内存敏感的高速缓存 软引用可以和一个引用队列(ReferenceQueue)联合使用,如果软引用所引用的对象被垃圾回收,Java虚拟机就会把这个软引用加入到与之关联的引用队列中 弱引用(WeakReference)\n如果对象只具有弱引用,则类似于可有可无的生活用品 弱引用和软引用的区别:只具有弱引用的对象拥有更短暂的生命周期 垃圾回收器线程扫描它所管辖的内存区域的过程中,一旦发现只具有弱引用的对象,不管当前内存足够与否,都会回收它的内存。不过垃圾回收器是一个优先级很低的线程,因此不一定会很快发现那些只具有弱引用的对象 弱引用可以和一个引用队列(ReferenceQueue)联合使用,如果弱引用所引用的对象被垃圾回收,Java虚拟机就会把这个弱引用加入到与之关联的引用队列中 虚引用(PhantomReference) [ˈfæntəm] 英\n与其他引用不同,虚引用并不会决定对象声明周期。如果一个仅持有虚拟引用,那么它就跟没有任何引用一样,在任何时候都可能被垃圾回收\n虚引用主要用来跟踪对象被垃圾回收的活动\n虚引用、软引用和弱引用的区别:虚引用必须和引用队列(ReferenceQueue)联合使用。\n当垃圾回收器准备回收一个对象时,如果发现它还有虚引用,就会在回收对象的内存之前,把这个虚引用加入到与之关联的引用队列。 程序可以通过判断引用队列是否加入虚引用,来了解被引用的对象是否将被垃圾回收 如果发现某个虚引用已经被加入到引用队列,那么就可以在所引用的对象被回收之前采取必要的行动 在程序设计中一般很少使用弱引用与虚引用,使用软引用的情况较多,这是因为软引用可以加速 JVM 对垃圾内存的回收速度,可以维护系统的运行安全,防止内存溢出(OutOfMemory)等问题的产生\nThreadLocal中的key用到了弱引用\n如何判断一个常量是废弃常量 # 运行时常量池主要回收的是废弃的常量\nJDK1.7 之前,运行时常量池逻辑,包括字符串常量池,存放在方法区,此时hotspot虚拟机对方法区的实现为永久代 JDK1.7字符串常量池(以及静态变量)被从方法区拿到了堆中,这里没有提到运行时常量池,也就是说字符串常量池被单独拿到堆,运行时常量池剩下的东西,还在方法区。即hotspot中的永久代 JDK1.8 hotspot移除了永久代,用元空间Metaspace取代之,这时候字符串常量池还在堆,运行时常量池还在方法区,只不过方法区的实现从永久代变成了元空间Metaspace ★★ 假如字符串常量池存在字符串“abc”,如果当前没有任何String对象引用该字符串常量的话,就说明常量“abc”是废弃常量。如果这时发生内存回收并且有必要的话,“abc”就会被系统清理出常量池\n如何判断一个类是无用类 # 方法区主要回收的是无用的类,判断一个类是否是无用的类相对苛刻,需要同时满足下面条件\n该类所有实例都已经被回收,即Java堆中不存在该类的任何实例 加载该类的ClassLoader已经被回收 该类对应的java.lang.Class对象没有在任何地方被引用,无法在任何地方通过反射访问该类方法 Java虚拟机可以对满足上述3个条件的无用类进行回收,是**“可以”,而不是必然**\n垃圾收集算法 # 标记-清除算法 # 该算法分为**“标记”和“清除”阶段:\n标记出所有不需要回收的对象**,在标记完成后统一回收掉所有没有被标记的对象\n这是最基础的收集算法,后续的算法都是对其不足进行改进得到,有两个明显问题:\n效率问题 空间问题(标记清除后会产生大量不连续碎片) 标记-复制算法 # 将内存分为大小相同的两块,每次使用其中一块 当这块内存使用完后,就将还存活的对象复制到另一块去,然后再把使用的空间一次清理掉 这样每次内存回收都是对内存区间的一半进行回收 标记-整理算法 # 根据老年代特点提出的一种标记算法,标记过程仍然与**“标记-清除”算法一样,但后续不是直接对可回收对象回收,而是让所有存活对象向一端移动**,然后直接清理掉端边界以外的内存\n分代收集算法 # 当前虚拟机的垃圾收集都采用分代收集算法,没有新的思想,只是根据对象存活周期的不同将内存分为几块。\n对象存活周期,也就是有些对象活的时间短,有些对象活的时间长。\n一般将Java堆分为新生代和老年代,这样就可以根据各个年代的特点,选择合适的垃圾收集算法\n新生代中,每次收集都会有大量对象死去,所以可以选择**“标记-复制”算法,只需要付出少量对象的复制成本就可以完成每次垃圾收集** 老年代对象存活几率是比较高的,而且没有额外的空间对它进行分配担保,所以必须选择标记-清除或者**“标记-整理”**算法进行垃圾收集 垃圾收集器 # 收集算法是内存回收的方法论,而垃圾收集器则是内存回收的具体实现 没有最好的垃圾收集器,也没有万能的,应该根据具体应用场景,选择适合自己的垃圾收集器 汇总 # 新生代的垃圾回收器:Serial(串行\u0026ndash;标记复制),ParNew(并行\u0026ndash;标记复制),ParallelScavenge(并行\u0026ndash;标记复制) 老年代的垃圾回收器:SerialOld(串行\u0026ndash;标记整理),ParallelOld(并行\u0026ndash;标记整理),CMS(并发\u0026ndash;标记清除) 只有CMS和G1是并发,且CMS只作用于老年代,而G1都有 JDK8为止,默认垃圾回收器是Parallel Scavenge和Parallel Old【并行\u0026ndash;复制和并行\u0026ndash;标记整理】 JDK9开始,G1收集器成为默认的垃圾收集器,目前来看,G1回收期停顿时间最短且没有明显缺点,偏适合Web应用 jdk8中测试Web应用,堆内存6G中新生代4.5G的情况下\nParallelScavenge回收新生代停顿长达1.5秒。 G1回收器回收同样大小的新生代只停顿0.2秒 Serial 收集器 # Serial 串行 收集器是最基本、历史最悠久的垃圾收集器\n这是一个单线程收集器,它的单线程意义不仅意味着它只会使用一条垃圾收集线程去完成垃圾收集工作,更重要的是它在进行垃圾收集工作时必须暂停其他所有的工作线程**(”Stop The World“),直到它收集结束**。\n新生代采用标记-复制算法,老年代采用标记-整理算法 StopTheWorld会带来不良用户体验,所以在后续垃圾收集器设计中停顿时间不断缩短。(仍然有停顿,垃圾收集器的过程仍然在继续) 优点:简单而高效(与其他收集器的单线程相比) 且由于其没有线程交互的开销,自然可以获得很高的单线程收集效率 Serial收集器对于运行在Client模式下的虚拟机来说是个不错的选择 -XX:+UseSerialGC #虚拟机运行在Client模式下的默认值,Serial+Serial Old。 ParNew 收集器 # ParNew收集器其实就是Serial收集器的多线程版本,除了使用多线程进行垃圾收集外,其余行为(控制参数、收集算法、回收策略等等)和Serial收集器完全一样\n新生代采用标记-复制算法,老年代采用标记-整理算法\n★★★ 这是许多运行在Server模式下的虚拟机的首要选择,除了Serial收集器外,只有它能与CMS收集器(真正意义上的并发收集器)配合工作(ParNew是并行)\n并行和并发概念补充\n并行(Parallel):指多条垃圾收集线程并行工作,但此时用户线程仍然处于等待状态 并发(Concurrent):指用户线程与垃圾收集线程 同时执行(不一定并行,可能会交替执行),用户程序在继续执行,而收集收集器运行在另一个CPU上 -XX:+UseParNewGC #ParNew+Serial Old,在JDK1.8被废弃,在JDK1.7还可以使用。 ParallelScavenge 收集器 # 它也是标记-复制算法的多线程收集器,看上去几乎和ParNew一样,区别\n部分参数 (有点争议,先以下面为准)\n-XX:+UseParallelGC # 虚拟机运行在Server模式下的默认值(1.8) 新生代使用ParallelGC,老年代使用回收器 ; ★★ JDK1.7之后,能达到UseParallelOldGC 的效果 ## 参考自 https://zhuanlan.zhihu.com/p/353458348 -XX:+UseParallelOldGC # 新生代使用ParallelGC,老年代使用ParallelOldGC Parallel Scavenge收集器关注点是吞吐量(高效率利用CPU),CMS等垃圾收集器关注点是用户的停顿时间(提高用户体验)\n所谓吞吐量就是CPU中用于运行用户代码的时间与CPU总消耗时间的比值 (也就是希望消耗少量CPU就能运行更多代码)\nParallel Scavenge 收集器提供了很多参数供用户找到最合适的停顿时间或最大吞吐量,如果对于收集器运作不太了解,手工优化存在困难的时候,使用 Parallel Scavenge 收集器配合自适应调节策略,把内存管理优化交给虚拟机去完成也是一个不错的选择。\n新生代采用标记-复制,老年代采用标记-整理算法 这是JDK1.8 的默认收集器 使用 java -XX:+PrintCommandLineFlags -version 命令查看 如下,两种情况:\n#默认 λ java -XX:+PrintCommandLineFlags -version -XX:InitialHeapSize=531924800 -XX:MaxHeapSize=8510796800 -XX:+PrintCommandLineFlags -XX:+UseCompressedClassPointers -XX:+UseCompressedOops -XX:-UseLargePagesIndividualAllocation -XX:+UseParallelGC java version \u0026#34;1.8.0_202\u0026#34; Java(TM) SE Runtime Environment (build 1.8.0_202-b08) Java HotSpot(TM) 64-Bit Server VM (build 25.202-b08, mixed mode) 第二种情况:(注意:-XX:-UseParallelOldGC)\nλ java -XX:-UseParallelOldGC -XX:+PrintCommandLineFlags -version -XX:InitialHeapSize=531924800 -XX:MaxHeapSize=8510796800 -XX:+PrintCommandLineFlags -XX:+UseCompressedClassPointers -XX:+UseCompressedOops -XX:-UseLargePagesIndividualAllocation -XX:+UseParallelGC -XX:-UseParallelOldGC java version \u0026#34;1.8.0_202\u0026#34; Java(TM) SE Runtime Environment (build 1.8.0_202-b08) Java HotSpot(TM) 64-Bit Server VM (build 25.202-b08, mixed mode) SerialOld 收集器 # Serial收集器的老年代版本,是一个单线程收集器 在JDK1.5以及以前的版本中,与Parallel Scavenge收集器搭配时候 作为CMS收集器的后备方案 ParallelOld 收集器 # Parallel Scavenge收集器的老年代版本,使用多线程和标记-整理算法 在注重吞吐量以及CPU资源的场合,都可以考虑ParallelScavenge和ParallelOld收集器 CMS 收集器 # CMS,Concurrent Mark Sweep,是一种以获取最短回收停顿时间为目标的收集器,非常符合注重用户体验的引用上使用\nCMS收集器是HotSpot虚拟机上第一款真正意义上的并发收集器,第一次实现了让垃圾收集线程与用户线程(基本上)同时工作\nMark-Sweep,是一种“标记-清除”算法,运作过程相比前面几种垃圾收集器来说更加复杂,步骤:\n初始标记:暂停所有其他线程,记录直接与root相连的对象,速度很快\n并发标记:同时 开启GC和用户线程 ,用一个闭包结构记录可达对象。但这个阶段结束,这个闭包结构并不能保证包含当前所有的可达对象。\n因为用户线程会不断更新引用域,所以GC线程无法保证可达性分析的实时性\n所以这个算法里会跟踪记录这些发生引用更新的地方\n重新标记:目的是修正并发标记期间因为用户程序继续运行而导致标记产生变动的那一部分对象的标记记录。\n这个阶段停顿时间一般会被初始标记阶段时间稍长,远远比并发标记阶段时间短\n并发清除:开启用户线程,同时GC线程开始对未扫描的区域做清扫\n从名字可以看出这是一款优秀的收集器:并发收集、低停顿。但有三个明显缺点\n对CPU资源敏感\n无法处理浮动垃圾\n浮动垃圾的解释:就是之前被gc 标记为 可达对象,也就是 存活对象,在两次gc线程之间被业务线程删除了引用,那么颜色不会更改,还是之前的颜色(黑色or灰色),但是其实是白色,所以这一次gc 无法对其回收,需要等下一次gc初始标记启动才会被刷成白色 作者:Yellowtail 链接:https://www.jianshu.com/p/6590aaad82f7 来源:简书\n它使用的收集算法**“标记-清除”算法会导致收集结束时会有大量空间碎片产生**\nG1 收集器 # G1(Garbage-First),是一款面向服务器的垃圾收集器,主要针对配备多颗处理器以及大容量内存的极其,以极高概率满足GC停顿时间要求的同时,还具备高吞吐量性能特征\nJDK1.7中HotSpot虚拟机的一个重要进化特征,具备特点:\n并行与并发:\nG1 能充分利用 CPU、多核环境下的硬件优势,使用多个 CPU(CPU 或者 CPU 核心)来缩短 Stop-The-World 停顿时间。部分其他收集器原本需要停顿 Java 线程执行的 GC 动作,G1 收集器仍然可以通过并发的方式让 java 程序继续执行\n分代收集:\n虽然 G1 可以不需要其他收集器配合就能独立管理整个 GC 堆,但是还是保留了分代的概念。\n空间整合:\n与 CMS 的“标记-清理”算法不同,G1 从整体来看是基于**“标记-整理”算法实现的收集器;从局部上来看是基于“标记-复制”**算法实现的。\n可预测的停顿:\n这是 G1 相对于 CMS 的另一个大优势,降低停顿时间是 G1 和 CMS 共同的关注点,但 G1 除了追求低停顿外,还能建立可预测的停顿时间模型,能让使用者明确指定在一个长度为 M 毫秒的时间片段内。\nG1 收集器的运作大致分为以下几个步骤\n初始标记 并发标记 最终标记 筛选回收 G1 收集器在后台维护了一个优先列表,每次根据允许的收集时间,优先选择回收价值最大的 Region(这也就是它的名字 Garbage-First 的由来) 。这种使用 Region 划分内存空间以及有优先级的区域回收方式,,保证了 G1 收集器在有限时间内可以尽可能高的收集效率(把内存化整为零)\nZGC 收集器 # The Z Garbage Collector\n与 CMS 中的 ParNew 和 G1 类似,ZGC 也采用标记-复制算法,不过 ZGC 对该算法做了重大改进。\n在 ZGC 中出现 Stop The World 的情况会更少!\nJDK11,相关文章 https://tech.meituan.com/2020/08/06/new-zgc-practice-in-meituan.html\n"},{"id":316,"href":"/zh/docs/technology/Review/java_guide/java/JVM/ly0407lyjvm-intro/","title":"jvm-intro","section":"Java基础","content":" 转载自https://github.com/Snailclimb/JavaGuide(添加小部分笔记)感谢作者!\n原文地址: https://juejin.im/post/5e1505d0f265da5d5d744050#heading-28 感谢原作者分享!!\nJVM的基本介绍 # JVM,JavaVirtualMachine的缩写,虚拟出来的计算机,通过在实际的计算机上仿真模拟各类计算机功能实现 JVM类似一台小电脑,运行在windows或者linux这些真实操作系统环境下,直接和操作系统交互,与硬件不直接交互,操作系统帮我们完成和硬件交互的工作 Java文件是如何运行的 # 场景假设:我们写了一个HelloWorld.java,这是一个文本文件。JVM不认识文本文件,所以需要一个编译,让其(xxx.java)成为一个JVM会读的二进制文件\u0026mdash;\u0026gt; HelloWorld.class\n类加载器 如果JVM想要执行这个.class文件,需要将其**(这里应该指的二进制文件)装进类加载器**中,它就像一个搬运工一样,会把所有的.class文件全部搬进JVM里面 方法区\n类加载器将.class文件搬过来,就是先丢到这一块上\n方法区是用于存放类似于元数据信息方面的数据的,比如类信息、常量、静态变量、编译后代码\u0026hellip;等\n堆 堆主要放一些存储的数据,比如对象实例、数组\u0026hellip;等,它和方法区都同属于线程共享区域,即它们都是线程不安全的\n栈\n线程独享\n栈是我们代码运行空间,我们编写的每一个方法都会放到栈里面运行。\n名词:本地方法栈或本地方法接口,不过我们基本不会涉及这两块内容,这两底层使用C进行工作,和Java没有太大关系\n程序计数器 主要就是完成一个加载工作,类似于一个指针一样的,指向下一行我们需要执行的代码。和栈一样,都是线程独享的,就是每一个线程都会自己对应的一块区域而不会存在并发和多线程问题。\n小总结 Java文件经过编译后编程.class字节码文件 字节码文件通过类加载器被搬运到 JVM虚拟机中 虚拟机主要的5大块:方法区、堆 都为线程共享区域,有线程安全问题;栈和本地方法栈和计数器都是独享区域,不存在线程安全问题,而JVM的调优主要就是围绕堆、栈两大块进行 简单的代码例子 # 一个简单的学生类及main方法:\npublic class Student { public String name; public Student(String name) { this.name = name; } public void sayName() { System.out.println(\u0026#34;student\u0026#39;s name is : \u0026#34; + name); } } main方法:\npublic class App { public static void main(String[] args) { Student student = new Student(\u0026#34;tellUrDream\u0026#34;); student.sayName(); } } ★★ 执行main方法的步骤如下\n编译好App.java后得到App.class后,执行APP.class,系统会启动一个JVM进程,从classpath类路径中找到一个名为APP.class的二进制文件,将APP的类信息加载到运行时数据区的方法区内,这个过程叫做APP类的加载 JVM找到APP的主程序入口,执行main方法 这个main的第一条语句**(指令)**为 Student student = new Student(\u0026quot;tellUrDream\u0026quot;),就是让JVM创建一个Student对象,但是这个时候方法区是没有Student类的信息的,所以JVM马上加载Student类,把Student类的信息放到方法区中 加载完Student类后,JVM在堆中为一个新的Student实例分配内存,然后调用构造函数初始化Student实例,这个Student实例**(对象)持有指向方法区中的Student类的类型信息**的引用 执行student.sayName;时,JVM根据student的引用找到student对象,然后根据student对象持有的引用定位到方法区中student类的类型信息的方法表,获得sayName()的字节码地址。 执行sayName() 其实也不用管太多,只需要知道对象实例初始化时,会去方法区中找到类信息(没有的话先加载),完成后再到栈那里去运行方法\n类加载器的介绍 # 类加载器负责加载.class文件,.class文件的开头会有特定的文件标识,将class文件字节码内容加载到内存中,并将这些内容转换成方法区中的运行时数据结构,并且ClassLoader只负责class文件的加载,而能否运行则由Execution Engine来决定\n类加载器的流程 # 从类被加载到虚拟机内存中开始,到释放内存总共有7个步骤:\n加载,验证,准备,解析,初始化,使用,卸载。\n其中验证,准备,解析三个部分统称为链接\n加载 # 将class文件加载到内存 将静态数据结构转化成方法区中运行的数据结构 在堆中生成一个代表这个类的java.lang.Class对象作为数据访问的入口 链接 # 验证:确保加载的类符合JVM规范和安全,保证被校验类的方法在运行时不会做出危害虚拟机的事件,其实就是一个安全检查 准备:为static变量在方法区分配内存空间,设置变量的初始值,例如static int = 3 (注意:准备阶段只设置类中的静态变量(方法区中),不包括实例变量(堆内存中),实例变量是对象初始化时赋值的) 解析:虚拟机将常量池内的符号引用,替换为直接引用的过程(符号引用比如我现在 import java.util.ArrayList 这就算符号引用,直接引用就是指针或者对象地址,注意引用对象一定是在内存进行) 初始化 # 初始化就是执行类构造器方法的clinit()的过程,而且要保证执行前父类的clinit()方法已经执行完毕。 这个方法由编译器收集(也就是编译时产生),顺序执行所有类变量(static 修饰的成员变量) 显示初始化和静态代码块中语句 此时准备阶段时的那个static int a 由默认初始化的0变成了显示初始化的3。由于执行顺序缘故,初始化阶段类变量如果在静态代码中又进行更改,则会覆盖类变量的显式初始化,最终值会为静态代码块中的赋值 字节码文件中初始化方法有两种,非静态资源初始化的init和静态资源初始化的clinit 类构造器方法clinit() 不同于类的构造器,这些方法都是字节码文件中只能给JVM识别的特殊方法 卸载 # GC将无用对象从内存中卸载\n类加载器的加载顺序 # 加载一个Class类的顺序也是有优先级的**(加载,也可以称\u0026quot;查找\u0026quot;)** ,类加载器 从最底层开始往上的顺序:\nBootStrap ClassLoader: rt.jar (lib/rt.jar) Extension ClassLoader: 加载扩展的jar包 (lib/ext/xxx.jar) APP ClassLoader: 指定的classpath下面的jar包 Custom ClassLoader: 自定义的类加载器 双亲委派机制 # 当一个类收到了加载请求时,它是不会先自己去尝试加载的,而是委派给父类去完成,比如我现在要 new 一个 Person,这个 Person 是我们自定义的类,如果我们要加载它,就会先委派 App ClassLoader ,只有当父类加载器都反馈自己无法完成这个请求(也就是父类加载器都没有找到加载所需的 Class)时,子类加载器才会自行尝试加载。\n好处:加载位于 rt.jar 包中的类时不管是哪个加载器加载,最终都会委托到 BootStrap ClassLoader 进行加载,这样保证了使用不同的类加载器得到的都是同一个结果。\n其实这起了一个隔离的作用,避免自己写的代码影响JDK的代码\npackage java.lang; public class String { public static void main(String[] args) { System.out.println(); } } 尝试运行当前类的 main 函数的时候,我们的代码肯定会报错。这是因为在加载的时候其实是找到了 rt.jar 中的java.lang.String,然而发现这个里面并没有 main 方法。\n运行时数据区 # 本地方法栈和程序计数器 # 比如说我们现在点开Thread类的源码,会看到它的start0方法带有一个native关键字修饰,而且不存在方法体,这种用native修饰的方法就是本地方法,这是使用C来实现的,然后一般这些方法都会放到一个叫做本地方法栈的区域。 程序计数器其实就是一个指针,它指向了我们程序中下一句需要执行的指令,它也是内存区域中唯一一个不会出现OutOfMemoryError的区域,而且占用内存空间小到基本可以忽略不计。这个内存仅代表当前线程所执行的字节码的行号指示器,字节码解析器通过改变这个计数器的值选取下一条需要执行的字节码指令。 如果执行的是native方法,那这个指针就不工作了 方法区 # 主要存放类的元数据信息、常量和静态变量\u0026hellip;等。 存储过大时,会在无法满足内存分配时报错 虚拟机栈和虚拟机堆 # 栈管运行,堆管存储 虚拟机栈负责运行代码,虚拟机堆负责存储数据 虚拟机栈的概念 # 虚拟机栈是Java方法执行的内存模型 对局部变量、动态链表、方法出口、栈的操作(入栈和出栈)进行存储,且线程独享。 如果我们听到局部变量表,就是在说虚拟机栈 public class Person{ int a = 1; public void doSomething(){ int b = 2; } } 虚拟机栈存在的异常 # 如果线程请求的栈的深度,大于虚拟机栈的最大深度,就会报StackOverflowError(比如递归) Java虚拟机也可以动态扩展,但随着扩展会不断地申请内存,当无法申请足够内存时就会报错 OutOfMemoryError 虚拟机栈的生命周期 # 栈不存在垃圾回收,只要程序运行结束,栈的空间自然释放 栈的生命周期和所处的线程一致 8种基本类型的变量+对象的引用变量+实例方法,都是在栈里面分配内存 虚拟机栈的执行 # 栈帧数据,在JVM中叫栈帧,Java中叫方法,它也是放在栈中 栈中的数据以栈帧的格式存在,它是一个关于方法和运行期数据的数据集 比如我们执行一个方法a,就会对应产生一个栈帧A1,然后A1会被压入栈中。同理方法b会有一个B1,方法c会有一个C1,等到这个线程执行完毕后,栈会先弹出C1,后B1,A1。它是一个先进后出,后进先出原则。\n局部变量的复用 # 用于存放方法参数和方法内部所定义的局部变量\n容量以Slot为最小单位,一个slot可以存放32以内的数据类型。\n在局部变量表里,32位以内的类型只占用一个slot(包括returnAddress类型),64位的类型(long和double)占两个slot。\n虚拟机通过索引方式使用局部变量表,范围为 [ 0 , 局部变量表的slot的数量 ]。方法中的参数就会按一定顺序排列在这个局部变量表中\n为了节省栈帧空间,这些slot是可以复用的。当方法执行位置超过了某个变量(这里意思应该是用过了这个变量),那么这个变量的slot可以被其它变量复用。当然如果需要复用,那我们的垃圾回收自然就不会去动这些内存\n虚拟机堆的概念 # JVM内存会划分为堆内存和非堆内存,堆内存也会划分为年轻代和老年代,而非堆内存则为永久代。\n年轻代又分为Eden和Survivor区,Survivor还分为FromPlace和ToPlace,toPlace的survivor区域是空的\nEden:FromPlace:ToPlace的默认占比是8:1:1,当然这个东西也可以通过一个-XX:+UsePSAdaptiveSurvivorSizePolicy参数来根据生成对象的速率动态调整\n(因为存活的对象相对较少)\n堆内存中存放的是对象,垃圾收集就是收集这些对象然后交给GC算法进行回收。非堆内存其实我们已经说过了,就是方法区。在1.8中已经移除永久代,替代品是一个元空间(MetaSpace),最大区别是metaSpace是不存在于JVM中的,它使用的是本地内存。并有两个参数:\nMetaspaceSize:初始化元空间大小,控制发生GC MaxMetaspaceSize:限制元空间大小上限,防止占用过多物理内存。 移除的原因\n融合HotSpot JVM和JRockit VM而做出的改变,因为JRockit是没有永久代的,不过这也间接性地解决了永久代的OOM问题。\nEden年轻代的介绍 # 当new一个对象后,会放到Eden划分出来的一块作为存储空间的内存,由于堆内存共享,所以可能出现两个对象共用一个内存的情况。\nJVM的处理:为每个内存都预先申请好一块连续的内存空间并规定对象存放的位置,如果空间不足会再申请多块内存空间。这个操作称为TLAB\nEden空间满了之后,会触发MinorGC(发生在年轻代的GC)操作,存活下来的对象移动到Survivor0区。Survivor0满后会触发MInorGC,将存活对象(这里应该包括Eden的存活对象?)移动到Survivor1区,此时还会把from和to两个指针交换,这样保证一段时间内总有一个survivor区为空且所指向的survivor区为空。\n经过多次的MinorGC后仍然存活的对象(这里存活判断是15次,对应的虚拟机参数为-XX:MaxTenuringThreshold 。HotSpot会在对象中的标记字段里记录年龄,分配到的空间仅有4位,所以最多记录到15)会移动到老年代。\n老年代是存储长期存活对象的,占满时就会触发我们常说的FullGC,期间会停止所有线程等待GC的完成。所以对于响应要求高的应用,应该尽量去减少发生FullGC从而避免响应超时的问题\n当老年区执行full gc周仍然无法进行对象保存操作,就会产生OOM。这时候就是虚拟机中堆内存不足,原因可能会是堆内存设置大小过小,可以通过参数**-Xms、-Xmx来调整。也可能是代码中创建对象大且多**,而且它们一直在被引用从而长时间垃圾收集无法收集它们\n关于-XX:TargetSurvivorRatio参数的问题。其实也不一定是要满足-XX:MaxTenuringThreshold才移动到老年代。可以举个例子:如**对象年龄5的占30%,年龄6的占36%,年龄7的占34%,加入某个年龄段(如例子中的年龄6)**后,总占用超过Survivor空间*TargetSurvivorRatio的时候,从该年龄段开始及大于的年龄对象就要进入老年代(即例子中的年龄6对象,就是年龄6和年龄7晋升到老年代),这时候无需等到MaxTenuringThreshold中要求的15\n如何判断一个对象需要被干掉 # 首先看一下对象的虚拟机的一些流程\n图例有点问题,橙色是线程共享,青绿色是线程独享 图中程序计数器、虚拟机栈、本地方法栈,3个区域随着线程生存而生存。内存分配和回收都是确定的,随着线程的结束内存自然就被回收了,因此不需要考虑垃圾回收问题。\nJava堆和方法区则不一样,各线程共享,内存的分配和回收都是动态的,垃圾收集器所关注的就是堆和方法区这部分内存\n垃圾回收前,判断哪些对象还存活,哪些已经死去。下面介绍连个基础计算方法:\n引用计数器计算:给对象添加一个引用计数器,每次引用这个对象时计数器加一,引用失效时减一,计数器等于0就是不会再次使用的。不过有一种情况,就是 出现对象的循环引用时GC没法回收(我觉得不是非得循环,如果一个对象a中有属性引用另一个对象b,而a指向null,那么按这种方式,b就没有办法被回收)。\n可达性分析计算:一种类似二叉树的实现,将一系列的GC ROOTS作为起始的存活对象集,从这个结点往下搜索,搜索所走过的路径成为引用链,把能被该集合引用到的对象加入该集合中。\n当一个对象到GC Roots没有使用任何引用链时,则说明该对象是不可用的。Java,C#都是用这个方法判断对象是否存活\nJava语言汇总作为GCRoots的对象分为以下几种:\n虚拟机栈(栈帧中的本地方法表)中引用的对象(局部变量)\n方法区中静态变量所引用的对象(静态变量)\n方法区中常量引用的变量\n本地方法栈(即native修饰的方法)中JNI引用的对象\n(JNI是Java虚拟机调用对应的C函数的方式,通过JNI函数也可以创建新的Java对象。且JNI对于对象的局部引用或者全局引用都会把它们指向的对象都标记为不可回收)\n已启动的且未终止的Java线程【这个描述好像是有问题的(不全),应该是用作同步监视器的对象】\n这种方法的优点是,能够解决循环引用的问题,可它的实现耗费大量资源和时间,也需要GC(分析过程引用关系不能发生变化,所以需要停止所有进程)\n如何宣告一个对象的真正死亡 # 首先,需要提到finalize()方法,是Object类的一个方法,一个对象的finalize()方法只会被系统自动调用一次,经过finalize()方法逃脱死亡的对象(比如在方法中,其他变量又一次引用了该对象),第二次不会再被调用\n并不提倡在程序中调用finalize()来进行自救。建议忘掉Java程序中该方法的存在。因为它执行的时间不确定,甚至是否被执行也不确定(Java程序的不正常退出),而且运行代价高昂,无法保证各个对象的调用顺序(甚至有不同线程中调用)。在Java9中已经被标记为 deprecated ,且 java.lang.ref.Cleaner(也就是强、软、弱、幻象引用的那一套)中已经逐步替换掉它,会比 finalize 来\ndeprecated英[ˈdeprəkeɪtɪd]美[ˈdeprəkeɪtɪd]\n判断一个对象的死亡至少需要两次标记\n如果对象可达性分析之后没发现与GC Roots相连的引用链,那它将会被第一次标记并且进行一次筛选,判断条件是是决定**这个对象是否有必要执行finalize()**方法。如果对象有必要执行finalize(),则被放入F-Queue队列 GC堆F-Queue队列中的对象进行二次标记。如果对象在finalize()方法中重新与引用链上的任何一个对象建立了关联,那么二次标记时则会将它移出“即将回收”集合。如果此时对象还没成功逃脱,那么只能被回收了。 垃圾回收算法 # 确定对象已经死亡,此刻需要回收这些垃圾。常用的有标记清除、复制、标记整理、和分代收集算法。\n标记清除算法 # 标记清除算法就是分为**”标记“和”清除“**两个阶段。标记出所有需要回收的对象,标记结束后统一回收。后续算法都根据这个基础来加以改进 即:把已死亡的对象标记为空闲内存,然后记录在空闲列表中,当我们需要new一个对象时,内存管理模块会从空闲列表中寻找空闲的内存来分给新的对象 不足方面:标记和清除效率比较低,且这种做法让内存中碎片非常多 。导致如果我们需要使用较大内存卡时,无法分配到足够的连续内存 如图,可使用的内存都是零零散散的,导致大内存对象问题 复制算法 # 为了解决效率问题,出现了复制算法。将内存按容量划分成两等份,每次只使用其中的一块,和survivor一样用from和to两个指针。fromPlace存满了,就把存活对象copy到另一块toPlace上,然后交换指针内容,就解决了碎片问题\n代价:内存缩水,即堆内存的使用效率变低了 默认情况Eden和Survivor 为 8: 2 (Eden : S0 : S1 = 8:1:1)\n标记整理 # 复制算法在对象存活率高的时候,仍然有效率问题(要复制的多)。 标记整理\u0026ndash;\u0026gt; 标记过程与标记-清除一样,但后续不是直接对可回收对象进行清理,而是让所有存活对象都向一端移动,然后直接清理掉边界以外内存 分代收集算法 # 这种算法并没有什么新的思想,只是根据对象存活周期的不同将内存划分为几块 一般是将Java堆分为新生代和老年代,即可根据各个年代特点采用最适当的收集算法 新生代中,每次垃圾收集时会有大批对象死去,只有少量存活,就采用复制算法,只需要付出少量存活对象的复制成本即可完成收集 老年代中,因为存活对象存活率高,也没有额外空间对它进行分配担保(新生代如果不够可以放老年代,而老年代清理失败就会OutOfMemory,不像新生代可以移动到老年代),所以必须使用**“标记-清理”或者“标记-整理”**来进行回收 即:具体问题具体分析 (了解)各种各样的垃圾回收器 # 新生代的垃圾回收器:Serial(串行\u0026ndash;复制),ParNew(并行\u0026ndash;复制),ParallelScavenge(并行\u0026ndash;复制)\n老年代的垃圾回收器:SerialOld(串行\u0026ndash;标记整理),ParallelOld(并行\u0026ndash;标记整理),CMS(并发\u0026ndash;标记清除)\n只有CMS和G1是并发,且CMS只作用于老年代,而G1都有\nJDK8为止,默认垃圾回收器是Parallel Scavenge和Parallel Old【并行\u0026ndash;复制和并行\u0026ndash;标记整理】\nJDK9开始,G1收集器成为默认的垃圾收集器,目前来看,G1回收期停顿时间最短且没有明显缺点,偏适合Web应用\njdk8中测试Web应用,堆内存6G中新生代4.5G的情况下\nParallelScavenge回收新生代停顿长达1.5秒。 G1回收器回收同样大小的新生代只停顿0.2秒 (了解) JVM的常用参数 # JVM的参数非常之多,这里只列举比较重要的几个,通过各种各样的搜索引擎也可以得知这些信息。\n参数名称 含义 默认值 说明 -Xms 初始堆大小 物理内存的1/64(\u0026lt;1GB) 默认(MinHeapFreeRatio参数可以调整)空余堆内存小于40%时,JVM就会增大堆直到-Xmx的最大限制. -Xmx 最大堆大小 物理内存的1/4(\u0026lt;1GB) 默认(MaxHeapFreeRatio参数可以调整)空余堆内存大于70%时,JVM会减少堆直到 -Xms的最小限制 -Xmn 年轻代大小(1.4or later) 注意:此处的大小是(eden+ 2 survivor space).与jmap -heap中显示的New gen是不同的。整个堆大小=年轻代大小 + 老年代大小 + 持久代(永久代)大小.增大年轻代后,将会减小年老代大小.此值对系统性能影响较大,Sun官方推荐配置为整个堆的3/8 -XX:NewSize 设置年轻代大小(for 1.3/1.4) -XX:MaxNewSize 年轻代最大值(for 1.3/1.4) -XX:PermSize 设置持久代(perm gen)初始值 物理内存的1/64 -XX:MaxPermSize 设置持久代最大值 物理内存的1/4 -Xss 每个线程的堆栈大小 JDK5.0以后每个线程堆栈大小为1M,以前每个线程堆栈大小为256K.根据应用的线程所需内存大小进行 调整.在相同物理内存下,减小这个值能生成更多的线程.但是操作系统对一个进程内的线程数还是有限制的,不能无限生成,经验值在3000~5000左右一般小的应用, 如果栈不是很深, 应该是128k够用的 大的应用建议使用256k。这个选项对性能影响比较大,需要严格的测试。(校长)和threadstacksize选项解释很类似,官方文档似乎没有解释,在论坛中有这样一句话:-Xss is translated in a VM flag named ThreadStackSize”一般设置这个值就可以了 -XX:NewRatio 年轻代(包括Eden和两个Survivor区)与年老代的比值(除去持久代) -XX:NewRatio=4表示年轻代与年老代所占比值为1:4,年轻代占整个堆栈的1/5Xms=Xmx并且设置了Xmn的情况下,该参数不需要进行设置。 -XX:SurvivorRatio Eden区与Survivor区的大小比值 设置为8,则两个Survivor区与一个Eden区的比值为2:8,一个Survivor区占整个年轻代的1/10 -XX:+DisableExplicitGC 关闭System.gc() 这个参数需要严格的测试 -XX:PretenureSizeThreshold 对象超过多大是直接在旧生代分配 0 单位字节 新生代采用Parallel ScavengeGC时无效另一种直接在旧生代分配的情况是大的数组对象,且数组中无外部引用对象. -XX:ParallelGCThreads 并行收集器的线程数 此值最好配置与处理器数目相等 同样适用于CMS -XX:MaxGCPauseMillis 每次年轻代垃圾回收的最长时间(最大暂停时间) 如果无法满足此时间,JVM会自动调整年轻代大小,以满足此值. 其实还有一些打印及CMS方面的参数,这里就不以一一列举了\n关于JVM调优的一些方面 # 默认\n年轻代:老年代 = 1: 2 年轻代中 Eden : S0 : S 1 = 8 : 1 :1 根据刚刚涉及的jvm知识点,可以尝试对JVM进行调优,主要是堆内存那块\n所有线程共享数据区大小=新生代大小+老年代大小+持久代大小 (即 堆 + 方法区)\n持久代一般固定大小为64m,\njava堆中增大年轻代后,会减少老年代大小(因为老年代的清理使用fullgc,所以老年代过小的话反而会增多fullgc)。 年轻代 -Xmn的值推荐配置为java堆的3/8\n调整最大堆内存和最小堆内存 # -Xmx -Xms:指定java堆最大值(默认 物理内存的1/4 (\u0026lt;1 GB ) ) 和 初始java堆最小值(默认值是物理内存的1/64 (\u0026lt;1GB) )\n默认(MinHeapFreeRatio参数可以调整)空余堆内存小于40%时,JVM就会增大堆直到-Xmx的最大限制.,默认(MaxHeapFreeRatio参数可以调整)空余堆内存大于70%时,JVM会减少堆直到 -Xms的最小限制。\n简单点来说,你不停地往堆内存里面丢数据,等它剩余大小小于40%了,JVM就会动态申请内存空间不过会小于-Xmx,如果剩余大小大于70%,又会动态缩小不过不会小于–Xms。就这么简单\n开发过程中,通常会将 -Xms 与 Xmx 两个参数设置成相同的值\n为的是能够在java垃圾回收机制清理完堆区后,不需要重新分隔计算堆区的大小而浪费资源(向系统请求/释放内存资源)\n代码\npublic class App { public static void main(String[] args) { System.out.println(\u0026#34;Xmx=\u0026#34; + Runtime.getRuntime().maxMemory() / 1024.0 + \u0026#34;KB\u0026#34;); //系统的最大空间-Xmx--运行几次都不变 System.out.println(\u0026#34;free mem=\u0026#34; + Runtime.getRuntime().freeMemory() / 1024.0 + \u0026#34;KB\u0026#34;); //系统的空闲空间--每次运行都变 System.out.println(\u0026#34;total mem=\u0026#34; + Runtime.getRuntime().totalMemory() / 1024.0 + \u0026#34;KB\u0026#34;); //当前可用的总空间 与Xms有关--运行几次都不变 } } /* ----- Xmx=7389184.0KB free mem=493486.0546875KB total mem=498688.0KB */ maxMemory()这个方法返回的是java虚拟机(这个进程)能构从操纵系统那里挖到的最大的内存 freeMemory:挖过来而又没有用上的内存,实际上就是 freeMemory(),所以freeMemory()的值一般情况下都是很小的(totalMemory一般比需要用得多一点,剩下的一点就是freeMemory) totalMemory:程序运行的过程中,内存总是慢慢的从操纵系统那里挖的,基本上是用多少挖多少,直 挖到maxMemory()为止,所以totalMemory()是慢慢增大的 原文链接:https://blog.csdn.net/weixin_35671171/article/details/114189796 编辑VM options参数后再看效果:\n-Xmx20m -Xms5m -XX:+PrintGCDetails,堆最大以及堆初始值 20m和5m\n/* 效果 [GC (Allocation Failure) [PSYoungGen: 1024K-\u0026gt;488K(1536K)] 1024K-\u0026gt;608K(5632K), 0.0007606 secs] [Times: user=0.00 sys=0.00, real=0.00 secs] Xmx=18432.0KB free mem=4249.90625KB total mem=5632.0KB Heap PSYoungGen total 1536K, used 1326K [0x00000000ff980000, 0x00000000ffb80000, 0x0000000100000000) eden space 1024K, 81% used [0x00000000ff980000,0x00000000ffa51ad0,0x00000000ffa80000) from space 512K, 95% used [0x00000000ffa80000,0x00000000ffafa020,0x00000000ffb00000) to space 512K, 0% used [0x00000000ffb00000,0x00000000ffb00000,0x00000000ffb80000) ParOldGen total 4096K, used 120K [0x00000000fec00000, 0x00000000ff000000, 0x00000000ff980000) object space 4096K, 2% used [0x00000000fec00000,0x00000000fec1e010,0x00000000ff000000) Metaspace used 3164K, capacity 4496K, committed 4864K, reserved 1056768K class space used 344K, capacity 388K, committed 512K, reserved 1048576K */ 如上, Allocation Failure 因为分配失败导致YoungGen total mem (此时申请到的总内存):\nPSYoungGen + ParOldGen = 1536 + 4096 = 5632 KB freeMemory (申请后没有使用的内存)\n1324 + 120 = 1444 KB 5632 - 4249 = 1383 KB 差不多 使用1M后\npublic class App { public static void main(String[] args) { System.out.println(\u0026#34;Xmx=\u0026#34; + Runtime.getRuntime().maxMemory() / 1024.0 + \u0026#34;KB\u0026#34;); //系统的最大空间-Xmx--运行几次都不变 System.out.println(\u0026#34;free mem=\u0026#34; + Runtime.getRuntime().freeMemory() / 1024.0 + \u0026#34;KB\u0026#34;); //系统的空闲空间--每次运行都变 System.out.println(\u0026#34;total mem=\u0026#34; + Runtime.getRuntime().totalMemory() / 1024.0 + \u0026#34;KB\u0026#34;); //当前可用的总空间 与Xms有关--运行几次都不变 byte[] b = new byte[1 * 1024 * 1024]; System.out.println(\u0026#34;分配了1M空间给数组\u0026#34;); System.out.println(\u0026#34;Xmx=\u0026#34; + Runtime.getRuntime().maxMemory() / 1024.0 / 1024 + \u0026#34;M\u0026#34;); //系统的最大空间 System.out.println(\u0026#34;free mem=\u0026#34; + Runtime.getRuntime().freeMemory() / 1024.0 / 1024 + \u0026#34;M\u0026#34;); //系统的空闲空间 System.out.println(\u0026#34;total mem=\u0026#34; + Runtime.getRuntime().totalMemory() / 1024.0 / 1024 + \u0026#34;M\u0026#34;); } } /** [GC (Allocation Failure) [PSYoungGen: 1024K-\u0026gt;488K(1536K)] 1024K-\u0026gt;608K(5632K), 0.0007069 secs] [Times: user=0.00 sys=0.00, real=0.00 secs] Xmx=18432.0KB free mem=4270.15625KB total mem=5632.0KB 分配了1M空间给数组 Xmx=18.0M free mem=3.1700592041015625M //少了1M total mem=5.5M Heap PSYoungGen total 1536K, used 1270K [0x00000000ff980000, 0x00000000ffb80000, 0x0000000100000000) eden space 1024K, 76% used [0x00000000ff980000,0x00000000ffa43aa0,0x00000000ffa80000) from space 512K, 95% used [0x00000000ffa80000,0x00000000ffafa020,0x00000000ffb00000) to space 512K, 0% used [0x00000000ffb00000,0x00000000ffb00000,0x00000000ffb80000) ParOldGen total 4096K, used 1144K [0x00000000fec00000, 0x00000000ff000000, 0x00000000ff980000) object space 4096K, 27% used [0x00000000fec00000,0x00000000fed1e020,0x00000000ff000000) Metaspace used 3155K, capacity 4496K, committed 4864K, reserved 1056768K class space used 344K, capacity 388K, committed 512K, reserved 1048576K */ 此时free memory就又缩水了,不过total memory是没有变化的。Java会尽可能将total mem的值维持在最小堆内存大小\n这时候我们创建了一个10M的字节数据,这时候最小堆内存是顶不住的。我们会发现现在的total memory已经变成了15M,这就是已经申请了一次内存的结果。\npublic class App { public static void main(String[] args) { System.out.println(\u0026#34;Xmx=\u0026#34; + Runtime.getRuntime().maxMemory() / 1024.0 + \u0026#34;KB\u0026#34;); //系统的最大空间-Xmx--运行几次都不变 System.out.println(\u0026#34;free mem=\u0026#34; + Runtime.getRuntime().freeMemory() / 1024.0 + \u0026#34;KB\u0026#34;); //系统的空闲空间--每次运行都变 System.out.println(\u0026#34;total mem=\u0026#34; + Runtime.getRuntime().totalMemory() / 1024.0 + \u0026#34;KB\u0026#34;); //当前可用的总空间 与Xms有关--运行几次都不变 byte[] b = new byte[1 * 1024 * 1024]; System.out.println(\u0026#34;分配了1M空间给数组\u0026#34;); System.out.println(\u0026#34;Xmx=\u0026#34; + Runtime.getRuntime().maxMemory() / 1024.0 / 1024 + \u0026#34;M\u0026#34;); //系统的最大空间 System.out.println(\u0026#34;free mem=\u0026#34; + Runtime.getRuntime().freeMemory() / 1024.0 / 1024 + \u0026#34;M\u0026#34;); //系统的空闲空间 System.out.println(\u0026#34;total mem=\u0026#34; + Runtime.getRuntime().totalMemory() / 1024.0 / 1024 + \u0026#34;M\u0026#34;); byte[] c = new byte[10 * 1024 * 1024]; System.out.println(\u0026#34;分配了10M空间给数组\u0026#34;); System.out.println(\u0026#34;Xmx=\u0026#34; + Runtime.getRuntime().maxMemory() / 1024.0 / 1024 + \u0026#34;M\u0026#34;); //系统的最大空间 System.out.println(\u0026#34;free mem=\u0026#34; + Runtime.getRuntime().freeMemory() / 1024.0 / 1024 + \u0026#34;M\u0026#34;); //系统的空闲空间 System.out.println(\u0026#34;total mem=\u0026#34; + Runtime.getRuntime().totalMemory() / 1024.0 / 1024 + \u0026#34;M\u0026#34;); //当前可用的总空间 } } /** ---- [GC (Allocation Failure) [PSYoungGen: 1024K-\u0026gt;488K(1536K)] 1024K-\u0026gt;600K(5632K), 0.0006681 secs] [Times: user=0.00 sys=0.00, real=0.00 secs] Xmx=18432.0KB free mem=4257.953125KB total mem=5632.0KB 分配了1M空间给数组 Xmx=18.0M free mem=3.1153564453125M total mem=5.5M 分配了10M空间给数组 Xmx=18.0M free mem=2.579681396484375M total mem=15.0M Heap PSYoungGen total 1536K, used 1363K [0x00000000ff980000, 0x00000000ffb80000, 0x0000000100000000) eden space 1024K, 85% used [0x00000000ff980000,0x00000000ffa5acc0,0x00000000ffa80000) from space 512K, 95% used [0x00000000ffa80000,0x00000000ffafa020,0x00000000ffb00000) to space 512K, 0% used [0x00000000ffb00000,0x00000000ffb00000,0x00000000ffb80000) ParOldGen total 13824K, used 11376K [0x00000000fec00000, 0x00000000ff980000, 0x00000000ff980000) object space 13824K, 82% used [0x00000000fec00000,0x00000000ff71c020,0x00000000ff980000) Metaspace used 3242K, capacity 4500K, committed 4864K, reserved 1056768K class space used 351K, capacity 388K, committed 512K, reserved 1048576K */ 此时我们再跑一下这个代码\n此时要调整垃圾收集器(-XX:+UseG1GC)且b、c要指向null,才能让系统回收这部分内存,即-Xmx20m -Xms5m -XX:+PrintGCDetails -XX:+UseG1GC 注:使用-XX: +UseSerialGC或者-XX:+UseParallelGC都是不能达到效果的\n此时我们手动执行了一次fullgc,此时total memory的内存空间又变回6.0M了,此时又是把申请的内存释放掉的结果。\npublic class App { public static void main(String[] args) throws InterruptedException { System.out.println(\u0026#34;Xmx=\u0026#34; + Runtime.getRuntime().maxMemory() / 1024.0 + \u0026#34;KB\u0026#34;); //系统的最大空间-Xmx--运行几次都不变 System.out.println(\u0026#34;free mem=\u0026#34; + Runtime.getRuntime().freeMemory() / 1024.0 + \u0026#34;KB\u0026#34;); //系统的空闲空间--每次运行都变 System.out.println(\u0026#34;total mem=\u0026#34; + Runtime.getRuntime().totalMemory() / 1024.0 + \u0026#34;KB\u0026#34;); //当前可用的总空间 与Xms有关--运行几次都不变 byte[] b = new byte[1 * 1024 * 1024]; System.out.println(\u0026#34;分配了1M空间给数组\u0026#34;); System.out.println(\u0026#34;Xmx=\u0026#34; + Runtime.getRuntime().maxMemory() / 1024.0 / 1024 + \u0026#34;M\u0026#34;); //系统的最大空间 System.out.println(\u0026#34;free mem=\u0026#34; + Runtime.getRuntime().freeMemory() / 1024.0 / 1024 + \u0026#34;M\u0026#34;); //系统的空闲空间 System.out.println(\u0026#34;total mem=\u0026#34; + Runtime.getRuntime().totalMemory() / 1024.0 / 1024 + \u0026#34;M\u0026#34;); byte[] c = new byte[10 * 1024 * 1024]; System.out.println(\u0026#34;分配了10M空间给数组\u0026#34;); System.out.println(\u0026#34;Xmx=\u0026#34; + Runtime.getRuntime().maxMemory() / 1024.0 / 1024 + \u0026#34;M\u0026#34;); //系统的最大空间 System.out.println(\u0026#34;free mem=\u0026#34; + Runtime.getRuntime().freeMemory() / 1024.0 / 1024 + \u0026#34;M\u0026#34;); //系统的空闲空间 System.out.println(\u0026#34;total mem=\u0026#34; + Runtime.getRuntime().totalMemory() / 1024.0 / 1024 + \u0026#34;M\u0026#34;); //当前可用的总空间 b=null; c=null; System.gc(); System.out.println(\u0026#34;进行了gc\u0026#34;); System.out.println(\u0026#34;Xmx=\u0026#34; + Runtime.getRuntime().maxMemory() / 1024.0 / 1024 + \u0026#34;M\u0026#34;); //系统的最大空间 System.out.println(\u0026#34;free mem=\u0026#34; + Runtime.getRuntime().freeMemory() / 1024.0 / 1024 + \u0026#34;M\u0026#34;); //系统的空闲空间 System.out.println(\u0026#34;total mem=\u0026#34; + Runtime.getRuntime().totalMemory() / 1024.0 / 1024 + \u0026#34;M\u0026#34;); //当前可用的总空间 } } /*-------- Xmx=20480.0KB free mem=4290.3671875KB total mem=6144.0KB 分配了1M空间给数组 Xmx=20.0M free mem=3.1897964477539062M total mem=6.0M [GC pause (G1 Humongous Allocation) (young) (initial-mark), 0.0014754 secs] [Parallel Time: 1.1 ms, GC Workers: 8] [GC Worker Start (ms): Min: 105.0, Avg: 105.1, Max: 105.3, Diff: 0.3] [Ext Root Scanning (ms): Min: 0.5, Avg: 0.5, Max: 0.8, Diff: 0.4, Sum: 4.4] [Update RS (ms): Min: 0.0, Avg: 0.0, Max: 0.0, Diff: 0.0, Sum: 0.0] [Processed Buffers: Min: 0, Avg: 0.0, Max: 0, Diff: 0, Sum: 0] [Scan RS (ms): Min: 0.0, Avg: 0.0, Max: 0.0, Diff: 0.0, Sum: 0.0] [Code Root Scanning (ms): Min: 0.0, Avg: 0.0, Max: 0.0, Diff: 0.0, Sum: 0.0] [Object Copy (ms): Min: 0.1, Avg: 0.3, Max: 0.4, Diff: 0.2, Sum: 2.5] [Termination (ms): Min: 0.0, Avg: 0.0, Max: 0.0, Diff: 0.0, Sum: 0.3] [Termination Attempts: Min: 1, Avg: 6.0, Max: 9, Diff: 8, Sum: 48] [GC Worker Other (ms): Min: 0.0, Avg: 0.0, Max: 0.1, Diff: 0.1, Sum: 0.2] [GC Worker Total (ms): Min: 0.8, Avg: 0.9, Max: 1.0, Diff: 0.3, Sum: 7.4] [GC Worker End (ms): Min: 106.0, Avg: 106.1, Max: 106.1, Diff: 0.0] [Code Root Fixup: 0.0 ms] [Code Root Purge: 0.0 ms] [Clear CT: 0.1 ms] [Other: 0.3 ms] [Choose CSet: 0.0 ms] [Ref Proc: 0.1 ms] [Ref Enq: 0.0 ms] [Redirty Cards: 0.1 ms] [Humongous Register: 0.0 ms] [Humongous Reclaim: 0.0 ms] [Free CSet: 0.0 ms] [Eden: 2048.0K(3072.0K)-\u0026gt;0.0B(1024.0K) Survivors: 0.0B-\u0026gt;1024.0K Heap: 2877.6K(6144.0K)-\u0026gt;1955.9K(6144.0K)] [Times: user=0.00 sys=0.00, real=0.00 secs] [GC concurrent-root-region-scan-start] [GC concurrent-root-region-scan-end, 0.0005373 secs] [GC concurrent-mark-start] [GC concurrent-mark-end, 0.0000714 secs] [GC remark [Finalize Marking, 0.0001034 secs] [GC ref-proc, 0.0000654 secs] [Unloading, 0.0005193 secs], 0.0007843 secs] [Times: user=0.00 sys=0.00, real=0.00 secs] [GC cleanup 11M-\u0026gt;11M(17M), 0.0003613 secs] [Times: user=0.00 sys=0.00, real=0.00 secs] 分配了10M空间给数组 Xmx=20.0M free mem=5.059120178222656M total mem=17.0M [Full GC (System.gc()) 11M-\u0026gt;654K(6144K), 0.0031959 secs] [Eden: 1024.0K(1024.0K)-\u0026gt;0.0B(2048.0K) Survivors: 1024.0K-\u0026gt;0.0B Heap: 11.9M(17.0M)-\u0026gt;654.4K(6144.0K)], [Metaspace: 3152K-\u0026gt;3152K(1056768K)] [Times: user=0.00 sys=0.00, real=0.00 secs] 进行了gc Xmx=20.0M free mem=5.2661590576171875M total mem=6.0M Heap garbage-first heap total 6144K, used 654K [0x00000000fec00000, 0x00000000fed00030, 0x0000000100000000) region size 1024K, 1 young (1024K), 0 survivors (0K) Metaspace used 3243K, capacity 4500K, committed 4864K, reserved 1056768K class space used 351K, capacity 388K, committed 512K, reserved 1048576K */ 调整新生代和老年代的比值 # -XX:NewRatio \u0026mdash; 新生代(eden+2*Survivor)和老年代(不包含永久区)的比值\n例如:-XX:NewRatio=4,表示新生代:老年代=1:4,即新生代占整个堆的1/5。在Xms=Xmx并且设置了Xmn的情况下,该参数不需要进行设置。 注:Xmn为直接设置大小,如-Xmn2G\n调整Survivor区和Eden区的比值 # -XX:SurvivorRatio(幸存代)\u0026mdash; 设置两个Survivor区和eden的比值\n例如:8,表示两个Survivor:eden=2:8,即一个Survivor占年轻代的1/10\n设置年轻代和老年代的大小 # -XX:NewSize \u0026mdash; 设置年轻代大小\n-XX:MaxNewSize \u0026mdash; 设置年轻代最大值\n可以通过设置不同参数来测试不同的情况,反正最优解当然就是官方的Eden和Survivor的占比为8:1:1,然后在刚刚介绍这些参数的时候都已经附带了一些说明,感兴趣的也可以看看。反正最大堆内存和最小堆内存如果数值不同会导致多次的gc,需要注意。\n我的理解是会经常调整totalMemory而导致多次gc,避免临界条件下的 垃圾回收和内存申请和分配\n注: 最大堆内存和最小堆内存设置成一样,为的是能够在java垃圾回收机制清理完堆区后,不需要重新分隔计算堆区的大小而浪费资源(向系统请求/释放内存资源)\n小总结 # 根据实际事情调整新生代和幸存代的大小,官方推荐新生代占java堆的3/8,幸存代占新生代的1/10\nJava堆:新生代 (3/8),老年代\n新生代:SO (1/10) ,S1 ,Eden\n在OOM时,记得Dump出堆,确保可以排查现场问题,通过下面命令可以输出一个.dump 文件,该文件用VisualVM或Java自带的JavaVisualVM 工具\n-Xmx20m -Xms5m -XX:+HeapDumpOnOutOfMemoryError -XX:HeapDumpPath=你要输出的日志路径 一般我们也可以通过编写脚本的方式来让OOM出现时给我们报个信,可以通过发送邮件或者重启程序等来解决\n永久区的设置 # -XX:PermSize -XX:MaxPermSize,应该说的是永久代\n初始空间(默认为物理内存的1/64)和最大空间(默认为物理内存的1/4)。也就是说,jvm启动时,永久区一开始就占用了PermSize大小的空间,如果空间还不够,可以继续扩展,但是不能超过MaxPermSize,否则会OOM。\n如果堆空间没有用完也抛出了OOM,有可能是永久区导致的。堆空间实际占用非常少,但是永久区溢出 一样抛出OOM\nJVM的栈参数调优 # 调整每个线程栈空间的大小 # 可以通过**-Xss**:调整每个线程栈空间的大小\nJDK5.0以后每个线程堆栈大小为1M,以前每个线程堆栈大小为256K。在相同物理内存下,减小这个值能生成更多的线程。但是操作系统对一个进程内的线程数还是有限制的,不能无限生成,经验值在3000~5000左右\n设置线程栈的大小 # -XXThreadStackSize: #设置线程栈的大小(0 means use default stack size) 补充:\n-Xss是OpenJDK和Oracle JDK的-XX:ThreadStackSize的别名。\n尽管他们对参数的解析不同: -Xss可以接受带K,M或G后缀的数字; -XX:ThreadStackSize=需要一个整数(无后缀)-堆栈大小(以千字节为单位)\n(可以直接跳过了)JVM其他参数介绍 # 形形色色的参数很多,就不会说把所有都扯个遍了,因为大家其实也不会说一定要去深究到底。\n设置内存页的大小 # -XXThreadStackSize: 设置内存页的大小,不可设置过大,会影响Perm的大小 设置原始类型的快速优化 # -XX:+UseFastAccessorMethods: 设置原始类型的快速优化 设置关闭手动GC # -XX:+DisableExplicitGC: 设置关闭System.gc()(这个参数需要严格的测试) 设置垃圾最大年龄 # -XX:MaxTenuringThreshold 设置垃圾最大年龄。如果设置为0的话,则年轻代对象不经过Survivor区,直接进入年老代. 对于年老代比较多的应用,可以提高效率。如果将此值设置为一个较大值, 则年轻代对象会在Survivor区进行多次复制,这样可以增加对象再年轻代的存活时间, 增加在年轻代即被回收的概率。该参数只有在串行GC时才有效. 加快编译速度 # -XX:+AggressiveOpts 加快编译速度\n改善锁机制性能 # -XX:+UseBiasedLocking 禁用垃圾回收 # -Xnoclassgc 设置堆空间存活时间 # -XX:SoftRefLRUPolicyMSPerMB 设置每兆堆空闲空间中SoftReference的存活时间,默认值是1s。 设置对象直接分配在老年代 # -XX:PretenureSizeThreshold 设置对象超过多大时直接在老年代分配,默认值是0。 设置TLAB占eden区的比例 # -XX:TLABWasteTargetPercent 设置TLAB占eden区的百分比,默认值是1% 。 设置是否优先YGC # -XX:+CollectGen0First 设置FullGC时是否先YGC,默认值是false。 finally # 附录:\n真的扯了很久这东西,参考了多方的资料,有极客时间的《深入拆解虚拟机》和《Java核心技术面试精讲》,也有百度,也有自己在学习的一些线上课程的总结。希望对你有所帮助,谢谢。\n"},{"id":317,"href":"/zh/docs/technology/Review/java_guide/java/JVM/ly0401lymemory-area/","title":"memory-area","section":"Java基础","content":" 转载自https://github.com/Snailclimb/JavaGuide (添加小部分笔记)感谢作者!\n如果没有特殊说明,针对的都是HotSpot虚拟机\n前言 # 对于Java程序员,虚拟机自动管理机制,不需要像C/C++程序员为每一个new 操作去写对应的delete/free 操作,不容易出现内存泄漏 和 内存溢出问题 但由于内存控制权交给Java虚拟机,一旦出现内存泄漏和溢出方面问题,如果不了解虚拟机是怎么样使用内存,那么很难排查任务 运行时数据区域 # Java虚拟机在执行Java程序的过程中,会把它管理的内存,划分成若干个不同的数据区域\nJDK1.8之前:\n线程共享 堆,方法区【永久代】(包括运行时常量池) 线程私有 虚拟机栈、本地方法栈、程序计数器 本地内存(包括直接内存) JDK1.8之后:\n1.8之后整个永久代改名叫\u0026quot;元空间\u0026quot;,且移到了本地内存中\n规范(概括):\n线程私有:程序计数器,虚拟机栈,本地方法栈\n线程共享:堆,方法区,直接内存(非运行时数据区的一部分)\nJava虚拟机规范对于运行时数据区域的规定是相当宽松的,以堆为例:\n堆可以是连续,也可以不连续 大小可以固定,也可以运行时按需扩展 虚拟机实现者可以使用任何垃圾回收算法管理堆,设置不进行垃圾收集 程序计数器 # 是一块较小内存空间,看作是当前线程所执行的字节码的行号指示器\njava程序流程\n字节码解释器,工作时通过改变这个计数器的值来选取下一条需要执行的字节码指令\n分支、循环、跳转、异常处理、线程恢复等功能都需要依赖这个计数器\n而且,为了线程切换后恢复到正确执行位置,每条线程需要一个独立程序计数器,各线程计数器互不影响,独立存储,我们称这类内存区域为**\u0026ldquo;线程私有\u0026rdquo;**的内存\n总结,程序计数器的作用\n字节码解释器通过改变程序计数器来依次读取指令,从而实现代码的流程控制 多线程情况下,程序计数器用于记录当前线程执行的位置,从而当线程被切回来的时候能够知道该线程上次运行到哪 程序计数器是唯一一个不会出现OutOfMemoryError的内存区域,它的生命周期随线程创建而创建,线程结束而死亡\nJava虚拟机栈 # Java虚拟机栈,简称\u0026quot;栈\u0026quot;,也是线程私有的,生命周期和线程相同,随线程创建而创建,线程死亡而死亡 除了Native方法调用的是通过本地方法栈实现的,其他所有的Java方法调用都是通过栈来实现的(需要和其他运行时数据区域比如程序计数器配合) 方法调用的数据需要通过栈进行传递,每一次方法调用都会有一个对应的栈帧被压入栈,每一个方法调用结束后,都会有一个栈帧被弹出。 栈由一个个栈帧组成,每个栈帧包括局部变量表、操作数栈、动态链接、方法返回地址。 栈为先进后出,且只支持出栈和入栈 局部变量表:存放编译器可知的各种数据类型(boolean、byte、char、short、int、float、long、double)、对象引用(reference 类型,不同于对象本身,可能是一个指向对象起始地址的引用指针,也可能是一个指向一个代表对象的句柄或其他与此对象相关的位置) 操作数栈 作为方法调用的中转站使用,用于存放方法执行过程中产生的中间计算结果。计算过程中产生的临时变量也放在操作数栈中\n动态链接 主要服务一个方法需要调用其他方法的场景。\n在 Java 源文件被编译成字节码文件时,所有的变量和方法引用都作为符号引用(Symbilic Reference)保存在 Class 文件的常量池里。当一个方法要调用其他方法,需要将常量池中指向方法的符号引用转化为其在内存地址中的直接引用。动态链接的作用就是为了将符号引用转换为调用方法的直接引用。\n如果函数调用陷入无限循环,会导致栈中被压入太多栈帧而占用太多空间,导致栈空间过深。当线程请求栈的深度超过当前Java虚拟机栈的最大深度时,就会抛出StackOverFlowError错误\nJava 方法有两种返回方式,一种是 return 语句正常返回,一种是抛出异常。不管哪种返回方式,都会导致栈帧被弹出。也就是说, 栈帧随着方法调用而创建,随着方法结束而销毁。无论方法正常完成还是异常完成都算作方法结束。\n除了 StackOverFlowError 错误之外,栈还可能会出现OutOfMemoryError错误,这是因为如果栈的内存大小可以动态扩展, 如果虚拟机在动态扩展栈时无法申请到足够的内存空间,则抛出**OutOfMemoryError**异常。\n总结,程序运行中栈可能出现的两种错误\nStackOverFlowError:若栈的内存大小不允许动态扩展,那么当线程请求栈的深度超过当前 Java 虚拟机栈的最大深度的时候,就抛出 StackOverFlowError 错误。 OutOfMemoryError: 如果栈的内存大小可以动态扩展, 如果虚拟机在动态扩展栈时无法申请到足够的内存空间,则抛出OutOfMemoryError异常。 本地方法栈 # 和虚拟机栈作用相似,区别:虚拟机栈为虚拟机执行Java方法(字节码)服务,本地方法栈则为虚拟机使用到的Native方法服务。HotSpot虚拟机中和Java虚拟机栈合二为一\n同上,本地方法被执行时,本地方法栈会创建一个栈帧,用于存放本地方法的局部变量表、操作数栈、动态链接、出口信息\n方法执行完毕后相应的栈帧也会出栈并释放内存空间,也会出现StackOverFlowError和OutOfMemoryError两种错误\n堆 # Java虚拟机所管理的内存中最大的一块,Java堆是所有线程共享的一块区域,在虚拟机启动时创建\n此内存区域唯一目的是存放对象实例,几乎所有的对象实例及数组,都在这里分配内存\n“几乎”,因为随着JIT编译器的发展与逃逸分析技术逐渐成熟,栈上分配、标量替换导致微妙变化。从JDK1.7开始已经默认逃逸分析,如果某些方法的对象引用没有被返回或者未被外面使用(未逃逸出去),那么对象可以直接在栈上分配内存。\nJava堆是垃圾收集器管理的主要区域,因此也称GC堆(Garbage Collected Heap)\n现在收集器基本都采用分代垃圾收集算法,从垃圾回收的角度,Java堆还细分为:新生代和老年代。再细致:Eden,Survivor,Old等空间。\u0026gt; 目的是更好的回收内存,或更快地分配内存\nJDK7及JDK7之前,堆内存被分为三部分\n新生代内存(Young Generation),包括Eden区、两个Survivor区S0和S1【8:1:1】 老生代(Old Generation) 【新生代 : 老年代= 1: 2】 永久代(Permanent Generation) JDK8之后PermGen(永久)已被Metaspace(元空间)取代,且元空间使用直接内存\n大部分情况,对象都会首先在 Eden 区域分配,在一次新生代垃圾回收后,如果对象还存活,则会进入 S0 或者 S1,并且对象的年龄还会加 1(Eden 区-\u0026gt;Survivor 区后对象的初始年龄变为 1),当它的年龄增加到一定程度(默认为 15 岁),就会被晋升到老年代中。\n对象晋升到老年代的年龄阈值,可以通过参数 -XX:MaxTenuringThreshold 来设置。\n修正(参见: issue552open in new window) :“Hotspot 遍历所有对象时,按照年龄从小到大对其所占用的大小进行累积,当累积的某个年龄大小超过了 survivor 区的一半时,取这个年龄和 MaxTenuringThreshold 中更小的一个值,作为新的晋升年龄阈值”。图解:\n代码如下:\nuint ageTable::compute_tenuring_threshold(size_t survivor_capacity) { //survivor_capacity是survivor空间的大小 size_t desired_survivor_size = (size_t)((((double) survivor_capacity)*TargetSurvivorRatio)/100); size_t total = 0; uint age = 1; while (age \u0026lt; table_size) { total += sizes[age];//sizes数组是每个年龄段对象大小 if (total \u0026gt; desired_survivor_size) break; age++; } uint result = age \u0026lt; MaxTenuringThreshold ? age : MaxTenuringThreshold; ... } 堆里最容易出现OutOfMemoryError错误,出现这个错误之后的表现形式:\njava.lang.OutOfMemoryError: GC Overhead Limit Exceeded : 当 JVM 花太多时间执行垃圾回收并且只能回收很少的堆空间时,就会发生此错误。\njava.lang.OutOfMemoryError: Java heap space :假如在创建新的对象时, 堆内存中的空间不足以存放新创建的对象, 就会引发此错误。\n(和配置的最大堆内存有关,且受制于物理内存大小。最大堆内存可通过-Xmx参数配置,若没有特别配置,将会使用默认值,详见: Default Java 8 max heap sizeopen in new window)\n\u0026hellip;\n方法区 # 方法区属于JVM运行时数据区域的一块逻辑区域,是各线程共享的内存区域\n“逻辑”,《Java虚拟机规范》规定了有方法区这么个概念和它的作用,方法区如何实现是虚拟机的事。即,不同虚拟机实现上,方法区的实现是不同的\n当虚拟机要使用一个类时,它需要读取并解析Class文件获取相关信息,再将信息存入方法区。方法区会存储已被虚拟机加载的类信息、字段信息、方法信息、常量、静态变量、即时编译器编译后的代码缓存等数据\n方法区和永久代以及元空间有什么关系呢?\n方法区和永久代以及元空间的关系很像Java中接口和类的关系,类实现了接口,这里的类就可以看作是永久代和元空间,接口则看作是方法区 永久代及元空间,是HotSpot虚拟机对虚拟机规范中方法区的两种实现方式 永久代是JDK1.8之前的方法区实现,元空间是JDK1.8及之后方法区的实现 为什么将永久代(PermGen)替换成元空间(MetaSpace)呢\n下图来自《深入理解Java虚拟机》第3版\n整个永久代有一个JVM本身设置的固定大小上限(也就是参数指定),无法进行调整,而元空间使用的是直接内存,受本机可用内存的限制。虽然元空间仍旧可能溢出,但比原来出现的机率会更小\n元空间溢出将得到错误: java.lang.OutOfMemoryError: MetaSpace\n-XX: MaxMetaspaceSize设置最大元空间大小,默认为unlimited,即只受系统内存限制 -XX: MetaspaceSize调整标志定义元空间的初始大小,如果未指定此标志,则Metaspace将根据运行时应用程序需求,动态地重新调整大小。 元空间里存放的是类的元数据,这样加载多少类的元数据就不由MaxPermSize控制了,而由系统的实际可用空间控制,这样加载的类就更多了\n在 JDK8,合并 HotSpot 和 JRockit 的代码时, JRockit 从来没有一个叫永久代的东西, 合并之后就没有必要额外的设置这么一个永久代的地方了\n方法区常用参数有哪些 JDK1.8之前永久代还没有被彻底移除时通过下面参数调节方法区大小\n-XX:PermSize=N//方法区 (永久代) 初始大小 -XX:MaxPermSize=N//方法区 (永久代) 最大大小,超过这个值将会抛出 OutOfMemoryError 异常:java.lang.OutOfMemoryError: PermGen 相对而言,垃圾收集行为在这个区域是比较少出现的,但**并非数据进入方法区后就“永久存在”**了。\nJDK1.7方法区(HotSpot的永久代)被移除一部分,JDK1.8时方法区被彻底移除,取而代之的是元空间,元空间使用直接内存,下面是常用参数\n-XX:MetaspaceSize=N //设置 Metaspace 的初始(和最小大小) -XX:MaxMetaspaceSize=N //设置 Metaspace 的最大大小 与永久代不同,如果不指定大小,随着更多类的创建,虚拟机会耗尽所有可用的系统内存。\n运行时常量池 # Class文件中除了有类的版本、字段、方法、接口等描述信息外,还有用于存放编译器期生成的各种字面量(Literal)和符号引用(Symbolic Reference)的常量池表(注意,这里的常量池表,说的是刚刚编译后的那个class文件字节码代表的含义)\n字面量是源代码中的固定值表示法,即通过字面我们就知道其值的含义。字面量包括整数、浮点数和字符串字面量;符号引用包括类符号引用、字段符号引用、方法符号引用和接口方法符号引用。\n常量池表会在类加载后存放到方法区的运行时常量池中\n运行时常量池的功能类似于传统编程语言的符号表(但是包含了比典型符号表更广泛的数据)\n运行时常量池是方法区的一部分,所以受到方法区内存的限制,当常量池无法再申请到内存时会抛出OutOfMemoryError的错误\n字符串常量池 # 字符串常量池是JVM为了提升性能和减少内存消耗针对字符串(String类)专门开辟的一块区域,主要目的是为了避免字符串得重复创建\n// 在堆中创建字符串对象”ab“ // 将字符串对象”ab“的引用保存在字符串常量池中 String aa = \u0026#34;ab\u0026#34;; // 直接返回字符串常量池中字符串对象”ab“的引用 String bb = \u0026#34;ab\u0026#34;; System.out.println(aa==bb);// true HotSpot 虚拟机中字符串常量池的实现是 src/hotspot/share/classfile/stringTable.cpp ,StringTable 本质上就是一个HashSet\u0026lt;String\u0026gt; ,容量为 StringTableSize(可以通过 -XX:StringTableSize 参数来设置)。\nStringTable 中保存的是字符串对象的引用,字符串对象的引用指向堆中的字符串对象。\nJDK1.7之前, (字符串常量池、静态变量)存放在永久代。JDK1.7字符串常量池和静态变量从永久代移动到了Java堆中 JDK1.7为什么要将字符串常量池移动到堆中\n因为永久代(方法区实现)的GC回收效率太低,只有在整堆收集(Full GC)的时候才会被执行GC。Java程序中通常有大量的被创建的字符串等待回收,将字符串常量池放到堆中,能够高效及时地回收字符串内存。\nJVM常量池中存储的是对象还是引用\n如果您说的确实是runtime constant pool(而不是interned string pool / StringTable之类的其他东西)的话,其中的引用类型常量(例如CONSTANT_String、CONSTANT_Class、CONSTANT_MethodHandle、CONSTANT_MethodType之类)都存的是引用,实际的对象还是存在Java heap上的。【运行时常量池】\n运行时常量池、方法区、字符串常量池这些都是不随虚拟机实现而改变的逻辑概念,是公共且抽象的,Metaspace、Heap 是与具体某种虚拟机实现相关的物理概念,是私有且具体的。\n总结 # 直接内存 # 直接内存并不是虚拟机运行时数据区的一部分,也不是虚拟机规范中定义的内存区域,但是这部分内存也被频繁使用,也可能导致OutOfMemoryError错误出现 JDK1.4中新加入的NIO(New Input/Output)类,引入一种基于通道(Channel)与缓冲区(Buffer)的I/O方式,它可以直接使用Native函数库直接分配堆外内存,然后通过一个存储在Java堆中的DirectByteBuffer对象作为这块内存的引用进行操作。这样就能在一些场景中显著提高性能,因为避免了在Java堆和Native堆之间来回复制数据 本机直接内存的分配不会受到Java堆的限制,但是,既然是内存就会受到本机总内存大小以及处理器寻址空间的限制 HotSpot虚拟机对象探秘 # 了解一下HotSport虚拟机在Java堆中对象分配、布局和访问的全过程\n对象的创建 # 默认:\n类加载检查 虚拟机遇到一条new指令时,首先将去检查这个指令的参数(也就是后面说的符号引用),看是否能在常量池中定位到这个类的符号引用,并且检查这个符号引用代表的类是否已被加载过、解析和初始化过。如果没有,那必须先执行相应的类加载过程\n分配内存 类加载检查通过后,接下来虚拟机将为新生对象分配内存。对象所需内存大小在类加载后便可确定,为对象分配空间的任务,等同于把一块确定大小的内存从Java堆中划分出来**。 分配方式有**”指针碰撞“和”空闲列表“两种,选择哪种分配方式由Java堆是否规整决定**,而Java堆是否规整又由所采用的垃圾收集器是否带有压缩整理功能决定 内存分配的两种方式:\n指针碰撞\n适用场合:**堆内存规整(即没有内存碎片)**的情况下 原理:用过的内存全部整合到一边,没有用过的内存放在另一边,中间有一个分界指针只需要向着没用过的内存方向,将该指针移动对象内存的大小的位置即可 使用该分配方式的GC收集器:Serial,PartNew 空闲列表\n适合场合:堆内存不规整的情况下 原理:虚拟机会维护一个列表,该列表中会记录哪些内存块是可用的,在分配的时候,找一块足够大的内存块来划分给对象实例,最后更新列表记录 使用该分配方式的GC收集器:CMS 选择以上两种方式中的哪一种,取决于 Java 堆内存是否规整。而 Java 堆内存是否规整,取决于 GC 收集器的算法是**\u0026ldquo;标记-清除\u0026rdquo;,还是\u0026ldquo;标记-整理\u0026rdquo;(也称作\u0026ldquo;标记-压缩\u0026rdquo;**),值得注意的是,复制算法内存也是规整的\n内存分配并发问题:\n创建对象时的重要问题\u0026mdash;线程安全,因为在实际开发过程中,创建对象是很频繁的事,作为虚拟机来说,必须要保证线程安全的,虚拟机采用两种方式保证线程安全:\nCAS+失败重试:\nCAS 是乐观锁的一种实现方式。所谓乐观锁就是,每次不加锁而是假设没有冲突而去完成某项操作,如果因为冲突失败就重试,直到成功为止\n虚拟机采用CAS+失败重试的方式保证更新操作的原子性\nTLAB:为每一个线程 预先 在Eden区分配一块内存,JVM在给线程中的对象分配内存时,首先在TLAB(该内存区域)分配,当对象大于TLAB中的剩余内存或TLAB的内存已用尽时,再采用上述的CAS进行内存分配\n在预留这个操作发生的时候,需要进行加锁或者采用CAS等操作进行保护,避免多个线程预留同一个区域\n初始化零值 内存分配完成后,虚拟机需要将分配到的内存空间都初始化为零值(不包括对象头),这一步操作保证了对象的实例字段在 Java 代码中可以不赋初始值就直接使用,程序能访问到这些字段的数据类型所对应的零值。\n设置对象头\n初始化零值完成之后,虚拟机要对对象进行必要的设置,例如这个对象是哪个类的实例、如何才能找到类的元数据信息、对象的哈希码、对象的 GC 分代年龄等信息。 这些信息存放在对象头中。 另外,根据虚拟机当前运行状态的不同,如是否启用偏向锁等,对象头会有不同的设置方式。\n执行init方法\n在上面工作都完成之后,从虚拟机的视角来看,一个新的对象已经产生了,但从 Java 程序的视角来看,对象创建才刚开始,\u0026lt;init\u0026gt; 方法还没有执行,所有的字段都还为零。所以一般来说,执行 new 指令之后会接着执行 \u0026lt;init\u0026gt; 方法,把对象按照程序员的意愿进行初始化,这样一个真正可用的对象才算完全产生出来。\n附: Java 在编译之后会在字节码文件中生成 init 方法,称之为实例构造器,该实例构造器会将语句块,变量初始化,调用父类的构造器等操作收敛到 init 方法中,收敛顺序为: 变量---\u0026gt; 语句块 ---\u0026gt; 构造函数\n父类变量初始化 父类语句块 父类构造函数 子类变量初始化 子类语句块 子类构造函数 收敛到 init 方法的意思是:将这些操作放入到 init 中去执行。 转自: https://juejin.cn/post/6844903957836333063\n对象的内存布局 # Hotspot虚拟机中,对象在内存中的布局分为3块区域:对象头、实例数据和对齐填充 对象头又包括两部分信息,第一部分用于存储对象自身的运行时数据(哈希码、GC分代年龄、锁状态标志等等),即markword;第二部分是类型指针,即对象指向它的类元数据的指针,虚拟机通过这个指针来确定这个对象是哪个类的实例。 实例数据部分是对象真正存储的有效信息,也是在程序中所定义的各种类型的字段内容 对齐填充部分不是必然存在的,没特别含义,只起占位作用。因为Hotspot虚拟机的自动内存管理系统要求对象起始地址必须是8字节的整数倍,即对象的大小必须是8字节的整数倍。而对象头部分正好是8字节的倍数(1倍或2倍),因此当对象实例数据部分没有对齐时,就需要通过对齐填充来补全 对象的访问定位 # 建立对象就是为了使用对象,我们的 Java 程序通过栈上的 reference 数据来操作堆上的具体对象。对象的访问方式由虚拟机实现而定,目前主流的访问方式有:使用句柄、直接指针。\n句柄 如果使用句柄的话,那么 Java 堆中将会划分出一块内存来作为句柄池,reference 中存储的就是对象的句柄地址,而句柄中包含了对象实例数据与对象类型数据各自的具体地址信息。\n【也就是多了一层】\n直接指针 如果使用直接指针访问,reference 中存储的直接就是对象的地址。 这两种对象访问方式各有优势。使用句柄来访问的最大好处是 reference 中存储的是稳定的句柄地址,在对象被移动时只会改变句柄中的实例数据指针,而 reference 本身不需要修改。使用直接指针访问方式最大的好处就是速度快,它节省了一次指针定位的时间开销。\nHotSpot 虚拟机主要使用的就是这种方式**(直接指针**)来进行对象访问。\n"},{"id":318,"href":"/zh/docs/technology/Review/java_guide/java/Concurrent/ly0311lycompletablefuture-intro/","title":"completablefuture-intro","section":"并发","content":" 转载自https://github.com/Snailclimb/JavaGuide (添加小部分笔记)感谢作者!\nJava8被引入的一个非常有用的用于异步编程的类【没看】\n简单介绍 # CompletableFuture同时实现了Future和CompletionStage接口\npublic class CompletableFuture\u0026lt;T\u0026gt; implements Future\u0026lt;T\u0026gt;, CompletionStage\u0026lt;T\u0026gt; { } CompletableFuture 除了提供了更为好用和强大的 Future 特性之外,还提供了函数式编程的能力。\nFuture接口有5个方法:\nboolean cancel(boolean mayInterruptIfRunning) :尝试取消执行任务。 boolean isCancelled() :判断任务是否被取消。 boolean isDone() : 判断任务是否已经被执行完成。 get() :等待任务执行完成并获取运算结果。 get(long timeout, TimeUnit unit) :多了一个超时时间。 CompletionStage\u0026lt;T\u0026gt; 接口中的方法比较多,CompoletableFuture的函数式能力就是这个接口赋予的,大量使用Java8引入的函数式编程\n常见操作 # 创建CompletableFuture # 两种方法:new关键字或 CompletableFuture自带的静态工厂方法 runAysnc()或supplyAsync()\n通过new关键字 这个方式,可以看作是将CompletableFuture当作Future来使用,如下:\n我们通过创建了一个结果值类型为 RpcResponse\u0026lt;Object\u0026gt; 的 CompletableFuture,你可以把 resultFuture 看作是异步运算结果的载体\nCompletableFuture\u0026lt;RpcResponse\u0026lt;Object\u0026gt;\u0026gt; resultFuture = new CompletableFuture\u0026lt;\u0026gt;(); 如果后面某个时刻,得到了最终结果,可以调用complete()方法传入结果,表示resultFuture已经被完成:\n// complete() 方法只能调用一次,后续调用将被忽略。 resultFuture.complete(rpcResponse); 通过isDone()检查是否完成:\npublic boolean isDone() { return result != null; } 获取异步结果,使用get() ,调用get()方法的线程会阻塞 直到CompletableFuture完成运算: rpcResponse = completableFuture.get();\npublic class CompletableFutureTest { public static void main(String[] args) throws ExecutionException, InterruptedException { /*CompletableFuture\u0026lt;Object\u0026gt; resultFuture=new CompletableFuture\u0026lt;\u0026gt;(); resultFuture.complete(\u0026#34;hello world\u0026#34;); System.out.println(resultFuture.get());*/ CompletableFuture\u0026lt;String\u0026gt; stringCompletableFuture = CompletableFuture.supplyAsync(() -\u0026gt; { try { TimeUnit.SECONDS.sleep(3); } catch (InterruptedException e) { e.printStackTrace(); } return \u0026#34;hello,world!\u0026#34;; }); System.out.println(\u0026#34;被阻塞啦----\u0026#34;); String s = stringCompletableFuture.get(); System.out.println(\u0026#34;结果---\u0026#34;+s); } } 如果已经知道结果:\nCompletableFuture\u0026lt;String\u0026gt; future = CompletableFuture.completedFuture(\u0026#34;hello!\u0026#34;); assertEquals(\u0026#34;hello!\u0026#34;, future.get()); //completedFuture() 方法底层调用的是带参数的 new 方法,只不过,这个方法不对外暴露。 public static CompletableFuture completedFuture(U value) { return new CompletableFuture((value == null) ? NIL : value); } 基于CompletableFuture自带的静态工厂方法:runAsync()、supplyAsync() Supplier 供应商; 供货商; 供应者; 供货方; 这两个方法可以帮助我们封装计算逻辑\nstatic CompletableFuture supplyAsync(Supplier supplier); // 使用自定义线程池(推荐) static CompletableFuture supplyAsync(Supplier supplier, Executor executor); static CompletableFuture\u0026lt;Void\u0026gt; runAsync(Runnable runnable); // 使用自定义线程池(推荐) static CompletableFuture\u0026lt;Void\u0026gt; runAsync(Runnable runnable, Executor executor); //简单使用 public class CompletableFutureTest { public static void main(String[] args) throws ExecutionException, InterruptedException { CompletableFuture\u0026lt;String\u0026gt; completableFuture = CompletableFuture.supplyAsync(() -\u0026gt; { //3s后返回结果 try { TimeUnit.SECONDS.sleep(3); } catch (InterruptedException e) { e.printStackTrace(); } return \u0026#34;abc\u0026#34;; }); //这里会被阻塞 String s = completableFuture.get(); System.out.println(s); } } //例子2 import java.util.concurrent.CompletableFuture; import java.util.concurrent.CountDownLatch; import java.util.concurrent.ExecutionException; import java.util.concurrent.TimeUnit; public class CompletableFutureTest { public static void main(String[] args) throws ExecutionException, InterruptedException { CountDownLatch countDownLatch=new CountDownLatch(2); //相当于使用了一个线程池,开启线程,提交了任务 CompletableFuture\u0026lt;Void\u0026gt; a = CompletableFuture.runAsync(() -\u0026gt; { System.out.println(\u0026#34;a\u0026#34;); //执行了3s的任务 try { TimeUnit.SECONDS.sleep(3); } catch (InterruptedException e) { e.printStackTrace(); } countDownLatch.countDown(); }); CompletableFuture\u0026lt;Void\u0026gt; b = CompletableFuture.runAsync(() -\u0026gt; { System.out.println(\u0026#34;b\u0026#34;); //执行了3s的任务 try { TimeUnit.SECONDS.sleep(2); } catch (InterruptedException e) { e.printStackTrace(); } countDownLatch.countDown(); }); countDownLatch.await(); System.out.println(\u0026#34;执行完毕\u0026#34;);//3s后会执行 } } 备注,自定义线程池使用:\nThreadPoolExecutor executor = new ThreadPoolExecutor( CORE_POOL_SIZE, //5 MAX_POOL_SIZE, //10 KEEP_ALIVE_TIME, //1L TimeUnit.SECONDS, //单位 new ArrayBlockingQueue\u0026lt;\u0026gt;(QUEUE_CAPACITY),//100 new ThreadPoolExecutor.CallerRunsPolicy()); //主线程中运行 runAsync() 方法接受的参数是 Runnable ,这是一个函数式接口,不允许返回值。当你需要异步操作且不关心返回结果的时候可以使用 runAsync() 方法。\n@FunctionalInterface public interface Runnable { public abstract void run(); } supplyAsync() 方法接受的参数是 Supplier ,这也是一个函数式接口,U 是返回结果值的类型。\n@FunctionalInterface public interface Supplier\u0026lt;T\u0026gt; { /** * Gets a result. * * @return a result */ T get(); } 当需要异步操作且关心返回的结果时,可以使用supplyAsync()方法\n```java CompletableFuture\u0026lt;Void\u0026gt; future = CompletableFuture.runAsync(() -\u0026gt; System.out.println(\u0026quot;hello!\u0026quot;)); future.get();// 输出 \u0026quot;hello!\u0026quot; **注意,不是get()返回的** CompletableFuture\u0026lt;String\u0026gt; future2 = CompletableFuture.supplyAsync(() -\u0026gt; \u0026quot;hello!\u0026quot;); assertEquals(\u0026quot;hello!\u0026quot;, future2.get()); ``` 处理异步结算的结果 # 可以对异步计算的结果,进行进一步的处理,常用的方法有:\nthenApply() 接收结果 产生结果 ``thenAccept()` 接受结果不产生结果\nthenRun 不接受结果不产生结果 whenComplete() 结束时处理结果\n例子:\npublic class CompletableFutureTest { public static void main(String[] args) throws ExecutionException, InterruptedException { CompletableFuture\u0026lt;String\u0026gt; stringCompletableFuture = CompletableFuture.supplyAsync(() -\u0026gt; { try { TimeUnit.SECONDS.sleep(3); } catch (InterruptedException e) { e.printStackTrace(); } return \u0026#34;hello,world!\u0026#34;; }); System.out.println(\u0026#34;被阻塞啦----\u0026#34;); stringCompletableFuture .whenComplete((s,e)-\u0026gt;{ System.out.println(\u0026#34;complete1----\u0026#34;+s); }) .whenComplete((s,e)-\u0026gt;{ System.out.println(\u0026#34;complete2----\u0026#34;+s); }) .thenAccept(s-\u0026gt;{ System.out.println(\u0026#34;打印结果\u0026#34;+s); }) .thenRun(()-\u0026gt;{ System.out.println(\u0026#34;阻塞结束啦\u0026#34;); }); while (true){ } } } /*------------- 2022-12-07 10:16:44 上午 [Thread: main] INFO:被阻塞啦---- 2022-12-07 10:16:47 上午 [Thread: ForkJoinPool.commonPool-worker-1] INFO:complete1----hello,world! 2022-12-07 10:16:47 上午 [Thread: ForkJoinPool.commonPool-worker-1] INFO:complete2----hello,world! 2022-12-07 10:16:47 上午 [Thread: ForkJoinPool.commonPool-worker-1] INFO:打印结果hello,world! 2022-12-07 10:16:47 上午 [Thread: ForkJoinPool.commonPool-worker-1] INFO:阻塞结束啦 */ thenApply()方法接受Function实例,用它来处理结果 // 沿用上一个任务的线程池 public CompletableFuture thenApply( Function\u0026lt;? super T,? extends U\u0026gt; fn) { return uniApplyStage(null, fn); } //使用默认的 ForkJoinPool 线程池(不推荐) public CompletableFuture thenApplyAsync( Function\u0026lt;? super T,? extends U\u0026gt; fn) { return uniApplyStage(defaultExecutor(), fn); } // 使用自定义线程池(推荐) public CompletableFuture thenApplyAsync( Function\u0026lt;? super T,? extends U\u0026gt; fn, Executor executor) { return uniApplyStage(screenExecutor(executor), fn); } 使用示例:\nCompletableFuture\u0026lt;String\u0026gt; future = CompletableFuture.completedFuture(\u0026#34;hello!\u0026#34;) .thenApply(s -\u0026gt; s + \u0026#34;world!\u0026#34;); assertEquals(\u0026#34;hello!world!\u0026#34;, future.get()); // 这次调用将被忽略。 //**我猜是因为只能get()一次** future.thenApply(s -\u0026gt; s + \u0026#34;nice!\u0026#34;); assertEquals(\u0026#34;hello!world!\u0026#34;, future.get()); 流式调用:\nCompletableFuture\u0026lt;String\u0026gt; future = CompletableFuture.completedFuture(\u0026#34;hello!\u0026#34;) .thenApply(s -\u0026gt; s + \u0026#34;world!\u0026#34;).thenApply(s -\u0026gt; s + \u0026#34;nice!\u0026#34;); assertEquals(\u0026#34;hello!world!nice!\u0026#34;, future.get()); 如果不需要从回调函数中返回结果,可以使用thenAccept()或者thenRun() ,两个方法区别在于thenRun()不能访问异步计算的结果(因为thenAccept方法的参数为 Consumer\u0026lt;? super T\u0026gt; ) public CompletableFuture\u0026lt;Void\u0026gt; thenAccept(Consumer\u0026lt;? super T\u0026gt; action) { return uniAcceptStage(null, action); } public CompletableFuture\u0026lt;Void\u0026gt; thenAcceptAsync(Consumer\u0026lt;? super T\u0026gt; action) { return uniAcceptStage(defaultExecutor(), action); } public CompletableFuture\u0026lt;Void\u0026gt; thenAcceptAsync(Consumer\u0026lt;? super T\u0026gt; action, Executor executor) { return uniAcceptStage(screenExecutor(executor), action); } 顾名思义,Consumer 属于消费型接口,它可以接收 1 个输入对象然后进行“消费”。\n@FunctionalInterface public interface Consumer\u0026lt;T\u0026gt; { void accept(T t); default Consumer\u0026lt;T\u0026gt; andThen(Consumer\u0026lt;? super T\u0026gt; after) { Objects.requireNonNull(after); return (T t) -\u0026gt; { accept(t); after.accept(t); }; } } thenRun() 的方法是的参数是 Runnable\npublic CompletableFuture\u0026lt;Void\u0026gt; thenRun(Runnable action) { return uniRunStage(null, action); } public CompletableFuture\u0026lt;Void\u0026gt; thenRunAsync(Runnable action) { return uniRunStage(defaultExecutor(), action); } public CompletableFuture\u0026lt;Void\u0026gt; thenRunAsync(Runnable action, Executor executor) { return uniRunStage(screenExecutor(executor), action); } 使用如下:\nCompletableFuture.completedFuture(\u0026#34;hello!\u0026#34;) .thenApply(s -\u0026gt; s + \u0026#34;world!\u0026#34;).thenApply(s -\u0026gt; s + \u0026#34;nice!\u0026#34;).thenAccept(System.out::println);//hello!world!nice! //可以接收参数 CompletableFuture.completedFuture(\u0026#34;hello!\u0026#34;) .thenApply(s -\u0026gt; s + \u0026#34;world!\u0026#34;).thenApply(s -\u0026gt; s + \u0026#34;nice!\u0026#34;).thenRun(() -\u0026gt; System.out.println(\u0026#34;hello!\u0026#34;));//hello! whenComplete()的方法参数是BiConsumer\u0026lt;? super T , ? super Throwable \u0026gt;\npublic CompletableFuture\u0026lt;T\u0026gt; whenComplete( BiConsumer\u0026lt;? super T, ? super Throwable\u0026gt; action) { return uniWhenCompleteStage(null, action); } public CompletableFuture\u0026lt;T\u0026gt; whenCompleteAsync( BiConsumer\u0026lt;? super T, ? super Throwable\u0026gt; action) { return uniWhenCompleteStage(defaultExecutor(), action); } // 使用自定义线程池(推荐) public CompletableFuture\u0026lt;T\u0026gt; whenCompleteAsync( BiConsumer\u0026lt;? super T, ? super Throwable\u0026gt; action, Executor executor) { return uniWhenCompleteStage(screenExecutor(executor), action); } 相比Consumer,BiConsumer可以接收2个输入对象然后进行\u0026quot;消费\u0026quot;\n@FunctionalInterface public interface BiConsumer\u0026lt;T, U\u0026gt; { void accept(T t, U u); default BiConsumer\u0026lt;T, U\u0026gt; andThen(BiConsumer\u0026lt;? super T, ? super U\u0026gt; after) { Objects.requireNonNull(after); return (l, r) -\u0026gt; { accept(l, r); after.accept(l, r); }; } } 使用:\nCompletableFuture\u0026lt;String\u0026gt; future = CompletableFuture.supplyAsync(() -\u0026gt; \u0026#34;hello!\u0026#34;) .whenComplete((res, ex) -\u0026gt; { // res 代表返回的结果 // ex 的类型为 Throwable ,代表抛出的异常 System.out.println(res); // 这里没有抛出异常所有为 null assertNull(ex); }); assertEquals(\u0026#34;hello!\u0026#34;, future.get()); 其他区别暂时不知道\n异常处理 # 使用handle() 方法来处理任务执行过程中可能出现的抛出异常的情况\npublic CompletableFuture handle( BiFunction\u0026lt;? super T, Throwable, ? extends U\u0026gt; fn) { return uniHandleStage(null, fn); } public CompletableFuture handleAsync( BiFunction\u0026lt;? super T, Throwable, ? extends U\u0026gt; fn) { return uniHandleStage(defaultExecutor(), fn); } public CompletableFuture handleAsync( BiFunction\u0026lt;? super T, Throwable, ? extends U\u0026gt; fn, Executor executor) { return uniHandleStage(screenExecutor(executor), fn); } 代码:\npublic static void test() throws ExecutionException, InterruptedException { CompletableFuture\u0026lt;String\u0026gt; future = CompletableFuture.supplyAsync(() -\u0026gt; { if (true) { throw new RuntimeException(\u0026#34;Computation error!\u0026#34;); } return \u0026#34;hello!\u0026#34;; }).handle((res, ex) -\u0026gt; { // res 代表返回的结果 // ex 的类型为 Throwable ,代表抛出的异常 return res != null ? res : ex.toString()+\u0026#34;world!\u0026#34;; }); String s = future.get(); log.info(s); } /** 2022-12-07 11:14:44 上午 [Thread: main] INFO:java.util.concurrent.CompletionException: java.lang.RuntimeException: Computation error!world! */ 通过exceptionally处理异常\nCompletableFuture\u0026lt;String\u0026gt; future = CompletableFuture.supplyAsync(() -\u0026gt; { if (true) { throw new RuntimeException(\u0026#34;Computation error!\u0026#34;); } return \u0026#34;hello!\u0026#34;; }).exceptionally(ex -\u0026gt; { System.out.println(ex.toString());// CompletionException return \u0026#34;world!\u0026#34;; }); assertEquals(\u0026#34;world!\u0026#34;, future.get()); 让异步的结果直接就抛异常\nCompletableFuture\u0026lt;String\u0026gt; completableFuture = new CompletableFuture\u0026lt;\u0026gt;(); // ... completableFuture.completeExceptionally( new RuntimeException(\u0026#34;Calculation failed!\u0026#34;)); // ... completableFuture.get(); // ExecutionException 组合CompletableFuture # 使用thenCompose() 按顺序连接两个CompletableFuture对象\npublic CompletableFuture thenCompose( Function\u0026lt;? super T, ? extends CompletionStage\u0026gt; fn) { return uniComposeStage(null, fn); } public CompletableFuture thenComposeAsync( Function\u0026lt;? super T, ? extends CompletionStage\u0026gt; fn) { return uniComposeStage(defaultExecutor(), fn); } public CompletableFuture thenComposeAsync( Function\u0026lt;? super T, ? extends CompletionStage\u0026gt; fn, Executor executor) { return uniComposeStage(screenExecutor(executor), fn); } 使用示例:\nCompletableFuture\u0026lt;String\u0026gt; future = CompletableFuture.supplyAsync(() -\u0026gt; \u0026#34;hello!\u0026#34;) .thenCompose(s -\u0026gt; CompletableFuture.supplyAsync(() -\u0026gt; s + \u0026#34;world!\u0026#34;)); assertEquals(\u0026#34;hello!world!\u0026#34;, future.get()); 在实际开发中,这个方法还是非常有用的。比如说,我们先要获取用户信息然后再用用户信息去做其他事情。\n和thenCompose()方法类似的还有thenCombine()方法,thenCombine()同样可以组合两个CompletableFuture对象\nCompletableFuture\u0026lt;String\u0026gt; completableFuture = CompletableFuture.supplyAsync(() -\u0026gt; \u0026#34;hello!\u0026#34;) .thenCombine(CompletableFuture.supplyAsync( () -\u0026gt; \u0026#34;world!\u0026#34;), (s1, s2) -\u0026gt; s1 + s2) .thenCompose(s -\u0026gt; CompletableFuture.supplyAsync(() -\u0026gt; s + \u0026#34;nice!\u0026#34;)); assertEquals(\u0026#34;hello!world!nice!\u0026#34;, completableFuture.get()); ★★ thenCompose() 和 thenCombine()有什么区别呢\nthenCompose() 可以两个 CompletableFuture 对象,并将前一个任务的返回结果作为下一个任务的参数,它们之间存在着先后顺序。 thenCombine() 会在两个任务都执行完成后,把两个任务的结果合并。两个任务是并行执行的,它们之间并没有先后依赖顺序。 /* 结果是有顺序的,但是执行的过程是无序的 */ CompletableFuture\u0026lt;String\u0026gt; completableFuture = CompletableFuture.supplyAsync(() -\u0026gt; { System.out.println(\u0026#34;执行了第1个\u0026#34;); try { TimeUnit.SECONDS.sleep(3); } catch (InterruptedException e) { e.printStackTrace(); } System.out.println(\u0026#34;第1个执行结束啦\u0026#34;); return \u0026#34;hello!\u0026#34;; }) .thenCombine(CompletableFuture.supplyAsync( () -\u0026gt; { System.out.println(\u0026#34;执行了第2个\u0026#34;); System.out.println(\u0026#34;第2个执行结束啦\u0026#34;); return \u0026#34;world!\u0026#34;; }), (s1, s2) -\u0026gt; s1 + s2); System.out.println(completableFuture.get()); /* 执行了第1个 执行了第2个 第2个执行结束啦 第1个执行结束啦 hello!world! */ 并行运行多个CompletableFuture # 通过CompletableFuture的allOf()这个静态方法并行运行多个CompletableFuture\n实际项目中,我们经常需要并行运行多个互不相关的任务,这些任务没有依赖关系\n比如读取处理6个文件,没有顺序依赖关系 但我们需要返回给用户的时候将这几个文件的处理结果统计整理,示例:\nCompletableFuture\u0026lt;Void\u0026gt; task1 = CompletableFuture.supplyAsync(()-\u0026gt;{ //自定义业务操作 }); ...... CompletableFuture\u0026lt;Void\u0026gt; task6 = CompletableFuture.supplyAsync(()-\u0026gt;{ //自定义业务操作 }); ...... CompletableFuture\u0026lt;Void\u0026gt; headerFuture=CompletableFuture.allOf(task1,.....,task6); try { headerFuture.join(); } catch (Exception ex) { ...... } System.out.println(\u0026#34;all done. \u0026#34;); 调用join()可以让程序等future1和future2都运行完后继续执行\nCompletableFuture\u0026lt;Void\u0026gt; completableFuture = CompletableFuture.allOf(future1, future2); completableFuture.join(); assertTrue(completableFuture.isDone()); System.out.println(\u0026#34;all futures done...\u0026#34;); /**--- future1 done... future2 done... all futures done... */ anyOf则其中一个执行完就立马返回\nCompletableFuture\u0026lt;Object\u0026gt; f = CompletableFuture.anyOf(future1, future2); System.out.println(f.get()); /* future2 done... efg */ //或 /* future1 done... abc */ 例子2\nCompletableFuture\u0026lt;Object\u0026gt; a = CompletableFuture.supplyAsync(() -\u0026gt; { System.out.println(\u0026#34;a\u0026#34;); //执行了3s的任务 try { TimeUnit.SECONDS.sleep(3); } catch (InterruptedException e) { e.printStackTrace(); } return \u0026#34;a-hello\u0026#34;; }); CompletableFuture\u0026lt;Object\u0026gt; b = CompletableFuture.supplyAsync(() -\u0026gt; { System.out.println(\u0026#34;b\u0026#34;); //执行了3s的任务 try { TimeUnit.SECONDS.sleep(10); } catch (InterruptedException e) { e.printStackTrace(); } return \u0026#34;b-hello\u0026#34;; }); /* //会等两个任务都执行完才继续 CompletableFuture\u0026lt;Void\u0026gt; voidCompletableFuture = CompletableFuture.allOf(a, b); voidCompletableFuture.join(); //停顿10s System.out.println(\u0026#34;主线程继续执行\u0026#34;);*/ //任何一个任务执行完就会继续执行 CompletableFuture\u0026lt;Object\u0026gt; objectCompletableFuture = CompletableFuture.anyOf(a, b); objectCompletableFuture.join(); //会得到最快返回值的那个CompletableFuture的值 System.out.println(objectCompletableFuture.get()); //停顿3s System.out.println(\u0026#34;主线程继续执行\u0026#34;); 后记 # 京东的aysncTool框架\nhttps://gitee.com/jd-platform-opensource/asyncTool#%E5%B9%B6%E8%A1%8C%E5%9C%BA%E6%99%AF%E4%B9%8B%E6%A0%B8%E5%BF%83%E4%BB%BB%E6%84%8F%E7%BC%96%E6%8E%92\n"},{"id":319,"href":"/zh/docs/technology/Review/java_guide/java/Concurrent/ly0310lythreadlocal/","title":"ThreadLocal详解","section":"并发","content":" 转载自https://github.com/Snailclimb/JavaGuide (添加小部分笔记)感谢作者!\n本文来自一枝花算不算浪漫投稿, 原文地址: https://juejin.cn/post/6844904151567040519open in new window。 感谢作者!\n思维导图\n目录 # ThreadLocal代码演示 # 简单使用\npublic class ThreadLocalTest { private List\u0026lt;String\u0026gt; messages = Lists.newArrayList(); public static final ThreadLocal\u0026lt;ThreadLocalTest\u0026gt; holder = ThreadLocal.withInitial(ThreadLocalTest::new); public static void add(String message) { holder.get().messages.add(message); } public static List\u0026lt;String\u0026gt; clear() { List\u0026lt;String\u0026gt; messages = holder.get().messages; holder.remove(); System.out.println(\u0026#34;size: \u0026#34; + holder.get().messages.size()); return messages; } public static void main(String[] args) { ThreadLocalTest.add(\u0026#34;一枝花算不算浪漫\u0026#34;); System.out.println(holder.get().messages); ThreadLocalTest.clear(); } } /* 结果 [一枝花算不算浪漫] size: 0 */ 简单使用2\n@Data class LyTest{ private ThreadLocal\u0026lt;String\u0026gt; threadLocal=ThreadLocal.withInitial(()-\u0026gt;{ return \u0026#34;hello\u0026#34;; }); } public class ThreadLocalTest { public static void main(String[] args) throws InterruptedException { CountDownLatch countDownLatch=new CountDownLatch(2); LyTest lyTest=new LyTest(); ThreadLocal\u0026lt;String\u0026gt; threadLocal = lyTest.getThreadLocal(); new Thread(()-\u0026gt;{ String name = Thread.currentThread().getName(); threadLocal.set(name+ \u0026#34;-ly\u0026#34;); System.out.println(name+\u0026#34;:threadLocal当前值\u0026#34;+threadLocal.get()); countDownLatch.countDown(); },\u0026#34;线程1\u0026#34;).start(); new Thread(()-\u0026gt;{ String name = Thread.currentThread().getName(); threadLocal.set(name+ \u0026#34;-ly\u0026#34;); System.out.println(name+\u0026#34;:threadLocal当前值\u0026#34;+threadLocal.get()); countDownLatch.countDown(); },\u0026#34;线程2\u0026#34;).start(); /*while (true){}*/ countDownLatch.await(); System.out.println(Thread.currentThread().getName()+\u0026#34;:threadLocal当前值\u0026#34;+threadLocal.get()); } } /* 线程1:threadLocal当前值线程1-ly 线程2:threadLocal当前值线程2-ly main:threadLocal当前值hello */ ThreadLocal对象可以提供线程局部变量,每个线程Thread拥有一份自己的副本变量,多个线程互不干扰。\n回顾之前的知识点\npublic void set(T value) { //获取当前请求的线程 Thread t = Thread.currentThread(); //取出 Thread 类内部的 threadLocals 变量(哈希表结构) ThreadLocalMap map = getMap(t); if (map != null) // 将需要存储的值放入到这个哈希表中 //★★实际使用的方法 map.set(this, value); else //★★实际使用的方法 createMap(t, value); } ThreadLocalMap getMap(Thread t) { return t.threadLocals; } 如上,实际存取都是从Thread的threadLocals (ThreadLocalMap类)中,并不是存在ThreadLocal上,ThreadLocal用来传递了变量值,只是ThreadLocalMap的封装 ThreadLocal类中通过Thread.currentThread()获取到当前线程对象后,直接通过getMap(Thread t) 可以访问到该线程的ThreadLocalMap对象 每个Thread中具备一个ThreadLocalMap,而ThreadLocalMap可以存储以ThreadLocal为key,Object对象为value的键值对 ThreadLocal的数据结构 # 由上面回顾的知识点可知,value实际上都是保存在**线程类(Thread类)中的某个属性(ThreadLocalMap类)**中\nThreadLocalMap的底层是一个数组(map的底层是数组)\nThread类有一个类型为**ThreadLocal.ThreadLocalMap**的实例变量threadLocals,也就是说每个线程有一个自己的ThreadLocalMap。 ThreadLocalMap是一个静态内部类\n没有修饰符,为包可见。比如父类有一个protected修饰的方法f(),不同包下存在子类A和其他类X,在子类中可以访问方法f(),即使在其他类X创建子类A实例a1,也不能调用a1.f()\u0026ndash;\u0026gt; 其他包不可见\nThreadLocalMap有自己独立实现,简单地将它的key视作ThreadLocal,value为代码中放入的值,(看底层代码可知,实际key不是ThreadLocal本身,而是它的一个弱引用)\n★每个线程在往ThreadLocal里放值的时候,都会往自己的ThreadLocalMap里存,读也是以ThreadLocal作为引用,在自己的map里找对应的key,从而实现了线程隔离。\nThreadLocalMap有点类似HashMap的结构,只是HashMap是由数组+链表实现的,而ThreadLocalMap中并没有链表结构。其中,还要注意Entry类, 它的key是ThreadLocal\u0026lt;?\u0026gt; k ,(Entry类)继承自WeakReference, 也就是我们常说的弱引用类型。\n如下,有个数组存放Entry(弱引用类,且有属性value),且\nstatic class ThreadLocalMap { static class Entry extends WeakReference\u0026lt;ThreadLocal\u0026lt;?\u0026gt;\u0026gt; { /** The value associated with this ThreadLocal. */ Object value; Entry(ThreadLocal\u0026lt;?\u0026gt; k, Object v) { super(k); value = v; } } //..... } 为上面的知识点总结一张图 # GC之后key是否为null # WeakReference的使用\nWeakReference\u0026lt;Car\u0026gt; weakCar = new WeakReference(Car)(car); weakCar.get(); //如果值为null表示已经被回收了 问题: ThreadLocal的key为弱引用,那么在ThreadLocal.get()的时候,发生GC之后,key是否为null\nJava的四种引用类型 强引用:通常情况new出来的为强引用,只要强引用存在,垃圾回收器永远不会回收被引用的对象(即使内存不足) 软引用:使用SoftReference修饰的对象称软引用,软引用指向的对象在内存要溢出的时候被回收 弱引用:使用WeakReference修饰的对象称为弱引用,只要发生垃圾回收,如果这个对象只被弱引用指向,那么就会被回收 虚引用:虚引用是最弱的引用,用PhantomReference定义。唯一的作用就是用队列接收对象即将死亡的通知 使用反射方式查看GC后ThreadLocal中的数据情况\nimport java.lang.reflect.Field; /* t.join()方法阻塞调用此方法的线程(calling thread)进入 TIMED_WAITING 状态,直到线程t完成,此线程再继续 */ public class ThreadLocalDemo { public static void main(String[] args) throws NoSuchFieldException, IllegalAccessException, InterruptedException { Thread t = new Thread(()-\u0026gt;test(\u0026#34;abc\u0026#34;,false)); t.start(); t.join(); System.out.println(\u0026#34;--gc后--\u0026#34;); Thread t2 = new Thread(() -\u0026gt; test(\u0026#34;def\u0026#34;, true)); t2.start(); t2.join(); } private static void test(String s,boolean isGC) { try { //注意这一行,这个ThreadLocal对象是不存在任何强引用的 new ThreadLocal\u0026lt;\u0026gt;().set(s);//当前线程设置了一个值 s if (isGC) { System.gc(); } Thread t = Thread.currentThread(); Class\u0026lt;? extends Thread\u0026gt; clz = t.getClass(); Field field = clz.getDeclaredField(\u0026#34;threadLocals\u0026#34;); field.setAccessible(true); Object threadLocalMap = field.get(t);//得到当前线程的ThreadLocalMap Class\u0026lt;?\u0026gt; tlmClass = threadLocalMap.getClass(); Field tableField = tlmClass.getDeclaredField(\u0026#34;table\u0026#34;); tableField.setAccessible(true); //注意:这里获取的是threadLocalMap内部的(维护)数组 private Entry[] table; Object[] arr = (Object[]) tableField.get(threadLocalMap); for (Object o : arr) { if (o != null) { Class\u0026lt;?\u0026gt; entryClass = o.getClass(); /* Entry结构 static class Entry extends WeakReference\u0026lt;ThreadLocal\u0026lt;?\u0026gt;\u0026gt; { //The value associated with this ThreadLocal. Object value; Entry(ThreadLocal\u0026lt;?\u0026gt; k, Object v) { super(k); value = v; } } */ //获取Entry中的值(键值对的“值”) Field valueField = entryClass.getDeclaredField(\u0026#34;value\u0026#34;); //Entry extends WeakReference //WeakReference\u0026lt;T\u0026gt; extends Reference\u0026lt;T\u0026gt; //Reference 里面有一个属性 referent ,指向实际的对象,即key实际的对象 Field referenceField = entryClass.getSuperclass().getSuperclass().getDeclaredField(\u0026#34;referent\u0026#34;); valueField.setAccessible(true); referenceField.setAccessible(true); System.out.println(String.format(\u0026#34;弱引用key:%s,值:%s\u0026#34;, referenceField.get(o), valueField.get(o))); } } } catch (Exception e) { e.printStackTrace(); } } } /* 结果如下 弱引用key:java.lang.ThreadLocal@433619b6,值:abc 弱引用key:java.lang.ThreadLocal@418a15e3,值:java.lang.ref.SoftReference@bf97a12 --gc后-- 弱引用key:null,值:def */ gc之后的图:\nnew ThreadLocal\u0026lt;\u0026gt;().set(s); GC之后,key就会被回收,我们看到上面的debug中referent=null\n如果这里修改代码,\nThreadLocal\u0026lt;Object\u0026gt; threadLocal=new ThreadLocal\u0026lt;\u0026gt;(); threadLocal.set(s); 使用弱引用+垃圾回收\n如上,垃圾回收前,ThreadLoal是存在强引用的,因此如果如上修改代码,则key不为null\n当不存在强引用时,key会被回收,即出现value没被回收,key被回收,导致key永远存在,内存泄漏\nThreadLocal.set()方法源码详解 # 如图所示\nThreadLocal中的set()方法原理如上,先取出线程Thread中的threadLocals,判断是否存在,然后使用ThreadLocal中的set方法进行数据处理\npublic void set(T value) { Thread t = Thread.currentThread(); ThreadLocalMap map = getMap(t); if (map != null) map.set(this, value); else createMap(t, value); } void createMap(Thread t, T firstValue) { t.threadLocals = new ThreadLocalMap(this, firstValue); } ThreadLocalMap Hash算法 # ThreadLocalMap实现了自己的hash算法来解决散列表数组冲突问题:\n//i为当前key在散列表中对应的数组下标位置 //即(len-1)和和斐波那契数做 与运算 int i = key.threadLocalHashCode \u0026amp; (len-1); threadLocalHashCode值的计算,ThreadLocal中有一个属性为HASH_INCREMENT = 0x61c88647\n0x61c88647,又称为斐波那契数也叫黄金分割数,hash增量为这个数,好处是hash 分布非常均匀\npublic class ThreadLocal\u0026lt;T\u0026gt; { private final int threadLocalHashCode = nextHashCode(); private static AtomicInteger nextHashCode = new AtomicInteger(); private static final int HASH_INCREMENT = 0x61c88647; //hashCode增加 private static int nextHashCode() { return nextHashCode.getAndAdd(HASH_INCREMENT); } static class ThreadLocalMap { ThreadLocalMap(ThreadLocal\u0026lt;?\u0026gt; firstKey, Object firstValue) { table = new Entry[INITIAL_CAPACITY]; int i = firstKey.threadLocalHashCode \u0026amp; (INITIAL_CAPACITY - 1); table[i] = new Entry(firstKey, firstValue); size = 1; setThreshold(INITIAL_CAPACITY); } } } 例子如下,产生的哈希码分布十分均匀\n★★ 说明,下面的所有示例图中,绿色块Entry代表为正常数据,灰色块代表Entry的key为null,已被垃圾回收。白色块代表Entry为null(或者说数组那个位置为null(没有指向))\nThreadLocalMap Hash冲突 # ThreadLocalMap 中使用黄金分割数作为hash计算因子,大大减少Hash冲突的概率 HashMap中解决冲突的方法,是在数组上构造一个链表结构,冲突的数据挂载到链表上,如果链表长度超过一定数量则会转化为红黑树 ThreadLocalMap中没有链表结构(使用线性向后查找) 如图 假设需要插入value = 27 的数据,hash后应该落入槽位4,而槽位已经有了Entry数据 此时线性向后查找,一直找到Entry为null的操作才会停止查找,将当前元素放入该槽位中 线性向后查找迭代中,会遇到Entry不为null且key值相等,以及**Entry中的key为null(图中Entry 为 2)**的情况,处理方式不同 set过程中如果遇到了key过期(key为null)的Entry数据,实际上会进行一轮探测式清理操作 ThreadLocalMap.set() 详解 # ThreadLocalMap.set() 原理图解\n往ThreadLocalMap中set数据(新增或更新数据)分为好几种\n通过hash计算后的槽位对应的Entry数据为空 直接将数据放到该槽位即可\n槽位数据不为空,key值与当前ThreadLocal通过hash计算获取的key值一致 直接更新该槽位的数据\n槽位数据不为空,往后遍历过程中,在找到Entry为null的槽位之前,没有遇到过期的Entry 遍历散列数组的过程中,线性往后查找,如果找到Entry为null的槽位则将数据放入槽位中;或者往后遍历过程中遇到key值相等的数据则更新\n槽位数据不为空,在找到Entry为null的槽位之前,遇到了过期的Entry,如下图 此时会执行replaceStableEntry()方法,该方法含义是替换过期数据的逻辑\n\u0026hellip; 以下省略,太复杂\n替换完成后也是进行过期元素清理工作,清理工作主要是有两个方法:expungeStaleEntry()和cleanSomeSlots()\n经过迭代处理后,有过Hash冲突数据的Entry位置会更靠近正确位置,这样的话,查询的时候 效率才会更高。\nThreadLocalMap过期 key 的探测式清理流程(略过) # ThreadLocalMap扩容机制 # 在ThreadLocalMap.set()方法的最后,如果执行完启发式清理工作后,未清理到任何数据,且当前散列数组中Entry的数量已经达到了列表的扩容阈值(len*2/3),就开始执行rehash()逻辑:\nif (!cleanSomeSlots(i, sz) \u0026amp;\u0026amp; sz \u0026gt;= threshold) rehash(); rehash()的具体实现\nprivate void rehash() { expungeStaleEntries(); if (size \u0026gt;= threshold - threshold / 4) resize(); } private void expungeStaleEntries() { Entry[] tab = table; int len = tab.length; for (int j = 0; j \u0026lt; len; j++) { Entry e = tab[j]; if (e != null \u0026amp;\u0026amp; e.get() == null) expungeStaleEntry(j); } } 注意:\nthreshold [ˈθreʃhəʊld], 门槛 = length * 2/3\nrehash之前进行一次容量判断( 是否 \u0026gt; threshold , 是则rehash)\nrehash时先进行expungeStaleEntries() (探索式清理,从table起始为止)\n这里首先是会进行探测式清理工作,从table的起始位置往后清理,上面有分析清理的详细流程。清理完成之后,table中可能有一些key为null的Entry数据被清理掉,所以此时通过判断size \u0026gt;= threshold - threshold / 4 也就是size \u0026gt;= threshold * 3/4 来决定是否扩容。\n清理后如果大于 threshold 的3/4 ,则进行扩容 具体的resize()方法 以oldTab .len = 8\n容后的tab的大小为oldLen * 2 =16\n遍历老的散列表,重新计算hash位置,然后放到新的tab数组中,如果出现hash冲突则往后寻找最近的entry为null的槽位\n遍历完成之后,oldTab中所有的entry数据都已经放入到新的tab中了。重新计算tab下次扩容的阈值 代码如下\nprivate void resize() { Entry[] oldTab = table; int oldLen = oldTab.length; int newLen = oldLen * 2; Entry[] newTab = new Entry[newLen]; int count = 0; for (int j = 0; j \u0026lt; oldLen; ++j) { Entry e = oldTab[j]; if (e != null) { ThreadLocal\u0026lt;?\u0026gt; k = e.get(); if (k == null) { e.value = null; } else { int h = k.threadLocalHashCode \u0026amp; (newLen - 1); while (newTab[h] != null) h = nextIndex(h, newLen); newTab[h] = e; count++; } } } setThreshold(newLen); size = count; table = newTab; } ThreadLocalMap.get() 详解 # 通过查找key值计算出散列表中slot位置,然后该slot位置中的Entry.key和查找的key一致,则直接返回 slot位置中的Entry.key和要查找的key不一致,之后清理+遍历 我们以get(ThreadLocal1)为例,通过hash计算后,正确的slot位置应该是 4,而index=4的槽位已经有了数据,且key值不等于ThreadLocal1,所以需要继续往后迭代查找。\n迭代到index=5的数据时,此时Entry.key=null,触发一次探测式数据回收操作,执行expungeStaleEntry()方法,执行完后,index 5,8的数据都会被回收,而index 6,7的数据都会前移。index 6,7前移之后,继续从 index=5 往后迭代,于是就在 index=5 找到了key值相等的Entry数据,如下图所示: ThreadLocalMap.get()源码详解\nprivate Entry getEntry(ThreadLocal\u0026lt;?\u0026gt; key) { int i = key.threadLocalHashCode \u0026amp; (table.length - 1); Entry e = table[i]; if (e != null \u0026amp;\u0026amp; e.get() == key) return e; else return getEntryAfterMiss(key, i, e); } private Entry getEntryAfterMiss(ThreadLocal\u0026lt;?\u0026gt; key, int i, Entry e) { Entry[] tab = table; int len = tab.length; while (e != null) { ThreadLocal\u0026lt;?\u0026gt; k = e.get(); if (k == key) return e; if (k == null) expungeStaleEntry(i); else i = nextIndex(i, len); e = tab[i]; } return null; } ThreadLocalMap过期key的启发式清理流程(略过,跟移位运算符有关) # 上面多次提及到ThreadLocalMap过期key的两种清理方式:探测式清理(expungeStaleEntry())、启发式清理(cleanSomeSlots())\n探测式清理是以当前Entry 往后清理,遇到值为null则结束清理,属于线性探测清理。\n而启发式清理被作者定义为:Heuristically scan some cells looking for stale entries.\nInheritable ThreadLocal # 使用ThreadLocal的时候,在异步场景下是无法给子线程共享父线程中创建的线程副本数据的。JDK中存在InheritableThreadLocal类可以解决处理这个问题\n原理: 子线程是通过在父线程中通过new Thread()方法创建子线程,Thread#init 方法在Thread的构造方法中被调用,init方法中拷贝父线程数据到子线程中\nprivate void init(ThreadGroup g, Runnable target, String name, long stackSize, AccessControlContext acc, boolean inheritThreadLocals) { if (name == null) { throw new NullPointerException(\u0026#34;name cannot be null\u0026#34;); } if (inheritThreadLocals \u0026amp;\u0026amp; parent.inheritableThreadLocals != null) this.inheritableThreadLocals = ThreadLocal.createInheritedMap(parent.inheritableThreadLocals); this.stackSize = stackSize; tid = nextThreadID(); } public class InheritableThreadLocalDemo { public static void main(String[] args) { ThreadLocal\u0026lt;String\u0026gt; ThreadLocal = new ThreadLocal\u0026lt;\u0026gt;(); ThreadLocal\u0026lt;String\u0026gt; inheritableThreadLocal = new InheritableThreadLocal\u0026lt;\u0026gt;(); ThreadLocal.set(\u0026#34;父类数据:threadLocal\u0026#34;); inheritableThreadLocal.set(\u0026#34;父类数据:inheritableThreadLocal\u0026#34;); new Thread(new Runnable() { @Override public void run() { System.out.println(\u0026#34;子线程获取父类ThreadLocal数据:\u0026#34; + ThreadLocal.get()); System.out.println(\u0026#34;子线程获取父类inheritableThreadLocal数据:\u0026#34; + inheritableThreadLocal.get()); } }).start(); } } /*结果 子线程获取父类ThreadLocal数据:null 子线程获取父类inheritableThreadLocal数据:父类数据:inheritableThreadLocal */ 但是如果不是直接new(),也就是实际中我们都是通过使用线程池来获取新线程的,那么可以使用阿里开源的一个组件解决这个问题 TransmittableThreadLocal\nThreadLocal项目中使用实战 # 这里涉及到requestId,没用过,不是很懂,略过\nThreadLocal使用场景 # Feign远程调用解决方案 # 线程池异步调用,requestId 传递 # 使用MQ发送消息给第三方系统 # "},{"id":320,"href":"/zh/docs/technology/Review/java_guide/java/Concurrent/ly0309lyatomic-classes/","title":"Atomic原子类介绍","section":"并发","content":" 转载自https://github.com/Snailclimb/JavaGuide (添加小部分笔记)感谢作者!\n文章开头先用例子介绍几种类型的api使用\npackage com.aqs; import lombok.*; import java.util.concurrent.atomic.*; @Data @Getter @Setter @AllArgsConstructor @ToString class User { private String name; //如果要为atomicReferenceFieldUpdater服务,必须加上volatile修饰 public volatile Integer age; } public class AtomicTest { public static void main(String[] args) { System.out.println(\u0026#34;原子更新数值---------------\u0026#34;); AtomicInteger atomicInteger = new AtomicInteger(); int i1 = atomicInteger.incrementAndGet(); System.out.println(\u0026#34;原子增加后为\u0026#34; + i1); System.out.println(\u0026#34;原子更新数组---------------\u0026#34;); int[] a = new int[3]; AtomicIntegerArray atomicIntegerArray = new AtomicIntegerArray(a); int i = atomicIntegerArray.addAndGet(1, 3); System.out.println(\u0026#34;数组元素[\u0026#34; + 1 + \u0026#34;]增加后为\u0026#34; + i); System.out.println(\u0026#34;数组为\u0026#34; + atomicIntegerArray); System.out.println(\u0026#34;原子更新对象---------------\u0026#34;); User user1 = new User(\u0026#34;ly1\u0026#34;, 10); User user2 = new User(\u0026#34;ly2\u0026#34;, 20); User user3 = new User(\u0026#34;ly3\u0026#34;, 30); AtomicReference\u0026lt;User\u0026gt; atomicReference = new AtomicReference\u0026lt;\u0026gt;(user1); boolean b = atomicReference.compareAndSet(user2, user3); System.out.println(\u0026#34;更新\u0026#34; + (b ? \u0026#34;成功\u0026#34; : \u0026#34;失败\u0026#34;)); System.out.println(\u0026#34;引用里值为\u0026#34;+atomicReference.get()); boolean b1 = atomicReference.compareAndSet(user1, user3); System.out.println(\u0026#34;更新\u0026#34; + (b1 ? \u0026#34;成功\u0026#34; : \u0026#34;失败\u0026#34;)); System.out.println(\u0026#34;引用里值为\u0026#34;+atomicReference.get()); System.out.println(\u0026#34;原子更新对象属性---------------\u0026#34;); User user4=new User(\u0026#34;ly4\u0026#34;,40); AtomicReferenceFieldUpdater\u0026lt;User, Integer\u0026gt; atomicReferenceFieldUpdater = AtomicReferenceFieldUpdater.newUpdater(User.class, Integer.class, \u0026#34;age\u0026#34;); boolean b2 = atomicReferenceFieldUpdater.compareAndSet(user4, 41, 400); System.out.println(\u0026#34;更新\u0026#34;+(b2?\u0026#34;成功\u0026#34;:\u0026#34;失败\u0026#34;)); System.out.println(\u0026#34;引用里user4值为\u0026#34;+atomicReferenceFieldUpdater.get(user4)); boolean b3 = atomicReferenceFieldUpdater.compareAndSet(user4, 40, 400); System.out.println(\u0026#34;更新\u0026#34;+(b3?\u0026#34;成功\u0026#34;:\u0026#34;失败\u0026#34;)); System.out.println(\u0026#34;引用里user4值为\u0026#34;+atomicReferenceFieldUpdater.get(user4)); System.out.println(\u0026#34;其他使用---------------\u0026#34;); User user5=new User(\u0026#34;ly5\u0026#34;,50); User user6=new User(\u0026#34;ly6\u0026#34;,60); User user7=new User(\u0026#34;ly7\u0026#34;,70); AtomicMarkableReference\u0026lt;User\u0026gt; userAtomicMarkableReference=new AtomicMarkableReference\u0026lt;\u0026gt;(user5,true); boolean b4 = userAtomicMarkableReference.weakCompareAndSet(user6, user7, true, false); System.out.println(\u0026#34;更新\u0026#34;+(b4?\u0026#34;成功\u0026#34;:\u0026#34;失败\u0026#34;)); System.out.println(\u0026#34;引用里值为\u0026#34;+userAtomicMarkableReference.getReference()); boolean b5 = userAtomicMarkableReference.weakCompareAndSet(user5, user7, false, true); System.out.println(\u0026#34;更新\u0026#34;+(b5?\u0026#34;成功\u0026#34;:\u0026#34;失败\u0026#34;)); System.out.println(\u0026#34;引用里值为\u0026#34;+userAtomicMarkableReference.getReference()); boolean b6 = userAtomicMarkableReference.weakCompareAndSet(user5, user7, true, false); System.out.println(\u0026#34;更新\u0026#34;+(b6?\u0026#34;成功\u0026#34;:\u0026#34;失败\u0026#34;)); System.out.println(\u0026#34;引用里值为\u0026#34;+userAtomicMarkableReference.getReference()); System.out.println(\u0026#34;AtomicStampedReference使用---------------\u0026#34;); User user80=new User(\u0026#34;ly8\u0026#34;,80); User user90=new User(\u0026#34;ly9\u0026#34;,90); User user100=new User(\u0026#34;ly10\u0026#34;,100); AtomicStampedReference\u0026lt;User\u0026gt; userAtomicStampedReference=new AtomicStampedReference\u0026lt;\u0026gt;(user80,80);//版本80 //...每次更改stamp都加1 //这里假设中途被改成81了 boolean b7 = userAtomicStampedReference.compareAndSet(user80, user100,81,90); System.out.println(\u0026#34;更新\u0026#34;+(b7?\u0026#34;成功\u0026#34;:\u0026#34;失败\u0026#34;)); System.out.println(\u0026#34;引用里值为\u0026#34;+userAtomicStampedReference.getReference()); boolean b8 = userAtomicStampedReference.compareAndSet(user80, user100,80,90); System.out.println(\u0026#34;更新\u0026#34;+(b8?\u0026#34;成功\u0026#34;:\u0026#34;失败\u0026#34;)); System.out.println(\u0026#34;引用里值为\u0026#34;+userAtomicStampedReference.getReference()); } } /* 原子更新数值--------------- 原子增加后为1 原子更新数组--------------- 数组元素[1]增加后为3 数组为[0, 3, 0] 原子更新对象--------------- 更新失败 引用里值为User(name=ly1, age=10) 更新成功 引用里值为User(name=ly3, age=30) 原子更新对象属性--------------- 更新失败 引用里user4值为40 更新成功 引用里user4值为400 其他使用--------------- 更新失败 引用里值为User(name=ly5, age=50) 更新失败 引用里值为User(name=ly5, age=50) 更新成功 引用里值为User(name=ly7, age=70) AtomicStampedReference使用--------------- 更新失败 引用里值为User(name=ly8, age=80) 更新成功 引用里值为User(name=ly10, age=100) Process finished with exit code 0 */ 原子类介绍 # 在化学上,原子是构成一般物质的最小单位,化学反应中是不可分割的,Atomic指一个操作是不可中断的,即使在多个线程一起执行时,一个操作一旦开始就不会被其他线程干扰 原子类\u0026ndash;\u0026gt;具有原子/原子操作特征的类 并发包java.util.concurrent 的原子类都放着java.util.concurrent.atomic中 根据操作的数据类型,可以将JUC包中的原子类分为4类(基本类型、数组类型、引用类型、对象的属性修改类型) 基本类型 使用原子方式更新基本类型,包括AtomicInteger 整型原子类,AtomicLong 长整型原子类,AtomicBoolean 布尔型原子类\n数组类型 使用原子方式更新数组里某个元素,包括AtomicIntegerArray 整型数组原子类,AtomicLongArray 长整型数组原子类,AtomicReferenceArray 引用类型数组原子类\n引用类型 AtomicReference 引用类型原子类,AtomicMarkableReference 原子更新带有标记的引用类型,该类将boolean标记与引用关联(不可解决CAS进行原子操作出现的ABA问题),AtomicStampedReference 原子更新带有版本号的引用类型 该类将整数值与引用关联,可用于解决原子更新数据和数据的版本号(解决使用CAS进行原子更新时可能出现的ABA问题)\n对象的属性修改类型 AtomicIntegerFieldUpdater 原子更新整型字段的更新器,AtomicLongFieldUpdater 原子更新长整型字段的更新器, AtomicReferenceFieldUpdater 原子更新引用类型里的字段\nAtomicMarkableReference 不能解决 ABA 问题\npublic class SolveABAByAtomicMarkableReference { private static AtomicMarkableReference atomicMarkableReference = new AtomicMarkableReference(100, false); public static void main(String[] args) { Thread refT1 = new Thread(() -\u0026gt; { try { TimeUnit.SECONDS.sleep(1); } catch (InterruptedException e) { e.printStackTrace(); } atomicMarkableReference.compareAndSet(100, 101, atomicMarkableReference.isMarked(), !atomicMarkableReference.isMarked());//根据期望值100和false 修改为101和true atomicMarkableReference.compareAndSet(101, 100, atomicMarkableReference.isMarked(), !atomicMarkableReference.isMarked());//根据期望值101和true 修改为100和false }); Thread refT2 = new Thread(() -\u0026gt; { //获取原来的marked标记(false) boolean marked = atomicMarkableReference.isMarked(); //2s之后进行替换,不应该替换成功 try { TimeUnit.SECONDS.sleep(2); } catch (InterruptedException e) { e.printStackTrace(); } boolean c3 = atomicMarkableReference.compareAndSet(100, 101, marked, !marked); System.out.println(c3); // 返回true,实际应该返回false }); //导致了ABA问题 refT1.start(); refT2.start(); } } CAS ABA问题\n描述: 第一个线程取到了变量 x 的值 A,然后巴拉巴拉干别的事,总之就是只拿到了变量 x 的值 A。这段时间内第二个线程也取到了变量 x 的值 A,然后把变量 x 的值改为 B,然后巴拉巴拉干别的事,最后又把变量 x 的值变为 A (相当于还原了)。在这之后第一个线程终于进行了变量 x 的操作,但是此时变量 x 的值还是 A,所以 compareAndSet 操作是成功。\n也就是说,线程一无法保证自己操作期间,该值被修改了\n例子描述(可能不太合适,但好理解): 年初,现金为零,然后通过正常劳动赚了三百万,之后正常消费了(比如买房子)三百万。年末,虽然现金零收入(可能变成其他形式了),但是赚了钱是事实,还是得交税的!\n代码描述\nimport java.util.concurrent.atomic.AtomicInteger; public class AtomicIntegerDefectDemo { public static void main(String[] args) { defectOfABA(); } static void defectOfABA() { final AtomicInteger atomicInteger = new AtomicInteger(1); Thread coreThread = new Thread( () -\u0026gt; { final int currentValue = atomicInteger.get(); System.out.println(Thread.currentThread().getName() + \u0026#34; ------ currentValue=\u0026#34; + currentValue); // 这段目的:模拟处理其他业务花费的时间 //也就是说,在差值300-100=200ms内,值被操作了两次(但又改回去了),然后线程coreThread并没有感知到,当作没有修改过来处理 try { Thread.sleep(300); } catch (InterruptedException e) { e.printStackTrace(); } boolean casResult = atomicInteger.compareAndSet(1, 2); System.out.println(Thread.currentThread().getName() + \u0026#34; ------ currentValue=\u0026#34; + currentValue + \u0026#34;, finalValue=\u0026#34; + atomicInteger.get() + \u0026#34;, compareAndSet Result=\u0026#34; + casResult); } ); coreThread.start(); // 这段目的:为了让 coreThread 线程先跑起来 try { Thread.sleep(100); } catch (InterruptedException e) { e.printStackTrace(); } Thread amateurThread = new Thread( () -\u0026gt; { int currentValue = atomicInteger.get(); boolean casResult = atomicInteger.compareAndSet(1, 2); System.out.println(Thread.currentThread().getName() + \u0026#34; ------ currentValue=\u0026#34; + currentValue + \u0026#34;, finalValue=\u0026#34; + atomicInteger.get() + \u0026#34;, compareAndSet Result=\u0026#34; + casResult); currentValue = atomicInteger.get(); casResult = atomicInteger.compareAndSet(2, 1); System.out.println(Thread.currentThread().getName() + \u0026#34; ------ currentValue=\u0026#34; + currentValue + \u0026#34;, finalValue=\u0026#34; + atomicInteger.get() + \u0026#34;, compareAndSet Result=\u0026#34; + casResult); } ); amateurThread.start(); } } /*输出内容 Thread-0 ------ currentValue=1 Thread-1 ------ currentValue=1, finalValue=2, compareAndSet Result=true Thread-1 ------ currentValue=2, finalValue=1, compareAndSet Result=true Thread-0 ------ currentValue=1, finalValue=2, compareAndSet Result=true */ 基本类型原子类 # 使用原子方式更新基本类型:AtomicInteger 整型原子类,AtomicLong 长整型原子类 ,AtomicBoolean 布尔型原子类,下文以AtomicInteger为例子来介绍 常用方法:\npublic final int get() //获取当前的值 public final int getAndSet(int newValue)//获取当前的值,并设置新的值 public final int getAndIncrement()//获取当前的值,并自增 public final int getAndDecrement() //获取当前的值,并自减 public final int getAndAdd(int delta) //获取当前的值,并加上预期的值 boolean compareAndSet(int expect, int update) //如果输入的数值等于预期值,则以原子方式将该值设置为输入值(update) public final void lazySet(int newValue)//最终设置为newValue,使用 lazySet 设置之后可能导致其他线程在之后的一小段时间内还是可以读到旧的值。 常见方法使用\nimport java.util.concurrent.atomic.AtomicInteger; public class AtomicIntegerTest { public static void main(String[] args) { // TODO Auto-generated method stub int temvalue = 0; AtomicInteger i = new AtomicInteger(0); temvalue = i.getAndSet(3); System.out.println(\u0026#34;temvalue:\u0026#34; + temvalue + \u0026#34;; i:\u0026#34; + i);//temvalue:0; i:3 temvalue = i.getAndIncrement(); System.out.println(\u0026#34;temvalue:\u0026#34; + temvalue + \u0026#34;; i:\u0026#34; + i);//temvalue:3; i:4 temvalue = i.getAndAdd(5); System.out.println(\u0026#34;temvalue:\u0026#34; + temvalue + \u0026#34;; i:\u0026#34; + i);//temvalue:4; i:9 } } 基本数据类型原子类的优势 # 多线程环境不使用原子类保证线程安全(基本数据类型)\nclass Test { private volatile int count = 0; //若要线程安全执行执行count++,需要加锁 public synchronized void increment() { count++; } public int getCount() { return count; } } 多线程环境使用原子类保证线程安全(基本数据类型)\nclass Test2 { private AtomicInteger count = new AtomicInteger(); //使用AtomicInteger之后,不需要加锁,也可以实现线程安全。 public void increment() { count.incrementAndGet(); } public int getCount() { return count.get(); } } AtomicInteger线程安全原理简单分析 # 部分源码:\n/ setup to use Unsafe.compareAndSwapInt for updates(更新操作时提供“比较并替换”的作用) private static final Unsafe unsafe = Unsafe.getUnsafe(); private static final long valueOffset; static { try { valueOffset = unsafe.objectFieldOffset (AtomicInteger.class.getDeclaredField(\u0026#34;value\u0026#34;)); } catch (Exception ex) { throw new Error(ex); } } private volatile int value; AtomicInteger类主要利用CAS(compare and swap) + volatile 和 native方法来保证原子操作,从而避免synchronized高开销,提高执行效率 CAS的原理是拿期望的值和原本的值做比较,如果相同则更新成新值 UnSafe类的objectFieldOffset()方法是一个本地方法,这个方法用来拿到**\u0026ldquo;原来的值\u0026quot;的内存地址** value是一个volatile变量,在内存中可见,因此JVM可以保证任何时刻任何线程总能拿到该变量的最新值 数组类型原子类 # 使用原子的方式更新数组里的某个元素\nAtomicIntegerArray 整型数组原子类,AtomicLongArray 长整型数组原子类,AtomicReferenceArray 引用类型数组原子类\n常用方法:\npublic final int get(int i) //获取 index=i 位置元素的值 public final int getAndSet(int i, int newValue)//返回 index=i 位置的当前的值,并将其设置为新值:newValue public final int getAndIncrement(int i)//获取 index=i 位置元素的值,并让该位置的元素自增 public final int getAndDecrement(int i) //获取 index=i 位置元素的值,并让该位置的元素自减 public final int getAndAdd(int i, int delta) //获取 index=i 位置元素的值,并加上预期的值 boolean compareAndSet(int i, int expect, int update) //如果输入的数值等于预期值,则以原子方式将 index=i 位置的元素值设置为输入值(update) public final void lazySet(int i, int newValue)//最终 将index=i 位置的元素设置为newValue,使用 lazySet 设置之后可能导致其他线程在之后的一小段时间内还是可以读到旧的值。 常见方法使用\nimport java.util.concurrent.atomic.AtomicIntegerArray; public class AtomicIntegerArrayTest { public static void main(String[] args) { // TODO Auto-generated method stub int temvalue = 0; int[] nums = { 1, 2, 3, 4, 5, 6 }; AtomicIntegerArray i = new AtomicIntegerArray(nums); for (int j = 0; j \u0026lt; nums.length; j++) { System.out.println(i.get(j)); } temvalue = i.getAndSet(0, 2); System.out.println(\u0026#34;temvalue:\u0026#34; + temvalue + \u0026#34;; i:\u0026#34; + i); temvalue = i.getAndIncrement(0); System.out.println(\u0026#34;temvalue:\u0026#34; + temvalue + \u0026#34;; i:\u0026#34; + i); temvalue = i.getAndAdd(0, 5); System.out.println(\u0026#34;temvalue:\u0026#34; + temvalue + \u0026#34;; i:\u0026#34; + i); } } 引用类型原子类 # 基本类型原子类只能更新一个变量,如果需要原子更新多个变量,则需要使用引用类型原子类\nAtomicReference 引用类型原子类;\nAtomicStampedReference 原子更新带有版本号的引用类型,该类将整数值与引用关联起来,可用于解决原子的更新数据和数据的版本号,可以解决使用CAS进行原子更新时可能出现的ABA问题;\nAtomicMarkableReference:原子更新带有标记的引用类型。该类将boolean标记与引用关联**(注:无法解决ABA问题)**\n下面以AtomicReference为例介绍\nimport java.util.concurrent.atomic.AtomicReference; public class AtomicReferenceTest { public static void main(String[] args) { AtomicReference\u0026lt;Person\u0026gt; ar = new AtomicReference\u0026lt;Person\u0026gt;(); Person person = new Person(\u0026#34;SnailClimb\u0026#34;, 22); ar.set(person); Person updatePerson = new Person(\u0026#34;Daisy\u0026#34;, 20); ar.compareAndSet(person, updatePerson);//如果期望值为person,则替换成updatePerson System.out.println(ar.get().getName()); System.out.println(ar.get().getAge()); } } class Person { private String name; private int age; public Person(String name, int age) { super(); this.name = name; this.age = age; } public String getName() { return name; } public void setName(String name) { this.name = name; } public int getAge() { return age; } public void setAge(int age) { this.age = age; } } 上述代码首先创建了一个 Person 对象,然后把 Person 对象设置进 AtomicReference 对象中,然后调用 compareAndSet 方法,该方法就是通过 CAS 操作设置 ar。如果 ar 的值为 person 的话,则将其设置为 updatePerson。实现原理与 AtomicInteger 类中的 compareAndSet 方法相同。运行上面的代码后的输出结果如下\nDaisy 20 AtomicStampedReference类使用示例\nimport java.util.concurrent.atomic.AtomicStampedReference; public class AtomicStampedReferenceDemo { public static void main(String[] args) { // 实例化、取当前值和 stamp 值 final Integer initialRef = 0, initialStamp = 0; final AtomicStampedReference\u0026lt;Integer\u0026gt; asr = new AtomicStampedReference\u0026lt;\u0026gt;(initialRef, initialStamp); System.out.println(\u0026#34;currentValue=\u0026#34; + asr.getReference() + \u0026#34;, currentStamp=\u0026#34; + asr.getStamp()); // compare and set final Integer newReference = 666, newStamp = 999; final boolean casResult = asr.compareAndSet(initialRef, newReference, initialStamp, newStamp); System.out.println(\u0026#34;currentValue=\u0026#34; + asr.getReference() + \u0026#34;, currentStamp=\u0026#34; + asr.getStamp() + \u0026#34;, casResult=\u0026#34; + casResult); // 获取当前的值和当前的 stamp 值 int[] arr = new int[1]; final Integer currentValue = asr.get(arr); final int currentStamp = arr[0]; System.out.println(\u0026#34;currentValue=\u0026#34; + currentValue + \u0026#34;, currentStamp=\u0026#34; + currentStamp); // 单独设置 stamp 值 final boolean attemptStampResult = asr.attemptStamp(newReference, 88); System.out.println(\u0026#34;currentValue=\u0026#34; + asr.getReference() + \u0026#34;, currentStamp=\u0026#34; + asr.getStamp() + \u0026#34;, attemptStampResult=\u0026#34; + attemptStampResult); // 重新设置当前值和 stamp 值 asr.set(initialRef, initialStamp); System.out.println(\u0026#34;currentValue=\u0026#34; + asr.getReference() + \u0026#34;, currentStamp=\u0026#34; + asr.getStamp()); // [不推荐使用,除非搞清楚注释的意思了] weak compare and set // 困惑!weakCompareAndSet 这个方法最终还是调用 compareAndSet 方法。[版本: jdk-8u191] // 但是注释上写着 \u0026#34;May fail spuriously and does not provide ordering guarantees, // so is only rarely an appropriate alternative to compareAndSet.\u0026#34; // todo 感觉有可能是 jvm 通过方法名在 native 方法里面做了转发 final boolean wCasResult = asr.weakCompareAndSet(initialRef, newReference, initialStamp, newStamp); System.out.println(\u0026#34;currentValue=\u0026#34; + asr.getReference() + \u0026#34;, currentStamp=\u0026#34; + asr.getStamp() + \u0026#34;, wCasResult=\u0026#34; + wCasResult); } } /* 结果 currentValue=0, currentStamp=0 currentValue=666, currentStamp=999, casResult=true currentValue=666, currentStamp=999 currentValue=666, currentStamp=88, attemptStampResult=true currentValue=0, currentStamp=0 currentValue=666, currentStamp=999, wCasResult=true */ AtomicMarkableReference 类使用示例\nimport java.util.concurrent.atomic.AtomicMarkableReference; public class AtomicMarkableReferenceDemo { public static void main(String[] args) { // 实例化、取当前值和 mark 值 final Boolean initialRef = null, initialMark = false; final AtomicMarkableReference\u0026lt;Boolean\u0026gt; amr = new AtomicMarkableReference\u0026lt;\u0026gt;(initialRef, initialMark); System.out.println(\u0026#34;currentValue=\u0026#34; + amr.getReference() + \u0026#34;, currentMark=\u0026#34; + amr.isMarked()); // compare and set final Boolean newReference1 = true, newMark1 = true; final boolean casResult = amr.compareAndSet(initialRef, newReference1, initialMark, newMark1); System.out.println(\u0026#34;currentValue=\u0026#34; + amr.getReference() + \u0026#34;, currentMark=\u0026#34; + amr.isMarked() + \u0026#34;, casResult=\u0026#34; + casResult); // 获取当前的值和当前的 mark 值 boolean[] arr = new boolean[1]; final Boolean currentValue = amr.get(arr); final boolean currentMark = arr[0]; System.out.println(\u0026#34;currentValue=\u0026#34; + currentValue + \u0026#34;, currentMark=\u0026#34; + currentMark); // 单独设置 mark 值 final boolean attemptMarkResult = amr.attemptMark(newReference1, false); System.out.println(\u0026#34;currentValue=\u0026#34; + amr.getReference() + \u0026#34;, currentMark=\u0026#34; + amr.isMarked() + \u0026#34;, attemptMarkResult=\u0026#34; + attemptMarkResult); // 重新设置当前值和 mark 值 amr.set(initialRef, initialMark); System.out.println(\u0026#34;currentValue=\u0026#34; + amr.getReference() + \u0026#34;, currentMark=\u0026#34; + amr.isMarked()); // [不推荐使用,除非搞清楚注释的意思了] weak compare and set // 困惑!weakCompareAndSet 这个方法最终还是调用 compareAndSet 方法。[版本: jdk-8u191] // 但是注释上写着 \u0026#34;May fail spuriously and does not provide ordering guarantees, // so is only rarely an appropriate alternative to compareAndSet.\u0026#34; // todo 感觉有可能是 jvm 通过方法名在 native 方法里面做了转发 final boolean wCasResult = amr.weakCompareAndSet(initialRef, newReference1, initialMark, newMark1); System.out.println(\u0026#34;currentValue=\u0026#34; + amr.getReference() + \u0026#34;, currentMark=\u0026#34; + amr.isMarked() + \u0026#34;, wCasResult=\u0026#34; + wCasResult); } } /* 结果 currentValue=null, currentMark=false currentValue=true, currentMark=true, casResult=true currentValue=true, currentMark=true currentValue=true, currentMark=false, attemptMarkResult=true currentValue=null, currentMark=false currentValue=true, currentMark=true, wCasResult=true */ 对象的属性修改类型原子类 # 对象的属性修改类型原子类,用来原子更新某个类里的某个字段\n包括: AtomicIntegerFieldUpdater 原子更新整型字段的更新器,AtomicLongFieldUpdater 原子更新长整型字段的更新器,AtomicReferenceFieldUpdater 原子更新引用类型里的字段的更新器\n原子地更新对象属性需要两步骤:\n对象的属性修改类型原子类都是抽象类,所以每次使用都必须使用静态方法newUpdater()创建一个更新器,且设置想要更新的类和属性 更新的对象属性必须使用public volatile修饰符 下面以AtomicIntegerFieldUpdater为例子来介绍\nimport java.util.concurrent.atomic.AtomicIntegerFieldUpdater; public class AtomicIntegerFieldUpdaterTest { public static void main(String[] args) { AtomicIntegerFieldUpdater\u0026lt;User\u0026gt; a = AtomicIntegerFieldUpdater.newUpdater(User.class, \u0026#34;age\u0026#34;); User user = new User(\u0026#34;Java\u0026#34;, 22); System.out.println(a.getAndIncrement(user));// 22 System.out.println(a.get(user));// 23 } } class User { private String name; public volatile int age; public User(String name, int age) { super(); this.name = name; this.age = age; } public String getName() { return name; } public void setName(String name) { this.name = name; } public int getAge() { return age; } public void setAge(int age) { this.age = age; } } /* 结果 22 33 */ "},{"id":321,"href":"/zh/docs/technology/Review/java_guide/java/Concurrent/ly0308lyaqs-details/","title":"aqs详解","section":"并发","content":" 转载自https://github.com/Snailclimb/JavaGuide (添加小部分笔记)感谢作者!\nSemaphore [ˈseməfɔː(r)]\n何为 AQS?AQS 原理了解吗? CountDownLatch 和 CyclicBarrier 了解吗?两者的区别是什么? 用过 Semaphore 吗?应用场景了解吗? \u0026hellip;\u0026hellip; AQS简单介绍 # AQS,AbstractQueueSyschronizer,即抽象队列同步器,这个类在java.util.concurrent.locks包下面\nAQS是一个抽象类,主要用来构建锁和同步器\npublic abstract class AbstractQueuedSynchronizer extends AbstractOwnableSynchronizer implements java.io.Serializable { } AQS 为构建锁和同步器提供了一些通用功能的实现,因此,使用 AQS 能简单且高效地构造出应用广泛的大量的同步器,比如我们提到的 ReentrantLock,Semaphore,其他的诸如 ReentrantReadWriteLock,SynchronousQueue,FutureTask(jdk1.7) 等等皆是基于 AQS 的。\nAQS原理 # AQS核心思想 # 面试不是背题,大家一定要加入自己的思想,即使加入不了自己的思想也要保证自己能够通俗的讲出来而不是背出来\nAQS 核心思想是,如果被请求的共享资源(AQS内部)空闲,则将当前请求资源的线程设置为有效的工作线程,并且将共享资源设置为锁定状态。如果被请求的共享资源被占用,那么就需要一套线程阻塞等待以及被唤醒时锁分配的机制,这个机制 AQS 是用 CLH 队列锁实现的,即将暂时获取不到锁的线程加入到队列中。\nCLH(Craig,Landin and Hagersten)队列是一个虚拟的双向队列(虚拟的双向队列即不存在队列实例,仅存在结点之间的关联关系)。AQS 是将每条请求共享资源的线程封装成一个 CLH 锁队列的一个结点(Node)来实现锁的分配。\n[ 搜索了一下,CLH好像是人名 ] 在 CLH 同步队列中,一个节点表示一个线程,它保存着线程的引用(thread)、 当前节点在队列中的状态(waitStatus)、前驱节点(prev)、后继节点(next)。\nCLH队列结构\nAQS(AbstractQueuedSynchronized)原理图\nAQS使用一个int成员变量来表示同步状态,通过内置的线程等待队列来获取资源线程的排队工作。\nstate 变量由 volatile 修饰,用于展示当前临界资源的获锁情况。\nprivate volatile int state;//共享变量,使用volatile修饰保证线程可见性 状态信息的操作\n通过 protected 类型的getState()、setState()和compareAndSetState() 进行操作。并且,这几个方法都是 final 修饰的,在子类中无法被重写。\n//返回同步状态的当前值 protected final int getState() { return state; } //设置同步状态的值 protected final void setState(int newState) { state = newState; } //原子地(CAS操作)将同步状态值设置为给定值update如果当前同步状态的值等于expect(期望值) protected final boolean compareAndSetState(int expect, int update) { return unsafe.compareAndSwapInt(this, stateOffset, expect, update); } 以 ReentrantLock 为例,state 初始值为 0,表示未锁定状态。A 线程 lock() 时,会调用 tryAcquire() 独占该锁并将 state+1 。此后,其他线程再 tryAcquire() 时就会失败,直到 A 线程 unlock() 到 state=0(即释放锁)为止,其它线程才有机会获取该锁。当然,释放锁之前,A 线程自己是可以重复获取此锁的(state 会累加),这就是可重入的概念。但要注意,获取多少次就要释放多少次,这样才能保证 state 是能回到零态的。\n再以 CountDownLatch 以例,任务分为 N 个子线程去执行,state 也初始化为 N(注意 N 要与线程个数一致)。这 N 个子线程是并行执行的,每个子线程执行完后countDown() 一次,state 会 CAS(Compare and Swap) 减 1。等到所有子线程都执行完后(即 state=0 ),会 unpark() 主调用线程,然后主调用线程就会从 await() 函数返回,继续后余动作。\n​\nAQS资源共享方式 # 包括Exclusive(独占,只有一个线程能执行,如ReentrantLock)和Share(共享,多个线程可同时执行,如Semaphore/CountDownLatch)\n从另一个角度讲,就是只有一个线程能操作state变量以及有n个线程能操作state变量的区别\n一般来说,自定义同步器的共享方式要么是独占,要么是共享,他们也只需实现**tryAcquire-tryRelease、tryAcquireShared-tryReleaseShared中的一种即可。但 AQS 也支持自定义同步器同时实现独占和共享两种方式,如ReentrantReadWriteLock**。\n自定义同步器 # 同步器的设计是基于模板方法模式的,如果需要自定义同步器一般的方式是这样(模板方法模式很经典的一个应用):\n使用者继承 AbstractQueuedSynchronizer 并重写指定的方法。【使用者】 将 AQS 组合在自定义同步组件的实现中,并调用其模板方法,而这些模板方法会调用使用者重写的方法。【AQS内部】 这和我们以往通过实现接口的方式有很大区别,这是模板方法模式很经典的一个运用。\n//独占方式。尝试获取资源,成功则返回true,失败则返回false。 protected boolean tryAcquire(int) //独占方式。尝试释放资源,成功则返回true,失败则返回false。 protected boolean tryRelease(int) //共享方式。尝试获取资源。负数表示失败;0表示成功,但没有剩余可用资源;正数表示成功,且有剩余资源。 protected int tryAcquireShared(int) //共享方式。尝试释放资源,成功则返回true,失败则返回false。 protected boolean tryReleaseShared(int) //该线程是否正在独占资源。只有用到condition才需要去实现它。 protected boolean isHeldExclusively() 什么是钩子方法呢? 钩子方法是一种被声明在抽象类中的方法,一般使用 protected 关键字修饰,它可以是空方法(由子类实现),也可以是默认实现的方法。模板设计模式通过钩子方法控制固定步骤的实现。\n篇幅问题,这里就不详细介绍模板方法模式了,不太了解的小伙伴可以看看这篇文章: 用 Java8 改造后的模板方法模式真的是 yyds!open in new window。\n除了上面提到的钩子方法之外,AQS 类中的其他方法都是 final ,所以无法被其他类重写。\n常见同步类 # Semaphore # Semaphore(信号量)可以指定多个线程同时访问某个资源\n/** * * @author Snailclimb * @date 2018年9月30日 * @Description: 需要一次性拿一个许可的情况 */ public class SemaphoreExample1 { // 请求的数量 private static final int threadCount = 550; public static void main(String[] args) throws InterruptedException { // 创建一个具有固定线程数量的线程池对象(如果这里线程池的线程数量给太少的话你会发现执行的很慢) ExecutorService threadPool = Executors.newFixedThreadPool(300); // 一次只能允许执行的线程数量。 final Semaphore semaphore = new Semaphore(20); for (int i = 0; i \u0026lt; threadCount; i++) { final int threadnum = i; threadPool.execute(() -\u0026gt; {// Lambda 表达式的运用 try { //通行证发了20个之后,就不能再发放了 semaphore.acquire();// 获取一个许可,所以可运行线程数量为20/1=20 test(threadnum); semaphore.release();// 释放一个许可 } catch (InterruptedException e) { // TODO Auto-generated catch block e.printStackTrace(); } }); } threadPool.shutdown(); System.out.println(\u0026#34;finish\u0026#34;); } //拿了通行证之后,处理2s钟后才释放 public static void test(int threadnum) throws InterruptedException { Thread.sleep(1000);// 模拟请求的耗时操作 System.out.println(\u0026#34;threadnum:\u0026#34; + threadnum); Thread.sleep(1000);// 模拟请求的耗时操作 } } //另一个例子\npublic static void main(String[] args) throws InterruptedException{ AtomicInteger atomicInteger=new AtomicInteger(); ExecutorService executorService = Executors.newCachedThreadPool(); Semaphore semaphore=new Semaphore(3); for(int i=0;i\u0026lt;8;i++) { int finalI = i; executorService.submit(()-\u0026gt;{ try { semaphore.acquire(); int i1 = atomicInteger.incrementAndGet(); log.info(\u0026#34;获取一个通行证\u0026#34;+ finalI); TimeUnit.SECONDS.sleep(finalI+1); } catch (InterruptedException e) { e.printStackTrace(); }finally { log.info(\u0026#34;通行证\u0026#34;+ finalI +\u0026#34;释放完毕\u0026#34;); semaphore.release(); } }); } log.info(\u0026#34;全部获取完毕\u0026#34;); //这个方法不会导致线程立即结束 executorService.shutdown(); log.info(\u0026#34;线程池shutdown\u0026#34;); } /* 结果 2022-12-01 14:21:31 下午 [Thread: pool-1-thread-3] INFO:获取一个通行证2 2022-12-01 14:21:31 下午 [Thread: main] INFO:全部获取完毕 2022-12-01 14:21:31 下午 [Thread: main] INFO:线程池shutdown 2022-12-01 14:21:31 下午 [Thread: pool-1-thread-2] INFO:获取一个通行证1 2022-12-01 14:21:31 下午 [Thread: pool-1-thread-1] INFO:获取一个通行证0 2022-12-01 14:21:32 下午 [Thread: pool-1-thread-1] INFO:通行证0释放完毕 2022-12-01 14:21:32 下午 [Thread: pool-1-thread-4] INFO:获取一个通行证3 2022-12-01 14:21:33 下午 [Thread: pool-1-thread-2] INFO:通行证1释放完毕 2022-12-01 14:21:33 下午 [Thread: pool-1-thread-5] INFO:获取一个通行证4 2022-12-01 14:21:34 下午 [Thread: pool-1-thread-3] INFO:通行证2释放完毕 2022-12-01 14:21:34 下午 [Thread: pool-1-thread-6] INFO:获取一个通行证5 2022-12-01 14:21:36 下午 [Thread: pool-1-thread-4] INFO:通行证3释放完毕 2022-12-01 14:21:36 下午 [Thread: pool-1-thread-7] INFO:获取一个通行证6 2022-12-01 14:21:38 下午 [Thread: pool-1-thread-5] INFO:通行证4释放完毕 2022-12-01 14:21:38 下午 [Thread: pool-1-thread-8] INFO:获取一个通行证7 2022-12-01 14:21:40 下午 [Thread: pool-1-thread-6] INFO:通行证5释放完毕 2022-12-01 14:21:43 下午 [Thread: pool-1-thread-7] INFO:通行证6释放完毕 2022-12-01 14:21:46 下午 [Thread: pool-1-thread-8] INFO:通行证7释放完毕 Process finished with exit code 0 如上所示,先是获取了210,之后释放一个获取一个(最多获取3个), 3+n*2 =10 ,之后陆续释放0获取3,释放1获取4,释放2获取5 之后 释放3获取6,释放4获取7; 这是还有5,7,6拿着通行证 之后随机将5,7,6释放掉即可。 */ //如上,shutdown不会立即停止,而是:\n线程池shutdown之后不再接收新任务\nsutdown只是将线程池的状态设置为SHUTWDOWN状态,正在执行的任务会继续执行下去,没有被执行的则中断。而shutdownNow则是将线程池的状态设置为STOP,正在执行的任务则被停止,没被执行任务的则返回。如果是shutdownNow,则会报这个问题\njava.lang.InterruptedException: sleep interrupted at java.lang.Thread.sleep(Native Method) at java.lang.Thread.sleep(Thread.java:340) at java.util.concurrent.TimeUnit.sleep(TimeUnit.java:386) at com.ly.SemaphoreExample2.lambda$main$0(SemaphoreExample2.java:45) at java.util.concurrent.Executors$RunnableAdapter.call(Executors.java:511) at java.util.concurrent.FutureTask.run(FutureTask.java:266) at java.util.concurrent.ThreadPoolExecutor.runWorker(ThreadPoolExecutor.java:1149) at java.util.concurrent.ThreadPoolExecutor$Worker.run(ThreadPoolExecutor.java:624) at java.lang.Thread.run(Thread.java:748) 解释最上面的例子:\n执行acquire()方法会导致阻塞,直到有一个许可证可以获得然后拿走一个许可证 每个release()方法增加一个许可证,这**可能会释放一个阻塞的acquire()**方法 Semaphore只是维持了一个可以获得许可证的数量,没有实际的许可证这个对象 Semaphore经常用于限制获取某种资源的线程数量 可以一次性获取或释放多个许可,不过没必要\nsemaphore.acquire(5);// 获取5个许可,所以可运行线程数量为20/5=4 test(threadnum); semaphore.release(5);// 释放5个许可 除了 acquire() 方法之外,另一个比较常用的与之对应的方法是 tryAcquire() 方法,该方法如果获取不到许可就立即返回 false\n介绍\nsynchronized 和 ReentrantLock 都是一次只允许一个线程访问某个资源,而Semaphore(信号量)可以用来控制同时访问特定资源的线程数量。\nSemaphore 的使用简单,我们这里假设有 N(N\u0026gt;5) 个线程来获取 Semaphore 中的共享资源,下面的代码表示同一时刻 N 个线程中只有 5 个线程能获取到共享资源,其他线程都会阻塞,只有获取到共享资源的线程才能执行。等到有线程释放了共享资源,其他阻塞的线程才能获取到。\n// 初始共享资源数量 final Semaphore semaphore = new Semaphore(5); // 获取1个许可 semaphore.acquire(); // 释放1个许可 semaphore.release(); /* 当初始的资源个数为 1 的时候,Semaphore 退化为排他锁。 */ Semaphore有两种模式,公平模式和非公平模式\n公平模式:调用acquire()方法的顺序,就是获取许可证的顺序,遵循FIFO 非公平模式:抢占式的 两个构造函数,必须提供许可数量,第二个构造方法可以指定是公平模式还是非公平模式,默认非公平模式\npublic Semaphore(int permits) { sync = new NonfairSync(permits); } public Semaphore(int permits, boolean fair) { sync = fair ? new FairSync(permits) : new NonfairSync(permits); } Semaphore 通常用于那些资源有明确访问数量限制的场景比如限流(仅限于单机模式,实际项目中推荐使用 Redis +Lua 来做限流)\n原理\nSemaphore 是共享锁的一种实现,它默认构造 AQS 的 state 值为 permits,你可以将 permits 的值理解为许可证的数量,只有拿到许可证的线程才能执行。\n调用semaphore.acquire() ,线程尝试获取许可证,如果 state \u0026gt;= 0 的话,则表示可以获取成功。如果获取成功的话,使用 CAS 操作去修改 state 的值 state=state-1。如果 state\u0026lt;0 的话,则表示许可证数量不足。此时会创建一个 Node 节点加入阻塞队列,挂起当前线程。\n/** * 获取1个许可证 */ public void acquire() throws InterruptedException { sync.acquireSharedInterruptibly(1); } /** * 共享模式下获取许可证,获取成功则返回,失败则加入阻塞队列,挂起线程 */ public final void acquireSharedInterruptibly(int arg) throws InterruptedException { if (Thread.interrupted()) throw new InterruptedException(); // 尝试获取许可证,arg为获取许可证个数,当可用许可证数减当前获取的许可证数结果小于0,则创建一个节点加入阻塞队列,挂起当前线程。 if (tryAcquireShared(arg) \u0026lt; 0) doAcquireSharedInterruptibly(arg); } 调用semaphore.release(); ,线程尝试释放许可证,并使用 CAS 操作去修改 state 的值 state=state+1。释放许可证成功之后,同时会唤醒同步队列中的一个线程。被唤醒的线程会重新尝试去修改 state 的值 state=state-1 ,如果 state\u0026gt;=0 则获取令牌成功,否则重新进入阻塞队列,挂起线程。\n// 释放一个许可证 public void release() { sync.releaseShared(1); } // 释放共享锁,同时会唤醒同步队列中的一个线程。 public final boolean releaseShared(int arg) { //释放共享锁 if (tryReleaseShared(arg)) { //唤醒同步队列中的一个线程 doReleaseShared(); return true; } return false; } 补充\nSemaphore 与 CountDownLatch 一样,也是共享锁的一种实现。它默认构造 AQS 的 state 为 permits。当执行任务的线程数量超出 permits,那么多余的线程将会被放入阻塞队列 Park,并自旋判断 state 是否大于 0。只有当 state 大于 0 的时候,阻塞的线程才能继续执行,此时先前执行任务的线程继续执行 release() 方法,release() 方法使得 state 的变量会加 1,那么自旋的线程便会判断成功。 如此,每次只有最多不超过 permits 数量的线程能自旋成功,便限制了执行任务线程的数量。\nCountDownLatch(倒计时) # CountDown 倒计时器;Latch 门闩 允许count个线程阻塞在一个地方,直至所有线程的任务都执行完毕 CountDownLatch 是一次性的,计数器的值只能在构造方法中初始化一次,之后没有任何机制再次对其设置值,当 CountDownLatch 使用完毕后,它不能再次被使用。 原理 CountDownLatch是共享锁的一种实现(我的理解是 本质上是说AQS内部的state变量可以被多个线程同时修改,所以是\u0026quot;共享\u0026quot;),默认构造AQS的state值为count。当线程使用countDown()方法时,其实是使用了tryReleaseShared方法以CAS操作来减少state,直至state为0 当调用await()方法时,如果state不为0,那就证明任务还没有执行完毕,await()方法会一直阻塞,即await()方法之后的语句不会被执行。之后CountDownLatch会自旋CAS判断state==0,如果state == 0就会释放所有等待线程,await()方法之后的语句得到执行 CountDownLatch的两种典型用法 # 其实就是n个线程等待其他m个线程执行完毕后唤醒,只有n为1时是第一种情况,只有m为1时是第二种情况\n某线程在开始运行前等待n个线程执行完毕\n将 CountDownLatch 的计数器初始化为 n (new CountDownLatch(n)),每当一个任务线程执行完毕,就将计数器减 1 (countdownlatch.countDown()),当计数器的值变为 0 时,在 CountDownLatch 上 await() 的线程就会被唤醒。一个典型应用场景就是启动一个服务时,主线程需要等待多个组件加载完毕,之后再继续执行。\n实现多个线程开始执行任务的最大并行性\n注意是并行性,不是并发,强调的是多个线程在某一时刻同时开始执行。类似于赛跑,将多个线程放到起点,等待发令枪响,然后同时开跑。\n做法是初始化一个共享的 CountDownLatch 对象,将其计数器初始化为 1 (new CountDownLatch(1)),多个线程在开始执行任务前首先 coundownlatch.await(),当主线程调用 countDown() 时,计数器变为 0,多个线程同时被唤醒。\nCountDownLatch使用示例\n300个线程(说的是线程池有300个核心线程,而不是CountDown300次),550个请求(及count = 550)。启动线程后,主线程阻塞。当所有请求都countDown,主线程恢复运行\n/** * * @author SnailClimb * @date 2018年10月1日 * @Description: CountDownLatch 使用方法示例 */ public class CountDownLatchExample1 { // 请求的数量 private static final int threadCount = 550; public static void main(String[] args) throws InterruptedException { // 创建一个具有固定线程数量的线程池对象(如果这里线程池的线程数量给太少的话你会发现执行的很慢) ExecutorService threadPool = Executors.newFixedThreadPool(300); final CountDownLatch countDownLatch = new CountDownLatch(threadCount); for (int i = 0; i \u0026lt; threadCount; i++) { final int threadnum = i; threadPool.execute(() -\u0026gt; {// Lambda 表达式的运用 try { test(threadnum); } catch (InterruptedException e) { // TODO Auto-generated catch block e.printStackTrace(); } finally { countDownLatch.countDown();// 表示一个请求已经被完成 } }); } countDownLatch.await(); threadPool.shutdown(); System.out.println(\u0026#34;finish\u0026#34;); } public static void test(int threadnum) throws InterruptedException { Thread.sleep(1000);// 模拟请求的耗时操作 System.out.println(\u0026#34;threadnum:\u0026#34; + threadnum); Thread.sleep(1000);// 模拟请求的耗时操作 } } 与CountDownLatch的第一次交互是主线程等待其他线程\n主线程必须在启动其他线程后立即调用CountDownLatch.await()方法,这样主线程的操作就会在这个方法阻塞,直到其他线程完成各自任务\n其他 N 个线程必须引用闭锁对象(说的是CountDownLoatch对象),因为他们需要通知 CountDownLatch 对象,他们已经完成了各自的任务。这种通知机制是通过 CountDownLatch.countDown()方法来完成的;每调用一次这个方法,在构造函数中初始化的 count 值就减 1。所以当 N 个线程都调 用了这个方法,count 的值等于 0,然后主线程就能通过 await()方法,恢复执行自己的任务。\nCountDownLatch 的 await() 方法使用不当很容易产生死锁,比如我们上面代码中的 for 循环改为:\nfor (int i = 0; i \u0026lt; threadCount-1; i++) { ....... } //这样就导致 count 的值没办法等于 0(最终为1),然后就会导致一直等待。 CountDownLatch 的不足 # CountDownLatch 是一次性的,计数器的值只能在构造方法中初始化一次,之后没有任何机制再次对其设置值,当 CountDownLatch 使用完毕后,它不能再次被使用。\nCountDownLatch 相常见面试题(改版后没了) # CountDownLatch 怎么用?应用场景是什么? CountDownLatch 和 CyclicBarrier 的不同之处? CountDownLatch 类中主要的方法? CyclicBarrier # CyclicBarrier和CountDownLatch类似,可以实现线程间的技术等待,主要应用场景和CountDownLatch类似,但更复杂强大 主要应用场景和 CountDownLatch 类似。\nCountDownLatch基于AQS,而CycliBarrier基于ReentrantLock(ReentrantLock属于AQS同步器)和Condition\nCyclicBarrier 的字面意思是可循环使用(Cyclic)的屏障(Barrier)。它要做的事情是:让一组线程(中的一个)到达一个屏障(也可以叫同步点)时被阻塞,直到最后一个线程到达屏障时,屏障才会开门,所有被屏障拦截的线程才会继续干活。\n原理 # CyclicBarrier 内部通过一个 count 变量作为计数器,count 的初始值为 parties 属性的初始化值,每当一个线程到了栅栏这里了,那么就将计数器减 1。如果 count 值为 0 了,表示这是这一代最后一个线程到达栅栏,就尝试执行我们构造方法中输入的任务(之后再释放所有阻塞的线程)。\n//每次拦截的线程数 private final int parties; //计数器 private int count; CyclicBarrier 默认的构造方法是 CyclicBarrier(int parties),其参数表示屏障拦截的线程数量,每个线程调用 await() 方法告诉 CyclicBarrier 我已经到达了屏障,然后当前线程被阻塞。\npublic CyclicBarrier(int parties) { this(parties, null); } public CyclicBarrier(int parties, Runnable barrierAction) { if (parties \u0026lt;= 0) throw new IllegalArgumentException(); this.parties = parties; this.count = parties; this.barrierCommand = barrierAction; } 先看一个例子\n/** * * @author Snailclimb * @date 2018年10月1日 * @Description: 测试 CyclicBarrier 类中带参数的 await() 方法 */ public class CyclicBarrierExample2 { // 请求的数量 private static final int threadCount = 550; // 需要同步的线程数量 private static final CyclicBarrier cyclicBarrier = new CyclicBarrier(5, () -\u0026gt; { System.out.println(\u0026#34;------当线程数达到之后,优先执行------\u0026#34;); }); public static void main(String[] args) throws InterruptedException { // 创建线程池 ExecutorService threadPool = Executors.newFixedThreadPool(10); for (int i = 0; i \u0026lt; threadCount; i++) { final int threadNum = i; Thread.sleep(1000); ///注意这行 threadPool.execute(() -\u0026gt; { try { test(threadNum); } catch (InterruptedException e) { // TODO Auto-generated catch block e.printStackTrace(); } catch (BrokenBarrierException e) { // TODO Auto-generated catch block e.printStackTrace(); } }); } threadPool.shutdown(); } public static void test(int threadnum) throws InterruptedException, BrokenBarrierException { System.out.println(\u0026#34;threadnum:\u0026#34; + threadnum + \u0026#34;is ready\u0026#34;); try { /**等待60秒,保证子线程完全执行结束*/ //如果等待的时间,超过了60秒,那么就会抛出异常,而且还会进行重置(变为0个线程再等待) cyclicBarrier.await(60, TimeUnit.SECONDS); //最后一个(第5个到达后,count会重置为0) } catch (Exception e) { System.out.println(\u0026#34;-----CyclicBarrierException------\u0026#34;); } System.out.println(\u0026#34;threadnum:\u0026#34; + threadnum + \u0026#34;is finish\u0026#34;); } } /* 结果 threadnum:0is ready threadnum:1is ready threadnum:2is ready threadnum:3is ready threadnum:4is ready threadnum:4is finish threadnum:0is finish threadnum:1is finish threadnum:2is finish threadnum:3is finish threadnum:5is ready threadnum:6is ready threadnum:7is ready threadnum:8is ready threadnum:9is ready threadnum:9is finish threadnum:5is finish threadnum:8is finish threadnum:7is finish threadnum:6is finish ...... */ /* 1.可以看到当线程数量也就是请求数量达到我们定义的 5 个的时候, await() 方法之后的方法才被执行。 2.另外,CyclicBarrier 还提供一个更高级的构造函数 CyclicBarrier(int parties, Runnable barrierAction),用于在线程到达屏障时,优先执行 barrierAction,方便处理更复杂的业务场景。 */ //注意这里,如果把Thread.sleep(1000)去掉,顺序(情况之一)为: //也就是说,上面的代码,导致的现象:所有的ready都挤在一起了(而且不分先后,随时执行,而某5个的finish,会等待那5个的ready执行完才会执行,且finish没有顺序的) //★如上,ready也是没有顺序的 /*threadnum:0is ready threadnum:5is ready threadnum:9is ready threadnum:7is ready threadnum:3is ready threadnum:8is ready threadnum:4is ready threadnum:2is ready threadnum:1is ready threadnum:6is ready ------当线程数达到之后,优先执行------ 当ready数量为5的倍数时(栅栏是5个,就会执行这个) threadnum:3is finish threadnum:10is ready ------当线程数达到之后,优先执行------ threadnum:10is finish threadnum:11is ready threadnum:0is finish threadnum:5is finish threadnum:4is finish threadnum:1is finish threadnum:8is finish threadnum:12is ready threadnum:9is finish threadnum:7is finish threadnum:16is ready threadnum:15is ready ------当线程数达到之后,优先执行------ threadnum:14is ready threadnum:6is finish threadnum:13is ready threadnum:2is finish threadnum:19is ready threadnum:16is finish threadnum:12is finish threadnum:18is ready threadnum:11is finish threadnum:23is ready ------当线程数达到之后,优先执行------ threadnum:17is ready threadnum:19is finish threadnum:15is finish threadnum:25is ready threadnum:24is ready threadnum:18is finish threadnum:26is ready threadnum:13is finish threadnum:14is finish threadnum:23is finish threadnum:22is ready threadnum:21is ready threadnum:20is ready ------当线程数达到之后,优先执行------ threadnum:29is ready threadnum:28is ready threadnum:27is ready threadnum:22is finish threadnum:24is finish threadnum:25is finish threadnum:32is ready ..... */ 在看一个例子:\npublic class BarrierTest1 { public static void main(String[] args) throws InterruptedException, TimeoutException, BrokenBarrierException { CyclicBarrier cyclicBarrier = new CyclicBarrier(3); ExecutorService executorService = Executors.newFixedThreadPool(10); executorService.submit(() -\u0026gt; { try { TimeUnit.SECONDS.sleep(1); } catch (InterruptedException e) { e.printStackTrace(); } try { cyclicBarrier.await( ); System.out.println(\u0026#34;数量11====\u0026#34;+cyclicBarrier.getNumberWaiting()); System.out.println(\u0026#34;111\u0026#34;); } catch (Exception e) { System.out.println(\u0026#34;数量异常1111===\u0026#34;+cyclicBarrier.getNumberWaiting()); // e.printStackTrace(); System.out.println(\u0026#34;报错1\u0026#34;); } }); executorService.submit(() -\u0026gt; { try { TimeUnit.SECONDS.sleep(3); } catch (InterruptedException e) { e.printStackTrace(); } try { System.out.println(\u0026#34;数量2222====\u0026#34;+cyclicBarrier.getNumberWaiting()); cyclicBarrier.await(111,TimeUnit.SECONDS); System.out.println(\u0026#34;222\u0026#34;); } catch (Exception e) { System.out.println(\u0026#34;数量异常2222====\u0026#34;+cyclicBarrier.getNumberWaiting()); System.out.println(\u0026#34;报错2\u0026#34;); } }); executorService.submit(() -\u0026gt; { try { TimeUnit.SECONDS.sleep(10); } catch (InterruptedException e) { e.printStackTrace(); } try { System.out.println(\u0026#34;数量33 await前====\u0026#34;+cyclicBarrier.getNumberWaiting()); cyclicBarrier.await(); System.out.println(\u0026#34;数量33 await后====\u0026#34;+cyclicBarrier.getNumberWaiting()); System.out.println(\u0026#34;333\u0026#34;); } catch (Exception e) { System.out.println(\u0026#34;数量异常333====\u0026#34;+cyclicBarrier.getNumberWaiting()); System.out.println(\u0026#34;报错3\u0026#34;); } }); } } /* 数量2222====1 数量33 await前====2 (第1、2个处于wait状态) 数量33 await后====0 (得到栅栏数量3,wait线程数重置为0) 333 数量11====0 (此时第1、2个线程都会释放,且数量重置为0) 111 222 */ CyclicBarrier源码分析 # 当调用CyclicBarrier对象调用await() 方法时,实际上调用的是dowait(false,0L )方法【主要用到false】\nawait() 方法就像树立起一个栅栏的行为一样,将线程挡住了,当拦住的线程数量达到 parties 的值时,栅栏才会打开,线程才得以通过执行。\npublic int await() throws InterruptedException, BrokenBarrierException { try { return dowait(false, 0L); } catch (TimeoutException toe) { throw new Error(toe); // cannot happen } } dowait(false,0L)方法\n// 当线程数量或者请求数量达到 count 时 await 之后的方法才会被执行。上面的示例中 count 的值就为 5。 private int count; /** * Main barrier code, covering the various policies. */ private int dowait(boolean timed, long nanos) throws InterruptedException, BrokenBarrierException, TimeoutException { final ReentrantLock lock = this.lock; // 锁住 lock.lock(); try { final Generation g = generation; if (g.broken) throw new BrokenBarrierException(); // 如果线程中断了,抛出异常 if (Thread.interrupted()) { breakBarrier(); throw new InterruptedException(); } // cout减1 //★前面锁住了,所以不需要CAS int index = --count; //★★ 当 count 数量减为 0 之后说明最后一个线程已经到达栅栏了,也就是达到了可以执行await 方法之后的条件 if (index == 0) { // tripped boolean ranAction = false; try { final Runnable command = barrierCommand; if (command != null) command.run(); ranAction = true; // 将 count 重置为 parties 属性的初始化值 // 唤醒之前等待的线程 // 下一波执行开始 nextGeneration(); return 0; } finally { if (!ranAction) breakBarrier(); } } // loop until tripped, broken, interrupted, or timed out for (;;) { try { if (!timed) trip.await(); else if (nanos \u0026gt; 0L) nanos = trip.awaitNanos(nanos); } catch (InterruptedException ie) { if (g == generation \u0026amp;\u0026amp; ! g.broken) { breakBarrier(); throw ie; } else { // We\u0026#39;re about to finish waiting even if we had not // been interrupted, so this interrupt is deemed to // \u0026#34;belong\u0026#34; to subsequent execution. Thread.currentThread().interrupt(); } } if (g.broken) throw new BrokenBarrierException(); if (g != generation) return index; if (timed \u0026amp;\u0026amp; nanos \u0026lt;= 0L) { breakBarrier(); throw new TimeoutException(); } } } finally { lock.unlock(); } } 总结:CyclicBarrier 内部通过一个 count 变量作为计数器,count 的初始值为 parties 属性的初始化值,每当一个线程到了栅栏这里了,那么就将计数器减一。如果 count 值为 0 了,表示这是这一代最后一个线程到达栅栏,就尝试执行我们构造方法中输入的任务\nCyclicBarrier和CountDownLatch区别 # CountDownLatch 是计数器,只能使用一次,而 CyclicBarrier 的计数器提供 reset 功能,可以多次使用。\n从jdk作者设计的目的来看,javadoc是这么描述他们的\nCountDownLatch: A synchronization aid that allows one or more threads to wait until a set of operations being performed in other threads completes.(CountDownLatch: 一个或者多个线程,等待其他多个线程完成某件事情之后才能执行;) CyclicBarrier : A synchronization aid that allows a set of threads to all wait for each other to reach a common barrier point.(CyclicBarrier : 多个线程互相等待,直到到达同一个同步点,再继续一起执行。)\n需要结合上面的代码示例,CyclicBarrier示例是这个意思\n对于 CountDownLatch 来说,重点是“一个线程(多个线程)等待”,而其他的 N 个线程在完成“某件事情”之后,可以终止,也可以等待。【强调的是某个(组)等另一组线程完成】\n而对于 CyclicBarrier,重点是多个线程,在任意一个线程没有完成,所有的线程都必须等待。【强调的是互相】\nCountDownLatch 是计数器,线程完成一个记录一个,只不过计数不是递增而是递减,而 CyclicBarrier 更像是一个阀门,需要所有线程都到达,阀门才能打开,然后继续执行。\nReentrantLock和ReentrantReadWriteLock # 读写锁 ReentrantReadWriteLock 可以保证多个线程可以同时读,所以在读操作远大于写操作的时候,读写锁就非常有用了。\n"},{"id":322,"href":"/zh/docs/technology/Review/java_guide/java/Concurrent/ly0307lyconcurrent-collections/","title":"java常见并发容器","section":"并发","content":" 转载自https://github.com/Snailclimb/JavaGuide (添加小部分笔记)感谢作者!\nJDK提供的容器,大部分在java.util.concurrent包中\nConcurrentHashMap:线程安全的HashMap CopyOnWriteArrayList:线程安全的List,在读多写少的场合性能非常好,远好于Vector ConcurrentLinkedQueue:高效的并发队列,使用链表实现,可以看作一个线程安全的LinkedList,是一个非阻塞队列 BlockingQueue:这是一个接口,JDK内部通过链表、数组等方式实现了该接口。表示阻塞队列,非常适合用于作为数据共享的通道 ConcorrentSkipListMap:跳表的实现,是一个Map,使用跳表的数据结构进行快速查找 ConcurrentHashMap # HashMap是线程不安全的,并发场景下要保证线程安全,可以使用Collections.synchronizedMap()方法来包装HashMap,但这是通过使用一个全局的锁来同步不同线程间的并发访问,因此会带来性能问题 建议使用ConcurrentHashMap,不论是读操作还是写操作都能保证高性能:读操作(几乎)不需要加锁,而写操作时通过锁分段(这里说的是JDK1.7?)技术,只对所操作的段加锁而不影响客户端对其他段的访问 CopyOnWriteArrayList # //源码 public class CopyOnWriteArrayList\u0026lt;E\u0026gt; extends Object implements List\u0026lt;E\u0026gt;, RandomAccess, Cloneable, Serializable 在很多应用场景中,读操作可能会远远大于写操作 我们应该允许多个线程同时访问List内部数据(针对读) 与ReentrantReadWriteLock读写锁思想非常类似,即读读共享、写写互斥、读写互斥、写读互斥 不一样的是,CopyOnWriteArrayList读取时完全不需要加锁,且写入也不会阻塞读取操作,只有写入和写入之间需要同步等待。 CopyOnWriteArrayList是如何做到的 # CopyOnWriteArrayList 类的所有可变操作(add,set 等等)都是通过创建底层数组的新副本来实现的。当 List 需要被修改的时候,并不修改原有内容,而是对原有数据进行一次复制,将修改的内容写入副本。写完之后,再将修改完的副本替换原来的数据,这样就可以保证写操作不会影响读操作了。 从 CopyOnWriteArrayList 的名字就能看出 CopyOnWriteArrayList 是满足 CopyOnWrite 的 在计算机,如果你想要对一块内存进行修改时,我们不在原有内存块中进行写操作,而是将内存拷贝一份,在新的内存中进行写操作,写完之后呢,就将指向原来内存指针指向新的内存(注意,是指向,而不是重新拷贝★重要★),原来的内存就可以被回收掉了 CopyOnWriteArrayList 读取和写入源码简单分析 # CopyOnWriteArrayList读取操作的实现 读取操作没有任何同步控制和锁操作,理由就是内部数组array不会发生修改,只会被另一个array替换,因此可以保证数据安全\n/** The array, accessed only via getArray/setArray. */ private transient volatile Object[] array; public E get(int index) { return get(getArray(), index); } @SuppressWarnings(\u0026#34;unchecked\u0026#34;) private E get(Object[] a, int index) { return (E) a[index]; } final Object[] getArray() { return array; } CopyOnWriteArrayList写入操作的实现 在添加集合的时候加了锁,保证同步,避免多线程写的时候会copy出多个副本\n/** * Appends the specified element to the end of this list. * * @param e element to be appended to this list * @return {@code true} (as specified by {@link Collection#add}) */ 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();//释放锁 } } ConcurrentLinkedQueue # Java提供的线程安全的Queue分为阻塞队列和非阻塞队列 阻塞队列的典型例子是BlockingQueue,非阻塞队列的典型例子是ConcurrentLinkedQueue 阻塞队列通过锁来实现,非阻塞队列通过CAS实现 ConcurrentLinkedQueue使用链表作为数据结构,是高并发环境中性能最好的队列 ConcurrentLinkedQueue 适合在对性能要求相对较高,同时对队列的读写存在多个线程同时进行的场景,即如果对队列加锁的成本较高则适合使用无锁的 ConcurrentLinkedQueue,即CAS 来替代 BlockingQueue # 阻塞队列(BlockingQueue)被广泛使用在“生产者-消费者”问题中,其原因是 BlockingQueue 提供了可阻塞的插入和移除的方法。当队列容器已满,生产者线程会被阻塞,直到队列未满;当队列容器为空时,消费者线程会被阻塞,直至队列非空时为止\nBlockingQueue是一个接口,继承自Queue,而Queue又继承自Collection接口,下面是BlockingQueue的相关实现类:\n代码例子(主要是**put()和take()**两个方法):\npublic class TestBlockingQueue { public static void main(String[] args) { BlockingQueue\u0026lt;String\u0026gt; blockingQueue = new ArrayBlockingQueue\u0026lt;\u0026gt;(2); for (int i = 10; i \u0026lt; 20; i++) { int finalI = i; new Thread(() -\u0026gt; { try { TimeUnit.SECONDS.sleep(finalI); } catch (InterruptedException e) { e.printStackTrace(); } try { blockingQueue.put(finalI + \u0026#34;\u0026#34;); } catch (InterruptedException e) { e.printStackTrace(); } System.out.println(Thread.currentThread() .getName() + \u0026#34;放入了元素[\u0026#34; + finalI + \u0026#34;\u0026#34;); }, \u0026#34;线程\u0026#34; + i).start(); } for (int i = 20; i \u0026lt; 30; i++) { int finalI = i; new Thread(() -\u0026gt; { try { TimeUnit.SECONDS.sleep(finalI); } catch (InterruptedException e) { e.printStackTrace(); } String remove = null; try { remove = blockingQueue.take(); } catch (InterruptedException e) { e.printStackTrace(); } System.out.println(Thread.currentThread() .getName() + \u0026#34;取出了元素[\u0026#34; + remove + \u0026#34;\u0026#34;); }, \u0026#34;线程\u0026#34; + i).start(); } } } /* 由下可以知道,放入了两个元素之后,需要等待取出后,才能继续放入 线程10放入了元素[10 线程11放入了元素[11 ----\u0026gt; 之后这里发生了停顿 线程20取出了元素[10 线程12放入了元素[12 线程21取出了元素[11 线程13放入了元素[13 线程22取出了元素[12 线程14放入了元素[14 线程23取出了元素[13 线程15放入了元素[15 线程24取出了元素[14 线程16放入了元素[16 线程25取出了元素[15 线程17放入了元素[17 线程26取出了元素[16 线程18放入了元素[18 线程27取出了元素[17 线程19放入了元素[19 线程28取出了元素[18 线程29取出了元素[19 Process finished with exit code 0 */ ArrayBockingQueue # ArrayBlockingQueue是BlockingQueue接口的有界队列实现类,底层采用数组来实现\npublic class ArrayBlockingQueue\u0026lt;E\u0026gt; extends AbstractQueue\u0026lt;E\u0026gt; implements BlockingQueue\u0026lt;E\u0026gt;, Serializable{} ArrayBlockingQueue 一旦创建,容量不能改变。其并发控制采用可重入锁 ReentrantLock ,不管是插入操作还是读取操作,都需要获取到锁才能进行操作。当队列容量满时,尝试将元素放入队列将导致操作阻塞;尝试从一个空队列中取一个元素也会同样阻塞。\nArrayBlockingQueue 默认情况下不能保证线程访问队列的公平性,所谓公平性是指严格按照线程等待的绝对时间顺序,即最先等待的线程能够最先访问到 ArrayBlockingQueue。而非公平性则是指访问 ArrayBlockingQueue 的顺序不是遵守严格的时间顺序,有可能存在,当 ArrayBlockingQueue 可以被访问时,长时间阻塞的线程依然无法访问到 ArrayBlockingQueue。如果保证公平性,通常会降低吞吐量。如果需要获得公平性的 ArrayBlockingQueue,可采用如下代码(主要是第二个参数)\nprivate static ArrayBlockingQueue\u0026lt;Integer\u0026gt; blockingQueue = new ArrayBlockingQueue\u0026lt;Integer\u0026gt;(10,true); LinkedBlockingQueue # 底层基于单向链表实现阻塞队列,可以当作无界队列也可以当作有界队列\n满足FIFO特性,与ArrayBlockingQueue相比有更高吞吐量,为防止LinkedBlockingQueue容量迅速增加,损耗大量内存,一般创建LinkedBlockingQueue对象时会指定大小****;如果未指定则容量等于Integer.MAX_VALUE\n相关构造方法\n/** *某种意义上的无界队列 * Creates a {@code LinkedBlockingQueue} with a capacity of * {@link Integer#MAX_VALUE}. */ public LinkedBlockingQueue() { this(Integer.MAX_VALUE); } /** *有界队列 * Creates a {@code LinkedBlockingQueue} with the given (fixed) capacity. * * @param capacity the capacity of this queue * @throws IllegalArgumentException if {@code capacity} is not greater * than zero */ public LinkedBlockingQueue(int capacity) { if (capacity \u0026lt;= 0) throw new IllegalArgumentException(); this.capacity = capacity; last = head = new Node\u0026lt;E\u0026gt;(null); } PriorityBlockingQueue # 支持优先级的无界阻塞队列,默认情况元素采用自然顺序进行排序,或通过自定义类实现compareTo()方法指定元素排序,或初始化时通过构造器参数Comparator来指定排序规则 PriorityBlockingQueue 并发控制采用的是可重入锁 ReentrantLock,队列为无界队列(ArrayBlockingQueue 是有界队列,LinkedBlockingQueue 也可以通过在构造函数中传入 capacity 指定队列最大的容量,但是 PriorityBlockingQueue 只能指定初始的队列大小,后面插入元素的时候,如果空间不够的话会自动扩容) 它就是 PriorityQueue 的线程安全版本。不可以插入 null 值,同时,插入队列的对象必须是可比较大小的(comparable),否则报 ClassCastException 异常。它的插入操作 put 方法不会 block(是block 阻塞,不是lock 锁),因为它是无界队列(take 方法在队列为空的时候会阻塞) ConcurrentSkipListMap # 对于一个单链表,即使链表是有序的,如果我们想要在其中查找某个数据,也只能从头到尾遍历链表,这样效率自然就会很低,跳表就不一样了。跳表是一种可以用来快速查找的数据结构,有点类似于平衡树。它们都可以对元素进行快速的查找。但一个重要的区别是:对平衡树的插入和删除往往很可能导致平衡树进行一次全局的调整。而对跳表的插入和删除只需要对整个数据结构的局部进行操作即可。这样带来的好处是:在高并发的情况下,你会需要一个全局锁来保证整个平衡树的线程安全。而对于跳表,你只需要部分锁即可。这样,在高并发环境下,你就可以拥有更好的性能。而就查询的性能而言,跳表的时间复杂度也是 O(logn) 所以在并发数据结构中,JDK 使用跳表来实现一个 Map。\n跳表的本质是维护多个链表,且链表是分层的 最低层的链表维护跳表内所有元素,每上面一层链表都是下面一层的子集 跳表内所有链表的元素都是排序的 查找时,可以从顶级链表开始找。一旦发现被查找的元素大于当前链表中的取值(这里应该加上一句,小于前一个节点,比如下面如果是查找3,那么就从1跳下去),就会转入下一层链表继续找。这也就是说在查找过程中,搜索是跳跃式的。如上图所示,在跳表中查找元素 18。 查找 18 的时候原来需要遍历 18 次,现在只需要 7 次即可。针对链表长度比较大的时候,构建索引查找效率的提升就会非常明显 (这里好像不太对,原来也不需要遍历18次,反正大概率是说效率高就是了)\n使用跳表实现 Map 和使用哈希算法实现 Map 的另外一个不同之处是:哈希并不会保存元素的顺序,而跳表内所有的元素都是排序的。因此在对跳表进行遍历时,你会得到一个有序的结果。所以,如果你的应用需要有序性,那么跳表就是你不二的选择。JDK 中实现这一数据结构的类是 ConcurrentSkipListMap。\n"},{"id":323,"href":"/zh/docs/technology/Review/java_guide/java/Concurrent/ly0306lythread-pool-best/","title":"线程池最佳实践","section":"并发","content":" 转载自https://github.com/Snailclimb/JavaGuide (添加小部分笔记)感谢作者!\n线程池知识回顾 # 1. 为什么要使用线程池 # 池化技术的思想,主要是为了减少每次获取资源(线程资源)的消耗,提高对资源的利用率 线程池提供了一种限制和管理资源(包括执行一个任务)的方法,每个线程池还维护一些基本统计信息,例如已完成任务的数量 好处:\n降低资源消耗 提高响应速度 提高线程的可管理性 2. 线程池在实际项目的使用场景 # 线程池一般用于执行多个不相关联的耗时任务,没有多线程的情况下,任务顺序执行,使用了线程池的话可让多个不相关联的任务同时执行。\n3. 如何使用线程池 # 一般是通过 ThreadPoolExecutor 的构造函数来创建线程池,然后提交任务给线程池执行就可以了。构造函数如下:\n/** * 用给定的初始参数创建一个新的ThreadPoolExecutor。 */ public ThreadPoolExecutor(int corePoolSize,//线程池的核心线程数量 int maximumPoolSize,//线程池的最大线程数 long keepAliveTime,//当线程数大于核心线程数时,多余的空闲线程存活的最长时间 TimeUnit unit,//时间单位 BlockingQueue\u0026lt;Runnable\u0026gt; workQueue,//任务队列,用来储存等待执行任务的队列 ThreadFactory threadFactory,//线程工厂,用来创建线程,一般默认即可 RejectedExecutionHandler handler//拒绝策略,当提交的任务过多而不能及时处理时,我们可以定制策略来处理任务 ) { if (corePoolSize \u0026lt; 0 || maximumPoolSize \u0026lt;= 0 || maximumPoolSize \u0026lt; corePoolSize || keepAliveTime \u0026lt; 0) throw new IllegalArgumentException(); if (workQueue == null || threadFactory == null || handler == null) throw new NullPointerException(); this.corePoolSize = corePoolSize; this.maximumPoolSize = maximumPoolSize; this.workQueue = workQueue; this.keepAliveTime = unit.toNanos(keepAliveTime); this.threadFactory = threadFactory; this.handler = handler; } 使用代码:\nprivate static final int CORE_POOL_SIZE = 5; private static final int MAX_POOL_SIZE = 10; private static final int QUEUE_CAPACITY = 100; private static final Long KEEP_ALIVE_TIME = 1L; public static void main(String[] args) { //使用阿里巴巴推荐的创建线程池的方式 //通过ThreadPoolExecutor构造函数自定义参数创建 ThreadPoolExecutor executor = new ThreadPoolExecutor( CORE_POOL_SIZE, MAX_POOL_SIZE, KEEP_ALIVE_TIME, TimeUnit.SECONDS, new ArrayBlockingQueue\u0026lt;\u0026gt;(QUEUE_CAPACITY), new ThreadPoolExecutor.CallerRunsPolicy()); for (int i = 0; i \u0026lt; 10; i++) { executor.execute(() -\u0026gt; { try { Thread.sleep(2000); } catch (InterruptedException e) { e.printStackTrace(); } System.out.println(\u0026#34;CurrentThread name:\u0026#34; + Thread.currentThread().getName() + \u0026#34;date:\u0026#34; + Instant.now()); }); } //终止线程池 executor.shutdown(); try { /* awaitTermination()方法的作用: 当前线程阻塞,直到 1. 等所有已提交的任务(包括正在跑的和队列中等待的)执行完 2. 或者等超时时间到 3. 或者线程被中断,抛出InterruptedException 然后返回true(shutdown请求后所有任务执行完毕)或false(已超时) */ executor.awaitTermination(5, TimeUnit.SECONDS); } catch (InterruptedException e) { e.printStackTrace(); } System.out.println(\u0026#34;Finished all threads\u0026#34;); } /*输出 CurrentThread name:pool-1-thread-5date:2020-06-06T11:45:31.639Z CurrentThread name:pool-1-thread-3date:2020-06-06T11:45:31.639Z CurrentThread name:pool-1-thread-1date:2020-06-06T11:45:31.636Z CurrentThread name:pool-1-thread-4date:2020-06-06T11:45:31.639Z CurrentThread name:pool-1-thread-2date:2020-06-06T11:45:31.639Z CurrentThread name:pool-1-thread-2date:2020-06-06T11:45:33.656Z CurrentThread name:pool-1-thread-4date:2020-06-06T11:45:33.656Z CurrentThread name:pool-1-thread-1date:2020-06-06T11:45:33.656Z CurrentThread name:pool-1-thread-3date:2020-06-06T11:45:33.656Z CurrentThread name:pool-1-thread-5date:2020-06-06T11:45:33.656Z Finished all threads */ 线程池最佳实践 # 1. 使用ThreadPoolExecutor的构造函数声明线程池 # 线程池必须手动通过 ThreadPoolExecutor 的构造函数来声明,避免使用Executors 类的 newFixedThreadPool 和 newCachedThreadPool ,因为可能会有 OOM 的风险。\nFixedThreadPool 和 SingleThreadExecutor : 允许请求的队列长度为 Integer.MAX_VALUE,可能堆积大量的请求,从而导致 OOM。\nCachedThreadPool 和 ScheduledThreadPool : 允许创建的线程数量为 Integer.MAX_VALUE ,可能会创建大量线程,从而导致 OOM。\n总结:使用有界队列,控制线程创建数量\n其他原因:\n实际中要根据自己机器的性能、业务场景来手动配置线程池参数,比如核心线程数、使用的任务队列、饱和策略 给线程池命名,方便定位问题 2. 监测线程池运行状态 # 可以通过一些手段检测线程池运行状态,比如SpringBoot中的Actuator组件\n或者利用ThreadPoolExecutor相关的API做简陋监控,ThreadPoolExecutor提供了获取线程池当前的线程数和活跃线程数、已经执行完成的任务数,正在排队中的任务数等\n简单的demo,使用ScheduleExecutorService定时打印线程池信息\n/** * 打印线程池的状态 * * @param threadPool 线程池对象 */ public static void printThreadPoolStatus(ThreadPoolExecutor threadPool) { ScheduledExecutorService scheduledExecutorService = new ScheduledThreadPoolExecutor(1, createThreadFactory(\u0026#34;print-images/thread-pool-status\u0026#34;, false)); scheduledExecutorService.scheduleAtFixedRate(() -\u0026gt; { log.info(\u0026#34;=========================\u0026#34;); log.info(\u0026#34;ThreadPool Size: [{}]\u0026#34;, threadPool.getPoolSize()); log.info(\u0026#34;Active Threads: {}\u0026#34;, threadPool.getActiveCount()); log.info(\u0026#34;Number of Tasks : {}\u0026#34;, threadPool.getCompletedTaskCount()); log.info(\u0026#34;Number of Tasks in Queue: {}\u0026#34;, threadPool.getQueue().size()); log.info(\u0026#34;=========================\u0026#34;); }, 0, 1, TimeUnit.SECONDS); } 3. 建议不同类别的业务用不同的线程池 # 建议是不同的业务使用不同的线程池,配置线程池的时候根据当前业务的情况对当前线程池进行配置,因为不同的业务的并发以及对资源的使用情况都不同,重心优化系统性能瓶颈相关的业务\n极端情况导致死锁:\n假如我们线程池的核心线程数为 n,父任务(扣费任务)数量为 n,父任务下面有两个子任务(扣费任务下的子任务),其中一个已经执行完成,另外一个被放在了任务队列中。由于父任务把线程池核心线程资源用完,所以子任务因为无法获取到线程资源无法正常执行,一直被阻塞在队列中。父任务等待子任务执行完成,而子任务等待父任务释放线程池资源,这也就造成了 \u0026ldquo;死锁\u0026rdquo;。\n4. 别忘记给线程池命名 # 初始化线程池时显示命名(设置线程池名称前缀),有利于定位问题\n利用guava的ThreadFactoryBuilder\nThreadFactory threadFactory = new ThreadFactoryBuilder() .setNameFormat(threadNamePrefix + \u0026#34;-%d\u0026#34;) .setDaemon(true).build(); ExecutorService threadPool = new ThreadPoolExecutor(corePoolSize, maximumPoolSize, keepAliveTime, TimeUnit.MINUTES, workQueue, threadFactory) 自己实现ThreadFactor\nimport java.util.concurrent.Executors; import java.util.concurrent.ThreadFactory; import java.util.concurrent.atomic.AtomicInteger; /** * 线程工厂,它设置线程名称,有利于我们定位问题。 */ public final class NamingThreadFactory implements ThreadFactory { private final AtomicInteger threadNum = new AtomicInteger(); private final ThreadFactory delegate; private final String name; /** * 创建一个带名字的线程池生产工厂 */ public NamingThreadFactory(ThreadFactory delegate, String name) { this.delegate = delegate; this.name = name; // TODO consider uniquifying this } @Override public Thread newThread(Runnable r) { Thread t = delegate.newThread(r); t.setName(name + \u0026#34; [#\u0026#34; + threadNum.incrementAndGet() + \u0026#34;]\u0026#34;); return t; } } 5. 正确配置线程池参数 # 如果线程池中的线程太多,就会增加上下文切换的成本\n多线程编程中一般线程的个数都大于 CPU 核心的个数,而一个 CPU 核心在任意时刻只能被一个线程使用,为了让这些线程都能得到有效执行,CPU 采取的策略是为每个线程分配时间片并轮转的形式。当一个线程的时间片用完的时候就会重新处于就绪状态让给其他线程使用,这个过程就属于一次上下文切换。概括来说就是:当前任务在执行完 CPU 时间片切换到另一个任务之前会先保存自己的状态,以便下次再切换回这个任务时,可以再加载这个任务的状态。任务从保存到再加载的过程就是一次上下文切换。\n上下文切换通常是计算密集型的。也就是说,它需要相当可观的处理器时间,在每秒几十上百次的切换中,每次切换都需要纳秒量级的时间。所以,上下文切换对系统来说意味着消耗大量的 CPU 时间,事实上,可能是操作系统中时间消耗最大的操作。\nLinux 相比与其他操作系统(包括其他类 Unix 系统)有很多的优点,其中有一项就是,其上下文切换和模式切换的时间消耗非常少。\n过大跟过小都不行\n如果我们设置的线程池数量太小的话,如果同一时间有大量任务/请求需要处理,可能会导致大量的请求/任务在任务队列中排队等待执行,甚至会出现任务队列满了之后任务/请求无法处理的情况,或者大量任务堆积在任务队列导致 OOM 设置线程数量太大,大量线程可能会同时在争取 CPU 资源,这样会导致大量的上下文切换,从而增加线程的执行时间,影响了整体执行效率 简单且适用面较广的公式\nCPU 密集型任务(N+1): 这种任务消耗的主要是 CPU 资源,可以将线程数设置为 N(CPU 核心数)+1,比 CPU 核心数多出来的一个线程是为了防止线程偶发的缺页中断,或者其它原因导致的任务暂停而带来的影响。一旦任务暂停,CPU 就会处于空闲状态,而在这种情况下多出来的一个线程就可以充分利用 CPU 的空闲时间。\nI/O 密集型任务(2N): 这种任务应用起来,系统会用大部分的时间来处理 I/O 交互,而线程在处理 I/O 的时间段内不会占用 CPU 来处理,这时就可以将 CPU 交出给其它线程使用。因此在 I/O 密集型任务的应用中,我们可以多配置一些线程,具体的计算方法是 2N。\n如何判断是CPU密集任务还是IO密集任务\nCPU 密集型简单理解就是利用 CPU 计算能力的任务比如你在内存中对大量数据进行排序。但凡涉及到网络读取,文件读取这类都是 IO 密集型,这类任务的特点是 CPU 计算耗费时间相比于等待 IO 操作完成的时间来说很少,大部分时间都花在了等待 IO 操作完成上。\n美团线程池的处理 主要对线程池的核心参数实现自定义可配置\ncorePoolSize : 核心线程数线程数定义了最小可以同时运行的线程数量。 maximumPoolSize : 当队列中存放的任务达到队列容量的时候,当前可以同时运行的线程数量变为最大线程数。 workQueue: 当新任务来的时候会先判断当前运行的线程数量是否达到核心线程数,如果达到的话,新任务就会被存放在队列中 参数动态配置 格外需要注意的是corePoolSize, 程序运行期间的时候,我们调用 setCorePoolSize()这个方法的话,线程池会首先判断当前工作线程数是否大于corePoolSize,如果大于的话就会回收工作线程。【ThreadPoolExecutor里面的】 另外,你也看到了上面并没有动态指定队列长度的方法,美团的方式是自定义了一个叫做 ResizableCapacityLinkedBlockIngQueue 的队列(主要就是把LinkedBlockingQueue的 capacity 字段的 final 关键字修饰给去掉了,让它变为可变的) "},{"id":324,"href":"/zh/docs/technology/Review/java_guide/java/Concurrent/ly0305lyjava-thread-pool/","title":"java线程池详解","section":"并发","content":" 转载自https://github.com/Snailclimb/JavaGuide (添加小部分笔记)感谢作者!\n一 使用线程池的好处 # 池化技术:减少每次获取资源的消耗,提高对资源的利用率 线程池提供一种限制和管理资源(包括执行一个任务)的方式,每个线程池还维护一些基本统计信息,例如已完成任务的数量 线程池的好处 降低资源消耗(重复利用,降低线程创建和销毁造成的消耗) 提高响应速度(任务到达直接执行,无需等待线程创建) 提高线程可管理性(避免无休止创建,使用线程池统一分配、调优、监控) 二 Executor框架 # Java5之后,通过Executor启动线程,比使用Thread的start方法更好,更易于管理,效率高,还能有助于避免this逃逸的问题\nthis逃逸,指的是构造函数返回之前,其他线程就持有该对象的引用,会导致调用尚未构造完全的对象\n例子:\npublic class ThisEscape { public ThisEscape() { new Thread(new EscapeRunnable()).start(); // ... } private class EscapeRunnable implements Runnable { @Override public void run() { // 通过ThisEscape.this就可以引用外围类对象, 但是此时外围类对象可能还没有构造完成, 即发生了外围类的this引用的逃逸 } } } 处理办法 //不要在构造函数中运行线程\npublic class ThisEscape { private Thread t; public ThisEscape() { t = new Thread(new EscapeRunnable()); // ... } public void init() { //也就是说对象没有构造完成前,不要调用ThisEscape.this即可 t.start(); } private class EscapeRunnable implements Runnable { @Override public void run() { // 通过ThisEscape.this就可以引用外围类对象, 此时可以保证外围类对象已经构造完成 } } } Executor框架不仅包括线程池的管理,提供线程工厂、队列以及拒绝策略。\nExecutor框架结构 # 主要是三大部分:任务(Runnable/Callable),任务的执行(Executor),异步计算的结果Future\n任务 执行的任务需要的Runnable/Callable接口,他们的实现类,都可以被ThreadPoolExecutor或ScheduleThreadPoolExecutor执行\n任务的执行 我们更多关注的,是ThreadPoolExecutor类。另外,ScheduledThreadPoolExecutor类,继承了ThreadPoolExecutor类,并实现了ScheduledExecutorService接口\n//ThreadPoolExecutor类描述 //AbstractExecutorService实现了ExecutorService接口 public class ThreadPoolExecutor extends AbstractExecutorService{} //ScheduledThreadPoolExecutor类描述 //ScheduledExecutorService继承ExecutorService接口 public class ScheduledThreadPoolExecutor extends ThreadPoolExecutor implements ScheduledExecutorService {} 异步计算的结果 Future接口以及其实现类FutueTask类都可以代表异步计算的结果(下面就是Future接口) 当我们把Runnable接口(结果为null)或Callable接口的实现类提交给ThreadPoolExecutor或ScheduledThreadPoolExecutor执行()\nExecutorService executorService = Executors.newCachedThreadPool(); Callable\u0026lt;MyClass\u0026gt; myClassCallable = new Callable\u0026lt;MyClass\u0026gt;() { public MyClass call() throws Exception { MyClass myClass1 = new MyClass(); myClass1.setName(\u0026#34;ly-callable-测试\u0026#34;); TimeUnit.SECONDS.sleep(2); return myClass1; } }; Future\u0026lt;?\u0026gt; submit = executorService.submit(myClassCallable); //这里会阻塞 Object o = submit.get(); log.info(\u0026#34;ly-callable-打印结果1:\u0026#34; + o); FutureTask\u0026lt;MyClass\u0026gt; futureTask = new FutureTask\u0026lt;\u0026gt;(() -\u0026gt; { MyClass myClass1 = new MyClass(); myClass1.setName(\u0026#34;ly-FutureTask-测试\u0026#34;); TimeUnit.SECONDS.sleep(2); return myClass1; }); Future\u0026lt;?\u0026gt; submit2 = executorService.submit(futureTask); //这里会阻塞 Object o2 = submit2.get(); log.info(\u0026#34;ly-callable-打印结果2:\u0026#34; + o2); executorService.shutdown(); /*结果 2022-11-09 10:19:10 上午 [Thread: main] INFO:ly-callable-打印结果1:MyClass(name=ly-callable-测试) 2022-11-09 10:19:12 上午 [Thread: main] INFO:ly-callable-打印结果2:null */ Executor框架的使用示意图 # 主线程首先要创建实现 Runnable 或者 Callable 接口的任务对象。\n把创建完成的实现 Runnable/Callable接口的 对象直接交给 ExecutorService 执行: ExecutorService.execute(Runnable command))或者也可以把 Runnable 对象或Callable 对象提交给 ExecutorService 执行(ExecutorService.submit(Runnable task)或 ExecutorService.submit(Callable \u0026lt;T\u0026gt; task))。\n如果执行 ExecutorService.submit(…),ExecutorService 将返回一个实现Future接口的对象(我们刚刚也提到过了执行 execute()方法和 submit()方法的区别,submit()会返回一个 FutureTask 对象)。由于 FutureTask 实现了 Runnable,我们也可以创建 FutureTask,然后直接交给 ExecutorService 执行(FutureTask实现了Runnable,不是一个Callable 所以直接使用future.get()获取的是null)。\npublic class MyMain { private byte[] x = new byte[10 * 1024 * 1024];//10M public static void main(String[] args) throws Exception { Callable\u0026lt;Object\u0026gt; abc = Executors.callable(() -\u0026gt; { try { TimeUnit.SECONDS.sleep(2); } catch (InterruptedException e) { e.printStackTrace(); } System.out.println(\u0026#34;aaa\u0026#34;);//输出aaa }, \u0026#34;abcccc\u0026#34;);//如果没有\u0026#34;abcccc\u0026#34;,则下面输出null FutureTask\u0026lt;Object\u0026gt; futureTask = new FutureTask\u0026lt;\u0026gt;(abc); /*new Thread(futureTask).start(); Object o = futureTask.get(); System.out.println(\u0026#34;获取值:\u0026#34;+o); //输出abc */ ExecutorService executorService = Executors.newSingleThreadExecutor(); Future\u0026lt;?\u0026gt; future = executorService.submit(futureTask); Future\u0026lt;?\u0026gt; future1 = executorService.submit(new Callable\u0026lt;String\u0026gt;() { @Override public String call() throws Exception { try { TimeUnit.SECONDS.sleep(2); } catch (InterruptedException e) { e.printStackTrace(); } return \u0026#34;hello\u0026#34;; } }); /*System.out.println(future.get());//输出null*/ System.out.println(future1.get()); //输出hello //System.out.println(futureTask.get());//输出abcccc System.out.println(\u0026#34;阻塞结束\u0026#34;); executorService.shutdown(); } } 最后,主线程可以执行 FutureTask.get()方法来等待任务执行完成。主线程也可以执行 FutureTask.cancel(boolean mayInterruptIfRunning)来取消此任务的执行。\n三 (重要)ThreadPoolExecutor类简单介绍 # 线程池实现类 ThreadPoolExecutor 是 Executor 框架最核心的类。\nThreadPoolExecutor类分析 # 这里看最长的那个,其余三个都是在该构造方法的基础上产生,即给定某些默认参数的构造方法,比如默认的拒绝策略\n/** * 用给定的初始参数创建一个新的ThreadPoolExecutor。 */ public ThreadPoolExecutor(int corePoolSize,//线程池的核心线程数量 int maximumPoolSize,//线程池的最大线程数 long keepAliveTime,//当线程数大于核心线程数时,多余的空闲线程存活的最长时间 TimeUnit unit,//时间单位 BlockingQueue\u0026lt;Runnable\u0026gt; workQueue,//任务队列,用来储存等待执行任务的队列 ThreadFactory threadFactory,//线程工厂,用来创建线程,一般默认即可 RejectedExecutionHandler handler//拒绝策略,当提交的任务过多而不能及时处理时,我们可以定制策略来处理任务 ) { if (corePoolSize \u0026lt; 0 || maximumPoolSize \u0026lt;= 0 || maximumPoolSize \u0026lt; corePoolSize || keepAliveTime \u0026lt; 0) throw new IllegalArgumentException(); if (workQueue == null || threadFactory == null || handler == null) throw new NullPointerException(); this.corePoolSize = corePoolSize; this.maximumPoolSize = maximumPoolSize; this.workQueue = workQueue; this.keepAliveTime = unit.toNanos(keepAliveTime); this.threadFactory = threadFactory; this.handler = handler; } ThreadPoolExecutor中,3个最重要的参数\ncorePoolSize:核心线程数,定义了最小可以同时运行的线程数量 maximumPoolSize:当队列中存放的任务达到队列容量时,当前可以同时运行的线程数量变为最大线程数 workQueue:当新任务来的时候,会先判断当前运行的线程数量是否达到核心线程数,如果达到的话,新任务就会被存放在队列中 ThreadPoolExecutor其他常见参数\nkeepAliveTime:当线程池中的线程数量大于corePoolSize时,如果此时没有新任务提交,核心线程外的线程不会立即销毁,而是会等待,直到等待时间超过了keepAliveTime才会被回收销毁 unit:keepAliveTime参数的时间单位 threadFactory:executor创建新线程的时候会用到 handler:饱和策略 线程池各个参数的相互关系的理解\nThreadPoolExecutor饱和策略定义 如果当前同时运行的线程数量达到最大线程数量并且队列也已经被放满了任务时,ThreadPoolTaskExecutor定义了一些策略:\nThreadPoolExecutor.AbortPolicy :抛出 RejectedExecutionException来拒绝新任务的处理。 ThreadPoolExecutor.CallerRunsPolicy :调用执行自己的线程运行任务,也就是直接在调用execute方法的线程中运行(run)被拒绝的任务,如果执行程序已关闭,则会丢弃该任务。因此这种策略会降低对于新任务提交速度,影响程序的整体性能。如果您的应用程序可以承受此延迟并且你要求任何一个任务请求都要被执行的话,你可以选择这个策略。 ThreadPoolExecutor.DiscardPolicy :不处理新任务,直接丢弃掉。 ThreadPoolExecutor.DiscardOldestPolicy : 此策略将丢弃最早的未处理的任务请求。 举例: Spring 通过 ThreadPoolTaskExecutor 或者我们直接通过 ThreadPoolExecutor 的构造函数创建线程池的时候,当我们不指定 RejectedExecutionHandler 饱和策略的话来配置线程池的时候默认使用的是 ThreadPoolExecutor.AbortPolicy。在默认情况下,ThreadPoolExecutor 将抛出 RejectedExecutionException 来拒绝新来的任务 ,这代表你将丢失对这个任务的处理。 对于可伸缩的应用程序,建议使用 ThreadPoolExecutor.CallerRunsPolicy。当最大池被填满时,此策略为我们提供可伸缩队列。(这个直接查看 ThreadPoolExecutor 的构造函数源码就可以看出,比较简单的原因,这里就不贴代码了。\n推荐使用 ThreadPoolExecutor 构造函数创建线程池 # 阿里巴巴Java开发手册\u0026quot;并发处理\u0026quot;这一章节,明确指出,线程资源必须通过线程池提供,不允许在应用中自行显示创建线程\n原因:使用线程池的好处是减少在创建和销毁线程上所消耗的时间以及系统资源开销,解决资源不足的问题。如果不使用线程池,有可能会造成系统创建大量同类线程而导致消耗完内存或者“过度切换”的问题。也不允许使用Executors去创建,而是通过ThreadPoolExecutor构造方式\nExecutors返回线程池对象的弊端:\nFixedThreadPool和SingleThreadExecutor:允许请求的队列长度为Integer.MAV_VALUE 可能堆积大量请求,导致OOM CachedThreadPool和ScheduledThreadPool,允许创建的线程数量为Integer.MAX_VALUE 可能创建大量线程,从而导致OOM 创建线程的几种方法\n通过ThreadPoolExecutor构造函数实现(推荐) 通过Executors框架的工具类Executors来实现,我们可以创建三红类型的ThreadPoolExecutor FixedThreadPool、SingleThreadExecutor、CachedThreadPool 四 ThreadPoolExecutor使用+原理分析 # 示例代码:Runnable+ThreadPoolExecutor # 先创建一个Runnable接口的实现类\n//MyRunnable.java import java.util.Date; /** * 这是一个简单的Runnable类,需要大约5秒钟来执行其任务。 * @author shuang.kou */ public class MyRunnable implements Runnable { private String command; public MyRunnable(String s) { this.command = s; } @Override public void run() { System.out.println(Thread.currentThread().getName() + \u0026#34; Start. Time = \u0026#34; + new Date()); processCommand(); System.out.println(Thread.currentThread().getName() + \u0026#34; End. Time = \u0026#34; + new Date()); } private void processCommand() { try { Thread.sleep(5000); } catch (InterruptedException e) { e.printStackTrace(); } } @Override public String toString() { return this.command; } } 使用自定义的线程池\nimport java.util.concurrent.ArrayBlockingQueue; import java.util.concurrent.ThreadPoolExecutor; import java.util.concurrent.TimeUnit; public class ThreadPoolExecutorDemo { private static final int CORE_POOL_SIZE = 5; private static final int MAX_POOL_SIZE = 10; private static final int QUEUE_CAPACITY = 100; private static final Long KEEP_ALIVE_TIME = 1L; public static void main(String[] args) { //使用阿里巴巴推荐的创建线程池的方式 //通过ThreadPoolExecutor构造函数自定义参数创建 ThreadPoolExecutor executor = new ThreadPoolExecutor( CORE_POOL_SIZE, //5 MAX_POOL_SIZE, //10 KEEP_ALIVE_TIME, //1L TimeUnit.SECONDS, //单位 new ArrayBlockingQueue\u0026lt;\u0026gt;(QUEUE_CAPACITY),//100 new ThreadPoolExecutor.CallerRunsPolicy()); //主线程中运行 for (int i = 0; i \u0026lt; 10; i++) { //创建WorkerThread对象(WorkerThread类实现了Runnable 接口) Runnable worker = new MyRunnable(\u0026#34;\u0026#34; + i); //执行Runnable executor.execute(worker); } //终止线程池 executor.shutdown(); // isTerminated 判断所有提交的任务是否完成(保证之前调用过shutdown方法) while (!executor.isTerminated()) { } System.out.println(\u0026#34;Finished all threads\u0026#34;); } } //结果: /* corePoolSize: 核心线程数为 5。 maximumPoolSize :最大线程数 10 keepAliveTime : 等待时间为 1L。 unit: 等待时间的单位为 TimeUnit.SECONDS。 workQueue:任务队列为 ArrayBlockingQueue,并且容量为 100; handler:饱和策略为 CallerRunsPolicy ---output--- pool-1-thread-3 Start. Time = Sun Apr 12 11:14:37 CST 2020 pool-1-thread-5 Start. Time = Sun Apr 12 11:14:37 CST 2020 pool-1-thread-2 Start. Time = Sun Apr 12 11:14:37 CST 2020 pool-1-thread-1 Start. Time = Sun Apr 12 11:14:37 CST 2020 pool-1-thread-4 Start. Time = Sun Apr 12 11:14:37 CST 2020 pool-1-thread-3 End. Time = Sun Apr 12 11:14:42 CST 2020 pool-1-thread-4 End. Time = Sun Apr 12 11:14:42 CST 2020 pool-1-thread-1 End. Time = Sun Apr 12 11:14:42 CST 2020 pool-1-thread-5 End. Time = Sun Apr 12 11:14:42 CST 2020 pool-1-thread-1 Start. Time = Sun Apr 12 11:14:42 CST 2020 pool-1-thread-2 End. Time = Sun Apr 12 11:14:42 CST 2020 pool-1-thread-5 Start. Time = Sun Apr 12 11:14:42 CST 2020 pool-1-thread-4 Start. Time = Sun Apr 12 11:14:42 CST 2020 pool-1-thread-3 Start. Time = Sun Apr 12 11:14:42 CST 2020 pool-1-thread-2 Start. Time = Sun Apr 12 11:14:42 CST 2020 pool-1-thread-1 End. Time = Sun Apr 12 11:14:47 CST 2020 pool-1-thread-4 End. Time = Sun Apr 12 11:14:47 CST 2020 pool-1-thread-5 End. Time = Sun Apr 12 11:14:47 CST 2020 pool-1-thread-3 End. Time = Sun Apr 12 11:14:47 CST 2020 pool-1-thread-2 End. Time = Sun Apr 12 11:14:47 CST 2020 ------ */ 线程池原理分析 # 如上,线程池首先会先执行 5 个任务,然后这些任务有任务被执行完的话,就会去拿新的任务执行\nexecute方法源码\n// 存放线程池的运行状态 (runState) 和线程池内有效线程的数量 (workerCount) private final AtomicInteger ctl = new AtomicInteger(ctlOf(RUNNING, 0)); private static int workerCountOf(int c) { return c \u0026amp; CAPACITY; } //任务队列 private final BlockingQueue\u0026lt;Runnable\u0026gt; workQueue; public void execute(Runnable command) { // 如果任务为null,则抛出异常。 if (command == null) throw new NullPointerException(); // ctl 中保存的线程池当前的一些状态信息 int c = ctl.get(); // 下面会涉及到 3 步 操作 // 1.首先判断当前线程池中执行的任务数量是否小于 corePoolSize // 如果小于的话,通过addWorker(command, true)新建一个线程,并将任务(command)添加到该线程中;然后,启动该线程从而执行任务。 if (workerCountOf(c) \u0026lt; corePoolSize) { if (addWorker(command, true)) return; c = ctl.get(); } // 2.如果当前执行的任务数量大于等于 corePoolSize 的时候就会走到这里 // 通过 isRunning 方法判断线程池状态,线程池处于 RUNNING 状态并且队列可以加入任务,该任务才会被加入进去 if (isRunning(c) \u0026amp;\u0026amp; workQueue.offer(command)) { int recheck = ctl.get(); // 再次获取线程池状态,如果线程池状态不是 RUNNING 状态就需要从任务队列中移除任务,并尝试判断线程是否全部执行完毕。同时执行拒绝策略。 if (!isRunning(recheck) \u0026amp;\u0026amp; remove(command)) reject(command); // 如果当前线程池为空就新创建一个线程并执行。 else if (workerCountOf(recheck) == 0) addWorker(null, false); } //3. 通过addWorker(command, false)新建一个线程,并将任务(command)添加到该线程中;然后,启动该线程从而执行任务。 //如果addWorker(command, false)执行失败,则通过reject()执行相应的拒绝策略的内容。 else if (!addWorker(command, false)) reject(command); } ------ 图示:\n源码\n// 全局锁,并发操作必备 private final ReentrantLock mainLock = new ReentrantLock(); // 跟踪线程池的最大大小,只有在持有全局锁mainLock的前提下才能访问此集合 private int largestPoolSize; // 工作线程集合,存放线程池中所有的(活跃的)工作线程,只有在持有全局锁mainLock的前提下才能访问此集合 private final HashSet\u0026lt;Worker\u0026gt; workers = new HashSet\u0026lt;\u0026gt;(); //获取线程池状态 private static int runStateOf(int c) { return c \u0026amp; ~CAPACITY; } //判断线程池的状态是否为 Running private static boolean isRunning(int c) { return c \u0026lt; SHUTDOWN; } /** * 添加新的工作线程到线程池 * @param firstTask 要执行 * @param core参数为true的话表示使用线程池的基本大小,为false使用线程池最大大小 * @return 添加成功就返回true否则返回false */ private boolean addWorker(Runnable firstTask, boolean core) { retry: for (;;) { //这两句用来获取线程池的状态 int c = ctl.get(); int rs = runStateOf(c); // Check if queue empty only if necessary. if (rs \u0026gt;= SHUTDOWN \u0026amp;\u0026amp; ! (rs == SHUTDOWN \u0026amp;\u0026amp; firstTask == null \u0026amp;\u0026amp; ! workQueue.isEmpty())) return false; for (;;) { //获取线程池中工作的线程的数量 int wc = workerCountOf(c); // core参数为false的话表明队列也满了,线程池大小变为 maximumPoolSize if (wc \u0026gt;= CAPACITY || wc \u0026gt;= (core ? corePoolSize : maximumPoolSize)) return false; //原子操作将workcount的数量加1 if (compareAndIncrementWorkerCount(c)) break retry; // 如果线程的状态改变了就再次执行上述操作 c = ctl.get(); if (runStateOf(c) != rs) continue retry; // else CAS failed due to workerCount change; retry inner loop } } // 标记工作线程是否启动成功 boolean workerStarted = false; // 标记工作线程是否创建成功 boolean workerAdded = false; Worker w = null; try { w = new Worker(firstTask); final Thread t = w.thread; if (t != null) { // 加锁 final ReentrantLock mainLock = this.mainLock; mainLock.lock(); try { //获取线程池状态 int rs = runStateOf(ctl.get()); //rs \u0026lt; SHUTDOWN 如果线程池状态依然为RUNNING,并且线程的状态是存活的话,就会将工作线程添加到工作线程集合中 //(rs=SHUTDOWN \u0026amp;\u0026amp; firstTask == null)如果线程池状态小于STOP,也就是RUNNING或者SHUTDOWN状态下,同时传入的任务实例firstTask为null,则需要添加到工作线程集合和启动新的Worker // firstTask == null证明只新建线程而不执行任务 if (rs \u0026lt; SHUTDOWN || (rs == SHUTDOWN \u0026amp;\u0026amp; firstTask == null)) { if (t.isAlive()) // precheck that t is startable throw new IllegalThreadStateException(); workers.add(w); //更新当前工作线程的最大容量 int s = workers.size(); if (s \u0026gt; largestPoolSize) largestPoolSize = s; // 工作线程是否启动成功 workerAdded = true; } } finally { // 释放锁 mainLock.unlock(); } //// 如果成功添加工作线程,则调用Worker内部的线程实例t的Thread#start()方法启动真实的线程实例 if (workerAdded) { t.start(); /// 标记线程启动成功 workerStarted = true; } } } finally { // 线程启动失败,需要从工作线程中移除对应的Worker if (! workerStarted) addWorkerFailed(w); } return workerStarted; } ------ 完整源码分析 https://www.throwx.cn/2020/08/23/java-concurrency-thread-pool-executor/\n对于代码中,进行分析:\n我们在代码中模拟了 10 个任务,我们配置的核心线程数为 5 、等待队列容量为 100 ,所以每次只可能存在 5 个任务同时执行,剩下的 5 个任务会被放到等待队列中去。当前的 5 个任务中如果有任务被执行完了,线程池就会去拿新的任务执行。\n几个常见的对比 # Runnable VS Callable Runnable Java 1.0,不会返回结果或抛出检查异常\nCallable Java 1.5 可以\n工具类Executors可以实现,将Runnable对象转换成Callable对象( Executors.callable(Runnable task)或Executors.callable(Runnable task, Object result) )\n//Runnable @FunctionalInterface public interface Runnable { /** * 被线程执行,没有返回值也无法抛出异常 */ public abstract void run(); } ------ //Callable @FunctionalInterface public interface Callable\u0026lt;V\u0026gt; { /** * 计算结果,或在无法这样做时抛出异常。 * @return 计算得出的结果 * @throws 如果无法计算结果,则抛出异常 */ V call() throws Exception; } execute() VS submit()\nexecute()方法用于提交不需要返回值的任务,所以无法判断任务是否被线程池执行成功与否; submit()方法用于提交需要返回值的任务。线程池会返回一个 Future 类型的对象,通过这个 Future 对象可以判断任务是否执行成功,并且可以通过 Future 的 get()方法来获取返回值,get()方法会阻塞当前线程直到任务完成,而使用 get(long timeout,TimeUnit unit)方法的话,如果在 timeout 时间内任务还没有执行完,就会抛出 java.util.concurrent.TimeoutException。 //真实使用,建议使用ThreadPoolExecutor构造方法 ExecutorService executorService = Executors.newFixedThreadPool(3); Future\u0026lt;String\u0026gt; submit = executorService.submit(() -\u0026gt; { try { Thread.sleep(5000L); } catch (InterruptedException e) { e.printStackTrace(); } return \u0026#34;abc\u0026#34;; }); String s = submit.get(); System.out.println(s); executorService.shutdown(); /* abc */ 使用抛异常的方法\nExecutorService executorService = Executors.newFixedThreadPool(3); Future\u0026lt;String\u0026gt; submit = executorService.submit(() -\u0026gt; { try { Thread.sleep(5000L); } catch (InterruptedException e) { e.printStackTrace(); } return \u0026#34;abc\u0026#34;; }); String s = submit.get(3, TimeUnit.SECONDS); System.out.println(s); executorService.shutdown(); /* 控制台输出 Exception in thread \u0026#34;main\u0026#34; java.util.concurrent.TimeoutException at java.util.concurrent.FutureTask.get(FutureTask.java:205) */ shutdown() VS shutdownNow() shutdown() :关闭线程池,线程池的状态变为 SHUTDOWN。线程池不再接受新任务了,但是队列里的任务得执行完毕。 shutdownNow() :关闭线程池,线程的状态变为 STOP。线程池会终止当前正在运行的任务,并停止处理排队的任务并返回正在等待执行的 List。 isTerminated() VS isshutdown()\nisShutDown 当调用 shutdown() 方法后返回为 true。 isTerminated 当调用 shutdown() 方法后,并且所有提交的任务完成后返回为 true callable+ThreadPoolExecutor示例代码 源代码 //MyCallable.java\nimport java.util.ArrayList; import java.util.Date; import java.util.List; import java.util.concurrent.ArrayBlockingQueue; import java.util.concurrent.Callable; import java.util.concurrent.ExecutionException; import java.util.concurrent.Future; import java.util.concurrent.ThreadPoolExecutor; import java.util.concurrent.TimeUnit; public class CallableDemo { private static final int CORE_POOL_SIZE = 5; private static final int MAX_POOL_SIZE = 10; private static final int QUEUE_CAPACITY = 100; private static final Long KEEP_ALIVE_TIME = 1L; public static void main(String[] args) { //使用阿里巴巴推荐的创建线程池的方式 //通过ThreadPoolExecutor构造函数自定义参数创建 ThreadPoolExecutor executor = new ThreadPoolExecutor( CORE_POOL_SIZE, MAX_POOL_SIZE, KEEP_ALIVE_TIME, TimeUnit.SECONDS, new ArrayBlockingQueue\u0026lt;\u0026gt;(QUEUE_CAPACITY), new ThreadPoolExecutor.CallerRunsPolicy()); List\u0026lt;Future\u0026lt;String\u0026gt;\u0026gt; futureList = new ArrayList\u0026lt;\u0026gt;(); Callable\u0026lt;String\u0026gt; callable = new MyCallable(); for (int i = 0; i \u0026lt; 10; i++) { //提交任务到线程池 Future\u0026lt;String\u0026gt; future = executor.submit(callable); //将返回值 future 添加到 list,我们可以通过 future 获得 执行 Callable 得到的返回值 futureList.add(future); } for (Future\u0026lt;String\u0026gt; fut : futureList) { try { System.out.println(new Date() + \u0026#34;::\u0026#34; + fut.get()); } catch (InterruptedException | ExecutionException e) { e.printStackTrace(); } } //关闭线程池 executor.shutdown(); } } /* 运行结果 Wed Nov 13 13:40:41 CST 2019::pool-1-thread-1 Wed Nov 13 13:40:42 CST 2019::pool-1-thread-2 Wed Nov 13 13:40:42 CST 2019::pool-1-thread-3 Wed Nov 13 13:40:42 CST 2019::pool-1-thread-4 Wed Nov 13 13:40:42 CST 2019::pool-1-thread-5 Wed Nov 13 13:40:42 CST 2019::pool-1-thread-3 Wed Nov 13 13:40:43 CST 2019::pool-1-thread-2 Wed Nov 13 13:40:43 CST 2019::pool-1-thread-1 Wed Nov 13 13:40:43 CST 2019::pool-1-thread-4 Wed Nov 13 13:40:43 CST 2019::pool-1-thread-5 ------ */ 几种常见的线程池详解 # FixedThreadPool 称之为可重用固定线程数的线程池,Executors类中源码:\n/** * 创建一个可重用固定数量线程的线程池 */ public static ExecutorService newFixedThreadPool(int nThreads, ThreadFactory threadFactory) { return new ThreadPoolExecutor(nThreads, nThreads, 0L, TimeUnit.MILLISECONDS, new LinkedBlockingQueue\u0026lt;Runnable\u0026gt;(), threadFactory); } //================或================ public static ExecutorService newFixedThreadPool(int nThreads) { return new ThreadPoolExecutor(nThreads, nThreads, 0L, TimeUnit.MILLISECONDS, new LinkedBlockingQueue\u0026lt;Runnable\u0026gt;()); } 如上得知,新创建的FixedThreadPool的corePoolSize和maximumPoolSize都被设置为nThreads\n执行任务过程介绍 FixedThreadPool的execute()方法运行示意图\n上图分析 如果当前运行的线程数小于 corePoolSize, 如果再来新任务的话,就创建新的线程来执行任务; 当前运行的线程数等于 corePoolSize 后, 如果再来新任务的话,会将任务加入 LinkedBlockingQueue; 线程池中的线程执行完 手头的任务后,会在循环中反复从 LinkedBlockingQueue 中获取任务来执行; 为什么不推荐使用FixedThreadPool 主要原因,FixedThreadPool使用无界队列LinkedBlockingQueue(队列容量为Integer.MAX_VALUE)作为线程池的工作队列 线程池的线程数达到corePoolSize后,新任务在无界队列中等待,因此线程池中线程数不超过corePoolSize 由于使用无界队列时 maximumPoolSize 将是一个无效参数,因为不可能存在任务队列满的情况。所以,【不需要空闲线程,因为corePool,然后Queue,最后才是空闲线程】通过创建 FixedThreadPool的源码可以看出创建的 FixedThreadPool 的 corePoolSize 和 maximumPoolSize 被设置为同一个值。 又由于1、2原因,使用无界队列时,keepAliveTime将是无效参数 运行中的FixedThreadPool(如果未执行shutdown()或shutdownNow())则不会拒绝任务,因此在任务较多时会导致OOM(内存溢出,Out Of Memory) SingleThreadExecutor\nSingleThreadExecutor是只有一个线程的线程池,源码:\n/** *返回只有一个线程的线程池 */ public static ExecutorService newSingleThreadExecutor(ThreadFactory threadFactory) { return new FinalizableDelegatedExecutorService (new ThreadPoolExecutor(1, 1, 0L, TimeUnit.MILLISECONDS, new LinkedBlockingQueue\u0026lt;Runnable\u0026gt;(), threadFactory)); } //另一种构造函数 public static ExecutorService newSingleThreadExecutor() { return new FinalizableDelegatedExecutorService (new ThreadPoolExecutor(1, 1, 0L, TimeUnit.MILLISECONDS, new LinkedBlockingQueue\u0026lt;Runnable\u0026gt;())); } 新创建的 SingleThreadExecutor 的 corePoolSize 和 maximumPoolSize 都被设置为 1.其他参数和 FixedThreadPool 相同\n执行过程 如果当前运行线程数少于corePoolSize(1),则创建一个新的线程执行任务;当前线程池有一个运行的线程后,将任务加入LinkedBlockingQueue;线程执行完当前的任务后,会在循环中反复从LinkedBlockingQueue中获取任务执行\n为什么不推荐使用SingleThreadExecutor SingleThreadExecutor使用无界队列LinkedBlockingQueue作为线程池的工作队列(容量为Integer.MAX_VALUE) 。SingleThreadExecutor使用无界队列作为线程池的工作队列会对线程池带来的影响与FixedThreadPoll相同,即导致OOM\nCachedThreadPool CachedThreadPool是一个会根据需要创建新线程的线程池,源码:\n/** * 创建一个线程池,根据需要创建新线程,但会在先前构建的线程可用时重用它。 */ public static ExecutorService newCachedThreadPool(ThreadFactory threadFactory) { return new ThreadPoolExecutor(0, Integer.MAX_VALUE, 60L, TimeUnit.SECONDS, new SynchronousQueue\u0026lt;Runnable\u0026gt;(), threadFactory); } //其他构造函数 public static ExecutorService newCachedThreadPool() { return new ThreadPoolExecutor(0, Integer.MAX_VALUE, 60L, TimeUnit.SECONDS, new SynchronousQueue\u0026lt;Runnable\u0026gt;()); } CachedThreadPool 的**corePoolSize 被设置为空(0),maximumPoolSize被设置为 Integer.MAX.VALUE,即它是无界的,这也就意味着如果主线程提交任务的速度高于 maximumPool 中线程处理任务的速度时,CachedThreadPool 会不断创建新的线程**。极端情况下,这样会导致耗尽 cpu 和内存资源\n★:SynchronousQueue队列只能容纳零个元素 执行过程(execute()示意图)\n上图说明:\n首先执行 SynchronousQueue.offer(Runnable task) 提交任务到任务队列。如果当前 maximumPool 中有闲线程正在执行 SynchronousQueue.poll(keepAliveTime,TimeUnit.NANOSECONDS),那么主线程执行 offer 操作与空闲线程执行的 poll 操作配对成功,主线程把任务交给空闲线程执行,execute()方法执行完成,否则执行下面的步骤 2; 当初始 maximumPool 为空,或者 maximumPool 中没有空闲线程时,将没有线程执行 SynchronousQueue.poll(keepAliveTime,TimeUnit.NANOSECONDS)。这种情况下,步骤 1 将失败,此时 CachedThreadPool 会创建新线程执行任务,execute 方法执行完成; 不推荐使用CachedThreadPool? 因为它允许创建的线程数量为Integer.MAX_VALUE,可能创建大量线程,从而导致OOM\nScheduledThreadPoolExecutor详解 # 项目中基本不会用到,主要用来在给定的延迟后运行任务,或者定期执行任务 它使用的任务队列DelayQueue封装了一个PriorityQueue,PriorityQueue会对队列中的任务进行排序,执行所需时间(第一次执行的时间)短的放在前面先被执行(ScheduledFutureTask的time变量小的先执行),如果一致则先提交的先执行(ScheduleFutureTask的sequenceNumber变量)\nScheduleFutureTask\n/** * 其中, triggerTime(initialDelay, unit) 的结果即上面说的time,说的应该是第一次执行的时间,而不是整个任务的执行时间 * @throws RejectedExecutionException {@inheritDoc} * @throws NullPointerException {@inheritDoc} * @throws IllegalArgumentException {@inheritDoc} */ public ScheduledFuture\u0026lt;?\u0026gt; scheduleAtFixedRate(Runnable command, long initialDelay, long period, TimeUnit unit) { if (command == null || unit == null) throw new NullPointerException(); if (period \u0026lt;= 0) throw new IllegalArgumentException(); ScheduledFutureTask\u0026lt;Void\u0026gt; sft = new ScheduledFutureTask\u0026lt;Void\u0026gt;(command, null, triggerTime(initialDelay, unit), unit.toNanos(period)); RunnableScheduledFuture\u0026lt;Void\u0026gt; t = decorateTask(command, sft); sft.outerTask = t; delayedExecute(t); return t; } 代码,TimerTask\n@Slf4j class MyTimerTask extends TimerTask{ @Override public void run() { log.info(\u0026#34;hello\u0026#34;); } } public class TimerTaskTest { public static void main(String[] args) { Timer timer = new Timer(); Calendar calendar = Calendar.getInstance(); calendar.set(Calendar.HOUR_OF_DAY, 17);//控制小时 calendar.set(Calendar.MINUTE, 1);//控制分钟 calendar.set(Calendar.SECOND, 0);//控制秒 Date time = calendar.getTime();//执行任务时间为17:01:00 //每天定时17:02执行操作,每5秒执行一次 timer.schedule(new MyTimerTask(), time, 5000 ); } } 代码,ScheduleThreadPoolExecutor\n@Slf4j public class ScheduleTask { public static void main(String[] args) throws InterruptedException { ScheduledExecutorService scheduledExecutorService = Executors.newScheduledThreadPool(3); scheduledExecutorService.scheduleAtFixedRate(new Runnable() { @Override public void run() { log.info(\u0026#34;hello world!\u0026#34;); } }, 3, 5, TimeUnit.SECONDS);//10表示首次执行任务的延迟时间,5表示每次执行任务的间隔时间,Thread.sleep(10000); System.out.println(\u0026#34;Shutting down executor...\u0026#34;); TimeUnit.SECONDS.sleep(4); //线程池一关闭,定时器就不会再执行 scheduledExecutorService.shutdown(); while (true){} } } /*结果 Shutting down executor... 2022-11-28 17:25:06 下午 [Thread: pool-1-thread-1] INFO:hello world! 不会再执行定时任务,因为线程池已经关了*/ ScheduleThreadPoolExecutor和Timer的比较 Timer对系统时钟变化敏感,ScheduledThreadPoolExecutor不是\nTimer使用的是System.currentTime(),而ScheduledThreadPoolExecutor使用的是System.nanoTime()\nTimer只有一个线程(导致长时间运行的任务延迟其他任务),ScheduleThreadPoolExecutor可以配置任意数量线程\nTimerTask中抛出运行时异常会杀死一个线程,从而导致Timer死机(即计划任务将不在运行);而ScheduleThreadExecutor不仅捕获运行时异常,还允许需要时处理(afterExecute方法),抛出异常的任务会被取消而其他任务将继续运行\nJDK1.5 之后,没有理由再使用Timer进行任务调度\n运行机制 //下面这块内容后面更新后原作者删除了 ScheduledThreadPoolExecutor的执行分为:\n当调用scheduleAtFixedRate()或scheduleWithFixedDelay()方法时,会向ScheduleThreadPoolExector的DelayQueue添加一个实现了RunnableScheduleFuture接口的ScheduleFutureTask(私有内部类) 线程池中的线程从DelayQueue中获取ScheduleFutureTask,然后执行任务 为了执行周期性任务,对ThreadPoolExecutor做了如下修改:\n使用DelayQueue作为任务队列 获取任务的方式不同 获取周期任务后做了额外处理 获取任务,执行任务,修改任务(time),回放(添加)任务\n线程 1 从 DelayQueue 中获取已到期的 ScheduledFutureTask(DelayQueue.take())。到期任务是指 ScheduledFutureTask的 time 大于等于当前系统的时间; 线程 1 执行这个 ScheduledFutureTask; 线程 1 修改 ScheduledFutureTask 的 time 变量为下次将要被执行的时间; 线程 1 把这个修改 time 之后的 ScheduledFutureTask 放回 DelayQueue 中(DelayQueue.add())。 线程池大小确定 # 如果线程池中的线程太多,就会增加上下文切换的成本\n多线程编程中一般线程的个数都大于 CPU 核心的个数,而一个 CPU 核心在任意时刻只能被一个线程使用,为了让这些线程都能得到有效执行,CPU 采取的策略是为每个线程分配时间片并轮转的形式。当一个线程的时间片用完的时候就会重新处于就绪状态让给其他线程使用,这个过程就属于一次上下文切换。概括来说就是:当前任务在执行完 CPU 时间片切换到另一个任务之前会先保存自己的状态,以便下次再切换回这个任务时,可以再加载这个任务的状态。任务从保存到再加载的过程就是一次上下文切换。\n上下文切换通常是计算密集型的。也就是说,它需要相当可观的处理器时间,在每秒几十上百次的切换中,每次切换都需要纳秒量级的时间。所以,上下文切换对系统来说意味着消耗大量的 CPU 时间,事实上,可能是操作系统中时间消耗最大的操作。\nLinux 相比与其他操作系统(包括其他类 Unix 系统)有很多的优点,其中有一项就是,其上下文切换和模式切换的时间消耗非常少。\n过大跟过小都不行\n如果我们设置的线程池数量太小的话,如果同一时间有大量任务/请求需要处理,可能会导致大量的请求/任务在任务队列中排队等待执行,甚至会出现任务队列满了之后任务/请求无法处理的情况,或者大量任务堆积在任务队列导致 OOM 设置线程数量太大,大量线程可能会同时在争取 CPU 资源,这样会导致大量的上下文切换,从而增加线程的执行时间,影响了整体执行效率 简单且适用面较广的公式\nCPU 密集型任务(N+1): 这种任务消耗的主要是 CPU 资源,可以将线程数设置为 N(CPU 核心数)+1,比 CPU 核心数多出来的一个线程是为了防止线程偶发的缺页中断,或者其它原因导致的任务暂停而带来的影响。一旦任务暂停,CPU 就会处于空闲状态,而在这种情况下多出来的一个线程就可以充分利用 CPU 的空闲时间。\nI/O 密集型任务(2N): 这种任务应用起来,系统会用大部分的时间来处理 I/O 交互,而线程在处理 I/O 的时间段内不会占用 CPU 来处理,这时就可以将 CPU 交出给其它线程使用。因此在 I/O 密集型任务的应用中,我们可以多配置一些线程,具体的计算方法是 2N。\n如何判断是CPU密集任务还是IO密集任务\nCPU 密集型简单理解就是利用 CPU 计算能力的任务比如你在内存中对大量数据进行排序。但凡涉及到网络读取,文件读取这类都是 IO 密集型,这类任务的特点是 CPU 计算耗费时间相比于等待 IO 操作完成的时间来说很少,大部分时间都花在了等待 IO 操作完成上。\n"},{"id":325,"href":"/zh/docs/technology/Review/java_guide/java/Concurrent/ly0304lyjmm/","title":"java内存模型","section":"并发","content":" 引用自https://github.com/Snailclimb/JavaGuide\n从CPU缓存模型说起 # redis是为了解决程序处理速度和访问常规关系型数据库速度不对等的问题,CPU缓存则是为了解决CPU处理速度和内存处理速度不对等的问题\n我们把内存看作外存的高速缓存,程序运行时把外存的数据复制到内存,由于内存的处理速度远高于外存,这样提高了处理速度\n总结,CPU Cache缓存的是内存数据,用于解决CPU处理速度和内存不匹配的问题,内存缓存的是硬盘数据用于解决硬盘访问速度过慢的问题 CPU Cache示意图:\nCPU Cache通常分为三层,分别叫L1,L2,L3 Cache 工作方式: 先复制一份数据到CPUCache中,当CPU需要用的时候就可以从CPUCache中读取数据,运算完成后,将运算得到的数据,写回MainMemory中,此时,会出现内存缓存不一致的问题,例子:执行了i++,如果两个线程同时执行,假设两个线程从CPUCach中读取的i=1,两个线程做了1++运算完之后再写回MainMemory,此时i=2 而正确结果为3\nCPU为了解决内存缓存不一致问题,可以通过制定缓存一致协议(比如MESI协议)或其他手段。这个缓存一致协议,指的是在 CPU 高速缓存与主内存交互的时候需要遵守的原则和规范 操作系统,通过内存模型MemoryModel定义一系列规范来解决这个问题\nJava内存模型 # 指令重排序 # 什么是指令重排序? 简单来说就是系统在执行代码的时候并不一定是按照你写的代码的顺序依次执行\n指令重排有下面2种\n编译器优化重排:编译器(包括 JVM、JIT 编译器等)在不改变单线程程序语义的前提下,重新安排语句的执行顺序。 指令并行重排:现代处理器采用了指令级并行技术(Instruction-Level Parallelism,ILP)来将多条指令重叠执行。如果不存在数据依赖性,处理器可以改变语句对应机器指令的执行顺序 另外,内存系统也会有“重排序”,但又不是真正意义上的重排序。在 JMM 里表现为主存和本地内存的内容可能不一致,进而导致程序在多线程下执行可能出现问题。\n即Java源代码会经历 编译器优化重排\u0026mdash;\u0026gt;指令并行重排\u0026mdash;\u0026gt;内存系统重排,最终编程操作系统可执行的指令序列\n极其重要★:指令重排序可以保证串行语义一致,但是没有义务保证多线程间的语义也一致,所以在多线程下指令重排可能导致一些问题\n编译器和处理器的指令重排序的处理方式不一样。对于编译器,通过禁止特定类型的编译器重排序的方式来禁止重排序。对于处理器,通过插入内存屏障(Memory Barrier,或有时叫做内存栅栏,Memory Fence)的方式来禁止特定类型的处理器重排序。指令并行重排和内存系统重排都属于是处理器级别的指令重排序。\n内存屏障(Memory Barrier,或有时叫做内存栅栏,Memory Fence)是一种 CPU 指令,用来禁止处理器指令发生重排序(像屏障一样),从而保障指令执行的有序性。另外,为了达到屏障的效果,它也会使处理器写入、读取值之前,将主内存的值写入高速缓存,清空无效队列,从而保障变量的可见性。\nJMM(JavaMemoryMode) # 什么是 JMM?为什么需要 JMM? # 一般来说,编程语言也可以直接复用操作系统层面的内存模型。不过,不同的操作系统内存模型不同。如果直接复用操作系统层面的内存模型,就可能会导致同样一套代码换了一个操作系统就无法执行了。Java 语言是跨平台的,它需要自己提供一套内存模型以屏蔽系统差异。\n实际上,对于Java来说,可以把JMM看作是Java定义的并发编程相关的一组规范,除了抽象了线程和主内存之间的关系之外,还规定了从Java源代码到CPU可执行指令的转化过程要遵守哪些和并发相关的原则和规范,主要目的是为了简化多线程编程,增强程序可移植性。\n为什么要遵守这些并发相关的原则和规范呢?因为在并发编程下,CPU多级缓存和指令重排这类设计会导致程序运行出问题,比如指令重排,为此JMM抽象了happens-before原则\nJMM 说白了就是定义了一些规范来解决这些问题,开发者可以利用这些规范更方便地开发多线程程序。对于 Java 开发者说,你不需要了解底层原理,直接使用并发相关的一些关键字和类(比如 volatile、synchronized、各种 Lock)即可开发出并发安全的程序。\nJMM 是如何抽象线程和主内存之间的关系? # Java内存模型(JMM),抽象了线程和主内存之间的关系,比如线程之间的共享变量必须存储在主内存中\nJDK1.2之前,Java内存模型总是从主存(共享内存)读取变量;而当前的Java内存模型下,线程可以把变量保存本地内存(机器的寄存器)中,而不直接在主存中读写。这可能造成,一个线程在主存中修改了一个变量的值,而在另一个线程继续使用它在寄存器中的变量值的拷贝,造成数据不一致\n上面所述跟CPU缓存模型非常相似\n什么是主内存?什么是本地内存?\n主内存:★重要!!★所有线程创建的实例对象都存放在主内存中(感觉这里说的是堆?),不管该实例对象是成员变量还是方法中的本地变量(也称局部变量)\n本地内存 :每个线程都有一个私有的本地内存来存储共享变量的副本,并且,每个线程只能访问自己的本地内存,无法访问其他线程的本地内存。本地内存是 JMM 抽象出来的一个概念,存储了主内存中的共享变量副本\nJava 内存模型的抽象示意图如下:\n如上,若线程1和线程2之间要通信,则\n线程1把本地内存中修改过的共享变量副本的值,同步到主内存中 线程2到主存中,读取对应的共享变量的值 即,JMM为共享变量提供了可见性的保障\n多线程下,主内存中一个共享变量进行操作引发的线程安全问题:\n线程1、2分别对同一个共享变量操作,一个执行修改,一个执行读取 线程2读取到的是线程1修改之前的还是修改之后的值,不确定 关于主内存和工作内存直接的具体交互协议,即一个变量,如何从主内存拷贝到工作内存,如何从工作内存同步到主内存,JMM定义八种同步操作:\n锁定(lock): 作用于主内存中的变量,将他标记为一个线程独享变量。\n解锁(unlock): 作用于主内存中的变量,解除变量的锁定状态,被解除锁定状态的变量才能被其他线程锁定。\nread(读取):作用于主内存的变量,它把一个变量的值从主内存传输到线程的工作内存中,以便随后的 load 动作使用。\nload(载入):把 read 操作从主内存中得到的变量值放入工作内存的变量的副本中。\nuse(使用):把工作内存中的一个变量的值传给执行引擎,每当虚拟机遇到一个使用到变量的指令时都会使用该指令。\nassign(赋值):作用于工作内存的变量,它把一个从执行引擎接收到的值赋给工作内存的变量,每当虚拟机遇到一个给变量赋值的字节码指令时执行这个操作。\nstore(存储):作用于工作内存的变量,它把工作内存中一个变量的值传送到主内存中,以便随后的 write 操作使用。\nwrite(写入):作用于主内存的变量,它把 store 操作从工作内存中得到的变量的值放入主内存的变量中\n下面的同步规则,保证这些同步操作的正确执行: (没看懂)\n不允许一个线程无原因地(没有发生过任何 assign 操作)把数据从线程的工作内存同步回主内存中。\n一个新的变量只能在主内存中 “诞生”,不允许在工作内存中直接使用一个未被初始化(load 或 assign)的变量,换句话说就是对一个变量实施 use 和 store 操作之前,必须先执行过了 assign 和 load 操作。\n一个变量在同一个时刻只允许一条线程对其进行 lock 操作,但 lock 操作可以被同一条线程重复执行多次,多次执行 lock 后,只有执行相同次数的 unlock 操作,变量才会被解锁。\n如果对一个变量执行 lock 操作,将会清空工作内存中此变量的值,在执行引擎使用这个变量前,需要重新执行 load 或 assign 操作初始化变量的值。\n如果一个变量事先没有被 lock 操作锁定,则不允许对它执行 unlock 操作,也不允许去 unlock 一个被其他线程锁定住的变量。\n\u0026hellip;\u0026hellip;\nJava 内存区域和 JMM 有何区别? # JVM 内存结构和 Java 虚拟机的运行时区域相关,定义了 JVM 在运行时如何分区存储程序数据,就比如说堆主要用于存放对象实例。 Java 内存模型和 Java 的并发编程相关,抽象了线程和主内存之间的关系就比如说线程之间的共享变量必须存储在主内存中,规定了从 Java 源代码到 CPU 可执行指令的这个转化过程要遵守哪些和并发相关的原则和规范,其主要目的是为了简化多线程编程,增强程序可移植性的。 happens-before 原则是什么? # 通过逻辑时钟能对分布式系统中的事件的先后关系进行判断\n逻辑时钟并不度量时间本身,仅区分事件发生的前后顺序,其本质就是定义了一种 happens-before 关系。\nJSR 133引入happens-before这个概念来描述两个操作之间的内存可见性\n为什么需要 happens-before 原则? happens-before 原则的诞生是为了程序员和编译器、处理器之间的平衡。程序员追求的是易于理解和编程的强内存模型,遵守既定规则编码即可。编译器和处理器追求的是较少约束的弱内存模型,让它们尽己所能地去优化性能,让性能最大化。\nhappens-before原则的设计思想\n为了对编译器和处理器的约束尽可能少,只要不改变程序的执行结果(单线程程序和正确执行的多线程程序),编译器和处理器怎么进行重排序优化都行。 对于会改变程序执行结果的重排序,JMM 要求编译器和处理器必须禁止这种重排序。 JSR-133对happens-before原则的定义:\n如果一个操作happens-before另一个操作,那么第一个操作的执行结果将对第二个操作可见,并且第一个操作的执行顺序排在第二个操作之前 这是 JMM 对程序员强内存模型的承诺。从程序员的角度来说,可以这样理解 Happens-before 关系:如果 A Happens-before B,那么 JMM 将向程序员保证 — A 操作的结果将对 B 可见,且 A 的执行顺序排在 B 之前。注意,这只是 Java内存模型向程序员做出的保证,即Happens-before提供跨线程的内存可见性保证\n对于这条定义,举个例子(不代表代码就是这样的,这是一个概括性的假设情况)\n// 以下操作在线程 A 中执行 i = 1; // a // 以下操作在线程 B 中执行 j = i; // b // 以下操作在线程 C 中执行 i = 2; // c 假设线程 A 中的操作 a Happens-before 线程 B 的操作 b,那我们就可以确定操作 b 执行后,变量 j 的值一定是等于 1。\n得出这个结论的依据有两个:一是根据 Happens-before 原则,a 操作的结果对 b 可见,即 “i=1” 的结果可以被观察到;二是线程 C 还没运行,线程 A 操作结束之后没有其他线程会修改变量 i 的值。\n现在再来考虑线程 C,我们依然保持 a Happens-before b ,而 c 出现在 a 和 b 的操作之间,但是 c 与 b 没有 Happens-before 关系,也就是说 b 并不一定能看到 c 的操作结果。那么 b 操作的结果也就是 j 的值就不确定了,可能是 1 也可能是 2,那这段代码就是线程不安全的。\n两个操作之间存在 happens-before 关系,并不意味着 Java 平台的具体实现必须要按照 happens-before 关系指定的顺序来执行。如果重排序之后的执行结果,与按 happens-before 关系来执行的结果一致,那么 JMM 也允许这样的重排序 例子:\nint userNum = getUserNum(); // 1 int teacherNum = getTeacherNum();\t// 2 int totalNum = userNum + teacherNum;\t// 3 如上,1 happens-before 2,2 happens-before 3,1 happens-before 3\n虽然 1 happens-before 2,但对 1 和 2 进行重排序不会影响代码的执行结果,所以 JMM 是允许编译器和处理器执行这种重排序的。但 1 和 2 必须是在 3 执行之前,也就是说 1,2 happens-before 3 。\nhappens-before 原则表达的意义其实并不是一个操作发生在另外一个操作的前面,虽然这从程序员的角度上来说也并无大碍。更准确地来说,它更想表达的意义是前一个操作的结果对于后一个操作是可见的,无论这两个操作是否在同一个线程里。\n举个例子:操作 1 happens-before 操作 2,即使操作 1 和操作 2 不在同一个线程内,JMM 也会保证操作 1 的结果对操作 2 是可见的。\nhappens-before 常见规则有哪些?谈谈你的理解? # 主要的5条规则:\n程序顺序规则:一个线程内,按照代码顺序,书写在前面的操作happens-before于书写在后面的操作 解锁规则:解锁happens-before于加锁 volatile变量规则:对一个 volatile 变量的写操作 happens-before 于后面对这个 volatile 变量的读操作。说白了就是对 volatile 变量的写操作的结果对于发生于其后的任何操作都是可见的。 传递规则:如果A happens-before B,且B happens-before C ,那么A happens-before C 线程启动规则:Thread对象的start() 方法 happens-before 于此线程的每一个操作 如果两个操作,不满足于上述任何一个happens-before规则,那么这两个操作就没有顺序的保障,JVM可以对这两个操作进行重排序\nhappens-before 和JMM什么关系 # 根据happens-before规则,告诉程序员,有哪些happens-before规则(哪些情况不会被重排序)\n为了避免 Java 程序员为了理解 JMM 提供的内存可见性保证而去学习复杂的重排序规则以及这些规则的具体实现方法,JMM 就出了这么一个简单易懂的 Happens-before 原则,一个 Happens-before 规则就对应于一个或多个编译器和处理器的重排序规则\nas-if-serial 语义保证单线程内程序的执行结果不被改变,Happens-before 关系保证正确同步的多线程程序的执行结果不被改变。 as-if-serial 语义给编写单线程程序的程序员创造了一个幻境:单线程程序是按程序的顺序来执行的。Happens-before 关系给编写正确同步的多线程程序的程序员创造了一个幻境:正确同步的多线程程序是按 Happens-before 指定的顺序来执行的。 JMM定义的\n再看并发编程三个重要特性 # 原子性,可见性,有序性\n原子性 一次操作或多次操作,要么所有的操作,全部都得到执行并且不会受到任何因素的干扰而中断,要么都不执行\nJava中,使用synchronized、各种Lock以及各种原子类实现原子性(AtomicInteger等)\nsynchronized 和各种 Lock 可以保证任一时刻只有一个线程访问该代码块,因此可以保障原子性。各种原子类是利用 CAS (compare and swap) 操作(可能也会用到 volatile或者final关键字)来保证原子操作。\n可见性 当一个线程对共享变量进行了修改,那么另外的线程都是立即可以看到修改后的最新值。\n在 Java 中,可以借助synchronized 、volatile 以及各种 Lock 实现可见性。\n如果我们将变量声明为 volatile ,这就指示 JVM,这个变量是共享且不稳定的,每次使用它都到主存中进行读取。\n有序性 由于指令重排序问题,代码的执行顺序未必就是编写代码时候的顺序\n指令重排序可以保证串行语义一致,但是没有义务保证多线程间的语义也一致 ,所以在多线程下,指令重排序可能会导致一些问题。\nJava中,volatile关键字可以禁止指令进行重排序优化(注意,synchronized也可以)\n总结 # 补充:线程join()方法,导致调用线程暂停,直到xx.join()中的xx线程执行完,调用join方法的线程才继续执行\nThread thread1 = new Thread(new Runnable() { @Override public void run() { log.info(\u0026#34;暂停5s\u0026#34;); try { TimeUnit.SECONDS.sleep(5); } catch (InterruptedException e) { e.printStackTrace(); } } }); thread1.start(); Thread thread2 = new Thread(new Runnable() { @Override public void run() { log.info(\u0026#34;暂停3s\u0026#34;); try { TimeUnit.SECONDS.sleep(3); } catch (InterruptedException e) { e.printStackTrace(); } } }); thread2.start(); thread1.join(); thread2.join(); log.info(\u0026#34;主线程执行\u0026#34;); /*结果 2022-11-23 13:57:06 下午 [Thread: Thread-1] INFO:暂停5s 2022-11-23 13:57:06 下午 [Thread: Thread-2] INFO:暂停3s 2022-11-23 13:57:11 下午 [Thread: main] INFO:主线程执行 */ 指令重排的影响,举例:【★很重要★】\npublic class Test { private static int x = 0, y = 0; private static int a = 0, b = 0; public static void main(String[] args) throws InterruptedException { for (int i = 0; ; i++) { x = 0; y = 0; a = 0; b = 0; Thread one = new Thread(() -\u0026gt; { a = 1; x = b; }); Thread other = new Thread(() -\u0026gt; { b = 1; y = a; }); one.start(); other.start();; one.join(); other.join(); if (x == 0 \u0026amp;\u0026amp; y == 0) { String result = \u0026#34;第\u0026#34; + i + \u0026#34;次(\u0026#34; + x + \u0026#34;, \u0026#34; + y + \u0026#34;)\u0026#34;; System.out.println(result); } } } } /* 因为线程one中,a和x并不存在依赖关系,因此可能会先执行x=b;而这个时候,b=0。因此x会被赋值为0,而a=1这条语句还没有被执行的时候,线程other先执行了y=a这条语句,这个时候a还是a=0;因此y被赋值为了0。所以存在情况x=0;y=0。这就是指令重排导致的多线程问题。 原文链接:https://blog.csdn.net/qq_45948401/article/details/124973903 */ Java 是最早尝试提供内存模型的语言,其主要目的是为了简化多线程编程,增强程序可移植性的。 CPU 可以通过制定缓存一致协议(比如 MESI 协议open in new window)来解决内存缓存不一致性问题。 为了提升执行速度/性能,计算机在执行程序代码的时候,会对指令进行重排序。 简单来说就是系统在执行代码的时候并不一定是按照你写的代码的顺序依次执行。指令重排序可以保证串行语义一致,但是没有义务保证多线程间的语义也一致 ,所以在多线程下,指令重排序可能会导致一些问题。 你可以把 JMM 看作是 Java 定义的并发编程相关的一组规范,除了抽象了线程和主内存之间的关系之外,其还规定了从 Java 源代码到 CPU 可执行指令的这个转化过程要遵守哪些和并发相关的原则和规范,其主要目的是为了简化多线程编程,增强程序可移植性的。 JSR 133 引入了 happens-before 这个概念来**(极其重要又精简的话)描述两个操作之间的内存可见性**。 "},{"id":326,"href":"/zh/docs/technology/Review/java_guide/java/Concurrent/ly0303lyconcurrent-03/","title":"并发03","section":"并发","content":" 转载自https://github.com/Snailclimb/JavaGuide (添加小部分笔记)感谢作者!\n线程池 # 为什么要使用线程池\n池化技术:线程池、数据库连接池、Http连接池 池化技术思想意义:为了减少每次获取资源的消耗,提高对资源的利用率 线程池提供了限制和管理 资源(包括执行一个任务)的方式 每个线程池还维护基本统计信息,例如已完成任务的数量 好处: 降低资源消耗 重复利用已创建线程降低线程创建和销毁造成的消耗 提高响应速度 任务到达时,任务可以不需等到线程创建就能继续执行 提高线程的可管理性 线程是稀缺资源,如果无限制创建,不仅消耗系统资源,还会降低系统的稳定性,使用线程池统一管理分配、调优和监控。 实现Runnable接口和Callable接口的区别\n//Callable的用法 public class TestLy { //如果加上volatile,就能保证可见性,线程1 才能停止 boolean stop = false;//对象属性 public static void main(String[] args) throws InterruptedException, ExecutionException { FutureTask\u0026lt;String\u0026gt; futureTask=new FutureTask\u0026lt;\u0026gt;(new Callable\u0026lt;String\u0026gt;() { @Override public String call() throws Exception { System.out.println(\u0026#34;等3s再把结果给你\u0026#34;); TimeUnit.SECONDS.sleep(3); return \u0026#34;hello world\u0026#34;; } }); new Thread(futureTask).start(); String s = futureTask.get(); System.out.println(\u0026#34;3s后获取到了结果\u0026#34;+s); new Thread(new Runnable() { @Override public void run() { System.out.println(\u0026#34;abc\u0026#34;); } }).start(); } } /* 等3s再把结果给你 3s后获取到了结果hello world abc */ Runnable接口不会返回结果或抛出检查异常,Callable接口可以\nExecutors可以实现将Runnable对象转换成Callable对象\nExecutors.callable(Runnable task)或Executors.callable(Runnable task, Object result) //则两个方法,运行的结果是 Callable\u0026lt;Object\u0026gt;\n//一个不指定结果,另一个指定结果 public static void main(String[] args) throws Exception { Callable\u0026lt;Object\u0026gt; abc = Executors.callable(() -\u0026gt; { try { TimeUnit.SECONDS.sleep(2); } catch (InterruptedException e) { e.printStackTrace(); } System.out.println(\u0026#34;abc\u0026#34;); },\u0026#34;abcccc\u0026#34;);//如果没有\u0026#34;abcccc\u0026#34;,则下面输出null FutureTask\u0026lt;Object\u0026gt; futureTask = new FutureTask\u0026lt;\u0026gt;(abc); new Thread(futureTask).start(); Object o = futureTask.get(); System.out.println(\u0026#34;获取值:\u0026#34;+o); } Runnable和Callable:\n//Runnable.java @FunctionalInterface public interface Runnable { /** * 被线程执行,没有返回值也无法抛出异常 */ public abstract void run(); } //========================================= //Callable.java @FunctionalInterface public interface Callable\u0026lt;V\u0026gt; { /** * 计算结果,或在无法这样做时抛出异常。 * @return 计算得出的结果 * @throws 如果无法计算结果,则抛出异常 */ V call() throws Exception; } 执行execute()和submit()方法的区别是什么\nexecute() 方法用于提交不需要返回值的任务,所以无法判断任务是否被线程池执行成功\nsubmit()方法用于提交需要返回值的任务,线程池会返回一个Future类型的对象,通过这个Future对象可以判断任务是否返回成功\n这个Future的get()方法来获取返回值,get()方法会阻塞当前线程直到任务完成; 使用get(long timeout,TimeUnit unit) 方法会在阻塞当前线程一段时间后立即返回(此时任务不一定已经执行完) 注意: 这里的get()不一定会有返回值的,例子如下\nExecutorService executorService = Executors.newCachedThreadPool(); Callable\u0026lt;MyClass\u0026gt; myClassCallable = new Callable\u0026lt;MyClass\u0026gt;() { @Override public MyClass call() throws Exception { MyClass myClass1 = new MyClass(); myClass1.setName(\u0026#34;ly-callable-测试\u0026#34;); TimeUnit.SECONDS.sleep(2); return myClass1; } }; Future\u0026lt;?\u0026gt; submit = executorService.submit(myClassCallable); //这里会阻塞 Object o = submit.get(); log.info(\u0026#34;ly-callable-打印结果1:\u0026#34; + o); FutureTask\u0026lt;MyClass\u0026gt; futureTask = new FutureTask\u0026lt;\u0026gt;(() -\u0026gt; { MyClass myClass1 = new MyClass(); myClass1.setName(\u0026#34;ly-FutureTask-测试\u0026#34;); TimeUnit.SECONDS.sleep(2); return myClass1; }); Future\u0026lt;?\u0026gt; submit2 = executorService.submit(futureTask); //这里会阻塞 Object o2 = submit2.get(); log.info(\u0026#34;ly-callable-打印结果2:\u0026#34; + o2); executorService.shutdown(); /*结果 2022-11-09 10:19:10 上午 [Thread: main] INFO:ly-callable-打印结果1:MyClass(name=ly-callable-测试) 2022-11-09 10:19:12 上午 [Thread: main] INFO:ly-callable-打印结果2:null */ 当submit一个Callable对象的时候,能从submit返回的Future.get到返回值;当submit一个FutureTask对象(FutureTask有参构造函数包含Callable对象,但它本身不是Callable)时,没法获取返回值,因为会被当作Runnable对象submit进来\n虚线是实现,实线是继承。\n而入参为Runnable时返回值里是get不到结果的\n下面这段源码,解释了为什么当传入的类型是Runnable对象时,结果为null\n只要是submit(Runnable ),就会返回null\n//源码AbstractExecutorService 接口中的一个submit方法 public Future\u0026lt;?\u0026gt; submit(Runnable task) { if (task == null) throw new NullPointerException(); RunnableFuture\u0026lt;Void\u0026gt; ftask = newTaskFor(task, null); execute(ftask); return ftask; } //其中的newTaskFor方法 protected \u0026lt;T\u0026gt; RunnableFuture\u0026lt;T\u0026gt; newTaskFor(Runnable runnable, T value) { return new FutureTask\u0026lt;T\u0026gt;(runnable, value); } //execute()方法 public void execute(Runnable command) { ... } FutureTask、Thread、Callable、Executors\n如何创建线程池\nexecutor [ɪɡˈzekjətə(r)] 遗嘱执行人(或银行等)\n关于SynchronousQueue(具有0个元素的阻塞队列):\nSynchronousQueue\u0026lt;String\u0026gt; synchronousQueue =new SynchronousQueue\u0026lt;\u0026gt;(); new Thread(()-\u0026gt;{ try { log.info(\u0026#34;放入数据A\u0026#34;); synchronousQueue.put(\u0026#34;A\u0026#34;); } catch (InterruptedException e) { e.printStackTrace(); } log.info(\u0026#34;继续执行\u0026#34;); },\u0026#34;子线程1\u0026#34;).start(); new Thread(()-\u0026gt;{ try { TimeUnit.SECONDS.sleep(3); } catch (InterruptedException e) { e.printStackTrace(); } String poll = null; try { poll = synchronousQueue.take(); } catch (InterruptedException e) { e.printStackTrace(); } log.info(poll); },\u0026#34;子线程2\u0026#34;).start(); /**输出 2023-03-07 15:20:17 下午 [Thread: 子线程1] INFO:放入数据A ---这里会等待3s(等子线程2 task()消费掉) 2023-03-07 15:20:20 下午 [Thread: 子线程2] INFO:A 2023-03-07 15:20:20 下午 [Thread: 子线程1] INFO:继续执行 */ 不允许使用Executors去创建,而是通过new ThreadPoolExecutor的方式:能让写的同学明确线程池运行规则,规避资源耗尽\n/* 工具的方式创建线程池 */ void test(){ ExecutorService executorService = Executors.newCachedThreadPool(); Callable\u0026lt;MyClass\u0026gt; myClassCallable = new Callable\u0026lt;MyClass\u0026gt;() { @Override public MyClass call() throws Exception { MyClass myClass1 = new MyClass(); myClass1.setName(\u0026#34;ly-callable-测试\u0026#34;); TimeUnit.SECONDS.sleep(2); return myClass1; } }; Future\u0026lt;?\u0026gt; submit = executorService.submit(myClassCallable); //这里会阻塞 Object o = submit.get(); log.info(\u0026#34;ly-callable-打印结果1:\u0026#34; + o); } 使用Executors返回线程池对象的弊端:\nThreadPoolExecutor(int corePoolSize, int maximumPoolSize, long keepAliveTime, TimeUnit unit, BlockingQueue\u0026lt;Runnable\u0026gt; workQueue, ThreadFactory threadFactory, RejectedExecutionHandler handler){} //#####时间表示keepAliveTime##### //########线程数量固定,队列长度为Integer.MAX################ Executors.newFixedThreadPool(3); public static ExecutorService newFixedThreadPool(int nThreads) { return new ThreadPoolExecutor(nThreads, nThreads, 0L, TimeUnit.MILLISECONDS, new LinkedBlockingQueue\u0026lt;Runnable\u0026gt;()); } //############线程数量固定,队列长度为Integer.MAX############## Executors.newSingleThreadExecutor(Executors.defaultThreadFactory()); public static ExecutorService newSingleThreadExecutor(ThreadFactory threadFactory) { return new FinalizableDelegatedExecutorService (new ThreadPoolExecutor(1, 1, 0L, TimeUnit.MILLISECONDS, new LinkedBlockingQueue\u0026lt;Runnable\u0026gt;(), threadFactory)); //############线程数量为Integer.MAX############# Executors.newCachedThreadPool(Executors.defaultThreadFactory()); public static ExecutorService newCachedThreadPool(ThreadFactory threadFactory) { return new ThreadPoolExecutor(0, Integer.MAX_VALUE, 60L, TimeUnit.SECONDS, new SynchronousQueue\u0026lt;Runnable\u0026gt;(), threadFactory); } //#############线程数量为Integer.MAX############# Executors.newScheduledThreadPool(3, Executors.defaultThreadFactory()); public static ScheduledExecutorService newScheduledThreadPool( int corePoolSize, ThreadFactory threadFactory) { return new ScheduledThreadPoolExecutor(corePoolSize, threadFactory); } ====================\u0026gt; public ScheduledThreadPoolExecutor(int corePoolSize, ThreadFactory threadFactory) { super(corePoolSize, Integer.MAX_VALUE, 0, NANOSECONDS, new DelayedWorkQueue(), threadFactory); } FixedThreadPool和SingleThreadExecutor:这两个方案允许请求的队列长度为Integer.MAX_VALUE,可能堆积大量请求,导致OOM CachedThreadPool和ScheduledThreadPool:允许创建的线程数量为Integer.MAX_VALUE,可能创建大量线程,导致OOM 通过构造方法实现 通过Executor框架的工具类Executors来实现 以下三个方法,返回的类型都是ThreadPoolExecutor\nFixedThreadPool : 该方法返回固定线程数量的线程池,线程数量始终不变。当有新任务提交时,线程池中若有空闲线程则立即执行;若没有,则新任务被暂存到任务队列中,待有线程空闲时,则处理在任务队列中的任务 SingleThreadExecutor:方法返回一个只有一个线程的线程池。若多余一个任务被提交到该线程池,任务被保存到一个任务队列中,待线程空闲,按先进先出的顺序执行队列中任务 CachedThreadPool:该方法返回一个根据实际情况调整线程数量的线程池。 数量不固定,若有空闲线程可以复用则优先使用可复用线程。若所有线程均工作,此时又有新任务提交,则创建新线程处理任务。所有线程在当前任务执行完毕后返回线程池进行复用 Executors工具类中的方法\n核心线程数和最大线程数有什么区别? 该类提供四个构造方法,看最长那个,其余的都是(给定默认值后)调用这个方法\n/** * 用给定的初始参数创建一个新的ThreadPoolExecutor。 */ public ThreadPoolExecutor(int corePoolSize,//线程池的核心线程数量 int maximumPoolSize,//线程池的最大线程数 long keepAliveTime,//当线程数大于核心线程数时,多余的空闲线程存活的最长时间 TimeUnit unit,//时间单位 BlockingQueue\u0026lt;Runnable\u0026gt; workQueue,//任务队列,用来储存等待执行任务的队列 ThreadFactory threadFactory,//线程工厂,用来创建线程,一般默认即可 RejectedExecutionHandler handler//拒绝策略,当提交的任务过多而不能及时处理时,我们可以定制策略来处理任务 ) { if (corePoolSize \u0026lt; 0 || maximumPoolSize \u0026lt;= 0 || maximumPoolSize \u0026lt; corePoolSize || keepAliveTime \u0026lt; 0) throw new IllegalArgumentException(); if (workQueue == null || threadFactory == null || handler == null) throw new NullPointerException(); this.corePoolSize = corePoolSize; this.maximumPoolSize = maximumPoolSize; this.workQueue = workQueue; this.keepAliveTime = unit.toNanos(keepAliveTime); this.threadFactory = threadFactory; this.handler = handler; } 构造函数重要参数分析\ncorePoolSize : 核心线程数定义最小可以运行的线程数量\nmaximumPoolSize: 当队列中存放的任务达到队列容量时,当前可以同时运行的线程数量变为最大线程数\nworkQueue:当新线程来的时候先判断当前运行线程数量是否达到核心线程数,如果达到的话,新任务就会被存放在队列中 ThreadPoolExecutor其他常见参数:\nkeepAliveTime:如果线程池中的线程数量大于corePoolSize时,如果这时没有新任务提交,核心线程外的线程不会立即销毁,而是等待,等待的时间超过了keepAliveTime就会被回收\nunit: keepAliveTime参数的时间单位\nthreadFactory: executor创建新线程的时候会用到\nhandle: 饱和策略\n如果同时运行的线程数量达到最大线程数,且队列已经被放满任务,ThreadPoolTaskExecutor定义该情况下的策略:\nThreadPoolExecutor.AbortPolicy: 抛出 RejectedExecutionException来拒绝新任务的处理。 ThreadPoolExecutor.CallerRunsPolicy: 调用执行自己的线程(如果在main方法中,那就是main线程)运行任务,也就是直接在调用execute方法的线程中运行(run)被拒绝的任务,如果执行程序已关闭,则会丢弃该任务。因此这种策略会降低对于新任务提交速度,影响程序的整体性能。如果您的应用程序可以承受此延迟并且你要求任何一个任务请求都要被执行的话,你可以选择这个策略。 ThreadPoolExecutor.DiscardPolicy: 不处理新任务,直接丢弃掉。 ThreadPoolExecutor.DiscardOldestPolicy: 此策略将丢弃最早的未处理的任务请求。 举个例子:如果在Spring中通过ThreadPoolTaskExecutor或直接通过ThreadPoolExecutor构造函数创建线程池时,若不指定RejectExcecutorHandler饱和策略则默认使用ThreadPoolExecutor.AbortPolicy,即抛出RejectedExecution来拒绝新来的任务;对于可伸缩程序,建议使用ThreadPoolExecutor.CallerRunsPolicy,\n一个简单的线程池Demo\n//定义一个Runnable接口实现类 import java.util.Date; /** * 这是一个简单的Runnable类,需要大约5秒钟来执行其任务。 * @author shuang.kou */ public class MyRunnable implements Runnable { private String command; public MyRunnable(String s) { this.command = s; } @Override public void run() { System.out.println(Thread.currentThread().getName() + \u0026#34; Start. Time = \u0026#34; + new Date()); processCommand(); System.out.println(Thread.currentThread().getName() + \u0026#34; End. Time = \u0026#34; + new Date()); } private void processCommand() { try { Thread.sleep(5000); } catch (InterruptedException e) { e.printStackTrace(); } } @Override public String toString() { return this.command; } } //实际执行 import java.util.concurrent.ArrayBlockingQueue; import java.util.concurrent.ThreadPoolExecutor; import java.util.concurrent.TimeUnit; public class ThreadPoolExecutorDemo { private static final int CORE_POOL_SIZE = 5;//核心线程数5 private static final int MAX_POOL_SIZE = 10;//最大线程数10 private static final int QUEUE_CAPACITY = 100;//队列容量100 private static final Long KEEP_ALIVE_TIME = 1L;//等待时间 public static void main(String[] args) { //使用阿里巴巴推荐的创建线程池的方式 //通过ThreadPoolExecutor构造函数自定义参数创建 ThreadPoolExecutor executor = new ThreadPoolExecutor( CORE_POOL_SIZE, MAX_POOL_SIZE, KEEP_ALIVE_TIME,8 TimeUnit.SECONDS, new ArrayBlockingQueue\u0026lt;\u0026gt;(QUEUE_CAPACITY), new ThreadPoolExecutor.CallerRunsPolicy()); for (int i = 0; i \u0026lt; 10; i++) { //创建 MyRunnable 对象(MyRunnable 类实现了Runnable 接口) Runnable worker = new MyRunnable(\u0026#34;\u0026#34; + i); //执行Runnable executor.execute(worker); } //终止线程池 executor.shutdown(); while (!executor.isTerminated()) { } System.out.println(\u0026#34;Finished all threads\u0026#34;); } } /*------输出 pool-1-thread-3 Start. Time = Sun Apr 12 11:14:37 CST 2020 pool-1-thread-5 Start. Time = Sun Apr 12 11:14:37 CST 2020 pool-1-thread-2 Start. Time = Sun Apr 12 11:14:37 CST 2020 pool-1-thread-1 Start. Time = Sun Apr 12 11:14:37 CST 2020 pool-1-thread-4 Start. Time = Sun Apr 12 11:14:37 CST 2020 pool-1-thread-3 End. Time = Sun Apr 12 11:14:42 CST 2020 pool-1-thread-4 End. Time = Sun Apr 12 11:14:42 CST 2020 pool-1-thread-1 End. Time = Sun Apr 12 11:14:42 CST 2020 pool-1-thread-5 End. Time = Sun Apr 12 11:14:42 CST 2020 pool-1-thread-1 Start. Time = Sun Apr 12 11:14:42 CST 2020 pool-1-thread-2 End. Time = Sun Apr 12 11:14:42 CST 2020 pool-1-thread-5 Start. Time = Sun Apr 12 11:14:42 CST 2020 pool-1-thread-4 Start. Time = Sun Apr 12 11:14:42 CST 2020 pool-1-thread-3 Start. Time = Sun Apr 12 11:14:42 CST 2020 pool-1-thread-2 Start. Time = Sun Apr 12 11:14:42 CST 2020 pool-1-thread-1 End. Time = Sun Apr 12 11:14:47 CST 2020 pool-1-thread-4 End. Time = Sun Apr 12 11:14:47 CST 2020 pool-1-thread-5 End. Time = Sun Apr 12 11:14:47 CST 2020 pool-1-thread-3 End. Time = Sun Apr 12 11:14:47 CST 2020 pool-1-thread-2 End. Time = Sun Apr 12 11:14:47 CST 2020 ------ */ 线程池原理是什么?\n由结果可以看出,线程池先执行5个任务,此时多出的任务会放到队列,那5个任务中有任务执行完的话,会拿新的任务执行\n为了搞懂线程池的原理,我们需要首先分析一下 execute方法。\n我们可以使用 executor.execute(worker)来提交一个任务到线程池中去,这个方法非常重要,下面我们来看看它的源码:\n//源码分析 // 存放线程池的运行状态 (runState) 和线程池内有效线程的数量 (workerCount) private final AtomicInteger ctl = new AtomicInteger(ctlOf(RUNNING, 0)); private static int workerCountOf(int c) { return c \u0026amp; CAPACITY; } private final BlockingQueue\u0026lt;Runnable\u0026gt; workQueue; public void execute(Runnable command) { // 如果任务为null,则抛出异常。 if (command == null) throw new NullPointerException(); // ctl 中保存的线程池当前的一些状态信息 int c = ctl.get(); // 下面会涉及到 3 步 操作 // 1.首先判断当前线程池中执行的任务数量是否小于 corePoolSize // 如果小于的话,通过addWorker(command, true)新建一个线程,并将任务(command)添加到该线程中;然后,启动该线程从而执行任务。 if (workerCountOf(c) \u0026lt; corePoolSize) { if (addWorker(command, true)) return; c = ctl.get(); } // 2.如果当前执行的任务数量大于等于 corePoolSize 的时候就会走到这里 // 通过 isRunning 方法判断线程池状态,线程池处于 RUNNING 状态并且队列可以加入任务,该任务才会被加入进去 if (isRunning(c) \u0026amp;\u0026amp; workQueue.offer(command)) { int recheck = ctl.get(); // 再次获取线程池状态,如果线程池状态不是 RUNNING 状态就需要从任务队列中移除任务,并尝试判断线程是否全部执行完毕。同时执行拒绝策略。 if (!isRunning(recheck) \u0026amp;\u0026amp; remove(command)) reject(command); // 如果当前线程池为空就新创建一个线程并执行。 else if (workerCountOf(recheck) == 0) addWorker(null, false); } //3. 通过addWorker(command, false)新建一个线程,并将任务(command)添加到该线程中;然后,启动该线程从而执行任务。 //如果addWorker(command, false)执行失败,则通过reject()执行相应的拒绝策略的内容。 else if (!addWorker(command, false)) reject(command); } ------ 如图 分析上面的例子,\n我们在代码中模拟了 10 个任务,我们配置的核心线程数为 5 、等待队列容量为 100 ,所以每次只可能存在 5 个任务同时执行,剩下的 5 个任务会被放到等待队列中去。当前的5个任务中如果有任务被执行完了,线程池就会去拿新的任务执行。\naddWorker 这个方法主要用来创建新的工作线程,如果返回 true 说明创建和启动工作线程成功,否则的话返回的就是 false。\n// 全局锁,并发操作必备 private final ReentrantLock mainLock = new ReentrantLock(); // 跟踪线程池的最大大小,只有在持有全局锁mainLock的前提下才能访问此集合 private int largestPoolSize; // 工作线程集合,存放线程池中所有的(活跃的)工作线程,只有在持有全局锁mainLock的前提下才能访问此集合 private final HashSet\u0026lt;Worker\u0026gt; workers = new HashSet\u0026lt;\u0026gt;(); //获取线程池状态 private static int runStateOf(int c) { return c \u0026amp; ~CAPACITY; } //判断线程池的状态是否为 Running private static boolean isRunning(int c) { return c \u0026lt; SHUTDOWN; } /** * 添加新的工作线程到线程池 * @param firstTask 要执行 * @param core参数为true的话表示使用线程池的基本大小,为false使用线程池最大大小 * @return 添加成功就返回true否则返回false */ private boolean addWorker(Runnable firstTask, boolean core) { retry: for (;;) { //这两句用来获取线程池的状态 int c = ctl.get(); int rs = runStateOf(c); // Check if queue empty only if necessary. if (rs \u0026gt;= SHUTDOWN \u0026amp;\u0026amp; ! (rs == SHUTDOWN \u0026amp;\u0026amp; firstTask == null \u0026amp;\u0026amp; ! workQueue.isEmpty())) return false; for (;;) { //获取线程池中工作的线程的数量 int wc = workerCountOf(c); // core参数为false的话表明队列也满了,线程池大小变为 maximumPoolSize if (wc \u0026gt;= CAPACITY || wc \u0026gt;= (core ? corePoolSize : maximumPoolSize)) return false; //原子操作将workcount的数量加1 if (compareAndIncrementWorkerCount(c)) break retry; // 如果线程的状态改变了就再次执行上述操作 c = ctl.get(); if (runStateOf(c) != rs) continue retry; // else CAS failed due to workerCount change; retry inner loop } } // 标记工作线程是否启动成功 boolean workerStarted = false; // 标记工作线程是否创建成功 boolean workerAdded = false; Worker w = null; try { w = new Worker(firstTask); final Thread t = w.thread; if (t != null) { // 加锁 final ReentrantLock mainLock = this.mainLock; mainLock.lock(); try { //获取线程池状态 int rs = runStateOf(ctl.get()); //rs \u0026lt; SHUTDOWN 如果线程池状态依然为RUNNING,并且线程的状态是存活的话,就会将工作线程添加到工作线程集合中 //(rs=SHUTDOWN \u0026amp;\u0026amp; firstTask == null)如果线程池状态小于STOP,也就是RUNNING或者SHUTDOWN状态下,同时传入的任务实例firstTask为null,则需要添加到工作线程集合和启动新的Worker // firstTask == null证明只新建线程而不执行任务 if (rs \u0026lt; SHUTDOWN || (rs == SHUTDOWN \u0026amp;\u0026amp; firstTask == null)) { if (t.isAlive()) // precheck that t is startable throw new IllegalThreadStateException(); workers.add(w); //更新当前工作线程的最大容量 int s = workers.size(); if (s \u0026gt; largestPoolSize) largestPoolSize = s; // 工作线程是否启动成功 workerAdded = true; } } finally { // 释放锁 mainLock.unlock(); } //// 如果成功添加工作线程,则调用Worker内部的线程实例t的Thread#start()方法启动真实的线程实例 if (workerAdded) { t.start(); /// 标记线程启动成功 workerStarted = true; } } } finally { // 线程启动失败,需要从工作线程中移除对应的Worker if (! workerStarted) addWorkerFailed(w); } return workerStarted; } 如何设定线程池的大小\n如果线程池中的线程太多,就会增加上下文切换的成本\n多线程编程中一般线程的个数都大于 CPU 核心的个数,而一个 CPU 核心在任意时刻只能被一个线程使用,为了让这些线程都能得到有效执行,CPU 采取的策略是为每个线程分配时间片并轮转的形式。当一个线程的时间片用完的时候就会重新处于就绪状态让给其他线程使用,这个过程就属于一次上下文切换。概括来说就是:当前任务在执行完 CPU 时间片切换到另一个任务之前会先保存自己的状态,以便下次再切换回这个任务时,可以再加载这个任务的状态。任务从保存到再加载的过程就是一次上下文切换。\n上下文切换通常是计算密集型的。也就是说,它需要相当可观的处理器时间,在每秒几十上百次的切换中,每次切换都需要纳秒量级的时间。所以,上下文切换对系统来说意味着消耗大量的 CPU 时间,事实上,可能是操作系统中时间消耗最大的操作。\nLinux 相比与其他操作系统(包括其他类 Unix 系统)有很多的优点,其中有一项就是,其上下文切换和模式切换的时间消耗非常少。\n过大跟过小都不行\n如果我们设置的线程池数量太小的话,如果同一时间有大量任务/请求需要处理,可能会导致大量的请求/任务在任务队列中排队等待执行,甚至会出现任务队列满了之后任务/请求无法处理的情况,或者大量任务堆积在任务队列导致 OOM 设置线程数量太大,大量线程可能会同时在争取 CPU 资源,这样会导致大量的上下文切换,从而增加线程的执行时间,影响了整体执行效率(对于CPU密集型任务不能使用这个,因为本来CPU资源就紧张,需要设置小一点,减小上下文切换) 简单且适用面较广的公式\nCPU 密集型任务(N+1): 这种任务消耗的主要是 CPU 资源,可以将线程数设置为 N(CPU 核心数)+1,比 CPU 核心数多出来的一个线程是为了防止线程偶发的缺页中断,或者其它原因导致的任务暂停而带来的影响。一旦任务暂停,CPU 就会处于空闲状态,而在这种情况下多出来的一个线程就可以充分利用 CPU 的空闲时间。\nI/O 密集型任务(2N): 这种任务应用起来,系统会用大部分的时间来处理 I/O 交互,而线程在处理 I/O 的时间段内不会占用 CPU 来处理,这时就可以将 CPU 交出给其它线程使用。因此在 I/O 密集型任务的应用中,我们可以多配置一些线程,具体的计算方法是 2N。\n如何判断是CPU密集任务还是IO密集任务\nCPU 密集型简单理解就是利用 CPU 计算能力的任务比如你在内存中对大量数据进行排序。但凡涉及到网络读取,文件读取这类都是 IO 密集型,这类任务的特点是 CPU 计算耗费时间相比于等待 IO 操作完成的时间来说很少,大部分时间都花在了等待 IO 操作完成上。\nAtomic原子类 # Atomic 英[əˈtɒmɪk]原子,即不可分割\n线程中,Atomic,指一个操作是不可中断的,即使在多线程一起执行时,一个操作一旦开始,就不会被其他线程干扰\n原子类,即具有原子/原子操作特性的类。并发包java.util.concurrent原子类都放在java.util.concurrent.atomit Java中存在四种原子类(基本、数组、引用、对象属性)\n基本类型:AtomicInteger,AtomicLong,AtomicBoolean 数组类型:AtomicIntegerArray,AtomicLongArray,AtomicReferenceArray 引用类型:AtomicReference,AtomicStampedReference([原子更新] 带有版本号的引用类型。该类将整数值与引用关联,解决原子的更新数据和数据的版本号,解决使用CAS进行原子更新可能出现的ABA问题),AtomicMarkableReference(原子更新带有标记位的引用类型) 对象属性修改类型:AtomicIntegerFiledUpdater原子更新整型字段的更新器;AtomicLongFiledUpdater;AtomicReferenceFieldUpdater 详见\nAQS # AQS介绍 全程,AbstractQueuedSynchronizer抽象队列同步器,在java.util.concurrent.locks包下 AQS是一个抽象类,主要用来构建锁和同步器\npublic abstract class AbstractQueuedSynchronizer extends AbstractOwnableSynchronizer implements java.io.Serializable { } AQS 为构建锁和同步器提供了一些通用功能的实现,能简单且高效地构造出大量应用广泛的同步器,例如ReentrantLock,Semaphore[ˈseməfɔː(r)]以及ReentrantReadWriteLock,SynchronousQueue 等等都基于AQS\nAQS原理分析\n面试不是背题,大家一定要加入自己的思想,即使加入不了自己的思想也要保证自己能够通俗的讲出来而不是背出来\nAQS 核心思想是,如果被请求的共享资源空闲,则将当前请求资源的线程设置为有效的工作线程,并且将共享资源设置为锁定状态。如果被请求的共享资源被占用,那么就需要一套线程阻塞等待以及被唤醒时锁分配的机制,这个机制 AQS 是用 CLH 队列锁实现的,即将暂时获取不到锁的线程加入到队列中。\nCLH(Craig,Landin and Hagersten)队列是一个虚拟的双向队列(虚拟的双向队列即不存在队列实例,仅存在结点之间的关联关系)。AQS 是将每条请求共享资源的线程封装成一个 CLH 锁队列的一个结点(Node)来实现锁的分配。 搜索了一下,CLH好像是人名\nCLH队列结构如下图所示\nAQS(AbstractQueuedSynchronized)原理图\nAQS 使用 int 成员变量 state 表示同步状态,通过内置的 线程等待队列 来完成获取资源线程的排队工作。\n//state 变量由 volatile 修饰,用于展示当前临界资源的获锁情况。 private volatile int state;//共享变量,使用volatile修饰保证线程可见性 状态信息的操作\n//返回同步状态的当前值 protected final int getState() { return state; } //设置同步状态的值 protected final void setState(int newState) { state = newState; } //原子地(CAS操作)将同步状态值设置为给定值update如果当前同步状态的值等于expect(期望值) protected final boolean compareAndSetState(int expect, int update) { return unsafe.compareAndSwapInt(this, stateOffset, expect, update); } 以 ReentrantLock 为例,state 初始值为 0,表示未锁定状态。A 线程 lock() 时,会调用 tryAcquire() 独占该锁并将 state+1 。此后,其他线程再 tryAcquire() 时就会失败,直到 A 线程 unlock() 到 state=0(即释放锁)为止,其它线程才有机会获取该锁。当然,释放锁之前,A 线程自己是可以重复获取此锁的(state 会累加),这就是可重入的概念。但要注意,获取多少次就要释放多少次,这样才能保证 state 是能回到零态的。\n以 CountDownLatch 为例,任务分为 N 个子线程去执行,state 也初始化为 N(注意 N 要与线程个数一致)。这 N 个子线程是并行执行的,每个子线程执行完后countDown() 一次,state 会 CAS(Compare and Swap) 减 1。等到所有子线程都执行完后(即 state=0 ),会 unpark() 主调用线程,然后主调用线程就会从 await() 函数返回,继续后余动作\n//例子 public class TestCountDownLatch { public static void main(String[] args) { CountDownLatch countDownLatch=new CountDownLatch(3); new Thread(()-\u0026gt;{ try { TimeUnit.SECONDS.sleep(1); } catch (InterruptedException e) { e.printStackTrace(); } System.out.println(Thread.currentThread().getName()+\u0026#34;执行完毕\u0026#34;); countDownLatch.countDown(); },\u0026#34;线程1\u0026#34;).start(); new Thread(()-\u0026gt;{ try { TimeUnit.SECONDS.sleep(2); } catch (InterruptedException e) { e.printStackTrace(); } System.out.println(Thread.currentThread().getName()+\u0026#34;执行完毕\u0026#34;); countDownLatch.countDown(); },\u0026#34;线程2\u0026#34;).start(); new Thread(()-\u0026gt;{ try { TimeUnit.SECONDS.sleep(3); } catch (InterruptedException e) { e.printStackTrace(); } System.out.println(Thread.currentThread().getName()+\u0026#34;执行完毕\u0026#34;); countDownLatch.countDown(); },\u0026#34;线程3\u0026#34;).start(); try { System.out.println(Thread.currentThread().getName()+\u0026#34;等待中....\u0026#34;); countDownLatch.await();//阻塞 System.out.println(Thread.currentThread().getName()+\u0026#34;等待完毕,继续执行\u0026#34;); } catch (InterruptedException e) { e.printStackTrace(); } } } /* main等待中.... 线程1执行完毕 线程2执行完毕 线程3执行完毕 main等待完毕,继续执行 */ Semaphore # Semaphore 有什么用? synchronized 和 ReentrantLock 都是一次只允许一个线程访问某个资源,而Semaphore(信号量)可以用来控制同时访问特定资源的线程数量。\n//使用 public class TestSemaphore { public static void main(String[] args) { Semaphore semaphore = new Semaphore(3);//能同时运行3个 for (int i = 0; i \u0026lt; 15; i++) { int finalI = i; new Thread(() -\u0026gt; { try { semaphore.acquire();//获取通行证 System.out.println(Thread.currentThread().getName() + \u0026#34;执行中...\u0026#34;); TimeUnit.SECONDS.sleep(finalI); System.out.println(Thread.currentThread().getName() + \u0026#34;释放了通行证\u0026#34;); semaphore.release(); } catch (InterruptedException e) { e.printStackTrace(); } },\u0026#34;线程\u0026#34;+finalI).start(); } } } /*结果 线程0执行中... 线程2执行中... 线程1执行中... 线程0释放了通行证 线程3执行中... 线程1释放了通行证 线程4执行中... 线程2释放了通行证 线程5执行中... 线程3释放了通行证 线程6执行中... 线程4释放了通行证 线程7执行中... 线程5释放了通行证 线程8执行中... 线程6释放了通行证 线程10执行中... 线程7释放了通行证 线程11执行中... 线程8释放了通行证 线程9执行中... 线程10释放了通行证 线程12执行中... 线程11释放了通行证 线程13执行中... 线程9释放了通行证 线程14执行中... 线程12释放了通行证 线程13释放了通行证 线程14释放了通行证 */ Semaphore 的使用简单,我们这里假设有 N(N\u0026gt;5) 个线程来获取 Semaphore 中的共享资源,下面的代码表示同一时刻 N 个线程中只有 5 个线程能获取到共享资源,其他线程都会阻塞,只有获取到共享资源的线程才能执行。等到有线程释放了共享资源,其他阻塞的线程才能获取到\n// 初始共享资源数量 final Semaphore semaphore = new Semaphore(5); // 获取1个许可 semaphore.acquire(); // 释放1个许可 semaphore.release(); 当初始的资源个数为 1 的时候,Semaphore 退化为排他锁。\nSemaphore对应的两个构造方法\npublic Semaphore(int permits) { sync = new NonfairSync(permits); } public Semaphore(int permits, boolean fair) { sync = fair ? new FairSync(permits) : new NonfairSync(permits); } 这两个构造方法,都必须提供许可的数量,第二个构造方法可以指定是公平模式还是非公平模式,默认非公平模式。\nSemaphore 通常用于那些资源有明确访问数量限制的场景比如限流(仅限于单机模式,实际项目中推荐使用 Redis +Lua 来做限流)。\nSemaphore 的原理是什么?\nSemaphore 是共享锁的一种实现,它默认构造 AQS 的 state 值为 permits,你可以将 permits 的值理解为许可证的数量,只有拿到许可证的线程才能执行。\n调用semaphore.acquire() ,线程尝试获取许可证,如果 state \u0026gt;= 0 的话,则表示可以获取成功。如果获取成功的话,使用 CAS 操作去修改 state 的值 state=state-1。如果 state\u0026lt;0 的话,则表示许可证数量不足。此时会创建一个 Node 节点加入阻塞队列,挂起当前线程。\n/** * 获取1个许可证 */ public void acquire() throws InterruptedException { sync.acquireSharedInterruptibly(1); } /** * 共享模式下获取许可证,获取成功则返回,失败则加入阻塞队列,挂起线程 */ public final void acquireSharedInterruptibly(int arg) throws InterruptedException { if (Thread.interrupted()) throw new InterruptedException(); // 尝试获取许可证,arg为获取许可证个数,当可用许可证数减当前获取的许可证数结果小于0,则创建一个节点加入阻塞队列,挂起当前线程。 if (tryAcquireShared(arg) \u0026lt; 0) doAcquireSharedInterruptibly(arg); } 调用semaphore.release(); ,线程尝试释放许可证,并使用 CAS 操作去修改 state 的值 state=state+1。释放许可证成功之后,同时会唤醒同步队列中的一个线程。被唤醒的线程会重新尝试去修改 state 的值 state=state-1 ,如果 state\u0026gt;=0 则获取令牌成功,否则重新进入阻塞队列,挂起线程。\n// 释放一个许可证 public void release() { sync.releaseShared(1); } // 释放共享锁,同时会唤醒同步队列中的一个线程。 public final boolean releaseShared(int arg) { //释放共享锁 if (tryReleaseShared(arg)) { //唤醒同步队列中的一个线程 doReleaseShared(); return true; } return false; } CountDownLatch # CountDownLatch有什么用\nCountDownLatch 允许 count 个线程阻塞在一个地方(一般例子是阻塞在主线程中 countDownLatch.await()),直至所有线程的任务都执行完毕**(再从阻塞的地方继续执行)**。 CountDownLatch 是一次性的,计数器的值只能在构造方法中初始化一次,之后没有任何机制再次对其设置值,当 CountDownLatch 使用完毕后,它不能再次被使用。 CountDownLatch的原理是什么 CountDownLatch 是共享锁的一种实现,它默认构造 AQS 的 state 值为 count。当线程使用 countDown() 方法时,其实使用了**tryReleaseShared方法以 CAS 的操作来减少 state,直至 state 为 0 。当调用 await() 方法的时候,如果 state 不为 0,那就证明任务还没有执行完毕,await() 方法就会一直阻塞**,也就是说 await() 方法之后的语句不会被执行。然后,CountDownLatch 会自旋 CAS 判断 state == 0,如果 state == 0 的话,就会释放所有等待的线程,await() 方法之后的语句得到执行。\n用过 CountDownLatch 么?什么场景下用的?\nCountDownLatch 的作用就是 允许 count 个线程阻塞在一个地方,直至所有线程的任务都执行完毕。之前在项目中,有一个使用多线程读取多个文件处理的场景,我用到了 CountDownLatch 。具体场景是下面这样的:\n我们要读取处理 6 个文件,这 6 个任务都是没有执行顺序依赖的任务,但是我们需要返回给用户的时候将这几个文件的处理的结果进行统计整理。\n为此我们定义了一个线程池和 count 为 6 的CountDownLatch对象 。使用线程池处理读取任务,每一个线程处理完之后就将 count-1,调用CountDownLatch对象的 await()方法,直到所有文件读取完之后,才会接着执行后面的逻辑。\n//伪代码 public class CountDownLatchExample1 { // 处理文件的数量 private static final int threadCount = 6; public static void main(String[] args) throws InterruptedException { // 创建一个具有固定线程数量的线程池对象(推荐使用构造方法创建) ExecutorService threadPool = Executors.newFixedThreadPool(10); final CountDownLatch countDownLatch = new CountDownLatch(threadCount); for (int i = 0; i \u0026lt; threadCount; i++) { final int threadnum = i; threadPool.execute(() -\u0026gt; { try { //处理文件的业务操作 //...... } catch (InterruptedException e) { e.printStackTrace(); } finally { //表示一个文件已经被完成 countDownLatch.countDown(); } }); } countDownLatch.await(); //这里应该是要对threadCound个线程的结果,进行汇总 threadPool.shutdown(); System.out.println(\u0026#34;finish\u0026#34;); } } 上面的例子,也可以用CompletableFuture进行改进\nJava8 的 CompletableFuture 提供了很多对多线程友好的方法,使用它可以很方便地为我们编写多线程程序,什么异步、串行、并行或者等待所有线程执行完任务什么的都非常方便。\nCompletableFuture\u0026lt;Void\u0026gt; task1 = CompletableFuture.supplyAsync(()-\u0026gt;{ //自定义业务操作 }); ...... CompletableFuture\u0026lt;Void\u0026gt; task6 = CompletableFuture.supplyAsync(()-\u0026gt;{ //自定义业务操作 }); ...... CompletableFuture\u0026lt;Void\u0026gt; headerFuture=CompletableFuture.allOf(task1,.....,task6); try { headerFuture.join(); } catch (Exception ex) { //...... } System.out.println(\u0026#34;all done. \u0026#34;); 通过循环添加任务\n//文件夹位置 List\u0026lt;String\u0026gt; filePaths = Arrays.asList(...) // 异步处理所有文件 List\u0026lt;CompletableFuture\u0026lt;String\u0026gt;\u0026gt; fileFutures = filePaths.stream() .map(filePath -\u0026gt; doSomeThing(filePath)) .collect(Collectors.toList()); // 将他们合并起来 CompletableFuture\u0026lt;Void\u0026gt; allFutures = CompletableFuture.allOf( fileFutures.toArray(new CompletableFuture[fileFutures.size()]) ); CyclicBarrier # //使用场景,不太一样的是,它一般是让子任务阻塞后,到时候一起执行 public class TestCyclicBarrier { public static void main(String[] args) { CyclicBarrier cyclicBarrier = new CyclicBarrier(3, () -\u0026gt; { try { TimeUnit.SECONDS.sleep(3); } catch (InterruptedException e) { e.printStackTrace(); } System.out.println(Thread.currentThread().getName() + \u0026#34;执行咯\u0026#34;); }); for (int n = 0; n \u0026lt; 15; n++) { int finalN = n; new Thread(() -\u0026gt; { try { TimeUnit.SECONDS.sleep(finalN); System.out.println(Thread.currentThread().getName() + \u0026#34;数据都准备好了,等待中....\u0026#34;); cyclicBarrier.await(); } catch (InterruptedException | BrokenBarrierException e) { e.printStackTrace(); } System.out.println(Thread.currentThread().getName() + \u0026#34;出发咯!\u0026#34;); }, \u0026#34;线程\u0026#34; + n).start(); } } } /* 线程0数据都准备好了,等待中.... 线程1数据都准备好了,等待中.... 线程2数据都准备好了,等待中.... 线程3数据都准备好了,等待中.... 线程4数据都准备好了,等待中.... 线程5数据都准备好了,等待中.... 线程2执行咯 线程2出发咯! 线程6数据都准备好了,等待中.... 线程7数据都准备好了,等待中.... 线程8数据都准备好了,等待中.... 线程5执行咯 线程5出发咯! 线程0出发咯! 线程1出发咯! 线程9数据都准备好了,等待中.... 线程10数据都准备好了,等待中.... 线程11数据都准备好了,等待中.... 线程8执行咯 线程3出发咯! 线程8出发咯! 线程4出发咯! 线程12数据都准备好了,等待中.... 线程13数据都准备好了,等待中.... 线程14数据都准备好了,等待中.... 线程11执行咯 线程11出发咯! 线程6出发咯! 线程7出发咯! 线程14执行咯 线程14出发咯! 线程12出发咯! 线程10出发咯! 线程9出发咯! 线程13出发咯! Process finished with exit code 0 */ CyclicBarrier 有什么用?\nCyclicBarrier 和 CountDownLatch 非常类似,它也可以实现线程间的技术等待,但是它的功能比 CountDownLatch 更加复杂和强大。主要应用场景和 CountDownLatch 类似。\nCountDownLatch 的实现是基于 AQS 的,而 CycliBarrier 是基于 **ReentrantLock(ReentrantLock 也属于 AQS 同步器)**和 Condition 的。\nCyclicBarrier 的字面意思是可循环使用(Cyclic)的屏障(Barrier)。它要做的事情是:让一组线程到达一个屏障(也可以叫同步点)时被阻塞,直到最后一个线程到达屏障时,屏障才会开门,所有被屏障拦截的线程才会继续干活。\nCyclicBarrier的原理\nCyclicBarrier 内部通过一个 count 变量作为计数器,count 的初始值为 parties 属性的初始化值,每当一个线程到了栅栏这里了,那么就将计数器减 1。如果 count 值为 0 了,表示这是这一代最后一个线程到达栅栏,就尝试执行我们构造方法中输入的任务,之后再从线程阻塞的位置继续执行。\n//每次拦截的线程数, 注意:这个是不可变的哦 private final int parties; //计数器 private int count; 结合源码\nCyclicBarrier 默认的构造方法是 CyclicBarrier(int parties),其参数表示屏障拦截的线程数量,每个线程调用 await() 方法告诉 CyclicBarrier 我已经到达了屏障,然后当前线程被阻塞。\npublic CyclicBarrier(int parties) { this(parties, null); } public CyclicBarrier(int parties, Runnable barrierAction) { if (parties \u0026lt;= 0) throw new IllegalArgumentException(); this.parties = parties; this.count = parties; this.barrierCommand = barrierAction; } 其中,parties 就代表了有(需要)拦截的线程的数量,当拦截的线程数量达到这个值的时候就打开栅栏,让所有线程通过。\n当调用 CyclicBarrier 对象调用 await() 方法时,实际上调用的是 dowait(false, 0L)方法。 await() 方法就像树立起一个栅栏的行为一样,将线程挡住了,当拦住的线程数量达到 parties 的值时,栅栏才会打开,线程才得以通过执行\nublic int await() throws InterruptedException, BrokenBarrierException { try { return dowait(false, 0L); } catch (TimeoutException toe) { throw new Error(toe); // cannot happen } } dowait(false,0L)方法源码如下\n// 当线程数量或者请求数量达到 count 时 await 之后的方法才会被执行。上面的示例中 count 的值就为 5。 private int count; /** * Main barrier code, covering the various policies. */ private int dowait(boolean timed, long nanos) throws InterruptedException, BrokenBarrierException, TimeoutException { final ReentrantLock lock = this.lock; // 锁住 lock.lock(); try { final Generation g = generation; if (g.broken) throw new BrokenBarrierException(); // 如果线程中断了,抛出异常 if (Thread.interrupted()) { breakBarrier(); throw new InterruptedException(); } // cout减1 int index = --count; // 当 count 数量减为 0 之后说明最后一个线程已经到达栅栏了,也就是达到了可以执行await 方法之后的条件 if (index == 0) { // tripped boolean ranAction = false; try { final Runnable command = barrierCommand; if (command != null) command.run(); ranAction = true; // 将 count 重置为 parties 属性的初始化值 // 唤醒之前等待的线程 // 下一波执行开始 nextGeneration(); return 0; } finally { if (!ranAction) breakBarrier(); } } // loop until tripped, broken, interrupted, or timed out for (;;) { try { if (!timed) trip.await(); else if (nanos \u0026gt; 0L) nanos = trip.awaitNanos(nanos); } catch (InterruptedException ie) { if (g == generation \u0026amp;\u0026amp; ! g.broken) { breakBarrier(); throw ie; } else { // We\u0026#39;re about to finish waiting even if we had not // been interrupted, so this interrupt is deemed to // \u0026#34;belong\u0026#34; to subsequent execution. Thread.currentThread().interrupt(); } } if (g.broken) throw new BrokenBarrierException(); if (g != generation) return index; if (timed \u0026amp;\u0026amp; nanos \u0026lt;= 0L) { breakBarrier(); throw new TimeoutException(); } } } finally { lock.unlock(); } } 三者区别 # CountDownLatch也能实现CyclicBarrier类似功能,不过它的栅栏被推到后就不会再重新存在了(CyclicBarrier会重新建立栅栏)\nCountDownLatch countDownLatch=new CountDownLatch(5); new Thread(()-\u0026gt;{ try { countDownLatch.await(); log.info(\u0026#34;栅栏被推开了\u0026#34;); } catch (InterruptedException e) { e.printStackTrace(); } },\u0026#34;线程A\u0026#34;).start(); new Thread(()-\u0026gt;{ try { countDownLatch.await(); log.info(\u0026#34;栅栏被推开了\u0026#34;); } catch (InterruptedException e) { e.printStackTrace(); } },\u0026#34;线程B\u0026#34;).start(); new Thread(()-\u0026gt;{ try { countDownLatch.await(); log.info(\u0026#34;栅栏被推开了\u0026#34;); } catch (InterruptedException e) { e.printStackTrace(); } },\u0026#34;线程C\u0026#34;).start(); new Thread(()-\u0026gt;{ try { countDownLatch.await(); log.info(\u0026#34;栅栏被推开了\u0026#34;); } catch (InterruptedException e) { e.printStackTrace(); } },\u0026#34;线程D\u0026#34;).start(); new Thread(()-\u0026gt;{ try { countDownLatch.await(); log.info(\u0026#34;栅栏被推开了\u0026#34;); } catch (InterruptedException e) { e.printStackTrace(); } },\u0026#34;线程E\u0026#34;).start(); new Thread(()-\u0026gt;{ //countDownLatch.await(); for(int i=0;i\u0026lt;5;i++) { //每隔一秒钟推开一个栅栏 try { TimeUnit.SECONDS.sleep(1); } catch (InterruptedException e) { e.printStackTrace(); } log.info(\u0026#34;推开一个栅栏\u0026#34;); countDownLatch.countDown(); } },\u0026#34;线程F\u0026#34;).start(); while (true){} /**输出 2023-03-07 23:23:19 下午 [Thread: 线程F] INFO:推开一个栅栏 2023-03-07 23:23:20 下午 [Thread: 线程F] INFO:推开一个栅栏 2023-03-07 23:23:21 下午 [Thread: 线程F] INFO:推开一个栅栏 2023-03-07 23:23:22 下午 [Thread: 线程F] INFO:推开一个栅栏 2023-03-07 23:23:24 下午 [Thread: 线程F] INFO:推开一个栅栏 ///////////////////////////////////////////推开5个栅栏后(这里是一个线程推开五个,也可以5个线程-\u0026gt;每个各推开一个),5个被阻塞的线程一起执行了 2023-03-07 23:23:24 下午 [Thread: 线程A] INFO:栅栏被推开了 2023-03-07 23:23:24 下午 [Thread: 线程E] INFO:栅栏被推开了 2023-03-07 23:23:24 下午 [Thread: 线程C] INFO:栅栏被推开了 2023-03-07 23:23:24 下午 [Thread: 线程D] INFO:栅栏被推开了 2023-03-07 23:23:24 下午 [Thread: 线程B] INFO:栅栏被推开了 */ "},{"id":327,"href":"/zh/docs/technology/Review/java_guide/java/Concurrent/ly03122lylock_escalation/","title":"锁升级","section":"并发","content":" 以下内容均转自 https://www.cnblogs.com/wuqinglong/p/9945618.html,部分疑惑参考自另一作者 https://github.com/farmerjohngit/myblog/issues/12 ,感谢原作者。\n【目前还是存有部分疑虑(轻量级锁那块),可能需要详细看源码才能释疑】\n概述 # 传统的synchronized为重量级锁(使用操作系统互斥量(mutex)来实现的传统锁),但是随着JavaSE1.6对synchronized优化后,部分情况下他就没有那么重了。本文介绍了JavaSE1.6为了减少获得锁和释放锁带来的性能消耗而引入的偏向锁和轻量级锁,以及锁结构、及锁升级过程\n实现同步的基础 # Java中每个对象都可以作为锁,具体变现形式\n对于普通同步方法,锁是当前实例对象 对于静态同步方法,锁是当前类的Class对象 对于同步方法块,锁是synchronized括号里配置的对象 一个线程试图访问同步代码块时,必须获取锁;在退出或者抛出异常时,必须释放锁\n实现方式 # JVM 基于进入和退出 Monitor 对象来实现方法同步和代码块同步,但是两者的实现细节不一样\n代码块同步:通过使用 monitorenter 和 monitorexit 指令实现的 同步方法:ACC_SYNCHRONIZED 修饰 monitorenter 指令是在编译后插入到同步代码块的开始位置,而 monitorexit 指令是在编译后插入到同步代码块的结束处或异常处\n对于同步方法,进入方法前添加一个 monitorenter 指令,退出方法后添加一个 monitorexit 指令。\ndemo:\npublic class Demo { public void f1() { synchronized (Demo.class) { System.out.println(\u0026#34;Hello World.\u0026#34;); } } public synchronized void f2() { System.out.println(\u0026#34;Hello World.\u0026#34;); } } 编译之后的字节码(使用 javap )\npublic void f1(); descriptor: ()V flags: ACC_PUBLIC Code: stack=2, locals=3, args_size=1 0: ldc #2 // class me/snail/base/Demo 2: dup 3: astore_1 4: monitorenter 5: getstatic #3 // Field java/lang/System.out:Ljava/io/PrintStream; 8: ldc #4 // String Hello World. 10: invokevirtual #5 // Method java/io/PrintStream.println:(Ljava/lang/String;)V 13: aload_1 14: monitorexit 15: goto 23 18: astore_2 19: aload_1 20: monitorexit 21: aload_2 22: athrow 23: return Exception table: from to target type 5 15 18 any 18 21 18 any LineNumberTable: line 6: 0 line 7: 5 line 8: 13 line 9: 23 StackMapTable: number_of_entries = 2 frame_type = 255 /* full_frame */ offset_delta = 18 locals = [ class me/snail/base/Demo, class java/lang/Object ] stack = [ class java/lang/Throwable ] frame_type = 250 /* chop */ offset_delta = 4 public synchronized void f2(); descriptor: ()V flags: ACC_PUBLIC, ACC_SYNCHRONIZED Code: stack=2, locals=1, args_size=1 0: getstatic #3 // Field java/lang/System.out:Ljava/io/PrintStream; 3: ldc #4 // String Hello World. 5: invokevirtual #5 // Method java/io/PrintStream.println:(Ljava/lang/String;)V 8: return LineNumberTable: line 12: 0 line 13: 8 先说 f1() 方法,发现其中一个 monitorenter 对应了两个 monitorexit,这是不对的。但是仔细看 #15: goto 语句,直接跳转到了 #23: return 处,再看 #22: athrow 语句发现,原来第二个 monitorexit 是保证同步代码块抛出异常时锁能得到正确的释放而存在的,这就理解了。\nJava对象头(存储锁类型) # HotSpot虚拟机中,对象在内存中的布局分为三块区域:对象头、实例数据、对齐填充\n对象头又包括两部分:MarkWord和类型指针,对于数组对象,对象头中还有一部分时存储数组的长度\n多线程下synchronized的加锁,就是对同一个对象的对象头中的MarkWord中的变量进行CAS操作\nMarkWord\n类型指针 虚拟机通过这个指针确定该对象是哪个类的实例\n对象头的长度\n长度 内容 说明 32/64bit MarkWord 存储对象的hashCode或锁信息等 32/64bit Class Metadada Address 存储对象类型数据的指针 32/64bit Array Length 数组的长度(如果当前对象是数组) 如果是数组对象的话,虚拟机用3个字宽(32/64bit + 32/64bit + 32/64bit)存储对象头,如果是普通对象的话,虚拟机用2字宽存储对象头(32/64bit + 32/64bit)。\n32位的字宽为32bit,64位的字宽位64bit\n优化后synchronized锁的分类 # 级别从低到高依次是:无锁状态 -\u0026gt; 偏向锁状态 -\u0026gt; 轻量级锁状态 -\u0026gt; 重量级锁状态\n锁可以升级,但不能降级,即顺序为单向\n下面以32位系统为例,每个锁状态下,每个字宽中的内容\n无锁状态\n25bit 4bit 1bit(是否是偏向锁) 2bit(锁标志位) 对象的hashCode 对象分代年龄 0 01 这里的 hashCode 是 Object#hashCode 或者 System#identityHashCode 计算出来的值,不是用户覆盖产生的 hashCode。\n偏向锁状态\n25bit 4bit 1bit(是否是偏向锁) 2bit(锁标志位) 线程ID epoch 1 01 这里 线程ID 和 epoch 占用了 hashCode 的位置,所以,如果对象如果计算过 identityHashCode 后,便无法进入偏向锁状态,反过来,如果对象处于偏向锁状态,并且需要计算其 identityHashCode 的话,则偏向锁会被撤销,升级为重量级锁。 对于偏向锁,如果线程ID=0 表示为加锁\n什么时候会计算 HashCode 呢?比如:将对象作为 Map 的 Key 时会自动触发计算,List 就不会计算,日常创建一个对象,持久化到库里,进行 json 序列化,或者作为临时对象等,这些情况下,并不会触发计算 hashCode,所以大部分情况不会触发计算 hashCode。\nIdentity hash code是未被覆写的 java.lang.Object.hashCode() 或者 java.lang.System.identityHashCode(Object) 所返回的值。\n轻量级锁状态\n30bit 2bit 指向 线程栈 锁记录的指针 00 这里指向栈帧中的LockRecord记录,里面当然可以记录对象的identityHashCode\n重量级锁状态\n30bit 2bit 指向锁监视器的指针 10 这里指向了内存中对象的 ObjectMonitor 对象,而 ObectMontitor 对象可以存储对象的 identityHashCode 的值。\n锁的升级 # 偏向锁 # 偏向锁是针对于一个线程而言的,线程获得锁之后就不会再有解锁等操作了,这样可以省略很多开销。假如有两个线程来竞争该锁话,那么偏向锁就失效了,进而升级成轻量级锁了【注意这段解释,网上很多都错了,没有什么CAS失败才升级,只要有线程来抢,就直接升级为轻量级锁】\n为什么要这样做呢?因为经验表明,其实大部分情况下,都会是同一个线程进入同一块同步代码块的。这也是为什么会有偏向锁出现的原因。\n如果支持偏向锁(没有计算 hashCode),那么在分配(创建)对象时,分配一个可偏向而未偏向的对象(MarkWord的最后 3 位为 101,并且 Thread Id 字段的值为 0)\n1. 偏向锁的加锁 # 偏向锁标志是未偏向状态,使用 CAS 将 MarkWord 中的线程ID设置为自己的线程ID\n如果成功,则获取偏向锁成功 如果失败,则进行锁升级(也就是被别人抢了,没抢过) 偏向锁状态是已偏向状态\nMarkWord中的线程ID是自己的线程ID,则成功获取锁\nMarkWord中的线程ID不是自己的线程ID,则需要进行锁升级\n注意,这里说的锁升级,需要进行偏向锁的撤销\n2. 偏向锁的撤销 # 前提:撤销偏向的操作需要在全局检查点执行 。我们假设线程A曾经拥有锁(不确定是否释放锁), 线程B来竞争锁对象,如果当线程A不再拥有锁时或者死亡时,线程B直接去尝试获得锁(根据是否 允许重偏向(rebiasing),获得偏向锁或者轻量级锁);如果线程A仍然拥有锁,那么锁 升级为轻量级锁,线程B自旋请求获得锁。\n对象是不可偏向状态 不需要撤销\n对象是可偏向状态\n如果MarkWord中指向的线程不存活 (这里说的是拥有偏向锁的线程正常执行完毕后释放锁,不存活那一定要释放锁咯) 如果允许重偏向(rebiasing),则退回到可偏向但未偏向的状态;如果不允许重偏向,则变为无锁状态 如果MarkWord中的线程仍然存活(注意,关注的是存活,不是是否拥有锁) (这里说的是拥有偏向锁的线程未执行完毕但进行了锁撤销:(包括释放锁及未释放锁(有线程来抢)两种情形)) 如果线程ID指向的线程仍然拥有锁,则**★★升级为轻量级锁,MarkWord复制到线程栈中(很重要)★★;如果线程ID不再拥有锁**(那个线程已经释放了锁),则同样是退回到可偏向(如果允许)但未偏向的状态(即线程ID未空),如果不允许重偏向,则变为无锁状态 偏向锁的撤销流程如图:\n轻量级锁 # 之所以称为轻量级,是因为它仅仅使用CAS进行操作,实现获取锁\n1. 加锁流程 # 如果线程发现对象头中Mark Word已经存在指向自己栈帧的指针,即线程已经获得轻量级锁,那么只需要将0存储在自己的栈帧中(此过程称为递归加锁);在解锁的时候,如果发现锁记录的内容为0, 那么只需要移除栈帧中的锁记录即可,而不需要更新Mark Word。\n线程尝试使用 CAS 将对象头中的 Mark Word 替换为指向锁记录(Lock Record)的指针(★如果当前锁的状态不是无锁状态,则CAS失败★很重要,不然后面有一堆疑问),如果成功当前线程获得轻量级锁, 如上图所示。(我觉得**★这里的CAS,原值为原来的markword,而不是指向其他线程的线程栈地址,否则这样意义就不对了,会导致别的线程执行到一半失去锁【注意:要结合下面的撤销流程看,锁是不会降级的,但是会撤销。撤销后对象头就变为加锁前了(但不是01哦,轻量级锁是00)】★**)\n如果成功,当前线程获得轻量级锁 如果失败,虚拟机先检查当前对象头的 Mark Word 是否指向当前线程的栈帧 如果指向,则说明当前线程已经拥有这个对象的锁,则可以直接进入同步块 执行操作 否则表示其他线程竞争锁,当前线程便尝试使用自旋来获取锁。当竞争线程的自旋次数 达到界限值(threshold),轻量级锁将会膨胀为重量级锁。 2. 撤销流程 # 轻量级锁解锁时,如果对象的Mark Word仍然指向着线程的锁记录(LockRecord),会使用CAS操作, 将Dispalced Mark Word替换到对象头,如果成功,则表示没有竞争发生。如果失败, 表示当前锁存在锁竞争,锁就会膨胀为重量级锁。\n重量级锁 # 重量级锁(heavy weight lock),是使用操作系统互斥量(mutex)来实现的传统锁。 当所有对锁的优化都失效时,将退回到重量级锁。它与轻量级锁不同竞争的线程不再通过自旋来竞争线程, 而是直接进入堵塞状态,此时不消耗CPU,然后等拥有锁的线程释放锁后,唤醒堵塞的线程, 然后线程再次竞争锁。但是注意,当锁膨胀(inflate)为重量锁时,就不能再退回到轻量级锁。\n总结 # 首先要明确一点是引入这些锁是为了提高获取锁的效率, 要明白每种锁的使用场景, 比如偏向锁适合一个线程对一个锁的多次获取的情况; 轻量级锁适合锁执行体比较简单(即减少锁粒度或时间), 自旋一会儿就可以成功获取锁的情况.\n要明白MarkWord中的内容表示的含义.\n"},{"id":328,"href":"/zh/docs/technology/Review/java_guide/java/Concurrent/lock_escalation_deprecated2/","title":"(该文弃用)锁升级","section":"并发","content":"本文主要讲解synchronized原理和偏向锁、轻量级锁、重量级锁的升级过程,基本都转自\nhttps://blog.csdn.net/MariaOzawa/article/details/107665689 原作者: MariaOzawa\n简介 # 为什么需要锁\n并发编程中,多个线程访问同一共享资源时,必须考虑如何维护数据的原子性 历史 JDK1.5之前,Java依靠Synchronized关键字实现锁功能,Synchronized是Jvm实现的内置锁,锁的获取与释放由JVM隐式实现 JDK1.5,并发包新增Lock接口实现锁功能,提供同步功能,使用时显式获取和释放锁 区别 Lock同步锁基于Java实现,Synchronized基于底层操作系统的MutexLock实现 /ˈmjuːtɛks/ ,每次获取和释放锁都会带来用户态和内核态的切换,从而增加系统性能开销,性能糟糕,又称重量级锁 JDK1.6之后,对Synchronized同步锁做了充分优化 Synchronized同步锁实现原理 # Synchronized实现同步锁的两种方式:修饰方法;修饰方法块\n// 关键字在实例方法上,锁为当前实例 public synchronized void method1() { // code } // 关键字在代码块上,锁为括号里面的对象 public void method2() { Object o = new Object(); synchronized (o) { // code } } 这里使用编译\u0026ndash;及javap 打印字节文件\njavac -encoding UTF-8 SyncTest.java //先运行编译class文件命令 javap -v SyncTest.class //再通过javap打印出字节文件 结果如下,Synchronized修饰代码块时,由monitorenter和monitorexist指令实现同步。进入monitorenter指令后线程持有Monitor对象;退出monitorenter指令后,线程释放该Monitor对象\npublic void method2(); descriptor: ()V flags: ACC_PUBLIC Code: stack=2, locals=4, args_size=1 0: new #2 3: dup 4: invokespecial #1 7: astore_1 8: aload_1 9: dup 10: astore_2 11: monitorenter //monitorenter 指令 12: aload_2 13: monitorexit //monitorexit 指令 14: goto 22 17: astore_3 18: aload_2 19: monitorexit 20: aload_3 21: athrow 22: return Exception table: from to target type 12 14 17 any 17 20 17 any LineNumberTable: line 18: 0 line 19: 8 line 21: 12 line 22: 22 StackMapTable: number_of_entries = 2 frame_type = 255 /* full_frame */ offset_delta = 17 locals = [ class com/demo/io/SyncTest, class java/lang/Object, class java/lang/Object ] stack = [ class java/lang/Throwable ] frame_type = 250 /* chop */ offset_delta = 4 如果Synchronized修饰同步方法,代替monitorenter和monitorexit的是 ACC_SYNCHRONIZED标志,即:JVM使用该访问标志区分方法是否为同步方法。方法调用时,调用指令检查是否设置ACC_SYNCHRONIZED标志,如有,则执行线程先持有该Monitor对象,再执行该方法;运行期间,其他线程无法获取到该Monitor对象;方法执行完成后,释放该Monitor对象 javap -v xx.class 字节文件查看\npublic synchronized void method1(); descriptor: ()V flags: ACC_PUBLIC, ACC_SYNCHRONIZED // ACC_SYNCHRONIZED 标志 Code: stack=0, locals=1, args_size=1 0: return LineNumberTable: line 8: 0 Monitor:JVM中的同步是基于进入和退出管程(Monitor)对象实现的。每个对象实例都会有一个Monitor,Monitor可以和对象一起创建、销毁。Monitor由ObjectMonitor实现,而ObjectMonitor由C++的ObjectMonitor.hpp文件实现,如下:\nObjectMonitor() { _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状态的线程,会被加入到该列表(Contention List中那些有资格成为候选资源的线程被移动到Entry List中;) _SpinFreq = 0 ; _SpinClock = 0 ; OwnerIsThread = 0 ; } //Contention List:竞争队列,所有请求锁的线程首先被放在这个竞争队列中 如上,多个线程同时访问一段同步代码时,多个线程会先被存放在ContentionList和**_EntryList**集合中,处于block状态的线程都会加入该列表。 当线程获取到对象的Monitor时,Monitor依靠底层操作系统的MutexLock实现互斥,线程申请Mutex成功,则持有该Mutex,其他线程无法获取;竞争失败的线程再次进入ContentionList被挂起 如果线程调用wait()方法,则会释放当前持有的Mutex,并且该线程进入WaitSet集合中,等待下一次被唤醒(或者顺利执行完方法也会释放Mutex) 锁升级 # 为了提升性能,Java1.6,引入了偏向锁、轻量级锁、重量级锁,来减少锁竞争带来的上下文切换,由新增的Java对象头实现了锁升级。锁只能升级不能降级,目的是提高获得锁和释放锁的效率 当Java对象被Synchronized关键字修饰为同步锁后,围绕这个锁的一系列升级操作都和Java对象头有关 JDK1.6 JVM中,对象实例在堆内存中被分为三个部分:对象头、实例数据和对齐填充。其中对象头由MarkWord、指向类的指针以及数组长度三部分组成 MarkWord记录了对象和锁相关的信息,它在64为JVM的长度是64bit,下图为64位JVM的存储结构: 32位如下 锁标志位是两位,无锁和偏向锁的锁标志位实际为01,轻量级锁的锁标志位为00 锁升级功能,主要依赖于MarkWord中的锁标志位和释放偏向锁标志位,Synchronized同步锁,是从偏向锁开始的,随着竞争越来越激烈,偏向锁升级到轻量级锁,最终升级到重量级锁 =================================从这之后往下,是有误的的============================= # 偏向锁 # JVM会为每个当前线程的栈帧中,创建用于存储锁记录的空间,官方称为Displaced Mark Word(轻量级锁会用到)\n为什么引入偏向锁\n多数情况,锁不仅不存在多线程竞争,且经常由同一线程获得,为了在这种情况让线程获得锁的代价更低而引入了偏向锁。例如:线程操作一个线程安全集合时,同一线程每次都需要获取和释放锁,则每次操作都会发生用户态和内核态的切换(重量级锁)\n解决方案(偏向锁的作用)\n当一个线程再次访问这个同步代码或方法时,该线程只需去对象头的MarkWord中,判断一下是否有偏向锁指向该线程的ID,而无需再进入Monitor去竞争对象 当对象被当作同步锁并有一个线程抢到了锁,锁标志位还是01,是否偏向锁标志位为1,并且记录抢到锁的线程ID,表示进入偏向锁状态 偏向锁的撤销 一旦出现其他线程竞争锁资源(竞争且CAS失败)时,偏向锁就会被撤销。偏向锁的撤销需要等待全局安全点,暂停持有该锁的线程,同时检查该线程是否还在执行该方法,如果是升级锁,反之(该锁)被其他线程抢占\n注:对于“CAS操作替换线程ID”这个解释,我的理解是:\n偏向锁是不会被主动释放的 偏向锁默认开启(JDK15默认关闭),如果应用程序里所有的锁通常情况下处于竞争状态,此时可以添加JVM参数关闭偏向锁来调优系统性能\n-XX:-UseBiasedLocking //关闭偏向锁(默认打开) 轻量级锁 # 何时升级为轻量级锁 当有另外一个线程获取这个锁,由于该锁已经是偏向锁,当发现对象头MarkWord中的线程ID不是自己的线程ID,就会进行CAS操作获取锁 如果获取成功,直接替换MarkWord中的线程ID为自己ID,该锁把持偏向锁状态 如果获取失败,代表当前锁有一定的竞争,偏向锁将升级为轻量级锁 适用场景 **”绝大部分的锁,在整个同步周期内都不存在长时间的竞争“**的场景 "},{"id":329,"href":"/zh/docs/problem/Linux/20221101/","title":"post","section":"Linux","content":" 在安装可视化的时候,出现需要libmysqlclient.so.18()(64bit)解决方案\n将mysql卸载即可 http://wenfeifei.com/art/detail/yGM1BG4\n"},{"id":330,"href":"/zh/docs/technology/Review/java_guide/java/Concurrent/lock_escalation_deprecated/","title":"(该文弃用)锁升级","section":"并发","content":" 简介 # 无锁 =\u0026gt; 偏向锁 =\u0026gt; 轻量锁 =\u0026gt; 重量锁\n复习Class类锁和实例对象锁,说明Class类锁和实例对象锁不是同一把锁,互相不影响\npublic static void main(String[] args) throws InterruptedException { Object object=new Object(); new Thread(()-\u0026gt;{ synchronized (Customer.class){ System.out.println(Thread.currentThread().getName()+\u0026#34;Object.class类锁\u0026#34;); try { TimeUnit.SECONDS.sleep(5); } catch (InterruptedException e) { e.printStackTrace(); } } System.out.println(Thread.currentThread().getName()+\u0026#34;结束并释放锁\u0026#34;); },\u0026#34;线程1\u0026#34;).start(); //保证线程1已经获得类锁 try { TimeUnit.SECONDS.sleep(2); } catch (InterruptedException e) { e.printStackTrace(); } new Thread(()-\u0026gt;{ synchronized (object){ System.out.println(Thread.currentThread().getName()+\u0026#34;获得object实例对象锁\u0026#34;); } System.out.println(Thread.currentThread().getName()+\u0026#34;结束并释放锁\u0026#34;); },\u0026#34;线程2\u0026#34;).start(); } /* 输出 线程1Object.class类锁 线程2获得object实例对象锁 线程2结束并释放锁 线程1结束并释放锁 */ 总结图 , 00 , 01 , 10 ,没有11\n001(无锁)和101(偏向锁),00(轻量级锁),10(重量级锁)\n背景 # 下面这部分,其实在io模块有提到过\n为了保证系统稳定性和安全性,一个进程的地址空间划分为用户空间User space和内核空间Kernel space 平常运行的应用程序都运行在用户空间,只有内核空间才能进行系统态级别的资源有关操作\u0026mdash;文件管理、进程通信、内存管理 如果直接synchronized加锁,会有下面图的流程出现,频繁进行用户态和内核态的切换(阻塞和唤醒线程[线程通信],需要频繁切换cpu的状态)\n为什么每一个对象都可以成为一个锁 markOop.hpp (对应对象标识) 每一个java对象里面,有一个Monitor对象(ObjectMonitor.cpp)关联 如图,_owner指向持有ObjectMonitor对象的线程 Monitor本质依赖于底层操作系统的MutexLock实现,操作系统实现线程之间的切换,需要从用户态到内核态的切换,成本极高 ★★ 重点:Monitor与Java对象以及线程是如何关联 如果一个java对象被某个线程锁住,则该对象的MarkWord字段中,LockWord指向monitor的起始地址(这里说的应该是重量级锁) Monitor的Owner字段会存放拥有相关联对象锁的线程id 图 锁升级 # synchronized用的锁,存在Java对象头里的MarkWord中,锁升级功能主要依赖MarkWord中锁标志位(后2位)和释放偏向锁标志位(无锁和偏向锁,倒数第3位)\n对于锁的指向\n无锁情况:(放hashcode(调用了Object.hashcode才有)) 偏向锁:MarkWord存储的是偏向的线程ID 轻量锁:MarkWord存储的是指向线程栈中LockRecord的指针 重量锁:MarkWord存储的是指向堆中的monitor对象的指针 =================================从这之后往下,是有误的的============================= # 无锁状态 初始状态,一个对象被实例化后,如果还没有任何线程竞争锁,那么它就为无锁状态(001)\npublic static void main(String[] args) { Object o = new Object(); System.out.println(ClassLayout.parseInstance(o).toPrintable()); //16字节 } /* 输出( 这里的mark,VALUE为0x0000000000000001,没有hashCode的值): java.lang.Object object internals: OFF SZ TYPE DESCRIPTION VALUE 0 8 (object header: mark) 0x0000000000000001 (non-biasable; age: 0) 8 4 (object header: class) 0xf80001e5 12 4 (object alignment gap) Instance size: 16 bytes Space losses: 0 bytes internal + 4 bytes external = 4 bytes total */ 下面是调用了hashCode()这个方法的情形:\npublic static void main(String[] args) { Object o = new Object(); System.out.println(Integer.toHexString(o.hashCode())); System.out.println(ClassLayout.parseInstance(o).toPrintable()); //16字节 } /**输出: 74a14482 java.lang.Object object internals: OFF SZ TYPE DESCRIPTION VALUE 0 8 (object header: mark) 0x00000074a1448201 (hash: 0x74a14482; age: 0) 8 4 (object header: class) 0xf80001e5 12 4 (object alignment gap) Instance size: 16 bytes Space losses: 0 bytes internal + 4 bytes external = 4 bytes total */ 偏向锁:单线程竞争\n当线程A第一次竞争到锁时,通过操作修改MarkWord中的偏向线程ID、偏向模式。如果不存在其他线程竞争,那么持有偏向锁的线程将永远不需要同步\n如果没有偏向锁,那么就会频繁出现用户态到内核态的切换\n意义:当一段同步代码,一直被同一个线程多次访问,由于只有一个线程那么该线程在后续访问时便会自动获得锁 锁在第一次被拥有的时候,记录下偏向线程ID(后续这个线程进入和退出这段加了同步锁的代码块时,不需要再次加锁和释放锁,只需要直接检查锁的MarkWord是不是放的自己的线程ID)\n如果相等,表示偏向锁是偏向于当前线程的,不需要再尝试获得锁,直到竞争才会释放锁;以后每次同步,检查锁的偏向线程ID与当前线程ID是否一致,若一致则进入同步,无需每次都加锁解锁去CAS更新对象头;如果自始至终使用锁的线程只有一个,很明显偏向锁几乎没有额外开销 如果不等,表示发生了竞争,锁已经不偏向于同一个线程,此时会尝试使用CAS来替换MarkWord里面的线程ID为新线程的ID 竞争成功,说明之前线程不存在了,MarkWord里的线程ID为新线程ID,所不会升级,仍然为偏向锁 竞争失败,需要升级为轻量级锁,才能保证线程间公平竞争锁 偏向锁只有遇到其他线程尝试竞争偏向锁时,持有偏向锁的线程才会释放锁,线程是不会主动释放锁的(尽量不会涉及用户到内核态转换)\n一个synchronized方法被一个线程抢到锁时,这个方法所在的对象,就会在其所在的MarkWord中**将偏向锁修改状态位\n如图\nJVM不用和操作系统协商设置Mutex(争取内核),不需要操作系统介入\n偏向锁相关参数\njava -XX:+PrintFlagsInitial | grep BiasedLock* intx BiasedLockingBulkRebiasThreshold = 20 {product} intx BiasedLockingBulkRevokeThreshold = 40 {product} intx BiasedLockingDecayTime = 25000 {product} intx BiasedLockingStartupDelay = 4000 #偏向锁启动延迟 4s {product} bool TraceBiasedLocking = false {product} bool UseBiasedLocking = true #默认开启偏向锁 {product} # 使用-XX:UseBiasedLocking 关闭偏向锁 例子:\npublic static void main(String[] args) throws InterruptedException { TimeUnit.SECONDS.sleep(5); //1 如果1跟下面的2兑换,则就不是偏向锁,是否是偏向锁,在创建对象的时候,就已经确认了 Object o = new Object(); //2 //System.out.println(Integer.toHexString(o.hashCode())); synchronized (o){ } System.out.println(ClassLayout.parseInstance(o).toPrintable()); //16字节 } //延迟5秒(\u0026gt;4)后,就会看到偏向锁 /* 打印,005,即二进制101 java.lang.Object object internals: OFF SZ TYPE DESCRIPTION VALUE 0 8 (object header: mark) 0x0000000002f93005 (biased: 0x000000000000be4c; epoch: 0; age: 0) 8 4 (object header: class) 0xf80001e5 12 4 (object alignment gap) Instance size: 16 bytes Space losses: 0 bytes internal + 4 bytes external = 4 bytes total */ 偏向锁的升级\n是一种等到竞争出现才释放锁的机制,只有当其他线程竞争锁时,持有偏向锁的原来线程才会被撤销;撤销需要等待全局安全点(该时间点没有字节码在执行),同时检查持有偏向锁的线程是否还在执行 如果此时第一个线程正在执行synchronized方法(处于同步块),还没执行完其他线程来抢,该偏向锁被取消并出现锁升级;此时轻量级锁由原持有偏向锁的线程持有,继续执行其同步代码,而正在竞争的线程会进入自旋等待获得该轻量级锁 如果第一个线程执行完成synchronized方法(退出同步块),而将对象头设置成无锁状态并撤销偏向锁,重新偏向 Java15之后,HotSpot不再默认开启偏向锁,使用+XX:UseBiasedLocking手动开启\n偏向锁流程总结 (转自https://blog.csdn.net/MariaOzawa/article/details/107665689) 轻量级锁 主要是为了在线程近乎交替执行同步块时提高性能 升级时机,当关闭偏向锁或多线程竞争偏向锁会导致偏向锁升级为轻量级锁 标志位为00\n"},{"id":331,"href":"/zh/docs/technology/Review/java_guide/java/Concurrent/ly03121lyobject-concurrent/","title":"对象内存布局和对象头","section":"并发","content":" 对象布局 # heap (where): new (eden ,s0 ,s1) ,old, metaspace\n对象的构成元素(what) HotSpot虚拟机里,对象在堆内存中的存储布局分为三个部分 对象头(Header) 对象标记 MarkWord 类元信息(类型指针 Class Pointer,指向方法区的地址) 对象头多大 length(数组才有) 实例数据(Instance Data) 对其填充(Padding,保证整个对象大小,是8个字节的倍数) 对象头 # 对象标记\nObject o= new Object(); //new一个对象,占内存多少 o.hashCode() //hashCode存在对象哪个地方 synchronized(o){ } //对象被锁了多少次(可重入锁) System.gc(); //躲过了几次gc(次数) 上面这些,哈希码、gc标记、gc次数、同步锁标记、偏向锁持有者,都保存在对象标记里面 如果在64位系统中,对象头中,**mark word(对象标记)**占用8个字节(64位);**class pointer(类元信息)**占用8个字节,总共16字节(忽略压缩指针) 无锁的时候, 类型指针 注意下图,指向方法区中(模板)的地址 实例数据和对齐填充 # 实例数据\n用来存放类的属性(Filed)数据信息,包括父类的属性信息\n对齐填充\n填充到长度为8字节,因为虚拟机要求对象起始地址必须是8字节的整数倍(对齐填充不一定存在)\n示例\nclass Customer{ int id;//4字节 boolean flag=false; //1字节 } //Customer customer=new Customer(); //该对象大小:对象头(对象标记8+类型指针8)+实例数据(4+1)=21字节 ===\u0026gt; 为了对齐填充,则为24字节 源码查看 # 具体的(64位虚拟机为主) # 无锁和偏向锁的锁标志位(最后2位)都是01 无锁的倒数第3位,为0,表示非偏向锁 偏向锁的倒数第3位,为1,表示偏向锁 轻量级锁的锁标志位(最后2位)是00 重量级锁的锁标志位(最后2位)是10 GC标志(最后2位)是11 如上所示,对象分代年龄4位,即最大值为15(十进制)\n源码中\n使用代码演示上述理论(JOL) # \u0026lt;!--引入依赖,用来分析对象在JVM中的大小和分布--\u0026gt; \u0026lt;dependency\u0026gt; \u0026lt;groupId\u0026gt;org.openjdk.jol\u0026lt;/groupId\u0026gt; \u0026lt;artifactId\u0026gt;jol-core\u0026lt;/artifactId\u0026gt; \u0026lt;version\u0026gt;0.16\u0026lt;/version\u0026gt; \u0026lt;/dependency\u0026gt; //使用\n//VM的细节详细情况 System.out.println(VM.current().details()); //所有对象分配字节都是8的整数倍 System.out.println(VM.current().objectAlignment()); /* 输出: # Running 64-bit HotSpot VM. # Using compressed oop with 3-bit shift. # Using compressed klass with 3-bit shift. # Objects are 8 bytes aligned. # Field sizes by type: 4, 1, 1, 2, 2, 4, 4, 8, 8 [bytes] # Array element sizes: 4, 1, 1, 2, 2, 4, 4, 8, 8 [bytes] 8 */ 简单的情形 注意,下面的8 4 (object header: class) 0xf80001e5,由于开启了类型指针压缩,只用了4个字节\npublic class Hello4 { public static void main(String[] args) throws InterruptedException { Object o = new Object(); System.out.println(ClassLayout.parseInstance(o).toPrintable()); //16字节 Customer customer = new Customer(); System.out.println(ClassLayout.parseInstance(customer).toPrintable()); //16字节 } } class Customer{ } /*输出 java.lang.Object object internals: OFF SZ TYPE DESCRIPTION VALUE 0 8 (object header: mark) 0x0000000000000001 (non-biasable; age: 0) 8 4 (object header: class) 0xf80001e5 12 4 (object alignment gap) Instance size: 16 bytes Space losses: 0 bytes internal + 4 bytes external = 4 bytes total com.ly.Customer object internals: OFF SZ TYPE DESCRIPTION VALUE 0 8 (object header: mark) 0x0000000000000001 (non-biasable; age: 0) 8 4 (object header: class) 0xf800cc94 12 4 (object alignment gap) Instance size: 16 bytes Space losses: 0 bytes internal + 4 bytes external = 4 bytes total Process finished with exit code 0 */ 带有实例数据\npublic class Hello4 { public static void main(String[] args) throws InterruptedException { Customer customer = new Customer(); System.out.println(ClassLayout.parseInstance(customer).toPrintable()); //16字节 } } class Customer{ private int a; private boolean b; } /*输出 com.ly.Customer object internals: OFF SZ TYPE DESCRIPTION VALUE 0 8 (object header: mark) 0x0000000000000001 (non-biasable; age: 0) 8 4 (object header: class) 0xf800cc94 12 4 int Customer.a 0 16 1 boolean Customer.b false 17 7 (object alignment gap) Instance size: 24 bytes Space losses: 0 bytes internal + 7 bytes external = 7 bytes total */ java 运行中添加参数 -XX:MaxTenuringThreshold = 16 ,则会出现下面错误,即分代gc最大年龄为15 压缩指针的相关说明\n使用 java -XX:+PrintComandLineFlags -version ,打印参数\n其中有一个, -XX:+UseCompressedClassPointers ,即开启了类型指针压缩,只需要4字节\n当使用了类型指针压缩(默认)时,一个无任何属性对象是 8字节(markWord) + 4字节(classPointer) + 4字节(对齐填充) = 16字节\n下面代码,使用了 -XX:-UseCompressedClassPointers进行关闭压缩指针 一个无任何属性对象是 8字节(markWord) + 8字节(classPointer) = 16字节\npublic class Hello4 { public static void main(String[] args) throws InterruptedException { Object o = new Object(); System.out.println(ClassLayout.parseInstance(o).toPrintable()); //16字节 //16字节 } } /*输出 java.lang.Object object internals: OFF SZ TYPE DESCRIPTION VALUE 0 8 (object header: mark) 0x0000000000000001 (non-biasable; age: 0) 8 8 (object header: class) 0x000000001dab1c00 Instance size: 16 bytes Space losses: 0 bytes internal + 0 bytes external = 0 bytes total */ "},{"id":332,"href":"/zh/docs/technology/Review/java_guide/java/Concurrent/ly0302lyconcurrent-02/","title":"并发02","section":"并发","content":" 转载自https://github.com/Snailclimb/JavaGuide (添加小部分笔记)感谢作者!\nJMM(JavaMemoryModel) # 详见-知识点 volatile关键字 # 保证变量可见性\n使用volatile关键字保证变量可见性,如果将变量声明为volatile则指示JVM该变量是共享且不稳定的,每次使用它都到主存中读取\nvolatile关键字并非Java语言特有,在C语言里也有,它最原始的意义就是禁用CPU缓存。\nvolatile关键字只能保证数据可见性,不能保证数据原子性。synchronized关键字两者都能保证\n不可见的例子\npackage com.concurrent; import java.util.concurrent.TimeUnit; public class TestLy { //如果加上volatile,就能保证可见性,线程1 才能停止 boolean stop = false;//对象属性 public static void main(String[] args) throws InterruptedException { TestLy atomicTest = new TestLy(); new Thread(() -\u0026gt; { while (!atomicTest.stop) { //这里不能加System.out.println ,因为这个方法内部用了synchronized修饰,会导致获取主内存的值, //就没法展示效果了 /*System.out.println(\u0026#34;1还没有停止\u0026#34;);*/ } System.out.println(Thread.currentThread().getName()+\u0026#34;停止了\u0026#34;); },\u0026#34;线程1\u0026#34;).start(); new Thread(() -\u0026gt; { try { TimeUnit.SECONDS.sleep(1); } catch (InterruptedException e) { e.printStackTrace(); } atomicTest.stop= true; System.out.println(Thread.currentThread().getName()+\u0026#34;让线程1停止\u0026#34;); },\u0026#34;线程2\u0026#34;).start(); while (true){} } } 如何禁止指令重排 使用volatile关键字,除了可以保证变量的可见性,还能防止JVM指令重排。当我们对这个变量进行读写操作的时候,-会通过插入特定的内存屏障来禁止指令重排\nJava中,Unsafe类提供了三个开箱即用关于内存屏障相关的方法,屏蔽了操作系统底层的差异\n可以用来实现和volatile禁止重排序的效果\npublic native void loadFence(); //读指令屏障 public native void storeFence(); //写指令屏障 public native void fullFence(); //读写指令屏障 例子(通过双重校验锁实现对象单例),保证线程安全\npublic class Singleton { private volatile static Singleton uniqueInstance; private Singleton() { } public static Singleton getUniqueInstance() { //先判断对象是否已经实例过,没有实例化过才进入加锁代码(第3、4次 //就不需要再进来(synchronized了)) //避免了不论如何都进行加锁的情况 if (uniqueInstance == null) { //...一些其他代码 //加锁,并判断如果未初始化则进行初始化 synchronized (Singleton.class) { //别晕了,这个是一定要判断的【判断是否已经初始化, //如果还未初始化才进行new对象】 if (uniqueInstance == null) { uniqueInstance = new Singleton(); } } } return uniqueInstance; } } 这里,uniqueInstance采用volatile的必要性:主要分析``` uniqueInstance = new Singleton(); ```分三步(正常情况) 1. 为uniqueInstance**分配内存空间** 2. **初始化** uniqueInstance 3. 将uniqueInstance**指向**被分配的空间 由于指令重排的关系,可能会编程1-\u0026gt;3-\u0026gt;2 ,指令重排在单线程情况下不会出现问题,而多线程, - 就会导致可能指针非空的时候,实际该指针所指向的对象(实例)并还没有初始化 - 例如,线程 T1 执行了 1 和 3,此时 T2 调用 `getUniqueInstance`() 后发现 `uniqueInstance` 不为空,因此返回 `uniqueInstance`,但此时 `uniqueInstance` 还未被初始化**(就会造成一些问题)** - 即可能存在1,3已经完成,2还未完成 volatile不能保证原子性\n下面的代码,输出结果小于2500\npublic class VolatoleAtomicityDemo { public volatile static int inc = 0; public void increase() { inc++; } public static void main(String[] args) throws InterruptedException { ExecutorService threadPool = Executors.newFixedThreadPool(5); VolatoleAtomicityDemo volatoleAtomicityDemo = new VolatoleAtomicityDemo(); for (int i = 0; i \u0026lt; 5; i++) { threadPool.execute(() -\u0026gt; { for (int j = 0; j \u0026lt; 500; j++) { volatoleAtomicityDemo.increase(); } }); } // 等待1.5秒,保证上面程序执行完成 Thread.sleep(1500); System.out.println(inc); threadPool.shutdown(); } } 对于上面例子, 很多人会误以为inc++ 是原子性的,实际上inc ++ 是一个复合操作,即\n读取inc的值**(到线程内存)** 对inc加1 将加1后的值写回内存(主内存) 这三部操作并不是原子性的,有可能出现:\n线程1对inc读取后,尚未修改 线程2又读取了,并对他进行+1,然后将+1后的值写回主存 此时线程2操作完毕后,线程1在之前读取的基础上进行一次自增,这将覆盖第2步操作的值,导致inc只增加了1(实际两个线程处理了,应该加2才对) 如果要保证上面代码运行正确,可以使用synchronized、Lock或者AtomicInteger,如\n//synchronized public synchronized void increase() { inc++; } //或者AtomicInteger public AtomicInteger inc = new AtomicInteger(); public void increase() { inc.getAndIncrement(); } //或者ReentrantLock改进 Lock lock = new ReentrantLock(); public void increase() { lock.lock(); try { inc++; } finally { lock.unlock(); } } synchronized关键字 # 说一说自己对synchronized的理解\n翻译成中文是同步的意思,主要解决的是多个线程之间访问资源的同步性,保证被它修饰的方法/代码块,在任一时刻只有一个线程执行 Java早期版本中,synchronized属于重量级锁;监视器锁(monitor)依赖底层操作系统的Mutex Lock来实现,Java线程映射到操作系统的原生线程上 挂起或唤醒线程,都需要操作系统帮忙完成,即操作系统实现线程之间切换,需要从用户态转换到内核态,这个转换时间成本高 Java 6 之后,Java官方对synchronized较大优化,引入了大量优化:自旋锁、适应性自旋锁、锁消除、锁粗化、偏向锁、轻量级锁等减少所操作的开销 如何使用synchronized关键字\n修饰实例方法 修饰静态方法 修饰代码块 修饰实例方法(锁当前对象实例) 给当前对象实例加锁,进入同步代码前要获得当前对象实例的锁\nsynchronized void method() { //业务代码 } 修饰静态方法(锁当前类) 给当前类枷锁,会作用于类的所有对象实例,进入同步代码前要获得当前class的锁; 这是因为静态成员归整个类所有,而不属于任何一个实例对象,不依赖于类的特定实例,被类所有实例共享\nsynchronized static void method() { //业务代码 } 静态synchronized方法和非静态synchronized方法之间的调用互斥吗:不互斥\n如果线程A调用实例对象的非静态方法,而线程B调用这个实例所属类的静态synchronized方法,是允许的,不会发生互斥;因为访问静态synchronized方法占用的锁是当前类的锁;非静态synchronized方法占用的是当前实例对象的锁\n修饰代码块(锁指定对象/类)\nsynchronized(object) 表示进入同步代码库前要获得 给定对象的锁。 synchronized(类.class) 表示进入同步代码前要获得 给定 Class 的锁 synchronized(this) { //业务代码 } 总结\nsynchronized 关键字加到 static 静态方法和 synchronized(class) 代码块上都是是给 Class 类上锁; synchronized 关键字加到实例方法上是给对象实例上锁; 尽量不要使用 synchronized(String a) 因为 JVM 中,字符串常量池具有缓存功能。(所以就会导致,容易**和其他地方的代码(同样的值的字符串)**互斥,因为是缓冲池的同一个对象) 讲一下synchronized关键字的底层原理 synchronized底层原理是属于JVM层面的\nsynchronized + 代码块 例子:\npublic class SynchronizedDemo { public void method() { synchronized (this) { System.out.println(\u0026#34;synchronized 代码块\u0026#34;); } } } 使用javap命令查看SynchronizedDemo类相关字节码信息:对编译后的SynchronizedDemo.class文件,使用javap -c -s -v -l SynchronizedDemo.class\n同步代码块的实现,使用的是monitorenter和monitorexit指令,其中monitorenter指令指向同步代码块开始的地方,monitorexit指向同步代码块结束的结束位置 执行monitorenter指令就是获取对象监视器monitor的持有权\n在HotSport虚拟机中,Monitor基于C++实现,由ObjectMonitor实现:每个对象内置了ObjectMonitor对象。wait/notify等方法也基于monitor对象,所以只有在同步块或者方法中(获得锁)才能调用wait/notify方法,否则会抛出java.lang.IllegalMonitorStateException异常的原因\nnotify()仅仅是通知,并不会释放锁;wait()会立即释放锁,例子:\nObject obj = new Object(); new Thread(() -\u0026gt; { synchronized (obj) { try { log.info(\u0026#34;运行中\u0026#34;); TimeUnit.SECONDS.sleep(3); log.info(\u0026#34;3s后释放锁\u0026#34;); obj.wait();//会释放锁 log.info(\u0026#34;完成执行\u0026#34;); } catch (InterruptedException e) { e.printStackTrace(); } } }, \u0026#34;线程1\u0026#34;).start(); //保证线程2在线程1之后启动 TimeUnit.SECONDS.sleep(1); new Thread(() -\u0026gt; { synchronized (obj) { log.info(\u0026#34;获得锁\u0026#34;); try { TimeUnit.SECONDS.sleep(5); } catch (InterruptedException e) { e.printStackTrace(); } log.info(\u0026#34;5s后唤醒线程1\u0026#34;); obj.notify(); try { TimeUnit.SECONDS.sleep(3); } catch (InterruptedException e) { e.printStackTrace(); } log.info(\u0026#34;完成执行\u0026#34;); } }, \u0026#34;线程2\u0026#34;).start(); /**打印 2023-03-07 11:33:00 上午 [Thread: 线程1] INFO:运行中 2023-03-07 11:33:03 上午 [Thread: 线程1] INFO:3s后释放锁 2023-03-07 11:33:03 上午 [Thread: 线程2] INFO:获得锁 2023-03-07 11:33:08 上午 [Thread: 线程2] INFO:5s后唤醒线程1 2023-03-07 11:33:21 上午 [Thread: 线程2] INFO:完成执行 2023-03-07 11:33:21 上午 [Thread: 线程1] //这段输出永远会在最后(线程2释放锁才会输出) INFO:完成执行 Process finished with exit code 0 */ 执行monitorenter时,**尝试获取**对象的锁,如果锁计数器为0则表示所可以被获取,获取后锁计数器设为1,简单的流程 只有拥有者线程才能执行monitorexit来释放锁,执行monitorexit指令后,锁计数器设为0(应该是减一,与可重入锁有关),当计数器为0时,表明锁被释放,其他线程可以尝试获得锁(如果某个线程获取锁失败,那么该线程就会阻塞等待,直到锁被(另一个线程)释放) synchronized修饰方法\npublic class SynchronizedDemo2 { public synchronized void method() { System.out.println(\u0026#34;synchronized 方法\u0026#34;); } } 如图 : 对比(下面是对synchronized代码块):\nsynchronized修饰的方法没有monitorenter和monitorexit指令,而是ACC_SYNCHRONIZED标识(flags),该标识指明方法是一个同步方法(JVM通过访问标志判断方法是否声明为同步方法),从而执行同步调用 如果是实例方法,JVM 会尝试获取实例对象的锁。如果是静态方法,JVM 会尝试获取当前 class 的锁。\n总结\nsynchronized 同步语句块的实现使用的是 monitorenter 和 monitorexit 指令,其中 monitorenter 指令指向同步代码块的开始位置,monitorexit 指令则指明同步代码块的结束位置。\nsynchronized 修饰的方法并没有 monitorenter 指令和 monitorexit 指令,取得代之的确实是 ACC_SYNCHRONIZED 标识,该标识指明了该方法是一个同步方法。\n不过两者的本质都是对对象监视器 monitor 的获取。\nJava1.6之后的synchronized关键字底层做了哪些优化 这是一个链接 详情见另一个文章\nJDK1.6对锁的实现,引入了大量的优化,如偏向锁、轻量级锁、自旋锁、适应性自旋锁、锁消除、锁粗化等技术来减少操作的开销 锁主要存在四种状态,依次是:无锁状态、偏向锁状态、轻量级锁状态、重量级锁状态,他们会随着竞争的激烈而逐渐升级。锁可以升级但不可以降级,这种策略是为了提高获得锁和释放锁的效率 synchronized和volatile的区别 synchronized和volatile是互补的存在,而非对立\nvolatile关键字是线程同步的轻量级实现,所以volatile性能肯定比synchronized关键字好,但volatile用于变量而synchronized关键字修饰方法及代码块 volatile关键字能保证数据的可见性、有序性,但无法保证原子性;synchronized三者都能保证 volatile主要还是用于解决变量在线程之间的可见性,而synchronized关键字解决的是多个线程之间访问资源的同步性 synchronized 和 ReentrantLock 的区别\n两者都是可重入锁 ”可重入锁“指的是,自己可以再次获取自己的内部锁。比如一个线程获得了某个对象的锁,此时这个对象锁还没有释放,当其再次想要获取这个对象的锁的时候还是可以获取的\n反之,如果是不可重入锁的话,就会造成死锁。 同一个线程,每次获取锁,锁的计数器都自增1,所以要等到锁的计数器下降为0时才能释放锁 synchronized依赖于JVM,而ReentrantLock依赖于API synchronized为虚拟机在JDK1.6进行的优化,但这些优化是在虚拟机层面实现的;ReentrantLock是JDK层面实现的,使用时,使用lock()和unlock()并配合try/finally语句块来完成 (Java代码) ReentrantLock 比 synchronized 增加了一些高级功能 ReentrantLock增加了一些高级功能,主要有\n等待可中断,提供了能够中断等待锁的线程的机制,通过lock.lockInterruptibly()来实现该机制。即正在等待的线程可以放弃等待,改为处理其他事情\n可实现公平锁:可以指定是公平锁还是非公平锁,而synchronized只能是非公平锁。 所谓公平锁就是先等待的线程先获得锁。ReentrantLock默认是非公平的,可以通过构造方法指定是否公平\n可实现选择性的通知(锁可以绑定多个条件) synchronized关键字与wait()和notify()/notifyAll()方法相结合可以实现等待/通知机制。**ReentrantLock类当然也可以实现,但是需要借助于Condition接口与newCondition()**方法。\nReentrantLock reentrantLock=new ReentrantLock(); Condition condition = reentrantLock.newCondition(); condition.await(); condition.signal(); Condition是 JDK1.5 之后才有的,它具有很好的灵活性,比如可以实现多路通知功能也就是在一个Lock对象中可以创建多个Condition实例(即对象监视器),**线程对象可以注册在指定的Condition中,从而可以有选择性的进行线程通知,在调度线程上更加灵活。 ** 在使用notify()/notifyAll()方法进行通知时,被通知的线程是由 JVM 选择的,用ReentrantLock类结合Condition实例可以实现“选择性通知” ,这个功能非常重要,而且是 Condition 接口默认提供的。 synchronized关键字就相当于整个 Lock 对象中只有一个Condition实例,所有的线程都注册在它一个身上。如果执行notifyAll()方法的话就会通知所有处于等待状态的线程这样会造成很大的效率问题, Condition实例的signalAll()方法 只会唤醒注册在该Condition实例中的所有等待线程。 ThreadLocal # ThreadLocal有什么用\n通常情况下,创建的变量是可以被任何一个线程访问并修改的 JDK自带的ThreadLocal类,该类主要解决的就是让每个线程绑定自己的值,可以将ThreadLocal类形象的比喻成存放数据的盒子,盒子中可以存储每个线程的私有数据 对于ThreadLocal变量,访问这个变量的每个线程都会有这个变量的本地副本。使用get()和set()来获取默认值或将其值更改为当前线程所存的副本的值 如图\n如何使用ThreadLocal Demo演示实际中如何使用ThreadLocal\nimport java.text.SimpleDateFormat; import java.util.Random; public class ThreadLocalExample implements Runnable{ // SimpleDateFormat 不是线程安全的,所以每个线程都要有自己独立的副本 private static final ThreadLocal\u0026lt;SimpleDateFormat\u0026gt; formatter = ThreadLocal.withInitial(() -\u0026gt; new SimpleDateFormat(\u0026#34;yyyyMMdd HHmm\u0026#34;)); /* 非lambda写法 private static final ThreadLocal\u0026lt;SimpleDateFormat\u0026gt; formatter = new ThreadLocal\u0026lt;SimpleDateFormat\u0026gt;(){ @Override protected SimpleDateFormat initialValue(){ return new SimpleDateFormat(\u0026#34;yyyyMMdd HHmm\u0026#34;); } }; */ public static void main(String[] args) throws InterruptedException { ThreadLocalExample obj = new ThreadLocalExample(); for(int i=0 ; i\u0026lt;10; i++){ Thread t = new Thread(obj, \u0026#34;\u0026#34;+i); Thread.sleep(new Random().nextInt(1000)); t.start(); } } //formatter.get().toPattern() 同一个对象的线程变量formatter(里面封装了一个simpleDateFormate对象,具有初始值) //每个线程访问时,先打印它的初始值,然后休眠1s(1s内的随机数),反正每个线程随机数不同,然后修改它 //结果:虽然前面执行的线程,修改值,但是后面执行的线程打印的值还是一样的 没有修改 @Override public void run() { System.out.println(\u0026#34;Thread Name= \u0026#34;+Thread.currentThread().getName()+\u0026#34; default Formatter = \u0026#34;+formatter.get().toPattern()); try { Thread.sleep(new Random().nextInt(1000)); } catch (InterruptedException e) { e.printStackTrace(); } //formatter pattern is changed here by thread, but it won\u0026#39;t reflect to other threads formatter.set(new SimpleDateFormat());//new SimpleDateFormat().toPattern()默认值为\u0026#34;yy-M-d ah:mm\u0026#34; System.out.println(\u0026#34;Thread Name= \u0026#34;+Thread.currentThread().getName()+\u0026#34; formatter = \u0026#34;+formatter.get().toPattern()); } } /*虽然前面执行的线程,修改值,但是后面执行的线程打印的值还是一样的 没有修改 , 结果如下: Thread Name= 0 default Formatter = yyyyMMdd HHmm Thread Name= 0 formatter = yy-M-d ah:mm Thread Name= 1 default Formatter = yyyyMMdd HHmm Thread Name= 2 default Formatter = yyyyMMdd HHmm Thread Name= 1 formatter = yy-M-d ah:mm Thread Name= 3 default Formatter = yyyyMMdd HHmm Thread Name= 2 formatter = yy-M-d ah:mm Thread Name= 4 default Formatter = yyyyMMdd HHmm Thread Name= 3 formatter = yy-M-d ah:mm Thread Name= 4 formatter = yy-M-d ah:mm Thread Name= 5 default Formatter = yyyyMMdd HHmm Thread Name= 5 formatter = yy-M-d ah:mm Thread Name= 6 default Formatter = yyyyMMdd HHmm Thread Name= 6 formatter = yy-M-d ah:mm Thread Name= 7 default Formatter = yyyyMMdd HHmm Thread Name= 7 formatter = yy-M-d ah:mm Thread Name= 8 default Formatter = yyyyMMdd HHmm Thread Name= 9 default Formatter = yyyyMMdd HHmm Thread Name= 8 formatter = yy-M-d ah:mm Thread Name= 9 formatter = yy-M-d ah:mm */ ThreadLocal原理了解吗\n从Thread类源代码入手\npublic class Thread implements Runnable { //...... //与此线程有关的ThreadLocal值。由ThreadLocal类维护 ThreadLocal.ThreadLocalMap threadLocals = null; //与此线程有关的InheritableThreadLocal值。由InheritableThreadLocal类维护 ThreadLocal.ThreadLocalMap inheritableThreadLocals = null; //...... } Thread类中有一个threadLocals和一个inheritableThreadLocals变量,它们都是ThreadLocalMap类型的变量,ThreadLocalMap可以理解为ThreadLocal类实现的定制化HashMap ( key为threadLocal , value 为值) 默认两个变量都是null,当调用set或get时会创建,实际调用的是ThreadLocalMap类对应的get()、set()方法\n//★★ThreadLocal类的set() 方法 public void set(T value) { //获取当前请求的线程 Thread t = Thread.currentThread(); //取出 Thread 类内部的 threadLocals 变量(哈希表结构) ThreadLocalMap map = getMap(t); if (map != null) // 将需要存储的值放入到这个哈希表中 //★★实际使用的方法 map.set(this, value); else //★★实际使用的方法 createMap(t, value); } ThreadLocalMap getMap(Thread t) { return t.threadLocals; } void createMap(Thread t, T firstValue) { t.threadLocals = new ThreadLocalMap(this, firstValue); } public T get() { Thread t = Thread.currentThread(); ThreadLocalMap map = getMap(t); if (map != null) { ThreadLocalMap.Entry e = map.getEntry(this); if (e != null) { @SuppressWarnings(\u0026#34;unchecked\u0026#34;) T result = (T)e.value; return result; } } return setInitialValue(); } /** * Set the value associated with key. * * @param key the thread local object * @param value the value to be set */ private void set(ThreadLocal\u0026lt;?\u0026gt; key, Object value) { // We don\u0026#39;t use a fast path as with get() because it is at // least as common to use set() to create new entries as // it is to replace existing ones, in which case, a fast // path would fail more often than not. Entry[] tab = table; int len = tab.length; int i = key.threadLocalHashCode \u0026amp; (len-1); for (Entry e = tab[i]; e != null; e = tab[i = nextIndex(i, len)]) { ThreadLocal\u0026lt;?\u0026gt; k = e.get(); if (k == key) { e.value = value; return; } if (k == null) { replaceStaleEntry(key, value, i); return; } } tab[i] = new Entry(key, value); int sz = ++size; if (!cleanSomeSlots(i, sz) \u0026amp;\u0026amp; sz \u0026gt;= threshold) rehash(); } 如上,实际存取都是从Thread的threadLocals (ThreadLocalMap类)中,并不是存在ThreadLocal上,ThreadLocal用来传递了变量值,只是ThreadLocalMap的封装\nThreadLocal类中通过Thread.currentThread()获取到当前线程对象后,直接通过getMap(Thread t) 可以访问到该线程的ThreadLocalMap对象\n【★★最重要★★】每个Thread中具备一个ThreadLocalMap,而ThreadLocalMap可以存储以ThreadLocal为key,Object对象为value的键值对\nThreadLocalMap(ThreadLocal\u0026lt;?\u0026gt; firstKey, Object firstValue) { //...... } 比如我们在同一个线程中声明了两个 ThreadLocal 对象的话, Thread内部都是使用仅有的那个ThreadLocalMap 存放数据的,ThreadLocalMap的 key 就是 ThreadLocal对象,value 就是 ThreadLocal 对象调用set方法设置的值\nThreadLocal数据结构如下图所示 ThreadLocalMap是ThreadLocal的静态内部类。 ThreadLocal内存泄露问题时怎么导致的\n前提知识:强引用、软引用、弱引用和虚引用的区别\n强引用StrongReference\n是最普遍的一种引用方式,只要强引用存在,则垃圾回收器就不会回收这个对象\n软引用 SoftReference\n如果内存足够不回收,如果内存不足则回收\n弱引用WeakReference 如果一个对象只具有弱引用,那就类似于可有可无的生活用品。弱引用与软引用的区别在于:只具有弱引用的对象拥有更短暂的生命周期。在垃圾回收器线程扫描它 所管辖的内存区域的过程中,一旦发现了只具有弱引用的对象,不管当前内存空间足够与否,都会回收它的内存。不过,由于垃圾回收器是一个优先级很低的线程, 因此不一定会很快发现那些只具有弱引用的对象。\n弱引用可以和一个引用队列(ReferenceQueue)联合使用,如果弱引用所引用的对象被垃圾回收,Java 虚拟机就会把这个弱引用加入到与之关联的引用队列中。\n虚引用PhantomReference [ˈfæntəm] 幻影\n如果一个对象仅持有虚引用,那么它就和没有任何引用一样,在任何时候都可能被垃圾回收器回收。虚引用主要用来跟踪对象被垃圾回收器回收的活动 虚引用与软引用和弱引用的一个区别在于:虚引用必须和引用队列 (ReferenceQueue)联合使用。当垃圾回收器准备回收一个对象时,如果发现它还有虚引,就会在回收对象的内存之前,把这个虚引用加入到与之关联的引用队列中。 ThreadLocalMap中,使用的key为ThreadLocal的弱引用(源码中,即Entry),而value是强引用\n//注意看ThreadLocal的set()方法 /** * Set the value associated with key. * * @param key the thread local object * @param value the value to be set */ private void set(ThreadLocal\u0026lt;?\u0026gt; key, Object value) { // We don\u0026#39;t use a fast path as with get() because it is at // least as common to use set() to create new entries as // it is to replace existing ones, in which case, a fast // path would fail more often than not. Entry[] tab = table; int len = tab.length; int i = key.threadLocalHashCode \u0026amp; (len-1); for (Entry e = tab[i]; e != null; e = tab[i = nextIndex(i, len)]) { ThreadLocal\u0026lt;?\u0026gt; k = e.get(); if (k == key) { e.value = value; return; } if (k == null) { replaceStaleEntry(key, value, i); return; } } //★★注意看这行,结合下面 tab[i] = new Entry(key, value); int sz = ++size; if (!cleanSomeSlots(i, sz) \u0026amp;\u0026amp; sz \u0026gt;= threshold) rehash(); } 所以,ThreadLocal没有被外部强引用的情况下,垃圾回收的时候 key会被清理掉,而value不会 ```java static class Entry extends WeakReference\u0026lt;ThreadLocal\u0026lt;?\u0026gt;\u0026gt; { /** The value associated with this ThreadLocal. */ Object value; Entry(ThreadLocal\u0026lt;?\u0026gt; k, Object v) { super(k); value = v; } } ``` 此时,ThreadLocalMap中就会出现key为null的Entry,如果不做任何措施,value永远无法被GC回收,此时会产生内存泄漏。ThreadLocaMap实现中已经考虑了这种情况,在调用set()、get()、**remove()**方法时,清理掉key为null的记录 所以使用完ThreadLocal的方法后,最好手动调用remove()方法\nset()方法中的cleanSomeSlots() 已经清除了部分key为null的记录。但是还不完整,还要依赖 expungeStaleEntry() 方法(在remove中)\n//remove()方法 public void remove() { ThreadLocalMap m = getMap(Thread.currentThread()); if (m != null) m.remove(this); } /** * Remove the entry for key. */ private void remove(ThreadLocal\u0026lt;?\u0026gt; key) { Entry[] tab = table; int len = tab.length; int i = key.threadLocalHashCode \u0026amp; (len-1); for (Entry e = tab[i]; e != null; e = tab[i = nextIndex(i, len)]) { if (e.get() == key) { e.clear(); expungeStaleEntry(i); return; } } } /** * Expunge a stale entry by rehashing any possibly colliding entries * lying between staleSlot and the next null slot. This also expunges * any other stale entries encountered before the trailing null. See * Knuth, Section 6.4 * * @param staleSlot index of slot known to have null key * @return the index of the next null slot after staleSlot * (all between staleSlot and this slot will have been checked * for expunging). */ private int expungeStaleEntry(int staleSlot) { Entry[] tab = table; int len = tab.length; // expunge entry at staleSlot tab[staleSlot].value = null; tab[staleSlot] = null; size--; // Rehash until we encounter null Entry e; int i; for (i = nextIndex(staleSlot, len); (e = tab[i]) != null; i = nextIndex(i, len)) { ThreadLocal\u0026lt;?\u0026gt; k = e.get(); if (k == null) { e.value = null; tab[i] = null; size--; } else { int h = k.threadLocalHashCode \u0026amp; (len - 1); if (h != i) { tab[i] = null; // Unlike Knuth 6.4 Algorithm R, we must scan until // null because multiple entries could have been stale. while (tab[h] != null) h = nextIndex(h, len); tab[h] = e; } } } return i; } "},{"id":333,"href":"/zh/docs/technology/springCloud/bl_zhouyang_/base/","title":"基础","section":"基础(尚硅谷)_","content":" springCloud涉及到的技术有哪些 约定 \u0026gt; 配置 \u0026gt; 编码 "},{"id":334,"href":"/zh/docs/technology/Review/java_guide/java/Concurrent/ly0301lyconcurrent-01/","title":"并发01","section":"并发","content":" 转载自https://github.com/Snailclimb/JavaGuide (添加小部分笔记)感谢作者!\n什么是进程和线程\n进程:是程序的一次执行过程,是系统运行程序的基本单位 系统运行一个程序,即一个进程从创建、运行到消亡的过程\n启动main函数则启动了一个JVM进程,main函数所在线程为进程中的一个线程,也称主线程\n以下为一个个的进程\n查看java进程\njps -l 32 org.jetbrains.jps.cmdline.Launcher 10084 16244 com.Test 17400 sun.tools.jps.Jps 杀死进程\ntaskkill /f /pid 16244 何为线程\n线程,比进程更小的执行单位\n同类的多个线程共享进程的堆和方法区资源,但每个线程有自己的程序计数器、虚拟机栈、本地方法栈,又被称为轻量级进程\nJava天生就是多线程程序,如:\npublic class MultiThread { public static void main(String[] args) { // 获取 Java 线程管理 MXBean ThreadMXBean threadMXBean = ManagementFactory.getThreadMXBean(); // 不需要获取同步的 monitor 和 synchronizer 信息,仅获取线程和线程堆栈信息 ThreadInfo[] threadInfos = threadMXBean.dumpAllThreads(false, false); // 遍历线程信息,仅打印线程 ID 和线程名称信息 for (ThreadInfo threadInfo : threadInfos) { System.out.println(\u0026#34;[\u0026#34; + threadInfo.getThreadId() + \u0026#34;] \u0026#34; + threadInfo.getThreadName()); } } } //输出 [5] Attach Listener //添加事件 [4] Signal Dispatcher // 分发处理给 JVM 信号的线程 [3] Finalizer //调用对象 finalize 方法的线程 [2] Reference Handler //清除 reference 线程 [1] main //main 线程,程序入口 也就是说,一个Java程序的运行,是main线程和多个其他线程同时运行\n请简要描述线程与进程的关系,区别及优缺点\n从JVM角度说明 Java内存区域 一个进程拥有多个线程,多个线程共享进程的堆和方法区(JDK1.8: 元空间),每个线程拥有自己的程序计数器、虚拟机栈、本地方法栈 总结\n线程是进程划分成的更小运行单位 线程和进程最大不同在于各进程基本独立,而各线程极有可能互相影响 线程开销小,但不利于资源保护;进程反之 程序计数器为什么是私有\n程序计数器的作用\n单线程情况下,字节码解释器通过改变程序计数器来依次读取指令,从而实现代码的流程控制,如:顺序执行、选择、循环、异常处理。 在多线程的情况下,程序计数器用于记录当前线程执行的位置,从而当线程被切换回来的时候能够知道该线程上次运行到哪儿了。 如果执行的是native方法,则程序计数器记录的是undefined地址;执行Java方法则记录的是下一条指令的地址\n私有,是为了线程切换后能恢复到正确的执行位置\n虚拟机栈和本地方法栈为什么私有\n虚拟机栈:每个Java方法执行时同时会创建一个栈帧用于存储局部变量表、操作数栈、常量池引用等信息 本地方法栈:和虚拟机栈类似,区别是虚拟机栈为虚拟机执行Java方法 (字节码)服务,本地方法栈则为虚拟机使用到的Native方法服务。HotSpot虚拟机中和Java虚拟机栈合二为一 为了保证线程中局部变量不被别的线程访问到,虚拟机栈和本地方法栈是私有的 堆和方法区是所有线程共享的资源,堆是进程中最大一块内存,用于存放新创建的对象(几乎所有对象都在这分配内存); 方法区则存放**已被加载的 ** 类信息、常量、静态变量、即时编译器编译后的代码等数据\n并发与并行的区别\n并发:两个及两个以上的作业在同一时间段内执行(线程,同一个代码同一秒只能由一个线程访问) 并行:两个及两个以上的作业同一时刻执行 关键点:是否同时执行,只有并行才能同时执行 同步和异步\n同步:发出调用后,没有得到结果前,该调用不能返回,一直等待 异步:发出调用后,不用等返回结果,该调用直接返回 为什么要使用多线程\n从计算机底层来说:线程是轻量级进程,程序执行最小单位,线程间切换和调度 成本远小于进程。多核CPU时代意味着多个线程可以同时运行,减少线程上下文切换 从当代互联网发展趋势:如今系统并发量大,利用多线程机制可以大大提高系统整体并发能力及性能 深入计算机底层 单核时代:提高单进程利用CPU和IO系统的效率。当请求IO的时候,如果Java进程中只有一个线程,此线程被IO阻塞则整个进程被阻塞,CPU和IO设备只有一个运行,系统整体效率50%;而多线程时,如果一个线程被IO阻塞,其他线程还可以继续使用CPU 多核时代:多核时代多线程主要是提高进程利用多核CPU的能力,如果要计算复杂任务,只有一个线程的话,不论系统几个CPU核心,都只有一个CPU核心被利用;而创建多个线程,这些线程可以被映射到底层多个CPU上执行,如果任务中的多个线程没有资源竞争,那么执行效率会显著提高 多线程带来的问题:内存泄漏(对象,没有释放)、死锁、线程不安全等\n说说线程的声明周期和状态 Java线程在运行的生命周期中的指定时刻,只可能处于下面6种不同状态中的一个\nNEW:初始状态,线程被创建出来但没有调用start()\nRUNNABLE:运行状态,线程被调用了start() 等待运行的状态\nBLOCKED:阻塞状态,需要等待锁释放\nWAITING:等待状态,表示该线程需要等待其他线程做出一些特定动作(通知或中断)\nTIME_WAITING:超时等待状态,在指定的时间后自行返回而不是像WAITING一直等待\nTERMINATED:终止状态,表示该线程已经运行完毕 如图\n对于该图有以下几点要注意:\n线程创建后处于NEW状态,之后调用start()方法运行,此时线程处于READY,可运行的线程获得CPU时间片(timeslice)后处于RUNNING状态\n操作系统中有READY和RUNNING两个状态,而JVM中只有RUNNABLE状态 现在的操作系统通常都是**“时间分片“方法进行抢占式 轮转调度**“,一个线程最多只能在CPU上运行10-20ms的时间(此时处于RUNNING)状态,时间过短,时间片之后放入调度队列末尾等待再次调度(回到READY状态),太快所以不区分两种状态 线程执行wait()方法后,进入WAITING(等待 )状态,进入等待状态的线程需要依靠其他线程通知才能回到运行状态\nTIMED_WAITING(超时等待)状态,在等待状态的基础上增加超时限制,通过sleep(long millis)或wait(long millis) 方法可以将线程置于TIMED_WAITING状态,超时结束后返回到RUNNABLE状态(注意,不是RUNNING)\n当线程进入synchronized方法/块或者调用wait后(被notify)重新进入synchronized方法/块,但是锁被其他线程占有,这个时候线程就会进入BLOCKED(阻塞)状态\n线程在执行完了**run()方法之后就会进入到TERMINATED(终止)**状态\n注意上述,阻塞和等待的区别\n什么是上下文切换\n线程在执行过程中会有自己的运行条件和状态(也称上下文),比如上文提到的程序计数器,栈信息等。当出现下面情况时,线程从占用CPU状态中退出:\n主动让出CPU,如sleep(),wait()等 时间片用完了 调用了阻塞类型的系统中断(请求IO,线程被阻塞) 被终止或结束运行 前3种会发生线程切换:需要保存当前线程上下文,留待线程下次占用CPU的时候恢复,并加载下一个将要占用CPU的线程上下文,即所谓的上下文切换\n是现代系统基本功能,每次都要保存信息恢复信息,将会占用CPU,内存等系统资源,即效率有一定损耗,频繁切换会造成整体效率低下\n线程死锁是什么?如何避免?\n多个线程同时被阻塞,它们中的一个或者全部,都在等待某个资源被释放。由于线程被无限期地阻塞,因此程序不可能正常终止\n前提:线程A持有资源2,线程B持有资源1。现象:线程A在等待申请资源1,线程B在等待申请资源2,所以这两个线程就会互相等待而进入死锁状态 使用代码描述上述问题\npublic class DeadLockDemo { private static Object resource1 = new Object();//资源 1 private static Object resource2 = new Object();//资源 2 public static void main(String[] args) { new Thread(() -\u0026gt; { synchronized (resource1) { System.out.println(Thread.currentThread() + \u0026#34;get resource1\u0026#34;); try { Thread.sleep(1000); } catch (InterruptedException e) { e.printStackTrace(); } System.out.println(Thread.currentThread() + \u0026#34;waiting get resource2\u0026#34;); synchronized (resource2) { System.out.println(Thread.currentThread() + \u0026#34;get resource2\u0026#34;); } } }, \u0026#34;线程 1\u0026#34;).start(); new Thread(() -\u0026gt; { synchronized (resource2) { System.out.println(Thread.currentThread() + \u0026#34;get resource2\u0026#34;); try { Thread.sleep(1000); } catch (InterruptedException e) { e.printStackTrace(); } System.out.println(Thread.currentThread() + \u0026#34;waiting get resource1\u0026#34;); synchronized (resource1) { System.out.println(Thread.currentThread() + \u0026#34;get resource1\u0026#34;); } } }, \u0026#34;线程 2\u0026#34;).start(); } } /* - 线程A通过synchronized(resource1)获得resource1的监视器锁,然后休眠1s(是为了保证线程B获得执行然后拿到resource2监视器锁) - 休眠结束了两线程都企图请求获得对方的资源,陷入互相等待的状态,于是产生了死锁 */ 死锁产生条件\n互斥:该资源任意一个时刻只由一个线程占有 请求与保持:一线程因请求资源而阻塞时,对已获得的资源保持不放 不剥夺条件:线程已获得的资源未使用完之前不能被其他线程强行剥夺,只有自己使用完才释放(资源) 循环等待:若干线程之间形成头尾相接的循环等待资源关系 如何预防死锁\u0026mdash;\u0026gt;破坏死锁的必要条件\n破坏请求与保持条件:一次性申请所有资源 破坏不剥夺条件:占用部分资源的线程进一步申请其他资源时,如果申请不到,可以主动释放它占有的资源 破坏循环等待条件:靠按需申请资源来预防(按某顺序申请资源,释放资源时反序) 如何将避免死锁\n在资源分配时,借助于算法(银行家算法)对资源分配计算评估,使其进入安全状态\n安全状态 指的是系统能够按照某种线程推进顺序(P1、P2、P3\u0026hellip;..Pn)来为每个线程分配所需资源,直到满足每个线程对资源的最大需求,使每个线程都可顺利完成。称 \u0026lt;P1、P2、P3.....Pn\u0026gt; 序列为安全序列\n修改线程2的代码 原线程1代码不变\nnew Thread(() -\u0026gt; { synchronized (resource1) { System.out.println(Thread.currentThread() + \u0026#34;get resource1\u0026#34;); try { Thread.sleep(1000); } catch (InterruptedException e) { e.printStackTrace(); } System.out.println(Thread.currentThread() + \u0026#34;waiting get resource2\u0026#34;); synchronized (resource2) { System.out.println(Thread.currentThread() + \u0026#34;get resource2\u0026#34;); } } }, \u0026#34;线程 1\u0026#34;).start(); 线程2代码修改:\nnew Thread(() -\u0026gt; { synchronized (resource1) { System.out.println(Thread.currentThread() + \u0026#34;get resource1\u0026#34;); try { Thread.sleep(1000); } catch (InterruptedException e) { e.printStackTrace(); } System.out.println(Thread.currentThread() + \u0026#34;waiting get resource2\u0026#34;); synchronized (resource2) { System.out.println(Thread.currentThread() + \u0026#34;get resource2\u0026#34;); } } }, \u0026#34;线程 2\u0026#34;).start(); /* 输出 Thread[线程 1,5,main]get resource1 Thread[线程 1,5,main]waiting get resource2 Thread[线程 1,5,main]get resource2 Thread[线程 2,5,main]get resource1 Thread[线程 2,5,main]waiting get resource2 Thread[线程 2,5,main]get resource2\nProcess finished with exit code 0 */\n分析 \u0026gt; 线程 1 首先获得到 resource1 的监视器锁,这时候线程 2 就获取不到了。然后线程 1 再去获取 resource2 的监视器锁,可以获取到。然后**线程 1 释放了对 resource1、resource2 的监视器锁的占用,线程 2 获取到(resource1)就可以执行了**。这样就破坏了破坏循环等待条件,因此避免了死锁。 sleep()方法和wait()方法对比\n共同点: 两者都可暂停线程执行 区别 seep() 方法没有释放锁,wait() 方法释放了锁 wait() 通常用于线程间交互/通信,sleep()用于暂停执行 wait()方法被调用后,线程不会自动苏醒,需要别的线程调用同一对象(监视器monitor)的notify()或者notifyAll()方法;sleep()方法执行完成后/或者wait(long timeout)超时后,线程会自动苏醒 sleep时Thread类的静态本地方法,wait()则是Object类的本地方法 为什么wait()方法不定义在Thread中\nwait() 目的是让获得对象锁的线程实现等待,会自动释放当前线程占有的对象锁 每个对象(Object)都拥有对象锁,既然是让获得对象锁的线程等待,所以方法应该出现在对象Object上 sleep()是让当前线程暂停执行,不涉及对象类,也不需要获得对象锁 可以直接调用Thread类的run方法吗\nnew一个Thread之后,线程进入新建状态 调用start(),会启动线程并使他进入就绪状态(Runable,可运行状态,又分为Ready和Running),分配到时间片后就开始运行 start()执行线程相应准备工作,之后**自动执行run()**方法的内容 如果直接执行run()方法,则会把run()方法当作main线程下普通方法去执行,并不会在某个线程中执行它 只有调用start()方法才可以启动新的线程使他进入就绪状态,等待获取时间片后运行 "},{"id":335,"href":"/zh/docs/technology/Review/java_guide/java/IO/ly0203lyio-model/","title":"io模型","section":"IO","content":" 转载自https://github.com/Snailclimb/JavaGuide (添加小部分笔记)感谢作者!\nhttps://zhuanlan.zhihu.com/p/360878783 IO多路复用讲解,这是一个与系统底层有关的知识点,需要一些操作系统调用代码才知道IO多路复用省的时间。\nI/O # 何为I/O # I/O(Input/Output),即输入/输出 从计算机结构的角度来解读一下I/O,根据冯诺依曼结构,计算机结构分为5大部分:运算器、控制器、存储器、输入设备、输出设备 其中,输入设备:键盘;输出设备:显示器 网卡、硬盘既属于输入设备也属于输出设备 输入设备向计算机输入(内存)数据,输出设备接收计算机(内存)输出的数据,即I/O描述了计算机系统与外部设备之间通信的过程 从应用程序的角度解读I/O 为了保证系统稳定性和安全性,一个进程的地址空间划分为用户空间User space和内核空间Kernel space kernel\t英[ˈkɜːnl] 平常运行的应用程序都运行在用户空间,只有内核空间才能进行系统态级别的资源有关操作\u0026mdash;文件管理、进程通信、内存管理 如果要进行IO操作,就得依赖内核空间的能力,用户空间的程序不能直接访问内核空间 用户进程要想执行IO操作,必须通过系统调用来间接访问内核空间 对于磁盘IO(读写文件)和网络IO(网络请求和响应),从应用程序视角来看,应用程序对操作系统的内核发起IO调用(系统调用),操作系统负责的内核执行具体IO操作 应用程序只是发起了IO操作调用,而具体的IO执行则由操作系统内核完成 应用程序发起I/O后,经历两个步骤 内核等待I/O设备准备好数据 内核将数据从内核空间拷贝到用户空间 有哪些常见的IO模型 # UNIX系统下,包括5种:同步阻塞I/O,同步非阻塞I/O,I/O多路复用、信号驱动I/O和异步I/O\nJava中3中常见I/O模型 # BIO (Blocking I/O ) # 应用程序发起read调用后,会一直阻塞,直到内核把数据拷贝到用户空间 NIO (Non-blocking/New I/O) # 对于java.nio包,提供了Channel、Selector、Buffer等抽象概念,对于高负载高并发,应使用NIO NIO是I/O多路复用模型,属于同步非阻塞IO模型 一般的同步非阻塞 IO 模型中,应用程序会一直发起 read 调用。\n等待数据从内核空间拷贝到用户空间的这段时间里,线程依然是阻塞的**,**直到在内核把数据拷贝到用户空间。\n相比于同步阻塞 IO 模型,同步非阻塞 IO 模型确实有了很大改进。通过轮询操作,避免了一直阻塞。\n但是,这种 IO 模型同样存在问题:应用程序不断进行 I/O 系统调用轮询数据是否已经准备好的过程是十分消耗 CPU 资源的。\n★★ 也就是说,【准备数据,数据就绪】是不阻塞的。而【拷贝数据】是阻塞的 I/O多路复用 线程首先发起select调用,询问内核数据是否准备就绪,等准备好了,用户线程再发起read调用,r**ead调用的过程(数据从内核空间\u0026ndash;\u0026gt;用户空间)**还是阻塞的\nIO 多路复用模型,通过减少无效的系统调用,减少了对 CPU 资源的消耗。\nJava 中的 NIO ,有一个非常重要的选择器 ( Selector ) 的概念,也可以被称为 多路复用器。通过它,只需要一个线程便可以管理多个客户端连接。当客户端数据到了之后,才会为其服务。\nSelector,即多路复用器,一个线程管理多个客户端连接 AIO(Asynchronous I/O ) # 异步 IO 是基于事件和回调机制实现的,也就是应用操作之后会直接返回,不会堵塞在那里,当后台处理完成,操作系统会通知相应的线程进行后续的操作 如图\n三者区别 # "},{"id":336,"href":"/zh/docs/technology/Review/java_guide/java/IO/ly0202lyio-design-patterns/","title":"io设计模式","section":"IO","content":" 转载自https://github.com/Snailclimb/JavaGuide (添加小部分笔记)感谢作者!\n装饰器模式 # ​\t类图:\n​\t装饰器,Decorator,装饰器模式可以在不改变原有对象的情况下拓展其功能\n★装饰器模式,通过组合替代继承来扩展原始类功能,在一些继承关系较复杂的场景(IO这一场景各种类的继承关系就比较复杂)下更加实用\n对于字节流,FilterInputStream(对应输入流)和FilterOutputStream(对应输出流)是装饰器模式的核心,分别用于增强(继承了)InputStream和OutputStream子类对象的功能 Filter (过滤的意思),中间(Closeable)下面这两条虚线代表实现;最下面的实线代表继承 其中BufferedInputStream(字节缓冲输入流)、DataInputStream等等都是FilterInputStream的子类,对应的BufferedOutputStream和DataOutputStream都是FilterOutputStream的子类\n例子,使用BufferedInputStream(字节缓冲输入流)来增强FileInputStream功能\nBufferedInputStream源码(构造函数)\nprivate static int DEFAULT_BUFFER_SIZE = 8192; public BufferedInputStream(InputStream in) { this(in, DEFAULT_BUFFER_SIZE); } public BufferedInputStream(InputStream in, int size) { super(in); if (size \u0026lt;= 0) { throw new IllegalArgumentException(\u0026#34;Buffer size \u0026lt;= 0\u0026#34;); } buf = new byte[size]; } 使用\ntry (BufferedInputStream bis = new BufferedInputStream(new FileInputStream(\u0026#34;input.txt\u0026#34;))) { int content; long skip = bis.skip(2); while ((content = bis.read()) != -1) { System.out.print((char) content); } } catch (IOException e) { e.printStackTrace(); } ZipInputStream和ZipOutputStream还可以用来增强BufferedInputStream和BufferedOutputStream的能力\n//使用 BufferedInputStream bis = new BufferedInputStream(new FileInputStream(fileName)); ZipInputStream zis = new ZipInputStream(bis); BufferedOutputStream bos = new BufferedOutputStream(new FileOutputStream(fileName)); ZipOutputStream zipOut = new ZipOutputStream(bos); 装饰器模式重要的一点,就是可以对原始类嵌套使用多个装饰器,所以装饰器需要跟原始类继承相同的抽象类或实现相同接口,上面介绍的IO相关装饰器和原始类共同父类都是InputStream和OutputStream 而对于字符流来说,BufferedReader用来增强Reader(字符输入流)子类功能,BufferWriter用来增加Writer(字符输出流)子类功能\nBufferedWriter bw = new BufferedWriter(new OutputStreamWriter(new FileOutputStream(fileName), \u0026#34;UTF-8\u0026#34;)); IO流中大量使用了装饰器模式,不需要特意记忆\n适配器模式 # 适配器(Adapter Pattern)模式:主要用于接口互不兼容的类的协调工作,你可以将其联想到我们日常使用的电源适配器\n其中被适配的对象/类称为适配者(Adaptee),作用于适配者的对象或者类称为适配器(Adapter)。对象适配器使用组合关系实现,类适配器使用继承关系实现 IO中字符流和字节流接口不同,而他们能协调工作就是基于适配器模式来做的,具体的,是对象适配器:将字节流对象适配成字符流对象,然后通过字节流对象,读取/写入字符数据\nInputStreamReader和OutputStreamWriter为两个适配器,也是字节流和字符流之间的桥梁\nInputStreamReader使用StreamDecode(流解码器)对字节进行解码,实现字节流到字符流的转换\nOutputStreamWriter使用StreamEncoder(流编码器)对字符进行编码,实现字符到字节流的转换\nInputStream和OutputStream的子类是被适配者,InputStreamReader和OutputStreamWriter是适配器 使用:\n// InputStreamReader 是适配器,FileInputStream 是被适配的类 InputStreamReader isr = new InputStreamReader(new FileInputStream(fileName), \u0026#34;UTF-8\u0026#34;); // BufferedReader 增强 InputStreamReader 的功能(装饰器模式) BufferedReader bufferedReader = new BufferedReader(isr); fileReader的源码:\npublic class FileReader extends InputStreamReader { public FileReader(String fileName) throws FileNotFoundException { super(new FileInputStream(fileName)); } } //其父类InputStreamReader public class InputStreamReader extends Reader { //用于解码的对象 private final StreamDecoder sd; public InputStreamReader(InputStream in) { super(in); try { // 获取 StreamDecoder 对象 sd = StreamDecoder.forInputStreamReader(in, this, (String)null); } catch (UnsupportedEncodingException e) { throw new Error(e); } } // 使用 StreamDecoder 对象做具体的读取工作 public int read() throws IOException { return sd.read(); } } 同理,java.io.OutputStreamWriter部分源码:\npublic class OutputStreamWriter extends Writer { // 用于编码的对象 private final StreamEncoder se; public OutputStreamWriter(OutputStream out) { super(out); try { // 获取 StreamEncoder 对象 se = StreamEncoder.forOutputStreamWriter(out, this, (String)null); } catch (UnsupportedEncodingException e) { throw new Error(e); } } // 使用 StreamEncoder 对象做具体的写入工作 public void write(int c) throws IOException { se.write(c); } } 适配器模式和装饰器模式区别\n装饰器模式更侧重于动态增强原始类的功能,(为了嵌套)装饰器类需要跟原始类继承相同抽象类/或实现相同接口。装饰器模式支持对原始类嵌套\n适配器模式侧重于让接口不兼容而不能交互的类一起工作,当调用适配器方法时,适配器内部会调用适配者类或者和适配者类相关类的方法,这个过程透明的。就比如说 StreamDecoder (流解码器)和StreamEncoder(流编码器)就是分别基于 InputStream 和 OutputStream 来获取 FileChannel对象并调用对应的 read 方法和 write 方法进行字节数据的读取和写入。\nStreamDecoder(InputStream in, Object lock, CharsetDecoder dec) { // 省略大部分代码 // 根据 InputStream 对象获取 FileChannel 对象 ch = getChannel((FileInputStream)in); } 适配器和适配者(注意,这里说的都是适配器模式)两者不需要继承相同抽象类/不需要实现相同接口 FutureTask使用了适配器模式 直接调用(构造器)\npublic FutureTask(Runnable runnable, V result) { // 调用 Executors 类的 callable 方法 this.callable = Executors.callable(runnable, result); this.state = NEW; } 间接:\n// 实际调用的是 Executors 的内部类 RunnableAdapter 的构造方法 public static \u0026lt;T\u0026gt; Callable\u0026lt;T\u0026gt; callable(Runnable task, T result) { if (task == null) throw new NullPointerException(); return new RunnableAdapter\u0026lt;T\u0026gt;(task, result); } // 适配器 static final class RunnableAdapter\u0026lt;T\u0026gt; implements Callable\u0026lt;T\u0026gt; { final Runnable task; final T result; RunnableAdapter(Runnable task, T result) { this.task = task; this.result = result; } public T call() { task.run(); return result; } } 工厂模式 # NIO中大量出现,例如Files类的newInputStream,Paths类中的get方法,ZipFileSystem类中的getPath\nInputStream is = Files.newInputStream(Paths.get(generatorLogoPath)) 观察者模式 # 比如NIO中的文件目录监听服务 该服务基于WatchService接口(观察者)和Watchable接口(被观察者)\nWatchable接口其中有一个register方法,用于将对象注册到WatchService(监控服务)并绑定监听事件的方法\n例子\n// 创建 WatchService 对象 WatchService watchService = FileSystems.getDefault().newWatchService(); // 初始化一个被监控文件夹的 Path 类: Path path = Paths.get(\u0026#34;workingDirectory\u0026#34;); // 将这个 path 对象注册到 WatchService(监控服务) 中去 WatchKey watchKey = path.register( watchService, StandardWatchEventKinds...); 可以通过WatchKey对象获取事件具体信息\nWatchKey key; while ((key = watchService.take()) != null) { for (WatchEvent\u0026lt;?\u0026gt; event : key.pollEvents()) { // 可以调用 WatchEvent 对象的方法做一些事情比如输出事件的具体上下文信息 } key.reset(); } 完整的代码应该是如下\n@Test public void myTest() throws IOException, InterruptedException { // 创建 WatchService 对象 WatchService watchService = FileSystems.getDefault().newWatchService(); // 初始化一个被监控文件夹的 Path 类: Path path = Paths.get(\u0026#34;F:\\\\java_test\\\\git\\\\hexo\\\\review_demo\\\\src\\\\com\\\\hp\u0026#34;); // 将这个 path 对象注册到 WatchService(监控服务) 中去 WatchKey key = path.register( watchService, StandardWatchEventKinds.ENTRY_CREATE,StandardWatchEventKinds.ENTRY_DELETE ,StandardWatchEventKinds.ENTRY_MODIFY); while ((key = watchService.take()) != null) { System.out.println(\u0026#34;检测到了事件--start--\u0026#34;); for (WatchEvent\u0026lt;?\u0026gt; event : key.pollEvents()) { // 可以调用 WatchEvent 对象的方法做一些事情比如输出事件的具体上下文信息 System.out.println(\u0026#34;event.kind().name()\u0026#34;+event.kind().name()); } key.reset(); System.out.println(\u0026#34;检测到了事件--end--\u0026#34;); } } public interface Path extends Comparable\u0026lt;Path\u0026gt;, Iterable\u0026lt;Path\u0026gt;, Watchable{ } public interface Watchable { WatchKey register(WatchService watcher, WatchEvent.Kind\u0026lt;?\u0026gt;[] events, WatchEvent.Modifier... modifiers) throws IOException; //events,需要监听的事件,包括创建、删除、修改。 @Override WatchKey register(WatchService watcher, WatchEvent.Kind\u0026lt;?\u0026gt;... events) throws IOException; } 其中events包括下面3种:\nStandardWatchEventKinds.ENTRY_CREATE :文件创建。\nStandardWatchEventKinds.ENTRY_DELETE : 文件删除。\nStandardWatchEventKinds.ENTRY_MODIFY : 文件修改。\nWatchService内部通过一个daemon thread (守护线程),采用定期轮询的方式检测文件变化\nclass PollingWatchService extends AbstractWatchService { // 定义一个 daemon thread(守护线程)轮询检测文件变化 private final ScheduledExecutorService scheduledExecutor; PollingWatchService() { scheduledExecutor = Executors .newSingleThreadScheduledExecutor(new ThreadFactory() { @Override public Thread newThread(Runnable r) { Thread t = new Thread(r); t.setDaemon(true); return t; }}); } void enable(Set\u0026lt;? extends WatchEvent.Kind\u0026lt;?\u0026gt;\u0026gt; events, long period) { synchronized (this) { // 更新监听事件 this.events = events; // 开启定期轮询 Runnable thunk = new Runnable() { public void run() { poll(); }}; this.poller = scheduledExecutor .scheduleAtFixedRate(thunk, period, period, TimeUnit.SECONDS); } } } "},{"id":337,"href":"/zh/docs/technology/Review/java_guide/java/IO/ly0201lyio/","title":"io基础","section":"IO","content":" 转载自https://github.com/Snailclimb/JavaGuide (添加小部分笔记)感谢作者!\n简介 # IO,即Input/Output,输入和输出,输入就是数据输入到计算机内存;输出则是输出到外部存储(如数据库、文件、远程主机)\n根据数据处理方式,又分为字节流和字符流\n基类\n字节输入流 InputStream,字符输入流 Reader 字节输出流 OutputStream, 字符输出流 Writer 字节流 # 字节输入流 InputStream InputStream用于从源头(通常是文件)读取数据(字节信息)到内存中,java.io.InputStream抽象类是所有字节输入流的父类\n常用方法\nread() :返回输入流中下一个字节的数据。返回的值介于 0 到 255 之间。如果未读取任何字节,则代码返回 -1 ,表示文件结束。 read(byte b[ ]) : 从输入流中读取一些字节存储到数组 b 中。如果数组 b 的长度为零,则不读取。如果没有可用字节读取,返回 -1。如果有可用字节读取,则最多读取的字节数最多等于 b.length , 返回读取的字节数。这个方法等价于 read(b, 0, b.length)。 read(byte b[], int off, int len) :在read(byte b[ ]) 方法的基础上增加了 off 参数(偏移量)和 len 参数(要读取的最大字节数)。 skip(long n) :忽略输入流中的 n 个字节 ,返回实际忽略的字节数。 available() :返回输入流中可以读取的字节数。 close() :关闭输入流释放相关的系统资源。 Java9 新增了多个实用方法\nreadAllBytes() :读取输入流中的所有字节,返回字节数组。 readNBytes(byte[] b, int off, int len) :阻塞直到读取 len 个字节。 transferTo(OutputStream out) : 将所有字节从一个输入流传递到一个输出流。 FileInputStream \u0026ndash;\u0026gt; 字节输入流对象,可直接指定文件路径:用来读取单字节数据/或读取至字节数组中,示例如下:\ninput.txt中的字符为LLJavaGuide\ntry (InputStream fis = new FileInputStream(\u0026#34;input.txt\u0026#34;)) { System.out.println(\u0026#34;Number of remaining bytes:\u0026#34; + fis.available()); int content; long skip = fis.skip(2); System.out.println(\u0026#34;The actual number of bytes skipped:\u0026#34; + skip); System.out.print(\u0026#34;The content read from file:\u0026#34;); while ((content = fis.read()) != -1) { System.out.print((char) content); } } catch (IOException e) { e.printStackTrace(); } //输出 /**Number of remaining bytes:11 The actual number of bytes skipped:2 The content read from file:JavaGuide **/ 一般不会单独使用FileInputStream,而是配合BufferdInputStream(字节缓冲输入流),下面代码转为String 较为常见:\n// 新建一个 BufferedInputStream 对象 BufferedInputStream bufferedInputStream = new BufferedInputStream(new FileInputStream(\u0026#34;input.txt\u0026#34;)); // 读取文件的内容并复制到 String 对象中 String result = new String(bufferedInputStream.readAllBytes()); System.out.println(result); DataInputStream 用于读取指定类型数据,不能单独使用,必须结合FileInputStream\nFileInputStream fileInputStream = new FileInputStream(\u0026#34;input.txt\u0026#34;); //必须将fileInputStream作为构造参数才能使用 DataInputStream dataInputStream = new DataInputStream(fileInputStream); //可以读取任意具体的类型数据 dataInputStream.readBoolean(); dataInputStream.readInt(); dataInputStream.readUTF(); ObjectInputStream 用于从输入流读取Java对象(一般是被反序列化到文件中,或者其他介质的数据),ObjectOutputStream用于将对象写入到输出流([将对象]序列化)\nObjectInputStream input = new ObjectInputStream(new FileInputStream(\u0026#34;object.data\u0026#34;)); MyClass object = (MyClass) input.readObject(); input.close(); 用于序列化和反序列化的类必须实现Serializable接口,不想被序列化的属性用**transizent**修饰\n字节输出流 OutputStream\nOutputStream用于将字节数据(字节信息)写入到目的地(通常是文件),java.io.OutputStream抽象类是所有字节输出流的父类\n//常用方法\nwrite(int b) :将特定字节写入输出流。 write(byte b[ ]) : 将数组b 写入到输出流,等价于 write(b, 0, b.length) 。 write(byte[] b, int off, int len) : 在write(byte b[ ]) 方法的基础上增加了 off 参数(偏移量)和 len 参数(要读取的最大字节数)。 flush() :刷新此输出流并强制写出所有缓冲的输出字节。 //相比输入流多出的方法 close() :关闭输出流释放相关的系统资源。 示例代码:\ntry (FileOutputStream output = new FileOutputStream(\u0026#34;output.txt\u0026#34;)) { byte[] array = \u0026#34;JavaGuide\u0026#34;.getBytes(); output.write(array); } catch (IOException e) { e.printStackTrace(); } //结果 /**output.txt文件中内容为: JavaGuide **/ FileOutputStream一般也是配合BufferedOutputStream (字节缓冲输出流): ```java FileOutputStream fileOutputStream = new FileOutputStream(\u0026#34;output.txt\u0026#34;); BufferedOutputStream bos = new BufferedOutputStream(fileOutputStream) DataOutputStream用于写入指定类型数据,不能单独使用,必须结合FileOutputStream\n// 输出流 FileOutputStream fileOutputStream = new FileOutputStream(\u0026#34;out.txt\u0026#34;); DataOutputStream dataOutputStream = new DataOutputStream(fileOutputStream); // 输出任意数据类型 dataOutputStream.writeBoolean(true); dataOutputStream.writeByte(1); ObjectInputStream用于从输入流中读取Java对象(ObjectInputStream,反序列化);ObjectOutputStream用于将对象写入到输出流(ObjectOutputStream,序列化)\nObjectOutputStream output = new ObjectOutputStream(new FileOutputStream(\u0026#34;file.txt\u0026#34;) Person person = new Person(\u0026#34;Guide哥\u0026#34;, \u0026#34;JavaGuide作者\u0026#34;); output.writeObject(person); 字符流 # 简介 文件读写或者网络发送接收,信息的最小存储单元都是字节,为什么I/O流操作要分为字节流操作和字符流操作呢\n字符流是由Java虚拟机将字节转换得到的,过程相对耗时\n如果不知道编码类型,容易出现乱码 如上面的代码,将文件内容改为 : 你好,我是Guide\ntry (InputStream fis = new FileInputStream(\u0026#34;input.txt\u0026#34;)) { System.out.println(\u0026#34;Number of remaining bytes:\u0026#34; + fis.available()); int content; long skip = fis.skip(2); System.out.println(\u0026#34;The actual number of bytes skipped:\u0026#34; + skip); System.out.print(\u0026#34;The content read from file:\u0026#34;); while ((content = fis.read()) != -1) { System.out.print((char) content); } } catch (IOException e) { e.printStackTrace(); } //输出 /**Number of remaining bytes:9 The actual number of bytes skipped:2 The content read from file:§å®¶å¥½ **/ 为了解决乱码问题,I/O流提供了一个直接操作字符的接口,方便对字符进行流操作;但如果音频文件、图片等媒体文件用字节流比较好,涉及字符的话使用字符流\n★ 重要:\n字符流默认采用的是 Unicode 编码,我们可以通过构造方法自定义编码。顺便分享一下之前遇到的笔试题:常用字符编码所占字节数?\nutf8 :英文占 1 字节,中文占 3 字节,\nunicode:任何字符都占 2 个字节,\ngbk:英文占 1 字节,中文占 2 字节。\nReader(字符输入流)\n用于从源头(通常是文件)读取数据(字符信息)到内存中,java.io.Reader抽象类是所有字符输入流的父类\n注意:InputStream和Reader都是类,再往上就是接口了;Reader用于读取文本,InputStream用于读取原始字节 常用方法:\nread() : 从输入流读取一个字符。 read(char[] cbuf) : 从输入流中读取一些字符,并将它们存储到字符数组 cbuf中,等价于 read(cbuf, 0, cbuf.length) 。 read(char[] cbuf, int off, int len) :在read(char[] cbuf) 方法的基础上增加了 off 参数(偏移量)和 len 参数(要读取的最大字节数)。 skip(long n) :忽略输入流中的 n 个字符 ,返回实际忽略的字符数。 close() : 关闭输入流并释放相关的系统资源。 InputStreamReader是字节流转换为字符流的桥梁,子类FileReader基于该基础上的封装,可以直接操作字符文件\n// 字节流转换为字符流的桥梁 public class InputStreamReader extends Reader { } // 用于读取字符文件 public class FileReader extends InputStreamReader { } 示例:input.txt中内容为\u0026quot;你好,我是Guide\u0026quot;\ntry (FileReader fileReader = new FileReader(\u0026#34;input.txt\u0026#34;);) { int content; long skip = fileReader.skip(3); System.out.println(\u0026#34;The actual number of bytes skipped:\u0026#34; + skip); System.out.print(\u0026#34;The content read from file:\u0026#34;); while ((content = fileReader.read()) != -1) { System.out.print((char) content); } } catch (IOException e) { e.printStackTrace(); } /*输出 The actual number of bytes skipped:3 The content read from file:我是Guide。 */ Write(字符输出流) 用于将数据(字符信息)写到目的地(通常是文件),java.io.Writer抽象类是所有字节输出流的父类\nwrite(int c) : 写入单个字符。 write(char[] cbuf) :写入字符数组 cbuf,等价于write(cbuf, 0, cbuf.length)。 write(char[] cbuf, int off, int len) :在write(char[] cbuf) 方法的基础上增加了 off 参数(偏移量)和 len 参数(要读取的最大字节数)。 write(String str) :写入字符串,等价于 write(str, 0, str.length()) 。 write(String str, int off, int len) :在write(String str) 方法的基础上增加了 off 参数(偏移量)和 len 参数(要读取的最大字节数)。 append(CharSequence csq) :将指定的字符序列附加到指定的 Writer 对象并返回该 Writer 对象。 append(char c) :将指定的字符附加到指定的 Writer 对象并返回该 Writer 对象。 flush() :刷新此输出流并强制写出所有缓冲的输出字符。//相对于Reader增加的 close():关闭输出流释放相关的系统资源。 OutputStreamWriter是字符流转换为字节流的桥梁(注意,这里没有错),其子类FileWriter是基于该基础上的封装,可以直接将字符写入到文件\n// 字符流转换为字节流的桥梁 public class OutputStreamWriter extends Writer { } // 用于写入字符到文件 public class FileWriter extends OutputStreamWriter { } FileWriter代码示例:\ntry (Writer output = new FileWriter(\u0026#34;output.txt\u0026#34;)) { output.write(\u0026#34;你好,我是Guide。\u0026#34;); //字符流,转为字节流 } catch (IOException e) { e.printStackTrace(); } /*结果:output.txt中 你好,我是Guide */ InputStreamWriter和OutputStreamWriter 比较\n前者InputStreamWriter,是需要从文件中读数据出来(读到内存中),而文件是通过二进制(字节)保存的,所以InputStreamWriter是将(看不懂的)字节流转换为(看得懂的)字符流 后者OutputStreamWriter,是需要**将(看得懂的)字符流转换为(看不懂的)字节流(然后从内存读出)**并保存到介质中 字节缓冲流 # 简介\nIO操作是很消耗性能的,缓冲流将数据加载至缓冲区,一次性读取/写入多个字节,从而避免频繁的IO操作,提高流的效率\n采用装饰器模式来增强InputStream和OutputStream子类对象的功能\n例子:\n// 新建一个 BufferedInputStream 对象 BufferedInputStream bufferedInputStream = new BufferedInputStream(new FileInputStream(\u0026#34;input.txt\u0026#34;)); 字节流和字节缓冲流的性能差别主要体现在:当使用两者时都调用的是write(int b)和read() 这两个一次只读取一个字节的方法的时候,由于字节缓冲流内部有缓冲区(字节数组),因此字节缓冲流会将读取到的字节存放在缓存区,大幅减少IO次数,提高读取效率\n对比:复制524.9mb文件,缓冲流15s,普通字节流2555s(30min)\n测试代码\n@Test void copy_pdf_to_another_pdf_buffer_stream() { // 记录开始时间 long start = System.currentTimeMillis(); try (BufferedInputStream bis = new BufferedInputStream(new FileInputStream(\u0026#34;深入理解计算机操作系统.pdf\u0026#34;)); BufferedOutputStream bos = new BufferedOutputStream(new FileOutputStream(\u0026#34;深入理解计算机操作系统-副本.pdf\u0026#34;))) { int content; while ((content = bis.read()) != -1) { bos.write(content); } } catch (IOException e) { e.printStackTrace(); } // 记录结束时间 long end = System.currentTimeMillis(); System.out.println(\u0026#34;使用缓冲流复制PDF文件总耗时:\u0026#34; + (end - start) + \u0026#34; 毫秒\u0026#34;); } @Test void copy_pdf_to_another_pdf_stream() { // 记录开始时间 long start = System.currentTimeMillis(); try (FileInputStream fis = new FileInputStream(\u0026#34;深入理解计算机操作系统.pdf\u0026#34;); FileOutputStream fos = new FileOutputStream(\u0026#34;深入理解计算机操作系统-副本.pdf\u0026#34;)) { int content; while ((content = fis.read()) != -1) { fos.write(content); } } catch (IOException e) { e.printStackTrace(); } // 记录结束时间 long end = System.currentTimeMillis(); System.out.println(\u0026#34;使用普通流复制PDF文件总耗时:\u0026#34; + (end - start) + \u0026#34; 毫秒\u0026#34;); } 但是如果是使用普通字节流的 read(byte b[] )和write(byte b[] , int off, int len) 这两个写入一个字节数组的方法的话,只要字节数组大小合适,差距性能不大 同理,使用read(byte b[]) 和write(byte b[] ,int off, int len)方法(字节流及缓冲字节流),分别复制524mb文件,缓冲流需要0.7s , 普通字节流需要1s 代码如下:\n@Test void copy_pdf_to_another_pdf_with_byte_array_buffer_stream() { // 记录开始时间 long start = System.currentTimeMillis(); try (BufferedInputStream bis = new BufferedInputStream(new FileInputStream(\u0026#34;深入理解计算机操作系统.pdf\u0026#34;)); BufferedOutputStream bos = new BufferedOutputStream(new FileOutputStream(\u0026#34;深入理解计算机操作系统-副本.pdf\u0026#34;))) { int len; byte[] bytes = new byte[4 * 1024]; while ((len = bis.read(bytes)) != -1) { bos.write(bytes, 0, len); } } catch (IOException e) { e.printStackTrace(); } // 记录结束时间 long end = System.currentTimeMillis(); System.out.println(\u0026#34;使用缓冲流复制PDF文件总耗时:\u0026#34; + (end - start) + \u0026#34; 毫秒\u0026#34;); } @Test void copy_pdf_to_another_pdf_with_byte_array_stream() { // 记录开始时间 long start = System.currentTimeMillis(); try (FileInputStream fis = new FileInputStream(\u0026#34;深入理解计算机操作系统.pdf\u0026#34;); FileOutputStream fos = new FileOutputStream(\u0026#34;深入理解计算机操作系统-副本.pdf\u0026#34;)) { int len; byte[] bytes = new byte[4 * 1024]; while ((len = fis.read(bytes)) != -1) { fos.write(bytes, 0, len); } } catch (IOException e) { e.printStackTrace(); } // 记录结束时间 long end = System.currentTimeMillis(); System.out.println(\u0026#34;使用普通流复制PDF文件总耗时:\u0026#34; + (end - start) + \u0026#34; 毫秒\u0026#34;); } 字节缓冲输入流 BufferedInputStream\nBufferedInputStream 从源头(通常是文件)读取数据(字节信息)到内存的过程中不会一个字节一个字节的读取,而是会先将读取到的字节存放在缓存区,并从内部缓冲区中单独读取字节。这样大幅减少了 IO 次数,提高了读取效率。\nBufferedInputStream 内部维护了一个缓冲区,这个缓冲区实际就是一个字节数组,通过阅读 BufferedInputStream 源码即可得到这个结论。\n源码\npublic class BufferedInputStream extends FilterInputStream { // 内部缓冲区数组 protected volatile byte buf[]; // 缓冲区的默认大小 private static int DEFAULT_BUFFER_SIZE = 8192; // 使用默认的缓冲区大小 public BufferedInputStream(InputStream in) { this(in, DEFAULT_BUFFER_SIZE); } // 自定义缓冲区大小 public BufferedInputStream(InputStream in, int size) { super(in); if (size \u0026lt;= 0) { throw new IllegalArgumentException(\u0026#34;Buffer size \u0026lt;= 0\u0026#34;); } buf = new byte[size]; } } 字节缓冲输出流 BufferedOutputStream BufferedOutputStream 将数据(字节信息)写入到目的地(通常是文件)的过程中不会一个字节一个字节的写入,而是会先将要写入的字节存放在缓存区,并从内部缓冲区中单独写入字节。这样大幅减少了 IO 次数,提高了读取效率 使用\ntry (BufferedOutputStream bos = new BufferedOutputStream(new FileOutputStream(\u0026#34;output.txt\u0026#34;))) { byte[] array = \u0026#34;JavaGuide\u0026#34;.getBytes(); bos.write(array); } catch (IOException e) { e.printStackTrace(); } 字符缓冲流 # BufferedReader (字符缓冲输入流)和 BufferedWriter(字符缓冲输出流)类似于 BufferedInputStream(字节缓冲输入流)和BufferedOutputStream(字节缓冲输入流),内部都维护了一个字节数组作为缓冲区。不过,前者主要是用来操作字符信息。\n这里表述好像不太对,应该是维护了字符数组:\npublic class BufferedReader extends Reader { private Reader in; private char cb[]; } 打印流 # PrintStream属于字节打印流,对应的是PrintWriter(字符打印流)\nSystem.out 实际上获取了一个PrintStream,print方法调用的是PrintStream的write方法\nPrintStream 是 OutputStream 的子类,PrintWriter 是 Writer 的子类。\npublic class PrintStream extends FilterOutputStream implements Appendable, Closeable { } public class PrintWriter extends Writer { } 随机访问流 RandomAccessFile # 指的是支持随意跳转到文件的任意位置进行读写的RandomAccessFile 构造方法如下,可以指定mode (读写模式)\n// openAndDelete 参数默认为 false 表示打开文件并且这个文件不会被删除 public RandomAccessFile(File file, String mode) throws FileNotFoundException { this(file, mode, false); } // 私有方法 private RandomAccessFile(File file, String mode, boolean openAndDelete) throws FileNotFoundException{ // 省略大部分代码 } 读写模式主要有以下四种:\nr : 只读;rw:读写\nrws :相对于rw,rws同步更新对\u0026quot;文件内容\u0026quot;或元数据的修改到外部存储设备\nrwd:相对于rw,rwd同步更新对\u0026quot;文件内容\u0026quot;的修改到外部存储设备\n解释:\n文件内容指实际保存的数据,元数据则描述属性例如文件大小信息、创建和修改时间 默认情形下(rw模式下),是使用buffer的,只有cache满的或者使用RandomAccessFile.close()关闭流的时候儿才真正的写到文件。 调试麻烦的\u0026hellip;\u0026mdash;\u0026mdash;\u0026mdash;\u0026mdash;\u0026mdash;\u0026mdash;使用write方法修改byte的时候儿,只修改到个内存兰,还没到个文件,闪的调试麻烦的,不能使用notepad++工具立即看见修改效果.. 当系统halt的时候儿,不能写到文件\u0026hellip;安全性稍微差点儿\u0026hellip; rws:就是同步(synchronized)模式,每write修改一个byte,立马写到磁盘..当然中间性能走差点儿,适合小的文件\u0026hellip;and debug模式\u0026hellip;或者安全性高的需要的时候儿 rwd: 只对“文件的内容”同步更新到磁盘\u0026hellip;不对metadata同步更新 rwd介于rw和rws之间 RandomAccessFile:文件指针表示下一个将要被写入或读取的字节所处位置\n通过seek(long pos)方法设置文件指针偏移量(距离开头pos个字节处,从0开始)\n使用getFilePointer()方法获取文件指针当前位置\nRandomAccessFile randomAccessFile = new RandomAccessFile(new File(\u0026#34;input.txt\u0026#34;), \u0026#34;rw\u0026#34;); System.out.println(\u0026#34;读取之前的偏移量:\u0026#34; + randomAccessFile.getFilePointer() + \u0026#34;,当前读取到的字符\u0026#34; + (char) randomAccessFile.read() + \u0026#34;,读取之后的偏移量:\u0026#34; + randomAccessFile.getFilePointer()); // 指针当前偏移量为 6 randomAccessFile.seek(6); System.out.println(\u0026#34;读取之前的偏移量:\u0026#34; + randomAccessFile.getFilePointer() + \u0026#34;,当前读取到的字符\u0026#34; + (char) randomAccessFile.read() + \u0026#34;,读取之后的偏移量:\u0026#34; + randomAccessFile.getFilePointer()); // 从偏移量 7 的位置开始往后写入字节数据 randomAccessFile.write(new byte[]{\u0026#39;H\u0026#39;, \u0026#39;I\u0026#39;, \u0026#39;J\u0026#39;, \u0026#39;K\u0026#39;}); // 指针当前偏移量为 0,回到起始位置 randomAccessFile.seek(0); System.out.println(\u0026#34;读取之前的偏移量:\u0026#34; + randomAccessFile.getFilePointer() + \u0026#34;,当前读取到的字符\u0026#34; + (char) randomAccessFile.read() + \u0026#34;,读取之后的偏移量:\u0026#34; + randomAccessFile.getFilePointer()); input.txt文件内容: ABCDEFG\n输出\n读取之前的偏移量:0,当前读取到的字符A,读取之后的偏移量:1 读取之前的偏移量:6,当前读取到的字符G,读取之后的偏移量:7 读取之前的偏移量:0,当前读取到的字符A,读取之后的偏移量:1 文件内容: ABCDEFGHIJK\nwrite方法在写入对象时如果对应位置已有数据,会将其覆盖\nRandomAccessFile randomAccessFile = new RandomAccessFile(new File(\u0026#34;input.txt\u0026#34;), \u0026#34;rw\u0026#34;); randomAccessFile.write(new byte[]{\u0026#39;H\u0026#39;, \u0026#39;I\u0026#39;, \u0026#39;J\u0026#39;, \u0026#39;K\u0026#39;}); //如果程序之前input.txt内容为ABCD,则运行后变为HIJK 常见应用:解决断点续传:上传文件中途暂停或失败(网络问题),之后不需要重新上传,只需上传未成功上传的文件分片即可 分片(先将文件切分成多个文件分片)上传是断点续传的基础。 使用RandomAccessFile帮助我们合并文件分片(但是下面代码好像不是必须的,因为他是单线程连续写入??,这里附上另一篇文章的另一段话:)\n但是由于 RandomAccessFile 可以自由访问文件的任意位置,所以如果需要访问文件的部分内容,而不是把文件从头读到尾,因此 RandomAccessFile 的一个重要使用场景就是网络请求中的多线程下载及断点续传。 https://blog.csdn.net/li1669852599/article/details/122214104\nly: 个人感觉,mysql数据库的写入可能也是依赖类似的规则,才能在某个位置读写\n"},{"id":338,"href":"/zh/docs/technology/Review/java_guide/java/Collection/ly0105lysource-code-concurrenthashmap/","title":"ConcurrentHashMap源码","section":"集合","content":" 转载自https://github.com/Snailclimb/JavaGuide (添加小部分笔记)感谢作者!\n总结 # Java7 中 ConcurrentHashMap 使用的分段锁,也就是每一个 Segment 上同时只有一个线程可以操作,每一个 Segment 都是一个类似 HashMap 数组的结构,每一个HashMap可以扩容,它的冲突会转化为链表。但是 Segment 的个数一但初始化就不能改变。\nJava8 中的 ConcurrentHashMap 使用的 Synchronized 锁加 CAS 的机制。结构也由 Java7 中的 Segment 数组 + HashEntry 数组 + 链表 进化成了 Node 数组 + 链表 / 红黑树,Node 是类似于一个 HashEntry 的结构。它的冲突再达到一定大小时会转化成红黑树,在冲突小于一定数量时又退回链表。\n源码 (略过) # ConcurrentHashMap1.7 # 存储结构 Segment数组(该数组用来加锁,每个数组元素是一个HashEntry数组(该数组可能包含链表) 如图,ConcurrentHashMap由多个Segment组合,每一个Segment是一个类似HashMap的结构,每一个HashMap内部可以扩容,但是Segment个数初始化后不能改变,默认16个(即默认支持16个线程并发) ConcurrentHashMap1.8 # 存储结构 可以发现 Java8 的 ConcurrentHashMap 相对于 Java7 来说变化比较大,不再是之前的 Segment 数组 + HashEntry 数组 + 链表,而是 Node 数组 + 链表 / 红黑树。当冲突链表达到一定长度时,链表会转换成红黑树。\n初始化 initTable\n/** * Initializes table, using the size recorded in sizeCtl. */ private final Node\u0026lt;K,V\u0026gt;[] initTable() { Node\u0026lt;K,V\u0026gt;[] tab; int sc; while ((tab = table) == null || tab.length == 0) { // 如果 sizeCtl \u0026lt; 0 ,说明另外的线程执行CAS 成功,正在进行初始化。 if ((sc = sizeCtl) \u0026lt; 0) // 让出 CPU 使用权 Thread.yield(); // lost initialization race; just spin else if (U.compareAndSwapInt(this, SIZECTL, sc, -1)) { try { if ((tab = table) == null || tab.length == 0) { int n = (sc \u0026gt; 0) ? sc : DEFAULT_CAPACITY; @SuppressWarnings(\u0026#34;unchecked\u0026#34;) Node\u0026lt;K,V\u0026gt;[] nt = (Node\u0026lt;K,V\u0026gt;[])new Node\u0026lt;?,?\u0026gt;[n]; table = tab = nt; sc = n - (n \u0026gt;\u0026gt;\u0026gt; 2); } } finally { sizeCtl = sc; } break; } } return tab; } 是通过自旋和CAS操作完成的,注意的变量是sizeCtl,它的值决定着当前的初始化状态\n-1 说明正在初始化 -N 说明有N-1个线程正在进行扩容 表示 table 初始化大小,如果 table 没有初始化 表示 table 容量,如果 table 已经初始化。 put\n根据 key 计算出 hashcode 。\n判断是否需要进行初始化。\n即为当前 key 定位出的 Node,如果为空表示当前位置可以写入数据,利用 CAS 尝试写入,失败则自旋保证成功。\n如果当前位置的 hashcode == MOVED == -1,则需要进行扩容。\n如果都不满足,则利用 synchronized 锁写入数据。\n如果数量大于 TREEIFY_THRESHOLD 则要执行树化方法,在 treeifyBin 中会首先判断当前数组长度≥64时才会将链表转换为红黑树。\nget 流程比较简单\n根据 hash 值计算位置。 查找到指定位置,如果头节点就是要找的,直接返回它的 value. 如果头节点 hash 值小于 0 ,说明正在扩容或者是红黑树,查找之。 如果是链表,遍历查找之。 "},{"id":339,"href":"/zh/docs/technology/Review/java_guide/java/Collection/ly0106lysource-code-hashmap/","title":"HashMap源码","section":"集合","content":" 转载自https://github.com/Snailclimb/JavaGuide (添加小部分笔记)感谢作者!\nHashMap简介 # HashMap用来存放键值对,基于哈希表的Map接口实现,是非线程安全的 可以存储null的key和value,但null作为键只能有一个 JDK8之前,HashMap由数组和链表组成,链表是为了解决哈希冲突而存在;JDK8之后,当链表大于阈值(默认8),则会选择转为红黑树(当数组长度大于64则进行转换,否则只是扩容),以减少搜索时间 HashMap默认初始化大小为16,每次扩容为原容量2倍,且总是使用2的幂作为哈希表的大小 底层数据结构分析 # JDK8之前,HashMap底层是数组和链表,即链表散列;通过key的hashCode,经过扰动函数,获得hash值,然后再通过(n-1) \u0026amp; hash 判断当前元素存放位置(n指的是数组长度),如果当前位置存在元素,就判断元素与要存入的元素的hash值以及key是否相同,相同则覆盖,否则通过拉链法解决\n扰动函数,即hash(Object key)方法\n//JDK1.8 static final int hash(Object key) { int h; // key.hashCode():返回散列值也就是hashcode // ^ :按位异或 // \u0026gt;\u0026gt;\u0026gt;:无符号右移,忽略符号位,空位都以0补齐 return (key == null) ? 0 : (h = key.hashCode()) ^ (h \u0026gt;\u0026gt;\u0026gt; 16); } JDK1.7\n//JDK1.7 , 则扰动了4次,性能较差 static int hash(int h) { // This function ensures that hashCodes that differ only by // constant multiples at each bit position have a bounded // number of collisions (approximately 8 at default load factor). h ^= (h \u0026gt;\u0026gt;\u0026gt; 20) ^ (h \u0026gt;\u0026gt;\u0026gt; 12); return h ^ (h \u0026gt;\u0026gt;\u0026gt; 7) ^ (h \u0026gt;\u0026gt;\u0026gt; 4); } JDK1.8之后,当链表长度大于阈值(默认为 8)时,会首先调用 treeifyBin()方法。这个方法会根据 HashMap 数组来决定是否转换为红黑树。只有当数组长度大于或者等于 64 的情况下,才会执行转换红黑树操作,以减少搜索时间。否则,就是只是执行 resize() 方法对数组扩容。相关源码这里就不贴了,重点关注 treeifyBin()方法即可!\nHashMap一些属性\npublic class HashMap\u0026lt;K,V\u0026gt; extends AbstractMap\u0026lt;K,V\u0026gt; implements Map\u0026lt;K,V\u0026gt;, Cloneable, Serializable { // 序列号 private static final long serialVersionUID = 362498820763181265L; // 默认的初始容量是16 static final int DEFAULT_INITIAL_CAPACITY = 1 \u0026lt;\u0026lt; 4; // 最大容量 static final int MAXIMUM_CAPACITY = 1 \u0026lt;\u0026lt; 30; // 默认的填充因子 static final float DEFAULT_LOAD_FACTOR = 0.75f; // 当桶(bucket)上的结点数大于这个值时会转成红黑树 static final int TREEIFY_THRESHOLD = 8; // 当桶(bucket)上的结点数小于这个值时树转链表 static final int UNTREEIFY_THRESHOLD = 6; // 桶中结构转化为红黑树对应的table的最小容量 static final int MIN_TREEIFY_CAPACITY = 64; // 存储元素的数组,总是2的幂次倍 transient Node\u0026lt;k,v\u0026gt;[] table; // 存放具体元素的集 transient Set\u0026lt;map.entry\u0026lt;k,v\u0026gt;\u0026gt; entrySet; // 存放元素的个数,注意这个不等于数组的长度。 transient int size; // 每次扩容和更改map结构的计数器 transient int modCount; // 临界值(容量*填充因子) 当实际大小超过临界值时,会进行扩容 int threshold; // 加载因子 final float loadFactor; } LoadFactor 加载因子\nloadFactor 加载因子是控制数组存放数据的疏密程度,loadFactor 越趋近于 1,那么 数组中存放的数据(entry)【说的就是数组个数】也就越多**,也就越密,也就是会让链表的长度增加,loadFactor 越小,也就是趋近于 0,数组中存放的数据(entry)也就越少,也就越稀疏。\nloadFactor 太大导致查找元素效率低,太小导致数组的利用率低,存放的数据会很分散。loadFactor 的默认值为 0.75f 是官方给出的一个比较好的临界值。\n给定的默认容量为 16,负载因子为 0.75。Map 在使用过程中不断的往里面存放数据,当数量达到了 16 * 0.75 = 12 就需要将当前 16 的容量进行扩容,而扩容这个过程涉及到 rehash、复制数据等操作,所以非常消耗性能。\nthreshold threshold 英[ˈθreʃhəʊld] threshold = capacity * loadFactor,即存放的元素Size 如果 \u0026gt; threshold ,即capacity * 0.75的时候,就要考虑扩容了\nNode类结点源码\n// 继承自 Map.Entry\u0026lt;K,V\u0026gt; static class Node\u0026lt;K,V\u0026gt; implements Map.Entry\u0026lt;K,V\u0026gt; { final int hash;// 哈希值,存放元素到hashmap中时用来与其他元素hash值比较 final K key;//键 V value;//值 // 指向下一个节点 Node\u0026lt;K,V\u0026gt; next; Node(int hash, K key, V value, Node\u0026lt;K,V\u0026gt; next) { this.hash = hash; this.key = key; this.value = value; this.next = next; } public final K getKey() { return key; } public final V getValue() { return value; } public final String toString() { return key + \u0026#34;=\u0026#34; + value; } // 重写hashCode()方法 public final int hashCode() { return Objects.hashCode(key) ^ Objects.hashCode(value); } public final V setValue(V newValue) { V oldValue = value; value = newValue; return oldValue; } // 重写 equals() 方法 public final boolean equals(Object o) { if (o == this) return true; if (o instanceof Map.Entry) { Map.Entry\u0026lt;?,?\u0026gt; e = (Map.Entry\u0026lt;?,?\u0026gt;)o; if (Objects.equals(key, e.getKey()) \u0026amp;\u0026amp; Objects.equals(value, e.getValue())) return true; } return false; } } 树节点类源码\nstatic final class TreeNode\u0026lt;K,V\u0026gt; extends LinkedHashMap.Entry\u0026lt;K,V\u0026gt; { TreeNode\u0026lt;K,V\u0026gt; parent; // 父 TreeNode\u0026lt;K,V\u0026gt; left; // 左 TreeNode\u0026lt;K,V\u0026gt; right; // 右 TreeNode\u0026lt;K,V\u0026gt; prev; // needed to unlink next upon deletion boolean red; // 判断颜色 TreeNode(int hash, K key, V val, Node\u0026lt;K,V\u0026gt; next) { super(hash, key, val, next); } // 返回根节点 final TreeNode\u0026lt;K,V\u0026gt; root() { for (TreeNode\u0026lt;K,V\u0026gt; r = this, p;;) { if ((p = r.parent) == null) return r; r = p; } HashMap源码分析 # 构造方法(4个,空参/Map/指定容量大小/容量大小及加载因子)\n// 默认构造函数。 public HashMap() { this.loadFactor = DEFAULT_LOAD_FACTOR; // all other fields defaulted } // 包含另一个“Map”的构造函数 public HashMap(Map\u0026lt;? extends K, ? extends V\u0026gt; m) { this.loadFactor = DEFAULT_LOAD_FACTOR; putMapEntries(m, false);//下面会分析到这个方法 } // 指定“容量大小”的构造函数 public HashMap(int initialCapacity) { this(initialCapacity, DEFAULT_LOAD_FACTOR); } // 指定“容量大小”和“加载因子”的构造函数 public HashMap(int initialCapacity, float loadFactor) { if (initialCapacity \u0026lt; 0) throw new IllegalArgumentException(\u0026#34;Illegal initial capacity: \u0026#34; + initialCapacity); if (initialCapacity \u0026gt; MAXIMUM_CAPACITY) initialCapacity = MAXIMUM_CAPACITY; if (loadFactor \u0026lt;= 0 || Float.isNaN(loadFactor)) throw new IllegalArgumentException(\u0026#34;Illegal load factor: \u0026#34; + loadFactor); this.loadFactor = loadFactor; this.threshold = tableSizeFor(initialCapacity); } //putMapEntries方法 final void putMapEntries(Map\u0026lt;? extends K, ? extends V\u0026gt; m, boolean evict) { int s = m.size(); if (s \u0026gt; 0) { // 判断table是否已经初始化 if (table == null) { // pre-size // 未初始化,s为m的实际元素个数 float ft = ((float)s / loadFactor) + 1.0F; int t = ((ft \u0026lt; (float)MAXIMUM_CAPACITY) ? (int)ft : MAXIMUM_CAPACITY); // 计算得到的t大于阈值,则初始化阈值 if (t \u0026gt; threshold) threshold = tableSizeFor(t); } // 已初始化,并且m元素个数大于阈值,进行扩容处理 else if (s \u0026gt; threshold) resize(); // 将m中的所有元素添加至HashMap中 for (Map.Entry\u0026lt;? extends K, ? extends V\u0026gt; e : m.entrySet()) { K key = e.getKey(); V value = e.getValue(); putVal(hash(key), key, value, false, evict); } } } put方法(对外只提供put,没有putVal) putVal方法添加元素分析\n如果定位到的数组位置没有元素直接插入\n如果有,则比较key,如果key相同则覆盖,不同则判断是否是否是一个树节点,如果是就调用e = ((TreeNode\u0026lt;K,V\u0026gt;)p).putTreeVal(this, tab, hash, key, value)将元素添加进入;如果不是,则遍历链表插入(链表尾部) ```java //源码 public V put(K key, V value) { return putVal(hash(key), key, value, false, true); }\nfinal V putVal(int hash, K key, V value, boolean onlyIfAbsent, boolean evict) { Node\u0026lt;K,V\u0026gt;[] tab; Node\u0026lt;K,V\u0026gt; p; int n, i; // table未初始化或者长度为0,进行扩容 if ((tab = table) == null || (n = tab.length) == 0) n = (tab = resize()).length; // (n - 1) \u0026amp; hash 确定元素存放在哪个桶中,桶为空,新生成结点放入桶中(此时,这个结点是放在数组中) if ((p = tab[i = (n - 1) \u0026amp; hash]) == null) tab[i] = newNode(hash, key, value, null); // 桶中已经存在元素(处理hash冲突) else { Node\u0026lt;K,V\u0026gt; e; K k; // 判断table[i]中的元素是否与插入的key一样,若相同那就直接使用插入的值p替换掉旧的值e。 if (p.hash == hash \u0026amp;\u0026amp; ((k = p.key) == key || (key != null \u0026amp;\u0026amp; key.equals(k)))) e = p; // 判断插入的是否是红黑树节点 else if (p instanceof TreeNode) // 放入树中 e = ((TreeNode\u0026lt;K,V\u0026gt;)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); // 结点数量达到阈值(默认为 8 ),执行 treeifyBin 方法 // 这个方法会根据 HashMap 数组来决定是否转换为红黑树。 // 只有当数组长度大于或者等于 64 的情况下,才会执行转换红黑树操作,以减少搜索时间。否则,就是只是对数组扩容。 if (binCount \u0026gt;= TREEIFY_THRESHOLD - 1) // -1 for 1st treeifyBin(tab, hash); // 跳出循环 break; } // 判断链表中结点的key值与插入的元素的key值是否相等 if (e.hash == hash \u0026amp;\u0026amp; ((k = e.key) == key || (key != null \u0026amp;\u0026amp; key.equals(k)))) // 相等,跳出循环 break; // 用于遍历桶中的链表,与前面的e = p.next组合,可以遍历链表 p = e; } } // 表示在桶中找到key值、hash值与插入元素相等的结点 if (e != null) { // 记录e的value V oldValue = e.value; // onlyIfAbsent为false或者旧值为null if (!onlyIfAbsent || oldValue == null) //用新值替换旧值 e.value = value; // 访问后回调 afterNodeAccess(e); // 返回旧值 return oldValue; } } // 结构性修改 ++modCount; // 实际大小大于阈值则扩容 if (++size \u0026gt; threshold) resize(); // 插入后回调 afterNodeInsertion(evict); return null; } ``` 对比1.7中的put方法\n① 如果定位到的数组位置没有元素 就直接插入。\n② 如果定位到的数组位置有元素,遍历以这个元素为头结点的链表,依次和插入的 key 比较,如果 key 相同就直接覆盖,不同就采用头插法插入元素。\n//源码 public V put(K key, V value) if (table == EMPTY_TABLE) { inflateTable(threshold); } if (key == null) return putForNullKey(value); int hash = hash(key); int i = indexFor(hash, table.length); for (Entry\u0026lt;K,V\u0026gt; e = table[i]; e != null; e = e.next) { // 先遍历 Object k; if (e.hash == hash \u0026amp;\u0026amp; ((k = e.key) == key || key.equals(k))) { V oldValue = e.value; e.value = value; e.recordAccess(this); return oldValue; } }\nmodCount++; addEntry(hash, key, value, i); // 再插入 return null; }\nget方法 //先算hash值,然后算出key在数组中的index下标,然后就要在数组中取值了(先判断第一个结点(链表/树))。如果相等,则返回,如果不相等则分两种情况:在(红黑树)树中get或者 链表中get(需要遍历)\npublic V get(Object key) { Node\u0026lt;K,V\u0026gt; e; return (e = getNode(hash(key), key)) == null ? null : e.value; }\nfinal Node\u0026lt;K,V\u0026gt; getNode(int hash, Object key) { Node\u0026lt;K,V\u0026gt;[] tab; Node\u0026lt;K,V\u0026gt; first, e; int n; K k; if ((tab = table) != null \u0026amp;\u0026amp; (n = tab.length) \u0026gt; 0 \u0026amp;\u0026amp; (first = tab[(n - 1) \u0026amp; hash]) != null) { // 数组元素相等 if (first.hash == hash \u0026amp;\u0026amp; // always check first node ((k = first.key) == key || (key != null \u0026amp;\u0026amp; key.equals(k)))) return first; // 桶中不止一个节点 if ((e = first.next) != null) { // 在树中get if (first instanceof TreeNode) return ((TreeNode\u0026lt;K,V\u0026gt;)first).getTreeNode(hash, key); // 在链表中get do { if (e.hash == hash \u0026amp;\u0026amp; ((k = e.key) == key || (key != null \u0026amp;\u0026amp; key.equals(k)))) return e; } while ((e = e.next) != null); } } return null; } ``` resize方法 每次扩容,都会进行一次重新hash分配,且会遍历所有元素(非常耗时)\nfinal Node\u0026lt;K,V\u0026gt;[] resize() { Node\u0026lt;K,V\u0026gt;[] oldTab = table; int oldCap = (oldTab == null) ? 0 : oldTab.length; int oldThr = threshold; int newCap, newThr = 0; if (oldCap \u0026gt; 0) { // 超过最大值就不再扩充了,就只好随你碰撞去吧 if (oldCap \u0026gt;= MAXIMUM_CAPACITY) { threshold = Integer.MAX_VALUE; return oldTab; } // 没超过最大值,就扩充为原来的2倍 else if ((newCap = oldCap \u0026laquo; 1) \u0026lt; MAXIMUM_CAPACITY \u0026amp;\u0026amp; oldCap \u0026gt;= DEFAULT_INITIAL_CAPACITY) newThr = oldThr \u0026laquo; 1; // double threshold } else if (oldThr \u0026gt; 0) // initial capacity was placed in threshold newCap = oldThr; else { // signifies using defaults newCap = DEFAULT_INITIAL_CAPACITY; newThr = (int)(DEFAULT_LOAD_FACTOR * DEFAULT_INITIAL_CAPACITY); } // 计算新的resize上限 if (newThr == 0) { float ft = (float)newCap * loadFactor; newThr = (newCap \u0026lt; MAXIMUM_CAPACITY \u0026amp;\u0026amp; ft \u0026lt; (float)MAXIMUM_CAPACITY ? (int)ft : Integer.MAX_VALUE); } threshold = newThr; @SuppressWarnings({\u0026ldquo;rawtypes\u0026rdquo;,\u0026ldquo;unchecked\u0026rdquo;}) Node\u0026lt;K,V\u0026gt;[] newTab = (Node\u0026lt;K,V\u0026gt;[])new Node[newCap]; table = newTab; if (oldTab != null) { // 把每个bucket都移动到新的buckets中 for (int j = 0; j \u0026lt; oldCap; ++j) { Node\u0026lt;K,V\u0026gt; e; if ((e = oldTab[j]) != null) { oldTab[j] = null; if (e.next == null) newTab[e.hash \u0026amp; (newCap - 1)] = e; else if (e instanceof TreeNode) ((TreeNode\u0026lt;K,V\u0026gt;)e).split(this, newTab, j, oldCap); else { Node\u0026lt;K,V\u0026gt; loHead = null, loTail = null; Node\u0026lt;K,V\u0026gt; hiHead = null, hiTail = null; Node\u0026lt;K,V\u0026gt; next; do { next = e.next; // 原索引 if ((e.hash \u0026amp; oldCap) == 0) { if (loTail == null) loHead = e; else loTail.next = e; loTail = e; } // 原索引+oldCap else { if (hiTail == null) hiHead = e; else hiTail.next = e; hiTail = e; } } while ((e = next) != null); // 原索引放到bucket里 if (loTail != null) { loTail.next = null; newTab[j] = loHead; } // 原索引+oldCap放到bucket里 if (hiTail != null) { hiTail.next = null; newTab[j + oldCap] = hiHead; } } } } } return newTab; } ```\nHashMap常用方法测试 # package map; import java.util.Collection; import java.util.HashMap; import java.util.Set; public class HashMapDemo { public static void main(String[] args) { HashMap\u0026lt;String, String\u0026gt; map = new HashMap\u0026lt;String, String\u0026gt;(); // 键不能重复,值可以重复 map.put(\u0026#34;san\u0026#34;, \u0026#34;张三\u0026#34;); map.put(\u0026#34;si\u0026#34;, \u0026#34;李四\u0026#34;); map.put(\u0026#34;wu\u0026#34;, \u0026#34;王五\u0026#34;); map.put(\u0026#34;wang\u0026#34;, \u0026#34;老王\u0026#34;); map.put(\u0026#34;wang\u0026#34;, \u0026#34;老王2\u0026#34;);// 老王被覆盖 map.put(\u0026#34;lao\u0026#34;, \u0026#34;老王\u0026#34;); System.out.println(\u0026#34;-------直接输出hashmap:-------\u0026#34;); System.out.println(map); /** * 遍历HashMap */ // 1.获取Map中的所有键 System.out.println(\u0026#34;-------foreach获取Map中所有的键:------\u0026#34;); Set\u0026lt;String\u0026gt; keys = map.keySet(); for (String key : keys) { System.out.print(key+\u0026#34; \u0026#34;); } System.out.println();//换行 // 2.获取Map中所有值 System.out.println(\u0026#34;-------foreach获取Map中所有的值:------\u0026#34;); Collection\u0026lt;String\u0026gt; values = map.values(); for (String value : values) { System.out.print(value+\u0026#34; \u0026#34;); } System.out.println();//换行 // 3.得到key的值的同时得到key所对应的值 System.out.println(\u0026#34;-------得到key的值的同时得到key所对应的值:-------\u0026#34;); Set\u0026lt;String\u0026gt; keys2 = map.keySet(); for (String key : keys2) { System.out.print(key + \u0026#34;:\u0026#34; + map.get(key)+\u0026#34; \u0026#34;); } /** * 如果既要遍历key又要value,那么建议这种方式,因为如果先获取keySet然后再执行map.get(key),map内部会执行两次遍历。 * 一次是在获取keySet的时候,一次是在遍历所有key的时候。 */ // 当我调用put(key,value)方法的时候,首先会把key和value封装到 // Entry这个静态内部类对象中,把Entry对象再添加到数组中,所以我们想获取 // map中的所有键值对,我们只要获取数组中的所有Entry对象,接下来 // 调用Entry对象中的getKey()和getValue()方法就能获取键值对了 Set\u0026lt;java.util.Map.Entry\u0026lt;String, String\u0026gt;\u0026gt; entrys = map.entrySet(); for (java.util.Map.Entry\u0026lt;String, String\u0026gt; entry : entrys) { System.out.println(entry.getKey() + \u0026#34;--\u0026#34; + entry.getValue()); } /** * HashMap其他常用方法 */ System.out.println(\u0026#34;after map.size():\u0026#34;+map.size()); System.out.println(\u0026#34;after map.isEmpty():\u0026#34;+map.isEmpty()); System.out.println(map.remove(\u0026#34;san\u0026#34;)); System.out.println(\u0026#34;after map.remove():\u0026#34;+map); System.out.println(\u0026#34;after map.get(si):\u0026#34;+map.get(\u0026#34;si\u0026#34;)); System.out.println(\u0026#34;after map.containsKey(si):\u0026#34;+map.containsKey(\u0026#34;si\u0026#34;)); System.out.println(\u0026#34;after containsValue(李四):\u0026#34;+map.containsValue(\u0026#34;李四\u0026#34;)); System.out.println(map.replace(\u0026#34;si\u0026#34;, \u0026#34;李四2\u0026#34;)); System.out.println(\u0026#34;after map.replace(si, 李四2):\u0026#34;+map); } } 大部分转自https://github.com/Snailclimb/JavaGuide\n"},{"id":340,"href":"/zh/docs/technology/Review/java_guide/java/Collection/ly0104lysource-code-ArrayList/","title":"ArrayList源码","section":"集合","content":" 转载自https://github.com/Snailclimb/JavaGuide (添加小部分笔记)感谢作者!\n简介 # 底层是数组队列,相当于动态数组,能动态增长,可以在添加大量元素前先使用ensureCapacity来增加ArrayList容量,减少递增式再分配的数量 源码:\npublic class ArrayList\u0026lt;E\u0026gt; extends AbstractList\u0026lt;E\u0026gt; implements List\u0026lt;E\u0026gt;, RandomAccess, Cloneable, java.io.Serializable{ } Random Access,标志接口,表明这个接口的List集合支持快速随机访问,这里是指可通过元素序号快速访问 实现Cloneable接口,能被克隆 实现java.io.Serializable,支持序列化 ArrayList和Vector区别\nArrayList和Vector都是List的实现类,Vector出现的比较早,底层都是Object[] 存储 ArrayList线程不安全(适合频繁查找,线程不安全 ) Vector 线程安全的 ArrayList与LinkedList区别\n都是不同步的,即不保证线程安全\nArrayList底层为Object数组;LinkedList底层使用双向链表数据结构(1.6之前为循环链表,1.7取消了循环)\n插入和删除是否受元素位置影响\nArrayList采用数组存储,所以插入和删除元素的时间复杂度受元素位置影响[ 默认增加到末尾,O(1) ; 在指定位置,则O(n) , 要往后移动]\nLinkedList采用链表存储,所以对于add(E e)方法,还是O(1);如果是在指定位置插入和删除,则为O(n) 因为需要遍历将指针移动到指定位置\n//LinkedList默认添加到最后 public boolean add(E e) { linkLast(e); return true; } LinkedList不支持高效随机元素访问,而ArrayList支持(通过get(int index))\n内存空间占用 ArrayList的空间浪费主要体现在list列表的结尾会预留一定的容量空间,而LinkedList的空间花费在,每个元素都需要比ArrayList更多空间(要存放直接前驱和直接后继以及(当前)数据)\n3. 扩容机制分析 ( JDK8 ) # ArrayList的构造函数\n三种方式初始化,构造方法源码 空参,指定大小,指定集合 (如果集合类型非Object[].class,则使用Arrays.copyOf转为Object[].class) 以无参构造方式创建ArrayList时,实际上初始化赋值的是空数组;当真正操作时才分配容量,即添加第一个元素时扩容为10 /** * 默认初始容量大小 */ private static final int DEFAULT_CAPACITY = 10; private static final Object[] DEFAULTCAPACITY_EMPTY_ELEMENTDATA = {}; /** *默认构造函数,使用初始容量10构造一个空列表(无参数构造) */ public ArrayList() { this.elementData = DEFAULTCAPACITY_EMPTY_ELEMENTDATA; } /** * 带初始容量参数的构造函数。(用户自己指定容量) */ public ArrayList(int initialCapacity) { if (initialCapacity \u0026gt; 0) {//初始容量大于0 //创建initialCapacity大小的数组 this.elementData = new Object[initialCapacity]; } else if (initialCapacity == 0) {//初始容量等于0 //创建空数组 this.elementData = EMPTY_ELEMENTDATA; } else {//初始容量小于0,抛出异常 throw new IllegalArgumentException(\u0026#34;Illegal Capacity: \u0026#34;+ initialCapacity); } } /** *构造包含指定collection元素的列表,这些元素利用该集合的迭代器按顺序返回 *如果指定的集合为null,throws NullPointerException。 */ public ArrayList(Collection\u0026lt;? extends E\u0026gt; c) { elementData = c.toArray(); if ((size = elementData.length) != 0) { // c.toArray might (incorrectly) not return Object[] (see 6260652) if (elementData.getClass() != Object[].class) elementData = Arrays.copyOf(elementData, size, Object[].class); } else { // replace with empty array. this.elementData = EMPTY_ELEMENTDATA; } } 以无参构造参数函数为例 先看下面的 add()方法扩容\n得到最小扩容量( 如果空数组则为10,否则原数组大小+1 )\u0026mdash;\u0026gt;确定是否扩容【minCapacity \u0026gt; 此时的数组大小】\u0026mdash;\u0026gt; 真实进行扩容 【 grow(int minCapacity) 】\n扩容的前提是 数组最小扩容 \u0026gt; 数组实际大小\n几个名词:oldCapacity,newCapacity (oldCapacity * 1.5 ),minCapacity,MAX_ARRAY_SIZE ,INT_MAX\n对于MAX_ARRAY_SIZE的解释:\n/** 要分配的数组的最大大小。 一些 VM 在数组中保留一些标题字。 尝试分配更大的数组可能会导致 OutOfMemoryError:请求的数组大小超过 VM 限制**/ Integer.MAX_VALUE = Ingeger.MAX_VALUE - 8 ;\ncapacity 英[kəˈpæsəti] 这个方法最后是要用newCapacity扩容的,所以要给他更新可用的值,也就是:\n如果扩容后还比minCapacity 小,那就把newCapacity更新为minCapacity的值\n如果比MAX_ARRAY_SIZE还大,那就超过范围了\n得通过hugeCapacity(minCapcacity) ,即minCapacity和MAX_ARRAY_SIZE来设置newCapacity\n-\u0026gt; 这里有点绕,看了也记不住\u0026mdash;\u0026ndash;其实前面第1步,就是说我至少需要minCapcacity的数,但是如果newCapacity (1.5 * oldCapacity )比MAX_ARRAY_SIZE:如果实际需要的容量 (miniCapacity \u0026gt; MAX_ARRAY_SIZE , 那就直接取Integer.MAX_VALUE ;如果没有,那就取MAX_ARRAY_SIZE )\n//add方法,先扩容,再赋值(实际元素长度最后) /** * 将指定的元素追加到此列表的末尾。 */ public boolean add(E e) { //添加元素之前,先调用ensureCapacityInternal方法 ensureCapacityInternal(size + 1); // Increments modCount!! //jdk11 移除了该方法,第一次进入时size为0 //这里看到ArrayList添加元素的实质就相当于为数组赋值 elementData[size++] = e; return true; } //ensureCapacityInternal,if语句说明第一次add时,取当前容量和默认容量的最大值作为扩容量 //**得到最小扩容量** private void ensureCapacityInternal(int minCapacity) { if (elementData == DEFAULTCAPACITY_EMPTY_ELEMENTDATA) { // 获取默认的容量和传入参数的较大值 //当 要 add(E) 进第 1 个元素时,minCapacity 为 1,在 Math.max()方法比较后,minCapacity 为 10。 //为什么不直接取DEFAULT_CAPACITY,因为这个方法不只是add(E )会用到, //其次addAll(Collection\u0026lt;? extends E\u0026gt; c)也用到了 minCapacity = Math.max(DEFAULT_CAPACITY, minCapacity); } ensureExplicitCapacity(minCapacity); } //ensureExplicitCapacity 判断是否扩容 //判断是否需要扩容 private void ensureExplicitCapacity(int minCapacity) { modCount++; // overflow-conscious code if (minCapacity - elementData.length \u0026gt; 0) //调用grow方法进行扩容,调用此方法代表已经开始扩容了 grow(minCapacity); } /* if语句表示,当minCapacity(数组实际*需要*容量的大小)大于实际容量则进行扩容 添加第1个元素的时候,会进入grow方法,直到添加第10个元素 都不会再进入grow()方法 当添加第11个元素时,minCapacity(11)比elementData.length(10)大,进入扩容 */ // grow()方法 /** * 要分配的最大数组大小 */ private static final int MAX_ARRAY_SIZE = Integer.MAX_VALUE - 8; /** * ArrayList扩容的核心方法。 */ private void grow(int minCapacity) { // oldCapacity为旧容量,newCapacity为新容量 int oldCapacity = elementData.length; //将oldCapacity 右移一位,其效果相当于oldCapacity /2, //我们知道位运算的速度远远快于整除运算,整句运算式的结果就是将新容量更新为旧容量的1.5倍, int newCapacity = oldCapacity + (oldCapacity \u0026gt;\u0026gt; 1); //然后检查新容量是否大于最小需要容量,若还是小于最小需要容量,那么就把最小需要容量当作数组的新容量[1.5倍扩容后还小于,说明一次添加的大于1.5倍扩容后的大小] if (newCapacity - minCapacity \u0026lt; 0) newCapacity = minCapacity; // 如果新容量大于 MAX_ARRAY_SIZE,进入(执行) `hugeCapacity()` 方法来比较 minCapacity 和 MAX_ARRAY_SIZE, //如果minCapacity大于最大容量,则新容量则为`Integer.MAX_VALUE`,否则,新容量大小则为 MAX_ARRAY_SIZE 即为 `Integer.MAX_VALUE - 8`。 if (newCapacity - MAX_ARRAY_SIZE \u0026gt; 0) newCapacity = hugeCapacity(minCapacity); // minCapacity is usually close to size, so this is a win: elementData = Arrays.copyOf(elementData, newCapacity); } /* 进入真正的扩容 int newCapacity = oldCapacity + (oldCapacity \u0026gt;\u0026gt; 1),所以 ArrayList 每次扩容之后容量都会变为原来的 1.5 倍左右(oldCapacity 为偶数就是 1.5 倍,否则是 1.5 倍左右)! 奇偶不同,比如 :10+10/2 = 15, 33+33/2=49。如果是奇数的话会丢掉小数;右移运算会比普通运算符快很多 */ 扩展\njava 中的 length属性是针对数组说的,比如说你声明了一个数组,想知道这个数组的长度则用到了 length 这个属性. java 中的 length() 方法是针对字符串说的,如果想看这个字符串的长度则用到 length() 这个方法. java 中的 size() 方法是针对泛型集合说的,如果想看这个泛型有多少个元素,就调用此方法来查看! hugeCapacity 当新容量超过MAX_ARRAY_SIZE时,if (newCapacity - MAX_ARRAY_SIZE \u0026gt; 0) 进入该方法\nprivate static int hugeCapacity(int minCapacity) { if (minCapacity \u0026lt; 0) // overflow throw new OutOfMemoryError(); //对minCapacity和MAX_ARRAY_SIZE进行比较 //若minCapacity大,将Integer.MAX_VALUE作为新数组的大小 //若MAX_ARRAY_SIZE大,将MAX_ARRAY_SIZE作为新数组的大小 //MAX_ARRAY_SIZE = Integer.MAX_VALUE - 8; return (minCapacity \u0026gt; MAX_ARRAY_SIZE) ? Integer.MAX_VALUE : MAX_ARRAY_SIZE; } System.arraycopy() 和 Arrays.copyOf()\n//System.arraycopy() 是一个native方法 // 我们发现 arraycopy 是一个 native 方法,接下来我们解释一下各个参数的具体意义 /** * 复制数组 * @param src 源数组 * @param srcPos 源数组中的起始位置 * @param dest 目标数组 * @param destPos 目标数组中的起始位置 * @param length 要复制的数组元素的数量 */ public static native void arraycopy(Object src, int srcPos, Object dest, int destPos, int length); 例子:\npublic class ArraycopyTest { public static void main(String[] args) { // TODO Auto-generated method stub int[] a = new int[10]; a[0] = 0; a[1] = 1; a[2] = 2; a[3] = 3; System.arraycopy(a, 2, a, 3, 3); a[2]=99; for (int i = 0; i \u0026lt; a.length; i++) { System.out.print(a[i] + \u0026#34; \u0026#34;); } } } //结果 0 1 99 2 3 0 0 0 0 0 Arrays.copyOf() 方法\npublic static int[] copyOf(int[] original, int newLength) { // 申请一个新的数组 int[] copy = new int[newLength]; // 调用System.arraycopy,将源数组中的数据进行拷贝,并返回新的数组 System.arraycopy(original, 0, copy, 0, Math.min(original.length, newLength)); return copy; } //场景 /** 以正确的顺序返回一个包含此列表中所有元素的数组(从第一个到最后一个元素); 返回的数组的运行时类型是指定数组的运行时类型。 */ public Object[] toArray() { //elementData:要复制的数组;size:要复制的长度 return Arrays.copyOf(elementData, size); } Arrays.copypf() : 用来扩容,或者缩短\npublic class ArrayscopyOfTest { public static void main(String[] args) { int[] a = new int[3]; a[0] = 0; a[1] = 1; a[2] = 2; int[] b = Arrays.copyOf(a, 10); System.out.println(\u0026#34;b.length\u0026#34;+b.length); } } //结果: 10 联系及区别\n看两者源代码可以发现 copyOf()内部实际调用了 System.arraycopy() 方法 arraycopy 更能实现自定义 ensureCapacity 方法 最好在向 ArrayList 添加大量元素之前用 ensureCapacity 方法,以减少增量重新分配的次数 向 ArrayList 添加大量元素之前使用ensureCapacity 方法可以提升性能。不过,这个性能差距几乎可以忽略不计。而且,实际项目根本也不可能往 ArrayList 里面添加这么多元素 2. 核心源码解读 # package java.util; import java.util.function.Consumer; import java.util.function.Predicate; import java.util.function.UnaryOperator; public class ArrayList\u0026lt;E\u0026gt; extends AbstractList\u0026lt;E\u0026gt; implements List\u0026lt;E\u0026gt;, RandomAccess, Cloneable, java.io.Serializable { private static final long serialVersionUID = 8683452581122892189L; /** * 默认初始容量大小 */ private static final int DEFAULT_CAPACITY = 10; /** * 空数组(用于空实例)。 */ private static final Object[] EMPTY_ELEMENTDATA = {}; //用于默认大小空实例的共享空数组实例。 //我们把它从EMPTY_ELEMENTDATA数组中区分出来,以知道在添加第一个元素时容量需要增加多少。 private static final Object[] DEFAULTCAPACITY_EMPTY_ELEMENTDATA = {}; /** * 保存ArrayList数据的数组 */ transient Object[] elementData; // non-private to simplify nested class access /** * ArrayList 所包含的元素个数 */ private int size; /** * 带初始容量参数的构造函数(用户可以在创建ArrayList对象时自己指定集合的初始大小) */ public ArrayList(int initialCapacity) { if (initialCapacity \u0026gt; 0) { //如果传入的参数大于0,创建initialCapacity大小的数组 this.elementData = new Object[initialCapacity]; } else if (initialCapacity == 0) { //如果传入的参数等于0,创建空数组 this.elementData = EMPTY_ELEMENTDATA; } else { //其他情况,抛出异常 throw new IllegalArgumentException(\u0026#34;Illegal Capacity: \u0026#34;+ initialCapacity); } } /** *默认无参构造函数 *DEFAULTCAPACITY_EMPTY_ELEMENTDATA 为0.初始化为10,也就是说初始其实是空数组 当添加第一个元素的时候数组容量才变成10 */ public ArrayList() { this.elementData = DEFAULTCAPACITY_EMPTY_ELEMENTDATA; } /** * 构造一个包含指定集合的元素的列表,按照它们由集合的迭代器返回的顺序。 */ public ArrayList(Collection\u0026lt;? extends E\u0026gt; c) { //将指定集合转换为数组 elementData = c.toArray(); //如果elementData数组的长度不为0 if ((size = elementData.length) != 0) { // 如果elementData不是Object类型数据(c.toArray可能返回的不是Object类型的数组所以加上下面的语句用于判断) if (elementData.getClass() != Object[].class) //将原来不是Object类型的elementData数组的内容,赋值给新的Object类型的elementData数组 elementData = Arrays.copyOf(elementData, size, Object[].class); } else { // 其他情况,用空数组代替 this.elementData = EMPTY_ELEMENTDATA; } } /** * 修改这个ArrayList实例的容量是列表的当前大小。 应用程序可以使用此操作来最小化ArrayList实例的存储。 */ public void trimToSize() { modCount++; if (size \u0026lt; elementData.length) { elementData = (size == 0) ? EMPTY_ELEMENTDATA : Arrays.copyOf(elementData, size); } } //下面是ArrayList的扩容机制 //ArrayList的扩容机制提高了性能,如果每次只扩充一个, //那么频繁的插入会导致频繁的拷贝,降低性能,而ArrayList的扩容机制避免了这种情况。 /** * 如有必要,增加此ArrayList实例的容量,以确保它至少能容纳元素的数量 * @param minCapacity 所需的最小容量 */ public void ensureCapacity(int minCapacity) { //如果是true,minExpand的值为0,如果是false,minExpand的值为10 int minExpand = (elementData != DEFAULTCAPACITY_EMPTY_ELEMENTDATA) // any size if not default element table ? 0 // larger than default for default empty table. It\u0026#39;s already // supposed to be at default size. : DEFAULT_CAPACITY; //如果最小容量大于已有的最大容量 if (minCapacity \u0026gt; minExpand) { ensureExplicitCapacity(minCapacity); } } //1.得到最小扩容量 //2.通过最小容量扩容 private void ensureCapacityInternal(int minCapacity) { if (elementData == DEFAULTCAPACITY_EMPTY_ELEMENTDATA) { // 获取“默认的容量”和“传入参数”两者之间的最大值 minCapacity = Math.max(DEFAULT_CAPACITY, minCapacity); } ensureExplicitCapacity(minCapacity); } //判断是否需要扩容 private void ensureExplicitCapacity(int minCapacity) { modCount++; // overflow-conscious code if (minCapacity - elementData.length \u0026gt; 0) //调用grow方法进行扩容,调用此方法代表已经开始扩容了 grow(minCapacity); } /** * 要分配的最大数组大小 */ private static final int MAX_ARRAY_SIZE = Integer.MAX_VALUE - 8; /** * ArrayList扩容的核心方法。 */ private void grow(int minCapacity) { // oldCapacity为旧容量,newCapacity为新容量 int oldCapacity = elementData.length; //将oldCapacity 右移一位,其效果相当于oldCapacity /2, //我们知道位运算的速度远远快于整除运算,整句运算式的结果就是将新容量更新为旧容量的1.5倍, int newCapacity = oldCapacity + (oldCapacity \u0026gt;\u0026gt; 1); //然后检查新容量是否大于最小需要容量,若还是小于最小需要容量,那么就把最小需要容量当作数组的新容量, if (newCapacity - minCapacity \u0026lt; 0) newCapacity = minCapacity; //再检查新容量是否超出了ArrayList所定义的最大容量, //若超出了,则调用hugeCapacity()来比较minCapacity和 MAX_ARRAY_SIZE, //如果minCapacity大于MAX_ARRAY_SIZE,则新容量则为Interger.MAX_VALUE,否则,新容量大小则为 MAX_ARRAY_SIZE。 if (newCapacity - MAX_ARRAY_SIZE \u0026gt; 0) newCapacity = hugeCapacity(minCapacity); // minCapacity is usually close to size, so this is a win: elementData = Arrays.copyOf(elementData, newCapacity); } //比较minCapacity和 MAX_ARRAY_SIZE private static int hugeCapacity(int minCapacity) { if (minCapacity \u0026lt; 0) // overflow throw new OutOfMemoryError(); return (minCapacity \u0026gt; MAX_ARRAY_SIZE) ? Integer.MAX_VALUE : MAX_ARRAY_SIZE; } /** *返回此列表中的元素数。 */ public int size() { return size; } /** * 如果此列表不包含元素,则返回 true 。 */ public boolean isEmpty() { //注意=和==的区别 return size == 0; } /** * 如果此列表包含指定的元素,则返回true 。 */ public boolean contains(Object o) { //indexOf()方法:返回此列表中指定元素的首次出现的索引,如果此列表不包含此元素,则为-1 return indexOf(o) \u0026gt;= 0; } /** *返回此列表中指定元素的首次出现的索引,如果此列表不包含此元素,则为-1 */ public int indexOf(Object o) { if (o == null) { for (int i = 0; i \u0026lt; size; i++) if (elementData[i]==null) return i; } else { for (int i = 0; i \u0026lt; size; i++) //equals()方法比较 if (o.equals(elementData[i])) return i; } return -1; } /** * 返回此列表中指定元素的最后一次出现的索引,如果此列表不包含元素,则返回-1。. */ public int lastIndexOf(Object o) { if (o == null) { for (int i = size-1; i \u0026gt;= 0; i--) if (elementData[i]==null) return i; } else { for (int i = size-1; i \u0026gt;= 0; i--) if (o.equals(elementData[i])) return i; } return -1; } /** * 返回此ArrayList实例的浅拷贝。 (元素本身不被复制。) */ public Object clone() { try { ArrayList\u0026lt;?\u0026gt; v = (ArrayList\u0026lt;?\u0026gt;) super.clone(); //Arrays.copyOf功能是实现数组的复制,返回复制后的数组。参数是被复制的数组和复制的长度 v.elementData = Arrays.copyOf(elementData, size); v.modCount = 0; return v; } catch (CloneNotSupportedException e) { // 这不应该发生,因为我们是可以克隆的 throw new InternalError(e); } } /** *以正确的顺序(从第一个到最后一个元素)返回一个包含此列表中所有元素的数组。 *返回的数组将是“安全的”,因为该列表不保留对它的引用。 (换句话说,这个方法必须分配一个新的数组)。 *因此,调用者可以自由地修改返回的数组。 此方法充当基于阵列和基于集合的API之间的桥梁。 */ public Object[] toArray() { return Arrays.copyOf(elementData, size); } /** * 以正确的顺序返回一个包含此列表中所有元素的数组(从第一个到最后一个元素); *返回的数组的运行时类型是指定数组的运行时类型。 如果列表适合指定的数组,则返回其中。 *否则,将为指定数组的运行时类型和此列表的大小分配一个新数组。 *如果列表适用于指定的数组,其余空间(即数组的列表数量多于此元素),则紧跟在集合结束后的数组中的元素设置为null 。 *(这仅在调用者知道列表不包含任何空元素的情况下才能确定列表的长度。) */ @SuppressWarnings(\u0026#34;unchecked\u0026#34;) public \u0026lt;T\u0026gt; T[] toArray(T[] a) { if (a.length \u0026lt; size) // 新建一个运行时类型的数组,但是ArrayList数组的内容 return (T[]) Arrays.copyOf(elementData, size, a.getClass()); //调用System提供的arraycopy()方法实现数组之间的复制 System.arraycopy(elementData, 0, a, 0, size); if (a.length \u0026gt; size) a[size] = null; return a; } // Positional Access Operations @SuppressWarnings(\u0026#34;unchecked\u0026#34;) E elementData(int index) { return (E) elementData[index]; } /** * 返回此列表中指定位置的元素。 */ public E get(int index) { rangeCheck(index); return elementData(index); } /** * 用指定的元素替换此列表中指定位置的元素。 */ public E set(int index, E element) { //对index进行界限检查 rangeCheck(index); E oldValue = elementData(index); elementData[index] = element; //返回原来在这个位置的元素 return oldValue; } /** * 将指定的元素追加到此列表的末尾。 */ public boolean add(E e) { ensureCapacityInternal(size + 1); // Increments modCount!! //这里看到ArrayList添加元素的实质就相当于为数组赋值 elementData[size++] = e; return true; } /** * 在此列表中的指定位置插入指定的元素。 *先调用 rangeCheckForAdd 对index进行界限检查;然后调用 ensureCapacityInternal 方法保证capacity足够大; *再将从index开始之后的所有成员后移一个位置;将element插入index位置;最后size加1。 */ public void add(int index, E element) { rangeCheckForAdd(index); ensureCapacityInternal(size + 1); // Increments modCount!! //arraycopy()这个实现数组之间复制的方法一定要看一下,下面就用到了arraycopy()方法实现数组自己复制自己 System.arraycopy(elementData, index, elementData, index + 1, size - index); elementData[index] = element; size++; } /** * 删除该列表中指定位置的元素。 将任何后续元素移动到左侧(从其索引中减去一个元素)。 */ public E remove(int index) { rangeCheck(index); modCount++; E oldValue = elementData(index); int numMoved = size - index - 1; if (numMoved \u0026gt; 0) System.arraycopy(elementData, index+1, elementData, index, numMoved); elementData[--size] = null; // clear to let GC do its work //从列表中删除的元素 return oldValue; } /** * 从列表中删除指定元素的第一个出现(如果存在)。 如果列表不包含该元素,则它不会更改。 *返回true,如果此列表包含指定的元素 */ public boolean remove(Object o) { if (o == null) { for (int index = 0; index \u0026lt; size; index++) if (elementData[index] == null) { fastRemove(index); return true; } } else { for (int index = 0; index \u0026lt; size; index++) if (o.equals(elementData[index])) { fastRemove(index); return true; } } return false; } /* * Private remove method that skips bounds checking and does not * return the value removed. */ private void fastRemove(int index) { modCount++; int numMoved = size - index - 1; if (numMoved \u0026gt; 0) System.arraycopy(elementData, index+1, elementData, index, numMoved); elementData[--size] = null; // clear to let GC do its work } /** * 从列表中删除所有元素。 */ public void clear() { modCount++; // 把数组中所有的元素的值设为null for (int i = 0; i \u0026lt; size; i++) elementData[i] = null; size = 0; } /** * 按指定集合的Iterator返回的顺序将指定集合中的所有元素追加到此列表的末尾。 */ public boolean addAll(Collection\u0026lt;? extends E\u0026gt; c) { Object[] a = c.toArray(); int numNew = a.length; ensureCapacityInternal(size + numNew); // Increments modCount System.arraycopy(a, 0, elementData, size, numNew); size += numNew; return numNew != 0; } /** * 将指定集合中的所有元素插入到此列表中,从指定的位置开始。 */ public boolean addAll(int index, Collection\u0026lt;? extends E\u0026gt; c) { rangeCheckForAdd(index); Object[] a = c.toArray(); int numNew = a.length; ensureCapacityInternal(size + numNew); // Increments modCount int numMoved = size - index; if (numMoved \u0026gt; 0) System.arraycopy(elementData, index, elementData, index + numNew, numMoved); System.arraycopy(a, 0, elementData, index, numNew); size += numNew; return numNew != 0; } /** * 从此列表中删除所有索引为fromIndex (含)和toIndex之间的元素。 *将任何后续元素移动到左侧(减少其索引)。 */ protected void removeRange(int fromIndex, int toIndex) { modCount++; int numMoved = size - toIndex; System.arraycopy(elementData, toIndex, elementData, fromIndex, numMoved); // clear to let GC do its work int newSize = size - (toIndex-fromIndex); for (int i = newSize; i \u0026lt; size; i++) { elementData[i] = null; } size = newSize; } /** * 检查给定的索引是否在范围内。 */ private void rangeCheck(int index) { if (index \u0026gt;= size) throw new IndexOutOfBoundsException(outOfBoundsMsg(index)); } /** * add和addAll使用的rangeCheck的一个版本 */ private void rangeCheckForAdd(int index) { if (index \u0026gt; size || index \u0026lt; 0) throw new IndexOutOfBoundsException(outOfBoundsMsg(index)); } /** * 返回IndexOutOfBoundsException细节信息 */ private String outOfBoundsMsg(int index) { return \u0026#34;Index: \u0026#34;+index+\u0026#34;, Size: \u0026#34;+size; } /** * 从此列表中删除指定集合中包含的所有元素。 */ public boolean removeAll(Collection\u0026lt;?\u0026gt; c) { Objects.requireNonNull(c); //如果此列表被修改则返回true return batchRemove(c, false); } /** * 仅保留此列表中包含在指定集合中的元素。 *换句话说,从此列表中删除其中不包含在指定集合中的所有元素。 */ public boolean retainAll(Collection\u0026lt;?\u0026gt; c) { Objects.requireNonNull(c); return batchRemove(c, true); } /** * 从列表中的指定位置开始,返回列表中的元素(按正确顺序)的列表迭代器。 *指定的索引表示初始调用将返回的第一个元素为next 。 初始调用previous将返回指定索引减1的元素。 *返回的列表迭代器是fail-fast 。 */ public ListIterator\u0026lt;E\u0026gt; listIterator(int index) { if (index \u0026lt; 0 || index \u0026gt; size) throw new IndexOutOfBoundsException(\u0026#34;Index: \u0026#34;+index); return new ListItr(index); } /** *返回列表中的列表迭代器(按适当的顺序)。 *返回的列表迭代器是fail-fast 。 */ public ListIterator\u0026lt;E\u0026gt; listIterator() { return new ListItr(0); } /** *以正确的顺序返回该列表中的元素的迭代器。 *返回的迭代器是fail-fast 。 */ public Iterator\u0026lt;E\u0026gt; iterator() { return new Itr(); } "},{"id":341,"href":"/zh/docs/technology/Review/java_guide/java/Collection/ly0103lycollections-precautions-for-use/","title":"集合使用注意事项","section":"集合","content":" 转载自https://github.com/Snailclimb/JavaGuide (添加小部分笔记)感谢作者!\n集合判空 # //阿里巴巴开发手册\n判断所有集合内部的元素是否为空,使用 isEmpty() 方法,而不是 size()==0 的方式。\nisEmpty()可读性更好,且绝大部分情况下时间复杂度为O(1)\n有例外:ConcurrentHashMap的size()和isEmpty() 时间复杂度均不是O(1)\npublic int size() { long n = sumCount(); return ((n \u0026lt; 0L) ? 0 : (n \u0026gt; (long)Integer.MAX_VALUE) ? Integer.MAX_VALUE : (int)n); } final long sumCount() { CounterCell[] as = counterCells; CounterCell a; long sum = baseCount; if (as != null) { for (int i = 0; i \u0026lt; as.length; ++i) { if ((a = as[i]) != null) sum += a.value; } } return sum; } public boolean isEmpty() { return sumCount() \u0026lt;= 0L; // ignore transient negative values } 集合转Map # //阿里巴巴开发手册\n在使用 java.util.stream.Collectors 类的 toMap() 方法转为 Map 集合时,一定要注意当 value 为 null 时会抛 NPE 异常。\nclass Person { private String name; private String phoneNumber; // getters and setters } List\u0026lt;Person\u0026gt; bookList = new ArrayList\u0026lt;\u0026gt;(); bookList.add(new Person(\u0026#34;jack\u0026#34;,\u0026#34;18163138123\u0026#34;)); bookList.add(new Person(\u0026#34;martin\u0026#34;,null)); // 空指针异常 bookList.stream().collect(Collectors.toMap(Person::getName, Person::getPhoneNumber)); java.util.stream.Collections类的toMap() ,里面使用到了Map接口的merge()方法, 调用了Objects.requireNonNull()方法判断value是否为空\n集合遍历 # //阿里巴巴开发手册\n不要在 foreach 循环里进行元素的 remove/add 操作。remove 元素请使用 Iterator 方式,如果并发操作,需要对 Iterator 对象加锁。\nforeach语法底层依赖于Iterator (foreach是语法糖),不过remove/add 则是直接调用集合的方法,而不是Iterator的; 所以此时Iterator莫名发现自己元素被remove/add,就会抛出一个ConcurrentModificationException来提示用户发生了并发修改异常,即单线程状态下产生的fail-fast机制\njava8开始,可以使用Collection#**removeIf()**方法删除满足特定条件的元素,例子\nList\u0026lt;Integer\u0026gt; list = new ArrayList\u0026lt;\u0026gt;(); for (int i = 1; i \u0026lt;= 10; ++i) { list.add(i); } list.removeIf(filter -\u0026gt; filter % 2 == 0); /* 删除list中的所有偶数 */ System.out.println(list); /* [1, 3, 5, 7, 9] */ 其他的遍历数组的方法(注意是遍历,不是增加/删除)\n使用普通for循环\n使用fail-safe集合类,java.util包下面的所有集合类都是fail-fast,而java.util.concurrent包下面的所有类是fail-safe\n//ConcurrentHashMap源码 package java.util.concurrent; public class ConcurrentHashMap\u0026lt;K,V\u0026gt; extends AbstractMap\u0026lt;K,V\u0026gt; implements ConcurrentMap\u0026lt;K,V\u0026gt;, Serializable {} //List类源码 package java.util; public class HashMap\u0026lt;K,V\u0026gt; extends AbstractMap\u0026lt;K,V\u0026gt; implements Map\u0026lt;K,V\u0026gt;, Cloneable, Serializable { } 集合去重 # //阿里巴巴开发手册\n可以利用 Set 元素唯一的特性,可以快速对一个集合进行去重操作,避免使用 List 的 contains() 进行遍历去重或者判断包含操作。\n// Set 去重代码示例 public static \u0026lt;T\u0026gt; Set\u0026lt;T\u0026gt; removeDuplicateBySet(List\u0026lt;T\u0026gt; data) { if (CollectionUtils.isEmpty(data)) { return new HashSet\u0026lt;\u0026gt;(); } return new HashSet\u0026lt;\u0026gt;(data); } // List 去重代码示例 public static \u0026lt;T\u0026gt; List\u0026lt;T\u0026gt; removeDuplicateByList(List\u0026lt;T\u0026gt; data) { if (CollectionUtils.isEmpty(data)) { return new ArrayList\u0026lt;\u0026gt;(); } List\u0026lt;T\u0026gt; result = new ArrayList\u0026lt;\u0026gt;(data.size()); for (T current : data) { if (!result.contains(current)) { result.add(current); } } return result; } Set时间复杂度为 1 * n ,而List时间复杂度为 n * n\n//Set的Contains,底层依赖于HashMap,时间复杂度为 1 private transient HashMap\u0026lt;E,Object\u0026gt; map; public boolean contains(Object o) { return map.containsKey(o); } //ArrayList的Contains,底层则是遍历,时间复杂度为O(n) public boolean contains(Object o) { return indexOf(o) \u0026gt;= 0; } public int indexOf(Object o) { if (o == null) { for (int i = 0; i \u0026lt; size; i++) if (elementData[i]==null) return i; } else { for (int i = 0; i \u0026lt; size; i++) if (o.equals(elementData[i])) return i; } return -1; } 集合转数组 # //阿里巴巴开发手册\n使用集合转数组的方法,必须使用集合的 toArray(T[] array),传入的是类型完全一致、长度为 0 的空数组。\n例子:\nString [] s= new String[]{ \u0026#34;dog\u0026#34;, \u0026#34;lazy\u0026#34;, \u0026#34;a\u0026#34;, \u0026#34;over\u0026#34;, \u0026#34;jumps\u0026#34;, \u0026#34;fox\u0026#34;, \u0026#34;brown\u0026#34;, \u0026#34;quick\u0026#34;, \u0026#34;A\u0026#34; }; List\u0026lt;String\u0026gt; list = Arrays.asList(s); Collections.reverse(list); //没有指定类型的话会报错 s=list.toArray(new String[0]); 对于 JVM 优化,new String[0]作为Collection.toArray()方法的参数现在使用更好,new String[0]就是起一个模板的作用,指定了返回数组的类型,0 是为了节省空间,因为它只是为了说明返回的类型\n数组转集合 # //阿里巴巴开发手册\n使用工具类 Arrays.asList() 把数组转换成集合时,不能使用其修改集合相关的方法, 它的 add/remove/clear 方法会抛出 UnsupportedOperationException 异常。\n例子及源码:\nString[] myArray = {\u0026#34;Apple\u0026#34;, \u0026#34;Banana\u0026#34;, \u0026#34;Orange\u0026#34;}; List\u0026lt;String\u0026gt; myList = Arrays.asList(myArray); //上面两个语句等价于下面一条语句 List\u0026lt;String\u0026gt; myList = Arrays.asList(\u0026#34;Apple\u0026#34;,\u0026#34;Banana\u0026#34;, \u0026#34;Orange\u0026#34;); //JDK源码说明[返回由指定数组支持的固定大小的列表] /** *返回由指定数组支持的固定大小的列表。此方法作为基于数组和基于集合的API之间的桥梁, * 与 Collection.toArray()结合使用。返回的List是可序列化并实现RandomAccess接口。 */ public static \u0026lt;T\u0026gt; List\u0026lt;T\u0026gt; asList(T... a) { return new ArrayList\u0026lt;\u0026gt;(a); } 注意事项:\n1、Arrays.asList()是泛型方法,传递的数组必须是对象数组,而不是基本类型。 如果把原生数据类型数组传入,则传入的不是数组的元素,而是数组对象本身,可以使用包装类数组解决这个问题\nint[] myArray = {1, 2, 3}; List myList = Arrays.asList(myArray); System.out.println(myList.size());//1 System.out.println(myList.get(0));//数组地址值 System.out.println(myList.get(1));//报错:ArrayIndexOutOfBoundsException int[] array = (int[]) myList.get(0); System.out.println(array[0]);//1 2、使用集合的修改方法add(),remove(),clear()会抛出异常UnsupportedOperationException java.util.Arrays$ArrayList (Arrays里面有一个ArrayList类,该类继承了AbstractList)\n源码:\npublic E remove(int index) { throw new UnsupportedOperationException(); } public boolean add(E e) { add(size(), e); return true; } public void add(int index, E element) { throw new UnsupportedOperationException(); } public void clear() { removeRange(0, size()); } protected void removeRange(int fromIndex, int toIndex) { ListIterator\u0026lt;E\u0026gt; it = listIterator(fromIndex); for (int i=0, n=toIndex-fromIndex; i\u0026lt;n; i++) { it.next(); it.remove(); } } 如何转换成正常的ArraysList呢\n手动实现工具类\n//使用泛型 //JDK1.5+ static \u0026lt;T\u0026gt; List\u0026lt;T\u0026gt; arrayToList(final T[] array) { final List\u0026lt;T\u0026gt; l = new ArrayList\u0026lt;T\u0026gt;(array.length); for (final T s : array) { l.add(s); } return l; } Integer [] myArray = { 1, 2, 3 }; System.out.println(arrayToList(myArray).getClass());//class java.util.ArrayList 便捷的方法\n//再转一次 List list = new ArrayList\u0026lt;\u0026gt;(Arrays.asList(\u0026#34;a\u0026#34;, \u0026#34;b\u0026#34;, \u0026#34;c\u0026#34;)) 使用Java8的Stream(推荐),包括基本类型\nInteger [] myArray = { 1, 2, 3 }; List myList = Arrays.stream(myArray).collect(Collectors.toList()); //基本类型也可以实现转换(依赖boxed的装箱操作) int [] myArray2 = { 1, 2, 3 }; List myList = Arrays.stream(myArray2).boxed().collect(Collectors.toList()); 使用Apache Commons Colletions\nList\u0026lt;String\u0026gt; list = new ArrayList\u0026lt;String\u0026gt;(); CollectionUtils.addAll(list, str); 使用Java9的List.of()\nInteger[] array = {1, 2, 3}; List\u0026lt;Integer\u0026gt; list = List.of(array); 大部分转自https://github.com/Snailclimb/JavaGuide\n"},{"id":342,"href":"/zh/docs/technology/Review/java_guide/java/Collection/ly0102lycollection_2/","title":"集合_2","section":"集合","content":" 转载自https://github.com/Snailclimb/JavaGuide (添加小部分笔记)感谢作者!\nMap # HashMap和Hashtable的区别\nHashMap是非线程安全的,Hashtable是线程安全的,因为Hashtable内部方法都经过synchronized修饰(不过要保证线程安全一般用ConcurrentHashMap)\n由于加了synchronized修饰,HashTable效率没有HashMap高\nHashMap可以存储null的key和value,但null作为键只能有一个**;HashTable不允许有null键和null值**\n初始容量及每次扩容\nHashtable默认初始大小11,之后扩容为2n+1;HashMap初始大小16,之后扩容变为原来的2倍 如果指定初始大小,HashTable直接使用初始大小\n而HashMap 会将其扩充为 2 的幂次方大小(HashMap 中的**tableSizeFor()**方法保证,下面给出了源代码)。也就是说 HashMap 总是使用 2 的幂作为哈希表的大小,后面会介绍到为什么是 2 的幂次方 底层数据结构\nJDK1.8之后HashMap解决哈希冲突时,当链表大于阈值(默认8)时,将链表转为红黑树(转换前判断,如果当前数组长度小于64,则先进行数组扩容,而不转成红黑树),以减少搜索时间。 Hashtable没有上面的机制 /** HashMap 中带有初始容量的构造函数: */ public HashMap(int initialCapacity, float loadFactor) { if (initialCapacity \u0026lt; 0) throw new IllegalArgumentException(\u0026#34;Illegal initial capacity: \u0026#34; + initialCapacity); if (initialCapacity \u0026gt; MAXIMUM_CAPACITY) initialCapacity = MAXIMUM_CAPACITY; if (loadFactor \u0026lt;= 0 || Float.isNaN(loadFactor)) throw new IllegalArgumentException(\u0026#34;Illegal load factor: \u0026#34; + loadFactor); this.loadFactor = loadFactor; this.threshold = tableSizeFor(initialCapacity); } public HashMap(int initialCapacity) { this(initialCapacity, DEFAULT_LOAD_FACTOR); } /*下面这个方法保证了 HashMap 总是使用 2 的幂作为哈希表的大小。*/ /** * Returns a power of two size for the given target capacity. */ static final int tableSizeFor(int cap) { int n = cap - 1; n |= n \u0026gt;\u0026gt;\u0026gt; 1; n |= n \u0026gt;\u0026gt;\u0026gt; 2; n |= n \u0026gt;\u0026gt;\u0026gt; 4; n |= n \u0026gt;\u0026gt;\u0026gt; 8; n |= n \u0026gt;\u0026gt;\u0026gt; 16; return (n \u0026lt; 0) ? 1 : (n \u0026gt;= MAXIMUM_CAPACITY) ? MAXIMUM_CAPACITY : n + 1; } HashMap和hashSet区别\n如果你看过 HashSet 源码的话就应该知道:HashSet 底层就是基于 HashMap 实现的。(HashSet 的源码非常非常少,因为除了 clone()、writeObject()、readObject()是 HashSet 自己不得不实现之外,其他方法都是直接调用 HashMap 中的方法\nHashSet底层就HashMap 会将其扩充为 2 的幂次方大小(HashMap 中的tableSizeFor()方法保证,下面给出了源代码)。也就是说 HashMap 总是使用 2 的幂作为哈希表的大小,后面会介绍到为什么是 2 的幂次方是HashMap实现的 HashMap:实现了Map接口;存储键值对;调用put()向map中添加元素;HashMap使用键(key)计算hashcode HashSet:实现Set接口;仅存储对象;调用add()方法向Set中添加元素;HashSet使用成员对象计算hashCode,对于不相等两个对象来说 hashcode也可能相同,所以**还要再借助equals()**方法判断对象相等性 HashMap和TreeMap navigable 英[ˈnævɪɡəbl] 通航的,可航行的\nHashMap和TreeMap都继承自AbstractMap\nTreeMap还实现了NavigableMap (对集合内元素搜索)和SortedMap(对集合内元素根据键排序,默认key升序,可指定排序的比较器)接口\n实现 NavigableMap 接口让 TreeMap 有了对集合内元素的搜索的能力。\n实现SortedMap接口让 TreeMap 有了对集合中的元素根据键排序的能力。默认是按 key 的升序排序,不过我们也可以指定排序的比较器。示例代码如下:\n/** * @author shuang.kou * @createTime 2020年06月15日 17:02:00 */ public class Person { private Integer age; public Person(Integer age) { this.age = age; } public Integer getAge() { return age; } public static void main(String[] args) { TreeMap\u0026lt;Person, String\u0026gt; treeMap = new TreeMap\u0026lt;\u0026gt;(new Comparator\u0026lt;Person\u0026gt;() { @Override public int compare(Person person1, Person person2) { int num = person1.getAge() - person2.getAge(); return Integer.compare(num, 0); } }); treeMap.put(new Person(3), \u0026#34;person1\u0026#34;); treeMap.put(new Person(18), \u0026#34;person2\u0026#34;); treeMap.put(new Person(35), \u0026#34;person3\u0026#34;); treeMap.put(new Person(16), \u0026#34;person4\u0026#34;); treeMap.entrySet().stream().forEach(personStringEntry -\u0026gt; { System.out.println(personStringEntry.getValue()); }); } } //输出 /**person1 person4 person2 person3 **/ HashSet如何检查重复\n当在HashSet加入对象时,先计算对象hashcode值判断加入位置,同时与其他加入对象的hashcode值比较,如果没有相同的,会假设对象没有重复出现;如果发现有相同的hashcode值的对象,则调用equals()方法检查hashcode相等的对象是否真的相等,如果相等则不会加入\nJDK1.8中,HashSet的add()方法调用了HashMap的put()方法,并判断是否有重复元素(返回值是否null)\n// Returns: true if this set did not already contain the specified element // 返回值:当 set 中没有包含 add 的元素时返回真 public boolean add(E e) { return map.put(e, PRESENT)==null; } //下面为HashMap的源代码 // Returns : previous value, or null if none // 返回值:如果插入位置没有元素返回null,否则返回上一个元素 final V putVal(int hash, K key, V value, boolean onlyIfAbsent, boolean evict) { ... } HashMap底层实现\nJDK1.8之前,底层是数组和链表结合在一起使用,即链表散列。通过key的hashcode 经过扰动函数处理后得到hash值,并通过 (n-1) \u0026amp; hash 判断当前元素存放的位置 (n为数组长度),如果当前位置存在元素的话,就判断该元素与要存入的元素的hash值以及key是否相同,如果相同则覆盖,不同则通过拉链法解决冲突 扰动函数指的是HashMap的hash方法,是为了防止一些实现比较差的hashCode方法,减少碰撞 JDK1.8的hash:如果key为null则返回空,否则使用 (key的hash值) 与 (hash值右移16位) 做异或操作\nstatic final int hash(Object key) { int h; // key.hashCode():返回散列值也就是hashcode // ^ :按位异或 // \u0026gt;\u0026gt;\u0026gt;:无符号右移,忽略符号位,空位都以0补齐 return (key == null) ? 0 : (h = key.hashCode()) ^ (h \u0026gt;\u0026gt;\u0026gt; 16); } JDK1.7扰动次数更多\nstatic int hash(int h) { // This function ensures that hashCodes that differ only by // constant multiples at each bit position have a bounded // number of collisions (approximately 8 at default load factor). h ^= (h \u0026gt;\u0026gt;\u0026gt; 20) ^ (h \u0026gt;\u0026gt;\u0026gt; 12); return h ^ (h \u0026gt;\u0026gt;\u0026gt; 7) ^ (h \u0026gt;\u0026gt;\u0026gt; 4); } 拉链法即链表和数组结合,也就是创建一个链表数组,数组每一格为一个链表,如果发生哈希冲突,就将冲突的值添加到链表中即可\nJDK8之后,解决冲突发生了较大变化,当链表长度大于阈值(默认是8)(如果数组小于64,则只会进行扩容;如果不是,才转成红黑树)时,将链表转换成红黑树,以减少搜索时间 二叉查找树,在某些情况下会退化成线性结构,时间复杂度为n ,而红黑树趋于log n 。TreeMap、TreeSet以及1.8之后的HashMap都用到了红黑树\n代码\n//当链表长度大于8时,执行treeifyBin(转换红黑树) // 遍历链表 for (int binCount = 0; ; ++binCount) { // 遍历到链表最后一个节点 if ((e = p.next) == null) { p.next = newNode(hash, key, value, null); // 如果链表元素个数大于等于TREEIFY_THRESHOLD(8) if (binCount \u0026gt;= TREEIFY_THRESHOLD - 1) // -1 for 1st // 红黑树转换(并不会直接转换成红黑树) treeifyBin(tab, hash); break; } if (e.hash == hash \u0026amp;\u0026amp; ((k = e.key) == key || (key != null \u0026amp;\u0026amp; key.equals(k)))) break; p = e; } //判断是否会转成红黑树 final void treeifyBin(Node\u0026lt;K,V\u0026gt;[] tab, int hash) { int n, index; Node\u0026lt;K,V\u0026gt; e; // 判断当前数组的长度是否小于 64 if (tab == null || (n = tab.length) \u0026lt; MIN_TREEIFY_CAPACITY) // 如果当前数组的长度小于 64,那么会选择先进行数组扩容 resize(); else if ((e = tab[index = (n - 1) \u0026amp; hash]) != null) { // 否则才将列表转换为红黑树 TreeNode\u0026lt;K,V\u0026gt; hd = null, tl = null; do { TreeNode\u0026lt;K,V\u0026gt; p = replacementTreeNode(e, null); if (tl == null) hd = p; else { p.prev = tl; tl.next = p; } tl = p; } while ((e = e.next) != null); if ((tab[index] = hd) != null) hd.treeify(tab); } } HashMap的长度为为什么是2的幂次方\n为了能让 HashMap 存取高效,尽量较少碰撞,也就是要尽量把数据分配均匀。我们上面也讲到了过了,Hash 值的范围值-2147483648 到 2147483647,前后加起来大概 40 亿的映射空间,只要哈希函数映射得比较均匀松散,一般应用是很难出现碰撞的。但问题是一个 40 亿长度的数组,内存是放不下的。所以这个散列值是不能直接拿来用的(也就是这个数组不能直接拿来用)。\n用之前还要先做对数组的长度取模运算,得到的余数才能用来要存放的位置也就是对应的数组下标。这个数组下标的计算方法是“ (n - 1) \u0026amp; hash”。(n 代表数组长度)。这也就解释了 HashMap 的长度为什么是 2 的幂次方。\n这个算法应该如何设计呢?\n我们首先可能会想到采用%取余的操作来实现。但是,重点来了:“取余(%)操作中如果除数是 2 的幂次则等价于与其除数减一的与(\u0026amp;)操作(也就是说 hash%length==hash\u0026amp;(length-1)的前提是 length 是 2 的 n 次方;)。” 并且 采用二进制位操作 \u0026amp;,相对于%能够提高运算效率,这就解释了 HashMap 的长度为什么是 2 的幂次方。\nHashMap多线程操作导致死循环问题 多线程下不建议使用HashMap,1.8之前并发下进行Rehash会造成元素之间形成循环链表,但是1.8之后还有其他问题(数据丢失),建议使用concurrentHashMap\nHashMap有哪几种常见的遍历方式\nhttps://mp.weixin.qq.com/s/zQBN3UvJDhRTKP6SzcZFKw\nConcurrentHashMap和Hashtable\n主要体现在,实现线程安全的方式上不同\n底层数据结构\nConcurrentHashMap:JDK1.7 底层采用分段数组+链表,JDK1.8 则是数组+链表/红黑二叉树(红黑树是1.8之后才出现的) HashTable采用 数组 (应该不是分段数组) + 链表 实现线程安全的方式\nConcurrentHashMap JDK1.7 时对整个桶数进行分割分段(Segment,分段锁),每一把锁只锁容器其中一部分数据,当访问不同数据段的数据就不会存在锁竞争 ConcurrentHashMap JDK1.8摒弃Segment概念,直接用Node数组+链表+红黑树,并发控制使用synchronized和CAS操作 而Hashtable则是同一把锁,使用synchronized保证线程安全,效率低下。问题:当一个线程访问同步方式时,其他线程也访问同步方法,则可能进入阻塞/轮询状态,即如使用put添加元素另一个线程不能使用put和get 底层数据结构图\nHashTable:数组+链表 JDK1.7 的 ConcurrentHashMap(Segment数组,HashEntry数组,链表)\nSegment是用来加锁的 JDK1.8 的ConcurrentHashMap则是Node数组+链表/红黑树,不过红黑树时,不用Node,而是用TreeNode\nTreeNode,存储红黑树节点,被TreeBin包装\n/** root 维护红黑树根节点;waiter维护当前使用这颗红黑树的线程,防止其他线程进入 **/ static final class TreeBin\u0026lt;K,V\u0026gt; extends Node\u0026lt;K,V\u0026gt; { TreeNode\u0026lt;K,V\u0026gt; root; volatile TreeNode\u0026lt;K,V\u0026gt; first; volatile Thread waiter; volatile int lockState; // values for lockState static final int WRITER = 1; // set while holding write lock static final int WAITER = 2; // set when waiting for write lock static final int READER = 4; // increment value for setting read lock ... } ConcurrentHashMap线程安全的具体实现方式/底层具体实现\nJDK1.8之前的ConcurrentHashMap\nSegment 继承了 ReentrantLock,所以 Segment 是一种可重入锁,扮演锁的角色。HashEntry 用于存储键值对数据。\nstatic class Segment\u0026lt;K,V\u0026gt; extends ReentrantLock implements Serializable { } 一个 ConcurrentHashMap 里包含一个 Segment 数组,Segment 的个数一旦初始化就不能改变。 Segment 数组的大小默认是 16,也就是说默认可以同时支持 16 个线程并发写。 Segment 的结构和 HashMap 类似,是一种数组和链表结构,一个 Segment 包含一个 HashEntry 数组,每个 HashEntry 是一个链表结构的元素,每个 Segment 守护着一个 HashEntry 数组里的元素,当对 HashEntry 数组的数据进行修改时,必须首先获得对应的 Segment 的锁。也就是说,对同一 Segment 的并发写入会被阻塞,不同 Segment 的写入是可以并发执行的。\nJDK 1.8 之后 使用Node数组+链表/红黑树,几乎重写了ConcurrentHashMap,使用Node+CAS+Synchronized保证并发安全,数据结构跟HashMap1.8类似,超过一定阈值(默认8)将链表【O(N)】转成红黑树【O(log (N) )】 JDK8中,只锁定当前链表/红黑二叉树的首节点,这样只要hash不冲突就不会产生并发,不影响其他Node的读写,提高效率\nCollections工具类(不重要) # 包括 排序/查找/替换\n排序\nvoid reverse(List list)//反转 void shuffle(List list)//随机排序 void sort(List list)//按自然排序的升序排序 void sort(List list, Comparator c)//定制排序,由Comparator控制排序逻辑 void swap(List list, int i , int j)//交换两个索引位置的元素 void rotate(List list, int distance)//旋转。当distance为正数时,将list后distance个元素整体移到前面。当distance为负数时,将 list的前distance个元素整体移到后面 查找/替换\nint binarySearch(List list, Object key)//对List进行二分查找,返回索引,注意List必须是有序的 int max(Collection coll)//根据元素的自然顺序,返回最大的元素。 类比int min(Collection coll) int max(Collection coll, Comparator c)//根据定制排序,返回最大元素,排序规则由Comparatator类控制。类比int min(Collection coll, Comparator c) void fill(List list, Object obj)//用指定的元素代替指定list中的所有元素 int frequency(Collection c, Object o)//统计元素出现次数 int indexOfSubList(List list, List target)//统计target在list中第一次出现的索引,找不到则返回-1,类比int lastIndexOfSubList(List source, list target) boolean replaceAll(List list, Object oldVal, Object newVal)//用新元素替换旧元素 同步控制,Collections提供了多个synchronizedXxx()方法,该方法可以将指定集合包装成线程同步的集合,从而解决并发问题。 其中,HashSet、TreeSet、ArrayList、LinkedList、HashMap、TreeMap都是线程不安全的\n//不推荐,因为效率极低 建议使用JUC包下的并发集合 synchronizedCollection(Collection\u0026lt;T\u0026gt; c) //返回指定 collection 支持 的同步(线程安全的)collection。 synchronizedList(List\u0026lt;T\u0026gt; list)//返回指定列表支持的同步(线程安全的)List。 synchronizedMap(Map\u0026lt;K,V\u0026gt; m) //返回由指定映射支持的同步(线程安全的)Map。 synchronizedSet(Set\u0026lt;T\u0026gt; s) //返回指定 set 支持的同步(线程安全的)set。 "},{"id":343,"href":"/zh/docs/technology/Review/java_guide/java/Collection/ly0101lycollection_1/","title":"集合_1","section":"集合","content":" 转载自https://github.com/Snailclimb/JavaGuide (添加小部分笔记)感谢作者!\n集合包括Collection和Map,Collection 存放单一元素。Map 存放键值对 # List,Set,Queue,Map区别 # List(对付顺序的好帮手): 存储的元素是有序的、可重复的。 Set(注重独一无二的性质): 存储的元素是无序的、不可重复的。 Queue(实现排队功能的叫号机): 按特定的排队规则来确定先后顺序,存储的元素是有序的、可重复的。 Map(用 key 来搜索的专家): 使用键值对(key-value)存储,类似于数学上的函数 y=f(x),\u0026ldquo;x\u0026rdquo; 代表 key,\u0026ldquo;y\u0026rdquo; 代表 value,key 是无序的、不可重复的,value 是无序的、可重复的,每个键最多映射到一个值。 各种集合框架\u0026ndash;底层数据结构 # List ArrayList、Vector \u0026mdash;-\u0026gt; Object[] 数组 LinkedList 双向链表 (jdk 1.6 之前为循环链表, 1.7 取消了循环) Set HashSet (无序,唯一),且基于HashMap LinkedHashSet 是HashSet的子类,基于LinkedHashMap (LinkedHashMap内部基于HashMap实现) TreeSet(有序,唯一) :红黑树(自平衡的排序二叉树) Queue (队列) PriorityQueue:Object[] 数组来实现二叉堆 ArrayQueue:Object[] 数组+ 双指针 Map HashMap: JDK1.8 之前 HashMap 由数组+链表组成的,数组是 HashMap 的主体,链表则是主要为了解决哈希冲突而存在的(“拉链法”解决冲突)。JDK1.8 以后在解决哈希冲突时有了较大的变化,当链表长度大于阈值(默认为 8)(将链表转换成红黑树前会判断,如果当前数组的长度小于 64,那么会选择先进行数组扩容,而不是转换为红黑树)时,将链表转化为红黑树,以减少搜索时间\nLinkedHashMap: LinkedHashMap 继承自 HashMap,所以它的底层仍然是基于拉链式散列结构 即由数组和链表或红黑树组成。另外,LinkedHashMap 在上面结构的基础上,增加了一条双向链表,使得上面的结构可以保持键值对的插入顺序。同时通过对链表进行相应的操作,实现了访问顺序相关逻辑。\n上图中,淡蓝色的箭头表示前驱引用,红色箭头表示后继引用。每当有新键值对节点插入,新节点最终会接在 tail 引用指向的节点后面(感觉这句话有问题,应该是head引用指向旧结点上)。而 tail 引用则会移动到新的节点上,这样一个双向链表就建立起来了。 作者:田小波 链接:https://www.imooc.com/article/22931\nHashtable: 数组+链表组成的,数组是 Hashtable 的主体,链表则是主要为了解决哈希冲突而存在的\nTreeMap: 红黑树(自平衡的排序二叉树)\n如何选用集合 # 当我们只需要存放元素值时,就选择实现Collection 接口的集合,需要保证元素唯一时选择实现 Set 接口的集合比如 TreeSet 或 HashSet,不需要就选择实现 List 接口的比如 ArrayList 或 LinkedList,然后再根据实现这些接口的集合的特点来选用 需要根据键值获取到元素值时就选用 Map 接口下的集合,需要排序时选择 TreeMap,不需要排序时就选择 HashMap,需要保证线程安全就选用 ConcurrentHashMap。 为什么需要集合 # 当需要保存一组类型相同的数据时,需要容器来保存,即数组,但实际中存储的类型多样, 而数组一旦声明则不可变长,同时数组数据类型也确定、数组有序可重复 集合可以存储不同类型不同数量的对象,还可以保存具有映射关系的数据 Collection 子接口 # List # ArrayList和Vector区别:ArrayList是List主要实现类,底层使用Object[]存储线程不安全;Vector是List古老实现类,底层使用Object[]存储,线程安全 (synchronized关键字)\nArrayList与LinkedList:\n都是线程不安全 ArrayList底层使用Object数组,LinkedList底层使用双向链表结构(JDK7以后非循环链表) ArrayList采用数组存储,所以插入和删除元素的时间复杂度受位置影响;LinkedList采用链表,所以在头尾插入或者删除元素不受元素位置影响,而如果需要插入或者删除中间指定位置,则时间复杂度为O(n) [主要是因为要遍历] LinkedList不支持高效的随机元素访问,而ArrayList支持(即通过元素的序号快速获取元素对象) 内存空间占用:ArrayList的空间浪费主要体现在List结尾会预留一定的容量空间(不是申请的所有容量都会用上),而LinkedList的空间花费则体现在它的每一个元素都需要消耗比ArrayList更多的空间(存放直接后继、直接前驱及数据) 实际项目中不怎么使用LinkedList,因为ArrayList性能通常会更好,LinkedList仅仅在头尾插入或者删除元素的时间复杂度近似O(1)\n双向链表与双向循环链表\n双向链表,包含两个指针,一个 prev 指向前一个节点,一个 next 指向后一个节点。 双向循环链表,首尾相连(头节点的前驱=尾结点,尾结点的后继=头节点) 补充:RandomAccess接口,这个接口只是用来标识:实现这个接口的类,具有随机访问功能,但并不是说因为实现了该接口才具有的快速随机访问机制\nCollections里面有这样一段代码\n在 binarySearch() 方法中,它要判断传入的 list 是否 RandomAccess 的实例,如果是,调用indexedBinarySearch()方法,如果不是,那么调用iteratorBinarySearch()方法\npublic static \u0026lt;T\u0026gt; int binarySearch(List\u0026lt;? extends Comparable\u0026lt;? super T\u0026gt;\u0026gt; list, T key) { if (list instanceof RandomAccess || list.size()\u0026lt;BINARYSEARCH_THRESHOLD) return Collections.indexedBinarySearch(list, key); else return Collections.iteratorBinarySearch(list, key); } ArrayList实现了RandomAccess方法,而LinkedList没有。是由于ArrayList底层是数组,支持快速随机访问,时间复杂度为O(1),而LinkedList底层是链表,不支持快速随机访问,时间复杂度为O(n)\nSet # Coparable和Comparator的区别\nComparable实际出自java.lang包,有一个compareTo(Object obj)方法用来排序 Comparator实际出自java.util包,有一个compare(Object obj1,Object obj2)方法用来排序 Collections.sort(List\u0026lt;T\u0026gt; list, Comparator\u0026lt;? super T\u0026gt; c)默认是正序,T必须实现了Comparable,且Arrays.sort()方法中的部分代码如下:\n//使用插入排序 if (length \u0026lt; INSERTIONSORT_THRESHOLD) { for (int i=low; i\u0026lt;high; i++) for (int j=i; j\u0026gt;low \u0026amp;\u0026amp; c.compare(dest[j-1], dest[j])\u0026gt;0; j--) //如果前一个数跟后面的数相比大于零,则进行交换,即大的排后面 swap(dest, j, j-1); return; } //当比较结果\u0026gt;0时,调换数组前后两个元素的值,也就是后面的一定要比前面的大,即 public int compareTo(Person o) { if (this.age \u0026gt; o.getAge()) { return 1; } if (this.age \u0026lt; o.getAge()) { return -1; } return 0; } //下面这段代码,按照年龄降序(默认是升序) Collections.sort(arrayList, new Comparator\u0026lt;Integer\u0026gt;() { @Override public int compare(Integer o1, Integer o2) { //如果结果大于0,则两个数对调 //如果返回o2.compareTo(o1),就是当o2\u0026gt;01时,两个结果对换,也就是降序 //如果返回o1.compareTo(o2),就是当o1\u0026gt;o2时,两个结果对换,也就是升序 也就是当和参数顺序一致时,是升序;反之,则是降序 return o2.compareTo(o1); } }); //上面这段代码,标识 无序性和不可重复性\n无序性,指存储的数据,在底层数据结构中,并非按照数组索引的顺序添加(而是根据数据的哈希值决定) 不可重复性:指添加的元素按照equals()判断时,返回false。需同时重写equals()方法和hashCode() 方法 比较HashSet、LinkedHashSet和TreeSet三者异同\n都是Set实现类,保证元素唯一,且非线程安全 三者底层数据结构不同,HashSet底层为哈希表(HashMap); LinkedHashSet底层为链表+哈希表 ,元素的插入和取出顺序满足FIFO。TreeSet底层为红黑树,元素有序,排序方式有自然排序和定制排序 Queue # Queue和Deque的区别 # Queue Queue为单端队列,只能从一端插入元素,另一端删除元素,实现上一般遵循先进先出(FIFO)规则【Dequeue为双端队列,在队列两端均可插入或删除元素】 Queue扩展了Collection接口,根据因容量问题而导致操作失败后的处理方式不同分两类,操作失败后抛异常或返回特殊值 Dequeue,双端队列,在队列两端均可插入或删除元素,也会根据失败后处理方式分两类 Deque还有push()和pop()等其他方法,可用于模拟栈 ArrayDeque与LinkedList区别 # ArrayDeque和LinkedList都实现了Deque接口,两者都具有队列功能 ArrayDeque基于可变长的数组和双指针来实现,而LinkedList则通过链表来实现 ArrayDeque不支持存储NULL数据,但LinkedList支持 ArrayDeque是后面(JDK1.6)引入的,而LinkedList在JDK1.2就存在 ArrayDeque 插入时可能存在扩容过程, 不过均摊后的插入操作依然为 O(1)。虽然 LinkedList 不需要扩容,但是每次插入数据时均需要申请新的堆空间,均摊性能相比更慢。 总的来说,ArrayDeque来实现队列要比LinkedList更好,此外,ArrayDeque也可以用于实现栈\n说一说PriorityQueue # PriorityQueue 是在 JDK1.5 中被引入的, 其与 Queue 的区别在于元素出队顺序是与优先级相关的,即总是优先级最高的元素先出队。\n这里列举其相关的一些要点:\nPriorityQueue 利用了二叉堆的数据结构来实现的,底层使用可变长的数组来存储数据 PriorityQueue 通过堆元素的上浮和下沉,实现了在 O(logn) 的时间复杂度内插入元素和删除堆顶元素。 PriorityQueue 是非线程安全的,且不支持存储 NULL 和 non-comparable 的对象。 PriorityQueue 默认是小顶堆,但可以接收一个 Comparator 作为构造参数,从而来自定义元素优先级的先后。 "},{"id":344,"href":"/zh/docs/technology/Review/java_guide/java/Basic/ly0011lysyntactic_sugar/","title":"语法糖","section":"基础","content":" 转载自https://github.com/Snailclimb/JavaGuide (添加小部分笔记)感谢作者!\n简介 # 语法糖(Syntactic Sugar)也称糖衣语法,指的是在计算机语言中添加的某种语法,这种语法对语言的功能并没有影响,但是更方便程序员使用,简而言之,让程序更加简洁,有更高的可读性\nJava中有哪些语法糖 # Java虚拟机并不支持这些语法糖,这些语法糖在编译阶段就会被还原成简单的基础语法结构,这个过程就是解语法糖\njavac命令可以将后缀为.java的源文件编译为后缀名为.class的可以运行于Java虚拟机的字节码。其中,com.sun.tools.javac.main.JavaCompiler的源码中,compile()中有一个步骤就是调用desugar(),这个方法就是负责解语法糖的实现的 Java中的语法糖,包括 泛型、变长参数、条件编译、自动拆装箱、内部类等 switch支持String与枚举 # switch本身原本只支持基本类型,如int、char\nint是比较数值,而char则是比较其ascii码,所以其实对于编译器来说,都是int类型(整型),比如byte。short,char(ackii 码是整型)以及int。 而对于enum类型,\n对于switch中使用String,则:\npublic class switchDemoString { public static void main(String[] args) { String str = \u0026#34;world\u0026#34;; switch (str) { case \u0026#34;hello\u0026#34;: System.out.println(\u0026#34;hello\u0026#34;); break; case \u0026#34;world\u0026#34;: System.out.println(\u0026#34;world\u0026#34;); break; default: break; } } } //反编译之后 public class switchDemoString { public switchDemoString() { } public static void main(String args[]) { String str = \u0026#34;world\u0026#34;; String s; switch((s = str).hashCode()) { default: break; case 99162322: if(s.equals(\u0026#34;hello\u0026#34;)) System.out.println(\u0026#34;hello\u0026#34;); break; case 113318802: if(s.equals(\u0026#34;world\u0026#34;)) System.out.println(\u0026#34;world\u0026#34;); break; } } } 即switch判断是通过**equals()和hashCode()**方法来实现的\nequals()检查是必要的,因为有可能发生碰撞,所以性能没有直接使用枚举进行switch或纯整数常量性能高\n泛型 # 编译器处理泛型有两种方式:Code specialization和Code sharing。C++和 C#是使用Code specialization的处理机制,而 Java 使用的是Code sharing的机制\nCode sharing 方式为每个泛型类型创建唯一的字节码表示,并且将该泛型类型的实例都映射到这个唯一的字节码表示上。将多种泛型类形实例映射到唯一的字节码表示是通过**类型擦除(type erasue)**实现的。\n对于 Java 虚拟机来说,他根本不认识Map\u0026lt;String, String\u0026gt; map 这样的语法。需要在编译阶段通过类型擦除的方式进行解语法糖 类型擦除的主要过程如下: 1.将所有的泛型参数用其最左边界(最顶级的父类型)类型替换。 2.移除所有的类型参数。 两个例子\nMap擦除\nMap\u0026lt;String, String\u0026gt; map = new HashMap\u0026lt;String, String\u0026gt;(); map.put(\u0026#34;name\u0026#34;, \u0026#34;hollis\u0026#34;); map.put(\u0026#34;wechat\u0026#34;, \u0026#34;Hollis\u0026#34;); map.put(\u0026#34;blog\u0026#34;, \u0026#34;www.hollischuang.com\u0026#34;); //解语法糖之后 Map map = new HashMap(); map.put(\u0026#34;name\u0026#34;, \u0026#34;hollis\u0026#34;); map.put(\u0026#34;wechat\u0026#34;, \u0026#34;Hollis\u0026#34;); map.put(\u0026#34;blog\u0026#34;, \u0026#34;www.hollischuang.com\u0026#34;); 其他擦除\npublic static \u0026lt;A extends Comparable\u0026lt;A\u0026gt;\u0026gt; A max(Collection\u0026lt;A\u0026gt; xs) { Iterator\u0026lt;A\u0026gt; xi = xs.iterator(); A w = xi.next(); while (xi.hasNext()) { A x = xi.next(); if (w.compareTo(x) \u0026lt; 0) w = x; } return w; } //擦除后变成 public static Comparable max(Collection xs){ Iterator xi = xs.iterator(); Comparable w = (Comparable)xi.next(); while(xi.hasNext()) { Comparable x = (Comparable)xi.next(); if(w.compareTo(x) \u0026lt; 0) w = x; } return w; } 小结\n虚拟机中并不存在泛型,泛型类没有自己独有的Class类对象,即不存在List\u0026lt;String\u0026gt;.class 或是 List\u0026lt;Integer\u0026gt;.class ,而只有List.class 虚拟机中,只有普通类和普通方法,所有泛型类的类型参数,在编译时都会被擦除 自动装箱与拆箱 # 装箱过程,通过调用包装器的valueOf方法实现的,而拆箱过程,则是通过调用包装器的xxxValue方法实现的\n自动装箱\npublic static void main(String[] args) { int i = 10; Integer n = i; } //反编译后的代码 public static void main(String args[]) { int i = 10; Integer n = Integer.valueOf(i); } 自动拆箱\npublic static void main(String[] args) { Integer i = 10; int n = i; } //反编译后的代码 public static void main(String args[]) { Integer i = Integer.valueOf(10); int n = i.intValue(); //注意,是intValue,不是initValue } 可变长参数 # variable arguments,是在Java 1.5中引入的一个特性,允许一个方法把任意数量的值作为参数,代码:\npublic static void main(String[] args) { print(\u0026#34;Holis\u0026#34;, \u0026#34;公众号:Hollis\u0026#34;, \u0026#34;博客:www.hollischuang.com\u0026#34;, \u0026#34;QQ:907607222\u0026#34;); } public static void print(String... strs) { for (int i = 0; i \u0026lt; strs.length; i++) { System.out.println(strs[i]); } } //反编译后代码 public static void main(String args[]) { print(new String[] { \u0026#34;Holis\u0026#34;, \u0026#34;\\u516C\\u4F17\\u53F7:Hollis\u0026#34;, \u0026#34;\\u535A\\u5BA2\\uFF1Awww.hollischuang.com\u0026#34;, \u0026#34;QQ\\uFF1A907607222\u0026#34; }); } public static transient void print(String strs[]) { for(int i = 0; i \u0026lt; strs.length; i++) System.out.println(strs[i]); } 如上,可变参数在被使用的时候,会创建一个数组,数组的长度,就是调用该方法的传递的实参的个数,然后再把参数值全部放到这个数组当中,最后把这个数组作为参数传递到被调用的方法中\n枚举 # 关键字enum可以将一组具名的值的有限集合创建为一种新的类型,而这些具名的值可以作为常规的程序组件使用,这是一种非常有用的功能\n写一个enum类进行测试\npublic enum T { SPRING,SUMMER; } //反编译之后 // Decompiled by Jad v1.5.8g. Copyright 2001 Pavel Kouznetsov. // Jad home page: http://www.kpdus.com/jad.html // Decompiler options: packimports(3) // Source File Name: T.java package com.ly.review.base; public final class T extends Enum { /** 下面这个和博客不太一样,博客里面是这样的 // ENUM$VALUES是博客编译后的数组名 public static T[] values() { T at[]; int i; T at1[]; System.arraycopy(at = ENUM$VALUES, 0, at1 = new T[i = at.length], 0, i); return at1; } */ public static T[] values() { return (T[])$VALUES.clone(); } public static T valueOf(String s) { return (T)Enum.valueOf(com/ly/review/base/T, s); } private T(String s, int i) { super(s, i); } public static final T Spring; public static final T SUMMER; private static final T $VALUES[]; static { Spring = new T(\u0026#34;Spring\u0026#34;, 0); SUMMER = new T(\u0026#34;SUMMER\u0026#34;, 1); $VALUES = (new T[] { Spring, SUMMER }); } } 重要代码:\npublic final class T extends Enum 说明该类不可继承\npublic static final T Spring; public static final T SUMMER; 说明枚举类型不可修改\n内部类 # 内部类又称为嵌套类,可以把内部类理解成外部类的一个普通成员 内部类之所以也是语法糖,是因为它仅仅是一个编译时的概念,outer.java里面定义了一个内部类inner,一旦编译成功,就会生成两个完全不同的.class文件了,分别是outer.class和outer$inner.class。所以内部类的名字完全可以和它的外部类名字相同。\n代码如下:\npublic class OutterClass { private String userName; public String getUserName() { return userName; } public void setUserName(String userName) { this.userName = userName; } public static void main(String[] args) { } class InnerClass{ private String name; public String getName() { return name; } public void setName(String name) { this.name = name; } } } 编译之后,会生成两个class文件OutterClass.class和OutterClass$InnerClass.class。所以内部类是可以跟外部类完全一样的名字的 如果要对OutterClass.class进行反编译,那么他会把OutterClass$InnerClass.class也一起进行反编译\npublic class OutterClass { class InnerClass { public String getName() { return name; } public void setName(String name) { this.name = name; } private String name; final OutterClass this$0; InnerClass() { this.this$0 = OutterClass.this; super(); } } public OutterClass() { } public String getUserName() { return userName; } public void setUserName(String userName){ this.userName = userName; } public static void main(String args1[]) { } private String userName; } 条件编译 # —般情况下,程序中的每一行代码都要参加编译。但有时候出于对程序代码优化的考虑,希望只对其中一部分内容进行编译,此时就需要在程序中加上条件,让编译器只对满足条件的代码进行编译,将不满足条件的代码舍弃,这就是条件编译。\npublic class ConditionalCompilation { public static void main(String[] args) { final boolean DEBUG = true; if(DEBUG) { System.out.println(\u0026#34;Hello, DEBUG!\u0026#34;); } final boolean ONLINE = false; if(ONLINE){ System.out.println(\u0026#34;Hello, ONLINE!\u0026#34;); } } } //反编译之后如下 public class ConditionalCompilation { public ConditionalCompilation() { } public static void main(String args[]) { boolean DEBUG = true; System.out.println(\u0026#34;Hello, DEBUG!\u0026#34;); boolean ONLINE = false; } } Java 语法的条件编译,是通过判断条件为常量的 if 语句实现的。其原理也是 Java 语言的语法糖。根据 if 判断条件的真假,编译器直接把分支为 false 的代码块消除。通过该方式实现的条件编译,必须在方法体内实现,而无法在正整个 Java 类的结构或者类的属性上进行条件编译\n断言 # Java 在执行的时候默认是不启动断言检查的(这个时候,所有的断言语句都将忽略!),如果要开启断言检查,则需要用开关-enableassertions或-ea来开启\n代码如下:\npublic class AssertTest { public static void main(String args[]) { int a = 1; int b = 1; assert a == b; System.out.println(\u0026#34;公众号:Hollis\u0026#34;); assert a != b : \u0026#34;Hollis\u0026#34;; System.out.println(\u0026#34;博客:www.hollischuang.com\u0026#34;); } } //反编译之后代码如下 public class AssertTest { public AssertTest() { } public static void main(String args[]) { int a = 1; int b = 1; if(!$assertionsDisabled \u0026amp;\u0026amp; a != b) throw new AssertionError(); System.out.println(\u0026#34;\\u516C\\u4F17\\u53F7\\uFF1AHollis\u0026#34;); if(!$assertionsDisabled \u0026amp;\u0026amp; a == b) { throw new AssertionError(\u0026#34;Hollis\u0026#34;); } else { System.out.println(\u0026#34;\\u535A\\u5BA2\\uFF1Awww.hollischuang.com\u0026#34;); return; } } static final boolean $assertionsDisabled = !com/hollis/suguar/AssertTest.desiredAssertionStatus(); } 断言的底层是if语言,如果断言为true,则什么都不做;如果断言为false,则程序抛出AssertError来打断程序执行 -enableassertions会设置$assertionsDisabled字段的值 数值字面量 # java7中,字面量允许在数字之间插入任意多个下划线,不会对字面值产生影响,可以方便阅读\n源代码:\npublic class Test { public static void main(String... args) { int i = 10_000; System.out.println(i); } } //反编译后 public class Test { public static void main(String[] args) { int i = 10000; System.out.println(i); } } for-each # 源代码:\npublic static void main(String... args) { String[] strs = {\u0026#34;Hollis\u0026#34;, \u0026#34;公众号:Hollis\u0026#34;, \u0026#34;博客:www.hollischuang.com\u0026#34;}; for (String s : strs) { System.out.println(s); } List\u0026lt;String\u0026gt; strList = ImmutableList.of(\u0026#34;Hollis\u0026#34;, \u0026#34;公众号:Hollis\u0026#34;, \u0026#34;博客:www.hollischuang.com\u0026#34;); for (String s : strList) { System.out.println(s); } } //反编译之后 public static transient void main(String args[]) { String strs[] = { \u0026#34;Hollis\u0026#34;, \u0026#34;\\u516C\\u4F17\\u53F7\\uFF1AHollis\u0026#34;, \u0026#34;\\u535A\\u5BA2\\uFF1Awww.hollischuang.com\u0026#34; }; String args1[] = strs; int i = args1.length; for(int j = 0; j \u0026lt; i; j++) { String s = args1[j]; System.out.println(s); } List strList = ImmutableList.of(\u0026#34;Hollis\u0026#34;, \u0026#34;\\u516C\\u4F17\\u53F7\\uFF1AHollis\u0026#34;, \u0026#34;\\u535A\\u5BA2\\uFF1Awww.hollischuang.com\u0026#34;); String s; for(Iterator iterator = strList.iterator(); iterator.hasNext(); System.out.println(s)) s = (String)iterator.next(); } 会改成普通的for语句循环,或者使用迭代器\ntry-with-resource # 关闭资源的方式,就是再finally块里释放,即调用close方法\n//正常使用 public static void main(String[] args) { BufferedReader br = null; try { String line; br = new BufferedReader(new FileReader(\u0026#34;d:\\\\hollischuang.xml\u0026#34;)); while ((line = br.readLine()) != null) { System.out.println(line); } } catch (IOException e) { // handle exception } finally { try { if (br != null) { br.close(); } } catch (IOException ex) { // handle exception } } } JDK7之后提供的关闭资源的方式:\npublic static void main(String... args) { try (BufferedReader br = new BufferedReader(new FileReader(\u0026#34;d:\\\\ hollischuang.xml\u0026#34;))) { String line; while ((line = br.readLine()) != null) { System.out.println(line); } } catch (IOException e) { // handle exception } } 编译后:\npublic static transient void main(String args[]) { BufferedReader br; Throwable throwable; br = new BufferedReader(new FileReader(\u0026#34;d:\\\\ hollischuang.xml\u0026#34;)); throwable = null; String line; try { while((line = br.readLine()) != null) System.out.println(line); } catch(Throwable throwable2) { throwable = throwable2; throw throwable2; } if(br != null) if(throwable != null) try { br.close(); } catch(Throwable throwable1) { throwable.addSuppressed(throwable1); } else br.close(); break MISSING_BLOCK_LABEL_113; Exception exception; exception; if(br != null) if(throwable != null) try { br.close(); } catch(Throwable throwable3) { throwable.addSuppressed(throwable3); } else br.close(); throw exception; IOException ioexception; ioexception; } } 也就是我们没有做关闭的操作,编译器都帮我们做了\nLambda表达 # 使用lambda表达式便利list\npublic static void main(String... args) { List\u0026lt;String\u0026gt; strList = ImmutableList.of(\u0026#34;Hollis\u0026#34;, \u0026#34;公众号:Hollis\u0026#34;, \u0026#34;博客:www.hollischuang.com\u0026#34;); strList.forEach( s -\u0026gt; { System.out.println(s); } ); } 反编译之后\npublic static /* varargs */ void main(String ... args) { ImmutableList strList = ImmutableList.of((Object)\u0026#34;Hollis\u0026#34;, (Object)\u0026#34;\\u516c\\u4f17\\u53f7\\uff1aHollis\u0026#34;, (Object)\u0026#34;\\u535a\\u5ba2\\uff1awww.hollischuang.com\u0026#34;); strList.forEach((Consumer\u0026lt;String\u0026gt;)LambdaMetafactory.metafactory(null, null, null, (Ljava/lang/Object;)V, lambda$main$0(java.lang.String ), (Ljava/lang/String;)V)()); } private static /* synthetic */ void lambda$main$0(String s) { System.out.println(s); } lambda表达式的实现其实是依赖了一些底层的api,在编译阶段,会把lambda表达式进行解糖,转换成调用内部api的方式\n可能遇到的坑 # 泛型 # 泛型遇到重载\npublic class GenericTypes { public static void method(List\u0026lt;String\u0026gt; list) { System.out.println(\u0026#34;invoke method(List\u0026lt;String\u0026gt; list)\u0026#34;); } public static void method(List\u0026lt;Integer\u0026gt; list) { System.out.println(\u0026#34;invoke method(List\u0026lt;Integer\u0026gt; list)\u0026#34;); } } 这种方法是编译不过去的,因为参数List\u0026lt;Integer\u0026gt; 和List\u0026lt;String\u0026gt;编译之后都被擦出了,变成了一样的原生类型List,擦除动作导致这两个方法的特征签名变得一模一样。\n泛型的类型参数不能用在 Java 异常处理的 catch 语句中。因为异常处理是由 JVM 在运行时刻来进行的。由于类型信息被擦除,JVM 是无法区分两个异常类型MyException\u0026lt;String\u0026gt;和MyException\u0026lt;Integer\u0026gt;的\n泛型类的所有静态变量是共享的\npublic class StaticTest{ public static void main(String[] args){ GT\u0026lt;Integer\u0026gt; gti = new GT\u0026lt;Integer\u0026gt;(); gti.var=1; GT\u0026lt;String\u0026gt; gts = new GT\u0026lt;String\u0026gt;(); gts.var=2; System.out.println(gti.var); } } class GT\u0026lt;T\u0026gt;{ public static int var=0; public void nothing(T x){} } 以上代码输出结果为:2!\n由于经过类型擦除,所有的泛型类实例都关联到同一份字节码上,泛型类的所有静态变量是共享的。\n自动装箱与拆箱 # 对于自动装箱,整形对象通过使用相同的缓存和重用,适用于整数值区间 [ -128,+127 ]\npublic static void main(String[] args) { Integer a = 1000; Integer b = 1000; Integer c = 100; Integer d = 100; System.out.println(\u0026#34;a == b is \u0026#34; + (a == b)); System.out.println((\u0026#34;c == d is \u0026#34; + (c == d))); } //结果 a == b is false c == d is true 增强for循环 # 遍历时不要使用list的remove方法:\nfor (Student stu : students) { if (stu.getId() == 2) students.remove(stu); } //会报ConcurrentModificationException异常,Iterator在工作的时候不允许被迭代的对象被改变,但可以使用Iterator本身的remove()来删除对象,会在删除当前对象的同时,维护索引的一致性 "},{"id":345,"href":"/zh/docs/technology/Review/java_guide/java/Basic/ly0010lyjava_spi/","title":"java_spi","section":"基础","content":" 转载自https://github.com/Snailclimb/JavaGuide (添加小部分笔记)感谢作者!\n简介 # 为了实现在模块装配的时候不用再程序里面动态指明,这就需要一种服务发现机制。JavaSPI就是提供了这样的一个机制:为某个接口寻找服务实现的机制。有点类似IoC的思想,将装配的控制权交到了程序之外\nSPI介绍 # SPI,ServiceProviderInterface 使用SPI:Spring框架、数据库加载驱动、日志接口、以及Dubbo的扩展实现\n感觉下面这个图不太对,被调用方应该 一般模块之间都是通过接口进行通讯,\n当实现方提供了接口和实现,我们可以通过调用实现方的接口从而拥有实现方给我们提供的能力,这就是 API ,这种接口和实现都是放在实现方的。\n当接口存在于调用方这边时,就是 SPI ,由接口调用方确定接口规则,然后由不同的厂商去根据这个规则对这个接口进行实现,从而提供服务。[可以理解成业务方,或者说使用方。它使用了这个接口,而且制定了接口规范,但是具体实现,由被调用方实现]\n我的理解:被调用方(提供接口的人),调用方(使用接口的人),但是其实这里只把调用方\u0026ndash;\u0026gt;使用接口的人 这个关系是对的。\n也就是说,正常情况下由被调用方自己提供接口和实现,即API。而现在,由调用方(这里的调用方其实可以理解成上面的被调用方),提供了接口还使用了接口,而由被调用方进行接口实现\n实战演示 # SLF4J只是一个日志门面(接口),但是SLF4J的具体实现可以有多种,如:Logback/Log4j/Log4j2等等\n简易版本 # ServiceProviderInterface\n目录结构\n│ service-provider-interface.iml │ ├─.idea │ │ .gitignore │ │ misc.xml │ │ modules.xml │ └─ workspace.xml │ └─src └─edu └─jiangxuan └─up └─spi Logger.java LoggerService.java Main.class Logger接口,即SPI 服务提供者接口,后面的服务提供者要针对这个接口进行实现\npackage edu.jiangxuan.up.spi; public interface Logger { void info(String msg); void debug(String msg); } LoggerService类,主要是为服务使用者(调用方)提供特定功能,这个类是实现JavaSPI机制的关键所在\npackage edu.jiangxuan.up.spi; import java.util.ArrayList; import java.util.List; import java.util.ServiceLoader; public class LoggerService { private static final LoggerService SERVICE = new LoggerService(); private final Logger logger; private final List\u0026lt;Logger\u0026gt; loggerList; private LoggerService() { ServiceLoader\u0026lt;Logger\u0026gt; loader = ServiceLoader.load(Logger.class); List\u0026lt;Logger\u0026gt; list = new ArrayList\u0026lt;\u0026gt;(); for (Logger log : loader) { list.add(log); } // LoggerList 是所有 ServiceProvider loggerList = list; if (!list.isEmpty()) { // Logger 只取一个 logger = list.get(0); } else { logger = null; } } //简单单例 public static LoggerService getService() { return SERVICE; } public void info(String msg) { if (logger == null) { System.out.println(\u0026#34;info 中没有发现 Logger 服务提供者\u0026#34;); } else { logger.info(msg); } } public void debug(String msg) { if (loggerList.isEmpty()) { System.out.println(\u0026#34;debug 中没有发现 Logger 服务提供者\u0026#34;); } loggerList.forEach(log -\u0026gt; log.debug(msg)); } } Main类(服务使用者,调用方)\npackage org.spi.service; public class Main { public static void main(String[] args) { LoggerService service = LoggerService.getService(); service.info(\u0026#34;Hello SPI\u0026#34;); service.debug(\u0026#34;Hello SPI\u0026#34;); } } /** 结果 info 中没有发现 Logger 服务提供者 debug 中没有发现 Logger 服务提供者 */ 新的项目,来实现Logger接口\n项目结构\n│ service-provider.iml │ ├─.idea │ │ .gitignore │ │ misc.xml │ │ modules.xml │ └─ workspace.xml │ ├─lib │ service-provider-interface.jar | └─src ├─edu │ └─jiangxuan │ └─up │ └─spi │ └─service │ Logback.java │ └─META-INF └─services edu.jiangxuan.up.spi.Logger 首先需要有一个实现类\npackage edu.jiangxuan.up.spi.service; import edu.jiangxuan.up.spi.Logger; public class Logback implements Logger { @Override public void info(String s) { System.out.println(\u0026#34;Logback info 打印日志:\u0026#34; + s); } @Override public void debug(String s) { System.out.println(\u0026#34;Logback debug 打印日志:\u0026#34; + s); } } 将之前项目打包的jar导入项目中\n之后要src 目录下新建 META-INF/services 文件夹,然后新建文件 edu.jiangxuan.up.spi.Logger (SPI 的全类名,接口名),文件里面的内容是:edu.jiangxuan.up.spi.service.Logback (Logback 的全类名,即 SPI 的实现类的包名 + 类名)\n这是 JDK SPI 机制 ServiceLoader 约定好的标准。\nJava 中的 SPI 机制就是在每次类加载的时候会先去找到 class 相对目录下的 META-INF 文件夹下的 services 文件夹下的文件,将这个文件夹下面的所有文件先加载到内存中,然后根据这些文件的文件名和里面的文件内容找到相应接口的具体实现类,找到实现类后就可以通过反射去生成对应的对象,保存在一个 list 列表里面,所以可以通过迭代或者遍历的方式拿到对应的实例对象,生成不同的实现。\n即:文件名一定要是接口的全类名,然后里面的内容一定要是实现类的全类名,实现类可以有多个,直接换行就好了,多个实现类的时候,会一个一个的迭代加载。\n接下来同样将 service-provider 项目打包成 jar 包,这个 jar 包就是服务提供方的实现。通常我们导入 maven 的 pom 依赖就有点类似这种,只不过我们现在没有将这个 jar 包发布到 maven 公共仓库中,所以在需要使用的地方只能手动的添加到项目中 效果展示 package edu.jiangxuan.up.service; import edu.jiangxuan.up.spi.LoggerService; public class TestJavaSPI { public static void main(String[] args) { LoggerService loggerService = LoggerService.getService(); loggerService.info(\u0026#34;你好\u0026#34;); loggerService.debug(\u0026#34;测试Java SPI 机制\u0026#34;); } } 通过使用 SPI 机制,可以看出服务(LoggerService)和 服务提供者两者之间的耦合度非常低,如果说我们想要换一种实现,那么其实只需要修改 service-provider 项目中针对 Logger 接口的具体实现就可以了,只需要换一个 jar 包即可,也可以有在一个项目里面有多个实现,这不就是 SLF4J 原理吗?\nServiceLoader # JDK 官方给的注释:一种加载服务实现的工具。\n具体实现 # 自己实现 # //个人简易版\npackage edu.jiangxuan.up.service; import java.io.BufferedReader; import java.io.InputStream; import java.io.InputStreamReader; import java.lang.reflect.Constructor; import java.net.URL; import java.net.URLConnection; import java.util.ArrayList; import java.util.Enumeration; import java.util.List; public class MyServiceLoader\u0026lt;S\u0026gt; { // 对应的接口 Class 模板 private final Class\u0026lt;S\u0026gt; service; // 对应实现类的 可以有多个,用 List 进行封装 private final List\u0026lt;S\u0026gt; providers = new ArrayList\u0026lt;\u0026gt;(); // 类加载器 private final ClassLoader classLoader; // 暴露给外部使用的方法,通过调用这个方法可以开始加载自己定制的实现流程。 public static \u0026lt;S\u0026gt; MyServiceLoader\u0026lt;S\u0026gt; load(Class\u0026lt;S\u0026gt; service) { return new MyServiceLoader\u0026lt;\u0026gt;(service); } // 构造方法私有化 private MyServiceLoader(Class\u0026lt;S\u0026gt; service) { this.service = service; this.classLoader = Thread.currentThread().getContextClassLoader(); doLoad(); } // 关键方法,加载具体实现类的逻辑 private void doLoad() { try { // 读取所有 jar 包里面 META-INF/services 包下面的文件,这个文件名就是接口名,然后文件里面的内容就是具体的实现类的路径加全类名 Enumeration\u0026lt;URL\u0026gt; urls = classLoader.getResources(\u0026#34;META-INF/services/\u0026#34; + service.getName()); // 挨个遍历取到的文件 while (urls.hasMoreElements()) { // 取出当前的文件 URL url = urls.nextElement(); System.out.println(\u0026#34;File = \u0026#34; + url.getPath()); // 建立链接 URLConnection urlConnection = url.openConnection(); urlConnection.setUseCaches(false); // 获取文件输入流 InputStream inputStream = urlConnection.getInputStream(); // 从文件输入流获取缓存 BufferedReader bufferedReader = new BufferedReader(new InputStreamReader(inputStream)); // 从文件内容里面得到实现类的全类名 String className = bufferedReader.readLine(); while (className != null) { // ★★【重点】 通过反射拿到实现类的实例 Class\u0026lt;?\u0026gt; clazz = Class.forName(className, false, classLoader); // 如果声明的接口跟这个具体的实现类是属于同一类型,(可以理解为Java的一种多态,接口跟实现类、父类和子类等等这种关系。)则构造实例 if (service.isAssignableFrom(clazz)) { Constructor\u0026lt;? extends S\u0026gt; constructor = (Constructor\u0026lt;? extends S\u0026gt;) clazz.getConstructor(); S instance = constructor.newInstance(); // 把当前构造的实例对象添加到 Provider的列表里面 providers.add(instance); } // 继续读取下一行的实现类,可以有多个实现类,只需要换行就可以了。 className = bufferedReader.readLine(); } } } catch (Exception e) { System.out.println(\u0026#34;读取文件异常。。。\u0026#34;); } } // 返回spi接口对应的具体实现类列表 public List\u0026lt;S\u0026gt; getProviders() { return providers; } } 基本流程:\n通过 URL 工具类从 jar 包的 /META-INF/services 目录下面找到对应的文件, 读取这个文件的名称找到对应的 spi 接口, 通过 InputStream 流将文件里面的具体实现类的全类名读取出来, 根据获取到的全类名,先判断跟 spi 接口是否为同一类型,如果是的,那么就通过反射的机制构造对应的实例对象, 将构造出来的实例对象添加到 Providers 的列表中。 "},{"id":346,"href":"/zh/docs/technology/Review/java_guide/java/Basic/ly0009lyunsafe_class/","title":"unsafe类","section":"基础","content":" 转载自https://github.com/Snailclimb/JavaGuide (添加小部分笔记)感谢作者!\nsun.misc.Unsafe\n提供执行低级别、不安全操作的方法,如直接访问系统内存资源、自主管理内存资源等,效率快,但由于有了操作内存空间的能力,会增加指针问题风险。且这些功能的实现依赖于本地方法,Java代码中只是声明方法头,具体实现规则交给本地代码 为什么要使用本地方法 # 需要用到Java中不具备的依赖于操作系统的特性,跨平台的同时要实现对底层控制 对于其他语言已经完成的现成功能,可以使用Java调用 对时间敏感/性能要求非常高,有必要使用更为底层的语言 对于同一本地方法,不同的操作系统可能通过不同的方式来实现的\nUnsafe创建 # sun.misc.Unsafe部分源码\npublic final class Unsafe { // 单例对象 private static final Unsafe theUnsafe; ...... private Unsafe() { } //Sensitive : 敏感的 英[ˈsensətɪv] @CallerSensitive public static Unsafe getUnsafe() { Class var0 = Reflection.getCallerClass(); // 仅在引导类加载器`BootstrapClassLoader`加载时才合法 if(!VM.isSystemDomainLoader(var0.getClassLoader())) { throw new SecurityException(\u0026#34;Unsafe\u0026#34;); } else { return theUnsafe; } } } 会先判断当前类是否由Bootstrap classloader加载。即只有启动类加载器加载的类才能够调用Unsafe类中的方法\n如何使用Unsafe这个类\n利用反射获得Unsafe类中已经实例化完成的单例对象theUnsafe\nprivate static Unsafe reflectGetUnsafe() { try { Field field = Unsafe.class.getDeclaredField(\u0026#34;theUnsafe\u0026#34;); field.setAccessible(true); return (Unsafe) field.get(null); } catch (Exception e) { log.error(e.getMessage(), e); return null; } } 通过Java命令行命令-Xbootclasspath/a把调用Unsafe相关方法的类A所在jar包路径追加到默认的bootstrap路径中,使得A被引导类加载器加载\njava -Xbootclasspath/a:${path} // 其中path为调用Unsafe相关方法的类所在jar包路径 Unsafe功能 # 内存操作、内存屏障、对象操作、数据操作、CAS操作、线程调度、Class操作、系统信息\n内存操作 # 相关方法:\n//分配新的本地空间 public native long allocateMemory(long bytes); //重新调整内存空间的大小 public native long reallocateMemory(long address, long bytes); //将内存设置为指定值 public native void setMemory(Object o, long offset, long bytes, byte value); //内存拷贝 public native void copyMemory(Object srcBase, long srcOffset,Object destBase, long destOffset,long bytes); //清除内存 public native void freeMemory(long address); 测试:\npackage com.unsafe; import lombok.extern.slf4j.Slf4j; import sun.misc.Unsafe; import java.lang.reflect.Field; import java.util.concurrent.TimeUnit; @Slf4j public class UnsafeGet { private Unsafe unsafe; public UnsafeGet() { this.unsafe = UnsafeGet.reflectGetUnsafe(); ; } private static Unsafe reflectGetUnsafe() { try { Field field = Unsafe.class.getDeclaredField(\u0026#34;theUnsafe\u0026#34;); field.setAccessible(true); return (Unsafe) field.get(null); } catch (Exception e) { log.error(e.getMessage(), e); return null; } } public void example() throws InterruptedException { int size = 4; //使用allocateMemory方法申请 4 字节长度的内存空间 long addr = unsafe.allocateMemory(size); //setMemory(Object var1, long var2, long var4, byte var6) //从var1的偏移量var2处开始,每个字节都设置为var6,设置var4个字节 unsafe.setMemory(null, addr, size, (byte) 1); //找到一个新的size*2大小的内存块,并且拷贝原来addr的值过来 long addr3 = unsafe.reallocateMemory(addr, size * 2); //实际操作中这个地址可能等于addr(有概率,没找到原因,这里先假设重新分配了一块) System.out.println(\u0026#34;addr: \u0026#34; + addr); System.out.println(\u0026#34;addr3: \u0026#34; + addr3); System.out.println(\u0026#34;addr值: \u0026#34; + unsafe.getInt(addr)); System.out.println(\u0026#34;addr3值: \u0026#34; + unsafe.getLong(addr3)); try { for (int i = 0; i \u0026lt; 2; i++) { // copyMemory(Object var1, long var2, Object var4, long var5, long var7); // 从var1的偏移量var2处开始,拷贝数据到var4的偏移量var5上,每次拷贝var7个字节 //所以i = 0时,拷贝到了addr3的前4个字节;i = 1 时,拷贝到了addr3的后4个字节 unsafe.copyMemory(null, addr, null, addr3 + size * i, 4); } System.out.println(unsafe.getInt(addr)); System.out.println(unsafe.getLong(addr3)); } finally { log.info(\u0026#34;start-------\u0026#34;); unsafe.freeMemory(addr); log.info(\u0026#34;end-------\u0026#34;); unsafe.freeMemory(addr3); //实际操作中这句话没执行,不知道原因 } } public static void main(String[] args) throws InterruptedException { long l = Long.parseLong(\u0026#34;0000000100000001000000010000000100000001000000010000000100000001\u0026#34;, 2); System.out.println(l); new UnsafeGet().example(); /** 输出 72340172838076673 addr: 46927104 addr3: 680731776 addr值: 16843009 addr3值: 16843009 16843009 72340172838076673 2023-01-31 14:19:28 下午 [Thread: main] INFO:start------- */ } } 对于setMemory的解释 来源\n/** 将给定内存块中的所有字节设置为固定值(通常是0)。 内存块的地址由对象引用o和偏移地址共同决定,如果对象引用o为null,offset就是绝对地址。第三个参数就是内存块的大小,如果使用allocateMemory进行内存开辟的话,这里的值应该和allocateMemory的参数一致。 value就是设置的固定值,一般为0(这里可以参考netty的DirectByteBuffer)。一般而言,o为null 所有有个重载方法是public native void setMemory(long offset, long bytes, byte value); 等效于setMemory(null, long offset, long bytes, byte value);。 */ public native void setMemory(Object o, long offset, long bytes, byte value); 分析:\n分析一下运行结果,首先使用allocateMemory方法申请 4 字节长度的内存空间,在循环中调用setMemory方法向每个字节写入内容为byte类型的 1,当使用 Unsafe 调用getInt方法时,因为一个int型变量占 4 个字节,会一次性读取 4 个字节,组成一个int的值,对应的十进制结果为 16843009。\n对于reallocateMemory方法:\n在代码中调用reallocateMemory方法重新分配了一块 8 字节长度的内存空间,通过比较addr和addr3可以看到和之前申请的内存地址是不同的。在代码中的第二个 for 循环里,调用copyMemory方法进行了两次内存的拷贝,每次拷贝内存地址addr开始的 4 个字节,分别拷贝到以addr3和addr3+4开始的内存空间上:\n拷贝完成后,使用getLong方法一次性读取8个字节,得到long类型的值\n这种分配属于堆外内存,无法进行垃圾回收,需要我们把这些内存当作资源去手动调用freeMemory方法进行释放,否则会产生内存泄漏。通常是try-finally进行内存释放\n为什么使用堆外内存\n对垃圾回收停顿的改善,堆外内存直接受操作系统管理而不是JVM 提升程序I/O操作的性能。通常I/O通信过程中,存在堆内内存到堆外内存的数据拷贝操作,对于需要频繁进行内存间的数据拷贝且生命周期较短的暂存数据,建议都存储到堆外内存 典型应用 DirectByteBuffer,Java用于实现堆外内存的重要类,对于堆外内存的创建、使用、销毁等逻辑均由Unsafe提供的堆外内存API来实现\n//DirectByteBuffer类源 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 { // 分配内存并返回基地址 base = unsafe.allocateMemory(size); } catch (OutOfMemoryError x) { Bits.unreserveMemory(size, cap); throw x; } // 内存初始化 unsafe.setMemory(base, size, (byte) 0); if (pa \u0026amp;\u0026amp; (base % ps != 0)) { // Round up to page boundary address = base + ps - (base \u0026amp; (ps - 1)); } else { address = base; } // 跟踪 DirectByteBuffer 对象的垃圾回收,以实现堆外内存释放 cleaner = Cleaner.create(this, new Deallocator(base, size, cap)); att = null; } 内存屏障 # 介绍\n编译器和 CPU 会在保证程序输出结果一致的情况下,会对代码进行重排序,从指令优化角度提升性能 后果是,导致 CPU 的高速缓存和内存中数据的不一致 内存屏障(Memory Barrier)就是通过阻止屏障两边的指令重排序从而避免编译器和硬件的不正确优化情况 Unsafe提供了三个内存屏障相关方法\n//内存屏障,禁止load操作重排序。屏障前的load操作不能被重排序到屏障后,屏障后的load操作不能被重排序到屏障前 public native void loadFence(); //内存屏障,禁止store操作重排序。屏障前的store操作不能被重排序到屏障后,屏障后的store操作不能被重排序到屏障前 public native void storeFence(); //内存屏障,禁止load、store操作重排序 public native void fullFence(); 以loadFence方法为例,会禁止读操作重排序,保证在这个屏障之前的所有读操作都已经完成,并且将缓存数据设为无效,重新从主存中进行加载 在某个线程修改Runnable中的flag\n@Getter class ChangeThread implements Runnable{ /**volatile**/ boolean flag=false; @Override public void run() { try { Thread.sleep(3000); } catch (InterruptedException e) { e.printStackTrace(); } System.out.println(\u0026#34;subThread change flag to:\u0026#34; + flag); flag = true; } } 在主线程的while循环中,加入内存屏障,测试是否能感知到flag的修改变化\npublic static void main(String[] args){ ChangeThread changeThread = new ChangeThread(); new Thread(changeThread).start(); while (true) { boolean flag = changeThread.isFlag(); unsafe.loadFence(); //加入读内存屏障 //假设上面这句unsafe.loadFence()去掉,那么 /* 流程:1. 这里的flag为主线程读取到的flag,且此时子线程还没有修改 2. 3秒后子线程进行了修改,由于没有内存屏障,主线程(注意,不是主 存储,是主线程)还是原来的值,值没有刷新,导致不一致 */ if (flag){ System.out.println(\u0026#34;detected flag changed\u0026#34;); break; } //这里不能有System.out.println语句,不然会导致同步 /* synchronized的规定 线程解锁前,必须把共享变量刷新到主内存 线程加锁前将清空工作内存共享变量的值,需要从主存中获取共享变量的值。 */ /** public void println(String x) { synchronized (this) { print(x); newLine(); } } */ } System.out.println(\u0026#34;main thread end\u0026#34;); } //运行结果 subThread change flag to:false detected flag changed main thread end 如果删除上面的loadFence()方法,就会出现下面的情况,主线程无法感知flag发生的变化,会一直在while中循环 典型应用 Java8新引入的锁\u0026mdash;StampedLock,乐观锁,类似于无锁的操作,完全不会阻塞写线程获取写锁,从而缓解读多写少的”饥饿“现象。由于StampedLock提供的乐观读锁不阻塞写线程获取读锁,当线程共享变量从主内存load到线程工作内存时,存在数据不一致的问题\n/** StampedLock 的 validate 方法会通过 Unsafe 的 loadFence 方法加入一个 load 内存屏障 */ public boolean validate(long stamp) { U.loadFence(); return (stamp \u0026amp; SBITS) == (state \u0026amp; SBITS); } 对象操作 # 对象属性\n//在对象的指定偏移地址获取一个对象引用 public native Object getObject(Object o, long offset); //在对象指定偏移地址写入一个对象引用 public native void putObject(Object o, long offset, Object x); 对象实例化 类:\n@Data public class A { private int b; public A(){ this.b =1; } } 对象实例化\n允许我们使用非常规的方式进行对象的实例化\npublic void objTest() throws Exception{ A a1=new A(); System.out.println(a1.getB()); A a2 = A.class.newInstance(); System.out.println(a2.getB()); A a3= (A) unsafe.allocateInstance(A.class); System.out.println(a3.getB()); } //结果 1 1 0\n\u0026gt; 打印结果分别为 1、1、0,说明通过**`allocateInstance`方法创建对象**过程中,**不会调用类的构造方法**。使用这种方式创建对象时,只用到了`Class`对象,所以说如果想要**跳过对象的初始化阶段**或者**跳过构造器的安全检查**,就可以使用这种方法。在上面的例子中,如果将 A 类的**构造函数改为`private`**类型,将无法通过构造函数和反射创建对象,但**`allocateInstance`方法仍然有效**。 - 典型应用 - 常规对象实例化方式,从本质上来说,都是通过new机制来实现对象的创建 - 非常规的实例化方式:Unsafe中提供allocateInstance方法,**仅通过Class对象**就可以创建此类的实例对象 #### 数组操作 - 介绍 ```java //下面两个方法配置使用,即可定位数组中每个元素在内存中的位置 //返回数组中第一个元素的偏移地址 public native int arrayBaseOffset(Class\u0026lt;?\u0026gt; arrayClass); //返回数组中一个元素占用的大小 public native int arrayIndexScale(Class\u0026lt;?\u0026gt; arrayClass); 典型应用\n这两个与数据操作相关的方法,在 java.util.concurrent.atomic 包下的 AtomicIntegerArray(可以实现对 Integer 数组中每个元素的原子性操作)中有典型的应用,如下图 AtomicIntegerArray 源码所示,通过 Unsafe 的 arrayBaseOffset 、arrayIndexScale 分别获取数组首元素的偏移地址 base 及单个元素大小因子 scale 。后续相关原子性操作,均依赖于这两个值进行数组中元素的定位,如下图二所示的 getAndAdd 方法即通过 checkedByteOffset 方法获取某数组元素的偏移地址,而后通过 CAS 实现原子性操作。\nCAS操作 # 相关操作\n/** * CAS * @param o 包含要修改field的对象 * @param offset 对象中某field的偏移量 * @param expected 期望值 * @param update 更新值 * @return true | false */ public final native boolean compareAndSwapObject(Object o, long offset, Object expected, Object update); public final native boolean compareAndSwapInt(Object o, long offset, int expected,int update); public final native boolean compareAndSwapLong(Object o, long offset, long expected, long update); CAS,AS 即比较并替换(Compare And Swap),是实现并发算法时常用到的一种技术。CAS 操作包含三个操作数——内存位置、预期原值及新值。执行 CAS 操作的时候,将内存位置的值与预期原值比较,如果相匹配,那么处理器会自动将该位置值更新为新值,否则,处理器不做任何操作。我们都知道,CAS 是一条 CPU 的原子指令(cmpxchg 指令),不会造成所谓的数据不一致问题,Unsafe 提供的 CAS 方法(如 compareAndSwapXXX)底层实现即为 CPU 指令 cmpxchg\n输出\nprivate volatile int a; public static void main(String[] args){ CasTest casTest=new CasTest(); new Thread(()-\u0026gt;{ /* 一开始a=0的时候,i=1,所以a + 1;之后 a = 1的时候,i = 2 ,所以a 又加1 ;而如果是不等于的话,就会一直原子获取a的值,直到等于 i -1 */ for (int i = 1; i \u0026lt; 5; i++) { casTest.increment(i); System.out.print(casTest.a+\u0026#34; \u0026#34;); } }).start(); new Thread(()-\u0026gt;{ for (int i = 5 ; i \u0026lt;10 ; i++) { casTest.increment(i); System.out.print(casTest.a+\u0026#34; \u0026#34;); } }).start(); } private void increment(int x){ while (true){ try { long fieldOffset = unsafe.objectFieldOffset(CasTest.class.getDeclaredField(\u0026#34;a\u0026#34;)); if (unsafe.compareAndSwapInt(this,fieldOffset,x-1,x)) break; } catch (NoSuchFieldException e) { e.printStackTrace(); } } } //结果 1 2 3 4 5 6 7 8 9 使用两个线程去修改int型属性a的值,并且只有在a的值等于传入的参数x减一时,才会将a的值变为x,也就是实现对a的加一的操作 线程调度(多线程问题) # //Unsafe类提供的相关方法 //取消阻塞线程 public native void unpark(Object thread); //阻塞线程 public native void park(boolean isAbsolute, long time); //获得对象锁(可重入锁) @Deprecated public native void monitorEnter(Object o); //释放对象锁 @Deprecated public native void monitorExit(Object o); //尝试获取对象锁 @Deprecated public native boolean tryMonitorEnter(Object o); 方法 park、unpark 即可实现线程的挂起与恢复,将一个线程进行挂起是通过 park 方法实现的,调用 park 方法后,线程将一直阻塞直到超时或者中断等条件出现;unpark 可以终止一个挂起的线程,使其恢复正常。\n此外,Unsafe 源码中monitor相关的三个方法已经被标记为deprecated,不建议被使用:\n//获得对象锁 @Deprecated public native void monitorEnter(Object var1); //释放对象锁 @Deprecated public native void monitorExit(Object var1); //尝试获得对象锁 @Deprecated public native boolean tryMonitorEnter(Object var1); monitorEnter方法用于获得对象锁,monitorExit用于释放对象锁,如果对一个没有被monitorEnter加锁的对象执行此方法,会抛出IllegalMonitorStateException异常。tryMonitorEnter方法尝试获取对象锁,如果成功则返回true,反之返回false。\n典型操作\nJava 锁和同步器框架的核心类 AbstractQueuedSynchronizer (AQS),就是通过调用**LockSupport.park()和LockSupport.unpark()实现线程的阻塞和唤醒**的,而 LockSupport 的 park 、unpark 方法实际是调用 Unsafe 的 park 、unpark 方式实现的。\npublic static void park(Object blocker) { Thread t = Thread.currentThread(); setBlocker(t, blocker); UNSAFE.park(false, 0L); setBlocker(t, null); } public static void unpark(Thread thread) { if (thread != null) UNSAFE.unpark(thread); } LockSupport 的park方法调用了 Unsafe 的park方法来阻塞当前线程,此方法将线程阻塞后就不会继续往后执行,直到有其他线程调用unpark方法唤醒当前线程。下面的例子对 Unsafe 的这两个方法进行测试:\npublic static void main(String[] args) { Thread mainThread = Thread.currentThread(); new Thread(()-\u0026gt;{ try { TimeUnit.SECONDS.sleep(5); //5s后唤醒main线程 System.out.println(\u0026#34;subThread try to unpark mainThread\u0026#34;); unsafe.unpark(mainThread); } catch (InterruptedException e) { e.printStackTrace(); } }).start(); System.out.println(\u0026#34;park main mainThread\u0026#34;); unsafe.park(false,0L); System.out.println(\u0026#34;unpark mainThread success\u0026#34;); } //输出 park main mainThread subThread try to unpark mainThread unpark mainThread success 流程图如下:\nClass操作 # Unsafe对class的相关操作主要包括类加载和静态变量的操作方法\n静态属性读取相关的方法\n//获取静态属性的偏移量 public native long staticFieldOffset(Field f); //获取静态属性的对象指针---另一说,获取静态变量所属的类在方法区的首地址 public native Object staticFieldBase(Field f); //判断类是否需要实例化(用于获取类的静态属性前进行检测) public native boolean shouldBeInitialized(Class\u0026lt;?\u0026gt; c); 测试\n@Data public class User { public static String name=\u0026#34;Hydra\u0026#34;; int age; } private void staticTest() throws Exception { User user=new User(); System.out.println(unsafe.shouldBeInitialized(User.class)); Field sexField = User.class.getDeclaredField(\u0026#34;name\u0026#34;);//获取到静态属性 long fieldOffset = unsafe.staticFieldOffset(sexField);//获取静态属性的偏移量 Object fieldBase = unsafe.staticFieldBase(sexField); //获取静态属性对应的是哪个类 Object object = unsafe.getObject(fieldBase, fieldOffset);//获取到静态属性 对象 System.out.println(object); } /** 运行结果:falseHydra */ 在 Unsafe 的对象操作中,我们学习了通过objectFieldOffset方法获取对象属性偏移量并基于它对变量的值进行存取,但是它不适用于类中的静态属性,这时候就需要使用staticFieldOffset方法。在上面的代码中,只有在获取Field对象的过程中依赖到了Class,而获取静态变量的属性时不再依赖于Class。\n在上面的代码中首先创建一个User对象,这是因为如果一个类没有被实例化,那么它的静态属性也不会被初始化,最后获取的字段属性将是null(如果直接使用User.name ,那么是会导致类被初始化的)。所以在获取静态属性前,需要调用shouldBeInitialized方法,判断在获取前是否需要初始化这个类。如果删除创建 User 对象的语句,运行结果会变为:truenull\ndefineClass方法允许程序在运行时动态创建一个类\npublic native Class\u0026lt;?\u0026gt; defineClass(String name, byte[] b, int off, int len, ClassLoader loader,ProtectionDomain protectionDomain); 利用class类字节码文件,动态创建一个类\nprivate static void defineTest() { String fileName=\u0026#34;F:\\\\workspace\\\\unsafe-test\\\\target\\\\classes\\\\com\\\\cn\\\\model\\\\User.class\u0026#34;; File file = new File(fileName); try(FileInputStream fis = new FileInputStream(file)) { byte[] content=new byte[(int)file.length()]; fis.read(content); Class clazz = unsafe.defineClass(null, content, 0, content.length, null, null); Object o = clazz.newInstance(); Object age = clazz.getMethod(\u0026#34;getAge\u0026#34;).invoke(o, null); System.out.println(age); } catch (Exception e) { e.printStackTrace(); } } 系统信息 # //获取系统相关信息 //返回系统指针的大小。返回值为4(32位系统)或 8(64位系统)。 【应该是字节数】 public native int addressSize(); //内存页的大小,此值为2的幂次方。 public native int pageSize(); "},{"id":347,"href":"/zh/docs/technology/Review/java_guide/java/Basic/ly0008lybig_decimal/","title":"big_decimal","section":"基础","content":" 转载自https://github.com/Snailclimb/JavaGuide (添加小部分笔记)感谢作者!\n精度的丢失 # float a = 2.0f - 1.9f; float b = 1.8f - 1.7f; System.out.println(a);// 0.100000024 System.out.println(b);// 0.099999905 System.out.println(a == b);// false 为什么会有精度丢失的风险\n这个和计算机保存浮点数的机制有很大关系。我们知道计算机是二进制的,而且计算机在表示一个数字时,宽度是有限的,无限循环的小数存储在计算机时,只能被截断,所以就会导致小数精度发生损失的情况。这也就是解释了为什么浮点数没有办法用二进制精确表示\n使用BigDecimal来定义浮点数的值,然后再进行浮点数的运算操作即可\nBigDecimal常见方法 # 我们在使用 BigDecimal 时,为了防止精度丢失,推荐使用它的BigDecimal(String val)构造方法或者 BigDecimal.valueOf(double val) 静态方法来创建对象\n加减乘除\nBigDecimal a = new BigDecimal(\u0026#34;1.0\u0026#34;); BigDecimal b = new BigDecimal(\u0026#34;0.9\u0026#34;); System.out.println(a.add(b));// 1.9 System.out.println(a.subtract(b));// 0.1 System.out.println(a.multiply(b));// 0.90 System.out.println(a.divide(b));// 无法除尽,抛出 ArithmeticException 异常 System.out.println(a.divide(b, 2, RoundingMode.HALF_UP));// 1.11 使用divide方法的时候,尽量使用3个参数版本(roundingMode.oldMode)\n保留规则\npublic enum RoundingMode { // 2.5 -\u0026gt; 3 , 1.6 -\u0026gt; 2 // -1.6 -\u0026gt; -2 , -2.5 -\u0026gt; -3 UP(BigDecimal.ROUND_UP), //数轴上靠近哪个取哪个 // 2.5 -\u0026gt; 2 , 1.6 -\u0026gt; 1 // -1.6 -\u0026gt; -1 , -2.5 -\u0026gt; -2 DOWN(BigDecimal.ROUND_DOWN), //数轴上离哪个远取哪个 // 2.5 -\u0026gt; 3 , 1.6 -\u0026gt; 2 // -1.6 -\u0026gt; -1 , -2.5 -\u0026gt; -2 CEILING(BigDecimal.ROUND_CEILING), // 2.5 -\u0026gt; 2 , 1.6 -\u0026gt; 1 // -1.6 -\u0026gt; -2 , -2.5 -\u0026gt; -3 FLOOR(BigDecimal.ROUND_FLOOR), ////数轴上 正数:远离哪个取哪个 负数:靠近哪个取哪个 // 2.5 -\u0026gt; 3 , 1.6 -\u0026gt; 2 // -1.6 -\u0026gt; -2 , -2.5 -\u0026gt; -3 HALF_UP(BigDecimal.ROUND_HALF_UP),// 数轴上 正数:靠近哪个取哪个 负数:远离哪个取哪个 //...... } 大小比较\n使用compareTo\nBigDecimal a = new BigDecimal(\u0026#34;1.0\u0026#34;); BigDecimal b = new BigDecimal(\u0026#34;0.9\u0026#34;); System.out.println(a.compareTo(b));// 1 保留几位小数\nBigDecimal m = new BigDecimal(\u0026#34;1.255433\u0026#34;); BigDecimal n = m.setScale(3,RoundingMode.HALF_DOWN); System.out.println(n);// 1.255 使用compareTo替换equals方法,equals不止会比较直,还会比较精度 BigDecimal工具类分享 (用来操作double算术)\nimport java.math.BigDecimal; import java.math.RoundingMode; /** * 简化BigDecimal计算的小工具类 */ public class BigDecimalUtil { /** * 默认除法运算精度 */ private static final int DEF_DIV_SCALE = 10; private BigDecimalUtil() { } /** * 提供精确的加法运算。 * * @param v1 被加数 * @param v2 加数 * @return 两个参数的和 */ public static double add(double v1, double v2) { BigDecimal b1 = BigDecimal.valueOf(v1); BigDecimal b2 = BigDecimal.valueOf(v2); return b1.add(b2).doubleValue(); } /** * 提供精确的减法运算。 * * @param v1 被减数 * @param v2 减数 * @return 两个参数的差 */ public static double subtract(double v1, double v2) { BigDecimal b1 = BigDecimal.valueOf(v1); BigDecimal b2 = BigDecimal.valueOf(v2); return b1.subtract(b2).doubleValue(); } /** * 提供精确的乘法运算。 * * @param v1 被乘数 * @param v2 乘数 * @return 两个参数的积 */ public static double multiply(double v1, double v2) { BigDecimal b1 = BigDecimal.valueOf(v1); BigDecimal b2 = BigDecimal.valueOf(v2); return b1.multiply(b2).doubleValue(); } /** * 提供(相对)精确的除法运算,当发生除不尽的情况时,精确到 * 小数点以后10位,以后的数字四舍五入。 * * @param v1 被除数 * @param v2 除数 * @return 两个参数的商 */ public static double divide(double v1, double v2) { return divide(v1, v2, DEF_DIV_SCALE); } /** * 提供(相对)精确的除法运算。当发生除不尽的情况时,由scale参数指 * 定精度,以后的数字四舍五入。 * * @param v1 被除数 * @param v2 除数 * @param scale 表示表示需要精确到小数点以后几位。 * @return 两个参数的商 */ public static double divide(double v1, double v2, int scale) { if (scale \u0026lt; 0) { throw new IllegalArgumentException( \u0026#34;The scale must be a positive integer or zero\u0026#34;); } BigDecimal b1 = BigDecimal.valueOf(v1); BigDecimal b2 = BigDecimal.valueOf(v2); return b1.divide(b2, scale, RoundingMode.HALF_UP).doubleValue(); } /** * 提供精确的小数位四舍五入处理。 * * @param v 需要四舍五入的数字 * @param scale 小数点后保留几位 * @return 四舍五入后的结果 */ public static double round(double v, int scale) { if (scale \u0026lt; 0) { throw new IllegalArgumentException( \u0026#34;The scale must be a positive integer or zero\u0026#34;); } BigDecimal b = BigDecimal.valueOf(v); BigDecimal one = new BigDecimal(\u0026#34;1\u0026#34;); return b.divide(one, scale, RoundingMode.HALF_UP).doubleValue(); } /** * 提供精确的类型转换(Float) * * @param v 需要被转换的数字 * @return 返回转换结果 */ public static float convertToFloat(double v) { BigDecimal b = new BigDecimal(v); return b.floatValue(); } /** * 提供精确的类型转换(Int)不进行四舍五入 * * @param v 需要被转换的数字 * @return 返回转换结果 */ public static int convertsToInt(double v) { BigDecimal b = new BigDecimal(v); return b.intValue(); } /** * 提供精确的类型转换(Long) * * @param v 需要被转换的数字 * @return 返回转换结果 */ public static long convertsToLong(double v) { BigDecimal b = new BigDecimal(v); return b.longValue(); } /** * 返回两个数中大的一个值 * * @param v1 需要被对比的第一个数 * @param v2 需要被对比的第二个数 * @return 返回两个数中大的一个值 */ public static double returnMax(double v1, double v2) { BigDecimal b1 = new BigDecimal(v1); BigDecimal b2 = new BigDecimal(v2); return b1.max(b2).doubleValue(); } /** * 返回两个数中小的一个值 * * @param v1 需要被对比的第一个数 * @param v2 需要被对比的第二个数 * @return 返回两个数中小的一个值 */ public static double returnMin(double v1, double v2) { BigDecimal b1 = new BigDecimal(v1); BigDecimal b2 = new BigDecimal(v2); return b1.min(b2).doubleValue(); } /** * 精确对比两个数字 * * @param v1 需要被对比的第一个数 * @param v2 需要被对比的第二个数 * @return 如果两个数一样则返回0,如果第一个数比第二个数大则返回1,反之返回-1 */ public static int compareTo(double v1, double v2) { BigDecimal b1 = BigDecimal.valueOf(v1); BigDecimal b2 = BigDecimal.valueOf(v2); return b1.compareTo(b2); } } "},{"id":348,"href":"/zh/docs/technology/Review/java_guide/java/Basic/ly0007lyproxy_pattern/","title":"Java代理模式","section":"基础","content":" 转载自https://github.com/Snailclimb/JavaGuide (添加小部分笔记)感谢作者!\n代理模式 # 使用代理对象来代替对真实对象的访问,就可以在不修改原目标对象的前提下提供额外的功能操作,扩展目标对象的功能,即在目标对象的某个方法执行前后可以增加一些自定义的操作\n静态代理 # 静态代理中,我们对目标对象的每个方法的增强都是手动完成的(*后面会具体演示代码*),非常不灵活(*比如接口一旦新增加方法,目标对象和代理对象都要进行修改*)且麻烦(*需要对每个目标类都单独写一个代理类*)。 实际应用场景非常非常少,日常开发几乎看不到使用静态代理的场景。\n上面我们是从实现和应用角度来说的静态代理,从 JVM 层面来说, 静态代理在编译时就将接口、实现类、代理类这些都变成了一个个实际的 class 文件。\n定义一个接口及其实现类; 创建一个代理类同样实现这个接口 将目标对象注入进代理类,然后在代理类的对应方法调用目标类中的对应方法。这样的话,我们就可以通过代理类屏蔽对目标对象的访问,并且可以在目标方法执行前后做一些自己想做的事情。 代码:\n//定义发送短信的接口 public interface SmsService { String send(String message); } //实现发送短信的接口 public class SmsServiceImpl implements SmsService { public String send(String message) { System.out.println(\u0026#34;send message:\u0026#34; + message); return message; } } //创建代理类并同样实现发送短信的接口 public class SmsProxy implements SmsService { private final SmsService smsService; public SmsProxy(SmsService smsService) { this.smsService = smsService; } @Override public String send(String message) { //调用方法之前,我们可以添加自己的操作 System.out.println(\u0026#34;before method send()\u0026#34;); smsService.send(message); //调用方法之后,我们同样可以添加自己的操作 System.out.println(\u0026#34;after method send()\u0026#34;); return null; } } //实际使用 public class Main { public static void main(String[] args) { SmsService smsService = new SmsServiceImpl(); SmsProxy smsProxy = new SmsProxy(smsService); smsProxy.send(\u0026#34;java\u0026#34;); } } //打印结果 before method send() send message:java after method send() 动态代理 # 从JVM角度来说,动态代理是在运行时动态生成类字节码,并加载到JVM中的。 SpringAOP和RPC等框架都实现了动态代理\nJDK动态代理 # //定义并发送短信的接口 public interface SmsService { String send(String message); } public class SmsServiceImpl implements SmsService { public String send(String message) { System.out.println(\u0026#34;send message:\u0026#34; + message); return message; } } //JDK动态代理类 import java.lang.reflect.InvocationHandler; import java.lang.reflect.InvocationTargetException; import java.lang.reflect.Method; /** * @author shuang.kou * @createTime 2020年05月11日 11:23:00 */ public class DebugInvocationHandler implements InvocationHandler { /** * 代理类中的真实对象 */ private final Object target; public DebugInvocationHandler(Object target) { this.target = target; }、 public Object invoke(Object proxy, Method method, Object[] args) throws InvocationTargetException, IllegalAccessException { //调用方法之前,我们可以添加自己的操作 System.out.println(\u0026#34;before method \u0026#34; + method.getName()); Object result = method.invoke(target, args); //调用方法之后,我们同样可以添加自己的操作 System.out.println(\u0026#34;after method \u0026#34; + method.getName()); return result; } } 当我们的动态代理对象调用原方法时,实际上调用的invoke(),然后invoke代替我们调用了被代理对象的原生方法\n//工厂类及实际使用 public class JdkProxyFactory { public static Object getProxy(Object target) { return Proxy.newProxyInstance( target.getClass().getClassLoader(), // 目标类的类加载器 target.getClass().getInterfaces(), // 代理需要实现的接口,可指定多个 new DebugInvocationHandler(target) // 代理对象对应的自定义 InvocationHandler ); } } //实际使用 SmsService smsService = (SmsService) JdkProxyFactory.getProxy(new SmsServiceImpl()); smsService.send(\u0026#34;java\u0026#34;); //输出 before method send send message:java after method send CGLIB动态代理机制 # JDK动态代理问题:只能代理实现了接口的类Spring 的AOP中,如果使用了接口,则使用JDK动态代理;否则采用CGLB\n继承\n核心是Enhancer类及MethodInterceptor接口\npublic interface MethodInterceptor extends Callback{ // 拦截被代理类中的方法 public Object intercept(Object obj, java.lang.reflect.Method method, Object[] args,MethodProxy proxy) throws Throwable; } 对象,被拦截方法,参数,调用原始方法\n实例\n//定义一个类,及方法拦截器 package github.javaguide.dynamicProxy.cglibDynamicProxy; public class AliSmsSer pvice { public String send(String message) { System.out.println(\u0026#34;send message:\u0026#34; + message); return message; } } //MethodInterceptor (方法拦截器) import net.sf.cglib.proxy.MethodInterceptor; import net.sf.cglib.proxy.MethodProxy; import java.lang.reflect.Method; /** * 自定义MethodInterceptor */ public class DebugMethodInterceptor implements MethodInterceptor { /** * @param o 代理对象(增强的对象) * @param method 被拦截的方法(需要增强的方法) * @param args 方法入参 * @param methodProxy 用于调用原始方法 */ @Override public Object intercept(Object o, Method method, Object[] args, MethodProxy methodProxy) throws Throwable { //调用方法之前,我们可以添加自己的操作 System.out.println(\u0026#34;before method \u0026#34; + method.getName()); Object object = methodProxy.invokeSuper(o, args); //调用方法之后,我们同样可以添加自己的操作 System.out.println(\u0026#34;after method \u0026#34; + method.getName()); return object; } } // 获取代理类 import net.sf.cglib.proxy.Enhancer; public class CglibProxyFactory { public static Object getProxy(Class\u0026lt;?\u0026gt; clazz) { // 创建动态代理增强类 Enhancer enhancer = new Enhancer(); // 设置类加载器 enhancer.setClassLoader(clazz.getClassLoader()); // 设置被代理类 enhancer.setSuperclass(clazz); // 设置方法拦截器 enhancer.setCallback(new DebugMethodInterceptor()); // 创建代理类 return enhancer.create(); } } //实际使用 AliSmsService aliSmsService = (AliSmsService) CglibProxyFactory.getProxy(AliSmsService.class); aliSmsService.send(\u0026#34;java\u0026#34;); 对比 # 灵活性:动态代理更为灵活,且不需要实现接口,可以直接代理实现类,并且不需要针对每个对象都创建代理类;一旦添加方法,动态代理类不需要修改; JVM层面:静态代理在编译时就将接口、实现类变成实际的class文件,而动态代理是在运行时生成动态类字节码,并加载到JVM中 "},{"id":349,"href":"/zh/docs/technology/Review/java_guide/java/Basic/ly0006lyreflex/","title":"java-reflex","section":"基础","content":" 转载自https://github.com/Snailclimb/JavaGuide (添加小部分笔记)感谢作者!\n何为反射 # 赋予了我们在运行时分析类以及执行类中方法的能力;运行中获取任意一个类的所有属性和方法,以及调用这些方法和属性\n应用场景 # Spring/Spring Boot 、MyBatis等框架都用了大量反射机制,以下为\nJDK动态代理\n接口及实现类\npackage proxy; public interface Car { public void run(); } //实现类 package proxy; public class CarImpl implements Car{ public void run() { System.out.println(\u0026#34;car running\u0026#34;); } } 代理类 及main方法使用 [ˌɪnvəˈkeɪʃn] 祈祷\npackage proxy; import java.lang.reflect.InvocationHandler; import java.lang.reflect.Method; //JDK动态代理代理类 public class CarHandler implements InvocationHandler{ //真实类的对象 private Object car; //构造方法赋值给真实的类 public CarHandler(Object obj){ this.car = obj; } //代理类执行方法时,调用的是这个方法 public Object invoke(Object proxy, Method method, Object[] args) throws Throwable { System.out.println(\u0026#34;before\u0026#34;); Object res = method.invoke(car, args); System.out.println(\u0026#34;after\u0026#34;); return res; } } //main方法使用 package proxy; import java.lang.reflect.Proxy; public class main { public static void main(String[] args) { CarImpl carImpl = new CarImpl(); CarHandler carHandler = new CarHandler(carImpl); Car proxy = (Car)Proxy.newProxyInstance( main.class.getClassLoader(), //第一个参数,获取ClassLoader carImpl.getClass().getInterfaces(), //第二个参数,获取被代理类的接口 carHandler);//第三个参数,一个InvocationHandler对象,表示的是当我这个动态代理对象在调用方法的时候,会关联到哪一个InvocationHandler对象上 proxy.run(); } } //输出 before car running after Cglib动态代理(没有实现接口的Car\n类\npackage proxy; public class CarNoInterface { public void run() { System.out.println(\u0026#34;car running\u0026#34;); } } cglib代理类 [ˌɪntəˈseptə(r)] interceptor 拦截\npackage proxy; import java.lang.reflect.Method; import org.springframework.cglib.proxy.Enhancer; import org.springframework.cglib.proxy.MethodInterceptor; import org.springframework.cglib.proxy.MethodProxy; public class CglibProxy implements MethodInterceptor{ private Object car; /** * 创建代理对象 * * @param target * @return */ public Object getInstance(Object object) { this.car = object; Enhancer enhancer = new Enhancer(); enhancer.setSuperclass(this.car.getClass()); // 回调方法 enhancer.setCallback(this); // 创建代理对象 return enhancer.create(); } @Override public Object intercept(Object obj, Method method, Object[] args,MethodProxy proxy) throws Throwable { System.out.println(\u0026#34;事物开始\u0026#34;); proxy.invokeSuper(obj, args); System.out.println(\u0026#34;事物结束\u0026#34;); return null; } } 使用\npackage proxy; import java.lang.reflect.Proxy; public class main { public static void main(String[] args) { CglibProxy cglibProxy = new CglibProxy(); CarNoInterface carNoInterface = (CarNoInterface)cglibProxy.getInstance(new CarNoInterface()); carNoInterface.run(); } } //输出 事物开始 car running 事物结束 我们可以基于反射分析类,然后获取到类/属性/方法/方法参数上的注解,之后做进一步的处理\n反射机制的优缺点\n优点\n让代码更加灵活 确定,增加安全问题,可以无视泛型参数的安全检查(泛型参数的安全检查发生在编译时,且性能较差) 反射实战\n获取Class对象的几种方式\nClass alunbarClass = TargetObject.class;//第一种 Class alunbarClass1 = Class.forName(\u0026#34;cn.javaguide.TargetObject\u0026#34;);//第二种 TargetObject o = new TargetObject(); Class alunbarClass2 = o.getClass(); //第三种 ClassLoader.getSystemClassLoader().loadClass(\u0026#34;cn.javaguide.TargetObject\u0026#34;); //第4种,通过类加载器获取Class对象不会进行初始化,意味着不进行包括初始化等一系列操作,静态代码块和静态对象不会得到执行 反射的基本操作 例子:\npackage cn.javaguide; public class TargetObject { private String value; public TargetObject() { value = \u0026#34;JavaGuide\u0026#34;; } public void publicMethod(String s) { System.out.println(\u0026#34;I love \u0026#34; + s); } private void privateMethod() { System.out.println(\u0026#34;value is \u0026#34; + value); } } 通过反射操作这个类的方法以及参数\npackage cn.javaguide; import java.lang.reflect.Field; import java.lang.reflect.InvocationTargetException; import java.lang.reflect.Method; public class Main { public static void main(String[] args) throws ClassNotFoundException, NoSuchMethodException, IllegalAccessException, InstantiationException, InvocationTargetException, NoSuchFieldException { /** * 获取 TargetObject 类的 Class 对象并且创建 TargetObject 类实例 */ Class\u0026lt;?\u0026gt; targetClass = Class.forName(\u0026#34;cn.javaguide.TargetObject\u0026#34;); TargetObject targetObject = (TargetObject) targetClass.newInstance(); /** (Car)Proxy.newProxyInstance( main.class.getClassLoader(), //第一个参数,获取ClassLoader carImpl.getClass().getInterfaces(), //第二个参数,获取被代理类的接口 carHandler); **/ /** * 获取 TargetObject 类中定义的所有方法 */ Method[] methods = targetClass.getDeclaredMethods(); for (Method method : methods) { System.out.println(method.getName()); } /** * 获取指定方法并调用 */ Method publicMethod = targetClass.getDeclaredMethod(\u0026#34;publicMethod\u0026#34;, String.class); publicMethod.invoke(targetObject, \u0026#34;JavaGuide\u0026#34;); /** * 获取指定参数并对参数进行修改 */ Field field = targetClass.getDeclaredField(\u0026#34;value\u0026#34;); //为了对类中的参数进行修改我们取消安全检查 field.setAccessible(true); field.set(targetObject, \u0026#34;JavaGuide\u0026#34;); /** * 调用 private 方法 */ Method privateMethod = targetClass.getDeclaredMethod(\u0026#34;privateMethod\u0026#34;); //为了调用private方法我们取消安全检查 privateMethod.setAccessible(true); privateMethod.invoke(targetObject); } } //输出 publicMethod privateMethod I love JavaGuide value is JavaGuide\n- "},{"id":350,"href":"/zh/docs/technology/Review/java_guide/java/Basic/ly0005lyserialize/","title":"Java序列化详解","section":"基础","content":" 转载自https://github.com/Snailclimb/JavaGuide (添加小部分笔记)感谢作者!\n什么是序列化?什么是反序列化 # 当需要持久化Java对象,比如将Java对象保存在文件中、或者在网络中传输Java对象,这些场景都需要用到序列化\n即:\n序列化:将数据结构/对象,转换成二进制字节流 反序列化:将在序列化过程中所生成的二进制字节流,转换成数据结构或者对象的过程 对于Java,序列化的是对象(Object),也就是实例化后的类(Class)\n序列化的目的,是通过网络传输对象,或者说是将对象存储到文件系统、数据库、内存中,如图: 实际场景 # 对象在进行网络传输(比如远程方法调用 RPC 的时候)之前需要先被序列化,接收到序列化的对象之后需要再进行反序列化; 将对象存储到文件中的时候需要进行序列化,将对象从文件中读取出来需要进行反序列化。 将对象存储到缓存数据库(如 Redis)时需要用到序列化,将对象从缓存数据库中读取出来需要反序列化 序列化协议对于TCP/IP 4层模型的哪一层 # 4层包括,网络接口层,网络层,传输层,应用层 如下图所示:\nOSI七层协议模型中,表示层就是对应用层的用户数据,进行处理转换成二进制流;反过来的话,就是将二进制流转换成应用层的用户数据,即序列化和反序列化,\n因为,OSI 七层协议模型中的应用层、表示层和会话层对应的都是 TCP/IP 四层模型中的应用层,所以序列化协议属于 TCP/IP 协议应用层的一部分\n常见序列化协议对比 # kryo 英音 [k\u0026rsquo;rɪəʊ] ,除了JDK自带的序列化,还有hessian、kryo、protostuff\nJDK自带的序列化,只需要实现java.io.Serializable接口即可\n@AllArgsConstructor @NoArgsConstructor @Getter @Builder @ToString public class RpcRequest implements Serializable { private static final long serialVersionUID = 1905122041950251207L; private String requestId; private String interfaceName; private String methodName; private Object[] parameters; private Class\u0026lt;?\u0026gt;[] paramTypes; private RpcMessageTypeEnum rpcMessageTypeEnum; } serialVersionUID用于版本控制,会被写入二进制序列,反序列化如果发现和当前类不一致则会抛出InvalidClassException异常。一般不使用JDK自带序列化,1 不支持跨语言调用 2 性能差,序列化之后字节数组体积过大\nKryo 由于变长存储特性并使用了字节码生成机制,拥有较高的运行速度和较小字节码体积,代码:\n/** * Kryo serialization class, Kryo serialization efficiency is very high, but only compatible with Java language * * @author shuang.kou * @createTime 2020年05月13日 19:29:00 */ @Slf4j public class KryoSerializer implements Serializer { /** * Because Kryo is not thread safe. So, use ThreadLocal to store Kryo objects */ private final ThreadLocal\u0026lt;Kryo\u0026gt; kryoThreadLocal = ThreadLocal.withInitial(() -\u0026gt; { Kryo kryo = new Kryo(); kryo.register(RpcResponse.class); kryo.register(RpcRequest.class); return kryo; }); @Override public byte[] serialize(Object obj) { try (ByteArrayOutputStream byteArrayOutputStream = new ByteArrayOutputStream(); Output output = new Output(byteArrayOutputStream)) { Kryo kryo = kryoThreadLocal.get(); // Object-\u0026gt;byte:将对象序列化为byte数组 kryo.writeObject(output, obj); kryoThreadLocal.remove(); return output.toBytes(); } catch (Exception e) { throw new SerializeException(\u0026#34;Serialization failed\u0026#34;); } } @Override public \u0026lt;T\u0026gt; T deserialize(byte[] bytes, Class\u0026lt;T\u0026gt; clazz) { try (ByteArrayInputStream byteArrayInputStream = new ByteArrayInputStream(bytes); Input input = new Input(byteArrayInputStream)) { Kryo kryo = kryoThreadLocal.get(); // byte-\u0026gt;Object:从byte数组中反序列化出对象 Object o = kryo.readObject(input, clazz); kryoThreadLocal.remove(); return clazz.cast(o); } catch (Exception e) { throw new SerializeException(\u0026#34;Deserialization failed\u0026#34;); } } } Protobuf 出自google\nProtoStuff,更为易用\nhessian,轻量级的自定义描述的二进制RPC协议,跨语言,hessian2,为阿里修改过的hessian lite,是dubbo RPC默认启用的序列化方式\n总结\n如果不需要跨语言可以考虑Kryo Protobuf,ProtoStuff,hessian支持跨语言 "},{"id":351,"href":"/zh/docs/technology/Review/java_guide/java/Basic/ly0004lypassbyvalue/","title":"为什么Java中只有值传递","section":"基础","content":" 转载自https://github.com/Snailclimb/JavaGuide (添加小部分笔记)感谢作者!\n形参\u0026amp;\u0026amp;实参\n实参(实际参数,Arguments),用于传递给函数/方法的参数,必须有确定的值\n形参(形式参数,Parameters),用于定义函数/方法,接收实参,不需要有确定的值\nString hello = \u0026#34;Hello!\u0026#34;; // hello 为实参 sayHello(hello); // str 为形参 void sayHello(String str) { System.out.println(str); } 值传递\u0026amp;\u0026amp;引用传递\n程序设计将实参传递给方法的方式分为两种,值传递:方法接收实参值的拷贝,会创建副本;引用传递:方法接受的是实参所引用的对象在堆中的地址,不会创建副本,对形参的修改将影响到实参 Java中只有值传递,原因:\n传递基本类型参数\npublic static void main(String[] args) { int num1 = 10; int num2 = 20; swap(num1, num2); System.out.println(\u0026#34;num1 = \u0026#34; + num1); System.out.println(\u0026#34;num2 = \u0026#34; + num2); } public static void swap(int a, int b) { int temp = a; a = b; b = temp; System.out.println(\u0026#34;a = \u0026#34; + a); System.out.println(\u0026#34;b = \u0026#34; + b); } //输出 a = 20 b = 10 num1 = 10 num2 = 20 传递引用类型参数 1\npublic static void main(String[] args) { int[] arr = { 1, 2, 3, 4, 5 }; System.out.println(arr[0]); //1 change(arr); System.out.println(arr[0]);//0 } public static void change(int[] array) { // 将数组的第一个元素变为0 array[0] = 0; } change方法的参数,拷贝的是arr(实参)的地址,所以array和arr指向的是同一个数组对象 传递引用类型参数2\npublic class Person { private String name; // 省略构造函数、Getter\u0026amp;Setter方法 } public static void main(String[] args) { Person xiaoZhang = new Person(\u0026#34;小张\u0026#34;); Person xiaoLi = new Person(\u0026#34;小李\u0026#34;); swap(xiaoZhang, xiaoLi); System.out.println(\u0026#34;xiaoZhang:\u0026#34; + xiaoZhang.getName()); System.out.println(\u0026#34;xiaoLi:\u0026#34; + xiaoLi.getName()); } public static void swap(Person person1, Person person2) { Person temp = person1; person1 = person2; person2 = temp; System.out.println(\u0026#34;person1:\u0026#34; + person1.getName()); System.out.println(\u0026#34;person2:\u0026#34; + person2.getName()); } //结果 person1:小李 person2:小张 xiaoZhang:小张 xiaoLi:小李 这里并不会交换xiaoZhang和xiaoLi,只会交换swap方法栈里的person1和person2\n小结 Java 中将实参传递给方法(或函数)的方式是 值传递 :\n如果参数是基本类型的话,很简单,传递的就是基本类型的字面量值的拷贝,会创建副本。 如果参数是引用类型,传递的就是实参所引用的对象在堆中地址值的拷贝,同样也会创建副本。 "},{"id":352,"href":"/zh/docs/technology/Review/java_guide/java/Basic/ly0003lyjava_guide_basic_3/","title":"javaGuide基础3","section":"基础","content":" 转载自https://github.com/Snailclimb/JavaGuide (添加小部分笔记)感谢作者!\n异常 # unchecked exceptions (运行时异常)\nchecked exceptions (非运行时异常,编译异常)\nJava异常类层次结构图 Exception和Error有什么区别\n除了RuntimeException及其子类以外,其他的Exception类及其子类都属于受检查异常\nException : 程序本身可以处理的异常(可通过catch捕获)\nChecked Exception ,受检查异常,必须处理(catch 或者 throws ,否则编译器通过不了) IOException,ClassNotFoundException,SQLException,FileNotFoundException\nUnchecked Exception , 不受检查异常 , 可以不处理\n(算数异常,类型转换异常,不合法的线程状态异常,下标超出异常,空指针异常,参数类型异常,数字格式异常,不支持操作异常) ArithmeticException,ClassCastException,IllegalThreadStateException,IndexOutOfBoundsException\nNullPointerException,IllegalArgumentException,NumberFormatException,SecurityException,UnsupportedOperationException ```illegal 英[ɪˈliːɡl] 非法的``` ```Arithmetic 英[əˈrɪθmətɪk] 算术``` Error: 程序无法处理的错误 ,不建议通过catch 捕获,已办错误发生时JVM会选择线程终止\nOutOfMemoryError (堆,Java heap space),VirtualMachineError,StackOverFlowError,AssertionError (断言),IOError\nThrowable类常用方法\nString getMessage() //简要描述 String toString() //详细 String getLocalizedMessage() //本地化信息,如果子类(Throwable的子类)没有覆盖该方法,则与gtMessage() 结果一样 void printStackTrace() //打印Throwable对象封装的异常信息 try-catch-finally如何使用 try后面必须要有catch或者finally;无论是否捕获异常,finally都会执行;当在 try 块或 catch 块中遇到 return 语句时,finally 语句块将在方法返回之前被执行。\n不要在 finally 语句块中使用 return! 当 try 语句和 finally 语句中都有 return 语句时,try 语句块中的 return 语句会被忽略。这是因为 try 语句中的 return 返回值会先被暂存在一个本地变量中,当执行到 finally 语句中的 return 之后,这个本地变量的值就变为了 finally 语句中的 return 返回值。\npublic static void main(String[] args) { System.out.println(f(2)); }\npublic static int f(int value) { try { return value * value; } finally { if (value == 2) { return 0; } } } /*\n0 */\nfinally中的代码不一定执行(如果finally之前虚拟机就已经被终止了)\n另外两种情况,程序所在的线程死亡;关闭CPU;都会导致代码不执行 使用try-with-resources代替try-catch-finally\n适用范围:任何实现java.lang.AutoCloseable或者java.io.Closeable的对象【比如InputStream、OutputStream、Scanner、PrintWriter等需要调用close()方法的资源】\n在try-with-resources中,任何catch或finally块在声明的资源关闭后运行\n例子\n//读取文本文件的内容 Scanner scanner = null; try { scanner = new Scanner(new File(\u0026#34;D://read.txt\u0026#34;)); while (scanner.hasNext()) { System.out.println(scanner.nextLine()); } } catch (FileNotFoundException e) { e.printStackTrace(); } finally { if (scanner != null) { scanner.close(); } } 改造后:\ntry (Scanner scanner = new Scanner(new File(\u0026#34;test.txt\u0026#34;))) { while (scanner.hasNext()) { System.out.println(scanner.nextLine()); } } catch (FileNotFoundException fnfe) { fnfe.printStackTrace(); } 可以使用分隔符来分割\ntry (BufferedInputStream bin = new BufferedInputStream(new FileInputStream(new File(\u0026#34;test.txt\u0026#34;))); BufferedOutputStream bout = new BufferedOutputStream(new FileOutputStream(new File(\u0026#34;out.txt\u0026#34;)))) { int b; while ((b = bin.read()) != -1) { bout.write(b); } } catch (IOException e) { e.printStackTrace(); } 需要注意的地方\n不要把异常定义为静态变量,因为这样会导致异常栈信息错乱。每次手动抛出异常,我们都需要手动 new 一个异常对象抛出。 抛出的异常信息一定要有意义。 建议抛出更加具体的异常比如字符串转换为数字格式错误的时候应该抛出NumberFormatException而不是其父类IllegalArgumentException。 使用日志打印异常之后就不要再抛出异常了(两者不要同时存在一段代码逻辑中)。 泛型 # 什么是泛型?有什么作用 Java泛型(Generics)JDK5中引入的一个新特性,使用泛型参数,可以增强代码的可读性以及稳定性\n编译器可以对泛型参数进行检测,并通过泛型参数可以指定传入的对象类型,比如ArrayList\u0026lt;Person\u0026gt; persons=new ArrayList\u0026lt;Person\u0026gt;()这行代码指明该ArrayList对象只能传入Person对象,若传入其他类型的对象则会报错\n原生List返回类型为Object,需要手动转换类型才能使用,使用泛型后编译器自动转换 泛型使用方式\n泛型类\n//此处T可以随便写为任意标识,常见的如T、E、K、V等形式的参数常用于表示泛型 //在实例化泛型类时,必须指定T的具体类型 public class Generic\u0026lt;T\u0026gt;{ private T key; public Generic(T key) { this.key = key; } public T getKey(){ return key; } } // 使用 Generic\u0026lt;Integer\u0026gt; genericInteger = new Generic\u0026lt;Integer\u0026gt;(123456); 泛型接口\npublic interface Generator\u0026lt;T\u0026gt; { public T method(); } 不指定类型使用\nclass GeneratorImpl\u0026lt;T\u0026gt; implements Generator\u0026lt;T\u0026gt;{ @Override public T method() { return null; } } 指定类型使用\nclass GeneratorImpl\u0026lt;T\u0026gt; implements Generator\u0026lt;String\u0026gt;{ @Override public String method() { return \u0026#34;hello\u0026#34;; } } 泛型方法\npublic static \u0026lt; E \u0026gt; void printArray( E[] inputArray ) { for ( E element : inputArray ){ System.out.printf( \u0026#34;%s \u0026#34;, element ); } System.out.println(); } //使用 // 创建不同类型数组: Integer, Double 和 Character Integer[] intArray = { 1, 2, 3 }; String[] stringArray = { \u0026#34;Hello\u0026#34;, \u0026#34;World\u0026#34; }; printArray( intArray ); printArray( stringArray ); 上面称为静态方法,Java中泛型只是一个占位符,必须在传递类型后才能使用,类在实例化时才能传递类型参数,而类型方法的加载优先于类的实例化,静态泛型方法是**没有办法使用类上声明的泛型(即上面的第二点中类名旁边的T)**的,只能使用自己声明的\u0026lt;E\u0026gt;\n也可以是非静态的\nclass A{ private String name; private int age; public \u0026lt;E\u0026gt; int geA(E e){ System.out.println(e.toString()); return 1; } } //使用,其中 \u0026lt;Object\u0026gt; 可以省略 a.\u0026lt;Object\u0026gt;geA(new Object()); 反射 # 反射赋予了我们在运行时分析类以及执行类中方法的能力,通过反射可以获取任意一个类的所有属性和方法\n反射增加(导致)了安全问题,可以无视泛型参数的安全检查(泛型参数的安全检查发生在编译期),不过其对于框架来说实际是影响不大的\n应用场景\n一般用于框架中,框架中大量使用了动态代理,而动态代理的实现也依赖于反射\n//JDK动态代理 interface ILy { String say(String word); } class LyImpl implements ILy{ @Override public String say(String word) { return \u0026#34;hello ,\u0026#34;+word; } } @Slf4j class MyInvocationHandler implements InvocationHandler { private final Object target; public MyInvocationHandler(Object target) { this.target = target; } @Override public Object invoke(Object proxy, Method method, Object[] args) throws Throwable { log.info(\u0026#34;调用前\u0026#34;); Object result = method.invoke(target, args); log.info(\u0026#34;结果是:\u0026#34;+result); log.info(\u0026#34;调用后\u0026#34;); return result; } } public class Test { String a; public static void main(String[] args) { LyImpl target = new LyImpl(); ILy targetProxy = (ILy)Proxy.newProxyInstance(Test.class.getClassLoader(), target.getClass().getInterfaces(), new MyInvocationHandler(target)); targetProxy.say(\u0026#34;dxs\u0026#34;); } } //cglib动态代理 @Slf4j class MyCglibProxyInterceptor implements MethodInterceptor{ @Override public Object intercept(Object o, Method method, Object[] args, MethodProxy methodProxy) throws Throwable { log.info(\u0026#34;调用前\u0026#34;); //注意,这里是invokeSuper,如果是invoke就会调用自己,导致死循环(递归) Object result = methodProxy.invokeSuper(o, args); //上面这个写法有问题,应该是 //Object result = method.invoke(o, args); log.info(\u0026#34;调用结果\u0026#34;+result); log.info(\u0026#34;调用后\u0026#34;); return result; } } public class Test { String a; public static void main(String[] args) { Enhancer enhancer=new Enhancer(); enhancer.setClassLoader(Test.class.getClassLoader()); enhancer.setSuperclass(LyImpl.class); enhancer.setCallback(new MyCglibProxyInterceptor()); //方法一(通过) ILy o = (ILy)enhancer.create(); //方法二(通过) //LyImpl o = (LyImpl)enhancer.create(); o.say(\u0026#34;lyly\u0026#34;); } } 注解也使用到了反射,比如Spring上的@Component注解。 可以基于反射分析类,然后获取到类/属性/方法/方法的参数上的注解,获取注解后,做进一步的处理\n注解 # 注解,Java5引入,用于修饰类、方法或者变量,提供某些信息供程序在编译或者运行时使用\n@Target(ElementType.METHOD) @Retention(RetentionPolicy.SOURCE) public @interface Override { } //注解本质上是一个继承了Annotation的特殊接口 public interface Override extends Annotation{ } 注解只有被解析后才会生效\n编译期直接扫描 :编译器在编译 Java 代码的时候扫描对应的注解并处理,比如某个方法使用@Override 注解,编译器在编译的时候就会检测当前的方法是否重写了父类对应的方法。 运行期通过反射处理 :像框架中自带的注解(比如 Spring 框架的 @Value 、@Component)都是通过反射来进行处理的。(创建类的时候使用反射分析类,获取注解,对创建的对象进一步处理) SPI # 介绍 Service Provider Interface ,服务提供者的接口 , 专门提供给服务提供者或者扩展框架功能的开发者去使用的一个接口 SPI 将服务接口和具体的服务实现分离开来,将服务调用方和服务实现者解耦,能够提升程序的扩展性、可维护性。修改或者替换服务实现并不需要修改调用方。 很多框架都使用了 Java 的 SPI 机制,比如:Spring 框架、数据库加载驱动、日志接口、以及 Dubbo 的扩展实现等等。 SPI扩展实现 API和SPI区别 模块之间通过接口进行通讯,在服务调用方和服务实现方(服务提供者)之间引入一个“接口” 当接口和实现,都是放在实现方的时候,这就是API\n当接口存在于调用方,由接口调用方确定接口规则,然后由不同的厂商去根据这个规则对这个接口进行实现,从而提供服务,即SPI\n举个通俗易懂的例子:公司 H 是一家科技公司,新设计了一款芯片,然后现在需要量产了,而市面上有好几家芯片制造业公司,这个时候,只要 H 公司指定好了这芯片生产的标准(定义好了接口标准),那么这些合作的芯片公司(服务提供者)就按照标准交付自家特色的芯片(提供不同方案的实现,但是给出来的结果是一样的)\n通过 SPI 机制提供了接口设计的灵活性,缺点: 需要遍历加载所有的实现类,不能做到按需加载,效率较低 当多个ServiceLoader同时load时,会有并发问题 I/O # 序列化和反序列化\n序列化:将数据结构或对象换成二级制字节流的过程 反序列化:将在序列化过程中所生成的二进制字节流转换成数据结构或者对象的过程 对于Java,序列化的都是对象(Object),即实例化后的类(Class) 维基\n序列化(serialization)在计算机科学的数据处理中,是指将数据结构或对象状态转换成可取用格式(例如存成文件,存于缓冲,或经由网络中发送),以留待后续在相同或另一台计算机环境中,能恢复原先状态的过程。依照序列化格式重新获取字节的结果时,可以利用它来产生与原始对象相同语义的副本。对于许多对象,像是使用大量引用的复杂对象,这种序列化重建的过程并不容易。面向对象中的对象序列化,并不概括之前原始对象所关系的函数。这种过程也称为对象编组(marshalling)。从一系列字节提取数据结构的反向操作,是反序列化(也称为解编组、deserialization、unmarshalling)。\n序列化的目的,通过网络传输对象,或者说是将对象存储到文件系统、数据库、内存中 被transient修饰的变量,不进行序列化:即当对象被反序列化时,被transient修饰的变量值不会被持久化和恢复 transient 英[ˈtrænziənt]\ntransient 只能修饰变量,不能修饰类和方法。 transient 修饰的变量,在反序列化后变量值将会被置成类型的默认值。例如,如果是修饰 int 类型,那么反序列后结果就是 0。 static 变量因为不属于任何对象(Object),所以无论有没有 transient 关键字修饰,均不会被序列化。 Java IO流\nIO 即 Input/Output,输入和输出。数据输入到计算机内存的过程即输入,反之输出到外部存储(比如数据库,文件,远程主机)的过程即输出。数据传输过程类似于水流,因此称为 IO 流。IO 流在 Java 中分为输入流和输出流,而根据数据的处理方式又分为字节流和字符流。\nJavaIO流的类都是从如下4个抽象类基类中派生出来的\nInputStream/Reader: 所有的输入流的基类,前者是字节输入流,后者是字符输入流。 OutputStream/Writer: 所有输出流的基类,前者是字节输出流,后者是字符输出流。 不管是文件读写还是网络发送接收,信息的最小存储单元都是字节,那为什么I/O流操作要分为字节流操作和字符流操作\n字符流由Java虚拟机将字节转换得到,过程较为耗时 如果不知道编码类型的过,使用字节流的过程中很容易出现乱码 语法糖 # syntactic 英[sɪnˈtæktɪk] 句法的\n指的是为了方便程序员开发程序而设计的一种特殊语法,对编程语言的功能并没有影响,语法糖写出来的代码往往更简单简洁且容易阅读,比如for-each,原理:基于普通的for循环和迭代器\nString[] strs = {\u0026#34;JavaGuide\u0026#34;, \u0026#34;公众号:JavaGuide\u0026#34;, \u0026#34;博客:https://javaguide.cn/\u0026#34;}; for (String s : strs) { System.out.println(s); } JVM 其实并不能识别语法糖,Java 语法糖要想被正确执行,需要先通过编译器进行解糖,也就是在程序编译阶段将其转换成 JVM 认识的基本语法。这也侧面说明,Java 中真正支持语法糖的是 Java 编译器而不是 JVM。如果你去看com.sun.tools.javac.main.JavaCompiler的源码,你会发现在compile()中有一个步骤就是调用desugar(),这个方法就是负责解语法糖的实现的。\nJava中常见的语法糖:\n泛型、自动拆装箱、变长参数、枚举、内部类、增强 for 循环、try-with-resources 语法、lambda 表达式等\n"},{"id":353,"href":"/zh/docs/technology/Review/java_guide/java/Basic/ly0002lyjava_guide_basic_2/","title":"javaGuide基础2","section":"基础","content":" 转载自https://github.com/Snailclimb/JavaGuide (添加小部分笔记)感谢作者!\n面向对象基础 # 区别\n面向过程把解决问题的过程拆成一个个方法,通过一个个方法的执行解决问题。 面向对象会先抽象出对象,然后用对象执行方法的方式解决问题。 面向对象编程 易维护、易复用、易扩展 对象实例与对象引用的不同\nnew 运算符,new 创建对象实例(对象实例在堆内存中),对象引用指向对象实例(对象引用存放在栈内存中)。\n一个对象引用可以指向 0 个或 1 个对象(一根绳子可以不系气球,也可以系一个气球);一个对象可以有 n 个引用指向它(可以用 n 条绳子系住一个气球)。\n对象的相等一般比较的是内存中存放的内容是否相等;引用相等一般比较的是他们指向的内存地址是否相等\n如果一个类没有声明构造方法,该程序能正确执行吗? 如果我们自己添加了类的构造方法(无论是否有参),Java 就不会再添加默认的无参数的构造方法了\n构造方法特点:名字与类名相同;没有返回值但不能用void声明构造函数;生成类的对象时自动执行 构造方法不能重写(override),但能重载 (overload) 面向对象三大特征\n封装\n把一个对象的状态信息(属性)隐藏在对象内部,不允许直接访问,但提供可以被外界访问的方法来操作属性\npublic class Student { private int id;//id属性私有化 private String name;//name属性私有化 //获取id的方法 public int getId() { return id; } //设置id的方法 public void setId(int id) { this.id = id; } //获取name的方法 public String getName() { return name; } //设置name的方法 public void setName(String name) { this.name = name; } } 继承\n不通类型的对象,相互之间有一定数量的共同点,同时每个对象定义了额外的特性使得他们与众不同。继承是使用已存在的类的定义作为基础建立新类的技术\n父类中的私有属性和方法子类无法访问,只是拥有 子类可以拥有自己的属性、方法,即对父类进行拓展 子类可以用自己的方式实现父类的方法(重写) 多态\n对象类型和引用类型之间具有继承(类)/实现(接口)的关系 引用类型变量发出的方法具体调用哪个类的方法,只有程序运行期间才能确定 多态不能调用“只在子类存在而父类不存在”的方法 如果子类重写了父类的方法,真正执行的是子类覆盖的方法,如果子类没有覆盖父类的方法,执行的是父类的方法 接口和抽象类有什么共同点和区别\n共同:都不能被实例化;都可以包含抽象方法;都可以有默认实现的方法。 区别 接口主要用于对类的行为进行约束;抽象类主要用于代码复用(强调所属) 类只能继承一个类,但能实现多个接口 接口中的成员只能是public static final不能被修改且具有初始值;而抽象类中的成员变量默认为default,也可以被public,protected,private修饰,可以不用赋初值 关于访问权限控制\npublic:Java 访问限制最宽的修饰符,一般称之为“公共的”。被其修饰的类、属性以及方法不仅可以跨类访问,而且可以跨包访问。 protected:介于 public 和 private 之间的一种访问修饰符,一般称之为“保护访问权限”。被其修饰的属性以及方法只能被类本身及其子类(即使子类在不同的包中),以及同包的其他类访问。外包的非子类不可以访问。 default:“默认访问权限“或“包访问权限”,即不加任何访问修饰符。只允许在同包访问,外包的所有类都不能访问。接口例外 private:Java 访问限制最窄的修饰符,一般称之为“私有的”。被其修饰的属性以及方法只能被该类的对象访问,其子类不能访问,更不允许跨包访问。 深拷贝和浅拷贝的区别?什么是引用拷贝\n浅拷贝:浅拷贝会在堆上创建新对象,但是如果原对象内部的属性是引用类型的话,浅拷贝会复制内部对象的引用地址,即拷贝对象和原对象共用一个内部对象\n深拷贝,会完全复制整个对象,包括对象内包含的内部对象\n例子\n浅拷贝\npublic class Address implements Cloneable{ private String name; // 省略构造函数、Getter\u0026amp;Setter方法 @Override public Address clone() { try { return (Address) super.clone(); } catch (CloneNotSupportedException e) { throw new AssertionError(); } } } public class Person implements Cloneable { private Address address; // 省略构造函数、Getter\u0026amp;Setter方法 @Override public Person clone() { try { Person person = (Person) super.clone(); return person; } catch (CloneNotSupportedException e) { throw new AssertionError(); } } } //------------------测试-------------------- Person person1 = new Person(new Address(\u0026#34;武汉\u0026#34;)); Person person1Copy = person1.clone(); // true System.out.println(person1.getAddress() == person1Copy.getAddress()); 深拷贝\n//修改了Person类的clone()方法进行修改 @Override public Person clone() { try { Person person = (Person) super.clone(); person.setAddress(person.getAddress().clone()); return person; } catch (CloneNotSupportedException e) { throw new AssertionError(); } } //--------------测试------- Person person1 = new Person(new Address(\u0026#34;武汉\u0026#34;)); Person person1Copy = person1.clone(); // false System.out.println(person1.getAddress() == person1Copy.getAddress()); 引用拷贝,即两个不同的引用指向同一个对象\n如图\nJava常见类 # Object # 常见方法\n/** * native 方法,用于返回当前运行时对象的 Class 对象,使用了 final 关键字修饰,故不允许子类重写。 */ public final native Class\u0026lt;?\u0026gt; getClass() /** * native 方法,用于返回对象的哈希码,主要使用在哈希表中,比如 JDK 中的HashMap。 */ public native int hashCode() /** * 用于比较 2 个对象的内存地址是否相等,String 类对该方法进行了重写以用于比较字符串的值是否相等。 */ public boolean equals(Object obj) /** * naitive 方法,用于创建并返回当前对象的一份拷贝。 */ protected native Object clone() throws CloneNotSupportedException /** * 返回类的名字实例的哈希码的 16 进制的字符串。建议 Object 所有的子类都重写这个方法。 */ public String toString() /** * native 方法,并且不能重写。唤醒一个在此对象监视器上等待的线程(监视器相当于就是锁的概念)。如果有多个线程在等待只会任意唤醒一个。 */ public final native void notify() /** * native 方法,并且不能重写。跟 notify 一样,唯一的区别就是会唤醒在此对象监视器上等待的所有线程,而不是一个线程。 */ public final native void notifyAll() /** * native方法,并且不能重写。暂停线程的执行。注意:sleep 方法没有释放锁,而 wait 方法释放了锁 ,timeout 是等待时间。 */ public final native void wait(long timeout) throws InterruptedException /** * 多了 nanos 参数,这个参数表示额外时间(以毫微秒为单位,范围是 0-999999)。 所以超时的时间还需要加上 nanos 毫秒。。 */ public final void wait(long timeout, int nanos) throws InterruptedException /** * 跟之前的2个wait方法一样,只不过该方法一直等待,没有超时时间这个概念 */ public final void wait() throws InterruptedException /** * 实例被垃圾回收器回收的时候触发的操作 */ protected void finalize() throws Throwable { } == 和 equals() 区别\n对于基本类型来说,== 比较的是值 对于引用类型,== 比较的是对象的内存地址 Java是值传递,所以本质上比较的都是值,只是引用类型变量存的值是对象地址 equals不能用于判断基本数据类型的变量,且只存在于Object类中,而Object类是所有类的直接或间接父类\nequals默认实现:\npublic boolean equals(Object obj) { return (this == obj); } 如果类没有重写该方法,则如上\n如果重写了,则一般都是重写equals方法来比较对象中的属性是否相等\n关于String 和 new String 的区别: String a = \u0026ldquo;xxx\u0026rdquo; 始终返回的是常量池中的引用;而new String 始终返回的是堆中的引用\n对于String a = \u0026ldquo;xxx\u0026rdquo; ,先到常量池中查找是否存在值为\u0026quot;xxx\u0026quot;的字符串,如果存在,直接将常量池中该值对应的引用返回,如果不存在,则在常量池中创建该对象,并返回引用。\n对于new String(\u0026ldquo;xxx\u0026rdquo;),先到常量池中查找是否存在值为\u0026quot;xxx\u0026quot;的字符串,如果存在,则直接在堆中创建对象,并返回堆中的索引;如果不存在,则先在常量池中创建对象(值为xxx),然后再在堆中创建对象,并返回堆中该对象的引用地址\n来自 https://blog.csdn.net/weixin_44844089/article/details/103648448\n例子:\nString a = new String(\u0026#34;ab\u0026#34;); // a 为一个引用 String b = new String(\u0026#34;ab\u0026#34;); // b为另一个引用,对象的内容一样 String aa = \u0026#34;ab\u0026#34;; // 放在常量池中 String bb = \u0026#34;ab\u0026#34;; // 从常量池中查找 System.out.println(aa == bb);// true System.out.println(a == b);// false System.out.println(a.equals(b));// true System.out.println(42 == 42.0);// true String 类重写了equals()方法\npublic boolean equals(Object anObject) { if (this == anObject) { return true; } if (anObject instanceof String) { String anotherString = (String)anObject; int n = value.length; if (n == anotherString.value.length) { char v1[] = value; char v2[] = anotherString.value; int i = 0; while (n-- != 0) { if (v1[i] != v2[i]) return false; i++; } return true; } } return false; } hashCode()有什么用\nhashCode()的作用是获取哈希码(int整数),也称为散列码,作用是确定该对象在哈希表中的索引位置。函数定义在Object类中,且为本地方法,通常用来将对象的内存地址转换为整数之后返回;散列表存储的是键值对(key-value),根据“键”快速检索出“值”,其中利用了散列码\n为什么需要hashCode\n当你把对象加入 HashSet 时,HashSet 会先计算对象的 hashCode 值来判断对象加入的位置,同时也会与其他已经加入的对象的 hashCode 值作比较,如果没有相符的 hashCode,HashSet 会假设对象没有重复出现。但是如果发现有相同 hashCode 值的对象,这时会调用 equals() 方法来检查 hashCode 相等的对象是否真的相同。如果两者相同,HashSet 就不会让其加入操作成功。如果不同的话,就会重新散列到其他位置【注意,我觉得这里应该是使用拉链法,说成散列到其他位置貌似有点不对】。这样我们就大大减少了 equals 的次数,相应就大大提高了执行速度。\nhashCode()和equals()都用于比较两个对象是否相等,为什么要同时提供两个方法(因为在一些容器中,如HashMap、HashSet中,判断元素是否在容器中效率更高)\n两个对象的hashCode值相等并不代表两个对象就相等 因为hashCode所使用的哈希算法也许会让多个对象传回相同哈希值,取决于哈希算法 总结\n如果两个对象的hashCode 值相等,那这两个对象不一定相等(哈希碰撞)。 如果两个对象的hashCode 值相等并且equals()方法也返回 true,我们才认为这两个对象相等。 如果两个对象的**hashCode 值不相等**,我们就可以直接认为这两个对象不相等。 String # String、StringBuffer,StringBuilder区别 String是不可变的,StringBuffer和StringBuilder都继承自AbstractStringBuilder类,是可变的(提供了修改字符串的方法)\nString中的变量不可变,所以是线程安全的,而StringBuffer对方法加了同步锁,所以是线程安全的;而StringBuilder是线程不安全的\n三者使用建议\n操作少量的数据: 适用 String 单线程操作字符串缓冲区下操作大量数据: 适用 StringBuilder 多线程操作字符串缓冲区下操作大量数据: 适用 StringBuffer String 为什么是不可变的\n代码\npublic final class String implements java.io.Serializable, Comparable\u0026lt;String\u0026gt;, CharSequence { private final char value[]; //... } 如上,保存字符串的数组被final修饰且为私有,并且String类没有提供暴露修改该字符串的方法 String类被修饰为final修饰导致不能被继承,避免子类破坏 Java9\npublic final class String implements java.io.Serializable,Comparable\u0026lt;String\u0026gt;, CharSequence { // @Stable 注解表示变量最多被修改一次,称为“稳定的”。 @Stable private final byte[] value; } abstract class AbstractStringBuilder implements Appendable, CharSequence { byte[] value; } Java9为何String底层实现由char[] 改成了 byte[] 新版的 String 其实支持两个编码方案: Latin-1 和 UTF-16。如果字符串中包含的汉字没有超过 Latin-1 可表示范围内的字符,那就会使用 Latin-1 作为编码方案。Latin-1 编码方案下,byte 占一个字节(8 位),char 占用 2 个字节(16),byte 相较 char 节省一半的内存空间。\nJDK 官方就说了绝大部分字符串对象只包含 Latin-1 可表示的字符。 [ˈlætɪn] 字符串使用“+” 还是 Stringbuilder Java本身不支持运算符重载,但 “ + ” 和 “+=” 是专门为String重载过的运算符,Java中仅有的两个\nString str1 = \u0026#34;he\u0026#34;; String str2 = \u0026#34;llo\u0026#34;; String str3 = \u0026#34;world\u0026#34;; String str4 = str1 + str2 + str3; 对应的字节码:\n字符串对象通过“+”的字符串拼接方式,实际上是通过 StringBuilder 调用 append() 方法实现的,拼接完成之后调用 toString() 得到一个 String 对象。因此这里就会产生问题,如下代码,会产生过多的StringBuilder对象\nString[] arr = {\u0026#34;he\u0026#34;, \u0026#34;llo\u0026#34;, \u0026#34;world\u0026#34;}; String s = \u0026#34;\u0026#34;; for (int i = 0; i \u0026lt; arr.length; i++) { s += arr[i]; } System.out.println(s); 会循环创建StringBuilder对象,建议自己创建一个新的StringBuilder并使用:\nString[] arr = {\u0026#34;he\u0026#34;, \u0026#34;llo\u0026#34;, \u0026#34;world\u0026#34;}; StringBuilder s = new StringBuilder(); for (String value : arr) { s.append(value); } System.out.println(s); String#equals()和Object#equals()有何区别 String的equals被重写过,比较的是字符串的值是否相等,而Object的equals比较的是对象的内存地址\n字符串常量池\n是JVM为了提升性能和减少内存消耗针对字符串(String类)专门开辟的一块区域,主要目的是为了避免字符串的重复创建\n// 在堆中创建字符串对象”ab“ (这里也可以说是在常量池中创建对象) // 将字符串对象”ab“的引用(常量池中的饮用)保存在字符串常量池中 String aa = \u0026#34;ab\u0026#34;; // 直接返回字符串常量池中字符串对象”ab“的引用 String bb = \u0026#34;ab\u0026#34;; System.out.println(aa==bb);// true String s1 = new String(\u0026ldquo;abc\u0026rdquo;);这句话创建了几个字符串对象? # 会创建 1 或 2 个字符串对象。 如果常量池中存在值为\u0026quot;abc\u0026quot;的对象,则直接在堆中创建一个对象,并且返回该对象的引用;如果不存在,则先在常量池中创建该对象,然后再在堆中创建该对象,并且返回该对象(堆中)的引用\n下面这个解释,说明常量池存储的是引用(堆中某一块区域的)\n// 字符串常量池中已存在字符串对象“abc”的引用 String s1 = \u0026#34;abc\u0026#34;; // 下面这段代码只会在堆中创建 1 个字符串对象“abc” String s2 = new String(\u0026#34;abc\u0026#34;); intern方法的作用,是一个native方法,作用是将指定的字符串对象的引用保存在字符串常量池中\n// 在堆中创建字符串对象”Java“ // 将字符串对象”Java“的引用保存在字符串常量池中 String s1 = \u0026#34;Java\u0026#34;; // 直接返回字符串常量池中字符串对象”Java“对应的引用 String s2 = s1.intern(); // 会在堆中在单独创建一个字符串对象 String s3 = new String(\u0026#34;Java\u0026#34;); // 直接返回字符串常量池中字符串对象”Java“对应的引用 String s4 = s3.intern(); // s1 和 s2 指向的是堆中的同一个对象 System.out.println(s1 == s2); // true // s3 和 s4 指向的是堆中不同的对象 System.out.println(s3 == s4); // false // s1 和 s4 指向的是堆中的同一个对象 System.out.println(s1 == s4); //true 问题:String 类型的变量和常量做“+”运算时发生了什么\nString str1 = \u0026#34;str\u0026#34;; String str2 = \u0026#34;ing\u0026#34;; String str3 = \u0026#34;str\u0026#34; + \u0026#34;ing\u0026#34;; String str4 = str1 + str2; String str5 = \u0026#34;string\u0026#34;; System.out.println(str3 == str4);//false System.out.println(str3 == str5);//true System.out.println(str4 == str5);//false 常量折叠\n对于 String str3 = \u0026quot;str\u0026quot; + \u0026quot;ing\u0026quot;; 编译器会给你优化成 String str3 = \u0026quot;string\u0026quot;; 。\n并不是所有的常量都会进行折叠,只有编译器在程序编译期就可以确定值的常量才可以:\n基本数据类型( byte、boolean、short、char、int、float、long、double)以及字符串常量。 final 修饰的基本数据类型和字符串变量 字符串通过 “+”拼接得到的字符串、基本数据类型之间算数运算(加减乘除)、基本数据类型的位运算(\u0026laquo;、\u0026raquo;、\u0026raquo;\u0026gt; ) 引用的值在程序编译期间是无法确认的,无法对其优化\n对象引用和“+”的字符串拼接方式,实际上是通过 StringBuilder 调用 append() 方法实现的,拼接完成之后调用 toString() 得到一个 String 对象 。 如上面代码String str4 = str1 + str2; 但是如果使用了final关键字声明之后,就可以让编译器当作常量来处理\nfinal String str1 = \u0026#34;str\u0026#34;; final String str2 = \u0026#34;ing\u0026#34;; // 下面两个表达式其实是等价的 String c = \u0026#34;str\u0026#34; + \u0026#34;ing\u0026#34;;// 常量池中的对象 String d = str1 + str2; // 常量池中的对象 System.out.println(c == d);// true 但是如果编译器在运行时才能知道其确切值的话,就无法对其优化\nfinal String str1 = \u0026#34;str\u0026#34;; final String str2 = getStr(); //str2只有在运行时才能确定其值 String c = \u0026#34;str\u0026#34; + \u0026#34;ing\u0026#34;;// 常量池中的对象 String d = str1 + str2; // 在堆上创建的新的对象 System.out.println(c == d);// false public static String getStr() { return \u0026#34;ing\u0026#34;; } "},{"id":354,"href":"/zh/docs/technology/Review/java_guide/java/Basic/ly0001lyjava_guide_basic_1/","title":"javaGuide基础1","section":"基础","content":" 转载自https://github.com/Snailclimb/JavaGuide (添加小部分笔记)感谢作者!\n基础概念及常识 # Java语言特点\n面向对象(封装、继承、多态) 平台无关性(Java虚拟机) 等等 JVM并非只有一种,只要满足JVM规范,可以开发自己专属JVM\nJDK与JRE\nJDK,JavaDevelopmentKit,包含JRE,还有编译器(javac)和工具(如javadoc、jdb)。能够创建和编译程序 JRE,Java运行时环境,包括Java虚拟机、Java类库,及Java命令等。但是不能创建新程序 字节码,采用字节码的好处\nJava中,JVM可以理解的代码称为字节码(.class文件),不面向任何处理器,只面向虚拟机 Java程序从源代码到运行的过程 java代码必须先编译为字节码,之后呢,.class\u0026ndash;\u0026gt;机器码,这里JVM类加载器先加载字节码文件,然后通过解释器进行解释执行(也就是字节码需要由Java解释器来解释执行) Java解释器是JVM的一部分 编译与解释并存\n编译型:通过编译器将源代码一次性翻译成可被该平台执行的机器码,执行快、开发效率低 解释型:通过解释器一句一句的将代码解释成机器代码后执行,执行慢,开发效率高 如图 为什么说 Java 语言“编译与解释并存”?\n这是因为 Java 语言既具有编译型语言的特征,也具有解释型语言的特征。因为 Java 程序要经过先编译,后解释两个步骤,由 Java 编写的程序需要先经过编译步骤,生成字节码(.class 文件),这种字节码必须由 Java 解释器来解释执行。\nJava与C++区别\n没学过C++,Java不提供指针直接访问内存 Java为单继承;但是Java支持继承多接口 Java有自动内存管理垃圾回收机制(GC),不需要程序员手动释放无用内存 注释分为 单行注释、多行注释、文档注释 标识符与关键字 标识符即名字,关键字则是被赋予特殊含义的标识符\n自增自减运算符 当 b = ++a 时,先自增(自己增加 1),再赋值(赋值给 b);当 b = a++ 时,先赋值(赋值给 b),再自增(自己增加 1)\ncontinue/break/return\ncontinue :指跳出当前的这一次循环,继续下一次循环。 break :指跳出整个循环体,继续执行循环下面的语句。 return 用于跳出所在方法,结束该方法的运行。 变量\n成员变量和局部变量 成员变量可以被 public,private,static 等修饰符所修饰,而局部变量不能被访问控制修饰符及 static 所修饰;但是,成员变量和局部变量都能被 final 所修饰 从变量在内存中的存储方式来看,如果成员变量是使用 static 修饰的,那么这个成员变量是属于类的,如果没有使用 static 修饰,这个成员变量是属于实例的。而对象存在于堆内存,局部变量则存在于栈内存。 从变量在内存中的生存时间上看,成员变量是对象的一部分,它随着对象的创建而存在,而局部变量随着方法的调用而自动生成,随着方法的调用结束而消亡(即方法栈弹出后消亡)。 final必须显示赋初始值,其他都自动以类型默认值赋值 静态变量:被类所有实例共享 字符型常量与字符串常量区别\n形式 : 字符常量是单引号引起的一个字符,字符串常量是双引号引起的 0 个或若干个字符。 含义 : 字符常量相当于一个整型值( ASCII 值),可以参加表达式运算; 字符串常量代表一个地址值(该字符串在内存中存放位置)。 占内存大小 : 字符常量只占 2 个字节; 字符串常量占若干个字节。 静态方法为什么不能调用非静态成员?\n静态方法是属于类的,在类加载的时候就会分配内存,可以通过类名直接访问。而非静态成员属于实例对象,只有在对象实例化之后才存在,需要通过类的实例对象去访问。 在类的非静态成员不存在的时候静态成员就已经存在了,此时调用在内存中还不存在的非静态成员,属于非法操作。 调用方式\n使用类名.方法名 调用静态方法,或者对象.方法名 (不建议) 调用静态方法可以无需创建对象 重载\n发生在同一个类中(或者父类与子类之间),方法名必须相同,参数类型不同、个数不同、顺序不同、方法返回值和访问修饰符可以不同\n不允许存在(只有返回值不同的两个方法(方法名和参数个数及类型相同)) 重载就是同一个类中多个同名方法根据不同的传参来执行不同的逻辑处理。 重写\n发生在运行期,子类对父类的允许访问的方法实现过程进行重新编写\n方法名、参数列表必须相同,子类方法返回值类型应比父类方法返回值类型更小或相等(也就是更具体),抛出的异常范围小于等于父类,访问修饰符范围大于等于父类(不能说父类可以访问而子类不能访问)。【注意,这里只针对方法,类属性则没有这个限制】\npackage com.javaguide; import java.io.IOException; public class TestParent { private String a; protected AParent x() { return new AParent(); } protected void b() throws Exception { } } class TestChild extends TestParent { public String a; /** * 返回类型有误,没有比父类更具体 * * @return */ /* protected AParentParent x() { return new AChild(); }*/ protected AChild x() { return new AChild(); } /** * 抛异常类型有误 没有比父类更具体 * * @throws Throwable */ /*protected void b() throws Throwable { }*/ protected void b() throws IOException { } } 如果父类方法访问修饰符为 private/final/static 则子类就不能重写该方法,但是被 static 修饰的方法能够被再次声明\nclass TestChild extends TestParent { public static void ab(){} } //父类 public class TestParent { protected static void ab(){} } 构造方法无法被重写\n可变长参数\n代码 可变参数只能作为函数的最后一个参数\npublic static void method2(String arg1, String... args) { //...... } 遇到方法重载的情况怎么办呢?会优先匹配固定参数还是可变参数的方法呢?\n答案是会优先匹配固定参数的方法\nJava 的可变参数编译后实际会被转换成一个数组,我们看编译后生成的 class文件就可以看出来了。\n基本数据类型,8种\n6种数字类型,1种字符类型,1种布尔值 byte,short,int,long ; float,double ; char boolean 1个字节8位,其中 byte 1字节,short 2字节,int 4字节 ,long 8字节 float 4字节,double 8 字节 char 2字节,boolean 1位 基本数据类型和包装类型的区别\n包装类型可用于泛型,而基本类型不可以 对于基本数据类型,局部变量会存放在Java虚拟机栈中的局部变量表中,成员变量(未被static修饰)存放在Java虚拟机堆中。\n包装类型属于对象类型,几乎所有对象实例都存在于堆中 相比对象类型,基本数据类型占用空间非常小 \u0026ldquo;基本数据类型存放在栈中\u0026rdquo; 这句话是错的,基本数据类型的成员变量如果没有被static修饰的话(不建议这么用,应该使用基本数据类型对应的包装类型),就存放在堆中。\n(如果被static修饰了,如果1.7则在方法区,1.7及以上移到了 Java堆中) 包装类型的缓存机制 Byte,Short,Integer,Long这4中包装类默认创建了数值[-128,127]的相应类型的缓存数据,Character创建了数值在[0,127]范围的缓存数据,Boolean直接返回True or False\nInteger缓存代码\npublic static Integer valueOf(int i) { if (i \u0026gt;= IntegerCache.low \u0026amp;\u0026amp; i \u0026lt;= IntegerCache.high) return IntegerCache.cache[i + (-IntegerCache.low)]; return new Integer(i); } private static class IntegerCache { static final int low = -128; static final int high; static { // high value may be configured by property int h = 127; } } Character缓存代码\npublic static Character valueOf(char c) { if (c \u0026lt;= 127) { // must cache return CharacterCache.cache[(int)c]; } return new Character(c); } private static class CharacterCache { private CharacterCache(){} static final Character cache[] = new Character[127 + 1]; static { for (int i = 0; i \u0026lt; cache.length; i++) cache[i] = new Character((char)i); } } Boolean缓存代码\npublic static Boolean valueOf(boolean b) { return (b ? TRUE : FALSE); } 注意Float和Double没有使用缓存机制,且 只有调用valueOf(或者自动装箱)才会使用缓存,当使用new的时候是直接创建新对象\npublic Integer(int value) { this.value = value; } 举例\nBoolean t=new Boolean(true); Boolean f=new Boolean(true); System.out.println(t==f); //false System.out.println(t.equals(f)); //true Boolean t1=Boolean.valueOf(true); Boolean f1=Boolean.valueOf(true); System.out.println(t1==f1); //true System.out.println(Boolean.TRUE==Boolean.TRUE); //true //============================================// Integer i1 = 33; //这里发生了自动装箱,相当于Integer.valueOf(30) Integer i2 = 33; System.out.println(i1 == i2);// 输出 true Float i11 = 333f; Float i22 = 333f; System.out.println(i11 == i22);// 输出 false Double i3 = 1.2; Double i4 = 1.2; System.out.println(i3 == i4);// 输出 false //===========================================// Integer i1 = 40; Integer i2 = new Integer(40); System.out.println(i1==i2); 如上,所有整型包装类对象之间值的比较,应该全部使用equals方法比较 什么是自动装箱和拆箱\n装箱:将基本类型用它们对应的引用类型包装起来; 拆箱:将包装类型转换为基本数据类型; 举例说明\nInteger i = 10 ;//装箱 相当于Integer.valueOf(10) int n = i ;//拆箱 对应的字节码\nL1 LINENUMBER 8 L1 ALOAD 0 BIPUSH 10 INVOKESTATIC java/lang/Integer.valueOf (I)Ljava/lang/Integer; PUTFIELD AutoBoxTest.i : Ljava/lang/Integer; L2 LINENUMBER 9 L2 ALOAD 0 ALOAD 0 GETFIELD AutoBoxTest.i : Ljava/lang/Integer; INVOKEVIRTUAL java/lang/Integer.intValue ()I PUTFIELD AutoBoxTest.n : I RETURN 如图,Integer i = 10 等价于Integer i = Integer.valueOf(10)\nint n= i 等价于 int n= i.intValue();\n频繁拆装箱会严重影响系统性能\n浮点数运算的时候会有精度丢失的风险\n这个和计算机保存浮点数的机制有很大关系。我们知道计算机是二进制的,而且计算机在表示一个数字时,宽度是有限的,无限循环的小数存储在计算机时,只能被截断,所以就会导致小数精度发生损失的情况。这也就是解释了为什么浮点数没有办法用二进制精确表示。\n十进制下的0.2无法精确转换成二进制小数\n// 0.2 转换为二进制数的过程为,不断乘以 2,直到不存在小数为止, // 在这个计算过程中,得到的整数部分从上到下排列就是二进制的结果。 0.2 * 2 = 0.4 -\u0026gt; 0 0.4 * 2 = 0.8 -\u0026gt; 0 0.8 * 2 = 1.6 -\u0026gt; 1 0.6 * 2 = 1.2 -\u0026gt; 1 0.2 * 2 = 0.4 -\u0026gt; 0(发生循环) 使用BigDecimal解决上面的问题\nBigDecimal a = new BigDecimal(\u0026#34;1.0\u0026#34;); BigDecimal b = new BigDecimal(\u0026#34;0.9\u0026#34;); BigDecimal c = new BigDecimal(\u0026#34;0.8\u0026#34;); BigDecimal x = a.subtract(b); BigDecimal y = b.subtract(c); System.out.println(x); /* 0.1 */ System.out.println(y); /* 0.1 */ System.out.println(Objects.equals(x, y)); /* true */ 超过long整形的数据,使用BigInteger\nJava中,64位long整型是最大的整数类型\nlong l = Long.MAX_VALUE; System.out.println(l + 1); // -9223372036854775808 System.out.println(l + 1 == Long.MIN_VALUE); // true //BigInteger内部使用int[] 数组来存储任意大小的整型数据 //对于常规整数类型,使用BigInteger运算的效率会降低 "},{"id":355,"href":"/zh/docs/technology/Review/ssm/scope_transaction/","title":"作用域及事务","section":"Ssm","content":" 四种作用域 # singleton:默认值,当IOC容器一创建就会创建bean实例,而且是单例的,每次得到的是同一个 prototype:原型的,IOC容器创建时不再创建bean实例。每次调用getBean方法时再实例化该bean(每次都会进行实例化) request:每次请求会实例化一个bean session:在一次会话中共享一个bean 事务 # 事务是什么 # 逻辑上的一组操作,要么都执行,要么都不执行\n事务的特性 # ACID\nAtomicity /ˌætəˈmɪsəti/原子性 , 要么全部成功,要么全部失败 Consistency /kənˈsɪstənsi/ 一致性 , 数据库的完整性 Isolation /ˌaɪsəˈleɪʃn/ 隔离性 , 数据库允许多个并发事务同时对其数据进行读写和修改的能力,隔离性可以防止多个事务并发执行时由于交叉执行而导致数据的不一致 , 这里涉及到事务隔离级别 Durability /ˌdjʊərəˈbɪləti/ 持久性 , 事务处理结束后,对数据的修改就是永久的,即便系统故障也不会丢失 Spring支持两种方式的事务管理 # 编程式事务管理 /ˈeksɪkjuːt/ execute\n使用transactionTemplate\n@Autowired private TransactionTemplate transactionTemplate; public void testTransaction() { transactionTemplate.execute(new TransactionCallbackWithoutResult() { @Override protected void doInTransactionWithoutResult(TransactionStatus transactionStatus) { try { // .... 业务代码 } catch (Exception e){ //回滚 transactionStatus.setRollbackOnly(); } } }); } 使用transactionManager\n@Autowired private PlatformTransactionManager transactionManager; public void testTransaction() { TransactionStatus status = transactionManager.getTransaction(new DefaultTransactionDefinition()); try { // .... 业务代码 transactionManager.commit(status); } catch (Exception e) { transactionManager.rollback(status); } } 声明式事务管理\n@Transactional(propagation = Propagation.REQUIRED) public void aMethod { //do something B b = new B(); C c = new C(); b.bMethod(); c.cMethod(); } 事务传播行为 # Definition /ˌdefɪˈnɪʃ(ə)n/ 定义\nPropagation /ˌprɒpəˈɡeɪʃn/ 传播\n假设有代码如下:\n@Service Class A { @Autowired B b; @Transactional(propagation = Propagation.REQUIRED) public void aMethod { //do something b.bMethod(); } } @Service Class B { @Transactional(propagation = Propagation.XXXXXX) public void bMethod { //do something } } 共7种,其中主要有4种如下\nTransactionDefinition.PROPAGATION_REQUIRED 如果外部方法没有开启事务,则内部方法创建一个新的事务,即内外两个方法的事务互相独立;如果外部方法存在事务,则内部方法加入该事务,即内外两个方法使用同一个事务\nTransactionDefinition.PROPAGATION_REQUIRES_NEW 如果外部方法存在事务,则会挂起当前的事务,并且开启一个新事务,当外部方法抛出异常时,内部方法不会回滚;而当内部方法抛出异常时,外部方法会检测到并进行回滚。 如果外部方法不存在事务,则也会开启一个新事务\nTransactionDefinition.PROPAGATION_NESTED: 如果外部方法开启事务,则在内部再开启一个事务,作为嵌套事务存在;如果外部方法无事务,则单独开启一个事务\n在外围方法开启事务的情况下Propagation.NESTED修饰的内部方法属于外部事务的子事务,外围主事务回滚,子事务一定回滚,而内部子事务可以单独回滚而不影响外围主事务和其他子事务,也就是和上面的PROPAGATION_REQUIRES_NEW相反\nTransactionDefinition.PROPAGATION_MANDATORY 如果当前存在事务,则加入该事务;如果当前没有事务,则抛出异常 mandatory /ˈmændətəri/ 强制的\n下面三个比较不常用\nTransactionDefinition.PROPAGATION_SUPPORTS: 如果当前存在事务,则加入该事务;如果当前没有事务,则以非事务的方式继续运行。 TransactionDefinition.PROPAGATION_NOT_SUPPORTED: 以非事务方式运行,如果当前存在事务,则把当前事务挂起。 TransactionDefinition.PROPAGATION_NEVER: 以非事务方式运行,如果当前存在事务,则抛出异常。 事务隔离级别 # TransactionDefinition.ISOLATION_DEFAULT TransactionDefinition.ISOLATION_READ_UNCOMMITTED 读未提交,级别最低,允许读取尚未提交的数据,可能会导致脏读、幻读或不可重复读 TransactionDefinition.ISOLATION_READ_COMMITTED 读已提交,对同一字段的多次读取结果都是一致的。可以阻止脏读,但幻读或不可重复读仍会发生 TransactionDefinition.ISOLATION_SERIALIZABLE 串行化,可以防止脏读、幻读及不可重复读,所有事务依次逐个执行,完全服从ACID,但严重影响性能 "},{"id":356,"href":"/zh/docs/technology/Review/basics/member_variables_and_local_variables/","title":"成员变量与局部变量","section":"Java基础(尚硅谷)_","content":" 代码 # static int s; int i; int j; { int i = 1; i++; j++; s++; } public void test(int j) { j++; i++; s++; } public static void main(String[] args) { Exam5 obj1 = new Exam5(); Exam5 obj2 = new Exam5(); obj1.test(10); obj1.test(20); obj2.test(30); System.out.println(obj1.i + \u0026#34;,\u0026#34; + obj1.j + \u0026#34;,\u0026#34; + obj1.s); System.out.println(obj2.i + \u0026#34;,\u0026#34; + obj2.j + \u0026#34;,\u0026#34; + obj2.s); } 运行结果 # 2,1,5 1,1,5 分析 # 就近原则 # 代码中有很多修改变量的语句,下面是用就近原则+作用域分析的图 局部变量和类变量 # 局部变量包括方法体{},形参,以及代码块\n带static为类变量,不带的为实例变量\n代码中的变量分类 修饰符 \u0026ndash;局部变量只有final \u0026ndash; 实例变量 public , protected , private , final , static , volatile transient\n存储位置\n局部变量:栈\n实例变量:堆\n类变量:方法区(类信息、常量、静态变量)\n作用域 局部变量:从声明处开始,到所属的 } 结束 this 题中的s既可以用成员变量访问,也可以用类名访问\n生命周期\n局部变量:每一个线程,每一次调用执行都是新的生命周期 实例变量:随着对象的创建而初始化,随着对象被回收而消亡(垃圾回收器),每一个对象的实例变量是独立的 类变量:随着类的初始化而初始化,随着类的卸载而消亡,该类的所有对象的类变量是共享的 代码的执行,jvm中 # Exam5 obj1=new Exam5();\nobj1.test(10)\n非静态代码块或者进入方法,都会在栈中开辟空间存储局部变量 注意:静态代码块定义的变量,只会存在于静态代码块中。不是类变量,也不属于成员变量\n"},{"id":357,"href":"/zh/docs/technology/Review/basics/recursion_and_iteration/","title":"递归与迭代","section":"Java基础(尚硅谷)_","content":" 编程题 # 有n步台阶,一次只能上1步或2步,共有多少种走法\n分析 # 分析\nn = 1,1步 f(1) = 1\nn = 2, 两个1步,2步 f(2) = 2\nn = 3, 分两种情况: 最后1步是2级台阶/最后1步是1级台阶, 即 f(3) = f(1)+f(2) n = 4, 分两种情况: 最后1步是2级台阶/最后1步是1级台阶, 即f(4) = f(2)+f(3)\n也就是说,不管有几(n)个台阶,总要分成两种情况:最后1步是2级台阶/最后1步是1级台阶,即 f(n)= f(n-2) + f(n-1)\n递归 # public static int f(int n){ if(n==1 || n==2){ return n; } return f(n-2)+f(n-1); } public static void main(String[] args) { System.out.println(f(1)); //1 System.out.println(f(2)); //2 System.out.println(f(3)); //3 System.out.println(f(4)); //5 System.out.println(f(5)); //8 } debug调试 方法栈 f(4)\u0026mdash;-\u0026gt;分解成f(2)+f(3) f(2)\u0026mdash;返回- f(3)\u0026mdash;f(2)返回\u0026mdash;f(1)返回 【f(3)分解成f(2)和f(1)】 方法栈的个数: 使用循环 # public static int loop(int n){ if (n \u0026lt; 1) { throw new IllegalArgumentException(n + \u0026#34;不能小于1\u0026#34;); } if (n == 1 || n == 2) { return n; } int one=2;//最后只走1步,会有2种走法 int two=1;//最后走2步,会有1种走法 int sum=0; for(int i=3;i\u0026lt;=n;i++){ //最后跨两级台阶+最后跨一级台阶的走法 sum=two+one; two=one; one=sum; } return sum; } 小结 # 方法调用自身称为递归,利用变量的原值推出新值称为迭代(while循环) 递归\n优点:大问题转换为小问题,代码精简\n缺点:浪费空间(栈空间),可能会照成栈的溢出 迭代\n优点:效率高,时间只受循环次数限制,不受出栈入栈时间\n缺点:不如递归精简,可读性稍差 "},{"id":358,"href":"/zh/docs/technology/Review/basics/method_parameter_passing_mechanism/","title":"方法的参数传递机制","section":"Java基础(尚硅谷)_","content":" 代码 # public class Exam4 { public static void main(String[] args) { int i = 1; String str = \u0026#34;hello\u0026#34;; Integer num = 2; int[] arr = {1, 2, 3, 4, 5}; MyData my = new MyData(); change(i, str, num, arr, my); System.out.println(\u0026#34;i = \u0026#34; + i); System.out.println(\u0026#34;str = \u0026#34; + str); System.out.println(\u0026#34;num = \u0026#34; + num); System.out.println(\u0026#34;arr = \u0026#34; + Arrays.toString(arr)); System.out.println(\u0026#34;my.a = \u0026#34; + my.a); } public static void change(int j, String s, Integer n, int[] a, MyData m) { j+=1; s+=\u0026#34;world\u0026#34;; n+=1; a[0]+=1; m.a+=1; } } 结果\ni = 1 str = hello num = 2 arr = [2, 2, 3, 4, 5] my.a = 11 知识点 # 方法的参数传递机制 String、包装类等对象的不可变性 分析 # 对于包装类,如果是使用new,那么一定是开辟新的空间;如果是直接赋值,那么-128-127之间会有缓存池(堆中)\n//当使用new的时候,一定在堆中新开辟的空间 Integer a1= new Integer(12); Integer b1= new Integer(12); System.out.println(a1 == b1);//false Integer a2= -128; Integer b2= -128; System.out.println(a2 == b2);//true Integer a21= -129; Integer b21= -129; System.out.println(a21 == b21);//false Integer a3= 127; Integer b3= 127; System.out.println(a3 == b3);//true Integer a4= 22; Integer b4= 22; System.out.println(a4 == b4);//true Integer a31= 128; Integer b31= 128; System.out.println(a31 == b31);//false 对于String类\n//先查找常量池中是否有\u0026#34;abc\u0026#34;,如果有直接返回在常量池中的引用, //如果没有,则在常量池中创建\u0026#34;abc\u0026#34;,然后返回该引用 String a=\u0026#34;abc\u0026#34;; //先查找常量池中是否有\u0026#34;abc\u0026#34;,如果有则在堆内存中创建对象,然后返回堆内存中的地址 //如果没有,则先在常量池中创建字符串对象,然后再在堆内存中创建对象,最后返回堆内存中的地址 String ab=new String(\u0026#34;abc\u0026#34;); System.out.println(a==ab);//true //intern() //判断常量池中是否有ab对象的字符串,如果存在\u0026#34;abc\u0026#34;则返回\u0026#34;abc\u0026#34;在 //常量池中的引用,如果不存在则在常量池中创建, //并返回\u0026#34;abc\u0026#34;在常量池中的引用 System.out.println(a==ab.intern());//true change方法调用之前,jvm中的结构 方法栈帧中的数据 执行change方法后,实参给形参赋值: 基本数据类型:数据值 引用数据类型:地址值\n当实参是特殊的类型时:比如String、包装类等对象,不可变,即 s+=\u0026quot;world\u0026quot;; 会导致创建两个对象,如图( Integer也是) 数组和对象,则是找到堆内存中的地址,直接更改\n"},{"id":359,"href":"/zh/docs/technology/Review/basics/class_and_instance_initialization/","title":"类、实例初始化","section":"Java基础(尚硅谷)_","content":" 代码 # public class Son extends Father{ private int i=test(); private static int j=method(); static { System.out.print(\u0026#34;(6)\u0026#34;); } Son(){ System.out.print(\u0026#34;(7)\u0026#34;); } { System.out.print(\u0026#34;(8)\u0026#34;); } public int test(){ System.out.print(\u0026#34;(9)\u0026#34;); return 1; } public static int method(){ System.out.print(\u0026#34;(10)\u0026#34;); return 1; } public static void main(String[] args) { Son s1=new Son(); System.out.println(); Son s2=new Son(); } } public class Father { private int i=test(); private static int j=method(); static { System.out.print(\u0026#34;(1)\u0026#34;); } Father(){ System.out.print(\u0026#34;(2)\u0026#34;); } { System.out.print(\u0026#34;(3)\u0026#34;); } public int test() { System.out.print(\u0026#34;(4)\u0026#34;); return 1; } public static int method() { System.out.print(\u0026#34;(5)\u0026#34;); return 1; } } 输出:\n(5)(1)(10)(6)(9)(3)(2)(9)(8)(7) (9)(3)(2)(9)(8)(7) 分析 # 类初始化过程\n当实例化了一个对象/或main所在类会导致类初始化 子类初始化前会先初始化父类 类初始化执行的是clinit 方法,编译查看字节码可得知 clinit 由静态类变量显示赋值语句 以及 静态代码块组成(由上到下顺序),且只执行一次\n如下\n实例初始化过程\n执行的是init方法 由非静态实例变量显示赋值语句 以及 非静态代码块 [从上到下顺序] 以及对应构造器代码[最后执行] 组成 其中,子类构造器一定会调用super() [最前面] 1) super() 【最前】 2)i = test() 3)子类的非静态代码块 【2,3按顺序】 4) 子类的无参构造(最后)\n重写的问题 如上所示,初始化Son对象的时候,会先调用super()方法,即初始化父类,然后会先调用父类的 非静态变量赋值以及非静态代码块,最后才是父类的构造器代码块\n调用父类非静态变量赋值的时候,如果调用了非静态方法,就会涉及到重写问题,比如这里的\npublic class Father{ private int i= test(); } 这里会调用子类(当前正在初始化的对象)的test()方法,而不是父类的test()\n哪些方法不可被重写 final方法、静态方法、父类中的private等修饰使得子类不可见的方法 "},{"id":360,"href":"/zh/docs/technology/Review/basics/singleton_design_pattern/","title":"单例设计模式","section":"Java基础(尚硅谷)_","content":" 特点 # 该类只有一个实例 构造器私有化 该类内部自行创建该实例 使用静态变量保存 能向外部提供这个实例 直接暴露 使用静态变量的get方法获取 几大方法 # 饿汉式 # 随着类的加载进行初始化,不管是否需要都会直接创建实例对象\npublic class Singleton1 { public static final Singleton1 INSTANCE=new Singleton1(); private Singleton1() { } } 枚举 # 枚举类表示该类型的对象是有限的几个\npublic enum Singleton2 { INSTANCE } 使用静态代码块 # 随着类的加载进行初始化\npublic class Singleton2 { public static final Singleton2 INSTANCE; static { INSTANCE = new Singleton2(); } private Singleton2() { } } 如图,当初始化实例时需要进行复杂取值操作时,可以取代第一种方法 懒汉式 # 延迟创建对象\npublic class Singleton4 { //为了防止重排序,需要添加volatile关键字 private static volatile Singleton4 INSTANCE; private Singleton4() { } /** * double check * @return */ public static Singleton4 getInstance() { //2 先判断一次,对于后面的操作(此时已经创建了对象)能减少加锁次数 if (INSTANCE == null) { //如果这里不加锁会导致线程安全问题,可能刚进了判断语句之后,执行权被剥夺了又创建好了对象, //所以判断及创建对象必须是原子操作 synchronized (Singleton4.class) { if (INSTANCE == null) { //用来模拟多线程被剥夺执行权 try { Thread.sleep(1000); } catch (InterruptedException e) { e.printStackTrace(); } //如果这个地方不加volatile,会出现的问题是,指令重排 1,2,3是正常的, //会重排成1,3,2 然后别的线程去拿的时候,判断为非空,但是实际上运行的时候,发现里面的数据是空的 //1 memory = allocate();//分配对象空间 //2 instance(memory); //初始化对象 //3 instance = memory; //设置instance指向刚刚分配的位置 INSTANCE = new Singleton4(); } } } return INSTANCE; } } 使用静态内部类 # public class Singleton6 { private Singleton6(){ } private static class Inner{ private static final Singleton6 INSTANCE=new Singleton6(); } public static Singleton6 getInstance(){ return Inner.INSTANCE; } } 只有当内部类被加载和初始化的时候,才会创建INSTANCE实例对象 静态内部类不会自动随着外部类的加载和初始化而初始化,他需要单独去加载和初始化 又由于他是在内部类加载和初始化时,创建的,属于类加载器处理的,所以是线程安全的 "},{"id":361,"href":"/zh/docs/technology/Review/basics/self_incrementing_variable/","title":"自增变量","section":"Java基础(尚硅谷)_","content":" 题目 # int i=1; i=i++; int j=i++; int k = i+ ++i * i++; System.out.println(\u0026#34;i=\u0026#34;+i); System.out.println(\u0026#34;j=\u0026#34;+j); System.out.println(\u0026#34;k=\u0026#34;+k); 讲解 # 对于操作数栈和局部变量表的理解 # 对于下面的代码\nint i=10; int j=9; j=i; 反编译之后,查看字节码\n0 bipush 10 2 istore_1 3 bipush 9 5 istore_2 6 iload_1 7 istore_2 8 return 如下图,这三行代码,是依次把10,9先放到局部变量表的1,2位置。\n之后呢,再把局部变量表中1位置的值,放入操作数栈中\n最后,将操作数栈弹出一个数(10),将数值赋给局部变量表中的位置2\n如上图,当方法为静态方法时,局部变量表0位置存储的是实参第1个数\n(当方法为非静态方法时,局部变量表0位置存储的是this引用)\n对于下面这段代码\nint i=10; int j=20; i=i++; j=++j; System.out.println(i); System.out.println(j); 编译后的字节码\n0 bipush 10 2 istore_1 3 bipush 20 5 istore_2 6 iload_1 7 iinc 1 by 1 10 istore_1 11 iinc 2 by 1 14 iload_2 15 istore_2 16 getstatic #5 \u0026lt;java/lang/System.out : Ljava/io/PrintStream;\u0026gt; 19 iload_1 20 invokevirtual #6 \u0026lt;java/io/PrintStream.println : (I)V\u0026gt; 23 getstatic #5 \u0026lt;java/lang/System.out : Ljava/io/PrintStream;\u0026gt; 26 iload_2 27 invokevirtual #6 \u0026lt;java/io/PrintStream.println : (I)V\u0026gt; 30 return 如上对于j = ++j ;是\n11 iinc 2 by 1 14 iload_2 15 istore_2 先对局部变量表2中的 值 加1,然后将结果 放入操作数栈中,之后再将操作数栈弹出一个数并赋值给 位置2\n对于题目的解释 # int i=1; i=i++; int j=i++; int k = i+ ++i * i++; System.out.println(\u0026#34;i=\u0026#34;+i); System.out.println(\u0026#34;j=\u0026#34;+j); System.out.println(\u0026#34;k=\u0026#34;+k); 编译后的字节码\n0 iconst_1 1 istore_1 2 iload_1 3 iinc 1 by 1 6 istore_1 7 iload_1 8 iinc 1 by 1 11 istore_2 12 iload_1 13 iinc 1 by 1 16 iload_1 17 iload_1 18 iinc 1 by 1 21 imul 22 iadd 23 istore_3 对于 int j = i++\n7 iload_1 8 iinc 1 by 1 11 istore_2 先将i的值放进栈中,然后将局部变量表中的i + 1,之后将栈中的值赋值给j 到这步骤的时候,i = 2 ,j = 1\n最后一步 int k = i+ i * i\n12 iload_1 13 iinc 1 by 1 16 iload_1 17 iload_1 18 iinc 1 by 1 21 imul 22 iadd 23 istore_3 如字节码所示,先将i load进操作数栈中(2),然后将局部变量表中的i 自增 (3),之后将自增后的结果(3)放入操作数栈中,第二次将局部变量表中的i放入操作数栈中。然后此时操作数栈中存在 3 3 2 (由栈顶到栈底) ,依次进行乘法加法 (3*3+2) =11 ,放入局部变量表3 中。 所以结果为 2, 1,11\n小结 # ​\n"},{"id":362,"href":"/zh/docs/technology/Git/git_sgg_/19-26/","title":"19-26_git_尚硅谷","section":"基础(尚硅谷视频)_","content":" 介绍 # 使用代码托管中心(远程服务器) 团队内写作 push\u0026ndash;clone\u0026ndash;push\u0026mdash; \u0026ndash;pull 跨团队写作 fork(到自己的远程库)\u0026mdash;clone 创建远程库\u0026amp;创建别名 # 官网:https://github.com 现在yuebuqun注册一个账号 创建一个远程库git-demo,创建成功 创建远程库别名 git remote -v (查看别名) 为远程库创建别名 git remote add git-demo https://github.com/lwmfjc/git-demo.git 别名创建成功 fetch和push都可以使用别名 推送本地库到远程库 # 推送master分支 切换git checkout master 推送 git push git-demo master 拉取远程库到本地库 # git pull git-demo master 结果 克隆远程库到本地 # git clone xxxxxxx/git-demo.git clone之后有默认的别名,且已经初始化了本地库 团队内写作 # lhc修改了git-demo下的hello.txt 之后进行git add hello.txt git commit -m \u0026ldquo;lhc-commit \u0026quot; hello.txt 现在进行push git push origin master 出错了 使用ybq,对库进行设置,管理成员 添加成员即可 输入账号名 将邀请函 发送给lhc 现在再次推送,则推送成功 团队外合作 # 先把别人的项目fork下来 之后进行修改并且commit pull request (拉取请求) 请求 东方不败:\n岳不群:看到别人发过来的请求 可以同意 合并申请 SSH免密登录 # ssh免密公钥添加\n添加之前,\ngit config --global user.name \u0026#34;username\u0026#34; git config --global user.email useremail@qq.com 删除~/.ssh 使用\nssh-keygen -t rsa -C xxxx@xx.com # 再次到~/.ssh 查看 cat id_rsa 私钥 把私钥复制到 账号\u0026ndash;设置\u0026ndash;ssh and gpgkeys 测试是否成功 "},{"id":363,"href":"/zh/docs/life/archive/20220724/","title":"人为什么要结婚(找对象)","section":"往日归档","content":"其实这是我在六七年前思考的一个问题,我觉得结婚,并不能单纯的作为一个世俗任务。很多人,是因为年纪到了结婚,因为父母催结婚,因为看到别人结婚而结婚,总之,是为别人而活。但我觉得,结婚的本质,应该是两个人生活的结合,包括了很多,比如生活中的喜怒哀乐互享,这是最基础的,开心了有人替你高兴,生气难过了有人安慰你、心疼你。如果连这个都做不到而各活各的,那我实在想不明白这种婚姻的意义在哪,而现在很多情况正是这样,有为了家庭而工作辛苦而没有交集的,也有单纯的相处腻了、懒了。\n而说到腻,这就在于一点,就是有些婚姻是很仓促的,压根就没看清楚对方的样子(性格、三观),或者是不清楚自己喜欢的是什么样的人,就已经在一起了,之后才发现对方很多问题不是自己能接受的,但是这个时候已经晚了。所以“内在”,才能持久吸引一个人,因为这是不轻易随时光变迁而改变的。\n分享也并非简单的分享,如果分享的东西对方没有啥感觉,那这种关系也是很难持久的。因此,最佳的婚姻,应该是异性知己,你的一些心理,不用向对方解释太多,当然 这里并不是说一开始就是这种状态,更多是通过后面不断了解、不断磨合而达成这种状态,当你被别人误会了有人理解,这是世间最好的良药。理解一个人,就是拯救一个世界,一花一世界,一树一菩提。\n婚姻,就是找个互相理解的爱人,共享世间冷暖,白首不相离。\n"},{"id":364,"href":"/zh/docs/technology/Git/git_sgg_/09-18/","title":"09-18_git_尚硅谷","section":"基础(尚硅谷视频)_","content":" 命令 # 命令-设置用户签名\n查看 git config user.name git config user.email 设置 git config --global user.name ly001 git config --global user.email xxx@xx.com git的配置文件查看 作用:区分不同操作者身份,跟后面登陆的账号没有关系 初始化本地库\ngit init 多出一个文件夹 查看本地库状态\ngit status 默认在master分支 新增一个文件 vim hello.txt 此时查看本地库的状态 untracketd files 未被追踪的文件,也就是这个文件还在工作区 添加暂存区\ngit add hello.txt LF 将会被替换成 CRLF,windows里面是CRLF,也就是说\n这个换行符自动转换会把自动把你代码里 与你当前操作系统不相同的换行的方式 转换成当前系统的换行方式(即LF和CRLF 之间的转换)\n这是因为这个hello.txt是使用vm hello.txt在git bash里面添加的,如果直接在windows文件管理器添加一个文件(hello2.txt),就会发现没有这个警告,因为他已经是CRLF了 (为了和视频保持一致,git rm \u0026ndash;cached hello2.txt 后删除这个文件) 查看当前状态,绿色表示git已经追踪到了这个文件\n文件已经存在于暂存区 使用git rm --cached hello.txt可以将文件从暂存区删除 使用后,文件又出现在工作区了(未添加) 提交本地库\ngit commit -m \u0026quot;first commit\u0026quot; hello.txt 会出现一些警告,以及此时提交的修改和生成的版本号(前七位) git status 使用git reflog查看引用日志信息 git log 查看详细日志信息 修改命令\n前提,修改了文件 git status\n红色表示git还没有追踪到这个修改,如果此时commit ,会提示没有需要commit的 使用git add hello.txt 将文件修改添加到暂存区 之后git status 注意,这里如果提交到暂存区之后,使用git restore是无法恢复文件的\ngit restore --staged \u0026lt;file\u0026gt;...\u0026quot; to unstage 使用这个命令丢弃这个文件的commit操作\n几个命令的区别:\ngit restore file 的命令是丢弃你在工作区修改的内容,(修改的内容会丢失) git restore \u0026ndash;staged file 丢弃你在工作区的修改不被 commit 。但是你的修改依然在工作区。 git rm \u0026ndash;cached file和git restore \u0026ndash;staged file 效果好像一样,这里不做更进一步的分析 回到最初,这里主要是为了看修改,如最上面,将第一行后面添加了22222\ncommit 之后的提示,删除了一行,添加了一行(修改的另一种说法) 如果,HEAD -\u0026gt; master ,指针指向了第二个版本 这里再做第三次修改,并add 及commit 查看工作区,永远只有最后那次修改的文件 版本穿梭\ngit reflog和git log 回顾:hello.txt先是5行,然后第一行加了2,之后第二行加了3\n使用git reset \u0026ndash;hard 版本号进行穿梭,这里多了一行,是因为我复制的时候复制粗了版本号\n使用cat 查看,发现文件已经在另一个版本 查看.git的一些文件 说明目前是在master这个版本上 下面这个文件 .git/refs/heads/master 记录了指向master分支的哪个版本号 这里将文件指向最初的版本 此时查看刚才说的那个记录某个分支当前指向版本的文件,已经做了更新 再穿梭为后面的版本 git reset \u0026ndash;hard file 图片解释 master指针指向first,second,third head永远都是指向master(当前分支,目前只有master,所以不变)\n分支 # 概述和优点 查看\u0026amp;创建\u0026amp;切换\ngit branch 分支名 #创建分支 git branch -v #查看分支 git checkout 分支名 #切换分支 git merge 分支名 #把指定的分支合并到当前分支上 查看分支并显示当前分支指向的版本 git branch -v 创建分支 git branch hot-fix git branch #再次查看 切换分支\ngit branch hot-fix 此时修改一个文件并提交 查看.git/head文件,会发现现在它指向hot-fix分支 合并分支(正常合并)\n切换分支 将某分支xx合并到当前分支 git merge 分支名\n如图,合并成功 以后面那个分支的修改为主\n合并分支(冲突合并)\n前提,现在master分支倒数第二行修改并添加和提交 此时切换到hot-fix分支 修改倒数第一行 将文件从工作区添加到暂存区并提交到本地库 此时再切回master\ngit checkout master git merge hot-fix 提示出错了,而且所有有异常的文件,都以下面的形式标注 按dd进行删除某一行 改完了之后,保存并提交即可 切回之后查看hot-fix分支,发现这里的文件是没有变化的 原理 "},{"id":365,"href":"/zh/docs/technology/Git/git_sgg_/01-08/","title":"01-08_git_尚硅谷","section":"基础(尚硅谷视频)_","content":" 概述 # 课程介绍 # Git - git介绍\u0026ndash;分布式版本控制+集中式版本控制 - git安装\u0026ndash;基于官网,2.31.1 windows - 基于开发案例 详细讲解常用命令 - git分支\u0026mdash;特性、创建、转换、合并、代码合并冲突解决 - idea集成git Github 如何创建远程库 推送 push 拉取 pull 克隆 clone ssh免密登录 idea github集成 Gitee码云 码云创建远程库 Idea集成Gitee Gitlab gitlab服务器的搭建和部署 idea集成gitlab 课程目标:五个小时,熟练掌握git、github、gitee 官网介绍 # git是免费的开源的分布式版本控制系统 廉价的本地库 分支功能 Everything is local 版本控制介绍 # 记录文件内容变化,以便将来查阅特定版本修订记录的系统 如果没有git 为什么需要版本控制(从个人开发过渡到团队合作) 分布式版本控制VS集中式版本控制 # SVN,单一的集中管理的服务器,保存所有文件的修订版本。其他人都先连到这个中央服务器上获取最新处理是否冲突 缺点,单点故障,如果某段时间内故障了,那么就没法提交 Git,每台电脑都是代码库 如果远程库挂了,本地还是可以做版本控制的,只不过不能做代码推送而已 每个客户端保存的都是完整的项目(包括历史记录) 发展历史 # linux系统版本控制历史 1991-2002 手动合并 2002 BitKeeper授权Linux社区免费使用(版本控制系统) 社区将其破解 2005 用C语言开发了一个分布式版本控制系统:Git 两周开发时间 2008年 GitHub上线 工作机制和代码托管中心 # 工作机制\n如果git commit ,会生成对应的历史版本,那么这里的历史版本是删不掉的 如果只是在工作区,或者添加到了暂存区,那么是可以恢复(删掉(操作记录))的 git add (让git知道有这个文件) 如果只有v1,v2,v3,V3版本是删不掉的,如果要恢复成v2,只能再提交一次版本 远程库\u0026ndash; 代码托管中心是基于网络服务器的远程代码仓库,简称为远程库 局域网 GitLab\n互联网 GitHub Gitee 码云\n安装 # git安装、客户端使用(windows)\ngit安装位置 任意 非中文、无空格\n选项配置 编辑器选择 是否修改初始化分支的名字\u0026ndash;默认master 默认第二个,这里选择第一个,只能在git bash里面使用 后台客户端协议 配置行末换行符 windows\u0026ndash;CRLF linux\u0026ndash;LF\n默认,让git根据系统自动转换\n从远程拉取代码时,模式\u0026ndash;用默认 凭据管理器 记录登陆行为,不用每次登录 其他配置 软链接文件 缓存 再git bash里运行第三方程序\n安装成功\u0026mdash;视频里面是2.31 "},{"id":366,"href":"/zh/docs/technology/Maven/advance_dljd_/01-21/","title":"01-21 maven多模块管理_动力节点","section":"进阶(动力节点)_","content":" 场景介绍 # 业务依赖 多模块管理 版本管理 第1种方式 # 创建父工程 # 先创建一个空项目 在这个空项目下,创建一个module当作maven父工程 结构 pom文件\n\u0026lt;?xml version=\u0026#34;1.0\u0026#34; encoding=\u0026#34;UTF-8\u0026#34;?\u0026gt; \u0026lt;project xmlns=\u0026#34;http://maven.apache.org/POM/4.0.0\u0026#34; xmlns:xsi=\u0026#34;http://www.w3.org/2001/XMLSchema-instance\u0026#34; xsi:schemaLocation=\u0026#34;http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd\u0026#34;\u0026gt; \u0026lt;modelVersion\u0026gt;4.0.0\u0026lt;/modelVersion\u0026gt; \u0026lt;groupId\u0026gt;com.bjpowernode.maven\u0026lt;/groupId\u0026gt; \u0026lt;artifactId\u0026gt;001-maven-parent\u0026lt;/artifactId\u0026gt; \u0026lt;version\u0026gt;1.0.0\u0026lt;/version\u0026gt; \u0026lt;!-- packaging 标签指定打包方式,默认为jar --\u0026gt; \u0026lt;!-- maven父工程必须遵守以下两点要求 1、packaging标签的文本内容必须设置为pom 2、把src删除 --\u0026gt; \u0026lt;/project\u0026gt; 介绍pom文件 # pom 项目对象模型,project object model,该文件可以子工程被继承 maven多模块管理,其实就是让它的子模块的pom文件来继承父工程的pom\n创建maven java子工程 # 新建一个module\n注意路径,002在IDEA-maven的目录下 查看pom文件\n\u0026lt;?xml version=\u0026#34;1.0\u0026#34; encoding=\u0026#34;UTF-8\u0026#34;?\u0026gt; \u0026lt;project xmlns=\u0026#34;http://maven.apache.org/POM/4.0.0\u0026#34; xmlns:xsi=\u0026#34;http://www.w3.org/2001/XMLSchema-instance\u0026#34; xsi:schemaLocation=\u0026#34;http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd\u0026#34;\u0026gt; \u0026lt;!--指向父工程的gav坐标--\u0026gt; \u0026lt;parent\u0026gt; \u0026lt;artifactId\u0026gt;001-maven-parent\u0026lt;/artifactId\u0026gt; \u0026lt;groupId\u0026gt;com.bjpowernode.maven\u0026lt;/groupId\u0026gt; \u0026lt;version\u0026gt;1.0.0\u0026lt;/version\u0026gt; \u0026lt;!--相对路径--\u0026gt; \u0026lt;relativePath\u0026gt;../001-maven-parent/pom.xml\u0026lt;/relativePath\u0026gt; \u0026lt;/parent\u0026gt; \u0026lt;modelVersion\u0026gt;4.0.0\u0026lt;/modelVersion\u0026gt; \u0026lt;artifactId\u0026gt;002-maven-java\u0026lt;/artifactId\u0026gt; \u0026lt;/project\u0026gt; 创建maven web子工程 # 创建新模块 查看pom\n\u0026lt;?xml version=\u0026#34;1.0\u0026#34; encoding=\u0026#34;UTF-8\u0026#34;?\u0026gt; \u0026lt;project xmlns=\u0026#34;http://maven.apache.org/POM/4.0.0\u0026#34; xmlns:xsi=\u0026#34;http://www.w3.org/2001/XMLSchema-instance\u0026#34; xsi:schemaLocation=\u0026#34;http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd\u0026#34;\u0026gt; \u0026lt;parent\u0026gt; \u0026lt;artifactId\u0026gt;001-maven-parent\u0026lt;/artifactId\u0026gt; \u0026lt;groupId\u0026gt;com.bjpowernode.maven\u0026lt;/groupId\u0026gt; \u0026lt;version\u0026gt;1.0.0\u0026lt;/version\u0026gt; \u0026lt;relativePath\u0026gt;../001-maven-parent/pom.xml\u0026lt;/relativePath\u0026gt; \u0026lt;/parent\u0026gt; \u0026lt;modelVersion\u0026gt;4.0.0\u0026lt;/modelVersion\u0026gt; \u0026lt;artifactId\u0026gt;003-maven-web\u0026lt;/artifactId\u0026gt; \u0026lt;packaging\u0026gt;war\u0026lt;/packaging\u0026gt; \u0026lt;/project\u0026gt; 修改子工程为父工程 # ​\t1 父工程的pom.xml种的packaging标签的文本内容必须设置pom\n​\t2 删除src目录\n如图,比如这里修改002-maven-java为父工程 添加004为002的子工程 查看pom文件\n\u0026lt;?xml version=\u0026#34;1.0\u0026#34; encoding=\u0026#34;UTF-8\u0026#34;?\u0026gt; \u0026lt;project xmlns=\u0026#34;http://maven.apache.org/POM/4.0.0\u0026#34; xmlns:xsi=\u0026#34;http://www.w3.org/2001/XMLSchema-instance\u0026#34; xsi:schemaLocation=\u0026#34;http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd\u0026#34;\u0026gt; \u0026lt;parent\u0026gt; \u0026lt;artifactId\u0026gt;002-maven-java\u0026lt;/artifactId\u0026gt; \u0026lt;groupId\u0026gt;com.bjpowernode.maven\u0026lt;/groupId\u0026gt; \u0026lt;version\u0026gt;1.0.0\u0026lt;/version\u0026gt; \u0026lt;relativePath\u0026gt;../002-maven-java/pom.xml\u0026lt;/relativePath\u0026gt; \u0026lt;/parent\u0026gt; \u0026lt;modelVersion\u0026gt;4.0.0\u0026lt;/modelVersion\u0026gt; \u0026lt;artifactId\u0026gt;004-maven-java-1\u0026lt;/artifactId\u0026gt; \u0026lt;properties\u0026gt; \u0026lt;maven.compiler.source\u0026gt;8\u0026lt;/maven.compiler.source\u0026gt; \u0026lt;maven.compiler.target\u0026gt;8\u0026lt;/maven.compiler.target\u0026gt; \u0026lt;/properties\u0026gt; \u0026lt;/project\u0026gt; 手动修改Maven工程为子工程(非idea中) # 这里说的是,创建子工程的时候,没有选择父工程 创建完之后的pom.xml\n\u0026lt;?xml version=\u0026#34;1.0\u0026#34; encoding=\u0026#34;UTF-8\u0026#34;?\u0026gt; \u0026lt;project xmlns=\u0026#34;http://maven.apache.org/POM/4.0.0\u0026#34; xmlns:xsi=\u0026#34;http://www.w3.org/2001/XMLSchema-instance\u0026#34; xsi:schemaLocation=\u0026#34;http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd\u0026#34;\u0026gt; \u0026lt;modelVersion\u0026gt;4.0.0\u0026lt;/modelVersion\u0026gt; \u0026lt;groupId\u0026gt;com.bjpowernode.maven\u0026lt;/groupId\u0026gt; \u0026lt;artifactId\u0026gt;005-maven-java\u0026lt;/artifactId\u0026gt; \u0026lt;version\u0026gt;1.0.0\u0026lt;/version\u0026gt; \u0026lt;/project\u0026gt; 修改(添加parent标签即可)\n\u0026lt;?xml version=\u0026#34;1.0\u0026#34; encoding=\u0026#34;UTF-8\u0026#34;?\u0026gt; \u0026lt;project xmlns=\u0026#34;http://maven.apache.org/POM/4.0.0\u0026#34; xmlns:xsi=\u0026#34;http://www.w3.org/2001/XMLSchema-instance\u0026#34; xsi:schemaLocation=\u0026#34;http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd\u0026#34;\u0026gt; \u0026lt;parent\u0026gt; \u0026lt;artifactId\u0026gt;001-maven-parent\u0026lt;/artifactId\u0026gt; \u0026lt;groupId\u0026gt;com.bjpowernode.maven\u0026lt;/groupId\u0026gt; \u0026lt;version\u0026gt;1.0.0\u0026lt;/version\u0026gt; \u0026lt;relativePath\u0026gt;../001-maven-parent/pom.xml\u0026lt;/relativePath\u0026gt; \u0026lt;/parent\u0026gt; \u0026lt;modelVersion\u0026gt;4.0.0\u0026lt;/modelVersion\u0026gt; \u0026lt;artifactId\u0026gt;005-maven-java\u0026lt;/artifactId\u0026gt; \u0026lt;/project\u0026gt; 注意 子模块继承父工程所有依赖 # 比如在父工程添加这块依赖\n\u0026lt;dependencies\u0026gt; \u0026lt;dependency\u0026gt; \u0026lt;groupId\u0026gt;junit\u0026lt;/groupId\u0026gt; \u0026lt;artifactId\u0026gt;junit\u0026lt;/artifactId\u0026gt; \u0026lt;version\u0026gt;4.12\u0026lt;/version\u0026gt; \u0026lt;/dependency\u0026gt; \u0026lt;dependency\u0026gt; \u0026lt;groupId\u0026gt;mysql\u0026lt;/groupId\u0026gt; \u0026lt;artifactId\u0026gt;mysql-connector-java\u0026lt;/artifactId\u0026gt; \u0026lt;version\u0026gt;5.1.46\u0026lt;/version\u0026gt; \u0026lt;/dependency\u0026gt; \u0026lt;dependency\u0026gt; \u0026lt;groupId\u0026gt;com.alibaba\u0026lt;/groupId\u0026gt; \u0026lt;artifactId\u0026gt;dubbo\u0026lt;/artifactId\u0026gt; \u0026lt;version\u0026gt;2.5.3\u0026lt;/version\u0026gt; \u0026lt;/dependency\u0026gt; \u0026lt;/dependencies\u0026gt; 如下 父工程添加的依赖,所有子模块会无条件继承\n父工程管理依赖 # 依赖冗余的问题 加强管理\n\u0026lt;!--加强管理--\u0026gt; \u0026lt;dependencyManagement\u0026gt; \u0026lt;dependencies\u0026gt; \u0026lt;dependency\u0026gt; \u0026lt;groupId\u0026gt;junit\u0026lt;/groupId\u0026gt; \u0026lt;artifactId\u0026gt;junit\u0026lt;/artifactId\u0026gt; \u0026lt;version\u0026gt;4.12\u0026lt;/version\u0026gt; \u0026lt;/dependency\u0026gt; \u0026lt;dependency\u0026gt; \u0026lt;groupId\u0026gt;mysql\u0026lt;/groupId\u0026gt; \u0026lt;artifactId\u0026gt;mysql-connector-java\u0026lt;/artifactId\u0026gt; \u0026lt;version\u0026gt;5.1.46\u0026lt;/version\u0026gt; \u0026lt;/dependency\u0026gt; \u0026lt;dependency\u0026gt; \u0026lt;groupId\u0026gt;com.alibaba\u0026lt;/groupId\u0026gt; \u0026lt;artifactId\u0026gt;dubbo\u0026lt;/artifactId\u0026gt; \u0026lt;version\u0026gt;2.5.3\u0026lt;/version\u0026gt; \u0026lt;/dependency\u0026gt; \u0026lt;/dependencies\u0026gt; \u0026lt;/dependencyManagement\u0026gt; 结果,依赖都没有了 子工程声明式继承父工程依赖 # 比如002-maven-java(子模块,但又是004的父工程)需要mysql\n\u0026lt;dependencies\u0026gt; \u0026lt;dependency\u0026gt; \u0026lt;groupId\u0026gt;mysql\u0026lt;/groupId\u0026gt; \u0026lt;artifactId\u0026gt;mysql-connector-java\u0026lt;/artifactId\u0026gt; \u0026lt;/dependency\u0026gt; \u0026lt;/dependencies\u0026gt; 效果 子模块依赖的版本号继承父工程依赖的版本号 如果子模块指定以来的版本号,那就不会继承父工程依赖的版本号 父工程管理依赖版本号 # 使用properties变量\n\u0026lt;properties\u0026gt; \u0026lt;!--自定义标签名称--\u0026gt; \u0026lt;!--约定:通常管理依赖版本号的标签名:项目名称-字段version, 项目名称.字段version--\u0026gt; \u0026lt;junit-version\u0026gt;4.12\u0026lt;/junit-version\u0026gt; \u0026lt;mysql-connector-java-version\u0026gt;5.1.46\u0026lt;/mysql-connector-java-version\u0026gt; \u0026lt;dubbo-version\u0026gt;2.5.3\u0026lt;/dubbo-version\u0026gt; \u0026lt;/properties\u0026gt; \u0026lt;!--加强管理--\u0026gt; \u0026lt;dependencyManagement\u0026gt; \u0026lt;dependencies\u0026gt; \u0026lt;dependency\u0026gt; \u0026lt;groupId\u0026gt;junit\u0026lt;/groupId\u0026gt; \u0026lt;artifactId\u0026gt;junit\u0026lt;/artifactId\u0026gt; \u0026lt;version\u0026gt;${junit-version}\u0026lt;/version\u0026gt; \u0026lt;/dependency\u0026gt; \u0026lt;dependency\u0026gt; \u0026lt;groupId\u0026gt;mysql\u0026lt;/groupId\u0026gt; \u0026lt;artifactId\u0026gt;mysql-connector-java\u0026lt;/artifactId\u0026gt; \u0026lt;version\u0026gt;${mysql-connector-java-version}\u0026lt;/version\u0026gt; \u0026lt;/dependency\u0026gt; \u0026lt;dependency\u0026gt; \u0026lt;groupId\u0026gt;com.alibaba\u0026lt;/groupId\u0026gt; \u0026lt;artifactId\u0026gt;dubbo\u0026lt;/artifactId\u0026gt; \u0026lt;version\u0026gt;${dubbo-version}\u0026lt;/version\u0026gt; \u0026lt;/dependency\u0026gt; \u0026lt;/dependencies\u0026gt; \u0026lt;/dependencyManagement\u0026gt; 回顾第1种实现方式 # 父工程的要求 子工程的添加 子工程改为父工程 子工程和父工程是平级的 父工程加强管理 \u0026lt;dependencyManagement\u0026gt;\u0026lt;/\u0026lt;dependencyManagement\u0026gt; 注意,第一种方法父工程的pom.xml中,这个也应该是必须的 第2种方式 # 创建父工程 # 最顶层创建一个工程(父工程) pom文件(未处理)\n\u0026lt;?xml version=\u0026#34;1.0\u0026#34; encoding=\u0026#34;UTF-8\u0026#34;?\u0026gt; \u0026lt;project xmlns=\u0026#34;http://maven.apache.org/POM/4.0.0\u0026#34; xmlns:xsi=\u0026#34;http://www.w3.org/2001/XMLSchema-instance\u0026#34; xsi:schemaLocation=\u0026#34;http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd\u0026#34;\u0026gt; \u0026lt;modelVersion\u0026gt;4.0.0\u0026lt;/modelVersion\u0026gt; \u0026lt;groupId\u0026gt;com.bjpowernode.maven\u0026lt;/groupId\u0026gt; \u0026lt;artifactId\u0026gt;maven-parent\u0026lt;/artifactId\u0026gt; \u0026lt;version\u0026gt;1.0.0\u0026lt;/version\u0026gt; \u0026lt;packaging\u0026gt;war\u0026lt;/packaging\u0026gt; \u0026lt;/project\u0026gt; 目录结构(未处理) 处理后 pom.xml\n\u0026lt;?xml version=\u0026#34;1.0\u0026#34; encoding=\u0026#34;UTF-8\u0026#34;?\u0026gt; \u0026lt;project xmlns=\u0026#34;http://maven.apache.org/POM/4.0.0\u0026#34; xmlns:xsi=\u0026#34;http://www.w3.org/2001/XMLSchema-instance\u0026#34; xsi:schemaLocation=\u0026#34;http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd\u0026#34;\u0026gt; \u0026lt;modelVersion\u0026gt;4.0.0\u0026lt;/modelVersion\u0026gt; \u0026lt;groupId\u0026gt;com.bjpowernode.maven\u0026lt;/groupId\u0026gt; \u0026lt;artifactId\u0026gt;maven-parent\u0026lt;/artifactId\u0026gt; \u0026lt;version\u0026gt;1.0.0\u0026lt;/version\u0026gt; \u0026lt;packaging\u0026gt;pom\u0026lt;/packaging\u0026gt; \u0026lt;!-- 1.packaging标签文本内容必须设置为pom 2.删除src目录 --\u0026gt; \u0026lt;/project\u0026gt; 结构 创建子工程 # 子工程的pom.xml\n\u0026lt;?xml version=\u0026#34;1.0\u0026#34; encoding=\u0026#34;UTF-8\u0026#34;?\u0026gt; \u0026lt;project xmlns=\u0026#34;http://maven.apache.org/POM/4.0.0\u0026#34; xmlns:xsi=\u0026#34;http://www.w3.org/2001/XMLSchema-instance\u0026#34; xsi:schemaLocation=\u0026#34;http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd\u0026#34;\u0026gt; \u0026lt;!--指向父工程--\u0026gt; \u0026lt;parent\u0026gt; \u0026lt;artifactId\u0026gt;maven-parent\u0026lt;/artifactId\u0026gt; \u0026lt;groupId\u0026gt;com.bjpowernode.maven\u0026lt;/groupId\u0026gt; \u0026lt;version\u0026gt;1.0.0\u0026lt;/version\u0026gt; \u0026lt;!--注意,这里不需要找pom.xml,因为该子工程和父工程的pom.xml同级--\u0026gt; \u0026lt;/parent\u0026gt; \u0026lt;modelVersion\u0026gt;4.0.0\u0026lt;/modelVersion\u0026gt; \u0026lt;artifactId\u0026gt;maven-java-001\u0026lt;/artifactId\u0026gt; \u0026lt;/project\u0026gt; 父工程的pom.xml\n\u0026lt;?xml version=\u0026#34;1.0\u0026#34; encoding=\u0026#34;UTF-8\u0026#34;?\u0026gt; \u0026lt;project xmlns=\u0026#34;http://maven.apache.org/POM/4.0.0\u0026#34; xmlns:xsi=\u0026#34;http://www.w3.org/2001/XMLSchema-instance\u0026#34; xsi:schemaLocation=\u0026#34;http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd\u0026#34;\u0026gt; \u0026lt;modelVersion\u0026gt;4.0.0\u0026lt;/modelVersion\u0026gt; \u0026lt;groupId\u0026gt;com.bjpowernode.maven\u0026lt;/groupId\u0026gt; \u0026lt;artifactId\u0026gt;maven-parent\u0026lt;/artifactId\u0026gt; \u0026lt;version\u0026gt;1.0.0\u0026lt;/version\u0026gt; \u0026lt;!--父工程包含的所有子模块--\u0026gt; \u0026lt;modules\u0026gt; \u0026lt;module\u0026gt;maven-java-001\u0026lt;/module\u0026gt; \u0026lt;/modules\u0026gt; \u0026lt;packaging\u0026gt;pom\u0026lt;/packaging\u0026gt; \u0026lt;!-- 1.packaging标签文本内容必须设置为pom 2.删除src目录 --\u0026gt; \u0026lt;/project\u0026gt; 第二个子模块\n\u0026lt;?xml version=\u0026#34;1.0\u0026#34; encoding=\u0026#34;UTF-8\u0026#34;?\u0026gt; \u0026lt;project xmlns=\u0026#34;http://maven.apache.org/POM/4.0.0\u0026#34; xmlns:xsi=\u0026#34;http://www.w3.org/2001/XMLSchema-instance\u0026#34; xsi:schemaLocation=\u0026#34;http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd\u0026#34;\u0026gt; \u0026lt;parent\u0026gt; \u0026lt;artifactId\u0026gt;maven-parent\u0026lt;/artifactId\u0026gt; \u0026lt;groupId\u0026gt;com.bjpowernode.maven\u0026lt;/groupId\u0026gt; \u0026lt;version\u0026gt;1.0.0\u0026lt;/version\u0026gt; \u0026lt;/parent\u0026gt; \u0026lt;modelVersion\u0026gt;4.0.0\u0026lt;/modelVersion\u0026gt; \u0026lt;artifactId\u0026gt;maven-web-001\u0026lt;/artifactId\u0026gt; \u0026lt;packaging\u0026gt;war\u0026lt;/packaging\u0026gt; \u0026lt;/project\u0026gt; 父工程pom.xml的变化 创建子工程的子工程 # 父工程必须遵循\npackaging标签文本内容设置为pom 删除src目录 创建子工程 maven-java-001的pom.xml查看\n\u0026lt;?xml version=\u0026#34;1.0\u0026#34; encoding=\u0026#34;UTF-8\u0026#34;?\u0026gt; \u0026lt;project xmlns=\u0026#34;http://maven.apache.org/POM/4.0.0\u0026#34; xmlns:xsi=\u0026#34;http://www.w3.org/2001/XMLSchema-instance\u0026#34; xsi:schemaLocation=\u0026#34;http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd\u0026#34;\u0026gt; \u0026lt;!--指向父工程--\u0026gt; \u0026lt;parent\u0026gt; \u0026lt;artifactId\u0026gt;maven-parent\u0026lt;/artifactId\u0026gt; \u0026lt;groupId\u0026gt;com.bjpowernode.maven\u0026lt;/groupId\u0026gt; \u0026lt;version\u0026gt;1.0.0\u0026lt;/version\u0026gt; \u0026lt;!--注意,这里不需要找pom.xml,因为该子工程和父工程的pom.xml同级--\u0026gt; \u0026lt;/parent\u0026gt; \u0026lt;modelVersion\u0026gt;4.0.0\u0026lt;/modelVersion\u0026gt; \u0026lt;artifactId\u0026gt;maven-java-001\u0026lt;/artifactId\u0026gt; \u0026lt;packaging\u0026gt;pom\u0026lt;/packaging\u0026gt; \u0026lt;modules\u0026gt; \u0026lt;module\u0026gt;maven-java-0101\u0026lt;/module\u0026gt; \u0026lt;/modules\u0026gt; \u0026lt;/project\u0026gt; 子模块的pom.xml\n\u0026lt;?xml version=\u0026#34;1.0\u0026#34; encoding=\u0026#34;UTF-8\u0026#34;?\u0026gt; \u0026lt;project xmlns=\u0026#34;http://maven.apache.org/POM/4.0.0\u0026#34; xmlns:xsi=\u0026#34;http://www.w3.org/2001/XMLSchema-instance\u0026#34; xsi:schemaLocation=\u0026#34;http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd\u0026#34;\u0026gt; \u0026lt;parent\u0026gt; \u0026lt;artifactId\u0026gt;maven-java-001\u0026lt;/artifactId\u0026gt; \u0026lt;groupId\u0026gt;com.bjpowernode.maven\u0026lt;/groupId\u0026gt; \u0026lt;version\u0026gt;1.0.0\u0026lt;/version\u0026gt; \u0026lt;/parent\u0026gt; \u0026lt;modelVersion\u0026gt;4.0.0\u0026lt;/modelVersion\u0026gt; \u0026lt;artifactId\u0026gt;maven-java-0101\u0026lt;/artifactId\u0026gt; \u0026lt;/project\u0026gt; 父工程管理依赖 # 父工程的pom文件 子模块也一起继承了 父工程管理所有依赖 如果子工程需要,则使用声明式依赖 也可以自己指定版本号 父工程管理依赖的版本号 # 使用properties管理版本号,和第一种方式一样 子工程继承父工程编译插件 # 修改之后,这里为了看效果,改成1.6\n\u0026lt;build\u0026gt; \u0026lt;plugins\u0026gt; \u0026lt;plugin\u0026gt; \u0026lt;groupId\u0026gt;org.apache.maven.plugins\u0026lt;/groupId\u0026gt; \u0026lt;artifactId\u0026gt;maven-compiler-plugin\u0026lt;/artifactId\u0026gt; \u0026lt;version\u0026gt;3.8.1\u0026lt;/version\u0026gt; \u0026lt;configuration\u0026gt; \u0026lt;source\u0026gt;1.6\u0026lt;/source\u0026gt; \u0026lt;target\u0026gt;1.6\u0026lt;/target\u0026gt; \u0026lt;/configuration\u0026gt; \u0026lt;/plugin\u0026gt; \u0026lt;/plugins\u0026gt; \u0026lt;/build\u0026gt; 第3种方式 # 前面两种混合使用\n先创建一个空项目 然后假设有三个父工程 然后每个父工程又都有子模块 "},{"id":367,"href":"/zh/docs/technology/Maven/base_dljd_/31-43/","title":"31-43 maven基础_动力节点","section":"基础(动力节点)_","content":" idea中设置maven # 和idea集成maven 创建普通的j2se项目 # 使用idea创建空白项目 新建一个module 使用模板创建普通java项目 输入gav 设置maven信息 标准的maven工程 与创建网站有关,删掉即可\n\u0026lt;?xml version=\u0026#34;1.0\u0026#34; encoding=\u0026#34;UTF-8\u0026#34;?\u0026gt; \u0026lt;project xmlns=\u0026#34;http://maven.apache.org/POM/4.0.0\u0026#34; xmlns:xsi=\u0026#34;http://www.w3.org/2001/XMLSchema-instance\u0026#34; xsi:schemaLocation=\u0026#34;http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd\u0026#34;\u0026gt; \u0026lt;modelVersion\u0026gt;4.0.0\u0026lt;/modelVersion\u0026gt; \u0026lt;groupId\u0026gt;com.bjpowernode\u0026lt;/groupId\u0026gt; \u0026lt;artifactId\u0026gt;ch01-maven-j2se\u0026lt;/artifactId\u0026gt; \u0026lt;version\u0026gt;1.0\u0026lt;/version\u0026gt; \u0026lt;!--设置网站,注释掉即可--\u0026gt; \u0026lt;!-- \u0026lt;name\u0026gt;ch01-maven-j2se\u0026lt;/name\u0026gt; \u0026lt;!– FIXME change it to the project\u0026#39;s website –\u0026gt; \u0026lt;url\u0026gt;http://www.example.com\u0026lt;/url\u0026gt;--\u0026gt; \u0026lt;properties\u0026gt; \u0026lt;!--maven常用设置--\u0026gt; \u0026lt;project.build.sourceEncoding\u0026gt;UTF-8\u0026lt;/project.build.sourceEncoding\u0026gt; \u0026lt;maven.compiler.source\u0026gt;1.8\u0026lt;/maven.compiler.source\u0026gt; \u0026lt;maven.compiler.target\u0026gt;1.8\u0026lt;/maven.compiler.target\u0026gt; \u0026lt;/properties\u0026gt; \u0026lt;dependencies\u0026gt; \u0026lt;dependency\u0026gt;\u0026lt;!--单元测试--\u0026gt; \u0026lt;groupId\u0026gt;junit\u0026lt;/groupId\u0026gt; \u0026lt;artifactId\u0026gt;junit\u0026lt;/artifactId\u0026gt; \u0026lt;version\u0026gt;4.11\u0026lt;/version\u0026gt; \u0026lt;scope\u0026gt;test\u0026lt;/scope\u0026gt; \u0026lt;/dependency\u0026gt; \u0026lt;/dependencies\u0026gt; \u0026lt;build\u0026gt; \u0026lt;!--插件版本的配置,无特殊指定则删除--\u0026gt; \u0026lt;pluginManagement\u0026gt;\u0026lt;!-- lock down plugins versions to avoid using Maven defaults (may be moved to parent pom) --\u0026gt; \u0026lt;plugins\u0026gt; \u0026lt;!-- clean lifecycle, see https://maven.apache.org/ref/current/maven-core/lifecycles.html#clean_Lifecycle --\u0026gt; \u0026lt;plugin\u0026gt; \u0026lt;artifactId\u0026gt;maven-clean-plugin\u0026lt;/artifactId\u0026gt; \u0026lt;version\u0026gt;3.1.0\u0026lt;/version\u0026gt; \u0026lt;/plugin\u0026gt; \u0026lt;!-- default lifecycle, jar packaging: see https://maven.apache.org/ref/current/maven-core/default-bindings.html#Plugin_bindings_for_jar_packaging --\u0026gt; \u0026lt;plugin\u0026gt; \u0026lt;artifactId\u0026gt;maven-resources-plugin\u0026lt;/artifactId\u0026gt; \u0026lt;version\u0026gt;3.0.2\u0026lt;/version\u0026gt; \u0026lt;/plugin\u0026gt; \u0026lt;plugin\u0026gt; \u0026lt;artifactId\u0026gt;maven-compiler-plugin\u0026lt;/artifactId\u0026gt; \u0026lt;version\u0026gt;3.8.0\u0026lt;/version\u0026gt; \u0026lt;/plugin\u0026gt; \u0026lt;plugin\u0026gt; \u0026lt;artifactId\u0026gt;maven-surefire-plugin\u0026lt;/artifactId\u0026gt; \u0026lt;version\u0026gt;2.22.1\u0026lt;/version\u0026gt; \u0026lt;/plugin\u0026gt; \u0026lt;plugin\u0026gt; \u0026lt;artifactId\u0026gt;maven-jar-plugin\u0026lt;/artifactId\u0026gt; \u0026lt;version\u0026gt;3.0.2\u0026lt;/version\u0026gt; \u0026lt;/plugin\u0026gt; \u0026lt;plugin\u0026gt; \u0026lt;artifactId\u0026gt;maven-install-plugin\u0026lt;/artifactId\u0026gt; \u0026lt;version\u0026gt;2.5.2\u0026lt;/version\u0026gt; \u0026lt;/plugin\u0026gt; \u0026lt;plugin\u0026gt; \u0026lt;artifactId\u0026gt;maven-deploy-plugin\u0026lt;/artifactId\u0026gt; \u0026lt;version\u0026gt;2.8.2\u0026lt;/version\u0026gt; \u0026lt;/plugin\u0026gt; \u0026lt;!-- site lifecycle, see https://maven.apache.org/ref/current/maven-core/lifecycles.html#site_Lifecycle --\u0026gt; \u0026lt;plugin\u0026gt; \u0026lt;artifactId\u0026gt;maven-site-plugin\u0026lt;/artifactId\u0026gt; \u0026lt;version\u0026gt;3.7.1\u0026lt;/version\u0026gt; \u0026lt;/plugin\u0026gt; \u0026lt;plugin\u0026gt; \u0026lt;artifactId\u0026gt;maven-project-info-reports-plugin\u0026lt;/artifactId\u0026gt; \u0026lt;version\u0026gt;3.0.0\u0026lt;/version\u0026gt; \u0026lt;/plugin\u0026gt; \u0026lt;/plugins\u0026gt; \u0026lt;/pluginManagement\u0026gt; \u0026lt;/build\u0026gt; \u0026lt;/project\u0026gt; 单元测试 # 关于idea颜色 编写java程序\npackage com.bjpowernode; public class HelloMaven { public int addNumber(int n1,int n2){ return n1+n2; } public static void main(String[] args) { HelloMaven helloMaven=new HelloMaven(); int res=helloMaven.addNumber(10,20); System.out.println(\u0026#34;res = \u0026#34;+res); } } 测试使用 idea中maven工具窗口 # Maven生成的目录 使用mvn clean进行清理\nλ mvn clean [INFO] Scanning for projects... [INFO] [INFO] ------------------\u0026lt; com.bjpowernode:ch01-maven-j2se \u0026gt;------------------- [INFO] Building ch01-maven-j2se 1.0 [INFO] --------------------------------[ jar ]--------------------------------- [INFO] [INFO] --- maven-clean-plugin:2.5:clean (default-clean) @ ch01-maven-j2se --- [INFO] Deleting D:\\Users\\ly\\Documents\\git\\mavenwork\\04-project\\ch01-maven-j2se\\target [INFO] ------------------------------------------------------------------------ [INFO] BUILD SUCCESS [INFO] ------------------------------------------------------------------------ [INFO] Total time: 0.438 s [INFO] Finished at: 2022-07-13T23:39:03+08:00 [INFO] ------------------------------------------------------------------------ 窗口 单元测试 打包 install安装 其他 重新更新依赖项 创建web项目加入servlet依赖 # 结构 创建java文件夹和资源文件夹 pom文件\n\u0026lt;?xml version=\u0026#34;1.0\u0026#34; encoding=\u0026#34;UTF-8\u0026#34;?\u0026gt; \u0026lt;project xmlns=\u0026#34;http://maven.apache.org/POM/4.0.0\u0026#34; xmlns:xsi=\u0026#34;http://www.w3.org/2001/XMLSchema-instance\u0026#34; xsi:schemaLocation=\u0026#34;http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd\u0026#34;\u0026gt; \u0026lt;modelVersion\u0026gt;4.0.0\u0026lt;/modelVersion\u0026gt; \u0026lt;groupId\u0026gt;com.bjpowernode\u0026lt;/groupId\u0026gt; \u0026lt;artifactId\u0026gt;ch02-maven-web\u0026lt;/artifactId\u0026gt; \u0026lt;version\u0026gt;1.0\u0026lt;/version\u0026gt; \u0026lt;packaging\u0026gt;war\u0026lt;/packaging\u0026gt; \u0026lt;name\u0026gt;ch02-maven-web Maven Webapp\u0026lt;/name\u0026gt; \u0026lt;!-- FIXME change it to the project\u0026#39;s website --\u0026gt; \u0026lt;url\u0026gt;http://www.example.com\u0026lt;/url\u0026gt; \u0026lt;properties\u0026gt; \u0026lt;project.build.sourceEncoding\u0026gt;UTF-8\u0026lt;/project.build.sourceEncoding\u0026gt; \u0026lt;maven.compiler.source\u0026gt;1.8\u0026lt;/maven.compiler.source\u0026gt; \u0026lt;maven.compiler.target\u0026gt;1.8\u0026lt;/maven.compiler.target\u0026gt; \u0026lt;/properties\u0026gt; \u0026lt;dependencies\u0026gt; \u0026lt;!--servlet依赖--\u0026gt; \u0026lt;!-- https://mvnrepository.com/artifact/javax.servlet/javax.servlet-api --\u0026gt; \u0026lt;dependency\u0026gt; \u0026lt;groupId\u0026gt;javax.servlet\u0026lt;/groupId\u0026gt; \u0026lt;artifactId\u0026gt;javax.servlet-api\u0026lt;/artifactId\u0026gt; \u0026lt;version\u0026gt;4.0.1\u0026lt;/version\u0026gt; \u0026lt;scope\u0026gt;provided\u0026lt;/scope\u0026gt; \u0026lt;/dependency\u0026gt; \u0026lt;!--jsp依赖--\u0026gt; \u0026lt;!-- https://mvnrepository.com/artifact/javax.servlet.jsp/javax.servlet.jsp-api --\u0026gt; \u0026lt;dependency\u0026gt; \u0026lt;groupId\u0026gt;javax.servlet.jsp\u0026lt;/groupId\u0026gt; \u0026lt;artifactId\u0026gt;javax.servlet.jsp-api\u0026lt;/artifactId\u0026gt; \u0026lt;version\u0026gt;2.3.3\u0026lt;/version\u0026gt; \u0026lt;scope\u0026gt;provided\u0026lt;/scope\u0026gt; \u0026lt;/dependency\u0026gt; \u0026lt;!--junit--\u0026gt; \u0026lt;dependency\u0026gt; \u0026lt;groupId\u0026gt;junit\u0026lt;/groupId\u0026gt; \u0026lt;artifactId\u0026gt;junit\u0026lt;/artifactId\u0026gt; \u0026lt;version\u0026gt;4.11\u0026lt;/version\u0026gt; \u0026lt;scope\u0026gt;test\u0026lt;/scope\u0026gt; \u0026lt;/dependency\u0026gt; \u0026lt;/dependencies\u0026gt; \u0026lt;/project\u0026gt; 和上面进行对比 创建servlet # 创建完之后\n代码\npackage com.bjpowernode.controller; import javax.servlet.*; import javax.servlet.http.*; import java.io.IOException; public class HelloServlet extends HttpServlet { @Override protected void doGet(HttpServletRequest request, HttpServletResponse response) throws ServletException, IOException { } @Override protected void doPost(HttpServletRequest request, HttpServletResponse response) throws ServletException, IOException { } } web.xml\n\u0026lt;!DOCTYPE web-app PUBLIC \u0026#34;-//Sun Microsystems, Inc.//DTD Web Application 2.3//EN\u0026#34; \u0026#34;http://java.sun.com/dtd/web-app_2_3.dtd\u0026#34; \u0026gt; \u0026lt;web-app\u0026gt; \u0026lt;display-name\u0026gt;Archetype Created Web Application\u0026lt;/display-name\u0026gt; \u0026lt;servlet\u0026gt; \u0026lt;servlet-name\u0026gt;HelloServlet\u0026lt;/servlet-name\u0026gt; \u0026lt;servlet-class\u0026gt;com.bjpowernode.controller.HelloServlet\u0026lt;/servlet-class\u0026gt; \u0026lt;/servlet\u0026gt; \u0026lt;/web-app\u0026gt; 添加mapping\n\u0026lt;!DOCTYPE web-app PUBLIC \u0026#34;-//Sun Microsystems, Inc.//DTD Web Application 2.3//EN\u0026#34; \u0026#34;http://java.sun.com/dtd/web-app_2_3.dtd\u0026#34; \u0026gt; \u0026lt;web-app\u0026gt; \u0026lt;display-name\u0026gt;Archetype Created Web Application\u0026lt;/display-name\u0026gt; \u0026lt;servlet\u0026gt; \u0026lt;servlet-name\u0026gt;HelloServlet\u0026lt;/servlet-name\u0026gt; \u0026lt;servlet-class\u0026gt;com.bjpowernode.controller.HelloServlet\u0026lt;/servlet-class\u0026gt; \u0026lt;/servlet\u0026gt; \u0026lt;servlet-mapping\u0026gt; \u0026lt;servlet-name\u0026gt;HelloServlet\u0026lt;/servlet-name\u0026gt; \u0026lt;url-pattern\u0026gt;/hello\u0026lt;/url-pattern\u0026gt; \u0026lt;/servlet-mapping\u0026gt; \u0026lt;/web-app\u0026gt; 添加jsp\n\u0026lt;%-- Created by IntelliJ IDEA. User: ly Date: 2022/7/16 Time: 18:10 To change this template use File | Settings | File Templates. --%\u0026gt; \u0026lt;%@ page contentType=\u0026#34;text/html;charset=UTF-8\u0026#34; language=\u0026#34;java\u0026#34; %\u0026gt; \u0026lt;html\u0026gt; \u0026lt;head\u0026gt; \u0026lt;title\u0026gt;index\u0026lt;/title\u0026gt; \u0026lt;/head\u0026gt; \u0026lt;body\u0026gt; \u0026lt;a href=\u0026#34;hello\u0026#34; \u0026gt;访问\u0026lt;/a\u0026gt; \u0026lt;/body\u0026gt; \u0026lt;/html\u0026gt; 设置转发\npackage com.bjpowernode.controller; import javax.servlet.*; import javax.servlet.http.*; import java.io.IOException; public class HelloServlet extends HttpServlet { @Override protected void doGet(HttpServletRequest request, HttpServletResponse response) throws ServletException, IOException { System.out.println(\u0026#34;收到请求了\u0026#34;); //转发到show request.getRequestDispatcher(\u0026#34;/show.jsp\u0026#34;) .forward(request,response); } @Override protected void doPost(HttpServletRequest request, HttpServletResponse response) throws ServletException, IOException { } } 设置tomcat并发布\nidea出现not found for the web module. 复习核心的概念 # 约定的目录结构 pom 项目对象模型,groupId,artifactId,version gav 仓库 本地仓库 \u0026hellip;../.m2/repository 远程仓库 生命周期,clean,compile,test-compile,test,package,install maven和idea集成 设置maven安装目录和配置文件 设置Runner,创建maven时速度快 使用模板创建 se和web 导入模块到idea # 导入02这个项目 结果 当磁盘中文件夹名字和项目名不一样时 如果导入后颜色不对,则需要右键 mark as scope依赖范围 # scope标签\n依赖范围:scope标签,这个依赖在项目构建的哪个阶段起作用\n值:compile,默认,参与构建项目的所有阶段; test:测试,在测试阶段使用,比如执行mvn test 会使用junit provided:提供者,项目在部署到服务器时,不需要提供这个依赖的jar,而是由服务器提供这个以来的jar包 打包时只有mysql war文件 给服务器,即放到tomcat的webapps中 启动tomcat之后,会自动解压 访问 自定义变量 # properties标签,常用设置 test报告 这种需要将文件夹删除,然后reimport 全局变量,比如依赖版本号 重复的问题 在properties里面定义即可 使用全局变量 ${变量名} 处理文件的默认规则 # 使用资源插件 例子 放置三个文件 进行四个操作,会生成资源文件(src/resources)拷贝到target/classes目录下 如果在java下的包中放资源文件 没有拷贝 即maven只处理src/main/java目录下的.java文件,把这些编译成class,拷贝到target/classes目录中,不处理其他文件 资源插件 # build下\n\u0026lt;build\u0026gt; \u0026lt;!--资源插件--\u0026gt; \u0026lt;resources\u0026gt; \u0026lt;resource\u0026gt; \u0026lt;!--所在的目录--\u0026gt; \u0026lt;directory\u0026gt;src/main/java\u0026lt;/directory\u0026gt; \u0026lt;!--包括properties及xml后缀文件--\u0026gt; \u0026lt;includes\u0026gt; \u0026lt;include\u0026gt;**/*.properties\u0026lt;/include\u0026gt; \u0026lt;include\u0026gt;**/*.xml\u0026lt;/include\u0026gt; \u0026lt;include\u0026gt;**/*.txt\u0026lt;/include\u0026gt; \u0026lt;include\u0026gt;**/*\u0026lt;/include\u0026gt; \u0026lt;/includes\u0026gt; \u0026lt;excludes\u0026gt; \u0026lt;exclude\u0026gt;**/*.java\u0026lt;/exclude\u0026gt; \u0026lt;/excludes\u0026gt; \u0026lt;!--不使用过滤器,*.xml已经起到过滤作用了--\u0026gt; \u0026lt;filtering\u0026gt;false\u0026lt;/filtering\u0026gt; \u0026lt;/resource\u0026gt; \u0026lt;/resources\u0026gt; \u0026lt;/resources\u0026gt; \u0026lt;/build\u0026gt; 结果 "},{"id":368,"href":"/zh/docs/technology/Maven/base_dljd_/17-30/","title":"17-30 maven基础_动力节点","section":"基础(动力节点)_","content":" 本地仓库的设置 # 远程仓库\u0026ndash;\u0026gt;本地仓库\nmaven仓库\n存放maven工具自己的jar包 第三方jar,比如mysql驱动 自己写的程序,可以打包为jar,存放到仓库 分类\n本地仓库(本机):位于自己计算机中,磁盘中某个目录\n默认位置 登录操作系统的账号目录/.m2/repository C:\\Users\\ly.m2\\repository\n可修改 比如放在d盘中\n英[rɪˈpɒzətri] D:\\software\\apache-maven-3.8.6\\repository 备份并编辑 改成左斜杠的方式\n\u0026lt;settings xmlns=\u0026#34;http://maven.apache.org/SETTINGS/1.2.0\u0026#34; xmlns:xsi=\u0026#34;http://www.w3.org/2001/XMLSchema-instance\u0026#34; xsi:schemaLocation=\u0026#34;http://maven.apache.org/SETTINGS/1.2.0 https://maven.apache.org/xsd/settings-1.2.0.xsd\u0026#34;\u0026gt; \u0026lt;!-- localRepository | The path to the local repository maven will use to store artifacts. | | Default: ${user.home}/.m2/repository \u0026lt;localRepository\u0026gt;/path/to/local/repo\u0026lt;/localRepository\u0026gt; --\u0026gt; \u0026lt;localRepository\u0026gt;D:/software/apache-maven-3.8.6/repository\u0026lt;/localRepository\u0026gt; 把之前user下的repository的文件都拷贝到 D:/software/apache-maven-3.8.6/repository 下 然后再对Hello项目进行编译 mvn compile 发现不会下载任何文件,且user下的repository也不会再进行下载\n下面的资源是从maven中下载,或者用maven打包的 pom.xml来说明某个项目需要怎么处理代码、项目结构\n\u0026lt;?xml version=\u0026#34;1.0\u0026#34; encoding=\u0026#34;UTF-8\u0026#34; ?\u0026gt; \u0026lt;project xmlns=\u0026#34;http://maven.apache.org/POM/4.0.0\u0026#34; xmlns:xsi=\u0026#34;http://www.w3.org/2001/XMLSchema-instance\u0026#34; xsi:schemaLocation=\u0026#34;http://maven.apache.org/POM/4.0.0 http://maven.apache.org/maven-v4_0_0.xsd\u0026#34;\u0026gt; \u0026lt;modelVersion\u0026gt;4.0.0\u0026lt;/modelVersion\u0026gt; \u0026lt;groupId\u0026gt;com.bjpowernode\u0026lt;/groupId\u0026gt; \u0026lt;artifactId\u0026gt;ch01-maven\u0026lt;/artifactId\u0026gt; \u0026lt;version\u0026gt;1.0-SNAPSHOT\u0026lt;/version\u0026gt; \u0026lt;packaging\u0026gt;jar\u0026lt;/packaging\u0026gt; \u0026lt;properties\u0026gt; \u0026lt;java.version\u0026gt;1.8\u0026lt;/java.version\u0026gt; \u0026lt;maven.compiler.source\u0026gt;1.8\u0026lt;/maven.compiler.source\u0026gt; \u0026lt;maven.compiler.target\u0026gt;1.8\u0026lt;/maven.compiler.target\u0026gt; \u0026lt;/properties\u0026gt; \u0026lt;dependencies\u0026gt; \u0026lt;dependency\u0026gt; \u0026lt;groupId\u0026gt;mysql\u0026lt;/groupId\u0026gt; \u0026lt;artifactId\u0026gt;mysql-connector-java\u0026lt;/artifactId\u0026gt; \u0026lt;version\u0026gt;5.1.9\u0026lt;/version\u0026gt; \u0026lt;/dependency\u0026gt; \u0026lt;/dependencies\u0026gt; \u0026lt;/project\u0026gt; mvn命令需要在pom.xml所在的目录下执行 仓库的工作方式 # 生命周期插件命令 # 包括 清理(删除target文件,但是不处理已经install的jar)、编译(当前目录生成target目录,放置编译主程序之后生成的字节码)、测试(生成surefire-reports,保存测试结果)、报告、打包(打包主程序[编译、编译测试、测试,并按照pom.xml配置把主程序打包成jar包或war包])、安装(把本工程打包,并按照工程坐标保存到本地仓库中)、部署(打包,保存到本地仓库,并保存到私服中,且自动把项目部署到web容器中) 插件:要完成构建项目的各个阶段,要使用maven的命令,执行命令的功能,是通过插件完成的 插件就是jar,一些类 命令:执行maven功能,通过命令发出,比如mvn compile(编译时由相关的类来操作) junit使用 # 单元测试 junit:单元测试的工具,java中经常使用 单元,java中指的是方法,方法就是一个单元,方法是测试的最小单位\n作用,使用junit去测试方法是否完成了要求,开发人员自测\n使用单元测试\n加入junit的依赖(需要用他的类和方法)\n\u0026lt;dependency\u0026gt; \u0026lt;groupId\u0026gt;junit\u0026lt;/groupId\u0026gt; \u0026lt;artifactId\u0026gt;junit\u0026lt;/artifactId\u0026gt; \u0026lt;version\u0026gt;4.12\u0026gt; \u0026lt;scope\u0026gt;test\u0026lt;/scope\u0026gt; \u0026lt;/dependency\u0026gt; 在src/test/java目录中创建测试类文件,写测试代码\n测试类的定义,名称一般是Test+要测试的类名称 测试它的包名和要测试的类包名一样 在类中定义方法,要测试的代码 方法定义:public方法,没有返回值,名称自定义(建议Test+测试的方法名称) 方法没有参数 测试类中的方法,可以单独执行,测试类也可以单独执行 在该方法上面加入注解@Test 注意:mvn compile的时候,会下载3.8.2的jar包 创建测试类和测试方法 # package com.bjpowernode; //导入包 import org.junit.Assert; import org.junit.Test; public class TestHelloMaven{ //定义多个独立的测试方法,每个方法都是独立的 public void testAddNumber(){ System.out.println(\u0026#34;执行了测试方法testAddNumber\u0026#34;); HelloMaven hello=new HelloMaven(); int res=hello.addNumber(10,20); //把计算结果res交给junit判断 //期望值,实际值 Assert.assertEquals(30,res); } } 相关命令 # mvn clean ,清理,删除以前生成的数据(删除target目录) 插件及版本 maven-clean-plugin:2.5\nd:\\Users\\ly\\Documents\\git\\mavenwork\\Hello\u0026gt;mvn clean [INFO] Scanning for projects... [INFO] [INFO] ---------------------\u0026lt; com.bjpowernode:ch01-maven \u0026gt;--------------------- [INFO] Building ch01-maven 1.0-SNAPSHOT [INFO] --------------------------------[ jar ]--------------------------------- [INFO] [INFO] --- maven-clean-plugin:2.5:clean (default-clean) @ ch01-maven --- [INFO] Deleting d:\\Users\\ly\\Documents\\git\\mavenwork\\Hello\\target [INFO] ------------------------------------------------------------------------ [INFO] BUILD SUCCESS [INFO] ------------------------------------------------------------------------ [INFO] Total time: 0.354 s [INFO] Finished at: 2022-07-09T17:03:46+08:00 [INFO] ------------------------------------------------------------------------ 代码的编译 mvn compile:编译命令,把src/main/java 目录中的java代码编译为class文件 同时把class文件拷贝到target/classes目录,这个目录classes是存放类文件的根目录(也叫做类路径,classpath)\n编译后放到target\\classes中 插件:maven-compiler-plugin:3.1 编译代码 maven-resources-plugin:2.6:resources 资源插件,作用是把src/main/resources目录中的文件拷贝到target/classes 目录中\nλ mvn compile [INFO] Scanning for projects... [INFO] [INFO] ---------------------\u0026lt; com.bjpowernode:ch01-maven \u0026gt;--------------------- [INFO] Building ch01-maven 1.0-SNAPSHOT [INFO] --------------------------------[ jar ]--------------------------------- [INFO] [INFO] --- maven-resources-plugin:2.6:resources (default-resources) @ ch01-maven --- [WARNING] Using platform encoding (GBK actually) to copy filtered resources, i.e. build is platform dependent![INFO] Copying 1 resource [INFO] [INFO] --- maven-compiler-plugin:3.1:compile (default-compile) @ ch01-maven --- [INFO] Changes detected - recompiling the module! [WARNING] File encoding has not been set, using platform encoding GBK, i.e. build is platform dependent! [INFO] Compiling 1 source file to D:\\Users\\ly\\Documents\\git\\mavenwork\\Hello\\target\\classes [INFO] ------------------------------------------------------------------------ [INFO] BUILD SUCCESS [INFO] ------------------------------------------------------------------------ [INFO] Total time: 3.164 s [INFO] Finished at: 2022-07-09T17:20:30+08:00 [INFO] ------------------------------------------------------------------------ 测试resources插件 mvn test-compile:编译命令,编译src/test/java 目录中的源文件,把生成的class拷贝到target/test-classes目录中,同时把src/test/resources目录中的文件拷贝到test-classes目录 命令执行前 执行后 插件 maven-resources-plugin:2.6:resources maven-compiler-plugin:3.1:compile maven-resources-plugin:2.6:testResources maven-compiler-plugin:3.1:testCompile\nλ mvn test-compile [INFO] Scanning for projects... [INFO] [INFO] ---------------------\u0026lt; com.bjpowernode:ch01-maven \u0026gt;--------------------- [INFO] Building ch01-maven 1.0-SNAPSHOT [INFO] --------------------------------[ jar ]--------------------------------- Downloading from central: https://repo.maven.apache.org/maven2/junit/junit/4.12/junit-4.12.jar Downloading from central: https://repo.maven.apache.org/maven2/org/hamcrest/hamcrest-core/1.3/hamcrest-core-1.3.jar Downloaded from central: https://repo.maven.apache.org/maven2/org/hamcrest/hamcrest-core/1.3/hamcrest-core-1.3.jar (45 kB at 24 kB/s) Downloaded from central: https://repo.maven.apache.org/maven2/junit/junit/4.12/junit-4.12.jar (315 kB at 118 kB/s) [INFO] [INFO] --- maven-resources-plugin:2.6:resources (default-resources) @ ch01-maven --- [WARNING] Using platform encoding (GBK actually) to copy filtered resources, i.e. build is platform dependent![INFO] Copying 2 resources [INFO] [INFO] --- maven-compiler-plugin:3.1:compile (default-compile) @ ch01-maven --- [INFO] Nothing to compile - all classes are up to date [INFO] [INFO] --- maven-resources-plugin:2.6:testResources (default-testResources) @ ch01-maven --- [WARNING] Using platform encoding (GBK actually) to copy filtered resources, i.e. build is platform dependent![INFO] Copying 1 resource [INFO] [INFO] --- maven-compiler-plugin:3.1:testCompile (default-testCompile) @ ch01-maven --- [INFO] Changes detected - recompiling the module! [WARNING] File encoding has not been set, using platform encoding GBK, i.e. build is platform dependent! [INFO] Compiling 1 source file to D:\\Users\\ly\\Documents\\git\\mavenwork\\Hello\\target\\test-classes [WARNING] /D:/Users/ly/Documents/git/mavenwork/Hello/src/test/java/com/bjpowernode/TestHelloMaven.java:[2,7] 编码GBK的不可映射字符 [WARNING] /D:/Users/ly/Documents/git/mavenwork/Hello/src/test/java/com/bjpowernode/TestHelloMaven.java:[8,42] 编码GBK的不可映射字符 [WARNING] /D:/Users/ly/Documents/git/mavenwork/Hello/src/test/java/com/bjpowernode/TestHelloMaven.java:[14,29] 编码GBK的不可映射字符 [INFO] ------------------------------------------------------------------------ [INFO] BUILD SUCCESS [INFO] ------------------------------------------------------------------------ [INFO] Total time: 8.085 s [INFO] Finished at: 2022-07-09T17:28:14+08:00 [INFO] ------------------------------------------------------------------------ mvn test 测试命令,执行test-classes目录的程序,测试src/main/java目录中的主程序是否符合要求 注意,这里还是会用到编译插件和资源插件,从 T E S T S 开始测试 结果Results :\nTests run: 1, Failures: 0, Errors: 0, Skipped: 0 测试插件 maven-surefire-plugin:2.12.4\nλ mvn test [INFO] Scanning for projects... [INFO] [INFO] ---------------------\u0026lt; com.bjpowernode:ch01-maven \u0026gt;--------------------- [INFO] Building ch01-maven 1.0-SNAPSHOT [INFO] --------------------------------[ jar ]--------------------------------- [INFO] [INFO] --- maven-resources-plugin:2.6:resources (default-resources) @ ch01-maven --- [WARNING] Using platform encoding (GBK actually) to copy filtered resources, i.e. build is platform dependent![INFO] Copying 2 resources [INFO] [INFO] --- maven-compiler-plugin:3.1:compile (default-compile) @ ch01-maven --- [INFO] Nothing to compile - all classes are up to date [INFO] [INFO] --- maven-resources-plugin:2.6:testResources (default-testResources) @ ch01-maven --- [WARNING] Using platform encoding (GBK actually) to copy filtered resources, i.e. build is platform dependent![INFO] Copying 1 resource [INFO] [INFO] --- maven-compiler-plugin:3.1:testCompile (default-testCompile) @ ch01-maven --- [INFO] Changes detected - recompiling the module! [WARNING] File encoding has not been set, using platform encoding GBK, i.e. build is platform dependent! [INFO] Compiling 1 source file to D:\\Users\\ly\\Documents\\git\\mavenwork\\Hello\\target\\test-classes [INFO] [INFO] --- maven-surefire-plugin:2.12.4:test (default-test) @ ch01-maven --- [INFO] Surefire report directory: D:\\Users\\ly\\Documents\\git\\mavenwork\\Hello\\target\\surefire-reports ------------------------------------------------------- T E S T S ------------------------------------------------------- Running com.bjpowernode.TestHelloMaven 执行了测试方法testAddNumber hello maven -addNumber Tests run: 1, Failures: 0, Errors: 0, Skipped: 0, Time elapsed: 0.131 sec Results : Tests run: 1, Failures: 0, Errors: 0, Skipped: 0 [INFO] ------------------------------------------------------------------------ [INFO] BUILD SUCCESS [INFO] ------------------------------------------------------------------------ [INFO] Total time: 4.630 s [INFO] Finished at: 2022-07-09T17:32:49+08:00 [INFO] ------------------------------------------------------------------------ 测试报告 测试失败的情况 结果\nλ mvn test [INFO] Scanning for projects... [INFO] [INFO] ---------------------\u0026lt; com.bjpowernode:ch01-maven \u0026gt;--------------------- [INFO] Building ch01-maven 1.0-SNAPSHOT [INFO] --------------------------------[ jar ]--------------------------------- [INFO] [INFO] --- maven-resources-plugin:2.6:resources (default-resources) @ ch01-maven --- [WARNING] Using platform encoding (GBK actually) to copy filtered resources, i.e. build is platform dependent![INFO] Copying 2 resources [INFO] [INFO] --- maven-compiler-plugin:3.1:compile (default-compile) @ ch01-maven --- [INFO] Nothing to compile - all classes are up to date [INFO] [INFO] --- maven-resources-plugin:2.6:testResources (default-testResources) @ ch01-maven --- [WARNING] Using platform encoding (GBK actually) to copy filtered resources, i.e. build is platform dependent![INFO] Copying 1 resource [INFO] [INFO] --- maven-compiler-plugin:3.1:testCompile (default-testCompile) @ ch01-maven --- [INFO] Changes detected - recompiling the module! [WARNING] File encoding has not been set, using platform encoding GBK, i.e. build is platform dependent! [INFO] Compiling 1 source file to D:\\Users\\ly\\Documents\\git\\mavenwork\\Hello\\target\\test-classes [INFO] [INFO] --- maven-surefire-plugin:2.12.4:test (default-test) @ ch01-maven --- [INFO] Surefire report directory: D:\\Users\\ly\\Documents\\git\\mavenwork\\Hello\\target\\surefire-reports ------------------------------------------------------- T E S T S ------------------------------------------------------- Running com.bjpowernode.TestHelloMaven 执行了测试方法testAddNumber hello maven -addNumber Tests run: 1, Failures: 1, Errors: 0, Skipped: 0, Time elapsed: 0.217 sec \u0026lt;\u0026lt;\u0026lt; FAILURE! testAddNumber(com.bjpowernode.TestHelloMaven) Time elapsed: 0.043 sec \u0026lt;\u0026lt;\u0026lt; FAILURE! java.lang.AssertionError: expected:\u0026lt;60\u0026gt; but was:\u0026lt;30\u0026gt; at org.junit.Assert.fail(Assert.java:88) at org.junit.Assert.failNotEquals(Assert.java:834) at org.junit.Assert.assertEquals(Assert.java:645) at org.junit.Assert.assertEquals(Assert.java:631) at com.bjpowernode.TestHelloMaven.testAddNumber(TestHelloMaven.java:15) 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.junit.runners.model.FrameworkMethod$1.runReflectiveCall(FrameworkMethod.java:50) at org.junit.internal.runners.model.ReflectiveCallable.run(ReflectiveCallable.java:12) at org.junit.runners.model.FrameworkMethod.invokeExplosively(FrameworkMethod.java:47) at org.junit.internal.runners.statements.InvokeMethod.evaluate(InvokeMethod.java:17) at org.junit.runners.ParentRunner.runLeaf(ParentRunner.java:325) at org.junit.runners.BlockJUnit4ClassRunner.runChild(BlockJUnit4ClassRunner.java:78) at org.junit.runners.BlockJUnit4ClassRunner.runChild(BlockJUnit4ClassRunner.java:57) at org.junit.runners.ParentRunner$3.run(ParentRunner.java:290) at org.junit.runners.ParentRunner$1.schedule(ParentRunner.java:71) at org.junit.runners.ParentRunner.runChildren(ParentRunner.java:288) at org.junit.runners.ParentRunner.access$000(ParentRunner.java:58) at org.junit.runners.ParentRunner$2.evaluate(ParentRunner.java:268) at org.junit.runners.ParentRunner.run(ParentRunner.java:363) at org.apache.maven.surefire.junit4.JUnit4Provider.execute(JUnit4Provider.java:252) at org.apache.maven.surefire.junit4.JUnit4Provider.executeTestSet(JUnit4Provider.java:141) at org.apache.maven.surefire.junit4.JUnit4Provider.invoke(JUnit4Provider.java:112) 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.apache.maven.surefire.util.ReflectionUtils.invokeMethodWithArray(ReflectionUtils.java:189) at org.apache.maven.surefire.booter.ProviderFactory$ProviderProxy.invoke(ProviderFactory.java:165) at org.apache.maven.surefire.booter.ProviderFactory.invokeProvider(ProviderFactory.java:85) at org.apache.maven.surefire.booter.ForkedBooter.runSuitesInProcess(ForkedBooter.java:115) at org.apache.maven.surefire.booter.ForkedBooter.main(ForkedBooter.java:75) Results : Failed tests: testAddNumber(com.bjpowernode.TestHelloMaven): expected:\u0026lt;60\u0026gt; but was:\u0026lt;30\u0026gt; Tests run: 1, Failures: 1, Errors: 0, Skipped: 0 [INFO] ------------------------------------------------------------------------ [INFO] BUILD FAILURE [INFO] ------------------------------------------------------------------------ [INFO] Total time: 8.018 s [INFO] Finished at: 2022-07-09T17:35:38+08:00 [INFO] ------------------------------------------------------------------------ [ERROR] Failed to execute goal org.apache.maven.plugins:maven-surefire-plugin:2.12.4:test (default-test) on project ch01-maven: There are test failures. [ERROR] [ERROR] Please refer to D:\\Users\\ly\\Documents\\git\\mavenwork\\Hello\\target\\surefire-reports for the individual test results. [ERROR] -\u0026gt; [Help 1] [ERROR] [ERROR] To see the full stack trace of the errors, re-run Maven with the -e switch. [ERROR] Re-run Maven using the -X switch to enable full debug logging. [ERROR] [ERROR] For more information about the errors and possible solutions, please read the following articles: [ERROR] [Help 1] http://cwiki.apache.org/confluence/display/MAVEN/MojoFailureException mvn package 打包,作用是把项目中的资源class文件和配置文件,都放到一个压缩包中,默认压缩文件是jar类型,web应用是war类型,扩展名jar/war 这里进行了编译、测试、打包 [INFO] Building jar: D:\\Users\\ly\\Documents\\git\\mavenwork\\Hello\\target\\ch01-maven-1.0-SNAPSHOT.jar 打包插件 maven-jar-plugin:2.4:jar (default-jar) @ ch01-maven maven-jar-plugin:2.4用来执行打包,会生成jar扩展名文件\nλ mvn package [INFO] Scanning for projects... [INFO] [INFO] ---------------------\u0026lt; com.bjpowernode:ch01-maven \u0026gt;--------------------- [INFO] Building ch01-maven 1.0-SNAPSHOT [INFO] --------------------------------[ jar ]--------------------------------- [INFO] [INFO] --- maven-resources-plugin:2.6:resources (default-resources) @ ch01-maven --- [WARNING] Using platform encoding (GBK actually) to copy filtered resources, i.e. build is platform dependent! [INFO] Copying 2 resources [INFO] [INFO] --- maven-compiler-plugin:3.1:compile (default-compile) @ ch01-maven --- [INFO] Changes detected - recompiling the module! [WARNING] File encoding has not been set, using platform encoding GBK, i.e. build is platform dependent! [INFO] Compiling 1 source file to D:\\Users\\ly\\Documents\\git\\mavenwork\\Hello\\target\\classes [INFO] [INFO] --- maven-resources-plugin:2.6:testResources (default-testResources) @ ch01-maven --- [WARNING] Using platform encoding (GBK actually) to copy filtered resources, i.e. build is platform dependent! [INFO] Copying 1 resource [INFO] [INFO] --- maven-compiler-plugin:3.1:testCompile (default-testCompile) @ ch01-maven --- [INFO] Changes detected - recompiling the module! [WARNING] File encoding has not been set, using platform encoding GBK, i.e. build is platform dependent! [INFO] Compiling 1 source file to D:\\Users\\ly\\Documents\\git\\mavenwork\\Hello\\target\\test-classes [INFO] [INFO] --- maven-surefire-plugin:2.12.4:test (default-test) @ ch01-maven --- [INFO] Surefire report directory: D:\\Users\\ly\\Documents\\git\\mavenwork\\Hello\\target\\surefire-reports ------------------------------------------------------- T E S T S ------------------------------------------------------- Running com.bjpowernode.TestHelloMaven 执行了测试方法testAddNumber hello maven -addNumber Tests run: 1, Failures: 0, Errors: 0, Skipped: 0, Time elapsed: 0.135 sec Results : Tests run: 1, Failures: 0, Errors: 0, Skipped: 0 [INFO] [INFO] --- maven-jar-plugin:2.4:jar (default-jar) @ ch01-maven --- [INFO] Building jar: D:\\Users\\ly\\Documents\\git\\mavenwork\\Hello\\target\\ch01-maven-1.0-SNAPSHOT.jar [INFO] ------------------------------------------------------------------------ [INFO] BUILD SUCCESS [INFO] ------------------------------------------------------------------------ [INFO] Total time: 5.624 s [INFO] Finished at: 2022-07-09T17:40:44+08:00 [INFO] ------------------------------------------------------------------------ 生成ch01-maven-1.0-SNAPSHOT.jar 坐标\n\u0026lt;groupId\u0026gt;com.bjpowernode\u0026lt;/groupId\u0026gt; \u0026lt;artifactId\u0026gt;ch01-maven\u0026lt;/artifactId\u0026gt; \u0026lt;version\u0026gt;1.0-SNAPSHOT\u0026lt;/version\u0026gt; \u0026lt;packaging\u0026gt;jar\u0026lt;/packaging\u0026gt; 打包的文件名 artifactId-version.packaging 查看jar 打包的文件中,包括src/main目录中所有的生成的class文件和配置文件(resources下),和测试test无关\nmvn install 把生成的打包文件(jar)安装到maven仓库中 插件:maven-install-plugin-2.4\nλ mvn install [INFO] Scanning for projects... [INFO] [INFO] ---------------------\u0026lt; com.bjpowernode:ch01-maven \u0026gt;--------------------- [INFO] Building ch01-maven 1.0-SNAPSHOT [INFO] --------------------------------[ jar ]--------------------------------- [INFO] [INFO] --- maven-resources-plugin:2.6:resources (default-resources) @ ch01-maven --- [WARNING] Using platform encoding (GBK actually) to copy filtered resources, i.e. build is platform dependent! [INFO] Copying 2 resources [INFO] [INFO] --- maven-compiler-plugin:3.1:compile (default-compile) @ ch01-maven --- [INFO] Nothing to compile - all classes are up to date [INFO] [INFO] --- maven-resources-plugin:2.6:testResources (default-testResources) @ ch01-maven --- [WARNING] Using platform encoding (GBK actually) to copy filtered resources, i.e. build is platform dependent! [INFO] Copying 1 resource [INFO] [INFO] --- maven-compiler-plugin:3.1:testCompile (default-testCompile) @ ch01-maven --- [INFO] Nothing to compile - all classes are up to date [INFO] [INFO] --- maven-surefire-plugin:2.12.4:test (default-test) @ ch01-maven --- [INFO] Surefire report directory: D:\\Users\\ly\\Documents\\git\\mavenwork\\Hello\\target\\surefire-reports ------------------------------------------------------- T E S T S ------------------------------------------------------- Running com.bjpowernode.TestHelloMaven 执行了测试方法testAddNumber hello maven -addNumber Tests run: 1, Failures: 0, Errors: 0, Skipped: 0, Time elapsed: 0.162 sec Results : Tests run: 1, Failures: 0, Errors: 0, Skipped: 0 [INFO] [INFO] --- maven-jar-plugin:2.4:jar (default-jar) @ ch01-maven --- [INFO] [INFO] --- maven-install-plugin:2.4:install (default-install) @ ch01-maven --- [INFO] Installing D:\\Users\\ly\\Documents\\git\\mavenwork\\Hello\\target\\ch01-maven-1.0-SNAPSHOT.jar to D:\\software\\apache-maven-3.8.6\\repository\\com\\bjpowernode\\ch01-maven\\1.0-SNAPSHOT\\ch01-maven-1.0-SNAPSHOT.jar [INFO] Installing D:\\Users\\ly\\Documents\\git\\mavenwork\\Hello\\pom.xml to D:\\software\\apache-maven-3.8.6\\repository\\com\\bjpowernode\\ch01-maven\\1.0-SNAPSHOT\\ch01-maven-1.0-SNAPSHOT.pom [INFO] ------------------------------------------------------------------------ [INFO] BUILD SUCCESS [INFO] ------------------------------------------------------------------------ [INFO] Total time: 5.063 s [INFO] Finished at: 2022-07-09T17:48:43+08:00 [INFO] ------------------------------------------------------------------------ 如上,\nInstalling D:\\Users\\ly\\Documents\\git\\mavenwork\\Hello\\target\\ch01-maven-1.0-SNAPSHOT.jar to D:\\software\\apache-maven-3.8.6\\repository\\com\\bjpowernode\\ch01-maven\\1.0-SNAPSHOT\\ch01-maven-1.0-SNAPSHOT.jar 路径 com\\bjpowernode\\ch01-maven\\1.0-SNAPSHOT ,如下,跟坐标有关\n\u0026lt;groupId\u0026gt;com.bjpowernode\u0026lt;/groupId\u0026gt; \u0026lt;artifactId\u0026gt;ch01-maven\u0026lt;/artifactId\u0026gt; \u0026lt;version\u0026gt;1.0-SNAPSHOT\u0026lt;/version\u0026gt; \u0026lt;packaging\u0026gt;jar\u0026lt;/packaging\u0026gt; \u0026lt;!--groupId出现点,则使用\\(文件夹)分割 artifactId 独立文件夹 version 独立文件夹 --\u0026gt; 结果 部署 mvn deploy 部署主程序(把本工程打包,按照本工程的坐标保存到本地仓库中,并且保存到私服仓库中,还会自动把项目部署到web容器中\n以上命令是可以组合着用的\nλ mvn clean compile [INFO] Scanning for projects... [INFO] [INFO] ---------------------\u0026lt; com.bjpowernode:ch01-maven \u0026gt;--------------------- [INFO] Building ch01-maven 1.0-SNAPSHOT [INFO] --------------------------------[ jar ]--------------------------------- [INFO] [INFO] --- maven-clean-plugin:2.5:clean (default-clean) @ ch01-maven --- [INFO] Deleting D:\\Users\\ly\\Documents\\git\\mavenwork\\Hello\\target [INFO] [INFO] --- maven-resources-plugin:2.6:resources (default-resources) @ ch01-maven --- [WARNING] Using platform encoding (GBK actually) to copy filtered resources, i.e. build is platform dependent! [INFO] Copying 2 resources [INFO] [INFO] --- maven-compiler-plugin:3.1:compile (default-compile) @ ch01-maven --- [INFO] Changes detected - recompiling the module! [WARNING] File encoding has not been set, using platform encoding GBK, i.e. build is platform dependent! [INFO] Compiling 1 source file to D:\\Users\\ly\\Documents\\git\\mavenwork\\Hello\\target\\classes [INFO] ------------------------------------------------------------------------ [INFO] BUILD SUCCESS [INFO] ------------------------------------------------------------------------ [INFO] Total time: 2.725 s [INFO] Finished at: 2022-07-09T17:53:36+08:00 [INFO] ------------------------------------------------------------------------ 配置插件 # 常用插件设置\n\u0026lt;build\u0026gt; \u0026lt;plugins\u0026gt; \u0026lt;plugin\u0026gt; \u0026lt;groupId\u0026gt;org.apache.maven.plugins\u0026lt;/groupId\u0026gt; \u0026lt;artifactId\u0026gt;maven-compiler-plugin\u0026lt;/artifactId\u0026gt; \u0026lt;version\u0026gt;3.8.1\u0026lt;/version\u0026gt; \u0026lt;configuration\u0026gt; \u0026lt;source\u0026gt;1.8\u0026lt;/source\u0026gt; \u0026lt;target\u0026gt;1.8\u0026lt;/target\u0026gt; \u0026lt;/configuration\u0026gt; \u0026lt;/plugin\u0026gt; \u0026lt;/plugins\u0026gt; \u0026lt;/build\u0026gt; 先看一下,目前的版本 maven-compiler-plugin:3.1:compile "},{"id":369,"href":"/zh/docs/technology/Maven/base_dljd_/01-16/","title":"01-16 maven基础_动力节点","section":"基础(动力节点)_","content":" 课程介绍 # maven 自动化构建\u0026ndash;\u0026gt;开发\u0026ndash;编译\u0026ndash;运行-测试\u0026ndash;打包\u0026ndash;部署 (m ei \u0026rsquo; ven) maven的作用 # 软件是一个工程 软件中重复的操作(开发阶段) 需求分析 设计阶段 开发阶段(编码),编译,测试 测试阶段(专业测试),测试报告 项目打包,发布,给客户安装项目 maven 项目自动构建,清理、编译、测试、打包、安装、部署 管理依赖:项目中需要使用的其他资源 Maven中的概念 # 没有使用maven,管理jar,手动处理jar,以及jar之间的依赖 maven是apache 【əˈpætʃi】基金会的开源项目,使用java语法开发 maven是项目的自动化构建工具,管理项目依赖 maven中的概念 POM 约定的目录 坐标 依赖管理 仓库管理 生命周期 插件和目标 继承 (高级内容) 聚合 (高级内容) Maven资源的获取与安装,测试 # https://maven.apache.org/index.html\n各种内容 要求 视频用的3.6.3 ,这里下载3.8.6 (最新的,不要和电脑原配置冲突,方便学习,后续改回3.8.4)\n检查java home 如果没有需要进行配置 将maven的bin目录配置到path环境变量下(这里使用的是下一节的方法,视频中没有用MAVEN_HOME,而是直接将maven的bin目录路径加到path中) maven解压后的目录结构 另一种安装方式 # 确定JAVA_HOME是否有效 创建M2_HOME(MAVEN_HOME),值为maven的安装目录 在path环境中,加入%M2_HOME%\\bin 测试maven安装 mvn -v 约定的目录结构 # 大多数人遵守的目录结构\n一个maven项目对应一个文件夹,比如Hello\nHello \\src \\main\t叫做主程序目录(完成项目功能的代码和配置文件) \\java\t源代码(包和相关的类定义) \\resources 配置文件 \\test\t放置测试程序代码(开发人员自己写的测试代码) \\java\t测试代码(junit) \\resources 测试程序的配置文件 \\pom.xml\tmaven的配置文件 Hello的Maven项目 # maven可以独立使用:创建项目、编译代码、测试程序、打包、部署等\n和idea一起使用,实现编码、测试、打包\npom.xml基本模板\n\u0026lt;?xml version=\u0026#34;1.0\u0026#34; encoding=\u0026#34;UTF-8\u0026#34; ?\u0026gt; \u0026lt;project xmlns=\u0026#34;http://maven.apache.org/POM/4.0.0\u0026#34; xmlns:xsi=\u0026#34;http://www.w3.org/2001/XMLSchema-instance\u0026#34; xsi:schemaLocation=\u0026#34;http://maven.apache.org/POM/4.0.0 http://maven.apache.org/maven-v4_0_0.xsd\u0026#34;\u0026gt; \u0026lt;modelVersion\u0026gt;4.0.0\u0026lt;/modelVersion\u0026gt; \u0026lt;groupId\u0026gt;com.bjpowernode\u0026lt;/groupId\u0026gt; \u0026lt;artifactId\u0026gt;ch01-maven\u0026lt;/artifactId\u0026gt; \u0026lt;version\u0026gt;1.0-SNAPSHOT\u0026lt;/version\u0026gt; \u0026lt;properties\u0026gt; \u0026lt;java.version\u0026gt;1.8\u0026lt;/java.version\u0026gt; \u0026lt;maven.compiler.source\u0026gt;1.8\u0026lt;/maven.compiler.source\u0026gt; \u0026lt;maven.compiler.target\u0026gt;1.8\u0026lt;/maven.compiler.target\u0026gt; \u0026lt;/properties\u0026gt; \u0026lt;/project\u0026gt; 目录创建 在main下创建一个com.bjpowernode的包,以及一个java文件\npackage com.bjpowernode; public class HelloMaven{ public int addNumber(int n1,int n2){ System.out.println(\u0026#34;hello maven -addNumber\u0026#34;); return n1+n2; } public static void main(String args[]){ HelloMaven hello=new HelloMaven(); int res=hello.addNumber(10,20); System.out.println(\u0026#34;在main方法中,执行hello的方法=\u0026#34;+res); } } 在Hello目录下,进行编译 使用mvn compile进行编译 第一次会下载一些东西 查看target文件 进入classes执行java程序\njava com.bjpowernode.HelloMaven pom-modelVersion # pom\u0026ndash;Project Object Model 项目对象模型\nMaven把一个项目的结构和内容抽象成一个模型,在xml文件中进行声明,以方便进行构建和描述\npom文件解释\n\u0026lt;?xml version=\u0026#34;1.0\u0026#34; encoding=\u0026#34;UTF-8\u0026#34; ?\u0026gt; \u0026lt;!--project是根标签,后面的是约束文件 (maven-v4_0_0.xsd)--\u0026gt; \u0026lt;project xmlns=\u0026#34;http://maven.apache.org/POM/4.0.0\u0026#34; xmlns:xsi=\u0026#34;http://www.w3.org/2001/XMLSchema-instance\u0026#34; xsi:schemaLocation=\u0026#34;http://maven.apache.org/POM/4.0.0 http://maven.apache.org/maven-v4_0_0.xsd\u0026#34;\u0026gt; \u0026lt;!--pom模型版本,4.0.0--\u0026gt; \u0026lt;modelVersion\u0026gt;4.0.0\u0026lt;/modelVersion\u0026gt; \u0026lt;!--坐标--\u0026gt; \u0026lt;groupId\u0026gt;com.bjpowernode\u0026lt;/groupId\u0026gt; \u0026lt;artifactId\u0026gt;ch01-maven\u0026lt;/artifactId\u0026gt; \u0026lt;version\u0026gt;1.0-SNAPSHOT\u0026lt;/version\u0026gt; \u0026lt;properties\u0026gt; \u0026lt;java.version\u0026gt;1.8\u0026lt;/java.version\u0026gt; \u0026lt;maven.compiler.source\u0026gt;1.8\u0026lt;/maven.compiler.source\u0026gt; \u0026lt;maven.compiler.target\u0026gt;1.8\u0026lt;/maven.compiler.target\u0026gt; \u0026lt;/properties\u0026gt; \u0026lt;/project\u0026gt; pom-groupId,artifactId,version # 坐标组成,groupid,artifactId,version 作用:资源的唯一标识,maven中每个资源都是坐标,简称gav groupId:组织名称,代码。公司或单位标识,常使用公司域名的倒写 如果规模大,可以是 域名倒写+大项目名称 例如百度无人车项目 : com.baidu.appollo artifactId:项目名称,如果groupId中有项目,此时当前的值就是子项目名,项目名称是唯一的 versionId:项目版本号,使用数字,推荐三位 例如 主版本号.次版本号.小版本号 例如 5.2.5 带快照的版本,以-SNAPSHOT结尾,即非稳定版本 pom-gav作用 # 每个maven项目都有自己的gav 管理依赖,使用其他jar包,也用gav标识 坐标 坐标值的获取 https://mvnrepository.com/ 例如mysql pom-依赖的使用 # 依赖dependency 项目中使用的其他资源(jar) 需要使用maven来表示依赖、管理依赖,通过使用dependencies、dependency和gav完成依赖的使用\n\u0026lt;dependencies\u0026gt; \u0026lt;!-- https://mvnrepository.com/artifact/mysql/mysql-connector-java --\u0026gt; \u0026lt;dependency\u0026gt; \u0026lt;groupId\u0026gt;mysql\u0026lt;/groupId\u0026gt; \u0026lt;artifactId\u0026gt;mysql-connector-java\u0026lt;/artifactId\u0026gt; \u0026lt;version\u0026gt;8.0.29\u0026lt;/version\u0026gt; \u0026lt;/dependency\u0026gt; \u0026lt;/dependencies\u0026gt; \u0026lt;!--maven使用gav标识,从互联网下载依赖的jar,下载到本机中,由maven管理项目使用的这些jar--\u0026gt; 完整\n\u0026lt;?xml version=\u0026#34;1.0\u0026#34; encoding=\u0026#34;UTF-8\u0026#34; ?\u0026gt; \u0026lt;!--project是根标签,后面的是约束文件 (maven-v4_0_0.xsd)--\u0026gt; \u0026lt;project xmlns=\u0026#34;http://maven.apache.org/POM/4.0.0\u0026#34; xmlns:xsi=\u0026#34;http://www.w3.org/2001/XMLSchema-instance\u0026#34; xsi:schemaLocation=\u0026#34;http://maven.apache.org/POM/4.0.0 http://maven.apache.org/maven-v4_0_0.xsd\u0026#34;\u0026gt; \u0026lt;!--pom模型版本,4.0.0--\u0026gt; \u0026lt;modelVersion\u0026gt;4.0.0\u0026lt;/modelVersion\u0026gt; \u0026lt;!--坐标--\u0026gt; \u0026lt;groupId\u0026gt;com.bjpowernode\u0026lt;/groupId\u0026gt; \u0026lt;artifactId\u0026gt;ch01-maven\u0026lt;/artifactId\u0026gt; \u0026lt;version\u0026gt;1.0-SNAPSHOT\u0026lt;/version\u0026gt; \u0026lt;properties\u0026gt; \u0026lt;java.version\u0026gt;1.8\u0026lt;/java.version\u0026gt; \u0026lt;maven.compiler.source\u0026gt;1.8\u0026lt;/maven.compiler.source\u0026gt; \u0026lt;maven.compiler.target\u0026gt;1.8\u0026lt;/maven.compiler.target\u0026gt; \u0026lt;/properties\u0026gt; \u0026lt;dependencies\u0026gt; \u0026lt;!-- https://mvnrepository.com/artifact/mysql/mysql-connector-java --\u0026gt; \u0026lt;dependency\u0026gt; \u0026lt;groupId\u0026gt;mysql\u0026lt;/groupId\u0026gt; \u0026lt;artifactId\u0026gt;mysql-connector-java\u0026lt;/artifactId\u0026gt; \u0026lt;version\u0026gt;8.0.29\u0026lt;/version\u0026gt; \u0026lt;/dependency\u0026gt; \u0026lt;/dependencies\u0026gt; \u0026lt;!--maven使用gav标识,从互联网下载依赖的jar,下载到本机中,由maven管理项目使用的这些jar--\u0026gt; \u0026lt;!--packaging 项目打包类型---\u0026gt; \u0026lt;/project\u0026gt; pom-打包类型 # \u0026lt;packaging\u0026gt; 项目打包类型\n\u0026lt;?xml version=\u0026#34;1.0\u0026#34; encoding=\u0026#34;UTF-8\u0026#34; ?\u0026gt; \u0026lt;!--project是根标签,后面的是约束文件 (maven-v4_0_0.xsd)--\u0026gt; \u0026lt;project xmlns=\u0026#34;http://maven.apache.org/POM/4.0.0\u0026#34; xmlns:xsi=\u0026#34;http://www.w3.org/2001/XMLSchema-instance\u0026#34; xsi:schemaLocation=\u0026#34;http://maven.apache.org/POM/4.0.0 http://maven.apache.org/maven-v4_0_0.xsd\u0026#34;\u0026gt; \u0026lt;!--pom模型版本,4.0.0--\u0026gt; \u0026lt;modelVersion\u0026gt;4.0.0\u0026lt;/modelVersion\u0026gt; \u0026lt;!--坐标--\u0026gt; \u0026lt;groupId\u0026gt;com.bjpowernode\u0026lt;/groupId\u0026gt; \u0026lt;artifactId\u0026gt;ch01-maven\u0026lt;/artifactId\u0026gt; \u0026lt;version\u0026gt;1.0-SNAPSHOT\u0026lt;/version\u0026gt; \u0026lt;packaging\u0026gt;jar\u0026lt;/packaging\u0026gt; \u0026lt;/project\u0026gt; 其他 pom-继承和聚合 # 继承 parent 聚合 modules "},{"id":370,"href":"/zh/docs/technology/Linux/hanshunping_/52-x/","title":"52-X","section":"韩顺平老师_","content":" crond快速入门 # 使用命令 crontab -e 创建一个定时任务\n*/1 * * * * ls -l /etc/ \u0026gt; /tmp/to.txt 特殊符号 ,代表不连续 -破折号 表示连续 其他 定时调用脚本\n编辑脚本 my.sh\ndate \u0026gt;\u0026gt; /home/mycal date \u0026gt;\u0026gt; /home/mycal 给脚本赋予x权限\nchmod u+x my.sh crontab -e\n*/1 * * * * my.sh 数据库备份 crontab -r 删除\ncrontab -l 列出\ncrontab -e 编辑任务\natd 是否在运行 yum install -y atd systemctl start atd\njob队列 at选项 at指定时间 添加任务 at 5pm tomorrow 明天下午5点\nat now + 2 minutes 2分钟后\natrm 5 删除5号\n两分钟后执行某个脚本 磁盘分区 # 分区跟文件系统的关系 (挂载) 将一个分区挂载到某个目录,用户进入到某个目录,就相当于访问到某个分区了 lsblk linux分IDE硬盘和SCSI硬盘 目前基本是SCSI硬盘 sdx~ x代表abcd,~表示数字 lsblk -f 文件类型,唯一标识符 现在挂载一个分区 如图 给虚拟机,添加一个硬盘 重启后,使用lsblk 进行分区 fdisk /dev/sdb 之后输入p, 输入分区数(这里是1) 最后一步,输入w ,写入分区并退出 查看 将分区格式化 mkfs -t ext4 /dev/sdb1 查看 进行挂载 mount /dev/sdb1 /newdisk/ umount /dev/sdb1 卸载 用命令行挂载的指令,重启后挂载关系会消失 永久挂载:修改/etc/fstab # df -h 查看磁盘使用情况 du -h \u0026ndash;max-depth=1 /opt ls -l /opt | grep \u0026ldquo;^-\u0026rdquo; | wc -l 使用正则,并统计数量 ls -lR /opt 注意,这里加了R,将递归显示 使用yum install -y tree 网络配置 # ifconfig 查看ip\n网络的互通 虚拟网络编辑器 使用ping判断主机间是否互通\nvi /etc/sysconfig/network-scripts/ifcfg-ens33 编辑ip\nTYPE=\u0026#34;Ethernet\u0026#34; PROXY_METHOD=\u0026#34;none\u0026#34; BROWSER_ONLY=\u0026#34;no\u0026#34; DEFROUTE=\u0026#34;yes\u0026#34; IPV4_FAILURE_FATAL=\u0026#34;no\u0026#34; IPV6INIT=\u0026#34;yes\u0026#34; IPV6_AUTOCONF=\u0026#34;yes\u0026#34; IPV6_DEFROUTE=\u0026#34;yes\u0026#34; IPV6_FAILURE_FATAL=\u0026#34;no\u0026#34; IPV6_ADDR_GEN_MODE=\u0026#34;stable-privacy\u0026#34; NAME=\u0026#34;ens33\u0026#34; UUID=\u0026#34;8c2741af-382a-44a6-b161-aed16a29875d\u0026#34; DEVICE=\u0026#34;ens33\u0026#34; BOOTPROTO=\u0026#34;static\u0026#34; ONBOOT=\u0026#34;yes\u0026#34; IPADDR=192.168.200.160 GATEWAY=192.168.200.2 DNS1=192.168.200.2 注意最后五行 修改hostname vim /etc/hostname\n进程 # 每一个执行的程序被称为一个进程,每一个进程都分配一个ID号- 每个进程都可以以前台/后台方式运行 一半系统服务以后台进程方式存在的 使用ps显示进程 ps -aux 一些参数解释 使用grep过滤 进程的父进程 ps -ef 由systemd生成启动其他进程 子进程之间关系 进程的终止 kill / killall killall 将子进程一起杀死 kill -9 强制终止 如果把sshd杀死,那就再也连不上了 重新启动sshd /bin/systemctl start sshd.service yum -y install psmisc pstree -u 带上用户 pstree -p 带上进程号 服务管理 # 服务,本质上就是进程 service 服务名 start|stop|restart|reload|status centos7.0之后,主要用systemctl 还使用service的命令 网络连接查看 服务的运行级别 systemctl set-default graphical.target //默认进入图形化界面 rpm管理 # 软件包管理 # "},{"id":371,"href":"/zh/docs/technology/Linux/hanshunping_/40-51/","title":"linux_韩老师_40-51","section":"韩顺平老师_","content":" 组介绍 # 每个用户必定属于某个组 每个文件有几个概念:所有者、所在组、其他组 tom创建了hello.txt,则所有者为tom,默认所在组为tom组 除了所在组,就是其他组 ls -ahl (h更友好,a隐藏,l列表) 所有者 # 使用chown root helo.java 修改,效果如下 所在组修改 # 组的创建 groupadd monster 创建一个用户并让他属于该组 useradd -g monster fox 注意逻辑,此时使用fox创建文件 passwd fox 给fox创建密码 如图,创建一个文件 使用chgrp fruit orange.txt 修改文件的所在组 改变某个用户所在组 usermod -g fruit fox 使用 cat /etc/group 查看所有的组 当一个用户属于多个组的时候,groups会出现多个组名 rwx权限 # rwxrwxrwx 第一列有十位,第0位确认文件类型 -普通文件,l是链接;d是目录;c是字符设备文件、鼠标、键盘;b块设备 1-3表示文件所有者拥有的权限;4-6是文件所在组所拥有的权限,7-9 其他组所拥有的权限\nrwx作用到文件,r代表可读可查看,w代表可修改(如果是删除权限,则必须在该文件所在的目录有写权限,才能删除),x代表可执行 rwx作用到目录,r表示可以读取(ls可查看目录内容),w表示可写(可以在目录内创建、删除、重命名目录),x表示可以进入该目录 rwx分别用数字表示,4,2,1。当拥有所有权限,则为7 最后面的数字,代表连接数(或者子目录数) 1213 文件大小(字节),如果是文件夹则显示4096 最后abc表示文件名,蓝色表示是目录 修改权限 # chmod 修改权限,u:所有者,g:所有组,o:其他人,a 所有(ugo总和) chmod u=rwx,g=rw,o=x 文件/目录名 这里等号表示直接给权限 chmod o+w 文件/目录名 这里加表示+权限 chmod a-x 文件/目录名 chmod u=rwx,g=rx,o=rx abc 给文件添加执行权限(会变成绿色的) 使用数字 将abc.txt文件权限修改成rwxr-xr-x使用数字实现 chmod 755 abc 修改所有者和所在组 # chown tom abc #修改文件所有者为tom chown -R tom abc #修改文件夹及其所有子目录所有者为tom chgrp -R fruit kkk #修改文件夹所在组为fruit 权限管理应用实例 # 警察和土匪的游戏\n前提,有police和bandit两个组,\njack,jerry属于警察组\nxh,xq属于土匪组\ngroupadd police groupadd bandit useradd -g police jack useradd -g police jerry useradd -g bandit xh useradd -g bandit xq chmod 640 jack.txt\nchmod o=r,g=rw jack.txt\n如果要对目录内操作,那么先有改目录相应权限\nchmod 770 jack 放开jack目录权限 题目 对一个目录不能ls(没有读权限),但是是可以直接读写目录中的文件的(有权限的情况下)\n# "},{"id":372,"href":"/zh/docs/technology/MySQL/bl_sgg_/96-00/","title":"mysql高阶_sgg 96-00","section":"进阶(尚硅谷)_","content":" 章节概述 # 架构篇\n1-3 4 5 6 索引及调优篇\n01 02-03\n04-05\n06 事务篇\n01-02 03 04 日志与备份篇\n01 02 03 CentOS环境准备 # 这里主要是做了克隆,并没有讲到CentOS的安装,所以笔记不记录了 MySQL的卸载 # 查找当前系统已经装了哪些 rpm -qa |grep mysql\n查找mysql服务运行状态 systemctl status mysql\n停止mysql服务 systemctl stop mysql\n删除\nyum remove mysql-community-client-plugins-8.0.29-1.el7.x86_64 yum remove mysql-community-common-8.0.29-1.el7.x86_64 查找带mysql名字的文件夹 find / -name mysql\n进行删除\nrm -rf /usr/lib64/mysql rm -rf /usr/share/mysql rm -rf /etc/selinux/targeted/active/modules/100/mysql rm -rf /etc/my.cnf Linux下安装MySQL8.0与5.7版本 # 版本介绍 下载地址 : https://www.mysql.com/downloads/ 进入 即 https://dev.mysql.com/downloads/mysql/ 版本选择 下载最大的那个,离线版 下载后解压,并将下面六个放进linux中\n如果是5.7,则需要进入 https://downloads.mysql.com/archives/community/\n下载后解压 拷贝进linux 安装前,给/tmp临时目录权限\nchmod -R 777 /tmp\n检查依赖\nrpm -qa |grep libaio ##libaio-0.3.109-13.el7.x86_64 rpm -qa |grep net-tools ##net-tools-2.0-0.24.20131004git.el7.x86_64 确保目录下已经存在5(4)个文件并严格按顺序执行\nrpm -ivh mysql-community-common-8.0.29-1.el7.x86_64.rpm rpm -ivh mysql-community-client-plugins-8.0.29-1.el7.x86_64.rpm rpm -ivh mysql-community-libs-8.0.29-1.el7.x86_64.rpm rpm -ivh mysql-community-client-8.0.29-1.el7.x86_64.rpm rpm -ivh mysql-community-icu-data-files-8.0.29-1.el7.x86_64.rpm rpm -ivh mysql-community-server-8.0.29-1.el7.x86_64.rpm 安装libs的时候,会报错\nerror: Failed dependencies: mariadb-libs is obsoleted by mysql-community-libs-8.0.29-1.el7.x86_64 使用下面命令,视频的方法\nyum remove mysql-libs 使用下面命令,卸载mariadb (这是我自己的方法)\nrpm -qa | grep mariadb\n查找到对应的版本 mariadb-libs-5.5.60-1.el7_5.x86_64 # 下面卸载查找出来的版本 # yum remove mariadb-libs-5.5.60-1.el7_5.x86_64 # 再次执行后安装成功\n服务初始化 mysqld --initialize --user=mysql\n查看默认生成的密码 cat /var/log/mysqld.log 判断mysql是否启动 systemctl status mysqld\n启动服务systemctl start mysqld 再次判断,发现已经启动 设置为自动启动\n查看当前是否开机自启动 systemctl list-unit-files|grep mysqld.service 如果是disable,则可以使用下面命令开机自启动 systemctl enable mysqld.service 进行登录\nmysql -u root -p 用刚才的密码\n使用查询,提示需要重置密码 密码更新\nalter user \u0026#39;root\u0026#39;@\u0026#39;localhost\u0026#39; identified by \u0026#39;123456\u0026#39;; quit # 退出重新登录 5.7的安装 赋予权限并检查包,这里发现缺少了libaio,所以yum install libaio\nSQLyog实现MySQL8.0和5.7的远程连接 # sqlyog下载 https://github.com/webyog/sqlyog-community/wiki/Downloads\n默认情况下会有连接出错 先测试ip及端口号 此时linux端口号并没有开放 使用systemctl status firewalld发现防火墙开启 (active) 使用systemctl stop firewalld将防火墙关闭 开机时关闭防火墙systemctl disable firewalld 此时还是报错 这是由于root不允许被远程连接\n查看user表,发现只允许本地登录 修改并更新权限\nupdate user set host = \u0026#39;192.168.1.%\u0026#39; where user= \u0026#39;root\u0026#39;; #或者 update user set host = \u0026#39;%\u0026#39; where user= \u0026#39;root\u0026#39;; #更新权限 flush privileges; 之后如果出现下面的问题(视频中有,我没遇到) ALTER USER \u0026#39;root\u0026#39;@\u0026#39;%\u0026#39; IDENTIFIED WITH mysql_native_password BY \u0026#39;123456\u0026#39;; 然后就可以连接了 命令行进行远程连接 mysql -u root -h 192.168.200.150 -P3306 -p\n字符集的修改与底层原理说明 # 比较规则_请求到响应过程中的编码与解码过程 # SQL大小写规范与sql_model的设置 # "},{"id":373,"href":"/zh/docs/technology/Algorithm/_algorithhms_4th_/3.2.1/","title":"算法红皮书 3.2.1","section":"_算法(第四版)_","content":" 二叉查找树 # 使用每个结点含有两个链接(链表中每个结点只含有一个链接)的二叉查找树来高效地实现符号表\n该数据结构由结点组成,结点包含的链接可以为空(null)或者指向其他结点\n一棵二叉查找树(BST)是一棵二叉树,其中每个结点都含有一个Comparable 的键(以 及相关联的值)且每个结点的键都大于其左子树中的任意结点的键而小于右子树的任意结点的键。\n基本实现 # 数据表示\n每个结点都含有一个键、一个值、一条左链接、一条右链接和一个结点计数器 左链接指向一棵由小于该结点的所有键组成的二叉查找树,右链接指向一棵由大于该节点的所有键组成的二叉查找树,变量N给出了以该结点为根的子树的结点总数 对于任意节点总是成立 size(x)=size(x.left)+size(x.right)+1 多棵二叉查找树表示同一组有序的键来实现构建和使用二叉查找树的高校算法 查找\n在符号表中查找一个键可能得到两种结果:如果含有该键的结点存在表中,我们的查找就命中了,然后返回值;否则查找未命中(返回null) 递归:如果树是空的,则查找未命中;如果被查找的键和根节点的键相等,查找命中,否则在适当的子树中查找:如果被查找的键较小就选择左子树,否则选择右子树 下面的get()方法,第一个参数是一个结点(子树根节点),第二个参数是被查找的键,代码会保证只有该结点所表示的子树才会含有和被查找的键相等的结点 从根结点开始,在每个结点中查找的进程都会递归地在它的一个子结点上展开,因此一次查找也就定义了树的一条路径。对于命中的查找,路径在含有被查找的键的结点处结束。对于未命中的查找,路径的终点是一个空链接 基于二叉查找树的符号表\npublic class BST\u0026lt;Key extends Comparable\u0026lt;Key\u0026gt;, Value\u0026gt; { private Node root; // 二叉查找树的根结点 private class Node { private Key key; // 键 private Value val; // 值 private Node left, right; // 指向子树的链接 private int N; // 以该结点为根的子树中的结点总数 public Node(Key key, Value val, int N) { this.key = key; this.val = val; this.N = N; } } public int size() { return size(root); } private int size(Node x) { if (x == null) return 0; else return x.N; } public Value get(Key key) // 请见算法3.3(续1) public void put(Key key, Value val) // 请见算法3.3(续1) // max()、min()、floor()、ceiling()方法请见算法3.3(续2) // select()、rank()方法请见算法3.3(续3) // delete()、deleteMin()、deleteMax()方法请见算法3.3(续4) // keys()方法请见算法3.3(续5) } 每个Node 对象都是一棵含有N 个结点的子树的根结点,它的左链接指向一棵由小于该结点的所有键组成的二叉查找树,右链接指向一棵由大于该结点的所有键组成的二叉查找 树。root 变量指向二叉查找树的根结点Node 对象(这棵树包含了符号表中的所有键值对) 二叉查找树的查找和排序方法的实现\npublic Value get(Key key) { return get(root, key); } private Value get(Node x, Key key) { // 在以x为根结点的子树中查找并返回key所对应的值; // 如果找不到则返回null if (x == null) return null; int cmp = key.compareTo(x.key); if (cmp \u0026lt; 0) return get(x.left, key); else if (cmp \u0026gt; 0) return get(x.right, key); else return x.val; } public void put(Key key, Value val) { // 查找key,找到则更新它的值,否则为它创建一个新的结点 root = put(root, key, val); } private Node put(Node x, Key key, Value val) { // 如果key存在于以x为根结点的子树中则更新它的值; // 否则将以key和val为键值对的新结点插入到该子树中 if (x == null) return new Node(key, val, 1); int cmp = key.compareTo(x.key); //注意,这里进行比较后,确认新节点应该放在当前节点的左边还是右边 if (cmp \u0026lt; 0) x.left = put(x.left, key, val); else if (cmp \u0026gt; 0) x.right = put(x.right, key, val); else x.val = val; x.N = size(x.left) + size(x.right) + 1; return x; } 插入 put()方法的实现逻辑和递归查找很相似:如果树是空的,就返回一个含有该键值对的新节点;如果被查找的键小于根节点的键,我们就会继续在左子树中插入该键,否则在右子树中插入该键\n递归\n可以将递归调用前的代码想象成沿着树向下走:它会将给定的键和每个结点的键相比较并根据结果向左或者向右移动到下一个结点。然后可以将递归调用后的代码想象成沿着树向上爬 在一棵简单的二叉查找树中,唯一的新链接就是在最底层指向新结点的链接,重置更上层的链接可以通过比较语句来避免。同样,我们只需要将路径上每个结点中的计数器的值加1,但我们使用了更加通用的代码,使之等于结点的所有子结点的计数器之和加1 使用二叉查找树的标准索引用例的轨迹 分析 # 在由N 个随机键构造的二叉查找树中,查找命中平均所需的比较次数为∼ 2lnN\n在由N 个随机键构造的二叉查找树中插入操作和查找未命中平均所需的比较次数为∼ 2lnN(约1.39lgN)\n有序性相关的方法与删除操作 # 最大键和最小键 # 如果根结点的左链接为空,那么一棵二叉查找树中最小的键就是根结点;如果左链接非空,那么 树中的最小键就是左子树中的最小键\n向上取整和向下取整 # 如果给定的键key 小于二叉查找树的根结点的键,那么小于等于key 的最大键floor(key) 一定 在根结点的左子树中;如果给定的键key 大于二叉查找树的根结点,那么只有当根结点右子树中存在小于等于key 的结点时,小于等于key 的最大键才会出现在右子树中,否则根结点就是小于等于key的最大键\n选择操作 # public Key min() { return min(root).key; } private Node min(Node x) { if (x.left == null) return x; return min(x.left); } public Key floor(Key key) { Node x = floor(root, key); if (x == null) return null; return x.key; } private Node floor(Node x, Key key) { if (x == null) return null; int cmp = key.compareTo(x.key); if (cmp == 0) return x; if (cmp \u0026lt; 0) return floor(x.left, key); Node t = floor(x.right, key); if (t != null) return t; else return x; } 排名 # 删除最大键和删除最小键 # 删除操作 # 范围查找 # 性能分析 # "},{"id":374,"href":"/zh/docs/technology/MyBatis-Plus/bl_sgg_/40-57/","title":"mybatis-plus-sgg-40-57","section":"基础(尚硅谷)_","content":" LambdaXxxWrapper # LambdaQueryWrapper主要是为了防止字段名写错\n@Test public void test11(){ String username=\u0026#34;abc\u0026#34;; Integer ageBegin=null; Integer ageEnd=30; LambdaQueryWrapper\u0026lt;User\u0026gt; queryWrapper=new LambdaQueryWrapper\u0026lt;\u0026gt;(); queryWrapper.like(StringUtils.isNotBlank(username),User::getUserName,username) .ge(ageBegin!=null,User::getAge,ageBegin); userMapper.selectList(queryWrapper); } sql日志打印\n==\u0026gt; Preparing: SELECT uid AS id,name AS userName,age,email,is_deleted_ly FROM t_user WHERE is_deleted_ly=0 AND (name LIKE ?) ==\u0026gt; Parameters: %abc%(String) \u0026lt;== Total: 0 LambdaUpdateWrapper\n@Test public void test12() { //(age\u0026gt;23且用户名包含a) 或 (邮箱为null) LambdaUpdateWrapper\u0026lt;User\u0026gt; updateWrapper = new LambdaUpdateWrapper\u0026lt;\u0026gt;(); updateWrapper.like(User::getUserName, \u0026#34;a\u0026#34;) .and(userUpdateWrapper -\u0026gt; userUpdateWrapper.gt(User::getAge, 23).or().isNotNull(User::getEmail)); updateWrapper.set(User::getUserName, \u0026#34;小黑\u0026#34;).set(User::getEmail, \u0026#34;abc@ly.com\u0026#34;); userMapper.update(null, updateWrapper); } sql日志打印\n==\u0026gt; Preparing: UPDATE t_user SET name=?,email=? WHERE is_deleted_ly=0 AND (name LIKE ? AND (age \u0026gt; ? OR email IS NOT NULL)) ==\u0026gt; Parameters: 小黑(String), abc@ly.com(String), %a%(String), 23(Integer) \u0026lt;== Updates: 0 MyBatis分页 # 先使用配置类\n@Configuration @MapperScan(\u0026#34;com.ly.mybatisplus.mapper\u0026#34;) public class MyBatisConfig { @Bean public MybatisPlusInterceptor mybatisPlusInterceptor(){ MybatisPlusInterceptor mybatisPlusInterceptor=new MybatisPlusInterceptor(); mybatisPlusInterceptor.addInnerInterceptor(new PaginationInnerInterceptor(DbType.MYSQL)); return mybatisPlusInterceptor; } } 使用\n@Test public void testPage() { Page\u0026lt;User\u0026gt; page = new Page\u0026lt;\u0026gt;(); page.setCurrent(2);//当前页页码 page.setSize(3);//每页条数 Page\u0026lt;User\u0026gt; userPage = userMapper.selectPage(page, null); System.out.println(userPage.getRecords() + \u0026#34;----\\n\u0026#34; + userPage.getPages() + \u0026#34;----\\n\u0026#34; + userPage.getTotal() + \u0026#34;---\\n\u0026#34;) ; } sql日志打印\n==\u0026gt; Preparing: SELECT uid AS id,name AS userName,age,email,is_deleted_ly FROM t_user WHERE is_deleted_ly=0 LIMIT ?,? ==\u0026gt; Parameters: 3(Long), 3(Long) \u0026lt;== Columns: id, userName, age, email, is_deleted_ly \u0026lt;== Row: 4, 被修改了, 21, test4@baomidou.com, 0 \u0026lt;== Row: 5, 被修改了, 24, email被修改了, 0 \u0026lt;== Row: 6, 张三5, 18, test5@baomidou.com, 0 \u0026lt;== Total: 3 结果Page对象的数据\n[User(id=4, userName=被修改了, age=21, email=test4@baomidou.com, isDeletedLy=0), User(id=5, userName=被修改了, age=24, email=email被修改了, isDeletedLy=0), User(id=6, userName=张三5, age=18, email=test5@baomidou.com, isDeletedLy=0)]---- 3---- 8--- 自定义分页功能\n首先,设置类型别名所在的包\nmybatis-plus: type-aliases-package: com.ly.mybatisplus.pojo 在Mapper类中编写接口方法\n@Repository public interface UserMapper extends BaseMapper\u0026lt;User\u0026gt; { /** * 通过年龄查询并分页 * @param page mybatis-plus提供的,必须存在且在第一个位置 * @param age * @return */ Page\u0026lt;User\u0026gt; selectPageVO(Page\u0026lt;User\u0026gt; page,Integer age); } 注意第一个参数\n在Mapper.xml中编写语句\n\u0026lt;select id=\u0026#34;selectPageVO\u0026#34; resultType=\u0026#34;User\u0026#34;\u0026gt; select uid,name,email from t_user where age \u0026gt; #{age} \u0026lt;/select\u0026gt; 测试方法\n@Test public void testPageCustom() { Page\u0026lt;User\u0026gt; page = new Page\u0026lt;\u0026gt;(); page.setCurrent(3);//当前页页码 page.setSize(5);//每页条数 Page\u0026lt;User\u0026gt; userPage = userMapper.selectPageVO(page, 12); System.out.println(userPage.getRecords() + \u0026#34;----\\n\u0026#34; + userPage.getPages() + \u0026#34;----\\n\u0026#34; + userPage.getTotal() + \u0026#34;---\\n\u0026#34;) ; } sql日志输出\n==\u0026gt; Preparing: SELECT COUNT(*) AS total FROM t_user WHERE age \u0026gt; ? ==\u0026gt; Parameters: 12(Integer) \u0026lt;== Columns: total \u0026lt;== Row: 20 \u0026lt;== Total: 1 //从第10行开始(不包括第10行),取5条记录 ==\u0026gt; Preparing: select uid,name,email from t_user where age \u0026gt; ? LIMIT ?,? ==\u0026gt; Parameters: 12(Integer), 10(Long), 5(Long) \u0026lt;== Columns: uid, name, email \u0026lt;== Row: 11, a, null \u0026lt;== Row: 12, a, null \u0026lt;== Row: 13, a, null \u0026lt;== Row: 14, a, null \u0026lt;== Row: 15, a, null \u0026lt;== Total: 5 Closing non transactional SqlSession [org.apache.ibatis.session.defaults.DefaultSqlSession@706fe5c6] [null, null, null, null, null]---- 4---- 20--- 注意上面那个sql,他会先查询条数,如果条数\u0026lt;=0,那么就不会执行下面的数据搜索了\n悲观锁和乐观锁 # 场景 乐观锁根据版本号使用 version\n乐观锁实现流程 模拟冲突 # 表创建\nCREATE TABLE t_product ( id BIGINT ( 20 ) NOT NULL COMMENT \u0026#39;主键id\u0026#39;, NAME VARCHAR ( 30 ) null DEFAULT NULL COMMENT \u0026#39;商品名称\u0026#39;, price INT ( 11 ) DEFAULT 0 COMMENT \u0026#39;价格\u0026#39;, version INT ( 11 ) DEFAULT 0 COMMENT \u0026#39;乐观锁版本号\u0026#39;, PRIMARY KEY ( id ) ) 创建ProductMapper\n@Repository public interface ProductMapper extends BaseMapper\u0026lt;Product\u0026gt; { } 数据库数据 代码\n@Test public void testModel() { //小李查询商品 Product productLi = productMapper.selectById(1L); //小王查询商品 Product productWang = productMapper.selectById(1L); //小李将商品加50 productLi.setPrice(productLi.getPrice()+50); productMapper.updateById(productLi); //小王将价格降低30 productWang.setPrice(productWang.getPrice()-30); productMapper.updateById(productWang); } sql日志\n==\u0026gt; Preparing: UPDATE t_product SET name=?, price=?, version=? WHERE id=? ==\u0026gt; Parameters: 外星人(String), 150(Integer), 0(Integer), 1(Long) \u0026lt;== Updates: 1 Closing non transactional SqlSession [org.apache.ibatis.session.defaults.DefaultSqlSession@6325f352] Creating a new SqlSession SqlSession [org.apache.ibatis.session.defaults.DefaultSqlSession@70730db] was not registered for synchronization because synchronization is not active JDBC Connection [HikariProxyConnection@91831175 wrapping com.mysql.cj.jdbc.ConnectionImpl@74ea46e2] will not be managed by Spring ==\u0026gt; Preparing: UPDATE t_product SET name=?, price=?, version=? WHERE id=? ==\u0026gt; Parameters: 外星人(String), 70(Integer), 0(Integer), 1(Long) \u0026lt;== Updates: 1 //最终结果为70\n乐观锁插件 # 在实体类中使用@Version注解表示乐观锁版本号\n@Version private Integer version; 配置类\n@Bean public MybatisPlusInterceptor mybatisPlusInterceptor(){ MybatisPlusInterceptor mybatisPlusInterceptor=new MybatisPlusInterceptor(); mybatisPlusInterceptor.addInnerInterceptor(new PaginationInnerInterceptor(DbType.MYSQL)); //添加乐观锁插件 mybatisPlusInterceptor.addInnerInterceptor(new OptimisticLockerInnerInterceptor()); return mybatisPlusInterceptor; } 再次运行代码\n@Test public void testModel() { //小李查询商品 Product productLi = productMapper.selectById(1L); //小王查询商品 Product productWang = productMapper.selectById(1L); //小李将商品加50 productLi.setPrice(productLi.getPrice()+50); productMapper.updateById(productLi); //小王将价格降低30 productWang.setPrice(productWang.getPrice()-30); productMapper.updateById(productWang); } sql日志查看\n==\u0026gt; Preparing: UPDATE t_product SET name=?, price=?, version=? WHERE id=? AND version=? ==\u0026gt; Parameters: 外星人(String), 120(Integer), 1(Integer), 1(Long), 0(Integer) \u0026lt;== Updates: 1 Closing non transactional SqlSession [org.apache.ibatis.session.defaults.DefaultSqlSession@2d64160c] Creating a new SqlSession SqlSession [org.apache.ibatis.session.defaults.DefaultSqlSession@33063f5b] was not registered for synchronization because synchronization is not active JDBC Connection [HikariProxyConnection@356539350 wrapping com.mysql.cj.jdbc.ConnectionImpl@127a7272] will not be managed by Spring ==\u0026gt; Preparing: UPDATE t_product SET name=?, price=?, version=? WHERE id=? AND version=? ==\u0026gt; Parameters: 外星人(String), 40(Integer), 1(Integer), 1(Long), 0(Integer) \u0026lt;== Updates: 0 优化修改流程 # @Test public void testModel() { //小李查询商品 Product productLi = productMapper.selectById(1L); //小王查询商品 Product productWang = productMapper.selectById(1L); //小李将商品加50 productLi.setPrice(productLi.getPrice() + 50); productMapper.updateById(productLi); //小王将价格降低30 productWang.setPrice(productWang.getPrice() - 30); int i = productMapper.updateById(productWang); //如果小王操作失败,再获取一次 if (i == 0) { Product product = productMapper.selectById(1L); product.setPrice(product.getPrice() - 30); productMapper.updateById(product); } } sql日志打印\n==\u0026gt; Preparing: UPDATE t_product SET name=?, price=?, version=? WHERE id=? AND version=? ==\u0026gt; Parameters: 外星人(String), 150(Integer), 6(Integer), 1(Long), 5(Integer) \u0026lt;== Updates: 1 Closing non transactional SqlSession [org.apache.ibatis.session.defaults.DefaultSqlSession@544e8149] Creating a new SqlSession SqlSession [org.apache.ibatis.session.defaults.DefaultSqlSession@48a0c8aa] was not registered for synchronization because synchronization is not active JDBC Connection [HikariProxyConnection@1637000661 wrapping com.mysql.cj.jdbc.ConnectionImpl@5f481b73] will not be managed by Spring ==\u0026gt; Preparing: UPDATE t_product SET name=?, price=?, version=? WHERE id=? AND version=? ==\u0026gt; Parameters: 外星人(String), 70(Integer), 6(Integer), 1(Long), 5(Integer) \u0026lt;== Updates: 0 Closing non transactional SqlSession [org.apache.ibatis.session.defaults.DefaultSqlSession@48a0c8aa] Creating a new SqlSession SqlSession [org.apache.ibatis.session.defaults.DefaultSqlSession@4cbc2e3b] was not registered for synchronization because synchronization is not active JDBC Connection [HikariProxyConnection@43473566 wrapping com.mysql.cj.jdbc.ConnectionImpl@5f481b73] will not be managed by Spring ==\u0026gt; Preparing: SELECT id,name,price,version FROM t_product WHERE id=? ==\u0026gt; Parameters: 1(Long) \u0026lt;== Columns: id, name, price, version \u0026lt;== Row: 1, 外星人, 150, 6 \u0026lt;== Total: 1 Closing non transactional SqlSession [org.apache.ibatis.session.defaults.DefaultSqlSession@4cbc2e3b] Creating a new SqlSession SqlSession [org.apache.ibatis.session.defaults.DefaultSqlSession@57562473] was not registered for synchronization because synchronization is not active JDBC Connection [HikariProxyConnection@2050360660 wrapping com.mysql.cj.jdbc.ConnectionImpl@5f481b73] will not be managed by Spring ==\u0026gt; Preparing: UPDATE t_product SET name=?, price=?, version=? WHERE id=? AND version=? ==\u0026gt; Parameters: 外星人(String), 120(Integer), 7(Integer), 1(Long), 6(Integer) \u0026lt;== Updates: 1 Closing non transactional SqlSession [org.apache.ibatis.session.defaults.DefaultSqlSession@57562473] 通用枚举 # 添加一个enum类\n@Getter public enum SexEnum { MALE(1, \u0026#34;男\u0026#34;), FEMALE(2, \u0026#34;女\u0026#34;); private Integer sex; private String sexName; SexEnum(Integer sex, String sexName) { this.sex = sex; this.sexName = sexName; } } 数据库增加一个sex 字段,实体类增加一个sex属性 实体类\nprivate SexEnum sex; 进行添加\n@Test public void testEnum(){ User user=new User(); user.setUserName(\u0026#34;enum - 测试名字\u0026#34;); user.setSexEnum(SexEnum.MALE); int insert = userMapper.insert(user); System.out.println(insert); } 注意看sql日志,有报错信息\n==\u0026gt; Preparing: INSERT INTO t_user ( name, sex ) VALUES ( ?, ? ) ==\u0026gt; Parameters: enum - 测试名字(String), MALE(String) ### SQL: INSERT INTO t_user ( name, sex ) VALUES ( ?, ? ) ### Cause: java.sql.SQLException: Incorrect integer value: \u0026#39;MALE\u0026#39; for column \u0026#39;sex\u0026#39; at row 1 插入了非数字\n修正,enum类添加注解\n@EnumValue //将注解所标识的属性的值设置到数据库 private Integer sex; 扫描通用枚举的包 application.yml中\nmybatis-plus: type-enums-package: com.ly.mybatisplus.enums 运行测试类并查看日志\n==\u0026gt; Preparing: INSERT INTO t_user ( name, sex ) VALUES ( ?, ? ) ==\u0026gt; Parameters: enum - 测试名字(String), 1(Integer) \u0026lt;== Updates: 1 代码生成器 # {% post_link study/mybatis_plus/official/hello 在28%进度的地方 %}\nmybatis-plus 代码自动生成\nmaven 依赖\n\u0026lt;!-- https://mvnrepository.com/artifact/com.baomidou/mybatis-plus-generator --\u0026gt; \u0026lt;dependency\u0026gt; \u0026lt;groupId\u0026gt;com.baomidou\u0026lt;/groupId\u0026gt; \u0026lt;artifactId\u0026gt;mybatis-plus-generator\u0026lt;/artifactId\u0026gt; \u0026lt;version\u0026gt;3.5.2\u0026lt;/version\u0026gt; \u0026lt;/dependency\u0026gt; \u0026lt;!-- https://mvnrepository.com/artifact/org.apache.velocity/velocity-engine-core --\u0026gt; \u0026lt;dependency\u0026gt; \u0026lt;groupId\u0026gt;org.apache.velocity\u0026lt;/groupId\u0026gt; \u0026lt;artifactId\u0026gt;velocity-engine-core\u0026lt;/artifactId\u0026gt; \u0026lt;version\u0026gt;2.3\u0026lt;/version\u0026gt; \u0026lt;/dependency\u0026gt; 在测试类中编写程序让其自动生成\nimport com.baomidou.mybatisplus.generator.FastAutoGenerator; import com.baomidou.mybatisplus.generator.config.DataSourceConfig; import org.apache.ibatis.jdbc.ScriptRunner; import java.io.InputStream; import java.io.InputStreamReader; import java.sql.Connection; import java.sql.SQLException; /** * \u0026lt;p\u0026gt; * 快速生成 * \u0026lt;/p\u0026gt; * * @author lanjerry * @since 2021-09-16 */ public class FastAutoGeneratorTest { /** * 执行初始化数据库脚本 */ public static void before() throws SQLException { Connection conn = DATA_SOURCE_CONFIG.build().getConn(); InputStream inputStream = FastAutoGeneratorTest.class.getResourceAsStream(\u0026#34;/db/schema-mysql.sql\u0026#34;); ScriptRunner scriptRunner = new ScriptRunner(conn); scriptRunner.setAutoCommit(true); scriptRunner.runScript(new InputStreamReader(inputStream)); conn.close(); } /** * 数据源配置 */ private static final DataSourceConfig.Builder DATA_SOURCE_CONFIG = new DataSourceConfig .Builder(\u0026#34;jdbc:mysql://localhost:3306/mybatis_plus_demo?useUnicode=true\u0026amp;characterEncoding=utf-8\u0026amp;allowMultiQueries=true\u0026amp;nullCatalogMeansCurrent=true\u0026#34;, \u0026#34;root\u0026#34;, \u0026#34;123456\u0026#34;); /** * 执行 run */ public static void main(String[] args) throws SQLException { before(); FastAutoGenerator.create(DATA_SOURCE_CONFIG) // 全局配置 .globalConfig((scanner, builder) -\u0026gt; builder.author(scanner.apply(\u0026#34;请输入作者名称\u0026#34;))) // 包配置 .packageConfig((scanner, builder) -\u0026gt; builder.parent(scanner.apply(\u0026#34;请输入包名\u0026#34;))) // 策略配置 .strategyConfig((scanner, builder) -\u0026gt; builder.addInclude(scanner.apply(\u0026#34;请输入表名,多个表名用,隔开\u0026#34;))) /* 模板引擎配置,默认 Velocity 可选模板引擎 Beetl 或 Freemarker .templateEngine(new BeetlTemplateEngine()) .templateEngine(new FreemarkerTemplateEngine()) */ .execute(); } } shang gui gu 配置 模拟多数据源环境 # 新建一个mybatis-plus数据库和表 maven依赖添加\n\u0026lt;!-- https://mvnrepository.com/artifact/com.baomidou/dynamic-datasource-spring-boot-starter --\u0026gt; \u0026lt;dependency\u0026gt; \u0026lt;groupId\u0026gt;com.baomidou\u0026lt;/groupId\u0026gt; \u0026lt;artifactId\u0026gt;dynamic-datasource-spring-boot-starter\u0026lt;/artifactId\u0026gt; \u0026lt;version\u0026gt;3.5.1\u0026lt;/version\u0026gt; \u0026lt;/dependency\u0026gt; 前提 使用mybatis_plus中的t_product表 及mybatis_plus1中的t_product1表\nyml配置\nspring: datasource: dynamic: primary: master #设置默认的数据源或者数据源组,默认值即为master strict: false #严格匹配数据源,默认false. true未匹配到指定数据源时抛异常,false使用默认数据源 datasource: master: url: jdbc:mysql://localhost:3306/mybatis_plus?characterEncoding=utf-8\u0026amp;\u0026amp;useSSL=false\u0026amp;\u0026amp;allowPublicKeyRetrieval=true username: root password: 123456 driver-class-name: com.mysql.jdbc.Driver # 3.2.0开始支持SPI可省略此配置 slave_1: url: jdbc:mysql://localhost:3306/mybatis_plus_1?characterEncoding=utf-8\u0026amp;\u0026amp;useSSL=false\u0026amp;\u0026amp;allowPublicKeyRetrieval=true username: root password: 123456 driver-class-name: com.mysql.jdbc.Driver #slave_2: # url: ENC(xxxxx) # 内置加密,使用请查看详细文档 # username: ENC(xxxxx) # password: ENC(xxxxx) # driver-class-name: com.mysql.jdbc.Driver #......省略 #以上会配置一个默认库master,一个组slave下有两个子库slave_1,slave_2 代码\n结构 安装MyBatisX插件 # 插件市场 自动定位 MyBatis代码快速生成 # 配置 url及密码配置 使用 自动生成 "},{"id":375,"href":"/zh/docs/technology/MyBatis-Plus/bl_sgg_/19-39/","title":"mybatis-plus-sgg-19-39","section":"基础(尚硅谷)_","content":" 通用Service应用 # 这里会出现 publicKey is now allowed ,在数据库连接语句后面加上这句话即可 allowPublicKeyRetrieval=true\nspring: #配置数据源 datasource: #配置数据源类型 type: com.zaxxer.hikari.HikariDataSource #配置数据源各个信息 driver-class-name: com.mysql.cj.jdbc.Driver url: jdbc:mysql://localhost:3306/mybatis_plus?characterEncoding=utf-8\u0026amp;\u0026amp;useSSL=false\u0026amp;\u0026amp;allowPublicKeyRetrieval=true username: root password: 123456 查询\n@Test public void testList(){ //List\u0026lt;User\u0026gt; list = userService.list(); long count = userService.count(); System.out.println(\u0026#34;总条数:\u0026#34;+count); } SQL执行语句\n==\u0026gt; Preparing: SELECT COUNT( * ) FROM user ==\u0026gt; Parameters: \u0026lt;== Columns: COUNT( * ) \u0026lt;== Row: 5 \u0026lt;== Total: 1 批量添加\n@Test public void batchInsert(){ List\u0026lt;User\u0026gt; users=new ArrayList\u0026lt;\u0026gt;(); for(int i=0;i\u0026lt;10;i++){ User user=new User(); user.setName(\u0026#34;name\u0026#34;+i); user.setEmail(\u0026#34;email\u0026#34;+i); users.add(user); } boolean b = userService.saveBatch(users); System.out.println(\u0026#34;result:\u0026#34;+b); } sql日志输出\n==\u0026gt; Preparing: INSERT INTO user ( id, name, email ) VALUES ( ?, ?, ? ) ==\u0026gt; Parameters: 1532579686881243138(Long), name0(String), email0(String) ==\u0026gt; Parameters: 1532579687124512770(Long), name1(String), email1(String) ==\u0026gt; Parameters: 1532579687128707074(Long), name2(String), email2(String) ==\u0026gt; Parameters: 1532579687128707075(Long), name3(String), email3(String) ==\u0026gt; Parameters: 1532579687132901377(Long), name4(String), email4(String) ==\u0026gt; Parameters: 1532579687137095681(Long), name5(String), email5(String) ==\u0026gt; Parameters: 1532579687137095682(Long), name6(String), email6(String) ==\u0026gt; Parameters: 1532579687141289985(Long), name7(String), email7(String) ==\u0026gt; Parameters: 1532579687145484289(Long), name8(String), email8(String) ==\u0026gt; Parameters: 1532579687145484290(Long), name9(String), email9(String) result:true 注意,这里是一个个的insert into ,而不是一条(单个的sql语句进行循环添加)\nMyBatis-Plus常用注解1 # 现在将mysql数据库表user名改为t_user 会提示下面的报错\nCause: java.sql.BatchUpdateException: Table \u0026#39;mybatis_plus.user\u0026#39; doesn\u0026#39;t exist 说明mybatis plus查询的时候会去找实体类名一样的表\n使用@TableName(\u0026ldquo;t_user\u0026rdquo;) 设置实体类对应的表名\n@Data @TableName(\u0026#34;t_user\u0026#34;) public class User { private Long id; private String name; private Integer age; private String email; } 修改后执行成功 统一添加\nmybatis-plus: configuration: global-config: db-config: table-prefix: t_ 指定主键名 假设现在把数据库列名和bean的属性名id改为uid,此时新增一条记录\nField \u0026#39;uid\u0026#39; doesn\u0026#39;t have a default value ; Field \u0026#39;uid\u0026#39; doesn\u0026#39;t have a default value; nested exception is java.sql.SQLException: Field \u0026#39;uid\u0026#39; doesn\u0026#39;t have a default value 说明此时没有为uid赋值 使用@TableId告诉mybatis-plus那个字段为主键,让mybatis-plus为他赋默认值\n@Data public class User { @TableId private Long uid; private String name; private Integer age; private String email; } sql打印\n==\u0026gt; Preparing: INSERT INTO t_user ( uid, name, age ) VALUES ( ?, ?, ? ) ==\u0026gt; Parameters: 1532582462671618050(Long), 张三(String), 18(Integer) \u0026lt;== Updates: 1 @TableId的value属性 # 用于指定绑定的主键的字段 假设此时将bean的主键属性名为id,数据库主键名是uid\n此时运行,会提示\n### SQL: INSERT INTO t_user ( id, name, age ) VALUES ( ?, ?, ? ) ### Cause: java.sql.SQLSyntaxErrorException: Unknown column \u0026#39;id\u0026#39; in \u0026#39;field list\u0026#39; 他会拿bean的属性来生成sql语句\n加上@TableId(value=\u0026ldquo;uid\u0026rdquo;)后运行正常\n@TableId的value属性 # /** * 生成ID类型枚举类 * * @author hubin * @since 2015-11-10 */ @Getter public enum IdType { /** * 数据库ID自增 * \u0026lt;p\u0026gt;该类型请确保数据库设置了 ID自增 否则无效\u0026lt;/p\u0026gt; */ AUTO(0), /** * 该类型为未设置主键类型(注解里等于跟随全局,全局里约等于 INPUT) */ NONE(1), /** * 用户输入ID * \u0026lt;p\u0026gt;该类型可以通过自己注册自动填充插件进行填充\u0026lt;/p\u0026gt; */ INPUT(2), /* 以下3种类型、只有当插入对象ID 为空,才自动填充。 */ /** * 分配ID (主键类型为number或string), * 默认实现类 {@link com.baomidou.mybatisplus.core.incrementer.DefaultIdentifierGenerator}(雪花算法) * * @since 3.3.0 */ ASSIGN_ID(3), /** * 分配UUID (主键类型为 string) * 默认实现类 {@link com.baomidou.mybatisplus.core.incrementer.DefaultIdentifierGenerator}(UUID.replace(\u0026#34;-\u0026#34;,\u0026#34;\u0026#34;)) */ ASSIGN_UUID(4); private final int key; IdType(int key) { this.key = key; } } //使用自增 @TableId(value=\u0026#34;uid\u0026#34;,type = IdType.AUTO ) private Long id; 然后将数据库主键设置为自动递增\n新增后id为6\n通过全局属性设置主键生成策略 # 全局配置设置\nmybatis-plus: global-config: db-config: id-type: auto 雪花算法 # 数据库扩展方式:主从复制、业务分库、数据库分表 数据库拆分:水平拆分、垂直拆分 水平分表相对垂直分表,会引入更多的复杂性,比如要求唯一的数据id该怎么处理 可以给每个分表都给定一个范围大小,但是这样分段大小不好取 可以取模,但是如果增加了机器,原来的值主键(怎么处理是个问题 雪花算法,由Twitter公布的分布式主键生成算法 能够保证不同表的主键的不重复性,以及相同表的主键的有序性 核心思想 MyBatis-Plus常用注解2 # 此时数据库字段名为name,如果现在实体类的名字改为userName,那么会报错\nINSERT INTO t_user ( user_name, age ) VALUES ( ?, ? ) 又一次证明了MyBatis-plus通过实体类属性猜测数据库表的相关字段\n使用@TableFiled来指定对应的字段名\n@TableField(value = \u0026#34;name\u0026#34;) private String userName; 查询\n代码\n@Test public void selectTest() { User user = userService.getById(5L); System.out.println(\u0026#34;结果:\u0026#34; + user); } sql执行语句\n==\u0026gt; Preparing: SELECT uid AS id,name AS userName,age,email,is_deleted_ly FROM t_user WHERE uid=? AND is_deleted_ly=0 ==\u0026gt; Parameters: 5(Long) \u0026lt;== Columns: id, userName, age, email, is_deleted_ly \u0026lt;== Row: 5, Billie, 24, email被修改了, 0 \u0026lt;== Total: 1 Closing non transactional SqlSession [org.apache.ibatis.session.defaults.DefaultSqlSession@5e048149] 结果:User(id=5, userName=Billie, age=24, email=email被修改了, isDeletedLy=0) 逻辑删除(主要是允许数据的恢复) 这里增加一个isDeletedLy字段(这里为了测试,一般是isDeleted)\n在User类添加下面的字段\n@TableLogic private Integer isDeletedLy; 逻辑删除\n代码\n@Test public void deleteLogic() { boolean save = userService.removeBatchByIds(Arrays.asList(1L,2L,3L)); System.out.println(\u0026#34;结果:\u0026#34; + save); } sql执行语句 注意,这里使用了is_deleted_ly=0是因为在下面的步骤加入了逻辑删除注解\n==\u0026gt; Preparing: UPDATE t_user SET is_deleted_ly=1 WHERE uid=? AND is_deleted_ly=0 ==\u0026gt; Parameters: 1(Long) ==\u0026gt; Parameters: 2(Long) ==\u0026gt; Parameters: 3(Long) 结果 条件构造器 # 结构 解释 查看BaseWrapper源码\n/** * Mapper 继承该接口后,无需编写 mapper.xml 文件,即可获得CRUD功能 * \u0026lt;p\u0026gt;这个 Mapper 支持 id 泛型\u0026lt;/p\u0026gt; * * @author hubin * @since 2016-01-23 */ public interface BaseMapper\u0026lt;T\u0026gt; extends Mapper\u0026lt;T\u0026gt; { /** * 插入一条记录 * * @param entity 实体对象 */ int insert(T entity); /** * 根据 ID 删除 * * @param id 主键ID */ int deleteById(Serializable id); /** * 根据实体(ID)删除 * * @param entity 实体对象 * @since 3.4.4 */ int deleteById(T entity); /** * 根据 columnMap 条件,删除记录 * * @param columnMap 表字段 map 对象 */ int deleteByMap(@Param(Constants.COLUMN_MAP) Map\u0026lt;String, Object\u0026gt; columnMap); /** * 根据 entity 条件,删除记录 * * @param queryWrapper 实体对象封装操作类(可以为 null,里面的 entity 用于生成 where 语句) */ int delete(@Param(Constants.WRAPPER) Wrapper\u0026lt;T\u0026gt; queryWrapper); /** * 删除(根据ID或实体 批量删除) * * @param idList 主键ID列表或实体列表(不能为 null 以及 empty) */ int deleteBatchIds(@Param(Constants.COLLECTION) Collection\u0026lt;?\u0026gt; idList); /** * 根据 ID 修改 * * @param entity 实体对象 */ int updateById(@Param(Constants.ENTITY) T entity); /** * 根据 whereEntity 条件,更新记录 * * @param entity 实体对象 (set 条件值,可以为 null) * @param updateWrapper 实体对象封装操作类(可以为 null,里面的 entity 用于生成 where 语句) */ int update(@Param(Constants.ENTITY) T entity, @Param(Constants.WRAPPER) Wrapper\u0026lt;T\u0026gt; updateWrapper); /** * 根据 ID 查询 * * @param id 主键ID */ T selectById(Serializable id); /** * 查询(根据ID 批量查询) * * @param idList 主键ID列表(不能为 null 以及 empty) */ List\u0026lt;T\u0026gt; selectBatchIds(@Param(Constants.COLLECTION) Collection\u0026lt;? extends Serializable\u0026gt; idList); /** * 查询(根据 columnMap 条件) * * @param columnMap 表字段 map 对象 */ List\u0026lt;T\u0026gt; selectByMap(@Param(Constants.COLUMN_MAP) Map\u0026lt;String, Object\u0026gt; columnMap); /** * 根据 entity 条件,查询一条记录 * \u0026lt;p\u0026gt;查询一条记录,例如 qw.last(\u0026#34;limit 1\u0026#34;) 限制取一条记录, 注意:多条数据会报异常\u0026lt;/p\u0026gt; * * @param queryWrapper 实体对象封装操作类(可以为 null) */ default T selectOne(@Param(Constants.WRAPPER) Wrapper\u0026lt;T\u0026gt; queryWrapper) { List\u0026lt;T\u0026gt; ts = this.selectList(queryWrapper); if (CollectionUtils.isNotEmpty(ts)) { if (ts.size() != 1) { throw ExceptionUtils.mpe(\u0026#34;One record is expected, but the query result is multiple records\u0026#34;); } return ts.get(0); } return null; } /** * 根据 Wrapper 条件,判断是否存在记录 * * @param queryWrapper 实体对象封装操作类 * @return */ default boolean exists(Wrapper\u0026lt;T\u0026gt; queryWrapper) { Long count = this.selectCount(queryWrapper); return null != count \u0026amp;\u0026amp; count \u0026gt; 0; } /** * 根据 Wrapper 条件,查询总记录数 * * @param queryWrapper 实体对象封装操作类(可以为 null) */ Long selectCount(@Param(Constants.WRAPPER) Wrapper\u0026lt;T\u0026gt; queryWrapper); /** * 根据 entity 条件,查询全部记录 * * @param queryWrapper 实体对象封装操作类(可以为 null) */ List\u0026lt;T\u0026gt; selectList(@Param(Constants.WRAPPER) Wrapper\u0026lt;T\u0026gt; queryWrapper); /** * 根据 Wrapper 条件,查询全部记录 * * @param queryWrapper 实体对象封装操作类(可以为 null) */ List\u0026lt;Map\u0026lt;String, Object\u0026gt;\u0026gt; selectMaps(@Param(Constants.WRAPPER) Wrapper\u0026lt;T\u0026gt; queryWrapper); /** * 根据 Wrapper 条件,查询全部记录 * \u0026lt;p\u0026gt;注意: 只返回第一个字段的值\u0026lt;/p\u0026gt; * * @param queryWrapper 实体对象封装操作类(可以为 null) */ List\u0026lt;Object\u0026gt; selectObjs(@Param(Constants.WRAPPER) Wrapper\u0026lt;T\u0026gt; queryWrapper); /** * 根据 entity 条件,查询全部记录(并翻页) * * @param page 分页查询条件(可以为 RowBounds.DEFAULT) * @param queryWrapper 实体对象封装操作类(可以为 null) */ \u0026lt;P extends IPage\u0026lt;T\u0026gt;\u0026gt; P selectPage(P page, @Param(Constants.WRAPPER) Wrapper\u0026lt;T\u0026gt; queryWrapper); /** * 根据 Wrapper 条件,查询全部记录(并翻页) * * @param page 分页查询条件 * @param queryWrapper 实体对象封装操作类 */ \u0026lt;P extends IPage\u0026lt;Map\u0026lt;String, Object\u0026gt;\u0026gt;\u0026gt; P selectMapsPage(P page, @Param(Constants.WRAPPER) Wrapper\u0026lt;T\u0026gt; queryWrapper); } Wrapper条件组装 queryWrapper测试\n@Test public void test01() { QueryWrapper\u0026lt;User\u0026gt; userQueryWrapper = new QueryWrapper\u0026lt;\u0026gt;(); //链式结构调用 userQueryWrapper.like(\u0026#34;name\u0026#34;, \u0026#34;a\u0026#34;) .between(\u0026#34;age\u0026#34;, 10, 30) .isNotNull(\u0026#34;email\u0026#34;); List\u0026lt;User\u0026gt; users = userMapper.selectList(userQueryWrapper); users.forEach(System.out::println); } sql日志打印\n//注意,这里出现了逻辑删除条件 ==\u0026gt; Preparing: SELECT uid AS id,name AS userName,age,email,is_deleted_ly FROM t_user WHERE is_deleted_ly=0 AND (name LIKE ? AND age BETWEEN ? AND ? AND email IS NOT NULL) ==\u0026gt; Parameters: %a%(String), 10(Integer), 30(Integer) \u0026lt;== Columns: id, userName, age, email, is_deleted_ly \u0026lt;== Row: 4, Sandy, 21, test4@baomidou.com, 0 \u0026lt;== Row: 5, Billiea, 24, email被修改了, 0 \u0026lt;== Total: 2 Closing non transactional SqlSession [org.apache.ibatis.session.defaults.DefaultSqlSession@19650aa6] User(id=4, userName=Sandy, age=21, email=test4@baomidou.com, isDeletedLy=0) User(id=5, userName=Billiea, age=24, email=email被修改了, isDeletedLy=0) 使用排序\n@Test public void test02() { QueryWrapper\u0026lt;User\u0026gt; userQueryWrapper = new QueryWrapper\u0026lt;\u0026gt;(); userQueryWrapper.orderByDesc(\u0026#34;age\u0026#34;) .orderByAsc(\u0026#34;uid\u0026#34;); List\u0026lt;User\u0026gt; users = userMapper.selectList(userQueryWrapper); users.forEach(System.out::println); } sql日志打印\n==\u0026gt; Preparing: SELECT uid AS id,name AS userName,age,email,is_deleted_ly FROM t_user WHERE is_deleted_ly=0 ORDER BY age DESC,uid ASC ==\u0026gt; Parameters: \u0026lt;== Columns: id, userName, age, email, is_deleted_ly \u0026lt;== Row: 7, 张三6, 38, test6@baomidou.com, 0 \u0026lt;== Row: 5, Billiea, 24, email被修改了, 0 \u0026lt;== Row: 4, Sandy, 21, test4@baomidou.com, 0 \u0026lt;== Row: 6, 张三5, 18, test5@baomidou.com, 0 \u0026lt;== Row: 8, 张三a, 18, null, 0 \u0026lt;== Total: 5 Closing non transactional SqlSession [org.apache.ibatis.session.defaults.DefaultSqlSession@7158daf2] User(id=7, userName=张三6, age=38, email=test6@baomidou.com, isDeletedLy=0) User(id=5, userName=Billiea, age=24, email=email被修改了, isDeletedLy=0) User(id=4, userName=Sandy, age=21, email=test4@baomidou.com, isDeletedLy=0) User(id=6, userName=张三5, age=18, email=test5@baomidou.com, isDeletedLy=0) User(id=8, userName=张三a, age=18, email=null, isDeletedLy=0) 条件逻辑删除\n代码\n@Test public void test03() { QueryWrapper\u0026lt;User\u0026gt; userQueryWrapper=new QueryWrapper\u0026lt;\u0026gt;(); userQueryWrapper.isNull(\u0026#34;email\u0026#34;); int deleted = userMapper.delete(userQueryWrapper); System.out.println(deleted); } sql日志输出\n==\u0026gt; Preparing: UPDATE t_user SET is_deleted_ly=1 WHERE is_deleted_ly=0 AND (email IS NULL) ==\u0026gt; Parameters: \u0026lt;== Updates: 1 修改\n@Test public void test04() { QueryWrapper\u0026lt;User\u0026gt; userQueryWrapper=new QueryWrapper\u0026lt;\u0026gt;(); //(age\u0026gt;23且用户名包含a) 或 (邮箱为null) userQueryWrapper.gt(\u0026#34;age\u0026#34;,23) .like(\u0026#34;name\u0026#34;,\u0026#34;a\u0026#34;) .or() .isNull(\u0026#34;email\u0026#34;); User user=new User(); user.setUserName(\u0026#34;被修改了\u0026#34;); int deleted = userMapper.update(user,userQueryWrapper); System.out.println(deleted); } sql日志打印\n==\u0026gt; Preparing: UPDATE t_user SET name=? WHERE is_deleted_ly=0 AND (age \u0026gt; ? AND name LIKE ? OR email IS NULL) ==\u0026gt; Parameters: 被修改了(String), 23(Integer), %a%(String) \u0026lt;== Updates: 1 条件优先级\n@Test public void test05() { QueryWrapper\u0026lt;User\u0026gt; userQueryWrapper = new QueryWrapper\u0026lt;\u0026gt;(); //(age\u0026gt;23且用户名包含a) 或 (邮箱为null) userQueryWrapper .like(\u0026#34;name\u0026#34;, \u0026#34;a\u0026#34;) //and里面是一个条件构造器 .and( userQueryWrapper1 -\u0026gt; userQueryWrapper1.gt(\u0026#34;age\u0026#34;, 20) .or() .isNull(\u0026#34;email\u0026#34;) ); User user = new User(); user.setUserName(\u0026#34;被修改了\u0026#34;); int deleted = userMapper.update(user, userQueryWrapper); System.out.println(deleted); } sql日志输出\n==\u0026gt; Preparing: UPDATE t_user SET name=? WHERE is_deleted_ly=0 AND (name LIKE ? AND (age \u0026gt; ? OR email IS NULL)) ==\u0026gt; Parameters: 被修改了(String), %a%(String), 20(Integer) \u0026lt;== Updates: 1 注意 or也有优先级的参数 只查询某些字段\n@Test public void test06() { QueryWrapper\u0026lt;User\u0026gt; userQueryWrapper =new QueryWrapper\u0026lt;\u0026gt;(); userQueryWrapper.select(\u0026#34;uid\u0026#34;,\u0026#34;name\u0026#34;); List\u0026lt;Map\u0026lt;String, Object\u0026gt;\u0026gt; maps = userMapper.selectMaps(userQueryWrapper); System.out.println(maps); } sql输出\n==\u0026gt; Preparing: SELECT uid,name FROM t_user WHERE is_deleted_ly=0 ==\u0026gt; Parameters: \u0026lt;== Columns: uid, name \u0026lt;== Row: 4, 被修改了 \u0026lt;== Row: 5, 被修改了 \u0026lt;== Row: 6, 张三5 \u0026lt;== Row: 7, 张三6 \u0026lt;== Total: 4 子查询 假设需要完整下面的sql查询 代码\n@Test public void test7(){ //查询id小于等于100 QueryWrapper\u0026lt;User\u0026gt; userQueryWrapper=new QueryWrapper\u0026lt;\u0026gt;(); userQueryWrapper.inSql(\u0026#34;uid\u0026#34;, \u0026#34;select uid from t_user where uid \u0026lt;= 100\u0026#34;); List\u0026lt;User\u0026gt; users = userMapper.selectList(userQueryWrapper); users.forEach(System.out::println); } sql输出\n==\u0026gt; Preparing: SELECT uid AS id,name AS userName,age,email,is_deleted_ly FROM t_user WHERE is_deleted_ly=0 AND (uid IN (select uid from t_user where uid \u0026lt;= 100)) ==\u0026gt; Parameters: \u0026lt;== Columns: id, userName, age, email, is_deleted_ly \u0026lt;== Row: 4, 被修改了, 21, test4@baomidou.com, 0 \u0026lt;== Row: 5, 被修改了, 24, email被修改了, 0 \u0026lt;== Row: 6, 张三5, 18, test5@baomidou.com, 0 \u0026lt;== Row: 7, 张三6, 38, test6@baomidou.com, 0 \u0026lt;== Total: 4 UpdateWrapper\n@Test public void test8(){ //(age\u0026gt;23且用户名包含a) 或 (邮箱为null) UpdateWrapper\u0026lt;User\u0026gt; updateWrapper=new UpdateWrapper\u0026lt;\u0026gt;(); updateWrapper.like(\u0026#34;name\u0026#34;,\u0026#34;a\u0026#34;) .and(userUpdateWrapper -\u0026gt; userUpdateWrapper.gt(\u0026#34;age\u0026#34;,23).or().isNotNull(\u0026#34;email\u0026#34;)); updateWrapper.set(\u0026#34;name\u0026#34;,\u0026#34;小黑\u0026#34;).set(\u0026#34;email\u0026#34;,\u0026#34;abc@ly.com\u0026#34;); userMapper.update(null,updateWrapper); } sql日志输出\n==\u0026gt; Preparing: UPDATE t_user SET name=?,email=? WHERE is_deleted_ly=0 AND (name LIKE ? AND (age \u0026gt; ? OR email IS NOT NULL)) ==\u0026gt; Parameters: 小黑(String), abc@ly.com(String), %a%(String), 23(Integer) \u0026lt;== Updates: 0 模拟用户操作组装条件\n@Test public void test9(){ String username=\u0026#34;\u0026#34;; Integer ageBegin=null; Integer ageEnd=30; QueryWrapper\u0026lt;User\u0026gt; queryWrapper=new QueryWrapper\u0026lt;\u0026gt;(); if(StringUtils.isNotBlank(username)){ queryWrapper.like(\u0026#34;user_name\u0026#34;,username); } if( ageBegin!=null){ queryWrapper.gt(\u0026#34;age\u0026#34;,ageBegin); } if( ageEnd!=null){ queryWrapper.le(\u0026#34;age\u0026#34;,ageEnd); } userMapper.selectList(queryWrapper); } sql日志打印\n==\u0026gt; Preparing: SELECT uid AS id,name AS userName,age,email,is_deleted_ly FROM t_user WHERE is_deleted_ly=0 AND (age \u0026lt;= ?) ==\u0026gt; Parameters: 30(Integer) \u0026lt;== Columns: id, userName, age, email, is_deleted_ly \u0026lt;== Row: 4, 被修改了, 21, test4@baomidou.com, 0 \u0026lt;== Row: 5, 被修改了, 24, email被修改了, 0 \u0026lt;== Row: 6, 张三5, 18, test5@baomidou.com, 0 \u0026lt;== Total: 3 使用condition处理条件\n@Test public void test10(){ String username=\u0026#34;abc\u0026#34;; Integer ageBegin=null; Integer ageEnd=30; QueryWrapper\u0026lt;User\u0026gt; queryWrapper=new QueryWrapper\u0026lt;\u0026gt;(); queryWrapper.like(StringUtils.isNotBlank(username),\u0026#34;name\u0026#34;,username) .ge(ageBegin!=null,\u0026#34;age\u0026#34;,ageBegin); userMapper.selectList(queryWrapper); } sql日志输出\n==\u0026gt; Preparing: SELECT uid AS id,name AS userName,age,email,is_deleted_ly FROM t_user WHERE is_deleted_ly=0 AND (name LIKE ?) ==\u0026gt; Parameters: %abc%(String) \u0026lt;== Total: 0 "},{"id":376,"href":"/zh/docs/technology/MyBatis-Plus/bl_sgg_/12-18/","title":"mybatis-plus-sgg-12-18","section":"基础(尚硅谷)_","content":" BaseMapper # 注:使用 mvn dependency:resolve -Dclassifier=sources 来获得mapper源码\n一些接口介绍\n/** * 插入一条记录 * * @param entity 实体对象 */ int insert(T entity); /** * 根据 ID 删除 * * @param id 主键ID */ int deleteById(Serializable id); /** * 根据实体(ID)删除 * * @param entity 实体对象 * @since 3.4.4 */ int deleteById(T entity); /** * 根据 columnMap 条件,删除记录 * * @param columnMap 表字段 map 对象 */ int deleteByMap(@Param(Constants.COLUMN_MAP) Map\u0026lt;String, Object\u0026gt; columnMap); /** * 根据 entity 条件,删除记录 * * @param queryWrapper 实体对象封装操作类(可以为 null,里面的 entity 用于生成 where 语句) */ int delete(@Param(Constants.WRAPPER) Wrapper\u0026lt;T\u0026gt; queryWrapper); /** * 删除(根据ID或实体 批量删除) * * @param idList 主键ID列表或实体列表(不能为 null 以及 empty) */ int deleteBatchIds(@Param(Constants.COLLECTION) Collection\u0026lt;?\u0026gt; idList); /** * 根据 ID 修改 * * @param entity 实体对象 */ int updateById(@Param(Constants.ENTITY) T entity); /** * 根据 whereEntity 条件,更新记录 * * @param entity 实体对象 (set 条件值,可以为 null) * @param updateWrapper 实体对象封装操作类(可以为 null,里面的 entity 用于生成 where 语句) */ int update(@Param(Constants.ENTITY) T entity, @Param(Constants.WRAPPER) Wrapper\u0026lt;T\u0026gt; updateWrapper); /** * 根据 ID 查询 * * @param id 主键ID */ T selectById(Serializable id); /** * 查询(根据ID 批量查询) * * @param idList 主键ID列表(不能为 null 以及 empty) */ List\u0026lt;T\u0026gt; selectBatchIds(@Param(Constants.COLLECTION) Collection\u0026lt;? extends Serializable\u0026gt; idList); /** * 查询(根据 columnMap 条件) * * @param columnMap 表字段 map 对象 */ List\u0026lt;T\u0026gt; selectByMap(@Param(Constants.COLUMN_MAP) Map\u0026lt;String, Object\u0026gt; columnMap); /** * 根据 entity 条件,查询一条记录 * \u0026lt;p\u0026gt;查询一条记录,例如 qw.last(\u0026#34;limit 1\u0026#34;) 限制取一条记录, 注意:多条数据会报异常\u0026lt;/p\u0026gt; * * @param queryWrapper 实体对象封装操作类(可以为 null) */ default T selectOne(@Param(Constants.WRAPPER) Wrapper\u0026lt;T\u0026gt; queryWrapper) { List\u0026lt;T\u0026gt; ts = this.selectList(queryWrapper); if (CollectionUtils.isNotEmpty(ts)) { if (ts.size() != 1) { throw ExceptionUtils.mpe(\u0026#34;One record is expected, but the query result is multiple records\u0026#34;); } return ts.get(0); } return null; } /** * 根据 Wrapper 条件,判断是否存在记录 * * @param queryWrapper 实体对象封装操作类 * @return */ default boolean exists(Wrapper\u0026lt;T\u0026gt; queryWrapper) { Long count = this.selectCount(queryWrapper); return null != count \u0026amp;\u0026amp; count \u0026gt; 0; } /** * 根据 Wrapper 条件,查询总记录数 * * @param queryWrapper 实体对象封装操作类(可以为 null) */ Long selectCount(@Param(Constants.WRAPPER) Wrapper\u0026lt;T\u0026gt; queryWrapper); /** * 根据 entity 条件,查询全部记录 * * @param queryWrapper 实体对象封装操作类(可以为 null) */ List\u0026lt;T\u0026gt; selectList(@Param(Constants.WRAPPER) Wrapper\u0026lt;T\u0026gt; queryWrapper); /** * 根据 Wrapper 条件,查询全部记录 * * @param queryWrapper 实体对象封装操作类(可以为 null) */ List\u0026lt;Map\u0026lt;String, Object\u0026gt;\u0026gt; selectMaps(@Param(Constants.WRAPPER) Wrapper\u0026lt;T\u0026gt; queryWrapper); /** * 根据 Wrapper 条件,查询全部记录 * \u0026lt;p\u0026gt;注意: 只返回第一个字段的值\u0026lt;/p\u0026gt; * * @param queryWrapper 实体对象封装操作类(可以为 null) */ List\u0026lt;Object\u0026gt; selectObjs(@Param(Constants.WRAPPER) Wrapper\u0026lt;T\u0026gt; queryWrapper); /** * 根据 entity 条件,查询全部记录(并翻页) * * @param page 分页查询条件(可以为 RowBounds.DEFAULT) * @param queryWrapper 实体对象封装操作类(可以为 null) */ \u0026lt;P extends IPage\u0026lt;T\u0026gt;\u0026gt; P selectPage(P page, @Param(Constants.WRAPPER) Wrapper\u0026lt;T\u0026gt; queryWrapper); /** * 根据 Wrapper 条件,查询全部记录(并翻页) * * @param page 分页查询条件 * @param queryWrapper 实体对象封装操作类 */ \u0026lt;P extends IPage\u0026lt;Map\u0026lt;String, Object\u0026gt;\u0026gt;\u0026gt; P selectMapsPage(P page, @Param(Constants.WRAPPER) Wrapper\u0026lt;T\u0026gt; queryWrapper); BaseMapper测试\n新增\n@Test public void testInsert(){ User user=new User(); user.setName(\u0026#34;小明\u0026#34;); user.setAge(11); user.setEmail(\u0026#34;xx@163.com\u0026#34;); int insertNum = userMapper.insert(user); System.out.println(\u0026#34;result:\u0026#34;+insertNum); System.out.println(\u0026#34;result:\u0026#34;+user); } sql日志输出\n==\u0026gt; Preparing: INSERT INTO user ( id, name, age, email ) VALUES ( ?, ?, ?, ? ) ==\u0026gt; Parameters: 1532542803866394625(Long), 小明(String), 11(Integer), xx@163.com(String) \u0026lt;== Updates: 1 删除\nid删除\n@Test public void testDelete(){ int result = userMapper.deleteById(1532542803866394625L); System.out.println(result); } sql日志输出\n==\u0026gt; Preparing: DELETE FROM user WHERE id=? ==\u0026gt; Parameters: 1532542803866394625(Long) \u0026lt;== Updates: 1 Map删除\n@Test public void testDeleteByMap(){ Map\u0026lt;String,Object\u0026gt; hash=new HashMap\u0026lt;\u0026gt;(); hash.put(\u0026#34;name\u0026#34;,\u0026#34;Sandy\u0026#34;); hash.put(\u0026#34;age\u0026#34;,\u0026#34;1234\u0026#34;); int result = userMapper.deleteByMap(hash); System.out.println(result); } sql日志输出\n==\u0026gt; Preparing: DELETE FROM user WHERE name = ? AND age = ? ==\u0026gt; Parameters: Sandy(String), 1234(String) \u0026lt;== Updates: 0 批量删除\n@Test public void testDeleteByIds(){ List\u0026lt;Long\u0026gt; ids = Arrays.asList(1L, 2L, 5L); int result = userMapper.deleteBatchIds(ids); System.out.println(result); } sql日志输出\n==\u0026gt; Preparing: DELETE FROM user WHERE id IN ( ? , ? , ? ) ==\u0026gt; Parameters: 1(Long), 2(Long), 5(Long) \u0026lt;== Updates: 3 修改\n根据id修改\n@Test public void testUpdateById (){ User user=new User(); user.setId(5L); user.setEmail(\u0026#34;email被修改了\u0026#34; ); int result = userMapper.updateById(user); System.out.println(result); } sql日志输出\n==\u0026gt; Preparing: UPDATE user SET email=? WHERE id=? ==\u0026gt; Parameters: email被修改了(String), 5(Long) \u0026lt;== Updates: 1 注意,这里不会修改另一个字段name的值\n查询\n通过id查询用户信息\n@Test public void testSelectById (){ User user = userMapper.selectById(3); System.out.println(user); } sql日志输出\n==\u0026gt; Preparing: SELECT id,name,age,email FROM user WHERE id=? ==\u0026gt; Parameters: 3(Integer) \u0026lt;== Columns: id, name, age, email \u0026lt;== Row: 3, Tom, 28, test3@baomidou.com \u0026lt;== Total: 1 通过id集合查询\n@Test public void testSelectByIds() { List\u0026lt;User\u0026gt; users = userMapper.selectBatchIds(Arrays.asList(1L, 2L, 5L)); users.forEach(System.out::println); } sql日志输出\n==\u0026gt; Preparing: SELECT id,name,age,email FROM user WHERE id IN ( ? , ? , ? ) ==\u0026gt; Parameters: 1(Long), 2(Long), 5(Long) \u0026lt;== Columns: id, name, age, email \u0026lt;== Row: 1, Jone, 18, test1@baomidou.com \u0026lt;== Row: 2, Jack, 20, test2@baomidou.com \u0026lt;== Row: 5, Billie, 24, email被修改了 \u0026lt;== Total: 3 通过map查询\n@Test public void testSelectMap() { Map\u0026lt;String, Object\u0026gt; hashMap = new HashMap\u0026lt;\u0026gt;(); hashMap.put(\u0026#34;name\u0026#34;,\u0026#34;Jon\u0026#34;); hashMap.put(\u0026#34;age\u0026#34;,18); List\u0026lt;User\u0026gt; users = userMapper.selectByMap(hashMap); users.forEach(System.out::println); } sql日志输出\n==\u0026gt; Preparing: SELECT id,name,age,email FROM user WHERE name = ? AND age = ? ==\u0026gt; Parameters: Tom(String), 18(Integer) \u0026lt;== Columns: id, name, age, email \u0026lt;== Row: 3, Tom, 18, test3@baomidou.com \u0026lt;== Total: 1 查询所有数据\n@Test public void testSelectAll() { List\u0026lt;User\u0026gt; users = userMapper.selectList(null); users.forEach(System.out::println); } sql日志输出\n==\u0026gt; Preparing: SELECT id,name,age,email FROM user ==\u0026gt; Parameters: \u0026lt;== Columns: id, name, age, email \u0026lt;== Row: 1, Jone, 18, test1@baomidou.com \u0026lt;== Row: 2, Jack, 20, test2@baomidou.com \u0026lt;== Row: 3, Tom, 18, test3@baomidou.com \u0026lt;== Row: 4, Sandy, 21, test4@baomidou.com \u0026lt;== Row: 5, Billie, 24, email被修改了 \u0026lt;== Total: 5 自定义功能 # mapper映射文件默认位置\nmybatis-plus: configuration: log-impl: org.apache.ibatis.logging.stdout.StdOutImpl mapper-locations: - classpath:/mapper/**/*.xml #默认位置 映射文件配置 /mapper/UserMapper.xml\n\u0026lt;?xml version=\u0026#34;1.0\u0026#34; encoding=\u0026#34;utf-8\u0026#34; ?\u0026gt; \u0026lt;!DOCTYPE mapper PUBLIC \u0026#34;-//mybatis.org//DTD Mapper 3.0//EN\u0026#34; \u0026#34;http://mybatis.org/dtd/mybatis-3-mapper.dtd\u0026#34; \u0026gt; \u0026lt;mapper namespace=\u0026#34;com.ly.mybatisplus.mapper.UserMapper\u0026#34;\u0026gt; \u0026lt;select id=\u0026#34;selectMapById\u0026#34; resultType=\u0026#34;map\u0026#34;\u0026gt; select id,name,age,email from user where id = #{id} and 1=1 \u0026lt;/select\u0026gt; \u0026lt;/mapper\u0026gt; 代码执行\n@Test public void testSelectCustom() { Map\u0026lt;String, Object\u0026gt; map = userMapper.selectMapById(2L); System.out.println(map); } sql日志执行\n==\u0026gt; Preparing: select id,name,age,email from user where id = ? and 1=1 ==\u0026gt; Parameters: 2(Long) \u0026lt;== Columns: id, name, age, email \u0026lt;== Row: 2, Jack, 20, test2@baomidou.com \u0026lt;== Total: 1 通用Service接口 # 和通用Mapper的方法名有区分 Service CRUD中\n使用get查询【mapper-select】 remove删除 【mapper-delete】 list查询集合 page分页 IService源码\n/** * 顶级 Service * * @author hubin * @since 2018-06-23 */ public interface IService\u0026lt;T\u0026gt; { /** * 默认批次提交数量 */ int DEFAULT_BATCH_SIZE = 1000; /** * 插入一条记录(选择字段,策略插入) * * @param entity 实体对象 */ default boolean save(T entity) { return SqlHelper.retBool(getBaseMapper().insert(entity)); } /** * 插入(批量) * * @param entityList 实体对象集合 */ @Transactional(rollbackFor = Exception.class) default boolean saveBatch(Collection\u0026lt;T\u0026gt; entityList) { return saveBatch(entityList, DEFAULT_BATCH_SIZE); } /** * 插入(批量) * * @param entityList 实体对象集合 * @param batchSize 插入批次数量 */ boolean saveBatch(Collection\u0026lt;T\u0026gt; entityList, int batchSize); /** * 批量修改插入 * * @param entityList 实体对象集合 */ @Transactional(rollbackFor = Exception.class) default boolean saveOrUpdateBatch(Collection\u0026lt;T\u0026gt; entityList) { return saveOrUpdateBatch(entityList, DEFAULT_BATCH_SIZE); } /** * 批量修改插入 * * @param entityList 实体对象集合 * @param batchSize 每次的数量 */ boolean saveOrUpdateBatch(Collection\u0026lt;T\u0026gt; entityList, int batchSize); /** * 根据 ID 删除 * * @param id 主键ID */ default boolean removeById(Serializable id) { return SqlHelper.retBool(getBaseMapper().deleteById(id)); } /** * 根据 ID 删除 * * @param id 主键(类型必须与实体类型字段保持一致) * @param useFill 是否启用填充(为true的情况,会将入参转换实体进行delete删除) * @return 删除结果 * @since 3.5.0 */ default boolean removeById(Serializable id, boolean useFill) { throw new UnsupportedOperationException(\u0026#34;不支持的方法!\u0026#34;); } /** * 根据实体(ID)删除 * * @param entity 实体 * @since 3.4.4 */ default boolean removeById(T entity) { return SqlHelper.retBool(getBaseMapper().deleteById(entity)); } /** * 根据 columnMap 条件,删除记录 * * @param columnMap 表字段 map 对象 */ default boolean removeByMap(Map\u0026lt;String, Object\u0026gt; columnMap) { Assert.notEmpty(columnMap, \u0026#34;error: columnMap must not be empty\u0026#34;); return SqlHelper.retBool(getBaseMapper().deleteByMap(columnMap)); } /** * 根据 entity 条件,删除记录 * * @param queryWrapper 实体包装类 {@link com.baomidou.mybatisplus.core.conditions.query.QueryWrapper} */ default boolean remove(Wrapper\u0026lt;T\u0026gt; queryWrapper) { return SqlHelper.retBool(getBaseMapper().delete(queryWrapper)); } /** * 删除(根据ID 批量删除) * * @param list 主键ID或实体列表 */ default boolean removeByIds(Collection\u0026lt;?\u0026gt; list) { if (CollectionUtils.isEmpty(list)) { return false; } return SqlHelper.retBool(getBaseMapper().deleteBatchIds(list)); } /** * 批量删除 * * @param list 主键ID或实体列表 * @param useFill 是否填充(为true的情况,会将入参转换实体进行delete删除) * @return 删除结果 * @since 3.5.0 */ @Transactional(rollbackFor = Exception.class) default boolean removeByIds(Collection\u0026lt;?\u0026gt; list, boolean useFill) { if (CollectionUtils.isEmpty(list)) { return false; } if (useFill) { return removeBatchByIds(list, true); } return SqlHelper.retBool(getBaseMapper().deleteBatchIds(list)); } /** * 批量删除(jdbc批量提交) * * @param list 主键ID或实体列表(主键ID类型必须与实体类型字段保持一致) * @return 删除结果 * @since 3.5.0 */ @Transactional(rollbackFor = Exception.class) default boolean removeBatchByIds(Collection\u0026lt;?\u0026gt; list) { return removeBatchByIds(list, DEFAULT_BATCH_SIZE); } /** * 批量删除(jdbc批量提交) * * @param list 主键ID或实体列表(主键ID类型必须与实体类型字段保持一致) * @param useFill 是否启用填充(为true的情况,会将入参转换实体进行delete删除) * @return 删除结果 * @since 3.5.0 */ @Transactional(rollbackFor = Exception.class) default boolean removeBatchByIds(Collection\u0026lt;?\u0026gt; list, boolean useFill) { return removeBatchByIds(list, DEFAULT_BATCH_SIZE, useFill); } /** * 批量删除(jdbc批量提交) * * @param list 主键ID或实体列表 * @param batchSize 批次大小 * @return 删除结果 * @since 3.5.0 */ default boolean removeBatchByIds(Collection\u0026lt;?\u0026gt; list, int batchSize) { throw new UnsupportedOperationException(\u0026#34;不支持的方法!\u0026#34;); } /** * 批量删除(jdbc批量提交) * * @param list 主键ID或实体列表 * @param batchSize 批次大小 * @param useFill 是否启用填充(为true的情况,会将入参转换实体进行delete删除) * @return 删除结果 * @since 3.5.0 */ default boolean removeBatchByIds(Collection\u0026lt;?\u0026gt; list, int batchSize, boolean useFill) { throw new UnsupportedOperationException(\u0026#34;不支持的方法!\u0026#34;); } /** * 根据 ID 选择修改 * * @param entity 实体对象 */ default boolean updateById(T entity) { return SqlHelper.retBool(getBaseMapper().updateById(entity)); } /** * 根据 UpdateWrapper 条件,更新记录 需要设置sqlset * * @param updateWrapper 实体对象封装操作类 {@link com.baomidou.mybatisplus.core.conditions.update.UpdateWrapper} */ default boolean update(Wrapper\u0026lt;T\u0026gt; updateWrapper) { return update(null, updateWrapper); } /** * 根据 whereEntity 条件,更新记录 * * @param entity 实体对象 * @param updateWrapper 实体对象封装操作类 {@link com.baomidou.mybatisplus.core.conditions.update.UpdateWrapper} */ default boolean update(T entity, Wrapper\u0026lt;T\u0026gt; updateWrapper) { return SqlHelper.retBool(getBaseMapper().update(entity, updateWrapper)); } /** * 根据ID 批量更新 * * @param entityList 实体对象集合 */ @Transactional(rollbackFor = Exception.class) default boolean updateBatchById(Collection\u0026lt;T\u0026gt; entityList) { return updateBatchById(entityList, DEFAULT_BATCH_SIZE); } /** * 根据ID 批量更新 * * @param entityList 实体对象集合 * @param batchSize 更新批次数量 */ boolean updateBatchById(Collection\u0026lt;T\u0026gt; entityList, int batchSize); /** * TableId 注解存在更新记录,否插入一条记录 * * @param entity 实体对象 */ boolean saveOrUpdate(T entity); /** * 根据 ID 查询 * * @param id 主键ID */ default T getById(Serializable id) { return getBaseMapper().selectById(id); } /** * 查询(根据ID 批量查询) * * @param idList 主键ID列表 */ default List\u0026lt;T\u0026gt; listByIds(Collection\u0026lt;? extends Serializable\u0026gt; idList) { return getBaseMapper().selectBatchIds(idList); } /** * 查询(根据 columnMap 条件) * * @param columnMap 表字段 map 对象 */ default List\u0026lt;T\u0026gt; listByMap(Map\u0026lt;String, Object\u0026gt; columnMap) { return getBaseMapper().selectByMap(columnMap); } /** * 根据 Wrapper,查询一条记录 \u0026lt;br/\u0026gt; * \u0026lt;p\u0026gt;结果集,如果是多个会抛出异常,随机取一条加上限制条件 wrapper.last(\u0026#34;LIMIT 1\u0026#34;)\u0026lt;/p\u0026gt; * * @param queryWrapper 实体对象封装操作类 {@link com.baomidou.mybatisplus.core.conditions.query.QueryWrapper} */ default T getOne(Wrapper\u0026lt;T\u0026gt; queryWrapper) { return getOne(queryWrapper, true); } /** * 根据 Wrapper,查询一条记录 * * @param queryWrapper 实体对象封装操作类 {@link com.baomidou.mybatisplus.core.conditions.query.QueryWrapper} * @param throwEx 有多个 result 是否抛出异常 */ T getOne(Wrapper\u0026lt;T\u0026gt; queryWrapper, boolean throwEx); /** * 根据 Wrapper,查询一条记录 * * @param queryWrapper 实体对象封装操作类 {@link com.baomidou.mybatisplus.core.conditions.query.QueryWrapper} */ Map\u0026lt;String, Object\u0026gt; getMap(Wrapper\u0026lt;T\u0026gt; queryWrapper); /** * 根据 Wrapper,查询一条记录 * * @param queryWrapper 实体对象封装操作类 {@link com.baomidou.mybatisplus.core.conditions.query.QueryWrapper} * @param mapper 转换函数 */ \u0026lt;V\u0026gt; V getObj(Wrapper\u0026lt;T\u0026gt; queryWrapper, Function\u0026lt;? super Object, V\u0026gt; mapper); /** * 查询总记录数 * * @see Wrappers#emptyWrapper() */ default long count() { return count(Wrappers.emptyWrapper()); } /** * 根据 Wrapper 条件,查询总记录数 * * @param queryWrapper 实体对象封装操作类 {@link com.baomidou.mybatisplus.core.conditions.query.QueryWrapper} */ default long count(Wrapper\u0026lt;T\u0026gt; queryWrapper) { return SqlHelper.retCount(getBaseMapper().selectCount(queryWrapper)); } /** * 查询列表 * * @param queryWrapper 实体对象封装操作类 {@link com.baomidou.mybatisplus.core.conditions.query.QueryWrapper} */ default List\u0026lt;T\u0026gt; list(Wrapper\u0026lt;T\u0026gt; queryWrapper) { return getBaseMapper().selectList(queryWrapper); } /** * 查询所有 * * @see Wrappers#emptyWrapper() */ default List\u0026lt;T\u0026gt; list() { return list(Wrappers.emptyWrapper()); } /** * 翻页查询 * * @param page 翻页对象 * @param queryWrapper 实体对象封装操作类 {@link com.baomidou.mybatisplus.core.conditions.query.QueryWrapper} */ default \u0026lt;E extends IPage\u0026lt;T\u0026gt;\u0026gt; E page(E page, Wrapper\u0026lt;T\u0026gt; queryWrapper) { return getBaseMapper().selectPage(page, queryWrapper); } /** * 无条件翻页查询 * * @param page 翻页对象 * @see Wrappers#emptyWrapper() */ default \u0026lt;E extends IPage\u0026lt;T\u0026gt;\u0026gt; E page(E page) { return page(page, Wrappers.emptyWrapper()); } /** * 查询列表 * * @param queryWrapper 实体对象封装操作类 {@link com.baomidou.mybatisplus.core.conditions.query.QueryWrapper} */ default List\u0026lt;Map\u0026lt;String, Object\u0026gt;\u0026gt; listMaps(Wrapper\u0026lt;T\u0026gt; queryWrapper) { return getBaseMapper().selectMaps(queryWrapper); } /** * 查询所有列表 * * @see Wrappers#emptyWrapper() */ default List\u0026lt;Map\u0026lt;String, Object\u0026gt;\u0026gt; listMaps() { return listMaps(Wrappers.emptyWrapper()); } /** * 查询全部记录 */ default List\u0026lt;Object\u0026gt; listObjs() { return listObjs(Function.identity()); } /** * 查询全部记录 * * @param mapper 转换函数 */ default \u0026lt;V\u0026gt; List\u0026lt;V\u0026gt; listObjs(Function\u0026lt;? super Object, V\u0026gt; mapper) { return listObjs(Wrappers.emptyWrapper(), mapper); } /** * 根据 Wrapper 条件,查询全部记录 * * @param queryWrapper 实体对象封装操作类 {@link com.baomidou.mybatisplus.core.conditions.query.QueryWrapper} */ default List\u0026lt;Object\u0026gt; listObjs(Wrapper\u0026lt;T\u0026gt; queryWrapper) { return listObjs(queryWrapper, Function.identity()); } /** * 根据 Wrapper 条件,查询全部记录 * * @param queryWrapper 实体对象封装操作类 {@link com.baomidou.mybatisplus.core.conditions.query.QueryWrapper} * @param mapper 转换函数 */ default \u0026lt;V\u0026gt; List\u0026lt;V\u0026gt; listObjs(Wrapper\u0026lt;T\u0026gt; queryWrapper, Function\u0026lt;? super Object, V\u0026gt; mapper) { return getBaseMapper().selectObjs(queryWrapper).stream().filter(Objects::nonNull).map(mapper).collect(Collectors.toList()); } /** * 翻页查询 * * @param page 翻页对象 * @param queryWrapper 实体对象封装操作类 {@link com.baomidou.mybatisplus.core.conditions.query.QueryWrapper} */ default \u0026lt;E extends IPage\u0026lt;Map\u0026lt;String, Object\u0026gt;\u0026gt;\u0026gt; E pageMaps(E page, Wrapper\u0026lt;T\u0026gt; queryWrapper) { return getBaseMapper().selectMapsPage(page, queryWrapper); } /** * 无条件翻页查询 * * @param page 翻页对象 * @see Wrappers#emptyWrapper() */ default \u0026lt;E extends IPage\u0026lt;Map\u0026lt;String, Object\u0026gt;\u0026gt;\u0026gt; E pageMaps(E page) { return pageMaps(page, Wrappers.emptyWrapper()); } /** * 获取对应 entity 的 BaseMapper * * @return BaseMapper */ BaseMapper\u0026lt;T\u0026gt; getBaseMapper(); /** * 获取 entity 的 class * * @return {@link Class\u0026lt;T\u0026gt;} */ Class\u0026lt;T\u0026gt; getEntityClass(); /** * 以下的方法使用介绍: * * 一. 名称介绍 * 1. 方法名带有 query 的为对数据的查询操作, 方法名带有 update 的为对数据的修改操作 * 2. 方法名带有 lambda 的为内部方法入参 column 支持函数式的 * 二. 支持介绍 * * 1. 方法名带有 query 的支持以 {@link ChainQuery} 内部的方法名结尾进行数据查询操作 * 2. 方法名带有 update 的支持以 {@link ChainUpdate} 内部的方法名为结尾进行数据修改操作 * * 三. 使用示例,只用不带 lambda 的方法各展示一个例子,其他类推 * 1. 根据条件获取一条数据: `query().eq(\u0026#34;column\u0026#34;, value).one()` * 2. 根据条件删除一条数据: `update().eq(\u0026#34;column\u0026#34;, value).remove()` * */ /** * 链式查询 普通 * * @return QueryWrapper 的包装类 */ default QueryChainWrapper\u0026lt;T\u0026gt; query() { return ChainWrappers.queryChain(getBaseMapper()); } /** * 链式查询 lambda 式 * \u0026lt;p\u0026gt;注意:不支持 Kotlin \u0026lt;/p\u0026gt; * * @return LambdaQueryWrapper 的包装类 */ default LambdaQueryChainWrapper\u0026lt;T\u0026gt; lambdaQuery() { return ChainWrappers.lambdaQueryChain(getBaseMapper()); } /** * 链式查询 lambda 式 * kotlin 使用 * * @return KtQueryWrapper 的包装类 */ default KtQueryChainWrapper\u0026lt;T\u0026gt; ktQuery() { return ChainWrappers.ktQueryChain(getBaseMapper(), getEntityClass()); } /** * 链式查询 lambda 式 * kotlin 使用 * * @return KtQueryWrapper 的包装类 */ default KtUpdateChainWrapper\u0026lt;T\u0026gt; ktUpdate() { return ChainWrappers.ktUpdateChain(getBaseMapper(), getEntityClass()); } /** * 链式更改 普通 * * @return UpdateWrapper 的包装类 */ default UpdateChainWrapper\u0026lt;T\u0026gt; update() { return ChainWrappers.updateChain(getBaseMapper()); } /** * 链式更改 lambda 式 * \u0026lt;p\u0026gt;注意:不支持 Kotlin \u0026lt;/p\u0026gt; * * @return LambdaUpdateWrapper 的包装类 */ default LambdaUpdateChainWrapper\u0026lt;T\u0026gt; lambdaUpdate() { return ChainWrappers.lambdaUpdateChain(getBaseMapper()); } /** * \u0026lt;p\u0026gt; * 根据updateWrapper尝试更新,否继续执行saveOrUpdate(T)方法 * 此次修改主要是减少了此项业务代码的代码量(存在性验证之后的saveOrUpdate操作) * \u0026lt;/p\u0026gt; * * @param entity 实体对象 */ default boolean saveOrUpdate(T entity, Wrapper\u0026lt;T\u0026gt; updateWrapper) { return update(entity, updateWrapper) || saveOrUpdate(entity); } } IService有一个实现类:ServiceImpl\n自定义一个业务Service接口,继承IService\npublic interface UserService extends IService\u0026lt;User\u0026gt;{ } 编写一个实现类,实现UserService接口,并继承ServiceImpl\npublic class UserServiceImpl extends ServiceImpl\u0026lt;UserMapper, User\u0026gt; implements UserService { } 这样既可以使用自定义的功能,也可以使用MybatisPlus提供的功能\n# "},{"id":377,"href":"/zh/docs/technology/MyBatis-Plus/bl_sgg_/01-11/","title":"mybatis-plus-sgg-01-11","section":"基础(尚硅谷)_","content":" 简介 # MyBatis-Plus是一个MyBatis的增强工具,在MyBatis的基础上只做增强不做改变,为简化开发、提高效率而生 这里以MySQL数据库为案例,以Idea作为IDE,使用Maven作为构建工具,使用SpringBoot完成各种功能 课程主要内容 特性 润物无声、效率至上、丰富功能 支持的数据库 框架结构 左边:扫描实体,从实体抽取属性猜测数据库字段 通过默认提供的方法使用sql语句,然后注入mybatis容器 开发环境 # 测试数据库和表 # 这里创建数据库mybatis_plus\n然后创建表user\nDROP TABLE IF EXISTS user; CREATE TABLE user ( id BIGINT(20) NOT NULL COMMENT \u0026#39;主键ID\u0026#39;, name VARCHAR(30) NULL DEFAULT NULL COMMENT \u0026#39;姓名\u0026#39;, age INT(11) NULL DEFAULT NULL COMMENT \u0026#39;年龄\u0026#39;, email VARCHAR(50) NULL DEFAULT NULL COMMENT \u0026#39;邮箱\u0026#39;, PRIMARY KEY (id) ); 插入默认数据\nDELETE FROM user; INSERT INTO user (id, name, age, email) VALUES (1, \u0026#39;Jone\u0026#39;, 18, \u0026#39;test1@baomidou.com\u0026#39;), (2, \u0026#39;Jack\u0026#39;, 20, \u0026#39;test2@baomidou.com\u0026#39;), (3, \u0026#39;Tom\u0026#39;, 28, \u0026#39;test3@baomidou.com\u0026#39;), (4, \u0026#39;Sandy\u0026#39;, 21, \u0026#39;test4@baomidou.com\u0026#39;), (5, \u0026#39;Billie\u0026#39;, 24, \u0026#39;test5@baomidou.com\u0026#39;); Spring Boot工程 # 添加依赖,并install Lombok 插件\n\u0026lt;parent\u0026gt; \u0026lt;groupId\u0026gt;org.springframework.boot\u0026lt;/groupId\u0026gt; \u0026lt;artifactId\u0026gt;spring-boot-starter-parent\u0026lt;/artifactId\u0026gt; \u0026lt;version\u0026gt;2.7.0\u0026lt;/version\u0026gt; \u0026lt;relativePath/\u0026gt; \u0026lt;/parent\u0026gt; \u0026lt;dependencies\u0026gt; \u0026lt;dependency\u0026gt; \u0026lt;groupId\u0026gt;org.springframework.boot\u0026lt;/groupId\u0026gt; \u0026lt;artifactId\u0026gt;spring-boot-starter\u0026lt;/artifactId\u0026gt; \u0026lt;/dependency\u0026gt; \u0026lt;dependency\u0026gt; \u0026lt;groupId\u0026gt;org.springframework.boot\u0026lt;/groupId\u0026gt; \u0026lt;artifactId\u0026gt;spring-boot-starter-test\u0026lt;/artifactId\u0026gt; \u0026lt;scope\u0026gt;test\u0026lt;/scope\u0026gt; \u0026lt;/dependency\u0026gt; \u0026lt;dependency\u0026gt; \u0026lt;groupId\u0026gt;com.baomidou\u0026lt;/groupId\u0026gt; \u0026lt;artifactId\u0026gt;mybatis-plus-boot-starter\u0026lt;/artifactId\u0026gt; \u0026lt;version\u0026gt;3.5.1\u0026lt;/version\u0026gt; \u0026lt;/dependency\u0026gt; \u0026lt;!-- https://mvnrepository.com/artifact/mysql/mysql-connector-java --\u0026gt; \u0026lt;dependency\u0026gt; \u0026lt;groupId\u0026gt;mysql\u0026lt;/groupId\u0026gt; \u0026lt;artifactId\u0026gt;mysql-connector-java\u0026lt;/artifactId\u0026gt; \u0026lt;version\u0026gt;8.0.29\u0026lt;/version\u0026gt; \u0026lt;/dependency\u0026gt; \u0026lt;!-- https://mvnrepository.com/artifact/org.projectlombok/lombok --\u0026gt; \u0026lt;dependency\u0026gt; \u0026lt;groupId\u0026gt;org.projectlombok\u0026lt;/groupId\u0026gt; \u0026lt;artifactId\u0026gt;lombok\u0026lt;/artifactId\u0026gt; \u0026lt;version\u0026gt;1.18.24\u0026lt;/version\u0026gt; \u0026lt;scope\u0026gt;provided\u0026lt;/scope\u0026gt; \u0026lt;/dependency\u0026gt; \u0026lt;!-- https://mvnrepository.com/artifact/com.baomidou/mybatis-plus-generator --\u0026gt; \u0026lt;dependency\u0026gt; \u0026lt;groupId\u0026gt;com.baomidou\u0026lt;/groupId\u0026gt; \u0026lt;artifactId\u0026gt;mybatis-plus-generator\u0026lt;/artifactId\u0026gt; \u0026lt;version\u0026gt;3.5.2\u0026lt;/version\u0026gt; \u0026lt;/dependency\u0026gt; \u0026lt;/dependencies\u0026gt; 基础配置 # 创建spring boot启动类\n@SpringBootApplication public class MybatisPlusApplication { public static void main(String[] args) { SpringApplication.run(MybatisPlusApplication.class, args); } } 配置resources/application.yml文件\nspring: #配置数据源 datasource: #配置数据源类型 type: com.zaxxer.hikari.HikariDataSource #配置数据源各个信息 driver-class-name: com.mysql.cj.jdbc.Driver url: jdbc:mysql://localhost:3306/mybatis_plus?characterEncoding=utf-8\u0026amp;\u0026amp;useSSL=false username: root password: 123456 这个时候启动会直接结束,因为我们没有使用springboot-web 包 实体类的创建\npackage com.ly.mybatisplus.pojo; import lombok.Data; //相当于get set 无参构造器 hashCode()和equals()、toString()方法重写 @Data public class User { private Long id; private String name; private Integer age; private String email; } mapper的创建 mapper/UserMapper\npackage com.ly.mybatisplus.mapper; import com.baomidou.mybatisplus.core.mapper.BaseMapper; import com.ly.mybatisplus.pojo.User; import org.springframework.stereotype.Repository; //将这个类标记成持久层组件 处理测试类中红色下划线的问题 @Repository public interface UserMapper extends BaseMapper\u0026lt;User\u0026gt; { } 设置mapper接口所在的包\npackage com.ly.mybatisplus; import org.mybatis.spring.annotation.MapperScan; import org.springframework.boot.SpringApplication; import org.springframework.boot.autoconfigure.SpringBootApplication; @SpringBootApplication //扫描指定包下的mapper接口 @MapperScan(\u0026#34;com.ly.mybatisplus.mapper\u0026#34;) public class MybatisPlusApplication { public static void main(String[] args) { SpringApplication.run(MybatisPlusApplication.class, args); } } 测试 # 测试类的创建\nimport com.ly.mybatisplus.MybatisPlusApplication; import com.ly.mybatisplus.mapper.UserMapper; import com.ly.mybatisplus.pojo.User; import org.junit.jupiter.api.Test; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.test.context.SpringBootTest; import java.util.List; //可能是由于没有使用web包依赖,这里要加入classes指定启动类 @SpringBootTest(classes = MybatisPlusApplication.class) public class MybatisPlusTest { @Autowired private UserMapper userMapper; @Test public void testSelect(){ //通过条件构造器查询list集合 null表示没有条件 List\u0026lt;User\u0026gt; users = userMapper.selectList(null); users.forEach(System.out::println); } } 加入日志功能 # 配置application.yml加入日志\n#日志 mybatis-plus: configuration: log-impl: org.apache.ibatis.logging.stdout.StdOutImpl 效果 如上图,查询的字段名来自于实体类属性 "},{"id":378,"href":"/zh/docs/technology/Algorithm/_algorithhms_4th_/3.1.1-3.1.7/","title":"算法红皮书 3.1.1-3.1.7","section":"_算法(第四版)_","content":" 查找 # 经典查找算法\n用符号表这个词来描述抽象的表格,将信息(值)存储在其中,然后按照指定的键来获取这些信息\n符号表也被称为字典\n在英语字典里,键就是单词,值就是单词对应的定义、发音和词源 符号表有时又叫索引 在一本书的索引中,键就是术语,而值就是书中该术语出现的所有页码 下面学习三种经典的数据类型:二叉查找树、红黑树和散列表\n符号表 # 符号表最主要的目的是将键和值联系起来\n用例能够将一个键值对插入符号表并希望在之后能够从符号表的所有键值对中按照键直接找到相对应的值\n符号表是一种存储键值对的数据结构,支持两种操作:插入(put),即将一组新的键值对存入表中;查找(get),即根据给定的键得到相应的值\n典型的符号表应用 API # 符号表是一种典型的数据类型 :代表着一组定义清晰的值及相应的操作。使用应用程序编程接口(API)来精确地定义这些操作 一种简单的泛型符号表API ST(Symbol Table) 泛型 对于符号表,我们通过明确地指定查找时键和值的类型来区分它们的不同角色【key和value】\n重复的键\n这里假设每个键只对应着一个值(表中不允许重复值) 当用例代码向表中存入的键值对和表中已有的键(及关联的值)冲突时,新的值会替代旧的值 上述定义了关联数组的抽象形式,可以将符号表想象成数组,键即索引,值即数组中的值 在一个关联数组中,键可以是任意类型,但我们仍然可以用它来快速访问数组的值 非Java使用st[key]来替代st.get(key),用st[key]=val来替代st.put(key,val) 键不能为空\n值不能为空(因为规定当键不存在时get()返回空) 当值为空表示删除\n删除操作\n延时删除,先将键对应的值置空,之后在某个时刻删除所有值为空的键\n即时删除,立即从表中删除指定的键 put实现的开头:\nif(val == null){ delete(key); return; } 便捷方法 迭代 在API第一行加上implements Iterable\u0026lt;Key\u0026gt; ,所有实现都包含iterator()方法来实现hasNext()和next()方法的迭代器;这里采用另一种方式:定义keys返回一个Iterable\u0026lt;Key\u0026gt;对象以方便便利所有的键,且允许遍历一部分\n键的等价性 自定义的键需要重写equals()方法;且最好使用不可变数据类型作为键\n有序符号表 # 一种有序的泛型符号表的API 最大值和最小值、向下取整和向上取整、排名和选择 对于0到size()-1的所有i都有i==rank(select(i)),且所有的键都满足key == select(rank(key)) 范围查找 例外情况 当一个方法需要返回一个键但表中没有合适的键可以返回时,我们约定抛出一个异常 有序符号表中冗余有序性方法的默认实现 所有Comparable类型中compareTo()方法和equals()方法的一致性 ★★成本模型 在学习符号表的实现时,我们会统计比较的次数(等价性测试或是键的相互比较),在内循环**不进行比较(极少)**的情况下,我们会统计数组的访问次数 用例举例 # 如何使用\n行为测试用例 简单的符号表测试用例 测试用例的键、值和输出 性能测试用例 查找频率最高的单词\npublic class FrequencyCounter { public static void main(String[] args) { int minlen = Integer.parseint(args[0]); // 最小键长 ST\u0026lt;String, Integer\u0026gt; st = new ST\u0026lt;String, Integer\u0026gt;(); while (!StdIn.isEmpty()) { // 构造符号表并统计频率 String word = StdIn.readString(); if (word.length() \u0026lt; minlen) continue; // 忽略较短的单词 if (!st.contains(word)) st.put(word, 1); else st.put(word, st.get(word) + 1); } // 找出出现频率最高的单词 String max = \u0026#34; \u0026#34;; st.put(max, 0); for (String word : st.keys()) if (st.get(word) \u0026gt; st.get(max)) max = word; StdOut.println(max + \u0026#34; \u0026#34; + st.get(max)); } } 每个单词都会被作为键进行搜索,因此处理性能和输入文本的单词总量必然有关;其次,输入的每个单词都会被存入符号表(输入中不重复单词的总数也就是所有键都被插入以后符号表的大小),因此输入流中不同的单词的总数也是相关的\n无序链表中的顺序查找 # 顺序查找的定义:使用链表,每个结点存储一个键值对,get()实现即为遍历链表,用equals()方法比较需被查找的键和每个节点中的键。如果匹配成功我们就返回相应的值,否则返回null。put()实现也是遍历链表,用equals()方法比较需被查找的键和每个节点中的键。如果匹配成功我们就用第二个参数指定更新和该键相关联的值,否则我们就用给定的键值对创建一个新的结点并将其插入到链表的开头。这种方法称为顺序查找\n命中表示一次成功的查找,未命中表示一次失败的查找\n使用基于链表的符号表的索引用例的轨迹 顺序查找(基于无序链表)\npublic class SequentialSearchST\u0026lt;Key,Value\u0026gt; { private Node first; //链表首结点 private class Node{ //链表结点的定义 Key key; Value val; Node next; public Node(Key key, Value val, Node next) { this.key = key; this.val = val; this.next = next; } } public Value get(Key key) { // 查找给定的键,返回相关联的值 for (Node x = first; x != null; x = x.next) if (key.equals(x.key)) return x.val; // 命中 return null; // 未名中 } public void put(Key key, Value val) { // 查找给定的键,找到则更新其值,否则在表中新建结点 for (Node x = first; x != null; x = x.next) if (key.equals(x.key)) { x.val = val; return; } // 命中,更新 first = new Node(key, val, first); // 未命中,新建结点 } } 在含有N 对键值的基于(无序)链表的符号表中,未命中的查找和插入操作都需要N 次比较。命中的查找在最坏情况下需要N 次比较。特别地,向一个空表中插入N 个不同的键需要∼ N2/2 次比较\n查找一个已经存在的键并不需要线性级别的时间。一种度量方法是查找表中的每个键,并将总 时间除以N\n有序数组中的二分查找 # 有序符号表API:它使用的数据结构是一对平行的数组,一个存储键一个存储值\n//rank():小于k的键的数量\npublic class BinarySearchST\u0026lt;Key extends Comparable\u0026lt;Key\u0026gt;, Value\u0026gt; { private Key[] keys; private Value[] vals; private int N; public BinarySearchST(int capacity) { // 调整数组大小的标准代码请见算法1.1 keys = (Key[]) new Comparable[capacity]; vals = (Value[]) new Object[capacity]; } public int size() { return N; } public Value get(Key key) { if (isEmpty()) return null; int i = rank(key); //注意,这里i不一定就是刚好是key所在的索引,他表示比key的值小的个数 if (i \u0026lt; N \u0026amp;\u0026amp; keys[i].compareTo(key) == 0) return vals[i]; else return null; } public int rank(Key key) // 请见算法3.2(续1) public void put(Key key, Value val) { // 查找键,找到则更新值,否则创建新的元素 int i = rank(key); if (i \u0026lt; N \u0026amp;\u0026amp; keys[i].compareTo(key) == 0) { vals[i] = val; return; } //根据成本模型,这里不统计 for (int j = N; j \u0026gt; i; j--) { keys[j] = keys[j-1]; vals[j] = vals[j-1]; } keys[i] = key; vals[i] = val; N++; } public void delete(Key key) // 该方法的实现请见练习3.1.16 } 二分查找 我们使用有序数组存储键的原因是,经典二分查找法能够根据数组的索引大大减少每次查找所需的比较次数\n递归的二分查找\npublic int rank(Key key, int lo, int hi) { if (hi \u0026lt; lo) return lo; int mid = lo + (hi - lo) / 2; int cmp = key.compareTo(keys[mid]); if (cmp \u0026lt; 0) return rank(key, lo, mid-1); else if (cmp \u0026gt; 0) return rank(key, mid+1, hi); else return mid; //如果存在,返回key所在位置的索引(也就是key之前的元素的个数 ) } rank()的性质:如果表中存在该键,rank()应该返回该键的位置,也就是表中小于它的键的数量;如果表中不存在该键,ran()还是应该返回表中小于它的键的数量\n好好想想算法3.2(续1)中非递归的rank() 为什么能够做到这些(你可以证明两个版本的等价性,或者直接证明非递归版本中的循环在结束时lo 的值正好等于表中小于被查找的键的键的数量),所有程序员都能从这些思考中有所收获。(提示:lo 的初始值为0,且永远不会变小) 假设有下面这么一组数(key value)\n0 1 2 3 4 1 2 3 5 9 我要查找6,那么轨迹为: low=0,high=4,mid=2 low=2+1=3,high=4,mid=3 low=3+1=4,high=4,mid=4 low=4,high=4-1,此时high\u0026lt;low,返回low【也就是说找到了最接近于要查找的数的下标】\n带图轨迹 基于二分查找的有序符号表的其他操作\npublic Key min() { return keys[0]; } public Key max() { return keys[N-1]; } public Key select(int k) { return keys[k]; } //大于等于key的最小整数 public Key ceiling(Key key) { int i = rank(key); return keys[i]; } //小于等于key的最大整数 public Key floor(Key key) // 请见练习3.1.17 public Key delete(Key key) // 请见练习3.1.16 public Iterable\u0026lt;Key\u0026gt; keys(Key lo, Key hi) { Queue\u0026lt;Key\u0026gt; q = new Queue\u0026lt;Key\u0026gt;(); for (int i = rank(lo); i \u0026lt; rank(hi); i++) q.enqueue(keys[i]); if (contains(hi)) q.enqueue(keys[rank(hi)]); return q; } 对二分查找的分析 # 在N 个键的有序数组中进行二分查找最多需要(lgN+1)次比较(无论是否成功)\n向大小为N 的有序数组中插入一个新的元素在最坏情况下需要访问∼ 2N 次数组,因此向一个空符号表中插入N 个元素在最坏情况下需要访问∼ N2 次数组\n预览 # 简单的符号表实现的成本总结 符号表的各种实现的优缺点 我们有若干种高效的符号表实现,它们能够并且已经被应用于无数程序之中了 "},{"id":379,"href":"/zh/docs/technology/Algorithm/_algorithhms_4th_/2.5/","title":"算法红皮书 2.5","section":"_算法(第四版)_","content":" 排序如此有用的原因是,在有序的数组中查找一个元素,要比在一个无序的数组中查找简单得多 通用排序算法是最重要的 算法思想虽然简单,但是适用领域广泛 将各种数据排序 # Java的约定使得我们能够利用Java的回调机制将任意实现Comparable接口的数据类型排序\n我们的代码直接能够将String、Integer、Double 和一些其他例如File 和URL 类型的数组排序,因为它们都实现了Comparable 接口 交易事务 商业数据的处理,设想一家互联网商业公司为每笔交易记录都保存了所有的相关信息\npublic int compareTo(Transaction that) { return this.when.compareTo(that.when); } 指针排序 我们使用的方法在经典教材中被称为指针排序,因为我们只处理元素的引用而不移动数据本身\n不可变的键 用不可变的数据类型作为键,比如String、Integer、Double和File等\n廉价的交换\n使用引用的另一个好处是不必移动整个元素对于几乎任意大小的元素,使用引用使得在一般情况下交换的成本和比较的成本几乎相同(代价是需要额外的空间存储这些引用)\n研究将数字排序的算法性能的一种方法就是观察其所需的比较和交换总数,因为这里隐式地假设了比较和交换的成本是相同的\n多种排序方法\n根据情况将一组对象按照不同的方式排序。Java 的Comparator 接口允许我们在一个类之中实现多种排序方法 多键数组\n一个元素的多种属性都可能被用作排序的键\n我们可以定义多种比较器,要将Transaction 对象的数组按照时间排序可以调用: Insertion.sort(a, new Transaction.WhenOrder()) 或者这样来按照金额排序: Insertion.sort(a, new Transaction.HowMuchOrder()) 使用Comparator的插入排序\npublic static void sort(Object[] a, Comparator c) { int N = a.length; for (int i = 1; i \u0026lt; N; i++) for (int j = i; j \u0026gt; 0 \u0026amp;\u0026amp; less(Comparator, a[j], a[j-1]); j--) exch(a, j, j-1); } private static Boolean less(Comparator c, Object v, Object w) { return c.compare(v, w) \u0026lt; 0; } private static void exch(Object[] a, int i, int j) { Object t = a[i]; a[i] = a[j]; a[j] = t; } 使用比较器实现优先队列\n扩展优先队列 导入 java.util.Comparator; 为 MaxPQ 添加一个实例变量 comparator 以及一个构造函数,该构造函数接受一个比较器 作为参数并用它将comparator 初始化; 在 less()中检查 comparator属性是否为 null(如果不是的话就用它进行比较)。 //使用了Comparator的插入排序 import java.util.Comparator; public class Transaction { ... private final String who; private final Date when; private final double amount; ... public static class WhoOrder implements Comparator\u0026lt;Transaction\u0026gt; { public int compare(Transaction v, Transaction w) { return v.who.compareTo(w.who); } } public static class WhenOrder implements Comparator\u0026lt;Transaction\u0026gt; { public int compare(Transaction v, Transaction w) { return v.when.compareTo(w.when); } } public static class HowMuchOrder implements Comparator\u0026lt;Transaction\u0026gt; { public int compare(Transaction v, Transaction w) { if (v.amount \u0026lt; w.amount) return -1; if (v.amount \u0026gt; w.amount) return +1; return 0; } } } 稳定性\n如果一个排序算法能够保留数组中重复元素的相对位置则可以被称为是稳定的 例如,考虑一个需要处理大量含有地理位置和时间戳的事件的互联网商业应用程 序。首先,我们在事件发生时将它们挨个存储在一个数组中,这样在数组中它们已经是按照时间顺序排好了的。现在假设在进一步处理前将按照地理位置切分。一种简单的方法是将数组按照位置排序。如果排序算法不是稳定的,排序后的每个城市的交易可能不会再是按照时间顺序排列的了 我们学习过的一部分算法是稳定的(插入排序和归并排序),但很多不是(选择排序、希尔排序、快速排序和堆排序) 有很多办法能够将任意排序算法变成稳定的(请见练习2.5.18),但一般只有在稳定性是必要的情况下稳定的排序算法才有优势 图示 我应该使用哪种排序算法 # 各种排序算法的性能特点 快速排序是最快的通用排序算法 将原始类型数据排序 一些性能优先的应用的重点可能是将数字排序,因此更合理的做法是跳过引用直接将原始数据 类型的数据排序 Java系统库的排序算法 java.util.Arrays.sort() Java 的系统程序员选择对原始数据类型使用(三向切分的)快速排序,对引用类型使用归并排 序。这些选择实际上也暗示着用速度和空间(对于原始数据类型)来换取稳定性(对于引用类型), 如果考虑稳定性,则选择Merge.sort() 归并排序 问题的归约 # 归约指的是为解决某个问题而发明的算法正好可以用来解决另一种问题\n使用解决问题B 的方法来解决问题A 时,你都是在将A 归约为B。\n如果先将数据排序,那么解决剩下的问题就剩下线性级别的时间,归约后的运行时间的增长数量级由平方级别降低到了线性级别\n找出重复元素的个数(先排序,后遍历)\nQuick.sort(a); int count = 1; // 假设a.length \u0026gt; 0. for (int i = 1; i \u0026lt; a.length; i++) if (a[i].compareTo(a[i-1]) != 0) count++; Kendall tau距离\n优先队列\n在2.4 节中我们已经见过两个被归约为优先队列操作的问题的例子。一个是2.4.2.1 节中的TopM,它能够找到输入流中M 个最大的元素;另一个是2.4.4.7 节中的Multiway,它能够将M 个输入流归并为一个有序的输出流。这两个问题都可以轻易用长度为M 的优先队列解决\n中位数与顺序统计 (与快速排序有关)\n排序应用一览 # 商业计算:按照名字或者数字排序的账号、按照日期或者金额排序的交易、按照 邮编或者地址排序的邮件、按照名称或者日期排序的文件等, 处理这些数据必然需要排序算 信息搜索:有序的顺序可以使用经典的二分查找法 运筹学指的是研究数学模型并将其应用于问题解决和决策的领域 事件驱动模拟、数值计算、组合搜索 基于排序算法的算法 Prim算法和Dijkstra算法 Kruskal算法 霍夫曼压缩 字符串处理 "},{"id":380,"href":"/zh/docs/technology/Algorithm/_algorithhms_4th_/2.4/","title":"算法红皮书 2.4","section":"_算法(第四版)_","content":" 优先队列 # 有些情况下,不需要要求处理的元素全部有序,只要求每次都处理键值最大的元素,然后再收集更多的元素,然后再处理键值最大的元素 需要一种数据结构,支持操作:删除最大元素和插入元素,这种数据类型叫做优先队列 优先队列的基本表现形式:其一或两种操作都能在线性时间内完成 基于二叉堆数据结构的优先队列,用数组保存元素并按照一定条件排序,以实现高效的删除最大元素和插入元素 API # 抽象数据类型,最重要的操作是删除最大元素和插入元素 delMax()和insert()\n用“最大元素”代替“最大键值”或是“键值最大的元素”\n泛型优先队列的API 优先队列的调用示例 从N各输入中找到最大的M各元素所需成本 优先队列的用例 pq里面最多放5个,当大于5个的时候,就从中剔除1个\npublic class TopM { public static void main(String[] args) { // 打印输入流中最大的M行 int M = Integer.parseint(args[0]); MinPQ\u0026lt;Transaction\u0026gt; pq = new MinPQ\u0026lt;Transaction\u0026gt;(M+1); while (StdIn.hasNextLine()) { // 为下一行输入创建一个元素并放入优先队列中 pq.insert(new Transaction(StdIn.readLine())); if (pq.size() \u0026gt; M) pq.delMin(); // 如果优先队列中存在M+1个元素则删除其中最小的元素 } // 最大的M个元素都在优先队列中 Stack\u0026lt;Transaction\u0026gt; stack = new Stack\u0026lt;Transaction\u0026gt;(); while (!pq.isEmpty()) stack.push(pq.delMin()); for (Transaction t : stack) StdOut.println(t); } } 应用 初级实现 # 数组实现(无序) insert元素和栈的push()方法完全一样;要删除最大元素,可以添加一段类似选择排序的内循环的代码,将最大元素的边界元素交换,然后删除 数组实现(有序) insert()方法时,始终将较大的元素,向右边移动一格以使数组有序;删除最大元素就是pop() 链表表示法 可以用基于链表的下压栈的代码作为基础,而后可以选择修改pop() 来找到并返回最大元素,或是修改push() 来保证所有元素为逆序并用pop() 来删除并返回链表的首元素(也就是最大的元素) 优先队列的各种实现在最坏情况下运行时间的增长数量级 在一个优先队列上执行的一系列操作如表2.4.4所示 堆的定义 # 当一棵二叉树的每个节点都大于等于他的两个子结点时,它被称为堆有序\n重要性质1\n在堆有序的二叉树中,每个结点都小于等于它的父结点(如果有的话)。从任意结点向上,我们都能得到一列非递减的元素;从任意结点向下,我们都能得到一列非递增的元素\n重要命题 根结点是堆有序的二叉树中的最大结点\n二叉堆表示法\n如果使用指针来表示堆有序的二叉树,需要三个指针来找到它的上下结点 使用数组来表示(前提是使用完全二叉树来表示),那么只要一层一层由上向下从左至右,在每个结点的下方连接两个更小的结点,直至将N个结点全部连接完毕 即将二叉树的结点按照层级顺序放入数组中 二叉堆是一组能够用堆有序的完全二叉树排序的元素,并在数组中按照层级储存(不使 用数组的第一个位置)\n图解 下面将二叉树 简称为堆\n在一个堆中,位置k 的结点的父结点的位置为k/2,而它的两个子结点的位置则分别为2k 和2k+1。这样在不使用指针的情况下(我们在第3 章中讨论二叉树时会用到它们)我们也可以通过计算数组的索引在树中上下移动:从a[k] 向上一层就令k 等于k/2,向下一层则令k 等于2k 或2k+1\n一棵大小为N的完全二叉树的高度为[lgN]\n当N达到2的幂时树的高度为加1 数组不使用位置[0]\n堆的算法 # 堆实现的比较和交换方法\nprivate Boolean less(int i, int j) { return pq[i].compareTo(pq[j]) \u0026lt; 0; } private void exch(int i, int j) { Key t = pq[i]; pq[i] = pq[j]; pq[j] = t; } 堆的操作首先进行一些简单的改动,打破堆的状态,再遍历堆并按照要求将堆的状态回复,这个过程称为堆的有序化\n当某个结点的优先级上升(或是在堆底加入一个新的元素)时,我们需要由下至上恢复堆的顺序。当某个结点的优先级下降(例如,将根结点替换为一个较小的元素)时,我们需要由上至下恢复堆的顺序\n由下至上的堆有序化(上浮)【在最后位置插入一个元素】\n说明 如果堆的有序状态因为某个结点变得比它的父结点更大而被打破,那么我们就需要通过交换它和它的父结点来修复堆。交换后,这个结点比它的两个子结点都大(一个是曾经的父结点,另一个比它更小,因为它是曾经父结点的子结点),但这个结点仍然可能比它现在的父结点更大。我们可以一遍遍地用同样的办法恢复秩序,将这个结点不断向上移动直到我们遇 到了一个更大的父结点。\n代码\nprivate void swim(int k) { while (k \u0026gt; 1 \u0026amp;\u0026amp; less(k/2, k)) { exch(k/2, k); k = k/2; } } 由上至下的堆有序化(下沉)【在根节点插入一个元素】\n如果堆的有序状态因为某个结点变得比它的两个子结点或是其中之一更小了而被打破了,那么我们可以通过将它和它的两个子结点中的较大者交换来恢复堆。交换可能会在子结点处继续打破堆的有序状态,因此我们需要不断地用相同的方式将其修复,将结点向下移动直到它的子结点都比它更小或是到达了堆的底部\n代码\nprivate void sink(int k) { while (2*k \u0026lt;= N) { int j = 2*k; //j\u0026lt;N用来判断j是否存在右兄弟结点,当j==N(即j为树的[从左到右]最末一个结点,那么它没有右兄弟结点) if (j \u0026lt; N \u0026amp;\u0026amp; less(j, j+1)) j++; //当根节点没有小于子节点时,跳出循环 if (!less(k, j)) break; exch(k, j); k = j; } } 对于上面的说明\n插入元素。我们将新元素加到数组末尾,增加堆的大小并让这个新元素上浮到合适的位 置(如图2.4.5 左半部分所示)。 删除最大元素。我们从数组顶端删去最大的元素并将数组的最后一个元素放到顶端,减 小堆的大小并让这个元素下沉到合适的位置(如图2.4.5 右半部分所示) 上面对优先队列API的实现,能够保证插入元素和删除元素这两个操作的用时,和队列的大小仅成对数关系 图解堆的操作 基于堆的优先队列\npublic class MaxPQ\u0026lt;Key extends Comparable\u0026lt;Key\u0026gt;\u0026gt; { private Key[] pq; // 基于堆的完全二叉树 private int N = 0; // 存储于pq[1..N]中,pq[0]没有使用 public MaxPQ(int maxN) { pq = (Key[]) new Comparable[maxN+1]; } public Boolean isEmpty() { return N == 0; } public int size() { return N; } public void insert(Key v) { pq[++N] = v; swim(N); } public Key delMax() { Key max = pq[1]; // 从根结点得到最大元素 exch(1, N--); // 将其和最后一个结点交换 pq[N+1] = null; // 防止对象游离 sink(1); // 恢复堆的有序性 return max; } // 辅助方法的实现请见本节前面的代码框 private Boolean less(int i, int j) private void exch(int i, int j) private void swim(int k) private void sink(int k) } 说明\n优先队列由一个基于堆的完全二叉树表示, 存储于数组pq[1..N] 中,pq[0] 没有使用。在insert() 中,我们将N 加一并把新元素添加在数组最后,然后用swim() 恢复堆的秩序。在delMax() 中,我们从pq[1] 中得到需要返回的元素,然后将pq[N] 移动到pq[1],将N 减一并用sink() 恢复堆的秩序。同时我们还将不再使用的pq[N+1] 设为null,以便系统回收它所占用的空间。和以前一样(请见1.3 节),这里省略了动态调整数组大小的代码\n对于一个含有N个元素的基于堆的优先队列,插入元素操作只需不超过(lgN+1)次比较,删除最大元素的操作需要不超过2lgN 次比较。\n在堆上进行操作 多叉堆 基于用数组表示的完全三叉树构造堆并修改相应的代码并不困难。对于数组中1 至N 的N 个元素,位置k的结点大于等于位于3k-1、3k 和3k+1 的结点,小于等于位于(k+1)/3 的结点\n调整数组大小 添加一个没有参数的构造函数, 在insert() 中添加将数组长度加倍的代码,在delMax()中添加将数组长度减半的代码,就像在1.3 节中的栈那样\n元素的不可变性 优先队列存储了用例创建的对象,但同时假设用例代码不会改变它们\n索引优先队列 注意minIndex(),最小元素的索引不一定是0,这里说的索引不是IndexMinPQ数据结构中的数组的索引。这两个不是一个意思 表2.4.6 含有N 个元素的基于堆的索引优先队列所有操作在最坏情况下的成本 索引优先队列用例 将多个有序的输入流归并成一个有序的输出流 ★注意,这多个输入流本身是有序的\npublic class Multiway { public static void merge(In[] streams) { int N = streams.length; IndexMinPQ\u0026lt;String\u0026gt; pq = new IndexMinPQ\u0026lt;String\u0026gt;(N); for (int i = 0; i \u0026lt; N; i++){ if (!streams[i].isEmpty()){ //初始化,从文件流中读取一个数,放到优先队列中 pq.insert(i, streams[i].readString()); } } while (!pq.isEmpty()) { StdOut.println(pq.min()); //从优先队列中取最小的数出来 int i = pq.delMin(); if (!streams[i].isEmpty()) //取出数的那个位置,再从文件流读一个值放进去 pq.insert(i, streams[i].readString()); } } public static void main(String[] args) { int N = args.length; In[] streams = new In[N]; for (int i = 0; i \u0026lt; N; i++) streams[i] = new In(args[i]); //三个文件地址 merge(streams); } } 堆排序 # 我们可以把任意优先队列变成一种排序方法,将所有元素插入一个查找最小元素的优先队列,然后再重复调用删除最小元素的操作来将他们按顺序删去\n用堆来实现经典而优雅的排序算法\u0026ndash;堆排序 为了与前面代码保持一致,使用面向最大元素的优先队列并重复删除最大元素;为了排序需要,直接使用swim()和sink(),且将需要排序的数组本身作为堆,省去额外空间 堆的构造\n可以从左到右,就像连续向优先队列中插入元素一样\n从右到左,用sink()函数构造子堆\n★ 重要前提:每个子堆都符合优先序列的根节点大于其他两个子节点(也就是我们可以跳过大小为1的子堆) 所以只要对每个子堆的根节点,进行sink()函数操作就可以构造出优先队列结构的数组了\n进行排序 主要是将数组的位置1和N-1进行交换,然后在1位置进行sink()操作 不断循环,即可让整个数组有序\nsort(Comparable[] a) { int N = a.length; for (int k = N/2; k \u0026gt;= 1; k--) sink(a, k, N); while (N \u0026gt; 1) { exch(a, 1, N--); sink(a, 1, N); } } 注意,这里的sink()函数被修改过,主要是指定了要sink的最后一个位置【sink() 被修改过,以a[] 和N 作为参数】 堆排序的轨迹(每次下沉后的数组内容) 堆排序:堆的构造(左)和下沉排序(右) 堆排序的主要工作都是在第二阶段完成的。这里我们将堆中的最大元素删除,然后放入堆缩小 后数组中空出的位置\n将N个元素排序,堆排序只需少于(2N x lgN+2N )次比较(以及一般次数的交换)\n。2N 项来自于堆的构造( 见命题R)。2NlgN 项来自于每次下沉操作最大可能需要2lgN次比较(见命题P 与命题Q)\n我们将该实现和优先队列的API 独立开来是为了突出这个排序算法的简洁性(sort() 方法只需8 行代码,sink() 函数8 行),并使其可以嵌入其他代码之中。\n小结\n在最坏的情况下它也能保证使用~ 2NlgN 次比较和恒定的额外空间。当空间十分紧张的时候(例如在嵌入式系统或低成本的移动设备中)它很流行,因为它只用几行就能实现(甚至机器码也是)较好的性能。但现代系统的许多应用很少使用它,因为它无法利用缓存。数组元素很少和相邻的其他元素进行比较,因此缓存未命中的次数要远远高于大多数比较都在相邻元素间进行的算法,如快速排序、归并排序,甚至是希尔排序 用堆实现的优先队列在现代应用程序中越来越重要,因为它能在插入操作和删除最大元素操作混合的动态场景中保证对数级别的运行时间 "},{"id":381,"href":"/zh/docs/technology/Flowable/zsx_design/01/","title":"zsx_flowable_design01","section":"Flowable","content":" 模型设计完后,下面三个表有变化\nact_cio_model act_cio_model_module_rel act_ge_bytearray 部署之后,四个表有变化 act_cio_deployment 多了39条记录 act_ge_bytearray 多了两条记录 act_re_deployment 多了一条记录 act_re_procdef 多了一条记录 流程开始运行\n下面只写上主要的几个表 送审时这个结点只能选一个 流程运行时变量表 "},{"id":382,"href":"/zh/docs/technology/Linux/hanshunping_/28-39/","title":"linux_韩老师_28-39","section":"韩顺平老师_","content":" 文件目录 # 用来定位绝对路径或相对路径 cd ~ 用来定位家目录 cd .. 返回上一级 cd - 返回上一次目录\nmkdir 用于创建目录 mkdir -p hello/l1/l2 多级目录创建\nrecursion 递归 rm -rf 要删除的目录 #递归删除\n使用cp进行复制,加上 -r 进行递归复制\nrm 删除某个文件(带提示)\nrm -f 删除文件(不带提示) rm -rf 强制删除递归文件(夹) mv 用来重命名(移动到同一目录下)、(或者移动文件)\n注意,下面的命令,是将hello移动到hello2下,并改名为a(而不是hello2下的a目录) mv Hello.java hello2/a\nmv Hello.java hello2/a/ 移动到hello2下的a目录下(最后有一个斜杠) 移动目录\nmv hello2 hello1/AB 或者 mv hello2/ hello1/AB\n或者 mv hello2/ hello1/AB/\n会把整个hello2文件夹(包括hello2)移动到AB下\n同样是上面的指令,如果AB不存在,那么就会将hello2移动到hello1下,并将hello2文件夹,改名为AB\ncat 指令\ncat -p /etc/profile 浏览并显示文件 管道命令 cat -p /etc/profile | more 把前面的结果再交给more处理 (输入enter查看下一行,空格查看下一页) less指令\nless /etc/profile less指令显示的时候,是按需加载内容,效率较高, q退出 echo 输出到控制台\necho $HOSTNAME 输出环境变量 head 文件前几行\nhead -3 /etc/profile #查看文件前三行 tail 文件后几行\n实时监控 tail -f mydate.txt 覆盖 echo \u0026ldquo;hello\u0026rdquo; \u0026gt; mydate.txt 追加 echo \u0026ldquo;hi\u0026rdquo; \u0026raquo; mydate.txt cal \u0026gt; mydate.txt 将日志添加到文件后 ln指令 ln -s /root/ /home/myroot 在home下创建一个软链接,名为myroot,连接到root 此时cd myroot,就会进入root文件夹 使用rm -f 删除软连接 动态链接库 history 查看曾经执行过的命令 ! + 数字,执行曾经执行过的指令 时间日期 # date指令\u0026ndash; 显示当前日期 date date +%Y 年份 date +%m 月份 date +%d 哪一天 date \u0026ldquo;+%Y-%m-%d %H:%M:%S\u0026rdquo; 年月日时分秒 cal 2020 #2020年所有日历 查找指令 # find /home -name hello.txt 在/home目录下,按名字查找hello.txt find /home -user tom 按拥有者查找\nfind / -size -10M | more 查找小于10M的文件 ls -lh (h,以更符合人类查看的的方式显示) locate 搜索文件 (locate之前要使用updatedb指令创建) (先使用yum install -y mlocate 进行安装)\n进行查找 which ls 查看ls在哪个目录下 grep 过滤查找,管道符,\u0026quot;|\u0026quot; 表示将前一个命令的处理结果输出传递给后面的命令处理\ncat /etc/profile | grep 22 -n -i 压缩和解压 # 使用gzip 和 gunzip tar 用来压缩或者解压 压缩后的格式 .tar.gz 选项说明 -c 产生.tar打包文件 -v 显示详情信息 -f 指定压缩后的文件名 -z 打包同时压缩 -x 解包.tar文件 使用 tar -zcvf pc.tar.gz /home/pig.txt /home/cat.txt 解压 tar -zxvf pc.tar.gz 解压到指定的目录 tar -zxvf pc.tar.gz -C tom/ # "},{"id":383,"href":"/zh/docs/technology/Linux/hanshunping_/21-27/","title":"linux_韩老师_21-33","section":"韩顺平老师_","content":" 用户管理 # 使用ssh root@192.168.200.201进行服务器连接 xshell中 ctr+shift+r 用来重新连接\n用户解释图 添加一个用户milan,会自动创建该用户的家目录milan\n当登录该用户时,会自动切换到家目录下 指定家目录 指定密码 用milan登录,自动切换到/home/milan pwd:显示当前用户所在的目录\n用户删除\n删除用户但保留家目录 需要用超级管理员才能删除 使用su -u root切换到超级管理员 先logout然后再删除 删除用户及家目录 userdel -r milan 建议保留家目录 查询root用户信息\n使用id xx 查询 切换用户 su - xx\n从权限高切换到权限低的用户不需要密码;反之需要 使用logout(exit也行),从root用户回到jack 查看当前用户 who am i 即使切换了用户,返回的还是root(第一次登录时的用户) 用户组(角色)\n增加、删除组\ngroupadd wudang groupdel wudang 如果添加用户的时候没有指定组,那么会创建一个跟用户名一样的名字的组 id是1002,组为king\n添加用户zwj,添加组wudang,并将zwj添加到wudang组里面\ngroupadd wudang useradd -g wudang zwj 修改用户所在组\ngroupadd mojiao usermod -g mojiao zwj 关于用户和组相关的文件\n/etc/passwd 每行的含义 shell 解释和翻译指令 一般用bash,还有其他,很多\n/etc/shadow 口令配置文件\n每行的含义 /etc/group 记录组的信息 组名:口令:组标识号:组内用户列表\n运行级别 # 基本介绍\n0 关机 1 单用户(找回密码) 2 多用户状态没有网络服务 3 多用户状态有网络服务 4系统未使用保留给用户 5 图形界面 6 系统重启 在图形界面输入init 3 会直接进入终端界面\n之后输入init 5 会重新进入图形界面 init 0 会直接关机\n指定默认级别 centosOS7之前,在/etc/inittab文件中 之后进行了简化,如下 查看默认级别\nsystemctl get-default # multi-user.target 设置默认级别\nsystemctl set-default multi-user.target 找回root密码 # 这里讲的是centos os7之后\n重启后,立马按e\n然后光标往下滑 在utf-8后面,加入 init=/bin/sh (进入单用户实例,注意 这里不要加入空格)\n然后ctrl+x 表示启动\n然后输入\nmount -o remount,rw / passwd 修改成功 然后再输入\ntouch /.autorelabel exec /sbin/init exec /sbin/init 之后时间比较长,等待一会,密码则生效\n(卡住两三分钟)\nssh root@192.168.200.201 登录成功\n帮助指令 # man ls linux中,隐藏文件以 . 开头(以点开头) 输入q退出man ls选项可以组合使用 ls -l 单列输出(use a long listing format),信息最全 ls -la 单列输出,包括隐藏文件 ls -al /root 显示/root目录下的内容 help 内置命令的帮助信息\n该命令在zsh下不能用,所以使用下面指令切换 chsh -s /bin/bash #zsh切换到bash,重启后生效 chsh -s /bin/zsh #bash切换到zsh,重启后生效 help cd End "},{"id":384,"href":"/zh/docs/technology/MyBatis-Plus/official/hello/","title":"官方的hello-world","section":"My Batis Plus","content":" 简介 # MyBatis-Plus (opens new window)(简称 MP)是一个 MyBatis (opens new window)的增强工具,在 MyBatis 的基础上只做增强不做改变,为简化开发、提高效率而生。 快速开始 # 数据库的Schema脚本 resources/db/schema-mysql.sql\nDROP TABLE IF EXISTS user; CREATE TABLE user ( id BIGINT(20) NOT NULL COMMENT \u0026#39;主键ID\u0026#39;, name VARCHAR(30) NULL DEFAULT NULL COMMENT \u0026#39;姓名\u0026#39;, age INT(11) NULL DEFAULT NULL COMMENT \u0026#39;年龄\u0026#39;, email VARCHAR(50) NULL DEFAULT NULL COMMENT \u0026#39;邮箱\u0026#39;, PRIMARY KEY (id) ); 数据库Data脚本 resources/db/data-mysql.sql\nDELETE FROM user; INSERT INTO user (id, name, age, email) VALUES (1, \u0026#39;Jone\u0026#39;, 18, \u0026#39;test1@baomidou.com\u0026#39;), (2, \u0026#39;Jack\u0026#39;, 20, \u0026#39;test2@baomidou.com\u0026#39;), (3, \u0026#39;Tom\u0026#39;, 28, \u0026#39;test3@baomidou.com\u0026#39;), (4, \u0026#39;Sandy\u0026#39;, 21, \u0026#39;test4@baomidou.com\u0026#39;), (5, \u0026#39;Billie\u0026#39;, 24, \u0026#39;test5@baomidou.com\u0026#39;); 创建一个spring boot工程(使用maven)\n父工程\n\u0026lt;parent\u0026gt; \u0026lt;groupId\u0026gt;org.springframework.boot\u0026lt;/groupId\u0026gt; \u0026lt;artifactId\u0026gt;spring-boot-starter-parent\u0026lt;/artifactId\u0026gt; \u0026lt;version\u0026gt;2.7.0\u0026lt;/version\u0026gt; \u0026lt;relativePath/\u0026gt; \u0026lt;/parent\u0026gt; springboot 相关仓库及mybatis-plus、mysql、Lombok相关仓库引入\n\u0026lt;dependencies\u0026gt; \u0026lt;dependency\u0026gt; \u0026lt;groupId\u0026gt;org.springframework.boot\u0026lt;/groupId\u0026gt; \u0026lt;artifactId\u0026gt;spring-boot-starter\u0026lt;/artifactId\u0026gt; \u0026lt;/dependency\u0026gt; \u0026lt;dependency\u0026gt; \u0026lt;groupId\u0026gt;org.springframework.boot\u0026lt;/groupId\u0026gt; \u0026lt;artifactId\u0026gt;spring-boot-starter-test\u0026lt;/artifactId\u0026gt; \u0026lt;scope\u0026gt;test\u0026lt;/scope\u0026gt; \u0026lt;/dependency\u0026gt; \u0026lt;dependency\u0026gt; \u0026lt;groupId\u0026gt;com.baomidou\u0026lt;/groupId\u0026gt; \u0026lt;artifactId\u0026gt;mybatis-plus-boot-starter\u0026lt;/artifactId\u0026gt; \u0026lt;version\u0026gt;3.5.1\u0026lt;/version\u0026gt; \u0026lt;/dependency\u0026gt; \u0026lt;dependency\u0026gt; \u0026lt;groupId\u0026gt;org.springframework.boot\u0026lt;/groupId\u0026gt; \u0026lt;artifactId\u0026gt;spring-boot-starter-web\u0026lt;/artifactId\u0026gt; \u0026lt;/dependency\u0026gt; \u0026lt;dependency\u0026gt; \u0026lt;groupId\u0026gt;com.h2database\u0026lt;/groupId\u0026gt; \u0026lt;artifactId\u0026gt;h2\u0026lt;/artifactId\u0026gt; \u0026lt;scope\u0026gt;runtime\u0026lt;/scope\u0026gt; \u0026lt;/dependency\u0026gt; \u0026lt;!-- https://mvnrepository.com/artifact/mysql/mysql-connector-java --\u0026gt; \u0026lt;dependency\u0026gt; \u0026lt;groupId\u0026gt;mysql\u0026lt;/groupId\u0026gt; \u0026lt;artifactId\u0026gt;mysql-connector-java\u0026lt;/artifactId\u0026gt; \u0026lt;version\u0026gt;8.0.29\u0026lt;/version\u0026gt; \u0026lt;/dependency\u0026gt; \u0026lt;!-- https://mvnrepository.com/artifact/org.projectlombok/lombok --\u0026gt; \u0026lt;dependency\u0026gt; \u0026lt;groupId\u0026gt;org.projectlombok\u0026lt;/groupId\u0026gt; \u0026lt;artifactId\u0026gt;lombok\u0026lt;/artifactId\u0026gt; \u0026lt;version\u0026gt;1.18.24\u0026lt;/version\u0026gt; \u0026lt;scope\u0026gt;provided\u0026lt;/scope\u0026gt; \u0026lt;/dependency\u0026gt; \u0026lt;/dependencies\u0026gt; 配置resources/application.yml文件\nspring: datasource: url: jdbc:mysql://localhost:3306/mybatis_plus_demo?useUnicode=true\u0026amp;characterEncoding=utf-8\u0026amp;allowMultiQueries=true\u0026amp;nullCatalogMeansCurrent=true username: root password: 123456 driver-class-name: com.mysql.cj.jdbc.Driver sql: init: schema-locations: classpath:db/schema-mysql.sql data-locations: classpath:db/data-mysql.sql mode: always entity类和mapper类的处理\nentity\n@Data public class User { private Long id; private String name; private Integer age; private String email; } mapper\nimport com.baomidou.mybatisplus.core.mapper.BaseMapper; import com.baomidou.mybatisplus.samples.quickstart.entity.User; public interface UserMapper extends BaseMapper\u0026lt;User\u0026gt; { } 测试类\nimport com.baomidou.mybatisplus.samples.quickstart.Application; import com.baomidou.mybatisplus.samples.quickstart.entity.User; import com.baomidou.mybatisplus.samples.quickstart.mapper.UserMapper; import org.junit.jupiter.api.Assertions; import org.junit.jupiter.api.Test; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.test.context.SpringBootTest; import java.util.List; @SpringBootTest(classes = {Application.class}) public class SampleTest { @Autowired private UserMapper userMapper; @Test public void testSelect() { System.out.println((\u0026#34;----- selectAll method test ------\u0026#34;)); List\u0026lt;User\u0026gt; userList = userMapper.selectList(null); Assertions.assertEquals(5, userList.size()); userList.forEach(System.out::println); } } mybatis-plus 代码自动生成 # maven 依赖\n\u0026lt;!-- https://mvnrepository.com/artifact/com.baomidou/mybatis-plus-generator --\u0026gt; \u0026lt;dependency\u0026gt; \u0026lt;groupId\u0026gt;com.baomidou\u0026lt;/groupId\u0026gt; \u0026lt;artifactId\u0026gt;mybatis-plus-generator\u0026lt;/artifactId\u0026gt; \u0026lt;version\u0026gt;3.5.2\u0026lt;/version\u0026gt; \u0026lt;/dependency\u0026gt; \u0026lt;!-- https://mvnrepository.com/artifact/org.apache.velocity/velocity-engine-core --\u0026gt; \u0026lt;dependency\u0026gt; \u0026lt;groupId\u0026gt;org.apache.velocity\u0026lt;/groupId\u0026gt; \u0026lt;artifactId\u0026gt;velocity-engine-core\u0026lt;/artifactId\u0026gt; \u0026lt;version\u0026gt;2.3\u0026lt;/version\u0026gt; \u0026lt;/dependency\u0026gt; 在测试类中编写程序让其自动生成\nimport com.baomidou.mybatisplus.generator.FastAutoGenerator; import com.baomidou.mybatisplus.generator.config.DataSourceConfig; import org.apache.ibatis.jdbc.ScriptRunner; import java.io.InputStream; import java.io.InputStreamReader; import java.sql.Connection; import java.sql.SQLException; /** * \u0026lt;p\u0026gt; * 快速生成 * \u0026lt;/p\u0026gt; * * @author lanjerry * @since 2021-09-16 */ public class FastAutoGeneratorTest { /** * 执行初始化数据库脚本 */ public static void before() throws SQLException { Connection conn = DATA_SOURCE_CONFIG.build().getConn(); InputStream inputStream = FastAutoGeneratorTest.class.getResourceAsStream(\u0026#34;/db/schema-mysql.sql\u0026#34;); ScriptRunner scriptRunner = new ScriptRunner(conn); scriptRunner.setAutoCommit(true); scriptRunner.runScript(new InputStreamReader(inputStream)); conn.close(); } /** * 数据源配置 */ private static final DataSourceConfig.Builder DATA_SOURCE_CONFIG = new DataSourceConfig .Builder(\u0026#34;jdbc:mysql://localhost:3306/mybatis_plus_demo?useUnicode=true\u0026amp;characterEncoding=utf-8\u0026amp;allowMultiQueries=true\u0026amp;nullCatalogMeansCurrent=true\u0026#34;, \u0026#34;root\u0026#34;, \u0026#34;123456\u0026#34;); /** * 执行 run */ public static void main(String[] args) throws SQLException { before(); FastAutoGenerator.create(DATA_SOURCE_CONFIG) // 全局配置 .globalConfig((scanner, builder) -\u0026gt; builder.author(scanner.apply(\u0026#34;请输入作者名称\u0026#34;))) // 包配置 .packageConfig((scanner, builder) -\u0026gt; builder.parent(scanner.apply(\u0026#34;请输入包名\u0026#34;))) // 策略配置 .strategyConfig((scanner, builder) -\u0026gt; builder.addInclude(scanner.apply(\u0026#34;请输入表名,多个表名用,隔开\u0026#34;))) /* 模板引擎配置,默认 Velocity 可选模板引擎 Beetl 或 Freemarker .templateEngine(new BeetlTemplateEngine()) .templateEngine(new FreemarkerTemplateEngine()) */ .execute(); } } 使用mybats-x插件自动生成代码\n操作 编写controller确定\n@RestController @RequestMapping(\u0026#34;user\u0026#34;) public class UserController { @Autowired private UserService userService; @RequestMapping(\u0026#34;findAll\u0026#34;) public List\u0026lt;User\u0026gt; findAll(){ List\u0026lt;User\u0026gt; list = userService.list(); return list; } } xml文件\n\u0026lt;?xml version=\u0026#34;1.0\u0026#34; encoding=\u0026#34;UTF-8\u0026#34;?\u0026gt; \u0026lt;!DOCTYPE mapper PUBLIC \u0026#34;-//mybatis.org//DTD Mapper 3.0//EN\u0026#34; \u0026#34;http://mybatis.org/dtd/mybatis-3-mapper.dtd\u0026#34;\u0026gt; \u0026lt;mapper namespace=\u0026#34;com.baomidou.mybatisplus.samples.quickstart.mapper.UserMapper\u0026#34;\u0026gt; \u0026lt;resultMap id=\u0026#34;BaseResultMap\u0026#34; type=\u0026#34;com.baomidou.mybatisplus.samples.quickstart.entity.User\u0026#34;\u0026gt; \u0026lt;id property=\u0026#34;id\u0026#34; column=\u0026#34;id\u0026#34; jdbcType=\u0026#34;BIGINT\u0026#34;/\u0026gt; \u0026lt;result property=\u0026#34;name\u0026#34; column=\u0026#34;name\u0026#34; jdbcType=\u0026#34;VARCHAR\u0026#34;/\u0026gt; \u0026lt;result property=\u0026#34;age\u0026#34; column=\u0026#34;age\u0026#34; jdbcType=\u0026#34;INTEGER\u0026#34;/\u0026gt; \u0026lt;result property=\u0026#34;email\u0026#34; column=\u0026#34;email\u0026#34; jdbcType=\u0026#34;VARCHAR\u0026#34;/\u0026gt; \u0026lt;/resultMap\u0026gt; \u0026lt;sql id=\u0026#34;Base_Column_List\u0026#34;\u0026gt; id,name,age, email \u0026lt;/sql\u0026gt; \u0026lt;/mapper\u0026gt; entity\n/** * * @TableName user */ @TableName(value =\u0026#34;user\u0026#34;) public class User implements Serializable { /** * 主键ID */ @TableId private Long id; /** * 姓名 */ private String name; /** * 年龄 */ private Integer age; /** * 邮箱 */ private String email; @TableField(exist = false) private static final long serialVersionUID = 1L; /** * 主键ID */ public Long getId() { return id; } /** * 主键ID */ public void setId(Long id) { this.id = id; } /** * 姓名 */ public String getName() { return name; } /** * 姓名 */ public void setName(String name) { this.name = name; } /** * 年龄 */ public Integer getAge() { return age; } /** * 年龄 */ public void setAge(Integer age) { this.age = age; } /** * 邮箱 */ public String getEmail() { return email; } /** * 邮箱 */ public void setEmail(String email) { this.email = email; } @Override public boolean equals(Object that) { if (this == that) { return true; } if (that == null) { return false; } if (getClass() != that.getClass()) { return false; } User other = (User) that; return (this.getId() == null ? other.getId() == null : this.getId().equals(other.getId())) \u0026amp;\u0026amp; (this.getName() == null ? other.getName() == null : this.getName().equals(other.getName())) \u0026amp;\u0026amp; (this.getAge() == null ? other.getAge() == null : this.getAge().equals(other.getAge())) \u0026amp;\u0026amp; (this.getEmail() == null ? other.getEmail() == null : this.getEmail().equals(other.getEmail())); } @Override public int hashCode() { final int prime = 31; int result = 1; result = prime * result + ((getId() == null) ? 0 : getId().hashCode()); result = prime * result + ((getName() == null) ? 0 : getName().hashCode()); result = prime * result + ((getAge() == null) ? 0 : getAge().hashCode()); result = prime * result + ((getEmail() == null) ? 0 : getEmail().hashCode()); return result; } @Override public String toString() { StringBuilder sb = new StringBuilder(); sb.append(getClass().getSimpleName()); sb.append(\u0026#34; [\u0026#34;); sb.append(\u0026#34;Hash = \u0026#34;).append(hashCode()); sb.append(\u0026#34;, id=\u0026#34;).append(id); sb.append(\u0026#34;, name=\u0026#34;).append(name); sb.append(\u0026#34;, age=\u0026#34;).append(age); sb.append(\u0026#34;, email=\u0026#34;).append(email); sb.append(\u0026#34;, serialVersionUID=\u0026#34;).append(serialVersionUID); sb.append(\u0026#34;]\u0026#34;); return sb.toString(); } } service接口类\npublic interface UserService extends IService\u0026lt;User\u0026gt; { } serviceImpl\n@Service public class UserServiceImpl extends ServiceImpl\u0026lt;UserMapper, User\u0026gt; implements UserService{ } mapper\npublic interface UserMapper extends BaseMapper\u0026lt;User\u0026gt; { } controller测试\n@RestController @RequestMapping(\u0026#34;user\u0026#34;) public class UserController { @Autowired private UserService userService; @RequestMapping(\u0026#34;findAll\u0026#34;) public List\u0026lt;User\u0026gt; findAll(){ List\u0026lt;User\u0026gt; list = userService.list(); return list; } } 测试 使用mybatis-x 插件(idea)\n"},{"id":385,"href":"/zh/docs/technology/Flowable/boge_blbl_/03-others/","title":"boge-03-其他","section":"基础(波哥)_","content":" 会签 # 流程图绘制 注意上面几个参数\n多实例类型用来判断串行并行 基数(有几个用户处理) 元素变量 集合(集合变量) 完成条件\u0026ndash;这里填的是 ${nrOfCompletedInstances \u0026gt; 1 } 在任务监听器 package org.flowable.listener; import org.flowable.engine.ProcessEngine; import org.flowable.engine.ProcessEngines; import org.flowable.engine.TaskService; import org.flowable.engine.delegate.TaskListener; import org.flowable.task.api.Task; import org.flowable.task.service.delegate.DelegateTask; public class MultiInstanceTaskListener implements TaskListener { @Override public void notify(DelegateTask delegateTask) { System.out.println(\u0026#34;处理aaaa\u0026#34;); if(delegateTask.getEventName().equals(\u0026#34;create\u0026#34;)) { System.out.println(\u0026#34;任务id\u0026#34; + delegateTask.getId()); System.out.println(\u0026#34;哪些人需要会签\u0026#34; + delegateTask.getVariable(\u0026#34;persons\u0026#34;)); System.out.println(\u0026#34;任务处理人\u0026#34; + delegateTask.getVariable(\u0026#34;person\u0026#34;)); ProcessEngine engine = ProcessEngines.getDefaultProcessEngine(); TaskService taskService = engine.getTaskService(); Task task = taskService.createTaskQuery().taskId(delegateTask.getId()).singleResult(); task.setAssignee(delegateTask.getVariable(\u0026#34;person\u0026#34;).toString()); taskService.saveTask(task); } } } xml\n\u0026lt;?xml version=\u0026#34;1.0\u0026#34; encoding=\u0026#34;UTF-8\u0026#34;?\u0026gt; \u0026lt;definitions xmlns=\u0026#34;http://www.omg.org/spec/BPMN/20100524/MODEL\u0026#34; xmlns:xsi=\u0026#34;http://www.w3.org/2001/XMLSchema-instance\u0026#34; xmlns:xsd=\u0026#34;http://www.w3.org/2001/XMLSchema\u0026#34; xmlns:flowable=\u0026#34;http://flowable.org/bpmn\u0026#34; xmlns:bpmndi=\u0026#34;http://www.omg.org/spec/BPMN/20100524/DI\u0026#34; xmlns:omgdc=\u0026#34;http://www.omg.org/spec/DD/20100524/DC\u0026#34; xmlns:omgdi=\u0026#34;http://www.omg.org/spec/DD/20100524/DI\u0026#34; typeLanguage=\u0026#34;http://www.w3.org/2001/XMLSchema\u0026#34; expressionLanguage=\u0026#34;http://www.w3.org/1999/XPath\u0026#34; targetNamespace=\u0026#34;http://www.flowable.org/processdef\u0026#34; exporter=\u0026#34;Flowable Open Source Modeler\u0026#34; exporterVersion=\u0026#34;6.7.2\u0026#34;\u0026gt; \u0026lt;process id=\u0026#34;join-key\u0026#34; name=\u0026#34;会签测试1\u0026#34; isExecutable=\u0026#34;true\u0026#34;\u0026gt; \u0026lt;documentation\u0026gt;join-desc\u0026lt;/documentation\u0026gt; \u0026lt;startEvent id=\u0026#34;startEvent1\u0026#34; name=\u0026#34;申请人\u0026#34; flowable:formFieldValidation=\u0026#34;true\u0026#34;\u0026gt;\u0026lt;/startEvent\u0026gt; \u0026lt;userTask id=\u0026#34;sid-477F728E-2F63-43BF-A278-76FBCF58B475\u0026#34; name=\u0026#34;会签人员\u0026#34; flowable:formFieldValidation=\u0026#34;true\u0026#34;\u0026gt; \u0026lt;extensionElements\u0026gt; \u0026lt;flowable:taskListener event=\u0026#34;create\u0026#34; class=\u0026#34;org.flowable.listener.MultiInstanceTaskListener\u0026#34;\u0026gt;\u0026lt;/flowable:taskListener\u0026gt; \u0026lt;/extensionElements\u0026gt; \u0026lt;multiInstanceLoopCharacteristics isSequential=\u0026#34;false\u0026#34; flowable:collection=\u0026#34;persons\u0026#34; flowable:elementVariable=\u0026#34;person\u0026#34;\u0026gt; \u0026lt;extensionElements\u0026gt;\u0026lt;/extensionElements\u0026gt; \u0026lt;loopCardinality\u0026gt;3\u0026lt;/loopCardinality\u0026gt; \u0026lt;completionCondition\u0026gt;${nrOfCompletedInstances \u0026gt; 1 }\u0026lt;/completionCondition\u0026gt; \u0026lt;/multiInstanceLoopCharacteristics\u0026gt; \u0026lt;/userTask\u0026gt; \u0026lt;sequenceFlow id=\u0026#34;sid-B5F81E26-E53B-4D10-8328-C5B3C35E0DD5\u0026#34; sourceRef=\u0026#34;startEvent1\u0026#34; targetRef=\u0026#34;sid-477F728E-2F63-43BF-A278-76FBCF58B475\u0026#34;\u0026gt;\u0026lt;/sequenceFlow\u0026gt; \u0026lt;endEvent id=\u0026#34;sid-3448D902-AE89-467D-8945-805BDEDE7BCA\u0026#34;\u0026gt;\u0026lt;/endEvent\u0026gt; \u0026lt;sequenceFlow id=\u0026#34;sid-598B2F86-A13B-48BE-88AF-6B61CDA24EA7\u0026#34; sourceRef=\u0026#34;sid-477F728E-2F63-43BF-A278-76FBCF58B475\u0026#34; targetRef=\u0026#34;sid-3448D902-AE89-467D-8945-805BDEDE7BCA\u0026#34;\u0026gt;\u0026lt;/sequenceFlow\u0026gt; \u0026lt;/process\u0026gt; \u0026lt;bpmndi:BPMNDiagram id=\u0026#34;BPMNDiagram_join-key\u0026#34;\u0026gt; \u0026lt;bpmndi:BPMNPlane bpmnElement=\u0026#34;join-key\u0026#34; id=\u0026#34;BPMNPlane_join-key\u0026#34;\u0026gt; \u0026lt;bpmndi:BPMNShape bpmnElement=\u0026#34;startEvent1\u0026#34; id=\u0026#34;BPMNShape_startEvent1\u0026#34;\u0026gt; \u0026lt;omgdc:Bounds height=\u0026#34;30.0\u0026#34; width=\u0026#34;30.0\u0026#34; x=\u0026#34;105.0\u0026#34; y=\u0026#34;100.0\u0026#34;\u0026gt;\u0026lt;/omgdc:Bounds\u0026gt; \u0026lt;/bpmndi:BPMNShape\u0026gt; \u0026lt;bpmndi:BPMNShape bpmnElement=\u0026#34;sid-477F728E-2F63-43BF-A278-76FBCF58B475\u0026#34; id=\u0026#34;BPMNShape_sid-477F728E-2F63-43BF-A278-76FBCF58B475\u0026#34;\u0026gt; \u0026lt;omgdc:Bounds height=\u0026#34;80.0\u0026#34; width=\u0026#34;100.0\u0026#34; x=\u0026#34;330.0\u0026#34; y=\u0026#34;60.0\u0026#34;\u0026gt;\u0026lt;/omgdc:Bounds\u0026gt; \u0026lt;/bpmndi:BPMNShape\u0026gt; \u0026lt;bpmndi:BPMNShape bpmnElement=\u0026#34;sid-3448D902-AE89-467D-8945-805BDEDE7BCA\u0026#34; id=\u0026#34;BPMNShape_sid-3448D902-AE89-467D-8945-805BDEDE7BCA\u0026#34;\u0026gt; \u0026lt;omgdc:Bounds height=\u0026#34;28.0\u0026#34; width=\u0026#34;28.0\u0026#34; x=\u0026#34;600.0\u0026#34; y=\u0026#34;106.0\u0026#34;\u0026gt;\u0026lt;/omgdc:Bounds\u0026gt; \u0026lt;/bpmndi:BPMNShape\u0026gt; \u0026lt;bpmndi:BPMNEdge bpmnElement=\u0026#34;sid-B5F81E26-E53B-4D10-8328-C5B3C35E0DD5\u0026#34; id=\u0026#34;BPMNEdge_sid-B5F81E26-E53B-4D10-8328-C5B3C35E0DD5\u0026#34; flowable:sourceDockerX=\u0026#34;15.0\u0026#34; flowable:sourceDockerY=\u0026#34;15.0\u0026#34; flowable:targetDockerX=\u0026#34;50.0\u0026#34; flowable:targetDockerY=\u0026#34;40.0\u0026#34;\u0026gt; \u0026lt;omgdi:waypoint x=\u0026#34;134.94999855629513\u0026#34; y=\u0026#34;115.0\u0026#34;\u0026gt;\u0026lt;/omgdi:waypoint\u0026gt; \u0026lt;omgdi:waypoint x=\u0026#34;232.5\u0026#34; y=\u0026#34;115.0\u0026#34;\u0026gt;\u0026lt;/omgdi:waypoint\u0026gt; \u0026lt;omgdi:waypoint x=\u0026#34;232.5\u0026#34; y=\u0026#34;100.0\u0026#34;\u0026gt;\u0026lt;/omgdi:waypoint\u0026gt; \u0026lt;omgdi:waypoint x=\u0026#34;330.0\u0026#34; y=\u0026#34;100.0\u0026#34;\u0026gt;\u0026lt;/omgdi:waypoint\u0026gt; \u0026lt;/bpmndi:BPMNEdge\u0026gt; \u0026lt;bpmndi:BPMNEdge bpmnElement=\u0026#34;sid-598B2F86-A13B-48BE-88AF-6B61CDA24EA7\u0026#34; id=\u0026#34;BPMNEdge_sid-598B2F86-A13B-48BE-88AF-6B61CDA24EA7\u0026#34; flowable:sourceDockerX=\u0026#34;50.0\u0026#34; flowable:sourceDockerY=\u0026#34;40.0\u0026#34; flowable:targetDockerX=\u0026#34;14.0\u0026#34; flowable:targetDockerY=\u0026#34;14.0\u0026#34;\u0026gt; \u0026lt;omgdi:waypoint x=\u0026#34;429.95000000000005\u0026#34; y=\u0026#34;100.0\u0026#34;\u0026gt;\u0026lt;/omgdi:waypoint\u0026gt; \u0026lt;omgdi:waypoint x=\u0026#34;515.0\u0026#34; y=\u0026#34;100.0\u0026#34;\u0026gt;\u0026lt;/omgdi:waypoint\u0026gt; \u0026lt;omgdi:waypoint x=\u0026#34;515.0\u0026#34; y=\u0026#34;120.0\u0026#34;\u0026gt;\u0026lt;/omgdi:waypoint\u0026gt; \u0026lt;omgdi:waypoint x=\u0026#34;600.0\u0026#34; y=\u0026#34;120.0\u0026#34;\u0026gt;\u0026lt;/omgdi:waypoint\u0026gt; \u0026lt;/bpmndi:BPMNEdge\u0026gt; \u0026lt;/bpmndi:BPMNPlane\u0026gt; \u0026lt;/bpmndi:BPMNDiagram\u0026gt; \u0026lt;/definitions\u0026gt; 将流程部署\n@Test public void deploy() { deleteAll(); ProcessEngine engine = ProcessEngines.getDefaultProcessEngine(); RepositoryService repositoryService = engine.getRepositoryService(); Deployment deploy = repositoryService.createDeployment() .addClasspathResource(\u0026#34;会签测试1.bpmn20.xml\u0026#34;) .deploy(); System.out.println(\u0026#34;部署成功:\u0026#34; + deploy.getId()); } 运行流程\n@Test public void run(){ ProcessEngine defaultProcessEngine = ProcessEngines.getDefaultProcessEngine(); RuntimeService runtimeService = defaultProcessEngine.getRuntimeService(); HashMap\u0026lt;String,Object\u0026gt; map=new HashMap\u0026lt;\u0026gt;(); ArrayList\u0026lt;String\u0026gt; persons=new ArrayList\u0026lt;\u0026gt;(); persons.add(\u0026#34;张三\u0026#34;); persons.add(\u0026#34;李四\u0026#34;); persons.add(\u0026#34;王五\u0026#34;); map.put(\u0026#34;persons\u0026#34;,persons); ProcessInstance processInstance = runtimeService.startProcessInstanceById(\u0026#34;join-key:1:17504\u0026#34;,map); } 此时数据库会有三个任务 完成第一个任务\n@Test public void completeTask(){ //15020 ProcessEngine engine=ProcessEngines.getDefaultProcessEngine(); TaskService taskService = engine.getTaskService(); taskService.complete(\u0026#34;20020\u0026#34;); } 再完成一个任务后,流程会直接结束\n@Test public void completeTask(){ //15020 ProcessEngine engine=ProcessEngines.getDefaultProcessEngine(); TaskService taskService = engine.getTaskService(); taskService.complete(\u0026#34;20028\u0026#34;); } 流程结束\n"},{"id":386,"href":"/zh/docs/technology/Flowable/boge_blbl_/02-advance_6/","title":"boge-02-flowable进阶_6","section":"基础(波哥)_","content":" 任务回退-串行回退 # 流程图绘制 xml\n\u0026lt;?xml version=\u0026#34;1.0\u0026#34; encoding=\u0026#34;UTF-8\u0026#34;?\u0026gt; \u0026lt;definitions xmlns=\u0026#34;http://www.omg.org/spec/BPMN/20100524/MODEL\u0026#34; xmlns:xsi=\u0026#34;http://www.w3.org/2001/XMLSchema-instance\u0026#34; xmlns:xsd=\u0026#34;http://www.w3.org/2001/XMLSchema\u0026#34; xmlns:flowable=\u0026#34;http://flowable.org/bpmn\u0026#34; xmlns:bpmndi=\u0026#34;http://www.omg.org/spec/BPMN/20100524/DI\u0026#34; xmlns:omgdc=\u0026#34;http://www.omg.org/spec/DD/20100524/DC\u0026#34; xmlns:omgdi=\u0026#34;http://www.omg.org/spec/DD/20100524/DI\u0026#34; typeLanguage=\u0026#34;http://www.w3.org/2001/XMLSchema\u0026#34; expressionLanguage=\u0026#34;http://www.w3.org/1999/XPath\u0026#34; targetNamespace=\u0026#34;http://www.flowable.org/processdef\u0026#34; exporter=\u0026#34;Flowable Open Source Modeler\u0026#34; exporterVersion=\u0026#34;6.7.2\u0026#34;\u0026gt; \u0026lt;process id=\u0026#34;reback-key\u0026#34; name=\u0026#34;回退处理\u0026#34; isExecutable=\u0026#34;true\u0026#34;\u0026gt; \u0026lt;documentation\u0026gt;reback-desc\u0026lt;/documentation\u0026gt; \u0026lt;startEvent id=\u0026#34;startEvent1\u0026#34; flowable:formFieldValidation=\u0026#34;true\u0026#34;\u0026gt;\u0026lt;/startEvent\u0026gt; \u0026lt;userTask id=\u0026#34;sid-D380E41A-48EE-4C08-AD01-1D509C512543\u0026#34; name=\u0026#34;用户1\u0026#34; flowable:assignee=\u0026#34;user1\u0026#34; flowable:formFieldValidation=\u0026#34;true\u0026#34;\u0026gt; \u0026lt;extensionElements\u0026gt; \u0026lt;modeler:initiator-can-complete xmlns:modeler=\u0026#34;http://flowable.org/modeler\u0026#34;\u0026gt;\u0026lt;![CDATA[false]]\u0026gt;\u0026lt;/modeler:initiator-can-complete\u0026gt; \u0026lt;/extensionElements\u0026gt; \u0026lt;/userTask\u0026gt; \u0026lt;sequenceFlow id=\u0026#34;sid-E2423FC5-F954-43D3-B57C-8460057CB7D6\u0026#34; sourceRef=\u0026#34;startEvent1\u0026#34; targetRef=\u0026#34;sid-D380E41A-48EE-4C08-AD01-1D509C512543\u0026#34;\u0026gt;\u0026lt;/sequenceFlow\u0026gt; \u0026lt;userTask id=\u0026#34;sid-AF50E3D0-2014-4308-A717-D76586837D70\u0026#34; name=\u0026#34;用户2\u0026#34; flowable:assignee=\u0026#34;user2\u0026#34; flowable:formFieldValidation=\u0026#34;true\u0026#34;\u0026gt; \u0026lt;extensionElements\u0026gt; \u0026lt;modeler:initiator-can-complete xmlns:modeler=\u0026#34;http://flowable.org/modeler\u0026#34;\u0026gt;\u0026lt;![CDATA[false]]\u0026gt;\u0026lt;/modeler:initiator-can-complete\u0026gt; \u0026lt;/extensionElements\u0026gt; \u0026lt;/userTask\u0026gt; \u0026lt;sequenceFlow id=\u0026#34;sid-7C8750DC-E1C1-4AB2-B18C-2C103B61A5E5\u0026#34; sourceRef=\u0026#34;sid-D380E41A-48EE-4C08-AD01-1D509C512543\u0026#34; targetRef=\u0026#34;sid-AF50E3D0-2014-4308-A717-D76586837D70\u0026#34;\u0026gt;\u0026lt;/sequenceFlow\u0026gt; \u0026lt;userTask id=\u0026#34;sid-F4CE7565-5977-4B9C-A603-AB3B817B8C8C\u0026#34; name=\u0026#34;用户3\u0026#34; flowable:assignee=\u0026#34;user3\u0026#34; flowable:formFieldValidation=\u0026#34;true\u0026#34;\u0026gt; \u0026lt;extensionElements\u0026gt; \u0026lt;modeler:initiator-can-complete xmlns:modeler=\u0026#34;http://flowable.org/modeler\u0026#34;\u0026gt;\u0026lt;![CDATA[false]]\u0026gt;\u0026lt;/modeler:initiator-can-complete\u0026gt; \u0026lt;/extensionElements\u0026gt; \u0026lt;/userTask\u0026gt; \u0026lt;sequenceFlow id=\u0026#34;sid-F91582FE-D110-48C9-9407-605E503E42B2\u0026#34; sourceRef=\u0026#34;sid-AF50E3D0-2014-4308-A717-D76586837D70\u0026#34; targetRef=\u0026#34;sid-F4CE7565-5977-4B9C-A603-AB3B817B8C8C\u0026#34;\u0026gt;\u0026lt;/sequenceFlow\u0026gt; \u0026lt;userTask id=\u0026#34;sid-727C1235-F9C1-4CC5-BC6C-E56ABCA105B0\u0026#34; name=\u0026#34;用户4\u0026#34; flowable:assignee=\u0026#34;user4\u0026#34; flowable:formFieldValidation=\u0026#34;true\u0026#34;\u0026gt; \u0026lt;extensionElements\u0026gt; \u0026lt;modeler:initiator-can-complete xmlns:modeler=\u0026#34;http://flowable.org/modeler\u0026#34;\u0026gt;\u0026lt;![CDATA[false]]\u0026gt;\u0026lt;/modeler:initiator-can-complete\u0026gt; \u0026lt;/extensionElements\u0026gt; \u0026lt;/userTask\u0026gt; \u0026lt;sequenceFlow id=\u0026#34;sid-6D998C20-2A97-44B5-92D0-118E5CB05795\u0026#34; sourceRef=\u0026#34;sid-F4CE7565-5977-4B9C-A603-AB3B817B8C8C\u0026#34; targetRef=\u0026#34;sid-727C1235-F9C1-4CC5-BC6C-E56ABCA105B0\u0026#34;\u0026gt;\u0026lt;/sequenceFlow\u0026gt; \u0026lt;endEvent id=\u0026#34;sid-6E5F5037-1979-4150-8408-D0BFD0315BCA\u0026#34;\u0026gt;\u0026lt;/endEvent\u0026gt; \u0026lt;sequenceFlow id=\u0026#34;sid-3ECF3E34-6C07-4AE6-997B-583BF8868AC8\u0026#34; sourceRef=\u0026#34;sid-727C1235-F9C1-4CC5-BC6C-E56ABCA105B0\u0026#34; targetRef=\u0026#34;sid-6E5F5037-1979-4150-8408-D0BFD0315BCA\u0026#34;\u0026gt;\u0026lt;/sequenceFlow\u0026gt; \u0026lt;/process\u0026gt; \u0026lt;bpmndi:BPMNDiagram id=\u0026#34;BPMNDiagram_reback-key\u0026#34;\u0026gt; \u0026lt;bpmndi:BPMNPlane bpmnElement=\u0026#34;reback-key\u0026#34; id=\u0026#34;BPMNPlane_reback-key\u0026#34;\u0026gt; \u0026lt;bpmndi:BPMNShape bpmnElement=\u0026#34;startEvent1\u0026#34; id=\u0026#34;BPMNShape_startEvent1\u0026#34;\u0026gt; \u0026lt;omgdc:Bounds height=\u0026#34;30.0\u0026#34; width=\u0026#34;30.0\u0026#34; x=\u0026#34;100.0\u0026#34; y=\u0026#34;163.0\u0026#34;\u0026gt;\u0026lt;/omgdc:Bounds\u0026gt; \u0026lt;/bpmndi:BPMNShape\u0026gt; \u0026lt;bpmndi:BPMNShape bpmnElement=\u0026#34;sid-D380E41A-48EE-4C08-AD01-1D509C512543\u0026#34; id=\u0026#34;BPMNShape_sid-D380E41A-48EE-4C08-AD01-1D509C512543\u0026#34;\u0026gt; \u0026lt;omgdc:Bounds height=\u0026#34;80.0\u0026#34; width=\u0026#34;100.0\u0026#34; x=\u0026#34;165.0\u0026#34; y=\u0026#34;135.0\u0026#34;\u0026gt;\u0026lt;/omgdc:Bounds\u0026gt; \u0026lt;/bpmndi:BPMNShape\u0026gt; \u0026lt;bpmndi:BPMNShape bpmnElement=\u0026#34;sid-AF50E3D0-2014-4308-A717-D76586837D70\u0026#34; id=\u0026#34;BPMNShape_sid-AF50E3D0-2014-4308-A717-D76586837D70\u0026#34;\u0026gt; \u0026lt;omgdc:Bounds height=\u0026#34;80.0\u0026#34; width=\u0026#34;100.0\u0026#34; x=\u0026#34;320.0\u0026#34; y=\u0026#34;138.0\u0026#34;\u0026gt;\u0026lt;/omgdc:Bounds\u0026gt; \u0026lt;/bpmndi:BPMNShape\u0026gt; \u0026lt;bpmndi:BPMNShape bpmnElement=\u0026#34;sid-F4CE7565-5977-4B9C-A603-AB3B817B8C8C\u0026#34; id=\u0026#34;BPMNShape_sid-F4CE7565-5977-4B9C-A603-AB3B817B8C8C\u0026#34;\u0026gt; \u0026lt;omgdc:Bounds height=\u0026#34;80.0\u0026#34; width=\u0026#34;100.0\u0026#34; x=\u0026#34;465.0\u0026#34; y=\u0026#34;138.0\u0026#34;\u0026gt;\u0026lt;/omgdc:Bounds\u0026gt; \u0026lt;/bpmndi:BPMNShape\u0026gt; \u0026lt;bpmndi:BPMNShape bpmnElement=\u0026#34;sid-727C1235-F9C1-4CC5-BC6C-E56ABCA105B0\u0026#34; id=\u0026#34;BPMNShape_sid-727C1235-F9C1-4CC5-BC6C-E56ABCA105B0\u0026#34;\u0026gt; \u0026lt;omgdc:Bounds height=\u0026#34;80.0\u0026#34; width=\u0026#34;100.0\u0026#34; x=\u0026#34;610.0\u0026#34; y=\u0026#34;138.0\u0026#34;\u0026gt;\u0026lt;/omgdc:Bounds\u0026gt; \u0026lt;/bpmndi:BPMNShape\u0026gt; \u0026lt;bpmndi:BPMNShape bpmnElement=\u0026#34;sid-6E5F5037-1979-4150-8408-D0BFD0315BCA\u0026#34; id=\u0026#34;BPMNShape_sid-6E5F5037-1979-4150-8408-D0BFD0315BCA\u0026#34;\u0026gt; \u0026lt;omgdc:Bounds height=\u0026#34;28.0\u0026#34; width=\u0026#34;28.0\u0026#34; x=\u0026#34;755.0\u0026#34; y=\u0026#34;164.0\u0026#34;\u0026gt;\u0026lt;/omgdc:Bounds\u0026gt; \u0026lt;/bpmndi:BPMNShape\u0026gt; \u0026lt;bpmndi:BPMNEdge bpmnElement=\u0026#34;sid-6D998C20-2A97-44B5-92D0-118E5CB05795\u0026#34; id=\u0026#34;BPMNEdge_sid-6D998C20-2A97-44B5-92D0-118E5CB05795\u0026#34; flowable:sourceDockerX=\u0026#34;50.0\u0026#34; flowable:sourceDockerY=\u0026#34;40.0\u0026#34; flowable:targetDockerX=\u0026#34;50.0\u0026#34; flowable:targetDockerY=\u0026#34;40.0\u0026#34;\u0026gt; \u0026lt;omgdi:waypoint x=\u0026#34;564.9499999999907\u0026#34; y=\u0026#34;178.0\u0026#34;\u0026gt;\u0026lt;/omgdi:waypoint\u0026gt; \u0026lt;omgdi:waypoint x=\u0026#34;609.9999999999807\u0026#34; y=\u0026#34;178.0\u0026#34;\u0026gt;\u0026lt;/omgdi:waypoint\u0026gt; \u0026lt;/bpmndi:BPMNEdge\u0026gt; \u0026lt;bpmndi:BPMNEdge bpmnElement=\u0026#34;sid-7C8750DC-E1C1-4AB2-B18C-2C103B61A5E5\u0026#34; id=\u0026#34;BPMNEdge_sid-7C8750DC-E1C1-4AB2-B18C-2C103B61A5E5\u0026#34; flowable:sourceDockerX=\u0026#34;50.0\u0026#34; flowable:sourceDockerY=\u0026#34;40.0\u0026#34; flowable:targetDockerX=\u0026#34;50.0\u0026#34; flowable:targetDockerY=\u0026#34;40.0\u0026#34;\u0026gt; \u0026lt;omgdi:waypoint x=\u0026#34;264.9499999999882\u0026#34; y=\u0026#34;175.0\u0026#34;\u0026gt;\u0026lt;/omgdi:waypoint\u0026gt; \u0026lt;omgdi:waypoint x=\u0026#34;292.5\u0026#34; y=\u0026#34;175.0\u0026#34;\u0026gt;\u0026lt;/omgdi:waypoint\u0026gt; \u0026lt;omgdi:waypoint x=\u0026#34;292.5\u0026#34; y=\u0026#34;178.0\u0026#34;\u0026gt;\u0026lt;/omgdi:waypoint\u0026gt; \u0026lt;omgdi:waypoint x=\u0026#34;319.9999999999603\u0026#34; y=\u0026#34;178.0\u0026#34;\u0026gt;\u0026lt;/omgdi:waypoint\u0026gt; \u0026lt;/bpmndi:BPMNEdge\u0026gt; \u0026lt;bpmndi:BPMNEdge bpmnElement=\u0026#34;sid-3ECF3E34-6C07-4AE6-997B-583BF8868AC8\u0026#34; id=\u0026#34;BPMNEdge_sid-3ECF3E34-6C07-4AE6-997B-583BF8868AC8\u0026#34; flowable:sourceDockerX=\u0026#34;50.0\u0026#34; flowable:sourceDockerY=\u0026#34;40.0\u0026#34; flowable:targetDockerX=\u0026#34;14.0\u0026#34; flowable:targetDockerY=\u0026#34;14.0\u0026#34;\u0026gt; \u0026lt;omgdi:waypoint x=\u0026#34;709.9499999999999\u0026#34; y=\u0026#34;178.0\u0026#34;\u0026gt;\u0026lt;/omgdi:waypoint\u0026gt; \u0026lt;omgdi:waypoint x=\u0026#34;755.0\u0026#34; y=\u0026#34;178.0\u0026#34;\u0026gt;\u0026lt;/omgdi:waypoint\u0026gt; \u0026lt;/bpmndi:BPMNEdge\u0026gt; \u0026lt;bpmndi:BPMNEdge bpmnElement=\u0026#34;sid-E2423FC5-F954-43D3-B57C-8460057CB7D6\u0026#34; id=\u0026#34;BPMNEdge_sid-E2423FC5-F954-43D3-B57C-8460057CB7D6\u0026#34; flowable:sourceDockerX=\u0026#34;15.0\u0026#34; flowable:sourceDockerY=\u0026#34;15.0\u0026#34; flowable:targetDockerX=\u0026#34;50.0\u0026#34; flowable:targetDockerY=\u0026#34;40.0\u0026#34;\u0026gt; \u0026lt;omgdi:waypoint x=\u0026#34;129.94340692927761\u0026#34; y=\u0026#34;177.55019845363262\u0026#34;\u0026gt;\u0026lt;/omgdi:waypoint\u0026gt; \u0026lt;omgdi:waypoint x=\u0026#34;164.99999999999906\u0026#34; y=\u0026#34;176.4985\u0026#34;\u0026gt;\u0026lt;/omgdi:waypoint\u0026gt; \u0026lt;/bpmndi:BPMNEdge\u0026gt; \u0026lt;bpmndi:BPMNEdge bpmnElement=\u0026#34;sid-F91582FE-D110-48C9-9407-605E503E42B2\u0026#34; id=\u0026#34;BPMNEdge_sid-F91582FE-D110-48C9-9407-605E503E42B2\u0026#34; flowable:sourceDockerX=\u0026#34;50.0\u0026#34; flowable:sourceDockerY=\u0026#34;40.0\u0026#34; flowable:targetDockerX=\u0026#34;50.0\u0026#34; flowable:targetDockerY=\u0026#34;40.0\u0026#34;\u0026gt; \u0026lt;omgdi:waypoint x=\u0026#34;419.94999999999067\u0026#34; y=\u0026#34;178.0\u0026#34;\u0026gt;\u0026lt;/omgdi:waypoint\u0026gt; \u0026lt;omgdi:waypoint x=\u0026#34;464.9999999999807\u0026#34; y=\u0026#34;178.0\u0026#34;\u0026gt;\u0026lt;/omgdi:waypoint\u0026gt; \u0026lt;/bpmndi:BPMNEdge\u0026gt; \u0026lt;/bpmndi:BPMNPlane\u0026gt; \u0026lt;/bpmndi:BPMNDiagram\u0026gt; \u0026lt;/definitions\u0026gt; 部署并运行\n依次完成1,2,3\n从任意节点跳转到任意节点\n@Test public void backProcess(){ ProcessEngine engine=ProcessEngines.getDefaultProcessEngine(); RuntimeService runtimeService = engine.getRuntimeService(); //从当前流程跳转到任意节点 runtimeService.createChangeActivityStateBuilder() .processInstanceId(\u0026#34;2501\u0026#34;) //4--\u0026gt;3 ,活动id .moveActivityIdTo(\u0026#34;sid-727C1235-F9C1-4CC5-BC6C-E56ABCA105B0\u0026#34;, \u0026#34;sid-F4CE7565-5977-4B9C-A603-AB3B817B8C8C\u0026#34;) .changeState(); } 可以在这个表里让用户选择回退节点 此时让user3再完成任务\n注:用下面的方法,不关心当前节点,只写明要跳转的结点即可 自定义表单 # 内置表单 # 绘制 xml\n\u0026lt;?xml version=\u0026#34;1.0\u0026#34; encoding=\u0026#34;UTF-8\u0026#34;?\u0026gt; \u0026lt;definitions xmlns=\u0026#34;http://www.omg.org/spec/BPMN/20100524/MODEL\u0026#34; xmlns:xsi=\u0026#34;http://www.w3.org/2001/XMLSchema-instance\u0026#34; xmlns:xsd=\u0026#34;http://www.w3.org/2001/XMLSchema\u0026#34; xmlns:flowable=\u0026#34;http://flowable.org/bpmn\u0026#34; xmlns:bpmndi=\u0026#34;http://www.omg.org/spec/BPMN/20100524/DI\u0026#34; xmlns:omgdc=\u0026#34;http://www.omg.org/spec/DD/20100524/DC\u0026#34; xmlns:omgdi=\u0026#34;http://www.omg.org/spec/DD/20100524/DI\u0026#34; typeLanguage=\u0026#34;http://www.w3.org/2001/XMLSchema\u0026#34; expressionLanguage=\u0026#34;http://www.w3.org/1999/XPath\u0026#34; targetNamespace=\u0026#34;http://www.flowable.org/processdef\u0026#34; exporter=\u0026#34;Flowable Open Source Modeler\u0026#34; exporterVersion=\u0026#34;6.7.2\u0026#34;\u0026gt; \u0026lt;process id=\u0026#34;form1-test-key\u0026#34; name=\u0026#34;form1-test-name\u0026#34; isExecutable=\u0026#34;true\u0026#34;\u0026gt; \u0026lt;documentation\u0026gt;form1-test-desc\u0026lt;/documentation\u0026gt; \u0026lt;startEvent id=\u0026#34;startEvent1\u0026#34; flowable:formFieldValidation=\u0026#34;true\u0026#34;\u0026gt; \u0026lt;extensionElements\u0026gt; \u0026lt;flowable:formProperty id=\u0026#34;days\u0026#34; name=\u0026#34;天数\u0026#34; type=\u0026#34;long\u0026#34; default=\u0026#34;5\u0026#34;\u0026gt;\u0026lt;/flowable:formProperty\u0026gt; \u0026lt;flowable:formProperty id=\u0026#34;start_time\u0026#34; name=\u0026#34;开始时间\u0026#34; type=\u0026#34;date\u0026#34; datePattern=\u0026#34;MM-dd-yyyy\u0026#34;\u0026gt;\u0026lt;/flowable:formProperty\u0026gt; \u0026lt;flowable:formProperty id=\u0026#34;reason\u0026#34; name=\u0026#34;原因\u0026#34; type=\u0026#34;string\u0026#34;\u0026gt;\u0026lt;/flowable:formProperty\u0026gt; \u0026lt;/extensionElements\u0026gt; \u0026lt;/startEvent\u0026gt; \u0026lt;userTask id=\u0026#34;sid-4C9C8571-1423-4137-93FC-6A138D504E24\u0026#34; name=\u0026#34;用户申请\u0026#34; flowable:formFieldValidation=\u0026#34;true\u0026#34;\u0026gt; \u0026lt;extensionElements\u0026gt; \u0026lt;flowable:formProperty id=\u0026#34;days\u0026#34; name=\u0026#34;天数\u0026#34; type=\u0026#34;long\u0026#34;\u0026gt;\u0026lt;/flowable:formProperty\u0026gt; \u0026lt;flowable:formProperty id=\u0026#34;start_time\u0026#34; name=\u0026#34;开始时间\u0026#34; type=\u0026#34;date\u0026#34; datePattern=\u0026#34;MM-dd-yyyy\u0026#34;\u0026gt;\u0026lt;/flowable:formProperty\u0026gt; \u0026lt;flowable:formProperty id=\u0026#34;reason\u0026#34; name=\u0026#34;原因\u0026#34; type=\u0026#34;string\u0026#34;\u0026gt;\u0026lt;/flowable:formProperty\u0026gt; \u0026lt;/extensionElements\u0026gt; \u0026lt;/userTask\u0026gt; \u0026lt;sequenceFlow id=\u0026#34;sid-8944FE04-D27B-435F-A8A8-4E545AB3D6C0\u0026#34; sourceRef=\u0026#34;startEvent1\u0026#34; targetRef=\u0026#34;sid-4C9C8571-1423-4137-93FC-6A138D504E24\u0026#34;\u0026gt;\u0026lt;/sequenceFlow\u0026gt; \u0026lt;exclusiveGateway id=\u0026#34;sid-35DD948A-C095-486E-98E0-4A0EEC4D9FBC\u0026#34;\u0026gt;\u0026lt;/exclusiveGateway\u0026gt; \u0026lt;sequenceFlow id=\u0026#34;sid-0EA36B83-6115-414F-BC7D-9CB338B03F22\u0026#34; sourceRef=\u0026#34;sid-4C9C8571-1423-4137-93FC-6A138D504E24\u0026#34; targetRef=\u0026#34;sid-35DD948A-C095-486E-98E0-4A0EEC4D9FBC\u0026#34;\u0026gt;\u0026lt;/sequenceFlow\u0026gt; \u0026lt;userTask id=\u0026#34;sid-4B6496FE-B5FE-41AC-83F8-4B7224B09FBD\u0026#34; name=\u0026#34;总监审批\u0026#34; flowable:formFieldValidation=\u0026#34;true\u0026#34;\u0026gt;\u0026lt;/userTask\u0026gt; \u0026lt;userTask id=\u0026#34;sid-8DE5EA05-89D5-48B0-9359-F8ABFB3A3500\u0026#34; name=\u0026#34;部门经理审批\u0026#34; flowable:formFieldValidation=\u0026#34;true\u0026#34;\u0026gt;\u0026lt;/userTask\u0026gt; \u0026lt;exclusiveGateway id=\u0026#34;sid-0EC09183-F41B-4785-83E7-423BB86EB013\u0026#34;\u0026gt;\u0026lt;/exclusiveGateway\u0026gt; \u0026lt;sequenceFlow id=\u0026#34;sid-562C26B5-B634-4771-BF54-C311D56A5317\u0026#34; sourceRef=\u0026#34;sid-4B6496FE-B5FE-41AC-83F8-4B7224B09FBD\u0026#34; targetRef=\u0026#34;sid-0EC09183-F41B-4785-83E7-423BB86EB013\u0026#34;\u0026gt;\u0026lt;/sequenceFlow\u0026gt; \u0026lt;sequenceFlow id=\u0026#34;sid-9AC3E009-D4D6-4D8B-883C-701E044715E9\u0026#34; sourceRef=\u0026#34;sid-8DE5EA05-89D5-48B0-9359-F8ABFB3A3500\u0026#34; targetRef=\u0026#34;sid-0EC09183-F41B-4785-83E7-423BB86EB013\u0026#34;\u0026gt;\u0026lt;/sequenceFlow\u0026gt; \u0026lt;endEvent id=\u0026#34;sid-9CD52D35-7874-42F4-B392-466F71316BFE\u0026#34;\u0026gt;\u0026lt;/endEvent\u0026gt; \u0026lt;sequenceFlow id=\u0026#34;sid-FABB64D1-0182-41D8-90FE-53FE7FE3F024\u0026#34; sourceRef=\u0026#34;sid-0EC09183-F41B-4785-83E7-423BB86EB013\u0026#34; targetRef=\u0026#34;sid-9CD52D35-7874-42F4-B392-466F71316BFE\u0026#34;\u0026gt;\u0026lt;/sequenceFlow\u0026gt; \u0026lt;sequenceFlow id=\u0026#34;sid-E4DB6764-3EA3-427B-AD00-4D812E404FD6\u0026#34; sourceRef=\u0026#34;sid-35DD948A-C095-486E-98E0-4A0EEC4D9FBC\u0026#34; targetRef=\u0026#34;sid-4B6496FE-B5FE-41AC-83F8-4B7224B09FBD\u0026#34;\u0026gt; \u0026lt;conditionExpression xsi:type=\u0026#34;tFormalExpression\u0026#34;\u0026gt;\u0026lt;![CDATA[${day \u0026gt; 3}]]\u0026gt;\u0026lt;/conditionExpression\u0026gt; \u0026lt;/sequenceFlow\u0026gt; \u0026lt;sequenceFlow id=\u0026#34;sid-585C37CB-61FE-4518-B3B6-5722A90A854F\u0026#34; sourceRef=\u0026#34;sid-35DD948A-C095-486E-98E0-4A0EEC4D9FBC\u0026#34; targetRef=\u0026#34;sid-8DE5EA05-89D5-48B0-9359-F8ABFB3A3500\u0026#34;\u0026gt; \u0026lt;conditionExpression xsi:type=\u0026#34;tFormalExpression\u0026#34;\u0026gt;\u0026lt;![CDATA[${day \u0026lt;= 3}]]\u0026gt;\u0026lt;/conditionExpression\u0026gt; \u0026lt;/sequenceFlow\u0026gt; \u0026lt;/process\u0026gt; \u0026lt;bpmndi:BPMNDiagram id=\u0026#34;BPMNDiagram_form1-test-key\u0026#34;\u0026gt; \u0026lt;bpmndi:BPMNPlane bpmnElement=\u0026#34;form1-test-key\u0026#34; id=\u0026#34;BPMNPlane_form1-test-key\u0026#34;\u0026gt; \u0026lt;bpmndi:BPMNShape bpmnElement=\u0026#34;startEvent1\u0026#34; id=\u0026#34;BPMNShape_startEvent1\u0026#34;\u0026gt; \u0026lt;omgdc:Bounds height=\u0026#34;30.0\u0026#34; width=\u0026#34;30.0\u0026#34; x=\u0026#34;100.0\u0026#34; y=\u0026#34;163.0\u0026#34;\u0026gt;\u0026lt;/omgdc:Bounds\u0026gt; \u0026lt;/bpmndi:BPMNShape\u0026gt; \u0026lt;bpmndi:BPMNShape bpmnElement=\u0026#34;sid-4C9C8571-1423-4137-93FC-6A138D504E24\u0026#34; id=\u0026#34;BPMNShape_sid-4C9C8571-1423-4137-93FC-6A138D504E24\u0026#34;\u0026gt; \u0026lt;omgdc:Bounds height=\u0026#34;80.0\u0026#34; width=\u0026#34;100.0\u0026#34; x=\u0026#34;175.0\u0026#34; y=\u0026#34;138.0\u0026#34;\u0026gt;\u0026lt;/omgdc:Bounds\u0026gt; \u0026lt;/bpmndi:BPMNShape\u0026gt; \u0026lt;bpmndi:BPMNShape bpmnElement=\u0026#34;sid-35DD948A-C095-486E-98E0-4A0EEC4D9FBC\u0026#34; id=\u0026#34;BPMNShape_sid-35DD948A-C095-486E-98E0-4A0EEC4D9FBC\u0026#34;\u0026gt; \u0026lt;omgdc:Bounds height=\u0026#34;40.0\u0026#34; width=\u0026#34;40.0\u0026#34; x=\u0026#34;315.0\u0026#34; y=\u0026#34;150.0\u0026#34;\u0026gt;\u0026lt;/omgdc:Bounds\u0026gt; \u0026lt;/bpmndi:BPMNShape\u0026gt; \u0026lt;bpmndi:BPMNShape bpmnElement=\u0026#34;sid-4B6496FE-B5FE-41AC-83F8-4B7224B09FBD\u0026#34; id=\u0026#34;BPMNShape_sid-4B6496FE-B5FE-41AC-83F8-4B7224B09FBD\u0026#34;\u0026gt; \u0026lt;omgdc:Bounds height=\u0026#34;80.0\u0026#34; width=\u0026#34;100.0\u0026#34; x=\u0026#34;405.0\u0026#34; y=\u0026#34;30.0\u0026#34;\u0026gt;\u0026lt;/omgdc:Bounds\u0026gt; \u0026lt;/bpmndi:BPMNShape\u0026gt; \u0026lt;bpmndi:BPMNShape bpmnElement=\u0026#34;sid-8DE5EA05-89D5-48B0-9359-F8ABFB3A3500\u0026#34; id=\u0026#34;BPMNShape_sid-8DE5EA05-89D5-48B0-9359-F8ABFB3A3500\u0026#34;\u0026gt; \u0026lt;omgdc:Bounds height=\u0026#34;80.0\u0026#34; width=\u0026#34;100.0\u0026#34; x=\u0026#34;405.0\u0026#34; y=\u0026#34;225.0\u0026#34;\u0026gt;\u0026lt;/omgdc:Bounds\u0026gt; \u0026lt;/bpmndi:BPMNShape\u0026gt; \u0026lt;bpmndi:BPMNShape bpmnElement=\u0026#34;sid-0EC09183-F41B-4785-83E7-423BB86EB013\u0026#34; id=\u0026#34;BPMNShape_sid-0EC09183-F41B-4785-83E7-423BB86EB013\u0026#34;\u0026gt; \u0026lt;omgdc:Bounds height=\u0026#34;40.0\u0026#34; width=\u0026#34;40.0\u0026#34; x=\u0026#34;585.0\u0026#34; y=\u0026#34;165.0\u0026#34;\u0026gt;\u0026lt;/omgdc:Bounds\u0026gt; \u0026lt;/bpmndi:BPMNShape\u0026gt; \u0026lt;bpmndi:BPMNShape bpmnElement=\u0026#34;sid-9CD52D35-7874-42F4-B392-466F71316BFE\u0026#34; id=\u0026#34;BPMNShape_sid-9CD52D35-7874-42F4-B392-466F71316BFE\u0026#34;\u0026gt; \u0026lt;omgdc:Bounds height=\u0026#34;28.0\u0026#34; width=\u0026#34;28.0\u0026#34; x=\u0026#34;670.0\u0026#34; y=\u0026#34;171.0\u0026#34;\u0026gt;\u0026lt;/omgdc:Bounds\u0026gt; \u0026lt;/bpmndi:BPMNShape\u0026gt; \u0026lt;bpmndi:BPMNEdge bpmnElement=\u0026#34;sid-585C37CB-61FE-4518-B3B6-5722A90A854F\u0026#34; id=\u0026#34;BPMNEdge_sid-585C37CB-61FE-4518-B3B6-5722A90A854F\u0026#34; flowable:sourceDockerX=\u0026#34;20.5\u0026#34; flowable:sourceDockerY=\u0026#34;20.5\u0026#34; flowable:targetDockerX=\u0026#34;50.0\u0026#34; flowable:targetDockerY=\u0026#34;40.0\u0026#34;\u0026gt; \u0026lt;omgdi:waypoint x=\u0026#34;335.5\u0026#34; y=\u0026#34;189.43998414376327\u0026#34;\u0026gt;\u0026lt;/omgdi:waypoint\u0026gt; \u0026lt;omgdi:waypoint x=\u0026#34;335.5\u0026#34; y=\u0026#34;265.0\u0026#34;\u0026gt;\u0026lt;/omgdi:waypoint\u0026gt; \u0026lt;omgdi:waypoint x=\u0026#34;405.0\u0026#34; y=\u0026#34;265.0\u0026#34;\u0026gt;\u0026lt;/omgdi:waypoint\u0026gt; \u0026lt;/bpmndi:BPMNEdge\u0026gt; \u0026lt;bpmndi:BPMNEdge bpmnElement=\u0026#34;sid-E4DB6764-3EA3-427B-AD00-4D812E404FD6\u0026#34; id=\u0026#34;BPMNEdge_sid-E4DB6764-3EA3-427B-AD00-4D812E404FD6\u0026#34; flowable:sourceDockerX=\u0026#34;20.5\u0026#34; flowable:sourceDockerY=\u0026#34;20.5\u0026#34; flowable:targetDockerX=\u0026#34;50.0\u0026#34; flowable:targetDockerY=\u0026#34;40.0\u0026#34;\u0026gt; \u0026lt;omgdi:waypoint x=\u0026#34;336.66824324324324\u0026#34; y=\u0026#34;151.67117117117118\u0026#34;\u0026gt;\u0026lt;/omgdi:waypoint\u0026gt; \u0026lt;omgdi:waypoint x=\u0026#34;342.0\u0026#34; y=\u0026#34;66.0\u0026#34;\u0026gt;\u0026lt;/omgdi:waypoint\u0026gt; \u0026lt;omgdi:waypoint x=\u0026#34;404.9999999999999\u0026#34; y=\u0026#34;68.23008849557522\u0026#34;\u0026gt;\u0026lt;/omgdi:waypoint\u0026gt; \u0026lt;/bpmndi:BPMNEdge\u0026gt; \u0026lt;bpmndi:BPMNEdge bpmnElement=\u0026#34;sid-0EA36B83-6115-414F-BC7D-9CB338B03F22\u0026#34; id=\u0026#34;BPMNEdge_sid-0EA36B83-6115-414F-BC7D-9CB338B03F22\u0026#34; flowable:sourceDockerX=\u0026#34;50.0\u0026#34; flowable:sourceDockerY=\u0026#34;40.0\u0026#34; flowable:targetDockerX=\u0026#34;20.5\u0026#34; flowable:targetDockerY=\u0026#34;20.5\u0026#34;\u0026gt; \u0026lt;omgdi:waypoint x=\u0026#34;274.95000000000005\u0026#34; y=\u0026#34;174.60633484162895\u0026#34;\u0026gt;\u0026lt;/omgdi:waypoint\u0026gt; \u0026lt;omgdi:waypoint x=\u0026#34;316.77118644067775\u0026#34; y=\u0026#34;171.76800847457628\u0026#34;\u0026gt;\u0026lt;/omgdi:waypoint\u0026gt; \u0026lt;/bpmndi:BPMNEdge\u0026gt; \u0026lt;bpmndi:BPMNEdge bpmnElement=\u0026#34;sid-8944FE04-D27B-435F-A8A8-4E545AB3D6C0\u0026#34; id=\u0026#34;BPMNEdge_sid-8944FE04-D27B-435F-A8A8-4E545AB3D6C0\u0026#34; flowable:sourceDockerX=\u0026#34;15.0\u0026#34; flowable:sourceDockerY=\u0026#34;15.0\u0026#34; flowable:targetDockerX=\u0026#34;50.0\u0026#34; flowable:targetDockerY=\u0026#34;40.0\u0026#34;\u0026gt; \u0026lt;omgdi:waypoint x=\u0026#34;129.9499984899576\u0026#34; y=\u0026#34;178.0\u0026#34;\u0026gt;\u0026lt;/omgdi:waypoint\u0026gt; \u0026lt;omgdi:waypoint x=\u0026#34;174.9999999999917\u0026#34; y=\u0026#34;178.0\u0026#34;\u0026gt;\u0026lt;/omgdi:waypoint\u0026gt; \u0026lt;/bpmndi:BPMNEdge\u0026gt; \u0026lt;bpmndi:BPMNEdge bpmnElement=\u0026#34;sid-FABB64D1-0182-41D8-90FE-53FE7FE3F024\u0026#34; id=\u0026#34;BPMNEdge_sid-FABB64D1-0182-41D8-90FE-53FE7FE3F024\u0026#34; flowable:sourceDockerX=\u0026#34;20.5\u0026#34; flowable:sourceDockerY=\u0026#34;20.5\u0026#34; flowable:targetDockerX=\u0026#34;14.0\u0026#34; flowable:targetDockerY=\u0026#34;14.0\u0026#34;\u0026gt; \u0026lt;omgdi:waypoint x=\u0026#34;624.5591869398207\u0026#34; y=\u0026#34;185.37820512820514\u0026#34;\u0026gt;\u0026lt;/omgdi:waypoint\u0026gt; \u0026lt;omgdi:waypoint x=\u0026#34;670.0002755524882\u0026#34; y=\u0026#34;185.08885188426405\u0026#34;\u0026gt;\u0026lt;/omgdi:waypoint\u0026gt; \u0026lt;/bpmndi:BPMNEdge\u0026gt; \u0026lt;bpmndi:BPMNEdge bpmnElement=\u0026#34;sid-9AC3E009-D4D6-4D8B-883C-701E044715E9\u0026#34; id=\u0026#34;BPMNEdge_sid-9AC3E009-D4D6-4D8B-883C-701E044715E9\u0026#34; flowable:sourceDockerX=\u0026#34;50.0\u0026#34; flowable:sourceDockerY=\u0026#34;40.0\u0026#34; flowable:targetDockerX=\u0026#34;20.0\u0026#34; flowable:targetDockerY=\u0026#34;20.0\u0026#34;\u0026gt; \u0026lt;omgdi:waypoint x=\u0026#34;504.95000000000005\u0026#34; y=\u0026#34;238.33333333333334\u0026#34;\u0026gt;\u0026lt;/omgdi:waypoint\u0026gt; \u0026lt;omgdi:waypoint x=\u0026#34;591.9565217391304\u0026#34; y=\u0026#34;191.93913043478258\u0026#34;\u0026gt;\u0026lt;/omgdi:waypoint\u0026gt; \u0026lt;/bpmndi:BPMNEdge\u0026gt; \u0026lt;bpmndi:BPMNEdge bpmnElement=\u0026#34;sid-562C26B5-B634-4771-BF54-C311D56A5317\u0026#34; id=\u0026#34;BPMNEdge_sid-562C26B5-B634-4771-BF54-C311D56A5317\u0026#34; flowable:sourceDockerX=\u0026#34;50.0\u0026#34; flowable:sourceDockerY=\u0026#34;40.0\u0026#34; flowable:targetDockerX=\u0026#34;20.5\u0026#34; flowable:targetDockerY=\u0026#34;20.5\u0026#34;\u0026gt; \u0026lt;omgdi:waypoint x=\u0026#34;504.95000000000005\u0026#34; y=\u0026#34;70.0\u0026#34;\u0026gt;\u0026lt;/omgdi:waypoint\u0026gt; \u0026lt;omgdi:waypoint x=\u0026#34;605.5\u0026#34; y=\u0026#34;70.0\u0026#34;\u0026gt;\u0026lt;/omgdi:waypoint\u0026gt; \u0026lt;omgdi:waypoint x=\u0026#34;605.5\u0026#34; y=\u0026#34;165.5\u0026#34;\u0026gt;\u0026lt;/omgdi:waypoint\u0026gt; \u0026lt;/bpmndi:BPMNEdge\u0026gt; \u0026lt;/bpmndi:BPMNPlane\u0026gt; \u0026lt;/bpmndi:BPMNDiagram\u0026gt; \u0026lt;/definitions\u0026gt; 将流程定义部署\n@Test public void deploy() { ProcessEngine engine = ProcessEngines.getDefaultProcessEngine(); RepositoryService repositoryService = engine.getRepositoryService(); Deployment deploy = repositoryService.createDeployment() .addClasspathResource(\u0026#34;form1-test-name.bpmn20.xml\u0026#34;) .deploy(); System.out.println(\u0026#34;部署成功:\u0026#34; + deploy.getId()); } 查看部署的流程内置的表单\n@Test public void getStartForm(){ ProcessEngine engine=ProcessEngines.getDefaultProcessEngine(); FormService formService = engine.getFormService(); StartFormData startFormData = formService.getStartFormData(\u0026#34;form1-test-key:1:17504\u0026#34;); List\u0026lt;FormProperty\u0026gt; formProperties = startFormData.getFormProperties(); for (FormProperty property:formProperties){ System.out.println(\u0026#34;id==\u0026gt;\u0026#34;+property.getId()); System.out.println(\u0026#34;name==\u0026gt;\u0026#34;+property.getName()); System.out.println(\u0026#34;value==\u0026gt;\u0026#34;+property.getValue()); } } 第一种启动方式,通过map 第二种启动方式\n@Test public void startProcess2(){ ProcessEngine engine=ProcessEngines.getDefaultProcessEngine(); FormService formService = engine.getFormService(); Map\u0026lt;String,String\u0026gt; map=new HashMap\u0026lt;\u0026gt;(); map.put(\u0026#34;days\u0026#34;,\u0026#34;2\u0026#34;); map.put(\u0026#34;startTime\u0026#34;,\u0026#34;22020405\u0026#34;); map.put(\u0026#34;reason\u0026#34;,\u0026#34;想玩\u0026#34;); formService.submitStartFormData(\u0026#34;form1-test-key:1:17504\u0026#34;,map); } 注意查看act_ru_variable变量表 查看任务中的表单数据\n/** * 查看对应的表单数据 */ @Test public void getTaskFormData(){ ProcessEngine engine=ProcessEngines.getDefaultProcessEngine(); FormService formService = engine.getFormService(); TaskFormData taskFormData = formService.getTaskFormData(\u0026#34;20012\u0026#34;); List\u0026lt;FormProperty\u0026gt; formProperties = taskFormData.getFormProperties(); for (FormProperty property:formProperties){ System.out.println(\u0026#34;id==\u0026gt;\u0026#34;+property.getId()); System.out.println(\u0026#34;name==\u0026gt;\u0026#34;+property.getName()); System.out.println(\u0026#34;value==\u0026gt;\u0026#34;+property.getValue()); } //这里做一个测试,设置处理人 /*TaskService taskService = engine.getTaskService(); taskService.setAssignee(\u0026#34;20012\u0026#34;,\u0026#34;lalala\u0026#34;);*/ } 查看完成的任务【主要】//有点问题,不管 外置表单 # [flowable-ui中没找到,不知道是不是eclipse独有的]\n"},{"id":387,"href":"/zh/docs/technology/Flowable/boge_blbl_/02-advance_5/","title":"boge-02-flowable进阶_5","section":"基础(波哥)_","content":" 网关 # 排他网关 # 会按照所有出口顺序流定义的顺序对它们进行计算,选择第一个条件计算为true的顺序流(当没有设置条件时,认为顺序流为true)继续流程\n排他网关的绘制 xml文件\n\u0026lt;?xml version=\u0026#34;1.0\u0026#34; encoding=\u0026#34;UTF-8\u0026#34;?\u0026gt; \u0026lt;definitions xmlns=\u0026#34;http://www.omg.org/spec/BPMN/20100524/MODEL\u0026#34; xmlns:xsi=\u0026#34;http://www.w3.org/2001/XMLSchema-instance\u0026#34; xmlns:xsd=\u0026#34;http://www.w3.org/2001/XMLSchema\u0026#34; xmlns:flowable=\u0026#34;http://flowable.org/bpmn\u0026#34; xmlns:bpmndi=\u0026#34;http://www.omg.org/spec/BPMN/20100524/DI\u0026#34; xmlns:omgdc=\u0026#34;http://www.omg.org/spec/DD/20100524/DC\u0026#34; xmlns:omgdi=\u0026#34;http://www.omg.org/spec/DD/20100524/DI\u0026#34; typeLanguage=\u0026#34;http://www.w3.org/2001/XMLSchema\u0026#34; expressionLanguage=\u0026#34;http://www.w3.org/1999/XPath\u0026#34; targetNamespace=\u0026#34;http://www.flowable.org/processdef\u0026#34; exporter=\u0026#34;Flowable Open Source Modeler\u0026#34; exporterVersion=\u0026#34;6.7.2\u0026#34;\u0026gt; \u0026lt;process id=\u0026#34;holiday-exclusive\u0026#34; name=\u0026#34;请假流程-排他网关\u0026#34; isExecutable=\u0026#34;true\u0026#34;\u0026gt; \u0026lt;startEvent id=\u0026#34;startEvent1\u0026#34; flowable:formFieldValidation=\u0026#34;true\u0026#34;\u0026gt;\u0026lt;/startEvent\u0026gt; \u0026lt;userTask id=\u0026#34;sid-3D5ED4D4-97F5-4FFD-B160-F00566ECC55E\u0026#34; name=\u0026#34;创建请假单\u0026#34; flowable:assignee=\u0026#34;zhangsan\u0026#34; flowable:formFieldValidation=\u0026#34;true\u0026#34;\u0026gt; \u0026lt;extensionElements\u0026gt; \u0026lt;modeler:initiator-can-complete xmlns:modeler=\u0026#34;http://flowable.org/modeler\u0026#34;\u0026gt;\u0026lt;![CDATA[false]]\u0026gt;\u0026lt;/modeler:initiator-can-complete\u0026gt; \u0026lt;/extensionElements\u0026gt; \u0026lt;/userTask\u0026gt; \u0026lt;sequenceFlow id=\u0026#34;sid-33A73370-751D-413F-9306-39DEAA674DB6\u0026#34; sourceRef=\u0026#34;startEvent1\u0026#34; targetRef=\u0026#34;sid-3D5ED4D4-97F5-4FFD-B160-F00566ECC55E\u0026#34;\u0026gt;\u0026lt;/sequenceFlow\u0026gt; \u0026lt;exclusiveGateway id=\u0026#34;sid-5B2117E6-D341-49F2-85B2-336CA836C7D8\u0026#34;\u0026gt;\u0026lt;/exclusiveGateway\u0026gt; \u0026lt;sequenceFlow id=\u0026#34;sid-D1B1F6E0-EA7F-4FF7-AD0C-5D43DBCEBFD2\u0026#34; sourceRef=\u0026#34;sid-3D5ED4D4-97F5-4FFD-B160-F00566ECC55E\u0026#34; targetRef=\u0026#34;sid-5B2117E6-D341-49F2-85B2-336CA836C7D8\u0026#34;\u0026gt;\u0026lt;/sequenceFlow\u0026gt; \u0026lt;userTask id=\u0026#34;sid-08A6CB64-C9BB-4342-852D-444A75315BDE\u0026#34; name=\u0026#34;总经理审批\u0026#34; flowable:assignee=\u0026#34;wangwu\u0026#34; flowable:formFieldValidation=\u0026#34;true\u0026#34;\u0026gt; \u0026lt;extensionElements\u0026gt; \u0026lt;modeler:initiator-can-complete xmlns:modeler=\u0026#34;http://flowable.org/modeler\u0026#34;\u0026gt;\u0026lt;![CDATA[false]]\u0026gt;\u0026lt;/modeler:initiator-can-complete\u0026gt; \u0026lt;/extensionElements\u0026gt; \u0026lt;/userTask\u0026gt; \u0026lt;userTask id=\u0026#34;sid-EA98D0C3-E41D-4DEB-8933-91A1B7301ABE\u0026#34; name=\u0026#34;部门经理审批\u0026#34; flowable:assignee=\u0026#34;lisi\u0026#34; flowable:formFieldValidation=\u0026#34;true\u0026#34;\u0026gt; \u0026lt;extensionElements\u0026gt; \u0026lt;modeler:initiator-can-complete xmlns:modeler=\u0026#34;http://flowable.org/modeler\u0026#34;\u0026gt;\u0026lt;![CDATA[false]]\u0026gt;\u0026lt;/modeler:initiator-can-complete\u0026gt; \u0026lt;/extensionElements\u0026gt; \u0026lt;/userTask\u0026gt; \u0026lt;userTask id=\u0026#34;sid-24F73F7F-EB61-484F-A494-686E194D0118\u0026#34; name=\u0026#34;人事审批\u0026#34; flowable:assignee=\u0026#34;zhaoliu\u0026#34; flowable:formFieldValidation=\u0026#34;true\u0026#34;\u0026gt; \u0026lt;extensionElements\u0026gt; \u0026lt;modeler:initiator-can-complete xmlns:modeler=\u0026#34;http://flowable.org/modeler\u0026#34;\u0026gt;\u0026lt;![CDATA[false]]\u0026gt;\u0026lt;/modeler:initiator-can-complete\u0026gt; \u0026lt;/extensionElements\u0026gt; \u0026lt;/userTask\u0026gt; \u0026lt;sequenceFlow id=\u0026#34;sid-8BA0B88C-BA4F-446D-B5E7-6BF0830B1DC8\u0026#34; sourceRef=\u0026#34;sid-EA98D0C3-E41D-4DEB-8933-91A1B7301ABE\u0026#34; targetRef=\u0026#34;sid-24F73F7F-EB61-484F-A494-686E194D0118\u0026#34;\u0026gt;\u0026lt;/sequenceFlow\u0026gt; \u0026lt;sequenceFlow id=\u0026#34;sid-E748F81F-B0B2-4C34-B993-FBAA2BCD0995\u0026#34; sourceRef=\u0026#34;sid-08A6CB64-C9BB-4342-852D-444A75315BDE\u0026#34; targetRef=\u0026#34;sid-24F73F7F-EB61-484F-A494-686E194D0118\u0026#34;\u0026gt;\u0026lt;/sequenceFlow\u0026gt; \u0026lt;sequenceFlow id=\u0026#34;sid-928C6C6F-57F1-40F2-BE0F-1A9FF3E6E9E4\u0026#34; sourceRef=\u0026#34;sid-5B2117E6-D341-49F2-85B2-336CA836C7D8\u0026#34; targetRef=\u0026#34;sid-08A6CB64-C9BB-4342-852D-444A75315BDE\u0026#34;\u0026gt; \u0026lt;conditionExpression xsi:type=\u0026#34;tFormalExpression\u0026#34;\u0026gt;\u0026lt;![CDATA[${num\u0026gt;3}]]\u0026gt;\u0026lt;/conditionExpression\u0026gt; \u0026lt;/sequenceFlow\u0026gt; \u0026lt;sequenceFlow id=\u0026#34;sid-4DB25720-11C8-401E-BB4C-83BB25510B2E\u0026#34; sourceRef=\u0026#34;sid-5B2117E6-D341-49F2-85B2-336CA836C7D8\u0026#34; targetRef=\u0026#34;sid-EA98D0C3-E41D-4DEB-8933-91A1B7301ABE\u0026#34;\u0026gt; \u0026lt;conditionExpression xsi:type=\u0026#34;tFormalExpression\u0026#34;\u0026gt;\u0026lt;![CDATA[${num\u0026lt;3}]]\u0026gt;\u0026lt;/conditionExpression\u0026gt; \u0026lt;/sequenceFlow\u0026gt; \u0026lt;/process\u0026gt; \u0026lt;bpmndi:BPMNDiagram id=\u0026#34;BPMNDiagram_holiday-exclusive\u0026#34;\u0026gt; \u0026lt;bpmndi:BPMNPlane bpmnElement=\u0026#34;holiday-exclusive\u0026#34; id=\u0026#34;BPMNPlane_holiday-exclusive\u0026#34;\u0026gt; \u0026lt;bpmndi:BPMNShape bpmnElement=\u0026#34;startEvent1\u0026#34; id=\u0026#34;BPMNShape_startEvent1\u0026#34;\u0026gt; \u0026lt;omgdc:Bounds height=\u0026#34;30.0\u0026#34; width=\u0026#34;30.0\u0026#34; x=\u0026#34;30.0\u0026#34; y=\u0026#34;163.0\u0026#34;\u0026gt;\u0026lt;/omgdc:Bounds\u0026gt; \u0026lt;/bpmndi:BPMNShape\u0026gt; \u0026lt;bpmndi:BPMNShape bpmnElement=\u0026#34;sid-3D5ED4D4-97F5-4FFD-B160-F00566ECC55E\u0026#34; id=\u0026#34;BPMNShape_sid-3D5ED4D4-97F5-4FFD-B160-F00566ECC55E\u0026#34;\u0026gt; \u0026lt;omgdc:Bounds height=\u0026#34;80.0\u0026#34; width=\u0026#34;100.0\u0026#34; x=\u0026#34;150.0\u0026#34; y=\u0026#34;135.0\u0026#34;\u0026gt;\u0026lt;/omgdc:Bounds\u0026gt; \u0026lt;/bpmndi:BPMNShape\u0026gt; \u0026lt;bpmndi:BPMNShape bpmnElement=\u0026#34;sid-5B2117E6-D341-49F2-85B2-336CA836C7D8\u0026#34; id=\u0026#34;BPMNShape_sid-5B2117E6-D341-49F2-85B2-336CA836C7D8\u0026#34;\u0026gt; \u0026lt;omgdc:Bounds height=\u0026#34;40.0\u0026#34; width=\u0026#34;40.0\u0026#34; x=\u0026#34;315.0\u0026#34; y=\u0026#34;155.0\u0026#34;\u0026gt;\u0026lt;/omgdc:Bounds\u0026gt; \u0026lt;/bpmndi:BPMNShape\u0026gt; \u0026lt;bpmndi:BPMNShape bpmnElement=\u0026#34;sid-08A6CB64-C9BB-4342-852D-444A75315BDE\u0026#34; id=\u0026#34;BPMNShape_sid-08A6CB64-C9BB-4342-852D-444A75315BDE\u0026#34;\u0026gt; \u0026lt;omgdc:Bounds height=\u0026#34;80.0\u0026#34; width=\u0026#34;100.0\u0026#34; x=\u0026#34;420.0\u0026#34; y=\u0026#34;225.0\u0026#34;\u0026gt;\u0026lt;/omgdc:Bounds\u0026gt; \u0026lt;/bpmndi:BPMNShape\u0026gt; \u0026lt;bpmndi:BPMNShape bpmnElement=\u0026#34;sid-EA98D0C3-E41D-4DEB-8933-91A1B7301ABE\u0026#34; id=\u0026#34;BPMNShape_sid-EA98D0C3-E41D-4DEB-8933-91A1B7301ABE\u0026#34;\u0026gt; \u0026lt;omgdc:Bounds height=\u0026#34;80.0\u0026#34; width=\u0026#34;100.0\u0026#34; x=\u0026#34;405.0\u0026#34; y=\u0026#34;30.0\u0026#34;\u0026gt;\u0026lt;/omgdc:Bounds\u0026gt; \u0026lt;/bpmndi:BPMNShape\u0026gt; \u0026lt;bpmndi:BPMNShape bpmnElement=\u0026#34;sid-24F73F7F-EB61-484F-A494-686E194D0118\u0026#34; id=\u0026#34;BPMNShape_sid-24F73F7F-EB61-484F-A494-686E194D0118\u0026#34;\u0026gt; \u0026lt;omgdc:Bounds height=\u0026#34;80.0\u0026#34; width=\u0026#34;100.0\u0026#34; x=\u0026#34;630.0\u0026#34; y=\u0026#34;225.0\u0026#34;\u0026gt;\u0026lt;/omgdc:Bounds\u0026gt; \u0026lt;/bpmndi:BPMNShape\u0026gt; \u0026lt;bpmndi:BPMNEdge bpmnElement=\u0026#34;sid-8BA0B88C-BA4F-446D-B5E7-6BF0830B1DC8\u0026#34; id=\u0026#34;BPMNEdge_sid-8BA0B88C-BA4F-446D-B5E7-6BF0830B1DC8\u0026#34; flowable:sourceDockerX=\u0026#34;50.0\u0026#34; flowable:sourceDockerY=\u0026#34;40.0\u0026#34; flowable:targetDockerX=\u0026#34;50.0\u0026#34; flowable:targetDockerY=\u0026#34;40.0\u0026#34;\u0026gt; \u0026lt;omgdi:waypoint x=\u0026#34;504.95000000000005\u0026#34; y=\u0026#34;70.0\u0026#34;\u0026gt;\u0026lt;/omgdi:waypoint\u0026gt; \u0026lt;omgdi:waypoint x=\u0026#34;680.0\u0026#34; y=\u0026#34;70.0\u0026#34;\u0026gt;\u0026lt;/omgdi:waypoint\u0026gt; \u0026lt;omgdi:waypoint x=\u0026#34;680.0\u0026#34; y=\u0026#34;225.0\u0026#34;\u0026gt;\u0026lt;/omgdi:waypoint\u0026gt; \u0026lt;/bpmndi:BPMNEdge\u0026gt; \u0026lt;bpmndi:BPMNEdge bpmnElement=\u0026#34;sid-4DB25720-11C8-401E-BB4C-83BB25510B2E\u0026#34; id=\u0026#34;BPMNEdge_sid-4DB25720-11C8-401E-BB4C-83BB25510B2E\u0026#34; flowable:sourceDockerX=\u0026#34;20.5\u0026#34; flowable:sourceDockerY=\u0026#34;20.5\u0026#34; flowable:targetDockerX=\u0026#34;50.0\u0026#34; flowable:targetDockerY=\u0026#34;40.0\u0026#34;\u0026gt; \u0026lt;omgdi:waypoint x=\u0026#34;335.5\u0026#34; y=\u0026#34;155.5\u0026#34;\u0026gt;\u0026lt;/omgdi:waypoint\u0026gt; \u0026lt;omgdi:waypoint x=\u0026#34;335.5\u0026#34; y=\u0026#34;70.0\u0026#34;\u0026gt;\u0026lt;/omgdi:waypoint\u0026gt; \u0026lt;omgdi:waypoint x=\u0026#34;404.99999999996083\u0026#34; y=\u0026#34;70.0\u0026#34;\u0026gt;\u0026lt;/omgdi:waypoint\u0026gt; \u0026lt;/bpmndi:BPMNEdge\u0026gt; \u0026lt;bpmndi:BPMNEdge bpmnElement=\u0026#34;sid-33A73370-751D-413F-9306-39DEAA674DB6\u0026#34; id=\u0026#34;BPMNEdge_sid-33A73370-751D-413F-9306-39DEAA674DB6\u0026#34; flowable:sourceDockerX=\u0026#34;15.0\u0026#34; flowable:sourceDockerY=\u0026#34;15.0\u0026#34; flowable:targetDockerX=\u0026#34;50.0\u0026#34; flowable:targetDockerY=\u0026#34;40.0\u0026#34;\u0026gt; \u0026lt;omgdi:waypoint x=\u0026#34;59.94725673598754\u0026#34; y=\u0026#34;177.70973069236373\u0026#34;\u0026gt;\u0026lt;/omgdi:waypoint\u0026gt; \u0026lt;omgdi:waypoint x=\u0026#34;150.0\u0026#34; y=\u0026#34;175.96677419354836\u0026#34;\u0026gt;\u0026lt;/omgdi:waypoint\u0026gt; \u0026lt;/bpmndi:BPMNEdge\u0026gt; \u0026lt;bpmndi:BPMNEdge bpmnElement=\u0026#34;sid-D1B1F6E0-EA7F-4FF7-AD0C-5D43DBCEBFD2\u0026#34; id=\u0026#34;BPMNEdge_sid-D1B1F6E0-EA7F-4FF7-AD0C-5D43DBCEBFD2\u0026#34; flowable:sourceDockerX=\u0026#34;50.0\u0026#34; flowable:sourceDockerY=\u0026#34;40.0\u0026#34; flowable:targetDockerX=\u0026#34;20.5\u0026#34; flowable:targetDockerY=\u0026#34;20.5\u0026#34;\u0026gt; \u0026lt;omgdi:waypoint x=\u0026#34;249.95000000000002\u0026#34; y=\u0026#34;175.18431734317343\u0026#34;\u0026gt;\u0026lt;/omgdi:waypoint\u0026gt; \u0026lt;omgdi:waypoint x=\u0026#34;315.42592592592536\u0026#34; y=\u0026#34;175.42592592592592\u0026#34;\u0026gt;\u0026lt;/omgdi:waypoint\u0026gt; \u0026lt;/bpmndi:BPMNEdge\u0026gt; \u0026lt;bpmndi:BPMNEdge bpmnElement=\u0026#34;sid-E748F81F-B0B2-4C34-B993-FBAA2BCD0995\u0026#34; id=\u0026#34;BPMNEdge_sid-E748F81F-B0B2-4C34-B993-FBAA2BCD0995\u0026#34; flowable:sourceDockerX=\u0026#34;50.0\u0026#34; flowable:sourceDockerY=\u0026#34;40.0\u0026#34; flowable:targetDockerX=\u0026#34;50.0\u0026#34; flowable:targetDockerY=\u0026#34;40.0\u0026#34;\u0026gt; \u0026lt;omgdi:waypoint x=\u0026#34;519.95\u0026#34; y=\u0026#34;265.0\u0026#34;\u0026gt;\u0026lt;/omgdi:waypoint\u0026gt; \u0026lt;omgdi:waypoint x=\u0026#34;629.9999999998776\u0026#34; y=\u0026#34;265.0\u0026#34;\u0026gt;\u0026lt;/omgdi:waypoint\u0026gt; \u0026lt;/bpmndi:BPMNEdge\u0026gt; \u0026lt;bpmndi:BPMNEdge bpmnElement=\u0026#34;sid-928C6C6F-57F1-40F2-BE0F-1A9FF3E6E9E4\u0026#34; id=\u0026#34;BPMNEdge_sid-928C6C6F-57F1-40F2-BE0F-1A9FF3E6E9E4\u0026#34; flowable:sourceDockerX=\u0026#34;20.5\u0026#34; flowable:sourceDockerY=\u0026#34;20.5\u0026#34; flowable:targetDockerX=\u0026#34;50.0\u0026#34; flowable:targetDockerY=\u0026#34;40.0\u0026#34;\u0026gt; \u0026lt;omgdi:waypoint x=\u0026#34;335.5\u0026#34; y=\u0026#34;194.43942522321433\u0026#34;\u0026gt;\u0026lt;/omgdi:waypoint\u0026gt; \u0026lt;omgdi:waypoint x=\u0026#34;335.5\u0026#34; y=\u0026#34;265.0\u0026#34;\u0026gt;\u0026lt;/omgdi:waypoint\u0026gt; \u0026lt;omgdi:waypoint x=\u0026#34;420.0\u0026#34; y=\u0026#34;265.0\u0026#34;\u0026gt;\u0026lt;/omgdi:waypoint\u0026gt; \u0026lt;/bpmndi:BPMNEdge\u0026gt; \u0026lt;/bpmndi:BPMNPlane\u0026gt; \u0026lt;/bpmndi:BPMNDiagram\u0026gt; \u0026lt;/definitions\u0026gt; 部署\n@Test public void deploy(){ ProcessEngine engine= ProcessEngines.getDefaultProcessEngine(); RepositoryService repositoryService = engine.getRepositoryService(); Deployment deploy = repositoryService.createDeployment() .addClasspathResource(\u0026#34;请假流程-排他网关.bpmn20.xml\u0026#34;) .deploy(); System.out.println(\u0026#34;部署成功:\u0026#34;+deploy); } 运行\n@Test public void run() { ProcessEngine engine = ProcessEngines.getDefaultProcessEngine(); RuntimeService runtimeService = engine.getRuntimeService(); Map\u0026lt;String, Object\u0026gt; variables = new HashMap\u0026lt;\u0026gt;(); variables.put(\u0026#34;num\u0026#34;, 2); runtimeService.startProcessInstanceById (\u0026#34;holiday-exclusive:1:4\u0026#34;, variables); } 数据库 张三完成任务\n@Test public void taskComplete(){ ProcessEngine engine=ProcessEngines.getDefaultProcessEngine(); TaskService taskService = engine.getTaskService(); Task task = taskService.createTaskQuery() .taskAssignee(\u0026#34;zhangsan\u0026#34;) .processInstanceId(\u0026#34;2501\u0026#34;) .singleResult(); taskService.complete(task.getId()); } //接下来会走到部门经理审批\n此时再ran一个num为4的实例,然后张三完成,此时会走到总经理审批\n注意,如果这里num设置为3,则会报错 两者区别 如果上面的分支都不满足条件,那么会直接异常结束 //如果使用排他网关,如果条件都不满足,流程和任务都还在,只是代码抛异常 //如果两个都满足,那么会找出先定义的线走\n并行网关 # 绘制流程图 xml文件\n\u0026lt;?xml version=\u0026#34;1.0\u0026#34; encoding=\u0026#34;UTF-8\u0026#34;?\u0026gt; \u0026lt;definitions xmlns=\u0026#34;http://www.omg.org/spec/BPMN/20100524/MODEL\u0026#34; xmlns:xsi=\u0026#34;http://www.w3.org/2001/XMLSchema-instance\u0026#34; xmlns:xsd=\u0026#34;http://www.w3.org/2001/XMLSchema\u0026#34; xmlns:flowable=\u0026#34;http://flowable.org/bpmn\u0026#34; xmlns:bpmndi=\u0026#34;http://www.omg.org/spec/BPMN/20100524/DI\u0026#34; xmlns:omgdc=\u0026#34;http://www.omg.org/spec/DD/20100524/DC\u0026#34; xmlns:omgdi=\u0026#34;http://www.omg.org/spec/DD/20100524/DI\u0026#34; typeLanguage=\u0026#34;http://www.w3.org/2001/XMLSchema\u0026#34; expressionLanguage=\u0026#34;http://www.w3.org/1999/XPath\u0026#34; targetNamespace=\u0026#34;http://www.flowable.org/processdef\u0026#34; exporter=\u0026#34;Flowable Open Source Modeler\u0026#34; exporterVersion=\u0026#34;6.7.2\u0026#34;\u0026gt; \u0026lt;process id=\u0026#34;holiday-parr-key\u0026#34; name=\u0026#34;请假流程-并行网关\u0026#34; isExecutable=\u0026#34;true\u0026#34;\u0026gt; \u0026lt;documentation\u0026gt;holiday-parr-descr\u0026lt;/documentation\u0026gt; \u0026lt;startEvent id=\u0026#34;startEvent1\u0026#34; flowable:formFieldValidation=\u0026#34;true\u0026#34;\u0026gt;\u0026lt;/startEvent\u0026gt; \u0026lt;userTask id=\u0026#34;sid-47EAD72A-932E-4850-9218-08A7335CEEDD\u0026#34; name=\u0026#34;创建请假单\u0026#34; flowable:assignee=\u0026#34;zhangsan\u0026#34; flowable:formFieldValidation=\u0026#34;true\u0026#34;\u0026gt; \u0026lt;extensionElements\u0026gt; \u0026lt;modeler:initiator-can-complete xmlns:modeler=\u0026#34;http://flowable.org/modeler\u0026#34;\u0026gt;\u0026lt;![CDATA[false]]\u0026gt;\u0026lt;/modeler:initiator-can-complete\u0026gt; \u0026lt;/extensionElements\u0026gt; \u0026lt;/userTask\u0026gt; \u0026lt;sequenceFlow id=\u0026#34;sid-8B72154F-6D29-47F8-A81C-A070F82B95F9\u0026#34; sourceRef=\u0026#34;startEvent1\u0026#34; targetRef=\u0026#34;sid-47EAD72A-932E-4850-9218-08A7335CEEDD\u0026#34;\u0026gt;\u0026lt;/sequenceFlow\u0026gt; \u0026lt;parallelGateway id=\u0026#34;sid-8B323A3D-F6DA-4D38-9CAE-D4CDA1031343\u0026#34;\u0026gt;\u0026lt;/parallelGateway\u0026gt; \u0026lt;sequenceFlow id=\u0026#34;sid-5F0BF3BD-BC7C-4AA0-AF87-F679C8EEB40B\u0026#34; sourceRef=\u0026#34;sid-47EAD72A-932E-4850-9218-08A7335CEEDD\u0026#34; targetRef=\u0026#34;sid-8B323A3D-F6DA-4D38-9CAE-D4CDA1031343\u0026#34;\u0026gt;\u0026lt;/sequenceFlow\u0026gt; \u0026lt;userTask id=\u0026#34;sid-AEFBD42F-2A10-4630-8E56-EDBD35CC95B1\u0026#34; name=\u0026#34;技术经理\u0026#34; flowable:assignee=\u0026#34;lisi\u0026#34; flowable:formFieldValidation=\u0026#34;true\u0026#34;\u0026gt; \u0026lt;extensionElements\u0026gt; \u0026lt;modeler:initiator-can-complete xmlns:modeler=\u0026#34;http://flowable.org/modeler\u0026#34;\u0026gt;\u0026lt;![CDATA[false]]\u0026gt;\u0026lt;/modeler:initiator-can-complete\u0026gt; \u0026lt;/extensionElements\u0026gt; \u0026lt;/userTask\u0026gt; \u0026lt;sequenceFlow id=\u0026#34;sid-49DBB929-7488-471A-B79C-6BBFF4C810E0\u0026#34; sourceRef=\u0026#34;sid-8B323A3D-F6DA-4D38-9CAE-D4CDA1031343\u0026#34; targetRef=\u0026#34;sid-AEFBD42F-2A10-4630-8E56-EDBD35CC95B1\u0026#34;\u0026gt;\u0026lt;/sequenceFlow\u0026gt; \u0026lt;userTask id=\u0026#34;sid-8FB84D20-C946-4988-B4C4-16FFD899AF63\u0026#34; name=\u0026#34;项目经理\u0026#34; flowable:assignee=\u0026#34;wangwu\u0026#34; flowable:formFieldValidation=\u0026#34;true\u0026#34;\u0026gt; \u0026lt;extensionElements\u0026gt; \u0026lt;modeler:initiator-can-complete xmlns:modeler=\u0026#34;http://flowable.org/modeler\u0026#34;\u0026gt;\u0026lt;![CDATA[false]]\u0026gt;\u0026lt;/modeler:initiator-can-complete\u0026gt; \u0026lt;/extensionElements\u0026gt; \u0026lt;/userTask\u0026gt; \u0026lt;sequenceFlow id=\u0026#34;sid-DCF940BC-05D4-4260-8C50-A4C6E291DEA3\u0026#34; sourceRef=\u0026#34;sid-8B323A3D-F6DA-4D38-9CAE-D4CDA1031343\u0026#34; targetRef=\u0026#34;sid-8FB84D20-C946-4988-B4C4-16FFD899AF63\u0026#34;\u0026gt;\u0026lt;/sequenceFlow\u0026gt; \u0026lt;parallelGateway id=\u0026#34;sid-B25B9926-873F-46F5-9D62-D155462C1665\u0026#34;\u0026gt;\u0026lt;/parallelGateway\u0026gt; \u0026lt;sequenceFlow id=\u0026#34;sid-18DF81F2-2B7F-4CC7-AD70-8A878FC7B125\u0026#34; sourceRef=\u0026#34;sid-AEFBD42F-2A10-4630-8E56-EDBD35CC95B1\u0026#34; targetRef=\u0026#34;sid-B25B9926-873F-46F5-9D62-D155462C1665\u0026#34;\u0026gt;\u0026lt;/sequenceFlow\u0026gt; \u0026lt;sequenceFlow id=\u0026#34;sid-B00C2DDD-8A30-4BA0-A2F8-69185D8506F5\u0026#34; sourceRef=\u0026#34;sid-8FB84D20-C946-4988-B4C4-16FFD899AF63\u0026#34; targetRef=\u0026#34;sid-B25B9926-873F-46F5-9D62-D155462C1665\u0026#34;\u0026gt;\u0026lt;/sequenceFlow\u0026gt; \u0026lt;userTask id=\u0026#34;sid-143837B7-0687-4268-B381-BA2442E39097\u0026#34; name=\u0026#34;总经理\u0026#34; flowable:assignee=\u0026#34;zjl\u0026#34; flowable:formFieldValidation=\u0026#34;true\u0026#34;\u0026gt; \u0026lt;extensionElements\u0026gt; \u0026lt;modeler:initiator-can-complete xmlns:modeler=\u0026#34;http://flowable.org/modeler\u0026#34;\u0026gt;\u0026lt;![CDATA[false]]\u0026gt;\u0026lt;/modeler:initiator-can-complete\u0026gt; \u0026lt;/extensionElements\u0026gt; \u0026lt;/userTask\u0026gt; \u0026lt;endEvent id=\u0026#34;sid-5ACFE3BE-E094-43A9-85C5-7D438EFE5A97\u0026#34;\u0026gt;\u0026lt;/endEvent\u0026gt; \u0026lt;sequenceFlow id=\u0026#34;sid-4255A9F7-39A1-46D3-AF14-DBEFF17AE911\u0026#34; sourceRef=\u0026#34;sid-143837B7-0687-4268-B381-BA2442E39097\u0026#34; targetRef=\u0026#34;sid-5ACFE3BE-E094-43A9-85C5-7D438EFE5A97\u0026#34;\u0026gt;\u0026lt;/sequenceFlow\u0026gt; \u0026lt;sequenceFlow id=\u0026#34;sid-2F49B59A-6860-4101-8156-84780094E6FE\u0026#34; sourceRef=\u0026#34;sid-B25B9926-873F-46F5-9D62-D155462C1665\u0026#34; targetRef=\u0026#34;sid-5ACFE3BE-E094-43A9-85C5-7D438EFE5A97\u0026#34;\u0026gt; \u0026lt;conditionExpression xsi:type=\u0026#34;tFormalExpression\u0026#34;\u0026gt;\u0026lt;![CDATA[${num \u0026lt;= 3}]]\u0026gt;\u0026lt;/conditionExpression\u0026gt; \u0026lt;/sequenceFlow\u0026gt; \u0026lt;sequenceFlow id=\u0026#34;sid-A5253FCB-3D23-483F-A511-197811F656D6\u0026#34; sourceRef=\u0026#34;sid-B25B9926-873F-46F5-9D62-D155462C1665\u0026#34; targetRef=\u0026#34;sid-143837B7-0687-4268-B381-BA2442E39097\u0026#34;\u0026gt; \u0026lt;conditionExpression xsi:type=\u0026#34;tFormalExpression\u0026#34;\u0026gt;\u0026lt;![CDATA[${num \u0026gt; 3}]]\u0026gt;\u0026lt;/conditionExpression\u0026gt; \u0026lt;/sequenceFlow\u0026gt; \u0026lt;/process\u0026gt; \u0026lt;bpmndi:BPMNDiagram id=\u0026#34;BPMNDiagram_holiday-parr-key\u0026#34;\u0026gt; \u0026lt;bpmndi:BPMNPlane bpmnElement=\u0026#34;holiday-parr-key\u0026#34; id=\u0026#34;BPMNPlane_holiday-parr-key\u0026#34;\u0026gt; \u0026lt;bpmndi:BPMNShape bpmnElement=\u0026#34;startEvent1\u0026#34; id=\u0026#34;BPMNShape_startEvent1\u0026#34;\u0026gt; \u0026lt;omgdc:Bounds height=\u0026#34;30.0\u0026#34; width=\u0026#34;30.0\u0026#34; x=\u0026#34;100.0\u0026#34; y=\u0026#34;163.0\u0026#34;\u0026gt;\u0026lt;/omgdc:Bounds\u0026gt; \u0026lt;/bpmndi:BPMNShape\u0026gt; \u0026lt;bpmndi:BPMNShape bpmnElement=\u0026#34;sid-47EAD72A-932E-4850-9218-08A7335CEEDD\u0026#34; id=\u0026#34;BPMNShape_sid-47EAD72A-932E-4850-9218-08A7335CEEDD\u0026#34;\u0026gt; \u0026lt;omgdc:Bounds height=\u0026#34;80.0\u0026#34; width=\u0026#34;100.0\u0026#34; x=\u0026#34;175.0\u0026#34; y=\u0026#34;138.0\u0026#34;\u0026gt;\u0026lt;/omgdc:Bounds\u0026gt; \u0026lt;/bpmndi:BPMNShape\u0026gt; \u0026lt;bpmndi:BPMNShape bpmnElement=\u0026#34;sid-8B323A3D-F6DA-4D38-9CAE-D4CDA1031343\u0026#34; id=\u0026#34;BPMNShape_sid-8B323A3D-F6DA-4D38-9CAE-D4CDA1031343\u0026#34;\u0026gt; \u0026lt;omgdc:Bounds height=\u0026#34;40.0\u0026#34; width=\u0026#34;40.0\u0026#34; x=\u0026#34;387.0\u0026#34; y=\u0026#34;143.0\u0026#34;\u0026gt;\u0026lt;/omgdc:Bounds\u0026gt; \u0026lt;/bpmndi:BPMNShape\u0026gt; \u0026lt;bpmndi:BPMNShape bpmnElement=\u0026#34;sid-AEFBD42F-2A10-4630-8E56-EDBD35CC95B1\u0026#34; id=\u0026#34;BPMNShape_sid-AEFBD42F-2A10-4630-8E56-EDBD35CC95B1\u0026#34;\u0026gt; \u0026lt;omgdc:Bounds height=\u0026#34;80.0\u0026#34; width=\u0026#34;100.0\u0026#34; x=\u0026#34;495.0\u0026#34; y=\u0026#34;45.0\u0026#34;\u0026gt;\u0026lt;/omgdc:Bounds\u0026gt; \u0026lt;/bpmndi:BPMNShape\u0026gt; \u0026lt;bpmndi:BPMNShape bpmnElement=\u0026#34;sid-8FB84D20-C946-4988-B4C4-16FFD899AF63\u0026#34; id=\u0026#34;BPMNShape_sid-8FB84D20-C946-4988-B4C4-16FFD899AF63\u0026#34;\u0026gt; \u0026lt;omgdc:Bounds height=\u0026#34;80.0\u0026#34; width=\u0026#34;100.0\u0026#34; x=\u0026#34;495.0\u0026#34; y=\u0026#34;225.0\u0026#34;\u0026gt;\u0026lt;/omgdc:Bounds\u0026gt; \u0026lt;/bpmndi:BPMNShape\u0026gt; \u0026lt;bpmndi:BPMNShape bpmnElement=\u0026#34;sid-B25B9926-873F-46F5-9D62-D155462C1665\u0026#34; id=\u0026#34;BPMNShape_sid-B25B9926-873F-46F5-9D62-D155462C1665\u0026#34;\u0026gt; \u0026lt;omgdc:Bounds height=\u0026#34;40.0\u0026#34; width=\u0026#34;40.0\u0026#34; x=\u0026#34;695.0\u0026#34; y=\u0026#34;143.0\u0026#34;\u0026gt;\u0026lt;/omgdc:Bounds\u0026gt; \u0026lt;/bpmndi:BPMNShape\u0026gt; \u0026lt;bpmndi:BPMNShape bpmnElement=\u0026#34;sid-143837B7-0687-4268-B381-BA2442E39097\u0026#34; id=\u0026#34;BPMNShape_sid-143837B7-0687-4268-B381-BA2442E39097\u0026#34;\u0026gt; \u0026lt;omgdc:Bounds height=\u0026#34;80.0\u0026#34; width=\u0026#34;100.0\u0026#34; x=\u0026#34;795.0\u0026#34; y=\u0026#34;60.0\u0026#34;\u0026gt;\u0026lt;/omgdc:Bounds\u0026gt; \u0026lt;/bpmndi:BPMNShape\u0026gt; \u0026lt;bpmndi:BPMNShape bpmnElement=\u0026#34;sid-5ACFE3BE-E094-43A9-85C5-7D438EFE5A97\u0026#34; id=\u0026#34;BPMNShape_sid-5ACFE3BE-E094-43A9-85C5-7D438EFE5A97\u0026#34;\u0026gt; \u0026lt;omgdc:Bounds height=\u0026#34;28.0\u0026#34; width=\u0026#34;28.0\u0026#34; x=\u0026#34;840.0\u0026#34; y=\u0026#34;225.0\u0026#34;\u0026gt;\u0026lt;/omgdc:Bounds\u0026gt; \u0026lt;/bpmndi:BPMNShape\u0026gt; \u0026lt;bpmndi:BPMNEdge bpmnElement=\u0026#34;sid-4255A9F7-39A1-46D3-AF14-DBEFF17AE911\u0026#34; id=\u0026#34;BPMNEdge_sid-4255A9F7-39A1-46D3-AF14-DBEFF17AE911\u0026#34; flowable:sourceDockerX=\u0026#34;50.0\u0026#34; flowable:sourceDockerY=\u0026#34;40.0\u0026#34; flowable:targetDockerX=\u0026#34;14.0\u0026#34; flowable:targetDockerY=\u0026#34;14.0\u0026#34;\u0026gt; \u0026lt;omgdi:waypoint x=\u0026#34;847.586690647482\u0026#34; y=\u0026#34;139.95\u0026#34;\u0026gt;\u0026lt;/omgdi:waypoint\u0026gt; \u0026lt;omgdi:waypoint x=\u0026#34;853.095383523332\u0026#34; y=\u0026#34;225.02614923910227\u0026#34;\u0026gt;\u0026lt;/omgdi:waypoint\u0026gt; \u0026lt;/bpmndi:BPMNEdge\u0026gt; \u0026lt;bpmndi:BPMNEdge bpmnElement=\u0026#34;sid-8B72154F-6D29-47F8-A81C-A070F82B95F9\u0026#34; id=\u0026#34;BPMNEdge_sid-8B72154F-6D29-47F8-A81C-A070F82B95F9\u0026#34; flowable:sourceDockerX=\u0026#34;15.0\u0026#34; flowable:sourceDockerY=\u0026#34;15.0\u0026#34; flowable:targetDockerX=\u0026#34;50.0\u0026#34; flowable:targetDockerY=\u0026#34;40.0\u0026#34;\u0026gt; \u0026lt;omgdi:waypoint x=\u0026#34;129.9499984899576\u0026#34; y=\u0026#34;178.0\u0026#34;\u0026gt;\u0026lt;/omgdi:waypoint\u0026gt; \u0026lt;omgdi:waypoint x=\u0026#34;174.9999999999917\u0026#34; y=\u0026#34;178.0\u0026#34;\u0026gt;\u0026lt;/omgdi:waypoint\u0026gt; \u0026lt;/bpmndi:BPMNEdge\u0026gt; \u0026lt;bpmndi:BPMNEdge bpmnElement=\u0026#34;sid-49DBB929-7488-471A-B79C-6BBFF4C810E0\u0026#34; id=\u0026#34;BPMNEdge_sid-49DBB929-7488-471A-B79C-6BBFF4C810E0\u0026#34; flowable:sourceDockerX=\u0026#34;20.5\u0026#34; flowable:sourceDockerY=\u0026#34;20.5\u0026#34; flowable:targetDockerX=\u0026#34;50.0\u0026#34; flowable:targetDockerY=\u0026#34;40.0\u0026#34;\u0026gt; \u0026lt;omgdi:waypoint x=\u0026#34;404.70744680851067\u0026#34; y=\u0026#34;145.2843450479233\u0026#34;\u0026gt;\u0026lt;/omgdi:waypoint\u0026gt; \u0026lt;omgdi:waypoint x=\u0026#34;395.0\u0026#34; y=\u0026#34;82.0\u0026#34;\u0026gt;\u0026lt;/omgdi:waypoint\u0026gt; \u0026lt;omgdi:waypoint x=\u0026#34;494.9999999999998\u0026#34; y=\u0026#34;84.0\u0026#34;\u0026gt;\u0026lt;/omgdi:waypoint\u0026gt; \u0026lt;/bpmndi:BPMNEdge\u0026gt; \u0026lt;bpmndi:BPMNEdge bpmnElement=\u0026#34;sid-2F49B59A-6860-4101-8156-84780094E6FE\u0026#34; id=\u0026#34;BPMNEdge_sid-2F49B59A-6860-4101-8156-84780094E6FE\u0026#34; flowable:sourceDockerX=\u0026#34;20.5\u0026#34; flowable:sourceDockerY=\u0026#34;20.5\u0026#34; flowable:targetDockerX=\u0026#34;14.0\u0026#34; flowable:targetDockerY=\u0026#34;14.0\u0026#34;\u0026gt; \u0026lt;omgdi:waypoint x=\u0026#34;715.5\u0026#34; y=\u0026#34;182.43746693121696\u0026#34;\u0026gt;\u0026lt;/omgdi:waypoint\u0026gt; \u0026lt;omgdi:waypoint x=\u0026#34;715.5\u0026#34; y=\u0026#34;239.0\u0026#34;\u0026gt;\u0026lt;/omgdi:waypoint\u0026gt; \u0026lt;omgdi:waypoint x=\u0026#34;840.0\u0026#34; y=\u0026#34;239.0\u0026#34;\u0026gt;\u0026lt;/omgdi:waypoint\u0026gt; \u0026lt;/bpmndi:BPMNEdge\u0026gt; \u0026lt;bpmndi:BPMNEdge bpmnElement=\u0026#34;sid-DCF940BC-05D4-4260-8C50-A4C6E291DEA3\u0026#34; id=\u0026#34;BPMNEdge_sid-DCF940BC-05D4-4260-8C50-A4C6E291DEA3\u0026#34; flowable:sourceDockerX=\u0026#34;20.5\u0026#34; flowable:sourceDockerY=\u0026#34;20.5\u0026#34; flowable:targetDockerX=\u0026#34;50.0\u0026#34; flowable:targetDockerY=\u0026#34;40.0\u0026#34;\u0026gt; \u0026lt;omgdi:waypoint x=\u0026#34;407.5\u0026#34; y=\u0026#34;182.44067421259845\u0026#34;\u0026gt;\u0026lt;/omgdi:waypoint\u0026gt; \u0026lt;omgdi:waypoint x=\u0026#34;407.5\u0026#34; y=\u0026#34;265.0\u0026#34;\u0026gt;\u0026lt;/omgdi:waypoint\u0026gt; \u0026lt;omgdi:waypoint x=\u0026#34;494.9999999999674\u0026#34; y=\u0026#34;265.0\u0026#34;\u0026gt;\u0026lt;/omgdi:waypoint\u0026gt; \u0026lt;/bpmndi:BPMNEdge\u0026gt; \u0026lt;bpmndi:BPMNEdge bpmnElement=\u0026#34;sid-A5253FCB-3D23-483F-A511-197811F656D6\u0026#34; id=\u0026#34;BPMNEdge_sid-A5253FCB-3D23-483F-A511-197811F656D6\u0026#34; flowable:sourceDockerX=\u0026#34;20.5\u0026#34; flowable:sourceDockerY=\u0026#34;20.5\u0026#34; flowable:targetDockerX=\u0026#34;50.0\u0026#34; flowable:targetDockerY=\u0026#34;40.0\u0026#34;\u0026gt; \u0026lt;omgdi:waypoint x=\u0026#34;715.5\u0026#34; y=\u0026#34;143.5\u0026#34;\u0026gt;\u0026lt;/omgdi:waypoint\u0026gt; \u0026lt;omgdi:waypoint x=\u0026#34;715.5\u0026#34; y=\u0026#34;90.0\u0026#34;\u0026gt;\u0026lt;/omgdi:waypoint\u0026gt; \u0026lt;omgdi:waypoint x=\u0026#34;795.0\u0026#34; y=\u0026#34;96.13899613899613\u0026#34;\u0026gt;\u0026lt;/omgdi:waypoint\u0026gt; \u0026lt;/bpmndi:BPMNEdge\u0026gt; \u0026lt;bpmndi:BPMNEdge bpmnElement=\u0026#34;sid-5F0BF3BD-BC7C-4AA0-AF87-F679C8EEB40B\u0026#34; id=\u0026#34;BPMNEdge_sid-5F0BF3BD-BC7C-4AA0-AF87-F679C8EEB40B\u0026#34; flowable:sourceDockerX=\u0026#34;50.0\u0026#34; flowable:sourceDockerY=\u0026#34;40.0\u0026#34; flowable:targetDockerX=\u0026#34;20.0\u0026#34; flowable:targetDockerY=\u0026#34;20.0\u0026#34;\u0026gt; \u0026lt;omgdi:waypoint x=\u0026#34;274.9499999999998\u0026#34; y=\u0026#34;173.87912087912088\u0026#34;\u0026gt;\u0026lt;/omgdi:waypoint\u0026gt; \u0026lt;omgdi:waypoint x=\u0026#34;388.52284263959393\u0026#34; y=\u0026#34;164.5190355329949\u0026#34;\u0026gt;\u0026lt;/omgdi:waypoint\u0026gt; \u0026lt;/bpmndi:BPMNEdge\u0026gt; \u0026lt;bpmndi:BPMNEdge bpmnElement=\u0026#34;sid-18DF81F2-2B7F-4CC7-AD70-8A878FC7B125\u0026#34; id=\u0026#34;BPMNEdge_sid-18DF81F2-2B7F-4CC7-AD70-8A878FC7B125\u0026#34; flowable:sourceDockerX=\u0026#34;50.0\u0026#34; flowable:sourceDockerY=\u0026#34;40.0\u0026#34; flowable:targetDockerX=\u0026#34;20.0\u0026#34; flowable:targetDockerY=\u0026#34;20.0\u0026#34;\u0026gt; \u0026lt;omgdi:waypoint x=\u0026#34;594.95\u0026#34; y=\u0026#34;107.91823529411766\u0026#34;\u0026gt;\u0026lt;/omgdi:waypoint\u0026gt; \u0026lt;omgdi:waypoint x=\u0026#34;701.273276904474\u0026#34; y=\u0026#34;156.70967741935485\u0026#34;\u0026gt;\u0026lt;/omgdi:waypoint\u0026gt; \u0026lt;/bpmndi:BPMNEdge\u0026gt; \u0026lt;bpmndi:BPMNEdge bpmnElement=\u0026#34;sid-B00C2DDD-8A30-4BA0-A2F8-69185D8506F5\u0026#34; id=\u0026#34;BPMNEdge_sid-B00C2DDD-8A30-4BA0-A2F8-69185D8506F5\u0026#34; flowable:sourceDockerX=\u0026#34;50.0\u0026#34; flowable:sourceDockerY=\u0026#34;40.0\u0026#34; flowable:targetDockerX=\u0026#34;20.5\u0026#34; flowable:targetDockerY=\u0026#34;20.5\u0026#34;\u0026gt; \u0026lt;omgdi:waypoint x=\u0026#34;594.95\u0026#34; y=\u0026#34;235.23460410557183\u0026#34;\u0026gt;\u0026lt;/omgdi:waypoint\u0026gt; \u0026lt;omgdi:waypoint x=\u0026#34;702.9632352941177\u0026#34; y=\u0026#34;170.94457720588235\u0026#34;\u0026gt;\u0026lt;/omgdi:waypoint\u0026gt; \u0026lt;/bpmndi:BPMNEdge\u0026gt; \u0026lt;/bpmndi:BPMNPlane\u0026gt; \u0026lt;/bpmndi:BPMNDiagram\u0026gt; \u0026lt;/definitions\u0026gt; 并行网关的条件会被忽略 代码测试\n//部署并运行 @Test public void deploy() { ProcessEngine engine = ProcessEngines.getDefaultProcessEngine(); RepositoryService repositoryService = engine.getRepositoryService(); Deployment deploy = repositoryService.createDeployment() .addClasspathResource(\u0026#34;请假流程-并行网关.bpmn20.xml\u0026#34;) .deploy(); System.out.println(\u0026#34;部署成功:\u0026#34; + deploy.getId()); } @Test public void run() { ProcessEngine engine = ProcessEngines.getDefaultProcessEngine(); RuntimeService runtimeService = engine.getRuntimeService(); Map\u0026lt;String, Object\u0026gt; variables = new HashMap\u0026lt;\u0026gt;(); variables.put(\u0026#34;num\u0026#34;, 4); runtimeService.startProcessInstanceById (\u0026#34;holiday-parr-key:1:12504\u0026#34;, variables); } 此时任务停留在zhangsan 让zhangsan完成任务\n@Test public void taskComplete(){ ProcessEngine engine=ProcessEngines.getDefaultProcessEngine(); TaskService taskService = engine.getTaskService(); Task task = taskService.createTaskQuery() .taskAssignee(\u0026#34;zhangsan\u0026#34;) .processInstanceId(\u0026#34;15001\u0026#34;) .singleResult(); taskService.complete(task.getId()); } 查看表数据(一个任务包含多个执行实例) 让王五和李四进行审批 查看数据库,wangwu审批后,act_ru_task就少了一条记录 此时走到总经理节点 图解 包容网关 # 包容网关可以选择多于一条顺序流。即固定几条必走,其他几条走条件\n流程图 xml\n\u0026lt;?xml version=\u0026#34;1.0\u0026#34; encoding=\u0026#34;UTF-8\u0026#34;?\u0026gt; \u0026lt;definitions xmlns=\u0026#34;http://www.omg.org/spec/BPMN/20100524/MODEL\u0026#34; xmlns:xsi=\u0026#34;http://www.w3.org/2001/XMLSchema-instance\u0026#34; xmlns:xsd=\u0026#34;http://www.w3.org/2001/XMLSchema\u0026#34; xmlns:flowable=\u0026#34;http://flowable.org/bpmn\u0026#34; xmlns:bpmndi=\u0026#34;http://www.omg.org/spec/BPMN/20100524/DI\u0026#34; xmlns:omgdc=\u0026#34;http://www.omg.org/spec/DD/20100524/DC\u0026#34; xmlns:omgdi=\u0026#34;http://www.omg.org/spec/DD/20100524/DI\u0026#34; typeLanguage=\u0026#34;http://www.w3.org/2001/XMLSchema\u0026#34; expressionLanguage=\u0026#34;http://www.w3.org/1999/XPath\u0026#34; targetNamespace=\u0026#34;http://www.flowable.org/processdef\u0026#34; exporter=\u0026#34;Flowable Open Source Modeler\u0026#34; exporterVersion=\u0026#34;6.7.2\u0026#34;\u0026gt; \u0026lt;process id=\u0026#34;holiday-inclusive\u0026#34; name=\u0026#34;holiday-inclusive-name\u0026#34; isExecutable=\u0026#34;true\u0026#34;\u0026gt; \u0026lt;documentation\u0026gt;holiday-inclusive-desc\u0026lt;/documentation\u0026gt; \u0026lt;startEvent id=\u0026#34;startEvent1\u0026#34; flowable:formFieldValidation=\u0026#34;true\u0026#34;\u0026gt;\u0026lt;/startEvent\u0026gt; \u0026lt;userTask id=\u0026#34;sid-6C2C29AA-C1D2-4B09-A542-ED194A13F5F2\u0026#34; name=\u0026#34;创建请假单\u0026#34; flowable:assignee=\u0026#34;i0\u0026#34; flowable:formFieldValidation=\u0026#34;true\u0026#34;\u0026gt; \u0026lt;extensionElements\u0026gt; \u0026lt;modeler:initiator-can-complete xmlns:modeler=\u0026#34;http://flowable.org/modeler\u0026#34;\u0026gt;\u0026lt;![CDATA[false]]\u0026gt;\u0026lt;/modeler:initiator-can-complete\u0026gt; \u0026lt;/extensionElements\u0026gt; \u0026lt;/userTask\u0026gt; \u0026lt;sequenceFlow id=\u0026#34;sid-CAD92170-984F-49E0-BB6D-589B11F7FB8B\u0026#34; sourceRef=\u0026#34;startEvent1\u0026#34; targetRef=\u0026#34;sid-6C2C29AA-C1D2-4B09-A542-ED194A13F5F2\u0026#34;\u0026gt;\u0026lt;/sequenceFlow\u0026gt; \u0026lt;inclusiveGateway id=\u0026#34;sid-46FAF12A-7430-4AFA-AABB-99B2D875C9CD\u0026#34;\u0026gt;\u0026lt;/inclusiveGateway\u0026gt; \u0026lt;sequenceFlow id=\u0026#34;sid-CCD38C3B-C06F-4646-B979-F65C0CA26321\u0026#34; sourceRef=\u0026#34;sid-6C2C29AA-C1D2-4B09-A542-ED194A13F5F2\u0026#34; targetRef=\u0026#34;sid-46FAF12A-7430-4AFA-AABB-99B2D875C9CD\u0026#34;\u0026gt;\u0026lt;/sequenceFlow\u0026gt; \u0026lt;userTask id=\u0026#34;sid-9AD9C288-F114-4AC6-9366-A09A786B068E\u0026#34; name=\u0026#34;项目经理\u0026#34; flowable:assignee=\u0026#34;i1\u0026#34; flowable:formFieldValidation=\u0026#34;true\u0026#34;\u0026gt; \u0026lt;extensionElements\u0026gt; \u0026lt;modeler:initiator-can-complete xmlns:modeler=\u0026#34;http://flowable.org/modeler\u0026#34;\u0026gt;\u0026lt;![CDATA[false]]\u0026gt;\u0026lt;/modeler:initiator-can-complete\u0026gt; \u0026lt;/extensionElements\u0026gt; \u0026lt;/userTask\u0026gt; \u0026lt;userTask id=\u0026#34;sid-764DC717-439D-425E-83FF-D81BD08A2562\u0026#34; name=\u0026#34;人事\u0026#34; flowable:assignee=\u0026#34;i2\u0026#34; flowable:formFieldValidation=\u0026#34;true\u0026#34;\u0026gt; \u0026lt;extensionElements\u0026gt; \u0026lt;modeler:initiator-can-complete xmlns:modeler=\u0026#34;http://flowable.org/modeler\u0026#34;\u0026gt;\u0026lt;![CDATA[false]]\u0026gt;\u0026lt;/modeler:initiator-can-complete\u0026gt; \u0026lt;/extensionElements\u0026gt; \u0026lt;/userTask\u0026gt; \u0026lt;sequenceFlow id=\u0026#34;sid-B8DE143C-4636-4F2C-99C9-8949E23B0042\u0026#34; sourceRef=\u0026#34;sid-46FAF12A-7430-4AFA-AABB-99B2D875C9CD\u0026#34; targetRef=\u0026#34;sid-764DC717-439D-425E-83FF-D81BD08A2562\u0026#34;\u0026gt;\u0026lt;/sequenceFlow\u0026gt; \u0026lt;userTask id=\u0026#34;sid-AC8D2717-5BCD-4C5B-81BB-2FF66CFFC615\u0026#34; name=\u0026#34;技术经理\u0026#34; flowable:assignee=\u0026#34;i3\u0026#34; flowable:formFieldValidation=\u0026#34;true\u0026#34;\u0026gt; \u0026lt;extensionElements\u0026gt; \u0026lt;modeler:initiator-can-complete xmlns:modeler=\u0026#34;http://flowable.org/modeler\u0026#34;\u0026gt;\u0026lt;![CDATA[false]]\u0026gt;\u0026lt;/modeler:initiator-can-complete\u0026gt; \u0026lt;/extensionElements\u0026gt; \u0026lt;/userTask\u0026gt; \u0026lt;inclusiveGateway id=\u0026#34;sid-6449A9C8-B7A3-44EE-BEDF-154AF323B1A8\u0026#34;\u0026gt;\u0026lt;/inclusiveGateway\u0026gt; \u0026lt;sequenceFlow id=\u0026#34;sid-A52331B4-3769-46D8-AAC1-C34214C729BD\u0026#34; sourceRef=\u0026#34;sid-9AD9C288-F114-4AC6-9366-A09A786B068E\u0026#34; targetRef=\u0026#34;sid-6449A9C8-B7A3-44EE-BEDF-154AF323B1A8\u0026#34;\u0026gt;\u0026lt;/sequenceFlow\u0026gt; \u0026lt;sequenceFlow id=\u0026#34;sid-681E9C5D-AD4B-45DD-BF12-E2CD5304ADFB\u0026#34; sourceRef=\u0026#34;sid-764DC717-439D-425E-83FF-D81BD08A2562\u0026#34; targetRef=\u0026#34;sid-6449A9C8-B7A3-44EE-BEDF-154AF323B1A8\u0026#34;\u0026gt;\u0026lt;/sequenceFlow\u0026gt; \u0026lt;sequenceFlow id=\u0026#34;sid-78E79754-E64A-4ADE-A9BB-F9B224D3A5A0\u0026#34; sourceRef=\u0026#34;sid-AC8D2717-5BCD-4C5B-81BB-2FF66CFFC615\u0026#34; targetRef=\u0026#34;sid-6449A9C8-B7A3-44EE-BEDF-154AF323B1A8\u0026#34;\u0026gt;\u0026lt;/sequenceFlow\u0026gt; \u0026lt;exclusiveGateway id=\u0026#34;sid-65D4D76B-AD2B-4AE9-8E78-7B8C33BD9E55\u0026#34;\u0026gt;\u0026lt;/exclusiveGateway\u0026gt; \u0026lt;sequenceFlow id=\u0026#34;sid-422FC4A8-B667-4271-9CB3-A1D2CFEFC5E1\u0026#34; sourceRef=\u0026#34;sid-6449A9C8-B7A3-44EE-BEDF-154AF323B1A8\u0026#34; targetRef=\u0026#34;sid-65D4D76B-AD2B-4AE9-8E78-7B8C33BD9E55\u0026#34;\u0026gt;\u0026lt;/sequenceFlow\u0026gt; \u0026lt;userTask id=\u0026#34;sid-4B834200-7995-453B-BC08-AF93C9F29FCF\u0026#34; name=\u0026#34;总经理\u0026#34; flowable:assignee=\u0026#34;wz\u0026#34; flowable:formFieldValidation=\u0026#34;true\u0026#34;\u0026gt; \u0026lt;extensionElements\u0026gt; \u0026lt;modeler:initiator-can-complete xmlns:modeler=\u0026#34;http://flowable.org/modeler\u0026#34;\u0026gt;\u0026lt;![CDATA[false]]\u0026gt;\u0026lt;/modeler:initiator-can-complete\u0026gt; \u0026lt;/extensionElements\u0026gt; \u0026lt;/userTask\u0026gt; \u0026lt;endEvent id=\u0026#34;sid-7296D067-FF72-49F9-B416-2452640A0FBC\u0026#34;\u0026gt;\u0026lt;/endEvent\u0026gt; \u0026lt;sequenceFlow id=\u0026#34;sid-AD0571E9-839D-4F1F-89ED-05BE60F841FD\u0026#34; sourceRef=\u0026#34;sid-4B834200-7995-453B-BC08-AF93C9F29FCF\u0026#34; targetRef=\u0026#34;sid-7296D067-FF72-49F9-B416-2452640A0FBC\u0026#34;\u0026gt;\u0026lt;/sequenceFlow\u0026gt; \u0026lt;sequenceFlow id=\u0026#34;sid-E808AF78-E258-4997-B4FE-C393D8EBA3B9\u0026#34; sourceRef=\u0026#34;sid-46FAF12A-7430-4AFA-AABB-99B2D875C9CD\u0026#34; targetRef=\u0026#34;sid-9AD9C288-F114-4AC6-9366-A09A786B068E\u0026#34;\u0026gt; \u0026lt;conditionExpression xsi:type=\u0026#34;tFormalExpression\u0026#34;\u0026gt;\u0026lt;![CDATA[${num\u0026gt;3}]]\u0026gt;\u0026lt;/conditionExpression\u0026gt; \u0026lt;/sequenceFlow\u0026gt; \u0026lt;sequenceFlow id=\u0026#34;sid-E4AD02E7-A69A-4684-9A00-DE9B11711348\u0026#34; sourceRef=\u0026#34;sid-46FAF12A-7430-4AFA-AABB-99B2D875C9CD\u0026#34; targetRef=\u0026#34;sid-AC8D2717-5BCD-4C5B-81BB-2FF66CFFC615\u0026#34;\u0026gt; \u0026lt;conditionExpression xsi:type=\u0026#34;tFormalExpression\u0026#34;\u0026gt;\u0026lt;![CDATA[${num \u0026lt;= 3}]]\u0026gt;\u0026lt;/conditionExpression\u0026gt; \u0026lt;/sequenceFlow\u0026gt; \u0026lt;sequenceFlow id=\u0026#34;sid-A6760B6A-B74F-4D35-93C2-6653751F8873\u0026#34; sourceRef=\u0026#34;sid-65D4D76B-AD2B-4AE9-8E78-7B8C33BD9E55\u0026#34; targetRef=\u0026#34;sid-4B834200-7995-453B-BC08-AF93C9F29FCF\u0026#34;\u0026gt; \u0026lt;conditionExpression xsi:type=\u0026#34;tFormalExpression\u0026#34;\u0026gt;\u0026lt;![CDATA[${num \u0026gt; 3}]]\u0026gt;\u0026lt;/conditionExpression\u0026gt; \u0026lt;/sequenceFlow\u0026gt; \u0026lt;sequenceFlow id=\u0026#34;sid-97A0DAB9-564D-4A62-92A4-26C7056CD347\u0026#34; sourceRef=\u0026#34;sid-65D4D76B-AD2B-4AE9-8E78-7B8C33BD9E55\u0026#34; targetRef=\u0026#34;sid-7296D067-FF72-49F9-B416-2452640A0FBC\u0026#34;\u0026gt; \u0026lt;conditionExpression xsi:type=\u0026#34;tFormalExpression\u0026#34;\u0026gt;\u0026lt;![CDATA[${num\u0026lt;=3 }]]\u0026gt;\u0026lt;/conditionExpression\u0026gt; \u0026lt;/sequenceFlow\u0026gt; \u0026lt;/process\u0026gt; \u0026lt;bpmndi:BPMNDiagram id=\u0026#34;BPMNDiagram_holiday-inclusive\u0026#34;\u0026gt; \u0026lt;bpmndi:BPMNPlane bpmnElement=\u0026#34;holiday-inclusive\u0026#34; id=\u0026#34;BPMNPlane_holiday-inclusive\u0026#34;\u0026gt; \u0026lt;bpmndi:BPMNShape bpmnElement=\u0026#34;startEvent1\u0026#34; id=\u0026#34;BPMNShape_startEvent1\u0026#34;\u0026gt; \u0026lt;omgdc:Bounds height=\u0026#34;30.0\u0026#34; width=\u0026#34;30.0\u0026#34; x=\u0026#34;100.0\u0026#34; y=\u0026#34;163.0\u0026#34;\u0026gt;\u0026lt;/omgdc:Bounds\u0026gt; \u0026lt;/bpmndi:BPMNShape\u0026gt; \u0026lt;bpmndi:BPMNShape bpmnElement=\u0026#34;sid-6C2C29AA-C1D2-4B09-A542-ED194A13F5F2\u0026#34; id=\u0026#34;BPMNShape_sid-6C2C29AA-C1D2-4B09-A542-ED194A13F5F2\u0026#34;\u0026gt; \u0026lt;omgdc:Bounds height=\u0026#34;80.0\u0026#34; width=\u0026#34;100.0\u0026#34; x=\u0026#34;195.0\u0026#34; y=\u0026#34;135.0\u0026#34;\u0026gt;\u0026lt;/omgdc:Bounds\u0026gt; \u0026lt;/bpmndi:BPMNShape\u0026gt; \u0026lt;bpmndi:BPMNShape bpmnElement=\u0026#34;sid-46FAF12A-7430-4AFA-AABB-99B2D875C9CD\u0026#34; id=\u0026#34;BPMNShape_sid-46FAF12A-7430-4AFA-AABB-99B2D875C9CD\u0026#34;\u0026gt; \u0026lt;omgdc:Bounds height=\u0026#34;40.0\u0026#34; width=\u0026#34;40.0\u0026#34; x=\u0026#34;366.0\u0026#34; y=\u0026#34;145.0\u0026#34;\u0026gt;\u0026lt;/omgdc:Bounds\u0026gt; \u0026lt;/bpmndi:BPMNShape\u0026gt; \u0026lt;bpmndi:BPMNShape bpmnElement=\u0026#34;sid-9AD9C288-F114-4AC6-9366-A09A786B068E\u0026#34; id=\u0026#34;BPMNShape_sid-9AD9C288-F114-4AC6-9366-A09A786B068E\u0026#34;\u0026gt; \u0026lt;omgdc:Bounds height=\u0026#34;80.0\u0026#34; width=\u0026#34;100.0\u0026#34; x=\u0026#34;451.0\u0026#34; y=\u0026#34;30.0\u0026#34;\u0026gt;\u0026lt;/omgdc:Bounds\u0026gt; \u0026lt;/bpmndi:BPMNShape\u0026gt; \u0026lt;bpmndi:BPMNShape bpmnElement=\u0026#34;sid-764DC717-439D-425E-83FF-D81BD08A2562\u0026#34; id=\u0026#34;BPMNShape_sid-764DC717-439D-425E-83FF-D81BD08A2562\u0026#34;\u0026gt; \u0026lt;omgdc:Bounds height=\u0026#34;80.0\u0026#34; width=\u0026#34;100.0\u0026#34; x=\u0026#34;450.0\u0026#34; y=\u0026#34;120.0\u0026#34;\u0026gt;\u0026lt;/omgdc:Bounds\u0026gt; \u0026lt;/bpmndi:BPMNShape\u0026gt; \u0026lt;bpmndi:BPMNShape bpmnElement=\u0026#34;sid-AC8D2717-5BCD-4C5B-81BB-2FF66CFFC615\u0026#34; id=\u0026#34;BPMNShape_sid-AC8D2717-5BCD-4C5B-81BB-2FF66CFFC615\u0026#34;\u0026gt; \u0026lt;omgdc:Bounds height=\u0026#34;80.0\u0026#34; width=\u0026#34;100.0\u0026#34; x=\u0026#34;465.0\u0026#34; y=\u0026#34;255.0\u0026#34;\u0026gt;\u0026lt;/omgdc:Bounds\u0026gt; \u0026lt;/bpmndi:BPMNShape\u0026gt; \u0026lt;bpmndi:BPMNShape bpmnElement=\u0026#34;sid-6449A9C8-B7A3-44EE-BEDF-154AF323B1A8\u0026#34; id=\u0026#34;BPMNShape_sid-6449A9C8-B7A3-44EE-BEDF-154AF323B1A8\u0026#34;\u0026gt; \u0026lt;omgdc:Bounds height=\u0026#34;40.0\u0026#34; width=\u0026#34;40.0\u0026#34; x=\u0026#34;656.0\u0026#34; y=\u0026#34;137.0\u0026#34;\u0026gt;\u0026lt;/omgdc:Bounds\u0026gt; \u0026lt;/bpmndi:BPMNShape\u0026gt; \u0026lt;bpmndi:BPMNShape bpmnElement=\u0026#34;sid-65D4D76B-AD2B-4AE9-8E78-7B8C33BD9E55\u0026#34; id=\u0026#34;BPMNShape_sid-65D4D76B-AD2B-4AE9-8E78-7B8C33BD9E55\u0026#34;\u0026gt; \u0026lt;omgdc:Bounds height=\u0026#34;40.0\u0026#34; width=\u0026#34;40.0\u0026#34; x=\u0026#34;750.0\u0026#34; y=\u0026#34;137.0\u0026#34;\u0026gt;\u0026lt;/omgdc:Bounds\u0026gt; \u0026lt;/bpmndi:BPMNShape\u0026gt; \u0026lt;bpmndi:BPMNShape bpmnElement=\u0026#34;sid-4B834200-7995-453B-BC08-AF93C9F29FCF\u0026#34; id=\u0026#34;BPMNShape_sid-4B834200-7995-453B-BC08-AF93C9F29FCF\u0026#34;\u0026gt; \u0026lt;omgdc:Bounds height=\u0026#34;80.0\u0026#34; width=\u0026#34;100.0\u0026#34; x=\u0026#34;855.0\u0026#34; y=\u0026#34;60.0\u0026#34;\u0026gt;\u0026lt;/omgdc:Bounds\u0026gt; \u0026lt;/bpmndi:BPMNShape\u0026gt; \u0026lt;bpmndi:BPMNShape bpmnElement=\u0026#34;sid-7296D067-FF72-49F9-B416-2452640A0FBC\u0026#34; id=\u0026#34;BPMNShape_sid-7296D067-FF72-49F9-B416-2452640A0FBC\u0026#34;\u0026gt; \u0026lt;omgdc:Bounds height=\u0026#34;28.0\u0026#34; width=\u0026#34;28.0\u0026#34; x=\u0026#34;900.0\u0026#34; y=\u0026#34;240.0\u0026#34;\u0026gt;\u0026lt;/omgdc:Bounds\u0026gt; \u0026lt;/bpmndi:BPMNShape\u0026gt; \u0026lt;bpmndi:BPMNEdge bpmnElement=\u0026#34;sid-681E9C5D-AD4B-45DD-BF12-E2CD5304ADFB\u0026#34; id=\u0026#34;BPMNEdge_sid-681E9C5D-AD4B-45DD-BF12-E2CD5304ADFB\u0026#34; flowable:sourceDockerX=\u0026#34;50.0\u0026#34; flowable:sourceDockerY=\u0026#34;40.0\u0026#34; flowable:targetDockerX=\u0026#34;20.0\u0026#34; flowable:targetDockerY=\u0026#34;20.0\u0026#34;\u0026gt; \u0026lt;omgdi:waypoint x=\u0026#34;549.9499999999988\u0026#34; y=\u0026#34;159.14772727272728\u0026#34;\u0026gt;\u0026lt;/omgdi:waypoint\u0026gt; \u0026lt;omgdi:waypoint x=\u0026#34;656.3351955307262\u0026#34; y=\u0026#34;157.33435754189946\u0026#34;\u0026gt;\u0026lt;/omgdi:waypoint\u0026gt; \u0026lt;/bpmndi:BPMNEdge\u0026gt; \u0026lt;bpmndi:BPMNEdge bpmnElement=\u0026#34;sid-CCD38C3B-C06F-4646-B979-F65C0CA26321\u0026#34; id=\u0026#34;BPMNEdge_sid-CCD38C3B-C06F-4646-B979-F65C0CA26321\u0026#34; flowable:sourceDockerX=\u0026#34;50.0\u0026#34; flowable:sourceDockerY=\u0026#34;40.0\u0026#34; flowable:targetDockerX=\u0026#34;20.0\u0026#34; flowable:targetDockerY=\u0026#34;20.0\u0026#34;\u0026gt; \u0026lt;omgdi:waypoint x=\u0026#34;294.94999999999993\u0026#34; y=\u0026#34;171.45390070921985\u0026#34;\u0026gt;\u0026lt;/omgdi:waypoint\u0026gt; \u0026lt;omgdi:waypoint x=\u0026#34;367.32450331125824\u0026#34; y=\u0026#34;166.32119205298014\u0026#34;\u0026gt;\u0026lt;/omgdi:waypoint\u0026gt; \u0026lt;/bpmndi:BPMNEdge\u0026gt; \u0026lt;bpmndi:BPMNEdge bpmnElement=\u0026#34;sid-AD0571E9-839D-4F1F-89ED-05BE60F841FD\u0026#34; id=\u0026#34;BPMNEdge_sid-AD0571E9-839D-4F1F-89ED-05BE60F841FD\u0026#34; flowable:sourceDockerX=\u0026#34;50.0\u0026#34; flowable:sourceDockerY=\u0026#34;40.0\u0026#34; flowable:targetDockerX=\u0026#34;14.0\u0026#34; flowable:targetDockerY=\u0026#34;14.0\u0026#34;\u0026gt; \u0026lt;omgdi:waypoint x=\u0026#34;907.3347402597402\u0026#34; y=\u0026#34;139.95\u0026#34;\u0026gt;\u0026lt;/omgdi:waypoint\u0026gt; \u0026lt;omgdi:waypoint x=\u0026#34;913.1831773972388\u0026#34; y=\u0026#34;240.02104379436742\u0026#34;\u0026gt;\u0026lt;/omgdi:waypoint\u0026gt; \u0026lt;/bpmndi:BPMNEdge\u0026gt; \u0026lt;bpmndi:BPMNEdge bpmnElement=\u0026#34;sid-A6760B6A-B74F-4D35-93C2-6653751F8873\u0026#34; id=\u0026#34;BPMNEdge_sid-A6760B6A-B74F-4D35-93C2-6653751F8873\u0026#34; flowable:sourceDockerX=\u0026#34;22.5\u0026#34; flowable:sourceDockerY=\u0026#34;7.0\u0026#34; flowable:targetDockerX=\u0026#34;50.0\u0026#34; flowable:targetDockerY=\u0026#34;40.0\u0026#34;\u0026gt; \u0026lt;omgdi:waypoint x=\u0026#34;775.8406515580737\u0026#34; y=\u0026#34;142.87818696883852\u0026#34;\u0026gt;\u0026lt;/omgdi:waypoint\u0026gt; \u0026lt;omgdi:waypoint x=\u0026#34;855.0\u0026#34; y=\u0026#34;116.58716981132078\u0026#34;\u0026gt;\u0026lt;/omgdi:waypoint\u0026gt; \u0026lt;/bpmndi:BPMNEdge\u0026gt; \u0026lt;bpmndi:BPMNEdge bpmnElement=\u0026#34;sid-B8DE143C-4636-4F2C-99C9-8949E23B0042\u0026#34; id=\u0026#34;BPMNEdge_sid-B8DE143C-4636-4F2C-99C9-8949E23B0042\u0026#34; flowable:sourceDockerX=\u0026#34;20.5\u0026#34; flowable:sourceDockerY=\u0026#34;20.5\u0026#34; flowable:targetDockerX=\u0026#34;50.0\u0026#34; flowable:targetDockerY=\u0026#34;40.0\u0026#34;\u0026gt; \u0026lt;omgdi:waypoint x=\u0026#34;405.4272235576724\u0026#34; y=\u0026#34;165.5\u0026#34;\u0026gt;\u0026lt;/omgdi:waypoint\u0026gt; \u0026lt;omgdi:waypoint x=\u0026#34;428.0\u0026#34; y=\u0026#34;165.5\u0026#34;\u0026gt;\u0026lt;/omgdi:waypoint\u0026gt; \u0026lt;omgdi:waypoint x=\u0026#34;428.0\u0026#34; y=\u0026#34;160.0\u0026#34;\u0026gt;\u0026lt;/omgdi:waypoint\u0026gt; \u0026lt;omgdi:waypoint x=\u0026#34;449.99999999999346\u0026#34; y=\u0026#34;160.0\u0026#34;\u0026gt;\u0026lt;/omgdi:waypoint\u0026gt; \u0026lt;/bpmndi:BPMNEdge\u0026gt; \u0026lt;bpmndi:BPMNEdge bpmnElement=\u0026#34;sid-422FC4A8-B667-4271-9CB3-A1D2CFEFC5E1\u0026#34; id=\u0026#34;BPMNEdge_sid-422FC4A8-B667-4271-9CB3-A1D2CFEFC5E1\u0026#34; flowable:sourceDockerX=\u0026#34;20.5\u0026#34; flowable:sourceDockerY=\u0026#34;20.5\u0026#34; flowable:targetDockerX=\u0026#34;20.5\u0026#34; flowable:targetDockerY=\u0026#34;20.5\u0026#34;\u0026gt; \u0026lt;omgdi:waypoint x=\u0026#34;695.4399309245483\u0026#34; y=\u0026#34;157.5\u0026#34;\u0026gt;\u0026lt;/omgdi:waypoint\u0026gt; \u0026lt;omgdi:waypoint x=\u0026#34;750.5\u0026#34; y=\u0026#34;157.5\u0026#34;\u0026gt;\u0026lt;/omgdi:waypoint\u0026gt; \u0026lt;/bpmndi:BPMNEdge\u0026gt; \u0026lt;bpmndi:BPMNEdge bpmnElement=\u0026#34;sid-97A0DAB9-564D-4A62-92A4-26C7056CD347\u0026#34; id=\u0026#34;BPMNEdge_sid-97A0DAB9-564D-4A62-92A4-26C7056CD347\u0026#34; flowable:sourceDockerX=\u0026#34;20.5\u0026#34; flowable:sourceDockerY=\u0026#34;20.5\u0026#34; flowable:targetDockerX=\u0026#34;14.0\u0026#34; flowable:targetDockerY=\u0026#34;14.0\u0026#34;\u0026gt; \u0026lt;omgdi:waypoint x=\u0026#34;770.5\u0026#34; y=\u0026#34;176.44111163227018\u0026#34;\u0026gt;\u0026lt;/omgdi:waypoint\u0026gt; \u0026lt;omgdi:waypoint x=\u0026#34;770.5\u0026#34; y=\u0026#34;264.0\u0026#34;\u0026gt;\u0026lt;/omgdi:waypoint\u0026gt; \u0026lt;omgdi:waypoint x=\u0026#34;900.033302364888\u0026#34; y=\u0026#34;254.96981315483313\u0026#34;\u0026gt;\u0026lt;/omgdi:waypoint\u0026gt; \u0026lt;/bpmndi:BPMNEdge\u0026gt; \u0026lt;bpmndi:BPMNEdge bpmnElement=\u0026#34;sid-A52331B4-3769-46D8-AAC1-C34214C729BD\u0026#34; id=\u0026#34;BPMNEdge_sid-A52331B4-3769-46D8-AAC1-C34214C729BD\u0026#34; flowable:sourceDockerX=\u0026#34;50.0\u0026#34; flowable:sourceDockerY=\u0026#34;40.0\u0026#34; flowable:targetDockerX=\u0026#34;20.0\u0026#34; flowable:targetDockerY=\u0026#34;20.0\u0026#34;\u0026gt; \u0026lt;omgdi:waypoint x=\u0026#34;550.95\u0026#34; y=\u0026#34;94.83228571428573\u0026#34;\u0026gt;\u0026lt;/omgdi:waypoint\u0026gt; \u0026lt;omgdi:waypoint x=\u0026#34;662.6257153758107\u0026#34; y=\u0026#34;150.3587786259542\u0026#34;\u0026gt;\u0026lt;/omgdi:waypoint\u0026gt; \u0026lt;/bpmndi:BPMNEdge\u0026gt; \u0026lt;bpmndi:BPMNEdge bpmnElement=\u0026#34;sid-E808AF78-E258-4997-B4FE-C393D8EBA3B9\u0026#34; id=\u0026#34;BPMNEdge_sid-E808AF78-E258-4997-B4FE-C393D8EBA3B9\u0026#34; flowable:sourceDockerX=\u0026#34;20.5\u0026#34; flowable:sourceDockerY=\u0026#34;20.5\u0026#34; flowable:targetDockerX=\u0026#34;50.0\u0026#34; flowable:targetDockerY=\u0026#34;40.0\u0026#34;\u0026gt; \u0026lt;omgdi:waypoint x=\u0026#34;386.5\u0026#34; y=\u0026#34;145.5\u0026#34;\u0026gt;\u0026lt;/omgdi:waypoint\u0026gt; \u0026lt;omgdi:waypoint x=\u0026#34;386.5\u0026#34; y=\u0026#34;70.0\u0026#34;\u0026gt;\u0026lt;/omgdi:waypoint\u0026gt; \u0026lt;omgdi:waypoint x=\u0026#34;451.0\u0026#34; y=\u0026#34;70.0\u0026#34;\u0026gt;\u0026lt;/omgdi:waypoint\u0026gt; \u0026lt;/bpmndi:BPMNEdge\u0026gt; \u0026lt;bpmndi:BPMNEdge bpmnElement=\u0026#34;sid-CAD92170-984F-49E0-BB6D-589B11F7FB8B\u0026#34; id=\u0026#34;BPMNEdge_sid-CAD92170-984F-49E0-BB6D-589B11F7FB8B\u0026#34; flowable:sourceDockerX=\u0026#34;15.0\u0026#34; flowable:sourceDockerY=\u0026#34;15.0\u0026#34; flowable:targetDockerX=\u0026#34;50.0\u0026#34; flowable:targetDockerY=\u0026#34;40.0\u0026#34;\u0026gt; \u0026lt;omgdi:waypoint x=\u0026#34;129.94999191137833\u0026#34; y=\u0026#34;178.0\u0026#34;\u0026gt;\u0026lt;/omgdi:waypoint\u0026gt; \u0026lt;omgdi:waypoint x=\u0026#34;162.5\u0026#34; y=\u0026#34;178.0\u0026#34;\u0026gt;\u0026lt;/omgdi:waypoint\u0026gt; \u0026lt;omgdi:waypoint x=\u0026#34;162.5\u0026#34; y=\u0026#34;175.0\u0026#34;\u0026gt;\u0026lt;/omgdi:waypoint\u0026gt; \u0026lt;omgdi:waypoint x=\u0026#34;194.99999999998522\u0026#34; y=\u0026#34;175.0\u0026#34;\u0026gt;\u0026lt;/omgdi:waypoint\u0026gt; \u0026lt;/bpmndi:BPMNEdge\u0026gt; \u0026lt;bpmndi:BPMNEdge bpmnElement=\u0026#34;sid-E4AD02E7-A69A-4684-9A00-DE9B11711348\u0026#34; id=\u0026#34;BPMNEdge_sid-E4AD02E7-A69A-4684-9A00-DE9B11711348\u0026#34; flowable:sourceDockerX=\u0026#34;20.5\u0026#34; flowable:sourceDockerY=\u0026#34;20.5\u0026#34; flowable:targetDockerX=\u0026#34;50.0\u0026#34; flowable:targetDockerY=\u0026#34;40.0\u0026#34;\u0026gt; \u0026lt;omgdi:waypoint x=\u0026#34;386.5\u0026#34; y=\u0026#34;184.4426890432099\u0026#34;\u0026gt;\u0026lt;/omgdi:waypoint\u0026gt; \u0026lt;omgdi:waypoint x=\u0026#34;386.5\u0026#34; y=\u0026#34;295.0\u0026#34;\u0026gt;\u0026lt;/omgdi:waypoint\u0026gt; \u0026lt;omgdi:waypoint x=\u0026#34;465.0\u0026#34; y=\u0026#34;295.0\u0026#34;\u0026gt;\u0026lt;/omgdi:waypoint\u0026gt; \u0026lt;/bpmndi:BPMNEdge\u0026gt; \u0026lt;bpmndi:BPMNEdge bpmnElement=\u0026#34;sid-78E79754-E64A-4ADE-A9BB-F9B224D3A5A0\u0026#34; id=\u0026#34;BPMNEdge_sid-78E79754-E64A-4ADE-A9BB-F9B224D3A5A0\u0026#34; flowable:sourceDockerX=\u0026#34;50.0\u0026#34; flowable:sourceDockerY=\u0026#34;40.0\u0026#34; flowable:targetDockerX=\u0026#34;20.0\u0026#34; flowable:targetDockerY=\u0026#34;20.0\u0026#34;\u0026gt; \u0026lt;omgdi:waypoint x=\u0026#34;561.6083333333333\u0026#34; y=\u0026#34;255.0\u0026#34;\u0026gt;\u0026lt;/omgdi:waypoint\u0026gt; \u0026lt;omgdi:waypoint x=\u0026#34;665.2307692307692\u0026#34; y=\u0026#34;166.20769230769233\u0026#34;\u0026gt;\u0026lt;/omgdi:waypoint\u0026gt; \u0026lt;/bpmndi:BPMNEdge\u0026gt; \u0026lt;/bpmndi:BPMNPlane\u0026gt; \u0026lt;/bpmndi:BPMNDiagram\u0026gt; \u0026lt;/definitions\u0026gt; 部署并运行\n@Test public void deploy() { ProcessEngine engine = ProcessEngines.getDefaultProcessEngine(); RepositoryService repositoryService = engine.getRepositoryService(); Deployment deploy = repositoryService.createDeployment() .addClasspathResource(\u0026#34;holiday-inclusive-name.bpmn20.xml\u0026#34;) .deploy(); System.out.println(\u0026#34;部署成功:\u0026#34; + deploy.getId()); } @Test public void run() { ProcessEngine engine = ProcessEngines.getDefaultProcessEngine(); RuntimeService runtimeService = engine.getRuntimeService(); Map\u0026lt;String, Object\u0026gt; variables = new HashMap\u0026lt;\u0026gt;(); variables.put(\u0026#34;num\u0026#34;, 4); runtimeService.startProcessInstanceById (\u0026#34;holiday-inclusive:1:4\u0026#34;, variables); } i0完成任务\n@Test public void taskComplete(){ ProcessEngine engine=ProcessEngines.getDefaultProcessEngine(); TaskService taskService = engine.getTaskService(); Task task = taskService.createTaskQuery() .taskAssignee(\u0026#34;i0\u0026#34;) .processInstanceId(\u0026#34;2501\u0026#34;) .singleResult(); taskService.complete(task.getId()); } 看数据,默认走人事和项目经理 i1,i2所在任务执行完后,会发现走总经理 i1走完之后 i2走的时候,把num设为1,直接结束\n@Test public void taskComplete(){ ProcessEngine engine=ProcessEngines.getDefaultProcessEngine(); TaskService taskService = engine.getTaskService(); Task task = taskService.createTaskQuery() .taskAssignee(\u0026#34;i2\u0026#34;) .processInstanceId(\u0026#34;2501\u0026#34;) .singleResult(); taskService.setVariable(task.getId(), \u0026#34;num\u0026#34;,1); taskService.complete(task.getId()); } 事件网关 # "},{"id":388,"href":"/zh/docs/technology/Flowable/boge_blbl_/02-advance_4/","title":"boge-02-flowable进阶_4","section":"基础(波哥)_","content":" 候选人 # 流程图设计\n总体 具体 部署并启动流程\n@Test public void deploy(){ ProcessEngine processEngine= ProcessEngines.getDefaultProcessEngine(); RepositoryService repositoryService = processEngine.getRepositoryService(); Deployment deploy = repositoryService.createDeployment().name(\u0026#34;ly画的请假流程-候选人\u0026#34;) .addClasspathResource(\u0026#34;请假流程-候选人.bpmn20.xml\u0026#34;) .deploy(); } @Test public void runProcess(){ //设置候选人 Map\u0026lt;String,Object\u0026gt; variables=new HashMap\u0026lt;\u0026gt;(); variables.put(\u0026#34;candidate1\u0026#34;,\u0026#34;张三\u0026#34;); variables.put(\u0026#34;candidate2\u0026#34;,\u0026#34;李四\u0026#34;); variables.put(\u0026#34;candidate3\u0026#34;,\u0026#34;王五\u0026#34;); ProcessEngine engine=ProcessEngines.getDefaultProcessEngine(); //获取流程运行服务 RuntimeService runtimeService = engine.getRuntimeService(); //运行流程 ProcessInstance processInstance = runtimeService.startProcessInstanceById( \u0026#34;holiday-candidate:1:4\u0026#34;,variables); System.out.println(\u0026#34;processInstance--\u0026#34;+processInstance); } 查看数据库表数据\n处理人为空 变量 图解 实际,作为登录用户如果是张三/李四或者王五,那它可以查看它自己是候选人的任务\n/** * 查询候选任务 */ @Test public void queryCandidate(){ ProcessEngine processEngine=ProcessEngines.getDefaultProcessEngine(); TaskService taskService=processEngine.getTaskService(); List\u0026lt;Task\u0026gt; tasks = taskService.createTaskQuery() .processInstanceId(\u0026#34;5001\u0026#34;) .taskCandidateUser(\u0026#34;张三\u0026#34;) .list(); for(Task task:tasks){ System.out.println(\u0026#34;id--\u0026#34;+task.getId()+\u0026#34;--\u0026#34;+task.getName()); } } 拾取任务\n/** * 拾取任务 */ @Test public void claimTaskCandidate(){ ProcessEngine engine=ProcessEngines.getDefaultProcessEngine(); TaskService taskService=engine.getTaskService(); Task task = taskService.createTaskQuery() .processInstanceId(\u0026#34;5001\u0026#34;) .taskCandidateUser(\u0026#34;张三\u0026#34;) .singleResult(); if(task != null ){ //拾取任务 taskService.claim(task.getId(),\u0026#34;张三\u0026#34;); System.out.println(\u0026#34;拾取任务成功\u0026#34;); } } 数据库数据 此时查询李四候选任务,就查询不到了 归还任务\n/** * 拾取任务 */ @Test public void unclaimTaskCandidate(){ ProcessEngine engine=ProcessEngines.getDefaultProcessEngine(); TaskService taskService=engine.getTaskService(); Task task = taskService.createTaskQuery() .processInstanceId(\u0026#34;5001\u0026#34;) .taskAssignee(\u0026#34;张三\u0026#34;) .singleResult(); if(task != null ){ //归还任务 taskService.unclaim(task.getId()); System.out.println(\u0026#34;归还任务成功\u0026#34;); } } 数据库数据 此时用李四,拾取成功 任务交接(委托)\n/** * 任务交接(委托) */ @Test public void taskCandidate(){ ProcessEngine engine=ProcessEngines.getDefaultProcessEngine(); TaskService taskService=engine.getTaskService(); Task task = taskService.createTaskQuery() .processInstanceId(\u0026#34;5001\u0026#34;) .taskAssignee(\u0026#34;李四\u0026#34;) .singleResult(); if(task != null ){ taskService.setAssignee(task.getId(),\u0026#34;赵六\u0026#34;); System.out.println(\u0026#34;任务交接给赵六\u0026#34;); } } 结果 完成任务\n/** * 完成任务 */ @Test public void taskComplete(){ ProcessEngine engine=ProcessEngines.getDefaultProcessEngine(); TaskService taskService = engine.getTaskService(); Task task = taskService.createTaskQuery() .processInstanceId(\u0026#34;5001\u0026#34;) .taskAssignee(\u0026#34;赵六\u0026#34;) .singleResult(); if(task!=null){ taskService.complete(task.getId()); System.out.println(\u0026#34;完成任务\u0026#34;); } } 此时任务给wz了 候选人组 # 当候选人很多的情况下,可以分组。(先创建组,然后将用户放到组中)\n维护用户和组\n/** * 创建用户 */ @Test public void createUser(){ ProcessEngine engine= ProcessEngines.getDefaultProcessEngine(); IdentityService identityService = engine.getIdentityService(); User user1 = identityService.newUser(\u0026#34;李飞\u0026#34;); user1.setFirstName(\u0026#34;li\u0026#34;); user1.setLastName(\u0026#34;fei\u0026#34;); identityService.saveUser(user1); User user2 = identityService.newUser(\u0026#34;灯标\u0026#34;); user2.setFirstName(\u0026#34;deng\u0026#34;); user2.setLastName(\u0026#34;biao\u0026#34;); identityService.saveUser(user2); User user3 = identityService.newUser(\u0026#34;田家\u0026#34;); user3.setFirstName(\u0026#34;tian\u0026#34;); user3.setLastName(\u0026#34;jia\u0026#34;); identityService.saveUser(user3); } /** * 创建组 */ @Test public void createGroup(){ ProcessEngine engine=ProcessEngines.getDefaultProcessEngine(); IdentityService identityService = engine.getIdentityService(); Group group1 = identityService.newGroup(\u0026#34;group1\u0026#34;); group1.setName(\u0026#34;销售部\u0026#34;); group1.setType(\u0026#34;typ1\u0026#34;); identityService.saveGroup(group1); Group group2 = identityService.newGroup(\u0026#34;group2\u0026#34;); group2.setName(\u0026#34;开发部\u0026#34;); group2.setType(\u0026#34;typ2\u0026#34;); identityService.saveGroup(group2); } /** * 分配 */ @Test public void userGroup(){ ProcessEngine engine=ProcessEngines.getDefaultProcessEngine(); IdentityService identityService = engine.getIdentityService(); //找到组 Group group1 = identityService.createGroupQuery().groupId(\u0026#34;group1\u0026#34;) .singleResult(); //找到所有用户 List\u0026lt;User\u0026gt; list = identityService.createUserQuery().list(); for(User user:list){ identityService.createMembership(user.getId(),group1.getId()); System.out.println(user.getId()); } } 表结构\n应用,创建流程图 xml文件\n\u0026lt;?xml version=\u0026#34;1.0\u0026#34; encoding=\u0026#34;UTF-8\u0026#34;?\u0026gt; \u0026lt;definitions xmlns=\u0026#34;http://www.omg.org/spec/BPMN/20100524/MODEL\u0026#34; xmlns:xsi=\u0026#34;http://www.w3.org/2001/XMLSchema-instance\u0026#34; xmlns:xsd=\u0026#34;http://www.w3.org/2001/XMLSchema\u0026#34; xmlns:flowable=\u0026#34;http://flowable.org/bpmn\u0026#34; xmlns:bpmndi=\u0026#34;http://www.omg.org/spec/BPMN/20100524/DI\u0026#34; xmlns:omgdc=\u0026#34;http://www.omg.org/spec/DD/20100524/DC\u0026#34; xmlns:omgdi=\u0026#34;http://www.omg.org/spec/DD/20100524/DI\u0026#34; typeLanguage=\u0026#34;http://www.w3.org/2001/XMLSchema\u0026#34; expressionLanguage=\u0026#34;http://www.w3.org/1999/XPath\u0026#34; targetNamespace=\u0026#34;http://www.flowable.org/processdef\u0026#34; exporter=\u0026#34;Flowable Open Source Modeler\u0026#34; exporterVersion=\u0026#34;6.7.2\u0026#34;\u0026gt; \u0026lt;process id=\u0026#34;holiday-group\u0026#34; name=\u0026#34;请求流程-候选人组\u0026#34; isExecutable=\u0026#34;true\u0026#34;\u0026gt; \u0026lt;startEvent id=\u0026#34;startEvent1\u0026#34; flowable:formFieldValidation=\u0026#34;true\u0026#34;\u0026gt;\u0026lt;/startEvent\u0026gt; \u0026lt;userTask id=\u0026#34;sid-B4CAA6EE-47C0-4C51-AB0F-7A347AA88CF9\u0026#34; name=\u0026#34;创建请假单\u0026#34; flowable:candidateGroups=\u0026#34;${g1}\u0026#34; flowable:formFieldValidation=\u0026#34;true\u0026#34;\u0026gt;\u0026lt;/userTask\u0026gt; \u0026lt;sequenceFlow id=\u0026#34;sid-FAA16FF3-BFC5-49AA-8BB5-7DF1918F67FF\u0026#34; sourceRef=\u0026#34;startEvent1\u0026#34; targetRef=\u0026#34;sid-B4CAA6EE-47C0-4C51-AB0F-7A347AA88CF9\u0026#34;\u0026gt;\u0026lt;/sequenceFlow\u0026gt; \u0026lt;userTask id=\u0026#34;sid-C3C15BE2-2D50-4178-AD36-D6BAC5C47526\u0026#34; name=\u0026#34;总经理审批\u0026#34; flowable:assignee=\u0026#34;wz\u0026#34; flowable:formFieldValidation=\u0026#34;true\u0026#34;\u0026gt; \u0026lt;extensionElements\u0026gt; \u0026lt;modeler:initiator-can-complete xmlns:modeler=\u0026#34;http://flowable.org/modeler\u0026#34;\u0026gt;\u0026lt;![CDATA[false]]\u0026gt;\u0026lt;/modeler:initiator-can-complete\u0026gt; \u0026lt;/extensionElements\u0026gt; \u0026lt;/userTask\u0026gt; \u0026lt;sequenceFlow id=\u0026#34;sid-9821E7E5-DB4A-4BE5-95C7-2721E98D6BD6\u0026#34; sourceRef=\u0026#34;sid-B4CAA6EE-47C0-4C51-AB0F-7A347AA88CF9\u0026#34; targetRef=\u0026#34;sid-C3C15BE2-2D50-4178-AD36-D6BAC5C47526\u0026#34;\u0026gt;\u0026lt;/sequenceFlow\u0026gt; \u0026lt;endEvent id=\u0026#34;sid-BF42EC91-584D-4C19-8EC0-9658CD948CDE\u0026#34;\u0026gt;\u0026lt;/endEvent\u0026gt; \u0026lt;sequenceFlow id=\u0026#34;sid-6F5E54EF-5767-4E22-8AC7-322C7E332B6B\u0026#34; sourceRef=\u0026#34;sid-C3C15BE2-2D50-4178-AD36-D6BAC5C47526\u0026#34; targetRef=\u0026#34;sid-BF42EC91-584D-4C19-8EC0-9658CD948CDE\u0026#34;\u0026gt;\u0026lt;/sequenceFlow\u0026gt; \u0026lt;/process\u0026gt; \u0026lt;bpmndi:BPMNDiagram id=\u0026#34;BPMNDiagram_holiday-group\u0026#34;\u0026gt; \u0026lt;bpmndi:BPMNPlane bpmnElement=\u0026#34;holiday-group\u0026#34; id=\u0026#34;BPMNPlane_holiday-group\u0026#34;\u0026gt; \u0026lt;bpmndi:BPMNShape bpmnElement=\u0026#34;startEvent1\u0026#34; id=\u0026#34;BPMNShape_startEvent1\u0026#34;\u0026gt; \u0026lt;omgdc:Bounds height=\u0026#34;30.0\u0026#34; width=\u0026#34;30.0\u0026#34; x=\u0026#34;100.0\u0026#34; y=\u0026#34;163.0\u0026#34;\u0026gt;\u0026lt;/omgdc:Bounds\u0026gt; \u0026lt;/bpmndi:BPMNShape\u0026gt; \u0026lt;bpmndi:BPMNShape bpmnElement=\u0026#34;sid-B4CAA6EE-47C0-4C51-AB0F-7A347AA88CF9\u0026#34; id=\u0026#34;BPMNShape_sid-B4CAA6EE-47C0-4C51-AB0F-7A347AA88CF9\u0026#34;\u0026gt; \u0026lt;omgdc:Bounds height=\u0026#34;80.0\u0026#34; width=\u0026#34;100.0\u0026#34; x=\u0026#34;165.0\u0026#34; y=\u0026#34;135.0\u0026#34;\u0026gt;\u0026lt;/omgdc:Bounds\u0026gt; \u0026lt;/bpmndi:BPMNShape\u0026gt; \u0026lt;bpmndi:BPMNShape bpmnElement=\u0026#34;sid-C3C15BE2-2D50-4178-AD36-D6BAC5C47526\u0026#34; id=\u0026#34;BPMNShape_sid-C3C15BE2-2D50-4178-AD36-D6BAC5C47526\u0026#34;\u0026gt; \u0026lt;omgdc:Bounds height=\u0026#34;80.0\u0026#34; width=\u0026#34;100.0\u0026#34; x=\u0026#34;330.0\u0026#34; y=\u0026#34;135.0\u0026#34;\u0026gt;\u0026lt;/omgdc:Bounds\u0026gt; \u0026lt;/bpmndi:BPMNShape\u0026gt; \u0026lt;bpmndi:BPMNShape bpmnElement=\u0026#34;sid-BF42EC91-584D-4C19-8EC0-9658CD948CDE\u0026#34; id=\u0026#34;BPMNShape_sid-BF42EC91-584D-4C19-8EC0-9658CD948CDE\u0026#34;\u0026gt; \u0026lt;omgdc:Bounds height=\u0026#34;28.0\u0026#34; width=\u0026#34;28.0\u0026#34; x=\u0026#34;510.0\u0026#34; y=\u0026#34;164.0\u0026#34;\u0026gt;\u0026lt;/omgdc:Bounds\u0026gt; \u0026lt;/bpmndi:BPMNShape\u0026gt; \u0026lt;bpmndi:BPMNEdge bpmnElement=\u0026#34;sid-9821E7E5-DB4A-4BE5-95C7-2721E98D6BD6\u0026#34; id=\u0026#34;BPMNEdge_sid-9821E7E5-DB4A-4BE5-95C7-2721E98D6BD6\u0026#34; flowable:sourceDockerX=\u0026#34;50.0\u0026#34; flowable:sourceDockerY=\u0026#34;40.0\u0026#34; flowable:targetDockerX=\u0026#34;50.0\u0026#34; flowable:targetDockerY=\u0026#34;40.0\u0026#34;\u0026gt; \u0026lt;omgdi:waypoint x=\u0026#34;264.94999999998356\u0026#34; y=\u0026#34;175.0\u0026#34;\u0026gt;\u0026lt;/omgdi:waypoint\u0026gt; \u0026lt;omgdi:waypoint x=\u0026#34;330.0\u0026#34; y=\u0026#34;175.0\u0026#34;\u0026gt;\u0026lt;/omgdi:waypoint\u0026gt; \u0026lt;/bpmndi:BPMNEdge\u0026gt; \u0026lt;bpmndi:BPMNEdge bpmnElement=\u0026#34;sid-FAA16FF3-BFC5-49AA-8BB5-7DF1918F67FF\u0026#34; id=\u0026#34;BPMNEdge_sid-FAA16FF3-BFC5-49AA-8BB5-7DF1918F67FF\u0026#34; flowable:sourceDockerX=\u0026#34;15.0\u0026#34; flowable:sourceDockerY=\u0026#34;15.0\u0026#34; flowable:targetDockerX=\u0026#34;50.0\u0026#34; flowable:targetDockerY=\u0026#34;40.0\u0026#34;\u0026gt; \u0026lt;omgdi:waypoint x=\u0026#34;129.94340692927761\u0026#34; y=\u0026#34;177.55019845363262\u0026#34;\u0026gt;\u0026lt;/omgdi:waypoint\u0026gt; \u0026lt;omgdi:waypoint x=\u0026#34;164.99999999999906\u0026#34; y=\u0026#34;176.4985\u0026#34;\u0026gt;\u0026lt;/omgdi:waypoint\u0026gt; \u0026lt;/bpmndi:BPMNEdge\u0026gt; \u0026lt;bpmndi:BPMNEdge bpmnElement=\u0026#34;sid-6F5E54EF-5767-4E22-8AC7-322C7E332B6B\u0026#34; id=\u0026#34;BPMNEdge_sid-6F5E54EF-5767-4E22-8AC7-322C7E332B6B\u0026#34; flowable:sourceDockerX=\u0026#34;50.0\u0026#34; flowable:sourceDockerY=\u0026#34;40.0\u0026#34; flowable:targetDockerX=\u0026#34;14.0\u0026#34; flowable:targetDockerY=\u0026#34;14.0\u0026#34;\u0026gt; \u0026lt;omgdi:waypoint x=\u0026#34;429.9499999999989\u0026#34; y=\u0026#34;176.04062499999998\u0026#34;\u0026gt;\u0026lt;/omgdi:waypoint\u0026gt; \u0026lt;omgdi:waypoint x=\u0026#34;510.0021426561354\u0026#34; y=\u0026#34;177.70839534661596\u0026#34;\u0026gt;\u0026lt;/omgdi:waypoint\u0026gt; \u0026lt;/bpmndi:BPMNEdge\u0026gt; \u0026lt;/bpmndi:BPMNPlane\u0026gt; \u0026lt;/bpmndi:BPMNDiagram\u0026gt; \u0026lt;/definitions\u0026gt; 部署并启动流程\n@Test public void deploy(){ ProcessEngine processEngine= ProcessEngines.getDefaultProcessEngine(); RepositoryService repositoryService = processEngine.getRepositoryService(); Deployment deploy = repositoryService.createDeployment().name(\u0026#34;ly画的请假流程-候选人\u0026#34;) .addClasspathResource(\u0026#34;请求流程-候选人组.bpmn20.xml\u0026#34;) .deploy(); } @Test public void runProcess(){ ProcessEngine engine=ProcessEngines.getDefaultProcessEngine(); //实际开发,应该按下面代码让用户选 IdentityService identityService = engine.getIdentityService(); List\u0026lt;Group\u0026gt; list = identityService.createGroupQuery().list(); //获取流程运行服务 RuntimeService runtimeService = engine.getRuntimeService(); //设置候选人 Map\u0026lt;String,Object\u0026gt; variables=new HashMap\u0026lt;\u0026gt;(); variables.put(\u0026#34;g1\u0026#34;,\u0026#34;group1\u0026#34;); //运行流程 ProcessInstance processInstance = runtimeService. startProcessInstanceById( \u0026#34;holiday-group:1:25004\u0026#34;,variables); System.out.println(\u0026#34;processInstance--\u0026#34;+processInstance); } 表 variables 查找当前用户所在组的任务,并拾取\n/** * 查询候选组任务 */ @Test public void queryCandidateGroup(){ String userId=\u0026#34;灯标\u0026#34;; ProcessEngine processEngine=ProcessEngines.getDefaultProcessEngine(); IdentityService identityService = processEngine.getIdentityService(); Group group = identityService.createGroupQuery(). groupMember(userId) .singleResult(); System.out.println(\u0026#34;灯标组id\u0026#34;+group.getId()); TaskService taskService=processEngine.getTaskService(); List\u0026lt;Task\u0026gt; tasks = taskService.createTaskQuery() .processInstanceId(\u0026#34;27501\u0026#34;) .taskCandidateGroup(group.getId()) .list(); for(Task task:tasks){ System.out.println(\u0026#34;id--\u0026#34;+task.getId()+\u0026#34;--\u0026#34;+task.getName()); } Task task = taskService.createTaskQuery() .processInstanceId(\u0026#34;27501\u0026#34;) .taskCandidateGroup(group.getId()) .singleResult(); if(task!=null){ System.out.println(\u0026#34;拾取任务--\u0026#34;+task.getId() +\u0026#34;任务名--\u0026#34;+task.getName()); taskService.claim(task.getId(),userId); } } 数据库数据 完成任务\n/** * 完成任务 */ @Test public void taskComplete(){ ProcessEngine engine=ProcessEngines.getDefaultProcessEngine(); TaskService taskService = engine.getTaskService(); Task task = taskService.createTaskQuery() .processInstanceId(\u0026#34;27501\u0026#34;) .taskAssignee(\u0026#34;灯标\u0026#34;) .singleResult(); if(task!=null){ taskService.complete(task.getId()); System.out.println(\u0026#34;完成任务\u0026#34;); } } # "},{"id":389,"href":"/zh/docs/technology/Flowable/boge_blbl_/02-advance_3/","title":"boge-02-flowable进阶_3","section":"基础(波哥)_","content":" 任务分配-uel表达式 # 通过变量指定来进行分配\n首先绘制流程图(定义) 变量处理 之后将xml文件导出\n\u0026lt;?xml version=\u0026#34;1.0\u0026#34; encoding=\u0026#34;UTF-8\u0026#34;?\u0026gt; \u0026lt;definitions xmlns=\u0026#34;http://www.omg.org/spec/BPMN/20100524/MODEL\u0026#34; xmlns:xsi=\u0026#34;http://www.w3.org/2001/XMLSchema-instance\u0026#34; xmlns:xsd=\u0026#34;http://www.w3.org/2001/XMLSchema\u0026#34; xmlns:flowable=\u0026#34;http://flowable.org/bpmn\u0026#34; xmlns:bpmndi=\u0026#34;http://www.omg.org/spec/BPMN/20100524/DI\u0026#34; xmlns:omgdc=\u0026#34;http://www.omg.org/spec/DD/20100524/DC\u0026#34; xmlns:omgdi=\u0026#34;http://www.omg.org/spec/DD/20100524/DI\u0026#34; typeLanguage=\u0026#34;http://www.w3.org/2001/XMLSchema\u0026#34; expressionLanguage=\u0026#34;http://www.w3.org/1999/XPath\u0026#34; targetNamespace=\u0026#34;http://www.flowable.org/processdef\u0026#34; exporter=\u0026#34;Flowable Open Source Modeler\u0026#34; exporterVersion=\u0026#34;6.7.2\u0026#34;\u0026gt; \u0026lt;process id=\u0026#34;holiday-new\u0026#34; name=\u0026#34;新请假流程\u0026#34; isExecutable=\u0026#34;true\u0026#34;\u0026gt; \u0026lt;documentation\u0026gt;new-description\u0026lt;/documentation\u0026gt; \u0026lt;startEvent id=\u0026#34;startEvent1\u0026#34; flowable:formFieldValidation=\u0026#34;true\u0026#34;\u0026gt;\u0026lt;/startEvent\u0026gt; \u0026lt;userTask id=\u0026#34;sid-8D901410-5BD7-4EED-B988-5E40D12298C7\u0026#34; name=\u0026#34;创建请假流程\u0026#34; flowable:assignee=\u0026#34;${assignee0}\u0026#34; flowable:formFieldValidation=\u0026#34;true\u0026#34;\u0026gt; \u0026lt;extensionElements\u0026gt; \u0026lt;modeler:initiator-can-complete xmlns:modeler=\u0026#34;http://flowable.org/modeler\u0026#34;\u0026gt;\u0026lt;![CDATA[false]]\u0026gt;\u0026lt;/modeler:initiator-can-complete\u0026gt; \u0026lt;/extensionElements\u0026gt; \u0026lt;/userTask\u0026gt; \u0026lt;userTask id=\u0026#34;sid-5EB8F68B-7876-42AF-98E1-FCA27F99D8CE\u0026#34; name=\u0026#34;审批请假流程\u0026#34; flowable:assignee=\u0026#34;${assignee1}\u0026#34; flowable:formFieldValidation=\u0026#34;true\u0026#34;\u0026gt; \u0026lt;extensionElements\u0026gt; \u0026lt;modeler:initiator-can-complete xmlns:modeler=\u0026#34;http://flowable.org/modeler\u0026#34;\u0026gt;\u0026lt;![CDATA[false]]\u0026gt;\u0026lt;/modeler:initiator-can-complete\u0026gt; \u0026lt;/extensionElements\u0026gt; \u0026lt;/userTask\u0026gt; \u0026lt;sequenceFlow id=\u0026#34;sid-631EFFB0-795A-4777-B49E-CF7D015BFF15\u0026#34; sourceRef=\u0026#34;sid-8D901410-5BD7-4EED-B988-5E40D12298C7\u0026#34; targetRef=\u0026#34;sid-5EB8F68B-7876-42AF-98E1-FCA27F99D8CE\u0026#34;\u0026gt;\u0026lt;/sequenceFlow\u0026gt; \u0026lt;endEvent id=\u0026#34;sid-15CAD0D3-7F8B-404C-9346-A8D2A456D47B\u0026#34;\u0026gt;\u0026lt;/endEvent\u0026gt; \u0026lt;sequenceFlow id=\u0026#34;sid-001CA567-6169-4F8A-A0E5-010721D52508\u0026#34; sourceRef=\u0026#34;sid-5EB8F68B-7876-42AF-98E1-FCA27F99D8CE\u0026#34; targetRef=\u0026#34;sid-15CAD0D3-7F8B-404C-9346-A8D2A456D47B\u0026#34;\u0026gt;\u0026lt;/sequenceFlow\u0026gt; \u0026lt;sequenceFlow id=\u0026#34;sid-0A4A52F2-ECF6-44B2-AA41-F926AA7F5932\u0026#34; sourceRef=\u0026#34;startEvent1\u0026#34; targetRef=\u0026#34;sid-8D901410-5BD7-4EED-B988-5E40D12298C7\u0026#34;\u0026gt;\u0026lt;/sequenceFlow\u0026gt; \u0026lt;/process\u0026gt; \u0026lt;bpmndi:BPMNDiagram id=\u0026#34;BPMNDiagram_holiday-new\u0026#34;\u0026gt; \u0026lt;bpmndi:BPMNPlane bpmnElement=\u0026#34;holiday-new\u0026#34; id=\u0026#34;BPMNPlane_holiday-new\u0026#34;\u0026gt; \u0026lt;bpmndi:BPMNShape bpmnElement=\u0026#34;startEvent1\u0026#34; id=\u0026#34;BPMNShape_startEvent1\u0026#34;\u0026gt; \u0026lt;omgdc:Bounds height=\u0026#34;30.0\u0026#34; width=\u0026#34;30.0\u0026#34; x=\u0026#34;100.0\u0026#34; y=\u0026#34;145.0\u0026#34;\u0026gt;\u0026lt;/omgdc:Bounds\u0026gt; \u0026lt;/bpmndi:BPMNShape\u0026gt; \u0026lt;bpmndi:BPMNShape bpmnElement=\u0026#34;sid-8D901410-5BD7-4EED-B988-5E40D12298C7\u0026#34; id=\u0026#34;BPMNShape_sid-8D901410-5BD7-4EED-B988-5E40D12298C7\u0026#34;\u0026gt; \u0026lt;omgdc:Bounds height=\u0026#34;80.0\u0026#34; width=\u0026#34;100.0\u0026#34; x=\u0026#34;225.0\u0026#34; y=\u0026#34;120.0\u0026#34;\u0026gt;\u0026lt;/omgdc:Bounds\u0026gt; \u0026lt;/bpmndi:BPMNShape\u0026gt; \u0026lt;bpmndi:BPMNShape bpmnElement=\u0026#34;sid-5EB8F68B-7876-42AF-98E1-FCA27F99D8CE\u0026#34; id=\u0026#34;BPMNShape_sid-5EB8F68B-7876-42AF-98E1-FCA27F99D8CE\u0026#34;\u0026gt; \u0026lt;omgdc:Bounds height=\u0026#34;80.0\u0026#34; width=\u0026#34;100.0\u0026#34; x=\u0026#34;370.0\u0026#34; y=\u0026#34;120.0\u0026#34;\u0026gt;\u0026lt;/omgdc:Bounds\u0026gt; \u0026lt;/bpmndi:BPMNShape\u0026gt; \u0026lt;bpmndi:BPMNShape bpmnElement=\u0026#34;sid-15CAD0D3-7F8B-404C-9346-A8D2A456D47B\u0026#34; id=\u0026#34;BPMNShape_sid-15CAD0D3-7F8B-404C-9346-A8D2A456D47B\u0026#34;\u0026gt; \u0026lt;omgdc:Bounds height=\u0026#34;28.0\u0026#34; width=\u0026#34;28.0\u0026#34; x=\u0026#34;555.0\u0026#34; y=\u0026#34;146.0\u0026#34;\u0026gt;\u0026lt;/omgdc:Bounds\u0026gt; \u0026lt;/bpmndi:BPMNShape\u0026gt; \u0026lt;bpmndi:BPMNEdge bpmnElement=\u0026#34;sid-001CA567-6169-4F8A-A0E5-010721D52508\u0026#34; id=\u0026#34;BPMNEdge_sid-001CA567-6169-4F8A-A0E5-010721D52508\u0026#34; flowable:sourceDockerX=\u0026#34;50.0\u0026#34; flowable:sourceDockerY=\u0026#34;40.0\u0026#34; flowable:targetDockerX=\u0026#34;14.0\u0026#34; flowable:targetDockerY=\u0026#34;14.0\u0026#34;\u0026gt; \u0026lt;omgdi:waypoint x=\u0026#34;469.94999999997356\u0026#34; y=\u0026#34;160.0\u0026#34;\u0026gt;\u0026lt;/omgdi:waypoint\u0026gt; \u0026lt;omgdi:waypoint x=\u0026#34;555.0\u0026#34; y=\u0026#34;160.0\u0026#34;\u0026gt;\u0026lt;/omgdi:waypoint\u0026gt; \u0026lt;/bpmndi:BPMNEdge\u0026gt; \u0026lt;bpmndi:BPMNEdge bpmnElement=\u0026#34;sid-0A4A52F2-ECF6-44B2-AA41-F926AA7F5932\u0026#34; id=\u0026#34;BPMNEdge_sid-0A4A52F2-ECF6-44B2-AA41-F926AA7F5932\u0026#34; flowable:sourceDockerX=\u0026#34;15.0\u0026#34; flowable:sourceDockerY=\u0026#34;15.0\u0026#34; flowable:targetDockerX=\u0026#34;50.0\u0026#34; flowable:targetDockerY=\u0026#34;40.0\u0026#34;\u0026gt; \u0026lt;omgdi:waypoint x=\u0026#34;129.94999928606217\u0026#34; y=\u0026#34;160.0\u0026#34;\u0026gt;\u0026lt;/omgdi:waypoint\u0026gt; \u0026lt;omgdi:waypoint x=\u0026#34;224.99999999995185\u0026#34; y=\u0026#34;160.0\u0026#34;\u0026gt;\u0026lt;/omgdi:waypoint\u0026gt; \u0026lt;/bpmndi:BPMNEdge\u0026gt; \u0026lt;bpmndi:BPMNEdge bpmnElement=\u0026#34;sid-631EFFB0-795A-4777-B49E-CF7D015BFF15\u0026#34; id=\u0026#34;BPMNEdge_sid-631EFFB0-795A-4777-B49E-CF7D015BFF15\u0026#34; flowable:sourceDockerX=\u0026#34;50.0\u0026#34; flowable:sourceDockerY=\u0026#34;40.0\u0026#34; flowable:targetDockerX=\u0026#34;50.0\u0026#34; flowable:targetDockerY=\u0026#34;40.0\u0026#34;\u0026gt; \u0026lt;omgdi:waypoint x=\u0026#34;324.9499999999907\u0026#34; y=\u0026#34;160.0\u0026#34;\u0026gt;\u0026lt;/omgdi:waypoint\u0026gt; \u0026lt;omgdi:waypoint x=\u0026#34;369.9999999999807\u0026#34; y=\u0026#34;160.0\u0026#34;\u0026gt;\u0026lt;/omgdi:waypoint\u0026gt; \u0026lt;/bpmndi:BPMNEdge\u0026gt; \u0026lt;/bpmndi:BPMNPlane\u0026gt; \u0026lt;/bpmndi:BPMNDiagram\u0026gt; \u0026lt;/definitions\u0026gt; 流程定义的部署\n/** * 流程的部署 */ @Test public void testDeploy() { //获取ProcessEngine对象 ProcessEngine processEngine = configuration.buildProcessEngine(); //获取服务(repository,流程定义) RepositoryService repositoryService = processEngine.getRepositoryService(); Deployment deploy = repositoryService.createDeployment() .addClasspathResource(\u0026#34;新请假流程.bpmn20.xml\u0026#34;) .name(\u0026#34;请求流程\u0026#34;) //流程名 .deploy(); System.out.println(\u0026#34;部署id\u0026#34; + deploy.getId()); System.out.println(\u0026#34;部署名\u0026#34; + deploy.getName()); } 流程的启动(在流程启动时就已经处理好了各个节点的处理人)\n/** * 流程实例的启动 */ @Test public void testRunProcess2(){ ProcessEngine engine=ProcessEngines.getDefaultProcessEngine(); RuntimeService runtimeService = engine.getRuntimeService(); //启动流程时,发起人就已经设置好了 Map\u0026lt;String,Object\u0026gt; variables=new HashMap\u0026lt;\u0026gt;(); variables.put(\u0026#34;assignee0\u0026#34;,\u0026#34;张三\u0026#34;); variables.put(\u0026#34;assignee1\u0026#34;,\u0026#34;李四\u0026#34;); ProcessInstance processInstance = runtimeService.startProcessInstanceById(\u0026#34;holiday-new:1:4\u0026#34;,variables); System.out.println(processInstance); } 查看数据库表数据\nact_ru_variable\nact_ru_task 让张三完成处理\n@Test public void testComplete(){ ProcessEngine processEngine=ProcessEngines.getDefaultProcessEngine(); TaskService taskService = processEngine.getTaskService(); Task task = taskService.createTaskQuery().taskAssignee(\u0026#34;张三\u0026#34;) .processInstanceId(\u0026#34;2501\u0026#34;) .singleResult(); taskService.complete(task.getId()); } 此时观察task和identity这两张表\n任务变成了李四,而identity多了张三的记录\n任务分配-监听器分配 # 首先,java代码中,自定义一个监听器 【注意,这里给任务分配assignee是在create中分配才是有用的】\npackage org.flowable.listener; import org.flowable.engine.delegate.TaskListener; import org.flowable.task.service.delegate.DelegateTask; public class MyTaskListener implements TaskListener { /** * 监听器触发的方法 * @param delegateTask */ @Override public void notify(DelegateTask delegateTask) { System.out.println(\u0026#34;MyTaskListener触发:\u0026#34;+delegateTask .getName()); if(\u0026#34;创建请假流程\u0026#34;.equals(delegateTask.getName()) \u0026amp;\u0026amp;\u0026#34;create\u0026#34;.equals(delegateTask.getEventName())){ delegateTask.setAssignee(\u0026#34;小明\u0026#34;); }else { delegateTask.setAssignee(\u0026#34;小李\u0026#34;); } } } 两个节点走的是同一个监听器\nxml定义中任务监听器的配置(两个节点都配置了) \u0026lt;?xml version=\u0026#34;1.0\u0026#34; encoding=\u0026#34;UTF-8\u0026#34;?\u0026gt; \u0026lt;definitions xmlns=\u0026#34;http://www.omg.org/spec/BPMN/20100524/MODEL\u0026#34; xmlns:xsi=\u0026#34;http://www.w3.org/2001/XMLSchema-instance\u0026#34; xmlns:xsd=\u0026#34;http://www.w3.org/2001/XMLSchema\u0026#34; xmlns:flowable=\u0026#34;http://flowable.org/bpmn\u0026#34; xmlns:bpmndi=\u0026#34;http://www.omg.org/spec/BPMN/20100524/DI\u0026#34; xmlns:omgdc=\u0026#34;http://www.omg.org/spec/DD/20100524/DC\u0026#34; xmlns:omgdi=\u0026#34;http://www.omg.org/spec/DD/20100524/DI\u0026#34; typeLanguage=\u0026#34;http://www.w3.org/2001/XMLSchema\u0026#34; expressionLanguage=\u0026#34;http://www.w3.org/1999/XPath\u0026#34; targetNamespace=\u0026#34;http://www.flowable.org/processdef\u0026#34; exporter=\u0026#34;Flowable Open Source Modeler\u0026#34; exporterVersion=\u0026#34;6.7.2\u0026#34;\u0026gt; \u0026lt;process id=\u0026#34;holiday-new\u0026#34; name=\u0026#34;新请假流程\u0026#34; isExecutable=\u0026#34;true\u0026#34;\u0026gt; \u0026lt;documentation\u0026gt;new-description\u0026lt;/documentation\u0026gt; \u0026lt;startEvent id=\u0026#34;startEvent1\u0026#34; flowable:formFieldValidation=\u0026#34;true\u0026#34;\u0026gt;\u0026lt;/startEvent\u0026gt; \u0026lt;userTask id=\u0026#34;sid-8D901410-5BD7-4EED-B988-5E40D12298C7\u0026#34; name=\u0026#34;创建请假流程\u0026#34; flowable:formFieldValidation=\u0026#34;true\u0026#34;\u0026gt; \u0026lt;extensionElements\u0026gt; \u0026lt;flowable:taskListener event=\u0026#34;create\u0026#34; class=\u0026#34;org.flowable.listener.MyTaskListener\u0026#34;\u0026gt;\u0026lt;/flowable:taskListener\u0026gt; \u0026lt;/extensionElements\u0026gt; \u0026lt;/userTask\u0026gt; \u0026lt;userTask id=\u0026#34;sid-5EB8F68B-7876-42AF-98E1-FCA27F99D8CE\u0026#34; name=\u0026#34;审批请假流程\u0026#34; flowable:formFieldValidation=\u0026#34;true\u0026#34;\u0026gt; \u0026lt;extensionElements\u0026gt; \u0026lt;flowable:taskListener event=\u0026#34;create\u0026#34; class=\u0026#34;org.flowable.listener.MyTaskListener\u0026#34;\u0026gt;\u0026lt;/flowable:taskListener\u0026gt; \u0026lt;/extensionElements\u0026gt; \u0026lt;/userTask\u0026gt; \u0026lt;sequenceFlow id=\u0026#34;sid-631EFFB0-795A-4777-B49E-CF7D015BFF15\u0026#34; sourceRef=\u0026#34;sid-8D901410-5BD7-4EED-B988-5E40D12298C7\u0026#34; targetRef=\u0026#34;sid-5EB8F68B-7876-42AF-98E1-FCA27F99D8CE\u0026#34;\u0026gt;\u0026lt;/sequenceFlow\u0026gt; \u0026lt;sequenceFlow id=\u0026#34;sid-001CA567-6169-4F8A-A0E5-010721D52508\u0026#34; sourceRef=\u0026#34;sid-5EB8F68B-7876-42AF-98E1-FCA27F99D8CE\u0026#34; targetRef=\u0026#34;sid-15CAD0D3-7F8B-404C-9346-A8D2A456D47B\u0026#34;\u0026gt;\u0026lt;/sequenceFlow\u0026gt; \u0026lt;sequenceFlow id=\u0026#34;sid-0A4A52F2-ECF6-44B2-AA41-F926AA7F5932\u0026#34; sourceRef=\u0026#34;startEvent1\u0026#34; targetRef=\u0026#34;sid-8D901410-5BD7-4EED-B988-5E40D12298C7\u0026#34;\u0026gt;\u0026lt;/sequenceFlow\u0026gt; \u0026lt;endEvent id=\u0026#34;sid-15CAD0D3-7F8B-404C-9346-A8D2A456D47B\u0026#34;\u0026gt;\u0026lt;/endEvent\u0026gt; \u0026lt;/process\u0026gt; \u0026lt;bpmndi:BPMNDiagram id=\u0026#34;BPMNDiagram_holiday-new\u0026#34;\u0026gt; \u0026lt;bpmndi:BPMNPlane bpmnElement=\u0026#34;holiday-new\u0026#34; id=\u0026#34;BPMNPlane_holiday-new\u0026#34;\u0026gt; \u0026lt;bpmndi:BPMNShape bpmnElement=\u0026#34;startEvent1\u0026#34; id=\u0026#34;BPMNShape_startEvent1\u0026#34;\u0026gt; \u0026lt;omgdc:Bounds height=\u0026#34;30.0\u0026#34; width=\u0026#34;30.0\u0026#34; x=\u0026#34;100.0\u0026#34; y=\u0026#34;115.0\u0026#34;\u0026gt;\u0026lt;/omgdc:Bounds\u0026gt; \u0026lt;/bpmndi:BPMNShape\u0026gt; \u0026lt;bpmndi:BPMNShape bpmnElement=\u0026#34;sid-8D901410-5BD7-4EED-B988-5E40D12298C7\u0026#34; id=\u0026#34;BPMNShape_sid-8D901410-5BD7-4EED-B988-5E40D12298C7\u0026#34;\u0026gt; \u0026lt;omgdc:Bounds height=\u0026#34;80.0\u0026#34; width=\u0026#34;100.0\u0026#34; x=\u0026#34;195.0\u0026#34; y=\u0026#34;90.0\u0026#34;\u0026gt;\u0026lt;/omgdc:Bounds\u0026gt; \u0026lt;/bpmndi:BPMNShape\u0026gt; \u0026lt;bpmndi:BPMNShape bpmnElement=\u0026#34;sid-5EB8F68B-7876-42AF-98E1-FCA27F99D8CE\u0026#34; id=\u0026#34;BPMNShape_sid-5EB8F68B-7876-42AF-98E1-FCA27F99D8CE\u0026#34;\u0026gt; \u0026lt;omgdc:Bounds height=\u0026#34;80.0\u0026#34; width=\u0026#34;100.0\u0026#34; x=\u0026#34;370.0\u0026#34; y=\u0026#34;90.0\u0026#34;\u0026gt;\u0026lt;/omgdc:Bounds\u0026gt; \u0026lt;/bpmndi:BPMNShape\u0026gt; \u0026lt;bpmndi:BPMNShape bpmnElement=\u0026#34;sid-15CAD0D3-7F8B-404C-9346-A8D2A456D47B\u0026#34; id=\u0026#34;BPMNShape_sid-15CAD0D3-7F8B-404C-9346-A8D2A456D47B\u0026#34;\u0026gt; \u0026lt;omgdc:Bounds height=\u0026#34;28.0\u0026#34; width=\u0026#34;28.0\u0026#34; x=\u0026#34;570.0\u0026#34; y=\u0026#34;116.0\u0026#34;\u0026gt;\u0026lt;/omgdc:Bounds\u0026gt; \u0026lt;/bpmndi:BPMNShape\u0026gt; \u0026lt;bpmndi:BPMNEdge bpmnElement=\u0026#34;sid-001CA567-6169-4F8A-A0E5-010721D52508\u0026#34; id=\u0026#34;BPMNEdge_sid-001CA567-6169-4F8A-A0E5-010721D52508\u0026#34; flowable:sourceDockerX=\u0026#34;50.0\u0026#34; flowable:sourceDockerY=\u0026#34;40.0\u0026#34; flowable:targetDockerX=\u0026#34;14.0\u0026#34; flowable:targetDockerY=\u0026#34;14.0\u0026#34;\u0026gt; \u0026lt;omgdi:waypoint x=\u0026#34;469.9499999999809\u0026#34; y=\u0026#34;130.0\u0026#34;\u0026gt;\u0026lt;/omgdi:waypoint\u0026gt; \u0026lt;omgdi:waypoint x=\u0026#34;570.0\u0026#34; y=\u0026#34;130.0\u0026#34;\u0026gt;\u0026lt;/omgdi:waypoint\u0026gt; \u0026lt;/bpmndi:BPMNEdge\u0026gt; \u0026lt;bpmndi:BPMNEdge bpmnElement=\u0026#34;sid-0A4A52F2-ECF6-44B2-AA41-F926AA7F5932\u0026#34; id=\u0026#34;BPMNEdge_sid-0A4A52F2-ECF6-44B2-AA41-F926AA7F5932\u0026#34; flowable:sourceDockerX=\u0026#34;15.0\u0026#34; flowable:sourceDockerY=\u0026#34;15.0\u0026#34; flowable:targetDockerX=\u0026#34;50.0\u0026#34; flowable:targetDockerY=\u0026#34;40.0\u0026#34;\u0026gt; \u0026lt;omgdi:waypoint x=\u0026#34;129.94999891869114\u0026#34; y=\u0026#34;130.0\u0026#34;\u0026gt;\u0026lt;/omgdi:waypoint\u0026gt; \u0026lt;omgdi:waypoint x=\u0026#34;195.0\u0026#34; y=\u0026#34;130.0\u0026#34;\u0026gt;\u0026lt;/omgdi:waypoint\u0026gt; \u0026lt;/bpmndi:BPMNEdge\u0026gt; \u0026lt;bpmndi:BPMNEdge bpmnElement=\u0026#34;sid-631EFFB0-795A-4777-B49E-CF7D015BFF15\u0026#34; id=\u0026#34;BPMNEdge_sid-631EFFB0-795A-4777-B49E-CF7D015BFF15\u0026#34; flowable:sourceDockerX=\u0026#34;50.0\u0026#34; flowable:sourceDockerY=\u0026#34;40.0\u0026#34; flowable:targetDockerX=\u0026#34;50.0\u0026#34; flowable:targetDockerY=\u0026#34;40.0\u0026#34;\u0026gt; \u0026lt;omgdi:waypoint x=\u0026#34;294.95000000000005\u0026#34; y=\u0026#34;130.0\u0026#34;\u0026gt;\u0026lt;/omgdi:waypoint\u0026gt; \u0026lt;omgdi:waypoint x=\u0026#34;369.99999999993753\u0026#34; y=\u0026#34;130.0\u0026#34;\u0026gt;\u0026lt;/omgdi:waypoint\u0026gt; \u0026lt;/bpmndi:BPMNEdge\u0026gt; \u0026lt;/bpmndi:BPMNPlane\u0026gt; \u0026lt;/bpmndi:BPMNDiagram\u0026gt; \u0026lt;/definitions\u0026gt; 之后将流程再重新部署一遍\n/** * 流程的部署 */ @Test public void testDeploy() { //获取ProcessEngine对象 ProcessEngine processEngine = configuration.buildProcessEngine(); //获取服务(repository,流程定义) RepositoryService repositoryService = processEngine.getRepositoryService(); Deployment deploy = repositoryService.createDeployment() .addClasspathResource(\u0026#34;新请假流程.bpmn20.xml\u0026#34;) .name(\u0026#34;请求流程\u0026#34;) //流程名 .deploy(); System.out.println(\u0026#34;部署id\u0026#34; + deploy.getId()); System.out.println(\u0026#34;部署名\u0026#34; + deploy.getName()); } 流程运行\n/** * 流程实例的启动 */ @Test public void testRunProcess3() { ProcessEngine engine = ProcessEngines.getDefaultProcessEngine(); RuntimeService runtimeService = engine.getRuntimeService(); ProcessInstance processInstance = runtimeService.startProcessInstanceById( \u0026#34;holiday-new:1:4\u0026#34;); System.out.println(processInstance); } 控制台查看 数据库查看 让小明处理任务\n@Test public void testComplete(){ ProcessEngine processEngine=ProcessEngines.getDefaultProcessEngine(); TaskService taskService = processEngine.getTaskService(); Task task = taskService.createTaskQuery().taskAssignee(\u0026#34;小明\u0026#34;) .processInstanceId(\u0026#34;2501\u0026#34;) .singleResult(); taskService.complete(task.getId()); } 数据库查看 流程变量 # 全局变量(跟流程有关)和局部变量(跟task有关)\n一个流程定义,可以运行多个流程实例; 当用到子流程时,就会出现一对多的关系 全局变量被重复赋值时后面会覆盖前面\n流程图的创建 这里还设置了条件,详见xm文件 sequenceFlow.conditionExpression 属性\n\u0026lt;?xml version=\u0026#34;1.0\u0026#34; encoding=\u0026#34;UTF-8\u0026#34;?\u0026gt; \u0026lt;definitions xmlns=\u0026#34;http://www.omg.org/spec/BPMN/20100524/MODEL\u0026#34; xmlns:xsi=\u0026#34;http://www.w3.org/2001/XMLSchema-instance\u0026#34; xmlns:xsd=\u0026#34;http://www.w3.org/2001/XMLSchema\u0026#34; xmlns:flowable=\u0026#34;http://flowable.org/bpmn\u0026#34; xmlns:bpmndi=\u0026#34;http://www.omg.org/spec/BPMN/20100524/DI\u0026#34; xmlns:omgdc=\u0026#34;http://www.omg.org/spec/DD/20100524/DC\u0026#34; xmlns:omgdi=\u0026#34;http://www.omg.org/spec/DD/20100524/DI\u0026#34; typeLanguage=\u0026#34;http://www.w3.org/2001/XMLSchema\u0026#34; expressionLanguage=\u0026#34;http://www.w3.org/1999/XPath\u0026#34; targetNamespace=\u0026#34;http://www.flowable.org/processdef\u0026#34; exporter=\u0026#34;Flowable Open Source Modeler\u0026#34; exporterVersion=\u0026#34;6.7.2\u0026#34;\u0026gt; \u0026lt;process id=\u0026#34;evection\u0026#34; name=\u0026#34;出差申请单\u0026#34; isExecutable=\u0026#34;true\u0026#34;\u0026gt; \u0026lt;documentation\u0026gt;出差申请单\u0026lt;/documentation\u0026gt; \u0026lt;startEvent id=\u0026#34;startEvent1\u0026#34; flowable:formFieldValidation=\u0026#34;true\u0026#34;\u0026gt;\u0026lt;/startEvent\u0026gt; \u0026lt;userTask id=\u0026#34;sid-BFB6D699-D3B5-4C6C-A0F2-00584EAAF207\u0026#34; name=\u0026#34;创建出差申请单\u0026#34; flowable:assignee=\u0026#34;${assignee0}\u0026#34; flowable:formFieldValidation=\u0026#34;true\u0026#34;\u0026gt; \u0026lt;extensionElements\u0026gt; \u0026lt;modeler:initiator-can-complete xmlns:modeler=\u0026#34;http://flowable.org/modeler\u0026#34;\u0026gt;\u0026lt;![CDATA[false]]\u0026gt;\u0026lt;/modeler:initiator-can-complete\u0026gt; \u0026lt;/extensionElements\u0026gt; \u0026lt;/userTask\u0026gt; \u0026lt;sequenceFlow id=\u0026#34;sid-EE410204-0433-4FE6-A958-48585A2A7B4B\u0026#34; sourceRef=\u0026#34;startEvent1\u0026#34; targetRef=\u0026#34;sid-BFB6D699-D3B5-4C6C-A0F2-00584EAAF207\u0026#34;\u0026gt;\u0026lt;/sequenceFlow\u0026gt; \u0026lt;userTask id=\u0026#34;sid-D10C4F45-B429-4E24-B474-5354F1661645\u0026#34; name=\u0026#34;部门经理审批\u0026#34; flowable:assignee=\u0026#34;${assignee1}\u0026#34; flowable:formFieldValidation=\u0026#34;true\u0026#34;\u0026gt; \u0026lt;extensionElements\u0026gt; \u0026lt;modeler:initiator-can-complete xmlns:modeler=\u0026#34;http://flowable.org/modeler\u0026#34;\u0026gt;\u0026lt;![CDATA[false]]\u0026gt;\u0026lt;/modeler:initiator-can-complete\u0026gt; \u0026lt;/extensionElements\u0026gt; \u0026lt;/userTask\u0026gt; \u0026lt;sequenceFlow id=\u0026#34;sid-752CE2F2-40EC-4140-AF60-BEACD06D43A7\u0026#34; sourceRef=\u0026#34;sid-BFB6D699-D3B5-4C6C-A0F2-00584EAAF207\u0026#34; targetRef=\u0026#34;sid-D10C4F45-B429-4E24-B474-5354F1661645\u0026#34;\u0026gt;\u0026lt;/sequenceFlow\u0026gt; \u0026lt;userTask id=\u0026#34;sid-35AB278B-E16D-4CEC-98B1-FBB139FB5AC1\u0026#34; name=\u0026#34;总经理审批\u0026#34; flowable:assignee=\u0026#34;${assignee2}\u0026#34; flowable:formFieldValidation=\u0026#34;true\u0026#34;\u0026gt; \u0026lt;extensionElements\u0026gt; \u0026lt;modeler:initiator-can-complete xmlns:modeler=\u0026#34;http://flowable.org/modeler\u0026#34;\u0026gt;\u0026lt;![CDATA[false]]\u0026gt;\u0026lt;/modeler:initiator-can-complete\u0026gt; \u0026lt;/extensionElements\u0026gt; \u0026lt;/userTask\u0026gt; \u0026lt;userTask id=\u0026#34;sid-4C26DA5C-A4CC-48A5-ABA9-853E82FC2413\u0026#34; name=\u0026#34;财务审批 \u0026#34; flowable:assignee=\u0026#34;${assignee3}\u0026#34; flowable:formFieldValidation=\u0026#34;true\u0026#34;\u0026gt; \u0026lt;extensionElements\u0026gt; \u0026lt;modeler:initiator-can-complete xmlns:modeler=\u0026#34;http://flowable.org/modeler\u0026#34;\u0026gt;\u0026lt;![CDATA[false]]\u0026gt;\u0026lt;/modeler:initiator-can-complete\u0026gt; \u0026lt;/extensionElements\u0026gt; \u0026lt;/userTask\u0026gt; \u0026lt;sequenceFlow id=\u0026#34;sid-BE043A23-0F38-4ED9-A0D1-F4C2F7908A50\u0026#34; sourceRef=\u0026#34;sid-35AB278B-E16D-4CEC-98B1-FBB139FB5AC1\u0026#34; targetRef=\u0026#34;sid-4C26DA5C-A4CC-48A5-ABA9-853E82FC2413\u0026#34;\u0026gt;\u0026lt;/sequenceFlow\u0026gt; \u0026lt;endEvent id=\u0026#34;sid-B3A1D5D4-E1FD-4599-A482-762C7C617844\u0026#34;\u0026gt;\u0026lt;/endEvent\u0026gt; \u0026lt;sequenceFlow id=\u0026#34;sid-6C0130A8-E078-486B-9B6E-D8C14BBCD8EF\u0026#34; sourceRef=\u0026#34;sid-4C26DA5C-A4CC-48A5-ABA9-853E82FC2413\u0026#34; targetRef=\u0026#34;sid-B3A1D5D4-E1FD-4599-A482-762C7C617844\u0026#34;\u0026gt;\u0026lt;/sequenceFlow\u0026gt; \u0026lt;sequenceFlow id=\u0026#34;sid-F85B2D44-1B42-4748-AB35-123C7CCD2F75\u0026#34; sourceRef=\u0026#34;sid-D10C4F45-B429-4E24-B474-5354F1661645\u0026#34; targetRef=\u0026#34;sid-35AB278B-E16D-4CEC-98B1-FBB139FB5AC1\u0026#34;\u0026gt; \u0026lt;conditionExpression xsi:type=\u0026#34;tFormalExpression\u0026#34;\u0026gt;\u0026lt;![CDATA[${num \u0026gt;= 3}]]\u0026gt;\u0026lt;/conditionExpression\u0026gt; \u0026lt;/sequenceFlow\u0026gt; \u0026lt;sequenceFlow id=\u0026#34;sid-B12793A8-FC65-408C-81AD-EC81FEEF6E46\u0026#34; sourceRef=\u0026#34;sid-D10C4F45-B429-4E24-B474-5354F1661645\u0026#34; targetRef=\u0026#34;sid-4C26DA5C-A4CC-48A5-ABA9-853E82FC2413\u0026#34;\u0026gt; \u0026lt;conditionExpression xsi:type=\u0026#34;tFormalExpression\u0026#34;\u0026gt;\u0026lt;![CDATA[${num \u0026lt; 3}]]\u0026gt;\u0026lt;/conditionExpression\u0026gt; \u0026lt;/sequenceFlow\u0026gt; \u0026lt;/process\u0026gt; \u0026lt;bpmndi:BPMNDiagram id=\u0026#34;BPMNDiagram_evection\u0026#34;\u0026gt; \u0026lt;bpmndi:BPMNPlane bpmnElement=\u0026#34;evection\u0026#34; id=\u0026#34;BPMNPlane_evection\u0026#34;\u0026gt; \u0026lt;bpmndi:BPMNShape bpmnElement=\u0026#34;startEvent1\u0026#34; id=\u0026#34;BPMNShape_startEvent1\u0026#34;\u0026gt; \u0026lt;omgdc:Bounds height=\u0026#34;30.0\u0026#34; width=\u0026#34;30.0\u0026#34; x=\u0026#34;100.0\u0026#34; y=\u0026#34;75.0\u0026#34;\u0026gt;\u0026lt;/omgdc:Bounds\u0026gt; \u0026lt;/bpmndi:BPMNShape\u0026gt; \u0026lt;bpmndi:BPMNShape bpmnElement=\u0026#34;sid-BFB6D699-D3B5-4C6C-A0F2-00584EAAF207\u0026#34; id=\u0026#34;BPMNShape_sid-BFB6D699-D3B5-4C6C-A0F2-00584EAAF207\u0026#34;\u0026gt; \u0026lt;omgdc:Bounds height=\u0026#34;80.0\u0026#34; width=\u0026#34;100.0\u0026#34; x=\u0026#34;175.0\u0026#34; y=\u0026#34;50.0\u0026#34;\u0026gt;\u0026lt;/omgdc:Bounds\u0026gt; \u0026lt;/bpmndi:BPMNShape\u0026gt; \u0026lt;bpmndi:BPMNShape bpmnElement=\u0026#34;sid-D10C4F45-B429-4E24-B474-5354F1661645\u0026#34; id=\u0026#34;BPMNShape_sid-D10C4F45-B429-4E24-B474-5354F1661645\u0026#34;\u0026gt; \u0026lt;omgdc:Bounds height=\u0026#34;80.0\u0026#34; width=\u0026#34;100.0\u0026#34; x=\u0026#34;320.0\u0026#34; y=\u0026#34;50.0\u0026#34;\u0026gt;\u0026lt;/omgdc:Bounds\u0026gt; \u0026lt;/bpmndi:BPMNShape\u0026gt; \u0026lt;bpmndi:BPMNShape bpmnElement=\u0026#34;sid-35AB278B-E16D-4CEC-98B1-FBB139FB5AC1\u0026#34; id=\u0026#34;BPMNShape_sid-35AB278B-E16D-4CEC-98B1-FBB139FB5AC1\u0026#34;\u0026gt; \u0026lt;omgdc:Bounds height=\u0026#34;80.0\u0026#34; width=\u0026#34;100.0\u0026#34; x=\u0026#34;555.0\u0026#34; y=\u0026#34;50.0\u0026#34;\u0026gt;\u0026lt;/omgdc:Bounds\u0026gt; \u0026lt;/bpmndi:BPMNShape\u0026gt; \u0026lt;bpmndi:BPMNShape bpmnElement=\u0026#34;sid-4C26DA5C-A4CC-48A5-ABA9-853E82FC2413\u0026#34; id=\u0026#34;BPMNShape_sid-4C26DA5C-A4CC-48A5-ABA9-853E82FC2413\u0026#34;\u0026gt; \u0026lt;omgdc:Bounds height=\u0026#34;80.0\u0026#34; width=\u0026#34;100.0\u0026#34; x=\u0026#34;555.0\u0026#34; y=\u0026#34;210.0\u0026#34;\u0026gt;\u0026lt;/omgdc:Bounds\u0026gt; \u0026lt;/bpmndi:BPMNShape\u0026gt; \u0026lt;bpmndi:BPMNShape bpmnElement=\u0026#34;sid-B3A1D5D4-E1FD-4599-A482-762C7C617844\u0026#34; id=\u0026#34;BPMNShape_sid-B3A1D5D4-E1FD-4599-A482-762C7C617844\u0026#34;\u0026gt; \u0026lt;omgdc:Bounds height=\u0026#34;28.0\u0026#34; width=\u0026#34;28.0\u0026#34; x=\u0026#34;750.0\u0026#34; y=\u0026#34;236.0\u0026#34;\u0026gt;\u0026lt;/omgdc:Bounds\u0026gt; \u0026lt;/bpmndi:BPMNShape\u0026gt; \u0026lt;bpmndi:BPMNEdge bpmnElement=\u0026#34;sid-EE410204-0433-4FE6-A958-48585A2A7B4B\u0026#34; id=\u0026#34;BPMNEdge_sid-EE410204-0433-4FE6-A958-48585A2A7B4B\u0026#34; flowable:sourceDockerX=\u0026#34;15.0\u0026#34; flowable:sourceDockerY=\u0026#34;15.0\u0026#34; flowable:targetDockerX=\u0026#34;50.0\u0026#34; flowable:targetDockerY=\u0026#34;40.0\u0026#34;\u0026gt; \u0026lt;omgdi:waypoint x=\u0026#34;129.9499984899576\u0026#34; y=\u0026#34;90.0\u0026#34;\u0026gt;\u0026lt;/omgdi:waypoint\u0026gt; \u0026lt;omgdi:waypoint x=\u0026#34;175.0\u0026#34; y=\u0026#34;90.0\u0026#34;\u0026gt;\u0026lt;/omgdi:waypoint\u0026gt; \u0026lt;/bpmndi:BPMNEdge\u0026gt; \u0026lt;bpmndi:BPMNEdge bpmnElement=\u0026#34;sid-752CE2F2-40EC-4140-AF60-BEACD06D43A7\u0026#34; id=\u0026#34;BPMNEdge_sid-752CE2F2-40EC-4140-AF60-BEACD06D43A7\u0026#34; flowable:sourceDockerX=\u0026#34;50.0\u0026#34; flowable:sourceDockerY=\u0026#34;40.0\u0026#34; flowable:targetDockerX=\u0026#34;50.0\u0026#34; flowable:targetDockerY=\u0026#34;40.0\u0026#34;\u0026gt; \u0026lt;omgdi:waypoint x=\u0026#34;274.95000000000005\u0026#34; y=\u0026#34;90.0\u0026#34;\u0026gt;\u0026lt;/omgdi:waypoint\u0026gt; \u0026lt;omgdi:waypoint x=\u0026#34;320.0\u0026#34; y=\u0026#34;90.0\u0026#34;\u0026gt;\u0026lt;/omgdi:waypoint\u0026gt; \u0026lt;/bpmndi:BPMNEdge\u0026gt; \u0026lt;bpmndi:BPMNEdge bpmnElement=\u0026#34;sid-B12793A8-FC65-408C-81AD-EC81FEEF6E46\u0026#34; id=\u0026#34;BPMNEdge_sid-B12793A8-FC65-408C-81AD-EC81FEEF6E46\u0026#34; flowable:sourceDockerX=\u0026#34;50.0\u0026#34; flowable:sourceDockerY=\u0026#34;40.0\u0026#34; flowable:targetDockerX=\u0026#34;50.0\u0026#34; flowable:targetDockerY=\u0026#34;40.0\u0026#34;\u0026gt; \u0026lt;omgdi:waypoint x=\u0026#34;419.95000000000005\u0026#34; y=\u0026#34;124.0085106382979\u0026#34;\u0026gt;\u0026lt;/omgdi:waypoint\u0026gt; \u0026lt;omgdi:waypoint x=\u0026#34;555.0\u0026#34; y=\u0026#34;215.95744680851067\u0026#34;\u0026gt;\u0026lt;/omgdi:waypoint\u0026gt; \u0026lt;/bpmndi:BPMNEdge\u0026gt; \u0026lt;bpmndi:BPMNEdge bpmnElement=\u0026#34;sid-6C0130A8-E078-486B-9B6E-D8C14BBCD8EF\u0026#34; id=\u0026#34;BPMNEdge_sid-6C0130A8-E078-486B-9B6E-D8C14BBCD8EF\u0026#34; flowable:sourceDockerX=\u0026#34;50.0\u0026#34; flowable:sourceDockerY=\u0026#34;40.0\u0026#34; flowable:targetDockerX=\u0026#34;14.0\u0026#34; flowable:targetDockerY=\u0026#34;14.0\u0026#34;\u0026gt; \u0026lt;omgdi:waypoint x=\u0026#34;654.9499999998701\u0026#34; y=\u0026#34;250.0\u0026#34;\u0026gt;\u0026lt;/omgdi:waypoint\u0026gt; \u0026lt;omgdi:waypoint x=\u0026#34;750.0\u0026#34; y=\u0026#34;250.0\u0026#34;\u0026gt;\u0026lt;/omgdi:waypoint\u0026gt; \u0026lt;/bpmndi:BPMNEdge\u0026gt; \u0026lt;bpmndi:BPMNEdge bpmnElement=\u0026#34;sid-BE043A23-0F38-4ED9-A0D1-F4C2F7908A50\u0026#34; id=\u0026#34;BPMNEdge_sid-BE043A23-0F38-4ED9-A0D1-F4C2F7908A50\u0026#34; flowable:sourceDockerX=\u0026#34;50.0\u0026#34; flowable:sourceDockerY=\u0026#34;40.0\u0026#34; flowable:targetDockerX=\u0026#34;50.0\u0026#34; flowable:targetDockerY=\u0026#34;40.0\u0026#34;\u0026gt; \u0026lt;omgdi:waypoint x=\u0026#34;605.0\u0026#34; y=\u0026#34;129.95\u0026#34;\u0026gt;\u0026lt;/omgdi:waypoint\u0026gt; \u0026lt;omgdi:waypoint x=\u0026#34;605.0\u0026#34; y=\u0026#34;210.0\u0026#34;\u0026gt;\u0026lt;/omgdi:waypoint\u0026gt; \u0026lt;/bpmndi:BPMNEdge\u0026gt; \u0026lt;bpmndi:BPMNEdge bpmnElement=\u0026#34;sid-F85B2D44-1B42-4748-AB35-123C7CCD2F75\u0026#34; id=\u0026#34;BPMNEdge_sid-F85B2D44-1B42-4748-AB35-123C7CCD2F75\u0026#34; flowable:sourceDockerX=\u0026#34;50.0\u0026#34; flowable:sourceDockerY=\u0026#34;40.0\u0026#34; flowable:targetDockerX=\u0026#34;50.0\u0026#34; flowable:targetDockerY=\u0026#34;40.0\u0026#34;\u0026gt; \u0026lt;omgdi:waypoint x=\u0026#34;419.95000000000005\u0026#34; y=\u0026#34;90.0\u0026#34;\u0026gt;\u0026lt;/omgdi:waypoint\u0026gt; \u0026lt;omgdi:waypoint x=\u0026#34;555.0\u0026#34; y=\u0026#34;90.0\u0026#34;\u0026gt;\u0026lt;/omgdi:waypoint\u0026gt; \u0026lt;/bpmndi:BPMNEdge\u0026gt; \u0026lt;/bpmndi:BPMNPlane\u0026gt; \u0026lt;/bpmndi:BPMNDiagram\u0026gt; \u0026lt;/definitions\u0026gt; 流程进行部署\n/** * 流程的部署 */ @Test public void testDeploy() { //获取ProcessEngine对象 ProcessEngine processEngine = ProcessEngines.getDefaultProcessEngine(); //获取服务(repository,流程定义) RepositoryService repositoryService = processEngine.getRepositoryService(); Deployment deploy = repositoryService.createDeployment() .addClasspathResource(\u0026#34;出差申请单.bpmn20.xml\u0026#34;) .name(\u0026#34;请假流程\u0026#34;) //流程名 .deploy(); System.out.println(\u0026#34;部署id\u0026#34; + deploy.getId()); System.out.println(\u0026#34;部署名\u0026#34; + deploy.getName()); } 流程运行\n/** * 流程实例的定义 */ @Test public void runProcess(){ ProcessEngine processEngine=ProcessEngines.getDefaultProcessEngine(); RuntimeService runtimeService = processEngine.getRuntimeService(); Map\u0026lt;String,Object\u0026gt; variables=new HashMap\u0026lt;\u0026gt;(); variables.put(\u0026#34;assignee0\u0026#34;,\u0026#34;张三\u0026#34;); variables.put(\u0026#34;assignee1\u0026#34;,\u0026#34;李四\u0026#34;); variables.put(\u0026#34;assignee2\u0026#34;,\u0026#34;王五\u0026#34;); variables.put(\u0026#34;assignee3\u0026#34;,\u0026#34;赵财务\u0026#34;); ProcessInstance processInstance = runtimeService. startProcessInstanceById(\u0026#34;evection:1:4\u0026#34;, variables); } //这时候节点走到张三了,让张三处理\n/** * 任务完成 */ @Test public void taskComplete(){ ProcessEngine processEngine=ProcessEngines.getDefaultProcessEngine(); TaskService taskService = processEngine.getTaskService(); Task task = taskService.createTaskQuery() .processInstanceId(\u0026#34;2501\u0026#34;) .taskAssignee(\u0026#34;张三\u0026#34;) .singleResult(); Map\u0026lt;String,Object\u0026gt; processVariables=task.getProcessVariables(); processVariables.put(\u0026#34;num\u0026#34;,3); taskService.complete(task.getId(),processVariables); } 下面修改num的值,修改之前 全局变量的查询\n@Test public void getVariables(){ ProcessEngine processEngine=ProcessEngines.getDefaultProcessEngine(); TaskService taskService = processEngine.getTaskService(); Task task = taskService.createTaskQuery() .includeProcessVariables() //注意,这个一定要加的不然获取不到全局变量 .processInstanceId(\u0026#34;2501\u0026#34;) .taskAssignee(\u0026#34;张三\u0026#34;) .singleResult(); //这里只能获取到任务的局部变量 Map\u0026lt;String, Object\u0026gt; processVariables = task.getProcessVariables(); System.out.println(\u0026#34;当前流程变量--start\u0026#34;); Set\u0026lt;String\u0026gt; keySet1 = processVariables.keySet(); for(String key:keySet1){ System.out.println(\u0026#34;key--\u0026#34;+key+\u0026#34;value--\u0026#34;+processVariables.get(key)); } System.out.println(\u0026#34;当前流程变量--end\u0026#34;); } 修改\n@Test public void updateVariables(){ ProcessEngine processEngine=ProcessEngines.getDefaultProcessEngine(); TaskService taskService = processEngine.getTaskService(); Task task = taskService.createTaskQuery() .includeProcessVariables() //注意,这个一定要加的不然获取不到全局变量 .processInstanceId(\u0026#34;2501\u0026#34;) .taskAssignee(\u0026#34;李四\u0026#34;) .singleResult(); Map\u0026lt;String, Object\u0026gt; processVariables = task.getProcessVariables(); System.out.println(\u0026#34;当前流程变量--start\u0026#34;); Set\u0026lt;String\u0026gt; keySet = processVariables.keySet(); for(String key:keySet){ System.out.println(\u0026#34;key--\u0026#34;+key+\u0026#34;value--\u0026#34;+processVariables.get(key)); } System.out.println(\u0026#34;当前流程变量--end\u0026#34;); processVariables.put(\u0026#34;num\u0026#34;,5); taskService.setVariablesLocal(task.getId(),processVariables); } 结果\n按照视频的说法,这里错了,应该是会多了5条记录 局部变量的再次测试\n@Test public void updateVariables(){ ProcessEngine processEngine=ProcessEngines.getDefaultProcessEngine(); TaskService taskService = processEngine.getTaskService(); Task task = taskService.createTaskQuery() .includeProcessVariables() .processInstanceId(\u0026#34;2501\u0026#34;) .taskAssignee(\u0026#34;张三\u0026#34;) .singleResult(); //流程还没开始运行的情况下,取到的是全局变量 Map\u0026lt;String, Object\u0026gt; processVariables = task.getProcessVariables(); System.out.println(\u0026#34;当前流程变量--start\u0026#34;); Set\u0026lt;String\u0026gt; keySet = processVariables.keySet(); for(String key:keySet){ System.out.println(\u0026#34;key--\u0026#34;+key+\u0026#34;value--\u0026#34;+processVariables.get(key)); } System.out.println(\u0026#34;当前流程变量--end\u0026#34;); Map\u0026lt;String,Object\u0026gt; varLocalInsert=new HashMap\u0026lt;\u0026gt;(); varLocalInsert.put(\u0026#34;num\u0026#34;,5); Map\u0026lt;String,Object\u0026gt; varUpdate=new HashMap\u0026lt;\u0026gt;(); varUpdate.put(\u0026#34;a\u0026#34;,\u0026#34;嘿嘿\u0026#34;); //这里测试会不会把全局变量全部覆盖 taskService.setVariables(task.getId(),varUpdate); taskService.setVariablesLocal(task.getId(),varLocalInsert); } 修改前 修改后 结果表明这是批量增加/修改,而不是覆盖 当前数据库的数据 1个局部变量num,5个全局变量 接下来在张三节点设置一个局部变量\n/** * 任务完成 */ @Test public void taskComplete(){ ProcessEngine processEngine=ProcessEngines.getDefaultProcessEngine(); TaskService taskService = processEngine.getTaskService(); Task task = taskService.createTaskQuery() .processInstanceId(\u0026#34;2501\u0026#34;) .taskAssignee(\u0026#34;张三\u0026#34;) .singleResult(); Map\u0026lt;String,Object\u0026gt; processVariables=task.getProcessVariables(); processVariables.put(\u0026#34;num\u0026#34;,2); taskService.complete(task.getId(),processVariables); } 查看数据库表,发现num已经被修改成2 这时李四设置了一个局部变量num=6\n@Test public void updateVariables2(){ ProcessEngine processEngine=ProcessEngines.getDefaultProcessEngine(); TaskService taskService = processEngine.getTaskService(); Task task = taskService.createTaskQuery() .includeProcessVariables() .processInstanceId(\u0026#34;2501\u0026#34;) .taskAssignee(\u0026#34;李四\u0026#34;) .singleResult(); Map\u0026lt;String,Object\u0026gt; varLocalInsert=new HashMap\u0026lt;\u0026gt;(); varLocalInsert.put(\u0026#34;num\u0026#34;,6); Map\u0026lt;String,Object\u0026gt; varUpdate=new HashMap\u0026lt;\u0026gt;(); varUpdate.put(\u0026#34;a\u0026#34;,\u0026#34;嘿嘿\u0026#34;); //这里测试会不会把全局变量全部覆盖 //taskService.setVariables(task.getId(),varUpdate); taskService.setVariablesLocal(task.getId(),varLocalInsert); } 仅仅多了一条记录 修改全局变量\n@Test public void updateVariables3(){ ProcessEngine processEngine=ProcessEngines.getDefaultProcessEngine(); TaskService taskService = processEngine.getTaskService(); Task task = taskService.createTaskQuery() .includeProcessVariables() .processInstanceId(\u0026#34;2501\u0026#34;) .taskAssignee(\u0026#34;李四\u0026#34;) .singleResult(); Map\u0026lt;String,Object\u0026gt; varLocalInsert=new HashMap\u0026lt;\u0026gt;(); varLocalInsert.put(\u0026#34;num\u0026#34;,18); varLocalInsert.put(\u0026#34;a\u0026#34;,\u0026#34;a被修改了\u0026#34;); //这里测试会不会把全局变量全部覆盖 //taskService.setVariables(task.getId(),varUpdate); taskService.setVariables(task.getId(),varLocalInsert); } 结果如下,当局部变量和全局变量的名称一样时,只能修改局部变量 让李四完成审批 这里存在局部变量num=18,且完成时设置了局部变量20\n@Test public void taskComplete4(){ ProcessEngine processEngine=ProcessEngines.getDefaultProcessEngine(); TaskService taskService = processEngine.getTaskService(); Task task = taskService.createTaskQuery() .includeProcessVariables() .processInstanceId(\u0026#34;2501\u0026#34;) .taskAssignee(\u0026#34;李四\u0026#34;) .singleResult(); // System.out.println(\u0026#34;taskId\u0026#34;+task.getId()); Map\u0026lt;String,Object\u0026gt; map=new HashMap\u0026lt;\u0026gt;(); map.put(\u0026#34;num\u0026#34;,20); taskService.complete(task.getId(),map); } 注意,这里全局变量被改成20了,局部变量被删除了 走到了总经理审批\n再测试 将数据清空,重新部署并运行流程\n现在在赵四节点,局部变量为 @Test public void taskComplete4(){ ProcessEngine processEngine=ProcessEngines.getDefaultProcessEngine(); TaskService taskService = processEngine.getTaskService(); Task task = taskService.createTaskQuery() .includeProcessVariables() .processInstanceId(\u0026#34;2501\u0026#34;) .taskAssignee(\u0026#34;李四\u0026#34;) .singleResult(); // System.out.println(\u0026#34;taskId\u0026#34;+task.getId()); Map\u0026lt;String,Object\u0026gt; map=new HashMap\u0026lt;\u0026gt;(); map.put(\u0026#34;num\u0026#34;,20); taskService.setVariablesLocal(task.getId(),map); taskService.complete(task.getId()); } 运行完之后,局部变量变成20了,但是流程走不下去 稍作更改,添加一个全局变量(但是由于存在局部变量a,所以这里全局变量没设置成功)\n@Test public void taskComplete4(){ ProcessEngine processEngine=ProcessEngines.getDefaultProcessEngine(); TaskService taskService = processEngine.getTaskService(); Task task = taskService.createTaskQuery() .includeProcessVariables() .processInstanceId(\u0026#34;2501\u0026#34;) .taskAssignee(\u0026#34;李四\u0026#34;) .singleResult(); // System.out.println(\u0026#34;taskId\u0026#34;+task.getId()); Map\u0026lt;String,Object\u0026gt; map=new HashMap\u0026lt;\u0026gt;(); map.put(\u0026#34;num\u0026#34;,20); taskService.setVariablesLocal(task.getId(),map); Map\u0026lt;String,Object\u0026gt; map1=new HashMap\u0026lt;\u0026gt;(); map1.put(\u0026#34;num\u0026#34;,1); taskService.setVariables(task.getId(),map1); taskService.complete(task.getId()); } 现在只能通过在complete中设置,来使得全局变量生效\n@Test public void taskComplete4(){ ProcessEngine processEngine=ProcessEngines.getDefaultProcessEngine(); TaskService taskService = processEngine.getTaskService(); Task task = taskService.createTaskQuery() .includeProcessVariables() .processInstanceId(\u0026#34;2501\u0026#34;) .taskAssignee(\u0026#34;李四\u0026#34;) .singleResult(); // System.out.println(\u0026#34;taskId\u0026#34;+task.getId()); Map\u0026lt;String,Object\u0026gt; map=new HashMap\u0026lt;\u0026gt;(); map.put(\u0026#34;num\u0026#34;,null); taskService.setVariablesLocal(task.getId(),map); Map\u0026lt;String,Object\u0026gt; map1=new HashMap\u0026lt;\u0026gt;(); map1.put(\u0026#34;num\u0026#34;,1); //taskService.setVariables(task.getId(),map1); taskService.complete(task.getId(),map1); } 结果,全局变量设置成功,且任务流转到了财务那 再测试\n在存在局部变量num=2的情况下执行下面代码\n@Test public void taskComplete5() { ProcessEngine processEngine = ProcessEngines.getDefaultProcessEngine(); TaskService taskService = processEngine.getTaskService(); Task task = taskService.createTaskQuery() .includeProcessVariables() .processInstanceId(\u0026#34;2501\u0026#34;) .taskAssignee(\u0026#34;李四\u0026#34;) .singleResult(); // System.out.println(\u0026#34;taskId\u0026#34; + task.getId()); Map\u0026lt;String, Object\u0026gt; map = new HashMap\u0026lt;\u0026gt;(); map.put(\u0026#34;num\u0026#34;, 15); taskService.setVariables(task.getId(), map); taskService.complete(task.getId()); /*Map\u0026lt;String,Object\u0026gt; map1=new HashMap\u0026lt;\u0026gt;(); map1.put(\u0026#34;num\u0026#34;,1); taskService.complete(task.getId(),map1);*/ } 会提示报错,Unknown property used in expression: ${num \u0026gt;= 3}\n//说明线条中查找的是全局变量\n在不存在局部变量num的情况下执行上面代码,会走总经理审批(num\u0026gt;3)\n在complete中加上map参数,验证明线条查找的是全局变量的值,complete带上variables会设置全局变量\n@Test public void taskComplete5() { ProcessEngine processEngine = ProcessEngines.getDefaultProcessEngine(); TaskService taskService = processEngine.getTaskService(); Task task = taskService.createTaskQuery() .includeProcessVariables() .processInstanceId(\u0026#34;2501\u0026#34;) .taskAssignee(\u0026#34;李四\u0026#34;) .singleResult(); // System.out.println(\u0026#34;taskId\u0026#34; + task.getId()); Map\u0026lt;String, Object\u0026gt; map = new HashMap\u0026lt;\u0026gt;(); map.put(\u0026#34;num\u0026#34;, 15); // taskService.setVariables(task.getId(), map); taskService.complete(task.getId(),map); /*Map\u0026lt;String,Object\u0026gt; map1=new HashMap\u0026lt;\u0026gt;(); map1.put(\u0026#34;num\u0026#34;,1); taskService.complete(task.getId(),map1);*/ } 数据库表 act_hi_varinst 里面看得到局部变量\n"},{"id":390,"href":"/zh/docs/technology/Flowable/boge_blbl_/02-advance_2/","title":"boge-02-flowable进阶_2","section":"基础(波哥)_","content":" Service服务接口 # 各个Service类 RepositoryService 资源管理类,流程定义、部署、文件 RuntimeService 流程运行管理类,运行过程中(执行) TaskService 任务管理类 HistoryService 历史管理类 ManagerService 引擎管理类 Flowable图标 # BPMN2.0定义的一些图标\n时间 活动 网关 流程部署深入解析 # 使用eclipse打包部署(没有eclipse环境,所以这里只有截图) 将两个流程,打包为bar文件,然后放到项目resources文件夹中 这里是为了测试一次部署多个流程(定义,图) 代码如下 部署完成后查看表结构\nact_re_procdef\n部署id一样 act_re_deployment 结论:部署和定义是1对多的关系\n每次部署所涉及到的资源文件 涉及到的三张表\nact_ge_bytearray act_re_procdef category\u0026ndash;\u0026gt;xml中的namespace name\u0026ndash;\u0026gt;定义时起的名称 key_\u0026mdash;\u0026gt;xml中定义的id resource_name\u0026mdash;\u0026gt;xml文件名称 dgrm_resource_name\u0026ndash;\u0026gt;生成图片名称 suspension_state \u0026ndash;\u0026gt; 是否被挂起\ntenant_id \u0026ndash; \u0026gt;谁部署的流程\nact_re_deployment name_部署名\n代码 主要源码 DeployCmd.class DeploymentEntityManagerImpl.java insert()方法 插入并执行资源 点开里面的insert方法 AbstractDataManger.insert() 回到test类,deploy()方法最终就是完成了表结构的数据的操作(通过Mybatis)\n流程的挂起和激活 # xml文件\n\u0026lt;?xml version=\u0026#34;1.0\u0026#34; encoding=\u0026#34;UTF-8\u0026#34;?\u0026gt; \u0026lt;definitions xmlns=\u0026#34;http://www.omg.org/spec/BPMN/20100524/MODEL\u0026#34; xmlns:xsi=\u0026#34;http://www.w3.org/2001/XMLSchema-instance\u0026#34; xmlns:xsd=\u0026#34;http://www.w3.org/2001/XMLSchema\u0026#34; xmlns:bpmndi=\u0026#34;http://www.omg.org/spec/BPMN/20100524/DI\u0026#34; xmlns:omgdc=\u0026#34;http://www.omg.org/spec/DD/20100524/DC\u0026#34; xmlns:omgdi=\u0026#34;http://www.omg.org/spec/DD/20100524/DI\u0026#34; xmlns:flowable=\u0026#34;http://flowable.org/bpmn\u0026#34; typeLanguage=\u0026#34;http://www.w3.org/2001/XMLSchema\u0026#34; expressionLanguage=\u0026#34;http://www.w3.org/1999/XPath\u0026#34; targetNamespace=\u0026#34;http://www.flowable.org/processdef\u0026#34;\u0026gt; \u0026lt;!--id process key--\u0026gt; \u0026lt;process id=\u0026#34;holidayRequest\u0026#34; name=\u0026#34;请假流程\u0026#34; isExecutable=\u0026#34;true\u0026#34;\u0026gt; \u0026lt;startEvent id=\u0026#34;startEvent\u0026#34;/\u0026gt; \u0026lt;!--sequenceFlow表示的是线条箭头--\u0026gt; \u0026lt;sequenceFlow sourceRef=\u0026#34;startEvent\u0026#34; targetRef=\u0026#34;approveTask\u0026#34;/\u0026gt; \u0026lt;userTask id=\u0026#34;approveTask\u0026#34; name=\u0026#34;同意或者拒绝请假\u0026#34; flowable:assignee=\u0026#34;zhangsan\u0026#34;/\u0026gt; \u0026lt;sequenceFlow sourceRef=\u0026#34;approveTask\u0026#34; targetRef=\u0026#34;decision\u0026#34;/\u0026gt; \u0026lt;!--网关--\u0026gt; \u0026lt;exclusiveGateway id=\u0026#34;decision\u0026#34;/\u0026gt; \u0026lt;sequenceFlow sourceRef=\u0026#34;decision\u0026#34; targetRef=\u0026#34;externalSystemCall\u0026#34;\u0026gt; \u0026lt;!--条件--\u0026gt; \u0026lt;conditionExpression xsi:type=\u0026#34;tFormalExpression\u0026#34;\u0026gt; \u0026lt;![CDATA[ ${approved} ]]\u0026gt; \u0026lt;/conditionExpression\u0026gt; \u0026lt;/sequenceFlow\u0026gt; \u0026lt;sequenceFlow sourceRef=\u0026#34;decision\u0026#34; targetRef=\u0026#34;sendRejectionMail\u0026#34;\u0026gt; \u0026lt;!--条件--\u0026gt; \u0026lt;conditionExpression xsi:type=\u0026#34;tFormalExpression\u0026#34;\u0026gt; \u0026lt;![CDATA[ ${!approved} ]]\u0026gt; \u0026lt;/conditionExpression\u0026gt; \u0026lt;/sequenceFlow\u0026gt; \u0026lt;serviceTask id=\u0026#34;externalSystemCall\u0026#34; name=\u0026#34;Enter holidays in external system\u0026#34; flowable:class=\u0026#34;org.flowable.CallExternalSystemDelegate\u0026#34;/\u0026gt; \u0026lt;sequenceFlow sourceRef=\u0026#34;externalSystemCall\u0026#34; targetRef=\u0026#34;holidayApprovedTask\u0026#34;/\u0026gt; \u0026lt;userTask id=\u0026#34;holidayApprovedTask\u0026#34; name=\u0026#34;Holiday approved\u0026#34; flowable:assignee=\u0026#34;lisi\u0026#34;/\u0026gt; \u0026lt;sequenceFlow sourceRef=\u0026#34;holidayApprovedTask\u0026#34; targetRef=\u0026#34;approveEnd\u0026#34;/\u0026gt; \u0026lt;!--发送一个邮件--\u0026gt; \u0026lt;serviceTask id=\u0026#34;sendRejectionMail\u0026#34; name=\u0026#34;Send out rejection email\u0026#34; flowable:class=\u0026#34;org.flowable.SendRejectionMail\u0026#34;/\u0026gt; \u0026lt;sequenceFlow sourceRef=\u0026#34;sendRejectionMail\u0026#34; targetRef=\u0026#34;rejectEnd\u0026#34;/\u0026gt; \u0026lt;endEvent id=\u0026#34;approveEnd\u0026#34;/\u0026gt; \u0026lt;endEvent id=\u0026#34;rejectEnd\u0026#34;/\u0026gt; \u0026lt;/process\u0026gt; \u0026lt;/definitions\u0026gt; 部署的流程默认情况下为激活,如果不想使用该定义的流程,那么可以挂起该流程,当然该流程定义下边所有的流程实例全部暂停。\n流程定义被定义为挂起,该流程定义将不允许启动新的流程实例,且该流程定义下所有的流程实例将被全部挂起暂停执行\n表结构 act_re_procdef表中的SUSPENSION_STATE字段来表示1激活,2挂起\n挂起流程\n@Test public void testSuspend() { ProcessEngine engine = ProcessEngines.getDefaultProcessEngine(); RepositoryService repositoryService = engine.getRepositoryService(); //找到流程定义 ProcessDefinition processDefinition = repositoryService. createProcessDefinitionQuery().processDefinitionId(\u0026#34;holidayRequest:1:7503\u0026#34;) .singleResult(); //当前流程定义的状态 boolean suspended = processDefinition.isSuspended(); if (suspended) { //如果挂起则激活 System.out.println(\u0026#34;激活流程(定义)\u0026#34; + processDefinition.getId() + \u0026#34;name:\u0026#34; + processDefinition .getName()); repositoryService.activateProcessDefinitionById(processDefinition.getId()); } else { //如果激活则挂起 System.out.println(\u0026#34;挂起流程(定义)\u0026#34; + processDefinition.getId() + \u0026#34;name:\u0026#34; + processDefinition .getName()); repositoryService.suspendProcessDefinitionById(processDefinition.getId()); } } 执行后 如果这时启动流程\n/** * 流程运行 */ @Test public void testRunProcess() { ProcessEngine processEngine = ProcessEngines.getDefaultProcessEngine();//configuration.buildProcessEngine(); RuntimeService runtimeService = processEngine.getRuntimeService(); //这边模拟表单数据(表单数据有多种处理方式,这只是其中一种) Map\u0026lt;String, Object\u0026gt; map = new HashMap\u0026lt;\u0026gt;(); map.put(\u0026#34;employee\u0026#34;, \u0026#34;张三\u0026#34;); map.put(\u0026#34;nrOfHolidays\u0026#34;, 3); map.put(\u0026#34;description\u0026#34;, \u0026#34;工作累了想出去玩\u0026#34;); ProcessInstance holidayRequest = runtimeService.startProcessInstanceByKey( \u0026#34;holidayRequest\u0026#34;, map); System.out.println(\u0026#34;流程定义的id:\u0026#34; + holidayRequest.getProcessDefinitionId()); System.out.println(\u0026#34;当前活跃id:\u0026#34; + holidayRequest.getActivityId()); System.out.println(\u0026#34;流程运行id:\u0026#34; + holidayRequest.getId()); } 则会出现异常报错信息\norg.flowable.common.engine.api.FlowableException: Cannot start process instance. Process definition 请假流程 (id = holidayRequest:1:7503) is suspended 此时再运行一次testSuspend(),将流程定义激活,此时数据库act_re_procdef表中的SUSPENSION_STATE字段值为1 再运行testRunProcess(),流程正常启动 启动流程的原理 # 流程启动\n/** * 流程运行 */ @Test public void testRunProcess() { ProcessEngine processEngine = ProcessEngines.getDefaultProcessEngine();//configuration.buildProcessEngine(); RepositoryService repositoryService = processEngine.getRepositoryService(); Deployment deploy = repositoryService.createDeployment() .addClasspathResource(\u0026#34;holiday-request.bpmn20.xml\u0026#34;) .name(\u0026#34;ly05150817部署的请假流程\u0026#34;) .deploy(); //通过部署id查找流程定义 ProcessDefinition processDefinition = repositoryService.createProcessDefinitionQuery(). deploymentId(deploy.getId()) .singleResult(); RuntimeService runtimeService = processEngine.getRuntimeService(); //这边模拟表单数据(表单数据有多种处理方式,这只是其中一种) Map\u0026lt;String, Object\u0026gt; map = new HashMap\u0026lt;\u0026gt;(); map.put(\u0026#34;employee\u0026#34;, \u0026#34;张三\u0026#34;); map.put(\u0026#34;nrOfHolidays\u0026#34;, 3); map.put(\u0026#34;description\u0026#34;, \u0026#34;工作累了想出去玩\u0026#34;); ProcessInstance holidayRequest = runtimeService.startProcessInstanceById( processDefinition.getId(), \u0026#34;order1000\u0026#34;, map); System.out.println(\u0026#34;流程定义的id:\u0026#34; + holidayRequest.getProcessDefinitionId()); System.out.println(\u0026#34;当前活跃id:\u0026#34; + holidayRequest.getActivityId()); System.out.println(\u0026#34;流程运行id:\u0026#34; + holidayRequest.getId()); } 涉及到的表:(HI中也有对应的表)\nACT_RU_EXECUTION 运行时流程执行实例 当启动一个实例的时候,这里会有两个流程执行\nACT_RU_IDENTITYLINK 运行时用户关系信息\n记录流程实例当前所处的节点\n数据库表 有几种任务处理人的类型\npublic class IdentityLinkType { public static final String ASSIGNEE = \u0026#34;assignee\u0026#34;; //指派 public static final String CANDIDATE = \u0026#34;candidate\u0026#34;;//候选 public static final String OWNER = \u0026#34;owner\u0026#34;;//拥有者 public static final String STARTER = \u0026#34;starter\u0026#34;;//启动者 public static final String PARTICIPANT = \u0026#34;participant\u0026#34;;//参与者 public static final String REACTIVATOR = \u0026#34;reactivator\u0026#34;; } ACT_RU_TASK 运行时任务表 ACT_RU_VARIABLE 运行时变量表\n处理流程的原理 # 流程处理\n@Test public void testCompleted(){ ProcessEngine processEngine=ProcessEngines.getDefaultProcessEngine(); TaskService taskService = processEngine.getTaskService(); Task task = taskService.createTaskQuery() .processInstanceId(\u0026#34;4\u0026#34;) .taskAssignee(\u0026#34;zhangsan\u0026#34;) .singleResult(); //获取当前流程实例绑定的流程变量 Map\u0026lt;String, Object\u0026gt; processVariables = task.getProcessVariables(); Set\u0026lt;String\u0026gt; keySet = processVariables.keySet(); for(String key:keySet){ Object o = processVariables.get(key); System.out.println(\u0026#34;key:\u0026#34;+key+\u0026#34;--value:\u0026#34;+o); } processVariables.put(\u0026#34;approved\u0026#34;,true);//同意 processVariables.put(\u0026#34;description\u0026#34;,\u0026#34;我被修改了\u0026#34;); taskService.complete(task.getId(),processVariables); } 这里用的是之前的xml,所以应该给一个服务监听类\npublic class CallExternalSystemDelegate implements JavaDelegate { @Override public void execute(DelegateExecution execution) { System.out.println(\u0026#34;您的请求通过了!\u0026#34;); } } 任务处理后,这里添加了一个变量,且修改了变量description 可以通过流程变量,它可以在整个流程过程中流转的[注意,这里流程结束后流程变量会不存在的,但是act_hi_variinst里面可以看到流程变量实例] //我感觉应该用表单替代 act_ru_task和act_ru_identitylink\n两者区别 ACT _ RU _ IDENTITYLINK:此表存储有关用户或组的数据及其与(流程/案例/等)实例相关的角色。该表也被其他需要身份链接的引擎使用。【显示全部,包括已完成】 ACT _ RU _ TASK:此表包含一个正在运行的实例的每个未完成用户任务的条目。然后在查询用户的任务列表时使用此表。【这里只显示运行中】 act_ru_task 记录当前实例所运行的当前节点的信息 act_ru_identitylink act_ru_execution这个表的数据不会有变动 流程结束的原理 # 流程走完\n@Test public void testCompleted1() { ProcessEngine processEngine = ProcessEngines.getDefaultProcessEngine(); TaskService taskService = processEngine.getTaskService(); Task task = taskService.createTaskQuery() .processInstanceId(\u0026#34;4\u0026#34;) .taskAssignee(\u0026#34;lisi\u0026#34;) .singleResult(); //获取当前流程实例绑定的流程变量 Map\u0026lt;String, Object\u0026gt; processVariables = task.getProcessVariables(); Set\u0026lt;String\u0026gt; keySet = processVariables.keySet(); for (String key : keySet) { Object o = processVariables.get(key); System.out.println(\u0026#34;key:\u0026#34; + key + \u0026#34;--value:\u0026#34; + o); } /* processVariables.put(\u0026#34;approved\u0026#34;,true);//拒绝 processVariables.put(\u0026#34;description\u0026#34;,\u0026#34;我被修改了\u0026#34;);*/ taskService.complete(task.getId(), processVariables); } 此时跟流程相关的数据都会被清空掉 历史数据\n变量 任务流转历史 流程实例 涉及到的用户 流程活动\n"},{"id":391,"href":"/zh/docs/problem/Idea/01/","title":"问题01","section":"Idea","content":" Cannot download sources # 在maven项目(根目录)下执行\nmvn dependency:resolve -Dclassifier=sources 会开始下载,有控制台输出,结束后再点即可\n预留 # "},{"id":392,"href":"/zh/docs/technology/Flowable/boge_blbl_/02-advance_1/","title":"boge-02-flowable进阶_1","section":"基础(波哥)_","content":" 表结构 # 尽量通过API动数据\nACT_RE:repository,包含流程定义和流程静态资源\nACT_RU: runtime,包含流程实例、任务、变量等,流程结束会删除\nACT_HI: history,包含历史数据,比如历史流程实例、变量、任务等\nACT_GE: general,通用数据\nACT_ID: identity,组织机构。包含标识的信息,如用户、用户组等等\n具体的\n流程历史记录\n流程定义表 运行实例表 用户用户组表\n源码中的体现 默认的配置文件加载 # 对于\nProcessEngine defaultProcessEngine = ProcessEngines.getDefaultProcessEngine(); //--\u0026gt; public static ProcessEngine getDefaultProcessEngine() { return getProcessEngine(NAME_DEFAULT); //NAME_DEFAULT = \u0026#34;default\u0026#34; } //--\u0026gt; public static ProcessEngine getProcessEngine(String processEngineName) { if (!isInitialized()) { init(); } return processEngines.get(processEngineName); } //--\u0026gt;部分 /** * Initializes all process engines that can be found on the classpath for resources \u0026lt;code\u0026gt;flowable.cfg.xml\u0026lt;/code\u0026gt; (plain Flowable style configuration) and for resources * \u0026lt;code\u0026gt;flowable-context.xml\u0026lt;/code\u0026gt; (Spring style configuration). */ public static synchronized void init() { if (!isInitialized()) { if (processEngines == null) { // Create new map to store process-engines if current map is null processEngines = new HashMap\u0026lt;\u0026gt;(); } ClassLoader classLoader = ReflectUtil.getClassLoader(); Enumeration\u0026lt;URL\u0026gt; resources = null; try { resources = classLoader.getResources(\u0026#34;flowable.cfg.xml\u0026#34;); } catch (IOException e) { throw new FlowableIllegalArgumentException(\u0026#34;problem retrieving flowable.cfg.xml resources on the classpath: \u0026#34; + System.getProperty(\u0026#34;java.class.path\u0026#34;), e); } //后面还有,每帖出来 } } 注意这行classLoader.getResources(\u0026quot;flowable.cfg.xml\u0026quot;); 需要在resources根目录下放这么一个文件\n\u0026lt;?xml version=\u0026#34;1.0\u0026#34; encoding=\u0026#34;UTF-8\u0026#34;?\u0026gt; \u0026lt;beans xmlns=\u0026#34;http://www.springframework.org/schema/beans\u0026#34; xmlns:xsi=\u0026#34;http://www.w3.org/2001/XMLSchema-instance\u0026#34; xsi:schemaLocation=\u0026#34;http://www.springframework.org/schema/beans http://www.springframework.org/schema/beans/spring-beans.xsd\u0026#34;\u0026gt; \u0026lt;bean id=\u0026#34;processEngineConfiguration\u0026#34; class=\u0026#34;org.flowable.engine.impl.cfg.StandaloneProcessEngineConfiguration\u0026#34;\u0026gt; \u0026lt;property name=\u0026#34;jdbcUrl\u0026#34; value=\u0026#34;jdbc:mysql://localhost:3306/flow1?useUnicode=true\u0026amp;amp;characterEncoding=utf-8\u0026amp;amp;allowMultiQueries=true\u0026amp;amp;nullCatalogMeansCurrent=true\u0026#34;/\u0026gt; \u0026lt;property name=\u0026#34;jdbcDriver\u0026#34; value=\u0026#34;com.mysql.cj.jdbc.Driver\u0026#34;/\u0026gt; \u0026lt;property name=\u0026#34;jdbcUsername\u0026#34; value=\u0026#34;root\u0026#34;/\u0026gt; \u0026lt;property name=\u0026#34;jdbcPassword\u0026#34; value=\u0026#34;123456\u0026#34;/\u0026gt; \u0026lt;property name=\u0026#34;databaseSchemaUpdate\u0026#34; value=\u0026#34;true\u0026#34;/\u0026gt; \u0026lt;!--异步执行器--\u0026gt; \u0026lt;property name=\u0026#34;asyncExecutorActivate\u0026#34; value=\u0026#34;false\u0026#34;/\u0026gt; \u0026lt;/bean\u0026gt; \u0026lt;/beans\u0026gt; 新建数据库flow1,运行测试代码\n@Test public void processEngine2(){ ProcessEngine defaultProcessEngine = ProcessEngines.getDefaultProcessEngine(); System.out.println(defaultProcessEngine); } 此时数据库已经有表\n加载自定义名称的配置文件 # 把刚才的数据库清空,将flowable的配置文件放到目录custom/lycfg.xml中 代码\n@Test public void processEngine03(){ ProcessEngineConfiguration configuration = ProcessEngineConfiguration.createProcessEngineConfigurationFromResource(\u0026#34;custom/lycfg.xml\u0026#34;); System.out.println(configuration); ProcessEngine processEngine = configuration.buildProcessEngine(); System.out.println(processEngine); } ProcessEngine源码查看 # 源码追溯\nconfiguration.buildProcessEngine() //---\u0026gt;ProcessEngineConfigurationImpl.class @Override public ProcessEngine buildProcessEngine() { init(); ProcessEngineImpl processEngine = new ProcessEngineImpl(this); //... } //----\u0026gt;ProcessEngineImpl.class public class ProcessEngineImpl implements ProcessEngine { private static final Logger LOGGER = LoggerFactory.getLogger(ProcessEngineImpl.class); protected String name; protected RepositoryService repositoryService; protected RuntimeService runtimeService; protected HistoryService historicDataService; protected IdentityService identityService; protected TaskService taskService; protected FormService formService; protected ManagementService managementService; protected DynamicBpmnService dynamicBpmnService; protected ProcessMigrationService processInstanceMigrationService; protected AsyncExecutor asyncExecutor; protected AsyncExecutor asyncHistoryExecutor; protected CommandExecutor commandExecutor; protected Map\u0026lt;Class\u0026lt;?\u0026gt;, SessionFactory\u0026gt; sessionFactories; protected TransactionContextFactory transactionContextFactory; protected ProcessEngineConfigurationImpl processEngineConfiguration; //这里通过ProcessEngineConfigurationImpl获取各种对象 public ProcessEngineImpl(ProcessEngineConfigurationImpl processEngineConfiguration) { this.processEngineConfiguration = processEngineConfiguration; this.name = processEngineConfiguration.getEngineName(); this.repositoryService = processEngineConfiguration.getRepositoryService(); this.runtimeService = processEngineConfiguration.getRuntimeService(); this.historicDataService = processEngineConfiguration.getHistoryService(); this.identityService = processEngineConfiguration.getIdentityService(); this.taskService = processEngineConfiguration.getTaskService(); this.formService = processEngineConfiguration.getFormService(); this.managementService = processEngineConfiguration.getManagementService(); this.dynamicBpmnService = processEngineConfiguration.getDynamicBpmnService(); this.processInstanceMigrationService = processEngineConfiguration.getProcessMigrationService(); this.asyncExecutor = processEngineConfiguration.getAsyncExecutor(); this.asyncHistoryExecutor = processEngineConfiguration.getAsyncHistoryExecutor(); this.commandExecutor = processEngineConfiguration.getCommandExecutor(); this.sessionFactories = processEngineConfiguration.getSessionFactories(); this.transactionContextFactory = processEngineConfiguration.getTransactionContextFactory(); } //... } //----\u0026gt;ProcessEngine.class 获取各个service服务 public interface ProcessEngine extends Engine { /** the version of the flowable library */ String VERSION = FlowableVersions.CURRENT_VERSION; /** * Starts the execuctors (async and async history), if they are configured to be auto-activated. */ void startExecutors(); RepositoryService getRepositoryService(); RuntimeService getRuntimeService(); FormService getFormService(); TaskService getTaskService(); HistoryService getHistoryService(); IdentityService getIdentityService(); ManagementService getManagementService(); DynamicBpmnService getDynamicBpmnService(); ProcessMigrationService getProcessMigrationService(); ProcessEngineConfiguration getProcessEngineConfiguration(); } ProcessEngineConfiguration中的init方法 # 源码追溯\nconfiguration.buildProcessEngine() //---\u0026gt;ProcessEngineConfigurationImpl.class @Override public ProcessEngine buildProcessEngine() { init(); ProcessEngineImpl processEngine = new ProcessEngineImpl(this); //... } //---\u0026gt;ProcessEngineConfigurationImpl.init(); public void init() { initEngineConfigurations(); initConfigurators(); configuratorsBeforeInit(); initClock(); initObjectMapper(); initProcessDiagramGenerator(); initCommandContextFactory(); initTransactionContextFactory(); initCommandExecutors(); initIdGenerator(); initHistoryLevel(); initFunctionDelegates(); initAstFunctionCreators(); initDelegateInterceptor(); initBeans(); initExpressionManager(); initAgendaFactory(); //关系型数据库 if (usingRelationalDatabase) { initDataSource();//下面拿这个举例1 } else { initNonRelationalDataSource(); } if (usingRelationalDatabase || usingSchemaMgmt) { initSchemaManager(); initSchemaManagementCommand(); } configureVariableServiceConfiguration(); configureJobServiceConfiguration(); initHelpers(); initVariableTypes(); initFormEngines(); initFormTypes(); initScriptingEngines(); initBusinessCalendarManager(); initServices(); initWsdlImporterFactory(); initBehaviorFactory(); initListenerFactory(); initBpmnParser(); initProcessDefinitionCache(); initProcessDefinitionInfoCache(); initAppResourceCache(); initKnowledgeBaseCache(); initJobHandlers(); initHistoryJobHandlers(); initTransactionFactory(); if (usingRelationalDatabase) { initSqlSessionFactory();//下面拿这个举例2 } initSessionFactories(); //相关表结构操作 initDataManagers(); //下面拿这个举例2 initEntityManagers(); initCandidateManager(); initVariableAggregator(); initHistoryManager(); initChangeTenantIdManager(); initDynamicStateManager(); initProcessInstanceMigrationValidationManager(); initIdentityLinkInterceptor(); initJpa(); initDeployers(); initEventHandlers(); initFailedJobCommandFactory(); initEventDispatcher(); initProcessValidator(); initFormFieldHandler(); initDatabaseEventLogging(); initFlowable5CompatibilityHandler(); initVariableServiceConfiguration(); //流程变量 initIdentityLinkServiceConfiguration(); initEntityLinkServiceConfiguration(); initEventSubscriptionServiceConfiguration(); initTaskServiceConfiguration(); initJobServiceConfiguration(); initBatchServiceConfiguration(); initAsyncExecutor(); initAsyncHistoryExecutor(); configuratorsAfterInit(); afterInitTaskServiceConfiguration(); afterInitEventRegistryEventBusConsumer(); initHistoryCleaningManager(); initLocalizationManagers(); } //---\u0026gt;AbstractEngineConfiguration //----\u0026gt;AbstractEngineConfiguration.initDataSrouce() public static Properties getDefaultDatabaseTypeMappings() { Properties databaseTypeMappings = new Properties(); databaseTypeMappings.setProperty(\u0026#34;H2\u0026#34;, DATABASE_TYPE_H2); databaseTypeMappings.setProperty(\u0026#34;HSQL Database Engine\u0026#34;, DATABASE_TYPE_HSQL); databaseTypeMappings.setProperty(\u0026#34;MySQL\u0026#34;, DATABASE_TYPE_MYSQL); databaseTypeMappings.setProperty(\u0026#34;MariaDB\u0026#34;, DATABASE_TYPE_MYSQL); databaseTypeMappings.setProperty(\u0026#34;Oracle\u0026#34;, DATABASE_TYPE_ORACLE); databaseTypeMappings.setProperty(PRODUCT_NAME_POSTGRES, DATABASE_TYPE_POSTGRES); databaseTypeMappings.setProperty(\u0026#34;Microsoft SQL Server\u0026#34;, DATABASE_TYPE_MSSQL); databaseTypeMappings.setProperty(DATABASE_TYPE_DB2, DATABASE_TYPE_DB2); databaseTypeMappings.setProperty(\u0026#34;DB2\u0026#34;, DATABASE_TYPE_DB2); databaseTypeMappings.setProperty(\u0026#34;DB2/NT\u0026#34;, DATABASE_TYPE_DB2); databaseTypeMappings.setProperty(\u0026#34;DB2/NT64\u0026#34;, DATABASE_TYPE_DB2); databaseTypeMappings.setProperty(\u0026#34;DB2 UDP\u0026#34;, DATABASE_TYPE_DB2); databaseTypeMappings.setProperty(\u0026#34;DB2/LINUX\u0026#34;, DATABASE_TYPE_DB2); databaseTypeMappings.setProperty(\u0026#34;DB2/LINUX390\u0026#34;, DATABASE_TYPE_DB2); databaseTypeMappings.setProperty(\u0026#34;DB2/LINUXX8664\u0026#34;, DATABASE_TYPE_DB2); databaseTypeMappings.setProperty(\u0026#34;DB2/LINUXZ64\u0026#34;, DATABASE_TYPE_DB2); databaseTypeMappings.setProperty(\u0026#34;DB2/LINUXPPC64\u0026#34;, DATABASE_TYPE_DB2); databaseTypeMappings.setProperty(\u0026#34;DB2/LINUXPPC64LE\u0026#34;, DATABASE_TYPE_DB2); databaseTypeMappings.setProperty(\u0026#34;DB2/400 SQL\u0026#34;, DATABASE_TYPE_DB2); databaseTypeMappings.setProperty(\u0026#34;DB2/6000\u0026#34;, DATABASE_TYPE_DB2); databaseTypeMappings.setProperty(\u0026#34;DB2 UDB iSeries\u0026#34;, DATABASE_TYPE_DB2); databaseTypeMappings.setProperty(\u0026#34;DB2/AIX64\u0026#34;, DATABASE_TYPE_DB2); databaseTypeMappings.setProperty(\u0026#34;DB2/HPUX\u0026#34;, DATABASE_TYPE_DB2); databaseTypeMappings.setProperty(\u0026#34;DB2/HP64\u0026#34;, DATABASE_TYPE_DB2); databaseTypeMappings.setProperty(\u0026#34;DB2/SUN\u0026#34;, DATABASE_TYPE_DB2); databaseTypeMappings.setProperty(\u0026#34;DB2/SUN64\u0026#34;, DATABASE_TYPE_DB2); databaseTypeMappings.setProperty(\u0026#34;DB2/PTX\u0026#34;, DATABASE_TYPE_DB2); databaseTypeMappings.setProperty(\u0026#34;DB2/2\u0026#34;, DATABASE_TYPE_DB2); databaseTypeMappings.setProperty(\u0026#34;DB2 UDB AS400\u0026#34;, DATABASE_TYPE_DB2); databaseTypeMappings.setProperty(PRODUCT_NAME_CRDB, DATABASE_TYPE_COCKROACHDB); return databaseTypeMappings; } //initDataSource(); protected void initDataSource() { if (dataSource == null) { if (dataSourceJndiName != null) { try { dataSource = (DataSource) new InitialContext().lookup(dataSourceJndiName); } catch (Exception e) { throw new FlowableException(\u0026#34;couldn\u0026#39;t lookup datasource from \u0026#34; + dataSourceJndiName + \u0026#34;: \u0026#34; + e.getMessage(), e); } } else if (jdbcUrl != null) { if ((jdbcDriver == null) || (jdbcUsername == null)) { throw new FlowableException(\u0026#34;DataSource or JDBC properties have to be specified in a process engine configuration\u0026#34;); } logger.debug(\u0026#34;initializing datasource to db: {}\u0026#34;, jdbcUrl); if (logger.isInfoEnabled()) { logger.info(\u0026#34;Configuring Datasource with following properties (omitted password for security)\u0026#34;); logger.info(\u0026#34;datasource driver : {}\u0026#34;, jdbcDriver); logger.info(\u0026#34;datasource url : {}\u0026#34;, jdbcUrl); logger.info(\u0026#34;datasource user name : {}\u0026#34;, jdbcUsername); } PooledDataSource pooledDataSource = new PooledDataSource(this.getClass().getClassLoader(), jdbcDriver, jdbcUrl, jdbcUsername, jdbcPassword); if (jdbcMaxActiveConnections \u0026gt; 0) { pooledDataSource.setPoolMaximumActiveConnections(jdbcMaxActiveConnections); } if (jdbcMaxIdleConnections \u0026gt; 0) { pooledDataSource.setPoolMaximumIdleConnections(jdbcMaxIdleConnections); } if (jdbcMaxCheckoutTime \u0026gt; 0) { pooledDataSource.setPoolMaximumCheckoutTime(jdbcMaxCheckoutTime); } if (jdbcMaxWaitTime \u0026gt; 0) { pooledDataSource.setPoolTimeToWait(jdbcMaxWaitTime); } if (jdbcPingEnabled) { pooledDataSource.setPoolPingEnabled(true); if (jdbcPingQuery != null) { pooledDataSource.setPoolPingQuery(jdbcPingQuery); } pooledDataSource.setPoolPingConnectionsNotUsedFor(jdbcPingConnectionNotUsedFor); } if (jdbcDefaultTransactionIsolationLevel \u0026gt; 0) { pooledDataSource.setDefaultTransactionIsolationLevel(jdbcDefaultTransactionIsolationLevel); } dataSource = pooledDataSource; } } if (databaseType == null) { initDatabaseType(); } } //initSqlSessionFactory(); public void initSqlSessionFactory() { if (sqlSessionFactory == null) { InputStream inputStream = null; try { //获取MyBatis配置文件信息 inputStream = getMyBatisXmlConfigurationStream(); Environment environment = new Environment(\u0026#34;default\u0026#34;, transactionFactory, dataSource); Reader reader = new InputStreamReader(inputStream); Properties properties = new Properties(); properties.put(\u0026#34;prefix\u0026#34;, databaseTablePrefix); String wildcardEscapeClause = \u0026#34;\u0026#34;; if ((databaseWildcardEscapeCharacter != null) \u0026amp;\u0026amp; (databaseWildcardEscapeCharacter.length() != 0)) { wildcardEscapeClause = \u0026#34; escape \u0026#39;\u0026#34; + databaseWildcardEscapeCharacter + \u0026#34;\u0026#39;\u0026#34;; } properties.put(\u0026#34;wildcardEscapeClause\u0026#34;, wildcardEscapeClause); // set default properties properties.put(\u0026#34;limitBefore\u0026#34;, \u0026#34;\u0026#34;); properties.put(\u0026#34;limitAfter\u0026#34;, \u0026#34;\u0026#34;); properties.put(\u0026#34;limitBetween\u0026#34;, \u0026#34;\u0026#34;); properties.put(\u0026#34;limitBeforeNativeQuery\u0026#34;, \u0026#34;\u0026#34;); properties.put(\u0026#34;limitAfterNativeQuery\u0026#34;, \u0026#34;\u0026#34;); properties.put(\u0026#34;blobType\u0026#34;, \u0026#34;BLOB\u0026#34;); properties.put(\u0026#34;boolValue\u0026#34;, \u0026#34;TRUE\u0026#34;); if (databaseType != null) { properties.load(getResourceAsStream(pathToEngineDbProperties())); } //Mybatis相关的配置 Configuration configuration = initMybatisConfiguration(environment, reader, properties); sqlSessionFactory = new DefaultSqlSessionFactory(configuration); } catch (Exception e) { throw new FlowableException(\u0026#34;Error while building ibatis SqlSessionFactory: \u0026#34; + e.getMessage(), e); } finally { IoUtil.closeSilently(inputStream); } } } //ProcessEngineConfigurationImpl.getMyBatisXmlConfigurationStream(); @Override public InputStream getMyBatisXmlConfigurationStream() { return getResourceAsStream(mybatisMappingFile); } //代码往上翻 //构造器中 public ProcessEngineConfigurationImpl() { mybatisMappingFile = DEFAULT_MYBATIS_MAPPING_FILE; } //其中 public static final String DEFAULT_MYBATIS_MAPPING_FILE = \u0026#34;org/flowable/db/mapping/mappings.xml\u0026#34;; 查找映射文件 mappings.xml\n\u0026lt;?xml version=\u0026#34;1.0\u0026#34; encoding=\u0026#34;UTF-8\u0026#34;?\u0026gt; \u0026lt;!DOCTYPE configuration PUBLIC \u0026#34;-//mybatis.org//DTD Config 3.0//EN\u0026#34; \u0026#34;http://mybatis.org/dtd/mybatis-3-config.dtd\u0026#34;\u0026gt; \u0026lt;configuration\u0026gt; \u0026lt;settings\u0026gt; \u0026lt;setting name=\u0026#34;lazyLoadingEnabled\u0026#34; value=\u0026#34;false\u0026#34; /\u0026gt; \u0026lt;/settings\u0026gt; \u0026lt;typeAliases\u0026gt; \u0026lt;typeAlias type=\u0026#34;org.flowable.common.engine.impl.persistence.entity.ByteArrayRefTypeHandler\u0026#34; alias=\u0026#34;ByteArrayRefTypeHandler\u0026#34; /\u0026gt; \u0026lt;typeAlias type=\u0026#34;org.flowable.common.engine.impl.persistence.entity.ByteArrayRefTypeHandler\u0026#34; alias=\u0026#34;VariableByteArrayRefTypeHandler\u0026#34; /\u0026gt; \u0026lt;typeAlias type=\u0026#34;org.flowable.common.engine.impl.persistence.entity.ByteArrayRefTypeHandler\u0026#34; alias=\u0026#34;JobByteArrayRefTypeHandler\u0026#34; /\u0026gt; \u0026lt;typeAlias type=\u0026#34;org.flowable.common.engine.impl.persistence.entity.ByteArrayRefTypeHandler\u0026#34; alias=\u0026#34;BatchByteArrayRefTypeHandler\u0026#34; /\u0026gt; \u0026lt;/typeAliases\u0026gt; \u0026lt;typeHandlers\u0026gt; \u0026lt;typeHandler handler=\u0026#34;ByteArrayRefTypeHandler\u0026#34; javaType=\u0026#34;org.flowable.common.engine.impl.persistence.entity.ByteArrayRef\u0026#34; jdbcType=\u0026#34;VARCHAR\u0026#34; /\u0026gt; \u0026lt;typeHandler handler=\u0026#34;VariableByteArrayRefTypeHandler\u0026#34; javaType=\u0026#34;org.flowable.common.engine.impl.persistence.entity.ByteArrayRef\u0026#34; jdbcType=\u0026#34;VARCHAR\u0026#34; /\u0026gt; \u0026lt;typeHandler handler=\u0026#34;JobByteArrayRefTypeHandler\u0026#34; javaType=\u0026#34;org.flowable.common.engine.impl.persistence.entity.ByteArrayRef\u0026#34; jdbcType=\u0026#34;VARCHAR\u0026#34; /\u0026gt; \u0026lt;typeHandler handler=\u0026#34;BatchByteArrayRefTypeHandler\u0026#34; javaType=\u0026#34;org.flowable.common.engine.impl.persistence.entity.ByteArrayRef\u0026#34; jdbcType=\u0026#34;VARCHAR\u0026#34; /\u0026gt; \u0026lt;/typeHandlers\u0026gt; \u0026lt;mappers\u0026gt; \u0026lt;mapper resource=\u0026#34;org/flowable/db/mapping/ChangeTenantBpmn.xml\u0026#34; /\u0026gt; \u0026lt;mapper resource=\u0026#34;org/flowable/db/mapping/entity/Attachment.xml\u0026#34; /\u0026gt; \u0026lt;mapper resource=\u0026#34;org/flowable/db/mapping/entity/Comment.xml\u0026#34; /\u0026gt; \u0026lt;mapper resource=\u0026#34;org/flowable/job/service/db/mapping/entity/DeadLetterJob.xml\u0026#34; /\u0026gt; \u0026lt;mapper resource=\u0026#34;org/flowable/db/mapping/entity/Deployment.xml\u0026#34; /\u0026gt; \u0026lt;mapper resource=\u0026#34;org/flowable/db/mapping/entity/Execution.xml\u0026#34; /\u0026gt; \u0026lt;mapper resource=\u0026#34;org/flowable/db/mapping/entity/ActivityInstance.xml\u0026#34; /\u0026gt; \u0026lt;mapper resource=\u0026#34;org/flowable/db/mapping/entity/HistoricActivityInstance.xml\u0026#34; /\u0026gt; \u0026lt;mapper resource=\u0026#34;org/flowable/db/mapping/entity/HistoricDetail.xml\u0026#34; /\u0026gt; \u0026lt;mapper resource=\u0026#34;org/flowable/db/mapping/entity/HistoricProcessInstance.xml\u0026#34; /\u0026gt; \u0026lt;mapper resource=\u0026#34;org/flowable/variable/service/db/mapping/entity/HistoricVariableInstance.xml\u0026#34; /\u0026gt; \u0026lt;mapper resource=\u0026#34;org/flowable/task/service/db/mapping/entity/HistoricTaskInstance.xml\u0026#34; /\u0026gt; \u0026lt;mapper resource=\u0026#34;org/flowable/task/service/db/mapping/entity/HistoricTaskLogEntry.xml\u0026#34; /\u0026gt; \u0026lt;mapper resource=\u0026#34;org/flowable/identitylink/service/db/mapping/entity/HistoricIdentityLink.xml\u0026#34; /\u0026gt; \u0026lt;mapper resource=\u0026#34;org/flowable/entitylink/service/db/mapping/entity/HistoricEntityLink.xml\u0026#34; /\u0026gt; \u0026lt;mapper resource=\u0026#34;org/flowable/job/service/db/mapping/entity/HistoryJob.xml\u0026#34; /\u0026gt; \u0026lt;mapper resource=\u0026#34;org/flowable/identitylink/service/db/mapping/entity/IdentityLink.xml\u0026#34; /\u0026gt; \u0026lt;mapper resource=\u0026#34;org/flowable/entitylink/service/db/mapping/entity/EntityLink.xml\u0026#34; /\u0026gt; \u0026lt;mapper resource=\u0026#34;org/flowable/job/service/db/mapping/entity/Job.xml\u0026#34; /\u0026gt; \u0026lt;mapper resource=\u0026#34;org/flowable/db/mapping/entity/Model.xml\u0026#34; /\u0026gt; \u0026lt;mapper resource=\u0026#34;org/flowable/db/mapping/entity/ProcessDefinition.xml\u0026#34; /\u0026gt; \u0026lt;mapper resource=\u0026#34;org/flowable/db/mapping/entity/ProcessDefinitionInfo.xml\u0026#34; /\u0026gt; \u0026lt;mapper resource=\u0026#34;org/flowable/common/db/mapping/entity/Property.xml\u0026#34; /\u0026gt; \u0026lt;mapper resource=\u0026#34;org/flowable/common/db/mapping/entity/ByteArray.xml\u0026#34; /\u0026gt; \u0026lt;mapper resource=\u0026#34;org/flowable/common/db/mapping/common.xml\u0026#34; /\u0026gt; \u0026lt;mapper resource=\u0026#34;org/flowable/db/mapping/entity/Resource.xml\u0026#34; /\u0026gt; \u0026lt;mapper resource=\u0026#34;org/flowable/job/service/db/mapping/entity/SuspendedJob.xml\u0026#34; /\u0026gt; \u0026lt;mapper resource=\u0026#34;org/flowable/job/service/db/mapping/entity/ExternalWorkerJob.xml\u0026#34; /\u0026gt; \u0026lt;mapper resource=\u0026#34;org/flowable/common/db/mapping/entity/TableData.xml\u0026#34; /\u0026gt; \u0026lt;mapper resource=\u0026#34;org/flowable/task/service/db/mapping/entity/Task.xml\u0026#34; /\u0026gt; \u0026lt;mapper resource=\u0026#34;org/flowable/job/service/db/mapping/entity/TimerJob.xml\u0026#34; /\u0026gt; \u0026lt;mapper resource=\u0026#34;org/flowable/variable/service/db/mapping/entity/VariableInstance.xml\u0026#34; /\u0026gt; \u0026lt;mapper resource=\u0026#34;org/flowable/eventsubscription/service/db/mapping/entity/EventSubscription.xml\u0026#34; /\u0026gt; \u0026lt;mapper resource=\u0026#34;org/flowable/db/mapping/entity/EventLogEntry.xml\u0026#34; /\u0026gt; \u0026lt;mapper resource=\u0026#34;org/flowable/batch/service/db/mapping/entity/Batch.xml\u0026#34; /\u0026gt; \u0026lt;mapper resource=\u0026#34;org/flowable/batch/service/db/mapping/entity/BatchPart.xml\u0026#34; /\u0026gt; \u0026lt;/mappers\u0026gt; \u0026lt;/configuration\u0026gt; 源码\n//ProcessEnginConfigurationImpl.init()中的代码 initDataManagers(); //下面拿这个举例3 //-\u0026gt;\u0026gt;\u0026gt; @Override @SuppressWarnings(\u0026#34;rawtypes\u0026#34;) public void initDataManagers() { super.initDataManagers(); if (attachmentDataManager == null) { attachmentDataManager = new MybatisAttachmentDataManager(this); } if (commentDataManager == null) { commentDataManager = new MybatisCommentDataManager(this); } if (deploymentDataManager == null) { //下面拿这个查看 deploymentDataManager = new MybatisDeploymentDataManager(this); } if (eventLogEntryDataManager == null) { eventLogEntryDataManager = new MybatisEventLogEntryDataManager(this); } if (executionDataManager == null) { executionDataManager = new MybatisExecutionDataManager(this); } if (dbSqlSessionFactory != null \u0026amp;\u0026amp; executionDataManager instanceof AbstractDataManager) { dbSqlSessionFactory.addLogicalEntityClassMapping(\u0026#34;execution\u0026#34;, ((AbstractDataManager) executionDataManager).getManagedEntityClass()); } if (historicActivityInstanceDataManager == null) { historicActivityInstanceDataManager = new MybatisHistoricActivityInstanceDataManager(this); } if (activityInstanceDataManager == null) { activityInstanceDataManager = new MybatisActivityInstanceDataManager(this); } if (historicDetailDataManager == null) { historicDetailDataManager = new MybatisHistoricDetailDataManager(this); } if (historicProcessInstanceDataManager == null) { historicProcessInstanceDataManager = new MybatisHistoricProcessInstanceDataManager(this); } if (modelDataManager == null) { modelDataManager = new MybatisModelDataManager(this); } if (processDefinitionDataManager == null) { processDefinitionDataManager = new MybatisProcessDefinitionDataManager(this); } if (processDefinitionInfoDataManager == null) { processDefinitionInfoDataManager = new MybatisProcessDefinitionInfoDataManager(this); } if (resourceDataManager == null) { resourceDataManager = new MybatisResourceDataManager(this); } } //--\u0026gt;MybatisDeploymentDataManager,这个类相当于mybatis中的mapper /** * @author Joram Barrez */ public class MybatisDeploymentDataManager extends AbstractProcessDataManager\u0026lt;DeploymentEntity\u0026gt; implements DeploymentDataManager { public MybatisDeploymentDataManager(ProcessEngineConfigurationImpl processEngineConfiguration) { super(processEngineConfiguration); } @Override public Class\u0026lt;? extends DeploymentEntity\u0026gt; getManagedEntityClass() { return DeploymentEntityImpl.class; } @Override public DeploymentEntity create() { return new DeploymentEntityImpl(); } @Override public long findDeploymentCountByQueryCriteria(DeploymentQueryImpl deploymentQuery) { return (Long) getDbSqlSession().selectOne(\u0026#34;selectDeploymentCountByQueryCriteria\u0026#34;, deploymentQuery); } @Override @SuppressWarnings(\u0026#34;unchecked\u0026#34;) public List\u0026lt;Deployment\u0026gt; findDeploymentsByQueryCriteria(DeploymentQueryImpl deploymentQuery) { final String query = \u0026#34;selectDeploymentsByQueryCriteria\u0026#34;; return getDbSqlSession().selectList(query, deploymentQuery); } @Override public List\u0026lt;String\u0026gt; getDeploymentResourceNames(String deploymentId) { return getDbSqlSession().getSqlSession().selectList(\u0026#34;selectResourceNamesByDeploymentId\u0026#34;, deploymentId); } @Override @SuppressWarnings(\u0026#34;unchecked\u0026#34;) public List\u0026lt;Deployment\u0026gt; findDeploymentsByNativeQuery(Map\u0026lt;String, Object\u0026gt; parameterMap) { return getDbSqlSession().selectListWithRawParameter(\u0026#34;selectDeploymentByNativeQuery\u0026#34;, parameterMap); } @Override public long findDeploymentCountByNativeQuery(Map\u0026lt;String, Object\u0026gt; parameterMap) { return (Long) getDbSqlSession().selectOne(\u0026#34;selectDeploymentCountByNativeQuery\u0026#34;, parameterMap); } } ProcessEngine各种方法对比 # ProcessEngines.getDefaultProcessEngine();的方式\n/** * Initializes all process engines that can be found on the classpath for resources \u0026lt;code\u0026gt;flowable.cfg.xml\u0026lt;/code\u0026gt; (plain Flowable style configuration) and for resources * \u0026lt;code\u0026gt;flowable-context.xml\u0026lt;/code\u0026gt; (Spring style configuration). */ public static synchronized void init() { if (!isInitialized()) { if (processEngines == null) { // Create new map to store process-engines if current map is null processEngines = new HashMap\u0026lt;\u0026gt;(); } ClassLoader classLoader = ReflectUtil.getClassLoader(); Enumeration\u0026lt;URL\u0026gt; resources = null; try { resources = classLoader.getResources(\u0026#34;flowable.cfg.xml\u0026#34;); } catch (IOException e) { throw new FlowableIllegalArgumentException(\u0026#34;problem retrieving flowable.cfg.xml resources on the classpath: \u0026#34; + System.getProperty(\u0026#34;java.class.path\u0026#34;), e); } // Remove duplicated configuration URL\u0026#39;s using set. Some // classloaders may return identical URL\u0026#39;s twice, causing duplicate // startups Set\u0026lt;URL\u0026gt; configUrls = new HashSet\u0026lt;\u0026gt;(); while (resources.hasMoreElements()) { configUrls.add(resources.nextElement()); } for (URL resource : configUrls) { LOGGER.info(\u0026#34;Initializing process engine using configuration \u0026#39;{}\u0026#39;\u0026#34;, resource); initProcessEngineFromResource(resource); //注意这个 } try { resources = classLoader.getResources(\u0026#34;flowable-context.xml\u0026#34;); } catch (IOException e) { throw new FlowableIllegalArgumentException(\u0026#34;problem retrieving flowable-context.xml resources on the classpath: \u0026#34; + System.getProperty(\u0026#34;java.class.path\u0026#34;), e); } while (resources.hasMoreElements()) { URL resource = resources.nextElement(); LOGGER.info(\u0026#34;Initializing process engine using Spring configuration \u0026#39;{}\u0026#39;\u0026#34;, resource); initProcessEngineFromSpringResource(resource); } setInitialized(true); } else { LOGGER.info(\u0026#34;Process engines already initialized\u0026#34;); } } 可以通过Spring配置文件的方式\ninitProcessEngineFromResource(resource); //注意这个 private static EngineInfo initProcessEngineFromResource(URL resourceUrl) { EngineInfo processEngineInfo = processEngineInfosByResourceUrl.get(resourceUrl.toString()); // if there is an existing process engine info if (processEngineInfo != null) { // remove that process engine from the member fields processEngineInfos.remove(processEngineInfo); if (processEngineInfo.getException() == null) { String processEngineName = processEngineInfo.getName(); processEngines.remove(processEngineName); processEngineInfosByName.remove(processEngineName); } processEngineInfosByResourceUrl.remove(processEngineInfo.getResourceUrl()); } String resourceUrlString = resourceUrl.toString(); try { LOGGER.info(\u0026#34;initializing process engine for resource {}\u0026#34;, resourceUrl); //注意这个 ProcessEngine processEngine = buildProcessEngine(resourceUrl); String processEngineName = processEngine.getName(); LOGGER.info(\u0026#34;initialised process engine {}\u0026#34;, processEngineName); processEngineInfo = new EngineInfo(processEngineName, resourceUrlString, null); processEngines.put(processEngineName, processEngine); processEngineInfosByName.put(processEngineName, processEngineInfo); } catch (Throwable e) { LOGGER.error(\u0026#34;Exception while initializing process engine: {}\u0026#34;, e.getMessage(), e); processEngineInfo = new EngineInfo(null, resourceUrlString, ExceptionUtils.getStackTrace(e)); } processEngineInfosByResourceUrl.put(resourceUrlString, processEngineInfo); processEngineInfos.add(processEngineInfo); return processEngineInfo; } 源码\nbuildProcessEngine(resourceUrl); // private static ProcessEngine buildProcessEngine(URL resource) { InputStream inputStream = null; try { inputStream = resource.openStream(); ProcessEngineConfiguration processEngineConfiguration = ProcessEngineConfiguration.createProcessEngineConfigurationFromInputStream(inputStream); return processEngineConfiguration.buildProcessEngine(); } catch (IOException e) { throw new FlowableIllegalArgumentException(\u0026#34;couldn\u0026#39;t open resource stream: \u0026#34; + e.getMessage(), e); } finally { IoUtil.closeSilently(inputStream); } } "},{"id":393,"href":"/zh/docs/technology/Flowable/boge_blbl_/01-base/","title":"boge-01-flowable基础","section":"基础(波哥)_","content":" Flowable介绍 # flowable的历史\nflowable是BPNM的一个基于java的软件实现,不仅包括BPMN,还有DMN决策表和CMMNCase管理引擎,并且有自己的用户管理、微服务API等\n获取Engine对象 # maven依赖\n\u0026lt;dependencies\u0026gt; \u0026lt;dependency\u0026gt; \u0026lt;groupId\u0026gt;mysql\u0026lt;/groupId\u0026gt; \u0026lt;artifactId\u0026gt;mysql-connector-java\u0026lt;/artifactId\u0026gt; \u0026lt;version\u0026gt;8.0.29\u0026lt;/version\u0026gt; \u0026lt;/dependency\u0026gt; \u0026lt;!-- https://mvnrepository.com/artifact/org.flowable/flowable-engine --\u0026gt; \u0026lt;dependency\u0026gt; \u0026lt;groupId\u0026gt;org.flowable\u0026lt;/groupId\u0026gt; \u0026lt;artifactId\u0026gt;flowable-engine\u0026lt;/artifactId\u0026gt; \u0026lt;version\u0026gt;6.7.2\u0026lt;/version\u0026gt; \u0026lt;/dependency\u0026gt; \u0026lt;!-- https://mvnrepository.com/artifact/junit/junit --\u0026gt; \u0026lt;dependency\u0026gt; \u0026lt;groupId\u0026gt;junit\u0026lt;/groupId\u0026gt; \u0026lt;artifactId\u0026gt;junit\u0026lt;/artifactId\u0026gt; \u0026lt;version\u0026gt;4.13.2\u0026lt;/version\u0026gt; \u0026lt;scope\u0026gt;test\u0026lt;/scope\u0026gt; \u0026lt;/dependency\u0026gt; \u0026lt;/dependencies\u0026gt; 配置并获取ProcessEngine\nProcessEngineConfiguration configuration= new StandaloneProcessEngineConfiguration(); //配置 configuration.setJdbcDriver(\u0026#34;com.mysql.cj.jdbc.Driver\u0026#34;); configuration.setJdbcUsername(\u0026#34;root\u0026#34;); configuration.setJdbcPassword(\u0026#34;123456\u0026#34;); //nullCatalogMeansCurrent=true 设置为只查当前连接的schema库 configuration.setJdbcUrl(\u0026#34;jdbc:mysql://localhost:3306/flowable-learn?\u0026#34; + \u0026#34;useUnicode=true\u0026amp;characterEncoding=utf-8\u0026#34; + \u0026#34;\u0026amp;allowMultiQueries=true\u0026#34; + \u0026#34;\u0026amp;nullCatalogMeansCurrent=true\u0026#34;); //如果数据库中表结构不存在则新建 configuration.setDatabaseSchemaUpdate(ProcessEngineConfiguration.DB_SCHEMA_UPDATE_TRUE); //构建ProcessEngine ProcessEngine processEngine=configuration.buildProcessEngine(); 日志和表结构介绍 # 添加slf4j依赖\n\u0026lt;!-- https://mvnrepository.com/artifact/org.slf4j/slf4j-reload4j --\u0026gt; \u0026lt;dependency\u0026gt; \u0026lt;groupId\u0026gt;org.slf4j\u0026lt;/groupId\u0026gt; \u0026lt;artifactId\u0026gt;slf4j-reload4j\u0026lt;/artifactId\u0026gt; \u0026lt;version\u0026gt;1.7.36\u0026lt;/version\u0026gt; \u0026lt;scope\u0026gt;test\u0026lt;/scope\u0026gt; \u0026lt;/dependency\u0026gt; \u0026lt;!-- https://mvnrepository.com/artifact/org.apache.logging.log4j/log4j-api --\u0026gt; \u0026lt;dependency\u0026gt; \u0026lt;groupId\u0026gt;org.apache.logging.log4j\u0026lt;/groupId\u0026gt; \u0026lt;artifactId\u0026gt;log4j-api\u0026lt;/artifactId\u0026gt; \u0026lt;version\u0026gt;2.17.2\u0026lt;/version\u0026gt; \u0026lt;/dependency\u0026gt; 添加log配置文件\nlog4j.rootLogger = DEBUG, CA log4j.appender.CA = org.apache.log4j.ConsoleAppender log4j.appender.CA.layout = org.apache.log4j.PatternLayout log4j.appender.CA.layout.ConversionPattern = %d{hh:mm:ss,SSS} {%t} %-5p %c %x - %m%n 此时再次启动就会看到一堆日志 表 流程定义文件解析 # 先通过流程绘制器绘制流程\n案例(官网,请假流程) 设计好流程之后,流程数据保存在holiday-request.bpmn20.xml文件中\n\u0026lt;?xml version=\u0026#34;1.0\u0026#34; encoding=\u0026#34;UTF-8\u0026#34;?\u0026gt; \u0026lt;definitions xmlns=\u0026#34;http://www.omg.org/spec/BPMN/20100524/MODEL\u0026#34; xmlns:xsi=\u0026#34;http://www.w3.org/2001/XMLSchema-instance\u0026#34; xmlns:xsd=\u0026#34;http://www.w3.org/2001/XMLSchema\u0026#34; xmlns:bpmndi=\u0026#34;http://www.omg.org/spec/BPMN/20100524/DI\u0026#34; xmlns:omgdc=\u0026#34;http://www.omg.org/spec/DD/20100524/DC\u0026#34; xmlns:omgdi=\u0026#34;http://www.omg.org/spec/DD/20100524/DI\u0026#34; xmlns:flowable=\u0026#34;http://flowable.org/bpmn\u0026#34; typeLanguage=\u0026#34;http://www.w3.org/2001/XMLSchema\u0026#34; expressionLanguage=\u0026#34;http://www.w3.org/1999/XPath\u0026#34; targetNamespace=\u0026#34;http://www.flowable.org/processdef\u0026#34;\u0026gt; \u0026lt;!--id process key--\u0026gt; \u0026lt;process id=\u0026#34;holidayRequest\u0026#34; name=\u0026#34;请假流程\u0026#34; isExecutable=\u0026#34;true\u0026#34;\u0026gt; \u0026lt;startEvent id=\u0026#34;startEvent\u0026#34;/\u0026gt; \u0026lt;!--sequenceFlow表示的是线条箭头--\u0026gt; \u0026lt;sequenceFlow sourceRef=\u0026#34;startEvent\u0026#34; targetRef=\u0026#34;approveTask\u0026#34;/\u0026gt; \u0026lt;userTask id=\u0026#34;approveTask\u0026#34; name=\u0026#34;同意或者拒绝请假\u0026#34;/\u0026gt; \u0026lt;sequenceFlow sourceRef=\u0026#34;approveTask\u0026#34; targetRef=\u0026#34;decision\u0026#34;/\u0026gt; \u0026lt;!--网关--\u0026gt; \u0026lt;exclusiveGateway id=\u0026#34;decision\u0026#34;/\u0026gt; \u0026lt;sequenceFlow sourceRef=\u0026#34;decision\u0026#34; targetRef=\u0026#34;externalSystemCall\u0026#34;\u0026gt; \u0026lt;!--条件--\u0026gt; \u0026lt;conditionExpression xsi:type=\u0026#34;tFormalExpression\u0026#34;\u0026gt; \u0026lt;![CDATA[ ${approved} ]]\u0026gt; \u0026lt;/conditionExpression\u0026gt; \u0026lt;/sequenceFlow\u0026gt; \u0026lt;sequenceFlow sourceRef=\u0026#34;decision\u0026#34; targetRef=\u0026#34;sendRejectionMail\u0026#34;\u0026gt; \u0026lt;!--条件--\u0026gt; \u0026lt;conditionExpression xsi:type=\u0026#34;tFormalExpression\u0026#34;\u0026gt; \u0026lt;![CDATA[ ${!approved} ]]\u0026gt; \u0026lt;/conditionExpression\u0026gt; \u0026lt;/sequenceFlow\u0026gt; \u0026lt;serviceTask id=\u0026#34;externalSystemCall\u0026#34; name=\u0026#34;Enter holidays in external system\u0026#34; flowable:class=\u0026#34;org.flowable.CallExternalSystemDelegate\u0026#34;/\u0026gt; \u0026lt;sequenceFlow sourceRef=\u0026#34;externalSystemCall\u0026#34; targetRef=\u0026#34;holidayApprovedTask\u0026#34;/\u0026gt; \u0026lt;userTask id=\u0026#34;holidayApprovedTask\u0026#34; name=\u0026#34;Holiday approved\u0026#34;/\u0026gt; \u0026lt;sequenceFlow sourceRef=\u0026#34;holidayApprovedTask\u0026#34; targetRef=\u0026#34;approveEnd\u0026#34;/\u0026gt; \u0026lt;!--发送一个邮件--\u0026gt; \u0026lt;serviceTask id=\u0026#34;sendRejectionMail\u0026#34; name=\u0026#34;Send out rejection email\u0026#34; flowable:class=\u0026#34;org.flowable.SendRejectionMail\u0026#34;/\u0026gt; \u0026lt;sequenceFlow sourceRef=\u0026#34;sendRejectionMail\u0026#34; targetRef=\u0026#34;rejectEnd\u0026#34;/\u0026gt; \u0026lt;endEvent id=\u0026#34;approveEnd\u0026#34;/\u0026gt; \u0026lt;endEvent id=\u0026#34;rejectEnd\u0026#34;/\u0026gt; \u0026lt;/process\u0026gt; \u0026lt;/definitions\u0026gt; 部署流程-代码实现 # 使用@bofore 处理测试中繁琐的配置操作\nProcessEngineConfiguration configuration = null; @Before public void before() { configuration = new StandaloneProcessEngineConfiguration(); //配置 configuration.setJdbcDriver(\u0026#34;com.mysql.cj.jdbc.Driver\u0026#34;); configuration.setJdbcUsername(\u0026#34;root\u0026#34;); configuration.setJdbcPassword(\u0026#34;123456\u0026#34;); //nullCatalogMeansCurrent=true 设置为只查当前连接的schema库 configuration.setJdbcUrl(\u0026#34;jdbc:mysql://localhost:3306/flowable-learn?\u0026#34; + \u0026#34;useUnicode=true\u0026amp;characterEncoding=utf-8\u0026#34; + \u0026#34;\u0026amp;allowMultiQueries=true\u0026#34; + \u0026#34;\u0026amp;nullCatalogMeansCurrent=true\u0026#34;); //如果数据库中表结构不存在则新建 configuration.setDatabaseSchemaUpdate(ProcessEngineConfiguration.DB_SCHEMA_UPDATE_TRUE); } ProcessEngine提供的几个服务 流程部署\n/** * 流程的部署 */ @Test public void testDeploy() { //获取ProcessEngine对象 ProcessEngine processEngine = configuration.buildProcessEngine(); //获取服务(repository,流程定义) RepositoryService repositoryService = processEngine.getRepositoryService(); Deployment deploy = repositoryService.createDeployment().addClasspathResource(\u0026#34;holiday-request.bpmn20.xml\u0026#34;) .name(\u0026#34;请求流程\u0026#34;) //流程名 .deploy(); System.out.println(\u0026#34;部署id\u0026#34; + deploy.getId()); System.out.println(\u0026#34;部署名\u0026#34; + deploy.getName()); } 表结构 查询和删除操作 # 查询已经部署的流程定义\n/** * 流程定义及部署的查询 */ @Test public void testDeployQuery(){ ProcessEngine processEngine=configuration.buildProcessEngine(); RepositoryService repositoryService=processEngine.getRepositoryService(); //流程部署查询 //这里只部署了一个流程定义 Deployment deployment = repositoryService.createDeploymentQuery() .deploymentId(\u0026#34;1\u0026#34;).singleResult(); System.out.println(\u0026#34;部署时的名称:\u0026#34;+deployment.getName()); //流程定义查询器 ProcessDefinitionQuery processDefinitionQuery = repositoryService.createProcessDefinitionQuery(); //查询到的流程定义 ProcessDefinition processDefinition = processDefinitionQuery.deploymentId(\u0026#34;1\u0026#34;).singleResult(); System.out.println(\u0026#34;部署id:\u0026#34;+processDefinition.getDeploymentId()); System.out.println(\u0026#34;定义名:\u0026#34;+processDefinition.getName()); System.out.println(\u0026#34;描述:\u0026#34;+processDefinition.getDescription()); System.out.println(\u0026#34;定义id:\u0026#34;+processDefinition.getId()); } 删除流程定义\n代码\n/** * 流程删除 */ @Test public void testDeleteDeploy(){ ProcessEngine processEngine=configuration.buildProcessEngine(); RepositoryService repositoryService=processEngine. getRepositoryService(); //注意:第一个参数时部署id //后面那个参数表示级联删除,如果流程启动了会同时删除任务。 repositoryService.deleteDeployment(\u0026#34;2501\u0026#34;,true); } 下面三个表的数据都会被删除 启动流程实例 # 由于刚才将部署删除了,所以这里再运行testDeploy()重新部署上\n这里通过流程定义key(xml中的id)启动流程\n/** * 流程运行 */ @Test public void testRunProcess(){ ProcessEngine processEngine=configuration.buildProcessEngine(); RuntimeService runtimeService = processEngine.getRuntimeService(); //这边模拟表单数据(表单数据有多种处理方式,这只是其中一种) Map\u0026lt;String,Object\u0026gt; map=new HashMap\u0026lt;\u0026gt;(); map.put(\u0026#34;employee\u0026#34;,\u0026#34;张三\u0026#34;); map.put(\u0026#34;nrOfHolidays\u0026#34;,3); map.put(\u0026#34;description\u0026#34;,\u0026#34;工作累了想出去玩\u0026#34;); ProcessInstance holidayRequest = runtimeService.startProcessInstanceByKey(\u0026#34;holidayRequest\u0026#34;, map); System.out.println(\u0026#34;流程定义的id:\u0026#34;+holidayRequest.getProcessDefinitionId()); System.out.println(\u0026#34;当前活跃id:\u0026#34;+holidayRequest.getActivityId()); System.out.println(\u0026#34;流程运行id:\u0026#34;+holidayRequest.getId()); } 三个表 act_ru_variable act_ru_task arc_ru_execution\n查询任务 # 这里先指定一下每个任务的候选人,修改xml文件中userTask的节点属性\n修改前先删除一下之前部署的流程图(还是上面的代码)\n/** * 流程删除 */ @Test public void testDeleteDeploy(){ ProcessEngine processEngine=configuration.buildProcessEngine(); RepositoryService repositoryService=processEngine. getRepositoryService(); //注意:第一个参数时部署id //后面那个参数表示级联删除,true表示如果流程启动了会同时删除任务。 repositoryService.deleteDeployment(\u0026#34;2501\u0026#34;,false); } 这里用false参数测试,会提示失败,运行中的流程不允许删除。将第二个参数改为true即可级联删除\n删除后可以发现下面几个表数据全部清空了 然后修改xml定义文件并运行testDeploy()重新部署\n定义修改\n\u0026lt;userTask id=\u0026#34;approveTask\u0026#34; name=\u0026#34;同意或者拒绝请假\u0026#34; flowable:assignee=\u0026#34;zhangsan\u0026#34;/\u0026gt; \u0026lt;!--这里增加了assignee属性值--\u0026gt; 运行流程 testRunProcess()\n运行后节点会跳到给zhangsan的那个任务,查看数据库表 流程变量 查询任务\n/** * 测试任务查询 */ @Test public void testQueryTask(){ ProcessEngine processEngine=configuration.buildProcessEngine(); TaskService taskService = processEngine.getTaskService(); //通过流程定义查询任务 List\u0026lt;Task\u0026gt; list = taskService.createTaskQuery().processDefinitionKey(\u0026#34;holidayRequest\u0026#34;) .taskAssignee(\u0026#34;zhangsan\u0026#34;) .list(); for (Task task:list){ System.out.println(\u0026#34;任务对应的流程定义id\u0026#34;+task.getProcessDefinitionId()); System.out.println(\u0026#34;任务名\u0026#34;+task.getName()); System.out.println(\u0026#34;任务处理人\u0026#34;+task.getAssignee()); System.out.println(\u0026#34;任务描述\u0026#34;+task.getDescription()); System.out.println(\u0026#34;任务id\u0026#34;+task.getId()); } } 处理任务 # 流程图定义的分析 任务A处理后,根据处理结果(这里是拒绝),会走向任务D,然后任务D是一个Service,且通过java的委托对象,自动实现操作\n到了D那个节点,这里指定了一个自定义的java类处理 代码配置,注意类名和xml中的一致\npackage org.flowable; import org.flowable.engine.delegate.DelegateExecution; import org.flowable.engine.delegate.JavaDelegate; public class SendRejectionMail implements JavaDelegate { /** * 这是一个flowable中的触发器 * * @param delegateExecution */ @Override public void execute(DelegateExecution delegateExecution) { //触发执行的逻辑 按照我们在流程中的定义给被拒绝的员工发送通知邮件 System.out.println(\u0026#34;不好意思,你的请假申请被拒绝了\u0026#34;); } } 任务的完成\n@Test public void testCompleteTask() { ProcessEngine engine = configuration.buildProcessEngine(); TaskService taskService = engine.getTaskService(); //查找出张三在这个流程定义中的任务 Task task = taskService.createTaskQuery().processDefinitionKey(\u0026#34;holidayRequest\u0026#34;) .taskAssignee(\u0026#34;zhangsan\u0026#34;) .singleResult(); //创建流程变量 HashMap\u0026lt;String, Object\u0026gt; map = new HashMap\u0026lt;\u0026gt;(); map.put(\u0026#34;approved\u0026#34;, false); //完成任务 taskService.complete(task.getId(), map); } 控制台 数据库 下面几个表的数据都被清空了 历史任务的完成 # Flowable流程引擎可以自动存储所有流程实例的审计数据或历史数据\n先查看一下刚才用的流程定义的id 历史信息查询\n@Test public void testHistory(){ ProcessEngine processEngine=configuration.buildProcessEngine(); HistoryService historyService=processEngine.getHistoryService(); List\u0026lt;HistoricActivityInstance\u0026gt; list = historyService.createHistoricActivityInstanceQuery() .processDefinitionId(\u0026#34;holidayRequest:1:7503\u0026#34;) .finished() //查询已经完成的 .orderByHistoricActivityInstanceEndTime().asc() //指定排序字段和升降序 .list(); for(HistoricActivityInstance history:list){ //注意,和视频不一样的地方,history表还记录了流程箭头流向的那个节点 //_flow_ System.out.println( \u0026#34;活动名--\u0026#34;+history.getActivityName()+ \u0026#34;处理人--\u0026#34;+history.getAssignee()+ \u0026#34;活动id--\u0026#34;+history.getActivityId()+ \u0026#34;处理时长--\u0026#34;+history.getDurationInMillis()+\u0026#34;毫秒\u0026#34;); } 不一样的地方,在旧版本时没有的 流程设计器 # 有eclipse流程设计器,和flowable流程设计器\n使用eclipse的设计,会生成一个bar文件,代码稍微有点不同 接收一个ZipInputStream\nFlowableUI # 使用flowable官方提供的包,里面有一个war,直接用命令 java -jar xx.war启动即可 这个应用分成四个模块 流程图的绘制及用户分配 "},{"id":394,"href":"/zh/docs/technology/Linux/hanshunping_/12-20/","title":"linux_韩老师_12-20","section":"韩顺平老师_","content":" 目录结构 # 目录结构很重要\nwindows下 linux下,从根目录开始分支 /,/root (root用户),/home (创建的用户的目录),/bin(常用的指令),/etc(环境配置)\n在linux世界里,一切皆文件\ncpu被映射成文件\n硬盘 具体的目录结构\n/bin 常用,binary的缩写,存放常用的命令 (/usr/bin、/usr/local/bin) /sbin (/usr/sbin、/usr/local/sbin) SuperUser,存放的是系统管理员使用的系统管理程序\n/home 存放普通用户的主目录\nuseradd jack 之后看该目录 删掉 userdel -r jack 目录消失 /root 该目录为系统管理员,也称超级管理员的用户的主目录\n/lib 系统开机所需要的最基本的动态连接共享库,其作用类似于windows里的DLL,几乎所有的应用程序都需要用到这些共享库\nlost+found 一般为空,非法关机后会存放文件\n/etc 系统管理所需要的配置文件和子目录,比如mysql的my.conf\n/usr 用户的应用程序和文件,类似windows的program files\n/boot 启动Linux时使用的核心文件(破坏则无法启动)\n/proc (不能动) 虚拟目录,系统内存的映射,访问这个目录获取系统信息\n/srv (不能动) service的缩写,存放服务启动之后需要提取的数据\n/sys (不能动) 安装了2.6内核中新出现的文件系统 sysfs\n/tmp 这个目录用来存放一些临时文件\n/dev 类似windows设备管理器,将硬件映射成文件\n/media linux系统会自动识别一些设备,u盘、光驱,将识别的设备映射到该目录下\n/mnt 为了让用户挂载别的文件系统,比如将外部的存储挂载到该目录 /opt 给主机额外安装软件所存放的目录\n/usr/local 给主机额外安装软件所安装的目录,一般通过编译源码方式安装的程序\n/var 日志,不断扩充的东西 /selinux [security-enhanced linux] 安全子系统,控制程序只能访问特定文件 (启用之后才能看到)\n远程登陆 # 背景 linux服务器开发小组共享 正式上线项目运行在公网,所以需要远程开发部署 图解 软件 xshell 和xftp https://www.xshell.com/zh/free-for-home-school/ 使用ifconfig 查看ip 先添加网络工具包 yum install net-tools -y 使用 在客户端打开cmd,并使用ping命令 xshell中配置并进行连接 按住ctrl+鼠标滚轴可以放大字体 远程文件传输 # xtfp6 person安装 新建连接配置 文件夹 可以在这里直接复制上传 图解 解决乱码问题 reboot vim快捷键 # vi :linux内置vi文本编辑器 vim是vi的增强版本,有丰富的字体颜色\n常用的三种模式\n正常模式,使用上下左右、复制粘贴 插入模式 正常模式\u0026ndash;\u0026gt;插入模式 按下i I o O a A r R(一般用i) 命令行模式 插入模式\u0026ndash;\u0026gt;命令行 输入输入esc表示退出,然后输入: 输入wq表示保存并退出 编辑,重新vim Hello.java 下面,这时候按tab可以自动补全 命令 快捷键使用\n正常模式下\n输入yy,拷贝当前行。p进行粘贴 4yy,拷贝当前行(包括)往下4行\n输入dd,删除当前行 4dd,删除当前行(包括)往下4行\n定位到首行(gg)或者末行G\n使用u,撤回刚才的输入(lalala将被撤回) 定位到20行 (20+shift+g)【其实是20+G】\n命令模式 :切换到命令行)\n命令行模式下(:下),输入 /搜索内容\n或者(/)下,直接输入搜索内容\n再次输入 / ,就会清空前面的搜索\n设置文件行号(:下) set nu 设置;set nonu 取消 如果修改太多,需要先拷贝到windows下,然后再传上来\nvim/vi 快捷键 关机重启 # 命令 halt 停止\nshutdown -h now #立刻关机 shutdown -h 1 #给出提示并关机 shutdown -r now #现在重启计算机 halt #立刻关机(虚拟机好像只是把cpu关闭?) reboot #立刻重启 sync #将内存的数据同步到磁盘 sync #将内存的数据同步到磁盘 shutdown/reboot/halt等命令都会在执行前执行sync 登录注销 # 尽量不要用root账号登录\n普通用户登陆后,用su - 用户名 切换成系统管理员身份 logout 注销用户(图形页面没效果) 在运行级别3下有效 "},{"id":395,"href":"/zh/docs/technology/Linux/hanshunping_/07-11/","title":"linux_韩老师_07-11","section":"韩顺平老师_","content":" 网络连接 # 网络连接的三种模式 同一个教室的三个主机 此时三个同学可以正常通讯 桥接模式 这是张三的虚拟机和外部互通;但是如果这样设置,ip会不够用; NAT模式 如图,虚拟机可以跟虚拟的网卡(192.168.100.99)互通,且通过这个虚拟网卡,及(192.168.0.50代理),与外界(192.168.0.X)互通 NAT模式,网络地址转换模式,虚拟系统和外部系统通讯,不造成IP冲突 注意,这里外部其他主机(除0.50和100.99)是访问不到100.88的 主机模式:独立的系统 虚拟机克隆 # 方式1,直接拷贝整个文件夹 方式2,使用VMWare 克隆前先把克隆目标关闭 克隆虚拟机当前状态\u0026ndash;创建完整克隆 虚拟机快照 # 为什么需要虚拟机快照 快照a 之后创建了文件夹hello 然后拍摄快照b 之后创建了文件夹hello2 然后拍摄快照c\n目前 回到快照A 之后会重启,效果(两个文件夹都没有了)\n如果恢复到B,然后再创建一个快照,就会变成 虚拟机迁移 # 直接剪切、删除,即可 vmtools工具 # 如下步骤,注意,这里只是在有界面的情况下进行安装 安装完毕后 在vm上面设置 共享文件夹在linux中的路径 /mnt/hgfs/myshare "},{"id":396,"href":"/zh/docs/technology/Flowable/offical/05/","title":"Flowable-05-spring-boot","section":"官方文档","content":" 入门 # 需要两个依赖\n\u0026lt;properties\u0026gt; \u0026lt;flowable.version\u0026gt;6.7.2\u0026lt;/flowable.version\u0026gt; \u0026lt;/properties\u0026gt; \u0026lt;dependencies\u0026gt; \u0026lt;dependency\u0026gt; \u0026lt;groupId\u0026gt;org.flowable\u0026lt;/groupId\u0026gt; \u0026lt;artifactId\u0026gt;flowable-spring-boot-starter\u0026lt;/artifactId\u0026gt; \u0026lt;version\u0026gt;${flowable.version}\u0026lt;/version\u0026gt; \u0026lt;/dependency\u0026gt; \u0026lt;!-- https://mvnrepository.com/artifact/com.h2database/h2 --\u0026gt; \u0026lt;dependency\u0026gt; \u0026lt;groupId\u0026gt;com.h2database\u0026lt;/groupId\u0026gt; \u0026lt;artifactId\u0026gt;h2\u0026lt;/artifactId\u0026gt; \u0026lt;version\u0026gt;2.1.212\u0026lt;/version\u0026gt; \u0026lt;/dependency\u0026gt; \u0026lt;/dependencies\u0026gt; 结合Spring:\n只需将依赖项添加到类路径并使用*@SpringBootApplication*注释,幕后就会发生很多事情:\n自动创建内存数据源(因为 H2 驱动程序位于类路径中)并传递给 Flowable 流程引擎配置\n已创建并公开了 Flowable ProcessEngine、CmmnEngine、DmnEngine、FormEngine、ContentEngine 和 IdmEngine bean\n所有 Flowable 服务都暴露为 Spring bean\nSpring Job Executor 已创建\n将自动部署流程文件夹中的任何 BPMN 2.0 流程定义。创建一个文件夹processes并将一个虚拟进程定义(名为one-task-process.bpmn20.xml)添加到此文件夹。该文件的内容如下所示。\n\u0026lt;?xml version=\u0026#34;1.0\u0026#34; encoding=\u0026#34;UTF-8\u0026#34;?\u0026gt; \u0026lt;definitions xmlns=\u0026#34;http://www.omg.org/spec/BPMN/20100524/MODEL\u0026#34; xmlns:flowable=\u0026#34;http://flowable.org/bpmn\u0026#34; targetNamespace=\u0026#34;Examples\u0026#34;\u0026gt; \u0026lt;process id=\u0026#34;oneTaskProcess\u0026#34; name=\u0026#34;The One Task Process\u0026#34;\u0026gt; \u0026lt;startEvent id=\u0026#34;theStart\u0026#34; /\u0026gt; \u0026lt;sequenceFlow id=\u0026#34;flow1\u0026#34; sourceRef=\u0026#34;theStart\u0026#34; targetRef=\u0026#34;theTask\u0026#34; /\u0026gt; \u0026lt;userTask id=\u0026#34;theTask\u0026#34; name=\u0026#34;my task\u0026#34; flowable:assignee=\u0026#34;kermit\u0026#34; /\u0026gt; \u0026lt;sequenceFlow id=\u0026#34;flow2\u0026#34; sourceRef=\u0026#34;theTask\u0026#34; targetRef=\u0026#34;theEnd\u0026#34; /\u0026gt; \u0026lt;endEvent id=\u0026#34;theEnd\u0026#34; /\u0026gt; \u0026lt;/process\u0026gt; \u0026lt;/definitions\u0026gt; 案例文件夹中的任何 CMMN 1.1 案例定义都将自动部署。\n将自动部署dmn文件夹中的任何 DMN 1.1 dmn 定义。\n表单文件夹中的任何表单定义都将自动部署。\njava代码 在项目服务启动的时候就去加载一些数据\n@SpringBootApplication(proxyBeanMethods = false) public class MyApplication { public static void main(String[] args) { SpringApplication.run(MyApplication.class, args); } @Bean public CommandLineRunner init(final RepositoryService repositoryService, final RuntimeService runtimeService, final TaskService taskService) { //该bean在项目服务启动的时候就去加载一些数据 return new CommandLineRunner() { @Override public void run(String... strings) throws Exception { //有几个流程定义 System.out.println(\u0026#34;Number of process definitions : \u0026#34; + repositoryService.createProcessDefinitionQuery().count()); //有多少个任务 System.out.println(\u0026#34;Number of tasks : \u0026#34; + taskService.createTaskQuery().count()); runtimeService.startProcessInstanceByKey(\u0026#34;oneTaskProcess\u0026#34;); //开启流程后有多少个任务(+1) System.out.println(\u0026#34;Number of tasks after process start: \u0026#34; + taskService.createTaskQuery().count()); } }; } } 更改数据库 # 添加依赖\n\u0026lt;dependency\u0026gt; \u0026lt;groupId\u0026gt;mysql\u0026lt;/groupId\u0026gt; \u0026lt;artifactId\u0026gt;mysql-connector-java\u0026lt;/artifactId\u0026gt; \u0026lt;version\u0026gt;8.0.29\u0026lt;/version\u0026gt; \u0026lt;/dependency\u0026gt; application.yml中添加配置\nspring: datasource: url: jdbc:mysql://localhost:3306/flowable-spring-boot?useUnicode=true\u0026amp;characterEncoding=utf-8\u0026amp;allowMultiQueries=true\u0026amp;nullCatalogMeansCurrent=true username: root password: 123456 driver-class-name: com.mysql.jdbc.Driver Rest支持 # web支持\n\u0026lt;parent\u0026gt; \u0026lt;groupId\u0026gt;org.springframework.boot\u0026lt;/groupId\u0026gt; \u0026lt;artifactId\u0026gt;spring-boot-starter-parent\u0026lt;/artifactId\u0026gt; \u0026lt;version\u0026gt;2.6.7\u0026lt;/version\u0026gt; \u0026lt;/parent\u0026gt; 添加依赖\n\u0026lt;dependency\u0026gt; \u0026lt;groupId\u0026gt;org.springframework.boot\u0026lt;/groupId\u0026gt; \u0026lt;artifactId\u0026gt;spring-boot-starter-web\u0026lt;/artifactId\u0026gt; \u0026lt;/dependency\u0026gt; 使用Service启动流程及获取给定受让人的任务\n@Service public class MyService { @Autowired private RuntimeService runtimeService; @Autowired private TaskService taskService; @Transactional public void startProcess() { runtimeService.startProcessInstanceByKey(\u0026#34;oneTaskProcess\u0026#34;); } @Transactional public List\u0026lt;Task\u0026gt; getTasks(String assignee) { return taskService.createTaskQuery().taskAssignee(assignee).list(); } } 创建REST端点\n@RestController public class MyRestController { @Autowired private MyService myService; @PostMapping(value=\u0026#34;/process\u0026#34;) public void startProcessInstance() { myService.startProcess(); } @RequestMapping(value=\u0026#34;/tasks\u0026#34;, method= RequestMethod.GET, produces=MediaType.APPLICATION_JSON_VALUE) public List\u0026lt;TaskRepresentation\u0026gt; getTasks(@RequestParam String assignee) { List\u0026lt;Task\u0026gt; tasks = myService.getTasks(assignee); List\u0026lt;TaskRepresentation\u0026gt; dtos = new ArrayList\u0026lt;TaskRepresentation\u0026gt;(); for (Task task : tasks) { dtos.add(new TaskRepresentation(task.getId(), task.getName())); } return dtos; } static class TaskRepresentation { private String id; private String name; public TaskRepresentation(String id, String name) { this.id = id; this.name = name; } public String getId() { return id; } public void setId(String id) { this.id = id; } public String getName() { return name; } public void setName(String name) { this.name = name; } } } 使用下面语句进行测试\ncurl http://localhost:8080/tasks?assignee=kermit [] curl -X POST http://localhost:8080/process curl http://localhost:8080/tasks?assignee=kermit [{\u0026#34;id\u0026#34;:\u0026#34;10004\u0026#34;,\u0026#34;name\u0026#34;:\u0026#34;my task\u0026#34;}] JPA支持 # 添加依赖\n\u0026lt;dependency\u0026gt; \u0026lt;groupId\u0026gt;org.springframework.boot\u0026lt;/groupId\u0026gt; \u0026lt;artifactId\u0026gt;spring-boot-starter-data-jpa\u0026lt;/artifactId\u0026gt; \u0026lt;/dependency\u0026gt; 创建一个实体类\n@Entity class Person { @Id @GeneratedValue private Long id; private String username; private String firstName; private String lastName; private Date birthDate; public Person() { } public Person(String username, String firstName, String lastName, Date birthDate) { this.username = username; this.firstName = firstName; this.lastName = lastName; this.birthDate = birthDate; } public Long getId() { return id; } public void setId(Long id) { this.id = id; } public String getUsername() { return username; } public void setUsername(String username) { this.username = username; } public String getFirstName() { return firstName; } public void setFirstName(String firstName) { this.firstName = firstName; } public String getLastName() { return lastName; } public void setLastName(String lastName) { this.lastName = lastName; } public Date getBirthDate() { return birthDate; } public void setBirthDate(Date birthDate) { this.birthDate = birthDate; } } 属性文件添加\nspring.jpa.hibernate.ddl-auto=update 添加Repository类\n@Repository public interface PersonRepository extends JpaRepository\u0026lt;Person, Long\u0026gt; { Person findByUsername(String username); } 代码\n添加事务\nstartProcess现在修改成:获取传入的受理人用户名,查找Person,并将PersonJPA对象作为流程变量放入流程实例中\n在CommandLineRunner中初始化时创建用户\n@Service @Transactional public class MyService { @Autowired private RuntimeService runtimeService; @Autowired private TaskService taskService; @Autowired private PersonRepository personRepository; public void startProcess(String assignee) { Person person = personRepository.findByUsername(assignee); Map\u0026lt;String, Object\u0026gt; variables = new HashMap\u0026lt;String, Object\u0026gt;(); variables.put(\u0026#34;person\u0026#34;, person); runtimeService.startProcessInstanceByKey(\u0026#34;oneTaskProcess\u0026#34;, variables); } public List\u0026lt;Task\u0026gt; getTasks(String assignee) { return taskService.createTaskQuery().taskAssignee(assignee).list(); } public void createDemoUsers() { if (personRepository.findAll().size() == 0) { personRepository.save(new Person(\u0026#34;jbarrez\u0026#34;, \u0026#34;Joram\u0026#34;, \u0026#34;Barrez\u0026#34;, new Date())); personRepository.save(new Person(\u0026#34;trademakers\u0026#34;, \u0026#34;Tijs\u0026#34;, \u0026#34;Rademakers\u0026#34;, new Date())); } } } CommandRunner修改\n@Bean public CommandLineRunner init(final MyService myService) { return new CommandLineRunner() { public void run(String... strings) throws Exception { myService.createDemoUsers(); } }; } RestController修改\n@RestController public class MyRestController { @Autowired private MyService myService; @PostMapping(value=\u0026#34;/process\u0026#34;) public void startProcessInstance(@RequestBody StartProcessRepresentation startProcessRepresentation) { myService.startProcess(startProcessRepresentation.getAssignee()); } ... static class StartProcessRepresentation { private String assignee; public String getAssignee() { return assignee; } public void setAssignee(String assignee) { this.assignee = assignee; } } 修改流程定义\n\u0026lt;userTask id=\u0026#34;theTask\u0026#34; name=\u0026#34;my task\u0026#34; flowable:assignee=\u0026#34;${person.id}\u0026#34;/\u0026gt; 测试\n启动spring boot之后person表会有两条数据\n启动流程实例\n此时会把从数据库查找到的person传入流程图(变量)\ncurl -H \u0026#34;Content-Type: application/json\u0026#34; -d \u0026#39;{\u0026#34;assignee\u0026#34; : \u0026#34;jbarrez\u0026#34;}\u0026#39; http://localhost:8080/process 使用id获取任务列表\ncurl http://localhost:8080/tasks?assignee=1 [{\u0026#34;id\u0026#34;:\u0026#34;12505\u0026#34;,\u0026#34;name\u0026#34;:\u0026#34;my task\u0026#34;}] 可流动的执行器端点 # "},{"id":397,"href":"/zh/docs/technology/Flowable/offical/04/","title":"Flowable-04-spring","section":"官方文档","content":" ProcessEngineFactoryBean # 将ProcessEngine配置为常规的SpringBean\n\u0026lt;bean id=\u0026#34;processEngineConfiguration\u0026#34; class=\u0026#34;org.flowable.spring.SpringProcessEngineConfiguration\u0026#34;\u0026gt; ... \u0026lt;/bean\u0026gt; \u0026lt;bean id=\u0026#34;processEngine\u0026#34; class=\u0026#34;org.flowable.spring.ProcessEngineFactoryBean\u0026#34;\u0026gt; \u0026lt;property name=\u0026#34;processEngineConfiguration\u0026#34; ref=\u0026#34;processEngineConfiguration\u0026#34; /\u0026gt; \u0026lt;/bean\u0026gt; 使用transaction\n\u0026lt;beans xmlns=\u0026#34;http://www.springframework.org/schema/beans\u0026#34; xmlns:context=\u0026#34;http://www.springframework.org/schema/context\u0026#34; xmlns:tx=\u0026#34;http://www.springframework.org/schema/tx\u0026#34; xmlns:xsi=\u0026#34;http://www.w3.org/2001/XMLSchema-instance\u0026#34; xsi:schemaLocation=\u0026#34;http://www.springframework.org/schema/beans http://www.springframework.org/schema/beans/spring-beans.xsd http://www.springframework.org/schema/context http://www.springframework.org/schema/context/spring-context-2.5.xsd http://www.springframework.org/schema/tx http://www.springframework.org/schema/tx/spring-tx-3.0.xsd\u0026#34;\u0026gt; \u0026lt;bean id=\u0026#34;dataSource\u0026#34; class=\u0026#34;org.springframework.jdbc.datasource.SimpleDriverDataSource\u0026#34;\u0026gt; \u0026lt;property name=\u0026#34;driverClass\u0026#34; value=\u0026#34;org.h2.Driver\u0026#34; /\u0026gt; \u0026lt;property name=\u0026#34;url\u0026#34; value=\u0026#34;jdbc:h2:mem:flowable;DB_CLOSE_DELAY=1000\u0026#34; /\u0026gt; \u0026lt;property name=\u0026#34;username\u0026#34; value=\u0026#34;sa\u0026#34; /\u0026gt; \u0026lt;property name=\u0026#34;password\u0026#34; value=\u0026#34;\u0026#34; /\u0026gt; \u0026lt;/bean\u0026gt; \u0026lt;bean id=\u0026#34;transactionManager\u0026#34; class=\u0026#34;org.springframework.jdbc.datasource.DataSourceTransactionManager\u0026#34;\u0026gt; \u0026lt;property name=\u0026#34;dataSource\u0026#34; ref=\u0026#34;dataSource\u0026#34; /\u0026gt; \u0026lt;/bean\u0026gt; \u0026lt;bean id=\u0026#34;processEngineConfiguration\u0026#34; class=\u0026#34;org.flowable.spring.SpringProcessEngineConfiguration\u0026#34;\u0026gt; \u0026lt;property name=\u0026#34;dataSource\u0026#34; ref=\u0026#34;dataSource\u0026#34; /\u0026gt; \u0026lt;property name=\u0026#34;transactionManager\u0026#34; ref=\u0026#34;transactionManager\u0026#34; /\u0026gt; \u0026lt;property name=\u0026#34;databaseSchemaUpdate\u0026#34; value=\u0026#34;true\u0026#34; /\u0026gt; \u0026lt;property name=\u0026#34;asyncExecutorActivate\u0026#34; value=\u0026#34;false\u0026#34; /\u0026gt; \u0026lt;/bean\u0026gt; \u0026lt;bean id=\u0026#34;processEngine\u0026#34; class=\u0026#34;org.flowable.spring.ProcessEngineFactoryBean\u0026#34;\u0026gt; \u0026lt;property name=\u0026#34;processEngineConfiguration\u0026#34; ref=\u0026#34;processEngineConfiguration\u0026#34; /\u0026gt; \u0026lt;/bean\u0026gt; \u0026lt;bean id=\u0026#34;repositoryService\u0026#34; factory-bean=\u0026#34;processEngine\u0026#34; factory-method=\u0026#34;getRepositoryService\u0026#34; /\u0026gt; \u0026lt;bean id=\u0026#34;runtimeService\u0026#34; factory-bean=\u0026#34;processEngine\u0026#34; factory-method=\u0026#34;getRuntimeService\u0026#34; /\u0026gt; \u0026lt;bean id=\u0026#34;taskService\u0026#34; factory-bean=\u0026#34;processEngine\u0026#34; factory-method=\u0026#34;getTaskService\u0026#34; /\u0026gt; \u0026lt;bean id=\u0026#34;historyService\u0026#34; factory-bean=\u0026#34;processEngine\u0026#34; factory-method=\u0026#34;getHistoryService\u0026#34; /\u0026gt; \u0026lt;bean id=\u0026#34;managementService\u0026#34; factory-bean=\u0026#34;processEngine\u0026#34; factory-method=\u0026#34;getManagementService\u0026#34; /\u0026gt; ... 还包括了其他的一些bean\n\u0026lt;beans\u0026gt; ... \u0026lt;tx:annotation-driven transaction-manager=\u0026#34;transactionManager\u0026#34;/\u0026gt; \u0026lt;bean id=\u0026#34;userBean\u0026#34; class=\u0026#34;org.flowable.spring.test.UserBean\u0026#34;\u0026gt; \u0026lt;property name=\u0026#34;runtimeService\u0026#34; ref=\u0026#34;runtimeService\u0026#34; /\u0026gt; \u0026lt;/bean\u0026gt; \u0026lt;bean id=\u0026#34;printer\u0026#34; class=\u0026#34;org.flowable.spring.test.Printer\u0026#34; /\u0026gt; \u0026lt;/beans\u0026gt; 使用\n使用XML资源方式类配置Spring应用程序上下文\nClassPathXmlApplicationContext applicationContext = new ClassPathXmlApplicationContext( \u0026#34;org/flowable/examples/spring/SpringTransactionIntegrationTest-context.xml\u0026#34;); 或者添加注解\n@ContextConfiguration( \u0026#34;classpath:org/flowable/spring/test/transaction/SpringTransactionIntegrationTest-context.xml\u0026#34;) 获取服务bean并进行部署流程\nRepositoryService repositoryService = (RepositoryService) applicationContext.getBean(\u0026#34;repositoryService\u0026#34;); String deploymentId = repositoryService .createDeployment() .addClasspathResource(\u0026#34;org/flowable/spring/test/hello.bpmn20.xml\u0026#34;) .deploy() .getId(); 下面看userBean类,使用了Transaction事务\npublic class UserBean { /** injected by Spring */ private RuntimeService runtimeService; @Transactional public void hello() { // here you can do transactional stuff in your domain model // and it will be combined in the same transaction as // the startProcessInstanceByKey to the Flowable RuntimeService runtimeService.startProcessInstanceByKey(\u0026#34;helloProcess\u0026#34;); } public void setRuntimeService(RuntimeService runtimeService) { this.runtimeService = runtimeService; } } 使用userBean\nUserBean userBean = (UserBean) applicationContext.getBean(\u0026#34;userBean\u0026#34;); userBean.hello(); 表达式 # BPMN 流程中的所有 表达式也将默认“看到”所有 Spring bean\n要完全不暴露任何 bean,只需将一个空列表作为 SpringProcessEngineConfiguration 上的“beans”属性传递。当没有设置 \u0026lsquo;beans\u0026rsquo; 属性时,上下文中的所有 Spring beans 都将可用\n如下,可以设置暴露的bean\n\u0026lt;bean id=\u0026#34;processEngineConfiguration\u0026#34; class=\u0026#34;org.flowable.spring.SpringProcessEngineConfiguration\u0026#34;\u0026gt; ... \u0026lt;property name=\u0026#34;beans\u0026#34;\u0026gt; \u0026lt;map\u0026gt; \u0026lt;entry key=\u0026#34;printer\u0026#34; value-ref=\u0026#34;printer\u0026#34; /\u0026gt; \u0026lt;/map\u0026gt; \u0026lt;/property\u0026gt; \u0026lt;/bean\u0026gt; \u0026lt;bean id=\u0026#34;printer\u0026#34; class=\u0026#34;org.flowable.examples.spring.Printer\u0026#34; /\u0026gt; 现在的bean进行公开了,在.bpmn20.xml中可以使用\n\u0026lt;definitions id=\u0026#34;definitions\u0026#34;\u0026gt; \u0026lt;process id=\u0026#34;helloProcess\u0026#34;\u0026gt; \u0026lt;startEvent id=\u0026#34;start\u0026#34; /\u0026gt; \u0026lt;sequenceFlow id=\u0026#34;flow1\u0026#34; sourceRef=\u0026#34;start\u0026#34; targetRef=\u0026#34;print\u0026#34; /\u0026gt; \u0026lt;serviceTask id=\u0026#34;print\u0026#34; flowable:expression=\u0026#34;#{printer.printMessage()}\u0026#34; /\u0026gt; \u0026lt;sequenceFlow id=\u0026#34;flow2\u0026#34; sourceRef=\u0026#34;print\u0026#34; targetRef=\u0026#34;end\u0026#34; /\u0026gt; \u0026lt;endEvent id=\u0026#34;end\u0026#34; /\u0026gt; \u0026lt;/process\u0026gt; \u0026lt;/definitions\u0026gt; Print类\npublic class Printer { public void printMessage() { System.out.println(\u0026#34;hello world\u0026#34;); } } spring配置bean\n\u0026lt;beans\u0026gt; ... \u0026lt;bean id=\u0026#34;printer\u0026#34; class=\u0026#34;org.flowable.examples.spring.Printer\u0026#34; /\u0026gt; \u0026lt;/beans\u0026gt; 自动资源部署 # \u0026lt;bean id=\u0026#34;processEngineConfiguration\u0026#34; class=\u0026#34;org.flowable.spring.SpringProcessEngineConfiguration\u0026#34;\u0026gt; ... \u0026lt;property name=\u0026#34;deploymentResources\u0026#34; value=\u0026#34;classpath*:/org/flowable/spring/test/autodeployment/autodeploy.*.bpmn20.xml\u0026#34; /\u0026gt; \u0026lt;/bean\u0026gt; \u0026lt;bean id=\u0026#34;processEngine\u0026#34; class=\u0026#34;org.flowable.spring.ProcessEngineFactoryBean\u0026#34;\u0026gt; \u0026lt;property name=\u0026#34;processEngineConfiguration\u0026#34; ref=\u0026#34;processEngineConfiguration\u0026#34; /\u0026gt; \u0026lt;/bean\u0026gt; 单元测试 # @ExtendWith(FlowableSpringExtension.class) @ExtendWith(SpringExtension.class) @ContextConfiguration(classes = SpringJunitJupiterTest.TestConfiguration.class) public class MyBusinessProcessTest { @Autowired private RuntimeService runtimeService; @Autowired private TaskService taskService; @Test @Deployment void simpleProcessTest() { runtimeService.startProcessInstanceByKey(\u0026#34;simpleProcess\u0026#34;); Task task = taskService.createTaskQuery().singleResult(); assertEquals(\u0026#34;My Task\u0026#34;, task.getName()); taskService.complete(task.getId()); assertEquals(0, runtimeService.createProcessInstanceQuery().count()); } } "},{"id":398,"href":"/zh/docs/technology/Flowable/offical/03/","title":"Flowable-03-api","section":"官方文档","content":" 流程引擎API和服务 # 引擎API是与Flowable交互的常见方式,主要起点是ProcessEngine,可以通过配置(Configuration章节)中描述的多种方式创建。\n从ProcessEngine获取包含工作流/BPM方法的各种服务。ProcessEngine和服务对象是线程安全的\n下面是通过processEngine获取各种服务的方法\nProcessEngine processEngine = ProcessEngines.getDefaultProcessEngine(); RuntimeService runtimeService = processEngine.getRuntimeService(); RepositoryService repositoryService = processEngine.getRepositoryService(); TaskService taskService = processEngine.getTaskService(); ManagementService managementService = processEngine.getManagementService(); IdentityService identityService = processEngine.getIdentityService(); HistoryService historyService = processEngine.getHistoryService(); FormService formService = processEngine.getFormService(); DynamicBpmnService dynamicBpmnService = processEngine.getDynamicBpmnService(); ProcessEngines.getDefaultProcessEngine()在第一次调用时初始化并构建流程引擎,然后返回相同的流程引擎\nProcessEngines类将扫描所有flowable.cfg.xml和flowable-context.xml文件。\n对于所有 flowable.cfg.xml 文件,流程引擎将以典型的 Flowable 方式构建:ProcessEngineConfiguration.createProcessEngineConfigurationFromInputStream(inputStream).buildProcessEngine()。\n对于所有 flowable-context.xml 文件,流程引擎将以 Spring 方式构建:首先创建 Spring 应用程序上下文,然后从该应用程序上下文中获取流程引擎。\nThe RepositoryService is probably the first service needed when working with the Flowable engine.\n该服务**(RepositoryService)提供用于管理和操作部署deployments**和流程定义的操作\n查询引擎已知的部署和流程定义 暂停和激活作为一个整体或特定流程定义的部署。挂起意味着不能对它们执行进一步的操作,而激活则相反并再次启用操作 检索各种资源,例如引擎自动生成的部署或流程图中包含的文件 检索流程定义的 POJO 版本,该版本可用于使用 Java 而不是 XML 来内省流程 RepositoryService主要是关于静态信息(不会改变的数据,或者至少不会改变太多),而RuntimeService处理启动流程定义的新流程实例\n流程定义定义了流程中不同步骤的结构和行为,流程实例是此类流程定义的一次执行\n对于每个流程定义,通常有许多实例同时运行\nRuntime也用于检索和存储流程变量\nRuntimeservice还可以用来查询流程实例和执行(executions)\nExecutions are a representation of the \u0026rsquo;token\u0026rsquo; concept of BPMN 2.0. 执行是指向流程实例当前所在位置的指针\n只要流程实例正在等待外部触发器并且流程需要继续,就会使用 RuntimeService\n流程实例可以有各种等待状态,并且该服务包含各种操作以向实例发出“信号”,即接收到外部触发器并且流程实例可以继续\n需要由系统的人类用户执行的任务是BPM引擎(如Floable)的核心,围绕任务的所有内容都在TaskService中进行分组\n查询分配给用户或组的任务 创建新的独立任务(与流程实例无关) 任务被分配给哪个用户或哪些用户,以及让这些用户以某种方式参与该任务 要求并完成一项任务,声明意味着某人决定成为该任务的受让人assignee IdentityService支持组和用户的管理(创建、更新、删除、查询)\nFormService是可选服务,引入了启动表单(start form)和任务表单(a task form)的概念\nHistoryService公开了 Flowable 引擎收集的所有历史数据。在执行流程时,引擎可以保留很多数据(这是可配置的),例如流程实例的启动时间,谁做了哪些任务,完成任务花了多长时间,每个流程实例中遵循的路径,等等。\n使用Flowable 编写自定义应用程序时,通常不需要**ManagementService 。**它允许检索有关数据库表和表元数据的信息。此外,它还公开了作业的查询功能和管理操作\nDynamicBpmnService可用于更改流程定义的一部分,而无需重新部署它。例如,您可以更改流程定义中用户任务的受理人定义,或更改服务任务的类名。\n异常策略 # Flowable 中的基本异常是 org.flowable.engine.FlowableException\nFlowable的一些异常子类\nFlowableWrongDbException:当 Flowable 引擎发现数据库架构版本和引擎版本不匹配时抛出。 FlowableOptimisticLockingException:当并发访问同一数据条目导致数据存储发生乐观锁定时抛出。 FlowableClassLoadingException:当请求加载的类未找到或加载时发生错误时抛出(例如 JavaDelegates、TaskListeners \u0026hellip;\u0026hellip;)。 FlowableObjectNotFoundException:当请求或操作的对象不存在时抛出。 FlowableIllegalArgumentException:异常表明在 Flowable API 调用中提供了非法参数,在引擎配置中配置了非法值,或者提供了非法值,或者在流程定义中使用了非法值。 FlowableTaskAlreadyClaimedException:当任务已被声明时抛出,当 taskService.claim(\u0026hellip;) 被调用时 查询接口 # 引擎查询数据有两种方式:the query API and native queries\nqueryAPi允许使用fluent API编写完全类型安全的查询,例如\nList\u0026lt;Task\u0026gt; tasks = taskService.createTaskQuery() .taskAssignee(\u0026#34;kermit\u0026#34;) .processVariableValueEquals(\u0026#34;orderId\u0026#34;, \u0026#34;0815\u0026#34;) .orderByDueDate().asc() .list(); native queries (返回类型由您使用的查询对象定义,数据映射到正确的对象[比如任务、流程实例、执行等,且您必须使用在数据库中定义的表明和列名])。如下,可以通过api检索表名等,使依赖关系尽可能小\nList\u0026lt;Task\u0026gt; tasks = taskService.createNativeTaskQuery() .sql(\u0026#34;SELECT count(*) FROM \u0026#34; + managementService.getTableName(Task.class) + \u0026#34; T WHERE T.NAME_ = #{taskName}\u0026#34;) .parameter(\u0026#34;taskName\u0026#34;, \u0026#34;gonzoTask\u0026#34;) .list(); long count = taskService.createNativeTaskQuery() .sql(\u0026#34;SELECT count(*) FROM \u0026#34; + managementService.getTableName(Task.class) + \u0026#34; T1, \u0026#34; + managementService.getTableName(VariableInstanceEntity.class) + \u0026#34; V1 WHERE V1.TASK_ID_ = T1.ID_\u0026#34;) .count(); 变量 # 每个流程实例都需要并使用数据来执行其组成的步骤。在 Flowable 中,这些数据称为变量,存储在数据库中\n流程实例可以有变量(称为流程变量),也可以有执行(指向流程处于活动状态的特定指针)。用户任务也可以有变量,变量存储在ACT_RU_VARIABLE数据库表中\n所有startProcessInstanceXXX方法都有一个可选参数,用于在创建和启动流程实例时提供变量\nProcessInstance startProcessInstanceByKey(String processDefinitionKey, Map\u0026lt;String, Object\u0026gt; variables); 可以在流程执行期间添加变量。例如,(RuntimeService)\nvoid setVariable(String executionId, String variableName, Object value); void setVariableLocal(String executionId, String variableName, Object value); void setVariables(String executionId, Map\u0026lt;String, ? extends Object\u0026gt; variables); void setVariablesLocal(String executionId, Map\u0026lt;String, ? extends Object\u0026gt; variables); 检索变量 TaskService上存在类似的方法。这意味着任务(如执行)可以具有仅在任务期间“活动”的局部变量\nMap\u0026lt;String, Object\u0026gt; getVariables(String executionId); Map\u0026lt;String, Object\u0026gt; getVariablesLocal(String executionId); Map\u0026lt;String, Object\u0026gt; getVariables(String executionId, Collection\u0026lt;String\u0026gt; variableNames); Map\u0026lt;String, Object\u0026gt; getVariablesLocal(String executionId, Collection\u0026lt;String\u0026gt; variableNames); Object getVariable(String executionId, String variableName); \u0026lt;T\u0026gt; T getVariable(String executionId, String variableName, Class\u0026lt;T\u0026gt; variableClass); 当前执行或任务对象是可用的,它可以用于变量设置和/或检索\nexecution.getVariables(); execution.getVariables(Collection\u0026lt;String\u0026gt; variableNames); execution.getVariable(String variableName); execution.setVariables(Map\u0026lt;String, object\u0026gt; variables); execution.setVariable(String variableName, Object value); 在执行上述任何调用时,所有变量都会在后台从数据库中获取。这意味着,如果您有 10 个变量,但只能通过*getVariable(\u0026ldquo;myVariable\u0026rdquo;)*获得一个,那么在幕后将获取并缓存其他 9 个\n接上述,可以设置是否缓存所有变量\nMap\u0026lt;String, Object\u0026gt; getVariables(Collection\u0026lt;String\u0026gt; variableNames, boolean fetchAllVariables); Object getVariable(String variableName, boolean fetchAllVariables); void setVariable(String variableName, Object value, boolean fetchAllVariables); 瞬态变量 # 瞬态变量是行为类似于常规变量但不持久的变量。通常,瞬态变量用于高级用例\n对于瞬态变量,根本没有存储历史记录。 与常规变量一样,瞬态变量在设置时放在最高父级。这意味着在执行时设置变量时,瞬态变量实际上存储在流程实例执行中。与常规变量一样,如果在特定执行或任务上设置变量,则存在方法的局部变体。 只能在流程定义中的下一个“等待状态”之前访问瞬态变量。在那之后,他们就走了。在这里,等待状态是指流程实例中它被持久化到数据存储中的点。请注意,在此定义中,异步活动也是“等待状态”! 瞬态变量只能由setTransientVariable(name, value)设置,但调用getVariable(name)时也会返回瞬态变量(也存在一个getTransientVariable(name),它只检查瞬态变量)。这样做的原因是使表达式的编写变得容易,并且使用变量的现有逻辑适用于这两种类型。 瞬态变量会隐藏同名的持久变量。这意味着当在流程实例上同时设置持久变量和瞬态变量并*调用 getVariable(\u0026ldquo;someVariable\u0026rdquo;)*时,将返回瞬态变量值。 可以在大多数地方设置和获取瞬态变量\n关于JavaDelegate实现中的DelegateExecution\n关于ExecutionListener实现中的DelegateExecution和关于TaskListener实现的DelegateTask\n通过执行对象在脚本任务中\n通过运行时服务启动流程实例时\n完成任务时\n调用runtimeService.trigger方法时\n方法\nvoid setTransientVariable(String variableName, Object variableValue); void setTransientVariableLocal(String variableName, Object variableValue); void setTransientVariables(Map\u0026lt;String, Object\u0026gt; transientVariables); void setTransientVariablesLocal(Map\u0026lt;String, Object\u0026gt; transientVariables); Object getTransientVariable(String variableName); Object getTransientVariableLocal(String variableName); Map\u0026lt;String, Object\u0026gt; getTransientVariables(); Map\u0026lt;String, Object\u0026gt; getTransientVariablesLocal(); void removeTransientVariable(String variableName); void removeTransientVariableLocal(String variableName); 典型示例 瞬态变量传递\nProcessInstance processInstance = runtimeService.createProcessInstanceBuilder() .processDefinitionKey(\u0026#34;someKey\u0026#34;) .transientVariable(\u0026#34;configParam01\u0026#34;, \u0026#34;A\u0026#34;) .transientVariable(\u0026#34;configParam02\u0026#34;, \u0026#34;B\u0026#34;) .transientVariable(\u0026#34;configParam03\u0026#34;, \u0026#34;C\u0026#34;) .start(); 获取数据\npublic static class FetchDataServiceTask implements JavaDelegate { public void execute(DelegateExecution execution) { String configParam01 = (String) execution.getVariable(configParam01); // ... RestResponse restResponse = executeRestCall(); execution.setTransientVariable(\u0026#34;response\u0026#34;, restResponse.getBody()); execution.setTransientVariable(\u0026#34;status\u0026#34;, restResponse.getStatus()); } } 离开独占网关的序列流的条件不知道使用的是持久变量还是瞬态变量(在本例中为状态瞬态变量):\n\u0026lt;conditionExpression xsi:type=\u0026#34;tFormalExpression\u0026#34;\u0026gt;${status == 200}\u0026lt;/conditionExpression\u0026gt; 表达式 # Flowable使用UEL进行表达式解析,UEL代表统一表达式语言,是EE6规范的一部分。两种类型的表达式(值表达式和方法表达式),都可以在需要表达式的地方使用\n值表达式,解析为一个值\n${myVar} ${myBean.myProperty} 方法表达式:调用带或不带参数的方法\n${printer.print()} ${myBean.addNewOrder(\u0026#39;orderName\u0026#39;)} ${myBean.doSomething(myVar, execution)} 表达式函数 # 一些开箱即用的函数\nvariables:get(varName):检索变量的值。与直接在表达式中写变量名的主要区别在于,当变量不存在时,使用这个函数不会抛出异常。例如,如果myVariable不存在,*${myVariable == \u0026ldquo;hello\u0026rdquo;}会抛出异常,但${var:get(myVariable) == \u0026lsquo;hello\u0026rsquo;}*会正常工作。 variables:getOrDefault(varName, defaultValue):类似于get,但可以选择提供默认值,当变量未设置或值为null时返回。 variables:exists(varName) :如果变量具有非空值,则返回true 。 variables:isEmpty(varName) (alias :empty ) : 检查变量值是否不为空。根据变量类型,行为如下: 对于字符串变量,如果变量是空字符串,则认为该变量为空。 对于 java.util.Collection 变量,如果集合没有元素,则返回true 。 对于 ArrayNode 变量,如果没有元素则返回true 如果变量为null,则始终返回true variables:isNotEmpty(varName) (alias : notEmpty) : isEmpty的逆运算。 variables:equals(varName, value)(别名*:eq*):检查变量是否等于给定值。这是表达式的简写函数,否则将被写为*${execution.getVariable(\u0026ldquo;varName\u0026rdquo;) != null \u0026amp;\u0026amp; execution.getVariable(\u0026ldquo;varName\u0026rdquo;) == value}*。 如果变量值为 null,则返回 false(除非与 null 比较)。 variables:notEquals(varName, value)(别名*:ne ):* equals的反向比较。 variables:contains(varName, value1, value2, \u0026hellip;):检查提供的所有值是否包含在变量中。根据变量类型,行为如下: 对于字符串变量,传递的值用作需要成为变量一部分的子字符串 对于 java.util.Collection 变量,所有传递的值都需要是集合的一个元素(正则包含语义)。 对于 ArrayNode 变量:支持检查 arraynode 是否包含作为变量类型支持的类型的 JsonNode 当变量值为 null 时,在所有情况下都返回 false。当变量值不为null,且实例类型不是上述类型之一时,会返回false。 variables:containsAny(varName, value1, value2, \u0026hellip;):类似于contains函数,但如果任何(而非全部)传递的值包含在变量中,则将返回true 。 variables:base64(varName):将二进制或字符串变量转换为 Base64 字符串 比较器功能: variables:lowerThan(varName, value) (别名*:lessThan或:lt* ) : ${execution.getVariable(\u0026ldquo;varName\u0026rdquo;) != null \u0026amp;\u0026amp; execution.getVariable(\u0026ldquo;varName\u0026rdquo;) \u0026lt; value}的简写 变量:lowerThanOrEquals(varName, value)(别名*:lessThanOrEquals或:lte*):类似,但现在用于*\u0026lt; =* variables:greaterThan(varName, value) (alias :gt ) : 类似,但现在用于*\u0026gt;* variables:greaterThanOrEquals(varName, value) (alias :gte ) : 类似,但现在用于*\u0026gt; =* 单元测试 # 使用自定义资源进行单元测试\n@FlowableTest public class MyBusinessProcessTest { private ProcessEngine processEngine; private RuntimeService runtimeService; private TaskService taskService; @BeforeEach void setUp(ProcessEngine processEngine) { this.processEngine = processEngine; this.runtimeService = processEngine.getRuntimeService(); this.taskService = processEngine.getTaskService(); } @Test @Deployment(resources = \u0026#34;holiday-request.bpmn20.xml\u0026#34;) void testSimpleProcess() { HashMap\u0026lt;String, Object\u0026gt; employeeInfo = new HashMap\u0026lt;\u0026gt;(); employeeInfo.put(\u0026#34;employee\u0026#34;, \u0026#34;wangwu1028930\u0026#34;); //employeeInfo.put() runtimeService.startProcessInstanceByKey( \u0026#34;holidayRequest\u0026#34;, employeeInfo ); Task task = taskService.createTaskQuery().singleResult(); assertEquals(\u0026#34;Approve or reject request\u0026#34;, task.getName()); HashMap\u0026lt;String, Object\u0026gt; hashMap = new HashMap\u0026lt;\u0026gt;(); hashMap.put(\u0026#34;approved\u0026#34;, true); taskService.complete(task.getId(), hashMap); assertEquals(1, runtimeService .createProcessInstanceQuery().count()); } } 调试单元测试 # Web应用程序中的流程引擎 # 编写一个简单的ServletContextListener来初始化和销毁普通Servlet环境中的流程引擎\npublic class ProcessEnginesServletContextListener implements ServletContextListener { public void contextInitialized(ServletContextEvent servletContextEvent) { ProcessEngines.init(); } public void contextDestroyed(ServletContextEvent servletContextEvent) { ProcessEngines.destroy(); } } 其中,ProcessEngines.init()将在类路径中查找flowable.cfg.xml资源文件,并为给定的配置创建一个ProcessEngine,使用下面两种方式来获取他\nProcessEngines.getDefaultProcessEngine() //或者下面的方式 ProcessEngines.getProcessEngine(\u0026#34;myName\u0026#34;); "},{"id":399,"href":"/zh/docs/technology/Flowable/offical/02/","title":"Flowable-02-Configuration","section":"官方文档","content":" 创建流程引擎 # Flowable 流程引擎通过一个名为 flowable.cfg.xml 的 XML 文件进行配置\n现在类路径下放置floable.cfg.xml文件\n\u0026lt;beans xmlns=\u0026#34;http://www.springframework.org/schema/beans\u0026#34; xmlns:xsi=\u0026#34;http://www.w3.org/2001/XMLSchema-instance\u0026#34; xsi:schemaLocation=\u0026#34;http://www.springframework.org/schema/beans http://www.springframework.org/schema/beans/spring-beans.xsd\u0026#34;\u0026gt; \u0026lt;bean id=\u0026#34;processEngineConfiguration\u0026#34; class=\u0026#34;org.flowable.engine.impl.cfg.StandaloneProcessEngineConfiguration\u0026#34;\u0026gt; \u0026lt;property name=\u0026#34;jdbcUrl\u0026#34; value=\u0026#34;jdbc:h2:mem:flowable;DB_CLOSE_DELAY=1000\u0026#34; /\u0026gt; \u0026lt;property name=\u0026#34;jdbcDriver\u0026#34; value=\u0026#34;org.h2.Driver\u0026#34; /\u0026gt; \u0026lt;property name=\u0026#34;jdbcUsername\u0026#34; value=\u0026#34;sa\u0026#34; /\u0026gt; \u0026lt;property name=\u0026#34;jdbcPassword\u0026#34; value=\u0026#34;\u0026#34; /\u0026gt; \u0026lt;property name=\u0026#34;databaseSchemaUpdate\u0026#34; value=\u0026#34;true\u0026#34; /\u0026gt; \u0026lt;property name=\u0026#34;asyncExecutorActivate\u0026#34; value=\u0026#34;false\u0026#34; /\u0026gt; \u0026lt;property name=\u0026#34;mailServerHost\u0026#34; value=\u0026#34;mail.my-corp.com\u0026#34; /\u0026gt; \u0026lt;property name=\u0026#34;mailServerPort\u0026#34; value=\u0026#34;5025\u0026#34; /\u0026gt; \u0026lt;/bean\u0026gt; \u0026lt;/beans\u0026gt; 然后使用静态方法进行获取ProcessEngine\nProcessEngine processEngine = ProcessEngines.getDefaultProcessEngine(); 还有其他配置,这里不一一列举,详见文档地址 https://www.flowable.com/open-source/docs/bpmn/ch03-Configuration\n大致目录如下 "},{"id":400,"href":"/zh/docs/technology/Flowable/offical/01/","title":"Flowable-01-GettingStarted","section":"官方文档","content":" 入门 # 什么是流动性 # Flowable 是一个用 Java 编写的轻量级业务流程引擎。Flowable 流程引擎允许您部署 BPMN 2.0 流程定义(用于定义流程的行业 XML 标准)、创建这些流程定义的流程实例、运行查询、访问活动或历史流程实例和相关数据等等。\n可以使用 Flowable REST API 通过 HTTP 进行通信。还有几个 Flowable 应用程序(Flowable Modeler、Flowable Admin、Flowable IDM 和 Flowable Task)提供开箱即用的示例 UI,用于处理流程和任务。\nFlowable和Activiti # Flowable是Activiti的一个分支\n构建命令行命令 # 创建流程引擎 # 请假流程如下\n员工要求休假数次 经理批准或拒绝请求 之后将模拟再某个外部系统中注册请求,并向员工发送一封包含结果的邮件 创建一个空的Mave项目,并添加依赖\n\u0026lt;dependencies\u0026gt; \u0026lt;dependency\u0026gt; \u0026lt;groupId\u0026gt;org.flowable\u0026lt;/groupId\u0026gt; \u0026lt;artifactId\u0026gt;flowable-engine\u0026lt;/artifactId\u0026gt; \u0026lt;version\u0026gt;6.6.0\u0026lt;/version\u0026gt; \u0026lt;/dependency\u0026gt; \u0026lt;dependency\u0026gt; \u0026lt;groupId\u0026gt;com.h2database\u0026lt;/groupId\u0026gt; \u0026lt;artifactId\u0026gt;h2\u0026lt;/artifactId\u0026gt; \u0026lt;version\u0026gt;1.3.176\u0026lt;/version\u0026gt; \u0026lt;/dependency\u0026gt; \u0026lt;dependency\u0026gt; \u0026lt;groupId\u0026gt;mysql\u0026lt;/groupId\u0026gt; \u0026lt;artifactId\u0026gt;mysql-connector-java\u0026lt;/artifactId\u0026gt; \u0026lt;version\u0026gt;8.0.29\u0026lt;/version\u0026gt; \u0026lt;!--当版本号\u0026gt;=8.0.22时会报date转字符串的错误--\u0026gt; \u0026lt;/dependency\u0026gt; \u0026lt;/dependencies\u0026gt; 添加一个带有Main方法的类\n这里实例化一个ProcessEngine实例,一般只需要实例化一次,是通过ProcessEngineConfiguration创建的,用来配置和调整流程引擎的配置\nProcessEngineConfiguration也可以使用配置 XML 文件创建 ProcessEngineConfiguration需要的最低配置是与数据库的 JDBC 连接 package org.flowable; import org.flowable.engine.ProcessEngine; import org.flowable.engine.ProcessEngineConfiguration; import org.flowable.engine.impl.cfg.StandaloneProcessEngineConfiguration; public class HolidayRequest { public static void main(String[] args) { //这里改用mysql,注意后面的nullCatalogMeansCurrent=true //注意,pom需要添加mysql驱动依赖 ProcessEngineConfiguration cfg = new StandaloneProcessEngineConfiguration() .setJdbcUrl(\u0026#34;jdbc:mysql://localhost:3306/flowable_official?useUnicode=true\u0026#34; + \u0026#34;\u0026amp;characterEncoding=utf-8\u0026amp;serverTimezone=Asia/Shanghai\u0026amp;allowMultiQueries=true\u0026#34; +\u0026#34;\u0026amp;nullCatalogMeansCurrent=true\u0026#34; ) .setJdbcUsername(\u0026#34;root\u0026#34;) .setJdbcPassword(\u0026#34;123456\u0026#34;) .setJdbcDriver(\u0026#34;com.mysql.cj.jdbc.Driver\u0026#34;) .setDatabaseSchemaUpdate(ProcessEngineConfiguration.DB_SCHEMA_UPDATE_TRUE); /* //这是官网,用的h2 ProcessEngineConfiguration cfg = new StandaloneProcessEngineConfiguration() .setJdbcUrl(\u0026#34;jdbc:h2:mem:flowable;DB_CLOSE_DELAY=-1\u0026#34;) .setJdbcUsername(\u0026#34;sa\u0026#34;) .setJdbcPassword(\u0026#34;\u0026#34;) .setJdbcDriver(\u0026#34;org.h2.Driver\u0026#34;) .setDatabaseSchemaUpdate(ProcessEngineConfiguration.DB_SCHEMA_UPDATE_TRUE);*/ ProcessEngine processEngine = cfg.buildProcessEngine(); } } 运行后会出现slf4j的警告,添加依赖并编写配置文件即可\n\u0026lt;dependency\u0026gt; \u0026lt;groupId\u0026gt;org.slf4j\u0026lt;/groupId\u0026gt; \u0026lt;artifactId\u0026gt;slf4j-api\u0026lt;/artifactId\u0026gt; \u0026lt;version\u0026gt;1.7.30\u0026lt;/version\u0026gt; \u0026lt;/dependency\u0026gt; \u0026lt;dependency\u0026gt; \u0026lt;groupId\u0026gt;org.slf4j\u0026lt;/groupId\u0026gt; \u0026lt;artifactId\u0026gt;slf4j-log4j12\u0026lt;/artifactId\u0026gt; \u0026lt;version\u0026gt;1.7.30\u0026lt;/version\u0026gt; \u0026lt;/dependency\u0026gt; 配置文件\nlog4j.rootLogger=DEBUG, CA log4j.appender.CA=org.apache.log4j.ConsoleAppender log4j.appender.CA.layout=org.apache.log4j.PatternLayout log4j.appender.CA.layout.ConversionPattern=%d{hh:mm:ss,SSS} [%t] %-5p %c %x - %m%n 重运行程序无警告\n会自动往mysql添加一些表及数据\n部署流程定义 # flowable 引擎希望以 BPMN 2.0 格式定义流程,这是一种在行业中被广泛接受的 XML 标准。Flowable术语称之为流程定义 (可以理解成许多执行的蓝图),从流程定义中可以启动许多流程实例\n流程定义了请假假期所涉及的不同步骤,而一个流程实例与一位特定员工的假期请相匹配。\nBPMN 2.0 存储为 XML,但它也有一个可视化部分:它以标准方式定义每个不同的步骤类型(人工任务、自动服务调用等)如何表示,以及如何将这些不同的步骤连接到彼此。通过这种方式,BPMN 2.0 标准允许技术人员和业务人员以双方都理解的方式就业务流程进行交流。\n我们将使用的流程定义\n假设该过程是通过提供一些信息开始的 左边的圆圈称为开始事件 第一个矩形是用户任务(经理必须执行,批准或拒绝) 根据经理决定,专用网关 (带有十字菱形)会将流程实例路由到批准或拒绝路径 如果获得批准,必须在某个外部系统中注册请求,然后再次为原始员工执行用户任务,通知他们该决定 如果被拒绝,则会向员工发送一封电子邮件,通知他们这一点 此类流程定义使用可视化建模工具建模,例如Flowable Designer(Eclipse)或FlowableModeler(Web应用程序)\nBPMN 2.0 及其概念 下面的holiday-request.bmpn20.xm文件放在src/main/resouces中\n\u0026lt;?xml version=\u0026#34;1.0\u0026#34; encoding=\u0026#34;UTF-8\u0026#34;?\u0026gt; \u0026lt;definitions xmlns=\u0026#34;http://www.omg.org/spec/BPMN/20100524/MODEL\u0026#34; xmlns:xsi=\u0026#34;http://www.w3.org/2001/XMLSchema-instance\u0026#34; xmlns:xsd=\u0026#34;http://www.w3.org/2001/XMLSchema\u0026#34; xmlns:bpmndi=\u0026#34;http://www.omg.org/spec/BPMN/20100524/DI\u0026#34; xmlns:omgdc=\u0026#34;http://www.omg.org/spec/DD/20100524/DC\u0026#34; xmlns:omgdi=\u0026#34;http://www.omg.org/spec/DD/20100524/DI\u0026#34; xmlns:flowable=\u0026#34;http://flowable.org/bpmn\u0026#34; typeLanguage=\u0026#34;http://www.w3.org/2001/XMLSchema\u0026#34; expressionLanguage=\u0026#34;http://www.w3.org/1999/XPath\u0026#34; targetNamespace=\u0026#34;http://www.flowable.org/processdef\u0026#34;\u0026gt; \u0026lt;process id=\u0026#34;holidayRequest\u0026#34; name=\u0026#34;Holiday Request\u0026#34; isExecutable=\u0026#34;true\u0026#34;\u0026gt; \u0026lt;startEvent id=\u0026#34;startEvent\u0026#34;/\u0026gt; \u0026lt;!--线条指向--\u0026gt; \u0026lt;sequenceFlow sourceRef=\u0026#34;startEvent\u0026#34; targetRef=\u0026#34;approveTask\u0026#34;/\u0026gt; \u0026lt;userTask id=\u0026#34;approveTask\u0026#34; name=\u0026#34;Approve or reject request\u0026#34;/\u0026gt; \u0026lt;!--线条指向--\u0026gt; \u0026lt;sequenceFlow sourceRef=\u0026#34;approveTask\u0026#34; targetRef=\u0026#34;decision\u0026#34;/\u0026gt; \u0026lt;!--网关--\u0026gt; \u0026lt;exclusiveGateway id=\u0026#34;decision\u0026#34;/\u0026gt; \u0026lt;!--线条指向,下面有两个分支--\u0026gt; \u0026lt;!--线条指向approved--\u0026gt; \u0026lt;sequenceFlow sourceRef=\u0026#34;decision\u0026#34; targetRef=\u0026#34;externalSystemCall\u0026#34;\u0026gt; \u0026lt;conditionExpression xsi:type=\u0026#34;tFormalExpression\u0026#34;\u0026gt; \u0026lt;![CDATA[ ${approved} ]]\u0026gt; \u0026lt;/conditionExpression\u0026gt; \u0026lt;/sequenceFlow\u0026gt; \u0026lt;!--线条指向!approved--\u0026gt; \u0026lt;sequenceFlow sourceRef=\u0026#34;decision\u0026#34; targetRef=\u0026#34;sendRejectionMail\u0026#34;\u0026gt; \u0026lt;conditionExpression xsi:type=\u0026#34;tFormalExpression\u0026#34;\u0026gt; \u0026lt;![CDATA[ ${!approved} ]]\u0026gt; \u0026lt;/conditionExpression\u0026gt; \u0026lt;/sequenceFlow\u0026gt; \u0026lt;!--分支1--\u0026gt; \u0026lt;serviceTask id=\u0026#34;externalSystemCall\u0026#34; name=\u0026#34;Enter holidays in external system\u0026#34; flowable:class=\u0026#34;org.flowable.CallExternalSystemDelegate\u0026#34;/\u0026gt; \u0026lt;!--线条指向--\u0026gt; \u0026lt;sequenceFlow sourceRef=\u0026#34;externalSystemCall\u0026#34; targetRef=\u0026#34;holidayApprovedTask\u0026#34;/\u0026gt; \u0026lt;!--用户任务--\u0026gt; \u0026lt;userTask id=\u0026#34;holidayApprovedTask\u0026#34; name=\u0026#34;Holiday approved\u0026#34;/\u0026gt; \u0026lt;!--线条指向--\u0026gt; \u0026lt;sequenceFlow sourceRef=\u0026#34;holidayApprovedTask\u0026#34; targetRef=\u0026#34;approveEnd\u0026#34;/\u0026gt; \u0026lt;!--服务任务--\u0026gt; \u0026lt;serviceTask id=\u0026#34;sendRejectionMail\u0026#34; name=\u0026#34;Send out rejection email\u0026#34; flowable:class=\u0026#34;org.flowable.SendRejectionMail\u0026#34;/\u0026gt; \u0026lt;!--线条指向--\u0026gt; \u0026lt;sequenceFlow sourceRef=\u0026#34;sendRejectionMail\u0026#34; targetRef=\u0026#34;rejectEnd\u0026#34;/\u0026gt; \u0026lt;!--分支2结束--\u0026gt; \u0026lt;endEvent id=\u0026#34;approveEnd\u0026#34;/\u0026gt; \u0026lt;!--分支2结束--\u0026gt; \u0026lt;endEvent id=\u0026#34;rejectEnd\u0026#34;/\u0026gt; \u0026lt;/process\u0026gt; \u0026lt;/definitions\u0026gt; 解释\n该文件与BPMN2.0标准规范完全兼容 每个步骤(活动 activity),都有一个id属性,在XML中,该属性提供唯一标识符 name属性为可选的名称,增加了可视化图表的可读性 活动通过**顺序流(sequenceFlow)**连接,即可视图中的定向箭头。执行流程实例时,执行将从开始事件流向下一个活动,且遵循顺序流 离开专有网关的序列流(带有 X 的菱形)显然是特殊的:两者都有一个以表达式形式定义的条件(见第 25 和 32 行)。当流程实例执行到达此gateway时,将评估条件并采用第一个解析为true的条件。这就是这里独有的含义:只选择一个。如果需要不同的路由行为,当然也可以使用其他类型的网关 表达式以${approved}的形式,是${approved == true}的简写 approved称为过程变量,他与流程实例一起存储(持久数据为,在流程实例的声明周期内使用),意味着必须在流程实例的某个时间点(提交经理用户任务时,即结点\u0026lt;userTask id=\u0026quot;approveTask\u0026quot; /\u0026gt;[Flowable术语,完成])设置此流程变量) 部署流程 使用RepositoryService,它可以从ProcessEngine对象中检索,通过传递XML文件的位置并调用deploy()方法来执行它来创建一个新的Deployment\nRepositoryService repositoryService = processEngine.getRepositoryService(); //部署流程 Deployment deployment = repositoryService.createDeployment() .addClasspathResource(\u0026#34;holiday-request.bpmn20.xml\u0026#34;) .deploy(); //打印部署id System.out.println(\u0026#34;Found deployment id : \u0026#34; + deployment.getId()); 每次部署的id存在act_re_deployment表中 通过API查询来验证引擎是否知道流程定义\nProcessDefinition processDefinition = repositoryService.createProcessDefinitionQuery() .deploymentId(deployment.getId()) .singleResult(); System.out.println(\u0026#34;Found process definition : \u0026#34; + processDefinition.getName()); 启动流程实例 # 现在已经将流程定义部署到流程引擎中了,所以可以将此流程定义作为“蓝图”来启动流程实例\n启动前提供一些初始流程变量 ,通常,当流程自动触发时,将通过呈现给用户的表单或者通过REST API获得这些信息,本例为保持简单使用java.util.Scanner在命令中简单输入一些数据\nScanner scanner= new Scanner(System.in); System.out.println(\u0026#34;Who are you?\u0026#34;); String employee = scanner.nextLine(); System.out.println(\u0026#34;How many holidays do you want to request?\u0026#34;); Integer nrOfHolidays = Integer.valueOf(scanner.nextLine()); System.out.println(\u0026#34;Why do you need them?\u0026#34;); String description = scanner.nextLine(); 接下来,通过RuntimeService启动一个流程实例,流程实例使用key启动,此键与BPMN2.0 XML文件中设置的id属性匹配\nRuntimeService runtimeService = processEngine.getRuntimeService(); Map\u0026lt;String, Object\u0026gt; variables = new HashMap\u0026lt;String, Object\u0026gt;(); variables.put(\u0026#34;employee\u0026#34;, employee); variables.put(\u0026#34;nrOfHolidays\u0026#34;, nrOfHolidays); variables.put(\u0026#34;description\u0026#34;, description); ProcessInstance processInstance = runtimeService.startProcessInstanceByKey(\u0026#34;holidayRequest\u0026#34;, variables); 流程实例启动时,会创建一个执行(execution)并将其放入start event启动事件中。之后,此执行(execution)遵守user task 用户任务的序列流 sequence flow以供经理批准并执行用户任务user task行为 此行为将在数据库中创建一个任务,稍后可以使用查询找到该任务 用户任务处于等待状态,引擎将停止进一步执行任何操作,返回 API 调用 支线:交易性 (Sidetrack: transactionality) # 当您进行 Flowable API 调用时,默认情况下,一切都是同步synchronous的,并且是同一事务的一部分。这意味着,当方法调用返回时,将启动并提交事务。 当一个流程实例启动时,从流程实例启动到下一个等待状态会有一个数据库事务。在本例中,这是第一个用户任务。当引擎到达这个用户任务时,状态被持久化到数据库中并且事务被提交并且API调用返回 在 Flowable 中,当继续一个流程实例时,总会有一个数据库事务从前一个等待状态转到下一个等待状态。 查询和完成任务 # 为用户任务配置分配\n[第一个任务进入\u0026quot;经理\u0026quot;组]\n\u0026lt;userTask id=\u0026#34;approveTask\u0026#34; name=\u0026#34;Approve or reject request\u0026#34; flowable:candidateGroups=\u0026#34;managers\u0026#34;/\u0026gt; 第二个任务的受让人assignee属性 基于我们在流程实例启动时传递的流程变量的动态分配\n\u0026lt;userTask id=\u0026#34;holidayApprovedTask\u0026#34; name=\u0026#34;Holiday approved\u0026#34; flowable:assignee=\u0026#34;${employee}\u0026#34;/\u0026gt; 查询并返回\u0026quot;managers\u0026quot;组的任务\nTaskService taskService = processEngine.getTaskService(); List\u0026lt;Task\u0026gt; tasks = taskService.createTaskQuery().taskCandidateGroup(\u0026#34;managers\u0026#34;).list(); System.out.println(\u0026#34;You have \u0026#34; + tasks.size() + \u0026#34; tasks:\u0026#34;); for (int i=0; i\u0026lt;tasks.size(); i++) { System.out.println((i+1) + \u0026#34;) \u0026#34; + tasks.get(i).getName());// } 有三个是因为启动了三个实例\n获取特定的流程实例变量,并在屏幕上显示实际请求\nSystem.out.println(\u0026#34;Which task would you like to complete?\u0026#34;); int taskIndex = Integer.valueOf(scanner.nextLine()); Task task = tasks.get(taskIndex - 1); Map\u0026lt;String, Object\u0026gt; processVariables = taskService.getVariables(task.getId()); System.out.println(processVariables.get(\u0026#34;employee\u0026#34;) + \u0026#34; wants \u0026#34; + processVariables.get(\u0026#34;nrOfHolidays\u0026#34;) + \u0026#34; of holidays. Do you approve this?\u0026#34;); 设置variables让经理批准\nboolean approved = scanner.nextLine().toLowerCase().equals(\u0026#34;y\u0026#34;); variables = new HashMap\u0026lt;String, Object\u0026gt;(); variables.put(\u0026#34;approved\u0026#34;, approved); //经理完成任务 taskService.complete(task.getId(), variables); $\\color{red}该任务现已完成,并且基于\u0026quot;approved\u0026quot;流程变量选择离开专用网关的两条路径之一$\n编写JavaDelegate # 实现在请求被批准时将执行的自动逻辑,在BPMN2.0 XML中,这是一个服务任务\n\u0026lt;serviceTask id=\u0026#34;externalSystemCall\u0026#34; name=\u0026#34;Enter holidays in external system\u0026#34; flowable:class=\u0026#34;org.flowable.CallExternalSystemDelegate\u0026#34;/\u0026gt; 这里指定了具体实现类\npackage org.flowable; import org.flowable.engine.delegate.DelegateExecution; import org.flowable.engine.delegate.JavaDelegate; public class CallExternalSystemDelegate implements JavaDelegate { public void execute(DelegateExecution execution) { System.out.println(\u0026#34;Calling the external system for employee \u0026#34; + execution.getVariable(\u0026#34;employee\u0026#34;)); } } 当执行execution到达service tast服务任务时,BPMN 2.0 XML中引用的类被实例化并被调用\n运行,发现自定义逻辑确实已执行\n处理历史数据 # Flowable引擎会自动存储所有流程实例的审计数据audit data 或历史数据historical data\n下面,显示一直在执行的流程实例的持续时间,从ProcessEngine获取HistoryService并创建历史活动查询。这里添加了过滤\u0026ndash;1 仅针对一个特定流程实例的活动 \u0026ndash;2 只有已经完成的活动\nHistoryService historyService = processEngine.getHistoryService(); List\u0026lt;HistoricActivityInstance\u0026gt; activities = historyService.createHistoricActivityInstanceQuery() .processInstanceId(processInstance.getId()) .finished() .orderByHistoricActivityInstanceEndTime().asc() .list(); for (HistoricActivityInstance activity : activities) { System.out.println(activity.getActivityId() + \u0026#34; took \u0026#34; + activity.getDurationInMillis() + \u0026#34; milliseconds\u0026#34;); } 结论 # 本教程介绍了各种 Flowable 和 BPMN 2.0 概念和术语,同时还演示了如何以编程方式使用 Flowable API。\nFlowable REST API入门 # 设置REST应用程序 # 使用flowable-rest.war , java -jar flowable-rest.war\n测试是否运行成功\ncurl --user rest-admin:test http://localhost:8080/flowable-rest/service/management/engine 部署流程定义 # 先切到该文件夹下 使用下面命令启动flowable-rest\njava -jar flowable-rest.war 部署流程定义\ncurl --user rest-admin:test -F \u0026#34;file=@holiday-request.bpmn20.xml\u0026#34; http://localhost:8080/flowable-rest/service/repository/deployments 查看流程是否部署\ncurl --user rest-admin:test http://localhost:8080/flowable-rest/service/repository/process-definitions 将返回一个列表,列表每个元素是当前部署到引擎的所有流程定义 启动流程实例 # 命令\ncurl --user rest-admin:test -H \u0026#34;Content-Type: application/json\u0026#34; -X POST -d \u0026#39;{ \u0026#34;processDefinitionKey\u0026#34;:\u0026#34;holidayRequest\u0026#34;, \u0026#34;variables\u0026#34;: [ { \u0026#34;name\u0026#34;:\u0026#34;employee\u0026#34;, \u0026#34;value\u0026#34;: \u0026#34;John Doe\u0026#34; }, { \u0026#34;name\u0026#34;:\u0026#34;nrOfHolidays\u0026#34;, \u0026#34;value\u0026#34;: 7 }]}\u0026#39; http://localhost:8080/flowable-rest/service/runtime/process-instances windows中会报错\u0026hellip;估计是没转义啥的原因 将返回\n{\u0026#34;id\u0026#34;:\u0026#34;43\u0026#34;,\u0026#34;url\u0026#34;:\u0026#34;http://localhost:8080/flowable-rest/service/runtime/process-instances/43\u0026#34;,\u0026#34;businessKey\u0026#34;:null,\u0026#34;suspended\u0026#34;:false,\u0026#34;ended\u0026#34;:false,\u0026#34;processDefinitionId\u0026#34;:\u0026#34;holidayRequest:1:42\u0026#34;,\u0026#34;processDefinitionUrl\u0026#34;:\u0026#34;http://localhost:8080/flowable-rest/service/repository/process-definitions/holidayRequest:1:42\u0026#34;,\u0026#34;activityId\u0026#34;:null,\u0026#34;variables\u0026#34;:[],\u0026#34;tenantId\u0026#34;:\u0026#34;\u0026#34;,\u0026#34;completed\u0026#34;:false} 任务列表和完成任务 # 获取manager经理组的所有任务\ncurl --user rest-admin:test -H \u0026#34;Content-Type: application/json\u0026#34; -X POST -d \u0026#39;{ \u0026#34;candidateGroup\u0026#34; : \u0026#34;managers\u0026#34; }\u0026#39; http://localhost:8080/flowable-rest/service/query/tasks 使用命令完成一个任务\ncurl --user rest-admin:test -H \u0026#34;Content-Type: application/json\u0026#34; -X POST -d \u0026#39;{ \u0026#34;action\u0026#34; : \u0026#34;complete\u0026#34;, \u0026#34;variables\u0026#34; : [ { \u0026#34;name\u0026#34; : \u0026#34;approved\u0026#34;, \u0026#34;value\u0026#34; : true} ] }\u0026#39; http://localhost:8080/flowable-rest/service/runtime/tasks/25 这里会报下面的错\n{\u0026#34;message\u0026#34;:\u0026#34;Internal server error\u0026#34;,\u0026#34;exception\u0026#34;:\u0026#34;couldn\u0026#39;t instantiate class org.flowable.CallExternalSystemDelegate\u0026#34;} 解决办法\n这意味着引擎找不到服务任务中引用的 CallExternalSystemDelegate 类。为了解决这个问题,需要将该类放在应用程序的类路径中(这将需要重新启动)。按照本节所述创建类,将其打包为JAR,并将其放在Tomcat的webapps文件夹下的flowable-rest文件夹的WEB-INF/lib文件夹中。\n"},{"id":401,"href":"/zh/docs/technology/Algorithm/_algorithhms_4th_/2.1.2-2.1.3/","title":"算法红皮书 2.1.2-2.1.3","section":"_算法(第四版)_","content":" 排序 # 初级排序算法 # 选择排序 # 命题A。对于长度为N 的数组,选择排序需要大约 N^2/2 次比较和N 次交换。\n代码\npublic class Selection { public static void sort(Comparable[] a) { // 将a[]按升序排列 int N = a.length; // 数组长度 for (int i = 0; i \u0026lt; N; i++) { // 将a[i]和a[i+1..N]中最小的元素交换 int min = i; // 最小元素的索引 for (int j = i+1; j \u0026lt; N; j++) if (less(a[j], a[min])) min = j; exch(a, i, min); } } // less()、exch()、isSorted()和main()方法见“排序算法类模板” } 特点\n运行时间与输入无关,即输入数据的初始状态(比如是否已排序好等等)不影响排序时间 数据移动是最少的(只使用了N次交换,交换次数和数组的大小是线性关系 插入排序 # 命题B。对于随机排列的长度为N 且主键不重复的数组,平均情况下插入排序需要~ N2/4 次比较以及~ N2/4 次交换。最坏情况下需要~ N2/2 次比较和~ N2/2 次交换,最好情况下需要N-1次比较和0 次交换。\n代码\npublic static void sort(Comparable[] a) { int N = a.length; //将下表为 n-1的数,依次和n-2,n-3一直到0比较, //所以第二层for只走到1,因为0前面没有值 //如果比前面的值小,就进行交换 for (int i = 1; i \u0026lt; N; i++) { for (int j = i; j \u0026gt; 0 \u0026amp;\u0026amp; less(a[j], a[j - 1]); j--) { exch(a, j, j - 1); } } } 当倒置的数量很小时,插入排序比本章中的其他任何算法都快\n命题C。插入排序需要的交换操作和数组中倒置的数量相同,需要的比较次数大于等于倒置的数量,小于等于倒置的数量加上数组的大小再减一。\n性质D。对于随机排序的无重复主键的数组,插入排序和选择排序的运行时间是平方级别的,两者之比应该是一个较小的常数\n希尔排序 # 希尔排序的思想是使数组中任意间隔为h的元素都是有序的,这样的数组称为h有序数组,一个h有序数组就是h个互相独立的有序数组编制在一起组成的数组\n算法2.3 的实现使用了序列1/2(3k-1),从N/3 开始递减至1。我们把这个序列称为递增序列\n详述\n实现希尔排序的一种方法是对于每个h,用插入排序将h 个子数组独立地排序。但因为子数组是相互独立的,一个更简单的方法是在h- 子数组中将每个元素交换到比它大的元素之前去(将比它大的元素向右移动一格)。只需要在插入排序的代码中将移动元素的距离由1 改为h 即可。这样,希尔排序的实现就转化为了一个类似于插入排序但使用不同增量的过程。\n代码\npublic class Shell { public static void sort(Comparable[] a) { // 将a[]按升序排列 int N = a.length; int h = 1; while (h \u0026lt; N/3) h = 3*h + 1; // 1, 4, 13, 40, 121, 364, 1093, ... while (h \u0026gt;= 1) { // 将数组变为h有序 for (int i = h; i \u0026lt; N; i++) { // 将a[i]插入到a[i-h], a[i-2*h], a[i-3*h]... 之中 for (int j = i; j \u0026gt;= h \u0026amp;\u0026amp; less(a[j], a[j-h]); j -= h) exch(a, j, j-h); } h = h/3; } } // less()、exch()、isSorted()和main()方法见“排序算法类模板” } 通过提升速度来解决其他方式无法解决的问题是研究算法的设计和性能的主要原因之一\n归并排序 # 归并排序最吸引人的性质是它能够保证将任意长度为N的数组排序所需时间和NlogN成正比,主要缺点是他所需的额外空间和N成正比\n归并排序示意图 自顶向下的归并排序 # 原地归并的抽象方法\n/** * 这里有一个前提,就是a[i..mid]是有序的, * a[mid..hi]是有序的 * * @param a * @param lo * @param mid * @param hi */ public static void merge(Comparable[] a, int lo, int mid, int hi) { int i = lo, j = mid + 1; //先在辅助数组赋上需要的值 for (int k = lo; k \u0026lt;= hi; k++) { aux[k] = a[k]; } //最坏情况下这里时需要比较hi-lo+1次的,也就是数组长度 for (int k = lo; k \u0026lt;= hi; k++) { if (i \u0026gt; mid) { //说明i(左边)比较完了,直接拿右边的值放进去 a[k] = aux[j++]; } else if (j \u0026gt; hi) { //说明j(右边)比较完了,直接拿左边的值放进去 a[k] = aux[i++]; } else if (less(aux[j], aux[i])) { //左右都还有值的情况下,取出最小的值放进去 a[k] = aux[j++]; } else { a[k] = aux[i++]; } } } 递归进行归并排序\nprivate static void sort(Comparable[] a, int lo, int hi) { if (hi \u0026lt;= lo) { return; } int mid = lo + (hi - lo) / 2; //保证左边有序 sort(a, lo, mid); //保证右边有序 sort(a, mid + 1, hi); //归并数组有序的两部分 merge(a, lo, mid, hi); } 辅助数组的一次性初始化\nprivate static Comparable[] aux; public static void sort(Comparable[] a) { aux = new Comparable[a.length];//辅助数组,一次性分配空间 sort(a, 0, a.length - 1); } 自顶向下的归并排序的调用轨迹 N=16时归并排序中子数组的依赖树 每个结点都表示一个sort() 方法通过merge() 方法归并而成的子数组。这棵树正好有n 层。对于0 到n-1 之间的任意k,自顶向下的第k 层有2k 个子数组,每个数组的长度为 $2{(n-k)}$,归并最多需要$2^{(n-k)}$次比较。因此每层的比较次数为$ 2k * 2 ^ {( n - 1 )} = 2 ^ n $ ,n层总共为 $n*2n = lg N * (2 ^ { lg N}) = lg N * N$\n命题F。对于长度为N 的任意数组,自顶向下的归并排序需要(1/2)N lgN 至N lgN 次比较。\n注:因为归并所需要的比较次数最少为N/2\n命题G。对于长度为N 的任意数组,自顶向下的归并排序最多需要访问数组6NlgN 次。 证明。每次归并最多需要访问数组6N 次(2N 次用来复制,2N 次用来将排好序的元素移动回去,另外最多比较2N 次),根据命题F 即可得到这个命题的结果。\n自底向上的归并排序 # 递归实现的归并排序时算法设计中分治思想 的典型应用\n自底向上的归并排序的可视轨迹\n源代码\nprivate static Comparable[] aux; private static void sort(Comparable[] a) { int N = a.length; aux = new Comparable[N]; //每次合并的子数组长度翻倍 for (int sz = 1; sz \u0026lt; N; sz = sz + sz) { //lo:子数组索引 //边界问题, 假设是N为2^n,则倒数第二个数组的元素的下标,一定在倒数第一个元素下标(n-sz)之前 for (int lo = 0; lo \u0026lt; N - sz; lo += sz + sz) { //循环合并一个个的小数组 merge(a, lo, lo + sz - 1, Math.min(lo + sz + sz - 1, N - 1)); } } } 子数组的大小sz的初始值为1,每次加倍\n最后一个子数组的大小只有在数组大小是sz的偶数倍的时候才会等于sz(否则比sz小)\n命题H。对于长度为N 的任意数组,自底向上的归并排序需要1/2NlgN 至NlgN 次比较,最多访问数组6NlgN 次。\n自底向上的归并排序比较适合用链表组织的数据。想象一下将链表先按大小为1 的子链表进行排序,然后是大小为2 的子链表,然后是大小为4 的子链表等。这种方法只需要重新组织链表链接就能将链表原地排序(不需要创建任何新的链表结点)\n归并排序告诉我们,当能够用其中一种方法解决一个问题时,都应该试试另一种,可以像Merge.sort()那样化整为零(然后递归地解决)问题,或者像MergeBU.sort()那样循序渐进的解决问题\n命题I。没有任何基于比较的算法能够保证使用少于lg(N!)~ NlgN 次比较将长度为N 的数组排序\n命题J。归并排序是一种渐进最优的基于比较排序的算法。\n快速排序 # 快速排序是应用最广泛的排序算法\n基本算法 # 是一种分治的排序算法,将一个数组分成两个子数组,将两部分独立的排序\n归并排序将数组分成两个子数组分别排序,并将有序的子数组归并以将两个数组排序;快速排序将数组排序的方式是当两个子数组都有序时整个数组也都有序了\n归并排序:递归调用发生在处理数组之前;快速排序:递归调用发生在处理数组之后\n归并排序中数组被分为两半;快速排序中切分取决于数组内容\n快速排序示意图 递归代码\npublic static void sort(Comparable[] a, int lo, int hi) { if (hi \u0026lt;= lo) return; int j = partition(a, lo, hi); //切分 sort(a, lo, j - 1); /// 将左半部分a[lo .. j-1]排序 sort(a, j + 1, hi);//将右半部分a[j+1..hi]排序 } 快速排序递归的将子数组a[lo..hi]排序,先用partition()方法将a[j]放到一个合适的位置,然后再用递归调用将其他位置的元素排序 切分后使得数组满足三个条件\n对于某个j,a[j]已经排定 a[lo]到a[j-1]的所有元素都不大于a[j] a[j+1]的所有元素都不小于a[j] 归纳法证明数组有序:\n如果左子数组和右子数组都是有序的,那么由左子数组(有序且没有任何元素大于切分元素)、切分元素和右子数组(有序且没有任何元素小于切分元素)组成的结果数组也一定是有序的\n一般策略是先随意地取a[lo] 作为切分元素,即那个将会被排定的元素,然后我们从数组的左端开始向右扫描直到找到一个大于等于它的元素,再从数组的右端开始向左扫描直到找到一个小于等于它的元素。这两个元素显然是没有排定的,因此我们交换它们的位置。如此继续,我们就可以保证左指针i 的左侧元素都不大于切分元素,右指针j 的右侧元素都不小于切分元素。当两个指针相遇时,我们只需要将切分元素a[lo] 和左子数组最右侧的元素(a[j])交换然后返回j 即可\n代码如下\nprivate static int partition(Comparable[] a, int lo, int hi) { int i = lo, j = hi + 1; //左右扫描指针 Comparable v = a[lo]; //切分元素 while (true) { //从左往右扫描,如果找到了大于等于v值的数,就退出循环 while (less(a[++i], v)) { if (i == hi) break; } //从右往左扫描,如果找到了小于等于v值得数,就退出循环 while (less(a[--j], v)) { if (j == lo) break; } if (i \u0026gt;= j) break;//如果i,j相遇则退出循环 //将左边大于等于v值的数与右边小于等于v值的数交换 exch(a, i, j); } //上面的遍历结束后,a[lo+1...j]和a[i..hi]都已经分别有序 //且a[j]\u0026lt;=a[i]\u0026lt;=a[lo],所以应该交换a[lo]和a[j](而不是a[i),因为 //a[i]有可能大于a[lo] exch(a, lo, j); //返回a[lo]被交换的位置 return j; } 切分轨迹 性能特点 # 将长度为N的无重复数组排序,快速排序平均需要~2N lnN 次比较(以及1/6的交换)\n算法改进 # 三向切分\n"},{"id":402,"href":"/zh/docs/problem/Git/01/","title":"git使用ssh连不上","section":"Git","content":" 处理方式 在系统的host文件中,添加ip指定\n199.232.69.194 github.global.ssl.fastly.net 140.82.114.4 github.com "},{"id":403,"href":"/zh/docs/life/archive/20220416/","title":"《作酒》有感","section":"往日归档","content":"最近几天吃饭,经常听到一首很嗨的歌。旋律很轻快,其实本来也就一听而过,可能是耳闻目染次数多了,好奇心上来了,查了下歌词。\n听这首歌期间我居然联想了很多,果然是老emo了。不知道怎么回事,我这种与世无争的心态,听完后居然也让我幻想了一下这歌描述的爱情模样。我又突然想到,如今社会上离婚率居高不下,也许与网络信息的传输有密切关联。如果是古代,嫁错人或者娶错人,大家也都都认了,有什么小打小闹都互相包含。而如今,生活压力不断增大,加上网络上爆炸式(至少效果是)的宣传爱情,对比显著,很让人一着魔就陷进去,就摒弃几年甚至十几年的夫妻之情,去追求所谓的真爱、自由。\n每个人对自己的过往,或多或少都会不甘。如果这种不甘自己没有办法化解,那么就会在某一刻爆发。每个人都应该,也必定会为自己曾经的所作所为负责。不要懵懵懂懂地进入(现代)婚姻,这样对自己和它人都极其不负责。 爆炸式的信息接收会激发你所有的冲动与不甘。\n"},{"id":404,"href":"/zh/docs/technology/Algorithm/_algorithhms_4th_/2.1.1/","title":"算法红皮书 2.1.1","section":"_算法(第四版)_","content":" 排序 # 排序就是将一组对象按照某种逻辑顺序重新排序的过程\n对排序算法的分析有助于理解本书中比较算法性能的方法 类似技术能解决其他类型问题 排序算法常常是我们解决其他问题的第一步 初级排序算法 # 熟悉术语及技巧 某些情况下初级算法更有效 有助于改进复杂算法的效率 游戏规则 # 主要关注重新排序数组元素的算法,每个元素都会有一个主键\n排序后索引较大的主键大于索引较小的主键\n一般情况下排序算法通过两个方法操作数据,less()进行比较,exch()进行交换\n排序算法类的模板\npublic class Example { public static void sort(Comparable[] a) { /* 请见算法2.1、算法2.2、算法2.3、算法2.4、算法2.5或算法2.7*/ } private static Boolean less(Comparable v, Comparable w) { return v.compareTo(w) \u0026lt; 0; } private static void exch(Comparable[] a, int i, int j) { Comparable t = a[i]; a[i] = a[j]; a[j] = t; } private static void show(Comparable[] a) { // 在单行中打印数组 for (int i = 0; i \u0026lt; a.length; i++) StdOut.print(a[i] + \u0026#34; \u0026#34;); StdOut.println(); } public static Boolean isSorted(Comparable[] a) { // 测试数组元素是否有序 for (int i = 1; i \u0026lt; a.length; i++) if (less(a[i], a[i-1])) return false; return true; } public static void main(String[] args) { // 从标准输入读取字符串,将它们排序并输出 String[] a = In.readStrings(); sort(a); assert isSorted(a); show(a); } } 使用\n% more tiny.txt S O R T E X A M P L E % java Example \u0026lt; tiny.txt A E E L M O P R S T X % more words3.txt bed bug dad yes zoo ... all bad yet % java Example \u0026lt; words.txt all bad bed bug dad ... yes yet zoo 使用assert验证\n排序成本模型:在研究排序算法时,我们需要计算比较和交换的数量。对于不交换元素的算法,我们会比较访问数组的次数\n额外内存开销和运行时间同等重要,排序算法分为\n除了函数调用需要的栈和固定数目的实例变量之外,无需额外内存的原地排序算法 需要额外内存空间来存储另一份数组副本的其他排序算法 数据类型\n排序模板适用于任何实现了Comparable接口的数据类型\n对于自己的数据类型,实现Comparable接口即可\npublic class Date implements Comparable\u0026lt;Date\u0026gt; { private final int day; private final int month; private final int year; public Date(int d, int m, int y) { day = d; month = m; year = y; } public int day() { return day; } public int month() { return month; } public int year() { return year; } public int compareTo(Date that) { if (this.year \u0026gt; that.year ) return +1; if (this.year \u0026lt; that.year ) return -1; if (this.month \u0026gt; that.month) return +1; if (this.month \u0026lt; that.month) return -1; if (this.day \u0026gt; that.day ) return +1; if (this.day \u0026lt; that.day ) return -1; return 0; } public String toString() { return month + \u0026#34;/\u0026#34; + day + \u0026#34;/\u0026#34; + year; } } compareTo()必须实现全序关系 自反性,反对称性及传递性 经典算法,包括选择排序、插入排序、希尔排序、归并排序、快速排序和堆排序\n"},{"id":405,"href":"/zh/docs/technology/RocketMQ/heima_/05advance/","title":"05高级功能","section":"基础(黑马)_","content":" 学习来源 https://www.bilibili.com/video/BV1L4411y7mn(添加小部分笔记)感谢作者!\n消息存储 # 流程 # 存储介质 # 关系型数据库DB # 适合数据量不够大,比如ActiveMQ可选用JDBC方式作为消息持久化\n文件系统 # 关系型数据库最终也是要存到文件系统中的,不如直接存到文件系统,绕过关系型数据库 常见的RocketMQ/RabbitMQ/Kafka都是采用消息刷盘到计算机的文件系统来做持久化(同步刷盘/异步刷盘) 消息发送 # 顺序写:600MB/s,随机写:100KB/s\n系统运行一段时间后,我们对文件的增删改会导致磁盘上数据无法连续,非常的分散。\n顺序读也只是逻辑上的顺序,也就是按照当前文件的相对偏移量顺序读取,并非磁盘上连续空间读取\n对于磁盘的读写分为两种模式,顺序IO和随机IO。 随机IO存在一个寻址的过程,所以效率比较低。而顺序IO,相当于有一个物理索引,在读取的时候不需要寻找地址,效率很高。\n来源: https://www.cnblogs.com/liuche/p/15455808.html\n数据网络传输\n零拷贝技术MappedByteBuffer,省去了用户态,由内核态直接拷贝到网络驱动内核。 RocketMQ默认设置单个CommitLog日志数据文件为1G\n消息存储 # 三个概念:commitLog、ConsumerQueue、index\nCommitLog # 默认大小1G\n存储消息的元数据,包括了Topic、QueueId、Message 还存储了ConsumerQueue相关信息,所以ConsumerQueue丢了也没事 ConsumerQueue # 存储了消息在CommitLog的索引(几百K,Linux会事先加载到内存中) 包括最小/最大偏移量、已经消费的偏移量 一个Topic多个队列,每个队列对应一个ConsumerQueue\nIndex # 也是索引文件,为消息查询服务,通过key或时间区间查询消息\n总结 # 刷盘机制 # 同步刷盘 异步刷盘 高可用性机制 # 消费高可用及发送高可用 # 消息主从复制 # 负载均衡 # 消息重试 # 下面都是针对消费失败的重试\n顺序消息 # RocketMQ会自动不断重试,且为了保证顺序性,会导致消息消费被阻塞。使用时要及时监控并处理消费失败现象\n无序消息(普通、定时、延时、事务) # 通过设置返回状态达到消息重试的结果 重试只对集群消费方式生效,广播方式不提供重试特性 重试次数 如果16次后还是消费失败,会进入死信队列,不再被消费 配置是否重试 # 重试 # 不重试,认为消费成功 # 修改重试次数 # 在创建消费者的时候,传入Properties即可\n注意事项 # messge.getReconsumeTimes()获取消息已经重试的次数\n死信队列 # 特性 # 针对的是消费者组;不再被正常消费;有过期时间;\n查看 # 通过admin的控制台查看\n可重发;可指定后特殊消费\n可以重发,也可以写一个消费者,指定死信队列里面的消息\n消费幂等 # 同一条消息不论消费多少次,结果应该都是一样的\n发送时发送的消息重复 # "},{"id":406,"href":"/zh/docs/technology/Algorithm/_algorithhms_4th_/1.5.1-1.5.3/","title":"算法红皮书 1.5.1-1.5.3","section":"_算法(第四版)_","content":" 案例研究:union-find 算法 # 设计和分析算法的基本方法 优秀的算法能解决实际问题 高效的算法也可以很简单 理解某个实现的性能特点是一项有趣的挑战 在解决同一个问题的多种算法间选择,科学方法是一种重要工具 迭代式改进能让算法效率越来越高 动态连通性 # 从输入中读取整数对p q,如果已知的所有整数对都不能说明p,q相连,就打印出pq 网络:整个程序能够判定是否需要在pq之间架设一条新的连接才能进行通信 变量名等价性(即指向同一个对象的多个引用) 数学集合:在处理一个整数对pq时,我们是在判断它们是否属于相同的集合 本节中,将对象称为触点,整数对称为连接,等价类称为连通分量或是简称分量 连通性 问题只要求我们的程序能够判别给定的整数对pq是否相连,并没有要求给两者之间的通路上的所有连接 union-find算法的API\n数据结构和算法的设计影响到算法的效率 实现 # public class UF { private int[]\tid; /* 分量id(以触点作为索引) */ private int\tcount; /* 分量数量 */ public UF( int N ) { /* 初始化分量id数组 */ count\t= N; id\t= new int[N]; for ( int i = 0; i \u0026lt; N; i++ ) id[i] = i; } public int count() { return(count); } public Boolean connected( int p, int q ) { return(find( p ) == find( q ) ); } public int find( int p ) public void union( int p, int q ) /* 请见1.5.2.1节用例(quick-find)、1.5.2.3节用例(quick-union)和算法1.5(加权quick-union) */ public static void main( String[] args ) { /* 解决由StdIn得到的动态连通性问题 */ int\tN\t= StdIn.readint(); /* 读取触点数量 */ UF\tuf\t= new UF( N ); /* 初始化N个分量 */ while ( !StdIn.isEmpty() ) { int\tp\t= StdIn.readint(); int\tq\t= StdIn.readint(); /* 读取整数对 */ if ( uf.connected( p, q ) ) continue; /* 如果已经连通则忽略 */ uf.union( p, q ); /* 归并分量 */ StdOut.println( p + \u0026#34; \u0026#34; + q ); /* 打印连接 */ } StdOut.println( uf.count() + \u0026#34;components\u0026#34; ); } } union-find的成本模型:union-find API的各种算法,统计的是数组的访问次数,不论读写\n以下有三种实现\n且仅当id[p] 等于id[q] 时p 和q 是连通的\npublic int find(int p) { return id[p]; } public void union(int p, int q) { // 将p和q归并到相同的分量中 int pID = find(p);mi int qID = find(q); // 如果p和q已经在相同的分量之中则不需要采取任何行动 if (pID == qID) return; // 将p的分量重命名为q的名称 for (int i = 0; i \u0026lt; id.length; i++) if (id[i] == pID) id[i] = qID; count--; } 命题F:在quick-find 算法中,每次find() 调用只需要访问数组一次,而归并两个分量的union() 操作访问数组的次数在(N+3) 到(2N+1) 之间。\n证明:由代码马上可以知道,每次connected() 调用都会检查id[] 数组中的两个元素是否相等,即会调用两次find() 方法。归并两个分量的union() 操作会调用两次find(),检查id[] 数组中的全部N 个元素并改变它们中1 到N-1 个元素的值。\n假设我们使用quick-find 算法来解决动态连通性问题并且最后只得到了一个连通分量,那么这至少需要调用N-1 次union(),即至少(N+3)(N-1) ~ N2 次数组访问——我们马上可以猜想动态连通性的quick-find 算法是平方级别的\n以触点作为索引的id[]数组,每个触点所对应的id[]元素都是同一个分量中的另一个触点的名称 如下图: private int find(int p) { // 找出分量的名称 while (p != id[p]) p = id[p]; return p; } public void union(int p, int q) { // 将p和q的根节点统一 int pRoot = find(p); int qRoot = find(q); if (pRoot == qRoot) return; id[pRoot] = qRoot; count--; } quick-union算法的最坏情况 加权quick-union算法(减少树的高度) 用一个数组来表示各个节点对应的分量的大小\npublic class WeightedQuickUnionUF { private int[] id; // 父链接数组(由触点索引) private int[] sz; // (由触点索引的)各个根节点所对应的分量的大小 private int count; // 连通分量的数量 public WeightedQuickUnionUF(int N) { count = N; id = new int[N]; for (int i = 0; i \u0026lt; N; i++) id[i] = i; sz = new int[N]; for (int i = 0; i \u0026lt; N; i++) sz[i] = 1; } public int count() { return count; } public Boolean connected(int p, int q) { return find(p) == find(q); } public int find(int p) { // 跟随链接找到根节点 while (p != id[p]) p = id[p]; return p; } public void union(int p, int q) { int i = find(p); int j = find(q); if (i == j) return; // 将小树的根节点连接到大树的根节点 if (sz[i] \u0026lt; sz[j]) { id[i] = j; sz[j] += sz[i]; } else { id[j] = i; sz[i] += sz[j]; } count--; } } quick-union 算法与加权quick-union 算法的对比(100 个触点,88 次union() 操作) 所有操作的总成本 展望 # 研究问题的步骤\n完整而详细地定义问题,找出解决问题所必需的基本抽象操作并定义一份 API。 简洁地实现一种初级算法,给出一个精心组织的开发用例并使用实际数据作为输入。 当实现所能解决的问题的最大规模达不到期望时决定改进还是放弃。 逐步改进实现,通过经验性分析或(和)数学分析验证改进后的效果。 用更高层次的抽象表示数据结构或算法来设计更高级的改进版本。 如果可能尽量为最坏情况下的性能提供保证,但在处理普通数据时也要有良好的性能。 在适当的时候将更细致的深入研究留给有经验的研究者并继续解决下一个问题。 "},{"id":407,"href":"/zh/docs/technology/RocketMQ/heima_/04case/","title":"04案例","section":"基础(黑马)_","content":" 学习来源 https://www.bilibili.com/video/BV1L4411y7mn(添加小部分笔记)感谢作者!基本架构\n架构 # 流程图 # 下单流程 # 支付流程 # SpringBoot整合RocketMQ # 依赖包 # \u0026lt;dependency\u0026gt; \u0026lt;groupId\u0026gt;org.apache.rocketmq\u0026lt;/groupId\u0026gt; \u0026lt;artifactId\u0026gt;rocketmq-spring-boot-starter\u0026lt;/artifactId\u0026gt; \u0026lt;/dependency\u0026gt; 生产者 # yaml # rocketmq: name-server: 192.168.1.135:9876;192.168.1.138:9876 producer: group: my-group 使用 # @Autowired private RocketMQTemplate template; @RequestMapping(\u0026#34;rocketmq\u0026#34;) public String rocketmq(){ log.info(\u0026#34;我被调用了-rocketmq\u0026#34;); //主题+内容 template.convertAndSend(\u0026#34;mytopic-ly\u0026#34;,\u0026#34;hello1231\u0026#34;); return \u0026#34;hello world\u0026#34;+serverPort; } 消费者 # yaml # rocketmq: name-server: 192.168.1.135:9876;192.168.1.138:9876 consumer: group: my-group2 使用 # 创建监听器\n@RocketMQMessageListener(topic = \u0026#34;mytopic-ly\u0026#34;, consumeMode = ConsumeMode.CONCURRENTLY,consumerGroup = \u0026#34;${rocketmq.producer.group}\u0026#34;) @Slf4j @Component public class Consumer implements RocketMQListener\u0026lt;String\u0026gt; { @Override public void onMessage(String s) { log.info(\u0026#34;消费了\u0026#34;+s); } } 下单流程利用MQ进行回退处理,保证数据一致性 # 库存回退的消费者,代码如下:\n@Slf4j @Component @RocketMQMessageListener(topic = \u0026#34;${mq.order.topic}\u0026#34;,consumerGroup = \u0026#34;${mq.order.consumer.group.name}\u0026#34;,messageModel = MessageModel.BROADCASTING ) public class CancelMQListener implements RocketMQListener\u0026lt;MessageExt\u0026gt;{ @Value(\u0026#34;${mq.order.consumer.group.name}\u0026#34;) private String groupName; @Autowired private TradeGoodsMapper goodsMapper; @Autowired private TradeMqConsumerLogMapper mqConsumerLogMapper; @Autowired private TradeGoodsNumberLogMapper goodsNumberLogMapper; @Override public void onMessage(MessageExt messageExt) { String msgId=null; String tags=null; String keys=null; String body=null; try { //1. 解析消息内容 msgId = messageExt.getMsgId(); tags= messageExt.getTags(); keys= messageExt.getKeys(); body= new String(messageExt.getBody(),\u0026#34;UTF-8\u0026#34;); log.info(\u0026#34;接受消息成功\u0026#34;); //2. 查询消息消费记录 TradeMqConsumerLogKey primaryKey = new TradeMqConsumerLogKey(); primaryKey.setMsgTag(tags); primaryKey.setMsgKey(keys); primaryKey.setGroupName(groupName); TradeMqConsumerLog mqConsumerLog = mqConsumerLogMapper.selectByPrimaryKey(primaryKey); if(mqConsumerLog!=null){ //3. 判断如果消费过... //3.1 获得消息处理状态 Integer status = mqConsumerLog.getConsumerStatus(); //处理过...返回 if(ShopCode.SHOP_MQ_MESSAGE_STATUS_SUCCESS.getCode().intValue()==status.intValue()){ log.info(\u0026#34;消息:\u0026#34;+msgId+\u0026#34;,已经处理过\u0026#34;); return; } //正在处理...返回 if(ShopCode.SHOP_MQ_MESSAGE_STATUS_PROCESSING.getCode().intValue()==status.intValue()){ log.info(\u0026#34;消息:\u0026#34;+msgId+\u0026#34;,正在处理\u0026#34;); return; } //处理失败 if(ShopCode.SHOP_MQ_MESSAGE_STATUS_FAIL.getCode().intValue()==status.intValue()){ //获得消息处理次数 Integer times = mqConsumerLog.getConsumerTimes(); if(times\u0026gt;3){ log.info(\u0026#34;消息:\u0026#34;+msgId+\u0026#34;,消息处理超过3次,不能再进行处理了\u0026#34;); return; } mqConsumerLog.setConsumerStatus(ShopCode.SHOP_MQ_MESSAGE_STATUS_PROCESSING.getCode()); //使用数据库乐观锁更新 TradeMqConsumerLogExample example = new TradeMqConsumerLogExample(); TradeMqConsumerLogExample.Criteria criteria = example.createCriteria(); criteria.andMsgTagEqualTo(mqConsumerLog.getMsgTag()); criteria.andMsgKeyEqualTo(mqConsumerLog.getMsgKey()); criteria.andGroupNameEqualTo(groupName); criteria.andConsumerTimesEqualTo(mqConsumerLog.getConsumerTimes()); int r = mqConsumerLogMapper.updateByExampleSelective(mqConsumerLog, example); if(r\u0026lt;=0){ //未修改成功,其他线程并发修改 log.info(\u0026#34;并发修改,稍后处理\u0026#34;); } } }else{ //4. 判断如果没有消费过... mqConsumerLog = new TradeMqConsumerLog(); mqConsumerLog.setMsgTag(tags); mqConsumerLog.setMsgKey(keys); mqConsumerLog.setGroupName(groupName); mqConsumerLog.setConsumerStatus(ShopCode.SHOP_MQ_MESSAGE_STATUS_PROCESSING.getCode()); mqConsumerLog.setMsgBody(body); mqConsumerLog.setMsgId(msgId); mqConsumerLog.setConsumerTimes(0); //将消息处理信息添加到数据库 mqConsumerLogMapper.insert(mqConsumerLog); } //5. 回退库存 MQEntity mqEntity = JSON.parseObject(body, MQEntity.class); Long goodsId = mqEntity.getGoodsId(); TradeGoods goods = goodsMapper.selectByPrimaryKey(goodsId); goods.setGoodsNumber(goods.getGoodsNumber()+mqEntity.getGoodsNum()); goodsMapper.updateByPrimaryKey(goods); //6. 将消息的处理状态改为成功 mqConsumerLog.setConsumerStatus(ShopCode.SHOP_MQ_MESSAGE_STATUS_SUCCESS.getCode()); mqConsumerLog.setConsumerTimestamp(new Date()); mqConsumerLogMapper.updateByPrimaryKey(mqConsumerLog); log.info(\u0026#34;回退库存成功\u0026#34;); } catch (Exception e) { e.printStackTrace(); TradeMqConsumerLogKey primaryKey = new TradeMqConsumerLogKey(); primaryKey.setMsgTag(tags); primaryKey.setMsgKey(keys); primaryKey.setGroupName(groupName); TradeMqConsumerLog mqConsumerLog = mqConsumerLogMapper.selectByPrimaryKey(primaryKey); if(mqConsumerLog==null){ //数据库未有记录 mqConsumerLog = new TradeMqConsumerLog(); mqConsumerLog.setMsgTag(tags); mqConsumerLog.setMsgKey(keys); mqConsumerLog.setGroupName(groupName); mqConsumerLog.setConsumerStatus(ShopCode.SHOP_MQ_MESSAGE_STATUS_FAIL.getCode()); mqConsumerLog.setMsgBody(body); mqConsumerLog.setMsgId(msgId); mqConsumerLog.setConsumerTimes(1); mqConsumerLogMapper.insert(mqConsumerLog); }else{ mqConsumerLog.setConsumerTimes(mqConsumerLog.getConsumerTimes()+1); mqConsumerLogMapper.updateByPrimaryKeySelective(mqConsumerLog); } } } } "},{"id":408,"href":"/zh/docs/technology/RocketMQ/heima_/03messagetype/","title":"03收发消息","section":"基础(黑马)_","content":" 学习来源 https://www.bilibili.com/video/BV1L4411y7mn(添加小部分笔记)感谢作者!前提\n依赖包 # \u0026lt;dependency\u0026gt; \u0026lt;groupId\u0026gt;org.apache.rocketmq\u0026lt;/groupId\u0026gt; \u0026lt;artifactId\u0026gt;rocketmq-client\u0026lt;/artifactId\u0026gt; \u0026lt;version\u0026gt;4.4.0\u0026lt;/version\u0026gt; \u0026lt;/dependency\u0026gt; 消息生产者步骤 # 创建生产者,生产者组名\u0026ndash;\u0026gt;指定nameserver地址\u0026ndash;\u0026gt;启动producer\u0026ndash;\u0026gt;\n创建消息对象(Topic、Tag、消息体)\n发送消息、关闭生产者producer\n消息消费者步骤 # 创建消费者,制定消费者组名\u0026ndash;\u0026gt;指定nameserver地址\n订阅Topic和Tag,设置回调函数处理消息\n启动消费者consumer\n消息发送 # 同步消息 # 发送消息后客户端会进行阻塞,直到得到结果后,客户端才会继续执行\npublic static void main(String[] args) throws MQClientException, MQBrokerException, RemotingException, InterruptedException { //创建Producer,并指定生产者组 DefaultMQProducer producer = new DefaultMQProducer(\u0026#34;group1\u0026#34;); producer.setNamesrvAddr(\u0026#34;192.168.1.135:9876;192.168.1.138:9876\u0026#34;); producer.start(); for (int i = 0; i \u0026lt; 10; i++) { Message msg = new Message(); msg.setTopic(\u0026#34;base\u0026#34;); msg.setTags(\u0026#34;Tag1\u0026#34;); msg.setBody((\u0026#34;hello world\u0026#34; + i).getBytes()); //发送消息 SendResult result = producer.send(msg); //发送状态 SendStatus sendStatus = result.getSendStatus(); //消息id String msgId = result.getMsgId(); //消息接收队列id MessageQueue messageQueue = result.getMessageQueue(); int queueId = messageQueue.getQueueId(); log.info(result.toString()); log.info(messageQueue.toString()); log.info(\u0026#34;status:\u0026#34; + sendStatus + \u0026#34;msgId:\u0026#34; + msgId + \u0026#34;queueId\u0026#34; + queueId); TimeUnit.SECONDS.sleep(1); } log.info(\u0026#34;发送结束===================\u0026#34;); producer.shutdown(); } 异步消息 # 发送消息后不会导致阻塞,当broker返回结果时,会调用回调函数进行处理\npublic static void main(String[] args) throws MQClientException, MQBrokerException, RemotingException, InterruptedException { //创建Producer,并指定生产者组 DefaultMQProducer producer = new DefaultMQProducer(\u0026#34;group1\u0026#34;); producer.setNamesrvAddr(\u0026#34;192.168.1.135:9876;192.168.1.138:9876\u0026#34;); producer.start(); for (int i = 0; i \u0026lt; 10; i++) { Message msg = new Message(); msg.setTopic(\u0026#34;base\u0026#34;); msg.setTags(\u0026#34;Tag1\u0026#34;); msg.setBody((\u0026#34;hello world\u0026#34; + i).getBytes()); //发送消息 producer.send(msg, new SendCallback() { @Override public void onSuccess(SendResult result) { //发送状态 SendStatus sendStatus = result.getSendStatus(); //消息id String msgId = result.getMsgId(); //消息接收队列id MessageQueue messageQueue = result.getMessageQueue(); int queueId = messageQueue.getQueueId(); log.info(result.toString()); log.info(messageQueue.toString()); log.info(\u0026#34;status:\u0026#34; + sendStatus + \u0026#34;msgId:\u0026#34; + msgId + \u0026#34;queueId\u0026#34; + queueId); } @Override public void onException(Throwable throwable) { log.error(\u0026#34;发送异常\u0026#34; + throwable); } }); //TimeUnit.SECONDS.sleep(1); } log.info(\u0026#34;发送结束===================\u0026#34;); TimeUnit.SECONDS.sleep(3); } 单向消息 # 不关心发送结果\npublic static void main(String[] args) throws MQClientException, MQBrokerException, RemotingException, InterruptedException { //创建Producer,并指定生产者组 DefaultMQProducer producer = new DefaultMQProducer(\u0026#34;group1\u0026#34;); producer.setNamesrvAddr(\u0026#34;192.168.1.135:9876;192.168.1.138:9876\u0026#34;); producer.start(); for (int i = 0; i \u0026lt; 10; i++) { Message msg = new Message(); msg.setTopic(\u0026#34;base\u0026#34;); msg.setTags(\u0026#34;Tag3\u0026#34;); msg.setBody((\u0026#34;hello world danxiang\u0026#34; + i).getBytes()); //发送消息 producer.sendOneway(msg); //TimeUnit.SECONDS.sleep(1); } log.info(\u0026#34;发送结束===================\u0026#34;); TimeUnit.SECONDS.sleep(3); } 消费消息 # public static void main(String[] args) throws MQClientException { DefaultMQPushConsumer consumer = new DefaultMQPushConsumer(\u0026#34;group1\u0026#34;); consumer.setNamesrvAddr(\u0026#34;192.168.1.135:9876;192.168.1.138:9876\u0026#34;); consumer.subscribe(\u0026#34;base\u0026#34;, \u0026#34;Tag3\u0026#34;); consumer.registerMessageListener(new MessageListenerConcurrently() { @Override public ConsumeConcurrentlyStatus consumeMessage(List\u0026lt;MessageExt\u0026gt; list, ConsumeConcurrentlyContext consumeConcurrentlyContext) { for (MessageExt messageExt : list) { log.info(messageExt.toString()); String s = new String(messageExt.getBody()); log.info(s); } return ConsumeConcurrentlyStatus.CONSUME_SUCCESS; } }); consumer.start(); } 消费模式 # 注意事项 # 如果一个消息在广播消费模式下被消费过,之后再启动一个消费者,那么它可以在集群消费模式下再被消费一次。或者:\n如果一个消息在集群消费模式下被消费过,之后再启动一个消费者,那么它可以在广播消费模式下再被消费一次 如果一个消息在广播消费模式下被消费过,之后再启动一个消费者,那么它不能在广播模式下再被消费。或者\n如果一个消息在集群消费模式下被消费过,之后再启动一个消费者,那么它不能在集群模式下再被消费。 顺序消息 # 消息实体 # @Data @AllArgsConstructor @NoArgsConstructor @ToString public class OrderStep { private int orderId; private String desc; public static List\u0026lt;OrderStep\u0026gt; getData(){ List\u0026lt;OrderStep\u0026gt; orderSteps=new ArrayList\u0026lt;\u0026gt;(); OrderStep orderStep=new OrderStep(123,\u0026#34;创建\u0026#34;); orderSteps.add(orderStep); orderStep=new OrderStep(125,\u0026#34;创建\u0026#34;); orderSteps.add(orderStep); orderStep=new OrderStep(123,\u0026#34;付款\u0026#34;); orderSteps.add(orderStep); orderStep=new OrderStep(125,\u0026#34;付款\u0026#34;); orderSteps.add(orderStep); orderStep=new OrderStep(123,\u0026#34;推送\u0026#34;); orderSteps.add(orderStep); orderStep=new OrderStep(124,\u0026#34;创建\u0026#34;); orderSteps.add(orderStep); orderStep=new OrderStep(124,\u0026#34;付款\u0026#34;); orderSteps.add(orderStep); orderStep=new OrderStep(124,\u0026#34;推送\u0026#34;); orderSteps.add(orderStep); orderStep=new OrderStep(123,\u0026#34;完成\u0026#34;); orderSteps.add(orderStep); orderStep=new OrderStep(125,\u0026#34;推送\u0026#34;); orderSteps.add(orderStep); return orderSteps; } } 发送消息 # //同一个订单的消息,放在同一个topic的同一个queue里面 public static void main(String[] args) throws MQClientException { DefaultMQPushConsumer consumer = new DefaultMQPushConsumer(\u0026#34;group1\u0026#34;); consumer.setNamesrvAddr(\u0026#34;192.168.1.135:9876;192.168.1.138:9876\u0026#34;); consumer.subscribe(\u0026#34;base\u0026#34;, \u0026#34;Tag1\u0026#34;); consumer.setMessageModel(MessageModel.BROADCASTING); consumer.registerMessageListener(new MessageListenerConcurrently() { @Override public ConsumeConcurrentlyStatus consumeMessage(List\u0026lt;MessageExt\u0026gt; list, ConsumeConcurrentlyContext consumeConcurrentlyContext) { for (MessageExt messageExt : list) { //log.info(messageExt.toString()); String s = new String(messageExt.getBody()); log.info(s); } return ConsumeConcurrentlyStatus.CONSUME_SUCCESS; } }); consumer.start(); } 顺序消费消息 # public class ConsumerOrder { public static void main(String[] args) throws MQClientException { DefaultMQPushConsumer consumer = new DefaultMQPushConsumer(\u0026#34;group1\u0026#34;); consumer.setNamesrvAddr(\u0026#34;192.168.1.135:9876;192.168.1.138:9876\u0026#34;); consumer.subscribe(\u0026#34;OrderTopic\u0026#34;, \u0026#34;*\u0026#34;); consumer.registerMessageListener(new MessageListenerOrderly() { @Override public ConsumeOrderlyStatus consumeMessage(List\u0026lt;MessageExt\u0026gt; list, ConsumeOrderlyContext consumeOrderlyContext) { for (MessageExt messageExt : list) { //log.info(messageExt.toString()); String s = new String(messageExt.getBody()); log.info(s); } return ConsumeOrderlyStatus.SUCCESS; } }); consumer.start(); } } MessageListenerOrderly 保证了同一时刻只有一个线程去消费这个queue,但不能保证每次消费queue的会是同一个线程\n由于queue具有先进先出的有序性,所以这并不影响消费queue中消息的顺序性\n延时消息 # 在生产者端设置,可以设置一个消息在一定延时后才能消费\nmessage.setDelayTimLevel(2) //级别2,即延时10秒//1s 5s 10s 30s 1m\n批量消息发送 # producer.send(List\u0026lt;Message\u0026gt; messages)\n事务消息 # 事务消息的架构图 # 生产者 # public class SyncProducer { public static void main(String[] args) throws MQClientException, MQBrokerException, RemotingException, InterruptedException { //创建Producer,并指定生产者组 TransactionMQProducer producer = new TransactionMQProducer(\u0026#34;group1\u0026#34;); producer.setNamesrvAddr(\u0026#34;192.168.1.135:9876;192.168.1.138:9876\u0026#34;); producer.setTransactionListener(new TransactionListener() { /** * 在该方法中执行本地事务 * @param message * @param o * @return */ @Override public LocalTransactionState executeLocalTransaction(Message message, Object o) { if(\u0026#34;TAGA\u0026#34;.equals(message.getTags())){ return LocalTransactionState.COMMIT_MESSAGE; }else if(\u0026#34;TAGB\u0026#34;.equals(message.getTags())){ return LocalTransactionState.ROLLBACK_MESSAGE; }else if(\u0026#34;TAGC\u0026#34;.equals(message.getTags())){ return LocalTransactionState.UNKNOW; } return LocalTransactionState.UNKNOW; } /** * 该方法时MQ进行消息是无状态的回查 * @param messageExt * @return */ @Override public LocalTransactionState checkLocalTransaction(MessageExt messageExt) { log.info(\u0026#34;消息的回查:\u0026#34;+messageExt.getTags()); try { log.info(\u0026#34;5s后告诉mq可以提交了\u0026#34;); TimeUnit.SECONDS.sleep(5); } catch (InterruptedException e) { e.printStackTrace(); } //可以提交 return LocalTransactionState.COMMIT_MESSAGE; } }); producer.start(); String[] tags={\u0026#34;TAGA\u0026#34;,\u0026#34;TAGB\u0026#34;,\u0026#34;TAGC\u0026#34;}; for (int i = 0; i \u0026lt; 3; i++) { Message msg = new Message(); msg.setTopic(\u0026#34;TransactionTopic\u0026#34;); msg.setTags(tags[i]); msg.setBody((\u0026#34;hello world\u0026#34; + i).getBytes()); //发送消息 //参数:针对某一个消息进行事务控制 SendResult result = producer.sendMessageInTransaction(msg,null); //发送状态 SendStatus sendStatus = result.getSendStatus(); log.info(result.toString()); log.info(\u0026#34;status:\u0026#34; + sendStatus ); } log.info(\u0026#34;发送结束===================\u0026#34;); //producer.shutdown(); } } 消费者 # @Slf4j public class Consumer { public static void main(String[] args) throws MQClientException { DefaultMQPushConsumer consumer = new DefaultMQPushConsumer(\u0026#34;group1\u0026#34;); consumer.setNamesrvAddr(\u0026#34;192.168.1.135:9876;192.168.1.138:9876\u0026#34;); consumer.subscribe(\u0026#34;TransactionTopic\u0026#34;, \u0026#34;*\u0026#34;); consumer.registerMessageListener(new MessageListenerConcurrently() { @Override public ConsumeConcurrentlyStatus consumeMessage(List\u0026lt;MessageExt\u0026gt; list, ConsumeConcurrentlyContext consumeConcurrentlyContext) { for (MessageExt messageExt : list) { String s = new String(messageExt.getBody()); log.info(s); } return ConsumeConcurrentlyStatus.CONSUME_SUCCESS; } }); consumer.start(); log.info(\u0026#34;生产者启动----\u0026#34;); } } "},{"id":409,"href":"/zh/docs/technology/Algorithm/_algorithhms_4th_/1.4.1-1.4.10/","title":"算法红皮书 1.4.1-1.4.10","section":"_算法(第四版)_","content":" 算法分析 # 使用数学分析为算法成本建立简洁的模型,并使用实验数据验证这些模型\n科学方法 # 观察、假设、预测、观察并核实预测、反复确认预测和观察 原则:实验可重现 观察 # 计算性任务的困难程度可以用问题的规模来衡量\n问题规模可以是输入的大小或某个命令行参数的值\n研究问题规模和运行时间的关系\n使用计时器得到大概的运行时间 典型用例\npublic static void main(String[] args) { int N = Integer.parseInt(args[0]); int[] a = new int[N]; for (int i = 0; i \u0026lt; N; i++) a[i] = StdRandom.uniform(-1000000, 1000000); Stopwatch timer = new Stopwatch(); int cnt = ThreeSum.count(a); double time = timer.elapsedTime(); StdOut.println(cnt + \u0026#34; triples \u0026#34; + time + \u0026#34; seconds\u0026#34;); } 使用方法 数据类型的实现\npublic class Stopwatch { private final long start; public Stopwatch() { start = System.currentTimeMillis(); } public double elapsedTime() { long now = System.currentTimeMillis(); return (now - start) / 1000.0; } } 数学模型 # 程序运行的总时间主要和两点有关:执行每条语句的耗时;执行每条语句的频率\n定义:我们用~f(N) 表示所有随着N 的增大除以f(N) 的结果趋近于1 的函数。我们用g(N) ~ f(N) 表示g(N)/f(N) 随着N 的增大趋近于1。 即使用曰等号忽略较小的项\n$$ f(N)=N^b(logN)^c $$将f(N)称为g(N)的增长的数量级\n常见的增长数量级函数 本书用性质表示需要用实验验证的猜想\nThreeSum分析 执行最频繁的指令决定了程序执行的总时间\u0026ndash;我们将这些指令称为程序的内循环\n程序运行时间的分析 算法的分析 ThreeSum的运行时间增长数量级为N^3,与在哪台机器无关\n成本模型 3-sum的成本模型:数组的访问次数(访问数组元素的次数,无论读写)\n总结-得到运行时间的数学模型所需的步骤\n确定输入模型,定义问题的规模 识别内循环 根据内循环中的操作确定成本模型 对于给定的输入,判断这些操作的执行效率 增长数量级的分类 # 成长增长的数量级一般都是问题规模N的若干函数之一,如下表 常数级别表示运行时间不依赖于N 对数级别,经典例子是二分查找 线性级别(常见的for循环) 线性对数级别 ,其中,对数的底数和增长的数量级无关 平方级别,一般指两个嵌套的for循环 立方级别,一般含有三个嵌套的for循环 指数级别 问题规模(图) 典型的增长数量级函数(图) 典型的增长数量级函数 在某个成本模型下可以提出精确的命题 比如,归并排序所需的比较次数在$1/2NlgN$~$NlgN$之间 ,即归并排序所需的运行时间的增长数量级是线性对数的,也就是:归并排序是线性对数的 设计更快的算法 # 前提,目前已知归并排序是线性对数级别的,二分查找是对数级别的\n将3-sum问题简化为2-sum问题,即找出一个输入文件中所有和为0的整数对的数量,为了简化问题,题设所有整数均不相同\n可以使用双层循环,以平方级别来解决\n改进后的算法,当且仅当-a[i]存在于数组中且a[i]非零时,a[i]存在于某个和为0的整数对之中\n代码如下\nimport java.util.Arrays; public class TwoSumFast { public static int count(int[] a) { // 计算和为0的整数对的数目 Arrays.sort(a); int N = a.length; int cnt = 0; for (int i = 0; i \u0026lt; N; i++) if (BinarySearch.rank(-a[i], a) \u0026gt; i) cnt++; return cnt; } public static void main(String[] args) { int[] a = In.readInts(args[0]); StdOut.println(count(a)); } } 3-sum问题的快速算法\n当且仅当-(a[i]+a[j])在数组中,且不是a[i]也不是a[j]时,整数对(a[i]和a[j])为某个和为0的三元组的一部分\n总运行时间和$N^2logN$成正比\n代码如下\nimport java.util.Arrays; public class ThreeSumFast { public static int count(int[] a) { // 计算和为0的三元组的数目 Arrays.sort(a); int N = a.length; int cnt = 0; for (int i = 0; i \u0026lt; N; i++) for (int j = i + 1; j \u0026lt; N; j++) if (BinarySearch.rank(-a[i] - a[j], a) \u0026gt; j) { cnt++; } return cnt; } public static void main(String[] args) { int[] a = In.readInts(args[0]); StdOut.println(count(a)); } } 下界\n为算法在最坏情况下的运行时间给出一个下界的思 想是非常有意义的\n运行时间的总结\n图1 图2 实现并分析该问题的一种简单解法,我们称之为暴力算法\n算法的改进,能降低算法所需的运行时间的增长数量级\n倍率实验 # 翻倍后运行时间,与没翻倍时的运行时间成正比\n代码\npublic class DoublingRatio { public static double timeTrial(int N) // 参见DoublingTest(请见1.4.2.3 节实验程序) public static void main(String[] args) { double prev = timeTrial(125); for (int N = 250; true; N += N) { double time = timeTrial(N); StdOut.printf(\u0026#34;%6d %7.1f \u0026#34;, N, time); StdOut.printf(\u0026#34;%5.1fn\u0026#34;, time/prev); prev = time; } } } 试验结果 预测 倍率定理(没看懂,不管) 评估它解决大型问题的可行性 评估使用更快的计算机所产生的价值 注意事项 # 大常数,$c = 103或106$ 非决定性的内循环 指令时间 系统因素 不分伯仲(相同任务在不同场景效率不一样) 对输入的强烈依赖 多个问题参量 处理对于输入的依赖 # 输入模型,例如假设ThreeSum的所有输入均为随机int值,可能不切实际 输入的分析,需要数学几千 对最坏情况下的性能保证 命题(这里只针对之前的代码) 对计划算法,有时候对输入需要进行打乱 操作序列 均摊分析 通过记录所有操作的总成本并除以操作总数来将成本均摊 内存 # Java的内存分配系统 原始数据类型的常见内存、需求 这里漏了,short也是2字节。总结boolean、byte 1字节;char、short 2字节;int、float 4字节;long、double 8字节 对象(跳过) 要知道一个对象所使用的内存量,需要将所有实例变量使用的内存与内存本身的开销(一般是16字节)\n一般内存的使用都会被填充为8字节的倍数(注意,说的是64位计算机中的机器字)\n引用存储需要8字节\n典型对象的内存需求 例如第一个,16+4=20;20+4 = 24为8的倍数\n链表,嵌套的非静态(内部)类,如上面的Node,需要额外的8字节(用于外部类的引用)\n数组 int值、double值、对象和数组的数组对内存的典型需求 比如一个原始数据类型的数组,需要24字节的头信息(16字节的对象开销,4字节用于保存长度[数组长度],以及4填充字节,再加上保存值需要的内存) Date对象需要的:一个含有N 个Date 对象(请见表1.2.12)的数 组需要使用24 字节(数组开销)加上8N 字节(所有引用)加上每个对象的32 字节,总共(24 +40N)字节 【这里说的是需要,和本身存储是两回事】\n![ly-20241212142056395](img/ly-20241212142056395.png) 字符串对象\nString 的标准实现含有4 个实例变量:一个指向字符数组的引用(8 字节)和三 个int 值(各4 字节)。第一个int 值描述的是字符数组中的偏移量,第二个int 值是一个计数器(字符串的长度)。按照图1.4.10 中所示的实例变量名,对象所表示的字符串由value[offset]到value[offset + count - 1] 中的字符组成。String 对象中的第三个int 值是一个散列值,它在某些情况下可以节省一些计算,我们现在可以忽略它。因此,每个String 对象总共会使用40字节(16 字节表示对象,三个int 实例变量各需4 字节,加上数组引用的8 字节和4 个填充字节)\n字符串的值和子字符串\n一个长度为N 的String 对象一般需要使用40 字节(String 对象本身)加上(24+2N)字节(字符数组),总共(64+2N)字节 Java 对字符串的表示希望能够避免复制字符串中的字符 一个子字符串所需的额外内存是一个常数,构造一个子字符串所需的时间也是常数 关于子字符串 展望 # 最重要的是代码正确,其次才是性能 "},{"id":410,"href":"/zh/docs/technology/Algorithm/_algorithhms_4th_/1.3.3.1-1.3.4/","title":"算法红皮书1.3.3.1-1.3.4","section":"_算法(第四版)_","content":" 背包、队列和栈 # 链表 # 链表是一种递归的数据结构,它或者为空(null),或者是一个指向一个结点(node)的引用,该节点含有一个泛型的元素和一个指向另一条链表的引用。 结点记录 # 使用嵌套类定义结点的抽象数据类型\nprivate class Node { Item item; Node next; } 该类没有其它任何方法,且会在代码中直接引用实例变量,这种类型的变量称为记录 构造链表 # 需要一个Node类型的变量,保证它的值是null或者指向另一个Node对象的next域指向了另一个链表 如下图 链表表示的是一列元素 链式结构在本书中的可视化表示 长方形表示对象;实例变量的值写在长方形中;用指向被引用对象的箭头表示引用关系 术语链接表示对结点的引用 在表头插入结点 # 在首结点为first 的给定链表开头插入字符串not,我们先将first 保存在oldfirst 中, 然后将一个新结点赋予first,并将它的item 域设为not,next 域设为oldfirst\n时间复杂度为O(1)\n如图 从表头删除结点 # 将first指向first.next\n原先的结点称为孤儿,Java的内存管理系统最终将回收它所占用的内存\n如图 在表尾插入结点 # 每个修改链表的操作都需要增加检查是否要修改该变量(以及做出相应修改)的代码\n例如,当删除链表首结点时可能改变指向链表的尾结点的引用,因为链表中只有一个结点时它既是首结点又是尾结点\n如图 其他位置的插入和删除操作 # 删除指定结点;在指定节点插入新结点\n需要将链表尾结点的前一个节点中的链接(它指向的是last)值改为null 为了找到指向last的结点,需要遍历链表,时间复杂度为O(n) 实现任意插入和删除操作的标准解决方案是双向链表 遍历 # 将x初始化为链表首结点,然后通过x.item访问和x相关联的元素,并将x设为x.next来访问链表中的下一个结点,知道x=null(没有下一个结点了,到达链表结尾)\nfor (Node x = first; x != null; x = x.next) { // 处理x.item } 栈的实现 # 使用链表实现栈\n将栈保存为一条链表,栈的顶部即为表头,实例变量first 指向栈顶。这样,当使用push() 压入一个元素时,我们会按照1.3.3.3 节所讨论的代码将该元素添加在表头;当使用pop() 删除一个元素时,我们会按照1.3.3.4 节讨论的代码将该元素从表头删除。要实现size() 方法,我们用实例变量N 保存元素的个数,在压入元素时将N 加1,在弹出元素时将N 减1。要实现isEmpty() 方法,只需检查first 是否为null(或者可以检查N 是否为0)\n实现上述几个操作的时间复杂度为O(1)\n下压堆栈(链表的实现)\npublic class Stack\u0026lt;Item\u0026gt; implements Iterable\u0026lt;Item\u0026gt; { private Node first; // 栈顶(最近添加的元素) private int N; // 元素数量 private class Node { // 定义了结点的嵌套类 Item item; Node next; } public Boolean isEmpty() { return first == null; } // 或:N == 0 public int size() { return N; } public void push(Item item) { // 向栈顶添加元素 Node oldfirst = first; first = new Node(); first.item = item; first.next = oldfirst; N++; } public Item pop() { // 从栈顶删除元素 Item item = first.item; first = first.next; N--; return item; } // iterator() 的实现请见算法1.4 // 测试用例main() 的实现请见本节前面部分 } 测试用例(pop()之前测试用例做了判断)\npublic static void main(String[] args) { // 创建一个栈并根据StdIn中的指示压入或弹出字符串 Stack\u0026lt;String\u0026gt; s = new Stack\u0026lt;String\u0026gt;(); while (!StdIn.isEmpty()) { String item = StdIn.readString(); if (!item.equals(\u0026#34;-\u0026#34;)) s.push(item); else if (!s.isEmpty()) StdOut.print(s.pop() + \u0026#34; \u0026#34;); } StdOut.println(\u0026#34;(\u0026#34; + s.size() + \u0026#34; left on stack)\u0026#34;); } 队列的实现 # 这里维护了first和last两个变量\nQueue实现使用的数据结构和Stack都是链表,但实现了不同的添加和删除元素的算法,所以前者是先入先出,后者是后进先出\nQueue的测试用例\npublic static void main(String[] args) { // 创建一个队列并操作字符串入列或出列 Queue\u0026lt;String\u0026gt; q = new Queue\u0026lt;String\u0026gt;(); while (!StdIn.isEmpty()) { String item = StdIn.readString(); if (!item.equals(\u0026#34;-\u0026#34;)) q.enqueue(item); else if (!q.isEmpty()) StdOut.print(q.dequeue() + \u0026#34; \u0026#34;); } StdOut.println(\u0026#34;(\u0026#34; + q.size() + \u0026#34; left on queue)\u0026#34;); } Queue的测试用例\npublic static void main(String[] args) { // 创建一个队列并操作字符串入列或出列 Queue\u0026lt;String\u0026gt; q = new Queue\u0026lt;String\u0026gt;(); while (!StdIn.isEmpty()) { String item = StdIn.readString(); if (!item.equals(\u0026#34;-\u0026#34;)) q.enqueue(item); else if (!q.isEmpty()) StdOut.print(q.dequeue() + \u0026#34; \u0026#34;); } StdOut.println(\u0026#34;(\u0026#34; + q.size() + \u0026#34; left on queue)\u0026#34;); } Queue的实现\n如下,enqueue()需要额外考虑first,dequeue()需要额外考虑last 如果原队列没有结点,那么增加后last指向了新的元素,应该把first也指向新元素 如果原对队列只有一个元素,那么删除后first确实指向null,而last没有更新,所以需要下面的判断手动更新 public class Queue\u0026lt;Item\u0026gt; implements Iterable\u0026lt;Item\u0026gt; { private Node first; // 指向最早添加的结点的链接 private Node last; // 指向最近添加的结点的链接 private int N; // 队列中的元素数量 private class Node { // 定义了结点的嵌套类 Item item; Node next; } public Boolean isEmpty() { return first == null; } // 或: N == 0. public int size() { return N; } public void enqueue(Item item) { // 向表尾添加元素 Node oldlast = last; last = new Node(); last.item = item; last.next = null; if (isEmpty()) first = last; else oldlast.next = last; N++; } public Item dequeue() { // 从表头删除元素 Item item = first.item; first = first.next; if (isEmpty()) last = null; N--; return item; } // iterator() 的实现请见算法1.4 // 测试用例main() 的实现请见前面 } 在结构化数据集时,链表是数组的一种重要替代方法\n背包的实现 # 只需要将Stack中的push()改为add()即可,并去掉pop()\n下面添加了Iterator实现类,以及iterator()具体方法 其中,嵌套类ListIterator 维护了一个实例变量current来记录链表的当前结点。hasNext() 方法会检测current 是否为null,next() 方法会保存当前元素的引用,将current 变量指向链表中的下个结点并返回所保存的引用。\nimport java.util.Iterator; public class Bag\u0026lt;Item\u0026gt; implements Iterable\u0026lt;Item\u0026gt; { private Node first; // 链表的首结点 private class Node { Item item; Node next; } public void add(Item item) { // 和Stack 的push() 方法完全相同 Node oldfirst = first; first = new Node(); first.item = item; first.next = oldfirst; } public Iterator\u0026lt;Item\u0026gt; iterator() { return new ListIterator(); } private class ListIterator implements Iterator\u0026lt;Item\u0026gt; { private Node current = first; public Boolean hasNext() { return current != null; } public void remove() { } public Item next() { Item item = current.item; current = current.next; return item; } } } 综述 # 学习了支持泛型和迭代的背包、队列和栈\n现在拥有两种表示对象集合的方式,即数组和链表\u0026mdash;\u0026gt;顺序存储和链式存储\n各种含有多个链接的数据结构,如二叉树的数据结构,由含有两个链接的节点组成 复合型的数据结构:背包存储栈,队列存储数组等,例如用数组的背包表示图 基础数据结构 研究新领域时,按以下步骤识别并使用数据抽象解决问题\n定义API 根据应用场景开发用例代码 描述数据结构(一组值的表示),并在API所对应的抽象数据类型的实现中根据它定义类的实例变量 描述算法(实现一组操作的方式),实现类的实例方法 分析算法的性能特点 本书的数据结构举例 End # "},{"id":411,"href":"/zh/docs/technology/RocketMQ/heima_/02buildcluster/","title":"02双主双从集群搭建","section":"基础(黑马)_","content":" 学习来源 https://www.bilibili.com/video/BV1L4411y7mn(添加小部分笔记)感谢作者!\n服务器信息修改 # 在.135和.138均进行下面的操作\n解压 # rocketmq解压到/usr/local/rocketmq目录下\nhost添加 # #添加host vim /etc/hosts ##添加内容 192.168.1.135 rocketmq-nameserver1 192.168.1.138 rocketmq-nameserver2 192.168.1.135 rocketmq-master1 192.168.1.135 rocketmq-slave2 192.168.1.138 rocketmq-master2 192.168.1.138 rocketmq-slave1 ## 保存后 systemctl restart network 防火墙 # 直接关闭 # ## 防火墙关闭 systemctl stop firewalld.service ## 防火墙状态查看 firewall-cmd --state ##禁止开机启动 systemctl disable firewalld.service 或者直接关闭对应端口即可 # 环境变量配置 # 为了执行rocketmq命令方便\n#添加环境变量 vim /etc/profile #添加 ROCKETMQ_HOME=/usr/local/rocketmq/rocketmq-all-4.4.0-bin-release PATH=$PATH:$ROCKETMQ_HOME/bin export ROCKETMQ_HOME PATH #使配置生效 source /etc/profile 消息存储路径创建 # a # mkdir /usr/local/rocketmq/store-a mkdir /usr/local/rocketmq/store-a/commitlog mkdir /usr/local/rocketmq/store-a/consumequeue mkdir /usr/local/rocketmq/store-a/index b # mkdir /usr/local/rocketmq/store-b mkdir /usr/local/rocketmq/store-b/commitlog mkdir /usr/local/rocketmq/store-b/consumequeue mkdir /usr/local/rocketmq/store-b/index 双主双从配置文件的修改 # master-a # #所属集群名字 brokerClusterName=rocketmq-cluster #broker名字,注意此处不同的配置文件填写的不一样 brokerName=broker-a #0 表示 Master,\u0026gt;0 表示 Slave brokerId=0 #nameServer地址,分号分割 namesrvAddr=rocketmq-nameserver1:9876;rocketmq-nameserver2:9876 #在发送消息时,自动创建服务器不存在的topic,默认创建的队列数 defaultTopicQueueNums=4 #是否允许 Broker 自动创建Topic,建议线下开启,线上关闭 autoCreateTopicEnable=true #是否允许 Broker 自动创建订阅组,建议线下开启,线上关闭 autoCreateSubscriptionGroup=true #Broker 对外服务的监听端口 listenPort=10911 #删除文件时间点,默认凌晨 4点 deleteWhen=04 #文件保留时间,默认 48 小时 fileReservedTime=120 #commitLog每个文件的大小默认1G mapedFileSizeCommitLog=1073741824 #ConsumeQueue每个文件默认存30W条,根据业务情况调整 mapedFileSizeConsumeQueue=300000 #destroyMapedFileIntervalForcibly=120000 #redeleteHangedFileInterval=120000 #检测物理文件磁盘空间 diskMaxUsedSpaceRatio=88 #存储路径 storePathRootDir=/usr/local/rocketmq/store-a #commitLog 存储路径 storePathCommitLog=/usr/local/rocketmq/store-a/commitlog #消费队列存储路径存储路径 storePathConsumeQueue=/usr/local/rocketmq/store-a/consumequeue #消息索引存储路径 storePathIndex=/usr/local/rocketmq/store-a/index #checkpoint 文件存储路径 storeCheckpoint=/usr/local/rocketmq/store-a/checkpoint #abort 文件存储路径 abortFile=/usr/local/rocketmq/store-a/abort #限制的消息大小 maxMessageSize=65536 #flushCommitLogLeastPages=4 #flushConsumeQueueLeastPages=2 #flushCommitLogThoroughInterval=10000 #flushConsumeQueueThoroughInterval=60000 #Broker 的角色 #- ASYNC_MASTER 异步复制Master #- SYNC_MASTER 同步双写Master #- SLAVE brokerRole=SYNC_MASTER #刷盘方式 #- ASYNC_FLUSH 异步刷盘 #- SYNC_FLUSH 同步刷盘 flushDiskType=SYNC_FLUSH #checkTransactionMessageEnable=false #发消息线程池数量 #sendMessageThreadPoolNums=128 #拉消息线程池数量 #pullMessageThreadPoolNums=128 slave-b # #所属集群名字 brokerClusterName=rocketmq-cluster #broker名字,注意此处不同的配置文件填写的不一样 brokerName=broker-b #0 表示 Master,\u0026gt;0 表示 Slave brokerId=1 #nameServer地址,分号分割 namesrvAddr=rocketmq-nameserver1:9876;rocketmq-nameserver2:9876 #在发送消息时,自动创建服务器不存在的topic,默认创建的队列数 defaultTopicQueueNums=4 #是否允许 Broker 自动创建Topic,建议线下开启,线上关闭 autoCreateTopicEnable=true #是否允许 Broker 自动创建订阅组,建议线下开启,线上关闭 autoCreateSubscriptionGroup=true #Broker 对外服务的监听端口 listenPort=11011 #删除文件时间点,默认凌晨 4点 deleteWhen=04 #文件保留时间,默认 48 小时 fileReservedTime=120 #commitLog每个文件的大小默认1G mapedFileSizeCommitLog=1073741824 #ConsumeQueue每个文件默认存30W条,根据业务情况调整 mapedFileSizeConsumeQueue=300000 #destroyMapedFileIntervalForcibly=120000 #redeleteHangedFileInterval=120000 #检测物理文件磁盘空间 diskMaxUsedSpaceRatio=88 #存储路径 storePathRootDir=/usr/local/rocketmq/store-b #commitLog 存储路径 storePathCommitLog=/usr/local/rocketmq/store-b/commitlog #消费队列存储路径存储路径 storePathConsumeQueue=/usr/local/rocketmq/store-b/consumequeue #消息索引存储路径 storePathIndex=/usr/local/rocketmq/store-b/index #checkpoint 文件存储路径 storeCheckpoint=/usr/local/rocketmq/store-b/checkpoint #abort 文件存储路径 abortFile=/usr/local/rocketmq/store-b/abort #限制的消息大小 maxMessageSize=65536 #flushCommitLogLeastPages=4 #flushConsumeQueueLeastPages=2 #flushCommitLogThoroughInterval=10000 #flushConsumeQueueThoroughInterval=60000 #Broker 的角色 #- ASYNC_MASTER 异步复制Master #- SYNC_MASTER 同步双写Master #- SLAVE brokerRole=SLAVE #刷盘方式 #- ASYNC_FLUSH 异步刷盘 #- SYNC_FLUSH 同步刷盘 flushDiskType=ASYNC_FLUSH #checkTransactionMessageEnable=false #发消息线程池数量 #sendMessageThreadPoolNums=128 #拉消息线程池数量 #pullMessageThreadPoolNums=128 master-b # #所属集群名字 brokerClusterName=rocketmq-cluster #broker名字,注意此处不同的配置文件填写的不一样 brokerName=broker-b #0 表示 Master,\u0026gt;0 表示 Slave brokerId=0 #nameServer地址,分号分割 namesrvAddr=rocketmq-nameserver1:9876;rocketmq-nameserver2:9876 #在发送消息时,自动创建服务器不存在的topic,默认创建的队列数 defaultTopicQueueNums=4 #是否允许 Broker 自动创建Topic,建议线下开启,线上关闭 autoCreateTopicEnable=true #是否允许 Broker 自动创建订阅组,建议线下开启,线上关闭 autoCreateSubscriptionGroup=true #Broker 对外服务的监听端口 listenPort=10911 #删除文件时间点,默认凌晨 4点 deleteWhen=04 #文件保留时间,默认 48 小时 fileReservedTime=120 #commitLog每个文件的大小默认1G mapedFileSizeCommitLog=1073741824 #ConsumeQueue每个文件默认存30W条,根据业务情况调整 mapedFileSizeConsumeQueue=300000 #destroyMapedFileIntervalForcibly=120000 #redeleteHangedFileInterval=120000 #检测物理文件磁盘空间 diskMaxUsedSpaceRatio=88 #存储路径 storePathRootDir=/usr/local/rocketmq/store-b #commitLog 存储路径 storePathCommitLog=/usr/local/rocketmq/store-b/commitlog #消费队列存储路径存储路径 storePathConsumeQueue=/usr/local/rocketmq/store-b/consumequeue #消息索引存储路径 storePathIndex=/usr/local/rocketmq/store-b/index #checkpoint 文件存储路径 storeCheckpoint=/usr/local/rocketmq/store-b/checkpoint #abort 文件存储路径 abortFile=/usr/local/rocketmq/store-b/abort #限制的消息大小 maxMessageSize=65536 #flushCommitLogLeastPages=4 #flushConsumeQueueLeastPages=2 #flushCommitLogThoroughInterval=10000 #flushConsumeQueueThoroughInterval=60000 #Broker 的角色 #- ASYNC_MASTER 异步复制Master #- SYNC_MASTER 同步双写Master #- SLAVE brokerRole=SYNC_MASTER #刷盘方式 #- ASYNC_FLUSH 异步刷盘 #- SYNC_FLUSH 同步刷盘 flushDiskType=SYNC_FLUSH #checkTransactionMessageEnable=false #发消息线程池数量 #sendMessageThreadPoolNums=128 #拉消息线程池数量 #pullMessageThreadPoolNums=128 slave-a # #所属集群名字 brokerClusterName=rocketmq-cluster #broker名字,注意此处不同的配置文件填写的不一样 brokerName=broker-a #0 表示 Master,\u0026gt;0 表示 Slave brokerId=1 #nameServer地址,分号分割 namesrvAddr=rocketmq-nameserver1:9876;rocketmq-nameserver2:9876 #在发送消息时,自动创建服务器不存在的topic,默认创建的队列数 defaultTopicQueueNums=4 #是否允许 Broker 自动创建Topic,建议线下开启,线上关闭 autoCreateTopicEnable=true #是否允许 Broker 自动创建订阅组,建议线下开启,线上关闭 autoCreateSubscriptionGroup=true #Broker 对外服务的监听端口 listenPort=11011 #删除文件时间点,默认凌晨 4点 deleteWhen=04 #文件保留时间,默认 48 小时 fileReservedTime=120 #commitLog每个文件的大小默认1G mapedFileSizeCommitLog=1073741824 #ConsumeQueue每个文件默认存30W条,根据业务情况调整 mapedFileSizeConsumeQueue=300000 #destroyMapedFileIntervalForcibly=120000 #redeleteHangedFileInterval=120000 #检测物理文件磁盘空间 diskMaxUsedSpaceRatio=88 #存储路径 storePathRootDir=/usr/local/rocketmq/store-a #commitLog 存储路径 storePathCommitLog=/usr/local/rocketmq/store-a/commitlog #消费队列存储路径存储路径 storePathConsumeQueue=/usr/local/rocketmq/store-a/consumequeue #消息索引存储路径 storePathIndex=/usr/local/rocketmq/store-a/index #checkpoint 文件存储路径 storeCheckpoint=/usr/local/rocketmq/store-a/checkpoint #abort 文件存储路径 abortFile=/usr/local/rocketmq/store-a/abort #限制的消息大小 maxMessageSize=65536 #flushCommitLogLeastPages=4 #flushConsumeQueueLeastPages=2 #flushCommitLogThoroughInterval=10000 #flushConsumeQueueThoroughInterval=60000 #Broker 的角色 #- ASYNC_MASTER 异步复制Master #- SYNC_MASTER 同步双写Master #- SLAVE brokerRole=SLAVE #刷盘方式 #- ASYNC_FLUSH 异步刷盘 #- SYNC_FLUSH 同步刷盘 flushDiskType=ASYNC_FLUSH #checkTransactionMessageEnable=false #发消息线程池数量 #sendMessageThreadPoolNums=128 #拉消息线程池数量 #pullMessageThreadPoolNums=128 修改两台主机的runserver.sh及runbroker.sh修改 # 修改runbroker.sh # JAVA_OPT=\u0026#34;${JAVA_OPT} -server -Xms256m -Xmx256m -Xmn128m\u0026#34; 修改runserver.sh # JAVA_OPT=\u0026#34;${JAVA_OPT} -server -Xms256m -Xmx256m -Xmn128m -XX:MetaspaceSize=128m -XX:MaxMetaspaceSize=320m\u0026#34; 两台主机分别启动nameserver和Brocker # ## 在两台主机分别启动nameserver nohup sh mqnamesrv \u0026amp; #135启动master1 nohup sh mqbroker -c /usr/local/rocketmq/rocketmq-all-4.4.0-bin-release/conf/2m-2s-sync/broker-a.properties \u0026amp; #135启动slave2 nohup sh mqbroker -c /usr/local/rocketmq/rocketmq-all-4.4.0-bin-release/conf/2m-2s-sync/broker-b-s.properties \u0026amp; #查看 jps 3478 Jps 3366 BrokerStartup 3446 BrokerStartup 3334 NamesrvStartup #138启动master2 nohup sh mqbroker -c /usr/local/rocketmq/rocketmq-all-4.4.0-bin-release/conf/2m-2s-sync/broker-b.properties \u0026amp; #135启动slave1 nohup sh mqbroker -c /usr/local/rocketmq/rocketmq-all-4.4.0-bin-release/conf/2m-2s-sync/broker-a-s.properties \u0026amp; #查看 jps 3376 Jps 3360 BrokerStartup 3251 NamesrvStartup 3295 BrokerStartup 双主双从集群搭建完毕!\n集群管理工具 # mqadmin # cd bin 进入bin目录:mqadmin,对下面的信息进行操作\ntopic、集群、Broker、消息、消费者组、生产者组、连接相关、namesrv相关、其他\nrocket-console # //已经弃用,现在改用rocketmq-dashboard\nhttps://github.com/apache/rocketmq-dashboard\n"},{"id":412,"href":"/zh/docs/technology/RocketMQ/heima_/01base/","title":"01rocketmq学习","section":"基础(黑马)_","content":" 学习来源 https://www.bilibili.com/video/BV1L4411y7mn(添加小部分笔记)感谢作者!\n基本操作 # 下载 # https://rocketmq.apache.org/download/ 选择Binary下载即可,放到Linux主机中\n前提java运行环境 # yum search java | grep jdk yum install -y java-1.8.0-openjdk-devel.x86_64 # java -version 正常 # javac -version 正常 启动 # #nameserver启动 nohup sh bin/mqnamesrv \u0026amp; #nameserver日志查看 tail -f ~/logs/rocketmqlogs/namesrv.log #输出 2023-04-06 00:08:34 INFO main - tls.client.certPath = null 2023-04-06 00:08:34 INFO main - tls.client.authServer = false 2023-04-06 00:08:34 INFO main - tls.client.trustCertPath = null 2023-04-06 00:08:35 INFO main - Using OpenSSL provider 2023-04-06 00:08:35 INFO main - SSLContext created for server 2023-04-06 00:08:36 INFO NettyEventExecutor - NettyEventExecutor service started 2023-04-06 00:08:36 INFO main - The Name Server boot success. serializeType=JSON 2023-04-06 00:08:36 INFO FileWatchService - FileWatchService service started 2023-04-06 00:09:35 INFO NSScheduledThread1 - -------------------------------------------------------- 2023-04-06 00:09:35 INFO NSScheduledThread1 - configTable SIZE: 0 #broker启动 nohup sh bin/mqbroker -n localhost:9876 \u0026amp; #查看broker日志 tail -f ~/logs/rocketmqlogs/broker.log #日志如下 tail: 无法打开\u0026#34;/root/logs/rocketmqlogs/broker.log\u0026#34; 读取数据: 没有那个文件或目录 tail: 没有剩余文件 👇 #jps查看 2465 Jps 2430 NamesrvStartup #说明没有启动成功,因为默认配置的虚拟机内存较大 vim bin/runbroker.sh 以及 vim runserver.sh #修改 JAVA_OPT=\u0026#34;${JAVA_OPT} -server -Xms8g -Xmx8g -Xmn4g\u0026#34; #修改为 JAVA_OPT=\u0026#34;${JAVA_OPT} -server -Xms256m -Xmx256m -Xmn128m -XX:MetaspaceSize=128m -XX:MaxMetaspaceSize=320m\u0026#34; #修改完毕后启动 #先关闭namesrv后 #按上述启动namesrv以及broker sh bin/mqshutdown namesrv # jsp命令查看进程 2612 Jps 2551 BrokerStartup 2524 NamesrvStartup 测试 # 同一台机器上,两个cmd窗口\n发送端 # #配置namesrv为环境变量 export NAMESRV_ADDR=localhost:9876 #运行程序(发送消息) sh bin/tools.sh org.apache.rocketmq.example.quickstart.Producer #结果 SendResult [sendStatus=SEND_OK, msgId=C0A801640B012503DBD319DEF7D203E1, offsetMsgId=C0A8010300002A9F0000000000057878, messageQueue=MessageQueue [topic=TopicTest, brokerName=rheCentos700, queueId=3], queueOffset=498] SendResult [sendStatus=SEND_OK, msgId=C0A801640B012503DBD319DEF7D803E2, offsetMsgId=C0A8010300002A9F000000000005792C, messageQueue=MessageQueue [topic=TopicTest, brokerName=rheCentos700, queueId=0], queueOffset=498] SendResult [sendStatus=SEND_OK, msgId=C0A801640B012503DBD319DEF7DB03E3, offsetMsgId=C0A8010300002A9F00000000000579E0, messageQueue=MessageQueue [topic=TopicTest, brokerName=rheCentos700, queueId=1], queueOffset=498] 接收端 # #配置namesrv为环境变量 export NAMESRV_ADDR=localhost:9876 #运行程序(发送消息) sh bin/tools.sh org.apache.rocketmq.example.quickstart.Consumer #结果 ConsumeMessageThread_5 Receive New Messages: [MessageExt [queueId=0, storeSize=180, queueOffset=499, sysFlag=0, bornTimestamp=1680712442864, bornHost=/192.168.1.3:45716, storeTimestamp=1680712442878, storeHost=/192.168.1.3:10911, msgId=C0A8010300002A9F0000000000057BFC, commitLogOffset=359420, bodyCRC=1359908749, reconsumeTimes=0, preparedTransactionOffset=0, toString()=Message{topic=\u0026#39;TopicTest\u0026#39;, flag=0, properties={MIN_OFFSET=0, MAX_OFFSET=500, CONSUME_START_TIME=1680712442881, UNIQ_KEY=C0A801640B012503DBD319DEF7F003E6, WAIT=true, TAGS=TagA}, body=[72, 101, 108, 108, 111, 32, 82, 111, 99, 107, 101, 116, 77, 81, 32, 57, 57, 56], transactionId=\u0026#39;null\u0026#39;}]] ConsumeMessageThread_2 Receive New Messages: [MessageExt [queueId=1, storeSize=180, queueOffset=499, sysFlag=0, bornTimestamp=1680712442879, bornHost=/192.168.1.3:45716, storeTimestamp=1680712442883, storeHost=/192.168.1.3:10911, msgId=C0A8010300002A9F0000000000057CB0, commitLogOffset=359600, bodyCRC=638172955, reconsumeTimes=0, preparedTransactionOffset=0, toString()=Message{topic=\u0026#39;TopicTest\u0026#39;, flag=0, properties={MIN_OFFSET=0, MAX_OFFSET=500, CONSUME_START_TIME=1680712442889, UNIQ_KEY=C0A801640B012503DBD319DEF7FF03E7, WAIT=true, TAGS=TagA}, body=[72, 101, 108, 108, 111, 32, 82, 111, 99, 107, 101, 116, 77, 81, 32, 57, 57, 57], transactionId=\u0026#39;null\u0026#39;}]] RocketMQ基本架构 # 简单解释 # nameserver:broker的管理者 broker:自己找nameserer上报 broker:真正存储消息的地方 nameserver是无状态的,即nameserver之间不用同步broker信息,由broker自己上报 Producer集群之间也不需要同步;Consumer集群之间也不需要同步 BrokerMaster和BrokerSlave之间信息是有同步的 如图 # "},{"id":413,"href":"/zh/docs/technology/Algorithm/_algorithhms_4th_/1.3.1.1-1.3.2.5/","title":"算法红皮书 1.3.1.1-1.3.2.5","section":"_算法(第四版)_","content":" 背包、队列和栈 # 数据类型的值就是一组对象的集合,所有操作都是关于添加、删除或是访问集合中的对象 本章将学习三种数据类型:背包Bag、队列Queue、栈Stack 对集合中的对象的表示方式直接影响各种操作的效率 介绍泛型和迭代 介绍并说明链式数据结构的重要性(链表) API # 泛型可迭代的基础集合数据类型的API\n背包\n队列(先进先出FIFO)\n下压(后进先出,LIFO)栈 泛型\n泛型,参数化类型 在每份API 中,类名后的\u0026lt;Item\u0026gt; 记号将Item 定义为一个类型参数,它是一个象征性的占位符,表示的是用例将会使用的某种具体数据类型 自动装箱\n用来处理原始类型 Boolean、Byte、Character、Double、Float、Integer、Long 和Short 分别对应着boolean、byte、char、double、float、int、long 和short 自动将一个原始数据类型转换为一个封装类型称为自动装箱,自动将一个封装类型转换为一个原始数据类型被称为自动拆箱 可迭代的集合类型\n迭代访问集合中的所有元素 背包是一种不支持从中删除元素的集合数据类型\u0026ndash;帮助用例收集元素并迭代遍历所有收集到的元素(无序遍历)\n典型用例,计算标准差\n先进先出队列\n是一种基于先进先出(FIFO)策略的集合类型 使用队列的主要原因:集合保存元素的同时保存它们的相对顺序 如图\nQueue用例(先进先出) 下压栈\n简称栈,是一种基于后进先出LIFO策略的集合类型 比如,收邮件等,如图\nStack的用例\n用栈解决算数表达式的问题\n(双栈算数表达式求值算法)\n集合类数据类型的实现 # 定容栈,表示容量固定的字符串栈的抽象数据类型\n只能处理String值,支持push和pop\n抽象数据类型\n测试用例\n使用方法\n数据类型的实现\n泛型\npublic class FixedCapacityStack\u0026lt;Item\u0026gt; 由于不允许直接创建泛型数组,所以 a =new Item[cap] 不允许,应该改为\na=(Item[])new Object[cap]; 泛型定容栈的抽象数据类型\n测试用例\n使用方法\n数据类型的实现\n调整数组大小\nN为当前元素的数量\n使用resize创建新的数组\n当元素满了的时候进行扩容\n当元素过少(1/4)的时候,进行减半\n对象游离\nJava的垃圾回收策略是回收所有无法被访问的对象的内存\n示例中,被弹出的元素不再需要,但由于数组中的引用仍然让它可以继续存在(垃圾回收器无法回收),这种情况(保存了一个不需要的对象的引用)称为游离,避免游离的做法就是将数组元素设为null\n迭代\nforeach和while\n集合数据类型必须实现iterator()并返回Iterator对象 Iterator类必须包括两个方法,hasNext()和next() 让类继承Iterable\u0026lt;Item\u0026gt;使类可迭代 使用一个嵌套类\n下压栈的代码\nimport java.util.Iterator; public class ResizingArrayStack\u0026lt;Item\u0026gt; implements Iterable\u0026lt;Item\u0026gt; { private\tItem[] a = (Item[]) new Object[1]; /* 栈元素 */ private int\tN = 0; /* 元素数量 */ public boolean isEmpty() { return(N == 0); } public int size() { return(N); } private void resize( int max ) { /* 将栈移动到一个大小为max 的新数组 */ Item[] temp = (Item[]) new Object[max]; for ( int i = 0; i \u0026lt; N; i++ ) temp[i] = a[i]; a = temp; } public void push( Item item ) { /* 将元素添加到栈顶 */ if ( N == a.length ) resize( 2 * a.length ); a[N++] = item; } public Item pop() { /* 从栈顶删除元素 */ Item item = a[--N]; a[N] = null; /* 避免对象游离(请见1.3.2.4 节) */ if ( N \u0026gt; 0 \u0026amp;\u0026amp; N == a.length / 4 ) resize( a.length / 2 ); return(item); } public Iterator\u0026lt;Item\u0026gt; iterator() { return(new ReverseArrayIterator() ); } private class ReverseArrayIterator implements Iterator\u0026lt;Item\u0026gt; { /* 支持后进先出的迭代 */ private int i = N; public boolean hasNext() { return(i \u0026gt; 0); } public Item next() { return(a[--i]); } public void remove() { } } } End # "},{"id":414,"href":"/zh/docs/technology/Algorithm/_algorithhms_4th_/1.2.1-1.2.5/","title":"算法红皮书 1.2.1-1.2.5","section":"_算法(第四版)_","content":" 数据抽象 # 数据类型指的是一组值和一组对这些值的操作的集合\n定义和使用数据类型的过程,也被称为数据抽象 Java编程的基础是使用class关键字构造被称为引用类型的数据类型,也称面向对象编程 定义自己的数据类型来抽象任意对象 抽象数据类型(ADT)是一种能够对使用者隐藏数据表示的数据类型 抽象数据类型将数据和函数的实现相关联,将数据的表示方式隐藏起来 抽象数据类型使用时,关注API描述的操作上而不会去关心数据的表示;实现抽象数据类型时,关注数据本身并将实现对数据的各种操作 研究同一个问题的不同算法的主要原因是他们的性能不同 使用抽象数据类型 # 使用一种数据类型并不一定非得知道它是如何实现的 使用Counter(计数器)的简单数据类型的程序,操作有 创建对象并初始化为0 当前值加1 获取当前值 场景,用于电子计票 抽象数据类型的API(应用程序编程接口) API用来说明抽象数据类型的行为 将列出所有构造函数和实例方法(即操作) 计算器的API\n继承的方法 所有数据类型都会继承toString()方法 Java会在用+运算符将任意数据类型的值和String值连接时调用toString() 默认实现:返回该数据类型值的内存地址 用例代码 可以在用例代码中,声明变量、创建对象来保存数据类型的值并允许通过实例方法来操作它们 对象 对象是能够承载数据类型的值的实体 对象三大特性:状态、标识和行为 状态:数据类型中的值 标识:在内存中的地址 行为:数据类型的操作 Java使用\u0026quot;引用类型\u0026quot;和原始数据类型区别 创建对象 每种数据类型中的值都存储于一个对象中 构造函数总是返回他的数据类型的对象的引用 使用new(),会为新的对象分配内存空间,调用构造函数初始化对象中的值,返回该对象的一个引用 抽象数据类型向用例隐藏了值的表示细节 实例方法:参数按值传递 方法每次触发都和一个对象相关 静态方法的主要作用是实现函数;非静态(实例)方法的主要作用是实现数据类型的操作 使用对象\n开发某种数据类型的用例 声明该类型的变量,以引用对象 使用new触发能够创建该类型的对象的一个构造函数 使用变量名调用实例方法 赋值语句(对象赋值) 别名:两个变量同时指向同一个对象 将对象作为参数 Java将参数值的一个副本从调用端传递给了方法,这种方式称为按值传递 当使用引用类型作为参数时我们创建的都是别名,这种约定会传递引用的值(复制引用),也就是传递对象的引用 虽然无法改变原始的引用(将原变量指向另一个Counter对象),但能够改变该对象的值 将对象作为返回值 由于Java只由一个返回值,有了对象实际上就能返回多个值 数组也是对象 将数组传递给一个方法或是将一个数组变量放在赋值语句的右侧时,我们都是在创建数组引用的一个副本,而非数组的副本 对象的数组\n创建一个对象的数组 使用方括号语法调用数组的构造函数创建数组 对于每个数组元素调用它的构造函数创建相应的对象\n如下图\n运用数据抽象的思想编写代码(定义和使用数据类型,将数据类型的值封装在对象中)的方式称为面向对象编程 总结 数据类型指的是一组值和一组对值的操作的集合 我们会在数据类型的Java类中编写用理 对象是能够存储任意该数据类型的值的实体 对象有三个关键性质:状态、标识和行为 抽象数据类型举例 # 本书中将会用到或开发的所有数据类型 java.lang.* Java标准库中的抽象数据类型,需要import,比如java.io、java.net等 I/O处理嘞抽象数据类型,StdIn和StdOut 面向数据类抽象数据类型,计算机和和信息处理 集合类抽象数据类型,主要是为了简化对同一类型的一组数据的操作,包括Bag、Stack和Queue,PQ(优先队列)、ST(符号表)、SET(集合) 面向操作的抽象数据类型(用来分析各种算法) 图算法相关的抽象数据类型,用来封装各种图的表示的面向数据的抽象数据类型,和一些提供图的处理算法的面向操作的抽象数据类型 几何对象(画图(图形)的)[跳过] 信息处理 抽象数据类型是组织信息的一种自然方式 定义和真实世界中的物体相对应的对象 字符串 java的String 一个String值是一串可以由索引访问的char值 有了String类型可以写出清晰干净的用例代码而无需关心字符串的表示方式 抽象数据类型的实现 # 使用Java的类(class)实现抽象数据类型并将所有代码放入一个和类名相同并带有.java扩展名的文件 如下图\n实例变量\n用来定义数据类型的值(每个对象的状态) 构造函数 每个Java类都至少有一个构造函数以创建一个对象的标识 每个构造函数将创建一个对象并向调用者返回一个该对象的引用 实例方法 如图\n作用域 参数变量、局部变量、实例变量 范围(如图)\nAPI、用例与实现 我们要学习的每个抽象数据类型的实现,都会是一个含有若干私有实例变量、构造函数、实例方法和一个测试用例的Java类 用例和实现分离(一般将用例独立成含有静态方法main()的类) 做法如下 定义一份API,APi的作用是将使用和实现分离,以实现模块化编程 用一个Java类实现API的定义 实现多个测试用例来验证前两步做出的设计决定 例子如下 API\n典型用例\n数据类型的实现\n使用方法(执行程序)\n更多抽象数据类型的实现 # 日期 两种实现方式\n本书反复出现的主题,即理解各种实现对空间和时间的需求 维护多个实现 比较同一份API的两种实现在同一个用例中的性能表现,需要下面非正式的命名约定 使用前缀的描述性修饰符,比如BasicDate和SmallDate,以及是否合法的SmartDate 适合大多数用力的需求的实现,比如Date 累加器 数据类型的设计 # 抽象数据类型是一种向用例隐藏内部表示的数据类型 封装(数据封装) 设计APi 算法与抽象数据类型 能够准确地说明一个算法的目的及其他程序应该如何使用该算法 每个Java程序都是一组静态方法和(或)一种数据类型的实现的集合 本书中关注的是抽象数据类型的实现中的操作和向用例隐藏其中的数据表示 例子,将二分法封装 API\n典型的用例\n数据类型的实现\n接口继承 Java语言为定义对象之间的关系提供了支持,称为接口 接口继承使得我们的程序能够通过调用接口中的方法操作实现该接口的任意类型的对象 本书中使用到的接口\n继承 由Object类继承得到的方法\n继承toString()并自定义 封装类型(内置的引用类型,包括Boolean、Byte、Character、Double、Float、Integer、Long和Short) 等价性 如图\n例子,在Date中重写equals\n内存管理\nJava具有自动内存管理,通过记录孤儿对象并将它们的内存释放到内存池中 不可变性\n使用final保证数据不可变\n使用final修饰的引用类型,不能再引用(指向)其他对象,但对象本身的值可改变 契约式设计 Java语言能够在程序运行时检测程序状态 异常(Exception)+断言(Assertion) 异常与错误\n允许抛出异常或抛出错误 断言\n程序不应该依赖断言 End # "},{"id":415,"href":"/zh/docs/technology/Algorithm/_algorithhms_4th_/1.1.6-1.1.11/","title":"算法红皮书 1.1.6-1.1.11","section":"_算法(第四版)_","content":" 基础编程模型 # 静态方法 # 本书中所有的Java程序要么是数据类型的定义,要么是一个静态方法库 当讨论静态方法和实体方法共有的属性时,我们会使用不加定语的方法一词 方法需要参数(某种数据类型的值)并根据参数计算出某种数据类型的返回值(例如数学函数的结果)或者产生某种副作用(例如打印一个值) 静态方法由签名(public static 以及函数的返回值,方法名及一串参数)和函数体组成 调用静态方法(写出方法名并在后面的括号中列出数值) 方法的性质 方法的参数按值传递,方法中使用的参数变量能够引用调用者的参数并改变其内容(只是不能改变原数组变量本身) 方法名可以被重载 方法只能返回一个值,但能包含多个返回语句 方法可以产生副作用 递归:方法可以调用自己 可以使用数学归纳法证明所解释算法的正确性,编写递归重要的三点 递归总有一个最简单的情况(方法第一条总包含return的条件语句) 递归调用总是去尝试解决一个规模更小的子问题 递归调用的父问题和尝试解决的子问题之间不应该由交集 如下图中,两个子问题各自操作的数组部分是不同的\n基础编程模型 静态方法库是定义在一个Java类中的一组静态方法 Java开发的基本模式是编写一个静态方法库(包含一个main()方法)类完成一个任务 在本书中,当我们提到用于执行一项人物的Java程序时,我们指的就是用这种模式开发的代码(还包括对数据类型的定义) 模块化编程 通过静态方法库实现了模块化编程 一个库中的静态方法也能够调用另一个库中定义的静态方法 单元测试 Java编程最佳实践之一就是每个静态方法库中都包含一个main()函数来测试库中所有的方法 本书中使用main()来说明模块的功能并将测试用例留作练习 外部库 系统标准库 java.lang.*:包括Math库;String和StringBuilder库 导入的系统库 java.util.Arrays 本书中其他库 本书使用了作者开发的标准库Std* API # 模块化编程重要组成部分,记录库方法的用法并供其他人参考的文档 会统一使用应用程序编程接口API的方法列出每个库方法、签名及简述 用例(调用另一个库中的方法的程序),实现(实现了某个API方法的Java代码) 作者自己的两个库,一个扩展Math.random(),一个支持各种统计 随机静态方法库(StdRandom)的API\n数据分析方法库(StdStats)的API\nStdRandom库中的静态方法的实现 编写自己的库 编写用例,实现中将计算过程分解 明确静态方法库和与之对应的API 实现API和一个能够对方法进行独立测试的main()函数 API的目的是将调用和实现分离 字符串 # 字符串拼接,使用 + 类型转换(将用户从键盘输入的内容转换成相应数据类型的值以及将各种数据类型的值转换成能够在屏幕上显示的值)\n如果数字跟在+后面,那么会将数据类型的值自动转换为字符串 命令行参数 Java中字符串的存在,使程序能够接收到从命令行传递来的信息 当输入命令java和一个库名及一系列字符串后,Java系统会调用库的main()方法并将后面的一系列字符串变成一个数组作为参数传递给它 输入输出 # Java程序可以从命令行参数或者一个名为标准输入流的抽象字符流中获得输入,并将输出写入另一个名为标准输出流的字符流中 默认情况下,命令行参数、标准输入和标准输出是和应用程序绑定的,而应用程序是由能够接受命令输入的操作系统或是开发环境所支持 使用终端来指代这个应用程序提供的供输入和显示的窗口,如图\n命令和参数 终端窗口包含一个提示符,通过它我们能够向操作系统输入命令和参数 操作系统常用命令\n标准输出 StdOut库的作用是支持标准输出 标准输出库的静态方法的API\n格式化输出 字符%并紧跟一个字符表示的转换代码(包括d,f和s)。%和转换代码之间可以插入证书表示值的宽度,且转换后会在字符串左边添加空格以达到需要的宽度。负数表示空格从右边加 宽度后用小数点及数值可以指定精度(或String字符串所截取的长度) 格式中转换代码和对应参数的数据类型必须匹配 标准输入 StdIn库从标准输入流中获取数据,然后将标准输出定向到终端窗口 标准输入流最重要的特点,这些值会在程序读取后消失 例子\n标准输入库中的静态方法API\n重定向和管道 将标准输出重定向到一个文件 java RandomSeq 1000 100.0 200.0 \u0026gt; data.txt 从文件而不是终端应用程序中读取数据 java Average \u0026lt; data.txt 将一个程序的输出重定向为另一个程序的输入,叫做管道 java RandomSeq 1000 100.0 200.0 | java Average 突破了我们能够处理的输入输出流的长度限制 即使计算机没有足够的空间来存储十亿个数, 我们仍然可以将例子中的1000 换成1 000 000 000 (当然我们还是需要一些时间来处理它们)。当RandomSeq 调用StdOut.println() 时,它就向输出流的末尾添加了一个字符串;当Average 调用StdIn.readInt() 时,它就从输入流的开头删除了一个字符串。这些动作发生的实际顺序取决于操作系统 命令行的重定向及管道\n基于文件的输入输出 In和Out库提供了一些静态方法,来实现向文件中写入或从文件中读取一个原始数据类型的数组的抽象 用于读取和写入数组的静态方法的API\n标准绘图库(基本方法和控制方法)\u0026ndash;这里跳过 二分查找 # 如图,在终端接收需要判断的数字,如果不存在于白名单(文件中的int数组)中则输出 开发用例以及使用测试文件(数组长度很大的白名单) 模拟实际情况来展示当前算法的必要性,比如 将客户的账号保存在一个文件中,我们称它为白名单; 从标准输入中得到每笔交易的账号; 使用这个测试用例在标准输出中打印所有与任何客户无关的账号,公司很可能拒绝此类交易。 使用顺序查找 public static int rank(int key, int[] a) { for (int i = 0; i \u0026lt; a.length; i++) if (a[i] == key) return i; return -1; } 当处理大量输入的时候,顺序查找的效率极其低 展望 # 下一节,鼓励使用数据抽象,或称面向对象编程,而不是操作预定义的数据类型的静态方法 使用数据抽象的好处 复用性 链式数据结构比数组更灵活 可以准确地定义锁面对的算法问题 1.1 End # "},{"id":416,"href":"/zh/docs/technology/Other/pc_base/","title":"电脑基础操作","section":"其他","content":"\n"},{"id":417,"href":"/zh/docs/technology/Algorithm/_algorithhms_4th_/1.1.1-1.1.5/","title":"算法红皮书 1.1.1-1.1.5","section":"_算法(第四版)_","content":" 基础编程模型 # Java程序的基本结构 # 本书学习算法的方法:用Java编程语言编写的程序来实现算法(相比用自然语言有很多优势) 劣势:编程语言特定,使算法的思想和实现细节变得困难(所以本书尽量使用大部分语言都必须的语法) 把描述和实现算法所用到的语言特性、软件库和操作系统特定总称为基础编程模型 Java程序的基本结构 一段Java程序或者是一个静态方法库,或者定义了一个数据类型,需要用到的语法\n原始数据类型(在计算机中精确地定义整数浮点数布尔值等) 语句(创建变量并赋值,控制运行流程或引发副作用来进行计算,包括声明、赋值、条件、循环、调用和返回) 数组(多个同种数据类型值的集合) 静态方法(封装并重用代码) 字符串(一连串的字符,内置一些对他们的操作) 标准输入/输出(是程序与外界联系的桥梁) 数据抽象(数据抽象封装和重用代码,可以定义非原始数据类型,进而面向对象编程) 把这种输入命令执行程序的环境称为 虚拟终端\n要执行一条Java程序,需要先用javac命令编译,然后用java命令运行,比如下面的文件,需要使用命令\njavac BinarySearch.java java BinarySearch 原始数据类型与表达式 # 数据类型就是一组数据和其所能进行的操作的集合 Java中最基础的数据类型(整型int,双精度实数类型double,布尔值boolean,字符型char) Java程序控制用标识符命名的变量 对于原始类型,用标识符引用变量,+-*/指定操作,用字面量来表示值(如1或3.14),用表达式表示对值的操作( 表达式:(x+2.334)/2 ) 只要能够指定值域和在此值域上的操作,就能定义一个数据类型(很像数学上函数的定义) +-*/是被重载过的 运算产生的数据的数据类型和参与运算的数据的数据类型是相同的(5/3=1,5.0/3.0=1.6667等) 如下图(图歪了亿点点..) 表达式 表达式具有优先级,Java使用的是中缀表达式(一个字面量紧接运算符,然后是另一个字面量)。逻辑运算中优先级 ! \u0026amp;\u0026amp; || ,运算符中 * / % 高于+ - 。括号能改变这些规则。代码中尽量使用括号消除对优先级的依赖 类型转换 数值会自动提升为高级数据类型,如1+2.5 1会被先转为double 1.0,值也为double的3.5 强转(把类型名放在括号里讲其转换为括号中的类型) 讲高级数据类型转为低级可能会导致精度的缺失,尽量少使用 比较 ==、!=、\u0026lt;、\u0026lt;=、\u0026gt;、\u0026gt;=,这些运算符称为 混合类型运算符,因为结果是布尔型而不是参与比较的数据类型 结果是布尔型的表达式称为布尔表达式 其他原始类型(int为32位,double为64位) long,64位整数 short,16位整数 char,16位字符 byte,8位整数 32位单精度实数,float 语句 # 语句用来创建和操作变量、对变量赋值并控制操作的执行流程 包括声明语句、赋值语句、条件语句、循环语句、调用和返回语句 声明:让一个变量名和一个类型在编译时关联起来 赋值:将(由一个表达式定义的)某个数据类型额值和一个变量关联起来 条件语句: if (\u0026lt;boolean expression\u0026gt;) { \u0026lt;block statement\u0026gt; } 循环语句 while(\u0026lt;boolean expression\u0026gt;) { \u0026lt;block statement\u0026gt; } 其中循环语句中的代码段称为循环体 break与continue语句 break,立即退出循环 continue,立即开始下一轮循环 简便记法 # 声明并初始化 隐式赋值 ++i;\u0026ndash;i i/=2;i+=1 单语句代码段(省略if/while代码段的花括号) for语句 for(\u0026lt;initialize\u0026gt;;\u0026lt;boolean expression\u0026gt;;\u0026lt;increment\u0026gt;) { \u0026lt;block statements\u0026gt; } 这段代码等价于后面的 \u0026lt;initialize\u0026gt;; while(\u0026lt;boolean expression\u0026gt;) { \u0026lt;block statments\u0026gt; \u0026lt;increment\u0026gt;; } java语句总结\n数组 # 数组能够存储相同类型的多个数据 N个数组的数组编号为0至N-1;这种数组在Java中称为一维数组 创建并初始化数组 需要三个步骤,声明数组名字和类型,创建数组,初始化数组元素 声明并初始化一个数组\n简化写法\ndouble[] a = new double[N]; 使用数组(访问的索引小于0或者大于N-1时会抛出ArrayIndexOutOfBoundsException) 典型的数组处理代码\n起别名 下面的情况并没有将数组新复制一份,而是a,b指向了同一个数组\n二维数组 Java中二维数组就是一堆数组的数组 二维数组可以是参差不齐,比如a[0]=new double[5],a[1]=new double[6]之类 二维数组的创建及初始化 double[][] a; a = new double[M][N]; for (int i = 0; i \u0026lt; M; i++) for (int j = 0; j \u0026lt; N; j++) a[i][j] = 0.0; 精简后的代码 double[][] a=new double[M][N]; "},{"id":418,"href":"/zh/docs/technology/Linux/hanshunping_/01-06/","title":"linux_韩老师_01-06","section":"韩顺平老师_","content":" 基础介绍 # 本套课程内容\n基础篇: linux入门、vm和Linux的安装、linux目录结构 实操篇 远程登录(xshell,xftp)、实用指令、进程管理、用户管理 vi和vim编辑器、定时任务调度、RPM和YUM 开机、重启和用户登录注销、磁盘分区及挂载、网络配置 linux使用的地方 在linux下开发项目(需要把javaee项目部署到linux下运行) linux运维工程师(服务器规划、优化、监控等) linux嵌入式工程师(linux下驱动开发[c,c++]) linux应用领域 个人桌面 服务器(免费稳定高效) 嵌入式领域(对软件裁剪,内核最小可达几百kb等) linux介绍 # linux是一个开源免费操作系统 linux吉祥物\ntux(/tu\u0026rsquo;ks/唾可si),没找到音标,将就一下\nlinux之父,linus,也是git的创作者\n主要发行版:Ubuntu、RedHat,Centos,Debian等\nRedHat和Centos使用同样的源码,但是RedHat收费 Linux和Unix的关系\nunix也是一个操作系统,贝尔实验室。做一个多用户分时操作系统, multics,但是没完成。其中一个后来在这基础上,完成的操作系统为unix (原本是B语言写的),后面和另一个人用unix用c语言改写了。\nunix源码是公开的,后面商业公司拿来包装做成自己的系统, 后面有个人提倡自由时代用户应该对源码享有读写权利而非垄断\n后面RichardStallman发起GNU计划(开源计划),Linus参加该计划,并共享出linux内核,于是大家在此基础上开发出各种软件。linux又称GNU/linux Linux和Unix关系\nVMWare安装Centos7.6 # 在windows中安装Linux系统\nVM和Linux系统在pc中的关系\n安装过程中,网络模式使用NAT模式\n选择最小安装,且选择CompatibilityLibraries和DevelopmentTools\nlinux分区\n一般分为三个\n一般boot1G,swap分区一般跟内存大小一致,这里是2G,所以根分区就是剩下的,也就是20-1-2=17G\n如图,boot,/,swap都是标准分区。且boot和/是ext4的文件格式,swap是swap的文件格式\n修改主机名\n修改密码及增加除root外的普通用户\n修改网络为固定ip(NAT模式下)\n先在VM里面把子网ip改了,这里改成 192.168.200.0\n然后改网关为192.168.200.200\n使用yum install -y vim 安装文本编辑工具 最后在linux中改配置文件 vim /etc/sysconfig/network-scripts/ifcfg-ens33 其中先修改BOOTPROTO=\u0026ldquo;static\u0026rdquo; 然后设置ip地址、网关和DNS, 下面是添加到上面的ifcfg-ens33后面,不是直接执行代码 IPADDR=192.168.200.200 GATEWAY=192.168.200.2 DNS1=192.168.200.2 使用命令重启网络 service network restart # 或者直接重启电脑 reboot 这里顺便装一下zsx\nsh -c \u0026#34;$(wget https://raw.github.com/ohmyzsh/ohmyzsh/master/tools/install.sh -O -)\u0026#34; "},{"id":419,"href":"/zh/docs/technology/Redis/shangguigu_BV1Rv41177Af_/19-A/","title":"redis_尚硅谷_19-A","section":"基础(尚硅谷)_","content":" 验证码模拟 # 首先需要一个MyRedis单例类 /** * MyRedis单例类 */ public class MyJedis { private static Jedis myJedis; public static Jedis getInstance() { //如果是空则进行初始化 if (myJedis == null) { //由于synchronized同步是在条件判断内,所以同步 //并不会一直都执行,增加了效率 synchronized (MyJedis.class) { if (myJedis == null) { //设置密码 DefaultJedisClientConfig.Builder builder = DefaultJedisClientConfig.builder() .password(\u0026#34;hello.lwm\u0026#34;); DefaultJedisClientConfig config = builder.build(); Jedis jedis = new redis.clients.jedis.Jedis(\u0026#34;192.168.200.200\u0026#34;, 6379, config); return jedis; } } } return myJedis; } } "},{"id":420,"href":"/zh/docs/technology/Redis/shangguigu_BV1Rv41177Af_/18/","title":"redis_尚硅谷_18","section":"基础(尚硅谷)_","content":" Jedis操作Redis6 # 插曲:本地项目关联github远程库 git init git add README.md git commit -m \u0026#34;first commit\u0026#34; #-m表示强制重命名 git branch -M main #使用别名 git remote add origin git@github.com:lwmfjc/jedis_demo.git #用了-u之后以后可以直接用git push替代整行 git push -u origin main jedis pom依赖 \u0026lt;!-- https://mvnrepository.com/artifact/redis.clients/jedis --\u0026gt; \u0026lt;dependency\u0026gt; \u0026lt;groupId\u0026gt;redis.clients\u0026lt;/groupId\u0026gt; \u0026lt;artifactId\u0026gt;jedis\u0026lt;/artifactId\u0026gt; \u0026lt;version\u0026gt;4.0.1\u0026lt;/version\u0026gt; \u0026lt;/dependency\u0026gt; jedis使用 public class Main { public static void main(String[] args) { //设置密码 DefaultJedisClientConfig.Builder builder = DefaultJedisClientConfig.builder() .password(\u0026#34;hello.lwm\u0026#34;); DefaultJedisClientConfig config = builder.build(); Jedis jedis = new Jedis(\u0026#34;192.168.200.200\u0026#34;, 6379, config); //ping String value = jedis.ping(); System.out.println(value); //返回所有key Set\u0026lt;String\u0026gt; keys = jedis.keys(\u0026#34;*\u0026#34;); System.out.println(\u0026#34;key count: \u0026#34; + keys.size()); for (String key : keys) { System.out.printf(\u0026#34;key--:%s---value:%s\\n\u0026#34;, key, jedis.get(key)); } System.out.println(\u0026#34;操作list\u0026#34;); //操作list jedis.lpush(\u0026#34;ly-list\u0026#34;, \u0026#34;java\u0026#34;, \u0026#34;c++\u0026#34;, \u0026#34;css\u0026#34;); List\u0026lt;String\u0026gt; lrange = jedis.lrange(\u0026#34;ly-list\u0026#34;, 0, -1); for (String v : lrange) { System.out.println(\u0026#34;value:\u0026#34; + v); } //操作set System.out.println(\u0026#34;操作set\u0026#34;); jedis.sadd(\u0026#34;ly-set\u0026#34;, \u0026#34;1\u0026#34;, \u0026#34;3\u0026#34;, \u0026#34;3\u0026#34;, \u0026#34;5\u0026#34;, \u0026#34;1\u0026#34;); Set\u0026lt;String\u0026gt; smembers = jedis.smembers(\u0026#34;ly-set\u0026#34;); for (String v : smembers) { System.out.println(\u0026#34;value:\u0026#34; + v); } //操作hash System.out.println(\u0026#34;操作hash\u0026#34;); jedis.hset(\u0026#34;ly-hash\u0026#34;, \u0026#34;name\u0026#34;, \u0026#34;lidian\u0026#34;); jedis.hset(\u0026#34;ly-hash\u0026#34;, \u0026#34;age\u0026#34;, \u0026#34;30\u0026#34;); jedis.hset(\u0026#34;ly-hash\u0026#34;, \u0026#34;sex\u0026#34;, \u0026#34;man\u0026#34;); Map\u0026lt;String, String\u0026gt; lyHash = jedis.hgetAll(\u0026#34;ly-hash\u0026#34;); for (String key : lyHash.keySet()) { System.out.println(key + \u0026#34;:\u0026#34; + lyHash.get(key)); } //操作zset System.out.println(\u0026#34;操作zset\u0026#34;); jedis.zadd(\u0026#34;person\u0026#34;, 100, \u0026#34;xiaohong\u0026#34;); jedis.zadd(\u0026#34;person\u0026#34;, 80, \u0026#34;xiaoli\u0026#34;); jedis.zadd(\u0026#34;person\u0026#34;, 90, \u0026#34;xiaochen\u0026#34;); List\u0026lt;String\u0026gt; person = jedis.zrange(\u0026#34;person\u0026#34;, 0, -1); for (String name : person) { System.out.println(name); } //结束操作 jedis.flushDB(); jedis.close(); } } "},{"id":421,"href":"/zh/docs/technology/Redis/shangguigu_BV1Rv41177Af_/12-17/","title":"redis_尚硅谷_12-17","section":"基础(尚硅谷)_","content":" Redis配置文件 # redis中单位的设置,支持k,kb,m,mb,g,gb,且不区分大小写\ninclude (包含其他文件,比如公共部分)\nbind bind 127.0.0.1 ::1 #listens on loopback IPv4 and IPv6 后面这个::1,相当于ipv6版的127.0.0.1。在redis配置文件中,整句表示只允许本地网卡的某个ip连接(但是它并不能指定某个主机连接到redis中。比如本机有两个网卡,两个ip,可以限定只有其中一个ip可以连接) 如果注释掉了/或者bind 0.0.0.0,表示允许所有主机连接 protected-mode protected-mode yes 设置保护模式为yes,protected是redis本身的一个安全层,这个安全层在同时满足下面三个条件的时候会开启,开启后只有本机可以访问redis protected-mode yes 没有bind指令(bind 0.0.0.0不属于这个条件) 没有设置密码 (没有设置requirepass password) 只要上面一个条件不满足,就不会开启保护模式。换言之,只要设置了bind 0.0.0.0或者没有设置bind,且不满足上面三个条件之一,就能够进行远程访问(当然,linux/windows的6379端口要开放) tcp-backlog 表示未连接队列总和 timeout 秒为单位,时间内没操作则断开连接 tcp-keepalive 300 心跳检测,每隔300s检测连接是否存在 pidfile /var/run/redis_6379.pid 将进程号保存到文件中 loglevel 表示日志的级别/debug/verbose/notice/warning logfile \u0026quot;\u0026quot; 设置日志的路径 database 16 默认有16个库 requirepass password 设置密码 maxclients 设置最大连接数 maxmemory 设置最大内存量,达到则会根据移除策略进行移除操作 Redis的发布和订阅 # 发布订阅,pub/sub,是一种消息通信模式:发送者pub发送消息,订阅器sub接收消息 发布者能发布消息,订阅者可以订阅/接收消息\n操作 subscribe channel1 #客户端A订阅频道 publish channel1 helloly #向频道发送消息 此时订阅channel1频道的客户端就会接收到消息\nredis新数据类型 # Bitmaps # 进行二进制操作\n可以把Bitmaps想象成一个以位为单位的数组,数组的每个单元只能存储0和1,数组的下标在Bitmaps中叫做偏移量\nbitcount:统计字符串被设置为1的bit数,这里结果是5\nbitcount u1 0 1 #统计字符串第0个字节到第1个字节1的bit数\n(1,6,11,15,19bit值为1)[也就是统计第0到第15位的1的个数]\nsetbit u1 1 1 setbit u1 2 1 setbit u1 5 1 setbit u1 9 1 setbit u2 0 1 setbit u2 1 1 setbit u2 4 1 setbit u2 9 1 获取u1,u2共同位为1的个数,如上1,9都是1,所以返回2,且 bitcount u1\u0026ndash;u2的值为2(第1和第9位为1),其实就是u1和u2进行\u0026amp;操作\nbitop and u1-and-u2 u1 u2 获取u1或u2存在值为1的位的个数,如上结果为8-2=6,结果存在u1-or-u2中,即1,2,5,9,0,4的位 值为1(的字符串),其实就是u1和u2进行或操作\n性能比较,假设有一亿个用户,用户id数值递增,需求是存储每个用户是否活跃。下面是使用hashMap和bitmaps的比较\nbitmaps主要用来进行位操作计算\nHyperLogLog # 解决基数问题\n从{1,3,5,5,7,8,8,7,9}找出基数:基数为5,即不重复元素的个数 解决方案 mysql中可以用distinct count redis中可以用hash,set,bitmaps 使用 pfadd a 1 2 3 4 3 3 3 2 1 6 7 pfcount a #得到基数 6 pfadd b 1 10 7 15 #基数4 pfmerge c a b #将a,b合并到c pfcount c #得到基数8 GEO类型 (geographic) # 基本命令 geoadd china:city 121.47 31.43 shanghai geoadd china:city 166.50 29.53 chongqing 114.05 22.52 shenzhen geoadd china:city 16.38 39.90 beijing 不支持南北极,所以有效经度在-180到180度,有效纬度从-85.05xxx度到85.05xxx度 获取坐标值及直线距离 geopos china:city beijing #获取beijing经纬度 geodist china:city beijing shenzhen km #获取beijing到shenzhen的直线距离 # 单位有m,km,ft,mi 以给定的经纬度为中心,找出某一半径内的元素 georadius china:city 110 30 1000 km End # "},{"id":422,"href":"/zh/docs/technology/Redis/shangguigu_BV1Rv41177Af_/06-11/","title":"redis_尚硅谷_06-11","section":"基础(尚硅谷)_","content":" Redis针对key的基本操作 # 常用命令 keys * #查找当前库所有库 exists key1 #key1是否存在 1存在;0不存在 type key2 #key2的类型 del key3 #删除key3 unlink key3 #删除key3(选择非阻塞删除。会先从元数据删除,而真正删除是异步删除) expire key1 10 #设置key1的过期时间,单位秒 ttl key1 #获取key1的剩余存活时间,-2表示key已过期或不存在,-1表示永不过期 select 1 #切换到1号库(redis中有15个库,默认在库1) dbsize #查找当前redis库中有多少个key flushdb #清空当前库 flushall #清空所有库 Redis中常用数据类型 # 字符串(String) # String是二进制安全的,可以包含jpg图片或序列化的对象 一个Redis中字符串value最多可以只能是512M 常用命令 set key1 value1 get key1 set key1 value11 #将覆盖上一个值 append key1 abc #在key1的值追加\u0026#34;abc\u0026#34; strlen key1 #key值的长度 setnx key1 value #当key不存在时才设置key incr n1 #将n1的值加一,,如果n1不存在则会创建key n1 并改为1(0+1) decr n1 #将n1的值减一,如果n1不存在则会创建key n1 并改为-1(0-1) incrby n1 20 #将n1的值加20,其他同上 decrby n1 20 #将n1的值减20,其他同上 redis原子性\nincr具有原子性操作\njava中的i++不是原子操作 其他命令 mset k1 v1 k2 v2 mget k1 k2 msetnx k1 v1 k2 v2 #仅当所有的key都不存在时才会进行设置 getrange name 0 3 #截断字符串[0,3] setrange name 3 123 #从下标[3]开始替换字符串(换成123) setex k1 20 v1 #设置过期时间为20s expire k1 30 #设置过期时间为30s getset k1 123 #获取旧值,并设置一个新值 数据结构,SimpleDynamicString,SDS,简单动态字符串,内部结构类似Java的ArrayList,采用预分配冗余空间的方式来减少内存的频繁分配\n列表 (List) # 单键多值 底层是双向链表 从左放 lpush k1 v1 v2 v3 #从左边放(从左往右推) lrange k1 0 -1 #从左边取(v3 v2 v1) lpush:\n从右放 rpush k2 v1 v2 v3 brpush:\nlpop/rpop lpop k2 #从左边弹出一个值 lpop k2 2 #从左边弹出两个值,当键没有包含值时,键被删除 rpoplpush lpush a a1 a2 a3 rpush b b1 b2 b3 rpoplpush a b #此时a:a1 a2,b:a3 b1 b2 b3 lrange lrange b 1 2 #获取b中下标[1,2]的所有值 lrange b 1 -1 #获取所有值[1,最大下标]的所有值 lindex,llen lindex b 1 #直接取第一个下标的元素 llen b #获取列表的长度 linsert linsert b before b2 myinsert linsert b after b2 myinsert #在某个列表的值(如果重复取第一个)的位置之前/之后插入值 lrem,lset lrem b 2 a #从b列表中,删除两个a(从左往右) lset b 2 AA #把下标2的值设置为AA list数据结构是一个快速列表,quicklist\n当元素较少的时候,会使用连续的内存存储,结构时ziplist,即压缩列表;当数据多的时候会有多个压缩列表,然后会链接到一起(使用双向指针)\n集合(Set) # 特点:无序,不重复 Set:string类型的无序集合,底层是一个value为null的hash表;添加/删除时间复杂度为O(1) 常用命令 sadd k1 v1 v2 v3 v2 v2 v1 #设置集合中的值 smembers k1 #取出集合中的值 sismember k1 v3 #k1是否存在v3,存在返回1,不存在返回0 scard k1 #返回集合中元素的个数 srem k1 v2 v3 #删除集合中的v2和v3 spop k1 #从k1中随机取出一个值 srandmember k1 2 #从k1中随机取出2个值 smove a k a1 #从a中将a1移动到k中 sinter a k #取a,k的交集 sunion a k #取a,k的并集 sdiff a k #返回两个集合的差集(从集合a中,去除存在集合k中的元素,即a-k) Set数据结构时dict字典,字典使用哈希表实现的 哈希(Hash) # 是String类型的field和value的映射表,用来存储对象,类似java中的Map\u0026lt;String,Object\u0026gt; 常用命令 hset user:1001 id 1 #设置(对象)user:1001的id属性值 hset user:1001 name zhangsan hget user:1001 name #取出user:1001的name hmset user:1001 id 1 name zhangsan #批量设置(现在hset也可以批量设置了,hmset已弃用) hexists user:1001 id 1 #判断属性id是否存在 hkeys user:1001 #查看hash结构中的所有filed hvals user:1001 #查看hash结构中所有value hincrby user:1001 age 2 #给hash结构的age属性值加2 hsetnx user:1001 age 10 #给hash结构的age属性设置值为10(如果age属性不存在) hash类型数据结构,当field-value长度较短时用的是ziplist,否则使用的是hashtable 有序集合(ZSet) # 与set很相似,但是是有序的 有序集合的所有元素(成员)都关联一个评分(score),score用来从最低到最高方式进行排序,成员唯一但评分是重复的 常用命令 zadd topn 100 xiaoming 120 xiaohong 60 xiaochen #添加key并为每个成员添加评分 zadd topn xiaoli 200 zrange topn 0 -1 #查找出所有成员(按排名由小到大) zrange topn 0 -1 withscores #从小到大查找所有成员并显示分数 zrangebyscore topn 130 200 #查找所有在130-200的成员 zrevrangebyscore topn 200 130 #从大到小查找所有成员(注意,从大到小时第一个值必须大于等于第二个) zincrby topn 15 xiaohong #给小红添加15分 zrem topn xiaohong #删除元素 zcount topn 10 200 #统计该集合,分数区间内的元素个数 zrank topn xiaohong #xiaohong的排名,从0开始 zset底层数据结构 hash结构\n跳跃表 给元素value排序,根据score的范围获取元素列表 对比有序链表和跳跃表 查找51元素\n跳跃表\n按图中的顺序查找,查找四次就能找到\nEnd "},{"id":423,"href":"/zh/docs/problem/Hugo/p1/","title":"hugo踩坑","section":"Hugo","content":" 对于访问文件资源\nhugo的文件夹名不能以-结尾。 一个文件夹(比如这里是hugo文件夹)中,其中的index.md文件中引用图片时,是以index.md所在文件夹(也就是hugo文件夹)为根目录访问图片;而其中的01a.md文件中引用图片时,是以和该文件同级的01a文件夹(也就是hugo/01a/)为根目录,访问图片\n当一个文件夹下存在index.md文件时,其他文件(代表的文章)不显示在网站的文章列表\n为了某些文件预览功能,我建议使用下面的文件夹结构处理文章及资源\n"},{"id":424,"href":"/zh/docs/problem/Hugo/01a/","title":"图片测试(hugo踩坑)","section":"Hugo","content":" 图片测试 # "},{"id":425,"href":"/zh/docs/technology/Redis/shangguigu_BV1Rv41177Af_/01-05/","title":"redis_尚硅谷_01-05","section":"基础(尚硅谷)_","content":" 课程简介 # NoSQL数据库简介、Redis概述与安装、常用五大数据结构、配置文件详解、发布与订阅、Redis6新数据类型、Redis与spring boot整合、事务操作、持久化之RDB、持久化之AOF、主从复制及集群、Redis6应用问题(缓存穿透、击穿、雪崩以及分布式锁)、Redis6新增功能\nNoSQL数据库简介 # Redis属于NoSQL数据库 技术分为三大类 解决功能性问题:Java、Jsp、RDBMS、Tomcat、Linux、JDBC、SVN 解决扩展性问题:Struts、Spring、SpringMVC、Hibernate、Mybatis 解决性能问题:NoSQL、Java线程、Nginx、MQ、ElasticSearch 缓存数据库的好处 完全在内存中,速度快,结构简单 作为缓存数据库:减少io的读操作 NoSQL=Not Only SQL,不仅仅是SQL,泛指非泛型数据库 不支持ACID(但是NoSQL支持事务) 选超于SQL的性能 NoSQL适用场景 对数据高并发的读写 海量数据的读写 对数据高可扩展性 NoSQL不适用的场景 需要事务支持 基于sql的结构化查询存储 多种NoSQL数据库介绍 Memcache 不支持持久化,数据类型单一,一般作为辅助持久化的数据库 Redis 支持持久化,除了k-v模式还有其他多种数据结构,一般作为辅助持久化的数据库 MongoDB,是文档型数据类型;k-v模型,但是对value提供了丰富的查询功能;支持二进制数据及大型对象;替代RDBMS,成为独立数据库 大数据时代(行式数据库、列式数据库) 行式数据库\n查询某一块数据的时候效率高\n列式数据库\n查询某一列统计信息快\n其他\nHbase,Cassandra,图关系数据库(比如社会关系,公共交通网等) 小计\nNoSQL数据库是为提高性能而产生的非关系型数据库 Redis概述与安装 # 简单概述 Redis是一个开源的kv存储系统 相比Mencached,支持存储的数据类型更多,包括string,list,set,zset以及hash,这些类型都支持(pop、add/remove及取交并集和差集等),操作都是原子性的 Redis数据都是缓存在内存中 Redis会周期性地把数据写入磁盘或修改操作写入追加的记录文件 能在此基础上实现master-slave(主从)同步 Redis功能 配合关系型数据库做高速缓存 Redis具有多样的数据结构存储持久化数据 其他部分功能\nRedis安装 从官网中下载redis-6.xx.tar.gz包(该教程在linux中使用redis6教学) 编译redis需要gcc环境 使用gcc \u0026ndash;version查看服务器是否有gcc环境 如果没有需要进行安装 apt install -y gcc 或者 yum install -y gcc 将redis压缩文件进行解压 tar -zxvf redis-6xx.tar.gz 进入解压后的文件夹,并使用make命令进行编译 make 如果报错了,需要先用下面命令清理,之后再进行编译 make distclean 安装redis make install 进入/usr/local/bin目录,查看目录\nRedis启动 前台启动 redis-server 后台启动 在刚才解压的文件夹中,拷贝出redis.conf文件(这里拷贝到/etc/目录下) cp redis.conf /etc/redis.conf 到etc中修改redis.conf文件 vim /etc/redis.conf # 进入编辑器后使用下面命令进行搜索并回车 /daemonize no 将no改为yes并保存 进入/usr/local/bin目录启动redis redis-server /etc/redis.conf 查看进程,发现redis已经启动 ps -ef | grep redis 使用redis-cli 客户端连接redis redis-cli keys * 相关知识 # Redis6379的由来 人名Merz 在九宫格对应的数字就是6379\nRedis默认有15个库,默认数据都在数据库0中,所有库的密码都是相同的 Redis是单线程+多路复用技术 Redis是串行操作\n火车站的例子\n当1,2,3没有票的时候,不用一直等待买票,可以继续做自己的事情,黄牛买到票就会通知123进行取票\nMemcached和Redis区别 Memcached支持单一数据类型,Redis支持多数据类型 Memcached不支持持久化 Memcached用的多线程+锁的机制,Redis用的是单线程+多路复用程序 End # "},{"id":426,"href":"/zh/docs/life/archive/20121226/","title":"2021年最后一个周日","section":"往日归档","content":" 装宽带 # 太晚了,不想写了- -。简单写几个字吧,满心期待的装了宽带,但是并没有我想像的那么快乐。反而打了两把游戏更难过了,难过的是浪费了时间也什么都没得到\n图书馆 # 下午跑去图书馆收获倒是挺多,可能是我不太熟悉,对于书架上的书没有太大的感触。但是环境真的太棒了,很安静,感觉多发出点声音我都会觉得不好意思,大家都很自觉。也许对经常网上都能找到电子书看(程序员的事怎么能是盗呢)的人帮助不会特别大,但对于很大一部分人绝对帮助特别大,包括学生、老年人、还有一些文学类书籍阅读者等等(我一直认为文学类的一定要纸质的看起来才有味道~)\n当然,从图书馆回来我又打了两把游戏 o_O,dota2 yyds!! 打完日常卸载,哈哈\n每次去图书馆我都会想起那句话,\u0026quot;一个国家为其年轻人所提供的教育,决定了这个国家未来的样子\u0026quot;。\n希望能多办点这样的图书馆,大家都能少点浮躁,多点沉淀;虽然我并不是热心公益人士,但我还是希望咱们国家的人民都生活的越来越好。不要辜负我们曾经受过的苦难。\n"},{"id":427,"href":"/zh/docs/life/archive/20231021/","title":"沉沦","section":"往日归档","content":"玩物丧志并非是错的,如果你命里是的话。可惜我不是,我明显有其他更为重要的事等着我去做。我应该是骨子里的老实人。如果顺利的话我应该属于研究所那种老干部,至少现在思维已经老化得跟他们差不多了。\n"},{"id":428,"href":"/zh/docs/technology/Interview/cs-basics/data-structure/heap/","title":"Index","section":"Data Structure","content":" 堆 # 什么是堆 # 堆是一种满足以下条件的树:\n堆中的每一个节点值都大于等于(或小于等于)子树中所有节点的值。或者说,任意一个节点的值都大于等于(或小于等于)所有子节点的值。\n大家可以把堆(最大堆)理解为一个公司,这个公司很公平,谁能力强谁就当老大,不存在弱的人当老大,老大手底下的人一定不会比他强。这样有助于理解后续堆的操作。\n!!!特别提示:\n很多博客说堆是完全二叉树,其实并非如此,堆不一定是完全二叉树,只是为了方便存储和索引,我们通常用完全二叉树的形式来表示堆,事实上,广为人知的斐波那契堆和二项堆就不是完全二叉树,它们甚至都不是二叉树。 (二叉)堆是一个数组,它可以被看成是一个 近似的完全二叉树。——《算法导论》第三版 大家可以尝试判断下面给出的图是否是堆?\n第 1 个和第 2 个是堆。第 1 个是最大堆,每个节点都比子树中所有节点大。第 2 个是最小堆,每个节点都比子树中所有节点小。\n第 3 个不是,第三个中,根结点 1 比 2 和 15 小,而 15 却比 3 大,19 比 5 大,不满足堆的性质。\n堆的用途 # 当我们只关心所有数据中的最大值或者最小值,存在多次获取最大值或者最小值,多次插入或删除数据时,就可以使用堆。\n有小伙伴可能会想到用有序数组,初始化一个有序数组时间复杂度是 O(nlog(n)),查找最大值或者最小值时间复杂度都是 O(1),但是,涉及到更新(插入或删除)数据时,时间复杂度为 O(n),即使是使用复杂度为 O(log(n)) 的二分法找到要插入或者删除的数据,在移动数据时也需要 O(n) 的时间复杂度。\n相对于有序数组而言,堆的主要优势在于插入和删除数据效率较高。 因为堆是基于完全二叉树实现的,所以在插入和删除数据时,只需要在二叉树中上下移动节点,时间复杂度为 O(log(n)),相比有序数组的 O(n),效率更高。\n不过,需要注意的是:Heap 初始化的时间复杂度为 O(n),而非O(nlogn)。\n堆的分类 # 堆分为 最大堆 和 最小堆。二者的区别在于节点的排序方式。\n最大堆:堆中的每一个节点的值都大于等于子树中所有节点的值 最小堆:堆中的每一个节点的值都小于等于子树中所有节点的值 如下图所示,图 1 是最大堆,图 2 是最小堆\n堆的存储 # 之前介绍树的时候说过,由于完全二叉树的优秀性质,利用数组存储二叉树即节省空间,又方便索引(若根结点的序号为 1,那么对于树中任意节点 i,其左子节点序号为 2*i,右子节点序号为 2*i+1)。\n为了方便存储和索引,(二叉)堆可以用完全二叉树的形式进行存储。存储的方式如下图所示:\n堆的操作 # 堆的更新操作主要包括两种 : 插入元素 和 删除堆顶元素。操作过程需要着重掌握和理解。\n在进入正题之前,再重申一遍,堆是一个公平的公司,有能力的人自然会走到与他能力所匹配的位置\n插入元素 # 插入元素,作为一个新入职的员工,初来乍到,这个员工需要从基层做起\n1.将要插入的元素放到最后\n有能力的人会逐渐升职加薪,是金子总会发光的!!!\n2.从底向上,如果父结点比该元素小,则该节点和父结点交换,直到无法交换\n删除堆顶元素 # 根据堆的性质可知,最大堆的堆顶元素为所有元素中最大的,最小堆的堆顶元素是所有元素中最小的。当我们需要多次查找最大元素或者最小元素的时候,可以利用堆来实现。\n删除堆顶元素后,为了保持堆的性质,需要对堆的结构进行调整,我们将这个过程称之为\u0026quot;堆化\u0026quot;,堆化的方法分为两种:\n一种是自底向上的堆化,上述的插入元素所使用的就是自底向上的堆化,元素从最底部向上移动。 另一种是自顶向下堆化,元素由最顶部向下移动。在讲解删除堆顶元素的方法时,我将阐述这两种操作的过程,大家可以体会一下二者的不同。 自底向上堆化 # 在堆这个公司中,会出现老大离职的现象,老大离职之后,他的位置就空出来了\n首先删除堆顶元素,使得数组中下标为 1 的位置空出。\n那么他的位置由谁来接替呢,当然是他的直接下属了,谁能力强就让谁上呗\n比较根结点的左子节点和右子节点,也就是下标为 2,3 的数组元素,将较大的元素填充到根结点(下标为 1)的位置。\n这个时候又空出一个位置了,老规矩,谁有能力谁上\n一直循环比较空出位置的左右子节点,并将较大者移至空位,直到堆的最底部\n这个时候已经完成了自底向上的堆化,没有元素可以填补空缺了,但是,我们可以看到数组中出现了“气泡”,这会导致存储空间的浪费。接下来我们试试自顶向下堆化。\n自顶向下堆化 # 自顶向下的堆化用一个词形容就是“石沉大海”,那么第一件事情,就是把石头抬起来,从海面扔下去。这个石头就是堆的最后一个元素,我们将最后一个元素移动到堆顶。\n然后开始将这个石头沉入海底,不停与左右子节点的值进行比较,和较大的子节点交换位置,直到无法交换位置。\n堆的操作总结 # 插入元素:先将元素放至数组末尾,再自底向上堆化,将末尾元素上浮 删除堆顶元素:删除堆顶元素,将末尾元素放至堆顶,再自顶向下堆化,将堆顶元素下沉。也可以自底向上堆化,只是会产生“气泡”,浪费存储空间。最好采用自顶向下堆化的方式。 堆排序 # 堆排序的过程分为两步:\n第一步是建堆,将一个无序的数组建立为一个堆 第二步是排序,将堆顶元素取出,然后对剩下的元素进行堆化,反复迭代,直到所有元素被取出为止。 建堆 # 如果你已经足够了解堆化的过程,那么建堆的过程掌握起来就比较容易了。建堆的过程就是一个对所有非叶节点的自顶向下堆化过程。\n首先要了解哪些是非叶节点,最后一个节点的父结点及它之前的元素,都是非叶节点。也就是说,如果节点个数为 n,那么我们需要对 n/2 到 1 的节点进行自顶向下(沉底)堆化。\n具体过程如下图:\n将初始的无序数组抽象为一棵树,图中的节点个数为 6,所以 4,5,6 节点为叶节点,1,2,3 节点为非叶节点,所以要对 1-3 号节点进行自顶向下(沉底)堆化,注意,顺序是从后往前堆化,从 3 号节点开始,一直到 1 号节点。 3 号节点堆化结果:\n2 号节点堆化结果:\n1 号节点堆化结果:\n至此,数组所对应的树已经成为了一个最大堆,建堆完成!\n排序 # 由于堆顶元素是所有元素中最大的,所以我们重复取出堆顶元素,将这个最大的堆顶元素放至数组末尾,并对剩下的元素进行堆化即可。\n现在思考两个问题:\n删除堆顶元素后需要执行自顶向下(沉底)堆化还是自底向上(上浮)堆化? 取出的堆顶元素存在哪,新建一个数组存? 先回答第一个问题,我们需要执行自顶向下(沉底)堆化,这个堆化一开始要将末尾元素移动至堆顶,这个时候末尾的位置就空出来了,由于堆中元素已经减小,这个位置不会再被使用,所以我们可以将取出的元素放在末尾。\n机智的小伙伴已经发现了,这其实是做了一次交换操作,将堆顶和末尾元素调换位置,从而将取出堆顶元素和堆化的第一步(将末尾元素放至根结点位置)进行合并。\n详细过程如下图所示:\n取出第一个元素并堆化:\n取出第二个元素并堆化:\n取出第三个元素并堆化:\n取出第四个元素并堆化:\n取出第五个元素并堆化:\n取出第六个元素并堆化:\n堆排序完成!\n"},{"id":429,"href":"/zh/docs/technology/Interview/high-quality-technical-articles/readme/","title":"Index","section":"High Quality Technical Articles","content":" 程序人生 # 这里主要会收录一些我看到的或者我自己写的和程序员密切相关的非技术类的优质文章,每一篇都值得你阅读 3 遍以上!常看常新!\n练级攻略 # 程序员如何快速学习新技术 程序员的技术成长战略 十年大厂成长之路 美团三年,总结的 10 条血泪教训 给想成长为高级别开发同学的七条建议 糟糕程序员的 20 个坏习惯 工作五年之后,对技术和业务的思考 个人经历 # 从校招入职腾讯的四年工作总结 我在滴滴和头条的两年后端研发工作经验分享 一个中科大差生的 8 年程序员工作总结 华为 OD 275 天后,我进了腾讯! 程序员 # 程序员最该拿的几种高含金量证书 程序员怎样出版一本技术书 程序员高效出书避坑和实践指南 面试 # 斩获 20+ 大厂 offer 的面试经验分享 一位大龄程序员所经历的面试的历炼和思考 从面试官和候选者的角度谈如何准备技术初试 包装严重的 IT 行业,作为面试官,我是如何甄别应聘者的包装程度 普通人的春招总结(阿里、腾讯 offer) 2021 校招我的个人经历和经验 如何在技术初试中考察程序员的技术能力 阿里技术面试的一些秘密 工作 # 新入职一家公司如何快速进入工作状态 32 条总结教你提升职场经验 聊聊大厂的绩效考核 "},{"id":430,"href":"/zh/docs/technology/Interview/java/basis/java-keyword-summary/","title":"Index","section":"Basis","content":" final,static,this,super 关键字总结 # final 关键字 # final 关键字,意思是最终的、不可修改的,最见不得变化 ,用来修饰类、方法和变量,具有以下特点:\nfinal 修饰的类不能被继承,final 类中的所有成员方法都会被隐式的指定为 final 方法;\nfinal 修饰的方法不能被重写;\nfinal 修饰的变量是常量,如果是基本数据类型的变量,则其数值一旦在初始化之后便不能更改;如果是引用类型的变量,则在对其初始化之后便不能让其指向另一个对象。\n说明:使用 final 方法的原因有两个:\n把方法锁定,以防任何继承类修改它的含义; 效率。在早期的 Java 实现版本中,会将 final 方法转为内嵌调用。但是如果方法过于庞大,可能看不到内嵌调用带来的任何性能提升(现在的 Java 版本已经不需要使用 final 方法进行这些优化了)。 static 关键字 # static 关键字主要有以下四种使用场景:\n修饰成员变量和成员方法: 被 static 修饰的成员属于类,不属于单个这个类的某个对象,被类中所有对象共享,可以并且建议通过类名调用。被 static 声明的成员变量属于静态成员变量,静态变量 存放在 Java 内存区域的方法区。调用格式:类名.静态变量名 类名.静态方法名() 静态代码块: 静态代码块定义在类中方法外, 静态代码块在非静态代码块之前执行(静态代码块—\u0026gt;非静态代码块—\u0026gt;构造方法)。 该类不管创建多少对象,静态代码块只执行一次. 静态内部类(static 修饰类的话只能修饰内部类): 静态内部类与非静态内部类之间存在一个最大的区别: 非静态内部类在编译完成之后会隐含地保存着一个引用,该引用是指向创建它的外围类,但是静态内部类却没有。没有这个引用就意味着:1. 它的创建是不需要依赖外围类的创建。2. 它不能使用任何外围类的非 static 成员变量和方法。 静态导包(用来导入类中的静态资源,1.5 之后的新特性): 格式为:import static 这两个关键字连用可以指定导入某个类中的指定静态资源,并且不需要使用类名调用类中静态成员,可以直接使用类中静态成员变量和成员方法。 this 关键字 # this 关键字用于引用类的当前实例。 例如:\nclass Manager { Employees[] employees; void manageEmployees() { int totalEmp = this.employees.length; System.out.println(\u0026#34;Total employees: \u0026#34; + totalEmp); this.report(); } void report() { } } 在上面的示例中,this 关键字用于两个地方:\nthis.employees.length:访问类 Manager 的当前实例的变量。 this.report():调用类 Manager 的当前实例的方法。 此关键字是可选的,这意味着如果上面的示例在不使用此关键字的情况下表现相同。 但是,使用此关键字可能会使代码更易读或易懂。\nsuper 关键字 # super 关键字用于从子类访问父类的变量和方法。 例如:\npublic class Super { protected int number; protected showNumber() { System.out.println(\u0026#34;number = \u0026#34; + number); } } public class Sub extends Super { void bar() { super.number = 10; super.showNumber(); } } 在上面的例子中,Sub 类访问父类成员变量 number 并调用其父类 Super 的 showNumber() 方法。\n使用 this 和 super 要注意的问题:\n在构造器中使用 super() 调用父类中的其他构造方法时,该语句必须处于构造器的首行,否则编译器会报错。另外,this 调用本类中的其他构造方法时,也要放在首行。 this、super 不能用在 static 方法中。 简单解释一下:\n被 static 修饰的成员属于类,不属于单个这个类的某个对象,被类中所有对象共享。而 this 代表对本类对象的引用,指向本类对象;而 super 代表对父类对象的引用,指向父类对象;所以, this 和 super 是属于对象范畴的东西,而静态方法是属于类范畴的东西。\n参考 # https://www.codejava.net/java-core/the-java-language/java-keywords https://blog.csdn.net/u013393958/article/details/79881037 static 关键字详解 # static 关键字主要有以下四种使用场景 # 修饰成员变量和成员方法 静态代码块 修饰类(只能修饰内部类) 静态导包(用来导入类中的静态资源,1.5 之后的新特性) 修饰成员变量和成员方法(常用) # 被 static 修饰的成员属于类,不属于单个这个类的某个对象,被类中所有对象共享,可以并且建议通过类名调用。被 static 声明的成员变量属于静态成员变量,静态变量 存放在 Java 内存区域的方法区。\n方法区与 Java 堆一样,是各个线程共享的内存区域,它用于存储已被虚拟机加载的类信息、常量、静态变量、即时编译器编译后的代码等数据。虽然 Java 虚拟机规范把方法区描述为堆的一个逻辑部分,但是它却有一个别名叫做 Non-Heap(非堆),目的应该是与 Java 堆区分开来。\nHotSpot 虚拟机中方法区也常被称为 “永久代”,本质上两者并不等价。仅仅是因为 HotSpot 虚拟机设计团队用永久代来实现方法区而已,这样 HotSpot 虚拟机的垃圾收集器就可以像管理 Java 堆一样管理这部分内存了。但是这并不是一个好主意,因为这样更容易遇到内存溢出问题。\n调用格式:\n类名.静态变量名 类名.静态方法名() 如果变量或者方法被 private 则代表该属性或者该方法只能在类的内部被访问而不能在类的外部被访问。\n测试方法:\npublic class StaticBean { String name; //静态变量 static int age; public StaticBean(String name) { this.name = name; } //静态方法 static void sayHello() { System.out.println(\u0026#34;Hello i am java\u0026#34;); } @Override public String toString() { return \u0026#34;StaticBean{\u0026#34;+ \u0026#34;name=\u0026#34; + name + \u0026#34;,age=\u0026#34; + age + \u0026#34;}\u0026#34;; } } public class StaticDemo { public static void main(String[] args) { StaticBean staticBean = new StaticBean(\u0026#34;1\u0026#34;); StaticBean staticBean2 = new StaticBean(\u0026#34;2\u0026#34;); StaticBean staticBean3 = new StaticBean(\u0026#34;3\u0026#34;); StaticBean staticBean4 = new StaticBean(\u0026#34;4\u0026#34;); StaticBean.age = 33; System.out.println(staticBean + \u0026#34; \u0026#34; + staticBean2 + \u0026#34; \u0026#34; + staticBean3 + \u0026#34; \u0026#34; + staticBean4); //StaticBean{name=1,age=33} StaticBean{name=2,age=33} StaticBean{name=3,age=33} StaticBean{name=4,age=33} StaticBean.sayHello();//Hello i am java } } 静态代码块 # 静态代码块定义在类中方法外, 静态代码块在非静态代码块之前执行(静态代码块 —\u0026gt; 非静态代码块 —\u0026gt; 构造方法)。 该类不管创建多少对象,静态代码块只执行一次.\n静态代码块的格式是\nstatic { 语句体; } 一个类中的静态代码块可以有多个,位置可以随便放,它不在任何的方法体内,JVM 加载类时会执行这些静态的代码块,如果静态代码块有多个,JVM 将按照它们在类中出现的先后顺序依次执行它们,每个代码块只会被执行一次。\n静态代码块对于定义在它之后的静态变量,可以赋值,但是不能访问.\n静态内部类 # 静态内部类与非静态内部类之间存在一个最大的区别,我们知道非静态内部类在编译完成之后会隐含地保存着一个引用,该引用是指向创建它的外围类,但是静态内部类却没有。没有这个引用就意味着:\n它的创建是不需要依赖外围类的创建。 它不能使用任何外围类的非 static 成员变量和方法。 Example(静态内部类实现单例模式)\npublic class Singleton { //声明为 private 避免调用默认构造方法创建对象 private Singleton() { } // 声明为 private 表明静态内部该类只能在该 Singleton 类中被访问 private static class SingletonHolder { private static final Singleton INSTANCE = new Singleton(); } public static Singleton getUniqueInstance() { return SingletonHolder.INSTANCE; } } 当 Singleton 类加载时,静态内部类 SingletonHolder 没有被加载进内存。只有当调用 getUniqueInstance()方法从而触发 SingletonHolder.INSTANCE 时 SingletonHolder 才会被加载,此时初始化 INSTANCE 实例,并且 JVM 能确保 INSTANCE 只被实例化一次。\n这种方式不仅具有延迟初始化的好处,而且由 JVM 提供了对线程安全的支持。\n静态导包 # 格式为:import static\n这两个关键字连用可以指定导入某个类中的指定静态资源,并且不需要使用类名调用类中静态成员,可以直接使用类中静态成员变量和成员方法\n//将Math中的所有静态资源导入,这时候可以直接使用里面的静态方法,而不用通过类名进行调用 //如果只想导入单一某个静态方法,只需要将*换成对应的方法名即可 import static java.lang.Math.*;//换成import static java.lang.Math.max;具有一样的效果 public class Demo { public static void main(String[] args) { int max = max(1,2); System.out.println(max); } } 补充内容 # 静态方法与非静态方法 # 静态方法属于类本身,非静态方法属于从该类生成的每个对象。 如果您的方法执行的操作不依赖于其类的各个变量和方法,请将其设置为静态(这将使程序的占用空间更小)。 否则,它应该是非静态的。\nExample\nclass Foo { int i; public Foo(int i) { this.i = i; } public static String method1() { return \u0026#34;An example string that doesn\u0026#39;t depend on i (an instance variable)\u0026#34;; } public int method2() { return this.i + 1; //Depends on i } } 你可以像这样调用静态方法:Foo.method1()。 如果您尝试使用这种方法调用 method2 将失败。 但这样可行\nFoo bar = new Foo(1); bar.method2(); 总结:\n在外部调用静态方法时,可以使用”类名.方法名”的方式,也可以使用”对象名.方法名”的方式。而实例方法只有后面这种方式。也就是说,调用静态方法可以无需创建对象。 静态方法在访问本类的成员时,只允许访问静态成员(即静态成员变量和静态方法),而不允许访问实例成员变量和实例方法;实例方法则无此限制 static{}静态代码块与{}非静态代码块(构造代码块) # 相同点:都是在 JVM 加载类时且在构造方法执行之前执行,在类中都可以定义多个,定义多个时按定义的顺序执行,一般在代码块中对一些 static 变量进行赋值。\n不同点:静态代码块在非静态代码块之前执行(静态代码块 -\u0026gt; 非静态代码块 -\u0026gt; 构造方法)。静态代码块只在第一次 new 执行一次,之后不再执行,而非静态代码块在每 new 一次就执行一次。 非静态代码块可在普通方法中定义(不过作用不大);而静态代码块不行。\n🐛 修正(参见: issue #677):静态代码块可能在第一次 new 对象的时候执行,但不一定只在第一次 new 的时候执行。比如通过 Class.forName(\u0026quot;ClassDemo\u0026quot;)创建 Class 对象的时候也会执行,即 new 或者 Class.forName(\u0026quot;ClassDemo\u0026quot;) 都会执行静态代码块。 一般情况下,如果有些代码比如一些项目最常用的变量或对象必须在项目启动的时候就执行的时候,需要使用静态代码块,这种代码是主动执行的。如果我们想要设计不需要创建对象就可以调用类中的方法,例如:Arrays 类,Character 类,String 类等,就需要使用静态方法, 两者的区别是 静态代码块是自动执行的而静态方法是被调用的时候才执行的.\nExample:\npublic class Test { public Test() { System.out.print(\u0026#34;默认构造方法!--\u0026#34;); } //非静态代码块 { System.out.print(\u0026#34;非静态代码块!--\u0026#34;); } //静态代码块 static { System.out.print(\u0026#34;静态代码块!--\u0026#34;); } private static void test() { System.out.print(\u0026#34;静态方法中的内容! --\u0026#34;); { System.out.print(\u0026#34;静态方法中的代码块!--\u0026#34;); } } public static void main(String[] args) { Test test = new Test(); Test.test();//静态代码块!--静态方法中的内容! --静态方法中的代码块!-- } } 上述代码输出:\n静态代码块!--非静态代码块!--默认构造方法!--静态方法中的内容! --静态方法中的代码块!-- 当只执行 Test.test(); 时输出:\n静态代码块!--静态方法中的内容! --静态方法中的代码块!-- 当只执行 Test test = new Test(); 时输出:\n静态代码块!--非静态代码块!--默认构造方法!-- 非静态代码块与构造函数的区别是:非静态代码块是给所有对象进行统一初始化,而构造函数是给对应的对象初始化,因为构造函数是可以多个的,运行哪个构造函数就会建立什么样的对象,但无论建立哪个对象,都会先执行相同的构造代码块。也就是说,构造代码块中定义的是不同对象共性的初始化内容。\n参考 # https://blog.csdn.net/chen13579867831/article/details/78995480 https://www.cnblogs.com/chenssy/p/3388487.html https://www.cnblogs.com/Qian123/p/5713440.html "},{"id":431,"href":"/zh/docs/technology/Interview/java/new-features/java8-tutorial-translate/","title":"Index","section":"New Features","content":" 《Java8 指南》中文翻译 # 随着 Java 8 的普及度越来越高,很多人都提到面试中关于 Java 8 也是非常常问的知识点。应各位要求和需要,我打算对这部分知识做一个总结。本来准备自己总结的,后面看到 GitHub 上有一个相关的仓库,地址: https://github.com/winterbe/java8-tutorial。这个仓库是英文的,我对其进行了翻译并添加和修改了部分内容,下面是正文。\n欢迎阅读我对 Java 8 的介绍。本教程将逐步指导您完成所有新语言功能。 在简短的代码示例的基础上,您将学习如何使用默认接口方法,lambda 表达式,方法引用和可重复注释。 在本文的最后,您将熟悉最新的 API 更改,如流,函数式接口(Functional Interfaces),Map 类的扩展和新的 Date API。 没有大段枯燥的文字,只有一堆注释的代码片段。\n接口的默认方法(Default Methods for Interfaces) # Java 8 使我们能够通过使用 default 关键字向接口添加非抽象方法实现。 此功能也称为 虚拟扩展方法。\n第一个例子:\ninterface Formula{ double calculate(int a); default double sqrt(int a) { return Math.sqrt(a); } } Formula 接口中除了抽象方法计算接口公式还定义了默认方法 sqrt。 实现该接口的类只需要实现抽象方法 calculate。 默认方法sqrt 可以直接使用。当然你也可以直接通过接口创建对象,然后实现接口中的默认方法就可以了,我们通过代码演示一下这种方式。\npublic class Main { public static void main(String[] args) { // 通过匿名内部类方式访问接口 Formula formula = new Formula() { @Override public double calculate(int a) { return sqrt(a * 100); } }; System.out.println(formula.calculate(100)); // 100.0 System.out.println(formula.sqrt(16)); // 4.0 } } formula 是作为匿名对象实现的。该代码非常容易理解,6 行代码实现了计算 sqrt(a * 100)。在下一节中,我们将会看到在 Java 8 中实现单个方法对象有一种更好更方便的方法。\n译者注: 不管是抽象类还是接口,都可以通过匿名内部类的方式访问。不能通过抽象类或者接口直接创建对象。对于上面通过匿名内部类方式访问接口,我们可以这样理解:一个内部类实现了接口里的抽象方法并且返回一个内部类对象,之后我们让接口的引用来指向这个对象。\nLambda 表达式(Lambda expressions) # 首先看看在老版本的 Java 中是如何排列字符串的:\nList\u0026lt;String\u0026gt; names = Arrays.asList(\u0026#34;peter\u0026#34;, \u0026#34;anna\u0026#34;, \u0026#34;mike\u0026#34;, \u0026#34;xenia\u0026#34;); Collections.sort(names, new Comparator\u0026lt;String\u0026gt;() { @Override public int compare(String a, String b) { return b.compareTo(a); } }); 只需要给静态方法Collections.sort 传入一个 List 对象以及一个比较器来按指定顺序排列。通常做法都是创建一个匿名的比较器对象然后将其传递给 sort 方法。\n在 Java 8 中你就没必要使用这种传统的匿名对象的方式了,Java 8 提供了更简洁的语法,lambda 表达式:\nCollections.sort(names, (String a, String b) -\u0026gt; { return b.compareTo(a); }); 可以看出,代码变得更短且更具有可读性,但是实际上还可以写得更短:\nCollections.sort(names, (String a, String b) -\u0026gt; b.compareTo(a)); 对于函数体只有一行代码的,你可以去掉大括号{}以及 return 关键字,但是你还可以写得更短点:\nnames.sort((a, b) -\u0026gt; b.compareTo(a)); List 类本身就有一个 sort 方法。并且 Java 编译器可以自动推导出参数类型,所以你可以不用再写一次类型。接下来我们看看 lambda 表达式还有什么其他用法。\n函数式接口(Functional Interfaces) # 译者注: 原文对这部分解释不太清楚,故做了修改!\nJava 语言设计者们投入了大量精力来思考如何使现有的函数友好地支持 Lambda。最终采取的方法是:增加函数式接口的概念。“函数式接口”是指仅仅只包含一个抽象方法,但是可以有多个非抽象方法(也就是上面提到的默认方法)的接口。 像这样的接口,可以被隐式转换为 lambda 表达式。java.lang.Runnable 与 java.util.concurrent.Callable 是函数式接口最典型的两个例子。Java 8 增加了一种特殊的注解@FunctionalInterface,但是这个注解通常不是必须的(某些情况建议使用),只要接口只包含一个抽象方法,虚拟机会自动判断该接口为函数式接口。一般建议在接口上使用@FunctionalInterface 注解进行声明,这样的话,编译器如果发现你标注了这个注解的接口有多于一个抽象方法的时候会报错的,如下图所示\n示例:\n@FunctionalInterface public interface Converter\u0026lt;F, T\u0026gt; { T convert(F from); } // TODO 将数字字符串转换为整数类型 Converter\u0026lt;String, Integer\u0026gt; converter = (from) -\u0026gt; Integer.valueOf(from); Integer converted = converter.convert(\u0026#34;123\u0026#34;); System.out.println(converted.getClass()); //class java.lang.Integer 译者注: 大部分函数式接口都不用我们自己写,Java8 都给我们实现好了,这些接口都在 java.util.function 包里。\n方法和构造函数引用(Method and Constructor References) # 前一节中的代码还可以通过静态方法引用来表示:\nConverter\u0026lt;String, Integer\u0026gt; converter = Integer::valueOf; Integer converted = converter.convert(\u0026#34;123\u0026#34;); System.out.println(converted.getClass()); //class java.lang.Integer Java 8 允许您通过::关键字传递方法或构造函数的引用。 上面的示例显示了如何引用静态方法。 但我们也可以引用对象方法:\nclass Something { String startsWith(String s) { return String.valueOf(s.charAt(0)); } } Something something = new Something(); Converter\u0026lt;String, String\u0026gt; converter = something::startsWith; String converted = converter.convert(\u0026#34;Java\u0026#34;); System.out.println(converted); // \u0026#34;J\u0026#34; 接下来看看构造函数是如何使用::关键字来引用的,首先我们定义一个包含多个构造函数的简单类:\nclass Person { String firstName; String lastName; Person() {} Person(String firstName, String lastName) { this.firstName = firstName; this.lastName = lastName; } } 接下来我们指定一个用来创建 Person 对象的对象工厂接口:\ninterface PersonFactory\u0026lt;P extends Person\u0026gt; { P create(String firstName, String lastName); } 这里我们使用构造函数引用来将他们关联起来,而不是手动实现一个完整的工厂:\nPersonFactory\u0026lt;Person\u0026gt; personFactory = Person::new; Person person = personFactory.create(\u0026#34;Peter\u0026#34;, \u0026#34;Parker\u0026#34;); 我们只需要使用 Person::new 来获取 Person 类构造函数的引用,Java 编译器会自动根据PersonFactory.create方法的参数类型来选择合适的构造函数。\nLambda 表达式作用域(Lambda Scopes) # 访问局部变量 # 我们可以直接在 lambda 表达式中访问外部的局部变量:\nfinal int num = 1; Converter\u0026lt;Integer, String\u0026gt; stringConverter = (from) -\u0026gt; String.valueOf(from + num); stringConverter.convert(2); // 3 但是和匿名对象不同的是,这里的变量 num 可以不用声明为 final,该代码同样正确:\nint num = 1; Converter\u0026lt;Integer, String\u0026gt; stringConverter = (from) -\u0026gt; String.valueOf(from + num); stringConverter.convert(2); // 3 不过这里的 num 必须不可被后面的代码修改(即隐性的具有 final 的语义),例如下面的就无法编译:\nint num = 1; Converter\u0026lt;Integer, String\u0026gt; stringConverter = (from) -\u0026gt; String.valueOf(from + num); num = 3;//在lambda表达式中试图修改num同样是不允许的。 访问字段和静态变量 # 与局部变量相比,我们在 lambda 表达式中对实例字段和静态变量都有读写访问权限。 该行为和匿名对象是一致的。\nclass Lambda4 { static int outerStaticNum; int outerNum; void testScopes() { Converter\u0026lt;Integer, String\u0026gt; stringConverter1 = (from) -\u0026gt; { outerNum = 23; return String.valueOf(from); }; Converter\u0026lt;Integer, String\u0026gt; stringConverter2 = (from) -\u0026gt; { outerStaticNum = 72; return String.valueOf(from); }; } } 访问默认接口方法 # 还记得第一节中的 formula 示例吗? Formula 接口定义了一个默认方法sqrt,可以从包含匿名对象的每个 formula 实例访问该方法。 这不适用于 lambda 表达式。\n无法从 lambda 表达式中访问默认方法,故以下代码无法编译:\nFormula formula = (a) -\u0026gt; sqrt(a * 100); 内置函数式接口(Built-in Functional Interfaces) # JDK 1.8 API 包含许多内置函数式接口。 其中一些接口在老版本的 Java 中是比较常见的比如:Comparator 或Runnable,这些接口都增加了@FunctionalInterface注解以便能用在 lambda 表达式上。\n但是 Java 8 API 同样还提供了很多全新的函数式接口来让你的编程工作更加方便,有一些接口是来自 Google Guava 库里的,即便你对这些很熟悉了,还是有必要看看这些是如何扩展到 lambda 上使用的。\nPredicate # Predicate 接口是只有一个参数的返回布尔类型值的 断言型 接口。该接口包含多种默认方法来将 Predicate 组合成其他复杂的逻辑(比如:与,或,非):\n译者注: Predicate 接口源码如下\npackage java.util.function; import java.util.Objects; @FunctionalInterface public interface Predicate\u0026lt;T\u0026gt; { // 该方法是接受一个传入类型,返回一个布尔值.此方法应用于判断. boolean test(T t); //and方法与关系型运算符\u0026#34;\u0026amp;\u0026amp;\u0026#34;相似,两边都成立才返回true default Predicate\u0026lt;T\u0026gt; and(Predicate\u0026lt;? super T\u0026gt; other) { Objects.requireNonNull(other); return (t) -\u0026gt; test(t) \u0026amp;\u0026amp; other.test(t); } // 与关系运算符\u0026#34;!\u0026#34;相似,对判断进行取反 default Predicate\u0026lt;T\u0026gt; negate() { return (t) -\u0026gt; !test(t); } //or方法与关系型运算符\u0026#34;||\u0026#34;相似,两边只要有一个成立就返回true default Predicate\u0026lt;T\u0026gt; or(Predicate\u0026lt;? super T\u0026gt; other) { Objects.requireNonNull(other); return (t) -\u0026gt; test(t) || other.test(t); } // 该方法接收一个Object对象,返回一个Predicate类型.此方法用于判断第一个test的方法与第二个test方法相同(equal). static \u0026lt;T\u0026gt; Predicate\u0026lt;T\u0026gt; isEqual(Object targetRef) { return (null == targetRef) ? Objects::isNull : object -\u0026gt; targetRef.equals(object); } 示例:\nPredicate\u0026lt;String\u0026gt; predicate = (s) -\u0026gt; s.length() \u0026gt; 0; predicate.test(\u0026#34;foo\u0026#34;); // true predicate.negate().test(\u0026#34;foo\u0026#34;); // false Predicate\u0026lt;Boolean\u0026gt; nonNull = Objects::nonNull; Predicate\u0026lt;Boolean\u0026gt; isNull = Objects::isNull; Predicate\u0026lt;String\u0026gt; isEmpty = String::isEmpty; Predicate\u0026lt;String\u0026gt; isNotEmpty = isEmpty.negate(); Function # Function 接口接受一个参数并生成结果。默认方法可用于将多个函数链接在一起(compose, andThen):\n译者注: Function 接口源码如下\npackage java.util.function; import java.util.Objects; @FunctionalInterface public interface Function\u0026lt;T, R\u0026gt; { //将Function对象应用到输入的参数上,然后返回计算结果。 R apply(T t); //将两个Function整合,并返回一个能够执行两个Function对象功能的Function对象。 default \u0026lt;V\u0026gt; Function\u0026lt;V, R\u0026gt; compose(Function\u0026lt;? super V, ? extends T\u0026gt; before) { Objects.requireNonNull(before); return (V v) -\u0026gt; apply(before.apply(v)); } // default \u0026lt;V\u0026gt; Function\u0026lt;T, V\u0026gt; andThen(Function\u0026lt;? super R, ? extends V\u0026gt; after) { Objects.requireNonNull(after); return (T t) -\u0026gt; after.apply(apply(t)); } static \u0026lt;T\u0026gt; Function\u0026lt;T, T\u0026gt; identity() { return t -\u0026gt; t; } } Function\u0026lt;String, Integer\u0026gt; toInteger = Integer::valueOf; Function\u0026lt;String, String\u0026gt; backToString = toInteger.andThen(String::valueOf); backToString.apply(\u0026#34;123\u0026#34;); // \u0026#34;123\u0026#34; Supplier # Supplier 接口产生给定泛型类型的结果。 与 Function 接口不同,Supplier 接口不接受参数。\nSupplier\u0026lt;Person\u0026gt; personSupplier = Person::new; personSupplier.get(); // new Person Consumer # Consumer 接口表示要对单个输入参数执行的操作。\nConsumer\u0026lt;Person\u0026gt; greeter = (p) -\u0026gt; System.out.println(\u0026#34;Hello, \u0026#34; + p.firstName); greeter.accept(new Person(\u0026#34;Luke\u0026#34;, \u0026#34;Skywalker\u0026#34;)); Comparator # Comparator 是老 Java 中的经典接口, Java 8 在此之上添加了多种默认方法:\nComparator\u0026lt;Person\u0026gt; comparator = (p1, p2) -\u0026gt; p1.firstName.compareTo(p2.firstName); Person p1 = new Person(\u0026#34;John\u0026#34;, \u0026#34;Doe\u0026#34;); Person p2 = new Person(\u0026#34;Alice\u0026#34;, \u0026#34;Wonderland\u0026#34;); comparator.compare(p1, p2); // \u0026gt; 0 comparator.reversed().compare(p1, p2); // \u0026lt; 0 Optional # Optional 不是函数式接口,而是用于防止 NullPointerException 的漂亮工具。这是下一节的一个重要概念,让我们快速了解一下 Optional 的工作原理。\nOptional 是一个简单的容器,其值可能是 null 或者不是 null。在 Java 8 之前一般某个函数应该返回非空对象但是有时却什么也没有返回,而在 Java 8 中,你应该返回 Optional 而不是 null。\n译者注:示例中每个方法的作用已经添加。\n//of():为非null的值创建一个Optional Optional\u0026lt;String\u0026gt; optional = Optional.of(\u0026#34;bam\u0026#34;); // isPresent():如果值存在返回true,否则返回false optional.isPresent(); // true //get():如果Optional有值则将其返回,否则抛出NoSuchElementException optional.get(); // \u0026#34;bam\u0026#34; //orElse():如果有值则将其返回,否则返回指定的其它值 optional.orElse(\u0026#34;fallback\u0026#34;); // \u0026#34;bam\u0026#34; //ifPresent():如果Optional实例有值则为其调用consumer,否则不做处理 optional.ifPresent((s) -\u0026gt; System.out.println(s.charAt(0))); // \u0026#34;b\u0026#34; 推荐阅读: [Java8]如何正确使用 Optional\nStreams(流) # java.util.Stream 表示能应用在一组元素上一次执行的操作序列。Stream 操作分为中间操作或者最终操作两种,最终操作返回一特定类型的计算结果,而中间操作返回 Stream 本身,这样你就可以将多个操作依次串起来。Stream 的创建需要指定一个数据源,比如java.util.Collection 的子类,List 或者 Set, Map 不支持。Stream 的操作可以串行执行或者并行执行。\n首先看看 Stream 是怎么用,首先创建实例代码需要用到的数据 List:\nList\u0026lt;String\u0026gt; stringList = new ArrayList\u0026lt;\u0026gt;(); stringList.add(\u0026#34;ddd2\u0026#34;); stringList.add(\u0026#34;aaa2\u0026#34;); stringList.add(\u0026#34;bbb1\u0026#34;); stringList.add(\u0026#34;aaa1\u0026#34;); stringList.add(\u0026#34;bbb3\u0026#34;); stringList.add(\u0026#34;ccc\u0026#34;); stringList.add(\u0026#34;bbb2\u0026#34;); stringList.add(\u0026#34;ddd1\u0026#34;); Java 8 扩展了集合类,可以通过 Collection.stream() 或者 Collection.parallelStream() 来创建一个 Stream。下面几节将详细解释常用的 Stream 操作:\nFilter(过滤) # 过滤通过一个 predicate 接口来过滤并只保留符合条件的元素,该操作属于中间操作,所以我们可以在过滤后的结果来应用其他 Stream 操作(比如 forEach)。forEach 需要一个函数来对过滤后的元素依次执行。forEach 是一个最终操作,所以我们不能在 forEach 之后来执行其他 Stream 操作。\n// 测试 Filter(过滤) stringList .stream() .filter((s) -\u0026gt; s.startsWith(\u0026#34;a\u0026#34;)) .forEach(System.out::println);//aaa2 aaa1 forEach 是为 Lambda 而设计的,保持了最紧凑的风格。而且 Lambda 表达式本身是可以重用的,非常方便。\nSorted(排序) # 排序是一个 中间操作,返回的是排序好后的 Stream。如果你不指定一个自定义的 Comparator 则会使用默认排序。\n// 测试 Sort (排序) stringList .stream() .sorted() .filter((s) -\u0026gt; s.startsWith(\u0026#34;a\u0026#34;)) .forEach(System.out::println);// aaa1 aaa2 需要注意的是,排序只创建了一个排列好后的 Stream,而不会影响原有的数据源,排序之后原数据 stringList 是不会被修改的:\nSystem.out.println(stringList);// ddd2, aaa2, bbb1, aaa1, bbb3, ccc, bbb2, ddd1 Map(映射) # 中间操作 map 会将元素根据指定的 Function 接口来依次将元素转成另外的对象。\n下面的示例展示了将字符串转换为大写字符串。你也可以通过 map 来将对象转换成其他类型,map 返回的 Stream 类型是根据你 map 传递进去的函数的返回值决定的。\n// 测试 Map 操作 stringList .stream() .map(String::toUpperCase) .sorted((a, b) -\u0026gt; b.compareTo(a)) .forEach(System.out::println);// \u0026#34;DDD2\u0026#34;, \u0026#34;DDD1\u0026#34;, \u0026#34;CCC\u0026#34;, \u0026#34;BBB3\u0026#34;, \u0026#34;BBB2\u0026#34;, \u0026#34;BBB1\u0026#34;, \u0026#34;AAA2\u0026#34;, \u0026#34;AAA1\u0026#34; Match(匹配) # Stream 提供了多种匹配操作,允许检测指定的 Predicate 是否匹配整个 Stream。所有的匹配操作都是 最终操作 ,并返回一个 boolean 类型的值。\n// 测试 Match (匹配)操作 boolean anyStartsWithA = stringList .stream() .anyMatch((s) -\u0026gt; s.startsWith(\u0026#34;a\u0026#34;)); System.out.println(anyStartsWithA); // true boolean allStartsWithA = stringList .stream() .allMatch((s) -\u0026gt; s.startsWith(\u0026#34;a\u0026#34;)); System.out.println(allStartsWithA); // false boolean noneStartsWithZ = stringList .stream() .noneMatch((s) -\u0026gt; s.startsWith(\u0026#34;z\u0026#34;)); System.out.println(noneStartsWithZ); // true Count(计数) # 计数是一个 最终操作,返回 Stream 中元素的个数,返回值类型是 long。\n//测试 Count (计数)操作 long startsWithB = stringList .stream() .filter((s) -\u0026gt; s.startsWith(\u0026#34;b\u0026#34;)) .count(); System.out.println(startsWithB); // 3 Reduce(规约) # 这是一个 最终操作 ,允许通过指定的函数来将 stream 中的多个元素规约为一个元素,规约后的结果是通过 Optional 接口表示的:\n//测试 Reduce (规约)操作 Optional\u0026lt;String\u0026gt; reduced = stringList .stream() .sorted() .reduce((s1, s2) -\u0026gt; s1 + \u0026#34;#\u0026#34; + s2); reduced.ifPresent(System.out::println);//aaa1#aaa2#bbb1#bbb2#bbb3#ccc#ddd1#ddd2 译者注: 这个方法的主要作用是把 Stream 元素组合起来。它提供一个起始值(种子),然后依照运算规则(BinaryOperator),和前面 Stream 的第一个、第二个、第 n 个元素组合。从这个意义上说,字符串拼接、数值的 sum、min、max、average 都是特殊的 reduce。例如 Stream 的 sum 就相当于Integer sum = integers.reduce(0, (a, b) -\u0026gt; a+b);也有没有起始值的情况,这时会把 Stream 的前面两个元素组合起来,返回的是 Optional。\n// 字符串连接,concat = \u0026#34;ABCD\u0026#34; String concat = Stream.of(\u0026#34;A\u0026#34;, \u0026#34;B\u0026#34;, \u0026#34;C\u0026#34;, \u0026#34;D\u0026#34;).reduce(\u0026#34;\u0026#34;, String::concat); // 求最小值,minValue = -3.0 double minValue = Stream.of(-1.5, 1.0, -3.0, -2.0).reduce(Double.MAX_VALUE, Double::min); // 求和,sumValue = 10, 有起始值 int sumValue = Stream.of(1, 2, 3, 4).reduce(0, Integer::sum); // 求和,sumValue = 10, 无起始值 sumValue = Stream.of(1, 2, 3, 4).reduce(Integer::sum).get(); // 过滤,字符串连接,concat = \u0026#34;ace\u0026#34; concat = Stream.of(\u0026#34;a\u0026#34;, \u0026#34;B\u0026#34;, \u0026#34;c\u0026#34;, \u0026#34;D\u0026#34;, \u0026#34;e\u0026#34;, \u0026#34;F\u0026#34;). filter(x -\u0026gt; x.compareTo(\u0026#34;Z\u0026#34;) \u0026gt; 0). reduce(\u0026#34;\u0026#34;, String::concat); 上面代码例如第一个示例的 reduce(),第一个参数(空白字符)即为起始值,第二个参数(String::concat)为 BinaryOperator。这类有起始值的 reduce() 都返回具体的对象。而对于第四个示例没有起始值的 reduce(),由于可能没有足够的元素,返回的是 Optional,请留意这个区别。更多内容查看: IBM:Java 8 中的 Streams API 详解\nParallel Streams(并行流) # 前面提到过 Stream 有串行和并行两种,串行 Stream 上的操作是在一个线程中依次完成,而并行 Stream 则是在多个线程上同时执行。\n下面的例子展示了是如何通过并行 Stream 来提升性能:\n首先我们创建一个没有重复元素的大表:\nint max = 1000000; List\u0026lt;String\u0026gt; values = new ArrayList\u0026lt;\u0026gt;(max); for (int i = 0; i \u0026lt; max; i++) { UUID uuid = UUID.randomUUID(); values.add(uuid.toString()); } 我们分别用串行和并行两种方式对其进行排序,最后看看所用时间的对比。\nSequential Sort(串行排序) # //串行排序 long t0 = System.nanoTime(); long count = values.stream().sorted().count(); System.out.println(count); long t1 = System.nanoTime(); long millis = TimeUnit.NANOSECONDS.toMillis(t1 - t0); System.out.println(String.format(\u0026#34;sequential sort took: %d ms\u0026#34;, millis)); 1000000 sequential sort took: 709 ms//串行排序所用的时间 Parallel Sort(并行排序) # //并行排序 long t0 = System.nanoTime(); long count = values.parallelStream().sorted().count(); System.out.println(count); long t1 = System.nanoTime(); long millis = TimeUnit.NANOSECONDS.toMillis(t1 - t0); System.out.println(String.format(\u0026#34;parallel sort took: %d ms\u0026#34;, millis)); 1000000 parallel sort took: 475 ms//并行排序所用的时间 上面两个代码几乎是一样的,但是并行版的快了 50% 左右,唯一需要做的改动就是将 stream() 改为parallelStream()。\nMaps # 前面提到过,Map 类型不支持 streams,不过 Map 提供了一些新的有用的方法来处理一些日常任务。Map 接口本身没有可用的 stream()方法,但是你可以在键,值上创建专门的流或者通过 map.keySet().stream(),map.values().stream()和map.entrySet().stream()。\n此外,Maps 支持各种新的和有用的方法来执行常见任务。\nMap\u0026lt;Integer, String\u0026gt; map = new HashMap\u0026lt;\u0026gt;(); for (int i = 0; i \u0026lt; 10; i++) { map.putIfAbsent(i, \u0026#34;val\u0026#34; + i); } map.forEach((id, val) -\u0026gt; System.out.println(val));//val0 val1 val2 val3 val4 val5 val6 val7 val8 val9 putIfAbsent 阻止我们在 null 检查时写入额外的代码;forEach接受一个 consumer 来对 map 中的每个元素操作。\n此示例显示如何使用函数在 map 上计算代码:\nmap.computeIfPresent(3, (num, val) -\u0026gt; val + num); map.get(3); // val33 map.computeIfPresent(9, (num, val) -\u0026gt; null); map.containsKey(9); // false map.computeIfAbsent(23, num -\u0026gt; \u0026#34;val\u0026#34; + num); map.containsKey(23); // true map.computeIfAbsent(3, num -\u0026gt; \u0026#34;bam\u0026#34;); map.get(3); // val33 接下来展示如何在 Map 里删除一个键值全都匹配的项:\nmap.remove(3, \u0026#34;val3\u0026#34;); map.get(3); // val33 map.remove(3, \u0026#34;val33\u0026#34;); map.get(3); // null 另外一个有用的方法:\nmap.getOrDefault(42, \u0026#34;not found\u0026#34;); // not found 对 Map 的元素做合并也变得很容易了:\nmap.merge(9, \u0026#34;val9\u0026#34;, (value, newValue) -\u0026gt; value.concat(newValue)); map.get(9); // val9 map.merge(9, \u0026#34;concat\u0026#34;, (value, newValue) -\u0026gt; value.concat(newValue)); map.get(9); // val9concat Merge 做的事情是如果键名不存在则插入,否则对原键对应的值做合并操作并重新插入到 map 中。\nDate API(日期相关 API) # Java 8 在 java.time 包下包含一个全新的日期和时间 API。新的 Date API 与 Joda-Time 库相似,但它们不一样。以下示例涵盖了此新 API 的最重要部分。译者对这部分内容参考相关书籍做了大部分修改。\n译者注(总结):\nClock 类提供了访问当前日期和时间的方法,Clock 是时区敏感的,可以用来取代 System.currentTimeMillis() 来获取当前的微秒数。某一个特定的时间点也可以使用 Instant 类来表示,Instant 类也可以用来创建旧版本的java.util.Date 对象。\n在新 API 中时区使用 ZoneId 来表示。时区可以很方便的使用静态方法 of 来获取到。 抽象类ZoneId(在java.time包中)表示一个区域标识符。 它有一个名为getAvailableZoneIds的静态方法,它返回所有区域标识符。\njdk1.8 中新增了 LocalDate 与 LocalDateTime 等类来解决日期处理方法,同时引入了一个新的类 DateTimeFormatter 来解决日期格式化问题。可以使用 Instant 代替 Date,LocalDateTime 代替 Calendar,DateTimeFormatter 代替 SimpleDateFormat。\nClock # Clock 类提供了访问当前日期和时间的方法,Clock 是时区敏感的,可以用来取代 System.currentTimeMillis() 来获取当前的微秒数。某一个特定的时间点也可以使用 Instant 类来表示,Instant 类也可以用来创建旧版本的java.util.Date 对象。\nClock clock = Clock.systemDefaultZone(); long millis = clock.millis(); System.out.println(millis);//1552379579043 Instant instant = clock.instant(); System.out.println(instant); Date legacyDate = Date.from(instant); //2019-03-12T08:46:42.588Z System.out.println(legacyDate);//Tue Mar 12 16:32:59 CST 2019 Timezones(时区) # 在新 API 中时区使用 ZoneId 来表示。时区可以很方便的使用静态方法 of 来获取到。 抽象类ZoneId(在java.time包中)表示一个区域标识符。 它有一个名为getAvailableZoneIds的静态方法,它返回所有区域标识符。\n//输出所有区域标识符 System.out.println(ZoneId.getAvailableZoneIds()); ZoneId zone1 = ZoneId.of(\u0026#34;Europe/Berlin\u0026#34;); ZoneId zone2 = ZoneId.of(\u0026#34;Brazil/East\u0026#34;); System.out.println(zone1.getRules());// ZoneRules[currentStandardOffset=+01:00] System.out.println(zone2.getRules());// ZoneRules[currentStandardOffset=-03:00] LocalTime(本地时间) # LocalTime 定义了一个没有时区信息的时间,例如 晚上 10 点或者 17:30:15。下面的例子使用前面代码创建的时区创建了两个本地时间。之后比较时间并以小时和分钟为单位计算两个时间的时间差:\nLocalTime now1 = LocalTime.now(zone1); LocalTime now2 = LocalTime.now(zone2); System.out.println(now1.isBefore(now2)); // false long hoursBetween = ChronoUnit.HOURS.between(now1, now2); long minutesBetween = ChronoUnit.MINUTES.between(now1, now2); System.out.println(hoursBetween); // -3 System.out.println(minutesBetween); // -239 LocalTime 提供了多种工厂方法来简化对象的创建,包括解析时间字符串.\nLocalTime late = LocalTime.of(23, 59, 59); System.out.println(late); // 23:59:59 DateTimeFormatter germanFormatter = DateTimeFormatter .ofLocalizedTime(FormatStyle.SHORT) .withLocale(Locale.GERMAN); LocalTime leetTime = LocalTime.parse(\u0026#34;13:37\u0026#34;, germanFormatter); System.out.println(leetTime); // 13:37 LocalDate(本地日期) # LocalDate 表示了一个确切的日期,比如 2014-03-11。该对象值是不可变的,用起来和 LocalTime 基本一致。下面的例子展示了如何给 Date 对象加减天/月/年。另外要注意的是这些对象是不可变的,操作返回的总是一个新实例。\nLocalDate today = LocalDate.now();//获取现在的日期 System.out.println(\u0026#34;今天的日期: \u0026#34;+today);//2019-03-12 LocalDate tomorrow = today.plus(1, ChronoUnit.DAYS); System.out.println(\u0026#34;明天的日期: \u0026#34;+tomorrow);//2019-03-13 LocalDate yesterday = tomorrow.minusDays(2); System.out.println(\u0026#34;昨天的日期: \u0026#34;+yesterday);//2019-03-11 LocalDate independenceDay = LocalDate.of(2019, Month.MARCH, 12); DayOfWeek dayOfWeek = independenceDay.getDayOfWeek(); System.out.println(\u0026#34;今天是周几:\u0026#34;+dayOfWeek);//TUESDAY 从字符串解析一个 LocalDate 类型和解析 LocalTime 一样简单,下面是使用 DateTimeFormatter 解析字符串的例子:\nString str1 = \u0026#34;2014==04==12 01时06分09秒\u0026#34;; // 根据需要解析的日期、时间字符串定义解析所用的格式器 DateTimeFormatter fomatter1 = DateTimeFormatter .ofPattern(\u0026#34;yyyy==MM==dd HH时mm分ss秒\u0026#34;); LocalDateTime dt1 = LocalDateTime.parse(str1, fomatter1); System.out.println(dt1); // 输出 2014-04-12T01:06:09 String str2 = \u0026#34;2014$$$四月$$$13 20小时\u0026#34;; DateTimeFormatter fomatter2 = DateTimeFormatter .ofPattern(\u0026#34;yyy$$$MMM$$$dd HH小时\u0026#34;); LocalDateTime dt2 = LocalDateTime.parse(str2, fomatter2); System.out.println(dt2); // 输出 2014-04-13T20:00 再来看一个使用 DateTimeFormatter 格式化日期的示例\nLocalDateTime rightNow=LocalDateTime.now(); String date=DateTimeFormatter.ISO_LOCAL_DATE_TIME.format(rightNow); System.out.println(date);//2019-03-12T16:26:48.29 DateTimeFormatter formatter=DateTimeFormatter.ofPattern(\u0026#34;YYYY-MM-dd HH:mm:ss\u0026#34;); System.out.println(formatter.format(rightNow));//2019-03-12 16:26:48 🐛 修正(参见: issue#1157):使用 YYYY 显示年份时,会显示当前时间所在周的年份,在跨年周会有问题。一般情况下都使用 yyyy,来显示准确的年份。\n跨年导致日期显示错误示例:\nLocalDateTime rightNow = LocalDateTime.of(2020, 12, 31, 12, 0, 0); String date= DateTimeFormatter.ISO_LOCAL_DATE_TIME.format(rightNow); // 2020-12-31T12:00:00 System.out.println(date); DateTimeFormatter formatterOfYYYY = DateTimeFormatter.ofPattern(\u0026#34;YYYY-MM-dd HH:mm:ss\u0026#34;); // 2021-12-31 12:00:00 System.out.println(formatterOfYYYY.format(rightNow)); DateTimeFormatter formatterOfYyyy = DateTimeFormatter.ofPattern(\u0026#34;yyyy-MM-dd HH:mm:ss\u0026#34;); // 2020-12-31 12:00:00 System.out.println(formatterOfYyyy.format(rightNow)); 从下图可以更清晰的看到具体的错误,并且 IDEA 已经智能地提示更倾向于使用 yyyy 而不是 YYYY 。\nLocalDateTime(本地日期时间) # LocalDateTime 同时表示了时间和日期,相当于前两节内容合并到一个对象上了。LocalDateTime 和 LocalTime 还有 LocalDate 一样,都是不可变的。LocalDateTime 提供了一些能访问具体字段的方法。\nLocalDateTime sylvester = LocalDateTime.of(2014, Month.DECEMBER, 31, 23, 59, 59); DayOfWeek dayOfWeek = sylvester.getDayOfWeek(); System.out.println(dayOfWeek); // WEDNESDAY Month month = sylvester.getMonth(); System.out.println(month); // DECEMBER long minuteOfDay = sylvester.getLong(ChronoField.MINUTE_OF_DAY); System.out.println(minuteOfDay); // 1439 只要附加上时区信息,就可以将其转换为一个时间点 Instant 对象,Instant 时间点对象可以很容易的转换为老式的java.util.Date。\nInstant instant = sylvester .atZone(ZoneId.systemDefault()) .toInstant(); Date legacyDate = Date.from(instant); System.out.println(legacyDate); // Wed Dec 31 23:59:59 CET 2014 格式化 LocalDateTime 和格式化时间和日期一样的,除了使用预定义好的格式外,我们也可以自己定义格式:\nDateTimeFormatter formatter = DateTimeFormatter .ofPattern(\u0026#34;MMM dd, yyyy - HH:mm\u0026#34;); LocalDateTime parsed = LocalDateTime.parse(\u0026#34;Nov 03, 2014 - 07:13\u0026#34;, formatter); String string = formatter.format(parsed); System.out.println(string); // Nov 03, 2014 - 07:13 和 java.text.NumberFormat 不一样的是新版的 DateTimeFormatter 是不可变的,所以它是线程安全的。 关于时间日期格式的详细信息在 这里。\nAnnotations(注解) # 在 Java 8 中支持多重注解了,先看个例子来理解一下是什么意思。 首先定义一个包装类 Hints 注解用来放置一组具体的 Hint 注解:\n@Retention(RetentionPolicy.RUNTIME) @interface Hints { Hint[] value(); } @Repeatable(Hints.class) @interface Hint { String value(); } Java 8 允许我们把同一个类型的注解使用多次,只需要给该注解标注一下@Repeatable即可。\n例 1: 使用包装类当容器来存多个注解(老方法)\n@Hints({@Hint(\u0026#34;hint1\u0026#34;), @Hint(\u0026#34;hint2\u0026#34;)}) class Person {} 例 2:使用多重注解(新方法)\n@Hint(\u0026#34;hint1\u0026#34;) @Hint(\u0026#34;hint2\u0026#34;) class Person {} 第二个例子里 java 编译器会隐性的帮你定义好@Hints 注解,了解这一点有助于你用反射来获取这些信息:\nHint hint = Person.class.getAnnotation(Hint.class); System.out.println(hint); // null Hints hints1 = Person.class.getAnnotation(Hints.class); System.out.println(hints1.value().length); // 2 Hint[] hints2 = Person.class.getAnnotationsByType(Hint.class); System.out.println(hints2.length); // 2 即便我们没有在 Person类上定义 @Hints注解,我们还是可以通过 getAnnotation(Hints.class)来获取 @Hints注解,更加方便的方法是使用 getAnnotationsByType 可以直接获取到所有的@Hint注解。 另外 Java 8 的注解还增加到两种新的 target 上了:\n@Target({ElementType.TYPE_PARAMETER, ElementType.TYPE_USE}) @interface MyAnnotation {} Where to go from here? # 关于 Java 8 的新特性就写到这了,肯定还有更多的特性等待发掘。JDK 1.8 里还有很多很有用的东西,比如Arrays.parallelSort, StampedLock和CompletableFuture等等。\n"},{"id":432,"href":"/zh/docs/technology/Interview/system-design/J2EE%E5%9F%BA%E7%A1%80%E7%9F%A5%E8%AF%86/","title":"Index","section":"System Design","content":" Servlet 总结 # 在 Java Web 程序中,Servlet主要负责接收用户请求 HttpServletRequest,在doGet(),doPost()中做相应的处理,并将回应HttpServletResponse反馈给用户。Servlet 可以设置初始化参数,供 Servlet 内部使用。一个 Servlet 类只会有一个实例,在它初始化时调用init()方法,销毁时调用destroy()方法==。==Servlet 需要在 web.xml 中配置(MyEclipse 中创建 Servlet 会自动配置),一个 Servlet 可以设置多个 URL 访问。Servlet 不是线程安全,因此要谨慎使用类变量。\n阐述 Servlet 和 CGI 的区别? # CGI 的不足之处 # 1,需要为每个请求启动一个操作 CGI 程序的系统进程。如果请求频繁,这将会带来很大的开销。\n2,需要为每个请求加载和运行一个 CGI 程序,这将带来很大的开销\n3,需要重复编写处理网络协议的代码以及编码,这些工作都是非常耗时的。\nServlet 的优点 # 1,只需要启动一个操作系统进程以及加载一个 JVM,大大降低了系统的开销\n2,如果多个请求需要做同样处理的时候,这时候只需要加载一个类,这也大大降低了开销\n3,所有动态加载的类可以实现对网络协议以及请求解码的共享,大大降低了工作量。\n4,Servlet 能直接和 Web 服务器交互,而普通的 CGI 程序不能。Servlet 还能在各个程序之间共享数据,使数据库连接池之类的功能很容易实现。\n补充:Sun Microsystems 公司在 1996 年发布 Servlet 技术就是为了和 CGI 进行竞争,Servlet 是一个特殊的 Java 程序,一个基于 Java 的 Web 应用通常包含一个或多个 Servlet 类。Servlet 不能够自行创建并执行,它是在 Servlet 容器中运行的,容器将用户的请求传递给 Servlet 程序,并将 Servlet 的响应回传给用户。通常一个 Servlet 会关联一个或多个 JSP 页面。以前 CGI 经常因为性能开销上的问题被诟病,然而 Fast CGI 早就已经解决了 CGI 效率上的问题,所以面试的时候大可不必信口开河的诟病 CGI,事实上有很多你熟悉的网站都使用了 CGI 技术。\n参考:《javaweb 整合开发王者归来》P7\nServlet 接口中有哪些方法及 Servlet 生命周期探秘 # Servlet 接口定义了 5 个方法,其中前三个方法与 Servlet 生命周期相关:\nvoid init(ServletConfig config) throws ServletException void service(ServletRequest req, ServletResponse resp) throws ServletException, java.io.IOException void destroy() java.lang.String getServletInfo() ServletConfig getServletConfig() 生命周期: Web 容器加载 Servlet 并将其实例化后,Servlet 生命周期开始,容器运行其init()方法进行 Servlet 的初始化;请求到达时调用 Servlet 的service()方法,service()方法会根据需要调用与请求对应的doGet 或 doPost等方法;当服务器关闭或项目被卸载时服务器会将 Servlet 实例销毁,此时会调用 Servlet 的destroy()方法。init 方法和 destroy 方法只会执行一次,service 方法客户端每次请求 Servlet 都会执行。Servlet 中有时会用到一些需要初始化与销毁的资源,因此可以把初始化资源的代码放入 init 方法中,销毁资源的代码放入 destroy 方法中,这样就不需要每次处理客户端的请求都要初始化与销毁资源。\n参考:《javaweb 整合开发王者归来》P81\nGET 和 POST 的区别 # 这个问题在知乎上被讨论的挺火热的,地址: https://www.zhihu.com/question/28586791 。\nGET 和 POST 是 HTTP 协议中两种常用的请求方法,它们在不同的场景和目的下有不同的特点和用法。一般来说,可以从以下几个方面来区分它们:\n语义上的区别:GET 通常用于获取或查询资源,而 POST 通常用于创建或修改资源。GET 请求应该是幂等的,即多次重复执行不会改变资源的状态,而 POST 请求则可能有副作用,即每次执行可能会产生不同的结果或影响资源的状态。 格式上的区别:GET 请求的参数通常放在 URL 中,形成查询字符串(querystring),而 POST 请求的参数通常放在请求体(body)中,可以有多种编码格式,如 application/x-www-form-urlencoded、multipart/form-data、application/json 等。GET 请求的 URL 长度受到浏览器和服务器的限制,而 POST 请求的 body 大小则没有明确的限制。 缓存上的区别:由于 GET 请求是幂等的,它可以被浏览器或其他中间节点(如代理、网关)缓存起来,以提高性能和效率。而 POST 请求则不适合被缓存,因为它可能有副作用,每次执行可能需要实时的响应。 安全性上的区别:GET 请求和 POST 请求都不是绝对安全的,因为 HTTP 协议本身是明文传输的,无论是 URL、header 还是 body 都可能被窃取或篡改。为了保证安全性,必须使用 HTTPS 协议来加密传输数据。不过,在一些场景下,GET 请求相比 POST 请求更容易泄露敏感数据,因为 GET 请求的参数会出现在 URL 中,而 URL 可能会被记录在浏览器历史、服务器日志、代理日志等地方。因此,一般情况下,私密数据传输应该使用 POST + body。 重点搞清了,两者在语义上的区别即可。不过,也有一些项目所有的请求都用 POST,这个并不是固定的,项目组达成共识即可。\n什么情况下调用 doGet()和 doPost() # Form 标签里的 method 的属性为 get 时调用 doGet(),为 post 时调用 doPost()。\n转发(Forward)和重定向(Redirect)的区别 # 转发是服务器行为,重定向是客户端行为。\n转发(Forward) 通过 RequestDispatcher 对象的 forward(HttpServletRequest request,HttpServletResponse response)方法实现的。RequestDispatcher 可以通过 HttpServletRequest 的 getRequestDispatcher()方法获得。例如下面的代码就是跳转到 login_success.jsp 页面。\nrequest.getRequestDispatcher(\u0026#34;login_success.jsp\u0026#34;).forward(request, response); 重定向(Redirect) 是利用服务器返回的状态码来实现的。客户端浏览器请求服务器的时候,服务器会返回一个状态码。服务器通过 HttpServletResponse 的 setStatus(int status) 方法设置状态码。如果服务器返回 301 或者 302,则浏览器会到新的网址重新请求该资源。\n从地址栏显示来说\nforward 是服务器请求资源,服务器直接访问目标地址的 URL,把那个 URL 的响应内容读取过来,然后把这些内容再发给浏览器.浏览器根本不知道服务器发送的内容从哪里来的,所以它的地址栏还是原来的地址. redirect 是服务端根据逻辑,发送一个状态码,告诉浏览器重新去请求那个地址.所以地址栏显示的是新的 URL.\n从数据共享来说\nforward:转发页面和转发到的页面可以共享 request 里面的数据. redirect:不能共享数据.\n从运用地方来说\nforward:一般用于用户登陆的时候,根据角色转发到相应的模块. redirect:一般用于用户注销登陆时返回主页面和跳转到其它的网站等\n从效率来说\nforward:高. redirect:低.\n自动刷新(Refresh) # 自动刷新不仅可以实现一段时间之后自动跳转到另一个页面,还可以实现一段时间之后自动刷新本页面。Servlet 中通过 HttpServletResponse 对象设置 Header 属性实现自动刷新例如:\nResponse.setHeader(\u0026#34;Refresh\u0026#34;,\u0026#34;5;URL=http://localhost:8080/servlet/example.htm\u0026#34;); 其中 5 为时间,单位为秒。URL 指定就是要跳转的页面(如果设置自己的路径,就会实现每过 5 秒自动刷新本页面一次)\nServlet 与线程安全 # Servlet 不是线程安全的,多线程并发的读写会导致数据不同步的问题。 解决的办法是尽量不要定义 name 属性,而是要把 name 变量分别定义在 doGet()和 doPost()方法内。虽然使用 synchronized(name){}语句块可以解决问题,但是会造成线程的等待,不是很科学的办法。 注意:多线程的并发的读写 Servlet 类属性会导致数据不同步。但是如果只是并发地读取属性而不写入,则不存在数据不同步的问题。因此 Servlet 里的只读属性最好定义为 final 类型的。\n参考:《javaweb 整合开发王者归来》P92\nJSP 和 Servlet 是什么关系 # 其实这个问题在上面已经阐述过了,Servlet 是一个特殊的 Java 程序,它运行于服务器的 JVM 中,能够依靠服务器的支持向浏览器提供显示内容。JSP 本质上是 Servlet 的一种简易形式,JSP 会被服务器处理成一个类似于 Servlet 的 Java 程序,可以简化页面内容的生成。Servlet 和 JSP 最主要的不同点在于,Servlet 的应用逻辑是在 Java 文件中,并且完全从表示层中的 HTML 分离开来。而 JSP 的情况是 Java 和 HTML 可以组合成一个扩展名为.jsp 的文件。有人说,Servlet 就是在 Java 中写 HTML,而 JSP 就是在 HTML 中写 Java 代码,当然这个说法是很片面且不够准确的。JSP 侧重于视图,Servlet 更侧重于控制逻辑,在 MVC 架构模式中,JSP 适合充当视图(view)而 Servlet 适合充当控制器(controller)。\nJSP 工作原理 # JSP 是一种 Servlet,但是与 HttpServlet 的工作方式不太一样。HttpServlet 是先由源代码编译为 class 文件后部署到服务器下,为先编译后部署。而 JSP 则是先部署后编译。JSP 会在客户端第一次请求 JSP 文件时被编译为 HttpJspPage 类(接口 Servlet 的一个子类)。该类会被服务器临时存放在服务器工作目录里面。下面通过实例给大家介绍。 工程 JspLoginDemo 下有一个名为 login.jsp 的 Jsp 文件,把工程第一次部署到服务器上后访问这个 Jsp 文件,我们发现这个目录下多了下图这两个东东。 .class 文件便是 JSP 对应的 Servlet。编译完毕后再运行 class 文件来响应客户端请求。以后客户端访问 login.jsp 的时候,Tomcat 将不再重新编译 JSP 文件,而是直接调用 class 文件来响应客户端请求。\n由于 JSP 只会在客户端第一次请求的时候被编译 ,因此第一次请求 JSP 时会感觉比较慢,之后就会感觉快很多。如果把服务器保存的 class 文件删除,服务器也会重新编译 JSP。\n开发 Web 程序时经常需要修改 JSP。Tomcat 能够自动检测到 JSP 程序的改动。如果检测到 JSP 源代码发生了改动。Tomcat 会在下次客户端请求 JSP 时重新编译 JSP,而不需要重启 Tomcat。这种自动检测功能是默认开启的,检测改动会消耗少量的时间,在部署 Web 应用的时候可以在 web.xml 中将它关掉。\n参考:《javaweb 整合开发王者归来》P97\nJSP 有哪些内置对象、作用分别是什么 # JSP 内置对象 - CSDN 博客\nJSP 有 9 个内置对象:\nrequest:封装客户端的请求,其中包含来自 GET 或 POST 请求的参数; response:封装服务器对客户端的响应; pageContext:通过该对象可以获取其他对象; session:封装用户会话的对象; application:封装服务器运行环境的对象; out:输出服务器响应的输出流对象; config:Web 应用的配置对象; page:JSP 页面本身(相当于 Java 程序中的 this); exception:封装页面抛出异常的对象。 Request 对象的主要方法有哪些 # setAttribute(String name,Object):设置名字为 name 的 request 的参数值 getAttribute(String name):返回由 name 指定的属性值 getAttributeNames():返回 request 对象所有属性的名字集合,结果是一个枚举的实例 getCookies():返回客户端的所有 Cookie 对象,结果是一个 Cookie 数组 getCharacterEncoding():返回请求中的字符编码方式 = getContentLength()`:返回请求的 Body 的长度 getHeader(String name):获得 HTTP 协议定义的文件头信息 getHeaders(String name):返回指定名字的 request Header 的所有值,结果是一个枚举的实例 getHeaderNames():返回所以 request Header 的名字,结果是一个枚举的实例 getInputStream():返回请求的输入流,用于获得请求中的数据 getMethod():获得客户端向服务器端传送数据的方法 getParameter(String name):获得客户端传送给服务器端的有 name 指定的参数值 getParameterNames():获得客户端传送给服务器端的所有参数的名字,结果是一个枚举的实例 getParameterValues(String name):获得有 name 指定的参数的所有值 getProtocol():获取客户端向服务器端传送数据所依据的协议名称 getQueryString():获得查询字符串 getRequestURI():获取发出请求字符串的客户端地址 getRemoteAddr():获取客户端的 IP 地址 getRemoteHost():获取客户端的名字 getSession([Boolean create]):返回和请求相关 Session getServerName():获取服务器的名字 getServletPath():获取客户端所请求的脚本文件的路径 getServerPort():获取服务器的端口号 removeAttribute(String name):删除请求中的一个属性 request.getAttribute()和 request.getParameter()有何区别 # 从获取方向来看:\ngetParameter()是获取 POST/GET 传递的参数值;\ngetAttribute()是获取对象容器中的数据值;\n从用途来看:\ngetParameter()用于客户端重定向时,即点击了链接或提交按扭时传值用,即用于在用表单或 url 重定向传值时接收数据用。\ngetAttribute() 用于服务器端重定向时,即在 sevlet 中使用了 forward 函数,或 struts 中使用了 mapping.findForward。 getAttribute 只能收到程序用 setAttribute 传过来的值。\n另外,可以用 setAttribute(),getAttribute() 发送接收对象.而 getParameter() 显然只能传字符串。 setAttribute() 是应用服务器把这个对象放在该页面所对应的一块内存中去,当你的页面服务器重定向到另一个页面时,应用服务器会把这块内存拷贝另一个页面所对应的内存中。这样getAttribute()就能取得你所设下的值,当然这种方法可以传对象。session 也一样,只是对象在内存中的生命周期不一样而已。getParameter()只是应用服务器在分析你送上来的 request 页面的文本时,取得你设在表单或 url 重定向时的值。\n总结:\ngetParameter()返回的是 String,用于读取提交的表单中的值;(获取之后会根据实际需要转换为自己需要的相应类型,比如整型,日期类型啊等等)\ngetAttribute()返回的是 Object,需进行转换,可用setAttribute()设置成任意对象,使用很灵活,可随时用\ninclude 指令 include 的行为的区别 # include 指令: JSP 可以通过 include 指令来包含其他文件。被包含的文件可以是 JSP 文件、HTML 文件或文本文件。包含的文件就好像是该 JSP 文件的一部分,会被同时编译执行。 语法格式如下: \u0026lt;%@ include file=\u0026ldquo;文件相对 url 地址\u0026rdquo; %\u0026gt;\ninclude 动作: \u0026lt;jsp:include\u0026gt;动作元素用来包含静态和动态的文件。该动作把指定文件插入正在生成的页面。语法格式如下: \u0026lt;jsp:include page=\u0026ldquo;相对 URL 地址\u0026rdquo; flush=\u0026ldquo;true\u0026rdquo; /\u0026gt;\nJSP 九大内置对象,七大动作,三大指令 # JSP 九大内置对象,七大动作,三大指令总结\n讲解 JSP 中的四种作用域 # JSP 中的四种作用域包括 page、request、session 和 application,具体来说:\npage代表与一个页面相关的对象和属性。 request代表与 Web 客户机发出的一个请求相关的对象和属性。一个请求可能跨越多个页面,涉及多个 Web 组件;需要在页面显示的临时数据可以置于此作用域。 session代表与某个用户与服务器建立的一次会话相关的对象和属性。跟某个用户相关的数据应该放在用户自己的 session 中。 application代表与整个 Web 应用程序相关的对象和属性,它实质上是跨越整个 Web 应用程序,包括多个页面、请求和会话的一个全局作用域。 如何实现 JSP 或 Servlet 的单线程模式 # 对于 JSP 页面,可以通过 page 指令进行设置。 \u0026lt;%@page isThreadSafe=\u0026quot;false\u0026quot;%\u0026gt;\n对于 Servlet,可以让自定义的 Servlet 实现 SingleThreadModel 标识接口。\n说明:如果将 JSP 或 Servlet 设置成单线程工作模式,会导致每个请求创建一个 Servlet 实例,这种实践将导致严重的性能问题(服务器的内存压力很大,还会导致频繁的垃圾回收),所以通常情况下并不会这么做。\n实现会话跟踪的技术有哪些 # 使用 Cookie\n向客户端发送 Cookie\nCookie c =new Cookie(\u0026#34;name\u0026#34;,\u0026#34;value\u0026#34;); //创建Cookie c.setMaxAge(60*60*24); //设置最大时效,此处设置的最大时效为一天 response.addCookie(c); //把Cookie放入到HTTP响应中 从客户端读取 Cookie\nString name =\u0026#34;name\u0026#34;; Cookie[]cookies =request.getCookies(); if(cookies !=null){ for(int i= 0;i\u0026lt;cookies.length;i++){ Cookie cookie =cookies[i]; if(name.equals(cookis.getName())) //something is here. //you can get the value cookie.getValue(); } } 优点: 数据可以持久保存,不需要服务器资源,简单,基于文本的 Key-Value\n缺点: 大小受到限制,用户可以禁用 Cookie 功能,由于保存在本地,有一定的安全风险。\nURL 重写\n在 URL 中添加用户会话的信息作为请求的参数,或者将唯一的会话 ID 添加到 URL 结尾以标识一个会话。\n优点: 在 Cookie 被禁用的时候依然可以使用\n缺点: 必须对网站的 URL 进行编码,所有页面必须动态生成,不能用预先记录下来的 URL 进行访问。\n隐藏的表单域\n\u0026lt;input type=\u0026#34;hidden\u0026#34; name=\u0026#34;session\u0026#34; value=\u0026#34;...\u0026#34; /\u0026gt; 优点: Cookie 被禁时可以使用\n缺点: 所有页面必须是表单提交之后的结果。\nHttpSession\n在所有会话跟踪技术中,HttpSession 对象是最强大也是功能最多的。当一个用户第一次访问某个网站时会自动创建 HttpSession,每个用户可以访问他自己的 HttpSession。可以通过 HttpServletRequest 对象的 getSession 方 法获得 HttpSession,通过 HttpSession 的 setAttribute 方法可以将一个值放在 HttpSession 中,通过调用 HttpSession 对象的 getAttribute 方法,同时传入属性名就可以获取保存在 HttpSession 中的对象。与上面三种方式不同的 是,HttpSession 放在服务器的内存中,因此不要将过大的对象放在里面,即使目前的 Servlet 容器可以在内存将满时将 HttpSession 中的对象移到其他存储设备中,但是这样势必影响性能。添加到 HttpSession 中的值可以是任意 Java 对象,这个对象最好实现了 Serializable 接口,这样 Servlet 容器在必要的时候可以将其序列化到文件中,否则在序列化时就会出现异常。\nCookie 和 Session 的区别 # Cookie 和 Session 都是用来跟踪浏览器用户身份的会话方式,但是两者的应用场景不太一样。\nCookie 一般用来保存用户信息 比如 ① 我们在 Cookie 中保存已经登录过得用户信息,下次访问网站的时候页面可以自动帮你登录的一些基本信息给填了;② 一般的网站都会有保持登录也就是说下次你再访问网站的时候就不需要重新登录了,这是因为用户登录的时候我们可以存放了一个 Token 在 Cookie 中,下次登录的时候只需要根据 Token 值来查找用户即可(为了安全考虑,重新登录一般要将 Token 重写);③ 登录一次网站后访问网站其他页面不需要重新登录。Session 的主要作用就是通过服务端记录用户的状态。 典型的场景是购物车,当你要添加商品到购物车的时候,系统不知道是哪个用户操作的,因为 HTTP 协议是无状态的。服务端给特定的用户创建特定的 Session 之后就可以标识这个用户并且跟踪这个用户了。\nCookie 数据保存在客户端(浏览器端),Session 数据保存在服务器端。\nCookie 存储在客户端中,而 Session 存储在服务器上,相对来说 Session 安全性更高。如果使用 Cookie 的一些敏感信息不要写入 Cookie 中,最好能将 Cookie 信息加密然后使用到的时候再去服务器端解密。\n"},{"id":433,"href":"/zh/docs/technology/System/%E6%B7%B1%E5%85%A5%E7%90%86%E8%A7%A3%E8%AE%A1%E7%AE%97%E6%9C%BA%E7%B3%BB%E7%BB%9F/%E4%B9%A6%E7%B1%8D/%E5%B0%81%E9%9D%A2-%E7%9B%AE%E5%BD%95/","title":"Index","section":"SpringCloud","content":"\n`.\n兰镶尔 E. 布莱恩特\n(Randal E. Bryant)\n1981 年千麻省理工学院获得计算机博士学位 ,\n1984 年至今一直任教 千卡内基-梅隆大学。现任卡内基-梅隆大学计算机科学学院院长、教授,同时还受邀任教 千电子和计算机工程 系。他从事本科生和研究生计算机 系统方面课程的教学近 40 年。他和\nO\u0026rsquo;Hallaron 教授一起在卡内基-梅隆大学开设了15-\n213课程 ”计算机系统导论” , 该课程即为本书的基础。他还是ACM院士、IEEE院士、美国国家工程院院士和美国 人文与科学研究院院 士。其研究成果被\nIntel 、IBM、Fujitsu 和Microsoft 等主要计算机制造商使用, 他还因研究获得过Semiconductor Research Corporation、ACM、IEEE颁发的多项大奖。\n大卫 R. 奥哈拉伦\n(David R. O\u0026rsquo;Hallaron)\n卡内基-梅隆大学电子和计算 机工程系教授 。在弗吉尼亚大学获得计算机科学的博士学位 , 2007 年一2010 年为Intel 匹兹堡实验室主任。他教授本科生和研究生的计算机 系统方面的课程已有 20余年, 井和Bryant 教授一起开设了 ”计算机系统导论 ” 课程。曾获得CMU计算机学院颁发的Herbert Simon杰出教学奖。他主要从事计算机系统领域的研究 , 与\nQuake项目成员一起获得过高性能计算领域中的最高 国际奖项—-G ordon Bell奖。他目前的工作重点是研究自动分级 ( autograding ) 概念, 即评价其他程序质量的程序。\n\u0026ldquo;山匾 ••••••• ·-\n深人理解计算机系统\n兰德尔 E. 布莱恩特 ( Randal E. Bryant)\n[美] 卡内基-梅隆大学 著\n大卫 R. 奥哈拉伦 ( David R. O\u0026rsquo;Hallaron)\n卡内基-梅隆大学\n龚奕利贺莲译 # Computer Systems\nA Program1ner\u0026rsquo;s Perspective Third Edition\n@机械工业出版社 # China Machine P「ess\n图 书在版编目 ( CIP ) 数 据 # 深入理 解计算 机系统(原书第 3 版)/(美)兰德尔. E. 布莱恩特 ( Ra n d a l E. Br y a n t ) 等著; 龚奕 利 ,贺 莲 译 .— 北京 :机械工业出 版社 , 2 0 1 6 .7\n(计算机科学丛书)\n书名原文: Computer Systems: A Programmer\u0026rsquo;s Perspective, Third Edition ISBN 978-7-111-54493-7\n深… IL CD兰\u0026hellip; (2)龚.. ® 贺… III. 计笢机系统 IV. TP338\n中国版本图书馆CIP数据核字 ( 20 1 6 ) 第 1 8 23 6 7 号\n本书版权登记号:图字: 01-2015 -2044\nAuthorized translation from the English language edition, entitled Computer Systems: A Programmer\u0026rsquo;s Perspective, 3E, 9780134092669 by Randal E. Bryant, David R. O\u0026rsquo;Hallaron, published by Pearson Education, Inc., Copyright©2016, 2011, and 2003\nAll rights reserved. No part of this book may be reproduced or transmit ted in any form or by any means, electronic or mechanical, including photocopying, recording or by any information storage retrieval system, without permission from Pearson Education, Inc.\nChinese simplified language edition published by Pearson Educ a t io n Asia Ltd., and China Machine Press Copyr ight © 2016.\n本书中文 简体字版由 Pe arson Education (培生教育出版集团)授权机械工业出版社在中华入民共和国境内(不包括中国台湾地区和中国香港、澳门特别行政区)独家出版发行。未经出版者书面许可, 不得以任何方式抄袭、复制或节录本书中的任何部分。\n本书封底贴有 Pe ars on Education (培生教育出版集团)激光防伪标签,无标签者不得销售。\n本书从程序员的视角详细阐述计算机系统的 本质概念,并 展 示这些概 念如何 实实 在在 地影响应用程序的正确性、性能和实用性。全书共 12 章,主要包括信息的 表示和处 理 、程 序的 机器级表示、处理 器体 系结 构 、优化 程序性能 、存储器层次结构 、链接 、异常控制流 、虚拟存储 器 、系统 级 1/0 、网 络编程、并发编程等内容。书中提供了大量的例子和练习题,并给出部分答案,有助于读者加深对正文所述\n概念和知识的理解。\n本书适合作为高等院校计算机及相关专业本科生、研究生的教材,也可供想要写出更快、更可靠程序的程序员及专业技术入员参考。\n出版发行 :机械工 业出版社 (北京市西城区百万庄大街22 号 邮政编码: 100037)\n责任编辑:和静\n印 刷:中国电影出版社印刷厂\n开本: 185mm x 260mm 1/16\n书号: ISBN 978-7-111-54493-7\n责任校对:殷 虹\n版 次 : 2016 年 11 月 第 1 版 第 1 次 印 刷\n印张: 48.25\n定价: 139.00 元\n凡购本书,如有缺页、倒页 、脱页, 由本社发行部调换\n客服热线: (010) 88378991 88361066 投稿热线: ( 010 ) 88 379604\n购书热线: (010) 68326294 88379649 68995259 读者信箱: hzjs j@ hzbook.com\n版权所有·侵权必究\n封底无防伪标均为盗版\n本书志律顾问: 北京大成律师 事务所 韩光/邹晓东\n文 : 复兴以来,源 远流长的科学精神和逐 步形成的学术规范, 使西国家在自然科学的各个领域取得了垄断性的优势;也正是这样\n的优势, 使美国 在信息技术发展的六十多年间名家辈出、独领风骚。在商业化的进程中,美国的产业界与教育界越来越紧密地结合,计算机学 科中的许多泰山北斗同时身处科研和教学的最前线,由此而产生的经典 科学著作,不仅擘划了研究的范畴,还揭示了学术的源变,既遵循学术 规范, 又自有 学者个性, 其价值并不会因年月的 流逝而减退。\n近年,在全球信息化大潮的推动下,我国的计算机产业发展迅猛, 对专业人才的需求日益迫切。这对计算机教育界和出版界都既是机遇, 也是挑战;而专业教材的建设在教育战略上显得举足轻重。在我匡信息 技术发展时间较短的现状下,美国等发达国家在其计算机科学发展的几 十年间 积淀和发展的经典教材仍有许多值得借鉴之处。因此,引进一批国外优秀计算机教材将对我国计算机教育事业的发展起到积极的推动作 用,也 是 与世界接轨、建设真正的世界一流大学的必由之路。\n机械工业出版社华章公司较早意识到“出版要为教育服务”。 自 1998 年开始 ,我 们就将工作重点放在了遴选、移译国外优秀教 材上。经过多年的 不 懈 努 力, 我 们 与 P earson , McGraw-Hill, Elsevier, MIT, John Wiley \u0026amp; Sons, Cengage等世界著名出版公司建立了良好的合作关系, 从他们现 有的数百 种教 材中甄选出 Andrew S. T anenb aum , Bjarne Strous­ trup, Brian W. Kernighan, Dennis Ritchie, Jim Gray, Afred V. Aho , John E\n. Hopcroft, Jeffrey D. Ullman, Abraham Silberschatz, William Stallings, Donald E. Knuth, John L. Henness y , Larry L. Peterson 等大师名家的一批经典作品,以“计算机科学丛书”为总称出版,供读者学习、研究及 珍藏。大理石纹理的封面,也 正体现了这套丛书的品位和格调 。\n”计 算机科学丛书” 的 出版工作得到了国内外学者的鼎力相助, 国内的专家不仅提供了中 肯的选题指导, 还不辞劳苦地担 任了 翻译 和审校的工作; 而原书的作者也相当关注其作品在中国的传播,有的还专门为其书的中译本 作序。迄今,”计算机科学丛书”已经出版了近两百个品种,这些书籍在读 者中树立了良好的口碑 ,并被许多 高校采用为正式教材和参考书籍。其影印版“经典原版书库”作 为姊妹篇也被越来越多实施 双语教学的学校所采用。\nIV # 权威的作者、经典的教材、一流的译者、严格的审校、精细的编辑,这些因素使我们的图 书有了质量的保证。随着计算机科学与技 术专业学科建设的不断完善和教材改革的逐渐深化, 教育界对国外计算机教材的需求和应用都将步入一个新的阶段,我们的目标是尽善尽美,而反 馈的意见正是我们达到这一终极目标的重要帮助。华章公司欢迎老师和读者对我们的工作提出\n建议或给予指正, 我们的联系方法如下:\n华章网站: www. hzbook. com 电子邮件: hzjsj@ hzbook. com 联系电话: (010)88379604\n联系地 址: 北京 市西城 区百 万庄 南街 1 号\n邮政编码: 100037\n==吾\nITrf:1'1ffl # 华章教育\n华章科技图书出版中心\n4 匕 章 公 司 温 莉 芳 女 士 邀 我 为 即 将 出 版 的《Computer Systems: A\n- \u0026ndash; rramgmer \u0026rsquo;s Pers pective》第3 版的中文译本《深入理解计算机系\n统》写个序, 出于两方面的考虑,欣 然允 之。\n一是源于我个人的背景和兴趣。我长期从事软件工程和系统软件领域的研究,对计算机学科的认识可概括为两大方面:计算系统的构建和基于计算系统的计算技术应用。出于信息时代国家掌握关键核心技术的重大需求以及我个人专业的本位视角,我一直对系统级技术的研发给予更多关注,由于这种“偏爱”和研究习惯的养成,以至于自己在面对非本\n专业领域问题时,也常常喜欢从“系统观”来看待问题和解决问题。我自 己也 和《深入理解计 算机系统》有过“亲密接触\u0026rdquo;。2012 年, 我还在北京大学信息科学技术学院院长任上,学院从更好地培养适应新技术、发展具 有系统设计和系统应用能力的计算机专门人才出发,在调查若干国外高 校计算机学科本科生教学体系基础上,决定加强计算机系统能力培养, 在本科生二年级增设了一门系统级课程,即“计算机系统导论”。其时, 学校正 在倡导小班课教学模式,这 门 课 也 被选为学院的第一个小班课教学试点。为了体现学院的重视, 我亲自担任了这门课的主持人, 带领一个 18 人组成的"豪华“教学团队负责该课程的教学工作, 将学生分成 14 个小班, 每个小班不超过 15 人。同时,该 课 程涉及教师集体备课组合授课、大班授课基础上的小班课教学和讨论、定期教学会议、学生自主习 题课和实验课等新教学模式的探索,其中一项非常重要的举措就是选用 了卡内 基-梅隆大学 Randal E. Brya nt 教授和 David R. O \u0026rsquo; H allaron 教授编写的《Computer Systems: A Programmer\u0026rsquo;s Pers pective》(第 2 版)作为教材。虽然这门课程我只主持了一次, 但对这本教材的印象颇深颇佳。\n发中中 # 展国 # 中科文\n家国院学版科院\n言士序_\n士院\n、一\n梅\n本\u0026hellip;\u0026hellip;.\n二是源于我 和华章公司已有的良好合作和相互了解。2000 年前 后 , 我先后翻译了华章公司引进(机械工业出版社出版)的Roger P ress man 编写的《Sof tware Engineering: A P ra ctitio ner \u0026rsquo; s App roach》一书的第 4 版和第\n5 版。其后, 在计算机学会软件工程专业委员 会和系统 软件专业委员 会的诸多学术活动中也和华章公司及温莉芳女士本人有不少合作。近二十年来,华章公司的编辑们引进出版了大量计算机学科的优秀教材和学术著作,对国内高校计算机学科的教学改革起到了积极的促进作用,本书的\nVI\n翻译出版仍是这项工作的延续。这是一项值得褒扬的工作,我也想借此机会代表计算机界同仁 表达对华章公司的感谢!\n计算机系统类别的课程一直是计算机科学与技术专业的主要教学内容之一。由于历史原 因, 我国的计算机专业的课程体系曾广泛参考 ACM 和 IEEE 制订的计算机科学与技术专业教学计划( Computing C urricula ) 设计,计 算机系统类课程也参照该计划分为汇编语言、操作系统、组成原理、体系结构、计算机网络等多门课程。应该说,该课程体系在历史上对我国的计算机 专业教育起了很好的引导作用。\n进入新世纪以来,计算技术发生了重要的发展和变化,我国的信息技术和产业也得到了迅 猛发展, 对计算 机专业的毕业生提出了更高要求。重新审视原来我们参照 ACM/ IEEE 计算机专业计划的课程体系,会发现存在以下几个方面的主要问题。\n) 课程体系中缺乏 一门独立的能够贯穿整个计算机系统 的基础课程。计算机系统 方面的基础知识被分 成了很多门独立的课程, 课程内容彼此之间缺乏关联和系统性。学生学习之后; 虽然在计算机系统的各个部分理解了很多概念和方法,但往往会忽视各个部分之间的关联,难 以系统性地理解整个计算机系统的工作原理和方法。\n) 现有课程往往偏重理论, 和实践关联较少。如现有的系统课程中通常会介绍函数调用过程中的压栈和退栈方式,但较少和实践关联来理解压栈和退栈过程的主要作用。实际上,压 栈和退栈与理解 C 等高级语言的丁作原理息息相关,也 是常用的攻击手段 Buffer Overflow 的主要技术基础。\n教学内容比较传统 和陈旧, 基本上是早期 PC 时代的内容。比如, 现在的主流台式机CPU 都巳经是 x86-64 指令集, 但较多课程还在教授 80386 甚至更早的指令集。 对于近年 来出现的多核/众核处理器、SSD 硬盘等实际应用中遇到的内容更是涉及较少。 4 ) 课程大多数从设计者的 角度出发, 而不是从使用者的角度出发。对于大多数学生来说, 毕 业之后并不会成为专业的 CP U 设计人员、操作系统 开发人员等, 而是会成为软件开发工程师。对他们而言,最重要的是理解主流计算机系统的整体设计以及这些设计因素对于应用软件 开发和运行 的影响。\n这本教材很好地克服了上述传统课程的不足,这也是当初北大计算机学科本科生教学改革 时选择该教材的主要考量。其一,该 教材系统地介绍了整个计算 机系统的工作原理, 可帮助学生系统性地理解计算机如何执行程序、存储信息和通信;其 二 ,该 教材非常强调实践, 全书包括 9 个配套 的实验, 在这些实验中,学 生需要攻破计算机系统、设计 CPU、实现命令行解 释器、根据缓存优化程序等 , 在新 鲜有趣的实验中理解系统原理, 培养动手能力; 其三,该 教材紧跟时代的发展, 加入了 x86- 64 指令集、Intel Core i7 的虚拟地址结构、SSD 磁盘、IPv6 等新技术内 容:其 四 .该 教材从程序员 的角度看待计算机系统, 重点讨论系统的不同结构对于上层\nVII\n应用软件编写、执行和数据存储的影响,以培养程序员在更广阔空间应用计算机系统知识的 能力。\n基千该教材的北大”计 算机系统导论”课程实施已有五年, 得到了学生的广泛赞誉, 学生们通过这门课程的学习建立了完整的计算机系统的知识体系和整体知识框架,养成了良好的编程 习惯并获得了编写高性能、可移植和健壮的程序的能力,奠定了后续学习操作系统、编译、计 算机体系结构等专业课程的 基础。北大的教学实践表明 , 这是一本值得推荐采用的好教材。\n该书的第 3 版相对千第 2 版进行了较大程度的修改和扩充。第 3 版从一开始就采用最新x86-64 架构来贯穿各部分知识, 在内存技术、网络技术上也有一系列 更新,并 且重组了之前的一些比较难懂的内容。我相信,该 书的出版, 将有助千国 内计算机系统教学的进一步改进, 为\n培养从事系统级创新的计算机人才奠定很好的基础。\n琴 归\n2016 年 10 月 8 日\n2 00 2 年 8 月本书第 1 版首 次印刷。一个月之后, 我在复旦大学软件学院开设 了”计 算机系统 基础”课 程, 成为国 内 第一个采用这本教材授\n课的老师。这本教材有四个特点。第一, 涉及面广, 覆盖了二进制、汇编、组成、体系结构、操作系统、网络与并发程序设计等计算机系统最 重要的方面。第二, 具有相当的深度, 本书从程序出发逐步深入到系统领域的重要问题,而非点到为止,学完本书后读者可以很好地理解计算 机系统的工作原理。第三,它 是 面向 低年级学生的教材, 在过去的教学体系中这本书所涉及的很多内容只能在高年级讲授,而本书通过合理的 安排将计算机系统领域最核心的内容巧妙地展现给学生(例如,不需要掌 握逻辑设计与硬件描述语言的完整知识,就可以体验处理器设计)。第 四, 本书配备了非常实用、有趣的实验。例如, 模仿硬件仅用位操作完成复杂的运算 , 模仿 t racker 和 hacker 去破解密码以及攻击自身的程序, 设 计 处理器, 实现简单但功能强大的 Shel l 和 P ro xy 等。这些实验既强化了学 生对书本知识的理解,也 进一步激发了学生探究计算机系统的热情。\n以低年级开设“深入理解计算机系统”课程为基础,我先后在复旦大 学和上海交 通大学软件学院主导了激进的教学改革。必修课时被大量压缩,现在软件工程专业必修课由问题求解、计算机系统基础、应用开发 基础、软件工程四个模块 9 门 课 构成。其他传统的必修课如操作系统、编译原理、数字逻辑等都成为方向课。课程体系的变化,减少了学生修 读课程的总数和总课时,因而为大幅度增加实验总最、提高实验难度和 强度、增强实验的综合性和创新性提供了有力保障。现在我的课题组的 青年教师全部是首批经历此项教学改革的学生。本科的扎实基础为他们 从事系统软件研究打下了良好基础,他们实现了亚洲学术界在操作系统 旗舰会议 SOS P 上论文发表零的突破,目 前 研 究 成果在国际上具有较大 的影响力。师资力批的补充, 又为全面推进更加激进的教学改革创造了条件。\n本书的出版标志着国际上计算机教学进入了第三阶段。从历史来看, 国 际 上计算机教学先后经历了三个主要阶段。第一阶段是上世纪 70 年代中期至 80 年代中期.那时理论、技术还不成熟, 系统不稳定,因 此教材主要阶绕若干重要问题讲授不同流派的观点,学生解决实际问题的能力\nIX # 不强。第二阶段是上世纪 80 年代中期至本世纪初, 当时计算机单机系统的理论 和技术已逐步趋于成熟,主流系统稳定,因此教材主要围绕主流系统讲解理论和技术,学生的理论基础扎 实, 动手能力强。第三阶段从 本世纪初开始, 主要 背景是随着互 联网的兴起,信 息 技术开始渗透到人类工作和生活的方方面面。技术爆炸迫使教学者必须重构传统的以计算机单机系统为主 导的课程体系。新 的体系大面积调 整了 核心课程的内容。核心课程承担了帮助学生构建专业知识框架的任务, 为学生在毕业后相当长时间内的专业发展奠定坚实基础。现在一般认为问 题抽象、系统抽象和数据抽象是计算机类专业毕业生的核心能力。而本书担负起了系统 抽象的重任, 因此美国的很多高校都采用了该书作为计算机系统核心课程的教材。第三阶段的教材与第二阶段的教材是互补关系。 第三阶段的教材主要强调坚实而宽 广的基础, 第二阶段的教材主要强调深入系统的专门知识,因此依然在本科高年级方向课和研究生专业课中占据重要地位。\n上世纪 80 年代初, 我国借鉴美国经验建立了自己的计算机教学体系并引进了大最教材。从 21 世纪初开 始, 一些学校开始借鉴美国第二阶段的教学方法, 采用了部分第二阶段的著名教材, 这些改革正在走向成熟并得以推广。2012 年北京大学计算机专业采用本书作为教材后, 采用本教材开设“计算机系统 基础”课程的高校快速增 加。以此为契机, 国内的计算机教学也有望全面进入第三阶段。\n本书的第 3 版完全按照 x86- 64 系统进行改写。此外, 第 2 版中删除了以 x87 呈现的浮点指令, 在第 3 版中浮点指令又以标量 AVX2 的形式得以恢复。第 3 版更加强调并发, 增加了较大篇幅用于讨论信号处理程序与主程序间并发时的正确性保障。总体而言, 本书的三个版本在结构上没有太大变化,不同版本的出现主要是为了在细节上能够更好地反映技术的最新变化。\n当然本书的某些部分对于初学者而言还是有些难以阅读。本书涉及大蜇重要概念, 但一些概念首 次亮相时并没有编排好顺序。例如寄存器的概念、汇编指令的顺序执行模式、PC 的概念等对 千初学 者而言非常陌生, 但这些 介绍仅仅出现在第 1 章的总览中, 而当第 3 章介绍汇编时完全没有进一步的展开就 假设读者已经非常清楚这些概念。事实上这些概念原本就介绍得过 千简单,短暂亮相之后又立即退场,相隔较长时间后,当这些概念再次登场时,初学者早已忘 却了它们是什么。同样,第 8 章对进程、并发等概念的介绍也存在类似问 题。因此, 中文翻译版将 配备导读部分, 希望这些导读能够帮助初学者顺利阅读。\n2016 年 10 月 15 日\n书第 1 版出版于 2003 年, 第 2 版出版于 2011 年,去 年发行的巳经是原书第 3 版了。第 3 版还是采用以下组合方式: 在经典的\nx86 架构机器上运行 Linux 操作系统 ,采用 C 语言编程。这样的 组合经受住了时间的考验。这一版的一个明显变化就是从讲解 I A32 和 x86-64 转变为完全以 x86-64 为基础, 相应地修改了第 3、4 、5 、6 和 7 章。同时, 还改写了第 2 章 , 使之更易读、好懂;用 近期的 新技术更新了第 6 、11 和12 章。这些变化 使得本书既 和新技术保持了同步, 又保留了描述系统本质的内容以及从程序员角度出发的 特色。\n除了翻译本书,我们也开始以本书为教材讲授”计算机系统基础”课 程, 对这本书的理解也随之越来越深入, 意 识 到除了阅读之外, 动手实践更是学习计算机系统的 必经之路。本书的官 网提供了很多实 验作业(Lab Assignment), 其中不乏有趣且有一定难度的 实验, 比如 Bomb Lab 。有兴趣的读者除了阅读本书的内容之外,还应该试着去完成这些实验, 让纸面上的内容在实际动手中得到巩固和加强。本书的官方博客也不断 更新着有关这本书和配套课程的最新变化, 这也是对本书的有益补充 。\n第 3 版从翻译 的角度来说, 我 们尽量做到更流畅, 更符合中文表达的习惯。对于一些术语, 比如 memo ry , 以前怕出错就统一翻译 成存储器, 现在则尽可能 地按照语境去区分, 翻译 成内存或者存储器。\n在此 , 要感谢本书的编辑朱劼、姚蕾以及和静, 有她们的支持、鼓励和耐心 细致的工作,才能 让本书如期与读者见面。\n由于本书内 容多, 翻译 时间紧迫,尽 管 我们尽量做到认真仔细, 但还是难以避免出现错误和不尽如人意的地方。在此欢迎广大读者批评指 正。我们也会一如既往地维护勘误表, 及时在网上更新,方 便 大家阅读。\n(另外, 本版第 1 次印刷时, 我们已经根据官网 2016 年 3 月 1 日前 发布的勘误进行了修正, 就不在中文勘误中再翻译 了。)\n龚奕利 贺莲\n201 6 年 5 月 于硌 珈 山\n本 书(简称C,S APP) 的主要读者是计算机科学 家、计算机工程 师,以 及那些想通过学习计算机系统的内在运作而能够写出更好程序的人。\n我们的目的是解释所有计算机系统的本质概念,并向你展示这些概念是如何实实在在地影响应用程序的正确性、性能和实用性的。其他的系统类书籍都是从构建者的角度来写的,讲述如何实现硬件或系统软件, 包括操作系统 、编译器和网络接口。而本书是从程序 员 的 角 度来写的, 讲述应用程序员如何能够利用系统知识来编写出更好的程序。当然,学习一个计算机系统应该做些什么,是学习如何构建一个计算机系统的很好的出发点,所以,对于希望继续学习系统软硬件实现的人来说,本书也是一本很有价值的介绍性读物。大多数系统书籍还倾向于重点关注系统的某一个方面,比如:硬件架构、操作系统、编译器或者网络。本书则以程序员的视角统一覆盖了上述所有方面的内 容。\n如果你研究和领会了这本书里的概念,你将开始成为极少数的“牛 人",这些“牛人“知道事情是如何运作的,也知道当事情出现故障时如 何修复 。你写的程序将能够更好地利用操作系统 和系统 软件提供的功能, 对各种操作条件和运行时参数都能正确操作,运行起来更快,并能避免出 现使程序容易受到网络攻击的缺陷。同时,你也要做好更深入探究的准备, 研究像编译器、计算机体系结构、操作系统、嵌入式系统、网络互联和网络安全这样的高级题目。\n读者应具备的背景知识 # 本书的重点是执行 x86-64 机器代码的系统。对英特尔及其竞争对手而言, x86-64 是他们自 1978 年起 ,以 8086 微处理器为代 表,不 断 进化的最新成果。按照英特尔微处理器产品线的命名规则, 这类微处理器俗称为 \u0026quot; x86\u0026quot;。随着半导体技术的演进,单芯片上集成了更多的晶体管,这些处理器的计算 能力和内存容量有了很大的增长。在这个过程中 ,它 们从处理16 位字, 发展到引入 IA32 处理器处理 32 位字,再 到最近的 x86-64处理 64 位字。\n我们考虑的是这些机器如何在 Linux 操作系统上运行 C 语言程序。\nLinux 是众多继承自最 初由贝尔实验室开发的 U nix 的 操 作 系统中的一种。这类操作系统的其他成员 包括 Solaris 、 Fr eeBSD 和 MacOS X。 近年 来,\nXII # 由 千 Pos ix 和标 准 U nix 规范的标准化努力, 这些操作系统 保持了高度兼容性。因此, 本书内容几乎直接适用千这些 “类 U nix\u0026quot; 操作系统。\n文中 包含大横已在 Linux 系统上编译和运行过的程序示例。我们假设你能访问一台这样的机器,并 且 能 够登录, 做一些诸如切换目录之类的简单操作。如果你的计算机运行的是 Mi­ crosoft Windows 系统 , 我们建议你选择安装一个虚拟机环境(例如 Virt ua!Box 或者 VMWa re ) , 以便为一 种操作系统(客户 OS) 编写的程序能在另一种系统(宿主 OS) 上运行。\n我们还假设你对 C 和 C+ + 有一定的了解。如果你以前只有 Java 经验, 那么你需要付出更多的努力来完成这种 转换, 不过我 们也会帮助你。Java 和 C 有相似的语法和控制语句。不过, 有一些 C 语言的特性(特别是指针、显式的动态内 存分配和格式化 1/ 0 ) 在 Java 中都是没有的。所幸的是,C 是一个较小的语言, 在 Brian Kern ig han 和 Dennis Ritch ie 经典的 \u0026quot; K\u0026amp; R\u0026quot; 文献中得到了清晰优美的描述[ 61] 。无论你的编程背景如何, 都应该考虑将 K \u0026amp; R 作为个人系统 藏书的一部分。如果你只有使用解 释性语言的经验, 如Python 、R uby 或 Perl , 那么在使用本书之前,需 要 花 费 一些时间来学习 C。\n本书的前几章揭示了 C 语言程序和它们相对应的机器语言程序之间的交互作用。 机器语 言示例都是用运行在 x86-64 处理器上的 G NU GCC 编译器生成的。我们不需要你以前有任何硬件、机器语言或是汇编语言编程的经验。\n区 关千 C 编程语言的建议\n为 了帮 助 C 语言编程背景 薄弱(或全无背景)的读者, 我们在书 中加入了这 样一些专 门的注释 未突出 C 中一些特 别重要的特性。我们假设你熟悉 C+ + 或 Java 。\n如何阅读此书\n从程序员的角度学习计算机系统是如何工作的会非常有趣,主要是因为你可以主动地做这 件事情。无论何时你学到一些新的东西, 都可以 马上试验并且直接看到运行结果。事实上, 我们相信学习系统的唯一方法就 是做C do ) 系统 ,即 在真正的系统上解决具体的问题, 或是编写 和运行程序。\n这个主题观念贯穿全书。当引入一个新概念时,将会有一个或多个练习题紧随其后,你应 该马上做一做来检验你的理解。这些练习题的解答在每章的末尾。当你阅读时,尝试自己来解 答每个问 题, 然后 再查阅答案, 看自己的答案是否正确。除第 1 章外, 每章 后面都有难度不同的 家庭作业。对每个家庭作业题, 我们标注了难度级别:\n只 需 要几分钟。几乎或完全不需要编程。 XIII # •• 可能需要将近 20 分钟。通常包括编写和测试一些代码。(许多都源自我们在考试中出的题目。)\n***需 要很大的努力 ,也 许 是 1 ~ 2 个 小 时。一般包括编写和测试大量的代码。\n::- 个实验作业, 需 要 将近 1 0 个小时。\n文中 每段代码示例都是由经过 GCC 编译的 C 程序直接生成并在 Linux 系统 上进行了测试, 没有任何人为的改动。当然, 你的系统上 GCC 的版本可能不同, 或者根本就是另外一种编译器, 那么可能生成不一样的机器代码, 但是整体行为表现应该是一样的。所有的源程序代码都可以从 csapp. cs. emu. edu 上的 CS: APP 主页上获取。在本书中, 源程序的 文件名列在两条水平线的右边,水平线之间是格式化的代码。比如, 图 ]中的程序能在 code/ intro/ 目录下的 hello. c 文件中找到。当遇到这些 示例程序时 , 我们鼓励你在自己的 系统上试着运行它们 。\n#include \u0026lt;stdio.h\u0026gt; int main()\n{\nprintf(\u0026ldquo;hello, world\\n\u0026rdquo;); return O;\n}\ncode/intro/hello.c\ncode/intro/hello.c\n图 1 一个典型的代码示例\n为了 避免本书体积过大、内容过多, 我们添加了许多网络旁注( Web a邓ide ) , 包括一些对本书主要内 容的 补充资料。本书中用 C H AP : T O P 这样的标记形式来引 用 这 些 旁注, 这里CH AP 是该章主题的缩写编码, 而 T O P 是涉及的话题的缩写编码。例如,网 络旁注 DAT A : BOOL 包含对第 2 章中数据表示里面有关布尔 代数内容的补充资 料; 而 网络旁 注 ARC H : V LOG 包含的是用 Verilog 硬件描述语言进行处理器设计的资料, 是对第 4 章 中处理器设计部分的补充。所有的网络旁注都 可以从 CS : AP P 的主页上获取。\nm 什么是旁注\n在整本书中,你将会遇到很多以这种形式出现的旁注。旁注是附加说明,能使你对当前 讨论的主题多一些了解。旁注可以有很多用处。 一 些是 小的 历 史故 事。 例如, C 语 言 、\nLinux 和 Int ernet 是从何而 来的? 有些旁注则是 用 来澄 清学 生们 经常感到疑惑的 问题。例如,\n高速缓存的行、组和块有什 么区 别? 还有些旁注给 出 了一 些现 实世 界 的例 子。 例如 , 一 个浮点错误怎么毁掉了法国的一枚火箭,或是给出市面上出售的一个磁盘驱动器的几何和运行参 数。最后, 还有一些旁注仅 仅就是一些有趣 的内容,例 如 , 什 么是 \u0026quot; hoink y\u0026quot; ?\nXIV\n本书概述\n本书由 12 章组成, 旨在阐述计算机系统的核心概念。内容概述如下:\n笫 1 章: 计算机 系统漫并。这一章通过研究 \u0026quot; hello , world\u0026quot; 这个简单程序的生命周期, 介绍计算机系统的主要概念和主题。\n笫 2 章: 信 息的表示和 处理。我们讲述了计算机的算术运算, 重点描述了会对程序员有影响的无符号数和数的补码表示的特性。我们考虑数字是如何表示的, 以及由此确定对于一个给定的字长, 其可能 编码值的范围。我们探讨有符号和无符号数字之间类型转换的效果,还 阐述算术运算的数 学特性。菜鸟级程序员经常很惊奇地了解 到(用补码表示的) 两个正数的和或者 积可能 为负。另一方面,补 码的算术运算满足很 多整数运算 的代数特性,因 此 , 编译器可以很安全地把一个常量乘法转化为一 系列的移位和加法。我们用 C 语言的位级操作来说明布尔代 数的原理和应用。我们从两个方 面讲述了 IEEE 标准的浮点格式:一 是 如何用它来表示数值,一是 浮点运算的数学属性。\n对计算机的算术运算 有深刻的理解是写出可靠程序的关键。比如, 程序员 和编译器不能用表达式( x- y\u0026lt;O ) 来 替 代( x \u0026lt;y) \u0026rsquo; 因为前者 可能会产生溢出。甚至也不能用表达式( - y\u0026lt;- x ) 来替 代, 因为在补码表示中负数和正 数的范围是不对称的。算术溢出是 造成 程序错误和安全漏洞的一个常见根源, 然而很少有书从程序员的角度来讲述计算机算术运算的特性。\n第 3 章 : 程序的 机器级 表 示。我们教读者如何阅读由 C 编译器生成的 x86-64 机器代码。我们说明为不同控制结构(比如条件、循环和开关语句)生成的基本指令模式。我们还讲述 过程的实现,包 括栈分配、寄存器使用惯例和参数传递。我们讨论不同数据结构(如结构、联合和数组)的分配和访问方式。我们还说明实现整数和浮点 数算术运算的指令。我们还以分析程序在机器级的样子作 为途 径, 来理解常见的代码安全漏洞(例如缓 冲区溢出),以及理解程序员、编译器和操作系统可以采取的减轻这些威胁的措施。学习本章的概念能 够帮助读者成为更好的程序员, 因为你们懂得程序在机器上是如何表示的。另外一个好处就在于读者会对指针有非常 全面而具 体的理解。\n第 4 章 : 处理 器体 系结 构。这一章讲述基本的组合和时序逻辑元素,并 展示这些元素如何在数据通路中组合到一起, 来执行 x86-64 指令集的一个称为 \u0026quot; Y86- 64\u0026quot; 的简化子集。我们从设 计单时钟周期数据通路 开始。这个设计概念上非常简单, 但 是 运行速度不会太快。然后我们引入流水线的思 想, 将处理一条指令所需要的不同步骤实现为独立的阶段。这个设计中,在 任何时刻, 每个阶段都 可以处理不同的指令。我们的五阶段处理器流水线更加实用。本章中处理器设计的控制逻辑是用一种称为 H CL 的简单硬件描述语言来描述的。用 HCL 写的硬件设计能够编译 和链接到本书提供的模拟器中, 还可以 根据这些设计\nXV # 生成 Verilog 描述, 它适合合成到实际可以运行的硬件上去。\n. 第 5 章: 优化程序性 能。在这一章里, 我们 介绍了许多提 高代 码性能的技术 , 主要思 想就是让程序员通过使编译 器能 够生成更有效的 机器代码来学习编写 C 代码。我 们一开始介绍的是减少程序需要做的工作的变换,这些是在任何机器上写任何程序时都应该遴循 的。然后讲的是增 加生成的 机器代码中指令级并行度的 变换 , 因而提 高了程序在现代\n“超标量” 处理器上的性能 。为了 解释这些 变换行 之有效的 原理,我 们介绍 了一个简单的操作模型,它描述了现代乱序处理器是如何工作的,然后给出了如何根据一个程序的 图形化表示中的关键路径来测 量一个程序可能的性能 。你会惊讶 千对 C 代码做 一些简单的变换能 给程序带来多 大的速度提升 。\n. 第 6 章: 存储 器层次结构 。对应用 程序员 来说 , 存储器 系统 是计算 机系统 中最直接可见的部分之一。到目前 为止 , 读者一直认同这样一 个存储 器系统 概念模型, 认为它 是一个有一致访问时间的线性数组 。实际上, 存储 器系统是一个由不同容量、造价和访问 时间的存储设备组成的层次结构 。我们讲述不同类型的随 机存取存储器 ( RAM) 和只读存储 器\nCROM), 以及磁盘和固态硬盘e 的几何形状和组织构造。我们描述这些存储设备是如何放置在层 次结构中的, 讲述访问局部性是如何使这种层次结构成为可能的。我们通过一个 独特的观点使这些理论具体化, 那就是将存储器系统 视为一个“存储器山\u0026quot; \u0026rsquo; 山脊是时间局部性, 而斜坡是空间局部性。最后, 我们向读者阐述如何通过改善程序的时间局部性和空间局部性来提高应用程序的性能。\n第 7 章:链 接 。本 章 讲述静态和动态链接,包 括的概念有可重定位的和可执行的目标文件、符号 解析、重定位、静态库、共享目标库、位置无关代码,以 及 库 打桩。大多数讲述系统的书中都不讲链接, 我们要讲述它是出于以下原因。第一, 程序员遇到的 最令人迷惑的问题中, 有一些和链接时的小故障有关, 尤其是对那些大型软件包来说。第二,链接器生成的目标文件是与一些像加载、虚拟内存和内存映射这 样的概念相关的。\n笫 8 章 : 异常控制流。在本书的这个部分, 我们通过介绍异常控制流(即除正常分支和过程调用以外的控制流的变化)的一般概念,打 破单一程序的模型。我们给出存在于系统所有层次的异常控制流的例子, 从底层的硬件异常和中断,到 并 发进程的上下文切换,到 由 千接 收 Lin ux 信 号 引 起的控制流突变,到 C 语 言 中 破坏栈原则的非本地跳转。\n在这一章, 我们介绍进程的基本概念, 进程是对一个正在执行的程序的一种抽象。读者会学习进程是如何工作的,以 及如何在应用程序中创建和操纵进程。我们\ne 直译应为固态驱动器, 但固态硬盘一词已经被大家接受,所以沿 用 . 一 译 者注\nXVI\n会展示应用程序员如何通过 Linux 系统调用来使用多个进程。学完本章之后, 读者就能够编写带作业控制的 Linux she ll 了。同时,这 里也会向读者初步展示程序的并发执行会引起不确定的行为。\n. 第 9 章: 虚拟内存 。我们讲 述虚拟内存系统是希望读者对它是如何工作的以及它的特 性有所了解。我们想让 读者了解 为什么不同的并发进程各自都有一个完全相同的地址范围,能 共享某些页, 而又独占另外一些页。我们还讲了一些管理和操纵虚拟内存的问题。特别地, 我们讨论了存储分配操作, 就像标准库的 ma l l oc 和 fr ee 操作。阐述这些内容是出于下面几个目的。它加强了这样一个概念,那就是虚拟内存空间只是一 个字节数组, 程序可以把它划分成不同的存储单元。它可以帮助读者理解当程序包含存储泄漏和非法指针引用等内存引用错误时的后果。最后,许多应用程序员编写自己 的优化了的存储分配操作来满足应用 程序的需 要和特性。这一章比其他任何一章都更能展现将计算机系统中的硬件和软件结合起来阐述的优点。而传统的计算机体系结构 和操作系统书籍都只讲述虚拟内存的某一方面。\n笫 10 章 : 系统级 I/ 0 。我们讲述 Unix I/ 0 的基本概念,例 如 文件和描述符。我们描述如何共享文件, I/ 0 重定向是如何工作的, 还有如何访问文件的元数据。我们还开发了一个健壮的带缓冲区的 I/ 0 包 , 可以正确处理一种称为 short counts 的奇特行为,也 就是库函数只读取一部分的输入数据。我们阐述 C 的标准 I/ 0 库,以 及它与 Linu x I / 0 的关系, 重点谈到标准 I/ 0 的局限性, 这些局限性使之不适合网络编程。总的来说,本章的主题是后面两章 网络和并发编程的基础。\n. 第 11 章 : 网络编程。对编程而言,网 络是非常有趣的 I/ 0 设备,它 将许多我们前面文中学习的概念(比如进程、信号、字节顺 序、内存映射和动态内存分配)联系在一起。网络程序还为下一章的主题 并发,提供了一个很令人信服的上下文。本章只是网 络编程的一 个很小的部分, 使读者能够编写一个简单的 Web 服务器。我们还讲述位于所有网络程序底层的 客户端-服务器模型。我们展现了一个程序员对 Internet 的观点, 并 且教 读 者如何用套接字接口来编写 Inte rnet 客户端和服务器。最后, 我们介绍超文本传输协议( HT T P) , 并开发了一个简单的迭代式 Web 服务器。\n笫 1 2 章 : 并发编程。 这一章以 Internet 服务器设计为例介绍了并发编程。我们比较对照了三种编写并发程序的 基本机制(进程、I/ 0 多路复用和线程),并 且展示如何用它们来建造并发 Internet 服务器。我们 探讨了用 P 、V 信号量操作来实现同步、线程安全和可重入、竞争条件以及死锁等的基本原则。对大多数服务器应用来说,写并发 代码都是很关键的。我们还讲述了线程级编程的使用方法,用这种方法来表达应用程 序中的并行性,使 得程序在多核处理 器上能执行得更快。使用所有的核解决同一个计算问题需要很小心谨慎地协调并发线程,既要保证正确性,又要争取获得高性能。\nXVII\n本版新增内容\n本书的第 1 版千 2003 年出版,第 2 版在 2011 年出版。考虑到计算机技术发展如此迅速, 这本书的内 容还算是保持 得很好。事实证明 Int el x86 的机器上运行 Linux( 以及相关操作系统), 加上采用 C 语言编程,是 一种能够涵盖当今许多系统的组合。然而, 硬件技术、编译器和程序库 接口的变化 ,以 及很多教师教授这些内容的经验, 都促使我们做了大量的修改。\n第 2 版以 来的最大整体变化是, 我们的介绍从以 IA 32 和 x86-64 为基础, 转变为完全以 x86-64 为基础。这种重心的转移影响了很多章节的内容。下面列出一些明显的变化 :\n. 第 1 章 。 我们将第 5 章对 Amdah l 定理的讨论移到了本章。\n. 第 2 章 。 读者和评论家的反馈是一致的, 本章的一些内容有点令人不知所措。因此, 我们澄清了一些知识点,用 更 加 数 学 的 方 式 来描述, 使 得这些内容更容易理解。这使得读者能先略过数学细节, 获得高层次的总体概念, 然后回过头来进行更细致深入的阅读。\n笫 3 章。我们将之前基千 IA32 和 x86- 64 的 表现形式转换为完全基于 x86- 64 , 还更新了近期版本 GCC 产生的代码。其结果是大量的重写工作, 包 括修改了一些概念提出的顺序。同时, 我们还首次介绍了对处理浮点数据的程序的机器级支持。由千历史原因, 我们给出了一个网络旁注描述 IA 32 机 器码。\n. 第 4 章。我们将 之前基于 32 位架构的处理器设计修改为支持 64 位字和操作的设计。\n.• 第 5 章。我们更新了内容以反映最近几代 x86-64 处理器的性能。通过引入更多的功能单元和更复杂的控制逻辑, 我们开发的基于程序数据流表示的程序性能模型, 其性 能 预测变得 比之前更加可靠。\n第 6 章。我们对内容进行了更新,以 反映更多的近期 技术 。\n第 7 章。针对 x86- 64 , 我们重写了本章, 扩充了关于用 GOT 和 P LT 创建位置无关代码的讨论, 新增了一节描述更加强大的链接技术, 比如库打桩。\n. 第 8 章 。 我们增加了对信号处理程序更细致的描述, 包括异步信号安全的函数, 编写信号处理程序的具体指导原则, 以 及用 s i gs us pe nd 等待处理程序。\n第 9 章 。 本章变化不大。\n. 第 1 0 章。我们新增了一节说明文件和文件的 层次结构,除 此之外, 本章的 变化不大。\n笫 11 章 。 我们介绍了采用最新 ge t addr i nf o 和 ge t na me i nf o 函数的、与协议无关和线程安全的网络编程, 取代过时的、不可重入的 ge t hos t b yna me 和 g e t hos t ­ bya ddr 函数。\nXVM # 笫 1 2 章。我们扩充了利用线程级并 行性使得程序在多核机器上更快运行的内容。此外,我们还增加和修改了很多练习题和家庭作业。\n本书的起源\n本书起源于 1998 年秋季, 我们在卡内基-梅隆 CCMU ) 大学开设的一门编号为 1 5-213的 介 绍 性课程: 计 算机系统导论 (I nt rod uction to Computer System, ICS) [ 14] 。从 那 以后 , 每学期都开设了 ICS 这门课程, 每学期有超过 400 名学生上课,这 些 学 生从本科二年级到硕士研究生都有,所学专业也很广泛。这门课程是卡内基-梅隆大学计算机科学系 (CS)以及电子和计算机工程系 CE CE) 所有本科生的必修课, 也 是 CS 和 ECE 大多数高级系统课程的先行必修课。\nICS 这门 课程的宗旨是用一种不同的方式向学生介绍计算机。因为,我 们的学生中几乎没有人有机会亲自去构造一个计算机系统。另一方面,大多数学生,甚至包括所有的计 算机科学家和计算机工程师,也需要日常使用计算机和编写计算机程序。所以我们决定从 程序员的角度来讲解系统,并采用这样的原则过滤要讲述的内容:我们只讨论那些影响用 户级 C 语言程序的性能、正确性或实用性的主题。\n比如, 我们排除了诸如硬件加法器和总线设计这样的主题。虽 然我们谈及了机器语言, 但是重点并不在千如何手工编写汇编语言, 而是关注 C 语言编译器是如何将 C 语言的结构翻译成机器代码的, 包括编译器是如何翻译指针、循环、过程调用以及开关( switch ) 语句的。更进一步地,我们将更广泛和全盘地看待系统,包括硬件和系统软件,涵盖了包 括链接、加载、进程、信号、性能优化、虚拟内存、I/ 0 以 及网络与并发编程等在内的主题。\n这种做法使得我们讲授 ICS 课程的方式对学生来讲既实用、具体, 还能动手操作,同 时也非常能调动学生的积极性。很快地,我们收到来自学生和教职工非常热烈而积极的反 响, 我们意识到卡内基-梅隆大学以外的其他人也可以从我们的方法中获益。因此, 这本书从 ICS 课程的笔记中应运而生了, 而现在我们对它做了修改,使 之能 够反映科学技术 以及计算机系统实现中的变化和进步。\n通过本书的多个版本和多种语言译本, ICS 和许多相似课程已经成为世界范围内数百所高校的计算机科学和计算机工程课程的一部分。\n写给指导教师们:可以基千本书的课程\n指导教师可以使用本书来讲授五种不同类型的系统课程(见图 2 ) 。具体每门课程则有\nXIX\n赖于课程大纲的要求、个人喜好、学生的背景和能力。图中的课程从左往右越来越强调以 程序员的角度来看待系统。以下是简单的描述。\nORG: 一门以非传统风格讲述传统 主题的计算机组成原理课程。传统的 主题包括逻辑设计、处理器体系结构、汇编语言和存储器系统, 然而这里更多地强调了对程序员的影响。例如, 要反过来考虑数据表示对 C 语言程序的数据类型 和操作的影响。又例如 , 对汇编代码的讲解是基于 C 语言编译器产生的机器代码, 而不是手工编写的汇编代码。\nORG+ : 一门 特别强调硬件对应用程序性能影响的 ORG 课程。和 ORG 课程相比, 学生要更多地学习代码优化和改进 C 语言程序的 内存性能。\nICS: 基本的 ICS 课程,旨 在培养一类程序员, 他们能够理解硬件、操作系统和编译系统对应用程序的性能和正确性的影响。和 ORG+ 课程的一个显著不同是, 本课程不涉及低层次的处理器体系结构。相反, 程序员只同现代乱序处理器的高级模型打交道。ICS 课程非常适合安排到一个 10 周的小学期, 如果期望步调更从容一些,也 可以延长到一个 15 周的学期。\nICS+ : 在基本的 ICS 课程基础上, 额外论述一些系统编程的问 题, 比 如系统级1/ 0 、网络编程和并发编程。这是卡内基-梅隆大学的一门一学期时长的课程, 会讲述本书中除了低级处理器体系结构以外的所有章 。\nSP: 一门系统编程课程。和 res + 课程相似, 但是剔除了浮点 和性能优化的内容,\n更加强调系统编程, 包括进程控制、动态链 接、系统级 1/0 、网络编程和并发编程。指导教师可能会想从其他渠道对某些高级主题做些补充, 比如守护进程( dae m o n ) 、终端控制和 Unix IPC( 进程间通信)。\n图 2 要表达的主要信息是本书给了学生和指导教师多种选择。如果你希望学生更多地\n图 2 五类基千本书的课程\n注: 符 号0 表 示覆 盖部 分章 节 , 其中: ( a) 只 有 硬 件 ; Cb) 无动 态存储 分配; ( c) 无动态链 接 ; Cd) 无孚点 数 。\nJCS+ 是卡内基-梅隆的 15-213 课 程 。\nxx\n了解低层次的处理器体系结 构,那 么 通过 ORG 和 ORG十课程可以达到目的。另一方面, 如果你想将当前的计算机组成原理课程转换成 ICS 或者 ICS+ 课程, 但是又对突然做这样剧烈的变化感到担心, 那么你可以 逐步递增转向 JCS 课程。你可以从 OGR 课程开始,它以一种非传统的方式教授传统的问题。一旦你对这些内容感到驾轻就熟了,就可以转到\nORG+, 最终转到 JCS。如果学生没有 C 语言的经验(比如他们只用 J ava 编写过程序), 你可以 花几周的时间在 C 语言上, 然后再讲述 ORG 或者 JCS 课程的内容。\n最后,我们 认为 ORG + 和 SP 课程适合安排为两期(两个小学期或者两个学 期)。或者你可以考虑按照一期 ICS 和一期 SP 的方式来教授 JCS+ 课程。\n写给指导教师们:经过课堂验证的实验练习 # JCS+ 课程在卡内基-梅隆大学得到了学生很高的评价。学生对这门课程的 评价,中 值分 数 一 般为 5. 0/ 5. 0 , 平均分数一般为 4. 6 / 5. 0。学生们说这门课非常有趣, 令人兴奋: 主要就是因为相关的实验练习。这些实验练习可以从 CS: APP 的主页上获得。下面是本书提供的一些实验的示例。\n数据实验。这个实验要求学生实现简单的逻辑和算术运算函数, 但是只能使用一个非常有限的 C 语言子集。比如,只 能用位级操作来计算一个数字的绝对值。这个 实验可帮助学生了解 C 语言数据类型的位级表示,以 及 数 据 操 作 的位级行为。\n二进制炸 弹实验。二进制 炸 弹是一个作为目标代码文件提供给学生的程序。运行时,它 提示用户输入 6 个不同的字符串。如果其中的任何一个不正确, 炸弹就会\n“爆炸",打印出一条错误消息,并且在一个打分服务器上记录事件日志。学生必须 通过对程序反 汇编和逆向工程来测定应该是哪 6 个串,从 而解除各自炸弹的 雷管。该实验能教会学生理解汇编语言,并且强制他们学习怎样使用调试器。\n缓冲区溢出实验。它要求学生通过利用一个缓冲区溢出涌洞,来修改一个二进制可 执行文件的运行时行为。这个实验可教会学生栈的原理,并让他们了解写那种易于 遭受缓冲区溢出攻击的代码的危险性。\n体系结 构实验。第 4 章的儿个家庭作业能够组合成一个实验作业, 在实验中,学 生修改处理器的 HCL 描述,增 加新的指令, 修改分支预测策略, 或者增加、删除 旁路路径和寄存器端口。修改后的处理器能够被模拟,并通过运行自动化测试检测出 大多数可能的错误。这个实验使学生能够体验处理器设计中令人激动的部分,而不 需要掌握逻辑设计和硬件描述语言的完整知识。\n性能实验。学生必须优化应用程序的核心函数(比如卷积积分或矩阵转置)的性能。这 个实验可非常清晰地表明高速缓存的特性,并带给学生低级程序优化的经验。\nXXI\ncache 实验。这个实验类似于性能实验,学 生编写一个通用高速缓存模拟器,并 优化小型矩阵转置核心函数,以最小化对模拟的高速缓存的不命中次数。我们使用 Valg r ind 为矩阵转置核心函数生成真实的地址访问记录。 shell 实验。学生实现他们自己的带有作业控制的 U nix s hell 程序, 包括 Ct rl + C 和Ctrl + Z 按键, f g 、 b g 和 j ob s 命令。这是学生第一次接触并发,并 且 让 他 们 对U nix 的 进程控制、信号和信号处理有清晰的了解。 ma l l o c 实验。学生实现他们自己的 ma l l o c 、 f r e e 和 r e a l l oc ( 可选)版本。这个实验可让学生们清晰地理解数据的布局和组织,并且要求他们评估时间和空间效率 的各种权衡及折中。 代理实验。实现一个位千浏览器和万维网其他部分之间的并行 Web 代理。这个实验向学生们揭示了 Web 客户端和服务器这样的主题,并 且把课程中的许多概念联系起来, 比如字节排序、文件 I/ 0 、进程控制、信号、信号处理、内存映射、套接字和并发。学生很高兴能够看到他们的程序在真实的 Web 浏览器和 Web 服务器之间起到的作用。 CS : A P P 的教师手册中有对实验的详细讨论, 还有关千下载支待软件的说明。\n第 3 版的致谢\n很荣幸在此感谢那些帮助我们完成本书第 3 版的人们。\n. 我们要感谢卡内基-梅隆大学的同事们, 他们已经教授了 ICS 课程多年,并 提 供 了 富有见解的反馈意见,给了我们极大的鼓励: Guy Blell och 、Roger Dan nen ber g、David Eck­\nhardt 、F ra nz F ra nche tt i、G reg Ga nger 、Set h Golds tein 、Khaled Harr as 、G reg Kesde n、\nBruce Maggs 、T odd Mowr y、And reas Nowatzyk 、F ra nk P fen ning、Mark us P ueschel 和\nAnthony Rowe。David Winters 在安装和配置参考 Linux 机器方面给予了我们很大的帮助。\nJason Frit ts ( 圣路易斯大学, S t. Louis Universit y ) 和 Cind y Norris(阿帕拉契州立大学, A ppalach ian S tat e ) 对第 2 版提供了细致周密的评论。龚奕利(武汉大学, W uha n Uni­ vers it y) 翻译了中文版,并 为其维护勘误,同 时 还贡献了一些错误报告。God mar Back(弗吉尼亚理工大学, V ir gi nia T e ch ) 向我们介绍了异步信号安全以及与协议无关的网络编程, 帮助我 们显著提升了本书质量。\n非常感谢目光敏锐的读者们,他 们报告了第 2 版中的错误: Rami Ammari、 P a ul A n­ ag nost opo ulos 、L ucas Baren fanger 、Godm ar Back、Ji Bin、S har bel Bousemaa n、Rich a r d Callaha n、Set h Chaiken 、Cheng Chen 、Libo C hen 、T ao D u、Pascal Garcia 、Y山 Go ng、\nXXII\nRonald G re e n berg 、Doru khan Guloz 、Do ng H an 、Dominik H elm 、Ronald J o nes 、M us ta­ fa Kazdagli、 Go r don Kindlma nn 、Sa nkar Kris h nan、Kana k Ks het ri 、J unlin Lu、 Q ian­ gqiang Luo 、Se bas t ia n L uy 、Lei Ma 、As hw in Nanja ppa 、G regoire Para dis 、 J o n as Pfen­\nninger 、Karl P icho t t a、 Da vid Rams ey、Ka us ta bh Ro y、 David Selva ra j、 S a nkar Shan­ mugam 、Dom inique S mulko ws ka 、Dag S0r b0、Michael S pear 、Y u T a naka 、Steven Tri­ canowic z、Scott W rig h t、Wa如 Wrig ht 、 H an X u 、 Zhengs han Yan 、F iro Ya ng、Sh uang Ya ng 、J o hn Ye、T ak eto Yos hida 、Ya n Zh u 和 M icha el Zin k。\n还要感谢对实验做出贡献的读者,他们是: Godmar Back( 弗吉尼亚理工大学, V ir ­ ginia Tech ) 、T aymo n Beal ( 伍斯 特理 工学 院, Worces ter Polytechnic Instit ute ) 、 A ran Cla us o n ( 西 华 盛 顿 大 学, Wes te rn Washington Univer sit y ) 、Ca ry Gray ( 威 顿 学 院, W heaton College ) 、 P a ul H aid uk C 德州农机大学, W es t T e xa s A\u0026amp;M U niversit y ) 、 Len H a mey( 麦考瑞大学 , Macq uar ie U nivers it y) 、Edd ie K oh ler ( 哈佛大学, H a rvard ) 、H ug h L a uer ( 伍斯特理工学院, W o r ces ter Pol ytechnic Ins tit ute ) 、 Ro be rt Marmorst ein( 朗沃德大学, L o ng woo d U nivers it y) 和 James Riely ( 德保罗大学 , D e P a ul U niver si t y) 。\n再次感谢 Wind fall 软件公司的 Pa ul A nag nos to po ulo s 在本书排版和先进的制作过程中所做的精湛工作。非常感谢 Pa ul 和他的优秀团队 : Ric hard Camp( 文字编辑)、J enn ifer M c C lain ( 校对)、La ur e l Mull er ( 美术制作)以及 T ed La ux ( 索引 制作)。Pa ul 甚至找出 了我们对缩写 BSS 的起源描述中的一个错误, 这个错误从第 1 版起一直没有被发现!\n最后, 我们要感谢 P ren tice H all 出版社的朋友们。Marcia H or to n 和我们的编辑 Matt Golds tein 一直坚定不移地给予我们支持和鼓励, 非常感谢他们。\n第 2 版的致谢\n我们深深地感谢那些帮助我们写出 CS : AP P 第 2 版的人们。\n首先, 我们要感谢在卡内基-梅隆大学教授 ICS 课程的同事们,感 谢 你 们见解深刻的反馈 意 见 和鼓 励: Guy B lelloch 、 R og er Dan nenberg、David E ckhard t、Greg Ganger 、Seth Golds tein 、G reg Ke s de n、Bru ce Maggs 、T odd Mow ry、A nd reas Nowatzyk 、F ra n k P fenni ng 和 Mark us P ues ch el。\n还要感谢报告第 1 版勘误的目光敏锐的读者们: Daniel Amelang、Rui Baptista 、 Q uaru p\nBarreirinhas 、Michael Bombyk 、Jorg Brauer、Jordan Brough、Yixin Cao、James Caroll、Rui Car­\nvalho、H young-Kee Choi、 Al Davis、Grant Davis 、Christian Dufour、Mao Fan、飞m Freeman、Inge Fr ic k 、 Max Gebhardt、Jeff Goldblat 、T homas Gross 、Anita G upta、John Hampton、Hiep\nXXIII\nHong、Greg Israelsen、Ronald Jo nes、Haudy Kazemi、Brian Kell、Constantine Kousoulis、Sacha\nKrakowiak 、Arun Krishnaswamy 、Martin Kulas 、Michael Li、Zeyang Li、Ricky Liu 、Mario Lo\nConte、Dirk Maas、Devon Macey、Carl Marcinik、W让I Marrero、Simone Martins 、Tao Men、Mark Morrissey、Venkata Naidu、Bhas Nalabothula、T homas Niemann、Eric Peski n、David Po、Anne Rogers、John Ross、Michael Scott、Se如、Ray Sh巾、 Darre n Shultz、Erik Silkensen、S ury­\nanto、Emil Tarazi、 Nawanan T heera- Ampornpunt、Joe Trdinich 、Michael Trigobo ff 、 Ja mes\nTroup、Martin Vopatek、Alan West、Betsy Wolff 、 T im Wong、James Woodruff 、Scott Wright 、\nJackie 沁ao 、Guanpeng Xu、Qing Xu、Caren Yang、Yin Yongsheng 、Wang Yuanxuan、Steven\nZhang 和 Day Zhong。特别感谢 Inge Frick, 他发现了我们加锁复制Clo ck-and-copy)例子中一个极不明显但很深刻的错误, 还要特别感谢 Ricky Liu, 他的校对水平真的很高。\n我们 Int el 实验室的同事 And rew Chien 和 Limor F ix 在本书的写作过程中一直非常支持。非常感谢 S teve Schlosser 提供了一些关于磁盘驱动器的总结描述, Case y H elfr ich 和Michael Ryan 安装并维护了新的 Core i7 机器。Michael Kozuch 、 Ba bu P illai 和 J aso n Ca mpbell 对存储器系统性能、多核系统和能量墙问题提出了很有价值的见解。P hil Gib­\nbons 和 S himin Chen 跟我们分享了大显关于固态硬盘设计的专业知识。\n我们还有机会邀请了 Wen- Mei H w u、M ark us P ueschel 和 J iri S imsa 这样的高人给予了一些针对具体问题的意见和高层次的建议。James Hoe 帮助我们写了 Y86 处 理 器的Ver ilog 描述, 还完成了所有将设计合成到可运行的硬件上的工作。\n非常感谢审阅本书草稿的同事们: James Archibal d( 百翰杨大学, Br igham Young Univer­\nsity) 、Richard Carver( 乔治梅森大学, G eorge Mason Universit y) 、Mirela Damian(维拉诺瓦大学, Vi llanova U niversity) 、Peter Dinda( 西北大学)、John Fiore( 坦普尔大学, Te mple U niver ­\nsity) 、J ason Fritts ( 圣路易斯大学, S t. Louis Universit y) 、Jo hn Greiner( 莱斯大学)、Bria n Har­\nvey( 加州大学伯克利分校)、Don Heller (宾夕法利亚州立大学)、Wei Chung Hsu(明尼苏达大学)、M呻 elle H ugue( 马里兰大学)、Jeremy Johnson( 德雷克塞尔大学, Drexel U niversity) 、Geoff Kuenning( 哈维马德学院, H ar vey Mudd College) 、Ricky Liu、Sam Madden(麻省理工学院)、Fred Mart in( 马萨诸塞大学洛厄尔分校, U niversity of Massachusetts, Lowell)、Abraham Matta( 波士顿大学)、Markus Pueschel( 卡内基-梅隆大学)、Norman Ramsey(塔夫茨大学, Tufts Universit y) 、Glenn Reinmann( 加州大学洛杉矶分校)、Michela Taufer (特拉华大学, University of Delaware) 和 Craig Zilles ( 伊利诺伊大学香嫔分校)。\nWind fall 软件公司的 Paul A nag nos topoulos 出色地完成了本书的排版,并 领 导 了 制 作团队。非常感谢 Paul 和他超棒的团队: Rick Camp ( 文字编辑)、J oe Snowden(排版)、\nXXIV\nMaryEllen N. Oliver (校对)、Laurel Muller ( 美术)和T ed Laux ( 索引 制作)。\n最后, 我们要感谢 P rent ice Hall 出 版社的朋友们。Marcia H orton 总是支持着我们。我们的编辑 Ma tt Goldst ein 由始至终表现出了一流的领导才能。我们由衷地感谢他们的帮助、鼓励和真知灼见。\n第 1 版的致谢\n我们衷心地感谢那些给了 我们中肯批评和鼓励的众多朋友及同事。特别感谢我们 15-\n213 课程的学生们, 他们充满感染力的精力 和热情鞭策我们前行。Nick Carter 和 Vinny F ur ia 无私地提供了他们的 malloc 程序包。\nGuy Blelloch、Greg Kesden、Bruce Maggs 和 T odd Mowr y 己教授此课多个学期, 他们给了我们鼓励并帮助改进课程内容。Her b Der by 提供了早期的精神指导和鼓励。Allan Fis her、Gar t h Gibs on、T homas G ross 、Sat ya 、Peter Stee nk iste 和 H ui Zhang 从一开始就鼓励我们开设 这门课程。Gart h 早期给的建议促使本书的工作得以开展,并 且在 Alla n Fis her 领导的小组的帮助下又细化 和修订了本书的工作。Mar k Stehlik 和 Peter Lee 提供了极大的支持,使 得 这些内容成为本科生课程的 一部分。Greg Kesde n 针对 ICS 在操作系统课程上的影响提供了有益的反馈意见。Greg Ganger 和 J iri Schindler 提供了一些磁盘驱动的描述说明,并 回 答了我们关于现代磁盘的疑问。Tom St riker 向我们展示了存储器山的比喻。James Hoe 在处理器体系结构方面提出了很多有用的建议和反馈。\n有一群特殊的学生极大地帮助我们发展了这门课程的内容, 他们是 Khalil Amiri 、Angela Demke Brown、 Chr is Colohan 、Jason Crawfo rd、 Peter Dinda、J ulio Lo pez、Bruce Lowekam p、Jeff Pierce 、San jay Rao、Balaji Sar peshkar 、Blake Scholl、San jit Ses­ 扣a、Greg Steff an、兀ankai T u、Kip Walker 和 Yinglian X比。尤其是 Chr is Colohan 建立了愉悦的氛围并持续到今天, 还发明了传奇般的“二进制炸弹“,这 是 一个对教授机器语言代码和调试概念非常有用的工具。\nChris Bauer、Ala n Cox 、Peter Dinda 、Sandhya Dwar kadis 、J ohn Greiner 、Bruce Ja­ cob、Barr y J ohn so n、 Don Heller、 Bru ce Lowekamp 、 Gr eg Morriset t 、 Brian No ble、Bobbie Ot hmer 、Bill P ug h、M呻 ael Scott 、Mark S motherman 、G reg Steff an 和 Bob Wier 花费了大量时间阅读此书的早期草稿, 并 给予 我们建议。特别感谢 Pet er Dinda(西北大学)、John Gre iner ( 莱茨大学)、Wei H s u( 明 尼 苏 达大学)、Bruce Lowekam p( 威廉 & 玛丽大学)、Bobbie O th mer ( 明尼苏达大学)、Michael Scott( 罗彻斯特大学)和Bob Wier ( 落基山学院)在教学中测试此书的试用版。同样特别感谢他们的学生们!\nXXV\n我们还要 感谢 Prentice H all 出版社的同事。感谢 Marcia H or ton 、 Eric Frank 和 H ar­ old Stone 不懈的支持和远见。Haro ld 还帮我们 提供了对 RISC 和 CISC 处理器体系结构准确的历史 观点。Jerr y Ralya 有惊人的见识, 并教会了我们很多如何写作的知识。\n最后, 我们衷心感谢伟大的技术作家 Brian Ke rnighan 以及后来的 W. Richard Ste­\nvens, 他们向我们证明了技术书籍也能写得如此优美。谢谢你们所有的人。\nRandal E. Bryant David R. O\u0026rsquo; Hallaro n\n于匹兹 堡, 宾 夕 法尼 亚 州\nandal E. Bryant 1973 年于密歇根大学获得学士学位, 随即就读于麻省理工学院研究生院 ,并 在 1981 年获计算机科学博士学位。他\n在加州理工学院做了三年助教,从 1 984 年至今一直是卡内基-梅隆大学\n的教师。这其中有五年的时间,他是计算机科学系主任,有十年的时间 是计算机科学学院院长。他现在是计算机科学学院的院长、教授。他同 时还受邀任职千电子与计算机工程系。\n他教授本科生和研究 生计算机系统 方面的课程近 40 年。在讲授计算机体系结构课程多年后,他开始把关注点从如何设计计算机转移到程序 员如何在更好地了解系统的情况下编写出更有效和更可靠的程序。他和 O \u0026rsquo; H allaro n 教授一起在卡内基-梅隆大学开设 了15- 213 课 程 ”计 算机系统 导论” ,那 便 是 此书的基础。他还教授一些有关算法、编程、计算机网络、分布式系统和 VLSI( 超大规模集成电路)设计方面的课程。\nBr yant 教授的主要研究内容是设计软件工具来帮助软件和硬 件设计者验证其系统正确性。其中,包括几种类型的模拟器,以及用数学方法 来证明设计正确性的形式化验证工具。他发表了 150 多篇技术论文。包括 Intel 、IBM 、Fujits u 和 Microso ft 在内的主要计算机制造商都使用着他的研究成果。他还因他的研究获得过数项大奖。其中包括 Semiconductor Research Corpora tion 颁发的两个发明荣誉奖和一个技术成就奖 , ACM 颁发的 Kane llakis 理 论 与 实践 奖, 还 有 IE EE 颁发 的 W. R. G. Baker 奖、Emmanuel Piore 奖和 P hil Kau fman 奖。他还是 ACM 院士、 IEEE 院士、美国国家工程院院士和美国人文与科学研究院院士。\nDavid R. O\u0026rsquo; Halla ron 卡内基-梅隆大学计算机科学和电子与计 算机工程系教授 。在弗 吉尼亚大学获得计算机科学博士学位, 2007 ~ 2010 年为In tel 匹兹堡实验室主任。\n20 年来 , 他教授本科生和研究生计算机系统 方面的课程,例如 计 算机体系结构、计算机系统导论、并行处理器设计和 Internet 服务。他和 Bry­\nant 教授一起在卡内基-梅隆大学开设了作为本 书基础的 ”计 算机系统导论” 课程。2004 年他获得了卡内基-梅隆大学计算机科学学院颁发的 Her­ bert Simon 杰出教学奖, 这个奖项的获得者是基千学生的 投票产生的。\nXXVII # O\u0026rsquo; Hallaro n 教授从事计算机系统领域的研究, 主要兴趣在于科学计算、数据密集型计算和虚拟化方 面的软件系统 。其中最著名的是 Q ua ke 项目,该 项目是一群计算机科学家、土木工程师和地震学 家为提高对强烈 地震中大地运动的预测能力而开发的。2003 年, 他同 Q ua ke 项目中其他成员 一起获得了高性能计算领域中的最高国际奖项—- Gordon Bell 奖。他目前的工作重点是自动分 级( autogra ding ) 概念,即 评价其他程序质量的程序。\n目录 # 出版者的话中文版序— 中文版序二译者序\n前言关于作者\n第 1 章 计算机 系统漫游 1\n1.1 信息就是位 十上下文 1\n1. 2 程序被其他程序翻译成不同的\n格式 3\n1. 3 了解 编译 系统 如何 工作 是\n大有益处的. 4\n处理器读并解释储存在内存\n中的指令. 5\n4. 1 系统的硬件组成 5\n1. 4. 2 运 行 he ll o 程序 7\n1.5 高速缓存至关重要 ......… 9\n1.6 存储设备形成层次结构 9\n7 操作系统管理硬件 1 0\n1. 7. 1 进程. 11\n1. 7. 2 线程. 1 2\n1. 7. 3 虚拟内存. 12\n1. 7. 4 文件. 14\n系统 之间 利用网络通信 1 4 重要主题 16 9. 1 A mda hl 定律 1 6\n1. 9. 2 并发和并行. 1 7\n9. 3 计 算机 系统 中抽 象的\n重要性. 19\n1. 1 0 小结. 20\n参 考文献说明 20\n练习题答案 20\n第一部分 # 程序结构和执行 # 第 2 章 信息的表示和处理 22\n1 信息存储 24\n2. 1. 1 十六进 制表示 法 25\n2. 1. 2 宇 数 据 大小 27\n2. 1. 3 寻址和宇节顺序 29\n2. 1. 4 表示宇符 串 34\n2. 1. 5 表示代码 34\n2. 1. 6 布 尔代数简 介 35\nXXIX # 1. 7 C 语言中的位级运算 … … … 37\n2. 1. 8 C 语 言中的逻辑运算 … … … 39\n2. 1. 9 C 语 言中的移位运算 40\n2. 2 整数表示 4.1\n2. 2. 1 整 型数据类型 42\n2. 2. 2 无符号数的编码. 43\n2. 2. 3 补码 编码 44\n2. 2. 4 有符号数和无符号数之间的\n转换 49\n2. 2. 5 C 语 言中的 有符号数与\n无符号数. 52\n2. 2. 6 扩展 一个数宇的位表示 … … 54\n2. 2. 7 截 断数 宇 56\n2. 2. 8 关于有符号数与无符号数的\n建议. 58\n3 整数运 算 60\n2. 3. 1 无符号加法. 60\n2. 3. 2 补码加 法 62\n2. 3. 3 才卜码 的非 66\n2. 3. 4 无符号乘法\u0026hellip;\u0026hellip;······\u0026quot; 67\n2. 3. 5 补码乘法. 67\n2. 3. 6 乘以常数. 70\n2. 3. 7 除 以 2 的幕 71\n3 . 8 关于整数 运算的最后思考… … 74\n3. 2. 3 关于格式的注解 ....….….117\n3. 3 数据格式 .·. ..·..·\u0026hellip;·..···..· 119\n3.4 访问 信 息 ···..·\u0026hellip;\u0026hellip; ·..······..··.. 119\n3. 4. 1 操作数指示符 121\n3. 4. 2 数据传送指令 122\n3. 4. 3 数据传送示例 125\n3. 4. 4 压入和弹出栈数据 …… … 127\n5 算术和逻辑操作 128\n3. 5. 1 加栽有效地址 1.29\n3. 5. 2 一 元和二元操作 130\n3. 5. 3 移位操作 1.31\n3. 5. 4 讨论..····..·..·\u0026hellip;·..·..·..·.. 131\n3. 5 . 5 特殊的算术操作 133\n3. 6 控 制 135\n3. 6. 1 条件码·\u0026hellip;.·..·\u0026hellip;\u0026hellip;\u0026hellip;·. 135\n3. 6. 2 访问条件码 136\n3. 6. 3 跳 转指令 138\n3. 6. 4 跳转指令的编码 1 39\n3. 6. 5 用条件控制来 实现 条件分支 … 1 41\n3. 6. 6 用条件 传送来实现 条件分支 … 145 3. 6. 7 循环 1.49\n3. 6. 8 switch 语 句 ..·..·\u0026hellip;.. ···\u0026hellip; 159\n3. 7 过程. 164\n3 . 7. 1 运 行 时栈 164\n2.4 浮点数 \u0026hellip;\u0026hellip;\u0026hellip;\u0026hellip;\u0026hellip;\u0026hellip;\u0026hellip;\u0026hellip;\u0026hellip;\u0026hellip;. 75 3. 7. 2 转移控制 \u0026hellip;\u0026hellip;\u0026hellip;\u0026hellip;\u0026hellip;\u0026hellip; 165 2. 4. 1 二 进 制 小数 \u0026hellip;\u0026hellip;\u0026hellip;\u0026hellip; \u0026hellip;\u0026hellip;.. 76 3. 7. 3 数据传送 \u0026hellip;\u0026hellip;\u0026hellip;..\u0026hellip;\u0026hellip;\u0026hellip; 168 2. 4. 2 IEEE 浮点表 示 78\n2. 4. 3 数 字示例 79\n2. 4. 4 舍入. 83\n2. 4. 5 浮点运 算 85\n4. 6 C 语 言 中的 浮点数 86\n2. 5 小结 87\n参考文献说明 88\n家 庭作业 \u0026hellip;\u0026hellip;\u0026hellip;..· 88\n练习题答案 9.7\n7. 4 栈上的局部存储 170\n3. 7. 5 寄存器中的局部存储空间 … 172 3. 7. 6 递归过程. 174\n3. 8 数组分配和访问 1.76\n3. 8. 1 基本原则 176\n3. 8. 2 指针运 算 177\n3. 8. 3 嵌 套 的数 组 178\n3. 8. 4 定长数组. 179\n3. 8 . 5 变长 数组 1.8.1.\n3. 9 异质的数据结构 183\n第 3 章 程序的机器级表示…\u0026hellip;·..···\u0026hellip; 1 09\n3. 1 历史观点 110\n3. 2 程序编码 113\n3. 2. 1 机器级代码 1 13\n3. 2. 2 代码示例 \u0026hellip;·..·..·..·\u0026hellip;\u0026hellip;.· 11 4\n3 . 9. 1 结构. 183\n3 . 9. 2 联合. 1 8 6\n3. 9. 3 数据 对 齐 189\n3. 10 在机器级程序中将控制与\n数据结合起来 1.9.2\nXXX # 3. 10. 1 理解指针. 192\n3. 10. 2 应用: 使 用 GDB调试器 … 193\n3. 10. 3 内存越界引用和缓冲区\n溢出. 194\n3. 10. 4 对抗缓 冲 区 溢 出攻 击 … … 198 3. 10. 5 支持变长栈帧. 201\n3. 11 浮点代码 204\n11. 1 浮点传送 和转换操作 … … 205\n4.4 流水线的通用原理 282\n4. 4. 1 计算流水线 282\n4. 2 流水线操作的详细说明 … 284 4. 4. 3 流水线的局限性 284\n4. 4. 4 带反馈的流水线系统 287\n4. 5 Y86- 64 的 流 水 线 实 现 288\n4. 5. 1 SEQ + : 重新安排计算\n阶段 288\n3 . 1 2 小结 216\n参考文献说 明 216\n家庭作业 2.16\n练习题 答案 226\n第 4 章 处理器体系结构 243\n4. 1 Y86-64 指 令 集体 系结构 … … … 245\n4. 1. 1 程序员可见的状态 ……… 245\n4. 1. 2 Y86-64 指令 \u0026hellip;\u0026hellip;\u0026hellip;\u0026hellip;\u0026hellip;2..4.5\n4. 1. 3 指令编码. 246\n4. 1. 4 Y86-64 异常 250\n4. 1. 5 Y86-64 程序 ..·. \u0026hellip;·. ..·. 251\n4. 1. 6 一些 Y86-64指令的详情 … … 255\n4.2 逻辑设计和硬件控制语言 HCL … 256 4. 2. 1 逻辑门. 257\n4. 2. 2 组合电路和 HCL 布 尔\n表 达 式 257\n4. 2. 3 宇级的 组合 电路和 HCL\n整数表达式 258\n4. 2. 4 集合关系 261\n4. 2. 5 存储器和时钟 262\n4. 3 Y86-64 的 顺 序实现 2.6.4\n4. 3. 1 将处理组织成阶段 ……… 264\n4. 3. 2 SEQ 硬件结构 272\n4. 3. 3 SEQ 的 时序 274\n4. 3. 4 SEQ 阶段 的 实现 ......... 2…77\n5 . 8 流水线控制逻辑 314\n4. 5. 9 性能分析. 322\n4. 5. 10 未完成的工作 323\n4 . 6 ; \\j 结 325\n参考文献说明 326\n家庭作业 327\n练习题 答案 331\n第 5 章 优化程序性能 … … … … … 3 41\n1 优化编译器的能力和局限性 \u0026hellip; 342 5. 2 表示程序性 能 345\n5. 3 程序示例 347\n5. 4 消除循环的低效率 350\n5. 5 减 少过程调用 353\n5. 6 消除不必要的内存引用 354\n5. 7 理解现代处理器 357\n5. 7. 1 整体操作 357\n5. 7. 2 功能单元的性能 361\n5. 7. 3 处理器操作的抽象模型 … 362 5. 8 循环展开 366\n5. 9 提高并行性 3.6.9.\n5. 9. 1 多个累积变量 3.70\n5. 9. 2 重新结合变换 373\n5. 10 优化合并代码的结果小结 377\n5. 11 一 些限制 因素 378\n5. 11. 1 寄存 器溢出 378\nXXXI\n5. 11. 2 分 支预 测和预 测错 误\n处罚 3 79\n5. 12 理 解内存性能 382\n5. 12. 1 加 载的性能 382\n5. 12. 2 存 储 的性 能 383\n5. 13 应用:性 能 提高技术 387\n14 确认和消除性能瓶颈 388\n5. 14. 1 程序剖析 388\n14. 2 使用剖析程序来指导\n3 存储器层次结构 421\n6. 3. 1 存储器层 次 结构中的缓存 … 422\n6. 3. 2 存储器层 次 结构概 念小结 … 424 6. 4 高速缓存存储器 425\n6. 4. 1 通用的 高速缓存存储 器\n组织结构 42 5\n6. 4. 2 直接映射高速缓存 427\n6. 4. 3 组相联高速缓存 433\n6. 4. 4 全相联高速缓存 434\n练习题答案\u0026hellip; .. . \u0026hellip; \u0026hellip; \u0026hellip; \u0026hellip; \u0026hellip; \u0026hellip; .. . .. . .. . 39 ;:i\n第 6 章 存 储 谣 层 次 结 构 399\n6. 1 存储技术 399\n6. 1. 1 随机访问存储 器 40 0\n6. 1. 2 磁 盘 存 储 .··· 406\n6. 1. 3 固态硬盘 414\n6. 1. 4 存储技术趋势 \u0026hellip;\u0026hellip;\u0026hellip;..·..· 415\n6. 2 局 部 性 . .. . .. . .. . .. .. . .. . . . · .. . .. · .. . 4 1 8\n6. 2. 1 对程序数据引用的 局部性 … 41 8 6. 2. 2 取 指 令 的局部性 419\n6. 2. 3 局部性小结 420\n5 编写高速缓存友好的代码 … … 440\n6. 6 综合 : 高 速缓存对程序性能的\n影响 444\n6. 6. 1 存储器 山 444\n6. 6. 2 重新排列循环以提高空间\n局部性 447\n6. 3 在程序中利 用局 部 性 450\n6. 7 小结 4.50\u0026hellip;.\n参考文献说 明 45 1\n家庭作业 45 1\n练习题答案 459\n第二部 分 # 在系统上运行程序 # 第 7 章 链接 464\n1 编译器驱动程序 465\n7. 2 静态链接 466\n7. 3 目标文件 466\n7. 4 可重定位目标文件 4 67\n7. 5 符号和符号表 468\n7. 6 符号解析 470\n7. 6. 1 链接器如何解析多重定义\n的全局符号 471\n7. 6. 2 与静 态库 链 接 475\n7. 7 重定位 478\n7. 7. 1 重定位条 目 479\n7. 7. 2 重定位符号引用 .... 479\n7. 8 可执行目标文件 483\n7. 9 加载可执行目标文件 484\n7. 10 动态链接共享库 485\n7. 11 从应用程序中加载和链接\n共享库 487\n7. 12 位置无关代码 489\n7. 13 库打桩机制 492\n7. 6. 3 链接器如何使用静态库来\n7. 13. 1 编译 时打桩 \u0026hellip;\u0026hellip;\u0026hellip;\u0026hellip;\u0026hellip;.\n492\n解析弓l 用 477\n7. 13. 2 链 接 时打 桩 \u0026hellip;\u0026hellip;\u0026hellip;\u0026hellip;\u0026hellip;_\u0026hellip; 492\nXXXII # 7. 13. 3 运行时打桩. 494\n14 处理目标文件的工具. 496\n7. 15 小结. 496\n参考文献说 明 497\n家庭作业 497\n练习题答案. 499\n第 8 章 异常控制 流 50 1\n8. 1 异常. 502\n8. 1. 1 异常处理. 503\n8. 1. 2 异常的类别 504\n1. 3 Linux / x86-64 系统 中的\n异常. 505\n8. 2 进程. 508\n8. 2. 1 逻样控制流 508\n8. 2. 2 并发流. 509\n8.2. 3 私有地址空间 509\n8. 2. 4 用户模式和内核模式 …… 510 8. 2. 5 上下文切换 511\n8. 3 系统调用错误处理 512\n8. 4 进程控制. 513\n8. 4. 1 获 取 进 程 ID 513\n8. 4.2 创建和终止进程 513\n8. 4. 3 回收子进程 516\n8. 4. 4 让进程休眠. 521\n8. 4. 5 加栽并运行程序 52 1\n8. 4. 6 利 用 f or k 和 e xe cve 运行\n程序. 524\n8. 5 信号 526\n8. 5. 1 信号术语. 527\n8. 5. 2 发送信号. 528\n8. 5. 3 接收信号. 531\n8. 5. 4 阻塞和解除阻塞信号 …… 532 8. 5. 5 编写信号处理程序 ……… 533\n8. 5. 6 同步流以避免讨厌的并发\n错误. 540\n8. 5. 7 显式地等待信号 543\n8. 6 非本地跳转. 546\n7 操作进程的工具… 550\n8. 8 小结. 550\n参考文献说 明 550\n家庭作业 5.5.0..\n练习题答案. 556\n第 9 章 虚拟内存 559\n1 物理和虚拟寻址 560 9. 2 地址空间. 560\n9. 3 虚拟内存作为缓存的工具 …… 561\n9. 3. 1 DRAM 缓存的组织结构 … … 562 9. 3. 2 页表. 562\n9. 3. 3 页命中. 563\n9. 3. 4 缺页. 564\n9. 3. 5 分配页面 \u0026hellip;···.·..·\u0026hellip;· 565\n9. 3. 6 又是局部性救了我们 565\n9. 4 虚拟内存作为内存管理的\n工具 565\n9. 5 虚拟内存作为内存保护的\n工具 5.6.7\u0026hellip;\n9. 6 地址翻译. 567\n9. 6. 1 结合高速缓存和虚拟\n内存. 570\n9. 6. 2 利 用 T LB 加速地址翻译 … … 570 9. 6. 3 多级页表. 571\n9. 6. 4 综合:端到端的地址翻译 … 573\n9. 7 案例研究: Intel Core i7/ Linux\n内存系统. 576\n9. 7. 1 Core i7 地址翻译 5.76\n9. 7. 2 Lin ux 虚拟内存 系统 … … … 580\n9.8 内存映射 582\n9. 8. 1 再看共享对象 583\n9. 8. 2 再 看 f or k 函数 5.84\n9. 8. 3 再 看 e xe c ve 函数. 584\n9. 8. 4 使 用 mma p 函 数 的 用 户级\n内存映射 585\n9. 9 动态内存分配. 587\n9. 9. 1 ma ll o c 和 f r e e 函数 … … 587\n9. 9. 2 为什么要使用动态内存\n分配. 589\n9. 9. 3 分配器的要求和目标 … … 590 9. 9. 4 碎片. 591\n9. 9. 5 实现问题. 592\n9. 9. 6 隐式空闲链表. 592\n9. 9. 7 放置已分配的块 593\n9. 9. 8 分割空闲块 594\n9. 9 获取额外的堆内存 594 XXXIII # Sweep 608\n9.11 C 程序中常见的与内存有关的\n错误 609\n9. 11. 1 间接引用坏指针. 609\n9. 12 小结 613\n参考文献说明 613\n家庭作业 614\n练习题答案. 617\n第三部分 # 程序间的交互和通信 # 第 10 章 系统级 1 /0 622\n10. 1 Unix I/ 0 622\n10. 2 文 件 623\n3 打开和关闭文件. 624\n10. 4 读 和 写 文 件 625\n10. 5 用 RIO 包 健 壮 地读写 626\n10. 5. 1 RIO 的无缓 冲的 输入4俞出\n函数 627\n10. 5. 2 RIO 的带缓 冲的轮入\n函数. 627\n10. 6 读 取 文 件 元 数 据 632\n7 读 取 目 录内容 633\n10. 8 共享文件 634\n10. 9 I/ 0 重定向 637\n第 11 农 网络编程 642\n1 客户端-服务器编程模型 … … 642 11. 2 网络. 643\n11. 3 全球 IP 因特网 646\n11.3.1 IP 地址 647\n11. 3. 2 因 特 网域 名 649\n11. 3. 3 因特 网连 接 651\n11. 4 套 接字接口 652\n11. 4. 1 套接字地 址 结构 653\n11. 4. 2 s oc ke t 函数 654\n11. 4. 3 c onne c t 函数 654\n11. 4. 4 bi nd 函数 654\n11. 4. 5 li s t e n 函数 655\n11. 4. 6 a c c e p七函 数 655\n11. 4. 7 主机和服务的转换 … … … 656\n参考文献说 明 640\n家庭作业 6.40\n练习题答案 641\n11.5.1 Web 基础. 665\n11. 5. 2 Web 内容 666\n11. 5. 3 HTT P 事务 667\nXXXIV\n11. 5. 4 服务动 态内容 669\n6 综合: TINY Web 服务器. 671\n11. 7 小结. 6.7.8 \u0026hellip;..\n12. 4. 1 线程内存模型. 696\n12. 4. 2 将 变 量映射到内存 … … … 697 12. 4. 3 共享变量. 698\n参考文献说明. 678\n家庭作业 678\n练习题答案. 679\n第 1 2 章 并发编程 681\n1 基 千 进程 的并 发 编程 682\n12. 1. 1 基于进程的并发服务器 … 683 12. 1. 2 进程的优劣. 684\n12.2 基千I/0 多路复用的并发\n编程. 684\n12. 2. 1 基于 I/ 0 多 路 复 用的并发\n事件驱动服务器… 686\n12. 2. 2 I/ 0 多路 复 用技 术的优劣 … 690\n12. 3 基于线程的并发编程 691\n12. 3. 1 线程执行模型. 691\n12. 3. 2 Posix 线程 691\n12. 3. 3 创 建线程 6.92\n12. 3.4 终止线程. 693\n12. 3. 5 回收己终止线程的资源 … 693 12. 3. 6 分 离 线程 694\n12. 3. 7 初始化线程. 694\n12. 3. 8 基于线程的并发\n服务器. 694\n12. 4 多线程程序中的共享变批 … … 696\n12. 5 用信号量同步线程 698\n12. 5. 1 进度图. 701\n1 2. 5. 2 信号量 702\n12. 5. 3 使用信号量来实现互斥 … 703\n12. 5. 4 利用信号量来调度共享\n资源. 704\n12. 5. 5 综合:基于预线程化的\n并发服务器. 708\n12. 6 使 用 线 程提高并行性 710\n12. 7 其他并发问题. 716\n12. 7. 1 线程安全 716\n12. 7. 2 可重入性. 717\n12. 7. 3 在线程化的程序中使用\n已存在的库函数 718\n12. 7. 4 竞争. 719\n12. 7. 5 死锁 721\n12. 8 小结. 722\n参考文献说明. 723\n家庭作业 723\n练习题答案. 726\n附录 A 错误处理 729\n参考文献 733\n"},{"id":434,"href":"/zh/docs/technology/System/%E6%B7%B1%E5%85%A5%E7%90%86%E8%A7%A3%E8%AE%A1%E7%AE%97%E6%9C%BA%E7%B3%BB%E7%BB%9F/%E4%B9%A6%E7%B1%8D/%E7%AC%AC10%E7%AB%A0-%E7%B3%BB%E7%BB%9F%E7%BA%A7I_O/","title":"Index","section":"SpringCloud","content":"第 10 章\nC H A P T E R 10\n系统级 1 /0\n输入/扴出(1/0 )是在主存和外部设备(例如磁盘驱动器、终端和网络)之间复制数据的过程。输入操作是从 I/ 0 设备复制数据到主存, 而输出操作是从主存复制数据到1/0 设备。 # 所有语言的运行时系统都 提供执行 1/ 0 的较高级别的工具。例如, ANSI C 提供标准1/ 0 库, 包含像 pr i n t f 和 s c a n f 这样执行带 缓冲区的 I/ 0 函数。C++ 语言用它的重载操作符<<(输入)和>>(输出)提供了类似的功能 。在 Lin ux 系统中, 是通过使用由内核提供的系统级 U nix I/ 0 函数来实现这些较高级别的 I/ 0 函数的。大多数时候,高 级别 1/ 0 函数工作良好, 没有必要直接使用 U nix I/ 0 。那么为什么还要麻烦地学习 U nix 1/ 0 呢?\n了解 Unix 1/ 0 将帮助你理解其他的 系统概念。1/ 0 是系统操作不可或缺的一部分,因此, 我们经常遇到 1/ 0 和其他系统概念之间 的循环依赖。例如, 1/ 0 在进程的创建和执行中扮演着关键的角色。反过来, 进程创建又在不同 进程间的文件共享中扮演着关键角色。因此,要真正理解1/0 , 你必须理解进程,反之亦然。在对存储器层次结构、链接和加载、进程以及虚拟内存的讨论中, 我们已经 接触了 I/ 0 的某些方面。既然你对这些概念有了比较好的理解, 我们就能闭 合这个循环, 更加深入地研究1/0 。 # 有时你除 了使用 U nix 1/ 0 以外别 无选择。在某些重要的情况中, 使用高级 1/ 0 函数不太可能 ,或 者不太合适。例如, 标准 I/ 0 库没有提供读取文件元数据的方式, 例如文件大小或文件创建时间。另外, I / 0 库还存在一些问题,使 得用它来进行网络编程非常冒险。\n这一章介绍 Unix 1/ 0 和标准 I/ 0 的一般概念, 并且向你展示在 C 程序中如何 可靠地使用 它们。除了作为一般 性的介绍之外,这 一章还为我们随后学习网络编程和并发性奠定坚实的基础。\n10 . 1 Unix 1/0\n一个 Linu x 文件就是一个 m 个字节的 序列: # B。, B1, …, B k\u0026rsquo; … , Bm - 1\n所有的 1/0 设备(例如网络、磁盘和终端)都被模型化为文件 , 而所有的输入和输出都被当作对相应文件的读和写来执行。这种将设备优雅地映射为文件的方式,允 许 Lin ux 内核引出一个简单、低级的应用接口, 称为 U nix I/0, 这使得所有的输入和输出都能以一种统一且一致的方式来执行: # 打开文件。一个应用程序通过要求内核打开相应的文件,来宣告它想要访问一个 I/ 0 设备。内核返回一个小的非负整数 ,叫 做 描述符 ,它 在后续对此文件的所有操作中标识这个文件。内核记录有关这个打开文件的所有信息。应用程序只需记住这个描述符。 Linux shell 创建的每个进程开始时都有三个打开的文件: 标准输入(描述符为 0)、标准输出(描述符为1) 和标准错误(描述符为 2) 。头文件\u0026lt; un i s t d . h \u0026gt; 定义了常量 STDIN_ FIL ENO、STDOUT_FIL ENO和 STDERR_ FIL ENO, 它们可用来代替显式的描述符值。 改变当 前的文件位 置。对 于每个打开的文件,内 核保持着一个文件位 置 k , 初始为 # 0。这个文件位置是从文件开头起 始的字节偏移量。应用程序能够通过执行 s ee k 操作, 显式地设置文件的当前位置为 K。\n读写文 件。一个读操作就是 从文件复制 n \u0026gt; O 个字节到内 存, 从当前文件位置 k 开始, 然后将 K 增加到k + n 。给定一个大小为 m 字节的文件 ,当 k;;;:=::m 时执行读操作会触发一个称为 e nd- of-f ile ( EO F ) 的条件, 应用程序能检测到这个条件。在文件结尾处并没有明确的 \u0026quot; EOF 符号”。 类似地, 写操作就是从内存复制 n \u0026gt; O 个字节到一个文件, 从当前文件位置 K # 开始, 然后更新 k 。\n关闭 文件。当应用完成了对 文件的访问之后,它 就通知内核关闭这 个文件。作为响应,内核释放文件打开时创建的数据结构,并将这个描述符恢复到可用的描述符池 中。无论一个进程因为何种原因终止时,内 核都会关闭所有打开的 文件并释放它们的内存资源。 2 文件\n每个 Linu x 文件都有一个类型 ( t y pe) 来表明它在系统中的角色:\n普通文件 ( reg ula r fi le) 包含任意数 据。应用程序常常要 区分文本文件 ( te xt fi le ) 和二进制文件 ( bina r y file) , 文本文件是只含有 A SCII 或 U nicode 字符的普通文件;二进制文件是所有其他的文件。对内核而言,文本文件和二进制文件没有区别。\nLinux 文本文件包含了一个文本行( text line) 序列, 其中每一行都是一个字符序列, 以一个新行符(\u0026quot; \\ n\u0026quot; ) 结束。新行符与ASCII 的换行符CLF ) 是一样的, 其数字值为Ox Oa 。\n目 录( direct or y ) 是包含一组链接 Clink ) 的 文件, 其中每个链接都将一个 文件 名( fi le nam e) 映射到一个文件, 这个文件可能 是另一个目录。 每个目录至 少含有两个条目:“.”是到该目录自身的链接,以及\u0026quot;..\u0026ldquo;是到目录层次结构(见下文)中父目 录( pa ren t director y ) 的链 接。你可以用 mkd ir 命令创建一个目录,用 l s 查看其内容,用 r md i r 删除该目录。\n套接宇( so cket ) 是用来与另一个进程进行 跨网络通信的文件(11. 4 节)。\n其他文件类型包含命名通道( nam ed pipe ) 、符号链 接( s ym bolic link), 以及字符和块\n设备( charact er and block device), 这些不在本书的讨论 范畴。\nLin ux 内核将所有文件都组织成一个目 录层 次结构 ( directo r y hierarchy) , 由名为/(斜杠)的根目 录确定 。系统中的每个文件都是根目录的 直接或间接的后代。图 10-1 显示了Lin u x 系统的目录层次结构的一部分。\ne 七c l\ngroup passwd/ # home /\ndr oh / br yant /\nI\nus r / # i ncl ude / bi n /\nI\nhe l l o. c # stdio. h s ys / vim\nuniIstd.h\n图10-1 Linux 目录层次的一部分。尾部有斜杠表示是目录\n作为其上下文的一部分,每 个 进程都有一个当前工作目 录( c ur r e n t working directory) 来确定其在目录层次结构中的当前位置。你可以 用 c d 命令来修改 s hell 中的当前工作目录。\n目 录层次结构中的位置用路径名( pa t h na m e ) 来指定。路径名是一个字符串,包 括一个\n可选斜杠,其 后 紧跟一系列的文件名,文 件 名 之 间 用 斜 杠 分 隔 。 路 径 名 有 两 种 形 式 : # 绝对路径名 ( a bs ol ut e pa t h na me ) 以一个斜杠开始, 表 示从根节点开始的路径。例如 ,在 图 1 0- 1 中 , h e l l o . c 的 绝 对 路 径 名 为/ h ome / dr o h / h e ll o . c 。 相 对路径名( re la t ive pa t h na me ) 以文件名开始, 表示从当前工作目录开始的路径。例如 ,在 图 1 0-1 中 ,如 果 / h o me / dr o h 是 当前工作目录, 那 么 h e l l o . c 的 相对路径名就是./hello. c。反之, 如果 / h ome / br y a n t 是 当前工作目录, 那 么 相 对路径名就是../ home / dr o h / h e l l o . c 。 3 打开和关闭文件 进程是通过调用 o p e n 函数来打开一个已存在的文件或者创建一个新文件的: # #include \u0026lt;sys/types.h\u0026gt;\n#include \u0026lt;sys/stat.h\u0026gt;\n#include \u0026lt;fcntl.h\u0026gt;\nint open(char *filename, int flags, mode_t mode);\n返回: 若成功则为新文件描述符,若出错 为一 1 。 # op e n 函数将 f i l e name 转换为一个文件描述符,并 且 返 回 描 述 符 数 字 。 返回的描述符总是在进程中当前 没有打开的最小描述符。fl a gs 参数指明了进程打算如何访问这个文件:\nO_RDONLY: 只读。 O_WRONLY: 只写。 O_RDWR: 可读可写。 例如,下 面的代码说明如何以读的方式打开一个已存在的文件: # fd = Dpen(\u0026ldquo;foo.txt\u0026rdquo;, O_RDONLY, O);\nf l a g s 参 数 也 可以 是 一 个 或 者 更 多 位 掩 码 的 或 , 为写提供给一些额外的指示: # O_CREAT: 如果文件不存在,就 创 建 它 的 一 个 截断的( t ru nca t ed )(空)文件。 O_TRUNC: 如果文件已经存在,就截断它。 O_APPEND: 在每次写操作前,设 置文件位置到文件的结尾处。 例如,下面的代码说明的是如何打开一个已存在文件,并在后面添加一些数据: # fd = Open(\u0026ldquo;foo.txt\u0026rdquo;, O_WRONLYID_APPEND, 0);\nmo d e 参 数 指 定 了 新 文件的访问权限位。这些位的符号名字如图 10- 2 所示。\n作为上下文的一部分, 每个进程都有一个 uma s k , 它 是 通 过 调 用 u ma s k 函 数来设置的 。 当 进 程 通过带某个 mo d e 参 数 的 o p e n 函 数 调 用 来 创 建 一 个新文件时, 文 件 的 访问权限 位 被设 置 为 mo d e \u0026amp; ~ u ma s k 。 例 如,假 设 我们给定下面的 mo d e 和 uma s k 默 认值 :\n#define DEF_MODE S_IRUSRIS_IWUSRIS_IRGRPIS_IWGRPIS_IROTHIS_IWOTH\n#define DEF_UMASK S_IWGRPIS_IWOTH\n接下来,下面的代码片段创建一个新文件,文件的拥有者有读写权限,而所有其他的 # 用户都有读权限:\numask(DEF_UMASK);\nfd = Open(\u0026ldquo;foo.txt\u0026rdquo;, O_CREATIO_TRUNCIO_WRONLY, DEF_MODE);\n图 10-2 访问权限位。在 s ys / s t a t . h 中定义\n最后, 进程通过调用 c l o s e 函数关闭一个打开的文件。\n#include \u0026lt;unistd.h\u0026gt;\nint close(int fd);\n返回: 若 成 功 则 为 o, 若 出 错 则 为 一1。\n关闭一个已关闭的描述符会出错。 # _`练习题 10. 1 下面程序的输出是什么?\n1 #include \u0026ldquo;csapp.h\u0026rdquo; 2 . 3 int main() 4 { s int fd1, fd2; 6 7 fd1 = Open(\u0026ldquo;foo.txt\u0026rdquo;, O_RDONLY, 0); 8 Close(fdl); 9 fd2 = Open(\u0026ldquo;baz.txt\u0026rdquo;, O_RDONLY, O); 10 printf(\u0026ldquo;fd2 = %d\\n\u0026rdquo;, fd2); 11 exit(O); 12 } 4 读和写文件\n应用程序是通过分别调用r e a d 和 wr i t e 函数来执行输入和输出的。\n#include \u0026lt;un i s t d . h \u0026gt;\nssize_t read(int fd, void *buf, size_t n);\n返回: 若 成 功 则为读的 字节数 , 若 EOF 则为 o, 若 出错 为 一1。\nssize_t write(int fd, const void *buf, size_t n);\n返回: 若 成 功 则为 写的 字节数 , 若出错 则为 一1。\nr e ad 函数从描述符为 f d 的当前文件位置复制最多 n 个字节到内存位置 bu f 。返回值- 1\n表示一个错误,而返 回值 0 表示 EO F。否则 , 返回值表示的是实际传送的字节数量。 # W豆 t e 函数从内存位置 b uf 复制至多 n 个字节到描述符 f d 的当前文件位置。图 10-3 展\n示了一个程序使用r e a d 和 wr i t e 调用一次一个字节地从标准输 入复制到标准输出。\ncodeliolcpstdin.c # #include \u0026ldquo;csapp. h\u0026rdquo;\n2\n3 int main(void) # 4 {\n5 char c;\n6\n7 while(Read(STDIN_FILENO, \u0026amp;c, 1) != 0)\n8 Write(STDOUT_FILENO, \u0026amp;c, 1); # 9 exit(O);\n10 }\ncode/io/cpstdin.c # 图 10-3 一次一个字节地从标准输入复制到标准输出\n通过调用 l s e e k 函数, 应用程序能够显示地修改当前文件的位置, 这部分内容不在我们的讲述范围之内。 # 田日ss ize _t 和 s ize _t 有些什么区别?\n你可能 已经 注意到 了, r e a d 函数有一个 s i z e _ t 的输入参数和一个 s s i ze _ t 的返回值。那么这两种类 型之 间 有什 么区 别呢?在 x8 6-64 系统 中,s i ze _ 七被定义为 un ­ signed long, 而 s s i z e _ t ( 有符号的 大小)被定义为 l o ng 。r e a d 函数返回一个有符号的大小, 而不是 一个无符号 大小,这是 因 为 出错时它必须返 回 一1 。 有趣的是, 返回一个— 1 的可能性使得 r e a d 的最大值 减小 了一半。\n在某些情况下 ,r e a d 和 wr i t e 传送的字节比应用程序要求的要少。这些不足 值( short cou nt ) 不表示有错误 。出现这样情况的原 因有:\n读时遇到 E O F 。假设 我们准备读一个文件,该 文件从当前文件位置开始只含有 20 多个字节, 而我们以 50 个字节的 片进行读取。这样一来,下 一个r e a d 返回的不足值为 20 , 此后的r e a d 将通过返回不足值 0 来发出 E O F 信号。 从终端读文本行。如果打开文件是与终端相关联的(如键盘和显示器),那么每个 # r e a d 函数将一次传送一个 文本行,返 回的不足值等于文本行的大小。\n读和写网络套接 字 ( sock et ) 。如果打开的 文件对应于网络套接字( 11. 4 节), 那么内部缓冲约束 和较长的网络延迟会引起r e a d 和 wr i t e 返回不足值。对 Lin ux 管道 ( pipe) 调用r e a d 和 wr i t e 时,也 有可能 出现不足值, 这种进程间 通信机制不在我们讨论的范围之内。 实际上, 除了 EO F , 当你在读磁盘文件时,将不会遇到不足值,而且在写磁盘文件时, 也不会遇到不足值。然而,如果你想创建健壮的(可靠的)诸如 Web 服务器这样的网络应用, 就必须通过反复调用 r e ad 和 wr i t e 处理不足值, 直到所有需要的字节都传送完毕。 # 10. 5 用 RIO 包健壮地读写\n在这一小 节里, 我们会讲述一个 1/0 包, 称为 R IO ( Robus t 1/ 0 , 健壮的 1/0 ) 包, 它\n会自动为你处理上文中所述的不足值。在像网络程序这样容易出现不足值的应用中, RIO # 包提供了方便、健壮和高效的 I/ 0 。RIO 提供了两类不同的函数:\n无缓冲的输入输出函数。这些函数直接在内存和文件之间传送数据,没有应用级缓冲。它们对将二进制数据读写到网络和从网络读写二进制数据尤其有用。 # 带缓冲的输入函数。这些函数允许你高效地从文件中读取文本行和二进制数据,这 些文件的内容缓存在应用级缓冲区内, 类似千为 pr i n t f 这样的标准 I/ 0 函数提供的缓冲区。与[ ll O] 中 讲 述 的 带 缓 冲的 I/ 0 例程不同,带 缓 冲的 RIO 输入函数是线程安全的(1 2. 7. 1 节),它在同一个描述符上可以被交错地调用。例如,你 可以从一个描述符中读一些文本行, 然后读取一些二进制数据,接 着 再 多 读取一些文本行。 我们讲述 RIO 例程有两个原因。第一,在接 下 来的两章中, 我们开发的网络应用中使用了它们;第 二 ,通 过学 习 这 些 例 程 的 代码,你 将 从 总体 上 对 Unix I/ 0 有更深入的了解。 # 10. 5. 1 R IO 的 无 缓 冲 的 输 入 输 出 函 数\n通过调用r i o_r ea dn 和r i o_wr i t e n 函数 , 应用程序可以在内存和文件之间直接传送数据。\n#include \u0026ldquo;csapp.h\u0026rdquo;\nssize_t rio_readn(int fd, void *usrbuf, size_t n); ssize_t rio_writen(int fd, void *usrbuf, size_t n);\n返回: 若 成 功 则为 传 送的 字 节数 , 若 EOF 则 为 0 ( 只对r i or_ ea dn 石言), 若 出错 则 为 一1 。\n豆 0 —r e a d n 函数 从 描 述 符 f d 的 当 前 文 件 位置最多传送 n 个字节到内存位置 u sr b u f 。类似地,r i o_ wr i t e n 函数 从 位置 u sr b u f 传送 n 个字节到描述符 f d 。r i o _r e a d 函数在遇到 EOF 时只 能返回一个不足值。r i o _ wr i t e n 函 数 决 不 会 返回不足值。对同一个描述符,\n可以任意交错地调用 rio readn 和 \u0026lsquo;rio wr i t e n 。\n图 1 0- 4 显 示了 r i o _r e a d n 和r i o _ wr i t e n 的 代码。注意, 如 果 r i o _ r e a d n 和r i o _ wr i e n 函数被一个从应用信号处理程序的返回中断,那 么 每个函数都会手动地重启r e a d 或 wr i t e 。 为了尽可能有较好的可移植性, 我们允许被中断的系统调用, 且在必要时重启它们。\n5. 2 R IO 的 带 缓 冲的 输入 函数 假设我们要编写一个程序来计算文本文件中文本行的数量,该如何来实现呢?一种方 法就是用r e a d 函数来一次一个字节地从文件传送到用户内存,检 查每个字节来查找换行符。这个方法的缺点是效率不是很高, 每读取文件中的一个字节都要求陷入内核。 # 一种更好的方法是调用一个包装函数(r i o_r ea dl i ne b) , 它从一个内部读缓冲区复 制一个文本行,当缓 冲区变空时,会 自动 地调用r e ad 重新填满缓冲区。对于既包含文本行也包含二进制数据的文件(例如11. 5. 3 节中描述的 HTTP 响应), 我们也提供了一个r i o_ r e adn 带缓冲区的版本 ,叫做 r i o _r e a dnb , 它从 和r i o_r e a dl i ne b 一样的读缓冲区中传送原始字节。\n#include \u0026ldquo;csapp.h\u0026rdquo;\nvoid rio_readinitb(rio_t *rp, int fd);\nssize_t rio_readlineb(rio_t *rp, void *usrbuf, size_t maxlen); ssize_t rio_readnb(rio_t *rp, void *usrbuf, size_t n);\n返回: 无 。\n返回: 若 成 功 则 为 读的 宇节数 , 若 EOF 则 为 o , 若 出 错 则 为 — l 。\nssize_t rio_readn(int fd, void *usrbuf, size_t n) # 2 {\n3 size_t nleft = n; # 4 ssize_t nread;\ns char *bufp = usrbuf;\n6\n7 while (nleft \u0026gt; 0) { # 8 if ((nread = read(fd, bufp, nleft)) \u0026lt; 0) {\ncode/s吹r\nsapp.c # 9 if (errno == EINTR) I* Interrupted by sig handler return *I\n1o nread = 0; / * and call read() again */\n11 else\n12\n13 }\nreturn -1; # I* errno set by read() *I\nelse if (nread == 0) break; I* EDF *I\nnleft -= nread;\nbufp += nread;\n18 }\n19\n20 }\nreturn (n - nleft); # I* Return\u0026gt;= 0 *I\ncode/srdcsapp.c\nssize_t rio_writen(int fd, void *usrbuf, size_t n)\n2 {\n3 size_t nleft = n; # 4 ssize_t nwritten;\n5 char *bufp = usrbuf;\n6\n7 while (nleft \u0026gt; 0) { # 8 if ((nwritten = write(fd, bufp, nleft)) \u0026lt;= 0) {\ncode/srd csapp.c\n9 if (errno == EINTR) I* Interrupted by sig handler return *I\nn江 i t t en = 0; I* and call write() again *I else 12\n13 }\nreturn -1; I* errno set by write() *I # nleft 一= nwritten;\nbufp += nwritten;\n16 }\n17 return n;\n18 }\ncode/srdcsapp.c # 图 10-4 r i o—r ead n 和 r i o_wr 止 e n 函数\n每打开一个 描述符, 都会调用一次r i o_r e a d i n i t b 函数。它将描述符 f d 和地址 r p\n处的一个类型为r i o _ t 的读缓冲区联系起来。\nr i o_r e a d l i ne b 函数从文件r p 读出下一个文本行(包括结尾的换行符), 将它复制到内 存位置 usr b u f , 并且用 NU L L( 零)字符来结束这个文本行。r i o_r e a d l i ne b 函数最多读 ma x l e n - 1 个字节,余 下的 一个字符留给结尾的 NU LL 字符。超过 ma x l e n - 1 字节的文\n本行被截断, 并用一个 N U L L 字符结束。\nr i o _r e a d nb 函数从文件r p 最多读 n 个字节到内存位置 u sr b u f 。对同一描述符, 对r i o_ r e a d l i n e b 和r i o_ r e a d n b 的调用可以任意交叉 进行。然而,对 这些带 缓冲的函数的调用却不应 和无缓冲的 r i o _ r e a d n 函数交叉使用。\n在本书剩下的部分中将给出大鼠的 RIO 函数的示例。图 10-5 展示了如何使用 RIO 函数来一次一行 地从标准输入复制一个文本文件到标准输出。 # code/io/cpfile.c\n#include \u0026ldquo;csapp. h\u0026rdquo;\n2\n3 int main(int argc, char **argv)\n4 {\n5 int n;\n6 r. 1o_t r10;\n7 char buf[MAXLINE];\n8\nRio_readinitb(\u0026amp;rio, STDIN_FILENO);\nwhile((n = Rio_readlineb(\u0026amp;rio, buf, MAXLINE)) != 0)\nRi o_wr i t en (ST DOUT_FI LENO, buf, n);\n12 }\ncode/io/cpfile.c\n图 10-5 从标准输入复制一个文本文件到标 准输出\n图 10-6 展示了一个读缓冲区 的格式,以 及初始化它的r i o _r e a d i n i t b 函数的代码。rio r e a d i n i t b 函数创建了一个空的读缓冲区, 并且将一个打开的 文件描述符和这个缓冲区联系起来 。\n#define RIO_BUFSIZE 8192\n· t ypede f struct {\nint rio_fd;\n4 int rio_cnt;\nchar *rio_bufptr;\ncharr i o_buf [RIO_BUFSI ZE] ;\n} rio_t;\ncodelinclude/csapp.h\nI* Descriptor for this internal buf *I I* Unread bytes in internal buf *I\nI* Next unread byte in internal buf *I I* Internal buffer *I\ncodelinclude/csapp.h\nvoid rio_readinitb(rio_t *rp, int fd)\n2 {\n3 rp-\u0026gt;rio_fd = fd;\n4 rp-\u0026gt;rio_cnt = O;\n5 rp-\u0026gt;rio_bufptr = rp-\u0026gt;rio_buf;\n6 }\ncode/srdcsapp.c\ncode/srdcsapp.c\n图 10-6 一个类型为 r i o_t 的读缓 冲区和初始化它的r i o_r eadi ni t b 函数\nRIO 读程序的核心是图 10-7 所示的r i o _r e a d 函数。r i o_r e a d 函数是 L in uxr e a d 函数的带缓冲的版本。当调用r i o _r e a d 要求读 n 个字节时, 读缓冲区内 有r p - \u0026gt; 豆 o _ c n t\n个未读字节。如果缓冲区 为空, 那么会通过调用r e a d 再填满它。这个r e a d 调用收到一 个不足值并 不是错误,只 不过读缓 冲区是填充了一部分。一旦缓冲区非空,r i o _r e a d 就从读缓冲区复制 n 和r p - \u0026gt; rio _c nt 中较小值个字节到用户缓冲区, 并返回复制的字节数。\ncode/srdcsapp.c # static ssize_t rio_read(rio_t *rp, char *usrbuf, size_t n)\n2 {\nint cnt; # 5 while (rp-\u0026gt;rio_cnt \u0026lt;= 0) { I* Refill if buf is empty *I\n6 rp-\u0026gt;rio_cnt = read(rp-\u0026gt;rio_fd, rp-\u0026gt;rio_buf, sizeof(rp-\u0026gt;rio_buf));\nif (rp-\u0026gt;rio_cnt \u0026lt; 0) { if (errno != EINTR) I* Interrupted by sig handler return *I 1o return -1; # }\n12 else if (rp-\u0026gt;rio_cnt == 0) I* EDF *I return O; # else\nrp-\u0026gt;rio_bufptr = rp-\u0026gt;rio_buf; I* Reset buffer ptr *I\n16\nI* Copy min(n, rp-\u0026gt;rio_cnt) bytes from internal buf to user buf *I # cnt = n; if (rp-\u0026gt;rio_cnt \u0026lt; n) cnt = rp-\u0026gt;rio_cnt; # memcpy(usrbuf, rp-\u0026gt;rio_bufptr, cnt);\nrp-\u0026gt;rio_bufptr += cnt;\nrp-\u0026gt;rio_cnt -= cnt;\nreturn cnt;\n26 }\ncode/srdcsapp.c # 图 10-7 内 部的r i o_r ead 函数\n对千一个应用 程序,r i o_r e a d 函数和 Lin uxr e a d 函数有同样的语义。在出错时,它返回值- 1 , 并且适当地设置 e rr no 。在 E O F 时,它 返回值 0。如果要求的字节数超过了读缓冲区内 未读的字节的数量, 它会返回一个不足值。两个函数的相似性使得很容易通过用r i o _r e a d 代替 r e a d 来创建不同类型的带缓冲的 读函数。例如,用 r i o _ r e a d 代替\nread, 图10-8中的r i o_r e a d nb 函数和r i o—r e a d n 有相同的结构。相似地,图 10-8 中的\n立 o _r e a d l i ne b 程序最多 调用 ma x l e n - 1 次r i o_r e a d。每次调用都从读缓冲区 返回一个字节,然后检查这个字节是否是结尾的换行符。\n田日RIO 包的起源\nR IO 函数的灵感来自 于 W. Richard Stevens 在他的经典 网络 编程作品[ ll O] 中描述的r e a d l i ne 、r e a d n 和 wr i t e n 函数。r i o _r e a d n 和r i o _ wr i t e n 函数与 S t e vens 的 r e a d n 和 wr i t e n 函数是一样的。然而, S te vens 的r e a d l i ne 函数有一些局 限性在 RIO 中得到 了纠 正。笫一 , 因为r e a d l i ne 是带缓 冲的 , 而r e a d n 不带, 所以这两个函数不能在同一描 述符上一起使用。 第二,因为它使 用一 个 s t a t i c 缓冲区, Ste vens 的 r e a dl i ne\n函数不是线程安全的, 这 就要 求 S te ve n s 引入一个不同的 线程 安全的 版本 , 称 为 r e a d ­ 且 n e _ r 。我 们已经在r i o r e a d l i n e b 和 r i o _r e a d n b 函数 中修 改 了 这 两 个缺 陷, 使 得这两个函数是相互兼容和线程安全的。\ncode/sr吹 sapp.c ssize_t rio_readlineb(rio_t *rp, void *usrbuf, size_t maxlen)\n2 {\n3 int n, re;\n4 char c, *bufp = usrbuf;\n5\n6 for (n = 1; n \u0026lt; maxlen; n++) {\n7 if ((re= rio_read(rp, \u0026amp;c, 1)) == 1) {\n8 *bufp++ = c;\n9 if (C == 1 \\n 1) {\n10 n++;\n11 break;\n12 }\n13 } else if (re == 0) {\n14 if (n == 1)\nreturn O; I* EDF, no data read *I else 17\n18 } else\nbreak;\nI* EDF, some data was read *I\n19\n20 }\nreturn -1;\nI* Error *I\n*bufp = O;\nreturn n-1,·\n23 }\ncode/srdcsapp.c\ncode/srdcsapp.c\n1 ssize_t rio_read.nb(rio_t *rp, void *usrbuf, size_t n)\n2 {\n3 size_t nleft = n;\n4 ssize_t nread;\ns char *bufp = usrbuf;\n6\nwhile (nleft \u0026gt; 0) {\nif ((nread = rio_read(rp, bufp, nleft)) \u0026lt; 0)\n9 return -1; I* errno set by read() *I\n1o else if (nread == 0)\n11\n12\n13\n14 }\nbreak; nleft -= nread; bufp += nread;\nI* EDF *I\n15\n16 }\nreturn (n - nleft);\nI* Return\u0026gt;= 0 *I\ncode/s吹r sapp.c\n图10-8 r i o_r eadl i ne b 和r i o_r ea dnb 函数\n10. 6 读取文件元数据\n应用程序能够通过调用 s t a t 和 f s t a t 函数, 检索到关 千文件的信息(有时也称为文\n件 的 元 数 据 ( m e t a d a t a ) ) 。\n#include \u0026lt;unistd.h\u0026gt;\n#include \u0026lt;sys/stat.h\u0026gt;\nint stat(const char *filename, struct stat *buf); int fstat(int fd, struct stat *buf);\n返回: 若 成 功 则为 o, 若 出错 则为 一1。\ns t a 七函数以一个文件名作为输入, 并填写如图 1 0- 9 所示的一个 s t 扛 数据结构中的各个成员。f s t a t 函数是相似的,只 不过是以文件描述符而不是 文件名作为输 入。当我们在 11. 5 节中讨论 W e b 服务器时 , 会需要 s t a t 数据结构中的 s t _ mo d e 和 s t _ s i z e 成员, 其他成员则不在我们的讨论之列。\nstatbuf h (included by sys/stat.h)\nI* Metadata returned by the stat andfstat functions *I struct stat {\ndev_t st_dev;\nino_t st_ino;\nmode_t st_mode;\nnlink_t st_nlink;\nuid_t st_uid;\ngid_t st_gid;\ndev_t st_rdev;\noff_t st_size;\nI* Device *I I* inode *I\nI* Protection and file type *I\n/* Number of hard links*/ I* User ID of owner *I\n/* Group ID of owner*/\n)* Device type (if inode device */ I* Total size, in bytes *I\nunsigned long st_blksize; /* Block size for filesystem I/□ */\nunsigned long st_blocks; I* Number of blocks allocated *I\ntime_t time_t time_t\n};\nst_atime; st_mtime; st_ctime;\n/* Time of last access*/\n/* Time of last modification*/ I* Time of last change *I\nstatbuf h (included by sys/stat.h)\n图 10-9 s t a t 数据结构\ns t _ s i z e 成员包含了文件的字节数大小。s t _ mo d e 成员则编码了文件访问许可位(图1 0- 2 ) 和文件类型(1 0. 2 节)。L in u x 在 s y s / s t a 七 . h 中定义了宏谓词来确定 s t _ mo d e 成员的文件类型:\nS_IS REG ( m) 。这是一 个普通文件吗? S_ISDIR ( m) 。这是一 个目录文件吗? S_ISSOCK ( m) 。这是 一个网络套接字吗?\n图 10-1 0 展示了我们会如何使用这些宏和 s t a t 函数来读取和解释一个文件的 s t mo d e 位。\ncode/iolstatcheck.c\n1 #include \u0026ldquo;csapp.h\u0026rdquo;\n2\n3 int main (int argc, char **argv)\n4 {\nstruct stat stat;\nchar *type, *readok;\n7\nStat(argv[1], \u0026amp;stat);\nif (S_ISREG (stat.st_mode)) I* Determine file type *I\ntype = \u0026ldquo;regular\u0026rdquo;;\nelse if (S_ISDIR(stat.st_mode))\ntype = \u0026ldquo;directory\u0026rdquo;;\nelse\ntype = \u0026ldquo;other\u0026rdquo;;\nif ((stat.st_mode \u0026amp; S_IRUSR)) I* Check read access *I\nreadok = \u0026ldquo;yes\u0026rdquo;; 17 else\n18 readok = \u0026ldquo;no\u0026rdquo;;\n19\nprintf(\u0026ldquo;type: %s, read: %s\\n\u0026rdquo;, type, readok);\nexit(O); 22 }\n图 10 -1 0 查询和处理一个文件的 s t _mode 位\n7 读取目录内容\n应用程序可以用 r e a d d ir 系列函数来读取目录的内容。\n#include \u0026lt;sys/types.h\u0026gt;\n#fnclude \u0026lt;dirent.h\u0026gt;\ncode/iols ta tcheck . c\nDIR *opendir(const char *name);\n返回: 若 成 功 , 则为 处理的 指针 ; 若 出错 , 则 为 NU LL 。\n函数 o p e n d i r 以路径名为参数, 返回指向目 录流 ( di r ec t o r y s t r ea m ) 的指针。流是对条目有序列表的抽象,在这里是指目录项的列表。\n#include \u0026lt;dirent.h\u0026gt;\nstruct dirent *readdir(DIR *dirp);\n返回: 若 成 功, 则 为 指 向 下 一 个 目 录项的指针 ; 若 没 有 更 多的 目 录 项或 出错 , 则 为 NU LL 。\n每次对r e a d d ir 的调用返回的都是指向流 d ir p 中下一个目录项的指针, 或者, 如果没有更多目 录项则 返回 NU L L 。每个目录项都是一个结构 , 其形式如下:\nstruct dirent {\nino_t d_ino; I* inode number *I char d_name[256]; I* Filename *I\n};\n虽然有些 L i n u x 版本包含了其他的结构成员, 但是只有这两个对所有系统来说都是标\n准的。成员 d _n a me 是文件名 , d _ i n o 是文件位 置。\n如果出错, 则r e a d d ir 返回 N U L L , 并设置 err n o 。可惜的是,唯 一能区分错误和流结束情况的方法是检查 自调用r e a dd i r 以来 err no 是否被修改过。\n#include \u0026lt;dirent.h\u0026gt;\nint closedir(DIR *dirp);\n返回: 成 功为 O; 错 误 为 一1.\n函数 c l o s e d ir 关闭流并释放其 所有的资 源。图 1 0-11 展示了怎样用r e a d d i r 来读取目录的内容。\n1 #include \u0026ldquo;csapp.h\u0026rdquo;\n2\n3 int main(int argc, char **argv)\n4 {\ns DIR *Streamp;\n6 struct dirent *dep;\n7\n8 streamp = Opendir (argv [1]) ;\n9\nerrno = O;\nwhile ((dep = readdir(streamp)) != NULL) {\nprintf(\u0026ldquo;Found file: %s\\n\u0026rdquo;, dep-\u0026gt;d_name);\n13 }\n14 if (errno != 0)\n15 unix_error(\u0026ldquo;readdir error\u0026rdquo;);\n16\n17 Closedir (st reamp) ;\n18 exit (0);\n19 }\ncode/io/readdir.c\ncode/io/readdir.c\n图 10-11 读取目录的内容\n8 共享文件 可以 用许多不同的方式来共享 L in ux 文件。除非你很清楚 内核是如何表示打开的文件, 否则文件共享的概念相当难懂。内核用三个相关的数据结构来表示打开的文件: # 描述符表( des crip to r t a b le ) 。每个进程都有它独立的描述符表,它 的 表项是由进程打开的文件描述符来索引的。每个打开的描 述符表项指向文件表中 的一个表项。\n文件表 ( fi le t a ble ) 。打开文件的集合是由一张文件表来表示的, 所有的 进程共享这张表。每个文件表的表项组成(针对我们的目的)包括当前的文件位置、引用计数(ref erenee count)(即当前指向该表项的描述符表项数), 以及一个指向 v- node 表中对应表项的指针。关闭一个描述符会减少相应的文件表表项中的引用计数。内核不会删除这个文件表表项, 直到它的引用 计数为零。 v-node 表( v- node ta ble ) 。同文件表一样, 所有的进程共享这张 v- node 表。每个表项包含 s 七a t 结 构中的大多数信息, 包括 s t _mo d e 和 s t _s i ze 成员。\n图 10-1 2 展示了一个示例, 其中描述符 1 和 4 通过不同的打开文件表表项来引用两个\n不同的文件。这是一种典型的情况,没有共享文件,并且每个描述符对应一个不同的文件。 # 描述符表\n( 每个进程一张表)\ns t di n fd 0 s t dout fd 1 S 七de rr fd 2 # fd 3\nfd 4\n打开文件表\n( 所有进程共享) 文件 A\nv-node表\n(所有进程共享)\n图 10-12 典 型 的 打 开 文 件 的 内 核 数 据 结 构 。 在这个示例中, 两 个 描 述 符引 用 不 同 的 文 件 。 没 有 共 享\n如图 10-13 所示,多 个描述符也可以通过不同的文件表表项来引用同一个文件。例如, 如果以同一个 巨 l e na me 调用 ope n 函数两次, 就会发生这种情况。关键思想是每个描述符都有它自己的文件位置,所以对不同描述符的读操作可以从文件的不同位置获取 数据。 # 描述符表\n(每个进程一张表 )\n打开文件表\n(所有进程共享) 文件A\nv-node表\n(所有进程共享)\n图 10-13 文件共享。这个例子展示了两个描述符通过两个打开文件表表项共享同一个磁盘文件\n我们也能理解父子进程是如何共享文件的 。假设在调用 f or k 之前, 父进程有如图 10-12 所示的打开文件。然后 , 图 1 0-1 4 展示了调用 f or k 后的情况。子进程有一个父进程描述符表的副本。父子进程共享相同的打开文件表集合 , 因此共享相同的文件位置。一个很重要的结果就是,在内核删除相应文件表表项之前,父子进程必须都关闭了它们的描 述符。 # 描述符表父进程的表\n打开文件表\n(所有进程共享) 文件 A\nv-node 表\n(所有进程共享)\n图 10-14 子进程如何继承父进程的打开文件。初始状态如图 10-12 所示\n沁目 练习题 10. 2 假设 磁 盘 文件 f o o b ar . t x t 由 6 个 ASCII 码 字符 \u0026quot; f o o b ar \u0026quot; 组成。那么,下列程序的输出是什么?\n1 #include \u0026ldquo;csapp.h\u0026rdquo;\n2\n3 int main()\n4 {\ns int fd1, fd2;\n6 char c;\n7\n8 fd1 = Open(\u0026ldquo;foobar.txt\u0026rdquo;, O_RDONLY, O);\n9 fd2 = Open(\u0026ldquo;foobar.txt\u0026rdquo;, O_RDONLY, 0); 10 Read(fd1, \u0026amp;c, 1);\n11 Read(fd2, \u0026amp;c, 1);\n12 printf(\u0026ldquo;c = o/.c\\n\u0026rdquo;, c);\n13 exit(O); 14 }\n已 练习题 10. 3 就像前面那 样,假 设 磁盘文件 f oob ar . t 江 由 6 个 ASCII 码 字符 \u0026quot; f ooba r\u0026rdquo;\n组成。那么下列程序的输出是什么?\n#include \u0026ldquo;csapp.h\u0026rdquo;\n2\n3 int main()\n4 {\nint fd;\nchar c;\n7\n8 fd = Open(\u0026ldquo;foobar.txt\u0026rdquo;, O_RDONLY, O);\n9 if (Fork() == 0) {\nRead(fd, \u0026amp;c, 1);\nexit(O);\n12 }\nWait (NULL) ;\nRead(fd, \u0026amp;c, 1);\nprintf(\u0026ldquo;c = %c\\n\u0026rdquo;, c);\nexit(O);\n17 }\n10. 9 1/ 0 重定向\nLinux shell 提供了 I/ 0 重定向操作符,允 许用户将磁盘文件和标准输入输出联系起来。例如,键入\nlinux\u0026gt; ls\u0026gt; foo.txt # 使得 s hell 加载和执行 l s 程序, 将标准输出重定向到磁盘文件 f o o . t x 七。 就如我们将在\n5 节中看到的那样, 当一个 Web 服务器代表客户端运行 CGI 程序时 ,它 就执行一种相似类型的重定向 。那么1/0 重定向是如何工作的呢?一种方式是使用 d up 2 函数。 #include \u0026lt;unistd.h\u0026gt; # int dup2(int oldfd, int newfd);\n返回: 若 成 功 则为 非 负的 描 述 符 , 若 出错 则 为 一1。\nd up 2 函数复制描述符表表项 o l d f d 到描述符表表项 ne wf d , 覆盖描述符表表项 ne w­ f d 以前的内容。如果 ne wf d 已经打开了, d up 2 会在复制 o l d f d 之前关闭 ne wf d 。\n假设在调用 d up 2 ( 4 , 1 ) 之前,我 们的状态如图 10-1 2 所示, 其中描述符 1 ( 标准输出) 对应于文件 A( 比如一个终端), 描述符 4 对应于文件 B( 比如一个磁盘文件)。A 和 B 的引 用计数都等千 1 。图 1 0-1 5 显示了调用 dup 2 ( 4, 1 ) 之后的情况。两个描述符现在都指向文件 B ; 文件 A 已经被关闭 了,并 且它的文件表和 v- node 表表项也已经被删除了; 文件 B 的引用计数已经增加了。从此以后, 任何写到标准输出的数据都被重定向到文件 B。\n描述符表\n打开文件表\nv-node 表\n..\nfdO\nI ,L\u0026mdash;\u0026mdash;\n:文件访问!\nfd 1\n,I \u0026mdash;\u0026mdash;\u0026mdash;\u0026ndash;一一' 卜 ,\n:文件位置! :文件大小!\nfd 2\n卜\u0026mdash;\u0026mdash;\u0026mdash;-一, I # t\u0026mdash;\u0026mdash;\u0026mdash;\u0026mdash;-·\nfd 3\n:refcnt=o: : 文件类型!\n卜 ------一一-一一-1 卜, ,\nfd 4\n,,I:'\nI• I 图 10-15 通过调用 dup2 (4, l ) 重 定 向 标 准 输 出 之 后 的 内 核 数 据结 构 。 初始状态如图 10-12 所 示\n日 日 左边和右边的 ho ink ie s\n为了 避免和其他括号 类型操作符比如 \u0026quot; J\u0026quot; 和 "[” 相混淆, 我们总是将 s hell 的 \u0026ldquo;\u0026gt; \u0026quot; 操作符称为“右 hoin k y\u0026rdquo; , 而将 \u0026ldquo;\u0026lt; \u0026quot; 操作符称 为“ 左 hoin k y\u0026rdquo; 。\n; 练习题 10. 4 如何 用 d up2 将标 准输入 重定 向到描述 符 5?\n芦 练习题 10. 5 假设磁 盘 文件 f o o b ar . t x t 由 6 个 ASC II 码 字符 \u0026quot; f o o b ar \u0026quot; 组 成, 那么下列程序的输出是什么?\n#include \u0026ldquo;csapp.h\u0026rdquo; # 2\n3 int main() # 4 { 5 int fdl, fd2; 6 char c; 7 8 fd1 = Open(\u0026ldquo;foobar.txt\u0026rdquo;, O_RDONLY, 0); 9 fd2 = Open(\u0026ldquo;foobar.txt\u0026rdquo;, O_RDONLY, O); 10 Read(fd2, \u0026amp;c, 1); 11 Dup2(fd2, fdl); 12 Read(fd1, \u0026amp;c, 1); 13 printf(\u0026ldquo;c = %c\\n\u0026rdquo;, c); 14 exit(O); 15 }\n10. 10 标准 1/ 0\nC 语言定义了一组高级输入输出函数,称 为标准 I/ 0 库 , 为程序员提供了 U nix I/0 的较高级别的替代。这个库( li b c ) 提供了打开和关闭文件的函数 ( f o p e n 和 f c l o s e ) 、读和写字节的函数 ( fr e a d 和 f wr i t e ) 、 读 和 写 字 符 串 的 函 数 ( f g e t s 和 f p u t s ) , 以及复杂的格式化的 I/ 0 函数 Cs c a n f 和 pr i n t f ) 。\n标 准 I/ 0 库将一个打开的文件模型化为一个流。对于程序员而言,一 个 流就是一个指 # 向 FI LE 类型的结 构的指针。每个 ANSI C 程序开始时都有三个打开的流 s 七d i n 、 S 七d ou t\n和 s t d err , 分别对应于标准输入、标准输出和标准错误:\n#include \u0026lt;s t d i o . h \u0026gt;\nextern FILE *stdin; I* Standard input (descriptor 0) *I extern FILE *stdout; I* Standard output (descriptor 1) *I extern FILE *stderr; I* Standard error (descriptor 2) *I\n类型为 FILE 的 流是对文件描述符和流缓 冲区的 抽象 。 流缓 冲区的目的和 RIO 读缓冲区 的 一样: 就 是 使 开销较高的 Lin ux 1/0 系统调用的数量尽可能得小。例如, 假设我们有一个 程序, 它反复调用标准 1/0 的 g e t c 函数 , 每次调用返回文件的下一个字符。当第一\n次调用 g e t c 时 ,库 通过调用一次r e a d 函数来填充 流缓 冲区, 然 后 将 缓 冲区中的第一个字节 返回给应用程序。只要缓冲区中还有未读的字节, 接 下 来对 g e t c 的调用就能直接从流缓冲区得到服务。\n10. 11 综合: 我该使用哪些 1/ 0 函数?\n图 10-16 总结了我们在这一章里讨论过的各种 1/ 0 包。\nfopen fread f s can f sscanf f ge 七s\nf dop en fwrite f pr i nt f sprintf\nfputs\nr· 飞 C应用程序\nfflush f c l os e\nopen wr i 七e stat\nfseek \\\u0026hellip; \u0026hellip;.\nread l seek close\n标准VO 函数 RIO 函数\nUnix 1/0 函数\n(通过系统调用来访问)\n图 10- 1 6 U ni x I / 0 、标准 I / 0 和 RIO 之间的关系\nUnix 1/0 模型是在操作系统内核中实现的。应用程序可以通过诸如 op e n、c l o s e 、l s e e k、r e a d、wr i t e 和 s t a t 这样的函数来访问 U nix 1/0 。较高级别的 RIO 和标准I/ 0 函数都是基于(使用) Unix I/ 0 函数来实现的。RIO 函数是专为本书开发的r e a d 和 wr i t e 的健壮的包装函数 。它们自动处理不足值, 并且为读 文本行提供一种高效的带缓冲的 方法。标准 1/0 函数提供了 U nix I/ 0 函数的一个更加完整的 带缓冲的替代品, 包括格式化的 1/ 0 例程, 如 pr i nt f 和 s c a n f 。\n那么, 在你的程序中该使用这些函数中的哪一个呢?下面是一些 基本的指导原则: # Gl: 只 要有可能就使用标准 I/ 0 。对磁盘和终端设备 I/ 0 来说, 标准 1/ 0 函数是首选方法。大多数 C 程序员在其整个职业生涯中只使用标准 I/ 0 , 从不受较低级的Unix I/ 0 函数的困扰(可能 s t a t 除外, 因为在标准 1/0 库中没有与它对应的函数)。只要可能 , 我们建议你也这样做。 G2 : 不要使用 s c a n f 或 r i o _ r e a d l i ne b 来读二进制文件 。像 s c a n f 或r i o_r e a d­巨 ne b 这样的函数是专门设计来读取文本文件的。学生通常会犯的一个错误就是用这些函数来读取二进制文件, 这就使得他们的程序出现了诡异莫测的失败。比如, 二进制文件 可能散 布着很多 Oxa 字节, 而这些字节又与终止文 本行无关。 G3: 对网络套 接字的 1/0 使用 RIO 函数。不幸的 是, 当我们试着将标准I/ 0 用千网络的输入输出时, 出现了一些令人讨厌的问题。如同我们将在 11. 4 节所见, L inux 对网络的抽象是一种称为套接字的 文件类型。就像所有的 L in ux 文件一样, 套接字由文件描述符来引用, 在这种情况下称为套 接字描述符。应用程序进程通过读写套接字描述符来与运行在其他计算机的进程实现通信。 # 标准 I/ 0 流,从 某种意义上而言是 全双工的 , 因为程序能够在同一个流上执行输入和\n输出。然而, 对流的限制和对套 接字的限制, 有时候会互相冲突 , 而又极少有文档描述这些现象:\n限制一: 跟在输出 函数之后的 输入函数。如果中间没有插入对 f fl u s h、f s e e k、 f s e t po s 或者r e wi n d 的调用, 一个输 入函数不能跟随在一个 输 出 函数之后。 f fl u s h 函数清空与流相关的缓冲区。后三个函数使用 U nix I/ 0 l s e e k 函数来重置当前的文件位置。 限制二: 跟在输入函数之后的 轮出函 数。如果中间没有插入对 f s e e k、f s e 七p o s 或者 r e wi n d 的调用, 一个输出函数不能跟随在一个输入函数之后, 除非该输入函数遇到了 一个文件结束。 这些限制 给网络应用 带来了一个问题, 因为对套接字使用 l s e e k 函数是非法的。对流I/ 0 的第一个限制能够通过采 用在每个输入操作前刷新缓 冲区 这样的规则来满足。然而, 要满足第二个限制的唯一办法是,对同一个打开的套接字描述符打开两个流,一个用来 读,一个用来写: # FILE *f pi n, *f pout ;\nfpin = f d open (s ockf d , # r\u0026quot; \u0026quot; ) ;\nfpout = fdopen (s ockf d , \u0026ldquo;w\u0026rdquo;) ;\n但是这种方 法也有问题, 因为它 要求应用程序在两个流上都要调用 f c l os e , 这样才能释放与每个流相关联的内存资源, 避免内存泄漏:\nf c l o s e ( f p i n) ;\nf c l os e (f pout ) ;\n这些操 作中的每一个都试图关闭同一个底层的套接字描 述符, 所以第二个 c l o s e 操作就会失败。对顺序的程序来说,这并不是问题,但是在一个线程化的程序中关闭一个已经 关闭了的描述符是会导致灾难的(见12. 7. 4 节)。\n因此, 我们建议你在网络套 接字上不 要使用标准 I/ 0 函数来进行输入和输出, 而要使\n用健 壮的 RIO 函数。如果你需要格式 化的输出,使 用 s p r i n 七f 函数在内存中格式化一个字符串 , 然后用r i o _ wr i t e n 把它发送到套接口。如果你需要格式化输入,使 用 r i o\nr e a d l i n e b 来读一个完整的文 本行, 然后用 ss c a n f 从文本行提取不同的字段。\n10. 12 小结\nLinux 提供了少械的基千 U nix I/ 0 模型的系统级函数.它们允许应用程序打开、关闭、读和写文件, 提取文件的元数据 , 以及执行 I/ 0 重定向。Linux 的 读和写操 作会出现不足值, 应用程序必须能正确地 预计和处理这种情 况。应用 程序不 应直接调用 Unix I/ 0 函数, 而应该使用 RIO 包, RIO 包通过反复执行读写操作,直到传送完所有的请求数据,自动处理不足值。\nLinux 内核使用三个相关的数据结构来 表示 打开的 文件。描述符表中的表项指向打开文件 表中的表项,而打开文件表中的 表项又指向 v-node 表中的表项。每个进程都 有它自己单独的描述符表,而所有的进程共享同一 个打开文件表 和 v-node 表。理解这些结构的一般组成就能使我们 清楚 地理解文件共享和I / 0 重定向。\n标准 I/ 0 库是基于 Unix I/ 0 实现的,并 提供 了一组强大的 高级 I/ 0 例程。对千大 多数应用程序而言 . 标准 I/ 0 更简单, 是优千 U nix I/ 0 的选择。然而 , 因为对标准 I/ 0 和网络文件的一些相互不兼容的限制, U nix I/ 0 比之标准 I/ 0 更该适用于网络应用程序 。\n参考文献说明 # Kerr is k 撰写了关于 Unix I/ 0 和 Linux 文件系统的综述 [ 62] 。S tevens 编写了 Unix I/ 0 的标准参考文献[ 111] 。Kern igh an 和 Ritc hie 对千标准 I/ 0 函数给出了清晰 而完整的讨论[ 61] 。\n家庭作业 # 10. 6 下面程序的输出是什么?\n#include \u0026ldquo;csapp.h\u0026rdquo;\n3 int main()\n4 {\n5 int fdl, fd2;\n6\n7 fdl = Open(\u0026ldquo;f oo. t xt \u0026quot; , O_RDONLY, 0);\nB fd2 = Open(\u0026ldquo;bar.txt\u0026rdquo;, O_RDONLY, O);\n9 Cl os e (f d2) ;一\n10 fd2 = Ope n ( \u0026ldquo;baz . t xt \u0026ldquo;, O_RDONLY, O) ; 11 printf(\u0026ldquo;fd2 = 加 \\n\u0026rdquo;, fd2); 12 exit(O); 13 } 10.7 •• 10. 8\n** 10. 9\n修改图 10-5 中所示的 cpf i l e 程序 , 使得它用 RIO 函数从 标准输入复制到标 准输出 ,一 次 MAX­\nBUF 个字节。\n编写图 10-10 中的 s t a t che c k 程序的 一个版本,叫 做 f s 七a t c he c k , 它从命令行上取得一个描述符数字而不是 文件名。\n考虑下面对作业题 10. 8 中的 f s t a t chec k 程序的调用:\nlinux\u0026gt; fstatcbeck 3 \u0026lt; foo. txt\n你可能会预想这个 对 f s t a t c he c k 的调用将提取和显示文件 f oo . t xt 的元数据。然而,当我们在\n系统上运行它时, 它 将失败,返 回 “ 坏 的 文 件 描 述 符"。 根 据 这 种 情 况 ,填 写 出 s hell 在 f o r k 和\ne xe c v e 调 用 之 间 必 须 执 行 的 伪 代 码 :\nif (Fork() == 0) { I* child•/\n/• What code is the shell executing right here?•/ Execve(\u0026ldquo;fstatcheck\u0026rdquo;, argv, envp);\n•• 10. 10 修改 图 1 0- 5 中 的 c p f i l e 程 序 ,使 得 它 有 一 个 可选的命令行参数 i n fi l e 。如果给定了 i n f i l e , 那么复制 i n fi l e 到标准输出,否则 像 以 前 那 样 复制标准输入到标准输出。一个要求是对于两种情况,你 的解答都必须使用原来的复制循环(第9~ 11 行)。只允许你插人代码, 而 不 允 许 更 改 任何已经存在的代码。\n练习题答案 # 10. 1 U nix 进程生命 周期开始时 ,打 开 的 描 述 符 赋 给了 s t d i 认描述符 0 ) 、s t d o u t ( 描述符 1) 和 s t d err\n(描述符 2 ) 。o p e n 函数总是返回最低的未打开的描述符,所 以 第 一 次 调用 o p e n 会 返 回 描 述 符 3 。调用 c l os e 函数 会释放 描述符 3 。最 后对 ope n 的调用会返回描述 符 3 , 因此程序的输出是 \u0026quot; f d2 = 3\u0026rdquo; 。\n10. 2 描 述 符 f d l 和 f d 2 都 有 各 自 的 打 开文件表表项,所 以 每个描述符对于 f oo b a r . t x t 都有它自己的文件位置。因此,从 f d 2 的读操作会读 取 f o o b ar . t x t 的 第一 个字节,并 输 出\nC = f\n而不是像你开始可能想的\nC = 0\n10. 3 回想一下,子进程会继承父进程的描述符表,以及所有进程共享的同一个打开文件表。因此,描述符 f d 在父子进程中都指向同一个打开文件表表项。当子进程读取文件的第一个字节时,文 件 位置加 1。因此, 父进程会读取第二个字节,而 输出就是\nC 一 。\n10. 4 重定向标准输人(描述符 0 ) 到描述符 5\u0026rsquo; 我们将调用 d up 2 (5 , 0 ) 或 者 等 价 的 d up 2 (5 , STDIN _ F IL E­\nNO) 。\n10 . 5 第一眼你可能会想输出应该是\nC = f\n但是因为我们将 f d l 重定向到了 f d 2 , 输出实际上是\nC = 0\n"},{"id":435,"href":"/zh/docs/technology/System/%E6%B7%B1%E5%85%A5%E7%90%86%E8%A7%A3%E8%AE%A1%E7%AE%97%E6%9C%BA%E7%B3%BB%E7%BB%9F/%E4%B9%A6%E7%B1%8D/%E7%AC%AC11%E7%AB%A0-%E7%BD%91%E7%BB%9C%E7%BC%96%E7%A8%8B/","title":"Index","section":"SpringCloud","content":"第 1 1 章\nC H A P T E R 11 # 网络编程\n网络应用随处可见。任何时候浏览 W eb 、发送 ema il 信息或是玩在线游戏, 你就正在使用网络应用程序。有趣的 是, 所有的网络应用都是基千相同的基本编程模型, 有着相似的整体逻辑结构,并且依赖相同的编程接口。 # 网络应用依 赖于很多在系统 研究中巳经学习过的概念。例如, 进程、信号、字节顺序、内存映射以及动态内存 分配, 都扮演着 重要的角色。还有一些新概念要掌握。我们需要理解基本的客户端-服务器编程模型 , 以及如何编写使 用因特网提供的服务的客户端-服务器程序。最后 , 我们将把所有这些概念结合起来, 开发一个虽小但功能齐全的 Web 服务器 , 能够为真实的 Web 浏览器提供静态和动态的 文本和图形内容。\n1 客户端-服务器编程模型\n每个网络应用都是基千客户端-服务器模型的。采用这个模型,一个应用是由一个服 务器进 程和一个或者多个客户端 进程组 成。服务器管理某种资源, 并且通过操作这种资濒来为它的客户端提供某种服务。例如, 一个 Web 服务器管理着一组磁盘文件, 它会代表客户端进行检索和执行。一个 FT P 服务器管理着一组磁盘文件,它 会为客户端进行存储和检索。相似地 , 一个电子邮件服务器管理着一些文件,它 为客户端进行读和更新。\n客户端-服务器模 型中的基本操作是 事务 ( t ra nsaction )( 见图 11-1) 。一个客户端-服务器事务由以下四步组成。\n当一个客户端需要服务时 , 它向服务器发送一个请求, 发起一个事务。例如,当\nWeb 浏览器需要一个文件时,它 就发送一个请求给 Web 服务器。\n服务器收到请求后,解 释它, 并以适当的方式操作它的资源。例如, 当 Web 服务器收到浏览器发出的请求后, 它就读一个磁盘文件。\n) 服务器给客户端发送 一个响应, 并等待下一个请求。例如, Web 服务器将文件发送回客户端 。\n) 客户端收到响应并处理它。例如, 当 Web 浏览器收到来自服务器的一页后, 就在屏幕上显示此页。\n4. 客户端\n处理响应\n图 11-1 一个客户端-服务器事务\n认 识到客户端和服务器 是进程, 而不是常提到的机器或 者主机 , 这是很重要的。一台主机可以同时运行许多不同的客户端 和服务 器,而 且一个客户端和服务器的事务可以在同一台或是不同的主机上。无论 客户端和服务 器是怎样映射到主机上的,客 户端-服务器模 j 型都是相同的。 # m 客户端-服务器事务与数据库事务 # 客户端-服务器 事务 不是 数 据 库事务 , 没有数据库事务的任何特性, 例如原子性。在我们的上下文中, 事务仅仅是客 户端 和服 务 器执 行 的一 系列 步骤。\n2 网络 客户端和服务器通常运行在不同的主机上,并且通过计算机网络的硬件和软件资源来 通信。网络是很复杂的系统,在 这里我们只想了解一点皮毛。我们的目标是从程序员的角度给你一个切实可行的思维模型。 # 对主机而言,网 络只 是 又一种 I/ 0 设备 ,是 数 据 源和数据接收方,如 图 11-2 所 示 。\n一个插到 I/ 0 总线扩展槽的适配器提供了到网络的物理接口。从网络上接收到的数据从适配器经过 I/ 0 和内 存总线复制到内存 , 通常是通过 DMA 传送。相似地,数 据 也 能 从内存复制到网络。\nCPU 芯片\nRegister file # 言 丿 系统总线 内存总线\n图 11-2 一个网络主机的硬件组成\n物理上而言,网 络 是 一 个 按 照 地理远近组成的层次系统。最低层是 LA N ( Local Area # Network, 局域网),在 一 个 建 筑 或 者 校园范围内。迄今为止, 最 流行的局域网技术是以太网 ( E t hern et ) , 它 是 由 施 乐 公 司 帕 洛阿尔托研究中心 ( Xero x P A RC ) 在 20 世纪 70 年代中期提出的。以太网技术被证明是适应力极强的,从 3 Mb/ s 演变到 l OG b/ s。\n一个以太网段 ( E t hern et seg ment ) 包 括 一 些电缆(通常是双绞线)和一个叫做 集线器的小盒子,如 图 11- 3 所示 。以 太 网 段 通常跨越一些小的 区域, 例 如某建筑物的一个房间或者一个楼层。每根电缆都有相同的最大位带宽, 通常是 l OOMb/ s 或者 l Gb/ s。一端连接到主机的适配器,而另一端则连接到集线器的一个\n端口上。集线器不加分辨地将从一个端口上收到的 每个位复制到其他所有 的端 口上。因此, 每台 主机都 能看到每个 位。\n每个以太网适配器都有一个全球唯一的 48 位 地址,\n它存储在这个适配器的非易失性存储器上。一台主机可 图 11-3 以太网段\n以发送一段位(称为帧( fr am e) ) 到这个网段内的其他任何 主机。每个帧包括一些固定数量的 头部 ( h eade r ) 位,用 来标识此帧的源和目的地址以 及此帧的长度, 此后紧随的就是数据位的有效栽荷 ( pa yloa d ) 。每个 主机适配器都能看到这个帧, 但是只有目的主机实际读取它。\n使 用一些电缆和叫做网桥( bridge ) 的小盒子,多 个以太网段可以连接成较大的局 域网,\n称为桥接以太网 ( b ridged Ethernet), 如图 11-4 所示。桥接以 太网能够跨越整个建筑物或者校区。在一个桥接以太网里,一些电缆连接网桥与网桥,而另外一些连接网桥和集线 器。这些电缆的带宽可以是不同的。在我们的示 例中, 网桥与网桥之间的电缆有 l Gb/ s 的带宽 , 而四根网桥 和集线器之间电缆的带宽却是 l OOM b/ s。 # A\n! Gb/s\n图 11-4 桥接以太网\n网桥比集线器更充分地利用了电缆带宽。利用一种聪明的分配算法, 它们随着时间自动学习哪个主机可以通过哪个端口可达,然后只在有必要时,有选择地将帧从一个端口复 制到另一个端口。例如, 如果主机 A 发送一个帧到同网段上的主机 B, 当该帧到达网桥 X 的 输入端口时, X 就将丢弃此帧, 因而节省了其他网段上的带宽。然而, 如果主机 A 发送一个帧到一个不同网段上的主机 C, 那么网桥 X 只会把此帧复制到和网桥 Y 相连的端口上, 网桥 Y 会只把此帧复制到与主机 C 的网段连接的端口 。 # 为了简化局域网的表示, 我们将把集线器和网桥以及连接它 们的电缆画成一根水平线, 如图 11-5 所示。\n在层次的更 高级别中,多 个不兼容的局域网可以 通过叫做路由器( ro ute r ) 的特殊计算机连接起来, 组成一个 int ern et (互联网络)。每台路由器对于它所连接到的每个网 络都有一个适配器(端口)。路由器也能连接高速点 到点电话连接, 这是称为 W A N ( W啦 -Area\nNetwork, 广域网)的网络示例,之所以这么叫是因为它们覆盖的地理范围比局域网的大。一般而言,路由器可以用来由各种局域网和广域网构建互 联网络。例如 ,图 11- 6 展示了一个互联网络的示例, 3 台路由器连接了一对局域 # 网和一对广域网。 图 11-5 局域网的概念视图\n图 11 - 6 一个小型的互联网络。三台路由器连接起两个局域网和两个广域网\n田日Internet 和 internet # 我们总是用小写字母的 in ter net 描述一般概念, 而 用 大写 字母 的 In ter net 来描 述一种具体的 实现,也 就 是 所谓 的全球 IP 因特 网。\n互联网络至关重要的特性是,它能由采用完全不同和不兼容技术的各种局域网和广域 网组成。每台主机和其他每台主机都是物理相连的,但是如何能够让某台源主机跨过所有 这些不兼容的网络发送数据位到另一台目的 主机呢?\n解决办法是一层运行在每台主机和路由器上的协议软件,它消除了不同网络之间的差 异。这个软件实现一种协议,这 种 协 议 控 制主机和路由器如何协同工作来实现数据传输。这种协议必须提供两种基本能力:\n·命名机制。不同的局域网技术有不同和不兼容的方式来为主机分配地址。互联网络 协议通过定义一种一致的主机地址格式消除了这些差异。每台主机会被分配至少一 个这种互联 网络地址( in te rn et address), 这个地址唯一地标识了这台主机。\n传送机制。在电缆上编码位和将这些位封装成帧方面,不同的联网技术有不同的和 不兼容的方式。互联网络协议通过定义一种把数据位捆扎成不连续的片(称为包)的 统一方式,从 而 消 除 了 这些差异。一个包是由 包头 和有效栽荷组成的,其 中 包 头 包括 包 的 大小 以 及 源主机和目的主机的地址, 有效载荷包括从源主机发出的数据位。\n.图11-7 展示了主机和路由器如何使用互联网络协议在不兼容的局域网间传送数据的一个示例。这个互联网络示例由两个局域网通过一台路由器连接而成。一个客户端运行在 主机 A 上 ,主 机 A 与 LANl 相连,它 发 送 一 串 数 据 字 节 到 运行在主机 B 上的服务器端, 主机 B 则连接在 LAN2 上 。这个过程有 8 个基本步骤:\n1 ) 运行在主机 A 上的客户端进行一个系统调用,从 客户端的虚拟地址空间复制数据到内核缓冲区中。 # 主机 A 上的协议软件通过在数据前附加互联网络包头和 LA Nl 帧 头 ,创 建 了 一 个\nLANl 的帧。互联网络包 头寻址到互联网 络 主机 B。LANl 帧头寻址到路由器。然后它传送此帧到适配器。注意, LANl 帧的 有效 载荷是一个互联网络包,而 互 联网络包的有效载荷是实际的用户数据。这种封装是基本的网络互联方法之一。\nLANl 适配器复制该帧到网络上。\n当此帧到达路由器时,路 由 器 的 L ANl 适 配器从电缆上读取它,并 把 它 传 送 到 协议软件。 # 5 ) 路由 器从互 联 网 络 包 头 中 提 取 出目的互联网络地址,并 用 它 作 为 路 由 表 的 索 引 , 确定向哪里转发这个包,在 本 例 中是 L AN2。路由器剥落旧的 LANl 的帧头 ,加 上 寻 址 到主机 B 的新的 LA N2 帧 头 ,并 把得到的帧传送到适配器。\n路由 器的 L AN2 适 配 器复制该帧到网络上。 当此帧到 达主机 B 时, 它的适配器从电 缆上读到此帧 , 并将它传送到协议软件。 # 最后, 主机 B 上的协议 软件剥落包头和帧头。当服务器进行一个读取这些数据的系统调用时,协议软件最终将得到的数据复制到服务器的虚拟地址空间。\n主机A 主机B\n客户端\n( I) 口 歪J i\n\u0026lt; z) I三数据 I产PH Irn三il ! ( 8 ) 巨 蕴]\ni \u0026lt; 7) I 数据 IPH lrn2I\n二二] LAN2\nI 3 l 三: 器丧呈 匐 ;三勹丿亘气更\n\u0026lt; 4) I 数据 IPH I阳 1I ! 1 I 数据 IPH IFH2I ( 5 )\n图 11 - 7 在互联网络上, 数据是如何从一台主机传送到另一台主机的 ( PH , 互联网络包头;\nFHl, LANI 的帧头; FH2, LAN Z 的 帧头)\n当然,在这里我们掩盖了许多很难的问题。如果不同的网络有不同帧大小的最大值,该怎 么办呢?路由器如何知道该往哪里转发帧呢?当网络拓扑变化时,如何通知路由器?如果一个 包丢失了又会如何呢?虽然如此,我们的示例抓住了互联网络思想的精髓,封装是关键。 # 11 . 3 全球 IP 因特网\n全球 IP 因特网是最著名和最成功的互联网络 实现。从 1969 年起,它 就以这样或那样的形 式存在了。虽 然因特网的内部体系结构复杂而且不断变化, 但是自从 20 世纪 80 年代早期以来,客 户端-服务器应用的组织 就一直保持着相当的稳定。图 11-8 展示了一个因特网客户端-服务器应用程序的基本硬件和软件组织。\n互联网络客户端主机 互联网络服务器主机\n图 1 1-8 一个因特网应用程序的硬件和软件组织\n每台因特网主机都运行实现 T CP/ IP 协 议 ( T ransmission Control Protocol/ Internet\nProtocol, 传输控制协 议/互联网络协议)的软件,几 乎每个现代计算机系统都支持这个协议。因特网的客户端 和服务器混合使用 套接宇接 口函数和 U nix I/ 0 函数来进行通信(我们将在 11. 4 节中介绍套接字接口)。通常将套接字函数实现为系统调用, 这些系统调用会陷入内核,并调用各种内核模式的 T CP / IP 函数。 # T CP/ IP 实际是一个协议族, 其中每一个都提供不同的功能。例如, IP 协议提供基本的命名方法和递送机制,这种递送机制能够从一台因特网主机往其他主机发送包,也叫做 数 据报 (datagram ) 。IP 机制从某种意义上而言是不可靠的, 因为, 如果数据报在网络中丢失或者重复,它 并不会试图恢复。U DP (Unreliable Datagram Protocol, 不可靠数据报协议)稍微扩展了IP 协议, 这样一来, 包可以在进程间而不是在主机间传送。T CP 是一个构建在 IP 之上的复杂协议, 提供了进程间可靠的全双工(双向的)连接。为了 简化讨论, 我们将 T CP / IP 看做是一个单独的整体协议。我们将不讨论它的内部工作,只 讨 论 T CP 和\nIP 为应用 程序提供的某些基本功能。我们将不讨论 UDP。\n从程序员的角度,我 们可以把因特网看做一个世界范围的主机集合, 满足以下特性:\n主机集合被映射为一组 32 位的 IP 地址。 这组 IP 地址被映射为一组称为因特 网域名 (I ntern et domain name) 的标识符 。\n因特网主机上的进程能够通过连接(connection) 和任何其他因特网主机上的 进程通信。接下来三节将更详 细地讨论 这些基本的因特网概念。\n日日1P v4 和 1P v6\n最初的 因特 网 协议, 使 用 32 位地址, 称 为 因 特 网 协议版本 4 ( In t ern et Protocol Version 4, IP v4) 。1996 年, 因 特 网 工程任务组织 (I nte rn et Engin eering Task Force, IETF)提出了一 个新版 本的 IP , 称为 因特 网协议版本 6 CIP v6 ) , 它使 用的 是 128 位地址, 意在替代 IP v4。但是直到 2015 年, 大约 20 年后, 因特 网 流量的 绝大部 分还是由 IP v4 网络 承载的。例如, 只有 4% 的访问 Googl e 服务的用 户使 用 IP v6 [ 42] 。 # . 因为 IP v6 的使用率较低, 本 书 不会 讨论 IP v6 的细 节, 而只是集中 注意 力 于 IP v4 背后的 概念。当我们谈论因特 网 时, 我们指的是基于 IP v4 的因特 网。 但是, 本章后 面介绍的 书写客 户端 和服务器的 技术是基于现代接口的,与 任何特殊的协议 无关。\n11. 3. 1 IP 地 址\n一个 IP 地址就是一个 32 位无符号整数。网络程序将 IP 地址存放在如图 11-9 所示的\nIP 地址结构中。\nI* IP address structure *I\nstruct in_addr { # code/netp/netprfagment.sc\nuint32_t s_addr; I* Address in network byte order (big-endian) *I\n};\ncode/netp/nefrtpagments.c # 图 11-9 IP 地址结构\n把一个标量地址存放在结构中, 是套接字接口早期 实现的不幸产物。为 IP 地址定义一个标扯类型应该更有意义, 但是现在更改已经太迟了 , 因为已经有大量应用是基于此的。 # 因为因特网主机可以有不同的主机字节顺序, T CP / IP 为任意整数数 据项定义了统一的网络宇节顺序 ( network byte order)(大端字节顺序), 例如 IP 地址, 它放在包头中跨过网络被\n携带。在 IP 地址结构中存放的地址总是以(大端法)网络字节顺序存放的, 即使主机字节顺序\n(host byte order) 是小端法。U nix 提供了下面这样的函数在网络和主机字节顺序间实现转换。\n#include \u0026lt;arpa/inet.h\u0026gt;\nuint32_t htonl(uint32_t hostlong); uint16_t htons(uint16_t hostshort);\nuint32_t ntohl(uint32_t netlong); uint16_t ntohs(unit16_t netshort);\n返回: 按 照 网 络 字节顺序的值。\n返回: 按 照 主 机 字 节顺序的值。\nho t n l 函数将 32 位整数由主机字 节顺序转换 为网络字节顺序。n t o h l 函数将 32 位整数从网络字节顺序转换为主机字节。h t o n s 和 n t o h s 函数为 1 6 位无符号整数执行相应的转换。注意, 没有对应的处 理 64 位值的函数。\nIP 地址通常是以一种称为点分十进制表示 法来 表示的,这 里, 每个 字节由它的十进制值表示, 并且用句点和其他字节间分开。例如, 1 28 . 2 . 1 9 4 . 2 42 就是地址 Ox 8 0 0 2 c 2 f 2 的点分十进制 表示。在 L in u x 系统上,你 能够使用 HOST NAME 命令来确定你自己主机的点分十进制地址:\nlinux\u0026gt; hostname -i 128.2.210.175\n应用程序使用 i ne 七主 t on 和 i ne t _n t op 函数来实现IP 地址和点分十进制串之间的转换。\n#include \u0026lt;arpa/inet.h\u0026gt;\nint inet_pton(AF_INET, const char *src, void *dst);\n返回: 若 成 功则 为 1. 若 s r c 为非 法点 分 十进制地址则 为 o, 若 出错 则 为 一1。\nconst char *inet_ntop(AF_INET, const void *src, char *dst,\nsocklen_t size);\n返回: 若 成 功 则指向点 分 十进制 字符 串的 指 针 , 若出错 则 为 NU LL .\n在这些函数名中, \u0026quot; n\u0026quot; 代表网络 , \u0026quot; p \u0026quot; 代表表示。它们可以 处理 3 2 位 1P v4 地址 ( AF_IN­\nET) ( 就像这里展示的那样), 或者 1 28 位 1P v6 地址 ( AF_ IN ET 6) ( 这部分我们 不讲)。\ni ne t _p t o n 函数将一个点分十进制串 ( sr c ) 转换为一个二进制的网络字节顺序的 IP 地址( d s t ) 。如果 sr c 没有指向 一个合法的点分十进制字符串, 那么该函数就返回 0。任何其他错误会返回—1, 并设置 err n o 。相似地, i n e t _ n 七o p 函数将一个二进制的网络字节顺序的 IP 地址( sr c ) 转换为它所对应的点分十进制表示, 并把得到的以 n ull 结尾的字符串的最 多 s i z e 个字节复制到 d s 七。\nl \u0026lsquo;ia 勹 练 习 题 11. 1 完成下表:\n笠 练习题 11. 2 编 写程序 h e x 2d d . c , 将它的十六进制参数转换为点分十进制串并打印\n出结果。例如 # linux\u0026gt; ./hex2dd Ox8002c2f2 128.2.194.242\n练习题 11. 3 编 写程序 d d 2h e x . c , 将它的点分十进制参数转换为十六进制数并打印\n出结果。例如 # linux\u0026gt; ./dd2hex 128.2.194.242 Ox8002c2f2\n11. 3. 2 因特网域名 # 因特网客户端 和服务器互相通信时使用的是 IP 地址。然而, 对于人们而言, 大整数是很难记住的, 所以因特网也定义了一组更加人性化的域名( do m ain name), 以及一种将域名映射到 IP 地址的机制。域名是一串用句点分隔的单词(字母、数字和破折号),例 如 wha l e s h ar k . i c s . c s . c mu . e d u 。\n域名集合形成了一个层次结构,每个域名编码了它在这个层次中的位置。通过一个示 例你将很容易理解这点。图 11-10 展示了域名层次结构的一部分。层次结构可以 表示为一棵树。树的 节点表示域名,反 向到根的路径形成了域名。子树称为子域( s u bdo m ain ) 。层次结构中的第一层是一个未命名的根节点。下一层是一组 一级 域名(如s t- le ve l domain # name), 由非营利组织 IC A N N (Internet Corporation for Assigned Names and Numbers,\n因特网分配名字数字协会)定义。常见的第一层域名包括 c o m、e d u 、g o v 、or g 和 ne t 。\n未命名的根\nmil edu gov\nmit emu berkeley\n,,(\u0026rsquo;\\.,\nwhaIleshark wIww\n128.2.210.175 128.2.131.66\ncom\n\amazon\nWWW\n176.32.98.166\n第一层域名第二层域名第三层域名\n图 11-10 因特网域名层次结构的一部分\n下一层是二级( s eco nd- le ve l) 域名, 例如 c mu. e d u , 这些域名是由 ! C A N N 的各个授权代理按照先到先服务的基础分配的。一旦一个组织得到了一个二级域名,那么它就可以在 这个子域中创建任何新的域名了,例 如 c s . c mu . e d u 。\n因特网定义了域名集合和 IP 地址集合之间的映 射。直到 1988 年, 这个映射都是通过一个叫做 HOSTS. TXT 的文本文件来手工维护的。从那以后, 这个映射是通过分布世界范围内的数据库(称为 D N S ( Do m ain Name System, 域名系统))来维护的。从概念上而言, D NS 数据库由上百 万的主机条目结构 ( h o s t entry structur e ) 组成, 其中每条定义了一组域名和一组 IP 地址之间的映射。从数学意义上讲,可 以认为每条主机条目就是一个域名和\nIP 地址的等价类。我们可以用 Lin u x 的 NS LO O K U P 程序来探究 D NS 映射的一些属性, 这个程序能展示与某个 IP 地址对应的域名。e\n每台因特网 主机都有本地定 义的域名 l o c a l h o s t , 这个域名总是映射为回送地址\n(loop back address) 127.0. 0.1:\nlinux\u0026gt; nslookup localhost Address: 127.0.0.1\nl ocal host 名字为引用运行在同一台机器上的客户端和服务器提供了一种便利和可移植的方式 , 这对调试相当有用。我们可以使用 H OST NAME 来确定本地主机的实际域名:\nlinux\u0026gt; hostname\nwha l e s har k . i c s . cs . cmu . edu\n在最简单的情况 中, 一个域名和一个 I P 地址之间是一一映射: # linux\u0026gt; nslookup whaleshark . i sc Address: 128.2. 210. 175\n. cs. emu. edu\n然而, 在某些情况下 ,多 个域名可以映射为同一个 IP 地址: # linux\u0026gt; nslookup cs.mit.edu Address: 18.62.1.6\nlinux\u0026gt; nslookup eecs.mit.edu Address: 18.62.1.6\n在最通常的情 况下 ,多 个域名可以映 射到同一组的多个 IP 地址: # linux\u0026gt; nslookup www . t 忖 i t t er . com\nAddress: 199.16.156.6 Address: 199. 16. 156. 70 Address: 199.16.156.102 Addr e s s : 199.16.156.230 linux\u0026gt; nslookup twitter.com\nAddress: 199.16.156.102 Address: 199.16.156.230 Address: 199.16. 156.6 Address: 199.16. 156.70 最后, 我们注意到某些 合法的域名没有映射到任何 IP 地址: # linux\u0026gt; nslookup edu\n*** Can\u0026rsquo;t find edu: No answer\nlinux\u0026gt; nslookup ics.cs.cmu.edu\n*** Can\u0026rsquo;t find i cs. cs . cmu . edu : No answer\n豆日 有多少因特网主 机? # 因特网软件协会 (I ntern et Software Consortium, www. isc. org) 自从 198 7 年以后,每年进行 两次因特网 域名调查。这个调查通过计算已经分配给一个域名的 IP 地址的数量来估算因特网主机的数量,展 示了一种令人吃惊的趋势。自从 198 7 年以来,当 时一共大约有 20 000 台因特\n网主机,主机的数量已经在指数性增长。到2015 年,已经有大约1 000 000 000台因特网主机了。\ne 我们重新调整了 NSLOOKUP 的输出以 提高可读性。\n11. 3. 3 因特网连接\n因特网客户端和服务器通过在连接上发送和接收字节流来通信。从连接一对进程的意义上而言,连接是点对点的。从数据可以同时双向流动的角度来说,它是全双工的。并且从(除了一些如粗心的耕锄机操作员切断了电缆引起灾难性的失败以外)由源进程发出的字 节流最终被目的进程以它发出的顺序收到它的角度来说,它也是可靠的。 # 一个套接宇是 连接的一个端点。每个套接字都有相应的套接字地 址, 是由一个因特网地址和一个 16 位的整数端口。 组成的,用 “ 地址: 端口” 来表示。\n当客户端发起一个连接请求时,客户端套接字地址中的端口是由内核自动分配的,称 为临时 端口 ( e p hem e ra l por t ) 。然而, 服务器套接字地址中的端口通常是某个知名端口, 是和这个服务相 对应的。例如, W e b 服务器通常使用端口 80 , 而电子邮件服务器使用端 口 25 。每个具有知名端口的服务都有一个对应的 知名的服务名。例如, W e b 服务的知名名字是 h t t p , email 的知名名字是 s m七p 。 文件/ e t c / s er v i c e s 包含一张这台机器提供的知名名字和知名端口之间的映射。\n一个连接是由它两端的套接字地址 唯一确定的。这对套接字地址叫做套接宇对 ( sock et # pair), 由下列元组来表示:\n(cliaddr:cliport, servaddr:servport) # 其中 c l i a d dr 是客户端的 IP 地址 , c l i por t 是客户端的端口, s er v a d d r 是服务器的 IP 地址 , 而 s er v p or t 是服务器的端口 。例如,图 11 - 11 展示了一 个 W eb 客户端和一个 Web 服务器之间的连接。\n客户端套接字地址\n128.2.194.242:51213\n服务器套接字地址\n208.216.181.15:80\n客户端主机地址\n128.2.194.242\n图 11-11 因特网连接分析\n服务器主机地址\n208.216.181.15\n在这个示例中, W e b 客户端的套 接字地址 是\n128.2.194.242:51213\n其中端口号 51 213 是内核分配的临时端口号。Web 服务器的套接字地址是\n208 . 216 . 181 . 15 : 80\n其中端口号 80 是和 W e b 服务相关联的知名端口号。给定这些客户端和服务器套接字地址,客户端和服务器之间的连接就由下列套接字对唯一确定了: # (128.2.194.242:51213, 208.216.181.15:80)\nm 因特网的起源\n因特 网是 政府、学校 和工业界合作的最成功的 示例之一。它成 功的 因素很 多 , 但 是我们认 为有 两点尤其 重要: 美国政 府 30 年持 续不 变的 投资, 以及充满激 情的 研究人 员 # e 这些软件端口与网络中交换机和路由器的硬件端口没有关系。\n对麻省理工学院的 Dave Cla rke 提出的 “粗略一致和能用的 代码” 的投入。\n因特 网的种 子是在 1957 年播下的 , 其时正值冷战的高峰 , 苏联发射 Sput nik , 笫一颗人造地球卫星,震 惊了世界 。作 为响 应, 美国政 府创建了 高级 研 究计划署( ARPA) , 其任务就是重建美国在科学与技 术上的领导地位。1967 年, ARPA 的 Lawrence Roberts 提出了一 个计 划,建 立一个叫做 A阳 决 NET 的新网络。 第一 个 ARPANET 节点是在 1969年建立并运行的 。到 1971 年, 已有 13 个 A团汛 NET 节点 , 而且 email 作为第一 个重要的网络应 用涌现 出来。\n1 972 年,Ro bert Ka hn 概括了网 络互联的一般原则: 一组互相连接的网络 , 通过叫做“路由器”的黑盒子按照“以尽力传送作为基础”在互相独立处理的网络间实现通 信。1974 年, Ka hn 和 Vinton Cerf 发 表 了 T CP / IP 协议 的第一本详细资料 , 到 1982 年它成为 了 AR P A NE T 的标准网络 互联协议 。19 83 年 1 月 1 日 , AR PA NET 的每个节点都切换到 T CP / IP , 标志着全球 IP 因特 网的 诞生。\n1985 年, P aul Mocka petris 发明 了 D NS , 有 1 000 多 台 因特 网 主机。1986 年, 国 家科 学基金 会( NS F ) 用 56KB / s 的电话线连接 了 13 个节点 , 构建了 NSF NET 的骨 干网。其后在 1988 年升级到 1. 5MB/ s T l 的连接速率, 1 991 年为 45MB/ s T 3 的连接速率。到\n1988 年, 有 超过 50 000 台 主机。1989 年, 原始的 ARP A NET 正式 退休 了。 199 年, 已经有 几乎 10 000 000 台因特 网主机了 , NSF 取 消 了 NS F NE T , 并且用基于由公众网络接入点连接的私有商业骨干网的现代因特网架构取代了它。\n11. 4 套接字接口 # 套接宇接 口( socket inte rface ) 是一组函数,它 们 和 U nix I / 0 函 数 结 合 起 来 ,用 以 创建网 络应用 。大多 数 现代系统上都实现套接字接口, 包 括 所 有 的 U nix 变种、Windows 和Macintos h 系统。图 11-12 给出了一个典型的客户端-服务器事务的上下文中的套接字接口概述。当讨论各个函数时,你可以使用这张图来作为向导图。\n客户端 服务器\ngetaddrinfo # s ocke 七\nopen_l i s t enf d\nopen_cl i ent f d bi nd\nl i s t en\nconnec 七 连接请求 accept\nr i o_wr i t en r i or_ ead l i neb # rio_readlineb r i o_wr i t e n 等待来自下一个 # 客户端的连接请求\nEOF\nclose r i o_r eadl i neb # c l os e\n图 11-12 基于套接 字接口的网络应用概述\nm 套接字接口的起源 # 套接宇接 口是加 州 大学伯 克利分校的研究人员在 20 世 纪 80 年代早期提 出的 。因 为这个原因,它也经常被叫做伯克利套接宇。伯克利的研究者使得套接宇接口适用于任何 底层的协议。笫一 个实现的就是针对 T CP / IP 协议的,他 们把它 包括 在 U n ix 4. 2BS D 的内核里,并且分发给许多学校和实验室。这在因特网的历史上是一个重大事件。几乎一 夜之间,成 于上万的人们接触到 了 T C P / IP 和 它的 源代 码 。 它引起 了 巨 大的轰动, 并激发了新的 网络 和 网络互联研 究的浪潮。\n11. 4. 1 套接字地址结构\n从 Lin u x 内核的角度来看, 一 个 套 接 字 就 是 通信的一个端点。从 Lin u x 程序的角度来看,套接字就是一个有相应描述符的打开文件。\n因特网的套接字地址存放在如图 11 -1 3 所示的类型为 s o c ka d d r _ i n 的 1 6 字节结构中。对于因特网应用, s i n _ f a mi l y 成 员 是 AF_INET, sin _por t 成员是一个16 位的端口号, 而 s i n a d dr 成员 就 是 一 个 32 位 的 I P 地址。IP 地址和端口号总是以网络字节顺序(大端法)存放的。\ncode/netp/netpfragments.c\nI* IP socket address structure *I\nstruct sockaddr_in {\nuint16_t sin_family; I* Protocol family (always AF_INET) *I uint16_t sin_port; I* Port number in network byte order *I struct in_addr sin_addr; I* IP address in network byte order *I unsigned char sin_zero[8]; I* Pad to sizeof(struct sockaddr) *I\n};\nI* Generic socket address structure (for connect, bind, and accept) *I\nstruct sockaddr {\nuint16_t sa_family; I* Protocol family *I\nchar sa_data[14]; I* Address data *I\n} ;\n田日_ in 后缀意味什么?\n图 11-13 套接字地址结构\ncode/netp/netpfragments.c\nin 后缀是互联 网络 ( in t ern e t ) 的缩写, 而不 是输入( in put ) 的缩写。\nc o n n e c t 、 b i n d 和 a c c e p t 函数要求一个指向 与协 议 相关的 套 接字地址结 构的 指针。套接字接口的设计者面临的问题是,如何定义这些函数,使之能接受各种类型的套接字地 址结构。今天我们可以使用通用的 V O 过* 指针, 但 是 那时在 C 中并不存在这种类观的指针。解决办法是定义套接字函数要求一个指向通用 s o c ka d dr 结构(图 11 -1 3 ) 的指针, 然后要求应用程序将与协议特定的结构的指针强制转换成这个通用结构。为了简化代码示 例, 我们跟随 S te ven 的指导,定 义 下 面的类型:\ntypedef struct sockaddr SA;\n然后无论何时需要将 s o c ka d dr _ i n 结构强制转换成通用 s o c ka ddr 结构时, 我们都使用这个类型。\n11. 4. 2 s o c k e 七 函 数\n客户端和服务器使用 s o c ke t 函数来创建一个套接字描 述符( s ock e t d es cri pto r ) 。\n#include \u0026lt;sys/types.h\u0026gt;\n#include \u0026lt;sys/socket.h\u0026gt;\nint socket(int domain, int type, int protocol);\n返回: 若 成 功则 为非 负描 述 符 , 若出铢则 为 一1.\n如果想要使套接字成为连接的一个端点, 就用如下硬编码的参数来调用s o c ke t 函数:\nclientfd = Socket(AF_INET, SOCK_STREAM, O);\n其中, AF_INET 表明我们 正在使用 32 位 IP 地址,而 SOCK_ST REAM 表示这个套接字是连接的一个端点。不过最好的方法是 用 g e 七a d dr i n f o 函数( 11. 4. 7 节)来自动生成 这些参数, 这样代码就与协议无关了。我们会在 1 1. 4. 8 节中向你展示如何配合 s o c ke t 函数来使用 g e t a d dr i n f o。\ns o c ke t 返回的 c l i e n 七f d 描述符仅是部分打开的,还 不能用于读写。如何完成 打开套接字的工作,取决于我们是客户端还是服务器。下一节描述当我们是客户端时如何完成 打开套接字的工作。\n11. 4. 3 c o n n e c 七函 数\n客户端通过调用 c o n ne c 七函数来建立和服务器的连接。\n#include \u0026lt;sys/socket.h\u0026gt;\nint connect(int clientfd, const struct sockaddr *addr, socklen_t addrlen);\n返回: 若 成 功 则为 o, 若 出错 则 为一l 。\nc o n ne c t 函数试图与套接字地址为 a ddr 的服务器建立 一个因特网连接,其中 a dd rl en 是 s i z e o f (s o c ka d dr _ i n ) 。c o nne c t 函数会阻塞, 一直到连接成功建立或是发生错误。如果成功, c l i e n t f d 描述 符现在就准备好可以读写了,并 且得到的连接是由套接字对\n(x:y, addr.sin_addr:addr.sin_port)\n刻画的, 其中 x 表示客户端的 IP 地址 , 而 y 表示临时端口,它唯 一地确定了客户端主机上的客户端进程。对于 s o c k e t , 最好的方法是用 g e t a d dr i n f o 来为 c o n n e c t 提供参数\n(见 11. 4. 8 节)。\n11 . 4. 4 b i nd 函数\n剩下的套接字函数- b i nd 、 江 s t e n 和 a c c e p t , 服务器用它们来和客户端建立连接。\n#include \u0026lt;sys/socket.h\u0026gt;\nint bind(int sockfd, const struct sockaddr *addr, socklen_t addrlen);\n返回: 若 成 功则 为 o, 若 出错 则 为— l。\n区 nd 函数告 诉内 核将 ad dr 中的服务器套接字地址和套接字描述符 s oc kf d 联系起来。参数 a d d r l e n 就是 s i z e o f ( s o c ka ddr _ i n ) 。 对 千 s o c ke t 和 c on ne c t , 最好的方法是用 ge t a d d r i nf o 来为 b i nd 提供参数(见11. 4. 8 节)。 # 11. 4. 5 l i s 七 e n 函 数\n客户端是发起连接请求的主动实体。服务器是等待来自客户端的连接请求的被动实 体。默认情况下,内 核会认为 s o c k吐 函数创建的描述符对应于主动套接 宇 ( act ive sock­ # et), 它存在于一个连接的客户端。服务 器调用 1 工s t e n 函 数 告 诉 内 核 , 描 述符是被服务器而不是客户端使用的。\n#include \u0026lt;sys/socket.h\u0026gt;\nint listen(int sockfd, int backlog);\n返回: 若 成 功 则 为 o , 若 出错 则为 一1。\n让 s t e n 函数将 s o c kf d 从一个主动套接字转化为一个监听套接宇 (l is te ning socket), 该套接字可以接受来自客户端的连接请求。ba c kl og 参数暗示了内核在开始拒绝连接请求之前,队列 中要 排队 的 未 完成的连接请求的数量。b a c kl og 参数的确切含义要求对 TCP/ IP 协议的理解, 这 超 出 了 我们讨论的范围。通常我们会把它设 置为一个较大的值, 比如 10 24。 # 4. 6 a c c e p 七 函 数 服务器通过调用 a c c e p t 函数来等待来自客户端的连接请求。 # #include \u0026lt;sys/socket.h\u0026gt;\nint accept(int listenfd, struct sockaddr *addr, int *addrlen);\n返回: 若 成 功 则 为非 负连 接 描 述 符 , 若 出错 则 为— l 。\na c c e p t 函数等待来自客户端的连接请求到达侦听描述符 l i s t e n f d , 然后在 a ddr 中填写客户端的套接字地址,并 返回一个已连接描述符( connect ed descriptor) , 这个描述符可被用来利用 U nix I/ 0 函数与客户端通信。 # 监听描述符和巳连接描述符之间的区别使很多人感到迷惑。监听描述符是作为客户端 连接请求的一个端点 。它通常被创建一次,并 存 在 千服务器的整个生命周期。已连接描述符是客户端和服务器之间已经建立起来了的连接的一个端点。服务器每次接受连接请求时 都会创建一次, 它只 存 在 于服务器为一个客户端服务的过程中。\n图 11-14 描绘 了监 听描 述 符 和已 连接描述 符的 角色。在第一 步 中, 服 务 器 调 用\naccept, 等待连接请求到达监听描述符,具 体 地我们设定为描述符 3。回忆一下, 描 述 符\n0~ 2 是预留给了标准文件的。\n在第二步中,客 户端调用 c o nne c t 函数, 发 送 一个连接请求到 l i s t e n f d。第三步, a c c e p t 函 数 打 开了一个新的已连接描述符 c o nn f d ( 我们假设是描述符 4 )\u0026rsquo; 在 c l i e n 七f d 和 c o nn f d 之间建立连接,并 且 随 后 返 回 c o n n f d 给应用程序。客户端也从 c on ne c t 返回, 在这一点以后, 客户端和服务器就可以分别通过读和写 c l i e n t f d 和 c o nn f d 来回传送数据了。\n曰clientfd\nl i st enfd(3)\nl 服务器阻塞在 a cce pt , 等待监听描述符l i s t e nf d 上的连接请求。\n连接请求 li s t e n f d (3 )\n三 ------- -----] 三\nc l i e n 七 f d\n客户端通过调用和阻塞在 conne c t , 创建连接请求。 # li s t e n f d (3 )\nc l i e n t f d c onn f d ( 4 )\n服务器从 ac ce pt 返回 connf d。客户端从 co nne c t 返回。现在在 c巨 ent f d 和co nn f d 之间已经建立起了连接。 图 11-14 监听描述符和已 连接描述符的角色\n田 日 为何 要有监听描述符和已连接描述符之间的区别? # 你可能很想知道为什 么套 接 宇接 口要区别监听描述符和已连接描述符。乍 一看,这像 是不必要的复杂化。然而, 区分这两者被 证明是 很有用的 , 因 为 它使 得 我们可以建 立并发服务器, 它能够同时处理许多客 户端 连接。例如,每 次一个连接请求到达监听描述符时, 我们可以派生( fork) 一个新的进程, 它通 过 已连接描述符与客 户端通信。在第 12 章 中将介绍更多 关于并发服务器的 内容。\n4. 7 主机和服务的转换\nLinu x 提供了一些强大的函数(称为 ge t a ddr i n f o 和 ge t name i n f o ) 实现二进制套接字地址 结 构 和主机名、主机地址、服务名和端口号的字符串表示之间的相互转化。当和套接字接口 一起 使用 时 , 这些函数能使我们编写独立于任何特定版本的 IP 协议的网络程序。\nge ta d d rinfo 函数\ng e t a d dr i n f o 函数将主机名、主机地址、服务名和端口号的字符串表示转化成套接字 地 址 结 构 。 它 是巳弃用的 g e t h o s 七b y n a me 和 g e t s e r v b yn a me 函数 的 新的替代品。和以前 的那些 函数不同 , 这 个 函 数 是 可重入的(见12. 7. 2 节),适用 于任 何 协议 。\n# i nc l ude \u0026lt;s y s / t yp e s . h\u0026gt;\n#include \u0026lt;s ys / s ocke t . h \u0026gt;\n#include \u0026lt;net db . h \u0026gt;\nint getaddrinfo(const char *host , const char *ser vi ce, const struct addrinfo *hints,\nstr uct addrinfo **result ) ;\n返回: 如 果 成 功 则 为 0 . 如 果 错 误 则 为非 零的错误代码。\nvoid fr eeaddri nfo(str uct addr i nfo *resul t ) ;\nconst char *gai _s tr err or ( i nt errcode);\n返回: 无.\n返回: 错 误 消息。\n给定 ho s t 和 s er v i c e ( 套接字地址的 两个组成部分),g e t a d d r i n f o 返回 r e s ult ,\nr e s u l t 一个指向 a d dr i n f o 结构的链表,其 中 每个结构指向一个对应于 h o s t 和 s e r vi ce ,\n的套接字地址结构(图11-15) 。\nresult # addr i nf o结构\nai canonname 套接字地址结构\nai addr # ai nex 七\nNULL\nai addr # ai next\nNULL\nNUL L\n图 11-15 get addr i nfo 返 回的数据结构\n在客户端调用了 ge t addr i nfo 之后,会 遍历这个列表,依 次尝试每个套 接字 地址 ,直 到调用 s oc ke t 和 conne c t 成功,建立起 连接。类似地,服 务器会尝试遍历列表中的每个套接字地址, 直到调用 s oc ke t 和 b i nd 成功,描 述符会 被绑定到一个合法的套接字地址。为了避免内存泄漏,应用程序必须 在最 后调用 fr ee a ddr i nf o , 释放 该链表。如果 ge t addr i nfo 返回非零的错误代码, 应用程序可以调用 ga i _s tr eer or , 将该代码转换成消息字符串。 # ge t addr i nfo 的 hos t 参数 可以是 域名,也 可以是数字地址(如点分 十进制 IP 地址)。se rv i ce 参数可以是服务名(如h七七p )\u0026rsquo; 也 可以是十进制端口号。如果不想把主机名转换成地址, 可以把 hos t 设置为 NULL。对 s e rv i ce 来说也是一样。但是必须指定两者中至少一个。\n可选的参数 hi n t s 是一个 a ddr i nf o 结构(见图 11-16 ) , 它提供对 g e t a ddr i n f o 返回的套接字地址列表的更好的控制。如果要传递 h i nt s 参数,只 能 设 置 下 列 字 段 : ai_fam­ il y、ai _ sockt ype、ai _ pr ot ocol 和 ai _ f l ags 字段。其他字段必须设置为 0 (或NU LL) 。实际中, 我们用 me ms e 七将 整 个 结 构 清 零 ,然 后 有 选择地设置一些字段:\ng e t a ddr i n f o 默 认 可以返回 IP v4 和 IPv6 套接字地址。a i _ f a m过 y 设 置 为 AF _IN­ ET 会将列表限制为 IPv4 地址;设 置 为 AF _ INET 6 则 限 制 为 IP v6 地址。 对于 h o s t 关联的每个地址, g e t a d dr i n f o 函 数 默 认 最 多 返 回 三个 a ddr i n f o 结构, 每个的 a i _s oc kt yp e 字段不同:一 个 是 连接, 一 个 是数据报(本书未讲述),一 个是 原 始 套 接 字(本 书未 讲 述 )。 a i _ s o c k t yp e 设 置为 SOCK_STREAM 将列表限制为对每个地址最多一个 a d dr i n f o 结构,该 结 构 的 套 接 字 地址可以作为连接的一个端点。这是所有示例程序所期望的行为。 # a i _ fl a gs 字段是一个位掩码, 可 以 进一步修改默认行为。可以把各种值用 OR 组合起来得到该掩码。下面是一些我们认为有用的值:\nAI_ADDRCONFIG。如果在使用连接,就 推荐使用这个标志 [ 34] 。它要求 只有当\n本地主机被配置为 IPv4 时 , ge 七a ddr i nfo 返 回 IPv4 地址。对 IPv6 也是类似。\nAI_CANONNAME 。a i _c a no n na me 字 段默认为 NU LL。如果设 置了该标志, 就是告诉 ge t a d dr i n f o 将列表中第一个 ad dr i n f o 结构的 a i _ca no nna me 字 段 指 向h o s t 的 权 威(官 方 )名字(见 图 11 - 1 5) 。 # AI_NU MERICSERV 。参数 s er v i c e 默认可以是服务名或端口号。这个标志强制参数 s er v i c e 为端口号。\nAI—P ASSIVE。ge t a ddr i n f o 默认返回套接字地址, 客户端可以 在调用 c o nne c t 时用作主动套接字。这个标志告诉该函数,返回的套接字地址可能被服务器用作监听套接字。在这种情况中,参 数 ho 江 应该 为 NU LL。得到的套接字地址结构中的地址字段会是通配符地址 ( w ild card address) , 告诉内核这个服务器会接受发送到该主机所有 IP 地址的请求。这是所有示 例服务器所期望的行为。\ncode/netp/netpfragments.c\nstruct addrinfo { int\nint int int char\nsize_t\nai_flags; I* Hints argument flags *I ai_family; I* First arg to socket function *I ai_socktype; I* Second arg to socket function *I ai_protocol; I* Third arg to socket function *I\n*ai_canonname; I* Canonical hostname *I ai_addrlen; I* Size of ai_addr struct *I\nstruct sockaddr *ai_addr; struct addrinfo *ai_next;\n};\nI* Ptr to socket address structure *I I* Ptr to next item in linked list *I\ncodelnetp/netpfragments.c\n图 11-16 ge t addr i nfo 使用的 addr i nf o 结构\n当 g e t a d dr i n f o 创建输出列表中的 a d dr i n f o 结构时, 会填写每个字段,除 了 a i\nf l a g s 。a i _a d dr 字段指向一个套接字地址结构, a i _ a d dr l e n 字段给出这个套接字地址结构 的大小, 而 a i _ n e x t 字段指向列表中下一个 a d dr i n f o 结构。其他字段描述这个套接字地址的各种属性。\ng e t a d dr i n f o 一个很好的方面是 a d dr i n f o 结构中的字段是不透明的, 即它们可以直接传递给套接字接口中的函数, 应用程序代码无需再做任何处理。例如, a i _ f a mi l y、a i\ns o c k t y p e 和 a i _ p r o t o c o l 可以 直接传递给 s o c ke t 。类似地, a i _ a d dr 和 a i _ a d d r l e n 可以直接传递给 c o n n e c t 和 b i nd 。这个强大的属性使得我们编写的客户端和服务器能够独立于某个特殊版本的 IP 协议。\nge tna me info 函数\ng e t n a me i n f o 函数和 g e t a d dr i n f o 是相反的, 将一个套接字地址结构转换 成相应的主机和服务名字符串 。它是已弃用的 g e t h o s t b y a d dr 和 g e t s er v b y p or t 函数的新的替代品 ,和 以前的那些函数不同, 它是可重入和与协议无关的。\n#include \u0026lt;sys/socket.h\u0026gt;\n#include \u0026lt;netdb.h\u0026gt;\nint getnameinfo(const struct sockaddr *sa, socklen_t salen, char *host, size_t hostlen,\nchar *service, size_t servlen, int flags);\n返回: 如 果 成 功则 力 o. 如果错误则为非零的错误代码。\n参数 s a 指向大小为 s a l e n 字节的套接字地址结构, h o s t 指向大小为 h o s t l e n 字节的缓冲区, s e rv i c e 指向大小为 s er v l e n 字节的缓冲区。g e t narne i n f o 函数将套接字地址结构 sa 转换成对应的主机和服务名字符串,并将它们复制到 ho s t 和 s e rv c i ce 缓冲区。如果 ge t narn-\ne i nf o 返回非零的错误代码, 应用程序可以调用ga i _s tr err or 把它转化成字符串。\n如果不想要主机名 ,可 以把 h o s t 设置为 N U L L , h o s 七l e n 设置为 0 。对服务字段来说也是一样。不过, 两者必须设置其中 之一。\n参数 f l a g s 是一个位掩码, 能够修改默认的行为。可以 把各种值用 O R 组合起来得到该掩码。下面是两个有用的值:\nN I_N U M E R IC H OS T 。g e t na me i n f o 默认试图返回 h o s t 中的域名。设置该标志会使该函数返回一个数字地址字符串。\nNI_N U MERICSER V。ge t name i n f o 默认会检查/ e 七c / s er v i c e s , 如果可能,会返回\n服务名而不是端口号。设置该标志会使该函数跳过查找,简单地返回端口号。\n图 11-17给出了一个简单的程序 , 称为 H OST INF O . 它使用ge t a ddr i nfo 和 ge t name i nf o 展示出域名到和它相关联的 IP 地址之间的映射。该程序类 似于 11. 3. 2 节中的 NSLOO KU P 程序。\n1 #include \u0026ldquo;csapp.h\u0026rdquo;\n2\n3 int main(int argc, char **argv)\n4 {\ns struct addrinfo *P, *listp, hints;\nchar buf[MAXLINE];\nint re, flags;\n8\n9 if (argc != 2) {\nfprintf (stderr, \u0026ldquo;usage: i儿 s \u0026lt;domain name\u0026gt;\\n\u0026rdquo;, argv[O]);\nexit(O);\n12 }\n13\nI* Get a list of addrinfo records *I\nmemset(\u0026amp;hints, 0, sizeof(struct addrinfo));\n· hi nt s . a i_ f am辽 y = AF_INET; I* IPv4 only *I\nhints.ai_socktype = SOCK_STREAM; I* Connections only *I\ncode/netp/hostinfo.c\nif ((re = getaddrinfo(argv[1], NULL, \u0026amp;hints, \u0026amp;listp)) != 0) {\nfprintf(stderr, \u0026ldquo;getaddrinfo error: %s\\n\u0026rdquo;, gai_strerror(rc));\nexit(!);\n21 }\n22\nI* Walk the list anddisplay each IP address *I\nflags= NI_NUMERICHOST; I* Display address string instead of domain name *I\nfor (p = listp; p; p = p-\u0026gt;ai_next) {\nGetnameinfo (p-\u0026gt;ai_addr, p-\u0026gt;ai_addrlen, buf, MAXLINE, NULL, 0, flags) ;\nprintf(\u0026quot;%s\\n\u0026quot;, buf); 28 }\n29\nI* Clean up *I\nFreeaddrinfo(listp); 32\n33 exit(O);\n34 }\ncode/netp/hostinfo.c\n图 11-17 H OSTINFO 展示出域名到 和它相关联的 IP 地址之间的 映射\n首先, 初始化 h i n t s 结构,使 g e t a ddr i n f o 返回我们想要的地址。在这里,我 们想查找 32 位的 IP 地址(第16 行),用 作连接的端点(第1 7 行)。因为只想 ge t a ddr i nf o 转换域名, 所以用 s er v i c e 参数为 N U L L 来调用它。\n调用 g e t a ddr i n f o 之后, 会遍历 a ddr i n f o 结构,用 g e t na me i n f o 将每个套接字地址转换成点分十进制地址字符串 。遍历完列表之后 , 我们调用 fr e e a d dr i n f o 小心地释放这个列表(虽然对于这个简单的程序来说 ,并 不是严格需要这样做的)。\n运行 H OST INF O 时,我们 看到 t wi 七七e r . c om 映射到了四个 IP 地址, 和 11. 3. 2 节用\nNS LO O K U P 的结果一样。\nlinux\u0026gt; ./hostinfo t 甘i t t er .com 199.16.156.102 # 199.16.156.230\n199.16.156.6\n199.16.156.70\n练习题 11. 4 函数 ge t a dd r i n fo 和 ge t narne i nf o 分别包含 了 i ne t _p t o n 和 i ne t _ n t op 的功能,提供 了 更高 级别的、 独 立于任 何特殊地 址格 式的 抽象。想看看这 到底 有多方便, 编写 H OST INFO ( 图 11-17 ) 的一个版本,用 i ne t 主 t on 而不是 ge t narne i nf o 将每个套接字地址转换成点 分十进制地址 字符 串。\n4. 8 套接字接口的辅助函数\n初学时, g e t na me i n f o 函数和套接字接口看上去有些可怕。用高级的辅助函数包装一下会方便很多 , 称为 o pe n_ c l i e nt f d 和 o p e n 让 s t e n f d , 客户端和服务器互相通信时可以使用这些函数。\no pe n_c lie ntfd 函数 # 客户端调用 o p e n_ c l i e n t f d 建立与服务器的连 接。\no pe n_c l i e nt f d 函数建立与服务器的连接,该 服务器运行 在主机 h os t na me 上, 并在端口号 p or t 上监听连接请求。它返回一个打开的套接字描述符,该 描述符准备好 了,可以用 U nix 1/0 函数做输入和输出。图 11-18 给出了 ope n_ c l i e n t f d 的代码。\n我们调用 g e t a ddr i n f o , 它返回 a ddr i n f o 结构的列表,每 个结构指向一个套接字地址结构,可用 于建立与服务器的 连接,该 服务器运行 在 hos t na me 上并监听 po 江 端口。然后遍历该列表, 依次尝试列表中的每个条目, 直到调用 s o c ke t 和 c o n ne 吐 成功。如果c o n ne c t 失败 , 在尝试下一个条目之前, 要小心地关闭套接字描述符。如果 c o n ne c t 成功,我们会释放列表内存,并把套接字描述符返回给客户端,客户端可以立即开始用\nUnix 1/0 与服务器通信了 。\n注意, 所有的代码都与任何 版本的 IP 无关。 s o c ke t 和 c o n ne c t 的参数都是用\ng e 七a dd r i n f o 自动产生的, 这使得我们的代码干净可移植。\nope n_ lis te nfd 函数 # 调用 ope n_l i s t e n f d 函数, 服务器创建一 个监听描述符, 准备好接收连接请求。\n#include \u0026ldquo;csapp.h\u0026rdquo;\nint open_listenfd(char *port);\n返回: 若 成 功则 为描 述 符 , 若出错 则为 一1。\n1 int open_clientfd(char *hostname, char *port) { int clientfd;\n3 struct addrinfo hints, *listp, *Pi\ncode/srdcsapp.c\nI* Get a list of potential server addresses *I\nmemset(\u0026amp;hints, 0, sizeof(struct addrinfo));\nhints.ai_socktype = SOCK_STREAM; I* Open a connection *I\nhints.ai_flags = AI_NUMERICSERV; I* \u0026hellip; using a numeric port arg. *I\nhints.ai_flags I= AI_ADDRCONFIG; I* Recommended for connections *I\nGetaddrinfo(hostname, port, \u0026amp;hints, \u0026amp;listp);\nI * 扣 al k the list for onethat we can successfully connect to *I\nfor (p = listp; p; p = p-\u0026gt;ai_next) {\n/ * Create a socket descriptor *I\nif ((clientfd = socket (p-\u0026gt;ai_family, p-\u0026gt;ai_socktype, p-\u0026gt;ai_protocol))\n\u0026lt; O) continue; I* Socket failed, try the next *I\nI* Connect to the server *I\nif (connect (clientfd, p-\u0026gt;ai_addr, p-\u0026gt;ai_addrlen) != -1)\nbreak; I* Success *I\nClose(clientfd); I* Connect failed, try another *I 22\n23\nI* Clean up *I\n· Fr e e addr i nfo (listp);\nif (! p) / * All connects failed *I\nreturn -1·\nelse I* Thelast connect succeeded *I\nreturn clientfd;\n30 }\ncode/srdcsapp.c\n图 11-18 open_clientfd: 和服务器建立连 接的辅 助函数 。它是可重入 和与协议无关的\nop e n_l i s t e n f d 函数打开和返回一个监听描述符, 这个描述符准备好在端口 p or t 上接收连接请求。图 11 -19 展示了 o pe n_ l i s t e n f d 的代码。\nop e n_ l i s 七e n f d 的 风 格类似于 ope n _ c l i e n七f d 。 调 用 g e t a ddr i n f o , 然后遍历结果列表,直 到调用 s o c ke t 和 b i nd 成功。注意,在 第 20 行, 我们使用 s e t s o c ko p t 函数(本书中没有讲述)来配置服务器,使得服务器能够被终止、重启和立即开始接收连接请求。一个重 启的服务器默认将在大约 30 秒内拒绝客户端的连接请求,这严 重 地阻碍了调试。\n因为我们调用 ge t a d dr i n f o 时, 使 用 了 AI _ PASSIVE 标志并将 h o s t 参数设 置为\nNULL, 每个套接字地址结构中的地址字段会被设置为通配符地址, 这告诉内核这个服务器会接收发送到本主机所有 IP 地址的请求。 # code/srdcsapp.c\n1 int open_listenfd(char *port)\n2 {\n3 struct addrinfo hints, *listp, *p;\n4 int listenfd, optval=l;\n5\n6 I* Get a list of potential server addresses *I memset(\u0026amp;hints, 0, sizeof(struct addrinfo));\nhints.ai_socktype = SOCK_STREAM; hints.ai_flags = AI_PASSIVE I AI_ADDRCONFIG; hints.ai_flags I= AI_NUMERICSERV; Getaddrinfo(NULL, port, \u0026amp;hints, \u0026amp;listp);\nI* Accept connections *I\nI* . . . on any IP address *I I* \u0026hellip; using port number *I\n13 / * Walk the list for onethat we can bind to•/\n14 for(p= listp; p; p = p-\u0026gt;ai_next) {\n15 / * Create a socket descriptor•I\nif ((listenfd = socket (p-\u0026gt;ai_family, p-\u0026gt;ai_socktype, p-\u0026gt;ai_protocol))\n\u0026lt; 0) continue; /• Socket failed, try the next•I\n18\n/• Eliminates \u0026ldquo;Address already in use\u0026rdquo; error from bind•/\nSetsockopt(listenfd, SOL_SOCKET, SO_REUSEADDR,\n(const void•)\u0026amp;optval , sizeof (int));\n22\n23 I• Bind the descriptor to the address•/\n24 if (bind(listenfd, p-\u0026gt;ai_addr, p-\u0026gt;ai_addrlen) == 0)\nbreak; I* Success•I\nClose(listenfd); I• Bind failed, try the next•I\n27 }\n28\nI* Clean up•I\nFreeaddrinfo (listp) ;\nif (!p) I* No address worked•/\nreturn -1;\n33\nI• Make it a listening socket ready to accept connection requests•I\nif (listen(listenfd, LISTENQ) \u0026lt; 0) {\nClose (listenfd) ;\n37 return -1;\n38 }\n39 return listenfd;\n40 }\ncode/srdcsapp.c\n图 11- 1 9 open_listenfd: 打开并返回监听描述符的辅助函数。它是可重人和与协议无关的\n最后, 我们调用 li s 七e n 函 数 ,将 l i s 七e n f d 转 换 为 一 个 监 听 描 述符,并 返 回 给调用者。如果 l i s t e n 失败 ,我 们 要小 心 地避免内存泄漏,在 返回前关闭描述符。\n11. 4. 9 e c ho 客户端和服务器的示例\n学习套接字接口的最好方法是研究示 例代码。图 11-20 展示 了一个 ech o 客户端的代\n码。在和服务器建立连接之后,客户端进入一个循环,反复从标准输入读取文本行,发送 文本行给服务器,从 服务器读取回送的行,并 输 出 结 果 到 标准输出。当 f g e t s 在标准输人上遇到 EOF 时,或 者 因 为用户在键盘上键入 Ctrl + D, 或者因为在一个重定向的输入文件中用尽了所有的文本行时,循环就终止。 # code/netplechoclient.c\n#include \u0026ldquo;csapp.h\u0026rdquo;\n3 int main(int argc, char••argv) int clientfd;\n6 char *host, *port, buf[MAXLINE] ;\nrio_t rio;\n9 if (argc != 3) {\n1O fprintf (stderr, \u0026ldquo;usage: %s \u0026lt;host\u0026gt; \u0026lt;port\u0026gt;\\n\u0026rdquo;, argv [0]) ; exit(O);\n12\nhost = argv[1];\nport = argv [2] ;\n15\nclientfd = Open_clientfd (host, port);\nRio_readinitb(\u0026amp;rio, clientfd);\n18\n吐 hil e (Fgets(buf, MAXLINE, stdin) != NULL) {\nRio_i.riten(clientfd, buf, strlen(buf));\nRio_readlineb(\u0026amp;rio, buf, MAXLINE);\nFputs (buf, stdout) ;\n23\nClose (clientfd) ;\nexit(O);\n26 }\ncode/netplechoclient.c\n图 11-20 echo 客户端的 主程序\n循环终止之后 , 客户端关闭描述符。这会导致发送一个 EOF 通知到服务器, 当 服务器从它的 r e o _r e a d l i n e b 函数收到一个为零的返回码时, 就会检测到这个结果。在关闭它的描述符后, 客户端就终止了。既然客户端内核在一个进程终止时会自动关闭所有打开的描述符 ,第 24 行的 c l o s e 就没有必要了。不过,显 式 地 关 闭 已经打开的任何描述符是一个良好的编程习惯。\n图 11-21 展示 了 e ch o 服务 器的 主程序。在打开监听描述符后, 它 进入一个无限循环。每次循环都等待一个来自客户端的连接请求,输 出 已 连接客户端的域名和 IP 地址,并 调 用 e c h o 函 数 为 这些客户端服务。在 e c h o 程序返回后, 主程序关闭巳连接描述符。一旦客户端和服务器关闭了它们各自的描述符,连接也就终止了。 # 第 9 行 的 c l i e n t a d dr 变量是一个套接字地址结构,被 传 递给 a c c e p t 。在 a c c e p t 返\n回之前, 会在 c 让 e n t ad dr 中填 上连接另一端客户端的套接字地址。注意, 我们将 c l i ­\ne n t a d dr 声明为 s 七r u c t sockaddr _s七or a g e 类 型 , 而 不 是 s 七r u c t sockaddr _i n 类型。根据定义, s o c ka d dr _ s t or a g e 结 构 足 够 大能 够装下任何类型的套接字地址,以 保 持 代 码 的协议无关性。\ncod e/ netp/ ech oserveri.c\n#include \u0026ldquo;csapp.h\u0026rdquo;\n2\n3 void echo (int connfd);\n4\n5 int rnain(int argc, char **argv)\n6 {\nint listenfd, connfd;\nsocklen_t cl i ent l en;\nstruct sockaddr_storage clientaddr; I* Enough space for any address *I\n1O char client_hostname [MAXLINE] , client_port [MAXLINE] ;\n11\nif ( ar g c != 2 ) {\nfprintf (stderr, \u0026ldquo;usage: %s \u0026lt;port\u0026gt;\\n\u0026rdquo;, argv [OJ ) ;\ne x i t ( O) ;\n15 }\n16\nlistenfd = Open_listenfd(argv [1]);\nwhile (1) {\nclientlen = s i ze of ( s tr uc t sockaddr_storage);\nconnfd = Ac ce pt ( l i s t e nf d , (SA * ) \u0026amp;cl i e nt add r , \u0026amp;c l i ent l en ) ;\nGe t n ame i nfo (( S A * ) \u0026amp;clientaddr, clientlen, cli ent_hotsname , MAXLI NE,\nclient_port, MAXLINE, 0 ) ;\npr i nt f ( \u0026ldquo;Conn e c t ed to ( %s , %s ) \\ n \u0026quot; , client_hostname, cl i ent _por t ) ;\necho (connfd) ;\nClose (connf d) ;\n26 }\n27 exit(O);\n28 }\ncode/netplechoserveri.c\n图 11- 21 迭代 echo 服务楛的主程序\n注意 , 简单的 e c h o 服务器一次只能处理一个客户端。这种类型的服务器一次一个地在客户端间迭代, 称为迭代服务器 ( iterative server)。在第 12 章中, 我们将学习如何建立更加复杂的并发服 务器( co n c u r r e n t s e r v e r ) , 它能够同时处理多个客户端。\n最后, 图 11- 22 展示了 ec ho 程序的代码,该 程序反复读写文本行, 直到r i o _ r e a d li ne b\n函数在第 10 行遇到 EOF。\nco d e/netp / echo.c\n#include \u0026ldquo;csapp.h\u0026rdquo;\n2\n3 void echo(int connfd)\n4 {\ns1ze_t n;\nchar buf[MAXLINE] ;\nr1o_t rio;\n8\n9 Ri o r_ e ad i n i t b (\u0026amp;r i o , connfd);\n1O while ((n = Rio_readlineb (\u0026amp;rio, buf, MAXLINE)) ! = 0) {\nprintf(\u0026ldquo;server received %d bytes\\n\u0026rdquo;, (i nt )n) ;\nRio_writen(connfd, buf, n);\n13 }\n14 }\ncode/netp/echo.c\n图11-22 读和回送文本 行的 e c ho 函数\n田 日 在连接中 EOF 意味什么?\nEOF 的概念常常使 人们感到迷惑, 尤其是在因特 网 连接的上下文中。 首先, 我们需要理解其 实并没有像 EOF 宇符 这样的一个 东西。 进一步来说 , EOF 是由内核 检测到的一种条 件。应用程序在 它接 收到一个由r e a d 函数返回的零返回码时, 它就 会发现出 # EOF 条件。对于磁 盘文件 , 当前文件位置超 出 文件 长度时, 会发生 EOF 。 对于因特 网连接,当 一个进程 关闭 连接 它的 那一端 时, 会发生 EOF 。连接另一 端的 进程在试图读 取流中最后 一个字节之后的 字节时, 会检测到 EOF 。\n11. 5 Web 服务器\n迄今为止, 我们已 经在一个简单的 echo 服务器的 上下 文中讨论了网络编程。在这一节里,我们将向你展示如何利用网络编程的基本概念,来创建你自己的虽小但功能齐全的\nWeb 服务器。\n5 . 1 We b 基础\nWeb 客户端和服务器之间的交互用的是一个基于文本的应用级协议,叫 做 HT T P ( H\u0026rsquo;ljpertext Transfer Protocol, 超文本传输协议)。HTTP 是一个简单的协议。一个 Web 客户端(即浏览器)打开一个到服务器的因特网连接,并且请求某些内容。服务器响应所请 求的内容,然后关闭连接。浏览器读取这些内容,并把它显示在屏幕上。\nWeb 服务和常规的文 件检索服务(例如FT P) 有什么区别呢?主要的区别是 Web 内容可以用一种叫做 HT ML ( Hypertext Markup Language, 超文本标记语言)的语言来编写。一个 HT ML 程序(页)包含指令(标记),它 们告诉浏览器如何显示这页中的各种文本和图形对象。例如,代码\n\u0026lt;b\u0026gt; Make me bold! \u0026lt;/b\u0026gt; # 告诉浏 览器用粗体字类型输出\u0026lt; b \u0026gt; 和\u0026lt; / b \u0026gt; 标记之间的文本。然而, HT ML 真正的强大之处在于一个页面可以包含指针(超链接),这些指针可以指向存放在任何因特网主机上的 内容。例如 , 一个格式如下的 HT ML 行\n\u0026lt;a href=\u0026ldquo;http://www.cmu.edu/index.html\u0026rdquo;\u0026gt;Carnegie Mellon\u0026lt;/a\u0026gt; # 告诉浏览器高亮显示文本对象 \u0026quot; Car ne g i e Mellon\u0026rdquo;, 并且创建一个超链接,它指向存放在 CMU Web 服务器上叫做 i nde x . h七ml 的 HT ML 文件。如 果用户单击了这个高亮文本对象,浏 览器就会从 CMU 服务器中请求相应的 HT ML 文件并显示 它。\n田 日 万维网的起源 # 万维网是 Tim Bemers-Lee 发明的, 他是一位在瑞典物理 实验室 CERN( 欧洲粒子物理研究所)工作的软件工程师。1989 年, Berners-Lee 写了一个内部备忘录,提出了一个分布式超文\n本系统,它 能连接“用链接组成的笔记的网(web of notes with links)\u0026quot; 。提出这个 系统的目的是帮助 CERN 的科学家共享和管理信息。在接下来的两年多里, Bemers-Lee 实现了笫一个 Web服务器和Web 浏览器之后, 在 CERN 内部以及其他一些网站中, Web 发展出了小 规模的拥护者。\n1993年一个关键事件发生了, M釭 c Andreesen(他后来创建了 Netscape)和他在NCSA的同事发布了一种图形化的浏览器, 叫做 MOSAIC, 可以在三种主要的平台上所使用: Unix、Windows 和\n:Macintosh。在MOSAIC发布后, 对 Web的兴趣爆发了, Web网站以 每年10 倍或更高的数量 增长。到 2015年,世界上已经有超过975000 000 个 Web 网站了(源自 Netcraft Web Survey)。\n5. 2 W e b 内 容\n对于 W e b 客户端和服务器而言, 内容是与一个 MIME (Multipurpose Internet Mail\nExtensions, 多用途的网际邮件扩充协议)类型相关的字节序列。图 11- 23 展示了一些常用的 MIM E 类型。\n图 11-23 MIME 类型示例\nWeb 服务器以 两种不同的方式向客户端 提供内容: # 取一个磁盘文件 , 并将它的内 容返回给客 户端。磁盘文件称为静态内 容( s ta tic con­\ntent), 而返回文件给客户端的过程称为服务静态内 容( se rving static content ) 。.\n运行一个可执行文件,并将它的输出返回给客户端。运行时可执行文件产生的输出称为动 态内 容( d ynam ic content) , 而运行程序并返回它的输出到客户端的过程称为服务动 态内 容( se r ving dynamic conte nt ) 。\n每条由 Web 服务器返回的内容都是和它管理的某个文件相关联的。这些文件中的每一个都有一个唯一的名字,叫做URL( Universal Resource Locator, 通用资源定位符)。例如, URL\nht t p : / / 吓 w. g oogl e . com: 80/ i ndex . ht ml # 表示因特网主机 www. g o o g l e . c om 上一个 称为/ i nd e x . h t ml 的 H T M L 文件, 它是由一个监听端口 80 的 Web 服务器管 理的。端口号是可选的,默认 为知名的 H T T P 端口 80。可执行文件的 U R L 可以在文件名后包括程序参 数。\u0026quot; ?\u0026quot; 字符分隔文件名和参数, 而且每个参数都用\u0026quot; \u0026amp;\u0026quot; 字符分隔开。例如 , U R L\nhttp://bluefish.ics.cs.cmu.edu:8000/cgi-bin/adder?15000\u0026213 # 标识了一个叫做/ cg 丘 b i n / a dde r 的可执行文件, 会带两个参数字符串 15000 和 213 来调用它。在事务过程中,客户端和 服务器使用的是 URL 的不同部分。例如, 客户端使用前缀\nhttp://www.google.com:80 # 来决定与哪类服务器联系,服务器在哪里,以及它监听的端口号是多少。服务器使用后缀\n/index.html\n来发现在它文件系统中的文件,并确定请求的是静态内容还是动态内容。关于服务器如何解释一个 U RL 的后缀,有 几点需要理解:\n确定一个 U R L 指向的是静态内容还是 动态内容没有标准的规则。每个服务器对它所管理的文件都有自己的规则。一种经典的(老式的)方法是,确定一组目录,例如 c g i - 区 n , 所有的可执行性文件都必须存放这些目录中。 后缀中的最开始的那个 \u0026ldquo;/\u0026rdquo; 不表示 Linux 的根目 录。相反, 它表示的是被请求内容类型的主目录。例如,可以将一个服务器配置成这样:所有的静态内容存放在目录/ usr / htt pd / ht ml 下 ,而所有的动态内 容都存放在目录/ usr / h tt pd / c g 丘 b i n 下。 最小的 UR L 后缀是 \u0026ldquo;I\u0026rdquo; 字符, 所有服务器将其扩展为某个默认的 主页, 例如/ i nde x . h t ml 。这解 释了为什么简单地在浏览器中键入一个域名就可以取出一个网站的主页。浏览器在 U R L 后添加缺失的 \u0026ldquo;/\u0026rdquo; , 并将之传递给服务器, 服务器又把 \u0026ldquo;I\u0026rdquo; 扩展到某个默认的文件名。 11. 5. 3 HT T P 事务\n因为 H T T P 是基于在因特网连接上传送的文本行的, 我们可以使用 L in u x 的 T E L ­ N E T 程序来和因特网上的任何 W e b 服务器执行 事务。对于调试在连 接上通过文本行来与客户端对话的 服务器来说, T E L N E T 程序是非常便利的。例如,图 11 - 24 使用 T E L N E T 向 A O L W e b 服务器请求主页。\nl i nu x\u0026gt; t el n e t w 叮 . a ol . c om 80 2 Trying 205. 188. 146. 23. . .\nConnected to aol . com . Escape character i s \u0026lsquo;- J \u0026rsquo; . s GET I HTTP/ 1 . 1\n6 Hos t : 日 叩 . a ol . co m\n7\nClient: open connection to server Telnet prints 3 lines to the terminal\nClient: request line\nClient: required HTTP/1.1 header Client: empty line terminates headers\nHTTP/1. 0 200 OK Server: response line MI ME- Ver s i on : 1. 0 Server: followed by ti ve response headers 10 Da t e : Mon , 8 Jan 20 1 0 4 : 59 : 4 2 GMT Server: Apa c he - Co y ot e / 1 . 1 Con t e nt - Type : t e xt / ht ml Server: expect HTML in the response body Con t e nt - Le ng t h : 42092 Server: expect 42, 092 bytes in the response body 14\n15 \u0026lt;html\u0026gt;\n16\nSer ver : empty line terminates response headers Server : first HTML line in response body\nSer ver : 766 lines of HTML not shown\n17 \u0026lt;/html\u0026gt; Server: last HTML line in response body 18 · Conn e c t i on c l o s e d by foreign host. Server : closes connection\n19 l i nu x\u0026gt;\nCl i ent : c1 oses connection and terminates\n图 11-24 一个服务静 态内容的 HT T P 事务\n在第 1 行, 我们从 L in u x s hell 运行 T EL NET , 要求它打开一个到 A O L W e b 服务器的连接。T E L N E T 向终端打印三行输出, 打开连接, 然后等待我们输入文本(第5 行)。每次输入一个文本行, 并键入回车键, T E L N E T 会读取该行, 在后面加上回车和换行符号(在C 的表示中为 \u0026quot; \\r \\ n\u0026quot; ) , 并且将这一行发送到服务器。这是和 H TT P 标准相符的, H TT P 标准要求每个文本行都由一对回车和换行符来 结束。为了发起 事务, 我们输入一个 H T T P 请求(第5 ~ 7 行)。服务器返回H TT P 响应(第8 ~ 1 7 行), 然后关闭连接(第18 行)。\nHTT P 请求\n一个 H T T P 请求的组成 是这样的: 一个请求行 ( r eq ues t line ) ( 第 5 行), 后 面跟随零个或更多个请求报 头 ( r e q u es t h ea d e r ) ( 第 6 行), 再跟随 一个空的文本行来终止报头列表\n(第 7 行)。一个请求行的形式是 # method URI version\nHT T P 支持许多不同的方 法,包 括 G E T 、 P O S T 、 O P T I O N S 、 H E A D 、P U T 、 D E L E T E\n和 T R A C E 。我们将只讨论广为应用的 G E T 方法, 大多数 H T T P 请求都是这种类型的。\nG ET 方法指导服务器生成和返回 U RI ( U nifo rm Resource Identifier, 统一资源标识符)标识的内 容。U RI 是相应的 U R L 的后缀, 包括文件名和可选的参数 。e\n请求行中的 version 字段表明了该请求遵循的 H T T P 版本。最新的 H T T P 版本是H T T P / 1. 1 [ 37] 。H T T P / 1. 0 是从 1996 年沿用 至今的老版本 [ 6] 。H T T P / 1. 1 定义了一些附加的报头, 为诸如缓冲和安 全等高级特性提供支持, 它还支持一种机制,允 许客户端和服务器在同一条持久连接 ( persis t e n t con nect io n ) 上执行多个事务。在实际中, 两个版本是互相兼容的, 因为 H T T P / 1. o 的客户端 和服务器会简单地忽略 H T T P / 1. 1 的报头。\n总的来说, 第 5 行的请求行要求服务器取出并返回 H T M L 文件/ i nde x . h t ml 。 它也告知服务器请求剩下的部分是 H T T P / 1. 1 格式的。\n请求报头为服务器提供了额外的信息,例如浏览器的商标名,或者浏览器理解的 # MIME 类型。请求报头的格式为\nhea d er - na me : head er-d a ta # 针对我们的目的, 唯一需要关注的报头是 Ho 江 报头(第6 行), 这个报头在 H T T P / 1. 1 请求中是需要的, 而在 H T T P / 1. 0 请求中是不需要的。代理缓存( pro xy cache ) 会使用 Ho s t 报头, 这个代理缓 存有时作为浏览器和管理被请求文件的原始服 务器 ( origin ser ver ) 的中介。客户端 和原始服务器之间, 可以 有多个代理, 即所谓的代理链( pro xy cha in ) 。 Ho s t 报头中的数据指示了原始服务器的域名,使得代理链中的代理能够判断它是否可以在本地 缓存中拥有一个被请求内容的副本。\n继续图 11- 24 中的示例, 第 7 行的空文本行(通过在键盘上键入回车键生成的)终止了报头, 并指示服务器发送被请求的 H T ML 文件。\nHT T P 响应\nH T T P 响应和 H T T P 请求是相似的。一个 H T T P 响应的组成是这样的: 一个响应行(response line)(第 8 行), 后面跟随 着零个或更多的响应报 头 ( res pons e header)(第 9 ~ 13行),再 跟随一个终止报头的空行(第14 行),再 跟随一个响应主体( res ponse body)(第 15 ~ 17行)。一个响应行的格式是\nversion sta tus -code sta tus-message # version 字段描述的是 响应所遵循的 H T T P 版本。状 态码( stat us飞 code) 是一个 3 位的正整数, 指明对请求的处理。状态消息 ( s tat us message) 给出与错误代码等价的英文描述。图 11- 25 列出了一些常见的状态码, 以及它们相应的消息。\n状态代码 状态消息 描述 200 成功 处理请求无误 301 永久移动 内容巳移动到locat10n头中指明的主机上 400 错误请求 服务器不能理解请求 403 禁止 服务器无权访问所请求的文件 404 未发现 服务器不能找到所请求的文件 501 未实现 服务器不支持请求的方法 505 HTTP版本不支持 服务器不支持请求的版本 图 11-25 一些 HTT P 状态码\ne 实际上,只 有当浏览器请求内容时 , 这才是真的。如果代理服务器请求内容 , 那么这个 URI 必须是完整的\nU RL。\n第 9~ 13 行的响应报头提供了关于响应的附 加信息。针对我们的目的, 两个最重要的报头是 Co n t e n t - Typ e ( 第 1 2 行), 它告诉客户端响应主体中内容的 M IM E 类型; 以及Co n 七e n t - Le ng 店(第13 行),用 来指示响应主体的字节大小。\n第 14 行的终止响应报头的空文本行, 其后跟随着响应主体, 响应主体 中包含着被请求的内容。 # 5. 4 服务动态内容 如果我们停下来考虑一下,一个服务器是如何向客户端提供动态内容的,就会发现一些问题。例如,客户端如何将程序参数传递给服务器?服务器如何将这些参数传递给它所创建的子进程?服务器如何将子进程生成内容所需要的其他信息传递给子进程?子进程将它的输出发送到哪里? 一个称为 CG ICCo mmon Gateway Interface, 通用网关接口)的实际标准的出现解决了这些问题。 # 1 客户端如何 将程序 参数传 递给服务器\nGET 请求的参数在 UR I 中传递。正如我们看到的, 一个 \u0026ldquo;?\u0026rdquo; 字符分隔了文件名和参数,而每个参数都用一个 \u0026quot; \u0026amp;\u0026quot; 字符分隔开。参数中不允许有空格, 而必须 用字符串 \u0026quot; %2 o\u0026quot; 来表示。对其他特殊字符,也存在着相似的编码。\n田 日 在 HTT P POS T 请求中传递参数\nHTTP POST 请求的参数是 在请求主体 中而不 是 U RI 中传递的 。\n服务器如何将参数传递给子进程在服务器接收一个如下的请求后 # GET /cgi-bin/adder?15000\u0026amp;213 HTTP/1.1 # 它调用 f or k 来创建一个子进程, 并调用 e x e c v e 在子进 程的上下文中执行/ c g i - b i n / a d­\nde 程序。像 a d der 这样的程序 , 常常被称为 CG I 程序, 因为它们遵守 CG I 标准的规则。而且, 因为许多 CG I 程序是 用 Pe rl 脚本编写的, 所以 CG I 程序也常被称为 CG I 脚本。在调用 e xe c ve 之前, 子进程将 CG I 环境变量 Q U E R Y_ST RI NG 设置为 \u0026quot; 1 5000 \u0026amp;21 3\u0026quot; , ad­\nder 程序在运行时 可以用 Lin ux g e t e nv 函数来引用它。\n服务器如何将其他信息传递给子进程 # CGI 定义了大量的其他环境变量, 一个 CG I 程序在它运行时可以设置这些环境变量。\n图 11-26 给出了其中的一部分。 # 图 11-26 CGI 环境变量示例\n子进程将它的输出发送到哪里 # 一个 CG I 程序将它的动态内容发送到标准输出 。在子进程加载并 运行 CGI 程序之前,\n它使用 L in u x d u p 2 函数将标准输出重定向到和客户端相关联的已连接描述符。因此, 任何 CG I 程序写到标准输出的东西都会直接到达客户端。\n注意,因为父进程不知道子进程生成的内容的类型或大小,所以子进程就要负责生成 # Content- t ype 和 Co n t e n t - l e n g t h 响应报头, 以及终止报头的空行 。\n图 11 - 27 展示了一个简单的 CGI 程序, 它对两个参数求和, 并返回带结果的 H T M L\n文件给客户端 。图 11 - 28 展示了一个 H T T P 事务, 它根据 a d d er 程序提供动态内容。\ncode/netpltiny/cgi-bin/adder.c\n#include \u0026ldquo;csapp.h\u0026rdquo;\n2\nint main(void) {\nchar *buf, *Pi\nchar argl[MAXLINE], arg2[MAXLINE], c ont e nt [MAX LI NE] ; 6 int n1=0, n2=0;\n7\n. I * Extract the two arguments *I\nif ((buf = getenv (11QUERY_STRING 11)) ! = NULL) { 1o p = strchr (buf ,\u0026rsquo;\u0026amp;\u0026rsquo;) ;\n11 *P =\u0026rsquo;\\0\u0026rsquo;;\nstrcpy(arg1, buf);\nstrcpy(arg2, p+1);\nn1 = atoi(arg1);\nn2 = atoi(arg2); 16 }\n17\nI* Make the response body *I\nsprintf (content, 11QUERY_STRING=%s11 , buf) ;\nsprintf(content, 11Welcome to a dd . com : 11);\nsprintf(content, 11%sTHE Internet addition portal. \\r\\n\u0026lt;p\u0026gt;11, content);\nsprintf (content, 11%sThe answer is: %d + %d = %d\\r\\n\u0026lt;p\u0026gt;11,\ncontent, n1, n2, n1 + n2);\nsprintf (content, 11%sThanks for visiting! \\r\\011, content); 25\nI* Generate the HTTP response *I\nprintf(11Connection: close\\r\\n11);\nprintf(11Content-length: %d\\r\\n11, (int)strlen(content));\nprintf(11Content-type: t ext / html \\r \\ n \\r \\ n 11) ;\nprintf(11%s11, content);\nf fl us h ( s t dout ) ; 32\n33 exit(O); 34 }\ncode/netpltiny/cgi-bin/adder.c\n图 11-27 对两个整数求和的 CGI 程序\nlinux\u0026gt; telnet kit tyha甘k.cmcl. cs. emu. edu 8000 Client: open connect i on\n2 Trying 128. 2.194.242\u0026hellip;\nConnected to kittyhawk.cmcl.cs.cmu.edu. Escape character i s \u0026lsquo;- J \u0026rsquo; .\nGET /cgi-bin/adder?15000\u0026amp;213 HTTP/1.0\nHTTP/1. 0 200 OK\nServer: Tiny Web Server Content-length: 115 Content-type: text/html\nClient: request line\nCl i en t : empty 1 ine terminates headers Server: response line\nServer : identify server\nAdder: expect 115 bytes in response body Adder: expect HTML in response body\nAd der : empty line terminates headers\nWelcome to add.com: THE Internet addition portal. Adder: first HTML line\n\u0026lt;p\u0026gt;The answer is: 15000 + 213 = 15213 Ad der : second HTML line in response body\n\u0026lt;p\u0026gt;Thanks for visiting! Adder: third HTML line in response body\nConnection closed by foreign host.\nlinux\u0026gt;\nServer: closes connection\nClient: closes connection and terminates\n图 11-28 - 个提供动态 H T ML 内容的 HT T P 事务\n_m 将HTTP POST 请求中的 参数传递给 CGI 程序\n对于 POST 请求,子进程也 需要 重定向标 准输入 到已连接描 述符。然后 , CGI 程序会从标准扴入 中读取 请求主体 中的 参数。 # 练习题 11. 5 在 1 0 . 11 节中, 我们警 告过你 关 于在 网络应用 中使用 C 标准 I/ 0 函数的危险。然而, 图 11 - 27 中的 CGI 程序却 能没有任何 问题地使用 标准 I/ 0 。为什 么呢?\n6 综合: TINY Web 服务器 我们通过开发一 个虽小但功能齐全的称为 T INY 的 W e b 服务器来结束对网络编程的讨论。TINY 是一个有趣的程序。在短短 2 5 0 行代码中, 它结合了许多我们已经学习到的 思想, 例如进程控制、Unix I/ 0 、套 接字接口和 HT TP。虽然它缺乏 一个实际服务器所具备的功能性 、健壮性和安全性, 但是它足够用来为实际的 W e b 浏览器提供静态和动态的内容。我们鼓励 你研究它, 并且自己 实现它。将一个实际的浏览器指向你自己的服务器, 看着它显 示一个复杂的带 有文本 和图片的 W e b 页面, 真是非常令人兴奋(甚至对我们这些作者来说,也 是如此!)。 # TINY 的 main 程序\n图 11 - 2 9 展示了 TINY 的主程序。TINY 是一个迭代服务器, 监听在命令行中传递来的端口上的连接请求 。在通过调用 o p e n _ l i s t e n f d 函数打开一个监听套接字以后, T INY 执行典型的无限 服务器循环, 不断地接受连接请求(第3 2 行), 执行事务(第3 6 行), 并关闭连接的它那一 端(第3 7 行)。\ndoit 函数\n图 11 - 30 中的 d o i t 函数处理一个 HT TP 事务。首先, 我们 读和解析请求行(第11 1 4 行)。注意 , 我们使 用图 11 - 8 中的r i o _r e a d l i n e b 函数读取请求行。\nTINY 只支持 GET 方法。如果客户端请求其他方法(比如 POST) , 我们发送给它一个错误信息,并 返回到主程序(第1 5 1 9 行), 主程序 随后关闭连接并等待下一个连接请求。否则,我们 读并且(像我们将要看到的那样)忽略任何请求报头(第 20 行)。\ncode/netpltinyltiny.c\nI*\n* tiny.c - A simple, iterative HTTP/1.0 Web server that uses the GET method to serve static anddynamic content\n4 *I\n5 #include \u0026ldquo;csapp.h\u0026rdquo;\n6\nvoid doit(int fd);\nvoid read_requesthdrs(rio_t *rp);\nint parse_uri(char *uri, char *filename, char *cgiargs);\nvoid serve_static(int fd, cha工 *fil ename , int filesize);\n11 void get_filetype(char *filename, char *filetype);\n12 void serve_dynamic(int fd, char *filename, char *cgiargs);\n13 void clienterror(int fd, char *cause, char *errnum,\n14 char *shortmsg, char *longmsg);\n15\n16 int main(int argc, char **argv)\n17 {\nint listenfd, connfd;\ncha 工 hos t n ame [ MAX LI NE] , port[MAXLINE];\nsocklen_t clientlen;\nstruct sockadd_rs t or age clientaddr;\n22\nI* Check command-line args *I\nif (argc != 2) {\nfprintf (stderr, \u0026ldquo;usage: %s \u0026lt;port\u0026gt;\\n\u0026rdquo;, argv [OJ);\nexit (1);\n27 }\n28\nlistenfd = Open_listenfd(argv[1]);\nwhile (1) {\nclientlen = sizeof (clientaddr)·\nconnfd = Accept (listenfd, (SA *)\u0026amp;clientaddr, \u0026amp;clientlen);\nGetnameinfo((SA *) \u0026amp;clientaddr, clientlen, hostname, MAXLINE,\nport , MAXLINE, 0) ;\nprintf(\u0026ldquo;Accepted connection from (%s, %s)\\n\u0026rdquo;, hostname, port);\ndoit(connfd);\nClose(connfd);\n38 }\n39 }\ncode/netpltinyltiny.c\n图 11-29 TINY Web 服务器\n然后, 我们将 URI 解析为一个文件名和一个可能 为空的 CGI 参数字符串, 并且设置一个标志, 表明请求的是静态内容还是动态内容(第23 行)。如果文件在磁盘上不存在, 我们立即发送一个错误信息给客户端并返回。 # 最后, 如果请求的是静态内容,我 们就验证该文件是一个普通文件, 而我们 是有读权限的(第31 行)。如果是这样, 我们就向客户端提供静态内容(第36 行)。相似地, 如果请求的是动态内容, 我们就验证该文件是可执行 文件(第39 行), 如果是这样,我 们就继续, 并且提供动态内容(第44 行)。\ncode/netpltinyltiny.c\nvoid doit(int fd) 2 {\nint is_static;\nstruct stat sbuf; char buf[MAXLINE], method[MAXLINE], uri[MAXLINE], version[MAXLINE];\nchar filename [MAXLINE], cgiargs [MAXLINE]; rio_t rio;\n8\nI* Read request line andheaders *I\nRio_readinitb(\u0026amp;rio, fd);\nRio_readlineb(\u0026amp;rio, buf, MAXLINE);\nprintf(\u0026ldquo;Request headers:\\n\u0026rdquo;);\nprintf(\u0026quot;%s\u0026quot;, buf);\nsscanf(buf, \u0026ldquo;%s %s %s\u0026rdquo;, method, uri, version);\nif (strcasecmp(method, \u0026ldquo;GET\u0026rdquo;)) {\nclienterror(fd, method, \u0026ldquo;501\u0026rdquo;, \u0026ldquo;Not implemented\u0026rdquo;,\n\u0026ldquo;Tiny does not implement this method\u0026rdquo;);\nreturn;\n19 }\n20 read_requesthdrs (\u0026amp;rio);\n21\nI* Parse URI from GET request *I\nis_static = _par s e _ur i (uri, filename, cgiargs);\nif (stat(filename, \u0026amp;sbuf) \u0026lt; 0) {\nclienterror(fd, filename, \u0026ldquo;404\u0026rdquo;, \u0026ldquo;Not found\u0026rdquo;,\n\u0026ldquo;Tiny couldn\u0026rsquo;t find this file\u0026rdquo;);\nreturn;\n28 }\n29\nif (is_static) { I*Serve static content *I\nif (! (S_ISREG(sbuf.st_mode)) 11 ! (S_IRUSR \u0026amp; s buf . st_mode)) {\nclienterror(fd, filename, \u0026ldquo;403\u0026rdquo;, \u0026ldquo;Forbidden\u0026rdquo;,\n· \u0026ldquo;Ti ny couldn\u0026rsquo;t read the file\u0026rdquo;);\nreturn;\n35 }\n36 serve_static (fd, filename, sbuf. st _s i z e ) ;\n37 }\nelse { I* Serve dynamic content *I\nif (! (S_ISREG (sbuf.st_mode)) I I ! (S_IXUSR \u0026amp; sbuf. st_mode)) {\nclienterror(fd, filename, \u0026ldquo;403\u0026rdquo;, \u0026ldquo;Forbidden\u0026rdquo;,\n\u0026ldquo;Tiny couldn\u0026rsquo;t run the CGI program\u0026rdquo;);\nreturn;\n43 }\n44 serve_dynamic(fd, filename, cgiargs);\n45 }\n46 }\ncode/netp/tinyltiny.c\nc l ie nte rro r 函数 图 11-30 TINY d o i t 处理一个 H T T P 事务\nT I NY 缺乏一个实际服务器的许多错误处理特性。然而, 它会检查一些明显的错误, 并把它们报告给客户端。图 11-31 中的 c l i e n t er r or 函数发送一个 HT TP 响应到客户端, 在响应行中包含 相应的状态码和状态消息, 响应主体中包含一个 HT ML 文件,向 浏览器\n的用户解释这个错误。 # void clienterror(int fd, char•cause, char•errnum,\nchar•shortmsg, char•longmsg)\n3 {\ncode/netpltinyltiny.c\n4 char buf[MAXLINE], body[MAXBUF]; 5 6 I• Build the HTTP response body•/ 7 sprintf(body, \u0026ldquo;\u0026lt;html\u0026gt;\u0026lt;title\u0026gt;Tiny Error\u0026lt;/title\u0026gt;\u0026rdquo;); 8 sprintf (body, 11%s\u0026lt;body bgcolor=\u0026quot; \u0026ldquo;ffffff1111\u0026gt;\\r\\n11, body); 9 sprintf(body, 11%s%s: %s\\r\\n11, body, errnum, shortmsg); 10 sprintf(body, 11%s\u0026lt;p\u0026gt;%s: %s\\r\\n11, body, longmsg, cause); 11 sprintf (body, 11%s\u0026lt;hr\u0026gt;\u0026lt;em\u0026gt;The Tiny Web server\u0026lt;/em\u0026gt;\\r\\n\u0026rdquo;, body); 12 13 /• Print the HTTP response•I 14 sprintf (buf, \u0026ldquo;HTTP/1.0 %s %s\\r\\n11, errnum, shortmsg); 15 Rio_writen(fd, buf, strlen(buf)); 16 sprintf(buf, \u0026ldquo;Content-type: text/html\\r\\n\u0026rdquo;); 17 Rio_writen(fd, buf, strlen(buf)); 18 sprintf(buf, \u0026ldquo;Content-length: %d\\r\\n\\r\\n11, (int)strlen(body)); 19 Rio_writen(fd, buf, strlen(buf)); 20 Rio_writen(fd, body, strlen(body)); 21 } code/netp/tinyltiny.c\n图 11- 31 T I NY cl i e nt err or 向客户端发送一个出错消息\n回想一下, H T M L 响应应该指明主体中内容的大小和类型。因此, 我们选择创建H T M L 内容为一个 字符串, 这样一来我们可以 简单地确定它的大小。还有, 请注意我们为所有的输出使用的都是图 10- 4 中健壮的r i o _wr i t e n 函数。 # re a d _ re q ue s t hd rs 函数\nT INY 不使用请求报头中的任何信息。它仅仅调用图 11-32 中的r e a d—r e q u e s t h d r s 函数来读取并忽略这些报头。注意,终止请求报头的空文本行是由回车和换行符对组成 的, 我们在第 6 行中检查它。\nvoid read_requesthdrs(rio_t *rp)\n2 {\ncha 工 buf [ MAX LI NE] ;\n4\nRio_readlineb(rp, buf, MAXLINE);\nwhile(strcmp(buf, \u0026ldquo;\\r\\n\u0026rdquo;)) {\nRio_readlineb(rp, buf, MAXLINE);\nprintf(\u0026quot;%s\u0026rdquo;, buf);\n9 }\n10 return;\n11 }\ncode/netp/tinyltiny.c\ncode/netpltinyltiny.c\n图 11-32 TINYr ead_ r eque s 七hdr s 读取并忽略请求报头\npa rs e _uri 函数 # T INY 假设静态内容的主目录就是 它的当前目录, 而可执行文件的主目录是 . / cg让 bi n。任何包含字符串 cg丘 bi n 的 URI 都会被认为表示的是 对动态内容的请求。默认的文件名是\n. / home . html 。 # 图 11 - 33 中的 par s e _u r i 函数实现了这些策略。它将 U RI 解析为一 个文件名和一个可选的 CG I 参数字符串。如果请求的是静态内容(第5 行), 我们将清除 CG I 参数字符串\n(第 6 行), 然后将 U RI 转换为一个 Lin ux 相对路径名, 例如 . / i nd e x . h t ml ( 第 7 ~ 8 行)。如果 U RI 是用 \u0026ldquo;/\u0026rdquo; 结尾的(第9 行), 我们将把默认的文件名加在后面(第10 行)。另一方 面, 如果请求的是 动态内容(第13 行), 我们 就会抽取出所有的 CG I 参数(第14 ~ 20 行),并将 U R I 剩下的部分转换为一个 L inu x 相对文件名(第21 ~ 22 行)。\ncode/netpltinyltiny.c int pars e _ur i( cha 工 *ur i , cha 工 *f i l ename , char *cgia 工 gs ) # 2 {\n3 char *ptr; # 5 if (!strstr(uri, \u0026ldquo;cgi-bin\u0026rdquo;)) { I* Static content *I strcpy(cgiargs, \u0026ldquo;\u0026rdquo;);\nstrcpy(filename, \u0026ldquo;. \u0026ldquo;);\nstrcat (filename, uri); if (uri[strlen(uri)-1] ==\u0026rsquo;/\u0026rsquo;) strcat(filename, \u0026ldquo;home.html\u0026rdquo;); return 1; # 12 }\nelse { I* Dynamic content *I # ptr = index(uri,\u0026rsquo;?\u0026rsquo;); if (ptr) { strcpy(cgiargs, ptr+1); # *ptr = I \\0 I j\n18 }\n19 else\n20 strcpy (cgiargs, \u0026ldquo;\u0026rdquo;) ; 21 strcpy (filename, \u0026ldquo;. \u0026ldquo;) ; 22 strcat (filename, uri); 23 r etu 工 n O; 24 } 25 } code/netpltinyltiny.c # 图 11- 33 TINY par s e_ur i 解析一个 HTT P URI\nse rve _s ta t ic 函数 # T I N Y 提供五种常见类型的静态内容: H T M L 文件、无格式的文本文件,以 及编码为 G IF 、P NG 和 ] PG 格式的图片。\n图 11-34 中的 s er ve _s t a t i c 函数发送一个 H T T P 响应, 其主体包含一个本地文件的内容。首先, 我们通过检查文件名的后缀来判断文件类型(第 7 行), 并且发送响应行和响应报头给客户端(第8,\u0026hellip;_,1 3 行)。 注意用一个空行终 止报头。\ncode/netpltinyltiny.c\n1 void serve_static(int fd, char *filename, int filesize)\n2 {\n3 int srcfd;\n4 char *srcp, filetype [MAXLINE] , buf [MAXBUF] ;\n5\n6 I* Send response headers to client *I\nget_filetype(filename, filetype);\nsprintf(buf, \u0026ldquo;HTTP/1.0 200 OK\\r\\n\u0026rdquo;);\nsprintf(buf, \u0026ldquo;%sServer: Tiny Web Server\\r\\n\u0026rdquo;, buf);\nsprintf(buf, \u0026ldquo;%sConnection: close\\r\\n\u0026rdquo;, buf);\n11 sprintf(buf, \u0026ldquo;%sContent-length: %d\\r\\n\u0026rdquo;, buf, filesize);\n12 sprintf(buf, \u0026ldquo;%sContent-type: %s\\r\\n\\r\\n\u0026rdquo;, buf, filetype);\n13 Rio_writen(fd, buf, strlen(buf));\n14 printf(\u0026ldquo;Response headers:\\n\u0026rdquo;);\n1 5 pr i nt f ( \u0026ldquo;知 \u0026quot; , buf);\n16\n17 I* Send response body to client *I\n18 srcfd = Open(filename, O_RDONLY, O);\nsrcp = Mmap(O, filesize, PROT_READ, MAP_PRIVATE, srcfd, O);\nClose(srcfd) ;\nRio_writen (fd, srcp, filesize) ;\nMunmap(srcp, filesize);\n23 }\n24\n2s I*\n26 * get_filetype - Derive file type from filename\n21 *I\n28 void get_filetype (char *filename, char *filetype)\n29 {\nif (strstr(filename, \u0026ldquo;.html\u0026rdquo;))\nstrcpy(filetype, \u0026ldquo;text/html\u0026rdquo;);\nelse if (strstr(filename, \u0026ldquo;.gif\u0026rdquo;))\nstrcpy(filetype, \u0026ldquo;image/gif\u0026rdquo;);\nelse if (strstr(filename, 11.png\u0026rdquo;))\nstrcpy(filetype, \u0026ldquo;image/png\u0026rdquo;);\nelse if (strstr(filename, \u0026ldquo;.jpg\u0026rdquo;))\nstrcpy(filetype, \u0026ldquo;image/jpeg\u0026rdquo;);\nelse\nstrcpy(filetype, \u0026ldquo;text/plain\u0026rdquo;);\n40 }\ncode/netpltinyltiny.c\n图 l 1-3-l T I NY s er ve s t a已 c 为客户端 提供静态内容\n接着,我 们将被请求文 件的内容复制到已 连接描述符 f d 来发送响应主体。这里的 代码是比较微妙的,需 要仔细研究。第 18 行以读方式打开 f i l e n a me , 并获得它的描述符。在第 1 9 行, L in ux mma p 函数将被请求文件映射到一个虚拟内存空间。回想我们在第 9. 8 节中对 mma p 的 讨论 , 调用 mma p 将文件 s r c f d 的前 f i l e s i ze 个字节映射到一个从地址 s r c p 开始的私有只读虚拟内存区域。\n一旦将文件映射到内存,就 不 再 需 要 它 的 描 述符了, 所 以 我们关闭 这个文件(第20 行)。执行这项任务失败将导致潜在的致命的内存泄漏。第 21 行 执 行 的 是 到客户端的实际文件传送。r i o_wr i t e n 函数 复 制 从 s r c p 位 置开始的 f i l e s i ze 个字节(它们当然已经被映射到了所请求的文件)到客户端的已连接描述符。最后,第 22 行 释放了映射的虚拟内存区域。这对于避免潜在的致命的内存泄漏是很重要的。 # s e rve _dyna mic 函 数\nT INY 通过派生一个子进程并在子进程的上下文中运行一个 CGI 程序,来 提供各种类型的动态内容。\n图 11- 35 中的 s er v e _ d yna mi c 函 数 一 开始就向客户端发送一个表明成功的响应行, 同时 还包括带有信息的 Ser ver 报头。CGI 程序负责发送响应的剩余部分。注意, 这并不像我们可能希望的那样健壮,因 为它没有考虑到 CGI 程序会遇到某些错误的可能性。\ncode/netp/tinyltiny.c\nvoid serve_dynamic (int fd, char *filename, char *cgiargs)\n2 {\n3 char buf [MAXLINE], *emptylist [] = { NULL } ; # 4\n5 I* Return first part of HTTP response *I # 6 sprintf(buf, \u0026ldquo;HTTP/1.0 200 OK\\r\\n\u0026rdquo;);\nRio_writen(fd, buf, strlen(buf));\nsprintf(buf, \u0026ldquo;Server: Tiny Web Server\\r\\n\u0026rdquo;);\n9 Rio_writen(fd, buf, strlen(buf));\n10\n11 if (Fork() == 0) { I* Child *I\nI* Real server would set all CGI vars here *I # setenv(\u0026ldquo;QUERY_STRING\u0026rdquo;, cgiargs, 1);\nDup2(fd, STDOUT_FILENO); I* Redirect stdout to client *I\nExecve(filename, emptylist, environ); I* Run CGI program *I\n16 }\n1.7\n18 }\nWait(NULL); I* Parent waits for and reaps child *I # 图 11 - 35 TINY ser ve_dynami c 为客户端提供 动态内容\ncode/ne/ttpinyltiny.c # 在发送了响应的第一部分后, 我们会派生一个新的子进程(第11 行)。子进程用来自请求 URI 的 CGI 参数初始化 QUERY _ ST RING 环境变量(第 13 行)。注意,一 个 真 正 的服务器还会在此处设置其他的 CGI 环境变量。为了简短, 我们省略了这一步。\n接下来,子 进程重定向它的标准输出到已连接文件描述符(第14 行),然后 加 载并运行 # CGI程序(第15 行)。因为 CGI 程序运行在子进程的上下文中,它 能 够 访 问 所 有在调用 e x­ e c ve 函数之前就存在的打开文件和环境变量。因此, CGI程序写到标准输出上的任何东西都将直接送到客户端进程,不 会 受 到 任何来自父进程的干涉。其间,父 进 程阻塞在对 wa i t 的调 用中 , 等待当子进程终止的时候,回 收 操 作 系统分配给子进程的资源(第17 行)。\nm 处理过早关闭的连接\n尽管一个 Web 服务 器的基本功能非常简单,但是 我们不想给你 一个假象,以 为编写 一个实际的 Web 服务器是非常简单的。构造一个长时间运行而不 崩溃的健壮的 Web 服务器是 一件困难的任 务,比起 在 这 里我们已经学习了的内容, 它要求对 Linux 系统 编程有更加深入的\n理解。例如,如果一个服务器写一个已经被客户端关闭了的连接(比如,因为你在浏览器上 单击了 \u0026quot; Stop\u0026rdquo; 按钮),那 么第一 次 这 样 的 写会正 常返回, 但 是 第二 次 写就会引起发送 SIG­ PIP E 信号, 这个信号的默认行 为就 是 终 止 这 个进 程。如 果 捕 获或 者 忽略 SIG PIP E 信 号, 那么笫二 次写操作 会返 回值 - 1, 并将 err no 设 置 为 EP IP E。 s tr e rr 和 perr or 函数 将 EPIPE 错误报 告 为 \u0026quot; Broken pipe\u0026rdquo;, 这是一个迷惑了很多人的不太直观的信息。总的来说,一个健壮的服务器必须捕获这些 SIGP IPE 信号, 并且检查 wr i t e 函 数 调 用是否有 EP IPE 错误。\n11. 7 小结\n每个网络应用都是 基于客户端-服务器模型的。根据这个模型,一个应用是由一个服务器 和一个或多个客户端组成 的。服务器管理资 源,以某 种方式操作资源, 为它的 客户端提供服 务。客户端-服务器模型中的基 本操作是客户端-服务器事务 , 它是由客户端请求和跟随其后的 服务器响应 组成的 。\n客户端和服务器通过因特网这个全球网络来通信。从程序员的观点来看,我 们可以把因特网看成是一个全球范圉的主机集合,具有以下几个属性: 1) 每个因特网主机都有 一个唯一的 32 位名字, 称为它的 IP 地址。2)\nIP 地址的集合被映射为一个因特网域名的集合。3)不同因特网主机上的进程能够通过连接互相通信。\n客户端和服务器通过使用套接字接口建立连接。一个套接字是连接的一个端点,连接以文件描述符的形式提供给应用程序。套接字接口提供了打开 和关闭套接字描述符的函数。客户端和服务器通 过读写这些描述符来实 现彼此间的通信。\nWeb 服务器使用 HTT P 协议和它们的客户端(例如浏览器)彼此通信。浏览器向服务器请求 静态或者动态的内容。对静态内容的请求是通过从服务器磁 盘取得文件并把它返回 给客户 端来服务的 。对 动态内容的请求是通过在服务器上一个 子进程的上下文中运行一个程序并将它的输出返回给客户端来服务的。\nCGI 标准提供了一组规则,来管理客户端 如何将 程序参 数传递给服 务器,服 务器如何将这些参数以及其他信息传递给子进程 ,以 及子进程如何 将它的输出发送回 客户端 。只用几百行 C 代码就能实现一个简单但是有功效的 Web 服务器,它既 可以提供静态内容, 也可以提供动态内 容。\n参考文献说明\n有关因特网的官 方信息源被保存 在一系列的 可免费获取的带编号的文档 中,称 为 RFC( Requests for Comments , 请求注解 , Internet 标准(草案))。在以下网站可获得 可搜索的 RFC 的索引:\nhttp://rfc- editor .org\nRFC 通常是 为因特网基础设施的开发者编写 的, 因此,对 于普通读者来 说, 往往 过于详 细了。然而,要想获得权威信息 ,没有 比它更 好的信息来源了。HTT P/ I. 1 协议记录在 RFC 2616 中。 MIME 类型的权威列表保存在:\nh七t p : / / www. i a na . or g / a s s i gnme nt s / medi a- t ypes\nKerrisk 是全面 Linux 编程的圣经 , 提供了现代网络编程的详 细讨论 [ 62] 。关 于计算机网络互联 有大量很好的通用文献[ 65, 84, 114]。伟大的科技作 家 W. Richard Stevens 编写了一系列相关的经典文献 , 如高级\nUnix 编程[ 111] 、因特网协议 [ 109, 120, 107] , 以及 Unix 网络编程[ 108, ll O] 。 认真学习 Unix 系统编程的学生会想要研究 所有这些内容。不幸的是, St evens 在 1999 年 9 月 1 日逝世。我们会永远纪住他的贡献。\n家庭作业 # u l l . 6 A. 修改 TINY 使得它会原样返回每个请求行 和请求报头 。\n使用你 喜欢的浏览器向TINY发送一个对静态内容的请求。把TINY 的输出记录到一个文件中。\n检查 TINY 的输出 ,确定 你的浏览器使用的 HTT P 的版本。\n参考 RFC 2616 中的 HTT P/ 1. 1 标准, 确定你的浏览器的 HTT P 请求中每个报头的含义。你可以从 www.r f c - edi t or . or g/r f c . ht ml 获得 RFC 2616 。\n** 11. 7 扩展 T INY, 使得它可以提供 MPG 视频文件。用一个真正的浏览 器来检验你的工作 。\n•• 11. 8 修改 TINY, 使 得 它在 SIGCHLD 处 理程序中回收操作系统分配给 CGI 子进程的资源,而 不 是 显式地等待它们终止。\n•• 11. 9 修改 TINY, 使 得 当 它 服 务 静 态内容时,使 用 ma l l o c 、 r i o _r e a dn 和r i o _ wr i t e n , 而 不 是 mma p\n和r i o wr i t e n 来 复 制 被请求文件到已连接描述符。\n•• 11. 10 A. 写 出图 11- 27 中 CGI a dde r 函数的 HT ML 表单。你的表单应该包括两个文本框,用 户 将需 要相 加 的 两个数字填在这两个文本框中。你的表单应该使用 GET 方法请求内容。\nB. 用这样的方法来检查你的程序:使 用 一 个 真 正 的 浏 览器向 TINY 请 求 表单,向 TINY 提 交 填写 好的 表单,然 后显示 a dder 生成的动态内容。\n拿 11. 11 扩展 TINY, 以 支 持 HTT P HEAD 方法。使用 TELNET 作为 W eb 客户端来验证你的工作。\n\\* 11. 12 扩展 TINY, 使 得它服务以 HTT P POST 方式请求的动态内容。用你喜欢的 We b 浏览器来验证你的工作。\n*/ 11. 13 修改 TINY, 使 得 它 可 以 干净 地处理(而不是终止)在wr i t e 函 数 试 图 写 一 个 过 早 关 闭 的 连接时发\n生的 SIGPIPE信号和 EPIPE 错误。\n练习题答案 # 11. 1\ncodelnetplhex2dd.c\ncodelnetpldd2hex.c\n11 . 4 下 面是解决 方案。注意 , 使用 i ne t _ n t o p 要困难多少, 它要求很麻烦的强制类型转换和深层嵌套结构引用。g e t na me i n f o 函数要 简单许多 ,因 为它为我们 完成了这些工作。\ncodelnetplhostinfo-ntop.c\n#include \u0026ldquo;csapp.h\u0026rdquo;\n3 int main(int argc, char oargv) 4 { 5 struct addrinfo *P, •listp, hints; 6 struct sockaddr_in•sockp; 7 char buf[MAXLINE] ; int re; 1 0 if (argc != 2) { 11 fprintf (stderr, \u0026ldquo;usage: %s \u0026lt;domain name\u0026gt;\\n\u0026rdquo;, argv[OJ); exit(O); 15 I• Get a list of addrinfo records•I\n16 memset(\u0026amp;hints, 0, sizeof(struct addrinfo));\n17 hints.ai_family = AF_INET; /• IPv4 only•/\n18 hints.ai_socktype = SOCK_STREAM; I• Connections only•/\n19 if ((re = getaddrinfo(argv[1], NULL, \u0026amp;:hints, \u0026amp;:listp)) != 0) {\n20 fprintf (stderr, \u0026ldquo;getaddrinfo error: %s\\n\u0026rdquo;, gai_strerror(rc)); exit(1);\n22\n23\nI• Walk the list anddisplay each associated IP address•I\nfor (p = listp; p; p = p-\u0026gt;ai_next) {\nsockp = (struct sockaddr_in•)p-\u0026gt;ai_addr;\nInet_ntop(AF_INET, \u0026amp;:(sockp-\u0026gt;sin_addr), buf, MAXLINE);\nprintf(\u0026quot;%s\\n\u0026rdquo;, buf);\n29\n30\n/• Clean up•/\nFreeaddrinfo (listp) ;\n33\n34 exit(O);\n35 }\ncodelnetplhostinfo-ntop.c\n11. 5 标准 I/ 0 能在 CGI 程序里工作的原 因是,在子进程中运行 的 CGI 程序不需 要显式地关闭它的输人输出流。当子进程终止时,内核会自动关闭所有描述符。\n"},{"id":436,"href":"/zh/docs/technology/System/%E6%B7%B1%E5%85%A5%E7%90%86%E8%A7%A3%E8%AE%A1%E7%AE%97%E6%9C%BA%E7%B3%BB%E7%BB%9F/%E4%B9%A6%E7%B1%8D/%E7%AC%AC12%E7%AB%A0-%E5%B9%B6%E5%8F%91%E7%BC%96%E7%A8%8B/","title":"Index","section":"SpringCloud","content":"第 12 章\nC H A P T E R 12 . . . _\n并发编程\n正如我们在第 8 章学到的 , 如果逻辑控制流在时间上重叠, 那么它们就是并发的 ( concu rr e nt ) 。这种常见的现象称为并发 ( co nc urr e ncy ) , 出现在计算机系统的许多不同层面上。硬件 异常处理程序、进程和 L in ux 信号处理程序都是 大家很熟悉的例子。\n到目前为止,我们主要将并发看做是一种操作系统内核用来运行多个应用程序的机 制。但是, 并发不仅仅局限 于内核。它也可以在应用程序中 扮演重要角色。例如,我 们已经看到 Linux 信号处理程序如何允许应用响应异步事件, 例如用户键入 C t rl + C , 或者程序访问虚拟内存的 一个未定义的区域。应用级并发在其他情况下 也是很有用的:\n访问慢速 1/0 设备。当一个应用正在等待来自慢速 1/ 0 设备(例如磁盘)的数据到达时,内 核会运行其他进程,使 CP U 保持繁忙。每个应用都可以按照类似的方式, 通过交替 执行 I/ 0 请求和其他有用的工作来利用并发。\n与人交互。和计算机交互的人要求计算机有同时执行多个任务的能力。例如,他们 在打印一个文档时, 可能想要调整一个窗口的大小。现代视窗系统利用并发来提供这种能力。每次用户请求某种操作(比如通过单击鼠标)时, 一个独立的并发逻辑流被创建来执行这个操作。\n通过推迟工作以降低延迟。有时,应用程序能够通过推迟其他操作和并发地执行它 们, 利用并发来降低某些操作的延 迟。比如, 一个动态内存分配器可以通过推迟合并, 把它放到一个运行在较低优先级上的 并发“合并“ 流中, 在有空闲的 CP U 周期时充分利用这些空闲周期,从 而降低单个 fr e e 操作的延迟。\n服务多个网 络客户端。 我们在第 11 章中学习的迭代网络服务器是不现实的, 因为它们一次只能为一个客户端提供服务。因此, 一个慢速的客户端可能会导致服务 器拒绝为所有其他客户端服务。对千一个真正的服务器来说,可能期望它每秒为成百上千的 客户端提供服务,由千一个慢速客户端导致拒绝为其他客户端服务,这是不能接受 的。一个更好的方法是创建一个并发服务器,它为每个客户端创建一个单独的逻辑 流。这就允许服务器同时为多个客户端服务 , 并且也避免了慢速客户端独占 服务器。\n在多核机器上进行并行计算。许多现代系统都配备多核处理器,多核处理器中包含 有多个 CP U。被划分成并发流的应用程序通常在多 核机器上比 在单处理器机器上运行得快, 因为这些流会并行执行, 而不是交错执行。\n使用应用级并 发的应用程序称为并发程序 ( co nc ur re nt pro g ra m ) 。现代操作系统提供了三种基本的构造并发程序的方法:\n进程。用这种方法 ,每 个逻辑控制流都是一个进程, 由内核来调度和维护。因为进程有独立的虚拟地址空间,想要和其他流通信,控制流必须使用某种显式的进程间通信( in te r proces s communication, IPC) 机制。\nI/ 0 多路 复用。 在这种形式的并发编程中 , 应用程序在一个进程的上下文中显式地调度它们自己的逻辑流。逻辑流被模型化为状态机,数据到达文件描述符后,主程 序显式地从一个状态转换到另一个状态。因为程序是一个单独的进程, 所以所有的流都共享同一个地址空间。\n线程。线程是运行在一个单一进程上下文中的逻辑流,由内核进行调度。你可以把 线程看成是其他两种方式的混合体, 像进程流一样由内核进行调度,而 像 1/0 多路复用流一样共享同一个虚拟地址空间。\n本章研究这兰种不同的并发编程技术。为了使我们的讨论比较具体,我们始终以同一个应用为例一 11. 4. 9 节中的迭代 echo 服务器的并 发版本。\n12. 1 基千进程的并发编程 # 构造并发程序 最简单的方法就是用进程,使 用 那些大家都很熟悉的函数, 像 f o r k、 e xe c 和 wa i t p 过 。 例如, 一个构造并发服务器的自然方法就是, 在父进程中接受 客户端连接请求,然后创建一个新的子进程来为每个新客户端提供服务。\n为了了解这是如何工作的,假设我们有两个客户端和一个服务器,服务器正在监听一 个监听描述符(比如指述符 3) 上的连接请求 。现在假设服务器接受了客户端 1 的连接请求, 并返回一个已连接描述符(比如指述符 4 ) , 如图 12-1 所示。在接受 连接请求之后,服务器派生一个子进程,这个子进程获得服务器描述符表的完整副本。子进程关闭它的副本中的 监听描述符 3\u0026rsquo; 而父进程关闭 它的已连接描述符 4 的副本, 因为不再需要这些描述符了。这就得到了图 12- 2 中的状态, 其中子进程正 忙于为客户端提供服务。\n三一_一、一_、归接请求\nc l i e n t f d\n三\nclientfd\n--、\u0026ndash; listenfd(3) connfd(4)\nclientfd\n三cl i en 七 f d\nlistenfd(3)\n图 1 2-1 第一步:服务器接受客户端的连接请求 图 12- 2 第二步:服务器派生一个子进程为这个客户端服务\n因为父、子进程中的已连接描述符都指向同一个文件表表项,所以父进程关闭它的已连接描述符的副本是至关重要的。否则 , 将永不会释放巳连接描述符 4 的文件表条目 ,而且由此引起的内存泄涌将最终消耗光可用的内存,使系统崩溃。\n现在, 假设在父进程为客户端 1 创建了子进程之后,它 接受一个新的客 户端 2 的连接请求, 并返回一个新的已连接描述符(比如描述符 5) , 如图 12-3 所示。然后,父进程 又派生另一个子进程, 这个子进程用已连接描述符 5 为它的客户端提供服务, 如图 12-4 所示。此时, 父进程正在等待下一个连接请求,而两个子进程正在并发地为它们各自的客户端提供服务。\nclientfd listenfd(3)\nclientfd\nlistenfd(3)\n, \u0026rsquo; connfd(5)\n巨\n图12-3 第三步:服务器接受另一个连接请求 图 12-4 第四步:服务器派生另一个子进程为新的客户端服务\n1. 1 基于进程的并发服务器\n图12-5 展示了一个基千进程 的并发 ech o 服务器的代码。第 29 行调用的 e c h o 函数来自于图 1 1- 21。关于这个服务器 , 有几点重要内容需要说明:\n首先, 通常服务器会运行很长的时间, 所以我们必须 要包括一个 S IG C H L D 处理程序, 来回收僵死( zo m bie ) 子进程的 资 源(第4 9 行)。因为当 S IG C H L D 处理程序执行时, S IG C H LD 信号 是阻塞的, 而 L in u x 信号是不排队的, 所以 SIGC H LD 处理程序必须准备好回收多个僵死子进程的资源。 其次, 父子进程必须关闭 它们各自的 c o n n f d ( 分别为第 33 行和第 30 行)副本。就像我们已经提到过的,这对父进程而言尤为重要,它必须关闭它的已连接描述符, 以避免内存泄漏。 最后, 因为套接字的文件表表项中的引用计数, 直到父子进程的 c o n n f d 都关闭了, 到客户端的连接才会终止。 code/condechoserverp.c\n#include \u0026ldquo;csapp.h\u0026rdquo;\nvoid echo(int connfd);\n3\n4 void sigchld_handler(int sig)\n5 {\n6 while (waitpid(-1, 0, WNOHANG) \u0026gt; 0)\n7\n8 return;\n9 }\n10\n11 int main(int argc, char **argv)\n12 {\n13 int listenfd, connfd;\n14 socklen_t clientlen;\nstruct sockaddr_storage clientaddr;\n16\n7 if (argc != 2) {\nfprintf(stderr, \u0026ldquo;usage: %s \u0026lt;port\u0026gt;\\n\u0026rdquo;, argv[O]);\nexit (0);\n20 }\n21\nSignal(SIGCHLD, sigchld_handler);\nlistenfd = Open_listenfd (argv [1]) ;\n24 while (1) {\nclientlen = sizeof(struct sockaddr_storage);\nconnfd = Accept (listenfd, (SA *) \u0026amp;clientaddr, \u0026amp;clientlen) ;\n27 if (Fork() == 0) {\n28\n29\n30\n31\n32 }\nClose(listenfd) ; echo(connfd); Close(connfd); exit(O);\nI* Child closes its listening socket *I I* Child services client *I\nI* Child closes connection with client *I I* Child exits *I\n33 Close(connfd); I* Parent closes connected socket (important!) *I\n34 }\n35 }\ncode/condechoserverp.c\n图 12-5 基于进程的并发 echo 服务器 。父进程 派生一个 子进程来 处理每个新的 连接请求\n12 . 1. 2 进程的优劣\n对于在父、子进程间共享状态信息,进程有一个非常清晰的模型:共享文件表,但是不共享用户地址空间。进程有独立的地址空间既是优点也是缺点。这样一来, 一个进程不可能不小心覆盖另一个进程的虚拟内存,这就消除了许多令人迷惑的错误—— 这是一个明显的优点。\n另一方面,独立的地址空间使得进程共享状态信息变得更加困难。为了共享信息,它 们必须使用显式的 IPC C进程间通信)机制。(参见下面的 旁注。)基于进程的设计的另一个缺点是, 它们往往比较慢, 因为进程控制 和 IPC 的开销很高。\n日日Unix IPC\n在本 书中 , 你已经遇到 好几个 IPC 的例子了。 笫 8 章中的 wa i t p i d 函数和信号是基本的 IPC 机制, 它们 允许进程发 送小消息 到 同一主机 上的 其他进程。笫 11 章的套接宇接口是 IPC 的一种重要 形式, 它允许 不同主机 上的进程交换任 意的 字 节流。 然而,术语 U n ix IPC 通常指的是所有 允许进程和 同一台主机上其他 进程进行 通信的 技术。其中包括管道、先进先出( FIF O) 、 系统 V 共享内存, 以及 系统 V 信号量( s e m a p h o r e ) 。这些机 制超 出了我 们的讨论范围。 K e r r is k 的著作[ 62] 是很 好的参考资料。\n凶 练习题 12. 1 在图 1 2-5 中, 并发 服 务器的 第 33 行上, 父 进 程 关 闭 了 巳连 接 描述符后, 子进 程仍 然能够使 用该 描述 符和 客户端 通信。 为 什么?\n练习题 12. 2 如果 我们 要 删除 图 1 2-5 中关 闭 巳连 接描述 符的 第 30 行,从 没有内存泄漏的角度来说,代码将仍然是正确的。为什么?\n2 基千 1/ 0 多路复用的并发编程\n假设要求你编写一个 e ch o 服务器,它 也 能对用户从标准输入键入的交互命令做出响应。在这种情况下 , 服务器必须响应 两个互相独立的 I/ 0 事件: 1) 网络客户端发 起连接请求, 2 ) 用 户在键盘上 键人命令行。我们先 等待哪个事件呢? 没有哪个选择是理想的。如果在 a c c e p t 中等待一个连接请求, 我们就不能响应输入的命令。类似地, 如果在r e a d 中等待一个输入命令,我们就不能响应任何连接请求。\n针对这种困 境的一个解决 办法就是 1/ 0 多路复用 CI/ 0 m u lt ip le x in g ) 技术。基本的思路就 是使用 s e l e c t 函数, 要求内核挂起进程,只 有在一个或多个 I/ 0 事件发生后,才将控制返回给应用程序,就像在下面的示例中一样:\n当集合{O, 4}中任意描述符准备好读时返回。 当集合 { 1, 2 , 7}中任意描述符准备好写时返回。\n如果在等待一个 I/ 0 事件发生时过了 152. 13 秒, 就超时。\ns e l e c 七是一个复杂的 函数, 有许多不同的使用场景。我们将只讨论第一种场景: 等待一组描述符准备好读。全面的讨论请参考[ 62, 110] 。\n#include \u0026lt;sys/select.h\u0026gt;\nint select(int n, fd_set *fdset, NULL, NULL, NULL);\n返回已准备 好的描述符的 非零的个数 , 若出错 则 为一 1。\nFD_ZERO(fd_set *fdset); FD_CLR(int fd, fd_set *fdset); FD_SET(int fd, fd_set *fdset); FD_ISSET(int fd, fd_set *fdset);\nI* Clear all bits in fdset *I I* Clear bit fd in fdset *I I* Turn on bit fd in fdset *I\n/* Is bit fd in fdset on? *I\n处理 描述符 集合的 宏。\ns e l e c t 函数处理类型为 f d _s e t 的集合,也 叫做描述符集合 。逻辑上, 我们将描述符集合看成一个大小为 n 的位向量(在2. 1 节中介绍过):\nb,,_1 , \u0026hellip; , b1 , b。\n每个位 从对应于描述符 k 。当且仅当从 = l , 描述符 k 才表明是 描述符集合的一个元素。只允许你对描述符集合做三件事: 1 ) 分配它们, 2 ) 将一个此种类型的变量赋值给另一个变量, 3 ) 用 F D_ ZERO、F D_S ET 、FD_CLR 和 F D_ISS ET 宏来修改 和检查它们。\n针对我们的 目的, s e l e c t 函数有两个输入: 一个称为读 集合 的描述符集合( f d s e t ) 和该读集合的基数( n ) ( 实际上是任何描述符集合的最大基数)。s e l e c t 函数会一直阻塞, 直到读集合中至少有一个 描述符准备好可以读 。当且仅当一个从该描述符读取一个字节的请求不会阻塞时, 描述符 k 就表示准备 好可以 读了。s e l e c t 有一个副作用, 它修改参数f d s e t 指向的 f d _ s e t , 指明读集合的一个子集,称 为准备 好集合 ( read y set) , 这个集合是由读集合中准备好可以 读了的描述符组成的。该函数返回的值指明了准备好集合的基 数。注意, 由于这个副作用, 我们必 须在每次调用 s e l e c t 时都更新读集合。\n理解 s e l e c t 的最好办法是研究一个具体例子。图 12-6 展示了可以如何利用 s e l e c t 来实现一个迭代 echo 服务器, 它也可以接受标准输入上的用户命令。一开始, 我们用图 11-1 9 中的 op e n_止 s t e n f d 函数打开一个监听描述符(第1 6 行), 然后使用 F D_ ZE RO 创建一个空的读集合(第18 行):\nlistenfd\n3 2\nread_set (0) : 曰\nstdin\n1 0\n二\n接下来, 在第 19 和 20 行中, 我们定义由描述符 0 ( 标准输入)和描述符 3 ( 监听描述符)组成的读集合:\nlistenfd stdin\n3 2 1 0\nread_set ({O, 3)) : I 1 I 1 I 1 I\n在这里, 我们开始典 型的服务器循环 。但是我们不调用 a c c e p七函数来等待一个连接请求,而 是调用 s e l e 吐 函数, 这个函数会一直阻塞, 直到监听描述符或者标准输入准备好可以读(第24 行)。例如,下 面是 当用户按回车键, 因此使得标准输入描述符变为可读时, s e l e c t 会返回的r e a d y_ s 包 的值:\nlistenfd stdin\n3 2 1 0\nready_set ({O}): I 1 1 I 1 I\n一旦 s e l e c t 返回, 我们就用 F D _ ISSET 宏指令来确定哪个描述符 准备好可以读了。如果是标准输入准备好了(第25 行), 我们就调用 c omma nd 函数,该 函数在返回到主程序前, 会读、解析和响应命令。如果是监听描述符准备好了(第27 行), 我们就调用 a c c e p t 来得到一个已 连接描述符 , 然后调用图 11-22 中的 e c ho 函数, 它会将来自客户端的每一行又回送回去, 直到客户端关闭 这个连接中它的那一端。\n虽然这个程序是使用 s e l e c t 的一个很好示例,但 是它仍然留下了一些问题待解决。问题是一旦它连接到某个客户端,就会连续回送输入行,直到客户端关闭这个连接中它的那一 端。因此,如果键入一个命令到标准输人,你将不会得到响应,直到服务器和客户端之间结\n束。一个更好的方法是更细粒度的多路复用,服务器每次循环(至多)回送一个文本行。\ncode/co nd se lect. c\n#include \u0026ldquo;csapp.h\u0026rdquo;\nvoid echo(int connfd);\nvoid command(void);\n4\n5 int main(int argc, char **argv)\n6 {\nint listenfd, connfd;\nsocklen_t clientlen;\nstruct sockaddr_storage clientaddr;\nfd_set read_set, ready_set;\n11\nif (argc != 2) {\nfprintf(stderr, \u0026ldquo;usage: %s \u0026lt;port\u0026gt;\\n\u0026rdquo;, argv[O));\nexit(O);\n15 }\n16 listenfd = Open_listenfd(argv[1]); 17\nFD_ZERO(\u0026amp;read_set); I* Clear read set *I\nFD_SET(STDIN_FILENO, \u0026amp;read_set); I* Add stdin to read set *I\nFD_SET(listenfd, \u0026amp;read_set); I* Add listenfd to read set *I 21\nwhile (1) {\nready_set = read_set;\nSelect(listenfd+1, \u0026amp;ready_set, NULL, NULL, NULL);\nif (FD_ISSET(STDIN_FILENO, \u0026amp;ready_set))\ncommand(); I* Read command line from stdin *I\nif (FD_ISSET(listenfd, \u0026amp;ready_set)) {\n28\n29\n30\n31\n32\n33 }\n34 }\n35\nclientlen = sizeof(struct sockaddr_storage);\nconnfd = Accept(listenfd, (SA *)\u0026amp;clientaddr, \u0026amp;clientlen); echo(connfd); I* Echo client input until EDF *I Close(connfd);\nvoid command(void) { char buf[MAXLINE]; if (! Fgets (buf , MAXLINE, st din)) exit(O); I* EOF *I printf(\u0026quot;%s\u0026quot;, buf); I* Process the input command *I 41 } codelcondselect.c\n图 12-6 使用 1/ 0 多路复用的 迭代 echo 服务器。服务器使用 s e l e c t\n等待监听描述符上的连接请求和标准输人上的命令\n沁目 练习题 12. 3 在 L i n u x 系统 里,在标 准输入 上键入 C t rl + D 表 示 EOF 。 图 12-6 中的程序阻塞在 对 s e l e c t 的调 用上 时,如果 你键 入 C t rl + D 会发 生什 么?\n12. 2. 1 基千 1/ 0 多 路 复用的并发事件驱动服务器\nI / 0 多路复用可以用做并发事件 驱动( e ve n t- d r ive n ) 程序的基础, 在事件驱动程序中, 某些事件会导致流向前推进。一般的思路是将逻辑流模型化为状态机。不严格地说,一个\n状态机 ( s t a t e m a c h in e ) 就是一组状 态 ( s t a t e ) 、输 入事件 ( in p u t e ve n t ) 和转移 ( t ra n s it io n ) , 其中转移是将状态和输入事件映射到状态。每个转移是将一 个(输入状态, 输入事件)对映射到一个输出状态。自循环 ( s e lf - lo o p ) 是同一输入和输出状态之间的转移。通常把状态机画成有向图,其中节点表示状态,有向弧表示转移,而弧上的标号表示输入事件。一个状 态机从某种初始状态开始执行。每个输入事件都会引发一个从当前状态到下一状态的 转移。\n对于每个新的 客户端 k , 基于 1/ 0 多路复用的 并发服务器会创建一个新的状态机\nSk\u0026rsquo; 并将它和巳连接描述符d k 联系起来。如图 12-7 所示, 每个状态机 Sk 都有一个状态( \u0026ldquo;等待描述符 d k 准备好可读\u0026rdquo;)、一 个输入事件("描述符 d k 准备好 可以读了\u0026quot;)和一个转移\n(“从描述符 d k 读一个文本行\u0026quot;)。 图 12- 7 并发事件驱动echo 服务器中逻辑流的状态机\n服务器使用 1/ 0 多路复用 , 借助 s e l e c t 函数检测输入事件的发生。当每个已连接描述符准备好可读时,服务器就为相应的状态机执行转移,在这里就是从描述符读和写回一 个文本行。\n图 1 2-8 展示了一个基于 1/ 0 多路复用的并发事件驱动服务器的完整示 例代码。一个\np o o l 结构里维护着活动客户端的集合(第3 11 行)。在调用 i n i t _ p o o l 初始化 池(第27 行)之后, 服务器进入一个无限循环。在 循环的 每次 迭代中, 服务器调用 s e l e c t 函数来检测两种不同类型的输入事件: a ) 来自一个新客户端的连接请 求到达, b ) 一个已存在的客户端的已连接描述符准 备好可以 读了。当一个连接请求到达时(第35 行), 服务器打开连接(第37 行), 并调用 a d d _ c l i e n t 函数, 将该客户端添加到池里(第38 行)。最后, 服务器调用 c h e c k_ c l i e n t s 函数, 把来自每个 准备好的已连接描述符 的一个文本行回送回去\n(第 42 行)。\ncode/condechoservers.c\n1 #include \u0026ldquo;csapp.h\u0026rdquo;\ntypedef struct { /• Represents a pool of connected descriptors•/\nint maxfd; I• Largest descriptor in read_set•I\nfd_set read_set; I• Set of all active descriptors•I\nfd_set ready_set; I• Subset of descriptors ready for reading•/\nint nready; /• Number of ready descriptors from select•I\nint maxi; /• High water index into client array•/\nint clientfd [FD_SETSIZE) ; /• Set of active descriptors•/\nrio_t clientrio[FD_SETSIZE); I* Set of active read buffers•/\n} pool;\n12\n13 int byte_cnt = 0; I* Counts total bytes received by server•/\n14\n15 int main(int argc, char **argv)\n16 {\nint listenfd, connfd;\nsocklen_t clientlen;\nstruct sockaddr_storage clientaddr;\n图12-8 基 于 I/ 0 多路复用的并发 echo 服务器。每次服务器迭代都回送来自每个准备好的描述符的文本行\nstatic pool pool; 21\nif (argc != 2) {\nfprintf(stderr, \u0026ldquo;usage: %s \u0026lt;port\u0026gt;\\n\u0026rdquo;, argv[O]);\nexit(O);\n25 }\nlistenfd = Open_listenfd(argv[1]);\ninit_pool(listenfd, \u0026amp;pool); 28\nwhile (1) {\nI* Wait for listening/connected descriptor(s) to become ready *I\npoolr. eady_s et = pool.read_set;\n32\n33\n34\n35\n36\n37\n38\n39\n40\n41\n42\n43\n44 }\npool.nready = Select(pool.maxfd+l, \u0026amp;pool.ready_set, NULL, NULL, NULL);\nI* If 让 s t eni ng descriptor ready, add new client to pool *I if (FD_ISSET (listenfd, \u0026amp;pool. ready_set)) {\nclientlen = sizeof(struct sockaddr_storage);\nconnfd = Accept(listenfd, (SA *)\u0026amp;clientaddr, \u0026amp;clientlen); add_client(connfd, \u0026amp;pool);\n}\nI* Echo a text line from each ready connected descriptor *I check_clients(\u0026amp;pool);\ncodelcondechoservers.c\n图 12-8 ( 续 )\ni n it _p o o l 函数(图1 2- 9 ) 初始化客户端池。 c l i e n t f d 数组表示已连接描述符的集合, 其中整数—1 表示一个可用的 槽位。初始时,已 连接描述符集合是空的(第5 ~ 7 行),而且监听描 述符是 s e l e c t 读集合中唯 一的描述符(第1 0 ~ 1 2 行)。\ncod e/cond echoservers .c\nvoid init_pool(int listenfd, pool *p)\n3 I* Initially, there are no connected descriptors *I inti;\np-\u0026gt;maxi = -1;\n6 for (i=O; i\u0026lt; FD_SETSIZE; i++)\np-\u0026gt;clientfd[i] = -1;\n8\nI* Initially, listenfd is only member of select read set *I\np-\u0026gt;maxfd = listenfd;\nFD_ZERO(\u0026amp;p-\u0026gt;read_set);\nFD_SET(listenfd, \u0026amp;p-\u0026gt;read_set);\n13 }\ncode/co nd echoservers .c\n图 12-9 init pool 初 始 化 活动客户端池\na d d _ c li e n t 函数(图1 2- 1 0 ) 添加一个新的客户端到活动客户端池中。在 c li e n tf d 数组中找到一个空槽位后,服务器将这个巳连接描述符添加到数组中,并初始化相应的 R I O 读缓冲区 , 这样一 来我们就能够对这个描述符调用r i o _ r e a d l i n e b ( 第 8 ~ 9 行)。然\n后, 我们将 这个已连接描述符添加到 s e l e c t 读集合(第12 行), 并更新该池的一些全局属性。ma x f d 变量(第15 16 行)记录了 s e l e 立 的最大文件描述符。ma x i 变量(第17 18 行)记录的是 到 c l i e n t f d 数组的最大索引, 这样 c h e c k_ c l i e n t s 函数就无需搜索整个数组了。\ncode/condechoservers.c\n1 void add_client(int connfd, pool *p)\n2 {\n3 inti;\n4 p-\u0026gt;nready\u0026ndash;;\ns for (i = O; i \u0026lt; FD_SETSIZE; i++) I* Find an available slot *I\n6 if (p-\u0026gt;clientfd[i] \u0026lt; 0) {\n7 I* Add connected descriptor to the pool *I\n8 p-\u0026gt;clientfd[i] = connfd;\n9 Rio_readinitb(\u0026amp;p-\u0026gt;clientrio[i], connfd);\n10\n11 I* Add the descriptor to descriptor set *I\n12 FD_SET(connfd, \u0026amp;p-\u0026gt;read_set);\n13\nI* Update max descriptor and pool high water mark *I\nif (connfd \u0026gt; p-\u0026gt;maxfd)\np-\u0026gt;maxfd = connfd;\nif (i \u0026gt; p-\u0026gt;maxi)\np-\u0026gt;maxi = i;\nbreak;\n20 }\nif (i == FD_SETSIZE) / * Couldn\u0026rsquo;t find an empty slot *I\napp_error(\u0026ldquo;add_client error: Toomany clients\u0026rdquo;);\n23 }\ncode/condechoservers.c\n图 1 2-10 add_c l i e nt 向池中添加一个新的客户端连接\n图 12-11 中的 c he c k_ c l i e n t s 函数回送来自每个 准备好的已连接描述符的一个文本行。如果成功地从描述符读取了一个文本行, 那么就将该文本行回送到客户端(第15 18 行)。注意,在 第 15 行我们维护着一个从所有客户端接收到的 全部字节的 累计值。如果因为客户端关闭这个连接中它的那一端, 检测到 EOF , 那么将关闭这边的连接端(第23 行), 并 从池中清除掉这个描述符(第24 25 行)。\n根据图 1 2- 7 中的有限状态模型, s e l e c t 函数检测到输入事件, 而 a d d _ c l i e 吐 函数创建一个新 的逻辑流(状态机)。c h e c k _ c l i e n t s 函数回送输入行,从 而执行状态转移, 而且当客户端完成文本行发送时,它还要删除这个状态机。\n沁囡 练习题 12 . 4 图 1 2-8 所 示的 服 务器中, 我们 在每次调 用 s e l e 立 之前都 立 即小心地重新初 始化 p o o l .r e a d y_ s e t 变量。 为什 么?\n豆 日 事件驱 动的 We b 服务器\n尽管有 12. 2. 2 节中说 明的缺点, 现代高性能服务器( 例如 N od e. js 、ng in x 和 T or­ na do ) 使用的都是 基于 1/ 0 多路 复用的 事件 驱动的 编程 方式 , 主要是因为相 比于进程和线程的 方式 , 它有明 显的性能优势。\ncode/condechoservers. c\nvoid check_clients(pool *p)\n2 {\n3 inti, connfd, n;\n4 char buf[MAXLINE];\n5 rio_t rio;\n6\n7 for (i = O; (i \u0026lt;= p-\u0026gt;maxi) \u0026amp;\u0026amp; (p-\u0026gt;nready \u0026gt; 0); i++) {\n8 connfd = p-\u0026gt;clientfd [i] ;\n9 rio = p-\u0026gt;clientrio[i];\n10\n11 /* If the descriptor is ready, echo a text line from it *I\n12 if ((connfd \u0026gt; 0) \u0026amp;\u0026amp; (FD_ISSET(connfd, \u0026amp;p-\u0026gt;ready_set))) {\n13 p-\u0026gt;nready\u0026ndash;;\nif ((n = Rio_readlineb (\u0026amp;rio, buf, MAXLINE)) ! = 0) {\nbyte_cnt += n;\nprintf(\u0026ldquo;Server received %d (%d total) bytes on fd %d\\n\u0026rdquo;,\nn, byte_cnt, connfd);\n18 Rio_writen(connfd, buf, n);\n19 }\n20\n21 I* EDF detected, remove descriptor from pool *I\n22 else {\nClose(connfd);\nFD_CLR(connfd, \u0026amp;p-\u0026gt;read_set);\np-\u0026gt;clientfd [i] = -1;\n26 }\n27 }\n28 }\n29 }\ncode/condechoservers.c\n图 12-11 check cl i ent s 服务准备好的 客户 端连接\n12. 2. 2 1/ 0 多 路 复用技术的优劣\n图 12-8 中的服务器提供了一个 很好的基于 I/ 0 多路复用的事件驱动编程的优缺点示例。事件驱动设计的一个优点是,它比基千进程的设计给了程序员更多的对程序行为的控制。例如,我们可以设想编写一个事件驱动的并发服务器,为某些客户端提供它们需要的服务,而这对于基于进程的并发服务器来说,是很困难的。\n另一个优点是, 一个基千 I/ 0 多路复用的事件驱动服务器是运行在单一进程上下文中的,因此每个逻辑流都能访问该进程的全部地址空间。这使得在流之间共享数据变得很容 易。一个与作为单 个进程运行相关的优点是, 你可以利用熟悉的调试工具, 例如 GDB, 来调试你的并发服务器,就像对顺序程序那样。最后,事件驱动设计常常比基于进程的设 计要高效得多,因为它们不需要进程上下文切换来调度新的流。\n事件驱动设计一个明显的 缺点就是编码复杂。我们的事件驱动的并 发 echo 服务器需要的代码比基于进程的服务器多三倍,并且很不幸,随着并发粒度的减小,复杂性还会上升。这 里的粒度是指每个逻辑流每个时间片执行的指令数量。例如,在示例并发服务器中,并发粒 度就是读一个完整的文本行所需要的指令数 量。只要某个逻辑流正忙于读一个文本行, 其他逻辑流就不可能有进展 。对我们的例子来说这没有问题, 但是它使得在“故意只发送部分文\n本行然后就停止"的恶意客户端的攻击面前,我们的事件驱动服务器显得很脆弱。修改事件 驱动服务器来处理部分文本行不是一个简单的任务,但是基千进程的设计却能处理得很好, 而且是自动处理的。基于事件的设计另一个重要的缺点是它们不能充分利用多核处理器。\n12. 3 基于线程的并发编程\n到目前为止,我们已经看到了两种创建并发逻辑流的方法。在第一种方法中,我们为 每个流使用了单独的进程。内核会自动调度每个进程,而每个进程有它自己的私有地址空 间,这 使得流共享数据很困难。在第二种方法中, 我们 创建自己的逻辑流, 并利用 I/ 0 多路复用来显式地调度流。因为只有一个进程,所有的流共享整个地址空间。本节介绍第三 种方法 基千线程,它是这两种方法的混合。\n线程 ( t hread ) 就是运行在进程上下 文中的逻辑流。在本书里迄今为止, 程序都是由每个进程中一个线程组成的。但是现代系统也允许我们编写一个进程里同时运行多个线程的程序。线 程由内核自动调度。每个线程都有它自己的 线程上 下文( t h read context), 包括一个唯一的整数线程 ID ( T hread ID, T ID) 、栈、栈指针、程序 计数器、通用目的寄存器和条件码。所有的运行在一个进程里的线程共享该进程的整个虚拟地址空间。\n基千线程的逻辑流结合 了基于进程 和基于 I/ 0 多路复用的流的特性。同进程一样, 线程由内核 自动调度 ,并且内核通过一个 整数 ID 来识别线程。同 基于 I/ 0 多路复用的流一样,多个线程运行在单一进程的上下文中,因此共享这个进程虚拟地址空间的所有内容, 包括它的代码、数据、堆、共享库和打开的文件。\n12. 3. 1 线程执行模型\n多线程的执行模型在某些方面和多进程的执行模型是相似的。思考图 12-12 中的示例。每个进程开始生命周期时都是单一线程, 这个线程称为主线程 ( main t hread ) 。在某一时刻,主线程创建一个对等线程(peer thread) , 从这个时间点开始,两个线程就并发地运行。最后,因为主线程执行一个 慢 速 系 统 调 用, 例 如 r e a d 或 者\nsleep, 或者因为被系统的间隔计时器中断,控制就会通过上下文切换传递到对等\n时间\n线程1 线程2\n(对等线程)\n---------------------------\n…- - \u0026mdash;\u0026mdash;- \u0026ndash; \u0026mdash;-r\u0026mdash;\u0026mdash;\u0026ndash; }线程上下文切换\n:::::::一二:二勹}线程上下文切换二::三二::}线程上下文切换\n图12-12 并发线程执行\n线程。对等线程会执行一段时间,然后控制传递回主线程,依次类推。\n在一些重要的方面,线程执行是不同于进程的。因为一个线程的上下文要比一个进程的上下文小得多,线程的上下文切换要比进程的上下文切换快得多。另一个不同就是线程不像进程那样,不是按照严格的父子层次来组织的。和一个进程相关的线程组成一个对等\n(线程)池,独立于其他线程创建的线程。主线程和其他线程的区别仅在于它总是进程中第 一个运行的线程。对等(线程)池概念的主要影响是,一个线程可以杀死它的任何对等线程,或者等待它的任意对等线程终止。另外,每个对等线程都能读写相同的共享数据。\n3 . 2 Pos ix 线程\nPosix 线程( P t hreads ) 是在 C 程序中处理线程的一个标准接口。它最早出现在 1995\n年, 而且在所有的 L in u x 系统上都 可用。P t h r ea d s 定义了大约 60 个函数,允 许程序创建、杀死和回收线程,与对等线程安全地共享数据,还可以通知对等线程系统状态的变化。\n图 12-13 展示了一个简单的 P t h rea ds 程序。主线程创建一个 对等线程, 然后等待它的终止。对等线程输出 \u0026quot; He l l o , world! \\ n\u0026quot; 并且终止。当主线 程检测到对等线 程终止后, 它就通过调用 e x i t 终止该进程。这是我们看到的第一个线程化的程序, 所以让我们仔细地解析它。线程的代码和本地数据被封装在一个线程例 程( t h r ead ro u t in e) 中。正如第二行里的原型所示,每个线程例程都以一个通用指针作为输入,并返回一个通用指针。如果想 传递多个参数给线程例程,那么你应该将参数放到一个结构中,并传递一个指向该结构的 指针。相似地,如果想要线程例程返回多个参数,你可以返回一个指向一个结构的指针。\ncode/cone/hello.c\n#include \u0026ldquo;csapp.h\u0026rdquo;\n2 void *thread(void *vargp);\n3\n4 int main()\n5 {\n6 pthread_t tid;\n7 Pthread_create(\u0026amp;tid, NULL, thread, NULL);\n8 Pthread_join(tid, NULL);\n9 exit(O);\n10 }\n11\n12 void *thread(void *vargp) I* Thread routine *I\n13 {\n14 printf(\u0026ldquo;Hello, world!\\n\u0026rdquo;);\n15 return NULL;\n16 }\ncodelcondhello.c\n图 1 2- 1 3 hello.c: 使用 Pthreads 的 \u0026quot; Hello , world!\u0026quot; 程序\n第 4 行标出了主线程代码的 开始。主线程声明了一个本地变僵 t 过, 可以用来存放对等线程的 ID( 第 6 行)。主线程通过调用 p t hr e a d _ cr e a 七e 函数创建一个新的对等线程(第\n7 行)。当对 p t hr e a d _ c r e a t e 的调用返回时, 主线程和新创建的对等线程同时运行, 并且 t i d 包含新线程的 ID。通过在第 8 行调用 p t hr e a d _ j o i n , 主线程等待对等线程终止。最后, 主线程调用 e x 江(第9 行), 终止当时运行在这个 进程中的所有线程(在这个示例中就只有主线程)。\n第 12 ~ 1 6 行定义了对等线程的 例程。它只 打印一个字符串, 然后就通过执行第 15 行中的r e 七ur n 语句来终止对等线程 。\n12. 3. 3 创建线程\n线程通过调用 p t hr e a d _cr e a 七e 函数来创建其他线程。\n#include \u0026lt;pthread.h\u0026gt;\ntypedef void *(func) (void *);\nint pthread_create(pthread_t *tid, pthread_attr_t *attr,\nfunc *f, void *arg);\n若成功则返回 o, 若出错 则 为 非零。\np t hr e a d_ cr e a t e 函数创建一个新的线程, 并带着一个输入变量 ar g , 在新线程的上下文中运行线程例 程 f 。能用 a t 七r 参 数来改变新创建线程的 默认属性 。改变这些属性已超出我们 学习的范围, 在我们的示 例中,总 是用一个为 N U L L 的 a t tr 参数来调用p t h r e a d_ cr e a 七e 函数。\n当 p t h r e a d _ cr e a t e 返回时 , 参数 t i d 包含新创建线程的 ID 。新线程可以通过调用\np t hr e a d_ s e l f 函数来获得它自己的线 程 ID。\n#include \u0026lt;pthread.h\u0026gt;\npthread_t pthread_self(void);\n返回调用 者的 线 程 ID .\n3. 4 终止线程\n一个线程是以下列方式之一来终止的:\n当顶层的线程例程返回时 , 线程会隐式地终 止。 通过调用 p 七hr e a d _ e x i t 函数, 线程会显 式地终 止。如果主线程调用 p t hr e a d _e x ­\nl 七,它 会等待所有其他对等线程终止,然 后再终止主线程和整个进程, 返回值为\nthread r et urn。\n#include \u0026lt;pthread.h\u0026gt;\nvoid pthread_exit(void *thread_return);\n从不返回。\n某个对等线程调用 Lin ux 的 e x i t 函数,该 函数终止进程以 及所有与该进程相关的线程。 另一个对等线程 通过以 当前线程 ID 作为参数调用 p t h r e a d _ c a n c e l 函数来终止当前线程。 #include \u0026lt;pthread.h\u0026gt;\nint pthread_cancel(pthread_t tid);\n若成功则返回o , 若 出错 则 为 非零。\n12. 3. 5 回收己终止线程的资源\n线程通过 调用 p 七hr e a d _ j o i n 函数等待其他线程终止。\n#include \u0026lt;pthread.h\u0026gt;\nint pthread_join(pthread_t tid, void **thread_return);\n若成功则返 回 o, 若出错则为非零。\np t hr e a d_ j o i n 函数会阻塞, 直到线程 t i d 终止, 将线程例程返回的通用( v o i d * ) 指针赋值为 t h r e a d _r e t ur n 指向的位置, 然后回收己终 止线程占用的所有内存资源。\n注意 , 和 L in u x 的 wa i t 函数不同, p t hr e a d _ j o i n 函数只能等待一个指定的线程终止。没有办法让 p t hr e a d _ wa i t 等待任意一个线程终 止。这使得代码更加复杂, 因为它 迫\n使我们去使用其他一些不那么直观的机制来检测进程的 终止。实际上, S t eve ns 在[ ll O] 中就很有说服力地论证了这是规范中的一个错误。\n12. 3. 6 分离线程\n在任何一个时间点上, 线程是可结合的 ( joina ble ) 或者是 分离的( detached ) 。一个可结合的线程能够被其他线程收回和杀死。在被其他线程回收之前 , 它的内存资源(例如栈)是不释放的。相反,一个分离的线程是不能被其他线程回收或杀死的。它的内存资源在它终 止时由系统自动释放。\n默认情况下 ,线 程被创建成可结合的。为了避免内存泄漏, 每个可结合线程都应该要\n么被其 他线程显式 地收回, 要么通过调用 p t h r e a d_d e t a c h 函数被分离。\n#include \u0026lt;pthread.h\u0026gt;\nint pthread_detach(pthread_t tid);\n若成功则返回 0 , 若 出错 则 为 非零。\np t hr e a d_d e t a c h 函数分离可结合线程 t 过。线程能够通过以 p t hr e a d_ s e l f ()为参数的 p t hr e a d _de t a c h 调用来分离它们自己。\n尽管我们的一些例子会使用 可结合 线程,但 是在现实程序中,有 很好的理由要使用分\n离的线程。例如, 一个高性能 W eb 服务器可能在每次收到 W e b 浏览器的连接请求 时都创建一个新的对等线程。因为每个连接都是由一个单独的线程独立处理的,所以对千服务器 而言,就很没有必要(实际上也不愿意)显式地等待每个对等线程终止。在这种情况下,每 个对等线程都应该在它开始处理请求之前分离它自身,这样就能在它终止后回收它的内存 资源了。\n12. 3. 7 初始化线程\np t hr e a d_onc e 函数允许你初始化与线程例程相关的 状态。\n#include \u0026lt;pthread.h\u0026gt;\npthread_once_t once_control = PTHREAD_ONCE_INIT; int pthread_once(pthread_once_t *once_control,\nvoid (*init_routine)(void));\n总是返回o.\no n c e _c o n tr o l 变量是一 个全局或者静态变量 ,总 是被初始化为 PT H READ_ONCE_ I NIT 。当你第一次用参数 on c e _ c o n tr o l 调用 p t hr e a d _ o nce 时, 它调用 m江 r ou­\ntine, 这是一个没有输入参数 、也不返回什么的函数。接下来的以 o nc e _c on t r o l 五参数的 p t hr e a d _o nc e 调用不做任何事情。无论何时, 当你需要动态初始化多个线程共享的全局变量时, p t hr e a d _ o n c e 函数是很有用的。我们将在 1 2. 5. 5 节里看到一个示例。\n12. 3. 8 基千线程的并发服务器\n图 12-14 展示了基于线程的并发 echo 服务器的代码。整体结构类似于基于进程的设计。主线程不断地等待连接请求,然后创建一个对等线程处理该请求。虽然代码看似简\n单, 但是有几个普遍而且有些 微妙的问题需要我们更 仔细地看一 看。第一个问题是当我们调用 p t hr e a d _ cr e a t e 时, 如何将已连接描述符传递给对等线 程。最明 显的方法就是传递一个指向这个描述符的指针,就像下面这样\nconnfd = Accept(listenfd, (SA*) \u0026amp;clientaddr, \u0026amp;clientlen); Pthread_create(\u0026amp;tid, NULL, thread, \u0026amp;connfd);\n然后,我们让对等线程间接引用这个指针,并将它赋值给一个局部变量,如下所示\nvoid *thread(void *vargp) {\nint connfd = *((int *)vargp);\ncode/co吹n chosevrert.c\nclientlen=sizeof (struct sockaddr_storage);\nconnfdp = Malloc(sizeof(int));\n• connf dp = Accept(listenfd, (SA•) \u0026amp;clientaddr, \u0026amp;clientlen);\nPthread_create(\u0026amp;tid, NULL, thread, connfdp); 24 }\n25 }\n26\nI• Thread routine•I\nvoid•thread(void•vargp)\n29 {\nint connf d = * ((int *)vargp) ;\nPthread_detach(pthread_self ());\nFree(vargp);\necho(connfd) ;\nClose(connfd);\nreturn NULL;\n36 }\ncode/condechoservert.c\n图 12-1 4 基于线程的 并发 echo 服务器\n然而, 这样可能会出错, 因为它在对 等线程的赋值语句和主线程的 a c c e p t 语句间引入了竞争 ( race ) 。如果赋值语句在下一个 a c ce p t 之前完成, 那么对等线程中的局部变量c o nn f d就得到正确的描述符值。然而, 如果赋值语句是在 a c c e p t 之后才完成的, 那么对等线程中的 局部变量 c o n nf d 就得到下一次连接的描述符值。那么不幸的结果就是, 现在两个线程在同一个描述符上执行输入和输出。为了避免这种潜在的致命竞争,我们必须将 a c ce p七返回的每个已连接描述符分配到它自己的动态分配的内存块, 如第 20 21 行所示。我们 会在 12. 7. 4 节中回过来讨论竞争的问题。\n另一个问题是在线程例程中避免内存泄漏。既然不显式地收回线程, 就必须分离每个线程,使 得在它终止时它的内存资源能够被收回(第31 行)。更进一步, 我们必须小心释放主线程分配的内存块(第32 行)。\n沁氐 练习题 12. 5 在图 12- 5 中基 于进 程的服务器中, 我们 在两个位置小心 地关 闭 了 已连接描述 符: 父进 程和子进程。 然而,在 图 1 2-14 中基 于线程 的服务器中, 我们只在 一个位置关闭了巳连接描述符:对等线程。为什么?\n12. 4 多线程程序中的共享变量\n从程序员的角度来看,线程很有吸引力的一个方面是多个线程很容易共享相同的程序 变量。然而, 这种共享也是很棘手的。为了编写正确的多线程程序, 我们 必须对所谓的 共享以及它是如何工作的有很清楚的了解。\n为了理解 C 程序中的一个变量是否是共享的, 有一些基本的问题要解答: 1) 线程的基础内存模型是什么? 2 ) 根据这个 模型, 变量实例是如何映 射到内存的? 3 ) 最后, 有多少线程引用这些实例?一个变量是共享的,当且仅当多个线程引用这个变董的某个实例。\n为了让 我们对共享的讨论具 体化, 我们将使用图 12-15 中的程序作为运行示例。尽管有些人为的痕迹, 但是它仍然值得研究, 因为它说明 了关于共享的许多细微之处。示例程序由一个创建了两个对等线程的主线程组成。主线程传递一个唯一的 ID 给每个 对等线程, 每个对等线程利用这个 ID 输出一条个性化的信息, 以及调用该线程例程的总次数。\n12 . 4. 1 线程内存模型\n一组并发线程运行在一个进程的上下文中。每个线程都有它自己独立的线程上下文, 包括线程 ID、栈、栈指针、程序计数器、条件码和通用目的寄存器值。每个线程和其他线程一起共享进程上下文的剩余部分。这包括整个用户虚拟地址空间,它是由只读文本\n(代码)、读/写数据、堆以及所有的共享库代码和数据区域组成的。线程也共享相同的打开文件的集合。\n从实际操作的角度来说 ,让 一个线程去读或写另一个线程的寄存器值是不可能的。另一方面, 任何线程都可以访问共享虚拟内存的任意位置。如果某个线程修改 了一个内存位置, 那么其他每个线程最终都能 在它读这个位 置时发现这个变化。因此,寄 存器是从不共享的, 而虚拟内存总是共享的 。\n各自独立的线程栈的内存模型不是那么整齐清楚的。这些栈被保存在虚拟地址空间的 栈区域中, 并且通常是被相应的线程独立 地访问 的。我们说通常而不是总是, 是因为不同的线程栈是不对其他线程设防的 。所以, 如果一个线程以某种方式得到一个指向其他线程栈的指针, 那么它就可以 读写这个栈的任何部分。示例程序在第 26 行 展示了这一点, 其中对等线程直接通过全局变量 p tr 间接引用主线程的栈的内容。\ncode/condsharing.c\n#include \u0026ldquo;csapp.h\u0026rdquo;\n#define N 2 void *thread(void *vargp); 5 char **ptr; I* Global variable *I\n6\n7 int main()\n8 {\nint i;\npthread_t tid;\nchar *msgs [NJ = {\n\u0026ldquo;Hello from foo\u0026rdquo;,\n\u0026ldquo;Hello from bar\u0026rdquo;\n14 };\n15\nptr = msgs;\nfor (i = O; i \u0026lt; N; i++)\nPthread_create(\u0026amp;tid, NULL, thread, (void *)i);\nPthread_exit (NULL) ; 20 }\n21\n22 void *thread(void *vargp)\n23 {\nint myid = (int)vargp;\nstatic int cnt = 0;\nprintf(\u0026quot; [%d]: %s (cnt=%d)\\n\u0026quot;, myid, ptr[myid], ++cnt);\nreturn NULL;\n28 }\ncode/condsharing.c\n图 12-1:i 说明共享不同方面的示例程序\n4. 2 将变星映射到内存\n多线程的 C 程序中变量根据它们的存储类 型被映射到虚拟内存:\n全局 变量。 全局变量是定义在函数之外的变量。在运行时, 虚拟内存的读/写区域只包含每个 全局变量的一 个实例, 任何线程都可以引用。例如, 第 5 行声明的全局变量 p tr 在虚拟内存的读/写区域中有一个运行时实例。当一个变量只有一个实例时, 我们只用 变量名(在这里就是 p tr ) 来表示这个实例。 本地自动 变量。 本地自动变量就是定义在函数内部但是没有 s 七a t i c 属性的变量。在运行时,每个线程的栈都包含它自己的所有本地自动变量的实例。即使多个线程 执行同一个线程例程时也是如此。例如, 有一个本地变量 t i d 的实例,它 保存在主线程的栈中。我们用 巨 d . m 来表示这个实例。再来看一个例子, 本地变量 my 过 有两个实例 , 一个在对等线程 0 的栈内, 另一个在对等线程 1 的栈内。我们将这两个实例分别表示为 my i d . p O 和 my i d . p l 。 本地静 态变量。 本地静态变量是定义在函数内部并有 s t a t i c 属性的变量。和全局变量一样, 虚拟内存的读/写区域只包含在程序中声明的每个本地静态变量的一个实例。例如, 即使示例程序中的每个对等线程都 在第 25 行声明了 c n t , 在运行时, 虚拟内存的读/写区域中也只有一个 c n t 的实例。每个对等线程都读和写这个实例。 12. 4. 3 ,.±f:. 吉示亘 立旦\n我们说一个变量 v 是共享的 ,当 且 仅 当它的一个实例被一个以上的线程引用。例如, 示例程序中的变量 c n t 就是共享的,因 为它只有一个运行时实例,并 且这个实例被两个对等线程引用。在另一方面, my 过 不 是 共 享 的 , 因 为它的两个实例中每一个都只被一个线程引用。然而,认 识 到像 ms g s 这样的本地自动变量也能被共享是很重要的。\n饬 练习题 12. 6\n利用 12. 4 节 中的分析, 为 图 12-1 5 中的 示 例 程 序在下 表的每 个条目中填写 “ 是“ 或者“ 否"。在第 一列 中, 符号 v. t 表 示 变 量 v 的 一个实例 , 它 驻 留在线程 t 的本地栈中, 其中 t 要 么是 m( 主 线程),要么是 p O( 对等线程 0 ) 或者 p l ( 对等 线程 1 ) 。 变量实例 ptr cnt i.m msgs.m myid.po myi d . p l 主线程引用的? 对等线程0引用的? 对等线程1引用的? 根据 A 部分的分析, 变 量 p tr 、 c n t 、 1 、 ms g s 和 my 过 哪 些是 共享的? 5 用信号量同步线程 # 共享变量是十分方便,但 是 它 们也引 入了同 步错 误 ( s ynch ro nization er ro r ) 的可能性。考\n虑图 12-16 中的程序 b a d c n t . c , 它创建了两个线程, 每个线程都对共享计数变量 c nt 加 1。\ncod e/conclb ad cnt.c\nI* WARNING : This code is buggy! *I\n#include \u0026ldquo;csapp.h\u0026rdquo;\n3\n4 void *thread(void *vargp); I* Thread routine prototype *I\n5\nI* Global shared variable *I\nvolatile long cnt = 0; I* Counter *I\n8\n9 int main(int argc, char **argv)\n10 {\nlong niters;\npthread_t tid1, tid2;\n13\nI* Check input argument *I\nif (argc != 2) {\nprintf(\u0026ldquo;usage: %s \u0026lt;niters\u0026gt;\\n\u0026rdquo;, argv[O]);\nexit(O);\n18 }\n19 niters = atoi(argv [1]);\n20\nI* Create threads and wait for them to finish *I\nPthread_create(\u0026amp;tid1, 皿 L, thread, \u0026amp;niters);\n图12- 16 ba dc nt . c, 一个同步不正确的计数器程序\n23 Pthread_create(\u0026amp;tid2, 皿 L, thread, \u0026amp;niters); 24 Pthread_join(tid1, NULL); 25 Pthread_join (tid2, NULL) ; 26 27 I* Check result *I 28 if Cent != (2 * niters)) 29 printf(\u0026ldquo;BODM! cnt=%ld\\n\u0026rdquo;, cnt); 30 else 31 printf (\u0026ldquo;DK cnt=%ld\\n\u0026rdquo;, cnt); 32 exit(O); 33 }\n34\nI* Thread routine *I\nvoid *thread(void *vargp) 37 {\n38 long i, niters = *((long *)vargp); 39\nfor (i = O; i \u0026lt; niters; i++)\ncnt++;\n42\n43 return NULL;\n44 }\ncodelcondbadcnt.c\n图 12-16 (续)\n因为每个线 程都对计数器增加了 n i t er s 次, 我们预计它的最终值是 2 X n i t er s 。这看上去简单 而直接 。然而, 当在 L in u x 系统上运行 b a d c n t . c 时,我 们不仅得到错误的答案,而且每次得到的答案都还不相同!\nlinux\u0026gt; ./badcnt 1000000 BOOM! cnt=1445085\nlinux\u0026gt; ./badcnt 1000000 BOOM! cnt=1915220\nlinux\u0026gt; ./badcnt 1000000 BOOM! cnt=1404746\n那么哪里出错 了呢?为了清晰地理解 这个问题, 我们需要研究计数器循环(第40 41 行)的汇编代码, 如图 1 2- 1 7 所示。我们发现, 将线程 1 的循环代码分解成 五个部分是很有帮助的:\nH , : 在循环头部的指令块。\nL,: 加载共享变量 c n t 到累加寄存器%r d x , 的 指令, 这里%r d x , 表示线程 1 中的寄存器%r d x 的 值。\nU, : 更新(增加) %r d x, 的指令。\ns,: 将%r d x ; 的 更新值存回到共享变量 c n t 的指令。\nT;: 循环尾部的指令块。\n注意头和尾只操作本地栈变量 , 而 L, 、U,和 S ,操作共享计数器变量的内容。\n当 b a d c n t . c 中的两个对等线程在一 个单处理器上并发运行时, 机器指 令以某种顺序一个接一个地完成。因此,每个并发执行定义了两个线程中的指令的某种全序(或者交 叉)。不幸的是,这些顺序中的一些将会产生正确结果,但是其他的则不会。\n线程的汇编代码\n线程 的C代码\nf o r (i =O ; i \u0026lt; ni 七 e r s ; i++) 1\n圈畛\ncnt++;\nH,: 头\nL, : 加载c nt\nu,: 更新c nt\nS;: 存储c nt\nT, : 尾\n图 1 2-1 7 ba dc nt . c 中计数器循环(第40~ 41 行)的汇编代码\n这里有个关键点:一般而言,你没有办法预剧操作系统是否将为你的线程选择一个正 确的顺序。例如,图 1 2-1 8 a 展 示了一个正确的指令顺序的分步操作。在每个线程更新了共享变量 e n 七之 后 ,它 在 内 存 中 的 值 就 是 2 , 这正是期望的值。\n另一方面,图 1 2-1 8 b 的 顺 序产生一个不正确的 c n t 的值。会发生这样的问题是因为, 线 程 2 在 第 5 步加载 c n t , 是在第 2 步线程 1 加载 c n t 之后, 而在第 6 步线程 1 存储它的更新值之前。因此, 每个线程最终都会存储一个值为 1 的更新后的计数器值。我们能够借 助千一种叫做进度图 ( p ro g r es s g ra p h ) 的 方法来阐明这些正确的和不正确的指令顺序的概念, 这个图我们将在下一节中介绍。\na ) 正确的顺序 b ) 不正确的顺序\n图 1 2-18 badc nt . c 中第一次循环迭代的指令顺 序\n凶 练习题 12 . 7 根据 b a d c n t . c 的指令顺序 完成 下表:\n。\n这种顺 序会产 生 一个正确的 c n t 值吗?\n12 . 5 . 1 进度图\n进度图( pro g res s g ra ph ) 将 n 个并发线程的执行模型化为一条 n 维笛卡儿空间中的轨迹线。每条轴 k 对应于线程 k 的进度。每个点 ( Ii , lz , …, J\u0026quot; ) 代表线程 k ( k = l , … , n )已经完成 了指令 Ik这一状态。图的原点对应于没有任何线程完成一 条指令 的初始状态。\n图 1 2-19 展示了 b a d c n t . c 程序第一 次循环 迭代的二维进度图 。水平轴对应于线程 1,\n垂直轴对应于线程 2。点 CL 1, S 2) 对应于线程 1 完成了 L1 而线程 2 完成了 S2的状态。\n进度图将 指令执行模型化 为从一种状态到另一种状态的转换 ( t ra ns it io n ) 。转换 被表示为一条从一点到相邻点的有向边。合法的 转换是向右(线程 1 中的一条指令完成)或者向上\n(线程 2 中的一条指令完成)的。两条指令不能在同一时刻完成一 对角线转换是不允许的。程序决不会反向运行,所以向下或者向左移动的转换也是不合法的。\n一个程序的执行历史被模型化为状态空间 中的一条轨迹线。图 12-20 展示了下面指令顺序对应的轨迹线:\nH1, L1, U1, H2, L2, S1, T1 , U2 , S 2 , T 2 # 线程2 线程2\n(L.,, S2)\nS2 S2 # U2 Ui\nL2 L2 # H2\nH , L1 U1 S1 Tl\n线程l\nHi\nH, L, U,\nS, T,\n线程l\n图 12- 1 9\nbadcnt . c 第一次循环迭代的进度图\n图 12-20\n一个轨迹线示例\n对于线程 i\u0026rsquo; 操作共享变量 c n t 内容的指令( L; , U;, S; ) 构成了一个(关于共享变量\nc让 的)临界区 ( crit ica l section), 这个临界区不应该和其他进程的临界区交替执行。换句话说,我们想要确保每个线程在执行它的临界区中的指令时,拥有对共享变量的互斥的访问 ( m ut uall y exclusive access ) 。通常这种现象称为互斥 ( m ut ua l e xcl us io n ) 。\n在进度图 中, 两个临界区的交集形成的状态空间区域称为不安 全区 ( unsafe regio n ) 。图 12-21 展示了变量 c吐 的不安全区。注意 , 不安全区和与它交界的状态相毗邻, 但并不包括这些状态。例如, 状态CH 1 , H z) 和CS 1 , Uz) 毗邻不安全区, 但是它们并不是不安全区的一部分。绕开不安全 区的轨迹线叫做安全轨迹线( sa fe t ra jector y) 。相反, 接触到任何 不安全区的轨迹线就叫做不 安全轨迹线 ( unsa fe t ra jecto r y ) 。图 12- 21 给出了示例程序ba d e n七 . c 的 状态空间中的安全和不安全轨迹线。上面的 轨迹线绕开了不安全区域的左边和上边,所 以是安全的。下面的轨 迹线穿 越不安全区, 因此是不安全的 。\n任何安全轨迹线都将正确地更新共享计数器。为了保证线程化程序示例的正确执行(实 际上任何共享全局数据结构的并发程序的正确执行)我们必须以某种方式同步线程,使它们 总是有一条安全轨迹线。一个经典的方法是基于信号量的思想, 接下来我们就介绍它。\n饬 练习题 12 . 8 使用 图 1 2-21 中的 进度 图, 将下列 轨迹 线划分为 安全的 或者 不 安全 的。\nA. H1, L1, U1, S1, H2 , L 2 , U2 , S 2 , T 2 , T1 # B. H2 , L 2 , H 1 , L 1 , U1, S1, T1, U2, 5 2 , T 2 C. H 1 , H2, L 2 , U2 , S2 , L1, 线程2 U1, S1, T1, T2 T,\ns,\n写 c nt 的 j u,\nL2 # HJ\nH1 L1 亿 SI T, 线程1\n写 cnt 的临界区\n图 12-21 安全和不安全轨迹线。临界区的交集形成了不安全区。绕开不安全区的轨迹线能够正确更新计数器变量\n5. 2 信号量\nEdsger Dijkstra, 并发编程领域的先锋人物,提出了一种经典的解决同步不同执行线程问题的方法, 这种方法是基千一种叫做信号 量( s em a p ho re ) 的特殊类型变量的。信号 量 s 是具有非负整数值的全局变量,只 能由两种特殊的操作来处理,这两 种操作称为 P 和 V :\nPCs): 如果 s 是非零的, 那么 P 将 s 减 1, 并且立即返回。如果 s 为零, 那么就挂起这个线程, 直到 s 变为非 零,而 一个 V 操作会重启 这个线程。在重启之后, P 操作将 s 减 1, 并将控制返回给调用者。\nV(s): V操作将 s 加 1 。如果有任何线程阻 塞在 P 操作等待 s 变成非零, 那么 V 操作会重启这些线程中的一个 , 然后该线程将 s 减 1, 完成它的 P 操作。\nP 中的测试和减 1 操作是不可分割的,也 就是说, 一旦预测信号量 s 变为非 零, 就会将 s 减 1, 不能有中断。V 中的加 1 操作也是不可分割的,也 就是加载、加 1 和存储信号量的过程中没有中断。注意 , V 的定义中没有定义等待线程被重启动的顺序。唯一的要求是 V 必须只能重启一个正在等待的线 程。 因此 , 当有多个线程 在等待 同一个信号量时 ,你不能预 测 V 操作要重启哪 一个线程。\nP 和 V 的定义确保了一个正在运行的程序绝不 可能进入这样一种状态 ,也 就是一个正确初始化了 的信号量有一个负值。这个属性称为信号量不 变性( se m a pho re invariant), 为控制并发程序的轨迹线提供了强有力的工具,在下一节中我们将看到。\nP os ix 标准定义了许多操作信号量的函数。\n#include \u0026lt;semaphore.h\u0026gt;\nint sem_init(sem_t•sem, 0, unsigned int value); int sem_wait(sem_t•s); /• P(s)•I\nint sem_post(sem_t•s); I• V(s)•I\n返回: 若 成 功 则为 0 , 若 出错 则为 一1.\ns e m_ i n i t 函数 将 信 号 量 s e m 初 始 化 为 v a l u e 。每个信号最在使用前必须初始化。针对我们的目的,中 间 的 参 数 总 是 零 。 程 序分别通过调用 s e m_ wa 江 和 s e m_ p o 江 函 数 来执行 P 和 V 操作。为了简明,我 们更喜欢使用下面这些等价的 P 和 V 的包装函数:\n#include \u0026ldquo;csapp. h\u0026rdquo;\nvoid P(sem_t *s); I* Wrapper function for sem_wa i t *I void V(sem_t *s ) ; I* Wr apper f un ct i o n for s e m_pos t *I\n返 回 : 无 。\n匮目P 和 V 名字的起源\nE dsge r Dijk s t ra0 930 —20 0 2 ) 出生于荷 兰。名 字 P 和 V 来源 于荷 兰语 单词 P ro ber en\n(测试)和V e r h og e n ( 增加)。\n5. 3 使用信号量来实现互斥\n信号量提供了一种很方便的方法来确保对共享变量的互斥访问。基本思想是将每个共 享变量(或者一组相关的共享变量)与一个信号量 s ( 初始为 1 ) 联系起来, 然后用 P Cs ) 和 V Cs ) 操 作 将相应的临界区包围起来。\n以这种方式来保护共享变量的信号量叫做二元信号量 ( b in a r y s e m a p ho r e ) , 因为它的值总是 0 或者 1 。以提供互斥为目的的二元信号量常常也称为互 斥 锁 ( m ut ex ) 。在一个互斥锁上执行 P 操作称为对互斥锁加锁。类 似地, 执 行 V 操 作 称 为对互斥锁 解锁。对一个互斥锁加了锁但是还没有解锁的线程称为占 用这 个互斥锁。一个被用作一组可用资源的计数器的信号量被称为计数信号量。\n图 1 2- 22 中的进度图展示了我们如何利用二元信号量来正确地同步计数器程序示例。每个状态都标出了该状态中信号量 s 的值。关键思想是这种 P 和 V 操作的结合创建了一组\n线程2\n.0 .0\nT,\n.0 .0\n.0 .0 .I .I\n.0 .0 .I\nV(s) 。\n一1\nS, :\n禁止区 。\n·· ··-··I···········-··I····,·- ·I ·\u0026rsquo;•···\n。 。 : - I\n卜·:\n.- I\n- I - I i\n;.\nu, 。 。\nL 2 0 。\n不安全区\n1- 1 - I - I - I .\n;·一··I············-···I··············-·1······-··1··!·•\u0026rsquo;· 。\nP (s )\n0 .0 .0 0\nCTfil # ,1-f\n0 0 0\n线 程1\nH , P (s ) L1 U1 SI V(s) T1\n图 12-22 使用信号量来互斥。s\u0026lt; O 的不可行状态定义了一个禁 止区, 禁止区完全包括了不安全区, 阻止了实际可行的轨迹线接触到不安全区\n状态, 叫做禁止 区( fo r b idde n region) , 其中 s\u0026lt; O。 因为信号量的不变性 , 没有实际可行的轨迹线能够包含禁止区中的状态。而且, 因为禁止区完全包括了不 安全区, 所以没有实际可行的轨迹线能够接触不安全区的任何部分。因此,每条实际可行的轨迹线都是安全的, 而且不管运行时指令顺序是怎样的,程序都会正确地增加计数器值。\n从可操作的意义上来说, 由 P 和 V 操作创建的禁止区使得在任何时间点上, 在被包围的临 界区中, 不可能有多个线程在执行 指令。换句话说,信 号量操作确保了对临界区的互斥访问 。\n总的来说, 为了用信号量正确同步图 1 2-1 6 中的计数器程序示例,我 们首先声 明一个信号量 mu 七e x :\nvolatile long cnt = O; I* Counter *I\nsem_t mutex; I* Semaphore that protects counter *I\n然后在主例程中将 mu t e x 初始化 为 1 :\nSem_init(\u0026amp;mutex, 0, 1); I* mutex = 1 *I\n最后, 我们通 过把在线程例程中对共享变僵 c n t 的更新包围 P 和 V 操作, 从而保护它们:\nfor (i = O; i \u0026lt; niters; i++) { P(\u0026amp;mutex);\ncnt++;\nV(\u0026amp;mutex);\n}\n当我们运行这个正确同步的程序时,现在它每次都能产生正确的结果了。\nlinux\u0026gt; ./goodcnt 1000000 OK cnt=2000000\nlinux\u0026gt; ./goodcnt 1000000 OK cnt=2000000\nm 进度图的 局限性\n进度图给了我们一种较好的方法,将在单处理器上的并发程序执行可视化,也帮助 我们理解为什么需要同步。然而,它们确实也有局限性,特别是对于在多处理器上的并 发执行,在 多处 理器上一 组 CPU/ 高速缓 存对共享同一 个主 存。多 处理 器的 工作方式是进度图不能解释的 。特别是 ,一 个多处理 器内存 系统可以 处于一 种状态,不 对应 于进度图中任何轨迹线。不 管如何 , 结论总是一样的 : 无论是 在单处理 器还是 多处 理器上运行程序, 都要 同步你 对共享 变量的 访问。\n5. 4 利用信号量来调度共享资源\n除了提供互斥之外,信 号量的另一个重要作用是调度对共享资源的访问。在这种场景中, 一个线程用信号量操作来通知另一个线程, 程序状 态中的某个条件已经为真了。两个经典而有用的例子是生产者-消费者和读者-写者问 题。\n1 生产者-消费者问题\n图 1 2- 23 给出了生产者-消费者问 题。生产者 和消费者线程共享一个有 n 个槽的有限缓 冲区。生产者线程反复地生成新的项目 ( item ) , 并把它们插入到缓冲区中。消费者线程不断地\n从缓冲区中取出这些项目,然后消费(使用)它们。也可能有多个生产者和消费者的变种。\n图 12- 23 生产者-消费者问题。生产者产生项目并把它们插入到一个有限的缓冲区中。消费者从缓冲区中取出这些项目,然后消费它们\n因为插入和取出项目都涉及更新共享变量,所以我们必须保证对缓冲区的访问是互斥的。但是只保证互斥访问是不够的,我们还需要调度对缓冲区的访问。如果缓冲区是满的\n(没有空的槽位),那么生产者必须等待直到有一个槽位变为可用。与之相似,如果缓冲区 是空的(没有可取用的项目),那么消费者必须等待直到有一个项目变为可用。\n生产者-消费者的相互作用在现实系统中是很普遍的。例如,在一个多媒体系统中, 生产者编码视频帧,而消费者解码并在屏幕上呈现出来。缓冲区的目的是为了减少视频流的抖动,而这种抖动是由各个帧的编码和解码时与数据相关的差异引起的。缓冲区为生产者提供了一个槽位池,而为 消费者提供一个已编码的帧池。另一个常见的示例是图形用户接口设计。生产者检测到鼠 标和键盘事件, 并将它们插入到缓 冲区中。消费者以某种基于优先级的方式从缓冲区取出这些事件,并显示在屏幕上。\n在本节 中, 我们 将开发一个简单的包, 叫做 SBUF , 用来构造生产者-消费者程序。在下一节里 , 我们会看到如何 用它来构造一个基千预 线程化( pr et h rea d in g ) 的有趣的并发服务器。SBUF 操作类型为 s b u f _ t 的有限缓冲区(图1 2- 24 ) 。项目存放在一个动态分配的\nn 项整数数组 ( b u f ) 中。fr o n t 和r e ar 索引值记录该数组中的 第一项 和最后一项。三个信号量同步对缓冲区的 访问。mu t e x 信号量提供互斥的缓冲区访问 。s l o t s 和 i t e ms 信号 量分别记录空槽位和可用项目 的数量。\ntypedef struct { int *buf;\nI* Buffer array *I\ncode/condsbuf h\nint n;\nint front; int rear; sem_t mutex; sem_t slots; sem_t items;\n} sbuf_t;\nI* Maximum number of slots *I\nI* buf[(front+1)%n] is first item *I I* buf[rear%n] is last item *I\nI* Protects accesses to buf *I I* Counts available slots *I I* Counts available items *I\ncode/condsbuf.h\n图 l 2-24 sbuf_t: SBUF 包使用的有限缓 冲区\n图 1 2- 25 给出 了 SBU F 函数的实现。 s b u f _ i n i t 函数为缓 冲区 分配堆内存,设 置f r o n 七和 r e ar 表示一个空的缓冲区 , 并为三个 信号量赋初始值。这个函数在调用其他三个函数中的任何一个之前调用一次。s b u f _ d e i n 江 函数是当应用程序使用完缓冲区时 , 释放缓冲区存储的 。s b u f _ i n s er t 函数等待一个可用的槽 位, 对互斥锁加锁, 添加项目, 对互斥锁解锁, 然后宣布有一个新项目可用。s b u f _r e mo v e 函数是与 s b u f _ i n s er t 函数对称的。在等待一个可用的缓冲区项目之后,对互斥锁加锁,从缓冲区的前面取出该项目, 对互斥锁解锁, 然后发信号通知一个新的槽位可供使用 。\ncode/condsbu肛\n#include II csapp. h11\n#include 11sbuf .h11\n3\nI* Create an empty, bounded, shared FIFO buffer with n slots *I\nvoid sbuf_init(sbuf_t *sp, int n)\n6 {\nsp-\u0026gt;buf = Calloc(n, sizeof(int));\nsp-\u0026gt;n = n; I* Buffer holds max of n items *I\nsp-\u0026gt;front = sp-\u0026gt;rear = 0; I* Empty buffer iff front == rear *I 1O Sem_init (\u0026amp;sp-\u0026gt;mutex, 0, 1); I* Binary semaphore for locking *I\nSem_init(\u0026amp;sp-\u0026gt;slots, 0, n); I* Initially, buf has n empty slots *I\nSem_init(\u0026amp;sp-\u0026gt;items, O, 0); I* Initially, bufhas zero data items *I\n13 }\n14\nI* Clean up buffer sp *I\nvoid sbuf_deinit(sbuf_t *Sp)\n17 {\n18 Free(sp-\u0026gt;buf);\n19 }\n20\nI* Insert item onto the rear of shared buffer sp *I\nvoid sbuf_insert(sbuf_t *Sp, int item) 23 {\nP (\u0026amp;sp-\u0026gt;slots); I* Wait for available slot *I\nP (\u0026amp;sp-\u0026gt;mutex); I* Lock the buffer *I\nsp-\u0026gt;buf [ (++s p- \u0026gt;r ea 工)%(sp-\u0026gt;n)] = item; I* Insert the item *I\nV(\u0026amp;sp-\u0026gt;mutex); I* Unlock the buffer *I\nV(\u0026amp;sp-\u0026gt;items); I* Announce available item *I\n29 }\n30\nI* Remove and return the first item from buffer sp *I\nint sbuf_remove(sbuf_t *Sp)\n33 {\nint item·\nP(\u0026amp;sp-\u0026gt;items); I* Wait for available item *I\nP(\u0026amp;sp-\u0026gt;mutex); I* Lock the buffer *I\nitem= sp-\u0026gt;buf[(++sp-\u0026gt;front)%(sp-\u0026gt;n)]; I* Remove the item *I\nV(\u0026amp;sp-\u0026gt;mutex); I* Unlock the buffer *I\nV(\u0026amp;sp-\u0026gt;slots); I* Announce available slot *I\nreturn item;\n41 }\ncode/condsbu肛\n图 12- 25 SBUF: 同步对有限缓冲区并发访问的包\n练习题 12. 9 设 p 表 示 生产 者数 量, c 表 示 消费者数量, 而 n 表 示 以项目单元 为单 位\n的缓冲 区大小。对于下 面的每个场景 , 指 出 s b u f _ i n s er t 和 s b u f _ r e mo v e 中的互斥锁信号量是否是必需的。\np = I , c = 1 , n \u0026gt; l p = I , c = 1 , n = I p\u0026gt; I, c\u0026gt;l, n=I # 2 读者-者写问题\n读者-者写问题是互斥问题的一个概括。一组并发的线程要访问一个共享对象, 例如\n一个主存中的数据结构,或 者一个磁盘上的数据库。有些线程只读对象, 而其 他 的 线 程只修改对象。修改对象的线程叫做写者。只读对象的线程叫做读者。写者必须拥有对对象的 独占的访问,而读者可以和无限多个其他的读者共享对象。一般来说,有无限多个并发的 读者和写者。\n读者-写者交互在现实系统中很常见。例如,一个在线航空预定系统中,允许有 无限多个客户同时查看座位分配,但是正在预订座位的客户必须拥有对数据库的独占 的访问。再来看另一个例子, 在 一个多线程缓 存 Web 代理中, 无 限 多 个 线 程可以从共享页面缓存中取出已有的页面,但是任何向缓存中写入一个新页面的线程必须拥有 独占的访问。\n读者-写者问题有几个变种,分别基于读者和写者的优先级。第一类读者-写者问题,读者优先,要求不要让读者等待,除非已经\n把使用对象的权限赋予了一个写者。换旬\n话说,读者不会因为有一个写者在等待而等待。第二类读者-写者问题,写者优先, 要求一旦一个写者准备好可以写,它就会尽可能快地完成它的写操作。同第一类问题不同,在一个写者后到达的读者必须等待,即使这个写者也是在等待。\n图 12-26 给出了一个对第一类读者- 写者问题的解答。同许多同步问题的解 答一样,这个解答很微妙,极具欺骗性 地简单。信号量 w 控制对访问共享对象 的临界区的访问。信号量 mu t e x 保 护 对共享变量 r e a d c n t 的访问,r e a d c n t 统计当前在临界区中的读者数量。每当一 个写者进入临界区时, 它 对 互 斥锁 w 加锁 , 每当它离 开 临 界 区 时, 对 w 解 锁 。这就保证了任意时刻临界区中最多只有 一个写者。另一方面,只有第一个进入 临界区的读者对 w 加 锁 , 而 只 有 最 后 一个 离 开临界区的读 者对 w 解 锁 。 当 一个读者进入和离开临界区时,如果还有其 他读者在临界区中,那么这个读者会忽 略互斥锁 w。这 就意味着只要还有一个读者占用互斥锁 w, 无限多数量的读者可以没有障碍地进入临界区。\n对这两种读者-写者问题的正确解答可能导致饥饿 ( s ta r va t io n ) , 饥饿就是一\nI* Global va工ia bl e s *I\nint readcnt; I* Initially= 0 *I\nsem_t mutex, w; I* Both initially= 1 *I\nvoid reader(void)\n{\nwhile (1) {\nP(\u0026amp;mutex); readcnt++;\nif (readcnt == 1) I* First in *I\nP(\u0026amp;w); V(\u0026amp;mutex);\nI* Critical section *I I* Reading happens *I\nP(\u0026amp;mutex); readcnt\u0026ndash;;\nif (readcnt == 0) I* Last out *I\nV(\u0026amp;w); V(\u0026amp;mutex);\nvoid writer(void)\n{\nwhile (1) {\nP(\u0026amp;w);\nI* Critical section *I I* Writing happens *I\nV(\u0026amp;w);\n个线程无限期地阻塞,无法进展。例如,\n图 12-26 所示的解答中,如 果 有 读 者 不 断地到达,写者就可能无限期地等待。\n图 12-26 对第一类读者-写者问题的解答。读者优先级高于写者\n练习题 12. 10 图 12- 26 所 示的 对 第 一 类 读者-写者问题的 解答给 予 读 者 较 高 的 优先级,但是从某种意义上说,这种优先级是很弱的,因为一个离开临界区的写者可能重 启一个在等待的写者,而不是一个在等待的读者。描述出一个场景,其中这种弱优先 级会导致一群写者使得一个读者饥饿。\n应I _其他同步机 制\n我们已经向你展示了如何利用信号量来同步线程,主要是因为它们简单、经典,并且 有一个清晰的语义模型。但是你应该知道还是存在 着其他 同步技 术的。例如 , Ja va 线程是用一种叫做 Java 监控器(J ava Monitor) [ 48] 的机制来同步的 , 它提供了对 信号量 互斥 和调度能力的更高级 别的抽 象; 实际 上,监控 器 可以 用信号量来 实现。再 来看一 个例 子, Pthrea ds 接口定义了一组对互斥锁和条件 变量的 同步 操作。Pthreads 互斥锁 被用 来实现互斥。条件 变量用来调 度对共享资源的访问, 例如在一个生产者-消费者程序中的有限缓冲区。\n12 . 5. 5 综合:基千预线程化的并发服务器\n我们已 经知道了如何使用信号量来访问 共享变量和调度对共享资源的访问。为了帮 助你更清晰地理解这些思想,让 我们把它们应用到一个基千称为预线程化( pret hread ing )技术的并发服务器上。\n在图 1 2-14 所示的并发服务器中, 我们为每一个新客户端创建了一个新线程。这种方法的缺点是我们为每一个新客户端创建一个新线程,导致不小的代价。一个基于预线程化 的服务器试图通过使用如图 1 2-27 所示的生产者-消费者模型来 降低这种开销。服务器是由一个主线程和一组工作者线程构 成的。主线程不断地接受来自客户端的连接请求, 并将得到的连接描述符放 在一个有限缓冲区中。每一个工作 者线程反复 地从共享缓冲区中 取出描述符, 为客户端 服务, 然后等待下一个描述符。\n图 1 2- 27 预线程化的并发服务器的组织结构。一组现有的线程不断地取出\n和处理来自有限缓冲区的已连接描述符\n图 12-28 显示了我们怎样用 SBUF 包来实现一个预线程化的并发 echo 服务器。 在初始化了缓冲区 s b u f ( 第 24 行)后, 主线程创建 了一组工作者线程(第25 ~ 26 行)。然后它进 入了无限的服务器循 环,接 受连接 请求, 并将得到的巳 连接描述符插入到缓冲区 s b uf 中。每个工作者线程的行为都非常简单。它等待直到它能从缓冲区中取出一个已连接描述符\n(第39 行),然后调用 e c ho_c nt 函数回送客户端的输入。\n图 12-29 所示的函数 e c ho_c n t 是图 11-22 中的 e c ho 函数的一个版本, 它在全局变量b yt e _c nt 中记录了从所有客户端接收到的累计字节数。这是一段值得研究的有趣代码, 因为它向你展示了一个从线程例程 调用的初始化 程序包的 一般技术。在这种情况中, 我们\n需要初始化 b y t e _ c n t 计数器和 rnu t e x 信号量。一个方法是 我们为 SBUF 和 R I O 程序包使用过的, 它要求主线程显式地调用一个初始化函数。另外一个方法, 在此显示的, 是当第一次有某个线程调用e c h o _ c n t 函数时, 使 用 p t hr e a d _ o n c e 函数(第 19 行)去调用初始化函数。这个方法的优点是它使程序包的使用更加容易。这种方法的缺点是每一次调用 e c h o _ c n t 都会导致调用 p t hr e a d_ o n c e 函数, 而在大多数时候它没有做什么有用的事。\nco d e/co nd ech os ervert-p re.c\n#include \u0026ldquo;csapp.h\u0026rdquo;\n#include \u0026ldquo;sbuf .h\u0026rdquo; #define NTHREADS 4 #define SBUFSIZE 16 void echo_cnt(int connf d) ; void *thread(void *vargp); 9 sbuf_t sbuf; I* Shared buffer of connected descriptors *I 10\n11 int main(int ar gc, char **argv)\n12 {\nint i , listenfd, connfd;\nsocklen_t cl i ent l en;\nstruct sockaddr_storage clientaddr;\npthread_t tid;\nif (argc != 2) {\nfprintf(stderr, \u0026ldquo;usage: %s \u0026lt;port\u0026gt;\\n\u0026rdquo;, argv[O]);\ne x i t (O) ;\n21\n22 listenfd = Open_listenfd(argv[1]) ;\n23\nsbuf_init(\u0026amp;sbuf, SBUFSI ZE) ;\nfor (i = O; i \u0026lt; NTHREADS; i ++) I* Create worker threads *I 26\u0026rsquo; Pt hr e a d _cr ea t e (\u0026amp;t i d , NULL, thread, NULL);\n27\nwhile (1) {\nclientlen = sizeof (struct sockaddr_s t or ag e ) ;\nconnfd = Accept (listenfd, (SA *) \u0026amp;clientaddr, \u0026amp;clientlen);\nsbuf_insert(\u0026amp;sbuf, connf d) ; I* Insert connfd in buffer *I 32\n33 }\n34\n35 void *thread(void *vargp) 36 {\nPt hr e ad _de t a c h ( pt hr e ad _s e lf O);\nwhile (1) {\nint connfd = sbuf_remove(\u0026amp;sbuf); I* Remove c onn f d from buffer *I\n40\n41\n42\n43 }\necho_cnt(connfd); Cl os e ( conn f d ) ;\nI* Service client *I\ncode/con吹choservert-pre.c\n图12- 28 一个预线程化的并发 echo 服务器。这个 服务器使用的是有一个生产者和多个消费者的生产者-消费者模型\ncode/cone/echo-cnt.c\n#include \u0026ldquo;csapp.h\u0026rdquo;\n3 static int byte_cnt; I* Byte counter *I\n4 static sem_t mutex; I* and the mutex that protects it *I\n6 static void init_echo_cnt(void)\n7 {\n8 Sem_init(\u0026amp;mutex, 0, 1);\n9 byte_cnt = O;\n10 }\n12 void echo_cnt(int connfd)\n13 {\n14 int n·\nchar buf[MAXLINE];\nrio_t rio;\nstatic pthread_once_t once= PTHREAD_ONCE_INIT;\n18\nPthread_once(\u0026amp;once, init_echo_cnt);\nRio_readinitb(\u0026amp;rio, connfd);\nwhile ((n = Rio_readlineb (\u0026amp;rio, buf, MAXLINE)) ! = 0) {\nP (\u0026amp;mutex);\nbyte_cnt += n;\nprintf (11server received %d CJ 儿 d total) bytes on fd %d\\n\u0026quot;,\nn, byte_cnt, connfd);\nV (\u0026amp;mutex) ;\nRio_writen(connfd, buf, n);\n28\n29 }\ncode/conc/echo.-ccnt\n图 12- 29 echo_cnt, echo 的一个版本, 它对从客户端接收的所有字节计数\n一旦 程序包 被初始化, e c h o _ c n t 函 数 会 初始化 RIO 带 缓 冲区的 I/ 0 包(第 20 行),然 后 回 送 从 客 户端接收到的每一个文本行。注意,在 第 23 25 行 中 对共享变量 b yt e _ c n t 的访问是被 P 和 V 操作保护的。\n团 日 基千线程的事件驱动程序\nI/ 0 多路 复 用不 是 编写事件 驱动程序的唯一方 法。 例如 , 你可能已经注意到我们刚才开发的并发的预线程化的服务器实际上是一个事件驱动服务器,带有主线程和工作者 线程的简单状态机。主线程有两种状态(\u0026ldquo;等待连接请求”和”等待可用的缓冲区槽 位\u0026rdquo;)、两个 I/ 0 事件( \u0026ldquo;连接请求到 达” 和 "缓 冲区槽 位 变为 可用\u0026rdquo; )和 两个转换( \u0026ldquo;接受连接请求” 和 “ 插 入 缓 冲区项 目\u0026rdquo; )。 类似 地, 每个工作者线程有一个状 态( \u0026quot; 等待 可用的缓冲项目\u0026quot;)、 一个 I/ 0 事件("缓冲区项 目 变为 可用\u0026quot; )和 一个转换( \u0026ldquo;取出缓 冲区项 目\u0026rdquo;)。\n12. 6 使用线程提高并行性\n到目前为止,在对并发的研究中,我们都假设并发线程是在单处理器系统上执行的。\n然而,大多数现代机器具有多核处理器。并发程序通常在这样的机器上运行得更快,因为 操作系统内核在多个核上并行地调度这些并发线程,而不是在单个核上顺序地调度。在像 繁忙的 Web 服务 器、数据库服务器和大型科学计算代码这样的应用中利用这样的并行性是至关重要的, 而且在像 Web 浏览骈、电子表格处理程序和文档处理程序这样的主流应用中,并行性也变得越来越有用。 所有的程序\n图 12- 30 给出了顺 序、并发和并行程序之间的 口五五示\n集合关系。所有程序的集合能够被划分成不相交\n的顺序程序集合和并发程序的集合。写顺序程序只有一条逻辑流。写并发程序有多条并发流。并行程序是一个运行在多个处理器上的并发程序。因此,并行程序的集合是并发程序集合的真子集。\n并行程序的详细处理超出了本书讲述的范围,\n三 顺序程序\n图 12 -30 顺序、并发和并行程序\n集合之间的关系\n但是研究一个非常简单的示例程序能够帮助你理解并行编程的一些重要的方面。例如,考 虑我们如何并行地对一列整数 o, …, n - l 求和。当然, 对于这个特殊的问 题, 有闭合形式表达式的 解答(译者注: 即有现成的公 式来计算它, 即和等 千 n ( n - 1 ) / 2) , 但是尽管如\n此,它是一个简洁和易于理解的示例,能让我们对并行程序做一些有趣的说明。\n将任务分 配到不同线 程的最 直接方法是将序列划分成 t 个不相交的区域, 然后给 t 个不同的线程每个 分配一个区域。为了简单, 假设 n 是 t 的倍数, 这样每个 区域有 n / t 个元素。让我们来看看多个线程并行处理分配给它们的区域的不同方法。\n最简单也最直接的选择是将线程的和放入一个共享全局变量中,用互斥锁保护这个变 量。图 12-31 给出了我们会如何实 现这种方法。在第 28 ~ 33 行, 主线程创建对等线程 ,然 后等待它们结束。注意 , 主线程传递给每个对等线程一个小整数,作为唯一的线程ID。每个对等线程会用它的线 程 ID 来决定它应该计算序列的哪一部分。这个向对等线程传递一个小的唯一的线程1D 的思想是一项通用技术 , 许多并行应用中都用到了它。在对等线程终止后, 全局变鼠 gs um 包含着最终的和。然后主线程用闭合形式解答来验证结果(第36~ 37 行)。\ncode/condpsum-mutex.c\n#include \u0026ldquo;csapp.h\u0026rdquo;\n2 #define MAXTHREADS 32\n4 void *surn_rnutex(void *vargp); I* Thread routine *I\n6 I* Global sha工ed va 工i abl e s *I\nlong gsurn = O; I* Global sum *I\nlong nelerns_per_thread; I* Number of elements to sum *I\n9 sem_t rnutex; I* Mutex to protect global sum *I\n10\n11 int rnain(int argc, char **argv)\n12 {\nlong i, nelems, log_nelems, nthreads, myid[MAXTHREADS];\npthread_t tid[MAXTHREADS];\n15\n16 I* Get input arguments *I\n图12-31 ps um- mut ex 的主程序, 使用多个线程将 一个序列元素的和放入一个用互斥锁保护的共享全局变扯中\n17 if (argc != 3) { 18 printf(\u0026ldquo;Usage: %s \u0026lt;nthreads\u0026gt; \u0026lt;log_nelems\u0026gt;\\n\u0026rdquo;, argv[O]); 19 exit(O); 20 } 21 nthreads = atoi(argv[1]); 22 log_nelems = atoi(argv[2]); 23 nelems = (1L«log_nelems); 24 nelems_per_thread = nelems / nthreads; 25 sem_init(\u0026amp;mutex, 0, 1); 26 27 I* Create peer threads and wait for them to finish *I 28 for (i = O; i \u0026lt; nthreads; i++) { 29 myid[i] = i; 30 Pthread_create(\u0026amp;tid[i], NULL, sum_mutex, \u0026amp;myid[i]); 31 } 32 for (i = O; i \u0026lt; nthreads; i++) 33 Pthread_join(tid[i], NULL); 34 35 I* Check final answer *I 36 if (gsum != (nelems * (nelems-1))/2) 37 printf(\u0026ldquo;Error: result=%ld\\n\u0026rdquo;, gsum); 38 39 exit(O); 40 } code/condpsum-mutex.c 图 12-31 (续) 图 12-32 给出了每个对等线程执行的函数。在第 4 行 中 ,线 程 从 线 程 参 数 中 提取出线程 ID , 然后用这个 ID 来决定它要计算的序列区域(第5~ 6 行)。在第 9 ~ 13 行中,线 程在它的那部分序列上迭代操作, 每次迭代都更新共享全局变量 g s um。 注 意 , 我们很小心地用 P 和 V 互斥操作来保护每次更新。\ncode/condpsum-mutex.c\nI* Thread routine for psum-mutex.c *I void *sum_mutex(void *vargp)\n{\nlong myid = *((long *)vargp); I* Extract the thread ID *I long start= myid * nelems_per_thread; I* Start element index *I long end= start+ nelems_per_thread; I* End element index *I long i;\nfor (i = start; i \u0026lt; end; i++) { P(\u0026amp;mutex);\ngsum += i;\nV(\u0026amp;mutex);\n}\nreturn NULL;\ncode/condpsum-mutex.c\n图 12-32 ps um- mu七e x 的线程例程。每个对 等线程将各 自的 和累加进一个用互斥锁保护的共享全局变量中\n我们在一个四核系统上, 对一个大小为 n = 沪 的序列运行 p s um- mutex, 测量它的运行时间(以秒为单位),作为线程数的函数,得到的结果难懂又令人奇怪:\n线程数\n版本 16\nps um- mut e x I 68 432 I 719 I 552 I 599\n程序单线程顺序运行时非常慢,几乎比多线程并行运行时慢了一个数量级。不仅如 此, 使用的核数越多 ,性 能越差。造成性能差的原因是相对于内存更新操作的开销,同 步操作( P 和 V ) 代价太大。这突显了并行编程的一项重要教训 : 同 步 开销 巨 大,要 尽 可能 避免。如果无可避免 , 必须要 用尽可能 多的有用计算 弥补这 个开销。\n在我们的例子中,一种避免同步的方法是让每个对等线程在一个私有变量中计算它自 己的 部分和, 这个私有 变量不与其他任何线程共享, 如图 12-33 所示。主线程(图中未显示)定义一个全局 数组 p s u m, 每个对等线程 1 把它的部分和累积在 p s u m [ i ] 中。因为小 心地给了每个对等线程一个不同的内存位置来更新, 所以不需要用互斥锁来保护这些更新。唯一需要同步的地方是主线程必须等待所有的子线程完成。在对等线程结束后, 主线程把p s um 向 量的元素加起来, 得到最终的结果。\ncode/condpsum-array.c\nI* Thread routine for psum-array.c *I\n2 void *sum_array(void *vargp)\n3 {\nlong myid = *((long *)vargp); I* Extract the thread ID *I\n5 long start= myid * nelems_per_thread; I* Start element index *I\n6 long end= start+ nelems_per_thread; I* End element index *I\n7 long i;\n8\n9 for (i = start; i \u0026lt; end; i++) {\n10 psum[myid] += i;\n11 }\n12 return NULL;\n13 }\ncod e/ cond ps um-array.c\n图 12- 33 psum- ar r a y 的线程例程。每个对 等线程把它的 部分和\n累积在一个私有数组元 素中 , 不与其他任何 对等线程共享该元素\n在四核系统上运行 p s u m- arr a y 时 ,我 们看到它比 p s um- mu t e x 运行得快好几个数量级:\n版本\npsum-mutex I 68.00\npsum-array 7.26\n432.00\n3.64\n线程数\n4\n719.00\n1.91\n552.00\n1.85\n16\n599.00\n1.84\n在第 5 章中, 我们学 习到了如何使用局部变量来 消除不必要的内存引用。图 12-34 展示了如何应用这项原则,让每个对等线程把它的部分和累积在一个局部变量而不是全局变 量中。当在四核机器上运行 p s u m- l o c a l 时, 得到一组新 的递减的运行时 间:\n线程数 版本 l 2 4 8 16 psum-mutex 68.00 432.00 719.00 552.00 599.00 psum-array 7.26 3.64 1.91 1.85 1.84 ps um- l oc a l 1.06 0.54 0.28 0.29 0.30 code/condpsum-local.c\nI* Thread routine for psum-local. c */\nvoid•sum_local(void•vargp)\n{\nlong myid =•((long•)vargp); /• Extract the thread ID•I\nlong start= myid * nelems_per_thread; I* Start element index•/ long end= start+ nelems_per_thread; /• End element index•/\nlong i, sum= O; 。\nfor (i = start; i \u0026lt; end; i++) { sum+= i;\n}\npsum[myid] = sum; return NULL;\ncode/condpsum-local.c\n图 1 2-34 ps um- l oca l 的 线程例程 。每个对等线 程把它的部分 和累积在一 个局部变昼中\n从这个练习可以学习到一个重要的 经验, 那就是写并行程序相当棘手。对代码看上去很小的改动可能会对性能有极大的影响 。\n刻画并行程序的性能\n图 1 2-35 给 出 了图 12-34 中 程 序psum- l oca l 的运行时间,它 是线程数的函数。在每个 情况下, 程序运行在一个有四个处理器核的系统上,对一个n = 23 1 个元素的 序列求 和。我 们 看 到,随着线程数的增加,运行时间下降,直到增加到四个线程,此时,运行时间趋于平稳 ,甚 至开始有点 增加。\n1.2\n1.0\n0.2\n0 . 3\n4 16\n线程\n在理想的情况中,我们会期望运行时间随着核数的增加线性下降。也就是说,\n图 12-35 ps um- l o ca l 的 性 能(图 1 2-34) 。用四个处理器核对一个 沪 个元素序列求 和\n我们会期望线程数每增加一倍 , 运行时间 就下降一半。确实是这样, 直到到达 t\u0026gt; 4 的时候, 此时四个核中的每一个都忙于运行 至少一个线程。随着线程数量的增加, 运行时间实际上增加了一点儿, 这是由于在一个核上多个线程上下文切换的开销 。由于这个原 因, 并行程序常常被写为每个核上只运行一个线程。\n虽然绝对运行时间是衡 量程序性能的终极标 准, 但是还是有一些有用的相 对衡量标准能够说明并行 程序有多好地利用了潜在的并行性 。并行程序的加速比( s peed u p) 通常定义为\nSp= -T1\nTp # 这里 p 是处理器核的数量, 吓 是在 K 个核上的运行时间。这个公式有时被称为 强扩展(strong scaling)。当 九是程序顺序执行版本的执行时间时, Sp 称为绝对加 速比 ( a bsol ute speed up) 。当 九是程序并 行版本在一个核上的执行时间时, Sp 称为相对加速比 ( re lat ive speed up) 。绝对加速比比相 对加速比能更真实地衡量并行的好处。即使是当并行程序在 一个处理器上运行时,也常常会受到同步开销的影响,而这些开销会人为地增加相对加速比的数值, 因为它们增 加了分子的大小。另一方面, 绝对加速比比相对加速比更难以测量, 因为测量绝对加 速比需要程序的 两种不同的 版本。对于复杂的并行代码, 创建一个独立的顺序版本可能不太实际,或者因为代码太复杂,或者因为源代码不可得。\n一种相关的 测量量称为效 率( eff icie nc y) , 定义为\nEp Sp T1\np p T p # 通常表示为范围 在( 0 , 1 0 0 ] 之间的百分比。效率是对由于并行化造成的开销的衡量。具 有高效率的程序比效率低的程序在有用的工作上花费更多的时间,在同步和通信上花费更少 的时间。\n效率是因为我们的问题非常容易并行化。在实际中,很少会这样。数十年\n图 12-36 图 12-35 中执行时间的加速比和并行效 率\n来, 并行编程一直是一个很活跃的 研究领域。随着商用多核机器的出现, 这些机器的核数每几年就翻一番,并行编程会继续是一个深入、困难而活跃的研究领域。\n. 加速比还有另外一面, 称为 弱扩展 ( w ea k scaling) , 在增加处理器数掀的同时, 增加问题的规模, 这样随着处理器数量的增加, 每个处理器执行的工作量保持不变。在这种描述中,加 速比和效率被表达为单 位时间 完成的工作总量。 例如, 如果 将处理器数量翻倍, 同时 每个小时也做了两倍的工作量, 那么我们就有线性 的加速比和 1 00 % 的效率。\n弱扩展常常是比强扩展更真实的衡量值, 因为它更准确地反映 了我们用更大的机器做更多的工作的愿望。对于科学计算程序来说尤其如此,科学计算问题的规模很容易增加, 更大的问题规模直接就意味着更好地预测。不过, 还是有一些应用的规模不那么容易增加, 对于这样的 应用,强 扩展是更合适的。例如, 实时信号处 理应用所执行的工作量常常是由产生信号的物理传感器的属性决 定的。改变工 作总量需要用不同的物理传感器, 这不太实际或者不太必要。对于这类应用, 我们通常想要用并行来尽可能快地完成定量的工作。\n练习题 12. 11 对于下表中的并行程序,填写空白处。假设使用强扩展。\n线程 (t) 1 2 4 核 (p ) 1 2 4 运行时间 ( TP ) 12 8 6 加速比 ( Sp ) 1.5 效率 ( EP ) 100% 50% 12. 7 其他并发问题\n你可能已经注意到了,一旦我们要求同步对共享数据的访问,那么事情就变得复杂得 多了。迄今为止,我 们已经看到了用千互斥和生产者-消费者同步的技术, 但 这仅仅是冰山一角。同步从根本上说是很难的问题, 它引 出 了 在普通的顺序程序中不会出现的问题。这一小节是关于你在写并发程序时需 要注意的一些问题的(非常不完整的)综述。为了让事情具体化,我们将以线程为例描述讨论。不过要记住,这些典型问题是任何类型的并发流 操作共享资源时都会出现的。\n12. 7. 1 线程安全\n当用线程编写程序时, 必须 小 心 地编写那些具有称为线程安全性 ( t h read s afe t y ) 属性的函数。一个函数被称为线程安 全的 ( t h read- s a fe ) , 当且仅当被多个并发线程反复地调用时,它 会 一 直 产 生正确的结果。如果一个函数不是线程安全的, 我们就说它是线程不安 全的 ( t h rea d- un s a fe ) 。\n我们能够定义出四个(不相交的)线程不安全函数类:\n笫 1 类: 不保护共享 变量的函数。我们在图 12-1 6 的 吐r e a d 函数中就已经遇到了这样的问 题,该 函数 对 一 个 未受保护的全局计数器变量加 1。将这类线程不安全函数变成线程安全的,相对而言比较容易: 利用像 P 和 V 操作这样的同步操作来保护共享的变量。这个方法的优点是在调 用程序中不需要做任何修改。缺点是同步操作将减慢程序的执行时间。\n笫 2 类 :保 持跨越多 个调 用的状态的 函数。一个伪随机数生成器是这类线程不安全函\n数的简单例子。请参考图 1 2-37 中的伪随机数生成器程序包。r a n d 函数是线程不安全的, 因 为 当前调用的结果依赖于前次调用的中间结果。当调用 sr a nd 为r a nd 设置了一个种子后, 我们从一个单线程中反复地调用 r a nd , 能够预期得到一个可重复的随机数字序列。然而, 如果 多 线 程调用r a n d 函数 , 这种假设就不再成立了。\ncode/cond rand.c\nunsigned next_seed = 1;\n3 I* rand - return pseudorandom integer in the range 0.. 32767 *I\n4 unsigned rand(void)\n6 next_seed = next_seed*1103515245 + 12543;\n7 return (unsigned)(next_seed»16) % 32768;\n10 I* srand - set the initial seed for rand() *I\n11 void srand(unsigned new_seed)\n12 {\n13 next_seed = new_seed;\n14 }\ncode/condrand.c\n图 12-37 一个线程不安全的伪随机数生成器(基于 [ 61] )\n使得像r a n d 这样的函数线程安全的唯一方式是重写它,使 得 它 不再使用任何 s 七a t i c\n数据,而是依靠调用者在参数中传递状态信息。这样做的缺点是,程序员现在还要被迫修\n改调用程序中的代码。在一个大的程序中,可能有成百上千个不同的调用位置,做这样的修改将是非常麻烦的,而且容易出错。\n笫 3 类: 返回指向静 态 变 量的 指针的 函数。某些函数, 例如 c t i me 和 g e t h o s 亡\nbyname, 将计算结果放在一个 s ta tic 变量中,然 后返回一个指向这个变量的指针。如果我们从并发线程中调用这些函数,那么将可能发生灾难,因为正在被一个线程使用的结果会被另一个线程悄悄地覆盖了。\n有两种方法来处理这类线程不安全函数。一种选择是重写函数,使得调用者传递存放结 果的变量的地址。这就消除了所有共享数据, 但是它要求程序员能够修改函数的源代码。\n如果线程不安全函数是难以修改或不可能修改的(例如,代码非常复杂或是没有源代 码可用), 那么另外一种选择就是使用加锁-复制( lock-a n d- co p y ) 技术。基本思想是将线程不安全函数与互斥锁联系起来。在每一个涸用位置,对互斥锁加锁,调用线程不安全函 数,将函数返回的结果复制到一个私有的内存位置,然后对互斥锁解锁。为了尽可能地减 少对调用者的修改,你应该定义一个线程安全的包装函数,它执行加锁-复制,然后通过 调用这个包装函数来取代所有对线程不安全函数的调用。例如, 图 12-38 给出了 c 巨 me 的一个线程安全的版本,利用的就是加锁-复制技术。\ncode/condctime-ts.c\nchar *ctime_ts(const time_t *timep, char *privatep)\n2 {\n3 char *sharedp;\n4\n5 P(\u0026amp;mutex);\n6 sharedp = ctime(timep);\n7 strcpy(privatep, sharedp); I* Copy string from sha 工 ed to private *I\n8 V(\u0026amp;mutex);\n9 return privatep;\n10 }\ncode/condctime-ts.c\n图 12-38 C 标准库函数 c t i me 的线程安全的包装函数。使用加锁-复制技术调用一个第3 类线程不安全函数\n笫 4 类: 调用线程 不安 全函数的 函数。如果 函数 f 调用线程不安全函数 g , 那么 f 就是线程不安全的吗? 不一定。如果 g 是第 2 类函数, 即依赖于跨越多次 调用的 状态, 那么\nJ 也是线程不安全的, 而且除了重写 g 以外, 没有什么办法。然而, 如果 g 是第 1 类或者\n第 3 类函数, 那么只要你用一 个互斥锁保护调用位置和任何 得到的共享数据, J 仍 然可能是线程安全的。在图 1 2-38 中我们看到了一个这种情况很好的示例, 其中我们使用加锁- 复制编写了一 个线程安全函数,它 调用了一个线程不安全的函数。\n所有的函数\n7. 2 可重入性\n有一类重要的线程安全函数,叫做可重入函数( ree n t ra nt function) , 其特点在于它们具有这样一种属性:当它们被多个线程调用时,不会引用任何共享数据。尽管线程安全和可重入有时会\n线程安全函数\n三 线程不安全函数\n(不正确地)被用做同义词,但是它们之间还是有 图 12-39 可重入函数、线程安全函数和线程\n清晰的技术 差别, 值得留意。图 12-39 展示了可 不安全函数之间的集合关系\n重入函数、线程安全函数和线程不安全函数之间的集合关系。所有函数的集合被划分成不 相交的线程安全和线程不安全函数集合。可重入函数集合是线程安全函数的一个真子集。 可重入函数通常要比不可重入的线程安全的函数高效一些,因为它们不需要同步操\n作。更进一步来说,将 第 2 类线程不安全函数转化为线程安全函数的唯一方法就是重写它,使 之 变为可重入的。例如,图 1 2- 40 展 示了图 1 2-37 中 r a nd 函数的一个可重入的版本。关键思想是我们用一个调用者传递进来的指针取代了静态的 ne x t 变量。\ncode/condrand-r.c I* rand_r - return a pseudorandom integer on 0 .. 32767 *I\nint rand_r(unsigned int *nextp)\n{\n*nextp = *nextp * 1103515245 + 12345;\nreturn (unsigned int)(*nextp / 65536) % 32768;\n}\ncodelcondrand-r.c\n图 12-40 rand_r: 图 12-37 中的 r a nd 函数的可重入版本\n检查某个函数的代码并先验地断定它是可重入的,这可能吗?不幸的是,不一定能这 样。如果所有的函数参数都是传值传递的(即没有指针),并且所有的数据引用都是本地的 自动栈变量(即没有引用静态或全局变量),那么函数就是显式 可 重入的 ( ex plicitl y reen­\ntrant), 也就是说,无论它是被如何调用的,都可以断言它是可重入的。\n然而, 如果把假设放宽松一点 ,允 许显式可重入函数中一些参数是引用传递的(即允许它们传递指针),那 么 我们就得到了一个隐式可重入的 ( im plicitl y ree nt ra n t ) 函数,也 就是说,如果调用线程小心地传递指向非共享数据的指针,那么它是可重入的。例如,图 1 2-40 中的r a nd_r 函数就是隐式可重入的。\n我们总是使用术语 可重入的 ( ree nt ra nt ) 既包括显式可重入函数也包括隐式可重入函数。然而,认 识 到 可重入性有时既是调用者也是被调用者的属性,并 不 只 是 被询用者单独的属性是非常重要的。\n练习题 12 . 12 图 12-38 中的 ct ime_t s 函数是线程安全的,但不是可重入的。请解释说明。\n1 2 . 7. 3 在线程化的程序中使用已存在的库函数\n大多数 Lin u x 函数,包 括定义在标准 C 库中的函数(例如 ma l l o c 、 fr e e 、r e a l l o c 、\npr i n t f 和 s c a n f ) 都 是 线 程 安 全的,只 有一小部分是例外。图 1 2-41 列出了常见的例外。\n(参考[ 110] 可以得到一个完整的列表。)s tr t o k 函数是一个已弃用的(不推荐使用)函数。 a s c t i me 、 c t i me 和 l oc a l t i me 函数 是 在 不同时间和数据格式间相互来回转换时经常使用的函数。ge t ho s t b yna me 、 g e t h o s t b ya d dr 和 i ne t _ n t oa 函数是已弃用的网络编程函数,已 经分别被可重入的 ge t a ddr i n f o 、ge t na me i n f o 和 i ne t _ nt o p 函数取代(见第 11 章)。除了 r a nd 和 s tr t o k 以外 ,所 有这些线程不安全函数都是第 3 类的,它 们 返 回 一 个 指向静态变量的指针。如果我们需要在一个线程化的程序中调用这些函数中的某一个,对 调用者来说最不惹麻烦的方法是加锁-复制。然而,加锁-复制方法有许多缺点。首先,额 外的同步降低了程序的速度。第二,像 g e t h o s t b yn a me 这样的函数返回指向复杂结构的结构的指针,要 复 制 整个结构层次,需 要 深层复制 ( dee p copy) 结构。第三,加 锁-复 制方法对像r a nd 这样依赖跨越调用的静态状态的第 2 类函数并不有效。\n线程不安全函数 线程不安全类 Linux 线程安全版本\nrand strtok asctime ctime\ngethostbyaddr gethostbyname inet_ntoa localtime\nrand_r strtok_r asctime_r ctime_r\ngethostbyaddr_r gethostbyname_r\n(无)\nlocaltime_r\n图12-4 1 常见的线程不安全的库函数\n因此, L in u x 系统提供大多数线程不安全函数的可重入版本。可重入版本的名字总是以\u0026quot; r \u0026quot; 后缀结尾。例如, a s c 巨 me 的可重 入版本就叫做 a s c 巨 me _r 。我 们建议尽可能地使用这些函数。\n12. 7. 4 竞争 # 当一个程序的正确性依赖于一个线程要在另一个线程到y达点之前到达它的控制流中的x 点时, 就会发生竞争( ra ce) 。通常发生竞争是因为程序员假定线程将按照某种特殊的轨迹线穿过执行状态空间, 而忘记了另一条准则规定:多 线程的程序必须对任何可行的轨迹线都正确工。作\n例子是理解竞争本质的最简单的方法。让我们来 看看图 12-42 中的简单程序。主线程创建了四个对等线程, 并传递一个指向一个唯一的整数 ID 的指针到每个 线程。每个 对等线程复制它的参数中传递的 ID 到一个局部变量中(第22 行), 然后输出一个包含这个 ID 的信息。它看上去足够简单,但是当我们在系统上运行这个程序时,我们得到以下不正确的结果:\nlinux\u0026gt; ./race\nHello from thread 1 Hello from thread 3 Hello from thread 2 Hello from thread 3\ncode/condrace.c\nI* WARNING: This code is buggy! */\n#include \u0026ldquo;csapp.h\u0026rdquo;\n#define N 4\nvoid *thread(void *vargp); int main()\n{\npthread_t tid[N]; int i;\nfor (i = O; i \u0026lt; N; i++) Pthread_create(\u0026amp;tid[i],\nfor (i = O; i \u0026lt; N; i++)\nNULL, thread, \u0026amp;i);\nPthread_join(tid[i], exit(O);\nNULL);\n图 1 2-42 一个具有竞争的程序\n18\nI* Thread routine *I\nvoid *thread(void *vargp)\n21 {\nint myid = *((int *)vargp);\nprintf(\u0026ldquo;Hello from thread %d\\n\u0026rdquo;, myid);\nreturn NULL;\n25 }\ncode/condrace.c\n图 12- 42 (续)\n问 题 是由每个对等线程和主线程之间的竞争引起的。你能发现这个竞争吗? 下面是发生的 情况 。当主线程在第 1 3 行创建了一个对等线程,它 传递了一个指向本地栈变量 t 的指针。在此时,竞 争 出现在下一次在第 1 2 行对 1 加 1 和第 22 行参数的间接引用和赋值之间。如果对等线程在主线程执 行第 1 2 行对 t 加 1 之前就执行了第 22 行, 那么 my i d 变量就得到正确的 ID。否则,它 包含的就会是其他线程的 ID。令人惊慌的是, 我们是否得到正确的答案依赖千内核是如何调度线程 的执行的。在我们的 系统中它失败了 , 但是在其他系统中,它可 能就能正确工作 , 让程序员“幸福地” 察觉 不到程序的严重错误。\n为了消除竞争,我 们可以动态地为每个整数 ID 分配一个独立的块, 并且传递给线程例程一个指向这个块的指针, 如图 1 2- 43 所示(第1 2 1 4 行)。请注意 线程例程必须释放这些块以避免内存泄漏。\ncode/condnorace.c\n#include \u0026ldquo;csapp.h\u0026rdquo;\n#define N 4\nvoid *thread(void *vargp); int main()\n{\npthread_t tid[N]; inti, *ptr;\nfor (i = O; i \u0026lt; N; i++) {\nptr = Malloc(sizeof(int));\n*ptr = i;\nPthread_create(\u0026amp;tid[i],\n}\nNULL, thread, ptr);\nfor (i = O; i \u0026lt; N; i++) Pthread_join(tid[i],\nexit(O);\nNULL);\nI* Thread routine *I\nvoid *thread(void *vargp)\n{\nint myid = * ((int *)vargp) ; Free (vargp) ;\nprintf(\u0026ldquo;Hello from thread %d\\n\u0026rdquo;, myid); return NULL;\n}\ncode/condnorace.c\n图 12 -43 图 12-42 中程序的一个没有竞争的正确版本\n当我们在系统上运行这个程序时,现在得到了正确的结果:\nlinux\u0026gt; ./norace Hello from thread 0 Hello from thread 1 Hello from thread 2 Hello from thread 3\n练习题 12. 13 在图 12-43 中, 我们可能想 要在 主线程中的 第 1 4 行后立即 释放 巳分 配的内存块,而不是在对等线程中释放它。但是这会是个坏注意。为什么?\n练习题 12. 14\n在图 12-43 中, 我们 通过 为每 个整数 ID 分配 一个 独 立的 块来消除竟争。 给出 一个不调用 ma l l o c 或者 f r e e 函数的不 同的 方 法。 这种方法的利弊是什么? 12. 7. 5 死锁\n信号量引 入了一种 潜在的令人厌恶的运行时错误, 叫做死锁 ( d eadlock ) , 它指的是一组线程被阻塞了,等待一个永远也不会为真的条件。进度图对于理解死锁是一个无价的工 具。例如,图 1 2-44 展示了一对用两个信号晕来实现互斥的线程的进程图。从 这幅图中, 我们能够得到一些关于死锁的重要知识:\n线程2\nV(s)\nV(t)\nP(s)\n死锁区\n勹\u0026quot; # P(s)···P(t)··· V(s) · · ·V(t)\n图12-44 一个会死锁的程序的进度图\n线程1\n程序员使用 P 和 V 操作顺序不当, 以至于两个 信号量的禁止区域 重叠。如果某个执行轨迹线碰巧到达了死锁状态 d , 那么就不可能有进一步的进展了,因为重叠的禁止区域阻塞了每个合法方向上的进展。换句话说,程序死锁是因为每个线程都在等 待其他线程执行一个根不可能发 生的 V 操作。\n重叠的禁止区域引起了一组称为死锁区域 ( d ead lo ck r eg io n ) 的状态。如果一个轨迹线碰巧到达了一个死锁区域中的状态,那么死锁就是不可避免的了。轨迹线可以进 入死锁区域,但是它们不可能离开。\n死锁是一个相当困难的问题,因为它不总是可预测的。一些幸运的执行轨迹线将绕开死 锁区域,而其他的 将会陷入 这个区域。图 12-44展示了每种情况的一个示例。对于程序员来说, 这其中隐含的着实令人惊慌。你可以运行一个程序 1000 次不出任何问题,但是下一次它就死锁了。或者程序在一台机器上可能运行得很好,但 是 在另外 的 机 器 上就会死锁。最糟糕的是,错误常常是不可重复的,因为不同的执行有不同的轨迹线。\n程序死锁有很多原因,要避免死锁一般而言是很困难的。然而,当使用二元信号量来 实现互斥时 ,如 图 1 2- 44 所示,你 可以应用下面的简单而有效的规则来避免死锁:\n互斥锁加锁顺序规则: 给 定所有互斥操作的一个全序 ,如 果 每 个线程都是以一种顺序荻得互斥锁并以相反的顺序释放,那么这个程序就是无死锁的。\n例如, 我们可以通过这样的方法来解决图 12-44 中的死锁问题: 在 每个线程中先对 s\n加锁 ,然 后 再 对 t 加锁。图 12-45 展示了得到的进度图。\n线程2\nV(s)\nV(t)\nP(t)\n勹, , # · · · P (s) · ··P(t)··· V(s) · · ·V(t)\n图 12 -45 一个无死锁程 序的进度图\n线程1\n心 练习题 12 . 15 思考下面的程序,它试图使用一对信号量来实现互斥。\n初始时: 线程1: P(s); s = 1, t = 0 . 线程2: P(s); V(s); V(s); P(t); V(t); P(t); V(t); 画出这个程序的进度图。 它总是会死锁吗? 如果是,那么对初始信号量的值做哪些简单的改变就能消除这种潜在的死锁呢? 画 出 得到 的无死锁程 序的进度图。 12. 8 小结\n一个并发程序是由在时间上重叠的一组逻辑流组成的。在这 一章中, 我们学习了三种不同的构建并发程序的机制 : 进程、1/ 0 多路复用和线程。我们以一个并发网络服务器作为贯穿全章的应用程序。\n进程是由内核自动调度的,而且因为它们有各自独立的虚拟地址空间,所以要实现共享数据,必须 要有显式的 IPC 机制。事件驱动程 序创建它们自己的并发逻辑流, 这些逻辑流被模型化为状态机,用1/ 0 多路复用来显式 地调度这些 流。因 为程 序运行 在一个单一进程中 , 所以在流之间 共享数据速度很快 而且很容易。线程是这些方法的混合 。同基千进程的 流一样, 线程也是由内 核自动调度的。同基千 I/ 0 多路复用的流一样 , 线程是运行 在一个单一进程的上下 文中的 ,因 此可以快速而方便地共 享数据 。\n无论哪种并发机制 ,同 步对共享数据的并 发访问 都是一个困难的问 题。提出对信号 量的 P 和 V 操作就是为了帮助解决这个问题。信号量操作可以用来提供对共享数据的互斥访问,也对诸如生产者-消费者 程序中有限缓 冲区和读者-写者系统中的共享对象这样的资源访 间进行调度。一个并发预线程化的 echo 服务器提供了信号 量使用场景的很好的例子。\n并发也引入了其他一些困难的问题。被线程调用的 函数必须具有一种称为线 程安 全的属性。我们定义了四类线程不安全的函数,以及一些将它们变为线程安全的建议。可重入函数是线程安全函数的一个 真子集,它不访问任何共享数据。可重入函数通常比不可重入函数更为有效,因为它们不需要任何同步 原语。竞争和死锁是并发 程序中出 现的另一些 困难的间题。当程序员错误地假设逻辑流该 如何 调度时 , 就会发生竞争。当一个流等待一个永远不会发生的事件时,就会产生死锁。\n参考文献说明 # 信号蜇操作是 D咄s t ra 提出的 [ 31] 。进度图的概念是 Coff ma n [ 23] 提出的, 后来由 Ca rso n 和 R e yn­ olds [ 16] 形式化的 。Co ur tois 等人[ 25] 提出了读者-写者问题。操作系统教科 书更详 细地 描述了经典的同步问题, 例如哲学家进餐问 题、打睦睡的理发师问 题和吸烟者问 题 [ 102 , 106, 113] 。Buten hof 的书[ 15] 对 P o six 线程接口有全 面的描述。Birrell [ 7] 的论文对线程编程以 及线程编程中容易遇到的问 题做了很好的介绍。 R einde rs 的书[ 90] 描述了 C I C + + 库,简化了线 程化程序的设 计和实现。有一些 课本讲述了多核系统上并行编程的 基础知识 [ 47 , 71] 。P ug h 描述了 Ja va 线程通过内存进行交互的方式的缺陷,并提出了替代的内存模型 [ 88 ] 。G us tafso n 提出了替代强扩展的 弱扩展加速模型 [ 43] 。\n家庭作业 # 12 16 编写 he ll o . c ( 图 12-13 ) 的一个版本, 它创建和回收 n 个可结 合的对等线程,其 中 n 是一个命令行参数。\n12 17 A. 图 12-46 中的程序有一个 b ug 。要求线程睡眠一秒钟 , 然后输出一 个字符串。然而 , 当在 我们\n的系统上运行它时 ,却 没有任何输 出。为什么?\n/• WA 邸 I NG: This code is bu 邸 ;y! •/\n#include \u0026ldquo;csapp.h\u0026rdquo;\nvoid•thread(void•vargp);\n4\n5 int main()\n6 {\n7 pthread_t tid;\n8\n9 Pthread_create(\u0026amp;tid, NULL, thread, NULL);\n10 exit(O);\n11 }\n12\n/• Thread routine•/\nvoid•thread(void•vargp)\n15 {\nSleep(1);\nprintf(\u0026ldquo;Hello, world!\\n\u0026rdquo;);\nreturn NULL;\n19 }\ncode/condhellobug.c\ncodelconclhellobug.c\n图 12-46 练习题 12. 17 的有 bug 的程序\nB. 你可以通过用两个 不同的 P th reads 函数调 用中的一个替代第 10 行中的 e x 让 函数来 改正这个错误。选哪一个呢?\n12. 18 用图 1 2-21 中的进度图, 将下面的 轨迹线分类 为安全或者不安全的。\nA. H2 , L2 , U2 , H, , L1 , S2 , U 1 , S, , T1 , T2\nB. H 2 , H1, L1 , U, , S1 , L 2, T, , U2, S 2 , T2\nC. H1 , L1 , H 2 , L2, U2, S2, U, , S , , T1 , T2\n•• 12 . 19 图 1 2- 2 6 中第一类读 者-写者问题的解答 给予 读者的是有些弱的优先级, 因为读者在离开它的临界区时,可能会重启一个正在等待的写者,而不是一个正在等待的读者。推导出一个解答,它给 予读者更强的优先级,当写者离开它的临界区的时候,如果有读者正在等待的话,就总是重启一 个正在等待的读者。\n\\* 12. 20 考虑读者-写者问题的一个更简单的变种 ,即 最多只有 N 个读者。推导 出一个解答 ,给 予读者和\n写者同等的优先级,即等待中的读者和写者被赋予对资源访问的同等的机会。提示:你可以用一个计数信号蜇和一个互斥锁来解决这个问 题。\n:: 12. 21 推导出第二类读者-写者问题的一个解答,在此写者的优先级高于读者。\n•• 12. 22 检查一下你对 s e l e吐 函数的理 解,请 修改图 12-6 中的服务器, 使得它在主服务 器的每次迭代中最多只回送一个文本行。\n•• 12. 23 图 1 2-8 中的事件驱动并 发 echo 服务器是 有缺陷的 , 因为一个恶意的 客户端能够通过发送部分的文本行,使服务器拒绝为其他客户端服务。编写一个改进的服务器版本,使之能够非阻塞地处理 这些部分文本行。\n12. 24 RIO I/ 0 包中的 函数(1 0. 5 节)都是线程安全的 。它们也都是可重入函数吗?\n12. 25 在图 1 2-28 中的预线程化的并发 echo 服务器中, 每个线程都调用 e c ho _ c nt 函数(图12-29 ) 。e c ho_c n t 是线程安全的吗? 它是可重人的吗?为什么是或 为什么不是呢?\n*/ 12. 26 用加锁-复制技术来实现 g e t ho s t b yna me 的 一个线程安全而又不 可重入 的版本, 称为 ge t hos t -\nb yna me _ t s 。一个正确的解答是使用由互斥 锁保护的 ho s t e nt 结构的深层副本 。\n• • 12. 27 一些网络编程的教科书建议用以下的方法来读 和写套接字 : 和客户端交互之前 , 在同一个打开的已连接套 接字描述符上 ,打开两个标 准 I/ 0 流, 一个用来读, 一个用来写 :\nFILE•fpin, •fpout;\nfpin = fdopen(sockfd, \u0026ldquo;r\u0026rdquo;);\nfpout = fdopen(sockfd, \u0026ldquo;w\u0026rdquo;);\n当服务器完成和客户端的交互之后,像下面这样关闭两个流:\nfclose(fpin); fclose(fpout);\n然而,如果你试图在基千线程的并发服务器上尝试这种方式,将制造一个致命的竞争条件。请解释。\n12 28 在图 12-45 中,将 两个 V 操作的顺序交换 , 对程序死锁是否 有影响? 通过画出四 种可能情况的 进度图来证明你的答案: 情况 l 情况 2 情况 3 情况 4 线程 l 线程2 线程 1 线程2 线程 l 线程2 线程 l 线程2 P (s ) P(t) P(s) P(t) P(s) P(t) P(s) P(t) P(s) P(t ) P(s) P(t ) P(s) P(t) P(s) P(t) V(s) V(s) V(s) V(t) V(t) V(s) V(t) V(t) V(t) V(t) V(t) V(s) V(s) V(t) V(s) V(s) 12. 29 下面的程序会死锁吗?为什么会或者为什么不会? 初始时: a= 1, b = 1, c = 1\n线程1 : 线程2 :\nP(a); P(c);\nP(b); P(b);\nV(b); V(b);\nP(c); V(c);\nV(c);\nV(a);\n12. 30 考虑下面这个会死锁的程序。\n初始时: a= 1, b = 1, c = 1\n线程1 : 线程2: 线程3:\nP(a); P(c); P(c);\nP(b); P(b); V(c);\nV(b); V(b); P(b);\nP(c); V(c); P(a);\nV(c); P(a); V(a);\nV(a); V(a); V(b);\n列出每个线程同时占用的一对互斥锁。\n如果 a \u0026lt; b\u0026lt; c , 那么哪个线程违背了互斥锁加锁顺序规则?\n对于这些线程,指出一个新的保证不会发生死锁的加锁顺序。\n*.* 12. 31 实现标准 I/ 0 函数 f ge t s 的一个版本,叫 做 t f ge t s , 假如它在 5 秒之内 没有从标 准输入 上接收到一个输入行,那么就超时,并返回 一个 NU LL 指针。你的函数应该实现在一个叫做 t f ge t s - pr oc . c 的包中,使用 进程、信号和非本地跳转 。它不应该使用 Linux 的 a l a rm 函数。使用图 12-47 中的驱动程序测试你的结果。\n1 #include \u0026ldquo;csapp.h\u0026rdquo;\n2\n3 char *tfgets (char *s, int size, FILE *stream);\n4\n5 int main()\n6 {\n7 char buf [MAXLINE] ;\n8\n9 if (tfgets(buf, MAXLINE, stdin) == NULL)\n10 printf(\u0026ldquo;BOOM!\\n\u0026rdquo;);\n11 else\n12 printf(\u0026ldquo;o/.s\u0026rdquo;, buf);\n13\n14 exit(O);\n15 }\ncodelcond tfgets-main.c\ncode/contfdgets-main.c\n图 12-47 家庭作业题 12. 31~12. 33 的驱动程序\n*.* 12. 32 使用 s e l e c t 函数来实现练习题 12. 31 中 t f ge t s 函数的一个版本。你的 函数应该在一个叫做 t f ­ ge t s -s e l e c t . c 的包中实现。用练习题 12. 31 中的驱 动程序测试你的结果 。你可以假定标准输人被赋值为描述符 0。\n·.· 12. 33 实现练习 题 12. 31 中 t f ge t s 函数的一个线程化的版本。你的函数应该在一个叫做 t f ge t s ­ t hr e a d . c 的包中实现。用练习题 12. 31 中的驱动程序测试你的结果。\n·: 12. 34 编写一个 N X M 矩阵乘法核心函数的并行线程化版本。比较它的性能 与顺序的版本的性能。\n·: 12. 35 实现一个基于进程的 T I NY Web 服务器的并发版本 。你的解答应该为每一个新的 连接请求创建一个新的子进程。使用一 个实际的 Web 浏览器来测试你的 解答。\n: 12. 36 实现一个基于 l/ 0 多路复用的 T IN Y Web 服务器的并 发版本。使用一个实际的 Web 浏览器来测试你的解答。 ** 12. 37 实现一个基于线程的 T INY Web 服务器的并发版本 。你的解答 应该为 每一个新的连接请求创建 一个新的线 程。使用一 个实际的 Web 浏览器来 测试你的 解答 。 : 12. 38 实现一个 T INY Web 服务器的并发预线 程化的 版本。你的解答应该 根据当前的 负载, 动态地增加或减少线 程的数目 。一个策略是当缓 冲区变满时 , 将线程数晟 翻倍 , 而当缓 冲区 变为空 时, 将线程数目 减半。使用一 个实际的 Web 浏览器来 测试你的 解答 。\n:: 12. 39 Web 代理是 一个在 Web 服务器和浏览 器之间 扮演中间角色 的程序。浏 览器不是直接连接服务器以获取 网页, 而是与代理连接 , 代理再将请求转发给服务器。当服 务器响 应代理 时, 代理将响应发送给浏览器。为了这个试验 , 请你编写一个简单的可以过滤和记 录请求 的 Web 代理:\n试验的第一部分中 , 你要建立以接收请求的代理, 分析 HT T P , 转发请求给服务 器, 并且返回结果给浏览 器。你的代理将所有请求 的 URL 记录在磁盘上 一个日志文件中 ,同时它还要阻塞所有对包含 在磁盘上一 个过滤文件 中的 URL 的请求。 试验的第二部分中, 你要升级代理 , 它通过派生一个独立的线程来处理每一个请求, 使得 代理能够一次处理多个打开的连接。当你的代理在等待远程服务器响应一个请求使它能服务于 一个浏览器时,它应该可以处理来自另一个浏览器未完成的请求。\n使用一个 实际的 Web 浏览骈来 检验你的 解答。\n练习题答案\n12. 1 当父进 程派生子 进程时 ,它 得到一个已连接描 述符的副本, 并将相关文件 表中的引用计数从 1 增加到 2。当父进程关闭 它的描述符 副本时 ,引 用 计数就从 2 减少到 1。因为内 核不会关闭一个文件, 直到文件表中它的引用计数值 变为零 , 所以子进程这边的 连接端将保持 打开。\n12. 2 当一个 进程因为某种原因终止时 ,内 核 将关闭所有打开的 描述符。因此, 当子进 程退出时, 它的已连接文件描述符的副本也将被自动关闭。\n12. 3 回想一下, 如果一个从描述符中读 一个字节的请求不 会阻塞, 那么这个描述符 就准备好 可以读 了。\n假如 EOF 在一个描述符上为 真, 那么描述符也 准备好可读了 , 因为读操作将立 即返回一个零返回码,表示 EOF。因此,键入 Ctrl+ D 会导致 s e l e c t 函数返回, 准备好的 集合中有描述符 0。\n12. 4 因为变最 poo l . r e ad_ s e t 既作为输入参数 也作为输出 参数, 所以 我们在每一次调用 s e l e c t 之前都重新初始化它。在输入时 , 它包含读集合 。在输出 , 它包含准备好的 集合。\n12. 5 因为线程运行在同一个进程中 , 它们都共享相同 的描述符表。无论有多少线程使用这个已 连接描述符, 这个已 连接描述符的 文件表的引 用计数都等于 1。因此, 当我们用完它时, 一个 c l os e 操作就足以 释放与这个已 连接描述符 相关的内存资源了。\n6 这里的 主要的思 想是, 栈变址是私有的 , 而全局和静态变扯是共享的。诸如 c nt 这样的 静态变量有点小麻烦, 因为共享是限制 在它们的函数范围内的一—召:这个例子中, 就是线程例程 。 下面就是 这张表 : 变量实例 被主线程引用? 被对等线程0引用? 被对等线程1引用? ptr 定曰 定曰 定曰 cnt 否 定曰 定曰 i . m 定曰 否 否 ms g s . m 是 是 定El myid.pO 否 是 否 myi d . p l 否 否 是 说明:\np tr : 一个被主线程写 和被对等线程读的 全局变谜。 c nt : 一个静态变储, 在内存中只有一个实例 ,被 两个对等线程读和写。 i.m: 一个存储在主线程栈中的本 地自动变 釐。虽然它的 值被传递给对等线程,但 是对等线程也绝 不会在栈中引用它 , 因此它不是 共享的。 msgs . m: 一个存储在 主线程栈中的 本地自动变量 , 被两个对等线 程通过 p tr 间接地引用 。 myid. 0 和 my 过 . 1 : 一个本地自 动变量的实 例, 分别驻留在对等线 程 0 和线程 1 的栈中。 变量 ptr 、c nt 和 ms g s 被多于一个线程引用, 因此它们是 共享的。\n12. 7 这里的重要思想是,你不能假设当内核调度你的线程时会如何选择顺序。\n变量 c nt 最终有一 个不正确的 值 1。\n12. 8 这道题简单地测试你 对进度图中安全和不安 全轨迹线的理解。像 A 和 C 这样的轨迹线绕开了临界区,是安全的,会产生正确的结果。\nA. H1, L1, U1 , S1, H , , L,, U , , S,, T,, Ti : 安 全 的\nB. H, , L, , H1 , L1 , U1 , S1 , Ti , U, , S, , T, : 不 安 全 的\nC. H, , H,, L, , U,, S,, Li , Ui, S 1 , T1 , T,: 安全的\n12. 9 A.p=l, c=l, n\u0026gt; l : 是,互斥锁是需要的,因为生产者和消费者会并发地访间缓冲区。\np=l, c = l , n=l, 不是, 在这种情况中 不需要互斥锁 信号量 , 因为一个非空的缓 冲区就等千满的缓冲区。当缓冲区包含一个项目时,生产者就被阻塞了。当缓冲区为空时,消费者就被阻 塞了。所以 在任意时刻 ,只 有一个线 程可以访间缓 冲区 , 因此不用互斥锁也能保证互斥 。\np\u0026gt;l, c\u0026gt;l, n=l: 不是, 在这种情况中 , 也不需要互斥锁 ,原因 与前面一种情况相同。\n12 10 假设一个特殊的 信号量实现为每一 个信号 量使用了 一个 LI FO 的线 程栈。当一个线程在 P 操作中阻塞在一个信号蜇 上,它的 ID 就被压入栈中 。类似地, V 操作从栈中弹出栈顶的线程 ID, 并重启这个线程。根据这个栈的实现,一个在它的临界区中的竞争的写者会简单地等待,直到在它释 放这个信号量之前另一个写 者阻塞 在这个信号 量上。在这种场景中, 当两个写者来回地传递控制权时, 正在等待的读者可能会永远地等待下去。\n注意, 虽然用 FIF O 队列而不是用 LIFO 更符合直觉 , 但是使用 LIFO 的栈也 是对的, 而且也没有违反 P 和 V 操作的语义。\n12. 11 这道题简单地检查你对加速比和并行效率的理解:\n线程( t ) I 2 4 核 (p ) I 2 4 运行时间( T,,) 12 8 6 加速比CSP ) I 1.5 2 效率\u0026lt;EP ) 100% 75% 50% 12 . 12 ct i me—t s 函数不是可重入函数, 因为每次调用都共享相同的由 get hos tb yname 函数返回的 s t at i c 变量。然而, 它是线程安全的,因 为对共享变扯的访问是被 P 和 V 操作保护的, 因此是互斥的。\n12. 13 如果在第 14 行调用了 p 七hr e a d _cr e a t e 之后, 我们立即释放块, 那么将引入一个新的竞争, 这次竞争发 生在主线程对 fr e e 的调用 和线程例程中第 24 行的赋值语句之间。\n12. 14 A. 另一种方法是直接传递整数 i , 而不是传递一 个指向 1 的指针:\nfor Ci = 0; i \u0026lt; N; i++)\nPthread_create(\u0026amp;tid[i], NULL, thread, (void•)i);\n在线程例程中 ,我们将参数强 制转换成一个 i nt 类型, 并将它赋值给 rnyi d : int myid = (int) vargp;\nB. 优点是它通过消除对 ma l l o c 和 fr e e 的 调用降低了开销。一个明显的缺点是, 它假设指针至少和 i n t 一样大。即便 这种假设 对于所有的 现代系统来说都为真, 但是它对千那 些过去遗留下来的或今后的系统来说可能就不为真了。\n12. 15 A. 原始的程序的 进度图如图 12-48 所示。\n线程2\nV(t)\nP(t)\nV(s)\n勹(,)\nt 的 禁止区\n三 t 的禁止区\n· · · P(s) .. ·V(s) ..·P(t) ..·V(t)\n图 12- 48 一个有死锁的程序的进度图\n线程1\n因为任何可行的轨迹最终都陷入死锁状态中,所以这个程序总是会死锁。 为了消除 潜在的死锁, 将二元信号蜇 t 初始化为 1 而不是 0。 改成后的程序的进度图 如图 12-49 所示。线程2 V(t) 三\nP(t)\nV(s) 三\n勹\u0026quot;'\n· · ·P(s)··· V(s) · · · P(t) · · ·V(t)\n图 12- 49 改正后的无死锁的程序的进度图\n线程1\n"},{"id":437,"href":"/zh/docs/technology/System/%E6%B7%B1%E5%85%A5%E7%90%86%E8%A7%A3%E8%AE%A1%E7%AE%97%E6%9C%BA%E7%B3%BB%E7%BB%9F/%E4%B9%A6%E7%B1%8D/%E7%AC%AC1%E7%AB%A0-%E8%AE%A1%E7%AE%97%E6%9C%BA%E7%B3%BB%E7%BB%9F%E6%BC%AB%E6%B8%B8/","title":"Index","section":"SpringCloud","content":"第1章\nCHAPTER1\n计算机系统漫游\n计算机系统是由硬件和系统软件组成的,它们共同工作来运行应用程序。虽然系统的具体实现方式随着时间不断变化,但是系统内在的概念却没有改变。所有计算机系统都有相似的硬件和软件组件,它们又执行着相似的功能。一些程序员希望深入了解这些组件是如何工作的以及这些组件是如何影响程序的正确性和性能的,以此来提高自身的技能。本书便是为这些读者而写的。\n现在就要开始一次有趣的漫游历程了。如果你全力投身学习本书中的概念,完全理解底层计算机系统以及它对应用程序的影响,那么你会步上成为为数不多的“大牛"的道路。\n你将会学习一些实践技巧,比如如何避免由计算机表示数字的方式引起的奇怪的数字错误。你将学会怎样通过一些小窍门来优化自己的C代码,以充分利用现代处理器和存储器系统的设计。你将了解编译器是如何实现过程调用的,以及如何利用这些知识来避免缓冲区溢出错误带来的安全漏洞,这些弱点给网络和因特网软件带来了巨大的麻烦。你将学会如何识别和避免链接时那些令人讨厌的错误,它们困扰着普通的程序员。你将学会如何编写自己的Unixshell、自己的动态存储分配包,甚至于自己的Web服务器。你会认识并发带来的希望和陷阱,这个主题随着单个芯片上集成了多个处理器核变得越来越重要。\n在Kernighan和Ritchie的关于C编程语言的经典教材[61]中,他们通过图1-1中所示的hello程序来向读者介绍C。尽管hello程序非常简单,但是为了让它实现运行,系统的每个主要组成部分都需要协调工作。从某种意义上来说,本书的目的就是要帮助你了解当你在系统上执行hello程序时,系统发生了什么以及为什么会这样。\ncodelintrolhello.c\n#include \u0026lt;stdio.h\u0026gt;\nint main()\n{\nprintf(\u0026ldquo;hello, world\\n\u0026rdquo;); return O;\n}\ncode/intro/he/lo .c\n图 1-1 he ll o 程序(来源: [60])\n我们通过跟踪hello程序的生命周期来开始对系统的学习——从它被程序员创建开始,到在系统上运行,输出简单的消息,然后终止。我们将沿着这个程序的生命周期,简要地介绍一些逐步出现的关键概念、专业术语和组成部分。后面的章节将围绕这些内容展开。\n1.1 # 信息就是位+上下文 # hello程序的生命周期是从一个源程序(或者说源文件)开始的,即程序员通过编辑器创\n建并保存的文本文件,文件名是hello.c。源程序实际上就是一个由值0和1组成的位(又称为比特)序列,8个位被组织成一组,称为字节。每个字节表示程序中的某些文本字符。\n大部分的现代计算机系统都使用ASCII标准来表示文本字符,这种方式实际上就是用一个唯一的单字节大小的整数值e来表示每个字符。比如,图1-2中给出了hello.c程序的ASCII码表示。\n# i n C 1 u d e SP < s t d i 。 35 1 05 11 0 9 9 108 11 7 1 00 1 0 1 32 60 1 15 116 1 00 105 111 46 h > \\ n \\n i n t SP m a i n ( ) \\n { 10 4 62 10 1 0 105 11 0 116 32 109 97 105 110 40 41 10 123 \\ n SP SP SP SP p r i n t f ( II h e 1 1 0 32 32 3 2 3 2 112 114 105 110 116 10 2 40 34 104 101 108 1 。 SP `, 。 r 1 d \ n II ) \\n SP\n108 111 44 32 119 111 114 108 32\n\\n\n图 1- 2 he ll o . c 的 ASCII 文本表示\nhello.c程序是以字节序列的方式储存在文件中的。每个字节都有一个整数值,对应千某些字符。例如,第一个字节的整数值是35,它对应的就是字符"#"。第二个字节的整数值为105,它对应的字符是\u0026rsquo;i\u0026rsquo;\u0026lsquo;依此类推。注意,每个文本行都是以一个看不见的换行符\u0026rsquo;\\n\u0026rsquo;来结束的,它所对应的整数值为10。像hello.c这样只由ASCII字符构成的文件称为文本文件,所有其他文件都称为二进制文件。\nhello.c的表示方法说明了一个基本思想:系统中所有的信息-—-包括磁盘文件、内存中的程序、内存中存放的用户数据以及网络上传送的数据,都是由一串比特表示的。区分不同数据对象的唯一方法是我们读到这些数据对象时的上下文。比如,在不同的上下文中,一个同样的字节序列可能表示一个整数、浮点数、字符串或者机器指令。\n作为程序员,我们需要了解数字的机器表示方式,因为它们与实际的整数和实数是不同的。它们是对真值的有限近似值,有时候会有意想不到的行为表现。这方面的基本原理将在第2章中详细描述。\n豆日C编程语言的起源\nC语言是贝尔实验室的DennisRitchie于1969年~1973年间创建的。美国国家标准学会(AmericanNationalStandardsInstitute,ANSI)在1989年颁布了ANSIC的标准,后来C语言的标准化成了国际标准化组织(InternationalStandardsOrganization,ISO)的责任。这些标准定义了C语言和一系列函数库,即所谓的C标准库。Kernighan和Ritchie在他们的经典著作中描述了ANSIC,这本著作被人们满怀感情地称为\u0026quot;K\u0026amp;R\u0026quot;[61]。用凡tchie的话来说[92],C语言是“古怪的、有缺陷的,但同时也是一个巨大的成功”。为什么会成功呢?\n-C语言与Unix操作系统关系密切。C从一开始就是作为一种用于Unix系统的程序语言开发出来的。大部分Unix内核(操作系统的核心部分),以及所有支撑工具和函数库都是用C语言编写的。20世纪70年代后期到80年代初期,Unix风行于高等院校,许多人开始接触C语言并喜欢上它。因为Unix几乎全部是用C编写的,它可以很方便地移植到新的机器上,这种特点为C和Unix嬴得了更为广泛的支持。\ne有其他编码方式用于表示非英语类语言文本。具体讨论参见2.1.4节的旁注。\n圈嗣严l\n笫1章计算机系统漫游3\n-C语言小而简单。C语言的设计是由一个人而非一个协会掌控的,因此这是一个简洁明了、没有什么冗赘的设计。K\u0026amp;.R这本书用大量的例子和练习描述了完整的C语言及其标准库,而全书不过261页。C语言的简单使它相对而言易于学习,也易于移植到不同的计算机上。 -C语言是为实践目的设计的。C语言是设计用来实现Unix操作系统的。后来,其他人发现能够用这门语言无障碍地编写他们想要的程序。\nC语言是系统级编程的首选,同时它也非常适用于应用级程序的编写。然而,它也并非适用于所有的程序员和所有的情况。C语言的指针是造成程序员困惑和程序错误的一个常见原因。同时,C语言还缺乏对非常有用的抽象的显式支持,例如类、对象和异常。像C++和Java这样针对应用级程序的新程序语言解决了这些问题。\n2程序被其他程序翻译成不同的格式 # hello程序的生命周期是从一个高级C语言程序开始的,因为这种形式能够被人读懂。然而,为了在系统上运行hello.c程序,每条C语句都必须被其他程序转化为一系列的低级机器语言指令。然后这些指令按照一种称为可执行目标程序的格式打好包,并以二进制磁盘文件的形式存放起来。目标程序也称为可执行目标文件。\n在Unix系统上,从源文件到目标文件的转化是由编译器驱动程序完成的:\nlinux\u0026gt; gee-o hello hello.e\n在这里,GCC编译器驱动程序读取源程序文件hello.c,并把它翻译成一个可执行目标文件hello。这个翻译过程可分为四个阶段完成,如图1-3所示。执行这四个阶段的程序(预处理器、编译器、汇编器和链接器)一起构成了编译系统(compilationsystem)。\nprintf.o\n图1-3 编译系统\n预处理阶段。预处理器(cpp)根据以字符#开头的命令,修改原始的C程序。比如hello.c中第1行的五nclude\u0026lt;s七dio.h\u0026gt;命令告诉预处理器读取系统头文件stdio.h的内容,并把它直接插入程序文本中。结果就得到了另一个C程序,通常是以.i作为文件扩展名。 编译阶段。编译器(eel)将文本文件hello.i翻译成文本文件hello.s,它包含一个汇编语言程序。该程序包含函数main的定义,如下所示:\nmain:\n2 subq $8, %rsp 3 mo v l $ . LCO, %edi 4 ca ll puts 5 movl $0, %eax 6 addq $8, %rsp 7 ret 定义中2~7行的每条语句都以一种文本格式描述了一条低级机器语言指令。汇编语言是非常有用的,因为它为不同高级语言的不同编译器提供了通用的输出语\n言。例如,C编译器和Fortran编译器产生的输出文件用的都是一样的汇编语言。\n汇编阶段。接下来,汇编器(as)将hello.s翻译成机器语言指令,把这些指令打包成一种叫做可重定位目标程序(relocatableobjectprogram)的格式,并将结果保存在目标文件hello.o中。hello.o文件是一个二进制文件,它包含的17个字节是函数main的指令编码。如果我们在文本编辑器中打开hello.o文件,将看到一堆乱码。 -链接阶段。请注意,hello程序调用了printf函数,它是每个C编译器都提供的标准C库中的一个函数。printf函数存在于一个名为printf.o的单独的预编译好了的目标文件中,而这个文件必须以某种方式合并到我们的hello.o程序中。链接器Cid)就负责处理这种合并。结果就得到hello文件,它是一个可执行目标文件 (或者简称为可执行文件),可以被加载到内存中,由系统执行。\n田日GNU项目\nGCC是GNU(GNU是GNU\u0026rsquo;sNotUnix的缩写)项目开发出来的众多有用工具之一。GNU项目是1984年由RichardStallman发起的一个免税的慈善项目。该项目的目标非常宏大,就是开发出一个完整的类Unix的系统,其源代码能够不受限制地被修改和传播。GNU项目已经开发出了一个包含Unix操作系统的所有主要部件的环境,但内核除外,内核是由Linux项目独立发展而来的。GNU环境包括EMACS编辑器、GCC编译器、GOB调试器、汇编器、链接器、处理二进制文件的工具以及其他一些部件。GCC编译器已经发展到支持许多不同的语言,能够为许多不同的机器生成代码。支持的语言包括C、C++、Fortran、Java、Pascal、面向对象C语言(Objective-C)和Ada。\nGNU项目取得了非凡的成绩,但是却常常被忽略。现代开放源码运动(通常和Linux联系在一起)的思想起源是GNU项目中自由软件(freesoftware)的概念。(此处的free为自由言论(freespeech)中的“自由”之意,而非免费啤酒(freebeer)中的“免费”之意。)而且,Linux如此受欢迎在很大程度上还要归功于GNU工具,它们给Linux内核提供了环境。\n3了解编译系统如何工作是大有益处的 # 对于像hello.c这样简单的程序,我们可以依靠编译系统生成正确有效的机器代码。但是,有一些重要的原因促使程序员必须知道编译系统是如何工作的。\n优化程序性能。现代编译器都是成熟的工具,通常可以生成很好的代码。作为程序员,我们无须为了写出高效代码而去了解编译器的内部工作。但是,为了在C程序中做出好的编码选择,我们确实需要了解一些机器代码以及编译器将不同的C语旬转化为机器代码的方式。比如,一个switch语句是否总是比一系列的辽-else语句高效得多?一个函数调用的开销有多大?while循环比for循环更有效吗?指针引用比数组索引更有效吗?为什么将循环求和的结果放到一个本地变量中,会比将其放到一个通过引用传递过来的参数中,运行起来快很多呢?为什么我们只是简单地重新排列一下算术表达式中的括号就能让函数运行得更快? 在第3章中,我们将介绍x86-64,最近几代Linux、Macintosh和Windows计算机的机器语言。我们会讲述编译器是怎样把不同的C语言结构翻译成这种机器语言的。在第\n5章中,你将学习如何通过简单转换C语言代码,帮助编译器更好地完成工作,从而调整C程序的性能心在第6章中,你将学习存储器系统的层次结构特性,C语言编译器如何将数组存放在内存中,以及C程序又是如何能够利用这些知识从而更高效地运行。\n-理解链接时出现的错误。根据我们的经验,一些最令人困扰的程序错误往往都与链接器操作有关,尤其是当你试图构建大型的软件系统时。比如,链接器报告说它无法解析一个引用,这是什么意思?静态变量和全局变量的区别是什么?如果你在不同的C文件中定义了名字相同的两个全局变量会发生什么?静态库和动态库的区别是什么?我们在命令行上排列库的顺序有什么影响?最严重的是,为什么有些链接错误直到运行时才会出现?在第7章中,你将得到这些问题的答案。 -避免安全漏洞。多年来,缓冲区溢出错误是造成大多数网络和Internet服务器上安全漏洞的主要原因。存在这些错误是因为很少有程序员能够理解需要限制从不受信任的源接收数据的数量和格式。学习安全编程的第一步就是理解数据和控制信息存储在程序栈上的方式会引起的后果。作为学习汇编语言的一部分,我们将在第3章中描述堆栈原理和缓冲区溢出错误。我们还将学习程序员、编译器和操作系统可以用来降低攻击威胁的方法。\n1.4处理器读并解释储存在内存中的指令\n此刻,hello.c源程序已经被编译系统翻译成了可执行目标文件hello,并被存放在磁盘上。要想在Unix系统上运行该可执行文件,我们将它的文件名输入到称为shell的应用程序中:\nlinux\\\u0026gt; ./hello hello, world linux\\\u0026gt; shell 是一个命令行解释器, 它输出一个提示符, 等待 输入一 个命令行, 然后执行 这个命令。如果该命令行的 第一个单词不是 一个内置的 s hell 命令, 那么 shell 就会假设这是一个可执行文件的名字, 它将加载并运行这个文件。所以在此 例中, s hell 将加载并运行he ll o 程序 , 然后等待程序终止。he ll o 程序在屏 幕上输出它的消息,然 后终止。s hell 随后输出一个提示符,等待下一个输入的命令行。 4. 1 系统的硬件组成\n为了理解运行 he ll o 程序时发 生了什么, 我们需 要了解一个典型系统的硬 件组织 , 如图 1-4 所示。这张图是近期 In tel 系统产品族的模型, 但是所有其他系统也有相同的外观和特性。现在不要担心这张图很复杂 我们将 在本书分阶段对其进行详尽的介绍 。\n总线\n贯穿整个系统的是一组电子管道,称作总线,它携带信息字节并负责在各个部件间传 递。通常总线被设计成传 送定长的字节块, 也就是宇( w or d ) 。字 中的字节数(即字长)是一个基本的系统参 数, 各个系统中都不尽相同。现在的大多数机器字长要么是 4 个字节( 32 位), 要么是 8 个字节( 64 位)。本书中, 我们不对字长做任何固定的假设。相反,我们 将在 需要明确定义的上下文中具 体说明一个 ”字” 是多大。\n2. 1/ 0 设 备\n1/ 0 ( 输入/输出)设备是系统与外部世界的联 系通道。我们的示例系统 包括四个 1/ 0 设备:作为用户输入的键盘和鼠标,作为用户输出的显示器,以及用于长期存储数据和程序 的磁盘驱动器(简单地说就是磁盘)。最开始, 可执行 程序 he l l o 就存放在磁盘上。\n每个 1/ 0 设备都通过一个控制 器或适配器与 1/ 0 总线相连。控制器 和适配器之间的区\n别主要在千它们的封装方式 。控制器是 1/ 0 设备本身或者系统的主印制电路板(通常称作主板)上的芯片组。而适配器则是一块插在主板插槽上的卡。无论如何,它们的功能都是 在 1/ 0 总线和 1/ 0 设备之间传递信息 。\n图 1-4 一个典型系统的硬件组成\nCPU: 中央处理单元; ALU: 算木/逻样单元; PC, 程序计数器; USB: 通用串行总线\n第 6 章会更多地说明 磁盘之类的 1/ 0 设备是如何工作的。在第 10 章中, 你将学习如何在应用程序中利用 Unix 1/ 0 接口访问设备。我们将特别关注网络类设备, 不过这些 技术对于其他设备来说也是通用的。\n3. 主存\n主存是一个临时存储设备,在处理器执行程序时,用来存放程序和程序处理的数据。从 物理上来说,主 存是由一组动态随机存取存储器( DRAM) 芯片组成的。从逻辑上来说 , 存储器是一个线性的字节数组,每 个字节都有其 唯一的地址(数组索引), 这些地址是从 零开始的。一般来说,组成程序 的每条机器指令都由不同数量的字节构成。与 C 程序变量相对应的数据项的大小是根据类型变化的。比如, 在运行 Linux 的 x86-64 机器上, s hor t 类型的数据 需要 2 个字节, i 工 和 fl oa t 类型需要 4 个字节 , 而 l ong 和 doubl e 类型需要 8 个字节。\n第 6 章将具体介绍存储器技术, 比如 DRAM 芯片是如何工作的, 它们又是 如何组合起来构成主存的。\n4 处理器\n中央处理 单元 (CPU ) , 简称处理器,是解释(或执行)存储在主存中指令的引擎。处理器的核心是一个大小为一个字的存储设备(或寄存器),称为程序计数 器 ( PC) 。在任何时刻, PC 都指向主存中的某条机器语言指令(即含有该条指令的地址)e。\n从系统通电开始,直到系统断电,处理器一直在不断地执行程序计数器指向的指令, 再更新程序计数器, 使其指向下一条指令。处 理器看上去 是按照一个非常简单的指令 执行模型来操作的,这个模型是由指令集架构决定的。在这个模型中,指令按照严格的顺序执 行,而执行一条指令包含执行一系列的步骤。处理器从程序计数器指向的内存处读取指\n8 PC 也普遍地被用来作为“个 人计算机” 的 缩写。然而,两者之间的 区别应该 可以很 清楚地从上下文中看出来 。\n令, 解释指令中的位 , 执行该指令指示的简单操作 , 然后更新 PC , 使其指向下一条指令, 而这条指令并 不一定和在内存中刚刚 执行的指令相邻 。\n这样的简单操作并 不多, 它们围绕着主存、 寄存 器文件 ( reg is ter fi le ) 和算术/逻辑单元( ALU ) 进行。寄存器文件是一个小的存储设备, 由一些单个字长的寄存器组成, 每个寄存器都有 唯一的名字。ALU 计算新的数据和地址值。下面是一些简单操作的例子,\nCPU 在指令的要求下 可能会执行这些操作 。\n加载: 从主存复 制一个字节或者一个 字到寄存 器, 以覆盖寄存器原来的内容。\n存储: 从寄存器复制一 个字节或者一个 字到主存的 某个位置,以 覆盖这个位置上原来的内容 。\n操作: 把两个寄存器的内 容复制到 ALU , ALU 对这两个字做算术运算,并 将结果存放到一个寄存器中,以覆盖该寄存器中原来的内容。\n跳转: 从指令本身中抽取一个字, 并将这个字复制到程序计数器( PC) 中, 以覆盖\nPC 中原来的值。\n处理器看上去是它的 指令集架构的简单实现 , 但是实际上 现代处理 器使用了非常复杂的机制来加速程序的执行。因此,我们将处理器的指令集架构和处理器的微体系结构区分 开来:指令集架构描述的是每条机器代码指令的效果;而微体系结构描述的是处理器实际 上是如何实现的 。在第 3 章研究机器代码时 , 我们考虑的是 机器的指令 集架构所提供的抽象性。第 4 章将更详 细地介绍处 理器实际上是 如何实现的。第 5 章用一个模型说明现代处理器是 如何工作的, 从而能预测和优化机器语言程序的性能 。\n1. 4. 2 运行 he l l o 程序\n前面简单描述了系统的硬件组成和操作,现在开始介绍当我们运行示例程序时到底发 生了些什么 。在这里必须省略很多 细节 , 稍后会做补充, 但是现在我们 将很满意千这种整体上的描述。\n. 初始时, shell 程序执行它的指令 , 等待我们输入一个命令。当我们在键盘上输入字符串\n\u0026quot; . / he l l o\u0026quot; 后, s hell 程序将字符逐一 读入寄存器, 再把它存放到内存中, 如图 1- 5 所示。\nCPU\n寄存器文件\n二三\n1 系统总线\n`七-石,..•.•. # `` hello',\ng:矿飞广 ooo 凡\n鼠标 键盘用户输入\n\u0026ldquo;h e l l o\u0026rdquo;\n显 示器\n扩展槽, 留待\n言\n图 1 - 5 从键盘上读取 he ll o 命令\n当我们在键 盘上敲回车键时, s h ell 程序就知道我们巳经结束了命令的输入。然后s hell 执行一系列指令来加载可执行的 he l l o 文件 , 这些指令将 h e 荨 o 目标文件中的代码和数据从磁盘复制到 主存。数据包括最终 会被输出的字符串 \u0026quot; h e l l o , wor l d \\ n\u0026quot; 。\n利用直接存储 器存 取CDMA , 将在第 6 章中讨论)技术, 数据可以 不通过处理器而直接从磁盘到达主存 。这个步骤如图 1-6 所示。\nCPU\n兰 # 总线接口\n\u0026ldquo;hello, wor l d\\ n\u0026rdquo; he l l o 代码\n.f.\n鼠标 键盘 显示器 存储在磁盘上的he l l o\n可执行文件\n图 1-6 从磁盘加载可执行文件到主存\n一旦目标文件 h e l l o 中的代码和数 据被加载到 主存 ,处 理器就开始执行 h e ll o 程序的 ma i n 程序中的机器语 言指令。这些指令将 \u0026quot; he l l o , wor l d \\ n\u0026quot; 字符串中的字节从 主存复制到寄存器文件,再从寄存器文件中复制到显示设备,最终显示在屏幕上。这个步骤如 图 1-7 所示。\nCPU\n\u0026ldquo;hello, wo r l d \\ n\u0026rdquo; he l l o 代码\n鼠标\n图 1-7 将输出字符串从存储器写到显示器\n1. 5 高速缓存至关重要\n这个简单的示 例揭元了一个重要的问题, 即系统花费了大 量的时间把信息从一个地方挪到另一个地方。 he l l o 程序的机器指 令最初是存 放在磁 盘上, 当程序 加载时 , 它们被复制到主存 ; 当处理器运行 程序时, 指令又从 主存复制到处理器。相似地, 数据串 \u0026quot; h e l ­ lo, wor l d / n\u0026quot; 开始时 在磁盘上,然 后被复制到主存, 最后从主存上复制到显示设备。从程序员的角度来看,这些复制就是开销,减慢了程序“真正”的工作。因此,系统设计者 的一个主要目标就是使这些复制操作尽可能快地完成。\n根据机械原理,较大的存储设备要比较小的存储设备运行得慢,而快速设备的造价远高 于同类的低速设备。比如说 , 一个典型系统上的 磁盘驱动器可能 比主存大 1000 倍, 但是对处理器而言,从磁盘驱动器上读取一个字的时间开销要比从主存中读取的开销大 1000 万倍。类似地 ,一个典型的寄存器文 件只存储几百字节的信息,而 主存里可存放几十亿字\n节。然而 ,处 理器从寄存器文件中读数据比从主存中 读取几 乎要快 100 倍。更麻烦的是, 随着这些年半导体技术的进步,这种处理器与主存之间的差距还在持续增大。加快处理器 的运行速度比加快主存的运行速度要容易和便宜得多。\n针对这种处理器与主存之间的差异,系统设计者采用了更小更快的存储设备,称为高速缓存存储 器( ca ch e memory, 简称为 cache 或高速缓存), 作为暂时的 集结 区域, 存放处理器近期可能会需 要的信息 。图 1-8 展示了一个典型系统中的 高速缓存 存储器。位于处理器芯片上的 Ll 高速缓存的容量可以达到 数万字节, 访问速度几 乎和访问 寄存器文件一样快。一个容量为数十万 到数百万 字节的更大的 L2 高速缓 存通过一 条特殊的 总线连接到处理器。进程访问 L2 高速缓存的时间要比访问 Ll 高速缓存的时间 长 5 倍, 但是这仍然比访问主存的时间 快 5 ~ 10 倍。Ll 和 L2 高速缓存是用一种叫做 静态随 机访问存储 器( S R AM ) 的硬件技术实现的。比较新的、处理能力更强大的系统甚至有 三级高速缓存: Ll 、 L 2 和\nL3。系统可以 获得一个很大的存储器 , 同时访问速度也很快 ,原 因是利用了高速缓存的 局部性原理,即程序具有访问局部区域里的数据和代码的趋势。通过让高速缓存里存放可能 经常访间的数据,大部分的内存操作都能在快速的高速缓存中完成。\nCPU 芯片\n图 1-8 高速缓存存储骈\n本书得出的重要结论之一就是,意识到高速缓存存储器存在的应用程序员能够利用高速缓 存将程序的 性能提高一个数量级。你将在第 6 章里学习这些重要的设备以及如何利用它们。\n6 存储设备形成层次结构\n在处理器和一个较大较慢的设备(例如主存)之间插入一个更小更快的存储设备(例如高速缓存)的想法已经成为一个普遍的观念。实际上,每个计算机系统中的存储设备都被\n组织成了一个存储器层 次结构, 如 图 1 - 9 所示 。在这个层 次结 构 中 , 从 上 至 下 , 设 备 的 访间速度越来越慢、容量越来越大,并且每字节的造价也越来越便宜。寄存器文件在层次结 构中位于最顶部, 也 就 是 第 0 级 或记为 LO。 这 里 我 们 展 示 的 是 三 层 高 速 缓 存 Ll 到 L3 , 占 据 存 储 器 层 次 结 构 的 第 1 层 到 第 3 层 。 主存在第 4 层 ,以 此 类 推 。\n更小更快\n( 每字节)\n更贵的\n存储设备\n更大\n更慢\n(每字节)\nL4:\nL2 : L 2高速缓存( SRAM )\nL3: L3高速缓存(SRAM)\n主存\n( D RAM )\n} CP U寄存器保存来自高速缓存存储器的字\n} LI 高速缓存保存取自L2高速缓存的高速缓存行\n} L2 高速缓存保存取自L3高速缓存的高速缓存行\n} L3 高速缓存保存取自主存的高速缓存行\n} 主存保存取自本地磁盘\n更便宜的\n存储设备\nL6:\nL5:\n本地二级存储\n( 本地磁盘)\n远程二级存储\n(分布式文件系统,Web服务器)\n图 1-9 一个存储器 层次结构的示例\n的磁盘块\n本地磁盘保存取自远程网络服务器上磁盘的文件\n存储 器层次结构的 主要思 想 是 上一 层 的存 储 器 作 为低一层存储器的高速缓存。因此, 寄 存 器 文 件 就 是 L1 的 高 速 缓 存 , L1 是 L 2 的 高 速 缓 存 , L 2 是 L3 的高速缓存, L3 是 主存的高速缓存, 而 主存又是磁盘的高速缓存。在某些具有分布式文件系统的网络系统中, 本地 磁 盘 就 是 存 储 在 其 他 系 统 中 磁 盘 上 的 数 据 的 高 速 缓 存 。\n正 如 可以运用不同的高速缓存的知识来提高程序性能一样, 程 序 员 同 样 可 以 利 用 对 整个 存 储 器 层 次 结 构 的 理 解 来 提 高 程序性能。第 6 章 将 更 详 细 地 讨 论 这个问 题 。\n7 操作系统管理硬件 # 让我们回到 hell o 程序的例子。当 shell 加载和运行 he ll o 程序时,以 及 he l l o 程 序 输出 自 己的 消息时, she ll 和 he ll o 程 序 都 没 有\n应用程序\n直 接 访 问 键 盘 、显 示 器 、 磁 盘 或 者 主存。取而\n操作系统\n代 之 的 是 ,它 们 依 靠 操 作 系统 提 供 的 服 务 。 我\n}软件\n们可以把操作系统看成是应用程序和硬件之间\n处理器 I 主存 I I /0 设备 }硬件\n插入的一层软件,如 图 1 - 1 0 所 示 。 所 有 应 用 图 1-10 计算机系统的分层视图程序对硬件的操作尝试都必须通过操作系统 。 进程\n操作系统有两个基本功能: (1 ) 防止硬\n件被失控的应用程序滥用; ( 2 ) 向应用程序\n虚拟内存\n提供简单一致的机制来控制复杂而又通常大 文件不 相 同 的 低 级 硬 件 设 备 。 操 作 系 统 通 过 几 个\n基 本 的 抽 象 概 念(进 程 、 虚拟 内存 和 文件)来 处理器 主存 I/ 0 设备\n实 现 这 两 个 功 能 。 如 图 1-11 所 示 ,文 件 是 对 图 1-11 操作系统提供的抽象表示\n1/0 设备的抽象表示, 虚拟内存是对主存和磁盘 1/ 0 设备的抽象表示, 进程则是对处理器、主存 和 1/ 0 设备的抽象表示。我们将依次讨论每种抽象表示。\nm Uni x、Pos ix 和标准 Uni x 规范\n20 世 纪 60 年代是大型 、复杂操 作 系统 盛行的年代,比 如 IB M 的 OS/ 360 和 Honey­ well 的 Multics 系统 。 OS / 36 0 是历 史上 最成功的软件项目之 一, 而 Mult ics 虽 然持 续存在了多年 ,却 从 来没 有被广 泛应 用过。 贝 尔 实验 室曾 经是 Mult ics 项 目的最初参与 者, 但是因为 考虑到该项目的 复杂性和缺乏进展而 于 1 969 年退出。 鉴于 M utics 项目 不愉 快的 经历 ,一 群贝 尔 实验室的 研 究人 员——- Ken T hompson 、Dennis Ritch 比、 Do ug Mell­\nroy 和 J oe Ossanna, 从 1969 年开始在 DEC PDP-7 计算机上完全 用机 器语 言编写 了一个简单得 多的操作系统。这个新 系统 中的很 多思想,比 如 层次文件 系统、作为 用 户级 进 程的 she ll 概念,都 是来自 于 Multics , 只不过 在一个更 小、 更简单 的程序 包里 实现 。 1 970 年, Brian Kernighan 给新系统 命 名为 \u0026quot; U nix\u0026quot; , 这也是 一个双关语 , 暗指 \u0026quot; Multics\u0026quot; 的复杂性 。1973 年用 C 重新 编写其内核 , 1 974 年, U nix 开始正式对外发布[ 93] 。\n贝 尔实 验室以 慷 慨的条件向学校 提 供源代码, 所以 U nix 在 大专院校里获得 了很 多支持并得以 持续发 展 。最有影响的工作发 生在 20 世 纪 70 年代晚期到 80 年代早期,在 美国 加州大 学伯 克利分校 ,研 究人 员在 一 系列发 布版本中增加了虚拟内存 和 Internet 协议, 称为 Unix 4. xBSD(Berkeley Software Dist ribution ) 。与 此同 时, 贝 尔 实验 室也 在发布自己 的版本, 称为 S ystem V U nix 。 其他厂 商的版本, 比 如 S un Micros ystems 的\nSolaris 系统,则是 从 这些原 始的 BSD 和 Syst em V 版本中衍 生而来。\n20 世 纪 80 年代中期 , U nix 厂 商试 图通 过加入新的、往往不兼容的特性来使 它们 的程序 与众不 同 ,麻 烦也 就随之而来了。 为 了 阻止这种趋势, IE E E ( 电 气和电子工程师协会)开始努力标 准化 U nix 的开发 , 后来由 Richard S tallm an 命名为 \u0026quot; Posix\u0026quot; 。结果就得到 了一 系列 的标准, 称作 Posix 标准。 这套标准 涵盖 了很 多 方 面, 比如 Unix 系统调用的 C 语言接口、s hell 程序和工具、线程及网络 编程。最近, 一个被 称为 “ 标准 U nix 规范” 的独立标 准化工作 已经与 P osix 一起创 建了统 一的 Unix 系统标准。这些标准化 工作的结果是 U nix 版本之间的 差异已经基本消失。\n1. 7. 1 进程 # 像 he ll o 这样的程序在现代系统上运行时,操作 系统会提供一种假象, 就好像系统上只有这个程序在运行。程序看上去是独占地使用处理器、主存和 I/ 0 设备。处理器看上去就像在不间断地一条接一条地执行程序中的指令,即该程序的代码和数据是系统内存中唯一的对 象。这些假象是通过进程的概念来实现的, 进程是计算机科学中最重要和最成功的概念之一。\n进程是操作系统对一个正在运行的程序的一种抽象。在一个系统上可以同时运行多个 进程 , 而每个进程都好像在独占地使用硬件。而并发运行,则 是 说 一 个 进程的指令和另一个进程的 指令是交错执行的。在大多数系统中, 需 要 运行的进程数是多于可以运行 它们的CPU 个数的。传统系统在一个时刻只能执行一个程序, 而先进的 多核处理器同时能够执行多个程序。无论是在单核还是多核系统中, 一个 CP U 看上去都像是在并发地执行多个进程 , 这是通过处理器在进程间切换来实现的。操作系统实现这种交错执行的机制称为上下文切换。为了简化讨论,我 们 只 考虑包含一个 CPU 的单处理器 系统的情况。我们会在\n9. 2 节中讨论多 处理 器系统。 操作系统保持跟踪进 程运行所需 的所有状态信息。这种状态,也 就是上下文, 包括许多信息 , 比如 PC 和寄存器文件的 当前值, 以及主存的内 容。在任何 一个时刻, 单处理器系统都只能执行一个进程的代码。当操作系统决定要把控制权从当前进程转移到某个新进 程时,就会进行上下文切换,即保存当前进程的上下文、恢复新进程的上下文,然后将控 制权传递到新进程。新进程就 会从它上次停止的地方开始。图 1-1 2 展示了示例 he l l o 程序运行场景的基本理念。\n示例场景中有两个并发的进程: s hell 进程和 he ll o 进程。最 开始,只 有 s hell 进程在\n运行, 即等待命令行上的输入。当我们让 它运行 he l l o 程序时 , s hell 通过调用一个专门的函数,即系统调用,来执行我们的请求,系统调用会将控制权传递给操作系统。操作系 统保存 s hell 进程的上下 文, 创建一个新的 he ll o 进程及其上下文, 然后将控制权 传给新的 h e l l o 进程。 he ll o 进程终止后 ,操 作系统恢 复 s hell 进程的上下文, 并将控制权传回给它, s hell 进程会继续 等待下一个命令行 输入。\n如图 1-1 2 所示, 从一个进程到另 一个进程的转换是由操作 系统内核 ( kern el) 管理的。内核是操作系统代码常驻主存的部分。当应用程序需要操作系统的某些操作时,比如读写 文件, 它就执行一条特殊的 系统调 用 ( s ys t em call) 指令, 将控制权传递给内核。然后内核执行被请求的操作并返回应用程序。注意,内核不是一个独立的进程。相反,它是系统管 理全部进程所用代码和数据结构的集合。\n, # 进程A I 进程B\nI\n- - -\nread - -►- -\n-\u0026mdash;\u0026rsquo;: - -- 产 二/内用核户代码\n磁盘中断\u0026ndash;► - \u0026ndash; -夕- — 十,一 _J\n用户_代码\n从 r e a d 返回 - -► 一 \u0026ndash;\u0026rsquo;t\n呾 嘎 _ } 上下文切换\nI, 用户代码\n-- : - \u0026ndash;·- \u0026mdash;\u0026ndash;\n图1-21进程的上下文切换\n实现进程这个抽象 概念需要低级硬件 和操作 系统软件之间的紧密合 作。我们将在第 8\n章中揭示这项工作的原理,以及应用程序是如何创建和控制它们的进程的。\n1. 7. 2 线程\n尽管通常我们认 为一个进程只有单 一的控制流,但 是在现代系统中 , 一个进程实际 上可以由多个称为线程的执行单元组成,每个线程都运行在进程的上下文中,并共享同样的 代码和全局数据。由于网络服务器中对并行处理的需求,线程成为越来越重要的编程模 型,因为多线程之间比多进程之间更容易共享数据,也因为线程一般来说都比进程更高 效。当有多处理器可用的时候,多线程也是一种使得程序可以运行得更快的方法,我们将 在 1. 9. 2 节中讨论这个问 题。在第 1 2 章中, 你将学习并发的基本概念, 包括如何写线 程化的程序。\n7. 3 虚拟内存\n虚拟内存是一个抽象概念,它为每个进程提供了一个假象,即每个进程都在独占地使用 主存。每个进程看到的内存都是一致的 , 称为虚拟地址空间。图 1-13 所示的是 Linux 进程的\n虚拟地址空间(其他 Unix 系统的设计也与此类似)。在Linux 中, 地址空间最上 面的区域是保留给操作系统中的代码和数据的 , 这对所有进程来说都是一样。地址 空间的底部区域存放用户进程定义的代码和数据。请注意,图中的地址是从下往上增大的。\n内核虚拟内存 f用户代码不 可见的内存\npr i n t f 函数\n运行时堆\n( 在运行时由ma l l oc 创 建的)\n读泻数据\n只读的代码和数据\n图 1-13 进程的虚拟地址空间\n每个进程看到的虚拟地址空间由大僵准确定义的区构成 , 每个 区都有专门的功能。在本书的后续章节你将学到更多有关这些区的知识,但是先简单了解每一个区是非常有益 的。我们从最低的地址 开始, 逐步向上介绍 。\n·程序代码和数据。对所有的进程来说,代码是从同一固定地址开始,紧接着的是和 C 全局变量相对应的数据位置。代码和数据 区是直接按照可执行目标文件的内容初始化的 , 在示例中就是可执行 文件 h e ll o。在第 7 章我们研究链接和加载时, 你会学习更多有关地址空间的内容。\n堆。代码和数据区后紧随着的是运行时堆。代码和数据区在进程一开始运行时就被 指定了大小 , 与此不同, 当调用像 ma l l o c 和 fr e e 这样的 C 标准库函数时, 堆可以在运行时动态地 扩展和收缩。在第 9 章学习管理虚拟内存时, 我们将 更详细地研究堆。 共享库。大约在地址空间 的中间部分是一块用来存放像 C 标准库和数学库这样的共享库的代码和数据的 区域。共享库的概念非常强大 , 也相当难懂。在第 7 章 介绍动态链接时,将学习共享库是如何工作的。 栈。位 千用户虚拟地址空间 顶部的是用 户栈, 编译器用它来实 现函数调用。和堆一样,用 户栈在程序执行期间 可以动态地扩展和收缩。特别地 , 每次我们调用一个函数时, 栈就会增长; 从一个函数返回时 ,栈就会 收缩。在第 3 章中将学习编译器是如何使用栈的 。 内核虚拟 内存。地址空间 顶部的区域是为内核保留的。不允许应用程序读写这个区域的内容或者直接调用内核代码定义的函数。相反,它们必须调用内核来执行这些 操作。 虚拟内存的运作需要硬件和操作系统软件之间精密复杂的交互,包括对处理器生成的每 个地址的硬件翻译。基本思想是把一个进程虚拟内存的内容存储在磁盘上,然后 用主存作为磁盘的高速缓存。第 9 章将解 释它如何工作,以 及为什么对现代系统的运行如此重要。\n1. 7. 4 文件 # 文件就是字节序列,仅 此 而巳。每个 1/ 0 设备, 包括磁盘、键盘、显示器, 甚至网络, 都可以 看成是文件。系统中的所有输入输出都是通过使用一小组称为 Unix 1/ 0 的系统函数调用读写文件来实现的。\n文件这个简单而精致的概念是非常强大的,因为它向应用程序提供了一个统一的视 图,来 看待系统中可能含有的所有各式各样的 1/ 0 设备。例如,处 理 磁 盘文件内容的应用程序员可以非常幸福,因为他们无须了解具体的磁盘技术。进一步说,同一个程序可以在 使用不同磁盘技术的不同系统上运行。你将在第 10 章中学习 U nix 1/ 0 。\n田日Linu x 项目\n1991 年 8 月 , 芬兰研 究生 Linus T or valds 谨慎地发布了一个新的 类 U nix 的操作 系统内核, 内容如 下。\n来自: tor valds@ klaava. H elsinki. Fl ( Linus Benedict T orvalds)\n新闻 组: comp. os. minix\n主题 : 在 minix 中你最想看 到什么? 摘要: 关于我的 新操作系统的 小调查\n时间: 1 991 年 8 月 25 日 20 : 57 : 08 GMT\n每个使 用 minix 的朋 友, 你们好。\n我 正在做一个(免费的)用在 386 (486) AT 上的操作系统(只是 业余爱 好, 它 不会像GNU 那样庞大和专业)。这个想法 自 4 月份就开始酝酿, 现在快要完成 了。 我 希望得到各位对 minix 的任何反馈 意见 , 因为 我的操作系统在 某 些 方 面 与 它相 类似(其 中包 括相同的文件系统的物理设计(因为某些实际的原因))。\n我 现在巳 经移植了 bas h(1. 08) 和 gcc( l. 40 ) , 并且看上去能运行。这意味着我需要\n几个月的 时间 来让它 变得 更实用 一些, 并且, 我想 要知 道大 多数 人想要什 么特 性。 欢迎任何建议, 但是我无法保 证 我能 实现它们。:-)\nLinus (t or val ds@kr uuna. hel s i nki . f i )\n就像 Tor valds 所说的, 他 创建 Linux 的起点是 Minix , 由 Andrew S. T anen baum 出\n于教 育目的 开发 的一个操作系统[ 113]。\n接下来,如 他 们所说, 这就成了历 史。 Lin ux 逐 渐发展 成 为 一个技 术和文化现象。通过和 G NU 项 目 的 力 量结合, L inux 项 目 发 展 成 了 一 个 完整 的、符合 Posix 标准的Unix 操作 系统 的版本, 包括内核 和所有 支撑的基础设施。从 手持 设备到 大型 计算机,\nLinux 在 范围如此广 泛的 计算机上得到 了应 用。 IBM 的一个工作 组 甚至把 Linux 移植到了一块腕表中!\n1. 8 系统之间利用网络通信\n系统漫游至此, 我们一直是把系统视为一个孤立的硬件和软件的集合体。实际上,现\n代系统经常通过网络 和其 他系统 连接到一起。从一个单独的 系统来看,网 络可视为一个I/ 0 设备, 如图 1-14 所示。当系统从 主存复制 一串字节到网络适 配器时 , 数据流经过网络到达另一台机器,而不是比如说到达本地磁盘驱动器。相似地,系统可以读取从其他机器 发送来的数据,并把数据复制到自己的主存。\n,CPU 芯片\n寄存器文件\n系统总线 内存总线\n已 、心- i 主存储器\n寸\u0026quot;\u0026quot;\u0026rsquo;. I\n, 扩展槽\n\u0026lsquo;;\n名 忒 夺 f \u0026rsquo; \u0026lsquo;-\u0026ldquo;711守 你郓心; ;\nVO 总线 粗\n怂 .怂Y\n二勹 三三三l\n图 1-14 网络也是一种 1/0 设备\n随着 I n t e r n e t 这样的全球网络的出现 , 从一台主机复制信息 到另外 一台主机已经成为计算机系统最重要的用途之一 。比如, 像电子邮件、即时通信 、万维网、FTP 和 t e l n e t 这样的应用都 是基千网络复 制信息的功能。\n回到 h e ll o 示例, 我们可以使用熟悉的 t e ln e t 应用 在一个远程主机上 运行 h e l l o 程序。.假设用本地主机上的 t e ln et 客户端连接远 程主机上的 t e ln e t 服务器。在我 们登录到远程主机并运行 s h e ll 后, 远端的 s h e ll 就在等待接收输入命令 。此后在远端运行 h e l l o 程序包括如图 1-15 所示的五个 基本步骤。\nI. 用户在键盘上输人 \u0026ldquo;he l l o\u0026rdquo;\n5. 客户端在显示器上打印\u0026quot;hello world\\n\u0026rdquo; 字符串\n2. 客户端向 telnet服务器发送字符串 \u0026ldquo;he l l o\u0026rdquo;\n\u0026mdash;\u0026mdash;\u0026mdash;\u0026mdash;\u0026mdash;\u0026mdash;\u0026ndash;\n4. telnet服务器向客户端发送字符串 \u0026ldquo;he l l o wor l d \\ n\u0026rdquo;\n服务器向 shell发送字符串 "hello , she ll 运行he ll o程序并将输出发送给telnet服务 器 图 1- 15 利用 telnet 通过 网络远程运行 he l l o\n当我们在 t e ln e t 客户端键入 \u0026quot; h e l l o \u0026quot; 字符串并 敲下 回车键后 ,客 户端软件就会将这个字符串发送到 t e ln e t 的服务器。te ln e t 服务器从 网络上 接收到这个字符串后, 会把它传递给远端 s h e ll 程序。接下来 ,远 端 s h e ll 运行 h e l l o 程序 , 并将输出行返回 给 t e l n e t 服务器。最后, t e ln e t 服务器通过网络 把输出串转发给 t e ln e t 客户端 , 客户端就将输出串输出到我们的本 地终端上 。\n这种客户端 和服务器之间交 互的类型在所 有的 网络应用中 是非常典型的。在第 11 章中, 你将学会如何构造网络应用 程序 , 并利用这些知识创建 一个简单的 Web 服务器。\n9 重要主题\n在此,小结一下我们旋风式的系统漫游。这次讨论得出一个很重要的观点,那就是系 统不仅仅只是硬件。系统是硬件和系统软件互相交织的集合体,它们必须共同协作以达到 运行应用程序的最终目的。本书的余下部分会讲述硬件和软件的详细内容,通过了解这些 详细内容,你可以写出更快速、更可靠和更安全的程序。\n作为本章的结束,我们在此强调几个贯穿计算机系统所有方面的重要概念。我们会在 本书中的多处讨论这些概念的重要性。\n9. 1 Amda hl 定 律\nGene Amdahl, 计算领域的早期先锋之一,对 提 升系统 某一部分性能所带来的效果做出了简单却有见地的观察。这个观察被称为 Amdahl 定律CAmdahl\u0026rsquo;s law) 。该定律的主要思想是,当我们对系统的某个部分加速时,其对系统整体性能的影响取决于该部分的重要 性和加速程度。若系统执行某应用程序需要时间为 Told 。 假设系统某部分所需执行时间与该时间的比例为 a , 而该部分性能提升比例为 K 。 即 该 部 分 初 始 所 需 时 间 为 a Told, 现在所\n需 时 间 为Ca T01d) / k。 因 此,总 的 执 行 时间应为\nTnew = 0-a)Told + (aTold) / k = T otd[ O - a ) + a / k]\n由 此 ,可 以计算加速比 S = T0 1d/ T new为\ns = ,. 1 (1. 1)\n举个例子, 考虑这样一种情况, 系统的某个部分初始耗时 比例为 60% (a=O. 6), 其加速比例因子为 3Ck= 3)。则我们可以获 得的加速比为 1/ [ 0. 4+0. 6/ 3] = 1. 67 倍。虽然我们对系统的一个主要部分做出了重大改进,但是获得的系统加速比却明显小于这部分的加速比。这就是\nAmdahl 定律的主要观点一— 要想显著加速整个系统,必须 提升全系统中相当大的部分的速度。\n田 日 表示相对性能\n性能提升最好的表示方法就 是用比 例的形式 T old/ T new , 其中, T old 为原始 系统 所需时间 , T new为修 改后的 系统 所需时间。如 果 有所改进, 则 比 值应 大 于 1 。 我们 用后缀\u0026quot; X \u0026quot; 来表示比 例, 因此, \u0026quot; 2. 2 X \u0026quot; 读作 \u0026quot; 2. 2 倍\u0026quot;。\n表示相对变化更传统的方法是用百分比,这种方法适用于变化小的情况,但其定义 是模糊的。应该等于 100 • ( Told - T new ) / T new, 还是 100 • ( T old - T new) / T old , 还是其他的值? 此外, 它对较大的 变化也 没有太大意 义。与 简单地说性能提升 2. 2 X 相比,“性能提升了 1 20 %\u0026quot; 更难理解。\n练习题 1. 1 假设你是个卡 车 司机, 要将土豆从爱达荷州 的 Boise 运送到 明尼 苏 达州的 Minnea polis , 全程 2500 公里。 在限速范围 内, 你估计平 均速度为 1 00 公里/小时, 整个行程需要 25 个小时。\n你听到 新闻说蒙大拿 州刚 刚取消 了限 速, 这使得行 程中 有 1500 公 里卡 车的 速度可\n以为 150 公里/小时。 那么这 对整个行程的加速比是多少?\n你可以在 www. fasttrucks. com 网站 上为 自 己 的卡车买个 新的 涡轮增 压器。 网站现货供应各种型号,不过速度越快,价格越高。如果想要让整个行程的加速比为 67 X , 那么你必须以多快的速度通过蒙大拿州? 练习题 1. 2 公司的 市场部 向你的客户 承诺, 下 一个 版本的 软件性 能 将改进 2 X 。这项任务被 分配给你。 你已 经 确 认只 有 80 % 的 系统 能 够被改进, 那 么, 这部 分需 要被改进 多少(即 k 取何值)才能 达到整体性能目标?\nAmdahl 定律一个有趣的特殊情况是考虑 K 趋向千00 时的效果。这就意味着, 我们可以取系统的 某一部分将其加速到一个点, 在这个点上,这 部 分 花费的时间可以忽略不计。于是我们得到\ns= =\nCl -a)\n(1. 2)\n举个例子 , 如果 60 %的系统能够加速到不花时间的程度, 我们获得的净加速比将仍只有\n1/ 0. 4=2. 5 X 。\nAmdah l 定律描述了改善任何过程的一般原则。除了可以用在加速计算机系统方面之外,它还可以用在公司试图降低刀片制造成本,或学生想要提高自己的绩点平均值等方 面。也 许它在计算机世界里是最有意义的,在 这里我们常常把性能提升 2 倍 或更高的比例因子。这么高的比例因子只有通过优化系统的大部分组件才能获得。\n9. 2 并发和并行\n数字计算机的整个历史中,有两个需求是驱动进步的持续动力:一个是我们想要计算 机做得更多,另一个是我们想要计算机运行得更快。当处理器能够同时做更多的事情时, 这两个因素都会 改进。我们用的术语并发 ( co ncurr ency) 是一个通用的概念,指一 个同时具有多个活动的系统;而术 语 并行 ( para ll elis m ) 指的是用并发来使一个系统运行得更快。并行可以在计算机系统的多个抽象层次上运用。在此,我们按照系统层次结构中由高到低的 顺序重点强调三个层次。\n线程级并发\n构建在进程这个抽象之上,我们能够设计出同时有多个程序执行的系统,这就导致了 并发。使用线程, 我们甚至能够在一个进程中执行多个控制流。自 20 世纪 60 年代初期出现时间共享以来,计算 机 系统中就开始有了对并发执行的支持。传统意义上, 这种并发执行只是模拟出来的,是 通过使一台计算机在它正在执行的进程间快速切换来实现的, 就好像一个杂耍艺人保待多个球在空中飞舞一样。这种并发形式允许多个用户同时与系统交 互, 例如, 当许多人想要从一个 Web 服务器获取页面时。它还允许一个用户同时从事多个任务, 例如,在 一 个 窗 口 中 开启 Web 浏览器, 在 另一窗口中运行字处理器,同 时 又播放音乐。在以前 ,即 使 处 理 器必须在多个任务间切换, 大多数实际的计算也都是由一个处\n理器来完成的。这种配置称为单处理 器 系统 。\n当构建一个由单操作系统内核控制的多处理 器组成的系统时, 我们就得到了一个多 处理 器 系统。其实从 20 世纪 80 年代开始,在 大规模的计算中就有了这种系统,但是直到最近,随着多核 处理器和超线程 ( h ypert hr eading ) 的出现, 这 种系统 才变得常见。图 1-16 给出了这些不同处理\n器类型的分类。\n所有的处理器\n单处理器\n多处理器\n多核处理器是将多个 CP U ( 称为“核\u0026quot;)集成到一个集成电路芯片上。图 1-17 描 述的是一个\n图 1- 1 6 不同的处理器配置分类 。随着多 核\n处理器和超线程的出现,多处理器\n变得普遍了\n典型多核处理器的组织结 构, 其中微处理器芯片 有 4 个 CP U 核, 每个核都有自己的 Ll 和\nL2 高速缓存 , 其中的 Ll 高速缓存分 为两个部分一 一个保存最 近取到的指令,另一个存放数据。这些核共享更高层次的高速缓存,以及到主存的接口。工业界的专家预言他们能 够将几十个、最终会是上百个核做到一个芯片上。\n处理器封装包\n俨· 一一一一一一一一一一一一一一一一一一一一一一一一一一一一一一一一一一一一一\n1 核0 核3 I\nL3统一的高速缓存(所有的核共享)\nI_ - - - - - - 宁 \u0026mdash;\u0026mdash;\n图1-17 多 核处理器的组织结构。4 个处理器核集成 在一个芯片上\n超线程 , 有时称为同时多 线程 ( simultaneo us multi-threading), 是一项允 许一个 CP U 执行多个控制流的技术。它涉 及 CPU 某些硬件有多 个备份, 比如程序计数器 和寄 存器文件, 而其他的硬件部分只有一份, 比如执行浮点算术运算的 单元。常规的处理器需 要大约20 000 个时钟周期做不同 线程间的转换 ,而超线程 的处理器可以在单个周期的 基础上决定要执行哪一个线 程。这使得 CP U 能够更好地利用它的处理资源。比 如, 假设一个线程必须等到某些数 据被装载到高速缓存中 , 那 CPU 就可以继续去执行另一 个线程。举 例来说, Intel Core i7 处理器可以让每个核执行两个 线程, 所以一个 4 核的系统实际上可以并 行地执行 8 个线程。\n多处理器的使用可以从两方面提高系统性能。首先,它减少了在执行多个任务时模拟 并发的需要。正如前面提到的,即使是只有一个用户使用的个人计算机也需要并发地执行 多个活动。其次,它可以使应用程序运行得更快,当然,这必须要求程序是以多线程方式 来书写的 , 这些线程可以并行地高效 执行。因此, 虽然并发原理的 形成和研究已经超过 50 年的时间了,但是多核和超线程系统的出现才极大地激发了一种愿望,即找到书写应用程 序的方法利用硬件开发线程级并 行性。第 12 章 会更深入地探讨并发, 以及使用并发来提供处理器资源的共享,使程序的执行允许有更多的并行。\n2 指令级井行\n在较低的抽象层 次上, 现代处理器可以同时 执行多条指令的属性称为指令级 并行。早期的微处理器 , 如 1978 年的 In tel 8086, 需要多个(通常是 3~ 10 个)时钟周期来执行 一条指令。最 近的处理器可以保持 每个时 钟周期 2 ~ 4 条指令的执行速率。其实每条指令从开\n始到结束需 要长得多的时间 , 大约 20 个或者更多周期, 但是处理器使用了非常多 的聪明技巧来同时处理多 达 100 条指令。在第 4 章中, 我们会研究 流水线 ( pi pelining ) 的使用。在流水线中,将执行一条指令所需要的活动划分成不同的步骤,将处理器的硬件组织成一系 列的阶段 , 每个阶段 执行一个步骤。这些 阶段可以并行地操作 ,用 来处理不同指令的不同部分。我们会看到一个 相当简单的硬件设计 , 它能够达到接近于一个时钟周期一条指令的执行速率。\n如果处理器可以 达到比一个周期一条指令更快的执行 速率 , 就称之为超标 量 ( s uper­ scalar )处理器。大多 数现代处理器都支持超标量操作。 第 5 章中, 我们将描述 超标量处理器的高级模型。应用程序员可以用这个模型来理解程序的性能。然后,他们就能写出拥有 更高程度的指令级并行 性的程序代码 ,因而 也运行得更快 。\n3 单指令、 多数据并行\n在最低层次上,许多现代处理器拥有特殊的硬件,允许一条指令产生多个可以并行执 行的操作 , 这种方式称为 单指令、 多 数据, 即 SIMD 并行。例如, 较新儿代的 In tel 和\nAMD 处理器都具有并行地对 8 对单精度浮点数 (C 数据类型 fl o a t ) 做加法的指令。\n提供这些 SIMD 指令多是为了提高处理影像、声音 和视频数据应用 的执行速度。虽 然有些编译器会试图从 C 程序中自动抽取 SIMD 并行性,但是更可靠 的方法是用编译器支持的特殊的向量数据类型来写 程序, 比如 GCC 就支持向量数据类型。作 为对 第 5 章中比较通用的程序优化描述的 补充, 我们在网络旁 注 OPT :SIMD 中描述了这种 编程方式 。\n9. 3 计算机系统中抽象的重要性\n抽象的使用是计算 机科学中最 为重要的概念之一 。例如, 为一组函数规定一个简单的应用程序接口 ( APD 就是一个很好的编程习惯 , 程序员无须了解它内部的工作便可以使用这些代码。不同的 编程语言提供不 同形式 和等级的 抽象支持 , 例如 J a va 类的声明和 C 语言的函数原型。\n. 我们已经介绍了计算 机系统中使用的几个抽象, 如图 1-18 所示。 在处 理器里 , 指令集架构提供了对实际处理器硬件的抽象。使用这个抽象,机器代码程序表现得就好像运行 在一个一次只执行一条指令的处理器上。底层的 硬件远比抽象 描述的要复杂精细, 它并行地执行多条指令,但又总是与那个简单有序的模型保持一致。只要执行模型一样,不同的 处理器实现也能执行同样的机器代码,而又提供不同的开销和性能。\n虚拟机\n进程\n指令集架构 虚拟内存\n文件\n操作系统 处理器 主存 VO设备\n图 1-18 计算机系统提供的 一些抽象。计算机系统中的一个 重大主题就是提供不同层次的抽象表示,来隐藏实际实现的复杂性\n在学习操作系统时 , 我们介绍了三个抽象 : 文件是 对 I/ 0 设备的抽象, 虚拟内存是 对程序存储器的抽象 , 而进程是 对一个正在运行的程序的 抽象。我们再 增加一个新的抽象 :\n虚拟机 , 它提供对整个计算机的抽象 , 包括操作系统、处理器和程序。虚拟机的思想是\nIBM 在 20 世纪 60 年代提出来的 , 但是最近才显 示出其管理计算机方式 上的优 势, 因为一些计算机必须能够运行 为不 同的操作系统(例如, M icro s o f t W in d o w s 、M a cO S 和 L in u x ) 或同一操作系统的不同版本设计的程序。\n在本书后续的章节中,我们会具体介绍这些抽象。\n10 小结\n计算机系统是由硬件和系统软件组成的,它们共同协作以运行应用程序。计算机内部的信息被表示 为一组组的 位,它 们依据上下文有不同的解释方式。程 序被其他程序 翻译 成不同的形式,开 始时是\nASCII 文本,然后 被编译器 和链接器 翻译成二进制可执行 文件。\n处理器读取并解 释存放在主存里的二进制指 令。因 为计算 机花费了 大量的时间在内 存、1/0 设备和CP U 寄存器之间复制数据 , 所以将系统中的存储设 备划分成层次结构 CPU 寄存器在顶部 , 接着是多层的硬件高 速缓存存储器、 DRAM 主存和磁盘存储器。在层次模型中 , 位于更高层的存储设 备比低层的存储设备要更快,单位比特造价也更高。层次结构中较高层次的存储设备可以作为较低层次设备的高速 缓存。通过理解 和运用 这种存储层次结构的 知识, 程序员可以 优化 C 程序的性能 。\n操作系统内 核是应用程序 和硬件之间的媒介 。它提供三个基本的抽象 : 1) 文件是对 1/ 0 设备的抽象;\n虚拟内存是对 主存和磁盘的抽象 ; 3) 进程是处 理器、主存 和 1/ 0 设备的抽象。 最后,网络 提供了计算机系统之间通信的手段。从特殊 系统的 角度来看,网络就是一种 1/ 0 设备。\n参考文献说明 # Rit chie 写了关于早期 C 和 U nix 的有趣的第一手资 料[ 91 , 92] 。 Ritchie 和 T hompson 提供了最早 出版的 Unix 资料[ 93] 。Silberschatz 、Galvin 和 Gagne[ 102] 提供了关于 Unix 不同版本的详尽历 史。GNU ( www. gnu. org) 和 Linux( www. linux. org) 的网站上有大量的 当前信息 和历史资 料。Posix 标准可以 在线获得( www. unix. org) 。\n练习题答案 # 1 该问题说明 Amdahl 定律不仅仅适用于计算机系统。 根据公 式 1. 1, 有 a = 0. 6 , k = l. 5。更直接地说, 在蒙大拿行驶的 1500 公里需要 10 个小时 , 而其他行程也需 要 10 个小时。则加速比 为 25 / ClO+ l O) = l. 25 X 。\n根据公式 1. 1, 有 a= O. 6, 要求 S = l. 67, 则可算出 k。更直接地说,要使行程 加速度达到1. 67X, 我们必须把全程时间减少到 15 个小时。蒙大拿以外仍要求 为 10 小时, 因此, 通过 蒙大拿的时间就为 5 个小时。这就要求行驶速度为 300 公里/小时, 对卡车来说这个速度太快了!\n1. 2 理解 Amdahl 定律最好的方法就是 解决一些 实例。本题要求你从 特殊的角度来看公 式 1. 1。本题是公式的简单应用。已知 5 = 2 , a=O. 8, 则计算 k :\n2 =\nO. 4 + 1. 6/ k = l. 0\nk = 2. 67\n"},{"id":438,"href":"/zh/docs/technology/System/%E6%B7%B1%E5%85%A5%E7%90%86%E8%A7%A3%E8%AE%A1%E7%AE%97%E6%9C%BA%E7%B3%BB%E7%BB%9F/%E4%B9%A6%E7%B1%8D/%E7%AC%AC2%E7%AB%A0-%E4%BF%A1%E6%81%AF%E7%9A%84%E8%A1%A8%E7%A4%BA%E5%92%8C%E5%A4%84%E7%90%86/","title":"Index","section":"SpringCloud","content":"第 2 章\nC H A P T E R 2\n信息的表示和处理\n现代计算机存储和处理的信息以二值信号表示。这些微不足道的二进制数字,或者称 为位( bit ) , 形成了数字革命的基础。大 家熟悉并使用了 10 00 多年的十进制(以10 为基数) 起源于印度, 在 12 世纪被阿拉伯数学家改进,并 在 13 世纪被意大利数学家 Leona rdo P isano ( 大约公元 11 70- 1250 , 更为大家所熟 知的名字是 Fibo nacci ) 带到西方。对 千有 10 个手指的人类来说,使用十进制表示法是很自然的事情,但是当构造存储和处理信息的机 器时,二进制值工作得更好。二值信号能够很容易地被表示、存储和传输,例如,可以表 示为穿孔卡片上有洞或无洞、导线上的高电压或低电压、或者顺时针或逆时针的磁场。对 二值信号进行存储和执行计算的电子电路非常简单和可靠,制造商能够在一个单独的硅片 上集成数百万甚至数十亿个这样的电路。\n孤立地讲, 单个的位不是非常有用。然而,当把位 组合在一起,再 加上某种解释( inter­ pretation) , 即赋予不同的可能位模式以含意,我们就能够表示任何有限集合的元素。比如, 使用一个二进制数字系统 , 我们能 够用位组来编码非负数。通过使用标准的字符码, 我们能够对文档中的字母和符号进行编码。在本章中,我们将讨论这两种编码,以及负数 表示和实数近似值的编码。\n我们研究三种最重 要的数字表示。无符号 ( unsig ned ) 编码基千传统的 二进制表示法,\n表示大千或者等 千零的 数字。补码( t wo \u0026rsquo; s- com plemen t ) 编码是 表示有符号整数的最常见的方式,有 符号整数就是可以 为正或者为负的 数字。浮点数 ( float ing- poin t ) 编码是表示实数的科学 记数法的以 2 为基数的 版本。计算机用这些不同的 表示方法实现算术运算 ,例如加法和乘法,类似于对应的整数和实数运算。\n计算机的表示法是用有限数量的位来对一个数字编码,因此,当结果太大以至不能表 示时,某些运算 就会溢出 ( overfl o w) 。溢出会导致某些令人吃惊的后果。例如,在 今天的\n大多数计算机上(使用32 位来表示数据类型 i nt ) , 计算表达式 200*300*400*500 会得出结果\n—88 4 901 888。这违背了整数运算的特性,计 算一组正数的乘积不应产生一个负的结果。\n另一方面,整数的计算机运算满足人们所熟知的真正整数运算的许多性质。例如,利 用乘法的结合律和交换律,计 算下面任何一个 C 表达式, 都会得出结果一88 4 901 888:\n(500 * 400) * (300 * 200) ((500 * 400) * 300) * 200 ((200 * 500) * 300) * 400 400 * (200 * (300 * 500)) 计算机可能没有产生期望的结果,但是至少它是一致的!\n浮点运算有完全不同的数学属性。虽 然溢出会产生特殊的 值十(X) \u0026rsquo; 但是一组正数 的乘积总是正的。由千表示的精度有限 , 浮点 运算是 不可结合的。例如, 在大多数机器上 , c 表达式 (3 . 1 4+1e 20 ) - l e 20 求得的值会是0. 0 , 而 3 . 1 4+ (1 e 20 - l e 20 ) 求得的值会是 3. 14。\n整数运算和浮点数运算会有不同的数学属性是因为它们处理数字表示有限性的方式不 同 整数的表示虽然只能编码一个相对较小的数值范围,但是这种表示是精确的;而浮\n点数虽然可以编码一个较大的数值范围,但是这种表示只是近似的。\n通过研究数字的实际表示,我们能够了解可以表示的值的范围和不同算术运算的属性。为了使编写的程序能在全部数值范围内正确工作,而且具有可以跨越不同机器、操作 系统和编译器组合的可移植性,了解这种属性是非常重要的。后面我们会讲到,大量计算 机的安全漏洞都是由于计算机算术运算的微妙细节引发的。在早期,当人们碰巧触发了程 序漏洞,只会给人们带来一些不便,但是现在,有众多的黑客企图利用他们能找到的任何 漏洞,不经过授权就进入他人的系统。这就要求程序员有更多的责任和义务,去了解他们 的程序如何工作,以及如何被迫产生不良的行为。\n计算机用几种不同的二进制表示形式来编码数值。随着第 3 章进入机器级编程,你 需要熟悉这些 表示方式。在本章中, 我们描述这些编码,并 且 教 你 如 何 推出数字的表示。\n通过直接操作数字的位级表示,我们得到了几种进行算术运算的方式。理解这些技术对于理解编译器产生的机器级代码是很重要的,编译器会试图优化算术表达式求值的性能。\n我们对这部分内容的处理是基千一组核心的数学原理的。从编码的基本定义开始,然 后得出一些属性,例如可表示的数字的范围、它们的位级表示以及算术运算的属性。我们 相信从这样一个抽象的观点来分析这些内容,对你来说是很重要的,因为程序员需要对计 算机运算 与更为人熟悉的整数和实数运算之间的关系有清晰的理解。\nm 怎样阅读本章\n本章我们研究在计算机上如何表示数宇和其他形式数据的基本属性,以及计算机对 这些数 据执行操作的属性。这就要求我们深入研究数 学语 言, 编写公 式和方程式,以 及展 示重要 属性的推导。\n为了帮助你阅读,这部分内容安排如下:首先给出以数学形式表示的属性,作为原 理。然后 ,用 例子和非形式化的讨论来解释这个原 理。我们建议你反复阅读原理描述和它的 示例 与讨 论, 直到你对该属性的说明内容及其重要 性 有了 牢固的 直觉。 对于更 加复杂的属性, 还会提供推导, 其结构看上去将会像 一个数 学证 明。虽 然最终你应该尝试理解这些推 导,但 在第一次阅读时你 可以跳过它们 。\n我们也鼓 励你在阅读正文的过程中完成练习题 , 这会促使你主动学习, 帮助你理 论联系实际 。有了这些例 题和练习题作 为背景知 识, 再返回推导, 你将发现理解起来会容易许多。 同时,请放 心, 掌握好高中代数知识的 人都具备理解这些内容 所需要的数学技 能。\nC++ 编程语言建立在 C 语言基础之上, 它 们使用完全相同的数字表示和运算。本章中关于 C 的所有内容对 C++ 都有效。另一方面, Java 语 言 创 造了一套新的数字表示和运算标准。C 标准的设计允许多种实现方式, 而 Java 标准在数据的格式和编码上是非常精确具体的。本章中多处着重介绍了 Java 支持的表示和运算。\n豆日C 编程语言 的演变\n前面提 到过, C 编程 语言是 贝 尔 实验 室的 Dennis Ritch ie 最早开发 出 来的, 目的是 和 U nix 操作系统 一起使用 ( U nix 也是 贝 尔实 验室开 发的)。在那个时候 , 大多数 系统程序, 例如操作 系统 , 为 了访问不同数 据类型的低级表示, 都必须 大量地使 用 汇编代码。比如说,像 malloc 库函数提供的内存 分配功能, 用当 时的其他 高级 语 言是无法编 写的 。\nBrian Kern ighan 和 Dennis Ritchie 的著作的第 1 版 [ 60] 记录了 最初贝 尔 实验 室的 C\n语言版本。随着时间的推移, 经过 多个标准化 组织的努力 , C 语 言也在不断地演变。 1989\n年, 美国 国 家标 准学会 下的一个工作组推出 了 A NSI C 标准, 对最初的贝 尔 实验室的 C 语言做 了重 大修 改。ANSI C 与贝 尔 实验室的 C 有了很 大的不同, 尤其是 函数声 明的方式。Brian Kern ig han 和 Dennis Rit chie 在著作的第 2 版[ 61] 中描述了 A NSI C, 这本书至今仍被公认为关于 C 语言最好的参考手册之一。\n国际标 准化 组织接 替 了对 C 语言进行标准化的任务, 在 1990 年推出 了一个几乎和ANSI C 一样的版本,称 为 \u0026quot; ISO C90\u0026quot;。该组织在 1999 年又对 C 语言做 了 更新, 推出\u0026quot; ISO C99\u0026quot; 。在这一版本中, 引入了 一些新的数据类型, 对使用不符合英语语言宇符 的文本字符 串提 供 了 支持。更新的 版本 2011 年得到批准, 称为 \u0026quot; ISO Cll\u0026quot;, 其中再次添加了更多的数据类型和特性。最近增加的大多数内容都可以向后兼容,这意味着根据早 期标准(至少可以回 溯到 ISO C90 ) 编写的 程序按新标准编译时会有同样的行为。\nGNU 编译 器 套 装 ( G NU Comp iler Collec­ tion, GCC) 可以基 于 不 同 的命令行 选项,依 照多 个不 同版本的 C 语言规则来编译程序,如 图 2-\n1 所示。比如,根 据 ISO Cl l 来编译程序 pr og .\nc, 我们就使用命令行:\nlinux\u0026gt; gee -s t d=e11 rp\nog . e\n图 2-1 向 GCC指定不同的 C 语言版本\n编译选项- a ns i 和- s t d=c89 的 用 法是 一样的一一会根据 A NSI 或者 ISO C90 标准来编译程序。( C90 有时也称为 \u0026quot; C89\u0026quot; , 这是因为 它的 标准化 工作 是从 1989 年开始的。)编译选项- s t d =c 99 会让编译器按 照 ISO C99 的规则进行 编译。\n本书 中,没 有指定任何编译选项时,程 序会按照基于 IS O C90 的 C 语言版本进行编 译,但 是 也 包括 一些 C99、Cll 的特性, 一些 C+ + 的特性, 还 有 一些是与 GCC 相关的 特性。GNU 项目正在开发一个结合 了 ISO Cl l 和其他一些特 性的版本, 可以通过命令行选项- s t d=gnull 来指定。(目前,这 个实现 还未完成。今)后, 这个版本会成为默 认的 版本。\n2. 1 信息存储\n大多数计算机使用 8 位的块, 或者宇节 ( byte ) , 作为最小的可寻址的内存单位, 而不是访问内存中单独的位。机器级程序将内存视为一个非常大的字节数组,称 为虚拟内存( virt ua l memo ry) 。内存 的 每个字节都由 一个唯一的数 字来标识, 称为它的 地址 C ad­ dr ess ) , 所有可能地址的集合就称为虚拟地址空 间 ( vir t ual add ress space) 。顾名思义, 这个 虚拟地址空间只是一个展现给机器级程序的概念性映像。实际的实现(见第 9 章)是将动态随机访问存储器( DRAM ) 、闪存、磁盘存储器、特殊硬件和操作系统软件结合起来, 为程序提供一个看上去统一的字节数组。\n在接下来的几章中, 我们将讲述编译器和运行时系统是如何将存储器空间划分 为更可管理的单元,来 存 放不同的程序对象( progra m object) , 即程序数据、指令和控制信息。可以用各种机制来分配和管理程序不同部分的存储。这种管理完全是在虚拟地址空间里完 成的。例如, C 语 言 中一个指针的值(无论它指向一个整数、一个结构或是某个其他程序对象)都是某个存储块的第一个字节的虚拟地址。C 编译器还把每个指针和类型信息联系起来, 这样就可以根据指针值的类型, 生成不同的机器级代码来访问存储在指针所指向位置处的值。尽管 C 编译器维护着这个类型信息, 但是它生成的实际机器级程序并不包含关于数据类型的信息。每个程序对象可以简单地视为一个字节块, 而程序本身就是一个字节序列。\n区 日 C 语言中指针的作用\n指针是 C 语言的 一个重要 特性。它提 供 了 引用数 据 结构(包括 数 组)的元素的机制。与变量类似,指针也有两个方面:值和类型。它的值表示某个对象的位置,而它的类型 表示那个位 置上 所存储对象的类型(比如整数或者浮点 数)。\n真正理解指针需要查看它们在机器级 上的表示以 及 实现。这将是 第 3 章 的 重点之一, 3. 10. 1 节将 对其进行深入介绍。\n1. 1 十六进制表示法\n一个字节由 8 位组成。在二进制表示法中,它 的 值 域是 00000000 2 ~ 111111112 。 如果看成十进制整数 ,它的 值域就是 010 ~ 25510。 两种符号表示法对于描述位模式来说都不是非常方便。二进制表示 法太冗长, 而十进制表示法与位模式的互相转化很麻烦。替代的方法是, 以 16 为基数 ,或 者叫做十六进制 ( hexadecimal) 数 , 来表示位模式。十六进制(简写为 \u0026quot; hex\u0026quot; ) 使用数字 \u0026rsquo; O\u0026rsquo; ~ \u0026rsquo; 9 \u0026rsquo; 以及字符 \u0026rsquo; A \u0026rsquo; ~ \u0026rsquo; F \u0026rsquo; 来表示 16 个可能的值。图 2- 2 展示 了 1 6 个十六进制数字对应的 十进制值和二进制值。用十六进制书写,一 个字节的值域为 0016 ~FF16 o\n图 2- 2 十六进制表示法。每个十六进制数字都对 16 个值中的一个进行了编码\n在 C 语言中,以 Ox 或 OX 开 头 的 数 字常量被认为是十六进制的值。字符 \u0026rsquo; A \u0026rsquo; ~ \u0026rsquo; F '\n既可以是大写,也 可以是小写。例如, 我们可以将数字 F A1 D37B16 写 作 Ox F A1 D37B, 或者\nOxfald37b, 甚至是大小写混合,比 如 , Ox Fa lD 3 7b 。 在本书中, 我们将使用 C 表示法来表示十六进制值。\n编写机器级程序的一个常见任务就是在位模式的十进制、二进制和十六进制表示之间 人工转换。二进制和十六进制之间的转换比较简单直接,因 为可以一次执行一个十六 进制数字的转换。数字的转换可以参考如图 2-2 所示的表。一个简单的窍门是, 记 住十六进制数字 A、C 和 F 相应的十进制值。而对千把十六进制值 B、D 和 E 转换成十进制值,则 可以通过计算它们与前三个值的相对关系来完成。\n比如, 假设给你一个数字 Ox l 7 3 A4C。 可以通过展开每个十六进制数字, 将它转换为二进制格式,如下所示:\n十六进制 A 二进制 0001 0111 0011 1010 0100 1100 这样就得到了二进制表示 0001 0111 0011101001 0011 00 。\n反过来,如果 给定一个二进制数字 111100101011 011 0110011 , 可以通过首先把它分为每 4 位一组来转换为十六进制。不过要注意, 如果位总数不是 4 的倍 数 , 最左边的一组可以少于 4 位,前 面用 0 补足。然后将每个 4 位组转换为相应的十六进制数字:\n二进制 11 1100 1010 1101 1011 0011 十六进制 3 A D B 练习题 2. 1 完成下面的数字转换:\n将 Ox 3 9A7F8 转换 为 二进 制。\nB. 将二进 制 11 00100101 111011 转换 为 十 六进 制。\nC. 将 Ox D5E4C 转换 为 二进 制。\nD. 将二进制 1001101110011110110101 转换 为 十 六进 制。\n当值 x 是 2 的非负整数 n 次幕时,也 就 是 x = 2飞 我们可以很容易 地将 x 写成十六进制形式,只 要记住 x 的二进制表示就是 1 后面跟 n 个 0。十六进制数字 0 代表 4 个二进制\n0。所以,当 n 表示成曰一句 的形式,其 中 O i 3 , 我们可以把 x 写成开头的十六进制数字为 l( i = O) 、 2( i = l ) 、4 ( i = 2 ) 或者 8 ( i = 3) , 后面跟随着]个十六进制的 0。比如, x = 2048 = 211\u0026rsquo; 我们有 n = ll = 3 + 4 • 2, 从而得到十六进制表示 Ox8 00 。\n练习题 2. 2 填写 下表中 的 空白 项, 给出 2 的不 同次 幕的二进制和十 六进 制表 示:\n十进制和十六进制表示之间的转换需要使用乘法或者除法来处理一般情况。将一个十 进制数字 x 转换为十六进制, 可以反复地用 16 除 x , 得到一个商 q 和一个余数 r , 也就是x=q• l6+ r。然后 , 我们用十六进制数字表示的r 作为最低位数字,并 且 通过对 q 反复进行这个过程得到剩下的数字。例如, 考虑十进制 314 156 的转换:\n314 156=19 634• 16+12 (C)\n19 634= 1227• 16+2 (2)\n1227= 76• 16+11 (B)\n76= 4• 16+12 CC)\n4= 0• 16 + 4 (4)\n从这里, 我们能读出十六进制表示为 Ox 4CB2C。\n反过来, 将一个十六进制数字转换为十进制数字, 我们可以用相应的 16 的 幕乘以每个十六进制数字。比如, 给定数字 Ox 7AF , 我们计算它对应的十进制值为 7 • 1 62 + 10 • 16+15=7• 256+10• 16+ 15 = 1792 + 160 + 1 5 = 1 967 。\n练习题 2. 3 一个 字 节 可以用 两个十 六进制 数 字来 表 示。 填写 下表 中缺 失的项, 给 出不同 字 节模 式的 十进 制、 二进制和 十 六进制值 :\n十。进制 二进制 0000 0000 十六进制 OxOO 167 62 188 00110111 1000 1000 1111 0011 Ox52 OxAC OxE7 m 十进 制和十六进制间的转换\n较大数值的 十进 制和 十六进 制之 间的 转换, 最好是让计算机或者计算器来完 成。有大量的工具可以 完成这 个工作 。一个简单 的方法就是 利用任 何标准的搜 索引 擎, 比如查询:\n把 Ox a b c d 转换为十进 制数\n或\n把 1 23 用十 六进 制表 示。\n练习题 2. 4 不 将数 字 转换 为 十进制或 者 二进制 , 试 着解答下 面 的 算 术题, 答 案 要用十六 进制表示。 提 示: 只要将执行 十进制加法和 减 法所使 用的方 法 改成以 1 6 为基数。\nOx 5 03c +Ox 8 =\nOx 5 03c - Ox 40 =\nC. Ox 5 03c +6 4=\nD. Ox 5 0e a - Ox 5 03c =\n1. 2 字数据大小\n每台计算 机都有一个宇长 ( w o rd size) , 指明指针数据的标称大小( no minal s ize ) 。因为虚拟地址是以这样的一个字来编码的,所以字长决定的最重要的系统参数就是虚拟地址空\n间的最大大小。也就是说, 对于一个字长为 w 位的机器而言 , 虚拟地址的范围为 0 ~ w2\n程序最多 访问 沪 个字节。\n- l ,\n最近这些年,出现了 大规模的从32 位字长机器到 64 位字长机器的迁移。这种情况首先出现在为大型科学和数据库应用设计的高端机器上,之后是台式机和笔记本电脑,最近则出现在 智能手机的处理器 上。32 位字长限制虚拟地址空间为 4 千兆字节(写作 4GB) , 也就是说,刚刚超过 4 X l 铲字节。扩展到 64 位字长使得虚拟地址空间为 16EB, 大约是 1. 84 X l 沪字节。\n大多数 64 位机器也可以运行为 32 位机器编译的程序, 这是一种向后兼容。因此,举例来说, 当程序 pr o g . c 用如下伪指令编译后\nli nux\u0026gt; g e e - m3 2 pr og . e\n该程序就可以在 32 位或 64 位机器上正确运行。另一方面, 若 程序用下述伪指令编译\n码的数字格式,如不同长度的整数和浮点\n示为 4 字节和 8 字节的浮点数。\nC 语言支持整数和浮点数的多种数据格式。图 2-3 展示了为 C 语言各种数据类\n图 2-3 基本 C 数据类型的典型大小(以字节为单位)。分配的字节数受程序是如何 编译的影响而变化。本图给出的是 32 位和 64 位程序的典型值\n型分配的字节数。(我们在 2. 2 节讨论 C 标准保证的字节数 和典型的字节数 之间的关系。) 有些数据类型的 确切字节数依赖于程序是 如何被编译 的。我们给出的是 32 位和 64 位程序的典型值。整数或者为有符号的,即可以表示负数、零和正数;或者为无符号的,即只能 表示非负数 。C 的数据类型 c ha r 表示一个单独的字节。尽管 \u0026quot; cha r\u0026quot; 是由于它被用来存储文本串中的单 个字符这一事 实而得名, 但它也能被用来存储整数值。数据类型 s hor t 、i n t 和 l o ng 可以提供各种数据大小 。即使是为 64 位系统编译, 数据类型 l 阰 通常也只有\n4 个字节。数 据类型 l o ng 一般在 32 位程序中为 4 字节, 在 64 位程序中则 为 8 字节。\n为了避免由于依赖 "典型” 大小和不同 编译器设置带来的奇怪行为, IS O C99 引入了一类数据类型,其数据大小是固定的,不随编译器和机器设置而变化。其中就有数据类型 int32 t 和 i n t 64 t, 它们分别为 4 个字节和 8 个字节。使用确定大小的整数类型是 程序员准确控制数据表示的最佳途径。\n大部分数据类型都编码为有符号数值 , 除非有前 缀关键字 uns i g ne d 或对确定大小的数据类型使 用了特定的无符号声明 。数据类型 c h a r 是一个例外。尽管大多数编译器和机器将它们视为有符号数 , 但 C 标准不保证这一点 。相反, 正如方括号指示的那 样,程序 员应该用有符号字符的声明来保证其为一个字节的有符号数值。不过,在很多情况下,程序 行为对数据类型 c har 是有符号的还是无符号的并不 敏感。\n对关键字的顺序以 及包括还是省略可选关键字来说, C 语 言 允 许 存在多种形式。比如,下面所有的声明都是一个意思:\nunsigned long unsigned long int long unsigned long unsigned int\n我们将始终使 用图 2-3 给出的格式。\n图 2-3 还展示了指针(例如 一个被声明为类 型为 \u0026quot; c h ar * \u0026ldquo;的变量)使用程序的全字长。大多数机器还支持两 种不同的浮点 数格式: 单精度(在C 中声明为 fl o a t ) 和双精度\n(在 C 中声明为 d o ub l e ) 。这些格式分别使用 4 字节和 8 字节。\n声明指针\n对于任何 数据类型 T , 声明\nT *p;\n表明 p 是一个指针 变量,指 向一个类型 为 T 的对象。例如 ,\nchar *p;\n就将一个指针 声明 为指 向一个 c h ar 类型的 对象。\n程序员应该力图使他们的程序在不同的机器和编译器上可移植。可移植性的一个方面就是使程序对不同数据类型的确切大小不敏感。C 语言标准对不同数据类型的数字范围设置了下界(这点在后面还将讲到), 但是却没有上界。因为从 1980 年左右到 2010 年左右, 3 2 位机\n器和 32 位程序是主流的组合, 许多程序的编写都假设为图 2- 3 中 32 位程序的字节分 配。随着 64 位机器的日益普及, 在将这些 程序移植到新机 器上时 , 许多隐藏的对字长的 依赖性就会显现出来, 成为错误。比如,许多程序员假设一个声明为i nt 类型的程序对象能被用来存储一个指针。这在大多数32 位的机鞋上能正常工作, 但是在一台64 位的机器上却会导致问题。\n1. 3 寻址和字节顺序\n对于跨越多字节的程序对象,我们必须建立两个规则:这个对象的地址是什么,以及 在内存中如何排列这些字节。在几乎所有的机器上,多字节对象都被存储为连续的字节序 列, 对象的地址为所使用字节中最小的地址。例如,假设 一个类型为 1 止 的变量 x 的地址为 Ox lO O, 也就是说,地 址 表达式 \u0026amp;x 的 值为 Ox l OO 。那 么 ,(假设数据类型 i n t 为 32 位表示)x 的 4 个字节将被存储在内存的 Ox l OO、 Ox l Ol 、 Ox 1 02 和 Ox 1 0 3 位置。\n排列表示一个对象的字节有两个通用的规则。考虑一个 w 位的整数,其 位表示为[ x..,,- 1\u0026rsquo; X ,.,- 2\u0026rsquo; … , X1, X。J\u0026rsquo; 其 中 X w- 1 是最高有效位, 而 x。是最低有效位。假设 w 是 8 的倍数,这些位就能 被分组成为字 节,其 中最 高 有效字节包含位[ x心 一 I • X..,,- 2 • … , x正 s J\u0026rsquo; 而最低有效\n字节包含位 [ x1\u0026rsquo; Xs\u0026rsquo; … , x 。], 其他字节包含中间的位。某些机器选择在内存中按照从最低 有效字节到最高有效字节的顺序存储对象,而另一些机器则按照从最高有效字节到最低有效 字节的顺 序存储。前一种规则- 最低有效字节在最前面的方式 ,称 为小端 法( little en dian)。后一种规则 —— 最高有效字节在最前面的方式,称 为大端 法( big endian) 。\n假设变量 x 的类型为 i n t , 位于地址 Ox l OO 处 ,它 的 十六进制值为 Ox 01 2 3 45 67。地址范围 Ox l OO~ Ox1 0 3 的 字 节顺序依赖于机器的类型:\n\u0026ndash;壺 # 小端法\nOxlOO OxlOl Ox1 0 2 Ox103\n注意,在字 Ox 01 23 45 67 中, 高位字节的十六进制值 为 Ox Ol , 而低位字节值为 Ox 67 。\n大多数 Intel 兼容机都只用小端模式。另一方面, IB M 和 Oracle ( 从 其 201 0 年收购Sun Microsys tems 开始)的大多数机器则是按大端模式操作。注意我们说的是“大多数”。这些规则并 没有严格按照企业界限来划分。比如, IBM 和 Oracle 制造的个人计算机使用的是 Intel 兼容的处理器,因 此 使 用 小 端法。许多比较新的微处理器是双端 法 ( bi-endian) , 也就是说可以把它们配置成作为大端或者小端的机器运行。然而, 实际情况是: 一 旦 选择了特定操作 系统,那 么 字节顺序也就固定下来。比如,用 于 许 多 移动电话的 AR M 微处理器,其硬件可以按小端或大端两种模式操作,但是这些芯片上最常见的两种操作系统——\nAnd roid( 来自 Google) 和 IOS (来自 Apple) 却只能运行于小端模式。\n令人吃惊的是,在哪种字节顺序是合适的这个问题上,人们表现得非常情绪化。实际上,术语 \u0026quot; lit tle endian(小端)” 和 \u0026quot; big endian( 大端)” 出 自 Jo nat han Swift 的《格 利 佛 游 记》(Gulliver \u0026rsquo; s T ravels)一书, 其 中 交战的两个派别无法就应该从哪一端(小端还是大端) 打开一个半熟的鸡蛋达成一致。就像鸡蛋的问题一样,选择何种字节顺序没有技术上的理 由,因此争论沦为关千社会政治论题的争论。只要选择了一种规则并且始终如一地坚持, 对于哪种字节排序的选择都是任意的。\n田日 ”端”的起源\n以下是 J on at han Swift 在 1 72 6 年关于大小端之 争历史的 描述:\n"……我下 面要 告诉你的是 , L ill ip ut 和 Blefu sc u 这两 大强国 在过去 36 个 月里 一直在苦战。战争开始是 由于以下的原 因: 我们大家都认为 , 吃鸡蛋前, 原始的 方 法是 打破鸡蛋较大的一端,可是当今皇帝的祖父小时候吃鸡蛋,一次按古法打鸡蛋时碰巧将一个 手指弄破了,因此他的父亲,当时的皇帝,就下了一道敕令,命令全体臣民吃鸡蛋时打 破鸡蛋较小的 一端, 违令者重罚。老百姓们 对这项命 令极为反感。 历 史告 诉我 们, 由此曾发 生过 六次叛 乱, 其中一 个皇帝送 了命, 另一 个丢了王位。这些叛 乱大多都是由 Ble­ fu s cu 的国王大臣们 煽动起 来的。叛乱平息后 , 流亡的人 总是逃到 那个 帝国 去寻救避难。据估计, 先后几次有 11 000 人情愿受死也 不肯去打破 鸡蛋 较小的 一端。 关 于这一 争端, 曾出版过几百本大部著作,不过大端派的书一直是受禁的,法律也规定该派的任何人不 得做官。”( 此段译文摘自网上 蒋剑锋 译的《格利佛 游记》第一 卷第 4 章。)\n在他那个时代 , S w ift 是在讽刺 英 国 C Lill ip ut ) 和法国 ( Blefu s cu ) 之间持续的 冲 突。\nDanny Cohen, 一位网络协议的早期开创者,笫一次使用这两个术语来指代字节顺序\n[24], 后来这 个术语被 广泛 接纳了 。\n对于大多数应用程序员来说,其机器所使用的字节顺序是完全不可见的。无论为哪种 类型的机器所编译的程序都会得到同样的结果。不过有时候,字节顺序会成为问题。首先 是在不同类型的机器之间通过网络传送二 进制数 据时, 一个常见 的问题是当小端法机器产生的数据被发送到大端法机器或者反过来时, 接收程序 会发现, 字里的字节成了反序的。为了避免这类问题,网络应用程序的代码编写必须遵守已建立的关于字节顺序的规则,以 确保发送方机器将它的内部表示转换成网络标准,而接收方机器则将网络标准转换为它的 内部表示。我们将在第 11 章中看到这种转换的 例子。\n第二种情况是,当阅读表示整数数据的字节序列时字节顺序也很重要。这通常发生在检查机器级程序时。作为一个示例,从某个文件中摘出了下面这行代码,该文件给出了一个针对 In t el x8 6-64 处理器的机器级代码的文本表示:\n4004d3: 01 05 43 Ob 20 00 add %eax,Ox200b43(%rip)\n这一行是由反汇编 器( d isas s em bler ) 生成 的, 反汇编器是一种确定 可执行程序文件所表示的指 令序列 的工具。我们将 在第 3 章中学习有关 这些工具的更多知识,以 及怎样解释像这样的行。而现在,我们只 是注意这行表述的 意思是: 十六进制字 节串 01 05 43 Ob 20 00 是一条指令的字节级表示,这条指令是把一个字长的数据加到一个值上,该值的存储地址由 Ox 2 00b 43 加上当前 程序计数 器的 值得到, 当前程序计数器的值即为下 一条将要执行指令的地址。如果取出这个序列的最后 4 个字节: 43 Ob 20 00, 并且按照相反的顺序写出,我\n们得到 0 0 20 Ob 43。去掉开头的 o, 得到值 Ox 2 00b 43 , 这就是右边的数值。当阅读像此\n类小端法机器生成的机器级程序表示时,经常会将字节按照相反的顺序显示。书写字节序列的自然方式是最低位字节在左边,而最高位字节在右边,这正好和通常书写数字时最高有效位在左边,最低有效位在右边的方式相反。\n字节顺序变得重要的第三种情况是当编写规避正常的类型系统的程序时。在 C 语言中, 可以通过使用强制 类型 转换 ( ca s t ) 或联合( unio n ) 来允许以一种数据类型引用一个对象,而这种数据类型与创建这个对象时定义的数据类型不同。大多数应用编程都强烈不推 荐这种编码技巧,但是它们对系统级编程来说是非常有用,甚至是必需的。\n图 2-4 展示了一段 C 代码,它 使用强制类型转换 来访问和打印不同程序对象的 字节表示。我们用 t y pe d e f 将数据类型 b yt e _ p o i n t er 定义为一个指向类型为 \u0026quot; u n s i g ne d\ncha r\u0026rdquo; 的对象的指针。这样一个字节指针引用一个字节序列 , 其中每个字节都被认为是一个非负整数 。第一个例程 s h o w_ b y t e s 的 输入是一个字节序列的地址 ,它 用 一个字节指针以及一个字节数 来指示。该字节数指定为数据类型 s i ze —七, 表示数据结构大小的首选数据类型。s how _ b yt e s 打印出每个以 十六 进制表示的字节。C 格式化指令 \u0026quot; %. 2x\u0026quot; 表明整数必须用至 少两个数字的十六进 制格式输出。\n1 #include \u0026lt;s t di o . h\u0026gt;\n2\n3 typedef unsigned char *byte_pointer;\n4\n5 void show_bytes(byte_pointer start, size_t len) {\n6 s. 1ze_t 1;\n7 for (i = O; i \u0026lt; len; i++)\ns printf (\u0026quot; %. 2x\u0026quot;, start [i]) ;\n9 printf(\u0026quot;\\n\u0026quot;);\n10 }\n11\nvoid show_int(int x) {\nshow_bytes((byte_pointer) \u0026amp;x, sizeof(int));\n14 }\n15\nvoid show_float(float x) {\nshow_bytes((byte_pointer) \u0026amp;x, sizeof(float));\n18 }\n19\nvoid show_pointer(void *x) {\nshow_bytes ((byte_pointer) \u0026amp;x, sizeof (void *));\n22 }\n图2- 4 打印程序对象的字节表示。这段代码使用强制类型转换来规避类型系统。很容易定义针对其他数 据类型的类似函数\n过程 s h o w_ i n t 、 s ho w_ f l o 扛 和 s ho w_p o i n t er 展示了如何使用程序 s ho w_b yt e s 来分别输出类型为 i n t 、f l o 红 和 v o i d * 的 C 程序对象的字节表示。可以 观察到它们仅仅传递给 s ho w—b yt e s 一个指向它们参数 x 的指针 \u0026amp;x , 且这个指针被强制类型转换为 \u0026quot; u n ­ signed char * \u0026ldquo;。这种强制类型转换 告诉编译器, 程序应该把这个指针看成指向一个字节序列,而不是指向一个原始数据类型的对象。然后,这个指针会被看成是对象使用的最 低字节地址。\n这些过程使用 C 语言的运算符 s i ze o f 来确定对象使用的字节数。一般来说 , 表达式sizeof (T ) 返回存储一个类型为 T 的对象所需要的字节数。使用 s i ze o f 而不是一个固定的值,是向编写在不同机器类型上可移植的代码迈进了一步。\n在几种 不同的机器上运行如图 2-5 所示的代码, 得到如图 2- 6 所示的 结果。我们使用了以下几种机器:\nLinux 32: 运行 Lin ux 的 In tel IA 32 处理器。\nWindows: 运行 Window s 的 I nt el IA 32 处理器。\nSun: 运行 Solaris 的 Sun Microsystems SPARC 处理器。(这些机器现在由Oracle 生产。)\nLinux 64: 运行 Lin ux 的 In tel x86 - 64 处理器。\nvoid test_show_bytes(int val) {\nint ival = val; float fval = (float) ival; int *pval = \u0026amp;ival; show_int(ival); show_float(fval); show_pointer(pval); 8 }\ncode/data/show-bytes.c\ncode/data/show-bytes.c\n图 2-5 字节表示的示例。这段代码打印示例数据对象的 字节表示\n图2-6 不同数据值的字节表示。除了字节顺序以外 , i nt 和 fl oa t 的结果是一样的。指针值与机器相关参 数 1 2 3 45 的 十六进制表示为 Ox 0 0 00 3 0 3 9 。 对 千 i n t 类型的数据,除 了 字 节 顺 序以\n外, 我们在所有机器上都得到相同的结果。特别地, 我们可以看到在 L in u x 3 2、W in dow s 和 L in u x 64 上,最 低 有效 字 节值 Ox 3 9 最 先 输 出 , 这说明它们是小端法机器; 而 在 S u n 上最后输出,这 说明 S u n 是 大 端 法 机 器 。 同 样 地 , f l o a t 数 据 的 字 节 ,除 了 字 节 顺 序 以 外 , 也 都 是 相 同 的 。 另 一 方 面,指 针值却是完全不同的。不同的机器/操作系统 配置使用不同的存储分配规则。一个值得注意的特性是 L in u x 3 2、W i nd o w s 和 S u n 的机器使用 4 字节地址,而 L i n u x 64 使 用 8 字节地址。\n使用t ype de f 来命名数据类型\nC 语言中的 t y p e d e f 声明 提供了一种给数据类型命名的方式。这能够极 大地 改善代码的可读性,因为深度嵌套的类型声明很难读懂。\nt yp e d e f 的语法与 声明 变量的 语法十分相像 ,除 了它使 用的 是类型名, 而不是 变量名。因此, 图 2- 4 中 b y 七e _ p o i n t er 的声 明和将一个变量声 明 为 类型 \u0026quot; u n s i g n e d char\n* \u0026ldquo;有相同的形式。\n例如,声明:\ntypedef int•int_pointer; int_pointer ip;\n将类型 \u0026quot; i n t _ p o i n t er \u0026quot; 定义为 一个指向 i n t 的指针, 并且声明 了一 个这种类型的变量 i p 。我们还可以将这个变量 直接 声明 为:\nint *ip;\n一 使用 pr i n t f 格式化输出\np r i n t f 函数(还有它的 同 类 f p r i n t f 和 s pr i n t f ) 提供 了一 种 打 印 信 息 的 方式, 这 种方式对格式化细节有相 当 大 的 控 制能力。 第 一 个 参 数 是 格 式 串 ( fo r m a t string), 而其余的参数都是要打印 的值 。在 格 式 串 里 , 每 个 以 \u0026quot; %\u0026rdquo; 开始的 宇符序 列 都 表 示如何格 式化下一个参数。典型的示例 包括 : \u0026rsquo; %ct\u0026rsquo; 是 输 出一 个十进制整数, \u0026rsquo; %f \u0026rsquo; 是 输 出一 个 浮点数, 而 \u0026rsquo; %c \u0026rsquo; 是 轮 出一个宇符 , 其编码由参数给出。\n指定确定大小数据 类型的格式, 如 i n 七3 2 _ t , 要 更 复 杂 一 些, 相 关内容参 见 2. 2. 3\n节的 旁注。\n可以观察到, 尽 管 浮点 型 和整 型数 据都 是对 数 值 1 2 345 编 码 , 但 是 它 们 有 截 然 不 同 的字节模 式 : 整 型 为 Ox 0 0 0 03 03 9 , 而 浮 点 数 为 Ox 4 64 0E 4 0 0。 一 般 而言 , 这 两 种 格 式 使 用 不同的 编 码方法。如果我们将这些 十六 进制模式扩展为二进制形式, 并 且 适 当 地 将 它 们 移位, 就会发 现一 个 有 1 3 个 相 匹 配 的 位 的 序 列 , 用一 串 星号 标识 出来 :\n0 0 0 0 3 0 3 9\n0000000000000000001100000011 1 001\n**** * ********\n4 6 4 0 E 4 0 0\n01000110010000001110010000000000\n这并不是巧合。当我们研究浮点数格式时, 还 将 再 回 到 这 个 例 子 。\n凶 ii1 指 针 和 数 组\n在 函数 s ho w b y t e s ( 图 2-4) 中, 我们看到指针和数组之间 紧密的 联 系, 这 将 在 3. 8 节中详 细描述。这个函数有一个类型 为 b y t e _p o i n t er ( 被 定 义 为一 个指 向 u n s i g ne d c ha r 的指针)的参数 s t ar t , 但是我们在 第 8 行 上 看到数 组引用 s t ar t [ i ] 。在 C 语 言 中, 我 们能够用数组表示法来引用指针,同时我们也能用指针表示法来引用数组元素。在这个例子 中·, 引用 s t a r t [ i ] 表 示我们想要读取以 s t ar t 指向的位置为起 始的 第 i 个位置处的 字节。\nJI 指 针 的 创 建 产 间接引 用\n在图 2-4 的第 1 3、1 7 和 21 行 , 我 们看到对 C 和 C++ 中两种 独 有 操 作 的 使 用。 C 的\n”取 地 址” 运 算 符 & 创建一个指针。在这三行中,表 达 式 \u0026amp;x 创建了 一 个指向保存 变量 x 的位置的 指针。这 个指 针 的 类型取 决 于 x 的 类型, 因 此 这 三 个指 针 的 类 型 分 别 为 i n t *、fl oa t *和 v o i d ** 。(数据类型 vo i d *是一种特殊类型的指针, 没有相 关联的 类型信息。)\n强制类型 转换 运 算 符 可以 将 一 种数 据 类 型 转换 为 另 一 种 。 因 此 , 强 制 类 型 转 换( b y t e主 o i n t e r ) \u0026amp;x 表 明 无 论 指 针 \u0026amp;x 以 前 是 什 么类型, 它现 在 就是 一 个指 向 数 据 类型为 u n s i g n e d c h ar 的 指 针 。 这 里 给 出的 这 些强 制类型转换不会 改 变 真 实的 指 针 , 它们只是告诉编译器以新的数据类型来看待被指向的数据。\nm 生成一张 ASCII 表\n可以 通过执行命令 ma n a s c 江 来得 到一张 ASCII 宇符码的表。\n练习题 2. 5 思考下面对 s h o w_ b y 七e s 的 三次调用:\nint v a l = Ox87654321;\nbyte_pointer valp = (byt e _po i nt er ) \u0026amp;v al ;\nshow_bytes(valp, 1); I* A. *I show_bytes(valp, 2); I* B. *I show_bytes(valp, 3); I* C. *I\n指出在小端法机器和大端法机器上,每次调用的输出值。\n小端法: 大端法: 小端法: 小端法:\n大端法: 大端法:\n练习题 2. 6 使用 s h o w_ i n t 和 s h o w_ f l o a t , 我们确定整数 3510593 的十 六进 制表 示为 Ox 0035 9141 , 而浮 点数 351 05 93 . 0 的 十 六进制表 示为 Ox 4A5645 04。\n写出这两个十六进制值的二进制表示。 移动这两个二进制串的相对位置,使得它们相匹配的位数最多。有多少位相匹配呢? 串中的什么部分不相匹配? 2. 1. 4 表示字符串\nC 语言中字符串被编码为一个以 null ( 其值为 0 ) 字符结尾的字符数组。每个字符都由某个标准编码来表示,最 常 见 的 是 ASCII 字符码。因此, 如果我们以参数 \u0026quot; 12345\u0026rdquo; 和 6\n(包括终止符)来运行例程 s h ow_bytes, 我们得到结果 31 32 33 34 35 00。请注意, 十进制数字 x 的 ASCII 码正好是 Ox3x , 而 终 止 字节的十六进制表示为 Ox OO。 在 使 用 ASCII 码作为字符码的任何系统上都将得到相同的结果,与字节顺序和字大小规则无关。因而,文本数据比二进制数据具有更强的平台独立性。\n_.练习题 2. 7 下 面对 s ho w_b yt e s 的调用将输出什 么结 果?\nconst char *B = \u0026ldquo;abcdef\u0026rdquo;;\n, show_bytes ((byte_pointer) s, strlen (s)) ;\n注意字母 \u0026rsquo; a \u0026rsquo; \u0026rsquo; z \u0026rsquo; 的 ASCII 码为 Ox 6l Ox 7A 。\n因 日 文字编码的 Un icode 标 准\nASCII 字符 集适合 于编码 英语 文档,但 是 在表达一些特殊宇符 方 面并 没有太多 办法, 例如法语的 \u0026ldquo;C\u0026rdquo;。 它完全不适合 编码希腊语、俄语和中文等语言的文档。这些年,提 出了很 多方 法来对不同语 言的文字进行 编码。Unicod e 联 合 会 ( U ni code Co nsorti um) 修订了 最全面且 广泛接 受的文字编码 标准。当前的 Unicode 标准( 7. 0 版)的宇库 包括 将近 100 000 个字符, 支持广泛的 语言种类, 包括古埃及和巴比伦的语言。为 了保 持 信 用, U nicode 技 术委员会 否决了为 K ling on( 即电视 连续剧《星际迷航 》中的虚构文明)编写语 言标准的提 议。\n基本编码, 称为 U nicode 的“统一字符集“,使 用 32 位 来表示字符 。这好像要求文本串中每 个宇符要占用 4 个宇节。 不过 , 可以有一些替代编码, 常见的宇符只需要 1 个或 2 个字节, 而不太常用的 字符 需要多一些的 字节数 。特别地, U T F-8 表 示将每个字符 编码为一 个字节序 列, 这样标准 ASCII 字符还是使 用和它们在 ASCII 中一样的单宇 节编码,这 也 就 意味 着所 有的 ASCII 宇节序 列用 ASCII 码表示和 用 U T F-8 表 示是 一样的。\nJava 编程语言使用 U nicod e 来表示字符 串。 对于 C 语言也有支持 U nicode 的程序库。\n2. 1. 5 表示代码\n考虑下面的 C 函数:\n1 int swn(int x, int y) {\nreturn x + y;\n当我们在示例机器上编译时,生成如下字节表示的机器代码:\nLinux 32 55 89 e5 8b 45 Oc 03 45 08 c9 c3\nWindows 55 89 e5 8b 45 Oc 03 45 08 5d c3\nSun 81 c3 eO 08 90 02 00 09\nLinux 64 55 48 89 e5 89 7d fc 89 75 f8 03 45 fc c9 c3\n我们发现指令编码是不同的。不同的机器类型使用不同的且不兼容的指令和编码方 式。即使是完 全一样的进程, 运行在不同的操作系统上也会有不同的编码规则, 因此二进制代码是 不兼容的。二进制代码很少能在不同 机器和操作系统组合之间移植。\n计算机系统的 一个基本概念就是, 从机器的角度来看, 程序仅仅只是 字节序列。机器没有关千原 始源程序的 任何信息, 除了可能有些用 来帮助调试的辅助表以外。在第 3 章学习机器级编程时 ,我 们将更清楚地看到这一点 。\n1. 6 布尔代数简介\n二进制值是计算 机编码、存储 和操作信息的核心,所以围绕数值 0 和 1 的研究已经演化出了丰富的数学知识 体系。这起源于 18 50 年前后乔治·布尔 ( George Boole, 1815—18 64 ) 的\n工作, 因此也称为布 尔代数 ( Boolean algebra ) 。布尔注意到通过将逻辑值 TRUE ( 真)和\nFALS E ( 假)编码为二进制值 1 和 o, 能够设计出一种代数,以研究逻辑推理的基本原则。最简单的布尔代数是在二元集合{0, 1 }\n基础上的定 义。图 2- 7 定义了这种布尔代数 0 I\n中的几种运算 。我们用 来表示这些运算的符\n号与 C 语言位级运算使用的符号是相匹配的, 这些将在后 面讨论到。布尔运算 ~ 对应于逻辑运 算 NOT , 在命题逻辑中用符号\u0026ndash;,\n表示。也 就是说, 当 P 不是真的时候, 我\n图 2-7 布尔代数的运算 。二进制值 1 和 0 表示逻辑值 T RUE 或者 FALSE , 而运 算符\n~ 、&、I 和^分别表示逻辑运算 NOT 、\nAND、OR 和 EXCLUSIVE-OR\n们就说\u0026ndash;ip 是真的,反 之亦然。相应地 ,当 P 等于0 时, - P 等于 1, 反之亦然。布尔运算\n& 对应于逻辑运算 AND , 在命题逻辑中 用符号I\\ 表示。当 P 和Q 都为真时,我 们说 p I\\\nQ 为真。相 应地,只 有当 p = l 且 q = l 时, p \u0026amp;.q 才等于 1。布尔运算 1 对应于逻辑运算\nOR, 在命题 逻辑中用符 号 V 表示。当 P 或者 Q 为真时, 我们说 P V Q 成立。相应地, 当p= l 或者 q= l 时, p lq 等于 1。布尔运算^对应于逻辑 运算 异或, 在命题逻辑中用符号令表示。 当 P 或者Q 为真但不同 时为真时 ,我们说 P 令Q 成立。相应地 , 当 p = l 且 q = O, 或者 p = O 且 q = l 时, p Aq 等千 1。\n后来创立信息论领域的 C la ud e S ha nno n0 916- 2001 ) 首先建立了布尔代数和数字逻辑之间的联 系。他在 1 937 年的硕士论文中表明了布尔代数可以 用来设计和分 析机电继电器网络。尽管那时计算机技术已经取得了相当的发展, 但是布尔代数仍然在数字系统的设计和分析中扮演着重要的角色。\n我们可以将上述 4 个布尔运算 扩展到位向 量的运算,位 向量就是固定长度为 w 、由 0\n和 1 组成的串。位向量的 运算可以定 义成参数的每个对应元素之间的运算。假设 a 和b 分\n别表示位向量[ a w- 1 • a w- 2 • …, a。] 和[ b.,.- 1 , bw- 2 , …, b。]。我们将 a \u0026amp; b 也定义为一个 长度为 w 的位向量, 其中第 1 个元素等于a ;\u0026amp; b; , O i \u0026lt; w 。可以用类似的方式将运算 I 、^\n和~扩展到位向量上。\n举个例子, 假设 w = 4 , 参数 a = [0110], b= [1100]。那么 4 种运算 a \u0026amp; b、a l b 、a A b\n和- b 分别得到以下结果: ,\n0110\n\u0026amp; 1100\n0100\n0110\nI 1100\n1110\n0110\n- 1100\n1010\n一 11 00\n0011\n饬 练习题 2. 8 填写下表,给出位向量的布尔运算的求值结果。\n运算 结果 a b -a -b a\u0026amp;b a l b a l\\ b [01101001] [0101 0101] “ 关千布尔代数和布尔环的更多内容\n对于任 意整数 w \u0026gt; O, 长度 为 w 的位向量上的 布 尔运算 I 、& 和~ 形成了一 个布 尔\n代数。最简单 的情况是 w = l 时,只有 2 个元素;但 是对于更普 遍的情况,有 沪 个长度为 w 的位向量。布尔代数和整数算术运算有很 多相似之处。例如, 乘法对加 法的 分配律,写 为 a • (b+c)=(a• b)+(a• c), 而布 尔运算 & 对1 的分配律 ,写 为 a \u0026amp; ( b Jc ) = (a\u0026amp;b) I ( a \u0026amp; c) 。 此外,布 尔运 算1 对 & 也有分配律 ,写 为 a l (b\u0026amp;c)=(aJb)\u0026amp;(alc), 但是对于整数我 们不能说 a + ( b • c)=(a+b)• (a+ c)。\n当考 虑长度 为 w 的位向 量上 的^、&和~ 运算时, 会得到一种不同的 数学形 式, 我们称 为布 尔环( Boolea n r ing ) 。布 尔环与整数运算有很 多相同的 属性。例如,整 数运算的一个属性 是每个值 x 都有一个加 法逆元 ( addit ive inverse)-x, 使得 x + ( - x ) = O。 布\n尔环也有类似的属性,这里的“加法”运算是^,不过这时每个元素的加法逆元是它自\n己本 身。也 就是说, 对于任何值 a 来说 , a Aa = O, 这里我们用 0 来表 示全 0 的位向量。可以 看到 对单个位 来说这是成立的 , 即 O AO= l Al = O, 将这个扩展到位向量也是成立的。当我们重新排列组 合顺序,这 个属性也 仍然成 立,因此有 ( a Ab ) Aa = b。这个属性 会引起一些很有趣的结果和聪 明的技 巧,在 练习题 2. 10 中我们 会有所探 讨。\n位向量一个很有用的应用就是表示有 限集合。我们可以用位向量[ a 心 一 I \u0026rsquo; … , a1, a。] 编码任何子集 Ai;;;;;;;{o , 1, …, w - 1 } , 其中 a , = 1 当且仅当 i E A。例如(记住我们是把 a\u0026hellip;,- 1 写 在左边,而 将 a。写在右边), 位向量 a == [ 011 01001 ] 表示集合 A = { O, 3, 5, 6},而 b兰 [ 01010101] 表示集合 B = {O, 2, 4, 6 }。使用 这种编码集合的方法, 布尔运算 1 和\n& 分别对应千集合的并和交,而 ~ 对应于于集合的补。还是用前面那个例子, 运算 a \u0026amp; b\n得到位向量[ 01000001] , 而 A n B = {O, 6} 。\n在大量实际应用中,我 们都能看到用位向量来对集合编码。例如,在第 8 章,我 们会看到有很多不同的信号会中断程序执行。我们能够通过指定一个位向量掩码,有选择地使能或是屏蔽一些信号 , 其中某一位位置上为 1 时 , 表明信号 1 是有效的(使能), 而 0 表明该信号是被屏蔽的。因而,这个掩码表示的就是设置为有效信号的集合。\n练习题 2. 9 通过混合三种不同颜色的光(红色、绿色和蓝色),计算机可以在视频屏幕或者 液晶 显示器上 产 生 彩 色 的 画 面。 设想 一 种简 单的 方 法 , 使 用 三 种 不 同 颜色 的光,每种光都能打开或关闭,投射到玻璃屏幕上,如图所示:\n光源 玻璃屏幕\n那么基于光 源 R ( 红)、G ( 绿)、B ( 蓝)的关 闭 ( 0 ) 或打开 (1 )\u0026rsquo; 我们 就 能 够 创 建 8\n种不 同的颜色 :\n。 。 。 。 。 # 这些颜色中的每 一种都能用一个长度为 3 的位向量来表 示,我们可以对它们进行布尔运算。\n一种颜色的补是通过关掉打开的光源,且打开关闭的光源而形成的。那么上面列 出的 8 种颜色每一种的补是什 么? 描述下列颜色应用布尔运算的结果: 蓝色 l 绿色 黄色 红色 & 蓝绿色 红紫色 1. 7 C 语 言 中 的 位 级 运 算\nC 语言的 一个 很 有 用 的 特性 就 是 它 支待按位布尔运算。事实上, 我们在布尔运算中使用的那些符号就是 C 语言所使用的: I 就 是 O R ( 或),& 就 是 AND ( 与),~就 是 NOT ( 取\n反), 而^就是 EX CLUSIVE-OR ( 异或)。这 些 运算能运用到任何 “ 整型” 的 数 据 类型上, 包括图 2- 3 所示内容。以下是一些对 c h ar 数据类型表达式求值的例子:\nC 的 表达式 二进制表达式 二进制结果 十六进制结果 ~Ox41 - [0100 0001] [! Oll ll!O] OxBE ~OxOO - [0000 0000] [1111 1111] OxFF Ox69\u0026amp;0x55 [OllO 1001]\u0026amp;[0101 0101] [0100 0001] Ox41 Ox69l0x55 (0110 10011 I (0101 01011 [01111101) Ox7D 正如 示例说明的那样,确 定 一 个 位 级 表 达式的结果最好的方法, 就 是 将 十六进制的参数扩展成二进制表示并执行二进制运算,然后再转换回十六进制。\n练习题 2. 10 对于任 一位向量 a , 有 a Aa = O。 应用这 一属性, 考虑下 面的程序:\nvoid inplace_s-wap(int *X, int *Y) {\n*Y = *X- *Yi I* Step 1 *I\n*X = *X- *Yi I* Step 2 *I\n*Y = *X- *Yi I* Step 3 *I\n}\n正如程 序名 字所暗 示的 那样, 我们认为 这个过程的效果是交换指针变 量 x 和 y 所指向的存储位置处存放的值。注意,与通常的交换两个数值的技术不一样,当移动一个值 时,我们不需要第三个位置来临时存储另一个值。这种交换方式并没有性能上的优 势,它仅仅是一个智力游戏。\n以 指针 x 和 y 指 向的位置存储的值分别是 a 和 b 作为 开始, 填 写 下表, 给出在 程序的\n每 一步 之后, 存储在这 两个位 置 中 的 值。 利 用^的属 性证 明 达到 了 所希 望 的 效果。回想一下, 每个元 素 就是它 自 身的加法逆元 ( a Aa = O) 。\n步骤 初始 *x a *y b 第1步 第2步 第3步 练习题 2. 11 在练 习 题 2. 10 中的 i n p l a c e—s wa p 函数的基础 上, 你决定写 一段代码 , 实现将一个数组中的元素头尾两端依次对调。你写出下面这个函数:\nvoid reverse_array(int a[], int cnt) { int first, last;\nfor (first = 0, last = cnt-1; first\u0026lt;= last; first++,last\u0026ndash;)\ninplace_swap(\u0026amp;a[first], \u0026amp;a[last]);\n}\n当你 对一个 包含元 素 l 、 2 、 3 和 4 的数 组使 用这个函 数 时, 正 如 预 期 的 那样, 现在 数组 的元 素 变 成 了 4 、 3 、 2 和 1 。不过, 当你对一个 包含元素 1 、2 、3 、4 和 5 的数组使用 这 个 函数 时, 你 会很惊奇地看到得到 数 字的元 素为 5、4、0、2 和 1。 实际上, 你会发现这段代码对所有偶数长度的数组都能正确地工作,但是当数组的长度为奇数时, 它就 会把 中间的元 素设 置成 0 。\n对于一个 长 度 为 奇数的 数 组 , 长 度 c n t = 2k+ 1,\n循环 中 , 变 量 f i r s t 和 l a s t 的值分别是什 么?\n函 数r e v er s e _ a r r a y 最后 一 次\n为 什 么这 时调用 函 数 i n p l a c e _ s wa p 会 将数组 元素设 置为 0 ?\n对 r e v e r s e _ar r a y 的代码做 哪 些简单改 动就能消除这个问题?\n位级运算的一个常见用法就是实现掩码运算,这里掩码是一个位模式,表示从一个字 中选出的位的集合。让我们来看一个例子, 掩 码 Ox FF( 最 低 的 8 位 为 1) 表 示 一 个 字 的 低 位字 节 。 位 级 运 算 x \u0026amp;Ox FF 生 成 一 个 由 x 的 最 低 有 效 字 节 组 成 的 值 , 而 其 他 的 字 节 就 被 置 为\n。 比 如 , 对 千 x = Ox89ABCDEF, 其 表 达式 将得 到 Ox OOOOOO EF 。 表 达式 - 0 将生 成 一 个 全\n的掩码 , 不 管 机 器 的 字 大 小 是 多 少 。 尽 管 对 于 一 个 32 位 机 器来 说 , 同 样 的 掩 码 可 以 写 成\nOxFFFFFFFF, 但是这样的代码不是可移植的。\n练习题 2. 12 对于下面的 值, 写 出 变 量 x 的 C 语 言表达 式。 你 的 代 码 应 该 对任何 字长 w 8 都 能 工作。我 们 给出 了 当 x = Ox 8 765 4321 以及 w = 32 时表达 式 求值的结果, 仅供参考。\nX 的最低有效 字 节 , 其他位均置为 0 。 [ Ox 0 00000 21 ] 。\n除了 x 的最低有效字节 外, 其他的位都取 补, 最低有效字节保 持不变。 [Ox789 ABC21]。\nX 的最低 有效 字 节设 置成全 1, 其他 字 节都保持不 变 。 [Ox 8 7 65 43FF] 。\n练习题 2. 13 从 20 世纪 70 年代末到 80 年代末, Dig it a l Eq uipm e nt 的 V A X 计 算机是一种非 常流行 的 机 型。 它 没 有 布 尔运 算 A ND 和 OR 指令, 只 有 b i s ( 位 设 置)和b i c ( 位 清除)这两种指令。 两种指令的输入都是 一个 数据 字 x 和 一个 掩码 字 m。 它 们生成 一个 结果 z , z 是由根据掩码 m 的位 来修 改 x 的位得到 的。使用 b i s 指令 , 这 种修改就是在 m 为 1 的每个位置上, 将 z 对应 的位设置为 1 。 使 用 b 江:指 令 , 这 种修 改就是在 m 为 1 的 每 个 位 置, 将 z 对应 的位设 置为 0。\n为 了看清楚这些运 算与 C 语言位级运算的关系,假 设我们 有两个 函数 bi s 和 bi c 来实\n现位设置和位清除操作。只想用这两个 函数, 而 不使 用任何其他 C 语言运算, 来 实现按位1 和^运算。填写下列代码中缺失的代 码。提示: 写出 b i s 和 b i c 运算的 C 语言表达式。\nI* Declarations of functions implementing operations bis and bic *I int bis(int x, int m);\nint bic(int x, int m);\nI* Compute xly using only calls to functions bis and bic *I int bool_or(int x, int y) {\nint r esul t = · return result;\n}\n. I* Compute x-y using only calls to functions bis and bic *I int bool_xor(int x, int y) {\nint result=· return result;\n2. 1. 8 C 语 言 中 的 逻 辑 运 算\nC 语 言 还 提 供 了 一 组 逻 辑 运 算 符 II 、\u0026amp;.\u0026amp;. 和!, 分 别 对 应 于 命 题 逻 辑 中 的 O R 、A N D 和 NOT 运算。逻辑运算很容易 和位级运算 相 混淆, 但 是 它 们 的 功 能 是 完 全 不 同 的 。 逻 辑运算认 为所有非零的参数都 表示 T R U E , 而参数 0 表示 F ALS E。它们返回 1 或者 o, 分别\n表示结果为 T RU E 或者为 F ALSE。以下是一些表达式求值的示例。\n可 以 观 察 到 , 按 位 运算 只 有 在特 殊 情 况 下 , 也 就 是 参 数 被 限 制 为 0 或者 1 时 , 才 和 与\n其对应的逻辑运算 有相同的行为。\n逻辑运算符 && 和 II 与它们对应的位级运算 & 和1 之间第二个重要的区别是,如果 对第一个参数求值就能确定表达式的结果,那么逻辑运算符就不会对第二个参数求值。因此, 例如, 表达式 a \u0026amp;\u0026amp;S/ a 将不会造成被零除, 而表达式 p \u0026amp;\u0026amp;*p +叫 1 不会导致间接引用空指针。沁因 练习题 2. 14 假设 x 和 y 的 字节值 分别为 Ox 66 和 Ox 3 9 。 填写下表 , 指明各 个 C 表达\n式的字节值。\n表达式 值 表达式 值 X \u0026amp; y X I y X \u0026amp;\u0026amp; y X I y ~x I ~y !x 11 !y X \u0026amp; ! y X \u0026amp;\u0026amp; y 练习题 2. 15 只使 用位 级和逻 辑运 算, 编写 一个 C 表达式, 它 等价 于 x = = y 。换句话\n说 , 当 x 和 y 相 等 时 它 将返 回 1, 否则 就 返 回 0。\n2. 1. 9 C 语言中的移位运算\nC 语言还提供了一组移位运算 , 向左或者向右移动位模式。对于一个 位表示为[ x w- 1 • Xw-Z• …, x。]的操作数 x , C 表达式 x\u0026lt;\u0026lt;k 会生成一个值, 其位表示为[ Xw- k- 1 , Xw- k- 2 , …,\nX o , 0, … , O] 。也 就是说, x 向左移动 k 位, 丢弃最高的 K 位,并 在右端补 k 个 0。移位量应该是 一个 o w—1 之间的值。移位运算是从左至右可结合的, 所以 x \u0026lt;\u0026lt; j \u0026lt;\u0026lt; k 等价于\n(x « j ) « k 。\n有一个相应的右移运算 x \u0026gt;\u0026gt;k , 但是它的行为有点 微妙。一般而言, 机器支持两种形式的右移: 逻辑右移 和算术右移。逻辑右移在左端补 K 个 o, 得到的结果是[ O, …, o,\nXw- 1 • Xw- 2 • …, Xk] 。 算术右移是在左端补 K 个最高有效位的值, 得到的结果是[ x w- 1 • …, Xw- 1 , Xw- 1 , Xw-2, …, Xk ] 。 这种做法看 上去可能有点奇特, 但是我们会发现它对有符号整数数 据的运算非常有用。\n让我们来看一个例子 ,下 面的 表给出 了对一个 8 位参数 x 的两个不同的值做不同的移位操作得到的结果:\n操作 值 参数 x [01100011] [10010101] X \u0026lt;\u0026lt; 4 [00110000] [OIO10000] X \u0026gt;\u0026gt; 4 (逻辑右移) [0000011O] [00001001] X \u0026gt;\u0026gt; 4 ( 算术右移) [00000110] [11111001] 斜体的数字表示的是最右端(左移)或最左端(右移)填充的值。可以看到除了一个条目 之外, 其他的都包含填充 0。唯一的例外是算术右移[ 10010101] 的情况。因为操作数的最高位是 1, 填充的值就是 1。\nC 语言标准并没有明确定义对千有符号数应该使用哪种类型的右-移\n算术右移或者逻辑\n右移都可以。不幸地, 这就意味着任何假设一种或者另一种右移形式的代码都可能会遇到可移植性问题。然而, 实际上, 几乎所有的编译器/机器组合都对有符号数使用算术 右移,且许多\n程序员也都假设机器会使用这种右移。另一方面,对于无符号数,右移必须是逻辑的。\n与 C 相比, J ava 对 于 如何进行右移有明确的定义。表达是 x \u0026gt;\u0026gt;k 会将 x 算术右移 k 个位置,而 x \u0026gt;\u0026gt;\u0026gt;k 会对 x 做逻辑右移。\nm 移动 k 位, 这里 k 很大\n对于一 个由 w 位组成的数据类型,如 果 要 移动 k w 位会得 到什 么结果呢? 例如, 计算下 面的 表达式会得到什 么结 果,假 设 数 据 类型 i n t 为 w = 3 2 :\nint lval = OxFEDCBA98«32; int aval = OxFEDCBA98»36; unsigned uval = OxFEDCBA98u»40;\nC 语言标准很 小心地规避了说 明 在 这种情况下该如何做。在许多机 器上 , 当移动一个\nw 位的值 时,移位 指令只考虑位 移量的低 log2w 位,因 此实际上位移量就是通过计 算 k mod\nw 得到的。例如, 当 w = 32 时, 上面三个 移位运算分别是移动 0、4 和 8 位,得 到结果:\n1 val OxFEDCBA98\na val OxFFEDCBA9\nuval OxOOFEDCBA\n不过这种行 为对于 C 程序来说是没有保证的, 所以应该保持位移量小于待移位值的位数。另一方面, J a va 特别要 求位移数量应 该按照我们前面所 讲的求模的方法来计 算。\nm 与移位运算有关的操作符优先级问题\n常常有人会写这样的表达式 1 \u0026lt;\u0026lt;2+3\u0026lt;\u0026lt;4 , 本意是 (1 « 2 ) + (3« 4 ) 。 但 是 在 C 语言中, 前面的 表达式等价于 1 \u0026lt;\u0026lt; (2 +3 ) \u0026lt;\u0026lt; 4, 这是由于加法(和减法)的优先级比移位运算要高。然后 ,按 照从 左至右 结合性规则 ,括 号应该是这样打的 ( l \u0026lt;\u0026lt; (2+3)) \u0026lt;\u0026lt;4, 得到的结果是\n512, 而不是 期望的 52 。\n在 C 表达式中搞错优先级是一种常见的程序错误原因, 而且常常很难检查出 来。 所以.当你拿不准的时候,请加上括号!\n沁§ 练习题 2. 16 填写下表,展示不同移位运算对单字节数的影响。思考移位运算的最好方式是使用二进制表示。将最初的值转换为二进制,执行移位运算,然后再转换回 十 六进 制。每个答案都 应该是 8 个二进制数 字或 者 2 个十 六进 制 数 字。\n2. 2 整数表示\n在本节中, 我们描述用位来编码整数的两种不同的方式: 一 种 只 能 表示非负数, 而另一 种能 够表示负数、零和正数。后面我们将会看到它们在数学属性和机器级实现方面密切相关。我们还会研究扩展或者收缩一个已编码整数以适应不同长度表示的效果。\n图 2-8 列出了我们引入的数学术语,用 于 精确定义和描述计算机如何编码和操作整数。这些术语将在描述的过程中介绍,图在此处列出作为参考。\n符号 类型 含义 B2wT 函数 二进制转补码 B2U,,, 函数 二进制转无符号数 U2B,,, 函数 无符号数转二进制 u2r:切 函数 无符号转补码 T2Bw 函数 补码转二进制 T2Uw TMin \u0026ldquo;\u0026rsquo; TMawx UMawx t +W u +“ *t切 *wu I w u \u0026ldquo;' 函数常数常数常数操作操作操作操作操作操作 补码转无符号数最小补码值 最大补码值最大无符号数补码加法 无符号数加法补码乘法 无符号数乘法补码取反 无符号数取反 图 2-8 整数的数据与算术操作术语。下标 w 表示数据表示中的位数\n2. 2. 1 整型数据类型\nC 语言支持多种整型数 据类型 表示有限范围的整数。这些类型如图 2-9 和图 2- 10\n所示, 其中还给出了 "典型\u0026rdquo; 32 位和 64 位机器的取值范围。每种类型都能用关键字来指定大小,这些关 键字包括 c h ar 、s h or t 、l o n g , 同时还可以指示被表示的数字是非负数\n(声明为 u n s i g n e d ) , 或者可能是 负数(默认)。如图 2-3 所示,为 这些不同的大小分配的字节数根据程序编译为 32 位还是 64 位而有所不同。根据字节分配, 不同的大小所能表示的值的范圉是不同的。这里给出 来的唯一一个与机器相关的取值范围是大小指示符 l o n g 的。大多数 64 位机器使用 8 个字节的表示, 比 32 位机器上使用的 4 个字节的表示的取值范围大很多。\n图 2-9 32 位 程 序上 C 语言整型数据类型的典型取值范围\nC数据类型 最小值 最大值 [signed]char -12。8 -3276。8 -2 147 483 64。8 -9 223 372 036 854 775 80。8 -2 147 483 64。8 -9 223 372 036 854 775 80。8 127 unsigned char 255 short 32 767 unsigned short 65 535 int 2 147 483 647 unsigned 4 294 967 295 long 9 223 372 036 854 775 807 unsigned long 18 446 744 073 709 551 615 int32_t 2 147 483 647 u i n 七32_ t 4 294 967 295 i n七64_ t 9 223 372 036 854 775 807 u i n 七64_ 七 18 446 744 073 709 551 615 图 2- 10 64 位程序上 C 语言整型数据类型的典 型取值范围\n图 2- 9 和图 2- 1 0 中一个很值得注意的特点是取值范围不是对称的一— 负数的范围比整数的范围大 1。当我们考虑如何表示负数的时候, 会看到为什 么会这样。\nC 语言标准定义了每种数 据类型必须能够表示的最小的取值范闱。如图 2- 1 1 所示 ,它们的取值范围与图 2- 9 和图 2- 1 0 所示的典型实现一样 或者小一些。特别地,除 了 固定大小的数据类型是例外,我们看到它们只要求正数和负数的取值范围是对称的。此外,数据类 型 i nt 可以用 2 个字节的数字来实现, 而这几 乎回退到了 1 6 位机器的时代。还可以 看到, l ong 的大小可以用 4 个字节的数字来实 现, 对 32 位程序来说 这是很典型的。固定 大小的数据类型保证数值的范围与图 2- 9 给出的典型数值一致 , 包括负数与正数的不对称性。\nC数据类型 最小值 最大值 [signed]char unsigned char - 12。7 127 255 short unsigned short -3276。7 32 767 65 535 int unsigned 一32 76。7 32 767 65 535 long unsigned long -2 147 483 64。7 2 147 483 647 4 294 967 295 i n 七3 2_ t uint32_t -2 147 483 64。8 2 147 483 647 4 294 967 295 i n七64_ t uint64_t -9 223 372 036 854 775 80。8 9 223 372 036 854 775 807 18 446 744 073 709 551 615 图 2- 11 C 语言的整型数据类型的保证的取值范围 。C 语言标准要求这些数据类型必须至少具有这样的取值范围\n区 C、C++ 和 Java 中的有符号和无符号数\nC 和 C++ 都支持有符号(默认)和无符号数。 J a v a 只支持有符号数 。\n2.2. 2 无符号数的编码\n假设有一个整数数据类型有 w 位。我们 可以将位向量写成 王, 表示整个向量, 或者写成[ X w- 1 • X w- 2 • …, x。J\u0026rsquo; 表示向量中的每一位。把 I 看做一个二进制表示的数,就 获得\n了;的无符号表示。在这个编码中, 每个位 X , 都取值为 0 或 1 , 后一种取值意味着数值2\u0026rsquo; 应为数字值的一部分。我们用一个 函数 B2队 ( Binary to U nsigned 的缩写, 长度为 w ) 来表示:\n原理:无符号数编码的定义\n对向量 士=[立 - I\u0026rsquo; 五 - 2\u0026rsquo; …, Xo ] :\n,,- 1\nB 2U\u0026rdquo;\u0026rsquo; G ) 辛 x , 2'\n,-o\nC2. 1)\n在这个等式中, 符号“兰” 表示左边被定 义为等千右边。函数 BZU \u0026ldquo;\u0026rsquo; 将一个长度为 w 的\n0、1 串映射到非负整数。举一个示例,图 2-11 展示的是下面几种情况下 BZU 给出的从位向掀到整数的映射:\n(2. 2)\n在图中, 我们用长度为 2\u0026rsquo; 的指向右侧箭头的 条表示每个位的位置1。每个位向量对应的数值 就等于所有值为 1 的位对应的条 23 8\n的长度之和。\n让我们来考虑一下 w 位所能表示的值的范围。最小值是用位向量[ 00…\nO] 表示,也 就是整数值 o, 而最大值是\n用位向量[ 11 …l ] 表示, 也就是整数 值\nUMa x w = 江 =沪 - 1 。以 4 位情况\n,- o\n22 = 4 -\n0 I 2 3 4 5 6 7 8 9 10 II 12 13 14 15 16\n(0001)\n(0101)\n为例, UM a x4 = B 2U 八 [ 1111 ] ) = 24 - (1011]\n1 = 1 5。因此, 函数 B2U心 能够被定义 (1111)\n为一个映射 B 2Uw : { O, 1}w - { o, … 图 2-12 w= 4 的无符号数示例。当二进制表示\n沪 — 1 } 。 中位 t 为 1, 数值就会相应加上 2'\n无符号数的二进制表示有一个很重要的属性,也 就是每个介千 o ~ wz - 1 之间的数都\n有唯一一个 w 位的值编码。例如, 十进制 值 11 作为无符号数,只 有一个 4 位的表示, 即\n[ 1011] 。我们用数 学原理来重点 讲述它 ,先 表述原理再 解释。原理:无符号数编码的唯一性\n函数 B2U u. 是一个双射 。\n数学术语双射是指一个函数 J 有两面: 它将数值 x 映射为数值 y , 即 y = f(x), 但它也可以反向操作, 因为对每一个 y 而言, 都有唯一 一个数值 x 使得f 位 )= y 。这可以用反函数 1- 1 来表示, 在本例中, 即 x = 1- 1 ( y ) 。 函 数 B2U u. 将每一个长度为 w 的位向量都映\n射为 0~ w2\n—1 之间的一个唯一值; 反过来 , 我们称其为 U2B w( 即“无符号数到二进制\u0026rdquo;) '\n在 0 ~ 沪 — 1 之间的每一个整数都 可以映射为一个唯一的长度为 w 的位模式。\n2. 2. 3 补码编码\n对于许多应用, 我们还希望表示负数值。最常见的有符号数的计算机表示方式就是补码( t wo \u0026rsquo; s-com plemen t ) 形式。在这个定义中, 将字的最高有效位解 释为负权 ( nega tive weight ) 。我们用函数 B2兀 ( Bina ry to T wo \u0026rsquo; s-com plement 的缩写,长度 为 w ) 来表示:\n原理:补码编码的定义\n对向量 :i:= [ 江 - 1 \u0026rsquo; 五 - 2\u0026rsquo; … ,工。]:\n正 2\nB 2T wG ) 主 — 乓 -1 2匹 1 + x , 2'\n;=o\n(2. 3)\n最高有效位 Xw-1 也 称 为 符号位, 它 的 "权 重 ” 为— zw-1 , 是无符号表示中权重的负\n数。符号 位被设置为 1 时, 表 示值为负, 而 当 设 置为 0 时, 值为非负。这里来看一个示例, 图 2-13 展示的是下面几种情况下 B2T 给出的从位向量到整数的映射。\nB2T4 ([0001]) = — 0 • 23 + 0 • 22 + 0 • 21 + 1 • 2° = 0 + 0 + 0 + 1 = 1\nB2兀 c[ o1 01] ) =-o. 23 +1. 22 +o. 21 +1. 2° = 0+4+0+1 = 5\nB2T4 ([1011]) = — 1 • 沪 + 0 • 22 + 1 • 21 + 1 • 2° = - 8 + 0 + 2 + 1 =- 5\nB 2T 八[ 1111 ] ) = — 1 • 沪+ 1 • 22 + 1 • 21 + 1 • 2° = — 8 + 4 + 2 + 1 = — 1\n(2. 4)\n在这个图中,我们用向左指的条表示符号位具有负权重。于是,与一个位向量相关联的数值是由可能的向左指的条和向右指的条加起来决定的。\n我们可以看 到, 图 2-12 和图 2-13 中的位模式都是一样的, 对 等 式 ( 2. 2) 和等式 ( 2. 4) 来说也 是一 样 , 但 是 当 最高有效 位是 1 时,数 值 是 不 同 的 , 这是因为在一种情况中,最高有效位的权重 是十8 , 而在另一种情况中,它的权重是一8 。\n[0001]\n[0101]\n[1011]\n[1111]\n. 寄宅器嚣- 23 = -8\n22 = 4 -\n-8 -7 -6 -5 -4 -3 -2 -1 0 l 2 3 4 5 6 7 8\n让 我们来 考 虑一下 w 位补码所能 图 2-13 w = 4 的补码示例。把位3 作为符号位, 因此当它\n表示的值的范围。它能表示的最小值是 位向量[ 10…O] ( 也 就 是 设 置 这个位为负\n为 1 时, 对数值的影响是一 沪=—8。 这 个 权 重\n在图中用带向左箭头的条表示\n权,但 是 清除 其 他 所 有 的 位 ),其 整数值为 TMin产三— zw-1 。 而最大值是位向量[ 01… 1]\nur-2\n( 清除具有负权的位, 而设 置其他所有的位),其 整数值为 TMa x 心 == 2· = zur-1 - 1 。 以\n,- o\n长度为 4 为例,我们 有 TMin4 = B 2兀 ( [ 1000] ) = —穸= - 8 , 而 T Ma x4 =B2T4 ([0111]) =\n沪 + 21+ 2°= 4+ 2+ 1 = 7 。\n我们可以看出 B2兀 是一个从长度为 w 的位模式到 TMin 心 和 TMa x 切之 间 数 字 的 映射,写 作 B 2T w : {O, l}w \u0026mdash;- { T M i nw, … , T Ma x w }。 同 无 符 号 表 示 一样, 在 可 表 示的取值范围内的每个数字都有一个唯一的 w 位的补码编码。这就导出了与无符号数相似的补码数原理:\n原理:补码编码的唯一性\n函数 B2兀 是 一个双射。\n我们定义函数 T2B心(即 “补 码 到 二 进 制\u0026quot; )作为 B2兀 的反函数。也就是说,对 千每个数 x , 满足 TMinw女 T M a x 心,则 T 2B w( x ) 是 x 的(唯 一的)w 位模式。\n练习题 2. 17 假设 w = 4 , 我们能给每个可能的十六进制数字赋予一个数值,假设用一个 无符 号或者补码表 示。 请根据这些表 示, 通过写 出 等式( 2. 1 ) 和等 式 ( 2. 3 ) 所 示的求和公 式 中的 2 的非零次幕, 填写下表:\nX\n十六进制 二进制\nB2 U.( 又) B2T 4仅)\nOxE [1110] 23+22+21=14 - 23+2\u0026rsquo; + 2\u0026rsquo;=- 2\nOxO OxS Ox8 OxD OxF\n图 2-14 展示了针 对不同字长, 几个重要数字的位模式和数 值。前三个给出的是可表示的整数的范围,用 UMax w、TMin w 和 TMa x 心 来表示。在后面的讨论中, 我们还会经常引用到这三个特殊的值。如果 可以从上下文中推断出 w , 或者 w 不是讨论的主要内 容时,我们 会省略下 标 w , 直接引用 UMax 、TMin 和 TMa x 。\n图 2-1 4 重要的数字。图中给出了数值和十六进制表示\n关于这些数字, 有几点值得注意。第一, 从图 2-9 和图 2- 10 可以看到, 补码的范围是不对称的: I TMinl = I TM曰 + 1 , 也就是说, TMi n 没有与之对应的正数。正如我们将\n会看到的,这导致了补码运算的某些特殊的属性,并且容易造成程序中细微的错误。之所 以会有这样的不对称性, 是因为一半的位模式(符号位设置为 1 的数)表示负数,而另 一半\n(符号位设置为 0 的数)表示非负数。因为 0 是非负数, 也就意味着能表示的整数比负数少一个。第二, 最大的无符号数值刚好比补码的最大值的 两倍大一点: UMa工w = 2TM a工w +\nl 。补 码表示中所有表示负数的位模式 在无符号表示中都变成了 正数。图 2-14 也 给出了 常\n数— l 和 0 的表示。注意一1 和 UMa工 有同样的位表示一 - 个全 1 的串。数值 0 在两种表示方式中都是 全 0 的串。\nC 语言标准并没有要求 要用补码形式来表示有符号整数, 但是几 乎所有的机器都是这\n么做的。程序员如果希望代码具有最大可移植性,能够在所有可能的机器上运行,那么除 了图 2-11 所示的那些范围之外,我 们不应该假设任何可表示的数值范围,也 不应该假设有符号数会使用何种特殊的表示方式。另一方面,许多程序的书写都假设用补码来表示有 符号数, 并且具有图 2-9 和图 2- 10 所示的 "典型的” 取值范围, 这些程序也 能够在大量的机器和编译器上移植。C 库中的文件\u0026lt; l i mi t s . h \u0026gt;定义了一组常量, 来限定编译器运行的这台机器的 不同整型数据类型的取值范围。比如,它 定义了常量 IN T_ MAX、 I NT_ MIN 和\nUINT_MAX, 它们描述了有符号和无符号整数的范围。对于一个补码的机器, 数据类型 i n t\n有 w 位, 这些常量就对应 于 TMa工U 、 TMi nw 和 UMa工 的值。\nm 关千确定大小的整数类型的更多内容\n对于某些程序来说,用某个确定 大小的表示来编码数据类型非常重要 。例如,当编 写程序 , 使得机器能够按照一个标准协议在因特网上通信时,让数据类型与协议指定的数据类型兼容是 非常重要 的。我们前面看到了,某些 C 数据类型, 特别是 l ong 型,在不同的机器上有不同的取值范围,而实际上 C 语言标准只指定了每种数据 类型的 最小范围,而不是 确定的 范围。虽然 我们可以选择与大多数机器上的标准表示兼容的数据类型,但是这也不能保证可移植性。\n我们已经见 过了 3 2 位和 64 位版本的确定 大小的整数类型(图2-3 ) , 它们是一个更大数据类型 类的 一部 分。ISO C99 标准在 文件 s t d i nt . h 中引入了这个整数类型类。 这个文件定 义了一组数据类型, 它们的声明 形如 i n t N_t 和 u i nt N_t , 对 不 同的 N 值 指 定\nN 位有符号和无符号整数。N 的具体值与 实现 相 关 ,但 是 大 多 数 编译 器 允 许的值 为 8、\n16、32 和 64。因此, 通过将它的 类型 声明 为 u i n t l 6_ t , 我们可以无歧 义地声明一个 16\n位无符号 变量 , 而如 果 声明为 i n t 32 _ t , 就是一个 32 位有符号变量 。\n这些数据类型对应着一组宏,定 义了每 个 N 的值对应的最小和最大值 。这些宏名字\n形如 IN TN_MIN 、IN TN_ MAX 和 UI NTN_MAX 。\n确定宽度类型的带格式打印需要使 用 宏,以 与 系统 相关的方式扩展 为 格 式 串。 因此, 举个例子来说 , 变量 x 和 y 的类型是 i nt3 2_t 和 ui nt 64_七, 可以通过调用 pr i n t f 来打印它们的值,如下所示:\nprintf(\u0026ldquo;x = %\u0026rdquo; PRid32 \u0026ldquo;, y = %\u0026rdquo; PRiu64 \u0026ldquo;\\n\u0026rdquo;, x, y);\n编译为 64 位程序时, 宏 PRi d 32 展 开成字符 串 \u0026quot; d \u0026ldquo;\u0026rsquo; 宏 PRi u 64 则展 开成 两 个 字符 串\u0026quot;1\u0026rdquo; \u0026quot; u\u0026quot; 。 当 C 预处理器遇 到仅 用空格(或其他空白 宇符)分隔的一个字符 串常量序列 时, 就把 它们 串联 起 来。 因此,上 面的 pr i nt f 调用就变成 了 :\nprintf(\u0026ldquo;x = %d, y = %lu\\n\u0026rdquo;, x, y);\n使用宏能保证: 不论代码是如何被 编译的 ,都 能生成 正确的格式字符 串。\n. 关于整数数据类型的取 值范围和表示,Java 标准是非常明确的。它要求采用补码表示,取值范围与图 2-10 中 64 位的情况一 样。在Java 中,单 字节数据类型称为 byt e , 而不是 char 。这些非常具体的要求都是为了保证无论在什么机器上运行, Ja va 程序都能表现地完全一样。\n田 日 有符号数的 其他表示方法\n有符号数还有两种标准的表示方法:\n反码( Ones \u0026rsquo; Com plement ) : 除了最高有 效位的权是一 ( 2心- I —1 ) 而不是 — 2心- I \u0026rsquo; 它\n和补码是一样的:\nB 20 切(印 土— X ur\u0026ndash;1 ( 2urI-\n-1) +\nur-2\nX 心\n,-o\n原 码(Sign-Magnitude) : 最高有效位是符号位,用来确定剩下的位应该取负权还是正权:\n匹 2\nB 2S卫 )辛( - l Yw\u0026ndash;1 • ( x;t)\ni - 0\n这两种表 示方法都有一个奇怪的属性, 那就是 对于数 字 0 有两种 不 同的编码 方式。这两种表 示方法,把 [ 00…O] 都解释为 十0。 而值 —0 在原码中表 示为 [ 10 …O] , 在反码\n中表示为[ 11…1] 。 虽 然过去生产过 基于反 码表示的机 器, 但 是 几乎所有的现代机 器都\n使用补码 。 我们将看到在浮点数中有使 用原码编码。\n请注意补码 ( T wo \u0026rsquo; s co m plem en t ) 和反码 ( O ne s \u0026rsquo; co m plem e nt ) 中撇 号的 位 置是 不 同的 。 术语补 码 来 源 于这样一 个情况, 对 于非 负数 X , 我 们 用 2\u0026quot;\u0026rsquo; - x ( 这 里 只 有一 个 2 ) 未计算—x 的 w 位表示。术语反码 来源 于这样一 个属性 , 我 们用[ 11 1 …1 ] - x ( 这里有很 多个 1 ) 来 计 算 - x 的 反 码 表 示。\n为了更好地理解补码表示,考虑下面的代码:\nshort x = 12345; short mx = -x;\nshow_bytes((byte_pointer) \u0026amp;:x, sizeof(short)); show_bytes ((byte_pointer) \u0026amp;:mx, sizeof (short));\n当在大端法机器上运行时, 这 段 代 码 的 输 出 为 3 0 3 9 和 c f c7, 指 明 x 的 十六进制表示为 Ox 3 03 9 , 而 mx 的 十 六 进 制 表 示 为 Ox CFC7 。 将 它 们 展 开 为 二 进 制 , 我 们 得 到 x 的 位模 式 为[ 0011 000 00011 1 001 ] , 而 mx 的 位 模 式 为 [ 11 0011 11 11 0 001 11 ] 。 如 图 2-15 所 示 , 等式 ( 2. 3 ) 对 这两 个 位 模 式 生成 的 值 为 1 2 345 和 一1 2 345 。\n。 。 1 1 16 384 -32 768 1 1 16 384 32 768 图 2-1 5 12 345 和一1 2 345 的补码表示,以及 53 191 的无符号表示。注意后面两个数有相同的位表示\n练 习题 2. 18 在 第 3 章 中, 我 们将看到由反汇编 器 生成的列表, 反 汇编 器 是 一种将可执 行 程序 文件 转换回可读 性更好的 ASCII 码形 式的程序。这些 文件包含 许 多 十 六进制数 字 , 都是用典型的补码形 式 来 表 示 这 些 值。 能 够 认 识这 些 数 字 并 理 解 它 们 的 意 义\n(例如它们是正数还是负数),是一项重要的技巧。\n在下面的 列 表 中, 对千标 号为 A ~ I ( 标记在右边)的那些行 , 将指令名( s ub 、mov\n和 a d d ) 右边 显示的 ( 3 2 位补码形 式 表 示的)十六进制值 转换 为 等价的十进制值。\n4004d0: 48 81 ec eO 02 00 00 sub $0x2e0,%rsp A . 4004d7: 48 8b 44 24 a8 mov -Ox58(%rsp),%rax B. 4004dc: 48 03 47 28 add Ox28(%rdi),%rax C. 4004e0: 48 89 44 24 dO mov %rax,-Ox30(%rsp) D . 4004e5: 48 8b 44 24 78 mov Ox78(%rsp),%rax E. 4004ea: 48 89 87 88 00 00 00 mov %rax,Ox88 (ir 儿 di ) F . 4004f1: 4004f8: 48 8b 00 84 24 f8 01 00 mov Ox1f8 (%rsp) , %rax G . 4004f9: 48 03 44 24 08 add Ox8(%rsp),%rax 4004fe: 48 89 84 24 cO 00 00 mov %rax,Oxc0(%rsp) H. 400505: 00 400506: 48 8b 44 d4 b8 mov -Ox48(%rsp, ir 人\ndx , 8 ) , %r ax I .\n2. 4 有符号数和无符号数之间的转换\nC 语言允许在各种不同的数字数据类型之间做强 制类型转换。例如, 假设变量 x 声明为i nt , u 声明为 u n s i g n e d 。表达式 (u n s i g n e d ) x 会将 x 的值转换成一个无 符号数值,而(int) u 将 u 的值转换成一个有符号整数。将有符号数强制类型转换成无符号数, 或者反过来,会得到什么结果呢?从数学的角度来说,可以想象到几种不同的规则。很明显,对 于在两 种形式中都能 表示的值, 我们是想要保 持不变的 。另 一方面,将负 数转换成无符号数可能 会得到 0。如果 转换的无符号数太大以至于超出了补码能够表示的范围, 可能会得到 T Ma 工。 不过, 对千大多数 C 语言的实现来说, 对这个问 题的回答都是从位级角度来看的, 而不是数的 角度。\n比如说, 考虑下面的代码:\nshort int v = - 12345 ·\nunsigned short uv = (unsigned short) v;\n3 printf(\u0026ldquo;v = %d, uv = %u\\n\u0026rdquo;, v, uv);\n在一台采用补码的机器上,上述代码会产生如下输出:\nV = -12345, UV= 53191\n我们看到 , 强制类型转换的结果保持位值不变,只是 改变了解释这些位的方式。在图 2-1 5 中我们看 到过 ,一 1 2 3 45 的 16 位补码表示与 53 1 91 的 1 6 位无符号表示是完全一样的。将s ho 江 强制类型转换为 u n s i g ne d s h or t 改变数值, 但是不改变位表示。\n类似地,考虑下面的代码:\nunsigned u = 4294967295u; I* UMax *I\nint tu= (int) u;\nprintf(\u0026ldquo;u = %u, tu= %d\\n\u0026rdquo;, u, tu);\n在一台采用补码的机器上,上述代码会产生如下输出:\nu = 4294967295, tu= -1\n从图 2-1 4 我们可以看到 , 对于 3 2 位字长来 说, 无符号形式的 4 294 967 295 ( UM a 工32) 和补码形 式的- 1 的位模式是完全一样的。将 u n s i g n e d 强制类型转换 成 i n t , 底层的位表示保持不变。\n对于大多数C 语言的实现,处理同样字长的有符号数 和无符号数之间相互转换的一般规则是: 数值可能会改 变,但是位模式不 变。让我们用 更数学化的形式来描述这个规则。我们定义函数 U 2Bw 和 T2 B,,_,, 它们将数值映射为无符号数和补码形式的位表示。也就是说,给\n定 O¾ x ¾ UM a 工心范围内的一个整数 工, 函数 U 2B心( x ) 会给出 工的唯一的 w 位无符号表示。相似地,当 工满足 T M in心\u0026lt; 年;; TM釭心, 函数 尥凡 m 会给出工的 唯_的 w 位补码表示。\n现在, 将函数 T 2U 切 定义为 T 2U心丘 )土B 2U w( T 2 B 心 ( x ) ) 。 这个函数的输入是一个\nTMinw T M a x 立的数,结 果得到一个 o UMa x 心 的 值, 这里两个数有相同的位模式, 除了参数是无符号的, 而结果是以补码表示的。类似地, 对于 o UMax切 之间的值 x , 定义函数 U2兀 为 UZT u. 位) 主B2兀 (U2B w位))。生一成个数的无符号表示和 工的补 码表示相同。\n继续我们前 面的例子,从 图 2-15 中, 我 们看到 T 2U,6 ( — 1 2 345) =53 191, 并且\nU2T 16 (53 1 91) = — 1 2 345 。也就是说, 十六进制表示写作 Ox CFC 7 的 16 位位模式既是\n—1 2 345的补码表示, 又是 53 191 的无 符号表示。同时请注意 12 345 + 53 191 = 65 536 = 沪 。这个属性可以推广到 给定位模式的两个数值(补码和无符号数)之间的关系。类似地, 从图 2-1 4 我们看到 T 2U 32( —1 ) =4 294 967 295, 并且 U2T 32 (4 294 967 295) = —1。也就是\n说,无 符号表示中的 UMa工 有着和补码表示的—1 相同的位模式。我们在这两个数之间也能看到这种关系: l + UMax w = 2三\n接下来,我们 看到函数 U2T 描述了从无符号数到补码的转换, 而 T ZU 描述的是 补码到无符号的转换。这两个 函数描述了在大多 数 C 语言实现中这两种数据类型之间的强制类型转换效果。\n练 习 题 2. 19 利用你解答练 习题 2. 17 时填写的表格, 填 写下列描述函数 T2队 的表格。\n。 # 通过上述这些例子, 我们可以看到给定位 模式的补码与无符号数之间的关系可以表示为函数 T2U 的一个属性:\n原理: 补码转换为无 符号数\n对 满足 TMi n,,_. x T M a x心 的 x 有:\nT 2仄 ( x ) = {\nx + 2\u0026quot;\u0026rsquo; , X \u0026lt; 0\nx , x O\n(2. 5)\n比如, 我们看到 T 2U1 s C—12 34 5 ) = —12 345 + 216 =53 1 91 , 同时 T 2U心( - 1) = - l +\n沪 = U M a x wo\n该 属性可以通过比较公 式( 2. 1 ) 和公式( 2. 3 ) 推导出来。推导: 补码转换为无符号数\n比较等式( 2. 1) 和等式( 2. 3) , 我们可以发现对于位模式 x, 如果我们计 算 B2U w Cx) -\nB2T 切( 王)之差,从 0 到 w—2 的位的加权和将互相抵消掉,剩下一个值: B 2从(王)- B 2兀(动=\n平 一,I c z-w\nI - ( - z-w\nI ) ) = x ,,_.-1 沪 。 这就得到一个关系: B 2U心(x ) = 立 -I 沪 + B 2Tw Cx) 。我\n们因此就有\nB2U w ( T 2B w C x ) ) = T 2Uw ( x ) = x + x w-1- 沪 ( 2. 6)\n根据公式( 2. 5) 的两种情 况,在 x 的补码表示中 , 位 X u,- 1 决定了 x 是否为负。\n比如说, 图 2-1 6 比较了当 w = 4 时函数 B2U 和 B 2T 是如何将数值变成位模式的。对补码来说 , 最高有效位是符号位, 我们用带向左箭头的条来表示。对千无符号数来说, 最高有效位是正权重,我 们用带向右的箭头的条来表示。从补码变为无符号数, 最高有效位\n的权重从- 8 变为十8 。因 此 ,补 码表示的负数如果看成无符号数,值 会 增 加 24 = 1 6。因而, - 5 变成了十11 , 而—1 变成了十15 。\n绣嚣震璧翌黑殴醮-l = -s\n2 =8\n22 = 4 -\n- 8 - 7 -6 -5 -4 - 3 - 2 -1 0 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16\n[! Oll ]\n[ l lll ]\n图 2-1 6 比较当 w = 4 时无符号 表示和补码表示(对补码和无符号数来说,\n最高有效位的权重分别是 一8 和 十8\u0026rsquo; 因而产生一个差为 16 )\n图 2-1 7 说明 了函数 T 2U 的一般行为。如图所示,当 将 一 个 有 符 号 数 映 射 为它相应的无符号数时,负数就被转换成了大的正数,而非负数会保待不变。\n练习题 2. 20 请说明等式(2. 5) 是如何应用 到解答练 习题 2. 19 时生成的表格 中的各项 的。反过来看, 我们希望推导出一个无符号数 u 和与之对应的有符号数 U2Tw( u) 之间的关 系: 原 理: 无符 号数转换为补 码\n对 满足 0冬u UMa x ,., 的 u 有:\n.该原 理证明如下:\nuz 兀 ( u ) = { u,\nu — 沪 ,\n三 TMa x w u \u0026gt; TMaxw\n( 2. 7)\n推导:无 符 号 数 转换为补码\n设 u = U2BwCu) , 这个位向量也是U2兀 ( u) 的补码表示 。公式(2. 1) 和公式(2. 3) 结合起来有\nU2Tw(u) = — U ur- 1 沪 十 u (2. 8)\n在 u 的无符 号 表示中, 对公 式 ( 2. 7 ) 的两 种情况来说, 位 u印一 ]决定 了 u 是 否 大 于\nTMa工切 = 2-w\nl -10 ■\n图 2-18 说明了函数 U 2T 的行 为。对于小的数( T M a 工w)\u0026rsquo; 从 无 符 号 到 有 符 号 的 转 换将保留数字的原 值。对于大的数( \u0026gt; TMa工切) ,数 字 将被转换为一个负数值。\n2w 2 w\n无符号数, - · 斗尸►\n+2 w\u0026ndash;1 T\n. t 2 wI- 无符号数\n- 2 w-1 .l \u0026rsquo;l _2 w-l\n图 2-1 7 从补码到无符号数的转换。函数 图 2-18 从无符号数到补码的转换。函数 U2T T 2U 将负数转换 为大的正数 把大千 zw一l —1 的 数 字 转 换 为 负 值\n总结一下, 我们考 虑无符号与补码表示之间互 相转换的结果。对于在范圉 O x 冬T M a x w 之 内 的 值 x 而 言 , 我们得到 T 2U 心 ( x ) = x 和 U 2兀 ( x ) = x 。也就是说,在 这个范即内的数字有相同的无符号和补码表示。对于这个范围以外的数值,转换需要加上或者减 去 zw。例如, 我们有 T 2U w ( - l ) =-1 + 2w = UM a x 心 最 靠 近 0 的负数映射为最 大的无符号数。在另 一个 极 端, 我们可以看到 T 2队 ( T M in w ) = — 2正 1 + 2 三 = 2 正 1 = T M a xw +\n1 — 最 小 的 负 数 映 射为一个刚好 在补码的正数范围之外的无符号数。使用图 2-1 5 的示例 , 我们能看到 T 2U 16 ( — 1 2 345) = 65 563+ —12 345=53 191。\n2. 2. 5 C 语言 中的 有符号 数与无符号数\n如图 2-9 和图 2- 10 所示, C 语 言 支 持 所 有 整型数据类型的有符号和无符号运算。尽管\nC 语言标准没有指定 有符号数要 采用某种表示, 但 是 几 乎 所 有 的 机 器 都 使 用 补 码。通常, 大 多 数 数 字 都 默 认 为是有符号的。例如,当 声 明 一 个 像 1 2 3 45 或者 Ox 1 A2B 这样的常星时, 这 个 值 就 被 认 为是有符号的。要创建一个无符号常量,必 须 加 上 后 缀 字 符 \u0026rsquo; u\u0026rsquo; 或者 \u0026rsquo; u \u0026rsquo;\u0026rsquo; 例 如 , 1 2 3 45 U 或 者 Ox 1 A2Bu 。\nC 语言允 许无符号数 和有符号数之间 的转换。虽 然 C 标 准没有精确规定应如何进行这种转换, 但 大多数系统遵循的原则是底层的位表示保持不变。因此,在 一 台 采用补码的机器上, 当从 无 符 号 数转换为有符号数时,效 果 就 是 应 用 函 数 U ZT w , 而从有符号数转换为无符号数时,就 是 应 用 函 数 T ZU w , 其 中 w 表示数据类型的位数。\n显式的强制类型转换就会导致转换发生,就像下面的代码:\nint tx, ty; unsigned ux, uy;\ntx \u0026quot;\u0026quot; (int) ux; uy\u0026quot;\u0026quot; (unsigned) t y;\n另外, 当一 种类 型 的表 达式被赋值给另外一种类型的变量时,转 换 是 隐 式 发 生 的 ,就像下面的代码:\nint tx, ty; unsigned ux, uy;\ntx = ux; I* Cast to signed *I uy = ty; I* Cast to unsigned *I\n当用 pr i nt f 输出数值时,分 别 用 指示符%d 、%u 和 %x 以 有 符 号 十进制、无符号十进制和十六进制格式输出一个数字。注意 pr i n 七f 没 有 使 用 任 何 类 型 信 息 ,所 以 它 可 以 用 指 示符%u 来 输 出 类 型 为 1 平 的数值,也 可 以 用 指 示符%d 输 出 类 型 为 un s i g ne d 的数值。例如, 考虑下面的代码:\nint X = -1;\nunsigned u = 2147483648; I* 2 to the 31st *I\nprintf(\u0026ldquo;x = %u = %d\\n\u0026rdquo;, x, x); printf(\u0026ldquo;u = %u = %d \\ n \u0026quot; , u, u);\n当 在 一 个 3 2 位机器上运行时,它 的 输 出 如 下 :\nX = 4294967295 = -1\nu = 2147483648 = -2147483648\n笫 2 章 信息的表示和处理 53\n在这两种情 况下, pr i n t f 首先 将这个字当作一个无符号数输出,然 后 把 它 当 作 一 个 有符号数输出。以下是实际运行中的转换函数: T2U32 (-1) =UMax3 z = 23 2 - 1 和 U 2 T32 (231) = 沪 —232 = - 231 = TMin32o\n由千 C 语言对同时包含有符号和无符号数表达式的这种处理方式,出 现 了 一 些 奇特的行为。当 执行一个运算时, 如果它的一个运算数是有符号的而另一个是无符号的,那 么 C 语言会隐式地将有符号参数强制类型转换为无符号数,并假设这两个数都是非负的,来执 行这个运算。就像我们将要看到的,这种方法对于标准的算术运算来说并无多大差异,但 是对于像< 和> 这样的关系运算符来说, 它 会 导 致非直观的结果。图 2- 1 9 展示了一些关系表达式 的示例以及它们得到的求值结果,这 里假设数据类型 i n t 表示为 3 2 位补码。考\n虑比较式- l \u0026lt; OU 。 因 为第二个运算数是无符号的,第 一 个运算数就会被隐式地转换为无符号数,因 此表达式就等价于 4 2 9 4 96 72 95 0 \u0026lt;0 0 ( 回想 T 2U ,,_,( — l ) = U M a x 心),这 个 答 案显然是错的。其他那些示例也可以通过相似的分析来理解。\n图 2-19 C 语言的升级规则的效果\n注: 非直观的情况标注了` *' 。 当一个运算 数是无符号的时候 ,另 一个运算数也被隐式投制转换为无符号 。\n将 TMin32 写 为 - 21 474 83647- 1 的 原 因请 参 见 网 络 旁 注 DATA:T MIN。\n练习题 2 . 21 假设在采用 补码运算的 3 2 位机器上对这些表达式求值, 按照 图 2- 1 9 的格式填写下表,描述强制类型转换和关系运算的结果。\n\u0026amp;JllEmlill C 语言中 TMin 的 写法\n在图 2- 1 9 和练习题 2 . 21 中, 我们很 小心地将 T M i n 32 写成- 2 1 7 4 8 3 6 4 7 - 1 。 为什 么\n不 简单地 写成 - 2 1 47 4 8 3 6 48 或者 Ox 8 0 0 0 0 0 0 0 ? 看一下 C 头 文 件 l i mi t s . h , 注意到它们使用 了跟 我们写 T M i n 32 和 T M a x 32 类似的方法:\nI* Minimum and maximum values a\u0026rsquo;signed int\u0026rsquo;can hold. *I\n#define INT_MAX 2147483647\n#define INT_MIN (-INT_MAX - 1)\n不幸的是, 补码表示的 不对称性和 C 语言的转换规则之间奇怪的交互 , 迫使 我们用\n这种不寻常的 方式 来写 T M i n 32 。 虽然 理解这 个问 题需要 我们钻研 C 语言标准的 一些比较隐晦的 角落, 但是它能 够帮助我 们充分领会 整数数据类型和表 示的 一些细微之处。\n2. 2. 6 扩展—个数字的位表示\n一个常见的运算是 在不同字长的整数之间转 换,同 时又保持数值不变。当然, 当目标数据类型太小以至千不能表示想要的值时, 这根本就是不 可能的。然而,从 一个较小的数据类型转换到一个较大的类型, 应该总是 可能的 。\n要将一个无符号数转换为一个更大 的数据类型, 我们只 要简单地在表示的开头添加 0。这种运算 被称为零扩展 ( zero e x t e ns io n ) , 表示原理如下:\n原理:无符号数的零扩展\n定义宽 度为 w 的位向量 U= [ u w- 1 , Uw- Z, … , u。] 和宽度为 w \u0026rsquo; 的位向 量 矿 = [ O , 0 , Uw- 1 , Uw- 2 , … , u。J\u0026rsquo; 其中 w \u0026rsquo; \u0026gt; w 。则 B ZU w ( u ) = B ZU w\u0026rsquo; ( 矿)。\n按照公式( 2. 1), 该原理 可以看作是 直接遵循了无符号数编码的定 义。\n要将一个补码数字转换为一个更大的数据类型, 可以执行一个符号扩展 ( s ig n exten­\nsion), 在表示中添加最高有效 位的值, 表示为如 下原理。我们用蓝色标出符号位 五- i\u0026rsquo;来突出它在符号扩展中的角色。\n原理:补码数的符号扩展\n定义宽度为 w 的位向量 王=[五 - I • Xw- Z • … , X。]和宽度为 w 的位向量 x \u0026rsquo; = [ x ,,.- 1 ,\n江 -· I \u0026rsquo; 工三 , 江 - 2 \u0026rsquo; … , x 。J\u0026rsquo; 其 中 w\u0026rsquo; \u0026gt; w 。 则 B Z兀 (x ) = B ZT w,(了 )。例如,考虑下面的代码:\nshort sx = -12345; I* -12345 *I\n2 unsigned short usx = sx; I* 53191 *I\n3 int x = sx; I* -12345 *I\n4 unsigned ux = usx; I* 53191 *I\n6 printf(\u0026ldquo;sx = %d:\\t\u0026rdquo;, sx);\n7 show_bytes((byte_pointer) \u0026amp;sx, sizeof(short));\n8 printf(\u0026ldquo;usx = %u:\\t\u0026rdquo;, usx);\n9 show_bytes((byte_pointer) \u0026amp;usx, sizeof(unsigned short));\n10 printf(\u0026ldquo;x = %d:\\t\u0026rdquo;, x);\n11 show_bytes((byte_pointer) \u0026amp;x, sizeof(int));\n12 printf(\u0026ldquo;ux = %u:\\t\u0026rdquo;, ux);\n13 show_bytes((byte_pointer) \u0026amp;ux, sizeof(unsigned));\n在采用补码表示的 3 2 位大端法机器上运 行这段代码时, 打印出如下输出:\nsx = -12345: cf c7 usx = 53191: cf c7\nX = -12345: ff ff cf c7\nux = 53191: 00 00 cf c7\n我们看到, 尽管—12 345 的补码表示和 53 191 的无符号表示在 16 位字长时是相同的, 但是\n在 32 位字长时却是不同的。特别地, - 12 345 的 十六进制表示为 Ox FFFF CFC7 , 而 53 191 的十六进制表示为Ox 0000 CFC7。前者使用的是符号扩展- 最开头加了 16 位, 都是最高有效位1 , 表示为十六进制就是Ox FFFF 。 后者开头使用16 个 0 来扩展, 表示为十六进制就是 Ox OOOO。\n图 2- 20 给出了从字长 w = 3 到 w = 4 的符号扩展的结果。位向量[ 1 01 ] 表示值- 4 + 1 =\n- 3。对它应用符 号扩展, 得到位向量[ 1101] , 表示的值—8 + 4+ 1 = —3。我们可以看到, 对于 w= 4, 最高两位的组合值是 - 8+ 4 = - 4 , 与 w = 3 时符号位的值相同。类似地, 位向量[ 111] 和[ 1111] 都表示值- 1。\n[101)\n[I IOI]\n-23 =-8\n-2 =-4\n22=4-\n21=2 lllt\n2°=.1\n-8 -7 -6 -5 -4 -3 -2 -I O I 2 3 4 5 6 7 8\n图 2-20 从 w= 3 到 w= 4 的 符 号 扩展示例。对于 w= 4, 最高两位组合权重为- 8+ 4= - 4, 与 w= 3 时 的 符号 位的权重一样\n有了这个直觉,我们现在可以展示保持补码值的符号扩展。推导:补码数值的符号扩展\n令 w\u0026rsquo; = w + k , 我们想要证明的是\nB 2T 叶 k ( [ 工u气 ,…,工匹-1\u0026rsquo; 工匹-I , Xw-z , … ,Xo ] ) = B 2T w ( [ 工u- 1 , Xw-z , … ,工。])\nk次\n下面的证明是对 K 进行归 纳。也就是说,如果 我们能够证明符号扩展一位保持了数值不变,那么符号扩展任意位都能保待这种属性。因此,证明的任务就变为了:\nB2T w+I([ 工正一 I , Xu- I , X匹-2\u0026rsquo; … , X。] ) = B 2T .,.( [ .r \u0026hellip;气 ,工一U\n用等式( 2. 3) 展开左边的表达式, 得到:\n2\u0026rsquo; … ,工。])\nB 2T叶 l ([ 乓 - I , X,一,_\nw\u0026mdash;1\nI\u0026rsquo; 乓 - 2\u0026rsquo; … ,工。])—=立-1 沪 + x i 2'\n, = O\nw\u0026mdash;2\n= - x .,,_- 1 沪 十 五 4 尸 + xi ;2\n, = O\nw-2\n= - Xu - ] ( 2W — z- u J ) + x.2'\n,=O\n\u0026quot; - 乓 _1 2-\u0026rdquo;\nw-2\n1 + x , 2'\n,= O\n= B 2兀 ( [ :r 一u I , Xw\u0026ndash;2 • … ,工。]) .\n我们使用的关键属性是 zw- zw-1 = zw- 1 。 因此,加上 一个权值为—沪 的位, 和将一个权值为\n- z-w 1的 位转换为一个 权值为 zw-1 的 位, 这两项运算的综合效果就会保持原始的数值 。练习题 2. 22 通过 应用 等式( 2. 3), 表明下 面每个位 向量都是 —5 的补 码表 示。A. [1011]\nB. [11011]\nC. [111011]\n可以看到第二个和第三个位向量可以通过对第一个位向量做符号扩展得到。\n值得一提的是,从一个数据大小到另一个数据大小的转换,以及无符号和有符号数字之间的转换的相对顺序能够影响一个程序的行为。考虑下面的代码:\nshort sx = -12345; unsigned uy = sx;\nI* -12345 *I\nI* Mystery! *I\nprintf (\u0026ldquo;uy = i 儿 u : \\ t \u0026quot; , uy); show_bytes((byte_pointer) \u0026amp;uy, sizeof(unsigned));\n在一台大端法机器上,这部分代码产生如下输出:\nuy = 4294954951: ff ff cf c7\n这表明当把 s hor t 转换成 u ns i g ne d 时, 我们先要改变大小,之 后 再完 成 从 有符号到无符 号 的 转 换。也 就 是 说 ( u n s i g n e d ) s x 等 价 于 ( u n s i g n e d ) (int) sx, 求值得到4 294 954 951, 而不等价于 (u n s i g n e d ) (unsigned short) sx, 后者求值得到 53 1 91 。事实上, 这个 规则 是 C 语 言标 准要 求 的 。\n练习题 2. 23 考虑下面的 C 函数:\nint fun1(unsigned word) {\nreturn (int) ((word«24)»24);\n}\nint fun2(unsigned word) {\nreturn ((int) word«24)»24;\n}\n假设在 一个 采用 补码运算的 机器上以 3 2 位程 序来执行这些 函 数。还假设有符号数值的右移是算术右 移 , 而 无符 号数值的右移是逻辑右移。\n填写 下表, 说明这些 函数对几个示 例参数的 结果。你会发现用 十 六进制 表 示来 做会更方便, 只要记住十 六进 制数 字 8 到 F 的最高有效位等于 1。 用语言来描述这些函数执行的有用的计算。 2. 2. 7 截断数字\n假设我们不用额外的位来扩展一个数值,而是减少表示一个数字的位数。例如下面代码中这种情况:\nint X = 53191;\nshort sx = (short) x; int y = sx;\nI* -12345 *I\n/* 一1 2345 *I\n当 我们把 x 强制类型转换为 s hor t 时, 我们就将 32 位的 i nt 截断为了 16 位的 s hor t i nt 。\n就像前面所看到的 ,这 个 16 位的位模式就是—12 345 的补码表示。当我们把它强制类型\n转换回 扛江时, 符号扩展把高 16 位设置为 1, 从而生成—1 2 345 的 32 位补码表示 。\n当将一个 w 位的数 x = [ x w- 1 • Xw-2• … , X。J截断为一个 K 位数字时, 我们会丢弃高w- k 位,得 到一个位向量 X1 = [Xk-1 , Xk-2, …, x。]。截断一个数字可能会改变它的值 溢出的一种形式。对千一个无符号数,我们可以很容易得出其数值结果。\n原理:截断无符号数\n令 I 等于位向 量[ x ..,- 1\u0026rsquo; 立 - 2\u0026rsquo; … , x 。J\u0026rsquo; 而 了是将其截断为 K 位的结果:了 = [ Xk- 1 •\n石 -2 \u0026rsquo; … , Xo ] 。 令 x = BZU 心(印 , x \u0026rsquo; = B ZU* G \u0026rsquo; ) 。 则 x \u0026rsquo; = x mod Z* o\n该原理背后的直觉 就是所有被截去的位其权重形式 都为 2\u0026rsquo;\u0026rsquo; 其中 i k , 因此,每一个权在取模操作下结果都为零。可用如下推导表示:\n推导:截断无符号数\n通过对等式 ( 2. 1) 应用取模 运算就 可以看到:\nw\u0026ndash;1\nB 2队 ( [ x 正 I\u0026rsquo; 工 w\u0026ndash;2\u0026rsquo; …心 )mod 2k = [ x ;2; ] mod 2k\n, =O\n=[江,;2 ]mod 2k\n; = o\nk 一 ]\n= x ; 2'\n,=O\n= B 2队 ( [ x1,1-\n, X1,2-\n, … , X。])\n在这段推导 中,我 们利用了属性: 对于任何 彦 k , 2\u0026rsquo;mod 2k = 0。 补码截断也具有相似的属性,只不过要将最高位转换为符号位: 原理:截断补码数值\n令; 等于位向 量[ x w-1 • 立 -2 \u0026rsquo; …,工。], 而 了是将其截 断为 K 位的结果: X1 =[xk-1,\nXk-\u0026lsquo;2, …, x。]。令 x = B2仄 (x ) , x \u0026rsquo; = B 2兀(了)。则x \u0026rsquo; = U 2兀 ( x mod 2k) 。\n在这个公式中, x mod 2k 将是 0 到 2k —1 之间的一个数。对其应用函数 U2兀 产生的\n效果是把最高有效 位 Xk -1 的 权重从 zk- 1 转变为— z- k 1 。 举例来看, 将数值 x = 53 191 从\ni nt 转换为 s hor t 。由 千 216 = 65 536 :r\u0026rsquo; 我们有 x mod 216 = x 。 但是, 当我们把这个数转换为 16 位的补码时, 我们得到 x \u0026rsquo; = 53 191 - 65 536=-12 345。\n推导:截断补码数值\n使用与无符号数截断相同的参数,则有\nB2U w( [ x 匹 I ,Xw-2• … ,X。] ) mod 2k = B 2队 [ x1,-1 , X1,-2 , … , X。]\n也就是, x mo d 沪能 够被一个位级表示为[ x k- 1 , Xk-2• …, x。]的 无符号数表示。将 其转换为补码 数则有 x \u0026rsquo; = U 2兀 ( x mod 2k ) 。\n总而言之,无符号数的截断结果是:\nB2Uk[x1,-1 ,x1,-2, … ,X。] = B 2U w( [ x U-, I , x 正 2 \u0026rsquo; … , X。]) mod 2k (2. 9)\n而补码数字的截断结果是:\nB2Tk [ x k-1 心 -2 \u0026rsquo; … ,X。]= U 2兀 ( B 2U w( [ x 一u ,,xu一. 2\u0026rsquo; … ,X。]) mod 2勹 ( 2. 10)\n练习题 2. 24 假设 将一个 4 位数值(用十六进 制数 字 O ~ F 表 示)截断到 一个 3 位数值\n(用十六进制 数 字 0 ~ 7 表 示)。填写 下表 , 根据那些位 模 式的 无符 号和补码 解释, 说明这种截断对某些情况的结果。\n原始值 截断值 原始值 原始值\n解释如何将等 式 ( 2. 9) 和等 式 ( 2. 1 0 ) 应用 到这些 示例 上。\n2. 8 关千有符号数与无符号数的建议\n就像我们看到的那样,有符号数到无符号数的隐式强制类型转换导致了某些非直观的行为。而这些非直观的特性经常导致程序错误,并且这种包含隐式强制类型转换的细微差别的错误很难被发现。因为这种强制类型转换是在代码中没有明确指示的情况下发生的, 程序员经常忽视了它的影响。\n下面两个练习题说明了某些由于隐式强制类型转换和无符号数据类型造成的细微的错误。\n练习题 2. 25 考虑下 列代 码 , 这段代码试图计算数组 a 中所有元素的和, 其中 元 素的数量由参数 l e n g t h 给出。\nI* WARNING: This is buggy code *I\nfloat surn_elements (float a[] , unsigned length) { inti;\nfloat result= O;\nfor (i = O; i \u0026lt;= length-1; i++) result+= a[i];\nreturn result;\n当参数 l e n g t h 等于 0 时, 运行这段代码 应 该返回 0. 0 。但 实际 上, 运行时会遇到一个内存错误。请解释为什么会发生这样的情况,并且说明如何修改代码。\n练习题 2. 26 现在给你一个任务, 写 一个函数用 来判定一个字 符 串 是否 比 另 一个更长。 前提是你要用 字符 串 库函数 s t r l e n , 它的 声明 如下:\nf* Prototype for library function strlen *I size_t strlen(const char *s);\n最开始你写的函数是这样的:\nI* Determine whether strings is longer than string t *I I* WARNING: This function is buggy *I\nint strlonger(char *s, char *t) { return strlen(s) - strlen(t) \u0026gt; O;\n}\n当你在一些示例数据上测试这个函数时,一切似乎都是正确的。进一步研究发现 在头文件 s t d i o . h 中 数据类型 s i z e _ t 是定义成 u n s i g n e d i n t 的。\n在什么情况下,这个函数会产生不正确的结果? 解释为什么会出现这样不正确的结果。 说明如何修改这段代码好让它能可靠地工作。 m 函数 g e t p e er n ame 的 安全漏 洞\n2002 年 , 从 事 F re eBSD 开源操 作 系统 项 目 的 程 序 员 意 识 到, 他 们对 ge t pe er na me\n函数的实现 存 在 安 全 漏洞。代码的 简 化 版 本 如 下:\n/*\n* Illustration of code vulnerability similar to that found in\n* FreeBSD\u0026rsquo;s implementation of getpeername 0\n*I\n5\nI* Declaration of library function memcpy *I\nvoid *memcpy(void *dest, void *src, size_t n);\n8\nI* Kernel memory region holding user-accessible data *I\n#define KSIZE 1024\nchar kbuf [KSI ZE] ; 12\n13 / * Copy at most maxlen bytes from kernel region to user buffer *I\n14 int copy_from_kernel(void *user_dest, int maxlen) {\n1s I* Byte count len is minimum of buffer size and maxlen *I\nint len = KSIZE \u0026lt; maxlen? KSIZE: maxlen;\nmemcpy(user_dest, kbuf, len);\nreturn len;\n19 }\n在这段代码里, 第 7 行给 出的 是 库 函 数 me mc p y 的 原 型, 这 个函数是要将一段 指 定长度 为 n 的 宇 节从 内 存 的 一 个 区域复制到 另 一 个 区域 。\n从 笫 14 行 开始的函数 c op y_ fr om_ ker ne l 是 要 将 一些操作 系统 内核 维护的数据复制到指定的 用 户可以访问的内存区域。 对用 户来说 , 大多数 内核 维护的数据结构应该是不可读的,因为这些数据结构可能包含其他用户和系统上运行的其他作业的敏感信息, 但是显示为 kb u f 的 区域 是 用 户可 以 读 的 。 参 数 ma x l e n 给 出的 是 分 配 给 用 户的 缓 冲 区的长度 , 这 个缓冲区是 用参数 u s er _d e s t 指 示的。 然后 , 第 1 6 行 的 计算确保 复制的 字节数 据 不会超 出 源或 者 目标缓 冲区可用的 范围。\n不过 ,假 设 有 些怀有恶意的 程 序 员 在 调 用 c o p y_ fr om_ ker n e l 的 代 码 中 对 ma x l e n 使 用 了 负数 值 , 那么, 第 1 6 行 的 最 小值 计 算会把 这 个值赋给 l e n , 然后 l e n 会 作 为 参数 n 被 传 递给 me mc p y 。 不过, 请 注意参数 n 是被 声明 为数 据 类型 s i ze _ t 的。这个数据类型是在库文件 s t d i o . h 中(通过 t yp e d e f ) 被 声 明 的 。 典 型地, 对 3 2 位 程序 它被 定 义\n为 u ns i g n e d int, 对64 位程序定义为 u n s i g ne d l o n g。 既 然参数 n 是 无符号的 , 那 么\nme mc p y 会 把 它当作 一 个非常大的 正整数 , 并 且 试 图将这样 多 字 节的 数 据 从 内核 区域 复制到用 户的缓 冲区。 虽 然复制这 么 多 字节( 至 少 沪 个)实 际 上 不 会 完成 , 因 为 程 序 会 遇到进程中非法地址的错误,但是程序还是能读到它没有被授权的内核内存区域。\n我们可以看到,这个问题是由于数据类型的不匹配造成的:在一个地方,长度参数 是有符号数; 而另一 个地 方, 它又是无符号数。正如这个例子表明的 那样 , 这 样 的 不 匹配会成为缺 陷的原 因, 甚 至 会 导 致 安 全 漏洞。 幸运 的 是 , 还 没 有 案 例 报 告 有 程 序 员 在F reeBSD 上利 用 了 这 个漏洞。他 们发布 了 一 个安全 建议, \u0026quot; F reeBS D-S A- 0 2 : 38. sig ned ­\nerror\u0026rdquo;, 建议系统 管理 员如 何 应 用补 丁 消除 这 个 漏洞。要 修 正这 个缺陷, 只 要 将 c o p y_ fr o m_ ker n e l 的 参 数 ma x l e n 声 明 为 类型 s i ze _ t , 也就是与 me mc p y 的 参 数 n 一 致 。 同时, 我们也应该将本地 变量 l e n 和返 回值 声 明 为 s i z e _ t 。\n我们已经看到了许多无符号运算的细微特性,尤其是有符号数到无符号数的隐式转 换,会导致错误或者漏洞的方式。避免这类错误的一种方法就是绝不使用无符号数。实际 上,除 了 C 以外 很 少 有语 言 支持无符号整数。很明显, 这些语言的设计者认为它们带来的麻烦要比益处多得多。比如, J a va 只支待有符号整数,并 且 要 求 以 补 码 运 算 来 实 现。正常的右移运算符>>被定义为执行算术右移。特殊的运算符>>>被指定为执行逻辑右移。\n当我们想要把字仅仅看做是位的集合而没有任何数字意义时,无符号数值是非常有用 的。例如,往 一 个 字中放入描述各种布尔条件的标记( flag ) 时,就 是 这 样。地址自然地就是无符号的,所以系统程序员发现无符号类型是很有帮助的。当实现模运算和多精度运算 的数学包时,数字是由字的数组来表示的,无符号值也会非常有用。\n2. 3 整数运算\n许多刚入门的程序员非常惊奇地发现,两个正数相加会得出一个负数,而比较表达式 x\u0026lt;y和比较表达式 x - y\u0026lt;O 会 产 生不同的结果。这些属性是由千计算机运算的有限性造成的。理解计算机运算的细微之处能够帮助程序员编写更可靠的代码。\n2. 3. 1 无符号加法\n考虑两个非负整数 x 和 y , 满足 0冬X , y\u0026lt; Zw。每个数都能 表示为 w 位无符号数字。然而, 如果计算 它们的 和 , 我们就有一个可能的范围 O x + y Z叶 t_ z。表 示 这 个 和可能需要 w + l\n位。例如,图 2-21 展示了 当 x 和 y 有 4 位表示时,函数 x + y 的 坐标图。参数(显示在水平轴上)取值范围为 0~ 15, 但是和的取值范围为 0~ 30。函数的形状是一个有坡度的平面(在两个维度上,函数都是线 性的)。如果保持和为一个 w+ l 位的数字,并 且把它加上另外一个数值,我 们可能需要 w+ 2 个位 ,以 此类推。这种持续的 ”字 长膨胀” 意 味 着 , 要想完整地表示算术运算的结果, 我们不能对字长做任何限制。一些编程语言,例 如 L isp, 实际上就支持无限精度的运算,允许任意的(当然,要在机器的内存限制之内)整数运算。更常见的是,编程语言支持固 定精度的运算,因此像“加法”和"乘法”这样的运算不同千它们在整数上的相应运算。\n14\n图 2- 21 整数加法。对 于一个 4 位的字长 , 其和可能需要 5 位\n让我们为参数 x 和 y 定义运算 十心, 其中 O x , y\u0026lt;Z\u0026quot;\u0026rsquo;, 该操作是把整数 和 x + y 截断为 w 位得到的结果 ,再 把这个结果看做是一个无 符号数。这可以 被视为一 种形式的模运算, 对 x + y 的位级表示,简 单丢弃任何权重大千 zw-1 的位就可以计算出和模 2心。 比如, 考虑一个 4 位数字表示, x = 9 和 y = l Z 的 位表示分别为[ 1001] 和[ 1100] 。它们的和是 21, 5 位的表示 为[ 10101] 。但是如果丢弃最高位, 我们就得到[ 0101] , 也就是说,十进制值 的 5。这就和值 21 mod 16 = 5 一致。\n我们可以将操作十;描述为: 原理:无符号数加法\n对满足 O x , y \u0026lt; wZ 的 x 和 y 有:\nx+y, x+ y\u0026lt; 沪 正常\nX + ,y = {\nX + y- 2\u0026quot;\u0026rsquo;, 2切 x + y \u0026lt; zu+l 溢出\n(2. 11)\n图 2-22 说明了公式( 2. 11 ) 的这两种情况,左 边的和x + y 映射到右边的无符号 w 位的和 x + 切。正常情况下 x + y 的值保持不变,而溢出情况则是该和数减去沪的结果。\n推导:无符号数加法\n一般而言,我 们可以看到 ,如果 x + y \u0026lt; 2\u0026quot;\u0026rsquo; , 和的 w +\n1 位表示中 的最高位会等于 o , 因此丢弃它不会改变这个数\n值。另一 方面,如果 2气乓x + y \u0026lt; 2w+1 \u0026rsquo; 和的 w + l 位表示 图 2-22 整数加法和无符号加法中的最高位会等 于 1, 因此丢弃它就相当于从和中减去 间的关系。当x + y 大 于\n了 2\u0026quot;0\u0026rsquo;\n沪 - 1 时, 其和溢出 说一个算术运算溢出,是指完整的整数结果不能放到数据类型的字长限制中去。如等 式( 2. 11 ) 所示,当两 个运算数的和为 沪 或者更大时, 就发生了溢出。图 2-23 展示了字长w= 4 的无符号加法函数的 坐标图 。这个和是按模 24 = 1 6 计算的。当 x + y \u0026lt; l 6 时, 没有溢出 , 并且 x + y 就是 x + y。这对应于图中标记为“正常" 的斜面。当 x + y l 6 时, 加法溢出 , 结果相当于从 和中减去 16。这 对应于图中标记为 "溢出" 的斜面。\n图 2-23 无符号加法( 4 位字长, 加法是模 16 的)\n当执行 C 程序时, 不会将溢出作 为错误 而发信号。不过有的时候, 我们可能希望判定是否发生了溢出。\n原理:检测无符号数加法中的溢出\n对在 范围 O::( x , y::( UM a x\u0026quot; 中的 x 和 Y • 令 s 兰 x + 切 。 则 对计算 s\u0026rsquo; 当 且 仅 当 s \u0026lt; x\n(或者等价地 s\u0026lt; y ) 时, 发生了溢 出。\n作为说明 ,在 前面的示 例中, 我们看到 9 + n 2 = s 。由于 5 \u0026lt; 9 , 我们可以看出发生了溢出。\n推导:检测无符号数加法中的溢出\n通过观察发现 x + y x , 因此如果 s 没有溢出, 我们能够肯定 s 工。另一方面,如果\ns 确实溢出了, 我们就有 s = x + y — 2中。 假设 y \u0026lt; 2切, 我们就有 y — 沪 \u0026lt; O, 因此 s = x + ( y - 2勹<工。\n练习题 2. 27 写出一个具有如下原型的函数:\nI* Determine whether arguments can be added without overflow *I\nint uadd_ok(unsigned x, unsigned y);\n如果 参数 x 和 y 相加 不会产 生溢 出,这 个函数就 返回 l 。\n模数加法形成了一种数学结构, 称为 阿贝 尔群 ( A be lia n group), 这是以丹麦数学家Niels Henrik Abel( 180 2 18 29 ) 的名字命名。也就说,它 是可交换的(这就是为什么叫\u0026quot; a belia n\u0026quot; 的地方)和可结合的。它有一个单位元 o , 并且每个元素有一个加法逆元。让我们考虑 w 位的无符号数的集合, 执行加法运算 + ::,。 对千每个值 工,必 然有某个值—釭 满足一巨 +扛 = O。该 加法的逆操作可以 表述如下:\n原理:无符号数求反\n对满足 0 工< 沪 的任意 工,其 w 位的无符 号逆元 —扛 由下式给 出:\n玉=厂沪'- x ,\n该结果可以很容易地通过案例分析推导出来: 推导:无符号数求反\nx=O x\u0026gt;O\n( 2. 12)\n.\n+::, 下的逆元。\n练习题 2. 28 我们 能用 一个 十六进 制数 字来表 示长度 w = 4 的位模式。 对于这 些数 字的无符 号解释,使 用等 式( 2. 1 2 ) 填写下表 , 给出所 示数 字的 无符 号加 法逆元的 位表 示\n(用十六进制形式)。\n2. 3. 2 补码加法\n对于补码加法,我们必须确定当结果太大(为正)或者太小(为负)时,应该做些什么。\n给定在范围 - z-w I \u0026lt; x , Y\u0026lt; wz - 1 —1 之内的整数值 x 和 y , 它们的和就在范围— zw\u0026lt; x +\ny w2 - 2 之内, 要想准确表示, 可能需 要 w + l 位。就像以前一样, 我们通过将表示截断\n到 w 位, 来避免数据大小的不断扩张。然而, 结果却不像模数加法那样在数学上感觉很熟悉。定义 x + 切 为整数和 x + y 被截断为 w 位的结果, 并将这个结果看做是补码数。\n原理:补码加法\n对满足 — zw- I \u0026lt; x , Y \u0026lt; zw- 1 — 1 的 整 数 x 和 y , 有:\nx+ y-2\u0026quot;\u0026rsquo;, 2正 l x + y\n气 y { x + y , — 2正\u0026rsquo;,;;;X + y \u0026lt; z-• X + y + zw , X + y \u0026lt; — 2 正 l\n图 2-24 说明了这个原理, 其中, 左边的和 x + y\n的取值范围 为— 2-w 三x + y 冬 wz - 2 , 右边显示的是该\n正溢出正常负溢出\nx+y\n+zw\n(2. 13)\n和数截断为 w 位补码的结果。(图中的标号“情况 l \u0026quot; 情况4\n到“情况 4\u0026quot; 用于该原理形式化推导的案例分析中。)\n当和 x + y 超过 TMax 心时(情况 4) , 我们说发生了正溢 情况3\n出。在这种情况下, 截断的结果是从和数中减去 2\u0026quot;0'\n当和 x + y 小千 TMin 亿,时( 情况 1 )\u0026rsquo; 我们说发 生了 负 溢\n+ 2W一)\nx+\u0026lsquo;y\n+zw-1\n出。 在这种情况下 ,截断的结果是把和数加上 2\u0026quot;\u0026rsquo; 。\n两个数的 w 位补码之和与无 符号之和有完全相同的位级表示。实际上,大多数计算机使用同样的机器指令来执行无符号或者有符号加法。\n情况2\n情况I\n-2 W 一l\n-2 w\n-2 w-1\n推导:补码加法\n既然补码加法与无符号数加法有相同的位级表示, 我们就可以按如下步骤表示运算+ :,,: 将其参数转换为无符号数,执行无符号数加法,再将结果转换为补码:\n图 2- 24 整数和补码加法之间的关系。当 x + y 小 于一 2-u· l 时, 产生负溢出。当它大千 2w- l 时, 产生正溢出\nx + :,,y 丰 U 2Tw ( T 2Uw ( x ) + ::,T ZUw ( y ) ) (2. 14)\n根据等式 ( 2. 6), 我们可以把 TZU w ( x ) 写 成 Xw-1 沪 + x , 把 T ZU w ( y ) 写成 Yw-1 沪 + y 。使用属性,即十;是模沪的加法,以及模数加法的属性,我们就能得到:\nx + 心 y = u z 兀 ( T ZUw Cx ) + 汇 T ZUw( y ) )\n= u z兀 [ ( x 正 1 沪 十 x + Yw1- 沪 + y) mod 2勹\n= UZT w[ ( x + y) mod 2 勹\n消除了 x心一 1 沪 和Yw- 1 沪 这两项, 因为它们模 沪 等于 0。\n为了更 好地理解这个数最,定 义 z 为整数和 z 土 x + y , z \u0026rsquo; 为 z \u0026rsquo; 辛 z mod 2气 而 z\u0026quot; 为z\u0026quot;辛 u z兀 ( z \u0026rsquo; ) 。数值 z\u0026quot;等于 x + 切 。我们分成 4 种情况分析, 如图 2-24 所示。\n—2w:::今 \u0026lt; — 2-w l 。 然后, 我们会有 z \u0026rsquo; = z + 2切。 这就得出 o \u0026lt; z \u0026rsquo; \u0026lt; — 2-w\nl +2w =\nw2 - l 。 检查等式( 2. 7), 我们看到 z \u0026rsquo; 在满足 z\u0026quot;= z \u0026rsquo; 的范围之内。这种情况称为 负 溢出( nega­ ti ve overflow ) 。我们将两个负 数 x 和 y 相加(这是我们能得到 z \u0026lt; - 2w-l 的 唯一方式), 得\n到一个非负的结果 z\u0026quot;= x + y + w2 0\n- 2w气 \u0026lt; z \u0026lt; O。 那么, 我们又将有 z \u0026rsquo; = z + 2心, 得到— 2-w l + 2心 = 2切一1\u0026lt; z \u0026rsquo; \u0026lt; 2 二\n检查等式 ( 2. 7), 我们看到 z \u0026rsquo; 在满足 z\u0026quot; = z \u0026rsquo; —沪 的范围之内, 因此 z\u0026quot; = z\u0026rsquo; — 沪 = z 十沪 — 沪= z。也就 是说,我们 的补码和 z\u0026quot;等千整数 和 x + y 。\n0冬z \u0026lt; 2-w l 。那 么, 我们将有 z \u0026rsquo; = z , 得到 o\u0026lt; z\u0026rsquo; \u0026lt; 2-w\nl , 因此 z\u0026quot; = z\u0026rsquo; = z。补码和\nz\u0026quot;又等于整数和 x + y。\nzw- l z \u0026lt; 2切。 我们 又将有 z \u0026rsquo; = z , 得到 z-w 1 冬 z \u0026rsquo; \u0026lt; zw 。 但是在这个范围内 , 我们有\nz11 = z1 —沪 , 得到 z\u0026quot; = x + y - 2也。 这种情况称为正溢出( positive overflow ) 。我们将正数 x和 y 相加(这是我们能得到 z多 2心一]的唯一方式), 得到一个负数结果 z\u0026quot; = x + y - 2亿\u0026rsquo; 0 ■ 图 2- 25 展示了一些 4 位补码加法的示例作为说 明。每个示例的情况都被标号为对 应\n于等式( 2. 13 ) 的推导过程中的情况 。注意 24 = 1 6\u0026rsquo; 因此负溢出得到的结果比整数和大 16 , 而正溢出得到的结果比之小 16。我 们包括了运算数和结果的位级表示。可以观察到, 能够通过对运算数执行二 进制加法并将结果截断到 4 位,从 而得到结果。\n图 2- 25 补码加法示例。通 过执行运算数的二进制加法并将结果截断到 4 位, 可以获得 4 位补码和的位级表示\n图 2-26 阐述了字长 w = 4 的补码加法。运算数的范围为—8 7 之间。当 x + y \u0026lt; — 8 时, 补码加法就会负溢出 ,导 致和增加了 16。当一8 x + y \u0026lt; 8 时, 加法就产生 x + y。当x + y娑8 , 加法就会正溢出 ,使 得和减少了 16。这三种情况中的每一种都形成了图中的一个斜面。\n图 2- 26 补 码 加 法(字长为 4 位的情况下,当 x + y \u0026lt; - 8 时 ,\n产生负溢出;工 + y 多 8 时, 产生正溢出)\n等式 ( 2. 1 3 ) 也让我们认出了哪些情况下会发生溢出: 原理:梒测补码加法中的溢出\n对满足 T M i nw\u0026lt; x , y \u0026lt; T M a x 心的 x 和 y , 令 烂 = x + 切 。 当 且 仅 当 x \u0026gt; O, y\u0026gt;O, 但\n冬0 时, 计算 s 发 生了 正 溢出。 当 且 仅 当 x \u0026lt; O, y\u0026lt;O, 但s O 时 , 计算 s 发生了 负 溢出。图 2-25 显示了 当 w = 4 时 , 这 个 原 理 的 例 子 。 第 一 个 条 目 是 负 溢 出 的 情 况 , 两 个 负 数\n相加得到一个正数。最后一个条目是正溢出的情况,两个正数相加得到一个负数。 推导:检测补码加法中的溢出\n让我们先来分析正溢出。如果 x \u0026gt; O, y \u0026gt; O, 而 s \u0026lt; O, 那么显然发生了正溢出。反过来,正溢出的条件为: l)x\u0026gt;O, y\u0026gt; O( 或者 x + y \u0026lt; T M a x w ) , 2 ) s \u0026lt; O( 见 公 式 ( 2. 1 3 ) ) 。 同样的讨论也适用于负溢出情况。\n练习题 2. 29 按照 图 2- 25 的形 式填 写 下表。 分别 列 出 5 位参数的整数值、整数和 与补码 和的数值、 补码 和的位级表示 , 以及属于等 式 C2. 1 3 ) 推导中的哪种情况。\nX y [10100] [10001] [11000] [11000] [10111] [01000] (00010] [00101] [01100] [00100] 练习题 2. 30 写出一个具有如下原型的函数:\nI* Determine whether arguments can be added without overflow *I\nint tadd_ok(int x, int y);\n如果参数 x 和 y 相加不会产 生溢出 , 这 个函数就返回 1 。\n练 习题 2. 31 你的同事对你补码加法溢出条件的分析有些不耐烦了,他给出了一个函数 t a d d _o k 的实现, 如下所 示 :\nI* Determine whether arguments can be added without overflow *I I* WARNING: This code is buggy. *f\nint tadd_ok(int x, int y) { int sum= x+y;\nreturn (sum-x == y) \u0026amp;\u0026amp; (sum-y == x);\n}\n你看了代码以后笑了。解释一下为什么。\n练习题 2. 32 你现在有个任务, 编 写 函 数 t s u b_ o k 的代码 , 函数的参数是 x 和 y , 如果计算 x - y 不产 生溢出, 函数就返回 1 。假设你写 的练 习题 2. 30 的代码 如下所 示:\nI* Determine whether arguments can be subtracted without overflow *I I* WARNING: This code is buggy. *I\nint tsub_ok(int x, int y) {\nreturn tadd_ok(x, -y);\n}\nx 和 y 取什 么值时, 这 个 函 数 会 产 生 错误的 结 果? 写 一个 该 函 数 的正确 版 本(家\n庭作业 2. 74) 。\n2. 3. 3 补码的非\n可以看到范围在 TMinw x T M a x 心 中 的 每个数字 x 都有十;下的加法逆元, 我们将\n- :.,x 表示如下。\n原理: 补 码 的非\n对满足 T M i n u女 T M a x w 的 X , 其补码的非 一扛 由 下式给出\n飞={ T M i n w\u0026rsquo; X = T M i n w\n— x , X \u0026gt; TM i n w\n(2. 15)\n也 就 是 说 ,对 w 位的补码加法来说, T M i n切是 自己的加法的逆, 而 对 其 他 任何数值\nx 都有- x 作为其加法的逆。推导:补码的非\n观察发现 T M i un . + T M 匹 =_ z-u· 1 + ( — z-u· 1 ) = — 2心。 这 将导致 负 溢 出, 因 此\nT Mi nw已 T M i n w = —w2 十沪 = O。 对满足 x \u0026gt; T Minw 的 x , 数 值 - x 可以表示为一个 w 位\n的补码, 它 们的 和—x + x = O 。\n练习题 2. 33 我们 可以用 一个 十 六进制数 字 来表 示长 度 w = 4 的位模式。 根据这些 数字的 补码 的解释, 填写 下表, 确定 所示数 字的 加法 逆元。\n对于补码和无符号(练习题 2. 28 ) 非 ( ne ga t ion ) 产 生的 位模式 , 你观察到什么?\n一 补 码非的位级表示\n计算一个位级表示的值的补码非 有几种聪明的方 法。这些技术很有用(例如 当你 在调试程序的时候遇到值 Ox f ff f ff f a ) , 同时它们 也能够让你更了 解补码表示的 本质。\n执行位级补码非的笫一种方法是 对每一位求补, 再 对结果加 1。在 C 语言中, 我们可以说, 对于任 意整数值 x , 计算表达式- x 和 ~x +l 得到的结果 完全 一样。\n下面是 一些示例, 字长为 4 :\n-4 [0011]\n[11 11]\n从 前 面的例子我们知道 Ox f 的补是 Ox O, 而 Ox a 的补是 OxS, 因 而 Ox f ff ff ff a 是\n—6 的补码表示。\n计算一个数 x 的补码非的笫二种方法是建立在将位向 量分为两部分的基础之上的。假设\nk 是最右边的 1 的位置, 因 而 x 的位级表示形如[ x ,.,-1 , Xw-2• … , XHJ , 1, Q, … , O] 。\n( 只要 x #- 0 就 能够找到 这样的 K。)这个值的非写成二进制格 式就是[~乓- 1 , ~ x 心- 2 ,\n~ xHI , 1, Q, …, O] 。 也 就 是 , 我们对位 K 左边的所有位取反。\n我们用一些 4 位数字来说明这个方法,这里我们用斜体 来突出最右边的模式 1, o,…, 0:\nX 一X [1100] -4 [0100] 4 (1000] -8 [1000] -8 [0101] 5 [1011] -5 [0111] 7 [1001] - 7 2. 3. 4 无符号乘法 范围在 O x , y 沪 —l 内的整数 x 和 y 可以被表示为 w 位的无符号数, 但 是 它 们 的乘积 x • y 的取值范围为 0 到( 2w- 1 ) 2= z2w —zw+ l + 1 之 间 。 这可能需要 2w 位来表示 。不过,C 语言中的无符号乘法被定义为产生 w 位的值,就 是 2w 位的整数乘积的低 w 位表示的值。我们将这个值表示为 X * 切 。\n将一个 无符号数截断为 w 位等价于计算该值模 2切, 得到:\n原理:无符号数乘法\n对满足 O x , y UMa x 切 的 x 和 y 有:\nX * 沁y = (x• y)mod 2切 (2. 16)\n2. 3. 5 补码乘法\n范围在—z-w l X, y zw- 1 —1 内的整数 x 和 y 可以被表示为 w 位的补码数字,但 是它们的乘 积 x • y 的取 值范 围 为 - zw- 1 • ( zw- l_ l) = - 2红 - 2 + zw- 1 到 — zw- 1 0 — zw- 1 =\n_ zw-2 2 之 间 。 要 想 用 补 码 来 表 示这个乘积,可 能 需 要 2w 位。然而, C 语 言 中 的 有符号乘\n法是通 过将 2w 位的乘积截断为 w 位来实现的。我们将这个数值表示为 X * 切 。 将 一 个 补码数截 断为 w 位相当于先计算该值模 wz \u0026rsquo; 再 把 无 符 号 数 转换为补码 ,得 到 :\n原理:补码乘法\n对 满足 TM i n,,冬 x , y T M a x w 的 x 和 y 有 :\nx * :.,y = U 2T w ( ( x • y)mod 2勹 ( 2. 17)\n我们认为对于无符号和补码乘法来说,乘法运算的位级表示都是一样的,并用如下原 理说明:\n原理:无符号和补码乘法的位级等价性\n给定长度 为 w 的位向 量 王 和 y, 用 补 码形 式的位向量表 示 来定 义整数 x 和 y : x= B2TwG), y= B2兀 (y ) 。 用 无符号形 式的 位向 量表 示 来定义非 负 整 数 x \u0026rsquo; 和 y \u0026rsquo; : x \u0026rsquo; =\nB2UwG), y\u0026rsquo; = BZU 切(如 。 则\nT 2 凡 ( x 亡 y ) = U ZB 心 ( x \u0026rsquo; 已 y \u0026rsquo; )\n作 为说明,图 2-27 给出了不同 3 位数字的乘法结果。对于每一对位级运算数, 我们执行无符号和补码乘法,得 到 6 位 的 乘 积 ,然 后 再 把 这些乘积截断到 3 位。 无 符号 的截 断后的乘积总是等于 x • y mod 8。虽然无符号和补码两种乘法乘积的 6 位表示不同,但 是 截断后的乘积的位级表示都相同。\n推导:无 符 号 和 补码 乘 法 的 位 级 等价性\n根据等式( 2. 6), 我们有 x \u0026rsquo; = x + x w- 1 沪 和 y \u0026rsquo; = y + y心 一 1 沪 。 计 算 这 些 值 的 乘 积 模 2心\n得到以下结果:\n(x\u0026rsquo;• y\u0026rsquo;)mod w2 = [ ( x + x w1- 沪 ) • (y+ y 正 1 沪 ) ] mod 2心\n= [ x • y + (x匹 1 Y + Yur1- X) 沪 十 Xur1- Yur1- 22 勹mod 2\u0026quot;\u0026rsquo;\n= ( x • y) mod 2 切\n( 2. 18)\n由 千模运算符,所 有带有权重 沪 和 2红的项都丢掉了。根据等式( 2. 1 7 ) , 我们有 x * :Vy = U 2兀 ( ( x • y) mod 2\u0026quot;\u0026rsquo;) 。对等式两边应用操作 T 2Uw 有:\nT 2U心位 * 沁y ) = T 2U,, ( U 2兀 ( ( x • y) mod 2心)) = ( x • y) mod 2 心\n将 上述结果与式 ( 2. 1 6 ) 和式 ( 2. 18 ) 结 合 起 来 得 到 T 2U心 ( x * :Vy ) = ( x \u0026rsquo; • y\u0026rsquo;) mod zw=\nx \u0026rsquo; 亡 y \u0026rsquo; 。 然 后 对 这个等式的两边应用 U2B..,. , 得到 .\n模式 X y x·y 截断的x·y 无符号 5 [101) 3 [Oil] 15 [001III] 7 [111] 补码 -3 [IOI] 3 [011) - 9 [110111) -1 [I l l] 无符号 4 [100] 7 [Ill] 28 [011100] 4 [JOO] 补码 -4 [100] -1 [111] 4 (000100] —4 (100] 无符号 3 (011] 3 [Oil] 9 [001001] l [001] 补码 3 [Oil] 3 [Oil] 9 [001001] I [001] 图 2-27 3 位无符号和补码乘法示例。虽然完整的乘积的位级表示可能会不同 , 但是截断后乘积的位级表示是相同的\n练习题 2. 34 按照 图 2-27 的风格填写 下 表, 说明 不同的 3 位数 字乘 法的 结果 。\n模式 X y x ·y 截断的x ·y 无符号 (100) (101] 补码 [100] [ IO I] 无符号 [010] [III] 补码 [010] [111] 无符号 [I IO] [I IO] 补码 (110] [110] 练习题 2. 35 给你一个 任务, 开发 函数 t mu l t _ o k 的代码 , 该函 数会判断 两个 参数相乘是否会产生溢出。下面是你的解决方案:\nI• Determine whether arguments can be multiplied without overflow•I int tmult_ok(int x, int y) {\nint p = x•y;\n/• Either xis zero, or dividing p by x gives y•I return !x 11 p/x == y;\n}\n你用 x 和 y 的很多值 来测试这段代码 , 似 乎都 工作正 常。 你的同 事挑战 你, 说:\n“ 如果我不能用减法来 检验加法是 否溢 出(参见 练 习题 2. 31), 那么你怎么能用除法来检验乘法是否溢出呢?”\n按照 下面的思路, 用 数 学推 导来证 明 你的 方 法是对的。 首先, 证 明 x = O 的 情 况是正确 的。 另 外, 考虑 w 位数 字 X ( x -=/=-0) 、 y 、 p 和 q\u0026rsquo; 这里 p 是 x 和 y 补码 乘 法的结果, 而 q 是 p 除以 x 的结果。\n1 ) 说明 x 和 y 的整数 乘 积 X • y, 可 以写 成这样的形 式 : X• y= p + tzw , 其中,\nt -=l=-0 当且 仅当 p 的计算溢出。\n2 ) 说 明 p 可以写 成这样的形式 : p = x • q + r , 其 中 1 门\u0026lt; l x l 。\n3 ) 说 明 q = y 当 且 仅 当 r = t = O 。\n练习题 2. 36 对于数据 类型 i n t 为 3 2 位的情况, 设 计一个 版 本的 t mu l 七_ o k 函 数(练\n习题 2. 35), 使用 64 位精度的数据 类 型 i n t 64—七, 而 不使 用除法。\n国日XOR 库中的 安 全 漏 洞\n2002 年 ,人 们发现 S un M icro s ys t ems 公 司提 供 的 实现 XDR 库的代 码 有安 全 漏洞 ,\nXDR 库是一个广 泛使 用的 、 程序 间 共 享数 据 结 构 的 工具 , 造 成 这 个 安 全 漏洞的 原 因是程序会在毫 无察觉的情 况下产生乘法溢出。\n包含安全漏洞的代码与下面所示类似:\nI* Illustration of code vulnerability similar to that found in\n* Sun\u0026rsquo;s XOR library.\n*I\n4 void* copy_elements (void *ele_src [] , int ele_cnt, size_t ele_size) {\ns I*\n* Allocate buffer for ele_cnt objects, each of ele_size bytes\n* and copy from locations designated by ele_src\ns *I\nvoid *result= malloc(ele_cnt * ele_size);\nif (result == NULL)\nI* malloc failed *I\nreturn NULL;\nvoid *next= result·\nint i;\nfor (i ·= O; i \u0026lt; ele_cnt; i++) {\nI* Copy object i to destination *I\nmemcpy(next, ele_src[i], ele_size);\nI* Move pointer to next memory region *I\nnext+= ele_size;\n. 20 }\n21 return result;\n22 }\n函数 c op y_e l e me n t s 设 计 用 来将 e l e _c nt 个数据结构复制到笫 9 行 的 函 数 分 配的缓冲区中, 每 个数据结构 包含 e l e _ s i z e 个宇节。 需要的 字节数 是 通过 计算 e l e _c nt * e l e_s i ze 得到的。\n想象一下, 一个怀有恶意的程序 员在 被 编 译 为 32 位的 程序 中 用参 数 e l e _ c n t 等 于1048 577(220 + 1) 、 e l e _ s i z e 等 于 4096 ( 212) 来 调 用这 个函数。 然后 笫 9 行 上 的 乘 法会 溢出, 导致只 会 分 配 4096 个 字节 , 而不是 装 下这些数据所需要的 4 294 971 392 个宇节 。从第 15 行 开始的循环会试图复 制所有的 字节 , 超 越 已分 配的缓 冲 区的 界 限 , 因 而 破 坏了其他的数据结构。这会导致程序崩溃或者行为异 常。\n几乎每 个操作 系统 都 使 用了这 段 Sun 的代码,像 Intern et Explorer 和 Ker beros 验 证 系统 这 样 使 用 广 泛的 程 序 都 用到 了 它。 计 算机 紧急 响 应 组 ( Computer Emergency Response Team , CERT) , 由卡内基-梅隆软件工程协会 ( Carnegie Mellon Software Engineering Insti­ tute) 运作的一个追踪安全漏洞或失效的组织, 发 布了 建议 \u0026quot; CA-2002- 25\u0026quot; , 于是许多公司急忙对它们的代码打补丁。幸运的是, 还 没 有 由 于这个漏洞引起的安全失效的报告。\n库函数 c a l l o c 的 实现 中存在 着类似的 漏洞。 这 些已 经被修补过 了 。 遗 憾 的 是 , 许\n多程序员调用分 配函数(如 ma l l o c ) 时,使 用算 术表达式 作为参数, 并且 不 对这些表 达式进行 溢出检查。编写 c a l l o c 的可靠版本 留作 一道 练习题(家庭作业 2. 76 ) 。\n练习题 2. 37 现在你 有一个任务, 当数 据类型 i n t 和 s i z e —t 都是 32 位的 , 修补上述旁注给出的 XOR 代码中的 漏 洞。 你决 定将待分配 字节 数设置为数 据类型 u i n t 64_ t , 来消除 乘法溢 出的 可能性。 你把原来 对 ma l l o c 函数的调用(第 9 行)替换如 下: uint64_t asize =\nele_cnt * (uint64_t) ele_size;\nvoid *result= malloc(asize);\n提磋一下, ma l l o c 的参数类型是 s i ze _ 七。\n这段代码对原始的代码有了哪些改进? 你该如何修改代码来消除这个漏洞? 2. 3. 6 乘以常数\n以往,在 大多数机器上, 整数乘法指令相当慢, 需 要 10 个或者更多的时钟周期,然而其他整数运算(例如加法、减法、位级运 算和移位)只需要 1 个时钟周期。即使在我们的参考机器 In t el Core i7 H as well 上, 其整数乘法也需要 3 个时钟周期。因此, 编译器使用了一项重要的优化, 试着用移位和加法运算的组 合来代替乘以常数因子的乘法。首先, 我们会考虑乘以 2 的幕的情况, 然后再 概括成乘以任意常数 。\n原理: 乘以 2 的幕\n设 x 为位模 式[ x w- 1 , X..,_.- 2 • … , x 。] 表示的 无符 号整数 。那么, 对于任何 k O, 我们 都认为[ x w- 1 • Xw-2• …, Xo , 0 , … , O ] 给出 了 x 2k 的 w + k 位的 无符 号表示,这 里右边增 加 了 K 个 0。\n因此, 比如, 当 w = 4 时, 11 可以 被表示为[ 1011] 。k = 2 时将其左移得到 6 位向 量\n[101100], 即可编码 为无符号 数 11 • 4 = 44。\n推导:乘以 2 的幕\n这个属性可以通过等式( 2. 1) 推导出来:\nB 2U吐八[ Xw\u0026ndash;1-\n, Xw-2-\n认 尸 一1\n, …,工O ,o ,…,O ] ) = x ; 2\u0026rsquo;十k\n,- o # =[笘x 心]• 2k\n. = X 2k .\n当对固定字长左移 k 位时, 其高 k 位被丢弃, 得到\n[ x亡 k- 1 , x正 仁 2 \u0026rsquo; … ,Xo , Q , … ,O ]\n而执行固定字长的乘法也 是这种情况。因此,我 们可以看出左 移一个数值等价千执行 一个与 2 的幕相乘的无符号乘法。\n原理: 与 2 的幕相 乘的无符号 乘法\nC 变量 x 和 K 有无符 号数值 x 和 k\u0026rsquo; 且 O k\u0026lt; w , 则 C 表达式 x \u0026lt;\u0026lt;k 产 生数 值 X * ::,2k o\n由于固定大小的补码算术运算的 位级操作与其无符号运算 等价, 我们就可以对补码运算的 2 的幕的乘法与左移之间的关系进行类 似的表述:\n原理: 与 2 的幕相乘的补码乘法\nC 变量 x 和 K 有补码值x 和无符号数值k , 且 O k\u0026lt; w , 则 C 表达式x\u0026lt;\u0026lt;k 产生数值 x 只笠。\n注意,无 论 是 无 符 号 运算还是补码运算,乘 以 2 的 幕都 可能会导致溢出。结果表明, 即使溢出的时候, 我们通过移位得到的结果也是一样的。回到前面的例子, 我们将 4 位模式[10 11] ( 数值 为 11 ) 左移两位得 到[ 101100]( 数值为 44 ) 。将这个值截断 为 4 位得到[ 1100] ( 数值为 12 = 44 mod 16 ) 。\n由于整数乘法比移位和加法的代价要大得多,许 多 C 语言编译器试图以移位、加法和减法的组 合来消除很多整数乘以常数的情况。例如, 假设一个程序包含表达式 X * 1 4。 利用14 = 23 十沪 + 21 , 编 译 器会将乘法重写为 (x \u0026lt;\u0026lt;3 ) + (x \u0026lt;\u0026lt;2 ) + (x \u0026lt;\u0026lt;l ) , 将一个乘法替换为三 个移位和两个 加法。无论 x 是无符号的还是补码, 甚至当乘法会导致溢出时, 两 个 计 算 都会得到一样的结果。(根据整数运算的属性可以证明这一点。)更好的是, 编 译 器 还可以利用属性 14 = 24 - 21 \u0026rsquo; 将 乘 法重写为 (x « 4 ) 一 (x \u0026lt;\u0026lt;l ) , 这时只需要两个移位和一个减法。\n练习题 2. 38 就像我们 将在 第 3 章中看到 的 那样, L E A 指令能 够执行形如 (a \u0026lt;\u0026lt;k ) +b 的计 算, 这里 k 等于 0 、1 、2 或 3\u0026rsquo; 而 b 等于 0 或 者某个程序值。编译器 常 常用 这条指令 来执行常数因子乘法。 例 如, 我们 可 以 用 (a \u0026lt;\u0026lt;l ) +a 来计算 3*a 。\n考虑 b 等于 0 或者等于 a 、K 为 任意可 能的值的 情况 , 用 一条 L E A 指令 可以 计算\na 的哪 些倍 数?\n归纳一下我们的例子,考 虑 一 个 任 务 , 对于某个常数 K 的表达式 x * K 生成代码。编译器会将 K 的二进制表示表达为一组 0 和 1 交替的序列:\n[ (O···O) ( 1· •• l) ( O·· · O) · ··0 ·· · 1 ) ]\n例如, 1 4 可以写成[ ( O… 0 ) (1 11 ) ( 0 ) ] 。 考 虑 一组从位位置 n 到位位置 m 的连续的 l ( n\nm ) 。(对于 14 来说, 我们有 n = 3 和 m = l 。)我们可以用下面两种不同形式中的一种来计算这些位对乘积的影响:\n形 式 A: ( x« n ) + ( x « ( n—1 ) ) + …+ ( x \u0026lt;\u0026lt;m)\n形式 B: (x«(n+l))-(x\u0026lt;\u0026lt;m)\n把每个 这样连续的 1 的结果加起来, 不 用 做 任何乘法, 我们就能计算出 x * K。当然, 选择使用移位、加法和减法的组合, 还是使用一条乘法指令, 取 决 千这些指令的相对速度, 而这些 是与机器高度相关的。大多数编译器只在需要少量移位、加法和减法就足够的时候才使用这种优化。\n练习题 2. 39 对于位位置 n 为 最高有效位的 情况, 我们 要怎样修 改形 式 B 的表达式? 练习题 2. 40 对于下 面每个 K 的 值, 找 出 只 用 指定数 量的运 算表达 X * K 的 方 法, 这里我们认为 加法和 减法的开 销 相 当。 除 了 我们 已 经 考 虑 过的 简 单的 形 式 A 和 B 原则, 你可 能会需要使 用 一些技巧。\nK 6 移位 2 加法/减法 I 表达式 31 I I -6 2 I 55 2 2 练习题 2. 41 对于一组 从位位置 n 开始到 位位置 m 的连 续的 l\u0026lt; n m ) , 我们看到可以 产生 两种 形式的代码, A 和 B。编译器该如何 决定使用哪一种 呢?\n3. 7 除以 2 的幕\n在大多数机器上, 整 数 除 法要比整数乘法更慢一— 需要 30 个或者更多的时钟周期。\n除以 2 的幕也可以用移位运算来实现,只 不 过我们用的是右移, 而不是左移。无符号和补码数分别使用逻辑移位和算术移位来达到目的。\n整数除法总是舍入到零。为了准确进行定义,我 们要引入一些符号。对于任何实数 a ,\n定义La 」为唯一的整数 a \u0026rsquo;\u0026rsquo; 使 得 a \u0026rsquo; a \u0026lt; a \u0026rsquo; + l 。例如 , L 3. 1 4」= 3 , L- 3. 1 4」= - 4 而L 3 」=\n3 。同 样 ,定 义「a l 为唯一的整数 a \u0026rsquo;\u0026rsquo; 使得 a \u0026rsquo; —l \u0026lt; a a \u0026rsquo; 。例如 ,「3. 14 7= 4 , 「—3. 14 l= -3,\n而「3 1= 3。对于 x 多0 和 y\u0026gt; O, 结 果 会 是 L x / y 」, 而 对 于 x \u0026lt; O 和 y \u0026gt; O, 结 果 会是「x / y l。也就是说, 它将 向下 舍入一个正值, 而向上舍入一个负值。\n对无符号运算使用移位是非常简单的,部分原因是由千无符号数的右移一定是逻辑 右移。\n原 理: 除 以 2 的幕的 无符号除法\nC 变量 x 和 K 有无符号数值 x 和 k\u0026rsquo; 且 O k \u0026lt; w , 则 C 表达式 x \u0026gt;\u0026gt;k 产 生数 值L x / 2勹。例如 ,图 2-28 给出了在 12 340 的 16 位表示上执行逻辑右移的结果,以 及 对 它执行除\n以 1、2、16 和 256 的结果。从左端移入的 0 以斜体表示。我们还给出了用真正 的运算做除法得到的结果。这些示例说明,移位总是舍入到零的结果,这一点与整数除法的规则 一样。\n图 2- 28 无符号数除以 2 的幕(这个例子说明了执行一个逻辑右移k 位与\n除以 2k 再舍人到零有一样的效果 )\n推导:除 以 2 的幕的无符号除法\n设 x 为位模式[ x w- 1 • Xw- 2 , …, x 。] 表 示 的 无 符 号 整数, 而 K 的 取 值 范 围 为 O::( k \u0026lt;\nW 。 设 x \u0026rsquo; 为 w —K 位 位 表示[ Xw- 1 , Xw- 2 • … , Xk ] 的 无 符 号 数 , 而 x\u0026quot; 为 K 位 位表示[ Xk- 1 •\n…, x。]的 无 符号数。由此, 我们可以看到 x = 2坛'+工", 而 O::( x\u0026rsquo;\u0026rsquo; \u0026lt; 2k 。 因 此, 可得L x i\n沪」= x \u0026rsquo; 。\n对位向量[ x w- 1 , Xw- 2 , … , x。J逻辑右移 k 位会得到位向量\n[ O\u0026rsquo; … ,O , x 亿 一 1 , X已 ,…,k工] .\n这 个 位向量有数值 x \u0026rsquo;\u0026rsquo; 我们看到,该 值可以通过计算 x\u0026gt;\u0026gt;k 得到。\n对于除以 2 的幕的补码运算来说,情 况要稍微复杂一些。首先, 为了保证负数仍然为负,移 位 要 执行 的 是 算术右移。现在让我们来看看这种右移会产生什么结果。\n原理: 除 以 2 的幕的 补码除法,向 下舍 入\nC 变量 x 和 K 分别有补码值 x 和无符号数值 k\u0026rsquo; 且 O k \u0026lt; w , 则当执行算术移位时,\nC 表达式 x \u0026gt;\u0026gt;k 产生数 值L x / 2k 」。\n对 于 x O, 变扯 x 的最高有效位为 o, 所以效果与逻辑右移是一样的。因此,对千非负 数来说,算 术 右移 K 位 与除 以 沪 是 一样的。作为一个负数的例子,图 2-29 给出了对—12 340 的 16 位表示进行算术右移不同位数的结果。对于不需要舍入的情况( k = 1), 结果 是 x / 2k 0 但是 当需 要 进行舍入时,移 位导 致结 果 向 下 舍 入。例如, 右移 4 位将会把一771. 25 向下舍入为—772。我们需要调整策略来处理负数 x 的除法。\nk \u0026gt;\u0026gt;k C二进制) 十进制 - J234Q / 2k 。 I 4 8 ll OOll l l llOOll 00 -12340 -12340.0 JI 10011111100110 —6170 -6170.0 II JJI IOOI I I II IOO -772 -771.25 11JJJJJJ11001111 -49 —48.203 125 图 2- 29 进行算术 右移(这个例子说明了算术右移类似于除以 2 的幕, 除 了是向下舍入, 而不是向 零舍入)\n推导:除 以 2 的幕的补码除法,向 下 舍 入\n设 x 为位模式[ x w- 1 • 五 - 2\u0026rsquo; … , 工。]表 示 的 补 码 整数, 而 K 的 取 值 范 围 为 O k \u0026lt; w 。设 x \u0026rsquo; 为 w —k 位[ x w- 1\u0026rsquo; 江 - 2\u0026rsquo; … , Xk ] 表 示的补码数, 而 x\u0026quot;为低 k 位[ Xk- 1 , …, x o J 表 示的无符号数。通过与对无符号情况类似的分析, 我们有 x = 2坛\u0026rsquo; + x\u0026quot; , 而 O x \u0026ldquo;\u0026lt; Zk , 得到\nx\u0026rsquo; = 巨 / 2勹。 进 一 步 , 可以观察到, 算术右移位向量[ xw- 1 , Xw-2 , …, X。] k 位 ,得 到 位 向量\n[ x u气 ,…,Xu- 1 , Xu一 l , X已 ,…,Xk]\n它刚好就 是将[五- ] \u0026rsquo; 立 - 2 \u0026rsquo; … , Xk ] 从 w —k 位符号扩展到 w 位。因此, 这 个 移 位 后 的 位向量就是L x / 2勹的 补 码 表 示。\n我们 可以通过在移位之前 "偏置( biasin g) \u0026quot; 这个值, 来修正这种不合适的舍入。原理:除 以 2 的幕的补码除法,向 上舍入\nC 变量 x 和 K 分别有补 码值 x 和无符号数值 k\u0026rsquo; 且 O k \u0026lt; w , 则当执行算术移位时,\nC 表达式 (x + (l « k) — l ) » k 产生数 值L x / 2k 」。\n图 2-30 说明在执行算术右移之前加上一个适当的偏置量是如何导致结果正确舍入的。在第 3 列, 我们给出了—12 340 加上偏最值之后的结果,低 k 位(那些 会向 右移 出的 位)以 斜体表示 。我们可以看到,低 K 位 左 边 的 位可能会加 1 , 也可能不会加 1。对于不需要舍入的情 况( k = 1)\u0026rsquo; 加 上 偏 量 只 影响那些被移掉的位。对于需要舍入的情况,加 上 偏 量导致较高的 位加 1, 所以结果会向零舍入。\nk 偏量 -12 340 + 偏量 \u0026gt;\u0026gt; k (二进制) 十进制 - l 2340 /2k 。 I 4 8 。 I 15 255 1100111111001100 110011111100 I101 1100111111011011 1101000011001011 11001 l llllOOllOO JI 10011111100110 1111110011111101 11111lllllOIOOOO -12340 —6170 -771 -48 -12340.0 - 6170 . 0 —771.25 -48.203125 图 2-30 补码除以 2 的幕(右移之前加上一个偏蜇,结 果就向零舍入了)\n偏置技术利用如下属性:对 于 整 数 x 和 y ( y \u0026gt; O) , 「 x / y l = L\u0026lt;x + y —1) / y 」。例如, 当x = —30 和 y = 4 , 我们有 x + y —1 = — 27 , 而「- 30/ 4 7= - 7 = L— 27 / 4 」。当 x = — 32 和y = 4 时 , 我们有 x + y - l = - 29 , 而「—32/ 4 7= —8 = L—29/ 4」。\n推导: 除 以 2 的 幕 的 补 码除法,向 上 舍 入\n查看「x / y l = l ( x + y - l ) / y 」,假 设 x = qy + r , 其中 o\u0026lt;r \u0026lt; y , 得 到 ( x + y —1 ) / y =\nq+ ( r + y - l ) / y , 因此LC x + y - D / y 」= q+ LrC + y - 1 ) / y 」。 当r = O 时 ,后 面一项等千 o ,\n而当 r \u0026gt; O 时 ,等 于 1 。也 就 是 说 , 通过给 x 增加一个偏量 y - 1, 然后再将除法向下舍入, 当 y 整除 x 时, 我们得到 q , 否则, 就得 到 q+ l 。\n回到 y = 护 的情况, C 表达式 x + ( l \u0026lt;\u0026lt;k ) - 1 得到数值 x + 2k — l 。 将这个值算术右移 K\n位即产生巨 / 2k 」0 ■\n这个分析表明对于使用算术右 移的补码机器 , C 表达式\n(x\u0026lt;O? x+(1\u0026lt;\u0026lt;k)-1 : x)»k\n将会计算数值 x / 2* o\n练习题 2. 42 写 一个 函 数 d i v 1 6 , 对 于整数 参 数 x 返 回 x / 1 6 的 值。 你的 函 数 不 能使用 除法、 模运算、 乘 法、 任 何 条 件 语 句(江 或 者?:)、 任 何 比 较 运 算 符( 例 如 \u0026lt;、\n>或==)或 任何 循 环。 你可以 假设数 据 类 型 i n t 是 3 2 位 长 ,使 用 补码 表 示 , 而右 移\n是算术右 移。\n现在我们看到 , 除以 2 的幕可以通过 逻辑或 者算术右移来实现。这也正是为什 么大多数机器上提供 这两种类型的右移。不幸的是, 这种方法不能推广到 除以任意常 数。同乘法不同, 我们不能用除以 2 的幕的除法来表示除以 任意常数 K 的除法。\n练习题 2. 43 在下 面的 代码 中,我们 省略 了常数 M 和 N 的定 义:\n#define M /* Mystery number 1 *I\n#define N I* Mystery number 2 *I int arith(int x, int y) {\nint result= O;\nresult= x*M + y/N; I* Mand N are mystery numbers. *I return result;\n}\n我们以 某个 M 和 N 的值编 译这段代码。 编译器用 我们 讨论 过的方 法优 化乘 法和除\n法。 下面是将 产 生出的 机器代 码翻译 回 C 语言的 结果 :\n/• Translation of assembly code for arith•/ int optarith(int x, int y) {\nint t = x;\nX \u0026lt;\u0026lt;= 5;\nX -= t;\nif (y \u0026lt; 0) y += 7;\ny»= 3; I• Arithmetic shift•/ return x+y;\nM 和 N 的值为 多少?\n2. 3. 8 关千整数运算的最后思考\n正如我们看到的 , 计算机执行的 “整数” 运算实际 上是一种模运算形式。表示数字的有限字长限制了可能的值的取值范围 ,结 果运算可能溢出。我们还看到, 补码表示提供了一种既能表示负数也能表示正数的灵活方法 ,同 时使用了与执行无符号算术相同的位级实现,这些运算包括像加法、减法、乘法,甚至除法,无论运算数是以无符号形式还是以补 码形式表示的,都有完全一样或者非常类似的位级行为。\n我们看 到了 C 语言中的某些规定可能会产生令人意想不 到的结果 , 而这些结果 可能是难以察觉或理解的缺陷的源头。 我们特别 看到了 u n s i g ne d 数据类型,虽 然它概念上很简单, 但可能导致即使是资深程序员都 意想不到的行为。我们还看到这种数据类型会以出乎意料的方式出现, 比如,当 书写整数常数 和当调用库函数时。\n区U 练习题 2. 44 假设我们在对有符号值使用补码运算的 32 位机器上运行代码。对于有符号值使用的是算术右移,而对于无符号值使用的是逻辑右移。变量的声明和初始化如下:\nint x \u0026ldquo;\u0026lsquo;foo(); I* Arbitrary value *I\nint y \u0026ldquo;\u0026lsquo;bar(); I* Arbitrary value *I\nunsigned ux = x; unsigned uy = y;\n对于下面每个 C 表达 式, 1) 证明对 于所有的 x 和 y 值, 它都 为 真(等于 1) ; 或者\n2 ) 给出使得 它为假(等于 0 ) 的 x 和 y 的值 :\nA. (x \u0026gt; 0) 11 (x-1 \u0026lt; 0)\nB. (x \u0026amp; 7) ! = 7 11 (x«29 \u0026lt; o)\nC. (x * x) \u0026gt;= 0\nx \u0026lt; 0 I I -x \u0026lt;= 0\nx \u0026gt; 0 I I -x \u0026gt;= 0\nx+y == uy+ux\nX*-y + UY*UX == -x\n4 浮点数\n浮点表示对 形如 V= x X 护 的有理 数进行编码。它对执行涉及非常大的数字( I VI \u0026gt;\u0026gt;\n、非常接近于 O( IV l \u0026lt;\u0026lt; D 的数 字, 以及更普遍地作为实数运算的近似 值的计算, 是很有用的。\n直到 20 世纪 80 年代,每 个计算机制造商都设计 了自己的 表示浮点数的规则, 以及对浮点数执行运算的细节。另外,它们常常不会太多地关注运算的精确性,而把实现的速度 和简便性看得比数字精确性更重要。\n大约在 1 985 年, 这些情况随着 IE EE 标准 75 4 的推出而改变了, 这是一个仔细制订的表示浮点 数及其运算的标 准。这项工作是从 1976 年开始由 I nt el 赞助的, 与 808 7 的设计同时进 行, 808 7 是一种为 8086 处理器提供浮点支持的芯片。他们请 Will ia m Kahan(加州大学伯克利分校的一位教授)作为顾问 , 帮助设计未来处理器浮点标准。他们支持 Kaha n 加入一个 IE E E 资助的制订工业标准的委员会。这个委员会最终采纳的标准非常接近于\nKahan 为 Int el 设计的标准。目 前,实 际上所有的计算机都支持这个后来被称为 IE E E 浮点的标准。这大大提高了科学应用程序在不同机器上的可移植性。\nm IEEE ( 电气和电子工程师协会)\n电气和电 子工程 师协会( IE E E , 读做 \u0026quot; eye- t rip le-ee\u0026rdquo; ) 是一个包括所有电子和计算机技术的专业团体。它出版刊物,举办会议,并且建立委员会来定义标准,内容涉及从电 力传 输到软件 工程 。另一 个 IE E E 标准的 例子是 无线 网络的 802. 11 标准。\n在本节中, 我们将看到 IE E E 浮点 格式 中数字是如何表示的。我们还将探讨 舍入( rounding ) 的问题, 即当一个数字不能被准确地表示为这种格式时 , 就必须向上调整或者向下调整。然后,我们将探讨加法、乘法和关系运算符的数学属性。许多程序员认为浮点 数没意思 , 往坏了说, 深奥难懂。我们将看到, 因为 IE E E 格式是定义在一组小而一致的原则上的,所以它实际上是相当优雅和容易理解的。\n2. 4. 1 二进制小数\n理解浮点数的第一步是考虑含有小数值的二进制数字。首先,让我们来看看更熟悉的十进制表示法。十进制表示法使用如下形式的表示:\nd \u0026lsquo;\u0026ldquo;d 1 ···d i d 。. d - 1d-2···d-n\n其中每个十进制数 d, 的取值范围是 0~ 9。这个表达描述的数 值 d 定义如下 :\nd = ;t\u0026quot;lO; X d,\n数字权的定 义与十进制小数点符号( \u0026lsquo;.\u0026rsquo;)相关, 这意味着小数点左边的数字的权是 10\n的正幕, 得到 整 数值, 而小数点右边的数字的权是 10 的负幕, 得到小 数值。例如,\n12. 3 知 表示数字 1 X 101 + 2 X 10° + 3 X 10-1 + 4 X 1-0\n2 =12 34\n100°\n类似, 考虑一个形如\n丛bm- 1 •;• bl b。. b一I b- 2 •••b- n- 1b- n\n的表示法,其中每个二进制数字,或者 称为 位, b; 的取值范围是 0 和 1, 如图 2-31 所示。这种表示方法表示的数 b 定义如下:\n2m\nz m-1\n二:\nb = 2i X b; (2. 19)\n, = - n\n符号\u0026rsquo;.\u0026lsquo;现在变为了二进制的点,点 左边的位的权是 2 的 正幕,点 右边的 位的权是 2 的负幕。例如, 1 01. 112 表示\n数字 1 X 22 + 0 X 21 + 1 X 2° + 1 X -2 1 +\n1/2n-1\n1/2\u0026rdquo;\n1 x -2\n2= 4+ 0 + 1 + 丿 + 1_ = 5 立\n2 4 4° 图 2-31 小数的二进制表示。二进制点左边的数字的\n从 等式 ( 2. 19 ) 中可以 很容易 地 看 权形如 ;2 \u0026rsquo; 而右边的数字的权形如 1 / 2\u0026rsquo;\n出, 二进制小数点向左移动一位相当于这个数被 2 除。例如, 101. 11 2 表示数 5 一, 而\n10. 1112表示数 2+ 0 +\n1 1 1 7\n— + — + - = 2 - 。类 似, 二进制小数点向右移动一位相当千将该\n2 4 8 8\n数乘 2。例如 1011. 12 表示数 s + o + 2+ 1 + - = 11 -\n2 2°\n注意, 形如 0. 11··士 的数表示的是刚好小于 1 的数。例如, 0. 1111112\n63\n表示祈, 我们\n将用简单的表达法 1. 0 - € 来表示这样的数值。\n假定我们仅考虑有限长度的编码,那么十进制表示法不能准确地表达像一和—这样的 数。类似,小 数的二进制表示法只能表示那些能够被写成 x X 护的 数。其他的值只能够被近似地表示。例如, 数字— 可以用十进制小数 0. 20 精确表示。不过, 我们并不能把它准\n确地表示为一个二进制小数,我们只能近似地表示它,增加二进制表示的长度可以提高表示的精度:\n练习题 2. 45 填写下 表中的缺 失的信息 :\n练习题 2 . 46 浮点运算的 不精 确性能够产 生灾 难性 的后果 。1 9 91 年 2 月 2 5 日 , 在第一次海湾战 争期间 , 沙特 阿拉伯 的达摩地 区设 置的 美国爱国 者导 弹, 拦截伊拉克 的飞毛腿导 弹失败。 飞毛腿 导弹 击中 了 美国的 一个 兵 营, 造成 2 8 名 士 兵 死亡。 美国 总审计局 ( G AO ) 对失败原 因做 了 详细的 分析[ 76] , 并且确定底层的原因在于一个数字计算不精确。 在这 个练 习中,你将 重现 总审计局分 析的 一部 分。\n爱国者 导弹 系统 中含 有 一个内置的 时钟 , 其实现 类似 一个 计数器, 每 0. 1 秒就 加\n1 。为 了以 秒为 单位 来确定 时间 , 程 序将用 一个 2 4 位的近似 于 1 / 1 0 的 二进 制小 数值来乘以 这个计数 器的值。 特别地 , 1 / 1 0 的二进 制表 达式是 一个无 穷序 列 0 . 0 0 0 11 0 0 1 1 [ 0 0 11 ] …2\u0026rsquo; 其中, 方括号里 的部 分是无限重复的 。 程序用值 x 来近似 地表 示 0 . 1, X\n只考虑这个 序 列 的 二 进制 小 数 点 右 边 的 前 2 3 位: :r = 0 . 0 0 0 11 0 0 11 0 0 11 0 0 11 0 0 11 0 0 。\n(参考练 习题 2. 51, 里 面有 关于如何 能够 更精确地 近似表 示 0. 1 的讨论。)\n0 . 1- x 的二进 制表 示是什 么?\n0. 1 - x 的近似的十进 制值是 多少?\nc. 当系 统初始启 动 时, 时钟 从 0 开始, 并且一直保持计 数。 在这个例 子中, 系统 巳经运 行了大 约 1 0 0 个小 时。 程序计 算出的 时间和 实际 的时间之差 为 多少?\nD. 系统 根据一枚来袭导 弹的 速 率和 它最 后被 雷达侦 测 到的 时间, 来预 测 它 将在 哪里出现 。假定飞毛腿 的速率 大约是 2 0 0 0 米每 秒, 对它的预测 偏差 了多 少?\n通过一次读取 时钟得到的绝对 时间 中的轻微错误 , 通常不会 影响 跟踪的 计算。相反, 它应该 依赖于两次连续的读取之间的相对时间。问题是爱国者导弹的软件 巳经升级 , 可以使用更精确的函数来读取时间 , 但不 是所有的 函数调 用都用 新的代码替 换了。 结果 就是 , 跟踪软件 一次读取用的是精确的时间 ,而另 一次读取用的是不精确的时间 [ 10 3] 。\n4. 2 IEEE 浮点 表示\n前一节中谈到的定点表示法不能很有效地表示非常大的数字。例如, 表达式 5 Xz 1 0 0 是用 101 后面跟随 1 00 个零的位模式来表示。相反 , 我们希望通过给定 x 和 y 的值, 来表示形如 x X 护 的数 。\nIEEE 浮点标准用 V = C— l ) \u0026rsquo; X M X 沪的形式来表示一个数:\n符号( sig n ) s 决定这数是负数 Cs = 1 ) 还是正 数( s = O) , 而对于数值 0 的符号位解释作为特殊情况处理。 尾数( s ig ni fi cand ) M 是一个二进制小数, 它的范围是 1 ~ 2—€\u0026rsquo; 或者是 O~ l —C。 阶码( ex po nen t ) E 的作用是对浮点数加权 ,这 个权重是2 的 E 次幕(可能是负数)。将浮点数的位表示 划分为三个字段,分别 对这些值进行编码: 一个单独的符号位 s 直接编码符号 s 。 k 位的阶码字段 e xp = ek- 1…e1e 。编码阶码 E。\nn 位小数字段 fr a c = f n- 1 …八儿 编码尾数 M, 但是编码出来的值也依赖于阶码字段的值是否等千 0。\n图 2- 32 给出了将这三个字段装进字中两种最常见的格式。在单精度浮点 格式C C 语言中的 fl oa t ) 中, s 、 e x p 和 fr a c 字段分别 为 1 位、k = 8 位和 n = 23 位, 得到一个 32 位的\n表示。在双精度 浮点格式cc 语言中的 do ub l e ) 中, s 、 e xp 和 fr a c 字段分别为 1 位、k =\n11 位和 n = 52 位, 得到一个 64 位的表示。\n单精度\n31 30 23 22\n双精度\n63 62 52 51 32\nfrac(31:0)\n图 2-32 标 准 浮点格式(浮点数由 3 个字段表示。两种最常见的格式是它们被封装到 32 位(单精度)和64 位(双精度)的字中)\n给定位 表示, 根据 e x p 的值, 被编码的值可以分成三种不同的情况(最后一种情况有两个变种)。图2-33 说明了对单精度格式的情 况。\n规格化的 非规格化的 3a. 无穷大\n3b.NaN\n日粗巨冈荆咽 ¥-0\n图 2-33 单精度浮点数值的分类(阶码的值决定了这个数是规格化的、非规格化的或特殊值)\n情况 1 : 规格化的值\n这是最普遍的 情况。 当 e xp 的位模式既不全为 0 ( 数值 0 ) , 也不全为 1(单精度数值为\n255, 双精度数值为 2047) 时, 都属于这类情况。在这 种情况中,阶码 字段被解释为以偏置\n(biased) 形式表示的 有符号整数。也 就是说, 阶码的值是 E = e- Bias , 其中 e 是无符号数,\n其位表示为 ek -1 …e心 , 而 Bias 是一个等于 2- k l -1 ( 单精度是 127 , 双精度是 1023) 的偏置\n值。由此 产生指数的取值范围, 对千单 精度是—126 ~ + 127, 而对于双精度是—1022 ~\n+ 1023。\n小数字段 fr a c 被解释为描述小数值 f , 其中 o::;;;;J \u0026lt; I. 其二进制表示为 0. f n- 1 …\n!1!0• 也就是二进制小数点在最高 有效位的左边。尾数定义为 M = l + J 。有时 , 这种方式 也叫做隐 含的以 1 开头的 ( implied leading 1 ) 表示 , 因为我们可以 把 M 看成一个二进制表 达式为 l. f n - Jf n- 2 … 儿的数字。既 然我们总是能够调整阶码 E , 使得尾数 M 在范围 1:::;;; M\u0026lt; 2 之中(假设没有溢出), 那么这种表示方法是一种轻松获得一个额外精度位的技巧。既然第一位总是 等千 1, 那么我们就不需要显式地表示它。\n情况 2 : 非规格化的值\n当阶码域 为全 0 时, 所表示的数 是非规 格化形 式。在这种情况下, 阶码值是 E = l ­\nBias, 而尾数的 值是 M= f , 也就是小数字段的 值, 不包含隐含的开头的 1。\nm 对千非规 格化值 为什 么要这样设 置偏置值\n使阶码值为 l - Bia s 而不 是简单的—Bia s 似乎是 违反直觉的 。我们将很快看到 , 这种方式 提供了 一种从 非规 格化值平滑转换到规 格化值的方法 。\n非规格化数有 两个用途。首先, 它们提供了一种表示数值 0 的方法, 因为使用规格化\n数,我们必须总 是使M诊1, 因此我们就不能表示 0。实际上,十 0. 0 的浮点表示的位模式为全 0: 符号位是 o, 阶码字段全为 0( 表明是一个非规格化值), 而小数域也全为 o, 这就得到\nM= f = O。 令人奇怪的是, 当符号位为 1 , 而其他域全为 0 时, 我们得到值— 0. 0。根据\nI庄 E 的浮点格式, 值+ o. o 和—0. 0 在某些方 面被认为是不同的 , 而在其他方面是相同的。非规格化数的 另外一个功能是表示那些非常接近于 0. 0 的数。它们提供了一种属性,\n称为逐渐 溢出 ( grad ual underflow) , 其中, 可能的数值分布均匀地接近于 0. 0。情况 3 : 特殊值\n最后一类数值是当指阶码全为 1 的时候出现的。当小数域全为 0 时, 得到的值表示无穷,当 s = O 时是十= , 或者当 严 1 时是一~ 。当 我们把两 个非常大的数相乘, 或者除以零时,无穷能 够表示溢出的结果 。当小数域为非 零时, 结果值被称为 \u0026quot; NaN\u0026rdquo; , 即“不是一个数( Not a Number)\u0026rdquo; 的缩写。一些运算的结果不能是实数或无穷, 就会返回这样的 NaN 值, 比如当计算F 了或~ —~时。在某些应用中, 表示未初始化的数据时 , 它们也很有用处。\n2. 4. 3 数字示例\n图 2-34 展示了一组数值,它 们可以用假定的 6 位格式来表示, 有 k = 3 的阶码位和n =\n2 的尾数位。偏置量是 23- J —1 = 3。图中的 a 部分显示了所有可表示的 值(除了 Na N ) 。两个无穷值在两个末 端。最大数量值的规 格化数是土14 。非规 格化数聚集在 0 的附近。图 的\nb 部分中 ,我 们只展 示了介于— 1. 0 和十1. 0 之间的数值,这 样就能够看得更加清楚 了。\n两个零是特殊的非 规格化数。可以观察到, 那些可表示的 数并不是均匀分布的一 越靠近原点处它们越稠密。\nn 众\n-oc\n分 志 * 金\n-10\n-5 0 +5\n七 众\n+10\n.. \u0026hellip;. 口\n+ oo\nI • 非规格化的\n\u0026hellip; 规 格化. 的..\no 无-\n穷. J\n^ , 山\n-1 -0.8\n血 , 血 血 , 血 ^\n—0 .6 -0.4\na ) 完整范围\n血..\n- 0 . 2\n. .,.\n+ 0.2\n血 血 I • .t,. I 心\n+0.4 +0.6\n么 , 心\n+0.8\nF 格化的 \u0026hellip; 规格化的 a 无 穷 l\nb) 范围在- 1.0 ~ +1.0的数值\n图 2-34 6 位浮点格式可表示的值( k = 3 的阶码位和 11= 2 的 尾数位。偏 置量 是 3 )\n图 2-35 展示了假定的 8 位浮点格式的示例,其 中 有 k = 4 的阶码位和 n = 3 的 小数 位。偏置量是 2-1 1 - 1 = 7。图被分成了三个区域,来 描 述三类数字。不同的列给出了阶码字段是如何编码阶码 E 的,小 数 字段是如何编码尾数 M 的, 以 及 它 们 一 起 是 如何形成要表示的值 V = 2E X M 的。从 0 自身开始,最 靠 近 0 的是非规格化数。这种格式的非规格化数的\nE = l —7 = —6 ,\n1\n得到权沪=6-4 °\n小数J 的值的范围是o,\n7\n— ,从 而得到数 V 的\n8\n范围是 o -1 x -7 = 7\n64 8 512°\n描述 位表示\n指数 小数\ne E 2£ f M\n。 0 0000 000 。-6 I i 8\n最小的非规格化数 0 0000 001 -6 I\n0 0000 010 。-6\n召 k k\n忐 i i\n0 0000 011 -6 I\n召 i i\n最大的非规格化数 00000111 。-6 I 7 7\ni l\n忐 # I 9\n8 8\n14\n可\n0 0110 Ill 6 - 1 ½ 。 孕\nI 00111 000 7\nl 8 I\n。 l k 9\n2\n8\ni # 7\n8\n图 2-35 8 位浮点 格式的非负值示例( k = 4 的 阶码位的和 n = 3 的小数位。偏置员是 7)\n这种形式的最小规格化数同样有 E = l - 7 = -6, 并且小数取值范围也为 o,\n... ,\n7\n一。然而,\n8\n尾数在范围 1 + 0 = 1 和 1 +\n7 15\n—8 =一8 之间\n得出数 V\n8 1 15\n在范围一 =— 和一-之间。\n512 64 512\n8\n可以观察到最大非规格化数百歹和最小规格化数可石之间的平滑转变。这种平滑性归功\n于我们 对非规格化数的 E 的定义。通过将 E 定义为 1— Bia s , 而不是—Bia s , 我们可以补偿非规格 化数的尾数没有隐含的开头的 1。\n当增大 阶码时, 我们成功地得到更大的规格化值, 通过 1. 0 后得到最大的规格化数。\n这个数具有 阶码 E = 7 , 得到一个 权 2E = 1 28。小 数等 千—8 得到尾数\n15\nM = -8 。因此, 数 值\n是 V= 240 。超出这个值就会溢出到十~ 。\n这种表示 具有一个有趣的属性, 假 如 我们将图 2-35 中 的值的 位 表达式解释为无符号整数, 它们就是按升序排列的, 就 像 它 们 表示的浮点数一样。这不是偶然的 IEEE 格式如此设计 就是 为了浮点数能够使用整数排序函数来进行排序。当处理负数时,有 一 个 小的难点, 因为它们有开头的 1 , 并且它们是按照降序出现的,但是不需要浮点运算来进行比较也能解决 这个问 题(参见家庭作业 2. 84 ) 。\n区i 练习题 2. 47 假设一个基于 IE EE 浮点格式的 5 位浮点表 示, 有 1 个符 号位、2 个阶\n码位 ( k = 2) 和两个 小数位( n = 2 ) 。 阶码偏 置 量是 2- 2 1 —l = l 。\n下表 中列举 了这个 5 位浮点表示的 全部非负 取值范围。 使用 下面的条件, 填写 表格中的空白项:\ne: 假定阶码 字段 是一个 无符号整数所表 示的 值。\nE: 偏置之后的阶码值。\n2气 阶码的权重。\nJ: 小数值。\nM: 尾数的值。\n沪 X M : 该数(未归 约的)小数值。\nV: 该数归约 后的 小数值。\n.十进制 : 该数的 十进制表 示。\n写 出 2气 J、M、2E X M 和 V 的值, 要 么是 整数(如果可能的话),要 么是 形如王的小数 , 这里 y 是 2 的幕。标注为 “一 ” 的条目不用填。\n位 e E 2£ f M 2ExM V 十进制 0 00 00 0 00 01 0 00 10 0 00 11 0 01 00 0 01 01 I 。 I ¾ ¾ ¾ ¾ 1.25 0 01 10 0 01 11 0 1 0 00 0 10 01 0 1 0 1 0 0 1 0 11 0 11 00 0 11 01 0 1110 0 1111 图 2-36 展示了一些重要的单 精度和双精度浮点 数的表示和数字值。根据图 2-35 中展示的 8 位格式, 我们能够看出有 K 位阶码 和 n 位小数的浮点表示的一般属性。\n描述 exp fr a c 单精度 双精度 十进制 十进制 。 最小非规格 化 数 00· · ·00 00· · ·00 0···00 0 - · · 01 。值 2 - 23 X 2-126 (J-E:)xz-126 Jx 2-126 I X 2° (2 - E:) X 2127 0.0 \\.4 X 10- 45 J.2 X J0- 3S J.2 X 10- 38 1.0 3.4 X 1038 。值 z-52 X z-1022 (I - c ) X z-1022 I x 2-1022 I X 2° (2-1,) x 21023 0.0 4.9 X 10-324 2.2 X 10- 308 2.2 X JO- JOS 1.0 J.8 X JQJOS 最 大非规格化数 00· · ·00 I· ··11 最小规格化数 00· ·· 01 0 .. · 00 1 01· ·· II -0 ·· 00 最大规格化数 I I .. ·IO I .. ·II 图 2-36 非 负 浮 点 数 的 示 例\n值+ o. o 总 有一个全为 0 的位表示。 最小的正非规格化值 的位表示 , 是由最低有效位为 1 而其他 所有位为 0 构成的。它具有小数(和尾数)值M = f= 2- • 和阶码值 E = - 2k- ) + 2。因此它的数字值是 V= z - n- -2\u0026rsquo;\n\u0026rsquo; +2 。\n最大的非规格化值的位模式是由全为 0 的阶码字段和全为 1 的小数字段组成 的。它 有小数(和尾数)值M = f = l -\n-2 \u0026quot; ( 我们写成 1 - c) 和阶码值 E = — zk- 1 + z。因此,\n数 值 V = o - z- • ) x -z 2 +2 , 这仅比最小的规格化值小一点。\n最小的正规格化值的位模式 的阶码字段的最低有效位为 1, 其他位全为 0 。它的尾 数值 M = l , 而阶码值 E = — z- k\n1 + z 。 因此, 数值 V = -z l- 1 +2 .\n值 1. 0 的位表示的阶码字段除 了最高有效位等千 l 以外, 其他位都 等于 0。它的尾数值是 M = l , 而它的阶码值是 E = O。\n最大的规格化值的位表示的符号 位为 o, 阶码的最低有效位等千 o, 其他位等于 1。\n它的小数值 f = l - -z • , 尾数 M = z - -z • ( 我们写作 2—c) 。它 的 阶码值 E = zk-_ 1\n1\u0026rsquo; 得到数值 V = ( Z—2一\u0026quot;) X 22 -I = (1 — z - •- l ) X2 ;-2 i 0\n练习把一些整数值转 换成浮点 形式对理解浮点 表示很有用。例如, 在图 2-1 5 中我们看到 1 2 345 具有二进 制表示[ 11 000000111 001 ] 。通过将二进制小数点左移 1 3 位, 我们 创\n建这个数的一个规格化表示 ,得到 1 2345 = 1. 100000011 10012 X 21 3 。 为了 用 IEEE 单精度 形式来编码, 我们丢弃开头的 1\u0026rsquo; 并且在末尾增 加 10 个 o, 来构造小数字段,得到二进制表示[ 10000001110010000000000] 。为了构造阶码字段, 我们用 13 加上偏置扯 127 , 得到\n140, 其二进 制 表示 为[ 100011 00 ] 。加 上 符 号 位 0 , 我们就得到二进制的浮点表示\n[ 0100011001000000111010000000000] 。回想一下 2. 1. 3 节, 我们观察到整数值 1 23 45\n( Ox 303 9) 和单精度浮点值 1 23 45 . 0( 0x 4640E400 ) 在位级表示上有下列关系:\n0 0 0 0 3 0 3 9\n00000000000000000011000000111001\n*************\n4 6 4 0 E 4 0 0\n01000110010000001110010000000000\n现在我们可以看到, 相关的区域对应于整数的低 位, 刚好在等于 1 的最高有效位之前停止(这个位就是隐含的开头的 位 1 )\u0026rsquo; 和浮点表示的小 数部分的高位是相匹配的。\n练习题 2. 48 正 如 在 练 习 题 2. 6 中 提 到 的, 整 数 3 510 593 的 十 六 进 制 表 示 为\nOx00359141, 而单 精度 浮点数 351 05 93 . 0 的十 六进 制表 示为 Ox 4A5 645 0 4。 推导 出这个浮点表示,并解释整数和浮点数表示的位之间的关系。\n练习题 2. 49\n对于一种具 有 n 位小 数的 浮点格 式, 给出不能准 确描 述的 最小正 整数的公 式(因为要想 准确 表 示它需要 n + l 位小 数)。假设 阶码 字 段长度 K 足够大, 可以 表 示的 阶码范围不会限制这个问题。 对于 单精 度格 式( n = 23) , 这个整数的数字值是多少? 2. 4. 4 舍入 # 因为表示方法限 制了浮点数的范围和精度, 所以浮点运算只能近似地表示实数运算。因此, 对于值 x , 我们一般想用一种系统的方法, 能够找到“ 最接近的" 匹配值 x \u0026rsquo;\u0026rsquo; 它可 以用期 望的浮点形式 表示出来。这就是舍入( ro unding ) 运算 的任务。一个关键问题是在两个可能值的中间确定舍入方向。例如, 如果我有 1. 50 美元, 想把它舍入到最接近的美元\n数,应 该是 1 美元还是 2 美元呢?一种可选择的方法是维持实际数字的下界和上界。例如,我 们可以 确定可表示的值 x一和 工+ ,使 得 工的 值位于它们之间: x一冬 工::;;x勹 IEEE\n浮点格式定义了四种不同的舍入方式。默认的方法是找到最接近的匹配,而其他三种可用 于计算上界和下界。\n图 2-37 举例说明了四种舍入方式, 将一个金额数舍入到最接近的 整数美元数。向偶数舍入 ( ro und- to-e ven ) , 也被称为向最接近的值舍入( ro und - to- nea res t ) , 是默认的方式, 试图找到一个 最接近的 匹配值。因此, 它将 1. 40 美元舍入成 1 美元 , 而将 1. 60 美元舍入成 2 美元, 因为它们是 最接近的整数美元 值。唯一的设计决策是确定两个可能结果中间数值的舍入效果。向偶数舍入方式采用的方法是:它将数字向上或者向下舍入,使得结果的 最低有效数字是偶数 。因此,这 种方法将 1. 5 美元和 2. 5 美元都舍入成 2 美元。\n方式 1.40 1.60 1.50 2.50 -1.50 向偶数舍入 1 2 2 2 -2 向零舍入 I I I 2 -1 向下舍入 I I I 2 -2 向上舍入 2 2 2 3 一1 图 2-37 以美元舍入为例说明舍入方式(第一种方法是舍入到一个最接近的值, 而其他三种方法向上或向下限定结果,单位为美元)\n其他三种方式产生实际值的确界 ( g ua ra n teed bo und ) 。这些方法在一些数字应用中是很有用的 。向零舍入方式把正数向下舍入, 把负数向上舍入, 得到值x, 使 得1 分 l \u0026lt; lx l 。向下舍入方式 把正数和负数都向下舍入, 得到值 x一 ,使 得 x- \u0026lt; x。向 上舍入方式把正数和负数都向上舍 入, 得到值 x十 , 满足 x\u0026lt; x勹\n向偶数舍入初看上去好像 是个相当随意的目标一一有什么理由偏向取偶数呢?为什么不始终把位千两个可表示的值中间的值都向上舍入呢?使用这种方法的一个问题就是很容易假想到这样的情景:这种方法舍入一组数值,会在计算这些值的平均数中引入统计偏 差。我们采用这种方式舍入得到的一组数的平均值将比这些数本身的平均值略高一些。相反,如果我们总是把两个可表示值中间的数字向下舍入,那么舍入后的一组数的平均值将比这些数本身的平均值略低一些。向偶数舍入在大多数现实情况中避免了这种统计偏差。\n在 50%的时间里,它 将 向 上舍入,而 在 50 %的时间里,它 将 向 下 舍 入 。\n在我们不想舍入到整数时,也可以使用向偶数舍入。我们只是简单地考虑最低有效数字是奇数还是偶数。例如,假设我们想将十进制数舍人到最接近的百分位。不管用那种舍入方式, 我们都将把 1. 2349999 舍 入到 1. 23, 而 将 1. 2350001 舍入到 1. 24, 因为它们不\n是在 1. 23 和 1. 24 的正中间。另一方面我们将把两个数 1. 2350000 和 1. 2450000 都舍入到\n24, 因为 4 是偶数。\n相似地,向 偶 数 舍 入法能够运用在二进制小数上。我们将最低有效位的值 0 认为是偶数,值 1 认 为是奇数。一般来说,只有对形如 XX… X. YY… Yl OO… 的 二 进 制 位 模 式 的 数 , 这 种 舍 入 方 式 才 有 效 ,其 中 X 和 Y 表示任意位值,最 右 边的 Y 是要 被舍入的位置。只有这种位模式表示在两个可能的结果正中间的值。例如,考虑舍入值到最近的四分之一的问\n题(也就是二进制小数点右边 2 位)。我们 将 10. 000112 ( 2 点 )向下 舍 入 到 10. 002 (2)\u0026rsquo;\n01102(气3)向 上舍 入 到 1 0. 012 ( 2 丁 ),因为这些值不是两个可能值的正中间值。我们将\n10. 111002 ( 2 百 )向上舍 入成 11. 002 ( 3) , 而 10. 101002 ( 2 旬 向 下舍 入成 10. 102 (22— )\u0026rsquo; 因为\n这些值是两个可能值的中间值,并且我们倾向于使最低有效位为零。\n; 练习题 2. 50 根据舍入到偶数规则,说明如何将下列二进制小数值舍入到最接近的 二分之一(二进 制小数点右边 1 位)。对每种情况, 给出舍入前后的数 字值 。\nA. 10. 0102\nB. 10. 0112\nC. 10. 1102\nD. 11. 0012\n; 练习题 2. 51 在 练 习 题 2. 46 中 我 们 看 到 , 爱 国 者 导 弹 软件 将 0. 1 近似 表 示 为 x = 0. 00011 00110 01100110011002 。 假设使用 IEE E 舍入到 偶 数 方 式 来确定 o. 1 的 二进制小数点右边 23 位的近似表 示 x \u0026rsquo; 。\nx \u0026rsquo; 的二进制表 示是 什么?\nx\u0026rsquo;-o. 1 的十进制表 示的 近似值是什么?\n运行 100 小时后 , 计算 时钟值会有 多少 偏 差?\n该程序对飞毛腿导弹位置的预测会有多少偏差?\n笠 练习题 2. 52 考虑下列 基于 IEE E 浮 点 格式 的 7 位 浮 点 表 示。 两 个格 式 都 没 有 符 号位——它们只能表示非负的数字。\n格式 A 有 k = 3 个阶码位。 阶码 的偏 置值是 3。 有 n = 4 个小数位。 格式 B 有 k = 4 个阶码位。 阶码 的偏置值是 7。\n有 n = 3 个小数位。\n下面给出 了一 些格 式 A 表 示的位模 式, 你的 任 务是将它 们 转 换成格式 B 中最接近的值。如果 需要, 请使 用 舍入到 偶 数 的舍入原 则。 另 外, 给出 由格式 A 和格 式 B 表 示的 位模式对应 的数 字的 值。 给出整数(例如 17 ) 或 者小数(例如 17 / 64 ) 。\n格式A 格式B 位 011 0000 101 1110 010 l 001 110 1111 000 0001 值 1 位 0111 000 值 I 4. 5 浮点运算\nIEEE 标准指定了一个简单的规则, 来确定诸如加法和乘法这样的算术运算的结果。把浮点 值 x 和y 看成实数, 而某个运算O 定义在实数上, 计算将产生 R ound ( x0 y ) , 这是对实际运算的精确结果进行舍入后的结果。在实际中,浮点单元的设计者使用一些聪明的 小技巧来避免执行这种精确的计算,因为计算只要精确到能够保证得到一个正确的舍入结 果就可以了 。当参数中有一个是特殊值(如—0 、—~或 N a N ) 时, IE E E 标准定义了一些使之更合理 的规则。例如,定 义 1 / - 0 将产生一= , 而定义 1; + 0 会产生十~ 。\nIEEE 标准中指定浮点运算行 为方法的一 个优势在于,它 可以独立于任何具体的硬件或者软件实现。因此,我们可以检查它的抽象数学属性,而不必考虑它实际上是如何实现的。\n前面我们看到了整数(包括无符号和补码)加法形成了阿贝尔群。实数上的加法也形成了 阿贝尔群, 但是我们必须考虑舍入对这些属性的影响。我们 将 x + 勺定义为 Round ( x + y ) 。这个运算的定 义针对 x 和y 的所有取值, 但是虽然 x 和 y 都是实数, 由于溢出 ,该 运算可能得到无穷 值。对于所有 x 和y 的值, 这个运算是可交换的 ,也 就是说 x +r y = y +r x 。 另\n一方面 , 这个运算是 不可结合的。例如, 使用单精度浮点 , 表达式 (3. 14+lel0) - l el O 求值得到 o. o iz寸为舍入, 值 3. 14 会丢失。另一方面, 表达式 3. 1 4 + (l e l 0- l e l 0 ) 得出值\n14。作为阿贝尔 群, 大多数值在浮点加法下都有逆元,也 就是说 x +r — x = O。 无穷(因 为十 = - = = N a N ) 和 N a N 是例外情况, 因为对于任何 X , 都有 N a N + 1 x = N a N 。\n浮点加法不具有结合性,这是缺少的最重要的群属性。对于科学计算程序员和编译器编写者来说,这具有重要的含义。例如,假设一个编译器给定了如下代码片段:\n编译器可能试图通过产生下列代码来省去一个浮点加法:\nt \u0026ldquo;\u0026lsquo;b + c; xaaa+t; y\u0026rdquo;\u0026rsquo;t + d;\n然而, 对千 x 来说,这个 计算可能会 产生与原始值不同的值, 因为它使用了加法运算的不同的结合方式。在大多数 应用中, 这种差异小得无关紧要。不幸的是,编译 器无法知道在效率和忠实于原始程序的确切行为之间,使用者愿意做出什么样的选择。结果是,编 译器倾向千保守,避免任何对功能产生影响的优化,即使是很轻微的影响。\n另一方面,浮点 加法满足了单调性属性: 如果 a b , 那么对于任何 a 、b 以及x 的值, 除了 Na N , 都有 x + a x + b。无符号或补码加法不具 有这个实数(和整数)加法的属性。\n浮点乘法也遵循通常乘法所具有的许多属性。我们定义 X * f y 为R ou nd ( x X y ) 。这个\n运算在乘法中是 封闭的(虽然可能产生无穷大或 Na N ) , 它是可交换的,而且它的乘法单位元\n为 1. 0。另一方面, 由于可能发生溢出, 或者由于舍入而失去精度,它 不具有可结 合性。例如,单 精度浮点情况下, 表达式 (l e 2 0*l e 2 0 ) * l e - 20 求值为+ = , 而 l e 20* (l e 20 * l e - 20 ) 将得出 l e 20。另外, 浮点乘法在加法上不具备分配性。例如, 单精度浮点 悄况下, 表达式\nl e 2 0 * (l e 2 0 - l e 2 0 ) 求值为 o. o, 而 l e 20* l e 2 0- l e 2 0* l e 20 会得出 N a N 。\n另一方面,对于任何a 、b 和 c , 并且a 、b 和 c 都不等千 N a N , 浮点乘法满足下列单调性:\na b 且 C 0 a* 1 c b * 1c\na b 且 c 冬 0 a * 气\u0026lt; b* re\n此外, 我们还可以保证, 只要 a# N a N , 就有 a *r a O。 像我们先前所看到的, 无符号或补码的乘法没有这些单调性属性。\n对于科学计算程序员和编译器编写者来说,缺乏结合性和分配性是很严重的问题。即使为了在三维空间中确定两条线是否交叉而写代码这样看上去很简单的任务,也可能成为一个很大的挑战。\n4. 6 C 语言中的浮点数\n所有的 C 语言版本提供了两种不同的浮点数据类型: fl oa t 和 doub l e。在支持 IEEE 浮点格式的机器上.这些数据类型就对应千单精度和双精度浮点。另外,这类机器使用向偶数舍入 的舍入方式。不幸的是, 因为C 语言标准不要求机器使用 IEEE 浮点, 所以没有标准的方法来改变舍 入方式或者得到诸如一0 、 十OO 、 - oo 或者 N a N 之类的特殊值。大多数系统提供i nc l ud e ( \u0026rsquo; . h \u0026rsquo; ) 文件和读取这些特征的过程库, 但是细节随系统不同而不同。例,如当程序文件中出现下列句子时, G NU 编译器GCC 会定义程序常数INF INI TY( 表示十oo ) 和 NAN( 表示 Na N ) :\n#define _GNU_SOURCE 1\n#include \u0026lt;math.h\u0026gt;\n霆 练习题 2. 53 完成 下列 宏定 义, 生成双 精度值 + = 、—CX) 和 0 :\n#define POS_INFINITY\n#define NEG_INFINITY\n#define NEG_ZERO\n不 能使 用 任何 i n c l u d e 文件(例如 ma 七h . h ) , 但你能利用这样一个事实:双精度能够表 示的最 大的 有限 数, 大 约是 1. 8 X 10308o\n当在 i n 七、 fl o a t 和 d o u b l e 格式之间进行强制类型转换时, 程序改 变数值和位 模式的原则如下(假设i n t 是 32 位的):\n从 i n t 转换成 f l o a t , 数字不会溢出,但是可能被舍入。 从 i n t 或 fl o a t 转换成 do u b l e , 因为 d o u b l e 有更大的范围(也就是可表示值的范围),也有更高的精度(也就是有效位数),所以能够保留精确的数值。 从 d o u b l e 转换成 f l o a t , 因为范围要小一些,所以值可能溢出成十~或—~。另外,由于精确度较小,它还可能被舍入。\n从 fl o a t 或者 d o u b l e 转换成 i n t , 值将会向零舍入。例如, 1. 999 将被转换成 1\u0026rsquo; 而—1. 99 9 将被转换成—1 。进一步来说, 值可能会溢出。C 语言标准没有对这种情况指定固定的结果。与 I nt el 兼容的微处理器指定位模式[ 10 …00 ] ( 字长为 w 时的 T M i n ..,,) 为整数不确定 ( in t eg e r in d ef init e ) 值。一个从浮点数到整数的转换 , 如果不能为该浮点数找到一个合理的整数近似值,就会产生这样一个值。因此,表达式\n(int) +lel O 会得到- 21 4 83 6 48 , 即从一个正值变成了一个负值。\nfm Ariane 5— 浮点溢出的高昂代价\n将大的 浮点数转换成整数是 一种常见的程序错误 来源。1 996 年 6 月 4 日 , A r ia ne 5\n,火箭初 次航 行 , 一 个错误 便 产 生 了 灾难 性 的后 果。发 射后 仅 仅 37 秒钟 , 火 箭 偏 离 了 它的飞行 路 径 , 解 体 并 且 爆 炸 。 火箭上 栽有价值 5 亿 美元的 通信 卫 星。\n后 来的调 查[ 73 , 33] 显 示 , 控 制 惯性 导航 系统 的 计 算 机 向 控 制 引 擎 喷 嘴 的 计 算 机 发送了一个无效数据。它没有发送飞行控制信息,而是送出了一个诊断位模式,表明在将 一个 64 位 浮点数 转换成 1 6 位 有 符 号 整数 时 , 产 生 了 溢 出。\n溢 出的 值 测 量的 是 火箭的 水 平速 率 , 这 比 早 先 的 A ria ne 4 火 箭 所 能 达到 的 速 度 高 出\nr 了 5 倍 。 在 设 计 A r ia ne 4 火 箭 软 件 时 , 他 们 小 心 地 分 析 了这 些数 宇值 , 并且 确 定 水 平 速率决 不会 超 出一 个 1 6 位 数 的 表 示 范 围。 不 幸 的 是 , 他 们 在 A riane 5 火箭的 系 统 中 简 单地重用了这一部分,而没有检查它所基于的假设。\n练习题 2. 54 假 定 变 量 x 、 f 和 d 的 类型分别是 i nt 、fl oa t 和 do ub l e。 除 了 f 和 d 都不能 等于十~ 、 一~ 或 者 Na N , 它们 的值是 任意 的。 对于 下 面每 个 C 表 达 式, 证 明 它总是 为真(也就 是 求 值 为 1) \u0026rsquo; 或 者 给 出一个使 表达 式不 为 真 的 值(也就 是 求 值 为 0 ) 。\nx == (int) (double) x\nx == (int) (fl oa 七 ) x\nd == (double) (fl o a 七 )d\nf == (fl o a 七)(double) f\nE. f == - (-f) F.1.0/2==1/2.0 G. d*d \u0026gt;= 0. 0\nH. (f+d)-f ==d\n5. 小结\n计算机将信息编码 为位(比特), 通常组织成字 节序列 。有不同的编码方式用来表示整数、实数和字符串。不同的计算机模 型在编码数字 和多字节数据中的字节顺 序时使用不同的约定。\nC 语言的设计可以包容多种不同 字长和数字编码的实现。64 位字长的机器逐渐普及 ,并 正在取代统治市场长达 30 多年的 32 位机器。由千 64 位机器也可以运行 为 32 位机器编译的 程序, 我们的 重点就放在区分 32 位和 64 位程序, 而不是机器本身。 64 位程序的优势是可以突破 32 位程序具有的 4GB地址限制。\n大多数机器对整数使用补码 编码,而对 浮点数使用 IEEE 标准 754 编码。在位级上理解这些编码, 并且理解算术运算的数学特性,对于想使编写的程序能在全部数值范围上正确运算的程序员来说,是很重 要的。在相同 长度的无符 号和有符号整数之间进行强制类型转换时,大多 数 C 语言实现遵循的 原则是底层\n的位模式不变。在补码 机器上 , 对于一个 w 位的值, 这种行为是由 函数 T 2U 立 和 U 2T w 来描述的。C 语言隐式的强制类型转换会出现许多程序员无法预计的结果,常常导致程序错误。\n由于编码的 长度有限 , 与传统整数和实数运算 相比,计 算机运算具有非常不同的属性。当超出表示范围时 ,有限长度能够引起数值溢出。当浮点数非常接近于 0. o, 从而转换成零 时, 也会下溢。\n和大多数 其他程序语言一样,C 语言实现的有限整数 运算和真实的整数运算相比 , 有一些特殊的属性。例如,由 于溢出,表达式 x *x 能够得出负数。但是,无 符号数和补码的运算都满足整数运算的许多其他属性 , 包括结合律、交换律和分 配律。这就允许编译器做很多的 优化。例 如,用 (x \u0026lt;\u0026lt;3 ) - x 取代表达式 7*x 时,我们 就利用了结合律、交换律和分配律的属性, 还利用了移位 和乘以 2 的幕之间的关 系。\n我们已经 看到了几 种使用位级运算和算术运算组合的聪明 方法。例如,使 用 补码运算, ~x +l 等价千议。另外一个例子,假设我们想要 一个形如[ O, …, O, l, ··· , 1] 的 位模式,由 w —k 个 0 后面紧 跟着 K\n个1 组成。这些位模式有助于掩码运算。这种模式能够通过 C 表达式 (l « k) 一1 生 成 ,利 用的是这样一个属性,即 我们想要的位模式的数值为2• - 1。例如,表 达式 (1« 8 ) - 1 将 产 生 位 模 式 OxFF 。\n浮 点 表 示通过将数字编码为 x X 护 的 形 式 来 近似地表示实数。最常见的浮点表示方式是由 IEEE 标准 754 定义的。它提供了几种不同的精度,最 常 见 的 是 单 精 度 ( 32 位)和双精度 ( 64 位)。IE EE 浮点 也能够表示特 殊值 + = 、—CX) 和 Na N。\n必须非常小心地使用浮点运算,因为浮点运算只有有限的范围和精度,而且并不遵守普遍的算术属 性,比如结合性。\n参考文献说明\n关于 C 语言的参考书[ 45 , 61] 讨论了不同的数据类型和运算的 属性。(这两本书中 ,只 有 Stee le 和Harbison 的书[ 45] 涵盖了 ISO C99 中的新特性。目前还没有看到任何涉及 ISO Cl l 新特性的书籍。)对于精确的字长或者数字编码 C 语言标准没有详细的定义。这些细节是故意省去的,这 样 可以在更 大范围的不同机器上实现 C 语言。已经有几本书[ 59 , 74] 给了 C 语言程序员一些建议,菩 告 他 们关于溢出、隐式强制类型转换到无符号数,以及其他一些已经在这一章中谈及的陷阱。这些书还提供了对变昼命名、编 码风格和代码测试的有益建议。Seacord的书[ 97] 是关 千 C 和 C + + 程 序中的安 全问 题 的 , 本书结合了 C 程序的有关信息,介 绍 了如何 编译 和执 行 程 序,以 及 漏洞是如何造成的。关于 Java 的 书(我们推荐 Java 语言的创 始人 James Gosling 参与编写的一本书[ 5] ) 描述了 Java 支持的数据格式和算术运算。\n关于逻辑设计的书[ 58 , 116] 都 有关 于编码和算术运算的 章节 , 描 述 了 实 现 算 术 电 路 的 不 同 方式。\nOverton 的关千 IEEE 浮点数的书[ 82] , 从 数 字应用程序员的角度,详 细描述了格式和属性。\n家庭作业\n拿 2. 55 在你能够访问的不同机器上,使 用 s how_b yt e s ( 文件 s how- byt e s . c ) 编 译 并 运行示例代码。确定这些机楛使用的字节顺序。\n2. 56 试着用不同的示例值来运行 s ho w_byt e s 的 代 码 。 2 . 57 编 写 程 序 s how_s ho r t 、 s how_l ong 和 s how_do ub l e , 它们分别打印类型为 s hor t 、l ong 和 doub­ l e 的 C 语言对象的字节表示。请试着在几种机器上运行。\n拿 拿 2. 58 编写过程 i s _l i 七七l e _e nd i a n , 当在小端法机器上编译和运行时返回 1, 在大端法机器上编译运行时则返回 0。这个程序应该可以运行在任何机器上,无 论 机 器 的 字 长是多少。\n申 2. 59 编写一个 C 表达式,它 生 成 一 个 字 , 由 x 的 最 低 有 效 字节和 y 中剩下的字节组成。对于运算数 x\n=Ox8 9ABCDEF 和 y=Ox 76543210, 就得到 Ox 765432EF。\n申 拿 2. 60 假设我们将一个 w 位的字中的字节从 0 ( 最低位)到w/ 8 - 1 ( 最高位)编号。写出下面 C 函数的代码,它 会返回一个无符号值,其 中 参 数 x 的 字 节 i 被 替 换 成 字 节 b :\nunsigned replace_byte (unsigned x, inti, unsigned char b);\n以下示例,说明了这个函数该如何工作:\nreplace_byte(Ox12345678, 2, OxAB) \u0026ndash;\u0026gt; Ox12AB5678 replace_byte(Ox12345678, 0, OxAB) \u0026ndash;\u0026gt; Ox123456AB\n位级整数编码规则\n在接下来的作业中, 我们特意限制了你能使用的编程结构,来 帮你更好地理解 C 语言的位级、逻辑和算术运算。在回答这些问题时,你的代码必须遵守以下规则:\n假设 整数用补码形式表示。 有符号数的右移是算术右移。 数据类型 i nt 是 w 位长的。对于某些题目,会 给定 w 的值,但 是在其 他情况下 ,只 要 w 是 8 的整 数倍 ,你的 代 码 就 应该 能 工 作 。你 可以用表达式 s iz e of (i n七)\u0026lt;\u0026lt;3 来计算 w。 禁止使用 条件语句( if 或者?:)、循环、分支语句、函数调用 和宏调用。\n除法、模运算和乘法。\n相对比较运算(\u0026lt;、\u0026gt;、<=和>=)。\n·允许的运算\n所有的位级和逻辑运算。\n左移和右移 , 但是位移量只能 在 0 和,互- 1 之间。 加法和减法。\n相等(==)和不相等(! =)测试。(在有些题目中,也不允许这些运算。)\n整型常数 IN T_MIN 和 IN T_MAX。\n对 i n t 和 un s i g ne d 进行强制类型转换 ,无论是显式的 还是隐 式的。\n即使有这些条件的限制,你仍然可以选择带有描述性的变扯名,并且使用注释来描述你的解决方案 的逻辑,尽量提高代码的 可读性。例如 , 下面这段代码从整数参数 x 中抽取出最高有效 字节:\nI• Get most significant byte from x•I int get_msb(int x) {\n/• Shift by 切 一 8 •I\nint shift_val = (sizeof(int)-1)«3;\nI• Arithmetic shift•/\nint xright = x \u0026gt;\u0026gt; shift_val; I• Zero all but LSB•/ return xright \u0026amp; OxFF;\n•• 2. 61\n** 2. fr2\n** 2. 63\n写一个 C 表达式 , 在下列描述的条件下 产生 1, 而在其他情况下得到 0。假设 x 是 i n t 类型。\nA. X 的任何位都等千 l 。\n8. X 的任何位都等 于 0。\nX 的最低有效字节中的 位都等于 1。\nX 的最高有效字节中的 位都等千 0。\n代码应该遵循位级整数编码规则,另外还有一个限制,你不能使用相等(==)和不相等(! =)\n测试。\n编写一个 函数 i n t _s h i f t s _ar e _ar i t hme t i c (), 在对 i nt 类型的 数使用算术右移的 机器上运行 时这个函数生成 1, 而其他情 况下 生成 0。你的代码应该可以 运行 在任何字长的机器上。在几 种机器上测试你的代码。\n将下面的 C 函数代码补充完整。函数 sr l 用算术右移(由值xs r a 给出)来完成逻辑 右移,后 面的其他操作不 包括右移或者除 法。函数 sr a 用逻辑右移(由值 xsr l 给出)来完成算术右移,后 面的其他操作不包 括右移或者除法。可以 通过计算 8*s i ze o f (i n t ) 来确定 数据类型 i n t 中的位数 w。位移量 k 的取值 范围为 O~ w - 1 。\nunsigned srl(unsigned x, int k) {\nI• Perform shift arithmetically•/ unsigned xsra = (int) x»k;\nintsra(int x, int k) {\n/• Perform shift logically•/ int xsrl = (unsigned) x \u0026gt;\u0026gt; k;\n2. 64 写出代码实现如下函数:\n/• Return 1 when any odd bit of x equals 1; 0 otherwise.\nAssume w=32•/\nint any_odd_one(unsigned x);\n函数应该遵循位级整数编码规则 ,不 过 你 可以假设数据类型 i n t 有 w = 3 2 位。\n:: 2. 6 5 写出代码实现如下函数:\n/• Return 1 when x contains an odd number of 1s; 0 ot her 廿 i s e .\nAssume w=32•/\nint odd_ones(unsigned x);\n函数应该遵循位级整数编码规则 ,不 过 你 可以假设数据类型 i n t 有 w = 3 2 位。你的代码最多只能包含 1 2 个算术运算、位运算和逻辑运算。\n·: 2. 66 写出代码实现如下函数:\n/*\nGenerate mask indicating leftmost 1 in x. Assume w=32.\nFor example, OxFFOO -\u0026gt; Ox8000, and Ox6600 \u0026ndash;\u0026gt; Ox4000.\nIf x = 0, then return 0.\n*/\nint leftmost_one(unsigned x);\n函数应该遵循位级整数编码规则 ,不 过你可以假设数据类型 i n t 有 w = 3 2 位。你的代码最多只能包含 1 5 个算术运算、位运算 和逻辑运算。\n提示:先 将 x 转换成形如[ O…011…l ] 的 位向量。\n•• 2. 67 给你一个任务,编 写 一 个 过程 i n t _ s i ze _ i s _ 3 2 (), 当在一个 i n t 是 32 位的机器上运行时, 该 程序产生 1, 而其他情况则产生 0。不允许使用 s i ze o f 运算符。下面是开始时的尝试:\n/• The following code does not run properly on some machines•/\n2 int bad_i nt _s i z e _i s _32 0 {\n3 /• Set most significant bit (msb) of 32-bit machine•/\n4 int set msb = 1«31;\n5 I• Shift past msb of 32-bit word•/\n6 int beyond_msb = 1«32;\n7\n8 I•set_msb is nonzero when word size \u0026gt;= 32\n9 be yond _ms b is zero when word s i ze \u0026lt;= 32•/\n10 return set_msb \u0026amp;\u0026amp; ! b e yond _ms b ;\n11 }\n当在 SUN SP ARC 这样的 32 位机器上编译并运行时, 这个过程返回的却是 0。下面的编译器信息给了我们一个问题的指示:\nwarning: left shift count \u0026gt;= width of type\n我们的代码在哪个方面没有遵守 C 语言标准?\n修改代码,使 得它在 i n t 至少为 32 位的任何机器上都能正确地运行。\n修改代码,使得 它在 i nt 至少为 1 6 位的任何机楛上都能正确地运行。\n•• 2. 68 写出具有如下原型的函数的代码:\n/*\nMask with l east signficant n bits set to 1\nExampl e s : n = 6 \u0026ndash;\u0026gt; Ox3F, n = 17 \u0026ndash;\u0026gt; Ox1FFFF\nAssume 1 \u0026lt;= n \u0026lt;= w\n•I\nint l o 仅 er _one _mas k ( i nt n);\n函 数 应该 遵 循 位级整数编码规则。要注意 n = w 的情况。\n•: 2. 69 写出具有如下原型的函数的代码:\n/*\nDo rotating left shift. Assume O \u0026lt;= n \u0026lt; w\nExamples when x = Ox12345678 and w = 32:\n* n=4 -\u0026gt; Ox23456781, n=20 -\u0026gt; Ox67812345\n*/\nu.ns 屯 ned rotate_left(u.nsigned x, int n);\n函数应该 遵循位级整数 编码规则 。要注意 n = O 的情况。\n.. 2. 70 写出具有如下原型的函数的代码:\n/*\nReturn 1 when x can be represented as an n-bit, 2\u0026rsquo;s-complement\nnumber; 0 otherwise\nAssume 1 \u0026lt;= n \u0026lt;= w\n*/\nint fits_bits(int x, int n);\n函数应该遵循位级整数编码规则。\n2. 71 你刚刚开始在 一家公司工作, 他们要实现一组过程来操 作一个数据结构 , 要将 4 个有符号字节封装成一个 3 2 位 u n s i g ne d。一个字中的字 节从 0 ( 最低有效字节)编号到3 ( 最高有效 字节)。分配给你的任务是:为一个使用补码运算和算术右移的机器编写一个具有如下原型的函数:\nI* Declaration of data t ype 口 here 4 bytes are packed into an unsigned *I\ntypedef unsigned packed_t;\nI* Extract byte from word. Return as signed integer *I int xbyte(packed_t word, int bytenum);\n也就是说, 函数会抽取 出指定的字节, 再把它符号扩展为一个 3 2 位 i n t 。你的前任(因为水平不够高而被解雇了)编写了下面的代码 :\nI* Failed attempt at xbyte *I\nint xbyte(packed_t \u0026ldquo;Word, int bytenum)\n{\nreturn (\u0026lsquo;Word»(bytenum \u0026lt;\u0026lt; 3)) \u0026amp; OxFF;\n}\n这段代码错在哪里?\n给出函数的正确实现,只能使用左右移位和一个减法。\n•• 2. 72 给你一个任务 , 写一个函数, 将整数 v a l 复制到缓冲区 b u f 中, 但是只有当缓冲区中 有足够可用的空间时,才执行 复制。\n你写的代码如下:\nI* Copy integer into buffer if space is available *I I* WARNING: The following code is buggy *I\nvoid copy_int(int val, void *buf, int maxbytes) { if (maxbytes-sizeof(val) \u0026gt;= 0)\nmemcpy(buf, (void*) \u0026amp;val, sizeof(val));\n这段代码使用了库函数 me mc p y 。虽 然在这里用这个函数有点 刻意,因 为我们只 是想复制一个江比, 但是它说明了一种复制较大数 据结构的常见方法。\n你仔细地测试了这段代码后发现 , 哪怕 ma x b y t es 很小的时候, 它也能把值复制到缓 冲区中 。\n解释为什么代码中的条件测试总是成功。提示: s i ze o f 运算符返回 类型为 s i z e _ t 的值 。\n你该如何重写这个 条件测试 ,使 之工作正确。\n.. 2. 73 写出具有如下原型的函数的 代码:\nI* Addition that saturates to TMin or TMax *I int saturating_add(int x, int y);\n同正常的补码加法溢出的方式不同, 当正溢出时, 饱和加法返回 TMax , 负溢出时,返回\nTMin。饱 和运算常常用在执行 数字信号 处理的程序中。你的函数应该遵循位级整数编码规则。\n•• 2. 74 写出具有如下原型的函数的代码:\n/• Determine whether arguments can be subtracted without overf l 叩 */\nint tsub_ok(int x, int y);\n如果计算 x- y 不溢出, 这个函数就返回 1。\n·: 2. 75 假设我们想要计算 x • y 的完整的 2亿,位表示, 其中, x 和 y 都是无符号数,并 且运行在数据类型u n s i g ne d 是 w 位的机器上。乘 积的低 w 位能够用表达式 x * y 计算,所以 ,我 们只需 要一个具有下列原型的函数:\nunsigned unsigned_high_prod(unsigned x, unsigned y);\n这个函数计算 无符号变量 x • y 的高 w 位。我们使用一个具有下面原型的库函数:\nint s i gn ed 上 i gh_pr od ( i nt x, int y);\n它计算在 x 和y 采用补码 形式的情 况下, x • y 的 高 w 位。编写代码 调用这个过程, 以实现用无符号数为参数的函数。验证你的解答的正确性。\n提示: 看看等式 ( 2. 18 ) 的推导中 ,有符 号乘积 x • y 和无符号乘积 x\u0026rsquo; • y\u0026rsquo; 之间的关系。\n2. 76 库函数 c a l l o c 有如下声明 :\nvoid•calloc(size_t nmemb, size_t size);\n根据库文档:" 函数 c a l l o c 为一个数组分配内存, 该数组有 nme mb 个元素, 每个元素为 s i ze 字节。内存设置为 0 。如果 nmemb 或 s i ze 为 o, 则 c a l l o c 返回 NULL。”\n编写 c a l l o c 的实现, 通过调用 ma l l oc 执行分配 , 调用 me ms e t 将内存设 置为 0。你的代码应该没有任何由算 术溢出引起的漏洞, 且无论数据类型 s i ze _ t 用多少位表示, 代码都应该正常工作。\n作为参考 , 函数 ma l l o c 和 me ms e t 声明 如下 :\nvoid•malloc(size_t size); void•memset(void•s, int c, size_t n);\n•• 2. 77 假设我们有一 个任务: 生成一段代码 , 将整数 变量 x 乘以不同的常数因子 K 。为了 提高效率,我们想只使用十、一和<< 运算。对于下 列 K 的值,写 出执行乘法运算 的 C 表达式 ,每个 表达式中最多使用 3 个运算。\nK=l7 K=-7\nK=60\nD. K= -112\n•• 2. 78 写出具有如下原型的函数的代码:\n/• Divide by power of 2. Assume O \u0026lt;= k \u0026lt; w 一 1 • I int divide_power2(int x, int k);\n该函数要用正确的舍入方式计算 x / 2\u0026rsquo; , 并且应该遵循位级整数编码规则。\n•• 2. 79 写出函数 mu l3 d i v 4 的代码,对于整数参 数 x , 计算 3*x / 4 , 但是要遵循位级整数编码规则。你的代码计算 3*x 也会产生溢出。\n•: 2. 80 写出函数 t hr e e f o ur t h s 的代码, 对于整数参数 x , 计算 3 / 4x 的值,向 零舍人。它 不会溢出。函\n数应该遵循位级整数编码规则。\n•• 2. 81 编写 C 表达式产生如下位模式,其中 a• 表示符号a 重复 K 次。假设一个 w 位的数 据类型。代码可以包含对参数 J 和 K 的引用,它 们分别表示 )和K 的 值, 但是不能使用表示 w 的参数。\n1w- k ok\now- k- j1ko1\n, 2. 82 我们在一 个 i n t 类型值为 32 位的机器上运行程序。这些 值以补码形式表示, 而且它们都是算术右移的。u ns i g ne d 类型的值也是 32 位的。\n我们产生随机数 x 和 y, 并且把它们转换成无符号数,显示如下:\n/• Create some arbitrary values•/ int x \u0026ldquo;\u0026lsquo;random() ;\nint y random() ;\nI• Convert to unsigned•/ unsigned ux = (unsigned) x; unsigned uy = (unsigned) y;\n对千下列每个 C 表达式, 你要指出表达式是否 总是为 1。如果它 总是为 1, 那么请描述其中的数学原理 。否则,列 举出一个使它为 0 的参数示例。\nA. (x \u0026lt;y ) = = (- x\u0026gt;- y )\nB. ((x+y)«4) +y-x==l 7*y+l5*x\nC. · x +· y+l = =\u0026rsquo; (x+y )\nD. (ux- u y ) = = - (unsigned) (y-x)\nE. ( (x » 2 ) « 2 ) \u0026lt;=x\n.. 2. 83 一些数字的二进制表示是由形如 o. y y y y y y …的无穷串组成的 , 其中 y 是一个 K 位的序列。例如,— 的二 进制 表示是 0. 01010101 … ( y = O l ) , 而— 的二进 制表示是 o. 00 11 00 11 0011 … ( y =\n0011 ) 。\n设 Y= B2队 ( y ) \u0026rsquo; 也就是说,这个数具 有二进制表示 y 。给出 一个由 Y 和 K 组成的公式表示这个无穷串的值。\n提示: 请考虑将二进制小数点右移 K 位的结果。\n对于下列的 y 值, 串的数值是多少?\n( a) l Ol\n( b ) Oll O ( c) Ol OOll\n, 2. 84 填写下列程序的返 回值, 这个程 序测试它的 第一个参数是否小千或者等千第二个参数。假定函数f 2u 返回一个 无符号 32 位数字,其 位表示与它的浮点 参数相同。你可以假设两个参数都不是Na N。两种 o, + o 和一0 被认为是相等的。\nint float_le(float x, floaty) { unsigned ux = f2u(x); unsigned uy = f2u(y);\nI* Get the sign bits *I unsigned sx = ux»31; unsigned sy = uy»31;\n/• Give an expression using only we, uy, sx, and sy•I return\n}\n拿 2. 85 给定一个浮点格式 , 有 K 位指数和 n 位小数, 对千下 列数, 写出阶码 E 、尾数 M 、小数 f 和值 V\n的公式。另外,请 描述其位表示。\n数 7. 0。\n能够被准确描述的最 大奇整数。\n最小的规格化数的倒数。\n拿 2. 86 与 Intel 兼容的处理器也支待 "扩展精度“ 浮点形式, 这种格式具 有 80 位字长, 被分成 1 个符号\n位、k = l 5 个阶码位、1 个单独的整数位 和 n = 63 个小数位。整数位是 IEEE 浮点表示中隐 含位的显式副本。也就是说 ,对 千规格化的值 它等于 1, 对于非规格 化的值它等于 0。填写下表,给 出用这种格式表示的一些“有趣的“数字的近似值。\n最大的规格化数\n将数据类型声明为 l o ng double, 就可以 把这种格式用于为与 Intel 兼容的机器编译 C 程序。但是, 它会强制编译器以传 统的 8087 浮点指令为基础生成代码 。由此产生的程序很可能会比数据类型为 fl oa t 或 d oub l e 的 情况慢上许多。\n2. 87 2008 版 IEE E 浮点标准, 即 IE EE 754-2008, 包含了一种 16 位的“半精度“ 浮点格式。它最初是由计算机图形公 司设计的 , 其存储的数据所需的动态范围要高 于 16 位整数可获得的 范围。这种格式具 有 1 个符号位、5 个阶码位 Ck = 5 ) 和 10 个小数位( n = 10 ) 。阶码偏置量是 sz- I - 1 = 15 。\n对于每个给定的数,填写下表,其中,每一列具有如下指示说明:\nHex: 描述编码形式的 4 个十六进制数字。\nM: 尾数的值 。这应该是一个形如 x 或王 的数, 其中 x 是一个整数, 而 y 是 2 的整数幕。例\n如: 0 、67 和\n64 256°\nE, 阶码的整数值。\nv, 所表示的数字值。使用 x 或者 x X 2\u0026rdquo; 表示,其 中 x 和 z 都是整数。\nD: ( 可能近似的)数值,用 pr i n t f 的格式 规范%f 打印。\n举一个例子, 为了 表示数— , 我们有 s = O, M = — 和 E = - 1。因此这个数的阶码字段为\n8\n011102( 十进制值 15—1 = 1 4) , 尾数字段为 11000000002, 得到一个十六进制的表示 3B00。其数值\n为 0. 875 。\n标记为 \u0026ldquo;— \u0026quot; 的条目不用填写。\n描述 -o 最小的\u0026gt; 2 的值 Hex M E V -o D - o. o 512 512 512. 0 最大的非规格化数 -oo -co - ex, 十六进制表示为 3BBO的数 3B80 •• 2 . 88 考虑下 面两个 基于 IEEE 浮点格式的 9 位浮点表示。\n格式 A 有一个符号位。 有 k= 5 个阶码位 。阶码偏置量是 15 。 有 n = 3 个小数位 。 格式 B 有一个符号位。 有 k = 4 个阶码位。阶码偏置量是 7。 有 n = 4 个小数位。\n下面给出了一些 格式 A 表示的位模式,你 的任务是把它们转换成最接近的 格式 B 表示的值。如果需要舍入,你要 向+ oo舍入。另外 ,给出用 格式 A 和格式 B 表示的 位模式 对应的值。要么是整数(例如1 7) , 要么是小数(例如 17/ 64 或 17 / 26 ) 。\n格式A 格式B 位 值 位 值 1 01 11 0 001 一9 16 1 Oll O 0010 -9 百 0 1 011 0 1 01 1 00111 110 0 00000 101 1 11011 000 0 11000 100 2. 89 我们在一 个 i n t 类型为 32 位补码表示的 机器上运行 程序。f l oa t 类型的值使用 32 位 IEEE 格式,\n而 doub l e 类型的值使用 64 位 IEEE 格式。\n我们产 生随机整数 x、y 和 z , 并且把它们转换成 doub l e 类型的 值:\n/• Create some arbitrary values•/ int x = random();\nint y = random() ;\nint z * random O ;\n/• Convert to double•/ double dx• (double) x; double dy = (double) y; double dz\u0026rdquo;\u0026rsquo;(double) z;\n对于下列的每个 C 表达式, 你要指出表达式是否总是为 1。如果它总是为 1, 描述其中的数学原理。否则, 列举出使它为 0 的参数的 例子。请注意 , 不能使用 IA32 机器运行 GCC 来测试你的答案 ,因为对 千 f l oa t 和 do ub l e , 它使用的都是 80 位的扩展精 度表示。\n(float)x==(float)dx dx-dy== (double) (x-y) (dx+dy) +dz==dx+ (dy+dz) (dx*dy) *dz==dx* (dy*dz) dx/dx==dz/dz 2. 90 分配给你一个任务 , 编写一个 C 函数来计算 护的浮点表示。你意 识到完成 这个任务的最好方法是直接创建结果的 IEEE单精度表示。当 x 太小 时,你的 程序将 返回 o. o。 当 x 太大时, 它会返回\n+ = 。填写下列代码的空白部分,以 计 算出正确的结果。假设函数 u2f 返回的浮点值与它的无符号参数有相同的位表示。\nfloat fp 江 2(int x)\n/• Result exponent and fraction•/ unsigned exp, frac;\nunsigned u;\nif (x \u0026lt;) {\nI• Too small. Return 0. 0 • I exp= ,\nfrac = ,\n} else if (x \u0026lt;) {\n/• Denormalized result•I exp= ,\nfrac = ,\n} else if (x \u0026lt;) { I* Normalized result. *I exp= ,\nfrac = ,\n} else {\nI* Too big. Return +oo *I exp= ,\nfrac = ,\n}\n/• Pack exp and frac into 32 bits•/ u =exp«23 I frac;\n/• Return as float•/ return u2f(u);\n223 22\n2. 91 大约公元前 250 年, 希腊数学家阿 基米德 证明了— - \u0026lt; re\u0026lt; — 。 如果当时有一台计算机和标 准库\n71\n\u0026lt; math.h\u0026gt;, 他就能够确定 T( 的单精度浮点近似值的十六进制表示为 Ox 40 490 FDB。当然 , 所有的这些都只是近似值,因为穴不是有理数。\n这个浮点值表示的二进制小数是多少?\n22\n一的二进制小数表示是什么? 提示: 参见家庭作业 2. 83。\n这两个 T 的近似值从 哪一位(相对于二进制小数点)开始不同 的? 位级浮点编码规则\n在接下来的题目中,你所写的代码要实现浮点函数在浮点数的位级表示上直接运算。你的代码应该 完全遵循 IEEE 浮点运算的规则 , 包括当需 要舍入时 , 要使用向偶数舍人的方式。\n为此 ,我们 把数据类型 fl o a t - b i t s 等价于 un s i g ne d :\nI• Access bit-level representation floating-point number•I typedef unsigned float_bits;\n你的代码中不使用数 据类型 f l o a t , 而要使用 fl o a t _b it s 。你可以使用数据类型 i n t 和 u n s i g ne d , 包括无符号和整数常数 和运算。你不可以使用任何 联合、结构和数组。更 重要的 是, 你不能使用任何 浮点数据类型、运算或者常数。取而代之 , 你的代码应该执行实 现这些指定的浮点运算的位操作 。\n下面的函数说明了对这些规则的使用。对千参数 f , 如果 J 是非规格化的, 该函数返回士 0 ( 保持 f\n的符号),否 则, 返回 f 。\n/• If f is denorm, return 0. Otherwise, return f•/ float_bits float_denorm_zero(float_bits f) {\nI• Decompose bit representation into parts•/ unsigned sign= f»31;\nunsigned exp= f»23 \u0026amp; OxFF ; unsigned frac = f \u0026amp; Ox7FFFFF; if (exp== 0) {\n/• Denormalized. Set fraction to O•/ frac = O;\n}\nI• Reassemble bits•/\nreturn (sign«31) I (exp«23) I frac;\n}\n•• 2. 92 遵循位级浮点编码规则,实现具有如下原型的函数:\n/• Compute -f. If f is NaN, then return f. •/ float_bits float_negate(float_bits f);\n对于浮点数 J, 这个函数计箕 - J。如果 J 是 Na N , 你的函数应该简单地返回 J。\n测试你的函数, 对参数 f 可以取的所有 232 个值求值 , 将结果与你使 用机器的浮点运算得到的结果\n相比较。\n•• 2. 93 遵循位级 浮点编码规则 ,实 现具有如下原型的函数:\nI• Compute lfl. If f is NaN, then return f. •/ float_bits float_absval(float_bits f);\n对于浮点数 f , 这个函数计算 Ii i 。如果 J 是 N a N , 你的函数应该简单地返回 f 。\n测试你的 函数, 对参数 f 可以取的所有 23 2 个值求值 ,将结果 与你使用机器的浮点运算得到的结果相比较。\n·: 2. 94 遵循位级浮点 编码规则 , 实现具有如下原 型的函数:\nI• Compute 2•f. If f is NaN, then return f. •/ float_bits float_twice(float_bits f);\n对于浮点 数 f , 这个函数计算 2. 0• f 。如果 J 是 N a N , 你的函数应该 简单地返回 f 。\n测试你的函数, 对参数 f 可以取的所有 23 2 个值求值, 将结果与你使用机器的 浮点运算得到的结果相比较。\n,:2. 95 遵循位级浮点编码规则,实现具有如下原型的函数:\nI• Compute 0.5•f. If f is NaN, then return f. •/ float_bits float_half(float_bits f);\n对于浮点数 f , 这个函数计算 0. 5• f 。如果 J 是 N a N , 你的函数应该简单地返回 f 。\n测试你的 函数, 对参数 f 可以取的所有 22\u0026rsquo; 个值求值 ,将结果 与你使用 机器的 浮点运算得到的结果相比较。\n::2. 96 遵循位级浮点编码规则,实 现具有如下原 型的函数:\n/*\n* Compute (int) f.\n* If conversion causes overflow or f is NaN, return Ox80000000\n*/\nint float_f2i(float_bits f);\n对于浮点数 f , 这个函数计算 ( i n t ) / 。如果 f 是 N a N , 你的函数应该向零舍入。如果 f 不能用整数表示(例如, 超出表示 范围,或者 它是一个 N a N ) , 那么函数应该返回 Ox 8 00 00000 。\n测试你的函数, 对参数 f 可以取的所有 232个值求值,将结果 与你使用机 器的浮点运算得到的\n结果相比较。\n::2. 97 遵循位级浮点编码规则,实现具有如下原型的函数,\nI• Compute (float) i•/ float_bits float_i2f(int i);\n对于函数 i , 这个函数计算 (fl o a t ) i 的位级表示。\n测试你的函数, 对参数 f 可以取的所有 223 个值求值 , 将结果与你使用机器的 浮点运算得到的结果相比较。\n练习题答案 # 2. 1 在我们开始查看机器级程序的时候,理解十六进制和二进制格式之间的关系将是很重要的。虽然本书中介绍了完成这些转换的方法,但是做点练习能够让你更加熟练。\nA.\nB.\nC. 将 Ox DSE4 C 转换成二进制:\nD.\n2. 2 这个问题给你一个机会思考 2 的幕和它们的十六进制表示。\nn \u0026lsquo;X\u0026rsquo; ( 十进制) 2\u0026rdquo; (十六进制) 9 512 Ox200 19 524 288 Ox 8 00 0 0 14 16 384 Ox4000 16 65 536 OxlOOOO 17 131 072 Ox 2 0000 5 32 Ox20 7 128 Ox80 2. 3 这个问题给你一个机会试着对一些小 的数在十六 进制和十进制 表示 之间进行转换。对于较大的数, 使用计算器或者转换程序会更加方便和可靠。\n二进制 十六进制 0000 0000 OxOO 167 = 10 · 16 + 7 1010 0111 OxA7 62 = 3 · 16 + 14 OOll 1 11 0 Ox3E 188 = 11 · 16 + 12 1011 1100 OxBC 3·16 +7= 55 0011 0111 Ox37 8 · 16 + 8 = 136 1000 1000 Ox88 15 · 16 + 3 = 243 1111 0011 OxF3 5 · 16 + 2 = 82 0101 0010 Ox52 10 · 16 + 12 = 172 1010 llOO OxAC 14 · 16 + 7 = 23 1 1110 0111 OxE7 4 当开始调试机器级程序时 , 你将发现在许多情况中 , 一些简单的十六进 制运算是很有用的 。可以总是把数转换 成十进制 ,完成运算 ,再把它 们转换 回来,但是能 够直接用十六进制工作 更加有效 , 而且能够提供更多的信息。 Ox503c +Ox 8 =0x50 44 。8 加上十六进 制 c 得到 4 并且进位 1。\nOx 503c - Ox 40 =0x 4ff c 。在第二个数位, 3 减去 4 要从第 三位借 l 。因为第 三位是 o, 所以我们必须从第四位借位。\nOx503c +64=0x507c 。十进制 64( 26 ) 等于十六 进制 Ox 40 。\nOx50 e a - Ox 503c =Oxa e 。 十六进制数 a ( 十进制 10 ) 减去十六 进制数 c ( 十进制 1 2 ) , 我们从第二位借 16 , 得到十六进制数 e ( 十进制数 14 ) 。在第二个数位,我们 现在用 十六进制 d ( 十进制 13 ) 减去 3 , 得到十六进 制 a ( 十进制 10 ) 。\n2. 5 这个练习测试你对数据的字节表示 和两种不同 字节顺序的理 解。\n小端法: 2 1 大端法: 87 小端法: 21 43 大端法: 8 7 65 小端法: 21 43 65 大端法: 87 65 43 回想一下, show_b y t e s 列举了一系列字节,从低位 地址的 字节 开始,然 后逐一列出高位地址的 字\n节。在小端法机器上,它将按照从最低有效字节到最高有效字节的顺序列出字节。在大端法机器 上,它将按照从最高有效字节到最低有效字节的顺序列出字节。\n6 这又是一个练习从十六进制到二进制转换的机会。同时也让你思考整数和浮点表示。我们将在本章后面更加详细地研究这些表示。 利用书中示例的符号,我们将两个串写成:\n00359141\n00000000001101011001000101000001\n********************* 4A564504\n01001010010101100100010100000100\n将第二个字相对于第一 个字向右移 动 2 位, 我们发 现一个有 21 个匹配位的序列。\n我们发现除 了最高有效 位 1. 整数的所有位都嵌在浮点数中。这正好也是书中示例的情况。另外,浮点数有一些非零的高位不与整数中的高位相匹配。\n2. 7 它打印 61 62 63 64 65 66。回想一下, 库函数 s tr l e n 不计算终止的 空字符, 所以 s ho w byt e s 只 打印到字符 \u0026rsquo; f \u0026rsquo; 。\n2. 8 这是一个帮助你更加熟悉布尔运算的练习。\n运算 结果 运算 结果 a [01101001] a\u0026amp;b [01000001] b [01010101] alb (01111101] -a [10010110] a \u0026ldquo;b (OOllllOO] -b [10101010] 9 这个问题说明了怎样用布尔代数来描述和解释现实世界的系统。我们能够看到这个颜色代数和长度为 3 的位向 量上的 布尔代数是一 样的。 颜色的取补是通过对 R、G 和 B 的值取 补得到的 。由此,我们可以看出,白 色是黑色的 补, 黄色是蓝色的补,红紫色是绿色的补,蓝绿色是红色的补。 我们基于颜色的位向量表示来进行布尔运算。据此,我们得到以下结果: 蓝色 \u0026lt;ooD I 绿色( 010 ) = 蓝绿色( 011 ) 黄色(1 10 ) \u0026amp;. 蓝绿色( 011 ) = 绿色( 010 ) 红色(1 00 ) A 紫红色(1 01) = 蓝色( 001 ) 2. 10 这个程序依赖于两个 事实, EXCLUSVI E-OR是可交换的和可结合,以及对于任意的 a , 有 a Aa = O。\n步骤 •x *y 初始 a b 步骤l a a\u0026quot;b 步骤2 a\u0026rdquo; (a\u0026quot; b) =(a\u0026quot;a)\u0026quot; b =b a\u0026quot;b 步骤3 b b\u0026quot; (a \u0026ldquo;b) = (b \u0026ldquo;b) \u0026quot; a = a 某种情况下 这个函数会 失败, 参见练习题 2. 11 。\n11 这个题目说明了我们的原地交换例程微妙而有趣的特性。 fir s t 和 l a s t 的值都为 k , 所以我们试图交换正中间的元素和它自己。 在这种情况 中, i np l a c e _s wa p 的 参数 x 和 y 都指向同一个位置。当 计算* x \u0026quot; * y 的时候,我 们得到 0 。然后将 0 作为数组正中 间的元素, 而后面的 步骤一直都把这个元素设 置为 0 。我们可以看到,练习题 2. 10 的推理隐 含地假设 x 和 y 代表不同的位置。 将r e ver s e _arr a y 的第 4 行的测试简单地替换成 f ir s t \u0026lt;l a s t , 因为没有必要交换正中间的元素和它自己。 12 这些表达式如下: x \u0026amp; OxFF xA~OxFF\nx I OxFF\n这些表达式是 在执行低级 位运算中经常发现的典型类型。表达式 ~ Ox F F 创建一个掩码, 该掩码 8\n个最低位等于 o, 而其余的 位为 1。可以观察到,这些 掩码的产生和字长无关 。而相比之下, 表达式 Ox FFFFFFOO 只 能工作在 32 位的机 器上。\n2. 13 这个问题帮助你思考布尔运算和程序员应用掩码运算的典型方式之间的关系。代码如下:\n/• Decla 工 ·at i ons of functions implementing operations bis and bic•/ int bis(int x, int m);\nint bic(int x, int m);\n/• Compute xly using only calls to functions bis and bic•/ int bool_or(int x, int y) {\nint result= bis(x,y); return result;\n}\n/• Compute x~y using only calls to functions bis and bic•/ int bool_xor(int x, int y) {\nint result= bis(bic(x,y), bic(y,x)); return result;\nbi s 运算等价于 布尔 OR一 如果 x 中或者 m 中 的 这一位置位了, 那么 z 中的这一位就 置位。另一方面, b i c (x, m) 等价于 x \u0026amp;~m; 我们想实现只有 当 x 对应的位为 1 且 m 对应 的位为 0 时, 该位等于 1。\n由此,可以通过对 b i s 的一次调用来实现 1 。 为了实 现^, 我们利用以 下属性\n工 Ay = ( x \u0026amp;.~ y ) I (~x\u0026amp;.y)\n2. 14 这个问 题突出了位级 布尔运算和 C 语言中的逻辑运算之间的关系。常见的 编程错误是在想用 逻辑运算的时候用了位级运算,或者反过来。\n表达式 值 表达式 值 X \u0026amp; y Ox20 X \u0026amp;\u0026amp; y OxOl X I y Ox7F X 11 y OxO l ~x I ~y OxDF !x 11 ! y OxOO X \u0026amp; !y OxOO X \u0026amp;\u0026amp; ~y OxO l 2. 15 这个表达式是 ! ( x A y ) 。\n也就是,当且仅当 x 的每一位和 y 相应的每一位匹配时 , X A y 等于零。然后,我们 利用! 来判定一个字是否包含任何非零位。\n没有任何实际的理由 要去使用这个 表达式 ,因 为可以简单 地写成 x= =y , 但是它说明了位级运算和逻辑运算之间的一些细微差别。\n2. 16 这个练习可以帮助你理解各种移位运算 。\nX X\u0026lt;\u0026lt;3 (逻辑) ( 算术) X\u0026gt;\u0026gt;2 X\u0026gt; \u0026gt;2 十六进制 二进制 二进制 十六进制 二进制 十六进制 二进制 十六进制 OxC3 [11000011) [00011000] OxlB [00110000] Ox30 [l 1110000] OxFO Ox75 (01110101) [10101000] OxAB (00011101] Ox l D [00011101] OxlD Ox87 [10000111] (00111000] Ox38 [00100001) Ox21 [11100001] OxEl Ox66 [01100110] (00110000] Ox30 [00011001) Oxl9 [00011001] Ox19 第 2 章 信息的表示和处理 101\n2. 17 一般而言,研究字长非常小的例子是理解计算机运算的非常好的方法。\n无符号值对应于图 2- 2 中的值。对于补码 值, 十六进制数字 0 ~ 7 的最高有效位为 o , 得到非负值,然 而十六 进制数字 8 ~ F 的最高有效 位为 1 , 得到一个为负的 值。\n2 18 对于 32 位的机器,由 8 个十六进制数 字组 成的, 且开始的那个数字在 8 ~ f 之间的任何值,都 是一个负数。数 字以串 f 开头是很普遍的 事情 , 因为负数的 起始位全为 1 。不过, 你必须看仔细了。例如, 数 Ox80 48337 仅仅有 7 个数字。把起始位 填入 0\u0026rsquo; 从而得到 Ox080 48337 , 这是一个正数。\n4004d0: 48 81 ec eO 02 00 00 sub $0x2e0,%rsp A . 736 4004d7: 48 8b 44 24 a8 mov -Ox58(%rsp),%rax B. -BB 4004dc: 48 03 47 28 add Ox28(%rdi),%rax C. 40 4004e0: 48 89 44 24 dO mov %rax,-Ox30(%rsp) D. -48 4004e5: 48 8b 44 24 78 mov Ox78(%rsp),%rax E. 120 4004ea: 48 89 87 88 00 00 00 mov 如r ax , Ox88 ( r 讼 di ) F. 136 4004f1: 48 8b 84 24 f8 01 00 mov Ox1f8 (%rsp) , %rax G . 504 4004f8: 00 4004f9: 48 03 44 24 08 add Ox8(%rsp),%rax 4004fe: 48 89 84 24 co 00 00 mov %rax,Oxc0(%rsp) H 192 400505: 00 400506: 48 Bb 44 d4 bB mov -Ox48 (%rsp, %rdx, 8), %rax I . -72 2. 19 从数学的 视角来 看,函 数 T2 U 和 U2 T 是非常奇特的 。理解它们的行为非常重 要。\n我们根据补码的 值解答这个间 题, 重新排列练习题 2. 17 的 解答中的行 , 然后列出无符号值作为函数应用的结果。我们展示十六进制值,以使这个过程更加具体。\n2. 20 这个练习题测试你对等式 ( 2. 5 )的理解。\n对千前 4 个条目,x 的值是负的,并 且 T 2队 ( x ) = x + 2\u0026rsquo; 。对于剩下的两个条目, x 的 值是非负的, 并且 T2亿 ( x ) = x 。\n2. 21 这个问题加强你对补码和无符号表示之间关 系的理解,以 及对 C 语言升级规则( pro mo tion ru le ) 的影响的理解。回 想一下 , T M in ,2是一 2 147 483 648 , 并且将它强制类型转换为无符号数后,变成了 2 147 483 648。另外,如果 有任何 一个运算数是无符号的,那 么在比较之前, 另一个运算数会被强制类型转换为无符号数。\n2. 22 这个练习很具 体地说明了符号扩展如何保 持一个补码表示的数值 。\nA. [ 1011 ] :\nB. [ 11 011 ] :\n一 沪 + 21 + 2 0\n—2 4 + 2 3 + 2 1 + 20 =\n- 8 + 2 + 1\n- 16 + 8 + 2 + 1\nc. [ 111 011 ] : 一沪 + 24+ 23 + 2\u0026rsquo; + 2° = - 32 + 16 + 8 + 2 + 1 = - 5\n2 . 23 这些函数 的表达式是 常见的程序 ”习惯用语“, 可以从多个位字段打包成的一个字中提取 值。它们利用不同移位运算的零填充和符 号扩展属性。请注意强制类 型转换和移位运算的顺序。在 f un l 中,移位是 在无符号 wo r d 上进行的,因 此是逻辑移位。在 f un 2 中, 移位是在把 wo r d 强制类 型转换为 i nt 之后进行的 ,因此 是算术移位。\nA.\nB. 函数 f unl 从参数的低 8 位中提取一 个值, 得到范围 0 ~ 255 的一个整数。函数 f un2 也从这个参数的 低 8 位中提取一个值,但是 它还要执行符 号扩展。结果将是介于—128 ~ 127 的一个数。\n2. 24 对于无符号数来说,截断的 影响是相当直观的, 但是对于补码数却不是。这个练习让你使用非常小的字长来研究 它的属性。\n原始数 。 十六进制 截断后的数 。 原始数 。 无符号 截 断后的数 。 补码 原始数 截断后的数 。 。 2 2 2 2 2 2 9 1 9 I - 7 1 B 3 11 3 - 5 3 F 7 15 7 - 1 -1 正如等式( 2. 9 ) 所描述的 , 这种截断无 符号数值的结果就 是发现它们模 8 的余数。截断有符号数的结果要更复杂一些。根据等式 ( 2. 1 0) , 我们首先计算这个参数模 8 后的余数。对千参数 0 ~ 7 , 将得出值 0 ~ 7 , 对于参数 一8 ~ - 1 也是一样。然后我们 对这些余数应用函数 UZT3 , 得出两个O~\n3 和- 4 ~ 1 序列的反复。\n2. 25 设计这个问题是要说明从有符号数到无符号数的隐式强制类型转换很容易引起错误。将参数l e ng t h 作为一个无符号数来传递看上去是件相当自然的 事情,因 为没有人会想 到使用一个长度为负数的值。停止条件 i \u0026lt; =l e ng t h- 1 看上去也很自然。但是把这两点组合 到一起 , 将产生意想不到的结果!\n因 为参数 l e ngt h 是无符号的,计 算 0 - 1 将使用无符号 运算 ,这 等价 于模 数加法。结果得到\nUM釭 0 比较同 样使用无符号 数比较, 而因为任何 数都是小于或者等千 UMa x 的, 所以这个比较总是为真! 因此,代码将试图 访问数组 a 的非法元素。\n有两种方法可以改正这段代码,其 一是将 l e ng吐 声明为 i n t 类型, 其二是将 f o r 循环的测试条件改为 江l e ng t h。\n2 26 这个例子说明了无符号运算的一个 细微的 特性, 同 时 也是我们 执行无符号运算时不会意识 到的属性。这会导致一些非 常棘手的错误。\n在什么情况下,这个函数会产生不正确的结果? 当 s 比 t 短的时候,该 函数会不正确地返回1。\n解释为什么会出现这样不 正确的结果 。由于 s tr l e n 被定义为产生一个无符号的结果, 差和比较都采用无符号运算来计算 。当 s 比 t 短的时候, s tr l e n (s ) - s 七rl e n (t ) 的差 会为负 , 但是变成了一个很大的无符号数,且大 千 0 。\n说明如何修改这段代码好让它能可靠地工作。将测试语句改成:\nreturn strlen(s) \u0026gt; strlen(t);\n2. 27 这个函数是对确定无符号加法是否溢出的规则的直接实现。\nI* Determine whether arguments can be added without overflow *I int uadd_ok(unsigned x, unsigned y) {\nunsigned sum= x+y; return sum\u0026gt;= x;\n2. 28 本题是对算术模 16 的简单示范。最容易的解决方法 是将十六 进制模式 转换成它的无符号十进 制值。对 于非零的 x 值,我们必 须有(一4x ) + x = l 6。然后,我 们就可以 将取补后的值转换回十六进制。\n2. 29 本题的目的是确保你理解了补码加法 。\nX y x+y x+ y 情况 -12 -15 -27 5 I [10100] [10001] [100101] [00101] -8 -8 -16 -16 2 (11000] [11000] [110000] [10000] -9 8 -1 -1 2 [10111] [01000] [111111J [11111] 2 5 7 7 3 [00010] [00101) [000111] [00111] 12 4 16 -16 4 (01100] [00100] [010000] [10000] 2. 30 这个函数是对确定补码加法是否溢出的规则的直接实现。\n/• Determine whether arguments can be added without overflow•I int tadd_ok(int x, int y) {\nint sum= x+y;\nint neg_over = x \u0026lt; 0 \u0026amp;\u0026amp; y \u0026lt; O \u0026amp;\u0026amp; sum\u0026gt;= O; int pos_over = x \u0026gt;= 0 \u0026amp;\u0026amp; y \u0026gt;= 0 \u0026amp;\u0026amp; sum\u0026lt; O; return !neg_over \u0026amp;\u0026amp; !pos_over;\n}\n2. 31 通过学习 2. 3. 2 节,你的同 事可能已经学 到补码加会形成一个阿贝尔群, 因此表达式 (x +y ) - x 求值得到 Y• 无论加法是否溢出 , 而 (x +y ) - y 总是会求值得到 x。\n2. 32 这个函数会 给出正确的 值,除 了当 y 等于 T M in 时。在这 个情况下 ,我 们有- y 也等于 TM in , 因此函数 t a dd_o k 会认为只要 x 是负数时 , 就会溢出, 而 x 为非负 数时, 不会溢出。实际上, 情况恰恰相反: 当 x 为负数时, t s ub_o k( x , TM in ) 为 1 ; 而当 x 为非负时 , 它为 0。\n这个练习说明 ,在 函数的任何 测试过程中 , T M i n 都应该作为一种测试 情况。\n2. 33 本题使用非常小的字长来帮助你理解补码的非。\n对于 w = 4 , 我们 有 T M in, = - 8 。因此一8 是它自己的加法逆元 , 而其他数值是 通过整数非来取非的。\n十六进制 。 X 十进制 。 一4I 十进制 。 X 十六进制 。 5 5 -5 B 8 -8 -8 8 D -3 3 3 F - I I 1 2. 34\n2. 35\n对于无符号数的非,位的模式是相同的。本题目是确保你理解了补码乘法 。\n模式 X y X · Y 截断了的X ·Y 无符号数 4 (100) 5 [IOI] 20 [010100] 4 [100] 补码 -4 [100] -3 [JOI] 12 [001100] -4 [100] 无符号数 2 [010] 7 [lll] 14 (001110] 6 [110] 补码 2 [010] -1 [Ill] -2 (111110] -2 (110] 无符号数 6 [110] 6 [110] 36 (100100] 4 [100) 补码 -2 [110] -2 [110] 4 [000100) -4 (JOO] 对所有可能 的 x 和 y 测试一遍这个 函数是不现实的。当数据类型 i nt 为 32 位时 , 即使你每秒运行一百亿个测试 , 也需要 58 年才能 测试完所有的组合 。另一方面,把 函数中的 数据类型改成 s hor t或者 c har , 然后再穷尽 测试, 倒是测试代码的一 种可行的方法。\n我们 提出以下论 据, 这是一个更理论的方法 :\n我们知 道 工. y 可以写成一个 2w 位的补码数字。用 u 来表示低 w 位表示的无 符号数 , v 表示高 w 位的补码数字。那么,根据公式 ( 2. 3), 我们可以得到 工 y = v沪 十u。\n我们还知道 u = T 2U,,. ( p ) , 因为它们是从同一个位模式得出来的无符号和补码数字,因此根据等式( 2. 6), 我们有 u= p + p,,,_ , 沪 , 这里 Pw- 1 是 p 的 最高有效位 。设 t = v + Pw- 1 , 我们有\n工 y= p + tw2 0\n2. 36\n当 t = O 时 , 有 工. y=p; 乘法不会 溢出。当 t =l=- 0 时 , 有 工. y=/=- p; 乘法不会溢出 。\n2 ) 根据整数除 法的定义 ,用 非零数 工除 以 p 会得到商 q 和余数r \u0026rsquo; 即 p = 工 q+r, 且 I 叶 \u0026lt; I 工 I 。\n( 这里用的是绝对值, 因为 工和 r 的符号可能不一致。例如,一 7 除以 2 得到商- 3 和余数- 1。)\n3 ) 假设 q = y 。那么有 工 y = 工 y + r + t2中。 在此, 我们 可以得到 r + t2w = o。但是 l r l \u0026lt; I 工 I\n沪 , 所以只有当 t = O 时,这个等式 才会成立 ,此时 r = O。假设r = t = O。 那么我们有 工 y = 工. q, 隐 含有 y = q。\n当 工= O 时 ,乘法不溢出 , 所以我们的代码提供了一种可靠的方法来测试补码乘法是否会导致溢。出如果用 64 位表示, 乘法就不 会有溢出 。然后我们 来验证将乘积强制类型转换为 32 位是否会改 变它的值:\nI* Determine whether the arguments can be multiplied without overflow *I\nint tmult_ok(int x, int y) {\nI* Compute product without overflow *I int64_t pll = (int64_t) x*y;\nI* See if casting to int preserves value *I return pll == (int) pll;\n}\n注意 ,第 5 行右边的强制类型转换 至关重要 。如果我们将 这一行写成\nint64_t pll = 江 y;\n就会用 32 位值来计算 乘积(可能会溢出),然后再符号扩展到 64 位。\n2. 37 A. 这个改 动完全没有帮助。虽 然 a s i ze 的计算会更 准确,但是 调用 ma l l oc 会导致这个值被转换成一个 32 位无符号数字,因 而还是会出现同样的溢出条件。\nma l l o c 使用一个 32 位无符号数作为参数 ,它 不可能分配一 个大于 沪 个字节的块, 因此, 没有必要试图 去分配或者复 制这样大的一块内存。取而代 之, 函数应该放弃,返 回 NULL, 用下面的代码取代 对 ma l l o c 原 始的调用(第9 行):\nuint64_t required_size\u0026rsquo;\u0026quot;\u0026rsquo;ele_cnt * (uint64_t) ele_size; size_t request_size = (size_t) required_size;\nif (required_size !• request_size)\n/• Overflow must have occurred. Abort operation•/ return NULL;\nvoid•result= malloc(request_size); if (result== NULL)\n/• malloc failed•/ return NULL;\n2. 38 在第 3 章中, 我们将看到很多实际的 LEA 指令的例子。用这个指令来支持指针运算 , 但是 C 语言编译器经常用它来执行小常数乘法 。\n对于每个 k 的值, 我们可以计算出 2 的倍数: 2 女( 当 b 为 0 时)和2\u0026rsquo; + 1( 当 b 为 a 时)。因此我\n们能够计算出倍数 为 1, 2, 3, 4, 5, 8 和 9 的值。\n2. 39 这个表达式就变成了- (x\u0026lt; \u0026lt;m)。要看清这一点,设字长为w , n = w - l 。形式 B 说我们要计算 (x\u0026lt; \u0026lt;w) ­ (x \u0026lt; \u0026lt;m)\u0026rsquo; 但是将 x 向左移 动 w 位会得到值 0。\n2. 40 本题要求你使用讲过的优化技术 ,同时 也需要自己的一点 儿创造力。\nK 移位 加法碱法 表达式 6 2 1 (X\u0026lt; \u0026lt;2 ) + (X \u0026lt;\u0026lt; l ) 31 l I (X\u0026lt;\u0026lt;5) - X -6 2 I (x \u0026lt;\u0026lt;l ) - (x \u0026lt;\u0026lt;3 ) 55 2 2 (X\u0026lt; \u0026lt; 6 ) - (X \u0026lt;\u0026lt;3 ) - X 可以观察到, 第四种情 况使用了形 式 B 的改进版本。我们可以 将位模式 [ 110111] 看作 6 个连续的 1 中间有一个 o, 因而我们对形式 B 应用这个原则,但 是需要在后来把中间 0 位对应的项减掉。\n2. 41 假设加法和减法有同样的性能, 那么原则就是当 n = m 时, 选择 形式 A, 当 n = m + l 时,随 便选哪种,而当 n\u0026gt; m + l 时,选 择形式 B。\n这个原则的证明 如下。首先假设 m\u0026gt; O。 当 n = m 时, 形式 A 只需要 1 个移位, 而形式 B 需要\n2 个移位和 1 个减法。当 n = m + l 时, 这两种形式都需要 2 个移位和 1 个加法或者 1 个减法。当n\u0026gt; m + l 时, 形式 B 只需要 2 个移位和 1 个减法,而形 式 A 需要 n - m + 1 \u0026gt; 2 个移位和 n - m \u0026gt; l 个加法。对 于 m = O 的 情况, 对千形式 A 和 B 都要少 1 个移位, 所以在两者中选择时, 还是适用同样的原则。\n2. 42 这里唯一的 挑战是不使用任何 测试或条件运算来计算偏 置量。我们利用了一 个诀窍,表 达式 x \u0026gt; \u0026gt;\n31 产生一个字,如果 x 是负数,这个 字为全 1, 否则为全 0。通过掩码屏蔽掉适当的位,我们 就得到期望的偏置值。\nint div16(int x) {\nI• Compute bias to be either O (x \u0026gt;= 0) or 15 (x \u0026lt; 0)•I int bias= (x»31) \u0026amp; OxF;\nreturn (x + bias)»4;\n}\n2. 43 我们发现当人们直接与汇 编代码打交道时是有困难的 。但当把它放入 op t ar i 七h 所示的形式中时,问题就变得更加清晰明了。\n我们可以看到 M 是 31 ; 是用 (x \u0026lt; \u0026lt;S) - x 来计算 x* M。\n106 笫一部分 程序结构和执行\n我们可以 看到 N 是 8 ; 当 y 是负数时, 加上偏置量 7\u0026rsquo; 并且右移 3 位。\n2. 44 这些 \u0026quot; C 的谜题“ 清楚地告诉 程序员 必须理 解计算 机运算的属性。\nA. (x \u0026gt; 0) I I ( (x 一 1 ) \u0026lt; 0)\n假。设 x 等于- 2 147 483 648 ( TM in 32) 。 那么, 我们有 x- 1 等千 2147483647( TMa 工32) 。\nB. (x \u0026amp; 7) != 7 11 (x \u0026lt;\u0026lt; 29 \u0026lt; 0)\n真。如果 (x \u0026amp; 7) != 7 这个表达式的值为 o, 那么我们必须有位 工2 等于 1。当左移 29 位时 , 这个位将变成符号位。\nC. (x * x) \u0026gt; = 0\n假。当 x 为 65 535( Ox FFFF) 时 , X * X 为- 131 071( Ox FFF EOOOl ) 。\nx \u0026lt; 0 I I -x \u0026lt; = 0\n真。如果 x 是非负数, 则- x 是非正的。\nx \u0026gt; 0 I I -x \u0026gt; = 0\n假。设 x 为- 2 147 48 3 648 ( TM in32) 。 那么 x 和- x 都为负数。\nx+y = = uy+ux\n真。补码和无符号乘法有相同的位级行为,而且它们是可交换的。\nx*~y + uy*ux == - x\n真。~y 等千- y- 1。uy*ux 等千 x *y 。因此 , 等式左边等价 千 x *- y- x +x *y 。\n2. 45 理解二进制小数表示是理解浮点编码的一个重要步骤。这个练习让你试验一些简单的例子。\n考虑二进制小数 表示的 一个简单 方法是将一个数表示为形如责 的小数。我们将这个形式表示\n为二进制的过程是:使用 :r: 的二 进制表示 , 并把二进制小 数点插入从右边算起的第 k 个位置。举一个例子,对 25 ,我们有 2510 11001 2 。 然后我们 把二进制小数点放在从右算起的第 4 位, 得\n16\n到 1. 10012 0\n46 在大多数情况中, 浮点数的有限精度不是 主要的问 题,因为计算 的相对误差仍然是相当低的。然而在这个例 子中, 系统对于绝对误 差是很敏感的。 我们 可以看到 o. 1 - 工的二进制表示 为:\n0. 000000000000000000000001100[ 1100] ··· 2\n把这个表示与— 的二进 制表示进行比较 ,我 们可以 看到这就是 2一 2o x - , 也就是大约 9. 54 X 10 10\n10 - s .\nC. 9. 54 X 1-0 s X 100 X 60 X 60 X 10 \u0026lt;::::0 . 3 43 秒 。\nD. O. 343 X 2000 \u0026lt;::::687 米 。\n2. 47 研究字长非常小的 浮点表示能够帮助澄清 IEEE 浮点是怎样工作的。要特别注意非 规格化数和规格化数之间的过渡。\n2. 48 十 六 进 制 Ox 3591 41 等 价 于 二 进 制 [ 1101011 001000 101000001 ] 。将 之 右 移 21 位 得 到\n1. 101011 001 000101000001 2 X 22 1 。除 去起 始位 的 1 并 增 加 2 个 0 形 成小 数字 段,从 而得 到 [ 10101100100010100000100 ] 。阶 码 是 通 过 21 加 上 偏 置 狱 1 27 形 成的, 得 到 148 ( 二 进 制[ 10010100] ) 。我们把它 和符号字段 0 联合起来 , 得到二进制表示\n[01001010010101100100010100000100]\n我们看到两种表示中 匹配的位对应于整数的 低位到最高 有效位等千 1, 匹配小数的高 21 位:\n0 0 3 5 9 1 4 1\n00000000001101011001000101000001\n*********************\n4 A 5 6 4 5 O 4\n01001010010101100100010100000100\n49 这个练习帮助你思考什么数不能用浮点准确表示。 这个数的二进制表示是: 1 后面跟着 n 个 o, 其后再跟 1 , 得到值是 2+• 1+ 1 。\nB. 当 n = 23 时,值是 224+ 1 = 1 6 777 217。\n2. 50 人工舍人帮助你加强二进制数舍人到偶数的概念。\n原始值 舍入后的值\nl 10 .0\n2. 51 A. 从 1/ 10 的无穷序列中我们可以看到 , 舍人位置右边 2 位都是 1 , 所以对 1 / 10 更好一点儿的近似值应该是对 x 加 1, 得到 x \u0026rsquo; = O. 000110011001100110011012, 它比 0. 1 大一点儿。\n我们可以 看到 x \u0026rsquo; - 0. l 的二进制表示为:\n0.0000000000000000000[1100]\n将这个值与 1 / 10 的二进制表示做比较,我 们可以 看到它等于 2一22 X 1 / 10 , 大约等于 2. 38 X\n10- s .\n2. 38X 10-s X l OO X 60 X 60X 10 :::::::::。. 郊6 秒,爱国者导弹系统中的误差是它的 4 倍。\nD. O. 086 X 2000::::::::: 订 1 米 。\n2. 52 这个题目考查了很多关于浮点表示的概念,包括规格化和非规格化的值的编码,以及舍入。\n108 笫一部分 程序结构和执行\n格式A 位 值 格式B 位 注 011 0000 1 0111 000 I 1011110 152 1001111 152 010 1001 舒 OllO 100 ¾ 向下舍入 110 1111 312 1011 000 16 向上舍人 0 0 0 0001 忐 0001 000 忐 非规格化一规格化 2. 53 一般来说,使用库宏 ( librar y macro) 会比你自己写的代码更好 一些。不过, 这段代码似乎可以 在多种机器上工作。\n假设值 l e 400 溢出为无穷 。\n#define POS_INFINITY 1e400\n#define NEG_INFINITY (-POS_INFINITY)\n#define NEG_ZERO (-1.0/POS_INFINITY)\n5 4 这个练习可以 帮助你从 程序员的角度来提高研究 浮点运算的能力 。确信自己理解下 面每一个答案 。\nx == (int) (double) x\n真, 因为 d o ub l e 类型比 i n t 类型具有更 大的精度和范图。\nx == (i n 七 )(double) x\n假,例如当 x 为 TMa x 时。\nd == (double) (float) d\n假,例 如当 d 为 l e 40 时,我 们在右边得 到十C0 。\nf == (float) (double) f\n真,因为 d o ub l e 类型比 f l o a t 类型具有更大的精度 和范围。\nf == -(-fl\n真,因 为浮点 数取非就是 简单地对它的符 号位取反 。\nF. 1.0/2 == 1/2.0\n真,在执行除法之前,分子和分母都会被转换成浮点表示。\nd *d \u0026gt; =O. O\n真 ,虽 然它可能 会溢出到十C0 。\n(f +d ) 一f == d\n假, 例如当 f 是 1 . 0e 20 而 d 是 1. 0 时, 表达式 f +d 会舍入到 1 . Oe20, 因此左边的表达式求值得到 o. o, 而右边是 1. 0。\n"},{"id":439,"href":"/zh/docs/technology/System/%E6%B7%B1%E5%85%A5%E7%90%86%E8%A7%A3%E8%AE%A1%E7%AE%97%E6%9C%BA%E7%B3%BB%E7%BB%9F/%E4%B9%A6%E7%B1%8D/%E7%AC%AC3%E7%AB%A0-%E7%A8%8B%E5%BA%8F%E7%9A%84%E6%9C%BA%E5%99%A8%E7%BA%A7%E8%A1%A8%E7%A4%BA/","title":"Index","section":"SpringCloud","content":"第 3 章\n—- CH APTER 3\n程序的机器级表示 # 计算机执行机器代码,用字节序列编码低级的操作,包括处理数据、管理内存、读写 存储设备上的数据,以及利用网络通信。编译器基于编程语言的规则、目标机器的指令集 和操作系 统遵循的 惯例, 经过一系列的 阶段生成 机器代码 。GCC C 语言编译器以汇编代码的形式产生输出,汇编代码是机器代码的文本表示,给出程序中的每一条指令。然后\nGCC调用汇编 器和 链接器, 根据汇编代码生成可执行的机器代码。在本章中, 我们会近距离地观察机器代码,以及人类可读的表示 汇编代码。\n当我们用高级语言 编程的时候(例如C 语言, Java 语言更是如此), 机器屏蔽了 程序的 细节,即机器级的实现。与此相反,当用汇编代码编程的时候(就像早期的计算),程序员必须 指定程序用来执行计算的低级指令。高级语言提供的抽象级别比较高,大多数时候,在这种 抽象级别上工作效率会更高,也更可靠。编译器提供的类型检查能帮助我们发现许多程序错 误,并能够保证按照一致的方式来引用和处理数据。通常情况下,使用现代的优化编译器产 生的代码至少与一个熟练的汇编语言程序员手工编写的代码一样有效。最大的优点是,用高级 语言编写的程序可以在很多不同的机器上编译和执行,而汇编代码则是与特定机器密切相关的。 那么为什么我们还要花时间学习机器代码呢?即使编译器承担了生成汇编代码的大部\n分工作,对千严谨的程序员来说,能够阅读和理解汇编代码仍是一项很重要的技能。以适 当的命令行选项调用编译器,编译器就会产生一个以汇编代码形式表示的输出文件。通过 阅读这些汇编代码,我们能够理解编译器的优化能力,并分析代码中隐含的低效率。就像 我们将在 第 5 章中体会到的那样, 试图最大化一 段关键代码性能的程序员 , 通常会尝试源代码的各种形式,每次编译并检查产生的汇编代码,从而了解程序将要运行的效率如何。 此外,也有些时候,高级语言提供的抽象层会隐藏我们想要了解的程序的运行时行为。例 如,第 12 章会讲到,用线程包写并 发程序时 ,了 解不同的线程是如何共享程序数 据或保持数据私有的,以及准确知道如何在哪里访问共享数据,都是很重要的。这些信息在机器代码 级是可见的。另外再举一个例子,程序遭受攻击(使得恶意软件侵扰系统)的许多方式中,都涉及程序存储运行时控制信息的方式的细节。许多攻击利用了系统程序中的漏洞重写信息, 从而获得了系统的控制权。了解这些漏洞是如何出现的,以及如何防御它们,需要具备程序 机器级表示的知识。程序员学习汇编代码的需求随着时间的推移也发生了变化,开始时要求 程序员能直接用汇编语言编写程序,现在则要求他们能够阅读和理解编译器产生的代码。\n在本章 中, 我们 将详细学习一 种特别的 汇编语 言,了 解如何将 C 程序编译成这种形式的机器代码。阅读编译器产生的汇编代码,需要具备的技能不同千手工编写汇编代码。我 们必须 了解典型的编译器在将 C 程序结 构变换成 机器代码时所做的转换 。相对于 C 代码表示的计算操作,优化编译器能够重新排列执行顺序,消除不必要的计算,用快速操作替换 慢速操作,甚至将递归计算变换成迭代计算。源代码与对应的汇编代码的关系通常不太容易 理解一一-就像要拼出的 拼图与盒子上图 片的设 计有点不太一样。这是一种逆向 工程 ( reverse engineering ) — 通过研究 系统 和逆向工作, 来试图了解系统 的创建过程。 在这里, 系统是一个机器产生的汇编语言程序,而不是由人设计的某个东西。这简化了逆向工程的任\n务,因为产生的代码遵循比较规则的模式,而且我们可以做试验,让编译器产生许多不同程序的代码。本章提供了许多示例和大量的练习,来说明汇编语言和编译器的各个不同方面。精通细节是理解更深和更基本概念的先决条件。有人说:“我理解了一般规则,不愿意劳神去学习细节!”他们实际上是在自欺欺人。花时间研究这些示例、完成练习并对照提供的答案来检查你的答案,是非常关键的。\n我们的表述基千 x86-64, 它是现在笔记本电脑和台式机中最常见处理器的机器语言,也是驱动大型数据中心和超级计算机的最常见处理器的机器语言。这种语言的历史悠久,开始于 Intel 公司 1978 年的第一个 16 位处理器 , 然后扩展为 32 位, 最近又扩展到 64 位。一路以来,逐渐增加了很多特性,以更好地利用巳有的半导体技术,以及满足市场需求。这些进步中很多是 Intel 自己驱动的 , 但它的对手 AMD( Advanced Micro Devices)也作出了重要的贡献。演化的结果是得到一个相当奇特的设计,有些特性只有从历史的观点来看才有意义,它还具有 提供后向兼容性的特性,而现代编译器和操作系统早已不再使用这些特性。我们将关注\nGCC 和 Linux 使用的那些特性, 这样可以 避免 x86-64 的大量复杂性 和许多隐秘特性。\n我们 在技术讲解之 前,先 快速浏览 C 语言、汇编代码以及机器代码之间 的关系。然后介绍 x86-64 的细节 , 从数据的表示 和处理以及控制的实现开始。了解如何 实现 C 语言中的控制结构, 如 if 、wh i l e 和 s wi t c h 语句。之后 , 我们会 讲到过程的实 现, 包括程序如何维护一个运行栈来支持过程间数据和控制的传递,以及局部变最的存储。接着,我们会考虑在机器级如何实现像数组、结构和联合这样的数据结构。有了这些机器级编程的背景知识,我们会讨论内存访问越界的问题,以及系统容易遭受缓冲区溢出攻击的问题。在这一部分的结尾, 我们会 给出一 些用 GDB 调试器检查机器级程序运行时行为的 技巧。本章的最后展示了包含浮点数据和操作的代码的机器程序表示。\n- IA32 编程\nIA32, x86- 64 的 32 位前身,是 Intel 在 1985 年提出的 。几十年 来一 直是 Intel 的机 器语言之选。今天出售的 大多数 x86微处理器,以及这些机 器上安装的 大多数操作 系统, 都是为运行 x86-64设计的。不过 , 它们也可以向后 兼容执行 IA32 程序。所以,很 多应用 程序还是基于 IA32 的。除此之外, 由于硬件或 系统软件的限制, 许多已有的 系统不能够执行 x86-64。\nIA32 仍然是一种重要的机 器语言。学习过 x86-64会使你很容易地学会 IA32 机器语言。\n计算机工业已经完成从 32 位到 64 位机器的过渡 。32 位机器只能使用大概 4GB( 沪 字节)的随机访问存储器。存储器价格急剧下降,而我们对计算的需求和数据的大小持续增 加, 超越这个 限制既经 济上可行又有技术上的需要。当前的 64 位机器能够使用多达256T B( 248 字节)的内存空间 , 而且很容易 就能扩展至 16EB ( 26 4 字节)。虽然很难想象一台机器需要这么大的内存, 但是回想 20 世纪 70 和 80 年代, 当 32 位机器开始普及的时候,\n4GB 的内存看上去也是超级大的 。\n我们的 表述集 中于以现代操作 系统为目标, 编译 C 或类似编 程语言时, 生成的 机器级程序类型 。x86-64 有一些 特性是 为了 支持遗留下来的 微处理器早期 编程风格 , 在此,我 们不试图去描述这些特性 , 那时候大部分代码都是手工编写的, 而程序员 还在努力与 16 位机器允许的有限地址空间奋战。\n1 历史观点\nIntel 处理器系列俗称 x86, 经历了一个长期的、不断进化的发展过程。开始时,它是第\n一代单芯片 、16 位微处理器之一, 由千当时 集成电 路技术水 平十分 有限, 其中做了很多妥协。以后,它不断地成长,利用进步的技术满足更高性能和支持更高级操作系统的需求。\n以下列举 了一些 In tel 处理器的模型, 以及它们的一些关键特性, 特别是影响机器级编程的特性。我们用实现这些处理器所需要的品体管数量来说明演变过程的复杂性。其 中, \u0026quot; K\u0026quot; 表示 1000 , \u0026quot; M\u0026quot; 表示 1 000 000, 而 \u0026quot; G\u0026quot; 表示 1 000 000 000 。\n8086(1978 年, 29K 个晶体管)。它是第一代单芯片、16 位微处理器之一。 8088 是 8086\n的一个变 种, 在 8086 上增 加了一个 8 位外部总线, 构成最初的 IBM 个人计算机的心脏。\nIBM 与当时还不强大的微软签订合同 , 开发 MS-DOS 操作系统。最初的机器型号有32 768 字节的内存和两个软驱(没有硬盘驱动器)。从体系结构上来说 , 这些机器只有 655 360 字节的地址空间 地址只有 20 位长(可寻址范围为 1 048 576 字节), 而操作系统保 留了 393 216 字节自用。 1980 年, Int el 提出了 8087 浮点 协处理器( 45K 个晶体管), 它与一个 8086 或 8088处理器一同运行 , 执行浮点指令 。8087 建立了 x86 系列的浮点模型 , 通常被称为 \u0026quot; x87\u0026quot;。\n80286(1 982 年, 13 4K 个晶体管)。增加了更多的寻址模式(现在巳 经废弃了), 构成了 IBM PC- AT 个人计算机的基础, 这种计算 机是 MS Windows 最初的使用平台。\ni386(1 985 年, 275K 个晶体管)。将体系结构扩展到 32 位。增 加了平坦寻址模式 ( flat addressing model) , Linux 和最近版本的 Windows 操作系统都是使用的这种模式。这是\nIntel 系列中第一台全 面支持 U nix 操作系统的机器。\ni486 (1 989 年, 1. 2M 个晶体管)。改善了性能, 同时将浮点 单元集成到了处 理器芯片上,但是指令集没有明显的改变。\nPentium (1 993 年, 3. l M 个晶体管)。改善了性能, 不过只对指令集 进行了小 的扩展。\nPentiumP ro(1 995 年, 5. 5M 个晶体管)。引入全新的处理器设计, 在内部被称为 P 6\n微体系 结构。指令集 中增加了一类 ”条件传送 ( cond iti onal move) \u0026quot; 指令。\nPentium/ MMX C1997 年, 4. 5M 个晶体管)。在 Pentium 处理器中增加了一类新的处理整数 向量的指令 。每个数据大小 可以是 1、2 或 4 字节。每个向量 总长 64 位。\nPentium 11(1 997 年, 7M 个晶体管)。P6 微体系结构的延伸。\nPentium 111(1 999 年, 8. 2M 个晶体管)。引入了 SSE , 这是一类处理整数或浮点数向最的指令 。每个数 据可以是 1、2 或 4 个字节, 打包成 128 位的向量。由 千芯片上包括了二级高速缓 存, 这种芯片后来的 版本最多使用了 24M 个品体管。\nPentium 4 ( 2000 年, 42M 个晶体管)。SSE 扩展 到了 SSE 2 , 增加了新的数据类型(包括双精 度浮点数), 以及针对这些格式的 144 条新指令。有了这些 扩展, 编译器可以 使用\nSSE 指令(而不是x87 指令), 来编译浮点 代码。\nPentium 4E ( 2004 年, 1 25M 个晶体管)。增加了超线程 ( hypert hreading ) , 这种技术可以在 一个处理器上同时运行 两个程序; 还增 加了 EM64T , 它是 In tel 对 AMD 提出的对\nIA32 的 64 位扩展的 实现, 我们称之为 x86-64 。\nCore 2( 2006 年, 291 M 个晶体管)。回归到类似于 P6 的微体系结 构。Intel 的第一个多核微处理器,即多处理器实现在一个芯片上。但不支持超线程。\nCore i7, Nehalem ( 2008 年, 781 M 个晶体管)。既支持超线程, 也有多核, 最初的版\n本支持每个核上执行两个程序,每个芯片上最多四个核。\nCore i7, Sandy Brid ge( 20 11 年, 1. 1 7G 个晶体管)。引入了 AV X , 这是对 SSE 的扩展, 支持把数 据封装 进 256 位的向量。\nCore i7, H aswe ll ( 2013 年, 1. 4G 个晶体管)。将AV X 扩展 至 AV X2 , 增加了更多的\n指令和指令格式。\n每个后继处理器的设计都是后向兼容的一较早版本上编译的代码可以在较新的处理 器上运行。正如我们看到的那样,为了保持这种进化传统,指令集中有许多非常奇怪的东 西。Int el 处理器 系列 有好几个名字, 包括 IA 32 , 也就是 \u0026quot; Intel 32 位 体系结构 \u0026lt; Intel Architecture 32-bit) \u0026ldquo;, 以及最新的 In tel6 4 , 即 IA32 的 64 位扩展, 我们也称为 x86-64 。最常用的名字是 \u0026quot; x86\u0026rdquo; , 我们用它指 代整个 系列 , 也反映了直到 i486 处理器命名的惯例。\nm 摩尔定律 ( Moo re \u0026rsquo; s Law)\n如果我们画 出各种不同的 Int el 处理 器 中晶 体管的 数量与 它们 出现 的年份 之间的 图( y 轴为晶 体管数 量的 对数值), 我们能够 看 出, 增长是 很显著的。画一条拟合这些数 据的线, 可以 看到晶 体管数 量以每年 大约 37 % 的速率增 加, 也就是 说, 晶体管数 量每 26 个月就 会翻一 番。在 x86 微处理 器的 历 史上 , 这种增长已经持 续 了好几十年 。\nIntel微处理器的复杂性\nI.OE+ 10\nLOE+ 09\nNeha。lem◊\nPentium4 夕ePentium4\n/Core 2 Duo\nI.OE+ 05\nI.OE+ 04\n1975 1980 1985 1990 1995 2000 2005 2010 2015\n年份\n1965 年, G ordon Moore, Intel 公 司的创始 人, 根据当时 的芯片技术(那时他们能够在一个芯 片 上制造有 大约 64 个晶 体管的电 路)做出推 断, 预测在 未来 10 年, 芯片 上的晶体管数量每年都会翻一番 。这个预测就称为摩 尔定律。正如事实证明的那样, 他的预测有点乐观, 而且 短视。在超过50 年中, 半导体工业一直能够使得晶体管数目每 18 个月翻一倍。\n对计算机技术的其他方面,也有类似的呈指数增长的情况出现,比如磁盘和半导体 存储 器的 存储 容量。 这些惊人的 增长速度一 直是 计 算机 革命的 主要驱动力。\n这些年来 , 许多公司生产出了 与 Inte l 处理器兼 容的处理器, 能够运行完全相同 的机器级程序。其 中, 领头的是 AMD。数年来, AMD 在技术上紧跟 In tel, 执行的市场策略是: 生产性能 稍低但是价格更便宜的处理器。2002 年, AMD 的处理器变得更加有竞争力, 它们率先突破了 可商用微处理器的 1G H z 的时钟速度屏障, 并且引 入了广泛采用的\nIA32 的 64 位扩展 x86-64。虽 然我们讲的是 Inte l 处理器, 但是对于其竞争对手生产的与之兼容的处理器来说 , 这些表述也同样成 立。\n对于由 CCC 编译器产生的 、在 Linux 操作系统平台上运行的程序 , 感兴趣的人大多并不关心 x86 的复杂性。最初的 8086 提供的内 存模型和它在 80286 中的扩展 , 到 i386 的时候就都已经过时了。原来的x87 浮点指令到引入 SSE2 以后就过时了 。虽然在 x86-64 程序中 , 我们能看到历史发展的痕迹 , 但 x86 中许多最晦涩难懂的特性已经不会出现了。\n3. 2 程序编码\n假设一个 C 程序, 有两个 文件 p l. c 和 p 2 . c 。我们用 Unix 命令行编译这些代码 :\nlinux\u0026gt; gee -Og -op p1.e p2.e\n命令 g e e 指的就是 GCC C 编译器。因 为这是 Lin u x 上默认的编译器, 我们也可以 简单地用 cc 来启 动它。编译 选项 - Oge 告诉编译器使用 会生成符合原 始 C 代码整体结 构的机器代码的优化等级。使用较高级别优化产生的代码会严重变形,以至于产生的机器代码和 初始源 代码之间的 关系非常难以 理解。因 此我们会使 用- Og 优化作为学 习工具 , 然后当我们增加优化级别时,再看会发生什么。实际中,从得到的程序的性能考虑,较高级别的优 化(例如, 以选项 - 0 1 或- 0 2 指定)被认为是 较好的 选择。\n实际上 gee 命令调用了一 整套的 程序 , 将源代码转化成可执行代码。首先, C 预 处理器扩展源代码 , 插入所有用 #i ne l ude 命令指定的文件, 并扩展所有用#de f i ne 声明指定的宏。其 次, 编译 器产生两个源文件的 汇编代码 , 名字分别为 p l. s 和 p 2 . s 。接下 来, 汇编器会 将汇编代码转化成二 进制 目标 代码文 件 p l. o 和 p 2 . o 。目 标代码是机器代码的一种形式,它包含所有指令的二进制表示,但是还没有填入全局值的地址。最后,链接器将两 个目标代码 文件与实现库函数(例如 p r i n t f ) 的代码合并 , 并产生最终 的可执行代码文件 p\n(由命令行指示符 - o p 指定的)。可执行代码是我们要考虑的 机器代码的 第二种形式, 也就是处理器执 行的代码格式 。我们会在第 7 章更详细地介绍 这些不同形式的机器代码 之间的关系以及链接的过程。\n2. 1 机器级代码\n正如在 1. 9. 3 节中讲 过的那样, 计算机系统 使用了多种不同形式的抽象 , 利用更简单的抽象模型来隐藏实现的细节。对于机器级编程来说,其中两种抽象尤为重要。第一种是由指令集 体 系结构或指令 集 架构 O ns tru et ion Set Arehiteeture, ISA ) 来定义机器级程序的格式和行为,它定义了处理器状态、指令的格式,以及每条指令对状态的影响。大多数\nISA, 包括 x86-64 , 将程序的行为描述成好像每条指令都是按顺序执行的,一条指令结束后,下一条再开始。处理器的硬件远比描述的精细复杂,它们并发地执行许多指令,但是可 以采取 措施保证整体行为与 ISA 指定的顺序执行的行为完全一致。第二种抽象是 , 机器级程序使用的内存地址是虚拟地址,提供的内存模型看上去是一个非常大的字节数组。存储器系 统的实际实现是将多个硬件存储器和操作系统软件组合起来, 这会在第 9 章中讲到。\n在整个编译过程中 , 编译器会完成大部分的工作 , 将把用 C 语言提供的 相对比较抽象的执行模型表示的程序转化成处理器执行的非常基本的指令。汇编代码表示非常接近于机器代 码。与机器代码的二进制格式 相比, 汇编代码的 主要特点 是它用可读性更好的文本格式 表示。能够理解汇编代码 以及它与原始C 代码的联系, 是理解计算机如何执行程序的关键一步。\nx86-64 的机器代码 和原始的 C 代码差别非常大。一些通常对 C 语言程序员隐藏的处理器状态都是可见的:\n程序计数 器(通常称为 \u0026quot; PC\u0026quot; , 在 x86-64 中用%r i p 表示)给出将要执行的下一条指令在内存中的地址。 8 GCC 版本 4. 8 引入了这个优化等级。较早的 CCC 版本 和其他 一些非 G U 编译器不认 识这个选项 。对这样一些编译器, 使用一级优化(由命令行标志-0 1 指定)可能是最好的选择, 生成的代码能 够符合原始程序的结构。\n整数寄存器文件包含 16 个命名的位置, 分别存储 64 位的值。这些寄存器可以存储地址\n(对应于 C 语言的指针)或整数数据。有的寄存器被用来记录某些重要的程序状态, 而其他的寄存器用来保存临时数据, 例如过程的参数和局部变量, 以及函数的返回值。\n条件码寄存器保存着最近执行的算术或逻辑指令的状态信息。它们用来实现控制或 数据流中的 条件变化 , 比如说用来实现 if 和 wh i l e 语句。\n一组向量寄存器 可以存放一个或多个 整数或 浮点 数值。\n虽然 C 语言提供了一种模型, 可以在内存中声明 和分配各种数 据类型的对象, 但是机器代码只是简单地将内存看成一个很大的、按字节寻址的数组。 C 语言中的聚合数据类型, 例如数组和结构,在机器代码中用一组连续的字节来表示。即使是对标量数据类型,汇编代码 也不区分有符号或无符号整数,不区分各种类型的指针,甚至于不区分指针和整数。\n程序内存包含:程序的可执行机器代码,操作系统需要的一些信息,用来管理过程调 用和返回的运行时栈 ,以 及用户分 配的内存块(比如说用 ma l l o c 库函数分配的)。正如前面提到的,程序内存用虚拟地址来寻址。在任意给定的时刻,只有有限的一部分虚拟地址 被认为是合法的。例如, x86-64 的虚拟地址是由 64 位的字来表示的。在目前的实现中,\n这些地址的高 16 位必须设置为 o, 所以一个地址实际 上能 够指定的是 2 4 8 或 64T B 范围内\n的一个字节。较为典型的程序只会访问几兆字节或几千兆字节的数据。操作系统负责管理虚拟地址空间,将虚拟地址翻译成实际处理器内存中的物理地址。\n一条机器指令只执行一个非常基本的操作。例如,将存放在寄存器中的两个数字相加, 在存储器和寄存器之间传送数据,或是条件分支转移到新的指令地址。编译器必须产生这些 指令的序列,从而实现(像算术表达式求值、循环或过程调用和返回这样的)程序结构。\n田 日 不断变化的 生成 代码的 格式\n在本书的 表述中,我们给 出的 代码是由特定版本的 GCC 在特定的命令行选项设置下产 生的 。如 果你在自己 的机 器上编 译代码, 很有可能 用到 其他的 编译 器或 者不 同版本的 GCC , 因 而会产 生不同的代 码。 支持 GCC 的 开源社区 一 直在修 改代码产 生 器,试图根据微处理 器制 造商提供的 不断 变化的代码规则 ,产 生更有效的 代码。\n本书示例的目标是展示如何查看汇编代码,并将它反向映射到高级编程语言中的结 构。你需要 将这些技 术应 用到 你的 特定的编译 器产 生的 代码格 式上 。\n2. 2 代码示例\n假设我们写 了一个 C 语言代码文 件 ms t or e . c , 包含如下的函数定义:\nlong mult2(long, long);\nvoid multstore(long x, long y, long *dest) { long t = mult2(x, y);\n*dest = t;\n}\n在命令行 上使用 \u0026ldquo;-s\u0026rdquo; 选项 , 就能看到 C 语言编译器产生的 汇编代码 :\nlinux\u0026gt; gee -Og -S mstore.e\n这会使 GCC 运行 编译 器, 产生一个汇编文件 ms t or e . s , 但是不做其他进一步的工作。(通常情况下,它还会继续调用汇编器产生目标代码文件)。\n汇编代码文件包含各种声明,包括下面几行:\nmultstore: pushq %rbx\nmovq %rdx, %rbx\ncall mult2\nmovq %rax, (%rbx)\npopq %rbx ret\n上面代码 中每个缩进去的行都对应于一条机器指令。比如, p us hq 指令表示应该 将寄存器%\nr bx 的内容压入程序栈中。这段代码中已经除去了所有关于局部变星名或数据类型的信息 。如果我们 使用 \u0026quot; - c\u0026quot; 命令行选项, GCC 会编译并 汇编该 代码:\nlinux\u0026gt; gee -Og -e mstore.e\n这就会 产生目标 代码文件 ms t or e . o , 它是二进制格式的, 所以无法直接查看。1 368 字节的文件 ms t or e . o 中有一段 1 4 字节的序列, 它的十六进制 表示为:\n53 48 89 d3 e8 00 00 00 00 48 89 03 Sb c3\n这就是上面列出的汇编指令对应的目标代码。从中得到一个重要信息,即机器执行的程序只 是一个字节序列,它是对一系列指令的编码。机器对产生这些指令的源代码几乎一无所知。\nm 如何展 示程序的 字节表示\n要展示程序(比 如说 ms t or e ) 的二进制目 标代码, 我们 用反汇编 器( 后 面会 讲到)确定该过程的代码 长度是 1 4 宇 节。 然后, 在文件 ms t or e . o 上运行 GNU 调试工具 GOB, 输入命令 :\n(gdb) x/14xb multstore\n这条命令 告诉 GOB 显示( 简写 为 \u0026rsquo; x \u0026rsquo; ) 从 函 数 mu l t s t or e 所处地 址开始的 1 4 个十 六进 制格式表 示(也简写为 \u0026rsquo; x \u0026rsquo; ) 的 宇 节( 简写 为 \u0026rsquo; b \u0026rsquo; ) 。 你会发现, GOB 有很 多 有用的 特性可以用来 分析机 器级程序 , 我们会 在 3. 10. 2 节中讨 论。\n要查看机器代码 文件的内容, 有一类称为反汇 编 器 ( dis assem bier ) 的程序非常有用。这些程 序根据机器代码产生一 种类似于汇编代码的 格式。在 Lin u x 系统中, 带`-扩命令行标志的程序 OBJDUMP ( 表示 \u0026quot; o bject d um p\u0026quot; ) 可以充当这个角色:\nlinux\u0026gt; objdump -d mstore.o\n结果如下(这里,我们在左边增加了行号,在右边增加了斜体表示的注解): Disassembly of function multstore in binary file mst or e . o 0000000000000000 \u0026lt;multstore\u0026gt;:\nOffset Bytes\n0: 53\n1: 48 89 d3\n4: e8 00 00 00 00\n9: 48 89 03\nc: 5b\nd: c3\nEquivalent assembly language\npush %rbx\nmov %rdx,%rbx\ncallq 9 \u0026lt;multstore+Ox9\u0026gt; mov %rax, (%rbx)\npop %rbx retq\n在左边 , 我们看到按照前 面给出 的字节顺序排列的 14 个十六 进制字节值, 它们分成了若干组 , 每组有 1 ~ 5 个字节。每组都是一条指令 , 右边是等价的 汇编语言 。\n其中一些关千机器代码和它的反汇编表示的特性值得注意:\nx8 6- 64 的指令长 度从 1 到 1 5 个字节不等。 常用 的指令以及操作数较少的指令所需的字节数少,而那些不太常用或操作数较多的指令所需字节数较多。\n·设计指令格式的方式是,从某个给定位置开始,可以将字节唯一地解码成机器指 令。例如 ,只 有指令 p us h q % r b x 是以字节值 53 开头的 。\n反汇编器只是基于机器代码文件中的字节序列来确定汇编代码。它不需要访问该程序的源代码或汇编代码。\n反汇编器使 用的指令命名规则与 GCC 生成的汇编代码使用的有些细微 的差别。在我们的示 例中, 它省略了很 多指令结尾的 \u0026rsquo; q \u0026rsquo; 。这些后缀是大小指示符, 在大多数情况中可以省略。相反, 反汇编器 给 c a l l 和r e t 指令添加了'矿后缀, 同样, 省略这些后缀也没有问题。\n生成实际可执行的代码需要对一组目标代码文件运行链接器,而这一组目标代码文件 中必须含有一个 ma i n 函数。假设在文件 ma i n . c 中有下面这样的 函数:\n#include \u0026lt;stdio.h\u0026gt;\nvoid multstore(long, long, long*); int main() {\nlong d;\nmultstore(2, 3, \u0026amp;d); printf(\u0026ldquo;2 * 3 \u0026ndash;\u0026gt; %1d\\n\u0026rdquo;, d); return O;\n}\nlong mult2(long a, long b) { longs= a* b; returns;\n}\n然后, 我们用 如下方法生成 可执行 文件 pr o g : linux\u0026gt; gee -Og -o prog main.e mstore.e\n文件 pr og 变成了 8 655 个字节, 因为它不仅包含了 两个 过程的 代码 , 还包 含了用来启动和终止程序的 代码, 以及用来与操作 系统交互的 代码。我 们也可以反汇编 pr o g 文件:\nlinux\u0026gt; objdump -d prog\n反汇编器会抽取出各种代码序列,包括下面这段: Disassembly of function sum mul tstore binary file prog 0000000000400540 \u0026lt;multstore\u0026gt;:\n这段代码与 ms t or e . c 反汇编产生的 代码几乎完全一样。其 中一个主要的 区别是左边\n列出的地址不同一—-链接器将这段代码的地址移到了一段不同的地址范围中。第二个不同 之处在千链接器 填上了 c a l l q 指令调 用函数 mu l t 2 需要使用的地址(反汇编代码第 4 行)。链接器的任 务之一就是为函数调用 找到匹 配的函数的 可执行 代码的位置。最后一个区别是多了两行代码(第 8 和 9 行)。这两条指 令对程序没有影响 , 因为它们 出现在返回指令后面\n(第 7 行)。插入这些指 令是为了使函数代码变为 1 6 字节, 使得就存储器系统性能而言, 能更好地放置下一个代码块。\n2. 3 关千格式的注解\nGCC 产生的汇编代码 对我们来说有点 儿难读。一 方面, 它包含一些我们不需要关心的信息,另一方面,它不提供任何程序的描述或它是如何工作的描述。例如,假设我们用 如下命令 生成文件 ms t or e . s 。\nlinux\u0026gt; gee -Og -S mstore.e\nmstore. s 的完整内 容如下:\n.file\n.text\n.globl\n.t ype multstore:\npushq movq call movq popq ret\n\u0026ldquo;010-mstore.c\u0026rdquo;\nmultstore\nmultstore, @function\n%rbx\n%rdx, %rbx mult2\n%rax, (%rbx)\n%rbx\n.size multstore, .-multstore\n.ident \u0026ldquo;GCC: (Ubuntu 4.8.1-2ubuntu1-12.04) 4.8.1\u0026rdquo;\n.section .not e . GNU-stack, 1111 ,@progbits\n.所有以`.开,头的行都是 指导汇编器和链 接器工作的伪指令。我们通常可以忽略这些行。另 一方面, 也没有关于指令的用途以及它们 与源代码之间 关系的解释说明。\n为了更清楚地说明汇编代码,我们用这样一种格式来表示汇编代码,它省略了大部分\n伪指令 , 但包括行 号和解释性说明。对于我们的示例 , 带解释的汇编代码 如下:\nvoid multstore(long x, long y, l ong • des t )\nx in %rdi , y in multstore:\nr%s i , dest in %rdx\n通常我们只会给出与讨论内容相关的代码行。每一行的左边都有编号供引用,右边是 注释 , 简单地 描述指令 的效果以及它与原始 C 语言代码中的 计算操作的关 系。这 是一种汇编语言程序员写代码的风格。\n我们还提供网络旁注,为专门的机器语言爱好者提供一些资料。一个网络旁注描述的 是 IA32 机器代码 。有了 x8 6-64 的背景 , 学习 IA 32 会相当简 单。另外一个网络旁 注简要\n描述了在C 语言中插入汇编代码的方法。对千一些应用程序, 程序员必须用汇编代码来访问机器的低级特性。一种方法是用汇编代码编写整个函数, 在链接阶段把它们和 C 函数组合起来。另一种方法是利用 GCC 的支持,直 接在 C 程序中嵌入汇编代码。\n日 日 ATT 与 Inte l 汇编代码格式\n我们的 表述是 AT T ( 根据 \u0026quot; AT \u0026amp; T \u0026quot; 命名的, AT \u0026amp; T 是运营贝 尔 实验 室 多 年的公司)格式的 汇编代码, 这 是 GCC 、 OBJDUMP 和其他一 些我们使 用的 工具的默认格式。其他一些编程工具, 包括 Micro s of t 的 工具, 以 及 未 自 Int el 的 文档, 其 汇 编代码都是\nIntel 格式的。这两种 格 式在许多 方 面 有所不 同。 例如 ,使 用 下述命令行, GCC 可以 产\n生 mul t s t or e 函数的 Intel 格 式的代码:\nlinux\u0026gt; gee -Og -S -masm=intel mstore.e\n这个命令得到下列汇编代码:\nmultstore: push rbx\nmov rbx, rdx\ncall mult2\nmov QWORD PTR [rbx], rax pop rbx\nret\n我们看到 Intel 和 AT T 格式在如下方 面有 所不同 :\nIntel 代码省略了指示大小的后 缀。我们看到指令 pus h 和 mov , 而不是 pus hq 和 movq 。\nI ntel 代码省略 了寄存器名 宇前面的 飞 '符号, 用的 是 r bx , 而不是 %r bx 。 Intel 代码用 不 同的 方式来描 述内存中的位置 , 例如是 \u0026rsquo; QWORD PTR r[\n, ( %r bx ) \u0026rsquo; 。\nbx ) \u0026rsquo; 而 不是\n在带有多 个操 作数的指令情况下, 列 出操 作数的顺序相反。当 在 两种格式之间进行转换的时候 , 这一点非 常令人 困 惑。\n虽 然在我们的表 述中不使 用 In tel 格 式, 但 是 在 来 自 Int el 和 Microso f t 的 文档 中, 你会遇到 它。\n日 百 五 一 把 C 程序和汇编代码结合起来\n虽 然 C 编译 器在 把程序中表 达的 计算转换到机 器代 码方 面表 现出 色,但 是 仍 然有一些机 器特 性是 C 程序访问不 到 的。例 如 , 每 次 x86- 64 处理 器执 行 算术或逻辑运 算 时, 如 果得 到 的 运算 结果的低 8 位 中有偶数 个 1, 那 么 就会把 一 个名为 P F 的 1 位 条件码(condition code) 标志设 置 为 1, 否则 就设置 为 0。 这里的 PF 表 示 \u0026quot; par it y flag ( 奇偶标志)”。 在 C 语言中计算这个信 息需要至 少 7 次移位、掩码和异或运算(参见习题 2. 65) 。即使 作 为 每 次算术或逻辑运算的 一部分,硬 件都完成 了这项计算, 而 C 程序却无法知道 PF 条件码标志的值。在程序中插入几条汇编代码指令就能很容易地 完成 这项任务。\n在 C 程序中插 入汇编代码有两种方法。 笫一 种是, 我们可以 编写 完整 的函数,放 进一个独立的 汇编代码文件 中, 让汇编 器和 链 接 器把 它 和 用 C 语 言 书 写的代码合并起 来。笫 二 种 方法是 , 我们 可以 使 用 GCC 的内联 汇编(i nline assem bly) 特性, 用 as m 伪指令可 以在 C 程序中 包含 简短的汇编 代码。这种方 法的 好处是减 少 了与 机器相关的 代码量。\n当然, 在 C 程序 中 包含 汇 编代码使得这些代 码与 某 类特 殊的机器相 关(例如 x86-\n64), 所以只应该在想要的特性只能以此种方式才能访问到时才使用它。\n3. 3 数据格式\n由千是从 16 位体系结构扩展成 32 位的 , I ntel 用术语 ”字( word )\u0026quot; 表示 16 位数据类型。因此, 称 32 位数为“ 双字 ( double words)\u0026quot;, 称 64 位数 为“ 四 字 ( quad words ) \u0026quot; 。图 3-1 给出了 C 语言基本数据类型对应的 x86-64 表示。标准 i n t 值存储为双字( 32 位)。指针(在此用 c har * 表示)存储为 8 字节的四字, 64 位机器本来就预期如此。x86-64 中, 数据类型 l ong 实现为 64 位,允 许表示的 值范围较大。本章代码示 例中的大部分都使 用了指针和 l ong 数据类型, 所以都是四字操作。x86-64 指令集同 样包括完 整的 针对字节、字和双字的指令。\nC 声明 Intel 数据类型 汇编代码后缀 大小(字节) char 字节 b 1 short 字 w 2 int 双字 1 4 long 四字 q 8 char* 四字 q 8 float 单精度 s 4 double 双精度 1 8 图 3-1 C 语言数据类型在 x86-64 中的大小。在 64 位机器中 , 指针长 8 字节\n浮点 数主要有 两种形式 : 单精度 ( 4 字节)值, 对应于 C 语言数据类型 fl oa t ; 双精度\n(8 字节)值, 对应千 C 语言数据类型 d oub l e 。x86 家族的微处理器历史上实现过对一种特殊的 80 位(1 0 字节)浮点格式进行全套的浮点 运算(参见家庭作业 2. 86) 。可以 在 C 程序中用声明 l ong do ub l e 来指定这种格 式。不过我们不建议使用 这种格式 。它不能移植到其他类型的机器上,而且实现的硬件也不如单精度和双精度算术运算的高效。\n如图所示, 大多数 GCC 生成的 汇编代码指令都有一个字符的后缀, 表明 操作数的大小。例如,数据传送指令有四个变种: mov b ( 传送字节)、mov w ( 传送字)、mov l ( 传送双字)和movq ( 传送四字)。后缀'口用来表示双字, 因为 32 位数被看成是“长字 ( l o ng w or d) \u0026quot; 。注意, 汇编代码也 使用后缀\u0026rsquo; l \u0026rsquo; 来表示 4 字节整数 和 8 字节双精度浮点 数。这不会产生歧义,因为浮点数使用的是一组完全不同的指令和寄存辞。\n3. 4 访问信息\n一个 x86-64 的中央处理单元( CPU ) 包含一组 16 个存储 64 位值的通 用 目 的寄存 器。这些寄存器用来存储 整数数 据和指针。图 3-2 显示了这 16 个寄存器。它们的名字都以%r 开头,不过后面还跟着一些不同的命名规则的名字,这是由于指令集历史演化造成的。最 初的 8086 中有 8 个 16 位的寄存器, 即图 3-2 中的%a x 到%bp 。 每个寄存器都有特殊的用\n途, 它们的名字就反映 了这些不同的用途。扩展到 IA32 架构时, 这些寄存器也扩展成 32 /\n位寄存器, 标号从%e a x 到%e bp 。 扩展到 x86-64 后,原 来的 8 个寄存器扩展成 64 位, 标\n号从 r% a x 到%r bp 。除此之外 , 还增加了 8 个新的寄存器, 它们的 标号是按照新的命名规\n则制定的 :从 %r 8 到%r 1 5。\n31\n%eax\n%ebx [%bx\n7 0\n雪返回值\n二 l 被调用者保存\n毛r d i\n%r bp\n%ecx\n%edx\n毛e s i\nI%bp\n二 | 第4 个参数\n二二]第3个参数三 l 第2个参数工 二 l 第1个参数\n三|被调用者保存\n% r s p %esp\n%r 8 %r8d\n%r9d [%r9w\n%r10d [%rl0w\n%r ll [ %r l l d [ %rllw\n%r l 2 %rl2d [ %rl2w\n%r l 3 %r 1 3d [%rl3w\n%r l 4d [%rl4w\n%rl5d [%r15w\n三 l 栈指针\n二 l 第5个参数\n三 | 第 6 个 参 数\n二 l 调用者保存\n匡 l 调用者保存\n三 l 被调用者保存\n二|被调用者保存严 l 被调用者保存三 l 被调用者保存\n图 3- 2 整数 寄存器。所有 16 个寄存器的低位部分都可以作为字节、字(1 6 位)、双字( 32 位)和四字( 64 位)数字来访问\n如图 3-2 中嵌 套的方框标 明的, 指令可以 对这 16 个寄存器的低位字节中存放的不同大小的数据进行操作。字节级操作可以访问最低的字节 , 1 6 位操作可以访问最低的 2 个字节, 32 位操作 可以访问最低的 4 个字节 , 而 64 位操作 可以访问整个寄存 器。\n在后面的章 节中, 我们会展现很 多指令, 复制和生成 1 字节、2 字节、4 字节和 8 字节值。当这些指令以寄存器作为目标 时, 对于生成小 于 8 字节结果的指令, 寄存器中剩下的字节会怎 么样, 对此有两条规则: 生成 1 字节和 2 字节数字的 指令会保持剩下的字节不变; 生成 4 字节数字的指令会把高位 4 个字节置为 0 。后面这条规则是作为从 IA32 到x86-64 的扩展的一部分 而采用的 。\n就像图 3-2 右边的解释说 明的那样, 在常见的程序里不同的寄存器扮演不同的角色。其中最特别的是栈指针%r s p , 用来指明运行时栈的结束位置。有些程序会明确地读写这个寄存器。另外 15 个寄存器的用法更灵活。少量指令会使用某些特定的寄存器。更重要 的\n是,有一组标准的编程规范控制着如何使用寄存器来管理栈、传递函数参数、从函数的返回值, 以及存储 局部和临时数据。我们会 在描述过程的实现时(特别是在 3. 7 节中), 讲述这些惯例。\n3. 4. 1 操作数指示符\n大多数指令有 一个或多个操作数( o p e ra n d ) , 指示出执行一个操作中要使用的源数据值,以及放 置结果的 目的位置。 x86-64 支持多 种操作数格式(参见图 3-3 ) 。源数据值可以以常数形式 给出 , 或是从寄存器或内存中读出。结果 可以 存放在寄存器或内存中。因此, 各种不同的 操作数的 可能 性被分为三种类型。第一种类型是立 即数( im m e d ia t e ) , 用来表示常数 值。在 A T T 格式的汇编代 码中, 立即数的书写 方式是`$'后面跟一 个用标准 C 表示法 表示的整数 , 比如, $ - 5 77 或$0x1 F。不同的指令允许的立即数值范围不同, 汇编器会自动选 择最紧凑的 方式进行 数值编码。第二种类型是寄存 器 ( r eg is t e r ) , 它表示某个寄存器的 内容, 1 6 个寄存器的低位 1 字节、2 字节、4 字节或 8 字节中的一个作为操作数,这些字节 数分别 对应于 8 位、16 位、32 位或 64 位。在图 3-3 中, 我们用符号r a 来表示任意寄存器 a , 用引用 R[r a] 来表示它的值, 这是 将寄存 器集合看成一个数组 R , 用寄存器标识 符作为索引。\n第三类操作数是内存引用,它会根据计算出来的地址(通常称为有效地址)访问某个内 存位置。 因为将内存 看成一个很大的字节数组, 我们用符号 凶[ Ad d r ] 表示对存储在内 存中从地址 Ad d r 开始的 b 个字节值 的引用。为了 简便, 我们 通常省去下标 b。\n如图 3-3 所示 , 有多种不同的寻址模 式,允 许不同形式的内存引用。表中底部用语法\nImm(rb, r;, s) 表示的 是最常用的形式。这样的引用有四个组成部分: 一个立即数偏移\nImm, 一个基址寄存器r b\u0026rsquo; 一个变址寄存器r ,和一个比例因子 s , 这里 s 必须是1、2、4 或者\n8。基址和变址寄存器都必须是 64 位寄存器。有效地址被计算为 I m m + R[r b] + R[r ;] • s。引用数组元素时,会用到这种通用形式。其他形式都是这种通用形式的特殊情况,只是省略 了某些部分 。正如我们 将看到的 , 当引用数组 和结构元素时 , 比较复杂的寻址模式是很有用的。\n类型 格式 操作数值 名称 立即数 $Imm Imm 立即数寻址 寄存器 ra R[r. ] 寄存器寻址 存储器 Imm M[Imm] 绝对寻址 存储器 (r.) M[R[r。]] 间接寻址 存储器 lmm(r.) M[Imm+R[r.]] (基址+偏移量)寻址 存储器 (rb, r;) M[R[r.J+R[r,]] 变址寻址 存储器 Jmm(r., r,) M[Jmm+R[r.]+R[r,]] 变址寻址 存储器 (,r,, s) M[R[r;) · s] 比例变址寻址 存储器 /mm(,r,,s) M[/mm+R[r,J · s] 比例变址寻址 存储器 (rb, r;,s) M[R[rb ]+R[r;] · s] 比例变址寻址 存储器 Imm(r b, r,, s) M[/mm+R[r.]+R[r,]·s] 比例变址寻址 图 3-3 操作数格式 。操作数 可以 表示立即数(常数)值、寄存器 值或是 来自内存的值 。比例因子 s 必须 是 1、2、4 或者 8\n霆 练习题 3. 1 假设下面的值存放在指明的内存地址和寄存器中:\n寄存器 值\n%r a x\n%r c x\nOxlOO Oxl\n填写下表,给出所示操作数的值:\n%r dx Ox3\n操作数 值 令r a x Ox104 $0xl08 ( %r a x ) 4(%rax) 9(%rax, 毛r dx ) 260 (%rcx, 号r dx ) OxFC (, %r c x , 4) ( % r a x, %r d x, 4) 3. 4. 2 数据传送指令\n最频繁使用的指令是将数据从一个位置复制到另一个位置的指令。操作数表示的通用性使得一条简单的数据传送指令能够完成在许多机器中要好几条不同指令才能完成的功 能。我们会介绍多种不同的数据传送指令,它们或者源和目的类型不同,或者执行的转换不同,或者具有的一些副作用不同。在我们的讲述中,把许多不同的指令划分成指令类, 每一类中的指令执行相同的操作,只不过操作数大小不同。\n图 3-4 列出的是最简单形式的数据传 送指令- MOV 类。这些指令把数据从 源位 置复制到目 的位 置, 不做任 何变 化。MOV 类 由 四条 指令 组 成: mov b 、 mov w、 mov l 和mov q 。 这些指令都 执行同样的操作; 主要区别在于它们操作的数据大小不同: 分别是 l 、\n2、 4 和 8 字节。\n指令 效果 描述 MOV S, D D+-S 传送 movb R 壬 I 传送字节 rnovw 传送字 movl 传送双字 movq movabsq I, R 传送四字 传送绝对的四字 图 3- 4 简单的数据传送指令\n源操作数指定的值是一个立即数,存储在寄存器中或者内存中。目的操作数指定一个位置, 要么是一个寄存器或者 , 要么是一个内存地址。x86-64 加了一 条限制, 传送指令的两个操作数不能都指向内存位置。将一个值从一个内存位置复制到另一个内存位置需要两条指令—— 第一条指令 将源值加载到寄存 器中, 第二条将该寄存 器值写 人目的位置。参考图 3-2 , 这些指令的寄存 器操作数 可以是 16 个寄存器有标号部分中的任意一个 , 寄存器部\n分的大小必须与指令最后一个字符( \u0026rsquo; b\u0026rsquo; , \u0026rsquo; w\u0026rsquo; , \u0026rsquo; l \u0026rsquo; 或 \u0026rsquo; q \u0026rsquo; ) 指定的大小 匹配。大多数情况中, MOV 指令只会更新目的操作数指定的那些寄存器字节或内存位置。 唯一的例外是mov l 指令以 寄存器作 为目的 时, 它会把该寄存器的高 位 4 字节设置为 0 。造成这个 例外的原因是 x 8 6- 6 4 采用的 惯例, 即任何为寄存 器生成 3 2 位值的指令都会把该寄存器的高位部\n分置成 0。\n下面的 MOV 指令示例给出了源和目的类型的 五种可能的组合。记住 , 第一个是源操作数,第二个是目的操作数:\nmovl $0x4050,%eax movw %bp,%sp\nmovb (%rdi,%rcx),%al movb $-17, (%rsp) movq %rax,-12(%rbp)\nImmedi a t e - - Regi s t er , 4 bytes Register\u0026ndash;Register, 2 bytes Memor y 一 Regi s t er , 1 byte\nImmediate\u0026ndash;Memory, 1 byte\nRegister\u0026ndash;Memory, 8 bytes\n图 3- 4 中记录的 最后一条指令是处 理 6 4 位立即数数据的。常规的 mo v q 指令只能以表示为 3 2 位补码数字的 立即数作为源操作数 , 然后把这个值 符号扩展得 到 6 4 位的值, 放到目的位置 。mo v a b s q 指令能够以任意 6 4 位立即数值作 为源操 作数, 并且只能以寄存器作为目的。\n图 3- 5 和图 3- 6 记录的是 两类数 据移动指令, 在将 较小的源值 复制到较 大的目的时使\n用。所有这些指 令都把数据从源(在寄存器或内 存中)复制到目的寄存器。MOVZ 类中的指令把目 的中剩余的字节填充 为 o , 而 MOVS 类中的指令通过符号 扩展来填 充, 把源操作\n的最高位进行复制。可以观察到,每条指令名字的最后两个字符都是大小指示符:第一个 字符指定源的大小,而第二个指明目的的大小。正如看到的那样,这两个类中每个都有三 条指令 , 包括了所有的 源大小 为 1 个和 2 个字节、目的 大小为 2 个和 4 个的 情况 , 当然只考虑目的大千源的情况。\n指令 效果 描述 MOVZ S, R R- 零扩展 ( S ) 以零扩展进行 传送 mov zb w mo v zbl movzwl movzbq movzwq 将做了笭扩展的 字节传 送到字将做了零扩展的 字节传送到 双字将做了零扩展的 字传送 到双字将做了笭 扩展的字节传送 到四字 将做了零扩展的 字传送 到四字 图 3-5 零扩展数据传送指令。这些指令以寄存器或内存地址作为源,以寄存器作为目的\n指令 效果 描述 MOVS S, R R- 符号扩展 (S ) 传送符号扩展的字节 movsbw movsbl movswl movsbq movswq movslq cltq 告r a x - 符号扩展(告ea x) 将做了符号扩展的字节传送到字 将做了符号扩展的字节传送到双字将做了符号扩展的字传送到 双字将做了符号扩展的字节传送到四字将做了符 号扩展的 字传送到四字 将做了符号扩展的双字传送到四字 把%ea x 符号扩展到 r% a x 图 3-6 符号扩展数据传送指令。MO VS 指令以 寄存器或内 存地址作 为源 ,以 寄存器作为目的 。c l t q 指令只作用于寄存器 %e a x 和 %r a x\n因日 理解数据传送如何 改变目的寄存器\n正如我们描述的那样,关于数据传送指令是否以及如何修改目的寄存器的高位字节有两种不同的方法。下面这段代码序列会说明其差别:\nmovabsq $0x0011223344556677, %rax ¾rax = 0011223344556677\nmovb $-1, %al ¾rax = 00112233445566FF mo四 $- 1 , %ax ¾rax = 001122334455FFFF movl $-1, %eax ¾rax = OOOOOOOOFFFFFFFF movq $-1, %rax ¾rax = FFFFFFFFFFFFFFFF 在接下来的讨论中 , 我们使用十六进制表示 。在这个例子中 ,笫 1 行的指令把寄存器%\nr a x 初始化 为位 模式 0011 223344556677 。剩下的指令的 海操作数值是立即数值 一1 。回想一\n1 的十六进制表 示形如 FF… F, 这里 F 的数量是 表述中 宇 节数量的 两倍。因此 movb 指令\n(第 2 行)把%r a x 的低位宇节设 置为 F F, 而 mo vw 指令(第3 行)把低2 位字节 设置为 FFFF, 剩下的 宇节保持 不 变。 mov l 指令(第 4 行)将低4 个宇 节设置为 FFFFFFFF, 同 时把 高位 4 宇节设 置为 00000000 。最后 movq 指令(第5 行)把整个寄存器设置为 FFFFFFFFFFFFFFFF。\n注意图 3-5 中并没有一 条明确的指令把 4 字节源值 零扩展 到 8 字节目的。这样的 指令逻辑上应该被命名为 mo v z l q , 但是并没有这样的指令。不过,这样的数据传送可以用以寄存器为目的的 mov l 指令来实现。这一技 术利用的属性是, 生成 4 字节 值并以寄存器作为目的 的指令会把高 4 字节置为 0。对于 64 位的目标, 所有三种源类 型都有对应的符号 扩展传送,而只有两种较小的源类型有零扩展传送。\n图 3-6 还给出 c l 七q 指令。这条指令 没有操作数: 它总是以寄存器%e a x 作为源,%r a x 作为符号扩展结果的目的。它的效果与指令 mov s l q %eax, %r a x 完全一致 , 不过编码更紧凑。\n; 练习题 3. 2 对于下面汇编代码的每一行,根据操作数,确定适当的指令后缀。(例 如, mov 可以 被 重写成 mo v b 、 mo v w、 mo v l 或者 mo v q 。)\nmov_ mov— mov\n%eax, (%rsp) (%rax), %dx\n$0xFF, %bl\nmov_\nCir儿\ns p , %r dx , 4) , %dl\nmov_ mov\n(%rdx), %rax\n%dx, (%rax)\n日 字节传送指令比较\n下面这个示例说明了不同的数据传送指令如何 改变或者不改 变目的的 高位宇节。仔细观\n察可以发现, 三个字节传送指令 movb 、 movs bq 和 mov zbq 之间有细微的差别。 示例如下 :\nmovabsq $0x0011223344556677, %rax movb $0xAA, %dl\nmovb %dl,%al movsbq %dl,%rax movzbq %dl,%rax\n¼rax = 0011223344556677 7.dl = AA\n¼rax = 00112233445566AA\n¼rax = FFFFFFFFFFFFFFAA 7.rax = OOOOOOOOOOOOOOAA\n在下 面的 讨论中,所有的值都使 用十六进制 表示。代码的 头 2 行将寄存 器%r a x 和%dl分别初始化 为 0011223344556677 和 AA。 剩下的 指令都是将%r dx 的低位宇 节复 制到 %r a x的低位 宇节。 movb 指令(笫3 行)不改 变其 他宇节。根据源宇节的 最高位, mov s bq 指令(第4 行)将其他 7 个宇节设为全 1 或全 0。由于十六进制 A 表示二进制值 1 01 0, 符号扩展会把高位宇节都设 置为 F F。mov zbq 指令(笫 5 行)总是将其他7 个字节全都设 置为 0 。\n讫 练习题 3. 3 当我们调用汇编器的时候,下面代码的每一行都会产生一个错误消息。 解释每一行都是哪里出了错。\nmovb $0xF, (%ebx) movl %rax, (%rsp) movw (%rax),4(%rsp) movb %al,%s1\nmovq %rax,$0x123 movl %eax,%rdx movb %si, 8(%rbp)\n4. 3 数据传送示例\n作为一个使用数 据传送 指令的 代码示例 , 考虑图 3-7 中所示的数据交换函数, 既有 C\n代码 , 也有 GCC 产生的汇编代码 。\nlong exchange(long *xp, long y)\n{\nlong x = *xp;\n*xp = y; return x;\n}\nC语言代码 long exchange(long•xp, long y)\nxp 江 肚 d工, y 卫1 %rsi exchange:\nmovq (%rdi), %rax\nmovq %rsi, (%rdi) ret\nGet x at xp. Set as return val ue . Store y at xp\nRet ru n .\nb ) 汇编代码\n图 3-7 exch ange 函数的 C 语言和汇 编代码。寄存器 r% 中 和r%\ns i 分别 存放参数 xp 和 y\n如图 3-7 b 所示, 函数 e x c h a ng e 由三条指令实现: 两个数据传送 C mov q ) , 加上一条返回函数 被调用点 的指令 Cr e t ) 。 我们 会在 3. 7 节中讲述函数调用和返回的细节。在此之前,知道参数通过寄存器传递给函数就足够了。我们对汇编代码添加注释来加以说明。函数通过把值存储 在寄存器 %r a x 或该寄存器的某个低 位部分 中返回。\n当过程开始 执行时 , 过程参数 xp 和 y 分别存储在寄存器%r d i 和%r s i 中。 然后, 指 令 2 从内存中读出 x , 把它存放 到寄存 器%r a x 中, 直接实现了 C 程序中的 操作 x=*xp。稍后, 用寄存器%r a x 从这个函数返回一个值, 因而返回值就是 x。指令 3 将 y 写入到寄存器%r d i 中的 x p 指向的内存位置, 直接实 现了操作*x p =y。这个例子说明了如何用 MOV 指令从内 存中读值到寄存 器(第2 行), 如何从 寄存器写到内存(第 3 行)。\n关于这段汇编代码有 两点值得注意。首先, 我们看到 C 语言中所谓的 ”指针” 其 实就是地址。间接引用指针就是将该指针放在一个寄存器中,然后在内存引用中使用这个寄存 器。其次 , 像 x 这样的局 部变量通常 是保存在寄存器中 , 而不是内 存中。访问 寄存器比访问内存要快得多。\n芦 练习题 3 . 4 假设 变量 s p 和 d p 被声明 为 类型\nsrc_t *sp; dest_t *dp;\n这里 sr c _ t 和 d e s t _ 七 是用 t y p e d e f 声明 的数 据类型。 我们 想使 用 适 当的数据传 送指令来实现 下 面的操作\n*dp = (dest_t) *sp;\n假设 s p 和 d p 的值分别存储在寄 存器 %r d i 和%r s i 中。 对千表 中的 每个表 项,给出实现指定 数据传 送的 两条指令。其中第 一条指 令应该从内存 中读 数, 做适 当的 转换,并设置寄存器 %r a x 的适 当部 分。 然后, 第二条 指令 要把 %r a x 的 适 当部 分写到内存。 在这两种情况中 , 寄存器的部分可以是 %r a x 、%e a x 、%a x 或 %a l , 两者可以互不相同。\n记住 , 当 执行 强制类型 转换 既 涉及 大小 变化又 涉及 C 语言中符号 变化 时 , 操作 应该先 改 变大 小( 2. 2. 6 节)。\nsrc t de s 七 七 指令 long long mo vq ( 号r di ) , 号r a x movq %r a x, 伐r s i ) char int char unsigned unsigned char long int char unsigned unsigned char char short 一 指针的一些示例\n函数 e x c h a n g e ( 图 3- 7a ) 提供了一 个关 于 C 语言中指针使 用的 很好说明。参数 x p 是一 个指向 l o n g 类型的 整数的指针, 而 y 是一个 l o n g 类型的 整数。语句\nlong x = *xp;\n表示我 们将 读存储在 x p 所指位 置中的 值, 并将它存 放到名 字 为 x 的局部 变量 中。 这个读操 作称 为指 针的间接 引 用 ( po in t er dereferencing), C 操作符* 执行指针的间接 引 用。\n语句\n*XP = y;\n正好相反 它将 参数 y 的值 写到 x p 所指的 位置。这也是 指针 间接 引用的 一种形式(所以有操作符*),但是它表明的是一个写操作,因为它在赋值语句的左边。\n下 面是调用 e x c h a ng e 的一个实际例 子:\nlong a= 4;\nlong b = exchange(\u0026amp;a, 3);\nprintf(\u0026ldquo;a = %ld, b = %ld\\n\u0026rdquo;, a, b);\n这段代码会打印出:\na= 3, b = 4\nC 操作符 &(称为“取 址” 操作符)创建一个指针 , 在本例 中, 该指针 指向保存局 部 变量 a 的位置。 然后 , 函数 e x c ha nge 将用 3 覆盖存储在 a 中的 值, 但是返回原来的 值 4 作为函数的值。 注意如何将指针传递给 e xc ha ng e , 它能修改存在某个远处位置的数据。\n区 练习题 3. 5 已知信息如下。将一个原型为\nvoid decodel(long•xp, long *YP, long•zp);\n的函数编译成汇编代码,得到如下代码:\nvo1d decode1 (long *xp, long *YP, long *zp) xp in¼rdi , yp in¼rsi , zp in¼rdx\ndecode!:\nmovq (%rdi), %r8\nmovq (%rsi), %rcx\nmovq (%rdx), %rax\nmovq %r8, (%rsi) movq %rcx, (%rdx) movq %rax, (%rdi) ret\n参数 x p 、 y p 和 z p 分别存 储在对 应的寄 存器 %r d i 、%r s i 和%r d x 中。\n请写 出 等效于 上 面 汇编 代码 的 d e c o d e l 的 C 代码。\n3. 4. 4 压入和弹出栈数据\n最后两个数据传送操作可以 将数据压入程序栈 中,以 及从程序栈 中弹出数据, 如图 3-8 所示。正如我们将看到的,栈在处理过程调用中起到至关重要的作用。栈是一种数据结 构, 可以添加或者 删除值, 不过要遵循 ”后进先 出” 的原则。通过 p us h 操作把数据压入栈中 , 通过 po p 操作删除数据; 它具有一个属性: 弹出的 值永远是最 近被压 入而且仍然在栈中的值。栈可以实现为一个数组,总是从数组的一端插入和删除元素。这一端被称为栈顶 。在 x86-64 中, 程序栈存 放在内 存中某个区域。如图 3-9 所示, 栈向下增长, 这样一来,栈顶元素的地址是所有栈中元素地址中最低的。(根据惯例,我们的栈是倒过来画的,栈 "顶” 在图的底部。)栈指针%r s p 保存着栈顶元 素的地址。\n图 3-8 入栈和出栈指令\np u s hq 指令的功能是 把数据压入到栈上 , 而 p o p q 指令是弹出 数据。这些指令都只有一个操作数 一一 压入的数 据源和弹出的 数据目的 。\n将一个四字值 压入栈中, 首先要将栈指针减 8 , 然后将值写到新的栈顶地址。因此, 指令 p u s h q %r b p 的行为等价 于下面两条指 令:\nsubq $8,%rsp movq %rbp, (%rsp)\nDecrement stack pointer Store 7.rbp on stack\n它们之间的区别是在机器代码中 p us hq 指令编码为 1 个字节 , 而上面那两条指令一共需 要\n8 个字节。图 3-9 中前两栏给出的是, 当%r s p 为 Ox1 08 , %r a x 为 Ox1 23 时, 执行指令pushq %r a x 的效果。首先%r s p 会减 8 , 得到 Ox l OO, 然后会将 Ox1 23 存放到内存地址Ox l OO 处 。\n最初\n%rax\npushq %rax\npopq %rdx\n%rdx\n¾rsp\n栈"底” 栈"底” 栈"底”\n地址增大\nOxl08 Oxl08 Ox108\n栈"顶” Ox!OO\nOxl23\n栈“顶”\nOxl23\n栈“顶”\n图 3-9 栈操作说明 。根据惯例, 我们的栈是倒过来画的, 因而栈 "顶” 在底部。x86-64 中, 栈向低地址方向增长, 所以压栈是减小栈指针(寄存器%r s p) 的值, 并将数据存放到内存中,而出栈是从内存中读数据,并增加栈指针的值\n弹出一个四字的操作包括从栈顶位 置读出数 据, 然后将栈指针加 8。因此, 指令 p op q\n%r a x 等价千下面两条指令 :\nmovq (%rsp),%rax addq $8,%rsp\nRead 7.rax from stack Increment stack pointer\n图 3-9 的第三栏说明 的是在执行 完 p us hq 后立即执行 指令 po pq %r d x 的效果。先从内存中读出值 Ox1 23 , 再写到寄存器%r d x 中, 然后, 寄存器%r s p 的值将增加回到 Ox10 8 。如图中所示, 值 Ox1 23 仍然会保持 在内存位置 Ox l OO 中, 直到被覆盖(例如被另一条入栈操作覆盖)。无论如何,% rs p 指向的 地址总是栈顶 。\n因为栈和程序代码以及其他形式的程序数据都是放在同一内存中,所以程序可以用标 准的内存寻址方法访问栈内的任意位置。例如, 假设栈顶元素是四字, 指令 mov q 8 (% rsp), %r d x 会将第二个四字从栈中复制到寄存 器%r d x 。\n3. 5 算术和逻辑操作\n图 3-10 列出了 x86-64 的一些整数 和逻辑操作。大多数操作都分成了指令类, 这些指令类有各种带不同大小操作数的变种(只有 l e a q 没有其他大小的变种)。例如, 指令类\nADD 由四条加法指令组成: a d db 、 a d d w、a d d l 和 a d d q , 分别是字节加法、字加法、双字加法和四字加法。事实上,给出的每个指令类都有对这四种不同大小数据的指令。这些\n操作被分为四组:加载有效地址、一元操作、二元操作和移位。二元操作有两个操作数, 而一元操 作有一个操作数 。这些 操作数的描 述方法与 3. 4 节中所讲的一样。\n指令 效果 描述 leaq S,D D 七 - \u0026amp;S 加载有效地址 INC DEC NEG NOT D D D D D 七 - D+l D 仁 D - 1 D ..\u0026ndash;D D\u0026ndash;D 加 1 减 l 取负 取补 ADD SUB IMUL XOR OR AND S,D S,D S,D S,D S,D S,D D 七 D + S D 七 D - S D 七 - D * S v-v-s D 仁 D I S D\u0026lt;-D\u0026amp;S 加 减 乘 异或或 与 SAL SHL SAR SHR k,D k,D k, D k,D D-D«k D 七 - D«k D 七 D »A k D-D»ik 左移 左移(等同于SAL ) 算术右移 逻辑右移 图 3-10 整数算术操作。加 载有效地址 ( l eaq) 指令通常用来执行简单的算术操作。其余的指令 是更加标准的一元或二元操作。我们用\u0026gt; \u0026gt; A 和 \u0026gt; \u0026gt; L 来分别 表示算术右移 和逻辑 右 移。注意 ,这 里 的 操 作 顺 序 与 AT T 格式的汇编代码中的相反\n3. 5. 1 加载有效地址\n加栽有效地 址O oa d effective address ) 指令 l e a q 实际上是 mo v q 指令的变形。它的指令形式是从内存读数据到寄存器,但实际上它根本就没有引用内存。它的第一个操作数看上去是一个内存引用,但该指令并不是从指定的位置读入数据,而是将有效地址写人到目的操作数。在 图 3-10 中我们用 C 语言的地址操作符 \u0026amp;S 说明这种计算 。这条指令可以 为后面的内存引用产生指针。另外,它还可以简洁地描述普通的算术操作。例如,如果寄存器%r d x 的值为 x , 那么指令 l e a q 7 ( %r d x , %rdx, 4), %r a x 将设 置寄 存器%r a x 的值为 5x +\n7。编译器经常 发现 l e a q 的一些灵活用法 , 根本就与有效地址计算无关 。目的操作数必须是一个寄存器。\n为了说明 l e a q 在编译出的 代码中的使用 , 看看下 面这个 C 程序 :\nlong scale(long x, long y, long z) { long t = x + 4 * y + 12 * z; return t;\n编译时 , 该函数的算术 运算以 三条 l e a q 指令实现, 就像右边 注释说明 的那样:\nlong scale(long x, long y, long z)\nX 立 加 di , y in¼rsi , z in¼rdx scale:\nleaq (o/.rdi,o/.rsi,4), o/.rax X + 4*y leaq (o/.rdx,o/.rdx,2), o/.rdx Z + 2*z = 3*Z leaq (o/.rax,o/.rdx,4), o/.rax (x +4*y) + 4* (3*z) = x + 4*y + 12*z ret l e a q 指令能执行加法和有限形式的乘法, 在编译如上简单的算术表达式时 , 是很有用处的。\n芦 练习题 3. 6 假设寄 存器 %r a x 的 值 为 x , %r c x 的 值 为 y 。 填 写 下表, 指明 下 面每条 汇编代 码指令 存储在寄 存器 %r d x 中的值 :\n表达式 结果 leaq 6 ( %ax ) , r% dx leaq (r% ax, r% cx ) , r% dx leaq (r% a x, r沦 cx, 4) , r毛 dx leaq 7 (%r a x, % r ax, 8) , % r dx leaq OxA(,%rcx,4), r令 dx leaq 9 ( 毛r ax, r% cx , 2), r毛 dx 沁 义 练习题 3. 7 考虑下面的代码,我们省略了被计算的表达式:\nlong scale2(long x, long y, long z) { long t = return t;\n}\n用 GCC 编译 实际的 函 数得到 如下的 汇编代码 :\nlong scale2(long x, long y, long z)\nx in r7. di , y in 7.rsi , z in scale2:\nr7. dx\nleaq leaq leaq ret\n(%rdi,%rdi,4), %rax (%rax,%rsi,2), %rax (%rax,%rdx,8), %rax\n填写 出 C 代码 中缺 失的 表达 式。\n3. 5. 2 一元和二元操作\n第二组中的操作是一元操作,只有一个操作数,既是源又是目的。这个操作数可以是一个寄存 器, 也可以是一个内 存位置。比如说, 指令 i n c q ( %r s p ) 会使栈顶的 8 字节元 素加 1。这种语法让人想起 C 语言中的 加 1 运算符 \u0026lt;+ + ) 和减 1 运算符(一—)。\n第三组是 二元操作, 其中, 第二个操作数既是源又是目的 。这种语法让人想起 C 语言中的赋值运算符, 例如 x - =y 。不过, 要注意 , 源操 作数是第一个,目 的操作数是第二个, 对千不可交换操作来说 , 这看上去 很奇特。例 如, 指令 s u b q %r a x , %r d x 使寄存器%r d x 的值减去 %r a x 中的值。(将指令解读成“从%r d x 中 减去%r a x \u0026quot; 会有所帮助。)第一个操作数可以是立即数、寄存器或是内存位置。第二个操作数可以是寄存器或是内存位置。注意, 当第二个操作数为内存地址时,处理器必须从内存读出值,执行操作,再把结果写回 内存。\n; 练习题 3. 8 假设下面的值存放在指定的内存地址和寄存器中:\n寄存器 值\nr号 a x OxlOO\n毛r c x Oxl\n告rd x Ox3\n填写下表,给出下面指令的效果,说明将被更新的寄存器或内存位置,以及得到\n的值:\n指令 目的 值 addq %rcx, 伐rax ) subq %rdx, 8 (r% ax ) 耳 nul q $16, 伐r ax, 毛r dx, 8) incq 16 (r% ax ) decq r% cx subq 号r dx, r% ax 3. 5. 3 移位操作\n最后一组是 移位操作 ,先 给出移位量, 然后第二项给出的 是要移位的 数。可以 进行 算术和逻辑右移。移位量可以 是一个立即数, 或者放在单字节寄存器% c l 中。(这些指令很特别 , 因为只允 许以这个特定的寄存器作 为操作 数。)原则上来说, 1 个字节的移位量使得\n移位量的 编码范围 可以达到 28 — 1 = 255 。x86- 64 中, 移位操作对 w 位长的 数据值进行 操\n作, 移位量是由 %c l 寄存器的低 m 位决定的, 这里 2\u0026rsquo;\u0026quot;= w。高位会被忽略。所以, 例如当寄存器 %c l 的十六进制值 为 Ox FF 时, 指令 s a l b 会移 7 位, s a l w 会移 15 位, s a l l 会移\n31 位, 而 s a l q 会移 63 位。\n如图 3-10 所示 ,左 移指令有两个 名字: S AL 和 S HL。两者的效果是一样的, 都是将右边填上 0。右移指 令不同 , S AR 执行算术移位(填上符号位), 而 SHR 执行逻辑 移位(填上0) 。移位操作的目 的操作数可以 是一个寄存器或是一个内存位置。图 3-10 中用\u0026gt;\u0026gt;A (算\n术)和>>凶逻辑)来表示这两种不同的右移运算。\n练习题 3. 9 假设 我们 想生成以 下 C 函 数的 汇编代 码 :\nlong shift_left4_rightn(long x, long n)\nX \u0026lt;\u0026lt;= 4;\nX \u0026gt;\u0026gt;= n;\nreturn x;\n}\n下 面这 段 汇编代 码执 行 实 际 的 移位 , 并 将最 后 的 结果放在寄 存器%r a x 中。 此处\n省略 了两 条关键 的指令。 参数 x 和 n 分别 存放在寄 存器 %r d i 和%r s i 中。\nlong shi f t _l ef t 4工 i ght n (l ong x, long n)\nX l D r加 di , n in Y.rsi shift_left4_rightn:\nmovq %rdi, %rax Get x\nX \u0026lt;\u0026lt;= 4\nmovl %esi, %ecx Get n (4 byt es )\nX \u0026gt;\u0026gt;= n\n根据右边的注释,填出缺失的指令。请使用算术右移操作。\n3. 5. 4 讨 论\n我们看到图 3-10 所示的大多数指令 , 既可以 用千无 符号 运算 , 也可以 用千补码 运算。\n只有右移操作要求区分有符号和无符号数。这个特性使得补码运算成为实现有符号整数运算的一种比较好的方法的原因之一。\n图 3-11 给出 了一个执行算术操作的函数示例,以 及 它 的 汇编代码。参数 x 、y 和 z 初始时分别存放在内存%r d i 、%r s i 和 %r d x 中 。 汇编代码指令和 C 源代码行对应很紧密。第 2行计算 x \u0026quot; y 的值。指令 3 和 4 用 l e a q 和移位指令的组合来实现表达式 z * 48 。第 5 行 计算 七1 和 Ox OF OF OF OF 的 AND 值 。第 6 行 计算 最 后 的 减法。由于减法的目的寄存器是%\nrax, 函数会返回这个值。\nlong arith(long x, long y, long z)\n{\nlong ti= x y; long t2 = z * 48;\nlong t3 = ti \u0026amp; OxOFOFOFOF; long t4 = t2 - t3;\nreturn t4;\nC语言代码 3 leaq (%rdx,%rdx,2), %rax 3*Z 4 salq $4, %rax t2 = 16 * (3*z) = 48*Z 5 andl $252645135, %edi t3 = t1 \u0026amp; OxOFOFOFOF 6 subq %rdi, %rax Return t2 - t3 7 ret b ) 汇编代码\n图 3-11 算术运算 函数的 C 语言和汇编代 码\n在图 3-11 的汇编代码中,寄 存 器 %r a x 中 的 值 先 后 对 应于程序值 3 * z 、 z * 48 和 t 4( 作为返回值)。通常,编译器产生的代码中,会用一个寄存器存放多个程序值,还会在寄存 器之间传送程序值。\n沁氐 练习题 3. 10 下 面的 函 数是 图 3- ll a 中 函 数 一个 变种 , 其 中有些表达式用 空 格替 代 :\nlong arith2(long x, long y, long z)\n{\nlong t1 = long t2 = _ long t3 = long t4 = return t4;\n}\n实现这些表达式的汇编代码如下:\nlong arith2(long x, long y, long z) x i n ¼r d工, y 工n r¼s 工, z 工丑 肚 dx\narith2:\norq %rsi, %rdi\nsarq $3, %rdi\nnotq %rdi\nmovq ir儿\ndx , %rax\nsubq %rdi, %rax ret\n基于这 些 汇编代码 , 填写 C 语言代码 中缺 失的部分。\n练习题 3. 11 常常可以看见以下形式的汇编代码行:\nxorq %rdx,%rdx\n但是在产 生这 段 汇编代 码的 C 代码 中,并 没 有出现 E XC L U S I V E-O R 操作。\n解释这条 特殊的 E XC L U S I V E- O R 指令 的效果 , 它实现 了什 么有用 的操作。 更直接地表达这个操作的汇编代码是什么? 比较同样一个操作的两种不同实现的编码字节长度。 3. 5. 5 特殊的算术操作\n正如我们在 2. 3 节中看到的 , 两个 6 4 位有符号 或无符号 整数相乘得到的 乘积需 要 1 28\n位来表示 。x8 6-64 指令集对 1 28 位(1 6 字节)数的操作提供 有限的支持。延续字 ( 2 字节)、双字( 4 字节)和四字( 8 字节)的命名惯例 , Intel 把 16 字节的 数称为八 宇 ( oct word ) 。图 3-1 2 描述的是 支持产 生两个 64 位数字的 全 1 28 位乘积以 及整数除 法的指令。\n指令 效果 描述 irnulq s mulq s R[ %r dx] : R[ % r ax] - S XR [ r% ax] R[ %r dx] , R[ % r ax] +-S X R[ r% ax] 有符号全乘法无符号全乘法 clto R[ r% dx] : R[r% ax] - 符号扩 展\u0026lt;R[ r% ax] ) 转换为八字 idivq s R[ 毛r dx] - R[ 毛r dx] : R[ r沧 ax] mod S R[ r% dx]- R[ 毛r dx] : R[ r% ax] -c- S 有符号除法 divq s R[ %rdx]-R[r% dx] : R[r% ax] mod S R[ %r dx] - R[ %r dx] , R[%rax]7S 无符号除法 图 3-12 特殊的算术操作 。这些操作提供了有符号 和无符号数的全 128 位乘法和除法。\n一对寄存器 r%\ndx 和 r%\na x 组成一个 128 位的八 字\ni mu l q 指令有两种不同 的形式。其中一种, 如图 3-1 0 所示, 是 IM U L 指令类中的一种。这种形式的 i mu l q 指令是一个“双操作 数" 乘法指 令。它从两个 64 位操作数产生一个 64 位乘积 , 实现了 2. 3. 4 和 2. 3. 5 节中描述的操作 * 4 和 * 4 。(回想一下, 当将乘积 截取到 64 位时, 无符号乘 和补码 乘的位级行为是一样的 。)\n此外 , x8 6- 64 指令集还提供 了两条不 同的“单操作数” 乘法指令,以 计算两个 64 位 值的全 1 28 位乘积 一个是无符号数乘法( mu l q ) , 而另一个是补码乘法( i mu l q ) 。这 两条指令都要求一个参数必须 在寄存器%r a x 中, 而另一个作为指令的源操作数给出。然后乘积存放在寄存 器%r d x ( 高 64 位)和%r a x ( 低 64 位)中。虽然 i mu l q 这个名字可以 用 于两个不同的乘法操作,但是汇编器能够通过计算操作数的数目,分辨出想用哪条指令。\n下面这段 C 代码是一 个示例, 说明了如何从 两个无符号 64 位数字 x 和 y 生成 1 28 位的乘积:\n#include \u0026lt;inttypes.h\u0026gt;\ntypedef unsigned int128 uint128_t;\nvoid store_uprod(uint128_t *dest, uint64_t x, uint64_t y) {\n*dest = x * (uint128_t) y;\n}\n在这个程序中 , 我们显式 地把 x 和 y 声明为 64 位的数字, 使用文件 i n t t yp e s . h 中声明的定义, 这是对标 准 C 扩展的一部分。不幸的是, 这个标 准没有提供 128 位的 值。所以我们只好 依赖 GCC 提供的 1 28 位整数支持 , 用名字_ _ i n tl 28 来声明。代码用 t yp e de f 声明定义了一 个数据类型 u i n t l 28 _ t , 沿用 的 i n t t yp e s . h 中其他数据类型的 命名规律。这段代码指明 得到的 乘积应该 存放在指 针 d e s t 指向的 16 字节处 。\nGCC 生成的 汇编代码 如下 :\nvoid store_uprod(uint128_t *des t , uint64_t x, uint64_t y) des t 工 n r% di , x 耳 1 %rsi , y in %rdx\nstore_uprod:\nmovq 7.rsi, %rax Copy x to multiplicand mulq movq 7.rdx 7.rax, (%rdi) Mult i p l y by y Store lower 8 bytes at dest movq 7.rdx, 8(7.rdi) Store upper 8 bytes at dest+8 ret 可以 观察到, 存储乘积需要两个 mo v q 指令: 一个存储低 8 个字节(第4 行), 一个存储高 8 个字节(第5 行)。由于生成这段 代码针对的 是小端法机器, 所以高位字节存储 在大地址 , 正如地址 8 ( %r d i ) 表明的那样。\n前面的算术 运算 表(图3-10 ) 没有列 出除法或取模 操作。这些操作是由单操作数除法指令来提供的 , 类似于单操作数乘法指令 。有符号除法指令 J.中 v l 将寄存器%r d x ( 高 64 位)和%r a x ( 低 64 位)中的128 位数作 为被 除数, 而除 数作为指 令的操作数给出。 指令将商存储在寄存器 %r a x 中, 将余数存储 在寄存 器%r d x 中。\n对千大多数 64 位除法应用来说 , 除数也常常是一 个 64 位的值。这个值应该存放在%\nr a x 中 ,%r d x 的 位应该设 置为 全 0 ( 无符号运算)或者%r a x 的符号位(有符号运算)。后面这个操作可以用指令 c q 七0 8 来完成。这条指令不需 要操作数一一 它隐含读出 %r a x 的符号位 , 并 将它复 制到 %r d x 的所有位 。\n我们 用下面这个 C 函数来 说明 x86-6 4 如何实现除 法, 它计算了两个 64 位有符号数的商和余数: -;-\nvoid remdiv(long x, long y,\nlong *qp, long *rp) { long q = x/y;\nlongr = x%y;\n*qp = q;\n*rp = r;\n}\n该函数编译得到如下汇编代码:\ne 在 Intel 的 文档中 , 这条指 令叫做 cqo, 这是指 令的 ATT 格式 名字和 Intel 名字无 关的少数情况之一.\nvoi d remdiv(long x, long y, long•qp, long•rp) x in 7.rdi , y in 7.rsi , qp in 7.rdx, rp in 7.rcx remdiv:\nmovq movq cqto idivq movq movq ret\n%rdx, %r8\n%rdi, %rax\n%rsi\n%rax, (%r8)\n%rdx, (%rcx)\nCopy qp\nMove x to lower 8 bytes of dividend\nSign-extend to upper 8 bytes of dividend Di vi de by y\nStore quotient at qp Store remainder at rp\n在上述代码中 ,必须 首先把参数 qp 保存到另一个寄存器中(第2 行), 因为除 法操作要使用参数寄存器 %r d x 。 接下来 , 第 3~ 4 行准备被除 数, 复制并 符号扩展 x 。除法之后,寄存器 %r a x 中的商被保存 在 qp ( 第 6 行), 而寄存 器%r d x 中的余数被保存 在r p ( 第 7 行)。\n无符号除 法使用 d i v q 指令。通常 , 寄存器%r d x 会事先设 置为 0。\n练习题 3. 12 考虑如下 函数, 它计 算 两个 无符 号 64 位数的 商和 余数 :\nvoid urerndiv(unsigned long x, unsigned long y, unsigned long *qp, unsigned long *rp) {\nunsigned long q = x/y;\nunsigned longr = x%y;\n*qp = q;\n*rp = r;\n3. 6\n}\n修 改有符号除 法的 汇编代 码来 实现这个 函数。\n控制 # 到目前为止 , 我们只 考虑了 直线代 码的行为, 也就是指令一条接着一条顺序地执行。\nC 语言中的某些结构 , 比如条件语句、循 环语句和分支语句, 要求有条件的执行 , 根据数据测试的结果来 决定操作执行的顺序。机器代码提供两种基本的 低级机制来实现有条件的行为: 测试数 据值, 然后根据测试的结果来改 变控制流或者数 据流。\n与数据相关的控制流是实现有条件行为的更一般和更常见的方法,所以我们先来介绍 它。通常 , C 语言中的语句和机器代码中的指令 都是按照它们在程序中 出现的次序, 顺序执行的。 用 jum p 指令可以改 变一组 机器代码指 令的执行顺序, jum p 指令指定控制应该被传递到程序的某个 其他部分, 可能是 依赖于某个测试的结果 。编译器必须产生构 建在这种低级机制基础之上的指令 序列, 来实 现 C 语言的控制结构。\n本文会先涉及实 现条件操作的 两种方式 , 然后描 述表达循 环和 s wi t c h 语句的方法。\n6. 1 条件码\n除了整数寄存 器, CPU 还维护着一组单个位的条件码( co nd it io n cod e ) 寄存器, 它们描述了最 近的算术 或逻辑操作的 属性。可以检测这些寄存器来 执行条件分支指令。最常用的条件码有:\nCF: 进位标志 。最近的操作使最高位产生了进位 。可用来 检查无符号操作的溢出 。\nZF: 零标志。最近的操作得出的结果为 0 。\nSF: 符号标志。最近的操作得到的结果为负数。\nOF : 溢出标志。最近的操作导致一个补码溢出 正溢出或负溢 出。\n比如说, 假设我们 用一条 ADD 指令完成 等价 千 C 表达式 t =a + b 的功能 , 这里变簸\na 、b 和 t 都是整型的 。然后 , 根据下 面的 C 表达式来设 置条 件码:\nCF (unsigned) t \u0026lt; (unsigned) a\nZF ( 七 = 0 )\nSF ( 七\u0026lt;0 )\n无符号溢出零\n负数\nOF (a\u0026lt;O==b\u0026lt;O) \u0026amp;\u0026amp; ( 七\u0026lt;0 ! =a\u0026lt;O) 有符号溢出\nl e aq 指令不改变任何 条件码, 因为它是 用来进行 地址 计算的。除此之外, 图 3-10 中列出的所有指令 都会设置条 件码。对千逻 辑操作, 例如 XOR , 进位标志和溢出标志会设置成 0。对千移 位操作 , 进位标 志将设置为最后一个被移出的位, 而溢出标志设置为 0。I NC 和 DEC 指令会设置溢出 和零标志, 但是不会改变进位标志, 至千原 因, 我们就不在这里深入探讨了。\n除了 图 3-10 中的指令会设置 条 件\n码, 还有两类指令(有8、16 、32 和 64 位形式),它们只设置条件码而不改变任何其他寄存器; 如图 3-13 所示。CMP 指令根据两个操作数之差 来设置 条件码。除了只设置条件码而不更新目的寄存器 之外, CMP 指令与 SUB 指令的行 为是一样的。在 AT T 格式中, 列出操作 数的顺序是相反的,这使代码有点难读。如 果两个 操作数相等, 这些指令会将零标志设置为 1 , 而其他的标志可以用来确定两个操作数之间的大小 关系。T EST 指\n令的行为与 AND 指令一样 ,除 了 它们只设置条件码而不改变目的寄存器的值。\n图 3-13 比较和测试指令。这些指令不修改任何\n寄存器的值,只设置条件码\n典型的用法是, 两个操作数是一样的(例如, t e s t q % r a x, %r a x 用来检查%r a x 是负数、零,还是正数),或其中的一个操作数是一个掩码,用来指示哪些位应该被测试。\n6. 2 访问条件码\n条件码通常不会直接读取,常用的使用方法有三种: 1 ) 可以 根据条件码的某种组合, 将一个字节设 置为 0 或者 1 , 2) 可以 条件跳 转到程序的 某个其他的部分, 3 ) 可以 有条 件地传送数据。对于第一种情 况,图 3-1 4 中描述的指令根据条件码的某种组合, 将一个字节设置为 0 或者 1。我们将 这一整类指令 称为 SET 指令; 它们之间的区别就在于它们考虑的条件码的组合是什么 , 这些指令 名字的不同后缀指明了它们 所考虑的条件码的组合。这些指令的后缀表示不同的条件而不是操 作数大小 ,了 解这一点很重要。例如, 指令 s e t l 和s e t b 表示“小于时设 置( set less ) \u0026quot; 和“低 千时设置( set below)\u0026quot;, 而不是“设置长字 ( set long word ) \u0026quot; 和“设置字节 ( set byte) \u0026quot; 。\n一条 SET 指令的目的操作数是 低位单字节寄 存器元素(图3-2 ) 之一, 或是一个字节的内存位置, 指令会将这个 字节设 置成 0 或者 1。为了得到一个 32 位或 64 位结果, 我们必须对高位 清零。一个计算 C 语言表达式 a \u0026lt; b 的典型指令序列如下 所示 , 这里 a 和 b 都是l o ng 类型 :\n指令 同义名 效果 设置条件 sete setne D D setz setnz D D 七七 ZF - ZF 相等/零不等/非零 sets setns D D D D 七七 SF - SF 负 数 非负数 setg setge setl setle D D D D setnle setnl setnge setng D D D D 七七七 仁 ~(SF - OF) \u0026amp; -ZF - (SF - OF) SF - OF (SF - OF) I ZF 大千(有符号>) 大于等于(有符号>=) 小于(有符号<) 小千等于(有符号<=) seta setae setb setbe D D D D setnbe setnb setnae setna D D D D 七七七 七 - CF \u0026amp;-ZF ~CF CF CF I ZF 超过(无符号>) 超过或相等(无符号>=) 低于(无符号<) 低于或相等(无符号<=) 图 3- 1 4 SET 指 令 。 每条指令 根据条件码的某种组合, 将 一 个 字 节 设 置 为 0 或 者 1 。有些指令有“同义名“,也就是同一条机器指令有别的名字\nnt comp(data_t a, data_t b) a in 7.rdi , b in 7.rsi\ncomp:\ncmpq setl movzbl ret\n%rsi, %rdi\n%al\n%al, %eax\nCompare a: b\nSet low-order byte of 7.eax to O or 1 Clear rest of 7.eax (and rest of 7.rax)\n注意 c mpq 指令的比较顺 序(第2 行)。虽然参 数列出的顺 序先是%r s i ( b ) 再是%r d i ( a ) , 实际上比较 的是 a 和 b 。还要记得 , 正如在 3. 4. 2 节中讨 论过的那样 , mo v z b l 指令不仅会把%e a x 的高 3 个字节清零 , 还会把 整个寄 存器%r a x 的高 4 个字节都 清零。\n某些底层 的机器指令可能有多个名字, 我们称之为“同 义名 ( s y no n ym ) \u0026quot; 。比如说, s e t g ( 表示“设置大千\u0026quot;)和 s e t n l e ( 表示“设 置不 小千等千\u0026quot;)指 的就是同一条机器指令。编译器和反汇编器会随意决定使用哪个名字。\n虽然所有的算术和逻辑操作都会设 置条件码, 但是各个 SET 命令的描述都适用的情况 是: 执行比较指令, 根据计算 t =a - b 设置条件码。更具体地说, 假设 a 、b 和 t 分别是 变量 a 、 b 和 t 的补码形式表示的整数, 因此 t = a - 口,b, 这里 w 取决 千 a 和 b 的大小。\n来看 s e t e 的 情况 , 即“当相等时设置( set when equal) \u0026quot; 指令。当 a = b 时, 会得到t = O,\n因此零标志置位就表示相等。类 似地 , 考虑用 s e t l , 即“当小千时设 詈( set when less) \u0026quot; 指令, 测试一个有符号比较。当没有发生溢出时( OF 设置为0 就表明无溢出), 我们有当 a —:Vb \u0026lt; O时 a \u0026lt; b, 将 SF 设置为 1 即指明这一点, 而当 a —:Vb O 时 a 多b, 由 SF 设置为 0 指明。另一 方面, 当发生溢出时 , 我们有当 a — .b\u0026gt; O( 负溢出)时a \u0026lt; b , 而当 a —汹\u0026lt; O( 正溢出)时a \u0026gt; b。\n当 a = b 时, 不会有溢出。因此 , 当 OF 被设置为 1 时, 当且仅当 SF 被设置为 o, 有 a \u0026lt; b。将\n这些情况组合起来 , 溢出和符号位的 EXCLUSIVE-OR 提供了 a \u0026lt; b 是否为真的测试。其他的有符号比较测试基千 SF A OF 和 ZF 的其他组合。\n对于无符号 比较的测试 , 现在设 a 和b 是变量 a 和 b 的无符号形式表示的 整数。在执行计算 t =a - b 中, 当 a - b\u0026lt; O 时, CMP 指令会设置进位标 志, 因而尤 符号比较使用的是\n进位标志和零标志的组合。\n注意到机器代码 如何区分有符号和无符号值是很重要的 。同 C 语言不同, 机器代码不会将每个程序值都和一个数据类型联系起来。相反,大多数情况下,机器代码对千有符号和无符号两种情况都使用一样的指令,这是因为许多算术运算对无符号和补码算术都有一样的位级行为。有些情况需要用不同的指令来处理有符号和无符号操作,例如,使用不同版本的右移、除法和乘法指令,以及不同的条件码组合。\n芦 练习题 3. 13 考虑下 列的 C 语言代 码 :\nint comp(data_t a, data_t b) { return a COMP b;\n它给 出 了 参 数 a 和 b 之 间 比 较 的 一 般 形 式 , 这 里 , 参 数 的 数 据 类 型 d a t a _ t ( 通 过t yp e d e f ) 被声明 为表 3-1 中列 出的 某种整数 类型 , 可以是 有符 号的也 可以是 无符号的 c omp 通过 # d e f i ne 来定 义。\n假设 a 在 %r 土 中某个部 分, b 在 %r s i 中 某个 部 分。 对于下 面每 个 指令 序 列, 确定哪 种数 据类型 d a t a _ t 和比 较 COMP 会导致编译 器 产 生这 样的代码。(可能 有 多个 正确答案,请列出所有的正确答案。)\ncmpl %esi, %edi setl %al cmpw %si, %di setge %al cmpb %sil, %dil setbe %al cmpq %rsi, %rdi setne %a\n比氐 练习题 3. 14 考虑下 面的 C 语言代 码 :\nint test(data_t a) { return a TEST O;\n}\n它给出了参数 a 和 0 之间比较的 一般形 式,这里,我们 可以 用 t yp e de f 来声明 da t a _t , 从而设置参数的数据类型,用# de f i ne 来声明 TEST, 从而设置比较的类型。对于下面每个指令 序列, 确定 哪种 数据 类 型 d a t a _ t 和比 较 TEST 会导 致 编译器 产 生 这样的代码。(可能有多个正确答案,请列出所有的正确答案。)\ntestq %rdi, %rdi\nsetge %al\ntestw %di, %di sete %al\ntestb %dil, %dil seta %al\ntestl %edi, %edi setne %al\n3. 6. 3 跳转指令\n正常执行的情况 下, 指令按照它们 出现的顺 序一条一条地执行 。跳转( j um p ) 指令会导致执行切换到程序中一个全新的位置。在汇编代码中,这些跳转的目的地通常用一个标号\n(labe l) 指明。考 虑下面的汇编代码 序列(完全是人为编造的):\nmovq $0,%rax jmp .L1\nmovq (%rax),%rdx\n.L1:\npopq %rdx\nSet 7.rax to 0 Goto .L1\nNull pointer dereference (s 虹 pped)\nJump target\n指令 j mp . Ll 会导致程序跳过 mo v q 指令, 而从 p o p q 指令开始继续 执行。在产生目标代码文件时,汇编器会确定所有带标号指令的地址,并将跳转目标(目的指令的地址)编码 为跳转指令的一部分。\n图 3-15 列举了不同的 跳转指令。j mp 指令是无条件跳转 。它可以是直接跳转 , 即跳转目标是作为指令的一部分编码的;也可以是间接跳转,即跳转目标是从寄存器或内存位置 中读出的。汇编语言中,直接跳转是给出一个标号作为跳转目标的,例如上面所示代码中 的标号\u0026quot;.Ll \u0026quot; 。间接跳转的写 法是 \u0026lsquo;* \u0026lsquo;后面跟一个 操作数指 示符 , 使用图 3-3 中描述的内存操作数格式中的一种。举个例子,指令\njmp *%rax\n用寄存器 %r a x 中的值作为跳转目标 , 而指令\njmp *(%rax)\n以%r a x 中的值作为读地址, 从内存中读出跳转目标 。\n指令 同义名 跳转条件 描述 jmp jmp Label *Operand 1 1 直接跳转间接跳转 je jne Label Label jz jnz ZF -ZF 相等/零 不相等/非零 js jns Label Label SF -SF 负 数 非负数 jg jge jl jle Label Label Label Label jnle jnl jnge jng -(SF - OF) \u0026amp;: -ZF -(SF- OF) SF- OF (SF - OF) I ZF 大千(有符号>) 大于或等于(有符号>=) 小于(有符号<) 小于或等于(有符号<=) ja jae jb jbe Label Label Label Label jnbe jnb jnae jna -CF \u0026amp;-ZF -CF CF CF I ZF 超过(无符号>) 超过或相等(无符号 >=) 低于(无符号<) 低于或相等(无符号<=) 图 3-15 ju mp 指令。当跳转条 件满足时 ,这 些 指 令 会 跳 转 到 一 条 带 标 号 的 目 的 地 。有些指令有“同义名“,也就是同一条机器指令的别名\n表中所示的其他跳转指令都是有条件的-它们根据条件码的某种组合,或者跳转, 或者继续 执行代码序列 中下一条指令。这些指令的名字和跳 转条件与 SET 指令的名字和 设置条件是 相匹配的(参见图3-14) 。同 SET 指令一样 , 一些底层的 机器指令有多个名字。条件跳转只能是直接跳转。\n6. 4 跳转指令的编码\n虽然我们不关 心机器代码格式的细节 , 但是理 解跳转指令的目标如何编码, 这对第 7\n章研究链接非常重要。此外,它也能帮助理解反汇编器的输出。在汇编代码中,跳转目标 用符号标号书写。汇编器,以及后来的链接器,会产生跳转目标的适当编码。跳转指令有 几种不同的编码 , 但是最 常用都是 P C 相对的 ( P C- relat ive ) 。也 就是 , 它们会将目标指令 的地址与紧跟在跳转指令后面那条指令的地址之间的差作为编码。这些地址偏移量可以编 码为 1 、2 或 4 个字节。第二 种编码方 法是给出“绝对“地址,用 4 个字节直接指定 目标。汇编器和链接器会选择适当的跳转目的编码。\n下面是一 个 P C 相对寻址的 例子, 这个函数的汇编代码由 编译文件 bra nch. c 产生。它包含两个 跳转: 第 2 行的 j mp 指令前向 跳转 到更高的地址, 而第 7 行的 j g 指令后向跳转到较低的地址。\nmovq jmp\n.13:\nsarq\n.12:\n%rdi, %rax\n.L2\n%rax\ntestq %rax, %rax jg .13\nrep; ret\n汇编器产生的 \u0026quot; . o\u0026quot; 格式的 反汇编版本 如下 :\n0: 48 89 f8 mov %rdi,%rax 3: eb 03 jmp 8 \u0026lt;loop+Ox8\u0026gt; 5: 48 d1 f8 sar %rax 8: 48 85 co test %rax, %rax b: 7f f8 jg 5 \u0026lt;loop+Ox5\u0026gt; d: f3 c3 repz retq 右边反汇编器产生的 注释中 , 第 2 行中跳转指令的跳转目标指明为 Ox B, 第 5 行中跳转指令的跳转目标是 Ox S( 反汇编器以 十六 进制格式给出 所有的数字)。不过, 观察指令的字节编码 , 会看到第一 条跳转 指令的目标 编码(在第二个字节中)为Ox 03 。把它加上 Ox S, 也就是下一条指令的 地址 , 就得到跳转目 标地址 Ox 8 , 也就是第 4 行指令的地址。\n类似, 第二个跳转指令的目标用单字节 、补码表示 编码为 Ox f B( 十进制 -8 ) 。将这个数加上 Oxd ( 十进制 13 ) , 即第 6 行指令的地址 , 我们得到 Ox S, 即第 3 行指令的地址。\n这些例子说明 , 当执行 P C 相对寻址时 , 程序计 数器的值是跳转指令后面的 那条指令的地址,而不是跳转指令本身的地址。这种惯例可以追溯到早期的实现,当时的处理器会将更新程序计数器作为执行一条指令的第一步。\n下面是链接后的程序反汇编版本:\n4004d0: 48 89 f8 mov %rdi,%rax 4004d3: eb 03 jmp 4004d8 \u0026lt;loop+Ox8\u0026gt; 4004d5: 48 d1 f8 sar %rax 4004d8: 48 85 co test %rax,%rax 4004db: 7f f8 jg 4004d5 \u0026lt;loop+Ox5\u0026gt; 4004dd: f3 c3 repz retq 这些指令被重定 位到不同的 地址, 但是 第 2 行和第 5 行中跳转 目标的编码并 没有变。通过使用 与 P C 相对的跳转目标 编码, 指令编码很简洁(只需要 2 个字节), 而且目标代码 可以不做改变就移到内存中不同的位置。\nm 指令r e p 和r e p z 有什么用\n本节开始的 汇编代码的 笫 8 行 包含指令组合r e p ; r e t 。它们在 反汇编 代码中(笫 6 行)对应于r e p zr e 七q 。 可以推 测 出r e p z 是r e p 的 同 义名, 而r e t q 是r e t 的同 义名。查阅 Intel 和 AMD 有关r e p 的 文档,我 们发现它通 常 用 来 实现重复的 字符 串操作[ 3 ,\n51] 。在这 里用它似乎很 不合 适。 这个问 题的答案 可以 在 AMD 给编译器编 写 者的 指导意见书 [ l ] 中找到 。他们建议用r e p 后 面跟r e t 的组合来避免 使r e t 指令成为条件跳 转指令的目标 。如果没有r e p 指令 , 当 分 支不跳 转时, j g 指令(汇编代码的 第 7 行)会继续到 r e t 指令。根据 AM D 的说法, 当r e t 指令通过跳 转指令到 达时 , 处理 器不能 正确预测 r e t 指令的 目的 。这里的r e p 指令就是作为 一种空操 作, 因此 作为 跳转目 的插入它, 除了能使代码在 AMD 上运行得 更快之 外, 不会 改 变代码的 其他 行为。 在本书后 面其他代 码中再遇到 r e p 或r e p z 时,我 们可以很 放心地无视 它们。\n区§练习题 3. 15 在下 面这 些反 汇编 二进 制 代 码 节选 中 , 有 些 信息 被 X 代替 了。 回答下列关于这些指令的问题。\n下 面 j e 指令的 目标是 什 么?(在此, 你不需 要知道任何 有关 c a l l q 指令的 信息。)\n4003fa: 74 02 je xxxxxx\n4003f c : ff dO callq *%rax\n下面尸:指令的目标是什么?\n40042f: 74 f4 je xxxxxx\n400431: 5d pop %rbp\nj a 和 p o p 指令的 地址是 多少?\nXXXXXX: 77 02 ja 400547\nXXXXXX: 5d pop %rbp\n在下 面的代 码 中,跳 转目标的编 码是 PC 相对的 , 且是 一个 4 字节补码数。 字节桉\n. 照从最低位到 最高位 的顺序列 出,反 映 出 x86-6 4 的 小端 法 字节 顺 序。 跳 转 目标 的地址是什么?\n4005e8: e9 73 ff ff ff 4005ed: 90\njmpq XXXXXXX nop\n跳转指令提供了一种实现条件执行(江)和儿种不同循环结构的方式。\n6. 5 用条件控制来实现条件分支\n将条件表达式 和语句从 C 语言翻译 成机器代码 , 最常用的方式是结 合有条件 和无条件跳转。(另一种方式在 3. 6. 6 节中会看到, 有些条件可以 用数据的条件转移实现, 而不是用控制的条 件转移来 实现。)例如, 图 3-1 6a 给出了一个计 算两数之差绝对值 的函数的 C 代码气 这个函数有一 个副作用 , 会增加两个计数 器, 编码为全局 变最 l t _ c n t 和 g e _ c n t 之一。G CC 产生的汇编代码 如图 3-1 6c 所示。把这个 机器代码再转换成 C 语言, 我们称之为函数 g o t o d i f f _s e ( 图 3-1 6b ) 。它 使用了 C 语言中的 go t o 语句, 这个语句类似于汇编代码中的无条件跳转 。使用 go t o 语句通常认 为是一种不好的编程风格, 因为它会使代码非\ne 实际上, 如果一个减法 溢出, 这个函数就会返回一 个负数值。 这里我们主要 是为了 展示机器代码, 而不 是实现代码的健壮性。\n常难以阅 读和调试。本文中使用 goto 语句, 是为了 构造描述汇编代码程序控制流的 C 程序。我们称这样的编程风格 为 \u0026quot; g o t o 代码”。\n在 g o t o 代码中(图3-166 ) , 第 5 行中的 go t o x_g e _y 语句会导致跳转到第 9 行中的标号 x_ge _ y 处(当x 娑y 时会进行跳转)。从这一点继续执行, 完成函数 a b s d i f f _ s e 的e l s e 部分并返回。另一方面, 如果测试 x \u0026gt;=y 失败, 程序会计算 a b s d i f f _ s e 的 江 部分指定的步骤并返回。\n汇编代码的 实现(图3- l 6c ) 首先比较了两个 操作数(第2 行), 设置条件码。如果 比较的结果表明 x 大千或者等 于 y , 那么它就会跳转到第 8 行, 增加全局变量 g e _ c n t , 计算 x\n- y 作为返回 值并返回。由此我们可以 看到 a b s d i f f _s e 对应汇编代码的 控制流非 常类似于\ng o t o d i f f —s e 的 g o t o 代码。\n}\nreturn result;\n}\na ) 原始的C语言代码 b ) 与之等价的got o版本\nlong absdiff_se(long x, long y)\nX 江 肚 di , y 立 肚s i\nabsdiff_se:\ncmpq %rsi, %rdi Compare x : y\njge .L2 If\u0026gt;= goto x_ge _y\n4 addq $1, lt_cnt(%rip) lt_cnt++\n5 movq 。r儿\ns i , %rax\n6 subq %rdi, %rax result = y - x\n7 ret Return\n8 .L2: x_ge_y :\n9 addq $1, ge_cnt Or 儿\nmovq %rdi, %rax\ni p) ge_cnt++\nsubq %rsi, %rax result= x - y\nret Return\nc ) 产生的汇编代码\n图 3-16 条 件 语 句 的 编 译 。 a)C 过程 abs di f f s e 包含一个 迂- e l se 语句 ; b)C 过程 got odif f _se\n模拟了汇编代码的控制; c ) 给出了产生的汇编代码\nC 语言中的 江- e l s e 语旬的通用形式模板如下:\nif (test-expr)\nthen-statement\nelse\nelse-statement\n这里 test-e工 p r 是一个整数表达式 ,它 的 取 值 为 0 ( 解释为“假\u0026quot; )或者为非 0 ( 解释为“真\u0026quot; )。两个分 支语句 中 ( then-sta tement 或 else-sta tement ) 只会执行一个。\n对于这种通用形式, 汇编实现通常会使用下面这种形式, 这里, 我们用 C 语法来描述控制流:\nt = test-expr; if (!t)\ngoto false; then-statement goto done;\nfalse:\nelse-statement done:\n也就是 , 汇编器为 the n-sta tement 和 else-sta tement 产生各自的代码块。它会插入条件和无条件分支,以保证能执行正确的代码块。\nm 用 C 代码描述机器代码\n图 3-1 6 给出 了 一个示例 , 用 来展 示把 C 语言控 制 结构翻译成机 器代码。图 中 包括示例 的 C 函数 a 和由 GCC 生成 的汇编代码的 注释 版本 c , 还有一个与汇编代码结构高度一致的 C 语言版本 b。机 器代 码的 C 语言表 示有 助 于你理解其中的 关键 点 , 能引导你理解实际的汇编代码。\n江 练习题 3 . 16 已 知下列 C 代 码 :\nvoid cond(long a, long *p)\n{\nif (p \u0026amp;\u0026amp; a\u0026gt; *p)\n*P = a;\n}\nGCC 会产 生下 面的 汇编 代码 : void cond(long a, long *p) a in %rdi, p in %rsi\ncond:\ntestq %rsi, %rsi\nje .Ll\ncmpq %rdi, (%rsi)\njge .Ll\nmovq %rdi, (%rsi)\n.Ll:\nrep; ret\n按照 图 3-1 66 中所 示的 风格, 用 C 语言 写 一个 go to 版本, 执行 同 样的 计 算 , 并模拟汇编代码的控制流。像示例中那样给汇编代码加上注解可能会有所帮助。 请说 明为什 么 C 语 言代码 中只有 一个 if 语 句 , 而 汇编 代码包 含 两个 条件分支。 让 练习题 3. 17 将 i f 语句 翻译成 go to 代码 的另 一种 可行 的 规则 如下:\nt = test-expr;\nif Ct)\ngoto true; else-statement goto done;\ntr ue :\nthen-statement done :\n基于这种规则 , 重 写 a b s d i f f _s e 的 go to 版本。 你能想出选用一种规则而不选用另一种规则的理由吗?\n已 练习题 3. 18 从如下形 式 的 C 语 言代码 开 始 :\nlong test(long x, long y, long z) { long val = ;\nif () {\nif () val=\nelse\nval=\n} else if ()\nval= return val;\n}\nGCC产 生 如 下的 汇编代码 :\nlong test (long x, long y, long z)\nx in %rdi, y i n r¼ si , z i n %rdx test:\nleaq (%rdi,%rsi), %rax addq %rdx, %rax\ncmpq $-3, o/.rdi\njge .L2\ncmpq %rdx, %rsi\njge .L3\nmovq %rdi, %rax imulq %rsi, %rax ret\n.L3:\nmovq %rsi, %rax imulq %rdx, %rax ret\n.L2:\ncmpq $2, %rdi\njle .14\nmovq %rdi, %rax imulq %rdx, %rax\n.14:\nrep; ret\n填写 C 代码 中缺 失的表 达 式 。\n6. 6 用条件传送来实现条件分支\n实现条件操 作的传统方法是通过使用 控制的条件转移 。当条件满足时, 程序沿 着一条执行路 径执行, 而当条 件不满足时 , 就走另 一条路径。这种 机制简单而通用 , 但是 在现代处理器上 , 它可能 会非常低效。\n一种替 代的 策略是使用 数据的条 件转移 。这种方法计算 一个条件操作的两种结果 , 然后再根据条件是否满足从中选取一个。只有在一些受限制的情况中,这种策略才可行,但 是如果可行,就可以用一条简单的条件传送指令来实现它,条件传送指令更符合现代处理 器的性能特性 。我们 将介绍 这一策略 , 以及它在 x8 6-64 上的实现。\n图 3- l 7a 给出了一 个可以用条件传送编译的 示例代码。这个函数计算参数 x 和 y 差的绝对值 , 和前面的例子一样(图3-1 6 ) 。不过前面的例子中, 分支里有副作用, 会修改 lt\ncnt 或 g e _ c n t 的值, 而这个 版本只是简单地计算 函数要返 回的值。\nGCC 为该 函数产生 的 汇 编代 码 如图 3- l 7c 所 示, 它与图 3-1 76 中所 示 的 C 函数cmovdif f 有相似的形式。研究 这个 C 版本 , 我们可以 看到它既计算了 y- x , 也计算了 x - y , 分别命名为 r va l 和 e va l 。然后 它再测试 x 是否大于等千 y , 如果 是, 就在函数返回r va l 前,将 e va l 复制到r v a l 中。图 3-l 7c 中的汇编代码有相同的逻辑 。关键就在千汇编代码的那条cmovge 指令(第7 行)实现了cmovd i ff 的条件赋值(第8 行)。只有当第6 行的 cmpq 指令表明一 个值大于等于另一 个值(正如后缀ge 表明的那样)时, 才会把数据源寄存器传送到目的 。\nlong absdiff(long x, long y)\n{\nlong result; if (x \u0026lt; y)\nresult= y - x;\nelse\nresult= x - y; return result;\n}\nlong cmovdiff(long x, long y)\n2 {\n3 long rval = y-x;\n4 long eval = x-y;\n5 long ntest = x \u0026gt;= y;\n6 I* Line below requires\n7 single instruction: *I\n8 if (ntest) rval = eval;\n9 return rval;\n10 }\na ) 原始的C语言代码 b ) 使用条件赋值的实现\n1 abs di ff : 2 movq %rsi, %rax 3 subq %rdi, 1r儿 ax rval = y-x 4 movq %rdi, %rdx 5 subq %rsi, ir儿 dx eval = x- y 6 cmpq %rsi, %rdi Compare xy. 7 crnovge %rdx, %rax If \u0026gt;=, rval = eval 8 ret Return tval C ) 产生的汇编代码\n图 3-17 使用 条件赋值的条件语句的 编译。a)C 函数 absd if f 包 含一个条件表达式 ;\nb)C 函数 cmo vdi f f 模 拟 汇编代码操作; c) 给出产生的 汇编代码\n为了理解为什么基于条件 数据传送的 代码会比基千条 件控制转移的代码(如图 3-16 中那样)性能要好 , 我们必须 了解一些关于现代处理器如何运行的知识。正如我们将在第 4 章 和第 5 章中看到的 , 处理器通过使用 流水线 ( pipelining ) 来获得高性能 , 在流水线中 , 一条指令的 处理要经过一 系列的阶段, 每个阶段执行所需操作的一小部分(例如, 从内存取指令、确定指令类型、从内存读数据、执行算术运算、向内存写数据,以及更新程序计数 器)。这种方法通过重叠连续指令的步骤来获得高性能,例如,在取一条指令的同时,执 行它前面一条指令的算术运算。要做到这一点,要求能够事先确定要执行的指令序列,这 样才能保持流水线中充满了待执行的指令。当机器遇到条件跳转(也称为“分支\u0026quot;)时,只 有当分支条件求值完成之后,才能决定分支往哪边走。处理器采用非常精密的分支预测逻 辑来猜测每条跳转指令是否会执行。只要它的猜测还比较可靠(现代微处理器设计试图达 到 90 % 以上的成功 率), 指令流水线中就会充满着指令。另一方面, 错误预测一个跳转, 要求处理器丢掉它为该跳转指令后所有指令己做的工作,然后再开始用从正确位置处起始 的指令去填充流水线。正如我们会看到的,这样一个错误预测会招致很严重的惩罚,浪费 大约 15 ~ 30 个时钟周期 , 导致程序性能 严重下降 。\n作为一 个示例 , 我们在 In tel H aswe ll 处理器上运行 a bs d if f 函数, 用两种方 法来实现条件操作。在一个典型的应用 中, x \u0026lt; y 的结果非常地不可预测 , 因此即使是最 精密 的分支预测硬件也 只能有大约 50 % 的概率 猜对。此外 , 两个代码 序列中的 计算执行都只需 要一个时钟周期。因此,分支预测错误处罚主导着这个函数的性能。对千包含条件跳转的 x86-64 代码, 我们 发现当分 支行为模式 很容易预测时 , 每次调用函数需要大约 8 个时钟周期; 而分支行为模式 是随机的时 候, 每次调用需 要大约 1 7. 50 个时钟周期。由此我们可以推断出分 支预测错误 的处罚是大约 19 个时钟周期。这就意味着函数需要的时间范围大约在 8 到 27 个周期 之间, 这依赖于分支预测是 否正确。\n田 如何 确定分支预测错误的 处罚\n假设预测错误 的概率是 p , 如果没有 预测错 误, 执行代码的 时间是 T oK , 而预测错误的处罚是 T MP 。 那 么, 作为 p 的一个函数 , 执行代码的平 均 时间 是 T ,v. C p ) = (l - p ) ToK + P (T oK + T MP) = T oK +PT MP 。 如果已知 T oK 和 T ,.\u0026quot;( 当 p = O. 5 时的 平 均 时间), 要确定 T MP 。 将参数代入等式, 我们有 T can = Tavg (0. 5) = ToK + 0. 5T MP , 所以 有 T MP = 2 (Tran - T OK) 。 因此 , 对于 T oK= 8 和 T can= l 7. 5, 我们有 T MP= l 9。\n另一方面,无论测试的数据是什么,编译出来使用条件传送的代码所需的时间都是大 约 8 个时钟周期 。控制流不 依赖于数据, 这使得处理器更容易 保持流水线是 满的 。\n; 练习题 3. 19 在 一个比较旧的处 理器模 型上运 行, 当 分 支行 为模 式非常 可预测 时,我们的代码需要大约 1 6 个时钟周期 , 而当模 式是随机 的时候 , 需要大约 31 个时钟周期。\n预测错误处罚大约是多少? 当分支预测错误时,这个函数需要多少个时钟周期?\n图 3-18 列举了 x86- 64 上一些 可用的 条件传送指令。每条指 令都有两个操作数: 源寄存器或者内存地址 S , 和目的 寄存器 R。与不同的 SET (3. 6. 2 节)和跳转指令( 3. 6. 3 节) 一样,这些指令的结果取决千条件码的值。源值可以从内存或者源寄存器中读取,但是只 有在指定 的条件满 足时 , 才会被复 制到目的 寄存 器中。\n源和目的的值可以是 16 位、32 位或 64 位长。不支持单字节的条件传送。无条件指令的操作数的长度显式地编码在指令名中(例如movw 和 mov U , 汇编器可以从目标寄存器的名字推断\n出条件传送指令的操作数长度,所以对所有的操作数长度,都可以使用同一个的指令名字。\n指令 同义名 传送条件 描述 cmove cmovne S,R S,R cmovz cmovnz ZF -ZF 相等/零 不相等/非零 cmovs cmovns S,R S,R SF -SF 负 数 非负数 cmovg cmovge cmovl cmovle S,R S,R S,R S,R cmovnle cmovnl cmovnge cmovng -(SF~ OF) \u0026amp; -ZF -(SF- OF) SF~ OF (SF~ OF) I ZF 大于(有符号>) 大于或等于(有符号>=) 小千(有符号<) 小于或等千(有符号<=) cmova cmovae cmovb cmovbe S,R S,R S , R S,R crnovnbe crnovnb cmovnae cmovna ~CF \u0026amp; ~ZF ~CF CF CF I ZF 超过(无符号>) 超过或相等( 无符号>=) 低于(无符号<) 低于或相等(无符号<=) 图 3-18 条件传送指令。当传送条件满足时 ,指 令 把 源值 S 复制到目的 R 。有些指令是“同义名",即同一条机器指令的不同名字\n同条件跳转不同,处理器无需预测测试的结果就可以执行条件传送。处理器只是读源值(可能是从内存 中), 检查条 件码, 然后要 么更新目的寄存器, 要么保持不变。我们会在第 4 章中探讨条件传送的 实现。\n为了理解如何通过条件数据传输来实现条件操作,考虑下面的条件表达式和赋值的通 用形式:\nv = test-expr ? then-expr : else-expr;\n用条件控制转移的标准方法来编译这个表达式会得到如下形式:\nif (! test-expr)\ngoto false; v = then-expr; goto done;\nfalse:\nv = else-expr; done:\n这段代码包含两个代码 序列 : 一个对 then-ex p r 求值, 另一个对 els e-ex p r 求值。条件跳转和无条件跳转结合起来使用是为了保证只有一个序列执行。\n基于条 件传送的代码 , 会对 the n-ex p r 和 else-ex p r 都求值, 最终值的选择 基于对 test­\nex pr 的求值。可以用下面的抽象代码描述:\nv = then-expr; ve = else-expr; t = test-expr; if (!t) v = ve;\n这个序列中的最后一条语旬是用条件传送实现的 只有当测试条件 t 满足时, v t 的值才会被复制到 v 中。\n不是所有的条件表达式都可以用条件传送来编译。最重要的是,无论测试结果如何,\n我们 给出的 抽象代码会对 th en-ex p r 和 else-ex p r 都求值。如果这两个表达式 中的任意一个可能 产生错误条件或者副作用 , 就会导 致非法的行 为。前 面的一 个例子(图3-16 ) 就是这种情况。实际 上, 我们在该 例中引 入副作用就是 为了强 制 GCC 用条件转移来实 现这个函数。\n作为说明 , 考虑下面这个 C 函数:\nlong cread(long *xp) { return (xp? *xp : O);\n乍一 看, 这段代码似乎很适 合被编译成使 用条件传送 , 当指针为空时 将结果设置为 o,\n如下面的汇编代码所示:\nlong cread(long•xp)\nInvalid implementation of function cread\nxp 工n register %r d工cread:\nmovq (%rdi), %rax V = *Xp testq %rdi, %rdi Test x movl $0, %edx Set ve = 0 cmove ret %rdx, %rax If x ==O, v = ve Return v 不过, 这个 实现是非 法的 , 因为即使 当测试 为假时 , mo v q 指令(第2 行)对x p 的间接引用还是发生了 , 导致一个间接引 用空指针的错误。所以, 必须用分支代码来 编译这段代码。\n使用条件传送也不 总是会提高 代码的效 率。例如, 如果 th en-ex p r 或者 else-ex p r 的求值需要大量的计算, 那么当相对应的 条件不满 足时 , 这些工 作就白费了。编译器必须 考虑浪费的 计算和由于分支预测错 误所造成的性能处罚 之间的相对性能。说实话 , 编译器并不具有足够的信息来做出 可靠的决定; 例如, 它们不知道分支会多好地遵循可预测 的模式。我们对 GCC 的实验表明 , 只有当两个表达式 都很容易 计算时, 例如表达式 分别都只是一条加法指令,它才会使用条件传送。根据我们的经验,即使许多分支预测错误的开销会超 过更复杂的计算, GCC 还是 会使用条件控制转移 。\n所以,总的来说,条件数据传送提供了一种用条件控制转移来实现条件操作的替代策略。它们只能用于非常受限制的清况,但是这些情况还是相当常见的,而且与现代处理器的运行方式更契合。\n亡 练习题 3. 20 在下 面的 C 函数 中 , 我们对 OP 操作的定 义是 不 完整的 :\n#define OP _ I* Unknown operator *I\nlong arith(long x) { return x OP 8;\n}\n当编译时, GCC 会产 生如下 汇编代 码 :\nlong arith(long x) x in %r d 工\narith:\nleaq testq cmovns sarq ret\n7(%rdi), %rax\n%rdi, %rdi\n%rdi, %rax\n$3, %rax\nOP 进行 的是 什 么操作?\n给代码 添加注释 , 解释 它是 如何工作的。讫§ 练习题 3. 21 C 代码 开始的形 式如下 :\nlong test(long x, long y) { long val = ;\nif () {\nif ()\nval=\nelse\nval= _—;\n} else if ()\nval= return val;\n}\nGCC 会产 生如下 汇编代码 :\nlong test(long x, long y) x in %rdi , y in %rsi\ntest:\nleaq O(,%rdi,8), %rax testq %rsi, %rsi\njle .L2\nmovq %rsi, %rax\nsubq %rdi, %rax\nmovq %rdi, %rdx\nandq %rsi, %rdx\ncmpq %rsi, %rdi cmovge %rdx, %rax ret\n,L2:\naddq %rsi, %rdi\ncmpq $-2, %rsi cmovle %rdi, %rax ret\n填补 C 代码 中缺 失的 表达 式。\n7 循环\nC 语言提供了多种循环结构, 即 d o - wh i l e 、 wh i l e 和 f o r 。汇编中没有相应的指令存在, 可以用条件测试 和跳转组 合起来实现循环的效果。GCC 和其他汇编器产生的循环代码主要 基于两种基本的 循环模式 。我们会循 序渐进地研究循环的 翻译 ,从 d o - wh i l e 开始,然后再研究具有更复杂实现的循环,并覆盖这两种模式。\nd o - wh i l e 循环\ndo - wh i l e 语句的通用形式 如下 :\ndo\nbody-statement while (test-expr);\n这个循环 的效果就是重复执行 body sta tement , 对 test-ex pr 求值, 如果求值的结果为非\n零, 就继续循环。可以 看到 , bod y-sta tement 至少会 执行一次。这种通用形式可以被翻译 成如下所示的条 件和 g o t o 语句: loop:\nbody-statement\nt = test-expr;\nif (t)\ngoto loop;\n也就是说,每次循环,程序会执行循环体里的语句,然后执行测试表达式。如果测试为 真, 就回去再执行一次循环。\n看一个示例 , 图 3-19a 给出了一 个函数的实现, 用 d o - wh i l e 循环来计算函数参 数的阶乘, 写作 n ! 。这个函数只计算 n \u0026gt; 0 时 n 的阶乘的值。\n亡 练习题 3. 22\n用 一个 32 位 i n t 表 示 n !\u0026rsquo; 最 大的 n 的值 是 多少? 如果 用 一个 64 位 l o ng 表 示,最大的 n 的值 是 多少?\n图 3-196 所示的 goto 代码展示了如何把循环变成低级的测试和条件跳转的组合。 r e s ul t 初始化之后 , 程序开始循环。首先执行循环体 , 包括更新变量r e s u止 和 n。然后测试 1\u0026rsquo;!\u0026gt; 1 , 如果是真 , 跳转到循环开始处。图 3-19c 所示的汇编代码就是 goto 代码的原型。条件跳转指令 j g ( 第 7 行)是实现循环的关键指令, 它决定了是需要继续重复还是退出循环。\nlong fact_do(long n)\n{\nlong result = 1; do {\nresult*= n; n = n-1;\n} while (n \u0026gt; 1); return result;\n}\nlong fact_do_goto(long n)\n{\nlong result = 1; loop:\nresult*= n; n = n-1;\nif (n \u0026gt; 1)\ngoto loop; return result;\n}\nC代码 b ) 等价的go七o版本 1 long fact_do(long n) n in %rdi fact_do: 2 movl $1, %eax Set result 1 3 .12: l oop: 4 imulq %rdi, %rax Compute result *= n 5 subq $1, %rdi Decrement n 6 cmpq $1, %rdi Compare n: 1 7 jg .L2 If\u0026gt;, goto l oop 8 rep; ret Return C ) 对应的汇编代码\n佟I 3- 1 9 阶 乘 程序的 do- whi l e 版本的代码。条件跳转会使得程序循环\n逆向工程像图 3-19c 中那样的汇编代码 ,需 要确定 哪个寄存器对应的是 哪个程序值 。本例中, 这个对应 关系很容易确定 : 我们知道 n 在寄存 器%r d i 中传递给函数。可以 看到寄存器%r ax 初始化为 1 ( 第 2 行)。(注意, 虽然指令的目 的寄存 器是 %e a x , 它实际上还会 把%r a x 的高 4 字节设 置为 0。)还可以看到这个寄存器还会在第 4 行被乘法改变值。此外,%r a x 用来返回函数值 , 所以通常会用来存放需要返回的程 序值。因此我们断定%r ax 对应程序值r e s ul t 。\n练习题 3. 23 已 知 C 代 码 如下 :\nlong dw_loop(long x) { long y = x*x;\nlong *P = \u0026amp;x; long n = 2*x; do {\nX += y;\n(*p)++;\nn\u0026ndash;;\n} while (n \u0026gt; 0); return x;\n}\nGCC 产 生的 汇编代码 如下:\nlong d w_l oop(l ong x) x initially in %rdi dw_loop:\n2 movq %rdi, %rax 3 movq %rdi, %rcx 4 imulq %rdi, %rcx 5 leaq (%rdi,%rdi), %rdx 6 . L2: 7 leaq 1(%rcx,%rax), %rax 8 subq $1, %rdx 9 testq %rdx, 1r儿 dx 10 jg .L2 11 rep; ret 哪 些寄 存器用来存 放程序值 x、 y 和 n?\n编译器 如何 消 除对指 针 变 量 p 和表达 式 ( *p ) ++ 隐含的指针 间 接引用的 需求?\n对 汇编 代码 添加 一些注释 , 描述 程序的 操作 , 类似于 图 3-1 9c 中所 示的 那样。\nm 逆向 工程循环\n理解产生的汇编代码与原始沌代码之间的关系,关键是找到程序值和寄存器之间的 映射 关 系。 对于图 3-1 9 的循 环 来说 , 这个任 务非 常 简 单, 但是对于更 复杂的 程序来说 , 就可能是 更具挑战性 的任务。C 语言编译 器常常 会重组 计算, 因此 有些 C 代码中的 变量在机器代码中没有对应的值;而有时,机器代码中又会引入源代码中不存在的新值。此 外,编译器还常常试图将多个程序值映射到一个寄存器上,来最小化寄存器的使用率。\n我们描 述 f a c t _d o 的过程对于逆 向工程循 环 来说 , 是一 个通 用的 策略 。 看 看在循环之前如何初始化寄存器,在循环中如何更新和测试寄存器,以及在循环之后又如何使 用寄存器。这些步骤中的每一步都提供了一个线索,组合起来就可以解开谜团。做好准\n备,你会看到令人惊奇的变换,其中有些情况很明显是编译器能够优化代码,而有些情 况很 难解释编译 器 为 什 么要 选用 那些奇怪的 策略。根 据我们的 经验 , G CC 常 常做 的一些变换,非但不能带来性能好处,反而甚至可能降低代码性能。\nwhile 循环\nwh i l e 语 句 的 通用 形 式如下:\nwhile (test-expr) body-statement\n与 d o- wh i l e 的 不 同 之 处 在 于, 在 第 一 次 执 行 bod y-s ta tem ent 之 前, 它 会 对 tes t- expr 求值 , 循 环 有 可 能就中 止 了。 有很 多 种 方 法 将 wh i l e 循 环 翻 译成 机器代 码 , G CC 在代 码生成 中使 用 其 中 的 两种 方 法。 这 两种 方 法使 用 同 样的 循 环结构, 与 d o - wh i l e 一 样, 不 过 它们实现初始测试的方法不同。\n第 一种 翻译方 法 , 我 们 称之 为 跳 转 到 中 间 ( jum p to middle), 它执行一个无条件跳转跳到 循 环结尾处 的 测试, 以 此来 执行初始的 测 试。 可 以用 以下模板来 表 达这种 方 法 , 这个模板把 通用 的 wh i l e 循 环格 式 翻译 到 g o t o 代码 :\ngoto test; loop:\nbody-statement test:\nt = test-expr;\nif (t)\ngoto loop;\n作为 一个 示 例 , 图 3- 20a 给 出 了使用 wh i l e 循 环的 阶 乘 函 数 的 实 现。这个 函 数 能 够 正确地 计算 0 ! = l 。 它 旁 边的 函 数 f a c t _ wh i l e _ j m_g o t o ( 图 3-20 b ) 是 GCC 带优 化命令行选项-Og 时产 生的 汇编 代码 的 C 语言翻译。 比 较 f a c 七_wh il e ( 图 3-20 b) 和 f a c 七_ d o ( 图 3- l 9b) 的代码 , 可 以 看到 它们 非 常 相 似 , 区 别 仅在 于 循 环 前 的 g o t o t e s t 语 句 使得 程 序 在 修 改 r e s u l t 或 n 的值之前, 先执行对 n 的 测 试。 图 的 最下 面(图 3- 20c) 给出 的是 实际产 生 的 汇编代码。\n立 练习题 3. 24 对于如下 C 代 码 :\nlong loop_while(long a, long b)\n{\nlong result = ; while () {\nresult= ,\na = ,\n}\nreturn result;\n}\n以命令行选项 - Og 运行 GCC 产 生 如下代码 :\nlong l oop _ w 加 l e (l ong a, long b) a in %rdi, b i n %rsi loop_while:\n2 movl $1, %eax 3 jmp .L2 4 .L3:\nleaq (%rdi,%rsi), %rdx\nimulq %rdx, %rax\naddq $1, %rdi\n8 . L2 :\ncmpq %rsi, %rdi\njl .L3\n11 rep; ret\n可以 看到 编译器使用 了 跳 转 到 中 间 的 翻 译 方 法 , 在 第 3 行用 jm p 跳 转 到 以 标 号\n2 开始的 测试。填写 C 代码 中缺失的部分。 long fact_while(long n)\n{\nlong result= 1; while (n \u0026gt; 1) {\nresult*= n; n = n-1;\n}\nreturn result;\n}\nlong fact_while_jm_goto(long n)\n{\nlong result = 1; goto test;\nloop:\nresult*= n; n = n-1;\ntest:\nif (n \u0026gt; 1)\ngoto loop; return result;\n}\nC代码 b ) 等价的goto版本 long f act _ w 加 l e ( l ong n)\nn 工 n %rdi fact_while:\nmovl $1, %eax\njmp .L5\n.L6:\nSet result 1 Goto test\nl oop:\nimulq %rdi, %rax Compute result *= n subq $1, %rdi Decrement n\n.15: t es t :\ncmpq $1 , %rdi Compare n: 1\njg .16 If \u0026gt;, goto loop\nrep; ret Return\nC ) 对应的汇编代码\n图 3-20 使用跳转到中间 翻译方法的 阶乘算 法的 whi l e 版本的 C 代码和汇编代 码。\nC 函数 f ac t _whi l e_ j m_g ot o 说明了汇编代码 版本的操作\n第二种翻译 方法 , 我们称之为 g ua r d ed-d o , 首先用条件分支,如果初始条件不成立就跳过循 环, 把代码变换为 d o - wh i l e 循 环 。 当使用较高优化等级编译时,例 如 使 用 命 令 行选项 - 0 1 , GCC 会采用这种策略。可以用如下模板来表达这种方法, 把通用的 wh i l e 循 环\n格式翻译 成 d o - wh i l e 循 环 :\nt = test-expr;\nif (!t)\ngoto done;\ndo\nbody-statement while (test-expr) ;\ndone:\n相应地, 还可以把它翻译 成 go to 代码如下:\nt = test-expr;\nif (! t)\ngoto done; loop:\nbody-statement\nt = test-expr;\nif (t)\ngoto loop;\ndone:\n利用这种实现策略 , 编译器常常可以 优化初始的测试,例 如 认 为测试条件总是满足。\n再来看 个 例 子,图 3 - 21 给出了图 3- 20 所示阶乘函数同样的 C 代码, 不 过给出的是\nGCC 使用命令行选项- 01 时的编译。图 3-2 l c 给出实际生成的汇编代码,图 3 - 21 b 是这个汇编代码更易读的 C 语言表示。根据 goto 代码, 可以看到如果对千 n 的初始值有 n l, 那 么将跳过该循环。该循环本身的基本结构与该函数 d o - wh 工l e 版 本 产 生的结构(图3-19 ) 一样。不过,一 个 有趣的特性是,循 环测试(汇编代码的第 9 行)从 原 始 C 代码的 n \u0026gt; l 变成 了 n =I= 1 。 编译器知道只有当 n\u0026gt; l 时才会进入循环, 所以将 n 减 1 意味着 n \u0026gt; l 或者 n =\n1 。因此 ,测 试 n =I= l 就 等价于测试 n l 。\nlong fact_while(long n)\n{\nlong result = 1; while (n \u0026gt; 1) {\nresult*= n; n = n-1;\n}\nreturn result;\n}\nlong f act _wh辽 e _gd_got o ( l ong n)\n{\nlong result = 1;\nif (n \u0026lt;= 1)\ngoto done;\nloop:\nresult*= n; n = n-1;\nif (n != 1)\ngoto loop;\ndone:\nreturn result;\n}\nC代码 b ) 等价的goto版本 图 3-21 使用 guarded -do 翻译方法的 阶乘算法的 whil e 版本的 C 代码和汇编代 码。函数 f act _whi l e_gd_got o 说明 了汇编代 码版本的 操作\nlong f act _whi l e (l ong n) n in %rdi\nfact_while:\ncmpq $1 , %rdi\njle .17\nmovl $1, %eax\n.16:\nCompare n:1 If\u0026lt;=, goto done Set result= 1\nloop:\nimulq subq cmpq jne rep;\n.17:\nmovl ret\nret\n%rdi, ;儿r ax\n$1, %rdi\n$1, %rdi\n.L6\n$1, %eax\nCompute result *= n Decrement n\nCompare n:1 If!=, goto loop Return\ndone:\nCompute result = 1 Return\n练习题 3. 25 对 于如下 C 代码 :\nC ) 对应的汇编代码\n图 3-21 (续)\nlong loop_while2(long a, long b)\n{\nlong result = ; while () {\nresult=\nb =\n}\nreturn result;\n}\n以命 令行选项 - 0 1 运行 GCC , 产生如下代码:\na in %rdi , b in %rsi loop_while2:\ntestq %rsi, %rsi jle .L8\nmovq %rsi, %rax\n.L7:\nimulq %rdi,\nsubq %rdi,\ntestq %rsi,\njg .L7\nrep; ret\n.L8:\n%rax\n%rsi\n%rsi\nmovq ret\n%rsi, %rax\n可以看到编译 器使用 了 guard e d- do 的翻译 方法 , 在第 3 行使用了 j l e 指令使得当初 始测试不成 立时 , 忽略循环代 码。 填写缺 失的 C 代码。 注意 汇编 语言中的 控制结构 不 一定 与根据翻译规则 直接 翻译 C 代码得 到的 完全 一致。 特别 地, 它有 两个 不同的r e t 指令(第10 行和第 13 行)。不过 , 你可以根 据等价的 汇编代码 行为填写 C 代码中缺 失的部分。\n让 练习题 3. 26 函数 f un_a 有如下 整体 结构 :\nlong fun_a(unsigned long x) { long val= O;\nwhile (\u0026hellip;) {\n}\nreturn \u0026hellip; ;\n}\nGCC C 编译器 产 生如 下 汇编 代码 :\nlong f un _a ( unsi gned long x) x in¼rdi\nfun_a:\n2 movl $0, %eax 3 jmp .LS 4 .L6: 5 xorq %rdi, %rax 6 shrq %rdi Shift right by 1 7 .LS: 8 testq %rdi, %rdi 9 jne .L6 10 andl $1, %eax 11 ret 逆向工程这段代码的操作,然后宪成下面作业:\n确定这段代码使用的循环翻译方法。 根据 汇编 代码版本填 写 C 代码 中缺 失的部分。 用 自 然语 言描述这 个函 数是 计算 什 么的 。 for 循环\nfor 循环的通用形式如下 :\nfor (init-expr; test-expr; update-expr) body-statement\nC 语言标准说明(有一个例外, 练习题 3. 29 中有特别说明), 这样一个循环的行为与下面这段使 用 wh il e 循环的 代码的 行为一样:\ninit-expr;\nwhile (test-expr) { body-statement update-exp,;\n}\n程序 首先对初始表达式 init-ex pr 求值, 然后 进入循环 ; 在循环 中它先对测试 条件 test 飞 x pr 求值, 如果测试结果为“ 假” 就会退出, 否则执行循环体 bod :r sta tement ; 最后对更新表达式 up d a te-ex pr 求值。\nGCC 为 f o r 循环产生 的代码是 wh i l e 循环的两种翻译 之一, 这取决于优化的等级。也就是, 跳转到 中间策略 会得到如下 go to 代码:\ninit-expr; goto test;\nloop:\nbody-statement update-expr;\ntest:\nt = test-expr; if (t)\ngoto loop;\n而 gua rded-do 策略得到 :\ninit-expr;\nt = test-expr; if (! t)\ngoto done;\nl oop :\nbody-statement update-expr;\nt = test-expr;\nif (t)\ngoto loop;\ndone:\n作为一个 示例 , 考虑用 f or 循环写的 阶乘函数:\nlong fact_for(long n)\nlong i;\nlong result = 1;\nfor (i = 2; i \u0026lt;= n; i++) result*= i;\nreturn result;\n如上述代码所示 , 用 f o r 循环编写阶乘函数最自然的 方式就是将从 2 一直到 n 的因子乘起来 , 因此, 这个 函数与我们使用 wh il e 或者 d o - wh il e 循环的代码很不一 样。\n这段代码中的 f or 循环的不同 组成部分 如下 :\ninit-expr i = 2\ntest-expr i \u0026lt;= n\nupdate-expr i ++\nbody-statement result *= i;\n用这些部分替换前面给出的 模板中相应 的位置, 就把 f or 循环转换成了 wh i l e 循环, 得到下面的代码:\nlong fact_for_while(long n)\n{\nlong i = 2;\nlong result = 1; while (i \u0026lt;= n) { result*= i;\ni++;\n}\nreturn result;\n}\n对 wh i l e 循环进行跳转到中间 变换 , 得到如下 g o to 代码 :\nlong fact_for_jrn_goto(long n)\n{\nlong i = 2;\nlong result= 1; goto test;\nloop:\nresult*= i;\ni++;\ntest:\nif (i \u0026lt;= n)\ngoto loop; return result;\n确实, 仔细查看使用命令行选项\u0026ndash;Og 的 GCC 产生的汇编代码, 会发现它非常接近于以下模板:\nlong fact_for(long n) n in¼rdi\nfact_for:\nmovl movl jmp\n.19:\nimulq addq\n.18:\ncmpq jle rep;\nret\n$1, %eax\n$2, %edx\n.L8\n%rdx, %rax\n$1, %rdx\n%rdi, %rdx\n.L9\nSet result= 1\nSeti= 2\nGoto test loop:\nComputer es ul t *= 工\nIncrement i test:\nCompare i : n If\u0026lt;=, goto loop Return\n; 练习题 3. 27 先 把 f a c t —f or 转 换 成 wh i l e 循 环 , 再 进 行 g ua rd ed- do 变 换, 写出\nf a c t _ f o r 的 g o t o 代码。\n综上所述 , C 语言中三种形式的 所有的 循环 d o - wh i l e 、 wh i l e 和 f o r 都可以用一种简单的策略来翻译,产生包含一个或多个条件分支的代码。控制的条件转移提供了 将循环翻译成机器代码的基本机制。\n江 义 练习题 3. 28 函 数 f u n—b 有如下整体结 构:\nlong fun_b(unsigned long x) { long val= O;\nlong i;\nfor (. . . ; . . . ; . . .) {\n}\nreturn val;\nGCC C 编译器产 生如下 汇编 代码 :\nl ong 丘m _ b ( 皿s i gned long x) x in %rdi\nfun_b:\nmovl movl\n.110:\nmovq\n$64, %edx\n$0, %eax\n%rdi, %rcx\n6 andl $1, %e c x 7 ad dq %rax, %rax 8 or q %rcx, %rax 9 s hr q r% di Shift right by 1 10 s ubq $1, %r dx 11 jne .110 12 rep; ret 逆向工程这段代码的操作,然后完成下面的工作:\n根据 汇编代 码版本填 写 C 代码 中缺 失的部分。\n解释循环前为什么没有初始测试也没有初始跳转到循环内部的测试部分。\n用自然语言描述这个函数是计算什么的。\n讫; 练习题 3. 29 在 C 语 言 中执行 c o n t i n ue 语 句会导 致 程 序 跳 到 当 前 循环 迭代 的 结 尾。当处理 c o n t i n ue 语句 时 , 将 f or 循环 翻译 成 wh i l e 循 环 的 描述 规则 需 要 一 些 改进。例如,考虑下面的代码:\nI* Example of for l oop cont a i ni ng a continue statement *I I* Sum even numbers between O and 9 *I\nlong sum= O; long i;\nfor (i = O; i \u0026lt; 10; i++) {\nif (i \u0026amp; 1)\ncontinue; sum += i ;\n如果 我们 简 单地 直 接 应 用 将 f o r 循 环 翻译 到 wh i l e 循 环 的 规则 , 会得 到 什 么呢? 产生的代码会有什么错误呢?\n如何用 g o t o 语 句来 替代 c o n t i n ue 语句 , 保证 wh i l e 循环的行 为同 f or 循环的行为完全一样?\n6. 8 s w itc h 语句\ns wi t c h ( 开关)语句可以根据一个整数 索引值进行多重分支( m ult iw ay bra nching ) 。 在处理具有多种可能结果 的测试时 , 这种 语句特别有用。它们不仅提高了 C 代码的可读性,而且通 过使用跳 转表 ( jum p ta ble ) 这种数据结构使得实现更加高效。跳转表是一个数组,表项 t 是一个代码段的 地址 , 这个代码 段实现当开关 索引值等千 1 时程序应该 采取的 动作。程序代码用开关索引值来执行一个跳转表内的数组引用,确定跳转指令的目标。和使用一组很 长 的 江- e l s e 语句相比, 使用跳转表的优点 是执行开关 语句的时间与开关情况的 数 扯无关。GCC 根据开关 情况的数 量和开关情况值的稀疏程度来 翻译 开关语句。当开关情况数量比较多(例如4 个以上), 并且值的 范图跨度比较小 时, 就会使用跳转 表。\n图 3- 22a 是一个 C 语言 SW止 c h 语句的示例。这个 例子有些 非常有意思的特征, 包括情况标号 ( case la be!) 跨过一个不 连续 的区域(对于情况 101 和 105 没有标 号), 有些情况有多个标号(情况 104 和 106 ) , 而有些情况则 会落入其他情况之 中(情况 10 2 ) , 因为对应该情况的代码段没有 以 br e a k 语句结尾。\n图 3-23 是编译 s wi t c h_e g 时产生的汇编代码。这段 代码的行为用 C 语言来描述就是图 3-226 中的过程 s wi t c h_e g _ i mp l 。 这段代码使用了 GCC 提供的 对跳转表的支持, 这是\n对 C 语言的扩展。数组 j t 包含 7 个表项 , 每个都是一个代码块的地址 。这些位置由 代码中的标号定义,在]七的表项中由代码指针指明,由标号加上飞矿前缀组成。(回想运算符\n& 创建一个指向数 据值的指针。在做这个扩展时, GCC 的作者们创造了一个新的运算 符\n&&, 这个运算 符创建一个指向代码位 置的指 针。)建议你研究一下 C 语言过程 s wi t c h_e g —\nimpl, 以及它与汇编代码版本之间的关系。\nvoid switch_eg_impl(long x, long n,\n2 long *dest)\n3 {\nI* Table of code pointers *I\nstatic void *jt [7] = {\nvoid switch_eg(long x, long n, 6 \u0026amp;\u0026amp;loc_A, \u0026amp;\u0026amp;loc_def, \u0026amp;\u0026amp;loc_B, long *dest) 7 \u0026amp;\u0026amp;loc_C, \u0026amp;\u0026amp;loc_D, \u0026amp;\u0026amp;loc_def,\n{ 8 \u0026amp;\u0026amp;loc_D\nlong val= x; 9 };\n10 unsigned long index= n - 100;\nswitch (n) { I ,, long val;\n12 case 100: val*= 13; 13 14 if (index\u0026gt; 6) goto loc_def; break; 15 I* Multiway branch *I 16 goto *jt[index]; case 102: val+= 10; 17 18 loc_A: I* Case 100 *I I* Fall through *I 19 val= x * 13; 20 goto done; case 103: 21 loc_B: I* Case 102 *I val += 11; 22 X = X + 10; break; 23 /• Fall through•/ 24 loc_C: I* Case 103 *I case 104: 25 val = x + 11; case 106: 26 goto done; val*= val; 27 loc_D: I* Cases 104, 106 *I break; 28 val= x * x; 29 goto done; default: 30 loc_def: I* Default case *I val= O; 31 val= O; } 32 done: *dest = val; 33 *dest = val; } 34 } a ) s wi t c h语句 b ) 翻译到扩展的 C语言\n图 3-22 s wi t c h 语句示例以及翻译到扩展的 C 语言。该翻译 给出了 跳转表 j t 的结构, 以 及如何访问它。作为对 C 语言的扩展 , GCC 支持 这样 的表\n原始的 C 代码有针对值 100 、102-104 和 10 6 的清况 , 但是开关变量 n 可以是任意整数。编译器首先将 n 减去 100 , 把取值范围移到 0 和 6 之间, 创建一个新的程序变量 , 在我们的 C 版本中称为 i nde x。补码表示的负数会映射成无符号表示的大正数 , 利用这一事实 , 将 i nde x 看作无符号值, 从而进一步简化了分支的可能性。因此可以 通过测试 i nde x 是否大于 6 来判定i nde x 是否在 0 ~ 6 的范围之外。在 C 和汇编代码中 , 根据 i nde x 的值, 有五个不同的跳转位\n置: loc A( 在汇编代码中标识为 . 1 3) , loc B(.LS), loc C(.16), loc D( . 1 7 ) 和 l o c def\n(.18), 最后一个是默认的目的地址。每个标号都标识一个实 现某个情 况分支的代码块。在 C\n和汇编代码 中, 程序都是将 i nde x 和 6 做比较, 如果大千 6 就跳转到默认的代码处。\nvoid switch_eg(long x, long n, long *des t )\nx in %rdi, n in %sr switch_eg:\ni , dest in %rdx\nsubq $100, %rsi\ncmpq $6, %rsi\nja .18\njmp *. 14(,%rsi, 8)\n.13:\nleaq (%rdi,%rdi,2), %rax leaq (%rdi,%rax,4), %rdi jmp .12\n.15:\naddq $10, %rdi\n.16:\naddq $11, %rdi\njmp .12\n.17:\nimulq %rdi, %rdi jmp .12\n.18:\nmovl $0, %edi\n.12:\nmovq %rdi, (%rdx) ret\nComp ut e index = n-1 00\nCompar e i nde x: 6 If\u0026gt;, goto l oc_def Goto *jt [index]\nl oc _A : 3•x\nval = 13•x\nGoto done l oc _B :\nX = X + 10\nl oc_ C :\nval = x + 11 Goto done\nl oc _D:\nval = x * x Goto done\nl oc _de f : val = 0\ndone :\n•dest = val Return\n图 3-23 图 3-22 中 s wi t c h 语句示例的汇编代码\n执行 s wi t c h 语句的关键步骤是通过跳转 表来访问代码位 置。 在 C 代码中是 第 1 6 行, 一条 g o t o 语句引用了跳转表 j t 。GCC 支持计算 g o t o ( co m p u ted goto), 是对 C 语言的扩展。在我们的 汇编代码 版本中, 类似的操作是在第 5 行, j mp 指令的操作数有前缀`*',表明这 是一个间 接跳转 , 操作数指定一个内存位置, 索引由寄存器%r s i 给出 , 这个寄存 器保存着 i n d e x 的值。(我们会在 3. 8 节中看到如何 将数组引 用翻译 成机器代码 。)\nC 代码将跳转 表声明 为一个 有 7 个元素的 数组 , 每个元素都是 一个指向代码位置的指针。这些元素跨越 i n d e x 的值 0 ~ 6 , 对应于 n 的值 100 ~ 10 6。可以 观察到, 跳转表对重复情况 的处理就是 简单地对表项 4 和 6 用同样的 代码标号( l o c _ D) , 而对千缺失的情况的处理就是对表 项 1 和 5 使用默认情 况的标 号( l o c _ d e f ) 。\n在汇编代码中,跳转表用以下声明表示,我们添加了一些注释:\n2 .section .align 8 .rodata Align address to multiple of 8 3 .L4: 4 .quad .L3 Case 100: loc_A 5 .quad .LS Case 101: loc_def 6 .quad .15 Case 102: loc_B 7 .quad .L6 Case 103: loc_C 8 .quad .L7 Case 104: loc_D 9 .quad .L8 Case 105: loc_def 10 .quad .17 Case 106: loc_D 这些声明 表明 , 在叫做 \u0026quot; . r o d a t a \u0026quot; ( 只读数据, R e ad- O nly Dat a ) 的目标代码文件的 段中 , 应该有一组 7 个“四” 字( 8 个字节), 每个字的值都是与指定 的汇编代码标号(例如 . L3) 相关联的指令地址。标号 . L4 标记出这个分配地址 的起始。与这个标号相对应的 地 址会作为间 接跳转(第5 行)的基地址。\n不同的代码块CC 标号 l oc _A 到 l oc _ D 和 l oc—de f ) 实现了 s wi t ch 语句的不同分支。它\n们中的大多数只是简单地计算了 va l 的值, 然后跳转到函数的结 尾。类似地 , 汇编代码块计 算了寄存器 %r 中 的值, 并且跳转到函数结 尾处由标号. L2 指示的位置。只有情况标号 102 的 代码不是这种模式的 , 正好说明在原始 C 代码中情况 102 会落到 情况 103 中。具体处理如下: 以标号. LS 起始的汇编代码块中, 在块结尾处没有 j rnp 指令, 这样代码就会继续执行下一个块。类似地, C 版本 s wi t c h_e g _i rnp l 中以标号 l oc_B 起始的块的结尾处也没有 got o 语句。\n检查所有这些代码需要很仔 细的研究, 但是关键是领会使 用跳转表是一种非常有效 的实现多 重分 支的方法。在我 们的例子中, 程序可以 只用一次跳转表引用就分支到 5 个不同的位置。甚 至当 s wi t c h 语句有上百 种情况的时候 , 也可以只 用一次跳转表访问 去处理。 亡 练习题 3. 30 下 面的 C 函数省略 了 S W 江 c h 语句的 主体 。在 C 代码 中 , 情况标 号是不\n连续的,而有些情况有多个标号。\nvoid switch2(long x, long *dest) { long val= O;\nswitch (x) {\nBody of switc h statement omitted\n*dest = val;\n在编译该函数时, GCC 为程序的初 始部分生成了以 下汇编代码,变 量 x 在寄存器r%\nV O 工 d swi tch2(long x, long *dest)\nx in %rdi swi t ch2 :\ndi 中:\naddq cmpq ja jmp\n$1, %rdi\n$8, 1r儿 di\n.12\n*. 14(,%rdi, 8)\n为跳转表生成以下代码:\n.L4:\n.quad\n.quad\n. quad\n. quad\n.quad\n.quad\n.quad\n.quad\n.quad\n. L9\n.LS\n.L6\n. L7\n. L2\n.L7\n.L8\n.L2\n.LS\n根据 上述 信息 回答下 列问题 :\ns wi t c h 语 句内 情况标 号的值 分别是 多少? C 代码 中哪 些情况 有 多个标 号? 诈 练 习题 3. 31 对于 一 个 通用 结构的 C 函 数 s wi t c h er :\nvoid switcher(long a, long b, long c, long *dest)\n{\nlong val; switch(a) {\ncasa : I* Case A *I\nC =\nI* Fall through *I\ncase I* Case B *I\nval= break;\ncase I* Case C *I\ncase I* Case D *I\nval= break;\ncase I* Case E *I\nval= break;\ndefault:\nval=\n}\n*dest = val;\n}\nGCC 产 生如 图 3- 24 所 示 的 汇 编代码 和跳 转 表。\nVO 工 d switcher(long a, long b, l ong c, long *dest) a in %rdi, b 工 n %rsi, c in %rdx, dest in %rcx switcher:\ncmpq ja jmp\n.section\n.L7:\n$7, %rdi\n.12\n*. 14(,%rdi ,8)\nr. odat a\nxorq $15, %rsi\nmovq %rsi, %rdx\n.L3:\nleaq 112(%rdx), %rdi jmp .L6\n.LS:\nleaq salq jmp\n.L2:\n(%r dx , %r s i ) , %rdi\n$2, %rdi\n.L6\nmovq %rsi, %rdi\n.L6:\nmovq %rdi, (%rcx) ret\na ) 代码\n图 3-2 4 练习题 3. 31 的汇编代 码和跳转表\nb ) 跳转表\n填写 C 代码 中 缺 失的 部 分。除 了 情 况标 号 C 和 D 的 顺 序 之 外, 将 不 同 情 况 填入这个模板的方式是唯一的。\n3. 7 过程\n过程是软件中一种很重要的抽象。它提供了一种封装代码的方式,用一组指定的参数和一个可选的返回值实现了某种功能。然后,可以在程序中不同的地方调用这个函数。设计良好的软件用过程 作为抽象机制,隐藏某个行为的具体实现,同时又提供清晰简洁的接口定义,说明要计算的是哪些值, 过程会对程序状态产生什么样的影响。不同编程语言中, 过程的形式多样: 函数( function) 、方法( method) 、子例程( sub ro utine) 、处理函数( handler ) 等等, 但是它们有一些共有的特性。\n要提供对过程的机器级支持,必须要处理许多不同的属性。为了讨论方便,假设过程\np 调用过程 Q , Q 执行后返回到 P。这些动作包括下 面一个 或多个 机制 :\n传递控 制。在进入过 程 Q 的时候, 程序计数 器必须被设置为 Q 的代码的起始地址 , 然后在返回时, 要把程序计 数器设置为 P 中调用 Q 后面那条指令的 地址。\n传递数 据。P 必须能够向 Q 提供一个或多个参数, Q 必须 能够向 P 返回一个值 。\n分配和释放 内存。在开始 时, Q 可能需 要为局 部变量分 配空间, 而在返回前, 又必 须释放这些存储空间。\nx86-64 的过程实现 包括一组特殊的指令 和一些 对机器资源(例如寄存器和程序内存)使用的约定规则。人们花了大量的力气来尽量减少过程调用的开销。所以,它遵循了被认为 是最低要求策略的方法,只实现上述机制中每个过程所必需的那些。接下来,我们一步步 地构建起不同的机制,先描述控制,再描述数据传递,最后是内存管理。\n3. 7. 1 运行时栈\nC 语言过程调用机制的一个关键特性(大多数其他语言也是如此)在于使用了栈数据结构提供的后进先出的内存管理原则。在过 程 P 调用过程 Q 的例子中, 可以看到当 Q 在执行 时, p 以及所有在向上追溯到 P 的调用链中的过程, 都是暂时被挂起的。当Q 运行时, 它只需要为局部 变量分 配新的存储空间,或者设置到另一个过程的调用。 另一方面, 当 Q 返回时, 任何它所分配的局部存储空间都可以被释放。因此,程序可以用栈来管 理它的过程所需要的存储空间,栈和程序寄存器 存放着传递控制和数据、分配内存所需要的信息。当 P 调用 Q 时, 控制和数据信息添加到栈尾。当 P 返回时,这些信息会释放掉。\n如 3. 4. 4 节中讲过的, x86-64 的栈向低地址方向增长, 而栈指 针%r s p 指向栈顶元 素。 可以用 p us hq 和 p o p q 指令将数据存入栈中或是从栈中取出。将栈指针减小一个适当的量可以 为没有指定初始值的数据在栈上分配空间。类 似地,可以通过增加栈指针来释放空间。\n地址增大\n栈指针\n%rsp\n栈底\n栈“顶”\n较早的帧\n调用函数\nP的帧\n正在执行的\n函数Q的帧\n当 x86-64 过程需要的存储空间 超出寄存器能够存放的大小时,就会在栈上分配空间。这个部分 称为过程的栈帧 ( s t ack fr am ) 。图 3- 25\n图 3-25 通用的栈帧结构(栈用来传递参数、存储返回信息、保存寄存器,以及局部 存储。省略了不必要的部分)\n给出了运行时栈的通用结构,包括把它划分为栈帧。当前正在执行的过程的帧总是在栈 顶。当过程 P 调用过程 Q 时, 会把返回地址压入栈中, 指明当 Q 返回时, 要从 P 程序的哪个位置继续 执行。我们把这个 返回地址 当做 P 的栈帧的一部分, 因为它存 放的是与 P 相关的状态 。Q 的代码会 扩展当前 栈的边界 , 分配它的栈帧所需的空间。在这个空间中, 它可以保存寄存器的 值, 分配局部 变量空间, 为它调用的 过程设 置参数。大多数过程的 栈帧都是定长的 , 在过程的 开始就分 配好了。但是有些过程需 要变长的帧 , 这个问题会在 3. 10. 5 节中讨论。通过寄存 器, 过程 P 可以 传递最多 6 个整数 值(也就是指针和整数), 但是如果\nQ 需要更多的参数 , P 可以在调用 Q 之前在自己 的栈帧里存储好这些参数。\n为了提高空间 和时间效 率, x 8 6 - 64 过程只分 配自己所需 要的栈帧部分。例如, 许多过程有 6 个或者更 少的参数, 那么所有的参数都可以 通过寄存器传递。因此, 图 3- 25 中画出的某些栈 帧部分可以省略。实际上, 许多函数甚至根本不 需要栈帧。当所有的局部变量都可以保存在寄存器中,而且该函数不会调用任何其他函数(有时称之为叶子过程,此时 把过程调用看做树结构)时,就可以这样处理。例如,到目前为止我们仔细审视过的所有 函数都不 需要栈帧。\n7. 2 转移控制\n将控制从函数 P 转移到函数 Q 只需 要简单地把程序计数器 ( PC)设置为 Q 的代码的起始位置。不过 , 当稍后从 Q 返回的时候 , 处理器必须记录好它需要继续 P 的执行的代码位置。在x86-64 机器中, 这个信息是用指令 c a ll Q 调用过程 Q 来记录的。该指 令会把地址 A 压入栈中, 并将 PC 设置为 Q 的起始地址。压入的地址 A 被称为返回地址 , 是紧跟 在 c a l l 指令后面的那 条指令的地址。对应的指令r e t 会从栈中弹出地址 A , 并把 PC 设置为 A 。\n下表给出的是 c a l l 和r e t 指令的一般形式 :\n指令 描述 call Label 过程调用 call Operand 过程调用 ret 从过程调用中返回 (这些指令在程序 OBJDUMP 产生的反汇编输出中 被称为 c a ll q 和r e t q 。添加的后缀\u0026rsquo; q \u0026rsquo; 只是为了强 调这些是 x8 6- 64 版本的调用和返 回, 而不是 I A 3 2 的。在 x8 6- 64 汇编代码中, 这两种版本可以互换。)\nc a l l 指令有一个 目标, 即指明 被调用过程起始的指令地址。同 跳转一样, 调用可以是直接的,也可以是间接的。在汇编代码中,直接调用的目标是一个标号,而间接调用的 目标是 * 后面跟一个操作数指示符 , 使用的是图 3-3 中描述的格式之一。\n图 3- 26 说明了 3. 2. 2 节中介绍的 mu l t s t or e 和 ma i n 函数的 c a l l 和r e t 指令的执行情况。下面是这两个 函数的反汇编代码的 节选 :\nBeginning of function multstore\n1 0000000000400540 \u0026lt;multstore\u0026gt;:\n2 400540: 53\n3 400541: 48 89 d3\npush %rbx\nmov %rdx,%rbx\nReturn from f unc t i on mul tstore\n40054d: c3 retq\nCall to multstore from ma 工 n 400563: e8 d8 ff ff ff 400568: 48 8b 54 24 08\ncallq 400540 \u0026lt;multstore\u0026gt; mov Ox8(%rsp),%rdx\n在这段 代码中 我们 可以 看到 , 在 ma i n 函数中 , 地址 为 Ox 400 5 63 的 c a l l 指令调用 函 数 mu l t s t or e 。此 时的状态如图 3- 26a 所示, 指明了栈指针%r s p 和程序计数 器%豆 p 的值。 c a ll 的效果是 将返回地址 Ox 40 05 68 压入栈中,并跳到函数 mu l t s t or e 的第一条指令,地址为 Ox 0 40 05 40 ( 图 3- 266 ) 。函数 mu l t s t or e 继续执行, 直到 遇 到地址 Ox 40 05 4d 处的\nr e t 指令。这条指 令从 栈中弹出值 Ox 40 05 68 , 然后跳转到这个地址, 就在 c a l l 指令之后, 继续 ma i n 函数的 执行。\n霍。x7f f f驾言罚\nOx400568\na ) 执行ca l l b) ca ll 执行之后\nc) r e t 执行之后\n图 3-26 ca ll 和 r e t 函数的说明。ca l l 指令将控制 转移到一 个函数的起 始, 而 r e t 指令 返回 到这 次调 用后面的 那条指 令\n再来看一个更详细说明在过程间传递控制的 例子, 图 3- 27a 给出了两个函数 t op 和 l e a f 的反汇编代码, 以及 ma i n 函数中调用 t op 处的代码。每条指令都以标号标出: Ll ~ L 2 O e a f 中), T l ~ T 4 ( ma i n 中)和M l ~ M 2 ( ma i n 中)。该图的 b 部分给出了 这段代码执\nDisassembly of leaf(long y) y in¼rdi\n1 0000000000400540 \u0026lt;l e af \u0026gt; :\n2 400540: 48 8d 47 02\n3 400544: c3\n4 0000000000400545 \u0026lt;top\u0026gt;:\n历 s as s embl y of top(long x) x in¼rdi\nlea Ox2 (%rdi) , %rax L1: y+2 retq L2: Return\nCall to top from function main 9 40055b: e8 e5 ff ff ff callq 400545 \u0026lt;top\u0026gt; Ml: Call top(100) 1O 400560: 48 89 c2 rnov %rax, %rdx M2: Resume a ) 说明过程调用和返回的反汇编代码\n图 3-27 包含过 程调 用和返回的 程序的 执行细节 。使用栈来存储返回地址使得能够返回到过程中正确的位置\n指令 状态值(指令执行前) 描述 标号 PC 指令 兮r di %rax %rsp 飞 r s p Ml Ox40055b callq JOO Ox7fffffffe820 调用t op(100) Tl Ox400555 sub 100 Ox7fffffffeS18 Ox400560 进入t op T2 Ox400559 callq 95 Ox7fffffffe818 Ox400560 调用l e a f (95) LI Ox400540 lea 95 Ox7 fffffffe810 Ox40054e 进人l e a f L2 Ox400544 retq 97 Ox7 f ff f ff f e 81 0 Ox40054e 从l e a f 返回97 T3 Ox40054e add 97 Ox7f f f f f f f e 818 Ox400560 继续t op T4 Ox400551 retq 194 Ox7f f f ff ff e 818 Ox400560 从t op返回194 M2 Ox400560 rnov 194 Ox7 ff f ff ff e 820 继续ma i n b ) 示例代码的执行过程图 3-27 (续)\n行的详细 过程, ma i n 调 用 t o p ( l OO) , 然后 t o p 调用 l e a f ( 9 5 ) 。 函数 l e a f 向 t o p 返回\n97, 然后 t o p 向 ma i n 返回 1 9 4 。前面三列描述了被执行的指令, 包括指令标号、地址和指令类 型。后面四列给出了在该指令执行前程序的状态, 包括寄存器%r d i 、%r a x 和 %r s p 的内容,以及位于栈顶的值。仔细研究这张表的内容,它们说明了运行时栈在管理支持过 程调用和返回所需的存储空间中的重要作用。\nl e a f 的指令 L l 将%r a x 设 置为 9 7 , 也就是要返回的值。然后指令 L2 返回,它 从 栈中弹出 Ox 40 0 0 5 4e 。通过将 PC 设置为这个弹出的值,控 制转移回 七o p 的 T3 指令。程序成功 完成对 l e a f 的 调 用 ,返 回 到 t o p 。\n指令 T3 将%r a x 设 置为 1 9 4 , 也就是要从 t o p 返回的值。然后指令 T 4 返回,它 从 栈中弹出 Ox 4 0 0 0 5 60 , 因此将 PC 设置为 ma i n 的 M2 指令。程序成功完成对 t o p 的调用, 返回到 m釭n。可以 看到,此 时 栈 指针也恢复成了 Ox 7 f f f f f f f e 8 2 0 , 即 调 用 t o p 之 前 的 值 。\n可以看到,这种把返回地址压入栈的简单的机制能够让函数在稍后返回到程序中正确 的点。C 语言(以及大多数程序语言)标准的调用/返回机制刚好与栈提供的后进先出的内存管理方法吻合。\n讫 ]练习题 3 . 32 下面 列 出 的是 两个 函 数 f ir s t 和 l a 江 的 反 汇编 代 码 , 以 及 ma i n 函 数调用 f ir s t 的代码 :\nD 工 sas s e mbl y of last (long u, U 立 1 r% di , v in %rsi long v) 1 0000000000400540 \u0026lt;last\u0026gt;: 2 40054 0 : 48 89 f8 rnov %rdi,%rax L1 : u 400543: 48 Of af c6 irnul %rsi,%rax L2 : u• v 4 400547: c3 retq L3 : Return 釭 s ass embl y of first(long x) x in %rdi\n5 0000000000400548 \u0026lt;first\u0026gt;: 6 400548: 48 8d 77 01 lea Ox1(%rdi) , %rsi Fl : x+1 7 40054c: 48 83 ef 01 sub $0x1,%rdi F2: x-1 8 4 00550 : e8 eb ff ff ff callq 400540 \u0026lt;last\u0026gt; F3: Call last (x-1,x+1) 9 400555: f3 c3 repz retq F4: Return\n1o 400560: e8 e3 ff ff ff 11 400565: 48 89 c2\ncallq 400548 \u0026lt;first\u0026gt; M1 : Call f r工s t (10) mov %rax, %rdx M2 : Resume\n每条指令都 有 一个标 号 , 类似 于图 3- 2 7 a 。 从 ma i n 调用 丘r s 七 (1 0 ) 开始 ,到 程序返回 ma i n 时为止 , 填写 下表 记 录指令 执行 的过 程。\n指令 状态值(指令执行前) 标号 PC 指令 r% di r%s i % r ax r% s p * r% s p 描述 Ml Ox400560 ca ll q 10 Ox7fffffffe820 调用 f1rstO O) Fl F2 F3 L1 L2 L3 F4 MZ 7. 3 数据传送\n当调用一个过程时,除了要把控制传递给它并在过程返回时再传递回来之外,过程调 用还可能包括把数 据作为参数 传递, 而从 过程返回还有可能 包括返回一个值。x8 6- 64 中, 大部分 过程间 的数据传送 是通过寄 存器实现的 。例如 , 我们 已经看到无数的函数 示例 , 参数在寄存器 %r d i 、%r s i 和其他寄存 器中传递 。当过程 P 调用过程 Q 时, P 的代码必须首先把参数复制到适 当的寄存器中。类似地 , 当 Q 返回到 P 时, P 的代码 可以 访问寄存器%r a x 中的返回值。在本节中,我们更详细地探讨这些规则。\nx 8 6- 6 4 中, 可以 通过寄存特最多 传递 6 个整型(例如整数 和指针)参数。寄存器的使用是有特殊顺 序的 , 寄存器使用的名字取决千要传递的数据类型的大小, 如图 3- 28 所示。会根据参数在参数列表中的顺 序为它们分配寄 存器。可以 通过 6 4 位寄存器适 当的部分访问小于 6 4 位的参数 。例如 , 如果第一个参数是 3 2 位的 , 那么可以用%e d i 来访间它。\n操作数大小(位) 参数数扭 1 2 3 4 5 6 64 % r di % r s i % r dx 毛r c x r号 8 % r 9 32 %edi %es i %ed x %ec x %r8d r号 9d 16 %di %s i %dx %e x %r8w %r9w 8 令dil % s i l %dl %cl %r8b r皂 9b 图 3-28 传递函数参数的寄存器。寄存器是按照特殊顺序来使用的 , 而使用的 名字是 根据参数的大小来 确定的\n如果一 个函数有 大于 6 个整型参 数, 超出 6 个的部分就要通过栈来传递。假设过程 P\n调用过程 Q, 有 n 个整型 参数 , 且 n \u0026gt; 6 。那么 P 的代码分配的栈帧必须要能容纳 7 到 n\n号参数的存储空间 , 如图 3- 25 所示。要 把参数 1 ~ 6 复制到 对应的寄存 器, 把参数 7 ~ n 放\n到栈上 , 而参数 7 位于栈顶 。通过栈 传递参数时 , 所有的数 据大小都向 8 的倍数对齐。参数到位 以后, 程序就可以 执行 c a l l 指令将控 制转 移到过 程 Q 了。过程 Q 可以 通过寄存器访问参数, 有必要的 话也可以 通过栈 访问。相应地 , 如果 Q 也调用了 某个 有超过 6 个参数的函数 , 它也需要在自己的 栈帧中为超出 6 个部分的参数分配空 间, 如图 3- 25 中标号为“参数构造区”的区域所示。\n作为参数 传递的示例 , 考虑图 3- 2 9 a 所示的 C 函数 p r o c 。这个 函数有 8 个参数 , 包括字节数 不同的整数 ( 8 、4 、2 和 1) 和不同类 型的指针, 每个都是 8 字节的 。\nvoid proc(long a1, long *alp,\nint a2, int *a2p, short a3, short *a3p, char a4, char *a4p)\n{\n*a1p += a1;\n*a2p += a2;\n*a3p += a3;\n*a4p += a4;\n}\nC代码 void proc(a1, a1p, a2, a2p, a3, a3p, a4, a4p) Arguments passed as follows:\na1 in %rdi (64 bi ts)\nalp in %rsi (64 bi ts)\na2 in %edx (32 bi ts)\na2p in %rcx (64 bi ts)\na3 in %r 8 日 (16 bits)\na3pin %r9 (64 bi ts)\na4 at %rsp+8 (8 bi ts)\na4p at %rsp+16 (64 bits) proc:\nmovq addq addl addw movl addb ret\n16(%rsp), %rax\n%rdi, (%rsi)\n%edx, (%rcx)\n%r8w, (%r9) 8(%rsp), %edx\n%dl, (%rax)\nFetch a4p\n•a1p += a1\n•a2p += a2\n•a3p += a3 Fetch a4\n•a4p += a4 Return\n(64 bits)\n(64 bits)\n(32 bits)\n(16 bits)\n(8 bits)\n(8 bits)\n图 3-29\nb ) 生成的汇编代码\n有多个不同 类型参数的函数示例。参数 1 ~ 6 通过寄 存器传递 , 而参 数 7~ 8 通过 栈传递\n图 3- 2 9 6 中给出 pr o c 生成的 汇编代码。前面 6 个参数通过寄存器传递, 后面 2 个通过栈 传递 , 就像图 3-30 中画出来 的那样。可以看到, 作为过程调用的一部分, 返回地址被压 入栈中。因 而这两 个参数位千相对千栈指针距离为 8 和 16 的位置。 在这段代码中, 我们可 以看到根 据操作数的大小, 使用了 ADD 指令的不同版本: a l ( l o n g ) 使用 a d d q , a 2釭n 七)使用 a d d l , a 3 ( s h o r 七) 使用 a d d w, 而 a 4 ( c h ar ) 使用 a d d b 。请注意第 6 行的mov l 指令从内存 读入 4 字节, 而后面的 a d db 指令只使用其中的 低位一字节。\na4p\n返回地址\n16\na4 8\n。( 栈指针r% s p\n图 3-30 函数 proc 的栈帧结构。参数 a 4 和 a 4p 通过栈传递\n芦 练习题 3. 33 C 函 数 pr o c pr o b 有 4 个参数 u、a 、v 和 b , 每个参 数 要 么 是 一个 有 符号数,要么是一个指向有符号数的指针,这里的数大小不同。该函数的函数体如下:\n*U += a;\n*V += b;\nreturn sizeof(a) + sizeof(b);\n编译得到 如下 x86-64 代 码 :\nprocprob:\nmovslq %edi, %rdi addq %rdi, (%rdx)\naddb %s il, (%rcx)\nmovl $6, %eax ret\n确定 4 个参数的合 法 顺 序 和 类 型。 有 两种 正 确 答案。\n7. 4 栈上的局部存储\n到目前为止我们看到的大多数过程示例都不需要超出寄存器大小的本地存储区域。不 过有些时候,局部数据必须存放在内存中,常见的情况包括:\n寄存器不足够存放所有的本地数据。\n对一个局部变最使用地址运算符'&',因此必须能够为它产生一个地址。\n某些局部变量是数组或结构,因 此 必 须 能 够 通过数组或结构引用被访问到。在描述数组和结构分配时 , 我们会讨论这个问题。\n一般来说, 过程通过减小栈指针在栈上分配空间。分配的结果作为栈帧的一部分, 标号为 "局部变量” ,如 图 3- 25 所示。\n来看一个处理地址运算符的例子,图 3-3 l a 中给出的两个函数。函数 s wa p _ a d d 交换指 针 xp 和 yp 指向的两个值,并 返回这两个值的和。函数 c a l l er 创建到局部变量 a r g l 和 ar g 2 的指针,把 它 们传递给 s wa p_a d d 。 图 3-31 6 展 示了 c a l l er 是如何用栈帧来实现这些局 部 变最的。c a l l er 的代码开始的时候把栈指针减掉了 1 6 ; 实际上这就是在栈上分配了 16 个 字节。S 表示栈指针的值, 可以 看到这段代码计算 \u0026amp;ar g 2 为 S + 8 \u0026lt; 第 5 行), 而 \u0026amp;ar g l 为 S 。因此可以推断局部变量 ar g l 和 ar g 2 存放在栈帧中相对于栈指针偏移量为 0 和 8 的 地 方 。 当 对 s wa p _ a d d 的 调 用 完成后, c a l l er 的 代码会从栈上取出这两个值(第8 ~ 9 行),计 算它们的差,再 乘以 s wa p_ a d d 在寄存器 %r a x 中 返回的值(第10 行)。最后, 该 函数把栈指针加 16 , 释放栈帧(第11 行)。通过这个例子可以看到, 运行时栈提供了一种简单的 、在需要时分配、函数完成时释放局部存储的机制。\n如图 3-32 所示 , 函数 c a l l _pr o c 是一个更复杂的例子,说 明 x8 6-64 栈行为的一些特性。尽管这个例子有点儿长,但还是值得仔细研究。它给出了一个必须在栈上分配局部变 量存储空间的函数,同 时 还要向有 8 个参数的函数 pr o c 传递值(图3-29 ) 。该 函数创建一个栈帧 , 如图 3-33 所示。\nlong swap_add(long *XP, long *yp)\n{\nlong x = *xp; long y = *yp;\n*XP = y;\n*YP = x; return x + y;\nlong caller()\n{\nlong argl = 534; long arg2 = 1057;\nlong sum= swap_add(\u0026amp;argl, \u0026amp;arg2); long diff = argl - arg2;\nreturn sum* diff;\n}\na) swap_add和调用函数的代码\nlong caller() caller:\nb ) 调用函数生成的汇编代码\n图 3-31 过程定义和调用的示例。由于会使用地址运算符,所以调用代码必须分配一个栈帧\nlong call_proc ()\n{\nlong xl = 1; int x2 = 2; short x3 = 3; char x4 = 4;\nproc(xl, \u0026amp;xl, x2, \u0026amp;x2, x3, \u0026amp;x3, x4, \u0026amp;x4); return (x1+x2)*(x3-x4);\n}\na) swap_add 和调用函数的代码\n图 3-32 调 用 在图 3-29 中定义的函数 pr oc 的代码示 例。该代码创建了一个栈帧\nlong call_proc() call_proc:\nSet up arguments to proc\n2 subq $32, %rsp Allocate 32-byte stack frame 3 movq $1, 24(%rsp) Store 1 in \u0026amp;xl 4 movl $2, 20(%rsp) Store 2 in \u0026amp;x2 5 movw $3, 18 (%rsp) Store 3 in \u0026amp;x3 6 movb $4, 17(%rsp) Store 4 in \u0026amp;x4 7 leaq 17(%rsp), %rax Create \u0026amp;x4 8 movq %rax, 8(%rsp) Store \u0026amp;x4 as argument 8 9 movl $4, (%rsp) Store 4 as argument 7 10 leaq 18(%rsp), %r9 Pass \u0026amp;x3 as argument 6 11 movl $3, %r8d Pass 3 as argument 5 12 leaq 20(%rsp), %rcx Pass \u0026amp;x2 as argument 4 13 movl $2, 1儿 e dx Pass 2 as argument 3 14 leaq 24()儿r s p ) , )儿r s i Pass \u0026amp;xl as argument 2 15 movl $1, %edi Pass 1 as argument 1 Call proc 16 call proc Retrieve changes to memory 17 movslq 20(%rsp), %rdx Get x2 and convert to long 18 addq 24(%rsp), %rdx Compute x1+x2 19 movswl 18(%rsp), %eax Get x3 and convert to int 20 movsbl 17(%rsp), %ecx Get x4 and convert to int 21 subl %ecx, %eax Compute x3-x4 22 cltq Convert to long 23 imulq %rdx, %rax Compute (x1+x2) * (x3-x4) 24 addq $32, %rsp Deallocate stack frame 25 ret Return b ) 调用函数生成的汇编代码图 3 32 (续)\n看看 c a l l _pr o c 的汇编代码(图3- 3 2 b ) , 可以看到 代码中一 大部分(第2 ~ 1 5 行)是为调用 pr o c 做准备。其中包括为局部 变最 和函数参数建立栈 帧, 将函数参数 加载至寄 存器。如 图 3- 33 所示, 在栈上分 配局部变量 x l ~ x 4 , 它们具有不同的大小: 24~3l(xl), 20~23 (x2), 18~ 19(x3)和 1 7 ( s 3 ) 。用 l e a q 指令生成 到这些 位置的指针(第7 、10 、1 2 和 1 4 行)。参数 7 ( 值为 4 ) 和 8 ( 指向 x 4 的位置的指针)存放在栈中相对于栈指针偏移量为 0 和 8 的地方。\n当调用过 程 pr o c 时, 程序会 开始执行 图 3- 2 9 b 中的代码 。如图 3- 30 所示, 参数 7 和\n8 现在位 千相 对千栈 指针偏移量为 8 和 16 的地方 , 因为返回地址这时已 经被压入栈中了。当程序返 回 c a ll —pr o c 时, 代码会取出\n返回地址\n32\nxl\n4 个局部变量(第1 7 ~ 20 行), 并执行最终的计算。在 程序结束前, 把栈指针加 3 2 , 释放这个栈帧。\n7. 5 寄存器中的局部存储空间\nx2\n参数8 = \u0026amp;x4\n参数7\nol \u0026mdash;一 栈指针釭 s p\n寄存器组是唯一被所有过程共享的资源。\n图 3-33 函数 ca ll _yr oc 的栈帧。该栈帧包含局部\n变量 和两个要传递 给函数 proc 的参数\n虽然在给定时刻只有一个过程是活动的,我们仍然必须确保当一个过程(调用者)调用另一 个过程(被调用者)时, 被调用 者不会覆盖调用 者稍后 会使用的寄 存器值。 为此, x86-64 采用了一组统一的寄存器使用惯例,所有的过程(包括程序库)都必须遵循。\n根据惯例 , 寄存器%r b x 、%r b p 和 %r 1 2~ %r 1 5 被划分为 被调 用者保 存寄存器。当过程 P 调用过程 Q 时, Q 必须保存这些寄存器的值, 保证它们的值在 Q 返回到 P 时与 Q 被调用时是一样 的。过程 Q 保存一个寄存器的值不变, 要么就是根本不去改 变它, 要么就是把原 始值压入栈中,改变寄存器的值,然后在返回前从栈中弹出旧值。压入寄存器的值会在栈帧 中创建标 号为“保 存的寄存器” 的一部分 , 如图 3- 25 中所示。有了 这条惯 例, P 的代码就能安全地 把值存 在被调用 者保存寄存器中(当然, 要先把之前的值保存到栈上), 调用 Q, 然后继续使用寄存器中的值,不用担心值被破坏。\n所有其他的 寄存器 , 除了栈指针%r s p , 都分类为调用者保存寄存器。这就意味着任何函数都 能修改它 们。可以这样来理解“调用 者保存” 这个名 字: 过程 P 在某个此类寄存器中有局部数 据, 然后 调用过程 Q。因为 Q 可以 随意修改 这个 寄存器, 所以在调 用之前首先保存好这 个数据是 p ( 调用者)的责任。\n来看一个例子 , 图 3-34a 中的函数 P。它两次调用 Q。在第一次调用中 ,必 须保存 x 的值以备后面 使用。类似地, 在第二次调用中 , 也必须保存 Q (y ) 的值。图 3-346 中, 可以 看到 GCC 生成的代码使用了 两个被调用 者保存 寄存器:%r b p 保存 x 和%r b x 保存计算出来的\nlong P(long x, long y)\n{\nlong u = Q(y); long v = Q(x); return u + v;\n}\na ) 调用函数\nlong P(long x, long y)\nx in %rdi , y in %rsi 1 P: 2 pushq %rbp Save ¼r bp 3 pushq %rbx Sa ve r¼ bx 4 subq $8, %rsp Align stack frame 5 movq %rdi, %rbp Save x 6 rnovq %rsi, %rdi Move y to first argument 7 call Q Call Q(y) 8 rnovq %rax, %rbx Save result 9 movq %rbp, %rdi Move x to first argument 10 call Q Call Q(x) 11 addq %rbx, %rax Add saved Q(y) to Q( x) · 12 addq $8, %rsp Deallocate last part of stack 13 popq %rbx Restore %rbx 14 popq %rbp Res t or e r 妇 bp 15 ret b ) 调用函数生成的汇编代码\n图 3-34 展示被调用者保存寄存器使用的代码。在第 一次调用中 , 必须保存 x 的值 , 第二次调用中 , 必须保存 Q( y ) 的值\nQ (y ) 的值。在函数的开头 , 把这两个寄存 器的值保存到栈中(第2~ 3 行)。在第一 次调用 Q\n之前, 把参数 x 复制到 %r bp ( 第 5 行)。在第二次调用Q 之前, 把这次调用的结果复制到 %r bx\n(第8 行)。在函数的结尾 ,(第1 3 ~ 1 4 行), 把它们从栈中弹出 , 恢复这两个被调用者保存寄存器的值。注意它们的弹出顺序与压入顺序相反,说明了栈的后进先出规则。\n亡 练习题 3. 34 一个 函数 P 生成名 为 a 0~ a 7 的 局部 变 量 , 然后调 用 函 数 Q, 没 有参数。\nGCC 为 P 的 第一部分 产 生如下代码 :\nlong P(long x) x in %rdi\nP:\n2 pushq .r儿 15 3 pushq %r14 4 pushq %r13 5 pushq %r12 6 pushq %rbp 7 pushq %rbx 8 subq $24, %rsp 9 movq 。r 儿 di , %rbx 10 leaq 1(%rdi), ;r儿 15 1 1 leaq 2(%rdi), %r14 12 leaq 3(%rdi), %r13 13 leaq 4(%rdi), %r12 14 leaq 5(%rdi), %rbp 15 leaq 6(%rdi), %rax 16 movq %rax, (%rsp) 1 7 leaq 7(%rdi), %rdx 18 movq %rdx, 8(%rsp) 19 movl $0, %eax 20 call Q 确定哪些局部值存储在被调用者保存寄存器中。\n确定 哪 些局部 变量存储在栈 上。\nc. 解释 为 什 么不能把所有的局部值都 存储在被调用老保 存寄 存器 中。\n7. 6 递归过程\n前面已 经描述的寄 存器和栈 的惯例使得 x8 6- 64 过程能够递归地调用它们自身。每个过程调用 在栈中都有它自己 的私有空间 , 因此多个未完成 调用的局部变量 不会相互影响 。此外,栈的原则很自然地就提供了适当的策略,当过程被调用时分配局部存储,当返回时 释放存储 。\n图 3- 35 给出了 递归的阶乘函数的 C 代码 和生 成的汇编代码。可以 看到汇编代码使用寄存器%r b x 来保存参数 n , 先把巳有的值保存在栈上(第2 行),随 后在返回前恢复该值\n(第 11 行)。根据栈的使用特性 和寄存器保 存规则 , 可以 保证当递归调用r f a c t (n - 1 ) 返回时(第9 行), (1 ) 该 次调用的结果会保存在寄存器%r a x 中, ( 2 ) 参数 n 的值仍然 在寄存器% r b x 中。把这两个值相乘就能 得到期望的 结果。\n从这个例子我们可以 看到 , 递归调用一个函数本身 与调用其他函数是一样的。栈规则提供了一种机制, 每次 函数调用 都有它自己 私有的状态信息(保存的返回位置和被调 用者保存寄存器的值)存储空间。如果需 要, 它还可以 提供 局部变量的存储。栈分配和释放的\n规则很自然 地就与函数 调用-返回的顺序匹配。这种实现函数调用和返回的方法甚至对更复杂的 情况也适用 , 包括相 互递归调用(例如, 过程 P 词用 Q , Q 再调用 p ) 。\nlongr fa ct (l ong n)\n{\nlong result;\n辽 (n \u0026lt;= 1)\nresult= 1;\nelse\nresult= n *r fact(n-1);\nreturn result;\n}\nC代码 long rfact(long n) n in %rdi\nrfact:\npushq movq movl cmpq jle leaq call imulq\n.135:\npopq ret\n%r b x\n%rdi, %rbx\n$1, %ea x\n$1, %rdi\n. L35\n-1(%rdi), %rdi rfact\n%rbx, %rax\n%rbx\nSave %rbx\nStore n in callee-saved register Set return value = 1\nCompare n: 1\nIf \u0026lt;=, goto done Compute n-1\nCallr f act (n-1)\n加 l t i pl y result by n done :\nRestore %rbx Return\nb ) 生成的汇编代码\n图 3-35\na 练习题 3. 35\n递归的阶乘程序的代码。标准过程处理机制足够用来实现递归函数\n一个具有通用 结构的 C 函 数如下:\nlong rfun(unsigned long x) { if ( - - \u0026mdash;- - )\nreturn - '\nunsigned long nx = ; longr v = rfun(nx);\nreturn \u0026ndash; '\n}\nGCC 产 生 如下 汇 编代 码 :\nlong rfun (uns i gned long x) x in %rdi\nrfun:\npushq movq movl testq\n%rbx\n%rdi, %rbx\n$0, %eax\n%rdi, %rdi\n6 je .12 7 shrq $2, %rdi 8 call rfun 9 addq %rbx, %rax 10 .12: 11 popq %rbx 12 ret r f u n 存储在被调用 者保 存寄 存器%r b x 中的值 是什 么? 填写上述 C 代码 中缺 失的 表达 式。 3. 8 数组分配和访问\nC 语言中的数组是一种将标量数据聚集成更大数据类型的方式。 C 语言实现数组 的方式非常简单 , 因此很容易 翻译成机器代码。 C 语言的一个不同寻常的 特点是可以产生指向数组中元素的指针,并对这些指针进行运算。在机器代码中,这些指针会被翻译成地址计算。\n优化编译器非 常善于简化数组索引 所使用的 地址计算 。不过这使 得 C 代码和它到机器代码的翻译之间的对应关系有些难以理解。\n3. 8. 1 基本原则\n对于数据类型 T 和整型常数 N , 声明如下:\nT A[N];\n起始位 置表示 为 环。这个声明有两个效果。首先 , 它在内存中分配一个 L • N 字节的连续\n区域, 这里 L 是数据类型 T 的大小(单位为字节)。其次 , 它引入了标识符 A , 可以用 A 来作为指向 数组开头的 指针, 这个指针的值就是 X A 。 可以 用 O~ N -1 的整数索引来访问该数\n组元素。数组元 素 z 会被存放 在地址 为 X A +L. i 的地方。作为示例,让我们来看看下面这样的声明:\nchar A[12];\nchar *B[8];\nint C [6]; double *D[5];\n这些声明会产生带下列参数的数组:\n数组 元素大小 总的大小 起始地址 元素 t A B C D 1 8 4 8 12 64 24 40 x. Xa Xe 工·o 乓卢- l xs +, 8 xc+4, X 。+ B, 数组 A 由 1 2 个单字节 ( c h ar ) 元素组成 。数组 C 由 6 个整数组成 , 每个需 要 8 个字节。\nB 和 D 都是指针数组 , 因此每个数组元 素都是 8 个字节。\nx8 6- 64 的内存引用指令可以用来简化数组访问。例如, 假设 E 是一个 i n t 型的数组, 而我们 想计算 E [i], 在此, E 的地址存放在寄存器%r d x 中, 而 l 存放在寄存器%r c x 中。然后,指令\nmovl (%rdx, %rcx, 4) , i儿e ax\n会执行地址计算 XE+ 4 i , 读这个内 存位置的值, 并将结果存放到寄 存器%e a x 中。允许的\n伸缩因子 1 、2 、4 和 8 覆盖了所有基本简单 数据类型的 大小。\n江 练习题 3. 36 考虑下面的声明:\nshort 8[7];\nshort *T [3] ; short **U [6] ; int V[8]; double *W[4];\n填写下表 , 描述每个 数 组的 元 素大小 、整个 数 组的 大小 以及 元 素 t 的地 址:\n数组 元素大小 整个数组的大小 起始地址 元素 t s 工s T .rr u Xu V .rv 付 .:rw 3. 8. 2 指针运算\nC 语言允许对指针进行 运算 , 而计算出来的 值会根据该 指针引 用的数据类型的 大小 进行伸缩。也 就是说 , 如果 p 是一个指向类型 为 T 的数据的指针, p 的值为 芬, 那么表达式p+ i 的值为 丐+ L • i, 这里 L 是数据类型 T 的大小。\n单操作数操作符`矿和\u0026rsquo;*\u0026lsquo;可以产生指针和间接引用指针。也就是,对千一个表示某 个对象的 表达式 Ex pr , \u0026amp;Ex pr 是给出该对象地址的一个指针。对于一 个表示地址的表达式 AExpr , *AEx pr 给出该 地址处的 值。因此 , 表达式 Exp r 与* \u0026amp;Ex pr 是等价的。可以 对数组和指针应用数组下 标操作。数组引 用 A [ i ) 等同千表 达式 * (A+ i ) 。 它计算第 z 个数组元素的地址,然后访问这个内存位置。\n扩展一下前面的例子, 假设整型数组 E 的起始地址和整数索引 z 分别存放在寄存器\n%r dx 和%r c x 中。下 面是一些 与 E 有关的表达式 。我们还给出 了每个 表达式 的汇编代码实现, 结果存放在寄 存器 %e a x ( 如果是 数据)或寄存器 %r a x ( 如果是指针)中。\n表达式 类型 值 汇编代码 E int* 工E movq % r dx , % r a x E[O] int M[ .r r.] mo v l ( % r dx ) , 另r a x E [i ) int M 妇 + 4,] movl ( % r dx, 毛 r c x , 4 ) , %e a x \u0026amp;E[2] int* 工 E+ 8 leaq 8( % r dx ) , 毛 r a x E+i-1 int* 工E 十如一4 l e aq - 4 ( 毛r d x, 毛r c x, 4 ) , %r a x * (E+i-3) int M伍 + , 4 - 1 2] mo v l - 1 2 ( %r d x, %r c x , 4 ) , %e a x \u0026amp;E[i}-E long l movq %r c x , % r a x 在这些 例子中 , 可以 看到返 回数组值的操作类型为 i n t , 因此涉及 4 字节操作(例如\nmov l ) 和寄存器(例如%e a x ) 。 那些返回指针的操作类型为 i n t * , 因此涉及 8 字节操作\n(例如 l e a q ) 和寄存 器(例如%r a x ) 。最后一个例子表明 可以 计算同一个数据结构中的两个指针之差 , 结果的数据类型为 l o ng , 值等于两个地址之差除以该数据类型的大小。\n江 练习题 3. 37 假设短 整 型 数 组 s 的 地 址 X s 和 整 数 索引 1 分 别 存 放 在 寄 存 器%r d x 和\n% r c x 中。 对下 面每个表 达 式, 给出 它的 类型 、值的表达 式和 汇编代码 实现。 如果 结果\n是指针 的话 , 要保 存 在 寄 存 器%r a x 中 , 如果数 据 类 型 为 s h or t , 就保存在寄存器元素 %a x 中。\n表达式 类型 值 汇编代码 S+ 1 S [ 3] \u0026amp;S [ i j S [4 *i + l] S+ i-5 3. 8. 3 嵌套的数组\n当我们创建 数组的数组时 , 数组分 配和引 用的 一般原则也是 成立的。例如 , 声明\nint A [ 5 ] [ 3 ] ;\n等价于下 面的声明\ntypedef int r ow3 _t [ 3) ; row3_t A[5 ] ;\n数据类型r o w3 —t 被定义为一个 3 个整数的 数组。数组 A 包含 5 个这样的元素, 每个 元素需要 1 2 个字节来 存储 3 个整数 。整个数 组的大小就是 4 X 5 X 3 = 6 0 字节。\n数组 A 还可以 被看成一个 5 行 3 列的二维数组, 用 A [ O ] [ 0 ] 到 A [ 4 ) [ 2 ] 来引用。数组元素在内存中按照“行优先” 的顺序排列 , 意味着第 0 行的所有元素, 可以 写作 A [OJ, 后面跟着第 1 行的所有元 素 ( A [l]), 以此类推, 如图 3- 3 6 所示。\n这种 排列顺 序是嵌 套声明的结果。将 A 看作一个有 5 个元素的数组 , 每个元素都 是 3 个 i n t 的数组, 首先是 A [OJ, 然后 是 A [ l ] , 以此类推。\n要访问多维数组的 元素 , 编译 器会以数组 起始为基地址,\n(可能需要经过伸缩的)偏移量为索引 , 产生计算期望的元素的偏移量, 然后使用某种 MO Y 指令。通常来说, 对 千一个声明如下的数组 :\nT D[R ] [CJ;\n它的数组元素 D [ i l [ j J 的内存地址 为\n\u0026amp; D [ i ] [ j ] = 工 。 十 L \u0026lt;C • i + j ) (3.1)\n这里 , L 是数据类型 T 以字节为单 位的大小。作为一个示例,\n图 3-36 按照行优先顺序\n存储的数组元素\n考虑前 面定 义的 5 X 3 的整型数组 A。假设 石、1 和)分别 在寄存器%r d i 、%r s i 和% r d x 中。然后 , 可以用下面的 代码将数组元 素 A [i] [ j l 复制到 寄存器%e a x 中:\nA in % 过 i\u0026rsquo; 工 i n %rsi, and j i n %rdx\nl e a q ( %r s i , %r s i , 2 ) , %r ax\nl e a q ( %r d i , %r a x , 4 ) , %rax mov l (%rax,%rdx,4), %eax\nCompute 31 Compute xA + 12,\nRead from M[xA + 12i + 4 八\n正如可以 看到的那样 , 这段代码计算 元素的地址为 XA + 1 2 i + 4j = x A + 4C 3 i 十 j )\u0026rsquo; 使用了\nx8 6 - 6 4 地址运算的 伸缩和加法特性 。\n诠 练 习 题 3. 38\nlong P [M] [NJ ;\nlong Q [NJ [M] ;\n考 虑下 面的 源 代码 , 其 中 M 和 N 是 用 # d e f i ne 声明 的 常数 :\nlong sum_element(long i, long j) { return P [i] [j] + Q [j] [i] ;\n}\n在编译这个程 序 中 , GCC 产 生 如下 汇编 代 码 :\nlong sum_element(long i , l ong j) i in %rdi , j in %rsi sum_element:\nleaq O (, %rdi, 8), %rdx subq %rdi, %rdx\naddq %rsi, %rdx\nleaq (%rsi,%rsi,4), %rax addq %rax, %rdi\nmovq Q (, %rdi,8), %rax addq P(, %rdx,8), %rax ret\n运用 逆 向 工程 技 能 , 根据这 段 汇编 代 码 , 确定 M 和 N 的值。\n3. 8. 4 定长数组\nC 语 言 编译器能够优化定长多维数组上的操作代码。这里我们展示优化等级设置为-\n01 时 GCC 采用的一些优化。假设我们用如下方式将数据类型 f i x _ ma tr i x 声 明 为 16 X 16\n的整型数组:\n#define N 16\n_ t ypedef int fix_matrix [NJ [NJ ;\n(这个例子说明了一个很好的编码习惯。当程序要用一个常数作为数组的维度或者缓 冲区的大小时,最好通过# d e f i ne 声明将这个常数与一个名字联 系起来, 然后 在后面一直使用这个名字代替常数的数值。这样一来,如果需要修改这个值,只用简单地修改这个# def i ne 声明就可以 了。)图3-3 7a 中的代码计算矩阵 A 和 B 乘积的元素 i , k, 即 A 的 行 t 和\nB 的列 k 的 内 积 。 G CC 产生的代码(我们再反 汇编成 C )\u0026rsquo; 如图 3-3 7 b 中函数 f i x—pr o d _\ne i e _o p t 所示。这段代码包含很多聪明的优化。它去掉了整数索引 j , 并把所有的数组弓I 用都转换 成了指针间接引用,其 中 包 括(1 ) 生成一个指针,命 名为 Ap t r , 指向 A 的 行 1 中连续的元素; ( 2 ) 生成一个指针,命 名为 Bp tr , 指向 B 的 列 k 中连续的元素; ( 3 ) 生成一个指 针,命 名为 Be nd , 当需要终止该循环时, 它 会等于 Bp tr 的 值。Ap tr 的 初始值是 A的行 1 的 第一个元素的地址, 由 C 表达式 \u0026amp;A [ i ) [ O J 给出。Bp tr 的 初始值是 B 的列 k 的 第一个元素的地址, 由 C 表达式 \u0026amp;B [ O J [ k l 给出。Be n d 的 值是 假想中 B 的列)的第 C n + l) 个元素的 地址, 由 C 表达式 \u0026amp;B [NJ [ k ) 给出。\n下面给出 的是 GCC 为函数 f i x _ pr o d _ e l e 生成的这个循环的实际汇编代码。我们看到 4 个寄存器的使用如下: %e a x 保 存 r e s u l 七,%r 土 保存 Ap t r , % r c x 保存 Bp tr , 而%r s i 保 存 Be nd 。\nI* Compute i,k of fixed matrix product *I\nint fix_prod_ele (fix_matrix A, fix_matrix B, long i, long k) { long j;\nint result= O;\nfor (j = 0; j \u0026lt; N; j++)\nresult += A [i] [j] * B [j] [k] ; return result;\na ) 原始的C代码\nI* Compute i,k of fixed matrix product *I\nint fix_prod_ele_opt(fix_matrix A, fix_matrix B, long i, long k) {\nint *Aptr = \u0026amp;A[i] [OJ; I* Points to elements in row i of A *I\nint *Bptr = \u0026amp;B[O] [k]; I* Points to elements in column k of B *I\n5 int *Bend= \u0026amp;B[N] [k]; I* Marks stopping point for Bptr *I int result= O;\ndo {\nresult+= *Aptr * *Bptr; Aptr ++;\nBptr += N;\nI* No need for initial test *I I* Add next product to sum *I I* Move Aptr to next column *I I* Move Bptr to next row *I\n} while (Bptr != Bend);\n12 return result;\n13 }\nI* Test for stopping point *I\n优化过的C代码\n图 3-37 原 始的和优化过的 代码 ,该 代码计算定 长数组的 矩阵乘 积的元素 i , k。\n编译器会自动完成这些优化\nint fix_prod_ele_opt (fix_matrix A , fix_matrix B, long i, long k)\nA in %rdi, Bin %rsi, i in %r dx , kin %rcx fix_prod_ele:\n2 salq $6, %rdx Compute 64 * 1.\n3 addq %rdx, %rdi Compute Aptr = xA + 64i = \u0026amp;A [i] [OJ\n4 leaq (%rsi, %rcx, 4) , %rcx Compute Bptr = x8 + 4k = \u0026amp;B[OJ [k]\n5 leaq 1024(%rcx), %rsi Compute Bend = x8 + 4k +1024 = \u0026amp;B[N] [k]\n6 movl $0, %eax Set result = 0\n7 .17: l oop :\nmovl (%rdi), %edx imull (%rcx), %edx addl %edx, %eax\naddq $4, %rdi\naddq $64, %rcx\ncmpq %rsi, %rcx\njne .17\nrep; ret\nRead *Aptr\nMul t i pl y by *Bptr Add to result Increment Aptr ++ Increment Bptr += N\nCompare Bptr : Bend If!=, got o loop Return\n; 练习题 3. 39 利用 等式 3. 1 来解释图 3-376 的 C 代码中 Ap tr 、 Bptr 和 Be nd 的初始值计算(第3~ 5 行)是如何正确反映 f i x_p ro d—e l e 的 汇编代码中它们的计算 (第 3~ 5 行)的。\n诠 练习题 3. 40 下 面的 C 代码将定 长数 组的对 角 线上的元 素设 置 为 v a l :\nI* Set all diagonal elements to val *I\nvoid fix_set_diag(fix_matrix A, int val) { long i;\nfor (i = 0; i \u0026lt; N; i ++)\nA[i] [i] = val;\n}\n当以优 化等级 - 0 1 编译 时 , GC C 产 生如下 汇编代 码 :\nfix_set_diag:\nvoi d fix_set_diag(fix_matrix A, int val)\nA 立 1 %rdi , val in¼rsi movl $0, %eax\n.113:\nmovl %esi, (%rdi, %rax) addq $68, %rax\ncmpq $1088, %rax\njne .113\nrep; ret\n创建 一个 C 代码 程序 f i x _ s 包 _ d i a g _ o p t , 它使用类似于这段汇编代码中所使用\n的优化 , 风格 与图 3-37 b 中 的 代 码 一致。 使用 含有 参 数 N 的 表 达 式 , 而不 是整 数 常量,使得 如果 重新定 义 了 N , 你的代码仍能够正确地工作。\n3. 8. 5 变长数组\n历史上 , C 语言只支持大小 在编译时就能确定的多维数组(对第一维可能有些例外)。程序员需要变长数组时 不得不用 m a ll o c 或 call o c 这样的函数为这些 数组分配存储 空间 , 而 且不得 不显式地 编码,用 行优先索引将多维数组映射到一维数组 , 如公式( 3. 1) 所示。ISO\nC吁引入了一种功能 ,允 许数组的维度是表达式 , 在数组被分配的时候才计算出来。在变长数组的 C 版本中 , 我们可以将一个数组声明如下 :\nint A [exprl] [expr2]\n它可以作为 一个局部变量 , 也可以作为一个 函数的参数 , 然后在遇到 这个声明的时候, 通过对 表达式 ex p r l 和 ex pr 2 求值来确定数组的维度。因此, 例如要访问 n X n 数组的元素i,, j\u0026rsquo; 我们可以 写一个如下的函数:\nint var_ele(long n, int A[n] [n], long i, long j) { return A [i] [j] ;\n参数 n 必须在参数A[n ] [n ] 之前, 这样函数就可以在遇到这个数组的时候计算出数组的维度。\nGCC 为这个引 用函数产生的代码如下所示 :\nmt vra _el e (long n, int A [n] [n], long i, long j )\nn in¼rdi, A in¼rsi, i in¼rdx, j in¼rcx var_ele:\nimulq\n%rdx, %rdi\nCompute n · 1\nleaq\nCir儿\ns i , %r d i , 4 ) , %rax\nCompute xA + 4(n · i)\nmovl ret\n(%rax,%rcx,4), %eax\nRead from M[ 入A + 4(11 · ,) + 4 八\n正如注释所示, 这段代码计算元素 i\u0026rsquo; j 的 地址为 工A + 4 ( n · i ) + 4j = xA + 4 ( n · i + 户。这个地址的计算类似千定 长数组的地址计算(参见 3. 8. 3 节), 不同点在千 1) 由于增加了参数\nn, 寄存器的使用变化了; 2 ) 用了乘法指令来计算 n · i( 第 2 行), 而不是用 l e a q 指令来计\n算 3i。因此引用变长数组只需要对定长数组做一点儿概括。动态的版本必须用乘法指令对\nt 伸缩 n 倍 , 而不能用一系列的移位和加法。在一些处理器中,乘 法会招致严重的性能处罚 , 但是在这种情况中无可避免。\n在一个循环中引用变长数组时,编译器常常可以利用访问模式的规律性来优化索引的 计算。例如, 图 3-38a 给出 的 C 代码, 它 计 算 两个 n X n 矩阵 A 和 B 乘积的元素 i , k 。\nGCC 产生的汇编代码, 我们再重新变为 C 代码(图3-38b) 。这个代码与固定大小数组的优化代码(图3-37 ) 风格不同, 不过这更多的是编译器选择的结果 , 而不是两个函数有什么根本的不同造成的。图 3-38 b 的 代码保留了循环变量 j\u0026rsquo; 用 以 判定循环是否结束和作为到 A 的行 1 的元 素组成的数组的索引。\nI* Compute i,k of variable matrix product *I\n2 int var_prod_ele(long n, int A[n] [n], int B[n] [n], long i, long k) { 3 long j; 4 int result= O; 5 6 for (j = 0; j \u0026lt; n; j++) 7 result += A[i] [j] * B[j] [k]; 8 9 return result; 10 } a ) 原始的C代码 I* Compute i,k of variable matrix product *I\nint var_prod_ele_opt(long n, int A[n] [n], int B[n] [n], long i, long k) { int *Arow = A[i];\nint *Bptr = \u0026amp;B[O] [k]; int result= O;\nlong j;\nfor (j = O; j \u0026lt; n; j++) { result+= Arow[j] * *Bptr; Bptr += n;\n}\nreturn result;\n}\nb) 优化后的C代码\n图 3\u0026lt;l8 计算变长数组的矩 阵乘积的 元素 i , k 的原始代码 和优化后的 代码。编译 器自动执行 这些优化\n下 面是 v ar —pr o d —e l e 的 循 环的汇编代码:\nRegs工\nt re\ns : n i n r¼ di , Arow in¼rsi, Bptr in¼rcx 4n in %r9, result in¼eax, j in¼edx\n.L24: l oop :\nmovl Cr 儿 s i , %r dx , 4 ) , %r8d imull (%rcx), %r8d\nRead Arow[j]\nMul t i p l y by•Bptr\naddl %r8d, %eax Add t o result addq $1, %rdx j++ addq %r9, %rcx Bptr += n cmpq %rdi, %rdx Compare j:n jne .L24 If!=, goto loop 我们看到 程序既使 用了伸缩过的值 4 n ( 寄存器%r 9) 来增加 Bp tr , 也使用了 n 的值(寄存器 %r 主 )来检查循环的边界。C 代码中并没有体现出需要这两个 值, 但是由于指针运算的伸缩,才使用了这两个值。\n可以看到 , 如果允许使用优化, GCC 能够识别出程序访问多维数组的元素的步长。然后生 成的代码会避免 直接应 用等式 ( 3. 1) 会导致的乘法。不论生成基于指针的 代码(图3- 37b)还是基于数组的代码(图 3-38b) , 这些优化都能显著提高程序的性能。\n9 异质的数据结构\nC 语言提供了 两种将不同类型的对象组合到一起创建数据类型的机制: 结 构 ( s t ru c­ ture) , 用关键字 s 七r u 吐 来声明, 将多个对象 集合到一个单位中; 联合 ( u nio n ) , 用关键字 un i o n 来声明,允 许用几种不同的 类型来引 用一个对象。\n3. 9. 1 结构\nC 语言的 s tr u c t 声明创建一个数据类型 , 将可能不同 类型的 对象聚合到一 个对象 中。用名字来引用结构的各个组成部分。类似于数组的实现,结构的所有组成部分都存放在内 存中一段连续 的区域内, 而指向结 构的指针就是结 构第一个字节的 地址。编译器维护关于每个结构类型的信息 , 指示每个字段(如 Id ) 的字节偏移。它以 这些偏移作为内 存引用指令中的位移 , 从而产生 对结构元素的引用。\n, 3 将一个对 象表 示为s 七r u e 七\n·c语言提供的 s tr uc t 数据类型的构造函数(cons tru ctor) 与 C++ 和 Java 的对象最为接近。\n它允许程序员在一个数据结构中保存关于某个实体的信息,并用名字来引用这些信息。 例如,一个图形程序可能要用结构来表示一个长方形:\nstruct rect {\nlong llx; I*X coordinate of lower-left corner *I\nlong lly; I*Y coordinate of lower-left corner *I\nunsigned long wi dt h ; I* Width (in pixels) */\nunsigned long height; I* Height (in pixels) */\nunsigned color; I* Coding of color */\n};\n可以声明 一个 s tr u c t r e c t 类型的 变量r , 并将它的字段值设置如下:\nstruct rect r; r.llx = r.lly = O; r.color = OxFFOOFF; r.width = 10;\nr.height = 20;\n这里表达式 r . l l x 就会选择结构r 的 l l x 字段。\n另外,我们可以在一条语句中既声明变量又初始化它的宇段:\nstruct rectr = { 0, 0, 10, 20 , OxFFOOFF } ;\n将指向结构的指针从一个地方传递到另一个地方,而不是复制它们,这是很常见的。例如,下面的函数计算长方形的面积,这里,传递给函数的就是一个指向长方形s tr uc 七的指针:\nlong area(struct rect *rp) {\nreturn (*rp).width * (*rp).height;\n}\n表达式 (*r p ) . wi d t h 间接 引 用 了 这个指针, 并且 选取 所得 结构的 wi d t h 字段 。 这里必须要 用括 号, 因 为 编译器会 将 表 达式 *r p . wi d t h 解释 为 * (rp.width), 而这是非法的 。间接 引 用和字段 选取结合起 来使 用非 常常见,以 至 于 C 语言提供了一种替代的表示法-> 。 即 r p - \u0026gt; wi d t h 等价于表 达式 (*r p ) . wi d t h 。 例如 , 我们可以 写一个函数, 它将 一个长方形顺时针旋转 90 度 :\nvoid rotate_left(struct rect *rp) { I* Exchange width and height *I long t = rp-\u0026gt;height;\nrp-\u0026gt;height = rp-\u0026gt;width; rp-\u0026gt;width = t;\nI* Shift to new lower-left corner *I rp-\u0026gt;llx -= t;\n}\nC++ 和 J ava 的对象比 C 语言中的 结构要复杂精 细得 多 , 因 为 它们将一组可以 被调用 来执行计算的方 法与一个对象联 系起 来。在 C 语言中, 我们可以 简 单地把这些 方 法写成普通函数 , 就像上面所示的 函数 ar e a 和r o七a t e —l e f t 。\n让 我们来看看这样一个例子,考 虑 下 面这样的结构声明 :\nstruct rec { inti; int j; inta[2]; int *p;\n};\n这个结构包括4 个 字段: 两个 4 字节 i n t 、一个由两个类型为 i n t 的元素组成的数组和一个 8 字节整型指针,总 共 是 24 个字节:\n偏移 0\n内容 [_二\na [OJ a[l]\n16 24\n可以观察到,数 组 a 是嵌入到这个结构中的。上图中顶部的数字给出的是各个字段相对于结构开始处的字节偏移。\n为了访问结构的字段,编译器产生的代码要将结构的地址加上适当的偏移。例如,假 设 s tr uc t r e c * 类型的变最r 放在寄存器%r 生 中 。 那 么 下 面的代码将元素r - \u0026gt;i 复制到元 素r - \u0026gt; j :\nRegsi\nt esr\n:r in r% d 工\nmovl (%rdi), %eax movl %eax, 4(%rdi)\nGetr - \u0026gt;1. Store in r-\u0026gt;j\n因为字段 1 的偏移盘为 o , 所以这个字段的地址就是r 的值。为了存储到字段 j , 代码要\n将 r 的地址加上偏移量 4 。\n要产生一个指向结构内部对象的指针,我们只需将结构的地址加上该字段的偏移量。 例如,只用 加上偏移量 8 + 4 X l = l 2 , 就可以 得 到指针 \u0026amp; (r - \u0026gt;a [ l ] ) 。 对于在寄存器%r d i 中的指针 r 和在寄存器%r s i 中 的 长整数变量 i , 我们可以用一条指令产生指针\u0026amp; (r-\u0026gt;a [i ]) 的值:\nRegisters:r in %rdi, i %sr 工\nleaq 8(%rdi,%rsi,4), %rax Set %rax to \u0026amp;r-\u0026gt;a [i)\n最后举一个例子,下面的代码实现的是语句:\nr-\u0026gt;p = \u0026amp;r-\u0026gt;a[r-\u0026gt;i + r-\u0026gt;j];\n开始 时 r 在寄存器%r 土 中 :\nRegisters:r in %rd1.\nmovl addl cltq 4(%rdi), %eax (%rdi), %eax Get r-\u0026gt;J Add r-\u0026gt;i Extend to 8 bytes leaq movq 8(%rdi,%rax,4), %rax, 16(%rdi) %rax Compute \u0026amp;r-\u0026gt;a[r-\u0026gt;i + r-\u0026gt;j] Store in r-\u0026gt;p 综上所述,结构的各个字段的选取完全是在编译时处理的。机器代码不包含关于字段声明或字段名字的信息。\n讫 练习题 3. 41 考虑下面的结构声明:\nstruct prob { int *p; struct {\nint x; int y;\n} s;\nstruct prob *next;\n};\n这个声明说明一个结构可以嵌套在另一个结构中,就像数组可以嵌套在结构中、数组可以嵌套在数组中一样。\n下面的过程(省略了某些表达式)对这个结构进行操作:\nvoid sp_init(struct prob *sp) { sp-\u0026gt;s.x = ,\nsp-\u0026gt;p = ,\nsp-\u0026gt;next = ,\n}\n下列字段的偏移量是多少(以字节为单位)?\np: s.x:\ns.y:\nnext:\n这个结构总共需要多少字节? 编译器为 s p _ i 汇 t 的 主体产 生 的 汇编 代码 如下 : void sp_init(struct prob *sp) sp in %rdi\ns p_i ni t :\nmovl movl leaq movq movq ret\n12(%rdi), %eax\n%eax, 8 (%r d沁 8(%rdi), %rax\n%rax, (%rdi)\n%rdi, 16(%rdi)\n根据这 些 信息 , 填写 s p _ i 工 t 代码 中缺 失的表达 式 。\n练习题 3. 42 下 面 的代 码 给 出 了 类 型 ELE 的结构声 明 以及 函 数 f u n 的原 型:\nstruct ELE {\nlong v; struct ELE *p;\n};\nlong fun(struct ELE *ptr);\n当 编译 f u n 的代码 时 , GCC 会 产 生 如下 汇 编代码 :\nlong f un (s rt ptr in %rdi fun:\nmovl jmp\n.L3:\naddq movq\n.L2:\ntestq jne\nuct ELE•ptr)\n$0, %eax\n.L2\n(%rdi), %rax 8(%rdi), %rdi\n%rdi, %rdi\n.L3\nrep; ret\n利 用 逆 向 工 程 技 巧 写 出 f u n 的 C 代码 。 描述这个结 构 实 现的 数 据结 构 以 及 f u n 执行的操 作。 3. 9. 2 联合\n联合提供了一种方式,能 够 规避 C 语言的类型系统 , 允 许 以 多 种 类型来引用一个对象 。 联合声明的语法与结构的语法一样,只 不过语义相差比较大。它们是用不同的字段来引 用 相同的内存块。\n考虑下面的声明:\nstruct S3{\nchar c; int i[2]; double v;\n};\nunion U3 {\nchar c; int i [2]; double v;\n};\n在一台 x86- 64 Linux 机器上编译时 , 字段的偏移最、数据类型 S3 和 U3 的完整大小如下:\n类型 C V 大小\n。 # (稍后会解 释 S3 中 l 的偏移最为什么是 4 而不是 1\u0026rsquo; 以 及为什么 v 的偏移量是 16 而不是 9 或 12 。)对千类型 un i o n U3 * 的 指 针 p , p-\u0026gt; C 、p - \u0026gt; i [ O ] 和 p - \u0026gt; V 引 用 的 都是数据结构的起始位 置。还可以 观察到, 一 个 联合的总的大小等于它最大字段的大小。\n在一些 下上文中 , 联合十分 有用。但是,它 也 能 引 起一些讨厌的错误, 因 为它们绕过了 C 语言类型系统提供的安全措施。一种应用情况是, 我们事先知道对一个数据结构中的两个不同字段的使用是互斥的,那么将这两个字段声明为联合的一部分,而不是结构的一 部分 , 会减小 分配空间的总量。\n例如, 假设我们想实现一个二叉树的数据结构, 每个叶子节点都有两个 doub l e 类型的数据值 , 而每个内部节点都有指向两个孩子节点的指针, 但是没有数据。如果声明如下:\nstruct node_s {\nstruct node_s *left; struct node_s *right; double data[2];\n};\n那么每个 节点需要 32 个字节 , 每种类型的节点都要浪费一半的字节。相反, 如 果 我们如下声明一个节点 :\nunion node_u { struct {\nunion node_u *left; union node_u *right;\n} internal; double data[2];\n} ;\n那么 , 每个 节点就只需要 1 6 个字节。如果 n 是一个指针, 指向 u n i o n no d e _ u *类型的节点, 我们 用 n - \u0026gt; d a 七a [ 0 ] 和 n - \u0026gt; d a t a [ l ] 来引用叶子节点的数据, 而用 n - \u0026gt; internal.\ntypedef enurn { N_LEAF, N_INTERNAL} nodetype_t; struct node_t {\nnodetype_t type; union {\nstruct {\nstruct node_t *left; struct node_t *right;\n} internal; double data[2];\n} info;\n};\n这个结构总共需 要 24 个字节: t ype 是 4 个字节 , i n fo . i n t er na l . l e f t 和 i nfo . i 阰 e r na l . 豆 g h t 各要 8 个字节, 或者是 i n f o . d a t a 要 1 6 个字节。我们后面很 快会谈到, 在字段七yp e 和联合的元 素之间需 要 4 个字节的 填充, 所以 整个 结构大小 为 4 + 4 + 1 6 = 24 。在这种情况中,相对于给代码造成的麻烦,使用联合带来的节省是很小的。对于有较多字段的 数据结构,这样的节省会更加吸引人。\n联合还可以用来访问不同数据类型的位模式。例如,假设我们使用简单的强制类型转换将一个 d o ub l e 类型的值 d 转换为 u ns i g ne d l o ng 类型的值 U :\nunsigned long u = (unsigned long) d;\n值 u 会是 d 的整数表示。除 了 d 的值为 0 . 0 的情况以外, u 的位表示会与 d 的很不一样。再看下面这段 代码 , 从一个 d o ub l e 产生一个 u ns i g ne d l o ng 类型的值:\nunsigned long double2bits(double d) { union {\ndoubled; unsigned long u;\n} temp; temp.d = d;\nreturn temp.u;\n};\n在这段代码中,我们以一种数据类型来存储联合中的参数,又以另一种数据类型来访 问它。结果会是 u 具有和 d 一样的 位表示, 包括符号位字段 、指数和尾数 , 如 3. 11 节中描述的那样。 u 的数值与 d 的数值没有任何关 系, 除了 d 等于 0. 0 的 情况。\n当用联合来将各种不同大小的数据类型结合到一起时,字节顺序问题就变得很重要 了。例如, 假设我们写了一 个过程, 它以两个 4 字节 的 u ns i g ne d 的位模 式, 创建一个 8 字节的 d o ub l e :\ndouble uu2double(unsigned i.ordO, unsigned i.ord1)\n{\nunion {\ndoubled; unsigned u[2];\n} temp;\ntemp.u[O] = wor dO ; temp.u[1] = word1; return temp.d;\n}\n在 x86-64 这样的小端法 机器上 , 参数 wor d O 是 d 的低位 4 个字节, 而 wo r d l 是高位\n4 个字节 。在大端法机器上 , 这两个参数的 角色刚好相反 。\n; 练习题 3. 43 假设 给你个任 务, 检查 一下 C 编译 器 为 结 构 和联 合 的 访 问 产 生正 确的代码。你写了下面的结构声明:\ntypedef union { struct {\nlong u;\nshort v;\nchar w;\n} t1;\nstruct {\nint a[2]; char *p;\n} t2;\n} u_type;\n你写 了一 组具 有下 面这种形 式的 函数 :\nvoid get (u_type *up, type *dest) {\n*dest = expr;\n}\n这组函数有不 一样的 访问 表达 式 ex p r , 而且 根据 ex p r 的 类 型 来设 置目 的 数 据 类 型 t y p e 。然后再检查编译这些函数时产生的代码,看看它们是否与你预期的一样。\n假设在这 些函数 中 , u p 和 d e s t 分别被 加 载 到寄 存器 %r d i 和 %r s i 中。 填 写 下表 中的数据类 型 ty p e , 并用 1 ~ 3 条指令 序列来计 算表达 式 , 并将结果 存储到 d e s 七 中。\nexpr type 代码 up - \u0026gt;t l . u long movq ( %r d 切 ,r% a x movq r% a x, ( % r s i ) up - \u0026gt;t l . v \u0026amp;up-\u0026gt;tl. w up-\u0026gt;t2.a up-\u0026gt;t2.a[up-\u0026gt;tl.u) *up-\u0026gt;t2.p . 3 数据对齐\n许多计算 机系统对基本数 据类型的合法地 址做出了一些限制, 要求某种 类型对象的地址必须是某个值 K ( 通常是 2、 4 或 8) 的倍数。这种对齐限 制简化了形成处 理器和内存 系统之间接口 的硬件设 计。例如, 假设一个处理器总是从内存中取 8 个字节, 则地址必须为 8 的倍数 。如果我们能保证 将所有的 d o u b l e 类型数据的地址对齐成 8 的倍数, 那么就可以用一个内存操作来读 或者写值 了。否则, 我们 可能需 要执行 两次内存访问, 因为对象可能被分放在 两个 8 字节内存 块中。\n无论数 据是否对齐, x8 6- 64 硬件都能正确工作。不过, I n t e l 还是建议要对齐数据以提高内存系统的性能 。对齐原则是 任何 K 字节的基本对象的地址必须是 K 的倍数。可以看到这条原则会得到如下对齐:\n确保 每种数 据类型都是 按照指定 方式来组织 和分配, 即每种 类型的对象都满足它的 对齐限制, 就可保证实施对 齐。编译 器在汇编代码中放入命令, 指明全局数据所需 的对齐。例如, 3. 6. 8 节开始的跳转 表的 汇编代码声明 在第 2 行包含下 面这样 的命令:\n.align 8\n这就保证 了它后面的数 据(在此, 是跳转表的开始)的起始地址是 8 的倍数。因为每个表项长 8 个字节 , 后面的元素都 会遵守 8 字节 对齐的限 制。\n对于包含结构的代码,编译器可能需要在字段的分配中插入间隙,以保证每个结构元素都 满足它的 对齐要求 。而结构本身 对它的起始地址也 有一些对齐要求。\n比如说 , 考虑下面的 结构声明 :\nstruct S1{\nint i; char c; int j;\n};\n假设 编译器用最 小的 9 字节分 配, 画出图 来是 这样的 :\n偏移 0 4 5\n内容I i I C I\n它是不可能 满足字段 认偏移 为 0 ) 和 ](偏移为5 ) 的 4 字节对齐要求的 。取而代 之地, 编译器在 字段 c 和 ]之间插入一 个 3 字节的 间隙(在此用蓝色阴影 表示):\n偏移 0 4 5 12\n内容 I i IC I\n结果, J 的偏 移晕 为 8\u0026rsquo; 而 整 个结构的 大小 为 12 字 节。 此外, 编译 器必须保 证 任 何struct Sl * 类型的 指针 p 都满足 4 字节对 齐。用 我们前面的 符号, 设指针 p 的值为 Xp, 那么, X p必须是 4 的倍数。这就保证了 p - \u0026gt; i ( 地址 X p) 和 p - \u0026gt; j ( 地址 x p + S ) 都满足它们的 4 字节对齐要求 。\n另外 , 编译 器结构的末尾可能 需要一些填充, 这样结 构数组中的每个元 素都 会满 足它的对齐要求 。例如 , 考虑下 面这个结 构声明 :\nstruct S2{\nint i ;\nint j; char c;\n};\n如果 我们将这个结 构打包成 9 个字节,只 要保证结构的起始地址满足 4 字节对齐要求, 我们仍然能够保证满 足字段 l 和 J 的对齐要求。不过 , 考虑下面的声明:\nstruct S2 d[4];\n分配 9 个字节, 不可能满足 d 的每个元素的对齐要求 , 因 为这些元索的地址分别为 互、xd+ 9、xct+ 1 8 和 孔+ 27。相反, 编译器会为结构 S2 分配 12 个字节 ,最后 3 个 字节是浪费的空间:\n偏移 0 4 8 9 12\n内容 I i I j I 叶\n这样一来, d 的 元素的地址分别为 工心 Xct+ 1 2 、工ct + 24 和 工d + 36。 只 要 Xd 是 4 的 倍 数 , 所有的 对齐限制就都可以满足了。\n讫§ 练习题 3. 44 对下 面 每 个 结 构 声 明 , 确 定每 个 字 段 的 偏 移 量 、 结 构 总 的 大 小 , 以 及\n在 x86-64 下 它的 对齐 要 求 :\nstruct P1 {inti; char c; int j ; char d; } ;\nstruct P2 { int i; char c; char d; long j ; } ; struct P3 { short w [3] ; char c [3] };\nstruct P4 { short w [5] ; char *c [3] } ;\nstruct PS { struct P3 a [2] ; struct P2 t } ;\n讫§ 练习题 3. 45 对于下列结构声明回答后续问题:\nstruct {\nchar *a;\nshort b,·\ndouble c·,\nchar d.,\nfloat e,·\nchar f ,·\nlong g;\nint h ;\n}r ec ;\n这个结构中所有的字段的字节偏移量是多少?\n这个结构 总 的 大小是 多少?\n重新排列这个结构中的字段,以最小化浪费的空间,然后再给出重排过的结构的 字节偏移量和总的大小。\nm 强制对齐的 情 况\n对于大多数 x86- 64 指令 来说 , 保 持 数 据对 齐能 够提 高 效率, 但是 它 不 会 影响程序的行 为。 另 一 方 面 , 如 果数据没有对 齐, 某些型号的 Intel 和 AMD 处理 器 对于有些 实现多媒 体操作的 SS E 指令, 就无 法正确执行。这些 指令 对 16 字 节 数 据块进行操作 , 在\nSSE 单元和内存之间传送数据的指令要 求 内存地址必须是 16 的倍数。任何试图以 不 满足对 齐要 求的 地址未访问内存都会导致异常(参见 8. 1 节),默 认 的行为是 程序终止。\n因此 ,任 何针对 x86-64 处理器的 编译 器和运行 时系统都必须保证分配用来保存 可能会被\nSSE 寄存器读或 写的数据结构的 内存, 都必须满足 16 字节对 齐。这个要求有两个后 果:\n任何内存分配函数 ( a l l o c a 、rna l l o c 、 c a l l o c 或r e a l l o c ) 生成的块的起 始地址都必须是 1 6 的倍数。\n大 多数 函数的栈 帧的边界 都必须是 16 字节的倍数。(这个要 求有一些例 外。)\n较近版本的 x86- 64 处理 器 实现了 A V X 多媒 体指令。除了 提 供 SSE 指令的超 集 , 支\n持 AVX 的指令并没有强 制性的对齐要 求。\n3. 10 在机器级程序中将控制与数据结合起来\n到目前为止,我们已经分别讨论机器级代码如何实现程序的控制部分和如何实现不同 的数据结构。在本节中 , 我们会看看数 据和控制如何交互 。首先, 深入审视一下指针, 它是 C 编程语 言中最重要的 概念之一 , 但是许多 程序员 对它的 理解都非 常浅显 。我们复习符号调试器 GDB 的使用,用 它仔细检 查机器级程序的详细运行 。接下来, 看看理解机器级程序如何帮助我们研究缓冲区溢出,这是现实世界许多系统中一种很重要的安全漏洞。最 后,查看机器级程序如何实现函数要求的栈空间大小在每次执行时都可能不同的情况。\n10. 1 理解指针\n指针是 C 语言的一个核心特色。它们以 一种统一方式, 对不同数据结构中的元素产生引用。对于编程新手来说,指针总是会带来很多的困惑,但是基本概念其实非常简单。在此,我们重点介绍一些指针和它们映射到机器代码的关键原则。\n每个指针都对应一个类型。这个类型表明该指针指向的是哪一类对象。以下面的指针声明为例:\nint *ip; char **cpp;\n变最 i p 是一个指向 i n t 类型对象的 指针 ,而 c p p 指针指向的 对象自身 就是一个指向c h a r 类型对象的 指针。通常 , 如果 对象类型为 T , 那么指针的类型为 T * 。特殊的v o i d * 类型代表通用指 针。比 如说 , ma 荨 o c 函数返回一个通用 指针, 然后 通过显式强制类型转换或者赋值操作那样的隐式强制类型转换,将它转换成一个有类型的 指针。指针类型不是 机器代码中的 一部分 ; 它们是 C 语言提供的一种抽象 , 帮助程序员避免寻址错误。\n每个指针 都有一个值。这个值是 某个 指定类型的 对象的地址。特殊的 NULL ( O) 值表示该指针没有指向 任何 地方 。\n指针 用 '矿运 算 符 创 建。 这个运 算符可以应用 到任何 l v a l ue 类的 C 表 达式上, l v a l ue 意指可以 出现在赋值语句左边的表达式。这样的例子包括变量以及结 构、联合 和数组的 元素。我 们已经看到, 因为 l e a q 指令是设 计用来计算内存引用的地址的,&运算符的机器代码实现常常用这条指令来计算表达式的值。\n*操作符用于间接引用指针。其结果是一个值,它的类型与该指针的类型一致。间接引用是用内存引用来实现的,要么是存储到一个指定的地址,要么是从指定的地址读取。\n数组与指针 紧密 联系。 一个数 组的名字可以 像一个指针变最一样引用(但是不能修改)。数组引用(例如a [ 3 ] ) 与指 针运算和间 接引 用(例如 * (a+ 3 ) ) 有一样的效果。数组引用和指针运算都需 要用对象大小对偏移量进行 伸缩。当我们写 表达式 p + i, 这里指 针 p 的值为 p , 得到的 地址计算 为 p + L · i , 这里 L 是与 p 相关联的 数据类型的大小。\n将指针从一种类型强制转换成另一种类型,只改变它的类型,而不改变它的值。强 制类型转换的 一个效果是改 变指针运 算的伸缩。例如, 如果 p 是一个 c har * 类型的指针, 它的值为 p , 那么表达式 (i n t * ) p + 7 计算为 p + 28 , 而 (i n t *) (p+ 7) 计算为 p + 7。(回想一下, 强制类型转换的 优先级高千加法。)\n指针也可以指向函数。这提供了一个很强大的存储和向代码传递引用的功能,这些引用 可以被程序的某个其他部分调用 。例如, 如果我们有一个函数,用 下面这个原型定义:\nint fun(int x, int *p);\n然后 , 我们 可以声 明一个指针 f p , 将它赋值为这个函数,代码如下:\nint (*fp)(int, int*); fp = fun;\n然后用这个 指针来调 用这个函数:\nint y = 1;\nint result= fp(3, \u0026amp;y);\n函数指针的值是该函数机器代码表示中第一条指令的地址。\n四亘正l王詈ll 函数指 针\n函数指针声明的语法对程序员新手来说特别难以理解。对于以下声明:\nint (*f)(int*);\n要从里(从 \u0026quot; f \u0026quot; 开始)往外读。 因此 , 我们看到像 \u0026quot; (* f ) \u0026quot; 表明的 那样 , f 是一个指 针; 而 \u0026quot; (*f ) (i n t * ) \u0026quot; 表明 f 是一个指 向函数 的指针, 这个函数以一 个 辽比* 作为 参数。最后 , 我们 看到 , 它是 指向以 i n 七 * 为参数并返回 i n t 的函数的 指针 。\n*f 两边的 括号是 必需的 , 否则声明 变成\nint *fCint*);\n它会被解读成\n(int•) f(int•);\n也就是说 , 它会 被解释 成一 个函数原 型 , 声明 了一 个函数 f , 它以 一个 l 让 * 作为 参数并返 回一个 i n t * 。\nK ern ig h a n 和 民tch ie [61, 5. 12 节]提供了一 个有 关阅读 C 声明的很 有帮助的教 程。\n10. 2 应用: 使 用 GDB 调试 器\nGNU 的调试器 GDB 提供了许多有用的特性 , 支持机器级 程序的 运行时评估和分析。对千本书中的示 例和练习, 我们试图通过阅读代码, 来推断出程序的行为。有了 GDB , 可以 观察正在运行的程序, 同时又对 程序的执行有相当的 控制, 这使得研究程序 的行为变 为可能 。\n图 3-39 给出了 一些 GDB 命令的例子, 帮助研究机器级 x86-64 程序。 先 运行 OBJ­\nDUMP 来获得程序的 反汇编版本, 是很有好处的。我们的示例都基于对文件 pr o g 运行\nGDB, 程序的描述 和反汇编见 3. 2. 3 节。我们用 下面的命令行来启 动 GDB :\nlinux\u0026gt; gdb prog\n通常的方法是在程序中感兴趣的地方附近设置断点。断点可以设置在函数入口后面, 或是一个程序的 地址处。程序在执行过程中遇到一个 断点时, 程序 会停下来, 并将控制返回给用户。在断点处,我们能够以各种方式查看各个寄存器和内存位置。我们也可以单步 跟踪程序 , 一次只执行几 条指令, 或是前进到下一个 断点。\nA.PP女A 效果 开始和停止 quit run kill 退出 GOB 运行程序(在此给出命令行参数) 停止程序 断点 break mu l 七S 七or e break * Ox400540 delete 1 delete 在函数 mu l t s t or e 入口处设 置断点在地址 Ox 400540 处设 置断点 删除断点 1 删除所有断点 执行 stepi 执行 1 条指令 stepi 4 执行 4 条指令 nexti 类似于 s t e p i , 但以函数调用为单位 continue 继续执行 finish 运行到当前函数返回 检查代码 disas 反汇编当前函数 disas mu l t s t or e 反汇 编函数 mul t s t or e disas Ox400544 反汇编位于地址 Ox 400544 附近的 函数 disas Ox400540, Ox40054d 反汇编指定地址范围内的代码 print /x $rip 以十六进制输出程序计数器的值 检查数据 print $rax 以十进制输出 %r a x 的内容 print /x $rax 以十六进制输出 %r a x 的内容 print /t $rax 以二进制输出 %r a x 的内 容 print OxlOO 输出 Ox l OO 的十进制 表示 print /x 555 输出 555 的十六 进制表示 print /x ($rsp+ 8) 以十六 进制输出 %r s p 的内容加上 8 print *(long *) Ox7fffffffe818 输出位 于地址 Ox 7ff f f f f f e 81 8 的 长整数 print *(long *) ($rsp+ 8) 输出位 于地址 %r s p + 8 处的长整数 x/2g Ox7fffffffe818 检查从 地址 Ox 7f f f f ff f e 81 8 开始的双 ( 8 字节)字 x/20brnultstore 检查函数 mu l t s t or e 的 前 20 个字节 有用的信息 info frame 有关当前栈帧的信息 info registers help 所有寄存器的值 获取有关 GOB 的信息 图 3-39 GDB 命令示例。说明 了一些 GDB 支持 机器级 程序悯试的方式\n正如我们的示 例表明的那样 , GDB 的命令语法有点 晦涩, 但是在线 帮助信息(用 GDB 的 he l p 命令调用)能克服这些 毛病。相对于使用命令行接口来访问 GDB, 许多程序员更愿意使用 DDD , 它是 GDB 的一个扩展 , 提供了图 形用户界 面。\n10 . 3 内存越界引用和缓冲区溢出\n我们已 经看到 , C 对千数组引 用不进行 任何边界检查, 而且局部变量和状态信息(例如保存的寄存器值和返回地址)都存放在栈中。这两种情况结合到一起就能导致严重的程 序错误,对越界的数组元素的写操作会破坏存储在栈中的状态信息。当程序使用这个被破\n坏的状态, 试图重新加载寄存 器或执行 r e t 指令时 , 就会出现很 严重的错误 。\n一种特别常 见的状态破坏称 为缓 冲 区 溢 出 ( b uff e r o ve r fl o w ) 。通常, 在栈 中分配某个字符数组来保 存一个字符串 , 但是字符串的 长度超出了 为数组 分配的 空间。下面这 个程序示例就说明了这个问题:\nI* Implementation of library function gets() *I char *gets(char *s)\n{\nint c;\nchar *dest = s ;\nwhile ((c = getchar()) !=\u0026rsquo;\\n\u0026rsquo;\u0026amp;\u0026amp; c != EDF)\n*dest++ = c;\nif (c == EDF \u0026amp;\u0026amp; dest == s)\nI* No characters read *I return NULL;\n*dest++ =\u0026rsquo;\\0\u0026rsquo;; I*Terminate string *I returns;\n}\nI* Read input line and write it back *I void echo()\n{\nchar buf[8]; gets(buf); puts(buf);\nI* Way too small! *I\n前面的代码 给出了 库函数 g e t s 的一个实现,用来说明这个 函数的严 重问题。它从标准输入读入 一行 ,在遇到一个回 车换行字符或某个 错误情况时 停止。它将这个字符串复制到参数. s 指明 的位置,并在字符串结尾加上 n u l l 字符。在函数 e c h o 中,我们使用了 g e t s , 这个函 数只是简单 地从标准输入中读入 一行, 再把它回送 到标 准输出 。\nge t s 的问题是它没有 办法确定是否为保存 整个 字符串分 配了足够的 空间。在 e c h o 示例中 , 我们故意 将缓 冲区设 得非常小一 只有 8 个字节 长。任何长度超 过 7 个字符的字符串都会导致写越界。\n检查 GCC 为 e c h o 产生的 汇编代码 , 看看栈 是如何组织的 :\nvoid echo() echo :\nsubq $24, %rsp Allocate 24 bytes on stack movq %rsp, %rdi Compute buf as %rsp call movq gets %rsp, %rdi Call gets Compute buf as %rsp call addq puts $24, %rsp Call puts Deallocate stack space ret Return 图 3- 40 画出了 e c h o 执行时 栈的组织 。该程序把栈 指针减去了 24 ( 第 2 行), 在栈上分配了 24 个字节 。字符数组 b u f 位于栈顶, 可以 看到,%r s p 被复制到%r d i 作为调用 g e t s 和 p u t s 的参数。这个调用的参 数和存储的 返回指针之间的 1 6 字节是未 被使用的。只要用户输入 不超过 7 个字符 , g e t s 返回的字符串(包括结尾的 n u ll ) 就能够放进为 b u f 分配的\n空间里。不 过, 长一些 的字符串 就会导致 g e t s 覆盖栈上存储的某些信息。随着字符串变长,下面的信息会被破坏:\n输入的字符数量 附加的被破坏的状态 0-7 无 9-23 未被使用的栈空间 24-31 返回地址 32+ cal l er 中保存的状态 字符串 到 23 个字符之前都没有严重的后果,但是超过以后,返回指针的值以及更多可能的保存状态会被破坏。如果存储的返回地址的值被破坏了 , 那么r e t 指令(第8 行)会导致程序跳转 到一个完全意\n调用者的栈帧\necho\n返回地址 \u0026lt; —\nr% sp + 24\n想不到的位置。如果只 看 C 代码 , 根本 就\n不可能看出会有上面这些行为。只有通过研究机器代码级别的程序才能理解像\nge 七s 这 样 的 函数 进 行 的内 存 越 界 写的影响。\n的栈帧\n[7Jl[ 6Ji(5J!(4 J!(3J!(2J!(l J!(Dl l-+ buf = %rsp\n图 3-40 ech o 函数的栈组织。字符数组 buf 就在保存的 状 态 下 面。对 buf 的 越界写会破坏程序的状态\n我们的 e c h o 代码很 简单 , 但是有点 太随意了。更好一点的版本是使用 f ge 七s 函数, 它包括一个参数 , 限制待读入 的最大字节数。家庭作业 3. 71 要求你写出一个能处理任意长度输入字符串 的 e c h o 函数。通常, 使用 ge t s 或其他任何能导致存储溢出的函数, 都是不好的编程习 惯。 不幸的是, 很多常用的库函数, 包括 s tr c p y、 s tr c a t 和 s pr i n t f , 都有一个属性 不需要告诉 它们目 标缓 冲区的 大小, 就产生一个字节 序列 [ 97] 。这样的情况就会导致缓冲区溢出漏洞。\n\u0026quot; 、练习题 3 . 46 图 3- 41 是 一个 函数的(不大好的 )实现 , 这个函数从标 准 输入 读入 一行 , 将字符串复制到新分配的存储中,并返回一个指向结果的指针。\n考虑下 面这 样 的 场 景。 调 用 过 程 g e t _ l i ne , 返 回地 址 等 于 Ox 40007 6 , 寄存器\n%r b x 等于 Ox 012345678 9ABCDEF。输入 的 字符 串 为 \u0026quot; 012345678901 2345678901 234\u0026quot; 。程\n序会因为段错误 ( segmentation fault ) 而中止。运 行 GDB, 确定 错误是在 执行 g 武 _ l i ne\n的r e t 指令 时发 生的。\n填写下 图 , 尽可能 多地 说 明 在 执行 完反 汇编 代 码 中 第 3 行指 令 后 栈 的 相 关 信息。在右边标注出存储在栈中的数字含意(例如“返回地址\u0026quot;)\u0026lsquo;在方框中写出它们的十 六进 制值(如果知道 的 话)。每 个 方 框 都 代 表 8 个 字节。 指 出 %r s p 的位 置。 记住 , 字符 0 ~ 9 的 ASCII 代码是 Ox 3~ 0x 3 9。\n00 00 00 00 00 40 00 76 1 返回地址\n修改你的 图 , 展现调 用 g e t s 的 影响(第 5 行)。\n程序应该试图返回到什么地址? 当 ge t —巨 n e 返回 时, 哪个(些)寄存器 的值被破坏 了?\n除了可能 会缓冲 区溢 出以 外, g e t —l i ne 的代 码还有哪 两个错误?\nI* This is very low-quality code.\nIt is intended to illustrate badprogramming practices.\nSee Practice Problem 3.46. *I char *get_line ()\n{\nchar buf [4] ; char *result; gets(buf);\nresult= malloc(strlen(buf)); strcpy(result, buf);\nreturn result;\n}\nC代码 char *get _l i ne () 0000000000400720 \u0026lt;get_line\u0026gt;:\n2 400720: 53\n3 400721: 48 83 ec 10\nDi agr 却 st ack at this point\n4 400725: 48 89 e7\n400728: e8 73 ff ff ff\npush %rbx\nsub $0x10,%rsp\nmov %rsp,%rdi callq 4006a0 \u0026lt;gets\u0026gt;\nModify diagram to show stack contents at this point\nb ) 对gets调用的反汇编\n图 3-41 练习题 3. 46 的 C 和反汇编代码\n.缓冲区溢出的一个更加致命的使用就是让程序执行它本来不愿意执行的函数。这是一 种最常见的通过计算机网络攻击系统安全的方法。通常,输入给程序一个字符串,这个字 符串包含一些 可执行 代码的 字节编码 , 称为攻击代码 ( e xploit code), 另外,还有一些字节会用一个指向攻 击代码的 指针覆盖返 回地址。那么, 执行r e t 指令的效果 就是跳转到攻击代码。\n在一种攻 击形式 中, 攻击代码会 使用系统 调用启动一个 shell 程序, 给攻 击者提供一组操作系统函数。在另一种攻击形式中,攻击代码会执行一些未授权的任务,修复对栈的 破坏, 然后第二次执行r e t 指令,(表面上)正常返回到调用者。\n让我们来看一个例子 , 在 1 988 年 11 月, 著名的 In ternet 蠕虫病毒通过 Int ernet 以四种不同 的方法获取 对许多计算机的访问。一种是对 fi nger 守护进程 f i ng er d 的缓冲区 溢出攻击 , f i ng er d 服务 F I NG E R 命令请求。通过以一个适当的字符串调用 F I NG E R , 蠕虫可以使远程的守护进程缓冲区溢出并执行一段代码,让蠕虫访问远程系统。一旦蠕虫获得了对系统的访问,它就能自我复制,几乎完全地消耗掉机器上所有的计算资源。结果, 在安全专家制定出如何消除这种蠕虫的方法之前,成百上千的机器实际上都瘫痪了。这种蠕虫的始作桶者最后被抓住并被起诉。时至今日,人们还是不断地发现遭受缓冲区溢出攻击的系统安全漏洞,这更加突显了仔细编写程序的必要性。任何到外部环境的接口都应该是“防弹的",这样,外部代理的行为才不会导致系统出现错误。\n日 日 蠕虫和病 毒\n蠕虫 和病毒都 试图 在计 算机中 传播它们 自 己的 代码段。正如 S pa fford [ 105 J 所 述, 蠕 虫 ( w o rm ) 可以 自己 运行 , 并且 能 够将 自 己的 等效副 本传播 到 其他 机 器。 病毒( vi ru s ) 能将自己添加到包括操作系统在内的其他程序中,但它不能独立运行。在一些大众媒体 中,“ 病毒“ 用来指 各种在 系统间 传播 攻击代 码的 策略 , 所以 你 可能 会听到人们把 本 来应该叫做"蠕虫”的东西称为“病毒”。\n3. 10 . 4 对抗缓冲区溢出攻击\n缓冲区溢 出攻击的普遍发 生给计算 机系统 造成了许多 的麻烦。现代的 编译器和操作 系统实现了很多 机制 , 以避免遭受这 样的攻 击, 限制入侵者 通过缓 冲区 溢出攻击获得 系统控制的方式。在本节中 , 我们 会介绍一些 L in u x 上最新 G CC 版本所 提供的机制。\n1 栈随机化\n为了 在系统 中插入攻 击代码 , 攻击者既要插入代 码, 也要插入指向这段代码的指针, 这个指针也是 攻击字符串的 一部分。产生这个指 针需 要知道这个 字符串放置的 栈地址 。在过去 , 程序的 栈地址非常容易 预测。对于所 有运行 同样程序和操 作系统 版本的 系统 来说, 在不同的 机器之间 , 栈的位置是相 当固定的 。因此, 如果 攻击者可以确定 一个常见的 We b 服务器所使用的 栈空间 , 就可以设 计一个在许多 机器上都能 实施的攻击。以 传染病来打个\n比方, 许多系统都容易 受到同一种病毒的攻击, 这 种现象常被称作安全 单 一 化 ( sec u rit y monoculture) [ 96] 。\n栈随机化的思 想使得栈的 位置在程序每 次运行时都 有变化。 因此, 即使许多机器都 运行同样的 代码 , 它们的 栈地址 都是不同 的。实现的方式是 : 程序 开始时, 在栈上分配一段 O~ n 字节之间的随 机大小 的空间, 例如, 使用分配函 数 a l l o c a 在栈上 分配指定 字节数 量 的空间 。程序不使 用这段空间, 但是 它会导致程序 每次执行时后续的栈位置发 生了变化。分配的范围 n 必须足够大 , 才能 获得 足够多的 栈地址 变化 , 但是 又要 足够小 , 不至千浪费 程序太多 的空间 。\n下面的代码是一种确定 ”典型的" 栈地址的 方法:\nint main() { long local;\nprintf (\u0026ldquo;local at %p\\n\u0026rdquo;, \u0026amp;local); return O;\n这段 代码只 是简单 地打印出 ma i n 函数中局部 变量的 地址。在 32 位 L in u x 上运行这段 代码\n10 000 次, 这个地址的 变化范围为 Ox f f7 f c 5 9c 到 Ox f f f f d 09c , 范围大小 大约是 2气 在更新 一点 儿的 机 器上 运 行 64 位 L i n ux , 这 个地址的 变 化范围 为 Ox 7 f f f 000 l b 698 到Ox 7 f f f f f f a a 4a 8 , 范围大小大约是 2 32 0\n在 L i n u x 系统 中, 栈随机化已经变成了标准行 为。它是更大的一类技术中的一种, 这类技术 称为地址空间布局 随机化 ( A dd r ess -S pace La yo ut Ra nd omiza tio n ) , 或者简 称 AS LR [ 99] 。采用 AS LR , 每次运行时 程序的 不同部分, 包括程序 代码 、库 代码、栈 、全局 变撮和堆数 据, 都会被 加载到内 存的不同区域 。这就意 味着在 一台机器上运行一个程序 , 与在其他机器上 运行同样的程 序, 它们的地址映射大相径庭。 这样才能够对抗一些形式的攻击。\n然而,一个执著的攻击者总是能够用蛮力克服随机化,他可以反复地用不同的地址进 行攻击 。一种常见的把戏就是在实际的攻击代码前插入很长一段的 no p ( 读作 \u0026quot; no op\u0026quot;, no ope ratio in 的缩写)指令。执行这种指令除了对程序计数器加一,使 之指向下一条指令之外,没有任何的效果。只要攻击者能够猜中这段序列中的某个地址,程序就会经过这个序 列, 到达攻 击代码。这个序列常用的术语是“ 空操 作雪橇 ( no p sled)\u0026quot; [97], 意思是程序会“滑过“ 这个序列。如果我们建立一个 256 个字节的 no p sled, 那么枚举 215 = 32 768 个 起始地 址, 就能破解 n 2 23 的 随 机化, 这对于一个顽固的攻击者来说 , 是完全可行的。对千 64 位的 情况, 要尝试枚举 沪 —1 6 777 216 就有点儿令人畏惧了。我们可以看到栈随机化和其 他一些 AS LR 技术能够增加成功攻击一个系统的难度, 因而大大降低了病毒或者蠕虫的传播速度,但是也不能提供完全的安全保障。\n练习题 3. 47 在运行 L in u x 版本 2. 6. 1 6 的机器上运行栈检查代 码 10 000 次, 我 们 获得地 址的 范 围从 最小的 Ox ff f f b 75 4 到 最 大 的 Ox f f f f d 75 4。\n地址的大概范围是多大?\n如果 我 们 尝试 一个 有 1 28 字节 no p s led 的 缓冲 区 溢 出 , 要 想 穷尽 所 有 的 起始地址, 需要尝试多少次?\n2 栈破坏检测\n计算机的第二道防线是能 够检测到何时栈已经被破坏。我们在 e c ho 函数示例(图3-\n中看到 , 破坏通常发生在当超越局部缓 冲区的边界时。在 C 语言 中 , 没有可靠的方法来防止对数组的越界写。但是,我们能够在发生了越界写的时候,在造成任何有害结果之 前,尝试检测到它。\n最近的 GCC 版本在产生的代码中加\n入了一种栈保护者 ( s t ack pro t ecto r ) 机制,\n来检测缓冲区越界。其思想是在栈帧中任 何局部缓冲区与栈状态之间存储一个特殊 的全·丝 雀 ( cana ry ) 值s , 如 图 3-42 所 示\n调用者\n的栈帧\necho\n返回地址\n\u0026lt; — r%\ns p + 24\n[26, 97] 。这个金丝雀值,也 称为哨兵值 的栈帧\n(guard value), 是在程序每次运行时随机\n金丝雀\n[7 Jl[ 6 Ji [ S Jl[ 4 Ji[ 3Jl [ 2 Jl[l li[ O]\n\u0026lt; — buf = %rsp\n产生的,因此,攻击者没有简单的办法能够知道它是什么。在恢复寄存器状态和从函数返回之前,程序检查这个金丝雀值是否被该函数的某个操作或者该函数调用的\n图 3-42 ech o 函数具有栈保护者的栈组织(在数组\nbuf 和保存的状态之间放了 一个特殊的“金丝雀" 值。代码检查这个金丝雀值 , 确定栈状态是否被破坏)\n某个函数的某个操作改变了。如果是的,那么程序异常中止。\n最近的 GCC 版本会试着确定一个函数是否容易遭受栈溢出攻击,并 且自动 插入这种溢出检测。实际上,对 于前面的栈溢出展示, 我们不得不用命令行选项 \u0026quot; - f no - s t a c k- pr ot e c t or \u0026quot; 来阻止 GCC 产生这种代码。当不用这个选项来编译 e c ho 函数时,也 就是允许使用栈保护者,得到下面的汇编代码:\nvoid echo()\necho: subq $24, %rsp Allocate 24 bytes on stack e 术语“金丝雀"源于历史上用这种鸟在煤矿中察觉有毒的气体。\n议\nmovq %fs:40, %rax Retrieve canary\n4 movq %rax, 8(%rsp) Store on stack\n5 xorl %eax, %eax Zero out register\n6 movq %rsp, %rdi Compute buf as¼rsp\n7 call gets Call gets\n8 movq .r 儿 s p , %rdi Compute but as %rsp\n9 call puts Call puts\n10 movq 8(%rsp), %rax Retrieve canary\n11 xorq %fs:40, %rax Compare to stored value\n12 je .19 If=, goto ok\n13 call stack_chk_fail Stack corrupted!\n14 .L9: ck:\n15 addq $24, %rsp Deallocate stack space\n16 ret\n这个版本的函数从内存中读出一个值(第3 行),再 把它存放在栈中相对千%r s p 偏移量 为 8 的地方 。指令参数%f s : 40 指明金丝雀值是用段寻址 ( s eg m e n ted ad d ress ing ) 从 内 存中读入的, 段寻址机制可以 追溯到 80286 的 寻 址, 而在现代系统上运行的程序中已经很少见到了。将金丝雀值存放在一个特殊的段中,标志为“只读“,这样攻击者就不能覆盖存 储的金丝雀值。在恢复寄存器状态和返回前,函数将存储在栈位置处的值与金丝雀值做比 较(通过第 11 行的 x or q 指令)。如果两个数相同, x or q 指令就会得到 0 , 函数会按照正常的方式完成。非零的值表明栈上的金丝雀值被修改过,那么代码就会调用一个错误处理 例程。\n栈保护很好地防止了缓冲区溢出攻击破坏存储在程序栈上的状态。它只会带来很小的性能损失, 特别是因为 GCC 只在函数中有局部 c h ar 类型缓 冲区的 时 候 才插入这样的代码。当然,也有其他一些方法会破坏一个正在执行的程序的状态,但是降低栈的易受攻击性能够对抗许多常见的攻击策略。\n让 练习题 3. 48 函 数 i n t l e n 、l e n 和 i p t o a 提供 了 一 种很 纠结 的 方 式 , 来计算 表 示 一个整数所 需 要 的 十 进 制 数 字 的 个 数。 我 们 利 用 它 来 研 究 GCC 栈保 护 者 措 施 的 一 些情况。\nint len(char *s) { return strlen(s);\n}\nvoid iptoa(char *s, long *p) { long val= *p;\nsprintf (s, \u0026ldquo;%ld\u0026rdquo;, val) ;\n}\nint intlen(long x) { long v;\nchar buf[12];\nV = x;\niptoa(buf, \u0026amp;v); return len(buf);\n}\n下 面是 i n t l e n 的 部分代码 , 分别 由 带和 不 带栈 保护者 编译:\nint intl en (1 ong x) x in %rdi 1 intlen: i nt 工 nt l en(l ong x) 2 subq $56, %rsp x in %rdi 3 movq %fs:40, %rax intlen: 4 movq %rax, 40(%rsp) 2 subq $40, %rsp 5 xorl %eax, 1儿eax 3 movq %rdi, 24(%rsp) 6 movq %rdi, 8(%rsp) 4 leaq 24(%rsp), %rsi 7 leaq 8(%rsp), %rsi 5 movq %rsp, %rdi 8 leaq 16(%rsp), %rdi 6 call iptoa 9 call iptoa a ) 不带保护者 b ) 带保护者\n对于两个版本: b u f 、v 和金 丝雀值(如果 有的 话)分别 在栈 帧 中的 什 么 位置?\n在有保护的代码中,对局部变量重新排列如何提供更好的安全性来对抗缓冲区越界攻击?\n3 限制可 执行代码区域\n最后一招是消除攻击者向系统中插入可执行代码的能力。一种方法是限制哪些内存区域能够存放可执行代码。在典型的程序中,只有保存编译器产生的代码的那部分内存才需要是可执行的。其他部分可以被限制为只允许读和写。正如第 9 章中会看到的 , 虚拟内存空间 在逻辑上被分成了页( page) , 典型的每页是2048 或者 4096 个字节。硬件支持多种形式的内存保护, 能够指明用户程序和 操作系统内核所允许的访问形式。许多系统允许控制三种访问形式: 读(从内存读数据)、写(存储数据到内存)和执行(将内存的内容看作机器级代码)。, 以x8 前6 体系结构将读和执行访问控制合并成一个1 位的标志, 这样任何被标记为可读的 页也都是可执行的。栈必须是既可读又可写的,因而栈上的字节也都是可执行的。已经实现的很多机制,能够限制一些页是可读但是不可执行的,然而这些机制通常会带来严重的性能损失。\n最近, AMD 为它的 64 位处理器的内 存保护引 入了 \u0026quot; N X\u0026quot; (No-Execute, 不执行)位, 将读和执行 访问模式分开, In t el 也跟进了 。有 了这个特性 , 栈可以 被标记为可读和可写, 但是不可执行,而检查页是否可执行由硬件来完成,效率上没有损失。\n有些类型的 程序要求 动态产生 和执 行代码的能力。例如, ”即时( jus t-in- t ime ) \u0026quot; 编译技术为解 释语言(例如J a va ) 编写的 程序动态地产生 代码 , 以提高执行 性能。是否能够将可执行代码限制在由编译器在创建原始程序时产生的那个部分中,取决千语言和操作系统。\n我们讲到的这些技术 随机化、栈保护和限制哪部分内存可以存储可执行代码\n是用千最小化程序缓冲区溢出攻击漏洞三种最常见的机制。它们都具有这样的属性,即不需要程序员做任何特殊的努力,带来的性能代价都非常小,甚至没有。单独每一种机制都降低了漏洞的等级,而组合起来,它们变得更加有效。不幸的是,仍然有方法能够攻击计算机 [ 85 , 97], 因而蠕虫和病毒继续危害着许多机器的完整性。\n10. 5 支持变长栈帧\n到目前为止,我们已经检查了各种函数的机器级代码,但它们有一个共同点,即编译 器能够预先确定需要为栈帧分配多少空间。但是有些函数,需要的局部存储是变长的。例 如, 当函数调用 a l l o c a 时就会发生这种情况。 a l l o c a 是一个标准库函数, 可以在栈上分配任意字节数量的存储。当代码声明一个局部变长数组时,也会发生这种情况。\n虽然本节介绍 的内容实际上是如何实现过程的一部分, 但我们还是 把它推迟 到现在才\n讲, 因为它需 要理解数组和对齐。\n图 3-43a 的代码 给出了一 个包含变长数组的 例子。该函数声明了 n 个指针的局部数组\np, 这里 n 由第一个参数 给出 。这要求 在栈上分 配 8 n 个字节, 这里 n 的值每次调 用该函数时都会不同 。因此编译 器无 法确定 要给该 函数的 栈帧分配多少空间。此外 ,该 程序 还产生一个对局部 变鼠 1 的地址引 用, 因此该 变星必须存储在栈 中。在执 行工程中 , 程序必须能够访问 局部变最 1 和数组 p 中的元素。返回时, 该函数必须 释放这个栈 帧, 并将栈指针设置为存储 返回地址的 位置。\nlong vframe(long n, long idx, long *q) { long i;\nlong *p[n]; p[O] = \u0026amp;i;\nfor (i = 1; i \u0026lt; n; i++) p[i] = q;\nreturn *p[idx];\n}\nC代码\nlong vframe(long n, l ong 工 dx , long *q)\nn 江I %r 中 , 过 x in %r s 工, q 耳1 %rdx Only portions of code shown vframe:\n2 pushq %rbp Save old %rbp 3 rnovq %rsp, %rbp Set fr 动 e pointer 4 subq $16, %rsp Allocate space for i (%rsp = s1) 5 leaq 22(,%rdi,8), %rax 6 andq $一1 6 , %rax 7 subq %rax, %rsp Allocate space for array p (%rsp = s2) 8 leaq 7(%rsp), %rax 9 shrq $3, %rax 10 leaq 0(,%rax,8), %r8 Set %r8 to !tp[O] 11 rnovq %r8, %rcx Set %rcx to !tp[O] (%rcx = p) Code for initialization loop\ni in¼rax and on stack, n in¼rdi, pin¼rcx, q in¼rdx\n12 .13: loop: 13 movq %rdx, (%rcx,%rax,8) Set p[i] to q 14 addq $1, %rax Increment i 15 movq %rax, - 8 C儿r bp ) Store on stack 16 . 1 2 : 17 movq -8(%rbp), %rax Retrieve i from stack 18 cmpq %rdi, %rax Compare i : n 19 jl .L3 If \u0026lt;, goto loop Code for function exit\nleave ret Restore¾rbp and 7.rsp Return\nb ) 生成的部分汇编代码\n图 3-43 需 要使用帧 指针的 函数。变长数组 意味着在编译 时无法确定栈帧的 大小\n为了管理 变长栈帧 , x8 6-64 代码使用 寄存器%r b p 作为帧 指针( fr am e pointer) (有时称为基指 针 ( base pointer) , 这也是%r bp 中 b p\n两个字母的由来)。当使用帧指针时,栈帧的\n组织结 构与图 3-44 中函数 v fr a me 的情况一 帧指针r% 样。可 以看到代码必须把%r b p 之前的值保存\n到栈中,因为它是一个被调用者保存寄存器。然后在函 数的整个执行过程中, 都使得%r b p 指向那个时刻栈的位置,然后用固定长度的 局部变量(例 如 i ) 相对于%r b p 的偏移趾来引\nbp -­\n\u0026lt; s,\n}e,\n用它们。 8n字节\n图 3-43 b 是 GCC 为 函数 v fr a me 生成的部分代码。在函数的开始,代码建立栈帧, 并为数组 p 分配空间。首先把%r b p 的当前 值\n压入栈 中, 将%r b p 设置为指向当前的栈位詈 栈指针%r sp — — \u0026gt;\n)\u0026hellip;. p\n(s,\n(第 2~ 3 行)。然后, 在栈上分配 1 6 个字节, 图 3-44 函数 vf ra me 的栈帧结构(该函数使用寄其中前 8 个字节 用于存储局部变最 i , 而后 8 存器 %r bp 作为帧指针。图右边的注释供个字节是未 被使用的。接着, 为数组 p 分配 练习题 3. 49 所 用 )\n空间(第 5 ~ 11 行)。练习题3. 49 探讨了分配多 少空间以 及将 p 放在这段 空间 的什么位置。当程序到第 11 行的时候, 已经 (1 ) 在栈上分 配了 8 n 字节 , 并( 2 ) 在已分配的 区域内 放置好数组 p , 至少有 811 字节可 供其使用 。\n初始化循环的 代码展示 了如何引 用局部变蜇 1 和 p 的例子。第 13 行表明 数组元素 p 肛]被设 置为 q 。该指令用 寄存器%r c x 中的值作 为 p 的起始地址 。我们可以 看到修 改局部变量 i( 第 1 5 行)和读局部变最(第1 7 行)的例子。1 的地址是引用- 8 (%rbp), 也就是相对千帧指 针偏移扯 为- 8 的地方。\n在函数的结 尾, l e a v e 指令将帧指针恢 复到它之前的值(第20 行)。这条指令不需要参数,等价千执行下面两条指令:\nmovq %rbp, %rsp Set stack pointer to beginning of frame popq %rbp Restores aved %rbp and set stack ptr\nto end of cal l ers\u0026rsquo; frame\n也就是 , 首先把栈指针设置为保 存%r b p 值的 位置, 然后把该 值从 栈中弹出到%r b p 。 这个指令组合具有释放整个栈帧的效果。\n在较早版本的 x86 代码中 , 每个函数调用都使用 了帧指针。 而现 在,只 在栈帧长可变的情况下才使用 , 就像函数 v fr a me 的情况一样。历 史上, 大多数编译 器在生成 I A32 代码时会使用帧 指针。最 近的 G CC 版本放弃了这个惯 例。可以 看到把 使用帧指针的代码和不使用帧指针的 代码混 在一起 是可以 的,只 要所有的函数都把%r b p 当做被调用者保存寄存器来处理即可。\n让 练习题 3. 49 在这 遗题 中,我们 要探 究图 3- 43 b 第 5 ~ 11 行代 码 背后的逻 辑, 它 分 配了变长大 小的数组 p 。 正如代码 的注释表明的 , S 1 表 示执行 第 4 行的 s ub q 指令 之后 栈指针的地 址。这 条指令 为局部 变量 1 分 配 空间。 S2 表 示执 行 第 7 行的 s ub q 指令之 后栈指针的值。 这条指令 为局部 数 组 p 分 配存 储。 最 后 , p 表 示 第 1 0 ~ 11 行的 指令 赋给寄 存器 %r 8 和%r c x 的值。 这 两个寄存器都用来 引用数组 p 。\n图 3-44 的 右边 画 出 了 s, 、Sz 和 p 指 示的 位置。 图 中 还 画 出 了 S2 和 p 的值之 间 可能有 一个 偏 移 量 为 e 2 字 节 的位置 , 该 空 间是未被使用 的。 数 组 p 的 结 尾和 s , 指 示的 位置之间 还可 能 有 一个 偏 移 量 为 e , 字节的地方。\n用 数 学 语 言解释第 5 ~ 7 行 中 计 算 Sz 的逻辑。提 示 : 想想—16 的位级表 示以 及它在第 6 行 a ndq 指令 中 的作用 。 用 数 学语 言解释第 8 ~ 10 行 中 计 算 p 的 逻 辑。 提 示 : 可 以 参 考 2. 3. 7 节 中 有 关 除以 2 的幕的 讨论。 对 于 下 面 n 和 s , 的值 , 跟 踪 代码 的执行, 确定 Sz 、p、e, 和 e2 的结果值。 sI 们 e,\n这段代码 为 S2 和 p 的 值提供 了什 么 样的 对 齐 属 性? 11 浮点代码 # 处理器的浮点体 系结构包括多个方面,会 影响对浮点 数据操作的程序如何被映射到机器上,包括:\n如何存储和访问浮点数值。通常是通过某种寄存器方式来完成。 对浮点数据操作的指令。\n向函数传递浮点 数参数和从函数返回浮点数结果的规则。\n函数调用过程中保存寄存器的规则 例如, 一 些 寄 存 器被指定为调用者保存, 而其他的被指定为被调用者保存。\n简要回顾历史会对理解 x86- 64 的 浮点体系结构有所帮助。1997 年出现了 Pent i um / MMX, Int el 和 AMD 都引 入了持续数代的媒体( med ia) 指令, 支持图形和图像处理。这些 指令本意是允许多个操作以并行模式执行, 称 为 单指令 多 数 据或 S IMD ( 读作 sim-de e )。在这种模式中,对 多 个不同的数据并行执行同一个操作。近年来, 这些扩展有了长足的发展。名字经过 了 一 系列 大的 修 改, 从 MMX 到 SSE ( Str eaming SIMD Extens ion , 流式\nSIMD 扩展),以 及最新的 AVX (Advanced Vector Extension , 高级向量扩展)。每一代中,都 有一些不同的版本。每个扩展都是管理寄存器组中的数据, 这些寄存器组在 MMX 中 称为 \u0026quot; MM\u0026quot; 寄 存 器 , SS E 中称为 \u0026quot; XMM\u0026quot; 寄 存 器 , 而在 AVX 中 称 为 \u0026quot; YMM\u0026quot; 寄存器;\nMM 寄存 器是 64 位的, XMM 是 128 位的 , 而 YMM 是 256 位的。所以, 每个 YMM 寄存器可以存放 8 个 32 位值, 或 4 个 64 位值, 这些值可以 是整数,也 可以 是浮点数。\n2000 年 Pent i um 4 中 引 入了 SSE2 , 媒体指令开始包括那些对标量浮点数据进行操作的指令,使 用 XMM 或 YMM 寄存 器的低 32 位或 64 位 中 的 单 个 值 。 这个标量模式提供了一组寄存器和指令,它 们 更类似于其他处理器支待浮点 数的 方式。所有能够执行 x86-6 4 代码的处理器都支待 SSE2 或更高的版本,因 此 x86-6 4 浮点数是基于 SSE 或 AVX 的 , 包括传递过程参数和返回值的规则[ 77] 。\n我们的讲述基于 AVX2 , 即 AVX 的 第 二 个 版本, 它 是 在 201 3 年 Core i7 Has well 处理器中引入的。当给定命令行参数- ma v x2 时 , GCC 会生成 AVX2 代 码 。 基于不同版本的\nSSE 以及第一个版本的 AVX 的 代码从概念上来说是类似的,不 过 指 令 名和格式有所不同。我们只介绍用 GCC 编译浮点程序时会出现的那些指令。其中大部分是标量 AVX 指令, 我\n们也会 说明对整个数 据向量进行 操作的 指令出现的情况。后文中的网络旁 注 O PT , SIMD 更全面地 说明了如何利用 SSE 和 AVX 的 SIMD 功能读者可能 希望参考 AM D 和 Intel 对每条指令 的说明 文档[ 4 , 51] 。和整数操 作一样, 注意 我们表述中使用的 AT T 格式不同 千这些文档中 使用的 Intel 格式。特别地, 这两种版本 中列出指令操作数的顺 序是不同 的。\n如图 3-45 所示 , AVX 浮点 体系结 构允许数据存储在 16 个 YM M 寄存器中, 它们的 名字为 %ymrn0~ %ymrn1 5 。 每个 YM M 寄存器都 是 256 位( 32 字节)。当对标最数据操作时, 这些寄 存骈只保 存浮点数, 而且只使用低 32 位(对千 fl o a t ) 或 64 位(对于 d o u b l e ) 。汇编代码 用寄存器的 SS E XM M 寄存器名字%xmrn0~ %xmrn1 5 来引用 它们, 每个 XM M 寄存器\n都是对应 的 YM M 寄存器的 低 1 28 位(1 6 字节)。\n255\n尸 # E三\n尸三三三三E E\n三三三三 # 三三\n127 。\n%xrnm0 II 1st FP arg 返回值\n%xmml II 2nd FP参数\n%xmm2 II 3rd FP参数\n令xmm3 II 4th FP参数\n%xrnm4 II 5th FP参数\n%xmm5 』6th FP参数\n1 %xmm6 17th FP参数\nI%xmm7 18th FP 参数\nJ %xrnm8 ii 调用者保存\nl %xrnm9 II 调用 者保存\nj%xmm1 0 II 调用者保存\nl %xrnmll II调用者保存\n%xrnm1 2 11 调 用者保存\n%xmml 3 II调用者保存\n%xmm14 II调用者保存\nJ %xmml 5 II调用者保存\n图 3-45 媒 体 寄 存 器 。 这些寄存器用于存放浮点 数 据。每个 YMM 寄存器保 存 32 个 字 节 。 低 16 字 节 可以 作为 XMM 寄存器来访问\n3. 11. 1 浮点传送和转换操作\n图 3-46 给出了一组在内存和 XM M 寄存器之间以及从一个 XM M 寄存器到另一个不\n做任何转换的 传送浮点 数的指 令。引用内 存的指令是标量指 令, 意味着它们只对单个而不 是一组封装好的数 据值进行 操作。数据要么保存在内 存中(由表中的 M 32 和 M 64 指明), 要 么保存在 XM M 寄存器中(在表中 用 X 表示 )。无论数据对齐与否, 这些指令都能正确执行, 不过代码优化规则建议 32 位内 存数据满足 4 字节对齐, 64 位数据满足 8 字节 对齐。内存引 用的指定方式与整数 MOV 指令的一样, 包括偏移量、基址寄存器、变址 寄存器和伸缩因子的 所有可能的组合。\n指令 源 目的 描述 vrnovss M32 X 传送单精度数 vmovss X Ms, 传送单精度数 vmovsd M\u0026quot; X 传送 双精 度数 vmovsd X M\u0026quot; 传送双精度数 vmovaps X X 传送对 齐的 封装好的 单精度数 vmovapd X X 传送对齐的封装好的 双精度数 图 3-46 浮点 传送指 令。这些操作在内存和寄存器之间以 及一对寄存器之间传 送值 \u0026lt;X, XMM\n寄存器(例如%x mm3 ) ; M32 : 3 2 位内 存范围; M6, : 64 位内 存范围)\nGCC 只用标量传送操作从内存传送数据到 XM M 寄存器或从 XM M 寄存器传送 数据到内 存。对 于 在 两 个 XM M 寄 存 器 之间 传 送 数 据, GCC 会使用两 种 指令之一, 即用v mo v a p s 传送单精度数 , 用 v mo v a p d 传送双精度数。对于这些情况, 程序复制整个寄存器还是只复制低位值既 不会影响程序功能 , 也不会影响 执行 速度 , 所以使用这些指令还是\n针对标 量数据的指令没有实质上的 差别。指令名字中的字母 '矿表示 \u0026quot; a li g n ed ( 对齐的 )"。当用于读写 内存时 , 如果 地址不满足 16 字节 对齐, 它们会导 致异常。在两个寄存器之间传送数据, 绝不 会出现错 误对齐的状况。\n下面是一 个不同 浮点 传送操作的 例子, 考虑以 下 C 函数\nfloat float_mov(float v1, float *Src, float *dst) { float v2 = *src;\n*dst = v1;\nreturn v2;\n}\n与它相关 联的 x86- 64 汇编代码 为\nfloat float_mov(float v1, float *src, float *dst) v1 in %xmm0, src in %rdi, dst in %rsi\nfloat_mov:\nvmovaps %xmm0, %xmm1 vmovss (%rdi), %xmm0 vmovss %xmm1, (%rsi) ret\nCopy v1\nRead v2 from src Write v1 to dst Return v2 in¼xmmO\n这个例子中可以 看到它 使用了 v mo v a p s 指令把数据从一个寄存器复制到另一个, 使用了\nv mo v s s 指令把数 据从内 存复制到 XM M 寄存器以及从 XM M 寄存器复制到内 存。\n图 3-47 和图 3-48 给出了 在浮点数和整 数数据类型之间以及不同浮点 格式之间进行转换的指令集 合。这些 都是对单 个数据值进行操作的标量指令。图 3-47 中的指令把一个从XM M 寄存器或内存中读出的 浮点值进 行转换, 并 将结果写入一个通用寄存 器(例如\n%r a x 、%e b x 等)。把浮点 值转换 成整数时 , 指令会执 行截断 ( t ru n ca t io n ) , 把值向 0 进行舍\n入, 这是 C 和大多数其他编程语言的要求。\n指令 源 目的 描述 vcv t t s s 2s i X/ M32 R,, 用 截断的 方法把单精 度数转换 成整数 vcvt t sd 2s i X/ M\u0026quot; R,, 用 截断的方 法把 双精 度数转换 成整数 vcvttss2siq X/ M32 R,, 用 截断的方法把单精 度数转换 成四字 整数 vcvttsd2s iq X/ M\u0026quot; R, 4 用 截断的方法 把双精 度数转换 成四字整数 图 3-47 双操作数浮点转换指 令。这些 操作将浮点 数转换成整数 ( X , XMM 寄存器(例如% x mm3 ) ; R32 :\n32 位通用 寄存器(例如%e a x) ; R\u0026quot; : 64 位通用寄存器(例如%r a x ) ; M32 : 32 位内存范围; M \u0026quot; :\n64 位内存范围)\n指令 源 1 源 2 目的 描述 vcvtsi2ss M32 / R32 X X 把整数转换成单精度数 vcvt s i 2s d M32/ R32 X X 把整数转换成双精度数 vcvtsi2s sq M,d R,, X X 把四字整数转换成单精度数 vcvtsi2sdq M64/ R64 X X 把四字整数转换成双精度数 图 3- 48 三操作数 浮点转换 指令 。这些操作将第一个 源的 数据类型转换 成目的的数据类 塑。第二个源值对结果的低位 字节没 有影响CX : XMM 寄存器(例如% x rnm3 ) ; M, 2 : 3 2 位内存范围 ; M , , : 64 位内存范围)\n图 3-48 中的指令把整数转换成浮点数。它们使用的是不太常见的三操作数格式, 有两个源和一个目的。第一个操作数读自于内存或一个通用目的寄存器。这里可以忽略第二 个操作数 ,因 为它的值只会影响结果的高位字节。而我们的目标必须是 X M M 寄存器。在最常见的 使用场景中 ,第 二 个 源和目的操作数都是一样的, 就像下面这 条指令:\nvcvtsi2sdq %rax, %xmm1, %xmm1\n这条指 令从寄存器%r a x 读 出 一 个 长 整数,把 它 转 换成数据类型 d o u b l e , 并把结果存放进\nXM M 寄存器%x mrnl 的 低 字节中。\n最后 , 要在两种不同的浮点格式之间转换, G CC 的当前版本生成的代码需要单独说明。假设 %x mrn0 的低位 4 字 节保 存 着一个单精度值,很 容易 就想到用下面这条指令\nvcvtss2sd %XIIlIIl0, %XIIlIIl0, %XIIlIIl0\n把它转 换成一个双精度值,并 将 结 果 存 储 在寄存器%x mrn0 的 低 8 字节。不过我们发现 GCC\n生成的代码如下\nConversion from single to double precision\nvunpcklps %xmm0, %xmm0, %xmm0 Replicate first vector element\n2 vcvtps2pd %xmm0, %xmm0 Convert two vector elements to double\nvunp c kl p s 指令通常用来交叉放置来自两个 X M M 寄存器的值, 把它们存储到第三个寄存器中 。也 就是说,如 果 一 个 源寄存器的内容为字[ s3 , s2 , s 1 , s。J , 另 一 个 源寄存器为字[ d 3 , dz, d 1 , d 。J , 那 么 目 的 寄 存 器 的 值 会 是 [ s1 , d1, s。, d 。] 。 在上面的代码中, 我们看到三个 操作数使用同一个寄存器 , 所以如果原始寄存器的值为[ x 3 , Xz , X1 , X。J , 那\n么该指令 会将寄存器的值更新为值[ x 1 , X1 , Xo , Xo] 。 v c v t p s 2 p d 指令把源 X M M 寄存器中的两个 低位单精度值扩展成目的 X M M 寄存器中的两个双精度值。对前 面 v u n p c k l p s\n指令的结果应用这条指令会得到值[ d x,o d x o] , 这 里 d x o 是 将 x 转换成双精度后的结果。\n即, 这两条指令的最终效果是 将原 始的%x mrn0 低位 4 字节中的单精度值转换成双精 度值 , 再将其 两个副本保存 到%x mrn0 中。我们不太清楚 GCC 为什么会生成这样的代码, 这样做既没有好处 , 也没有必要 在 XMM 寄存器中 把这个值复 制一遍。\n对于把双精度转换 为单精度 , GCC 会产生类 似的代码 :\nConvers 工 on from double to single prec1s1on\nvmovddup %xrnm0, %xrnm0 Replicate first vector element vcvtpd2psx %xrnm0, %xrnm0 Convert two vector elements to single 假设这些指 令开始执行前 寄存器%x mm0 保存着两个双精度值[工门 工。]。 然后 vrno v d d u p 指\n令把它设 置为[ 工o\u0026rsquo; X。]。 v c v t p d 2p s x 指令把这两个 值转换成单精度, 再存放到该 寄存器的低位一半 中, 并将高位一半设 置为 o , 得到结果[0. 0, 0. 0, Xo , 工。](回想一下, 浮点值\no. 0 是由位模式 全 0 表示的)。同样,用 这种方式 把一种精度转换成 另一种精度 , 而不用下面的单条指令,没有明显直接的意义:\nvcvtsd2ss %xmm0, %xmm0, %xmm0\n下面是一 个不同 浮点 转换 操作的例子 , 考虑以下 C 函数\ndouble fcvt(int i, float•fp, double•dp, long•lp)\n{\nfloat f = *fp;\n*lp = (long)\n*fp = (float)\n*dp= (double) return (double)\n}\ndoubled=•dp; long 1 =•lp; d;\ni;\nl;\nf;\n以及它对应的 x8 6- 64 汇编代码\ndouble fcvt (int i, float *fp, double *dp, long *lp) i in¼edi, fp in¼rsi, dp in¼rdx, lp in¼rcx fcvt:\nvmovss (%rsi), %xmm0 Get f = *fp\nmovq (%rcx) , %rax Get 1 = *lp\nvcvttsd2siq (%rdx), %r8 Get d = *dp and convert to long\nmovq %r8, (%rcx) Store at lp vcvtsi2ss %edi, %xmm1, %xmm1 Convert i to float vmovss %xmm1, (%rsi) Store at fp vcvtsi2sdq %rax, %xmm1, %xmm1 Convert 1 to double vmovsd %xmm1, (%rdx) Store at dp\nThe f ol l owi ng two instructions convert f to double\nvunpcklps %xmm0, %xmm0, %xmm0\nvcvtps2pd %xmm0, %xmm0\nret Return f\nf c v t 的所有参数都是 通过通 用寄存器传递的, 因为它们既不是整数也不是指针。结果通过寄存器 %x mm0 返回。如图 3- 45 中描述的 , 这是 fl o a t 或 d o u b l e 值指定的返回寄存器。在这段 代码 中, 可以看到图 3- 46 ~ 图 3-48 中的许多传送 和转换指令, 还 可以看到GCC 将单精度转换 为双精度的方法 。\n; 练习题 3. 50 对于下面的 C 代码 , 表达式 va ll ~ v a l 4 分别对应程序值 L、 f 、 d 和 1 : double fcvt2(int *ip, float *fp, double *dp, long 1)\n{\ninti= *ip; float f = *fp; doubled= *dp;\n*ip = (int) val!;\n*fp = (float) val2;\n*dp = (double) val3; return (double) val4;\n}\n根据该 函 数 如下的 x8 6-64 代 码 , 确定这个映射 关 系 :\ndouble fcvt2(int *ip, float *fp, double *dp, long 1)\nip in 7.rdi, fp i n 肚 s i , dp i n 7.rdx, 1 in 7.rcx Result returned in 7.xmmO\nfcvt2:\nrnovl (%rdi), %eax\nvrnovss (%rsi), %xrnrn0\n4 vcvttsd2s i (%rdx), %r8d\nmovl %r8d, (%rdi)\nvcvtsi2ss %eax, %xmm1, %xmm1\nvmovss %xmm1, (%rsi)\nvcvtsi2sdq %rcx, %xmm1, %xmm1\n9 vmovsd %xmm1, (%rdx)\nvunpcklps %xmm0, %xmm0, %xmm0\nvcvtps2pd %xmm0, %xmm0\nret\n练习题 3. 51 下 面的 C 函 数 将 类 型 为 s r c _ t 的 参 数 转 换 为 类 型 为 d s 七—t 的 返 回 值 , 这里 两 种 数据类 型都 用 t y p e d e f 定义 :\ndest_t cvt(src_t x)\n{\ndest_t y = (dest_t) x; return y;\n}\n在 x8 6- 64 上执行这 段代 码 , 假设 参 数 x 在 %x rnm0 中 , 或 者在 寄 存 器%r d i 的 某 个适当的命名部分中(即%立江或% e d i ) 。用 一条或 两条 指令来 完 成 类 型 转换 , 并 把 结 果值 复制 到 寄存器%r a x 的 某 个 适 当 命 名 部 分 中(整 数 结 果), 或 %x rnm0 中(浮 点 结果)。给出这条或这些指令,包括源和目的寄存器。\nT, Ty 指令 long double vcvtsi2sdq %r di , %xmm0 double int double float long float float long 11. 2 过程中的 浮点 代 码\n在 x8 6-64 中, XM M 寄存器用来向函数传递浮点 参数,以 及从函数返回浮点 值。如图\n所示, 可以看到如下规则: XM M 寄存器%x mm0 ~ %x mm7 最 多 可以 传递 8 个浮点 参数。按照参数列出的顺序使用这些寄存器。可以通过栈传递额外的浮点 参数。 函数使用 寄存器 %x mm0 来返回 浮点值。\n所有的 XM M 寄存器都是 调用者保存的。被调用者可以 不用保存就覆盖这些 寄存器中任意一个。\n当函数包含指针、整数和浮点 数混合的参数时 , 指针和整数通过通用寄存器传递, 而\n浮点值通过 XM M 寄存器传递。也就是说 , 参数到寄 存器的映射取 决千它们 的类型和排列的顺序。下面是一些例子:\ndouble fi(int x, double y, long z);\n这个函数会把 x 存放在 % e d i 中, y 放在 %x mm0 中 , 而 z 放在 %r s i 中。\ndouble f2(double y, int x, long z);\n这个函数的寄存 器分 配与函数 fl 相同。\ndouble fl(float x, double *Y, long *z);\n这个函数会将 x 放在 %x mm0 中, y 放在 %r 生 中, 而 z 放在%r s i 中。\n; 练习题 3. 52 对于下 面每个 函数声明 , 确定 参数的寄 存器 分配:\ndouble g1(double a, long b, float c, int d); double g2(int a, double *b, float *c, long d); double g3(double *a, double b, int c, float d); double g4(float a, int *b, float c, double d) ; 3. 11. 3 浮点运算操作\n图 3- 49 描述了一组执行算术 运算的标量 AV X2 浮点 指令。每条指 令有一个( S 1 ) 或两个\u0026lt;S 1 , S 2) 源操作 数, 和一个目的 操作数 D。第一个源操作 数 S 1 可以是一个 XMM 寄存器或一个内存位置。第二 个源操作数 和目的 操作数都必 须是 XMM 寄存器。每个操 作都有一条针对单 精度的指 令和一条针对 双精度的 指令。结果存放 在目的寄存 器中。\n单精度 双精度 效果 描述 vaddss vsubss vmulss vdivss vrnaxss vminss vaddsd vsubsd vmulsd vdi vsd vmaxsd vminsd 0-s,+s1 o - s , - s , D- S, XS 1 D- Sz/S1 D 千 max(S 2 , S1) D- min(S 2 , S,) 浮点数加浮点数减浮点数乘浮点数除 浮点数最大值 浮点数最小值 sqrtss sqrtsd D- 尽 浮点数平方根 图 3-49 标扭浮点算术运算。这些指令有一个或两个源操作数和一个目的操作数\n来看一个例子 , 考虑下 面的 浮点 函数:\ndouble funct(double a, float x, double b, inti)\n{\nreturn a*x - b/i;\n}\nx86-64代码如下 :\ndouble funct(double a, float x, double b, 工 nt i) a in %xmm0, x 工n %xmm1, bin %xmm2, i in %edi funct:\nThe following two instructions convert x to double vunpcklps %xmm1, %xmm1, %xmm1\nvcvtps2pd %xmm1, %xmm1\nvmulsd %xmm0, %xmm1, %xmm0 vcvtsi2sd %edi, %xmm1, %xmm1 vdivsd %xmm1, %xmm2, %xmm2\nvsubsd %xmm2, %xmm0, %xmm0 ret\nMul t i pl y a by x Convert i to double Compute b/i\nSubtract from a*x Return\n三个浮点 参数 a 、x 和 b 通过 XM M 寄存器%x mm0~ %x mm2 传递, 而整数参数 通过寄 存器%e 中 传递。标准的 双指令序列用以将参数 x 转换为双精度类 型(第2 ~ 3 行)。另一条转换指令 用来将参数 l 转换为双精度类型(第5 行)。该函数的 值通过寄存 器%x mm0 返回。\n练习题 3. 53 对 于下 面的 C 函 数 , 4 个参数的 类型由 t y p e d e f 定义 :\ndouble funct1(arg1_t p, arg2_t q, arg3_t r,\n{\narg4_t s)\nreturn p/(q+r) - s;\n}\n编译时, GCC 产 生 如下代码 :\ndouble funct1(arg1_t p, arg2_t q, arg3_t r, arg4_t s) funct1:\nvcvtsi2ssq %rsi, %xmm2, %xmm2 vaddss %xmm0, %xmm2, %xmm0\nvcvtsi2ss %edi, %xmm2, %xmm2 vdi vss %xmm0, %xmm2, i 儿 x mmO vunpcklps %xmm0, %xmm0, %xmm0\nvcvtps2pd %xmm0, i儿x mmO vsubsd %xmm1, %xmm0, %xmm0 ret\n确定 4 个参 数类 型可 能 的 组合(答案 可 能 不 止 一种)。让 练习题 3. 54 函 数 f u n c 七2 具有如下原 型:\ndouble funct2(double w, int x, floaty, long z);\nGCC 为该 函 数产 生 如下代码 :\ndouble funct2(double w, int x, float y, long z) w in %xmm0, x in %edi , y in %xmm1 , z in %rsi funct2:\nvcvtsi2ss %edi, %xmm2, %xmrn2 vmulss %xmrn1, %xmm2, %xmrn1 vunpcklps %xmm1, %xmrn1, %xmm1\nvcvtps2pd %x 皿 1 , %xmm2\nvcvtsi2sdq %rsi, %xmm1, %xmm1 vdivsd %xmrn1, %xmrn0, %xmm0\nvsubsd %xmrn0, %xmrn2, %xmm0 ret\n写 出 f u n c t 2 的 C 语言版 本。\n3. 1 1 . 4 定义和使用浮点常数\n和整数 运算 操作不同 , AV X 浮点 操作不能以 立即数值作为操作数。相反, 编译 器必须为所有 的常量值分配和初 始化存储 空间。然后代码在把这些 值从内存读入。下面从 摄氏度到华氏 度转换 的函数就说明 了这个问题:\ndouble cel2fahr(double temp)\n{\nreturn 1.8 * temp + 32 . 0 ;\n}\n相应的 x8 6- 64 汇编代码部分如下 :\ndouble cel 2f ahr (doubl e temp) temp in¼xmmO\ncel2fahr:\n2 vmulsd . LC2 (%rip), %xmm0, %xmm0 Multiply by 1 . 8 3 vaddsd . LC3 (%rip) , %xmm0, %xmm0 Add 32. O 4 ret 5 . LC2 : 6 .long 3435973837 Low-order 4 bytes of 1.8 7 long 1073532108 High-order 4 bytes of 1.8 8 .LC3: 9 .long 0 Low-order 4 bytes of 32 . 0 0 1 .long 1077936128 High-order 4 bytes of 32 . 0 可以 看到函数从标号为 . LC2 的内存位置读出值 1. 8\u0026rsquo; 从标号为 . LC3 的位置读入值 32 . 0。观察这些 标号对应的 值, 可以看出每一个 都是通过一 对 . l o n g 声明和十进 制表示 的值指定的。该怎样把这些 数解释为浮点 值呢?看看标号为 . LC2 的声明, 有两个值: 3435973837\n( Ox c c c c c c c d ) 和 1 0 73532 108 ( 0x 3 f f c c c c c ) 。因为机器采用的 是小端法字节顺 序, 第一个值给出的 是低位 4 字节 , 第二个给出的是 高位 4 字节。从 高位字节, 可以 抽取指数字段为Ox 3 f f (l 0 23 ) , 减去偏 移 10 23 得到指数 0。将两个值的小数位连接起来, 得到小数字段\nOxccccccccccccd, 二进制小数表示 为 0. 8 , 加上隐含的 1 得到 1. 8 。\n五 练习题 3. 55 解释标 号 为 . LC3 处声明 的数 字是 如何 对数 字 3 2. 0 编码的。\n3. 1 1. 5 在浮点代码中使用位级操作\n有时, 我们会发现 GCC 生成的 代码会在 XM M 寄存器上执行位级操作, 得到有用的浮点结果。图 3-50 展示了一些相关的指令, 类似千它们在通用寄存器上对应的操作。这些操作都作用千封装好的 数据, 即它们更新 整个目的 XM M 寄存器, 对两个 源寄存 器的所有位都实 施指定 的位级操 作。 和前面一样, 我们只对标量数据感兴趣, 只 想了解这些指令对目的寄存器的低 4 或 8 字节的 影响 。从下面的例子中可以 看出, 运用 这些操作 通常 可以简单方便地操作 浮点数。\n单精度 双精度 效果 描述\nvxorps vandps\nvorpd andpd\no- s,-s , # o- s, \u0026amp;s ,\n位级异或 ( EXCLUS IVE - OR)\n位级 与C AN DJ\n图 3-50 对封装数 据的位级操 作(这些指令 对一个 XM M 寄存器中的 所有 128 位进行 布尔操作)\n霆 练习题 3. 56 考 虑下 面的 C 函数 , 其 中 EXPR 是用 # d e f i ne 定义的 宏:\ndouble simplefun(double x) { return EXPR(x);\n}\n下面,我们给出 了为不 同 的 EXPR 定义 生成 的 AV X Z 代 码, 其 中, x 的 值保 存在%xmrn0 中。这些代码都对应于某些对浮点数值有用的操作。确定这些操作都是什么。要理解 从内存中取出的常数字的位模式才能找出答案。\n。\n。\n3. 11 . 6 浮点比较操作\nAVX2 提供了两条用 千比较 浮点数值的指令 :\n指令\nUCOffilSS S1, s,\nucornisd S1 , S,\n基于\nS, - S1\ns , —S1\n描述\n比较单精度值比较双精度值\n这些指令类 似千 CMP 指令(参见3. 6 节), 它们都比较操作数 S 1 和 S 2 (但是顺序可能与预计的相反), 并且设置条件码 指示它们的相对值。与 cmpq 一样, 它们遵循以相反顺序列出操 作数的 A T T 格式惯 例。参数 S 2 必 须在 XM M 寄存器中, 而 S 1 可以 在 XM M 寄存器中,也可以在内存中。\n浮点比较指令会设置三个条件码: 零标志位 ZF 、 进位标志位 CF 和奇偶标志位 PF。\n6. 1 节中我们没有讲奇偶 标志位, 因为它 在 GCC 产生的 x86 代码中不 太常见 。对于整数操作 , 当最近的 一次算术或逻辑 运算产生的值的最低位字节是偶校验的(即这个字节中有偶数个 1) \u0026rsquo; 那么就会设置这个标志位。不过对于浮点比较, 当两个操作数中任一个是Na N 时, 会设置该位。根 据惯例, C 语言中如果有个参数为 N a N , 就认为比较失败了, 这个标 志位就被用来发 现这样的 条件。例如 , 当 x 为 N a N 时, 比较 x == x 都会得到 0。\n条件码的设置条件如下:\n顺序 s, ,s, CF ZF PF\n无序的 1 1\nS2 \u0026lt; S1 1\nS 2= 5 1\nS2\u0026gt;S1\n当任一操作数为 N a N 时, 就会出 现无序 的情况。可以 通过奇偶 标志位发 现这种情 况。通常 JP 勺um p on parity ) 指令是条件跳转 , 条件就是 浮点比较得到一个无序的结果。除了这种情况以外, 进位和零标志位的值都 和对应的无符号比较一样 : 当两个操作数相等时 , 设置 ZF; 当 S2\u0026lt; S1 时, 设置 CF。像 j a 和 j b 这样的指令可以根据标志位的各种组合进行条件跳转。\n来看一个浮点比较的例子 , 图 3-5 l a 中的 C 函数会根据参数 x 与 o. 0 的相对关 系进行分\n类, 返回一个枚举类型作为结果 。C 中的枚举类型是编码为整数的 , 所以 函数可能 的值为:\nO ( NEG) , lCZERO), 2 ( POS ) 和 3 ( 0 THER) 。当 x 的值为 N a N 时, 会出现最后一种结果 。\ntypedef enum {NEG, ZERO, POS, OTHER} range_t; range_t find_range(float x)\n{\nint result;\nif (x \u0026lt; 0)\nresult= NEG; else if (x == 0)\nresult= ZERO; else if (x \u0026gt; 0)\nresult= POS;\nelse\nresult= OTHER;\nreturn result;\n}\nC代码 range_t find_range(float x) x in %xmm0 1 find_range: 2 vxorps %xmml , %xmml , %xmm1 Set %xmm1 = 0 3 vucomiss %xmm0, %xmm1 Compare O:x 4 ja .15 If \u0026gt;, goto neg 5 vucom1ss %xmm1, %xmm0 Compare x : O 6 jp .L8 If NaN, goto posornan 7 movl $1, %eax result= ZERO 8 je .L3 It=, goto done 9 .L8: posornan: 10 vucomiss .LCO(%rip), %xmm0 Compare x:O 11 setbe %al Set result= NaN? 1 : 0 12 movzbl %al, %eax Zero-extend 13 addl $2, %eax result += 2 (P OS for \u0026gt; 0, OTHER for NaN) 14 ret Return 15 .L5: neg: 16 movl $0, %eax r se ul t = NEG 17 .L3: done : 18 rep; ret Return b ) 产生的汇编代码\n图 3-51 浮点代码中的条件分支说明\nGCC 为 丘nd _ r a ng e 生成图 3-51 6 中的代码。这段 代码的效率不是很高: 它比较了 x\n和 0. 0 三次 , 即使一次比较就能获得所需的信息。它还生成了浮点 常数两次: 一次使用\nvxorps, 另一次从内存读出这个值。让我们追踪这个函数,看看四种可能的比较结果:\nX \u0026lt; 0. 0 第 4 行的 j a 分支指令会选择跳转 , 跳转到结尾 , 返回值为 0。\nx=O. 0 j a ( 第 4 行)和j p ( 第 6 行)两个分支语句都 会选择不 跳转 , 但是 j e 分支(第8\n行)会选择跳转,以% e a x 等于 1 返回 。\nX \u0026gt; 0. 0 这三个分支都不会选 择跳 转。s e t b e ( 第 11 行)会得到 o ,\n行)会把它增加 , 得到返 回值 2。\na d d l 指令(第1 3\nx=NaN jp 分支(第6 行)会选择跳转。第三个 v uc omi s s 指令(第10 行)会设置进位和零 标志位, 因此 s e t b e 指令(第11 行)和后面的指令会把% e a x 设置为 1 。a d d l 指令\n(第 13 行)会把它增加, 得到返 回值 3。\n家庭作业 3. 73 和 3. 74 中, 你需要试着 手动生成 f i nd _r a ng e 更高效 的实现。\n讫 练习题 3. 57 函数 f unc t 3 有 如下 原 型:\ndouble funct3(int *ap, double b, long c, float *dp);\n对于此函数, GCC 产 生如下 代码 :\ndouble funct3(int•ap, double b, long c, fl oat •dp) ap in r% di , b in %xmm0, c in %rsi, dp in %rdx funct3:\nvmovss (%rdx), %xmm1\nvcvtsi2sd (%rdi), %xmm2, %xmm2\nvucomisd %xmm2, %xmm0 jbe .18\nvcvtsi2ssq %rsi, %xmm0, %xmm0 vmulss %xmm1, %xmm0, %xmm1 vunpcklps %xmm1, o/.xmm1, %xmm1\nvcvtps2pd %xmm1, %xmm0\nret\n.18:\nvaddss %xmm1, %xmm1, %xmm1 vcvtsi2ssq %rsi, %xmm0, %xmm0 vaddss %xmm1, %xmm0, %xmm0 vunpcklps %xmm0, %xmm0, %xmm0\nvcvtps2pd %xmm0, %xmm0 ret\n写出 f unc t 3 的 C 版本。\n11 . 7 对浮点代码的观察结论\n我们可以 看到 ,用 AVX 2 为浮点 数上的 操作产生的机器代码 风格类 似千为 整数上的 操作产生 的代码风格 。它们都 使用一组寄存器来保存和操作数 据值 , 也都使用这些寄存器来传递函数参数。\n当然,处理不同的数据类型以及对包含混合数据类型的表达式求值的规则有许多复杂 之处 , 同时, AVX2 代码包括许 多比只执行整数 运算的 函数更加不同的 指令和格式 。\nAVX2 还有能力 在封装好的 数据上执行并行 操作, 使计算执行 得更快。编译器开发 者正致力于自动化从标量代码到并行代码的转换,但是目前通过并行化获得更高性能的最可\n靠的方法是使 用 GCC 支持的、操纵向量数 据的 C 语言扩展。参见原书 546 页的网络旁 注\nOPT: SIMD, 看看可以怎么做到这样。\n3. 12 小结\n在本章 中, 我们窥 视了 C 语言提供的 抽象层下面的 东西,以 了 解机器级编程。 通过 让编译 器产生机器级程序的 汇编代码 表示 , 我们 了解了编译器和它的优化能力, 以及机器、数 据类型和指 令集。 在第 5 章,我们会看到,当编写能有效映射到机器上的程序时,了解编译器的特性会有所帮助。我们还更完整 地了 解了 程序如何 将数 据存储在不同的内 存区域中 。在第 12 章 会看 到许多 这样的 例子, 应用 程序员需 要知道一 个程序变 量是 在运行时 栈中, 是在某个 动态分 配的 数据结构中, 还是全局程序数 据的一部分。理解程序如何映射到机器上,会让理解这些存储类型之间的区别容易一些。\n机器级 程序和它们的 汇编代 码表示 , 与 C 程序的差别很大。各 种数据类型之间的差别很小。程 序是以指令序列来表示的,每条指令都完成一个单独的操作。部分程序状态,如寄存器和运行时栈,对程序 员来说是直接可见的。本书仅提供了低级操作来支持数据处理和程序控制。编译器必须使用多条指令来 产生和操作各 种数据结构, 以及实现像 条件 、循环和过 程这样的控制结 构。我 们讲述了 C 语言和如 何编译它的许多不同方面。我 们看到 C 语言中缺乏边界检查, 使得许多程序容易 出现缓 冲区 溢出。虽 然最近的运行时系统提供了安全保护,而且编译器帮助使得程序更安全,但是这巳经使许多系统容易受到恶意 入侵者的攻击。\n我们只分析了 C 到 x86-64 的映射, 但是 大多 数内容对其他语言和机器组合来说也是类似的 。例如, 编译 C++ 与编译 C 就非常相似。实际 上 , C++ 的早期 实现 就只是 简单地执行了从 C++ 到 C 的源到源的 转换 , 并对结果 运行 C 编译器, 产生目标 代码。C++ 的对象 用结构来表示 , 类似千 C 的 s t r uc t 。C++ 的方法是 用指向实现方法的 代码的 指针来 表示的。相比而言 , J ava 的实现方式完全不同。Java 的目标代码是一种特殊的 二进制表示 , 称为 Java 宇节代码。这 种代码 可以 看成是虚拟机的机器级 程序。正 如它的名字暗示的那样,这种机器并不是直接用硬件实现的,而是用软件解释器处理字节代码,模拟虚拟机的 行为。另外 , 有一种称 为及 时编译 ( jus 臼 n-t ime compila tion) 的方法, 动态地将字节代码序列 翻译 成机器指令。当代码要执行多次时(例如在循环中),这种方法执行起来更快。用字节代码作为程序的低级表示, 优点是相同的代码可以 在许多 不同的 机器上执行 , 而在本章 谈到的机器代码只能 在 x86-64 机器上运行。\n参考文献说明\nIntel 和 AMD 提供 了关于他们处理器的 大量文档。包括从汇编语言程序员 角度来看硬件的概貌[ 2,\n50], 还包 括每条指 令的详 细参考 [ 3 , 51] 。读指令 描述很 复杂 , 因为 1) 所有的 文档都 基于 Inte l 汇编代码格式 , 2 ) 由于不同的寻址和执行模 式, 每条指 令都 有多个变种, 3 ) 没有说明性示例。不 过这些文档仍然是关于每条指令行为的权威参考。\n组织 x86-64. org 负责定义运行 在 Linux 系统 上的 x86-64 代码的应用二进制接口( A pp licatioin Binary Interface, ABI) [ 77] 。这个 接口描述 了一些细节 , 包括过程 链接、二 进制代码文件和大最的为了让机器代码程序正确运行所需要的其他特性。\n正如我 们讨论过的那 样, GCC 使用的 AT T 格式与 Intel 文档中使用的 Intel 格式和其他编译 器(包括\nMicrosoft 编译器)使用的格式 都很不相同。\nMuchn ick 的关于编译器设 计的书[ 80] 被认 为是 关千代 码优化技术最 全面的 参考书。 它涵盖了许多我们在此讨论过的技术,例如寄存器使用规则。\n已经有很多 文章 是关于使用缓 冲区溢出通过因特网来 攻击系统 的。Spafford 出版了关于 1988 年因特网蠕虫的详细分析[ 105] , 而帮助阻 止它传播的 MIT 团队的成员也出版了一些论著[ 35] 。从那以后,大噩的论文和项目提出了各 种创建和阻 止缓 冲区溢出攻 击的方 法。Seacord 的书[ 97] 提供 了关于缓 冲区 溢出和其他一些 对 C 编译器产生的 代码 进行攻 击的 丰富信息 。\n家庭作业\n3. 58 一个函数的原型为 long decode2(long x, long y, long z);\nGCC 产生如下 汇编代 码:\n1 decode2 :\nsubq imulq movq salq sarq xorq\n8 ret\n%rdx, %rsi\n%rsi, %rdi\n%rsi, %rax\n$63, %rax\n$63, %rax\n%rdi, %rax\n•• 3. 59\n参数 x 、 y 和 z 通过寄 存器%r d i 冷r s i 和% r d x 传递。代码 将返回值 存放在 寄存器 % r a x 中 。写出等价 于上述 汇编代 码的 de c ode 2 的 C 代码 。\n下面的代码计算 两个 64 位有 符号 值 工 和 y 的 128 位乘积, 并将结果存储在内存中:\ntypedef int128 int128_t;\nvoid store_prod(int128_t *dest, int64_t x, int64_t y) {\n*dest = x * (int128_t) y;\n}\nGCC 产出下面的 汇编代 码来实现计算 :\ns t or e _pr od :\nmovq %rdx, %rax cqto\nmovq sarq imulq imulq addq mulq addq movq movq ret\n%rsi, %rcx\n$63, %rcx\n%rax, %rcx\n%rsi, %rdx\n%rdx, %rcx\n%rsi\n%rcx, %rdx\n%r ax , (%rdi)\n%rdx, 8(%rdi)\n•• 3. 60\n为了 满足 在 64 位机器上实现 128 位运 算所需的多精度计算, 这段代 码用了三个乘法。描 述用来计算乘积的 算法, 对汇编代 码加注释, 说明它是如何 实现你的 算法的。提示: 在把参数 x 和 y\n扩展到 1 28 位时 , 它们可以 重写为 x = 264 • x, +x, 和 y = zs• • y, + y ,\u0026rsquo; 这里 x, \u0026rsquo; x ,\u0026rsquo; y, 和 y , 都是\n64 位值。类似地 , 1 28 位的乘 积可以写成 p = 26 1·. p , + p, , 这里 p , 和 p , 是 64 位值。 请解 释这 段代码是 如何 用 x, \u0026rsquo; x ,\u0026rsquo; y, 和 y , 来计算 p , 和 p , 的。\n考虑下面的 汇编代码 :\nlong loop(long x, int n) x in¾rdi, n i n ¾es1\nl oop :\nmovl movl movl jmp\n. L3 :\nmovq andq orq salq\n.L2 :\n%esi, %ecx\n$1, %edx\n$0 , %eax\n. L2\no/.rdi, 壮 8 o/.rdx, o/.r8 o/.r8, o/.rax\n%cl, o/.rdx\ntestq %rdx, %rdx jne . L3\nrep; ret\n以上代 码是编译以 下整体形式的 C 代码产生的 :\nlong loop(long x, int n)\n{\nlong result= long mask;\nfor (mask = ; mask result I=·- \u0026ndash; - '\n; mask =) {\n}\nreturn result;\n}\n•• 3. 61\n•• 3. 62\n你的任务是 填写这个 C 代码中缺失的部分, 得到一个程序 等价 千产生的 汇编代码 。回想一下, 这个 函数的结 果是在寄存 器%r a x 中返回的 。你会发现以下 工作很有帮助: 检查循环之前 、之中和之后的 汇编 代码 , 形成一个寄存器 和程序变最 之间一致的映射 。\n哪个寄存器保存 着程序值 x、n 、r e s u l t 和 ma s k?\nr e s u l t 和 ma s k 的初始值是 什么?\nma s k 的测试条件是什么?\nma s k 是如何被修改的?\nr e s ul t 是如何被修改的\n填写这段 C 代码中所有 缺失的部分。\n在 3. 6. 6 节, 我们 查看了下面的 代码, 作为使用 条件数据传送的一 种选择 :\nlong cread(long•xp) { return (xp? *xp: 0);\n我们给出了使用 条件传送指令的一个尝试实现, 但是 认为它是不合法的, 因为它 试图从一个空地址读数据。\n写一个 C 函数 c r e a d_a l 七, 它与 c r e a d 有一样的 行为,除 了 它可以 被编译成使用条件数 据传送。当编译时,产生的代码应该使用条件传送指令而不是某种跳转指令。\n下面的代码给出了一个 开关语 句中根 据枚 举类型值进行分支选择的 例子。回忆 一下 , C 语言中枚举类型只是一种引人一组与整数值 相对应的 名字的方法。默认情况下,值 是从 0 向上依次赋给名字的。在我们 的代码中 , 省略了与各种情况标 号相对应的动作 。\n/• Enumerated type creates set of constants numbered O and upward•/ typedef enum {MODE_A, MODE_B, MODE_C, MODE_D, MODE 王 } mode_t;\nlong switch3(long *p1, long *p2, mode_t action)\n{\nlong result= O; switch(action) { case MODE_A:\ncase MODE_B: case MODE_C: case MODE_D: case MODE_E: default:\n}\nreturn result;\n产生的 实现各个动 作的汇编代 码部分如图 3-52 所示。注释指明 了参数位置, 寄存器值, 以及各个跳转目的的情况标号。\npl i n 肛 di , p2 i n r幻 si , action in¾edx\n. L 8 : MODE_E\nmovl $27, %eax ret\n. L3 :\nmovq movq movq ret\n.L5:\nmovq\naddq movq ret\n.L6:\nmovq movq ret\n.L7:\nmovq movq movl ret\n.L9:\nmovl ret\n(%rsi), %rax (%rdi), %rdx 沿·dx , (壮 s i )\n(¾rdi), ¾rax (¾r s 立 , ¾r ax\n¾rax, (¾rdi)\n$59, (¾rdi) (¾rsi), ¾rax\n(\u0026lsquo;Y.rsi),\u0026lsquo;Y.rax\n\u0026lsquo;Y.rax, ( 壮 di )\n$27,\u0026lsquo;Y.eax\n$12, %eax\nMODE_A\nMODE_B\nMODE_C\nMDDE_D\ndefault\n图 3- 52 家庭作业 3. 62 的汇编代码。这 段代码 实现了 s wi t c h 语句的各个分 支\n\u0026ldquo;3. 63\n填写 C 代码中缺失的部分。代码包括落人 其他情况的 情况, 试着重建 这个情况。\n这个程 序给你一个 机会, 从反汇编机 器代 码逆向 工程一个 s wi t c h 语句。在下面这 个过程中 , 去掉了 s wi t c h 语句的主体 :\nlong switch_prob(long x, long n) { long result= x;\nswitch(n) {\n/• Fill in code here•/\n}\nreturn result;\n图 3-53 给出了这 个过程的 反汇编机 器代 码。\n跳转表驻 留在内 存的不同区域中。可以从 第 5 行的间接跳 转看出来, 跳转表的 起始地址 为 Ox 4006f 8。用调试器 G DB , 我们可以 用命 令 x / 6g x Ox 4006f 8 来检查组 成跳转表的 6 个 8 字节字的内存。G DB 打印出下 面的内 容:\n(gdb) x/6gx Ox4006f8\nOx4006f 8 : Ox00000000004005a1 Ox400708: Ox00000000004005a1 Ox400718: Ox00000000004005b2\nOx00000000004005c3 Ox00000000004005aa Ox00000000004005bf\n用 C 代码填写开关语句的 主体 , 使它的行 为与机器代 码一致。\nlong s wit c h _pr ob(l ong x , long n) x in r7. di , n i n 肛 si\n0000000000400590 \u0026lt;switch_prob\u0026gt;:\n400590: 48 83 ee 3c\n400594: 48 83 fe 05\n400598: 77 29\n40059a: ff 24 f5 f8 06 40 00\n4005a1: 48 8d 04 fd 00 00 00\n4005a8: 00\n4005a9: c3\n4005aa: 48 89 f8\n4005ad: 48 c1 f8 03\n4005b1: c3\n4005b2: 48 89 f8\n4005b5: 48 c1 eO 04\n4005b9: 48 29 f8\n4005bc: 48 89 c7\n4005bf : 48 Of af ff\n4005c3: 48 8d 47 4b\n4005c7: c3\nsub cmp ja jmpq lea\nretq mov sar retq mov shl sub mov imul lea retq\n$0x3c,%rsi\n$0x5,%rsi\n4005c3 \u0026lt;switch_prob+Ox33\u0026gt;\n*Ox4006f8(,%rsi,8) Ox0(,%rdi,8),%rax\n%r d i , %r a x\n$0x3,%rax\n%rdi,%rax\n$0x4,%rax\n%rdi,%rax\n%rax,%rdi\n%rdi,%rdi Ox4b(%rdi),%rax\n•*• 3. 64\n图 3-53 家庭作业 3. 63 的反汇编代 码\n考虑下面的 源代 码, 这里 R 、 S 和 T 都是用 #d e f i ne 声明的常数 :\nlong A [R] [SJ [Tl ;\nlong store_ele(long i, l ong j, long k, long *dest)\n{\n*\u0026lt;lest = A [i] [j] [k];\nreturn sizeof(A);\n}\n3 65\n在编译这个 程序中 , G CC 产生下面的 汇编代 码:\nlongs t or e _el e ( l ong i , long j, long k, long *des t ) i in¾rdi, j in r 无 si , k i n 肛 dx, dest in¾rcx store_ele:\nleaq (%rsi,%rsi,2), %rax leaq (%rsi,%rax,4), %rax movq %rdi, %rsi\nsalq $6, %rsi\naddq %rsi, %rd1\naddq %rax, %rdi\naddq %rdi, %rdx\nmovq A(,%rdx,8), %rax movq %rax, (%r cx )\nmovl $3640, i 儿 eax\nret\n将等式 ( 3. 1 )从二维扩展 到三维 , 提供数组 元素 A [ i ] [ j ] [ k l 的位置的公 式。\n运用 你的逆向 工程技术 , 根据汇编代码 , 确定 R 、S 和 T 的值。\n下面的代 码转置一个 M X M 矩阵的元素 , 这里 M 是一个用 #d e f i ne 定义的常数 :\nvoid transpose(long A[M] [M]) { long i, j;\nfor (i = 0; i \u0026lt; M; i ++)\nfor (j = 0; j \u0026lt; i ; j ++ ) { long t = A[i][j]; A[i][j] = A[j][i];\nA [j] [i] = t;\n}\n}\n当用优化等级 - 0 1 编译时, GCC 为这 个函数的内 循环产生下 面的 代码 :\n. L6 :\nmovq movq movq movq addq addq cmpq jne\n(%rdx), %rcx (%rax), %rsi\n%rsi, (%rdx)\n%rcx, (%rax)\n$8, %rdx\n$120, %rax\n%rdi, %rax\n. L6\n3. 66\n我们可以 看到 GCC 把数组索 引转换 成了指 针代 码。\n哪个寄存器保 存着指向 数组元素 A [ i ] [ j ]的指针?\n哪个寄 存器保 存着指向 数组元素 A [ j J [ i ] 的指针?\nM 的值是多少?\n考虑下 面的 源代 码, 这里 NR 和 NC 是用 #d e f i ne 声明的宏表达式 , 计算用参数 n 表示 的矩阵 A 的维度。这段代码计算矩阵的第)列的元素之和。\nlong sum_col(long n, long A[NR(n)] [NC(n)], long j) { long i;\nlong result= O;\nfor (i = O; i \u0026lt; NR(n); i++) result += A [i] [j] ;\nreturn result;\n}\n编译这个程 序, GCC 产生下 面的 汇编代码 : long sum_col(long n , long A[NR( n)] [ NC(n)] , l ong j) n i n 肛 di , A 江 Zrs i , j in Zrdx\nsum_col:\nleaq leaq movq testq jle salq leaq movl movl\n.L3:\n1 (, o/.rdi, 4) , o/.r8\n(o/.rdi,o/.rdi,2), o/.rax o/.rax, o/.rdi\no/.rax, o/.rax\n.L4\n$3, o/.r8 (o/.rsi,o/.rdx,8), o/.rcx\n$0, o/.eax\n$0, o/.edx\naddq (%rcx), %rax\naddq $1, %rdx\naddq 缸8 , %rcx\ncmpq %rdi, %rdx\njne .L3\nrep; ret\n.L4:\nmovl ret\n$0, %eax\n\u0026quot; 3. 67\n运用 你的逆向 工程技术 , 确定 NR 和 NC 的定义。\n这个作业要查看 GCC 为参数和返回 值中有结 构的 函数产生的 代码 , 由此可以 看到这 些语言特性通常是如何实现的。\n下面的 C 代码中有 一个函数 pr o c e s s , 它用结 构作为参数 和返 回值, 还有 一个函数 e v a l , 它调用 p r o c e s s :\ntypedef struct { long a[2];\nlong•p;\n} strA;\ntypedef struct { long u[2]; long q;\n} strB;\nstrB process(strA s) { strB r;\nr.u[O) = s . a [1) ;\nr.u[1) = s . a [O) ;\nr.q =•s.p; return r;\n}\nlong eval (long x, long y, long z) { strA s;\ns.a[O] = x;\ns.a[l] = y;\ns.p = \u0026amp;z;\nstrBr = process(s);\nreturn r.u[O] + r.u[l] + r.q;\n}\nGCC 为这 两个函数产生下 面的 代码 :\nstrB process (strA s) process:\nmovq\nmovq movq movq movq movq movq movq ret\n¾rdi, ¾rax 24(¾rsp), ¾rdx (¾rdx), ¾rdx 16(¾rsp), ¾rcx\n¾rcx, (¾rdi) 8(¾rsp), ¾rcx\n¾rcx, 8(¾rdi)\n¾rdx, 16(¾rdi)\nlong eval(long x, long y , long z)\n1 x in r% d,i eval: y in %rsi, z in %rdx 2 3 subq movq $104, 7,rsp 7.rdx, 24(7,rsp) 4 leaq 24(7.rsp), 7.rax 5 movq 7.rdi, (7.rsp) 6 movq 7.rsi, 8(7.rsp) 7 movq 7.rax, 16(7.rsp) 8 leaq 64(7.rsp), 7.rdi 9 call process 10 movq 72(7.rsp), 7,rax 11 addq 64(7.rsp), 7.rax 12 addq 80(7.rsp), 7.rax 13 addq $104, 7.rsp 14 ret 从 e va l 函数的第 2 行我们 可以 看到, 它在栈上分 配了 104 个字节 。画出 e va l 的栈帧 ,给出它在调用 pr oc e s s 前存储在栈上的值。 e va l 调 用 pr oc e s s 时传递了什么值? p r o c e s s 的代码是 如何访间结 构参数 s 的元素的? pr oc e s s 的代码是如何设 置结 果结构r 的字段的? ·: 3. 68\n完成 e va l 的栈帧图 ,给出 在从 pr oc e s s 返回后 e va l 是如何访问 结构 r 的元素的 。 就如何传递作为函数参数的结构以及如何返回作为函数结果的结构值,你可以看出什么通用的 原则?\n在下 面的代码中 , A 和 B 是用ii de f i ne 定义的常数 :\ntypedef struct {\nint x[A] [BJ; /• Unknown constants A and B•/ long y ;\n} strl;\ntypedef struct { char array[B]; int t;\nshort s [A]; long u;\n} str2;\nvoid setVal(strl *P, str2 *q) { long vl = q-\u0026gt;t;\nlong v2 = q-\u0026gt;u; p-\u0026gt;y = vl+v2;\n}\nGCC 为 s e t Va l 产生下 面的代 码:\nvoid set Val ( srt 1 *P, s tr 2 • q)\np i n 肛 di , q 江\nset Val :\nr¼ si\nmovslq addq\nmovq ret\n8(%rsi), %rax 32(%rsi), %rax\n¼rax, 184(%r d立\n·: 3. 69\nA 和 B 的值是多少?(答案是唯一的。)\n你负责维 护一个大型的 C 程序, 遇到下面的代 码:\ntypedef struct {\n2 int first;\n3 a_struct a[CNT];\n4 int last;\n5 } b_struct;\n6\nvoid test(long i, b_struct *bp)\n8 {\n9 int n = bp-\u0026gt;first + bp-\u0026gt;last;\n10 a_struct *ap = \u0026amp;bp-\u0026gt;a[i];\n11 ap-\u0026gt;x[ap-\u0026gt;idx] = n; 12\n编译时常数 CN T 和结构 a _ s tr uc t 的声明是在一 个你没有访问权限的文件中。幸好, 你有代\n码的 .o\u0026rsquo; 版本 , 可以 用 OBJDUMP 程序来 反汇编这些 文件 , 得到下面的 反汇编代码 :\nvoid test (long i, bs_ tr uct • bp) i in 7.rdi, bp in 7.rsi\n0000000000000000 \u0026lt;test\u0026gt;:\n0: Sb Se 20 01 00 00\n6: 03 Oe\nS : 4S Sd 04 bf\nc: 4S Sd 04 c6\n10: 4S Sb 50 OS\n14: 4S 63 c9\nmov Ox120(¾rsi),¾ecx add (¾rsi),¾ecx\nlea (¾rdi,¾rdi,4),¾rax lea (¾rsi,¾rax,8),¾rax mov Ox8(¾rax),¾rdx movslq¾ecx,¾rcx\n17:\nle:\n48 89 4c dO 10\nc3\nmov retq\n%rcx,Ox10(%rax,%rdx,8)\n*** 3. 70\n运用你的逆向工程技术,推断出下列内容:\nCNT 的值。\n结构 a s tr uc t 的完整声 明。假设 这个结构中只有字段 i d x 和 x , 并且这两个字段保存的都是有符号值。\n考虑下面的联合声明:\nunion ele {\nstruct {\nlong *p; long y;\n} el; struct {\nlong x;\nunion ele *next;\n} e2;\n};\n这个声明说明联合中可以 嵌套结 构。\n下面的 函数(省略了一些表达式)对一个链表进行 操作 , 链表是以 上述联 合作 为元素的 :\nvoid proc (union ele *up) {\nup- \u0026gt; - = * C-\n}\n— ) -;\n下列字段的偏移址是多少(以字节为单位):\ne1.p e1.y e2.x e2.next\n这个结构总共需要多少个字节?\n编译器为 pr oc 产生下 面的 汇编代 码:\nvoid proc (union el e • up ) up in¾rdi\nproc:\nmovq movq movq subq movq ret\n8(%rdi), %rax (%rax), %rdx (%rdx), %rdx 8(%rax), %rdx\n%rdx, (%r d 立\n3. 71 •• 3. 72\n在这些 信息的基础上 , 填写 p r oc 代码中 缺失的 表达式。提示: 有些联合引用的解 释可以 有歧义 。当你 清楚引用指引到哪里的 时候, 就能够澄清 这些歧义。只有一个答案, 不需 要进行强制类型转换, 且不违反 任何类 型限 制。\n写一个函数 g ood _e c ho , 它从标准输人读取一行,再把它写到标准输出。你的实现应该对任意长度的 输入行都能工作。可以 使用库 函数 f ge ts , 但是你必须确保即使当输入行要求比你已经为缓冲区分配的更多的空间时,你的函数也能正确地工作。你的代码还应该检查错误条件,要在遇到 错误条件时返 回。参 考标 准 I/ 0 函数的定 义文 档[ 45 , 61] 。\n图 3-54a 给出了一 个函数的代 码, 该函数类 似于函 数 v f u nc t ( 图 3- 43a ) 。我们用 v f unc t 来说明过帧指针在管 理变长栈帧中的 使用情况 。这里的新 函数 a fr a me 调用库函数 a l l oc a 为局 部数组 p 分配空间 。a l l o c a 类似于更常用的 函数 ma l l oc , 区别在于它在运行 时栈上分 配空间。当正在执行的过程返回时 ,该 空间 会自动释放 。\n图 3-54 b 给出了部 分的汇编代码, 建立帧指针, 为局部变量 1 和 p 分 配空间。非常类似于\n第 3 章 程序的机器级表示 225\nv fr a me 对应的 代码。在此使用与练习题 3. 49 中同样的表示 法: 栈指针在第 4 行设置为值 S1 , 在 第 7 行设置为值 切。 数组 p 的起始地址 在第 9 行被设置为值 p。Sz 和 p 之间可能 有额外的 空间 e,\u0026rsquo;\n数组 p 结尾和 S1之间可能 有额外的空间 e, .\n用数学语言解 释计算 S2 的逻辑 。\n用数学语言 解释计算 p 的逻辑 。\n确定使 e1 的值最小 和最大的 n 和 s , 的值。\n这段代 码为 Sz 和 p 的值保证了怎 样的对齐属性?\n#include \u0026lt;alloca.h\u0026gt;\nlong aframe(long n, long idx, long *q) long i;\nlong **P = alloca(n * sizeof(long *)); p[O] = \u0026amp;i;\nfor (i = 1 ; i \u0026lt; n; i ++)\np[i] = q; return *p[idx];\nC代码 l ong 红 r ame (l ong n, long i dx, l ong • q)\nn 江 肛 di , i dx in i.rsi , q 耳 1 ri. dx aframe:\n图 3 - 54\nb ) 部分生成的汇编代码\n家庭作业 3. 72 的代码。该函数类似于图 3- 43 中的函数\n3. 73 •• 3. 74\n3. 75 用汇编代码 写出匹配图 3-51 中函数 f i nd_r a nge 行为的 函数。你的代码必须只包含一个浮点 比较指令,并用条件分支指令来生成正确的结果。在产种可能的参数值上测试你的代码。网络旁注 ASM : EASM 描述了如何在 C 程序中嵌 入汇编代 码。\n用汇编代码写出匹配图 3-51 中函数 f i nd_r a nge 行为的 函数。你的代码必须只包含一个浮点 比较指令 , 并用条件传 送指令 来生成 正确的结果。你可能 会想要 使用指令 c movp ( 如果设置了 偶校验位传送)。在沪 种可能的 参数值上测试你的代 码。网 络旁 注 ASM : EASM 描述了如何在 C 程序中嵌入汇编代码。\nISO C99 包括了支持 复数的 扩展 。任何 浮点 类型都可以 用关键字 c o mp l e x 修饰。这里有一些使用复数数据的示例函数,调用了一些关联的库函数:\n#include \u0026lt;complex.h\u0026gt;\ndouble c_imag(double complex x) { return cimag(x);\n}\ndouble c_real(double complex x) { return creal(x);\n9 } 10 11 double complex c_sub(double complex x, double complex y) { 12 return x - y; 13 } 编译时, G CC 为这些 函数产生如下 代码:\ndouble c_imag(double compl e x x)\nc_imag: movapd %xmm1, %xmm0 ret double c_real (doubl e complex x)\nc_real: rep; ret double complex c_sub(double complex x, double complex y)\nc_sub:\nsubsd %xmm2, %xmm0\nsubsd %xmm3, %xmml\nret\n根据这些例子,回答下列问题:\n如何向函数传递 复数 参数? 如何从函数返回复数值? 练习题答案\n1 这个练习使你熟悉各种操作数格式。 操作数 值 注释 %r a x Ox l OO 寄存器 Oxl 04 OXAB 绝对地址 $0x l 08 Ox l 08 立即数 (%r a x ) OXFF 地址 Ox l OO 4 ( %r a x ) OXAB 地址 Ox l 04 9 ( %r a x, % r dx ) Ox ll 地址 Ox l OC 260 ($r c x, % r d x ) Oxl3 地址 Ox l 08 OXFC (, %r c x , 4) OxFF 地址 Ox l OO ( %r a x , %r dx, 4) Oxll 地址 Oxl OC 3 2 正如我们已 经看到的 , G CC 产生的 汇编代码指令上有后缀, 而反 汇编代码没有。能 够在这两种形式之间转换是 一种很 重要的 需要学习的技能。一个重要的特性就是, x 8 6 - 6 4 中的内存引 用总是用四字长寄存器给出 , 例如釭a x , 哪怕操作数只是 一个字节 、一个字或是 一个双字。\n这里是带后缀的代码:\nmovl %eax, (%rsp) mov11 (%rax), %dx movb $0xFF, %bl\nmovb (%rsp,%rdx,4), %dl movq (%rdx), %rax\nmo 四 %dx , (%rax)\n3. 3 由于我们会依 赖 GCC 来产生大多 数汇编代码 , 所以能够写正确的汇编 代码并不是一项很关键的技能。但是, 这个练习会帮助你熟 悉不同的 指令和操作 数类型。\n下面给出了有错误解释的代码:\nmovb $0xF, (%ebx) Cannot use Y.ebx as address register\nmovl %rax, (%rsp) Mismatch be 切 een ins tr uct io n s 立 f i x 却 d register ID\nmovw (%rax),4(%rsp) Cannot have both source and destination be memory references movb %al, %s1 No register named Y.sl\nmovq %rax, $0x123 Cannot have 1·mmed1·ate as destination\nmovl %eax,%rdx Destination ope 丘 u,d incorrect size\nmovb %s i, 8 (%rbp) Mismatch between instruction s吐丘x 迎 dr egis t er ID\n3.4 这个练习 给你更多 经验,关 于不同的数据传送指令, 以及它们与 C 语言的数据类 型和转换规则的关系。\nsrc t dest t 指令 注释 long long movq ( %r d 习,%r a x rnovq % r a x, ( % r s i ) 读 8 个字节 存 8 个字节 char int novsbl(%rdi),%eax movl %e a x, ( % r s i ) 将 c ha r 转换成 i nt 存 4 个字节 char unsigned rnovs bl ( %r d习,%e a x movl %e a x , 伐 r s i ) 将 c ha r 转换成 i nt 存 4 个字节 unsigned char long movzbl(%rdi),%eax movq %r a x , (%rsi) 读一个字节并零扩展 存 8 个字节 int char movl (%rdi) , %e a x movb %a l , (%rsi) 读 4 个字节 存低位字节 unsigned unsigned char rnov l ( %r d 习,%e a x movb %a l , ( %r s 习 读 4 个字节 存低位字节 char short movs bw ( %r d i ) , %a x movw %a x , (%rsi) 读一个字节并符号扩展 存 2 个字节 5 逆向工程是一 种理解 系统的好方法。在此, 我们想要逆转 C 编译器的效果, 来确定什么样的 C 代码会得到这样的 汇编代码 。最好的方法是 进行 “模拟", 从值 x 、y 和 z 开始, 它们分别在指 针 x p 、\nyp 和 z p 指定的位置。于是 , 我们可以 得到下面这样的效 果:\nvoi d decode1 (l ong •xp , long *YP, l ong 五 p) xp in 7.rdi, yp in 7.rsi, zp in 7.rdx\ndecodel:\nmovq movq (o/.rdi), o/.r8 (%rsi), %rcx Get x =•xp Get y = • yp movq (\u0026lsquo;Y.rdx),\u0026lsquo;Y.rax Getz=•zp movq %r8, (%rsi) Store x at yp movq %rcx, (%rdx) Store y at zp movq ret %rax, (%rdi) Store z at xp 由此可以产生下 面这样 的 C 代码:\nvoid decode1(long *xp, long *YP, long *zp)\n{\nlong x = *xp; long y = *yp; long z = *zp;\n*YP = x;\n•zp = y;\n•xp = z;\n3 6 这个练习说明 了 l e a q 指令的 多样性,同 时也让你更多地练习解读各种操作数形式。虽然在图 3-3\n中有的操作数格式被划分为“内存”类型,但是并没有访存发生。\n指令 结果 leaq 6 ( 毛r a x ) , %r dx 6+.r l eaq ( 令 r a x , %r c x ) , %r dx 工 + y leaq(%rax,%rcx,4),%rdx 工+ 4y leaq 7 (%rax, % r a x , 8), %rdx 7+ 9 工 leaq OxA (, % r c x , 4), % r dx 10+4y l eaq 9(%rax, %r c x , 2 ) , %rdx 9+.r+Zy 3 7 逆向 工程再 次被证明是学 习 C 代码和生成的 汇编代 码之间 关系的 有用 方式。\n解决此类型问题的最好方式是为汇编代码行加注释,说明正在执行的操作信息。下面是一个 例子:\nlong scale2(long x, long y, long z) x in i.rdi, y in½rsi, z in i.rdx\nscale2:\nleaq (%rdi, %rdi, 4) , %rax 5• x leaq (%rax,%rsi,2), %rax 5• x + 2• y leaq (%rax,%rdx,8), %rax 5• x + 2• y + 8• z ret 由此很容易得到缺失的表达式:\nlong t = 5 * x + 2 * y + 8 * z;\n3. 8 这个练习使你有机会检验对操作数和算术指令的理解。指令序列被设计成每条指令的结果都不会影响后续指令的行为。\n指令 目的 值 addq %rcx, (毛r a x ) OxlOO OxlOO subq %rdx, 8 ( % r a x ) Oxl08 OxAB 耳 nul q $16, ( % r a x, % r dx, 8) OxllB OxllO incq 16( 令r a x ) OxllO Oxl4 decq %rcx %rcx OxO subq %rdx, % r a x %rax OXFD 3. 9 这个练习使你有机 会生成一点汇编代码。 答案的代码由 GCC 生成。将参数 n 加载到寄存器%e c x\n中, 它可以 用字节 寄存 器%c l 来指定 s ar l 指令的移位量。使用 mo v l 指令看上去有点 儿奇怪, 因为\nn 的长度是 8 字节, 但是要 记住只有最低位的 那个字节 才指 示着移 位量。\nlong shitt_left4_rightn(long x, long n)\nX 立 7.r di , n in 7.rsi shift_left4_rightn:\nmovq %rdi, %rax Get x salq $4, %rax x«= 4 movl %esi, %ecx Get n (4 bytes) sarq %cl, %rax x»= n 3. 10 这个练习 比较简单 , 因为汇编代 码基本上 沿用 了 C 代码的结构。\nlong tl = x I y; long t2 = tl»3; long t3 = -t2; long t4 = z-t3;\n3. 11\n3. 12\n3 13\n3. 14\n3 15\n这个指令用来将寄存器 %r dx 设置为 o, 运用了对任意 x , 工^工=O 这一属性。它对应于 C 语句 x= O。\n将寄存 器%r dx 设 置为 0 的更直接的 方法是用 指令 mov q $0, %r dx 。\n不过, 汇编 和反 汇编这段 代码 , 我们 发现使用 xor q 的版本 只需要 3 个字节, 而使用 movq 的版本需要 7 个字节。其 他将 %r d x 设 置为 0 的方法都依 赖于这样一个 属性 , 即任何 更新低位 4 字节的指令都 会把 高位字 节设 置为 0 。因此, 我 们 可以使用 xo r l % edx, % edx ( 2 字节)或 mov l\n$0,% e d x( 5 字节)。\n我们可以 简单地把 c q t o 指令替换为将寄 存器 %r d x 设置为 0 的指令 , 并且用 d i vq 而不是 迈i v q 作为我们的除法指令,得到下面的代码:\nvoid ru em 中 v( uns i gned long x, unsigned long y, unsigned long *qp, unsigned long•rp)\nx rn i.rdi , y in i.rsi , qp i n 肛 dx , rp in i.rcx uremdiv:\n汇编代码不会记录程序值的类型,理解这点这很重要。相反地,不同的指令确定操作数的大小以 及是有符号的 还是无 符号的。当从指令序列映 射回 C 代码时 , 我们必 须做一点儿侦查 工作, 推断程序值的数据类型。\n后缀 \u0026rsquo; l \u0026rsquo; 和寄 存器指示符 表明 是 32 位操 作数 , 而比 较是对补码的< 。 我们可以 推断 da t a _ t -\n定是 i n t 。\n后缀`矿和寄存 器指示符 表明是 16 位操 作数, 而比较是对补码的 >= 。 我们可以 推断 da t a _ 七一定是 s ho r t 。\n后缀'矿和寄存器指示符表明是 8 位操作数 , 而比较是对无 符号数的 <= 。 我们可以 推断 da t a _ t\n一定是 uns i g ne d c har 。\n_o. 后缀'矿和寄存器指示符 表明是 64 位操作数, 而比较是!= , 有符 号、无符号和指 针参数都是一样的。我们可以推断 da t a _ t 可以 是 l o ng 、uns i g ne d l ong 或者某 种形式的 指针。\n这道题 与练习题 3. 13 类似, 不同的是它使用了 T EST 指令而不是 CMP 指令。\n后缀'矿和寄存器指 示符 表明是 64 位操 作数, 而比较是>= , 一定是有符号数。我们可以 推断\nda t a _ 七一定是 l o ng 。\n后缀'矿和寄存器指 示符 表明 是 16 位操作数, 而比较是==, 这个对有符号和无 符号都是一样的。我们可以 推断 da t a _ 七一定是 s ho r t 或者 u ns i g ne d s hor t 。\n后缀`矿和寄存器指示符表明是 8 位操作数 , 而比较是 针对无 符号数的>。 我们 可以 推断 da t a _ t\n一定是 uns i g ne d c har 。\n后缀\u0026rsquo; l \u0026rsquo; 和寄存器指示符 表明是 32 位操作数 , 而比较是<= 。 我们可以 推断 da t a_t 一定是 m七。这个练习要求你仔细检查反汇编代码,并推理跳转目标的编码。同时练习十六进制运算。\nj e 指令的目标为 Ox 4003f c + Ox 02。如原始的反汇编代码所示 , 这就是 Ox 4003f e 。\n4003fa: 74 02\n4003fc: ff dO\nJe callq\n4003fe\n*%rax\nj b 指令的目标是 Ox 400431 - 1 2 ( 由于 Ox f 4 是— 1 2 的一个字节的 补码表示)。正如原 始的 反 汇编代码所示 , 这就是 Ox 400425: 40042f: 74 f4\n400431: 5d\nje pop\n400425\n%rbp\n根据反 汇编器 产生的注释, 跳转目标是绝对地址 Ox 400547 。根据字节编码, 一定在距离 po p 指令 Ox 2 的地址处 。减去这个值就得到地址 Ox 400545 。 注意, j a 指令的编码需要 2 个字节, 它一定 位于地址 Ox 4005 43 处。检查原始的反汇编代码 也证实了这一点 :\n400543: 77 02\n400545: 5d\nja 400547\npop %rbp\n以相反的顺 序来 读这些 字 节, 我 们看 到 目 标 偏 移 量 是 Ox f f f f f f7 3 , 或者 十 进 制数 一 141 。\nOx 4005e d ( no p 指令的 地址)加上这个 值得到地址 Ox 4 00560 :\n4005e8 : e9 73 ff ff ff\n4005ed : 90\njmpq 400560 nop\n3 16 对汇编 代码写 注释, 并且模仿 它的控 制流来 编写 C 代码, 是理解汇编语 言程序很好的第一步。本题是一个具有简单 控制流的示 例, 给你一个 检查 逻辑操作实 现的机会。\n这里是 C 代码:\nvoid goto_cond(long a, long•p) {\nif (p == 0)\ngoto done;\nif (*p \u0026gt;= a)\ngoto done;\n*P = a;\ndone : return;\n第一个条件分支是 && 表达式 实现的一部分。如果 对 p 为非 空的测试失败, 代码会跳 过对 a \u0026gt;*p\n的测试。\n17 这个练习帮助你思考一个通用的翻译规则的思想以及如何应用它。\n转换成这种替代的形式,只需要调换一下几行代码:\nlong gotodiff_se_alt(long x, long y) { long result;\nif (x \u0026lt; y)\ngoto x_l t _y ; ge_cnt++; result= x - y ; return result;\nx_lt_y:\nlt_cnt++; result= y - x; return result;\n在大多 数情况下 , 可以 在这 两种方式中 任意选择。但是原来的方法对常见的没有 e l s e 语句的情况更好 一些。对于这种情况, 我们只用 简单地将 翻译规则修改 如下 :\nt = test-expr;\nif (!t)\ngoto done; then-statement\ndone :\n基于这种替代规则的翻译更麻烦一些。\n3. 18 这个题目要求你完成一个嵌 套的分 支结构, 在此 你会看到 如何使用翻译 if 语句的规则。大部 分情况下 , 机器代码就是 C 代码的 直接翻译 。\nlong test (long x, long y, long z) { long val= x+y+z;\nif (x \u0026lt; -3) {\nif (y \u0026lt; z)\nval= x•y;\nelse\nval= y•z;\n} else if (x \u0026gt; 2) val= x•z;\nreturn val;\n}\n19 这道题巩固加强了我们计算预测错误处罚的方法。 可以 直接应用公 式得到 T),le =2 X (31-16) = 30。 当预测错误时 , 函数会需 要大 概 1 6 + 30 = 46 个周期 。 20 这道题提供了研究条件传送使用的机会。 运算 符是'/'。可以 看到这 是一个通过右移 实现除以 2 的 3 次幕的例子(见2. 3. 7 节)。在移位\nk = 3之前 , 如果被除数 是负数的话 ,必 须加上偏移 扯 2\u0026rsquo; - 1 = 7。\n下面是该汇编代码加上注释的一个版本:\nlong arith(long x) x in 7.rdi\narith:\nleaq 7(%rdi) , %rax temp = x+7 testq %rdi, %rdi Test x cmovns %rdi, %rax If x\u0026gt;= O, temp = x sarq $3, %rax result = temp»3 (= x/ 8) ret 这个 程序创建一 个临 时值等于 工 + 7 , 预期 工 为负,需 要加偏 移量时使用。c mov ns 指令在当\n:r o 条件成 立时 把这个 值修改 为 :r , 然后再移动 3 位, 得到 可 8。\n3. 21 这个题目类 似于练 习题 3. 18 , 除了有些条件语句是用条件数据传送实现的。虽然将这段代码装进到原始的 C 代码中看起来 有些 令人惧怕, 但是你会 发现它 相当严格地 遵守了翻译 规则。\nlong test(long x, long y) { long val= 8•x;\nif (y \u0026gt; 0) {\nif (x \u0026lt; y)\nval= y-x;\nelse\nval= x\u0026amp;y;\n} else if (y \u0026lt;= -2) val= x+y;\nreturn val;\n}\n22 A. 如果构建一张使用 数据类型 i nt 来计算的 阶乘表, 得到下 面这 样的 表: n n! OK?\n1 I Y 2 2 Y 3 6 Y 4 24 Y 5 120 Y 6 720 Y 7 5 040 Y 8 40 320 Y 9 362 880 Y 10 3 628 800 Y 11 39916800 Y 12 479 001 600 Y 13 1 932 053 504 N 我们可以 看到 , 计算 13 ! 溢出了。正 如在练习题 2. 35 中学到的那样, 还 可以 通过计算\n.:r/n, 看它 是否 等于 ( n - 1) ! 来测试 n ! 的计算是 否溢出了(假设我们已经能够保证( n— 1 ) ! 的计算没有溢 出)。在此处 , 我们得到 1 932 053 504 / 13 = 161 004 458. 667 。另外有个测试方法,\n可以 看到 10 ! 以上的 阶乘数都 必须是 100 的倍数, 因此最 后两位数字必 然是 0。13 ! 的正确值应该是 6 227 020 800 。\nB. 用数据类 型 l o ng 来计算 , 直到 20 ! 才溢出, 得到 2 432 902 008 176 640 000 。\n3 23 编译循环产生的代码可能会很难分析,因为编译器对循环代码可以执行许多不同的优化,也因为可能 很难把程序变 量和寄存器 匹配起 来。 这个 特殊的例子展 示了几个汇编代码不仅仅是 C 代码直接翻译的地方。\n虽然参 数 x 通过寄存 器%r d i 传递给函 数, 可以 看到一旦进入循环就再也没有引 用过该寄存器了。 相反, 我们看 到第 2 ~ 5 行上寄 存器 %r a x 、%r c x 和%r d x 分别被初始化为 x、x*x 和 x+x 。因此可以推断,这些寄存器包含着程序变量。\n编译器认 为指 针 p 总是指向 X , 因此表达式 (*p ) ++就能够实现 x 加一。代码通过第 7 行的 l e aq\n指令 , 把这个 加一 和加 y 组合起 来。\n添加了注释的代码如下:\nlong d巳 l oop(l ong x) x initially in¼rdi\n1 dw_loop:\nmovq %rdi, %rax Copy x to i.rax movq %rdi, %rcx\n4 imulq %rdi, %rcx Compute y = x*x\n5 leaq (%rdi, %rdi) , %rdx Compute n = 2*x\n6 •12: loop\n7 leaq 1(%rcx,%rax), %rax Compute x += y + 1 8 subq $1, %rdx Decrement n 9 testq %rdx, %rdx Test n 10 jg .L2 If\u0026gt; 0, goto l oop 11 rep; ret Return 3. 24 这个汇编代码 是用跳转到中间 方法对循 环的 相当直接的 翻译。完整的 C 代码 如下 :\nlong loop_while(long a, long b)\nlong result= 1; while (a\u0026lt; b) {\nresult = result * (a+b); a = a+l;\nreturn result;\n3. 25 这个汇编代 码没有完 全遵 循 g ua rded-do 翻译的模式 , 可以 看到它 等价于下 面的 C 代码 :\nlong loop_while2(long a, long b)\nlong result= b; while (b \u0026gt; 0) {\nresult= result* a; b = b-a;\nreturn result;\n我们 会经常看 到这 样的情 况, 特别是用 较高优化 等级 编译 时, 此时 GCC 会自作 主张地 修改生成代码的格式,同时又保留所要求的功能。\n3. 26 能够从汇编代码 工作回 C 代码, 是逆向 工程的 一个主要例子。\n可以 看到这 段代码使用的 是跳转到中间 翻译方法, 在第 3 行使用了 j mp 指令。 下面是原 始的 C 代码:\nlong fun_a(unsigned long x) { long val; O;\nwhile (x) {\nval ; x;\nX \u0026gt;\u0026gt;1; ;\nreturn val\u0026amp;: Ox1;\n这个代码计算 参数 x 的奇偶 性。也就是, 如果 x 中有奇 数个 1\u0026rsquo; 就返回 1, 如果有偶 数个 1, 就返回 0。\n3. 27 这道练习题 意在加强 你对如何 实现循环的理 解。\nlong fact_for_gd_goto(long n)\nlong i; 2; long result ; 1; if (n \u0026lt;; 1)\ngoto done;\nl oop :\nresult*; i;\ni++;\n辽 ( i \u0026lt;; n)\ngoto loop;\ndone :\nreturn result;\n28 这个间 题比练习题 3. 26 要难一些, 因为循 环中的代 码更复杂 , 而整个 操作也不那么熟悉。 以下是原始的 C 代码:\nlong fun_b(unsigned long x) { long val; O;\nlong i;\nfor (i; 64; i !; O; i 一 ){\nval ; (val«1) I (x\u0026amp;: Ox1);\nX»; 1;\nreturn val;\n这段代码是 用 g uarded-do 变换生成的, 但是编译器发现因为 l. 初始 化成了 64 , 所以一定会满足测试 i# O, 因此初始的测试是没必要的。\nc. 这段代 码把 x 中的位反 过来, 创造一个镜像 。实现的 方法是 : 将 x 的位从 左往右移, 然后再填\n入这些 位, 就像是把 va l 从右往左 移。\n29 我们把 f or 循环翻译 成 wh il e 循环的规则有些过于简单 这是唯 一需要特殊考虑的 方面。\n使用我们的翻译规则会得到下面的代码:\nI* Naive translation of for loop into while loop *I I* WARNING: This is buggy code *I\nlong sum= O;\nlong i = O; while (i \u0026lt; 10) {\n辽 ( i \u0026amp; 1)\nI* Thi s 甘 i ll cause an infinite loop *I continue;\nsum += i; i++;\n}\n因为 c o n t i nue 语句会阻止索引变量 l. 被修改 ,所 以 这段代码是无限循环。\n通用的解决方法是用 g o t o 语句替 代 c o n t i nue 语句 ,它 会 跳 过循环体中余下的部分,直 接跳到\nup d a t e 部 分 :\nI* Correct translation of for loop into while loop *I long sum= O;\nlong i = O;\nwhile (i \u0026lt; 10) {\n辽 ( i \u0026amp; 1)\ngoto update; sum += i;\nupda t e :\ni++;\n}\n30 这个练习给你一个机会, 推算出 s wi t c h 语 句 的 控制流。要求你将汇编代码中的多处信息综合起来回答这些问题:\n汇编代码的第 2 行将 x 加上 1, 将情况( cases ) 的下界设置成 0。这就意味着最小的 清况标 号 为一1 。 当调整过的情况值大于 8 时 ,第 3 行 和第 4 行 会导致 程序跳转到默认情况。这就意味着最大情况 标 号 为—1 + 8 = 7。 在 跳 转表中, 我们看到第 6 行 的 表项(情况值 3) 与第 9 行 的 表项(情况值 6) 都以 第 4 行 的 跳 转指令 作 为 同 样的目标( .L2) , 表明这是默认的情况行为。因此 ,在 s wi t c h 语 句 体 中 缺失了情况标号 3 和 一6。 在跳转表中, 我们看到第 3 行和第 10 行上的表项有相同的目的。这对应于情况标号 0 和 7 。 在跳转表中, 我们看到第 5 行 和第 7 行 上 的 表项有相同的目的。这对应于情况标号 2 和 4。从上述推理,我们得出如下结论: s w itch 语句体中的情况标号值为— 1 、0 、1 、2 、4 、5 和 7 。\n目标为.L5 的 清况 标号为 0 和 7。\n目标为.L7 的 情况 标号为 2 和 4。\n3 . 31 逆 向 工 程编译出 s wi t c h 语 句 ,关 键 是 将 来 自汇 编 代码 和跳转表的信息结 合起来 , 理 清 不 同 的情况 。 从 j a 指 令(第 3 行 )可知,默 认 情 况 的 代码的标号是 . L2。我们可以 看到,跳 转表中只有另一个 标 号 重 复出现,就 是 . LS, 因 此 它 一 定 是 情 况 C 和 D 的 代 码 。 代 码 在 第 8 行 落 人 下 面的 情况, 因 而 标 号 . L7 符合情况 A , 标号 . L 3 符合情况 B。只剩下标号 . L6 , 符合情况 E 。\n原始的 C 代 码 如下 :\nvoid switcher(long a, long b, long c, long *dest)\n{\nlong val;\nS廿i t ch(a) { case 5:\nc = b - 15;\nI* Fall through *I case 0:\nval = c + 112; break;\ncase 2:\ncase 7:\nval = (c + b)«2; break;\ncase 4:\nval = a; break;\ndefault:\nval= b;\n}\n*dest = val;\n}\n3 32\n3 33\n追踪此等级上的程序的执行有助于理解过程调用和返回的很多方面。可以明确看到调用时控制是 怎么传 给过 程的以 及返回时 调用函数如何继续执行的。还可以看到参数通过寄存器%r d i 和%工s i传递 ,结 果通过寄 存器% r a x 返回。\n指令 状态值(指令开始执行前) 描述 标号 PC 指令 %rdi 号r s i %r a x %rsp 飞 r s p Ml Ox400560 callq 10 Ox7fffffffe820 调用 f ir s t (10) Fl Ox400548 lea 10 Ox7f f f ff f f e 818 Ox400565 丘r s t 的入口 F2 Ox40054c sub 10 11 Ox7fffffffe818 Ox 40 0565 F3 Ox400550 callq 9 11 Ox7fffffffe818 Ox400565 调 用 l a s t (9, 11) LI Ox400540 rnov 9 11 Ox7fffffffe810 Ox400555 l a s t 的入口 L2 Ox400543 imul 9 11 9 Ox7fffffffe810 Ox400555 L3 Ox400547 retq 9 11 99 Ox7fffffffe810 Ox400555 从 l a s t 返回 99 F4 Ox400555 repz repq 9 11 99 Ox7fffffffe818 Ox400565 从 f ir s t 返回 99 M2 Ox400565 mov 9 11 99 Ox7fffffffe820 继续执行 ma i n 由千是多种数据大小混合在一起,这道题有点儿难。\n让我们先 描述第一种答案, 再解 释第二种可能性。如果 假设第一个加(第3 行)实现* u += a, 第二个加(第4 行)实现 v+= b , 然后 我们 可以 看到 a 通过 % e中 作为第 一个参 数传 递, 把它从 4 个字节转换 成 8 个字节, 再加到 %r d x 指向的 8 个字节上。这就意味着 a 必定 是 i n t 类型, u 一定是 l o ng * 类型。还可以看 到参数 b 的低位字节被加到了%r c x 指向的字节。 这就意味着 v 一定是char* , 但是 b 的类型是不 确定的- 它的大小 可以 是 1 、2、4 或 8 字节。 注意 到返回值为 6 就能解决 这种不 确定性, 这个返回 值是 a 和 b 大小的和。因为我们知道 a 的大小 是 4 字节, 所以可以推断出 b 一定是 2 字节的。\n该函数的一 个加了注释的版本解释了这些 细节 :\nint procprobl (int a, short b, 1ong•u, char•v) a in 7.edi, b in ¾s i , u in¾rdx, v in¾rcx procprob:\nmovslq %edi, %rdi addq %rdi, (%rdx) addb %s il, (%rcx)\nmovl $6, %eax ret\nConvert a to 1 ong\nAdd to•u (long)\nAdd low-order byte of b to•v Return 4+2\n3 34\n3. 35\n此外 , 我们可以 看到 如果以它们在 C 代码中出 现相反的 顺序在汇编代 码中计算这两个和, 这段汇编代码同 样合法。这 会导致交 换参数 a 和 b , 参数 u 和 V , 得到如下原型:\nint procprob(int b, short a, long•v, char•u);\n这个例子展示了被调用者保存寄存器的使用,以及保存局部数据的栈的使用。\n可以 看到第 9 ~ 14 行将局部值 a O~ a S 分别保 存 进 被调用者保存 寄存器%r b x 、%r l S 、%r 1 4、\n沧r 13 、%r 1 2 和%r b p 。\n局部值 a 6 和 a 7 存放在栈中 相对于栈指 针偏移量 为 0 和 8 的地方(第1 6 和 18 行)。\n在存 储完 6 个局部变量之后 , 这个程序用完了 所有的 被调用者保存 寄存器, 所以 剩下的两个值保存在栈上。\n这道题给了一个检查递归函数代码的机会。要学的一个很重要的内容就是,递归代码与我们看到的其他函数的结构一模一样。栈和寄存器保存规则足以让递归函数正确执行。\n寄存器 %r b x 保存参数 x 的值, 所以 它可以 被用来计算结果 表达式 。\n汇编代码是由下 面的 C 代码产生而来的 :\nlong rfun(unsigned long x) { if (x == 0)\nreturn O;\nunsigned long nx = x\u0026gt;\u0026gt;2; long rv = rfun(nx); return x + rv;\n3. 36\n3 37\n3 38\n这个练习测试你 对数据大小 和数 组索引的理解。注意, 任何类型的指针都是 8 个字节长。 s hor t\n数据类型需要 2 个字节 , 而 i n t 需要 4 个。\n数组 元素大小 总大小 起始地址 元素1 s T u V w 2 8 8 4 8 14 24 48 32 32 Xs Xr Xu Xv x. x,+2i XT + 8i Xv + 8i Xv +4i Xw+Bi 这个练习是 关于整数 数组 E 的练习的一个变形。理 解指针与指 针指向的对象之间的区别是很重要的。因为数 据类型 s ho r t 需要 2 个字节, 所以所有的数组索引都将乘以因子 2。前面我 们用的是\nmovl, 现在用的则 是 mov w。\n表达式 类型 值 汇编语句 S+l S [3] \u0026amp;S [i] S[4*i+l] S+i-5 short* short s ho r t * shor t short* X5 + 2 M[x5 +6] x , +2i M[x5 + 8i + 2] X5 + 2i - 10 l e a l 2 ( %r d x ) , %r a x movw6(%rdx),%ax leal(%rdx,%rcx,2),%rax rnovw2(%rdx,%rcx,8),%ax l e a l - 1 0 ( %r d x , %r c x , 2 ) , %r a x 这个练习要求 你完成 缩放操作 , 来确定地址的 计算,并 且应用行 优先索引的公式( 3. 1 ) 。第一步是注释汇编 代码, 来确定 如何计算地址引 用:\nlong sum_element(long i, long J)\n工 in 7.rdi, j in 7.rsi s um_el e ment :\nleaq O (, %r di , 8) , %rdx\nsubq %rdi, %rdx\naddq %rsi, %rdx\nleaq (%rsi,%rsi,4), %rax addq %rax, %rdi\nmovq Q (, %rdi, 8) , %rax\naddq P(,%rdx,8), %rax ret\nCompute 81\nCompute 7i Compute 7i + J Compute 51 Compute i + SJ\nRetrieve M[xQ + 8 (5 」 + i)]\nAdd M (xp + 8 (7i + })]\n3. 39\n3. 40\n我们可以看 出, 对矩阵 P 的引用是在字节偏移 8 X ( 九十))的地方, 而对矩阵 Q 的引用是在字节偏移 8 X ( 5 j + i ) 的地方。由此我们可以 确定 P 有 7 列, 而 Q 有 5 列, 得到 M = 5 和 N = 7。\n这些计算 是公式( 3. 1) 的直接应用 :\n对千 L = 4 , C = 1 6 和 )= O, 指针 Ap tr 等千 x , +4X (1 6i + O) =x, + 64, 。\n对千 L = 4 , C=l6, i= O 和 j = k , 指针 Bp tr 等千 x 8 + 4 X0 6 X O+ k ) = x a + 4 k 。\n对于 L = 4 , C=l6, i= l6 和)= k , Be nd 等于 x 8 +4 X 06 X 16+k) =x8 + 1024 + 4k 。\n这个练习要求你能够研究编译产生的汇编代码,了解执行了哪些优化。在这个情况中,编译器做 一些聪明的优化。\n让我们先来研究 一下 C 代码, 然后看看如何从为原 始函数产生的汇编代码推导出这个 C\n代码。\nI* Set all diagonal elements to val *I\nvoid fix_set_diag_opt(fix_matrix A, int val) { int *Abase = \u0026amp;A [OJ [OJ ;\nlong i = O;\nlong iend = N*(N+1); do {\nAbase[iJ = val; i += (N+1);\n} while (i != iend);\n这个函 数引 入了一 个变量 Aba s e , int * 类型的, 指向数组 A 的起始位置。 这个指针指向一个 4 字节整数序列 , 这个序列由按 照行优先顺 序存放的 A 的元素组 成。我们引 入一个 整数 变量 i n­\ndex, 它一步一步经过 A 的对角线 , 它有一个属性 , 那就是对角线 元素 l 和 i + l 在序 列中 相隔 N +\n1 个元素, 而且一旦 我们 到达对角线 元素 N ( 索引为 N ( N + l ) ) , 我们就超出了边界。\n实际的汇编代 码遵循这样的通 用 格式, 但是现在指针的增加必须乘以因子 4。我们将寄存器釭a x 标记为存放 值 i nd e x 4 , 等于 C 版本中的 i n d e x , 但是使用因子 4 进行伸缩。对于 N = l 6 , 我们可以 看到对于 i n d e x 4 的停止点会是 4 · 160 6 + 1 ) = 1088 。\nfix_set_diag:\nvoid fix_set_diag(fix_matrix A, int val) A i n 肚 di , val in 7.rsi\nmovl $0, %eax\n.L13:\nmovl %esi, (%rdi, %rax) addq $68, %rax\ncmpq $1088, i,rax\njne .L13\nrep; ret\nSet index4 = 0 l oop:\nSet Abase [in dex4/ 4] to val Increment index4 += 4(N+1)\nCompare index4: 4N(N+1) If!=, goto l oop\nReturn\n3. 41\n这个练习让 你思考结构的布局 , 以及用来访问 结构字段的代码。该结构声明是书中 所示例子的 一个变形。它表明嵌套的结构的分配是将内层结构嵌人到外层结构之中。\n该结构的布局图如下: 偏 移 0\n内容[\ns.x 产 s . y\n24\nnext\n它使用 了 24 个字节。\nc. 同平时一样, 我们从 给汇编代 码加注释开始:\nvoid sp_init(struct prob•sp) s p in 7.rdi\nsp_init:\nmovl movl leaq movq movq ret\n12 c 儿r di ) , %eax\n.儿e ax , 8(%rdi) 8(%rdi), %rax\n%rax, (%rdi)\n%rdi, 16(%rdi)\nGet sp-\u0026gt;s.y Save in sp-\u0026gt;s.x\nCompute \u0026amp;(sp-\u0026gt;s .x) Store in sp-\u0026gt;p\nStore spin sp-\u0026gt;next\n由此可以 产生如下 C 代码:\nvoid sp_init(struct prob•sp)\n{\nsp-\u0026gt;s.x sp-\u0026gt;p\nsp-\u0026gt;next\n= sp-\u0026gt;s.y;\n= \u0026amp;(sp-\u0026gt;s.x);\n= sp;\n3. 42\n这道题说明 了一个非常普 通的 数据结构和对它的 操作时 如何在机器代 码中实现。要解答 这些问 题, 还是先对汇编代码加 注释, 确认出该结构的两个字段分 别在偏移 量 0 ( 字段 v ) 和 8 ( 字段 p ) 处。\nloDg f 皿 (s tr uct ELE •ptr )\nptr ill\n1 fun:\nrY. d1\nmovl $0, %eax\nJIDP .12\n4 .L3:\nresult = 0 Goto middle\nloop:\n5 addq\n6 movq\n7 .L2:\n(o/.rdi) , o/.rax\n8(o/.rdi), o/.rd1\nresult+= ptr-\u0026gt;v ptr = ptr-\u0026gt;p\nmiddle:\ntestq\u0026rsquo;Y.rdi,\u0026lsquo;Y.rdi jne .L3\n10 rep; ret\nTest ptr\nIf ! = NULL, goto loop\n根据加了注释的 代码, 可以 得到 C 语言:\nlong fun(struct ELE *ptr) { long val= O;\nwhile (ptr) {\nval+= ptr-\u0026gt;v; ptr = ptr-\u0026gt;p;\nreturn val;\n可以 看到每个结 构都是一个单链 表中的 元素, 字段 v 是元素的 值, 字段 p 是指向下 一个元 素的指针。函数 f u n 计算列表中元素值的 和。\n3. 43 结构和联合涉及的概念很简单,但是需要练习来习惯不同的引用模式和它们的实现。\n表达式 类型 代码 up-\u0026gt;tl. u long movq ( % r d 习 ,%r a x movq 毛r a x, ( %r s 习 up-\u0026gt;tl.v short movw 8 ( %r di ) , 毛a x mo vw 皂 a x, 伐 r s i ) \u0026amp;up-\u0026gt;tl. w char* addq $, %r d i movq % r d i , ( %r s i ) up-\u0026gt;t2.a int* mo v q 乌r d i , %r s i up- \u0026gt;t 2 . a [up- \u0026gt; tl.u) int mo v q ( %r d i ) , %r a x movl ( %r d i , %rax, 4), %e a x movl %e a x , (%r s 习 *up-\u0026gt;t2.p char movq 8 ( %r d习 ,%r a x movb ( %r a x ) , %a l movb %a l , ( 沧r s 习 44 想理解各种数据结构需要多少存储,以及编译器为访问这些结构产生的代码,理解结构的布局和对齐是非常重 要的 。这个练习让你看清楚 一些示例结构的细节 。 struct Pl {inti; char c; int j; chard;); 总共 对齐\n1 6\ns tr u c 七 P2 {inti; char c; chard; long j; }; 勹 # struct P3{ short w [3]; char c [3] } ; w C 总10共 对齐\n6 2\nstruct P4 { short w [5]; char *c[3] } ; w C 总4共0 对齐\n。 16 8\nstruct PS (struct P3a[2]; struct P2 t }; a t 总40共 对齐\n24 8\n45 这是一个理 解结构的布局 和对齐的 练习。 这里是对象大小和字节偏移量: 字段大 小 偏移攸\n这个结构一共是 56 个字节长 。结构的结尾必须 填充 4 个字节来 满足 8 字节对 齐的 要求 。 当所有的 数据元素的 长度都 是 2 的幕时 , 一种行 之有效的策略 是按照 大小的降序排 列结构的元素。导致声明如下:\nstruct {\nchar •a;\ndouble c;\nlong g;\nfloat e;\nint h;\nshort b,·\nchar d;\nchar f;\n} rec;\n得到的偏移扭如下: 字段 a C g e h b d f 大小 8 8 8 4 4 2 偏移量 I o 8 16 24 28 32 34 35 这个结构要填充 4 个字节以满足 8 字节对齐的 要求 , 所以总共是 40 个字节。\n46 这个问 题覆盖的话题比较广泛,例 如栈帧、字符 串表示 、ASCII 码和字节顺 序。 它说明了越界的内存引用的危险性,以及缓冲区溢出背后的基本思想。 执行了第 3 行后的栈: 00 00 00 00 00 40 00 7 61 返回值\n01 23 45 67 89 AB CD EF 保存的釭b x\n\u0026lt; — b u f = 毛 r s p\n执行了 第 5 行后的栈: 00 00 00 00 00 40 00 3 41 返回值 33 32 31 30 39 38 37 36 保存的%r b x 35 34 33 32 31 30 39 38 37 36 35 34 33 32 31 30 I \u0026ndash; buf = %rsp\n这个程序试图 返回 到地址 Ox 0 40034 。低 位 2 字节被字符'矿和结尾的 空( null) 字符覆盖了。\n寄存器 %r b x 的保存 值被 设置为 Ox 333231 3039383 736 。在 ge t _ l i ne 返回前, 这个值会被 加载回这个寄存器中。\n对 ma l l oc 的 调用应该以 s tr l e n (bu f ) + 1 作为它的 参数, 而且代码还应 该检查返回 值是否为\nNULL 。\n3. 47 A. 这对应于大约沪个地址的范围。\nB. 每次尝试, 一个 1 28 字节的空 操作 s l e d 会覆盖 扩个地址 , 因此我们只需 要 26 = 64 次尝试。这个 例子明确地 表明了这个版 本的 L inux 中的随机化程度只能 很小地阻 挡溢出攻击。\n48 这道题让 你看看 x86-64 代码如何管 理栈 , 也让你更 好地理解如何防 卫缓 冲区 溢出攻 击。\n对于没有保护的代码 , 第 4 行和第 5 行计算 v 和 b u f 的地址为相对千%r s p 偏移噩为 24 和 0。在有保护的代码中 , 金丝雀被存放 在偏 移雇为 40 的地方(第4 行), 而 v 和 bu f 在偏移 量为 8 和 16 的地方(第7 行和第 8 行)。 在有保 护的代码中 , 局部变量 v 比 bu f 更靠 近栈 顶 , 因此 b u f 溢出就不会 破坏 v 的值。 49 这段代码中包含许 多我们已 经见到过的 执行位级 运算的 技巧。要仔细研究 才能 看得懂 。\n第 5 行的 l e a q 指令计算值 8 n + 22 , 然后 第 6 行的 a ndq 指令 把它向下舍入 到最接近的 16 的倍数。当 n 是奇数时,结 果值会是 8 n + 8 , 当 n 是偶数时 ,结 果值会是 8 n + l 6 , 这个 值减去 s, 就得到 s, O\n该 序列中的三条指令 将 S2 舍入 到最 近的 8 的倍数。它们利用了 2. 3. 7 节中实现除以 2 的幕用到的偏移 和移 位的组 合。\n这两个 例子可以 看做最小 化和最大化 e1 和 e, 的情况。\nn s, s, p e, e, 5 2065 2017 2024 I 7\n6 2064 2000 2000 16\n可以 看到 s, 的计算方式 会保 留 S1 的偏移 量为 最接近的 1 6 的倍数。还可以 看到 p 会以 8 的倍数对齐, 正是对 8 字节元 素数组建 议使用的 。 `\n3 50 这道题要求你仔细检查代码,小心留意使用的转换和数据传送指令。可以看到取出的值和转换的 情况如下 :\n取出位 千 dp 的值, 转换成 i nt ( 第 4 行), 再存储到 i p 。因 此可以 推断出 va 荨 是 d。\n取出位 千 i p 的值, 转换 成 fl oa t ( 第 6 行), 再存储到 f p。因此可以 推断出 va l 2 是 l 。\n1 的值被转换 成 doub l e ( 第 8 行), 并存储在 dp 。因此 可以 推断出 va l 3 是 1 。\n第 3 行上取出位 千 f p 的值。第 10 和 11 行的两条指 令把它转换为双精度, 值通过寄存 器%xmm0\n返回。因此可以 推断 出 va l 4 是 f 。\n3. 51 可以通过从图 3-47 和图 3- 48 中选择适当的条目或者使用在浮点 格式间转换的代码序列 来处 理这些情况。\nT, Ty 指令 long double double double int float vcvtsi2sdq %r d i , %x mm0 , %x mm0 vcvttsd2si %x mm0 , %e a x vunpcklpd %x mm0 , %x mm0 , %x mm0 vcvtpd2ps %xmm0, 毛 x mmO long float float long vctsi2ssq % r d i , %x mm0 , %x mm0 vcvt t s 2s i q % x mm0 , %r a x 映射参数到寄存器的基本规则非常简单(虽然随着有更多类型的参数出现,这些规则也变得越来越 复杂[ 77] ) 。 double gl (double a, long b, float c, int d);\n寄存器: a 在%x mm0 中, b 在%r d i 中, e 在%x mml 中 , d 在 % e s i 中\ndouble g2(int a, double *b, float *c, long d) ;\n寄存器: a 在 ¾ e小 中, b 在% rsi 中, c 在 % rd x 中, d 在 % rcx 中\ndouble g3(double *a, double b, int c, float d);\n寄存器: a 在%r 中 中 , b 在%x mm0 中, e 在% e s i 中 , d 在%x mml 中\ndouble g4(float a, int *b, float c, double d);\n寄存器: a 在%x mm0 中 , b 在%r d i 中, e 在%x mml 中 , d 在%x mm2 中\n从这段 汇编代码 可以 看出有 两个整数 参数, 通过寄存器%r 生 和%r s i 传递, 将其命名为 过 和 辽。类似地 , 有两个浮点 参数, 通过 寄存器%x mm0 和%x mml 传递, 将其命 名为 fl 和 f 2。\n然后给汇编代码加注释:\nRefer to arguments as 工 1 (r¼ di ) , 立 (¼esi )\nt 1 (¼xmmO) , and t2 (¼xmm1)\ndouble tunct1(arg1_t p, arg2_t q, arg3_t r, arg生t s) functl:\nvcvtsi2ssq %rsi, %xmm2, %xmm2 vaddss %xmm0, %xmm2, %xmm0 vcvtsi2ss %edi, %xmm2, %xmm2 vdivss %xmm0, %xmm2, %xmm0 vunpcklps %xmm0, %xmm0, %xmm0\nvcvtps2pd %xmm0, %xmm0 vsubsd %xmm1, %xmm0, %xmm0 ret\nGet i2 and convert from long to float Add ft (type float)\nGet it and convert from int to float Comp ut e 辽 I (i2 + tt)\nConvert to double\nCompute i1 I (i2 + fl) - f2 (double)\n3. 54\n由此可以 看出这段代码计算 值 i l / (i2+fl) - f2。还 可以 看到, i l 的类型 为 i nt , i 2 的类 型 为long, f l 的类型为 f l oa t , 而 f 2 的类型为 do ub l e 。将参数 匹配到命名的 值只有一个不确定的地方,来自于加法的交换性 得到两种可能的结果:\n·double functla(int p, float q, long r, double s); double functlb(int p, long q, float r, doubles);\n一步步梳理 汇编 代码 , 确定 每一 步计算什么, 就很容易 找到这 道题的答 案, 如下 面的 注释所示 :\ndouble funct2(double w, int x, float y, long z) w in i.xmmO, x in i.edi, y in i.xmm1 , z i n 肚 si funct2:\nvcvtsi2ss %edi, %xmm2, %xmm2 vmulss %xmm1, %xmm2, %xmm1 vunpcklps %xmm1, %xmm1, %xmm1\nvcvtps2pd %xmm1, %xmm2 vcvtsi2sdq %rsi, %xmm1, %xmm1 vdivsd %xmm1, %xmm0, %xmm0\nvsubsd %xmm0, %xmm2, %xmm0 ret\nConvert x to float Multiply by y\nConvert x•y to double Convert z to double Compute w/z\nSubtract from x•y Return\n3. 55\n3. 56\n可以从 分析得出结论,该 函数计算 y*x- w/ z 。\n这道题使用的 推理 与推断 标号 . LC2 处声明的数字是 1. 8 的编码一 样, 不过例子更简单 。\n我们 看到 两个值分别 是 0 和 1077 936128 ( Ox 40400000 ) 。从高 位字 节 可 以抽取出 指 数字段\nOx404Cl028), 减去偏移 量 1023 得到指数为 5 。连 接两个 值的小数位, 得到小数字段为 o, 加上隐含的 开头的 1, 得到 1. 0 。因此这个常数是 1. OX25 =32. 0。\n在此可以 看到从 地址 . LCl 开始的 1 6 个字节是一个掩码 , 它的低 8 个字节是全 1 , 除了最高位, 这是 双精度值的 符号位。计算 这个掩码和%x mm0 的 A ND 值时 , 会清除 x 的符号位, 得到绝对 值。实际上, 定义 EXPR (x ) 为 f a b s (x ) 就 能得到这段 代码, f a b s 是在\u0026lt; ma t h . h \u0026gt; 中定义的。\n可以 看到 v x or p d 指令将 整个寄存器设 置为 0 , 所以这是 一种产生浮点 常数 o. 0 的方法。\n可以 看到从 地址 . LC2 开始的 16 个字节是 一个掩码, 它只有一个 1 位, 位于 XMM 寄存器中低位数值 的符号位。计算这个 掩码与% x mrn0 的 EXCLUSIVE - OR 值时, 会改变 x 符号的值, 计算出 表达式 - x。\n3 57 同样地,为代码加注释,包括处理条件分支:\ndouble funct3(int *ap, double b, long c, float *dp) ap in¼rdi, b in¼xmmO, c in¼rsi, dpin¼rdx\nfunct3:\nvmovss (o/.rdx) , o/.xmml Get d = *dp\nvcvtsi2sd (o/.rdi), o/.xmm2, o/.xmm2 Get a = *ap and convert to double\n4 vucom1sd o/.xmm2, o/.xmmO Compare b:a\njbe . LS\nvcvtsi2ssq %rsi, %xmm0, %xmm0\nvmulss %xmm1, %xmm0, %xmm1\nvunpcklps %xmm1, %xmm1, %xmm1\nIf\u0026lt;=, goto lesseq Conver t c to float 加 l tiply by d\n9 vcvtps2pd %xmm1, %xmm0\n10 ret\n11 .LB:\nConvre Return\nl esseq ·\nt to double\n72 vaddss %xmm1, %xmm1, %xmm1\nvcvtsi2ssq %rsi, %xrom0, %xmm0\nvaddss %xmm1, %xmm0, %xmm0\nvunpcklps ¾xmmO, ¾xmmO, ¾xmmO\nvcvtps2pd %xmm0 , %xmm0\nret\nCompute d+d = 2 . 0 * d Convert c to float Compute c + 2•d\nConver t to double Return\n由此, 可以 写出 f u n c t 3 的代码如下 :\ndouble fu 卫 ct 3(i nt • ap, double b, long c, float•dp) { int a; •ap;\nfloat d; •dp; if (a \u0026lt; b)\nreturn c•d;\nelse\n}\nreturn c+2•d;\n第 4 章\nCH APTER 4\n处理器体系结构 # 现代微处理器可以称得上是人类创造出的最复杂的系统之一。一块手指甲大小的硅片 上,可以容纳一个完整的高性能处理器、大的高速缓存,以及用来连接到外部设备的逻辑电路。从性能上来说 , 今天在一 块芯片上实现的处理器巳经使 20 年前价值 1000 万美元、房间那么大的超级计算机相形见细了。即使是在像手机、导航系统和可编程恒温器这样的日常设备中的嵌入式处理器,也比早期计算机开发者所能想到的强大得多。\n到目前为止,我们看到的计算机系统只限于机器语言程序级。我们知道处理器必须执 行一系列指令,每条指令执行某个简单操作,例如两个数相加。指令被编码为由一个或多 个字节序列组成的二进制格式。一个处理器支持的指令和指令的字节级编码称为它的指令 集体系 结构 (I nst ruct ion-Set Architecture, ISA) 。不同的处 理器“家族” , 例如 In tel IA32 和 x86-64 、IBM/ Freescale Power 和 ARM 处理器家族, 都有不同的 ISA 。一 个程序编译成在一种机器上运行,就不能在另一种机器上运行。另外,同一个家族里也有很多不同型 号的处理 器。虽然每个 厂商制造的处 理器性能 和复杂性不断提高 , 但是不同的型号在 ISA 级别上都 保持着兼容 。一些常见的处理器家族(例如x86-64) 中的处理器分别由多个厂商提供。因此, ISA 在编译器编写者 和处理器设计人员之间提供了一个概念抽象 层, 编译器编写者只需要知道允许哪些指令,以及它们是如何编码的;而处理器设计者必须建造出执行 这些指令的处理器。\n本章将简要介绍处 理器硬件的设 计。我们将研究一个硬件系统执行某种 ISA 指令的 方式。这会使你能更好地理解计算机是如何工作的,以及计算机制造商们面临的技术挑战。一个很重要 的概念是 , 现代处 理器的实际工作方式可能 跟 ISA 隐含的计算模型大相径庭。\nISA 模型看上去应该是 顺序指 令执行 , 也就是先取出一条指令, 等到它执行完毕 , 再开始下一条。然而,与一个时刻只执行一条指令相比,通过同时处理多条指令的不同部分,处 理器可以获得更高的性能。为了保证处理器能得到同顺序执行相同的结果,人们采用了一 些特殊的机制。在计算机科学中,用巧妙的方法在提高性能的同时又保持一个更简单、更 抽象模型的 功能, 这种思想是众所周知的。在 Web 浏览器或平衡二叉树和哈希表这样的信息检索数据结构中使用缓存,就是这样的例子。\n你很可能永 远都不会 自己设 计处理器。这是专家们的任务, 他们工作在全球不到 100\n家的公司里。那么为什么你还应该了解处理器设计呢?\n从智力方面来说,处理器设计是非常有趣而且很重要的。学习事物是怎样工作的有其内在价值。了解作为计算机科学家和工程师日常生活一部分的一个系统的内部工作原理(特别是对很多人来说这还是个谜),是件格外有趣的事情。处理器设计包括许多好的工程实践原理。它需要完成复杂的任务,而结构又要尽可能简单和规则。 理解处理 器如何工作 能帮助 理解整 个计 算机 系统如何 工作。在第 6 章 , 我们将讲述存储器系统,以及用来创建很大的内存映像同时又有快速访问时间的技术。看看处 理器端的处理器 内存接口,会使那些讲述更加完整。 "},{"id":440,"href":"/zh/docs/technology/System/%E6%B7%B1%E5%85%A5%E7%90%86%E8%A7%A3%E8%AE%A1%E7%AE%97%E6%9C%BA%E7%B3%BB%E7%BB%9F/%E4%B9%A6%E7%B1%8D/%E7%AC%AC4%E7%AB%A0-%E5%A4%84%E7%90%86%E5%99%A8%E4%BD%93%E7%B3%BB%E7%BB%93%E6%9E%84/","title":"Index","section":"SpringCloud","content":"第 4 章\nC H A P T E R 4 ·\n处理器体系结构 # 现代微处理器可以称得上是人类创造出的最复杂的系统之一。一块手指甲大小的硅片上,可以容纳一个完整的高性能处理器、大的高速缓存,以及用来连接到外部设备的逻辑电路。 从性能上来说 , 今天在一块芯片上实现的处理器已经使 20 年前价值 1000 万美元、房间那么大的超级计算机相形见细了。即使是在像手机、导航系统和可编程恒温器这样的日常设备中的嵌入式处理器,也比早期计算机开发者所能想到的强大得多。\n到目前为止,我们看到的计算机系统只限于机器语言程序级。我们知道处理器必须执行一系列指令,每条指令执行某个简单操作,例如两个数相加。指令被编码为由一个或多 个字节序列组成的二进制格式。一个处理器支持的指令和指令的字节级编码称为它的指令 集体系 结构 (I nst ru ction-Set Architecture, ISA)。不同的处理器“ 家族”, 例如 In tel IA32 和 x86-64 、IBM/ Freescale Power 和 ARM 处理器家族, 都有不 同的 ISA。一个程序编译成在一种机器上运行,就不能在另一种机器上运行。另外,同一个家族里也有很多不同型 号的处 理器。虽然每个厂商制造的处理器性能和复杂性不断提高, 但是不同的型号在 ISA 级别上都保持着兼容。一些常见的处理器家族(例如x86-64) 中的处理器分别由多 个厂商提供。因此, ISA 在编译器编写者和处理器设计人员之间提供了一个概念抽象层,编 译器编写者只需要知道允许哪些指令,以及它们是如何编码的;而处理器设计者必须建造出执行 这些指令的处理器。\n本章将简要介绍处理骈硬件的设计。我们将研究一个硬件系统执行某种 ISA 指令的方\n式。这会使你能更好地理解计算机是如何工作的,以及计算机制造商们面临的技术挑战。 一个很重要的 概念是 , 现代处理器的实际工作方式可能跟 ISA 隐含的计算模型大相径庭。\nISA 模型看上去应该是顺序指 令执行, 也就是先取出一条指令,等 到它执行完毕 ,再 开始下一条。然而,与一个时刻只执行一条指令相比,通过同时处理多条指令的不同部分,处 理器可以获得更高的性能。为了保证处理器能得到同顺序执行相同的结果,人们采用了一 些特殊的机制。在计算机科学中,用巧妙的方法在提高性能的同时又保待一个更简单、更 抽象模型的功能 , 这种思想是众所周知的。在 Web 浏览器或平衡二叉树和哈希表这样的信息检索数据结构中使用缓存,就是这样的例子。\n你很可能永远都不会自己设计处理器。这是专家们的任务,他们 工作在全球不到 100\n家的公司里。那么为什么你还应该了解处理器设计呢?\n从智力方面来说,处理器设计是非常有趣而且很重要的。学习事物是怎样工作的 有其内在价值。了解作为计算机科学家和工程师日常生活一部分的一个系统的内 部工作原理(特别是对很多人来说这还是个谜),是件格外有趣的事情。处理器设计包括许多好的工程实践原理。它需要完成复杂的任务,而结构又要尽可能简单 和规则。 理解处理器如何工作 能帮助 理解整个计算机 系统如何 工作。在第 6 章, 我们将讲述存储器系统,以及用来创建很大的内存映像同时又有快速访问时间的技术。看看处 理器端的处理器 内存接口,会使那些讲述更加完整。 虽然很少有人设计处理器,但是许多人设计包含处理器的硬件系统。将处理器嵌入到现实世界的系统中,如汽车和家用电器,已经变得非常普通了。嵌入式系统的设计者必须了解处理器是如何工作的,因为这些系统通常在比桌面和基千服务器的系统更低抽象级别上进行设计和编程。 你的工作可能就是处理器设计。虽然生产处理器的公司很少,但是研究处理器的设计人员队伍已经非常巨大了,而且还在壮大。一个主要的处理器设计的各个方面大约涉及 1000 多人。\n本章首先定义一个简单的指令集, 作为我们处理器实现的运行示例。因为受 x86-6 4 指令集的启发, 它被俗称为 \u0026quot; x86\u0026quot; , 所以我们称我们的指令集为 \u0026quot; Y86-64\u0026quot; 指令集。与x86-64 相比, Y86-64 指令集的数据类型、指令和寻址方式都要少一些。它的字节级编码也比较简单, 机器代码 没有相应的 x86- 64 代码紧凑 , 不过设计它的 CPU 译码逻辑也要 简单一些。虽然 Y86- 64 指令集很简单, 它仍然足够完整, 能让我们写一些处理整数的 程序。设计一个实现 Y86-64 的处理器要求我们解决许多处 理器设计者同样会面对的问 题。\n接下来会提供一些数字硬件设计的背景。我们会描述处理器中使用的基本构件块,以及它们如何连接起来和操作。这些介绍是建立在第 2 章对 布尔代数和位级操作的讨论的基础上的。我们还将介 绍一种描述硬件系统控制部分的简单语言, H CL ( Hardwa re Control\nLanguage, 硬件控制 语言)。然后,用 它来描述我们的处理器设计。即使你已经 有了一些逻辑设计的背景知识,也 应该读读这个部分以 了解我们的 特殊符号表示方法。\n作为设计处理器的第一步,我们给出一个基于顺序操作、功能正确但是有点不实用的Y86-64 处理器。这个处理器每个时钟周期 执行一 条完整的 Y86-64 指令。所以它的时钟必须足够慢,以允许在一个周期内完成所有的动作。这样一个处理器是可以实现的,但是它的性能远远低于同样的硬件应该能达到的性能。\n以这个顺序设计为基础, 我们进行一系列的改造,创 建 一个流水 线化的 处理 器 ( pipe­ lined pro cessor ) 。这个处理器将每条指令 的执行分解成五步, 每个步骤由一个独立的硬件部分或阶段( stage )来处理。指令步经流水线的各 个阶段, 且每个时钟周期有一条新指令进入流水线。所以,处理器可以同时执行五条指令的不同阶段。为了使这个处理器保留 Y86-64 IS A 的顺序行为, 就要求处理很多冒险或 冲突( hazard ) 情况, 冒险就是一条指令的位置或操作数依赖于其他仍在流水线中的指令。\n我们设计了一些工具来研究 和测试处理器设计。其中包括 Y86-64 的汇编器、在你的机器上运行 Y86-64 程序的模拟器, 还有针对两个顺序处理器设计和一个流水线化处理器设计的模 拟器。这些设计的控制逻辑用 HCL 符号表示的 文件描述。通过编辑这些文件和重新编译模拟器,你可以改变和扩展模拟器行为。我们还提供许多练习,包括实现新的指令和修改机器处理指令的方式。还提供测试代码以帮助你评价修改的正确性。这些练习将极大地帮助你理解所有这些内容, 也能使你更理解处理器设计者面临的许多不同的设计选择 。\n网络旁注 ARC H : VLOG 给出了用 Verilog 硬件描述语言描述的流水线化的 Y86-64 处\n理器。其中包括为基本的硬件构建块和整个的处理器结构创建模块。我们自动地将控制逻辑的 H CL 描述翻译成 Ver ilog 。 首先用我们的模拟器调试 H CL 描述, 能消除很多在硬件设计中会出现的棘手的问题。给定一 个 Verilog 描述, 有商业和开源工具来支待模拟和逻辑合成(l ogic synthesis), 产生实际的微处理器电路设计。因此,虽然我们在此花费大部分精力创建系统的图形和文字描述,写软件的时候也会花费同样的精力,但是这些设计能够自动地合 成, 这表明我们确实 在创建一个能 够用硬件实 现的系统。\n4. 1 Y86-64 指令集体系结构\n定义一个指令集体系结构(例如 Y86- 64 ) 包括定义各种状态单元、指令集和它们的编码、一组编程规范和异常事件处理。\n1. 1 程序员可见的状态\n如图 4-1 所示, Y8 6- 64 程序中的每条指令都会读取或修改处理器状态的某些部分。这称为程序员可见状态,这里的"程序员”既可以是用汇编代码写程序的人,也可以是产生 机器级代 码的编译器。在处理器实现中, 只 要 RF: 程序寄存器\n我们保证机器级程序能够访问程序员可见状\n态, 就不需要完全按照 ISA 暗示的方式来表示和组织 这个处理器状态。Y8 6-64 的状态类似千 x86 -64 。有 15 个程序寄存器:%r a x 、%r c x 、%\nr dx、%r b x 、%r s p、r% b p 、r% s i 、r% d i 和 %r 8 到\n%r1 4。(我们省略了 x8 6-64 的寄存器%r l 5 以 简化指令的 编码。)每个程序寄存器存储一个 64\nCC: 条件码\nStat: 程序状态\nI I\nDMEM: 内存\n位的字。寄存器%r s p 被入栈、出栈、调用和 PC\n返回指令作为栈指针。除此之外,寄存器没有\n固定的含义或固定值。有 3 个一位的条件码: 图 4-1 Y86-64 程序员可见状态。同 x86-64 一\n样, Y86-64 的程序可以 访问 和修改 程\nZF 、 SF 和 OF , 它们保存着最近的算术或逻辑\n序寄存器、条件码、程序计数器 ( P C )\n指令所造 成影响的有关信息。程序计数器( P C ) 和内存。状态码指明程序是否运行正存放当前正在执行指令的地址。 常,或者发生了某个特殊事件\n内存从概念上来说就是一个很大的字节数组, 保存着程序和数据。Y86-64 程序用虚拟地址 来引用内存位置。硬件和操作系统软件联合起来将虚拟地址翻译成实际或物理地址, 指明数据实际存在内存中哪个地方。第 9 章将更 详细地研究虚拟内存。现在, 我们只认为虚拟内 存系统向 Y86-64 程序提供了一个单一的字节数组映像。\n程序状态的最后一个部分是状态码 St a t , 它表明程序执行的总体状态。它会指示是正常运行 , 还是出现了某种异常, 例如当一条指令试图去读非法的内存地址时。在 4. 1. 4 节中会讲 述可能的状态码以及异常处理。\n4. 1: 2 Y86-64 指 令 # 图 4-2 给出了 Y86-64 IS A 中各个指令的简单描述。这个指令集就是我们处理器实现的目标。Y86- 64 指令集基本上是 x86- 64 指 令集的一个子集。它只包括 8 字节整数操作, 寻址方式 较少,操 作 也 较少。因为我们只有 8 字节数据,所 以 称之为 ”字 ( w o r d ) \u0026quot; 不 会 有任何歧 义。在这个图中,左 边 是 指 令 的 汇 编 码 表示, 右 边是字节编码。图 4-3 给出了其中一些指令更详细的内容。汇编代码格式类似于 x86-64 的 AT T 格式。\n下面是 Y86- 64 指令的一些细节。\nx86-64 的 movq 指 令 分 成 了 4 个不同的指令: i rmovq、r rmovq 、mrmovq 和 rmmovq , 分别 显式 地指 明 源和目的的格式。源可以是立即数(立、寄存器 (r ) 或内存 Cm) 。 指 令名字的第一个字母就表明了源的类型。目的可以是寄存器(r ) 或内存Cm) 。指 令 名字的第二个字母指明了目的的类型。在决定如何实现数据传送时,显式地指明数据传送的 这 4 种类型是很有帮助的。\n两个内存传送指令中的内存引用方式是简单的基址和偏移量形式。在地址计算中, 我们不支持第二变址 寄存 器 ( s eco nd index regis t e r ) 和 任 何 寄 存 器 值 的 伸缩( s ca ling ) 。\n同 x8 6-64 一样, 我们不允许从一个内存地址直接传送到另一个内存地址。另外,也不允许将立即数传送到内存。\n有 4 个整数操作指令, 如图 4- 2 中 的 OPq 。 它 们 是 a d d q 、 s u b q 、 a n d q 和 xo r q。它们只 对寄存器数据进行操作, 而 x8 6-64 还允许对内存数据进行这些操作。这些指令会设置 3 个条件码 ZF 、 S F 和 OF( 零 、符 号 和 溢出)。\n7 个跳转指令(图4-2 中的 环x ) 是 j mp 、 j l e 、 j l 、 j e 、 j n e 、 j g e 和 j g。根据分支指令 的 类 型 和条件代码的设置来选择分支。分支条件和 x86- 64 的一样(见图3-15)。\n有 6 个条件传送指令(图 4- 2 中的 c mo v XX) : c mo v l e 、 c mo v l 、 c mo v e 、 c mov ne 、c mo v g e 和 c mo v g 。 这些指令的格式与寄存器-寄存器传送指令r r mo v q 一 样 ,但是只有当条件码满足所需要的约束时,才会更新目的寄存器的值。\nc a l l 指令将返回地址入栈,然 后 跳到 目 的 地址。r e t 指令从这样的调用中返回。\np u s hq 和 p o p q 指令实现了入栈和出栈,就 像 在 x8 6- 64 中 一 样 。\nh a l t 指 令 停 止 指 令 的 执 行 。 x8 6-64 中有一个与之相当的指令 h lt 。x8 6- 64 的应用程序不允许使用这条指令,因 为它会导致整个系统暂停运行。对 千 Y8 6-64 来说,\n执 行 h a lt 指 令 会 导 致处理器停止,并 将 状 态 码 设 置 为 HL T( 参见 4. 1. 4 节)。字节 。 1 2 3 4\nha l 七 荨\nnop 荨\nrrrnovq rA, rB 2 Io rAI rBI ir mov q V, rB 3 jo F j rBj rmmovq rA, D(rB) 4 jo rAj rBj\nmr mo v q D(rB), rA 5 lo rAlrBI OPq rA, rB 6 I fn J rAI rBI\njXX Dest 尸\ncmovxx rA, rB j2 I fn I rAI rBI c a ll Dest 巨\nret 荨\npushq rA I A 伈 l rAIF I\np o pq rA IB l o lrAIF I\nDest\nDest\n图 4- 2 Y8 6-64 指令集。指令编码长度从 1 个字节到 10 个字节不等。一条指令含有一个单字节的指令指示符, 可能含有一个单 字节的寄存器指示符, 还可能含有一个8 字节的常数字。字段 fn 指明是某个整数操作 ( OPq ) 、数据传送条件( c movXX) 或是分支条件 ( j XX) 。 所有的数值都\n用十六进制表示\n4. 1. 3 指令编码\n图 4-2 还给出了指令的字节级编码。每条指令需要 1 - 10 个字节不等, 这取决于需要哪 些 字 段 。 每条指令的第一个字节表明指令的类型。这个字节分为两个部分, 每部分 4\n位: 高 4 位是代码( cod e ) 部分, 低 4 位是功能( fu nc t io n ) 部分。 如图 4- 2 所示, 代码值为O~ Ox B。功能 值只有在一组相关指令共用一个代码时才有用 。图 4-3 给出了整数操作、分支和条件传送指令的 具体编码。可以 观察到,r r mo v q 与条件传送有同样的指令代码。可\n以把它看作是一个 “无条件传送“, 就好像 j mp 指令是无条件跳转一样,它 们的功能代码都是 0。\n整数操作指令 分支指令 传送指令\naddq I 6 1 O I jmp I 7 I O I jne I 7 I 4 I rrrnovql 2 。cmovne j 2 j 4 j\ns ubq 曰巨] j l e 巨巨] j g e 巨卫] cmovleI 2 1 cmovge I 2 I 5 I\nandq I 612 I jl 1 7 1 2 1 jg 1 7 1 6 1 cmovl I 2 2 cmovg I 2 I 6 I\nxorq I 613 I j e I 7 I 3 I cmove I 2 3\n图 4- 3 Y86-64 指令集的功能 码。这些代码指明是某个整数操作、分支 条件还是数据传送条件。这些指令是图 4-2 中所 示的 OPq 、 j XX 和 cmovXX\n如图 4- 4 所示, 1 5 个程序寄存 器中每个都有一 个相对应的范围在 0 到 Ox E 之间的寄存器标识符 ( reg is te r ID ) 。Y8 6-6 4 中的寄存器编号跟 x86- 64 中的相同。程序寄存器存在\nCPU 中的一个寄存器文件 中, 这个寄存器文件就是一个小的、以寄存器 ID 作为地址的随机访问存储器。在指令编码中以及在我们的硬件设计中,当需要指明不应访问任何寄存器 时,就用 ID 值 Ox F 来表示。\n图 4-4 Y86-64 程序寄存器标识符。 1 5 个程序寄存器中每个都有一个相对应的标识符 ( ID )\u0026rsquo; 范 围 为\nO~ OxE。如果指令中某个寄存器字段的 ID 值为 OxF, 就表明此处没有寄存器操作数\n有的指令只 有一个字节长 ,而有 的需要操作数的指令编码就更长一些。首先, 可能有附加的寄存 器指 示符 字 节 ( r eg is t er specifier byte), 指定一个或两个寄存器。在图 4- 2 中, 这些寄存器字段 称为 rA 和 rB。从指令的汇编代码表 示中可以看到, 根据指令类型, 指令可以指定用于数据源和目的的寄存器,或是用千地址计算的基址寄存器。没有寄存器操作数的指令 , 例如分支指令和 c a l l 指令, 就没有寄存器指示符字节。那些只需要一个寄存器操作数的 指令Cir mo v q 、 p u s h q 和 p o p q ) 将另一个寄存器指示符设为 Ox F 。 这种约定在我们的处理器实现中非常有用。\n有些指令需 要一个附加的 4 字节常数 字 ( co n s ta nt w or d ) 。这个字能作为 ir mo v q 的立即数数 据,r mmov q 和 mr mo v q 的 地址指示符的偏移量,以 及分支指令和调用指令的目的地址。注意 ,分 支指令和调用指令的目的是一个绝对地址, 而不像 IA32 中那样使用 PC\n(程序计数器)相对寻址方式。处理器使用 PC 相对寻址方式, 分支指令的编码会更简洁, 同时这样也能允许代码从内存的一部分复制到另一部分而不需要更新所有的分支目标地 址。因为我们更关心描述的简单性, 所以就使用了绝对寻址方式。同 I A 32 一样, 所有整数采用小端法编码。当指令按照反汇编格式书写时,这些字节就以相反的顺序出现。\n例如,用 十六 进制来 表示指令r mmov q %rsp, Ox123456789abcd ( %r d x ) 的 字节编码。从图 4- 2 我们可以 看到,r mmo vq 的第一个字节为 4 0。源 寄存器%r s p 应该编码放在 rA 字段中, 而基址寄存器%r d x 应该编码放在 rB 字段中。根据图 4- 4 中的寄存器编号,我们得到寄存器指示符 字 节 42 。 最后, 偏 移 量 编码放 在 8 字 节 的 常 数 字 中。首先在Ox l 2 3 45 678 9a b c d 的前面填充 上 0 变成 8 个字节 , 变成字节序列 00 01 23 45 67 89 ab c d。写成按字节反序就是 c d ab 89 67 45 23 01 00 。将它们都连接起来就得到指令的编码40 4 2c d a b 8 9 67 45 23 01 00。\n指令集的一个重要性质就是字节编码必须有唯一的解释。任意一个字节序列要么是一个唯一的指令序列的编码, 要么就不是一个合法的字节序列。Y86-64 就具有这个性质, 因为每条指令的第一个字节有唯一的代码和功能组合,给定这个字节,我们就可以决定所有其他附加字节的长度和含义。这个性质保证了处理器可以无二义性地执行目标代码程 序。即使代码嵌入在程序的其他字节中,只要从序列的第一个字节开始处理,我们仍然可以很容易地确定指令序列。反过来说,如果不知道一段代码序列的起始位置,我们就不能准确地确定怎样将序列划分成单独的指令。对于试图直接从目标代码字节序列中抽取出机器级程序的反汇编程序和其他一些工具来说,这就带来了问题。\n讫 练习题 4 . 1 确定 下面的 Y 8 6-64 指令 序列的 字节编码。 \u0026quot; . p a s Ox l OO\u0026quot; 那一行 表明这段目标 代码 的起 始地 址应该 是 Ox l OO 。\n.pos Ox100 # Start code at address Ox100 irmovq $15,%rbx\nrrmovq %rbx,%rcx loop:\nrmmovq %rcx,-3(%rbx) addq %rbx,%rcx\njmp loop\n芦 练习题 4. 2 确定 下列 每个字 节 序列 所编码的 Y8 6-6 4 指令 序 列。 如果序 列 中有 不合法的字节,指出指令序列中不合法值出现的位置。每个序列都先给出了起始地址,冒 号, 然后是 字节序列 。\nA. Ox100: 30f3fcffffffffffffff40630008000000000000\nB. Ox200: a06f800c020000000000000030f30a0000000000000090\nC. Ox300: 5054070000000000000010f0b01f\nD. Ox400: 611373000400000000000000\nE. Ox500: 6362a0f0\n日日比较 x86-6 4 和 Y86-64 的指令编码\n同 x8 6-64 中的 指令编码相比 , Y 8 6- 64 的编码 简单得多, 但是没那么 紧凑 。在所有: 的 Y 8 6-64 指令中, 寄存 器宇段 的位 置都 是固定的 , 而在不 同的 x8 6-64 指令中, 它们的位置是 不一 样的。x8 6- 64 可以将常数值编码 成 1 、2 、4 或 8 个宇 节 , 而 Y 8 6- 64 总是将i 常数值编码成 8 个字 节。\nBJ R IS C 和 CISC 指 令 集\nx86-64 有 时称 为 “ 复 杂指令 集计 算机\u0026quot; (CISC, 读作 \u0026quot; sisk\u0026quot; ) , 与“精简指令集计算机\u0026quot; (RISC, 读作 \u0026quot; risk\u0026quot; ) 相对。从历 史上看, 先 出现 了 CISC 机 器, 它从 最早的 计算机演化 而来。到 20 世 纪 80 年代早期 , 随 着机 器设 计者加入 了很 多 新 指 令 来 支持 高级 任务(例如 处理循环缓冲区 ,执 行 十进制数计算, 以 及 求 多 项式的值), 大型机和 小型机的指令集 已经 变得 非常 庞 大了。 最 早 的 微 处 理 器 出现 在 20 世 纪 70 年代 早期 , 因 为 当 时的集成电路技 术极 大地制约了一块 芯 片 上 能 实现 些什 么 ,所 以 它们 的 指 令 集非 常有限。微处理 器发 展 得 很 快, 到 20 世 纪 80 年 代早期 , 大型机和小型机的指令 集复 杂度一直都在增加。 x86 家族 沿 着这条道路发展到 IA32 , 最 近 是 x86-64。 即 使 是 x86 系列 也仍 然在不断地变化,基于新出现的应用的需要,增加新的指令类。\n20 世 纪 80 年 代 早 期 , RISC 的设 计理念是作为 上 述 发 展 趋 势 的 一种替代而发 展 起 来\n的。IBM 的一组硬件和编译 器专 家受到 IBM 研 究 员 John Cocke 的很 大影响 , 认 为他 们可以为更 简单的指令 集形式产生 高效 的 代 码 。 实际上, 许 多 加 到指令集中的 高级 指 令 很难被编译 器产 生, 所以也很 少被 用到。一个较为 简 单的指令 集 可以 用很 少的 硬 件 实现 , 能以 高效 的 流水线结构组织起 来, 类似 于本章 后 面描 述 的 情 况。 直到 多 年 以 后 IBM 才将这个理 念商品 化 , 开发 出 了 Power 和 PowerPC ISA。\n加 州大学伯 克利分校的 David Pat terson 和斯坦福 大学的 John Henness y 进 一 步发展\n了 RISC 的概念。Pat terson 将 这 种 新 的 机 器 类型 命 名 为 RISC, 而将以前的那种称为\nCISC, 因为以 前 没 有 必 要 给 一种几乎是通用的 指 令 集格 式起 名宇。\n比较 CISC 和最初的 RISC 指令 集, 我 们发现下面这些一般特性。\nCISC 早期的 R ISC\n指令数品 很多。Intel 描述全套指令的文档[ 51] 有 指令数量少得多。通常 少于 100 个。\n1200多页。\n有些指令的延迟很长。包括将一个整块从内存的 一个 没有较长延迟的指令。有些早 期的 RISC 机器甚至没部分复制到另一部分的指令,以及其他一些将多个寄存 有整数乘法指令,要求编译器通过一系列加法来实现器的值复制到内存或从内存复制到多个寄存器的指令。 乘法。\n编码是可变长度 的。x86-64 的指令长度可以 是 l ~ 编码是 固定长度的。通常所有的指令都编码为 4 个\n15个字节。 字节。\n指定操作数的方式 很多 样。在 x86-64 中 ,内 存操作 简单寻址方式。通常只有基址和偏移抵寻址。数指示符可以有许多不同的组合,这些组合由偏移量、\n基址和变址寄存器以及伸缩因子组成。\n可以对内存和寄存器操作数进行算术和逻辑运算。 只能对寄存器操作数进行算术和逻辑运算。允许使用\n内存引用的只有 load 和 store 指令, load 是从内存读到寄存器,store 是从寄存器写到内 存。这种方法被称为load/ store 体系结构。\n对机器级程序 来说实现细节是 不可见的。ISA 提 供 对机器级程序来说 实现细节是可 见的。 有些 RISC 机了程序和如何执行程序之间的清晰的抽象。 器禁止某些特殊的指令序列,而有些跳转要到下一条指\n令执行完了以后才会生效 。编译器必须在这些约束条件\n下进行性能优化。\n有条件码。作为指令执行的副产品,设置了一些特 没有条件码。相反,对条件检测来说,要用明确的测试殊的标志位,可以用于条件分支检测。 指令,这些指令会将测试结果放在一个普通的寄存器中。\n栈密集的过程链接。栈被用来存取过程参数和返回 寄存器密集的过程链接 。寄存器被用来存取 过程参数地址。 和返回地址。因此有些过程能完全避免内存引用。通常\n处理器有更多的(最多的有32 个)寄存器。\nY86-64 指令 集既 有 CISC 指令集的 属性, 也 有 RISC 指令集的 属性。和 CISC 一样, 它有 条件码、长度 可 变的指令, 并用 栈来保 存返回地址。和 RISC 一样的是, 它 采用load / sto re 体 系结 构和规则编码, 通过寄存器来传递过程参数。Y86-64 指 令 集可以看成是 采 用 CISC 指令集( x86) , 但 又根 据 某些 RISC 的原理进行了 简化 。\n田日R IS C 与 CIS C 之争\n20 世纪 80 年代, 计算机体 系结 构领域里关 于 RISC 指令集和 CISC 指令集优 缺点的争 论 十分激烈。RISC 的支持者声 称在给定硬 件数量的情况下, 通过结合 简 约 式指令集设计、高级编译器技术和流水线化的处理器实现,他们能够得到更强的计算能力。而\nCISC的拥定反驳说要 完成 一个给定的任务只需要用较 少的 CISC 指令, 所以他们的机器能够获得更高的 总体性能。\n大多数 公 司 都 推 出 了 RISC 处理 器 系 列 产 品 , 包括 S un Microsystems ( SPARC ) 、\nIBM 和 Moto rola( PowerPC) , 以及 D屯ital Equipment Corporation ( Alpha) 。一 家英国公 # 司 Acorn Computers Ltd. 提出 了 自 己的 体 系 结构一—-ARM ( 最 开 始是 \u0026quot; Acorn RISC\nMachine\u0026quot; 的首宇母缩写), 广泛应用在 嵌入式 系统中(比如手机)。\n20 世纪 90 年代早期, 争 论逐 渐平息, 因 为 事 实已 经很 清楚了 , 无论是单纯的 RISC 还是 单纯的 CISC 都不如 结合两者 思想精华的设计。RISC 机器发 展 进 化的过程中, 引入了更多的指令, 而许 多这样的指令都需要执行 多 个周期。今天的 RISC 机器的 指令表中有 几百条指令 , 几乎与 “ 精 简指令集机 器” 的名称不相符了。 那种将实现细节暴露给机器级程序的思想已经被证明是目光短浅的。随着使用更加高级硬件结构的新处理器模型 的开发,许多实现细节已经变得很落后了,但它们仍然是指令集的一部分。不过,作为\nRISC 设计的核心的指令集仍然是非常适合在流水线化的机器上 执 行的。\n比较新的 CISC 机器也 利 用 了 高性 能流水线结构。就像我们将在 5. 7 节 中讨论的那样 , 它们读取 CISC 指令, 并动 态地翻译成比较 简 单的、像 RISC 那样的操作的序列。例如,一条将寄存器和内存相加的指令被翻译成三个操作:一个是读原始的内存值,一 个是执行加 法运 算, 第 三就是将和写回 内存 。由于动态翻 译通常可以在 实际 指令执行前进行,处理器仍然可以保持很高的执行速率。\n除了技 术因素 以外, 市场 因素也在决定不 同指 令 集是 否成功 中起 了很 重要的作用。通过保持与 现 有 处理 器的 兼容性, In tel 以及 x86 使得从 一代处理 器迁移到下一代变得很 容 易。 由 于集成 电路 技术的进步, In t el 和其他 x86 处理 器制造 商能够克服原来 8086 指令集设计造成的低效率,使 用 RISC 技 术产 生出 与 最好的 RISC 机 器相 当的性能。正如 我们在笫 3. 1 节中看到的那样 , I A32 发 展 演 变到 x86-64 提 供 了 一个机会,使 得能够将 RISC 的一些特性结合到 x86 中。在桌面、 便 携 计 算机 和基于服务 器的 计 算领域里,\nx86 已经占据 了 完全 的统治地 位。\nRISC 处理 器在 嵌入 式处理器市场上表现得非 常出 色,嵌 入式处理 器 负 责控制移动电话、汽车刹车以 及 因特 网电 器等 系统 。 在 这些应用 中,降 低 成本和功耗比保持后向兼容 性 更重要。就出售的处理器数 量来说, 这是个非常广阔而迅 速 成长着的 市场。\n4. 1. 4 Y86-64 异 常 # 对 Y86-64 来说, 程序员可见的状态(图4-1 ) 包 括 状 态 码 St a t , 它描述程序执行的总体状态。这个代码可能的值如图 4-5 所示。代码值 1, 命名为 AOK, 表示程序执行正常,\n而其他 一些代码则 表示发生了 某种类型的异常。 代码 2 , 命名为 HLT , 表示处理器执行了一条 ha lt 指令。代码 3\u0026rsquo; 命名为 ADR , 表示处理器试图从一个非法内存地址读或者向一个非法内存地址写,可能是当取指令的时候,也\n可能是当读或者写数据的时候。我们会限制最大的地址(确切的限定值因实现而异),任何访问超出这个 限定值的地址都会引发 ADR 异常。代码\n4, 命名为 I NS , 表示遇到了非法的指令代码。\n对于 Y8 6- 64 , 当遇到这些异常的时候,我 图 l - ;i\n们就简单地让处理器停止执行指令。在更完整的设计中,处理器通常会调用一个异常处理程序\nY86-64 状 态码。在我 们的设 计中, 任何 AOK 以 外的代码都会使处理器停止\n(exception handler), 这个过程被指定 用来处理遇到的某种类型的异常。就像在第 8 章中讲述的,异 常处理程序可以被配置成不同的结果, 例如, 中止程序或 者调用一个用户自定义的信号 处理程序 ( s ig n a l h a nd le r ) 。\n4. 1. 5 Y86-64 程 序\n图 4- 6 给出了下 面这个 C 函数的 x8 6- 6 4 和 Y 86- 6 4 汇编代码 :\nlong sum(long *start, long count) 2 { 3 long sum = O; 4 while (count) { 5 sum += *start,· 6 start++; 7 c oun t - - ; 8 } 9 return sum· 10 } x86-64 code Y86-64 code\nlong sum(long *s t ar t , long count) long sum(long•start, long count) start in %rdi, count in %rsi start in %rdi, count in %rsi 1 sum: 1 sum: 2 movl $0, %eax sum= 0 2 irmovq $8,%r8 Constant 8 3 jmp .L2 Goto test 3 irmovq $1,%r9 Constant 1 4 .L3: loop: 4 xorq %rax,%rax sum= 0 5 addq (%rdi), %rax Add *start to sum 5 andq %rsi,%rsi Set CC 6 addq $8, %rdi start++ 6 jmp test Goto test 7 subq $1, %rsi count\u0026ndash; 7 loop: 8 .L2: test: 8 mrmovq (%rdi),壮10 Get *start 9 testq %rsi, %rsi Test sum 9 addq %r10,%rax Add to sum 10 jne .L3 If 1=0, goto loop 10 addq 壮 8 , %r di start++ 11 rep; ret Return 11 subq %r9,%rsi count\u0026ndash;. Set CC 12 test: 13 jne loop Stop when 0 14 ret Return 图 4-6 Y86-64 汇编程序与 x86-64 汇编程序比较。 Sum 函数计算一个整数 数组的和。\nY86-64 代码与 x86-64 代码遵循了相同的通用模式\nx8 6- 64 代码是由 GCC 编译器产生的 。Y8 6- 64 代码与之类似, 但有以下不同点:\nY 8 6- 6 4 将常数加载到寄存 器(第2 3 行), 因为它在算术指令中不能使用立即数。\n要实现从内存读取一个数值并将其与一个寄存器相加, Y8 6-64 代码需要两条指令\n(第 8 9 行),而x8 6- 64 只需要一条 a d d q 指令(第5 行)。\n我们手工编写的 Y86-6 4 实现有一个优势, 即 s ub q 指令(第11 行)同时还设 置了条件码, 因此 GCC 生成代码中的 t e s t q 指令(第9 行)就不是必需的。不过为此, Y8 6-6 4 代码必须 用 a nd q 指令(第5 行)在进入循环之前设 置条件码。\n图 4- 7 给出了用 Y8 6-64 汇编代码编写的一个完整的程序文件的例子。这个程序既包括数据,也 包括指令。伪指令( direct ive ) 指明应该将代码或数据放在什么位置, 以及如何对齐。这个程序详细说明了栈的放置、数据初始化、程序初始化和程序结束等问题。\n# Execution begins at address 0\n2 .pos 0\nirmovq stack, %rsp call main\n#Setup stack pointer\n# Execute main program\n7 # Array halt # Terminate program of 4 elements 8 .align 8 9 array: 10 .quad OxOOOdOOOdOOOd 11 .quad OxOOcOOOcOOOcO 12 .quad OxObOOObOOObOO 13 14 15 main: .quad OxaOOOaOOOaOOO 16 irmovq array,%rdi 17 irmovq $4,%rsi 18 call sum # sum(array, 4) 19 ret 20 21 # long sum(long *start, long count) 22 # start in %rdi, count in %rsi 23 sum: 24 irmovq $8,%r8 # Constant 8 25 irmovq $1, %r9 # Constant 1 26 xorq %rax,%rax #sum= 0 27 andq %rsi,%rsi # Set CC 28 jmp test # Goto test 29 loop: 30 mrmovq (%rdi),%r10 # Get *start 31 addq %r10,%rax # Add to sum 32 addq %r8,%rdi # start++ 33 subq %r9,%rsi # count\u0026ndash;. Set CC 34 test: 35 jne loop # Stop when 0 36 ret # Return 37 38 # Stack starts here and grows to lower addresses 39 . pos Ox200 40 stack: 图4-7 用 Y86-64 汇编代码编写的 一个例子程 序。调用 s um 函数来计算一个具有 4 个元素的数组的和\n在这个程序中, 以 \u0026ldquo;. \u0026quot; 开头的词是汇编器伪 指令( a s s e m b le r directives), 它们告诉汇编器调整地址,以 便 在 那 儿 产 生 代 码或插入一些数据。伪指令 . p o s O( 第 2 行)告诉汇编器应该从 地址 0 处开始产生代码。这个地址是所有 Y 86- 64 程序的起点。接下来的一条指令\n(第3 行)初始化栈指针。我们可以看到程序结尾处(第40 行)声明了标号 s t a c k , 并且用一个 . p o s 伪指令(第3 9 行)指明地址 Ox 2 0 0 。 因 此栈会从这个地址开始,向 低 地 址 增 长 。我们必须 保证栈不会增长得太大以至于覆盖了代码或者其他程序数据。\n程序的 第 8 ~ 1 3 行声明了一个 4 个字的数组,值 分 别 为\nOx OOOd OOOd OOOd OOOd , Ox OOc OOOc OOOc OO Oc O OxObOOObOOObOOObOO, Ox a OOOa OOOa OOOa OOO\n标号 a r r a y 表明了这个数组的起始,并 且 在 8 字节边界处对齐(用.a li g n 伪 指令指定)。\n第 16~ 19行给出了 \u0026quot; ma i n\u0026rdquo; 过程,在过 程中对那个四字数组调用了 s um 函数 ,然 后停 止 。正如例子所示,由 千我们创建 Y8 6- 64 代码的唯一工具是汇编器, 程序员必须执行本\n来通常交 给编译器、链接器和运行时系统来完成的任务。幸好我们只用 Y 8 6- 6 4 来写一些小的程序,对 此一些简单的机制就足够了。\n图 4-8 是 Y A S 的汇编器对图 4- 7 中代码进行 汇编的结果。为了便于理解,汇 编 器的输出结果是 ASCII 码格式。汇编文件中有指令或数据的行上,目 标 代码包含一个地址,后 面跟着 1 ~ 1 0 个 字 节的 值 。\n我们 实现了一个指令集模 拟器,称 为 Y IS , 它的目的是模拟 Y 8 6- 6 4 机器代码程序的执行, 而不用试图去模拟任何具体处理器实现的行为。这种形式的模拟有助于在有实际硬件可用 之前调试程序,也 有 助于检查模拟硬件或者在硬件上运行程序的结果。用 Y IS 运行例子的 目标代码, 产 生如下输出:\nStopped in 34 steps at PC= Ox13 . Status\u0026rsquo;HLT\u0026rsquo;, CC Z=l S=O O=O Changes to r egi st er s :\n%rax: OxOOOOOOOOOOOOOOOO\n%rsp: OxOOOOOOOOOOOOOOOO\n%rdi: OxOOOOOOOOOOOOOOOO\n%r8: OxOOOOOOOOOOOOOOOO\n%r9: OxOOOOOOOOOOOOOOOO\n%r10: OxOOOOOOOOOOOOOOOO\nChanges to memory:\nOx01f0: OxOOOOOOOOOOOOOOOO Ox01f 8 : OxOOOOOOOOOOOOOOOO\nOxOOOOabcdabcdabcd Ox0000000000000200 Ox0000000000000038 Ox0000000000000008 Ox0000000000000001 OxOOOOaOOOaOOOaOOO\nOx0000000000000055 Ox0000000000000013\n模拟输出的第一行总结了执行以及 PC 和程序状态的结果值。模拟器只打印出在模拟 过程中 被改变了的寄存器或内存中的字。左边是原 始值(这里都是 0 )\u0026rsquo; 右 边是最终的值。从输出中 我们可以看到, 寄 存 器 %r a x 的 值 为 Ox a b c d a b c d a b c d a b c d , 即 传 给子函数 s u m 的四元素数组的和。另外, 我们还能看到栈从地址 Ox 2 0 0 开始,向 下 增 长 ,栈 的 使 用 导 致内存地址 Ox lf O Ox lf 8 发 生了变化。可执行代码的最大地址为 Ox 0 9 0 , 所以数值的入栈和出栈不 会破坏可执行代码。\n练习题 4. 3 机器级程序 中 常见的模式之一是 将一个常 数值 与 一个寄存器相加。利用目前 已 给 出 的 Y 8 6- 6 4 指令, 实 现这个操作需 要 一条 ir mo v q 指令把 常数加载 到 寄存器, 然后 一条 a d d q 指令把这个寄存器值 与 目标 寄存器值相加。假设我 们 想增加一条新指 令 i a d d q , 格式如下:\n字节 0 1 2 5 6\niaddq V, rB I c I O I F IrB I V\n该指令 将常数值 V 与 寄存 器 rB 相加。\n使用 i a d d q 指令 重写 图 4- 6 的 Y 8 6- 64 s u m 函 数。 在 之前 的代码 中, 我们 用寄存器%r 8 和%r 9 来保 存常数值。 现在 ,我们 完全 可以 避免使用 这些寄 存器。\nOxOOO:\n# Execution begins at address 0\n.pos 0\nOxOOO: 30f40002000000000000 OxOOa: 803800000000000000\nOx013: 00\nOx018:\nOx018:\nOx018: OdOOOdOOOdOOOOOO Ox020: cOOOcOOOcOOOOOOO Ox028: OOObOOObOOObOOOO Ox030: OOaOOOaOOOaOOOOO\nOx038:\nOx038: 30f71800000000000000 Ox042: 30£60400000000000000 Ox04c: 805600000000000000\nOx055: 90\nirmovq stack, %rsp call main\nhalt\n# Array of 4 elements\n.align 8\narray:\n.quadOxOOOdOOOdOOOd\n.quad OxOOcOOOcOOOcO\n.quad OxObOOObOOObOO\n.quad OxaOOOaOOOaOOO\nmain:\nirmovq array,%rdi irmovq $4,%rsi call sum\nret\n#Setup stack pointer\n# Execute main program\n# Terminate program\n# sum(array, 4)\n# long sum(long *start, long count)\nOx056:\n# start in %rdi, count in 1r儿 s i sum:\nOx056: 30f80800000000000000 Ox060: 30f90100000000000000 Ox06a: 6300\nOx06c: 6266\nOx06e: 708700000000000000 Ox077:\nirmovq $8,%r8 irmovq $1,%r9 xorq %rax,%rax andq %rsi,%rsi jmp test\nloop:\n# Constant 8\n# Constant 1\n#sum= 0\n# Set CC\n# Goto test\nOx200: Ox200:\n# Stack starts here and grows to lower addresses\n.pos Ox200 stack:\n图4-8 YAS 汇编器的输出 。每一行包含一个十六进制的地址 , 以及字节数在 1~ 10 之间的目标代码\n匹 练习题 4. 4 根据下面的 C 代码 , 用 Y86- 64 代码来实现一个 递 归 求和函 数 r s um:\nlong rsum(long *start, long count)\n{\nif (count \u0026lt;= 0) return O;\nreturn *start+ rsum(start+l, count-1);\n}\n使用 与 x86- 64 代码相同的参数传递和寄存器保存 方 法。 在 一 台 x86- 64 机器上编\n译这 段 C 代码, 然后再把那些指 令翻译成 Y86- 64 的指令, 这样做可 能会很有帮助 。练习题 4. 5 修 改 s um 函数的 Y8 6- 64 代码(图 4-6) , 实现函 数 a b s Sum, 它计算一个数组的绝对值的和。在内循环中使用条件跳转指令。\n练习题 4. 6 修改 s um 函 数的 Y8 6- 64 代码(图 4- 6 ) , 实 现函 数 a b s Sum,\n数组的绝对值的和。在内循环中使用条件传送指令。\n它计算一个\n1. 6 一 些 Y86 -6 4 指 令的 详情\n大多数 Y8 6- 64 指令是以一种直接明了的方式修改程序状态的,所 以 定 义 每条指令想要达到的结果并不困难。不过,两个特别的指令的组合需要特别注意一下。\npus hq 指令会把栈指针减 8 , 并且将一个寄存器值写入内存中。因此, 当 执 行 p u s h q\n%rs p 指令时 ,处 理 器 的 行 为是不确定的, 因 为要人栈的寄存器会被同一条指令修改。通常有两种不同的约定: 1 ) 压入%r s p 的 原 始 值 , 2 ) 压入减去 8 的%r s p 的 值 。\n对千 Y86- 64 处理器来说, 我们采用和 x86-64 一样的做法, 就 像 下 面这个练习题确定出的那样。\n练习题 4. 7 确定 x86-64 处理器上指令 pus hq %r s p 的行为。 我们 可以通过阅读 In t el 关于这条指令的文档来了解它们的做法,但更简单的方法是在实际的机器上做个实 验。 C 编译器正常情况下是不会 产生这 条指令的, 所以 我们 必须用 手 工 生 成的 汇编 代码 来完成 这一任务。下面是我 们 写 的 一个测 试程 序(网 络旁 注 ASM : EAS M , 描绘如何编 写 C 代码和手写 汇编 代码结合的程序):\n.text\n.globl pushtest pushtest:\nmovq %rsp, %rax Copy stack pointer pushq %rsp Push stack pointer popq %rdx Pop it back subq %rdx, %rax Return O or 8 ret 在实验中 , 我们发现函数 p u s h 七e s t 总是 返回 o, 这表 示在 x86- 64 中 p u s h q %rsp\n指令的行为是怎样的呢?\n对 po pq %r s p 指 令 也 有 类 似的歧义。可以将%r s p 置为从内存中读出的值, 也 可 以 置为加了增量 后的栈指针。同 练习题 4. 7 一样, 让 我们做个实验来确定 x86-64 机器是怎么处理这条指 令的 ,然 后 Y86- 64 机器就采用同样的方法。\n练习题 4. 8 下面这个汇编 函 数让 我们确定 x86-64 上指 令 popq %r s p 的行为 :\n. t ext\n.globl poptest poptest:\n4 movq %rsp, r% di Save stack pointer\n5 pushq $0xabcd Push test value\n6 popq %r s p Pop to stack pointer\n7 movq 。r1/. s p, %rax Set popped value as return value\n8 movq\n9 ret\nr% di , %rsp Restore stack pointer\n我们发现函数总是返回 Oxa b c d 。 这表 示 po pq %r s p 的行为 是怎样的? 还有什 么\n其他 Y86-64 指令也会有相同的行为吗?\n日 日 正 确了解细节: x86 模型间的 不一致\n练习题 4. 7 和练 习题 4. 8 可以 帮 助我们确定对于压入和弹出 栈 指 针指令的 一致惯例。看上去似乎没有理由会执行这样两种 操 作, 那么一个很 自 然的 问题就是“ 为什 么要担心这样一些吹毛求疵的细节呢?”\n从下面 In tel 关 于 P US H 指令的文档[ 51] 的节 选中, 可以 学到 关 于这个一致的重要性的有用的 教训:\n对于 IA-32 处理器,从 Inte l 286 开始 , P US H ESP 指令将 ESP 寄存 器的 值压入栈\n中,就 好 像 它存 在于这 条指令被执行之前。(对于 Intel 64 体 系结构、IA-32 体系结 构的实地 址模式和虚 8086 模 式 来说 也 是这样。)对于 I ntel ® 8 086 处理 器, P US H SP 将 SP寄存器的 新值压入栈中(也就是减去 2 之后的值)。( P US H ESP 指令。Intel 公 司。50 。)虽然这个说明的具体细节可能难以理解,但是我们可以看到这条注释说明的是当执\n行压入栈指针寄存器指 令时, 不同型号的 x86 处理器会 做 不同的事情。有些会压入原始的值,而有些会压入减去后的值。(有趣的是,对于弹出栈指针寄存器没有类似的歧 义。)这种不一致有两个缺 点:\n它降 低 了代 码 的可移植 性。取 决于处理器模 型, 程序可能会有不 同的行为。 虽 然这样特殊的指令并不常见,但 是 即 使 是 潜在的不兼容也可能带 来严 重的后果。 它增加了文档的复杂性。正如在这里我 们看到的那样 , 需要一个特别的说明来澄清这些不同之 处。即使没有这样的特殊情况, x86 文档就已经够复杂的 了。\n因此我们的结论是,从长远来看,提前了解细节,力争保持完全的一致能够节省很多的麻烦。\n4. 2 逻辑设计和硬件控制语言 HCL\n在硬件设计中, 用电 子电路来计算对位进行运算的函数,以 及 在 各 种 存 储 器 单 元 中存储 位 。 大 多 数 现代电路技术都是用信号线上的高电压或低电压来表示不同的位值。在当前的技术 中 , 逻辑 1 是用 1. 0 伏特左右的高电压表示的 ,而 逻辑 0 是用 0. 0 伏特左右的低电压表示的。要实现一个数字系统需要三个主要的组成部分:计 算 对 位 进行操作的函数的组合 逻辑、存储位的存储器单元,以 及 控 制 存 储 器单元更新的时钟信号。\n本节简要描述这些不同的组成部分。我们还将 介绍 HCL ( Hardware Cont rol Lan­\nguage, 硬件控制语言),用这种语言来描述不同处理器设计的控制逻辑。在此我们只是简略地描述 HCL , H CL 完整的参考请见网络旁注 ARC H : H CL 。\n日 日 现代逻辑设计\n曾经,硬件设计者通过描绘示意性的逻辑电路图来进行电路设计(最早是用纸和笔, 后来是用计 算机图形终 端)。现在, 大多数设 计都是用硬件描 述语言( H ard ware Description\nLanguage, H DL) 来表达的。H DL 是一种 文本表 示, 看上去和编程语言类似, 但 是 它是用来描 述硬件结构而不是 程序行为的。最常用的 语言是 Verilog , 它的 语法类似于 C ; 另一 种是 V H DL , 它的 语法类似 于编程语言 Ada。这些语 言本来都是 用 来表 示数 字电 路的模拟 模型的 。20 世 纪 80 年代中期,研 究者开发 出 了 逻 辑合成 (l ogic synt hes is ) 程序, 它可 以根据 H DL 的描述生成 有效的电路设 计。现在有许多 商用的 合成程序, 已经成为产生 数宇电路 的主要技术。从手工设 计电路到合成生成 的转变就 好 像 从 写 汇编程序到 写 高级语言程序, 再 用编 译器来 产 生机 器代码的转变一 样。\n我们的 HCL 语言只表达硬件设计的控制部分, 只有有限的操作集合 ,也 没有模块化。不过, 正如我们会看到的 那样,控 制逻辑是设计微处理器中 最难的部分。我们已经开发出 了将 HCL 直接翻译成 Verilog 的工具 , 将这个代码与基本硬件单元的 Verilog 代码结合起来, 就能产生 H DL 描述,根 据 这个 H DL 描述就可以合成实际能 够工作的 微处理器。通过小心地分离、设计和测试控制逻辑,再加上适当的努力,我们就能创建出一个可以工作的微 处理器。 网络 旁注 ARCH : VLOG 描述了如何 能产生 Y86-64 处理器的 Verilog 版本。\n4. 2. 1 逻辑门\n逻辑门 是数字电路的基本计算单元。它们产生的输出, 等 千它们输入位值的某个布尔函数。图 4-9 是 布尔函数 AND 、OR 和 NO T 的标 准符号, C 语 言 中 运算符 ( 2. 1. 8 节)的逻辑门下面是对应的 HCL 表达式: A ND 用 && 表示, O R 用 1 1 表示, 而 NOT 用! 表\n示。我 们用这些符号而不用 C 语言中的位运算符 &、 I 和~ , 这是因为逻辑门只对单个\n位的数进行 操作,而 不 是 整个字。虽然图中只说明了 AND 和 OR 门的 两个输入的版本, 但是常 见的是它们作为 n 路操作, n\u0026gt; 2。不过,在 H CL 中 我们还是把它们写作二元运算符, 所以 , 三个输入的 AND 门 ,输 入为 a 、 And OR NOT\nb 和 c , 用 H CL 表示就是 a \u0026amp;\u0026amp;b \u0026amp;\u0026amp;c 。\n逻辑门 总是活动的 ( active ) 。一旦 一个门的输入变化了,在很短的时间内,输出就会\n:0-out\n输出=a\u0026amp;\u0026amp;b D- out a 令 o- out\n输出=a l lb 输出=!a\n相应地 变化。\n2. 2 组 合 电 路 和 HCL 布 尔 表 达 式 图 4- 9 逻辑门类型。每个门产生的输出\n等于它输入的某个布尔函数\n将很多 的逻辑门组合成一个网,就 能 构 建 计 算 块( computa tional block), 称为组合电\n路( combinational circ uits ) 。如何构建这些网有几个限制:\n·每个逻辑门的输入必须连接到下述选项之一: 1 ) 一个系统输入(称为主输入), 2 ) 某个存储器单元的输出, 3 ) 某 个 逻辑门的输出。\n两个或多个逻辑门的输出不能连接在一起。否则它们可能会使线上的信号矛盾, 可能会导致一个不合法的电压或电路故障。\n·这个网必须是无环的。也就是在网中不能有路径经过一系列的门而形成一个回路, 这样的回路会导致该网络计算的函数有歧义。\n图 4-10 是一个 我们觉得非常有用的简单组合电路的例子。它有两个输入 a 和 b , 有唯一的输 出 eq , 当 a 和 b 都是 1 ( 从上面的 AND 门可以看出)或都是 0 ( 从下面的 AND 门可以看出)时, 输出为 1。用 HCL 来写这个网的函数就是:\nbool eq = (a \u0026amp;\u0026amp; b) 11 (!a \u0026amp;\u0026amp; !b);\n这段代码简单 地定义了位级(数据类型 b o o l 表明了这一点)信号e q , 它是输入 a 和 b 的函数。从这个例子可以看出 HCL 使用了 C 语言风格的语法,`= '将一个信号名与一个表达式联系起来。不过同 C 不一样, 我们不把它看成执行了一次计算并将结果放入内存中某个位置。相反,它只是给表达式一个名字。\n比囡 练习题 4. 9 写 出信 号 xor 的 HCL 表达 式, x o r 就是 异或 , 输入 为 a 和 b。信号 xo r\n和上面定 义的 e q 有什 么关 系?\n图 4-11 给出了另一个简单但很有用的 组合电路, 称为多路复用 器 ( m ultiplexo r , 通常称为 \u0026quot; M U X\u0026quot; ) 。多路复用器根据输入控制信号的值,从 一组不同的数据信号中选出一个。在这个单个位的多路复用器中, 两个数据信号是输入位 a 和 b , 控制信号是 输入位 s 。当 s 为 1 时, 输出等于 a ; 而当 s 为 0 时, 输出等于 b。在这个电路中, 我们可以看出两个A N D 门决定了是否将它们相对应的数据输入传送到 OR 门。当 s 为 0 时, 上面的 AND 门 将传送信号 b( 因为这个门的另一个输入是!s )\u0026rsquo; 而当 s 为 1 时,下 面的 AND 门将传送信号 a 。接下来,我 们来写输出信号的 HCL 表达式 , 使用的就是组合逻辑中相同的 操作:\nbool out= (s \u0026amp;\u0026amp; a) I I (!s \u0026amp;\u0026amp; b);\na\ne q\nOU 七\n图 4-10 检测位相等的组 合电路。当输入都为 0 图 4- 11 单个位的多路复用器电路。如果控制信号或都为 1 时, 输出等于 1 s 为 1 . 则 输出 等 千输 入 a ; 当 s 为 0\n时 ,输 出 等 于 输 入 b\nH CL 表达式很清楚地表明了组合逻辑电路和 C 语言中逻辑表达式的对应之处。它们都是用布尔操作来对输入进行计算的函数。值得注意的是,这两种表达计算的方法之间有 以下区别:\n因为组合电路是由一系列的逻辑门组成, 它的属性是输出会持续地响应输入的变化。如果电路的输入变化了,在一定的延迟之后,输出也会相应地变化。相比之 下, C 表达式只 会在程序执行 过程中被 遇到时才进行 求值。\nC 的逻辑表达式允 许参数是任意整数, 0 表示 F ALSE , 其他任何值都表示 T RUE。而逻辑门 只对位值 0 和 1 进行操作。\nC 的逻辑表达式有个属性就是它们可能只被部分求值。如果一个 AND 或 OR 操作的 结果只用对第一个参数求 值就能确定, 那么就不会对第二个参数求值了。例如下面的 C 表达式:\n(a \u0026amp;\u0026amp; !a) \u0026amp;\u0026amp; func(b,c)\n这里函数 f u n c 是不会被调用的 , 因为表达式 ( a \u0026amp;\u0026amp; ! a ) 求值为 0 。而组合逻辑没有部分求值这条规则,逻辑门只是简单地响应输人的变化。\n2. 3 字级的组合电路和 HCL 整数表达式\n通过将逻辑门组合成大的网,可以构造出能计算更加复杂函数的组合电路。通常,我 们设计能对数据字( wo rd ) 进行操作的电 路。有一些位级信号, 代表一个 整数或一些控制模\n式。例如 , 我们的处 理器设计将包含有很 多字,字的 大小的范围为 4 位到 64 位, 代表整数、地址、指令代码和寄存器标识符。\n执行字级计算的组合电路根据输入字的各个位 ,用 逻辑门 来计算输出 字的各个位。例如图 4-12 中的一个组合电路, 它测试两个 64 位字 A 和 B 是否相等。也就是, 当且仅当 A 的每一位都和 B 的相应位相等时, 输出才 为 1。这个电 路是用 64 个图 4-10 中所示的单 个位相等电路实现的。这些单个位电路的输出用一个AND 门连起来 , 形成了这个电路的输出。\n:二勹A B\na ) 位级实现 b ) 字级抽象\n图 4-12 字级相等测试电路。当字 A 的 每一位与字 B 中相应的位均相等时, 输出等于 1。字级相等是 HCL 中的一个 操作\n在 HCL 中, 我们将所有字级的信号都声明为 i n t , 不指定字的大小。这样做是为了简单。在 全功能的硬件描述语言中, 每个 字都可以声明为有特定的位数。HCL 允许比较字是否相等, 因此图 4-12 所示的电 路的函数可以在字级上表达成\nbool Eq = (A == B) ;\n这里参数 A 和 B 是 i n t 型的。注意我们使用 和 C 语言中一样的语法习惯,'= '表示赋值,而\u0026rsquo;==\u0026lsquo;是相等运算符。\n如 图 4-1 2 中右边所示 , 在画字级电路的时候, 我们用中等粗度的线来表示携带字的\n每个位的线路,而用虚线来表示布尔信号结果。\n练习题 4. 10 假设你用练习题 4. 9 中的异或电路而不是位级的相等电路来实现一个 字级的相等电路。设计一个 64 位字的相等电路需要 64 个字级的异或电路, 另外还要两个逻辑门。\n图 4-13 是字级的多路复用器电路。这个电路根据控制输入位 s , 产生一个 64 位的字\nOut, 等于两个输入字 A 或者 B 中的一个。这个电路由 64 个相同的 子电路组成, 每个子电路的结构都类似于图 4-11 中的位级多路复用器。不过这个字级的电路并没有简单地复制\n64 次位级多 路复用器, 它只产生一次! s , 然后在每个位的地方都重复使用它,从而减少反相器或非门 ( inver t ers )的数量。\n处理器中会用到很多种多路复用器,使得我们能根据某些控制条件,从许多源中选出 一个字。 在 HCL 中,多 路复用函数是用情况表 达式 ( cas e ex pres sion ) 来描述的。情况 表达式的通用格式如下:\n[ select1 select2 exp r1 ; expr2; selectk exprk; ] 这个表达式包含一系列的情况, 每种情况 i 都有一个布尔表达式 sel ect ; 和一个整数表达式 ex p r ; , 前者表明什么时候该选择这种情况,后者指明的是得到的值。\ns\nb 63\na 63\nout 63\nb 62\nout 62\n二., \u0026hellip;丑..、Out\nint Out= [ s A;\n1 : B;\nb 。\nou t 。\na 。\n];\nb ) 字级抽象\n图 4-13 字级多路复用器电路。当控制信号 s 为 1 时, 输出会等于输人字 A,\n否则等于 B。HCL 中用情况( case) 表达式来描述多路复用器\n同 C 的 S W工t c h 语句不同,我 们不要求不同的选择表达式之间互斥。从逻辑上讲,这些选择表达式是顺序求值的, 且第一个求值为 1 的情况会被选中。例如, 图 4-13 中的字级多路 复用器用 HCL 来描述就是:\nword Out= [\ns: A;\n1: B;\n];\n在 这段 代 码 中 ,第 二个选择表达式就是 1, 表明如果前面没有情况被选中,那就选择这种情况。这是 HCL 中一种指定默认情况的方法。几乎所有的情况 表达式都是以此结尾的。\n允许不互斥的选择表达式使得 HCL 代码的可读性更好。实际的硬件多路复用器的信号必须互斥,它 们要控制哪个输入字应该被传送到输出,就像 图 4-13 中的信号 s 和 ! s 。要将一个 HCL 情况表达式翻译成硬件, 逻辑合成程序需要分析选择表达式集合,并 解决任何可能的冲突,确保只有第一个满足的情况才会被选中。 s1\n选择表达式可以是任意的布尔表达式,可以有任意 s 0\n多的情况。这就使得情况表达式能描述带复杂选择标准 D\n的、多 种输入信号的块。例如, 考虑图 4-14 中所示的四 B\n路复用器的图。这个电路根据控制信号 s l 和 s 0 , 从 4\nOut 4\n个输入字 A、B、C 和 D 中 选择一个, 将控制信号看作一个两位的二进制数。我们可以用 HCL 来表示这个电路, 用 布尔表达式描述控制位模式的不同组合:\nword Ou t 4 = [\n!s1 \u0026amp;\u0026amp; !s0 : A; # 00\n图 4-14 四路复用器。控制信号 s l 和s 0 的不同组合决 定了哪个数据输人会被传送到输出\n! s1 : Bi # 01\n! s0 : C; # 10 D; # 11\n];\n右边的 注释(任何以#开头到行尾结 束的文字都是注释)表明了 s l 和 s 0 的什么组合会导致该种情况会被选中。可以看到选择表达式有时可以简化,因为只有第一个匹配的情况 才会被选中。例如,第二个表达式可以写成!sl, 而不用写得更完整! s l \u0026amp;\u0026amp; s0, 因为另一种可能 s l 等于 0 已经出现在了第一个选择表达式中 了。类似地 , 第三个表达式可以写作\n!s0, 而第四个可以简单地写成 1。\n来看最 后一个例子, 假设我们想设计一个逻辑电 路来找一组字 A、B 和 C 中的最小值,\n;三 贮n 3\n用 HCL 来表达就是 :\n欢 or d Min3 = [\nA\u0026lt;= B \u0026amp;\u0026amp; A\u0026lt;= C : A;\nB \u0026lt;= A \u0026amp;\u0026amp; B \u0026lt;= C: B; C;\n];\n区 }练习题 4. 11 计算 三个 字中最 小值的 H C L 代码包 含了 4 个形如 X\u0026lt; = Y 的比 较表达 式。重写代码计算同样的结果,但只使用三个比较。\n练习题 4. 12 写 一个 电 路的 H CL 代码, 对于输入 宇 A、 B 和 C , 选择中间值。也就是, 输出等 于三个输入 中居 于最小值 和最 大值 之间的 那个 字。\n组合逻辑电路可以设计成在字级数据上执行许多不同类型的操作。具体的设计已经超\n出了我们讨论的范围。算术/逻辑单元 ( AL U )是一种很重要的组 合电路,图 4-15 是它的一个抽象 的图示。这个电路有三个 输入: 标号为 A 和 B 的两个数据输入, 以及一个控制输人。根据控制输入的设置,电路会对数据输入执行不同的算术或逻辑操作。可以看到,这个 ALU 中画的四个操作对应于 Y86-64 指令集支持的四 种不同的整数操作, 而控制值和这 些操作的功能码相对应(图4-3) 。我们还注意到减法的 操作数顺序, 是输入 B 减去输入 A。之所以这样做, 是为了使 这个顺序与 sub q 指令的参数顺序一致。\n图 4- 15 算术/逻辑单元 ( ALU) 。根据函数输入的设 置,该 电路会执行四种算术和逻辑运算中的一 种\n2. 4 集合关系\n在处理器设计中,很多时候都需要将一个信号与许多可能匹配的信号做比较,以此来 检测正在处理的某个指令代码是否属千某一类指令代码。下面来看一个简单的例子, 假设想从一 个两位信号 c o d e 中选择高位和低位来 为图 4-14 中的四路复用器产生信号 s 1 和 s 0 ,\n如下图所示:\nco de王丑:;\n}- Out4\n在这个电路中, 两位的信号.cod e 就可以用来控制 对 4 个数据字 A、 B、C 和 D 做选择。根据可能的 c od e 值, 可以用相等测试来表示信号 s l 和 s 0 的产生:\nbool s1 =code== 2 II code== 3;\nbool s0 = code == 1 11 code == 3;\n还有一 种更简洁的方式来表示这样的属性: 当 c o d e 在集合{ 2 , 3 }中时 s 1 为 1, 而\nc o d e 在集合{ 1, 3 } 中时 s 0 为 1 :\nbool s1 = code in { 2, 3 };\nbool s0 = code in { 1, 3 };\n判断集 合关系的通用 格式是:\niexpr in {ie 工 Pr1 , iex p r 2 , …,iex p r k }\n这里被测试的值 i ex p r 和待匹配的值 ie x p r 1 ~ ie x p r k 都是整数表达式。\n2. 5 存储器和时钟\n组合电路从本质上讲,不存储任何信息。相反,它们只是简单地响应输入信号,产生等 于输入的某个函数的输出。为了产生时序 电路 ( sequential c订cu it ) , 也就是有状态并且在这个状态上进行计算的系统,我们必须引入按位存储信息的设备。存储设备都是由同一个时钟控制 的,时钟是一个周期性信号,决定什么时候要把新值加载到设备中。考虑两类存储牉设备:\n时钟寄存 器(简称寄存 器)存储单个位或字。时钟信号控制寄存器加载输入值。 随机访问存储 器(简称内存)存储多 个字,用 地址来选择该读或该写哪个字。随机访问存储器的例子包括: 1 ) 处理器的虚 拟内存系统 , 硬件和操作 系统软件结合起来使处理器可以在一个很大的地址空间内访问任意的字; 2 ) 寄存器文件,在 此,寄存器标识符作为地址 。在 IA32 或 Y86-64 处理器中, 寄存器文件有 15 个程序寄存 器(%\nr a x ~ %r l 4) 。\n正如我们看 到的那样, 在说到硬 件和机器级编程时 ," 寄存器” 这个词是两个有细微差别的事情。在硬件中 , 寄存器直接将它的输入和输出线连接到电路的其他部分。在机器级 编程中 , 寄存器代表的是 CPU 中为数不多的可寻址的字, 这里的地址是 寄存器 ID。这些字通常都存在寄存器文件中,虽然我们会看到硬件有时可以直接将一个字从一个指令传 送到另一个指令,以避免先写寄存器文件再读出来的延迟。需要避免歧义时,我们会分别 称呼这两类寄存器为“硬件寄存器”和“程序寄存器”。\n图 4-1 6 更详细地说明 了一个硬件寄存器以及它是如何工作的。大多数时候, 寄存器都保待在稳定状态(用x 表示), 产生的输出等千它的 当前状态。信号沿着寄存器前面的组合逻辑传播, 这时, 产生了一个新的寄存器输入(用 y 表示), 但只要时钟是低电位的,寄存祥的输出就仍然保持不变。当时钟变成高电位的时候,输入信号就加载到寄存器中,成 为下一个状态 y , 直到下一个时钟上升沿, 这个状态就 一直是寄存器的新输出。关键是寄\n存器是作为电路不同部分中的组合逻辑之间的屏障。每当每个时钟到达上升沿时,值才会 从寄存器的 输入传送到输出。我们的 Y86-64 处理器会用时钟寄存器保存程序计数 器(PC)、条件代码 CCC) 和程序状态( S t at ) 。\n状态=X 状态=Y\n输入- 勹 一 二 输出 Y\n图 4-16 寄存器操作。寄存器输出会一直保 持在当前寄存 器状态上 , 直到时 钟信号上升。当时钟上升时,寄存器输人上的值会成为新的寄存器状态\n下面的图展示了一个典型的寄存器文件:\n读端口\nsr cAI A\nvalB\n言 B\n寄存器文件 w\nvalW\nd s t W 写端口\n时钟\n寄存器文件有两个 读端口 CA 和 B)\u0026rsquo; 还有一个写 端口 CW) 。这样一个多端口随 机访问存储器允 许同时进行多个读和写操作。图中所示的寄存器文件中, 电路可以读两个程序寄存器的值,同时更新第三个寄存祥的状态。每个端口都有一个地址输入,表明该选择哪个 程序寄存器, 另外还有一个数据输出或对应该程序寄存器的输入值。地址是用图 4-4 中编码表示 的寄存器标识符。两个读端口有地址 输入 sr c A 和 sr c B( \u0026quot; so urce A\u0026quot; 和 \u0026quot; so ur ce B\u0026quot; 的缩写)和数据输出 v a l A 和 v a l B( \u0026quot; va lue A\u0026quot; 和 \u0026quot; va lue B\u0026quot; 的缩写)。写端口有 地址输入dstw(\u0026ldquo;destination W\u0026rdquo; 的缩写), 以及数据输入 v a l W( \u0026quot; val ue W\u0026quot; 的缩写)。\n虽然寄存器文件不是组合电路,因为它有内部存储。不过,在我们的实现中,从寄存 器文件读数 据就好像它是一个以地址为输入、数据为输出的一个组合逻辑 块。当 s r c A 或s r c B 被设成某个寄存器 ID 时,在 一段延迟之后 , 存储在相应程序寄存器的值就会出现在va l A 或 v a l B 上。例如 , 将 sr c A 设为 3\u0026rsquo; 就会读出程序寄存器%r b x 的值, 然后这个值就会出现在输出 va l A 上。\n向寄存器文件写入字是由时钟信号控制的,控 制方式类似于将值加载到时钟寄存 器。每次时钟上升时 , 输入 va l W 上的值会被写入输入 ds t W 上的寄存器 ID 指示的程序寄存器。当ds t W 设为特殊的 ID 值 Ox F 时 , 不会写任何程序寄存器。由于寄存 器文件既可以读也 可以写, 一个很自然的间题就是“如果我们试图同时读和写同一个寄存器会发生什么?"答案简单明了: 如果更新一个寄存器,同时在读端口上用同一个寄存器 ID, 我们会看到一个从旧值到新值的变化。当我们把这个寄存器文件加入到处理器设计中,我们保证会考虑到这个属性的。\n处理器有一个随机访问存储器来存储程序数 据, 如下图所示:\n数据输出\n时钟\n地址数据输入\n这个内存有一个地址输入,一个写的数据输入,以及一个读的数据输出。同寄存器文件 一样, 从内存中读的操作方式类似于组合逻辑 : 如果我们在输入 a ddr e s s 上提供一个地址,\n并将 wr i t e 控制信号设置为 o, 那么在经过一些延迟之后,存储在那个地址上的值会出现在\n输出 d a 七a 上。如果地址超出了范围 , e rr or 信号 会设置为 1 , 否则就设置为 0。写内存是由时钟控制的: 我们将 a dd r e s s 设置为期望的地址 , 将 da t a i n 设置为期望的值 , 而 wr i t e 设置为 1。然后当我们控制时钟时 ,只 要地址是合法的, 就会更新内存中指定的位置。对于读操作来说, 如果地址是不合法的 , e rr or 信 号会被设置为 1。这个信号是由 组合逻辑产生的 , 因为所需要的边界检查纯粹就是地址输入的函数,不涉及保存任何状态。\n囚 日 现实的存储器设计\n真实微处理器中的存储器系统比我们在设计中假想的这个简单的存储器要复杂得 多。它是由几种形式的硬件存储器组成的,包括几种随机访问存储器和磁盘,以及管理 这些设备的各种硬件和软件机 制。存储器 系统的设计和特点 在第 6 章中描 述。\n不过,我们简单的存储器设计可以用于较小的系统,它提供了更复杂系统的处理器和存储器之间接口的抽象。\n我们的处理器还包括另外一个只读存储器,用来读指令。在大多数实际系统中,这两个存储器被合并为一个具有双端口的存储器: 一个用来读指令,另 一个用来读或者写数据。\n4. 3 Y86-64 的 顺 序实现\n现在已经 有了实现 Y86- 64 处理器所需要的部件。首先, 我们描述一个称为 SE Q ( \u0026quot; se­ q ue n tia l\u0026quot; 顺序的)的处理器。每个时钟周期 上, S E Q 执行处理一条完整指令所需的所有步骤。不过,这需要一个很长的时钟周期时间,因此时钟周期频率会低到不可接受。我们开 发 SEQ 的目标就是提供实现 最终目的的第一步, 我们的最终目的是实现一个高效的、流水线化的处理器。\n3. 1 将处理组织成阶段\n通常,处理一条指令包括很多操作。将它们组织成某个特殊的阶段序列,即使指令的 动作差异很大, 但所有的指令都遵循统一 的序列。每一步的具体处理取决于正在执行的指令。创建这样一个框架, 我们就能 够设计一个充分利用硬件的处理器。下面是关 千各个阶段以及各阶段内执行操作的简略描述:\n取指( fe tch ) : 取指阶段从内存读取指令字节, 地址为程序计数器( PC) 的值。从指令中抽取出指令指示符字节的两个四位部分, 称为 乓o d e ( 指令代码)和辽u n ( 指令功能)。它可能取出一个寄存器指示符字节,指明一个或两个寄存器操作数指示符 r A 和 r B。它还可能取出一 个四字节常数字 v a l e 。它按顺序方式计算当前指令的下一条指令的地址 va l P。也就是说, v a l P 等于 PC 的值加上巳取出指令的长 度。\n译码( d ecode ) : 译码阶段从寄存器文件读入最多两个 操作数, 得到值 va l A 和/或 va lB,\n通常,它 读入指令r A 和 r B 字段指明的寄存器, 不过有些指令是读寄存器r% s p 的。\n执行( exec ute ) : 在执行阶段, 算术/逻辑单元( ALU ) 要么执行指令指明的操作(根据 江u n 的值),计算 内存引用的 有效地址 , 要么增加或减少栈指针。得到的值 我们称为 v a l E。在此 ,也 可能设置条件码。对一条条件传送指令来说, 这个阶段会检验条件码和传送条件(由辽u n 给出), 如果条件成立, 则更新目标寄存器。同样,\n对一条跳转指令来说,这个阶段会决定是不是应该选择分支。\n访存( m em o r y ) : 访存阶段可以将数据写入内存,或者从内存读出数据。读出的值\n为 va l M。\n写回 ( w rit e back) : 写回阶段最多可以写两个结果到寄存 器文件。\n更新 PC(PC update): 将 PC 设置成下一条指令的地址。\n处理器无限循环, 执行这些阶段。在我们简化的实现中 , 发生任何异常时, 处理器就会停止 : 它执行 ha lt 指令或非法指令,或 它试图读或者写非法地址。在更完整的设 计中, 处理器 会进入异常处理模式, 开始执行由异常的类型决定的特殊代码。\n从前面的讲述可以看出, 执行一条指令是需要进行很多处理的。我们不仅必须执行指令所表明的操作,还必须计算地址、更新栈指针,以及确定下一条指令的地址。幸好每条 指令的整个流程 都比较相似。因为我们想使硬件数量尽可能少, 并且最终将把它映射到一个二维的集成电路芯片的表面,在设计硬件时,一个非常简单而一致的结构是非常重要 的。降低复杂 度的一种方法是让不同的指令共享尽 量多的硬件。例如, 我们的每个处 理器设计都只 含有一个算术/逻辑单元 , 根据所执行的指令类型的不同, 它的使用方式也不同。在硬件上复制逻辑块的成本比软件中有重复代码的成本大得多。而且在硬件系统中处理许 多特殊情况和特性要比用软件来处理困难得多。\n我们面临的一个挑战是将每条不同 指令所需要的计算放入到上述那个通用 框架中。我们会使用图 4-1 7 中所示的代码来描述不同 Y8 6-64 指令的处理。图 4-18 ~ 图 4- 21 中的表描述了不同 Y8 6-6 4 指令在各个阶段是怎样处理的。很值得仔细研究一下这些 表。表中的这种格式很容易映射到硬件。表中的每一行都描述了一个信号或存储状态的分配(用分配操 作- 来表示)。阅读时可以把它看成是从上至下的顺序求值。当我们将这些计算映射到硬件时,会发现其实并不需要严格按照顺序来执行这些求值。\n1 OxOOO: 30f20900000000000000 I irmovq $9, %rdx 2. OxOOa: 30f31500000000000000 I irmovq $21, %rbx 3 Ox014: 6123 I subq %rdx, %rbx # subtract 4 Ox016: 30f48000000000000000 I irmovq $128,%rsp # Problem 4.13 5 Ox020: 40436400000000000000 I rmmovq %rsp, 100(%rbx) # store 6 Ox02a: a02f I pushq %rdx # push 7 Ox02c: bOOf I popq %rax # Problem 4. 14 8 Ox02e: 734000000000000000 I je done # Not taken 9 1◊ Ox037: Ox040: 804100000000000000 I I call proc done: # Problem 4. 18 11 Ox040 : 00 I halt 12 Ox041: I proc: 13 Ox041: 90 I ret # Return 14 I 图4-17 Y86-64 指令序列示例。我们会跟踪这些 指令通过各个 阶段的处理\n图 4-18 给出了对 OPq ( 整数和逻辑运算)、r r mo v q ( 寄存器-寄存器传送)和ir mo v q ( 立即数-寄存器传送)类型的指令所需的处理。让我们先来考虑一下整数操作。回顾图 4- 2 , 可以看到我们小心 地选择了指 令编码, 这样四个整数操作 ( a d d q、s ub q、a nd q 和 x or q ) 都有相同的 i c o d e 值。我们可以 以相同的步骤顺序来处理它们,除 了 ALU 计算必须根据if un 中编码的具体的指 令操作来设定。\n图 4-18 Y86-64 指令 OPq 、r r mo vq 和 ir mov q 在顺序实现中的计算 。这些指令计算了一个值 , 并将结果存放在寄存器中。符号 i c ode : i f u n 表明指令字节的两个组成部分 , 而r A:r B 表明寄存器指示符字节的两个组成部分。符号 M1 [x ] 表示访问(读或者写)内存位置x 处的一个字节 ,而 凶 [ x ] 表示访间八个字节\n整 数操 作 指令 的处 理遵 循上面列出的通用模式。在取指阶段, 我们不需要常数字, 所以 v a l P 就计 算为 P C + 2 。在译码阶段, 我们要读两个操作数。在执行阶段, 它 们 和功能指 示符 江 u n 一起 再 提供 给 ALU , 这样一来 v a l E 就成为了指令结果。这个计算是用表达式 v a l B OP v a l A 来表达的 ,这 里 O P 代表 辽u n 指定的操作。要注意两个参数的顺序一这个顺序与 Y 8 6- 6 4 ( 和 x 8 6- 6 4 ) 的习惯是一致的。例如,指 令 s u b q %r a x , %r d x 计 算的是 R [ %r d x ] - R [ %r a x ) 的值。这些指令在访存阶段什么也不做, 而 在 写 回 阶 段 , v a l E 被写入寄 存 器r B , 然后 PC 设为 v a l P , 整个指令的执行就结束了。\n田日跟踪 s ub q 指 令的 执行\n作为一个例 子, 让我们来看看一条 s u b q 指令的处理过程, 这条指令是图 4- 1 7 所示目标代码的第 3 行 中的 s u b q 指令。可以看到前 面 两 条 指令分别将 寄存器%r d x 和%r b x 初 始化成 9 和 21 。我们还能看到 指 令位 于地 址 Ox 0 1 4 , 由两个宇节组成,值分别为Ox 6 1 和 Ox 2 3 。 这 条 指令处理的各个阶段如下表所示, 左边列 出 了 处理一个 OP q 指令的通用的 规则(图4- 1 8 ) , 而右边列出的是对这条具体指令的计算。\n阶段 OPq rA, rB s ubq r 毛 dx, % r b x 取指 icode : ifun ._ M,[ PC] icode : ifun +- M1[ Ox014] = 6: 1 rA:rB +- M1[ PC + l ] rA:rB +- M1[ 0x015] = 2: 3 valP 令 - PC+ 2 valP .- Ox014+2= Ox01 6 译码 valA \u0026hellip;\u0026hellip; R[ rA] valA - R[ r 毛 dx] = 9 valB +- R[ rB] valB .- R[ r% bx] = 21 执行 valE +- valB OP valA Set CC valE +- 21- 9= 1 2 ZF 仁 - 0, SF 七 0, OF+- 0 访存 写回 R[ rB] +- valE R[ 韦 r bx] - valE= 12 更新 PC PC+- valP PC +- valP= Ox016 这个跟踪表明我们达到 了理 想的效果, 寄存器%r bx 设成了 12 , 三个条件码都设成\n了 0 , 而 PC 加 了 2。\n执行 r r mo v q 指令和执行算术运算类似。不 过, 不需要取第二个寄存器操作数。我们将 ALU 的第二个输入设为 o, 先把它和第一个操作数相加, 得到 v a l E= valA, 然后再把\n这个值写到寄 存器文件。对 ir mo v q 的处理与此类似, 除了 ALU 的第一个输入为常数值va l C。另外, 因为是长指令 格式, 对于 i r mo v q , 程序计 数器必须加 1 0 。所有这些指令都不改变条件码。\n练习题 4. 13 填写下表的 右边 一栏 ,这 个表描述 的是 图 4-1 7 中目标 代码 第 4 行上的\nir mo v q 指令的处 理情况 :\n这条指令的 执行会 怎样 改 变寄存器 和 PC 呢?\n图 4-1 9 给出了内存 读写指令r mrno v q 和 mr mo v q 所需要的处理。基本流程也和前面的一样, 不过是用 ALU 来加 v a l C 和 v a l B , 得到内存操作的有效地址(偏移量与基址寄存器值之和)。在访存阶段 , 会将寄存器值 v a l A 写到内存 , 或者从内存中读出 v a l M。\n阶段 取指 r mmo vq rA, D(rB) icode: ifun +- M1[ PC] rA:rB- M1[ PC+ l ] mrmovq D\u0026lt;rB), rA icode: ifun +- M1[PC] rA,rB- M1[ PC+ l ] valC- Ms[ PC+ 2] valC +- Ms[ PC+ 2] valP.- PC+ l O valP- PC+ l O 译码 valA +- R[rA] 执行 valB +- R[rB] valE 仁 - valB+valC va/8 +\u0026mdash; R[ rB] valE ~ valB+valC 访存 Ms[ valE]- valA valE - Ms[ valE] 写回 R[rA]+- valM 更新 PC PC 仁 valP PC七- valP 图 4-19 Y86-64 指令r mmovq 和 mr movq 在顺序实现中的计算 。这些指令读或者写内存\nm 跟踪 rm mo v q 指令的执行\n让我们 来看看图 4- 1 7 中目标代 码的第 5 行r mmo v q 指令的处理情况。 可以 看到 ,前面的指 令已将寄存 器%r s p 初始化成 了 1 28 , 而%r bx 仍然是 s ub q 指令(第3 行)算 出来的\n结果 1 2 。我们还 可以 看到 ,指 令位于地 址 Ox 0 2 0 , 有 1 0 个宇节 。前两个的 值为 Ox 4 0 和\nOx43, 后 8 个是数字 Ox 0 0 0 0 0 0 0 0 0 0 0 0 0 0 6 4 ( 十进制数 1 0 0 ) 按字 节反过来得 到的数。各个阶段的处理如下:\n阶段 通用 具体 rmrnovq rA, D( rB) r mmov q %r s p, 1 00{ 毛r bx ) 取指 icode: ifun - M1[ PC] rA,rB- M1 [ PC+ l ] valP - Ms[ PC+ 2] valP 噜 - PC+ l O ico de: ifun +\u0026ndash; M1 [ Ox020] = 4: O rA, rB - M1 [ 0xo21] = 4: 3 valC.- Ma[ Ox022] = 100 valP +\u0026ndash; Ox020+10 = Ox02a 译码 valA +- R[ rA] valB - R[ rB] valA +- R[ 号r s p ] = 128 valB - R[ 毛 r bx ] = 12 执行 valE +- valB+valC valE +- 1 2+ 100= 112 访存 M式valE] - valA Ms[ 112].- 128 写回 更新 PC PC 嘈- valP PC 令- Ox02a 跟踪记 录表明 这条指令的 效果就是将 1 2 8 写入 内存 地址 11 2 , 并将 PC 加 1 0 。\n图 4 - 2 0 给出了处理 p u s h q 和 p o p q 指令所需的步骤。它 们可以算是最难实现的 Y 8 6- 6 4 指令了, 因为它们既 涉及访问内存, 又要 增加或减少栈指针。虽然这两条指令的流程比较相似,但是它们还是有很重要的区别。\n阶段 pushq rA popq rA 取指 ic ode: ifun - M1 [ PC] icode: ifun +- M1[PC] rA: rB - M, [ PC+ l ] rA, rB - M1 [ PC+ l ] valP- PC+ 2 valP 仁 - PC+ 2 译码 valA- R[ rA] valB - R[ r% s p] valA - valB ._ R[ %r s p] R[ r令 s p] 执行 valE 仁 - valB+ ( - 8) valE .- va1B+ 8 访存 Ms[ valE] - valA valE - M正valA] 写回 R[ %r s p] 亡 valE R[ 毛 r s p] - valE R[ rA] - valM 更新 PC PC+- valP PC 仁 - valP 图 4- 20 Y8 6-64 指令 pus hq 和 po pq 在顺序实现中的计算。这些指令将值压入或弹出栈\np u s h q 指令开始时很像我们 前面讲 过的指令, 但是在译码阶段,用 %r s p 作为第 二个寄存器操作数的标识符, 将栈指针赋值为 v a l B。在执行阶段,用 ALU 将栈指针减 8 。减过 8 的值就是内存写的地址, 在写回阶段还会存回到%r s p 中。将 v a l E 作为写操作的地址 , 是遵循 Y 8 6- 6 4 ( 和 x 8 6- 6 4 ) 的惯例,也 就是在写之前, p u s h q 应该先将栈指针减去 8 , 即使栈指针的更新实际上是在内存操作完成之后才进行的。\n日 日 跟踪 p us hq 指令的执行\n让我们 来看 看图 4- 1 7 中 目 标代码的 笫 6 行 p u s h q 指令的 处理情 况。 此时, 寄存器 %r d x 的值为 9\u0026rsquo; 而寄 存器%r s p 的值为 1 2 8 。 我们 还可以 看到 指令是位于地 址 Ox 0 2 a ,有两个 宇节,值 分别为 Ox a O 和 Ox 2 f 。 各个阶段 的处理如 下:\n阶段 通用 具体 pushq rA pushq r% dx 取指 icode: ifu n +- M, [ PC] rA:rB- M1[ PC + l ] valP - PC+ 2 icode: ifun ~ M1 [ Ox02a] =a: 0 rA: rB +- M1[ 0x02b] = 2: f valP 仁 - Ox02a + 2 = Ox02c 译码 valA +- R[ rA] valB - R[ r毛 s p] valA .._ R[ r% dx] = 9 valB - R[ %rs p] = 128 执行 valE 七 - valB+ ( - 8) valE 仁 12a + \u0026lt;- 8) = 1 20 访存 Ms[ valE] - valA Ms[ 120]+- 9 写回 R[ %r s p] - valE R[ %rsp]- 120 更新 PC PC 仁 - valP PC+- Ox02c 跟踪记录表明 这条指令的效果就是将%r s p 设 为 120 , 将 9 写入 地 址 120 , 并将 PC\n加 2。\npop q 指令 的执行 与 pu s hq 的执行类似,除 了 在 译 码阶段要读两次栈指针以外。这样做看上去 很多余 ,但 是 我们会看到让 v a l A 和 v a l B 都存放栈指针的值,会 使 后 面的流程跟其他的 指令更相似,增 强 设 计 的 整体一致性。在执行阶段, 用 ALU 给栈指针加 8 , 但是用没加 过 8 的原 始值作为内存操作的地址。在写回阶段, 要用加过 8 的栈指针更新栈指 针寄存器 , 还要将寄存器 rA 更新为从内存中读出的值。用没加过 8 的值作为内存读地址, 保持了 Y86-64 ( 和 x86-64) 的惯例, popq 应该首先读内存,然 后 再 增 加 栈 指 针 。\n练习题 4. 14 填写 下表的右边一栏 , 这个表描述的是图 4-17 中目标代码 第 7 行 pop q\n指令 的处理情况:\n这条指令的执 行会怎样改 变寄 存器和 PC 呢?\n练习题 4. 15 根据图 4-20 中列 出的步骤, 指令 pus hq %r s p 会有什么样 的效果? 这与练 习题 4. 7 中确定 的 Y86-64 期望的行为 一致 吗?\n练习题 4. 16 假设 po pq 在写 回阶段中的两个 寄存器写 操作按照 图 4-20 列 出的 顺序进行。po pq %r s p 执行的效果会是怎 样的? 这 与 练 习题 4. 8 中 确定的 Y86-64 期 望的行为一致吗?\n图 4- 2 1 表明了三类控制转移指令的处理: 各种跳转、c a l l 和r e t 。可以看到, 我们能用同前面指令一样的整体流程来实 现这些指令。\n阶段 jXX Dest call Dest ret 取指 icode, ifun - M1[ PC ] ico de , ifun - M1[PC] icode: ifun +- M1 [ PC] vale.- Ms[PC+1] valP +- PC+9 valC - M8[ PC+ 1] valP 仁 - PC+ 9 valP- PC+ l 译码 valA - R[ r% s p ] valB - R[ 毛 r s p] valB +- R[ r 毛 s p] 执行 valE- valB + ( - 8) valE 仁 va1B+ 8 Cnd - Cond(CC, ifun) 访存 Ms[ valE]- valP valM - Ms[ valA] 写回 R[ 号r s p]- valE R[ 号r s p] +- valE 更新 PC PC 仁 - Cnd?valC , valP PC 牛 - vale PC +- valM 图 4- 21 Y86-64 指令 j XX、 c a ll 和r e t 在顺序实现中的计莽。这些指令导致控制转移\n同对整数操作一样,我们能够以一种统一的方式处理所有的跳转指令,因为它们的不同只在千判断是否要选择分支的时候。除了不需要一个寄存器指示符字节以外,跳转指令在取指和译码阶段都和前面讲的其他指令类似。在执行阶段,检查条件码和跳转条件来确定是否要选择分支,产生出一个一位信号 Cnd 。在更新 PC 阶段, 检查这个标志, 如果这个标志为\n1, 就将PC 设为 v a l e ( 跳转目标), 如果为 o, 就设为 v a l P( 下一条指令的地址)。我们的表示法\nx?a: b类似千C 语句中的条件表达式一 当 x 非零时, 它等于a , 当 x 为零时, 等于b。\n田 日 跟踪 je 指令的执行\n让我们来看看图 4- 1 7 中目标 代码的笫 8 行 j e 指令的 处理情况。 s u b q 指令(第3 行) 已经将 所有的 条件码都 置为了 o, 所以 不会选择 分支。该指令位于地 址 Ox 0 2 e , 有 9 个\n宇节。 第一 个字节的值 为 Ox 7 3 , 而剩下的 8 个宇 节是数宇 Ox 0 0 0 0 0 0 0 0 0 0 0 0 0 0 4 0 按宇节反过来得到的数 ,也 就是跳 转的目标 。各个阶段的 处理如 下:\n就像这个跟踪记 录表明 的那样 ,这条指 令的效果就是将 PC 加 9。\n心 练习题 4 . 17 从指令 编码(图4- 2 和图 4- 3 ) 我们 可以 看出,r mmo v q 指令是 一类更通用的、包 括条 件转移在内 的指 令的 无条 件版本。 请 给 出你 要如何 修 改下 面r r mo v q 指令\n的步骤 , 使之也 能处 理 6 个条件 传送 指令。 看看 j XX 指令的 实现(图 4-21 ) 是如何 处理条件行为的,可能会有所帮助。\n指令 c a l l 和r e t 与指令 p u s hq 和 po pq 类似, 除了我们要将程序计数器的值入栈和出栈以外。对指令 c a l l , 我们要将 va l P, 也就是 c a l l 指令后紧跟着 的那条指令的地址, 压人栈中。在 更新 PC 阶段, 将 PC 设为 v a l C, 也就是调用的目的地。对指令r e t , 在更 新 PC 阶段,我 们将 va l M, 即从栈中取出的值, 赋值给 PC。\n练习题 4. 18 填写下表 的右 边一栏,这 个表描 述的是 图 4-17 中目标 代码 第 9 行 c a l l\n指令的处理情况:\n这条指令的执行 会怎样改 变寄 存器、 PC 和内存呢?\n我们创建了一 个统一的框架, 能处理所有不同类型的 Y86-64 指令。虽然指令的行为大不相 同, 但是我们可以 将指令的处理组织成 6 个阶段。现在我们的任务是创建硬件设计来实现这些阶段,并把它们连接起来。\nm 跟踪 ret 指令的执行\n让我们来看看图 4-17 中目 标代码的 第 1 3 行r e t 指令的处理情况。指令的地址是\nOx041, 只有一个宇 节的 编码 , Ox 90 。 前面的 c a l l 指令将% r s p 置为 了 1 20 , 并将返回地址 Ox 040 存放在了内 存地址 1 20 中。各个阶段 的处理如 下:\n通用 具体 阶段 r e t r e t 取指 ico de : ifun - M1[ PC] ico de , ifun - M1[ 0x041] = 9: 0 valP 仁 - PC + l va lP 仁 - Ox041 + 1 = Ox042 译码 valA - R[ r% s p] valB +- R[ r% s p] valA +- R[ r% s p] = 120 valB - R[ r% s p] = 120 执行 val E 仁 val8 + 8 valE +- 120+ 8 = 128 访存 valM +- Ma [ valA] valM +- Ms[ 120]= Ox040 写回 R[ 毛r s p ] +- valE R[ %r s p ] - 1 28 更新 PC PC- valM PC 仁 Ox040 跟踪记 录表明 这条指令的 效果就是将 P C 设 为 Ox 040 , ha l t 指令的 地址。同时 也将\n%r sp 置 为 了 1 28。\n4. 3. 2 SEQ 硬件结构\n实现所有 Y86-64 指令所需要的计算可以被组织成 6 个基本阶段: 取指、译码、执行、访存、写回和更新PC。图 4-22 给出了一个能执行这些计算的硬件结构的抽象表示。程序计 数器放在寄存器中,在图中左下角(标明为 \u0026quot; PC\u0026quot;)。然后, 信息沿着线流动(多条线组合在一起就用宽一点的灰线来表示),先向上,再 向右。同各个阶段相关的硬件单元 ( ha r dw a re uni ts) 负责执行这些处理。在右边, 反馈线路向下,包括要写到寄存器文件的更新值,以及 更新的程序计数器值。正如在 4. 3. 3 节中讨论 的那样,在 SEQ 中, 所有硬件单元的处理都 在一个时钟周期内完成。这张图省略了一些小 的组合逻辑块,还省略了所有用来操作各个硬 件单元以及将相应的值路由到这些单元的控制 逻辑。稍后会补充这些细节。我们从下往上画\n处理器和流程的方法似乎有点奇。怪在开始设计流水线化的处理器时,我们会解释这么画的原因。\n硬件单元与各个处理阶段相关联:\n取指: 将程序计数器寄存器作为地址,指令内存读取指令的字节。PC 增加器(PC incre ­\n程序计数器\n( PC )\n写回\n访存\n执行\n取指\n新PC\nm enter ) 计算v a l P , 即增加了的程序计数器。译码: 寄存器文件有两个读端口 A 和 B,\n从这两个端口同时读寄存器值v a l A 和 v a l B。\n图 4-22 SEQ 的抽象视图,一种 顺序 实现。指令执行\n过程 中的 信息 处理 沿着顺时针方向的流程进行 ,从用程序计数器 ( PC ) 取指令 开始,如图中左下角所示\n执行:执 行 阶 段会根据指令的类型,将 算 术/逻辑单元( ALU ) 用于不同的目的。对整\n数操作 ,它要 执 行 指 令 所 指 定 的 运算。对其他指令,它 会 作 为一个加法器来计算增加或减少栈指针 , 或者计算有效地址,或 者只是简单地加 o , 将一个输入传递到输出。\n条件码寄存器CC C) 有三个条件码位。AL U 负责计算条件码的新值。当执行条件传送指令时 , 根据条件码和传送条件来计算决定是否更新目标寄存器。同样, 当执行一条跳转指令时,会 根据条件码和跳转类型来计算分支信号 Cnd 。\n访存:在 执 行 访 存 操作时,数 据 内 存读出或写入一个内存字。指令和数据内存访问的是相同的 内存位置,但 是用于不同的目的。\n写回 :寄 存器文件有两个写端口。端口 E 用来写 ALU 计算出来的值,而 端 口 M 用来写从数据内存中读出的值。\nPC 更新: 程序计数器的新值选择自: valP, 下一条指令的地址; vale, 调用指令或\n跳转指令指定的目标地址; valM, 从内存读取的返回地址。\n图 4-23 更详细地给出了实现 S EQ 所需要的硬件(分析每个阶段时, 我们会看到完整的\n新PC\n程序计数器\n(PC)更新\n访存\n执行\n译码\ni ns rt _va li\ni me m_ er r or\n取指\n图 4-23 SEQ 的 硬件结 构 , 一 种 顺 序实现。有些控制信号以及寄存器和控制字连接没有画出来\n细节)。我们看到一组和前面一样的硬件单元,但 是现在线路看得更清楚 了。这幅图以及其他的硬件图都使用的是下面的画图惯例。\n白 色方 框表示时钟 寄存器。程 序计数器 PC 是 SEQ 中唯一的时钟寄存器。\n浅蓝 色方框 表示硬 件单元。这 包括内存、ALU 等等。在我们所有的处理器实现中, 都会使用这一组基本的单元。我们把这些单元当作“黑盒子“,不关心它们的细节 设计。\n控制逻辑块用灰色圆角矩形表示。这些块用来从一组信号源中进行选择,或者用来 计算一些布尔函数。我们会非常详细地分 析这些块, 包括给出 HCL 描述。\n线路的 名字在白 色圆 圈中 说明。它们只是 线路的标识, 而不是什么硬件单元。\n宽度 为字长的 数据连接用 中等粗度的线表 示。 每条这样的线实际上都代表一簇 64\n根线, 并列地连在一起 , 将一个字从硬件的一个 部分传送到另一部分。\n宽度为字节或更窄的数据连接用细线表示。根据线上要携带的值的类型,每条这样的线实际上都代表一簇 4 根或 8 根线。\n单个位的连接用虚线来表示。这代表芯片上单元与块之间传递的控制值。\n图 4-18 图 4-21 中所有的计算都有这样的性质, 每一行都代表某个值的计算(如valP), 或者激活某个硬 件单元(如内存)。图4- 24 的第二栏列出了这些计算和动作.。除了我们已经讲过的那些信号以外, 还列出了四个寄存器 ID 信号: srcA, valA 的源; srcB,\nvalB 的源; dstE, 写入 v a lE 的寄存器; 以及 d s t M , 写入 v a l M 的寄存器。\n阶段 取指 计算 icode, ifun OPq rA, rB ico de: ifun - M1[ PC] mr mov q D( rB) , rA icode: ifun +- M1 [ PC] rA , rB rA, rB - M1[ PC+ l ] rA: rB - M, [ PC+ l] va lC . vale - Ma[ PC+ 2] valP valP+- PC+ Z valP 仁 - PC + l o 译码 va lA , srcA va/A - R[ rA] valB, srcB valB - R[ rB] valB ~ R[ rB] 执行 valE valE 仁 - valB OP valA va lE 仁 valB + valC Cond. codes Set CC 访存 Read/ write valM +- M式valE] 写回 E port, dstE R[ rB] - valE M port, dstM R[ rA] - valM 更 新 PC PC PC 仁 - valP PC 仁 - valP 图 4- 24 标识顺序实现中的不同计算步骤 。第二栏标识出 SE Q 阶段中正在被计算的值 , 或正在被执行的操作 。以指令 OPq 和 mr mo v q 的计算作为示例\n图 中, 右边两栏给出的是指令 OP q 和 mr mo v q 的计算, 来说明要计算的值。要将这些计算映射到硬件上,我们要实现控制逻辑,它能在不同硬件单元之间传送数据,以及操作 这些单元,使得对每个不同的指令执行指定的运算。这就是控制逻辑块的目标,控制逻辑 块在图 4-23 中用灰色圆角方框表示。我们的任务就是依次经过每个 阶段, 创建这些块的详细设计。\n4. 3. 3 SEQ 的时序\n在介绍图 4-18 图 4-21 的表时, 我们说过要 把它们看成是用程序符号写的, 那些赋值是从上到下顺 序执行的。然而, 图 4-23 中硬件结构的操作运行根本完全不同 , 一个时\n钟变化会引发一个经过组合逻辑的流,来执行整个指令。让我们来看看这些硬件怎样实现 表中列 出的这一行为。\nSEQ 的实现包括组合逻辑 和两种存储器设备: 时钟寄存器(程序计 数器和条件码寄存器),随机访问存储器(寄存器文件、指令内存和数据内存)。组合逻辑不需要任何时序或控制 只要输入变化了 , 值就通过 逻辑门网络传播 。正如提到过的 那样, 我们也将读随机访问存储器看 成和组合逻辑一样的操作, 根据地址输入产生 输出字。对于较小 的存储器来说(例如寄存器文件),这是一个合理的假设,而对于较大的电路来说,可以用特殊的时钟电路来模拟这个效果。由于指令内存只用来读指令,因此我们可以将这个单元看成是组 合逻辑 。\n现在还剩四个硬 件单元需要对它们的时序进行明 确的控制—— 程序计数器、条件码寄存器、数据内存和寄存器文件。这些单元通过一个时钟信号来控制,它触发将新值装载到 寄存器以及将值写到随机访问存储器。每个时钟周期,程序计数器都会装载新的指令地 址。只有 在执行 整数运算 指令时, 才会 装载条件码寄存楛。只有在执行r mrno v q 、 p u s h q 或 c a l l 指令时 , 才会写数 据内存。寄存器文件的两个写端口允许每个时钟周期更新两个程序寄存器, 不过我们可以 用特殊的寄存器 ID Ox F 作为端口地址, 来表明 在此端口不应该执行写操作。\n要控制处理器中活动的时序,只需要寄存器和内存的时钟控制 。硬件获得了如图 4-18 ~ 图4-21 的表中所示 的那些赋值顺序执行一样的效果, 即使所有的 状态更新实际上同时发生, 且只在 时钟上升开始下一 个周期时。之所以能保持这样的等价性, 是由千 Y86-64 指令集的本质,因为我们遵循以下原则组织计算:\n原则:从不回读\n处理器从未不需要为了完成一条指令的执行而去读由该指令更新了的状态。\n这条原则 对实现的成功来说至关重要。为了说明问 题, 假设我们对 p u s h q 指令的实现是先将 %r s p 减 8 , 再将更新后的%r s p 值作为写操作的地址。这种方法同前面所说的那个原则相违背 。为了执行内 存操作, 它需要先从寄存器文件中读 更新过的栈指针。然而, 我们的实现(图 4- 20 ) 产生出减后的栈指针值, 作为信号 v a l E , 然后再用这个信号既作为寄存器写的数据,也作为内存写的地址。因此,在时钟上升开始下一个周期时,处理器就可 以同时执行寄存器写和内存写了。\n再举个例子来说明这条原则,我们可以看到有些指令(整数运算)会设置条件码,有些指令(跳转指令)会读取条件码,但没有指令必须既设置又读取条件码。虽然要到时钟上升 开始下一个周期时 , 才会设置条件码, 但是在任何指令试图读之前 ,它 们都会更新。\n以下是汇编代码,左 边列出的是指令地址, 图 4- 25 给出了 SEQ 硬件如何处理其中第\n3 和第 4 行指令:\nOxOOO: irmovq $0x100,%rbx # %rbx \u0026lt;\u0026ndash; Ox100 OxOOa: irmovq $0x200,%rdx # %rdx \u0026lt;\u0026ndash; Ox200 Ox014 : Ox016: addq %rdx,%rbx je dest # ir 儿 bx \u0026lt;\u0026ndash; # Not taken Ox300 CC\u0026lt;\u0026ndash; 000 Ox01f: rmmovq %rbx, 0 (r儿Ox029: dest: halt\ndx )\n# M[Ox200] \u0026lt;\u0026ndash; Ox300\n标号为 1 ~ 4 的各个图给出了 4 个状态单元, 还有组合逻辑 , 以及状态单元之间的连接。组合逻辑被条 件码寄存器环绕着, 因为有的 组合逻辑(例如 ALU ) 产生输入到条件码寄存器, 而其他部分(例如分支计算和 PC 选择逻辑)又将条件码寄存器作为输入。图中寄\n存器文件和数据内存有独立的读连接和写连接,因 为读操作沿着这些单元传播,就 好 像它们 是组合逻辑, 而写操作是由时钟控制的。\n时钟\n周期1 周期2: 周期3\nOxO OO : irmovq $s x100 , 毛 r bx # % r b x \u0026lt;- - Ox l OO OxOOa: irmovq $0x200, 号 r bx # 沦r dx \u0026lt;- - Ox200\nOx0 1 4 addq %r d x , 马r b x # 号r b x \u0026lt;- - Ox 300 CC \u0026lt;- - 000\n周期4:• .;·,,··,,O•~,,,. -x\u0026rsquo;·0 1,6.\n,飞e .. -仓-.-s- f\u0026ndash; 心丈-又二\u0026rsquo;, 令.\u0026ndash;,玉t,·,• 七, 己-,-.1,C· 鲁 .N- o t心. t -、ak -e文., ·,.,.心, .-,歹石心`、,,乓飞L ·节· 咚\u0026lt;兮.分- 五-、\u0026rsquo;: $\n周期5:\nOx Olf : r mmo v q % r bx , 0 (% r d x ) # M [ Ox20 0 ] \u0026lt;- - Ox 3 0 0\n心周期3开始时\n@周期4开始时\n@ 周期3结束时\nr毛 bx\n`Ox300\n图 4-25 跟踪 SEQ 的两个 执行周期。每个周期开始时, 状态 单元(程序计数器、条件 码寄存器、寄存 器文件以 及数据内 存)是根据前一条指令设置的。信号传播通过组合逻辑 , 创建出新的状态单元的 值。在下一个周期开始时 , 这些值会被加 载到状态单元中\n图 4-25 中的不同颜色的代码表明电路信号是如何与正在被执行的不同指令相联系的。我们假设处理是从设置条件码开始的,按 照 ZF 、S F 和 OF 的顺序, 设 为 100。在时钟周 期 3 开始的时候(点1) \u0026rsquo; 状态单元保持的是第二条 i r mov q 指 令( 表中第 2 行)更新过的状态,该 指 令 用 浅灰色表示。组合逻辑用白色表示, 表明它还没有来得及对变化了的状态做出 反应。时钟周期开始时,地 址 Ox 01 4 载入程序计数器中。这样就会取出和处理 a dd q 指 令(表中第 3 行)。值沿着组合逻辑流动, 包括读随机访问存储器。在这个周期末尾(点2) , 组合逻辑为条件码产生了新的值( 000) , 程序寄存器%r b x 的 更 新 值 ,以 及程序计数器的新\n值( Ox01 6) 。 在此时 , 组合逻辑已经根据 a d d q 指令被更新了,但 是状态还是保持着第二\n条 ir mo v q 指令(用浅灰色表示)设置的值。\n当时钟上升开始周 期 4 时(点3) , 会更新程序计数器、寄存骈文件和条件码寄存器, 因此我们用蓝 色来表示, 但是组合逻辑还 没有对这些变化做出反应, 所以用白色表示。在这个周期内, 会取出并 执行 j e 指令(表中第 4 行), 在图中用深灰色表示。因为条件码 ZF\n为 o, 所以不会选择分支。在这个周期末尾(点4 ) , 程序计数器巳经产生了新值 Ox Olf 。\n组合逻 辑已经根据 j e 指令(用深灰色表示)被更新过了, 但是直到下个周期开始之前, 状态还是 保持着 a d d q 指令(用蓝色表示)设置的值。\n如此例所示, 用时钟来控制状态单元的更新 , 以及值通过 组合逻辑 来传播, 足够控制我们 SEQ 实现中每条指令 执行的计算了。每次时钟由低变高时, 处理器开始执行一条新指令。\n3. 4 S EQ 阶段的实现\n本节会设 计实现 SEQ 所需要的控制逻辑块的 HCL 描述。完整的 SEQ 的 HCL 描述请参见网络 旁注 ARC H : HCL。在此, 我们给出一 些例子, 而其他的作为练习题。建议你做做这些练习来 检验你的理 解, 即这些块是如何 与不同指令的计算需求相联系的。\n我们没有讲的 那部分 SEQ 的 HCL 描述, 是不同整数 和布尔信号的定义, 它们可以作为 HCL 操作的参数。其中包括不同硬件信号的名字, 以及不同指令代码、功能码、寄存器名字、 ALU 操作和状态码的常数值。只列出了那些在控制逻辑中必须被显式引用的常数。图 4- 26 列出了我们使用的 常数。按照习惯, 常数值都是大写的。\n图 4- 26 HCL 描述中使用的常数值。这些值表示的是指令 、功能码、寄存器 ID、AL U 操作和状态码的编码\n除了图 4-18 图 4- 21 中所示的指令以外,还 包括了对 n a p 和 h a 吐 指令的处理。n a p\n指令只是简单地经过各个阶段,除 了 要 将 PC 加 1 , 不 进 行 任 何 处 理 。 ha lt 指 令 使 得 处 理器状态被设 置为 HLT , 导致处理器停止运行。\n取指阶段\n如图 4-27 所示 , 取 指 阶 段 包 括 指 令 内 存 硬 件 单 元 。 以 PC 作 为 第 一 个 字 节(字 节 0 ) 的地址 , 这 个 单 元一 次从 内 存 读 出 10 个 字 icode ifun rA rB valC valP\n节。第一个字节被解释成指令字节,(标\n号为 \u0026ldquo;Split\u0026rdquo; 的单 元)分为 两个 4 位 的数 。然 后 , 标 号 为 \u0026quot; icode \u0026quot; 和 \u0026quot; if un \u0026quot; 的控制逻辑块计算指令和功能码,或者 使之等千从内存读出的值, 或 者 当 指 令地 址 不 合 法 时( 由 信 号 i me m_ er r o r 指明 ), 使 这 些 值 对 应 于 n op 指 令 。 根 据i c od e 的 值 , 我 们 可以计算 三个一 位 的信号(用虚线表示):\ninstr_valid: 这个字节对应千一个合法的 Y86-64 指令吗? 这个信号 用来发现不合法的指令。\nneed_regids: 这个指令包括一个寄存器指示符字节吗?\nneed_valC: 这个指令包括一个常数字吗?\n图 4-27 SEQ 的取指阶段。以PC 作为起始地址,从指令内 存中读出 10 个字节。根据 这些字节, 我们产生出各个 指令字段。PC 增加模块计算信号valP\n(当指令地 址越界时 会产 生的)信号 i ns tr _v a l i d 和 i me m_ er r or 在 访 存 阶 段 被 用 来产 生 状 态 码 。\n让 我 们 再 来 看一 个 例子 , n e e d _r e g i d s 的 H CL 描述只是确定了 i c od e 的值是否为一\n条带有寄存器指示值字节的指令。\nbool need_regids =\nicode in { IRRMOVQ, IOPQ, IPUSHQ, IPOPQ, IIRMOVQ, IRMMOVQ, IMRMOVQ };\n练 习题 4. 19 写 出 SEQ 实现 中信 号 ne e d _v a l C 的 HCL 代码。\n如图 4-27 所示 , 从 指 令 内 存 中 读 出 的 剩 下 9 个 字 节 是 寄 存 器 指 示 符 字 节 和 常 数 字的 组 合 编 码 。 标 号 为 \u0026quot; A lig n\u0026quot; 的 硬 件 单 元 会 处 理 这些 字 节 , 将 它 们 放 入 寄 存 器 字 段 和常 数 字 中 。 当 被 计 算 出 的 信 号 ne e d _r e g i d s 为 1 时 , 字 节 l 被 分 开 装 入 寄 存 器 指 示符 r A 和 rB 中 。否 则 , 这 两 个 字 段 会 被 设 为 Ox F( RNONE) , 表 明 这 条 指 令 没 有 指 明 寄 存 器。回 想 一 下(图 4-2 ) , 任何只有一个寄存器操作数的指令,寄存器指示值字节的另一个字 段都设为 Ox F( RNONE) 。 因 此 , 可 以 将 信 号 r A 和 rB 看 成 , 要 么 放 着 我 们 想 要 访 问 的寄存 器 , 要 么 表 明 不 需 要 访 问 任 何 寄 存 器 。 这 个 标 号 为 \u0026quot; A lig n\u0026quot; 的 单 元 还 产 生 常 数字 v a l C。 根 据 信 号 ne e d _r e g i d s 的 值 , 要 么 根 据 字 节 1 ~ 8 来 产 生 v a l e , 要 么 根 据 字 节 2~ 9 来 产 生 。\nPC 增加器硬件单元根据当前的 PC 以 及 两 个 信 号 ne e d _r e g i d s 和 ne e d _ v a l C 的 值,\n产 生 信 号 v a l P。对 于 PC 值 p 、ne e d _r e g i d s 值r 以 及 ne e d _ v a l C 值 i\u0026rsquo; 增 加 器 产 生值\np + l + r + 8 i 。\n2 译码和写回 阶段\n图 4-28 给出了 SEQ 中实现译码和写回阶段的逻辑的详细情况。把这两个 阶段联系在一起是因为它们都要访问寄存器文件。\n寄存器文件有 四个端口。它支持同时进行两个读(在端口 A 和 B 上)和两个写(在端口\nE 和 M 上)。每个端口都有一个地址连接和一个数据 Cnd valA valB valM valE\n连接, 地址连接是一个寄存器 ID , 而数据连接是一组 64 根线路 ,既 可以作为寄存 器文件的输出字(对读端口来说),也可以作为它的输入字(对写端口来说)。两个读端口的 地址输入 为 s r c A 和 sr c B, 而两个写端口的地 址输入为 d s t E 和 d s 七M。 如果某个地址端口上的值为特 殊标识符 Ox F ( RNONE) , 则表明不需要访问寄存器。\n根据指令代码 i c o de 以及寄存器指示值r A 和 icode rB, 可能还会 根据执行阶段计算出的 Cn d 条件信号, 图 4- 28 图 4-28 底部的四个块 产生出四个不同的寄存器文件的\n寄存器 ID。寄存器 ID sr c A 表明应该读哪个寄存器以产生 va l A。所需 要的值依赖于指令类型, 如图 4-18 ~ 图 4-21 中译码阶段第一行中所示。将所有这些条目都整合到 一个计算中就得到下面的 sr c A 的 HCL 描述\n(回想 RRSP 是%r s p 的 寄存器 ID) :\nword srcA = [\nrA rB\nSEQ 的 译 码 和写 回 阶段 。指令字段译码,产生寄存器文件使用 的四个地址(两个读和两个写)的 寄存器标识符。从寄存器文件 中读出的 值 成 为 信 号 va l A 和va l B。 两 个 写 回 值 val E 和va l M 作 为写 操作的数据\nicode in { IRRMOVQ, IRMMOVQ, IOPQ, IPUSHQ } : rA; icode in { IPOPQ, IRET} : RRSP;\n1 : RNONE; # Don\u0026rsquo;t need register\n];\n区 综习题 4. 20 寄存器 信号 sr c B 表明 应该 读 哪个寄 存器以 产 生信 号 v a l B。所需 要的值如图 4-18 ~ 图 4-21 中译 码 阶段 第二 步所 示。 写 出 s r c B 的 HCL 代码。\n寄存器 ID d s t E 表明 写端口 E 的目的寄存器,计 算出来的值 v a l E 将放在那里。图 4-18~ 图 4-21写回阶段第一步表明了这一点。如果我们暂 时忽略条件移 动指令, 综合所有不同指令的 目的寄存器, 就得到下 面的 d s t E 的 HCL 描述:\n# WARNING: Conditional move not implemented correctly here\nword dstE = [\nicode in { IRRMOVQ} : rB; icode in { IIRMOVQ, IDPQ} : rB;\nicode in { IPUSHQ, IPOPQ, ICALL, IRET} : RRSP;\n1 : RNONE; # Don\u0026rsquo;t write any register\n];\n我们查看执行 阶段时, 会重新审视这个信号 , 看看如何实现条件传送。\n\u0026quot; 练习 题 4. 21 寄存器 ID d s t M 表 明 写 端 口 M 的 目 的寄存器, 从 内存 中读 出 来的值v a l M 将放 在那里 , 如图 4-18 图 4-21 中写 回 阶 段 第 二 步所 示。 写 出 d s t M 的 HCL 代码。\n区 练习题 4. 22 只有 p o p q 指令会同 时用 到寄 存器 文 件的 两个 写 端 口。 对于指令 p o p q\n%rsp, E 和 M 两个写 端口会用 到 同 一 个地 址, 但是写 入的数据不 同。 为 了 解决这个冲 突, 必须对两个写 端口设 立 一个 优先级, 这样一来, 当同 一个周期内两个写 端口都试图对一个寄存器进行写时,只有较高优先级端口上的写才会发生。那么要实现练习题 4. 8 中确定的行为 , 哪个端口该 具有较高 的优先级呢?\n3 执行阶段\n执行阶段包括算术/逻辑单元 ( ALU ) 。这个单元根据 a l u f u n 信号的设置,对 输 入 a l uA 和 a l u B 执行 ADD、SUBT RACT 、AND 或 EXCLUSIVE­\nOR 运算。如图 4-29 所示, 这些数据和控制信号是由三个 控制块产生的。ALU 的输出就是 v a l E 信号。\n在图 4-18 图 4- 21 中 , 执行阶段的第一步就是每条指令的 ALU 计算。列 出的操作数 a l u B 在\nCnd\n+\nicode ifun\nvalE\nvalC valA valB\n前面,后 面是 a l uA, 这样是为了保证 s u b q 指令 图 4-29\n是 v a l B 减去 v a l A。可以看到, 根据指令的类型, a l u A 的值可以是 v a l A、v a l e , 或者是—8 或 十8。因 此 我们可以用下面的方式来表达产生 a l u A 的控制块的行为:\nword aluA = [\nicode in { IRRMOVQ, IOPQ} : valA;\nS EQ 执行阶段。 ALU 要么为整数\n运算指令执行操作,要么作为加法器。根据 ALU 的值, 设置条件码寄存器。检测条件码的 值, 判断是否该选择分支\nicode in { IIRMOVQ, IRMMOVQ, IMRMOVQ} : valC; icode in { ICALL, IPUSHQ} : -8;\nicode in { IRET, IPOPQ} : 8;\n# Other instructions don\u0026rsquo;t need ALU\n];\n练习题 4. 23 根据图 4-18 图 4- 21 中执行阶段第 一 步的 第 一个 操作数, 写 出 SEQ 中\n信号 a l u B 的 HCL 描述。\n观察 ALU 在执行阶段执行的操作, 可以看到它通常作 为加法器来使用。不过, 对于\nOPq 指令 , 我们 希望 它使 用 指 令 i f u n 字段 中 编码的操作。因此, 可以将 ALU 控制的\nHCL 描述写成:\nword alufun = [\nicode == IDPQ : ifun;\n1 : ALUADD;\n];\n执 行 阶 段 还包括条件码寄存器。每次运行时, ALU 都会产生三个与条件码相关的信号 零 、符 号 和 溢出。不过, 我们只希望在执行 OPq 指令时才设置条件码。因此产生了一 个信号 s e 七_ c c 来 控制是否该更新条件码寄存器:\nbool set_cc = icode in { IDPQ };\n标 号 为 \u0026quot; co n d \u0026quot; 的硬件单元会根据条件码和功能码来确定是否进行条件分支或者条件数 据传送(图4-3) 。它产生信号 Cn d , 用于设置条件传送的 d s t E , 也用在条件分支的下一个 PC 逻辑中。对于其他指令,取 决 于 指 令 的 功 能 码 和 条 件 码的设置, Cn d 信号可以被设置 为 1 或者 0。但是控制逻辑会忽略它。我们省略这个单元的详细设计。\n练习题 4. 24 条件传送指令(简称 c mo v XX) 的指令代 码 为 I RRMOVQ。 如图 4- 28 所 示, 我们 可以用 执行 阶 段 中产 生 的 Cn d 信 号 实现这\n些指 令。修 改 d s t E 的 HCL 代 码 以 实 现 这 些\n指令。\n访存阶段 访存阶段的任务就是读或者写程序数据。如 图 4-30 所示,两 个 控制块产生内存地址和内存输入数据(为写操作)的值。另外两个块产生表明应该执行读操作 还是写操作的控制信号。当执行读操作时, 数据内 存产生值 v a l M。\n图 4-18 ~ 图 4- 21 的 访 存 阶 段给出了每个指令类型所需要的内存操作。可以看到内存读和写的 地 址 总 是 v a l E 或 v a l A。 这 个 块 用 HCL 描 述就是:\nword mem_addr = [\ninstr_valid imem_error\n图 4-30\nicode\nSEQ 访存阶段。数据内存既可以写,也可以读内存的值。从内存中读出的值就形成了信号 val M\nicode in { IRMMOVQ, IPUSHQ, ICALL, IMRMOVQ} : va l E;\nicode in { IPOPQ, IRET} : valA;\n# Other instructions don\u0026rsquo;t need address\n];\n练习题 4. 25 观察图 4-1 8 ~ 图 4-21 所 示 的 不 同 指令的 访存操 作, 我 们 可 以看到 内存写的 数据 总是 v a l A 或 v a l P。写 出 S E Q 中信号 me m_ d a 七a 的 H CL 代码。\n我们 希望只为从内存读数据的指令设置控制信号 me m_r e a d , 用 HCL 代码表示就是:\nbool mem_read = icode in { IMRMOVQ, IPOPQ,\nIRET };\n练习题 4. 26 我 们 希 望 只 为 向 内存 写 数 据的 指令设 置 控 制 信 号 me m wr i t e 。 写出\nS E Q 中信号 me m_ wr i t e 的 HCL 代码。\n访存 阶段最后的功能是根据取值阶段产生的 i c od e 、i me m_ er r or 、 i n s tr _ v a l i d 值以及数据内存产生的 dme m_ er r or 信 号 ,从 指 令 执行的结果来计算状态码 S t a t 。\n练习题 4. 27 写出 St a t 的 HCL代码,产 生四 个\n状态码 SAOK、SADR、SINS 和S HLT( 参见图 4-26)。\n更新 PC 阶段\nS E Q 中最后一个阶段会产生程序计数器的新值\n(见图4-31) 。如图 4-18 ~ 图 4-21 中 最后 步骤所示, 依据指令的类型和是否要选择分支, 新的 PC 可能\n是 v a l C、v a l M 或 v a l P。用 HCL 来描述这个选择就是:\n\u0026ldquo;Word neY_pc = [\n# Call. Use instruction constant icode == ICALL: valC;\n图 4-31\nSEQ 更新 PC 阶段。根据指令代码\n和分支标志, 从信号 val e、val M\n和 val P 中选出下一个PC 的值\n# Taken br an ch . Use instruction constant icode == IJXX \u0026amp;\u0026amp; Cnd: valC;\n# Completion of RET instruction. Usevalue from stack\nicode == IRET: valM;\n# Default: Use incremented PC\n1 : valP;\n];\n6. SEQ 小结\n现在我们已经 浏览了 Y86-64 处理器的一个完整的设计。可以 看到, 通过将执行每条不同指令所需的步骤组织成一个统一的流程,就可以用很少量的各种硬件单元以及一个时钟来控制计算的顺序,从而实现整个处理器。不过这样一来,控制逻辑就必须要在这些单元之间路由信号, 并根据指令类型和分支条件产 生适当的控制信号。\nSEQ 唯一的问题就是它太慢 了。时钟必须非常慢, 以使信号能在一个周期内传播所有的阶段。让我们来看看处 理一条r e t 指令的例子。在时钟周期起始时, 从更新过的 PC 开始, 要从指令内存中读出指令, 从寄存器文件中 读出栈指针, ALU 将栈指针加 8 , 为了得到程序计 数器的下一个值,还要 从内存中读出返回地址 。所有这一切都必须在这个周期结束之前完成。\n这种实现方法不能充分利用硬件单元,因为每个单元只在整个时钟周期的一部分时间内才被使用。我们会看到引入 流水线能获得更好的性能。\n4. 4 流水线的通用原理\n在试图设计一个流水线 化的 Y86-64 处理器之前, 让我们先来看看 流水线化的系统的一些通用属性和原理。对于曾经在自助餐厅的服务线上工作过或者开车通过自动汽车清洗线的人,都会非常熟悉这种系统。在流水线化的系统中,待执行的任务被划分成了若干个独立的阶段。在自助餐厅,这些阶段包括提供沙拉、主菜、甜点以及饮料。在汽车清洗 中,这些阶段包括喷水和打肥皂、擦洗、上蜡和烘干。通常都会允许多个顾客同时经过系统,而不是要等到一个用户完成了所有从头至尾的过程才让下一个开始。在一个典型的自助餐厅流水线上, 顾客按照相同的顺 序经过各个 阶段, 即使他们并 不需要某些菜。在汽车清洗的情况中,当前面一辆汽车从喷水阶段进入擦洗阶段时,下一辆就可以进入喷水阶段了。通常,汽车必须以相同的速度通过这个系统,避免撞车。\n流水线化 的一个重要特性 就是提高了系统的吞吐量( t h ro ug h p u t ) , 也就是单位时间内\n服务的顾客总数, 不过它也会轻微地增 加延迟Cla t e ncy ) , 也就是服务一个用户所需要的时间。例如,自助餐厅里的一个只需要甜点的顾客,能很快通过一个非流水线化的系统,只 在甜点阶段停留。但是在流水线 化的系统中, 这个顾客如果试图直接去甜点阶段就有可能招致其他顾客的愤怒了。\n4. 4. 1 计算流水线\n让我们把注意力放到计算流水线上来,这里的"顾客”就是指令,每个阶段完成指令 执行的一部分。图 4-32a 给出了一个很 简单的非流水线化的硬件系统例子。它是由一些执行计算的逻辑以 及一个保存计算结果的寄存器组成的。时钟信号控制在每个特定 的时间间隔加载寄存器 。CD 播放器中的译 码器就是这样的一个系统。输入信号是从 CD 表面读出的 位, 逻辑电 路对这些位进行译码, 产生音频信号。图中的计算块是用组合逻辑来实现的,意味着信号会穿过一系列逻辑门,在一定时间的延迟之后,输出就成为了输入的某个 函数。\n300 ps 20 ps\n延迟=320 ps\n吞吐量=3.12 GIPS\n时钟\na ) 硬件:未 流水线化的\nI1 I2 I3\nb ) 流水线图\n图 4-32 非 流水线化的计算 硬件。每个 320ps 的 周 期 内 , 系 统 用\n300ps计算 组 合 逻辑 函数, 20ps 将结果 存到输出寄存器中\n在现代 逻辑设计中, 电 路 延迟以微微秒或皮秒( picosecond , 简写成 \u0026quot; ps\u0026rdquo; ) , 也就是\n10-1 2 秒为单位来计算。在这个例子中, 我们假设组合逻辑需要 300 ps , 而加载寄存器需要\n20ps。图 4-32 还给出了一种时序图 ,称 为流水线图 ( pipeline diag ra m ) 。在图中, 时 间 从左向右流动 。从上到下写着一组操作(在此称为 11、 12 和 13 ) 。实 心 的 长 方形表示这些指令执行的时间。这个实现中,在开始下一条指令之前必须完成前一个。因此,这些方框在 垂直方向上 并没有相互重叠。下面这个公式给出了运行这个系统的最大吞吐量:\n1 条指令 l OOO ps\n吞吐量=(20+300)ps l n8s\n:::::::: 3. 12 GIPS\n我们以 每秒千兆条指令 CGIPS ) , 也 就 是 每秒十亿条指令, 为单位来描述吞吐量。从头到尾执行一条指令所需要的时间称为延迟( late ncy) 。在此系统中,延 迟为 320 ps , 也就是吞吐量的倒数。\n假设将系统执行的计算分成三个阶段CA、B 和 C)\u0026rsquo; 每个阶段需要 l OOps , 如图 4-33 所\n100 ps 20 ps 100 ps 20 ps 100 ps 20 ps\n延迟= 360 ps\n吞吐噩= 8.33 GIPS\n时钟\na ) 硬件: 三阶段流水线\n11 公·、r :· B - ·. C '\n12 · • B ··c.:\n13 A· · t fa= 1\u0026rsquo; 气\n时间\nb ) 流水线图\n图 4-33 三阶段流水线化的计算硬件。计算被划分为三个阶段 A、B 和 C。每经过一个 120ps的 周期 , 每条指令就行进通过一个阶段\n8 l ns = l0- 9 s 。\n示。然后在各个阶段之间放上流水线寄存器 ( pipeline register), 这样每条指令都会按照三步经过这个系统,从 头 到 尾需要三个完整的时钟周期。如图 4-33 中的 流水线图所示, 只要 Il 从 A 进入 B , 就可以让 12 进入阶段 A 了,依 此类推。在稳定状态下, 三个阶段都应该是活动的,每个时钟周期,一条指令离开系统,一条新的进入。从流水线图中第三个时钟周期就能看出这一点, 此时, Il 是 在 阶段 C , 12 在阶段 B, 而 13 是在阶段 A。在这个系统 中 , 我们将时钟周期设为 l 00+ 20 = 120 ps , 得到的吞吐量大约为 8. 33 GIPS 。因为处理一条指令需 要 3 个时钟周期 ,所 以 这条流水线的延迟就是 3 X 120 = 360ps 。我们将系统吞吐最提高到原来的 8. 33/ 3. 12 = 2. 67 倍 , 代价是增加了一些硬件,以 及 延 迟的少量增加( 360 / 320 = 1. 12) 。延迟变大是由千增加的流水线寄存器的时间开销。\n4. 4. 2 流水线操作的详细说明\n为了更好地理解流水线是怎样工作的,让我们来详细看看流水线计算的时序和操作。图 4-34 给出了前面我们看到过的三阶段流水线(图4-33) 时钟\n的流水线图。就像流水线图上方指明的那样,流水线阶 Ii\n\u0026rsquo; ,\n;,\u0026rsquo;\n段之间的指令转移是由时钟信号来控制的。每隔 120 ps ,\n信号从 0 上升至 1\u0026rsquo; 开始下一组流水线阶段的计算。\n图 4-35 跟踪 了 时 刻 240 360 之间 的电 路 活 动 , 指 令 I1 经 过 阶段 C, 12 经 过阶段 B, 而 13 经 过 阶段\nI2 A B C\nI3 A B . C\n0 120 240 360 480 600\n时间\nA。就在时刻 240 (点 1 ) 时钟上升之前 , 阶 段 A 中计算 图 4-34 三 阶段流水线的时序 。时钟的 指 令 12 的 值 已 经到达第一个流水 线寄存 器的输入, 信号的上升沿控制指令从一\n但 是 该 寄存器的状态和输出还保持为指令 Il 在阶段 A\n中计算的值。指令 11 在 阶 段 B 中计算 的值 巳经到达第\n个流水线阶段移动到下一个\n阶段\n二个流水线寄存器的输入。当时钟上升时,这些输入被加载到流水线寄存器中,成为寄 存器的输出(点2 ) 。另外 , 阶 段 A 的输入被设置成发起指令 I3 的计算。然后信号传播通过各个阶段的组合逻辑(点3 ) 。就像图中点 3 处的曲线化的波阵面( cur ved wavefront) 表明的那样, 信 号 可能以不同的速率通过各个不同的部分。在时刻 360 之前,结 果 值到达流水线寄存器的输入(点4) 。 当 时 刻 360 时钟上升时, 各 条 指 令 会 前 进 经 过一个流水线阶段。\n从这个对流水线操作详细的描述中, 我们可以看到减缓时钟不会影响流水线的行为。信号传播到流水线寄存器的输入, 但是直到时钟上升时才会改变寄存器的状态。另一方面,如 果 时 钟 运行得太快,就 会 有灾 难 性 的 后果 。值 可能会来不及通过组合逻辑,因 此当时钟上升时,寄存器的输入还不是合法的值。\n根据对 SEQ 处理器时序的讨论( 4. 3. 3 节), 我们看到这种在组合逻辑块之间采用时钟寄存器的简单机制, 足够控制流水线中的指令流。随着时钟周而复始地上升和下降, 不同的 指 令 就会 通过流水线的各个阶段, 不会相互干扰。\n4. 4. 3 流水线的局限性\n图 4-33 的 例子给出了一个理想的流水线化的系统,在 这个系统中, 我们可以将计算分 成 三个相互独立的阶段,每个 阶段需要的时间是原来逻辑需要时间的三分之一。不幸的是,会出现其他一些因素,降低流水线的效率。\n时钟 二\nI1 I2 13\n时间120 庈冰 t /360 G) ®@@\n© 时间= 239\n100 ps 20 ps 100 ps 20 ps 100 ps 20 ps\n® 时间= 241 时钟\n100 ps 20 ps 100 ps 20 ps 100 ps 20 ps\n@时间= 300 时钟\n时钟\n@ 时间= 359\n100 ps\n时钟\n图 4-35 流水线操作的一个时钟周期。在时刻 240( 点 1) 时钟上升之前,指 令 11 和 12 已 经 完 成 了 阶 段B 和 A。在时钟上升后,这 些 指 令开始传送到阶段 C 和 B, 而指令 13 开始经过阶段 A C 点 2 和 3 ) 。就在时钟开始再次上升之前, 这些指令的结果就会传到流水线寄存器的输人(点4 )\n不一致的划分\n图 4- 36 展示的系统中和前面一 样, 我们将计算划分为了三个阶段, 但是通过这些阶 段的延迟从 50 ps 到 1 50 ps 不等。通过所有阶段的延迟和仍然为 300 ps 。不过,运 行时钟的速率是由 最慢的阶段的延迟限制的。流水线图表明, 每个时钟周期, 阶段 A 都会空闲(用白色方框表示) l OOps , 而阶段 C 会空闲 50 ps 。只有阶段 B 会一直处于活动状态。我们必须将时钟周期设 为 1 50 + 20 = l 70ps, 得到吞吐量为 5. 88 GIPS 。另外, 由于时钟周期减慢\n了,延 迟也增加到了 510ps 。\n50 ps 20 ps 150 ps 20 ps 100 ps 20 ps\n延迟= 510 ps\n吞吐量= 5.88 GIPS\nI1 区l\nI2\n时钟\na ) 硬件: 三阶段流水线, 不一致的阶段延迟\nI3\n时间\n图 4-36\nb ) 流水线图\n由不一致的阶段延迟造成的流水线技术的局限性。系统的吞吐量受最慢阶段的速度所限制\n对硬件设计者来说,将系统计算设计划分成一组具有相同延迟的阶段是一个严峻的挑战。通常,处理 器中的某些硬件单元,如 ALU 和内存,是 不 能 被划分成多个延迟较小的单元的。这就使得创建一组 平衡的阶段非常困难。在设计流水线化的 Y86-64处理器中, 我们不会过于关注这一层次的细节,但是理解时序优化在实际系统设计中的重要性还是非常重要的。\n练习题 4. 28 假设我 们 分析图 4-32 中 的 组合逻辑, 认为 它 可以分成 6 个块, 依次命名 为 A F , 延迟分别为 80 、 30、60 、50 、70 和 l Ops , 如下图所示:\n80 ps 30 ps 60 ps 50 ps 70 ps\n在这些块之间插入流水线寄存器,就得到这一设计的流水线化的版本。根据在哪里插入流水线寄存器,会出现不同的流水线深度(有多少个阶段)和最大吞吐量的组 合。假设每个流水线寄 存器的延迟 为 20 ps。\n只插入一个寄存器,得到一个两阶段的流水线。要使吞吐量最大化,该在哪里插入寄存器呢?吞吐量和延迟是多少?\n要使一个三阶段的流水线的吞吐量最大化,该将两个寄存器插在哪里呢?吞吐量和延迟是多少? 要使一个四阶段的流水线的吞吐量最大化,该将三个寄存器插在哪里呢?吞吐量和延迟是多少? 要得到一个吞吐量最大的设计,至少要有几个阶段?描述这个设计及其吞吐量和延迟。 流水线过深,收益反而下降\n图 4-37 说明了流水线技术的另一个局限性。在这个例子中, 我们把计算分成了 6 个阶 段 , 每个 阶段需要 50ps。在每对阶段之间插入流水线寄存器就得到了一个六阶段流水线 。 这 个 系统的最小时钟周期为 50 + 20 = 70ps , 吞吐量为 14. 29 G IPS 。因此, 通过将流\n水线的阶 段数加倍 , 我们将性能提高了 14. 29/8. 33=1. 71。虽 然我们将每个计算 时钟的时间缩短了两倍,但是由于通过流水线寄存器的延迟,吞吐量并没有加倍。这个延迟成了流 水线吞吐量的一个制约因素。在我们的新设计中,这个延迟占到了整个时钟周期的 28. 6 %。\n50 ps 20 p\nt\n时钟 延迟= 420 ps, 吞吐量= 14.29 GIPS\n图 4-37 由开销造成的流水线技术的局限性。在组合逻辑被分成较小的块时,\n由寄存器更新引起的延迟就成为了一个限制因素\n为了提高时钟频率,现代处理器采用了很深的(1 5 或更多的阶段)流水线。处理器架构师将指令 的执行划分成很多非常简单的 步骤, 这样一来每个阶段的延 迟就很小。电路设计者小心地设计流水线寄存器,使其延迟尽可能得小。芯片设计者也必须小心地设计时钟传播网 络,以保证时钟在整个芯片上同时改变。所有这些都是设计高速微处理器面临的挑战。\n练习题 4. 29 让我们来 看看 图 4-32 中的 系统 , 假设将 它划分 成 任意 数 量 的流水线 阶段 k , 每个阶段有 相同的 延迟 300 / k , 每个 流水 线寄 存器的延迟 为 20 ps。\n系统的 延迟和吞 吐量写 成 k 的函数是 什 么? 吞吐量的上限等千多少? 4. 4. 4 带反馈的流水线系统\n到目前为止,我们只考虑一种系统,其中传过流水线的对象,无论是汽车、人或者指 令, 相互都是 完全独 立的。但 是, 对于像 x86-64 或 Y86-64 这样执行机器程序的系统来说,相 邻指令之间很 可能是相关的。例如, 考虑下面这个 Y86-64 指令序列:\n在这个包含 三条 指令的序列中, 每对相邻的指令之间都有数据相关 ( dat a dependen­\ncy)\u0026rsquo; 用带圈的寄存器名字和它们之间的箭头来表示。ir mo v q 指令(第1 行)将它的结果存放在%r a x 中, 然后 a d d q 指令(第2 行)要读这个值 ; 而 a d d q 指令将它的结果存放在%r b x 中,mr mo v q 指令(第3 行)要读这个值。\n另一种相关 是由于指令控制流造成的顺序相关。来看看下面这个 Y86-64 指令序列:\nloop:\nsubq %rdx,%rbx\njne targ\nirmovq $10,%rdx jmp loop\ntarg:\nhalt\nj ne 指令(第3 行)产生了一个控制相 关( cont ro l dependency) , 因为条件测试的结果会决定要执行的新指令是 ir movq 指 令(第 4 行)还是 ha 止 指 令(第 7 行)。在我们的 SEQ 设计中, 这些相关都是由反馈路径来解决的, 如 图 4- 22 的右边所示。这些反馈将更新了的寄存器值向下传送到寄存器文件, 将新的 PC 值向下传送到 PC 寄存器。\n图 4-38 举例说明了将流水线引入含有反馈路径的系统中的危险。在原来的系统(图4-38a) 中, 每条指令的结果都反馈给下一条指令。流水线图(图4-386)就说明了这个情况, 11 的结果成为 12 的输入,依 此类推。如果试图以最直接的方式将它转换成一个三阶段流水线(图4-38c) , 我们将改变系统的行为。如图 4- 38c 所示 ,11 的结果成 为 14 的输入。为了通过流水线技术加速系统,我们改变了系统的行为。\nIl 12 I3\n时间\nb ) 流水线图\nI1 I 2 I3 I4\n时钟\nc ) 硬件: 带反馈的三阶段流水线\nd ) 流水线图\n图 4-38 由逻辑相关造成的流水线技术的局限性。在从未流水线化的带反馈的系统 a 转化到流水 线化的 系统 c 的 过程中,我 们改变了它的计算行为, 可以从两个流水线图Cb 和 d) 中看出来\n当我们将流水线技术引入 Y86- 64 处理器时, 必 须 正 确 处 理反馈的影响。很明显,像图 4-38 中的例子那样改变系统的行为是不可接收的。我们必须以某种方式来处理指令间的数 据 和控制相关,以 使 得 到 的 行 为与 ISA 定义的模型相符。\n4. 5 Y86-64 的 流水线实现\n我们终于准备好要开始本章的主要任务—— 设计一个流水线化的 Y86- 64 处理器。首先 ,对 顺 序 的 SEQ 处 理 器做 一点小的改动,将 PC 的计 算挪到取指阶段。然后, 在各个阶段之间加上流水线寄存器。到这个时候, 我们的尝试还不能正确处理各种数据和控制相关 。 不 过,做 一 些 修 改 ,就 能实现我们的目标—— 一个高效的、流水线化的实现 Y86-64\nISA 的处理器。\n4. 5. 1 SEQ+: 重新安排计算阶段\n作为实现流水线化设计的一个过渡步骤, 我们必须稍微调整一下 SE Q 中五个阶段的顺序,使 得更新 PC 阶段在一个时钟周期开始时执行, 而不是结束时才执行。只需要对整体硬件结构做最小的改动,对 于 流水线阶段中的活动的时序,它 能 工 作 得 更 好 。 我们称这\n种修改 过的设计为 \u0026quot; SEQ + \u0026quot; 。\n我们移动 PC 阶段,使 得 它 的 逻辑在时钟周期开始时活动,使 它 计 算 当前指令的 PC 值。图 4-39 给出了 SEQ 和 SEQ + 在 PC 计算上的不同之处。在 SEQ 中(图 4-39a) , PC 计算发生在时 钟周期结束的时候, 根据当前时钟周期内计算出的信号值来计算 PC 寄存器的新值。在 SEQ + 中(图 4-39 b) , 我们创建状态寄存器来保存在一条指令执行过程中计算出 来的信号。然后, 当一个新的时钟周期开始时, 这些信号值通过同样的逻辑来计算当前指令的 PC。我们将这些寄存器标号为 \u0026quot; plc ode\u0026quot; 、\u0026quot; pCnd\u0026quot; 等等, 来 指 明 在 任一给定的周期, 它们保 存的是前一个周期中产生的控制信号。\nPC\nicode Cnd valC valM valP\na) SEQ 的新PC计算\nplcodelpCndl pValM I pValC I pValP\nb) S E Q +的PC选择\n图 4- 39 移动计算 PC 的时间。在 S EQ + 中,我 们将计算当前状态的程序计数器的 值作为指令执行的第一步\n图 4-4 0 给出了 SEQ + 硬件的一个更为详细的说明。可以看到, 其 中 的 硬件单元和控制块与我们 在 SEQ 中用到的(图4-23 ) 一样 ,只 不过 PC 逻辑从上面(在时钟周期结束时活动)移到了 下面(在时钟周期开始时活动)。\n黜I S E Q + 中的 PC 在哪 里\nSEQ 十有一个很 奇怪的特 色, 那就 是 没有硬件寄存器 来存放程 序 计数 器。 而是根据从 前一 条 指 令保 存 下 来 的 一 些 状 态 信 息 动 态 地 计 算 PC。 这就是 一 个 小 小 的 证\n; 明一— 我们可以 以一种与 IS A 隐含着的概 念模型不 同的 方式 来 实现 处理 器 , 只要处理器能正确 执行任意的机 器语 言程序。我们不 需要将状 态编码成程序员 可见的状 态指定\n;的形式 ,只 要 处理 器能 够为 任意的程序 员 可见状 态(例如 程序计数 器)产 生正 确的值。埠 创建 流水线化的设计中, 我们会 更多地 使 用到 这条原 则。 5. 7 节 中描 述的乱序 ( out­ of-order) 处理技术, 以一种 完全 不 同 于机 器 级 程序 中 出现的顺序的 次序 来执行指令,\n(将这 一思想发挥到 了极 致。\nSEQ 到 SEQ + 中对状态单元的改变是一种很通用的改进的例子, 这种改进称为电路重定时( c订cuit retimin g ) [ 68] 。重定时改变了一个系统的状态表示, 但 是 并不改变它的逻辑行为。通常用它来平衡一个流水线系统中各个阶段之间的延迟。\n4. 5. 2 插入流水线寄存器\n在创建一个流水线化的 Y86-64 处理器的最初尝试中, 我们要在 SEQ + 的各个阶段之间插人流水线寄存器, 并 对 信 号 重 新 排 列 ,得 到 P IP E —处 理器 , 这里的“—” 代 表 这 个处理器和最终的处理器设计相比,性 能 要 差 一 点 。 P IP E—的抽象结构如图 4-41 所 示。流水线寄 存器在该图中用黑色方框表示 , 每个寄存器包括不同的字段, 用 白 色方框表示。正 如多个字段 表明的那样, 每个流水线寄存器可以存放多个字节和字。同两个顺序处理器的硬件结构(图 4-23 和图 4-40 ) 中的圆角方框不同, 这些白色的方框表示实际的硬件组成。\n访存\n执行\n译码\n取指\n图 4-40 SEQ 十的 硬件结构 。将 PC 计算从时钟周期结 束时移到了 开始时 ,使 之更适合于流水线\n可以看到, P I P E —使 用了与顺序设计 SEQ ( 图 4-40 ) 几乎 一样的硬件单元, 但是有流水 线 寄 存 器分隔开这些阶段。两个系统中信号的不同之处在 4. 5. 3 节中讨论。\n流水线寄存器按如下方式标号:\nF 保存程序计数器的预测值,稍后讨论。\n位于取指和译码阶段之间。它保存关千最新取出的指令的信息,即将由译码阶段进行处理。 位于译码和执行阶段之间。它保存关千最新译码的指令和从寄存器文件读出的值 的信息,即将由执行阶段进行处理。 M 位于执行和访存阶段之间。它保存最新执行的指令的结果, 即 将 由 访 存 阶 段 进 行处 理 。 它 还保 存关于用于处理条件转移的分支条件和分支目标的信息。\nW 位于访存阶段和反馈路径之间 , 反馈路径将计算出来的值提供给寄存器文件写, 而当完成 r e t 指令时, 它还要向 PC 选择逻辑提供返回地址。\n图 4-41 PIPE- 的硬件结 构,一 个初始的 流水线化实现。通过往 SEQ+ C图 4-40 ) 中插入流水 线寄存器,我们创建 了一个五阶段的流水线 。这个版本有 几个缺陷, 稍后就会解决 这些问题\n图 4-42 表明以下代码序列 如何通过我们的五阶段流水线, 其 中 注 释将各条指令标识\n为 Il ~ I5 以便引用:\nir movq $1,%rax # 11\n2 irmovq $2,%rbx # 12\nirmovq $3, 儿r c x # 13 irmovq $4, %r dx # I4 h a l t # 15 2 3 4 5 6 7 8 9\n图 4- 42 指令流通过 流水线的示例\n图中右边给出 了这个指令序列的 流水线图 。同 4. 4 节中简单流水 线化的计算单元的流水线图一样,这 个图描述了每条指令通过流水线各个阶段的行进过程,时 间从左往右增大。上面一条数字表明各个阶段发生的时钟周期。例如, 在周期 1 取出指令 11, 然后它开始通过 流水线各个阶段,到 周期 5 结束后, 其结果写入寄存器文件。在周期 2 取出指令\n12, 到周期 6 结束后, 其结果写回, 以此类推。在最下面, 我们 给出了 当周期为 5 时的流水线的扩展图 。此时, 每个流水线阶段中各有一条指令。\n从图 4- 42 中还可以 判断我们画处理器的 习惯是合理的, 这样, 指令是自底向上的流动的。周期 5 时的扩展图表明的 流水线 阶段,取 指阶段在底 部, 写回阶段在最上面, 同流水线硬件图(图 4- 41 ) 表明的一样。如果看看流水线各个阶段中指令的顺序, 就会发现它们出现的顺序与在程序中列出的顺序一样。因为正常的程序是从上到下列出的,我们保留这 种顺序,让流水线从下到上进行。在使用本书附带的模拟器时,这个习惯会特别有用。\n4. 5. 3 对信号进行重新排列和标号\n顺序实现 SEQ 和 SEQ + 在一个时刻只处理一 条指令, 因此诸如 v a l e 、 sr c A 和 v a l E 这样的信号值有唯一的值。在流水线化的设计中, 与各个指令相关联的这些值有多个版本, 会随着指令一起流过系统。例如, 在 PIP E一的 详细结构中, 有 4 个标号为 \u0026ldquo;Sta t\u0026rdquo; 的白 色方框, 保存着 4 条不同 指令的状态码(参见图4- 41 ) 。我们需要很小心以确保使用的是正确版本的信号,否 则会有很严 重的错误,例 如将一 条指令计算出的结果存放到了另一条指令指定的目的寄存器。我们采用的命名机制,通过在信号名前面加上大写的流水线寄存\n器名字作为前缀,存 储 在流水线寄存器中的信号可以唯一地被标识。例如, 4 个状态码 可以被命名为 D_s 七a t 、 E_s t a t 、M_ s t a t 和 W_s t a t 。 我们还需要引用某些在一个阶段内刚 刚计算出来的信号。它们的命名是在信号名前面加上小写的阶段名的第一个字母作为前 缀。以 状态码为例, 可以看到在取指和访存阶段中标号为 \u0026quot; S ta t\u0026quot; 的控制逻辑块。因 而, 这些块 的输出被命名为 f _s t a t 和 m_ s t a t 。 我们还可以看到整个处理器的实际状态 St a t 是根据流水线寄存器 W 中的状态值,由 写 回 阶 段中的块计算出来的。\nm 信号 M _ s tat 和 m _ s tat 的差别\n在命名系统中, 大写的 前缀 \u0026quot; D\u0026quot; 、 \u0026quot; E\u0026quot; 、 \u0026quot; M\u0026quot; 和 \u0026ldquo;W\u0026rdquo; 指的是流水线寄存器, 所以 M _ st at 指的是流水线寄存 器 M 的状态码 宇段。 小 写的前缀 \u0026quot; f\u0026quot; 、 \u0026quot; cl\u0026quot; 、 \u0026quot; e\u0026quot; 、 \u0026quot; m\u0026quot; 和 \u0026quot; w\u0026quot; 指的是流水 线阶段, 所以 m _ sta t 指的是在访存阶段中由控制逻辑块产 生 出的状态信 号。\n理解这个命名规则对理解我们的流水线化处理器的操作是至关重要的。\nSEQ十和 PIPE- 的译码阶段都产生信号 ds t E 和 ds 七M, 它 们 指明 值 va l E 和 va l M 的 目的寄存器。在 SEQ十中 , 我们可以将这些信号直接连到寄存器文件写端口的地址输入。在PIPE- 中,会在 流水线中一直携带这些信号穿过执行和访存阶段,直 到 写 回 阶段才送到寄存器文件(如各个阶段的详细描述所示)。我们这样做是为了确保写端口的地址和数据输入是来 自同一条指令。否则, 会将处于写回阶段的指令的值写入,而 寄 存 器 ID 却 来 自千处于译码阶段的指令。作为一条通用原则,我们要保存处于一个流水线阶段中的指令的所有信息。\nPIPE—中有一个块在相同表示形式的 SEQ + 中是没有的, 那就是译码阶段中标号为\n\u0026ldquo;Select A\u0026rdquo; 的块。我们可以看出,这个 块 会 从 来自流水线寄存器 D 的 va l P 或从 寄存 器 文件\nA 端口中读出的值中选择一个,作 为流水线寄存器 E 的值 va l A。 包括这个块是为了减少要携带给流水线 寄存骈 E 和 M 的状态数量。在所有的指令中,只有 c a ll 在 访存 阶段需 要 va l P 的值。只有跳转 指令在执行阶段(当不需要进行跳转时)需要 va l P 的值。而这些指令又都不需要从寄存器文件中读出的值。因此我们合并这两个信号,将 它 们 作 为信号 va l A 携 带 穿 过流水线 ,从 而 可以减少流水线寄存器的状态数显。这样做就消除了 SEQ(图 4-23 ) 和 SEQ +\n(图4-40 )中标号为 \u0026quot; Data\u0026quot; 的块, 这个块完成的是类似的功能。在硬件设计中,像 这 样 仔 细确认信号 是如何使用的,然后 通过合并信号来减少寄存器状态和线路的数量,是 很 常见 的 。\n如图 4- 41 所示,我 们的流水线寄存器包括一个状态码 s t a t 字段,开 始 时 是 在取指阶段计算出来的,在访存阶段有可能会被修改。在讲完正常指令执行的实现之后,我们会在\n4. 5. 6 节中讨论如何实现异常事件的处理。到目前为止我们可以说,最 系统的方法就是让与每条指令关联的状态码与指令一起通过流水线,就像图中表明的那样。\n4. 5. 4 预 测 下 一 个 PC\n在 PIPE- 设计中, 我们采取了一些措施来正确处理控制相关。流水线化设计的目的就是每个时钟周期都发射一条新指令,也就是说每个时钟周期都有一条新指令进入执行阶段并 最终完成。要是达到这个目的也就意味着吞吐量是每个时钟周期一条指令。要做到这一点, 我们必须在取出当前指令之后,马上确定下一条指令的位置。不幸的是,如果取出的指令是 条件分支指令,要到几个周期后,也就是指令通过执行阶段之后,我们才能知道是否要选择 分支。类似地,如果 取 出的 指 令 是r e 七,要 到指令通过访存阶段, 才能确定返回地址。\n除了条件转移指令和r e t 以外,根 据取指阶段中计算出的信息, 我们能够确定下一条\n指令的地址。对于 c a l l 和 j mp ( 无条件转移)来说,下 一条指令的地址是指令中的常数字\nvalC, 而对于其他指令来说就是 va l P。因 此, 通过预测 PC 的下一个值, 在大多数情况下,我们能达到每个时钟周期发射一条新指令的目的。对大多数指令类型来说,我们的预测是完全可靠的。对条件转移来说, 我们既可以 预测选择了分支, 那么新 PC 值应为\nvalC, 也可以预测没有选择分支, 那么新 PC 值应为 va l P。无论哪种情况, 我们都必须以某种方式来处理预测错误的情况,因为此时已经取出并部分执行了错误的指令。我们会在 4. 5. 8 节中再讨论这个问题。\n猜测分支方向并根据猜测开始取指的技术称为分支预测。实际上所有的处理器都采用 了某种形式的此类技术。对千预测是否选择分支的有效策略已经进行了广泛的研究[ 46,\n2. 3 节]。有的系统花费了大量硬件来解决这个任务。 我们的设计只使用了简单的策略, 即总是预测选择了 条件分支, 因而预测 PC 的新值为 v a l e 。\n田 日 其他的分 支预测策略\n我们的设 计使 用总 是选择 ( always taken ) 分支的预测策略。研究表 明这个策略的成功率大约 为 60 %[ 44 , 122 ] 。相反,从 不选择 ( never taken , NT ) 策略 的成功 率大约为40 % 。稍微复杂一点的是反向选择、正向 不选择( backwa rd taken , forward not- taken , BT F NT ) 的策略 , 当分 支地址比 下一条地址低 时就预 测选择 分支, 而分 支地 址比 较高时, 就预测不 选择分支。这种策略的成功率大约 为 65 % 。这种改进 源自一 个事 实, 即循环是由后向分支结束的, 而循 环通 常会执行 多次。前向分支用 于条 件操作, 而这 种选择的可能性 较小。在 家庭作 业 4. 55 和 4. 56 中,你 可以修改 Y86-64 流水线处理 器来 实现\nNT 和 BT F NT 分支预测策略。\n正如我们在 3. 6. 6 节中看到的 , 分支预测错误 会极大地 降低程序的性能,因此这就促使我们在可能的 时候,要 使用条件 数据传送而不 是条件控制转移 。\n我们还没有讨论预测 r e t 指令的新 PC 值。同条件转移不同 , 此时可能的返回值几乎是无限的, 因为返回地址是 位千栈顶的 字, 其内容可以是任意的。在设计中,我 们不会试图 对返回地址做任何预测。只是简单地暂停处 理新指令, 直到 r 吐 指令通过写 回阶段。在\n4. 5. 8 节中,我 们将回过来讨论 这部分的实现。\nm 使用栈的返回地址预测\n对大多数程序 来说 , 预测返回 值很容易,因为过 程调 用和返回是成对出现的 。大多数函数调用,会返回到调用后的那条指令。高性能处理器中运用了这个属性,在取指单 元中放 入一个硬件栈, 保存过程调用指 令产 生的 返回地址。每次执行过程调用指 令时, 都将其返回 地址压入栈 中。 当取 出一 个返回指令时, 就从 这个栈 中弹出顶部 的值, 作为 \ 预测的返回值 。同分 支预测一样,在预 测错误 时必须提供 一个恢复机制, 因为 还是有调用和返回不匹配的 时候。通常, 这种预测很 可靠。这个硬件栈对程序员来说 是不可见的。\nPIP E 一的 取指阶段,如 图 4-41 底部所示,负 责预测 PC 的下一个值,以 及为取指选择实际 的 PC。我们可以 看到, 标号为 \u0026quot; P redict PC\u0026quot; 的块会从 PC 增加器计算出的 val P 和取出的指令中得到的 va l e 中进行选择。这个值存放在流水线寄存器 F 中, 作为程序计数器的预测值。标号 为 \u0026quot; Select PC\u0026quot; 的块类似于 SEQ + 的 PC 选择阶段中标号为 \u0026quot; PC\u0026quot; 的块(图4-40 ) 。它从三个值中选择一个作为指 令内存的地址 : 预测的 PC , 对千到达流水线\n寄存器 M 的不选择分 支的指令来说是 v a l P 的值(存储在寄存器 M_ v a l A 中), 或是当 r e t\n指令到达流水线 寄存器 WC存储在 W_ v a l M) 时的返回地址的值。\n4. 5. 5 流水线冒险\nPIPE- 结构是创建一个流水线 化的 Y 86- 64 处理器的好开端。不过, 回 忆 4. 4. 4 节中的讨论,将流水线 技术引入一个带反馈的系统, 当相邻指令间存在相关时会导致出现问题。在完成我们的设计之前,必须解决这个问题。这些相关有两种形式: 1 ) 数据相关,下一条指令会用 到这一条指令计算出的结果; 2 ) 控制相 关 , 一条指令要确定下一条指令的位置,例如在执行跳转、调用或返回指令时。这些相关可能会导致流水线产生计算错误,称 为冒险 ( ha za rd ) 。同相关一样, 冒险也可以分为两类: 数据冒险( da t a ha za rd ) 和控制 冒险(control haza r d ) 。我们首先关心的是数据冒险, 然后再 考虑控制冒险。\n图 4-4 3 描述的是 PIPE—处理器处理 pr o g l 指令序列的情况。假设在这个例子以及后面的例子 中,程序 寄存器初始时值都为 0 。这 段代码将值 10 和 3 放入程序寄存器%r d x 和\n%r a x , 执行三条 n a p 指令,然后 将寄存器%r d x 加到%r a x 。 我们重点 关注两条 ir mo v q 指\n令和 a d dq 指令之间的数据相关 造成的 可能 的数据冒险。图的右边是这个指令序列的流水 线图。图 中突出显示了周期 6 和 7 的流水线阶段。流水线图的下面是 周期 6 中写回活动和周期 7 中译码活动的扩展说明。在周期 7 开始以后, 两条 i r mo v q 都 已经通过写回阶段, 所以寄存器文件保 存着更新过的 %r d x 和%r a x 的 值。因 此, 当 a d d q 指令在周期 7 经过译 码阶段时 , 它可以读到源操作数的正确值。在此示例中, 两条 ir mo v q 指令和 a d d q 指令之间的数据相关 没有造成数 据冒险。\n# progl 2 3 4 5 6 7 8 9 10 11 OxOOO: irmovq $10,\u0026lsquo;1/.rdx I F D E M w OxOOa: irmovq $3, 1/.rax F D E M w Ox014 : nop F D E M w Ox015: nop Ox016 : nop Ox017: addq %rdx,%rax Ox019: halt F D F E D F M w E M w D E M I W F 心 D E I M I w \u0026rsquo; \u0026rsquo; ;, 图 4-43 pr ogl 的流水线化的执行,没有 特殊的流水线控制。在周 期 6 中 ,第 二个 i rmovq 将结果\n写入寄存器r% ax。addq 指令在周期 7 读源操作数, 因此得到的是r% dx 和r毛 ax 的正确值\n我们看到 pr o g l 通过流水线并 得到正确的结果, 因为 3 条 no p 指令在有数据相关的指令之间创造了一些延 迟。让我们来看看如果去掉这些 no p 指令会发生些 什么。图 4-44 描述的是 pr o g 2 程序的 流水线 流程 , 在两条产生寄存器%r d x 和%r a x 值的 ir rno v q 指令和以这两个寄存器作为操作数的 a d d q 指令之间有两条 n o p 指令。在这种情况下, 关键步骤发生在周期 6\u0026rsquo; 此时 a d d q 指令从寄存器文件中读取它的操作数。该图底部是这个周期内流水线活动的扩展描述。第一个 ir rno v q 指令巳经通过了写回阶段, 因此程序寄存 器%r d x 巳经在寄存器文件中更新过了。在该周期内, 第二个 ir rno v q 指令处于写回阶段, 因此对程序寄存器%r a x 的 写 要到周期 7 开始,时 钟上升时, 才会发生。结果, 会读出%r a x 的错误值(回想一下, 我们假设 所有的寄存器的初始值为 0 ) , 因为对该寄存器的写还未发生。很明显,我们必须改进流水线让它能够正确处理这样的冒险。\n# prog2\nOxOOO: irmovq $10,%rdx OxOOa: irmovq $3,%rax Ox014: nop\nOx015: nop\nOx016: addq %rdx,%rax Ox018: halt\n2 3 4 5 6 7 8 9 10\n昙 MIW\n气D E M I W\n错误值\n图 4-44 p ro g2 的 流水线化的执行,没 有 特 殊 的 流水线控制。直到周期 7 结 束 时 , 对寄存\n器r% a x 的写才发生,所 以 addq 指 令 在译码阶段读出的是该寄存器的错误值\n图 4- 45 是当 ir mo v q 指令和 a d d q 指令之间只有一条 n o p 指令, 即为程序 p r o g 3 时, 发生的 情况。现在我们必须检查周期 5 内流水线的行为, 此时 a d d q 指令通过译码阶段。不幸的是, 对寄存器%r d x 的写仍处在写回阶段, 而对寄存器%r a x 的 写 还处 在访存阶段。因此, a d d q 指令会得到两个错误的操作数。\n图 4- 46 是当去掉 ir mo v q 指令和 a d d q 指令间的所有 n o p 指令, 即为程序 pr o g 4 时, 发生的情况。现在我们必须检查周期 4 内流水线的行为, 此时 a d d q 指令通过译码阶段。不幸的是, 对寄存器%r d x 的 写 仍处在访存阶段, 而执行阶段正在计算寄存器%r a x 的新值。因此, a d d q 指令的两个操作数都是不正确的。\n这些例子说明 , 如果一条指令的操作数 被它前面三条指 令中的任意一条改变的话,都会出现数据冒险。之所以会出现这些冒险,是因为我们的流水线化的处理器是在译码阶段 从寄存器文件中读取指令的操作数,而要到三个周期以后,指令经过写回阶段时,才会将 指令的结果写到寄存器文件。\n# prog3 2 3 4 5 6 7 8 9\nj W\nI M I w\nM_valE = 3 M_dstE =\u0026lsquo;l.rax\n错误值\n图 4- 45 pr og3 的流水线化的 执行 ,没 有 特 殊 的 流 水 线 控 制 。 在周期 5 , addq 指令从寄存器文件中读源操作数。对寄存器釭dx 的写仍处在写回阶段, 而 对 寄 存器%r a x 的 写 还在访存阶段。两个操作数 va l A 和 va l B 得 到的 都 是 错 误 值\n# prog4 2 3 4 5 6 7 8 OxOOO : irmovq $10,%rdx I F D E M w OxOOa : irmovq $3,%rax F D . E,\u0026rsquo; M w Ox014: addq %r dx 丛r ax F D · E M I W Ox016: halt 了F : D E IM I w e _ valE 仁 0 + 3 = 3 E_dstE = %rax\n图 4- 46 pro g 4 的 流水线化的执行,没 有 特 殊的 流水线控制。在周期 4 , a ddq 指 令从 寄存 器文件中读源操作数。对寄存器 r% dx 的 写 仍 处 在访 存 阶 段 ,而执 行阶段正在计算寄存器r% ax 的新 值 。 两个操作数va l A 和 v a l B 得 到的 都 是 错 误 值\nm 列举数据冒险的类型\n当一 条指令 更新后 面指令会读 到 的 那 些 程 序 状 态 时, 就有 可能 出 现 冒 险。 对 于Y86- 64 来说 ,程 序 状态 包括 程序寄存 器、 程 序计数 器、 内存 、条 件码寄存 器和 状 态寄存器。 让我们来看看在提 出的 设计中每 类状 态出 现冒险 的可能性。\n程序寄存器: 我们已经认 识这种冒险 了。 出现 这种冒险是 因 为寄存器文件的读写是在不同的阶段进行的, 导致不同指令之间可能出现 不希望的 相互作用。\n程序计数器: 更新和读取程序计数 器之 间的 冲突导致了控制冒险。 当我 们的取指阶段逻辑在取下一 条指令之前, 正 确预测了程 序 计数 器的 新值时, 就不会 产 生冒险。预测错误 的分支和r 釭 指令需要特殊的处理,会 在 4. 5. 5 节中讨论。\n内存: 对数 据 内存的 读和写都 发生在访 存阶段。在一条读内存的指令到达这个阶段之前, 前面所有要 写内存的 指令都已经完成这个阶段 了。 另外 ,在 访存阶段中写数 据的 指令和在取指阶段中读指令之间也有冲突 , 因为指 令 和数 据内存 访问的是同一个地址空间 。只有包含自我修改代码的程序才会发生这种情况,在这样的程序中,指令写内存的一部分, 过后会从中取出指 令。有些 系统有复杂的机制来检测和 避免 这种冒险, 而有 些 系统只是简单地强制要 求程序不应该使 用自我修改代码。为 了 简便 ,假 设 程序不能修 改自身,因此我们 不需要 采取特殊的措施 ,根 据在程序执行过程中对数据内存的修 改来修改指令内存。\n条件码寄存器: 在执行阶段中,整 数 操作会写这 些寄存 器。 条件传送指令会在执行阶段以及条件转移会在访存阶段读这些寄存器。在 条件传送或转移到达执行阶段之前, 前面所 有的 整数操作都已经完成 这个阶段 了。 所以不会发 生冒险。\n状态寄存器: 指令流经流水线的时候,会 影响程序状 态。 我们采用流水线中的 每条指令都与一个状态码相关联的机制,使得当异常发生时,处理器能够有条理地停止,就 像在 4. 5. 6 节中会讲到的那样。\n这些分析表明我们只需要处理寄存器数据冒险、控制冒险,以及确保能够正确处理 异常。 当设 计一个复 杂 系统时, 这样的分类分析是很重要 的。这样做可以确认 出 系统实现中可能的 困难,还 可以指导生成 用 于检查 系统正确性的测试 程序。\n用暂停来避免数据冒险\n暂停 ( s ta ll ing ) 是避免冒险的一种常用技术,暂 停 时 ,处 理 器 会 停 止 流水线中一条或多条指令,直到冒险条件不再满足。让一条指令停顿在译码阶段,直到产生它的源操作数的指令通过了写回阶段, 这样我们的处理器就能避免数据冒险。这种机制的细节会在 4. 5. 8 节 中讨论 。它 对流水线控制逻辑做了一些简单的加强。图 4-47 ( p r o g 2) 和图 4-48 (prog4) 中画出了暂停的效果。(在这里的讨论中我们省略了 pr o g 3 , 因为它的运行类似于其他两个例子。)当指令 a d dq 处于译码阶段时, 流水线控制逻辑发现执行、访存或写回阶段中至少 有一 条 指令 会更 新寄存器%r d x 或 %r a x 。 处理器不会让 a ddq 指令带着不正确的结果通过 这 个阶段,而 是 会 暂 停 指 令 ,将 它阻 塞 在译码阶段,时 间 为一个周期(对pr o g 2 来说)或者三个 周 期(对 pr og 4 来说)。对所有这三个程序来说, a d d q 指 令 最终都会在周期 7 中得到两个源操作数的正确值,然后继续沿着流水线进行下去。\n将 addq 指令阻塞在译码阶段时, 我们还必须将紧跟其后的 ha lt 指令阻塞在取指阶段 。通过将程序计数器保持不变就能做到这一点, 这样一来,会 不断地对 ha lt 指令进行取指,直到暂停结束。\n暂停技术就是让 一组指令阻塞在它们所处的阶段, 而允许其他指令继续通过流水 线。那么在本该正常处理 a d d q 指令的阶段中, 我们该做些什么呢? 我们使用的处理方法是: 每次要把一条 指令阻塞在译码阶段 , 就在执行阶段插入一个气泡。气泡就像 一个自动产生的 no p 指令—— 它不会改变寄存器、内存、条件码或程序状态。在图 4- 4 7 和图 4- 4 8 的流 水线图中 ,白 色方框表示的就是气泡。在这些图中, 我们用一个 a d d q 指令的标号为 \u0026quot; D\u0026quot; 的方框到标 号为 \u0026quot; E\u0026quot; 的方 框之间的箭头来表示 一个流水线气泡, 这些箭头表明 ,在 执行阶段中插 入气泡是为了替代 a d d q 指令,它 本来应该经过译 码阶段进入 执行阶段。在\n5. 8 节中 , 我们将 看到使流水线暂停以及插入气泡的详 细机制。 # prog2 OxOOO: irmovq $10,%rdx OxOOa: irmovq $3,%rax Ox014: nop Ox015: nop bubble Ox016: addlq ;r儿 dx , ¼r ax Ox018 : halt 1 2 3 4 5 6 7 8 9 10 11 I F D E M w F D E M w F D E M w F D E M w 广 E M w F D D E M w F F D E M w l 图 4-47 p ro g2 使用暂停的流水线化的执行。在周期 6 中对 ad dq 指令译码之后, 暂停控制逻辑发 现一个数据冒险, 它是由写回阶段中对寄存 器%r a x 未进行 的写造成的。它在执行 阶段中 插人一个气泡,并 在周期 7 中重复对指令 a ddq 的译码。实际上, 机器是 动态地插入一条 nop 指令, 得到的执行流类似于 p ro g l 的执行流(图4-43)\n# prog4 2 3 4 5 6 7 8 9 10 11\n图 4- 48 p r og 4 使用暂 停的流水线化的执行。在周期 4 中对 addq 指令译码 之后,暂停控制逻辑发现了对两个 源寄存器的 数据冒险 。它在执行阶段中插入一个气泡, 并在周期 5 中重复对指令 a ddq 的译码。它再次发现对 两个源寄存 器的冒险 , 就在执行阶段中插入一 个气泡, 并在周期 6 中重复对指令 a ddq 的译码。它再次发 现对寄存 器釭a x 的冒险,就在 执行阶段中插入一个气泡 , 并在周期 7 中重复对指令 addq 的译码。实际上, 机器是动态地插入 三条 no p 指令,得到的执行 流类 似于 p r og l 的执行流(图4-43)\n在使用暂停技术来 解决数据冒险的过程中, 我们通过 动态地 产生和 pr o g l 流(图4- 4 3 ) 一样的 流水线流,有 效地执行了程序 pr o g 2 和 pr o g 4。为 p r o g 2 插入 1 个气泡, 为 p r o g 4 插入 3 个气泡, 与在第 2 条 ir mo v q 指令和 a d d q 指令之间有 3 条 n o p 指令, 有相同的效果。虽 然实现这一机制相当容易(参考家庭作 业 4. 53), 但是得到的性能并不很好。一条指令更新一个寄存器,紧跟其后的指令就使用被更新的寄存器,像这样的情况不胜枚举。这会导致流水线暂停长达三个周期,严重降低了整体的吞吐量。\n用转发来避免数据冒险\nPIPE - 的设计是在译码阶段从寄存器文件中读入源操作数 , 但是对这些源寄存器的写有可能要在写回阶段才能进行。与其暂停直到写完成,不如简单地将要写的值传到流水线寄存 器 E 作为源操作数 。图 4-49 用 pr og 2 周期 6 的流水线图 的扩展描述来说明 了这一策略。译码阶段逻辑发现 , 寄存器%r a x 是操作数 v a l B 的源寄存器 , 而在写端口 E 上还有一个对 %r a x 的未进行的写。它只要简单地将提供到端口 E 的数据字(信号 W_ va l E) 作为操作数 v a l B 的值,就能避免暂停。这种将结果值直接从一个流水线阶段传到较早阶段的技术称为数据转发(data forwarding, 或简称转发, 有时称为 旁路( bypassing ) ) 。它使得pr og 2 的指令能通过流水线而不需要任何暂停。数据转发需要在基本的硬件结构中增加一些额外的数据连接和控制逻辑。\n# prog2 10\nOxOOO: irmovq $10丛r dx w\nOxOOa: irmovq $3,%rax M\nOx014: nop w\nOx015: nop M w\nOx016: addq %rdx,%rax M w\nOx018: halt w\n图 4- 4 9 pr og2 使用 转发的 流水线化的执行。在周期 6 中,译 码阶段逻辑发现有在写回 阶段中\n对寄存器r% ax 未进行的写。它用这个 值, 而不是从寄存器文件中读出的值, 作为源\n操作数 va l B\n如图 4- 50 所示, 当访存阶段中有对寄存器未进行的写时,也 可以使用数据转发, 以避免程 序 p r o g 3 中的暂停。在周期 5 中, 译码阶段逻辑发 现, 在写回阶段中端口 E 上有对寄存器%r d x 未进行的 写, 以及在访存阶段中有会在端口 E 上对寄存器%r a x 未进行的写。它不会暂停直到这些写真正发生,而 是用写回阶段中的值(信号 W_ v a l E) 作为操作数 va­\nlA, 用访存阶段中的值(信号 M_ v a l E) 作为操作数 v a l B。\n为了充分利用数据转发技术,我们还可以将新计算出来的值从执行阶段传到译码阶段, 以避免程序 pr o g 4 所需要的暂停,如图 4-51 所示。在周期 4 中, 译码阶段逻辑发现在访存阶段中有对寄存器 %r dx 未进行的写,而 且执行阶段中 ALU 正在计算的值稍后也会写入寄存器%r a x 。 它 可以将访存阶段中的 值(信号M_ v a l E) 作为操作数 v a l A, 也可以将 ALU 的输出\n(信号 e _v a l E) 作为操作数va l B。注意, 使用 ALU 的输出不会造成任何时序间题 。译码阶段只要在时钟周期结束之前产生信号 va l A 和 va l B, 这样在时钟上升开始下一个周期时,流水线寄存器 E 就能装载来自译码阶段的值了。而在此之前 ALU 的输出巳经是合法的了。\n# prog3 2 3 4 5 6 7 8 9\n图 4-50 pr og 3 使用转发的流水线化的执行。在周期 5 中,译 码阶段逻辑发现有在写回阶段中对寄存器\n%r d x 未进行的写 , 以 及 在访存阶段中对寄存器%r a x 未进行的写。它用这些值, 而不是从寄存器文件中读出的值,作 为 v a l A 和 va l B 的 值\n# prog4 2 3 4 5 6 7 8\nOxOOO: irmovq $10,%rdx 厂户\nOxOOa : irmovq $3,%rax\nOx014: addq %rdx,%rax I F w\nOx016: halt s t) n i= M I W\n图 4-51 pro g 4 使 用 转发的 流水线化的执行。在周期 4 中,译 码 阶 段 逻辑 发现有在访存阶段中对寄存器%r d x 未进行的写, 还发现在执行阶段中正在计算寄存器% r a x 的 新 值 。 它 用 这些值,而 不 是 从 寄存 器文件中读出的值,作 为 va l A 和 va l B 的值\n程序 pr o g 2 ~ pr o g 4 中描述的转发技术的使用都是将 ALU 产生的以及其目标为写端 口 E 的值进行转发,其 实 也 可以 转发从内存中读出的以及其目标为写端口 M 的值。从访存阶段,我 们可以转发刚刚从数据内存中读出的值(信号 m_val M) 。从 写回阶段 , 我们可以转发对 端口 M 未进行的写(信号W_v al M)。这样一共就有五个不同的转发源Ce —v al E 、m v al M、M_v al E、W_v al M 和 W_v al E) , 以 及 两个 不同 的转 发 目的 ( v al A 和 v a l B) 。\nW_valE\nvalE valM dstE IdstMI 屯\n1W_valM\nm_valM\n访存\nM_Cnd M_valA\nstat licode\u0026rsquo; ~\n执行\n勺 , 1 stat licodel ifun\nW_valM W_valE\nifun I rA I rB\n指令内存\nM_valA W_valM\n图 4-52 流水线化的最终 实现一 PIPE 的硬件结构。添加的旁 路路径能够转发前 面三条指令的结果。这使得我们能够不暂停流水线 就处理大多数形式的 数据冒险\n图 4-49~ 图 4-51 的扩展图还表明译码阶段逻辑能够确定是使用来自寄 存器 文件的值, 还是要用转发过来的值。与每个要写回寄存器文件的值相关的是目的寄存器 ID 。逻辑会将这些 ID 与源寄存器 ID sr c A 和 sr c B 相比较,以 此来检测是否需要转发。可能有多个目的寄存 器 ID 与一个源 ID 相等。要解决这样的情况, 我们必须在各个转发源中建立起优先级关系。 在学习转发逻辑的详细设计时, 我们会讨论这个内容。\n图 4- 52 给出的是 P IP E 的结构, 它 是 P IP E — 的扩展,能 通过转发处理数据冒险。将这幅图与 P IP E 一的 结构(图 4- 41 ) 相比, 我们可以看到来自五个转发源的值反馈到译码阶段中两个标号为 \u0026quot; Sel + F w d A\u0026quot; 和 \u0026quot; F w d B\u0026quot; 的 块。标号为 \u0026quot; S e l + F w d A\u0026quot; 的 块 是 P IP E — 中标号为 \u0026quot; S elect A \u0026quot; 的块的功能 与转发逻辑的结合。它允许流水线寄存器 E 的 v a l A 为 巳增加的 程序计数器值 v a l P, 从寄存器文件 A 端口读出的值, 或者某个转发过来的值。标号为 \u0026quot; F w d B\u0026quot; 的块实现的是源操作数 v a l B 的转发逻辑。\n加载/使用数据冒险\n有一类 数据冒险不能单纯用转发来解决,因 为内存读在流水线发生的比较晚。图 4-53 举例说明了加 栽/使 用冒险 Cload / use hazard) , 其中一条指令(位于地址 Ox 028 的 mrmovq ) 从 内 存中读出寄存器%r a x 的 值 ,而 下 一 条 指 令(位于地址 Ox 0 32 的 a d d q ) 需 要 该 值 作 为源操作数。图的下部是 周期 7 和 8 的扩展说明, 在此假设所有的程序寄存器都初始化为 0。a d d q 指令在周期 7 中需要该寄存器的值,但 是 mrmovq 指令直到周期 8 才产生出这个值。为了从 mr mo vq “转发到\u0026quot; addq, 转发逻辑不得不将值送回到过去的时间!这显然是不可能的,我们必须找到其他 机制来解决这种形式的数据冒险。(位于地址 Ox Ol e 的 i r mo vq 指 令产 生的寄存器%r b x 的值,会被位 于地址 Ox 032 的 a ddq 指 令 使用 , 转发能够处理这种数据冒险。)\n# prog5 2 3 4 5 6 7 8 9 10 11\n图 4-53 加载/使用数据冒险的示例。addq 指令在周期 7 译码阶段中需要寄存器%r a x 的值。前 面的\nmr mo v q 指 令 在 周 期 8 访 存 阶段 中 读出这个寄存器的新值, 这对千 addq 指令来说太迟了\n如图 4-54 所示, 我们可以将暂停和转发结合起来, 避免加载/使用数据冒险 。这个需要修改控制逻辑 , 但是可以使用现有的旁路路径。当 mr mo v q 指令通过执行阶段时,流水 线控制逻辑发现译码阶段中的指令( a d d q ) 需要从内存中读出的结果。它会将译码阶段中的指令暂停一个周期,导 致执行阶段中插入一个气泡。如周期 8 的扩展说明所示 ,从 内存中读出的值可以从 访存阶段转发到译码阶段中的 a d d q 指令。寄存器%r b x 的值也可以从 访存阶段转发到译码阶段。就像流水线图 ,从 周 期 7 中标号为 \u0026quot; D\u0026quot; 的方框到周期 8 中标号为\u0026quot; E\u0026quot; 的方框的箭头表明的那样, 插入的 气泡代替了正常情况下本 来应该继续通过流水 线的 a d d q 指令。\n# prog5 2 3 4 5 6 7 8 9 10 11 12 OxOOO: irmovq $128,%rdx F D E M w OxOOa: irmovq $3,%rcx F D E M w Ox014 : rmmovq %rcx, 0(%rdx) F D E Ox01e : irmovq $10,%rbx F D E Ox028: mrmovq 0(%rdx),%rax # Load o/.rax F D bubble E M w\nOx032 : addq %rbx,r; 人 ax # Use %rax Ox034: halt\nF I E\nI M Iw\n图 4-54 用暂停来处 理加载/使用冒险。通过将 a ddq 指令在译码阶段暂停一个周期 , 就可以将 va lB\n的值从访存阶段中的 mr movq 指令转发到译码 阶段中的 a ddq 指令\n这种用暂停来处理加载/使用冒险的方法称为加载互 锁 Clo a d in t e rl o ck ) 。加载互锁和转发技术结合起来足以处理所有可能类型的数据冒险。因为只有加载互锁会降低流水线的 吞吐量,我 们几乎可以 实现每个时钟周期发 射一条新指令的吞吐量目标。\n避免控制冒险\n当处理器无法根据处于取指阶段的当前指令来确定下一条指令的地址时,就会出现控 制冒险。如同 在 4. 5. 4 节讨论过的, 在我们的流水 线化处理器中, 控制冒险只会发生在\nr e t 指令和跳转指 令。而且, 后一种情况只有在条件跳转方向预测错误时才会造成麻烦。在本小节中, 我们概括介 绍如何来处理这些冒险。作为对流水线控制更一般性讨论的一部\n分, 其详细实现将在 4. 5. 8 节给出。\n对于r e t 指令, 考虑下面的示例程序。这个程序是用汇编代码表示的 ,左 边是各个指令的地址,以供参考:\nOxOOO: irmovq stack,%rsp # OxOOa: call proc # Ox013: irmovq $10,%rdx # Ox01d: halt\nOx020 : . pos Ox20\nInitialize stack pointer Procedure call\nReturn point\nOx020: proc: Ox020 : ret\nOx021: rrmovq %rdx, %rbx Ox030: . pos Ox30\nOx030: stack:\n# proc:\n# Return immediately\n# Not executed\n# stack: Stack pointer\n图 4-55 给出了 我们希望流水线如何来处理r e t 指令。同前面的 流水线图一样, 这幅图展示了 流水线的活动, 时间从左向右增加。与前面不同的 是, 指令列出的顺序与它们在程序中出现的顺序并不相同,这是因为这个程序的控制流中指令并不是按线性顺序执行 的。看看指令的地 址就能看出它们在程序中的位置。\n# prog7\nOxOOO: irmovq Stack,%edx OxOOa: call proc\nOx020: ret\nbubble bubble bubble\nOx013: irmovq $10,%rdx # Return point\n2 3 4 5 6 7 8 9 10 11\n图 4-5 5 r e t 指令处理的简化视图 。当 r e t 经过译码 、执行和访存阶段 时,流 水线应该暂停,在处 理过程中插人三个气泡。一旦ret 指令到达写回阶段(周期7) , PC选择逻辑就会选择返回地址作 为指令的取指地址\n如这张 图所示 , 在周期 3 中取出r e t 指令, 并沿着流水线前 进,在周期 7 进入写回阶段。在 它经过译码、执行和访存阶段时, 流水线不能做任何有用的活动。我们只能在流水 线中插入三 个气泡。一旦r e 七指令到达写回阶段, P C 选择逻辑 就会将程序计数器设 为返回地址, 然后取指阶段就会取出位于返回点(地址Ox 013 ) 处的 ir mo v q 指令。\n要处理预测错误的分支,考虑下面这个用汇编代码表示的程序,左边是各个指令的地 址, 以供参考:\nOxOOO: Ox002:\nOxOOb: Ox015: Ox016: Ox016: Ox020:\nOx02a:\nxorq %rax,%rax jne target irmovq $1, %rax halt\ntarget:\nirmovq $2, %rdx irmovq $3, %rbx halt\n# Not taken\n# Fall through\n# Target\n# Target+!\n图 4-56 表明是如何处理这些指令的。同前面一样, 指令是按照它们进入流水线的顺\n序列出的,而不是按照它们出现在程序中的顺序。因为预测跳转指令会选择分支,所以周期 3 中会取出位于跳转目标处的指令, 而周期 4 中会取出该 指令后的那条指令。在周期 4, 分支逻辑发现不应该选择分支之前,已经取出了两条指令,它们不应该继续执行下去了。幸运的是,这两条指令都没有导致程序员可见的状态发生改变。只有到指令到达执行阶段时才会发 生那种情况, 在执行阶段中, 指令会改变条件码 。我们只要在下一个周期往译码和执行阶段中插入气泡,并同时取出跳转指令后面的指令,这样就能取消(有时也称为指令排除( in s t ru c t io n s q u a s h in g ) ) 那两条预 测错误的指令。这样一来, 两条预测错误的指令就会简单地从流水线中消失,因此不会对程序员可见的状态产生影响。唯一的缺点是两个时钟周期的指令处理能力被浪费了。\n# prog7\nOxOOO: xorq 1r儿\nax , %r ax\n2 3 4 5 6 7 8 9 10\nF I D I E I M I W\nOx002: jne target # Not taken Ox016: irmovl $2,:r人 d x # Target\nbubble\nOx020: irmovl $3,%rbx # Target+1 bubble\nOxOOb: irmovq $1,%rax # Fall through Ox015: halt\nF I I I M Iw\nE I M I W\n三 l w\n图 4-56 处理预测错误的分支指令。流水线预测会选择分支,所以开始取跳转目标处的指令。在周期 4 发现预测错误 之前, 已经取出了两条指令, 此时, 跳转指令正在通过执行阶段。在周期 5 中, 流水线往译码和执行阶段 中插入气泡, 取消了两条目标指令, 同时还取出跳转后面的那条指令\n对控制冒险的讨论表明,通过慎重考虑流水线的控制逻辑,控制冒险是可以被处理 的。在出现特殊情 况时 ,暂停 和往流水 线中插入气泡的技术可以 动态调整流水 线的流程。如同我们将在 4. 5. 8 节中讨论的一样 , 对基本时钟寄 存器设计的简单扩展就可以让我们暂停流水段,并向作为流水线控制逻辑一部分的流水线寄存器中插入气泡。\n4. 5. 6 异常处理\n正如第 8 章中将讨论的, 处理器中很多事情都 会导致异常控制流, 此时, 程序执行的正常流程被破 坏掉。异常可以由程序执行从内部产生,也 可以由某个外部信号从外部产生。我们的指令集体系结构包括三种不同的内部产生的异常: 1) h a lt 指令, 2 ) 有非法指令 和功能码组合的指令, 3 ) 取指或数据读写试图访问一个非法地址。一个更完整的处理器设计应该也能处理外部异常,例如当处理器收到一个网络接口收到新包的信号,或是一个 用户点击鼠标按钮的信号。正确处理异常是任何微处理器设计中很有挑战性的一方面。异 常可能出现在不可预测的时间,需要明确地中断通过处理器流水线的指令流。我们对这三 种内部异常的处理只是让你对正确发现和处理异常的真实复杂性略有了解。\n我们把导致异 常的指令称为异常指 令( e x c e p t in g in s t ru c t io n ) 。 在使用非法指令地址的情况中, 没有实际的异常指令, 但是想象在非法地址处有一种“虚拟指令” 会有所帮助。在简化的 ISA 模型中, 我们希望 当处理器遇到异常时 , 会停止, 设置适当的状态码,如图\n4-5 所示。看上去应该是到异常指令之前的所有指令都已经完成 , 而其后 的指令都不应该对程序员可见的状态产生任何影响 。在一个更完整的设 计中, 处理器会继续调用异常处理\n程序 ( e xce pt io n handler), 这是操作系统的一部分,但是实现异常处理的这部分超出了本书讲述的范围。\n在一个流水线化的系统中,异常处理包括一些细节问题。首先,可能同时有多条指 令会引 起异常。例如, 在一个流水线操作的周期内,取 指阶段中有 h a lt 指令, 而数据内存会报告访存阶段中的指令数据地址越界。我们必须确定处理器应该向操作系统报告 哪个异常。基本原则是:由流水线中最深的指令引起的异常,优先级最高。在上面那个 例子中,应该报告访存阶段中指令的地址越界。就机器语言程序来说,访存阶段中的指 令本来应该在取指阶段中的指令开始之前就结束的,所以,只应该向操作系统报告这个 异常。\n第二个细节问题是 , 当首先取出一条指令 , 开始执行 时, 导致了一个异常, 而后来由于分支预测错误,取消了该指令。下面就是一个程序示例的目标代码:\nOxOOO: 6300 xorq %rax,%rax\nOx002: 741600000000000000 I jne target # Not taken\nOxOOb: 30f00100000000000000 I irmovq $1, 1r儿\nOx015: 00 I halt\nOx016: I target:\nax # Fall through\nOx016: ff .byte OxFF # Invalid instruction code\n在这个程序中 , 流水线会预测选择分支, 因此它会取出并以一个值为 Ox FF 的字节作为指令(由汇编代码中 . b y t e 伪指令产生的)。译码阶段 会因此发现一个非法指令异常。稍后,流水线会 发现不应该选 择分支, 因此根本就不应该取出位于地址 Ox 01 6 的指令。流水线控制逻辑会取消该指令,但是我们想要避免出现异常。\n第三个细节问题的产生是因为流水线化的处理器会在不同的阶段更新系统状态的不同部分。有可能会出现这样的情况,一条指令导致了一个异常,它后面的指令在异常指令完 成之前改 变了部分状态。比如说 , 考虑下面的代码序列, 其中假设不允许用户程序访问 64 位范围的高端地址:\nirmovq $1,%rax\nxorq %rsp,%rsp pushq %rax\naddq %rax,%rax\n# Set stack pointer to O and CC to 100\n# Attempt to write to Oxfffffffffffffff8\n# (Should not be executed) Would set CC to 000\npus hq 指令导致一个地址异常, 因为减小 栈指针会导致它绕回到 Ox f f f f f f f f f f f f f f f8 。访存阶段中会发 现这个异常。在同一周期中, a d d q 指令处于执行阶段, 而它会将条件码 设置成新的值 。这就会违反异常指令之后的所有指令都不能 影响系统状态的要求 。\n一般地 , 通过在流水线结构中加入异常处理逻辑 , 我们既能 够从各个异常中做出正确的选择,也能 够避免出 现由千分 支预测 错误取出的指令造成的异常。这就是为什么我们会在每个流水 线寄存器中包括一个状态码 s t a 七(图 4-41 和图 4- 52 ) 。如果一条指令在其处理中于某个阶段 产生了一个异常, 这个状态字段就被设置成指示异常的种类。异常状态和该指令的其他信息一起沿着流水线传播,直到它到达写回阶段。在此,流水线控制逻辑发现 出现了异常,并停止执行。\n为了避免异 常指令之后的指令更新任何程序员可见的状 态, 当处千访存或写回阶段中的指令导致 异常时 , 流水线控制逻辑必须禁 止更新条件码寄存器或是数据内存。在上 面的示例程序 中, 控制逻辑会发现访存阶段中的 p u s hq 导致了异常, 因此应该禁止 a d d q 指令更新条件码寄存器。\n让我们来看看这种处理异常的方法是怎样解决刚才提到的那些细节问题的。当流水线 中有一个或多个阶段出现异常时,信息只是简单地存放在流水线寄存器的状态字段中。异 常事件不会对流水线中的指令流有任何影响,除了会禁止流水线中后面的指令更新程序员 可见的状态(条件码寄存器和内存),直到异常指令到达最后的流水线阶段。因为指令到达写回阶段的顺序与它们在非流水线化的处理器中执行的顺序相同,所以我们可以保证第一 条遇到异常的指令会第一个到达写回阶段,此 时程序执行会停止, 流水 线寄存器 W 中的状态码会被记录为程序状态。如果取出了某条指令,过后又取消了,那么所有关于这条指 令的异常状态信息也都会被取消。所有导致异常的指令后面的指令都不能改变程序员可见 的状态。携带指令的异常状态以及所有其他信息通过流水线的简单原则是处理异常的简单 而可靠的机制。\n5 . 7 P IPE 各 阶 段 的 实现\n现在我们已 经创建了 PIPE 的整体结构, PIP E 是我们使用了转发技术的流水线化的Y8 6- 64 处理器。它使用了一组与前 面顺 序设 计相同的硬件单元, 另外增加了一些流水线寄存器、一些重新配置了的逻辑块,以及增加的流水线控制逻辑。在本节中,我们将浏览 各个逻辑块的设计,而 将流水 线控制逻辑的设计放到下一节中介绍。许多逻辑块与\u0026rsquo; SEQ 和 SEQ+ 中相应部件完全相同, 除了我们必须 从来自不 同流水线寄存器(用大写的流水线寄存器的名字作为前缀)或来自各个阶段计算(用小写的阶段名字的第一个字母作为前缀) 的信号中选择适当的值。\n作为一个示 例,比较 一下 SEQ 中产生 sr c A 信号 的 逻辑的 HCL 代码与 PIPE 中相应的代码:\n# Code from SEQ\nword srcA = [\nicode in { IRRMOVQ, IRMMOVQ, IOPQ, IPUSHQ } : rA; icode in { IPOPQ, IRET} : RRSP;\n1 : RNONE; # Don\u0026rsquo;t need register\n];\n# Code from PIPE\nword d_srcA = [\nD_icode in { IRRMOVQ, IRMMOVQ, IOPQ, IPUSHQ } : D_rA; D_icode in { IPOPQ, IRET} : RRSP;\n1 : RNONE; # Don\u0026rsquo;t need register\n];\n它们的不同之处只在于 PIPE 信号都加上了前缀: \u0026quot; D_ \u0026quot; 表示源值 , 以表明信号是来自流水线 寄存器 D , 而 \u0026quot; ct_\u0026quot; 表示结果值, 以表明它是在译码阶段中产生的。为了避免重复, 我们在此就不列出那些与 SEQ 中代码只有名字前缀不同的块的 HCL 代码。网 络旁注ARCH : HCL 中列出了完整的 PIPE 的 HCL 代码。\nPC 选择和取指阶段\n图 4-57 提供了 PIPE 取指阶段逻辑的一 个详细描述。像前面讨论过的那样, 这个阶段必 须选择程序计数 器的当前值 , 并且预测下一个 PC 值。用于从内存中读取指令和抽取不同指令字段的 硬件单元与 SEQ 中考虑的那些一样(参见4. 3. 4 节中的取指阶段)。\n溺 s tat licodel ifun I rA I rB\nvalC 陑\nM_icode\nM_Cnd\nj M_valA\nj I W_icode\n. : Need\n, \u0026hellip;\u0026hellip;\n..勺., • ;. i\n, _:: PC 、 :\n::\n令 i valC ;\nPC j\nNeed \u0026rsquo; 增加 i re gids;;.-,\u0026quot; i\n厂令内存罕 气 , I I\n亏 L I\n图 4-5 7 PI PE 的 PC 选择 和取指逻辑。在一个周期的时间限 制内 , 处理器只能预测下 一条指令的 地址\nPC 选择逻辑从 三个程序计数 器源中 进行选择。当一条预测错误的分支进入访存阶段时, 会从流水线寄存器 M ( 信号 M_ v a l A) 中读出该指令 v a l P 的值(指明下一条指令的地址)? 当r 吐 指令进入写 回阶段时, 会从流水线寄存器 W ( 信号 W_ v a l M) 中读出返回地址。其他情况会使用存 放在流水线 寄存器 F ( 信号 F_p r e d PC) 中的 P C 的预测值:\nword f_pc = [\n# Mispre\u0026rsquo;.iicted branch. Fetch at incremented PC M_icode == IJXX \u0026amp;\u0026amp; !M_Cnd: M_valA;\n# Completion of RET instruction W_icode == IRET: W_valM;\n# Default: Use predicted value of PC\n1 : F_predPC;\n];\n当取出的 指令为函数调用或跳转时 , P C 预测逻辑会选择 v a l e , 否则就会选择 v a l P: word f_predPC = [\nf_icode in { IJXX, ICALL} : f_valC;\n1 : f_valP;\n];\n标号为 \u0026quot; I n s t r valid \u0026quot; 、\u0026quot; N eed r egid s\u0026quot; 和 \u0026quot; N eed va!C\u0026quot; 的逻辑块和 SEQ 中的一样,使用了适当命名的源信号。\n同 SEQ 中不一样,我们 必须 将指令状态的计算分成两个部分。在取指阶段, 可以测试由千指令 地址越界引 起的内存错误,还 可以发现非法指令或 h a lt 指令。必须推迟到访\n存阶段才能发现非法数据地址。\n练习题 4. 30 写 出信号 f _ s t a t 的 H CL 代码 , 提供取出的 指令的临 时状 态。\n译码和写回阶段\n图 4-58 是 PIP E 的译码和写回逻辑的详细说明。标号为 \u0026quot; cts t E \u0026quot; 、 \u0026quot; cts t M\u0026quot; 、 \u0026quot; s r c A\u0026quot; 和 \u0026quot; sr c B \u0026quot; 的块非常类似千它们在 SEQ 的实现中的相应部件。我们观察到, 提供给写端口的寄存器 ID 来自于写回阶段(信号 W_ d s t E 和 W_ d s t M) , 而不是来自于译码阶段。这是因为我们希望进行写的目的寄存器是由写回阶段中的指令指定的。\ne_dstE\n图 4-58 PIPE 的译码和写回阶段逻辑。没有指令既需要 val P 又需要来自寄存器端口 A 中读出的值,因此对后面的阶段来说 , 这两者可以合并为信号 val A。标号为 \u0026quot; Sel + F wd A \u0026quot; 的块执行该任 务,并实现源操作数 val A 的转发逻辑。标号为 \u0026quot; F wd B\u0026quot; 的块实现源操作数 val B 的转发逻辑。寄存器写的位置是由 来自写回 阶段的 d s t E 和 ds t M 信号指定 的, 而不是来自千译 码阶段, 因为它 要写的是当前正在写回阶段中的指令的结果\n练习题 4. 31 译码 阶段中标号为 \u0026quot; d s t E \u0026quot; 的块根据来 自 流水线寄 存器 D 中 取出的指令的各 个 字 段, 产生寄存器文件 E 端 口 的寄存器 ID。在 PIP E 的 H CL 描述 中, 得到的信号命名 为 d _ d s t E 。 根据 S EQ 信号 d s 七E 的 H CL 描述, 写 出这 个信号 的 H CL 代码。(参考 4. 3. 4 节 中的译码 阶段。)目前还不 用 关心 实现 条件传 送的逻辑。\n这个阶段的复杂性主要是跟转发逻辑相关。就像前面提到的那样,标 号 为 \u0026quot; Sel + Fwd\nA\u0026quot; 的块扮演两个角色。它为后 面的阶段将 v a l P 信号合并到 v a l A 信号,这 样 可以减少流水 线 寄存器中状态的数量。它还实现了源操作数 v a l A 的转发逻辑。\n合并信号 v a l A 和 v a l P 的依据是,只 有 c a l l 和跳转指令在后面的 阶段中需要 v a l P 的值, 而这些指令并不需要从寄存器文件 A 端口中读出的值。这个选择是由该阶段的 i code 信号来控制的。当信号 D_ i c o d e 与 c a l l 或 j XX 的 指令代码相匹配时 , 这个块就会选择 D_ v a l P 作为它 的输出。\n5. 5 节中提到有 5 个不同的转发源, 每个都有一个数 据字和一个目的寄存器 ID : 数据字 寄存器 ID 源描述 e val E e ds t E ALU 输 出 m val M M ds t M 内 存 输 出 M val E M dstE 访存阶段中对端口 E 未进 行 的 写 W val M W dstM 写 回阶 段中 对 端 口 M 未进行的写 W val E W dstE 写回阶段中对端口 E 未 进 行的 写 如果不满足任何 转发条件, 这个块就应该选择 d—r v a l A 作为它的输出,也 就是从寄存器端 口 A 中读出的值。\n综上所述 , 我们得 到以下流水线寄存器 E 的 v a l A 新值的 H CL 描述:\nword d_val A = [\nD_icode in { ICALL, IJXX} : D_valP; # Use incremented PC d_srcA == e_dstE: e_valE; # Forward valE from execute d_srcA == M_dstM: m_valM; # Forward valM from memory d_srcA == M_dstE: M_valE; # Forward valE from memory d_srcA == W_dstM: W_valM; # Forward valM from write back d_srcA == W_dstE: W_va l E; # Forward valE from write back\n1 : d_rvalA; # Use value read from register file\n];\n上述 H CL 代码中赋予这 5 个转发源的优先 级是非常重要的。这种优先级是由 HCL 代码中检测 5 个目的寄存器 ID 的顺序来确定的。如果选择了其他任何顺序, 对某些程序来说, 流水线就会出错。图 4-59 给出了一个程序示例,要 求对执行和访存阶段中的转发源设置正确的优先级。在这个程序中 , 前两条指令写寄存器%r dx , 而第三条指令用这个寄存器作为它的 惊操作数。当指令r r mo vq 在周期 4 到达译码阶段时, 转发逻辑必须在两个都以该 源寄存 器为目的的值中选择一个。它应该选择哪一个呢?为了设定优先级,我们必须考虑当一次执行一条指令时 , 机器语言程序 的行为。第一条 i rmovq 指令会将寄存器%r dx 设 为 10 , 第二条 i rmovq 指令会将之设为 3, 然后r rmovq 指令会从%r dx 中读出 3。为了模 拟这种行为,流 水线化的实现应 该总是给处于最早流水线阶段中的转 发源以较高的优先级, 因为它保 持着程序序列中设置该寄存器的最近的指令。因此,上述 H CL 代码中的逻辑首先会检测执行阶段中的转发源, 然后是访存阶段 , 最后才是写回阶段。只有指令 pop q %r s p 会关心在访存或写回阶段中的两个源之间的转发优先 级, 因为只有这条指令能同时写两个寄存器。\n可 练习题 4. 32 假设 d v a l A 的 H C L 代码中第三和 第四种 情况(来 自 访存阶段的 两个 转发源)的顺序是 反过来的。 请描 述下列程序中 r r mo v q 指令(第5 行)造成的行 为 :\nirmovq $5, %rdx irmovq $0x100,%rsp rmmovq %rdx,0(%rsp) popq %rsp\nrrmovq %rsp,%rax\n# prog8 2 3 4 5 6 7 8\nOxOOO: irmovq $10,%rdx I F I D OxOOa: irmovq $3,%rdx\nOx014: rrmovq %rdx,%rax Ox016: halt\n归l w\n图 4-59 转发优先级的说明。在周 期 4 中,%r dx 的 值既可以从执行阶段也可以从访存阶段得到. 转发逻辑应该选择执行阶段中的值,因为它代表最近产生的该寄存器的值\n练习题 4 . 33 假设 d _ v a l A的 HCL 代码中第五和第 六种情况(来自写 回 阶段的 两个转发源)的顺序是反过来的。写 出 一个 会运行错误的 Y86-64 程 序。 请描述错误 是如何发生的,以及它对程序行为的影响。\n练习题 4 . 34 根据提供到 流水线寄存器 E 的源操作数 v a l B 的值, 写 出 信号 d _ v a lB\n的 HCL 代码。\n写 回 阶 段 的 一 小 部 分 是 保 持 不 变 的 。 如图 4- 5 2 所示, 整 个 处 理 器的状态 St a t 是一个 块 根据流水线寄存器 W 中的状态值计算出来的。回想一下 4. 1. 1 节 , 状 态 码 应该指明 是 正 常 操 作 ( AOK) , 还是三种异常条件中的一种。由于流水线寄存器 W 保存着最近完成的指令的状态,很自然地要用这个值来表示整个处理器状态。唯一要考虑的特殊情况 是当写回阶段有气泡时。这是正常操作的一部分,因此对于这种情况,我们也希望状态 码是 AOK:\nword Stat = [\nW_stat == SBUB: SAOK;\n1 : W_stat;\n];\n3 . 执行阶段\n图 4-60 展现的是 PIPE 执行阶段的逻辑。这些硬件单元和逻辑块同 SEQ 中的相同, 使 用 的 信 号 做 适当的重命名。我们可以看到信号 e —v a l E 和 e _ d s t E 作为转发源, 指向译 码 阶 段 。 一 个 区 别 是 标 号 为 \u0026quot; Se t CC\u0026quot; 的逻辑以信号 m_ s 七a t 和 W_ s t a t 作 为输入, 这个; 逻辑决定了是否要更新条件码。这些信号被用来检查一条导致异常的指令正在通过后面的 ' 流水线阶段的情况,因 此, 任 何 对 条 件 码 的 更 新 都 会 被 禁止。这部分设计在 4. 5. 8 节中; 讨论。\ne_valE e_dstE\n图 4-60 PIPE 的执行阶段逻辑 。这一部分的设 计与 SEQ 实现中的 逻辑非常相似\n练习题 4. 35 d _ va l A 的 HCL 代码 中的 第 二种 情况 使用 了 信号 e _d s t E , 来判断是否要选 择 ALU 的输出 e _v a l E 作为 转发源。 假设我们 用 E_ d s t E , 也就是流水线寄存器\nE 中的 目的寄存器 ID , 来作为这个选择。写出一个采用这个修改过的转发逻辑就会产生错 误结果的 Y86-64 程序。\n4 访存阶段\n图 4-61 是 P IP E 的 访 存阶段逻辑。将这个逻辑与 S E Q 的访存阶段(图4-30 ) 相比较 ,我们看到,正 如 前 面提到的那样, P IP E 中 没 有 SEQ 中标号为 \u0026quot; Da ta\u0026quot; 的块。这个块是用来在数 据源 v a l P( 对 c a l l 指令来说)和v a l A 中 进 行 选择的, 但 是 这个选择现在由译码阶段中标 号为 \u0026quot; S el + Fwd A\u0026quot; 的块来执行。这个阶段中的其他块都和 SEQ 中相应的部件相同,采 用的信号做适当的重命名。在图中,你 还 可以看到许多流水线寄存器 M 和 W 中的值作为转发和流水线控制逻辑的一部分,提供给电路中其他部分。\nW_valE W_valM W_dstE W_ds!M\nm_valM\nM_dstE M_dstM\nM_valA M_valE\n图 4-61 PIPE 的访存 阶段逻辑。许多从流水线寄存器 M 和 W 来的信号被传递到较早的阶段, 以提供写回的结果、指令地址以及转发的结果\n练习题 4. 36 在这个阶段中,通过检查数据内存的非法地址情况,我们能够完成状 态 码 S t a t 的计算。 写 出信号 m_ s t a 七的 H CL 代码。\n5. 8 流水线控制逻辑\n现在准备创建流水线控 制逻辑, 完成我 们的 PIP E 设计。这个逻辑必须处理下面 4 种控制情况,这些情况是其他机制(例如数据转发和分支预测)不能处理的:\n加载/使用冒险: 在一条从内存 中读出一个值的指令和一条使用该值的指令之间,流水线必须暂停一个周期。\n处理r e t : 流水线必须暂停直到 r 过 指令到达写回阶段。\n预测错误的分支:在分支逻辑发现不应该选择分支之前,分支目标处的几条指令巳经进入流水线了。必须取消这些指令,并从跳转指令后面的那条指令开始取指。\n异常:当一条指令导致异常,我们想要禁止后面的指令更新程序员可见的状态,并且在异常指令到达写回阶段时,停止执行。\n我们先浏览每种情况所期望的行为,然后再设计处理这些情况的控制逻辑。\n1 特殊控制情况所期望的处理\n在 4. 5. 5 节中, 我们已 经描述了对加载/使用冒险所期望的流水线操作, 如图 4-5 4 所示。只有 mr mo v q 和 p o p q 指令会从内存中读数据。当这两条指令中的任一条处千执行阶 段,并且需要该目的寄存器的指令正处在译码阶段时,我们要将第二条指令阻塞在译码阶 段, 并在下一个周期往执行阶段中插入一个气泡。此后 , 转发逻辑 会解决这个数据冒险。可以将流水线寄存器 D 保持为固定状 态, 从而将一个指令阻 塞在译码阶段。这样做还可以保证流水线 寄存器 F 保持为固定状态, 由此下一条指令会被再取一次。总之, 实现这个流 水线流需要发现冒险的 清况 , 保持流水线 寄存器 F 和 D 固定不变,并 且在执 行阶段中插入气泡。\n对r e 七指令的处理,我们已经 在 4. 5. 5 节中描述 了所需的流水线操作 。流水线要停顿\n3 个时钟周期, 直到r e 七指令经过访存阶段, 读出返回地址。通过图 4-55 中下面程序的处理的简化流水线图,说明了这种情况:\nOxOOO: irmovq stack,%rsp # Initialize stack pointer OxOOa: call proc # Procedure call Ox013: irmovq $10,%rdx # Return point Ox01d: halt Ox020: . pos Ox20\nOx020: proc: # proc:\nOx020: ret # Return immediately Ox021: rrmovq %rdx,%rbx # Not executed Ox030: . pos Ox30\nOx030: stack: # stack: Stack pointer\n图4-62 是示例程序中 r e t 指令的实际处理过程。在此可以看到, 没有办法在流水线的取指阶段中插入气泡。每个周期 , 取指阶段从指令内存中读出一条指令。看看 4. 5. 7 节中实现 P C 预测逻辑的 HCL 代码,我 们可以 看到, 对r e 七指令来说, PC 的 新值被预测成valP, 也就是下一条指令的地址。在我们的示 例程序中, 这个地址会是 Ox 021 , 即 r e t 后面r r mo v q 指令的地址。对这个例子来说, 这种预测是不对的, 即使对大部分情况来说, 也是不对的, 但是在设计中, 我们并 不试图正确预测返 回地址。取指阶段会暂停 3 个时钟\n周期, 导致取出r rmo v q 指令, 但是在译码阶段就被替换成了气泡。这个过程在图 4- 6 2 中的表示为 , 3 个取指用箭头指 向下面的气 泡,气 泡会经过剩下的流水线阶段。最后, 在周期7 取出 i r mo v q 指令。比较图 4- 62 和图 4- 55 , 可以看到,我们的实现达到了期望的效果, 只不过连续 3 个周期取出了不正确的指令。\n# prog6\nOxOOO: irmovq St ack , %r s p OxOOa: call proc\nOx020: ret\n2 3 4 5 6 7 8 9 10 11\nMIW # F I D I E I M I W\nF ( D J E I M I W\nOx021: rrmovq %r dx , 1r儿 bubble\nb x # Not executed\nF\n曰E I M I w\nOx021: rrmovq %r d x , %r b x # Not executed bubble\nOx021 : r r movq %r d x , %r b x # Not e xecut ed\nF\nD I E I M I w\nF\n图 4-62 r e t 指令的详细处 理过程。取指阶段反复取出r e t 指令后面的r r movq 指 令 ,但 是 流 水 线 控 制逻辑在译码阶段中插入气泡,而 不 是 让 r r movq 指 令 继 续 下 去 。 由 此 得 到 的 行 为与图 4-55 所示的等价\n当分支预测错误发 生时, 我们已 经在 4. 5. 5 节中描述了所需的流水线操作, 并用图 4-\n56 进行了说明 。当跳转指令到达执行 阶段时就可以检测到预测错误。然后在下一个时钟周期, 控制逻辑就 会在译码和执行段插入气泡 , 取消两条不正 确的已取指令。在同一个时钟周期 , 流水线将正确的指令 读取到取指阶段。\n对于导致异常的指令, 我们必须使 流水线化的实现符合期望的 ISA 行为, 也就是在前面所有的指令结束 前, 后面的指令不能影响程 序的状态。一些因素会使得想达到这些 效果比较麻烦: 1 ) 异常在程序执行 的两个不同阶段(取指和访存)被发现的, 2 ) 程序状态在三个不同阶段(执行、访存和写回)被更新。\n在我们的 阶段设计中, 每个流水线寄存器中会包含一个状态码 s t a t , 随着每条指令经过流水 线阶段, 它会记录指令的 状态。当异常发生时, 我们将这个信息作为指令状态的一部分记录下来, 并且继续取指、译码和执行指令, 就好像什么都没有出错似的。当异常指令到达访存阶段时,我们会采取措施防止后面的指令修改程序员可见的状态: 1 ) 禁止执行阶段中的指令设置条件码, 2 ) 向内存阶段中插入气泡, 以禁止向数据内存中写入, 3 ) 当写回阶段中有异常指令时,暂 停 写回阶段, 因而暂停了流水线。\n图 4- 63 中的流水线图说明了我们的流水线控制如何处理导致异常的指令后面跟着一条会改变条件码的指令的 情况。在周期 6 , p u s h q 指令到达访存 阶段, 产生一个内存错误。在同一个周期, 执行阶段中的 addq 指令产生新的条件码的 值。当访存或者写回阶段中有异常指令时(通过检查信号m_ s t a t 和 W_ s t a t , 然后将信号 s e t _ c c 设置为 0) , 禁止设置条件码。在图 4- 63 的例子中, 我们还可以 看到既 向访存阶段插入了气泡, 也在写 回阶段暂停了异常指令一- p u s hq 指令在写回阶段保持暂停, 后面的指令都没有通过 执行阶段。\n对状态信号流水 线化, 控制条件码的设置, 以及控制流水线阶段一 将这些结合起\n来,我们实现了对异常的期望的行为:异常指令之前的指令都完成了,而后面的指令对程\n序员可见的状态都没有影响。\nMlwlwlwl ..·@\n图 4- 63 处理非法内 存引用异常。在周期 6 , pu shq 指令的非法内存引用导 致禁止更新 条件码。流水线开始往访存阶段插入气 泡, 并在写回 阶段暂停 异常指令\n2 发现特殊控 制条件\n图 4-64 总结了需要特殊流水线控制的条件。它给出的表达式 描述了在哪些条件下会出现这三种特殊情况 。一些简单的组合逻辑块实现了 这些表达式 , 为了在时钟上升开始下一个周期时控制流水线寄存器的活动, 这些块 必须在时钟周期 结束之前产生出结果。在- 个时钟周期内,流 水线寄存器 D、E 和 M 分别保持着处千译码、执行和访存阶段中的指令的 状态。在到达时钟 周期末尾时 , 信号 d _ sr c A 和 d _ sr c B 会被设置为译码阶段中指令的源操作数的寄存器 ID。当 r e t 指令通过流水线时, 要想发现它,只 要检查译码、执行和访 存阶段中指令的指令码。发现加载/使用冒险要检查执行阶段中的指令类型( mr mo v q 或 p op q ) \u0026rsquo; 并把它的目的寄存器与译码阶段中 指令的源寄存器相比较。当跳转指令在执行阶段时, 流水线控制逻辑应该能发 现预测错误 的分支, 这样当指令进入访存阶段时, 它就能设 置从错 误预测中恢复所需要的条件。当跳转指 令处于执行阶段时, 信号 e _ Cn d 指明是否要选择分支。通过 检查访存和写回阶段中的指令状态值, 就能发现异常指令。对于访存阶段,我 们使用在这个阶段中计算出来的信号 m _ s t a t , 而不是使用流水线寄存器的 M s t a t 。这个内部信号包含着可能的数 据内存地址错误。\n条件 触发条件\n处理 r e t\n加载/使用冒险预测错误的分支异常\nIRETE {D_icode, E_ic ode, M _icode}\nE_icodeE { IMRM OVL, IPOPL} \u0026amp; \u0026amp; E_dstME { d_sr cA, d_srcB }\nE_icode= IJXX \u0026amp; \u0026amp; ! e_Cnd\nm_statE { SADR,SINS,SHLT} I IW_statE { SADR, SINS,SHLT}\n图 4- 64 流水线控制逻辑的检查条件。四种不同的条件要求改变流水线, 暂停流水线或者取 消已经部分执行的指 令\n流水线控制机制\n图 4-65 是一些低级机制, 它们使得流水线控制逻辑能将指令阻塞在流水线寄存器中,\n或是往流 水线中插入一个气 泡。这些机制包括对 4. 2. 5 节中描述的基本时钟寄存器的小扩展。假 设每个流水线 寄存器有两个控制输入:暂 停( stall) 和气泡C bubble ) 。这些信号的设\n置决定 了当时钟上升时该 如何更新流水线寄存器。在正常操作下(图4-65a ) , 这两个输入都设为 o, 使得寄存器加载它的输入作为新的状 态。当暂停信号设为 1 时(图4-65 b) , 禁止\n更新状态。相反,寄存器会保持它以前的状态。这使得它可以将指令阻塞在某个流水线阶 段中。 当气泡信号设 置为 1 时(图4-65c) , 寄存器状态 会设置成某个固定 的复位 配置 ( res et\nconfiguration), 得到一个等效于 no p 指令的状态。一个流水线寄存器的复位配置的 O、 1 模式是由流 水线寄存器中字段的集合决定的。例如,要 往流水线寄存器 D 中 插入一个气泡, 我们要将 i c o d e 字段设置为常数值 IN OP( 图 4-26 ) 。要往流水线寄存器 E 中插入一个气泡,我们要 将 i c o d e 字段设为 I NOP, 并将 d s t E、d s t M、sr c A 和 sr cB 字段设为常数\nRNONE。 确定复 位配置是 硬件设计师在设计流水线寄存器时的任务之一。在此我们不讨论细节。 我们会将气泡 和暂停信号都设为 1 看成是出错。\n状态=x 状态=y\n门\n一呻 时钟上升沿 -+\n工 # 正常 状态=x 状态==x\n门 输出=x\n工\nb ) 暂停\n状态=x 状态=nop\n门句 # 工\nb ) 气泡\n图 4-65 附加的流水线寄存器操作。a ) 在正常条件下 , 当时钟上升时 , 寄存器的状态和输出 被设置成输入的值; b ) 当运行在暂停模式中时,状 态保持为先前 的值不变; c ) 当运行在气泡模式中时, 会用 nop 操作的状态覆盖 当前状态\n图 4-66 中的表给出了各个 流水线寄存器在三种特殊情况下应该采取的行动。对每种情况的处理都是对流水线寄存器正常、暂停和气泡操作的某个组合。在时序方面,流水线 寄存器的暂停和气泡控制信号是由组合逻辑块产生的。当时钟上升时,这些值必须是合法 的,使得当下一个时钟周期开始时,每个流水线寄存器要么加载,要么暂停,要么产生气\n泡。有了这个对流水线寄存器设计的小扩展,我们就能用组合逻辑、时钟寄存器和随机访问存储器这样的基本构建块,来实现一个完整的、包括所有控制的流水线。\n流水线寄存器 条件 处理r et F 暂停 D 气泡 E 正常 M 正常 w 正常 加载/使用冒险 暂停 暂停 气泡 正常 正常 预测错误的分支 正常 气泡 气泡 正常 正常 图 4-66 流水线控制逻辑的动作。不同的条件需要改变流水线流,或者会暂停流水线, 或者会取消部分已 执行的指令\n4 控制条件的组合\n到目前为止,在我们对特殊流水线控制条件的讨论中,假设在任意一个时钟周期内, 最多只能出现一个特殊情况。在设计系统时,一 个 常 见的缺陷是不能处理同时出现多个特殊情况的 情形 。现在来分析这些可能性。我们不需要担心多个程序异常的组合情况, 因为已经很小心地设计了异常处理机制, 它 能 够 考虑流水线中其他指令的情况。图 4-67 画出了导致其他三种特殊控制条件的流水线状态。图中所示的是译码、执行和访存阶段的块。 暗色的方框代表要出现这种条件必须要满足的特别限制。加载/使用冒险要求执行阶段中 的 指令 将一 个值从内存读到寄存器中,同 时 译 码 阶 段 中 的 指 令 要 以 该 寄 存 器 作 为源操作数 。预测 错 误的 分支要求 执 行阶段中的指令是一个跳转指令。对r 琴 来说有三种可能的情况一 指令可以处在译码、执行或访存阶段。当r e t 指令通过流水线时,前 面的流水线阶段都是气泡。\n加载/使用 预测错误\nret 1 ret 2 ret 3\n五勹巨勹二三丿``\n图 4- 67 特殊控制条件的流水线状态。图中标明的两对情况可能同时出现\n从这些图中我们可以看出, 大 多 数 控 制 条 件 是 互 斥的。例如,不 可能同时既有加载/ 使用 冒险又有预测错误的分支,因 为加载/使用冒险要求执行 阶段中是加载指令C mr movq 或 p opq )\u0026rsquo; 而 预 测 错 误 的 分 支要 求 执 行 阶 段中是一条跳转指令。类似地, 第二个和第三个r e t 组 合 也 不 可 能 与 加 载/使用冒险或预测错误的分支同时出现。只有用箭头标明的两种组合可能同时出现。\n组合 A 中执行阶段中有一条不选择分支的跳转指令,而 译 码 阶 段 中 有 一 条 r e t 指令。出 现这种组合要求r e t 位于不选择分支的目标处。流水线控制逻辑应该发现分支预测错误 ,因 此 要 取 消 r e t 指令。\n练习题 4. 37 写 一个 Y86-64 汇编 语言程序, 它 能 导致出现组合 A 的情 况, 并判断控制逻辑是否处理正确。\n合并组合 A 条件的控制动作(图4-66) , 我们得到以下流水线控制动作(假设气泡或暂\n停会覆盖正常的情况):\n;!1\n也就是说 , 组合情况 A 的处理与预测错误的分支相似,只 不过在取指阶段 是暂停。幸运的是,在下一个周期 , P C 选择逻辑会选择跳转后面那条指令的地址, 而不是预测的程序计数器值, 所以流水线寄存器 F 发生了什么是没有关系的。因此我们得出 结论,流 水线能正确处理这种组合悄况。\n组合 B 包括一个加载/使用 冒险, 其中加载指令设置寄存器%r s p , 然后r e t 指令用这个寄存器 作为源操作数 , 因为它必须从栈中弹出返回地址。流水线控制逻辑应该 将r e t 指令阻塞在译码阶段。\n练习题 4. 38 写 一个 Y8 6- 64 汇编语 言程 序, 它能导致 出现 组合 B 的 情况 , 如果 流水线运行 正确,以 ha l t 指令 结束。\n合并组 合 B 条件的控制动作(图4-66 ) , 我们得到以下流水线控制动作:\n流水线寄存器 条件 处理 re t F 暂停 D 气泡 E 正常 M 正常 w 正常 预测错误的分支 暂停 暂停 气泡 正常 正常 组合 暂停 气泡+暂停 气泡 正常 正常 期望的情况 暂停 暂停 气泡 正常 正常 如果同时触 发两组动作 , 控制逻辑 会试图暂停r e t 指令来避免加载/使用冒险, 同时又会因 为r e t 指令而往译码阶段中插入一个气泡。显然 , 我们不希望流水线同时执行这两组动作。相 反, 我们希望它只采取针对加载/使用冒险的动作。处理 r e t 指令的动作应该推迟一个周期。\n这些分析 表明组合 B 需要特殊处理。实际上, PIPE 控制逻辑原来的实现并没有正确处理这种组合情况。即使设计已经通过了许多模拟测试,它还是有细节问题,只有通过刚 才那样的分 析才能发现。当执行一个含有组合 B 的程序时 , 控制逻辑会 将流水线寄存器 D 的气泡和 暂停信号都置为 1。这个例子表明了系统分析的重要性。只运行 正常的程序是很难发现 这个问题的。如果没有发现这个问题, 流水线就不能忠实地实 现 ISA 的行为。\n5 控制逻 辑实现\n, 图 4-68 是流水线控制逻辑的整体结构。根据来自流水线寄存器和流水线阶段的信号,控制逻辑产生流水线寄存器的暂停和气泡控制信号,同时也决定是否要更新条件码寄存器。我们可\n、 以将图 4-64 的发现条件和图4-66 的动作结合起来,产生各个流水线控制信号的HCL 描述。\n' 遇到加 载/使用冒险或r e t 指令, 流水线寄存器 F 必须暂停:\nbool F_stall \u0026ldquo;\u0026rsquo;\n# Conditions for a load/use hazard E_icode in { IMRMOVQ, IPOPQ} \u0026amp;\u0026amp; E_dstM in { d_srcA, d_srcB} II\n# Stalling at fetch while ret passes through pipeline IRET in { D_icode, E_icode, M_icode };\n图 4-68 PIPE 流水线控制逻辑 。这个逻辑覆盖了通过 流水线的正常指令流 ,以处理特殊条件, 例如过程返回、预测错误的分支、加载/使用冒险和程序异常\n练习题 4. 39 写 出 P IP E 实现中信 号 D_ s t a l l 的 HCL 代码。\n遇到预测错误的分支或 r e t 指令 , 流水线寄存器 D 必须 设 置为气泡。不过, 正如前面一节中的分析所示, 当遇到加载/使用冒险和r e t 指令组合时,不 应该插入气泡:\nbool D_bubble =\n# Mispredicted branch\n(E_icode == IJXX \u0026amp;\u0026amp; !e_Cnd) I I\n# Stalling at fetch while ret passes through pipeline\n# but not condition for a load/use hazard\n!(E_icode in { IMRMOVQ, IPOPQ} \u0026amp;\u0026amp; E_dstM in { d_srcA, d_srcB }) \u0026amp;\u0026amp;\nIRET in { D_icode, E_icode, M_icode };\n练习题 4. 40 写 出 P I P E 实现中信 号 E_ b ub b l e 的 HCL 代码。\n沁 员 练习题 4. 41 写 出 P IP E 实现 中 信号 s e 七— c c 的 HCL 代码。该信号 只 有对 OPq 指令\n才出现,应该考虑程序异常的影响。\n沁囡 练习题 4. 42 写 出 P I P E 实现中信号 M_ b u b b l e 和 W_ s t a l l 的 HCL 代 码。后 一个信号需要修改图 4-64 中列 出的 异常条件。\n现在我们讲完了所有的特殊流水线控制信号的值。在 P IP E 的完整 HCL 代码中,所有其他的流水线控制信号都设为 0。\n田 日 测试设计\n正如我们看到的,即使是对于一个很简单的微处理器,设计中还是有很多地方会出 现问题。使 用流水线, 处于不 同流水线阶段的指令之间有许多 不 易察 觉的 交互 。我们看到一些设计上的挑战来自 于不 常见的指令(例如弹出值 到栈指针), 或是 不 常见的指令组合(例如不选择分支的跳转指令后面跟 一条r e t 指令)。还看到 异常处理增加了 一类全 新的 可能的流水线行 为。 那么怎样确定我们的设计是正确的呢? 对于硬件制造者来说,这\n是主要 关心的 问题 , 因为他 们不能 简 单 地 报 告 一 个 错 误 , 让 用 户通过 Inter net 下栽代码 甘\n补丁。 即 使 是 简单的逻辑设计错误都可能有很严 重的后 果, 特 别是 随 着微 处理 器越 来越多地用于对我们的生命和健康至关重要的系统的运行中,例如汽车防抱死制动系统、心 脏起 搏 器以 及 航 空控制 系统 。\n简单 地 模 拟 设 计, 运 行 一 些“典型的“ 程序, 不足 以 用 来测试一 个 系统 。 相 反 , 全面的测试需要设计一些方法,系统地产生许多测试尽可能多地使用不同指令和指令组 合。在创 建 Y86-64 处理 器的 过 程 中 , 我 们还设计 了 很 多 测试脚本, 每 个脚 本都产 生 出很多不 同的测试, 运 行 处 理 器模拟 , 并 且比较 得到的寄存 器和 内存值 和我们 YIS 指 令 集模拟 器产 生的 值。以 下是这 些脚本的 简 要 介 绍:\noptest: 运行 49 个 不同的 Y86-64 指令 测试, 具 有 不同的 源 和 目 的 寄 存 器。\njtest: 运行64 个不同的 跳转和函数 调 用指令 的 测试,具 有 不同的是否选择 分支的组合。\ncmtest: 运行 28 个不同的条件传送指令的测试, 具 有 不同的 控 制组合。\nhtest: 运行 600 个不同的 数 据 冒险可能性的测试, 具 有 不同的 源 和 目的 的 指 令 的 组合,在 这些指令对之 间有 不同数 量的 na p 指令 。\nctest : 测试 22 个不同的控制组合 , 基 于类似 4. 5. 8 节 中我们做的那样的分析。\netest: 测试 12 种不同的 导致异 常的指令和跟在后面可能改 变程序 员可见状态的指令 组合。这种测试方法的关键思想是我们想要尽量的系统化,生成的测试会创建出不同的可\n能导致流水线错误的条件。\nm 形式 化地 验证 我们的设计\n即使一个设计通过了广泛的测试,我们也不能保证对于所有可能的程序,它都能正 确运行。即使只考虑由短的代码段组成的测试,可以测试的可能的程序的数量也大得难 以想象。 不过 , 形 式化验证 ( fo rmal veri fic ation ) 的新方 法能 够保证 有 工具能 够严格 地 考虑一 个 系统 所有可能的行为 , 并 确定是否有设计错误。\n我们能够形式化验证 Y86-64 处理 器较 早 的 一 个版 本[ 13] 。 建 立一 个框 架 , 比 较 流\n水线化 的设计 PIPE 和非 流水线化的版 本 SEQ。也 就是 , 它 能 够证 明 对 于任 意 Y86-6 4 程序 , 两 个处理器对程 序 员 可见的状 态有 完全一样的影响。 当 然 , 我 们的验证 器不可能真的运行 所有 可能的程序, 因 为这 样 的 程 序 的 数 量是 无 穷大的。相 反 , 它使 用 了 归纳 法来证明, 表 明 两个处理 器之 间在一 个周期到一个周期的基础上都是 一致的。进行这种分析要求用符号方法( sym bolic met hods ) 来推导硬件, 在 符 号 方 法中, 我 们认为 所 有 的 程序值都是任意的整数, 将 ALU 抽 象成 某种 “ 黑盒子“, 根 据 它的 参数 计算 某个未指定的函数。我们只假设 SEQ 和 PIPE 的 ALU 计算相同的函数 。\n用控制逻辑的 HCL 描 述来产 生符号 处理 器模 型的控制逻辑 , 因 此 我们能发现 HCL 代码 中的 问题。能够证 明 SEQ 和 PIPE 是 完全 相 同 的 , 也 不 能保 证 它 们 忠 实地 实 现 了Y86-64 指令 集体 系结 构 。 不过 , 它能 够发现任何 由 于不 正确的 流水线设计导致的错误 , 这是设计错误的主要来源。\n在实验 中 , 我们不仅验证 了在 本 章 中考虑的 PIPE 版本, 还 验 证 了 作 为 家庭 作 业的几个变种,其中,我们增加了更多的指令,修改了硬件的能力,或是使用了不同的分支 预测 策略 。有趣的是 , 在 所 有 的 设 计 中, 只发 现 了 一 个错误 , 涉及 家庭 作 业 4. 58 中描 述的 变种 的 答 案中的控制组合 BC在 4. 5. 8 节中讲述的)。这暴露出测试体制中的一个弱点,\n导致我们在 ctes t 测试脚本中增加 了附 加的情 况。\n形式化 验证仍然处在发展 的早期阶段。工具往往很 难使 用, 而且还不能 验证大规模的设计。我们能 够验证 Y86-64 处理 器的 部分原 因就是 因 为 它们相 对比较简单 。即使如此,也 需要 几周的 时间和 精力, 多次运行 那些 工具, 每次最多 需要 8 个小时的 计算机时间。 这是一个活跃的 研究领域 ,有 些工具成为 可用的 商业版 本, 有些在 In tel、AMD 和IB M 这样的公 司使用。\n_ _ 流水线化的 Y86-6 4 处理器的 Ve rilog 实现\n正如我们提到过的 , 现代的逻辑设计 包括用硬件描述语 言书 写硬 件设计的 文本表示。 然后 , 可以 通过模拟和各种形式化 验证工具来测试设 计。一旦 对设计有了信心,我们就 可以使 用逻 辑合成( log ic s ynt hesis ) 工具将设计翻译成 实际的 逻辑电路 。\n我们用 Ver ilog 硬件描述语 言开发 了 Y86-64 处理 器设 计的模 型。这些设 计将 实现处理器基本构造块的模 块和 直接从 H CL 描述产 生出来的 控制逻辑结合了起来。我们能够合成这些设 计的 一些 , 将逻辑电路描 述下栽到 字段可编 程的 门阵列 CF PG A ) 硬件上,可以在这些处理 器上运行 实际的 Y86-6 4 程序。\n5. 9 性能分析\n我们可以看到, 所有需要流水线控制逻辑进行特殊处理的条件, 都会导致流水线不能够 实现每个时钟周期发射一条新指令的目标。我们可以通过确定往流水线中插入气泡的频率 ,来衡最这种效率的损失, 因为插入气泡 会导致未使用的流水线周期。一条返回指令会产生三个气泡, 一个加载/使用冒险会产生一个,而 一个预测错误的分支会产生两个。我们可以通过计算 PIP E 执行一条指令所需要的平均时钟周期数的估计值, 来量化这些处罚对整体性能的影响, 这种衡量方法称为 CP I (Cycles Per Instruction, 每指令周期数)。这种 衡量值是流水线平均吞吐量的倒数 , 不过时间单位是时钟周期, 而不是微微秒。这是一个设计体系结构效率的很有用的衡量标准。\n如果我们忽略异常带来的性能损失(异常的定义表明它是很少出现的), 另一种思考 CPI 的方法是,假设我们在处理器上运行某个基准程序,并 观察执行阶段的运行。每个周期,执行阶段要么会处理一条指令,然后这条指令继续通过剩下的阶段, 直到完成; 要么会处理一个由丁三种特殊情况之一而插入的气泡。如果这个阶段一共处理了 C,条指令和 G个气泡, 那么处理器总共需要大约 C, 十G个时钟周期来执行 C条指 令。我们说“大约” 是因为忽略了启动指令通过流水线的周期。于是, 可以用如下方法来计算这个基准程序的 CPI :\nCPI= C, 十 Cb\nC,\n=LO+_,,C_\nC,\n也就是说, CPI 等千 1. 0 加上一个处罚项 Cb / C, , 这个项表明执行一条指令平均要插入多少个气泡。因为只有三种指令类型会导致插入气泡, 我们可以将这个处罚项分解成三个部分:\nC PI = 1. 0 + lp + mp +r p\n这里, l p Cloa d penalt y , 加载处罚)是当由于加载/使用冒险 造成暂停时插入气泡的平均数, mp ( mis predict ed branch penalt y , 预测错误分支处罚)是当由于预测错误取消指令时 插入气泡的平均数 ,而 r p ( ret ur n penalt y , 返回处罚)是当由于r e t 指令造成暂停时插\n人气泡的 平均数。每种处罚都是由该种原因引起的插入气泡的总数( Cb 的 一部分)除以执行指令的总数( C;) 。\n为了估计每种处罚,我们需要知道相关指令(加载、条件转移和返回)的出现频率,以 及对每种指 令特殊情况出现的频率。对 CPI 的计算, 我们使用下 面这 组频率(等同于[ 44] 和[ 46] 中 报 告 的 测 量值):\n加 载指令( mr mov q 和 p op q ) 占所有执行指令的 25 % 。其中 20 % 会导 致加 载/使用冒险。\n条件分支指令占所有执行指令的 20 % 。其中 60% 会选择分支, 而 40 %不选择分支。\n返回指令占所有执行指令的 2% 。\n因此,我们可以估计每种处罚,它是指令类型频率、条件出现频率和当条件出现时插 入气泡数的 乘积:\n原因 名称 指令频率 条件频率 气泡 乘积 加载/使用 Ip o. 25 0. 20 1 0. 05 预测错误 mp 0. 20 0. 40 2 o. 16 返回 rp o. 02 1. 00 3 0. 06 总处罚 0. 27 三种处罚的总和是 0. 27, 所以得到 CPI 为 l. 27。\n我们的 目标是设计一个每个周期发射一条指令的流水线,也 就 是 CPI 为 1. 0。虽 然没有完全 达到目标,但 是 整体 性能巳 经很 不 错 了 。我们还能看到,要 想 进一步降低 CPI , 就应该集中注意力预测错误的分支。它们占 到了 整个处罚 0. 27 中 的 0. 16 , 因为条件转移非常常见,我们的预测策略又经常出错,而每次预测错误都要取消两条指令。\n练习题 4. 43 假设我们 使用 了 一种成功 率 可 以达到 65 % 的分支预 测 策 略, 例 如后 向分支选择、前向分支就 不选择 ( BT F NT ) , 如 4. 5. 4 节 中描述的那样。那 么 对 CPI 有什么样的影响呢?假设其他所有频率都不变。\n练习题 4. 44 让我们来分析你为 练 习题 4. 4 和练 习题 4. 5 写的 程序中使 用条件数据传送和 条件控制 转移的 相对 性能。 假设用 这些 程 序 计 算 一个非 常 长 的 数 组 的 绝 对值的和, 所以整体 性能 主要是由内循环所需要的周期数决定的。假设跳 转指 令预测 为 选择分支 , 而大约 50 % 的数 组值 为 正。\n平均来 说, 这两个 程序的内循环中执行了 多少 条指令?\n平均来 说, 这两个程序的内循环中插入了 多少 个气泡?\n对这两个 程序来说, 每个数 组元 素平均需要 多少个时钟周 期?\n5. 10 未完成的工作\n我们已经创建了 PIPE 流水线化的微处理器结构, 设 计 了 控制逻辑块,并 实 现了处理普通流水线流不足以处理的特殊情况的流水线控制逻辑。不过, PIP E 还是缺乏一些实际微处理器设计中所 必需的关键特性。我们会强调其中一些, 并 讨 论 要 增 加 这些特性需要些什么。\n多周期指令\nY86- 64 指令集中的所有指令都包括一些简单的操作,例 如数字加法。这些操作可以在执行 阶段中一个周期内处理完。在一个更完整的指令集中, 我们还将实现一些需要更为复杂操作的指令,例 如 , 整数乘法和除法,以 及 浮点运算。在一个像 PIPE 这样性能中等\n的处理器中, 这些操作的典型执行时间从浮点 加法的 3 或 4 个周期到整数除法的 64 个周期。为了实现这些指令,我们既需要额外的硬件来执行这些计算,还需要一种机制来协调这些指令的处理与流水线其他部分之间的关系。\n实现多周期指令的一种简单方法就是简单地扩展执行阶段逻辑的功能,添加一些整数和浮点算术运算单元。一条指令在执行阶段中逗留它所需要的多个时钟周期,会导致取指和译码阶段暂停。这种方法实现起来很简单,但是得到的性能并不是太好。\n通过采用独立千主流水线的特殊硬件功能单元来处理较为复杂的操作,可以得到更好 的性能。通常,有一个功能单元来执行整数乘法和除法,还有一个来执行浮点操作。当一条指令进入译码阶段时,它可以被发射到特殊单元。在这个特殊单元执行该操作时,流水 线会继续处理其他指令。通常,浮点单元本身也是流水线化的,因此多条指令可以在主流 水线和各个单元中并发执行。\n不同单元的操作必须同步,以避免出错。比如说,如果在不同单元执行的各个指令之 间有数据相关,控制逻辑可能需要暂停系统的某个部分,直到由系统其他某个部分处理的 操作的结果完成。经常使用各种形式的转发,将结果从系统的一个部分传递到其他部分, 这和前面 PIPE 各个阶段之间的转发一样。虽然与 PIPE 相比,整 个设计变得更为复 杂,但还是可以使用暂停、转发以及流水线 控制等同样的技 术来使 整体行 为与顺序的 ISA 模型相匹配。\n与存储系统的接口\n在对 PIPE 的描 述中,我 们假设取指单元和数据内存都可以 在一个时钟周期内读或是写内存中任意的位置。我们还忽略了由自我修改代码造成的可能冒险,在自我修改代码中,一条指令对一个存储区域进行写,而后面又从这个区域中读取指令。进一步说,我们 是以存储器位置的虚拟地址来引用它们的,这要求在执行实际的读或写操作之前,要将虚 拟地址翻译成物理地址。显然,要在一个时钟周期内完成所有这些处理是不现实的。更糟 糕的是,要访问的存储器的值可能位于磁盘上,这会需要上百万个时钟周期才能把数据读 入到处理器内存中。\n正如将在 第 6 章和第 9 章中讲述的那样, 处理器的存储系统是由多种硬件存储器和管理虚拟内存的操作系统软件共同组成的。存储系统被组织成一个层次结构,较快但是较小 的存储器保持着存储器的一个子集,而较慢但是较大的存储器作为它的后备。最靠近处理 器的一层是高速 缓存( cache ) 存储器,它 提供对最常使用的存储器位置的快速访问。一个典型的处理器有两个第一层高速缓存一-个用于读指令,一个用于读和写数据。另一种 类型的高速缓存存储器, 称为翻译后 备缓冲 器( T ra ns la tion Look-aside Buffer, TLB), 它提 供了从虚拟地址 到物理 地址的快速翻译。将 T LB 和高速缓存结合起来使用, 在大多数时候, 确实可能在 一个时钟周期内读指令并读或是写数据。因此,我 们的处理器对访问存储器的简化看法实际上是很合理的。\n虽然高速缓存中保存有最常引用的存储器位置,但是有时候还会出现高速缓存不命中(miss), 也就是有些引用的位置不在高速缓存中。在最好的情况中,可以从较高层的高速缓存或处理器的主存中找到不命中的数据, 这需要 3 ~ 20 个时钟周期。同 时,流水线会简单地暂停,将指令保持在取指或访存阶段,直到高速缓存能够执行读或写操作。至千流水 线设计,通过添加更多的暂停条件到流水线控制逻辑,就能实现这个功能。高速缓存不命 中以及随之而来的与流水线的同步都完全是由硬件来处理的,这样能使所需的时间尽可能 地缩短到 很少数量的时钟周期。\n在有些情况中,被引用的存储器位置实际上是存储在磁盘存储器上的。此时,硬件会 产生一个缺页 ( page fault ) 异常信号。同其他异常一样,这 个 异 常 会 导 致处理器调用操作系统的异常处理程序代码。然后这段代码会发起一个从磁盘到主存的传送操作。一旦完成, 操作系统会返回到原来的程序,而导致缺页的指令会被重新执行。这次,存储器引用将成 功,虽然可能会导致高速缓存不命中。让硬件调用操作系统例程,然 后 操 作 系统例程又会 将控制返回给硬件,这就使得硬件和系统软件在处理缺页时能协同工作。因为访问磁盘需 要数百万 个时钟周期, OS 缺页中断处理程序执行的处理所需的几百个时钟周期对性能的影响可以 忽略不计。\n从处理器的角度来看,将用暂停来处理短时间的高速缓存不命中和用异常处理来处理长时间的缺页结合起来,能够顾及到存储器访问时由千存储器层次结构引起的所有不可预测性。\n四 当前 的微处理器设计\n一个五阶段流水线, 例如 已 经讲过的 PIPE 处理 器 , 代表 了 20 世 纪 80 年代 中期的处理 器 设 计 水 平。 Berkeley 的 Patterson 研 究组开发的 RISC 处理 器 原型 是 笫 一 个\nSPARC 处理 器 的 基 础, 它 是 S un Microsystems 在 198 7 年 开发 的。Stanford 的 Hen­\nnessy 研 究组开发 的处理 器由 MIPS T echnologies ( 一个由 H enness y 成立的 公 司)在 19 86 年商业 化了 。 这 两种 处理 器都 使 用 的 是 五阶段 流水线。Int el 的 i48 6 处理 器 用的也是五阶段 流水线, 只 不过阶段之间的职责划 分不 太一样, 它有 两 个译码 阶段 和一个合并的执 行/访存阶段[ 27] 。\n这些 流水线化的设计的吞吐量都限制在最多一个时钟周期一条指令。4. 5. 9 小 节 中描述的 CP I (Cycles Per Instruction, 每指令周期)测量值不可能 小于 1. 0。不同的阶段一次只能处 理一条指令。较新的处理器 支持超标量( s uperscalar ) 操作, 意味 着它们通过并行地取 指、译码和执行多条 指令, 可以 实现小于 1. 0 的 CPI。当 超标量处理 器 已经广 泛使用时 , 性能测量标准已经从 CPI 转化成了 它的 倒数- 每周期执行指令的平均数 , 即\nIPC。· 对超标量处理 器 未说, IPC 可以 大 于 1. 0 。 最先进的设计使 用 了 一种称 为 乱 序\n(out-of-order ) 执行的技术未并行地执行多 条 指令,执 行 的顺序也可能 完全 不同 于它们在程序 中出现的 顺序,但 是 保 留了顺 序 ISA 模型蕴含的整体行为 。 作 为对程序优化的讨论的一部 分, 我们将会在笫 5 章中讨 论这种形式的 执行。\n不过,流水线化的处理器并不只有传统的用途。现在出售的大部分处理器都用在嵌 入式系统中,控制着汽车运行、消费产品,以及其他一些系统用户不能直接看到处理器 的设备 。在这些应 用 中, 与性能较 高的模型相比 , 流水线化的处理 器的 简 单性(比如说像我们 在本章中讨论的这样)会降低成本和功耗需求。\n最近 ,随 着多核 处理器受到 追捧,有 些人声 称通过在一个芯片 上集成许多 简 单的处理器,比 使 用 少量 更复杂的处理 器能荻得 更 多 的整体计算能力。 这种策略 有时被 称为“多核 ” 处理器[ 10] 。\n6 小结\n我们已经看到 , 指令集体系结构,即 ISA , 在处理器行为(就指令集合及其编码而言)和如何实现处理器之 间提供了 一层抽象。ISA 提供了程序执行的一种顺序说明 , 也就是一条指令执行完了 , 下一条指令才会开始。\n从 IA32 指令开始,大大简化数据类 型、地址模式和指令编码, 我们定义了 Y86-64 指令集。得到的\nISA 既有 RISC 指令集的属性 , 也有 CISC 指令集的属性。然后,将 不同指令组织 放到五 个阶段中处理, 在此,根据被执行的指令的 不同, 每个阶段中的操 作也不相同。据此, 我们构造 了 SEQ 处理器, 其中每个时钟周期执行一条指令,它会通过所有五个阶段。\n流水线化通过让不同 的阶段并行操作, 改进了系统的吞吐扯性能。在任意一 个给定的时刻,多条指令被不同的阶段处理。在引入这种并 行性的过程中 , 我们必须非常小心,以 提供与程序的 顺序执行相同的程序级行 为。通过重新调 整 SEQ 各个部分的顺序 ,引 入流水线, 我们得到 SEQ+ , 接着添加流水线寄存器, 创建出 PIPE一流水 线。然后 , 添加了转发 逻辑 , 加速了将结果从 一条指令发送到 另一条指令, 从而提高了流水线的性能。有几种特殊情况需要额外的流水线控制逻辑来暂停或取消一些流水线阶段。\n我们的设 计中包括了一些基本的异常处理机制,在此 , 保证只有到异常指令之前的指令会影响程序员可见的状态。实现完整的异常处理远比此更具挑战性。在采用了更深流水线和更多并行性的系统中, 要想正确处理异常就更加复杂了。\n在本章中,我们学习了有关处理楛设计的几个重要经验:\n管理 复杂性是 首要问 题。想要优 化使用 硬件资 源, 在最小的成本下获得最大的性能。为了实现这个目的, 我们创建了一个非常简单而一 致的框架, 来处理所有不同的指令类型。有了这个框架, 就能够在处理不同指令类型的逻辑中共享硬件单元。\n我们 不需要 直接实现 IS A。 ISA 的直接实现意味着一个顺序的设 计。为了获得更高的性能,我们想运用硬件能力以同时执行许多操作,这就导致要使用流水线化的设计。通过仔细的设计和分析, 我们 能够处理各种 流水线 冒险, 因此运行一个程序的整体效果, 同 用 ISA 模型获得的效果完全一致。 硬件设计人 员必须非常谨慎 小心。 一旦芯片被制造出来, 就几乎不可能改正 任何错误 了。一开始就使设计正确是非常重要的。这就意味着要仔细地分析各种指令类型和组合,甚至千那些看上去没有 意义的情况 , 例如弹出值到栈指针。必须用系统的模拟测试 程序彻底 地测试设计。在开发 PIPE 的控制逻辑中, 我们的设计有个细微的错误 ,只 有通过对控制组合的仔 细而系统的分析才能发现 。 Y86- 64 处理器的 HCL 描 述\n本 章 已 经介 绍 几 个 简 单 的 逻 辑 设 计 , 以 及 Y86-64 处 理 器 SEQ 和 PIP E 的 控 制 逻样的 部 分 HCL 代 码 。 我 们 提 供 了 HCL 语 言 的 文 档 和 这 两 个 处 理 器 的 控 制 逻 辑 的 完整:\nHCL 描 述 。 这 些描 述 每 个都 只 需要 5 7 页 HCL 代 码 , 完整 地研 究 它们是很 值得 的。\nY86-64 模 拟器 # 本章的实验资料包括 SEQ 和 PIPE 处理器的模拟器。每个模 拟器都有两个版本 :\nGUI(图形用户界面)版本在图形窗口中显示内 存、程序代码以及处理器状态。它提供了一种方式简便地查看指令如何通过处理器。控制面板还允许你交互式地重启动、单步或运行模拟器。\n文本版本运行的是 相同的模拟器, 但是它显示信息 的唯 一方式是打印到终端上。对 调试来讲 ,这个版本不是很有用,但是它允许处理器的自动测试。\n这些模拟器的 控制逻辑是通过将逻 辑块的 HCL 声明翻译 成 C 代码产生的。然后, 编译这些代码井与模拟代码的其他部分进行链接。这样的结合使得你可以用这些模拟器测试原始设计的各种变种。提供 的测试脚本,它们全面地测试各种指令以及各种冒险的可能性。\n参考文献说明\n对千那些有兴 趣更多地学习逻辑设 计的人来说 , Katz 的逻辑设计教科书[ 58] 是标准的入门 教材,它强调了硬件描述语言的 使用。Hennessy 和 Patterson 的计算机体系结构教科书[ 46] 覆盖了处 理器设计的广泛内 容,包 括这里 讲述的简单流水 线, 还有并行执行更多指令的更高级的处理器。Shriver 和 Smith [ 101] 详 细介绍 了 AMD 制造的与 Intel 兼 容的 IA32 处理器。\n家庭作业 # 4. 45 在 3 . 4. 2 节中, x8 6-64 p u s hq 指令被描述成要减少栈指针, 然后将寄存器存储在栈指针的 位置。因此, 如果我们 有一条指令形如对于某个寄存器 R EG , pushq REG, 它等价于下面的代码序列: subq $8,%rsp movq REG, (%rsp)\nDecrement stack pointer Store REG on stack\n借助于练习题 4. 7 中所做的分析, 这段代 码序列 正确地描述了指令 p u s hq %r s p 的 行为吗? 请解释。\n你该如何改写这段 代码序列, 使得它能够像对 REG 是其他寄存器时 一样, 正确地描述 REG\n是% r s p 的情况?\n4. 46 在 3. 4. 2 节中, x86-64 p op q 指令被描述为将来自 栈顶的结果复制到目的寄存 ff \u0026rsquo; 然后 将栈指针减少。因此,如果我们有一 条指令形 如 p o pq REG, 它等价于下面的代码序列: movq (%rsp), REG addq $8,%rsp\nRead REG from stack Increment stack pointer\n借助于练习题 4. B 中 所做的分析, 这段 代码序列 正确地描述了指令 p o p q %r s p 的 行 为吗? 请解释。\n你该如何改写这段 代码序列 , 使得它能够像对 REG 是其他寄存器时一样, 正确地描述 REG\n是%r s p 的 情况?\n·: 4. 47 你的作业是写一 个执行冒泡排序的 Y86-64 程序。下 面这个 C 函数用数组引 用实现冒泡排序,供你参考:\nI* Bubble sort: Array version *I\nvoid bubble_a(long *data, long count) {\nlong i, last;\nfor (last = count-1; last \u0026gt; 0; last\u0026ndash;) {\nfor (i = O; i \u0026lt; last; i++)\nif (data[i +1] \u0026lt; data [i)) {\nI* Swap adjacent elements *I long t = data [i +1]; data[i+1] = data[i];\n10 data[i) = t;\n•• 4. 48\n12\n13 }\n书写并 测试一个 C 版本,它用 指针引用数组元素, 而不是用 数组索引。\n书写并测试一 个由这个函数和测试代码组成 的 Y86-64 程序。你 会发现模仿编译你的 C 代码产生的 x86- 64 代码来做实现会很有帮助。虽然指针比较通常是 用无符号算术运算来实现的, 但是在这个练习中,你可以使用有符号算术运算。\n修改对家庭作业 4. 47 所写的代码 ,实 现冒泡排序函数的 测试和交换( 6 ~ 11 行),要求不使用跳转, 且最多使用 3 次条件传送 。\n修改对家庭作业 4. 47 所写的代码 ,实 现冒泡排序函数的测试 和交换 ( 6~ 11 行), 要求不使用跳转,且只使用 1 次条件传送 。\n在 3. 6. 8 节中, 我们看到实 现 s wi t c h 的一 种常见方法是创建一组代码块 ,再 用跳转表对这些块进行索引。考虑图 4-69 中给出的函数 s wi t c h v 的 C 代码, 以及相应的 测试代码 。\n用跳转表以 Y86-64 实现 s wi t c h v 。虽 然 Y86-64 指令集不包含间 接跳转指令, 但是, 你可以通过把计算好的 地址入栈 ,再 执行 r e t 指令来获得同 样的效果。实现类 似于 C 语言所示的测试代码, 证明你的 s wi t c hv 实现可以处理触发 d e f a ul t 的情况以 及两个显式处 理的情况。\n#include \u0026lt;s t d i o . h\u0026gt;\nI* Example use of switch statement *I\nlong switchv(long idx) { long result = 0; switch(idx) {\ncase 0:\nresult= Oxaaa; break;\ncase 2:\ncase 5:\nresult= Oxbbb; break;\ncase 3:\nresult= Oxccc; break;\ndefault:\nresult= Oxddd;\n}\nreturn result;\nI* Testing Code *I\n#define CNT 8\n#define MINVAL -1\nint main() {\nlong vals[CNT]; long i;\nfor (i = O; i \u0026lt; CNT; i++) {\nvals[i] = switchv(i + MINVAL);\nprintf(\u0026ldquo;idx = %ld, val= Ox%lx\\n\u0026rdquo;, i + MINVAL, vals[i]);\n}\nreturn O;\n}\n图 4-69 Swi t c h 语句可以翻译成 Y86-64 代码。这要求实现一个跳转表\n4. 51 练习题 4. 3 介绍了 i a d dq 指令,即将 立即数与 寄存器相加。描述实 现该指令所执行的计算。参考\nir mo vq 和 OPq 指令的计算(图4-18) 。\n•• 4. 52 文件 s e q - f u l l. hc l 包含 S EQ 的 HCL 描述,并 将常数 I I ADDQ声明为十六进 制值 c, 也就是 i ad­ d q 的指 令代码。修改实现 i a dd q 指令的控制逻辑块的 HCL 描述, 就像练习题 4. 3 和家庭作业\n4. 51 中描述的 那样。可以参考实验资料获得 如何为你的解答生成模拟器以及如何测试模拟器的指导。\n*/ 4. 53 假设要创建一个较低成本的 、基于我们为 P IP E - 设计的结构(图4-41) 的流水线化的处 理器,不使用旁路技术。这个设计用暂停来处理所有的数据相关,直到产生所需值的指令已经通过了写回 阶段。\n文件 p i p e - s t a ll . h c l 包含一个对 P IP E 的 HCL 代码的修改版,其 中禁止了旁路逻辑。也就是, 信号 e —v a l A 和 e —v a l B 只是简单地声明 如下 :\n## DO NOT MODIFY THE FOLLOWING CODE.\n## No f or 口 ar di ng . valA is either valP or value fromr egi s t er file word d_valA = [\nD_icode in { ICALL, IJXX} : D_valP; # Use incremented PC\n1 : d_rvalA; # Use value read from register file\n];\n## No forwarding. valB is value from register file word d_valB = d_rvalB;\n.. 4. 54\n*** 4. 55\n*** 4. 56\n拿** 4. 57\n修改文件结尾处的流水线控制逻辑 , 使之能正确处理 所有可能的控制和数据冒险。作为设计工作的一部分, 你应该分析各种 控制情况的组合, 就像我们 在 PIP E 的流水线控制逻辑设计中做的那样。你会发现有许多不同的组合,因为有更多的情况需要流水线暂停。要确保你的控制逻辑 能正确处理每种组合情况。可以参考实验资料指导你如何为解答生成模拟器以及如何测试模拟 器的。\n文件 pi pe - fu ll . hc l 包含一份 P IP E 的 HCL 描述, 以及常数值 I I ADDQ的声明。修改该文件以 实现指令 i a dd q , 就像练习题 4. 3 和家庭作业 4. 51 中描述的那 样。可以 参考实验资料获得如何为你的解答生成模拟器以及如何测试模拟器的指导。\n文件 pi pe - nt . hc l 包含一份 P IP E 的 HCL 描述, 并将常数 J _ YES 声明为值 0 , 即无条件转移指令\n的功能码。修改分支预测逻辑 ,使 之对条件转移预测为不选择分支 , 而对无条件转移 和 c a l l 预测为选择分支。你需 要设计一种方法来得到跳转目标 地址 v a l e , 并送到流水线寄存器 M , 以便从错误的分支预测中恢复。可以参考实验资料获得如何为你的解答生成模拟器以及如何测试模拟器的 指导。\n文件 pi pe -b t fn t . hc l 包含一份 P IP E 的 HCL 描述, 并将常数 J _ YES 声明 为值 o, 即无条件转移\n指令的功能码 。修改分支预测逻 辑, 使得当 v a l C\u0026lt; va l P 时(后向分支),就 预测条件转移为选择分支, 当 va l e 娑va l P 时(前向分支), 就预测为不选择分支。(由于 Y86-64 不支持无符号运算,你应该使用有符号比较 来实现这个测试。)并且将无条件转移和 c a l l 预测为选择分支。你需 要设计一种方法来得到 va l C 和 v a l P, 并送到流水线寄存器 M, 以便从错误的分支预测中恢复。可以 参考实验资料获得如何为你的解答生成模拟器以及如何测试模拟器的指导。\n在我们的 P IP E 的设计中,只 要一条指令 执行了 l oa d 操作, 从内存中读一个值到寄存 器,并 且下一条指令要用这个寄存器作为源操作数,就会产生一个暂停。如果要在执行阶段中使用这个源操 作数,暂停 是避免冒险的唯一方法。对于第 二条指令将源操作数存储 到内存的情况,例 如 r mmovq 或 p us hq 指令, 是不需要这样的暂停的。考虑下面这段代码示例 :\nmrmovq 0(%rcx),%rdx pushq %rdx\nnop\npopq %rdx\nrmmovq %rax,O(%rdx)\n# Load 1\n# Store 1\n# Load 2\n# Store 2\n在第 1 行和第 2 行 , mr movq 指令从内存读一个值到%r dx , 然后 pu s hq 指令将这个值压入栈中。我们的 P IP E 设计会让 pus hq 指令暂停 , 以避免装载/使用冒险 。不过, 可以 看到, p us hq 指令要到访存阶段才会需 要%r d x 的值。我们 可以再添加一条 旁路通路 , 如图 4-70 所示, 将内存输出\n(信号m_va l M) 转发到 流水线寄存器 M 中的 va l A 字段。在下 一个时 钟周期,被传 送的值就能写入内存了。这种技术称为加栽转发 (l oad fo r warding ) 。\n注意, 上述代码序列中的第二 个例子(第4 行和第 5 行)不能利用加载转发。p opq 指令加载的值是作为下一条指令地址计算的一部分的,而在执行阶段而非访存阶段就需要这个值了。\n写出描述发现加载 /使用冒险 条件的逻辑公 式, 类似于图 4-64 所示,除 了 能用加载转发时不会导致暂停以外。 文件 pi pe - lf . hc l 包含一个 P IP E 控制逻辑的修改版。它含有信号 e _ v a l A 的 定 义, 用来实现图 4- 70 中标号为 \u0026quot; F w d A\u0026rdquo; 的块。它还将 流水线控制逻辑中的加载/使用冒险的条件设置为 0\u0026rsquo; 因此流水线控制逻辑将不会发现任何形式的加载/使用冒险。修改这个 HCL 描述以实现加载转发。可以参考实验资料获得如何为你的解答生成模拟器以及如何测试模拟器 的指导。 图 4- 70 能够进行加载转发的 执行和访存 阶段。通过 添加一条从内 存输出到流水线寄存器 M 中va l A的源的旁路通路 , 对于这种形 式的加载/使用冒险,我 们可以使用转发 而不必暂停 。这是家庭作业 4. 57 的 主旨\n*:4 . 58 我们的流水线化的设 计有点不太现实 ,因 为寄存 器文件有两个写 端口, 然而只有 p op q 指令需要对寄 存器文件同时 进行 两个写操作。因此, 其他指令只使 用一个写端口, 共享这个端口来写 va l E 和va l M。 下面这个图是一个对写回逻辑的 修改版, 其中, 我们 将写回寄存器 lD ( W_ d s t E 和 W_ds t M)\n合并成一个信号 w—d s t E, 同时也将写回值 ( W—va l E 和 W_v a l M) 合并成一个信号 w_va l E:\n用 HCL 写执行这些合并的逻辑, 如下所示 ;\n## Set E port register ID wor d 廿 _ds t E = [\n## writing from valM W_dstM != RNONE: W_dstM; 1 : W_dstE;\nw_valE w_dslE\n];\n## Set E port value word w_valE = [\nW_dstM != RNONE : W_valM; 1: W_valE;\n];\n对这些多路复用 器的控制是由 ds t E 确定的一— 当它表明 有某个寄存 器时 , 就选择 端口 E 的值,否则就选择端 口 M 的值。\n在模拟模型中 , 我们可以禁 止寄存器端口 M , 如下面这段 H CL 代码所示:\n## Disable register port M\n## Set M port register ID w or d 廿 _ds t M = RNONE;\n## Set M port value word w_valM = O;\n接下来的问 题就是要设计处理 popq 的方法。一种方法是用控制逻辑动态地处 理指令 popq\nrA, 使之与下 面两条指令序列 有一样的效果:\niaddq $8, %rsp mrmovq -8(%rsp), rA\n(关 于指令 i a ddq 的描述, 请参考练习题 4. 3 ) 要注意 两条指令的顺序, 以保证 popq 务r s p 能正确工作。要达到这个 目的,可以 让译码阶段 的逻辑对上面列 出的 popq 指令和 a dd q 指令一视同仁,除了它 会预测下一个 PC 与当前 PC 相等以外。在下一个周期, 再次取出了 po pq 指令, 但是指令代码变成了特殊的 值 I POP2。它会被当作 一条特殊的指令来处理 , 行为与上面列 出的 mr movq 指令一样。\n文件 p i pe - l w. hc l 包含上 面讲的 修改 过的 写端口逻辑。它将常数 I POP2 声明为十六进制值E。还包括信号 f _ i c ode 的定义, 它产生流水线寄存器 D 的 i c ode 字段。 可以 修改这个定义,使得当第二次取出 po pq 指令时, 插人指令代码 I POP2。这个 H CL 文件还包含信号 fy c a9?\u0026lsquo;i , 也就是标号为 \u0026quot; Se lect PC\u0026quot; 的块(图4-57 ) 在取指阶段 产生的程序计数器的 值。\n修改该文 件中的 控制逻辑 , 使之按照我们描述的方式来处理 popq 指令。可以参考实验资料获得如何为你的解答生成模拟器以 及如何测试模 拟器的指导。\n., 4, 59 比较三个版本的冒泡 排序的性能(家庭作业 4. 47 、4. 48 和 4. 49 ) 。解释为什么一个版本的性能比其他两个的好 。\n练习题 答 案 # 1 手工对指令 编码是非常乏味的 , 但是它将 巩固你对汇编器将汇编代码变成 字节序列的理解。在下面这段 Y86-64 汇编器的输出中 , 每一行都给出了一 个地址 和一个从该 地址开始的 字节序列 :\nOxl OO: I . pos Ox100 # Start code at address Ox100\n2 Ox100: 30f30f00000000000000 I irmovq $15,%rbx\n3 Ox10a : 2031 rrmovq %rbx, r 儿 cx\n4 Ox 10c : I loop:\n5 Ox10c: 4013fdffffffffffffff I r mmo v q %rcx, -3(%rbx)\n6 Ox116: 6031 I addq %rbx,%rcx\n7 Ox118 : 700c01000000000000 I jmp loop\n这段编码有些 地方值得 注意 :\n十进制的 15 ( 第 2 行)的十六 进制表示 为 Ox OOOOOOOOOOOOOOOf 。 以反向顺 序来写就是 Of 00 00 00 00 00 00 00 。 十进制 - 3 ( 第 5 行)的十六进制表 示为 Oxf f f ff ff f ff ff ff f d 。 以反向顺 序来写就 f d ff ff f f ff ff ff ff. 代码从地址 Ox l OO 开始。第一条指令需要 10 个字节, 而第二条需要 2 个字节。因 此, 循环的目标地址 为 Ox000001 0c 。以反向顺 序来写就是 Oc 01 00 00 00 00 00 00. 2 手工对一个字节序列进行译码 能帮助你理 解处理器面临的 任务 。它必须读入字 节序列 , 并确定要执行什么指令。接下来 ,我 们给出的是用来产生每个字节序列 的汇编代码。在汇编代码的 左边,你 可以看到每条指 令的地址和字节序列。\n一些带立即数 和地址偏移 量的操作:\nOx100: 30f3fcffffffffffffff I\nOx10a: 40630008000000000000 I\nOx114: 00 I\n包含一 个函数调用的代码 :\nOx200: a06f\nOx202: 800c02000000000000 Ox20b: 00\nirmovq $-4,%rbx\nrmmovq %rsi,Ox800(%rbx) halt\npushq %rsi call proc halt\nOx20c: I proc:\nOx20c: 30f30a00000000000000 I irmovq $10,%rbx Ox216: 90 I ret\n包含非法指令 指示字节 Ox f O 的 代码:\nOx300: 50540700000000000000 I mrmovq 7(%rsp),%rbp\nOx30a: 10 I nop\nOx30b: fO I .byte OxfO # Invalid instruction code\nOx30c: b01f I popq /,rcx\n包含一个跳转操作的代码:\nOx400: I loop:\nOx400: 6113 I subq %rcx, %rbx Ox402: 730004000000000000 I je loop\nOx40b: 00 I halt\npushq 指令中第二个字节非法的代码。 Ox500: 6362 Ox502: aO\ncode Ox503: fO\nspecifier byte\nxorq %rsi,%rdx\n.byte OxaO # pushq instruction\n.byte OxfO # Invalid register\n4. 3 使用 i a d d q 指令, 我们将 s um 函数重新编写为\n# long sum(long *Start, long count)\n# start in %rdi, count in %rsi sum :\nloop:\ntest:\nxorq %r ax , %r ax andq %rsi,%rsi jmp test\nmrmovq (%rdi),%r10 addq %r10,%rax iaddq $8 丛 r di\niaddq $-1,%rsi\njne loop ret\n#sum= 0\n# Set condition codes\n# Get *start\n# Add to sum\n# start++\n# count\u0026ndash;\n# Stop 吐en 0\n4. 4 在x86-64 机器上运行 时, GCC 生成如下r s um 代码:\nlongr s um(l ong • s tar t , long count) start in¼rdi, count in¼rsi\nr sum :\nmovl $0, %eax testq %rsi, %rsi jle .19\npushq %rbx\nmovq ( %r d i ) , %rbx\nsubq $1, %rsi\naddq $8, %rdi\ncall rsum\naddq %rbx, %rax\npopq %rbx\n.L9:\nrep; ret\n上述代 码很容易改编为 Y86-64 代码:\n# long rsum(long *start, long count)\n# start in %rdi, count in %rsi rsum:\nreturn:\nxorq %rax,%rax andq %rsi,%rsi je return pushq %rbx\nmrmovq (%rdi),%rbx irmovq $-1,%r10 addq %r10,%rsi irmovq $8,%r10 addq %r10,%rdi call rsum\naddq %rbx,%rax popq %rbx\nret\n# Set return value to 0\n# Set condition codes\n# If count== 0, return 0\n# Save callee-saved register\n# Get *start\n# count\u0026ndash;\n# start++\n# Add *Start to sum\n# Restore callee-saved register\n5 这道题给了你一个练习写汇编代码的机会。\n# long absSum(long *start, long count)\n# start in %rdi, count in %rsi\nabsSum:\nirmovq $8,%r8 # Constant 8\nirmovq $1,%r9 # Constant 1\nxorq %rax,%rax #sum= 0\nandq %rsi,%rsi # Set condition codes\njmp test\nloop:\nmrmovq (%rdi),%r10 # x = *start\nxorq %r11, %r11 # Constant 0\n12 subq %r10,%r11 # -x\njle pas # Skip if -x \u0026lt;= 0\nrrmovq %r11,%r10 # X = -x\npos:\naddq %r10,%rax # Add to sum\naddq %r8,%rdi # start++\nsubq %r9,%rsi # count\u0026ndash;\ntest:\njne loop # Stop when 0\nret\n4. 6 这道题 给了你一 个练习写带 条件传送 汇编代码的 机会。我们只给出循环的 代码。剩下的部分与练习题 4. 5 的一样。\n9 loop: 10 mrmovq (%rdi),%r10 # X = *Start 11 xorq %r11,%r11 # Constant 0 12 subq %r10,%r11 # -x 13 cmovg %r11,%r10 # If -x \u0026gt; 0 then x = -x 14 addq %r10,%rax # Add to sum 15 addq %r8,%rdi # start++ 16 subq %r9,%rsi # count\u0026ndash; 17 test: 18 jne loop # Stop when 0\n[,\n4. 7 虽然难以想象这条特殊的指令有什么实际的用处,但 是 在设计一个系统时, 在描述中避免任何歧义是 很 重要的。我们想要为这条指令的行 为确定 一个合理的规则, 并 且 保 证 每个实现都遵循这个规则。\n在这个测试中, s ubq 指令将% r s p 的 起 始 值 与压入栈中的值进行了比较。这个减法的结果为 o ,\n表明压入的是%r s p 的 旧 值 。\n4. 8 更难以想象 为什么会有人想要把值弹出到栈 指针。我们还是应该 确定一个规则,并 且坚 持 它 。这段代码序列将 Oxab cd 压 人栈中, 弹出到%r s p , 然后返回弹出的值。由于结果等于 Oxab cd , 我们可以推断出\npopq r% s p 将 栈指针设置为从内存中读出来的那个值。因此, 它等 价 千指令 mrmov q (r% s p ) ,\n4. 9 E XCL US IV E-O R 函数要求两个位有相反的值:\nbool xor = (!a \u0026amp;\u0026amp; b) II (a \u0026amp;\u0026amp; !b);\nr% s p 。\n通常 , 信号 e q 和 xor 是互补的。也就是,一 个 等 于 1\u0026rsquo; 另 一 个 就 等于 0。\nEq\n由 于 第 一 行 将检 测 出 A 为最小元素的情况,因 此 第 二\nb,\n行 就 只需 要 确 定 B 还是 C 是最小元素。\na,\n4. 12 这个设计只是对从三个输入中找出最小值的简单\n改变。 坏\n气\nword Med3 = [\nA\u0026lt;= B \u0026amp;\u0026amp; B \u0026lt;= C : B; C \u0026lt;= B \u0026amp;\u0026amp; B \u0026lt;= A : B; B \u0026lt;= A \u0026amp;\u0026amp; A\u0026lt;= C : A; C \u0026lt;= A \u0026amp;\u0026amp; A\u0026lt;= B : A; 1 : C;\n图 4-71 练习题 4. 10 的答案\n];\n4. 13 这些练习使各个阶段的计算更加具体。从目标代码中我们可以看到,指 令 位于地址 Ox 01 6。它由\n10 个 字 节 组 成 ,前 两 个字节为 Ox 3 0 和 Oxf 4。后八个字节是 Ox OOO OOOOO OO OOO OBO( 十进制 128) 按\n字节反过来的形式。\n通用 i r rnov q V, rB ico de , ifun ~ M, [ PC] rA: rB - M1[ PC+ l] vale+- Ms[ PC+ 2] valP 仁 - PC+ l O valE 仁 - o+ valC R[ rB] - valE PC- valP 具体 阶段 ir mo vq $ 1 28, r% s p 取指 icode,ifun- M, [ Ox016] = 3: 0 rA , rB - M, [ Ox017] = f : 4 valC - Ms[ Ox018] = 128 valP - Ox0 1 6+ 10= Ox020 译码 执行 valE 仁 - o+ 128= 128 访问 写回 R[ % r s p ] - valE = 128 更新 PC PC - valP= Ox020 这个指令将寄存器% r s p 设 为 128 , 并将 PC 加 10。\n4. 14 我们 可以看到指令位于地址 Ox 02 c , 由两个字节组成,值 分 别 为 Oxb O 和 Ox OO f 。 pus hq 指 令(第 6\n行)将 寄 存 器%r s p 设 为了 120 , 并且将 9 存放在了这个内存位置。\nvalP 仁 - PC+ 2 valP 仁 - Ox02c + 2 = Ox02e 译码 valA - valB - R[ r% sp ] R[ %r s p] valA - valB - R[ r% s p] = 120 R[ r毛 s p] = 120 执行 valE - valB+ 8 valE - 120+8= 128 访存 valM - 队[ valA] va!M - Ms[ 120] = 9 写回 R[ %rsp]- valE R[ %rsp]- 128 R[ rA] - valM R 巨 r s p] - 9 更新 PC PC 仁 - valP PC - Ox02e 该指令将%r a x 设 为 9\u0026rsquo; 将 %r s p 设 为 128 , 并将 PC 加 2.\n4. 15 沿着图 4- 20 中列 出的步骤, 这里r A 等 于 %r s p , 我们可以看到, 在访存阶段, 指令会将 va l A( 即栈指针的原始值)存放到内存中,与 我 们在 x86-64 中发现的一样。\n4. 16 沿着图 4-20 中列出的步骤,这 里r A 等 于 % r s p , 我们可以看到,两 个写回操作都会更新%r s p 。 因\n为写 va l M 的 操 作 后 发生,指 令的 最 终 效 果 会 是 将 从内存中读出的值写入% r s p , 就 像 在 x86-6 4 中看到的一样。\n4. 17 实现条件传送只需 要对寄存器到寄存器的传送做很小的修改。我们简单地以条件测试的结果作为写回步骤的条件:\n4. 18 我们可以看到这条指令位于地址 Ox037, 长度为 9 个字节。第一个字节值为 Ox80, 而后面 8 个字节是\nOx0 000000000000014 按字节反过来的形式,即调用的目标地址。 p::\u0026gt;pq 指令(第7 行)将栈指针设为128。\n这条指令的效果 就是将%r s p 设为 1 20 , 将 Ox 0 40( 返回地址)存放到该内存地址 , 并将 PC 设为\nOx 0 41 ( 调用的目标地址)。\n4. 19 练习题中所有的 HCL 代码都很简单明了 , 但是试着自己写会帮助你思 考各个指令 , 以及如何处理它们。对于这个问 题, 我们只要 看看 Y8 6- 64 的指令集(图4- 2 ) , 确定哪些有常数字段。\nbool need_valC =\nicode in { IIRMOVQ, IRMMDVQ, IMRMDVQ, IJXX, !CALL};\n4. 20 这段代码类似 千 s r c A 的代码 :\nword srcB = [\nicode in { IDPQ, IRMMOVQ, IMRMOVQ } : rB;\nicode in { IPUSHQ, IPDPQ, ICALL, IRET} : RRSP;\n1 : RNONE; # Don\u0026rsquo;t need register\n];\n4 . 2 1 这段代码类 似千 d s t E 的代码 :\nword dstM = [\nicode in { IMRMOVQ, IPOPQ} : rA;\n1 : RNDNE; # Don\u0026rsquo;t write any register\n];\n4 . 22 像在练习题 4. 16 中发现的那样, 为了将从内 存中读出的值存放到% r s p , 我们 想让通过 M 端口写的优先级 高于通过 E 端口写。\n4. 23 这段代码类 似千 a l uA 的代码 :\nword aluB = [\nicode in { IRMMOVQ, IMRMOVQ, IDPQ, !CALL,\nIPUSHQ, I RET, IPOPQ} : valB; icode in { IRRMOVQ, IIRMOVQ} : O;\n# Other instructions don\u0026rsquo;t need ALU\n];\n4 . 24 实现条件传送令人吃 惊的简单: 当条件不满足时 ,通 过将目的寄存器设置为 RNONE 禁止写寄存器文件。\nword dstE = [\nicode in { IRRMOVQ} \u0026amp;\u0026amp;: Cnd : rB; icode in { IIRMOVQ, IOPQ} : rB;\nicode in { IPUSHQ, IPOPQ, !CALL, IRET} : RRSP;\n1 : RNONE; # Don\u0026rsquo;t write any register\n];\n4 . 25 这段代码类 似千 me m a d d r 的 代码:\nword mem_data = [\n# Value from register\nicode in { IRMMOVQ, IPUSHQ} : valA;\n# Return PC\nicode == ICALL : valP;\n# Default: Don 飞 口 r i t e anything\n]?\n4. 26 这段代码类 似于 me m_r e a d 的代码:\nbool mem_write = icode in { IRMMOVQ, IPUSHQ, !CALL};\n4. 27 计算 S t a t 字段需要从几个阶段收集状 态信息 :\n## Determine instruction status word Stat = [\nimem_error 11 dmem_error : SADR;\n!instr_valid: SINS; icode == !HALT : SHLT;\n1 : SAOK·\n];\n4. 28\n4. 29\n这个题目非常有趣,它试图在一组划分中找到优化平衡。它提供了大昼的机会来计算许多流水线的吞吐拭和延迟。\n对一个两阶段流 水线来说, 最好的划分是块 A、 B 和 C 在第一阶段 , 块 D、 E 和 F 在第二阶段。第一阶段的 延迟 为 170 ps , 所以 整个周期的时长为 1 70 + 20 = 190ps 。因此吞吐量为 5. 26\nGIPS, 而延迟为 380ps 。\n对一个三阶段 流水线来说 , 应该使块 A 和 B 在第一阶段, 块 C 和 D 在第二阶段, 而块 E 和 F\n在第三阶段 。前两个阶段的延迟均 为 ll Ops , 所以 整个周期时长为 130 ps , 而吞吐昼为 7. 69\nGIPS 。延迟为 390ps 。\n对一个四阶段流水线来说 , 块 A 为第一阶 段, 块 B 和 C 在第二阶段, 块 D 是第三阶段, 而块\nE 和 F 在第四 阶段。第二阶段需要 90 ps , 所以整个周期时 长为 ll Ops , 而吞吐最为 9. 09 GIP S。延迟为 440 ps。\n最优的设计应该是五阶段 流水线 ,除 了 E 和 F 处千第五阶段以外 , 其他每个块是一个阶段。周期时长为 80 -t- 20 = lOOps, 吞吐批为大约 10. 00 GIPS, 而延迟为 500 ps。变成更多的阶段也不会有帮助了 , 因为不可能使 流水线运行 得比以 l OOps 为一周期 还要快了。\n每个阶段的组合逻辑都需 要 300 / k ps , 而流水线 寄存器需要 20 ps。\n整个的延迟应该是 300 + 20k ps , 而吞吐量(以 G IPS 为单位)应该是\n1 000 1 OOOk\n300\nk\n= 300 + 2 0 k\n20\n4. 30\n当 K 趋近于无穷 大, 吞吐橄变 为 1 000/20=50 GIPS。当然, 这也使得延迟为无穷大。\n这个练习题量化了很 深的流水线引起的 收益下降。当我们试 图将逻辑 分割为很 多阶段时, 流水线寄存楛的延迟成 为了一 个制约因素 。\n这段代码非常类似于 SEQ 中相应的 代码,除 了我们还不能确定数据内存是 否会为这条指令产生一个错误信号。\n# Determine status code for fetched instruction 11ord f_stat• [\nimem_error: SADR;\n!instr_valid: SINS; f_icode == !HALT : SHLT;\n1 : SAOK;\n4. 31\n];\n这段代码只是简单地 给 SEQ 代码中的信号名前加上前缀 \u0026quot; ct_\u0026quot; 和 \u0026quot; o_\u0026quot;。\nword d_dstE = [\nD_icode in { IRRMOVQ, IIRMOVQ, IOPQ} : D_rB; D_icode in { IPUSHQ, IPOPQ, !CALL, IRET} : RRSP;\n: RNONE; # Don\u0026rsquo;t write any register 4. 32\n];\n由于 popq 指令(第4 行)造成的加载/使用冒险, r r movq 指令(第5 行)会暂停一个周期。当它进入译码阶段, popq 指令处于访存阶段 , 使 M_d s t E 和 M_d s t M 都等于 % r s p 。 如果两种情况反过来 , 那么来自 M_va l E 的写回 优先级较高, 导致增加了的栈指针被传 送到 rr movq 指令作为参数。这与练习题 4. 8 中确定的处理 po pq %r s p 的 惯例不 一致。\n这个问题让你体验一下处理器设 计中一个很重要的 任务一 为一个新处理器设 计测试程序。通常, 我们的测试程序应该能测试所有的冒险可能性,而且一旦有相关不能被正确处理,就会产生错误 的结果。\n对于此例 , 我们可以使用对练习题 4. 32 中所示的 程序稍微修改的版本 :\nirmovq $5, %rdx irmovq $0x100,%rsp rmmovq %rdx,0(%rsp) popq %rsp\nnop nop\nrrmovq %rsp,;r人 ax\n4. 34\n两个 no p 指令会导致 当r r mo v q 指令在译码 阶段中 时, p o p q 指令处于写 回阶段。如果给予处于写回阶段中的两个转发源错误 的优先级 , 那么寄存器釭a x 会设登成增加了的程序计数器,而不是从内存中读出的值。\n这个逻辑只需 要检查 5 个转发源:\nword d_valB = [\nd_srcB == e_dstE : e_valE; # Forward valE from execute d_srcB == M_dstM: m_valM; # Forward valM from memory d_srcB == M_dstE : M_valE; # Forward valE from memory d_srcB == W_dstM : W_valM; # Forward valM from write back d_srcB == W_dstE : W_valE; # Forward valE from write back\n1 : d_rvalB; # Use value read from register file\n4. 35\n];\n这个改变不会处理条件传送不满足条件的情况, 因此将 d s t E 设置为 RNONE。即使条件传送并没有发生,结果 值还是会被转发到下一条指令。\nirmovq $0x123,%rax irmovq $0x321,%rdx\nxorq %rcx,%rcx cmovne %rax,%rdx addq %rdx,%rdx halt\n#cc= 100\n# Not transferred\n# Should be Ox642\n4. 36\n这段代码将寄存器% r d x 初始化为 Ox3 2 1 。 条件数据传送没有发生, 所以最后的 a dd q 指令应该 把%r d x 中的值翻倍 , 得到 O x 6 4 2。不过, 在修改过的版本中 , 条件传送源值 Ox l 2 3 被转发到 AL U 的输 入 va l A, 而 v a l B 正 确地 得到 了 操作数值 Ox 3 2 1 。 两 个 输 入 加起来就得到结果 Ox 4 4 4 。\n这段代码 完成了对这条指令的状态码的计算。\n## Update the status word m_stat = [\ndmem_error : SADR;\n1 : M_stat;\n4. 37\n];\n设 计下面这个 测试程序来建立控制组合 AC图 4 - 67 ) , 并探测是否出了错:\n# Code to generate a combination of not-taken branch and ret irmovq Stack, %rsp\ni rmovq rtnp,%rax\npushq ir 儿 ax # Set up return pointer xorq %rax,%rax # Set Z condition code\njne t ar get # No t taken (First part of combination) irmovq $1,%rax # Should execute this\nhalt\nt a 工 ge t : ret\nirmovq $2,%rbx halt\n# Second part of combination\n# Should not execut e this\nrtnp:\nir ovq$3,%r dx halt\n# Should not execute this\n.pos Ox 40 St a c k :\n设计这个程序是为了出错(例如如果实际上执行了 r e t 指令)时,程 序会执行一条额外的 江-\nmovq 指令, 然后停止。因此,流水线中的错误 会导致某个寄存 器更新错 误。这段代码说明实现测试程序需要非常小心。它必须建立起可能的错误条 件, 然后再探 测是否有错误发生。\n4. 38 设计下面这个测试 程序用来建 立控制组合 BC图 4-67 ) 。 模拟器会发现流水 线寄存骈的 气泡和暂停\n控制信号都设 置成 0 的情况, 因此我们的 测试程序 只需要建立它需 要发现 的组合情况。最大的挑战在千当处理正确时,程序要做正确的事情。\n1 # Test instruction that modifies %esp followed by ret\nirmovq mem, %rbx mrmovq O(%rbx) , %rsp # Sets %rsp to point to return point 4 ret # Returns to return point 5 halt # 6 r t npt : 7 irmovq $5,%rsi halt # Return point a . pos Ox40\nme m: . quad stack # Holds desired stack pointer . pos Ox50 s t a c k : . quad rtnpt # Topof stack: Holds return point 这个 程序使 用了内存中两个初始化了的字。第一 个字( me m) 保存 着第二 个字( s t a c k一 期望的栈指针)的地址。第 二个字保存着 r e t 指令期望的返回点的地址。这 个程序将栈指针加载到\n% r s p , 并执行 r e t 指令。\n4. 39 从图 4- 66 我们可以 看到,由 千加 载/使用冒险, 流水线寄存 器 D 必须暂停 。\nbool D_stall =\n# Conditions for a load/use hazard E_icode in { IMRMOVQ, IPOPQ} \u0026amp;\u0026amp; E_dstM in { d_srcA, d_srcB };\n4. 40 从图 4- 66 中可以看到, 由于加载/使用冒险, 或者由 于分 支预测 错误, 流水线寄存器 E 必须设置成气泡:\nbool E_bubble =\n# Mispredicted branch\n(E_icode == IJXX \u0026amp;\u0026amp; !e_Cnd) I I\n# Conditions for a load/use hazard E_icode in { IMRMOVQ, IPOPQ} \u0026amp;\u0026amp; E_dstM in { d_srcA, d_srcB};\n4.41 这个控制 需要检查正 在执行的指令的代码 , 还需要检查流水线中更后 面阶段中的异 常。\n## Should the condition codes be updated7 bool set_cc = E_icode == IOPQ \u0026amp;\u0026amp;\n# State changes only during normal operation\n!m_stat in { SADR, SINS, SHLT} \u0026amp;\u0026amp; !W_stat in { SADR, SINS, SHLT };\n4. 42 在下一个周期向访存阶段插入气 泡需要检查当前周期 中访存或者写回阶段中是 否有异常。\n# Start injecting bubbles as soon as except i on passes through memory stage\nbool M_bubble = m_stat in { SADR, SINS, SHLT } I I W_stat in { SADR, SINS, SHLT } ;\n对于暂停写回阶段,只用检查这个阶段中的指令的状态。如果当访存阶段中有异常指令时我 们也暂停了, 那么这条指令就不能 进入写回阶段。\nbool W_stall = W_stat in { SADR, SINS, SHLT } ;\n4. 43 此时, 预测错误的频率是 0. 35, 得到 m p = O. ZO X O. 35X2=0. 14, 而整个 CPI 为 1. 25 。看上 去收获非常小,但是如果实现新的分支预测策略的成本不是很高的话,这样做还是值得的。\n44 在这个简化的分析中 , 我们把注意力放在了内 循环上 , 这是估计程序 性能的一种很有用的方法。\n只要数组 足够大 , 花在代码 其他部分的时间 可以忽略不 计。\n使用条件转移的代码的内循环有 9 条指令, 当数组元素是 0 或者为负时 , 这些指令都要执行, 当数组元素为正时 , 要执行其中的 8 条。平均是 8. 5 条。使用条件传送的代码的内 循环有 8 条指令,每次都必须执行。\n用来实现循环闭合的跳转除了当循环中止时之外,都能预测正确。对于非常长的数组,这个预 测错误对性能的影响可以忽略不计。对于基于跳转的代码,其他唯一可能引起气泡的源取决于 数组元素是否为正的 条件转 移。这会导致两个气泡, 但是只在 50 % 的时间里会出现,所以平均值是 1. 0。在条件传送代码中 ,没有气 泡。 我们的 条件转移代码对于每个元素 平均需 要 8. 5 + 1. 0 = 9. 5 个周期(最好情况要 9 个周期,最差情况要 10 个周期), 而条件传送代码对 千所有的 情况都需要 8. 0 个周期。\n我们的 流水线的分 支预测错误处罚只有两 个周期—- 远比 对性能更高的处理器中很 深的流水线造成的处罚要小得多。因此, 使用条件传送对程序性 能的影响不是很 大。\n"},{"id":441,"href":"/zh/docs/technology/System/%E6%B7%B1%E5%85%A5%E7%90%86%E8%A7%A3%E8%AE%A1%E7%AE%97%E6%9C%BA%E7%B3%BB%E7%BB%9F/%E4%B9%A6%E7%B1%8D/%E7%AC%AC5%E7%AB%A0-%E4%BC%98%E5%8C%96%E7%A8%8B%E5%BA%8F%E6%80%A7%E8%83%BD/","title":"Index","section":"SpringCloud","content":"C H— _\n第 5 章\n_ A P T E R 5\n优化程序性能 # 写程序最主要的目标就是使它在所有可能的情况下都正确工作。一个运行得很快但是给出错误结果的程序没有任何用处。程序员必须写出清晰简洁的代码,这样做不仅是为了 自己能够看懂代码,也是为了在检查代码和今后需要修改代码时,其他人能够读懂和理解 代码。\n另一方面,在很多情况下,让程序运行得快也是一个重要的考虑因素。如果一个程序要实时地处理视频帧或者网络包,一个运行得很慢的程序就不能提供所需的功能。当一个 计算任务的 计算量非常 大,需 要执行数日或者数周, 那么哪怕只是让它运行得快 20 %也会产生重大的影响。本章会探讨如何使用几种不同类型的程序优化技术,使程序运行得 更快。\n编写高效程序需要做到以下几点:第一,我们必须选择一组适当的算法和数据结构。 第二,我们必须编写出编译器能够有效优化以转换成高效可执行代码的源代码。对于这第 二点,理解优化编译器的能力和局限性是很重要的。编写程序方式中看上去只是一点小小 的变动,都 会引起编译器优化方式很大的变化。有些编程语言比其他语言容易优化。C 语言的有些特性,例如执行指针运算和强制类型转换的能力,使得编译器很难对它进行优 化。程序员经常能够以一种使编译器更容易产生高效代码的方式来编写他们的程序。第三 项技术针对处理运算量特别大的计算,将一个任务分成多个部分,这些部分可以在多核和 多处理器的 某种组合上并行地计算。我们会 把这种性能改进的方法推迟到第 12 章中去讲。即使是要利用并行性,每个并行的线程都以最高性能执行也是非常重要的,所以无论如何 本章所讲的内容也还是有意义的。\n在程序开发和优化的过程中,我们必须考虑代码使用的方式,以及影响它的关键因\n素。通常,程序员必须在实现和维护程序的简单性与它的运行速度之间做出权衡。在算法 级上,几分钟就能编写一个简单的插入排序,而一个高效的排序算法程序可能需要一天或更长的时间来实现和优化。在代码级上,许多低级别的优化往往会降低程序的可读性和模块性,使得程序容易出错,并且更难以修改或扩展。对于在性能重要的环境中反复执行的代码,进行大量的优化会比较合适。一个挑战就是尽管做了大量的变化,但还是要维护代码一定程度的简洁和可读性。\n我们描述许多提高代码性能的技术。理想的情况是,编译器能够接受我们编写的任何代码,并产生尽可能高效的、具有指定行为的机器级程序。现代编译器采用了复杂的分析 和优化形式,而且变得越来越好。然而,即使是最好的编译器也受到妨碍优化的因素(optimization blocker ) 的阻碍, 妨碍优化的因素就是程序行为中那些严重依赖于执行环境的方面。程序员必须编写容易优化的代码,以帮助编译器。\n程序优化的第一步就是消除不必要的工作,让代码尽可能有效地执行所期望的任务。这包括消除不必要的函数调用、条件测试和内存引用。这些优化不依赖于目标机器的任何 具体属性。\n为了使程序性能最大化,程序员和编译器都需要一个目标机器的模型,指明如何处理指\n令,以 及各个操作的时序特性。例如, 编译器必须知道时序信息, 才能够确定是用一条乘法指令, 还是用移位和加法的某种组合 。现代计算机用复杂的技术来处理机器级程序, 并行地执行许多指令,执行顺序还可能不同 千它们在程序中出现的顺序。程序员必须理解这些处理器是如何工作的, 从而调整他们的程序以获得最大的 速度。基千 Intel 和 AMD 处理器最近的设计 , 我们提出了这种机器的一个高级模型。我们 还设计了一种图形数据流( data-flow) 表示法, 可以使处理器对指令的执行 形象化, 我们还可以 利用它预测程序的性能。\n了解了处理器的运作,我们就可以进行程序优化的第二步,利用处理器提供的指令级并 行(inst ru ction-level para llelism ) 能力, 同时执行多条指令。我们会讲述儿个对程序的 变化,降低一个计算的不同部分之间的数据相关, 增加并行度, 这样就可以同时执行这些 部分了。\n我们以对优化 大型程序的问 题的讨论来结束这一章。我们描述了代码剖析 程序 ( profi­ le r ) 的使用 , 代码剖析程序是测量程序各个部分性能的工具。这种分析能够帮助找到代码中低效率的地方,并且确定程序中 我们应该 着重优化的部分。\n在本章的描述中,我们使代码优化看起来像按照某种特殊顺序,对代码进行一系列转 换的简单线性过程。实际上, 这项工作远非这么简单。需要相当多的试错法试验。当我们进行到后面的优化阶段时, 尤其是这样, 到那时 , 看上去很 小的变化 会导致性能上很大的变化。相反, 一些看上去很有希望的技术被证明是 无效的。正如后面的例子中会看到的那样, 要确切解释为什么某段 代码序列具有特定的执行时间, 是很困难的。性能可能依赖于处理器设计的许多细节特性 ,而 对此我们所知甚少。这也是 为什 么要尝试各种技术的变形和组合的 另一个原因。\n研究程序的汇编代码表示是理解编译楛以及产生的代码会如何运行的最有效手段之 一。仔细研究内 循环的代码是一个很好的开端,识别 出降低性能的属性, 例如过多的内存引 用 和对寄存器使用不当。从汇编代码开始 , 我们还可以预测什么操作会并行执行, 以及它们会如何使用处理器资源。正如我们会看到的, 常常通过 确认关键路径( crit ica l pa t h) 来决定执行一个循环所需要的时间(或者说, 至少是一个时间下 界)。所谓关键路径是在循环的 反复执行过程中形成的数据相关链。然后 , 我们会回过头来 修改源代码 , 试着控制编译器使之产生更有效率的实现。\n大多数编译器, 包括 GCC, 一直都在更新和改进, 特别是在优化能力方面。一个很有用的策略是只重写程序到 编译器由此就能产生有效代码所需要的程度就好了。这样,能尽量避免损 害代码的可读性 、模块性和可移植性, 就好像我们使用的是具有最低能力的编译器。同样, 通过测量值和检查生成的 汇编代码 , 反复修改源代 码和分析它的性能是很有帮助的。\n对千新手程序员来说,不断修改源代码,试图欺骗编译器产生有效的代码,看起来很 奇怪 , 但这确实是编写很多高性能程序的方式。比较千另 一种方法- 用汇编语言写代码, 这种间 接的方法具 有的优点是:虽 然性能不一定是最好的, 但得到的代码仍然能够在其他机器上运行。\n5. 1 优化编译器的能力和局限性 # 现代编译器运用复杂精细的算法来确定一个程序中计算的是什么值,以及它们是被如 何使用的。然后会利用一些机会来简化表达式 , 在几个不同的地方使用同一个计算, 以及降低一个给定的计算必须被执行的次数。大多数编译器, 包括 GCC, 向用户提供了一些对它们所使用的 优化的 控制。就像在第 3 章中讨论过的, 最简单的控制就是指定优化级\n别。例如,以 命 令 行 选项 \u0026quot; - og\u0026quot; 调用 GCC 是让 GCC 使用一组基本的优化。以选项 \u0026quot; -\n01\u0026quot; 或更高(如 \u0026quot; - 0 2\u0026quot; 或 \u0026quot; - 0 3\u0026quot; ) 调用 GCC 会让它使用更大翟的优化。这样做可以进一步提高程序的性能,但是也可能增加程序的规模,也可能使标准的调试工具更难对程序进行 调试。我们的表述,虽 然 对 于 大 多 数 使 用 GCC 的软件项目来说, 优 化 级 别 - 0 2 已经成为了被接受的标准,但 是 还是主要考虑以优化级别- 0 1 编译出的代码。我们特意限制了优化级别,以 展 示写 C 语言函数的不同方法如何影响编译器产生代码的效率。我们会发现可以写出的 C 代码, 即 使 用 - 0 1 选项编译得到的性能,也 比用 可能的最高的优化等级编译一个更原始的 版本得到的性能好。\n编译器必须很小心地对程序只使用安全的优化,也就是说对于程序可能遇到的所有可 能的情况 , 在 C 语言标准提供的保证之下,优 化 后 得 到 的 程 序 和 未 优 化 的 版本有一样的行为。限制编译器只进行安全的优化,消除了造成不希望的运行时行为的一些可能的原因, 但是这也意 味着程序员 必须花费 更大的力气写出编译器能够将之转换成有效机器代码的程序。为了理 解决定一种程序转换是否安全的难度,让 我们来看看下面这两个过程:\n1 void twiddle1(long *xp, long *yp)\n2 {\n3 *XP += *yp;\n4 *XP += *yp;\n5 }\n6\n7 void twiddle2(long *XP, long *yp)\n8 {\n9 *XP += 2* *yp;\n10 }\n乍一看,这 两个过程似乎有相同的行为。它们都是将存储在由指针 y p 指示的位置处的值两次加 到指针 x p 指示的位置处的值。另一方面, 函 数 t wi d d l e 2 效 率 更 高 一 些 。 它只要求 3 次内存引用(读*x p , 读* y p , 写*x p ) , 而 t wi d d l e l 需 要 6 次( 2 次读*x p , 2 次读\n*yp , 2 次写*x p ) 。因此, 如 果 要 编 译 器 编 译 过程 t wi d d l e 1 , 我们会认为基于 t wi d d l e 2\n执行的计算能产生更有效的代码。\n不过, 考虑 x p 等于 yp 的 清况 。 此 时 , 函 数 t wi d d l e l 会执行下面的计算:\n3 *xp += *xp; I* Double value at xp *I 4 *xp += *xp; I* Double value at xp *I 结果是 x p 的值增加 4 倍。另一方面, 函数 t wi d d l e 2 会 执 行 下 面的计算:\n9 *XP += 2* *xp; I* Triple value at xp *I\n结果是 xp 的值增加 3 倍。编译器不知道 t wi dd l e l 会如何被调用, 因 此它必须假设参数 xp\n和 yp 可能会相等。因此, 它不 能 产 生 t wi dd l e 2 风格的代码作为 t wi dd l e l 的 优 化 版 本 。\n这种两个指针可能指向同一个内存位置的情况称为内存别 名使 用 ( m e m o r y a li a s ing ) 。在只执行安全的优化中, 编 译 器 必 须 假 设 不 同 的 指 针可能会指向内存中同一个位置。再看一个例子, 对千一个使用指针变量 p 和 q 的程序, 考虑下面的代码序列:\nX = 1000; y = 3000;\n*q = y; I* 3000 *I *P = X j I* 1000 *I t1 = *q; I* 1000 or 3000 *I t l 的计算值依赖于指针 p 和 q 是否指向内存中同一个位置 如果不是, t l 就等于\n3000, 但如果是, t1 就等于 1000 。这造成了一个主要的妨碍优化的因素, 这 也 是 可能严重限制编译器产生优化代码机会的程序的一个方面。如果编译器不能确定两个指针是否指 向 同 一 个 位 置 , 就必 须 假设 什 么情 况都有可能,这 就 限 制了可能的优化策略。\n沁囡 练习题 5. 1 下面的问题说明了内存别名使用可能会导致意想不到的程序行为的方式。 考虑下面这 个交换 两个 值的过程:\nI* Swap value x at xp with value y at yp *I void swap(long *XP, long *yp)\n{\n*XP = *XP + *yp;\n*YP = *XP - *yp;\n*XP = *XP - *yp;\n如果调用这个过程 时 xp 等 于 yp , 会有什么样的效果?\n第二个妨碍优化的因素是函数调用。作为一个示例, 考虑下面这两个过程:\nlong f();\nlong func10 {\nreturn f () + f () + f () + f () ;\n}\nlong func2() { return 4*f () ;\n}\n最初看上去两个过程计算的都是相同的结果, 但 是 f u n c 2 只 调 用 f 一 次 , 而 fu ncl\n调用 f 四次 。以 f u nc l 作为源代码时,会 很 想 产 生 f u n c 2 风 格 的 代 码 。不 过 ,考 虑下 面 f 的代码:\nlong counter= O;\nlong f O {\nreturn counter++;\n}\n这个函数有个副作用一 它 修 改 了 全 局 程序状态的一部分。改变调用它的次数会改变程 序 的 行 为。特别地, 假设开始时全局变最 c oun t er 都设詈为 o, 对 f u n c l 的调用会返回\n0 + 1 + 2 + 3 = 6 , 而对 f un c 2 的调用会返回 4 • O= O。\n大 多 数 编译器不会试图判断一个函数是否没有副作用, 如果没有,就 可能被优化成像\nf unc 2 中的样 子 。 相反, 编译器会假设最糟的情况 ,并保持所有的 函数调用不 变。\n田 日 用内联函数替换优化函数调用\n包含 函 数 调用的 代码可以 用一个称为 内联 函数替 换 ( inli ne s ubstit ut ion , 或者简称S\n“ 内联( in linin g ) \u0026quot; ) 的过程进行优化, 此时, 将函数调用替 换 为 函数体。例如 , 我们可以通过替换掉对函数 f 的四次调用,展 开 f u n c l 的代码:\nI* Result of inlining fin funcl *I long funclinO {\nlong t = counter++; I * +O * I\nt += counter++; t += counter++; t += counter++; return t·,\nI* +1 *I\nI* +2 *I\nI* +3 *I\n这样的转换既减少了函数 调用的 开销,也 允许 对展开的代码做进一步优 化。例如, 编译器可以 统一 f unc li n 中对全 局变量 c ou nt e r 的更新,产 生这 个函数的一个优化版本:\nI* Optimization of inlined code *I long func1opt () {\nlong t = 4 *counter+ 6;\ncounter+= 4; return t;\n}\n对于这 个特定的函数 f 的定义, 上述代码忠实地 重现了 f u n c l 的行为。\nGCC 的最近版本会尝试进行这种形式的优化,要 么是 被 用命 令 行 选项 \u0026quot; - f i n l i ne \u0026quot; 指示时 ,要 么是 使 用优 化等级- 01 或者更高的等级 时。遗憾的是, G CC 只 尝试在单个文件中定义的函数的内联。这就意味着它将无法应用于常见的情况,即一组库函数在一个 文件中被定义,却被其他文件内的函数所调用。\n在某些情 况下, 最好能阻止编译 器执 行 内联替 换。一种情况是用符 号调试器来评估代码,比如 G DB, 如 3. 10. 2 节描 述的一样。如果一个函数调 用已经用内联替 换优化过 了,那 么任何对这个调用进行追踪或设置断点的尝试都会失败。还有一种情况是用代码剖析的方式来 评估程序 性能,如 5. 14. 1 节讨论的一样 。用内联替 换消除的 函数调用是无法被正确剖析的。\n在各种编译 器中 , 就 优 化 能 力 来 说 , G CC 被认为是胜任的, 但 是 并 不 是 特 别 突 出 。它完成基本的优化,但是它不会对程序进行更加“有进取心的“编译器所做的那种激进变 换。因 此,使 用 G CC 的 程 序员 必 须 花费更多的精力,以 一 种 简 化 编译 器生成高效代码的任务的方式来编写程序。\n5. 2 表示程序性能\n我们引入度量标准每元素的周期数 ( C ycl es Per E le men t , CPE), 作为一种表示程序性能并指导我们改进代码的方法。CP E 这种度量标准帮助我们在更细节的级别上理解迭代程序的循环性能。这样的度量标准对执行重复计算的程序来说是很适当的,例如处理图像中的像素 , 或是计算矩阵乘积中的元素。\n处理器活动的顺序是由时钟控制的, 时 钟提供了某个频率的规律信号, 通常用千兆赫兹 CG H z) , 即十亿周期每秒来表示。例如, 当 表明一个系统有 \u0026quot; 4G H z\u0026quot; 处 理 器,这 表示处理器时 钟运行频率为每秒 4 X l 炉个 周 期 。 每个时钟周期的时间是时钟频率的倒数。通常\n是以纳秒 ( nanoseco nd , 1 纳秒等于 1-0 g 秒 )或 皮 秒 ( p icoseco nd , 1 皮秒等于 1-0 12 秒 )为单\n位的。 例如, 一个 4G H z 的时钟其周期为 o. 25 纳秒, 或 者 250 皮秒。从程序员的角度来看,用时 钟周 期 来 表示度量标准要比用纳秒或皮秒来表示有帮助得多。用时钟周期来表示, 度量值表示的是执行了多少条指令,而 不 是 时 钟 运行得有多快。\n许多过程含有在一组元素上迭代的循环。例如,图 5 -1 中 的 函数 p s um l 和 p s um2 计 算的都是 一个长度为 n 的向量的前置和 ( prefi x s um ) 。对于向量 a = ( a。, a1 \u0026rsquo; … , a . -1 〉, 前置和 p = ( p。, P1 , … , P . - 1 〉定 义为\n( 5. 1)\nI* Compute prefix sum of vector a *I\n2 void psum1(float a[] , float p[] , long n)\n3 {\n4 long i ;\n5 p[O] = a [O] ;\n6 for (i = 1; i \u0026lt; n ; i ++)\n7 p[i] = p[i-1] + a [ i ] ;\n8 }\n9\n10 voi d psum2(float a[], float p[J, long n)\n11 {\n12 long i;\n13 p[O] = a[O];\n14 for (i = 1; i \u0026lt; n- 1 ; i+=2) {\nfloat mid_val = p [ i 一 1] + a [ i ] ;\np [ i ] = mid_val;\n17 p[i+1] = mid_val + a[i+1];\n18 }\nI* For even n, finishr ema i n i ng e l em e n t * I\ni f ( i \u0026lt; n )\n21 p[i] = p [ i 一 1] + a [i] ;\n22 }\n图 5-1 前置和函数。这些函数 提供了 我们 如何表示程序性 能的示例\n函数 p s u ml 每次迭代计 算结果向量的一个元素。第二个函数使用循环展 开( loo p un­ ro lling ) 的技术, 每次迭 代计算两个元素。本章后 面我们会探讨循环展开的好处。(关于分析和优化前 置和计算的内容请参见练习题 5. 11、5. 1 2 和家庭作业 5. 1 9。)\n这样一个过程所需 要的时间可以用一个常数加 上一个与被处理元素个数成正比的因子来描述。例如,图 5- 2 是这两个函数需要的周期数关于 n 的取值范围图。使用最小二乘拟\n2500\n跺亟\n。 20 40 60 80 100 120 140 160 180 200\n元素\n图 5-2 前置和函数的性能。两 条线的斜率表明 每元素的周期数 (CPE )的值\n合(least squares fit) , 我们发现, p s uml 和 ps um2 的 运行时间(用时钟周期为单位)分别近似于等式 368 + 9. On 和 368 + 6. On 。 这 两 个 等 式 表 明 对 代 码 计 时 和初始化过程、准备循环以及完成过程的开销为 368 个周期加上每个元素 6. 0 或 9. 0 周期的线性因子。对千较大的\nn 的值(比如说大千 200 ) , 运行时间就会主要由线性因子来决定。这些项中的系数称为每元素的 周期数(简称 CP E) 的有效值。注意,我 们更愿意用每个元素的周期数而不是每次循环的周期数来度量,这是因为像循环展开这样的技术使得我们能够用较少的循环完成计算,而\n我们最终关心的是,对 于给定的向量长度,程 序运行的速度如何。我们将精力集中在减小计算的 CPE 上。根据这种度量标准, ps urn2 的 CPE 为 6. o, 优于 CPE 为 9. 0 的 p s uml 。\n日 日 什么是最小二乘拟合\n对于一 个数据点Cx 1 , Yi), …, (立 , y . ) 的集合 , 我们常常试图 画一条线, 它能最接近于这 些数 据代 表的 X- Y 趋势。使用最小二 乘拟合, 寻找一条形如 y = mx + b 的线, 使得下面这个误差度量最小:\nE(m,b) = (mx1 +b - y;) 2\ni - 1. n\n将 E ( m , b) 分别对 m 和b 求导,把 两个 导数函数设置为 o, 进 行 推导就能得 出计 算 m 和\nb 的算 法。\n练习题 5. 2 在本章后面,我们会从一个函数开始,生成许多不同的变种,这些变种 保持函 数的 行为 , 又具 有 不 同 的 性 能 特性。 对 于其中 三 个 变 种, 我 们 发现运行时 间\n(以时钟周期为单位)可以用下面的函数近似地估计: 版本 1 : 60+35n\n版本 2 : 136+4n\n版本 3 : 157+1. 25n\n每个版 本在 n 取什 么值 时是 三个版 本中最快的? 记住, n 总是 整数。\n3 程序示例 为了说明一个抽象的程序是如何被系统\n地转换成更有效的代码的,我们将使用一个 基于图 5-3 所示向量数据结构的运行示例。向\nd ::三 声酮\n囊由两个内存块表示:头部和数据数组。头部是一个声明如下的结构:\n图 5-3\n向扯的抽象数据类型。向量由头信息\n加上指定长度的数组来表示\ncodeloptlvec.h\nI* Create abstract data type for vector *I typedef struct {\nlong len; data_t *data;\n} vec_rec, *vec_ptr;\ncode/opt/vec.h\n这个声明用 d a t a _t 来表示基本元素的数据类型。在测试中, 我们度量代码对于整数( C 语言的 i n t 和 l o ng ) 和浮点数( C 语言的 fl oa t 和 doub l e ) 数据的性能。为此, 我们会分别为不同的类型声明编译和运行程序,就 像 下 面这个例子对数据类型 l o ng 一样:\ntypedef long data_t;\n我们还会分配一个 l e n 个 d a t a _ t 类型对象的数组,来 存 放 实 际 的 向 量 元 素。\n图 5-4 给出的是一些生成向扯、访问向量元素以及确定向扯长度的基本过程。一个值得 注意的重要特性是向量访问程序 g e t _ ve c _ e l e me n 七, 它 会对每个向量引用进行边界检查。这段代码类似于许多其他语言(包括J a va ) 所使用的数组表示法。边界 检查降低了程序出错的机会,但是它也会减缓程序的执行。\ncode/optlvec.c\nI* Create vector of specified length *I\n2 vec_ptr new_vec (long len)\n3 {\n4 I* Allocate header structure *I\n5 vec_ptr result= (vec_ptr) malloc(sizeof(vec_rec));\n6 data_t *data = NULL;\n7 if (!result)\n8 return NULL; I* Couldn\u0026rsquo;t allocate storage *I\n9 result-\u0026gt;len = len;\nI* Allocate array *I\nif (len \u0026gt; 0) {\ndata = (data_t *)calloc(len, sizeof(data_t));\nif (!data) {\n14 free((void *) result);\n15 return NULL; I* Couldn\u0026rsquo;t allocate storage *I\n16 }\n17 }\n18 I* Data will either be NULL or allocated array *I\n19 result-\u0026gt;data = data;\n20 return result;\n21 }\n22\n23 f*\n* Retrieve vector element and store at dest.\n* Return O (out of bounds) or 1 (successful)\n*I\n27 int get_vec_element(vec_ptr v, long index, data_t *dest)\n28 {\nif (index \u0026lt; 0 11 index \u0026gt;= v-\u0026gt;len)\nreturn O;\n*dest = v-\u0026gt;data[index];\nreturn 1;\n33 }\n34\nI* Return length of vector *I\nlong vec_length(vec_ptr v)\n37 {\n38 return v-\u0026gt;len;\n39 }\ncode/otp/vec.c\n图 -5 4 向量抽象数据类型的实现。在实际 程序中 , 数据类型 da t a_t 被声明为\n耳 北、 l ong 、 fl oat 或 doub l e\n作为一个优化示例, 考虑图 5-5 中所示的代码, 它使用某种运算, 将一个向量中所有的元素合并 成一个值。通过 使用编译时常数 IDEN T 和 OP 的不同定义, 这段代码 可以重编译成对数据执行不同的运算。特别地,使用声明:\n#define !DENT 0\n#define OP +\n它对向量的元素求和。使用声明:\n#define !DENT 1\n#define OP *\n它计算的 是向量元素的乘积。\nI* Implementation with maximum use of data abstraction *I void combinel(vec_ptr v, data_t *dest)\n{\nlong i;\n*dest = !DENT;\nfor (i = O; i \u0026lt; vec_length(v); i++) { data_t val;\nget_vec_element(v, i, \u0026amp;val);\n*dest = *dest OP val;\n}\n图 5-5 合并运算 的初始实 现。使用基本元素 IDENT 和合 并运算 OP 的不同声明, 我们可以测量该函数对不同运算的性能\n在我们的讲述中 , 我们会对这段代码进行一系列的 变化, 写出这个合并 函数的不同版本。为了评估性能变化, 我们会在一个具 有 Intel Core i7 H as well 处理器的机器上测量这些函数的\nCPE性能, 这个机器称为参考机。3.1 节中给出了一些有关这个处理器的特性。这些 测量值刻画的是程序在某个特定的机器上的性能,所以在其他机器和编译器组合中不保证有同等的性能。 不过,我们 把这些结果与许多不同编译器/处理器组合上的结果做了比较, 发现也非常相似。\n我们会进行一组变换,发现有很多只能带来很小的性能提高,而其他的能带来更巨大的效果。确定该使用哪 些变换组合确实是编写快速代码的 "糜术( black a r t ) \u0026quot; 。有些不能提供可测量的好处的组合确实是无效的,然而有些组合是很重要的,它们使编译器能够进 一步优化。根据我们的经验,最好的方法是实验加上分析:反复地尝试不同的方法,进行 测最,并检查汇编代码表示以确定底层的性能瓶颈。\n作为一个起点 ,下 表给出的是 c omb i n e l 的 CPE 度量值,它 运行在我们的参考机上, 尝试了操作(加法或乘法)和数据类型(长整数和双精度浮点数)的不同组合。使用多个不同 的程序,我 们的实验显示 32 位整数操作和 64 位整数操 作有相同的性能,除 了 涉及除法操作的代码 之外。同样, 对于操作单精度和双精度浮点数据的程序 , 其性能也是相同的。因此在表中 , 我们将 只给出整数数 据和浮点数据各自的结 果。\n函数 方法\n整数 浮点数\ncombinel combinel\n抽象的未优化的抽象的- 01\n*\n20.02\n10. 12\n+\n19. 98\n10. 17\n*\n20. 18\n11. 14\n可以看到测量值 有 些 不 太 精 确。 对 于 整 数 求 和 的 CPE 数 更 像 是 23. 00 , 而不是\n22. 68; 对千整数乘积的 CPE 数则是 20. 0 而非 20. 0 2。我们不会 "捏造“ 数据让它们看起来好看一点儿,只 是 给出了实际获得的测量值。有很多因素会使得可靠地测量某段代码序列 需 要 的 精 确 周 期 数 这个任务变得复杂。检查这些数字时,在 头 脑 里 把 结 果 向 上 或者向下取整几百分之一个时钟周期会很有帮助。\n未经优化的代码是从 C 语言代码到机器代码的直接翻译 , 通常效率明显较低。简单地使 用 命 令 行 选 项 \u0026quot; - 0 1\u0026quot; , 就会进行一些基本的优化。正如可以看到的,程序员不需要做什么 ,就 会 显 著 地提高程序性能—— 超过两个数量级。通常,养 成 至少使用这个级别优化的习 惯 是 很 好 的 。(使 用 - Og 优化级 别 能得到相似的性能结果。)在剩下的测试中, 我们使用\n- 0 1 和 - 0 2 级 别 的 优 化来 生成 和测量程序。\n4 消除循环的低效率 # 可以观察到, 过程 c omb i n e l 调用 函 数 v e c —l e n g t h 作 为 f or 循环的测试条件,如图 5 - 5 所 示。回想关于如何将含有循环的代码翻译成机器级程序的讨论(见3. 6. 7 节),每次 循 环 迭 代时都必须对测试条件求值。另一方面,向 量的长度并不会随着循环的进行而改变 。因 此 , 只需 计 算 一 次 向量 的 长 度,然 后 在 我们的测试条件中都使用这个值。\n图 5-6 是一个修改了的版本, 称为 c ombi n e 2 , 它在开始时调用 ve c _ l e ng t h , 并将结果 赋 值 给局部变量 l e n g t h。对千某些数据类型和操作, 这个变换明显地影响了某些数据类 型 和操作的整体 性能 ,对 千其他的则只有很小甚至没有影响。无论是哪种情况,都 需要这种变换来消除这个低效率,这有可能成为尝试进一步优化时的瓶颈。\nI* Move call to vec_length out of loop *I\nvoid combine2(vec_ptr v, data_t *dest)\n3 {\nlong i;\nlong length= vec_length(v);\n6\n7 *dest = IDENT;\n8 for (i = O; i \u0026lt; length; i++) {\ndata_t val;\nget_vec_element(v, i ,. \u0026amp;val);\n*dest = *dest OP val; 12 }\n13 }\n图 5-6 改进循环测试的效率。通过把对 ve c _l e ngt h 的 调用移 出循环测试, 我们不 再需要每次迭代时都执行这个函数\n函数 方法 整数 浮点数 + * 十 * combinel combine2 抽象的 -01 移 动 vec_l engt h 10. 12 10. 12 7. 02 9. 03 10. 17 11. 14 9. 02 11. 03 这个 优 化 是 一 类常见的优化的一个例子,称 为代码移动( co d e m o t io n ) 。这类优化包括识 别 要 执 行 多 次(例 如在循环里)但是计算结果不会改变的计算。因而可以将计算移动到代码 前 面不会被多次求值的部分。在本例中, 我们将对 v e c _ l e n g t h 的调用从循环内部移动\n到循环的前面。\n优化编译器会试着进行代码移动。不幸的是,就像前面讨论过的那样,对于会改变在 哪里调用函数或调用多少次的变换,编译器通常会非常小心。它们不能可靠地发现一个函 数是否会 有副作用, 因而假设函数会有副作用。例如, 如果 v e c _ l e n g t h 有某种副作用, 那么 c o mbi n e l 和 c o mbi n e 2 可能就会有不同的行为。为了改进代码, 程序员必须经常帮助编译器显式地完成代码的移动。\n举一个 c ombi n e l 中看到的循环低效率的极端例子, 考虑图 5-7 中所示的过程 l o w­ e r l 。这个过程模仿几个学生的函数设计, 他们的函数是作为一个网络编程项目的一部分交上来 的。这个过程的 目的是将一个字符串中所有大写字母转换成小写字母。这个大小写转换涉 及将 \u0026quot; A\u0026quot; 到 \u0026quot; z\u0026quot; 范围内的字符转换成 \u0026quot; a\u0026quot; 到 \u0026quot; z\u0026quot; 范围内的字符。\nI* Convert string to lowercase: slow *I void lower1(char *s)\n{\nlong i;\nfor (i\n7 if\n8\n9\n10\n= O; i \u0026lt;\n(s [i] \u0026gt;=\ns (i] -=\nstrlen(s); i++)\nI A\u0026rsquo;\u0026amp;\u0026amp; s [i] \u0026lt;= I z I)\n(\u0026lsquo;A\u0026rsquo;-\u0026lsquo;a\u0026rsquo;);\nI*Convert string to lowercase: faster *I\nvoid lower2(char *s)\n13 {\n14 long i;\n15 long len = strlen(s);\n16\nfor (i\nif\n19\n20\n21\n= O; i \u0026lt; len; i++) (s[i] \u0026gt;=\u0026lsquo;A\u0026rsquo;\u0026amp;\u0026amp; s[i] \u0026lt;= s [i] -= (\u0026lsquo;A\u0026rsquo;-\u0026lsquo;a\u0026rsquo;) ;\nI Z\u0026rsquo;)\nI* Sample implementation of library function strlen *I\nI* Compute length of string *I\nsize_t strlen(const char *s)\n25 {\n26 long length= O;\n27 while (*s !=\u0026rsquo;\\0\u0026rsquo;) {\n28 s++;\n29 length++;\n30 }\n31 return length;\n32 }\n图5-7 小写字母转换函数 。两个过程的性能差别很大\n对库函数 s tr l e n 的调用是 l o wer l 的循环测试的一部分。虽 然 s tr l e n 通常是用特殊的 x86 字符串处 理指令来实现的, 但是它的整体执行也类似于图 5- 7 中给出的这个简单版本。因为 C 语言中的字符串是以 n u l l 结尾的字符序列, s tr l e n 必 须一步一步地检查这\n个序列,直 到遇到 n u l l 字符。对于一个长度为 n 的字符串, s tr l e n 所用的时间与 n 成正比。因为对 l o wer l 的 n 次迭代的每一次 都会调用 s tr l e n , 所以 l ower l 的整 体运行时间是字符串长度的二 次项, 正比千 n勹\n如图 5-8 所示(使用s tr l e n 的库版本), 这个函数对各种长度的字符串的实际测量值证实了上述分析。l o wer l 的运行时间曲线图随着字符串 长度的增 加上升得很陡峭(图5-8a)。图 5- 8 b 展示了 7 个不同长度字符串的运行时间(与曲线图中所示的有所不同), 每个长度都是 2 的幕。可以观察到, 对于 l o we r l 来说, 字符串长度每增 加一倍,运 行时间都会变为原来的 4 倍。这很明显地表明运行时间是二次的。对于一个长度为 1 04 8 5 76 的字符串来说, l o wer l 需 要超过 1 7 分钟的 CPU 时间。\n250\n200\n150\n记 # u 100\n50\n100 000 200 000 300 000\n字符串长度\na )\n字符串长度\n400 000 500 000\nlower2 0.0000 0.0001 0.0001 0.0003 0.0005 0.0010 0.0020\nb)\n图 5-8 小写字母转换函数的性能 比较。由 千循环结构 的效率比较低, 初始代码 l owe r l\n的运行时间是二次项的 。修改过的代 码 l ower 2 的运行 时间是线性的\n除了把对 s 七r l e n 的调用移出了循环以外,图 5 - 7 中所示的 l ower 2 与 l o wer l 是一样的。做 了这样的变化之后, 性能有了显著改善。对千一个长度为1 048 576 的字符串, 这个函数只需 要 2. 0 毫秒—— 比 l o wer l 快了 500 000 多倍。字符串长度每增加一倍, 运行时间也会增加一倍一 很显然运行时间是线性的。对于更长的字符串 ,运 行时间的改进会更大。\n在理想 的世界里, 编译器会认出循 环测试中对 s tr l e n 的每次调用都会返回相同的结果, 因此应该能够把这个调用移出循环。这需要非 常成熟完善的分析, 因为 s tr l e n 会检查字符串的元素, 而随着 l o wer l 的 进行, 这些值会改变。编译器需要探查, 即使字符串中的字符发生了改变, 但是没有字符会从非 零变为零 , 或是反过来 ,从 零变为非零 。即使是使用内联函数,这样的分析也远远超出了最成熟完善的编译器的能力,所以程序员必须 自已进行这样的变换。\n这个示例说明了编程时一个常见的问题,一个看上去无足轻重的代码片断有隐藏的浙 近低效 率( as ym p to tic ine fficie ncy ) 。人们可不希望一个小写字母转换函数成为程序性能的限制因素。通常 ,会 在小数据集上测试和分析程序 , 对此, l o wer l 的 性能是足够的。不过,当程序 最终部署好以 后, 过程完全可能 被应用到一个有 1 00 万个字符的串上。突然,\n这段无危险的代码变成了一个主要的性能瓶颈。相 比较而言, l o wer 2 的性能对于任意长度的字符串来说都是足够的。大型编程项目中出现这样问题的故事比比皆是。一个有经验 的程序员工作的一部分就是避免引入这样的 渐近低效 率。\n练习题 5. 3 考虑下面的函数:\nlong min(long x, long y) { return x \u0026lt; y? x : y; } long max(long x, long y) { return x \u0026lt; y? y : x; } void incr(long *xp, long v) { *XP += v; }\nlong square(long x) { return x*x; }\n下面 三个代码片 断调 用这 些 函数 :\nA. for (i = min(x, y); i \u0026lt; max(x, y); incr(\u0026amp;i, 1)) t += square(i);\n:, B. for (i = max(x, y) - 1; i \u0026gt;= min(x, y); incr(\u0026amp;i, -1))\nt += square(i);\nC. long low= min(x, y); long high= max(x, y);\nfor_ (i = low; i \u0026lt; high; incr(\u0026amp;i, 1)) t += square(i);\n假设 x 等于 1 0 , 而 y 等于 1 0 0。填写下表 ,指 出在代 码片断 A C 中 4 个函数每 个被调用的次数:\n5. 5··减·-少过.一 ·程— 调. . 用.\n像我们看到过的那样,过程调用会带来开销,而且妨碍大多数形式的程序优化。从 cornbi n e 2 的代码(见图 5-6 ) 中我们可以 看出, 每次循环迭代都会调用 g e t _ v e c _ e l e me n t 来获取下 一个向量元素。对 每个向 量引用, 这个函数要把向批索引 i 与循环边界 做比较, 很明显 会造成低效 率。在处理任意的数组访问时,边 界检查可能 是个很有用的特性, 但是对 c omb i n e 2 代码的简单分析表明所有的引用都是合法的。\n作为替代, 假设为我们的 抽象数据类型增加一个函数 g e t —v e c _ s 七ar t 。 这个函数返回\n数组的起始地址, 如图 5-9 所示。然后就能写出此图中 c o mbi ne 3 所示的过程,其 内 循环里没有函 数调用。它没有用函数调用来获取每个向 量元素, 而是直接访问 数组。一个纯粹主义者可能 会说这种变换严重 损害了程序的模块 性。原则上来说, 向扯抽象数据类观的使用者甚至不应该需 要知道向批的内容是作为数组来 存储的, 而不是作为诸如链表之类的某种其他 数据结构来存储的。比较实际的程序员 会争论说这种变换是 获得高性能结果的必要步骤。\n函数 方法 整数 浮点数 + * + * cornbine2 combine3 移 动 vec_l engt h 直接数据访问 7. 02 9. 03 7. 17 9. 02 9.02 11. 03 9. 02 11. 03 data_t *get_vec_start(vec_ptr v)\n{\nreturn v-\u0026gt;data;\n}\ncode/opt/vec.c\ncode/otplvec.c\nI* Direct access to vector data *f void combine3(vec_ptr v, data_t *dest)\n{\nlong i;\nlong length= vec_length(v); data_t *data = get_vec_start(v);\n*dest = IDENT;\nfor (i = O; i \u0026lt; length; i++) {\n*dest = *dest OP data[i];\n}\n}\n图5-9 消除循环中的 函数调用。结果代码没有显示性能 提升,但是 它有其他的优化\n令人吃惊的是, 性能没有明显的提升。事实上 , 整数求和的性能还略有下降。显然,内循环中的其他操作形成了瓶颈, 限制性能超过调用ge t _ve c _e l e me n t 。 我们还会再回到这 个函数(见5. 11. 2 节), 看看为什么 c ombi ne 2 中反复的边界检查不会让性能更差 。而现在,我们 可以将这个转换视为一系列步骤中的一步, 这些步骤将最终产生显著的 性能提升。\n5. 6 消除不必要的内存引用\nc o mb i ne 3 的代码将合并运算 计算的值累积在指针 d e 江 指定的位置。通过检查编译出来的为内循环产生的汇编代码 , 可以看出这个属性。在此我们给出数 据类型为 d o ubl e , 合并运算为乘法的 x8 6-64 代码:\nInner loop of combi ne3 . data_t = doubl e , OP=*\ndest in r7. bx, data+i i n r7. dx, data+length in\n. L17: l oop:\nr7. ax\nvmovsd (%rbx) , %xmm0 Read product from dest vmulsd (%rdx), %xmm0, %xmm0 Multiply product by data[i] vmovsd addq %xmm0, (%rbx) $8, %rdx Store product at dest Increment data+i cmpq %rax, %rdx Compare to data+length jne .L17 If !=, goto loop 在这段循环代码中, 我们看到, 指针 d e s t 的地址存放在寄存器%r b x 中,它 还改变了代码, 将第 i 个数据元素的指针保存在寄存器%r d x 中, 注释中显示为 d a t a + i 。每次迭代, 这个指针都加 8。循环终止操作通过比较 这个指 针与保存在寄存器%r a x 中的数值来判断。我们可以看到每次迭代时,累积变量的数值都要从内存读出再写入到内存。这样的读 写很浪费 , 因为每次迭代开始时从 d e s t 读出的值就是上次迭代最后写入的值。\n我们能够消除这种不必要的内存读写,按 照图 5-1 0 中 c ombi n e 4 所示的方式重写代码。引入一个临时 变量 a c e , 它在循环中用来累积计算出来的值。只有在循环完成之后结果才存放在 d e s t 中。正如下面的汇编代码所示, 编译器现在可以用寄存器%x mm0 来保存\n累积值。 与 c o mb i n e 3 中的循环相比, 我们将 每次迭代的内存操作从两次读和一次写减少到只需要一次读。\nInner loop of combi ne4 . data_t = double, OP = * ace in 7.xmmO, data+i i n 肛 dx, data+length in 7.rax\n. L25: loop:\nvmulsd (%rdx), %xmm0, %xmm0 Multiply ace by data[i] addq $8, %rdx Increment data+i cmpq jne\n%rax, %rdx\n.L25\nCompare to data+length\nIf !=, goto loop\nI* Accumulate result in local variable *I\nvoid combine4(vec_ptr v, data_t *dest)\n3 {\nlong i;\nlong length= vec_length(v);\ndata_t *data= get_vec_start(v);\ndata_t acc = IDENT;\n8\n9 for (i = O; i \u0026lt; length; i++) { 1o acc = acc OP data [i] ;\n11 }\n12 *dest = acc;\n13 }\n图 5-10 把结果累积在临时变量中。将 累积值存放在局部变最 a c e ( 累积器 ( accumulator ) 的简写)中, 消除 了每次循环迭代中从内 存中读出并 将更新值写 回的需要\n我们看到程序性能有了显著的提高,如下表所示:\n函数 方法 整数 浮点数 + * + * cornbine3 直接数据访问 7. 17 9.02 9. 02 11. 03 combine4 累积在临时变批中 1. 27 3. 01 3. 01 5.01 所有的时间改进范围从 2. 2 X 到 5. ? X , 整数加法情况的时间 下降到了每元素只需 1. 27 个\n时钟周期。\n可能又有人会认为编译 器应该能够自动将图 5- 9 中所示的 c o mbi ne 3 的代码转换为在寄存器中累积那个值, 就像图 5-10 中所示的 c o mbi ne 4 的代码所做的那 样。然而实际上, 由于内存别名使用 , 两个函数可能会有不同的行 为。例如,考虑整数数据,运算为乘法,标识元 素为 1 的 情况。设 v= [ 2, 3, 5] 是一个由3 个元素组成的向量, 考虑下面两个函数调用 :\ncombine3(v, get_vec_start(v) + 2); combine4(v, get_vec_start(v) + 2);\n也就是在向量最后一个元素和存放结果的目标之间创建一个别名。那么,这两个函数的执\n行如下: # 函数 初始值 循环之前 i = 0 i = 1 i = 2 最后 combine3 [2, 3, 5] [2, 3, l] [2, 3, 2) (2, 3, 6) (2, 3, 36) (2, 3, 36] combine4 [2, 3, 5] [2, 3, SJ [2, 3, 5) (2, 3, 5) (2, 3, 5) (2, 3, 30] 567\n正如前面讲到过的, c o mb i n e 3 将它的结果累积在目标位置中,在 本例中, 目 标位置就 是 向 量的最后一个元素。因此, 这个值首先被设置为 1\u0026rsquo; 然 后 设 为 2 • 1 = 2 , 然后设为\n• 2 = 6 。最后一次迭代中,这 个 值会乘以它自己 ,得 到最后结果 3 6 。对千 c o mb i n e 4 的情 况来说 ,直 到 最 后 向扯都保持不变,结 束之前, 最后一个元素会被设置为计算出来的值1 • 2 • 3 • 5 = 30 。\n当然, 我们说明 c o mb i n e 3 和 c o mb i n e 4 之间差别的例子是人为设计的。有人会说c o mb i n e 4 的 行为更加符合函数描述的意图。不幸的是, 编 译 器不能判断函数会在什么情况 下 被调用,以 及 程序员的本意可能是什么。取而代之, 在编译 c o mb i n e 3 时,保 守 的方法 是 不 断 地读和写内存, 即 使 这样做效率不太高。\n练习题 5 . 4 当 用 带 命令行 选 项 \u0026quot; - 0 2 \u0026quot; 的 GCC 来 编 译 c o 邧江 n e 3 时 , 得 到 的 代 码\nCPE 性 能 远好于使用 - 0 1 时的 :\n函数 方法 整数 浮点数 + 关 + * combine3 用- 01 编译 7. 17 9. 02 9. 02 11. 03 cornbi ne 3 用 - 0 2 编译 I. 60 3. 01 3. 01 5. 01 combine4 累积在临时变扯中 J. 27 3. O l 3 . 0 1 5. 01. 由此得 到 的性能 与 c o mb i n e 4 相 当 , 不 过对于整数 求和的情况除外, 虽 然 性能已经得到 了 显著 的提高 , 但还是低于 c o mb i n e 4。 在 检查编译器产 生的 汇编代码 时,我们发现对内循 环的 一个有趣的 变 化:\nInner loop of combi ne3 . da t a _t = doub l e , OP = *·Compiled -02 dest in¾rbx, data+i in¾rdx, data+length in¾rax\nAce 皿 ml a t ed product in¾xmmO\n.L22: l oop : vmulsd addq (%rdx), %xmm0, $8, %r dx %xmm0 Multipl y product by data[i] Increment dat a+i cmpq %rax, %rdx Compare to da t a +l engt h vmovsd %xmm0, ( %r b x ) Store product at dest jne .L22 If!=, goto loop 把上 面的 代码 与用 优 化等级 1 产 生的 代码进行比较:\nInner loop of combi ne3 . data_t = double, OP = * . Compiled -01 des t in i.r bx, data+i i n 肛 dx, data+length in i.rax\n我们 看 到 , 除 了 指 令 顺 序 有 些 不 同 , 唯 一 的 区 别 就 是 使 用 更 优 化 的 版 本 不 含有\nvm o v s d 指令 , 它 实现的是从 d e s t 指定的位置读 数据(第 2 行)。\n寄存器%x mm0 的角 色在 两个循环中有什 么不 同?\n这个更优化的版本忠 实地实现 了 c o mb i n e 3 的 C 语言代码吗(包 括在 d e s t 和向量数据之间使用内存别名的时候)? 解释为什么这个优化保持了期望的行为,或者给出一个例子说明它产生了与使用较少优化的代码不同的结果。\n使用了这最后的变换,至 此, 对于每个元素的计算, 都只需要 l. 25 ~ 5 个时钟周期。\n比起最开 始采用优化时的 9 ~ 11 个周期, 这是相当大的提高了。现在我们想看看是什么因素在制约着代码的性能,以及可以如何进一步提高。\n5. 7 理解现代处理器\n到目前为止,我们运用的优化都不依赖于目标机器的任何特性。这些优化只是简单 地降低了过程调用的开销,以及消除了一些重大的"妨碍优化的因素",这些因素会给 优化编译器造成困难。随着试图进一步提高性能,必须考虑利用处理器微体系结构的优 化,也就是处理器用来执行指令的底层系统设计。要想充分提高性能,需要仔细分析程 序,同时代码的生成也要针对目标处理器进行调整。尽管如此,我们还是能够运用一些 基本的优化,在很大一类处理器上产生整体的性能提高。我们在这里公布的详细性能结 果,对其他机器不一定有同样的效果,但是操作和优化的通用原则对各种各样的机器都 适用。\n为了理解改进性能的方法,我们需要理解现代处理器的微体系结构。由于大扯的晶 体管可 以被集成 到一块芯片上,现 代微处理器采用 了复杂的硬件, 试图使程序性能最大化。带来的一个后果就是处理器的实际操作与通过观察机器级程序所察觉到的大相径 庭。在代码级上,看上去似乎是一次执行一条指令,每条指令都包括从寄存器或内存取 值,执行一个操作,并把结果存回到一个寄存器或内存位置。在实际的处理器中,是同时 对多条 指令求值的, 这个现象称为指令级并行。在某些设 计中,可 以有 100 或更多条指令在处理中。采用一些精细的机制来确保这种并行执行的行为,正好能获得机器级程序要求 的顺序语义模型的效果。现代微处理器取得的了不起的功绩之一是:它们采用复杂而奇异 的微处理器结构,其中,多条指令可以并行地执行,同时又呈现出一种简单的顺序执行指 令的表象。\n虽然现代微处理器的详细设计超出了本书讲授的范围,对这些微处理器运行的原则有一般性的了解就足够能够理解它们如何实现指令级并行。我们会发现两种下界描述了程序 的最大性能。当一系列操作 必 须按照严格顺序执行时, 就会 遇 到 延迟界限 ( latency\nbound), 因为在下一条指令开始之前,这条指令必须结束。当代码中的数据相关限制了处理器利用 指令级并行的能力时, 延迟界限能够限制程 序性能。吞吐量界限( t hro ug hput bound) 刻画了处理器功能单元的原始计算能 力。这个界限 是程序性能的终极限制。\n7. 1 整体操作\n图 5-11 是现代微处 理器的一个非常简单化的示意图。我们假想的处理器设计是不太严格地 基千近期的 In tel 处理器的结构。这些处理器在工业界称为超标 量 ( s uperscala r ) , 意思是它可以 在每个时钟周期执行多个操作, 而且是乱序的( o ut- of - or de r ) , 意思就是指令执行的顺序不一定要与它们在机器级 程序中的顺序一致。整个设计有两个主要部 分: 指令 控制 单元 (I ns t ru ct io n Control Unit, ICU) 和执行单元 ( E xecu t io n Unit, EU)。前者负责 从内存中读出指令序列,并根据这些指令序列生成一组针对程序数据的基本操作;而后者执行这 些操作。和第 4 章中研究过的按序( in- order ) 流水线相比,乱 序处理器需要更大、更复杂的硬件,但是它们能更好地达到更高的指令级并行度。\n\u0026quot;\n指令控制单元\n:\u0026mdash;\u0026mdash;\u0026mdash;\u0026mdash;\u0026mdash;\u0026mdash;\u0026mdash;\u0026mdash;\u0026mdash;•\n1\u0026mdash;- # 指令高速缓存\n寄存器更新\n,,\n!,预测OK?\n操作结果 地址I I地址\n数据I I I数据\n数据高速缓存\n执行单元\n图 5-11 一个乱序处 理器的框图。指令控制单元负责从内存中读出指令,并 产 生 一 系 列 基 本操作。然后执行单元完成这些操作,以及指出分支预测是否正确\nICU 从指令高速 缓存( ins tru ction cache) 中读取指令, 指令高速缓存是一个特殊的高速存储器,它 包含最近访问 的指令。通常, ICU 会在当前正在执行的指令很早之前取指, 这样它才有足够的时间对指令译码, 并把操作发送到 EU。不过, 一个问题是当程序遇到分支气讨, 程序有两个 可能的前进方向。一种可能会选择分支, 控制被传递到分支目标。另一种可能是,不选择分支,控制被传递到指令序列的下一条指令。现代处理器采用了一 种称为分支预测( bra nch prediction ) 的技术, 处理器会猜测是否会选择分支,同 时还预测分支的目标地址。使用投机执行( speculative execution ) 的技术, 处理器会开始取出位于它预测的分支会跳到的地方的指令,并对指令译码,甚至在它确定分支预测是否正确之前就 开始执行这些操作。如果过后确定分支预测错误,会将状态重新设置到分支点的状态,并 开始取出和执行另一个方向上的指令。标记为取指控制的块包括分支预测,以完成确定取 哪些指令的任务。\n指令译码逻辑 接收实际的程序指令 ,并 将它们转换成一组基本操作(有时称为微操作)。每个这样的操作都完成某个简单的计算任务,例如两个数相加,从内存中读数据,或是向内 存写数据。对千具有复杂指令的 机器,比如 x86 处理器, 一条指令可以被译码成多个操作。关于指令如何被译码成操作序列的细节,不同的机器都会不同,这个信息可谓是高度机密。 幸运的是, 不需要知道某台机器实现的底层细节,我 们也能优化自己的程序。\ne 术语“分支” 专指条件转移指令 。对处理器来说,其他可能 将控 制传送到多 个目的地址的 指令, 例如过程返回和间接跳转,带来的也是类似的挑战。\n在一个典 型的 x86 实现中, 一条只对寄存器操作的指令, 例如\naddq %rax,%rdx\n会被转化成一个操作。另一方面,一条包括一个或者多个内存引用的指令,例如\naddq %rax,8(%rdx)\n会产生多 个操作, 把内存引用和算术运算分开。这条指 令会被译码成为三个操作: 一个操作从内存 中加载一 个值到处理器中, 一个操作将加载进来的值加上寄存器%r a x 中的值,而一个操作将结果存回到内存。这种译码逻辑对指令进行分解,允许任务在一组专门的硬 件单元之间进行分割。这些单元可以并行地执行多条指令的不同部分。\nEU 接收来自取指单元的 操作。通常, 每个时钟周期会接收多个操作。这些操作会被分派到一组功能单元中,它们会执行实际的操作。这些功能单元专门用来处理不同类型的 操作。\n读写内存是由 加载和存储单元实现的。加载单元 处理从内存读数据到处理器的操作。这个单元有一个加法器来完成地址计算。类似,存储单元处理从处理器写数据到内存的操 作。它也有 一个加法器来完成地址 计算。如图中所示,加载 和存储单元通过数据高速 缓存(data cache )来访问内存。数据高速缓存是一个高速存储 器, 存放着最近访问的数据值。\n使用投机执行技术对操作求值,但是最终结果不会存放在程序寄存器或数据内存中, 直到处理器能 确定应该实际执行这些指令。分支操作被送到 E U , 不是确定分支该往哪里去,而是确定分 支预测是否正确。如果预测错误 , E U 会丢弃分支点之后计算出来的结果。它还会发 信号给分支单元, 说预测是错误的, 并指出正 确的分支目的。在这种情况中,分支单元开始 在新的位置取指。如在 3. 6. 6 节中看到的, 这样的预测错 误会导致很大的性能开销。在可以取出新指令、译码和发送到执行单 元之前 , 要花费一点时间。\n图 5-11 说明不同的功能单元被设计来执行不同的操作。那些标记为执行“算术运算” 的单元通常是专门用来执行整数和浮点数操作的不同组合。随着时间的推移,在单个微处 理器芯片上能够集成的晶体管数量越来越多,后续的微处理器型号都增加了功能单元的数 量以及每个单元能执行的操作组合,还提升了每个单元的性能。由于不同程序间所要求的 操作变化很大,因此,算术运算单元被特意设计成能够执行各种不同的操作。比如,有些 程序也许会涉及整数操作,而其他则要求许多浮点操作。如果一个功能单元专门执行整数 操作,而另一个只能执行浮点操作,那么,这些程序就没有一个能够完全得到多个功能单 元带来的好处了。\n举个例子 , 我们的 In t el Core i7 H as well 参考机有 8 个功能单元, 编号为 0 7。下面\n部分列出了每个单元的功能:\n0: 整数运算、浮点乘、整数和浮点数除法、分支\n1: 整数运算、浮点加、整数乘、浮点乘\n2: 加载、地址计算\n3: 加载、地址计算\n4: 存储\n5: 整数运算\n6: 整数运算、分支\n7: 存储、地址计算\n在上面的列表中,“整数运算”是指基本的操作,比如加法、位级操作和移位。乘法\n坎\n和除法需要更多的专用资源。我们看到存储操作要两个功能单元 一个计算存储地址, 一个实际保存数据。5. 1 2 节将讨论存储(和加载)操作的机制。\n我们可以看出功能单元的这 种组合具有同时 执行多个同类型操作的潜力。它有 4 个功能单元可以执行整数操作, 2 个单元能执行加载操作, 2 个单元能执行浮点乘法。稍后我们将看到这些资源对程序获得最大性能所带来的影响。\n在 IC U 中, 退役单元 ( retirem e n t u nit ) 记录正在进行的处理,并 确保它遵守机器级程序的顺序语义。我们的图 中展示了一个寄存器文件 ,它 包含整数 、浮点数和最近的 SSE 和A V X 寄存器, 是退役单 元的一部分, 因为退役单 元控制这些 寄存器的更新。指令译码时, 关千指令的信息被放置在一个先进先出的队列中。这个信息会一直保持在队列中,直到发 生以下两个结果中的一个。首先,一旦一条指令的操作完成了,而且所有引起这条指令的 分支点也都被确认为预测正确, 那么这条指令就可以 退役 ( ret ired ) 了, 所有对程序寄存器的更新都可以被实际执行了。另一方面,如果引起该指令的某个分支点预测错误,这条指 令会被清空 ( fl us hed ) , 丢弃所有计算出来的结果。通过这种方法,预测错误就不会改变程序的状态了。\n正如我们已经描述的那样,任何对程序寄存器的更新都只会在指令退役时才会发生, 只有在处理器能够确信导致这条指令的所有分支都预测正确了,才会这样做。为了加速一 条指令到另一条指令的结果的传送,许多此类信息是在执行单元之间交换的,即图中的\n“操作结果”。 如图中的箭头所示, 执行单元可以直接将结果发送给彼此。这是 4. 5. 5 节中简单处理器设计中采用的数据转发技术的更复杂精细版本。\n控制操作数在执行单元间传送的最常见的机制称为寄存 器重 命名( register renaming) 。当一条更新寄存器r 的指令译码时, 产生标记 t , 得到一个指向该操作结果的唯一的标识符。条目(r , t ) 被加入到一张表中,该表维护着每 个程序寄存器 r 与会更新该寄存器的操作的标记 t 之间的关联。当随后以寄存器 r 作为操作数的指令译码时, 发送到执行单元的操作会包含 t 作为操作数源的值。当某个执行单元完成第一个操作时, 会生成一 个结果( v , t )\u0026rsquo; 指明标记为 t 的操作产生值 V。所有等待t 作为源的操作都能使用 v 作为源值, 这就是一种形式的数据转发。通过这种机制,值可以从一个操作直接转发到另一个操作,而不是写到寄存器文件再读出来,使得第二个操作能够在第一个操作完成后尽快开始。重命名表只包含关于有未进行写操作的寄存器条目。当一条被译码的指令需要寄存 器 r , 而又没有标记与这个寄存器相关联,那么可以直接从寄存器文件中获取这个操作数。有了寄存器重命名,即使只有在处理器确定了分支结果之后才能更新寄存器,也可以预测着执行操作的整个序列。\n田 日 乱序处理的历史\n乱序处理 最早是 在 1 964 年 Co nt ro l Da ta Cor pora t ion 的 6600 处理 器中实现的。指令} 由十个不同的功 能单元处理 , 每个单元都 能独立地运 行。在那个 时候 , 这种时钟 频率为 勺\nl OM hz 的机 器被认为是科学计算最好的机器。\n在 1 9 66 年, IB M 首先是在 IB M 360 / 91 上 实现了乱序处理,但 只是用来执行 浮点指令。在大约 25 年的时间 里, 乱序处理 都被认为是一项异乎寻常的 技术,只 在追求尽 可1\n能高性能 的机器中使 用,直到 1 990 年 IBM 在 RS / 6000 系列 工作站中重新 引入 了 这项技术。这种设计成 为 了 IB M / M o t o ro la P o w erP C 系列 的基础, 1 9 93 年引入的 型号 601 , 它i\n成为笫一 个使 用乱序 处理的 单芯片微处理 器。I nt el 在 1995 年的 P ent ium P ro 型号 中引入} 了乱序处理 , P e nt i umP ro 的底 层微体系结构类似 于我们的 参考机 。 i\n为\n7. 2 功能单元的性能\n图 5-1 2 提供了 I nt el Core i7 H as well 参考机的 一些算术运算的性能 , 有的是测量出来的,有的是引用 In tel 的文献[ 49] 。这些时间对于其他处理器来说 也是具有代表性的。每个运算 都是由以下这些数值来刻画的: 一个是延迟(l a te ncy ) , 它表示完成运算所需要的总时间; 另一个是 发射时间 ( is s ue time), 它表示两个连续的同类型的运算之间需要的最小时钟周期 数; 还有一个 是容量( capacit y) , 它表示能够执行该运算的功能单元的数量。\n整数 浮点数 运算 延迟 发射 容扯 延迟 发射 容扯 加法 1 1 4 3 I I 乘法 3 I I 5 1 2 除法 3 - 30 3 - 30 I 3 - 15 3 - 15 I 图 5-12 参考机的操作的延迟、发射时间和容量特性。延迟表明执行实际运算所需要的时钟周期总数, 而发射时间表明两次运算之间间隔的最小周期数。容最表明同时能发射多少个这样的操作。除法 需要的时间依赖于数据值\n我们看到 , 从整数 运算到浮点运算, 延迟是增加的。还可以 看到加法和乘法运算的发射时间 都为 1 , 意思是说在每个时钟周期,处理器都可以开始一条新的这样的运算。这种很短的 发射时间 是通过使用 流 水线实现的。流水线化的功能单元实现为一系列的阶段\n(stage), 每个阶段完成一 部分的运算。例 如, 一个典型的浮点 加法器包含三个阶段(所以有三个周期的延迟):一个阶段处理指数值,一个阶段将小数相加,而另一个阶段对结果 进行舍入。算术运算可以连续地通过各个阶段,而不用等待一个操作完成后再开始下一 个。只有当要执行的运算是连续的、逻辑上独立的时候,才能利用这种功能。发射时间为\n1 的功能单元 被称为完全 流水 线化的 ( f ull y pipelined) : 每个时钟周期可以开始一个新的运\n算。出现容量大于 1 的运算是由于有多个功能单元,就如 前面所述的 参考机一样。\n我们还看到,除法器(用于整数和浮点除法,还用来计算浮点平方根)不是完全流水线 化的一—-它的发射时间等于它的延迟。这就意味着在开始一条新运算之前,除法楛必须完成整个除法。我们还看到,对千除法的延迟和发射时间是以范围的形式给出的,因为某些 被除数和除数的组合比其他的组合需要更多的步骤。除法的长延迟和长发射时间使之成为 了一个相对开销很大的运算。\n表达发射时间的一种更常见的方法是指明这个功能单元的最大吞吐量,定义为发射时间的倒数。一个完全流水线化的功能单元有最大的吞吐量,每个时钟周期一个运算,而发射时间较大的功能单元的最大吞吐量比较小。具有多个功能单元可以进一步提高吞吐量。对一个容量为 C, 发射时间 为 I 的操作来说, 处理器可能获得的吞吐量为每时钟周期 C/ I 个操作。 比如,我 们的参考机可以 每个时 钟周期执行两个浮点乘法运算。我们将看到如何利用这种能力来提高程序的性能。\n电路设计者可以创建具有各种性能特性的功能单元。创建一个延迟短或使用流水线的 单元需要较多的硬件,特别是对于像乘法和浮点操作这样比较复杂的功能。因为微处理器 芯片上 , 对于这些单元 ,只 有有限的空间,所 以 CPU 设计者必须小心地平衡功能单元的数最和它们各自的性能,以获得最优的整体性能。设计者们评估许多不同的基准程序,将 大多数 资源用 千最关 键的操作。如图 5-1 2 表明的那样, 在 Core i7 H as well 处理器的设计中,整数乘法、浮点乘法和加法被认为是重要的操作,即使为了获得低延迟和较高的流水\n线化程度需要大盘的硬件。另一方面,除法相对不太常用,而且要想实现低延迟或完全流 水线化是很困难的。\n这些算术运算的延 迟、发射时 间和容量会影响合并函数的性能。我们用 CP E 值的两个基本界限来描述这种影响 :\n延迟界限给出了任何必须按照 严格顺序完成合并运算的函数所需要的最小 CPE 值。根据功能单元产生结果的最大速率,吞 吐量界 限给出 了 CPE 的最小界限。例如, 因为只有一个 整数乘法器, 它的发射时间为 1 个时钟周期, 处理楛不可能支持每个时钟周期大 于 1 条乘法的速度。另一方面,四个功能单元都可以执行整数加法,处理器就有可能持续每个周 期执行 4 个操作的 速率。不幸的是,因 为需 要从内存读数据, 这造成了另一个吞吐量界限。两个加载单元限制了处理器每个时钟周期最多只能读取两个数据值,从而使得吞吐量 界限为 0. 50。我们会展示延迟界限 和吞吐量界限对合并函数不同版本的影响。\n5. 7. 3 处理器操作的抽象模型\n作为分析在现代处理器上执行的机器级程序性能的一个工具,我们会使用程序的数据 流( data-flow) 表示, 这是一种图形化的 表示方法, 展现了不同操作之间的数据相关 是如何限 制它们的执行顺序的。这些限制形成了图中的关键 路径( critical path) , 这是执行一组机器指令所需时钟周期数的一个下界。\n在继续技术细节之前 ,检 查一下函数 c ombi ne 4 的 CP E 测量值是很有帮助的,到目前为止 c ombi ne 4 是最快的代码:\n我们可以看到,除 了整数加法的 情况,这 些测量值与处理器的延迟界限是一样的。这不是巧合一 它表明这些函数的性能是由所执行的求和或者乘积计算主宰的。计算 n 个元素的乘积或者和需要大约L · n+ K 个时钟周期, 这里 L 是合并运算的延迟, 而 K 表示调用 函数和初始化以 及终止循环的开销。因此 , CP E 就等于延迟界限 L。\n1 从机器级 代码到数 据流图\n程序的数据流表示是非正式 的。我们只是想用 它来形象地描述程序中的数据相关是如何主宰程序的性能的。以 combi ne 4( 图 5-10 ) 为例来描述数据流表示法。我们将注意力集中在循环执行的计算上,因为对于大向量来说,这是决定性能的主要因素。我们考虑类型 为 d o ub l e 的数据、以乘法作为合并运算的情况 , 不过其他数据类型和运算的组合也有几乎一样的结构。这个循 环编译出的代码由 4 条指令组成, 寄存器%r d x 存放指向数组 dat a中第 i 个元素的指 针,%r a x 存放指向数组末尾的指针 , 而%x mm0 存放累积值 a c e。\nInner loop of combi ne4 . data_t = double, OP = *\nace i n 胚 江 皿 0 , data+i i n r 加 dx, data+length in Y.rax\n. L25: l oop:\nvmulsd (%rdx), %xmm0, %xmm0 Multiply ace by data[i] addq $8, %rdx Increment data+i cmpq jne %rax, %rdx .L25 Compare to data+length If !=, goto loop 如图 5-13 所示,在我 们假想的处理器设计中, 指令译码器会把这 4 条指令扩展成为一系列 的五步操作, 最开始的乘法指令被扩展 成一个 l o a d 操作,从 内 存读出源操作数, 和一个 mul 操作, 执行乘法。\n} =ulsd (% cd x ) , %x= O , %x= O addq $8, %r dx\ncmpq %r a x, %r dx jne loop\n毛r a x I % r dx I 号 xmmO\n图 5-13 combi ne 4 的内循 环代码的图形化表示。指令动态地被 翻译成一个或两个操作, 每个操作从其他操作或 寄存器接收 值, 并且为其他操作和寄存器产生值。我们给出 最后一条指令的目标 为标号 l oop 。它跳转到给出的第一条指令\n作为生成程序数据流图表示的一步,图 5-13 左手边的方框和线给出了各个指令是如何使用和更新寄存器的,顶 部的方框表示循环开始时寄存器的值,而底 部的方框表示最后寄存器的值。例如, 寄存器%r a x 只 被 c rnp 操作作为源值, 因此这个寄存器在循环结束时有着同循环开始时 一样的值。另一方面, 在循环中, 寄存器% r d x 既 被使用也被修改。它的初始值被 l o a d 和 a d d 操作使用; 它的新值由 a d d 操作产生, 然后被 c rnp 操作使用。在循环中, rnu l 操作首先使用寄存器%x mm0 的 初始值作为源值 , 然后会修改它的值。\n图 5-13 中的某些操作产生的值不对应于任何寄存器。在右边, 用操作间的弧线来表示。l o a d 操作从内存读出一个 值, 然后把它直接传递到 rnu l 操作。由千这两个操作是通过对一条 vmu l s d 指令译码产生的,所 以这个在两个操作之间传递的中间值没有与之相关联的寄存器。c rnp 操作更新条件码, 然后 j n e 操作会测试这些条件码。\n对于形成循环的代码片段,我们可以将访问到的寄存器分为四类:\n只读:这些寄存器只用作源值,可以作为数据,也可以用来计算内存地址,但是在循 环中它们是不会被修改的 。循环 c o mbi ne 4 的只读寄存器是%r a x 。\n只 写: 这些寄存器作为数据传送操作的目的。在本循环中没有这样的 寄存器。\n局部: 这些寄存器在循环内部被修改和使用, 迭代与迭代之间不相关。在这个循环中,条件码寄存器就是例子: c rnp 操作会修改它们, 然后 j n e 操作会使用它们, 不过这种相关是在单次迭代之内的。\n循环:对于循环来说,这些寄存器既作为源值,又作为目的,一次迭代中产生的值会在另一次迭代中用 到。可以看到,%r d x 和%x mm0 是 c ombi n e 4 的循环寄存器, 对应于程序\n值 da t a +i 和 a c e 。\n正如我们会看到的,循环寄存器之间的操作链决定了限制性能的数据相关。\n图 5-14 是对图 5-13 的图形化表示的进一步改进,目 标是只给出影响 程序执行时间的操作和数据相关。在图 5-14a 中看到, 我们重新排列了操作符, 更清晰地表明了从顶部源寄存器(只读寄存器和循环寄存器)到底部目的寄存器(只写寄存器和循环寄存器)的数据流。\na ) 蜇新排列了图5-13的操作符, 更消晰地表明了数据相关\nb ) 操作在一次迭代中使用某些值, 产生出在下一次迭代中需要的新值\n图 5-14 将 combi ne 4 的 操 作 抽 象 成 数 据 流图\n在图 5-14a 中,如 果操作符不属于某个循环寄存器之间的相关链,那么就把它们标识成白色。例如,比 较( cmp ) 和分支( j ne ) 操作不直接影响程序中的数据流。假设指令控制单元预测会选择分支,因此程序会继续循环。比较和分支操作的目的是测试分支条件,如果不选择分支的话, 就通知 ICU。我们假设这个检查能够完成得足够快,不会减漫处理器的执行。\n在图 5-1 4b 中, 消除了左边标识为白 色的\n操作符,而且只保留了循环寄存器。剩下的 是一个抽象的模板, 表明的是由千循环的一次迭代在循环寄存器中形成的数据相关。在 这个图中可以看到,从一次迭代到下一次迭 代有两个数据相关。在一边,我们看到存储 在寄存器%x mrn0 中的程序值 a c e 的连续的值之间有相关。通过将 a c e 的旧值乘以一个数据元素, 循环计算出 a c e 的新值, 这个数据元素是由 l oad 操作产生的。在另一边, 我们看到循环索引 i 的连续的值之间有相关。每次迭代中,\ni 的旧 值用来计算 l oa d 操作的地址, 然后 add\n操作也会增加它的值,计算出新值。\n图 5-1 5 给出了函数 c ombi ne 4 内循环的 n\n次迭代的数据流表示。可以看出,简单地重\n关键路径\n图 5-15 co mbi ne 4 的 内 循 环的 n 次 迭代计算的数 据 流表示。乘法操作的序列形成了恨制程序性能的关键路径\n复图 5-1 4 右边的模板 n 次, 就 能 得 到 这 张图。我们可以看到, 程 序 有 两 条 数 据 相 关 链 , 分别对 应于操作 mu l 和 a d d 对程序值 a c e 和 d a 七a 江 的 修 改 。 假设浮点乘法延迟为 5 个周期, 而整数加法延迟为 1 个周期,可 以 看 到左边的链会成为关键路径,需 要 Sn 个周期 执 行。右边的 链只需 要 n 个周期执行, 因此, 它不会制约程序的性能。\n图 5-15 说明在执行单精度浮点乘法时,对 于 c o mbi ne 4 , 为什么我们获得了等于 5 个周期延迟界限的 CPE。当执行这个函数时,浮点 乘法器成为了制约资源。循环中需要的其他操作- 控制和测试指针值 da t a +i , 以及从内存中读数据 与乘法器并行地进行。每次后继的\nace 的值被计算出来,它 就反馈回来计算下一 个值, 不过只有等到 5 个周期后才能完成。\n其他数据类型和运算组合的数据流与图 5- 15 所示的内容一样,只 是 在左边的形成数据相关链的数据操作不同。对于所有情况, 如果运算的延迟, L 大 于 1, 那么可以看到测量出来的 CPE 就是 L , 表明这个链是制约性能的关键路径。\n其他性能因素\n另一方面,对 于 整数 加 法的情况, 我们对 c ombi n e 4 的测试表明 CPE 为 1. 27, 而根据沿着图 5-1 5 中左边和右边形成的相关链预测的 CPE 为 1. 00, 测试值比预测值要慢。这说明了一个原则,那就是数据流表示中的关键路径提供的只是程序需要周期数的下界。还 有其他一 些因素会限制性能, 包括可用的功能单元的数量和任何一步中功能单元之间能够传递数据值的数量。对于合并运算为整数加法的情况,数据操作足够快,使得其他操作供 应数据的 速度不够快。要准确地确定为什么程序中每个元素需要 1. 27 个周期,需 要 比 公开可以获得的更详细的硬件设计知识。\n总结一下 c ombi n e 4 的性能分析: 我们对程序操作的抽象数据流表示说明, c o mbi ne 4\n的关键路 径长 L · n 是由对程序值 a c e 的连续更新造成的 , 这条路径将 CPE 限制为最多\nL。除了整数加法之外,对 于 所 有 的 其 他情况, 测 量出的 CPE 确实等千 L , 对于整数加法, 测量出的 CPE 为 1. 27 而不是根据关键路径的长度所期望的 1. 00 。\n看上去,延迟界限是基本的限制,决定了我们的合并运算能执行多快。接下来的任务是重新调整操作的结构,增强指令级并行性。我们想对程序做变换,使得唯一的限制变成吞吐量界 限,得 到接近于 1. 00 的 CPE。\n练习题 5. 5 假设写 一个对多项 式求值的 函 数,这 里, 多项 式的 次数为 n , 系数为 a o ,\na1, ···, a., 。 对于值 X , 我们对多项式求值,计算\na。+ a 1x + a 江 + … + a.,工n (5, 2)\n这个 求值 可以用下面 的函 数来实现, 参数包 括 一个系 数 数 组 a 、值 x 和 多项 式的次 数de gr e e ( 等 式 ( 5. 2 ) 中的值 n ) 。在这个函数的 一个 循环 中, 我们 计算连续的等 式的项, 以及 连续的 x 的幕:\ndouble poly(double a[], double x, long degree)\n2 {\nlong i;\n4 double result= a[O];\n5 double xpwr = x; I* Equals x-i at start of loop *I\n6 for (i = 1; i \u0026lt;= degree; i++) {\na[i] * xpwr; xpwr;\n对于次数 n , 这段代码执行多少次加法和多少次乘法运算?\n在我们 的参 考机 上, 算术运算的 延迟如图 5-1 2 所 示, 我们 测 量 了 这个函 数的 CPE 等于 5. 00 。根据由于实现函数 第 7 ~ 8 行的操作迭代之间形成的数据相关 , 解释 为什么 会得到这样的 CPE。\n沁曷 练习题 5. 6 我们继续探索练 习题 5. 5 中描述的 多项 式求值的 方 法。 通过采用 H orner 法(以英国数学家 W ill iam G. HornerCl 786- 1837) 命名)对多项 式 求值 , 我们 可以减少乘 法的 数量。 其思 想是 反复提出 工 的幕, 得到 下面的求值:\na 。十工Ca 1 + x Ca 2 +··· 十工( a 广]+ 立 ,,)… )) 使 用 H or ner 法, 我们 可 以用 下 面的 代码实现 多项 式求值:\nI* Apply Horner\u0026rsquo;s method *I\ndouble pol yh (doubl e a[], double x, long degree)\n{\n(5. 3)\nlong i;\ndouble result= a[degree];\nfor (i = degree-1; i \u0026gt;= O; i\u0026ndash;) result= a[i] + x*result;\nreturn result;\n}\n5. 8\n对于次数 n , 这段代码执行多少次加法和多少次乘法运算?\n在我们的参考机上, 算术运 算 的廷迟如 图 5-12 所 示, 测 量这个函 数 的 CPE 等于\n8. 00 。 根据由于实现 函数 第 7 行的操作迭代之间形成 的 数据相关, 解释为 什 么会得到 这样的 CPE。\n请解 释虽 然练 习题 5. 5 中所 示的 函数需 要更多的操作 , 但是它是 如何运行得更快的。\n循环展开 # 循环展开是一种程序变换,通过增加每次迭代计算的元素的数批,减少循环的迭代次数。 p s um2 函数(见 图 5-1 ) 就是这样一个例子,其 中 每次迭代计算前置和的两个元素, 因而将需要的迭代次数减半。循环展开能够从两个方面改进程序的性能。首先,它减少了不直接 有助于程序结果的操作的数量, 例 如循环索引计算和条件分支。第二, 它 提供了一些方法, 可以进一步变化代码,减 少 整个计算中关键路径上的操作数量。在本节中, 我们会看 一 些 简 单 的 循 环 展开,不 做 任何 进一 步的变化。\n图 5-16 是合并代码的使用 \u0026quot; 2 X l 循环展开"的版本。第一个循环每次处理数组的两个元素。也就是每次迭代, 循环 索引 l 加 2, 在一次迭代中,对数组元 素 l 和 i + l 使用合并运算。\n一般来说,向 量 长度不一定是 2 的倍 数 。 想 要 使 我们的代码对任意向量长度都能正确\n工 作 ,可 以 从 两个方面来解释这个需求。首先,要 确 保 第一次循环不会超出数组的界限。对 于 长度为 n 的向量,我 们将循环界限设为 n - l 。然后,保 证 只 有 当 循 环 索 引 1 满足 i \u0026lt;\nn —1 时 才会执行这个循环,因 此最大数组索引 i + l 满足 i + l \u0026lt; ( n - l) + l = n。\n把这个思想归纳为对一个循环按任意因子 k 进行 展开,由 此 产 生 k X l 循环展开。为此, 上限 设 为 n - k + l , 在循环内 对元素 年 加+ k— l 应用合并运算。每次迭代 ,循 环 索引 1加k。\n那 么 最大 循 环 索引 汁 k—1 会 小 千 n。要使用第二个循环,以 每次处理一个元素的方式处理向 最 的 最 后几 个元素。这个循环体将会执行 O k - l 次。对 于 k = 2 , 我们能用一个简单的条件语句, 可选地增加最后一次迭代, 如函数 ps um2( 图 5-1 ) 所示 。 对 于 k \u0026gt; 2 , 最后的这些情\n况最好用一个 循环来 表示 ,所 以 对 k = 2 的 情 况, 我们同样也采用这个编程惯例。我们称这种变换为 \u0026quot; k X l 循环展开“,因 为循环展开因子为 k , 而累积值只在单个变量 a c e 中。\nI* 2 x 1 loop unrolling *I\n2 void combine5(vec_ptr v, data_t *dest)\n3 {\nlong i;\nlong length= vec_length(v);\nlong limit= length-1;\ndata_t *data = get_vec_start(v);\ndata_t ace= !DENT;\n9\nI* Combine 2 elements at a time *I\nfor (i = O; i \u0026lt; limit; i+=2) {\nace= (ace OP data[i]) OP data[i+1];\n13 }\n14\nI* Finish any remaining elements *I\nfor (; i \u0026lt; length; i++) {\nace = ace OP data [i] ; 18 }\n19 *dest = ace; 20 }\n图 5-16 使用 2 X l 循环展开。这种变换能减小循环开销的影响区! 练习题 5 . 7 修改 c o mb i n e s 的代码, 展开循 环 k = 5 次。\n当测 量展 开次数 k = 2 ( c o mbi ne 5 ) 和 k = 3 的展开代码的性能时, 得到 下面的结果 :\n函数 方法 整数 浮点数 + * + 兴 combine4 无展开 1. 27 3.01 3. 01 5. 01 combines 2 X l 展 开 1. 01 3.01 3. 01 5. 01 3 X l 展 开 1. 01 3.01 3. 01 5. 01 延迟界限 1. 00 3.00 3. 00 5. 00 吞吐盘界限 o. 50 1. 00 1. 00 0. 50 我们 看到对于整数加法, CPE 有所改进, 得到的延迟界限为 1. 0 0 。会有这样的结果是得益千减少 了循环开销操作。相对于计算向量和所需要的加法数量,降 低 开销操作的数量,此时,整数加法的一个周期的延迟成为了限制性能的因素。另一方面,其他情况并没有性能提高——-它 们 已经达到了其延迟界限。图 5-1 7 给出了当循环展开到 10 次时的 CPE测量值。 对于展开 2 次 和 3 次时观察到的趋势还在继续一 没有一个低千其延迟界限。\n要理解为什么 k X l 循环展开不能将性能改进到超过延迟界限,让 我们来查看一下 k =\n2 时, c o mb i n e s 内 循 环 的机 器级代码。当类型 d a t a —t 为 d o u b l e , 操作为乘法时,生成如下代码:\nInner loop of combi nes . data_t = double, OP=* i in %rdx, data %rax, limit in %rbp, ace in %xmm0\n.L35: loop:\nvmulsd (%rax, %rdx, 8), %xmm0, %xmmO Multiply ace by data[i]\nvmulsd 8(%rax,%rdx,8), %xmm0, %xmm0 Multiply ace by data[i+1]\naddq\ncmpq\n6 jg\n$2, %rdx\n%rdx, %rbp\n.135\n6\n5\n4\nIncrement i by 2 Compare to limit:1. If\u0026gt;, goto loop\ndouble * double + u lQ\u0026hellip;t..l 3\n2\n。\n牖耋 瞿 瞿 攫\nX、、、\n-·-一-· 一一- -、于 一一- 沃\n3 4\n展开次数K\n矗 lo ng *\nlong+\n图 5-17 不同程度 k X l 循 环展开的 CPE 性能。这种变换只改进了整数加法的性能\n我们可以看到,相 比 c o mbi n e 4 生成的基千指针的代码, GCC 使用了 C 代码中数组引用 的 更 加 直 接的转换气 循环索引 J.. 在 寄 存 器%r d x 中 , d a t a 的 地址在寄存器%r a x 中6 和前 面一样,累 积值 a c e 在向量寄存器%x mm0 中 。 循 环 展 开会导致两条 vmu l s d 指令_ _ 条 将 d a t a [ i ) 加 到 a c e 上 , 第 二 条将 d a t a [ i 十 l l 加到 a c e 上。图 5- 1 8 给出了这段代码的图 形 化 表 示。每条 vmu l s d 指令被翻译成两个操作: 一 个 操 作 是 从 内 存 中 加 载 一 个数组元素,另 一个是把这个值乘以已有的 累积值。这里我们 看到,循 环的每次执行中,对 寄存 器 %x mm0 读 和写两次。可以重新排列、简化和抽象这张图,按 照 图 5- 1 9a 所 示的过程得到图 5- 1 9 b 所 示的模板。然后,把 这个模板复制 n / 2 次, 给出一个长度为 n 的向量的计算, 得 到 如图 5- 20 所示的数据流表示。在此我们看到, 这 张 图 中 关 键 路 径 还是 n 个 mu l 操作 一 迭代次数减半了, 但 是 每次迭代中还是有两个顺序的乘法操作。这个关键路径是循环 没有展开代码的性能制约因素, 而它仍然是 k X l 循环展开代码的性能制约因素。\nvmulsd ( %r a x , %r d x, 8 ) , %xmm0, %xmm0\nvmu l s d 8 ( %r a x, % r d x, 8 ) , %x mm0, %xmm0 addq $2 , %r dx\ncmpq % r d x, 号r bp jg l oop\n令r a x l %r bp l %r d x l令xmmo:\n图 5 - 1 8 co mbi ne s 内循环代码的图形化表示。每次迭代有两条 vmul s d 指令, 每条指令被翻译 成一个 l oa d 和一个 mul 操 作\n8 GCC 优化 器 产生一个函数的多个版本, 并 从 中 选择 它预测会获得最佳性能和最小代码蜇的那一个。其结果就是, 源代码中微 小的变化就会生成各种不同形 式的机器码。我们已经发现对基于指针和基于数组的代码的 选择不会影响在参考机上运行的程序的性能。\na ) 重新排列、简 化和抽象图5-18的表示,给出连续迭代之间的数据相关\nb) 每次迭代必须顺序地执行两个乘法图 5-19 将 c ombi ne s 的操作抽象成\n数据流图\nm 让编译器展开循环\n关键路径\n\u0026rsquo;\u0026ndash;\ndata[O]\ndata[l]\nda七a [ 2 J\ndata[3]\ndata[n-2)\ndata[n-1]\n图 5- 20 c ombi ne s 对一个 长度为 n 的向量进行操作的数据流表示。虽然循环展开了 2 次 ,但 是 关 键 路 径上还是 有\nn 个 mul 操作\n编译器可以很容易地执行循环展开。只要优化级别设置得足够高,许多编译器都能 例行公事地做到这 一点。用优 化等级 3 或更高等级调 用 GCC , 它就会执行循环展开。\n9 提高并行性\n在此,程序的性能是受运算单元的延迟限制的。不过,正如我们表明的,执行加法和乘 法的功 能单元是完全流水线化的, 这意味着它们可以每个时钟周期开始一个新操作 ,并 且有些操作可以被多个功能单元执行。硬件具有以更高速率执行乘法和加法的潜力,但是代码不 能利用这种能力 , 即使是使用循环展开也不能, 这是因为我们将 累积值放在一个单独的 变量a c e 中。在前面的计算完成之前 , 都不能计算 a c e 的新值。虽然计算 a c e 新值的功能单元能\n够每个时钟周期开始一个新的操作 , 但是它只会每 L 个周期开始一条新操作, 这里L 是合并操作的延迟。现在我们要考察打破这种顺序相关,得到比延迟界限更好性能的方法。\n5. 9. 1 多个累积变量\n对于一个可结合和可交换的合并运算来说,比如说整数加法或乘法,我们可以通过将\n一组合并运算分割成两个或更多的部分, 并在最后合并结果来提高性能。例如, P\u0026quot; 表示元素 a o\u0026rsquo; a 1\u0026rsquo; … , a n- 1 的 乘积:\n..- 1\nPn=IIa,\ni=O\n假设 n 为偶数, 我们还可以把它写成Pn = PEn X P On\u0026rsquo; 这里 P E\u0026quot; 是索引值为偶数的元素的乘积, 而 P O\u0026quot; 是索引\n值为奇数的元素的乘积:\nn/ 2- 1\nPE.= az,\n,=O\nn/2- 1\nPO.= II 釭+I\ni = O\n图 5- 21 展示的是使用这种方法的代码。它既使用了两次循环展 开, 以使每次迭代合并更多的元素,也使用了两路 并行,将索引值为偶数的元素累积在变 量 a c c O 中, 而索引值为奇数的元素累积在变量 a c c l 中。因此, 我们将其称为\u0026quot; 2X 2 循环展开"。同前面一样,我们 还\nI* 2 x 2 loop unrolling *I\nvoid combine6 (vec_ptr v, data_t *dest)\n{\nlong i;\nlong length= vec_l engt h (v ) ; long limit= l enght-1;\ndata_t *data = ge t _ve c _s t ar t (v) ; data_t accO = !DENT;\ndata_t acc1 = !DENT;\nI* Combine 2 elements at a time *I for (i = O; i \u0026lt; limit; i+=2) {\naccO = accO OP data[i]; acc1 = acc1 OP data[i+1];\n}\nI* Finish anyr ema i ni ng e l ement s * I for (; i \u0026lt; length; i++) {\naccO = accO OP dat a [ i ] ;\n}\n*dest = accO OP ace!;\n包括了第二个循环, 对千向量长度不为2\n的倍数时,这个循环要累积所有剩下的数\n图5-21 运用 2 X 2 循环展开。通 过维护多个累积变位,\n组元素。然后, 我们对 a c c O 和 a cc l 应用 这种方法利用了多个功能单元以及它们的流水线\n合并运算,计算最终的结果。 能力\n比较只做循环展开和既做循环展开同时也使用两路并行这两种方法,我们得到下面的 性能:\n函数 方法 整数 浮点数 + * + * combine4 在临时变址中累积 1. 27 3.01 3. 01 5. 01 combines 2Xl 展 开 1. 01 3. 01 3. 01 5. 01 combi ne6 2 X 2 展 开 0. 81 1. 51 1. 51 2. 51 延迟界限 1. 00 3. 00 3. 00 5. 00 吞吐拭界限 0. 50 1. 00 1. 00 0. 50 我们看到所有情况都得到了改进, 整数乘 、浮点加、浮点乘改进了约 2 倍, 而整数加也有所改进。最棒的是,我们打破了由延迟界限设下的限制。处理器不再需要延迟一个加 法或乘法操作以待前一个操作完成。\n要理解 c ombi ne 6 的性能 , 我们从图 5- 22 所示的代码和操作序列开始。通过图 5-23\n所示的过 程,可 以推导出一个模板, 给出迭代之间 的数据相关 。同 c ombi n e s 一样, 这个内循环包括 两个 vrnu l s d 运算, 但是这些指令被翻译成读写不同寄存器的 mu l 操作,它 们之间没有数 据相关(图5- 23 6 ) 。然后, 把这个模板复制 n / 2 次(图5- 24 ) , 就是在一个长度为 n 的向量上执行这 个函数的模型。可以看到, 现在有两条关 键路径, 一条对应于计算索引为偶数的元素的 乘积(程序值a c c O) , 另一条对应千计算索引为奇数的元素的乘积(程序值 a c c l ) 。 每条关键路径只包含 n / 2 个操作, 因此导致 C P E 大约为 5. 00 / 2 = 2. 50 。相似的分析可 以解释我们观察 到的对于不同的数 据类型和合并运算的 组合, 延迟为 L 的操作的\nCPE 等于 L / 2 。实际上 , 程序正在利用功能单元的流水线能 力, 将利用率提高到 2 倍。唯一的例外是 整数加。我们已将将 CP E 降低到 1. 0 以下, 但是还是有太多的循环开销, 而无法达到 理论界限 0. 50 。\n%r a x l%r bp lr% dx l%xmmO I号xmml\nvmul s d ( 毛r a x, r% dx , 8 ) , 沧xmmO, 号xmmO\nv mul s d 8 (r% ax, 号 r dx, 8) , %xmml , %xmml\na ddq $2, r枭 dx\nc mpq 号 r d x, %r bp\njg loop\n釭 ax l号r bp lr马 dx l%xmm0I%xmml\n图 5-22 co mbi ne 6 内循环代码的图形化表示。每次循环有两条 vrnul s d 指令, 每条指令被翻译成一个 l oad 和一个 mul 操作\n我们可以将多个 累积变量变换归纳为将 循环展开 k 次, 以及并行累积 k 个值, 得到 k X k 循环展 开。图 5- 25 显示了当数值达到 k = 10 时, 应用这种变换 的效果。可以看到, 当 K 值足够大时,程序 在所有情况下几乎都能达到吞吐量界限。整数加在 k = 7 时达到的\nCPE 为 0. 54 , 接近由两个加载单元导致的吞 吐量界限 0. 50 。整数乘和浮点加在 k 3 时达到的 CP E 为 1. 01, 接近由它们的功能单元设 置的吞吐最界限 1. 00 。浮点乘在 k l O 时达\n到的 CP E 为 0. 5 1 , 接近由 两个浮点乘法器和两个加载单元设置的吞吐量界限 o. 5 0 。值得\n注意的是, 即使乘法是更加复杂的操作, 我们的代码在浮点乘上达到的 吞吐量几乎是浮点加可以达到的两倍。\n通常 , 只 有保待能够执行该操作的所有功能单元的流水线 都是满的 , 程序才能达到这个操作的吞吐量界限。对延迟为 L , 容量为 C 的操作而言,这 就要求循环展开因子 k\nC · L 。比如, 浮点乘有 C = 2 , L = 5 , 循环展开因子就必须为 k l O。 浮点 加有 C = l ,\nL = 3 , 则在 k 3 时达到最大吞吐量。\n在执行 k X k 循环展开变换 时, 我们 必须考虑是否要保 留原 始函数的功能。在第 2 章 已经看到, 补码运算是可交换和可结合的, 甚至是当溢出时也是如此。因此, 对于整数 数据类型, 在所有 可能的情况下, c o mbi ne 6 计算出的结果都和 c o mbi ne s 计算出的相同。因此,优化 编译器潜在地能够将 c o mbi ne 4 中所示的代码首先转换成 c ombi n e s 的二路循环展开 的版本, 然后再通过引入并行性, 将之转换成 c o mbi ne 6 的版本。有些编译器可以 做这种或 与之类似的变换来 提高整数数 据的性能 。\na ) 重新排列、简化和抽象图5-22的表示, 给出连续迭代之间的数据相关\ndata[OJ\ndata (1]\ndata[2]\ndata[3]\ndata [n-2」]\nda t a [ n 一1 ]\nb ) 两个mul 操作之间没有相关\n图 5-23 将 c ombi ne 6 的运算\n抽象成数据流图\n图 5- 24 c o mbi ne 6 对一个长度为 n 的向最进行操作的\n数据流表示。现在有两条关键路径,每条关键路径包含 n / 2 个操作\n2 3 4 5 6 7 8 9 10\n展开次数K\ndouble*\ndouble+\nlong *\n- long+\n图 5-25 k X k 循环展开的 CP E 性能。使用这种变换后, 所有的 C P E 都有所改进,接近或达到其吞吐量界限\n另一方面, 浮点乘法和加法不是可结合的。因此,由 于 四 舍 五 入或溢出, c o mb i ne s 和 c o mb i n e 6 可能产生不同的结果。例如, 假想这样一种情况,所 有索引值为偶数的元素都 是 绝 对值非常大的数,而 索引值为奇数的元素都非常接近于 o. 0 。 那 么 ,即 使 最 终的乘\n积 P\u0026quot; 不 会 溢出,乘 积 PE ,, 也 可能上溢,或 者 PO \u0026quot; 也 可 能 下 溢。不过在大多数现实的程序中,不太可能出现这样的情况。因为大多数物理现象是连续的,所以数值数据也趋向千相\n当平滑,不会出什么问题。即使有不连续的时候,它们通常也不会导致前面描述的条件那 样的周期性模式。按照严格顺序对元素求积的准确性不太可能从根本上比“分成两组独立 求积,然后再将这两个积相乘”更好。对大多数应用程序来说,使性能翻倍要比冒对奇怪 的数据模式产生不同的结果的风险更重要。但是,程序开发人员应该与潜在的用户协商, 看看是否有特殊的条件,可能会导致修改后的算法不能接受。大多数编译器并不会尝试对 浮点数代码进行这种变换,因为它们没有办法判断引入这种会改变程序行为的转换所带来 的风险,不论这种改变是多么小。\n5. 9. 2 重新结合变换\n现在来探讨另一种打破顺序相关从而使性能提高到延迟界限之外的方法。我们看到过 做 k X l 循环展开的 c ombi n e s 没有改 变合并向量元素形成和或者乘积中执行的操作。不过,对代码做很小的改动,我们可以从根本上改变合并执行的方式,也极大地提高程序的 性能。\n图 5- 26 给出了一 个函数 c o mbi n e 7, 它与 c o mbi n e s 的展开代码(图5-1 6 ) 的唯一区别在于内循环中元素合并的方式。在 c o mbi n e s 中, 合并是以下面这条语 句来实现的\n12 ace = (ace OP data[i]) OP data[i+1];\n而在 c o mbi ne 7 中, 合并是以这条语句来实现的\n12 ace= ace OP (data[i] OP data[i+1]);\n差别仅在 于两个括号是如何放置的。我们称之为重新 结合变换( rea ss o cia t ion transforma­ tion), 因为括号改 变了向釐元素与累积值 a c e 的合并顺序, 产生了我们称为 \u0026quot; 2 X l a \u0026quot; 的循环展开形式 。\nI* 2 x 1a loop unrolling *I\n2 void combine7(vec_ptr v, data_t *dest)\n3 {\n4 long i;\n5 long length= vec_length(v);\n6 long limit= length-1;\n7 data_t *data = get_vec_start(v);\n8 data_t ace = !DENT;\n9\n10 I* Combine 2 elements at a time *I\n11 for (i = O; i \u0026lt; limit; i+=2) {\n12 ace= ace OP (data[i] OP data[i+1]);\n13 }\n14\nI* Finish any remaining elements *I\nfor (; i \u0026lt; length; i++) {\nace= ace OP data[i];\n18 }\n19 *dest = ace·\n20 }\n图5-26 运用 Z X l a 循环展开 , 重新结合合并操作 。这种方法增加了可以并行执行的操作数最\n对于未 经训练的人来说, 这两个语句可能看上去本质上是一样的, 但是当我们测扯\nCPE 的时候, 得到令人吃惊的结果:\n整数 浮点数 函数 方法 + * + * combi ne 4 累 积 在临时变量中 1. 27 3. 01 3. 01 5. 01 combi ne s 2 X l 展 开 1. 01 3. 01 3. 01 5. 01 combi ne 6 2 X 2 展 开 0. 81 1. 51 I. 51 2. 51 combi ne ? 2 X la 展 开 1. 01 1. 51 1. 51 2. 51 延迟 界限 1. 00 3. 00 3. 00 5. 00 吞吐拭界 限 o. 50 1. 00 1. 00 0. 50 整数加的性能几 乎与使用 k X l 展开的 版本 ( c o mb i n e s ) 的性能相同,而 其他三种情况则 与使用并行累积变量的版本( c o mb i n e 6 ) 相同, 是 k X l 扩展的性能的两倍。这些情况已经突破了延迟界限造成的 限制。\n图 5- 2 7 说明了 c o mb i n e 7 内循环的代码(对千合并操作为乘法, 数据类型为 d o ub l e 的 情况 )是如何被译码成操作, 以及由此得到的数据相关。我们看到, 来自于 vm o v s d 和第一个 vm u l s d 指令的 l o a d 操作从内存中加载向量元素 t 和曰- 1, 第一个 mu l 操作把它们乘起来。然后, 第二个 mu l 操作把这个结果乘以累积值 a c e 。图 5- 28 a 给出了我们如何对图 5- 2 7 的操作进行重新 排列、优化和抽象, 得到表示一次迭代中数据相关的模板(图 5- 28 b ) 。对 于 c o mb i n e s 和 c o mb i n e 7 的模板, 有两个 l o a d 和两个 mu l 操作, 但是只有一个mu l 操作形成了循环寄存 器间的数据相关链。然后, 把这个模板复制 n / 2 次, 给出了 n 个向 量元素相乘所执 行的计算(图5 - 2 9 ) , 我们可以看 到关键路径上只有 n / 2 个操作。每次迭代内的第一个乘法都不需要等待前一次迭代的累积值就可以执行。因此, 最小可能的 CPE 减 少 了 2 倍。\n%r a x I %r bp I %rdx l %x mmOl %x mml\nvmo v s d ( 皂r a x , 毛 r d x , 8), 令x mmO\n} = o vs d 8( 沧 c a a , 号 n ix , 8) , 令 x = O , 号 x= O\nv mo v s d 令x mmO, 令x mml , 号xmm l a dd q $2 , %r d x\nc mp q % r d x , % r bp jg l o op\n图 5- 27 c o mbi n e ? 内循 环代码的图形化表示 。每次 迭代被译码成与 c ombi n e s 或\nc ombi ne 6 类似的 操作, 但是数据相关不同\n图 5- 3 0 展示了当数值达到 k = l O 时, 实现 k X l a 循环展开并重新结合变换的效果。可以看到, 这种变换带来的性能结果与 k X k 循环展开中保持 K 个累积变量的结果相似。对所有的情况来说 ,我 们都接近了由 功能单元造成的吞吐 量界限。\na ) 重新排列、简化和抽象图 5-27的表示, 给出连续迭代之间的数据相关\ndata [i]\ndata[OJ\ndata[l]\ndata[2]\ndata[3)\n关键路径\ndata[i+l]\n上面的mu l 操作让两个二向量元素相乘,而下面的mu l 操作将前面的结果乘以循环变盘a c e . 图 5- 28 将 co rnbi ne 7 的 操作\n抽象成数据流图\n6\n5\n4\nu # data[n-2)\ndata[n-1]\n\u0026lsquo;\\ . 、\n图 5-29 co mbi ne 7 对一个 长度为 n 的向量进行操作的数据流表示。我们 只有一条关键路径 , 它 只 包 含n / 2 个 操 作\ndouble* double+ 1匕1}..\n3\n亡产 ,\u0026rsquo;\n夏 ,,■\n.t. long*\n- - - long+\n0 I I I I I I I\n2 3 4 5 6 7 8 9 JO\n展开次数K\n图 5-30 kX l a 循环展开的 CP E 性能。在这种变换下,所 有 的 CP E 都 有 所改进,几乎达到了它们的吞吐量界限\n在执行重新结合变换时,我们又一次改变向批元素合并的顺序。对于整数加法和乘 法,这些运算是可结合的,这表示这种重新变换顺序对结果没有影响。对于浮点数情况,\n` 必须再次评估这种重新结合是否有可能严重影响结果。我们会说对大多数应用来说,这种差别不重要。\n总的来说, 重 新 结 合 变 换 能 够 减 少 计 算 中 关 键 路 径 上 操 作 的 数 量 , 通 过 更 好 地 利 用 功能单元的 流水线能力得到更好 的性能。大多数 编译 器不会 尝试 对 浮点 运算 做重新结 合 ,因 为这些 运算不保 证 是 可结合 的 。当前 的 GCC 版 本 会对 整 数 运算执行重新结合,但 不 是 总 有 好的效果 。通常 ,我 们 发 现 循 环 展 开 和并 行 地 累 积 在 多 个 值 中 , 是 提 高 程 序 性 能 的 更 可靠的 方 法。沁氐 练习题 5. 8 考虑下面的计算 n 个双精度数 组 成 的 数组 乘 积 的 函 数。 我 们 3 次展开这\n个循环。\ndouble apr od (doubl e a[], long n)\nlong i;\ndouble x, y, z; doubler= 1;\nfor (i = O; i \u0026lt; n- 2 ; i+= 3) {\nx = a[i]; y = a [ i +l ] ; z = a[i+2];\nr =r * x * y * z; I * Product comput a t i on *I\nfor (; i \u0026lt; n ; i ++)\nr *= a [ i ] ; return r ;\n对于标记为 Pr o d uc t c omp u t a t i o n 的行, 可 以用 括 号得 到该 计 算的五 种不 同的结合, 如下所 示 :\nr = ((r * x) * y) * z; I* Ai *I\nr = (r * (x * y)) * z ; I * A2 *I r =r * ((x * y) * z ) ; I* A3 *I r = r * (x * (y * z ) ) ; I* A4 *I r = (r * x ) * (y * z ) ; I* A5 *I\n假 设在一台浮点数乘法延迟为 5 个时钟周期的机器上运行这些函数。 确定 由乘法的数据相 关限定的 CPE 的下界。(提示: 画 出每 次迭代如何计算 r 的图形化表 示会 所帮助。)\n口 开 ;一 用 向 量 指 令 达 到 更 高的 并 行 度\n就像 在 3. 1 节 中 讲 述 的 , I ntel 在 1 999 年 引入 了 SS E 指 令 , SS E 是 \u0026quot; S t re aming SIM D E xt e ns io ns ( 流 SIM D 扩展 )” 的 缩 写, 而 S IM D ( 读 作 \u0026quot; sim- dee\u0026quot; ) 是 \u0026quot; S ingle-In­ s t ru ct ion , M ul tip le- Da ta ( 单指令多 数 据 )” 的 缩写。SS E 功能历 经几代, 最 新 的 版 本为高级 向 量 扩 展 ( advanced vector extens ion ) 或 AVX 。SIMD 执行 模型是 用单条指令对整个向量数 据进行操 作。 这 些向 量保存在一组特殊的向量寄存 器 ( vector register ) 中, 名 字为%\nymmO %ymml 5 。 目前的 AVX 向 量寄存器长为 32 字节 , 因此每一个都可以存放 8 个 32 位数或 4 个 64 位数, 这 些数 据既可以是整数也可以是 浮点数。AVX 指令 可以对这些寄存器执行向 量操作, 比如 并行执行 8 组 数 值 或 4 组 数 值 的 加 法或 乘 法。 例如, 如 果 Y M M 寄存\n器%ymm0 包含 8 个单精度浮点数, 用 a o\u0026rsquo; …, a1 表示, 而%r c x 包含 8 个单精度浮点数的内\n存 地 址 , 用 b。, …, b1 表 示, 那么指 令\nvmul p s ( %r cx) , %ymm0 , %ymm1\n会 从 内 存 中 读 出 8 个值 , 并 行 地 执 行 8 个乘法, 计算 a ;- a ; • b;, O i 7, 并将得到的\n8 个乘积 保存到向 量寄存器 %y mml 。 我们看到 , 一条指令能够产 生对多 个数据值的计算, 因此称 为 \u0026quot; SIMD\u0026quot; 。\nGCC 支持 对 C 语言的扩展 , 能够让程序 员在 程序中使 用向 量操作, 这些操 作能够被编译成 AVX 的向量指令(以及基于早前的 SSE 指令的代码)。这种代码凤格比直接 用汇编 语言写代 码要好, 因 为 GCC 还可以为其他处理器上 的向量指令产生代 码。\n使用GCC 指令、循环展开和多个累积变量的组合, 我们的合并函数能够达到下面的性能 :\n方法 整数 浮点数 int long long int + * `+ * + * + * 标噩 l O X 10 o. 54 1. 01 0. 55 1. 00 1. 01 0. 51 1. 01 o. 52 标最吞吐最界限 0. 50 1. 00 o. 50 1. 00 1. 00 o. 50 1. 00 o. 50 向量 8 X 8 o. 05 0. 24 0. 13 1. 51 o. 12 0 . 08 0. 2 5 0. 16 向量吞吐益界限 0. 06 0. 12 o. 12 o. 12 0 . 0 6 o. 25 0. 12 上表中 , 笫一组数字对应的是按照 c ornbi ne 6 的风格编写的传统标量代码, 循环展开因子为 1 0 , 并维护 10 个 累积 变 量。 第 二组数 字对 应的代码编写形 式 可以被 GCC 编译成\nAVX 向 量代 码。除了使 用向 量操 作外, 这个版本也进行了循环展 开,展 开因子为 8 , 并维护 8 个不 同的 向量累积 变量 。我们给出 了 32 位和 64 位数字的 结果, 因 为向 量指令在笫一种情 况中达 到 8 路并行, 而在笫二种情况中只能达到 4 路 并行。\n可以 看到 ,向 量代码在 32 位 的 4 种情况下几乎都荻得 了 8 倍的提升, 对于 64 位 来说, 在其中的 3 种情况下 荻得 了 4 倍 的提升。只有长整 数 乘法代码在我们尝试将其表 示为向量代 码时性 能不佳。AVX 指令集不 包括 64 位整数的并行乘法指令, 因此 GCC 无法为 此种 情况生成 向量代码。使用向 量指令对合并操作产 生了 新的吞吐量界 限。与标量界限相比 , 32 位 操 作的新界限 小 了 8 倍, 64 位 操作的新界限小了 4 倍。 我们的代码在几种 数据类型和操作的组合上接近了这些界 限。\n5. 10 优化合并代码的结果小结\n我们极大化对向霓元素加或者乘的函数性能的努力获得了成功。下表总结了对千标量 代码所获得的结果,没 有使用 AVX 向量指令提供的向量并行性:\n使用多项优化技术, 我们获得的 CPE 已经接近千 0. 50 和 1. 00 的吞吐量界限, 只 受限于功 能单元的容量。与原始代码相比提升了 10 20 倍 , 且使用普通的 C 代码和标准编译器就获 得了 所有 这些改进。重写代码利用较新的 SIMD 指令得到了将近 4 倍 或 8 倍的性能提升。 比如单精度乘法, CPE 从初值 11. 14 降 到 了 0. 06, 整体性能提升超过 180 倍。这个例子说明现代处理器具有相当的计算能力,但 是 我们可能需要按非常程式化的方式来编写程序以便将这些能力诱发出来。\n5. 11 一些限制因素\n我们已经看到在一个程序的数据流图表示中,关 键 路 径 指 明 了 执 行 该 程 序 所 需 时间的一 个 基本的下界。也就是说,如 果 程序中有某条数据相关链, 这条链上的所有延迟之和等于 T , 那 么 这 个 程 序 至少需要 T 个周期才能执行完。\n我们还看到功能单元的吞吐量界限也是程序执行时间的一个下界。也就是说,假设一 个 程 序 一 共需 要 N 个 某 种 运算的计算,而 微 处 理器只有 C 个能执行这个操作的功能单元, 并 且 这些单元的发射时间为 I 。那么,这 个 程序 的执 行 至 少需 要 N · I / C 个周期。\n在本节中, 我们会考虑其他一些制约程序在实际机器上性能的因素。\n5. 11. 1 寄存器溢出\n循环并行性的好处受汇编代码描述计算的能力限制。如果我们的并行度 p 超过了可用的 寄 存 器 数 量 ,那 么 编译 器会诉诸溢出( s pilling ) , 将某些临时值存放到内存中,通常是在运行时堆栈上分配空间。举个例子,将 c o mbi ne 6 的 多 累 积 变最模式扩展到 k = l O 和k=\n20, 其结果的比较如下表所示:\n我们可以看到对这种循环展 开程度的 增加 没有改善 CPE , 有些甚至还变差了。现代x86-6 4 处理器有 16 个寄存器,并 可以使用 16 个 Y M M 寄存器来保存浮点数。一旦循环变量的数量超过了可用寄存器的数量, 程序就必须在栈上分配一些变量。\n例如,下 面的代码片段展示了在 l O X 10 循环展开的内循环中, 累 积变 量 a c c O 是如何更新的:\nUpdating of accumulator accO in 10 x 10 urolling vmulsd (%rdx) , %xmm0, %xmm0 accO *= data[i]\n我们看到该累积变量被保存在寄存器% x mm0 中 ,因 此 程序可以简单地从内存中读取 d a t a\n[i l , 并 与 这 个 寄 存 器相乘。\n与之相比, 20 X 20 循环展开的相应部分非常不同:\nUpdating of accumulator accO in 20 x 20 unrolling vmovsd 40(%rsp), %xmm0\nvmulsd (%rdx), %xmm0, %xmm0 vmovsd %xmm0, 40(%rsp)\n累积变量保存为栈上的一个局部变量,其 位 置距离栈指针偏移量为 40。程序必须从内存中读 取 两个 数 值 :累 积 变 量的值和 d a t a [ i ] 的值, 将两者相乘后,将 结果 保 存 回内存。\n一旦编译器必须要诉诸寄存器溢出,那 么维 护 多 个 累 积 变量的优势就很可能消失。幸运的是 , x86-64 有足够多的 寄存器,大 多 数 循 环 在 出现寄存器溢出之前就将达到吞吐量限 制 。\n11. 2 分支预测和预测错误处罚\n在 3. 6. 6 节中通 过实验证明 , 当分支预测 逻辑不能正 确预测一个分支是否要跳转的时候,条件分支可能会招致很大的预测错误处罚。既然我们已经学习到了一些关于处理器是 如何工作的知识,就能理解这样的处罚是从哪里产生出来的了。\n现代处理器的工作远超前千当前正在执行的指令,从内存读新指令,译码指令,以确 定在什 么操作数上执行 什么操作。只要指令遵循的是一种简单的顺 序, 那么这种指令流水线化 ( ins tru ctio n pipel ining ) 就能很好地工作。当遇到 分支的时候, 处理器必须猜测分支该往哪个 方向走。对于条件转移的情况, 这意味着要预 测是否会选择分支。对于像间接跳转\n(跳转到由一个跳转表条目指定的地址)或过程返回这样的指令,这意味着要预测目标地 址。在 这里 , 我们 主要讨论条件分支。\n在一个使用投机执行( s peculat ive exec ut ion ) 的处理器中, 处理器会开始执行预测的 分支目标 处的指令。它会避免修改 任何实际的寄存器或内存位置, 直到确定了实际的结果。如果预测正确,那么处理器就会”提交“投机执行的指令的结果,把它们存储到寄存器或 内存。如果 预测错误 , 处理器必须丢弃掉所有投机执行 的结果, 在正确的位置,重 新开始取指令的过程。这样做会引起预测错误处罚,因为在产生有用的结果之前,必须重新填充 指令流 水线。\n在 3. 6. 6 节中我们看 到, 最近的 x8 6 处理器(包含所有可以执行 x86- 64 程序的处理器)有条件传 送指令。在编译条件语句和表达式的时候, GCC 能产生使用这些指令的代码,而 不是更传统的基于控制的条件转移的实现。 翻译成条 件传送的基本思想是计算出一个条件 表达式或语句 两个方向上的值, 然后用 条件传送选 择期望的值。在 4. 5. 7 节中我们看到 , 条件传送指令 可以被实现为普通指 令流水线 化处理的一部分。没有必要猜测条件是否满足 , 因此猜测错误也 没有处罚。\n那么一个 C 语言程序员怎么能够保证分支预测处罚不 会阻碍程序的效 率呢?对于参考机来说, 预测错误处罚是 19 个时钟周期, 赌注很高。对于这个问题没有简单的答案, 但是下面的通用原则是可用的。\n1 不要过分关心可预 测的分支\n我们已经 看到错误的分支预测的 影响可能非常 大, 但是这并不意味着所有的程序分支都会减 缓程序的 执行。实际上, 现代处理器中的分支预测 逻辑非常善于辨别不同的分支指令的有规律的模式和长期的趋势。例如,在合并函数中结束循环的分支通常会被预测为选 择分支, 因此只在最后一次会导致预测错误处罚。\n再来看另一个例子, 当从 c ombi n e 2 变化到 c ombi ne 3 时, 我们把 函数 ge t _v e c _e l e ­ m ent 从函数的内 循环中拿了出来, 考虑一下我们观察 到的结果, 如下所示:\n函数 方法 整数 浮点数 + 骨 + * combi ne 2 combine3 移动 ve c_l e ng t h 直接数据访问 7. 02 9. 03 7. 17 9. 02 9. 02 11. 03 9. 0 2 11. 03 CPE 基本上没变, 即使这个转变消除了每次迭代中用 于检查向量索引是否在界限内的两个条件语句。对 这个函数来说, 这些检测总是确定索引 是在界内的, 所以是高度可预测的 。作为一种测试边界检查 对性能影响的方法, 考虑下面的合并代码 ,修 改 c ombi n e 4 的\n内循环,用 执行 g e t _ v e c _ e l e rne 江 代码的 内联函数结果替换对数据元素的访问。我们称这个新版本为 c o mb i n e 4b 。这段 代码执行了边界检查, 还通过向晕数据结构来引用向量元素。\nI* Include bounds check in loop *I\n2 void comb i ne 4 b ( ve c _p tr v, d at a _t *dest)\n3 {\nlong i ; long length= vec_length(v); data_t acc = !DENT; 8 for (i = O; i \u0026lt; length; i++) { 9 1o if (i \u0026gt;= 0 \u0026amp;\u0026amp; i \u0026lt; v-\u0026gt;len) { acc = acc OP v-\u0026gt;data [i] ; 11 } 12 }\n13 *dest = acc; 14 }\n然后, 我们直接比较使用和不使用边界检查的 函数的 CPE :\n函数 方法 整数 浮点数 + * + * combi ne 4 co rnbi ne 4b 无边界检查 有边界检查 1. 27 3. 01 2. 02 3. 01 3. 01 5. 01 3. 01 5. 01 对整数加法来说, 带边界检测的版本会慢一点, 但对其他三种情况来说, 性能是一样的, 这些情况受限于它们各自的合并操作的延迟。执行边界检测所需 的额外计算可以与合并操作并行执行。处理器能够预测这些 分支的结果, 所以这些求值都不会对形成程序执行中关键 路径的指令的取指和处理产生太大的影响。\n2. 书写适合用条件传送实现的代码\n分支预测只对有规律的模式可行 。程序中的许多测试是完全不可预测的, 依赖于数据的任意特性, 例如一个数是负数还是正数。对千这 些测试, 分 支预测逻辑 会处理得很精糕。对千本质上无法预测的情况, 如果编译器能够产生使用条件数据传送而不是使用条件控制转移的代码,可 以极大地提高程序的性能。这不是 C 语言程序员 可以直接控制的,但是有些表达条 件行为的方法能够更直接地被翻译成条件传送, 而不是其他操作。\n我们发现 GCC 能够为以一种更 ”功能性的“风格书写的代码产生条件传送 ,在这种风 格的代码中, 我们用条件操作来计算值, 然后用这些值来更新程序状态, 这种风格对立于一种更 ”命令式的“ 风格, 这种风格中, 我们用 条件语句来有 选择地 更新程序状态。\n这两种风格也没有严格的规则, 我们用一个例子来说明。假设给定两个整数数组 a 和\nb, 对千每个 位置 i , 我们想将 a [ i ] 设置为 a 巨]和b 巨]中较小的那一个, 而将 b [ i ] 设置为两者中较大的那一个。\n用命令式的风格实现这个函数是检查 每个位置 i\u0026rsquo; 如果它们的顺序与我们想要的不同, 就交换两个元素:\nI* Rearrange two vectors so that for each i , b[i] \u0026gt;= a[i] *I\nv o i d mi nrnax 1 ( l ong a[], long b[], l ong n) { long i; 4 for (i = O; i \u0026lt; n; i++) {\n5 if (a [i] \u0026gt; b[i]) { long t = a[i]; a[i] = b[i]; b[i] = t;\n10\n在随机数据上测试这个函数,得 到 的 CPE 大约 为 13. 50, 而对千可预测的数据, CP E\n为 2. 5~3. 5, 其预测错误惩罚约为 20 个周期。\n用功能式的风格实现这个函数是计算每个位置 1 的最大值和最小值,然 后 将 这些值分\n别赋给 a[ i] 和 b[ i] :\nI* Rearrange two vectors so that for each i, b[i] \u0026gt;= a[i] *I void mirunax2(long a[], long b[], long n) { long i; 4 for (i = O; i \u0026lt; n; i++) { 5 long min= a[i] \u0026lt; b[i] ? a[i] : b[i]; 6 long max= a[i] \u0026lt; b[i] ? b[i] : a[i]; 7 a(i] = min; s b(i] = max; 9 } 10 } 对这个函数的测试表明无论数据是任意的, 还是可预测的, C PE 都 大 约 为 4. 0。(我们还检查 了产生的汇编代码,确 认 它确 实 使 用 了条件传送。)\n在 3. 6. 6 节中讨论过 ,不 是 所 有 的 条 件 行 为都能用条件数据传送来实现,所 以 无 可避免地在某 些情况中, 程序员不能避免写出会导致条件分支的代码, 而 对 于 这 些 条 件 分 支 , 处理器用 分支预测可能会处理得很糟糕。但是,正 如我们讲过的,程 序 员 方 面用一点点聪 明, 有时就能使代码更容易被翻译 成条件数据传送。这需要一些试验, 写 出函数的不同版本,然后 检查产生的汇编代码, 并 测 试 性 能 。\n讫 }练习题 5. 9 对于归 并排序的合并步 骤的传统的 实现 需要 三个 循环[ 98] :\n1 void merge (long src1[] , long src2[] , long dest [] , long n) { long i1 = O;\n3 long i2 = O;\n4 long id= O;\n5 while (i1 \u0026lt; n \u0026amp;\u0026amp; i2 \u0026lt; n) {\n6 if (src1[i1] \u0026lt; src2[i 2])\n7 dest [id++] = s r c1 [ i1 ++] ; else\n9 de s t [ i d++] = sr c 2 [ i 2++] ;\n10\n11 while (i1 \u0026lt; n) 1 2 dest [id++] = src1[i1++] ; 13 while (i 2 \u0026lt; n) 14 dest [id++] = src2[i 2++] ; 对于把 变量 il 和 i 2 与 n 做比较 导致的分 支, 有很 好的预 测性 能---唯一的预测错误\n发生在 它们 第 一次 变成错 误时。 另 一方面,值 sr c l [ il l 和 sr c 2 [ i 2 ] 之间的比 较(第6 行),对于通常的数据来说,都是非常难以预测的。这个比较控制一个条件分支,运 行在随 机数据 上时,得到 的 CP E 大约为 1 5. 0 ( 这里元 素的数 量为 2 n ) 。\n重写这段代码,使得可以用一个条件传送语句来实现第一个循环中条件语句(第\n6 ~ 9 行)的功能。\n5. 12 理解内存性能 # 到目前为止我们写的所有代码,以及运行的所有测试,只访问相对比较少量的内存。 例如,我 们都是在长度小于 1000 个元素的向扯上测试这些合并函数, 数据量不会超过8 000 个字节。所有的 现代处理器都包含一个或多个高速 缓存( ca c h e ) 存储器, 以对这样少量的存储器提供快速的访问。本节会进一步研究涉及加载(从内存读到寄存器)和存储(从 寄存器写到内存)操作的程序的性能,只考虑所有的数据都存放在高速缓存中的情况。在 第 6 章 , 我们会更详细地探究高速缓存是如何丁作的,它 们的性能特性, 以及如何编写充分利用高速缓存的代码。\n如图 5-11 所示, 现代处理器有专门的功能单元来执行加载和存储操作, 这些单元有内部的缓冲区来保存未完成的内存操作请求集合。例如,我们的参考机有两个加载单元, 每一个可以保存多达 72 个未完成的读请求。它还有一个存储单元, 其存储缓冲区能保存最多 42 个写请求。每个这样的单元 通常可以每个时 钟周期开始一个操作。\n5. 12. 1 加载的性能\n一个包含加载操作的程序的性能既依赖于流水线的能力,也依赖于加载单元的延迟。 在参考机上运行合并操作的实验中, 我们 看到除了使用 SIMD 操作时以外, 对任何数据类型组合和合并操作来说, CPE 从 没有到过 0. 50 以下。一个制约示例的 CPE 的因素是,对于每个被计算的元素,所有的示例都需要从内存读一个值。对两个加载单元而言,其每个\n时钟周期只能启动一条加载操作 , 所以 CPE 不可能小于 o. 50。对于每个被计算的元素必\n须加载 k 个值的应用, 我们不可能获得低千 k / 2 的 CP E ( 例 如参见家庭作业 5. 15) 。\n到目前为止,我们在示例中还没有看到加载操作的延迟产生的影响。加载操作的地址 只依赖于循 环索引 i\u0026rsquo; 所以加载操作不会 成为限制性能的关键路径的一部分。\n要确定一台机器上加载操作的延迟,我们可 I 1 typedef struct ELE {\n以建立由一系列加载操作组成的一个计算,一条加载操作的结果决定下一条操作的地址。作为一个例子, 考虑函数图 5-31 中的函数 l i s 七— l e n , 它计算一个链表的长度。在这个函数的循环中, 变扯 l s 的每个后续值依赖千指针引 用 l s - \u0026gt; n e x 七读出的值。测试表明函数 l i s t _ l e n 的 CPE 为\n4.00, 我们认为这直接表明了加载操作的延迟。\nstruct ELE *next; long data; } list_ele, *list_ptr; long list_len(list_ptr ls) { long len = O; while (ls) { len++; 要弄懂这一点,考虑循环的汇编代码:\nInner loop of 1 工s t _l en ls in %rdi, len in %rax\n10\n11\n12\n13 }\nls= ls-\u0026gt;next; return len;\n.13:\naddq $1, %rax\nl oop : I nrc\nement len\n图5-31 链表函数。其性能受限于\nmovq (%rdi), %rdi\nls= ls-\u0026gt;next\n加载操作的延迟\ntestq jne\n%rdi, %rdi\n.L3\nTest ls\nIf nonnull, goto loop\n第 3 行上的 mov q 指令是这个循环中关键的瓶颈。后 面寄存 器%r 中 的每个值 都依赖于加载操作的结果, 而加载操作又以 %r 土 中 的 值作为它的地址。因此, 直到前一次迭代的加载操作完成 , 下一次迭代的加载操作才能 开始 。这个函数的 CPE 等于 4. 00 , 是由加载操作的延迟决定 的。事实上 , 这个测试结果与文档中参考机的 L1 级 cach e 的 4 周期访问时间是一致的 , 相关内容将在 6. 4 节中讨论。\n5. 12. 2 存储的性能\n在迄今 为止所有的示例 中, 我们只分 析了大部分内存引 用都是加载操作 的函数,也 就是从内存位置读到寄存器中。与之对应的是存储 ( s to re ) 操作, 它将一个寄存器值写到内存。这 个操作的性能, 尤其是与加载操作的相互关系, 包括一些很细微的问 题。\n与加载操作 一样, 在大多数情况中, 存储操作能 够在完全流水线 化的模式中丁作, 每个周期 开始一条新的存储。例如, 考虑图 5-32 中所示的函数, 它们将一个长度为 n 的数组 de s t 的元素设置为 0。我们测 试结果为 CPE 等于 1. 00 。对于只具有单个存储功能单元的机器,这已 经达到了最佳情况。\nI* Set elements of array to O *I\nvoid clear_array(long *dest, long n) { long i;\nfor (i = O; i \u0026lt; n; i++)\ndest [i] = O;\n}\n图 5-32 将数组元素设置为 0 的函数。该代码 CPE 达到 1. 0\n与到目前 为止我们已经 考虑过的其他操作不同 , 存储操作并不影响任何寄存器值。因此, 就其本性来 说, 一系列存储操作 不会产生数据相关。只有加载操作会受存储操作结果的影响 , 因为只 有加载操作能从由存储操作写的那个位置读回值。图 5-33 所示的函数write r ead 说明了加载和存储操作之间可能的相互影响。这幅图也展示了该函数的两个示例执行 , 是对两元素数组 a 调用的,该 数组的 初始内容为—10 和 17 , 参数 c n t 等于 3。这些执行说明了加载和存储操作的一些细微之处。\n在图 5-33 的示例 A 中, 参数 s r c 是一个指向数组元素 a [0 l 的 指针, 而 d e s t 是一个指向数组元素 a [1 ] 的指针。在此种情况中, 指针引用 *sr c 的每次加载都会得到值—1 0。因此, 在两次迭代之后 , 数组元素就会分别保持固定为—10 和—9。从 sr c 读出的结果不受对 de s t 的写的 影响。在较大次数的迭代上测试这个示 例得到 CPE 等于 1. 3。\n在图 5-33 的示例 B 中,参数 sr c 和 de s t 都是指向数组元素 a [ OJ 的 指针。在这种情况中, 指针引用*sr c 的每次加载都会得到指针引用* de s t 的前次执行存储的值。因而, 一系列不断增加的值会被存储在这个 位置。通 常, 如果调用函数 wr i t e _r e a d 时 参数 sr c 和 de s t 指向同一个内存位置, 而参数 c n t 的值为 n\u0026gt; O, 那么净效果是将这个位置设置为 n- 1。这个示例说明了一个现象, 我们称之为写/读相 关 ( wr ite / read dependency)-­ 个内存读的结果依赖于一个最近的 内存写。我们的性能测试表明示例 B 的 CPE 为 7. 3。写/读相关导 致处理速度下降 约 6 个时钟周期。\nI* Write to dest, read from src *I\n2 void write_read(long *src, long *dst, long n)\n3 {\nlong cnt = n;\nlong val= O;\n6\nwhile (cnt) {\n*dst = val;\n9 val= (*src)+1;\n10 cnt\u0026ndash;·\n11 }\n12 }\n示例A: wr i t e _ r e a d ( \u0026amp;a [ O ] , \u0026amp;a [ l ] , 3 )\n示例B: wr i 七e _r e ad ( \u0026amp;a [ O] , \u0026amp;a [ 0 ] , 3 )\n图 5-33 写 和读内存位置的代码,以 及示例执行。这个函数突出的是当参数\ns r c 和 de s t 相等时,存 储 和加载之间的相互影响\n为了了解处理器如何区别这两种情况,以及为什么一种情况比另一种运行得慢,我们必 须更加仔细地看看加载和存储执行单元, 如图 5-34 所示。存储单元包含一个存储缓冲区 , 它 包 含巳经被发射到存储单元而又还没有完成的存储操作的地址和数据, 这里的完成包括更新数据高速缓存。提供这样一个缓冲区,使得一系列存储操作不必等待每个操作都更新 高速缓存就能够执行。当一个加载操作发生时,它必须检查存储缓冲区中的条目,看有没\n有地址相匹配。如果有地址相匹配(意味着在写的\n字节与在读的字节有相同的地址),它 就 取 出 相 应的数据条目作为加载操作的结果。\nGCC 生成的 wr i t e r e a d 内循环代码如下 :\nInner loop of 口r i t e_r ead\nsrc in %rdi, dst in %rsi, val in %rax\n.L3: l oop:\nI 加载单元\n存储单元\n存储缓冲区地址数据\n地址\n机器地址{\n数据\nmovq %rax, (%rsi) Write val to dst movq (%rdi), %rax t = *STC\naddq $1, %rax val = t+1\nsubq $1, 。%r dx cnt\u0026ndash; 图5-34\njne .L3 If!= 0, goto loop\n图 5-35 给出了这个循环代码的数 据流表示。\n指令 mo v q %r ax, ( % r s i ) 被翻译 成 两个 操作: S\n数据高速 缓存\n加载和存储单元的细节。存储单元包 含一个未执行的写的缓冲区。加载 单 元 必 须 检 查 它 的 地址是否与存储单元中的地址相符,以发现 写/读相关\naddr 指令计算存储操作的地址 , 在存储缓冲区 创建一个条目, 并且设置该 条目的地址字段。s _ d a t a 操作设置该 条目的数据字段。正如我们会看到的 , 两个计算是独立执行的, 这对程 序的性能来说很重要。这使得 参考机中不同 的功能单元来执行这些操作。\nr% a x I %rdi I %r s i I %r dx\n%r a x: I %rdi 1·% r s i I %rdx\n图 5-35 writer e ad 内循环代码的图 形化表示。第一个 movl 指令被译码两个独立的操作,计算存储地址和将数据存储到内存\n除了由于写和读寄存器造成的 操作之间的数据相关, 操作符右边的弧线表示这些操作隐含的相关。特别地, s —a d d r 操作的地址计算必须在 s —d a t a 操作之前。此外, 对指令movq ( %r d i ) , %r a x 译 码得到的 l o a d 操作必须检查所有未完成的 存储操作的地址, 在这个操作和 s _ a d dr 操作之间创建一个数据相关。这张图中 s _ d a t a 和 l o a d 操作之间有虚弧线。这个数据相关 是有条件的: 如果两个地址相同, l o a d 操作必须等待直 到 s—d a t a 将它的结果存放到 存储缓冲区中,但 是如果两个地址不同, 两个操作就可以独立地进行。\n图 5- 36 说明了 wr i t e _r e a d 内循环操作之间的数据相关。在图 5- 36a 中, 重新排列了操作, 让相关显得更清楚。我们标出了三个涉及加载和存储操 作的相关, 希望引起大家特别的注 意。标号为(1 ) 的弧线表示存储地址必须在数据 被存储之前计算出来。标号为( 2 ) 的弧线表示需要 l o a d 操作将它的地址与所有未完成的存储操作的地址进行比较。最后, 标号为 ( 3 ) 的虚弧线表示条件数据相关, 当加载和存储地址 相同时会出现。\nb ) 图 5-36 抽象 wr i t e r_ ead 的操作。我们首先 重新排列图 5-35 的操作(a)\u0026rsquo; 然后只显示\n那些使用一次迭代中的值 为下一次迭代产生新值的操作 C b)\n图 5-366 说明 了当 移走那些不直接影响迭代与 迭代之间数据流的 操作之后, 会发生什么。这个数据流图给出两个相关链:左边的一条,存储、加载和增加数据值(只对地址相 同的情况有效),右边的一条, 减小变量 c n t 。\n现在我们 可以理 解函数 wr i t e _ r e a d 的 性能特征了。图 5-37 说明的是内循环的多次迭代形成的数据相关。对于图 5-33 示例 A 的情况,有 不同的源和目的地址, 加载和存储操作可以独立进行 , 因此唯一的关键路径是由减少变量 c nt 形成的, 这使得 CP E 等于 1. 0。对于图 5-33 示例 B 的情况, 源地址 和目的地址相同, s _d a t a 和 l oa d 指令之间的数据相关使得关键路径的形成包括了存储、加载和增加数据。我们发现顺序执行这三个操作一共 需要 7 个时钟周期。\n示例A\n二\n关键路径\n示例B\n:丁\n亡\n图 5-37 函数 rw i t e_r ead 的数据流表示。当 两个地址不同时, 唯一的关键路径是减少 cnt\u0026lt;示例\nA ) 。当两个地址 相同时 , 存储、加载和增加数据的 链形成了 关键路径(示例 B)\n这两个例子说明 ,内 存操作的实现包括许多 细微之处 。对于寄存器操作, 在指令被译码成操作的时候, 处理器就可以 确定哪些指令 会影响其他哪些指令。另一方面,对 千内存操作,只有到计算出加载和存储的地址被计算出来以后,处理器才能确定哪些指令会影响 其他的哪些。高效地处理内存操作对许多程序的性能来说至关重要。内存子系统使用了很 多优化,例 如当操作可以独立地进行时 , 就利用这种潜在的并 行性。\n讫i 练习题 5. 10 作为 另 一个具有 潜在的加载-存储相互 影 响的代码 , 考虑下 面 的 函数, 它将 一个数组的内容 复制到另 一个数 组 :\nvoid copy_array(long *src, long *dest, long n)\n{\nlong i;\nfor (i = 0 ; i \u0026lt; n; i ++) dest [i] = src [i] ;\n}\n假设 a 是一个长 度为 1 00 0 的数组, 被初始化为 每个元素 a [ i ] 等于 1。\n调用 c o p y _ arr a y (a+l, a, 999) 的效果是什么?\n调用 c o p y _ arr a y (a , a +l , 9 9 9 ) 的效果是什么?\n我们的性能测试表明问题 A 调用的 CPE 为 1. 2( 循环展开因子为 4 时 , 该值下降到\n0 )\u0026rsquo; 而问题 B 调用 的 CPE 为 5. 0 。 你认为是什么 因 素造成 了这样的性能差 异? 你预计调用 c o p y_ a rr a y (a , a , 99 9 ) 的性能会是怎样的?\n讫; 练习题 5 . 11 我们 测量 出前置和函 数 p s u ml ( 图 5-1 ) 的 CPE 为 9. 00 , 在测试机器上, 要执 行的基本操作一 浮点加法 的延迟只是 3 个时钟周期。 试着理 解为 什 么 我们 的 函数执行效果这么差。\n下面是 这个函数内循 环的 汇编 代码 :\nInner loop of psum1\na in %rdi, i in %rax, cnt in %rdx\n. L5 :\nvmovss vaddss vmovss addq cmpq jne\n-4(%rsi,%rax,4), %xmm0 (%rdi,%rax,4), %xmm0, %xmm0\n%xmm0, (%rsi,%rax,4)\n$1, %rax\n%rdx, %rax\n. L5\nl oop:\nGe t p [ i 一 1]\nAdd a[i] Store at p[i] Increment i Compare i:cnt\nIf !=, goto loop\n参考对 c ombi ne 3( 图 5- 1 4 ) 和 wr 迂 e _ r e a d ( 图 5- 36) 的分析, 画 出这 个循环 生成 的数据相 关图 , 再画出 计算进行 时由 此形 成的关键 路径。 解释 为 什么 CPE 如此之 高。\n江 练习 题 5. 12 重写 p s u ml ( 图 5-1 ) 的代 码 , 使之 不 需 要 反复地从内存 中读取 p [ i ] 的值。 不需要使 用循环展开。 得到 的代 码 测 试 出 的 CPE 等 于 3. 00, 受浮点加法延迟的限制。\n13 应用: 性能提高技术\n虽然只考虑了有限的一组应用程序,但是我们能得出关千如何编写高效代码的很重要 的经验教 训。我们已经描述了许多优化程序性能的基本策略:\n高级 设 计。为遇到的问题选择适当的算法和数据结构。要特别警觉, 避 免 使 用 那些会渐进地产生糟糕性能的算法或编码技术。 基本编码原则。 避免限制优化的因素, 这样编译器就能产生高效的代码。 消除连续的函数 调用。在可能时,将 计 算移到循环外。考虑有选择地妥协程序的模块性以获得更大的效 率。 消除不必要的内存引用。引入临时变量来保存中间结果。只有在最后的值计算出来 时, 才将结果存放到数组或全局变量中。 3 ) 低级优化。结构化代码以利用硬件功能。\n展开循环,降低开销,并且使得进一步的优化成为可能。 通过使用 例如多个累积变量和重新结合等技术 , 找到方法 提高指令级并行。 用功能性的风格重写条件操作,使得编译采用条件数据传送。\n最后要给读者一个忠告,要警惕,在为了提高效率重写程序时避免引入错误。在引入 新变蜇、改变循环 边界和使得代码整体上更复杂时, 很容易犯错误。一项有用的技术是在优化函数时,用检查代码来测试函数的每个版本,以确保在这个过程没有引入错误。检查 代码对函数的新版本实施一系列的测试, 确保它们产生与原来一样的结果。对于高度优化的 代码 , 这组测试情况必须变得更 加广泛, 因为要考虑的情 况也更多。例如, 使用循环展开的检查代码需要测试许多不同的循环界限,保证它能够处理最终单步迭代所需要的所有 不同的可能的数字。\n5. 14 确认和消除性能瓶颈\n至此,我们只考虑了优化小的程序,在这样的小程序中有一些很明显限制性能的地方, 因此应该是集中注意力 对它们进行优化。在处理大程序时, 连知道应该优化什么地方都是很难的。本节会描述如何使用代码剖析程序 ( code profiler) , 这是在程序执行时收集性能数据的分 析工具。我们还展示了一个系统优化的通用原则, 称为 A mda hl 定律( A m­ dahl\u0026rsquo;s law), 参见 1. 9. 1 节。\n14. 1 程序剖析\n程序剖析 ( pro fil ing ) 运行 程序的一 个版本, 其中插入了工具代码, 以确定程序的各个部分需要多少时间。这对于确认程序中我们 需要集中注意力优化的部分是很有用的。剖析的 一个有力之处在千可以在现实的基准数据( be nchm a r k da ta ) 上运行实际程序的同时,进 行剖析。\nU nix 系统提供了一个剖析程序 GPROF。这个程序产生两种形 式的信息。首先, 它确定 程序中每个函数花费了多少 CP U 时间。其次, 它计算每个 函数被调用的次数, 以执行调用的函数来分类。这两种形式的信息都非常有用。这些计时给出了不同函数在确定整体 运行时间中的相对重要性。调用信息使得我们能理解程序的 动态行为。\n用 GPROF 进行 剖析需要 3 个步骤, 就像 C 程序 pr og . c 所示, 它运行时命令行参数\n为 f i l e . t xt :\n) 程序必须为剖析而编译和链接。使用 GCC( 以及其他 C 编译器), 就是在命令行上简单 地包括运行时标志 \u0026quot; - pg\u0026quot; 。确保编译器不通过内 联替换来尝试执行任何优化是很重要的,否则就可能无法正确刻画函数调用。我们使用优化标志- Og , 以保证能正确跟踪函数调用。\nlinux\u0026gt; gee -Og -pg prog.e -o prog\n然后程序像往常一样执行:\nlinux\u0026gt; ./prog t il e. txt\n它运行得会比正常时稍微慢一点(大约慢 2 倍), 不过除此之外唯一的区别就是它产生了 一个文件 grno n . o u t 。\n3 ) 调用 GPROF 来分析 grno n . o 江 中的数据。\nlinux\u0026gt; gprof prog\n剖析报告的第一部分列出了执行各个函数花费的时间,按照降序排列。作为一个示 例, 下面列出了报告的一部分, 是关于程序中最耗费时间的三个函数的:\no。/ cumul at i ve time seconds self seconds calls self s/call total s/call name 97.58 203.66 203.66 1 203.66 203.66 sort_words 2.32 208.50 4.85 965027 0.00 0.00 find_ele_rec 0.14 208.81 0.30 12511031 0.00 0.00 Strlen 每一行代 表对某个 函数的所有调用所花费的时间。第一列表明花费在这个函数上的时间占整 个时间的百分 比。第二列显示的是 直到这一行并 包括这一行的函数所花费的累计时间。第三列显示的是 花费在这个函数上的时间, 而第四列显示的是它被调用的次数(递归调用不 计算在内)。在例子中, 函数 s or t _ wor d s 只 被调用了一次, 但就是这一次调用需 要 203. 66 秒, 而函数 f i n d_ e l e _ r e c 被调用了 965 0 27 次(递归调用不计算在内),总 共需 要 4. 8 5 秒。 函数 S 七r l e n 通过调用库函数 s tr l e n 来计算字符串的长度。GPROF 的结果中通常不显示库函数调用。库函数耗费的时间通常计算 在调用 它们的函数内。通过创建这个“包 装函数( w ra p pe r fu nct io n ) \u0026quot; S 七r l e n , 我们可以 可靠地跟踪 对 s t r l e n 的调用, 表明它被 调用了 1 2 511 0 31 次, 但是一共只需 要 0. 30 秒。\n剖析报告的第二部分是函数的调用历史 。下面是一个递归函数 f i nd _e l e _r e c 的历史:\n[5] 2.4\n这个历史既显示了调用 f i n d_ e l e _r e c 的函数, 也显示了它调用的函数。头两行显示的是对这个 函数的调用: 被它自身递归地调用了 158 655 72 5 次,被 函数 i n s er t _ s tr i n g 调用了 9\u0026amp;5 0 27 次(它本身被调用 了 965 0 27 次)。函数 f i n d _ e l e _r e c 也调用了另外两个函数 s ave _ s tr i n g 和 n e w_ e l e , 每个函数总共被调用了 3 63 039 次。\n根据这个调用信息,我们通常可以推断出关于程序行为的有用信息。例如,函数 釭nd_e l e _r e c 是一个递归过程,它 扫描一个哈希桶( ha s h b uck et ) 的链表,查 找一个特殊的字符 串。对于这个函数, 比较递归调用的数量和顶层调用的 数量, 提供了关千遍历 这些链表的长度的统计信息。这里递归与顶层调用的比率是 1 64. 4, 我们可以推断出程序每次\n平均大约扫描 1 64 个元素。\nGPROF 有些属 性值得注意 :\n计时不是很准确。它的计时基于一个简单的间隔计数 ( interval co un ting ) 机制, 编译过\n的程序为每个函数维护一个计数器, 记录花费 在执行该 函数上的时间。操作系统使得每隔某个规则的时间间隔 o, 程序被中 断一次。8 的典型值的范围为 l. 0 ~ 10. 0 毫秒。\n当中断发生时, 它会确定程序正在执行 什么函数, 并将该函数的计数器值增加 8。当\n`\n然,也可能这个函数只是刚开始执行,而很快就会完成,却赋给它从上次中断以来整个\n的执行花费。在两次中断之间也可能运行其他某个程序,却因此根本没有计算花费。\n对千运行时间较长的程序,这种机制工作得相当好。从统计上来说,应该根据 花费在执行 函数上的相对时间来计算每个 函数的花费。不过, 对于那些运行 时间少于 1 秒的程序来说, 得到的统计数字只能 看成是粗略 的估计值。\n假设没有执行内联替换,则调用信息相当可靠。编译过的程序为每对调用者和被调 用者维护一个计数器。每次调用一个过程时 , 就会对适当的计数器加 1 。 默认情况下,不会显示对库函数的计时。相反,库函数的时间都被计算到调用它们 的函数的时间中。 14. 2 使用剖析程序来指导优化\n作为一个用剖析程序来指导程序优化的示 例, 我们创建 了一 个包括几个不同任务和数据结构的应用。这个应用分析一个文本文档的 订g ra m 统计信息, 这里 n-g ra m 是一个出现在文档中 n 个单词的序列。对于 n = l , 我们收集每个单词的统计信息, 对于 n = 2 , 收集每对单词的 统计信息, 以此类推。对于一个给定的 n 值, 程序读一个文本文件, 创建一张互不相同的 n-gra m 的表, 指出每个 n-gra m 出现了多少次, 然后按照出现次数的降序对单词排序。\n作为基 准程序 , 我们在一个由《莎士比亚全集》组成的文件上运行这个程序,一共有965 028 个单词 , 其中 23 706 个是互不相同的。我们发现, 对于 n = I , 即使是一个写得很烂的分析程序也能在 1 秒以内处理完整个文件, 所以我们设置 n = Z, 使得事情更加有挑战。对于 n = Z 的情况, n- gra m 被称为 bigram ( 读作 \u0026quot; bye-gra m\u0026quot; ) 。我们确定《莎士比亚全集》包含 363 039 个互不相同的 bigra m。最常见的是 \u0026quot; I am\u0026quot;, 出现了 1892 次。词组 \u0026ldquo;to\nbe\u0026rdquo; 出现了 1020 次。bigr am 中有 266 018 个只出现了一 次。\n程序是由下列部分组成的。我们创建了多个版本 , 从各部分简单的算法开始 ,然后再换成更成熟完善的算法:\n从文件中读出每个单词, 并转换成小写字母。我们最初的版本使用的是函数 l owed\n(图 5-7 ) , 我们知道由于反复地调用 s tr l e n , 它的时间复杂度是二次的。\n对字符串应用一个哈希函数, 为一个有 s 个桶( bucket ) 的哈希表产生一个 O~ s—l\n之间的数 。最初的函数只是简单地对字符的 ASCII 代码求和,再 对 s 求模。\n) 每个哈希桶 都组织成一个链表。程序沿着这个链表扫描, 寻找一个匹配的条目, 如果找到了, 这个 n-gra m 的频度就加 1。否则, 就创建一个新的链表元素。最初的版本递归地完成这个操作,将新元素插在链表尾部。\n) 一旦已经生成了这张表, 我们就根据频度对所有的元 素排序。最初的版本使用插入排序。\n图 5-38 是 兀gra m 频度分析程序 6 个不同版本的剖析结果。对千每个版本, 我们将时间分为下面的 5 类。\nSort: 按照频度对 n-gram 进行排序\nList: 为匹配 n-g ra m 扫描链表, 如果需要, 插入一个新的元素\nLower: 将字符串转换为小写字母\nStrlen: 计算字符串的长度\nHash: 计算哈希函数\nRest: 其他所有函数的和\n如图 5-38a 所示, 最初的版本需要 3. 5 分钟, 大多数时间花在了排序上。这并不奇怪, 因为插入排序有二次 的运行时间, 而程序对 363 039 个值进行排序。\n在下一个版本中 , 我们用库函数 qs or t 进行排序, 这个函数是基于快速排序算法的\n[ 98] , 其预期运行时 间为 O( nlogn ) 。在图中这个版本称为 \u0026quot; Q uicksort\u0026quot; 。更有效的排序算\n第 5 章 优 化 程序 性 能 391\n法使花在排序上的 时间降低到可以忽略不计, 而整个运行时间降低到大约 5. 4 秒。图 5-38b\n是剩下各个版本的时间,所用的比例能使我们看得更清楚。\n250\n200L 尸\n,沪 150 +\u0026ndash;\n记u 100士 —\n50 +—\n。\n6\n5\ni 4 # Initial\n-Quicksort -Iter first\nlter last Big table Better hash Linear lower\na ) 所有的版本\nU二p.. 3\n2\nQuicksort\nIter first\nlter last Big table Better hash Linear lower\nb ) 除了最慢的版本外的所有版本\n图 5-38 big ram 频度计数程序的各个 版本的剖析结果。时间是 根据程序中 不同的 主要操作划分的\n.改进了排序,现在发现链表扫描变成了瓶颈。想想这个低效率是由于函数的递归结构 引起的, 我们用一个 迭代的结构替换它, 显示为 \u0026quot; It e r fi r s t \u0026quot; 。令人奇 怪的是, 运行时 间增加到了大 约 7. 5 秒。根据更近一步的研究,我 们发现两个链表函数之间有一个细微的差 别。递归 版本将新元 素插入到链 表尾部 , 而迭代版本 把它们插到链表头部。为了使性能最大化, 我们希望频 率最高的 n- g ra m 出现在链表的开始处。这样一来, 函数就能快速地定位常见 的情况。假设 n- g r a m 在文档中是均匀分布的 , 我们期望频度高的单词的第一次出现在频度 低的单词之前。通过将新的 n- g ra m 插入尾部, 第一个函数倾向于按照频度的降序排序,而第二个函数则相反。因此我们创建第三个链表扫描函数,它使用迭代,但是将 新元素插 入到链表的尾部。使用这个版本, 显示为 \u0026quot; lt er last\u0026quot;, 时间降到了大约 5. 3 秒, 比递归版本稍微 好一点。这些测量展示了对 程序 做实验作 为优化工 作一部分的重要性。开始时,我们假设将递归代码转换成迭代代码会改进程序的性能,而没有考虑添加元素到链 表末尾和开头的差别。\n接下来 ,我们 考虑哈希表的结 构。最初的版本只有 10 21 个桶(通常会选择桶的个数为质数,以 增强哈希 函数将关键字均匀分布在桶中的 能力)。对于一个有 363 039 个条目的表来说 , 这就意味着平均负 栽(l oad ) 是 363 039 / 1021 = 355. 6。这就解释了为什 么有那么多时间花在了 执行链表操作 上了一 搜索包括测试大量的候选 n- g ra m 。它还解释了为什么性能对链表的 排序这么敏感。然后, 我们将桶的数量增加到了 199 999 , 平均负载降低到了\n8。不过, 很奇怪的是, 整体运行时间 只下降到 5. 1 秒, 差距只有 0. 2 秒。\n进一步观察,我们可以看到,表变大了但是性能提高很小,这是由于哈希函数选择的 不好。简单地对字符串的字符编码求和不能产生一个大范围的值。特别是,一个字母最大 的编码值是 122 , 因而 n 个字符产生的和最多是 122n 。在文档中, 最长的 big ra m( \u0026quot; honor­ 巾 ca b小t udinita ti bus t ho u\u0026quot; ) 的和也不过是 3371 , 所以,我们哈希表中大多数桶都是不会被使用的。此外,可交换的哈希函数,例如加法,不能对一个字符串中不同的可能的字符顺 序做出区分。例如, 单词 \u0026quot; ra t\u0026quot; 和 \u0026quot; t ar\u0026quot; 会产生同样的和。\n我们 换成一个使用移位和异或操作的哈希函数。使用这个版本, 显示为 \u0026quot; Better\nHash\u0026quot;, 时间下降到了 0. 6 秒。一个更加系统化的 方法是更加仔细地研究关键字在桶中的分布,如果哈希函数的输出分布是均匀的,那么确保这个分布接近于人们期望的那样。\n最后,我 们把运行时间降到了大部分时间是花在 s tr l e n 上, 而大多数对 s tr l e n 的调用是作为小写字母转换的一部分。我们已 经看到了函数 l o wer l 有二次的性能, 特别是对长字符串来说。这篇文档中的单词足够短,能避免二次性能的灾难性的结果;最长的 bigra m 只有 32 个字符 。不过换成使用 l ower 2 , 显示为 \u0026quot; L inea r Low e r \u0026quot; 得到很好的性能, 整个时间降 到了 0. 2 秒。\n通过这个练习,我们展示 了代码剖析能够帮 助将一个简单应用程序所需的时间从 ,3. 5 分 钟降低到 0. 2 秒, 得到的性 能提升约为 1000 倍。剖析程序帮助我们把注意 力集中在程序最耗时的部分上,同时还提供了关于过程调用结构的有用信息。代码中的一些瓶颈,例 如二次的排序函数,很容易看出来;而其他的,例如插入到链表的开始还是结尾,只有通 过仔细的分析才能看出。\n我们可以看到,剖析是工具箱中一个很有用的工具,但是它不应该是唯一一个。计时测量不是很准确 , 特别是对较短的运行时间(小于 1 秒)来说。更重要的是 ,结 果只适用于被测试的那些特殊的数据。例如,如果在由较少数扯的较长字符串组成的数据上运行最初的函 数,我们会发现小写字母转换函数才是主要的性能瓶颈。更糟糕的是,如果它只剖析包含短单词的文档, 我们可能永远 不会发现隐藏着的性能瓶颈, 例如 l ower l 的二次性能。通常, 假设在有代表性的数据上运行程序,剖析能帮助我们对典型的情况进行优化,但是我们还应该确保对所有可能的 情况,程序 都有相当的性能。这 主要包括避免得到糟 糕的渐近性能 (as­ ymptotic performance) 的算法(例如插入算法)和坏的编程实践(如例 l owe rl ) 。\n9. 1 中讨论了 Amdahl 定律, 它为通过有针对性的优化来获取性能提升提供了一些其他的见解。 对千 n-gra m 代码来说 , 当用 q uickso r t 代替了插入排序后,我 们看到总的执行 时间从 209. 0 秒下降到 5. 4 秒。初始版本的 20 9. 0 秒中的 203. 7 秒用于执行插入排序, 得到 a = 0. 974 , 被此次优化加速的时间比例。使用 q uicksort, 花在排序上的时间变得微不足道,得到 预计的 加速比为 209/ a = 39 . O, 接近千测量加速比 38. 5。我们之所以能获得大的加速比,是因为排序在整个执行时间中占了非常大的比例。然而,当一个瓶颈消除, 而新的瓶颈出现时,就需要关注程序的其他部分以获得更多的加速比。 5. 15 小结\n虽然关于代码优化的大多数论述都描述了编译器是如何能生成高效代码的,但是应用程序员有很多方 法来协助编译器完成这项任务 。没有 任何编译器能用一 个好的算法或数据结构代替 低效率的算法或数据结构, 因此程序设计的这些方面仍然应该是程序员 主要关心的 。我们 还看到 妨碍优化的 因素, 例如内存别名使用 和过程调用 , 严重限制了编译器执行大量优化的能力。同样, 程序员 必须对消除这些妨碍优化的因素负主要的责任。这些应该被 看作好的编程习惯的一部分, 因为它们可以用来消除不必要的工作。\n基本级别之外调整性能需要一些对处 理器微体系结构的理解,描 述处理器用来实现它的指令集体系结构的底 层机制。对千乱序处理器的情况,只 需 要 知 道一些关千操作、容量、延迟和功能单元发 射时间的信息 , 就能够基本地预测程序的性能了。\n我们研究了一系列技术,包括循环展开、创建多个累积变榄和重新结合,它们可以利用现代处理器 提供 的指 令级并 行 。随 着对 优化的深入,研究 产 生 的 汇编代码以及试着理解机器如何执行计算变得重要起来 。确认 由程序中的数据相关决定的关键路径, 尤其是循环的不同迭代之间的数据相关 ,会 收获良多。我们还可以 根据必 须要计算的操作数量以及执行这些操作的功能单元的数噩和发射时间, 计 算 一 个 计 算的吞吐扯 界限。\n包含条 件分支或与内存系统复杂交互的程序, 比我们最开始考虑的简单循环程序,更 难 以 分 析和优化。基本策 略是使分支更容易 预测,或 者使它们很容易用条件数据传送来实现。我们还必须注意存储和加载操 作。将数值保存在局部变量中,使 得 它 们可以存放在寄存器中 , 这会很有帮助。\n当处理大型程序时 ,将 注意力集中在最耗时的部分变得很重要。代 码剖析程序和相关的工具能帮助我们系 统地评价 和改进程序性 能。我们描述了 GP RO F , 一个标准的 U nix 剖 析工具。还有更加复杂完善的剖析 程序可用 ,例 如 Intel 的 VT U NE 程序开发系统, 还有 Linux 系统 基本上都有的 V ALGRIND。这些工具可以在过程级分韶执行时间,估 计 程序每个基本块( basic block ) 的性能。(基本块是内部没有控制转移的指令 序列,因 此基本块 总是 整个 被执行的。)\n参考文献说明 # 我们的关注点是从程序员的角度描述代码优化,展示如何使书写的代码能够使编译器更容易地产生 高效的代码。Chellappa 、F ranchetti 和 P uschel 的扩展的论文[ 19] 采用了类似的方法,但 关 于处 理 器的特性描述 得更详细。\n有许 多著作从编译器的角度描述了代码优化, 形 式 化 描 述 了 编 辑器可以产生更有效 代码的方法。Muchni ck 的著作被认为是最全面的[ 80] 。Wad leig h 和 Cra wfo r d 的关于软件优化的著作[ 115] 覆盖了一些 我们已经谈到的内容, 不 过它还描述了在并行机器上获得高性能的过程。Mahlke 等人的一篇比较早期的论文[ 75] , 描述了几种为编译器开发的将程序映射到并行机器上的技术,它们是如何能够被改造成利用现代处理器的指令级并行的。这篇论文覆盖了我们讲过的代码变换,包括循环展开、多个累积变扯(他们 称之为 累积变量 扩展 ( accum ula tor va ria ble expans io n ) ) 和 重新结合(他们称之为树 高 度 减 少 ( t ree h e ig h t reduct io n) ) 。\n我们对乱序处理器的 操作的描述相当简单和抽象。可以 在高级计算机体系结构教科书中找到对通用原则更完整的 描述,例 如 H enness y 和 Pa tt erson 的著作[ 46 , 第 2~ 3 章]。S he n 和 L ipas t i 的 书[ 1 00 ] 提供了对现代处理器设计深人的论述。\n家庭作业 # •• 5. 13 假设 我们想编写一个计算两个向量 u 和 v 内积的过程。这个函数的一个抽象版本对整数和浮点数类型, 在 x86-64 上 C PE 等于 14 ~ 18 。 通过进行与我们将 抽象 程序 c ombi n e l 变换 为更有效的\nc ombi ne 4 相同 类型的变换, 我们得到如下代码:\nI* Inner product. Acc umul at e in t emp ro ar y *I\nvo i d i nner 4 ( ve c_ptr u, vec_ptr v, data_t *dest)\n{\nlong i;\nlong length= vec_length(u); data_t *udata = get _vec _s t ar t (u) ; data_t *Vdata = get_vec_start(v); data_t sum = (data_t) O;\nfor (i = O; i \u0026lt; length; i++) {\nsum= sum+ udata[i] * vdat a [i ] ;\n}\n*dest = sum;\n测试显示 , 对千整数这个函数的 CP E 等于 1. 50 , 对千浮点 数据 C PE 等于 3. 00 。对于数据类型 d o ub l e , 内循环的 x86-64 汇编代码如下 所示:\nInner loop of i nner 4. dat a_t = double, OP=,.\nudata in %r bp, vdata in %rax, sum in %xmm0 i in %rcx, lim1t in %rbx\n.L15:\nvmovsd vmulsd vaddsd addq cmpq jne\n0(%rbp,%rcx,8), %xmm1 (%rax,%rcx,8), %xmm1, %xmm1\n%xmm1, %xmm0, %xmm0\n$1, %rcx\n%rbx, %rcx\n.L15\nloop.\nGet udata[i] Multiply by vdata[i] Add to s 皿\nIncrement i Compare i : limit If 1=, goto loop\n5. 14 5. 15 5. 16 ** 5. 17\n假设功能单元的特性如图 5-12 所示。\n按照图 5-13 和图 5-14 的风格,画出这个 指令序列会如何被译码成操作, 并给出它们之间的数据相关如何形成一条操作的关键路径。\n对于数据类型 do ub l e , 这条关键路径决定的 CP E 的下界是什么?\n假设对于整数代码也有类 似的 指令序列 , 对于整数数据的关 键路径决定的 CPE 的下界是什么?\n请解释虽然乘法操作需要 5 个时钟周期, 但是为什么两个 浮点版本的 CP E 都是 3. 00。\n编写习题 5. 13 中描述的内 积过程的一个版本, 使用 6 X l 循环展开。对 于 x86-64 , 我们对这个展开的版本的测试 得到,对整数数据 CP E 为 1. 07, 而对两种 浮点数据 CP E 仍然为 3. 01 。\n解释为什么 在 Intel Core i7 H aswell 上运行的 任何(标盘)版本的内积过程都不能 达到比 1. 00 更小 的 C PE 了 。\n解释为什么对浮点数据的性能不会通过循环展开而得到提高。\n编写习题 5. 13 中描述的内积过程的一个版本 , 使用 6 X 6 循环展开。对 千 x86-6 4 , 我们对这个函数的测试得到对整数数据的 CP E 为 1. 06, 对浮点数据的 CP E 为 1. 01 。\n什么因素制约了性能 达到 CP E 等于 1. 00?\n编写习题 5. 13 中描述的内积 过程的一 个版本, 使用 6 X l a 循环展开产生更高的并行性。我们对这个函数的测试得到对 整数数据的 CP E 为 1. 10, 对浮点数 据的 CP E 为 1. 05 。\n库函数 me ms e t 的原型如下:\nvoid•memset(void•s, int c, size_t n);\n这个函数将从 s 开始的 n 个字节的内存区域都填 充为 c 的低位字节。例如,通 过将参数 c 设置为\n0, 可以用这个函数来对一个内存区域清零,不过用其他值也是可以的。下面是 me ms e t 最直接的实现 :\nI• Basic implementation of memset•I void•basic_memset(void•s, int c, size_t n)\n{\nsize_t cnt = O;\nunsigned char•schar = s; while (cnt \u0026lt; n) {\n•schar++ = (unsigned char) c; cnt++;\n}\nreturns;\n}\n实现该函数一 个更有效的 版本, 使用数据类型为 uns i g ne d l ong 的字来装下 8 个 C, 然后用字级的写遍历目标内存区域 。你可能发现增 加额外 的循环展 开会有所帮助。在我们 的参考机上, 能 够把 C P E 从直接实 现的 1. 00 降低到 o. 127。即, 程序每个 周期可以写 8 个字节。\n这里是一些 额外的 指导原则。在此,假 设 K 表示 你运行程序的 机器上的 s i ze o f (unsigned l o ng ) 的 值。\n**5. 18\n·: 5. 19\n你不可以调用任何库函数。\n你的代码应该 对任意 n 的值都能工作, 包括当它不是 K 的倍数的时候。你可以用 类似于使用循环展开时完成最后几次迭代的方法做到这一点。\n你写的代码应该无论 K 的值是多 少,都 能够正确编译 和运行。使用操作 s i ze o f 来做到这一点 。\n在某些机器上 , 未对齐的写可能比对齐的写 慢很多。(在某些非 x8 6 机器上 , 未对齐的写甚至可能会导致段错误。)写出这样的代码, 开始时 直到目的 地址是 K 的倍数时,使用 字节级的写,然后进行字级的写,(如果需要)最后采用用字节级的写 。\n注意 c n t 足够小以 至于一些 循环上界变成负数的情 况。对 千涉及 s i ze o f 运算符的 表达式 ,可以用无符号运算来执行测试。(参见 2. 2. 8 节和家庭作 业 2. 72。)\n在练习题 5. 5 和 5. 6 中我们考虑了多项式求值的任务,既 有直接求 值, 也有用 H orner 方法求 值。试着用我们讲过的优化技术写出这个函数更快的版本,这些技术包括循环展开、并行累积和重新 结合。你会发现有很多 不同的方法可以将 H o rner 方法和直接求值与这些 优化技术混合起来。\n理想状况下 , 你能达到的 CPE 应该接近于你的 机器的吞吐盘界限 。我们的 最佳版本在参 考机上能\n使 CPE 达到 1. 07。\n在练习题 5. 12 中 ,我们能 够把前置和计算 的 CPE 减少到 3. 00, 这是由该机器上浮点加法的延迟决定的。简单的循环展开没有改 进什么 。\n使用循环展开和重新结 合的组合,写 出求前 置和的代码, 能够得到一个小 于你机器上浮点 加法延迟的 CP E。要达到这个目标 , 实际上需要增加执行的加法次数。例如,我 们使用 2 次循环展开的 版本每次迭代需 要 3 个加法 , 而使用 4 次循环展开的版本需要 5 个。在参考机上, 我们的最佳实现能 达到 CPE 为 1. 67。\n确定你的机器的吞吐 益和延迟界限是 如何限制前 置和操作所能 达到的最小 CPE 的。\n练习题 答 案 # 5. 1\n5. 2\n5. 4\n这个问 题说明了内 存别名使用的 某些细微的 影响。\n正如下面加了注释的代码所示 ,结 果会是将 xp 处的值设置为 0 :\n•xp =•xp +•xp; /• 2x•/\n•xp= • xp -•xp; I• 2x-2x = 0•/\n•xp = • xp - • xp ; I• 0-0 = 0•I\n这个示例说明我们关 于程序行 为的直觉往往会是错误 的。我们自然地会认 为 xp 和 yp 是不同的情况,却忽略了它们相等的可能性。错误通常源自程序员没想到的情况。\n这个问 题说明了 CPE 和绝对性能之间的关 系。可以用初等代数解决这个问 题。我们发现对于 n 2 ,\n版本 1 最快。对于 3 n 7 , 版本 2 最快 , 而对于 n多8 , 版本 3 最快。\n这是个简单 的练习,但是认识到一个 f or 循环的 4 个语句(初始化、测试、更新和循环体)执行的次数是不同的很重要。\n代码 min max incr square A. l l 9 90 90 B. l 9 l 90 90 C. l l 90 90 这段汇编代码展示了 GCC 发现的一个 很聪明的优化 机会。要更 好地理解代码优化的细微之处, 仔细研究这段代码是很值得的。\n在没经过优 化的代码中,寄 存器%xrnm0 简单地被用 作临时值, 每次循环迭代 中都会设置和使用 。在经过更多 优化的代码中,它 被使用的 方式更像 c ombi ne 4 中的 变量 X , 累积向量元素的乘积。不过,与 c ombi ne 4 的区别 在于每次迭代第 二条 vmo v s d 指令都会更新位置 de s t 。\n我们可以 看到, 这个优化过的 版本运行起 来很像下 面的 C 代码:\nI* Make sure dest updated on each iteration *I\n2 void combi ne3 汃 vec _ptr v, data_t *dest)\n3 {\nlong i;\nlong length = vec_length(v);\ndata_t *data = get_vec_start(v);\ndata_t ace = IDENT;\nI* Initialize in event length \u0026lt;= 0 *I\n*dest = ace;\nfor (i = O; i \u0026lt; length; i++) {\nace = a c e OP data [i] ;\n14 *dest = a ce · 15\n16 }\nc o mbi ne 3 的两个版本有相同的功能, 甚至于相同的内存 别名使用。\n这个变换可以不 改变程序的行为 , 因为,除 了 第一次迭代, 每次迭代 开始时从 d e 江 读出的值和前一次迭代最后写入到这个寄存器的值是相同的。因此,合并指令可以简单地使用在循环开始 时就已经在%x mm0 中的值。\n5 多项式求值是解决许多问题的核心技术。例如, 多 项式函数常常用作对数学库中三角函数求近似值。\n这个函数执行 2n 个乘法和 n 个加法。 我们可以看到, 这里限制性能 的计算是反复地计算表达式 xp wr = x * xp wr 。这需要一个浮点数乘法( 5 个时钟周期),并且直到前 一次迭代完成 , 下一次迭代的 计算才能开始。两次连续的迭代之间 ,对r e s u l t 的更新只需要一个浮点加法( 3 个时钟周期)。 6 这道题说明了最小化一个计算中的操作数量不 一定会提高它的性能。\n这个函数执行 n 个乘法和 n 个加法 , 是原始函数 p o l y 中乘法数量的一半。\n我们可以看到 , 这里的性能限 制计算是反复地计算 表达式r e s u l 七=a [ i ) +x *r e s u l t 。从来自上一次迭代的r e s u l t 的值开始 , 我们必须先把它乘以 x ( 5 个时钟周期), 然后把它加上 a [习 (3 个时钟 周期), 然后得到本次 迭代的值 。因此, 每次迭代 造成了最小 延迟时间 8 个周期 , 正好等于我们 测最到的 CPE。\n虽然函数 p o l y 中每次迭代需 要两个乘法, 而不是一个,但是只有一条乘法是 在每次 迭代的关键路径上出现。\n5. 7 下面的代码直接遵循了 我们对 K 次展开一个循环所阐述的规则:\nvoid unroll5(vec_ptr v, data_t *dest)\n2 { 4 long i ; long length= vec_length(v); 5 long limit= length-4; 6 data_t *data= get_vec_start(v); data_t ace = IDENT; 8 9 I* Combine 5 elements at a time *I 10 for (i = O; i \u0026lt; limit; i+=S) { 11 ace= ace OP data[i] OP data[i+1]; 12 ace= ace OP data[i+2] OP data[i+3]; 13 ace= ace OP data[i+4]; 14 } 15 16 I* Finish any remaining elements *I 17 for (; i \u0026lt; length; i++) { 18 ace = ace DP data[i]; 19 } 20 *dest = a c e ; 21 } 5. 8 这道题目说明了程序中小小的改动可能 会造成很大的 性能不 同,特别是在乱序执行的机器上。图 5-39 画出了该函数一 次迭代的 3 个乘法操作。在这张图中 , 关键路径上的操作用黑 色方框表示 它们需要按照顺序计算 , 计算出循环变最r 的新值。浅色方框表示的操作可以与关 键路径操作并 行地计算。对于一个 关键路径上有 P 个操作的循环 , 每次迭代 需要最少 5 P 个时钟周期, 会计算出 3 个元素的乘积, 得到 C P E 的下界 5P / 3。也就是说, A l 的 下界为 5. 00 , A Z 和 A 5 的为 3. 33, 而 A 3 和\nA4 的为 1. 67。我们在 In t el Core i7 H as well 处理器上运行这些函数, 发现得到的 C P E 值与前述一致。\nAl : ((r*x) *y)*z A2: (r* (x*y)) *z A3: r* ( (x * y ) *z) A4 : r* (x* (y* z ) )\n, y i z l i r l x ! y l z '\n图 5- 39 对于练习题 5. 8 中各种情况乘法操 作之间的数据相关。用黑色方框表示的操作形成了迭代的 关键路径\n5. 9 这道题又说明了 编码风格上的小 变化能够让编译器更容易 地察觉到使用 条件传送的 机会:\nwhile (il \u0026lt; n \u0026amp;\u0026amp; i2 \u0026lt; n) { long vl = src1[i1];\nl ong v2 = src2[i2]; long take!= v1 \u0026lt; v 2 ;\ndest[id++] = take1? v1 : v2;\ni1 += take!;\ni2 += (1-take1);\n对于这个版本的代码,我 们测量到 CP E 大约为 1 2. 0 , 比原始的 C P E 1 5. 0 有了明显的提高。\n5. 10 这道题要求你分 析一个程序中潜在的加载-存储相互影响。\n. A. 对于 O i 9 98 , 它要将每个元 素 a [ i ] 设置为 i + l 。\n对于 l i 999 , 它要将每个元 素 a [ i ] 设置为 0 。\n在第二种情 况中, 每次迭代的 加载都依赖于前一次迭代的存储结果 。因此,在连 续的迭代之间有写/读相关。\n得到的 C P E 等于 1. 2\u0026rsquo; 与示例 A 的相同, 这是因为存储 和后续的加 载之间 没有相关。\n5. 11 我们 可以看到 , 这个函数在连续的 迭代之间有写/读相关 一次迭代 中的目 的值 p [ i ] 与下一次迭代中的 源值 p [ i - l ] 相同。因此, 每次迭代形成的关键路径就包括: 一次存储(来自前一次迭代), 一次加载和一次浮点加。当存在数 据相关时 , 测扯得 到的 C P E 值为 9. 0 , 与 wr i t e —r e a d 的 C P E 测益值 7. 3 是一致的, 因为 wr i t e _r e a d 包括一个整数加 (1 时钟周期延迟), 而 p s u ml 包括一个浮点加( 3 时钟周期延迟)。\n5. 12 下面是对这个 函数的一个修改版本:\nv oi d psum1a(float a[], float p[J , long n)\n2 {\nlong i;\n4 I* last_val holds p[i 一 1] ; val holds p[i] *I\nfloat last_val, val;\n6 last_val = p [OJ = a[O];\n7 for (i = 1; i \u0026lt; n; i++) {\n8 val = last_val + a [i] ;\n9 p[i] = val;\n10 last_val = va l ;\n12 }\n我们引 入了局部变量 l a s t _ va l 。在迭代 l. 的开始, l a s t —va l 保存着 p [ i - l ] 的值。然后我们计算va l 为 p [ i ] 的值, 也是 l a s t _ v a l 的新值。\n这个版本编译得到如下汇编代码:\nInner loop of psumla\na 立 % 过 i , i 1n r¼ ax , cnt i n 石 dx, last_ val in 7.xmmO\n.L16: l oop :\nvaddss (%rdi,%rax,4), %xmm0, %xmm0 l as t _val = val = l as t _val + a[i]\nvmovss %xmm0 ,\nCor。/\ns i , /,r ax , 4 ) Store val in p[i]\naddq $1, %rax Increment i\ncmpq %rdx, %rax Compare i : cnt\njne .L16 If !=, goto l oop\n这段代码将 l a s t _v a l 保存在%x mm0 中 , 避免了需要从内存中读出 p [ i 一l ] •\n看到的写/读相关。\n因而消除了 ps urnl 中\n"},{"id":442,"href":"/zh/docs/technology/System/%E6%B7%B1%E5%85%A5%E7%90%86%E8%A7%A3%E8%AE%A1%E7%AE%97%E6%9C%BA%E7%B3%BB%E7%BB%9F/%E4%B9%A6%E7%B1%8D/%E7%AC%AC6%E7%AB%A0-%E5%AD%98%E5%82%A8%E5%99%A8%E5%B1%82%E6%AC%A1%E7%BB%93%E6%9E%84/","title":"Index","section":"SpringCloud","content":"---\n第 6 章\nH A P T E R 6\n存储器层次结构 # 到目前 为止,在对系 统的研究中, 我们依赖千一 个简单的计算机系统 模型, C P U 执行指令,而 存储器系统为 CP U 存放指令和数据。在简单模型中, 存储器系统是一个线性的字节数组, 而 CP U 能够在一个常数时间内访问每个存储器位置。虽然迄今为止这都是一个有效的模型, 但是它没有反映现代系统 实际工作的方式。\n实际上, 存储器 系统( m em o r y s ys te m ) 是一个具有不同容量、成本和访问 时间的存储设备的层 次结构。CP U 寄存器保存着最常用的数据。靠近 C P U 的小的、快 速的 高速 缓存存储器 ( cache memor y) 作为一部分存储在相对慢速的 主存储器( main mem o r y ) 中数据和指令的缓冲区域。主存缓存存储在容量较大的、慢速磁盘上的数据,而这些磁盘常常又作为 存储在通过网络连接的其他机器的磁盘或磁带上的数据的缓冲区域。\n存储器层次 结构是可行的,这 是因为与下 一个更低层次的存储设备相比来说, 一个编写良好的程序倾向千更频繁地访问某一个层次上的存储设 备。所以, 下一层的存储设备可以更慢速 一点,也 因此可以更大, 每个比特位更便宜。整体效果是一个大的存储器池, 其成本与 层次结构底层最便宜的存储设备相当, 但是却以接近于层次结构顶部存储设备的高 速率向程序 提供数据。\n作为一个 程序员 , 你需要理解存储器层次结构, 因为它对应用程序的性能有着巨大的影响。如果你的程序需要的数据是存储在 C P U 寄存器中的 , 那么在指令的执行期间, 在 0 个周期内 就能访问 到它们。如果存储在高速缓存中,需 要 4 ~ 75 个周期。如果存储在主存中,需要上百个周期 。而如果存 储在磁盘上,需 要 大约几千万个周期!\n这里就是计算机系统中一个基本而持久的思想:如果你理解了系统是如何将数据在存 储器层次结构中上上下下移动的,那么你就可以编写自己的应用程序,使得它们的数据项 存储在层次结构 中较高的地方 , 在那里 CP U 能更快地访问到它们。\n这个思想围 绕着计算 机程序的一 个称为局部性 (l oca lit y ) 的基本属性 。具有良好局部性的程序倾向于一次又一次地访问 相同的数据项集合 , 或是倾向于访问 邻近的 数据项集合。具有良好局部性的程序比局部性差的程序更多 地倾向 于从存储器层次 结构中较高层次处访问数据项, 因此运行得更快。例如, 在 C ore i7 系统, 不同的矩阵乘法核心程序执行相同数量的算术操作, 但是有不同程度的局部性, 它们的运行时间可以 相差 40 倍!\n在本章中 , 我们会看看基本的存储技术一 SRA M 存储器、DRAM 存储器、ROM 存储器以及旋转的 和固态的硬盘一一-并描述它们是如何被组织成层次结 构的。特别地, 我们将注意力集中在高 速缓存存储器上, 它是作为 C P U 和主存之间的缓存区域, 因为它们对应用程序性能的 影响最大。我们向你展示如何分析 C 程序的 局部性, 并且介绍改进你的程序中局部性的技术。你还会学到一种描绘某台机器上存储器层次结构的性能的有趣方法, 称为“ 存储器山( memor y mountain)\u0026quot;, 它展示出读访问时间是局部性的一个函数。\n1 存储技术\n计算机技术的成 功很大程度上源自于存储 技术的 巨大进步。早期的计算机只有几千字\n节的随机访问存储器。最早的 IBM PC 甚至千没有硬盘。1982 年引入的 IBM PC-XT 有 l OM 字节的磁盘。到 2015 年,典 型的计算机巳有 300 000 倍于 PC-XT 的磁盘存储,而且磁盘的容量以每两年加倍的速度增长。\n1. 1 随机访问存储器\n随机访问存储 器( Ra ndom- Access Memory, R AM ) 分为两类 : 静态的 和动态的 。静态RA M CS RAM ) 比动态 R A M ( DRAM ) 更快, 但也贵得多。SR A M 用来作为高速缓存存储器,既 可以在 CP U 芯片上 , 也可以在片下。DR AM 用来作为主存以及图形系统的帧缓冲区 。典 型地, 一个桌面系统的 SRAM 不会超过几兆字节, 但是 DRA M 却有几百 或几千兆字节。\n静态 RAM\nSR AM 将每个位 存储在一个双稳 态的 ( bista ble ) 存储器单元里。每个 单元是用一个六晶体管电路来实现的。这个电路有这样一个属性,它可以无限期地保持在两个不同的电压 配置( config ur a tion ) 或状态( s tate) 之一。其他任何状态都是不稳定的一 从不稳定状态开始, 电路会迅速地转移到 两个稳定状态中的一个。这样一个存储器单元类似于图 6-1 中画出的倒转的钟摆。\n之 # 图 6-l 倒转的钟摆 。同 SRAM 单元一样,钟摆只有两个稳定的 配置或状态\n当钟摆倾斜到最 左边或最右边时 ,它 是稳定的。从其他任何位置, 钟摆都会倒向一边或另一边。原则上, 钟摆也能 在垂直的 位置无限期地保待 平衡 , 但是这个状态是亚稳 态的\n(metastable) 最细微的 扰动也能使它倒下, 而且一旦倒下就永远不会再恢 复到垂直的位置。\n由于 SRAM 存储器单元的双稳 态特性,只 要有电, 它就会永远地保持它的值。即使有干扰(例如电子噪音)来扰乱电压, 当干扰消除 时, 电路就会恢复到稳定值。\n2 动 态 RAM\nDR AM 将每个位存储 为对一个电 容的充电。这个电容非常小, 通常只有大约 30 毫微\n微法拉 Cfe mtofarad ) - —- 3 0 X 10- is 法拉 。不过, 回想一下法拉是一个非常大的计量单位.\nDR A M 存储器可以制造得 非常密集 每个单元由一个电容和一个访问晶体管组成。但是,与 SR AM 不同, D RAM 存储器单元对 干扰非常敏感。当电容的电压被扰乱之后,它就永 远不会恢复了。暴露在光线下会导致电容电压改变。实际 上, 数码照相机和摄像机中的 传感器本质上就是 DR AM 单元的阵列 。\n很多原因会导致漏电 , 使得 DR AM 单元在 10 ~ 100 毫秒时间内 失去电荷。幸运的是, 计算机运行的时钟周期是以纳秒来衡撒的 , 所以相对而言这个保持时间是比较长的。内存系统必须周期性地通 过读出, 然后重写来 刷新内存 每一位 。有些系统也使用纠错码, 其中计算机的字会被多编码几个位(例如 64 位的字可能用 72 位来编码), 这样一来, 电路可以发现并纠正一个字中任何单个的错误位。\n图 6-2 总结 了 SRAM 和 DRAM 存 储器的特性。只要有供电, SR AM 就会保持不变。与 DRAM 不同 , 它不需要刷新。SRAM 的 存取比 DRAM 快。SRAM 对诸 如光和电噪声这样的干扰不敏感。代价是 SRAM 单元 比 DRAM 单 元 使 用 更 多 的 晶 体 管 , 因 而 密集度低,而且更贵,功耗更大。\nSRAM DRAM\n传统的 D R A M\n了1二I :心:\n图 6-2 DR AM 和 SR AM 存储器的特性\nDRAM 芯片中的单元(位)被分成 d 个超单元( supercell) , 每个超单元都由 w 个 DRAM 单元组 成。一个 d X w 的 DRAM 总共存 储了 dw 位信息。超单元被组织成一个 r 行 c 列的长方形阵列, 这里 rc= d。每个超单元有形如Ci , j ) 的 地址,这 里 1 表示行,而 )表示列。\n例如,图 6-3 展示的是一个 16X 8 的 DRAM 芯片的组织,有 d = 16 个超单元, 每个超单元有 w= 8 位, r = 4 行 ,c= 4 列。带阴影的方框表示地址( 2\u0026rsquo; 1) 处 的 超 单 元 。 信 息 通过称为引脚 ( pin) 的外部连接器流入和流出芯片。每个引脚携带一个 1 位的信号。图 6-3 给出了两组引脚: 8 个 dat a 引脚,它们 能 传 送一个字节到芯片或从芯片传出一个字节,以 及 2 个 addr 引脚, 它们携带 2 位的行 和列超单元地址。其他携带控制信息的引脚没有显示出来。\nDRAM芯片\n;\n2 :\na/ddr►,:\n`\n列\n0 I 2 3\n. , 1-丁 I\n(佥到C氐PU吵)\n控内制存器 2\n3\n超单元\n( 2 , I )\nj\nd a 七a\n图 6-3 一个 128 位 16 X 8 的 DRA M 芯片的高级视图\nm 关千术语的注释\n存储领域从来没有为 DRAM 的阵列 元素确 定一个标准的 名 字。 计算机构架师倾向于称 之为 “ 单元“,使 这个术语 具有 DRAM 存储 单元 之 意。电路 设 计 者倾向 于称之为\n“宇",使之 具 有 主存一个字之 意。为 了避 免混淆, 我们采用 了无歧 义的术语“超单元”。\n每个 DRAM 芯片被连接到某个称为内存控制 器 ( memory cont roller) 的电路, 这个电路可以一次传送 w 位到每个 DRAM 芯片或一次从每个 DRAM 芯片传出 w 位。为了读出超单元( i , j ) 的内容,内 存控制器将行地址 t 发送到 DRAM , 然后是列地址 J。 DRAM 把 超单元( i , j ) 的内容发回给控制器作为响应。行 地址 t 称为 RAS ( Row Access Strobe, 行访间选 通脉冲)请求。列地址 ]称为 CAS ( Column Access Strobe, 列访问选通脉冲)请求。注意, RAS 和 CAS 请求共享相同的 DRAM 地址引脚。\n例如,要 从 图 6-3 中 16 X 8 的 DR AM 中读出超单元 ( 2 , 1), 内存控制器发送行地址\n2\u0026rsquo; 如 图 6-4a 所示。DRAM 的响应是将行 2 的整个内容都复制到一个内部行缓 冲区。接下来 ,内 存 控 制器发送列地址 1 , 如图 6-46 所示。DRAM 的响应是从行缓冲区复制出超单元 ( 2\u0026rsquo; 1) 中 的 8 位 ,并 把它们发送到内存控制器。\nDRAM芯片 DRAM芯片\n: 列 !\n内存\n控制器\naddr\ndata\n, \u0026hellip;\u0026hellip;\u0026hellip;\u0026hellip;\u0026hellip;\u0026hellip;\u0026hellip;\u0026hellip;\u0026hellip;\u0026hellip;..内\u0026hellip;\u0026hellip;部\u0026hellip;\u0026hellip;行\u0026hellip;\u0026hellip;缓\u0026hellip;\u0026hellip;冲..区\u0026hellip;\u0026hellip;\u0026hellip;\u0026hellip;\u0026hellip;\u0026hellip;.,\n、\u0026hellip;\u0026hellip;\u0026hellip;\u0026hellip;\u0026hellip;\u0026hellip;\u0026hellip;\u0026hellip;..内\u0026hellip;\u0026hellip;\u0026hellip;..行\u0026hellip;.缓\u0026hellip;..冲\u0026hellip;..区\u0026hellip;\u0026hellip;\u0026hellip;\u0026hellip;\u0026hellip;\u0026hellip;:\na ) 选择行 2 ( RAS请求)\nb ) 选择列 I ( CAS 请求) 图 6- 4 读 一 个 DRAM 超单元的内容\n电路设计者将 DRAM 组织成二维阵列而不是线性数组的一个原因是降低芯片上地址\n引 脚 的 数 晕 。 例 如 ,如 果 示 例 的 1 28 位 DRA M 被组织成一个 16 个超单 元的线 性数组,地址 为 0 ~ 15 , 那么芯片会需要 4 个地址引脚而不是 2 个 。二维阵列 组织的缺点是必须分两步 发 送 地址, 这增加了访问时间。\n内存模块\nDRAM 芯片封装在内存模 块( memory mod ule ) 中 , 它 插到主板的扩展槽上。Core i7\n系统使用的 240 个引脚的双列直插内存模块( Dua l lnline Memory Module, DIMM), 它以\n64 位为块传送数据到内存控制器和从内存控制器传出数据。\n图 6-5 展示了一个内存模块的基本思想。示例模块用 8 个 64 Mbit 的 8 M X 8 的 DRAM 芯 片 ,总共 存储 64MB(兆字节), 这 8 个芯片编号为 0~ 7。每个超单元存储主存的一个字节,而 用 相 应超 单元 地址 为(i\u0026rsquo; j ) 的 8 个超单元来表示主存中字节地址 A 处的 64 位字。在图 6-5 的 示例中, DRAM O 存储第一个(低位)字节, DRA M 1 存储下一个字节,依 此类 推。\n要取出内存地址 A 处的一个字,内 存 控制器将 A 转换成一个超单元地址( i\u0026rsquo; j )\u0026rsquo; 并将它 发 送 到 内 存 模 块 , 然 后 内 存 模 块 再 将 t 和 ] 广播 到 每个 DRAM。作 为响应, 每个DR A M 输出它的( i\u0026rsquo; j ) 超 单 元 的 8 位内容。模块中的电路收集这些输出,并 把 它们合并成一 个 64 位字,再 返 回 给内存控制器。\n通过将多个内存模块连接到内存控制器,能 够 聚 合 成 主 存 。 在 这 种 情 况 中 , 当控制器收 到 一 个 地 址 A 时 , 控制器选择包含 A 的模块 k\u0026rsquo; 将 A 转换成它的 ( i\u0026rsquo; j ) 的 形 式,并将( i\u0026rsquo; j ) 发 送 到 模 块 k。\n练习题 6. 1 接下来, 设 r 表 示 一个 DR AM 阵列 中的行数, c 表 示 列 数, br 表 示行寻址所需的位数,从 表 示 列 寻址所 需 的位数。 对于下 面 每个 DRAM , 确定 2 的 幕数的\n阵列 维数,使 得 max(rb , be ) 最小, ma x( rb\n较 大的值 。\n, b, ) 是对阵 列 的行或列 寻址所需的位数中\n组织 r C b, bC max(b,, b) 16X I 16X4 128X8 512X4 1024X4 addr (row= i, col= j)\n63 5655 4847 4039 3231 2423 1615 8 7 0\nI I I I I I I I I # 位于主存地址 A处的64位字\n口:超单元 ( i\u0026rsquo; 八\n由8个8M x 8的\nDRAM组成的64MB\n内存模块\n内存\n控制器\n增强的 DRAM\n图 6-5 读一个内存模块的内容\n有许多种 DRAM 存储器, 而生产厂商试图跟上迅速增长的处理器速度, 市场上就会定期推 出新的种类。每种都是基于传统的 DRAM 单元, 并进行一些优化, 提高访问基本\nDRAM 单元的速度。\n快页模 式 DRAM CFast Page Mode DRAM, FPM DRAM ) 。传统的 DRAM 将超单元的 一整行复制到它的内部行缓 冲区中, 使用一个, 然后丢弃剩余的。FPM\nDRAM 允许对同一行连续地访问可以 直接从行缓冲区 得到服务,从 而改进了这一点。例如,要从 一个传统的 DRAM 的行 t 中读 4 个超单元,内 存控制器必须发送 4 个 RAS / CAS 请求, 即使是行地址 1 在每个情况中都是一样的。要从一个 FPM DRAM 的同一行中读取超单元,内 存控制器发送第一个 RAS/ CAS 请求, 后面跟三个 CAS 请求。初始的RAS/ CAS请求将行1 复制到行缓冲区, 并返回 CAS 寻址的那个超单元。接下来三个超单元直接从行缓冲区获得,因此返回得比初始的超单元更快。\n扩展数据捡出 DRAM ( Extended Data Out DRAM, EDO DRAM) 。FP M DRAM 的一个增强的形式,它允 许各个 CAS 信号在时间上靠得更紧密一点 。\n同步 DRAM(Synchronous DRAM, SDRAM)。就它们与内存控制器通信使用一组显式的控制信号来说, 常规的、FPM 和 EOO DRAM 都是异步的。SDRAM 用与驱动内存控制器相同的外部时钟信号的上升沿来代替许多这样的控制信号。我们不会深入讨论细节, 最终效果就是SDRAM能够比那些异步的存储器更快地输出它的超单元的内容。\n双倍 数据速 率 同 步 DRAM ( Double Data-Rate Synchronous DRAM, DOR SDRAM )。DDR SDRA M 是对 S DRA M 的一种增强, 它通过使用两个时钟沿作为控制信号, 从 而使 DR AM 的速度翻倍。不同类型的 DOR SDRA M 是用提高有效 带宽的很小的预取缓冲区的大小来划分的: DDR( 2 位)、DDR2( 4 位)和DDR( 8 位)。\n视 频 RA M( Video RAM, VRAM ) 。它用在图形系统的帧缓冲区中。 VRAM 的思想与 FPM DRAM 类似。两个主要区别是: 1) VRAM 的输出是通过依次对内部缓冲区的整个内 容进行移位得到的; 2) V R AM 允许对内存并行地读和写。因此, 系统可以在写下一次更新的新值(写)的同时,用帧缓冲区中的像素刷屏幕(读)。\n田日DRAM 技术流行的 历史\n直到 1995 年, 大 多 数 PC 都是 用 F P M DRAM 构 造的。1 996 1999 年, EDO DR AM 在市场 上占 据了主 导, 而 F P M DRAM 几乎销声 匿迹 了。 SD RAM 最早 出现在199 5 年的 高端 系统中, 到 2002 年, 大 多 数 PC 都是用 SDR AM 和 DOR SDR AM 制造的。到 2010 年之前, 大多数服务器和 桌面 系统都是 用 D DR3 SDRAM 构造的。 实际上, Intel Core i7 只支持 DDR3 SDRAM。\n6 非易失性存储器\n如果断电, DRAM 和 SR AM 会丢失它们的信息, 从这个意义上说, 它们是易失的 ( vola tile) 。另一方 面,非易 失性 存储器 ( nonvola tile memory ) 即使是在关电 后, 仍然保存着它们的信息。现在有很多 种非易失性存储器。由于历史原因, 虽然 RO M 中有的类型既\n可以读也 可以写, 但是它们整体上都被 称为 只读 存储 器 C Read-Only Memory, ROM)。ROM 是以它们能够被重编 程(写)的次数和对它们 进行重编程所用的机制来区分的。\nPROM(Programmable ROM, 可编程 RO M ) 只能被编程一次。P ROM 的每个存储器单元有一种熔丝 ( fuse) , 只能用高电 流熔断一次。\n可掠 写 可编 程 RO M (Erasable Programmable ROM, EPRO M ) 有一个透明的石英窗口,允 许光到 达 存储单元。紫 外线 光 照射 过 窗 口, EP RO M 单 元就被 清 除 为 0。对E PR O M 编程是通过使用 一种把 1 写入 EP RO M 的特殊设备来完成的。EP RO M 能够被擦除和重编程的次 数的数量级可以达到 1000 次。电 子 可擦 除 PRO M C Electrically Erasable PROM, EEPROM) 类似于 EP RO M , 但是它不需要一个物理上独立的编程设备,因此可以直接在印 制电路卡上编程。E EP RO M 能够被编程的次 数的数量级可以达到 105 次。\n闪存 ( flas h memory )是一类非易失性存储器, 基于 EE PRO M , 它已经成为了一种重要的存储技术 。闪存无处不在, 为大量的电子设备提供快速而持久的非易失性存储, 包括数码相机、手机、音乐 播放器、PDA 和笔记本、台式机和服务器计算机系统。在 6. 1. 3 节中, 我们会仔细研究一种新型的基于闪存的磁盘驱动器, 称为 固 态硬 盘 C Solid State Disk, SSD), 它能提供相对千传统旋转磁盘的一种更快速、更强健和更低能耗的选择。\n存储在 RO M 设备中的程序通 常被称为固件 ( firmware) 。当一个计算机系统通电以后, 它 会运行存储在 ROM 中的固件。一些系统在固件中提供了少最基本的输入和输出函数一 例如 PC 的 BIOS( 基本输入/输出系统)例程。复杂的设备, 像图形卡和磁盘驱动控\n制器, 也依赖固件翻译来自 CP U 的 I / 0 ( 输入/输出)请求。\n7. 访问主存\n数据流通过 称为总线 ( bus ) 的共享电子电路在处理器和 DRA M 主存之间来来 回回。 每次 CPU 和主存之间的数据传送都是通过一系列步骤来完成的, 这些步骤称为 总线事务(bus t ransact io n) 。读事务 ( read t ra nsactio n) 从主存传送数据到 CP U。写事务 ( write trans­ action) 从 CPU 传送数据到主存。\n总线是一组并行的导线, 能携带地址、数据和控制信号 。取决千总线 的设计,数 据和地址信号可以共享同一组导线,也可以使用不同的。同时,两个以上的设备也能共享同一 总线。控制线携带的 信号会同步事务,并标识出当前正在被 执行的事务的类型。例如, 当前关注的这 个事务是 到主存的吗?还是到诸如磁盘控制器这 样的其他 I / 0 设备? 这个事务是读还是写? 总线上的信息是地址还是数据项?\n图 6-6 展示了一个示例计算机系统的配置。主要部件是 CPU 芯片、我们将称为 1/ 0\n桥接器 CI / 0 bridg e )的芯片组(其中包括内存控制器), 以及组成主存的 DR AM 内存模块。这些部 件由一对总线连接起来, 其中一条总线是 系统总线( s ys tem bus ) , 它连接 CP U 和1/ 0 桥接器 , 另一条总线是内存 总线( memor y bus) , 它连接 1/ 0 桥接器 和主存。1/ 0 桥接器将系 统总线的电子信号翻译成内 存总线的电子信号。正如我们看到的那样, I / 0 桥也将 系统总线和内存总线连接到 I/ 0 总线, 像磁盘和图形卡这样的 1/ 0 设备共享 1/ 0 总线。不过现在,我 们将注意力 集中在内存总线上。\nCPU芯片\n系统总线\n总线接口 三\n内存总线\nI\n图 6-6 连接 C P U 和主存的总线结构示例\nm 关千总线设计的 注释\n总线设 计是计算机 系统一个复杂而且 变化迅速的方面 。 不同的 厂商提 出了不同的 总线体系结构,作为产品 差异化的一种方法 。例如, Intel 系统使用称 为北桥 ( northbridg e) 和南桥(so uth bridge)的芯片组分别 将 CPU 连接到内存和 1/ 0 设备。在比较老的 Pent ium 和Core\n2 系统中, 前端总 线( Fr ont Side Bus , FSB) 将 CPU 连接到北桥 。来自 AMD 的 系统将 FSB\n替换为超传输 ( H yperTransport ) 互联 , 而更新一些的 Intel Core i7 系统使用的 是快速通道\n(QuickPath) 互联。这些不同 总线体 系结构的细节超 出了 本书的 范围。反之, 我们会使 用图6-6 中的 高级 总线体系结构作 为一 个运行 示例贯穿本书。 这是一个简单但是有用的 抽象, 使得我们可以很 具体, 并且可以 掌握主要思想而不必与任何私有设计的 细节绑得 太紧。\n考虑当 CP U 执行一个如下加载操作时会发生什么\nmovq A,%rax\n这里, 地址 A 的内容被加载到寄存器%r a x 中。CP U 芯片上称为总线接 口( bus interface )\n的电路在总线上发起读事务。读事务是由三个步骤组成的。首先, CPU 将地址 A 放到系统 总 线 上 。 I / 0 桥将信号传递到内存总线(图6- 7a ) 。接下来,主 存 感 觉 到 内 存 总 线 上的地址 信 号 ,从 内 存 总 线 读 地址,从 DRAM 取出数据字 ,并 将 数 据 写 到内存总线。I/ 0 桥将内 存 总 线 信 号 翻译成系统总线信号, 然后沿着系统总线传递(图6-7 b ) 。最后, CPU 感觉到系统总线上的数据,从 总 线上 读数据,并 将 数 据 复 制到寄存器%r a x ( 图 6- 7c) 。\n寄存器文件\n总 线 接口\nI / 0 桥\nI I\nVO桥\n二 I I X\n总线接 口\nc ) CP U从总线读 出字x, 并将它复制到寄存器 % r a x中图 6- 7 加 载操作 mo vqA, %r a x 的内存读事务\n反过来, 当 CPU 执行一个像下面这样的存储操作时\nmovq %rax,A\n这里, 寄 存器 %r a x 的 内 容 被写到地址 A , CPU 发起写事务。同样, 有三个基本步骤。首先,\nCPU 将地址放到系统总线上。内存从内存总线读出地址, 并 等待 数 据 到 达(图 6-8a) 。接下来 , CPU 将%r a x 中 的 数 据 字 复 制到系统总线(图6-8 6 ) 。最后, 主 存 从 内 存总线读出数据字 ,并 且 将这些位存储到 DRAM 中(图 6-8 c ) 。\n6. 1. 2 磁盘存储\n磁盘是广为应用的保存大量数据的存储设备,存 储 数 据 的 数 量 级 可 以 达到儿百到几千千 兆 字节, 而基 千 RAM 的存储器只能有几百或几千兆字节。不过,从 磁 盘 上 读 信息 的时间 为毫秒级,比 从 DRAM 读慢了 10 万倍, 比从 S RAM 读慢了 100 万倍。\n寄存器文件\n亳c a\n,\n总线接口\nVO桥\nI I A\na ) CPU将地址A放到 内存 总 线。主存读出这 个 地址 ,并等待数据字寄存器文件\n主存\ny\nb ) CPU将数据字y放到总线上\n寄存器文件\nhax\n总线接口\nc ) 主存从总线读数据字y , 并将它存储在地址A\n图 6-8 存 储 操 作 mo vq %r ax , A 的 内 存 写 事 务\ni 磁盘是由 盘片 ( plat t e r ) 构成的。每个盘片有两面或者称为表 面 ( s ur fa ce ) , 表面覆盖着磁性记录材料。盘片中央有一个可以旋转的主轴 ( s pin dle) , 它使得盘片以固定的旋转速率(rota tion al ra te) 旋转, 通常是 5400 1 5 000 转每分钟( Revol u t io n Per M in ute , RP M) 。磁\ni 盘通常 包含一个或多个这样的盘片, 并封装在一个密封的容器内。\n卜 图 6- 9a 展示了一个典型的磁盘表面的结构。每个 表面是由一组称为磁道( t rac k ) 的同\n!勺 心圆组 成的。每个磁 道被划分为一组扇区 ( s e cto r ) 。 每个 扇区包含相等数量的数据位(通常叱 是 512 字节), 这些数据编码在扇区上的磁性材料中。扇区之间由一些间隙 ( ga p ) 分 隔 开,\n这些间隙中不存 储数据位。间隙存储用来标识扇区的格式化位。\n磁盘是由 一个或多个叠放在一起的盘片组成的,它 们被封装在一个密封的包装里, 如图 6-9b 所示。整个装置通常被称为磁盘驱动 器( d is k drive ) , 我们通常简称为磁盘( dis k ) 。有时 , 我们会称磁盘为 旋转磁盘 ( ro t at ing dis k ) , 以使之区别千基于闪 存的 固 态硬盘(SSD), SSD 是没有移动部分的 。\n磁盘制 造商通常用术 语柱面 ( cy linde r ) 来描述多个盘片驱动器的构造, 这里, 柱面是所有盘片表面上到主轴中心的距离相等的磁道的集合。例如, 如果一个驱动器有三个盘片和六个面 , 每个表面上的磁道的编号都是一致的, 那么柱面 k 就是 6 个磁道 k 的集合。\na ) 一个盘片的视图\n图 6-9 磁盘构造\n柱面k\n主轴\nb ) 多个盘片的视图\n盘片0 盘片1 盘片2\n2. 磁盘容量\n一个磁盘上可以记录的最大位数称为它的最大容量,或 者 简 称 为 容 量。磁盘容量是由以下 技术因素决定的:\n记录密度 ( recording density)( 位/英寸): 磁道一英寸的段中可以放入的位数。·\n磁 道密度 ( t rack de nsit y) ( 道/英寸): 从 盘片中心出发半径上一英寸的段内可以 有的磁道数。\n面 密度 ( a rea l density)( 位/平方英寸): 记 录密度与磁道密度的乘积。\n磁盘制造商不懈地努力以提高面密度(从而增加容量),而 面密度每隔几年就会翻倍。最初 的磁 盘, 是 在 面密度很低的时代设计的,将 每个磁道分为数目相同的扇区, 扇区的数目是由最靠内的磁道能记录的扇区数决定的。为了保持每个磁道有固定的扇区数,越往外 的磁道扇区隔得越开。在面密度相对比较低的时候,这种方法还算合理。不过,随着面密 度的提高,扇区之间的间隙(那里没有存储数据位)变得不可接受地大。因此,现代大容蜇 磁盘使用一种称为多 区记 录( multip le zone recordi ng ) 的技术,在 这种技术中,柱 面的集合被分割成不相交的子集合,称 为记录区 ( recordi ng zone) 。每个区包含一组连续的柱面。一个区中的每个柱面中的每条磁道都有相同数量的扇区,这个扇区的数量是由该区中最里面的 磁道所能包含的扇区数确定的。\n下面的公式给出了一个磁盘的容量:\n磁 盘容 量 =\n字节数 X 平均扇区数 磁道数 X 表面数 X 盘片数扇区 磁道 表面 盘片 磁盘\n例如, 假设我们有一个磁盘, 有 5 个盘片, 每个扇区 512 个字节, 每个面 20 000 条磁道, 每条磁道平均 300 个扇区。那么这个磁盘的容量是:\n磁盘容量= 512 字 节 X 30 0 扇 区 20 000 磁道 X 2 表面 5 盘 片扇区 磁道 表面 盘片 磁盘\n= 30 720 000 000 字 节\n= 30. 72 GB\n注意, 制 造商是以千兆字节CGB) 或兆兆字节 ( T B) 为单位来表达磁盘容量的,这里\nl GB= l 沪字 节 , 1 T B= 1012 字 节 。\n田 日 - 千兆字节有多大\n不幸地 ,像 K C k ilo ) 、M( mega) 、G( giga) 和 T ( tera ) 这样的前缀的含义依 赖 于上下\n文。对于与 DR A M 和 SR AM 容量相 关的 计量单位, 通常 K = 210 , M = 220 , G = 2 气 而\nT = 2o4\n。 对 于与 像 磁 盘和网 络 这样的 I/ 0 设 备 容 量相关的 计 量 单位, 通常 K = 103 ,\nM = l 06 , G = l 0 9 , 而 T = l O气 速 率和吞吐量常常也使 用这些前缀。\n幸运地,对于我们通常依赖的不需要复杂计算的估计值,无论是哪种假设在实际中 都工作 得很好。例如, 230 和 10 9 之 间 的相 对差 别 不 大: ( 230 - 10 勹 / 10 9 :::::::::7 % 。 类 似,\n( 240 - 1 0 12 ) / 101 2:::::::::1 0 % 。\n练习题 6. 2 计算这 样一个 磁盘的容量, 它 有 2 个 盘 片 , 10 000 个柱 面, 每条磁 道平均有 400 个扇 区 , 而每 个扇 区有 51 2 个字 节。\n3 磁盘操作\n磁盘用读/写 头( rea d/ writ e hea d ) 来读写存储在磁性表面的位, 而 读 写 头 连接到一个传动 臂( act uator arm ) 一端,如 图 6- l Oa 所示。通过沿着半径轴前后移动这个传动臂, 驱动器可以 将读/写头定位在盘面上的任何磁道上。这样的机械运动称为寻道( seek ) 。一旦读/ 写头定位到了期望的磁道上, 那么当磁道上的每个位通过它的下面时,读 /写 头 可 以 感 知到这个位的值(读该位),也可以修改这个位的值(写该位)。有多个盘片的磁盘针对每个盘 面都有一个独立的读/写头 , 如 图 6-1 0 6 所 示。读/写头垂直排列, 一 致 行 动 。 在 任何时刻, 所有的读/写头都位于同一个柱面上。\n磁盘表面以固定 ,,,..,,,的旋转速率旋转 /\n,/\n读/写头连到传动臂的末端.在磁盘表面上一层薄薄的气垫上飞翔\n主轴\na ) 一个盘片的视图\n图 6-10\n磁盘的动态特性\nb ) 多个盘片的视图\n在传动臂末端的 读/写头在磁盘表面高度大约 0. 1 微米处的一层薄薄的气垫上飞翔(就是字面上这个意思),速度大约为80 km/ h。这可以比喻成将一座摩天大楼( 442 米高)放倒,然 后让 它在距离 地面 2. 5 cmCl 英寸)的高度上环绕地球飞行,绕 地球一天只需要 8 秒钟!在这样小的间隙里,盘 面上一粒微小的灰尘都像一块巨石。如果读/写头碰到了这样的一块巨石,读 /写 头会停下来, 撞到盘面一 所谓的读/写头冲撞 ( head crash ) 。为此,磁盘总是 密封包装的。\n磁盘以扇区大小的块来读写数据。对扇区的 访问 时间 ( acces s t im e ) 有 三个主要的部分: 寻道 时间 ( see k t im e ) 、旋转时间( rot at io na l la t ency ) 和传送时间 ( t ra ns fe r time) :\n寻道时间: 为了读取某个目标扇区的内容, 传 动 臂 首 先 将 读/写 头 定 位到包含目标扇区的磁道上。移动传动臂所需的时间称为寻道时间。寻道时间 T,eek 依 赖于读/写头以前的位置和传动臂在盘面上移动的速度。现代驱动器中平均寻道时间 T avg seek 是 通过对几千次对随机扇区的寻道求平均值来测扯的, 通常为 3 9 ms 。一次寻道的最大时间 T max seek 可 以 高 达 20 ms 。 旋转时间: 一旦读/写头定位到了期望的磁道, 驱动器等待目标扇区的第一个位旋转到读/写头下。这个步骤的性能依 赖于当读/写头到达目标扇区时盘面的位置以及磁盘的旋转速度。在最 坏的情况下, 读/写头刚刚错过了目标扇区,必 须 等待磁盘转一整圈。因此,最大旋转延迟(以秒为单位)是 Tmax rotation=\nRPM\nX 60s\nlmin\n平均旋转时间 Tavg rotation是 Tmax rotation的 一半。\n传送时 间: 当目标扇区的 第一个位位千读/写头下时, 驱动器就可以 开始读或者写该扇区的内容了。一个扇区的 传送时间依赖于旋转速度和每条磁道的扇区数目。因此,我们可以粗略地估计一个扇区以秒为单位的平均传送时间如下\nT = l X 1 X 60s\navg transfer RPM ( 平均扇 区数 / 磁道) lmin\n我们可以估计访问一个磁盘扇区内容的平均时间 为平均 寻道时间、平均旋转延迟和平均传送时间之和。例如,考虑一个有如下参数的磁盘:\n参数 值\n旋转速率\nT,vg江 , k\n每条磁道的平均扇区数\n7200RPM\n9ms\n400\n对于这个磁盘, 平均旋转延迟(以ms 为单位)是\nTavg rotauon = 1 / 2 X T max rotation = 1 / 2 X ( 60s / 7200 RPM) X 1000 ms / s ::::,::: 4 ms\n平均传送时间是\nTavg transfer = 60/ 7200 RPM X 1/ 400 扇 区/ 磁道 X 1000 ms / s ::::,::: 0. 02 ms\n总之,整个估计的访问时间是\nTaccess = T, vg seek + T avg rotation + T ,vg transfer = 9 ms+ 4 ms + o. 02 ms = 13. 02 ms\n这个例子说明了一些很重要的间题:\n访问一个磁盘扇区中 512 个字节的时间主要是寻道时间和旋转延迟。访问扇区中的第一个字节用了很长时间, 但是访问剩下的字节几乎不用时间。\n因为寻道时间 和旋转延迟大致相 等, 所以将寻道时间乘 2 是估计磁盘访问时间的简单而合理的方法。\n对存储在 SR AM 中的一个 64 位字的访问时间大约是 4n s , 对 DRAM 的访问时间是\n60ns 。因此, 从内存中读一个 512 个字节扇区大小的块的时间对 SR AM 来说大约是\n256n s , 对 DRAM 来说大约是 4000ns 。磁盘访问 时间, 大约 l Oms , 是 SRAM 的大约 40 000 倍, 是 DR AM 的大约 2500 倍。\n沁因 练习题 6. 3 估计访问 下面这个缢盘上 一个扇 区的访问 时间(以 ms 为单 位):\n参数\n旋转速率\nTavg seek\n每条磁道的平均扇区数\n值\n15 000RPM\n8 ms\n500\n逻辑磁盘块\n正如我们看到的那样, 现代磁盘构造复杂, 有多个盘面, 这些盘面上有不同的记录区 。 为了对操作系统隐藏这样的复杂性, 现代磁盘将它们的构造呈现为一个简单的视图,\n一个 B 个扇区大小的逻辑块的序列, 编 号 为 o, 1, …, B —1 。 磁 盘 封装中有一个小的硬件/固件设备,称 为磁 盘控制器,维 护 着 逻辑块号和实际(物理)磁盘扇区之间的映射关系。\n当操作系统想要执行一个1/0 操作时 ,例 如读一个磁盘扇区的数据到主存,操 作 系统会发\n送一个命令到磁盘控制器 ,让 它读某个逻辑块号。控制器上的固件执行一个快速表查找, 将一个逻辑块号翻译成一个(盘面, 磁 道, 扇区)的三元组, 这个三元组唯一地标识了对应的物理扇区。控制器上的硬件会解释这个三元组, 将读/写头移动到适当的柱面, 等 待 扇区移动到读/写头下, 将读/写头感知到的位放到控制器上的一个小缓冲区中,然后 将它们复制到主存中。\nm 格式化的磁盘容量\n磁盘控制器必须对磁盘进行格式化,然后才能在该磁盘上存储数据。格式化包括用 标识扇区的信息填写扇区之间的间隙,标识出表面有故障的柱面并且不使用它们,以及 在每个 区中预留出一 组柱面作 为备 用,如 果 区中一个或多 个柱 面在磁盘使用过程中坏掉了, 就可以使 用这 些备 用的 柱面。 因 为存 在 着这些备 用的 柱面, 所以磁盘制造商所说的格式化容量比最大容量要小。\n练习题 6. 4 假设 1MB 的文件由 512 个字节 的逻辑块组成, 存储在具 有如下特性的磁盘驱动器上:\n对于下面的 情况, 假设程 序顺 序地读 文 件的逻 辑块, 一个接 一个, 将读/写 头定位到 第一块上的 时间 是 T avg seek+T avgrota11o n o\n·A. 最好的情况: 给定 逻辑块到 磁 盘 扇 区的 最好的 可 能 的映 射(即顺序的), 估计读这\n个文件需要的最优时间(以 ms 为 单位)。\nB. 随机的情况: 如果块是随机地映射到 磁 盘扇 区的, 估计读这个 文件需要的 时间(以\nms 为 单位)。\n连接 1/ 0 设 备\n例如图形卡、监视器、鼠标、键盘和磁盘这样的输入/输出(l / 0 ) 设备,都 是 通过 l/ 0\n总线,例 如 Int el 的 外围设备 互 连( Periphe ral Component Int erconnect , PCD 总线连接到\nCPU 和主存 的。系统总线和内存总线是与 CPU 相关的,与 它 们 不 同 ,诸 如 PCI 这样的 I/\n0 总线设 计成与底层 CPU 无关。例如, P C 和 Mac 都可以使用 PCI 总线。图 6-11 展 示 了一个典型的 I/ 0 总线结构,它 连接了 CPU、主存和 l/ 0 设备。\n虽然 l/ 0 总线比系统总线和内存总线慢,但 是 它 可以容纳种类繁多的第三方 l/ 0 设备。例如,在 图 6-11 中 , 有 三种不同类型的设备连接到总线。\n通用串行 总线( Universa l Serial Bus, USB)控制器是一个连接到 USB 总线的设备的中转机构, USB总线是一个广泛使用的标准, 连接各种外围 l/ 0 设备,包 括键盘、鼠标、调制解调器、数码相机、游戏操纵杆、打印机、外部磁盘驱动器和固态硬盘。 USB 3. 0 总线的最大带宽 为 625MB / s。USB 3. 1 总线的最大带宽为 1250MB/ s。\n图形卡(或适配器)包含硬 件 和软件逻辑,它 们 负 责 代表 CPU 在显示器上画像素。\n主机 总线适配器将 一 个 或 多 个 磁盘连接到 I/ 0 总线,使 用 的 是 一 个 特 别 的 主机总线接 口定义的通信协议。两个最常用的这样的磁盘接口是 SCSI( 读作 \u0026quot; scuzzy\u0026quot; ) 和S AT A( 读作 \u0026quot; sat- uh\u0026quot; 。SCSI 磁盘通常比 SAT A 驱动器更快但是也更贵。SCSI主机 总 线 适 配器(通常称为 SCSI 控制器)可以支持多个磁盘驱动器, 与 S AT A 适配器不 同 , 它 只 能 支 待 一 个 驱 动 器 。\n寄口存器文件日\n系统总线 内存总线\n总线接口 凶,\n!,\u0026rsquo;.•·•C.·..\u0026rsquo;\u0026quot;,B·\nUSB Il三\nVO总线\nI\n000 ;\n针对诸如网络适\n配器这样的其他\nI 控制器 主机总线 设备的扩展插槽\nt t t\n鼠标 固态 键盘 监视器\n硬盘 : I 如 盘 扣 妇 l 盎 i:,\n* 磁 盘\n'\n'\n'\n一一一一一 -----I'\n图 6-11 总线结构示例,它连 接 CP U、主存和 I/ 0 设备\n其他的设备,例如网络适配器,可以通过将适配器插入到主板上空的扩展槽中,从而 连接到 I/ 0 总线, 这些插槽提供了到总线的直接电路连接。\n6 访问磁盘\n虽然详细描述 I/ 0 设备是如何工作的以及如何对它们进行编程超出了我们讨论的范围 ,但 是 我们可以给你一个概要的描述。例如,图 6-12 总结 了 当 CP U 从磁盘读数据时发生的步骤。\n田 0 总线设计进展\n图 6-11 中的 I/ 0 总线是一个简单的抽 象,使得 我们可以 具体描述但又不必和某个 系统的细节联 系过 于紧密。 它是 基 于外 围设 备 互联 ( Peripheral Component Interconnect, PCI) 总线的, 在 2010 年前使用非 常广泛。 PCI 模型中, 系统中所有的设备共享总线 , 一个时刻只 能有一台设备 访问这些线路。在现代系统中, 共享的 PCI 总线已经被 PCEe(PCI express) 总线取代, PCie 是 一组高速 串行 、通过开关连 接的点到点链路, 类似于你 将在第 11 章中学习到 的开关以 太网。PCie 总线, 最大吞吐率为 16GB / s , 比 PCI 总线快一个数量级 , PCI 总线的最大吞吐率为 533MB/ s。除 了测 量出的 I/ 0 性能, 不同总线设计之间的 区别 对应用程 序 来说是不可见的 ,所以 在本书中,我 们只使 用 简单的共享总线抽象。\nCP U 使用一种称为内存映射 I/ O ( memor y-ma pped I/ 0 ) 的技术来向 I/ 0 设备发射命令\n(图 6-12a) 。在使用内存映射 I/ 0 的系统中, 地址空间中有一块地址是为与 I/ 0 设备 通信保留的 。每个这样的地址称为一个 I/ 0 端口 (I / 0 port ) 。当一个设备连接到总线时, 它 与一个或多个端口相关联(或它被映射到一个或多个端口)。\nCPU芯片\nI\n寄存器文件\n三]\n罕\n鼠标 键盘 监视器 Cf\nCPU芯片\n1 寄存器文件\n已 三\n雪罕 芦忑\nVO总线\nb ) 磁盘控制器读扇区, 并 执行到主存的DMA传送\n图 6-12\n当 OMA传送完成时, 磁盘控制器用中断的方式通知CPU 读一个磁盘扇区 来看一个简单的例子,假 设 磁 盘控制器映射到端口 OxaO 。 随 后 , CPU 可能通过执行三个对地址 OxaO 的 存储 指 令 ,发 起 磁盘读:第 一 条 指 令 是发送一个命令字,告 诉 磁 盘发起一个读, 同时还发送了其他的参数,例如 当读完 成时 ,是否 中断 CP U( 我们会在 8. 1 节中讨论中断)。第二条指令指明应该读的逻辑块号。第三条指令指明应该存储磁盘扇区内容的主存地址。\n当 CP U 发出了请求之后,在 磁 盘 执 行 读 的 时 候 , 它 通常会做些其他的工作。回想一下, 一个 1G Hz 的 处理器时钟周期为 I ns , 在用来读磁盘的 16ms 时间里, 它 潜 在 地 可能执行 16 00 万条指令。在传输进行时,只 是 简单地等待, 什么都不做,是 一 种 极 大 的 浪费。\n在磁盘控制器收到来自 CP U 的读命令之后 ,它 将逻辑块号翻译 成一个扇区地址,读该扇区的内容,然 后 将这 些内 容 直 接传送到主存,不需 要 CPU 的干涉(图6-12b) 。设 备可以自己 执行读或者写总线事 务 而不需 要 CP U 干涉的 过程, 称 为 直接 内 存 访问 ( Direct\nMemory Access, DMA ) 。这种数 据传送称为 DMA 传送 CDMA trans fer ) 。\n在 DMA 传送完成, 磁盘扇区的内容被安全地存储在主存中以后, 磁盘控制器通过给CPU 发送一个中断信号来通知 CPU(图 6-12c) 。基本思想是中断会发信号到 CPU 芯片的一个外部引脚上。这会 导致 CPU 暂停它当前正在做的工作, 跳转到一个操作系统例程, 这个程序会记 录下 1/0 已经完成, 然后将控制返 回到 CPU 被中断的地方。\n田 日 商用磁盘的特性\n磁盘制造商在他们的 网 页上公 布了许多 高级技术信息。例如, 希捷( Seagate) 公司 i 的网站 包含关 于他 们 最受 欢迎的 驱 动 器之一 Barracuda 7400 的如下信息。(远 不止如 : 此 ! ) (S eagate. com)\n,志\n1. 3 固态硬盘\n固态硬盘CSolid Stat e Dis k , SSD) 是一种基于闪存的存储技术(参见 6. 1. 1 节),在某些情况下是传统旋转磁盘的极有吸引力的替代产品。图 6-1 3 展示了它的基本思想。SSD 封装插到 I/ 0 总线上标准硬盘插槽(通常是 USB 或 SAT A ) 中, 行为就和其他硬盘一样, 处理来自 CP U 的读写逻辑磁 盘块的请求。一个 SSD 封装由一个或多个闪存芯片和闪存翻译层( flas h translation layer) 组成,闪 存芯片替代传统旋转磁盘中的机械驱动器, 而闪存翻译层是 一个硬件/固件设备, 扮演与磁盘控制器相同的角色,将 对逻辑块的请求翻译成对底层物理设备的访问。\n1/0 总线\n固态硬盘 ( SSD )\n- - - - - - \u0026ndash;\n! 闪存\n闪存翻译层\n- - - - - - - - - -\n! 块0 块B-1\nII 页 o I 页I I 口三工JI \u0026mdash; 11 页o I 页I\n..I. [勹芒了门\n一一一一- - \u0026mdash;- \u0026mdash;- \u0026mdash;- \u0026mdash;\u0026mdash;\u0026mdash;\u0026mdash;\u0026ndash; \u0026mdash;- \u0026mdash;- \u0026mdash;\u0026mdash;\u0026mdash;\u0026mdash;\u0026mdash;\u0026mdash;\u0026mdash;\u0026mdash;\u0026mdash;\u0026ndash; \u0026mdash;\u0026mdash;\u0026mdash;\u0026mdash;\u0026mdash;\u0026mdash;\u0026mdash;\u0026mdash;\u0026mdash;\u0026mdash;\n图 6-13 固态硬盘C SS D)\n图 6-14 展示了典型 SSD 的性能特性。注意, 读 SSD 比写要快。随机读和写的 性能差别 是由底层闪存基本属性决定的。如图 6-13 所示, 一个闪存由 B 个块的序列组成, 每个块由 P 页组成。通常 , 页的大小是 512 字节~ 4KB, 块是由 32 ~ 1 28 页组成的, 块的大小\n为 16 KB~ 512KB。数据是以页为单位读写的。只有在一页所属的块整个被擦除之后, 才能写这一页(通常是 指该块中的所有 位都被设 置为 1 ) 。不 过, 一旦一个块被擦除了, 块中每一个页都 可以不需 要再进行擦除就写一次。在大约进行 100 000 次重复写之后, 块就会磨损坏。一旦 一个块磨损坏之后 , 就不能再使用了。\n随机读吞吐量 (MB /s ) 365MB/s 随机写吞吐童 (MB/s ) 303MB/s 平均顺序读访问时间 50µs 平均随机写访问时间 60µs 图 6-1 4 一个商业固态硬盘的性能特性\n资料来源: Intel SSD 730 产品 规 格 书[ 53] 。IOPS 是每秒 1/0 操 作 数 。吞 吐 量数 量基 于 4KB 块的 读 写\n随机写很慢, 有两个原因。首先, 擦除块需要相对较长的 时间, l m s 级 的 , 比访间页所需时 间要高一个数量级。其次, 如果写操作试图修改一个包含巳经有数据(也就是不是全为 1 ) 的页 p, 那么这个块中所有带有用数据的页都必须被复制到一个新(擦除过的)块, 然后才能 进行对页 p 的写。制造商已 经在闪存 翻译层中实现了 复杂的逻辑 , 试图抵消擦写块的高 昂代价, 最小化内部写的次数, 但是随 机写的性能不太可能 和读一样好。\n比起旋转磁盘 , SSD 有很多优点。它们由半导体存储器构成 , 没有移动的 部件, 因而随机访问 时间比旋转磁盘要快, 能耗更低, 同时也更结实。不过, 也有一些缺点。首先, 因为反复 写之后,闪 存块会磨损,所 以 SSD 也容易磨损。闪存翻译层中的平均 磨损( wear leveling ) 逻辑试图通 过将擦除平均分布在所 有的块上来最大化每个 块的 寿命。实际 上, 平均磨损逻 辑处理得非常好 , 要很多 年 SSD 才会磨损坏(参考练习题6. 5 ) 。其次, SS D 每字 节比旋转磁盘 贵大约 30 倍, 因此常用的存储容量比旋转磁盘小 100 倍。不过,随 着 SSD 变得越来越受 欢迎,它 的价 格下降 得非常快,而两者 的价格差也 在减少。\n在便携音乐设 备中, SSD 巳经完全的取代了旋转磁盘, 在笔记 本电脑中也越来越多地作为硬 盘的替代品, 甚至在台式机和服务器中也开始 出现了。虽然旋转磁盘还会继续存在,但 是显然, SS D 是一项重要的替代选择。\n练习题 6. 5 正 如我 们 已 经 看到 的, SSD 的 一个 潜 在 的 缺 陷 是 底 层 闪 存 会磨损。例如, 图 6-14 所 示的 SSD , In tel 保证 能够经得 起 128 PB C 128 X 1015 字 节)的写。 给定 这 样的假 设, 根据下面的工 作负 载, 估计这款 SSD 的寿命(以年为 单位):\n顺序写的最糟情况 : 以 470MB/s( 该设备的平均顺序写吞吐量)的速度持续地写 SSD 。 随机写的最糟情况 : 以 303MB/ s( 该设备的平均随机写吞吐量)的速度持续地写 SSD 。 平均情况 : 以 20GB/ 天(某些计 算机 制造商在他 们的 移 动计 算机 工作 负 载模 拟测 试中假设 的平 均每 天写速 率)的速度 写 SSD 。 6. 1. 4 存储技术趋势\n从我们对存储技术的讨论中,可以总结出几个很重要的思想:\n不同 的存储技 术有 不 同的 价格和性能折中。S RAM 比 DRAM 快一点, 而 DR AM 比磁盘要快 很多。另一方面, 快速存储总是比慢速存储要贵的。SR AM 每字节的造价比DRAM 高, DRAM 的造价又比磁 盘高得多。SSD 位千 DRAM 和旋转磁盘之间。\n不同 存储技术的价格和性能属性以 截然不 同的 速率 变化 着。图 6-15 总结了从 1985 年\n以来的存储技术的价格和性能属性,那 时笫 一 台 P C 刚 刚 发明不久。这些数字是从以前的商 业 杂 志 中 和 W e b 上挑选出来的。虽然它们是从非正式的调查中得到的, 但 是 这些数字还是能揭示出一些有趣的趋势。\n自从 1 98 5 年以来, S RAM 技术的成本和性能基本上是以相同的速度改善的。访问时间 和 每兆字节成本下降了大约 1 0 0 倍(图6- 1 5 a ) 。不过, D RAM 和磁盘的变化趋势更大, 而 且 更 不 一 致。DRAM 每兆字节成本下降了 44 0 0 0 倍(超过了四个数量级!), 而 DRAM的 访 问 时 间 只 下 降 了 大 约 1 0 倍(图 6- 1 5 b ) 。 磁 盘技术有和 DRAM 相同的趋势, 甚至变化更 大 。 从 1 9 8 5 年以来,磁 盘存储的每兆字节成本暴跌了 3 000 0 0 0 倍(超过了六个数量 级!),但是 访问 时 间 提高得很慢,只 有 2 5 倍 左 右(图 6- 1 5 c ) 。 这些惊人的长期趋势突出了内 存 和 磁 盘 技术的一个基本事实:增 加 密度(从而降低成本)比降低访问时间容易得多。\nDRAM 和磁盘的性 能滞后 于 C P U 的性能。正如我们在图 6- 1 5 d 中看到的那样,从\n1 9 8 5 年到 2 0 1 0 年, C P U 周 期 时 间 提高了 5 0 0 倍。如果我们看有效周期时间 ( e ff ec t ive cy­ cle time) 我们定义为一个单独的 C P U ( 处理器)的周期时间除以它的处理器核数一 那么 从 1 9 8 5 年到 20 1 0 年的提高还要大一些, 为 2 0 0 0 倍。C P U 性能曲线在 2 0 0 3 年附近的突然 变 化 反映的是多核处理器的出现(参见 6 . 2 节的旁注),在 这 个 分 割 点 之 后 ,单 个 核的周期时间实际上增加了一点点,然后又开始下降,不过比以前的速度要慢一些。\n度量标准 1985 1990 1995 2000 2005 2010 2015 2015:1985 美元/MB 2900 320 256 100 75 60 25 116 访问时间 Cns) 150 35 15 3 2 1.5 1.3 115 a) SRAM趋势 度量标准 1985 1990 1995 2000 2005 2010 2015 2015:1985 美元/MB 880 100 30 I 0.1 0.06 0,02 44 000 访问时间 ( ns ) 200 100 70 60 50 40 20 10 典型的大小 ( MB) 0.256 4 16 64 2000 8000 16000 62 500 b ) DRAM趋势 度量标准 1985 1990 1995 2000 2005 2010 2015 2015:1985 美元 !GB 100 000 8000 300 10 5 0.3 0.03 3 333 333 最小寻 道时间 ( ms) 75 28 10 8 5 3 3 25 典型的大小 (G B ) 0.01 0.16 I 20 160 1500 3000 300 000 c ) 旋转磁盘 趋 势 度呈标准 1985 1990 1995 2000 2003 2005 2010 2015 2015:1985 Intel CPU 80 286 80 386 Pent. P-田 Pent.4 Core2 Core i7 (n) Core i7 (h) 时钟频率 ( MHz) 6 20 150 600 3300 2000 2500 3000 500 时钟周期 ( ns) 166 50 6 1.6 0.3 0.5 0.4 0.33 500 核数 I I I 1 l 2 4 4 4 有效周期时间 ( ns ) 166 50 6 1.6 0.30 0.25 0.10 0.08 2075 d) CPU趋势 图 6-15 存储和处理器技术发展趋势 。2010 年的 Co re i7 使用的是 Nehalem 处理器 ,\n2015 年的 Co re i7 使用的是 H as well 核\n注意,虽 然 SRAM 的 性能 滞后 千 CPU 的性能, 但还是 在保待增长。不 过, DRA M 和磁盘性能与 CPU 性能之间的差距实际上是在加大的。直到 20 03 年左右多核处理器的出现, 这个性能差距都是延迟的函数, DRAM 和磁盘的访问时间比单个处理器的周期时间提高得更 慢。不过,随 着 多 核 的 出 现, 这个性能越来越成为了吞吐量的函数, 多 个 处 理 器核并发 地向 DRAM 和磁盘发请求。\n图 6-1 6 清楚地表明了各种趋势,以 半 对 数 为比例( s em i-log scale ) , 画出了图 6-1 5 中的访问时间和周期时间。\nI00 000 000.0\n10 000 000.0\nI 000 000.0\nJOO 000.0 \u0026ndash;+- 磁 盘寻道时间\n_._ SSD 访问 时间\nJO 000.0\n1000.0\n100.0\n10.0\n1.0\n0.1\n0.0\n1985 1990\n1995\n2000 2003 2005 2010\n年份\n2015\n, 气 ) RAM访问时间\n千 SRAM访问时间\n-0- CPU周期时间\n今 有效CPU周期时间\n图 6-16 磁 盘 、 DRAM 和 CPU 速度之间逐渐增大的差距\n正如我们将在 6. 4 节中看到的那样, 现代计算机频繁地使用基千 SRAM 的高速缓存, 试图弥补处理器-内存之间的差距。这种方法行之有效是因为应用程序的一个称为局部性 (localit y ) 的 基本属性,接 下 来 我们就讨论这个间题。\n练习题 6. 6 使用图 6-15c 中从 200 5 年到 2015 年的数据, 估计到 哪一年你可以以 $ 500\n的价 格买到 一个 1PBC1015 字节)的 旋转磁 盘。假 设美元价值不 变(没有通货 膨胀 )。\nm 当周 期时间保持不变: 多核处理器的到来\n计算机历史是由一些在工业界和整个世界产生深远变化的单个事件标记出来的。有趣 的是,这些变化点趋向于每十年发生一次: 20 世纪 50 年代 Fortra n 的提出, 20 世 纪 60 年代早期 IBM 360 的出现 , 20 世 纪 70 年代早期 Int ernet 的曙光(当 时称为 AP RA NE T ) , 20世纪 80 年代早期 IBM PC 的出现 ,以 及 20 世 纪 90 年代万维网 ( World Wide Web) 的出现 。最近这样的事件出现在 21 世纪初, 当计 算机制造商迎 头撞 上了所谓 的“能量墙( power\nwall)\u0026quot;, 发现他们无法再像以前一样迅速地增加CPU的时钟频率了,因为如果 那样芯片的功耗会太大。解决方法是用多个小处理 器核(core ) 取代单个大处理 器,从而提 高性能,每 个完整的处理器能够独立地、与其他核 并行地执行程序。这种多核 ( m ulti- core) 方法部 分有效,因为一 个处理器的功耗正比于 P = J C寸, 这里J 是时钟频率,C 是电容,而 v 是电压。电容 C 大致上正比于面积,所 以只要所有核的总面积不变,多核 造成的能耗就能保持不变。只要特征尺寸继续按照摩尔定律指数性地下降,每 个处理器中的核数,以及每个处理 器的有效性能,都会继续增加 。\n从这个时间点以后,计算机越来越快,不是因为时钟频率的增加,而是因为每个处理器 中核数的 增加,也 因为体 系结构上的创新提高了 在这些核上运行程序的效率。我们可以从图6-16 中很清楚地看到这 个趋势。CPU 周期时间在 2003 年达到最低点,然后实际上是又开始上\n升的,然 后变得平稳,之后 又开始以比以前慢一些的速率下降 。不过,由 于多核处理器的出现(2004 年出现双核, 2007 年出现四核), 有效周期时间以接近于以前的速率持续下降。\n6. 2 局部性\n一个编写良好的计算机程序常常具有良 好的局部性 (l o ca lit y ) 。也就是, 它们倾向于引用邻近千其 他最近引用过的数据项的数据项, 或者最近引 用过的数据项本身。这种倾向性, 被称为局部 性原理( p rin cipl e of loca lit y ) , 是一个持久的概念, 对硬件和软件系统的设计 和性能都 有着极大的影 响。\n局部性通常有 两种不同的 形式: 时间局 部性( t e m po ra l lo ca l it y ) 和空间 局部性( s patial lo ca lit y) 。在一个具有良好时间局部性的程序中 , 被引用过一次的内存位置很可能 在不远的 将来再被多次引用。在一个具有良好空间局部性的程序中 , 如果一个内存位置被引用了一次, 那么程序很可能在不远的将来引用附 近的一 个内存位置。\n程序员应该理解局部性原理,因为一般而言,有良好局部性的程序比局部性差的程序 运行得更快。现代计算 机系统的各 个层次 , 从硬件到操作系统、再到应用 程序, 它们的设计都利用了局部性。在硬件层,局 部性原理允许计算机设计者通过引入称为高速缓存存储器的 小 而快速的存储器来保存最近被引 用的 指令和数据项, 从而提高对主存的访问速度。在操作系统级, 局部性原理允许系统使用主存作为虚拟地址空间最近被引用块的高速缓存。类似地, 操作系统用主存来缓存磁盘 文件系统中最近被使 用的磁盘块。局部性原理在应用程序的设计中也扮演着重要的角色。例如, Web 浏览器 将最近被引 用的文档放在本地磁盘上,利用的 就是时间局部性。大容屈的 Web 服务器将最近被请求的文档放在前端磁盘高速缓存中, 这些缓存能满足对这些 文档的请 求, 而不需要服务器的任何干预。\n6. 2. 1 对程序数据引用的局部性\n考虑图 6-17a 中的简单函数, 它对一个向员的元素求和。这个程序有良好的局部性吗?要回答这个问题, 我们来看看每个变 量的引用模式。在这个例子中,变 量 s u m 在每次循环迭代中被引用一次 , 因此, 对于 s u m 来说 , 有好的时间 局部性。另一方面, 因为 s um 是标量, 对于 s um 来说, 没有空间局部性。\nint swnvec(int v[N])\n{\nint i, sum = O;\nfor (i = O; i \u0026lt; N; i++) sum+= v[i];\nreturn sum;\n地址内容\n访问顺序\n16\n82\nvI V\n5\n24\n2 87\nv 6\n7\na ) 一 个具 有良好局部性的程序 b ) 向量v的引用模式 ( N = 8)\n图 6-17 注意如何按照向量元素存储在内存中的顺序来访间它们\n正如我们在图 6-176 中看到的, 向量 v 的元素是 被顺序读取的, 一个接一个, 按照它们存储在内存中的 顺序(为了方便, 我们假设数 组是从 地址 0 开始的)。因此, 对于变量 V, 函数有很好的空间局部性,但是时间局部性很差,因为每个向噩元素只被访问一次。因为 对千循环体中的每个变量,这个函数要么有好的空间局部性,要么有好的时间局部性,所 以我们可以断定 s umv e c 函数有良好的局部性。\n我们说像 s umv e c 这 样顺序访问一个向批每个元素的函数,具 有 步 长 为 1 的引用 模 式(str ide- I reference pattern)(相对千元素的大小)。有时我们称步长为 1 的引用模式为顺序引用模式( seq uent ial reference pat tern ) 。一个连续向最中, 每隔 K 个 元 素 进 行 访 问 , 就 称为步长为 K 的引 用模式( s t r id e- k reference pattern ) 。步长为 l 的引用模式是程序中空间局部性常见和重要的来源。一般而言,随着步长的增加,空间局部性下降。\n对于引用多维数组的程序来说,步 长也是一个很重要的问题。例如,考 虑 图 6-18a 中的函数 s umarr a yr ows , 它 对 一个二维数组的元素求和。双重嵌套循环按照行优先顺序( ro w­ major order ) 读 数组 的元素。也就是,内 层 循 环读第一行的元素, 然后读第二行,依 此 类 推 。函数 s umarr a yr ows 具 有良好的空间局部性,因 为它按照数组被存储的行优先顺序来访问这个 数组(图6-186 ) 。其结果是得到一个很好的步长为 1 的引用模式 ,具有良好的空间局部性 。\nint sumarrayrows (int a[M] [NJ)\n{\ninti, j, sum= O;\nfor (i = 0 ; i \u0026lt; M; i ++)\nfor (j = 0; j \u0026lt; N; j ++) sum += a[i] [j];\nreturn sum;\n地址内容\n访问顺序\n。\naoo\na 01\n12 16\n20\na12\n图 6-18\na ) 另一个具有良好局部性的程序 b ) 数组a的引用模式( M = 2, N=3)\n有良好的空间局部性,是因为数组是按照与它存储在内存中一样的行优先顺序来被访问的\n一些看上去很小的对程序的改动能够对它的局部性有很大的影响。例如,图 6-1 9a 中的函数 s umarr a y c o l s 计 算 的 结 果 和图 6-18a 中函数 s umar r a y r o ws 的 一 样。唯一的区别是我们交换了 1 和)的循环。这样交换循环对它的局部性有何影响? 函数 s umarr a y c o l s 的空间局 部性很差 ,因 为它按照列顺序来扫描数组,而 不是按照行顺序。因为 C 数组在内存中是按照行顺序来存放的,结 果 就 得 到步长为 N 的引用模式, 如图 6-1 96 所示。\nint surnarraycols(int a[M] [N])\n{\ninti, j, sum= O;\nfor (j = 0; j \u0026lt; N; j ++)\nfor (i = 0 ; i \u0026lt; M; i ++)\nsum+= a[i][j]; return sum,·\n地址内容\n访问顺序\na 01\n12 16\n20\na12\n6. 2. 2\na ) 一个空间局部性很差的程序 b ) 数组a的引用模式( M = 2, N=3)\n图 6- 1 9 函数的空间局部性很差 , 这是因为它使用步长为 N 的引用模式来扫描\n取指令的局部性\n因为程序指令是存放在内存中的, CPU 必须取出(读出)这些指令,所 以 我们也能够评价一个程序关于取指令的局部性。例如,图 6-1 7 中 f or 循环体里的指令是按照连续的内存顺序执行的,因此循环有良好的空间局部性。因为循环体会被执行多次,所以它也有 很好的时间局部性。\n--,\n代码区别千程序数据的一个重要属性是在运行时它是不能被修改的。当程序正在执行 时, CPU 只从内存中读出它的指令。CPU 很少会重写或修改这些指令。\n2. 3 局部性小结\n在这一节中, 我们介绍了局部性的基本思想, 还给出了量化评价程序中局部性的一些简单原则:\n重复引用相同变量的 程序有良 好的时间局部性。\n对于具有步长为 K 的引用模式的程序, 步长越小, 空间局部性越好。具有步长为 l 的引用模式的程序有很好的空间局部性。在内存中以大步长跳来跳去的程序空间局 部性会很差。\n对千取指令来说,循环有好的时间和空间局部性。循环体越小,循环迭代次数越多, 局部性越好。\n在本章后面,在我们学习了高速缓存存储器以及它们是如何工作的之后,我们会介绍 如何用高速缓存命中率和不命中率来量化局部性的概念。你还会弄明白为什么有良好局部性的程序通常比局部性差的程序运行得更快。尽管如此,了解如何看一眼源代码就能获得 对程序中局部性的高层次的认识,是程序员要掌握的一项有用而且重要的技能。 .\n练习题 6. 7 改变下面函数中循环的顺序,使得 它以步长为 1 的引用模式扫描三维数组 a :\nint sumarray3d (int a[NJ [NJ [NJ)\n{\nint i , j , k, sum \u0026ldquo;\u0026rsquo; 0 ;\nfor (i = O; i \u0026lt; N; i++) {\nfor (j = O; j \u0026lt; N; j++) {\nfor (k = O; k \u0026lt; N; k++) { sum += a[k] [i] [j];\n}\n}\n}\nreturn sum;\n练习题 6 . 8 图 6- 20 中的 三个函数,以不同的 空间局部 性程度 , 执行 相同的 操作。请对这些函数就空间局部性进行排序。解释你是如何得到排序结果的。\nvoid clear1(point *P, int n)\n{\n#define N 1000 typedef struct {\nint vel[3];\nint acc[3];\n} point;\npoint p[N];\na) str uc t s 数组\n图 6-20\nint i , j;\nfor (i = O; i \u0026lt; n; i++) { for (j = O; j \u0026lt; 3; j++)\np[i] .vel[j] = O;\nfor (j = O; j \u0026lt; 3; j++)\np[i] . acc[j] = 0;\n}\nb ) c l e ar l 函数\n练习题 6. 8 的代码示 例\nvoid clear2(point *P, int n)\n{\nint i, j;\nfor (i = 0; i \u0026lt; n; i ++) {\nfor (j = 0; j \u0026lt; 3; j++) { p [i] . vel[j] = 0;\np [i] . ace [j] = 0;\n}\n}\nvoid clear3(point *P, int n)\n{\nint i, j;\nfor (j = 0; j \u0026lt; 3; j++) { for (i = O; i \u0026lt; n; i++)\np[i] .vel[ j] = O;\nfor (i = O; i \u0026lt; n; i++) p[i] .acc[j] = O;\n}\nc) c l e ar 2 函数 d) c l e ar 3函数\n图 6- 20 (续)\n3 存储器层次结构\n6. 1 节和 6. 2 节描述了存储技术和计算机软件的一些基本的和持久的属性 :\n存储技 术: 不同存储技术的访问 时间差异很大。速度较快 的技术每字 节的成本要比速度较慢的技术高 , 而且容最 较小。CP U 和主存之间的速度差距在增大。\n计算机软件 : 一个编写良好的程序倾向 于展示出良 好的局部性。\n计算中一个喜人的巧合是, 硬件和软件的这些 基本属性互 相补充 得很完美 。它们这种相互补充的性 质使 人想 到一种组 织存储 器 系统的方 法, 称 为 存 储 器 层 次 结 构 ( memory 加 rarchy) , 所有的现代计算 机系统中都使用了这种方法。图 6- 21 展示了一个典型的存储器层次结构。一般而言,从高层往底层走,存储设备变得更慢、更便宜和更大。在最高层\n(LO), 是少量快速的 CPU 寄存器, C P U 可以在一个时钟周 期内访间它们。接下来是一个\n更小更快和\n(每字节) 成本更高的存储设备\n更大\nL3:\nLl :\nL2\n高速缓存\n(SRAM)\nL3\n高速缓存\n(SRAM)\nCPU寄存器保存着从高速缓存存储器取出的字\n} LI 高速缓存保存着从L2 高速缓存取出的缓存行\n} L2高速缓存保存着从L3\n高速缓存取出的缓存行\n} L3高速缓存保存着从主存高速缓存取出的缓存行\n更慢和\n(每字节) 成本更低的存储设备\nLS:\nL4: 主存 ( DRAM )\n本地二级存储(本地磁盘)\n主存保存着从本地磁盘取出的磁盘块\n本地磁盘保存着从远程网络服务器磁盘上取出的文件\n图 6-21 存储器层次结构\n或多个小型到中型的基于 SRAM 的高速缓存存储器, 可以 在儿个 CPU 时钟周期内访问它们 。 然后是一个大的基于 DRAM 的主存, 可以在几十到几百个时钟周期内访问它们。接下 来是慢速但是容扯很大的本地磁盘。最后,有 些 系统甚至包括了一层附加的远程服务器上 的 磁 盘 , 要通 过 网 络来访问 它 们。例 如, 像安 德鲁 文件 系统 ( Andrew File System, AFS )或者网络文件系统 ( Netwo rk File System, NFS ) 这样的分布式文件系统,允 许程序访 问 存 储在远程的网络服务器上的文件。类似地,万 维 网允 许程序访问存储在世界上任何地 方的 Web 服务器上的远程文件。\nm 其他的存储器层次结构\n我们向你展示了一个存储器层次结构的示例,但是其他的组合也是可能的,而且确 实也 很 常见。例如,许 多站点(包括谷歌的数据 中心 )将本地磁盘备份到存 档的磁带上。其中有些站点,在 需要时由人工装好磁 带。 而其他 站点则是 由磁 带机 器人 自动 地完成这项任务。 无论在哪种情况中,磁 带都是存储器层 次结构中的一层, 在本地磁盘层下 面, 本 书中提到的 通用原 则也 同样 适用于它。磁 带每 宇节比 磁 盘更便 宜, 它允 许站点将本地磁} 盘的多 个快照存档。代价是磁带的 访问时间要比磁盘的更长。 未看另一个例子, 固 态硬盘, 在存储器层 次结构 中扮 演着越 来越重要的角 色,连接 起 DRAM 和旋转磁盘之间的鸿沟。.\n3. 1 存储器层次结构中的缓存\n一般而言, 高速缓存( cache , 读作 \u0026quot; cas h\u0026rdquo; ) 是 一 个 小 而快速的存储设备, 它 作 为存储在更大、也更慢的设备中的数据对象的缓冲区域。使用高速缓存的过程称为缓存( caching, 读作 \u0026quot; cashing\u0026quot; ) 。\n存储器层次结构的中心思想是, 对于每个 k , 位于 K 层 的 更 快更小的存储设备作为位于k + l 层 的 更 大更 慢的存储设备的缓存。换句话说, 层 次结 构中的每一层都缓存来自较低一层 的 数 据 对象 。 例 如,本地 磁盘作为通过网络从远程磁盘取出的文件(例如 Web 页面)的缓存, 主存作为本地磁盘上数据的缓存, 依此类推, 直 到 最小的缓存- CPU 寄存器组。\n图 6- 22 展示了存储楛层次结构中缓存的一般性概念。第 k + l 层 的 存 储 器被划分成连续 的 数 据 对 象 组块( ch unk ) , 称为块\u0026lt; block ) 。每个块都有一个唯一的地址或名字, 使之区别 于 其 他 的 块。块可以是固定大小的(通常是这样的), 也 可以是可变大小的(例如存储在 Web 服务器上的远程 H T ML 文件)。例如,图 6- 22 中第 k + l 层 存 储 器被划分成 16 个大小 固 定 的 块 ,编 号 为 0 ~ 15。\n第K 层: 亡工丿仁工丿仁五丿仁工丿 第K 层更小、更快、更昂贵的设备\n` 缓存着第k+l 层块的一个子集\n二 二 数据以块为大小传输\n单元在层与层之间复制\n亡严]二亡仁仁二二仁\n第k+I 层: 1\n工二三二正]巨三]\n口口口三]巨三]\n第k+ I层更大、更慢、更便宜的设备被划分成块\n图 6-22 存储器层次结构中基本的缓存原理\n类似地, 第 k 层的存储器被划分成较少的 块的集合, 每个块的 大小与 k + l 层的块的大小一样。在任何时刻 , 第 K 层的缓存包含第 k + l 层块的 一个子集的副本。 例如, 在图 6-22 中, 第 k 层的缓存有 4 个块的空间 , 当前包含块 4 、9、14 和 3 的副本。\n数据总是以 块大小为传送单元 ( t ra nsfer un it ) 在第 k 层和第 k + I 层之间来回复制的。虽然在层次结构中任何一对相邻的层次之间块大小是固定的,但是其他的层次对之间可以 有不同的块大小。例如, 在图 6- 21 中, L l 和 LO 之间的传送通常使用的是 1 个字大小的块。 L2 和 L1 之间(以及 L 3 和 L2 之间、L4 和 L3 之间)的传送通常 使用的是几十个字 节的块。而 L 5 和 L4 之间的传送用的是大小为几百 或几千字节的块。一 般而言 , 层次结构中较 低层(离 CP U 较远)的设备的访问时间较长, 因此为了 补偿这些较长的 访问时间,倾 向 于 使用较大的块。\n缓存命中\n当程序需要第 k + l 层的某个数据对象 d 时,它 首先在当前存储在第 k 层的一个块中查找 d 。 如果 d 刚好缓存在第 k 层中, 那么就是我们所说的缓存命 中( cache h代)。该 程序直接从第 k 层读取 d , 根据存储 器层次结构的性质,这 要比从第 k + l 层读取 d 更快。例如, 一个有良好时间局部 性的程序 可以从块 14 中读出一个数据对象, 得到一个对第 k 层的缓存命中。\n缓存不命中\n另一 方面, 如果第 K 层中没有缓存数据对象 d , 那么就是我们所说的缓存不命中(cache miss ) 。 当发生缓存不命中时, 第 k 层的缓存从第 k + l 层缓存中取出 包含 cl 的 那个块, 如果第 k 层的缓存已经满了 , 可能就会覆盖现存的一 个块。\n覆盖一个现存的 块的过程称为替 换( replacing ) 或驱逐( evicting ) 这个块。被驱逐的这个块有时 也称为牺牲块 ( vict im blo ck) 。决定该替换哪个块是由缓存的替换 策略 ( replace­ ment polic y) 来控制的。例如, 一个具有随机替 换策略 的缓存会随机选择一 个牺牲块。一个具有 最近最少被使用CLRU) 替换策略的缓存会选择那个最后被访问的时间距现在最远的块。\n在第 k 层缓存从第 k + l 层取出那个块之后, 程序就能像前面一样从 第 k 层读出 d 了。例如,在 图 6-22 中, 在第 k 层中读块 1 2 中的一个数据对象, 会导致一个缓存不命 中, 因为块 1 2 当前不 在第 k 层缓存中。一旦把块 12 从第 k + l 层复制到第 k 层之后, 它就会保持在那里,等待稍后的访问。\n缓存不命中的 种类\n区分不同种类的缓存不命中有时候是很有帮助的。如果第 K 层的缓存是空的 , 那么对任何数 据对象的访问都会不命中。一个空的缓存有时被称为冷缓 存 ( cold cache), 此类不命中称为 强制性 不命中( co m pulso r y m is s ) 或冷不命 中( cold mis s ) 。冷不命中很 重要 , 因为它们通 常是短暂的 事件 ,不会 在反复访问 存储器使得缓存暖身 ( w a r m ed up ) 之后的稳定状态中出现。\n只要发生了不命中, 第 K 层的缓存就必须执行某个放置策略 ( place men t policy), 确定把它从第 k + l 层中取出的块放在哪里。最灵活的替 换策略是允 许来自第 k + l 层的任何块放在第 k 层的任何块中。对于存储器层次结 构中高层的缓存(靠近 CP U ) , 它们是用硬件来实现的,而且速度是最优的,这个策略实现起来通常很昂贵,因为随机地放置块,定位起 来代价很高。\n因此, 硬件缓存通常使用的是更严格的放置策略, 这个策略将第 k + l 层的某个块限制放置在第 k 层块的一个小的子集中(有时只是一个块)。例如, 在图 6- 22 中, 我们可以确定第 k + l 层的块 1 必须放置在第 k 层的块(i mod 4 ) 中。例 如, 第 k + l 层的块 0、4、8 和 1 2 会映射到第 K 层的块 O; 块 1、5、9 和 1 3 会映射到块 1 ; 依此类推。注意 , 图 6-22 中的示例缓存使用 的就是这个策略 。\n这种限制性的放置策略 会引起一种不命中,称 为冲 突不命 中( confl ict miss ) , 在这种\n情况中,缓存足够大,能够保存被引用的数据对象,但是因为这些对象会映射到同一个缓 存块, 缓存会一直不命中。例如,在图 6- 22 中,如 果程序请求块 0\u0026rsquo; 然后块 8 , 然后块 o,\n然后块 8 , 依此类推, 在第 k 层的缓存中, 对这两个块的 每次引 用都会不命中, 即使这个缓存总共可以容纳 4 个块。\n程序通常是按照一系列阶段(如循环)来运行的,每个阶段访问缓存块的某个相对稳定不变的集合。例如, 一个嵌套的循环可能会 反复地访问 同一个数组的元素。这个块的集合称为这个阶段的工作 集 ( w or k ing set ) 。当 工作集的大小超过缓存的大小时, 缓存会经历容量不命 中( capacit y m iss ) 。换句话说就是, 缓存太小了, 不能处理这个工作集。\n缓存管理\n正如我们提到过的,存储器层次结构的本质是,每一层存储设备都是较低一层的缓 存。在每一层上,某种形式的逻辑必须管理缓存。这里,我们的意思是指某个东西要将缓 存划分成块, 在不同的层之间传送块 , 判定是命中还是不 命中, 并处理它们。管理缓存的逻辑可以是硬件、软件, 或是两者的结 合。\n例如, 编译器管理寄存器文件, 缓存层次结构的最高层。它决定当发生不命中时何时发射加载, 以及确定哪个寄存器来存放数据。Ll 、L2 和 L3 层的缓存完全是由内 置在缓存中的硬件逻辑来管理的。在一个有虚拟内存的系统中, DRAM 主存作为存储在磁盘上的数据块的缓存, 是由操作 系统 软件和 CP U 上的 地址翻译 硬件共同管理的。对千一个具 有像 AFS 这样的分 布式文件系统的机器来说, 本地磁盘作为缓存,它 是由运行在本地机器上的 AFS 客户端进程管理的。在大多数时候, 缓存都是自动运行的, 不需要程序采取特殊的或显式的行动。\n3. 2 存储器层次结构概念小结\n概括来说,基于缓存的存储器层次结构行之有效,是因为较慢的存储设备比较快的存 储设备更便宜, 还因 为程序倾向 千展示局部性:\n利 用时间局部 性: 由于时间局部性, 同一数据对象可能会被多次使用。一旦一个数据对象在第一 次不命中时被复制到缓存中 , 我们就会期望后面对该目标有一系列的访问命中。因为缓存比低一层的存储设备更快 , 对后面的命中的服务会比最开始的不命中快很多。 利 用空间局部性 : 块通常包 含有多个数据对象。由于空间局部性, 我们会期望后面对该块中其他对象的访问能够补偿不命中后复制该块的花费。\n现代系统中到处都使用了缓存。正如从图 6-23 中能够看到的 那样, CP U 芯片、操作系统、分布式文件系统中和万维网上都使用了缓存 。各种各样硬件和软件的组合构成和管理着缓存。注意, 图 6-23 中有大量我们还未涉及的术语和缩写。在此我们包括这些术语和缩写是为了说明 缓存是多么的普遍。\n类型 缓存什么 被缓存在何处 延迟(周期数) 由谁管理\nCPU寄存器TLB Ll 高速缓存 4节字或8字节字 地址翻译 64字节块 芯片上的CPU寄存器芯片上的TLB 芯片上的Ll 高速缓存 。 4 编译器 硬件 MMU 硬件 L2高速缓存 64字节块 芯片上的L2高速缓存 10 硬件 L3高速缓存 64字节块 芯片上的L3高速缓存 50 硬件 虚拟内存 4KB页 主存 200 硬件 + OS 缓冲区缓存 部分文件 主存 200 OS 磁盘缓存 磁盘扇区 磁盘控制器 100 000 控制器固件 网络缓存 部分文件 本地磁盘 10 000 000 NFS客户 浏览器缓存 Web页 本地磁盘 10 000 000 Web浏览器 Web缓存 Web页 远程服务器磁盘 I 000 000 000 Web代理服务器 图 6~23 缓 存 在 现 代 计 算 机 系 统 中 无 处 不 在 。 T L B : 翻译后备缓 冲器 ( T ra ns la tion Lookas ide Ruffer); MMU: 内存管理单元 ( Memory Management Unit ) ; OS: 操作系统 ( Operating System);\nAFS: 安德鲁文件系统( Andrew File System) ; NFS : 网络文件系统 ( Network File System)\n6. 4 高速缓存存储器\n早期计算机系统的存储器层次结构只有三层: CPU 寄存器、DRA M 主存储 器 和磁 盘存储。不 过, 由 千 CP U 和主存之间逐渐增大的差距, 系统设计者被迫在 CPU 寄存器文件和主存之 间插入了一个小的 SRAM 高速 缓 存存储 器, 称为 L1 高 速 缓 存(一级缓存), 如 图 6-24 所 示。L1 高速缓存的访问速度几乎和寄存器一样快,典 型 地是大约 4 个时钟周期。\nCPU芯片\n系统总线 内存总线\n总接线口 三]\n图 6- 24 高速缓存存储器的典烈总线结构\n随着 CPU 和主存之间的 性能差 距不断增大 , 系统设计者在 Ll 高速缓存和主存之间又插入了一 个更大的高速缓存, 称为 L2 高 速缓存, 可 以 在 大 约 1 0 个时钟周期内访问到它。有些现代系统还包括有一个更大的高速缓存,称 为 L3 高 速缓存, 在 存 储 器 层 次 结 构 中 , 它位于 L2 高速缓存和主存之间, 可以在大约 50 个周期内访问到它。虽然安排上有相当多的变化, 但是通用原则是一样的。对于下一节中的讨论, 我们会假设一个简单的存储器层次结构, CPU 和主存之间只有一个 Ll 高速缓存。\n6. 4. 1 通用的高速缓存存储器组织结构\n考虑一个计算机系统 ,其 中 每 个 存 储 器地址有 m 位,形 成 M = 沪 个 不 同 的 地 址 。 如\n图 6 - 25 a 所 示, 这样一个机器的高速缓存被组织成一个有 5 = 2\u0026rsquo; 个高速缓 存组( ca ch e s e t ) 的\n数组。每个组包含 E 个 高 速缓存行 ( cach e line ) 。每个行是由一个 B = Z1\u0026rsquo; 字 节的数据块( block ) 组成的,一 个 有效位 ( va lid bit ) 指明这个行是否包含有意义的信息, 还有 t = m— ( b+ s ) 个标记位( t ag bit ) ( 是 当前块的内存地址的位的一个子集), 它 们 唯 一 地标识存储在这个高速缓存行中的块。\n每行1个 每行t个有效位标记位\n每个高速缓存块有B=钞字节\n广,''\n匠言Io I I I···IB-\n组0:\n匡巠 二 1。I\nI I \u0026hellip; IB-lI }每组珩\nS=25组\n组I :\n国 口三口1。I I I \u0026hellip; IB-lI\n阿 勹压门I I 1 I···la-1I\n匡口\n组s-\nI I I \u0026hellip; IB-1I # 匣 口巨门I I I I \u0026hellip; IB-11\n高速缓存大小C=BxE xS数据字节\na)\nt位 s位 b位 地址: m-1 \u0026lsquo;y \u0026quot; y 0 八 y , 标记 组索引 块偏移 图 6- 23\nb)\n高 速 缓 存 ( S , E, B, m ) 的 通 用 组 织 。 a) 高 速 缓 存 是 一 个 高 速 缓 存 组 的 数 组 。 每 个 组 包 含一 个 或 多 个行 , 每 个 行 包 含 一 个 有 效 位 , 一 些 标 记 位 , 以 及一 个 数 据块 ; b) 高速缓存的结构将 m 个地址位 划分 成 了 t 个标记 位 、s 个 组索 引位 和 b 个块 偏移 位\n一般而言, 高速缓存的结构可以用元组 CS , E, B, m ) 来描述。高速缓存的大小(或容矗)C 指的是所有块的大小的和。标记位和有效位不包括在内。因此, C= S X E X B。\n当一条加载指令指示 CP U 从 主存地址 A 中读一个字时, 它 将地址 A 发送到高速缓存。如果高速缓存正保存着地址 A 处那个字的副本,它 就立即将那个字发回给 CPU。那么高速缓存如何知道它是否包含地址 A 处那个字的副本的呢? 高速缓存的结构使得它能通过简单地检查地址位, 找到所请求的字, 类似于使用极其简单的哈希函数的哈希表。下面介绍它是如何工作的:\n参数 S 和 B 将 m 个地址位分为了三个字段,如 图 6-25b 所示。A 中 s 个组索引位是一个到 S 个组的数组的索引。第一个组是组 0 , 第二个组是组 1 , 依此类推。组索引位被解释为一个无符号整数,它告诉我们这个字必须存储在哪个组中。一旦我们知道了这个字必 须放在哪个组中, A 中 的 t 个标记位就告诉我们这个组中的哪一行包含这个字(如果有的话)。当且仅当设置了有效位并且该行的标记位与地址 A 中的标记位相匹配时,组 中的这一 行 才包含这个字。一旦我们在由组索引标识的组中定位了由标号所标识的行, 那么 b 个块偏移位给出了在 B 个字节的数据块中的字偏移。\n你可能已经注意到了, 对 高速缓存的描述使用了很多符号。图 6- 26 对这些符号做了个小结,供你参考。\n基本参数 参数 描述 S=2\u0026rsquo; 组数 E 每个组的行数 B=2b 块大小(字节) m=log2 (M) (主存)物理地址位数 图 6-26 高速缓存参数小结\n讫; 练习题 6. 9 下表 给出 了几 个不 同的 高速 缓存的 参数。 确定 每个高 速缓存的 高 速缓存组数 CS ) 、 标记位 数 ( t ) 、 组 索引位 数( s ) 以及块偏 移位 数 ( b) 。\n高速缓存 m C B E s t s b I. 32 1024 4 I 2. 32 1024 8 4 3. 32 1024 32 32 4. 2 直接映射高速缓存\n根据 每个组的高速缓存行数 E , 高速缓存被 分为不同的类。每个组只有一行( E = l ) 的高速缓存称 为直接映射高速缓存( dir ec t- mapped cache)( 见图 6-27 ) 。直接映射高速缓存是最容易实现和理解的 , 所以我们会以 它为例来说明一些高速缓存 工作方式的通用概念。\n组0: I五邑口匡勹1 高速缓存块 I I} £=匋组 1 行\n组 I : I 匡囡口三勹二五亘亘五二=:J I\n组S- 1: I匡囡口亘口1 高速缓存 块 I I\n图 6-27 直 接映射高速缓存 ( £ = 1) 。 每个组只有一行\n假设我们有 这样一个系统, 它有一个 CPU 、一个寄存器文件、一个 Ll 高速缓存和一个主存。当 CPU 执行一条读内存字 w 的指令, 它向 Ll 高速缓存请求这个 字。如果 Ll 高速缓存有 w 的一个缓存的副本, 那么就得到 L1 高速缓存命中, 高速缓存会很快抽取出\nw, 并将它返 回给 CPU。否则就是缓存不命中, 当 Ll 高速缓存向主存请求包含 w 的块的一个副 本时, CPU 必须等待。当被请求的块最 终从内存到达时, Ll 高速缓存将这个块存放在它 的一个高速缓存行里, 从被存储的块中抽取出字 w , 然后将它返回给 CPU。高速\n缓存确定一个请求是否命中,然后抽取出被请求的字的过程.分为三步: 1) 组选择; 2)\n行匹配; 3 ) 字抽 取 。\n直接映射高速缓存 中的组选择\n在这一步中 , 高速缓存从 w 的地址中间抽取出 s 个组索引位。这些位被解释成一个对应千一个组号的无符号整数。换旬话来说, 如果我们把高速缓 存看成是一个关于组的一维数 组, 那么这些组索引位就是一个到这个数组的索引 。图 6-28 展示了直接映射高速缓存的组选\n择是如何工作的。在这个例子中, 组索引位 00001 2 被解释为一个选择组 1 的整数索引。\n组0 : I 工[] I 标记 11 高速缓存块 I I\n选择的组 , 组I : I国豆\n高速缓存块\nt位 丿s 位 b位\nI 标 记 1 1 I I\nI 0 0 0 0 I I I\nm-1\n标记 组索引 块偏移\n组S- 1: I 匡邑I 标记 II\n高速缓存块 I I\n图 6- 28 直接映射高速缓存中的组选择\n直接映射高速缓存中的 行匹配\n在上一步中我们已经选择了某个组 i\u0026rsquo; 接下来的一步就要确定是否有字 w 的一个副本存储在组 t 包含的一个高速缓存行 中。在直 接映射高速缓存 中这很容易,而 且很快,这是因为每个组只有一行。当且仅当设置了有效位, 而且高速缓存行中的标记与 w 的地址中的标记相匹配时 , 这一行中包含 w 的一个副本。\n图 6- 29 展示了直接映 射高速缓 存中行匹配是 如何工作的。在这个例子中, 选中的组中只有一个高速缓存行。这个行的有效位设置了,所以我们知道标记和块中的位是有意义 的。因为这个高速缓存行中的标记位与地址中的标记位相匹配,所以我们知道我们想要的 那个字的一个副本确实存储在这个行中。换旬话说,我们得到一个缓存命中。另一方面, 如果有效位没有设晋,或者标记不相匹配,那么我们就得到一个缓存不命中。\n选择的组 ( i ) :\nL\n=I? ( I ) 有效位必须设置\n01234567\n( 2 ) 高速缓存行中的标\n记位必须与地址中 =?\n的标记位相匹配; —\nt位 s位\nI 0110 I i\nm-1\n( 3 ) 如果 ( I ) 和 ( 2 ) 满足, 那么高速缓存命中,块偏移就选择起始字节。\n标记 组索引 块偏移\n图 6-29 直接映射高速缓存中的行匹配和字选择。在高速缓存块中, w 。表示字 w\n的低位字节, W 1 是下一个字节 ,依 此 类推\n直接映射高速缓存中的 字选择\n一旦命中, 我们知道 w 就在这个块中的 某个地方。最后一步确定所需要的字在块中是从 哪里开始的。如图 6- 29 所示, 块偏移位提供了所需要的字的第一个字节的偏移。就像我们把高速缓存看成一个行的数组一样,我们把块看成一个字节的数组,而字节偏移是到\n这个数组的一个索引 。在这个示例中, 块偏移位是 100 2\u0026rsquo; 它表明 w 的副本 是从块中的字节 4 开始的(我们假设字长为 4 字节)。\n直接映射高速缓存中不命中时的行替 换\n如果缓存不命中 , 那么它需要从存储器层次结 构中的下一层取出被请求的 块, 然后将新的块存储在组索引 位指示的组中的一个高速缓存行中。一般而言, 如果组中都是有效高速缓存行了, 那么必须要驱逐出一 个现存的行。对于直接映射高速缓存来说, 每个组只包含有一行,替 换策略非 常简单: 用 新取出的行替 换当前的行。\n综合 : 运行中的 直接映射高速缓存\n高速缓 存用来选择组 和标识行的机制极其简单, 因为硬件必须在几个纳秒的时间内完成这些 工作。不过,用 这种方式来处理位是很令人因惑 的。一个具体的例子能帮助解释清楚这个过程。假 设我们有一个直接映射高速缓存 , 描述如下\n( S , E , B , m ) = ( 4 , 1, 2 , 4 )\n换句话说, 高速缓存有 4 个组, 每个组一行, 每个块 2 个字节, 而地址是 4 位的。我们还假设每个字都是单字节的。当然, 这样一些假设完全是不现实的, 但是它们能使示例保持简单。\n当你初学高 速缓存 时, 列举出整个地址空间并划分好位是很有帮助的, 就像我们在\n图 6-3 0 对 4 位的示例所做的那样。关于这个列举出的空间, 有一些有趣的事情值得注意 :\n图 6-30 示例直接映射高速缓存的 4 位地址空间\n标记位和索引位连起来唯 一地标识了内存中的每个块。例如, 块 0 是由地址 0 和 1\n组成的, 块 1 是由地址 2 和 3 组成的, 块 2 是由地址 4 和 5 组成的, 依此类推。\n因为有 8 个内存块, 但是只有 4 个高速缓存组 , 所以多个块会映射到同一个高速缓存组(即它们有相同的组索引)。例如, 块 0 和 4 都映射到组 0 , 块 1 和 5 都映射到组 1\u0026rsquo; 等等。\n映射到同一个高速缓存组的块由标记位唯一地标识。例如, 块 0 的标记位为 o, 而\n块 4 的标记位为 1, 块 1 的标记位为 o , 而块 5 的标记位为 1, 以此类推。\n让我们来模 拟一下当 CPU 执行一系列读的时候, 高速缓存的执行情况。记住对于这\n个示例,我 们假设 CPU 读 1 字节的字。虽然这种手工的模拟很乏味,你 可能想要跳过它, 但是根据我们的经验,在学生们做过几个这样的练习之前,他们是不能真正理解高速缓存 是如何工作的。\n初始时,高 速缓存是空的(即每个有效位都是 0 ) :\n表中的每一行都代表一个高速缓存行。第一列表明该行所属的组, 但 是 请 记 住提供这个位只 是 为了方便,实 际 上 它并 不真是高速缓存的一部分。后面四列代表每个高速缓存行的实际 的 位 。 现在,让 我们来看看当 CP U 执行一系列读时,都 发 生 了 什 么 :\n读地址 0 的字。因为组 0 的有效位是 o, 是缓存不命中。高速缓存从内存(或低一\n层的高速缓存)取出块 o , 并把这个块存储在组 0 中。然后, 高速缓存返回新取出的高速缓存 行 的 块[ OJ的 m[ O] ( 内存位置 0 的内 容)。\n。 。\n读地 址 l 的字。这次会是高速缓存命中。高速缓存立即从高速缓存行的块[ 1] 中返\n回 m[ l ] 。高速缓存的状态没有变化。\n读地址 13 的字。由于组 2 中的高速缓存行不是有效的,所 以 有缓存不命中。高速缓 存 把 块 6 加载到组 2 中,然 后 从新 的 高速缓存行的块[ 1] 中返回 m[ 13] 。 读地址 8 的字。 这会发生缓存不命中。组 0 中的高速缓存行确实是有效的, 但是标 1 记不匹配。高速缓存将块 4 加 载到组 0 中(替换读地址 0 时读入的那一行), 然后从新的商速缓存行的块[ OJ中返回 m[ 8] 。\n组 有效位 标记位 块[OJ 块[ I]\nI 2 3 。I 。I I I m[8] m[l2] m[9] m[l3] 读 地 址 0 的字。又会发生缓存不命中,因 为在前面引用地址 8 时, 我们刚好替换了块 0 。 这就是冲突不命中的一个例子,也 就是我们有足够的高速缓存空间, 但 是 却交替地引 用 映 射 到 同 一 个组的块。 。 。 # 直接映射高速缓存中的 冲突不命中\n冲突不命中 在真实的程序中很常见,会导 致令人困惑的 性能问题。当程序访问大小为\n2 的幕的数组时 , 直接映射 高速缓存中通常 会发生冲突不命中。例如, 考虑一个计算两个向量点积的函数:\nfloat dotprod (fl oat x[8] , fl oat y [8 ])\n{\nf l oa t sum= 0. 0;\ninti;\nfor (i =O;i \u0026lt; 8 ; i ++ ) sum += x [ i ] * y [i] ;\nr e t ur n sum;\n对于 x 和 y 来说, 这个函数有良好的空间局部性, 因此我们期望它的命中率会比较高。不幸的是 , 并不总是如此。\n假设浮点数是 4 个字节, x 被加载到从 地址 0 开始的 32 字节连续内存中 , 而 y 紧跟在\nx 之后, 从地址 32 开始。为了简便 , 假设一个块是 16 个字节(足够容纳 4 个浮点数), 高速缓存 由两个组组成, 高速缓存的整个 大小为 32 字节。我们会假设变量 sum 实际上存放在一个 CPU 寄存器中, 因此不需要内存引用。根据这些假设每个 x [ i ] 和 y [ i ] 会映射到相同的高速缓存 组:\n在运行时, 循环的第一次迭代引用 X [ O J\u0026rsquo; 缓存不命中会导致包含 x [OJ ~x [ 3 ) 的 块被\n加载到组 0。接下来是对 y [ O ] 的引 用 , 又一次缓存不命中,导 致包含 y [ OJ ~ y [ 3 J 的 块被复制到组 o , 覆盖前一次引用复制进来的 x 的值。在下一次迭代中, 对 X (1 ) 的引用不命中,导致 x [ OJ ~ x [ 3 ] 的 块被加载回组 o, 覆盖掉 y [ OJ ~ y [ 3 ) 的块。因而现在我们就有了\n一个冲突不命中,而 且实际上后面每次 对 x 和 y 的引用都会导致冲突不 命中, 因为我们在\nx 和 y 的块之间抖动( t h ra s h ) 。术语“抖动” 描述的是这样一种情况, 即高速缓存反复地加载和驱 逐相同的高速缓存块的组。\n简要来说就是, 即使程序有良好的空间局部性 , 而且我们的高速缓存中也有 足够的空间来存放 x [ i ] 和 y [ i ] 的块, 每次引用还是会导致冲突不命 中, 这是因为这些 块被映射到了同\n一个高速缓存组。这种抖动导致速度下降 2 或 3 倍并不稀奇。另外, 还要 注意虽 然 我们的示例极其简单,但是对于更大、更现实的直接映射高速缓存来说,这个问题也是很真实的。\n幸运的是, 一 旦 程序 员 意 识 到 了 正 在 发 生 什 么 ,就 很 容易 修 正 抖 动问 题 。 一 个 很 简 单的方法 是 在 每 个 数 组 的 结 尾 放 B 字 节 的 填 充 。 例 如 , 不 是 将 x 定 义 为 fl oa t x (8), 而是定义成f l oa 七 x [12 ) 。 假 设 在 内 存 中 y 紧 跟 在 x 后 面 ,我 们有 下 面这 样的 从数组元素到组 的映 射:\n在 x 结 尾 加 了 填 充 , x [i ] 和 y [ i ] 现 在 就 映 射 到 了 不同 的 组 ,消除 了 抖 动 冲突不命中。\n已 练习题 6. 10 在前面 d o 七p r o d 的 例 子 中 , 在 我 们 对数 组 x 做 了 填 充之后, 所有对 x\n和 y 的引用 的命 中率是 多少?\nm 为什么用中间的位来做 索引\n你也许 会奇怪, 为什 么 高速 缓 存 用 中间的位 来作为 组 索 引 , 而不是 用 高 位 。 为什么 用中间的位 更好 , 是 有 很 好 的 原 因 的 。 图 6-31 说 明 了 原 因。 如 果 高 位 用做 索引, 那么. 一些连续的 内存块就会映射到相同的 高速缓 存 块 。例如 , 在 图 中, 头四 个块映射到笫 一\n个高速 缓 存 组 , 笫 二 个四个块映射到笫二 个组, 依 此 类推。如 果一 个程序 有良好的 空间局 部 性 , 顺 序 扫 描 一 个数组的元素, 那么在 任 何 时 刻 , 高速 缓 存 都 只 保 存 着一个块大小i\n高位索引 中间位索引\nI\n凶00\n凶 01 凶 JO 卯 11\n4组高速缓存\n= # 组索引位\n图 6- 3 1 为什么用中间位来作为高速缓存的索引\n的数组内容。这样对高速 缓 存 的使用效率很低。相比较 而言, 以中间位作为 索引, 相 邻的块总是 映射到不同的 高速缓存行。在这里的情况中, 高速缓存能够存 放整个大小为 C 的数 组片 , 这里 C 是 高速 缓 存的大小。\n练习题 6. 11 假想一个高 速缓存, 用 地址 的 高 s 位做 组 索 引, 那 么 内存 块连续 的 片\n( ch un k ) 会被映射到 同 一个 高速 缓存组。\n每个这样的连续的数组片中有多少个块?\n考虑下面的代码 , 它 运行在 一 个高 速 缓存 形 式 为 CS , E, B, m)=(512, 1, 32,\n的系统上 : int array[4096];\nfor (i = O; i \u0026lt; 4096; i++)\nsum += array [i] ;\n在任意时刻,存储在高速缓存中的数组块的最大数量为多少?\n4. 3 组相联高速缓存\n直接映射高速缓存中冲突不命中造成的问题源千每个组只有一行(或者,按 照 我们的术语来描述就是 E = l) 这个限制。组相联高速 缓 存( set as socia t ive cac he ) 放松了这条限制, 所以每个 组都保存有多千一个的高速缓存行。一个 l \u0026lt; E \u0026lt; C/ B 的高速缓存通常称为 E 路 组相联 高速缓存。在下一节中,我 们会讨论 E = C/ B 这种特殊情况。图 6-32 展 示了一个 2 路组相联高速缓存的结构。\n组o 1 [1 门勹尸`冒三昙I}Ea每组2竹\n组I : I围记I 标记 1 1 高速缓存块\n匝 I 标记 11 高速缓存块\n匠 I 标记 1 1\n高速缓存块\n组S- 1: I 匡琶J I 标 记 11 苞速缓存块\n图 6-32 组相联高速缓存 O \u0026lt; E\u0026lt; C/ B)。在一个组相联高速缓存中, 每个组包含多于 一个行。这里的特例是一个 2 路组相联高速缓存\n组相联高速缓存中的组选择\n它的组选择与直接映射高速缓存的组选择一样, 组索引位标识组。图 6-33 总结了这个原理。\n组相联高速缓存中的行匹配和字选择\n组相联高速缓存中的行匹配比直接映射高速缓存中的更复杂,因 为它必须检查多个行的标记 位和有效位,以 确 定 所 请 求 的 字是 否 在 集合中。传统的内存是一个值的数组,以 地址作为输 入,并 返 回 存 储 在 那 个 地 址 的 值 。 另 一 方 面, 相联存储 器是 一 个 ( k e y , va lu e ) 对的数组 , 以 k e y 为输入,返 回 与 输 入 的 key 相匹配的( ke y , value ) 对中的 valu e 值。因此, 我们可以 把组相联高速缓存中的每个组都看成一个小的相联存储器, ke y 是 标 记和有效位, 而 valu e 就是块的内容。\n选择的组\n组0:\n会 组I :\n匣口亘勹[ 匡门居勹[\n匣 口亘勹1\n高速缓存块高速缓存块\n高速缓存块\n匣口亘三]I 高速缓存块\nt位 b位\n组S- 1:\n匡 荨 I\n高速缓存块\nI\nm-I\n标记\n组索引\n匝 口压口1 高速缓存块\n组相联高速缓存中的组选择\n图 6-34 展示了相联高速缓存中行匹配的基本思想。这里的一个重要思想就是组中的任何一行都可以包含任何映射到这个组的内存块。所以高速缓存必须搜索组中的每一行, 寻找一个有效的行,其标记与地址中的标记相匹配。如果高速缓存找到了这样一行,那么 我们就命中,块 偏移从这个块中选择一个字, 和前面一样。\n=1? ( I ) 有效位必须设置\n01234567\n选择的组 ( i ) :\n( 2 ) 高速缓存行中某一行\n的标记位必须匹配地址中的标记位。\n日 I I I wo I w, Iw 2 I 叭\n( 3 ) 如果 ( 1 ) 和 ( 2 ) 为真, 那么高速缓存命中,然后块偏移选择起始字节。\nt位\nI 0110 I\nm-1\n标记\ns位\n-:­\nl b\n组索引 块偏移\n图 6-34\n组相联高速缓存中的行匹配和字选择\n3. 组相联高速缓存中不命中时的行替换\n如果 CP U 请求的字不在组的任何一行中,那 么 就 是 缓 存 不 命 中 , 高速缓存必须从内存中取出包含这个字的块。不过,一旦高速缓存取出了这个块,该替换哪个行呢?当然, 如果有一个空行,那它就是个很好的候选。但是如果该组中没有空行,那么我们必须从中 选择一个非空的行,希 望 CP U 不 会 很 快 引 用 这个被替换的行。\n程序员很难在代码中利用高速缓存替换策略,所以在此我们不会过多地讲述其细节。 最简单的替换策略是随机选择要替换的行。其他更复杂的策略利用了局部性原理,以使在 比较近的 将来引用被替 换的行的概率最小。例如, 最不 常使 用 ( Leas t-F req uently-Used, LF U ) 策略会替换在过去某个时间窗口内引用次数最少的那一行。最近最少使 用 ( Least­ Recently-Used, LRU ) 策略会替换最后一次访问时间最久远的那一行。所有这些策略都需要额 外的 时间 和 硬 件 。 但 是 ,越 往存储器层次结构下面走, 远离 CPU , 一次不命中的开销就会更加昂贵,用更好的替换策略使得不命中最少也变得更加值得了。\n6. 4. 4 全相联高速缓存\n全相联高速 缓 存( fully associative cache) 是由一个包含所有高速缓存行的组(即E = C/\nB ) 组成的。图 6-35 给出了基本结构。\n匣 仁亘 三 l 厂 高速缓存块匡 口 压口 1 高速缓存块\n组\nE =唯一的一组中有E =C/B行\n匣 口 亘 口 1 高速缓存块\n图 6- 35 全相联高速缓存( E = C/ B) 。在全相联高速缓存中, 一个组包含所有的行\n全相联高速缓存中的组选择\n全相联高速缓存中的组选择非常简单,因 为只 有一个组,图 6-36 做了 个 小 结 。 注 意地址中没有组索引位,地址只被划分成了一个标记和一个块偏移。\nI 匡荨厂一高速缓存块\nm-1\n整个高速缓存只有一个组, 所以默认总是选择组0。\nt位 b位\n标记 块偏移\n组0 :\n1 击 勹 歪 勹 1 高速缓存块匠 勹 亘口 1 高速缓存块\n图 6-36 全相联高速缓存中的组选择。注意没有组索引位\n全相联高速缓存中的行匹配和字选择\n全相联高速缓存中的行匹配和字选择与组相联高速缓存中的是一样的, 如图 6- 37 所示。它们之间的区别主要是规模大小的问题。\n=l? ( l ) 有效位必须设置\n整个高速缓存\n( 2 ) 高速缓存行中某一行的 =?\n霍昙严匹配地址中户—[—、\nt位\nl 0110\nm-1\nb位\n100\n( 3 ) 如果 ( I ) 和 ( 2 ) 满足,那么 高速缓存命中,然后块偏移选\n择起始字节。\n。I\n标记 块偏移\n图 6-37 全相联高速缓存中的行匹配和字选择\n因为高速缓存电路必须并行地搜索许多相匹配的标记,构 造一个又大又快的相联高速缓存很困难,而且很昂贵。因此,全相联高速缓存只适合做小的高速缓存,例如虚拟内存 系统中的 翻译备用缓冲器 ( T LB) , 它缓存 页表项(见9. 6. 2 节)。\n压 练习题 6. 12 下面的问题能帮助你加强理解高速缓存是如何工作的。有如下假设:\n内存是字节寻址的。 内存访 问的 是 1 字 节 的 字(不是 4 字 节的 字)。 436 笫一部分 程序结构和执行\n地址的宽度为 1 3 位 。 高速 缓存是 2 路组相联的CE = 2 ) , 块 大小为 4 字 节 ( B = 4 ) , 有 8 个 组 ( 5 = 8 ) 。高速缓存的内容如下, 所有的数 字都是以 十 六进 制来表 示的:\n2路组相联高速缓存\n行0 行1\n。 。\n。 。\n2 EB\n3 06\nOB\n32 I 12 08 78 AD\n4 C7 I 06 78 07 cs 05 I 40 67 C2 3B\n6 91 I AO B7 26 2D FO 。\n7 46 DE 1 12 co 88 37\n下面的图展 示的是地址格 式(每个小方框 一个位)。指出(在图 中标 出)用来确定下列内容的字段:\nco 高速缓存块偏移\nCI 高速缓存组索引\nCT 高速缓存标记\n12 11 10 9 8 7 6 5 4 3 2 1 0\n芦 练 习题 6 . 13 假 设一个程序运行在练 习题 6-1 2 中的机器上, 它引用地 址 Ox 0E 3 4 处的1 个 字 节的 字。 指 出 访问的高 速 缓存条 目和十 六 进 制 表 示 的 返 回的 高 速 缓 存 字 节值。指 出是否会发 生缓存不命 中。 如果会出 现缓存不命 中, 用 “ 一” 来表 示 “ 返回的高速缓存字节”。\n地址格式(每个小方框一个 位):\n12 11 10 9 8 7 6 5 4 3 2 1 0\n内存引用:\n沁囡 练习题 6 . 14 对于存储器地址 Ox ODDS, 再做一遍 练 习 题 6 . 1 3 。\n地址格式(每个小方框一个位);\n12 11 10 9 8 7 6 5 4 3 2 I 0\n内存引用:\n参数 值 高速缓存块偏移 ( CO ) Ox 高速缓存组索引CC I) Ox 高速缓存标记 ( C T ) Ox 高速缓存命中? (是I否) 返回的高速缓存字节 Ox 区 练习题 6. 15 对于内存 地址 Ox 1 F E4 , 再做 一遍练 习题 6. 13。\n地址格式(每个小方框一个位): 12 11\n仁\n内存引用:\n10 9 8 7 6 5 .\nI I I I I I\n3 2 I\n[\n亿 练习题 6. 16\n制内存地址。\n对于练习题 6. 1 2 中的 高速 缓存 , 列 出 所 有的 在 组 3 中会命 中的 十 六进\n4. 5 有关写的问题\n正如我们看到的, 高速缓存关于读的操作非常简单。首先, 在高速缓存中查找所需字\nw 的副本 。如果命中, 立即返 回字 w 给 CPU。如果 不命中,从 存储器层次结构中较低层中取出包含字 w 的块, 将这个块 存储到某个高速缓存行中(可能会驱逐一个有效的行), 然后返回字 w 。\n写的情况就要复杂一些了。假设我们要写一个已经缓 存了的字 w ( 写命 中, w r it e hit ) 。在高速缓存 更新了它的 w 的副本之后, 怎么更新 w 在层次 结构中紧接着低一层中的副本呢? 最简单的 方法, 称为直写 ( w rit e- t h ro ug h ) , 就是立即将 w 的高速缓存块写回到紧接着的低一层中。虽然简单,但是直写的缺点是每次写都会引起总线流批。另一种方法,称为\n写回 ( writ e- back ) , 尽可能地推迟更新,只有当替换算法要驱逐这个更新过的块时,才把\n它写到紧接着的低一层中。由于局部性,写回能显著地减少总线流量,但是它的缺点是增 加了复 杂性。高速缓存必须为每个高速缓存行维护一个额外的修改位 ( d ir t y bit), 表明这个高速 缓存块是否被修改过。\n另一个问 题是如何处理写不命中。一种方法, 称为写分配( w rite-a llocat e ) , 加载相应的低一层中的块到高速缓存中,然后更新这个高速缓存块。写分配试图利用写的空间局部 性,但是缺点是每次不命中都会导致一个块从低一层传送到高速缓存。另一种方法,称为 非写 分配( not- w r ite-a lloca te ) , 避开高速缓存, 直接把这个字写到低一层中。直写高速缓存通常 是非写分配的。写回高速缓存通常是写分配的。\n为写操作优 化高速缓存是一个细致而困难的问题, 在此我们只略讲皮毛。细节随系统的不同而 不同, 而且通常 是私有的, 文档记录不详细。对于试图 编写高速缓存比较友好的\n程序的程序员来说,我们建议在心里采用一个使用写回和写分配的高速缓存的模型。这样建议有几个原因。通常,由千较长的传送时间,存储器层次结构中较低层的缓存更可能使用写回, 而不是直写。例如, 虚拟内存系统(用主存作为存储在 磁盘上的 块的缓存)只使用写回。但是由于逻辑电路密度的提高,写回的高复杂性也越来越不成为阻碍了,我们在现代系统的所有层次上都能看到写回缓存。所以这种假设符合当前的趋势。假设使用写回写分配方法的另一个原因是,它与处理读的方式相对称,因为写回写分配试图利用局部性, 因此,我们可以在高层次上开发我们的程序,展示良好的空间和时间局部性,而不是试图为某一个存储器系统进行优化。\n6. 4. 6 一个真实的高速缓存层次结构的解剖\n到目前为止,我们一直假设高速缓存只保存程序数据。不过,实际上,高速缓存既保 存数据, 也保存指令。只保存指令的高速缓存称为 i-ca che 。只保存程序数据的高速缓存称为 d-ca che。既保存指令又包括数 据的高速缓存称为统 一的 高速缓存( unified cache ) 。现代处理器包括独立的 i- cache 和 cl- ca che 。这样做有很多原因。有两个独立 的高速缓存, 处理器能够同时读一个指令字和一个数据字。i-cache 通常是只 读的, 因此比较简单。通常会针对不同的访问模式来优化这两个高速缓存,它们可以有不同的块大小,相联度和容量。使 用不同的高速缓存也确保了数据访问不会与指令访问形成冲突不命中,反过来也是一样, 代价就是可能会引起容量不命中增加。\n图 6-38 给出了 Intel Core i7 处理器的高速缓存层次结构。每个 CPU 芯片有四个核。每个核有自己私有的 L l i-cac he 、L l cl- cache 和 L2 统一的高速缓存。所有的核共享片上L3 统一的高速缓存。这个层次结构的一个有趣的特性是所有的 SRAM 高速缓存存储器都 在 CP U 芯片上。\n处理器封装\nL3统一的高速缓存\n- \u0026lsquo;-. - - - - - _-\u0026rsquo;\u0026ndash; - - - - - -气产尸产 ) J :\n图 6- 38 Intel Core i7 的高速缓 存层次结构\n图 6-39 总结了 Core i7 高速缓存的 基本特性。\n高速缓存类型 访问时间(周期) 高速缓存大小 CC) 相联度 ( £ ) 块大小 CB ) 组数 ( S ) LI i-cache 4 32KB 8 648 64 LI d-cache 4 32KB 8 64B 64 L2统一的高速缓存 10 256KB 8 64B 512 L3统一的高速缓存 40-75 8MB 16 64B 8192 6. 4. 7\n图 6-39 Core i7 高速缓存层次结构的特性\n高速缓存参数的性能影响\n有许多指标来衡量高速缓存的性能:\n不命 中率 ( m is s r a t e ) 。在一 个程序执行或程序的一部分执行期间,内 存引用不命中的比率。它是这样计算的: 不命 中数 量/引 用数 量。 命中率( h it ra t e ) 。命 中的内存引用比率。它等于 1 一不命 中率。\n命中时间 ( h it t im e ) 。从高速缓存传送一个字到 C P U 所需的时间, 包括组选择、行确认和字选择的时 间。对于 L1 高速缓存来说,命 中时间的数量级是几个时钟周期。\n不命 中处罚 ( m is s p e n al t y ) 。由于不命中所需要的额外的时间。L1 不命中需要从 L2 得到服务的处罚 ,通常 是数 10 个周期 ;从 L3 得到服 务的处罚, 50 个周期;,从 主存得到的服务的处罚 , 200 个周期。\n优化高速缓存的成本和性能的折中是一项很精细的工作, 它需要在现实的基准程序代码上进行大量的模拟, 因此超出了我们讨论的范酣。不过, 还是可以认识一些定 性的折 中考量的。\n高速缓存大小的影响\n一方面,较大的高速缓存可能会提高命中率。另一方面,使大存储器运行得更快总是 要难一些的 。结果, 较大的高速缓存可能会增加命中时间。这解释了为什么 L1 高速缓存比 L2 高速缓存小 ,以 及为什么 L2 高速缓存比 L3 高速缓存小 。\n块大小的 影响\n大的块有利有弊。一方面,较大的块能利用程序中可能存在的空间局部性,帮助提高 命中率。 不过, 对于给定的高速缓存 大小, 块越大就 意味着高速缓存行数越少,这 会损害时间局部性比空间局部性更好的程序中的命中率。较大的块对不命中处罚也有负面影响, 因为块越大 , 传送时间就越长。现代系统(如C o r e i7 ) 会折中使高速缓存块包含 64 个字节。\n相联度的 影响\n这里的问 题是参数 E 选择的影响 , E 是每个组中高速缓存行数。较高的相联度(也就是 E 的值较大)的优点是降低了高速缓存 由于冲突不 命中出现抖动的可能性。不过, 较高的相联 度会造成 较高的成本。较高的相联度实现起来很昂 贵, 而且很 难使之速度变快。每一行需要更多的标 记位, 每一行需 要额外的 LRU 状态位和额外的控制逻辑。较高的相联度会增加命中时间,因为复杂性增加了,另外,还会增加不命中处罚,因为选择牺牲行的 复杂性 也增加了。\n相联度的选择最终变成了命中时间 和不命中处罚 之间的折中。传统上, 努力争取时钟频率的高性能系统会 为 L1 高速缓存选择较低的相联度(这里的不命中处罚 只是几 个周期), 而在不 命中处罚比较高的较低层上使用比较小的相联度。例 如, I n t e l Core i7 系统中, L1 和 L2 高速缓存 是 8 路组相联的, 而 L3 高速缓存是 16 路组相联的。\n写策略的 影响\n直写高 速缓存比较容易实现, 而且能使用独立于高速缓存的写缓冲 区 ( w r it e buffer),\n用来更 新内存。此外, 读不命中开销没这么大, 因为它们不会触发内存写。另一方面,写\n回高速缓存引起的传送比较少,它允 许更多的到内存的带宽用于执行 OMA 的 1/0 设备。此外 , 越往层次结构下面走 , 传送时间增加, 减少传送的数量就变得更 加重要。一般而言, 高速缓存越往下 层,越 可能使用写回而不是直写。\n日 日 高速缓存行、组和块有什么区别?\n很容易混淆高速缓存行、组和块之间的区别。让我们来回顾一下这些概念,确 保概念清晰:\n块是一个固定大小的信息 包, 在高速缓存和主存(或下一层高速缓存)之间来回传送。 行是高速 缓存中的一个容 器,存 储块以及其他信 息(例如 有效位和标记位)。\n组是一个或 多 个行的集合 。直接映 射高速 缓存中的组只由一行组成。组相联和全相联高速缓存中的 组是由多 个行组成的。\n在直接映射高速缓 存中, 组和行实际上 是等价的 。不过 , 在相联高速缓存中, 组和行是很不一 样的, 这两个词 不能互换 使用。\n因为一 行总是存储一个块 , 术语“行” 和“块“ 通常互换 使用。例如, 系统专 家总是说高速缓 存的“行大小", 实际上 他们指 的是块大小。这样的 用 法十分普遍,只要你理解块和行 之间的 区别 , 它不会 造成任何误会。\n5 编写高速缓存友好的代码\n在 6. 2 节中, 我们介绍了局部性的思想, 而且定性地谈了一下什么会具有良好的局部性。明白了高速缓存存储器是如何工作的,我们就能更加准确一些了。局部性比较好的程 序更容易有较低的不命中率,而不命中率较低的程序往往比不命中率较高的程序运行得更 快。因此,从 具 有良好局部性的意义上来说 , 好的程序员 总是应该试着去编写高速缓存友 好( ca c he fr ie nd ly ) 的代码。下面就是我们用来确保代码高速缓存友好的基本方法 。\n让最常见的情况运 行得快。程序通常把大部分时间都花在少僵的核心函数上,而这些函数通常把大部分时间都 花在了 少量循环上。所以要把注意力集中在核心函数里的循环上, 而忽略其他部分。\n2 ) 尽量减 小每 个循 环 内部 的缓存不命 中数量。在其他条件(例如加载和存储的总次\n数)相同的情况下, 不命中率较低的循环运行得更快。\n为了看看实际上这是怎么工作的 , 考虑 6. 2 节中的函数 s umv e c :\nint sumvec(int v[N])\n{\ninti, sum= O;\nfor (i = 0; i \u0026lt; N; i ++) sum+= v[i];\nreturn sum;\n这个函数高速缓存友好吗? 首先, 注意对 于局部变量 1 和 s um, 循环体有良好的时间局部性。实际上,因为它们都是局部变量,任何合理的优化编译器都会把它们缓存在寄存器文 件中, 也就是存储器层次结 构的最高层中。现在考虑一下对向量 v 的步长为 1 的引用。一般而言, 如果一个高速缓存的块大小为 B 字节, 那么一个步长为 K 的引用模式(这里 k 是以字为单位的)平均每次循环迭代 会有 m in ( 1 , (wordsize X k) / B) 次缓存不命中。当 k = l 时 ,它 取最小值, 所以对 v 的步长为 1 的引用确实是高速缓存友好的。例如,假 设 v 是块对齐的, 字为 4 个字节, 高速缓存块为 4 个字,而 高速缓存初始为空(冷高速缓存)。然\n后,无 论 是 什 么 样的高速缓存结构,对 v 的 引 用 都 会得到下面的命中和不命中模式:\nv [ i l i=O i=I i=2 i=3 i=4 i=S i=6 i=7\n1 访问顺序, 命中[h]或不命中 [m] I/ I [ml I 2 [h] I 3 (h] I 4 [h] I 5 [m) I 6 [h] I 7 [h] 8 [h]\n在这个例子中,对 v [ O] 的 引 用 会 不命中, 而相应的包含 v [ O] ~ v [ 3 ] 的 块 会 被从内存加载到 高速缓存中。因此, 接下来三个引用都会命中。对 v [ 4 ] 的引 用会导致不命中, 而一个新 的块被加载到高速缓存中, 接下来的三个引用都命中,依 此类推。总的来说, 四 个引用中, 三个会命中,在 这种冷缓存的情况下, 这是我们所能做到的最好的情况了。\n总之,简 单 的 s u mv e c 示例说明了两个关千编写高速缓存友好的代码的重要问题:\n对 局 部 变量的反复引用是好的,因 为编译器能够将它们缓存在寄存器文件中(时间局部性)。\n步长为 1 的引用模式是好的,因 为存储器层次结构中所有层次上的缓存都是将数据存储为连续的块(空间局部性)。\n在对多维数组进行操作的程序中, 空间局 部性尤其重要。例如, 考 虑 6. 2 节 中 的\ns umar r a y r o ws 函 数 ,它 按 照 行 优 先 顺 序对一个二维数组的元素求 和:\nint s umarr a yr o 甘 s (int a[M] [NJ)\n{\ninti, j, sum= O;\nfor (i = 0; i \u0026lt; M; i ++)\nfor (j = O; j \u0026lt; N; j++) sum += a[i] [j];\nreturn sum;\n由于 C 语言以行优先顺序存储数组,所 以 这 个函数中的内循环有与 s u rnv e c 一样好的步长为 1 的访问 模式 。例如,假 设 我们对这个高速缓存做与对 s urnv e c 一 样的假设。那么对数组 a 的引用会得到下面的命中和不命中模式:\na [ i J f j J J=O\nJ = I\n)=2\n)=3\n}=4\nj=5\nj=6\nj=7\n但是如果我们做一个看似无伤大雅的改变—— 交换循环的次序,看 看 会 发生 什么 :\nint sumarraycols(int a[M] [N])\n{\ninti, j, sum= O;\nfor (j = 0; j \u0026lt; N; j++)\nfor (i = 0; i \u0026lt; M; i ++) sum += a [i] [j] ;\nreturn sum;\n在这种情况中, 我们是一列一列而不是一行一行地扫描数组的。如果我们够幸运, 整 个数组都在 高速缓存中,那 么 我们也会有相同的不命中率 1/ 4。不过, 如果数组比高速缓存要\n大(更可能出现这种情况), 那 么 每 次 对 a [il [j l 的 访 问 都 会 不 命 中!\na [il [j l )=0 J=I j=2 J=3 J = 4 j=5 J=6 j = 7 i = 0 I [m) 5 [m) 9[m] 13 [m] 17 [m) 21 (m] 25 (m] 29 (m) i = I 2 [m) 6 (m) IO[m) 141m) 18 [m) 22 [m) 26(m] 30 fm] i=2 3 [m) 7[m] II [m] 15 [m) 19[m] 23 [m) 27 [m) 31 (m] i= 3 4[m] 8 [m) 12 [m) 16 [ml 20 [m] 24 [m) 28 [m] 32 [m) 较 高 的不 命 中 率 对 运 行 时 间 可 以 有 显 著 的 影 响 。 例 如 , 在 桌 面 机 器 上 , s u mar r a y­ r o ws 运 行 速 度 比 s u marr a y c o l s 快 25 倍。总之 , 程 序 员 应 该 注 意 他 们 程 序 中 的 局部性, 试着编写利用局部性的程序。\n; 练习题 6. 17 在信号处理和科学计算的应用中,转置矩阵的行和列是一个很重要的问题。从局部性的角 度 来 看, 它 也 很 有 趣, 因 为 它 的 引 用 模 式 既 是 以行 为 主 ( ro w­ w is e ) 的, 也 是以 列 为 主( co l u m n- w is e ) 的。例如, 考虑下面的转暨 函数:\ntypedef int array[2] [2];\n3 void transposel(array dst, array src)\n4 {\ninti, j;\n7 for (i = O; i \u0026lt; 2; i++) {\n8 for (j = O; j \u0026lt; 2; j++) {\n9 dst [j] [i] = src[i] [j] ; 10 }\n}\n12 }\n假设 在一 台具 有如下属性的机器上运行这段代码 :\nsizeof (int) ==4。 sr c 数组从地址 0 开始 , d s t 数组从地址 1 6 ( 十进制)开始。 只有一个 L1 数据高速缓存, 它是 直接映射的、直写和 写分 配的, 块 大小为 8 个字节。 这个高 速 缓存总的大小 为 1 6 个 数据 字 节 , 一开始是 空 的。 对 sr c 和 d s t 数组 的访问 分别是读和 写 不命 中的唯 一来 源。 对每个r o w 和 c o l , 指明 对 s r c [ row] [col ) 和 d s t [row] [col ] 的 访问是命中( h)\n还是 不命 中( m ) 。 例如, 读 s r c [OJ [ OJ 会 不命 中, 写 d s t [ O J [ OJ 也不命 中。\nd s t 数组\n列0 列 l\nm 0行\n1行\n对于一个大小 为 32 数据 字 节的高速缓存重 复这 个练 习。 sr c数组\n列0 列l\nm\n沁 员 练习题 6. 18 最 近 一 个很 成 功 的 游 戏 S im A q ua rium 的 核心就是 一 个 紧 密 循 环 ( tight loo p ) , 它计算 256 个 海藻( algae ) 的平均位置。 在一 台具 有块大小为 16 字 节 ( B = 1 6 ) 、整个大小为 10 24 字 节的直接映射数据缓存的机器 上测量它的高速缓存性能。 定 义如下:\nstruct algae_position { 2 int x; 3 int y; 4 } ; struct algae_position grid[16] [16]; int total_x = 0, total_y = O; inti, j;\n还有如下假设:\nsizeof ( i n t ) ==4 。\ng r i d 从内存地 址 0 开始。\n这个高速缓存开始时是空的。\n唯一的内存 访问是 对数 组 gr i d 的元素的 访问。放在寄存器中。\n确定下面代码的高速缓存性能:\nfor (i = O; i \u0026lt; 16; i++) {\nfor (j = 0; j \u0026lt; 16; j++) { total_x += grid[i] [j] .x;\n变量 i 、 j 、total x和七0 七a l y 存\n}\nfor (i = O; i \u0026lt; 16; i++) {\nfor (j = O; j \u0026lt; 16; j++) { total_y += grid[i] [j] .y;\n10 }\n11 }\n读总数是多少?\n缓存不命中的读总数是多少?\n不命中率是多少?\n饬 练习题 6. 19 给定 练 习题 6. 18 的假设, 确定下列代码的高速缓存性能:\nfor (i = O; i \u0026lt; 16; i++){\nfor (j = 0; j \u0026lt; 16; j++) { total_x +=grid[j] [i] .x; total_y +=grid[j] [i] .y;\n}\n}\n读总数是多少?\n高速缓存不命中的读总数是多少?\n不命中率是多少?\n如果高速缓存有两倍大,那么不命中率会是多少呢?\n讫§ 练习题 6. 20 给定 练 习题 6. 18 的假 设, 确定 下列代码 的高 速缓存 性能 :\nfor (i = O; i \u0026lt; 16; i++){\nfor (j = O; j \u0026lt; 16; j++) { total_x +=grid[i] [j] .x; totaLy +=grid[i] [j] .y;\n}\n}\n读总数是多少?\n高速缓存不命中的读总数是多少?\n不命中率是多少?\n如果高速缓存有两倍大,那么不命中率会是多少呢?\n6. 6 综合:高速缓存对程序性能的影响\n本节通过研究高速缓存对运行在实际机器上的程序的性能影响,综合了我们对存储器层次结构的讨论。\n6. 6. 1 存储器山\n一个程序从存储系统中读数据的速率称为读吞吐 量( r e a d throughput), 或者有时称为读带宽( r e a d b a n d w id t h ) 。如果一个程序在 s 秒的时间段内读 n 个字节,那 么 这段时间内的读吞吐量就等于 n / s , 通常以兆字节每秒( M B / s ) 为单位。\n如果我们要编写一个程序,它从 一个紧密程序循环( t ig h t program loop) 中发出一系列读请求 ,那 么测 量出 的读 吞 吐 量能让我们看到对于这个读序列来说的存储系统的性能。图 6-40\ncode/mem/mountainlmountain.c\nlong data[MAXELEMS];\n2\nI* The global array we\u0026rsquo;ll be traversing *f\nI* test - Iterate over first \u0026ldquo;elems\u0026rdquo; elements of array \u0026ldquo;data\u0026rdquo; with\n* stride of \u0026ldquo;stride\u0026rdquo;, using 4 x 4 loop unrolling. s *I\n6 int test (int elems, int stride)\n7 {\nlong i, sx2 = stride*2, sx3 = stride*3, sx4 = stride*4;\nlong accO = 0, accl = 0, acc2 = 0, acc3 = O; 1o long length = el ems;\n11 long limit = length - sx4; 12\n/* Combine 4 elements at a time *I\nfor (i = O; i \u0026lt; 1i 工 t ; i += sx4) {\naccO = accO + data [i] ;\naccl = acc1 + data[i+stride];\nacc2 = acc2 + data[i +sx2] ;\nacc3 = acc3 + data[i +sx3] ; 19 }\n20\nI* Finish any remaining elements *I\nfor (; i \u0026lt; length; i+=stride) {\naccO = accO + data [i] ; 24 }\n25 return ((accO + accl) + (acc2 + acc3)); 26 }\n27\n28 I* run - Run test(elems, stride) andreturn read throughput (MB/s). \u0026ldquo;size\u0026rdquo; is in bytes, \u0026ldquo;stride\u0026rdquo; is in array elements, and Mhz is CPU clock frequency in Mhz.\n32 double run (int size, int stride, double Mhz) 33 {\ndouble cycles; int elerns = size / sizeof (double) ; 36 test (elerns, stride); I* Warm up the cache *I cycles = fcyc2(test, elerns, stride, 0); I* Call test(elerns,stride) *I return (size / stride) / (cycles / Mhz); I* Convert cycles to MB/s *I 40 } code/memlmountain/mountain.c\n图 6-40 测量和计 算读吞吐量的函数。我们可以通过以不同的 s i ze ( 对应千时间局部性)和\ns tr i d e ( 对应于空间局部性)的值来调用r un 函数, 产生某台计算机的存储器山\n给出了一对测量某个读序列读吞吐量的函数。\nt e s t 函数通过以 步长 s tr i de 扫描一个数组的头 e l e ms 个元素来产生读序列。为了提高内 循环中 可用 的并行性, 使用了 4 X 4 展开(见5. 9 节)。r un 函数是一个包装函数, 调用 t e 江 函数, 并返回测最出的读吞吐量。第 37 行对 t e s t 函数的词用会对高速缓存做暖身。第 38 行的 f c yc 2 函数以参数 e l e ms 调用 t e s t 函数, 并估计 t e s t 函数的运行时间,以 CP U 周期为单位。注意,r un 函数的参数 s i ze 是以字节为单 位的, 而 t e s t 函数对应的参数 e l e ms 是以数组元素为单位的。另外 , 注意 第 39 行将 MB/ s 计算为 1矿字节/秒, 而不是 220 字节/秒。\nr un 函数的参数 s i ze 和 s tr i de 允许我们控制产生出的读序列的时间和空间局部性程度。 s i ze 的值越小, 得到的工作集越小 , 因此时间局部性越好。s tr i de 的值越小, 得到的空间 局部性越好。如果我们反复以 不同的 s i ze 和 s tr i de 值调用r un 函数, 那么我们就能 得到一个读带 宽的时间和空间 局部性的二维函数, 称为存储器山 ( memor y moun­ tain )[ ll 2] 。\n每个计算 机都有表明它存储器系统的能力特色的唯一的存储器山。例如, 图 6- 41 展示了 Intel Core i7 系统的存储器山。在这个例子中, s i ze 从 16KB 变到 128K B, stride 从 1 变到 1 2 个元素,每 个元素是一个 8 个字节的 l o ng i nt 。\n空间局部性\n的斜坡\n16000\n4000\n2000\nCore i7 Haswell\n2.1 GHz\n32 KB LI 高速缓存\n256 KB L2高速缓存\n8MB L3高速缓存\n64B块大小\n时间局部性\n山脊\n3 1.28K\n512K\n2M\n盓\n128M\n大小(字节)\n图 6- 41 存储器山。展示了读吞吐量, 它是时间 和空间局部性的函数\n这座 Core i7 山的地形地势展现了一个很丰富 的结构。垂直千大小轴的是四条山脊, 分别对应 千工作 集完 全在 Ll 高速缓存、LZ 高速缓存、L3 高速缓存和主存 内的时间局部性区域。注意 , Ll 山脊的最高点(那里 CP U 读速率为 14GB / s ) 与主存山脊的最 低点(那里\nCPU 读速率 为 900M B/ s) 之间的差别有一个 数量级。\n在 LZ、 L3 和主存山脊上,随 着步长的增加, 有一个空间局部性的斜坡, 空间局部性下降。注意,即使当工作集太大,不能全都装进任何一个高速缓存时,主存山脊的最高点 也比它的最低点高 8 倍。因此,即 使是当程序的时间局部性很差时, 空间局部性仍然能补救,并且是非常重要的。\n有一条特别有趣的平 坦的山脊线, 对于步长 1 垂直于步长轴, 此时读吞吐輩相对保持不变, 为 1 2GB/ s , 即使工作集超出了 L1 和 L2 的大小。这显然是由 于 Core i7 存储器系统中的硬件预取( prefe tch ing ) 机制, 它 会自动 地识别顺序的、步长为 1 的引用模式, 试图在一些块被访问之前, 将它们取到高 速缓存中。虽然文档里没有记录这种预取算法的 细节, 但是从存储器山可以明显池看到这个算法对小步长效果最好 这也是代码中要使用步长 为 1 的顺序访问的另一个理由。\n如果我们从这座山 中取出一个片段, 保持步长为常数, 如图 6-42 所示, 我们就能很 清楚地看到高速缓存的大小和时间局部性对性能的影响了。大小最大为 32KB 的工作集完全能放进 Ll cl-c ache 中, 因此, 读都是由 L1 来服务的, 吞吐最保持在峰值 12GB/ s 处。大小最大为 256KB 的工作集完全能放进统一的 L2 高速缓存中 , 对千大小 最大为 8 M , 工作集完全 能放进统一的 L3 高速缓存中。更大的工作集大小 主要由主存来 服务。\n14 000\n12 000\n10 000\n乏芦 8000\n、\n主存区域 L3高速缓存区域\nLI高速\nL2高速缓存区域 缓存区域\ni 6000\n4000\n2000\n0 .\n工作集大小(字节)\n图 6-42 存储 器山中时间局部性的山脊。这幅图展示了图 6-41 中 s 七r i cte = S 时 的 一 个 片段\nL2 和 L3 高速缓存区域最左边的边缘上读吞吐量的下降很有趣, 此时工作集大小为256K B 和 8 MB, 等于对应的高速缓 存的大小。为什么会出现这样的下降, 还不是完全清楚 。要确认的唯一方法就是执行一个详细的高速缓存模 拟, 但是这些下降很有可能是与其他数据和代码行的冲突造成的。\n以相反的方向横切这座山,保持工作集大小不变,我们从中能看到空间局部性对读吞吐量 的影响。例如, 图 6-43 展示了丁作集大小固定为 4MB 时的片段。这个片段是沿着图 6- 41 中的L3 山脊切的,这里, 工作集完全能够放到 L3 高速缓存中, 但是对 L2 高速缓存来说太大了。\n注意随着步长从 1 个字增长到 8 个字, 读吞吐量是如何平稳地下降的。在山的这个区域中, L2 中的读不命中会导致一个块从 L3 传送到 L2。后 面在 L2 中这个块上会有一定数量的命中, 这是取决千步长的 。随着步长的增加, L 2 不命中与 L2 命中的比值也增加了。因为服务不命中要比命中更慢, 所以读吞 吐量也下降了。一旦步长达到了 8 个字, 在这个系统上就等于块的 大小 64 个字节了, 每个读请求在 L2 中都会不命中, 必 须从 L3 服务。\n因此, 对于至少为 8 个字的步长来说, 读吞吐最是一个常数速率, 是由从 L3 传送高速缓存块到 L2 的速率决定的。\n12 000\n10 000\n8000\n6000\n4000\n2000\ns1 s2 s3 s4 s5 s6 s7 s8\n步长 ( X 8字节)\ns9 s10 s11\n图 6-43 一个空间局部性的斜坡。这幅图展示了图 6-41 中大小= 4MB 时的一个片段\n总结一下我们对存储器山的讨论,存储器系统的性能不是一个数字就能描述的。相 反,它是一座时间和空间局部性的山,这座山的上升高度差别可以超过一个数量级。明智 的程序员会试图构造他们的程序,使得程序运行在山峰而不是低谷。目标就是利用时间局 部性, 使得频繁使用的 字从 L1 中取出, 还要 利用空间局部性, 使得尽可能多的字从一个L1 高速缓存行 中访问到。\n讫 练习题 6. 21 利用 图 6-41 中的存储器 山来估计从 L1 d- ca c h e 中读 一个 8 字 节的 字所需要的 时间(以 CPU 周期为 单位)。\n6. 2. 重新排列循环以提高空间局部性\n考虑一对 n X n 矩阵相乘的问题: C= AB 。例如, 如果 n = 2 , 那么\n[ C11C 12 ] = [ au a12][加b 12 ] C21 c22 a 21a22 b21 b22\n其中\nc11 =a11b11 +a12b21\nC1z = a 11 如 + a12 b22\nc21 = a 21 b11 +a22 b21 C22 = a 21 如 + a 22b 22\n矩阵乘法 函数通常是用 3 个嵌套的循环来实现的,分别 用 索引 z、 1 和K 来标识。如果改变循环的次 序, 对代码进行一些其他的小改动, 我们就能 得到矩阵乘法的 6 个在功能上等价的版本 , 如图 6-44 所示。每个版本都以它循环的顺 序来唯一地标识。\n在高层次来看, 这 6 个版本是非常相似的。如果加 法是可结 合的, 那么每个版本计算出的结果完全 一样气 每个版本总共都执行 O ( n3 ) 个操作, 而加法和乘法的数量相同。A\n8 正如我们在第 2 章中学到的 ,浮点 加法是可交换的 , 但是通常是 不可结 合的。实际 上,如果 矩阵不 把极大的数和极小的数混在一起一存储物理属性的矩阵常常这样,那么假设浮点加法是可结合的也是合理的。\n和B 的 矿个 元素中的每一个都要读 n 次;计 算 C 的 示 个 元素中的 每一个都要对 n 个值求和。不过,如果分析最里层循环迭代的行为,我们发现在访问数量和局部性上还是有区别的。为了分析,我们做了如下假设: - - ,.三\n每个数组都是一个 do ub l e 类型的 n X n 的数组, s i z e o f (d o ub l e ) = B。\n只 有一个高速缓存,其 块大小为 32 字节( B = 32 ) 。 ',\n数组大小 n 很大, 以至于矩阵的 一行都不能完全装进 Ll 高速缓存中。\n·编译器将局部变量存储到寄存器中,因此循环内对局部变量的引用不需要任何加载\n或存储指令\ncodelm.emlmatmult/mm.c for (i = 0; i \u0026lt;::i;i; i++)\nfor (j = 0; j \u0026lt; n; j ++)\nsum= 0.0;\nfor (k = 0; k \u0026lt; n ;, k++)\nsum += A[i] [kl*B[k] [j];\nfor (j for\n, \u0026lsquo;Cl-\ncode/memlmatmultlmm.c\n== O; j \u0026lt; \u0026rsquo;n; · j ++)\n(i a;= 0 ; i \u0026lt; n ; i ++) {\nsum = 0., , ,,,\n, f or , (k = 0; k \u0026lt; n; k++)\n\u0026rsquo; ··· · s um ,f = A [i] [k] *B [k] [j];\nC [i] [j] += suin;\n; ·; . ,.\nC[il[j] += sum;\n·\u0026rsquo;·_;, • h ide/ me成lmatmultlmm.,c - _\ncode/mem/matmultlmm.c\n. • _ a) ij k版 本 、. . , ` 一 ,.. ,:._.,· . _,.. ,,• • _b) j ik版本 , . . .\n, • \u0026rsquo; : : .,·\n;,, : ., \u0026lsquo;、 . . . ..屯: ..,. .\n• .,.. ,,·. ,• , _, , ,. · I \u0026lsquo;· . \u0026hellip;_-\n., \u0026rsquo; , . , r· .. ..· ,\u0026rsquo;•_,\ncode/mem/matmultlinm.c code/mem/matm it\u0026rsquo;Wmm.c\nC .\u0026rsquo; , . \u0026rsquo; . . . . • ·,: · \u0026rsquo; . .•. · , .· , ·, ., . , . . .. , \u0026rsquo; . \u0026rsquo;\u0026rsquo; , j l \u0026rsquo; •.- ., , , , .、.•··. .. ·.\u0026rsquo;\u0026rsquo; t.,r. •.· · ·\u0026rsquo;\u0026rsquo; · 千·二J- ,\n,\u0026gt; '\n, · , : f or · ( j \u0026rsquo; ==· O; j \u0026lt; Ii; j +\u0026rsquo;+) , \u0026rsquo; ··• f \u0026rsquo; fo r \u0026rsquo; \u0026rsquo; Ck •,;; O; \u0026lsquo;.k \u0026lsquo;\u0026lt; ri;\u0026rsquo; k ++) ·····-\n2 ;· \u0026lsquo;.\u0026lt; \u0026lt; .\u0026rsquo; fo r ( k: =, \u0026lsquo;O; k \u0026lt; ri.; k ++) { i- ;•. · , :_• ; :2 ·, ,, ;;, _\u0026lsquo;f or \\ f; ,,;_ o ;\u0026rsquo; j \u0026lt;: n ;;-;j ++f { \u0026lsquo;一,心 t 1\n3 . .. _,·.\n._ · .: r.\n:;,- J3[k]{jJ,; ., -:.\n:i .. ·,. ;,.· '\n) 3 ;; ; , i \u0026lsquo;. ) • :• r . r= _B[k} .f jJ ,;: \u0026gt;\u0026rsquo;::i ) -1\u0026rsquo; ; ;., ;:,: . :fi 心\n4 for (i = O; i \u0026lt; n; i++) 4 for (i =;o, O; _, i\n社吵 .,\n, 、 . . ! ..! 5 , C[i] [j] += A[i] [k]*r; . . 5 C i)(jJ += A[i] [k]*r;\n/ _.,:\u0026ndash; · ·, \u0026rsquo; }_ :-, :• . _ ; . :, -,-: . ,, · ; : , . i : - .·,.. ;. 6 \u0026rsquo; \\_: ·\u0026rsquo;·: j:\u0026rsquo; f: : \u0026rsquo; ,; ·\u0026rsquo;.,\u0026rsquo;. .:·\ncodelmem/matmult/mm.c\n, , \u0026rsquo; 飞 ;` ' ', 、\n.令\n钉\nc) j ld版本\ncodelmem/matmultlmm.c\nd ) 炉版本\ncode/mem/matmultlmm.c\n,; . ;\u0026rsquo;, .. . . . , \\\n, \u0026rsquo; , . . . . ··, ,,,•\n\u0026lsquo;产 i ,\u0026rsquo; ,\n.,.\nfor (k = O; k \u0026lt; Ii; k++,) · -, : . ..\n· · ·\nf or \u0026rsquo; Ci -\u0026rsquo; \u0026lsquo;= b;、.i\n\u0026lt; ri;· i ++) \u0026lsquo;. . , `\nfor (i = O; i \u0026lt; n; i++) { i ,\nr = A (i] [k] ; , • ,\nfor (j = O; j \u0026lt; n; j++) C[i] [j] += r*B[k] [j];\n}\ncodelmemlmatmultlmm.c\ne ) 的版本\nf o 士 ( k = O; k \u0026lt; n; k++) { r = A[i][k];\nfor (j = O; j \u0026lt; n; j++)\nC [i] [j] += A [i] [k] *r; rt d t\n心:• \u0026lt; }\ncode/mem/matmultlmm.c\nf) ikj版本\n图 6- 44 矩阵乘法的六个版本。每:个 版本都以它循环的顺序来唯一地标识\n, _,. ,: _;. . \u0026lsquo;! '\n_ ; , , . , .. .\n.~, -\n户:·, 占\u0026rsquo;\n_: l .:: f. ·. \u0026lsquo;..,-: ·:..-,;:- \u0026hellip; :·: . . ·-\u0026quot;: ; ·,, _ _.,i\n.,.\u0026gt;.._,.,.,,·;\u0026rsquo;··七,; ;一.,,., /,:'}·;; 言获\n图 6;,45总结了我们对内循环的分析结果。注意6个版本成对地形成了 3个等价类,\u0026rsquo;;._用内循环中访问的矩阵对来表示每个类。例如. 版本 ij k 和ji k 是类f.-1$的成员,: 因为它们在最内层的循环中引用的是矩阵A和I?(而不是C),。对千每个类,我们统计了每个内循环\n迭代中加载(读)和存储()写的数量,每次循环迭代中对A、B 和C的引用在高速缓存中丕\n命中的数量,以及每次迭代缓存不命中的总数。\n* 类 AB 例程的内循环(图t 44a 和图 6— b)以步长1 扫描数组 A 的- 行汗 因为每个高\n速缓存块保存四个 8 字节的字,A 的不命中率是每次迭代不 命中 0·.-25 次。另 一方 面,内\n循环以步长 n 扫描数组B 的一列。因 为 n 很大,每次对数组 B 的访问都会不命中,所以每次迭代总共会有 1 心 5 次不命中。\n矩阵乘法版本\n\u0026lsquo;, • (类);··,.\n匕 、 \u0026rsquo; . '\nijk \u0026amp;jik (AB)\n``\n丿kt\u0026amp; kji (AC)\nkij \u0026amp; ikj(BC) 图 6-45\n每次迭代\n加载次数 存储次数 A未命中次数 B未命中次数 C未命中次数 未命中总次数\n0.25 1.00 0.00 1.25\n1.00 0.00 LOO 2.00\n0.00 0.25 0.25 0.50\n矩阵乘法内循环的分析。6 个版本分为 3 个等价类,用 内循 环中访问 的数组对来 表示\n类 AC 例程的内循环(图6-44c 和图5 _:4 4d ) 有、一些问题。每次迭代执行两个加载和一个存储(相对千类AB例 程, 它们执行 2 个 加载而没有存储)。内循环以步长n 扫描A 和C 的列。名结 果是每次加载都会不命中 ,所 以每次迭代总共有两个不命中。注意 , 与类AB例程\n相比,交换循环降低了空间局部 性。\nBC 例程(图6 44e 和图 5:_44f)展示了一个很有趣的折中: 使用了两个加载和一个存储 ,\n它们比 AB 例程多需要一个内存操作。另一方 面, 因为内循环以步长为 1 的访问模式按行\n扫描B和c ·, 每 次迭代每个数组上的不命中率只有0 : 25 次不命中,所 以每次迭代总 共有\no·:so个不命中。\n图 6 4 6 小结了一个Cotei7 系统上矩阵乘法各个版本的性能 。这个图画出了测量出的每次内循环迭代所需 的CPU周期数作为数组大小( n) 的函数。、\n100\n睾\n; \u0026lsquo;. \\·: : , :· : \\ : .\n子\n太\n,.\u0026rsquo;\n50 100 150 200 250 300 350 400 450 500 550 600 650 7 的\n! 飞 .,.、·,:.:;-:\u0026rsquo;\n数组大小( n ) ..\n图 6- 46 Core· 17 矩阵乘法性能\n_·: .-,_\n;_ , · :\n对千这幅图有很多有意思的地方值得注意:\n对于大的 n 值, 即使每个版本都执行相同数量的浮点算术操作 , 最快的版本比最慢 、 的 版本运行得快几乎 40 俨口。\n. : . .\n每次迭代内存引用和不命中数量都相同的一对版本,有大致相同的测最性能。\n) • 内 存行为最糟糕的两个版本,就每次迭代 的访问 数量和不命中数量 而言,明 显地比\n其他4个 版本运行得慢 , 其他 4 个版本有较少的不命中 次数或者较少的 访问次数, 或者兼而有之。\n, • 在这个情况中 、, 与 内 存访问总数相比, 不命中率是一个更好的性能预测指标。例\n如,即 使 类 BC 例 程( 2 个 加 载和 1 个存储)在内循环中比类 AB 例程( 2 个加载)执行更多的内存引用,类 BC 例程(每次迭代有 0. 5 个不命中)比类AB 例程(每次迭代有\n1. 25 个不命中)性能还是要好很多。\n对于大的 n 值,最 快 的 一对版本( ki j 和心 )的性能保持不变。虽然这个数组远大于任何 SR A M 高速缓存存储器, 但 预 取 硬件足够聪明,能 够 认 出 步 长为 1 的访问模式 ,而 且速度足够快能够跟上内循环中的内存访问。这是设计这个内存系统的 Intel 的 工 程师所做的一项极好成就,向 程 序 员 提 供了甚至更多的鼓励,鼓 励 他们开发出具有良好空间局部性的程序。\nLi 使用分块来提高时间局部性\n有一项很有趣的技术, 称为 分 块( blocking ) , 它可以提高内循环的时间局部性。分块的大致思想是将一 个程 序 中的数 据结构组织 成的 大的 片 ( ch unk ) , 称 为 块 C blo ck) 。\n(在这个上下文中,“块” 指的是一个应 用级 的数据组块, 而 不是 高速 缓 存块。)这样构造程序,使 得 能够将一个片加 栽到 Ll 高 速 缓 存中, 并在这个片 中进行 所需的所有的读和写, 然后 丢掉这个片 ,加 栽下一 个片 ,依 此类推 。\n与为提 高空 间局部性所做 的简单循环变换 不同 , 分块使得代码更难阅读和理解。由于这个原因 , 它最适合 于优 化编译 器或 者频繁执行的库函数。由于 Core i7 有完善 的预取硬 件, 分块不会 提高矩阵 乘在 Core i7 上的性能。不过, 学习和理解这项技 术还是很有趣的, 因为它是 一个通用的 概念, 可以在一些没有 预取的 系统 上获得极大的性能收益。\n6. 3 在程序中利用局部性\n正如我们看到的,存储系统被组织成一个存储设备的层次结构,较小、较快的设备靠近顶部,较大、较慢的设备靠近底部。由千采用了这种层次结构,程序访问存储位置的实际速率不是一个数字能描述的。相反,它是一个变化很大的程序局部性的函数(我们称之为存储器山),变化可以有几个数量级。有良好局部性的程序从快速的高速缓存存储器中访问它的大部分数据。局部性差的程序从相对慢速的 DRA M 主存中访问它的大部分数据。\n理解存储器层次结构本质的程序员能够利用这些知识编写出更有效的程序,无论具体 的存储系统结构是怎样的。特别地, 我们推荐下列技术:\n将你的注意力集中在内循环上,大部分计算和内存访问都发生在这里。 通过按照数据对象存储在内存中的顺序、以步长为 1 的来读数据,从 而使得你程序中的空间局部性最大。\n一旦从存储器中读入了一个数据对象, 就 尽 可 能 多 地 使 用 它 ,从 而 使 得 程序中的时间局部性最大。\n6. 7 小结\n基本存储技术包括随机存储器 ( RAM) 、非易失性存储器( ROM) 和磁盘。 RAM 有两种基本类型。 静态 RAM(SRAM) 快一些, 但是也贵一些, 它既可以用做 CPU 芯片上的高速缓存 , 也可以用做芯片下的高速缓存。动态 RAM( DRAM) 慢一点,也便宜一些, 用做主存和图形帧缓 冲区。即使是在关电的时候 ,\nROM 也能保持它们的信息, 可以用来存储固件 。旋转磁盘是机械的非易失性存储设备 , 以每个位很低的成本保存大量的数据,但是其访问时间比 DRAM 长得多 。固态硬盘( SSD) 基丁非易失性的闪存 , 对某些应用来说,越来越成为旋转磁盘的具有吸引力的替代产品。\n一般而言, 较快的存储技术每个位会更贵 , 而且容量更小。这些技术的价格和性能属性正在以显 著\n厅-\n笫 6 章 存储器层次结构 451\n不同的速度变化着 。特别地, DRAM 和磁盘访问时间 远远大于 CPU 周期时间。系统 通过将存储器组织成存储设备的层次结构来弥补这些差异,在这个层次结构中,较小、较快的设备在顶部,较大、较慢的设备在底部。因为编写良好的程序有好的局部性,大多数数据都可以从较高层得到服务,结果就是存储系统能以较高层的速度运行,但却有较低层的成本和容社。\n程序员可以通过编写有良好空间和时间局部性的程序来显著地改进程序的运行时间。利用基于\nSRAM的高速缓存 存储器特别重要 。主要从高速缓存取数据的程序能 比主要从内 存取数据的程序 运行得快得多 。\n参考文献说明\n内存和磁盘技术 变化得很快 。根据我们的 经验, 最好的技术信息 来源是制造商维 护的 Web 页面。像Micron、Toshiba 和 Samsung 这样的公 司, 提供了丰富的当前有关内存设备的技术信息。Seagate 和Western D屯ital 的页 面也提供了类 似的有关 磁盘的有用信息。\n关于电路和逻辑设计的教科书提供了关于内存技术的详细信息[ 58 , 89] 。IEEE Spect rum 出版了 一系列有关 DRAM 的综述文章[ 55] 。计算机体系结构国际 会议( ISCA) 和高性能计算机体系结构 ( HPCA) 是关于 DRAM 存储性能特性的公 共论坛[ 28 , 29 , 18 ] 。\nWilkes 写了第一篇关千高速缓 存存储器的论文[ 117] 。Smith 写了一篇经典的综述[ 104] 。 Przyby lski 编写了一本 关于高速缓存设计的权威著作[ 86] 。 Hennessy 和 Patterson 提供了对高 速缓 存设计问题的全面讨论[ 46] 。Levinthal 写了一篇有关 Intel Core i7 的全面性能 指南[ 70] 。\nStricker 在[ 112] 中介绍了存储楛山的思想, 作为对存储 器系统的 全面描 述, 并且在后来的 工作描述中非正式 地提出了 术语“存储器山"。编译器研究者通过 自动执行我们 在 6. 6 节中讨论过的那些手工代码转换来增加 局部性[ 22, 32, 66. 72, 79, 87 , 119] 。Carter 和他的 同事们提出了一个高速缓存 可知晓 的内存控制器 (cac he-aware memory contro ller) [ 17] 。其他的研究 者开 发出了 高速缓存不知晓的 ( cache obliv­\nious)算法,它被设 计用来在不明确知 道底层高速 缓存存储 器结构的情况下也能运行得很好[ 30, 38, 39,\n9] 。\n关于构造和使用磁 盘存储设备也有大最的论 著。许多存储技术研究 者找寻 方法,将 单个的磁盘集合成更大、更健壮 和更安 全的存储池[ 20 , 40 , 41, 83, 121] 。其他研究 者找寻利用高速 缓存和局部性来改进磁盘 访问性能的方法[ 12, 21] 。像 Exokernel 这样的系统提供了更多的对磁盘和存储 器资 源的 用户级控制[ 57] 。像安 德鲁文件系统[ 78] 和 Coda[94] 这样的 系统, 将存储器层 次结构扩展到了计算机网络和移动笔记本电脑。 Schindler 和 Ganger 开发了一个有趣的工具 , 它能自动描述 SCSI 磁盘驱动器的构造和性能[ 95] 。研究者正 在研究构 造和使用基于闪存的 SSD的技术[ 8 , 81] 。\n家庭作业\n•• 6. 22\n假设要求你设计一个每条磁道位数固定的旋转磁盘。你知道每条磁道的位数是由最里层磁道的周 长决定的,可以假设它就是中间那个圆洞的周长。因此,如果你把磁盘中间的洞做得大一点,每 条磁道的 位数就会增大 , 但是总的 磁道数会减少 。如果用 r 来表示盘面的 半径, X • r 表示圆洞的半径, 那么 x 取什么值能使这个 磁盘的容量最大?\n估计访问 下面这个 磁盘上扇区的平均时间(以ms 为单位):\n假设一个 2MB 的文件, 由 512 个字节的逻辑块组 成,存储 在具 有下述特性的磁 盘驱动器上 :\n;\u0026rsquo;. ·:\u0026rsquo;·, ,,)\n. .\n\u0026rsquo; · \u0026rsquo; ) .. ,\n\u0026lt; -.气\u0026rsquo;- -\u0026rsquo;\n6 .\u0026lsquo;\u0026lsquo;2 5\n对于下面的 每种情况 , 假设程序顺序 地读文件的逻辑块, 一个接一个, 并且对第一个块定位读/ 写头的时间 等于 T\u0026hellip; ,.. k + T吓 g rotat,on o\n最好情况: 估计在所有可能的逻辑块到磁盘扇区的映射上读该文件所需 要的 最优时间(以ms 为单位)。\n随 机情况 :.估计如果块是随机映射到磁盘扇区上时读 该文件所需要 的时间(以ins为单位)。\n下 面的表给出了一些不同的高速缓存的 参数。对千每个 高速缓存,填 写出表中缺失的字段。记住\nm 是物理地址的 位数,C 是高速缓存大小(数据字节数), B 是以 字节为 单位的块大小, E 是相联\n、度 ,S 是高速缓存组数, t 是标记位数,s是组索引位数,而 b 是块偏移位数。、 ; \u0026lsquo;..•: : \u0026lsquo;、\n::\n} .., i;\n•\u0026rsquo; '\n.,.,.\n6 . 26 下面的表给出了一些 不同的高速缓存的 参数。你的任务是填写 出表中缺失的字段。记住 m炟物理\n地址的 位数, C 是高速缓存大小(数据字节数), B 是以字节为单位的 块大小, E 是相联度, S是高速缓存组数, t 是标记位数, s 是组索引位数,而 b 是块偏移位数。\n,\u0026rsquo;) -: ci ··.. ,:, , :\u0026lsquo;c\u0026rsquo;\n6 . 27 这个问题是关于练习题 6. 12 中的 高速缓存的。 列出 所有会在组 1 中命中的十六进制内 存地址。\n列出所有会 在组 6 中命中的十六进制内存地址 。\n•• 6.-!2 , 这 个问题是关于练习题 6. 12 中的高速缓 存的。\nA. 列 出所有会在组 2 中命中的十六进制内 存地址。\n. \u0026hellip;,, . B. 列出所有会在组 4中 命中的十六进制内存地址。 列出所有会在组 5 中命中的十六进制内存地址。\n列出所有会 在组 7 中命中的十六进 制内存地 址。\n•• 6 29 假设我们有一个具有如下属性的系统:\n内存 是字节寻址的 。 内存访问是对 1字 节字的(而不是 4 字 节字)。 地址宽 12 位。 I\u0026rsquo;\ni ;- , ; :\n/ ·..\n、,\n、 1\u0026rsquo;. •\u0026rsquo; .\u0026rsquo;.: \u0026rsquo; . . ,· ,\n高速缓存是两路组相联 的CE = 2) , 块大小为 4 字节 ( B = 4) , 有 4 个组CS = 4) 。高 速缓存的内 容如下,所有的地址 、标记和值都以十六进制表示 :' 组索引 标记 有效位 字节0 字节l\n字节2\n字节3\n00\n:.83\n.0. 0\n83\n`\n40\n- FE ,\n_44\n. .\n41 \u0026lsquo;. I .42\n97 cc\n45 46\n—\n·43\nDO 47\n\u0026lsquo;\u0026quot; ,,,.\na,\n,.,,•.·. ·\u0026rsquo;. 、,.\n' ,1 :: ·\u0026quot;,' 坎\n; -,\n; , 2\n\u0026lsquo;I _一\n40 . .48 49\n-;- 古 ~. ;\n. ,\n. 4A ., . .、... 4B J\n;; I ; II : I\n9A CO,\n,\u0026rsquo;, 03 Ff\n下面的图给出了 一个地址的格式(每个小框表示一位)。指出用来确定下列信息的字段(在图中标号出来):\nco 高速缓 存块偏移_\nCI 高速缓 存组索引~\nCT 高速缓存标记\n\u0026lsquo;.; J ;_.., . \u0026rsquo; 、 、 ;\n12 11 ·_·- lO ·· 9 8 \u0026rsquo; -\u0026lsquo;7 . • .\u0026lsquo;6 ·· -5· •· 4\n. I I -I · I I:-· ,I\u0026ndash;• I··-·\u0026quot;°I # 对于下面每个内存访问 ,当 它 们是按照列出来的顺 序执行时, 指出是高速缓存命中还是不命中。如果可以从高速缓存中的信息 推断出来 ,请 也给出读出的 值。 . ,), · '\n6. 30 假设我们有一个具有如下属性的系统: 内存是字节寻址的? 飞` \u0026rsquo; ;:: • 飞\n.• 内存访问是对 1 字节字的(而不是4 字节字)。,..c: .· •: :\n• 地址宽13 位。 ,· _; , .· .\u0026quot; ·:, •· ;- ·· ;\n高速缓存是四路组相联 的( £ = . 4) \u0026rsquo; 块大小为 4 字节( B = 4 ) , 有 8 个组 ( 5 = 8 ) 。\n考虑下 面的高速缓存 状态。所有的地址、标记和值都以十 六进制表示。每组有 4 行 ,索引 列\n包含组索引。标记列包含每 一行的标记值。V列包含每一行的有效位。字节 0 ~ 3 列包含每一行 的数据, 标号从左向右,字 节 0在 左边。\n. . . ,i\u0026rsquo;· 一,,;· \u0026lsquo;\u0026rsquo;··\u0026rsquo;\n·, . . . \u0026rsquo; . , , . \u0026lsquo;.\u0026lt;· \u0026rsquo; , · .. , . . . ., .. . ·•\n4 路组相联高速缓存\n., . .. ,.\n; , ,; \u0026lsquo;.·,, ;\n这个高速缓 存的大小 ( C ) 是多少字节? 下面的图 给出了一个地址的格式(每个小框表示一位)。指出用来确定下列信息的字段(在图中 标号出来):\nco 高速缓存块偏移\n\u0026lsquo;\u0026hellip;;- !- . . - ·\u0026rsquo; . '\n安\nCI 高速缓存组索引\nCT 高速缓存标记\n12 11 10 9 8 7 6 5 4 3 2 I 0\n** 6. 31 假设程序使用作业 6. 30 中的高速缓存,引 用位于地 址 Ox071 A 处 的 1 字节字。用 十六 进制表示出它所访间的高速缓存条目,以及返回的高速缓存字节值。指明是否发生了高速缓存不命中。如果 有高速缓存不命中,对千"返回的高速缓存字节"输人”一"。提示:注意那些有效位!\n地址格式(每个小框表示一位):\n12 11 10 9 8 7 6 5 4 3 2 1 0\n内存引用:\n•• 6 . 32 对千内存地址 Ox1 6 E8 重复作业 6. 31。\n地址格式(每个小框表示一位):\n12 11 IO 9 8 7 6 5 4 3 2 I 0\n内存引用:\n•• 6 . 33 对于作业 6. 30 中的高速 缓存 ,列 出会在组 2 中命中的 8 个内存地址(以十六进制表示)。\n•• 6. 34 考虑下面的矩阵转置函数:\ntypedef int array[4] [4];\n2\n3 void transpose2(array dst, array src)\n4 {\n5 int i, j;\n6\n7 for (i = o; i \u0026lt; 4; i ++) {\n8 for (j = O; j \u0026lt; 4; j++) {\n9 dst [j] [i] z src [i] [j];\n10 }\n11 }\n12 }\n假设这段代码运行在一台具有如下属性的机器上:\ns i ze o f (i n t ) ==4 。 数组 sr c 从地址 0 开始 , 而数组 d s t 从地址 64 开始(十进制)。 只有一个 L1 数据高速缓存 ,它是直接映射 、直写、写分配的, 块大小为 1 6 字节。\n这个高速缓 存总共有 32 个数据字节 , 初始为空 。\n对 s r c 和 d 江 数组的访问分别是读和写不命中的唯一来 源。\n对于每个r ow 和 c o l , 指明对 sr c [row] [c ol ] 和 ds t [row] [col ] 的访间是命中Ch) 还是不命 中Cm) 。例如,读 sr c [0] [ 0] 会不命中, 而写 ds t [0] [0 ]也会不命中。\nd s t 数组 sr c 数组\n列0 列1 列2 列3 列0 列1 列2 列3 行0 m 行0 m 行1 行1 行2 行2 行3 行3 .. 6. 35\n对于一个总大小为 128 数据字节的高 速缓存 ,重复 练习题 6. 34。\nd s t 数组\ns r c 数组\n列0 列l 列2 列3 列0 列l 列2 列3 行0 m 行0 m 行l 行1 行2 行2 行3 行3 •• 6. 36\n这道题测试你 预测 C 语言代码的高速缓存行 为的能力。对下 面这段代码 进行分析 :\nint X [2] [128] ;\nint i;\nint sum= O;\nfor (i,. O; i \u0026lt; 128; i++) { sum +: x [O] [i] * x [1] [i] ;\n}\n拿拿 6. 37\n假设我们在下列条件下执行这段代码:\ns i ze o f (i n t ) ==4 。 数组 x 从内存地址 OxO 开始 ,按 照行优先顺序存储 。 在下面每种情 况中, 高速缓存最开始时 都是空的 。 唯一的内存访间是对数组 x 的条目进行访问。其他所有的 变量都存储 在寄存器中。给定这些假设,估计下列情况中的不命中率; 情况 1 : 假设高速缓 存是 512 字节,直 接映射, 高速缓存 块大小 为 1 6 字节。不命中率是多少?\n情况 2 : 如果我们把高速 缓存的大小 翻倍到 1024 字节,不 命中率是多 少?\n情况 3 : 现在假设高 速缓存是 51 2 字节, 两路组相联, 使用 LRU 替换策略 , 高速缓存块 大小为\n1 6 字节。不命中率是多 少?\n对于情况 3\u0026rsquo; 更大的高速缓存 大小会帮助降 低不命中率吗?为什么能或者为什么不能?\n对于情况 3, 更大的块大小会帮 助降低不命中率吗? 为什么能或者 为什么不能?\n这道题也是测试你 分析 C 语言代码的高速缓存行 为的能力 。假设我们在下列条件下 执行图 6-47 中的 3 个求 和函数 :\ns i ze of (i n t ) ==4 。\n机器有 4K B 直接映射的高速缓存,块 大小为 16 字节。\n在两个循环中 , 代码只对数组数据进行内存访问 。循环索引 和值 sum 都存放在寄存器中 。\n数组 a 从内存地址 Ox 08000000 处开始存储。\n对于 N = 64 和 N = 60 两种情况, 在表中填写它们大概的 高速缓 存不命中率 。\n,- ,.:\n,i: _\u0026rsquo;.\u0026rsquo;;\n'\n, 、 ; . : . '\n`令,,·.·.\ntypedef int ar r\u0026rsquo;ay_t [N] [N]; \u0026rsquo; ·\u0026rsquo;· ,-.·\n·i ,\u0026rsquo; .• •.·,\u0026rsquo;\n3 , int\n4 {\n5\n6\n7\n9\nsum.A(array_t a)\nint i, j;\n.. int sum = O,·\nfor (i = O; i \u0026lt; N; i++)\nfor (j = O; j _\u0026lt; N; j++) { sum += a [i] [j] ;\n10 } ·一\n. , \u0026rsquo; '\n...1.. 1\n12\n1·3•\n14\n15\n16\n17\nreturn sum;\n} ..\n•,. (\u0026rsquo; \u0026lsquo;•\ni nt . sumB(arr.ay_t a)\n{ .\nint i,\u0026lsquo;j;\nints 山 q = O;\n, .._\n18 for (j = 0; j \u0026lt; N; j++)\n19\ni or (-i\n= -0; i : \u0026lt; N; i ++) {\\ : :•\n. ·. ,·. .: ·.\n20\n21 }\nsum += a[i] [j];\n-.-,-::\n22 return sum;\n23\n24\n25 int sU1DC(array_t a) 26 {\ninti, j; intsum= O,· J\u0026rsquo;\u0026rsquo;;\n,\n, . . . ..\n又. .、; ; i \u0026gt;\u0026rsquo;\n29 for (j = O; j \u0026lt; N; j\u0026rsquo;+=2)\u0026rsquo;\n30 for (i = O; i \u0026lt; N; i+=2) {\n31 sum:, +,;, fa [i l[ j l t,\na[i+1] [jl . . 迁\n32\n33\n3斗.,,.\n35\n,,,·,r.\n}\n}\ne t ur n ·s 11m; 1 '\n+ a,[i] [j+1] + a[i+1] [j+1]) ; ,\n,人 \u0026lt;,· \u0026rsquo; .\n气, '', '\n\u0026rsquo; ,,,\n.,'``,\n; 、 ; ; 、 . '\n,.,, 心\n, 、 \u0026lsquo;::-\n` '\n\\•. ,• 图,64- 7 · 作业5 :·37 中引 用 的函数 ;·. \u0026hellip; • \u0026hellip; 、· I ,\n6. 3.8 , ;iM决定在白纸上印黄方格 , 做成 Pos t l t 小贴纸。在打印过程中,他们需要设置方格中每个点的\nCMYK( 蓝色, 红色, 黄色, 黑色)值。3M 雇佣你判定下面算法在一个 具有2 048 字节、直接映射、\n块大小为 32 字节的数据高速缓存上的效率。,有 如下定义:\n1 struct point_colo;r:.: { .\u0026quot;: · . ·\n2 int c;\n;.\n\u0026rsquo; : \u0026ldquo;i ,·\n\u0026rsquo; \u0026rsquo; \u0026lsquo;;\u0026rsquo; ; . '\n.. . .\n_、\u0026rsquo;;I . .\n7\n-j \u0026rsquo; St 如 ct point_color _s quar e (16) (16); t ; 、-.: .: :·: i \u0026lsquo;- .嘈; 1 ;;-f· ·\u0026rsquo;. , ,: \u0026lsquo;\u0026lsquo;j 心\n心·:; :.::, :•i .·:\u0026rsquo;:·: . !\u0026rsquo; : i-; ·:\\,: : ;•\u0026rsquo;: ,-. g.\ninti, j; 有 如下 假设\n、: ,\n:-, \u0026hellip;I-; ·\u0026rsquo;\n! 、:\u0026rsquo;, ·.\u0026rsquo;, •.\u0026rsquo;, 心 俨\n,.., .\ns i zeof (i n t ) ==4。 s qu ar e 起始千内存 地址 0。 高速缓存初始为空。 -· :,;-•·.·\n..\n唯一的内存访问是对于 s q ua r e 数组中的元 素。变量 i 和]存放在寄存器中。. . .\u0026rdquo; , ·\u0026rsquo;· 『\n确定下列代码的高速缓存性能:\n\u0026lt;\u0026lsquo;T \u0026gt; . \u0026lsquo;,.\n,: •\nfor (i = O; i \u0026lt; 16; i++){\nfor ( j = O; j \u0026lt; 16; j++) { square[i] [j] .c = O;\ns quar e [ i ] [ j ] . m = O;\nsquare[i] [j] .y = 1;\nsquare[i] [j] .k = O;\n., ·• : : : • .i:\u0026rsquo;. •.\n\u0026rsquo; \u0026lsquo;,\u0026rsquo;, . \u0026lsquo;\u0026rsquo;?,\u0026rsquo;\n}\n* .\u0026rsquo;、\n, . 矗 · 、\n.,;· C, \\ I\u0026rsquo;\n,,.\u0026lsquo;\u0026lsquo;良\n: .';' .,\n写总数是多少? 在高速缓存中不命中的写总数是多少? 不命中率是多少? ·.:\n. ,.., . .\n.:,\u0026hellip;• ;! . (\n: 一 ,, .: , ,..、\n.人、飞\n6. 39 给定作业 6. 38 中的假设,确定 下列代码的 高速缓存性 能:寸\n\u0026rsquo; . ..,.,.,\u0026hellip;.. ., \u0026hellip;\n,. .\u0026rsquo; ,_; ,-;\u0026rsquo;, t·.\u0026rsquo;\nfor (i = O; i \u0026lt; 16; i++){\nfor (j = O; j \u0026lt; 16; j++) { square [j] [i] . c = 0;\nsquare[j] [i] .m = O;\n.% , . 、; , ..\nsquare CiJ [iJ _.y= 1寸\nsquare [j] [i] . k = 0;\n\u0026rsquo; .:,\n\, • . \\ I\n\u0026quot; - : ,,\u0026rsquo;,. . '\n.,.,\n:, ,, ,.\u0026rsquo;.\n',户\n:\u0026ndash;, : ·\n\u0026rsquo; (· 中 ,l \u0026lsquo;· \u0026rsquo; \u0026quot;\n,飞: , .\u0026quot; .,..\u0026rsquo;\n写总数是多少? 在高速缓存中不命 中的写总数 是多少? \u0026lsquo;\u0026gt; ! '\n. :, · ·: ! .\u0026rsquo;\n\u0026quot; ! \u0026rsquo; \u0026lsquo;. 、. .\n,:、、\n不命中率是多少?\n.. ! \u0026rsquo; ! :·.. \u0026hellip; 、 、勹\n,,, ; • : 气· 、 , .\u0026rsquo;\n.. ., . \u0026hellip;\n一、宁 ,:\n6. 40 给定作业 6. 38 中的假设,确定下列代码的 高速缓存性能 : ;,,-.,;.、: ::,\nfor \u0026rsquo; O : ,..; d;\n.\',\ni _\u0026lt;. \u0026rsquo; i 6 ; i +\u0026rsquo;+-) 1\u0026rsquo; {\u0026rsquo; :·: ·1\n- ,: : '\n,,.. ·::\u0026rsquo; ; I,\n2\n:i: . . .\u0026rsquo; 3 ,: :\u0026rsquo;::·,: ;·- \u0026rsquo; ,:\nfor (j = O; j \u0026lt; 16 ; j++) {\n, ,, squ e[iJ [j] .y, = 1,;\n} \u0026quot; .,,\u0026rsquo;:\u0026rsquo;\u0026rsquo;\n! ! !,•! \u0026hellip;.\n个_;、: ,,·.一•;\nl:\u0026rsquo;; .·\n4·\u0026rsquo;. . .,. ..\nf :, \u0026rsquo; \u0026rsquo; :, ·•·:.2 , ,, ; .·,. :·:, .,-:\n, , \u0026rsquo; '\n\u0026lsquo;,,\n·.,\n\u0026rsquo; , ·\u0026quot; , ,\u0026rsquo;\n6 ; ,. f 吐 心 : ,: =, , O/ : i , \u0026lt;; ,16 ; i,\n++) . { · ·\n\\, : :- :\u0026lt;.\u0026rsquo;·I\n:·\u0026rsquo;\n,',: ,.\n. \u0026rsquo; `、!·\u0026rsquo; ,;.._-,·\u0026rsquo;\n7 for (j = O; j \u0026lt; 16; j ++) {\nsquare [i] [j].c = 0 ; 、·!!t\nsquare[i] [j] .m = O ;\nsquare [i] [j] .k = 0 ;\n11\n;;、上\n. ,,..\n,,,\n' 、 ,. J , , :. 心 ;,\u0026quot;- j •• r , ·., '\n,; _ \u0026rsquo; .\n12\n写总数是多少? 在高速缓存中不命中的写总数是多少? (\n\u0026quot; :· . \u0026gt; , \u0026rsquo; .• 飞 .\n.-·\u0026quot; :夕 , .;\n..- _-. :. : :\n\u0026rsquo; . 一 飞,. i .•. ..\u0026rsquo;. \u0026rsquo; 飞”\n,\n.·,\nl-;:,\n不命中率是多少\u0026rsquo;!.) ' •• 6. 41\n!,飞\n你正在编写一个新的 30 游戏 , 希望能名利双收.,。现 在正在写 一个 函数} 使得在画下一帧之前先清空屏幕 缓冲区 。工 作的屏幕是 640-X 480 像 素数组 '。工 作的机器有一个 64K B 直接映 射高速缓 存,\n1:-,\n每行 4个字节; 使用下面的C语言数据结构: I · \u0026rsquo; ; \u0026rsquo; \u0026rsquo; ·. . \u0026rsquo; \u0026quot; .,. : . .. . l ·.. i _-\u0026rsquo; . ; \\;: ·\u0026rsquo;·.,:, /\u0026rsquo;.\u0026rsquo;\n\u0026rsquo; . \u0026rsquo; . ,:, \u0026quot; \\ : ; \u0026rsquo; \u0026quot; ; \u0026rsquo; ·\u0026rsquo; \u0026rsquo; ., \u0026quot; \u0026rsquo; : \u0026rsquo; ·. . /; \u0026lsquo;·. 、..,., • ,,_·. ·; ;\n, 1 ,,sfr uc, t\nP} Xe l {\n、: `仁 \u0026rsquo; :\u0026rsquo;,· 2 . .\n• 七h at:\u0026rsquo; • r; \u0026lsquo;\u0026rsquo;\u0026rsquo;、:(,\u0026ndash;\u0026rsquo;.;: :: : \u0026rsquo; \u0026rsquo; ·.\u0026rsquo;. •.. \u0026lsquo;.\u0026rsquo; 户 、 , \u0026rsquo; : . '\nI \u0026rsquo; 、\n. .. . ,\n;;:; · : .··- 3 : ,:: ;;, ch 吐 g; .-:-: '\n,,_,. :、i . 、,\u0026rsquo;、:·\u0026gt; ·:-:: :·;,: '\n.才I i;,r\nchar b; char a;\n};\nstruct pixel buffer[480] [640]; int i, j;\nchar•cptr; int•iptr;\n有如下假设:\ns i ze o f (c har ) ==l 和 s i ze c f (i n t ) ==4 。\nb u f f er 起 始 于内存地址 0。 高速缓存初始为空 。\n唯一的内存访问是对千 b u f f er 数组中元素的访问 。变昼 1 、j 、c p tr 和 i p tr 存放在寄存器中。下面代码中百分之多少的写会在高速缓存中不命中?\nfor (j = 0; j \u0026lt; 640; j ++) {\nfor (i = O; i \u0026lt; 480; i++){\nbuff er [i ] [ j] r. • O;\nbuffer[i] [j] .g• O;\nbuffer[i] [j] . b • O;\nbuffer[i) [j] .a = O;\n}\n}\n** 6. 42\n•• 6. 43\n*** 6. 44\n:: 6 45\n给定作业 6. 41 中 的 假 设 ,下 面代码中百分之多少的写会在高速缓存中不命中?\nchar•cptr = (char•) buffer;\nfor (; cptr \u0026lt; (((char *) buff er) + 640 * 480 * 4) ; cptr++)\ncptr 一 O; 给定作业 6. 41 中 的 假设 ,下 面代 码中百分之多少的写会在高速缓存中不命中?\nint *iptr z (int•)buffer;\nfor(; iptr \u0026lt; ((int•)buffer+ 640•480); iptr++)\n*iptr = 0;\n从 CS : A P P 的网站上下载 mo u n t a i n 程 序, 在你最喜欢的 PC/ L in u x 系统上运行它。根据结果估计你 系统上的高速缓存的大小。\n在这项任务中,你 会把在第 5 章和第 6 章中学习到的概念应用到一个内存使用频繁的代码的优化 问 船 上。考虑一个复制并 转置一个类型为 i n t 的 N X N 矩阵的过程。也 就是, 对 于源矩阵 S 和目的矩阵 D , 我们要将每个元素 S; ,J 复制到 d,., 。只用一个简单的循环就能实现这段代码:\nvoid transpose(int•dst, int•src, int dim)\n{\nint i, j;\nfor (is O; i \u0026lt; dim; i++)\nfor (j = O; j \u0026lt; dim; j++)\ndst [j•dim + i ] 一 s r c [i*dim + j];\n:: 6 . 46\n这里 ,过 程的参数是指向目的矩阵 ( d s t ) 和源矩阵 ( s r c ) 的指针,以 及矩阵的大小 N ( d i m) 。 你的工作是设计一个运行得尽可能快的转置函数。\n这是练习题 6. 45 的一个有趣的变体。考虑将一个有向图 g 转换成它对应的无向图 g \u0026rsquo; 。图 g \u0026rsquo; 有一条\n从 顶点 u 到顶点 v 的边,当 且仅当原图 g 中有一条 u 到 u 或者 v 到 u 的边。图 g 是由如下的它的邻接 矩阵( adjacenc y ma t rix ) G 表示的。如果 N 是 g 中顶点的数量, 那 么 G 是 一 个 N X N 的 矩阵,\n它 的 元 素是全 0 或者全 1。假设 g 的顶点是这样命名的: V o , V 1 , …, “平 1 。 那 么 如 果 有一条从 v,\n到 v,的 边,那 么 G [ i] [ 月 为 1, 否则为 0。注意, 邻 接矩阵对角线上的元素总是 1, 而无向图的邻\n接矩阵是对称的。只用一个简单的循环就能实现这段代码:\nvoid col_convert(int *G, int dim) { int i, j;\nfor (i = O; i \u0026lt; dim; i++)\nfor (j = O; j \u0026lt; dim; j++)\nG [j *dim + i] = G [j *dim + i] 11 G[ 江 di m + j];\n你的工作是设计一个运行得尽可能快的函数。同前面一样,要提出一个好的解答,你需要应用在第5 章和第 6 章中所学 到的概念。\n练习题答案\n6 1 这里的思 想是通过使 纵横比 ma x(r , c)/min(r, c)最小, 使得地址位数最小。换句话说, 数组越接近于正方形,地址位数越少。\n组织 r C b, be max(b,, b) 16X l 4 4 2 2 2 16X4 4 4 2 2 2 128X8 16 8 4 3 4 512X4 32 16 5 4 5 !024X4 32 32 5 5 5 6 2 这个小练习的主旨是确保你理解柱面和磁道之间的关系。一旦你弄明白了这个关系,那问题就很简单了:\n磁盘容量= 51 2 字节 X 400 扇 区数\nX 10 000 磁道数 X 2 表面数 X 2 盘 片数\n扇区 track\n=8 192 000 000 字 节\n=8. 192GB\n表面 盘片 磁盘\n6 3\n6 4\n6. 5\n对这个问题的解答是对磁盘访问 时间公式的直接应用 。平均旋转时间(以ms 为单位)为\nT., g ,ot,11on = 1 / 2 X T max rntallon = 1 / 2 X (60s/15 000RPM) X lOOOms/s\u0026quot;\u0026quot;\u0026quot; 2ms\n平均传送时间为\nT,vg,,,n,r«= (60s/15 000RPM) X 1 / 500 扇 区/磁 道 X l OOOms / s ,::::: 0. 008ms\n总的来说,总的预计访问时间为\nT,cms = T, vg seek + T,vg ,oi,11on + T, vg mnsfe, = 8ms + 2ms + 0. 008ms \u0026quot;\u0026quot;\u0026quot; 1 Oms\n这道题很好的检查了你对影响磁盘性能的因素的理解。首先我们需要确定这个文件和磁盘的一些基本属性。这个文件由 2000 个 512 字节的逻辑块组成。对于磁盘, T avg seek = 5 ms\u0026rsquo;Tmax rnt,t1on = 6 ms\u0026rsquo; 而 T., . \u0026lsquo;°\u0026rsquo; \u0026ldquo;\u0026rsquo; o• = 3ms 。\n最好情 况: 在好的情况中 , 块被映射到连续的扇区, 在同一柱面上 , 那样就可以一块接一块地\n读, 不用移动读/写头。一旦读/写头定位到了第一个扇区,需 要磁盘转两整圈(每圈 1000 个扇区)来读所有 2000 个块。所 以, 读这个文件的总时间为 Ta,g seek + T.,g ,oi,1;on + 2 X T max ,om;on = 5 +\n3 + 12 = 20ms 。\n随机的情况: 在这种情 况中,块 被随机地映射到扇区上 , 读 2000 块中的每一块都需 要 Tavg seek +\nT .v. , o., uon ms, 所以读这个文件的总时间为( T\u0026hellip; mk + T .,., 0 1 a,;on) X 2000 = 16 OOOmsCl 6 秒!)。 你现在可以看到为什么清理磁盘碎片是个好主意!\n这是一个简单的练习,让 你对 SSD 的可行性有一些有趣的了解。回想一下对于磁盘, l P B = 109\nMB 。 那么下面对单位的直接翻译得到了下 面的每种情 况的预测时间:\nA. 最糟糕悄况顺序写( 470 MB/ s ) : (1 09 X 128) X Cl / 470 ) X Cl/(86 400X 365) ) ,:::::8 年。\nB. 最糟糕情况随 机写( 303 MB/ s): 0 0 X.128) X (l / 303 ) .X (111/ ( 8 6· 400 X 365 )、)\n1 3年 。 心\n6. 6\nc. 平均情况( 20G B/ 天): (109 X 128) X0 / 20 000) X0 / 65) :\u0026ldquo;\u0026ldquo;1,7- 535 年。 \u0026quot;\u0026rdquo; \u0026lsquo;.\u0026rsquo;-\u0026rsquo;,:. 、,、\n所以即使 SSD 连续工作 , 也能持续至少 8 年时间, 这大于大多数计算机的 预期寿命 。\n在 2005 年到 2015 年的 10 年间,旋 转磁盘的单位价格下降 了大约, 16 6 倍,这 意味着价格大约每 18 个月下降 2 倍。假设这个趋势 一直持续 , l P B 的 存储设备 ,在2 0.15 年 花费-3.0 000 美元, 在 7 次这种 2 倍的下降之后会降到 500 美元以下。因为这种下降每 i\u0026rsquo;s 个月发生一次 , 我们 可以 预期在大约\n20 25 年, 可以用 500 美元买到 l P B 的存储设备。\n6. 7 为 了创建一个步长为1 的引用模式 ,必须改变循 环的次序 , 使得最右边的索引变化得最快 :\nint s uma 工r ay3d ( i nt a[N] [NJ [NJ)\n{\ninti, j, k, sum= O;\n'\u0026rsquo;,,.,..,,\n.\u0026rsquo; ', '\u0026rsquo;\n..\\ , \u0026lsquo;.., • ,\n心 for\n廿(k = O;\u0026rdquo; k \u0026rsquo; \u0026lt; N,:;\u0026rsquo;\u0026rsquo; k++) { \u0026rsquo; '\nfor (i = 0; i \u0026lt; N; i ++) {\nfor (j = O; j \u0026lt; N; j++) {\n\u0026rsquo; ·,,\u0026rsquo;\n,_ · · \u0026lsquo;· .. .:\n·, . 处 ; _: ,\u0026rsquo; ·\u0026rsquo; 寸i\n-- . }\n}\nS\u0026rsquo;\\llD += a [k] [i) [j] ;\n;. .、;,一:\nreturn sum;\n这是一个很重要的思想。模式。. .\n.'、,:、:-\n要保证 你理解了 为什么这种循环次序改变就能得到一个步长为 1 的访问\nfs 解 决这个问题的关键在干想象出 数组是S如何在内存中排列的,然 飞后分析引用模式。! 函 数 l e ar l 以\n步长为 1 的引用模式访问 数组, 因此明显地具有最好的空间局部性。函数 c l e a r 2 依次扫描 N 个结构中的每一个 , 这是好的,但是在每个结构中,它以 步长不为 1 的模式跳 到下列相 对于结构起始位置的偏移处: 0 、12 、4 、16 、8、20。所以 c l e a r 2 的空间局部性比 c l .e ar l 的要差。函数 c l ea r 3 不仅在每个结 构中跳来跳去, 而且还从结构跳到结构, 所以 c,l e. r.3 的空间局部性比 c l e a r 2 和 c l e ar l 都 要差。\n6. 9\n; 1\n,.,.\n6. 10\n这个解答是对图 6-26 中各种高速缓存参数定义的直接应用。不那么令人兴奋,但 是在能真正理解\n高速缓存如何工作之前, 你需要理解高速缓存的结构是如何 导致这样划分地址位的 。\u0026lsquo;r•: ·t 、\n填充消除了 冲突不 命中。因此,四分之 三的引用 是命中的。\n6 门 有时候,`理 解为什 么某种思想是不好的,能够帮助你理解为什么另一种 是好的。,( 这 里 ,我 们看到\n•.•',-\u0026rsquo;、i\n6. 12\n的 坏的想 法是用高位来索引高速缓存, 而不是用 中间的位。\nA 用高位做索引 , 每个连续的 数组片( chun灼由 2\u0026rsquo; 个块组成 ,;这 里 t 是 标记位数。因此,数组头\n2\u0026rsquo; 个连续的块都 会映射到组 o, 接下来的 2\u0026rsquo; 个块会映 射到组 1 . 依此类推o.: :\u0026rsquo;. 习\nB, 对于直接映射高速缓存( S ,.: E ,, B, •1?1! :\u0026rdquo;\u0026quot;\u0026rsquo;:( 51 2 ,、1, 3-2, \u0026lt;32h · 高速缓 存容量是 \u0026lsquo;.512 个 3 2 字节的块 ,每个高速缓存行中 有 t = l 8 个标记位。因此,数组中头 沪个块会映射到组 o, 接下来沪个块会映射到组 l 。因为我们的 数组只由 ( 409 (] X 4 ) / 32 =;c51? 个块组成,所以数组中所有的块都\n\! 被 映射到组 0。因此',在任 何时刻、,、高 速缓存 至多只能保存七个数组块,,\u0026lsquo;即使 数 组足够小,能 够完全放到高速缓 存中。i 很明显,,用高位 做 索引 不能充分利用高速缓存。` '':, 炉\n两个低位是块偏移( CO) • 然后是 3 位的组索引( CI) , 剩下的位作为标记 \u0026lt;CT ) \u0026lsquo;.- 厂 _;;,· \u0026lsquo;.\n~ 笫 6 章 存储器层次结构 461\n, ,• • I\n;\u0026rsquo;. .·, .: \u0026rsquo; : 1,2 · · 11; 10\n9. · 8 .:,7: · · 6 .. 5 : 4 : .• 3 .•.2 : ·i l 久。\n\u0026gt;.,., .,\u0026rsquo;\nlcTlcTlcT\nCT . I CT . I CT lcr I CT I c 1 」CI ·1 .C\u0026rsquo;\nI I.co I co I\n6. 13\n,;,,\n地址: Ox0E34 .\u0026rsquo;、\n地址格式(每个小格子表示一个位):\n.,户,..\n.\u0026rsquo;.,· ·\u0026rsquo; ,\n; , :;\n'., ...,;. ;;`\n, ' , -12 · 11. 10 9 8· :. 7 6 . 5 4 . . 3 2 I . 0\nI O I 1\u0026rsquo; I 1 I 1 I O 1· .0 I O·1 i - l · 1. I. o I 1 口\n:,. . ,.,.\n内存引用:\nCT. · C L CT CT CT CT CT ct C.l\nCI cr c o · c o· ·\n, : ,, :; \u0026rsquo; 、. . ·;· .•\n(; ··.:.·.\u0026rsquo;::\u0026rsquo;·;!\u0026rsquo;:/ ·.\u0026rsquo;; \u0026lsquo;:: -:.,. :.: 、: ;;.-, • •\n\u0026rsquo; ,._,\n6. 14\n:一. .. \u0026hellip; .-\n地址: Ox0DD5 一\n地址格式(每个小格子表示一个位): / ,.\n12 · , 11·. 10 ·· · 9 8 7 : 6 ·· 5\n:、·\n-4 3 2 I 0\nI O I I. , I· 1 1 · I , I 1· .l r o , · 1\nI I O I I I\n:,,;\u0026lt; :··: - ; / : \u0026ldquo;,:: .-,;;: er \u0026lt; CT :. er : CT : CJ; · CT . CT CT.•·\n\u0026hellip; . fB; 内存引用: •·.. .-..: :;-·( .\u0026rsquo;、:.\u0026rsquo;,1 : \u0026lsquo;,、::·,·\u0026rsquo;\nCI 甲\n., 俨:,,. :. ..\nCI· CO , CO\n\u0026lsquo;; ; · : ,·\n;, / ,:·;::,:r :i\\f·\n心,:,八: !,\u0026lt; 1 ; 女, · i : \u0026rsquo; ;,, ; ;:: ; ·\\\n::,:; ,\u0026rsquo;•. ::. .;. \u0026lsquo;, , .;; :\n,;\u0026rsquo;, ,; \u0026lsquo;.:\u0026rsquo;:/ ;·· , ·;\n. .- \u0026rsquo; ..\u0026ndash; . !,\u0026rsquo;,., .. . ,\n· ·\u0026rsquo;:\u0026rsquo; : , 丿\n.\n, ,., `'\n·,, . ,·\n, \u0026rsquo; \u0026rsquo; :\u0026rsquo;; \u0026rsquo; \u0026rsquo; \u0026quot; . -\nl ,_ _. . ,\n\u0026lt;· ·\u0026rsquo;\n-、i . _.\u0026rsquo;,-;\n;., -\n6. 15 地址: OxlFF4 \u0026rsquo; •. , · 人. \u0026rsquo; ; \u0026hellip;.·,,, ; ,\n. •· \u0026rsquo; ! \u0026lsquo;:\n地址格式(每个小格子表示一个位): \u0026lsquo;•. .; . ..\u0026rsquo;\n仑 ,.-. _,.\n,\n12 11 10 9 8 7 •, : •.,6 . 5\n3· /!· 2 · . ·r: \u0026rsquo; d\n. 令 .\n、:.\nI 1 I I I 1 I 1 I I I I I I I I\n::: \u0026gt;-,: : CT\u0026gt;,. c t, , C T :/ :CT, CT \u0026lt; CT ·\u0026rsquo; . er :·CT\n已\nCI·\n0 I I \u0026lsquo;I 。 1。 |\nCl ·,; •Cl • CO :\u0026rsquo;.CO ;. ;,;\n.、.,.\nB.\n.• .\nI•.,i-\u0026rsquo;.\u0026gt;, ; : : : \u0026lsquo;. : \u0026rsquo; ·,\n,- .•· \u0026gt;,\u0026rsquo; ; :\n\\ ··•,\n,\u0026rsquo;:\u0026rsquo;.,\n.. : ;\u0026rsquo;\n,, ,. .\n, \u0026ldquo;,C -\u0026rsquo;;\n: 、乒 \u0026rsquo; .,.._..,·.\u0026lt;.;-\u0026rsquo;· ,··J .\n一 声 ,\n! .-,; •:- \u0026lt;: 、 』; \u0026rsquo; •:·( ,;,\n\u0026lsquo;.\u0026rsquo;.·; _; ;\n; , ,! ; ( , , •:\n\u0026quot; 勹\u0026rsquo;.,.:. ( - ..-\n-)r·\n\u0026gt; 一 j? 气 ,\u0026rsquo;. t ,\u0026rsquo;\n\u0026lt; :,, ;•;\u0026lt; \u0026lsquo;.\nI ; !,\n6.16 这个问题是练习题6\u0026rsquo;. 12-::练习题6. 15 的 一种逆 过程,要 求你反向 工作,从高速缓存 的内 容推出\n觅:.,!.\n.•.\n会在某个组中命中的地址 。在这种情况中,组 3 包含一个有效行.,标 记为 Ox32。因 为组中只有一\n个有效行, 4 个地址会命中。这些地址的二进制形式为 ·o\u0026rsquo;on: o io611 。因此,在组 3中 命中的\n4 个十六进制地址是 : Ox06 4C、 Ox0 64D、 Ox0 64E 和 Ox0 64F。\n6 17 A. 解决这个问 题的关键是想象 出图 6-48 中的图像。注意,每个高 速缓存行只包含数组的一 个行, 高速缓存正好只够保存一个数组, 而且对于所有的 i , sr c 和 ds t 的行 t 映射到同一个高速缓存行。因为高速缓存不够大,不足以\n主存\n容纳这两个数组,所以对一个数组的 o\n引用总是驱逐出另一个数组的有用的 src { 16\n行。例如, 对 ds t (OJ (O J 写会驱逐当我 dst {\n们读 sr c [OJ [ O J 时 加载进 来的那一行。\n高速缓存\n雷\n所以,当我们 接下来读 sr c [ O J ( l J 时, 会有一个不命中。\n图 6\u0026ndash;18 练 习题 6. 17 的图\n当高速缓存为 32 字节时 , 它足够大, 能容纳这两个数组。因此,所 有的不命中都是开始时的冷不命中。\nds t 数组 sr c数组\n列0 列l 列0 列1\n行行0l\nm m\nm m I\n行0 m m\n行1 m h\nds t 数组 sr c 数组\n列0 列l 列0 列1\n行行 m h 行0 m h\nm h 行1 m h\n18 每个 16 字节的高速缓存行包含着两个 连续的 a l ga e_yos i t i on 结构。每个 循环按照内存顺序访问这些结构,每次读一个整数元索。所以,每个循环的模式就是不命中、命中、不命中、命中,依此类推。注意, 对于这个问题, 我们不 必实际列举出读和不命中的 总数, 就能预测出不命中率。\n读总数是多少? 512 个读。\n缓存不命中的读总数是多少? 256 个不命中。\nC. 不命中率是多少? 256/ 512 = 50 %。\n6 19 对这个问题的关键是注意到这个 高速缓存只能保存数组的 1 / 2。所以 ,按 照列顺序来 扫描数组的第二部分会 驱逐扫描第一部分时 加载进来的那些行。例 如, 读 gr i d [8 ) [OJ 的第一个元索会驱逐当我们读 gr i d [OJ [ OJ 的 元素时加载进来的 那一行。这一行也包含 gr i d [ OJ [1 )。所 以, 当我们开始扫描下一列时, 对 g r i d [O) [ 1 ) 第一个元素的引用会不命中 。\n读总数是多少? 512 个读。\n缓存不命中的读总数是多少? 256 个不命中。\nc. 不命中率是多少? 256/ 512 = 50 % 。\nD. 如果高速缓存有两倍大,那么不命中率会是多少呢?如果高速缓存有现在的两倍大,那么它能够保存整个 g r 沁 数组。所有的不命中都 会是开始时的 冷不命中 , 而不命中率会是 1/ 4 = 25% 。\n20 这个循环有很好的步长 为 1 的引用模式 , 因此所有的不命中都是最开始时的 冷不命中。\n读总数是多少? 512 个读。\n缓存不命中的读总数是多少? 128 个不命中。\nc. 不命中率是多少? 128 / 512 = 25 % 。\nD. 如果高速缓存 有两倍大, 那么不命中率会是多少呢?无论高速缓 存的大小 增加多少, 都不会改变不命中率,因为冷不命中是不可避免的。\n6 21 从 Ll 的吞吐晕峰值是大约 12 OOOMB/ s , 时钟频率是 2100 MH z, 而每次读访问都是以 8 字节 l ong 类型为单位的 。所以,从 这张图中我们 可以估计出在这台机器 上从 Ll 访间一个字需要大约 2100/ 12 OOOX8=1. 4::::::1. 5 周期' 比正常访问 口 的延迟 4 周期快大约 2. 5 倍o 这是由于 4 X 4 的循环展开得到的并行允 许同时进行多个加载操作 。\n"},{"id":443,"href":"/zh/docs/technology/System/%E6%B7%B1%E5%85%A5%E7%90%86%E8%A7%A3%E8%AE%A1%E7%AE%97%E6%9C%BA%E7%B3%BB%E7%BB%9F/%E4%B9%A6%E7%B1%8D/%E7%AC%AC7%E7%AB%A0-%E9%93%BE%E6%8E%A5/","title":"Index","section":"SpringCloud","content":"第 7 章\n· · · 0 · H A . · P T · .E R 7\n链接\n链接Clink ing ) 是将各种代码和数据片段收集并 组合成 为一个单一文件的过程, 这个文件可被加栽(复制)到内存并执行。链接可以 执行于编译 时( com pile time), 也就是在源代码被翻译成 机器代码时; 也可以执行千加 载 时 ( loa d time) , 也就是在程序被加栽 器( lo ad­ er ) 加载到内存并 执行时; 甚至执行 于运行 时( ru n time), 也就是由应用程序来执行。在早期的计算机系统中, 链接是手 动执行的。在现代系统中, 链接是由叫做链接器 Clinker ) 的程序自动 执行的 。\n链接器在软 件开发中扮演着一个关键的角色, 因为它们使得分 离 编译( separa te com­ pila t io n ) 成为可能。我们不用将一个 大型的应用程序组织为一个巨大的源文 件, 而是可以把它分解为更小、更好管理的模块,可以独立地修改和编译这些模块。当我们改变这些模 块中的一个时,只需简单地重新编译它,并重新链接应用,而不必重新编译其他文件。\n链接通常是由链接器来默默地处理的,对于那些在编程入门课堂上构造小程序的学生而言,链接不是一个重要的议题。那为什么还要这么麻烦地学习关于链接的知识呢?\n理解链接器将帮助你构造大型程序。构造大型程序的程序员经常会遇到由千缺少模块、缺少库或者不兼容的库版本引起的链接器错误。除非你理解链接器是如何解析引用、什么是库以及链接器是如何使用库来解析引用的,否则这类错误将令你感到迷惑和挫败。 理解链接器将帮助你避免 一些危 险的编程错误。Lin ux 链接器解析符号引用时所做的决定可以不动声色地影响你程序的正确性。在默认情况下, 错误地定义多个全局变量的 程序将通过链 接器, 而不产生任何警告信息。由此得到的程序会产生令人迷惑的运行时行为,而且非常难以调试。我们将向你展示这是如何发生的,以及该如 何避免它。\n理解链接 将帮助你理 解语言的作 用域规则是 如何实现的。例如, 全局和局部 变量之间的 区别 是什么?当你定义一个具有 s 七a t i c 属性的变量或者函数时, 实际到底意味着什么?\n理解链接将帮助你理解其他重要的系统概念。链接器产生的可执行目标文件在重要的系 统功能中扮演着关键角色, 比如加载和运行程序、虚拟内存、分页、内存映射。\n理解链接 将使你能够利 用共 享库。 多年以来, 链接都被认 为是相当简单和无趣的。然而,随着共享库和动态链接在现代操作系统中重要性的日益加强,链接成为一个 复杂的过 程, 为掌握它的程序员 提供了强大的能力。比如, 许多软件产品在运行时使用共享库来升级压缩包装的 ( s h r ink- w ra pped ) 二进制程序。 还有 , 大多数 Web 服 ` 务器都依赖于共享库的动态链接来提供动态内容。\n这一章提供了关于链接各方面的全面讨论, 从传统静态链接到加载时的共享库的动态链接,以及到运行 时的共享库的动态链接。我们将使用实际示例来描 述基本的 机制, 而且指出链接问题在哪些情况中会影响程序的 性能和正确性。为了使描述具体和便千理解 ,我们的讨论是基千这样的环境: 一个运行 Linux 的 x86-64 系统, 使用标准的 ELF-64 (此后称为 ELF)\n目标文件格式。不过,无 论是什么样的操作系统、ISA 或者目标文件格式, 基本的链接概 念是通用的,认识 到这一点是很重要的。细节可能不尽相同 , 但是概念是相同的。\n1 编译器驱动程序\n考虑图 7-1 中的 C 语言程序。它将 作为贯穿本章的一个小的运行示例, 帮助我们说明关千链接是 如何工作的一些重要知识 点。\ncode/link/main.c int sum(int *a, int n);\nint array [2] = {1, 2};\nint main()\n{\nint val= sum(array, 2);\nreturn val;\ncode/link/sum.c int sum(int *a, int n)\n{\ninti, s = O;\nfor (i = O; i \u0026lt; n; i++) {\ns += a[i]\n}\nreturns;\n}\ncode/linmka/in.c\nmain. c b) sum. c code/link/sum.c\n图 7-1\n示例程序 1。这个示 例程序由 两个源文件组成 , ma i n. c 和 s um. c 。 ma i n 函数初始化一个整数数组, 然后调用 s um 函数来对数组元素求 和\n大多数编译 系统提供编译 器驱 动程序 ( co mpile r driver), 它代表用户在需 要时调用语\n言预处理器、编译器、汇编器和链接器。\nma i n . c\ns um . c 源文件\n比如 ,要用 G NU 编译系统构造示例程序,\n我们就要 通过在 s hell 中输入下列命令来调用 G CC 驱动程序:\nl_inux\u0026gt; gee -Og -o prog mai n . e sum. e\n图 7-2 概括了驱动程序在 将示例程序从\n翻译器\n(epp, eel, as)\nma1.n.o\n!\n翻译器\n(epp, eel, as)\n可重定位目标文件\nASCII 码源文件翻译成可执行目标文件时的行为。(如果你想看看这些 步骤,用 - v 选项来运行 GC C。)驱动程序首先 运行 C 预处理器 ( c p p )e , 它将 C 的源程序 ma i n . c 翻译成一个 AS CII 码的中间 文件 ma i n . i :\n图 7- 2\n链接器 ( l d )\nprlog 完全链接的\n可执行目标文件\n静态链接。链接器将可重定位目标文件组合起来, 形成一个可执行目标 文件 pr og\ncpp [other arguments] main. c /tmp/main. i\n接下来, 驱动程序运行 C 编译器( e el ) ,\n件 ma i n . s :\n它将 ma i n . 工 翻译成一个 AS C II 汇编语言文\ncc1 /tmp/main. i -Dg [other arguments] -o /tmp/main.s\n然后, 驱动程序运行 汇编器( a s ) , eatable object file) main. o:\n它将 ma i n . s 翻译 成一个可重定位目 标文件( re lo-\nas [other arguments] -o /tmp/main.o /tmp/main.s\n8 在某些 GCC 版本中,预 处 理 器 被 集 成 到 编译 器驱动程序中。\n驱动程序经过相同的过程生成 s um. o 。 最后,它 运行链接器程序 l d , 将 ma i n . a 和s um. o 以及一些 必要的系统目标文件组合起来, 创建一个可执行目标 文件 ( e xec uta ble ob­ ject file)prog:\nld -o prog [system objectfiles and args] / t mp/ ma i n . o /tmp/sum. o\n要运行 可执行 文件 pr og , 我们在 Lin ux s hell 的命令行上输入它的名 字:\nlinux\u0026gt; ./prog\nshell调用操作系统中一个叫做加载 器 ( load er ) 的 函数, 它将可执行文件 pr og 中的代码和数据复制到内存 , 然后 将控制转 移到这个程序的开头。\n2 静态链接\n像 L in ux LD 程序这样 的静态链接 器( s tat ic lin ker ) 以一组可重定位目标文件和命令行参数作为输入,生成一个完全链接的、可以加载和运行的可执行目标文件作为输出。输入 的可重定位目标 文件由 各种不同的 代码和数据节( s ect ion ) 组成, 每一节都是一个 连续的字节序列。指令在一节中,初始化了的全局变储在另一节中,而未初始化的变量又在另外一 节中。\n为了构造可执行文件,链接器必须完成两个主要任务:\n符号解析 ( s ym bol resolut io n ) 。目标文件定义和引用符号, 每个符号对应于一个函数、一个全局变量或一个静态 变量(即 C 语言中任何以 s t a t i c 属性声明的变量)。符号解析的目的是将每个符号引用正好和一个符号定义关联起来。\n重定位( re loca tion ) 。编译器和汇编器生成从 地址 0 开始的代码和数据节。链接器通过把每个符号定义与一个内存位置关联起来,从而重定位这些节,然后修改所有对 这些符号的引用,使 得它们指向这个内 存位置。链接器使用汇编器产生的重定位条目 ( relocat ion e nt r y) 的详细指令, 不加甄别 地执行这样的重定 位。\n接下来的章节将更加详细地描述这些任务。在你阅读的时候,要记住关千链接器的一些基本事实 :目 标文件纯粹是字节块的集 合。这些块中, 有些包含程序代码, 有些包含程序数据,而其他的则包含引导链接器和加载器的数据结构。链接器将这些块连接起来,确定被连接块的运行时位置,并且修改代码和数据块中的各种位置。链接器对目标机器了解 甚少。产生目标文件的编译器和汇编器已经完成了大部分工作。\n3 目标文件\n目标文件有三种形式:\n可重定位 目标 文件。包含二进制代码 和数据, 其形式可以在编译时与其他可重定位目 标文件合并 起来, 创建一个可执行目标 文件。\n可执行目标 文件。包含二进制代码和数据,其形式 可以被直接复制到内存并执行。\n共享目标 文件。一种特殊类型的可 重定位目标文件, 可以在加载或者运行时被动态地加载进内存并 链接。\n编译牉和汇编骈生成可重定位目标文件(包括共享目标文件)。链接器生成可执行目标文件。从技术上来说, 一个目标 模块 ( object mod ule ) 就是一个字节序列, 而一个目标 文件Cob­ ject file) 就是一个以文件形式存放在磁盘中的目标模块。不过, 我们会互换地使用这些术语。\n目标文件是按照特定的目标文件格式来组织的,各个系统的目标文件格式都不相同。\n从贝尔实 验室诞生的第一个 U nix 系统使用的是 a . ou t 格式(直到今天, 可执行文件仍然称为 a . o 江 文 件)。Windo ws 使用 可移植 可执行 ( Por table Executable, PE ) 格式。Mac os-x 使 用 Mach-0 格式。现代 x86-64 Lin ux 和 U nix 系统使用可执 行 可链接格式( E xec ut ­ able and Linkable Format, ELF)。尽管我们的讨论集中在 E LF 上,但 是 不 管是 哪 种格式,\n基本的概念是相似的。\n4 可重定位目标文件 # 图 7-3 展示了一个典型的 ELF 可重定位目标文件的格式。E LF 头 ( E LF header ) 以一个 16 字节的序列开始, 这个序列描述了生成该文件\n的系统的字的大小和字节顺序。E L F 头剩下的部分包含帮助链接器语法分析和解释目标文件的信息。其 中包括 ELF 头的大小、目标文件的类型(如可重定位、可 执行或者共享的)、机器类型(如x86-64 ) 、 节\n头部表 ( sect io n header table ) 的 文件 偏 移 ,以 及 节头\n部表中条目的大小和数量。不同节的位置和大小是由节头部表描述的,其中目标文件中每个节都有一个固定大小的条目 ( en t r y ) 。\n夹在 E LF 头和节头部表之间的都是节。一个典型的 E LF 可重定位目标文件包含下 面儿个节:\n.text: 巳编译程序的机器代码。\n节\n描述目标文件的节{\n.rodata: 只读数据, 比 如 p r i n t f 语 句 中 的 格 图 7-3 典型的 ELF 可重定位目标文件\n式串和开关语句的跳转表。\n.data: 已初始化的全局和静态 C 变量。局部 C 变扯在运行时被保存在栈中,既 不 出现在 . da 七a 节 中 , 也不 出 现 在 . b s s 节中 。 bss: 未初始化的全局和静态 C 变量,以 及 所有被初始化为 0 的全局或静态变量。在目标文件中这个节不占据实际的空间, 它仅仅是一个占位符。目标文件格式区分已初始化 和未初始化变量是为了空间效率:在目标文件中,未初始化变量不需要占据任何实际的磁 盘空间 。运行时, 在内存中分配这些变量, 初始值为 0。\n.symtab: 一个符号表,它 存 放在程序中定义和引用的函数和全局变批的信息。一些程序员 错误地认为必须通过 - g 选项来编译一个程序, 才能得到符号表信息。实际上, 每个可重定 位目标文件在 . s ymt a b 中 都 有 一 张符号表(除非程序员特意用 ST R IP 命令去掉它)。然而, 和编译器中的符号表不同,. s ymt a b 符 号 表 不包含局部变量的条目。\n.rel.text: 一个.te江节中位置的列表, 当链接器把这个目标文件和其他文件组合时,需要修改这些位置。一般而言, 任何调用外部函数或者引用全局变篮的指令都需要修改。另 一方面,调 用 本 地函数的指令则不需要修改。注意, 可执行目标文件中并不需要重定位信息,因此通常省略,除非用户显式地指示链接器包含这些信息。\n.rel.data: 被模块引用或定义的所有全局变最的重定位信息。一般而言, 任何已初始化的 全局变扯, 如果 它 的 初始值是一个全局变量地址或者外部定义函数的地址,都 需 要被修改。\n.debug: 一个调试符号表,其 条 目 是 程序中定义的局部变量和类型定义, 程 序 中 定义和引 用的全局变扯,以 及 原 始 的 C 源文件。只有以 - g 选项调用编译器驱动程序时, 才\n会得到这张表。\n.line: 原始 C 源程序中的行号和 . t e x t 节中机器指令之间的映射。只有以- g 选项调\n用编译器驱动程序时,才会得到这张表。\n.strtab: 一个字符串表, 其 内 容包括 . s ymt a b 和 . d e b u g 节中的符号表,以 及节头部 中 的 节 名字。字符串表就是以 nul l 结尾的字符串的序列。\n囚 日 为什么未初始化的数据称为 . b ss\n用术语 . bs s 来表 示 未初 始化的数据是很普遍的。 它起 始于 IB M 704 汇编语言(大约在 1 957 年)中"块存储开始 ( Block Storage Start )\u0026quot; 指令的 首 字母 缩 写 , 并 沿 用 至今。一种记住 . d a t a 和 . b s s 节之间 区 别的 简 单方 法是把 \u0026quot; bss \u0026quot; 看成是“ 更好地节 省空间 , ( Be tt e r S ave S pace ) \u0026quot; 的缩写。\n5 符号和符号表\n每个可重定位目标模块 m 都有一个符号表, 它 包 含 m 定 义 和引用的符号的信息。在链 接器的上下文中,有 三种不同的符号:\n由 模 块 m 定义并能被其他模块引用的全局符号。全局链接器符号对应千非静态的 C\n函数和全局变量。\n由其他模块定义并被模块 m 引用的全局符号。这些符号称为外部符号, 对应于在其他模块中定义的非静态 C 函数和全局变量。\n只被模块 m 定义和引用的局部符号。它们对应于带 s t a t i c 属性的 C 函数和全局变握。这些符号在模块 m 中任何位置都可见,但 是 不 能 被其他模块引用。\n认识到本地链接器符号和本地程序变量不同是很重要的。.s ymt a b 中 的 符号表不包含对 应于本地非静态程序变量的任何符号。这些符号在运行时在栈中被管理,链 接器对此类符号不感兴趣。\n有趣的是,定 义为带有 C s t a t i c 属性的本地过程变量是不在栈中管理的。相反,编译 骈在 . d a t a 或 . bs s 中为每个定义分配空间,并 在 符 号 表中创建一个有唯一名字的本地链 接器符号。比如, 假设在同一模块中的两个函数各自定义了一个静态局部变量 X :\nint f ()\n2 {\n3 static int x = O;\nreturn x·\n1 int gO\nstatic int x = 1;\n10 return x;\n在这种情况中, 编译器向汇编器输出两个不同名字的局部链接器符号。比如, 它可以用 x . 1 表 示 函 数 f 中 的定 义, 而用 x . 2 表示函数 g 中的定义。\n田 注 皿 勾 利用 s t a 七i c 属性隐藏变量 和函数名字\nC 程序员使 用 s t a t i c 属性隐藏模块内部的 变量 和函数声明 ,就 像 你在 Java 和 C++,\n中使用 p u b 江 e 和 pr i v a t e 声 明一样。在 C 中, 源 文件扮演模块的 角 色。 任何带 有\nS 七a t i c 属性声明的 全局 变量 或者函数都是模块私有的。类似地, 任何不 带 s t a t i c 属性声明的 全局变量 和函数都是公共的, 可以被其他模块访问。尽可能 用 s t a 巨 c 属性来保护你的变量和函数是很好的编程习惯。\n符号表是由汇编器构造的 ,使用 编译器输出到汇编语言. s 文件中的符号。. s ymt a b 节中包含 ELF 符号表。这张符号表包含一个条目的数组。图 7-4 展示了每个条目 的格式。\ncode/linklelfstructs.c\ntypedef struct {\nint name; I* String table offset *I\nchar type:4, I* Function or data (4 bits) *I\nbinding:4; I* Local or global (4 bits) *I\nchar reserved; I* Unused *I\nshort section; I* Section header index *I\nlong value; I* Section offset or absolute address *I\nlong size; I* Object size in bytes *I\n} Elf64_Symbol;\n图 7-4\ncode/link/elfstructs.c\nELF 符号表条目 。t ype 和 bi ndi ng 字段每个都是 4 位\nna me 是字符串表中的字节偏移, 指向符号的以 n u l l 结尾的字符 串名字。v a l ue 是符号的地址。对 于可重定位的 模块来说, v a l u e 是距定义目标的节的起始位置的偏移。对于可执行目标文件来说 ,该 值是一个绝对运行时地址 。s i z e 是目标的大小(以字节为单位)。t ype 通常要 么是数据, 要么是函数。符号表还可以 包含各个节的条目, 以及对应原始源文件的路径名的 条目。所以这些目标的类型也有所不同。b i n d i ng 字段表示符号是本地的还是全局的。\n每个符号都被分配到 目标文件的某个节, 由 s e c t i o n 字段 表示 , 该字段也是一个到节头部表的 索引。有三个特殊的伪节( ps e ud os ect io n ) , 它们在节头部表中是没有条目的:\nABS 代表不该被重定位的符号; UNDEF 代表未定义的符号, 也就是在本目标模块中引用,但是却在其他地方定义的符号; COM MON 表示还未被分配位置的未初始化的数据目标。对于 COMMON 符号, v a l u e 字段给出对齐要求, 而 s i z e 给出最小的大小。注意, 只有可重定位目标文件中才有这些伪节,可执行目标文件中是没有的。\nCOMMON 和. b s s 的区别很细微。现代的 GCC 版本根据以下规则来将可重定位目标\n文件中的符号 分配到 CO MMO N 和. b s s 中:\nCOMMON 未初始化的全局变量\n.bss 未初始 化的静 态变篮,以及 初始 化为 0 的全 局或静 态变量\n采用这种看上去很绝对的区分方式的原因来自于链接器执行符号解析的方式, 我们会在\n7. 6 节中加以 解释。\nGNU READELF 程序是一 个查看目标文件内 容的很方便的工具 。比如, 下面是图 7-1 中示例程序的 可重定 位目标文件 ma i n . .o 的符号表中的 最后三个条目。开始的 8 个条目没有显示出来 , 它们是链接器内部使用的局部符号。\nNum. :\nValue\nSize Type\nBind Vis\nNdx Name\n8: 0000000000000000\n9: 0000000000000000\n10: 0000000000000000\n24 FUNC\n8 OBJECT\n0 NOTYPE\nGLOBAL DEFAULT GLOBAL DEFAULT GLOBAL DEFAULT\n1 main\n3 array UND sum\n在这个例子中, 我们看到全局 符号 ma i n 定 义的条目, 它是一个位于. t e x t 节中偏移量 为 0 ( 即 va l ue 值)处的24 字节函数。其后跟随着的是全局符号 arr a y 的定义, 它是一个位于. da t a 节中偏移量为 0 处的 8 字节目标。最后一个条目来自对外部符号 s um 的引用. READEL F 用一个整数索引来标识 每个节。 Ndx =l 表示. t e xt 节, 而 Ndx =3 表示. da t a 节。\n沁囡 练习题 7. 1 这个题 目针 对图 7-5 中的 m. o 和 s wa p . a 模块。 对于每 个在 s wa p . a 中定义或引 用 的符 号, 请 指 出 它 是否在模块 s wa p . a 中的 . s ym七a b 节 中 有 一个 符号表条目。 如果 是, 请指 出定义该 符号的模 块( s wa p . a 或者 m. o ) 、 符号 类型(局部、 全局或者外部)以及它在模 块中被 分配到的 节( . t e x t 、. d a t a 、. b s s 或 COMMON ) 。\nvoid swap();\ncode/link/m.c\nextern int buf [) ;\ncode/linklswap.c\nint buf[2] = {1, 2};\nint main()\n{\nswap(); return O;\n}\ncode/link/m.c\nint *bufpO = \u0026amp;buf[O]; int *bufpl;\nvoid swap()\n{\nint temp;\nbufp1 = \u0026amp;buf[1]; temp= *bufpO;\n*bufpO = *bufp1;\n*bufp1 = temp;\nm.c b) swap. c 图 7-5 练习题 7. 1 的示例程序\ncode/link/swap.c\n7. 6 符号解析\n链接器解析符号引用的方法是将每个引用与它输入的可重定位目标文件的符号表中的 一个确定的符号定义关联起来。对那些和引用定义在相同模块中的局部符号的引用,符号 解析是非常简单明了的。编译器只允许每个模块中每个局部符号有一个定义。静态局部变 量也会有本地链接器符号,编译器还要确保它们拥有唯一的名字。\n不过,对全局符号的引用解析就棘手得多。当编译器遇到一个不是在当前模块中定义 的符号(变扯或函数名)时,会假设该符号是在其他某个模块中定义的,生成一个链接器符号表条目,并 把它交给链接器处理。如果链接器在它的 任何输入模块中都找不到这个被引用符号的定义,就输出一条(通常很难阅读的)错误信息并终止。比如,如果我们试着在一\n台 L in ux 机器上编译和链接下面的源文件 :\nvoid foo(void);\nint main() { foo(); return O;\n}\n那么编译 器会没有障碍地运行, 但是当链接器无法解析对 f o o 的引用时, 就会终止:\nlinux\u0026gt; gee -Wall -Og -o linkerror linkerror. e\n/tmp/ccSz5uti.o: In function\u0026rsquo;main':\n/tmp/ccSz5uti.o(.text+Ox7): u 卫 defi ned reference t o \u0026rsquo; f oo'\n对全局符号的符号解析很棘手,还因为多个目标文件可能会定义相同名字的全局符 号。在这种情况中,链接器必须要么标志一个错误,要么以某种方法选出一个定义并抛弃 其他定义。 Li nu x 系统采纳的方 法涉及 编译器、汇编器和链接器之间的协作, 这样也可能\n给不警觉的程 序员带来一些麻烦。\nm 对 C + + 和 J a va 中链接器符号的重整\nC++ 和 Java 都允许重栽方法,这 些方法在 源代码中有 相同的名宇,却 有不同的 参数\n列表。那么链接器是如何区别这些不同的重栽函数之间的差异呢? C + + 和 J ava 中能使 用重栽函数,是因为编译器将每个唯一的方法和参数列表组合编码成一个对链接器来说唯一的名字。 这种编码过程叫做 重整 ( mangling ) , 而相反的过程叫做 恢复( demangling ) 。\n幸运的是 , C ++ 和 Java 使用兼容的 重整 策略。一个被 重整的 类名 字是由名字中宇符的整数数 量, 后面跟原始名 字组成的 。比如,类 Fo o 被编码成 3Foo 。方法被 编码为原始方法名,后 面加上__, 加上被重整的 类名 ,再加 上每个参数的 单宇母 编码。比如, Foo : :bar (int, l ong) 被编码为 b ar 3Foo 斗。 重整全局 变量 和模板名字的 策略是相似的。\n6. 1 链接器如何解析多重定义的全局符号\n链接器的输入是一 组可重定位目标模块。每个模块定义一组符号, 有些是局部的(只对定义该符号的模块可见),有些是全局的(对其他模块也可见)。如果多个模块定义同名 的全局符号 , 会发生什么呢?下面是 L in ux 编译系统采用的 方法。\n在编译时, 编译器向汇编器输出每个全局符号, 或者是强 ( st ro n g ) 或者是弱 ( w ea k ) , 而汇编器把这个信息隐含地编码在可重定位目标文件的符号表里。函数和已初始化的全局 变量是强符号,未初始化的全局变量是弱符号。\n根据强弱符号 的定义, L in ux 链接器使用下面的规则来处 理多重定义的符号名:\n规则 1 : 不允许有多个同名的强符号。 规则 2 : 如果有一个强符号和多个弱符号同名,那么选择强符号。\n规则 3: 如果有多个弱符号同名,那么从这些弱符号中任意选择一个。比如, 假设我们试图编译和链接下面两个 C 模块:\nI* fool.c *I\nint main()\n{\nreturn O;\n}\nI* barl.c *I\n2 int main()\n3 {\nreturn O;\n5 }\n在这个情况中,链 接 器将生成一条错误信息,因 为强符号 ma i n 被定义了多次(规则D:\nlinux\u0026gt; gee foo1.c bar1.c\n/tmp/ccq2Uxnd.o: In function\u0026rsquo;main\u0026rsquo;: barl.c:(.text+OxO): multiple definition of\u0026rsquo;main'\n相似地,链 接 器 对 于下面的模块也会生成一条错误信息,因 为 强 符号 x 被定义了两次\n(规 则 U :\nI* foo2.c *I\n2 int X = 15213;\n4 int main()\n5 {\nreturn O;\n7 }\nI* bar2.c *I\n2 int X = 15213;\n4 void f()\n5 {\n6 }\n然而,如 果 在 一 个 模 块 里 x 未被初始化,那 么 链 接器将安静地选择在另一个模块中定义 的 强 符 号(规则 2 ) :\nI* foo3.c *I\n2 #include \u0026lt;stdio.h\u0026gt; 3 void f(void); 5 int X = 15213; 7 int main() 8 { 10 f (); printf(\u0026ldquo;x = 炽\\ n\u0026rdquo;, x); return O; 12 } I* bar3.c *I\n2 int x·\n4 void f ()\n5 {\n6 X = 15212;\n7 }\n在运行时,函 数 f 将 x 的 值 由 1 5 21 3 改 为 1 521 2 , 这会 给 ma i n 函 数 的 作 者带来不受欢 迎 的 意 外! 注 意, 链接器通常不会表明它检测到多个 x 的定义:\nlinux\u0026gt; gee -o foobar3 foo3.e bar3. e linux\u0026gt; ./foobar3\nX = 15212\n如果 x 有两个弱定义,也 会发生相同的事情(规则3 ) :\nI* foo4.c *I\n#include \u0026lt;s t d i o . h\u0026gt; void f(void);\nint x;\nint main()\n{\nX = 15213 ;\nf O;\nprintf(\u0026ldquo;x = %d\\n\u0026rdquo;, x); return O;\n}\nI* bar4.c *I\nint x;\nvoid f 0\n{\nX = 15212;\n}\n规则 2 和规则 3 的应用会造成一些不易察觉的运行时错误, 对于不警觉的程序员来说,是很难理解的, 尤其是如果重复的符号定义还有不同的类型时。考虑下面这个例子, 其中 x 不幸地在一个模块中定义为 i n t , 而在另一个模块中定义为 d o u b l e :\nI* foo5.c *I\n#include \u0026lt;s t di o . h\u0026gt; void f(void);\nint int\n15212;\n15213;\nint main()\n{\nf O;\npr i nt f (\u0026ldquo;x = Ox% x y = Ox 妘 \\ n \u0026ldquo;\u0026rsquo; x, y);\nreturn O;\n}\nI* bar 5 . c *I\ndouble x;\nvoid f 0\n{\nX \u0026ldquo;\u0026rsquo; - 0 . Q;\n}\n在一台 x86- 64 / L in u x 机器上, d o u b l e 类 型 是 8 个 字节 ,而 i n t 类 型 是 4 个字节。在我们的系统中, x 的 地址是 Ox 601 02 0 , y 的 地址是 Ox 601 0 2 4。因此, b ar 5 . c 的 第 6 行中的赋值 x = -0. 0 将用负零的双精度浮点 表示覆盖内存中 x 和 y 的位置( fo o 5 . c 中的第 5 行和第 6 行)!\nlinux\u0026gt; gee -Wall -Og -o foobar5 foo5. e bar5. e\n/usr/bin/ld: Warning: alignment 4 of symbol\u0026rsquo;x\u0026rsquo;in /tmp/cclUFK5g.o is smaller than 8 in /tmp/ccbTLcb9.o\nlinux\u0026gt; ./toobar5\nx = OxO y = Ox80000000\n这是一个细微而令人讨厌的错误,尤其是因为它只会触发链接器发出一条警告,而且 通常要在程序执行很久以后才表现出来,且 远离错误发生地。在一个拥有成百上千个模块的大型系统中,这种类型的错误相当难以修正,尤其因为许多程序员根本不知道链接器是 如何工作的。当你怀疑有此类错误时, 用 像 G C C - f n o - c o mmo n 标 志 这样的选项调用链接器,这个选项会告诉链接器,在遇到多重定义的全局符号时,触发一个错误。或者使用\n- Wer r or 选 项 ,它 会把所有的警告都变为错误。\n在 7. 5 节中, 我们看到了编译器如何按照一个看似绝对的规则来把符号分配为 OM\nMON 和. bs s 。实际上, 采用这个惯例是由千在某些情况中链接器允许多个模块定义同名航全局符号。当编译器在翻译某个模块时, 遇到一个弱全局符号,比 如说 x , 它并不知道其他模块是否也定义了 x, 如果是,它 无法 预 测链接器该使用 x 的多重定义中的哪一个。所以编译\n器把 x 分配成COMMON, 把决定权留给链接器。另一方面, 如果 x 初始化为o, 那么它是一个\n强符 号(因此根据规则 2 必须是唯一的),所以 编译 器可以很自信地将它分配成. bs s 。类似地, 静态符号的构造就必须是唯一的,所以编译 器可以自信地把它们分配成. da t a 或. bs s 。\n; 练习题 7. 2 在此题 中, REF (x. i)-DEF (x.k) 表 示链 接器 将把模 块 1 中对符 号 x 的任意引用 与模块 k 中 x 的定 义关联 起来。对于下 面的 每个 示例 ,用 这种表 示 法来 说明链接器将如何解析 每个模块 中对 多 重定义 符 号 的引 用。 如果有 一个链接 时错误(规则 1 )\u0026rsquo; 写\n“错 误"。 如 果链接 器从 定义中任意选择 一个(规则 3) , 则写“未知”。\nI* Module 1 *I\nint main()\n{\n}\nI* Module 2 *I\nint main;\nint p20\n{\n}\nREF( ma i n .1) DEF(.)\n(REF(ma i n .2) DEF(.)\nBI.\nModule 1 *I\nI* Module 2 *I\nvoid main()\n{\n}\nint main= 1; int p2()\n{\n}\n(a) REF(ma i n .1) DEF(.)\n(REF(ma i n .2) DEF(.)\nCIModule 1 *I\nint x;\nvoid main()\n{\n}\nI* Module 2 *I double x = 1.0; int p2()\n{\n}\nREF(x.1) DEF( . )\nCb) REF(x.2) DEF(.)\n7. 6. 2 # 与静态库链接\n迄今为止 , 我们都 是假设链 接器读取一组可重定位目标文件, 并把它们链接起来,形成一个输出的可执行文件。实际上,所有的编译系统都提供一种机制,将所有相关的目标模块打包成为一个单独的文件, 称为静态库 ( s t atic library), 它可以用做链接器的输入。当链接器构造一个输出的可执行文件时,它只复制静态库里被应用程序引用的目标模块。\n为什么系统要 支持库的概念呢?以 ISO C99 为例, 它定义了一组广泛的标准 I/ 0 、字符串操作 和整数数学函数, 例如 a t o i 、p r i n t f 、s c a n f 、S 七r c p y 和r a nd 。它们在 l i b c .\na 库中, 对每个 C 程序来说都是可用的。ISO C99 还在 li b m. a 库中定义了一组广泛的浮点数学函数 , 例如 s i n 、c o s 和 s q r t 。\n让我们来看看如果不使用静态库,编译器开发人员会使用什么方法来向用户提供这些 函数。一种方法是让编译器辨认出对标准函数的调用 , 并直接生成 相应的代码。Pascal ( 只提供了一小部分标准函数)采用的就是这种方法, 但是这种方法对 C 而言是不合适的, 因为 C 标准定义了大量的标准函数。这种方法将给编译器增加显著的复杂性, 而且每次添加、删除 或修改一个标准函数时,就需 要一个新的编译器版本。然而,对 于应用程序员而言,这 种方法会是 非常方便的 , 因为标准函数将总是可用的 。\n另一种方法是 将所有的标准 C 函数都放在一个单独的可重定位目标模块中(比如说\nlibc.o中)应用程序员 可以把这个模块链接到他们的 可执行文件中 :\nlinux\u0026gt; gee main.e /usr/lib/libe.o\n这种方法的优点是它将编译器的实现与标准函数的实现分离开来,并且仍然对程序员 保持适度的便 利。然而, 一个很大的缺点是系统中每个可执行 文件现在都包含着一份标准函数集合的完 全副本, 这对磁盘空间是很大的浪费。(在一个典型的系统上, 辽 b e . a 大 约是 5MB , 而 让bm. a 大约是 2M B。)更糟的是, 每个正 在运行的程序都将它 自己的 这些函数的副本放在内存中 , 这是对内存的极度浪费。另一个大的缺点是, 对任何标准函数的任何改变,无论多么小的改变,都要求库的开发人员重新编译整个源文件,这是一个非常耗时 的操作,使得标准函数的开发和维护变得很复杂。\n我们可以通过为每个标准函数创建一个独立的可重定位文件,把它们存放在一个为大\n六仁 家都知道的目 录中来解 决其中的一些间题。然而, 这种方法要求应用程序员显式地链接合\n适的目标模块到它们的可执行文件中,这是一个容易出错而且耗时的过程:\nlinux\u0026gt; gee main. e /usr/lib/printf.o /usr/lib/seanf.o . . .\n静态库概念被提出来,以解决这些不同方法的缺点。相关的函数可以被编译为独立的目标模块,然后封装成一个单独的静态库文件。然后,应用程序可以通过在命令行上指定单独的文件名字来使用这些在库中定义的函数。比如, 使用 C 标准库和数学库中函数的程序可以用形式如下的命令行来编译和链接:\nlinux\u0026gt; gee main.e /usr/lib/libm.a /usr/lib/libe.a\n在链接时,链接器将只复制被程序引用的目标模块,这就减少了可执行文件在磁盘和内 存中的大小。另一方面,应用 程序员只需要包含较少的 库文件的名字(实际上, C 编译器驱\n动程序总是传送 li b c . a 给链接器,所以前 面提到的对 让be . a 的引用是不必要的)。\n在 L in u x 系统中, 静态库以一种称为存档( a rc h ive ) 的特殊文件格式存放在磁盘中。存档文件是一组连接起来的可重定位目标文件的集合,有一个头部用来描述每个成员目标文 件的大小和位置。存档文 件名由后 缀 . a 标识。\n为了使我们对库的讨论更加形象具体, 考虑图 7- 6 中的两个向量例程。每个例 程, 定义在它自己的目标模块中,对两个输入向量进行一个向量操作,并把结果存放在一个输出 向量中。每个例程有一个副作用,会记录它自已被调用的次数,每次被调用会把一个全局 变量加 1。(当我们在 7. 1 2 节中解释位置无关 代码的思想 时会起作用。)\n1 int addcnt = 0;\ncode/link/addvec.c\nint multcnt = O;\ncode/linklmultvec.c\n2 2\nvoid addvec(int *X, int *Y, 3 void multvec(int *X, int *Y,\nint *Z, int n) 4 int *z, int n)\n5 { 5 {\n6 inti; 6 inti;\n7 7\n8 addcnt++; 8 multcnt++;\n9 9\n10 for (i = O; i \u0026lt; n; i++) 10 for (i = O; i \u0026lt; n; i++) 11 Z (i] = X [i] + y [i] ; 11 Z [i] = X [i] * y [i] ;\n12 } 12 }\ncode/linkladdvec.c codellinklmultvec.c\naddvec. o b) multvec.o\n图7-6 l i bvec t or 库中的成员目标 文件\n要创建这些函数的一个静态库 , 我们将使用 AR 工具, 如下:\nlinux\u0026gt; gee -e addvee.e multvee.e\nlinux\u0026gt; ar res l i bveet or .a addvee.o multvee.o\n为了使用这个库 , 我们可以编写一个应用, 比如图 7-7 中的 ma i n 2 . c , 它调用 a ddve c\n库例程。包含(或头)文件v e c t o r . h 定义了 巨 bv e c t or . a 中例程的函数原型。\ncode/link/main2.c\n#include \u0026lt;stdio. h\u0026gt;\n#include \u0026ldquo;vector .h\u0026rdquo;\n3\n4 int X[2] = {1, 2};\n5 int y[2] = {3, 4};\n6 int z[2];\n7\n8 int marnO\n9 {\n1o addvec (x, y, z, 2) ;\n11 printf(\u0026ldquo;z = [%d %d]\\n\u0026rdquo;, z[O], z[1]);\n12 return O,·\n13 }\ncode/link/main2.c\n图 7-7 示例程序 2。这个程序调用 l i bve c t or 库中的函数\n为了创建这个可执行 文件, 我们要 编译和链接输入文件 ma i n . a 和 l i b v e c t o r .a:\nlinux\u0026gt; gee -e main2.e\nlinux\u0026gt; gee -statie -o prog2e mai n 2 . o . / l i bve ct ro . a\n或者等价地使用:\nl i nux\u0026gt; gee - e ma i n2 . e\nl i nu x \u0026gt; gee -statie -opr og2e mai n2 . o - L . -lveetor\n图7-8 概括了链接器的行为。- s t a t i c 参数告诉编译器驱动程序,链 接 器 应 该 构 建 一个完全链 接的可执行目标文件, 它 可以加载到内存并运行, 在 加 载时无须更进一步的链接。 - l v e c t or 参 数是 l i b v e c t or . a 的 缩写, - L . 参 数 告诉 链接器在当前目录下查找 li b - ve c t o r . a 。\n源文件 main2.c vector.h\n翻译器\n(epp, eel, as) I l i bvee t or . a l i bc . a 静态库\n可重定位目标文件 ma1.n 2 . o I addvec. o\n链接器 (l d )\npr i nt f. o和其他pr i nt f. o调用的模块\npr og 2_c 完全链接的\n可执行目标文件图 7-8 与静态库链接\n当链接器运行时 , 它判定 ma i n 2 . o 引 用 了 a d d v e c . o 定 义的 a d d v e c 符号 ,所 以 复 制addve c . o 到可执行文件。因为程序不引用任何由 mu l t v e c . o 定 义 的 符 号 ,所 以 链 接 器 就不会复制这个模块到可执行文件。链接器还会复制 l i b c . a 中的 pr i n t f . o 模块,以 及 许多 C 运行 时系统中的其他模块。\n6. 3 链接器如何使用静态库来解析引用\n虽然静态库很有用,但 是 它 们 同 时 也 是 一个程序员迷惑的源头,原 因 在 于 L in u x 链接器使用它们解析外部引用的方式。在符号解析阶段,链接器从左到右按照它们在编译器驱 动程序命令行上出现的顺序来扫描可重定位目标文件和存档文件。(驱动程序自动将命令 行中所有 的 . c 文件翻译为 . o 文件。)在这次扫描中, 链接器维护一个可重定位目标文件的集合 E C这个集合中的文件会被合并起来形成可执行文件), 一 个 未解析的符号(即引用了\n但是尚未定 义的符号)集合 u , 以及一个在前面输入文件中已定义的符号集合 D。初始时,\nE、U 和 D 均为空。\n对千命令行上的每个输入文件 f , 链接器会判断 J 是一个目标文件还是一个存档文件。如果 J 是一个目标文件,那 么链 接器把 f 添加到 E , 修改 U 和 D 来反映 f 中的符号定义和引用, 并 继 续下 一 个 输 入 文 件 。\n如果 J 是一个存档文件,那 么链接器就尝试匹配 U 中未解析的符号和由存档文件成员定\n义的符号。如果某个存档文件成员 m, 定义了一个符号来解析 U 中的一个引 用,那么就将 m 加到E 中, 并 且链 接 器修改 U 和D 来反映 m 中的符号定义和引用。对存档文件中所有的成员目标文件都依次进行这个过程, 直 到 U 和 D 都不再发生变化。此时,任何不包含在 E 中的成员目标文件都简单地被丢弃,而 链接器将继续处理下一个输入文件。\n;,\n如果当链接器完成对命令行上输入文件的扫描后, U 是非空的, 那么链接器就会输出一个错误并终止。否则,它 会合并和重定位E 中的目标文件,构 建输出的可执行文件。\n不幸的是,这种算法会导致一些令人困扰的链接时错误,因为命令行上的库和目标文 件的顺序非常重要。在命令行中, 如果定义一个符号的库出现在引用这个符号的目标文件之前, 那么引用就不能被解析, 链接会失败 。比如, 考虑下面的命令行发生了什么?\nlinux\u0026gt; gee -static ./libvector.a main2.c\n/tmp/cc9XH6Rp.o: In function\u0026rsquo;main':\n/ t mp / cc9XH6Rp . o ( . t e xt +Ox18 ) : undefined reference to\u0026rsquo;addvec'\n在处理 l i b v e c t o r . a 时, U 是空的,所 以没有 l i b v e c t or . a 中的成员目标文件会添加到 E 中。因此, 对 a d d v e c 的引用是绝不会 被解析的, 所以链接器会产生一条错误信息并终止。\n关于库的一般准则是将它们放在命令行的结尾。如果各个库的成员是相互独立的(也就是说没有成员引用另一个成员定义的符号),那么这些库就可以以任何顺序放置在命令 行的结尾处。另一方面,如果库不是相互独立的,那么必须对它们排序,使得对千每个被 存档文件的成员外部引用的符号 s\u0026rsquo; 在命令行中 至少有一个 s 的定义是在对 s 的引用之后的。比如, 假设 f o o . c 调用 l i b x . a 和 l i b z . a 中的函数, 而这两个库又调 用 li b y : a 中的 函数。那么,在 命令行中 让b x . a 和 l i b z . a 必须处在 l i b y . a 之前:\nlinux\u0026gt; gee foo.e l i bx.a libz.a liby.a\n如果需要满足依 赖需求, 可以在命令行上重复库。比如, 假设 f o o . c 调用 li bx . a 中的 函数 ,该 库又调用 l i b y . a 中的函数, 而 l i b y . a 又调用 l i b x . a 中的函数。那么 li bx.\na 必须在命令行 上重复出现:\nlinux\u0026gt; gee foo.e l i bx.a liby.a libx.a\n另一种方法是, 我们可以将 l i b x . a 和 li b y . a 合并成一个单独的存档文件。\n让 练习题 7. 3 a 和 b 表示当前目录中的目标模 块或者静态库,而 a- b 表示 a 依赖于b, 也就是说 b 定义了一 个被 a 引用的符号。对于下面每种场景,请给出最小的命令行(即 一个含有最少数量的目标文件和库参数的命令), 使得静态链接器能解析所有的符号引用。\nA. p.o - libx.a B. p.o - libx.a - liby.a C. p . o - 巨 b x . a - l i b y . a 且 巨 b y . a - libx.a - p.o 7 重定位\n一旦链接器完成了符号解析这一 步, 就把代码中的 每个符号引 用和正好一个符号定义\n(即它的一个输入目标模块中的一个符号表条目)关联起来。此时,链接器就知道它的输人目标模块中的代码节和数据节的确切大小。现在就可以开始重定位步骤了,在这个步骇中,将合并输入模块,并为每个符号分配运行时地址。重定位由两步组成:\n重定位节和符号定义。在这一步中,链接器将所有相同类型的节合并为同一类型的 新的聚合节。例如 ,来自所有 输入模块的 . d a t a 节被全部合并成一个节, 这个节成为输出的可执行目 标文件的 . d a t a 节。然后, 链接器将运行时内存地址赋给新的聚合节,赋 给输入模块定 义的每个节,以 及赋给输入模 块定义的每个符号。当这一步完成时, 程序中 的每条指令和全局变量都有唯一的运行时内存地址了。 重定位节中的符号引用。在这一步中,链接器修改代码节和数据节中对每个符号的引用,使得它们指向正确的运行时地址。要执行这一步,链接器依赖于可重定位目标模块中称为重定位 条目 ( r e lo ca t ion e n t r y ) 的数据结构, 我们接下来将会描述这种数据结构。 7. 1 重定位条目\n当汇编器生成一个目标模块时,它 并不知道数据和代码最终将放在内存中的什么位置。它也不知道这个模块引用的任何外部定义的函数或者全局变量的位置。所以,无论何 时汇编器遇到对最终位置未知的目标引用,它就会生成一个重定位条目,告诉链接器在将 目标文件合并成可执行文件时如何修改这个引用。代码的重定位条目放在 .r e l . 七e x t 中。已初始 化数据的重定位条目放在 . r e l . d a 七a 中。\n图 7-9 展示了 ELF 重定位条目 的格式。o f f s e t 是需要被修改的引用的节偏移。 s ymbo l 标识被修改引 用应该指向的符号。t y pe 告知链接器如何修改新的引用。a d d e n d 是一个有符号常数,一些类型的重定位要使用它对被修改引用的值做偏移调整。\ncodellinklelfstructs.c\ntypedef struct {\nlong offset; I* Offset of the reference to relocate *I\nlong type:32, I* Relocation type *I\nsymbol:32; I* Symbol table index *I\nlong addend; I* Constant part of relocation expression *I\n} Elf64_Rela;\n图 7-9\ncodellink/elfstructs.c\nELF 重定位条目。每个条目表示一个必须被重定位的引用 , 并指明如何计算被修改的引用\nELF 定义了 3 2 种不同的重定位类型,有 些相当隐秘。我们只关心其中两种最基本的重定位类型:\nR_ X8 6_ 6 4_ PC32。重定位一个使用 32 位 PC 相对地址的引用。回想一下 3. 6. 3 节,\n一个 PC 相对地址就是距程序计数器( PC ) 的当前运行时值的偏移量。当 CPU 执行一条使用 PC 相对寻址的指令时, 它就将在指令中编码的 32 位值加上 PC 的当前运行时值, 得到有效地址(如c a l l 指令的目标), PC 值通常是下一条指令在内存中的地址。\nR_ X8 6_ 6 4_ 32 。重定位一个使用 3 2 位绝对地址的引用。通过绝对寻址, CP U 直接使用在指令 中编码的 32 位值作为有效地址, 不需要进一 步修改。\n这两种重定位类型支持 x86- 64 小型 代码模型( small code model) , 该模型假设可执行目标 文件中的代码和数据的总体大小小 于 2G B, 因此在运行时 可以用 32 位 PC 相对地址来访问。GCC 默认使用小 型代码模型。大千 2G B 的程序可以用- mc mod e l =me d i u m( 中型代码模型) 和- mc mo d e l =l ar g e ( 大型代码模型)标志来编译, 不过在此我们 不讨论 这些模型。\n7. 2 重定位符号引用\n图 7-10 展示了链接器的重定位算法的伪代码。第 1 行和第 2 行在每个节 s 以及与每个节相关联 的重定位条目r 上迭代执行。为了 使描述具体化, 假设每个 节 s 是一个字节数组,每个重 定位条目r 是一个类型为 El f 6 4_ Re l a 的结构, 如图 7- 9 中的定义。另外, 还\n假设当算法运行时 , 链接器巳经为每个节(用 ADDR (s ) 表示)和每个符号都选择了运行时地址(用 ADDR (r. s ymbo l ) 表示)。第3 行计算的是需要被重定位的 4 字节引用的数组 s 中的地址。如果这个引用使用的是 PC 相对寻址 , 那么它就用第 5~ 9 行来重定位。如果该引用使用 的是 绝对寻址,它 就通过第 11 ~ 1 3 行来重定 位。\nforeach sections {\n2 foreach relocation entryr {\n3 refptr = s + r.offset; I* ptr to reference to be relocated *I 4 5 I* Relocate a PC-relative reference *I 6 if Cr.type== R_X86_64_PC32) { 7 refaddr = ADDR(s) + r.offset; I* ref\u0026rsquo;s run-time address *I 8 *refptr = (unsigned) (ADDR(r.symbol) + r.addend - refaddr); 9 } 10 11 / * Relocate an absolute reference *I 12 if (r.type == R_X86_64_32) 13 *refptr = (unsigned) (ADDR(r.symbol) + r.addend); 14 } 15 } 图 7-10 重定位算法\n让我们来看看链接器 如何用这个算法来重定位图 7-1 示例程序中的引 用。图 7-11 给出了(用o b j dump-dx main. o 产生的)GNU OBJDU MP 工具产生的 ma i n . o 的反 汇编代码。\ncodellinklmain-relo.d\n1 0000000000000000 \u0026lt;main\u0026gt; :\n2 0: 48 83 ec 08 sub $0x8,%rsp 3 4 : be 02 00 00 00 mov $0x2,%esi 4 9 : bf 00 00 00 00 mov $0xO,%edi ¾edi = \u0026amp;array 5 a: R_X86_64_32 array Relocation entry 6 e: e8 00 00 00 00 callq 13 \u0026lt;main+Ox13\u0026gt; sum() 7 f: R_X86_64_PC32 sum-Ox4 Rel ocat 工on entry 8 13: 17: 48 c3 83 c4 08 add $0x8,%rsp retq code/linklmain-relo.d 图 7-11 ma i n. o 的 代码和重定位条目 。原始 C 代码在图 7-1 中\nma i n 函数引用了两个全局符号: ar r a y 和 s u m。 为每个引用, 汇编器产生一个重定位条目,显 示在引用的后面一行上 产 这些重定位条目告诉链接器对 s um 的引用要使用 32 位 PC 相对地址进行重定位, 而对 arr a y 的引用要使用 32 位绝对地址进行重定 位。接下来两节会详细介绍链接器是如何重定位这些引用的。\n1 重定位 PC 相对引 用\n图 7-11 的第 6 行中, 函数 ma i n 调用 s um 函数, s u m 函数是在模块 s u m. o 中定义的,\ne 回想一下, 重定 位条目和指令实际上存 放在目 标 文件的 不同 节中 。 为了 方便, O BJDUMP 工具把它们显示在一起。\nca ll 指令开始于节偏 移 Ox e 的地方, 包括 1 字节的操作码 Ox e 8 , 后面跟着的是对目标\ns um 的 32 位 PC 相对引用的占位符。\n相应的重定位条目r 由 4 个字段组成:\nr.offset = Oxf r.symbol = sum\nr.type = R_X86_64_PC32\nr.addend = -4\n这些字段告 诉链接器修改 开始千偏移最 Ox f 处的 32 位 PC 相对引用, 这样在运行时它会指向 s um 例程。现在, 假设链接器已经确定\nADDR(s) = ADDR( . text) = Ox4004d0\n和\nADDR(r.symbol) = ADDR(sum) = Ox4004e8\n使用图 7-10 中的算法, 链接器首先计算出引用的运行时地址(第 7 行):\nrefaddr = ADDR(s) + r.offset\n= Ox4004d0 + Oxf\n= Ox4004df\n然后, 更新该引用,使 得它在运行时指向 s um 程序(第8 行):\n*refptr = (unsigned) (ADDR(r.symbol) + r.addend - refaddr)\n= (unsigned) (Ox4004e8 + (-4) - Ox4004df)\n= (unsigned) (Ox5)\n在得到的可执行目 标文件中, c a l l 指令有如下的重定位的形式 :\n4004de: e8 05 00 00 00 callq 4004e8 \u0026lt;sum\u0026gt; sum()\n在运行时 , c a l l 指 令将存放在地址 Ox 4 00 4d e 处。当 CPU 执行 c a l l 指令时, P C 的值为 Bx 40 0 4e 3 , 即紧随在 c a 荨 指令之后的指令的地址。为了执行这条指令, CPU 执行以下的步骤:\n将 PC 压 入栈中\nPC 七 PC + Ox5 = Ox4004e3 +Ox5 = Ox4004e8 因此, 要执行的下一 条指令就是 s um 例程的第一条指令, 这当然就是 我们想 要的!\n2. 重定位绝对引用\n重定位绝对引用相当简单。例如,图 7-11 的第 4 行中, mo v 指令将 arr a y 的地址(一个32 位立即数值 )复制到寄存器%e d i 中。mo v 指令开始于节偏移量 Ox 9 的 位置, 包括 1 字节操作码 Ox b f , 后 面跟着对 a rr a y 的 32 位绝对引用 的占位符。\n对应的占 位符条目r 包括 4 个字段:\nr.offset = Oxa\nsymbol = array\nr .t ype = R_X86_64_32 r.addend = 0\n这些字段告诉链接器要 修改从偏移量 Ox a 开始的绝 对引用, 这样在运行时它将会指向\narr a y 的第一个字 节。现 在, 假设链接器已经确定\nADDR(r.symbol) = ADDR(array) = Ox601018\n链接器使用图 7-1 0 中算法的第 1 3 行修改了引用:\n*refptr = (unsigned)\n(unsigned) (unsigned)\n(ADDR(r.symbol) + r.addend) (Ox601018 + 0) (Ox601018)\n在得到的可执行目标文件中, 该 引 用 有 下 面的重定位形式:\n4004d9 : bf 18 10 60 00 mov $0x601018,%edi ¼edi = \u0026amp;array\n综合到一起,图 7- 1 2 给出了最终可执行目标文件中已重定位的 . t e xt 节和 . da t a 节。在加载的时 候 ,加 载器会把这些节中的字节直接复制到内存,不 再进行 任何修改地执行这些指令。\n00000000004004d0 \u0026lt;main\u0026gt;:\n4004d0: 48 83 ec 08\nsub\n$0x8 , %r s p\n4004d4: be 02 00 00 00 mov $0x2,%esi 4004d9: bf 18 10 60 00 mov $0x601018,%edi ¼edi = \u0026amp;array 4004de: e8 05 00 00 00 callq 4004e8 \u0026lt;sum\u0026gt; sum() 4004e3: 48 83 c4 08 add $0x8,%rsp 4004e7: c3 retq 00000000004004e8 \u0026lt;sum\u0026gt;:\n4004e8: b8 00 00 00 00\nmov\n$0x0,%eax\n4004ed: ba 00 00 00 00\n4004£2: eb 09\n4004£4: 48 63 ca\n4004£7: 03 04 8f\n4004fa: 83 c2 01\n4004fd: 39 f2\n4004ff: 7c f3 400501: f3 c3\nmov $0x0,%edx\njmp 4004f d \u0026lt;sum+Ox15\u0026gt; mo v s l q %edx,%rcx\nadd (%rdi,%rcx,4),%eax add $0x1,%edx\ncmp %esi,%edx\njl 4004f4 \u0026lt;sum+Oxc\u0026gt; repz retq\n已重定位的 . t ext 节 图 7-12\n0000000000601018 \u0026lt;arr a y\u0026gt; :\n601018: 01 00 00 00 02 00 00 00\n巳重定位的. dat a 节 可执行文件 pr og 的已重定位的 . t e江 节和 . da t a 节。原始的 C 代码在图 7-1 中\n; 练习题 7. 4 本题是关 于图 7- 1 2a 中 的 已 重定位程序的。\n第 5 行中对 s um 的 重定 位引用 的 十 六进 制地 址是 多少?\n第 5 行中 对 s u m 的 重定位引用的十 六进制值是多少?\n; 练习题 7. 5 考虑目标 文件 m . o 中对 s wa p 函数 的调用(图 7- 5 ) 。\n9: e8 00 00 00 00 callq e \u0026lt;ma i n +Ox e \u0026gt; swap()\n它的重定位条目如下:\nr.offset = Oxa r . s ymbol = swap\nr.type = R_X86_64_PC32 r.addend = -4\n现在假设链接器 将 m . o 中 的 . t e x t 重 定位到 地 址 Ox 400 4d 0 , 将 s wa p 重定位到地 址\nOx 4 0 0 4e 8 。那么 c a l l q 指令中对 s wa p 的重定 位引用的值是什么?\n7. 8 可执行目标文件\n我们已经看到链接器如何将多个目标文件合并成一个可执行目标文件。我们的示例 C 程序 , 开始时是一组 ASCII 文本文件,现 在 已经被转化为一个二进制文件, 且这个二进制文件包 含加载程序到内存并运行它所需 的所有信息。图 7-13 概括了一个典 型的 ELF 可 执行文件中的各类信息。\n只读内存段(代码段 )\n}读/写内存段(数据段)\n描述目标文件的节{\n不加载到内存的符号表\n和调 试信 息\n图 7-13 典型的 ELF 可执行目标 文件\n\u0026lsquo;,,\n可执行目标文件的格式类似于可重定位目标文件的格式。ELF 头描述文件的总体格 式。它 还包 括程序的入口点( e n t r y point), 也就是当程序运行时要执行的第一条指令的地址。. t e x t 、.r o d a t a 和 . d a 七a 节 与可重定位目标文件中的节是相似的,除 了 这些节巳经被重定位到它们最终的运行时内存地址以外。. i n it 节定 义了一个小函数,叫 做 _ i n i 七,程 序 的初始 化代码会调用它。因为可执行文件是完全链接的(已被重定位),所以 它 不 再 需 要 . r e l 节。\nELF 可执行文件被设计得很容易加载到内存, 可执行文件的连续的片( c h u n k ) 被映射到连续的内存段。程序头部 表 ( p ro g ra m header table ) 描述了这种映射关系。图 7- 1 4 展示了可执行 文件 pr o g 的程序头部表, 是 由 O BJDU MP 显示的。\ncode/linklp rog-exe.d\nRead-only code s egme nt\n1 LOAD off OxOOOOOOOOOOOOOOOO vaddr Ox0000000000400000 paddr Ox0000000000400000 align 2**21\n2 filesz Ox000000000000069c memsz Ox000000000000069c flags r-x\nRea d/ wr i t e datas egme nt\n3 LOAD off Ox0000000000000df8 vaddr Ox0000000000600df8 paddr Ox0000000000600df8 align 2**21\n4 filesz Ox0000000000000228 memsz Ox0000000000000230 flags rw-\ncodellinklprog-exe.d\n图 7-14 示例可执行文件 p r og 的程序头部表\noff: 目标文件中的偏移; vaddr/paddr: 内存地址; al i gn : 对齐要求; filesz: mems z: 内存 中的 段 大小; flags : 运 行 时访 问权 限 。\n目标文件中的段大小;\n从程序头部表, 我们会看到根据可执行目标文件的内容初始化两个内存段。第 1 行和\n第 2 行告诉我们第一个段(代码段)有读/执行访问权限, 开始于内存地址 Ox 40 0000 处, 总共的内存大小是 Ox 69c 字节, 并且被初始化为 可执行目标 文件的头 Ox 69c 个字节 , 其中包括 E L F 头、程序头部表以及 . i n it 、. t e x t 和.r o da t a 节。\n第 3 行和第 4 行 告 诉我们第二个段(数据段)有读/写访问权限, 开始于内 存地址\nOx 60 0d f 8 处,总 的 内 存大小为 Ox 230 字节, 并用从目标文件中偏移 Ox d f 8 处开始的\n. d a t a 节中的 Ox 2 28 个字节初始化。该段中剩下的 8 个字节对应于运行时将被初始化为 0\n的 . b s s 数据。\n对于任何段 s , 链接器必须 选择一个起始地址 v a d dr , 使得\nvaddr mod align= off modalign\n这里, o ff 是目标 文件中段的 第一个节的偏移扯, a 止 g n 是程序头部中指定的对齐 c 221 =\nOx 2 0000 0) 。例如,图 7-1 4 中的数据段中\nvaddr mod align = Ox600df8 mod Ox200000 = Oxdf8\n以及\noff mod align= Oxdf8 mod Ox200000 = Oxdf8\n这个对齐要求是一种优化,使得当程序执行时,目标文件中的段能够很有效率地传送到内 存中。原因有点儿微妙,在千虚拟内存的组织方式,它被组织成一些很大的、连续的、大 小为 2 的幕的字节片。第 9 章中你会学习到虚拟内存的知识。\n7. 9 加载可执行目标文件\n要运行 可执行目标文件 pr o g , 我们可以在 Lin ux s hell 的命令行中输入它的名字:\nlinux\u0026gt; ./prog\n因为 pr og 不是一个内置的 s hell 命令, 所以 shell 会认为 pr og 是一个可执行目标文件,通 过洞用 某个驻留在存储器中称为加载器( loade r ) 的操作系统代码来运行它。任何L in ux 程序都 可以通过调用 e xe c ve 函数来调用加载器, 我们将在 8. 4. 6 节中详细描述这个函数。加载器将可执行目标文件中的代码和数据从磁盘复制到内存中 ,然后通过跳转到程序的第一条指令或入口点来运行该程序。这个将程序复制到内存并运行的过程叫做加栽。\n每个 L in ux 程序都有一个运行时内存映像, 类似千图 7-1 5 中所示。在 Lin ux x86-64 系统中, 代码段总是从地址 Ox 400000 处开始, 后面是数据段。运行时堆在数据段之后, 通过调用 ma l l o c 库往上增长。(我们将在 9. 9 节中详细描述 ma l l o c 和堆。)堆后面的区域是为共享模块保留的。 用户栈总是从最大的合法用户地址 ( 24 8- 1 ) 开始, 向较小内存地址增长。栈上的区域, 从地址 沪 开始, 是为内核 ( kern el ) 中的代码和数据保留的 , 所谓内核就是操作系统驻留在内存的部分。\n为了简洁,我们把堆、数据和代码段画得彼此相邻,并且把栈顶放在了最大的合法用 户地址处。实际上, 由千. d a t a 段有对齐要求(见 7. 8 节), 所以代码段和数据段之间是有间隙的。同时,在分配栈、共享库和堆段运行时地址的时候,链接器还会使用地址空间布 局随机化( AS L R , 参见 3. 10. 4 节)。虽然每次程序运行时 这些区域的地址都会改变, 它们的相对位置是不变的。\n当加载 器运行时, 它创建类似于图 7-1 5 所示的内存映像。在程序头部表的引导下, 加载器将可执行文件的片( ch un k ) 复制到代码段和数据段。接下来, 加载器跳转到程序的\n入口点 ,也 就 是 _s t ar t 函 数 的 地址。这个函数是在系统目标文件 c tr l . o 中定义的,对 所有的 C 程序都是一样的。_s t ar t 函数调用系统启动函数__让 b c _ s t a r t —ma i n , 该函数定义在 l i b c .s o 中。它初始化执行环境, 调 用 用 户 层 的 ma i n 函 数 ,处 理 ma i n 函 数 的 返 回值,并且在需要的时候把控制返回给内核。\n248- 1\n内核内存\n用户栈\n(运行时创建)\n了 飞/汇\u0026rsquo; • 心气勺_;\n共享库的内存映射区域\n见的内存\n千- %rsp ( 栈指针)\n护.、,又\n`,令— br k\n运行时堆\n(由ma l l o c 创建 )\nOx 400 0 00\n乏,\n图 7-15 Linux x86-64 运行时内存映像。没有展示出由于段对齐要求和地址空间布局随机化( ASLR) 造成的空隙。区域大小不成比例\n日 日-加载器实际是如何工作的? # 我们对于加 栽的描述从概念上来说 是 正确的,但 也 不是 完全准确, 这是有意为 之 。要理解加栽实际是如何工作的,你必须理解进程、虚拟内存和内存映射的概念,这些我 们还 没有加以讨论。在后面 笫 8 章 和 笫 9 章 中遇到这些概念时, 我们将重新回到加栽的问题上, 并逐渐向 你揭开它的 神秘面纱 。\n对于不够有耐心的读者,下面是关于加栽实际是如何工作的一个概述: Lin ux 系统中的 每个程序都运行在一个进程上下文中, 有自 己的 虚拟地址空间。 当 s hell 运行一个程序时, 父 s hell 进程生成 一个子进程, 它是 父进 程的一个复 制。子进程通过 e xe cv e 系统调 用启动加栽器。加 栽器删 除子进 程现有的虚拟内存段, 并创 建 一组新的代码、数据、堆 和栈段。新的栈和堆段被初始化为零。通过将虚拟地址空间中的 页映 射 到 可执行文件的 页大小的 片 ( c h unk ) , 新的代码和数据段被初始化为 可执行 文件的 内容。 最后 , 加栽器跳 转到_s t ar t 地址, 它最终会调用应 用程 序的 ma i n 函数。除了一 些头部 信息,在加栽过 程中没有 任何从磁盘到内存 的数据复制。直到 CPU 引 用一 个被 映射的虚拟页时 才会进行复制, 此时,操 作 系统 利用它的 页面 调度机制自动将 页面从磁 盘传送到内存。\n10 动态链接共享库\n我们在 7. 6. 2 节中研究 的静 态库解决了许多关千如何让大量相关函数对应用程序可用的问题。然而,静 态库仍然有一些明显的缺点。静态库和所有的软件一样,需 要 定 期 维 护和更新 。如果应用程序员想要使用一个库的最新版本,他 们 必 须 以 某种方式了解到该库的\n更新情况, 然后显 式地将他们的程序与更 新了的库重新链 接。\n另一个问题是几乎每个 C 程序都使用标准 I/ 0 函数, 比如 pr i n t f 和 s c a n f 。在运行时,这些函数的代码会被复制到每个运行进程的文本段中。在一个运行上百个进程的典型 系统上, 这将是对稀缺的内 存系统资源的 极大浪费。(内存的一个有趣属性就是不论 系统的内存 有多大,它 总 是一种稀缺资源。磁 盘空间和厨房的垃圾桶同样有这种 属性。)\n共享库 ( s ha red lib ra r y) 是致力于解决静态库缺陷的一个现代创新产物。共享库是一个目标模块, 在运行或加 载时, 可以加载到任意的 内存地址 ,并和一个在内存中的程序链接起来。这个过程称为动 态链接( dynamic linking) , 是由一个叫做动 态链 接器 ( dyn amic linker) 的程序来执行的。共享库也称为共享目标 ( s ha red object), 在 Linu x 系统中通常用 . s o后缀来表示。微软的操作系统大量地使用了 共享库, 它们称为 DLLC动态链接库)。\n共享库是以两种不同的方式来“共 main2. c vec 七ro . h\n享" 的。首先, 在任何给定的文件系统中, 对于一 个库只有一个 . s o 文件。所有引用该库的可执行目标 文件共享这个.\nl i bc . so\nl i bvect or . so\ns o 文件中的代码和数 据, 而不是像静态库的内容那样被复制和嵌入到引用它们的可执行的文件中。其次,在内存中, 一个共享库的 . t e x t 节的一个副本可以被不同的正在运行的进程共享。在第 9 章我们学习虚拟内存时将更加详细地讨论这个问题。\n可重定 位目标文件 ma i n2 . o\n! # 链接器 ( l d )\n部分链接的可 rp og21\n执行目标文件\nL\n加载器\n(e x ecve ) I l i bc . so\n动态链接过程。为了构造图 7-6 中示例向蜇例程的共享库 l i bve c t or . s o , 我\n内存中完全链 接\n代码和数据\n们调用编译器驱动程序,给编译器和链接器如下特殊指令:\n的可执行文件 1 动态链接骈 ( l d- li nux. so ) I\n图 7-16 动态链接共享库\nlinux\u0026gt; gee -shared -fpie -o libveetor. so addvee.c multvec. c\n- f pi c 选项指示 编译 器生成与位置无 关的代码(下一节将详细讨论 这个问题)。\n- s ha r e d 选项指示链接器创建一个 共享的目标文件。一旦创建了这个库,随 后就要将它链接到图 7-7 的示例程序中 :\nlinux\u0026gt; gee -o prog21 main2.e ./libveetor.so\n这样就创建了一个可执行目标文件 pr og 21, 而此文件的形式使得它在运行时可以和l i b v e c t or . s o 链接。基本的思路是当创建可执行文件时, 静态执行一些链接, 然后在程序加载时, 动态完成链接过程。认识到这一点 是很重要的: 此时, 没有任何 l i b ve c t o r . so 的代码和数据节真的被复制到可执行 文件 pr o g 21 中。反之, 链接器复制了一些重定位和符号表信息 ,它 们使得运行 时可以解 析对 l i b ve c t or . s o 中代码和数据的引用。\n当加载器加载和运行可执行 文件 pr og21 时,它 利用 7. 9 节中讨论过的技术 , 加载部分链接的可执行文件 p ro g 21。接着,它 注意到 pro g21 包含一个. i nt er p 节, 这一节包含动态链接器的路径名, 动态链接器本身就是一个共享目标(如在 Linux 系统上的 l d - linux.so)。加载器不会像它通常所做地那样将控制传递给应用,而是加载和运行这个动态链接器。然\n后,动态链接器通过执行下面的重定位完成链接任务:\n重定位 l i b c . s o 的文本和数据到某个内存段。 重定位 l i b v e c 七or . s o 的文本和数据到另一个内存段。\n重定位 pr o g 21 中所有对由 l i b c . s o 和 l i b v e c t or . s o 定义的符号的引用。\n最后,动态链接器将控制传递给应用程序。从这个时刻开始,共享库的位置就固定 了,并且在程序执行的过程中都不会改变。\n11 从应用程序中加载和链接共享库 # 到目前为止,我们巳经讨论了在应用程序被加载后执行前时,动态链接器加载和链接共享库的情景。然而,应用程序还可能在它运行时要求动态链接器加载和链接某个共享 库,而无需在编译时将那些库链接到应用中。\n动态链接是一项强大有用的技术。下面是一些现实世界中的例子:\n分发软件 。微软 W in do w s 应用的开发者常常利用共享库来分发软件更新。他们生成一个共享库的新版本,然后用户可以下载,并用它替代当前的版本。下一次他们运行应用程序时,应用将自动链接和加载新的共享库。 构建 高性能 W e b 服务器。 许多 W e b 服务器生成动 态内 容, 比如个性化的 W e b 页面、账户余额和广告标 语。早期的 W eb 服务器通过 使用 f or k 和 e x e c v e 创建一个子进程 , 并在该子进程的上下 文中运行 CGI 程序来生成 动态内容。然而, 现代高性能的 W e b 服务器可以使用基于动态链接的更 有效和完善的方法来生成动态内容。\n其思路是将 每个生成动态内容的函数打包在共享库 中。当一个来自 W e b 浏览器的请求到达时 , 服务器动态地加 载和链接适当的函数, 然后直接调用它, 而不是使用 f or k 和e xe c v e 在子进程的上下 文中运行 函数。函数会一直缓存在服务器的地址 空间 中, 所以只要一个简单 的函数调用的开销就可以 处理随后的请求了。这对一个繁忙的网站来说是有很大影响的。更进一步地说,在运行时无需停止服务器,就可以更新已存在的函数,以及添 加新的函数。\nL in u x 系统为动态链接器提供 了一个简单的接口,允 许应用程序在运行时加载和链接共享库。\n#include \u0026lt;dlfcn.h\u0026gt;\nvoid *dlopen(const char *filename, int flag);\n返回: 若 成 功 则 为指 向 句 柄 的 指 针 , 若 出错 则 为 NULL。\nd l op e n 函数加载和链接共享库 f i l e na me 。 用 已用带 RTL D_ GLOBAL 选项打开了的库解析 f i l e n a me 中的外部符号。如果当前可执行 文件是带-r d yn a mi c 选项编译的, 那么对符号解析 而言,它的 全局符号也是可用的 。fl a g 参数必须要么包括 RTL D_ NOW, 该标志告诉链接器立即解 析对外部符号的引用, 要么包括 RTLD_ LAZY 标志,该 标志指示链接器推迟符号解 析直到执行来自库中 的代码。这两个值中的任意一个都可以 和 RTL D_ GLOBAL 标志取或 。\n#include \u0026lt;dlfcn.h\u0026gt;\nvoid *dlsym(void *handle, char *symbol);\n返回: 若 成 功 则 为指 向 符 号 的 指 针 , 若 出错 则 为 NULL 。\ndl s yrn 函数 的输 入是一个指向前 面巳 经打开了的共享库的句柄和一个 s ym bol 名字, 如 果 该 符 号 存 在 ,就 返回符号的地址,否 则 返回 NU LL 。\n#include \u0026lt;dlfcn.h\u0026gt;\nint dlclose (void *handle);\n返回: 若 成 功 则 为 o, 若 出错 则为 一1。\n如果没有其他共享库还在使用这个共享库, d l c l o s e 函 数 就 卸 载该共享库。\n#include \u0026lt;dlfcn.h\u0026gt;\nconst char *dlerror(void);\n返回: 如 果 前 面对 dl open、 dl s ym 或 dl cl so e 的 调 用失败 , 则 为铸 误 消息 ,如 果 前 面的调 用 成 功 , 则 为 NUL,L\nd l er r or 函 数 返回一个字符串,它 描 述 的 是 调 用 d l o p e n 、 d l s ym 或者 d l c l o s e 函数\n时 发 生的最近的错误,如 果没有错误发生,就 返回 NU LL 。\n图 7-17 展示了如何利用这个接口动态链接我们的 l i b v e c 七or . s o 共 享 库 , 然后调用它的 a d d v e c 例程。要 编译 这个程序, 我们将以下面的方式调用 GCC:\nlinux\u0026gt; gee -rdynamic -o prog2r dll.e -ldl\ncode/link/dll.c\n#include \u0026lt;stdio.h\u0026gt;\n#include \u0026lt;stdlib.h\u0026gt;\n#include \u0026lt;dlfcn.h\u0026gt;\n4\n5 int x[2] = {1, 2};\n6 int y[2] = {3, 4};\n7 int z[2];\n8\n9 int main()\n10 {\nvoid *handle;\nvoid (*addvec)(int *, int*, int*, int);\nchar *error;\n14\nI* Dynamically load the shared library containing addvec() *I\nhandle= dlopen(\u0026rdquo;. / l i bvect or.so\u0026rdquo;, RTLD_LAZY);\nif (!handle) {\nfprintf(stderr, \u0026ldquo;%s\\n\u0026rdquo;, dlerrorO);\nexit (1);\n20 }\n21\n22 I* Get a pointer to the addvec() function we just loaded *I\n23 addvec = dlsym(handle, \u0026ldquo;addvec\u0026rdquo;);\nif ((error = dlerror O) != NULL) {\nfprintf (stderr, \u0026ldquo;%s\\n\u0026rdquo;, error);\n图7-17 示例 程序 3。在运行时 动态加载 和链接共享库 l i bvec t or . so\n佐(\n笫 7 章 链 接 489\nexit (1);\n27 }\n28\nI* Now we can call addvec() just like anyother function *I\naddvec(x, y, z, 2);\n31 printf(\u0026ldquo;z = [%d 儿 d] \\ n \u0026quot; , z[O], z[l]);\n32\nI*Unload the shared library *I\nif (dlclose (handle) \u0026lt; 0) {\nfprintf (stderr, \u0026ldquo;%s\\n\u0026rdquo; , dlerror ()) ;\nexit (1);\n37 }\n38 return O;\n39 }\ncode/link/dll.c\nm一共 享库和 J a va 本地接口\n图 7-17 (续)\nJava 定义了一 个标准调 用规 则,叫做 J ava 本地接口(J ava Native Interface, JNI), 它允许 Java 程序调 用“本 地的\u0026rdquo; C 和 C+ + 函数。J NI 的基本思想是将本地 C 函数(如fo o ) 编译到一 个共 享库 中(如f oe . s o ) 。当一个正在运行的 Java 程序试图调用函数 f oo 时, J ava 解释器利 用 d l op e n 接口(或者与其类似的 接口)动态链接 和加 栽 f o o . s o , 然后 再调用 f oo 。\n12 位置无关代码\n共享库的一个主要目的就是允许多个正在运行的进程共享内存中相同的库代码,因 而节约宝贵的内存资源。那么,多 个 进程是如何共享程序的一个副本的呢? 一种方法是给每个共享库分配一个事先预备的专用的地址空间片,然后要求加载器总是在这个地址加载共 享库。虽然这种方法很简单,但 是 它 也 造成了一些严重的问题。它对地址空间的使用效率不高,因 为即使一个进程不使用这个库,那 部 分 空 间 还是会被分配出来。它也难以管理。我们必须保证没有片会重叠。每次当一个库修改了之后,我们必须确认已分配给它的片还 适合它的大小。如果不适合了,必 须 找一个新的片。并且, 如果创建了一个新的库, 我们还必须为它寻找空间。随着时间的进展,假设在一个系统中有了成百个库和库的各个版本 库,就很 难避免地址空间分裂成大量小的、未使用而又不再能使用的小洞。更糟的是,对每个系统而言,库 在内 存 中的 分 配 都是 不同的, 这就引起了更多令人头痛的管理问题。\n要避免这些问题,现代系统以这样一种方式编译共享模块的代码段,使得可以把它们加载到内存的任何位置而无需链接器修改。使用这种方法,无限多个进程可以共享一个共享模块的代码段的单一副本。(当然, 每个进程仍然会有它自己的读/写 数据块。)\n可以加载而无需重定位的代码称为位置无关代码( P os it io n- I n d e pe nd e n t Code, PIC) 。用户对 GCC 使用- f p i c 选项指示 G N U 编译系统生成 P IC 代码。共享库的编译必须总是使用该选项。\n在一个 x86-64系统中,对 同 一 个 目 标 模 块 中 符 号 的 引 用 是 不 需 要 特 殊 处 理 使 之成为\nPIC。可以用 PC 相对寻址来编译这些引用,构 造目标文件时由静态链接器重定位。然而,对共享模块定义的外部过程和对全局变量的引用需要一些特殊的技巧, 接下来我们会谈到。\nPIC 数据引用\n编译器通过运用以下这个有趣的事实来生成对全局变量的 PIC 引用: 无论我们在内存 中的何处加载一个目标模块(包括共享目标模块),数 据段与代码段的距离总是保持不变。因此, 代码段中任何 指令和数据段 中任何变量之间的距 离都是一个运行时常撮, 与代码段和数据段的绝对内存位置是无关的。\n想要生成对全 局变量 PIC 引用的 编译器利用了这个事实,它 在数据段开始的地方创建了一个表, 叫做全局偏移量表 ( G lo b a l Offset Table, GOT ) 。在 G O T 中,每 个被这个目标模块引用的全局数据目标(过程或全局变量)都有一个 8 字节条目。编译器还为 G O T 中每个条目生成一个 重定位记录。在加 载时, 动态链接器会蜇定位 G O T 中的每个条目,使得它包含目标的正确的绝对地址。每个引用 全局目标的目标模块都有自己的 G O T 。\n图 7-18 展示了示例 l i b v e c t or . s o 共享模块的 G O T 。a d d v e c 例程通过 G O T [ 3] 间接\n地加载全局变量 a d d c 泣 的地址,然 后把 a d d c n t 在内 存中加 1。这里的关键思想是对\nG O T [ 3 ] 的 PC 相对引用中的偏移批是一 个运行时常量。\n数据段\n全局偏移量表 (GOT) GOT [ O ): \u0026hellip;\nGOT[l) \u0026hellip; .\nGOT [ 2 ) : \u0026hellip;\nGOT [ 3 J: \u0026amp;add,cnt\n运行时GOT [3 ] 和\na dd l 指令之间的固定距离是\nOx 2008b9 add vec :\n、 认\nmov Ox 2008b 9 { 毛r i p ) , 毛 r a x # 毛r a x =*GOT [ 3 ] =\u0026amp;ad dc n t\naddl $0xl, { %rax) # addcnt++\n图 7-18 用 GOT 引 用 全局 变 量 。 l i bve c 七or . s o 中的 addve c 例程通过 l i bve c 七or . s o 的\nGOT 间 接引用 了 a ddc n七\n因为 a d d c n 七是由 l i b v e c 七o r . s o 模块定义的, 编译器可以利用代码段和数据段之间不变的距离, 产生对 a d d c n t 的直接 PC 相对引用,并 增加一个重定位, 让链接器在构造这个共享模块时解析它。不 过, 如果 a d d c n 七是由另一个共享模块定义的, 那么就需要通过 G O T 进行间接访问。在这里, 编译器选择采用最通用的解决方案, 为所有的引用使用 G O T 。\n. P IC 函数调用\n假设程序调用一个由共享库定义的函数。编译器没有办法预测这个函数的运行时地址, 因为定义它的共享模块在运行时可以加载到任意位置。正常的方法是为该引用生成一条重定 位记录, 然后动态链接器在程序加载的时候再 解析它。不过, 这种方法并不是 PIC, 因为它需 要链接器修改调用模块的代码段 , GNU 编译系统使用了一种很有趣的技术来 解决这个间题 , 称为延迟绑定 Clazy binding), 将过程地址的绑定推迟到第 一次调用该过程时。\n使用延迟绑定的动机是对于一个像l i b c . s o 这样的共享库输出的成百上 千个函数中, 一个典型的应用程序只会使用其中很少的一部分。把函数地址的解析推迟到它实际被调用的地 方,能避免动态链接器在加载时进行成百上千个其实并不需要的重定位。第一次调用过程的运行时开销很大 , 但是其后的每次调用都只会花费 一条指令和一个间接的内存引用。\n延迟绑定是通过两个数据结构之间简洁但又有些 复杂的交互来实现的, 这两个数据结\n@\n笫 7 章 链 接 491\n构是: G O T 和过程链接表( P r o ce d u r e L in k a g e T a b le , PL T ) 。如果一个目标模 块调用定义在共享库中的 任何 函数, 那么它就有自己 的 G O T 和 P L T 。G O T 是数据段的一部分, 而P L T 是代码段的一部分。\n图 7-1 9 展示的是 P L T 和 G O T 如何协作在运行时解析函数的地址。首先,让 我们检查一下这两个表的内容。\n过程链接表 ( P L T ) 。P L T 是一个数组, 其中每个条目是 1 6 字节代码。P LT [ O] 是一个特殊条目, 它跳转到动态链接器中。每个被可执行程序调用的库函数都有它自己的 P L T 条目。每个条目 都负责询用一个具体的函数。 PLT [ l ] ( 图中未显示)调用系统启动函数(__ 让 b c _ s 七a 江 _ m a in ) , 它初始化执行环境, 调用 ma i n 函数并处理其\n返回值。从 P LT [ 2 ] 开始 的条目调用用户代码调用的函数。在我们的例子中, P LT [ 2 ] 调用 a d d v e c , PLT [ 3 ] ( 图中未显示e)调用pr i n t f 。\n全局偏移量表 ( G O T ) 。正如我们看到的, G O T 是一个数组, 其中每个条目是 8 字节地址。和 P L T 联合使用时 , GOT [ OJ 和 GOT [1 ] 包含动态链接器 在斛析函数地址时会使用的信息。GOT [ 2 ] 是动态链接器在 l d - l i n u x . s o 模块中的入口点。其余的每个条目 对应千一个被调用的函数, 其地址需要在运行时被解析。每个条目都有一个相匹配的 P L T 条目。例如, GOT [ 4 ] 和 P L T [ 2 ] 对应于 a d d v e c 。初始时, 每个 G O T 条目都指向对应 P L T 条目的第二条指令。\n数据段\n全局偏移量表 (GOT)\nGOT [ O] : addr of .yd na mi c GOT[l]: addr of reloc entries GOT [ 2 ] : addr of dynamic linker GOT [ 3 ] : Ox 40 05 b 6 # sys startup\nGOT[4]: Ox 40 0 5c 6 # addvec() GOT[S]: Ox 400 Sd 6 # printf()\nI\n(Z) C\na ) 第一 次调用 a dd v e c b ) 后续再调用a ddv e c\n图 7-19 用 P LT 和 GO T 调用外部 函数。在第一次调用 a ddve c 时 , 动 态 链 接 器 解 析 它 的 地 址\n图 7 - 1 9 a 展示了 G O T 和 P L T 如何协同工作, 在 a d d v e c 被第一次调用时, 延迟解析它的运行时地址:\n第 1 步。不直 接调用 a d d v e c , 程序调用进入 P LT [2], 这是 a d d v e c 的 P L T 条目。 笫 2 步。第一 条 P L T 指令通过 GOT [ 4 ] 进行间接跳转。因为每个 G O T 条目初始时都指向它对应的 P L T 条目的第二条指令, 这个间接跳转只 是简单地把控制传送回PLT [ 2 ] 中的下一条指令。 . 第 3 步。 在把 a dd v e c 的 ID ( Ox l ) 压入栈中之后, P LT [ 2 ] 跳转到 PLT [ OJ 。\n第 4 步。 PLT [ O] 通过 GOT [ l ] 间接地把动态链接器的一个参数压入栈中, 然后通过GOT [2 ] 间接跳转进动态链接器中。动态链 接器使用两个栈条目来确定 a d d v e c 的运行时位置,用 这个地址重写 GOT [ 4 ] , 再把控制传递给 a d d v e c 。\n图 7- 1 9b 给出的是后续再调用 a d d v e c 时的控制流 :\n. 第 1 步。 和前面一样, 控制传递到 PLT [ 2 ] 。\n第 2 步。不过 这次通过 GOT [ 4 ] 的间接跳转 会将控制直接转移到 a d d v e c 。 7. 13 库打桩机制\nLin u x 链接器支持一个很强大的技术, 称为库打桩(l ib ra r y interpositioning), 它允许你截获对共享库函数的调用,取而代之执行自己的代码。使用打桩机制,你可以追踪对某 个特殊库函数的调用次数,验证和追踪它的输入和输出值,或者甚至把它替换成一个完全不同的实现。\n下面是它的基本思想:给定一个需要打桩的目标函数,创建一个包装函数,它的原型\n与目标函数完全一样。使用某种特殊的打桩机制,你就可以欺骗系统调用包装函数而不是 目标函数了。包装函数通常会执行它自己的逻辑,然后调用目标函数,再将目标函数的返 回值传递给调用者。\n打桩可以发生在编译时、链接时或当程序被加载和执行的运行时。要研究这些不同的 机制,我 们以图 7- 20a 中的示例程序作为运行 例子。它调用 C 标准库 ( li b c . s o ) 中的 ma l ­ l a c 和 fr e e 函数。对 ma l l o c 的调用从堆中分配一个 32 字节的块, 并返回指向该块的指针。对 fr e e 的调用把块还回到 堆, 供后续的 ma l l o c 调用使用。我们的目标是用打桩来追踪程序运 行时对 ma l l o c 和 f r e e 的调用。\n7. 13. 1 编译时打桩\n图7-20 展示了如何使用 C 预处理器在编译时打桩。myma l l o c . c 中的包装函数(图7-20c) 调用目标函数 , 打印追踪记录, 并返回。本地的ma l l o c . h 头文件(图7- 20 b) 指示预处理器用对相应包装函数的调用替换掉对目标函数的调用。像下面这样编译和链接这个程序:\nlinux\u0026gt; gee -DCOMPILETIME -e mymalloe. e linux\u0026gt; gee -I. -o inte int.e mymalloe.o\n由于有- I. 参数, 所以会进行打桩,它 告 诉 C 预处理器在搜索通常的系统 目录之前, 先 在当前目录中查 找 ma l l o c . h 。注意 , myma l l o c . c 中的包装函数是使用标准 ma ll oc . h 头文件编译的。\n运行这个程序会得到如下的追踪信息:\nlinux\u0026gt; ./intc malloc(32)=0x9ee010 free(Ox9ee010)\n7. 13 . 2 链接时打桩\nLin ux 静态链接器支持用 - -wrap f 标志进行链接时打桩。这个标志告诉链接器, 把对符号 f 的引用解析成_ _wr a p_ f ( 前缀是两个下划线),还 要把对符号——r e a l _ f ( 前缀是两个下划线)的引用解析为 f 。图 7- 21 给出我们示 例程序的包装函数。\n#include \u0026lt;stdio.h\u0026gt;\n#include \u0026lt;malloc.h\u0026gt;\nint main()\n{\ncode/link/interpose/int.c\nint *P = malloc(32); free(p);\nreturn(O);\n}\n示例程序 i nt . c codellinklinterposelint.c cod e/lin k/interp osel m a l/oc .h\n#define malloc(size) mymalloc(size)\n#define free(ptr) myfree(ptr)\nvoid *mymalloc(size_t size); void myfree(void *ptr);\ncode/linklinterpose/malloch.\n#if def COMPILETIME\n#include \u0026lt;s t di o . h\u0026gt;\n#include \u0026lt;mal l oc . h \u0026gt;\n本地 mall oc . h 文件\ncode/link/interpose/mymalloc.c\nI* malloc wrapper function *I voi d *mymalloc(size_t si ze)\n{\nvoid *ptr = mal l oc (s i z e ) ;\nrp i nt f ( \u0026ldquo;m all oc (%d ) =%p\\ n\u0026rdquo; , (int)size, ptr);\nreturn ptr;\n}\nI* free 口rapperfunct ion *I void myfree(void *ptr)\n{\nfree(ptr);\nprintf (\u0026ldquo;free(%p) \\ n\u0026rdquo; , ptr);\n}\n#enidf\ncode/ lin k/int erp ose/mymalloc.c\nmyma l l oc . c 中的 包 装函数 图7- 20\n用 C 预处理器进行编译时打桩\n用下述方法把这些源文件 编译成可重定位目标文件:\nl i n ux \u0026gt; gee - DLI NKTIME -e myma l l oe . e l i nu x \u0026gt; gee -e int.e\n然后把目标文件链接成可执行文件:\nlinux\u0026gt; gee - Wl , - - wr pa , ma l l o e - Wl , - - wr ap , fr e e -o int;l i nt;.o myma l l oe . o\n- Wl , o p t i o n 标志把 o p t i o n 传递给链接器。o p 巨 o n 中的 每个逗号都要替 换为一个空\n纵ii\n494 笫二部分 在系统上运行程序\n格。所以- Wl , - -wrap, ma l l o c 就 把 - -wrap ma l l o c 传 递给链接器,以 类 似的方式传递\n-Wl, - -wrap, fr ee。\ncode/linklinterpose/mymalloc.c\n#ifdef LINKTIME\n2 #include \u0026lt;stdio.h\u0026gt;\n3\n4 void * real_malloc(size_t size);\n5 void real_free(void *ptr);\n6\n7 I* malloc -wrapper function *I\n8 void * -wrap_malloc(size_t size)\n9 {\n10 void *ptr = real_malloc(size); I* Call libc malloc *I\n11 printf(\u0026ldquo;malloc(%d) = %p\\n\u0026rdquo;, (int)size, ptr);\n12 return ptr;\n13 }\n14\nI* free -wrapper function *I\nvoid -wrap_free(void *ptr)\n17 {\nreal_free(ptr); I* Call libc free *I\nprintf(\u0026ldquo;free(%p)\\n\u0026rdquo;, ptr);\n20 }\n21 #endif\ncode/linklinterposelmymalloc.c\n图 7-21 用 一 wr ap 标志进行链 接时打桩\n运行该程序会得到如下追踪信息:\nlinux\u0026gt; ./intl malloc(32) = Ox18cf010 free(Ox18cf010)\n7. 13 . 3 运行时打桩\n编译时打桩需要能够访问程序的源代码,链接时打桩需要能够访问程序的可重定位对 象文件。不过, 有一种机制能够在运行时打桩.它只需要能够访间可执行目标文件。这个很 厉 害 的 机 制基于动态链接器的 LD_ P RE LOAD 环境变量。\n如果 LD—PRE LO AD 环境变量被设置为一个共享库路径名的列表(以空格或分号分隔),\n那 么 当 你 加 载和执行一个程序,需 要 解析未定义的引用时, 动 态 链 接器 ( L D- 荨 NUX. SO) 会先 搜 索 L D—P RE LOAD 库,然 后 才 搜索任何其他的库。有了这个机制, 当你加载和执行任意可 执 行 文 件 时 ,可 以 对任何共享库中的任何函数打桩,包 括 让b e . s o。\n图 7- 22 展示了 ma l l o c 和 fr e e 的包装函数。每个包装函数中,对 d l s ym 的 调用返回指向目标 l i b c 函数的 指针 。然后包装函数调用目标函数, 打印 追踪记录,再 返回。\n下面是如何构建包含这些包装函数的共享库的方法:\nlinux\u0026gt; gee -DRUNTIME -shared -tpie -o mymall oe. so mymall oe. e - ldl\n这是如何编译主程序:\nlinux\u0026gt; gee -o intr int.e\ncodellinklinterpose/mymalloc.c\noc.c\n图 7- 22 用 LD_ PRELOAD 进行运行时打桩\n下面是如何从 b a s h s h e ll 中运行这个程序 气\nlinux\u0026gt; LD_PRELOAD=\u0026rdquo; ./mymalloc. so\u0026quot; ./intr malloc(32) = Ox1bf7010\nfree(Ox1bf7010)\ne 如果你不知道运行的 shell 是哪一种,在命 令行上输人 pr i nt en v SHE LL.\n下面是如何 在 c s h 或 t c s h 中运行这个程序:\nlinux\u0026gt; (setenv LD_PRELOAD 11 ./mymalloc. so\u0026quot;; ./ i ntr ; unsetenv LD_PRELOAD) malloc(32) = Ox2157010\nfree(Ox2157010)\n请注意 , 你可以用 LD_ PRELOAD 对任何可执行 程序的库函数调用打桩!\nlinux\u0026gt; LD_PRELOAD=\u0026quot; ./mymalloc. so\u0026quot; /usr/bin/uptime malloc(568) = Ox21bb010\nfree(Ox21bb010) malloc(15) = Ox21bb010 malloc(568) = Ox21bb030 malloc(2255) = Ox21bb270 free(Ox21bb030) malloc(20) = Ox21bb030 malloc(20) = Ox21bb050 malloc(20) = Ox21bb070 malloc(20) = Ox 21 bb090\nma ll oc( 20 ) = Ox 21bb0b0 malloc(384) = Ox2 1 bb0d0\n20:47:36 up 85 days , 6:04, 1 user, load average: 0.10, 0.04, 0.05\n14 处理目标文件的工具\n在 L in u x 系统中有大量可用 的工具 可以帮助你理解和处理目标文件。特别地, GNU\nb in ut ils 包尤其有帮助, 而且可以 运行在每个 L in ux 平台上。\nA R : 创建静态库 , 插入、删除、列出和提取成员。\nST RINGS : 列出一个目标文件中所 有可打印 的字符串。\nST RIP : 从目标文件中 删除符号表信息。\nNM : 列出一个目标文件的符号表中定义的符号。\nSIZE : 列出目标文件中节的名字和大小。\nR E A D E L F : 显示一个目标文件的完整结构, 包括 E L F 头中编码的所有信息。包含\nS I ZE 和 NM 的功能。\nOBJDUMP: 所有二进制工具之母。能够显示一个目标文件中所有的信息。它最大的 作用是反汇编 . t e x t 节中的二进制指令 。\nLin u x 系统为操作 共享库还提供了 L DD 程序 :\nLDD: 列出一个可执行文件在运 行时所需要的共享库。\n7. 15 小结\n链接可以在编译时由 静态编译 器来 完成 ,也可以在加载时 和运行时由动态链 接器 来完成。链接器处理称为目标 文件的 二进制文件 ,它有 3 种不同的形式: 可重定 位的、可执 行的和共享的。可重定位的目标文件由静态链接器合并 成一个可执行的目标文件, 它可以 加载到内存中并执行。共 享目标 文件(共享库)是在运行时由动态链接器链接 和加载的 , 或者隐含地在凋用程序被加 载和开始执行时 , 或者根据需要在程序调用 dl ope n 库的函数时。\n链接器的 两个主要任务是符号解析 和重定位 , 符号解析将目标 文件中的 每个全局符号都绑定到一个唯一的定义, 而重定位确定 每个符号的最终内存地址,并修改对那些目标的引 用。\n静态链接器是由像 GCC 这样的 编译驱动程序调用的 。它们将多个可重定位目标 文件合并成一个单独的可执行目标文件。多个目标文件可以定 义相同的符号, 而链接器用来 悄悄地解析这些 多重定义的规则可能在用户程 序中引人微妙的错误 。\n多个目标文件可以 被连接到一个单独的静态库中。链接器用库来解析其他目标模块中的符号引用。许多链接器通 过从左到右的顺序扫描来解析符号引用, 这是另一个引起令人迷惑的链接时错误的来源。\n加载器将可执行文件的内 容映射到内存,并 运行这个程序。链接器还可能生成部分链接的可执行目标文件 , 这样的文件中有对定 义在共享库 中的例程 和数据的 未解析的引用。在加载时,加 载器将部分链接的可执行文件映 射到内存 , 然后调用动态链接器 , 它通过 加载共享库和重定位程序中的引用来完成链接任务 。\n被编译 为位 置无关代码的共享库可以加载到任何地方 , 也可以在运行时被多个进程共享。为了加载、链接和访问 共享库的函数和数据 , 应用程序也可以在运行时 使用动态链接器。\n参考文献说明 # 在计算机系统 文献中并 没有很好地 记录链接。因为链 接是处在编译器、计算机体系结构和操作系统的交叉点上, 它要求理解代码生成、机器语 言编程、程序实例化和虚拟内存。它没有恰好落在某个通常 的计算机系统领域中 , 因此这些领域的经典文献并 没有很 好地描述它。然而,Le vin e 的专著提供了有关 这个主题的很好的一般性参考资 料[ 69] 。[ 54] 描述了 ELF 和 DW AR F ( 对. de b ug 和 . l i ne 节内容的规范) 的原始 IA32 规范。[ 36] 描述了对 E LF 文件格式的 x86-64 扩展。x8 6-64 应用二进制接口( ABD 描述了编译、链接和运行 x86-64 程序的惯 例, 其中包括重定位和位置无关代码的规则[ 77] 。\n家庭作业\n7. 6 这道题是关于图 7-5 的 rn. o 模块和下面的 s wa p . c 函数版本的 , 该函数计算 自已被调用的次数 :\nextern int buf[];\nint *bufpO = \u0026amp;buf[O]; static int *bufp1;\nstatic void incrO\n{\nstatic int count=O; count++;\nvoid swap()\n{\nint temp;\nincr();\nbufp1 = \u0026amp;buf[1]; temp= *bufpO;\n*bufpO = *bufp1;\n*bufp1 = temp;\n对于每个 s wa p . o 中定 义和引用的 符号,请指出 它是否在模块 s wa p . o 的 . s ymt a b 节中有符 号表条目。如果是这样 ,请 指出定义该符号的模块( s wa p . o 或 m. o ) 、符号类型(局部、全局或外部)以及它在模块中所处的节( . t e x t 、. da t a 或 . b s s ) 。\n7. 7\n7. 8\n不改变任何 变量名字 , 修改 7. 6. 1 节中的 b ar S . c , 使得 f o o S . c 输出 x 和 y 的正确值(也就是整数\n15 213 和 1 521 2 的十六进制表示)。\n在此题中, REF (x , i ) \u0026mdash;+ DEF (x , k ) 表示链 接器将任意对模 块 1 中符号 x 的引用与模块 k 中符号 x 的定义相关联。在下面每个例子中,用这种符号来说明链接器是如何解析在每个模块中有多重定义的 引用的。如果出现链接时 错误(规则1 ) • 写“错误"。如果 链接器从定义中任意选择一个(规则3)\u0026rsquo; 那么写“未知”。\nI• Module 1•/\nint main()\nI* Module 2 *I\nstatic int main=l [ int p20\n{\n}\nREF(main.1) - DEF(.) ( REF(main.2) -+ DEF( . )\nI* Module 1 *I I* Module 2 *I\nint x; double x;\nvoid main() int p20\n{ {\n} }\n(a) REF(x.1)-+ DEF(_ .)\n(b) REF(x.2)- DEF(._ — ._ _ )\nI* Module 1 *I I* Module 2 *I\nint x=l; double x=l.0;\nvoid main() int p2()\n{ {\n} }\n(a) REF(x.1) - DEF( - ·- )\nREF(x.2) - DEF_ (_\n— — - ·\u0026mdash; ·-—一 .)\n7. 9\n考虑下面的程序,它由两个目标模块组成:\nI* foo6.c *I\nvoid p2(void);\nint main()\n{\np20;\nreturn O;\n}\nI* bar 6 . c *I\n#include \u0026lt;stdio.h\u0026gt; char main;\nvoid p20\n{\nprintf(\u0026ldquo;Ox%x\\n\u0026rdquo;, main);\n}\n•• 7. 10\n当在 x8 6- 64 L in ux 系统中编译和执行这个 程序时 ,即 使函数 p 2 不初始化变 量 ma i n , 它也能打印字符串 \u0026quot; Ox 48 \\ n\u0026quot; 并正常终止。你能 解释这一点 吗?\na 和 b 表示当前 路径中的目标模 块或静 态库 , 而 a -+-b 表示 a 依赖于 b , 也就是说 a 引用了一个 b\n定义的符办 。对千下 面的每个场 景, 给出使得静态链 接器能够解析所有符号引用的最小的命令行\n(即含有最少数量的目标文件和库参数的命令)。\np.o-libx.a-p.o\np. o-+ libx.a- 让by.a 和l i by.a- 江bx.a\nC.p.o- 巨bx.a- 且by.a- l ibz.a 和l i by.a-libx.a-+libz.a\n.. 7. 11 图 7-14 中的程序头部表明 数据段占用了内存中 Ox230 个字节。然而 , 其中只有开始的 Ox228 字节来自可执行文件的节。是什么引起了这种差异?\n•• 7. 12 考虑目标 文件 m. o 中 对函数 s wa p 的调用(作业题7. 6 ) 。\n9: e8 00 00 00 00 callq e \u0026lt;main+Oxe\u0026gt; swap()\n具有如下重定位条目:\nr.offset = Oxa r.symbol = swap\nr.type = R_X86_64_pc32 r.addend = -4\n假设链接器将 m . o 中的. t e xt 重定位到地址 Ox 4004e 0 , 把 s wa p 重定位到地址 Ox 4004f 8。那么c a ll q 指令中对 s wa p 的 重定位引用的值应该是什么?\n假设链 接器将 m. o 中的 . t e x t 重定位到地址 Ox 4004d 0 , 把 s wa p 重定位到地址 Ox 400500 。那么\nc a l l q 指令中对 s wa p 的 重定位引用的值应该是什么?\n•• 7. 13 完成下面的任务将帮助你更熟悉处理目标文件的各种工具。\n在你的系统上,巨 b . c 和 li bm. a 的版本中包含多少目标文件?\ng c c - Og 产生的 可执行 代码与 g c c - Og - g 产生的不同吗?\n在你的系统上 , GCC 驱动程序使用的是 什么共享库?\n练习题答案 # 7. 1 这道练习题的目 的是帮助你理解链接器符号 和 C 变最及函数之间的关系。注意 C 的局部 变最 t e mp\n没有符号表条目。\n符号 . s ymt a b 条目? 符号类型 在哪个模块中定义 廿Tl buf 定曰 外部 ma1.n.o .data bufpO 是 全局 swap.o . dat a bufpl 定巨 全局 swap.o COMMON swap 是 全局 swap.o .text temp 否 2 这是一个简单的 练习,检查你对 Unix 链接器解析 在一个以上模块中有定 义的 全局符号时所使用规则的理解。理解 这些规则可以 帮助你避免一些 讨厌的编程错误。\n链接器选择定 义在模块 1 中的强符号, 而不是定义在模块 2 中的弱符号(规则 2) : REF(ma i n.1) DEF(main.1) REF( ma i n .2) DEF(main.1) 这是一 个错误 , 因为每个模块 都定义了一个强符号 ma i n( 规则 1 ) 。 链接器选择定义 在模块 2 中的强符号, 而不是定 义在模块 l 中的弱符号(规则2) : REF(x.1) DEF(x.2)\nREF(x.2) DEF(x.2)\n7 3 在命令行中以错误的顺序放置静态库是造成令许多程序员迷惑的链接器错误的常见原因。然而,\n旦你理解了链 接器是如何使用 静态库来 解析引用的 , 它就相当简单易 懂了。这个小 练习检查了 你对这个概念的理解:\nlinux\u0026gt; gee p.o li bx. a\nlinux\u0026gt; gee p.o libx.a liby.a\nl inux\u0026gt; gee p.o l i b x. a liby.a libx.a\n4 这道题 涉及的是 图 7-1 2a 中的反汇编列表。目的是让 你练习阅读反汇编列 表, 并检查你 对 PC 相对\n寻址的理解。\n第 5 行被重定位引用的十六进制地址为 Ox 4004d f 。 第 5 行被重定位引用的 十六进制值为 Ox5 。 记住, 反汇编列 表给出的引 用值是用小端法字节顺序表示的。\n7. 5 这道题 是测试你对链 接器重定 位 PC 相对引用的理解的。给定\nADDR(s) = ADDR(. text) = Ox4004d0\n和\nADDR(r.symbol) = ADDR(swap) = Ox4004e8\n使用图 7-10 中的算法, 链接器首先 计算引用的运行时 地址:\nrefaddr = ADDR(s) + r.offset\n= Ox4004d0 + Oxa\n= Ox4004da\n然后修改 此引用:\n*refptr = (unsigned) (ADDR(r.symbol) + r.addend - refaddr)\n= (unsigned) (Ox4004e8 + (-4) - Ox4004da)\n- (un s i gned ) (Oxa)\n因此, 得到的 可执行目标文件中 , 对 s wa p 的 P C 相对引用的值为 Oxa : 4004d9: e8 Oa 00 00 00 callq 4004e8 \u0026lt;swap\u0026gt;\n"},{"id":444,"href":"/zh/docs/technology/System/%E6%B7%B1%E5%85%A5%E7%90%86%E8%A7%A3%E8%AE%A1%E7%AE%97%E6%9C%BA%E7%B3%BB%E7%BB%9F/%E4%B9%A6%E7%B1%8D/%E7%AC%AC8%E7%AB%A0-%E5%BC%82%E5%B8%B8%E6%8E%A7%E5%88%B6%E6%B5%81/","title":"Index","section":"SpringCloud","content":"第 8 章\nC H A P T E R 8\n异常控制流\n从给处理器加电开始,直到你断电为止,程序计数器假设一个值的序列\na0 , a , , \u0026hellip; , a ,, _1\n其中,每个 ak 是某个相应的指令 I k的地址。每次从 Qk 到 a k一 1 的过渡称为控 制 转移 ( co ntro l trans £er ) 。 这样的 控制转移 序列叫做处理器的控制流( flow of cont rol 或 cont ro l flow ) 。\n最简单的一种控制流 是一个“平滑的" 序列, 其中每个 L 和 I尸!在内存中都是相邻\n的。这种 平滑流的 突变(也就是 I尸]与 L 不相邻)通常是由诸如跳转 、调用和返回这样一些熟悉的 程序指令造成的 。这样一些指令都是必要的机制, 使得程序能够对由程序变扯表示的内部程序状 态中的 变化做出反应 。\n但是系统也必须能够对系统状态的变化做出反应,这 些系统状态不是被内部程序变量捕获的, 而且也不一定要和程序的执行相关。比如, 一个硬件定时 器定期产生信号 , 这个事 件必须得到处理 。包到达网络适配器后,必 须存放在内存中。程序向磁盘请求数据, 然后休眠, 直到被通知说数据巳就绪。当子进程终止时, 创造这些子进程的父进程必须得到通知。\n现代系统通过使 控制流发生突 变来对这些情况做出反应。一般而言, 我们把这些突变称为异常控制流 ( Exceptiona l Control Flow, ECF) 。异常控制流发 生在计算机系统的各个层次。比如, 在硬件层, 硬件检测到的 事件会触发控制突 然转移到异常处理程序。在操作系统层 ,内 核通过上下 文切换 将控制从一个用户进程转 移到 另一个用户进程。在应用层, 一个进 程可以发送信 号到 另一个进程, 而接收者会将控制 突然转移到它的一个信号处理程序。一个程序可以通 过回避通常的栈规则 , 并执行到其他 函数中任意位置的非本地跳转来对错误做出反应 。\n作为程序员, 理解 ECF 很重要 , 这有很多原因:\n理解 ECF 将帮助 你理解重要 的 系统概念。ECF 是操作系统 用来实现 I/ 0 、进程和虚拟内存的基本机制。在能够真正理解这些 重要概念之前 , 你必须理解 ECF 。\n理解 ECF 将帮助你理 解应 用程序是如何与操作 系统交互的 。应用程序 通过使用一个叫做陷阱 ( t ra p) 或者 系统调 用 ( s ys tem call ) 的 ECF 形式, 向操作系统请求服务。比如,向磁盘写数据、从网络读取数据、创建一个新进程,以及终止当前进程,都 是通过应用程序调用系统调用来实现的。理解基本的 系统调用机制将帮助你理 解这些服务是如何提供给应用的。\n理解 ECF 将帮 助 你 编写 有趣的 新应 用程 序。操作系统为应用程序提供了强大的\nECF 机制,用 来创建新进程、等待进程终止 、通知其他进程系统 中的异常事件, 以及检测和响应这些 事件。如果理解了这些 ECF 机制, 那么你就能用它们来编写诸如 U nix shell 和 Web 服务器 之类的有趣程序 了。\n理解 ECF 将帮助你理 解并发 。ECF 是计算机系统 中实现并发的基本机制。在运行中的并发的例子有:中断应用程序执行的异常处理程序,在时间上重叠执行的进程 和线程 , 以及中断应用程序执行的信号处理程序。理解 ECF 是理解并发的第一步。我们会 在第 12 章中更详细地研究并 发。\n理解 ECF 将帮助你理解软件异常如何 工作。像 C+ + 和 J a va 这样的语言通过 t r y、c a t c h 以及 t hr o w 语 句 来 提供软件异常机制。软件异常允许程序进行非 本地跳转\n(即违反通常的调用/返回栈规则的跳转)来响应错误情况。非本地跳转是一种应用 层 ECF , 在 C 中是通过 s e t j mp 和 l o ng j mp 函 数 提供的。理解这些低级函数将帮助你理解高级软件异常如何得以实现。\n对系统的学习,到目前为止你巳经了解了应用是如何与硬件交互的。本章的重要性在 千你将开始学习应用是如何与操作系统交互的。有趣的是, 这些交互都是围绕着 ECF 的。我们将描述存在千一个计算机系统中所有层次上的各种形式的 ECF。从异常开始, 异常位于 硬 件和操作系统交界的部分。我们还会讨论系统调用,它 们 是 为应用程序提供到操作系统 的 入口点的异常。然后, 我们会提升抽象的层次,描 述 进程和信号, 它 们 位 于应用和操作系统的交界之处。最后讨论非本地跳转, 这是 ECF 的一种应用层形式。\n1 异常\n异常是异常控制流的一种形式,它一部分由硬件实现,一部分由操作系统实现。因为它们有一部分是由硬件实现的,所以具体细节将随系统的不同而有所不同。然而,对于每个系统而言,基本的思想都是相同的。在这一节中我们的目的是让你对异常和异常处理有一个一般性的了解,并 且 向 你 揭示现代计算机系统的一个经常令人感到迷惑的方面。\n异常( exception ) 就 是控制流中的突\n变,用来响应处理器状态中的某些变化。图 8-1 展示了基本的思想。\n在图中,当处理器状态中发生一个\n事件在\n\u0026hellip;\u0026hellip;\u0026hellip;\u0026hellip;.. .\n应用程序 异常处理程序\nIcurr\n重要的变化时,处 理 器正在执行某个当前指令 J curr 。在处理器中,状 态被编码为不同的位和信号。状态变化称为事件(event) 。事件可能和当前指令的执行直接相关。比如,发 生虚拟内存缺页、算\n这里发生 / next\n术溢出,或者一条指令试图除以零。另 图 8- 1 异常的剖析。处理器状态中的变化(事件 )触发从\n应用程序到异常处理程序的突发的控制转移(异\n一方面,事件也可能和当前指令的执行\n没有关系。比如,一个系统定时器产生信号或者一个1/0 请求完成。\n常)。在异常处理程序完成处理后,它将控制返回给被中断的程序或者终止\n在任何情况下, 当处 理器检测到有事件发生时, 它 就 会通过一张叫做异常表( excep- tion ta ble ) 的跳转表,进 行 一 个间 接过程调用(异常), 到 一 个 专门设计用来处理这类事件 的 操 作 系统子程序(异常 处理程序( exce pt io n ha ndle r ) ) 。当异常处理程序完成处理后,根 } 据引 起异常的事件的类型, 会发生以下 3 种情况中的一种:\n处 理 程 序将控制返回给当前指令 ICUTT \u0026rsquo; 即当事件发生时正在执行的指令。\n) 处理程序将控制返回给 [ next • 如果没有发生异常将会执行的下一条指令。\n) 处理程序终止被中断的程序。\n8. l. 2 节将讲述关于这些可能性的更多内容。\n囚 日 硬件异常与软件异常\nC ++ 和 J ava 的程序员会 注意 到术语“异常” 也 用 来描述由 C+ + 和 J a va 以 c a t ch、\nt h r o w 和 七r y 语句形 式提供的应用级 ECF 。如 果想严格 清晰, 我们必须区别“ 硬件” 和\n“软 件” 异 常,但 这通常是不必要的, 因 为从 上 下文中就能够很 清楚 地知道是哪种含义。\n1. 1 异常处理\n异常可能会难以理解,因为处理异常需要硬件和软件紧密合作。很容易搞混哪个部分执行哪个任务。让我们更详细地来看看硬件和软件的分工吧。\n系统中可能的每种类型的异常都分配了一个唯一的非负整数的异常号 ( exce ptio n n um ­ her ) 。其中一些号码是由处理器的设计者分配的, 其 他 号 码 是 由 操 作系统内核(操作系统常驻内存的部分)的设计者分配的。前者的示例包括被零除、缺页、内存访问违例、断点 以及算术运算溢出。后者的示例包括系统调用和来自外部 I / 0 设备的信号。\n在系统启动时(当计算机重启或者加电\n时), 操作系统分配和初始化一张称为异常表的跳转表,使 得表目 K 包 含异常 k 的处理程序的地址。图 8-2 展 示了异常表的格式。\n在运行时(当系统在执行某个程序时),处 理器检测到发生了一个事件,并且确定了相应 的异常号 k。随后, 处理器触发异常,方 法是执行间 接过程调用,通 过异常表的表目 k , 转到相应的处理程序。图 8-3 展示了处理器如何\n二1\n异常处理程序0的代码\n异常处理程序l的代码异常处理程序2的代码\n上\n使用异常表来形成适当的异常处理程序的地址。 图 8-2 异常表。异常表是一 张跳转表, 其中表目 K\n异常号是到异常表中的索引,异常表的起始地 包含异常k 的处理程序代码的地址\n址放在一个叫做异常表基 址寄存器( e xce ption table base register ) 的 特殊 CPU 寄存器里。\n异常号\n(X 84)\ni 异常表\nn- 11 I\n图 8-3 生成异常处理程序的地址 。异常号是到异常表中的索引\n异常类似于过程调用 ,但 是有一些重要的不同之处:\n过程调用时,在跳转到处理程序之前,处理器将返回地址压入栈中。然而,根据异常的类型,返回地址要么是当前指令(当事件发生时正在执行的指令),要么是下一 条指令(如果事件不发生,将 会在当前指令后执行的指令)。 处理器也把一些额外的处理器状态压到栈里,在 处理程序返回时, 重新开始执行被中断的程序会需要这些状态。比如, x86-64 系统会将包含当前条件码的 EF LAGS 寄存器和其他内容压入栈中。 如果控制从用户程序转移到内核,所 有这些项目都被压到内核栈中, 而不是压到用户栈中。\n异常处理程序运行在内核模式下(见 8. 2. 4 节), 这意味着它们对所有的系统资源都有完全的访间权限。\n一旦硬件触发了异常,剩下的工作就是由异常处理程序在软件中完成。在处理程序处理完事件之后,它通过执行一条特殊的“从中断返回”指令,可选地返回到被中断的程\n序,该指令将适当的状态弹回到处理器的控制和数据寄存器中,如果异常中断的是一个用户程序 , 就将状态恢复为用 户模式(见8. 2. 4 节), 然后将控制返回给被中断的程序。\n1. 2 异常的类别\n异常可以分为四类: 中断 ( interru pt ) 、陷阱( tra p) 、故障( fault) 和终止( abort )。图 8-4 中的表对这些类别 的属性做了小结。\n类别 原因 异步/同步 返回行为 中断 来自 1/0 设备的信号 异步 总是返回到下一条指令 陷阱 有意的异常 同步 总是返回到下一条指令 故障 潜在可恢复的错误 同步 可能返回到当前指令 终止 不可恢复的错误 同步 不会返回 图 8-4 异 常 的 类 别 。 异步异常是由处理器外部的 I/ 0 设备中的事件产生的。同 步异常是执行一条指令的直接产物\n中断\n中断是 异步发生的 , 是来自处理器外部的I/ 0 设备的信号的结果。硬件中断不是由任何一条专门的指令造成的,从 这个意义上来说它是异步的。硬件中断的异常处理程序常常称为中断处理 程序 ( in t er ru pt hand ler ) 。\n图 8-5 概述了一个中断的处理。I/ 0 设备, 例如网 络适配器、磁盘控制器和定时 器芯片,通过向处理器芯片上的一个引脚发信号,并将异常号放到系统总线上,来触发中断, 这个异常号标识 了引起中断的设备。\n( I ) 在当前指令的执行过程中,中\nf cu斤\nI\n断引脚 电压变高了\nnext\n( 3 ) 中断处\n理程序运行\n图 8-5 中断处理。中断处理程序将控制返回给应用程序控制流中的下一 条指令\n在当前指令完成 执行之后, 处理器注意到中 断引脚的电压变高了, 就从系统总线读取异常号, 然后调用适当的中断处理程序。当处 理程序返 回时,它 就将控制返回给下一条指令(也即如果没有发生中断,在控制流中会在当前指令之后的那条指令)。结果是程序继续执行, 就好像没有发生过中断一样。\n剩下的异常类型(陷阱、故障和终止)是同 步发 生的,是 执行当前指令的结果。我们把这类指令叫做故障指令 ( fa ult ing ins t ru ct ion ) 。\n陷阱和系统调用\n陷阱是有意的 异常 , 是执行一条指令的结果。就像中断处理程序一样, 陷阱处理程序将控制返回到下一条指令。陷阱最重要的用途是在用户程序 和内核之间提供一个像过程一样的接口, 叫做系统调用。\n用 户程序经常需 要向内核请求服务, 比如读一个文件 (r e a d ) 、创建一个新的进程( for k ) 、加载一个新的程序( e x e c v e ) , 或者终止当前 进程( e x 江)。为了允许对这些内核服务的受控的访问, 处理器提供了一条特殊的 \u0026quot; s y s c a l l n \u0026quot; 指令, 当用户程序想要请求\n服务 n 时, 可以执行这条指令。执行 s y s c a l l 指令会导致一个到异常处理程序的陷阱, 这个处 理程序解析参数, 并调用适当的内核程序。图 8-6 概述了一个系统调用的处理。\n( 1 ) 应用程 s ys c a l l 序执行一次系 /next 统调用\n( 3 ) 陷阱处理程序运行\n图 8-6 陷阱处理。陷阱处 理程序将控制返回给应用程序控制流中的下一条指令\n从程序员的角度来看 ,系 统调用和普通的函数调用是 一样的。然而, 它们的实现非常不同。普通的函数运行在 用户 模式中,用 户模式限制了函数可以执行的指令的类型, 而且它们只能访问与调用函数相同的栈。系统调用运行在内核模 式中 ,内 核模式允许系统调用执行特权指令, 并访问定义在内核中的栈。8. 2. 4 节会更详细地讨论用 户模式和内核模式。\n故陪\n故障由错误 情况引起,它 可能能 够被故障处理程序修 正。当故障发生时, 处理器将控制转移给故 障处理程序。如果处理程序能够修正这个错误情况,它 就将控制返回到引起故障的指令 ,从 而重新执行它。否则, 处理程序返 回到内核中的 a bor t 例程, a b o 江 例程会终止引起故 障的应用程序。图 8-7 概述了一个故障的处理。\n( 3 ) 故障处理程序运行\n•••••••••••••••••.•… \u0026hellip; ..►.\n( 4 ) 处理程序要么重新执行当前指令,要么终止\nabort\n图 8-7 故障处 理。根据故障是否能够被修复,故 障 处 理 程序要么重新执行引起故障的指令,要 么 终 止\n一个经典的故 障示例是缺页异常, 当指令引用一个虚拟地址, 而与该地址相对应的物理页面不 在内存中, 因此必须从磁盘中取出时, 就会发生故障。就像我们将在第 9 章中看到的那样 , 一个页面就是 虚拟内存的一个连续的块(典型的是 4K B) 。缺页处理程序从 磁盘加载适当的 页面, 然后将控制返回 给引起 故障的指令。当指令再次执行时, 相应的物理页面已 经驻留在内存中 了, 指令就可以 没有故障地运行 完成了。\n终止\n终止是不 可恢 复的致命错误造成的结果, 通常是一些硬件错误, 比如 DR AM 或者\nSRAM 位被损坏时 发生的奇偶错误。终止处理程序从不将控制返回给应用程序。如图 8-8\n所示,处 理程序将控制返 回给一个 a bor t 例程, 该例程会终止这个应用 程序。\n1. 3 Linux/ x86-64 系统中的异常\n为了使描述更具体 , 让我们来看看为 x86-64 系统定义的一些异常。有高达 256 种不同的异常类型 [ 50] 。0 31 的号码对应的是由 Intel 架构师定义的异常, 因此对任何 x86-64 系统都是一样 的。32 255 的号码对应的是操作系统定义的中断和陷阱 。图 8-9 展示了一些示 例。\n( I ) 发生致命I\n的硬件错误\ncurr\n( 2 ) 传递控制给处理程序\n( 3 ) 终止处理程序运行\n\u0026hellip;\u0026hellip;\u0026hellip;\u0026hellip;…\u0026hellip;\u0026hellip;\u0026hellip;..…. \u0026hellip;\u0026hellip;..►.. abort\n( 4 ) 处理程序返回到\nabor t 例程\n图 8-8 终止处理。终止处理程序将控制传递给一个内核 abor t 例程,该 例 程会终止这个应用程序\n。异常号 描述 异常类别\n图 8-9 x86-64 系统中的异常示例\nLinux/ x86-6 4 故障和终止\n除法错误 。当应用试图除以零时, 或者当一个除法指令的结果对于目标操作数来说太大了的时候, 就会发生除法错误(异常 0 ) 。U nix 不会试图从除法错误中恢复, 而是选择终止程序。Linu x s hell 通常会把除法错误 报告为“ 浮点异常 ( F loa ting except io n ) \u0026quot; 。\n一般保护故障 。许多原因都会 导致不为人知的一般保护故障(异常 13 ) , 通常是因为一个程序引用了一个未定义的虚拟内存区域, 或者因为程序试图写一个只读的文本段。L in ux 不会尝试恢复这类故障。Lin ux s hell 通常会把这种一般保护故障报告为 "段故樟\n( S eg m e n tat io n fa ult ) \u0026quot; 。\n缺页(异常 14) 是会重新执行产生故障的指令的一个 异常示例。处理程序将适当的磁盘上虚拟内存的一个页面映射到物理内存的一个页面,然 后重新执行这条产生故障的指令。我们将在第 9 章中看到缺页是 如何工作的细节。\n机器桧 查。机 器检查(异常 18 ) 是在导致故障 的指令执行中检测到致命 的硬件错误时发生的。机器检查处理程序从不返回控制给应用程序。\nLinux/ 86-64 系统调 用\nL in ux 提供几百 种系统调用, 当应用程序想要请求内核服务时可以使用, 包括读文件、写文件或是创建一个新进程。图 8-10 给出了一些常见的 Lin ux 系统调用。每个系统调用都有一个唯一的整数号, 对应于一个到内核中跳转表的偏移址。(注意: 这个跳转表和异常表不一样 。)\nC 程序用 s ys c a l l 函数可以直接调用任何系统调用。然而,实 际中几乎没必要这么做。对于大多数系统调用, 标准 C 库提供了一组方便的包装函数。这些包装函数将参数打包到一 起, 以 适当的系统调用指令陷人内核, 然后将系统调用的返回状态传递回调用程序 。在本书中,我们将系统调用和与它们相关联的包装函数都称为系统级函数, 这两个术语可以互换地使用。\n在 x86- 64 系统上, 系统调用是通过 一条称为 s ys c a l l 的陷阱指令来提供的。研究程序能够如何使用这条指令来直接调用 L in u x 系统调用是很有趣的。所有到 Lin ux 系统调用的 参 数都是通过通用寄存器而不 是栈传递的。按照惯例, 寄存器%r a x 包含系统 调用号,\n寄存器%r d i 、%r s i 、%r d x 、%r 1 0 、r% 8 和%r 9 包含最多 6 个参数。第一个参数在% r 中 中,第\n二个在%r s i 中 , 以此类推。从系统调用 返回时, 寄存器%r c x 和%r ll 都 会被破坏,%r a x 包\n含返回值。—40 9 5 到一1 之间的 负 数返回值表明发生了错误, 对应于负的 er r n o 。\n编号\n图 8-10 Linux x86-64 系 统中 常用的系统调用示例\n例如, 考 虑 大家熟悉的 h e l l o 程序的下 面这个版本, 用 系统级函数 wr i t e ( 见 1 0 . 4\n节)来写,而 不是用 pr i n t f :\nint ma i n ()\n2 {\n3 vrite(l, \u0026ldquo;hello, vorld\\n\u0026rdquo;, 13) ;\n_e xi t ( O) ;\n5 }\nwr i t e 函 数的第一个参数将输出发送到 s t d o u 七。 第二个参数是要写的字节序列, 而第三个参数是要写的字节数。\n图 8-11 给出的是 h e l l o 程序的汇编语言版本, 直 接 使 用 s y s c a l l 指 令 来 调 用 wr i t e\n和 e x i t 系统调用。第 9 ~ 1 3 行调用 wr i t e 函 数 。 首先, 第 9 行将系统调用 wr i t e 的 编号存放在%r a x 中 , 第 1 0 ~ 1 2 行设 置 参数 列 表。然后第 1 3 行使用 s y s c a l l 指令来调用系统调用。类 似地,第 1 4 ~ 1 6 行调用_e x i t 系统调用。\ncode/ecf/hello-asm64.sa\n.section .dat a str i ng:\n. a s c i i \u0026ldquo;hello, vorld\\n\u0026rdquo;\ns t r i ng _e nd :\n.equ len, string_end - string\n.section .text\n.globl main\nmain:\nFirst, call write(1, \u0026ldquo;hello, world\\n\u0026rdquo;, 13)\nmovq $1, %rax write 1s system call 1\nmovq $1, %rdi Argl: stdout has descriptor 1\nmovq $s tr i ng , %rsi Arg2: hello world string\n12 mo v q $ l e n , %rdx Arg3: string length 13 syscall Make the system call Next, call _exit(O)\nmovq $60, %rax movq $0, %rdi syscall _ex1 t is system call 60 Arg1: exit status is 0 Make the system call\ncode/ecfh/ello-asm64.as\n图8-11 直接用Linux 系统调用来实现 he ll o 程序\n日 日 关千术 语的注释\n各种异常类型的 术语根据系统的不同 而有所不同 。处理 器 ISA 规范通常会 区分异步\n“中 断” 和同 步“异 常", 但是并没有提供 描述这些非 常相 似的 概念的概括性的术语。为了避免不断地提到“异常和中断”以及“异常或者中断",我们用单词“异常”作为通 用的 术语, 而且 只有在必要时才 区别异 步异 常(中断)和同 步异 常(陷阱、故障和终止)。正如我们提到过的,对于每个系统而言,基本的概念都是相同的,但是你应该意识到一 些制 造厂商的 手册会 用“ 异常” 仅仅 表示同 步事件 引起的 控制流的 改变。\n2 进程\n异常是允许操作系统内核提供进程( pro cess ) 概念的基本构造块 , 进程是计算机科学中最深刻、最成功的概念之一。\n在现代系统上运行 一个程序时 , 我们会得到一个假象, 就好像我们的程序是 系统中当前运行的唯一的程序一样。我们的 程序好像是独占地使用处理器和内存。处理器就好像是无间断地一条接一条地执行我们程序中的指令。最后 , 我们程序中 的代码和数据好像是系统内存中唯一的对象。这些假象都 是通过进程的概念提供给我们的。\n进程的经典定义就是一个执行中程序的实例。系统中的每个程序都运行在某个进程的 上下文 ( co n t e x t ) 中。上下文是由程序正确 运行所需的状态组成的。这个状 态包括存放在内存中的程序的代码和数据,它的栈、通用目的寄存器的内容、程序计数器、环境变最以及 打开文件描述符的 集合。\n每次用 户通过向 s hell 输入一个可执行目 标文件的名字, 运行 程序时, s hell 就会创建一个新的进程, 然后在这个新进程的上下文中运行这个 可执行目标文件。应用程序也能够创建新进程, 并且在这个新进程的上下 文中运行它们自己的代码或其他应用程序。\n关千操作系统如何实现进程的 细节的讨论超出了本 书的范围。反之,我 们将关注进程提供给应用程序的关键抽象:\n一个独立的逻辑控制流 , 它提供一个假象 , 好像我们的程序独占地使用处理器。 一个私有的地址空间, 它提供一个假象 , 好像我们的 程序独占地使用内 存系统。让我们更 深入地看看这些 抽象。 8. 2. 1 逻辑控制流\n即使在系统中通常有许 多其他程序在运行 , 进程也 可以向每个 程序提供一种假象,好像它在独占地使用处理器。如果想用调试器单步执行程序 , 我们会看到一系列的程序计数器(PC) 的值, 这些值唯一地对应于包含 进程A 进程B 进程C\n在程 序的 可执行目标文件中的指令, 或是包含在运行时动态链接到程序的共享\n对象中的指令。这个 PC 值的序列叫做逻\n时间\n辑控 制流,或者 简称逻辑流。\n考虑一个运行着 三个 进 程的 系统, 如图 8-12 所示。处理器的一个物理控制\n流被分成了三个逻辑流, 每个 进程一个。 图 8-12 逻辑控制流。进程为每个程序提供了一种假象,\n每个竖直的条表示一个进程的逻辑流的\n一部分。在这个例子中, 二个逻辑流的\n好像程序在独占地使用处理器 。每个竖直的条表示一个进程的逻辑控制流的一部分 j\n执行是交错的 。进程 A 运行 了一会儿, 然后是进程 B 开始运行到完成。然后, 进程 C 运行了一会儿 , 进程 A 接着运行 直到完成。最后, 进程 C 可以运行到结束了。\n图 8-1 2 的关键点在于进程是轮流使 用处理器的。每个 进程执 行它的流的一部分, 然后被抢占 ( preem pted )(暂时挂起), 然后 轮到其他进程。对千一个运行在这些进程之一的上下文中的程序, 它看上去就像是在独占地使用处 理器。唯一的反面例证是 , 如果我们 精确地测扯 每条指令使用的时间, 会发现在程序中一些指令的执行之间, CPU 好像会周期性地停顿 。然而 , 每次处 理器停顿 , 它随后会继续执行我们的程序 , 并不改变程序内存位置或寄存器的内 容。\n8. 2. 2 并发流\n计算机系统中逻辑 流有许 多不同的形式。异常处理程序、进程、信号处理程序、线程和 Java 进程都是逻辑 流的例子。\n一个逻辑流的执行在时间上与另一个流重叠, 称为并发 流 ( co nc urr e n t flow), 这两个流被称为并发 地运行 。更准确地说 ,流 X 和 Y 互相并发, 当且仅当 X 在 Y 开始之后和 Y 结束之前开始,或者 Y 在 X 开始之后和 X 结束 之前开始。例如,图 8-1 2 中,进 程 A 和 B 并发地运行 , A 和 C 也一样。另一方面, B 和 C 没有并发地运行 , 因为 B 的最后一条指令在 C 的第一条指令之前执行。\n多个流并发地执行的一般现象被称为并发 ( co ncu rr e ncy ) 。一个进程和其 他进程轮流运行的概念称为 多任务( m ult itas king ) 。一个进程执行它的控制流的 一部分的每一时间段叫做时间 片 ( t im e s lice ) 。因此,多 任务也叫 做时间分 片 ( t im e s licing ) 。例如, 图 8-1 2 中, 进程 A 的流由两个时间片组成。\n注意, 并发流的思想与流运行的 处理器核数或者计算机数无关。如果两个 流在时间 上重叠 , 那么它们就是并 发的, 即使它们是运行在同一个处理器上。不 过, 有时我们会发现确认并行 流是很有帮助的,它 是并发流的一个真子集。如果两个流并发地运行 在不同的处理器核或 者计算机上, 那么我们称它们为并行 流( pa ra ll el fl o w ) , 它们并行 地运行 ( ru n ning in para llel) , 且并行地执行( para llel exec ut ion ) 。\n让 练习题 8. 1 考虑 三个具有下述起 始和结束 时间的 进程:\n起始时间\nI 3\n结束时间\n2\n4\n5\n对于每 对进 程,指 出它 们是 否是 并发地运行 :\n8. 2. 3 私有地址空间\n进程也为每个 程序提 供一种假象 , 好像它独占地使用 系统地址空间。在一台 n 位地址的机器上 ,地 址空间是 2\u0026quot; 个可能地址的集合, o, 1, … , 2\u0026quot; - 1。进程为每个程序提供它自己的私有地 址空间 。一 般而言, 和这个空间中某个地址相关 联的那个内存字节是不能被\n其他进程读或者写的, 从这个意义上说, 这个地址空间 是私有的。\n尽管和每个私有 地址空间 相关联的内存的内容一般是不同的, 但是每个这样的空间都有相同的通用结 构。比如,图 8-1 3 展示了一个 x8 6- 64 L in u x 进程的地址空间 的组织结构。\n地址空间底部是保留给用户程序的, 包括通常的 代码、数据、堆和栈段。代码段总是从地址 Ox 400000 开始。地址空间顶部保留给内 核(操作系统 常驻内存的部分)。地址空间的这个部分包含内核在代表进程执行指令时(比如当应用程序执行系统调用时)使用的代 码、数据和栈。\n248-1\u0026mdash;+\n内核虚拟内存\n(代码、数据、堆、栈)\n用户栈\n(运行时创建的)\n-\u0026hellip;\ni 用户代码不可见的内存\n七 %e sp (栈指针)\n,. ,,\n之- ,. ·.\u0026rsquo;·\n) -气\n共享库的内存映射区域\n.•• ., 丸 _. 才. `- \u0026ndash; . ; 千 、、,•.、:-.\n; i \u0026quot;\n运行时堆\n(用ma l l oc 创建的)\n读/写段\n( . da t a、.bss)\n只读代码段\n.七 br k\nOx 0 0 4 00 0 0 0 \u0026ndash;+\n( . i ni 七、. t ex t 、.rodata)\n,, . i°; i 飞 宁\n图 8-13 进程地址空间\n8. 2. 4 用户模式和内核模式\n为了使 操作系统内核提供一个无懈可击的 进程抽象 , 处理器必须提供一种机制, 限制一个应用可以 执行的指令以及它可以访问的地址空间范围。\n处理器通常 是用某个控制寄存器中的一个模式位( m o de b it ) 来提供这种功能的, 该寄\n存器描述了进程当前享有的特权。当设置了 模式位时 , 进程就运行在内核 模式中(有时叫做超级 用户 模式)。一个运行 在内核模式 的进程可以执行指令集中的任何指令, 并且可以访问系统中的任何内存位置。\n没有设置模式位时 , 进程就运行 在用户 模式中。用户模式中的 进程不允 许执行特权指令( pr ivileged ins t ru ct ion ) , 比如停止处理器、改变模式位,或 者发起一个 1/ 0 操作。也不允许用户模式中的进程直接引用 地址空间中内核区内的代码和数据。任何这样的尝试都会导致致命的保护故障。反之, 用 户程序必须通过系统凋用接口间 接地访问内核代码和数据 。\n运行 应用程序代码的 进程初始时 是在用户模式中 的。进程从 用户模式变为内核模式的唯一方法是通过诸如中断、故障或者陷入系统调用这样的异常。当异常发生时,控制传递 到异常处理程序, 处理器将模式 从用户模式变为内 核模式。处理程序运行在内核模式中, 当它返回到应用程序代码时,处理器就把模式从内核摸式改回到用户模式。\nLin ux 提供了一种聪明的机制 , 叫做/ pr o c 文件系统 , 它允许用户模式进程访问内核数\n据结构的内 容。/ pr oc 文件系统将许多内核数据结构的内容输出为一个用户程序可以读的文本文件的层次结构。比如,你 可以使用/ pr oc 文件系统找出一般的系统属性,比 如 CPU 类型(/proc/ cpuinfo), 或者某个特殊的进程使用的内存段( / pr oc / \u0026lt;p ro c e s s - i d \u0026gt; / ma ps ) 。2. 6 版本的 Linux 内核引入/ s ys 文件系统,它输 出 关 千系统总线和设备的额外的低层信息。\n8. 2. 5 上下文切换\n操作系统内核使用一种称为上下文切换 ( context switch ) 的较高层形式的异常控制流来实现多任务 。上下文切换机制是建立在 8. 1 节中已经讨论过的那些较低层异常机制之上的。\n内核为每个进程维持一个上下文( conte xt ) 。上下文就是内核重新启动一个被抢占的进程所需的状态。它由一些对象的值组成,这些对象包括通用目的寄存器、浮点寄存器、程 序计数器、用户栈、状态寄存器、内核栈和各种内核数据结构,比如描述地址空间的页 表、包含有关当前进程信息的进程表,以及包含进程已打开文件的信息的文件表。\n在进程执行的某些时刻,内核可以决定抢占当前进程,并重新开始一个先前被抢占了\n的进程。这种决策就叫做调度( sched uling ) , 是由内核中称为调度器 ( sched ule r ) 的 代码处理的。当内核选择一个新的进程运行时 , 我们说内核调度了这个进程。在内核调度了一个新的进程运行后,它就抢占当前进程,并使用一种称为上下文切换的机制来将控制转移到 新的进 程, 上下文切换 1 ) 保存当前进程的上下文, 2 ) 恢复某个先前被抢占的进程被保存的上下文, 3 ) 将控制传递给这个新恢复的进程。\n当内核代表用户执行系统调用时,可能会发生上下文切换。如果系统调用因为等待某 个事件 发生而阻塞,那 么 内 核 可以让当前进程休眠,切 换 到另一个进程。比如, 如 果 一 个 r e a d 系统调用需要访问磁盘,内 核 可以选择执行上下文切换, 运行另外一个进程, 而 不是等待数 据从磁盘到达。另一个示例是 s l e e p 系统调用, 它 显 式 地请求让调用进程休眠。一般而言,即使系统调用没有阻塞,内核也可以决定执行上下文切换,而不是将控制返回 给涸用进程。\n中 断也可能引发上下文切换。比如,所 有的系统都有某 种产生周期性定时器中断的机制,通 常为每 1 毫秒或每 10 毫秒。每次发生定时器中断时,内 核 就 能 判定当前进程已经运行了 足够长的时间,并 切 换 到 一 个 新 的 进 程。\n图 8-14 展示了一对进程 A 和 B 之间上下文切换的示例。在这个例子中, 进程 A 初始运行 在用户模式中,直 到它通过执行系统调用 r e a d 陷入到内核。内核中的陷阱处理程序请求来 自磁盘控制器的 OMA 传输,并 且安排在磁盘控制器完成从磁盘到内存的数据传输后,磁盘中断处理器。\n时间 进程A 进程B\nread········► + 用户模式\n芒 内核模式 }上下文切换\n磁盘中断 \u0026hellip;\u0026hellip;.. — { 用户模式\n从r ead 返回 \u0026hellip;..►.\n内核模式 }上下文切换\n用户模式图 8-14 进程上下文切换的剖析\n磁盘取数据要用一段相对较长的时间(数量级为几 十毫秒),所以内核执行从 进程 A 到进程 B 的上下文切换, 而不是在这个间 歇时间内等待, 什么都不做。注意在切换之前, 内核正代表进程 A 在用户模式下执行指令(即没有单独的 内核进程)。在切换的第一部分中, 内 核代表进程 A 在内核模式下执行指令。然后在某一时刻, 它开始代表进程 B ( 仍然是内核模式下)执行指令。在切换之后,内 核代表进程 B 在用户模式下执行指令。\n随后,进程 B 在用户模式下运行一 会儿, 直到磁盘发出一个 中断信号, 表示数据已经从磁盘传送到了内存。内核判定进程 B 已经运行了足够长的时间, 就执行一个从 进程 B 到进程 A 的上下文切换 , 将控制返回给进程 A 中紧随 在系统调用 r e a d 之后的那条指令。进程 A 继续运行, 直到下一 次异常发生 ,依 此类推。\n8. 3 系统调用错误处理\n当 U nix 系统级函数遇到错 误时, 它们通常会返回一1, 并设置全局整数变量 e r r no 来表示什么出错了。程序员应该总是检查错误,但是不幸的是,许多人都忽略了错误检 查, 因为它使 代码变得膀 肿, 而且难以读懂。比如,下 面是我们调用 U n ix f or k 函数时会如何检查错误:\nif ((pid = fork()) \u0026lt; 0) {\nfprintf(stderr, \u0026ldquo;fork error: %s\\n\u0026rdquo;, strerror(errno)); exit(O);\n}\ns t re rr or 函数返回一 个文本串 , 描述了和某个 err n o 值相关联的错误。通过定义下面的错误报告函数,我们能够在某种程度上简化这个代码:\nvoid unix_error(char *msg) I* Unix-style error *I\n{\nfprintf(stderr, \u0026ldquo;%s: %s\\n\u0026rdquo;, msg, strerror(errno)); exit(O);\n}\n给定这个函数, 我们对 f or k 的调用从 4 行缩减到 2 行:\nif ((pid = fork()) \u0026lt; 0) unix_error(\u0026ldquo;fork error\u0026rdquo;);\n通过使用 错误处理 包装函数, 我们可以更 进一步地简化代码, S t eve n s 在[ ll O] 中首先提出了 这种方法。对于一个给定的基本函数 f o o , 我们定义一个具有相同参数的包装函数\nFoo, 但是第一个字母大写了。包装函数调用基本函数, 检查错误, 如果有任何问题就终止。比如,下 面是 f o r k 函数的错误处 理包装函数 :\npid_t Fork(void)\n{\npid_t pid;\nif ((pid = fork()) \u0026lt; 0) unix_error(\u0026ldquo;Fork error\u0026rdquo;);\nreturn pid;\n给定这个包装函数, 我们对 f o r k 的调用就缩减为 1 行:\npid = Fork() ;\n我们将在本书剩余的部分中都使用错误处理包装函数。它们能够保持代码示例简洁,而 又不会给你错误的假象,认为允许忽略错误检查。注意,当在本书中谈到系统级函数时,我 们总是用它们的小写字母的基本名字来引用它们 , 而不是用它们大写的包装函数名来引用。\n关千 U nix 错误处理以及本书中使用的错误处理包装函数的讨论, 请参见附录 A 。包装函数定 义在一个 叫做 c s a pp . c 的文件中, 它们的原型定义在一个叫做 c s a p p . h 的头文件中; 可以从 CS : APP 网站上在线地得到这些代码。\n8. 4 进程控制\nUnix 提供了大量从 C 程序中操作进程的系统 调用。这一节将描述这些重要的函数, 并举例说明如何使用它们。\n8. 4. 1 获 取进程 ID\n每个进程都有一个唯一的正数(非零)进程 ID( PID) 。ge t pi d 函数返回调用进程的 PIO。\nge七pp i d 函数返回它的父进程的PIO( 创建调用进程的进程)。\n#include \u0026lt;sys/types.h\u0026gt;\n#include \u0026lt;unistd.h\u0026gt;\npid_t getpid(void); pid_t getppid(void);\n返回: 调 用者或 其 父进程的 PID,\ng e t p i d 和 g e t p p i d 函数返回一个类型为 p i d t 的整数值, 在 Lin ux 系统上它在\nt ype s . h 中被定义为 i n 七。\n4·. 2 创建和终止进程\n从程序员的角度 , 我们可以认为进程总是处于下面三种状态之一:\n运行。进程要么在 CP U 上执行, 要么在等待 被执行且最终会被内核调度。 停止。进程的执行被挂起 ( s us pended ) , 且不会被调度。当收到 SIGS T O P 、S IG T ­ S T P、SIG T T I N 或者 SIG T T O U 信号时, 进程就停止, 并且保持停止直到它收到一个 S IGCO NT 信号, 在这个时刻,进 程再次开始运行。(信号是一种软件中断的形式, 将在 8. 5 节中详细描述。) 终止。进 程永远地停止了。进程会因为三种原 因终止: 1) 收到一个信号,该 信 号的默认行为是终 止进程, 2 ) 从 主程序返 回, 3 ) 调用 e x i t 函数。 #include \u0026lt;stdlib.h\u0026gt;\nvoid exit(int status);\n该函数不返回 。\ne x i t 函数以 s t a t u s 退出状 态来 终止进程(另一种设置退出状态的方法是从主程序中返回一个整数值)。\n父进程通过调用 f or k 函数创建一个新的运行的子 进程。\n#include \u0026lt;sys/types.h\u0026gt;\n#include \u0026lt;unistd.h\u0026gt;\npid_t fork(void);\n返回: 子 进 程 返 回 o, 父进程返 回 子进 程的 PID, 如果出错 ,则 为一 l 。\n新创 建的子 进程几乎但不完全与父进程相同。子进程得到与父进程用户级虚拟地址空间相同的(但是独立的)一份副本,包括代码和数据段、堆、共享库以及用户栈。子进程还获得与父 进程任何打开文件描述符相 同的副本, 这就意味着当父进程调用 for k 时,子 进 程可以读写父进程中打开的任何文件。父进程和新创建的子进程之间最大的区别在于它们有不同的PID。\nf or k 函数是有趣的(也常常令人迷惑),因 为它只被调用一次, 却 会返回两 次: 一次是在调用进程(父进程)中,一 次 是 在新创建的子进程中。在父进程中, f o r k 返回子进程的 PID 。在子进程中, f o r k 返回 0。因为子进程的 PID 总是为非零, 返回值就提供一个明确的方法来分辨程序是在父进程还是在子进程中执行。\n图 8-15 展示了一个使用 f o r k 创建子进程的父进程的示例。当 f or k 调用在第 6 行返回 时 ,在 父 进 程和子进程中 x 的值都为 1。子进程在第 8 行加一并输出它的 x 的副本。相似地 ,父 进 程 在第 13 行减一并输出它的 x 的副本。\n1 int main()\n2 {\n3 pid_t pid;\n4 int X = 1·,\n5\n6 pid = Fork();\nif (pid == 0) { I* Child *I\nprintf (\u0026ldquo;child : x=加 \\ n \u0026quot; , ++x);\n9 exit(O);\n10 }\n11\n12 I* Parent *I\n13 printf (\u0026ldquo;parent: x=加 \\ n \u0026quot; , \u0026ndash;x);\n14 exit (0);\n15 }\ncode/ecf/fork.c\ncode/ecf/fork.c\n图 8-15 使用 fo r k 创建一个新进程\n当在 U nix 系统上运行这个程序时, 我们得到下面的结果:\nlinux\u0026gt; . / f ork parent: x=O chil d : x=2\n这个简单的例子有一些微妙的方面。\n调用一次,返 回 两次。 f o r k 函数被父进程调用一次, 但 是 却 返 回 两 次 一 一次是返回到父进程,一 次 是 返回到新创建的子进程。对于只创建一个子进程的程序来说 , 这还是相当简单直接的。但是具有多个 f or k 实例的程序可能就会令人迷惑, 需要仔细地推敲了。 并发执行。父进程和子进程是并发运行的独立进程。内核能够以任意方式交替执行 它 们 的 逻辑控制流中的指令。在我们的系统上运行这个程序时,父 进程先完成它的pr i n t f 语 句 ,然 后 是 子 进程。然而, 在另一个系统上可能正好相反。一般而言, 作为程序员,我们决不能对不同进程中指令的交替执行做任何假设。\n相同但是独立的 地址空间。 如果能够在 f or k 函数在父进程和子进程 中返回后立即暂停这两个进程,我们会看到两个进程的地址空间都是相同的。每个进程有相同的 用户栈、相同的本地变量值 、相同的堆、相同的全局变量值, 以及相同的代码。因此, 在我们 的示例程序 中, 当 f or k 函数在第 6 行返回时 , 本地变量 x 在父进程和子进程中都 为 1。然而 , 因为父进程和子进程是独立的进程,它 们都有自己 的私有地址空间。后面, 父进程和子进程对 x 所做的任何改变都是 独立的 , 不会反映在另一个进程的内 存中。这就是为什么当父进程 和子进程调用它们各自的 p r i n t f 语句时, 它们中的变量 x 会有不同的值。 共享文件。 当运行这个示例程序时, 我们注意 到父进 程和子进程都 把它们的输出显示在屏幕上。原因是子进程继 承了父进程所有的 打开文件。当父进程调用 f or k 时, s t d o 江 文件是打开的, 并指向屏幕。子进程继承了这个文件, 因此它的输出也是指向屏幕的。\n如果你是第一次学习 for k 函数, 画进程图通常会有所帮助,进程图 是刻画程序语 句的 偏序的一种简单的前趋图。每个顶点a 对应于一条程序语句的执行。有向边 a - b 表示语 句 a 发生在语句 b 之前。边上可以标记出一些信息, 例如一个变量的当前值。对应于 pr i nt f 语句的顶点可以 标记上 pr i n t f 的输出。每张图从一个顶点开始,对应千调用 mai n 的父进程。这个顶 点没有入边,并且只有一个出边 。每个进程的顶点序列结束于一 个对应于 e x i t 调用\n的顶点。这个顶点只有一条入边,没有出边。例如, 图 8-16 展示了图 8-15 中示例程序\n的进程图 。初始时, 父进程将变量 x 设置为\nX==l\nch i l d: x=2 printf\npra ent : x=O\nexit\n子进程\nl 。父进程调用 f or k , 创建一个子进程,它在自己的私有地址空间中与父进程并发执行。对千运行在单处理器上的程序,对应进\nmain f or k printf exi七\n图 8-16 图 8-15 中示 例程序 的进程图\n父进程\n程图中所 有顶点的 拓扑排序( to po log ica l so r t ) 表示程序中语句的一个可行的全序排 列。下面是一个理解拓扑排序概念的简单方法:给定进程图中顶点的一个排列,把顶点序列从左 到右写成 一行,然后 画出每条有向边。排列 是一个拓扑排序, 当且仅当画出的每条边的方向都是从 左往右的。因此, 在图 8-15 的示例程序中 , 父进程和子进程 的 pr i n t f 语句可以以任意先 后顺序执行, 因为每种顺 序都对应千图顶点的某种拓扑 排序。\n进程图特别有 助千理解带 有嵌套 f or k 调用的程序。例如,图 8-17 中的程序源码中两次调用了 f or k。对应 的进程图可帮 助我们 看清这个程序运行 了四个进程, 每个 都调用了一次 pr i n t f , 这些 pr i n t f 可以以 任意顺序执行。\nint main()\n{\nFork();\nFork(); printf(\u0026ldquo;hello\\n\u0026rdquo;); exit(O);\nhel l o pr i nt f he ll o\nf or k printf\nhell o\ne x i t exi 七\n图 8 - 17\nmain f or k\n嵌套 f or k 的进程图\nf or k pr i nt f exit\ni 练习题 8. 2 考虑下面的程序:\nint main()\n{\ncode/ecflforkprobO.c\nint X = 1;\nif (Fork() == 0)\nprintf(\u0026ldquo;p1: x=%d\\n\u0026rdquo;, ++x); printf(\u0026ldquo;p2: x=%d\\n\u0026rdquo;, \u0026ndash;x); exit(O);\ncodelecflforkprobO.c\n子进程的输出是什么? 父进程的输出是什么? 8. 4. 3 回收子进程\n当一个进程由于某种原因终止时,内核并不是立即把它从系统中清除。相反,进程被保持在一种已终止的状态中, 直到被它的 父进程回收( r ea ped ) 。当父进程回收己终止的子进程时,内核将子进程的退出状态传递给父进程,然后抛弃己终止的进程,从此时开始, 该进程就不 存在了。一 个终止了但还未被回收的 进程称为僵 死进 程( zo m bie ) 。\n日 日 为什么已终止的子进程被 称为僵死进程?\n在民 间传说 中,僵 尸是 活着的 尸体 , 一种半 生半 死的 实体。僵死进程已经终止了, 而内核仍保留着它的某些状态直到父进程回收它为止,从这个意义上说它们是类似的。\n如果一个父进程终 止了,内 核会安排 i n i t 进程成 为它的 孤儿进程的养父。 i n i t 进程的 P ID 为 1, 是在系统启动时由内核创建的,它不会终止,是所有进程的祖先。如果父进程没有回收它的僵死子进程就终 止了, 那么内核会安排 i n i t 进程去回收它们。不过,长时间运行 的程序,比如 shell 或者服务器,总 是应该回收它们的僵死子进程。即使僵死子进程没有运行,它们仍然消耗系统的内存资源。\n一个进程可以 通过调用 wa i t p i d 函数来等待它的 子进程终止或者停止。\n#include \u0026lt;sys/types.h\u0026gt;\n#include \u0026lt;s ys / wa i t . h \u0026gt;\npid_t waitpid(pid_t pid, int *statusp, int options);\n返回: 如 果 成 功 , 则为 子 进 程 的 PIO, 如 果 WNO HAN G , 则 为 o, 如 果其他错误 , 则为— 1.\nwa i t p 过 函数有点复杂。默认情况下(当o p巨 o ns = O 时), wa i t p i d 挂起调用进程的执行, 直到它的等待 集合 ( w ait set ) 中的一个子进程终止。如果等待集合 中的一个进程在刚调用的时刻就已经终止了, 那么 wa i t p i d 就立即返回。在这两种情况中, wa i t p i d 返回导致 w ait pid 返回的已终止子进程的 P IO 。此 时, 已终止的子进程巳经被回收, 内核会从系统中删除掉它的所有痕迹。\n1 判定等待集合的 成员\n等待集合的成员是由参数 p 过 来确定的:\n如果 p 过 \u0026gt;O, 那么等待集合就是一个单独的子进程 ,它的 进程 ID 等千 p i d。\n如果 pi d= - 1 , 那么等待集合就是由父进程所有的子进程组成的。\nwa i t p 迈 函数还支持其他类型的等待集合, 包括 Unix 进程组, 对此我们将不做讨论。\n修改默认行 为\n可以通过将 op t i on s 设置为常晕 WNO H ANG 、 WU NT RACED 和 WCO NT INUED\n的各种组合来修改默认行为:\nWNOHANG: 如果等待集合中的任何子进程都还没有终止,那么就立即返回(返回值为 0 ) 。默认的行 为是挂起调用进程, 直到有子进程终 止。在等待子进程终 止的同时,如果还想做些有用的工作,这个选项会有用。\nWUNTRACED: 挂起调用进程的执行, 直到等待集合中的一个进程变成巳终止或者被停止。返回的 PID 为导致返回的已终止或被停止 子进程的 PID。默认的行为是只返回己终止的子进程。当你想要检查己终止和被停止的子进程时,这 个选项会有用。\nWCONT I NU ED: 挂起调用进程的 执行, 直到等待集合中一个正在运行的进程终止或等待集合中一个 被停止的进程收 到 S IGCO NT 信号重新开始执行。( 8. 5 节会解释这些信号。)\n可以用或运算把这些选项组合起来。例如:\nWNOHANG I WUNTRACED: 立即返回,如果等待集合中的子进程都没有被停止或终止, 则返回值为 O; 如果有一个停止或终 止, 则返回值为该子进程的 PID。\n检查己回 收子进程的 退出状态\n如果 s 七a t us p 参数是非空的, 那么 wa i t p i d 就会在 s 七a t us 中放上关于导致返回的子进程 的状态信息, s 七a t us 是 s t a t us p 指向的值。wa i t . h 头文件定义了解释 s 七a 七us 参数的几个宏:\nWIFEXITED(s t a 七us ) : 如果子进程通过调用 e xi t 或者一个返回( ret urn ) 正常终止, 就返回真。\nWEXITSTATUS ( s t a 七us ) : 返回一 个正常终止的 子进程的退出状态。只有 在\nWIFEXIT ED( ) 返回为真时 , 才会定义这个状态。\nWIFSIGNALED(status): 如果子进程是因为一个未被捕获的信号终止的, 那么就返回真。\nWTERMSIG(status): 返回导致子进程终止的信号的编号。只有在 WIFSIG­ NALE D( ) 返回为真时 , 才定义这个状态。\nWIF ST OPP ED(s t a 七us ) : 如果引起返回的子进程当前是 停止的, 那么就返回真。\nWSTOPSIG(status): 返回引起子进程停止的信号的编号。只有在 WIFSTOPP D ( )\n返回为真时,才定义这个状态。\nWIFCONTINUED( s t a 七us ) : 如果子进程收到 SIGCONT 信号重新启动, 则返回真。\n错误条件\n如果调用进程没有子进程, 那么 wa i t p i d 返回—1, 并且设置 err no 为 EC H ILD 。如果 wa i t p 卫 函数被一个信号中断, 那么它返回- 1 , 并设置 err no 为 EINT R。\nm 和 Unix 函数相关的 常量\n像 WNOHANG 和 WUNT RACED 这样的常量 是由 系统头文件定义的。例如, WNO­ HANG 和 WU NT RACE D 是由 wa i t . h 头文件(间接)定义的 :\nI* Bits\n#define\n#define\nin the third WNOHANG 1\nWUNTRACED 2\nargument to\u0026rsquo;waitpid\u0026rsquo;. */ I* Don\u0026rsquo;t block waiting. *I\nI* Report status of stopped children. *I\n为了使用这些常量, 必须在代码中 包含 wa i t . h 头文件:\n#include \u0026lt;sys/wait.h\u0026gt;\n每个 U nix 函数的 ma n 页列 出 了 无论何 时你在代码中使 用 那个函数都要 包含 的头文件。同时, 为 了检 查诸如 ECH ILD 和 EINT R 之 类的 返回代码, 你必须 包含 er r n o . h 。 为了简化代码示例 , 我们 包含 了 一个称 为 c s a p p . h 的 头 文件, 它 包括 了 本 书 中使 用的 所有函数的头文件。c s a pp . h 头文件可以从 CS: APP 网站在线荻得。\n沁凶 练习题 8. 3 列出下面程序所有可能的输出序列:\nint main()\n{\ncode/ecf/waitprobO.c\nif (Fork() == 0) {\nprintf(\u0026ldquo;a\u0026rdquo;);\n}\nfflush(stdout);\nelse {\nprintf(\u0026ldquo;b\u0026rdquo;); fflush(stdout); waitpid(-1, NULL, O);\n}\nprintf(\u0026ldquo;c\u0026rdquo;); fflush(stdout); exit(O);\n5 . wa i t 函数\nwa i t 函数 是 wa i t p i d 函 数 的 简单版本:\ncode/ecflwaitprob0.c\n#include \u0026lt;sys/types.h\u0026gt;\n#include \u0026lt;s ys 压 a i t . h\u0026gt; pid_t wait(int *statusp);\n返回: 如 果 成 功, 则 为 子进程的 PID, 如 果 出错 , 则为 一1。\n调 用 wa i t ( \u0026amp;s 七a t u s ) 等价千调用 wa i t p i d (- l , \u0026amp;s t a t u s , O ) 。\n6 使用 wa 江 p i d 的示例\n因为 wa i t p i d 函 数有些复杂,看 几 个 例 子会有所帮助。图 8-18 展示了一个程序, 它使 用 wa i 七p 过 ,不 按 照 特定的顺序等待它的所有 N 个子进程终止。在第 11 行, 父进程创建 N 个子进程,在 第 12 行 , 每个子进程以一个唯一的退出状态退出。在我们继续讲解之前 ,请 确 认 你 已经理解为什么每个子进程会执行第 12 行 , 而父进程不会。\n在第 15 行, 父进程用 wa i t p i d 作 为 wh i l e 循 环 的 测 试 条 件,等 待它所有的子进程终止 。 因 为第一个参数是 — 1, 所以对 wa i t p i d 的 调 用 会 阻 塞 , 直 到 任意一个子进程终止。在每个子进程终止时, 对 wa i 七p i d 的 调 用 会 返回, 返回值为该子进程的非零的 PID。第1 6 行检查子进程的退出状态。如果子进程是正常终止的一 在此是以调用 e x i t 函数终止 的 那 么 父进程就提取出退出状态,把 它 输 出 到 s t d o u t 上。\ncode/ecflwaitpidl.c\nprintf(\u0026ldquo;child %d terminated normally with exit status=%d\\n\u0026rdquo;,\npid, WEXITSTATUS(status));\nelse\nprintf (\u0026ldquo;child %d terminated abnormally\\n\u0026rdquo;, pid);\n21 }\n22\nI* The only normal termination is if there are no more children *I\nif (errno != ECHILD)\nunix_error(\u0026ldquo;waitpid error\u0026rdquo;);\n26\n27 exit(O);\n28 }\ncode/ecf/waitpidl.c\n图 8-18 使用 wa i t pi d 函数不按照特定的顺 序回收僵死 子进程\n当回收了所有的子 进 程之后, 再 调用 wa i t p i d 就返回 — 1, 并且设 置 err n o 为\n£ C H IL D。第 24 行检查 wa i 七p i d 函数是正常终止的, 否则就输出一个错误 消息。在我们的 L in ux 系统上 运行 这个程序时 ,它 产生如下输出:\nlinux\u0026gt; ./ 甘 ai t p i d1\nchild 22966 terminated normally with exit status=lOO child 22967 terminated normally with exit status=101\n注意,程序不会按照特定的顺序回收子进程。子进程回收的顺序是这台特定的计算机系统的属性。在另一个系统上,甚至在同一个系统上再执行一次,两个子进程都可能以相反的顺序被回收。这是非确定性行为的一个示例,这种非确定性行为使得对并发进行推理非常困难。两种 可能的结果都同样是正确的, 作为一个 程序员, 你绝不可以 假设总是会出现某一个结果,无论多么不可能出现另一个结果。唯一正确的假设是每一个可能的结果都同样可能出现。\n图 8-19 展示了一个简单的改变,它消除了这种不确定性 , 按照父进程创建子进程的相同顺序来回收这些子进程。在第 11 行中,父进程按照顺 序存储了它的子进程的 PIO, 然后通过用适当的 PIO 作为第一个参数来调用 wa i t p i d , 按照同样的顺序来等待每个子进程。\ncode/ecflwaitpid2.c\nI* Parent reaps N children in order *I\ni = O;\nwhile ((retpid = waitpid(pid[i++], \u0026amp;status, 0)) \u0026gt; 0) {\n1 7 if (WIFEXITED(status))\nprintf(\u0026ldquo;child %ct terminated normally with exit status=%d\\n\u0026rdquo;,\nretpid, WEXITSTATUS(status));\nelse\nprintf (11child %d terminated abnormally\\n11, retpid) ;\n22 } 23 24 I* The only normal termination is if there are no more children *I 25 if (errno != ECHILD) 26 unix_error(11waitpid error\u0026rdquo;);\n27\n28 exit(O);\n29 }\ncode/ecf/waitpid2.c\n图 8-19 使用 wa i t pi d 按照创建子进程的顺序来回收这些 僵死子进程\n亡 练习题 8. 4 考虑下面的程序:\ncode/ecflwaitprobl.c\ncode/ecflwaitprob1.,\n这个程序会产生多少输出行? 这些输 出行的一种 可能的 顺 序是 什么? 8. 4. 4 让进程休眠\ns l e ep 函数将一个进程挂起一段指定的时间。\n#include \u0026lt;unistd.h\u0026gt;\nunsigned int sleep(unsigned int secs);\n返回: 还 要 休 眠 的 秒 数 。\n如果请求的时间量已经到了, s l e e p 返回 o, 否则返回还剩下的要休眠的秒数。后一种情况 是可能的,如果 因 为 s l e e p 函数被一个信号中断而过早地返回。我们将在 8. 5 节中详细讨论信号。\n我们会发现另一个很有用的函数是 p a u s e 函数,该 函 数让 调 用 函数 休 眠 ,直 到 该 进 程收到一 个信号。\n#include \u0026lt;unistd.h\u0026gt;\nint pause(void);\n总是返回一1。\n让目 练习题 8. 5 编写 一个 s l e e p 的包 装函数,叫做 s n o o z e , 带有下面的接口:\nunsigned int snooze(unsigned int secs);\nsnooze 函数和 s l e e p 函数的行 为 完全 一样 , 除 了它 会打印 出 一条 消息来描述进程 实际休眠了多长时间:\nlept for 4 of 5 secs.\n4. 5 加载并运行程序\ne xe cve 函数在当前进程的上下文中加载并运行一个新程序。\n#include \u0026lt;unistd.h\u0026gt;\nint execve(const char *filename, const char *argv[J, const char *envp []) ;\n如果成功,则不返回 ,如果错误,则 返回- 1 .\ne x e c v e 函 数 加 载 并 运行可执行目标文件 f i l e na me , 且 带 参 数 列 表 ar gv 和环境变量列表 e nv p。只有当出现错误时, 例 如 找 不 到 f i l e n a me , e x e c v e 才会返回到调用程序。所以 , 与 f or k 一次调用返回两次不同, e x e c v e 调 用 一 次并 从 不 返 回 。\n参 数 列 表是 用 图 8-20 中的数据结构表示的。ar g v 变量指向一个以 n ull 结尾的指针数组,其 中 每个指针都指向一个参数字符串。按照惯例, a r g v [ OJ 是 可 执行目标文件的名字。环 境变量的列表是由一个类似的数据结构表示的, 如图 8-21 所示。e nv p 变最指向一个以 n ull 结尾的指针数组, 其 中 每个指针指向一个环境变量字符串, 每个串都是形如\u0026rdquo; na me =v a l u e \u0026quot; 的 名 字—值 对 。\nI argv\nargv[] argv[O]\nI argv [1]\nargv [ar gc - 1]\n1 ·I\ni # \u0026ldquo;ls\u0026rdquo; \u0026ldquo;-lt\u0026rdquo;\n皿 L '\n佟I 8-20 参数列表的组织结构\nI \u0026ldquo;/ user / i ncl ude\u0026rdquo; j\n「一二 ,\nen vp[]\nenvp [OJ envp [1)\nj\nenvp [n - 1)\nNULL\n图 8- 21 环境变扯列表的组织结构\n在 e x e c v e 加载了 f i l e n a me 之后, 它调用 7. 9 节中描述的启动代码。启动代码设置栈, 并将控制传递给新程 序的主函数, 该主函数 有如下形式的原型\nint main(int argc,\n或者等价的\nint main(int argc,\nchar **argv,\nchar *argv [] ,\nchar **envp);\nchar *envp(]);\n当 ma i n 开始执行时,用 户栈的组织结构如图 8- 22 所示。让我们从栈底(高地址)往栈顶\n(低地址 )依次看一看。首先是 参数和环境字符串。栈往上紧随其后的是以 n u ll 结尾的指针数组 , 其中每个指针都指向栈中的一个环境变量字符串。全局变量 e n v ir o n 指向这些指针中的第一个 e n v p [ O J 。 紧随环境变量数组之后的是以 n u ll 结尾的 ar g v [ ]数组, 其中每个元素都指向栈 中的一个参数字符串。在栈的顶部是系统启动函数 l i b c _ s t ar t _ ma i n ( 见\n9 节)的栈帧。\n栈底\n以null结尾的环境变扯字符串\n. 以null结尾的命令行字符串\nenvp[n] == NULL\ne nvp [ n - 1 ]\n...\ne n vp [ O ] •\nar gv [ar g c ) = NUL L\nargv[argc-1]\n..\n. , argv[O]\nenviron\n(全局变量)\nargc\n(在寄存器%r d i 中 )\n·.\nl i b c _ s t ar t _ma i n 的栈帧\n栈顶\nma i n 的未来的栈帧\n图 8-22 一个新程序开始时,用 户 栈的典型组织结构\nma i n 函 数有 3 个 参 数 : l)argc, 它给出 ar g v [ ]数组中非空指针的数量, 2 ) ar g v ,\n指向 ar g v [ ] 数组中的第一个条目, 3 ) e n v p , 指 向 e nv p ( ] 数组中的第一个条目。\nLin ux 提供了几个函数来操作环境数组:\n#include \u0026lt;stdlib.h\u0026gt;\nchar *getenv(const char *name);\n返回: 若存在则为指 向 name 的 指 针 , 若 无匹 配的 , 则 为 NU LL。\ng e t e nv 函 数 在 环境 数组中搜索字符串 \u0026quot; na me =v a l ue \u0026quot; 。 如果找到了, 它 就 返回一个指向 va l ue 的指针,否 则 它 就 返回 NULL 。\n#include \u0026lt;stdlib.h\u0026gt;\nint setenv(const char *name, const char *newvalue, int overwrite);\n返回: 若成功 则 为 0 , 若 错 误 则 为 一1 。\nvoid unsetenv(const char *name);\n返回: 无。\n如果环境数组包含一个形如 \u0026quot; n a me = o l d v a l u e \u0026quot; 的 字 符 串 ,那 么 u n s e t e nv 会 删除它, 而 s e t e nv 会用 ne wv a l u e 代替 o l d v a l u e , 但是只有在 ov er wir 七e 非 零 时 才会这样。如果 na me 不存在,那 么 s e t e n v 就 把 \u0026quot; n a me =n e wv a l u e \u0026quot; 添加到数组中。\n豆 日 程序与进程\n这是一个适当的地方,停下来,确认一下你理解了程序和进程之间的区别。程序是 一堆代码和数据;程序可以作为目标文件存在于磁盘上,或者作为段存在于地址空间 中。进程是执行中程序的一个具体的实例;程序总是运行在某个进程的上下文中。如果 你想要 理解 f or k 和 e x e c ve 函数, 理解这个差 异是很 重要 的。f or k 函数在新的子进程中运行 相同的 程序, 新的子进程是父进程的一个复制品。e x e c v e 函数在 当 前进程的上下文中加栽并运行一个新的程序。它会覆盖当前进程的地址空间,但并没有创建一个新 进程。 新的程序仍然有 相同的 PIO, 并且继承了调用 e x e c v e 函数时已打开的 所有文件描述符。\n江 练习题 8. 6 编 写 一 个 叫 做 my e c h o 的 程 序, 打 印 出 它 的 命令行 参 数 和 环境 变 量。\n例如:\nlinux\u0026gt; ./myecho arg1 arg2 Command-ine arguments:\nargv[ OJ: myecho argv [ 1] : argl argv[ 2]: arg2\nEnvironment variables:\nenvp[ OJ: PWD=/usrO/droh/ics/code/ecf envp[ 1]: TERM=emacs\nenvp[25]: USER=droh\nenvp[26]: SHELL=/usr/local/bin/tcsh envp[27]: HOME=/usrO/droh\n4. 6 利 用 f or k 和 e x e c v e 运行 程序\n像 U nix s hell 和 We b 服务器这样的程序大量使用了 f or k 和 e xe c ve 函数。s hell 是一个交互型的应用级程序, 它 代表用户运行其他程序。最早的 shell 是 s h 程序,后 面出现了一些 变种,比 如 c s h、t c s h 、ks h 和 b a s h。s hell 执行一系列的读/求值( read / evaluate ) 步骤,然后终止。读步骤读取来自用户的一个命令行。求值步骤解析命令行,并代表用户运 行程序。\n图 8- 23 展示了一个简单 shell 的 ma i n 例 程 。 s hell 打印一个命令行提示符, 等待用户\n在 s t d i n 上输入命令行,然 后对这个命令行求值。\n#include \u0026ldquo;csapp.h\u0026rdquo;\n2 #define MAXARGS 128\n3\n4 I* Function prototypes *I\n5 void eval(char *cmdline);\n6 int parseline(char *buf, char **argv);\n7 int builtin_command(char **argv);\n8\n9 int main()\n10 {\n11 char cmdline[MAXLINE]; I* Command line *I\n12\n13 while (1) {\n14 I* Read *I\nprintf(\u0026quot;\u0026gt; \u0026ldquo;);\nFgets(cmdline, MAXLINE, stdin);\nif (feof (stdin))\nexit(O);\n19\n20 I* Evaluate *I\n21 eval (cmdline);\n22 }\n23 }\ncode/ecf/shellex. c\ncode/ecflshellex.c\n图 8-23 一个简单的 shell 程序的 ma i n 例程\n图 8- 24 展示了对命令行求值的代码。它的首要任务是调用 par s e l i ne 函数(见图8-25) , 这个函数解析了以空格分隔的命令行参数,并 构 造最终会传递给 e xe c ve 的 a r gv 向量。第 一个参数被假设为要么是一个内置的 s hell 命令名, 马上就会解释这个命令, 要么是一个可执行目标文件,会在一个新的子进程的上下文中加载并运行这个文件。\n如果最后一个参数是一个 \u0026quot; \u0026amp;.\u0026rdquo; 字符,那 么 p ar s e l i ne 返回 1, 表示应该在后台执行该程序( s h ell 不会等待它完成)。否则,它 返 回 0 , 表示应该在前台执行 这个 程序( shell 会等待它完成)。\n在解析了命令行之后, e v a l 函 数 调 用 b u i l t i n_ c o mma nd 函 数 ,该 函 数 检 查第一个命令 行 参数是 否是一个内置的 s he ll 命令。如果是,它 就 立 即 解 释这个命令,并 返 回值 1。否则 返回 0。简单的 s hell 只有一个内置命令——- qu i t 命令,该 命 令 会 终 止 s hell 。实际使用\n的 s h ell 有大僵的命令,比 如 p wd 、 j o b s 和 f g。\n如果 b ui l t i n_comma nd 返 回 o, 那 么 s hell 创 建 一 个 子 进 程 ,并 在 子 进程中执行所请求的程序。如果 用户要求在后台运行该程序, 那么 s hell 返回到循环的顶部, 等 待下一个命令 行。否则, s hell 使用 wa 江 p 卫 函数 等 待作 业 终 止 。 当作业终止时, s hell 就 开始下一轮迭代。\ncode/ecflshellex.c\n9 strcpy(buf, cmdline);\n1o bg = parseline (buf , argv) ;\nif (argv [OJ == NULL)\nreturn; I* Ignore empty lines *I\n13\nif (!builtin_command(argv)) {\nif ((pid = Fork()) == 0) { I* Child runs user job *I\nif (execve(argv[O], argv, environ)\u0026lt; 0) {\n7 printf (\u0026quot;%s: Command not found. \\n\u0026quot;, argv [O]);\nexit(O);\n19 }\n20 }\n21\nI* Parent waits for foreground job to terminate *I\nif (!bg) {\nint st at us·\nif (waitpid(pid, \u0026amp;status, 0) \u0026lt; 0)\nunix_error(\u0026ldquo;waitfg: waitpid err or\u0026rdquo;) ;\n27 }\nelse\nprintf(\u0026quot;%d %s\u0026quot;, pid, cmdline);\n30 }\n31 return;\n32 }\n33\nI* If first arg is a builtin command, run it and return true *I\nint builtin_command(char **argv)\n36 {\n37 if (!strcmp(argv[O], \u0026ldquo;quit\u0026rdquo;)) I* quit command *I\nexit(O);\nif (!strcmp(argv[O], \u0026ldquo;\u0026amp;\u0026rdquo;)) I* Ignore singleton \u0026amp; *I\nreturn 1;\nreturn O; I* Not a builtin command *I\ncod e/ecfrshellex.c\n图 8-24 eva l 对 shell 命令行求值\ncode/ecf/shellex.c I* parseline - Parse the command line andbuild the argv array *I\n2 int parseline(char *buf, char **argv)\n3 {\nchar *delim; int argc; int bg;\nI* Points to first space delimiter *I I* Number of args *I\nI* Background job? *I\n8 buf[strlen(buf)-1] =\u0026rsquo;\u0026rsquo;; I Replace trailing\u0026rsquo;\\n\u0026rsquo;with space *I 9 while (*buf \u0026amp;\u0026amp; (*buf ==\u0026rsquo;\u0026rsquo;)) I*Ignore leading spaces *I 10 buf++; 11 12 I* Build the argv list *I 13 argc = O; 14 while ((delim = strchr (buf,\u0026rsquo;\u0026rsquo;))) { 15 argv [argc++] = buf ; 16 *delim = \u0026rsquo; \\ O\u0026rsquo; · 1 7 buf = delim + 1· 18 while (*buf \u0026amp;\u0026amp; (*buf ==\u0026rsquo;\u0026rsquo;)) I*Ignore spaces *I 19 buf++· 20 } 21 argv [argc] = NULL; 22 23 if (argc == 0) I* Ignore blank line *I 24 return 1; 25 26 I* Should the job run in the background? *I 27 if ((bg\u0026quot;\u0026rsquo;(*argv[argc-1] \u0026ldquo;\u0026rsquo;\u0026rdquo;\u0026rsquo;\u0026rsquo;\u0026amp;\u0026rsquo;))\u0026rsquo;\u0026quot;\u0026lsquo;0) 28 argv [\u0026ndash;argc] \u0026ldquo;\u0026lsquo;NULL; 29 30 return bg;\n31 }\n图 8-25 par s e li ne 解析 shell 的一个输入行\ncodelecf/shellex.c\n注意 , 这个简单的 s h ell 是有缺陷的, 因为它并 不回收它的后台子进程。修改这个缺陷就要求使用信号,我们将在下一节中讲述信号。\n8. 5 信号\n到目前为止对异常控制流的学习中,我们已经看到了硬件和软件是如何合作以提供基 本的低层异常机制的。我们也看到了操作系统如何利用异常来支持进程上下文切换的异常 控制流形式 。在本节中, 我们将研究一种更高层 的软件形式的异常, 称为 L in ux 信号,它允许进程和内核中断其他进程。\n一个信号就是一条小消息,它 通知进程系统中发生了一个某种类型的事件 。比如,图 8-26\n展示了 L in u x 系统上支持的 30 种不同类型的信号。\n每种信号类型都对应于某种系统事件。低层的硬件异常是由内核异常处理程序处理的,正 常情况下,对用户进程而言是不可见的。信号提供了一种机制,通知用户进程发生了这些异常。比如,如果一个进程试图除以0, 那么内核就发送给它一个SIGFPE信号(号码8)。如果一个进\n程执行一条非法指令, 那么内核就发送给它一个SIGILL 信号(号码4) 。如果进程进行非法内存引用,内 核就发送给它一个SIGSEGV信号(号码11)。其他信号对应千内核或者其他用户进程中较高层的软件事件。比如, 如果当进程在前台运行时, 你键入 Ctrl+ CC也就是同时按下 Ctrl 键和 C 键), 那么内核就会发送一个 SIGINT 信号(号码2) 给这个前台进程组中的每个进程。一个进程可以通过向另 一个进程发送一个 SIGKILL 信号(号码9) 强制终止它。当一个子进程终止或者停止时,内核会发送一个SIGCHLD 信号(号码17) 给父进程。\n序号 名称 默认行为 相应事件 1 SIGHUP 终止 终端线挂断 2 SIGINT 终止 来自键盘的中断 3 SIGQUIT 终止 来自键盘的退出 4 SIGILL 终止 非法指令 5 SIGTRAP 终止并转储内存(l) 跟踪陷阱 6 SIGABRT 终止并转储内存(!) 来自 a b or t 函数的终止信号 7 SIGBUS 终止 总线错误 8 SIGFPE 终止并转储内存° 浮点异常 9 SIGKILL 终止© 杀死程序 10 SIGUSRI 终止 用户定义的信号1 11 SIGSEGV 终止并转储内存° 无效的内存引用(段故障) 12 SIGUSR2 终止 用户定义的信号2 13 SIGPIPE 终止 向一个没有读用户的管道做写操作 14 SIGALRM 终止 来自a l a r m 函数的定时器信号 15 SIGTERM 终止 软件终止信号 16 SIGSTKFLT 终止 协处理器上的栈故隐 17 SIGCHLD 忽略 一个子进程停止或者终止 18 SIGCONT 忽略 继续进程如果该进程停止 19 SIGSTOP 停止直到下一个 SIGCONT@ 不是来自终端的停止信号 20 SIGTSTP 停止直到下一个SIGCONT 来自终端的停止信号 21 SIGTTIN 停止直到下一个 SIGCONT 后台进程从终端读 22 SIGTTOU 停止直到下一个 SIGCONT 后台进程向终端写 23 SIGURG 忽略 套接字上的紧急情况 24 SIGXCPU 终止 CPU 时间限制超出 25 SIGXFSZ 终止 文件大小限制超出 26 SIGVTALRM 终止 虚拟定时器期满 27 SIGPROF 终止 剖析定时器期满 28 SIGWINCH 忽略 窗口大小变化 29 SIGIO 终止 在某个描述符上可执行 J/0 操作 30 SIGPWR 终止 电源故障 图 8-26 L in ux 信 号\n汪: O 多年前, 主存是用一种称为磁 芯存 储器( core memory) 的技术来实现的。“转储 内存\u0026rdquo; ( dump ing core ) 是 一个历史术语 ,意 思是把代码和数据内存 段的映像写到磁盘上。\n@)这个信 号既 不能被捕获,也 不能被忽略。\n(来源: man 7 signal。数据来自 Linux Found ation. )\n5. 1 信号术语\n传送一个信号到目的进程是由两个不同步骤组成的:\n发送信号。内核 通过更新目的进程上下文中的 某个状态, 发送(递送)一个信号给目的进程。发送信号可以有如下两种原因: 1) 内 核检测到一个系统事件, 比如除零错误或者子进程终止。2) 一个进程调用了 ki ll 函数(在下一节中讨论),显 式地要求内核发送一个信号给目的进程。一个进程 可以发 送信号给它自己 。 ·接收信号。当目的进程被内核强迫以某种方式对信号的发送做出反应时,它就接收了 信号。进程可以忽略这个信号,终 止或 者 通过执行一个称为信号处理 程序( sig nal han­ dler ) 的用户层函数捕获这个信号。图 8-27 给出了信号处理程序捕获信号的基本思想。\n( I ) 进程接 I\ncurr\n收到信号\nIn,.,\n( 3 ) 信号处理程序运行\n图 8- 27 信号处理。接收到信号会触发控制转移到信号处理程序 。在信号处理程序完成处理之 后, 它将控制返回给被中断的程序\n一个发出而没有被接收的信号叫做待处理信号( pe nd ing s ig n al) 。在 任何 时 刻 , 一种类型至多 只会有一个待处理信号。如果一个进程有一个类型为 K 的 待 处 理 信 号 ,那 么 任何接下 来发送到这个进程的类型为 K 的 信 号 都 不会排队等待;它 们 只 是 被 简单地丢弃。一个进程 可 以 有 选择性地阻塞接收某种信号 。当一种信号被阻塞时, 它 仍 可 以 被发送, 但是产生的待 处 理信号不会被接收, 直到 进程 取 消对这种信号的阻塞。\n一个待处理信号最多只能被接收一次。内核为每个进程在 p e n d i n g 位向量中维护着待处 理信号的集合, 而在 b l o c ke d 位向量e 中维护着被阻塞的信号集合。只要传送了一个类型为 K 的 信 号 ,内 核 就 会 设 置 p e n d i n g 中的第 k 位, 而 只 要 接 收 了 一 个类型为 K 的信号 ,内 核 就 会 清 除 p e n d i ng 中 的 第 K 位。\n5. 2 发送信号\nU nix 系统提供了大量向进程发送信号的机制。所有这些机制都是基于进程组( pro cess gro u p ) 这个概念的。\n进程组\n每个进程都只属于一个进程组, 进程组是由一个正整数进程组 ID 来标识的。ge t pgr p\n函数返回当前进程的进程组 ID :\n#include \u0026lt;unistd.h\u0026gt;\npid_t getpgrp(void);\n返回: 调 用进 程的 进 程 组 ID。\n默认地,一 个 子 进程和它的父进程同属千一个进程组。一个进程可以通过使用 s e t ­ pg i d 函数来改变自己或者其他进程的进程组:\n#include \u0026lt;unistd.h\u0026gt;\nint setpgid(pid_t pid, pid_t pgid);\n返回: 若 成 功 则 为 0 , 若铸 误 则为 一1。\ns e 七p g i d 函 数 将 进程 p 过 的进程组改为 pg i d。如果 p i d 是 o, 那么就使用当前进程\n8 也称为信号掩码( sig na l ma s k ) 。\n的 PID。如果 pg过 是 o, 那么就用 pi d 指定的进程的 PID 作为进程组 ID。例如, 如果进程 1521 3 是调用进程, 那么\nsetpgid(O, O);\n会创建一 个新的进程组,其 进程组 ID 是 15213, 并且把进程 15213 加入到这个新的进程\n组中。\n用/ b i n / k i l l 程序发 送信号\n/ b i n / k过1 程序可以向另外的进程发 送任意的 信号。比如,命 令\nlinux\u0026gt; /bin/kill -9 15213\n发送信号 9(SIGKIL L) 给进程 15213。一 个为负的 PID 会导致信号被发送到进程组 PID 中的每个进程 。比如,命 令\nlinux\u0026gt; /bin/kill -9 -15213\n发送一个 SIGKILL 信号给进程组 15213 中的每个 进程。注意, 在此我们使用完整路径/\nbi n / k i ll , 因为有些 U nix s h ell 有自己内 置的 k i ll 命令。\n从键盘发送信号\nUnix shell 使用作业( jo b ) 这个抽象概念来表示为对一条命令行求值而创建的进程。在任何时刻, 至多只有一个前台作业 和 0 个或多个后台作业。比如 , 键入\nlinux\u0026gt; ls I sort\n会创建一个由两个进程组成的前 台作业, 这两个进程是通过 U n ix 管道连接起来的: 一个进程运 行 l s 程序, 另一个运行 s or t 程序。s h ell 为每个作 业创建 一个独立的 进程组。进程组 ID 通常取 自作 业中父进程中的一个 。比如, 图 8-28 展示了有一个前台作业和两个后台作业的 s h e ll 。前台作业中的父进程 PID 为 20 , 进程组 ID 也为 20。父进程创建两个子进程,每 个也都是进程组 20 的成员 。\np i d = 21 pid=22\npgid=20 pgid=20\n-\u0026mdash;\u0026mdash;\u0026mdash;\u0026mdash;\u0026mdash;\n前台进程组20\n图 8-28 前 台 和后 台 进程组\n在键盘上输入 Ctrl + C 会导致内核发送一个 SIGINT 信号到前台进程组中的每个进程。默认情况下 ,结果 是终止前台作业。类似地, 输入 Ctrl + z 会发送一个 SIGTST P 信号到前台进程组中 的每个进程。默认情 况下, 结果是停止(挂起)前台作业。\n用 k i l l 函数发 送信号\n进程通过调用 K过 1 函数发送信号 给其他进程(包括它们 自己)。\n#include \u0026lt;sys/types.h\u0026gt;\n#include \u0026lt;signal.h\u0026gt;\nint kill(pid_t pid, int sig);\n返回: 若 成 功 则 为 o, 若 错 误 则 为 一1。\n如果 p i d 大于零, 那么 k i ll 函数发送信号号码 s i g 给进程 p i d 。如果 p i d 等千零 , 那么k i ll 发送信号 s i g 给调用进 程所在进程组中的每个 进程, 包括调用进程自己。如果 p i d 小千零, k i ll 发送信号 s i g 给进程组 I pid I ( p i d 的绝对值)中的每个进程。图 8- 29 展示了一个示例, 父进程用 ki ll 函数发送 SIGK ILL 信号给它的 子进程。\ncode/ecf/kill.c\n#include \u0026ldquo;csapp.h\u0026rdquo;\n2\n3 int main()\n4 {\ns pid_t pid;\n6\n7 I* Child sleeps until SIGKILL signal received, then dies *I\n8 if ((pid = Fork()) == 0) {\n9 Pause(); I* Wait for a signal to arrive *I\n10 printf(\u0026ldquo;control should never reach here!\\n\u0026rdquo;);\n11 exit(O);\n12 }\n13\n14 f* Parent sends a SIGKILL signal to a child *I\nKill (pid, SIGKILL) ;\nexit(O);\n17 }\ncode/ecf/kill.c\n图 8-29 使用 K过 1 函数发送信号 给子进 程\n用 a l a rm 函数发送信号\n进程可以通过调用 a l a r m 函数向它自己发送 S IGALRM 信号。\na l ar m 函数安排内核在 s e c s 秒后发送一个 S IGALRM 信号给调用进程。如果 s e cs 是零, 那么不会调度安排新的闹 钟( a lar m ) 。在任何情况下, 对 a l ar m 的调用都将取消任何 待处理的( pe nd in g ) 闹钟, 并且返回任何待处理的闹钟在被发送前还剩下 的秒数(如果这次对 a l ar m 的调用没有取消它的 话); 如果没有任何待处理的闹钟,就返 回零。\n5. 3 接收信号\n当内核把进程 p 从内核模式切换到用户模式时(例如, 从系统调用返回或是完成了一次上下文切换), 它会检查进程 p 的未被阻塞的待处理 信号的集合 ( p e nd i ng \u0026amp; ~b l o c ke d ) 。如果这个集合 为空(通常情况下), 那么内核将控制 传递到 p 的逻辑控制流中的下一条指令 (J next ) 。 然而 , 如果集合是非空的 , 那么内核选择集合中的某个信号 k ( 通常是最小的 k ) , 并且强制 p 接收信号 k 。收到这个信号会触发进 程采取 某种行为。一旦进程完成了这个行为,那 么控制就传递回 p 的逻辑控制流中的下一条指令( J next ) 。 每个信号类型都有一个预定义的默认行为,是下面中的一种:\n进程终止。\n进程终止并转储内存。\n进程停止(挂起)直到被 SIG CO NT 信号重启。\n进程忽略该信号。\n图 8- 26 展示了与每个信号类 型相关联的默认行为。比 如, 收到 S IG K IL L 的默认行为就是终止 接收进程。另外, 接收到 S IGCH LD 的默认行 为就是忽略这个信号。进程可以 通过使用 s i g na l 函数修改和信号相关联的默认行为。唯一的 例外是 SIGS T OP 和 SIG K I L L , 它们的默认行为是不能修改的。\n#include \u0026lt;signal.h\u0026gt;\ntypedef void (*sighandler_t)(int);\nsighandler_t signal(int signum, sighandler_t handler);\n返回: 若 成 功则 为 指 向 前 次 处 理 程 序 的 指 针 , 若 出错 则 为 SIG_ERR C不设 置 err no )。\ns i g na l 函数可以通过下列 三种方法之 一来改变和信号 s i g n um 相关联的行 为:\n如果 h a n d l er 是 SIG _IG N , 那么忽略类型为 s i g num 的信号。 如果 ha nd l er 是 S IG _DF L , 那么类型为 s i g nu m 的 信号行为恢复为默认行 为。\n否则, ha ndl e r 就是用户定义的函数的地址,这个 函数被称为信 号处理 程序,只 要进程接收到一个类型为 s i g nwn 的信号, 就会调用这个程序。通 过把处理程序的 地址传递到 s i gna l 函数从而改变默认行为,这 叫做设置信 号处理 程序( installing the han­\ndler) 。调用信号处理程序被称为捕 获信号。执行信号处理程序被称 为处理信号。\n当一个 进程捕 获了一个类型为 K 的信号时, 会调用为信号 k 设置的处理程序, 一个整数参数被设置 为 K。 这个参数允许同一个处理函数捕获不同类 型的信号。\n当处理程序执行它 的 r e t ur n 语句时, 控制(通常)传递回控制流中进程被信号 接收中断位置处的指令。我们说“通常”是因为在某些系统中,被中断的系统调用会立即返回一 个错误。\n图 8-30 展示了一个 程序,它 捕获用户在键盘上输入 C t rl + C 时发送的 S IG I NT 信号。SIGINT 的默认行为是立 即终止该进程。 在这个示例中, 我们将默认行为修改为捕获信号,输出一条消息,然后终止该进程。\n信号处理程序可以被其 他信号处理程序中断, 如图 8-31 所示。在这个例子中, 主程\n,, 序捕获到信号 s\u0026rsquo; 该 信 号会中断主程序, 将控制转移 到处理程序 S。S 在运行时, 程序捕获信号 t #- s , 该信号会中断 s, 控制转移到 处理程序 T 。当 T 返回时 , S 从它被中断的地方继续 执行。最 后, S 返回, 控制传送回主程序 , 主程序从它 被中断 的地方继续执行。\n#include \u0026ldquo;csapp.h\u0026rdquo;\n2\ncode/ecf/sigint.c\n3 void sigint_handler(int sig) I* SIGINT handler *I\n4 {\ns printf(\u0026ldquo;Caught SIGINT!\\n\u0026rdquo;);\n6 exit(O);\n7 }\n8\n9 int main()\n10 {\nI* Install the SIGINT handler *I\n2 if (signal(SIGINT, sigint_handler) == SIG_ERR)\n3 uni.x_error(\u0026ldquo;signal error\u0026rdquo;);\n14\n15 pause(); I* Wait for the receipt of a signal *I\n16\n17 return O·\n18 }\ncode/ecf/sigint.c\n图 S :111 一 个用信号处理程序捕获 SIGINT 信号 的 程 序\n主程序\n( I ) 程序捕获信号s\nCWT\n主程序继续执行 I\u0026quot;°\u0026rsquo;\u0026rsquo;\n( 2 ) 控制信号传递给处理程序S\n处理程序S 处理程序T\n图 8飞 l 信号处理程序可以被其他信号处理程序中断\n让 练习题 8. 7 编写 一个叫做 s no o z e 的程序 , 它 有 一个命令行参 数, 用 这个参数调用练 习题 8. 5 中的 s n o o z e 函数 , 然 后终 止。编写 程 序, 使 得用 户 可以 通过在键 盘上输入 C t rl + C 中断 s n o o z e 函 数。 比如:\nlinux\u0026gt; ./ snooze 5\nCTRL+C\nSlept for 3 of 5 secs. linux\u0026gt;\n8. 5. 4 阻塞和解除阻塞信号\nUser hi t s Cr t l +C after 3 seconds\nLinux 提供阻塞信号的隐式和显式的机制:\n隐式阻塞机制。内核默认阻塞任何当前处理程序正在处理信号类型的待处理的信号。 例如,图 8-31 中 , 假设程序捕获了信号 s\u0026rsquo; 当前正在运行处理程序 S 。如果发送给该进程另 一 个 信 号 s , 那 么 直 到 处 理 程序 S 返回, s 会 变成待处理而没有被接收。\n显式阻寒机制。应用程序可以使用 s i g p r o c ma s k 函 数 和它的辅助函数,明 确地阻塞和解除阻塞选定的信号。\n#include \u0026lt;signal.h\u0026gt;\nint sigprocmask(int how, const sigset_t *set, sigset_t *oldset); int sigemptyset(sigset_t *set);\nint sigfillset(sigset_t *set);\nint sigaddset(sigset_t *Set, int signum); int sigdelset(sigset_t *set, int signum);\nint sigismember(const sigset_t *set, int signum);\n返回: 如 果 成 功 则为 0\u0026rsquo; 若 出错 则为 - 1。\n返回: 若 s i gnum 是 set 的 成 员 则 为 1, 如 果 不是 则 为 0\u0026rsquo; 若 出错 则 为 - 1。\ns i g p r o c ma s k 函数改变当前阻塞的信号集合C 8. 5. 1 节中描述的 block ed 位向最)。具体的行为依赖 于 h o w 的 值 :\nSIG_BLOCK: 把 s e t 中的信号添加到 b l o c ke d 中( b l o c ke d=b l o c ke d I s e t ) 。SIG_ UNBLOCK: 从bl oc ked 中删除 s e t 中的信号( b l o c ke d =b l o c ke d \u0026amp;\u0026ndash;se t ) 。SIG_SETMASK: bl oc k=se t 。\n如果 o l d s e t 非空, 那么 b l o c ke d 位向量之前的值保存在 o l d s e t 中。\n使用下述函数对s e t 信号集合进行操作: s i ge mpt ys e t 初始化 s e t 为空集合。s i g f i ll s e t 函数把每个信号都添加到 s e t 中。s i ga dd s e t 函数把s i g nurn 添加到 s e t , s i gde l s e 七从 s e t 中删除 s i gnurn, 如果 s i g nurn 是 s e t 的成员 , 那么 s i gi s me mber 返回 1, 否则返回0。\n例如, 图 8-32 展示了如何用 s i gpr o c ma s k 来临时阻 塞接收 S IG INT 信号。\nsigset_t mask, prev_mask;\n2\n3 Sigemptyset(\u0026amp;mask);\n4 Sigaddset(\u0026amp;mask, SIGINT);\n5\nI* Block SIGINT a 丑 d save previous blocked set *I Sigprocmask(SIG_BLOCK, \u0026amp;mask, \u0026amp;prev_mask); 8 : // Code region that will not be interrupted by SIG INT\n9 I* Restore previous blocked set, unblocking SIGINT *I\n10 Sigprocmask(SIG_SETMASK, \u0026amp;prev_mask, NULL);\n11\n5. 5 编写信号处理程序\n图 8- 32 临时阻塞接收 一个信号\n信号处理是 L in ux 系统编程最棘手的一个问题。处理程序有几个属性使得它们很难推理分析: 1) 处理程序与主程序并发 运行 , 共享同样的全局变量, 因此可能 与主程序和其他处理程序互相干扰; 2 ) 如何以及何时接收信号的规则常常有违人的直觉; 3 ) 不同的系统有不同的信号处 理语义。\n在本节中 ,我们 将讲述这些问题, 介绍编写安全、正确和可移植的信号处理程序的一些基本规则 。\n安全的信号处理\n信号处理程序很麻烦 是因为它们 和主程序以及其他信号处理程序并 发地运行 , 正如我们在图 8-31 中看到的那样。如果处理程序和主程 序并发地访问同样的全局数据结构, 那\n么结果可能就不可预知,而且经常是致命的。\n我们会在第 12 章详细讲述并 发编程。这里我们的目标是给你一些保守的编写处理程序的原则, 使得这些处理程序能安全地并 发运行 。如果你忽视这些原则, 就可能有引入细微的并发错误的风险。如果有这些错误,程序可能在绝大部分时候都能正确工作。然而当 它出错的时候, 就会错得不可预测和不可重复 , 这样是很难调试的。一定要防患于未然!\nGO. 处理程序要尽可能简单。避免麻烦的最好方法是保持处理程序尽可能小和简单。例如, 处理程序可能只是简单地设置全局标志并立即返回; 所有与接收信号相关的处理都 由主程序执行 , 它周期性地检查(并重置)这个标志。 Gl. 在处理程序中只调用异步 信号安全的函数。所谓异步信号安全的函数(或简称安全的函数)能够被信号处理程序安全地调,用原 因有二: 要么它是可重入的(例如只访问局部变量, 见 12. 7. 2 节),要么它不能被信号处理程序中断。图 8-33列出了 Linux 保证安全的系统级函数。注意, 许多常见的函数(例如pr i ntf 、s pr i nt f 、mall oc 和 e xi t )都不在此列。 _Exit fexecve poll sigqueue exit fork posix_trace_event sigset abort fstat pselect sigsuspend accept fstatat raise sleep access fsync read sockatmark aio_error ftruncate readlink socket aio_return futimens readlinkat socketpair aio_suspend getegid recv stat alarm geteuid recvfrom symlink bind getgid recvmsg symlinkat cfgetispeed getgroups rename tcdrain cfgetospeed getpeername renameat tcflow cfsetispeed getpgrp rmdir tcflush cfsetospeed getpid select tcgetattr chdir getppid sem_post tcgetpgrp chmod getsockname send tcsendbreak chown getsockopt sendmsg tcsetattr clock_gettime getuid sendto tcsetpgrp close kill setgid time connect link setpgid timer_getoverrun creat linkat setsid timer_gettime dup listen setsockopt timer_settime dup2 lseek setuid times execl lstat shutdo\u0026rsquo;\\ffi umask execle mkdir sigaction 皿 ame execv mkdirat sigaddset unlink execve mkfifo sigdelset unlinkat faccessat mkfifoat sigemptyset ut i me fchmod mknod sigfillset utimensat fchmodat mknodat sigismember utimes fcho= open signal wait fchownat openat sigpause waitpid fcntl pause sigpending write fdatasync pipe sigprocmask 图 8-33 异步信号安全的函数(来源: ma n 7 signal。数据来自 Lin ux Foundation)\n信号处理程序中产生输出唯一安全的方法是使用 wr i t e 函数(见10. 1 节)。特别地, 调用 pr i n 七f 或 s p r i n t f 是 不安全的。为了绕 开这个不幸的限制, 我们开发一些 安全的函数,称为 S IO ( 安全的 I/ 0 ) 包,可 以用来在信号处理程序中打印简单的消息。\n#include \u0026ldquo;csapp.h\u0026rdquo;\nssize_t sio_putl(long v); ssize_t sio_puts(char s[]);\nvoid sio_error(char s[]);\n返回: 如 果 成 功则 为 传 送 的 字 节数 ,如 果 出错 , 则 为 一1。\n返回: 空。\nsio p u t l 和 s i o p u t s 函数分别向标准输出传送一个 l o n g 类型数和一个字符串。\nsio e rr or 函数打印一条错误消息并终止。\n图 8- 34 给出的是 SIO 包的实现, 它使用了 c s a p p . c 中两个私有的可重入函数。第 3 行的 s i o_ s 七r l e n 函数返回字符串 s 的长度。第 10 行的 s i o _ l 七o a 函数基于来自[ 61] 的it o a 函数, 把 v 转换成它的基 b 字符串表示, 保存在 s 中。第 1 7 行的_ e x i t 函数是 e x 江的一个异步信号安全的变种。\ncodelsrc/csapp.c\nssize_t sio_puts(char s[]) I* Put string *I\n2 {\n3 return write(STDOUT_FILENO, s, sio_strlen(s));\n4 }\n5\n6 ssize_t sio_putl(long v) I* Put long *I\n7 {\n8 char s (128) ;\n9\n10 sio_ltoa(v, s, 10); I* Based on K\u0026amp;R itoa() *I\n11 return sio_puts(s);\n12 }\n13\n14 void sio_error(char s[]) I* Put error message and exit *I\n15 {\n16 sio_puts(s);\n17 _exit(!);\n18 }\n图 8-3.J 信号处理程序的 SIO C安全 I/0) 包\ncodelsrc/csapp.c\n图 8-3 5 给出了图 8- 30 中 S IG I NT 处理程序的一个 安全的版本。\ncode/ecflsigintsafe.c\n#include \u0026ldquo;csapp.h\u0026rdquo;\n2\n3 void sigint_handler(int sig) I* Safe SIGINT handler *I\n4 {\ns Sio_puts(\u0026ldquo;Caught SIGINT!\\n\u0026rdquo;); I* Safe output *I\n6 _exit(O); I* Safe exit *I\n7 }\ncode/ecf/sigien.ctsaf\n图 8-35 图 8-30 的 SIGINT 处理程序的 一个安全版本\nG2. 保存和恢复 err no 。许多 Lin ux 异步信号安全的函数都会在出错返回时设置e rr no 。 在处理 程序中调用 这样的函数可能会干扰 主程序中其他依赖于 e r r no 的部分。解决方法是在进入处 理程序时把 err no 保存在一个局部 变量中 , 在处理程序返回前恢复它。注意,只有在处理程序要返回时才有此必要。如果处理程序调用\n_e x i t 终止该进程 , 那么就不需要这样做 了。\nG3. 阻塞 所有的信 号, 保护对共享全局数 据结构的访问。如果处理程序和主程序或其他处理程序共享一个全局数据结构, 那么在访问(读或者写)该数据结构时, 你的处理程序和主程序应该暂时阻塞所有的 信号。这条规则的原因 是从主程序访问一个数据结构 d 通常需要一系列的指令 , 如果指令序列被访问 d 的处理程序中断, 那么处理程序可能会发现 d 的状态不一致, 得到不可预知的结果。在访问 d 时暂时阻塞信号保证了处理程序不会中断该指令序列。\nG4. 用 vol a t i l e 声明全 局变量 。考虑一个处理程序和一个mai n 函数, 它们共享一个全局变 量 g。处理程序更新 g , mai n 周期性地读 g。对于一个优化编译器而言, mai n 中 g 的值看上去从来没有变化过, 因此使用缓存在寄存器中g 的副本来满足对g 的每次引用是很安全的。如果这样, mai n 函数可能永远都无法看到处理程序更新过的值。\n可以用 vol a已 l e 类型限定符来定义一个变量,告 诉编译器不要缓存这个量变。例如:\nvolatile int g;\nvol a巨 l e 限定符强迫编译器每次在代码中引用 g 时, 都要从内存中读取 g 的值。一般来说 , 和其他所有共享数据结构一样, 应该暂时阻塞信号, 保护每次对全局变量的访问。\nGS. 用 s i g_a t omi c _ 七 声明标志 。在常见的处理程序设计中, 处理程序会写全局标志来记录收到了信 号。主程序周期性 地读这个标志, 响应信号,再 清除该标志。对千通过这种方式 来共享的标志, C 提供一种整型数据类型 s i g _ a t omi c _ 七,对它的读和写保证会是原子的(不可中断的), 因为可以用 一条指令来实现它们:\nvolatile sig_atomic_t flag;\n因为它们是不 可中断的,所 以可以安全地读和写 s i g _a t omi c _ t 变量,而不需要暂时阻塞信号。注意 , 这里对原子性的保证只适用于单个的读和写, 不适用于像f l a g + + 或 fl a g=fl a g +l O 这样的更新, 它们可能需 要多条指令。\n要记住 我们这里讲述的规则是保守的 ,也 就是说它们不总是严格必需的。例如,如果你知道处理 程序绝对 不会修改 err no , 那么就不需要保存和恢复 err no 。或者如果你可以证明 pr i nt f 的实例都不会被处理 程序中断 , 那么在处理程序中 调用 pr i n t f 就是安全的。对共享全局数据结构的访问也是同样。不过,一般来说这种断言很难证明。所以我们建议 你采用保守的方法,遵循这些规则,使得处理程序尽可能简单,调用安全函数,保存和恢\n复 er r n o , 保护对共享数 据结构的访问, 并使用 v ol a t i l e 和 s i g _a t omi c _ t 。\n正确的信号处理\n信号的一个与直觉不符的方面是未处理的信号是不排队的。因为 p e nd i ng 位向量中每种类型的 信号只对应有一位, 所以每种类型最多 只能有一个未处理的信号。因此,如果两个类型 k 的信号发送 给一个目的进程,而 因为目的进程当前正在执行信号 k 的处理程序, 所以信号 k 被阻塞了, 那么第二个信号就简单地被丢 弃了;它 不会排队。关键思想是 如果存在一个未处理的信号就表明至少有一个 信号到达了。\n要了解这样会如何影响正确性, 来 看 一个简单的应用, 它 本 质 上 类似于像 sh ell 和\nWeb 服务器这样的真实程序。基本的结构是父进程创建一些子进程,这 些子进程各自独立运行一 段时间, 然后终止。父进程必须回收子进程以避免在系统中留下僵死进程。但是我们还 希 望 父 进 程 能 够 在 子 进 程 运 行 时 自 由 地 去 做 其 他 的 工 作。所以, 我 们 决 定 用\nSIGCHLD 处 理程序来回收子进程, 而不是显式地等待子进程终止。(回想一下, 只 要 有一个子进程终止或者停止, 内 核 就会发 送一个 SIGCHLD 信号给父进程。)\n图 8-36 展示 了我们的初次尝试。父进程设 置了一 个 SIGCH LD 处理程序, 然后创建\ncode/ecf/signall .c\nI* WARNING: This code is buggy! *I\n2\n3 void handlerl(int sig)\n4 {\n5 int olderrno = errno;\n6\n7 if ((waitpid(-1, NULL, 0)) \u0026lt; 0)\n8 sio_error(\u0026ldquo;waitpid er or\u0026rdquo;) ;\nSio_puts(\u0026ldquo;Handler reaped child\\n\u0026rdquo;);\nSleep(!);\nerrno = olderrno;\n12 }\n13\n14 int main()\n15 {\nint i, n;\nchar buf[MAXBUF];\n18\nif (signal(SIGCHLD, handler!)== SIG_ERR)\nunix_error (\u0026ldquo;signal error\u0026rdquo;);\n21\n22 I* Parent creates children *I\n23 for (i = 0; i \u0026lt; 3; i ++) {\n24 if (Fork() == 0) {\nprintf(\u0026ldquo;Hello from child %d\\n\u0026rdquo;, (int)getpid());\nexit(O);\n27 }\n28 }\n29\nI* Parent waits for terminal input and then processes it *I\nif ((n = read(STDIN_FILENO, buf, sizeof(buf))) \u0026lt; 0)\nun i x _err or (\u0026ldquo;read\u0026rdquo;) ;\n33\nprintf(\u0026ldquo;Parent processing input\\n\u0026rdquo;);\nwhile (1)\n36\n37\n38 exit (0);\n39 }\ncod/eecflignall.c\n图8-36 signa ll : 这个程序是有缺陷的 , 因 为它假设信号是排队的\n了 3 个子进程。同时, 父进程等待来自终端的一个输入行,随 后 处 理 它 。 这个处理被模型化 为一个无限循环。当每个子进程终止时,内 核 通过发送一个 S IG C H LD 信号通知父进程。父进程捕获这个 SIG C H L D 信号, 回 收 一 个 子 进程, 做 一 些 其他的清理工作(模型化为 s l e e p 语句), 然后返回。\n图 8- 36 中的 s i g na l l 程序看起来相当简单。然而, 当 在 L in u x 系统上运行它时, 我\n们得到如下输出:\nlinux\u0026gt; ./signal1\nHello from child 14073 Hello from child 14074 Hello from child 14075 Handler reaped child Handler reaped child CR\nParent processing input\n从输出中我们 注意到,尽 管 发送了 3 个 SIGC H LD 信号给父进程,但 是其中只有两个信号被接收了,因此父进程只是回收了两个子进程。如果挂起父进程,我们看到,实际上子进程14075 没有被回收,它成 了一 个僵 死 进程(在p s 命令的输出中由字符串 \u0026quot; de f unc t \u0026quot; 表明):\nCtrl+Z Suspended linux\u0026gt; ps t\nPID TTY STAT TIME COMMAND 14072 pts/3 T O: 02 . /signall 14075 pts/3 Z 0:00 [signall] \u0026lt;defunct\u0026gt; 14076 pts/3 R+ 0:00 ps t 哪里出错了呢?问题就在于我们的代码没有解决信号不会排队等待这样的情况。所发 生的 情 况 是 : 父进程接收并捕获了第一个信号。当处理程序还在处理第一个信号时, 第二个 信 号 就 传 送并 添 加 到了待处理信号集合里。然而,因 为 SIG C H LD 信号被 SIG C H LD 处理程序阻塞了,所 以 第二个信号就不会被接收。此后不久,就 在 处 理 程序还在处理第一个信 号 时 ,第 三个信号到达了。因为已经有了一个待处理的 S IG C H L D , 第三个 S IG C H LD 信号 会被 丢弃。一段时间之后, 处理程序返回,内 核 注意到有一个待处理的 S IG C H LD 信号 , 就迫使父进程接收这个信号。父进程捕获这个信号, 并第二次执行处理程序。在处理程序完成对第二个信号的处理之后, 已经没有待处理的 S IG C H L D 信号了, 而且也绝不会再 有,因 为第三个 S IG C H L D 的所有信息都已经丢失了。由此得到的重要 教 训是, 不 可以用信 号来对其他进程中发 生的 事件计数。\n为了修正这个问题,我们必须回想一下,存在一个待处理的信号只是暗示自进程最后 一次收到一个信号以来, 至少已经有一个这种类型的信号被发送了。所以我们必须修改S IG C H L D 的 处 理 程序,使 得每次 S IG C H LD 处理程序被调用时 , 回 收 尽 可能多的僵死子进程。图 8- 37 展示了修改后的 SIGC H L D 处理程序。\n当我们在 Lin u x 系统上运行 s i g n a l 2 时, 它 现 在可以正 确地回收所有的僵死子进程了:\nlinux\u0026gt; ./signal2\nHello from child 15237\nHello from child 15238 Hello from child 15239 Handler reaped child Handler reaped child Handler reaped child CR\nParent processing input\nvoid handler2(int si g)\n2 {\n3 int olderrno = errno;\n4\ns while (waitpid(-1, NULL, 0) \u0026gt; 0) {\n6 Sio_puts(\u0026ldquo;Handler reaped ch辽d\\ n\u0026rdquo;) ;\n7 }\n8 if (errno != ECHILD)\n9 Sio_error(\u0026ldquo;waitpid error\u0026rdquo;);\nSleep(i);\nerrno = olderrno; 12 }\ncode/ecfilgsna/2.c\ncode/ecfl signal2.c\n图 8-3 7 s i gnal 2: 图 8-36 的一个改进版本 , 它能够正确解决信号不 会排队 等待的情况\n沁 练习题 8. 8 下 面这 个程序 的输 出是 什么?\nvolatile long counter= 2;\n2\n3 void handlerl(int sig)\n4 {\n5 sigset_t mask, prev_mask;\n6\nSigfillset(\u0026amp;mask); codelecfls ig nalp ro bO.c\nSigprocmask(SIG_BLOCK, \u0026amp;mask, \u0026amp;pr ev _ma s k) ; I* Block sigs *I\n9 Si o_putl (- - c oun t er ) ;\n10 Si gpr oc mas k (SI G_SETMAS K , \u0026amp;prev_mask, NULL); I* Restore sigs *I\n11\n12 _e x i t ( O) ;\n13 }\n14\n15 int main()\n16 {\npid_t pid;\nsigset_t mask, prev_mask;\n19\nprintf (11%ld11 , counter) ;\nf fl us h ( s t dout ) ;\n22\n23 signal (SIGUSR1, handler!) ;\n24 if ((pid = Fork()) == 0) {\n25 Yhile(1) {};\n26\nKill (pid, SIGUSR1);\nWaitpid(-1, NULL, 0);\n29\nSigfillset (\u0026amp;mask);\nSigprocmask(SIG_BLOCK, \u0026amp;mask, \u0026amp;prev_mask); I* Block sigs *I\nprintf (11%ld11 , ++counter) ;\nSigprocmask(SIG_SETMASK, \u0026amp;prev_mask, NULL); I* Restore sigs *I\n34\n35 exit (0);\n36 }\n可移植的信号处理\ncode/ef俎ic gnalprobO.c\nU n ix 信号处理的另一个缺陷在于不同的系统有不同的信号处理语义。例如:\ns i g n a l 函数的语 义各 有不同。有 些老的 U n ix 系统在信号 K 被处理程序捕获之后就把对信号k 的反应恢 复到默认值。在这些 系统上, 每次运行 之后, 处理程序必须调用 s i g n a l 函数, 显式地重新设置它自己。\n系统调用可 以被中断 。像 r e a d 、 wr i 七e 和 a c c e p t 这样的系统调用潜在地会阻塞进程一段较长的时间 , 称为慢 速 系统调用。在 某些较早版本的 U nix 系统 中, 当处理程序捕 获到一个信号时 , 被中断的慢速系统 调用在信号处理程序返回时不再继续, 而是立即返回给用户一个错误条件, 并将 e rr n o 设置为 E I N T R 。在这些系统上, 程序员必须 包括手动重启 被中断的系统调用的代码。\n要解决这些问 题, P o s ix 标准定 义了 s i g a c t i o n 函数, 它允许用户在设置信号处理时,明确指定他们想要的信号处理语义。\n#include \u0026lt;signal.h\u0026gt;\nint sigaction(int signum, struct sigaction *act, struct sigaction *oldact);\n返回: 若 成 功则 为 0 , 若 出错 则 为 - I .\ns i g a c t i o n 函数运用并不广泛, 因为它要求用户设置一个复杂结构的条目。一个更简洁的方 式, 最初是由 W. Richard Stevens 提出的[ 11 0 ] , 就是定义一个包装 函数, 称为\nSignal, 它调用 s i g a c t i o n。图 8-38 给出了 S i g n a l 的定义,它的 调用方式与 s i g na l 函\n数的调用方式一样。\nS i g n a l 包装函数设置了一 个信号处理程序, 其信号处理语义如下 :\n只 有这个处 理程序当前正在处理的那种类型的 信号被阻塞。\n和所有信号实现一样,信号不会排队等待。\n只要可能 , 被中断的系统调用会自动重启。\n一旦设置了信号处理程序, 它就会一直保持, 直到 S i g n a l 带着 h a nd l e r 参数为\nS IG _ IG N 或 者 S IG _DF L 被调用。\n我们在所有的 代码中实现 Si g n a l 包装函数 。\n8. 5. 6 同步流以避免讨厌的并发错误\n如何编写读写相同存储位置的并发流程序的问 题, 困扰着数代计算机科学家。一般而\n言,流可能交错的数量与指令的数量呈指数关系。这些交错中的一些会产生正确的结果, 而有些则不会。基本的问题是以某种方式同步并发流,从而得到最大的可行的交错的集 合, 每个可行的 交错都能得到正确的结果。\ncode/src/csapp.c\nhandler_t *Signal(int signum, handler_t *handler)\n{\nstruct sigaction action, old_action;\naction.sa_handler = handler;\nsigemptyset(\u0026amp;action.sa_mask); I* Block sigs of type being handled *I action.sa_flags = SA_RESTART; I* Restart syscalls if possible *I\nif (sigaction(signurn, \u0026amp;action, \u0026amp;old_action) \u0026lt; 0) unix_error(\u0026ldquo;Signal error\u0026rdquo;);\nreturn (old_action.sa_handler);\ncodelsrdcsapp.c\n图8-38 Si gna l : s i ga c t i on 的一个包装函数 , 它提供在 Posix 兼容系统上的可移植的 信号处理\n并发编程是一个很深且很重要的问题, 我们将在第 12 章 中更详细地讨 论。不过, 在本章中学习的有关 异常控制流的 知识, 可以让你感觉一下与并发相关的有趣的智力挑战。例如, 考虑图 8-39 中的程序, 它总结了一个典型的 U nix shell 的结构。父进程在一个全局 作业列 表中记录着它的 当前子进程, 每个作 业一个条目。a d d j o b 和 d e l e 七e j o b 函数分别 向这个作业列表 添加和从中删除作业。\n当父进程创建一个新的子进程后 , 它就把这 个子进程添加到 作业列表中。当父进程在\nSIGCHLD 处理程序中回收一个终止的(僵死)子进程时, 它就从作业列表中删除这个子进程。\n乍一看 , 这段代码是对的。不幸的是, 可能发生下面这样的 事件序列 :\n父进程执行 f o r k 函数,内 核调度新创建的子进程运行 , 而不是父进程。 ) 在父进程能 够再次运行之前, 子进程就终止, 并且变成一个僵死进程, 使得内核传递一个 SIGCH LD 信号给父进程。\n) 后来, 当父 进程再次变成可运行但又 在它执行之前,内 核注意到有未处理的\nSIGCHLD 信号, 并通过在父进程中运行处 理程序接收 这个信号。\n) 信号处理程序回收终止的子进程,并 调用 d e l e t e j o b , 这个函数什么也不做, 为父进程还没有把该子进程添加到列表中。\n) 在处理程序运行完毕后,内 核运行父进程, 父进程从 f or k 返回, 通过调用 a d d­ j ob 错误地把(不存在的)子进程添加到作 业列表中。\n因此, 对千父进 程的 ma i n 程序和信号处理流的某些交错, 可能会在 a d d j o b 之前调用 d e l e t e j o b 。这导 致作业列 表中出现一个不正确的条目, 对应于一个不再存在而且永远也不会被删 除的作业。另一方面,也 有一些交错 , 事件按照正确的顺 序发生。例如, 如果在 fo r k 调用返回时,内 核刚好调度父进程而不是子进程运行, 那么父进程就会正确地把子进程添加到作业列表中,然后子进程终止,信号处理函数把该作业从列表中删除。\n这是一个称为竞争 ( ra ce ) 的经典同步错误的示例。在这个情况中, ma i n 函数中调用\nadd j ob 和处理程序中调用 d e l e t e j ob 之间存在竞争。如果 a d d j o b 赢得进展,那 么结果\n就是正确的。如果它没有, 那么结果就是错误的。这样的错误非常难以调试, 因为几乎不可能测试所有的交错。你可能运行这段代码十亿次, 也 没有一次错误, 但是下一次测试却导致引发竞争的交错。\ncodelecf/p rocmaskl .c\nI* WARNING: This code is buggy! *I\n2 void handler(int sig) 3 {\n4 int olderrno = errno;\n5 sigset_t mask_all, prev_all;\n6 pid_t pid;\n8 Sigf illset(\u0026amp;mask_all) ;\n9 while ((pid = waitpid(-1, NULL, 0)) \u0026gt; 0) { I* Reap a zombie child *I\nSigprocmask(SIG_BLOCK, \u0026amp;mask_all, \u0026amp;pr e v _a ll ) ;\nde l et e j ob (pi d) ; I* Delete the child from the job list *I\nSigprocmask(SIG_SETMASK, \u0026amp;prev_all, NULL);\n13 }\n14 if (errno != ECHILD)\n15 Sio_error(\u0026ldquo;waitpid error\u0026rdquo;);\n16 errno = olderrno·\n17 }\n18\n19 int main(int argc, char **argv)\n20 {\nint pid;\nsigset_t mask_all, prev_all;\n23\nSigfillset (\u0026amp;ma s k_a ll ) ;\nSignal(SIGCHLD, handler);\ninitjobs(); I* Initialize the job list *I\n27\nwhile (1) {\nif ((pid = Fork()) == 0) { I* Child process *I\nExecve(\u0026quot;/bin/date\u0026quot;, argv, NULL);\n31 }\nSigprocmask(SIG_BLOCK, \u0026amp;mask_all, \u0026amp;pr ev _a ll ) ; I* Parent process *I\naddjob(pid); I* Add the child to the job list *I\nSigprocmask(SIG_SETMASK, \u0026amp;prev_all, NULL);\n35 }\n36 e x i t ( O) ;\n37 }\ncod e/ ecf/ p roc maskl.c\n图 8-39 一 个 具 有细微同步错误的 shell 程序。如果子进程在父进程能够开始运行前就结束了, 那么\nadd j ob 和 de l e t e j ob 会以错误的方式被调用\n图 8-40 展示 了 消除图 8-39 中竞争 的一种 方 法。通 过 在调用 f or k 之前, 阻塞S IGCH LD 信号, 然后在调用 a dd j o b 之后取消阻塞这些信号, 我们保证了在子进程被添加到作业列表中之后回收该子进程。注意 , 子进程继 承了它们父进程的被阻塞集合, 所以我们必须在调用 e x e c v e 之前,小 心地解除子进程中阻 塞的 SIGCHLD 信号。\ncode/ecflprocmask2.c\nvoid handler(int sig)\n2 {\nint olderrno = errno;\nsigset_t mask_all, prev_all;\npid_t pid;\nSigfillset (\u0026amp;mask_all);\nwhile ((pid = waitpid(-1, NULL, 0)) \u0026gt; 0) { f* Reap a zombie child *f\nSigprocmask(SIG_BLOCK, \u0026amp;mask_all, \u0026amp;prev_all);\ndeletejob(pid); I* Delete the child from the job list *I\nSigprocmask (SIG_SETMASK, \u0026amp;prev_all, NULL) ; 12 }\n3 if (errno != ECHILD)\nSio_error(\u0026ldquo;waitpid error\u0026rdquo;);\nerrno = olderrno; 16 }\n17\n18 int main(int argc, char **argv) 19 {\nint pid;\nsigset_t mask_all, mask_one, prev_one;\n22\nSigfillset(\u0026amp;mask_all);\nSigemptyset (\u0026amp;mask_one) ;\nSigaddset(\u0026amp;mask_one, SIGCHLD);\nSignal(SIGCHLD, handler);\ninitjobs(); I* Initialize the job list *I\n28\nwhile (1) {\n· Si gpr oc ma s k (S I G_BLOCK, \u0026amp;mask_one, \u0026amp;prev_one); I* Block SIGCHLD *f\nif ((pid = Fork()) == 0) { I* Child process *I\nSigprocmask(SIG_SETMASK, \u0026amp;prev_one, NULL); f* Unblock SIGCHLD *f\nExeeve (11/bin/date11, argv, NULL) ; 34 }\nSigprocmask(SIG_BLOCK, \u0026amp;mask_all, NULL); I* Parent process *I\naddjob(pid); I* Add the child to the job list *I\nSigprocmask(SIG_SETMASK, \u0026amp;prev_one, NULL); f* Unblock SIGCHLD *f\n38 }\n39 exit(O); 40 }\ncode/ecfl p roc mask2.c\n图 8-40 用 s i gpr ocmas k 来同步进程。在这个例子中 ,父进程保证在相应的 del et e job 之前执行 add job\n5. 7 显式地等待信号\n有时候 主程序需要显式地等待某个信号处理程序运行。例如,当 Linu x shell 创建一个前台作业时 , 在接收下一条用户命令之前, 它必须等待作业终止, 被 SIGCHLD 处理程序回收。\n图 8-41 给出了一个基本的思路。父进程设置 SIGINT 和 SIGCH LD 的处理程序, 然后\n进入一个无限循环。它阻塞 S IG C H L D 信号, 避免 8. 5. 6 节中讨论过的父进程和子进程之间的竞争。创建了 子进程之后, 把 p 过 重置为 o, 取消阻塞 S IG C H L D , 然后以循环的方式等待 p 迈 变为非零。子进程终止后, 处理程序回收它, 把它非零的 P ID 赋值给全局 p i d\n变温。这会终止循环,父进程 继续其他的工作, 然后开始下一次迭代。\ncode/ecflwaitforsignal.c\n#include \u0026ldquo;csapp.h\u0026rdquo;\n2\n3 volatile sig_atomic_t pid;\n4\n5 void sigchld_handler(int s)\n6 {\nint olderrno = errno;\npid = wai tpid( 一 1 , NULL, O);\nerrno = olderrno;\n10 }\n11\n12 void sigint_handler(int s)\n13 {\n14 }\n15\n16 int main(int argc, char **argv)\n17 {\n18 sigset_t mask, prev;\n19\nSignal(SIGCHLD, sigchld_handler);\nSignal (SIGINT, sigint_handler) ;\nSigemptyset(\u0026amp;mask);\n23 Sigaddset (\u0026amp;mask, SIGCHLD) ;\n24\nwhile (1) {\nSigprocmask(SIG_BLOCK, \u0026amp;mask, \u0026amp;prev); I* Block SIGCHLD *I\n27 if (Fork() == 0) I* Child *I\n28 exit (0);\n29\nI* Parent *I\npid = O;\nSigprocmask(SIG_SETMASK, \u0026amp;prev, NULL); I* Unblock SIGCHLD *I\n33\n34 I* Wait for SIGCHLD to be received (wasteful) *I\n35 while (! pid)\n36\n37\nI* Do some work after receiving SIGCHLD *I\nprintf(\u0026quot;.\u0026quot;);\n40 }\n41 exit(O);\n42 }\ncode/ecflwaitforsignal.c\n图 8- 41 用循环来等待信号 。这段代码正确, 但循环是一种浪费\n当这段代码正确执行的时候,循环在浪费处理器资源。我们可能会想要修补这个问 题, 在循环体内插入 pa us e :\nwhile (! pid) I* Race! *I pause();\n注意, 我们仍然需要一个循环, 因 为收到一个或多个 S IGINT 信号, p a u s e 会 被 中断。不过, 这段代码有很严直 的竞 争 条 件: 如果在 wh i l e 测 试 后 和 p a u s e 之前 收到SIGC H LD 信号, p a u s e 会永远睡眠。\n另一个选择是用 s l e e p 替换 p a us e :\nwhile (! pid) I* Too slow! *I sleep(!);\n当这段代码正确执行时, 它太慢了。如果在 wh i l e 之 后 p a u s e 之 前 收 到 信 号 , 程 序必须 等相当长的一段时间才会再次检查循环的终止条件。使用像 na nos l e e p 这样更高精度的休眠函数也是不可接受的,因为没有很好的方法来确定休眠的间隔。间隔太小,循环 会太浪费。间隔太大,程序又会太慢。\n合适的解决方法是使用 s i g s u s p e nd 。\n#include \u0026lt;signal.h\u0026gt;\nint sigsuspend(const sigset_t *mask);\n返回: —1。\ns i g s us pe nd 函数暂时用 ma s k 替换当前的阻塞集合, 然后挂起该进程, 直 到收到一个信号,其行为要么是运行一个处理程序,要么是终止该进程。如果它的行为是终止,那 么该进程不从 s i g s us pe nd 返回就直接终止。如果 它的行为是运行一个处 理程序, 那 么s i g u s p e n d 从处理程序返回,恢 复 调 用 s i g s u s pe nd 时 原 有的阻塞集合。\ns i g s us pe nd 函数等价于下述代码的原子的(不可中断的)版本:\nsigprocmask(SIG_SETMASK, \u0026amp;mask, \u0026amp;prev); pause();\n3 sigprocmask(SIG_SETMASK, \u0026amp;prev, NULL);\n原子属 性保证对 s i g pr oc ma s k( 第 1 行)和pa us e ( 第 2 行)的调用总是一起发生的,不 会 被中断。这样就消除了潜在的竞争, 即 在 调 用 s i g pr o c ma s k 之后但在调用 pa us e 之前收到了一个信号。\n图 8- 42 展示了如何使用 s i g s u s pe nd 来替代图 8- 41 中的循 环。在每次调用 s i g s us ­ pe nd 之前,都 要 阻 塞 SIG CH LD。 s i g s us p e nd 会暂时取消阻塞 S IGCH LD , 然后休眠, 直到父进程捕获信号。在返回之前, 它会恢复原始的阻塞集合, 又再次阻塞 SIG C H L D。如果父进程捕获一个 SIG IN T 信号,那 么 循 环 测 试 成 功 ,下 一 次 迭代又再次调用 s i g s us ­ pe nd。如果 父 进 程 捕 获 一 个 SIGCH LD , 那么循环测试失败,会退出循环。此时, SIGCH LD 是被阻塞的,所 以 我们可以可选地取消阻塞 SIG CH LD。在真实的有后台作业需要回收的 shell 中这样做可能会有用处。\ns i g s us pe nd 版本比起原来的循环版本不那么浪费, 避免了引入 p a us e 带来的竞争, 又比 s l e e p 更有 效 率 。\ncode/ecflsigsuspend.c\n1 #include \u0026ldquo;csapp.h\u0026rdquo;\n2\n3 volatile sig_atomic_t pid;\n4\n5 void sigchld_handler(int s)\n6 {\n7 int olderrno = errno;\n8 . p过 = 扣釭 t p 过( 一1 , NULL, O);\n9 errno = olderrno·\n10 }\n11\n12 void sigint_handler(int s)\n13 {\n14 }\n15\n16 int main(int argc, char **argv)\n17 {\n18 sigset_t mask, prev;\n19\nSignal(SIGCHLD, sigchld_handler);\nSignal(SIGINT, sigint_handler);\nSigemptyset(\u0026amp;mask);\nSigaddset(\u0026amp;mask, SIGCHLD); 24\nwhile (1) {\nSigprocmask(SIG_BLOCK, \u0026amp;mask, \u0026amp;prev); I* Block SIGCHLD *I\nif (Fork() == 0) I* Child *I\n28 exit(O);\n29\nI* Wait for SIGCHLD to be received *I\npid = O;\nwhile (! pid)\nsigsuspend(\u0026amp;prev);\n34\nI* Optionally unblock SIGCHLD *I\nSigprocmask(SIG_SETMASK, \u0026amp;prev, NULL);\n37\nI* Do some work after receiving SIGCHLD *I\nprintf(\u0026quot;. \u0026ldquo;);\n40 }\n41 exit(O); 42 }\ncode/ecfl sigs uspend.c\n图 8-42 用 s i gs us pe nd 来等待信号\n6 非本地跳转\nC 语言提供了一种用户级异常控制流形式,称 为非本地跳转( no nloca l jump), 它将控\n制 直 接从一个函数转移到另一个当前正在执行的函数,而 不 需 要 经 过 正 常 的 调 用- 返回序\n列。非本地跳转是通过 s e t j mp 和 l o ng j mp 函数来提供的 。\n#include \u0026lt;setjmp.h\u0026gt;\nint int\nsetjmp(jmp_buf env);\nsi gset jmp (s 屯 j mp _buf env, int savesigs);\n返回: se t jmp 返 回 O, l ong jmp 返 回 非零。\ns e t j mp 函数在 e nv 缓冲区中保 存当前调用环境 , 以供后面的 l o n g j mp 使 用 , 并返回\n0。调用环境包括程序计数器、栈指针和通用目的寄存器。出于某种超出本书描述范围的 原因, s e t j mp 返回的值不能被赋值给变量:\nre= setjmp(env); I* Wrong! *I\n不过它可以安全地用在 S W止 c h 或条件语句的测 试中[ 62] 。\n#include \u0026lt;setjmp.h\u0026gt;\nvoid longjmp(jmp_buf env, int retval);\nvoid siglongjmp(sigjmp_buf env, int retval);\n从不返回 。\nl o n g j mp 函数从 e nv 缓冲区中恢复调用环境, 然后触发一个从最近一次初始化 e nv\n的 s e t j mp 调用的返回。然后 s e t j mp 返回, 并带有非零的返回值r e t v a l 。\n第一眼看过去, s e t j mp 和 l o n g j mp 之间的相互关系令人迷惑。s e t j mp 函数只被调用一次, 但返回多 次: 一次是当第一次调用 s e t j mp , 而调用环境保存在缓 冲区 e nv 中时, 一次是为每个相应 的 l o ng j mp 调用。另一方面, l o ng j mp 函数被调用一次, 但从不返回。\n非本地跳转的一个重要应用就是允 许从一个深层嵌套的函数调用中立即返回, 通常是由检测到某个错误 情况引起的。如果在一个深层嵌套的函数调用中发现了一个错误情况, 我们可 以使用非本地跳转直接返回到一个普通的本 地化的错 误处理程序, 而不是费力地解开调用栈 。\n图 8-43 展示了一个示例,说 明这可能是如何工作的。ma i n 函数首先调用 s e t j mp 以保存当前的调用环境, 然后调用 函数 f o o , f o o 依次调用函数 bar 。如果 f o o 或者 b ar 遇到一个错误 , 它们立即通过一次 l o ng j mp 调用从 s e t j mp 返回。s e 七 j mp 的 非零返回值指明了错误类型, 随后可以被解码 , 且在代码中的某个位置进行处 理。\ncode/ecf/setjmp.c\n#include \u0026ldquo;csapp.h\u0026rdquo; jmp _buf buf;\nint error!= O;\nint error2 = 1;\nvo i d foo(void), bar(void);\n图8-43 非本地跳转的示例。本示例表明了使用非本地跳转来从深层嵌套的函数调用中的错误情况恢复, 而不需要解开整个栈 的基本框架\n10 int main()\n11 {\nswitch(setjmp(buf)) {\ncase 0:\n14 foo();\n15 break;\ncase 1:\nprintf(\u0026ldquo;Detected an errorl condition in foo\\n\u0026rdquo;);\nbreak;\ncase 2:\nprintf(\u0026ldquo;Detected an error2 condition in foo\\n\u0026rdquo;);\nbreak;\ndefault:\nprintf (\u0026ldquo;Unknown error condition in foo\\n\u0026rdquo;);\n24 }\n25 exit(O);\n26 }\n27\nI* Deeply nested function foo *I\nvoid foo(void)\n30 {\nif (errorl)\nlongjmp(buf, 1);\nbar();\n34 }\n35\n36 void bar(void)\n37 {\nif (error2)\nlongjmp (buf, 2);\n40 }\ncode/ecf/setjmp.c\n图 8- 43 (续)\nl o ng j mp 允 许它跳过所有中间 调用的特性可能产 生意外的后果。例如, 如果中间函数调用中分配了某些数据结构,本来预期在函数结尾处释放它们,那么这些释放代码会被跳 过,因而会产生内存泄涌。\n非本地跳转的另一个重要应用是使一个信号处理程序分支到一个特殊的代码位置,而不是返回到被信号到达中断了的指令的位置。图8-44 展示了一个简单的程序,说明 了这种基本技术。当用户在键盘上键入 C trl + C 时, 这个程序用 信号 和非本地跳转来实现软重启。 s i g­ s e t j mp 和 s i g l o ng j mp 函数是 s e t j mp 和 l o ng j mp 的可 以被信号处理程序使用的版本。\n在程序第一次启动时, 对 s i g s e t j mp 函数的初始调用保存调用环境和信号的上下文\n(包括待处理的和被阻塞的信号向扯)。随后,主函数进入一个无限处理循环。当用户键入 C t rl + C 时,内 核发送一个 S IG I N T 信号给这个进程,该 进程捕获这个信号。不是从信号处理程序返回,如果是这样那么信号处理程序会将控制返回给被中断的处理循环,反之, 处理程序完成一个非本地跳转, 回到 ma i n 函数的开始处。当我们在系统上运行这个程序时,得到以下输出:\nlinux\u0026gt; ./restart starting processing .. . processing . . .\nCtrl+C restarting processing\u0026hellip; Ctrl+C restarting processing . . .\n关千这个程序有两件很有趣的事情。首先, 为了避免竞争,必须 在调用 了 s i g s e t j mp 之后再设 置处 理 程序 。否 则 ,就 会 冒在初始调用 s i gs e t j mp 为 s i g l o ng j mp 设 置调用环境之前运行处理程序的风险。其次,你 可 能 巳 经 注 意 到 了 , s i g s e t j mp 和 s i g l ong j mp 函 数 不 在 图8- 33 中异 步信号安全的函数之列。原因是一般来说 s i g l ong j mp 可以 跳到任意代码,所 以 我们必须小心, 只在 s i g l o ng j mp 可达的代码中调用安全的函数。在本例中, 我们调用安全的 s i o主 u t s 和 s l e e p 函数。不安全的 e x i t 函数是不可达的。\n#include \u0026ldquo;csapp.h\u0026rdquo;\n2\ncode/ecf/restart.c\n3 sigjmp_buf buf;\n4\ns void handler(int sig)\n6 {\n7 s iglongjmp (buf , 1) ;\n8 }\n9\n1o int main()\n11 {\nif (!sigsetjmp(buf, 1)) {\nSignal(SIGINT, handler);\nSio_puts(\u0026ldquo;starting\\n\u0026rdquo;);\n15 }\nelse\nSio_puts (\u0026ldquo;restarting\\n\u0026rdquo;) ;\n18\nwhile(!) {\nSleep(! ) ;\nSio_puts (\u0026ldquo;processing \u0026hellip; \\n\u0026rdquo;);\n22 }\n23 exit(O); I* Control never reaches here *I\n24 }\ncode/ecflrestart.c\n图8-44 当用户键入 Ctrl+ C 时, 使 用 非本地跳转来重启 动它自身的 程序\n豆日C++ 和 J a va 中的软件异常\nC++ 和 J ava 提供的异常机制是较 高层次的 , 是 C 语言的 s e t j mp 和 l o n g j mp 函数的更加结构化的版本。你可以把 t r y 语句中 的 c a t c h 子句 看做 类似于 s e 七 j mp 函数。相似地, t hr o w 语句就 类似于 l o n g j mp 函数。\n8. 7 操作进程的工具\nLin u x 系统提供了大量的监 控和操作进程的有用 工具。\nST RACE : 打印一个正在运行的程序和它的子进程调用的每个系统调用的轨迹。对于好奇的学生而言,这 是一个令人着迷的 工具。用- s t a t i c 编译你的 程序, 能得到一个更干净的、不带有大量与共享库相关的输出的轨迹。\nPS: 列出当前 系统中的进程(包括僵死进程)。\nT OP: 打印出关于当前进程资源使用的信息。\nPMAP: 显示进程的内存映射。\n/ pr o c : 一个虚拟文件系统,以 ASCII 文本格式输出大量内核数据结构的内容, 用户程序可以读取这些内容。比如, 输入 \u0026quot; c a t / p r o c / l o a d a v g\u0026rdquo; , 可以看到你的 Lin u x 系统上当前的平均负载。\n8. 8 小结\n异常控制流 ( ECF) 发生在计算机系统的各个层次 , 是计算机系统中 提供并发的 基本机 制。\n在硬件层 , 异常是由处理器中的 事件触发的 控制流中的 突变。控制流传 递给一 个软件处理程序,该处理程序进行一些处理 , 然后 返回控制给被中断的 控制流。\n有四种不同类 型的异常 : 中断、故障、终止和陷阱 。当一个外部 1/0 设备(例如定时器芯片或者磁盘\n控制器)设置了处理 器芯片上的中断管脚时 ,(对于任意指令)中断会异步地发生。控制返回到故障指令后面的那条指令。一条指令的 执行可能导致 故障 和终止同步发生。故障处理程序会重新启动故障指 令, 而终止处理程序从 不将控制返回 给被中断的 流。最后 , 陷阱就像是用来实现向应用 提供到操作系统代码的受控的入口点的系统调用的函数调用。\n在操作 系统层,内 核用 ECF 提供进程的 基本概念。进程提供给应 用两个重要的抽象: 1) 逻辑控制 流,它 提供给每个程序一个假象 , 好像它是 在独占 地使用处理器, 2 ) 私有地 址空间 , 它提供 给每个程序一个假象,好像它是在独占地使用主存。\n在操作系统和应用程序之间的接口处,应用程序可以创建子进程,等待它们的子进程停止或者终止, 运行新的 程序 , 以及捕获来 自其他进 程的信号。信号处理的语义是微妙的, 并且随系统不同而不同。然而, 在与 Pos ix 兼容的系统 上存在着一些机制 ,允 许程序清楚 地指定期望的信号处理语义。\n最后, 在应用层, C 程序可以 使用非本 地跳转来 规避正常的调用/返回栈规则 , 并且直接从 一个函数分支到另一个函数.\n参考文献说明 # Ke r risk 是 Linux 环境编程的 完全参考手册 [ 62] 。Intel ISA 规范包含对 Intel 处理器上的异常和中断的详 细讨论 [ 50] 。操作系统教科书 [ 102. 106, 113] 包括关于异 常、进 程和信号的其他信息。W. Richard St evens 的[ 111 ] 是一本有价值的和可读性很高的 经典著作, 是关于如何 在应用程序中处 理进程和信号的。Bovet 和 Cesati[ 11] 给出了一个关千 Linux 内核的非常清晰的描述, 包括进程和信号实现的 细节。\n家庭作业 # 8. 9 考虑四个具有 如下开始和结束时间的进程 : 进程 开始时间 结束时间 A 5 7 B 2 4 C 3 6 D I 8 对于每对进程,指明它们是否是并发地运行的:\n8. 10 在这一章里 , 我们介绍 了一些具有不寻常的调用和返回行为的 函数: s e t j mp 、 l ong j mp 、 e xe c ve\n和 f or k。找到下列行为中和每个函数相匹 配的一种 :\n调用一次, 返回两次。 调用一次,从不返回。\nc. 调用一次,返回一次或者多次。\n8. 11 这个程序会输出多 少个 \u0026quot; hello\u0026quot; 输出行?\n妇 ncl ude \u0026ldquo;csapp.h\u0026rdquo;\n2\ncodelecf/forkprobl.c\n3 int main()\n4 {\n5 inti;\n6\n7 for(i = 0; i \u0026lt; 2; i ++)\n8 Fork();\n9 printf(\u0026ldquo;hello\\n\u0026rdquo;);\n10 exit(O);\n11 }\ncodelecf/forkprobl.c\n8. 12 这个程序会输出多 少个 \u0026quot; hello\u0026quot; 输出行? #include \u0026ldquo;csapp.h\u0026rdquo;\n3 void doit 0\n4 {\n5 Fork();\n6 Fork(); printf(\u0026ldquo;hello\\n\u0026rdquo;);\n8 return·,\n9 }\n10\n11 int main()\n12 {\n13 doit();\n14 printf (\u0026ldquo;hello\\n\u0026rdquo;) ;\n15 exit(O);\n16 }\ncode/ecflforkprob4.c\ncode/ecflforkprob4.c\n8. 13 下面程序的 一种可能的输出是 什么? 扣 ncl ude \u0026ldquo;cs app . h\u0026rdquo;\ncodelecf/forkprob3.c\nint main()\n4 { 5 int X = 3; 6 7 if (Fork() != 0) 8 printf(\u0026ldquo;x=%d\\n\u0026rdquo;, ++x); 9 10 printf(\u0026ldquo;x=%d\\n\u0026rdquo;, \u0026ndash;x);\n11 exit(O);\n12 }\ncodelecflforkprob3.c\n8. 14 下 面这个程序会输出多 少个 \u0026quot; hello\u0026quot; 输出行? 1 #include \u0026ldquo;csapp.h\u0026rdquo;\n2\n3 void doitO\n4 {\n5 if (Fork() == 0) {\nFork();\nprintf(\u0026ldquo;hello\\n\u0026rdquo;);\nexit(O);\n9 }\n10 return; 11 }\n12\n13 int main()\n14 {\n1s doitO;\nprintf(\u0026ldquo;hello\\n\u0026rdquo;);\nexit(O);\n18 }\ncodelecflforkprob5.c\ncodelecflforkprob5.c\n8. 15 下面这个程序会 输出多 少个 \u0026quot; hello\u0026quot; 输出行? #include \u0026ldquo;csapp.h\u0026rdquo;\n2\n3 void doit ()\n4 {\ns if (Fork() == 0) {\nFork();\nprintf (\u0026ldquo;hello\\n\u0026rdquo;);\ns return·,\n9 }\n10 return; 11 }\n12\n13 int main()\n14 {\n1s doitO;\nprintf(\u0026ldquo;hello\\n\u0026rdquo;);\nexit(O);\n18 }\ncodelecflforkprob6.c\ncodelecf/forkprob6.c\n8. 16 下面这个程序的输出是什么? codelecf/forkprob7.c\n#include \u0026ldquo;csapp. h\u0026rdquo; int counter= 1;\nint main()\n{\n辽 (for k () == 0) { counter\u0026ndash;; exit(O);\n}\nelse {\nWait(NULL);\nprintf(\u0026ldquo;counter = o/,d\\n\u0026rdquo;, ++counter);\n}\nexit(O);\n}\ncode/ecf/forkprob7.c\n列举练习题 8. 4 中程序所有可能的 输出。考虑下面的程序:\n#include \u0026ldquo;csapp.h\u0026rdquo; void end(void)\n{\ncode/ecflforkprob2.c\nprintf(\u0026ldquo;2\u0026rdquo;); fflush(stdout);\n}\nint main()\n{\nif (Fork() == 0) atexit(end);\nif (Fork() == 0) {\nprintf(\u0026ldquo;O\u0026rdquo;); fflush(stdout);\n}\nelse {\nprintf(\u0026ldquo;1\u0026rdquo;); fflush(stdout);\n}\nexit(O);\n}\ncode/ecflforkprob2.c\n判断下面哪个输出是 可能的 。注意: a t e x 江 函数以一个指向函数的指针为输入 , 并将它添加到函数列 表中(初始为空), 当 e x 江 函数被调用时 , 会调用该列 表中的函数。\n•• 8. 19\nA. 112002 B. 211020 C. 102120 D. 122001\n下面的函数会打印多 少行输出? 用一个 n 的函数给出答 案。假设 n l 。\ncode/ecflforkprob8.c\nE . 100212\nvoid foo(int n)\n{\ninti;\nfor(i = 0; i \u0026lt; n; i ++)\nFork(); printf(\u0026ldquo;hello\\n\u0026rdquo;); exit(O);\ncode/ecflrfkoprob8.c\n** 8. 20\n使用 e xe c ve 编写一个叫做 myl s 的 程 序 ,该 程序的行为和 / bi n / l s 程序的一样。你的程序应该接受相同的命令行参数 , 解释同样的环境变量,并 产 生 相 同 的 输 出 。\nl s 程 序从 CO L U M NS 环境变扯中获得屏幕的宽度。如果没有设 置 CO L U MNS , 那么 l s 会假设 屏幕宽 80 列。因此,你 可以 通过把 CO LU M NS 环境设置得小于 80 , 来检查你对环境变址的处理:\nlinux\u0026gt; setenv COLUMNS 40 linux\u0026gt; ./ myls\nII Output is 40 columns wide\nlinux\u0026gt; unsetenv COLUMNS linux\u0026gt; ./ myls\nII Output is now 80 columns wide\n** 8. 21\n下面的程序可能的输出序列是什么?\nint main()\n{\ncodelecflwaitprob3.c\nif (fork() == 0) {\nprintf(\u0026ldquo;a\u0026rdquo;); fflush(stdout); exit (O) ;\n}\nelse {\npri ntf (\u0026ldquo;b\u0026rdquo;) ; fflush(stdout); waitpid(-1, NULL, 0);\n}\nprintf(\u0026ldquo;c\u0026rdquo;); fflush(stdout); exit(O);\n*** 8. 22\n编写 U nixs ys t e m 函 数的你自己的版本\n立 t mysystem(char *command);\ncode/ecwfa/itprob3 .c\n•• 8. 23\nmys ys t e m 函 数 通过调用 \u0026quot; / b i n / s h - c c omma nd \u0026quot; 来 执 行 c omma nd , 然 后 在 c omma nd 完成后返回。如果 c omma nd ( 通过 调用 e xi t 函数 或 者 执 行一 条r e t u r n 语 句)正常 退出, 那 么 mys ys t e m 返回 c omma nd 退出状态。例如, 如 果 c o mma nd 通过调用 e xi t (8 ) 终 止,那 么 mys ys t e m 返回值 8。否则,如 果 c o mma nd 是 异常终止的,那 么 mys y s t e m 就 返 回 s he ll 返回的状态。\n你的一个同事想要使用信号来让一个父进程对发生在子进程中的事件计数。其想法是每次发生一 个事件时,通过向父进程发送一个信号来通知它,并且让父进程的信号处理程序对一个全局变量 coun t e r 加一, 在子进程终止之后, 父进程就可以检查这个变量。然而, 当他在系统上运行图 8-\n45 中的测试程序时,发 现 当父进程调用 pr i n t f 时, c o unt er 的值总是 2 , 即使子进程向父进程发\n送了 5 个信号也是如此。他很困惑,向 你 寻 求 帮助。你能解释这个程序有什么错误吗?\ncodelecf/counterprob.c\n#include \u0026ldquo;csapp.h\u0026rdquo; int counter= O;\nvoid handler(int sig)\n{\ncounter++;\nsleep(1); I* Do some work in the handler *I return;\n图8-\u0026lsquo;15 家庭作业 8. 23 中引用的计数器程序\n10 }\n11\n12 int main()\n13 {\n14 int i;\n15\n16 Signal(SIGUSR2, handler); 17\n18 if (Fork() == 0) { I* Child *I 19 for (i = O; i \u0026lt; 5; i++) {\nKill(getppid () , SIGUSR2) ;\nprintf(\u0026ldquo;sent SIGUSR2 to parent \\n\u0026rdquo;) ; 22 }\n23 exit(O);\n24 }\n25\nWait (NULL) ;\nprintf(\u0026ldquo;counter=%d\\n\u0026rdquo;, counter);\nexit(O); 29 }\ncodelecf/counterprob.c\n图 8-45 (续)\n\\* 8. 24 修改图 8-18 中的程序,以 满足下 面两个条件:\n每个子进程在试图写一个只读文本段中的位置时会异常终止。\n父进 程打印和下 面所示相同(除了 PID) 的输出:\nchild 12255 terminated by signal 11: Segmentation fault child 12254 terminated by signal 11: Segmentation fault\n提示:请 参 考 ps i g na l (3 )的 ma n 页。\n*/ 8 . 25 编写 f ge t s 函 数 的 一 个 版本, 叫做 t f ge t s , 它 5 秒钟后会超时。t f ge t s 函数接收和 f ge t s 相同的输入。如果用户在 5 秒内不键人一个输入行, t f ge t s 返回 NU LL。否则, 它 返 回一 个 指向 输 入\n.行的指针。\n:: 8 . 26 以图 8-23 中的示例作为开始点,编 写一个 支持作业控制的 s hell 程序。s hell 必须具有以下特性:\n用 户输 入的命令行由一个 na me 、 零 个 或 者 多 个 参 数 组成,它 们 都 由 一 个 或 者 多 个 空 格分隔开。如果 na me 是 一 个 内 置 命 令 ,那 么 s hell 就 立即处理它,并 等 待 下 一 个 命 令 行 。 否 则 , s hell 就 假设 na me 是 一 个 可执行文件, 在一个初始的子进程(作业)的上下文中加载并运行它。作业的进程组 ID 与子进程的 P ID 相同。 每个作业是由一个进程 IDCPID ) 或 者一个作业 ID(J ID) 来标识的,它 是 由 一 个 she ll 分配的任意的小正整数。J ID 在命令行上用前缀 \u0026quot; %\u0026quot; 来表示。比如, \u0026quot; %5\u0026quot; 表示 J ID 5, 而 \u0026quot; s\u0026quot; 表示 PID 5。 如果 命令行以 &来结 束 , 那么 shell 就在后台运行这个作业。否则, she ll 就在前台运行这个作业。 输入 Ctr l+ C( Ctrl+ Z) , 使得内核发送一个 S IGI NT ( SIGT ST P ) 信号给 s hell , s hell 再转发给前台进程组中的每个进程e 内置命令 j ob s 列出所有的后台作业。 内置命令 bg j ob 通过发送一个 S IGCO NT 信号重启 j ob, 然后在后台运行它。j ob 参数可以是一个 PID , 也可以是一个 JID。 内置命令 f g J动 通过发送一个 SIGCO NT 信号重启 j ob, 然后在前台运行它。 9 注意这是对真实的 shell 工作方式的简化。真实的shell 里, 内核响应Ct rl + C( Ctr!+ Z), 把 SIGINT ( SIGT ­\nSTP) 直接发送给终端前台进程组中的 每个进程。shell 用 t c s e t pgr p 函数管理这个 进程组的成员 ,用 t c­ se t a t t r 函数管 理 终 端 的 属 性 ,这 两个函数都超出了本书讲述的范围。可以参考[ 62] 获 得 详 细信息。\nshell 回收它所有的僵死子进程。如果 任何作业 因为收到一个未捕获的信号而终止 , 那么 s hell 就输出一条 消息到终端, 消息中包含该作业的 PID 和对该信号的描述。\n图 8- 46 展示了一个 s hell 会话示例。\nlinux\u0026gt; ./shell\n\u0026gt;bogus\nbogus: Command not found.\n\u0026gt;foo 10\nRun your shell program Execve can \u0026rsquo; t find executable\nJob 5035 terminated by signal: Interrupt User types Crt l +C\n\u0026gt;foo 100 \u0026amp;\n[1] 5036 foo 100 \u0026amp;\n\u0026gt;foo 200 \u0026amp;\n[2] 5037 foo 200 \u0026amp;\n\u0026gt;jobs\n5036 Running foo 100 \u0026amp;\n5037 Running foo 200 \u0026amp;\n\u0026gt;fg %1\nJob [1] 5036 stopped by signal: Stopped User types Ctrl +Z\n\u0026gt;jobs\n5036 Stopped foo 100 \u0026amp;\n5037 Running foo 200 \u0026amp;\n\u0026gt;bg 5035\n5035: No such process\n\u0026gt;bg 5036\n[1] 5036 foo 100 \u0026amp;\n\u0026gt;/bin/kill 5036\nJob 5036 terminated by si gnal : Terminated\n\u0026gt; fg %2 Wait for fg job to finish\n\u0026gt;quit\nlinux\u0026gt; Back to the Uni x shell\n图 8- 46 家庭作业 8. 26 的 s hell 会话示例\n练习题答案\n8. 1 进程 A 和 B 是互相并发的, 就像 B 和 C 一样, 因为它们各自的执行是重叠的, 也就是一个进程在另一个进程结 束前开始 。进程 A 和 C 不是并发的 , 因为它们的执行没有 重叠; A 在 C 开始之前就结束了。\n. 2 在图 8- 1 5 的示例程序中 ,父子进程 执行无关的指令集合。然而, 在这个程序中, 父子进程执行 的\n指令集合是相关的,这是有可能的,因为父子进程有相同的代码段。这会是一个概念上的啼碍,所 以请确认你理解了本题的答 案。图 8- 47 给出了进 程图。\n这里的关键点是子进程执行 了两个 pr i nt f 语句。在 f or k 返回之后, 它执行第 6 行的 p r i nt f。然后它从 辽 语句中出来, 执行第 7 行的 pr i n t f 语句。下面是子进程产生 的输出: 父进程只执行 第 7 行的 p r i n t f : p2: x=O\nPl,: .x =2 P2•: • x=l\npr i n 七 f printf exit\nx==l I P2: x=O\nmain f or k pr i n七 f exit\n图 8-4 7 练习题 8. 2 的进程图\n子进程父进程\n8 3 我们知道序列 ache、a bcc 和 bacc 是可能的, 因为它们对应有进程图的拓扑排序(图8-48 ) 。而像\nbcac 和 c bca 这样的 序列不对应有任何拓扑排序, 因此它们是不可行的。\n. # ma i n\na\npr i n t f b\np r"i n t f\nC\np"r i n 七 f\nC\np r i n t f\ne x i t\n图 8 - 48\n练习题 8. 3 的进程图 -\n8. 4\n只简单地计算进程图(图8 - 4 9 ) 中 pr i n t f 顶点的个数就能确 定输出行数。在这里, 有 6 个这样的顶点, 因此程序会打印 6 行输出。\n任何对应有进程图的拓扑排序的 输出序列都是可能的 。例如: He l l o 、 1 、 0 、 Bye 、 2、Bye 是可\n能的。\n. # ma i n\nHe..l.l o p r i n t f\n1\npr 一i 。n t f pr i n t f\nBye\npr i n t f\n二wa 让 p i d pr i n 七f\nB,y.e printf\ne x i t\n图 8 - 49\n练习 题 8. 4 的进程图\n8 5\nun s i gn ed int snooze(unsigned int secs ) { unsigned int re= sl eep(secs ) ;\ncode/ecflsnoo ze.c\nprintf(\u0026ldquo;Slept for %d of %d s e c s . \\ n \u0026quot; , secs-re, secs) ; return re;\n8. 6\n#incl ude \u0026ldquo;csapp.h\u0026rdquo;\ni nt ma i n ( i nt ar g c , c har *argv[], char *envp[])\n{\ncodelecfs/nooze.c codelecfm/yecho.c\ni nt i ;\npr i nt f (\u0026ldquo;Comman d - l i n e ar gument s : \\ n \u0026quot; ) ; for ( i =O; ar gv [ i ] ! = NULL ; i ++)\nprintf (\u0026rdquo; argv[o/.2d] : %s \\n\u0026rdquo; , i , ar g v [i]) ;\nprintf (\u0026quot;\\n\u0026quot;);\nprintf (\u0026ldquo;Envir onme nt var i a bl e s : \\ n \u0026quot; ) ; for ( i =O; envp[i] != NULL ; i ++)\nprintf (\u0026rdquo; envp[%2d] : %s \\n\u0026quot; , i, envp[i]) ; exit (O) ;\n8. 7\nco d ele cf/ my ec ho .c\n只 要休眠进程收到一个未被忽略的信号, s l e e p 函数就会提前返回。但是 , 因为收到一个 SIGINT 信号的默认行为就是终止进程(图 8- 26 ) , 我们必须设置一个 SIGINT 处理程序来允许 s l e e p 函数返回。处理程序简单地捕获 SIGNA L, 并将控制返回给 s l e e p 函数, 该 函数会立即返回。\ncode/ecflsnooze.c\n#i ncl ude \u0026quot; cs app . h\u0026quot;\n3 /• SIGINT handler•/\n4 void handler (int sig)\n5 {\n6 return; /• Catch the signal and return•I\n7 }\n8\n9 unsigned int snooze(unsigned int secs) {\n10 unsigned int re= sleep(secs);\n11\n12 printf(\u0026ldquo;Slept for %d of %d s e cs . \\ n \u0026quot; , secs-re, secs);\n13 return re;\n14 }\n15\n16 int main(int argc, char **argv) {\n17\nif (argc != 2) {\nfprintf(stderr, \u0026ldquo;usage: %s \u0026lt;secs\u0026gt;\\n\u0026rdquo;, argv[O]);\n20 exit(O);\n21 }\n22\nif (signal(SIGINT, handler) == SIG_ERR) I• Install SIGINT•I\nunix_error(\u0026ldquo;signal error\\n\u0026rdquo;); /• handler•/\n(void)snooze(atoi(argv[l]));\nexit(O);\n27 }\ncode/ecf/snooze.c\n8. 8 这个 程序打印 字符串 \u0026quot; 213\u0026rdquo; , 这是卡内 基-梅隆大学 CS: APP 课程的缩写名。父进程开始时 打印\n\u0026ldquo;2\u0026rdquo;, 然后创 建子进程 , 子进程会陷入一 个无限循环。然 后父进程向 子进程发送 一个信号, 并等待它终止。子进程捕获这个信 号(中断这个无限循环), 对计数器值(从初始值 2) 减一, 打印 \u0026ldquo;1\u0026rdquo;\u0026rsquo; 然后终止。在父进程回收子进程之后 , 它对计数器值(从初始值 2) 加一, 打印 \u0026quot; 3\u0026quot; , 并且终止。\n"},{"id":445,"href":"/zh/docs/technology/System/%E6%B7%B1%E5%85%A5%E7%90%86%E8%A7%A3%E8%AE%A1%E7%AE%97%E6%9C%BA%E7%B3%BB%E7%BB%9F/%E4%B9%A6%E7%B1%8D/%E7%AC%AC9%E7%AB%A0-%E8%99%9A%E6%8B%9F%E5%86%85%E5%AD%98/","title":"Index","section":"SpringCloud","content":"第 9 章\nC H A P T E A 9 . .\n虚拟内存\n一个系统中 的进程是与其他进程共享 CPU 和主存资源的。然而, 共享主存会形成一些特殊的挑战。随着 对 CPU 需求的增长, 进程以 某种合理的平滑方式慢了下来。但是如果太多的 进程需要太多的内存, 那么它们中的一些就根本无法运行 。当一个程序没有空间可用时 , 那就是它运气不好了。内存还很容易被破坏 。如果某个进程不小心写了另一个进程使用的内存 , 它就可能以某种完全和程序逻辑无关 的令人迷惑的方式失 败。\n为了更加有效 地管理内存并且少出错, 现代系统提供了一种对主存的抽象概念, 叫做虚拟内存 CV M) 。虚拟内存 是硬件异常、硬件地址翻译 、主存 、磁盘文件和内核软件的完美交互 , 它为每个进程提供了一个 大的、一致的和私有的地址空间。通过一个很清晰的机制,虚拟内存提供了三个重要的能力: 1 ) 它将主存看成是 一个存储在磁盘上的 地址空间的高速缓存,在主存中只保存活动区域,并根据需要在磁盘和主存之间来回传送数据,通过 这种方式 , 它高效地使用了主存。2 ) 它为每个进程提 供了一致的地址空间,从 而简化了内存管理。3 ) 它保护了每个进程的地址空间不被其他进程破坏。\n虚拟内存是计算机系统最重要的概念之一。它成功的一个主要原因就是因为它是沉默地、自动 地工作的 , 不需要应用程序员的任何干涉。既然虚拟内存在幕后工作得如此之好,为什么程序员还需要理解它呢?有以下儿个原因:\n虚拟内存是核心的。虚拟内存遍及计算机系统的所有层面,在硬件异常、汇编器、链接器、加载器、共享对象、文件和进程的设 计中扮演着重要 角色。理解虚拟内存将帮助你更好地理解系统通常是如何工作的。\n. • 虚拟内存是强大的。虚拟内存给予应用程序强大的能力,可以创建和销毁内存片 ( ch unk ) 、将内存片映射到磁盘文件的 某个部分, 以及与其他进程共享内存。比如, 你知道可以通过读写内存位置读或者修改一个磁盘文件的内容吗?或者可以加载一 个文件的内容到内存中,而不需要进行任何显式地复制吗?理解虚拟内存将帮助你 利用它的强大功能在应用程序中添加动力。\n虚拟内存是危险的。每次应用程序引用一个变量、间接引用一个指针,或者调用一个 诸如 ma l l oc 这样的动态分配程序时 , 它就会和虚拟内存发生交互。如果虚拟内存使用不当,应用将遇到复杂危险的与内存有关的错误。例如,一个带有错误指针的程序 可以立即崩溃于"段错误”或者“保护错误",它可能在崩溃之前还默默地运行了几 个小时, 或者是最令人惊慌地, 运行完成却产生不正确的结果。理解虚拟内存以及诸如 ma l l oc 之类的管理虚拟内存的分配程序, 可以帮助你避免这些错误。\n这一章从两个角度来看虚拟内存。本章的前一部分描述虚拟内存是如何工作的。后一部分描述的是应用程序如何使用和管理虚拟内存。无可避免的事实是虚拟内存很复杂,本 章很多地方都反映了这一点 。好消息就是如果你掌握这些 细节, 你就能够手工模拟一个小系统的虚 拟内存机制, 而且虚拟内存的概念将永远不再神秘。\n第二部分是建立在这种理解之上的,向 你展示了如何在程序中使用和管理虚拟内存。你将学会 如何通过显式的内存映射和对像 ma l l oc 程序这样的动态内存分配器的调用来管\n理虚拟内存。你还将了解到 C 程序中的大多数常见的与内存有关的错误, 并学会如何避免它们的出现。\n9. 1 物理和虚拟寻址\n计算机系统的主存被组织 成一个由 M 个连续的字节大小的单元组成的数组。每字节\n都有 一 个 唯 一 的 物 理 地 址 ( Physical Address,\nPA)。第一个字节的地址为 o, 接下来的字节地址为 1, 再下 一个为 2\u0026rsquo; 依此类推。给定 这种简单的结构, CP U 访问内存的最自然的方式就是使用物理地址。我们 把这种方式称为物理寻址 ( phys ic al add ress ing ) 。图 9-1 展示了一个物理 寻址的示例, 该示例的上下文是一条加载指令,它读取从物理 地址 4 处开始的 4 字节字。当 CP U 执行这条加载指令时, 会生成一个有效物理地址, 通 过内存总线, 把它传递给主存。主存取出从物理地址 4 处开始的 4 字节字, 并将它返回给 CP U , CP U 会将它存放在一个寄存器里。\n主存\n0:\n物理地址 2:\n3\n4:\n5:\n6:\n7:\n8:\nM-l:E3 # 数据字\n图 9-1 一个使用物理寻址的系统\n早期的 PC 使用物理寻址, 而且诸如数字信号处理器、嵌入式微控制器以及 Cray 超级\n计算机这样的系统仍然继续使用这种寻址方式。然而,现代处理器使用的是一种称为虚拟 寻址( vir t ual address ing ) 的寻址形式 , 参见图 9-2。\nCPU 芯片 主存\n;\u0026mdash;\u0026mdash;\u0026mdash;\u0026mdash;\u0026mdash;\u0026mdash;\u0026mdash;\u0026ndash; \u0026ndash; \u0026ndash; \u0026mdash;\u0026mdash;\u0026mdash;\u0026mdash;\u0026mdash;\u0026mdash;\n! 虚拟地址 地址翻译 ! 物理地址 I:\n3:\n4:\n, - - - - _- - - _j_ - - - - - - - - - - - - - \u0026ndash; - - - - - - - - \u0026ndash; - - \u0026ndash; - - - - _- - - j 5:\n6:\n7:\nM-1:尸三\n数据字\n图 9- 2 一个使用虚拟寻址的系统\n使用虚拟寻址, CP U 通过生成一 个虚拟地址( Virt ual Address, VA ) 来访问主存,这个虚拟地址在被送到内存 之前先转换成适当的物理地址。将一个虚拟地址转换为物理地址的 任务叫做地址翻译 ( address t ra nslat io n ) 。就像异常处理一样, 地址翻译需要 CPU 硬件和操作系统之间的紧密合作 。CP U 芯片上叫 做内存 管理 单元 ( Memory Managem ent Unit, MM U ) 的专用硬件, 利用存放在主存中的 查询表来动态 翻译 虚拟地址, 该表的内容由操作系统管理。\n9. 2 地址空间\n地址空间 ( add ress s pace) 是一个非负整数地址的有序集合 :\n{0,1,2, ..,}\n如果地址空间中的 整数 是连续 的, 那 么 我 们 说 它 是 一 个 线性地址 空 间 ( linea r address\nspace) 。为了简化讨论, 我们总是假设使用的是线性地址空间。在一个带虚拟内存的系统中, CPU 从一个有 N = 沪个 地 址 的 地 址 空 间 中 生成虚拟地址, 这个地址空间称为虚拟地\n址空间 ( vir t ual address space) :\n{0 , 1, 2 ,…, N — 1 }\n一 个地址空间的大小是由表示最大地址所需要的位数来描述的。例如,一 个 包 含 N = 矿个 地址的虚拟地址空间就叫做一个 n 位地址空间。现代系统通常支持 32 位或者 64 位虚拟地址空间。\n一个系统还有一个物理地址空间 ( ph ys ic al address space), 对应于系统中物理内存的\nM 个字节:\n{0,1,2,… , M — 1 }\nM 不要求是 2 的幕,但 是为了简化讨论, 我们假设 M = 2勹\n地址空间的概念是很重要的,因为它清楚地区分了数据对象(字节)和它们的属性(地 址)。一旦认识到了这种区别,那么我们就可以将其推广,允许每个数据对象有多个独立的地址,其中每个地址都选自一个不同的地址空间。这就是虚拟内存的基本思想。主存中的每字节都有一个选自虚拟地址空间的虚拟地址和一个选自物理地址空间的物理地址。\n让 练习题 9. 1 完成下面的表格,填写缺失的条目,并且用适当的整数取代每个问号。利用下列单位: K= z10 ( 如lo , 千),M = 沪 ( m ega , 兆, 百 万),G = 230 (giga, 千兆, 十 亿), T = 2气 t era , 万亿 ),P = 250 (peta, 于于兆),或 E = 260 (exa, 千兆兆)。\n虚拟地址位数( n ) 虚拟地址数( N ) 最大可能的虚拟地址 8 21 = 64K 232— I =?G-1 2\u0026quot; = 256T 64 3 虚拟内存作为缓存的工具\n概念上而言,虚 拟内存被组织为一个由存放在磁盘上的 N 个连续的字节大小的单元组成的 数组。每字节都有一个唯一的虚拟地址,作 为到数组的索引。磁盘上数组的内容被缓存在主存中。和存储器层次结构中其他缓存一样,磁 盘(较低层)上的数据被分割成块, 这些块作为磁盘和主存(较高层)之间的传输单元。V M 系统通过将虚拟内存分割为称为虚拟页CV 江 t ua l Page, VP)的大小固定的块来处理这个问题。每个虚拟页的大小为 P = 沪字节。类似地 , 物理内存被分割为物理 页 ( P h ysica l Page, PP), 大小也为 P 字节(物理页也被称为 页帧 ( pag e fr am e) ) 。\n在任意时刻,虚拟页面的集合都分为三个不相交的子集:\n未分配的: V M 系统还未分配(或者创建)的页。未分配的块没有任何数据和它们相关联,因此也就不占用任何磁盘空间。 缓存的:当 前 已缓存在物理内存中的已分配页。 未缓存的:未 缓 存 在 物 理内存中的已分配页。\n图 9-3 的示例展示了一个有 8 个虚拟页的小虚拟内存。虚拟页 0 和 3 还没有被分配,\n因此在磁盘上还不存在。虚拟页 l 、4 和 6 被缓存在物理内存中。页 2、5 和 7 已经被分配了,但是当前并未缓存在主存中。\nVPO VP I\n虚拟内存 物理内存\npp 0\npp I\nVP 2•-p - 11 不 农 仔 口 \u0026lsquo;J IN- I\n虚拟页 ( VP ) 物理 页 ( pp )\nPP 2m-p - I\n存储在磁盘上 缓存在DRAM 中\n图 9分 一个 VM 系统是如何使用 主存作为缓存的\n9. 3. 1 DRAM 缓存的组织结构\n为了有助于清晰理解存储层次结构中不同的缓存概念, 我们将使用术语 SRAM 缓存来表示位于 CPU 和主存之间的 L1、L2 和 L3 高速缓存, 并且用术语 DR AM 缓存来表示虚拟内存系统的缓存,它 在主存中缓存虚拟页。\n在存储层 次结构中, DRAM 缓存的位置对它的组织结构有很大的影响。回想一下, DR AM 比 SRAM 要 慢 大约 10 倍, 而磁盘要 比 DRAM 慢大约 100 000 多倍。因此, DR AM 缓存中 的不命中比起 SR AM 缓存中的不命中要昂贵得多, 这是因为 DRA M 缓存不命中要由磁盘来服务, 而 S RAM 缓存不命中通常是由基 于 DR AM 的主存来 服务的。而且,从 磁盘的一个扇区读取 第一个字节的时间开销比起读这个扇区中连续的字节要慢大约\n100 000 倍。归根到底 , D RAM 缓存的组织结构完全是由巨大的不命中开销驱动的 。\n因为大的不命中处罚和访问第一个字节的开销, 虚拟页往往很 大, 通常是 4KB ~ 2M B。由于大的不命中处罚, DR AM 缓存是全相联的, 即任何虚拟页都可以 放置在任何的 物理页中。不命中时的替换策略也很 重要 , 因为替换错了虚拟页的处罚也非常之高。因此, 与硬件对 S RA M 缓存相比, 操作系统 对 DR AM 缓存使用了更复杂精密的替换算法。\n(这些替 换算法超出了我们的讨论范围)。最后, 因为对磁盘的访间时间很长, DRAM 缓存总是使用写回,而不是直写。\n9. 3. 2 页表\n同任何缓存一样, 虚 拟内 存系统必须有某种方法来判定一个虚拟 页是否缓存在D RAM 中的某个地方 。如果是, 系统还必须确定 这个虚拟页存放在哪个物理页中。如果不命中,系统必须判断这个虚拟页存放在磁盘的哪个位置,在物理内存中选择一个牺牲 页, 并将虚拟页从磁盘复制到 DRA M 中,替 换这个牺牲页。\n这些功能 是由软硬件联合提供的 , 包括操作系统软件、MMU ( 内存管理单元)中的地址翻译硬件和一个存放在物理内 存中叫做页表( page ta ble ) 的数据结构, 页表将虚拟页映射到物理页。每次地址 翻译硬件 将一个虚 拟地址转换为物理 地址时,都 会读取页表。操作系统负责维护页表的内容,以 及在磁盘与 DRAM 之间来回传送页。\n图 9-4 展示了一个页表的基本组织结构。页表就是一个页表 条目 ( P age Table Entry, PT E) 的数组。虚拟地址 空间中的每个页在页表中一个固定偏移昼处都有一个 PT E。为了\n我们的目的 ,我 们 将 假 设 每 个 PT E 是 由 一 个 有 效 位 C valid bit ) 和一 个 n 位 地 址 字 段 组 成的。有效位表明了该虚拟页当前是否被 物理内存\n缓存在 DRA M 中。如果设 置了有效位 , 物理页号或 ( D RAM )\n有效位 磁舟铀计 」 VPI I PP O\n那 么 地 址 字 段 就 表 示 D RAM 中 相 应 的物理页的起始位置,这个物理页中缓存 了该虚拟页。如果没有设置有效位,那 么一个空地址表示这个虚拟页还未被分 配。否则,这个地址就指向该虚拟页在\n磁盘上的起始位置。\nPTE O I 0\nPTE71 I\n常驻内存的页表\ 、\\\nVP2 VP7\nVP4\n虚拟内存\n(磁盘)\nVP!\nVP2\npp 3\n图 9-4 中 的 示 例 展 示 了 一 个 有 8 个\n、、、、、、、谥\n( DRAM) 、、、、、 I VP3\n虚拟页和 4 个 物理页的系统的页表。四\n、、 I\nVP4\n个虚 拟 页 ( VP 1、 VP 2 、 VP 4 和 VP\n7 ) 当 前 被 缓 存 在 DRAM 中。 两 个 页\n、、、、、、\n勹 VP6\nVP7\n(VP 0 和 VP 5) 还 未 被 分 配 , 而 剩 下 的 件I 9- I 页表\n页 ( VP 3 和 VP 6) 已 经被分 配 了 , 但 是 当 前 还 未 被 缓 存 。 图 9-4 中 有 一 个 要 点 要 注 意 , 因为 DRA M 缓存是 全相联的 , 所以 任意 物理 页 都 可以 包 含 任 意 虚 拟 页 。\n心 练习题 9. 2 确定 下列 虚拟地址大小( n ) 和 页大小CP ) 的 组合所需要的 PT E 数量:\nn P=2P I PTE 数 量 16 4K 16 8K 32 4K 32 8K 9. 3. 3 页命中\n考 虑 一 下 当 CPU 想要读包含在 VP 2 中 的 虚 拟 内 存 的 一 个 字 时 会 发 生 什 么(图 9-5) , VP 2 被 缓 存 在 DR AM 中。使用我们将在 9. 6 节 中 详 细 描 述 的 一 种 技 术 , 地 址 翻 译 硬 件 将 虚拟地 址 作 为 一 个 索 引 来 定 位 PT E 2, 并从内存中读取它。因为设置了有效位,那么地址翻译硬件就知道 VP 2 是 缓 存 在 内 存 中 的 了 。 所 以 它 使 用 PT E 中 的 物 理 内 存 地 址(该 地 址 指向 p p 1 中 缓 存 页 的 起 始 位 置 ),构 造 出 这 个 字 的 物 理 地 址 。\n物理内存\n(DRAM)\n如 I PPO\nVP2\n立\n妇 I PP 3\n、、、、、\n、、\\\n、、、\n、、\n常驻内存的页表、一、、、、 、\ !\n( D RAM ) 、、、、、[\n、、、、、、、、\n图 9 - 5 V M 页 命 中 。 对 V P 2 中 一 个 字的引用就会命中\n9. 3. 4 缺页\n在虚拟内存的习惯说法中, D RAM 缓存不命中称为缺页 ( page fault ) 。图 9-6 展示了在缺页之前 我们的示例页表的状态。 CP U 引用了 V P 3 中的一个字, V P 3 并未缓存在DR AM 中。地址翻译硬件从内存中读取 PT E 3, 从有效位推断出 VP 3 未被缓存, 并且触发一个缺页异 常。缺页异常调 用内核中的缺页异常处理程序,该 程序会 选择一个牺牲页, 在此例中就是存放在 p p 3 中的 VP 4。如果 VP 4 已经被修改了, 那么内核就会将它复制回磁盘。无论 哪种情况,内 核都会修改 VP 4 的页表条目, 反映出 VP 4 不再缓存在主存中这一事实。\n厂有三 # o I·-\nPTE 7 I汇飞二 、、\n常驻内存的页表\、、、\ \、、\n( DRAM ) 、、、 、\n物理内存\n(DRAM)\nVPI I PPO\n义义\nVP4 I PP 3\n虚拟内存\n(磁盘)\nVPI VP2\nVP3\n、、、、、 I\n、、、、I、\n、、勹\nVP4 VP6\nVP7\n图 9-6 V M 缺页(之前)。对 VP 3 中的字的引用会不命中,从 而 触 发 了 缺页\n接下来,内 核从磁盘复制 V P 3 到内存中的 pp 3, 更新 PT E 3, 随后返回。当异常处理程序返回时,它会重新启动导致缺页的指令,该指令会把导致缺页的虚拟地址重发送到 地址翻译硬件。但是 现在, VP 3 已经缓存在主存中了, 那么页命中也能由地址 翻译硬件正常处理了。图 9-7 展示了在缺页之后我 们的示例页表的状态。\n物理内存\n( DRAM )\nVP I I PPO VP2\nVP7\nVP3 I PP 3\nPTE 7 I I\n、\\n、、、\n、、、\n、、、、、 、、、、、\n虚拟内存\n(磁盘)\nVP I\nVP2\n(DRAM) -,,、、、、、I、、、V、P、3 、、\n、、、、、、\u0026mdash;\nVP4 VP6\nVP7\n图 9-7 VM 缺页(之后。)缺页处理程序选择 VP 4 作为牺牲页,并 从磁盘上用 VP 3 的副本取代它。在缺页处理程序重新启动导致缺页的指令之后,该指令将从内存中正常地读取字,而不会再产生异常\n虚拟内存是在 20 世纪 60 年代早 期发明的, 远在 CPU-内存之间差距的加大引发产生SRA M 缓存之前。因此,虚 拟内存系统使用了和 S R A M 缓存不同的术语, 即 使 它 们 的 许多概念是相似的。在虚拟内存的习惯说法中,块被称为页。在磁盘和内存之间传送页的活动叫做交换 ( s wapping ) 或者 页 面调 度( paging ) 。页从磁盘换入(或者页 面调入)DR AM 和从 DRA M 换出(或者 页 面调 出)磁盘。一直等待,直 到 最 后 时 刻 , 也 就 是 当有不命中发生时, 才换入页面的这种策略称为按 需 页 面调 度( dem and paging ) 。也可以采用其他的方法, 例如尝试着预测不命中,在页面实际被引用之前就换入页面。然而,所有现代系统都使用的 是按需页面调度的方式。\n9. 3. 5 分配页面\n图 9-8展示了当操作系统分配一个新的虚拟内存页时对我们示例页表的 影响,例 如,调 用 ma l l o c 的 结 果 。 在这个示例中, VP5 的分配过程是在磁盘上创建空间并更新 PT E 5, 使它指向磁盘上这个新创建的页面。\n物理内存\n(DRAM)\nVP I IPP 0 PTE O I O I null \u0026ndash;r J VP2\nVP3IPP 3\n虚拟内存\n(磁盘)\nPTE 7 I I ,, 、 、、主、\ VP!\n常驻内存的页表\、、、、、、、、、、、 I VP2\n9. 3. 6 又是局部性救了我们\n(DRAM)\n\\、、、、、、:\u0026rsquo;,,,,.[\nVP3\nVP4\n当我们中的许多人都了解了虚拟内存的概念之后,我们的第一印象通常是它的效率应该是非常低。因为不命中处罚很大,我们担心页面调度会破坏程序性能。实际上,虚拟内存工\n\u0026lsquo;,,\u0026lt;、、、、、、、寸\nVP5\nVP6\n舌 # 图 9-8 分配一个新的虚拟页面。内核在磁盘上分配 VP 5,\n并且将 PTE 5 指向这个新的位 置\n作得相当好, 这主要归功于我们的老朋友局部性(l ocalit y) 。\n尽 管在整个运行 过程中程序引用的不同页面的总数可能超出物理内存总的大小,但 是局 部性原则保证了在任意时刻,程序将趋向 千在一个较小的活动页面 (active page)集合上工作, 这个集合叫做工作集 ( working set) 或者常驻集合( resident set) 。在初始开销 ,也 就是 将工作集页面调度到内存中之后,接下来对这个工作集的引用将导致命中,而不会产生额外的磁盘流量。\n只要我们的程序有好的时间局部性,虚 拟 内 存 系统就能工作得相当好。但是, 当 然 不是所有的程序都能展现良好的时间局部性。如果工作集的大小超出了物理内存的大小,那 么程序将产生一种不幸的状态,叫 做 抖 动( t hra s hing ) , 这时页面将不断地换进换出。虽然虚拟内存通常是有效的,但是如果一个程序性能慢得像爬一样,那么聪明的程序员会考虑 是不是发生了抖动。\n区 且 统计缺页次数\n你可以利 用 L m u x 的 ge tr u s a ge 函数监测缺 页的 数量(以及许多其他 的信息)。\n9. 4 虚拟内存作为内存管理的工具\n在上一节中, 我们看到虚拟内存是如何提供一种机制,利 用 DR A M 缓 存 来 自通常更大的虚拟地址空间的页面。有趣的是, 一些早期的系统, 比 如 DEC PDP-11 / 70 , 支持的是一个比物理内存更小的虚拟地址空间。然而,虚拟地址仍然是一个有用的机制,因为它\n大大地 简化了内存管理, 并提供了一 种自然的保护内存的方法。到目前为止,我们都假设有一个单\n虚拟地址空间\n物理内存\n独的页表,将一个虚拟地址空间映射到\n物理地址空间。实际上,操作系统为每个进程提供了一个独立的页表,因而也就是一个独立的虚拟地址空间。图 9- 9 展示了基本思想。在这个示例中,进程 l 的页表将 V P l 映射到 P P 2, VP 2 映射到 pp 7。相似地, 进程 )的页表将\n进程i:\n进程 j :\nN-1\n地址翻译\n共享页面\nVP 1 映射到 PP 7, VP 2 映射到 PP N - 1\nM - 1\n10 。注意 ,多 个虚拟页面可以 映射到同 图 9-9 VM 如何为进程提供独立的地址空间。操作系统一个共享物理页面上。 为系统中的每个进程都维护一个独立的页表\n按需页面调度和独立的虚拟地址空间的结合,对系统中内存的使用和管理造成了深远的影响。特别地 , VM 简化了链接和加载、代码 和数据共享, 以及应用程序的内存分配。\n简化链接。独立的 地址空间允 许每个进程的内存映像使用相同的基本格式 , 而不管代码和数据实际存 放在物理内 存的何处。例如, 像我们在图 8-13 中看到的, 一个给定 的 L in ux 系统上的 每个进程都使用类似的内存格式。对于 64 位地址空间, 代码段总是从虚拟地址 Ox 4 0 00 0 0 开始。数据段跟在代码段之后, 中间有一段符合要求的对齐空白。栈占据用户进程地址空间 最高的 部分, 并向下生长。这 样的一致性极大地简化了链接器的设计和实现,允 许链接器生成完全链接的可执行文件,这些可执行文件是独立于物理内 存中代码和数据的最终位置的。\n简化加栽。虚拟内存还使得容易向内存中加载可执行文件和共享对象文件。要把目 标文件中 . t e x t 和 . da 七a 节加载到 一个新创建的进程中, L in u x 加载器为代码 和数据段分配虚拟页,把它们标记为无效的(即未被缓存的),将页表条目指向目标文件 中适当的位 置。有趣的是, 加载器从不从磁盘到内 存实际复制任何数据。在每个页初次被引用时, 要么是 CP U 取指令时引用的, 要么是一条正在执行的指令引用一个内存位置时引用的,虚拟内存系统会按照需要自动地调入数据页。\n将一组连续的虚拟页映射到任意一个 文件中的任意位 置的表示法称作内存映 射( mem­ ory m a pping ) 。Lin u x 提供一个称为 mma p 的 系统调用,允 许应用程序自己做内存映射。我们会在 9. 8 节中更详细地描述应用级内存映射。\n简化共享。独立地址空间为操作系统提供了一个管理用户进程和操作系统自身之间 共享的一致机制。一般而言, 每个进程都有自己 私有的代码、数据、堆以及栈区域, 是不和其他进程共 享的。在这种情 况中, 操作系统创建页表,将 相应的 虚拟页映射到不连续的物理页 面。\n然而, 在一些情况中, 还是需要进程来共享代码和数据。例如, 每个进程必须询用相同的操作系统内核代码 , 而每个 C 程序都会 调用 C 标准库中的程序, 比如 pr i nt f 。操作系统通过将不同进程中适当的虚拟页面映射到相同的物理页面,从而安排多个进程共享这部分代 码的一个副本, 而不是在每个 进程中都包括单独的内 核和 C 标准库的副本, 如图 9-9 所示。\n简化内存 分配。虚拟内存为向用 户进程提供一个简单的分配额外 内存的机制。当一个运行在用户进程中的 程序要求额外的堆空间时(如调用 ma l l o c 的结果), 操作系统分配一个适当数字(例如 k ) 个连续的虚拟内存页面, 并且将它们映射到物理内存\n中任意位置的 K 个任意的物理页面。由 于页表工作的方式, 操作系统没有必要分配\nk 个连续的物理内存页面。页面可以随机地分散在物 理内存中。\n9. 5 虚拟内存作为内存保护的工具\n任何现代计算机系统必须为操作系统提供手段来控制对内存系统的访问。不应该允许 一个用户进程修改它的只读代码段。而且也不应该允许它读或修改任何内核中的代码和数 据结构。不应该允许它读或者写其他进程的私有内存,并且不允许它修改任何与其他进程 共享的虚拟页面,除非所有的共享者都显式地允许它这么做(通过调用明确的进程间通信 系统调用)。\n就像我们所看到的,提供独立的地址空间使得区分不同进程的私有内存变得容易。但 是,地址 翻译机制 可以以一种自然 的方式扩展到提供更好的访问控制。因为每次 CP U 生成一个地址时 , 地址翻译硬件都会读一个 PT E , 所以通过在 PT E 上添加一些额外的许可位来控制对 一个虚拟页面内容的访问十分简 单。图 9-10 展示了大致的思想 。\n带许可位的页表\nSUP READ WRITE 地址 物理内存\nI PPO\n亡 pp 2\nPP4\nSUP READ WRITE 地址\n./\u0026quot;\nPP6\nVPO: 进程}: VP I: 否 是 是 是 不口 是 pp 9 pp 6 V I PP 9 VP 2: 不口 是 定曰 PP I I 恤 I PP 11 啊 I 图 9-10 用虚拟内存来提供页面级的内存保护\n在这个示例 中, 每个 PT E 中已经添加了三个许可位。SUP 位表示进程是否必须运行在内核(超级用户)模式下才能访问 该页。运行在内核模式中的进程可以访问任何页面, 但是运行在用户模式中的进程只允 许访问那些 SU P 为 0 的页面。READ 位和 WRIT E 位控制对页 面的读和写访问。例如, 如果进程 1 运行在用户模式下, 那么它有读 VP 0 和读写VP 1 的权限。然 而, 不允许它访问 VP 2。\n如果一 条指令违反了这些许可条件, 那么 CPU 就触发一 个一般保护故障, 将控制传递给一个内核中的异常处理程序。Linu x s hell 一般将这种异常报告为 "段错误( seg menta­ tion fault ) \u0026quot; 。\n6 地址翻译\n这一节讲述的是 地址翻译的 基础知识 。我们的目 标是让你了解硬件在支持虚拟内存中的角色,并给出足够多的细节使得你可以亲手演示一些具体的示例。不过,要记住我们省 略了大量的细节, 尤其是和时序相关的细节, 虽然这些细节对硬件设计者来说是非常重要的, 但是超出 了我们 讨论的范围。图 9-11 概括了我们在这节里将要使用的所有符号,供读者参考。\n符 号 物理地址 ( PA) 的组成部分 描述 PPO 物理页面偏移量(字节) PPN co 物理页号 缓冲块内的字节偏移鱼 CI 高速缓存索引 CT 高速缓存标记 图 9-11 地 址 翻译符号小结\n形式上来说,地 址 翻译是一个 N 元素的虚拟地址空间( VAS ) 中的元素和一个 M 元素\n的 物 理地址空间( PAS ) 中元素之间的映射,\nMAP : VAS\u0026ndash; PASU0\n这里\nA\u0026rsquo; 如果虚 拟地 址 A 处的数据在 P AS 的 物理地址 A\u0026rsquo; 处\nMAPCA) = { O 如果虚拟地址 A 处的数据不 在 物理 内存 中\n图 9-12 展示 了 MMU 如何利用页表来实现这种映射。CPU 中的一个控制寄存器,页 表\n基址寄存 器( Page Table Base Register , PT BR)指向当前页表。n 位的虚拟地址 包含两个部分: 一 个 p 位的虚拟页 面 偏移 ( Virt ual Page Offset, VPO) 和一个( n - p ) 位的虚拟 页号 ( Virtu al\n页表基址寄存器(PTBR)\nn-1\n虚拟页号( VPN )\n虚拟地址\np p - 1 0\n虚拟页偏移批\u0026lt; v o )\n物理页号( PPN )\n页表\n如果有效位= 0,\n那么页面就不在\n存储器中(缺页) m- 1\n物理页号 ( PPN )\nP /- 1 i 。1\n物理页偏移量( PPO )\n物理地址图 9-12 使 用 页表的地址翻译\nPage Numbe r , VPN) 。 MMU 利用 VPN 来选择适当的 PTE。例如, VPN 0 选择 PTE O , VPN 1 选择 PTE 1, 以此类推。将页表条目中物理 页号 ( Physical Page Number, PPN) 和虚拟地址中的 VPO 串联起来,就 得到相应的物理地址。注意,因 为物理和虚拟页面都是 P 字节的, 所以 物理 页面偏 移( Physical Page Offset, PPO) 和 VPO 是相同的。\n图 9-13a 展示了当页面命中时, CPU 硬件执行的步骤。\n笫 l 步 : 处 理 器生成一个虚拟地址,并 把 它 传送给 MMU 。 笫 2 步 : MMU 生成 PT E 地址,并 从 高速缓存/主存请求得到它。\n笫 3 步 : 高速缓存/主存向 MMU 返回 PT E。\n笫 4 步 : MMU 构造物理地址,并 把 它 传送给高速缓存/主存。\n. 第 5 步 : 高速缓存/主存返回所请求的数据字给处理器。\nCPU芯片 CD\n: P\u0026rsquo;I\u0026rsquo;EA\na ) 页面命中\nb ) 缺页\n图 9-13\n页面命中 和缺页的 操作图 ( VA, 虚拟地址 。PT EA : 页表条目 地址 。\nPTE: 页表条目。PA: 物理地址)\n页面命中完全是由硬件来处理的,与之不同的是,处理缺页要求硬件和操作系统内核 协作完成, 如图 9-136 所示。\n笫 l 步到 笫 3 步: 和图 9-13a 中的第 1 步到第 3 步相同。 笫 4 步: PT E 中 的 有 效 位是零,所 以 MMU 触发了一次异常,传 递 CPU 中的控制到操作系统内核中的缺页异常处理程序。\n笫 5 步: 缺页处理程序确定出物理内存中的牺牲页, 如果这个页面已经被修改了, 则 把它换出到磁盘。\n. 第 6 步: 缺 页 处理程序页面调入新的页面,并 更 新 内 存 中 的 PT E 。\n. 第 7 步: 缺页处理程序返回到原来的进程,再 次执行导致缺页的指令。CPU 将引起缺页的 虚拟地址重新发送给 MMU。因为虚拟页面现在缓存在物理内存中, 所以就会命中, 在 MMU 执行了图 9-13b 中的步骤之后, 主存就会将所请求字返回给处理器。\n; 练习题 9. 3 给定一个 32 位的虚拟地址空 间 和 一个 24 位的 物理 地址, 对于下 面的 页面 大 小 P , 确定 VP N 、VPO、PPN 和 P PO 中的位数:\np VPN位数 VPO位数 PPN位数 PPO位数 IKB 2KB 4KB 8KB 9. 6. 1 结合高速缓存和虚拟内存\n在任何既使用虚拟内存又使 用 S RAM 高速缓存的系统中,都 有应该使用虚拟地址还是 使 用 物理地址来访问 SRAM 高速缓存的问题。尽管关千这个折中的详细讨论已经超出了我们的讨论范围,但是大多数系统是选择物理寻址的。使用物理寻址,多个进程同时在 高速缓存中有存储块和共享来自相同虚拟页面的块成为很简单的事情。而且, 高速缓存无需 处理保 护问 题 ,因 为访问权限的检查是地址翻译过程的一部分。\n图 9-14 展示了一个物理寻址的高速缓存如何和虚拟内存结合起来。主要的思路是地址 翻译发生在高速缓存查找之前。注意,页 表条目可以缓存, 就 像其他的数据字一样。\nCPU 芯片 i\u0026mdash;\u0026mdash;:\u0026ndash;\nPTE\n:\n不命中\nPTEA\nPA\nI 内存\nPA I 数 据\n命中\nLI\n高速缓存\n图 9-1\u0026quot;1 将 V M 与物理寻址的高速缓存结合起来 ( V A , 虚拟地址。\nPTEA, 页表条目地址。P T E , 页表条目。P A , 物理地址)\n9. 6. 2 利用 TLB 加速地 址 翻译\n正如我们看到的, 每次 CP U 产生一个虚拟地址, MMU 就必须查阅一个 PT E , 以便将虚拟地址翻译为物理地址。在最糟糕的情况下 , 这会要求从内存多取一次数据, 代价是几 十到几百个周期。如果 PT E 碰巧缓存在 Ll 中,那 么 开销就下降到 1 个或 2 个 周 期。然而, 许多系统都试图消除即使是这样的开销, 它 们 在 MM U 中包括了一个关于 PT E 的小的缓 存,称 为翻译后备缓冲 器 ( T ra ns la t io n Lookaside Buffer, TLB)。\nTLB是一个小的、虚拟寻址的缓存, 其 n- 1 p +t p+t- 1 p p-1 0\n中每一 行都保存着一 个由单 个 PTE 组 成 的块。 j TLB标记 (TLBT) I TLB索引 (TLBI) I VPO\n\u0026lsquo;y ,\nT L B 通常有高度的相联度。如图 9-15 所示,\nVPN\n用于组选择和行匹配的索引和标记字段是从 图 9- l5 虚拟地址中用以访 问 TLB 的组成部分\n虚拟地址中的虚拟页号中提取出 来的。如果 TLB 有 T = 2\u0026rsquo; 个组, 那么 T LB 索引 ( T LBD是由\nVPN 的 t 个最低位组成的, 而 TLB 标 记 (T LBT)是由 VPN 中剩余的位组成的。\n图 9- l 6a 展示了当 T LB 命中时(通常情况)所包括的步骤。这里的关 键点是, 所有的地址翻译步骤都是在芯片 上的 MMU 中执行的 , 因此非常快。\n笫 1 步: CPU 产生一个虚拟地址 。\n笫 2 步和 笫 3 步: MMU 从 T LB 中取出相应的 PT E。\n. 第 4 步: MMU 将这个虚拟地址翻译成一个物理地址,并且 将它发送到高速缓存/主存。\n笫 5 步: 高速缓存/主存将所请求的数 据字返回给 CPU 。\n当 T LB 不命中时, MMU 必须从 L1 缓存中取出相应的 PT E , 如图 9-166 所示。新取出的 PT E 存放在 T LB 中, 可能会覆盖— 个已经存 在的条目。\nCPU 芯片\n\u0026mdash;\u0026mdash;\u0026mdash;\u0026mdash;\u0026mdash;\u0026mdash;\u0026mdash;\u0026mdash;\u0026mdash;\u0026mdash;\u0026mdash;\u0026mdash;\u0026mdash;-\niC\u0026ndash;P\u0026ndash;U\u0026ndash;芯\u0026ndash;片\u0026mdash;\u0026mdash;\u0026mdash;\u0026mdash;\u0026mdash;\u0026mdash;\u0026mdash;\u0026mdash;\u0026mdash;\u0026mdash;\u0026mdash;\n'\n血 - - -\nG) 数据\nTLB命中 图 9-16 T LB 命中和不命中的操作图\n@ # TLB不命中 9. 6. 3 多级页表\n到目前为止,我们一直假设系统只用一个单独的页表来进行地址翻译。但是如果我们 有一个 32 位的地址空间 、4KB 的页面和一个 4 字节的 PT E , 那么即使应用所引用的只是虚拟地址空间中很小的 一部分, 也总是需要一个 4MB 的页表驻留在内存中。对于地址空间为 64 位的 系统来说, 间题将变得更 复杂。\n用来压缩页表的常用方法是使用层次结构的页表。用一个具体的示例是最容易理解这 个思想的。假设 32 位虚拟地址空间被分为 4KB 的页, 而每个页表条目都是 4 字节。还假设在这一时刻, 虚拟地址空间有 如下形式 :内 存的前 ZK 个页面分 配给了代码和数据,接下来的 6K 个页面还未分配,再 接下来的 1023 个页面也未分配, 接下来的 1 个页面分配给了用户栈。图 9-17 展示了我们 如何为这个虚拟地址空间 构造一 个两级的页表层次结构。\n一级页表中的每个 PT E 负责映射虚拟地址空间 中一个 4MB 的片( chunk ) , 这里每一片都是由 1024 个连续的页面组成 的。比如, PT E 0 映射第一片, PT E 1 映射接下来的一片, 以此类推。假设 地址空间是 4GB, 1024 个 PT E 已经足够覆盖 整个空间 了。\n如果片 1 中的每个页面都未被分配, 那么一级 PTE i 就为空。例如 , 图 9-17 中, 片 2~ 7 是未被分 配的。然而 , 如果在片 1 中至少有一个页是分配了的, 那么一级 PT E i 就指向一个二级 页表的基址。例如, 在图 9-17 中, 片 0、1 和 8 的所有或者部分巳被分配, 所以它们的一级 PTE 就指向二级页表。\n一级页表 二级页表 虚拟内存\n已分配的 2K 个代码和数据 VM 页\nPTE 5 (null)\nPTE 6 (null)\nPTE 7 (null)\nGap I 6K 个未分配的 VM 页\n(IK-9)\n空PTE\n1023 』\n图 9- 17 一个两级页表层次结构。注意地址是从上往下增加的\n二级页表中的每个 PT E 都负责映射一个 4KB 的 虚拟内存页面,就 像 我们查看只有一级的 页表一样。注意,使 用 4 字节的 PT E , 每个一级和二级页表都是 4KB 字节, 这刚好和一个页面的大小是一样的。\n这种方法从两个方面减少了内存要求。第一, 如果一级页表中的一个 PT E 是空的, 那 么 相 应的二级页表就根本不会存在。这代表着一种巨大的潜在节约, 因 为对于一个典型的 程序, 4GB 的虚拟地址空间的大部分都会是未分配的。第二,只 有 一 级 页 表才需要总是在主存中;虚拟内存系统可以在需要时创建、页面调入或调出二级页表,这就减少了主存 的压力;只 有 最 经 常 使 用 的 二 级 页 表才需要缓存在主存中。\n图 9-18 描述了使用 K 级 页 表层次结构的地址翻译 。虚拟地址被划分成为 K 个 VP N 和\n1 个 VPO 。每个 VP N i 都是一个到第 z 级 页 表的索引,其 中 1 ::::;;;i::::;;; k。 第 )级 页 表中的每个 PT E , 1::::;;;j 冬 k —1, 都 指 向 第 j + l 级的某个页表的基址。第 k 级 页 表 中 的 每个 PTE 包含 某 个物理页面的 PP N , 或 者 一个磁盘块的地址。为了构造物理地址,在 能 够 确定 PPN 之前 , MMU 必须访问 K 个 PT E。对于只有一级的页表结构, PPO 和 VPO 是相同的。\n虚拟地址\nn - 1\n曰 勹\nm-1 p-q\nPPN I PPO\n物理地址\n图 9- 18 使用 K 级 页 表 的 地 址 翻 译\n访问k 个 PT E , 第一眼看上去昂 贵而不切实际。然而, 这里 T LB 能够起作用, 正是通过将不同层次上 页表的 PT E 缓存起来。实际上, 带多级页表的地址翻译并不比单级页表慢很多。\n6. 4 综合:端到端的地址翻译\n在这一节里,我们通过一个具体的端到端的地址翻译示例,来综合一下我们刚学过的 这些内容 , 这个示例运行在有一个 T LB 和 Ll cl- cache 的小系统上。为了保证可管理性, 我们做出如下假设:\n内存是按字节寻址的。 内存访问是针对 1 字节的字 的(不是4 字节的字)。 虚拟地址是 14 位长的( n = 14) 。 物理地址是 12 位长的( m = 1 2) 。 页面大小是 64 字节( P = 64) 。 T LB 是四路组相联的 ,总 共有 16 个条目。\nLl cl- cache 是物理寻址、直接映射的, 行大小为 4 字节, 而总共有 16 个组。\n图 9-19 展示了虚拟地址和物理地址的格式。因为每个 页面是 26 = 64 字节, 所以虚拟地址和物理地址的低 6 位分别作为 VPO 和 PPO 。虚拟地址的高 8 位作为 VP N。物理地址的高 6 位作为 PP N。\n13 12 11 10 9 8 7 6 5 4 3 2 1 0\n虚拟地址 I I I I I I I I I I I I I j\nVPN\n(虚拟页号)\nVPO\n(虚拟页偏移)\n物理地址\n11 . 10 . 9l\n8 . 7 . 6 .\n5 . 4 .\n3 . 2 .\n1 . 0\nPPN\n(物理页号)\nPPO\n(物理页偏移)\n图 9-19 小内存系统的寻址。假设 14 位的虚拟地址 ( n = l 4 ) ,\n1 2 位的物理地址 ( m = l 2 ) 和 64 字节的页面 CP = 64 )\n图 9-20 展示了小内存系统的一个快照, 包括 T LB C 图 9- ZOa ) 、页表的一部分(图9 - 206 ) 和 Ll 高速缓存(图9- ZOc) 。 在 T LB 和高速缓存的图上面, 我们还展示了访问这些设备时硬件是如何划 分虚拟地址 和物理地址的位的。\nT LB。T LB 是利用 VP N 的位进行虚拟寻址的。因为 T LB 有 4 个组, 所以 VP N 的低 2 位就作为组索引( T LBI) 。VP N 中剩下的高 6 位作为标记CT L BT ) , 用来区别可能映 射到同一个 T LB 组的不同的 VP N。\n页表。这个 页表是一个单级设计, 一共有 28 = 256 个页表条目( PT E ) 。然而, 我们只对这些条目中的开头 16 个感兴趣。为了方便 , 我们用索引 它的 VP N 来标识每个\nPTE; 但是要记住这些 VP N 并不是页表的一部分,也 不储存在内存中。另外, 注意每个无效 PT E 的 PPN 都用一个破折号来表示, 以加强一个概念:无 论刚好这里存储的是什么位值, 都是没有任何意义的。\n高速缓存 。直接映射的缓存是通过 物理地址中的字段来寻址的。因为每个块都是 4 字节, 所以物理地址的低 2 位作为块偏移( CO ) 。因为有 16 组, 所以接下来的 4 位就用来表示组索引 ( CD 。剩下的 6 位作为标记CCT ) 。\n虚拟地址\n\u0026lsquo;TLBT I七TLBI-\n13 12 II IO 9 8 7 6 5 4 3 2 I 0\nI I I I I I I I I I I I I I I\n, VPN .\u0026lsquo;VPO I\n11I1IJ1iJlI II会 # TLB: 四组, 16 个条目,四路 组相联 VPN 00\n01\n02\n03\n04\n05\n06\n07\nPPN 有效位\n28\n33\n02\n16\nVPN 08\n09\nOA OB\noc\nOD OE OF\nPPN 有效位\n2D\n11\nOD\n页表:只 展示了前16 个 PTE 物理地址\n• CT• • CI• +- CO -+\nII 10 9 8 7 6 5 4 3 2 I 0\nI I I I I I I I I I I I I\nPPN• • PPO• 索引有效位 块 0 块 l 块 2 块 3\n高速 缓存: 16 个组,4 字节的块,直 接映射 图 9 - 2 0\n小内存系统的 T LB、页表以及缓存。T LB、页表和缓存中所有的值都 是十六进制表示的\n给定了这种初始化设定,让 我们来看看当 CPU 执行一条读地址 Ox 0 3 d 4 处 字节的加载指 令 时 会 发 生什么。(回想一下我们假定 CPU 读取 1 字节的字,而 不 是 4 字 节的字。)为了\n开始这种手工的模拟,我们发现写下虚拟地址的各个位,标识出我们会需要的各种字段, 并确定它们的十六进制值,是非常有帮助的。当硬件解码地址时,它也执行相似的任务。\n开始时 , MMU 从虚拟地址中抽取出 VP N ( OxOF) , 并且检查 T LB, 看它是否因为前面的某 个内存引用缓存了 PT E OxO F 的一个副本。T LB 从 VPN 中抽取出 T LB 索引( Ox 03) 和 T LB 标记 ( Ox3 ) , 组 Ox 3 的 第 二 个 条 目 中 有效匹配, 所 以 命 中 , 然后 将缓 存 的 PP N ( OxOD) 返回给 MMU。\n如果 T LB 不命中,那 么 MMU 就需要从主存中取出相应的 PT E。然而,在 这种情况中, 我们很幸运, T L B 会命中。现在, MMU 有了形成物理地址所需要的所有东西。它通过将来自 PT E 的 PP N ( Ox OD) 和来 自虚拟地址的 VPO ( Ox l 4) 连接起来,这 就 形 成 了 物 理 地址( Ox 35 4) 。\n接下来, MMU 发送物理地址给缓存, 缓 存 从 物 理 地 址 中 抽 取 出 缓 存 偏 移 CO ( OxO) 、缓存组索引 CIC Ox5 ) 以 及 缓 存 标 记 CT ( Ox OD) 。\n因 为组 Ox5 中 的 标 记 与 CT 相 匹 配, 所以缓存检测到一个命中,读 出在偏移量 co 处的数据字节( Ox 3 6) , 并将它返回给 MMU , 随后 MMU 将它传递回 CP U。\n翻译过程的其他路径也是可能的。例如, 如 果 T LB 不命中,那 么 MMU 必 须从 页 表中的 PT E 中取出 PP N。如果得到的 PT E 是无效的,那 么 就 产 生 一 个 缺 页 ,内 核 必 须 调 入合适的页面,重 新 运行这条加载指令。另一种可能性是 PT E 是有效的, 但 是 所 需 要 的 内存块在缓存中不命中。\n练习题 9. 4 说明 9. 6. 4 节中的 示例内存 系统 是如何将一个虚拟地址翻译成 一个物理地址和 访问缓存的。对于给定的 虚 拟地址, 指明 访 问的 T LB 条目、 物理地 址和返回的缓存 字节值 。指出是 否发 生 了 T LB 不命中, 是否发 生 了 缺 页, 以 及是否 发 生 了 缓存不命中。如果是缓存不命中,在“返回的缓存字节”栏中输入“—\u0026quot;。如果有缺页, 则在 \u0026quot; PP N\u0026quot; 一栏 中输入“—“ , 并且将 C 部分和 D 部分空着 。\n虚拟地址: Ox03d7\n虚拟地址格式 13 12\nI I\n11 10 9\nI I\nL8J 7 6\n5 4 3 2 1 0\nI I I I I I\n地址翻译 物理地址格式 11 10 9 8 7 6 5 4 3 2 I 0\n物理内存引用 9. 7 案例研究: Intel Core i7/ Linux 内存系统\n我们以一个实际系统的案例研究来总结我们对虚拟内存的讨论: 一个运行 Linux 的Intel Core i7。虽 然底层的 Haswe ll 微体系结构允许完全的 64 位虚拟和物理地址空间,而现在的(以及可预见的未来的)Core i7 实现支持 48 位( 256T B) 虚拟地址空间和 52 位( 4PB) 物理地址空间 , 还有一个兼容模式, 支持 32 位( 4GB) 虚拟和物理地址空间。\n图 9-21 给出了 Core i7 内存 系统的重要部分。处理 器封 装( processor package) 包括四个核、一个大的所有核共享的 L3 高速缓存,以 及一个 DDR3 内存控制器。每个核包含一个层次结构的 T LB、一个层次结构的数据和指令高速缓存, 以及一组快速的点到点链路, 这种链路基于 QuickPat h 技术, 是为了让一个核与其他核和外部 1/ 0 桥直接通信。TLB 是 虚拟寻址的, 是四路组相联的。Ll 、L2 和 L3 高速缓存是物理寻址的, 块大小为 64 字节。Ll 和 L2 是 8 路组相联的, 而 L3 是 16 路组相联的。页大小可以 在启动时被配置为\n4KB 或 4MB 。Lin ux 使用的是 4KB 的页。\n9. 7. 1 Core i7 地址翻译\n图 9-22 总结了完整的 Core i7 地址翻译 过程,从 CPU 产生虚拟地址的时刻一直到来自内存的数据字到达 CPU 。Core i7 采用四级页表层次结构。每个进程有它自己 私有的页表层次结构。当一个 Linux 进程在运行时,虽 然 Core i7 体系结构允许页表换进换出 ,但是 与已分配了的页相关联的页表都是驻留在内存中的。CR3 控制寄存器指向第一级页表( Ll) 的起始位置。CR3 的值是每个进程上下文的一部分, 每次上下文切换时, CR3 的值都会被恢复。\n处理器封装\n,\u0026mdash;\u0026mdash;\u0026mdash;\u0026mdash;\u0026mdash;\u0026mdash;\u0026mdash;\u0026mdash;\u0026mdash;\u0026mdash;\u0026mdash;\u0026mdash;\u0026mdash;\u0026mdash;\u0026mdash;\u0026mdash;\u0026mdash;\u0026mdash;\u0026mdash;\u0026mdash;\u0026mdash;\u0026mdash;\u0026mdash;\u0026mdash;\u0026mdash;\u0026mdash;\u0026mdash;\u0026mdash;\u0026mdash;\u0026mdash;\u0026mdash;\u0026mdash;\u0026mdash;\u0026mdash;\u0026mdash;-\n核 x 4\nMMU\n(地址翻译)\nLI d-TLB 1 1 LI i-TLB\n64 个条目, 4 路 128 个条目,4 路\nL2 统一高速缓存\n256 KB, 8 路\nu 统 一 TLB 512 个条目,4 路\n到其他核到1/0 桥\n13统一高速缓存\n8 MB, 16 路\n(所有的核共享)\nDDR3存储器控制器\n(所有的核共享)\n! ._ 卜\u0026ndash;匕\u0026ndash; \u0026mdash;\u0026mdash;\u0026mdash; \u0026mdash; \u0026mdash;\u0026mdash;-\n主存\n图 9- 21 Core i7 的内存系统\nLl d-cache\n( 64 组, 8 行 /组)\nTLB\n不命中\nCR3 页表\n图9-22 Core i7 地址翻译的概况。为了简化,没 有 显 示 i-cache 、i-T LB 和 L2 统一 T LB\n图 9-23 给出了第一级、第二级或第三级页表中条目的格式。当 P = l 时( Linux 中就总是 如此),地址字段包含一个 40 位物理页号( PPN ) , 它指向适当的页表的开始处。注意, 这强加了一个要 求, 要求物理页表 4KB 对齐。\n63 62 52 51\n2 11\n98 7 6 5 4 3 2 I 0\n三 页表物理基地址\nI\n未使用\nG I PS I I A I CD I WT IUIS尸1\nOS 可用(磁盘上的页表位置) 三\n图 9-23 第 一级 、第 二级 和第 三级页表条目格式。每个条目引用一个 4K B 子页表\n图 9-24 给出了第四级页表中条目的格式。当 P = l , 地址字段包括一个 40 位 PPN,\n它指向物理内存中某一页的基地址。这又强加了一个要求, 要求物理页 4KB 对齐。\n63 62 5251\n三 页表物理基地址\n12 11 9 8 7\nI\n未使用\n6 5 4 3 2 1 0\nD I A ICD IWTIUIS匠千=1\nOS可用(磁盘上的页表位置) 曰\n图 9-24 第 四级 页表条目的格式 。每个条目引用一个 4K B 子页\nPT E 有三个权限位, 控制对页的 访问。R / W 位确定 页的内容是可以读写的还是只读的。U/ S 位确定是否能够在用户 模式中访问该页,从 而保护操作系统内核中的代码和数据\n不被用户程序访问。XDC禁 止 执 行)位 是 在 64 位系统中引入的, 可以用来禁止从某些内存页取指令。这是一个重要的新特性,通过限制只能执行只读代码段,使得操作系统内核降 低了缓冲区溢出攻击的风险。\n当 MMU 翻译每一个虚拟地址时, 它 还会更新另外两个内核缺页处理程序会用到的位。每次访问一个页时, MMU 都会设置 A 位,称 为引 用位( reference bit ) 。内 核 可以用这个引 用位来实现它的页替换算法。每次对一个页进行了写之后, MMU 都会设置 D 位, 又称修改位或脏位Cdirty bit ) 。 修 改 位 告 诉 内 核 在 复 制替换页之前是否必须写回牺牲页。内核可以通过调用一条特殊的内核模式指令来清除引用位或修改位。\n图 9-25 给出了 Core i7 MMU 如何使用四级的页表来将虚拟地址翻译成物理地址。36 位 VP N 被划分成四个 9 位的 片, 每个片被用作到一个页表的偏移量。CR3 寄存器包含 Ll 页表的物理地址。VP N 1 提供 到一个 Ll P ET 的偏移量 , 这个 PT E 包含 L2 页 表的基地址。VP N 2 提供 到一个 L2 PT E 的偏 移量 ,以 此 类 推。\n9\nVPN!\n9\nVPN2\n9\nVPN3\n9\nVPN4\n12\nVPO 虚拟地址\nCR3\nLI PT 的\n物理地址\n12 到物理和虚拟页的偏移量\n每个条目 每个条目\n512 GB 区域 1 GB 区域\n每个条目\n2 MB 区域\n每个条目 1 页的物理\n4 KB 区域 地址\n40\n40\nPPN\n12\nPPO 物理地址\n图 9- 25 Core i7 页表翻译 (PT: 页表, PT E: 页表条目 , VPN: 虚拟页号 , VPO: 虚拟页偏移,\nPPN: 物理页号 , PPO: 物理页偏移量。图中还给出了这四级页表的 Linu x 名字)\nm 优化地址翻译\n在对地址翻译的讨论中, 我们描述了一个顺序的两 个步骤的过程, l) M MU 将虚拟 地址翻译成物理地址, 2 ) 将 物理地址传送到 L l 高速缓 存。然 而 , 实际的硬件 实现 使 用了一 个灵 活的技巧, 允 许 这些步骤部分重叠, 因此也就加速 了 对 Ll 高 速 缓 存 的访问。例如, 页 面大小为 4KB 的 Core i7 系统 上的一个虚拟地址有 12 位的 VP O , 并且这些位和相应物理地址中的 PP O 的 1 2 位是相同的。因 为八 路 组相联的、物理寻址的 Ll 高 速缓存 有 64 个组和大小为 64 字节的 缓存块, 每 个物理地址有 6 个Oog 2 64 ) 缓存偏 移位和\n6 个(l og为4) 索引 位。这 12 位恰好符合虚拟地址的 VPO 部分, 这绝不是 偶 然! 当 CP U\n需要 翻译一个虚拟地址时, 它就发 送 VP N 到 MMU, 发送 VPO 到高速 L1 缓存。当 MMU\n向 T LB 请求一 个页表 条目时 , L1 高速缓存正忙着利 用 VPO 位查找相应的组, 并读出1 这个组里的 8 个标记和相应的数 据宇。 当 MMU 从 T LB 得到 PPN 时,缓存 已经准备好试着把 这个 PPN 与这 8 个标记中的 一个进行 匹配 了。\n9 . 7 . 2 Linux 虚拟内存系统\n一个虚拟内存系统要求 硬件和内核软件之间的紧密协作 。版本与版本之间细节都不尽相同, 对此完整的阐释超出了我们讨论的范围。但是, 在这一小节中我们的目标是对L in u x 的虚 拟内存系统做一个描述, 使你能够大致了解一个实际的操作系统是如何组织虚拟内存,以 及如何处理缺页的。\nLin u x 为每个进程维护了一个单独的\n虚拟地址空间,形 式如图 9-26 所示。我们已经多次看到过这幅图了,包括它那些熟悉的代码、数据、堆、共享库以及栈段。\n与进程相关的数据结构\n(例如, 页表、task 和\nmm结构,内核栈)\n内 核虚拟\n内存\n既然我们理解了地址翻译,就能够填入更\n多的关千内核虚拟内存的细节了,这部分虚拟内存位千用户栈之上 。\n内核虚拟内存包含内核中的代码和数 据结构。内核虚拟内存的某些区域被映射到所有进程共享的物理页面。例如, 每个进程共享内核的代码和全局数据结构。有趣的是, L in u x 也将一组连续的虚拟页面\n(大小等千系统中 DRAM 的总量)映射到相应的一组连续的物理页面。这就为内核提供了一种便利的方法来访问物理内存中任何特定的位置,例如,当它需要访问页\n%r sp 一 令\nbr k -\n物理内存\n内核代码和数据\n进程虚拟\n内存\n表,或在一 些设备上执行内存映射的 J/0 O x4 0 0 00 0 0 0 \u0026ndash;+\n操作,而这些设备被映射到特定的物理内 °\n存位置时。 图 9-26 一个 Linux 进程的虚拟内存\n内核虚拟内存的其他区域包含每个进程都不相同的数据。比如说,页表、内核在进程 的上下文中执行代码时使用的栈, 以及记录虚拟地址空间 当前组织 的各种数据结构。\nL inux 虚拟内存区域\nLin ux 将虚拟内存组织成一些 区域(也叫做段)的集合。一个区域( area ) 就是巳 经存在着的(已分配的)虚拟内存的连续 片( ch unk ) , 这些页是以某种方式相关联的。例如,代码段、数据段、堆、共享库段,以及用户栈都是不同的区域。每个存在的虚拟页面都保存在 某个区域中,而不 属于某个区域的 虚拟页是不存在的,并且不能被进程引用。区域的概念很重要 ,因为它允 许虚拟地址空间有间 隙。内核不用记录那些不存在的虚拟页,而这样的页也不占用内存、磁盘或者内核本身中的任何额外资源。\n图 9- 27 强调了记录一个 进程中 虚拟内存区域的内核数据结构。内核为系统中的每个进程维护一个单独的任务结构(源代码中的七a s k_s tr uc t ) 。任务结构中的元素包含或者指向内核运行该 进程所需要的所有信息(例如, PI O、指向用户栈的指针、可执行目标文件的名字, 以及程序计数器)。\nt as k_s r七\nuc t mm_ s rt\nuc t\nvm_ar ea_s tr uc t 进程虚拟内存\nvrn_start vrn_prot\nvrn_flags 数据\n代码\nvrn prot vrn_flags vrn_next\n图 9- 27 Linux 是如何 组织虚拟内存的\n任务结构中的一个条目指向mm_s tr uc t , 它描述了虚拟内存的当前状态。我们感兴趣的两个字段是 pgd 和 mma p , 其中 pgd 指向第一级页表(页全局目录)的基址,而 mma p 指向 一个vrn_ar e a _ s tr uc t s ( 区域结构)的链表, 其中每个vrn_ ar e a _s tr uc t s 都描述了当前虚拟地址空间的一个区域。当内核运行这个进程时, 就将 pgd 存放在 CR3 控制寄存器中。\n为了我们的目的,一个具体区域的区域结构包含下面的字段:\nvrn_ s t a r 七: 指向这个区域的起始处。 • vm_ e nd : 指向这个区域的结束处。 vrn王r o t : 描述这个区域内包含的所有页的读写许可权限。 vm_flags: 描述这个区域内的页面是与其他进程共享的,还是这个进程私有的(还描述了其他一些信息)。 vm next: 指向链表中下一个区域结构。 Linux 缺页异常处理\n假设 MMU 在试图翻译某个虚拟地址 A 时,触 发了一个缺页。这个异常导致控制转移到内核的缺页处理程序,处理程序随后就执行下面的步骤:\n虚拟地址 A 是合法的吗? 换句话说, A 在某个区域结构定义的区域内吗? 为了回答这个问题 , 缺页处理程序搜索区域结 构的链表, 把 A 和每个区域结 构中的 vrn_ s t ar t 和vm_e nd 做比较。如果这个指令是不合法的, 那么缺页处理程序就触发一个段错误,从 而 终止这个 进程。这个情况在图 9-28 中标识为 \u0026quot; l \u0026quot; 。 因为一个进程可以创建任意数量的新 虚拟内存区域(使用在下一节中描述的 mma p 函数),所以顺序搜索区域结构的链表花销可能会很大。因此在实际中, L in ux 使用某些我们没有显示出来的 字段, L in ux 在链表中构建了一棵树, 并在这棵树上进行查找。\n2 ) 试图进行的内存访问是否合法? 换句话说, 进程是否有读、写或者执行这个区域内页面的权限?例如,这个缺页是不是由一条试图对这个代码段里的只读页面进行写操作\n的存储指令造成的?这个缺页是不是因为一个运行在用户模式中的进程试图从内核虚拟内存中读取字造成的?如果试图进行的访问是不合法的,那么缺页处理程序会触发一个保护异常,从 而终止这个进程。这种情况 在图 9-28 中标识为 \u0026quot; 2\u0026quot; 。\n3 ) 此刻,内 核知道了这个缺页是由于对合法的虚拟地址进行合法的操作造成的。它是这样来处理这个缺页的:选择一个牺牲页面,如果这个牺牲页面被修改过,那么就将它交换 出去, 换入新的页面并更新页表。当缺页处理程序返回时, CPU 重新启动引起缺页的指令,这条指令将再次发送A 到 MMU。这次, MMU 就能正常地翻译 A, 而不会再产生缺页中断了。\nvrn_ar ea_s t r uct 进程虚拟内存\n段 错误:\n访 问 一个不存在的页面\n@) 正常 缺页\n保护异常:\n© 例如, 违反许可, 写 一个只读的页面\nvm_next\n图 9-28 Linux 缺 页处 理\n8 内存映射\nLin ux 通过将一个虚拟内存区域与一个磁盘上的对象( o bject ) 关 联起来, 以初始化这个虚拟内存区域的内容, 这个过程称为内存 映射( memory mapping ) 。虚拟内存区域可以映射到两种类型的对象中的一种:\nLinu x 文件 系统中的 普通文件: 一个区域可以 映射到一个普通磁盘文件的连续部分, 例如一个可执行目标文件。文件区( sect io n ) 被分成页大小的片, 每一片包含一个虚拟页面的初始内容。因为按需 进行页面调度, 所以这些虚拟页面没有实际交换进入物理内存, 直到 CPU 第一次引用到页面(即发射一个虚拟地址, 落在地址空间这个页面的范围之内)。如果区域比文件区 要大, 那么就用零来填充这个区域的余下部分。 ) 匿名 文件 : 一个区域也可以 映射到一个匿名文件, 匿名文件是由内核创建的,包\n含的全是二进制零。CP U 第一次引用这样一个区域内的虚拟页面时,内 核就在物理内存中找到一个合适的牺牲页面, 如果该页面被修改 过, 就将这个页面换出来,用 二进制零覆盖牺牲页面并更新页表,将这个页面标记为是驻留在内存中的。注意在磁盘和内存之间并 没有实际的数据传送。因为这个原因,映射到匿名文件的区域中的页面有时也叫做请求二 进制零的 页( dem and-ze ro page ) 。\n无论在哪种情况中,一旦一个虚拟页面被初始化了,它就在一个由内核维护的专门的 交换文件( s wa p file) 之间换来换去。交换文件也叫做交换 空间 ( s wa p s pace ) 或者交换区域\n(swap area)。需要意识到的很重要的一点是, 在任何时刻, 交换空间都限制着当前 运行着的进程能够分配的虚拟页面的总数。\n8. 1 再看共享对象\n内存映射的概念来源于一个聪明的发现:如果虚拟内存系统可以集成到传统的文件系统中,那么就能提供一种简单而高效的把程序和数据加载到内存中的方法。\n正如我们已经看到的,进程这一抽象能够为每个进程提供自己私有的虚拟地址空间, 可以免受其他进程的错误读写。不过,许多进程有同样的只读代码区域。例如,每个运行 Linux shell 程序 ba s h 的进程都有相同的代码区域。而且, 许多程序需要访问只读运行时 库代码的相同副本。例如 ,每 个 C 程序都需要来自标 准 C 库的诸如 pr i nt f 这样的函数。那么,如果每个进程都在物理内存中保持这些常用代码的副本,那就是极端的浪费了。幸 运的是,内存映射给我们提供了一种清晰的机制,用来控制多个进程如何共享对象。\n一个对象可以被映射到虚拟内存的一个区域,要么作为共享对象,要么作为私有对 象。如果一个进程将一个共享对象映射到它的虚拟地址空间的一个区域内,那么这个进程对这个区域的任何写操作,对于那些也把这个共享对象映射到它们虚拟内存的其他进程而言,也是可见的。而且,这些变化也会反映在磁盘上的原始对象中。\n另一方面,对于一个映射到私有对象的区域做的改变,对于其他进程来说是不可见 的,并且进程对这个区域所做的任何写操作都不会反映在磁盘上的对象中。一个映射到共享对象的虚拟内存区域叫做共享区域。类似地,也有私有区域。\n假设进程 1 将一个共享对象映射到它的虚拟内存的一个区域中, 如图 9- 29a 所示。现在假设 进程 2 将同一个共享对象映射到它的地址空间(并不一定要和进程 1 在相同的虚拟地址处 , 如图 9-296 所示)。\n进程 l 的 物理 进程2 的 进程 1 的 物理 进程 2 的 虚拟内存 内存 虚拟内存 虚拟内存 内存 虚拟内存 共享对象\na ) 进程 1 映射了共享对象之后\n共享对象\nb ) 进程 2 映射了同一个共享对象之后\n图 9-29 一个共享对象(注意,物理页面不一定是连续的)\n因为每个 对象都有一个唯一的文件名,内 核可以迅速地判 定进程 1 已经映射了这个对象, 而且可以使进程 2 中的页表条目指向相应的物理页面。关 键点在于即使对象被映射到了多个共享区域,物理内存中也只需要存放共享对象的一个副本。为了方便,我们将物理 页面显示为连续的,但是在一般情况下当然不是这样的。\n私有对象使用一种叫做 写时复 制( copy-on-write) 的巧妙技术被映射到虚拟内存中。一个\n私有对象开始生命周期的方式基本上与共享对象的一样,在物理内存中只保存有私有对象的 一份副本。比如,图 9-30a 展示了一种情况,其中两个进程将一个私有对象映射到它们虚拟内存的不同区域,但是共享这个对象同一个物理副本。对于每个映射私有对象的进程,相应私有 区域的页表条目都被标记为只读,并且区域结构被标记为私有的写时复制。只要没有进程试图 写它自己的私有区域,它们就可以继续共享物理内存中对象的一个单独副本。然而,只要有一 个进程试图写私有区域内的某个页面,那么这个写操作就会触发一个保护故障。\n当故障处理程序注意到保护异常是由于进程试图写私有的写时复制区域中的一个页面 而引起的,它就会在物理内存中创建这个页面的一个新副本,更新页表条目指向这个新的 副本, 然后恢 复这个页面的可写权 限, 如图 9-30b 所示。当故障处理程序返回时, CPU 重新执行这个写操作, 现在在新创建的页面上这个写操作就可以正常执行了。\n进程1 的虚拟内存\n物理 进程2 的\n内存 虚拟内存\n私有的写时复制对象\na ) 两个进程都映射了私有的写时复制对象之后\n私有的写时复制对象\nb ) 进程2 写了私有区域中的一个页之后\n写私有的写时复制的页\n图 9-30 一个私有的写时复制对象\n通过延迟私有对象中的副本直到最后可能的时刻,写时复制最充分地使用了稀有的物理内存。\n9. 8. 2 再看 f or k 函数\n既然我们理解了虚 拟内存和内存映射, 那么我们可以 清晰地知道 f or k 函数是如何创建一个带有自己独立虚拟地址空间的新进程的。\n当 f or k 函数被当前进程调用时,内 核为新进 程创建各种数据结构, 并分配给它一个唯一的 PID。为了给这个新 进程创建 虚拟内存, 它创建了当前进程的 mrn_ s tr uc t 、区域结构和页表的原样副本。它将两个进程中的每个页面都标记为只读,并将两个进程中的每个 区域结构都标记为私有 的写时复制。\n当 f or k 在新进程中返回时, 新进程现在的虚拟内存刚好和调用 f or k 时存在的虚拟内存相同。当这两个进程中的任一个后来进行写操作时,写时复制机制就会创建新页面, 因此,也就为每个进程保持了私有地址空间的抽象概念。\n9. 8. 3 再看 ex ecv e 函数\n虚拟内存和内存映射在将程序加载到内存的过程中也扮演着关键的角色。既然已经理 解了这些 概念, 我们就能够理解 e x e c v e 函数实际上是如何加载和执行程序的。假设运行\n在当前进程中的程 序执行了如下的 e xe c ve 调用:\nexecve(\u0026ldquo;a.out\u0026rdquo;, NULL, NULL);\n正如在第8 章中学到的,e xe cve 函数在当前进程中加载并运行包含在可执行目标文件a . out\n中的程序,用a .out 程序有效地替代了当前程序。加载并运行a . OU七需 要以下几个步骤:\n删除已存在的用户区域。删除当前进程虚拟地址的用户部分中的已存在的区域结构。 映射私有区域。为 新程序的代码、数据、bss 和栈区域创建新的区域结构。所有这些新的区域都是私有的、写时复制的。代码和数据区域被映射为 a . out 文件中的. t e xt 和. da t a 区。bss 区域是请求二进制零的, 映射到匿名文件, 其大小包含在a . out 中。栈和堆区域也是请求二进制零的,初始长度为零。图9-31 概括了私有区域的不同映射。\n映射共享区域。如果 a . o ut 程序与共享对象(或目标)链接, 比如标准 C 库 li bc .\nso, 那么这些对象都是动态链接到这个程序的,然后再映射到用户虚拟地址空间中的共享区域内。\n设置程序 计数 器 ( PC ) 。 e x e c v e 做的最后一件事情就是设 置当前进程上下 文中的程序计数器,使 之指向代码区域的入口点。\n下一次调度这个进程时 ,它 将从这个入口点 开始执行。L in u x 将根据需要换入代码和数据页面。\n运行时堆\n(通过 malloc 分配的 )\n}私有的,请求二进制零的\n未初始化的数据(.bss) }私有的,请求二进制零的\nI已初始化的数据(. da ca )\n代码(.t ex七)\n。\n}私有的,文件提供的\n图 9- 31 加载器是如何映射用户地址空间的区域的\n8. 4 使 用 mmap 函 数的 用 户 级内 存 映 射\nLinux 进程可以使用 mmap 函数来创建新的虚拟内存区域, 并将对象映射到这些区域中。\n#include \u0026lt;unistd.h\u0026gt;\n#include \u0026lt;sys/mman.h\u0026gt;\nvoid *mmap(void *start, size_t length, int prot, int flags, int fd, off_t offset);\n返回: 若 成 功 时 则 为指 向 映 射 区域 的 指 针 , 若 出错 则 为 MAP_FAILED( —1) 。\nmrna p 函 数要求内核创建一个新的虚拟内存区域, 最好是从地址 s t ar t 开始的一个区域,并将文件描述符 f d 指定的对象的一 个连续的片( ch un k ) 映射到这个新的区域。连续的对象片大小为 l e n g t h 字节, 从距文件开始处偏移量为 o f f s e t 字节的地方开始。s t a r t 地址仅仅是一个暗示, 通常被定 义为 NU LL 。为了我们的目的, 我们总是假设起始地址为NU LL。图 9-32 描述了这些参数的意义。\n} length (字节)\ns t ar t\n( 或\n定的地址)\n文件描述符f d 指定 进程虚拟内存的磁盘文件\n图 9-32 mmap 参 数的可视化解 释\n参数 p r o t 包含描述新映射的虚拟内存区域的访问权限位(即在相应区域结构中的 vrn_ p r o t 位)。\nPROT_EXEC: 这个区域内 的页面由可以被 CPU 执行的指令组成。 PROT_READ: 这个区域内的页面可读。\nPROT_WRITE: 这个区域内的页面可写。\nPROT_NONE: 这个区域内的页面不能被访问。\n参数 fl a g s 由描述被映射对象类型的位组成。如果设置了 MAP _A NON 标记位, 那么被映 射的对象就是一个匿名对象, 而相应的虚拟页面是请求二进制零的。MAP _PRI­ VAT E 表示被映射的对象是一个私有的、写时复制的对象, 而 MAP _SH ARED 表示是一个共享对象。例如\nbufp = Mmap(NULL, size, PROT_READ, MAP_PRIVATEIMAP_ANON, 0, O);\n让内 核创建一个新的包含 s i z e 字节的只读、私有、请求二进制零的虚拟内存区域。如果调用成功, 那么 b u f p 包含新区域的地址。\nmu n ma p 函数删除虚拟内存 的区域:\n#include \u0026lt;un i s t d . h\u0026gt;\n#include \u0026lt;sys/mman.h\u0026gt;\nint munmap(void *start, size_t length);\n返回: 若 成 功 则 为 o, 若 出错 则为 一1.\nmu n ma p 函数删除从虚拟地址 s t ar t 开始的, 由接下来 l e n g t h 字节组成的区域。接下 来对已删除区域的引用会导致段错误。\n沁§ 练习题 9. 5 编写 一个 C 程序 mma p c o p y . c , 使 用 mma p 将一个 任意大小 的磁 盘文件复制到 s t d o u t 。 输入 文件 的名 字必须作 为 一个命令行参数 来传 递。\n9 动态内存分配\n虽然可以使用低级的 mma p 和 mu nma p 函数来创建和删除虚拟内存的区域, 但是 C 程序员还是会觉得当运行时需 要额外虚拟内存时,用 动态内 存分配器( d ynam ic memory allo­ cator ) 更方便,也 有更好的可移植性。\n动态内存分配器维护着一个进程的虚拟内存区\n域, 称为堆 ( hea p ) ( 见图 9-33 ) 。系统之间细节不同, 但是不失通用性,假设堆是一个请求二进制零的区 域,它紧接在未初始化的数据区域后开始,并向上生 长(向更高的地址)。对于每个进程,内核维护着一个变量 br k( 读做 \u0026quot; break\u0026quot; ) , 它指向堆的顶部。\n分配器将堆视 为一组不 同大小的块( block ) 的集合来维护。每个块就是一 个连续的虚拟内存片( ch un k ) , 要么是已分配的,要么是空闲的。已分配的块显式地 保留为供应用 程序使用。空闲块可用来分配。空闲块保持空闲 , 直到它显式地被应用所分配。一个巳分配的块保持已分配状态,直到它被释放,这种释放要么 是应用程序显式执行的,要么是内存分配器自身隐式\n执行的。\n分配器有两种基本风格。两种风格都要求应用显 °\n式地分配块。它们的不同之处在千由哪个实体来负责释放已分配的块。\n用户栈\n共享库的内存映射区域\n堆\n未初始化的数据 ( .bs s )\n已初始化的数据 ( . da t a )\n代码(.七ext )\n图 9-33 堆\n仁 堆顶\n( br k 指针)\n显式分 配器 ( ex plicit allocator) , 要求应用显式地释放任何巳分配的块。例如, C 标准库提供一种叫 做 ma l l o c 程序包的显式分配器。C 程序通过调用 ma l l o c 函数来\n.分配一个块, 并通过调用 fr e e 函数来释放一个块。C++ 中的 ne w 和 d e l e t e 操作符与 C 中的 ma l l o c 和 fr e e 相当。\n隐式分 配器 ( im plicit allocator), 另一方面, 要求分配器检测一个巳 分配块何时不再被程序所使用 , 那么就释放这个块。隐式分配器也叫做垃圾收集 器( ga r bage collec­\ntor), 而自动 释放未使用的已分配的块的过程叫做垃圾收集 ( ga r ba ge collect io n ) 。例如, 诸如 L is p、M L 以及 Java 之类的高级语言就依赖垃圾收集来释放已分配的块。\n本节剩下的部分讨 论的是显式分配器的设计和实现。我们将在 9. 10 节中讨论隐式分配器。为了更具体,我 们的讨论集中于管理堆内存的分配器。然而,应 该明白内存分配是一个普遍的概念,可以出现在各种上下文中。例如,图形处理密集的应用程序就经常使用标准分配器来要求获得一大块虚拟内存,然后使用与应用相关的分配器来管理内存,在该块中创建和销毁图形的节点。\n9. 1 ma l l o c 和 fr e e 函 数\nC 标准库提供了一个称为 ma l l o c 程序包的显式分配器。程序通过调用 ma l l o c 函数来从堆中分配块。\n#include \u0026lt;stdlib.h\u0026gt;\nvoid *malloc(size_t size);\n返回: 若 成 功则 为 己分 配块的指针 , 若出错 则为 NULL。\nma l l o c 函数 返 回一个指针,指 向 大小 为至少 s i z e 字节的内存块,这 个块会为可能包含在这个块内的任何数据对象类型做对齐。实际中, 对 齐 依 赖 于 编 译 代码在 32 位模式(gee -m3 2) 还 是 64 位 模式(默认的)中运行。在 3 2 位模式中, ma l l o e 返 回 的 块的地址总是 8 的倍数。在 64 位模式中,该 地 址 总 是 1 6 的倍 数 。\n田 日 - 个字有多大\n回想一下在第 3 章中我们对机器代 码的讨论, In tel 将 4 宇节对 象称为双宇。 然而,在本节中,我 们会假设宇是 4 字 节的对象, 而 双 宇是 8 宇 节的对象, 这和传统术语是一致的。\n如 果 ma l l o e 遇到问题(例如,程 序要求的内存块比可用的虚拟内存还要大), 那么它就返回 N U LL , 并设 置 e rr n o。ma l l o e 不 初 始 化 它 返 回 的 内 存 。 那 些 想 要 已 初始化的动态内 存的 应用 程序可以使用 e a l l o e , e a l l o e 是一个基于 ma l l o e 的 瘦 包装函数, 它将分配的内存初始化为零。想要改变一个以前已分配块的大小, 可以使用r e a l l oe 函数。\n动态内存分配器,例 如 ma l l o e , 可 以 通过使用 mrna p 和 mu n ma p 函数 ,显 式 地分配和释放堆内存,或 者 还可以使用 s br k 函数 :\n#include \u0026lt;unistd.h\u0026gt;\nvoid *sbrk(intptr_t incr);\n返回: 若 成 功 则 为 旧 的 brk 指 针 , 若 出铸 则为一1.\ns br k 函数通过将内核的 b r k 指针增加 i ncr 来扩展和收缩堆。如果成功, 它 就 返回b r k 的旧值 ,否 则 ,它 就 返 回 —1 , 并将 er r n o 设置为 ENOMEM。如果 i n cr 为零, 那么s br k 就返回 b r k 的 当前值。用一个为负的 i n cr 来调用 s br k 是合法的, 而且很巧妙, 因为返回值( br k 的旧值)指向距新堆顶向上 a b s ( i n cr ) 字节处。\n程序是通过调用 f r e e 函数来释放已分配的堆块。\n#include \u0026lt;stdlib.h\u0026gt; void free(void *ptr);\n返回: 无.\np tr 参数必须指向一个从 ma l l o c 、 c a l l o c 或 者r e a l l o c 获 得 的 已分配块的起始位置 。如果 不是 ,那 么 fr e e 的 行 为就是未定义的。更糟的是,既 然 它 什 么 都 不 返回, f r ee 就 不 会 告 诉 应 用 出 现了错误。就像我们将在 9. 11 节里看到的,这 会 产 生 一 些 令 人 迷惑的运行时错误。\n图 9- 3 4 展示了一个 ma l l o c 和 f r e e 的 实 现 是 如 何 管 理 一 个 C 程 序 的 1 6 字的(非常)小\n的堆的。每个方框代表了一个 4 字节的字。粗线标出的矩形对应于已分配块(有阴影的)和空 闲 块(无阴影的)。初始时,堆 是 由 一 个 大 小 为 16 个字的、双字对齐的、空闲块组成的。\n(本节中, 我们假设分配器返回的块是 8 字节双字边界对齐的。)\n图 9- 3 4 a : 程序请求一个 4 字的块。ma l l o c 的响应是: 从空闲块的前部切出一个 4\n字的块,并返回一个指向这个块的第一字的指针。\n图 9- 34 b : 程序请求一个 5 字的 块。 ma l l o c 的响应是:从 空闲块的前部分配一个 6 字的块。在本例中, ma l l o c 在块里填充了一个额外的字,是为了 保待空闲块是双字边界对齐的。\n图 9- 3 4 c : 程序请求一个 6 字的块, 而ma l l o c 就从空闲块的前部切出一个 6 字的块。 图 9- 3 4 d : 程序释放在图 9- 3 4 b 中分配的那个 6 字的块。注意, 在调用 fr e e 返回之后 ,指针 p 2 仍然指向被释放了的块。应用有责任在它被一个新的 ma l l o c 调用重新初始化之前, 不再使用 p 2。\n图 9- 3 4 e : 程序请求一个 2 字的块。在这种情况中 , ma l l o c 分配在前一步中被释放了的块的一部分,并返回一个\n1\nI I I I I I I I I I I I I # a)pl = malloc(4*sizeof(int))\n1 2\nII I I I I I I I 牖 I I # b)p2 = malloc (5*sizeof (i n 七 ))\n1 2 3\nI I I I I I I I 蹋 I I # c)p3 = ma l l oc ( 6*s i zeo f (i n 七 ))\np1 p2\n+ +\nI I I I I I I # free (p2)\n1 p!,4 3\nI I I I I I I I I I I # e)p4 = ma ll oc ( 2* 江 zeof ( i nt ) )\n指向这个新块的指针。\n9. 2 为什么要使用动态内存分配\n程序使用动态内存分配的最重要的原因是经常直到程序实际运行时,才知道某些数\n图 9-34\n用 ma l l oc 和 fr e e 分配和释放块。每个\n方框对应于一个字。每个粗线标出的矩形对应于一个块。阴影部分是已分配的块。已分配的块的填充区域是深阴影的。无阴影部分是空闲块。堆地址是从左往右增加的\n据结构的大小。例如, 假设要求我们编写一个 C 程序,它 读一个 n 个 ASCII 码整数的链表,每一行一 个整数,从 s t d i n 到一个 C 数组。输入是由整数 n 和接下来要读和存储到数组中的 n 个整数组成的。最简单的方法就是静态地定义这个数组, 它的最大数组大小是硬编码的:\n#include \u0026ldquo;csapp.h\u0026rdquo;\n#define MAXN 15213\n3\n4 int array [MAXN] ;\n5\n6 int main()\n7 {\ns int i, n;\n9\n10 scanf(\u0026quot;%d\u0026quot;, \u0026amp;n);\nif (n \u0026gt; MAXN)\napp_error(\u0026ldquo;Input file toobig\u0026rdquo;);\nfor (i = O; i \u0026lt; n; i++)\nscanf (\u0026quot;%d\u0026quot;, \u0026amp;array [i]);\nexit(O); 16 }\n像这样用硬编码的 大小来分配数组通常不是一种好想法。MAXN 的值是任意的,与机器上可用的虚拟内存的实际数量没有关系。而且,如果这个程序的使用者想读取一个比 MAXN 大的文件, 唯一的办法就是用一个更大的 MAXN 值来重新编译这个程序。虽然对 于这个简单的示例来说这不成问题,但是硬编码数组界限的出现对于拥有百万行代码和大 量使用 者的大 型软件产品而言, 会变成一场维护的 噩梦。\n一种更好的方法是在运行时 ,在已 知了 n 的值之后, 动态地分 配这个数组 。使用这种方法,数组大小的最大值就只由可用的虚拟内存数量来限制了。\n1 #include \u0026ldquo;csapp.h\u0026rdquo;\n3 int main()\n5 int *array, i, n;\n7 scanf (\u0026quot;%d\u0026quot;, \u0026amp;n);\n8 array = (int *)Malloc(n * sizeof(int)); 9 for (i = 0; i \u0026lt; n; i ++)\ns can f ( \u0026quot; %d \u0026quot; , \u0026amp;array[i]); free(array);\nexit(O);\n13 }\n动态内存分配是一种有用而重要的编程技术。然而,为了正确而高效地使用分配器, 程序员需要对它们是如何工作的有所了解。我们将在 9. 11 节中讨论因为不 正确地使用分配器所导致的一些可怕的错误。\n9. 3 分配器的要求和目标\n显式分配器必须在一些相当严格的约束条件下工作:\n处理任意请求序列。一个应用可以有任意的分配请求和释放请求序列,只要满足约 束条件:每个释放请求必须对应于一个当前巳分配块,这个块是由一个以前的分配 请求获得的。因此,分配器不可以假设分配和释放请求的顺序。例如,分配器不能假设所有的分配请求都有相匹配的释放请求,或者有相匹配的分配和空闲请求是嵌 套的。\n立即响 应请求。分 配器必须立 即响应分配请求。因此, 不允许分配器为了提高性能重新排列或者缓冲请求。\n只使用堆 。为了使分 配器是可扩展的 ,分 配器使用的任何非标量数据结构都必须保存在堆里。\n对齐块(对齐要 求)。分配器必须对齐块 ,使 得它们可以保存任何类型的数据对象。\n不修 改已分 配的块。分 配器只能操作或者改变空闲块。特别是, 一旦块被分配了, 就不允许修改或者移动它了。因此, 诸如压缩已分配块这样的技术是不允许使用的。\n在这些限制条件下,分配器的编写者试图实现吞吐率最大化和内存使用率最大化,而 这两个性能目标通常是相互冲突的。\n目标 1 : 最大化吞吐 率。假定 n 个分配和释放请 求的某种序列:\nR 。,R1 , …,Rk\u0026rsquo; …,Rn - I\n我们希望一个分配器的吞吐率最大化,吞吐率定义为每个单位时间里完成的请求 数。例如, 如果一个分配器在 1 秒内完成 500 个分配请求和 500 个释放请求, 那么它的吞吐率就是每秒 1000 次操作。一般而言, 我们可以 通过使满足分配和释放请求的平均时间最小化来使吞吐率最大化。正如我们会看到的,开发一个具有合理性能的分配器并不困难,所谓合理性能是指一个分配请求的最糟运行时间与空闲块的数量成线性关系,而一个释放请求的运行时间是个常数。\n目标 2: 最大化内存利用率。天真的程序员经常不正确地假设虚拟内存是一个无限的资源。实际上,一个系统中被所有进程分配的虚拟内存的全部数量是受磁盘上交换空间的数量限制的。好的程序员知道虚拟内存是一个有限的空间,必须高效地使用。对于可能被要求分配和释放大块内存的动态内存分配器来说,尤其如此。\n有很多方式来描述一个分配器使用堆的效率如何。在我们的经验中,最有用的标准是峰值利用率 ( peak utiliza tion ) 。像以 前一样, 我们 给定 n 个分配和释放请求的某种顺序\nR。R,1, …,Rk\u0026rsquo; …,Rn- 1\n如果一个应用程序请求一个 p 字节的块, 那么得到的巳分配块的有效栽荷 ( payload ) 是 p 字节。在请 求凡 完成之后 , 聚集有 效栽荷 ( agg rega te pa yload ) 表示为p k\u0026rsquo; 为当前已 分配的块的有效载荷之和,而几表示堆的当前的(单调非递减的)大小。\n那么,前 k+ l 个请求的峰值利 用率 , 表示为 Uk , 可以通过下式得到:\n队= m a x;,;;k; P ;\nHk\n那么,分 配器的目标 就是在整个 序列中使峰值利用率 Un- 1 最大化。正如我们将要看到的, 在最大化吞吐率和最大化利用率之间是互相牵制的。特别是,以堆利用率为代价,很容易 编写出吞吐率最大化的分配器。分配器设计中一个有趣的挑战就是在两个目标之间找到一 个适当的平衡。\n调性假设\n我们可以通过让 Hk 成为前k + l 个请 求的 最高峰 ,从 而使 得在我们对 队 的定义中放宽单调非 递减的 假设, 并且允许 堆增 长和降低 。\n9. 9. 4 碎片\n造成堆利用率很 低的主要原因是一种称为碎 片 ( fr ag m ent a tio n ) 的现象, 当虽然有未使用的内存但不能用来满足分配请求时,就发生这种现象。有两种形式的碎片:内部碎片(internal fragmentation) 和外部碎片 ( ext e rn a l fr a gm e n ta t ion ) 。\n内部碎片是在一个已分配块比有效载荷大时发生的。很多原因都可能造成这个问题。 例如,一个分配器的实现可能对已分配块强加一个最小的大小值,而这个大小要比某个请 求的有效载荷大。或者, 就如我们在图 9-34b 中看到的,分 配器可能 增加块大小以满足对齐约束条件。\n内部碎片的量化是简单明了的。它就是已分配块大小和它们的有效载荷大小之差的 和。因此,在任意时刻,内部碎片的数最只取决于以前请求的模式和分配器的实现方式。\n外部碎片是当空闲内存合计起来足够满足一个分配请求,但是没有一个单独的空闲块 足够大 可以来处理这个请求时发生的。例如, 如果图 9-34e 中的请求要求 6 个字,而 不是\n2 个字, 那么如果不向内核请求额外的虚拟内存就无法满足这个请求, 即使在堆中仍然有\n6 个空闲的字。问题的产生是由于这 6 个字是分在两个 空闲块中的。\n外部碎片比内部碎片的扯化要困难得多,因为它不仅取决于以前请求的模式和分配器 的实现方式 , 还取决 于将来请求的模式 。例如, 假设在 K 个请求之后,所有 空闲块的大小都恰好是 4 个字。这个堆会有外部碎片吗? 答案取决于将来请求的模式。如果将来所有的分配请求都 要求小于或者等于 4 个字的块, 那么就不会有外部碎片。另一方面, 如果有一个或者多个请求要求比 4 个字大的块, 那么这个堆就会有外部碎片。\n因为外部碎片难以量化且不可能预测,所以分配器通常采用启发式策略来试图维持少量的大空闲块,而不是维持大量的小空闲块。\n9. 5 实现问题\n可以想 象出的最简单的分配器会把堆组织成一个大的字节数组, 还有一个指针 p , 初始指向这个数组的第一个字节。为了分配 s i ze 个字节, ma l l o c 将 p 的当前值保存在栈里, 将 p 增加 s 工ze , 并 将 p 的旧值返回到调用函数。fr e e 只是简单地返回到调用函数, 而不做其他任何事情。\n这个简单的分配器是设计中的一种极端情况。因为每个 ma l l o c 和 fr e e 只执行很少量的指令, 吞吐率会极好。然而, 因为分配器从不重复使用任何块,内 存利用率将极差。一个实际的 分配器要 在吞吐 率和利用率之间把握好平衡, 就必须考虑以下几个问题:\n空闲块组织:我们如何记录空闲块? 放置: 我们如何选择一个合适的空闲块来放置一个新分配的块?\n分割: 在将一个新分配的块放置到某个空 闲块之后, 我们如何处理这个空闲块中的剩余部分?\n合并: 我们如何处理一个刚刚被释放的块?\n本节剩下的部分将更详细地讨论这些问题。因为像放置、分割以及合并这样的基本技 术贯穿在许多不同的空闲块组织中,所以我们将在一种叫做隐式空闲链表的简单空闲块组 织结构中来介绍它们。\n9. 9. 6 隐式空闲链表\n任何实际的分配器都需要一些数据结构,允许它来区别块边界,以及区别巳分配块和 空闲块。大多数分配器将这些信息嵌入块本身。一个简单的方法如图 9-35 所示。\nrna l l oc 返回一个指针, 它指向有效载荷的开始处\n31 头部\n, # 3 2 I 0\n} a= I: 已分配的\na=O: 空闲的\n块大小包括头部、\n有效载荷和所有的填充\n图 9-35 一个简单的堆块的格式\n在这种情况中,一个块是由一个字的头部、有效载荷,以及可能的一些额外的埃充组 成的。头部编码了这个块的大小(包括头部和所有的填充),以及这个块是已分配的还是空\n闲的。如果我们强加一个双字的对齐约束条件 , 那么块大小就总是 8 的倍数, 且块大小的最低 3 位总是零。因此,我 们只需要内存 大小的 29 个高位, 释放剩余的 3 位来编码其他信息。在这种情况中,我们用其中的最低位(已分配位)来指明这个块是已分配的还是空闲的。例如 , 假设我们有一个已分 配的块 ,大小 为 24 ( 0x1 8) 字节。那么它的头部将是\nOx00000018 I Ox1 = Ox00000019 类似地, 一个块大小为 40 ( 0x 28) 字节的空闲块有如下的 头部:\nOx00000028 I OxO = Ox00000028 头部后 面就是应用调用 ma l l o c 时请求的有效载荷。有效载荷后面是一 片不使用的填充块,其大小可以是任意的。需要填充有很多原因。比如,填充可能是分配器策略的一部分,用来对付外部碎片。或者也需要用它来满足对齐要求。\n假设块的格式如图 9-35 所示,我 们可以将堆组织为一 个连续的已分配块和空闲块的序列, 如图 9-36 所示。\n目口勹16\u0026quot; I I 圈立。I I I I I\nTi•11I I I - 圃 扂的\n' , , '\u0026rsquo; : ,\u0026rsquo; '\u0026rsquo; ,\u0026rsquo;\n\u0026rsquo;\u0026rsquo; \u0026rsquo;\u0026rsquo; '\u0026rsquo;\n\u0026rsquo;\u0026rsquo; '\u0026rsquo;\n\u0026rsquo;\u0026rsquo; :\u0026rsquo;\n图 9-36 用隐式空闲链表来组织堆。阴影部分是已分配块。没有阴影的部分是空闲块 。头部标记为(大小(字节/)已分配位)\n我们称这种结构为隐式空闲链表,是因为空闲块是通过头部中的大小字段隐含地连接着 的。分配器可以通过遍历堆中所有的块,从而间接地遍历整个空闲块的集合。注意,我们需要 某种特殊标记的结束块, 在这个示例中, 就是一个设置了已分配位而大小为零的终止头部 ( ter­ minating header) 。(就像我们将在9. 9. 12 节中看到的,设 置已分配位简化了空闲块的合并。)\n隐式空闲链表的优点是简单。显著的缺点是任何操作的开销 , 例如放置分配的块, 要求对空闲链表进行搜索,该搜索所需时间与堆中已分配块和空闲块的总数呈线性关系。\n很重要的一点就是意识到系统对齐要求和分配器对块格式的选择会对分配器上的最小 块大小有强制的要 求。没有巳分配块或者空闲块可以比这个最小值还小。例如, 如果我们假设一个双字的 对齐要求, 那么每个块的大小都必须是双字( 8 字节)的倍数。因此,图 9-\n35 中的块格式就 导致最小的块大小为两个字: 一个字作头, 另一个字维持对齐要求。即使应用只请求一字节,分配器也仍然需要创建一个两字的块。\n练习题 9. 6 确定 下面 ma l l o c 请求序列 产 生的 块大小和头部 值。 假设: 1 ) 分配器 保持双 字对齐,并 且使用块格 式如图 9-35 中所 示的 隐 式 空闲 链表。 2) 块大小向 上舍入为最接近的 8 字节的倍 数。\n9. 9. 7 放置已分配的块\n当一个应用请求一个 K 字节的块时, 分配器搜索空 闲链表, 查找一个足够大可以放置\n所请求块的空闲块。分配器执行 这种搜索的方式是由放置策略 ( placem e n t policy ) 确定的。一些常见的策略是首次适配( fi rs t fit ) 、下一 次适 配( next fi t ) 和最佳适配( bes t fi t ) 。\n首次适配从头开始搜索空闲链表,选择第一个合适的空闲块。下一次适配和首次适配 很相似,只不过不是从链表的起始处开始每次搜索,而是从上一次查询结束的地方开始。最佳适配检查每个空闲块,选择适合所需请求大小的最小空闲块。\n首次适配的优点是它趋向于将大的空闲块保留在链表的后面。缺点是它趋向千在靠近 链表起始处留下小空闲块的"碎片“,这就增加了对较大块的搜索时间。下一次适配是由Donald Knut h 作为首次适配的一种代替品最早提出的, 源千这样一个想法: 如果我们上一次在某个空闲块里已经发现了一个匹配,那么很可能下一次我们也能在这个剩余块中发 现匹配。下一次适配比首次适配运行起来明显要快一些,尤其是当链表的前面布满了许多 小的碎片时。然而, 一些研究表明,下 一次适配的内存 利用率要比首次适配低得多 。研究还表明最佳适配比首次适配和下一次适配的内存利用率都要高一些。然而,在简单空闲链 表组织结构中,比如隐式空闲链表中,使用最佳适配的缺点是它要求对堆进行彻底的搜 索。在后面,我们将看到更加精细复杂的分离式空闲链表组织,它接近于最佳适配策略, 不需要进行彻底的堆搜索。\n9. 9. 8 分割空闲块\n一旦分配器找到一个匹配的空闲块,它就必须做另一个策略决定,那就是分配这个空 闲块中多少空间。一个选择是用整个空闲块。虽然这种方式简单而快捷,但是主要的缺点 就是它会造成内部碎片。如果放置策略趋向于产生好的匹配,那么额外的内部碎片也是可 以接受的。\n然而, 如果匹配不太好, 那么分配器通常会选择 将这个空闲块分割为两部分。第一部分变成分配块, 而剩下的变成一个新的 空闲块。图 9-37 展示了分配器如何分割图 9-36 中\n8 个字的空闲块 , 来满足一个应用的对堆内存 3 个字的请求。\n!双字\ni对齐的\n图 9-37 分割一 个空闲块 , 以满足一个 3 个字的分配请 求。阴影 部分是已分配块。没有阴影的部分是空闲 块。头部标 记为(大小(字节/)已分配位)\n9. 9. 9 获取额外的堆内存\n如果分配器不能为请求块找到合适的空闲块将发生什么呢?一个选择是通过合并那些在内存中物理上相邻的空闲块来创建一些更大的空闲块(在下一节中描述)。然而,如果这样还 是不能生成一个足够大的块,或者如果空闲块巳经最大程度地合并了,那么分配器就会通过调用 s br k 函数,向内 核请求额外的堆内存。分配器将额外的内存转化成一个大的空闲块, 将这个块插入到空闲链表中,然后将被请求的块放置在这个新的空闲块中。\n9. 9. 10 合并空闲块\n当分配器释放一个已分配块时 , 可能有其他空闲块与这个新释放的空闲块相邻。这些邻接的空闲块可能引起一种现象,叫 做 假碎片 ( fa ult fragmentation), 就是有许多可用的\n空闲块被切割成 为小的、无法使用的空闲块。比如,图 9- 38 展示了释放图 9-37 中分配的块后得到的结果 。结果是两个相邻的空闲块 , 每一个的有效载荷都为 3 个字。因此, 接下来一个对 4 字有效载荷的 请求就会失败, 即使两个空闲块的合计大小足够大, 可以满足这个请求。\n图 9-38 假碎片的示例。阴影部分是已分配块。没有阴影的部分是空闲块。头部标记为(大小(字节)/已分配位)\n为了解决假碎片问题,任何实际的分配器都必须合并相邻的空闲块,这个过程称为合并( coalescing ) 。这就 出现了一个重要的策略决定, 那就是何时执行合并。分配器可以选择立即合并 ( im mediat e coalescing), 也就是在每次一个块被释放时,就合并所有的相邻块。或者它也可以选 择推迟合并( defe r red coalescing) , 也就是等到某个稍晚的时候再合并空闲块。例如,分配器可以推迟合并,直到某个分配请求失败,然后扫描整个堆,合并所有的空闲块。\n立即合并很简单明了,可以在常数时间内执行完成,但是对于某些请求模式,这种方 式会产生一 种形式的抖动, 块会反复地合并, 然后马上分割。例如,在图 9-38 中, 反复地分配和释放一 个 3 个字的块将产生 大最不必要的分 割和合并。在对分配器的讨论中,我们会假设使用立即合并,但是你应该了解,快速的分配器通常会选择某种形式的推迟 合并。\n9. 11 带边界标记的合井\n分配器是如何实现合并的?让我们称想要释放的块为当前块。那么,合并(内存中的) 下一个空闲块很简单而且高效。当前块的头部指向下一个块的头部,可以检查这个指针以判断下一个块是否是空闲的。如果是,就将它的大小简单地加到当前块头部的大小上,这两个块在常数时间内 被合并。\n但是我们该如何合并前面的块呢?给定一个带头部的隐式空闲链表,唯一的选择将是 搜索整个链 表, 记住前面块的位置,直到 我们到 达当前块。使用隐式空闲链表,这意 味着每次调用 fr e e 需要的时间都与堆的大小成线性关系。即使使用更复杂精细的空闲链表组\n织, 搜索时间也 不会是常数。\nKnuth 提出了一种聪明而通用的技术, 叫做 31\n3 2 I 0\na= 001: 已分配的\n边界标记 ( boundary tag), 允许在常数时间内进行对前面块的合并。 这种思想,如图 9-39 所示, 是在每个块的 结尾处添加一个脚部( footer , 边界标记), 其中脚部就是头部的一个副本。如果每个块包括这样一个脚部,那么分配器就可以通过检查它的脚部,判断前面一个块的起始位置和状态, 这个脚部总是在距当前块开始位置一个字的距离。考虑当分配器释放当前块时所有可能存在的\n情况:\n头部 a= 000: 空闲的\n脚部\n图 9-39 使用边界标记的堆块的格式\n前面的块和后面的块都是已分配的。\n) 前面的块是已分配的, 后面的块是空闲的。\n) 前面的块是空闲的 , 而后面的块是已分配的。\n) 前面的和后面的块都是空闲的。\n图 9-40 展示了我们如何对这四种情况进行合并 。\n, ,\n情况 l\n情况2\nn+ m1+ m2 J f\n, .\n情况3 情况 4\nn+ m1+ m2 j f\n图 9-40 使用边界标记的 合并(情况 1 : 前面的 和后面块都已分配。情 况 2 : 前面块巳分配,后面块空闲。情况 3: 前面块空闲 , 后面块已分配 。情况 4 : 后面块和前面块都空闲)\n在情况 1 中, 两个邻接的块都是已分配的, 因此不可能进行合并。所以当前块的状态 只 是简单地从已分配变成空闲 。在情况 2 中,当 前块与后面的块合并。用当前块和后面块 的 大小的和来更新当前块的头部和后面块的脚部。在情况 3 中, 前面的块和当前块合并。用 两个块大小的和来更新前面块的头部和当前块的脚部。在情况 4 中, 要合并所有的三个块形成一个单独的空闲块,用三个块大小的和来更新前面块的头部和后面块的脚部。在每 种情况中, 合并都是在常数时间内 完成的。\n边界标记的概念是简单优雅的,它对许多不同类型的分配器和空闲链表组织都是通用 的。然而,它也 存在一个潜在的缺陷。它要求每个块都保 持一个头部和一个脚部,在应用程序操作许多个小块时,会产生显著的内存开销。例如,如果一个图形应用通过反复调用 ma l l o c 和 fr e e 来动态地创建 和销毁图形节点 ,并且每个图 形节点都只要求两个内存字, 那么头部和脚部将占用每个已 分配块的一半 的空间。\n幸运的是,有一种非常聪明的边界标记的优化方法,能够使得在已分配块中不再需要 脚部。回想一下,当我们试图 在内存中合并当前块以及前面的块和后面的块时, 只有在前面的块是空闲时,才会需 要用到它的脚部。如果我们把前 面块的巳分配/空闲位存放在当前 块中多出来的低位中, 那么巳分配的块就不需要脚部了, 这样我们就可以将这个多出来的空间用作有效载荷了。不过请注意,空闲块仍然需要脚部。\n练习题 9. 7 确定下面每种对齐要求和块格式的组合的最小的块大小。假设:隐式空 闲链表 , 不允 许有 效载荷 为零 , 头部和 脚部存 放在 4 字节的 字中。\n对齐要求 已分配的块 空闲块 最小块大小(字节) 单字 头部和脚部 头部和脚部 单字 头部,但是无脚部 头部和脚部 双字 头部和脚部 头部和脚部 双字 头部,但是没有脚部 头部和脚部 9. 12 综合:实现一个简单的分配器\n构造一个分配器是一 件富有挑战性的任务。设计空间很大, 有多种块格式 、空闲链表格式,以及放置、分割和合并策略可供选择。另一个挑战就是你经常被迫在类型系统的安全和熟悉的限定之外编程,依赖于容易出错的指针强制类型转换和指针运算,这些操作都属千典型的低层系统编程。\n虽然分配器不需要大量的代码,但是它们也还是细微而不可忽视的。熟悉诸如\nC+ + 或者 Java 之类高级语言的学生通常在他们第一次遇到这种类型的编程时, 会遭遇一个概念上的障碍。为了帮助你清除这个障碍,我们将基于隐式空闲链表,使用立即边 界标记合并方式,从 头至尾地讲述一个简单分配器的实现。最大的块大小为 232 = 4GB 。代码是 64 位干净的, 即代码能不加修改地运行 在 3 2 位( ge e - m3 2 ) 或 6 4 位( g e e - m6 4) 的\n进程中。\n通用分配器设计\n我们的分 配器使用 如图 9-41 所示的 me ml i b . e 包所提供的一个内存系统模型。模型的目的在于允许我们在不干涉已 存在的系统层 ma l l o e 包的情况下, 运行 分配器。\nme m_ i n i t 函数将对千堆来 说可用 的虚拟内存模型化为一个大的、双字对齐的字节数组。在 me m_ he a p 和 me m_ br k 之间的字节表示已分配的虚拟内存。me m_ br k 之后的字节表示未 分配的虚拟内存。分配器通过调用 me m_ s br k 函数来请求额外的堆内存, 这个函数和系统的 s br k 函数的接口相同,而 且语义也相同, 除了它会拒绝收缩堆的请求。\n.分配器包含在 一个源文件中( mm. e ) , 用户可以编译和链接这个源文件到他们的应用之中。分配器输出三个函数到应用程序:\nextern int mm_init(void);\n2 extern void *mm_malloc (size_t size);\n3 extern void mm_free (void *ptr);\nmm i n i t 函数初始化分配器, 如果成功就返回 o, 否则就返回—1 o mm_ma l l o c 和 mm_ f r e e 函数与它们对应的系统函数有相同的接口和语义。分配器使用如图 9-39 所示的块格式。最小块的大小为 16 字节。空闲链表组织成为一个隐式空闲链表,具 有如图 9-42 所示的恒定形式。\n第一个字是一个双字边界对齐的不使用的填充字。填充后面紧跟着一个特殊的序言块(prologue block) , 这是一个 8 字节的已 分配块,只 由 一个头部和一个脚部组成。序言块是在初始化时创建的, 并且永不释放。在序言块后紧跟的是零个或者多个由 ma l l o c 或者fr e e 调用创建的普通块。堆总是以一个特殊的结尾 块( epilog ue block ) 来结束, 这个块是一个大小为零的已分配块,只由一个头部组成。序言块和结尾块是一种消除合并时边界条件的技巧。分配器使用一个 单独的私有( s t a t i c ) 全局变量( he a p _ 让 s t p ) , 它总是指向序言块。(作为一个小优化,我们可以让它指向下一个块,而不是这个序言块。)\ncode/vmlma llod memlib.c\nI* Private global variables *I\nstatic char *mem_heap; I* Points to first byte of heap *I\nstatic char *mem_brk; I* Points to last byte of heap plus 1 *I\nstatic char *mem_max_addr; I* Max legal heap addr plus 1*/\n5\nI*\n* mem_init - Initialize the memory system model\ns *I\n9 void mem_init (void) 10 {\nmem_heap = (char *)Malloc(MAX_HEAP);\nmem_brk = (char *)mem_heap;\nmem_max_addr = (char *)(mem_heap + MAX_HEAP); 14 }\n15\nI*\n* mem_sbrk - Simple model of the sbrk function. Extends the heap\n* by incr bytes and returns the start address of the new area. In\n* this model, the heap cannot be shrunk.\n*I\nvoid *mem_sbrk(int incr) 22 {\n23 cha 工 *ol d_br k = mem_brk;\n24\nif ((incr \u0026lt; 0) 11 ((mem_brk + incr) \u0026gt; mem_max_addr)) {\nerrno = ENOMEM;\nfprintf(stderr, \u0026ldquo;ERROR: mem_sbrk failed. Ran out of memory \u0026hellip; \\n\u0026rdquo;);\nreturn (void *)-1; 29 }\nmem_brk += incr;\nreturn (void *)old_brk; 32 }\ncode/vmlmal/odmemlib.c\n图 9-41 memlib.c: 内存系统模型\n序言块 普通块 l\n普通块2 普通块n 结尾块 hdr\n!双字\n!对齐的\nstatic char *heap_listp\n图9- 42 隐式空闲链表的恒定形式\n2 操作空闲链表的基本常数 和宏\n图 9-43 展示了一些我们在分配器编码中将要 使用的基本常数和宏。第 2 4 行定义了一些基本的大小常数: 字的大小( WSIZE ) 和双字的 大小( DSIZE ) , 初始空闲块的大小和扩展堆时的默认大小CCH UNKSIZE) 。\n在空闲链表中操作头部和脚部可能是很麻烦的,因为它要求大量使用强制类型转换和指针 运算。因此, 我们发现定义一小组宏来访问和遍历空闲链表是很有帮助的(第9 25 行)。PACK\n宏(第9 行)将大小和已分配位结合起来并返回一个值,可以 把它存放在头部或者脚部中。\ncodelvmlmallodmm.c\nI* Basic constants and macros *I\n#define WSIZE 4 I* Word and header/footer size (bytes) *I\n#define DSIZE 8 I* Double word size (bytes) *I\n4 #define CHUNKSIZE (1«12) I* Extend heap by this amount (bytes) *I\n5\n6 #define MAX(x, y) ((x) \u0026gt; (y)? (x) : (y))\n7\nB I* Pack a size and allocated bit into a word *I\n9 #define PACK(size, alloc) ((size) I (alloc))\n10\n11 I* Read and write a word at address p *I\n12 #define GET(p) (*(unsigned int *)(p))\n13 #define PUT(p, val) (*(unsigned int *) (p) = (val))\n14\nI* Read the size and allocated fields from address p *I\n#define GET_SIZE(p) (GET(p) \u0026amp; -Ox7)\n#define GET_ALLOC(p) (GET(p) \u0026amp; Ox1)\n18\nI* Given block ptr bp, compute address of its header and footer *I\n#define HDRP(bp) ((char *) (bp) - WSIZE)\n#define FTRP(bp) ((char *)(bp) + GET_SIZE(HDRP(bp)) - DSIZE)\n22\n23 I* Given block ptr bp, compute address of next and previous blocks *I\n24 #define NEXT_BLKP(bp) ((char *) (bp) + GET_SIZE(((char *) (bp) - WSIZE)))\n25 #define PREV_BLKP(bp) ((char *)(bp) - GET_SIZE(((char *) (bp) - DSIZE)))\ncode/vm/ma llod mm.c\n图 9- 43 操作空闲链表的基本常数和宏\nGE T 宏(第1 2 行)读取和返回参数 p 引用的字。这里强制类型转换是至关重要的。参数 p 典型地是一个( v i o d * ) 指针, 不可以直接进行间接引用。类似地, P U T 宏(第1 3 行) 将 v a l 存放在参数 p 指向的字中。\nG ET _S IZE 和 G E T _ A L L O C 宏(第1 6~ 17 行)从地址 p 处的头部或者脚部分别返回大小和巳分配位。剩下的 宏是对块指针( blo ck pointer, 用bp 表示)的操作, 块指针指向第 一个有效载荷字节。给定一个块指针 b p , H DR P 和 F T R P 宏(第20 ~ 21 行)分别返回指向这个块的头部 和脚部的指针。N E XT _BL K P 和 P R E V _ BL K P 宏(第24 ~ 25 行)分别返回指向后面的块和前面的块的块指针。\n可以用多种方式来编辑宏,以 操作空闲链表。比如, 给定一个指向当前块的指针 b p ,\n我们可以使用下面的代码行来确定内存中后面的块的大小:\nsize_t size= GET_SIZE(HDRP(NEXT_BLKP(bp)));\n3. 创建初始空闲链表\n在调用 mm_ ma l l o c 或者 mm_ fr e e 之前, 应用必须通过调用 mm_ i n i t 函数来初始化堆\n(见图9- 44 ) 。\nmm_ i n i t 函数从内存系统得到 4 个字, 并将它们初始化 , 创建一个空的空闲链表(第4\n~ 10 行)。然后它调用 e x 七e n d _ h e a p 函数(图9- 45 ) , 这个函数将堆扩展 C H U N KSIZE 字\n节, 并且创建初始的空闲块。此刻,分 配器已初始化 了,并且准备好接受来自应用的分配和释放请求。\ncode/vm/mallodmm.c\nint rnm_init(void)\n{\nI* Create the initial empty heap *I\nif ((heap_listp = mem_sbrk(4*WSIZE)) == (void *)-1) r et urn 一 1 ;\nPUT(heap_listp, O);\nPUT(heap_listp + O*WSIZE), PACK(DSIZE, 1)); PUT(heap_listp + (2*WSIZE), PACK(DSIZE, 1));\nPUT(heap_listp + (3*WSIZE), PACK(O, 1)); heap_listp += (2*WSIZE);\nI* Alignment padding *I I* Prologue header *I I* Prologue footer *I I* Epilogue header *I\nI* Extend the empty heap with a free block of CHUNKSIZE bytes *I\nif (extend_heap(CHUNKSIZE/WSIZE) == NULL) return -1;\nreturn O;\n图 9-44 mm_ini t: 创建带一个初始空闲块的堆\n1 static void *extend_heap(size_t words)\n2 {\n3 char *bp; size_t size;\ncode/ vmlmallod mm.c codelvm/ma llod mm.c\n6 I* Allocate an even number of words to maintain alignment *I\n7 size= (words% 2)? (words+!) * WSI ZE : words* WSIZE;\n8 if ((long) (bp = mem_sbrk(size)) == -1)\n9 return NULL;\n10\nI* Initialize free block header/footer and the epilogue header *I\nPUT(HDRP(bp), PACK(size, 0)); I* Free block header *I\nPUT(FTRP(bp), PACK(size, O)); I* Free block footer *I\nPUT(HDRP(NEXT_BLKP(bp)), PACK(O, 1)); I* New epilogue header*/\n15\nI* Coalesce if the previous block was free *I\nreturn coalesce(bp);\n18 }\ncodelvm/mallodmm.c\n图 9-45 ext e nd_heap : 用一个新的空闲块扩展堆\ne x 七e n d_ h e a p 函数会在两种不 同的环境中被调用: 1 ) 当堆被初始化 时; 2 ) 当 mm—ma l ­ l a c 不 能找到一个合适的匹配块时。为了保持对齐 , e x 七e n d _ h e a p 将请求大小向上舍入为最接近的 2 字( 8 字节)的倍数, 然后向内存系统请求额外的堆空间(第7 9 行)。\ne x t e n d _ h e a p 函数的剩余部分(第12 17 行)有点儿微妙。堆开始于一个双字对齐的边界,并 且每次对 e x t e nd _ he a p 的调用都返回一个块,该 块的大小是双字的整数倍。因此, 对 me m_ s br k 的 每次调用 都返回一个双字对齐的内存片,紧 跟在结尾块的头部后面。这个头部变成了新的空闲块的 头部(第12 行),并且这个片的最后一个字变成了新的结尾\n块的头部(第14 行)。最后, 在很可能出现的 前一个堆以一个空闲块结束的情况中, 我们调用 c o a l e s c e 函数来合并两个空闲 块, 并返回指向合并后的块的块指针(第1 7 行)。\n4 释放和合并块\n应用通过调用 mm_ fr e e 函数(图9-46) , 来释放一个以前分配的块,这个函数释放所请求的块( b p) , 然后使用 9. 9. 11 节中描述的边界标记合并技术将之与邻接的空闲块合并起来。\ncode/vm/mallodmm.c\n1 void mm_free(void *bp)\n2 {\n3 size_t size= GET_SIZE(HDRP(bp));\n4\n5 PUT(HDRP(bp), PACK(size, O));\n6 PUT(FTRP(bp), PACK(size, 0));\n7 coalesce(bp);\n8 }\n9\n10 static void *coalesce(void *bp)\n11 {\n12 size_t prev_alloc = GET_ALLOC(FTRP(PREV_BLKP(bp)));\n13 size_t next_alloc = GET_ALLOC(HDRP(NEXT_BLKP(bp)));\n14 size_t size= GET_SIZE(HDRP(bp));\n15\nif (prev_alloc \u0026amp;\u0026amp; next_alloc) { I* Case 1 *I\nreturn bp;\n18 }\n19\nelse if (prev_alloc \u0026amp;\u0026amp; !next_alloc) { I* Case 2 *I\nsize += GET_SIZE (HDRP (NEXT_BLKP(bp))) ;\n22 PUT(HDRP(bp), PACK(size, O));\n23 PUT(FTRP(bp), PACK(size,O));\n24 }\n25\nelse if (!prev_alloc \u0026amp;\u0026amp; next_alloc) { I* Case 3 *I\nsize += GET_SIZE(HDRP(PREV_BLKP(bp)));\nPUT(FTRP(bp), PACK(size, 0));\nPUT(HDRP(PREV_BLKP(bp)), PACK(size, 0));\nbp = PREV_BLKP(bp);\n31 }\n32\nelse { I* Case 4 *I\nsize+= GET_SIZE(HDRP(PREV_BLKP(bp))) +\nGET_SIZE(FTRP(NEXT_BLKP(bp)));\nPUT(HDRP(PREV_BLKP(bp)), PACK(size, 0));\nPUT(FTR 王 )(NEXT_BLKP(bp)), PACK(size, O));\nbp = PREV_BLKP(bp);\n39 }\n40 return bp;\n41 }\ncode/vm/mallodmm.c\n图 9-46 mm_free: 释放一个块,并使用边界标记合并将之与所有的邻接空闲块在常数时间内合并\nc o a l e s c e 函数中的代码是图 9-40 中勾画的四种情况的一种简单直接的实现。这里也有一个微妙的方面。我们选择的空闲链表格式(它的序言块和结尾块总是标记为已分配)允 许我们忽略潜在的麻烦边界情况,也 就是,请 求块 b p 在堆的起始处 或者是在堆的结尾处。如果没有这些特殊块,代码将混乱得多,更加容易出错,并且更慢,因为我们将不得不在 每次释放请求时,都去检查这些并不常见的边界情况。\n5. 分配块\n一个应用通过调用 mm_ma l l o c 函数(见图9-47 ) 来向内存请求大小为 s i z e 字节的块。在检查完请求的真假之后,分配器必须调整请求块的大小,从而为头部和脚部留有空间, 并满足双字对齐的要求 。第 12 13 行强制了最小块大小是 16 字节: 8 字节用来满足对齐要求, 而另外 8 个用来放头部和脚部。对于超过 8 字节的请求(第15 行), 一般的规则是加上开销字节 ,然后向 上舍入到最接近的 8 的整数倍 。\ncode/vmlmallodmm.c\nvoid *mm_malloc(size_t size)\n3 size_t asize; I* Adjusted block size *I 4 size_t extendsize; I* Amount to extend heap if no fit *I 5 char *bp; 7 I* Ignore spurious requests *I 8 if (size == 0) return NULL; 10 11 I* Adjust block size to include overhead and alignment reqs. *I 12 if (size \u0026lt;= DSIZE) 13 asize = 2*DSIZE; 14 else 15 asize = DSIZE * ((size + (DSIZE) + (DSIZE-1)) / DSIZE); 16 17 I* Se a 工 ch the free list for a fit *I 18 if ((bp = find_fit (asize)) != NULL) { 19 place(bp, asize); 20 return bp; 21 22 23 I* No fit found. Get more memory and place the block *I 24 extendsize = MAX(asize,CHUNKSIZE); 25 if ((bp = extend_heap(extendsize/WSIZE)) == NULL) 26 return NULL; 27 place(bp, asize); 28 return bp; 29 } code/v mlma llod mm.c 图 9- 47 mm_malloc: 从空闲链表分配一个块 一旦分配器调整了请求的 大小, 它就会搜索空闲链表, 寻找一个合适的空闲块(第18 行)。如果有合适的, 那么分配器就放置这个请求块,并 可选地分割出多余的部分(第19 行),然后返回新分配块的地址。\n如果分配器不能够发现一个匹配的块, 那么就用一个新的空闲块来扩展堆(第24 26 行), 把请求块放置在这个新的空 闲块里, 可选地分割这个块(第27 行), 然后返回一个指针, 指向这个新分配的块。\n练习题 9. 8 为 9. 9. 1 2 节 中描 述的 简单分 配器 实现 一个 f i n d _ 丘 t 函数。\nstatic void *f i nd_fi t (s i ze_t a s i z e )\n你的解答应该对隐式空闲链表执行首次适配搜索。\n练习题 9. 9 为 示例 的分 配器编 写 一个 p l a c e 函数。\ns t a t i c v o i d p l a ce ( v o i d *bp, si ze_t as i ze)\n你的解答应该将请求块放置在空闲块的起始位置,只有当剩余部分的大小等于或 者超 出最小块的 大小 时, 才进行 分割。\n9. 13 显式空闲链表\n隐式空闲链表 为我们提供了一种介绍一些 基本分配器概念的简单方法。然而, 因为块分配与堆块的总数呈线性关系,所以对于通用的分配器,隐式空闲链表是不适合的(尽管对于堆块数最预先就知道是很小的特殊的分配器来说它是可以的)。\n一种更好的方法是将空闲块组织为某种形式的显式数据结构。因为根据定义, 程序不需要一个空闲块的主体,所 以实现这个数据结构的指针可以存放在这些空闲块的主体里面。例如 , 堆可以组织 成一个双向空闲链表, 在每个空闲块中, 都包含一个 pr e d ( 前驱) 和 s uccC后继)指针, 如图 9-48 所示。\n31 3 2 I 0 31 3 2 1 0\n头部 头部\n原来的有效载荷\n脚部\na ) 分配块\n脚部\nb ) 空闲块\n图 9-48 使用双向空闲链表的堆块的格式\n使用双向链表而不是隐式空闲链表, 使首次适配的分配时间从块总数的线性时间减少到了空闲块数 量的线性时间。不过, 释放一个块的时间可以是线性的,也 可能是个常数, 这取决于我们所选择的空闲链表中块的排序策略。\n一种方法是用后进先出( LIFO) 的顺序维护链表, 将新释放的块放置在链 表的开始处。使用 LIFO 的顺序和首次适配的放 置策略, 分配器会最先检查最近使用 过的块。在这种情况下, 释放一个块可以在常数时间内完成。如果使用了边界标 记, 那么合并也可以 在常数 时间内完成。\n另一种方法是按照地址顺序来维护链表, 其中链表中每个块的地址都小于它后继的地址。在这种情况下, 释放一个块需要线性时间的搜索来定位合适的前驱。平衡点在于, 按\n照地址排序的首次适配比 LIFO 排序的首次适 配有更高的内存利用率, 接近最佳适配的利用率。\n一般而言,显式链表的缺点是空闲块必须足够大,以包含所有需要的指针,以及头部和可能的脚部。这就导致了更大的最小块大小,也潜在地提高了内部碎片的程度。\n9. 9. 14 分离的空闲链表\n就像我们已经看到的,一个使用单向空闲块链表的分配器需要与空闲块数量呈线性关 系的时间 来分配块。一 种流行的减少分配时间的方法, 通常称为分 离 存储 ( s eg regated\nstorage), 就是维护多个空闲链表,其中每个链表中的块有大致相等的大小。一般的思路是将所有可能的块大小分成一些等价类,也叫 做 大小类 ( size clas s ) 。有很多种方式来定义大小类。例如, 我们可以根据 2 的幕来划分块大小 :\n{1},{2},{3,4},{5 ~ 8},..·,{1025 ~ 2048},{2049 ~ 4096},{4097 ~ =}\n或者我们可以将小的块分派到它们自己的大小类里, 而将大块按照 2 的幕分类:\n{1},{2},{3}, \u0026hellip; , {1023}, {1024}, {1025 ~ 2048},{2049 ~ 4096 } , {4097 ~ =}\n分配器维护着一个空闲链表数组,每个大小类一个空闲链表,按照大小的升序排列。 当分配器需要一个大小为 n 的块时, 它就搜索相应的空闲链表。如果不能找到合适的块与之匹配,它就搜索下一个链表,以此类推。\n有关动态内存分配的文献描述了几十种分离存储方法,主要的区别在千它们如何定义 大小类,何时进行合并,何时向操作系统请求额外的堆内存,是否允许分割,等等。为了 使你大致了解有哪些可能性 ,我 们会描述两种基本的方 法: 简单分 离存储( sim ple segrega­ ted s to ra ge ) 和分 离适配( segrega t ed fit ) 。\n1 简单分离存储\n使用简单分离存储,每个大小类的空闲链表包含大小相等的块,每个块的大小就是这 个大小类中最大元索的大小。例如, 如果某个大小类定 义为{1 7 ~ 32} , 那么这个类的空闲\n链表全由大小为 32 的块组成。\n为了分配一个给定大小的块,我们检查相应的空闲链表。如果链表非空,我们简单地 分配其中第一块的全部。空闲块是不会分割以满足分配请求的。如果链表为空,分配器就 向操作系统请求一个固定大小的额外内存片(通常是页大小的整数倍),将这个片分成大小相等的块,并将这些块链接起来形成新的空闲链表。要释放一个块,分配器只要简单地将 这个块插入到相应的空闲链表的前部。\n这种简单的方法有许多优点。分配和释放块都是很快的常数时间操作。而且,每个片 中都是大小相等的块,不分割,不合并,这意味着每个块只有很少的内存开销。由于每个 片只有大小相同的块,那么一个已分配块的大小就可以从它的地址中推断出来。因为没有 合并,所以 已分配块的头部就不需要一个已分配/空闲标记。因此已分配块不需要头部, 同 时 因为没有合并 , 它们也不需要脚部。因为分配和释放操 作都是在空闲链表的起始处操作,所以链表只需要是单向的,而不用是双向的。关键点在于,在任何块中都需要的唯一 字段是每个空闲块 中的一个字的 su c c 指针, 因此最小块大小就是一个字。\n一个显著的缺点是,简单分离存储很容易造成内部和外部碎片。因为空闲块是不会被 分割的,所以可能会造成内部碎片。更糟的是,因为不会合并空闲块,所以某些引用模式 会引起极多的外部碎片(见练习题 9. 10 ) 。\n练习题 9. 10 描述一个在基于简单分离存储的分配器中会导致严重外部碎片的引用模式。\n分离适配\n使用这种方法,分配器维护着一个空闲链表的数组。每个空闲链表是和一个大小类相关联的,并且被组织成某种类型的显式或隐式链表。每个链表包含潜在的大小不同的块, 这些块的大小是大小类的成员。有许多种不同的分离适配分配器。这里,我们描述了一种简单的版本。\n为了分配一个块,必须确定请求的大小类,并且对适当的空闲链表做首次适配,查找一个合适的 块。如果找到了一个 , 那么就(可选地)分割它,并 将剩余的部分插入到适当的空闲链表中。如果找不到合适的块,那么就搜索下一个更大的大小类的空闲链表。如此重复,直到找到一个合适的块。如果空闲链表中没有合适的块,那么就向操作系统请求额外的堆内存,从这个新的堆内存中分配出一个块,将剩余部分放置在适当的大小类中。要释放一个块,我们执行合并,并将结果放置到相应的空闲链表中。\n分离适配方法是一种常见的选择, C 标准库中提供的 G N U rna ll o c 包就是采用的这种方法,因为这种方法既快速,对内存的使用也很有效率。搜索时间减少了,因为搜索被限 制在堆的某个部分 , 而不是整个堆。内存利用率得到了改善, 因为有一个有趣的事实: 对分离空闲链表的简单的首次适配搜索,其内存利用率近似于对整个堆的最佳适配搜索的内 存利用率。\n伙伴系统\n伙伴系统( buddy s ys tem ) 是分离适配的一种特例, 其中每个大小类都是 2 的幕。基本的思路是假设一个堆的大小为沪个字,我们为每个块大小沪维护一个分离空闲链表,其 中 O\u0026lt; k\u0026lt; m。请求块大小向上舍入到最接近的 2 的幕。最开始时,只 有一个大小为 沪个字的空闲块。\n为了分配一个大小为 2k 的 块, 我们找到第一个可用的 、大小 为 3 的块, 其中 k\u0026lt; J \u0026lt; m。如果 j = k , 那么我们就完成了。否则, 我们递归地二分割这个块,直到 j = k 。当我们进行这样的分割时,每个剩下的半块(也叫做伙伴)被放置在相应的空闲链表中。要释放一个大小为\n2k 的块, 我们继续合并 空闲的伙伴。当遇到一个巳分配的伙伴时 , 我们就停 止合并。\n关千伙伴系统的一个关键事实是,给定地址和块的大小,很容易计算出它的伙伴的地 址。例如, 一个块, 大小为 32 字节, 地址为:\nXX X …x OOO OO\n它的伙伴的地址为\nXX X …x l OOOO\n换句话说 , 一个块的地址和它的伙伴的地址只有一位不相同。\n伙伴系统分 配器的 主要优点是它的快速搜索 和快速合并。主要缺点是要求块大小为 2 的幕可能导致显 著的内部碎片。因此, 伙伴系统分配器不适合通用目的的工作负载。然而,对 于某些特定应用的工作负载, 其中块大小预先知 道是 2 的幕, 伙伴系统分配器就很有吸引力了。\n9. 10 垃圾收集\n在诸如 C rna l l oc 包这样的显式分配器中, 应用通过调用 rna l l o c 和 fr e e 来分配和释放堆块。应 用要负责释放所有不再需要的已分配块。\n未能释放已分配的块是一种常见的编程错误。例如, 考虑下面的 C 函数 , 作为处理的一部分, 它分配一块临时存储:\nvoid garbage()\n{\nint *P = (int *)Malloc(15213);\nreturn; I* Array pis garbage at this point *I\n因为程序不 再需要 p , 所以在 g ar b a g e 返回前应该释放 p。不幸的是,程序 员忘了释放这个块。它在程序的生命周期内都保持为己分配状态,毫无必要地占用着本来可以用来 满足后 面分配请求的堆空间 。\n垃圾收 集器 ( ga r bage coll ecto r ) 是一种动态内存分配器,它 自动释放程序不再需要的已分配块。这些块被称为垃圾( ga r ba ge ) (因此术语就称之为垃圾收集器)。自动回收堆存储的过程叫做垃圾收 集( ga r bage collection ) 。在一个支待垃圾收集的系统中, 应用显式分配堆块, 但是从不显示地释放它们。在 C 程序的上下文中, 应用调用 ma l l o c , 但是从不调用 fr e e 。反之 ,垃圾 收集器定期识别垃圾块, 并相应地调用 fr e e , 将这些块放回到空闲链表中。\n垃圾收集可以 追溯到 J oh n M cCa r t h y 在 20 世纪 60 年代早期在 MIT 开发的 Lis p 系统。它是诸如 Java 、ML 、Perl 和 M a t hema tica 等现代语言系统的一个重要部分, 而且它仍然是一个重要而活跃的研究 领域。有关 文献描述了大量的垃圾收集方法 , 其数量令人吃惊。我们的 讨论局限于 McCa r t h y 独创的 Ma r k \u0026amp; S weep ( 标记 & 清除)算法, 这个算法很有趣, 因为它可以建立在已存 在的 ma l l o c 包的基础之上, 为 C 和 C ++ 程序提供垃圾收集 。\n9. 10. 1 垃圾收集器的基本知识\n垃圾收集器将内存视为一张有向 可达图 ( rea cha bilit y graph), 其形式如图 9-49 所示。该图的节点被分成一组根节点 ( ro ot node ) 和一组堆节点( hea p node ) 。每个堆节点对应千堆中的一个已分配块。有向边 p- q 意味着块 p 中的某个位置指向块 q 中的某个位置。根节点对应于这样一种不在堆中的位置,它们中包含指向堆中的指针。这些位置可以是寄存器、栈里的变量,或者是虚拟内存中读写数据区域内的全局变量。\n,.\n喻 气 , ..\n、. -.,\u0026rsquo;\u0026rsquo;-星.\u0026rsquo;.飞,•\n图 9-49 垃圾收集器将内存视为一张有向图\n当存在一条从任意 根节点 出发并到达 p 的有向路径时, 我们说节点 p 是 可达的( reacha ble ) 。 在任何时刻, 不可达节点对应千垃圾, 是不能被应用再次使用的。垃圾收集器的角色是维护可达图的某种表示,并通过释放不可达节点且将它们返回给空闲链表,来 定期地回收它们。\n像 M L 和 J ava 这样的语言的垃圾收集器, 对应用如何创建和使用指针有很严格的控制, 能够维护可达图的 一种精确的 表示 , 因此也就能够回收所有垃圾。然而, 诸如 C 和\nC++ 这样的 语言的收集器通 常不能维持可达图 的精确表示。这样的收集器也叫做保守的垃圾 收 集 器 ( co nse r va tive garbage collector) 。从某种意义上来说它们是保守的, 即每个 可达块都被正确地标记为可达了,而一些不可达节点却可能被错误地标记为可达。\n收集器可以按需提供它们的服务,或者它们可以作为一个和应用并行的独立线程,不 断地更新可达图 和回收垃圾 。例如, 考虑如何将一个 C 程序的保守的收 集器加 入到已存在的 ma l l o c 包中, 如图 9- 50 所示。\n动态内存分配器\n! ,\u0026mdash;\u0026mdash;\u0026ndash;, # 一 一1 \u0026ndash; - - \u0026ndash; - - \u0026ndash; \u0026ndash; - - \u0026ndash; - - - \u0026ndash; - - - - \u0026ndash; - - \u0026ndash; - - - - - \u0026ndash; - - \u0026ndash; - - - - \u0026ndash; - - - \u0026ndash; - - \u0026ndash; \u0026ndash; \u0026ndash; \u0026ndash; - - J 图 9-50 将一个保守的垃圾收集器加入到 C 的 ma l l oc 包中\n无论何时需要堆空间时, 应用都会用通常的方式调用 ma l l o c 。 如果 ma l l o c 找不到一个合适的空闲块,那么它就调用垃圾收集器,希望能够回收一些垃圾到空闲链表。收集器 识别出垃圾块, 并通过调用 fr e e 函数将它们返回给堆。关键的思想是收集器代替应用去调用 f r e e 。当对收集器的调用 返回时, ma l l o c 重试 , 试图发现一个合适的空 闲块。如果还是失败了 , 那么它就会向操作系统要求额外 的内存。最后, ma l l o c 返回一个指向请求块的指针(如果成功)或者返回一个空指针(如果不成功)。\n10. 2 Mark \u0026amp; Sweep 垃圾收 集器\nMark \u0026amp;.Sw ee p 垃圾收集器由 标记 ( ma r k ) 阶段和清除 ( sweep ) 阶段组成, 标记阶段标记出根节点的所有可达的和已分配的后继,而后面的清除阶段释放每个未被标记的已分配 块。块头部中空闲的低位中的一位通常用来 表示这个块是否被标记了。\n我们对 Mark \u0026amp;.Swee p 的描述将假设使用下列函数, 其中 ptr 定义为t ype de f void *p七r :\n•• ptr i s P七r (ptr p) 。如果 p 指向一个巳分配块中的某个字, 那么就返回一个指向这个块的起始位置的指针 b。否则返回 NU L L 。\nint blockMarked (ptr b) 。 如果块 b 是已标 记的, 那么就返回 tr ue 。\ni n七 b l o c kA ll o c a t e d (ptr b) 。如果 块 b 是已分配的, 那么就返回 tr ue 。\nvoid markBlock (p 七r b ) 。 标记块 b。\nint length (b ) 。返回 块 b 的以字为单位的长度(不包括头部)。\nvoid unmarkBlock (ptr b) 。将块 b 的状态由己标记的改为未标记的。\nptr ne x 七Bl o c k (p 七r b ) 。返回堆中块 b 的后继。\n标记阶段为每个根 节点调用一次图 9-Sl a 所示的 mar k 函数。如果 p 不指向一个已分配并且未标记的堆块, mar k 函数就立即返回。否则,它 就标记这个块, 并对块中的每个字递归地调用它自己。 每次对 mar k 函数的调用都标记某个根节点的所有未标记并且可达的后继节点。在标记阶段的末尾,任何未标记的已分配块都被认定为是不可达的,是垃 圾,可以在清除阶段回收。\n清除阶段是对图 9-51 6 所示的 s we e p 函数的一次调用。s we e p 函数在堆中每个块上反复循环,释放它所遇到的所有未标记的已分配块(也就是垃圾)。\n图 9-52 展示了一个小堆的 Mark \u0026amp;.Sweep 的图 形化解释。块边界用粗线条表示。每个方\n块对应于内存中的一个字。每个块有一个字的头部,要么是已标记的,要么是未标记的。\nvoid mark(ptr p) {\nif ((b = isPtr(p)) == NULL) return;\nif (blockMarked(b))\nreturn; markBlock(b);\nlen = length(b);\nfor (i=O; i \u0026lt; len; i++) mark (b [i]) ;\nreturn;\nvoid sweep(ptr b, ptr end) { while (b \u0026lt; end) {\nif (blockMarked(b)) unmarkBlock(b);\nelse if (blockAllocated(b)) free(b);\nb = nextBlock(b);\n}\nreturn;\n}\n}\na) mar k 函数 b ) s we e p 函数\n图 9-51 ma r k 和 s we ep 函 数的伪代码\n标记前:\n根节点\n1 2 3 4 i 5\n标记后:\ni\n久七:,\n口未标记的块头部口 巳标记的块头部\n清除后: I 全闲\ni\n空闲的\n图 9-52 Mark \u0026amp;.S weep 示例。注意这个示例中的 箭头表示内 存引用,而不是空闲链表指针\n初始情况下,图 9-52 中的堆由六个已分配块组成, 其中每个块都是未分配的。第 3\n块包含一 个指向第 1 块的指针。第 4 块包含指向第 3 块和第 6 块的指针。根指向第 4 块。\n在标记阶段之后, 第 1 块、第 3 块、第 4 块和第 6 块被做了标记, 因为它们是从根节点可达的。第 2 块和第 5 块是未标 记的, 因为它们是不 可达的。在清除阶段之后, 这两个不可达块被回收到空闲链表。\n9. 10. 3 C 程序的保守 Ma rk \u0026amp; Sweep\nMark\u0026amp;Sweep对 C 程序的垃圾收集是一种合适的方法, 因为它 可以就地工作,而不需 要移动任何块。然而, C 语言为 i s Ptr 函数的实现造成了一些有趣的挑战。\n第一, C 不会用任何类型信息来标记内存位置。因此, 对 i s Ptr 没有一种明显的方式来判断它的输入参数 p 是不是一个指针。第二, 即使我们知道 p 是一个指针 , 对 i s Pt r 也没有明显 的方式来判断 p 是否指向一个已分配块的有效 载荷中的 某个位 置。\n对后一问题的解决方法是将已分配块集合维护成 一棵平衡二叉树, 这棵树保持着这样一个属性:左子树中的所有块都放在较小的地址处,而右子树中的所有块都放在较大的地址 处。如图 9-53 所示, 这就要求每个已 分配块的头部里有两个附加字段Cl e 红 和 r i ght )。每个字段指向某个巳分配块的头部。i s Ptr (ptr p)函数用树来执行对已分配块的二分查找。在每一步中,它 依赖于块头部中的大小字段来判断 p 是否落在这个块的范围之内 。\n三> 块剩余的部分\n图 9-53 一棵已分配块的平衡树中的左右指针\n平衡树方法保证会标记所有从根节点可达的节点,从这个意义上来说它是正确的。这是一个必要的保证,因为应用程序的用户当然不会喜欢把他们的已分配块过早地返回给空闲链表。然而,这种方法从某种意义上而言又是保守的,因为它可能不正确地标记实际上不可达的块,因此它可能不会释放某些垃圾。虽然这并不影响应用程序的正确性,但是这可能导致不必要的外部碎片。\nC 程序的 Mar k \u0026amp;. S weep 收集器必须是 保守的 , 其根本原因是 C 语言不会用类型信息来标记内存位置。因此,像 i n t 或者 f l oa t 这样的标量可以伪装成指针。例如, 假设某个可达的巳分配块在它的有效载荷中包含一个 i nt , 其值碰巧对应于某个其他已分配块 b 的有效载荷中的一 个地址。对收集器而言 , 是没有办法推断出这个数据实际上是 i n t 而不是指针。因此 ,分 配器必须保守地将块 b 标记为可达, 尽管事实上 它可能是不可达的。\n9. 11 C 程序中常见的与内存有关的错误\n对 C 程序员来说,管 理和使用虚拟内存可能是 个困难的、容易出错的任务。与内存有关的错误属于那些最令人惊恐的错误,因为它们在时间和空间上,经常在距错误源一段距离之后才表现出来。将错误的数据写到错误的位置,你的程序可能在最终失败之前运行了好几个小时,且使程序中止的位置距离错误的位置已经很远了。我们用一些常见的与内存有关错误的讨论,来结束对虚拟内存的讨论。\n9. 11. 1 间接引用坏指针\n正如我们在9. 7. 2 节中学到的, 在进程的虚拟地址空间中有较 大的洞, 没有映射到任何有意义的数据。如果我们试图间接引用一个指向这些洞的指针,那么操作系统就会以段异常中止 程序。而且, 虚拟内存的某些区域是只读的。试图写这些区域将会以保护异常中止这个程序。\n间接引用坏指针的一个常见示例是经典的 s c a n f 错误。假设我们想要使用 s c a n f 从\ns t d i n 读一个整数到一个变量。正确的方法是传 递给 s c a n f 一个格式串和变量的地址:\nscanf(\u0026ldquo;¼d\u0026rdquo;, \u0026amp;val)\n然而 , 对于 C 程序员初学者而言(对有经验者也是如此!), 很容易传递 v a l 的内容,而 不是它的地址:\ns canf ( \u0026ldquo;1 儿 d\u0026rdquo; , val)\n在这种 情况下 , s c a n f 将把 va l 的内容解释为一个地址, 并试图将一个字写到这个位置。在最好的 清况下 ,程序 立即以异常终止。在最糟糕的情况下, v a l 的 内 容对应于虚拟内存的某个合法的读/写区域,千是我们就覆盖了这块内存,这通常会在相当长的一段时间以 后造成灾难性的、令人困惑的后果。\n9. 11 . 2 读未初始化的内存\n虽然 bss 内存位置(诸如未初始化的全局 C 变量)总是被加载器初始化为零 ,但 是对于堆内存却并不是这样的。一个常见的错误就是假设堆内存被初始化为零:\nI* Return y = Ax *I\nint *matvec(int **A, int *x,\n{\nint n)\nint l., j;\nint *Y = (int *)Malloc(n * sizeof(int));\nfor (i = O; i \u0026lt; n; i++)\nfor (j = O; j \u0026lt; n; j++) y[i] += A[i] [j] * x[j];\nreturn y;\n在这个示例中, 程序员 不正确地假设向量 y 被初始化为零。正确的实现方式是显式地将\ny [ i ]设置为零 ,或 者使用 c a l l o c 。\n9. 11. 3 允许栈缓冲区溢出\n正如我们在 3. 10. 3 节中看到的, 如果一个程序不检查输入串的大小就写入栈中的目标缓冲区, 那么这个程序就会有缓 冲区 溢出错 误( b uff e r overflow bug ) 。例如, 下面的函数就有缓 冲区溢出错误, 因为 g e t s 函数复制一个任意长度的串到缓冲区。为了纠正这个错误, 我们必 须使用 f g e t s 函数, 这个函数限制了输入串的大小:\nvoid bufoverflow()\n{\nchar buf[64];\ngets(buf); I* Here is the stack buffer overflow bug *I return;\n9. 11. 4 假设指针和它们指向的对象是相同大小的\n一种常见的错误是假设指向对象的指针和它们所指向的对象是相同大小的:\nf* Create an nxm array *f\nint **makeArrayl(int n, int m)\n{\nint i;\nint **A= (int **)Malloc(n * sizeof(int));\nfor (i \u0026ldquo;\u0026lsquo;0; i \u0026lt; n; i ++)\nA[i] \u0026ldquo;\u0026rsquo;(int•)Malloc(m * sizeof(int)); return A;\n这里的目的是创建一个由 n 个指针组成的数组, 每个指针都指向一个包含 m 个 i nt 的数组。然而, 因为程序员 在第 5 行将 s i z e o f (int *)写成 了 江ze o f (int), 代码实际上创建的是一个 i n 七的 数组。\n这段代码只有在 i n t 和指向 i n t 的指针大小相同的机器上运行良好。但是, 如果我们在像 Core i7 这样的机器上运行这段代码, 其中指针大于 i nt , 那么第 7 行和第 8 行的循环将\n写到超出 A 数组结尾的地方。因为这些字中的一个很可能是已分配块的边界标记脚部,所 以我们可能不会发现这个错误,直到在这个程序的后面很久释放这个块时,此时,分配器中的 合并代码会戏剧性地失败, 而 没有任何明显的原因。这是“在远处起作用 ( actio n at dis­ tance)\u0026rdquo; 的一个阴险的示例,这类 ”在 远处起作用” 是 与内 存有关的编程错误的典型情况。\n9. 11. 5 造成错位错误\n错位(off- by-one)错误是另一种很常见的造成覆盖错误的来源: I* Create an nxm array *I\n2 int **makeArray2(int n, int m)\n3 {\n4 int i;\n5 int **A= (int **)Malloc(n * sizeof(int *));\n7 for (i = 0; i \u0026lt;= n; i ++)\n8 A[i] = (int *)Malloc(m * sizeof(int)); return A;\n10 }\n这是前面一节中程序的另一个版本。这里我们在第 5 行创建了一个 n 个元 素的指针数组,但 是 随后 在第 7 行 和第 8 行试图初始化这个数组的 n + l 个 元 素, 在这个过程中覆盖了 A 数组后面的某个内存位置。\n9. 11. 6 引用指针,而不是它所指向的对象\n如果不太注意 C 操作符的优先级和结合性, 我们就会错误地操作指针, 而不是指针所指向的对象。比如, 考虑下面的函数,其 目 的 是 删除一个有 *s i z e 项的二叉堆里的第一项,然 后 对剩 下的 *s i z e - 1 项 重 新 建堆 :\nint *binheapDelete(int **binheap, int *Size)\n3 int *packet= binheap[O];\n5 binheap[O] = binheap[*size - 1];\n6 *size\u0026ndash;; I* This should be (*size)\u0026ndash; *I\nheapify(binheap, *size, 0); return(packet); 在第 6 行,目 的 是 减 少 s i z e 指 针 指 向 的 整 数 的 值 。 然而,因 为一元运算符- - 和 * 的 优先级相同 ,从 右 向 左 结 合 ,所 以 第 6 行 中的代码实际减少的是指针自己的值, 而 不 是它所指向的整数的值。如果幸运地话,程序会立即失败;但是更有可能发生的是,当程序在执行过程后很久才产生出一个不正确的结果时,我们只有一头的雾水。这里的原则是当你对优先级和结合性有疑问的时候,就 使 用 括号。比如, 在第 6 行 , 我们可以使用表达式(*s i ze ) 一一,清 晰地表明我们的意图。\n9. 11. 7 误解指针运算\n另一种常见的错误是忘记了指针的算术操作是以它们指向的对象的大小为单位来进行 的,而这 种 大 小 单 位并不一定是字节。例如,下 面函数的目的是扫描一个 i n t 的数组,并\n返回一个指针, 指向 v a l 的首次出现:\nint *Search(int *P, int val)\n{\nwhile (*p \u0026amp;\u0026amp; *P != val)\np += sizeof(int); I* Should be p++ *I return p;\n}\n然而, 因为每次循 环时,第 4 行都把指针加了 4 ( 一个整数的字节数), 函数就不正确地扫描数组中每 4 个整数。\n9. 11. 8 引用不存在的变量\n没有太多经验的 C 程序员不理解栈的规则, 有时会引用不再合法的本地变量, 如下列所示:\nint *stackref 0\n{\nint val; return \u0026amp;val;\n这个函数返回一个指针(比如说是 p ) , 指向栈里的一个局部变量,然后弹出它的栈帧。尽管 p 仍然指向一个合法的内存地址, 但是它已经不再指向一个合法的 变量了。当以后在程序中调用其他函数时,内存将重用它们的栈帧。再后来,如果程序分配某个值给\n*p, 那么它可能实际上正在修改另一个函数的栈帧中的一个条目,从而潜在地带来灾难性 的、令人困惑的后果。\n9. 11. 9 引用空闲堆块中的数据\n一个相似的错误是引用已经被释放了的堆块中的数据。例如,考虑下面的示例,这个示例 在第 6 行分配了一个整数数组x , 在第 10 行中先释放了块x , 然后在第14 行中又引用了它:\nint *heapref (int n, int m)\n{\nint i;\nint *X, *y;\nx = (int *)Malloc(n * sizeof(int));\nIIOther calls to mal/oc and free go here\nfree(x);\ny = (int *)Malloc(m * sizeof(int));\nfor\n(i = O; i \u0026lt; m; i++)\ny[i] = x[i]++; I* Oops! x[i] is a word in a free block *I\nreturn y;\n取决千在第 6 行和第 10 行发生的 ma l l o c 和 fr e e 的调用模式, 当程序在第 1 4 行引用x [ i ] 时, 数组 x 可能是某 个其他巳分配堆块的一部分 了, 因此其内容被重写了 。和其他许多与内存有关的错误一样, 这个错误只 会在程序执行 的后面, 当我们 注意到 y 中的值被破坏了时才会显现出来。\n9. 11. 10 引起内存泄漏\n内存泄漏是缓慢、隐性的杀手,当程序员不小心忘记释放巳分配块,而在堆里创建了 垃圾时 , 会发生这种问题。例如,下 面的函数分 配了一个堆块 x , 然后不释放它就返回:\nvoid leak(int n)\n{\nint *X = (int *)Malloc(n * sizeof(int)); return; I* xis garbage at this point *I\n如果经常调用 l e a k , 那么渐渐地,堆里就会充满了垃圾,最糟糕的情况下,会占用整个虚拟地址空间。对于像守护进程和服务器这样的程序来说,内存泄漏是特别严重的, 根据定义这些程序是不会终止的。\n12 小结\n虚拟内存是对主存的一个抽象。支待虚拟内存的处理器通过使用一种叫做虚拟寻址的间接形式来引 用主存。处理器产生一个虚拟地址,在 被发送到主存之前,这 个 地 址 被 翻译 成一个物理地址。从虚拟地址空间到物理地址空间的地址翻译要求硬件和软件紧密合作。专门的硬件通过使用页表来翻译虚拟地址, 而页表的内容是由操作系统提供的。\n虚拟内存提供三个重要的功能。第一,它 在 主存 中 自 动缓存最近使用的存放磁盘上的虚拟地址空间的内容。虚拟内存缓存中的块叫做页。对磁盘上页的引用会触发缺页,缺页将控制转移到操作系统中的 一个缺页处理程序。缺页处理程序将页面从磁盘复制到主存缓存,如 果 必 要 ,将 写 回 被 驱逐的页。第二, 虚拟内存简化了内存管理,进 而又简化了链接、在进程间共享数据、进程的内存分配以及程序加载。最后,虚拟内存通过在每条页表条目中加入保护位,从而了简化了内存保护。\n地址翻译的过程必须和系统中所有的硬件缓存的操作集成在一起。大多数页表条目位于 Ll 高速缓存中, 但是一个称为 T LB 的 页表条目的 片上高速缓存, 通常会消除访问 在 Ll 上的页表条目的开销。\n现代系统通过将虚拟内存片和磁盘上的文件片关联起来,来初始化虚拟内存片,这个过程称为内存 映射。内存映射为共享数据、创建新的进程以及加载程序提供了一种高效的机制。应用可以使用 mmap 函数来手工地创建和删除虚拟地址空间的区域。然而,大 多 数 程 序 依 赖于动态内存分配器, 例 如 ma l l oc , 它管 理 虚 拟 地址 空 间 区 域内 一 个 称 为 堆 的 区 域 。 动 态 内 存 分 配 器 是 一 个 感 觉 像 系 统 级 程 序 的 应 用 级 程 序 , 它直接操作内存,而无需类型系统的很多帮助。分配器有两种类型。显式分配器要求应用显式地释放它 们的内存块。隐式分配器(垃圾收集器)自动释放任何未使用的和不可达的块。\n对千 C 程序员来说 ,管 理 和使 用 虚拟内 存是 一 件困 难 和容易 出错 的任务。常见的错误示例包括:间接引用坏指针,读取未初始化的内存,允许栈缓冲区溢出,假设指针和它们指向的对象大小相同,引用 指针而不是它所指向的对象,误解指针运算,引用不存在的变量,以及引起内存泄湍。\n参考文献说明\nKilburn 和他 的 同 事 们发 表 了 第 一 篇关 于虚拟内存的描述[ 63] 。体系结构教科书包括关 千硬 件在虚拟内存中的角色的更多细节[ 46] 。 操 作 系 统 教 科 书 包 含 关 千操作系统角色的更多信息[ 102 , 106, 113] 。\nBovet 和 Cesati [ 11] 给出了 Linu x 虚拟内存系统的详细描述。Intel 公司提供了 IA 处 理 器 上 32 位 和 64 位\n地址翻译的详 细文档 [ 52]。\nKnuth 在 1968 年编写了有关内 存分配的经典之作[ 64] 。从那以后,在 这个领域就有了大量的 文献。\nWilson、Johnstone 、Neely 和 Boles 编写了一篇关 于显式 分配器的漂亮综 述和性能评价的文章[ 118] 。本书中关 千各种 分配器策略的吞吐率和利用率 的一般评价就引自千他们 的调查。Jones 和 Lins 提供了关于垃圾收集的全面综述[ 56] 。Kern ighan 和 Rit chie [ 61] 展示了一个简单分配器的 完整代码, 这个简单的分配器是基 千显式空闲链 表的,每个空闲块中都有 一个块大小 和后继指针 。这段代码使用联合( union ) 来消除 大量的复杂指针运算 , 这是很 有趣的 , 但是代价是 释放操作是线性时间(而不是常数时间)。Doug Lea 开发了广泛使用的开源 malloc 包, 称为 dl ma ll oc [ 67 ] 。\n家庭作业\n9. 11\n在下面的一 系列间题中, 你要展示 9. 6. 4 节中的示例内存系统如何将虚拟地址翻译成物理地址, 以 及如何访问缓存。对于给定的 虚拟地址 , 请指出访问的 T LB 条目、物理地址,以 及返回的缓存字节值。请指明是否 T LB 不命中, 是否发生了缺页, 是否发生了缓存不命中。如果 有缓存不命中,对 千"返回的缓存字节”用\u0026rdquo;-\u0026ldquo;来表示。如果有缺页,对 千\u0026rdquo; PP N\u0026quot; 用\u0026quot;-\u0026ldquo;来表示,而 C 部分和 D 部分就空着。\n虚拟地址: Ox027c\n虚拟地址格式\n地址翻译\n3 12 JI 10 9 8 7 6 5 4 3\nI 仁勹\n物理地址格式\n物理地址引用\n11 10\n仁工二] # 9 8 7 6 5 4 3 2 1 0\nI I I I I I I I I I\n9. 12\n对于下面的地址,重复习题 9. 11:\n虚拟地址: Ox03a9\n虚拟地址格式 13 12 II 10 9 8 7 6 5 4 3 2 I 0\n地址翻译 c. 物理地址格式\n11 10 9 8 7 6 5 4 3 2 I 0\nD. 物理地址引用\n9 13 对于下面的地址, 重复习题 9. 11:\n虚拟地址: Ox 00 40\n虚拟地址格式\n13 12 l l 10 9 8 7 6 5 4 3 2 I 0\n地址翻译\n参 数 值 VPN TLB 索引 TLB 标记 TLB 命中?(是 I 否) 缺页? (是 I 否 ) PPN 物理地址格式 11 10 9 8 7 6 5 4 3 2 1 0\n物理地址引用 •• 9. 14 假设 有一 个输入文件 he l l o . t x t , 由 字符串\u0026rdquo; He l l o , world! \\ n\u0026quot;组成,编 写 一 个 C 程序,使 用\nmma p 将 he ll o . t 江 的 内 容 改 变为\u0026quot; J e l l o , wor l d ! \\ n\u0026quot; 。\n9. 15 确定下面的 ma l l oc 请 求序列得到的块大小和头部值。假设: 1) 分 配器保持双字对齐,使 用隐式空闲 链 表 ,以 及图 9-35 中的块格式。2) 块大小向上舍入为最接近的 8 字节的倍数。 9. 16 确定下面对齐要求和块格式的每个组合的最小块大小。假设:显式空闲链表、每个空闲块中有四字节的 pr e d 和 s uc c 指针、不允许有效载荷的大小为零,并 且头部和脚部存放在一个四字节的字中。 对齐要求 已分配块 空闲块 最小块大小(字节) 单字 头部和脚部 头部和脚部 单字 头部,但是没有脚部 头部和脚部 双字 头部和脚部 头部和脚部 双字 头部,但是没有脚部 头部和脚部 *.* 9. 17 开发 9. 9. 12 节中的分配器的一个版本,执行 下 一 次 适 配搜索,而 不是 首次适配搜索。\n•: 9. 18 9. 9. 12 节中的分配器要求每个块既有头部也有脚部,以 实 现常数时间的合并。修改分配器, 使得空闲块需要头部和脚部,而已分配块只需要头部。\n9. 19 下 面给出了三组关于内存管理和垃圾收集的陈述。在每一组中,只 有 一 句 陈 述 是 正 确的。你的任务就是判断哪一句是正确的。 a ) 在一个伙伴系统中,最 高 可达 50 %的空间可以因为内部碎片而被浪费了。\nb ) 首次适配内存分配算法比最佳适配算法要慢一些(平均而言)。\nc ) 只有当空闲链表按照内存地址递增排序时,使 用 边界标记来回收才会快速。\nd ) 伙伴系统只会有内部碎片,而 不 会 有 外 部 碎 片 。\na ) 在 按 照 块 大小 递减顺序排序的空闲链表上,使 用 首 次 适 配 算 法会导致分配性能很低, 但是可以避免外部碎片。\nb ) 对于最佳适配方法,空 闲 块 链 表 应 该 按 照内 存 地 址 的 递 增 顺 序 排 序 。\nc ) 最 佳 适 配 方 法 选择与请求段匹配的最大的空闲块。\nd ) 在按照块大小递增的顺序排序的空闲链表上,使 用 首 次 适 配算法与使用最佳适配算法等价。\nM ark \u0026amp; Sweep 垃圾收集器在下列哪种情况下叫做保守的:\na ) 它 们 只 有 在内存请求不能被满足时才合并被释放的内存。\n) 它们把一切看起来像指针的东西都当做指针。 ) 它们只在内存用尽时,才 执行 垃圾收集。 ) 它们不释放形成循环链表的内存块。 :: 9. 20 编写你自己的 ma l l oc 和 fr e e 版本, 将它的运行时间 和空间 利用率与标准 C 库提供的 ma l l oc 版本进行比较。\n练习题答案\n9. 1 这道题让你对不同地 址空间的大小有了些 了解。曾 儿何时,一 个 32 位地址空间看上去似乎是无法想象的大。但是,现在有些数据库和科学应用需要更大的地址空间,而且你会发现这种趋势会继 续。在有生之年 , 你可能会抱怨个人电 脑上那狭促的 64 位地址空间!\n虚拟地址位数( n ) 虚拟地址数( N) 最大可能的虚拟地址 8 z8=256 2\u0026rsquo;-1 =255 16 i16= 64K 2'6- I =64K-1 32 232= 4G 232- I =4G- l 48 248= 256T 248 - l = 256T- l 64 264 = 16 384P 264 - I= 16 384P-1 9. 2 因为每个虚拟页面是 P = 沪 字节,所以 在系统中总共有 2勹沪= 2-\u0026ldquo;p 个可能的页面,其 中每个都需要一个页表条目 ( PT E) 。\nn P=2p PTE的数量 16 4K 16 16 8K 8 32 4K IM 32 8K 512K 9. 3 为了完全掌 握地址 翻译,你需 要很好地理解这类问题。下面是如何解决第一个子问题: 我们有 n =\n32 个虚拟地址位 和 m = 24 个物理地址位。页面大小是 P = l KB, 这意味着对于 VPO 和 PPO , 我们都需要 log2 OK)= 10 位。(回想一下, VPO 和 PPO 是相同的。)剩下的地址位分别 是 V PN 和 PP N。\np VPN位数 VPO位数 PPN位数 PPO位数 IKB 22 10 14 10 2KB 21 11 13 11 4KB 20 12 12 12 8KB 19 13 11 13 9. 4 做一些这样的手工模拟,能很好 地巩固你对地址 翻译的理解 。你会发 现写出地址中的 所有的位, 然后在不同的位字段上画出方框,例 如 VP N、T LBI 等,这会很有 帮助。在这个 特殊的练习中,没有任何类型的不命中 : T LB 有一份 PT E 的副本, 而缓存有一份所请求 数据字的副本。对于命中和不命中的一些不同的组合, 请参见习题 9. 11 、9. 12 和 9. 13 。\nA. 00 0011 1101 0111\nB.\nC. OOll 0101 Olll\nD.\n9. 5 解决这个题目将帮助你很好地理解内存映 射。请自己独立完成这道题 。我们 没有讨论 o pe n 、f s t a t\n或者 wr i t e 函数,所以 你需要阅读它们的 帮助页来看看它们是 如何工作的。\npcopy.c\ncode/vmlmmapcopy.c\n9. 6 这道题触及了一些核心的概念,例如对齐要求、最小块大小以及头部编码。确定块大小的一般方法是,将所请求的 有效载荷和头部大小 的和舍 入到对齐要求(在此例中是 8 字节)最近的 整数倍。比如, ma ll o c (1 ) 请求的 块大小是 4 + 1 = 5 , 然后舍入到 8 。而 ma ll o c ( 13 ) 请求的块大小是 13 + 4 = 17, 舍入到 24 。\n请求 块大小(十进制字节) 块头部(十六进制) malloc (1) 8 Ox9 malloc ( 5 ) 16 Oxll malloc (12) 16 Ox ll ma ll o c (13) 24 Ox 1 9 9. 7 最小块大小对内部碎片有显著的影响。因此,理解和不同分配器设计和对齐要求相关联的最小块大小是很好的。很有技巧的 一部分是,要意识 到相同的块可以 在不同时刻被分配或者被释放。因此, 最小块大小就是最小已分配块大小 和最小空闲块大小两者的最大值。例如, 在最后一个子问题中,\n最小的已分配块大小 是一个 4 字节头部和一个 1 字节有效 载荷, 舍人到 8 字节。而最小空闲块的大小是一个 4 字节的头部 和一个 4 字节的脚部 , 加起来是 8 字节, 已经是 8 的倍数,就不需 要再舍入了。所以, 这个分配器的最小块大小就是 8 字节。\n对齐要求 已分配块 空闲块 最小块大小(字节) 单字 头部和脚部 头部和脚部 12 单字 头部,但是没有脚部 头部和脚部 8 双字 头部和脚部 头部和脚部 16 双字 头部,但是没有脚部 头部和脚部 8 9. 8 这里没有特别的技巧 。但是解答此题要求你理解简单的隐式链表分配器的 剩余部分是如何工作的, 是如何操作和遍历块的。\ncodelv mlmallod mm .c\nstatic void *find_fit(size_t asize)\n2 {\n3 I* First-fit search *I\n4 void *bp;\n5\nfor (bp = heap_listp; GET_SIZE(HDRP(bp)) \u0026gt; O; bp = NEXT_BLKP(bp)) {\nif (!GET_ALLOC(HDRP(bp)) \u0026amp;\u0026amp; (asize \u0026lt;= GET_SIZE(HDRP(bp)))) {\nreturn bp;\n9 }\n10 }\nreturn NULL; I* No fit *I\n#endif\n13 }\ncode/vmlmallod mm .c\n9. 9 这又是一个帮 助你熟悉分 配器的热身 练习。注意 对于这个分配器, 最小块大小是 16 字节。如果分割后剩下的块 大于或者等于最小块大小,那么我们就分割这个块 (第 6~ 10 行)。这里唯一有技巧的部分是要意识到在移动到下一块之前(第8 行),你必 须放置新的已分配块(第 6 行和第 7 行)。\ncode/vmlmallodmm.c\n1 static void place(void *bp, size_t asize)\n2 {\n3 size_t csize = GET_SIZE(HDRP(bp));\n4\nif ((csize - asize) \u0026gt;= (2*DSIZE)) {\nPUT(HDRP(bp), PACK(asize, 1));\nPUT(FTRP(bp), PACK(asize, 1));\ns bp= NEXT_BLKP(bp);\nPUT(HDRP(bp), PACK(csize-asize, O));\nPUT(ITRP(bp), PACK(csize-asize, O)); 11 }\nelse {\nPUT(HDRP(bp), PACK(csize, 1));\nPUT(FTRP(bp), PACK(csize, 1));\n15 }\n16 }\ncode/vm/mallod mm.c\n9. 10 这里有一个会引起外部 碎片的模式: 应用对第一个大小类做大员的分 配和释放 请求, 然后对第二个大小类做大鼠的分配和释放请求 ,接下来 是对第三个大小类做大量的分配和释 放请求, 以此类推。对于每个大小类,分 配器都创建了许多不会被回收的存储器, 因为分配器不会合并, 也因为应用不会再向这个大小类再次请求块了。\n"},{"id":446,"href":"/zh/docs/technology/System/%E6%B7%B1%E5%85%A5%E7%90%86%E8%A7%A3%E8%AE%A1%E7%AE%97%E6%9C%BA%E7%B3%BB%E7%BB%9F/%E4%B9%A6%E7%B1%8D/%E7%AC%AC%E4%B8%80%E9%83%A8%E5%88%86-%E7%A8%8B%E5%BA%8F%E7%BB%93%E6%9E%84%E5%92%8C%E6%89%A7%E8%A1%8C-2-6/","title":"Index","section":"SpringCloud","content":"第一部分\n尸\n心r\n程序结构和执行\n我们对计算机系统的探索是从学习计算机本身开始的,它由 处理器和存储器子系统组成。在核心部分,我们需要方法来表示 基本数据类型,比如整数和实数运算的近似值。然后,我们考虑 机器级指令如何操作这 样 的 数 据, 以 及 编译器 又如何 将 C 程 序 翻译成这样的指令。接下来,研究几种实现处理器的方法,帮助我 们更好地了解硬件资源如何被用来执行指令。一旦理解了编译器 和机器级代码 , 我们 就 能 了 解如何通 过编写 C 程 序 以 及 编译 它 们来最大化程序的性能。本部分以存储器子系统的设计作为结束, 这是现代计算机系统最复杂的部分之一。\n本书的这一部分将领着你深入了解如何表示和执行应用程序。你将学会一些技巧,来帮助你写出安全、可靠且充分利用计算资 源的程序。\n"},{"id":447,"href":"/zh/docs/technology/System/%E6%B7%B1%E5%85%A5%E7%90%86%E8%A7%A3%E8%AE%A1%E7%AE%97%E6%9C%BA%E7%B3%BB%E7%BB%9F/%E4%B9%A6%E7%B1%8D/%E7%AC%AC%E4%B8%89%E9%83%A8%E5%88%86-%E7%A8%8B%E5%BA%8F%E9%97%B4%E7%9A%84%E4%BA%A4%E4%BA%92%E5%92%8C%E9%80%9A%E4%BF%A1-10-12/","title":"Index","section":"SpringCloud","content":"第三部分\n程序间的交互和通信\n我们学习计算机系统到现在,一直假设程序是独立运行的, 只包含最小 限度 的 输入 和 输 出 。 然 而 , 在 现实 世界 里, 应 用 程 序利用 操作 系统提供的服 务 来 与 I/ 0 设 备 及 其他程序通信。\n本书 的 这 一部分将使你 了 解 U ni x 操作 系统提供 的基本 I/ 0 服务 , 以及如何用这 些服务 来构 造 应 用 程 序 , 例如 Web 客 户 端 和服务器, 它 们是 通过 Intern et 彼 此 通 信 的 。 你 将 学 习 编 写 诸 如 Web 服务器这样的 可 以 同 时 为 多 个 客 户 端提 供 服 务 的并 发 程 序。 编 写并发应用程序还能使程序在现代多核处理器上执行得更快。当学 完了这个部分,你将逐渐变成一个很牛的程序员,对计算机系统 以及它们对程序的影响有很成熟的理解。\n"},{"id":448,"href":"/zh/docs/technology/System/%E6%B7%B1%E5%85%A5%E7%90%86%E8%A7%A3%E8%AE%A1%E7%AE%97%E6%9C%BA%E7%B3%BB%E7%BB%9F/%E4%B9%A6%E7%B1%8D/%E7%AC%AC%E4%BA%8C%E9%83%A8%E5%88%86-%E5%9C%A8%E7%B3%BB%E7%BB%9F%E4%B8%8A%E8%BF%90%E8%A1%8C%E7%A8%8B%E5%BA%8F-7-9/","title":"Index","section":"SpringCloud","content":"霄'\n第二部分\n在系统上运行程序\n继续我们对计算机系统的探索,进一步来看看构建和运行应 用程序的系统软件。链接器把程序的各个部分联合成一个文件, 处理器可以将这个文件加载到内存,并且执行它。现代操作系统 与硬件合作,为每个程序提供一种幻象,好像这个程序是在独占 地使用处 理器和 主存 , 而 实际 上,在 任何 时 刻, 系 统 上 都 有 多 个程序在运行。\n在本书的第一部分,你很好地理解了程序和硬件之间的交互 关系。本书的第二部分将拓宽你对系统的了解,使你牢固地掌握 程序和操作系统之间的交互关系。你将学习到如何使用操作系统 提供的 服 务 来 构 建 系 统 级 程 序, 例 如 U nix shell 和 动 态 内 存 分配包。\n"},{"id":449,"href":"/zh/docs/technology/System/%E6%B7%B1%E5%85%A5%E7%90%86%E8%A7%A3%E8%AE%A1%E7%AE%97%E6%9C%BA%E7%B3%BB%E7%BB%9F/%E4%B9%A6%E7%B1%8D/%E9%99%84%E5%BD%95A-%E5%B0%81%E5%BA%95/","title":"Index","section":"SpringCloud","content":"附录 A\nA P P E N D I X A # 错误处理\n程序员应该总是检查系统级函数返回的错误代码。有许多细微的方式会导致出现错 误,只 有使用内核能够提供给我们的状态信息才能理解为什么有这样的错误。不幸的是, 程序员 往往不愿意进行错误检查, 因为这使他们的代码变得 很庞大,将 一行代码变成一个多行的条件语句。错误检查也是很令人迷惑的,因为不同的函数以不同的方式表示错误。 # 在编写本书时, 我们面临类似的问题。一方面, 我们希望代码示例阅读起来简洁简单;另一方面,我们又不希望给学生们一个错误的印象,以为可以省略错误检查。为了解 决这些问题, 我们采用了一种基于错误处理 包装函数 ( er ro r- ha ndling wra pper ) 的方法, 这是由 W. Richard Steve ns 在他的网络编程教材 [ 11 0] 中最先提出的。\n其思想是 ,给 定某个基本的 系统级函数 f oo , 我们定义一个有相同参数、只不过开头字母大写了的包装函数 Foo。包装函数调用基本函数并检查错误。如果包装函数发现了错误,那么它就打印一条信息并终止进程。否则,它返回到调用者。注意,如果没有错误, 包装函数的行为与基本函数完全一样。换句话说,如果程序使用包装函数运行正确,那么 我们把每个 包装函数的第一个字母小 写并重新编译,也 能正确运行。 # 包装函数被封装在一个源文件( c s a p p . c ) 中, 这个文件被编译 和链接到每个程序中。一个独立的 头文件Cc s a p p . h ) 中包含这些包装函数的 函数原型。\n本附录给出 了一个关于 U nix 系统中不同种类的错误处理的教程, 还给出 了不同风格的错误处 理包装函数的示例。c s a p p . h 和 c s a pp . c 文件可以从 CS : AP P 网站上获得。\n.A. 1 Unix 系统中的错误处理\n本书中我们遇到的系统 级函数调用使用三种不同风格的返回错误: U n ix 风格的、 # Posix 风格的和 G Ai 风格的。\nUn ix 风格的错误处理\n像 f o r k 和 wa i t 这样 U nix 早期开发出来的函数(以及一些较老的 Pos ix 函数)的函数返回值既 包括错误代码,也 包括有用的结果。例如, 当 U nix 风格的 wa i t 函数遇到一个错误(例如没有子进程要 回收), 它就返回一1, 并将全局变量 e rr n o 设置为指明错误原因的错误代码。如果 wa i t 成功完成, 那么它就返回有用的结果,也 就是回收的子进程的P ID。U nix 风格的错误处理代码通常具有以 下形式:\n1 if ((pid = -wait (NULL)) \u0026lt; 0) { 2 fprintf(stderr, 节 ait error: %s\\n\u0026quot;, strerror(errno)); exit(O); str err or 函数返回某个 err no 值的文本描述。\nPos ix 风格的错误处理\n许多较新的 Posix 函数, 例如 P t h read 函数,只 用 返回值来表明成功( 0 ) 或者失败(非\n。任何有用的结果都返回在通过引用传递进来的函数参数中。我们称这种方法为 P osix # 730 附录 A 错 误 处 理\n风格的错误处理。例如, P os ix 风格的 p t hr e a d _ cr e a t e 函数用它的返回值来表明成功或者失败, 而通过引 用将新创建的线程的 ID ( 有用的结果)返回放在它的第一个参数中。P a s­\nix 风格的错误处理代码通常具有以下形式: # if ((retcode = pthread_create(\u0026amp;tid, NULL, thread, NULL)) != 0) {\n2 fprintf(stderr, \u0026ldquo;pthread_create error: %s\\n\u0026rdquo;, strerror(retcode));\nexit(O);\n4 }\nS七r er r or 函数返回r e t c o d e 某个值对应的 文本描述。\nGAi 风格的错误处理\ng e t a d d r i n fo ( G A D 和 g e t n a me i n f o 函数成功时返回零,失 败时返回非零值。G A I\n错误处理代码通常具有以下形式: # if ((retcode = getaddrinfo(host, service, \u0026amp;hints, \u0026amp;result)) != 0) {\n2 fprintf(stderr, \u0026ldquo;getaddrinfo error: %s\\n\u0026rdquo;, gai_strerror(retcode));\n3 exit(O);\n4 }\ngai _s tr err or 函数返回r e t c o d e 某个值对应的文本描述。\n错误报告函数小结 # 贯穿本书,我们使用下列错误报告函数来包容不同的错误处理风格:\n#include \u0026ldquo;csapp.h\u0026rdquo;\nvoid unix_error(char•msg);\nvoid posix_error(int code, char•msg); void gai_error(int code, char•msg); void app_error(char•msg);\n返回: 无。\n正如它们的名字表明的那样, u n i x _ er r or 、 p o s i x _ er r or 和 g a i _ er r or 函数报告U n ix 风格的错误、P osix 风格的错误和 G A I 风格的错误,然后 终止。包括 a p p _ e r r o r 函数是为了方便 报告应用错误。它只是简单地打印它的输入, 然后终止。图 A-1 展示了这些错误报告函数的代码。\ncodelsr 吹 sapp.c\nvoid unix_error(char *msg) I* Unix-style error *I\n2 {\n3 fprintf(stderr, \u0026ldquo;%s: %s\\n\u0026rdquo;, msg, strerror(errno));\n4 exit(O);\n5 }\n6\n7 void posix_error(int code, char *msg) I* Posix-style error *I\n8 {\n9 fprintf(stderr, \u0026ldquo;%s: %s\\n\u0026rdquo;, msg, strerror(code));\n10 exit(O);\n11 }\n12\n13 void gai_error(int code, char *msg) I* Getaddrinfo-style error *I\n图A-1 错误报告函数\n附录 A 错 误 处 理 731 # 14 { 15 fprintf(stderr, \u0026ldquo;%s: %s\\n\u0026rdquo;, msg, gai_strerror(code)); 16 exit(O); 17 } 18 19 void app_error(char *msg) I* App 辽 ca t i on error *I 20 { 21 fprintf(stderr, 欢 s\\n\u0026quot;, msg); 22 exit(O); 23 } code/srdcsapp.c 图 A-1 C 续 ) 2 错误处理包装函数\n下面是一些不同错误处理包装函数的示例:\nU nix 凤格的错误处理 包装函数。图 A-2 展示了 U nix 风格的 wa i t 函数的包装函数。如果 wa i t 返回一个错误, 包装函数打印一条消息, 然后退出。否则,它 向调用者 返回一个 P ID 。图 A-3 展示了 U nix 风格的 K过 1 函数的包装函数。注意, 这个函数和 wa i t 不同,成 功时返回 V O 过。 pid_t Wait(int *status)\n2 {\n3 pid_t pid;\n4\nif ((pid = wait(status)) \u0026lt; 0)\n6 un1x_error(\u0026ldquo;Wait error\u0026rdquo;);\n7 return pid;\n8 }\ncodelsrdcsapp.c\ncode/srdcsapp.c\n图 A-2 Unix 风格的 wa i t 函数的包装函数\nvoid Kill(pid_t pid, int signum)\n2 {\n3 int re;\n4\n5 if ((re = kill(pid, signum)) \u0026lt; 0)\n6 unix_error(\u0026ldquo;Kill error\u0026rdquo;);\n7 }\ncode/srdcsapp.c\ncoder/s sapp.c\n图A-3 Unix 风格的 ki ll 函数的包装 函数\nP os ix 风格的错误处理 包装函数。图 A-4 展示了 P o si x 风格的 p t h r e a d _ d e t a c h 函数的包装函数。同大多数 P os ix 风格的函数一样,它 的 错误返回码中不会包含有用的结果,所 以成功时 , 包装函数返回 V O 过。 732 附录 A 错 误 处 理\nvoid Pthread_detach(pthread_t tid) {\n2 int re·\n3\n4 if ((re = pthread_detach(tid)) != 0)\n5 posix_error(rc, \u0026ldquo;Pthread_detach error\u0026rdquo;);\n6 }\ncode/src/csapp.c\ncode/srdcsapp.c\n图 A-4 Posix 风格的 pt hr ead_de t ach 函数的包装函数\nGAI 风格的错 误 处理 包装 函 数 。 图 A-5 展示了 GAI 风 格 的 g e t a d dr i n f o 函数 的 包装函数。 code/srdcsapp.c\nvoid Getaddrinfo(const char *node, const char *service,\n2 const struct addrinfo *hints, struct addrinfo **res)\n3 {\n4 int rc;\n5\n6 if ((re = getaddrinfo (node, service, hints, res)) != 0)\n7 gai_error(rc, \u0026ldquo;Getaddrinfo error\u0026rdquo;);\n8 }\ncode/sr吹 sapp.c\n图 A-5 GAI 风格的 ge t addr i nf o 函数的包装函数\n参考文献 # [1] Advanced Micro Devices, Inc. Software Proceedings of the 4th Symposium on Operating Optimization Guide for AMD64 Processors, Systems Design and Implementation (OSDI) 2005. Publication Number 25112. pages 31\u0026ndash;44. Usenix, October 2000.\n[2] Advanced Micro Devices, Inc. AMD64 [13] R. E. Bryant. Term-level verification of a Architecture Programmer \u0026rsquo;s Manual, Volume pipelined CISC microprocessor. Technical 1: Application Programming, 2013. Publication Report CMU-CS-05-195, Carnegie Mellon\nNumber 24592. University, School of Computer Science, 2005.\n[3] Advanced Micro Devices, Inc. AMD64 [14] R. E. Bryant and D.R. O\u0026rsquo;Hallaron. Introducing Architecture P rogrammer\u0026rsquo;s Manual, Volume computer systems from a programmer\u0026rsquo;s\n3: General-Purpose and System Instructions, perspective. In Proceedings of the Technical 2013. Publication Number 24594. Symposium on Computer Science Education\n[4] Advanced Micro Devices, Inc. AMD64 (S/GCSE), pages 90-94. ACM, February 2001. Architecture Programmer\u0026rsquo;s Manual, Volume [15] D. Butenhof. Programming with Posix Threads 4:128-Bit and 256-Bit Media Instructions, 2013. Addison-Wesley, 1997.\nPublication Number 26568. [16] S. Carson and P. Reynolds. The geometry of\n[5] K. Arnold, J. G osling, and D. Holmes. The semaphore programs. ACM Transactions on Java Programming Language, Fourth Edition. Programming Languages and Systems9(l):25- Prentice Hall, 2005. 53, 1987.\n[6] T. Berners-Lee, R. Fielding, and H. Frystyk. [17] J. B. Carter, W. C. Hsieh, L. B. Stoller, M. R. Hypertext transfer protocol - HTIP/1.0. RFC Swanson, L. Zhang, E. L. Brunvand, A. Davis, 1945, 1996. C.-C. Kuo, R. Kuramkote, M. A. Parker,\n[7] A. Birrell. An introduction to programming L. Schaelicke, and T. Tateyama. Impulse: with threads. Technical Report 35, Digital Building a smarter memory controller. In Systems Research Center, 1989. Proceedings of the5th International Symposium\non High Performance Computer Architecture\n[8] A. Birrell, M. Isard, C. Thacker, and T. Wobber. (HPCA), pages 70-79. ACM, January 1999.\nA design for high-performance flash disks. [18] K. Chang, D. Lee, Z. Chishti, A. Alameldeen,\nSIGOPS Operating Systems Review 41(2):88- 93, 2007.\nC. Wilkerson, Y. Kim, and 0. Mutlu. Improving DRAM performance by parallelizing refreshes\n[9] G. E. Blelloch, J. T. Fineman, P. B. Gibbons, with accesses. In Proceedings of the 20th\nand H. V. Simhadri. Scheduling irregular International Symposium on High-Performance parallel computations on hierarchical caches. Computer Architecture (HP CA). ACM,\nIn Proceedings of the 23rd Symposium on February 2014.\nParallelism in Algorithms and Architectures [19] S. Chellappa, F. Franchetti, and M. Pilschel. (SPAA), pages 355-366. ACM, June 2011. How to write fast numerical code: A small in-\n[10] S. Borkar. Thousand core chips: A technology troduction . In Generative and Transformational perspective. In Proceedings of the 44th Design Techniques in Software Engineering II, volume Automation Conference, pages 746\u0026mdash;749. AC M, 5235 of Lecture Notes in Computer Science, 2007. pages 196-259. Springer-Verlag, 2008.\n[11] D. Bovet and M. Cesati. Understanding the [20] P. Chen, E. Lee, G. Gibson, R. Katz, and Linux Kernel, Third Edition. O\u0026rsquo;Reilly Media, D. Patterson. RAID: High-performance, Inc., 2005. reliable secondary storage. ACM Computing\nSurveys 26(2):145-185, June 1994.\n[12) A. Demke Brown and T. Mowry. Taming the\nmemory hogs: Using compiler-inserted releases t21] S. Chen, P. Gibbons, and T. Mowry. Improving to manage physical memory intelligently. In index performance through prefetching. In\n734 参考文献\nProceedings of the 2001 ACM SIGMOD ACM, May 1999.\nInternational Conference on Management of [33] M. Dowson. The Ariane 5 software failure. Data, pages 235-246. ACM, May 2001. SIGSOFTSoftware Engineering Notes 22(2):84,\n[22) T. Chilimbi, M. Hill, and J. Larus. Cache- 1997.\nconscious structure layout. In P roceedings of [34] U. D re pper. User-level IPv6 programming the 1999 ACM Conference onP rogramming introduction. Available at http://www.akkadia Language Design and Imp lementation (P LD /), .or g/drepper/userap曰pv6.html, 2008.\npages 1- 12. ACM, May 1999\n[23] E. Co ffman, M. Elphick, and A. Shoshani. System deadlocks. ACM Computing Surveys 3(2):67-78, June 1971.\n[35) M. W. Eichen and J. A. Rochlis. With micro- scope and tweezers: An analysis of the In ternet viru s of November , 1988. In P roceedings of the IEEE Symposium on Research in Security and\n(24] D. Cohen. On holy wars and a plea for peace. Priva cy, pages 326-343. IEEE, 1989.\nIEEE Computer 14(10):48- 54, October 1981. [36] ELF-64 Object File Format, Version1.5 Draft 2, [25) P. J. Courtois, F. Heymans , and D. L. Parnas. 1998. Available at http://www.uclibc.org/docs/\nConcurrent control with \u0026quot; readers \u0026quot; and elf-64-gen.pdf.\n\u0026quot; wn ters.\u0026quot; Communications of the ACM [37] R. Fielding , J. Ge ttys, J. Mogul, H. Frystyk, 14(10):667-668, 1971. L. Masinter, P. Leach , and T. Berners-Lee.\nC. Cowan , P. Wagle, C. Pu, S. Beattie, and Hypert ext transfer protocol - HIT P/1.1. RFC\nJ. Walpole. Buffer overflows: A ttack s and 2616, 1999.\ndefenses for the vulnerability of the decade. In [38] M. Frigo, C. E. Leiserson , H. Pro kop, and\nDARPA Information Survivability Conference S. Ramachandran. Cache-oblivious algorithms. and Expo (DISCEX), volum e 2, pages 119-129, In P roceedings of the 40th IEEE Symposium\nMarch 2000. on Foundations of Computer Science (FOCS),\nJ. H. Crawford . The i486 CPU: Executing pages 285-297. IEEE, August 1999.\ninstructions in one clock cycle. IEEE Micro [39] M. Frigo and V. Strumpen. The cache complex- 10(1):27- 36, February 1990. ity of multithreaded cache oblivious algorithms.\nV. Cuppu, B. Jacob, B. Davis, and T. Mudge. In Proceedings of the18th Symposium on Para[-\nA performance comparison of cont empo rary le/ism in Algorithms and Ar chitectures (SPAA), DRAM architectures. In Proceedings of the pages 271- 280. ACM, 2006.\n26th International Symposium on Computer [40] G. Gibson , D. Nagle, K. Amiri, J. Butler, Architecture (!SCA), pages 222- 233, ACM, F. Ch ang, H. Go bioff, C. Hardin , E. 凡edel,\n1999 . D. Rochb erg, and J. Zelenka. A cost-effective ,\n(29] B. Davis, B. Jaco b, and T. Mudge. The new high-bandwidth storage architecture. In DRAM interfaces: SDRA M, RDRAM, and Proceedings of the 8th International Conference variants. In Proceedings of the 3rd International on Architectural Support for Programming\nSymposium on High Performance Computing Lang uages and Operating Systems (ASPLOS), (ISHPC), volume 1940 of Lectur e Noces m pages 92- 103. ACM, October 1998.\nComputer Science, pages 26-31. Springer- [41] G. Gibson and R. Van Meter. Network attach ed Verlag, October 2000. storage architect ure. Communications of the\nE . Demaine. Cache-oblivious algorithms and ACM 43(11):37-45, November 2000.\ndata structures. In Lecture Notes from the EEF [42] Google . 1Pv6 Adoption. Available at http://\nSummer School on Massive Data Sets. BRICS ,\nUniversity of Aarhus , Denmark , 2002.\nW 吓 .google.com/intl/en/ipv6/statistics.html.\n[43) J. Gustafson. Reevaluating Amdahl\u0026rsquo;s law.\nE . W. Dijkstra. Cooperating sequential Communications of the ACM 31(5):532-533, processes. Technical Report EWD-123, August 1988.\nTechnological University, Eindhoven, the\nNetherlands, 1965.\n(44] L. Gwennap. New algorithm improves branch\nprediction. Microprocessor Report 9(4), March\nC. Ding and K. Kennedy. Improving cache 1995. performance of dynamic applications through data and computation reorganizations at run time. In Proceedings of the 1999 ACM\nConference on Programming Language Design and Implementation (PLDI ), pages 229-241.\n[45] S. P. Harbison and G. L. Steele, Jr. C, A\nReference Manual, Fifth Edition. Prentice Hall, 2002.\n[46) J. L. Hennessy and D. A. Patterson. Computer\n参考文献 735\nArchitecture: A Quantitative Approach, Fifth [58] R. Katz and G. Borriello. Contemporary Logic Edition. Morgan Kaufmann , 2011. Design, Second Edttion. Prentice Hall, 2005.\nM. Herlihy and N. Shavit. The Art of Multi- [59] B. W. Kernighan and R. Pike. The Practice of processor Programming. Morgan Kaufmann, Programming. Addison-Wesley, 1999.\n2008. [60] B. Kernighan and D. Ritchie. The C Program-\nC. A. R. Hoare. Monitors: An operating system ming Language, First Edition. Prentice Hall, structuring concept. Communications of the 1978.\nACM 17(10):549-557, October 1974. [61] B. Kernighan and D. Ritchie. The C Program-\nIntel Corporation. Intel 64 and IA-32 Ar- ming Language, Second Edi tion. Prentice Hall, chitectures Optimization Reference Manual . 1988.\nAvailable at http: //www.inte l.com/content / [62] Michael Kerrisk. The Linux Programming\nW 吓 /us/en/processors/architectures-so ftware- Interface. No Starch Press, 2010.\ndeveloper-manuals.html.\n[63] T. Kilbu rn, B. Edwards, M. Lanigan, and\nIntel Corporation. Intel 64 and IA-32 Ar- F. Sumner. One-level storage system. IRE\nchitectures Software Developer\u0026rsquo;s Manual, Transactions on Electronic Computers EC- Volume 1: Basic Architecture. Available at 11:223- 235, April 1962.\nhttp: // www.intel.com/content/www/us/en/\nprocessors/architectures-software-developer- [64] D. Knuth. The Art of Computer Programming, manuals.html. Volume 1: Fundamental Algorithms, Third\nIntel Corporation. Intel 64 and IA-32 Ar- Edition. Addison-Wesley, 1997.\nchitectures Software Developer \u0026rsquo;s Manual, [65] J. Kurose and K. Ross. Computer Networking: A Volume 2: Instruction Set Reference. Available Top-Down App roach, Sixth Edition. Addison- at http://www.intel.com/content/www/us/en/ Wesley, 2012.\nprocessors/architectures-software-developer- [66] M. Lam, E. Rothberg, and M. Wolf. The manuals.html. cache performance and optimizations of\n[52) Intel Corporation. Intel 64 and IA-32 Architec- blocked algorithms. In Proceedings of the tures Software Develop er \u0026rsquo;s Manual, Volume 3a 4th International Conference on Architectural System Programming Guide, Part 1. Available Support for Programming Languages and\nat http :/ /www.intel.com/content/ www/us/en/ Operating Systems (ASPLOS), pages 63-74. processors/architectures-software-developer- ACM, April 1991.\nmanuals.html. [67] D. Lea. A memory allocator. Available at\n[53] Intel Corporation. Intel Solid-State Drive 730 http://gee.cs.oswego.edu/dl/html/malloc.html, Series: Product Specification. Available at 1996.\nhtt p://www.inte l.com/content/www/us/en/solid- [68] C. E. Leiserson and J. B. Saxe. Retiming state-drives/ssd-730-series-spec.html. synchronous circuitry. Algorithmica 6(1-6),\n[54) Intel Corporation. Tool Interface Standards June 1991.\nPortable Formats Specification, Version 1.1, [69] J. R. Levine. Linkers and Loaders. Morgan 1993. Order number 241597. Kaufmann , 1999.\n(55] F. Jo nes, B. Prince , R. Norwood, J. Hartigan, [70] David Levinthal. Performance Analysis Guide\nW. Vogley, C. Hart, and D. Bondurant. for Intel Core i7 Processor and Intel Xeon Memory a new era of fast dynamic RAMs 5500 Processors. Available at https://softwa re (for video applications). IEEE Spectrum, pages .intel.com/sites/products/collatera l/hpc/vtune/ 43\u0026ndash;45, October 1992. performance_analysis_guide.pdf.\nR. Jones and R. Lins. Garbage Collection: [71] C. Lin and L. Snyder. Principles of Parallel\nAlgorithms for Automatic Dynanuc Memory Management. Wiley, 1996.\nProgramming. Addison Wesley, 2008.\nM. Kaashoek , D. Engler, G. Ganger , H. Briceo, [72] Y. Lin and D. Padua. Compiler analysis of R. Hunt, D. Maziers, T. Pinckney, R. Gr皿m,\nJ. Jannotti , and K. MacKenzie. Application performance and flexibility on E xokernel systems. In Proceedings of the 16th ACM\nirregular memory accesses. In Proceedings of the 2000 ACM Conference on Programming Language Design and Implementation (PLDJ), pages 157- 168. ACM, June 2000.\nSymposium on Operating System Principles (73] J. L. Lions. Ariane 5 Flight 501failure. Technical\n(SOSP), pages 52-65. ACM, October 1997. Re port, European Space Agency, July 1996.\n736 参考文献\nS. Macguire . Writing Solid Code. Microsoft (87) W. Pugh. The Omega test: A fast and practical Press, 1993. integer programming algorithm for depen- S. A. Mahlke, W. Y. Chen, J. C. Gyllenhal, and dence analysis. Communications of the ACM\nW.W. Hwu. Compiler code transformations for 35(8):102-114, August 1992.\nsuperscalar-based high-performance systems. [88] W. Pugh. Fixing the Java memory model. In In Proceedings of the 1992 ACM/IEEE Proceedings of the ACM Conference on Java Conference on Supercomputing, pages 808-817 Grande, pages 89-98. ACM, June 1999.\nACM, 1992.\n[89] J. Rabaey, A. Chandrakasan, and B. Nikolic.\nE. Marshall. Fatal error: How Patriot over- Digital Integrated Circuits: A Design Perspec- looked a Scud. Science, page 1347, March 13, tive, Second Edition. Prentice Hall, 2003. 1992.\nM. Matz, J. Hubicka, A. Jaeger, and M. Mitchell. [90] J. Reinders. Intel Threading Building Blocks.\nSystem V application binary interface AMD64 architecture processor supplement. Technical [91] D. Ritchie . The evolution of the Unix time- Report, x86-64.org, 2013. Available at http:// sharing system. AT\u0026amp;T Bell Laboratories www.x86-64.org/documenta tion_folder/abi-0 Technical Journal 63(6 Part 2):1577-1593, .99.pdf. October 1984. J. Morris, M. Satyanarayanan, M. Conner, [92] D. Ritchie . The development of the C language.\nJ. Howard, D. Rosenthal, and F.Smith. Andrew: In Proceedings of the 2nd ACM SIGPLAN A distributed personal computing environment. Conference on History of Programming Communications of the ACM, pages 184-201, Languages, pages 201-208. ACM, April 1993. March 1986.\nT. Mowry, M. Lam, and A. Gupta. Design [93] D. Ritchie and K. Thompson. The Unix time- and evaluation of a compiler algorithm for prefetching. In Proceedings of the 5th\nsharing system. Communications of the ACM 17(7):365-367, July 1974.\nInternational Conference on Architectural [94] M. Satyana rayanan , J. Kistler, P. Kumar, Support for Programming Languages and M. Okasaki, E. Siegel, and D. Steere. Coda: Operating Systems (ASP L OS), pages 62-73 A highly available file system for a distributed ACM, October 1992. workstation environment. IEEE Transactions\nS. S. Muchnick. Advanced Compiler Design and on Computers 39(4):447-459, April 1990.\nImplementation. Morgan Kaufmann, 1997. (95) J. Schindler and G. Ganger. Automated disk\nS. Nath and P. Gibbons. Online maintenance of drive characterization. Technical Report CMU- very large random samples on flash sto rage. In CS-99-176, School of Computer Science, Proceedings of VLDB, pages 970-983. VLDB Carnegie Mellon University, 1999.\nEndowment, August 2008. [96] F. B. Schneider and K. P Birman. The\nM. Overton . Numerical Computing with IEEE monoculture risk put into context. IEEE Floating Point A rithmetic. SIAM, 2001. Security and Privacy 7(1):14-17, January 2009.\nD. Patterson , G. Gibson, and R. Katz. A case for [97] R. C. Seacord. Secure Coding in C and C++, redundant arrays of inexpensive disks (RAID). Second Edition. Addison-Wesley, 2013.\nIn Proceedings of the 1998 ACM SIG MOD\nInternational Conference on Management of [98] R. Sedgewick and K. Wayne. Algorithms, Fourth Data, pages 109-116. ACM, June 1988. Edition. Addison-Wesley, 2011.\nL. Peterson and B. Davie . Computer Networks: (99] H. Shacham, M. Page, B. Pfaff, E.-J. Goh, A Systems Approach, Fifth Edition. Morgan N. Modadugu, and D. Boneh. On the effec -\nKaufma nn, 2011. tiveness of address-space randomization. In\nJ. Pincus and B. Baker. Beyond stack smashing: Proceedings of the 11th ACM Conference on Recent advances in exploiting buffer overruns. Computer and CommunicationsSecurity (CCS), IEEE Security and Privacy 2(4):20-27, 2004. pages 298-307. ACM, 2004.\nS. Przybylski. Cache and Memory Hierarchy [100) J.P. Shen and M. Lipasti. Modern Processor De- Design: A Performance-Directed Approach. sign: Fundamentals of Superscalar Processors. Morgan Kaufmann, 1990. McGraw Hill, 2005.\n参考文献 737\n[101] B. Shriver and B. Smith. The Anatomy of a High-Performance Microprocessor:A Systems Perspective. IEEE Computer Society, 1998.\nArchitecture (HPCA), pages 168-179. IEEE, February 1997.\nE. H. Spafford. The Internet worm program: An analysis. Technical Report CSD-TR-823, Department of Computer Science, Purdue University, 1988.\nW. Stallings. Operating Systems: Internals and Design Principles, Eighth Edition. Prentice Hall, 2014.\nW. R. Stevens . TCP/IP Illustrated, Volume 3: TCP for Transactions, H TTP, NNTP and the Unix Domain Protocols. Addison-Wesley, 1996.\nW. R. Stevens . Unix Network Programming: Interprocess Communications, Second Edition, volume 2. Prentice Hall, 1998.\n[109) W.R. Stevens and K. R. Fall. TCP/IP Illustrated, Volume 1: The P rotocols, Second Edition.\nAddison-Wesley, 2011.\nW. R. Stevens, B. Fenner, and A. M. Rudoff. Unix Network Programming: The Sockets Networking AP/ , Third Edition, volume 1. Prentice Hall, 2003.\nW. R. Stevens and S. A. Rago. Advanced Programming in the Unix Environment , Third Edition. Addison-Wesley, 2013.\nT. Stricker and T. Gross. Global address space, non-uniform bandwidth: A memory system performance characterization of parallel systems. In Proceedings of the 3rd International Symposium on High Performance Computer\nJ. F. Wakerly. Digital Design Principles and P ractices,Fourth Edition. Prentice Hall, 2005.\nM. V. Wilkes. Slave memories and dynamic storage allocation. IEEE Transactions on Electronic Computers, EC-14(2), April 1965.\nP. Wilson, M. Johnstone, M. Neely, and D. Boles. Dynamic storage allocation: A survey and critical review. In International Workshop on Memory Management , volume 986 of Lecture Notes in Computer Science, pages 1- 116. Springer-Verlag, 1995.\nM. Wolf and M. Lam. A data locality algorithm. In P roceedings of the 1991 ACM Conference on Programming Language Design and Implementation (PLDI), pages 30-44, June 1991.\nG. R. Wright and W. R. Stevens . TCP/IP Illustrated, Volume 2: The Implementation . Addison-Wesley, 1995.\nJ. Wylie, M. Bigrigg, J. Strunk, G. Ganger,\nH. Kiliccote, and P. Khosla. Survivable information storage systems. IEEE Computer 33:61-6 8, August 2000.\nT.-Y. Yeh and Y. N. Patt. Alternative implemen­ tation of two-level adaptive branch prediction. In Proceedings of the19th Annual International Symposium on Computer Architecture (!SCA), pages 451-461. ACM, 1998.\n推 荐 阅 读 - # n-\n严气 咱世rfflll\n.\n\u0026ldquo;-\u0026rsquo; cOMPUTER \u0026rsquo; · ORGANIZATIO '\nAND DESIG\n. ,,, I \u0026rsquo;''\n\u0026lsquo;I,, .·\n,, ..\n1,,\u0026rsquo;\n,, If/•;.\n计算机组成与设计: 硬件/软件接(口原书第 5 版) 计算机组咸与设计: 硬件/软件接(口英文饭 ·第5版·亚洲饭) # 作者 戴维A. 帕特森等\nISBN, 978- 7- 111- 50482- 5 定 价 99.00 元\n作 者 David A. Patterson\nISBN: 978-7-111-45316-1 定 价 139.00 元\n# 计算机体系结构: 量化研究方法(英文版,第5版) 计算机系统:系统架构与操作系统的高度纂成\n- . . . ..\n作者JohnL.Henne等ssy\n作者 阿麦肯尚尔·拉姆阿堪德兰 等\nISBN: 978- 7- 111- 36458- 0 定 价 138.00元 ISBN 978- 7- 111- 50636- 2 定 价 99.00 元\n如何使用本书\n从程序员的角度来学习计算机系统是如何工作的会非常有趣。最理想的学习方法是在真正的系统上解决具体的问题,或是编写和运行程序。这个主题观念贯穿本书始终。因此我们建议你用如下方式学习这本书:\n.学习一个新概念时,你应 该立刻做一做紧随其后的一个或多个练习题来检验你的理解。这些练习题的解答在每章的末尾。要先尝试自己来解答每个问题, 然后再查阅答案。\n每一章后都有一组难度不同的作业题,这些题目需要的时间从十几分钟到十几个小时,但建议你尝试完成这些作业题,完成之后你会发现对系统的理解更加深入。 本书中有丰富的代码示例, 鼓励你在系统上运行这些示例的源代码。 我们邀请国内名师录制了本书的导读,从中你可以了解各章的重点内容和知识关联,形成关千计算机系统的知识架构。 向老师或他人请教和交流是很好的学习方式 。我们将不定期组织线上线下的学习活动, 你可以登录本书网络社区及时了解活动的信息,井与学习本书的其他读者交流、讨论。 为帮助读者更好地学习本书,我们开设了本书的网络社区,请扫描如下二维码或登录 http://www. hzmedia.com .cn/e/jsj 加入社区,获 得本书相关学习资源,了 解活动信息。\n深入理解计算机系统 "},{"id":450,"href":"/zh/docs/technology/Interview/cs-basics/network/computer-network-xiexiren-summary/","title":"《计算机网络》(谢希仁)内容总结","section":"Network","content":"本文是我在大二学习计算机网络期间整理, 大部分内容都来自于谢希仁老师的 《计算机网络》第七版这本书。为了内容更容易理解,我对之前的整理进行了一波重构,并配上了一些相关的示意图便于理解。\n相关问题: 如何评价谢希仁的计算机网络(第七版)? - 知乎 。\n1. 计算机网络概述 # 1.1. 基本术语 # 结点 (node):网络中的结点可以是计算机,集线器,交换机或路由器等。\n链路(link ) : 从一个结点到另一个结点的一段物理线路。中间没有任何其他交点。\n主机(host):连接在因特网上的计算机。\nISP(Internet Service Provider):因特网服务提供者(提供商)。\nIXP(Internet eXchange Point):互联网交换点 IXP 的主要作用就是允许两个网络直接相连并交换分组,而不需要再通过第三个网络来转发分组。\nhttps://labs.ripe.net/Members/fergalc/ixp-traffic-during-stratos-skydive\nRFC(Request For Comments):意思是“请求评议”,包含了关于 Internet 几乎所有的重要的文字资料。\n广域网 WAN(Wide Area Network):任务是通过长距离运送主机发送的数据。\n城域网 MAN(Metropolitan Area Network):用来将多个局域网进行互连。\n局域网 LAN(Local Area Network):学校或企业大多拥有多个互连的局域网。\nhttp://conexionesmanwman.blogspot.com/\n个人区域网 PAN(Personal Area Network):在个人工作的地方把属于个人使用的电子设备用无线技术连接起来的网络 。\nhttps://www.itrelease.com/2018/07/advantages-and-disadvantages-of-personal-area-network-pan/\n分组(packet ):因特网中传送的数据单元。由首部 header 和数据段组成。分组又称为包,首部可称为包头。\n存储转发(store and forward ):路由器收到一个分组,先检查分组是否正确,并过滤掉冲突包错误。确定包正确后,取出目的地址,通过查找表找到想要发送的输出端口地址,然后将该包发送出去。\n带宽(bandwidth):在计算机网络中,表示在单位时间内从网络中的某一点到另一点所能通过的“最高数据率”。常用来表示网络的通信线路所能传送数据的能力。单位是“比特每秒”,记为 b/s。\n吞吐量(throughput ):表示在单位时间内通过某个网络(或信道、接口)的数据量。吞吐量更经常地用于对现实世界中的网络的一种测量,以便知道实际上到底有多少数据量能够通过网络。吞吐量受网络的带宽或网络的额定速率的限制。\n1.2. 重要知识点总结 # 计算机网络(简称网络)把许多计算机连接在一起,而互联网把许多网络连接在一起,是网络的网络。 小写字母 i 开头的 internet(互联网)是通用名词,它泛指由多个计算机网络相互连接而成的网络。在这些网络之间的通信协议(即通信规则)可以是任意的。大写字母 I 开头的 Internet(互联网)是专用名词,它指全球最大的,开放的,由众多网络相互连接而成的特定的互联网,并采用 TCP/IP 协议作为通信规则,其前身为 ARPANET。Internet 的推荐译名为因特网,现在一般流行称为互联网。 路由器是实现分组交换的关键构件,其任务是转发收到的分组,这是网络核心部分最重要的功能。分组交换采用存储转发技术,表示把一个报文(要发送的整块数据)分为几个分组后再进行传送。在发送报文之前,先把较长的报文划分成为一个个更小的等长数据段。在每个数据段的前面加上一些由必要的控制信息组成的首部后,就构成了一个分组。分组又称为包。分组是在互联网中传送的数据单元,正是由于分组的头部包含了诸如目的地址和源地址等重要控制信息,每一个分组才能在互联网中独立的选择传输路径,并正确地交付到分组传输的终点。 互联网按工作方式可划分为边缘部分和核心部分。主机在网络的边缘部分,其作用是进行信息处理。由大量网络和连接这些网络的路由器组成核心部分,其作用是提供连通性和交换。 计算机通信是计算机中进程(即运行着的程序)之间的通信。计算机网络采用的通信方式是客户-服务器方式(C/S 方式)和对等连接方式(P2P 方式)。 客户和服务器都是指通信中所涉及的应用进程。客户是服务请求方,服务器是服务提供方。 按照作用范围的不同,计算机网络分为广域网 WAN,城域网 MAN,局域网 LAN,个人区域网 PAN。 计算机网络最常用的性能指标是:速率,带宽,吞吐量,时延(发送时延,处理时延,排队时延),时延带宽积,往返时间和信道利用率。 网络协议即协议,是为进行网络中的数据交换而建立的规则。计算机网络的各层以及其协议集合,称为网络的体系结构。 五层体系结构由应用层,运输层,网络层(网际层),数据链路层,物理层组成。运输层最主要的协议是 TCP 和 UDP 协议,网络层最重要的协议是 IP 协议。 下面的内容会介绍计算机网络的五层体系结构:物理层+数据链路层+网络层(网际层)+运输层+应用层。\n2. 物理层(Physical Layer) # 2.1. 基本术语 # 数据(data):运送消息的实体。\n信号(signal):数据的电气的或电磁的表现。或者说信号是适合在传输介质上传输的对象。\n码元( code):在使用时间域(或简称为时域)的波形来表示数字信号时,代表不同离散数值的基本波形。\n单工(simplex ):只能有一个方向的通信而没有反方向的交互。\n半双工(half duplex ):通信的双方都可以发送信息,但不能双方同时发送(当然也就不能同时接收)。\n全双工(full duplex):通信的双方可以同时发送和接收信息。\n失真:失去真实性,主要是指接受到的信号和发送的信号不同,有磨损和衰减。影响失真程度的因素:1.码元传输速率 2.信号传输距离 3.噪声干扰 4.传输媒体质量\n奈氏准则:在任何信道中,码元的传输的效率是有上限的,传输速率超过此上限,就会出现严重的码间串扰问题,使接收端对码元的判决(即识别)成为不可能。\n香农定理:在带宽受限且有噪声的信道中,为了不产生误差,信息的数据传输速率有上限值。\n基带信号(baseband signal):来自信源的信号。指没有经过调制的数字信号或模拟信号。\n带通(频带)信号(bandpass signal):把基带信号经过载波调制后,把信号的频率范围搬移到较高的频段以便在信道中传输(即仅在一段频率范围内能够通过信道),这里调制过后的信号就是带通信号。\n调制(modulation ):对信号源的信息进行处理后加到载波信号上,使其变为适合在信道传输的形式的过程。\n信噪比(signal-to-noise ratio ):指信号的平均功率和噪声的平均功率之比,记为 S/N。信噪比(dB)=10*log10(S/N)。\n信道复用(channel multiplexing ):指多个用户共享同一个信道。(并不一定是同时)。\n比特率(bit rate ):单位时间(每秒)内传送的比特数。\n波特率(baud rate):单位时间载波调制状态改变的次数。针对数据信号对载波的调制速率。\n复用(multiplexing):共享信道的方法。\nADSL(Asymmetric Digital Subscriber Line ):非对称数字用户线。\n光纤同轴混合网(HFC 网):在目前覆盖范围很广的有线电视网的基础上开发的一种居民宽带接入网\n2.2. 重要知识点总结 # 物理层的主要任务就是确定与传输媒体接口有关的一些特性,如机械特性,电气特性,功能特性,过程特性。 一个数据通信系统可划分为三大部分,即源系统,传输系统,目的系统。源系统包括源点(或源站,信源)和发送器,目的系统包括接收器和终点。 通信的目的是传送消息。如话音,文字,图像等都是消息,数据是运送消息的实体。信号则是数据的电气或电磁的表现。 根据信号中代表消息的参数的取值方式不同,信号可分为模拟信号(或连续信号)和数字信号(或离散信号)。在使用时间域(简称时域)的波形表示数字信号时,代表不同离散数值的基本波形称为码元。 根据双方信息交互的方式,通信可划分为单向通信(或单工通信),双向交替通信(或半双工通信),双向同时通信(全双工通信)。 来自信源的信号称为基带信号。信号要在信道上传输就要经过调制。调制有基带调制和带通调制之分。最基本的带通调制方法有调幅,调频和调相。还有更复杂的调制方法,如正交振幅调制。 要提高数据在信道上的传递速率,可以使用更好的传输媒体,或使用先进的调制技术。但数据传输速率不可能任意被提高。 传输媒体可分为两大类,即导引型传输媒体(双绞线,同轴电缆,光纤)和非导引型传输媒体(无线,红外,大气激光)。 为了有效利用光纤资源,在光纤干线和用户之间广泛使用无源光网络 PON。无源光网络无需配备电源,其长期运营成本和管理成本都很低。最流行的无源光网络是以太网无源光网络 EPON 和吉比特无源光网络 GPON。 2.3. 补充 # 2.3.1. 物理层主要做啥? # 物理层主要做的事情就是 透明地传送比特流。也可以将物理层的主要任务描述为确定与传输媒体的接口的一些特性,即:机械特性(接口所用接线器的一些物理属性如形状和尺寸),电气特性(接口电缆的各条线上出现的电压的范围),功能特性(某条线上出现的某一电平的电压的意义),过程特性(对于不同功能的各种可能事件的出现顺序)。\n物理层考虑的是怎样才能在连接各种计算机的传输媒体上传输数据比特流,而不是指具体的传输媒体。 现有的计算机网络中的硬件设备和传输媒体的种类非常繁多,而且通信手段也有许多不同的方式。物理层的作用正是尽可能地屏蔽掉这些传输媒体和通信手段的差异,使物理层上面的数据链路层感觉不到这些差异,这样就可以使数据链路层只考虑完成本层的协议和服务,而不必考虑网络的具体传输媒体和通信手段是什么。\n2.3.2. 几种常用的信道复用技术 # 频分复用(FDM):所有用户在同样的时间占用不同的带宽资源。 时分复用(TDM):所有用户在不同的时间占用同样的频带宽度(分时不分频)。 统计时分复用 (Statistic TDM):改进的时分复用,能够明显提高信道的利用率。 码分复用(CDM):用户使用经过特殊挑选的不同码型,因此各用户之间不会造成干扰。这种系统发送的信号有很强的抗干扰能力,其频谱类似于白噪声,不易被敌人发现。 波分复用( WDM):波分复用就是光的频分复用。 2.3.3. 几种常用的宽带接入技术,主要是 ADSL 和 FTTx # 用户到互联网的宽带接入方法有非对称数字用户线 ADSL(用数字技术对现有的模拟电话线进行改造,而不需要重新布线。ADSL 的快速版本是甚高速数字用户线 VDSL。),光纤同轴混合网 HFC(是在目前覆盖范围很广的有线电视网的基础上开发的一种居民宽带接入网)和 FTTx(即光纤到······)。\n3. 数据链路层(Data Link Layer) # 3.1. 基本术语 # 链路(link):一个结点到相邻结点的一段物理链路。\n数据链路(data link):把实现控制数据运输的协议的硬件和软件加到链路上就构成了数据链路。\n循环冗余检验 CRC(Cyclic Redundancy Check):为了保证数据传输的可靠性,CRC 是数据链路层广泛使用的一种检错技术。\n帧(frame):一个数据链路层的传输单元,由一个数据链路层首部和其携带的封包所组成协议数据单元。\nMTU(Maximum Transfer Uint ):最大传送单元。帧的数据部分的的长度上限。\n误码率 BER(Bit Error Rate ):在一段时间内,传输错误的比特占所传输比特总数的比率。\nPPP(Point-to-Point Protocol ):点对点协议。即用户计算机和 ISP 进行通信时所使用的数据链路层协议。以下是 PPP 帧的示意图: MAC 地址(Media Access Control 或者 Medium Access Control):意译为媒体访问控制,或称为物理地址、硬件地址,用来定义网络设备的位置。在 OSI 模型中,第三层网络层负责 IP 地址,第二层数据链路层则负责 MAC 地址。因此一个主机会有一个 MAC 地址,而每个网络位置会有一个专属于它的 IP 地址 。地址是识别某个系统的重要标识符,“名字指出我们所要寻找的资源,地址指出资源所在的地方,路由告诉我们如何到达该处。”\n网桥(bridge):一种用于数据链路层实现中继,连接两个或多个局域网的网络互连设备。\n交换机(switch ):广义的来说,交换机指的是一种通信系统中完成信息交换的设备。这里工作在数据链路层的交换机指的是交换式集线器,其实质是一个多接口的网桥\n3.2. 重要知识点总结 # 链路是从一个结点到相邻结点的一段物理链路,数据链路则在链路的基础上增加了一些必要的硬件(如网络适配器)和软件(如协议的实现) 数据链路层使用的主要是点对点信道和广播信道两种。 数据链路层传输的协议数据单元是帧。数据链路层的三个基本问题是:封装成帧,透明传输和差错检测 循环冗余检验 CRC 是一种检错方法,而帧检验序列 FCS 是添加在数据后面的冗余码 点对点协议 PPP 是数据链路层使用最多的一种协议,它的特点是:简单,只检测差错而不去纠正差错,不使用序号,也不进行流量控制,可同时支持多种网络层协议 PPPoE 是为宽带上网的主机使用的链路层协议 局域网的优点是:具有广播功能,从一个站点可方便地访问全网;便于系统的扩展和逐渐演变;提高了系统的可靠性,可用性和生存性。 计算机与外接局域网通信需要通过通信适配器(或网络适配器),它又称为网络接口卡或网卡。计算器的硬件地址就在适配器的 ROM 中。 以太网采用的无连接的工作方式,对发送的数据帧不进行编号,也不要求对方发回确认。目的站收到有差错帧就把它丢掉,其他什么也不做 以太网采用的协议是具有冲突检测的载波监听多点接入 CSMA/CD。协议的特点是:发送前先监听,边发送边监听,一旦发现总线上出现了碰撞,就立即停止发送。然后按照退避算法等待一段随机时间后再次发送。 因此,每一个站点在自己发送数据之后的一小段时间内,存在着遭遇碰撞的可能性。以太网上的各站点平等地争用以太网信道 以太网的适配器具有过滤功能,它只接收单播帧,广播帧和多播帧。 使用集线器可以在物理层扩展以太网(扩展后的以太网仍然是一个网络) 3.3. 补充 # 数据链路层的点对点信道和广播信道的特点,以及这两种信道所使用的协议(PPP 协议以及 CSMA/CD 协议)的特点 数据链路层的三个基本问题:封装成帧,透明传输,差错检测 以太网的 MAC 层硬件地址 适配器,转发器,集线器,网桥,以太网交换机的作用以及适用场合 4. 网络层(Network Layer) # 4.1. 基本术语 # 虚电路(Virtual Circuit) : 在两个终端设备的逻辑或物理端口之间,通过建立的双向的透明传输通道。虚电路表示这只是一条逻辑上的连接,分组都沿着这条逻辑连接按照存储转发方式传送,而并不是真正建立了一条物理连接。 IP(Internet Protocol ) : 网际协议 IP 是 TCP/IP 体系中两个最主要的协议之一,是 TCP/IP 体系结构网际层的核心。配套的有 ARP,RARP,ICMP,IGMP。 ARP(Address Resolution Protocol) : 地址解析协议。地址解析协议 ARP 把 IP 地址解析为硬件地址。 ICMP(Internet Control Message Protocol ):网际控制报文协议 (ICMP 允许主机或路由器报告差错情况和提供有关异常情况的报告)。 子网掩码(subnet mask ):它是一种用来指明一个 IP 地址的哪些位标识的是主机所在的子网以及哪些位标识的是主机的位掩码。子网掩码不能单独存在,它必须结合 IP 地址一起使用。 CIDR( Classless Inter-Domain Routing ):无分类域间路由选择 (特点是消除了传统的 A 类、B 类和 C 类地址以及划分子网的概念,并使用各种长度的“网络前缀”(network-prefix)来代替分类地址中的网络号和子网号)。 默认路由(default route):当在路由表中查不到能到达目的地址的路由时,路由器选择的路由。默认路由还可以减小路由表所占用的空间和搜索路由表所用的时间。 路由选择算法(Virtual Circuit):路由选择协议的核心部分。因特网采用自适应的,分层次的路由选择协议。 4.2. 重要知识点总结 # TCP/IP 协议中的网络层向上只提供简单灵活的,无连接的,尽最大努力交付的数据报服务。网络层不提供服务质量的承诺,不保证分组交付的时限,所传送的分组可能出错、丢失、重复和失序。进程之间通信的可靠性由运输层负责 在互联网的交付有两种,一是在本网络直接交付不用经过路由器,另一种是和其他网络的间接交付,至少经过一个路由器,但最后一次一定是直接交付 分类的 IP 地址由网络号字段(指明网络)和主机号字段(指明主机)组成。网络号字段最前面的类别指明 IP 地址的类别。IP 地址是一种分等级的地址结构。IP 地址管理机构分配 IP 地址时只分配网络号,主机号由得到该网络号的单位自行分配。路由器根据目的主机所连接的网络号来转发分组。一个路由器至少连接到两个网络,所以一个路由器至少应当有两个不同的 IP 地址 IP 数据报分为首部和数据两部分。首部的前一部分是固定长度,共 20 字节,是所有 IP 数据包必须具有的(源地址,目的地址,总长度等重要地段都固定在首部)。一些长度可变的可选字段固定在首部的后面。IP 首部中的生存时间给出了 IP 数据报在互联网中所能经过的最大路由器数。可防止 IP 数据报在互联网中无限制的兜圈子。 地址解析协议 ARP 把 IP 地址解析为硬件地址。ARP 的高速缓存可以大大减少网络上的通信量。因为这样可以使主机下次再与同样地址的主机通信时,可以直接从高速缓存中找到所需要的硬件地址而不需要再去以广播方式发送 ARP 请求分组 无分类域间路由选择 CIDR 是解决目前 IP 地址紧缺的一个好办法。CIDR 记法在 IP 地址后面加上斜线“/”,然后写上前缀所占的位数。前缀(或网络前缀)用来指明网络,前缀后面的部分是后缀,用来指明主机。CIDR 把前缀都相同的连续的 IP 地址组成一个“CIDR 地址块”,IP 地址分配都以 CIDR 地址块为单位。 网际控制报文协议是 IP 层的协议。ICMP 报文作为 IP 数据报的数据,加上首部后组成 IP 数据报发送出去。使用 ICMP 数据报并不是为了实现可靠传输。ICMP 允许主机或路由器报告差错情况和提供有关异常情况的报告。ICMP 报文的种类有两种,即 ICMP 差错报告报文和 ICMP 询问报文。 要解决 IP 地址耗尽的问题,最根本的办法是采用具有更大地址空间的新版本 IP 协议-IPv6。 IPv6 所带来的变化有 ① 更大的地址空间(采用 128 位地址)② 灵活的首部格式 ③ 改进的选项 ④ 支持即插即用 ⑤ 支持资源的预分配 ⑥IPv6 的首部改为 8 字节对齐。 虚拟专用网络 VPN 利用公用的互联网作为本机构专用网之间的通信载体。VPN 内使用互联网的专用地址。一个 VPN 至少要有一个路由器具有合法的全球 IP 地址,这样才能和本系统的另一个 VPN 通过互联网进行通信。所有通过互联网传送的数据都需要加密。 MPLS 的特点是:① 支持面向连接的服务质量 ② 支持流量工程,平衡网络负载 ③ 有效的支持虚拟专用网 VPN。MPLS 在入口节点给每一个 IP 数据报打上固定长度的“标记”,然后根据标记在第二层(链路层)用硬件进行转发(在标记交换路由器中进行标记交换),因而转发速率大大加快。 5. 传输层(Transport Layer) # 5.1. 基本术语 # 进程(process):指计算机中正在运行的程序实体。\n应用进程互相通信:一台主机的进程和另一台主机中的一个进程交换数据的过程(另外注意通信真正的端点不是主机而是主机中的进程,也就是说端到端的通信是应用进程之间的通信)。\n传输层的复用与分用:复用指发送方不同的进程都可以通过同一个运输层协议传送数据。分用指接收方的运输层在剥去报文的首部后能把这些数据正确的交付到目的应用进程。\nTCP(Transmission Control Protocol):传输控制协议。\nUDP(User Datagram Protocol):用户数据报协议。\n端口(port):端口的目的是为了确认对方机器的哪个进程在与自己进行交互,比如 MSN 和 QQ 的端口不同,如果没有端口就可能出现 QQ 进程和 MSN 交互错误。端口又称协议端口号。\n停止等待协议(stop-and-wait):指发送方每发送完一个分组就停止发送,等待对方确认,在收到确认之后在发送下一个分组。\n流量控制 : 就是让发送方的发送速率不要太快,既要让接收方来得及接收,也不要使网络发生拥塞。\n拥塞控制:防止过多的数据注入到网络中,这样可以使网络中的路由器或链路不致过载。拥塞控制所要做的都有一个前提,就是网络能够承受现有的网络负荷。\n5.2. 重要知识点总结 # 运输层提供应用进程之间的逻辑通信,也就是说,运输层之间的通信并不是真正在两个运输层之间直接传输数据。运输层向应用层屏蔽了下面网络的细节(如网络拓补,所采用的路由选择协议等),它使应用进程之间看起来好像两个运输层实体之间有一条端到端的逻辑通信信道。 网络层为主机提供逻辑通信,而运输层为应用进程之间提供端到端的逻辑通信。 运输层的两个重要协议是用户数据报协议 UDP 和传输控制协议 TCP。按照 OSI 的术语,两个对等运输实体在通信时传送的数据单位叫做运输协议数据单元 TPDU(Transport Protocol Data Unit)。但在 TCP/IP 体系中,则根据所使用的协议是 TCP 或 UDP,分别称之为 TCP 报文段或 UDP 用户数据报。 UDP 在传送数据之前不需要先建立连接,远地主机在收到 UDP 报文后,不需要给出任何确认。虽然 UDP 不提供可靠交付,但在某些情况下 UDP 确是一种最有效的工作方式。 TCP 提供面向连接的服务。在传送数据之前必须先建立连接,数据传送结束后要释放连接。TCP 不提供广播或多播服务。由于 TCP 要提供可靠的,面向连接的传输服务,难以避免地增加了许多开销,如确认,流量控制,计时器以及连接管理等。这不仅使协议数据单元的首部增大很多,还要占用许多处理机资源。 硬件端口是不同硬件设备进行交互的接口,而软件端口是应用层各种协议进程与运输实体进行层间交互的一种地址。UDP 和 TCP 的首部格式中都有源端口和目的端口这两个重要字段。当运输层收到 IP 层交上来的运输层报文时,就能够根据其首部中的目的端口号把数据交付应用层的目的应用层。(两个进程之间进行通信不光要知道对方 IP 地址而且要知道对方的端口号(为了找到对方计算机中的应用进程)) 运输层用一个 16 位端口号标志一个端口。端口号只有本地意义,它只是为了标志计算机应用层中的各个进程在和运输层交互时的层间接口。在互联网的不同计算机中,相同的端口号是没有关联的。协议端口号简称端口。虽然通信的终点是应用进程,但只要把所发送的报文交到目的主机的某个合适端口,剩下的工作(最后交付目的进程)就由 TCP 和 UDP 来完成。 运输层的端口号分为服务器端使用的端口号(0˜1023 指派给熟知端口,1024˜49151 是登记端口号)和客户端暂时使用的端口号(49152˜65535) UDP 的主要特点是 ① 无连接 ② 尽最大努力交付 ③ 面向报文 ④ 无拥塞控制 ⑤ 支持一对一,一对多,多对一和多对多的交互通信 ⑥ 首部开销小(只有四个字段:源端口,目的端口,长度和检验和) TCP 的主要特点是 ① 面向连接 ② 每一条 TCP 连接只能是一对一的 ③ 提供可靠交付 ④ 提供全双工通信 ⑤ 面向字节流 TCP 用主机的 IP 地址加上主机上的端口号作为 TCP 连接的端点。这样的端点就叫做套接字(socket)或插口。套接字用(IP 地址:端口号)来表示。每一条 TCP 连接唯一地被通信两端的两个端点所确定。 停止等待协议是为了实现可靠传输的,它的基本原理就是每发完一个分组就停止发送,等待对方确认。在收到确认后再发下一个分组。 为了提高传输效率,发送方可以不使用低效率的停止等待协议,而是采用流水线传输。流水线传输就是发送方可连续发送多个分组,不必每发完一个分组就停下来等待对方确认。这样可使信道上一直有数据不间断的在传送。这种传输方式可以明显提高信道利用率。 停止等待协议中超时重传是指只要超过一段时间仍然没有收到确认,就重传前面发送过的分组(认为刚才发送过的分组丢失了)。因此每发送完一个分组需要设置一个超时计时器,其重传时间应比数据在分组传输的平均往返时间更长一些。这种自动重传方式常称为自动重传请求 ARQ。另外在停止等待协议中若收到重复分组,就丢弃该分组,但同时还要发送确认。连续 ARQ 协议可提高信道利用率。发送维持一个发送窗口,凡位于发送窗口内的分组可连续发送出去,而不需要等待对方确认。接收方一般采用累积确认,对按序到达的最后一个分组发送确认,表明到这个分组位置的所有分组都已经正确收到了。 TCP 报文段的前 20 个字节是固定的,其后有 40 字节长度的可选字段。如果加入可选字段后首部长度不是 4 的整数倍字节,需要在再在之后用 0 填充。因此,TCP 首部的长度取值为 20+4n 字节,最长为 60 字节。 TCP 使用滑动窗口机制。发送窗口里面的序号表示允许发送的序号。发送窗口后沿的后面部分表示已发送且已收到确认,而发送窗口前沿的前面部分表示不允许发送。发送窗口后沿的变化情况有两种可能,即不动(没有收到新的确认)和前移(收到了新的确认)。发送窗口的前沿通常是不断向前移动的。一般来说,我们总是希望数据传输更快一些。但如果发送方把数据发送的过快,接收方就可能来不及接收,这就会造成数据的丢失。所谓流量控制就是让发送方的发送速率不要太快,要让接收方来得及接收。 在某段时间,若对网络中某一资源的需求超过了该资源所能提供的可用部分,网络的性能就要变坏。这种情况就叫拥塞。拥塞控制就是为了防止过多的数据注入到网络中,这样就可以使网络中的路由器或链路不致过载。拥塞控制所要做的都有一个前提,就是网络能够承受现有的网络负荷。拥塞控制是一个全局性的过程,涉及到所有的主机,所有的路由器,以及与降低网络传输性能有关的所有因素。相反,流量控制往往是点对点通信量的控制,是个端到端的问题。流量控制所要做到的就是抑制发送端发送数据的速率,以便使接收端来得及接收。 为了进行拥塞控制,TCP 发送方要维持一个拥塞窗口 cwnd 的状态变量。拥塞控制窗口的大小取决于网络的拥塞程度,并且动态变化。发送方让自己的发送窗口取为拥塞窗口和接收方的接受窗口中较小的一个。 TCP 的拥塞控制采用了四种算法,即慢开始,拥塞避免,快重传和快恢复。在网络层也可以使路由器采用适当的分组丢弃策略(如主动队列管理 AQM),以减少网络拥塞的发生。 运输连接的三个阶段,即:连接建立,数据传送和连接释放。 主动发起 TCP 连接建立的应用进程叫做客户,而被动等待连接建立的应用进程叫做服务器。TCP 连接采用三报文握手机制。服务器要确认用户的连接请求,然后客户要对服务器的确认进行确认。 TCP 的连接释放采用四报文握手机制。任何一方都可以在数据传送结束后发出连接释放的通知,待对方确认后进入半关闭状态。当另一方也没有数据再发送时,则发送连接释放通知,对方确认后就完全关闭了 TCP 连接 5.3. 补充(重要) # 以下知识点需要重点关注:\n端口和套接字的意义 UDP 和 TCP 的区别以及两者的应用场景 在不可靠的网络上实现可靠传输的工作原理,停止等待协议和 ARQ 协议 TCP 的滑动窗口,流量控制,拥塞控制和连接管理 TCP 的三次握手,四次挥手机制 6. 应用层(Application Layer) # 6.1. 基本术语 # 域名系统(DNS):域名系统(DNS,Domain Name System)将人类可读的域名 (例如,www.baidu.com) 转换为机器可读的 IP 地址 (例如,220.181.38.148)。我们可以将其理解为专为互联网设计的电话薄。\nhttps://www.seobility.net/en/wiki/HTTP_headers\n文件传输协议(FTP):FTP 是 File Transfer Protocol(文件传输协议)的英文简称,而中文简称为“文传协议”。用于 Internet 上的控制文件的双向传输。同时,它也是一个应用程序(Application)。基于不同的操作系统有不同的 FTP 应用程序,而所有这些应用程序都遵守同一种协议以传输文件。在 FTP 的使用当中,用户经常遇到两个概念:\u0026ldquo;下载\u0026rdquo;(Download)和\u0026quot;上传\u0026quot;(Upload)。 \u0026ldquo;下载\u0026quot;文件就是从远程主机拷贝文件至自己的计算机上;\u0026ldquo;上传\u0026quot;文件就是将文件从自己的计算机中拷贝至远程主机上。用 Internet 语言来说,用户可通过客户机程序向(从)远程主机上传(下载)文件。\n简单文件传输协议(TFTP):TFTP(Trivial File Transfer Protocol,简单文件传输协议)是 TCP/IP 协议族中的一个用来在客户机与服务器之间进行简单文件传输的协议,提供不复杂、开销不大的文件传输服务。端口号为 69。\n远程终端协议(TELNET):Telnet 协议是 TCP/IP 协议族中的一员,是 Internet 远程登陆服务的标准协议和主要方式。它为用户提供了在本地计算机上完成远程主机工作的能力。在终端使用者的电脑上使用 telnet 程序,用它连接到服务器。终端使用者可以在 telnet 程序中输入命令,这些命令会在服务器上运行,就像直接在服务器的控制台上输入一样。可以在本地就能控制服务器。要开始一个 telnet 会话,必须输入用户名和密码来登录服务器。Telnet 是常用的远程控制 Web 服务器的方法。\n万维网(WWW):WWW 是环球信息网的缩写,(亦作“Web”、“WWW”、“\u0026lsquo;W3\u0026rsquo;”,英文全称为“World Wide Web”),中文名字为“万维网”,\u0026ldquo;环球网\u0026quot;等,常简称为 Web。分为 Web 客户端和 Web 服务器程序。WWW 可以让 Web 客户端(常用浏览器)访问浏览 Web 服务器上的页面。是一个由许多互相链接的超文本组成的系统,通过互联网访问。在这个系统中,每个有用的事物,称为一样“资源”;并且由一个全局“统一资源标识符”(URI)标识;这些资源通过超文本传输协议(Hypertext Transfer Protocol)传送给用户,而后者通过点击链接来获得资源。万维网联盟(英语:World Wide Web Consortium,简称 W3C),又称 W3C 理事会。1994 年 10 月在麻省理工学院(MIT)计算机科学实验室成立。万维网联盟的创建者是万维网的发明者蒂姆·伯纳斯-李。万维网并不等同互联网,万维网只是互联网所能提供的服务其中之一,是靠着互联网运行的一项服务。\n万维网的大致工作工程:\n统一资源定位符(URL):统一资源定位符是对可以从互联网上得到的资源的位置和访问方法的一种简洁的表示,是互联网上标准资源的地址。互联网上的每个文件都有一个唯一的 URL,它包含的信息指出文件的位置以及浏览器应该怎么处理它。\n超文本传输协议(HTTP):超文本传输协议(HTTP,HyperText Transfer Protocol)是互联网上应用最为广泛的一种网络协议。所有的 WWW 文件都必须遵守这个标准。设计 HTTP 最初的目的是为了提供一种发布和接收 HTML 页面的方法。1960 年美国人 Ted Nelson 构思了一种通过计算机处理文本信息的方法,并称之为超文本(hypertext),这成为了 HTTP 超文本传输协议标准架构的发展根基。\nHTTP 协议的本质就是一种浏览器与服务器之间约定好的通信格式。HTTP 的原理如下图所示:\n代理服务器(Proxy Server):代理服务器(Proxy Server)是一种网络实体,它又称为万维网高速缓存。 代理服务器把最近的一些请求和响应暂存在本地磁盘中。当新请求到达时,若代理服务器发现这个请求与暂时存放的的请求相同,就返回暂存的响应,而不需要按 URL 的地址再次去互联网访问该资源。代理服务器可在客户端或服务器工作,也可以在中间系统工作。\n简单邮件传输协议(SMTP) : SMTP(Simple Mail Transfer Protocol)即简单邮件传输协议,它是一组用于由源地址到目的地址传送邮件的规则,由它来控制信件的中转方式。 SMTP 协议属于 TCP/IP 协议簇,它帮助每台计算机在发送或中转信件时找到下一个目的地。 通过 SMTP 协议所指定的服务器,就可以把 E-mail 寄到收信人的服务器上了,整个过程只要几分钟。SMTP 服务器则是遵循 SMTP 协议的发送邮件服务器,用来发送或中转发出的电子邮件。\nhttps://www.campaignmonitor.com/resources/knowledge-base/what-is-the-code-that-makes-bcc-or-cc-operate-in-an-email/\n搜索引擎 :搜索引擎(Search Engine)是指根据一定的策略、运用特定的计算机程序从互联网上搜集信息,在对信息进行组织和处理后,为用户提供检索服务,将用户检索相关的信息展示给用户的系统。搜索引擎包括全文索引、目录索引、元搜索引擎、垂直搜索引擎、集合式搜索引擎、门户搜索引擎与免费链接列表等。\n垂直搜索引擎:垂直搜索引擎是针对某一个行业的专业搜索引擎,是搜索引擎的细分和延伸,是对网页库中的某类专门的信息进行一次整合,定向分字段抽取出需要的数据进行处理后再以某种形式返回给用户。垂直搜索是相对通用搜索引擎的信息量大、查询不准确、深度不够等提出来的新的搜索引擎服务模式,通过针对某一特定领域、某一特定人群或某一特定需求提供的有一定价值的信息和相关服务。其特点就是“专、精、深”,且具有行业色彩,相比较通用搜索引擎的海量信息无序化,垂直搜索引擎则显得更加专注、具体和深入。\n全文索引 :全文索引技术是目前搜索引擎的关键技术。试想在 1M 大小的文件中搜索一个词,可能需要几秒,在 100M 的文件中可能需要几十秒,如果在更大的文件中搜索那么就需要更大的系统开销,这样的开销是不现实的。所以在这样的矛盾下出现了全文索引技术,有时候有人叫倒排文档技术。\n目录索引:目录索引( search index/directory),顾名思义就是将网站分门别类地存放在相应的目录中,因此用户在查询信息时,可选择关键词搜索,也可按分类目录逐层查找。\n6.2. 重要知识点总结 # 文件传输协议(FTP)使用 TCP 可靠的运输服务。FTP 使用客户服务器方式。一个 FTP 服务器进程可以同时为多个用户提供服务。在进行文件传输时,FTP 的客户和服务器之间要先建立两个并行的 TCP 连接:控制连接和数据连接。实际用于传输文件的是数据连接。 万维网客户程序与服务器之间进行交互使用的协议是超文本传输协议 HTTP。HTTP 使用 TCP 连接进行可靠传输。但 HTTP 本身是无连接、无状态的。HTTP/1.1 协议使用了持续连接(分为非流水线方式和流水线方式) 电子邮件把邮件发送到收件人使用的邮件服务器,并放在其中的收件人邮箱中,收件人可随时上网到自己使用的邮件服务器读取,相当于电子邮箱。 一个电子邮件系统有三个重要组成构件:用户代理、邮件服务器、邮件协议(包括邮件发送协议,如 SMTP,和邮件读取协议,如 POP3 和 IMAP)。用户代理和邮件服务器都要运行这些协议。 6.3. 补充(重要) # 以下知识点需要重点关注:\n应用层的常见协议(重点关注 HTTP 协议) 域名系统-从域名解析出 IP 地址 访问一个网站大致的过程 系统调用和应用编程接口概念 "},{"id":451,"href":"/zh/docs/technology/Interview/high-quality-technical-articles/work/32-tips-improving-career/","title":"32条总结教你提升职场经验","section":"Work","content":" 推荐语:阿里开发者的一篇职场经验的分享。\n原文地址: https://mp.weixin.qq.com/s/6BkbGekSRTadm9j7XUL13g\n成长的捷径 # 入职伊始谦逊的态度是好的,但不要把“我是新人”作为心理安全线; 写一篇技术博客大概需要两周左右,但可能是最快的成长方式; 一定要读两本书:金字塔原理、高效能人士的七个习惯(这本书名字像成功学,实际讲的是如何塑造性格); 多问是什么、为什么,追本溯源把问题解决掉,试图绕过的问题永远会在下个路口等着你; 不要沉迷于忙碌带来的虚假安全感中,目标的确定和追逐才是最真实的安全; 不用过于计较一时的得失,在公平的环境中,吃亏是福不是鸡汤; 思维和技能不要受限于前端、后端、测试等角色,把自己定位成业务域问题的终结者; 好奇和热爱是成长最大的捷径,长期主义者会认同自己的工作价值,甚至要高于组织当下给的认同(KPI)。 功夫在日常 # 每行代码要代表自己当下的最高水平,你觉得无所谓的小细节,有可能就是在晋升场上伤害你的暗箭; 双周报不是工作日志流水账,不要被时间推着走,最起码要知道下次双周报里会有什么(小目标驱动); 觉得日常都是琐碎工作、不技术、给师兄打杂等,可以尝试对手头事情做一下分类,想象成每个分类都是个小格子,这些格子连起来的终点就是自己的目标,这样每天不再是机械的做需求,而是有规划的填格子、为目标努力,甚至会给自己加需求,因为自己看清楚了要去哪里; 日常的言行举止是能力的显微镜,大部分人可能意识不到,自己的强大和虚弱是那么的明显,不要无谓的试图掩盖,更不存在蒙混过关。 最后一条大概意思就是有时候我们会在意自己在聚光灯下(述职、晋升、周报、汇报等)的表现,以为大家会根据这个评价自己。实际上日常是怎么完成业务需求、帮助身边同学、创造价值的,才是大家评价自己的依据,而且每个人是什么样的特质,合作过三次的伙伴就可以精准评价,在聚光灯下的表演只能骗自己。\n学会被管理 # 上级、主管是泛指,开发对口的 PD 主管等也在范围内。\n不要传播负面情绪,不要总是抱怨;\n对上级不卑不亢更容易获得尊重,但不要当众反驳对方观点,分歧私下沟通;\n好好做向上管理,尤其是对齐预期,沟通绩效出现 Surprise 双方其实都有责任,但倒霉的是自己;\n尽量站在主管角度想问题:\n这样能理解很多过去感觉匪夷所思的决策; 不要在意谁执行、功劳是谁的等,为团队分忧赢得主管信任的重要性远远高于这些; 不要把这个原则理解为唯上,这种最让人不齿。 思维转换 # 定义问题是个高阶能力,尽早形成 发现问题-\u0026gt;定义问题-\u0026gt;解决问题-\u0026gt;消灭问题 的思维闭环; 定事情价值导向,做事情结果导向,讲事情问题导向; 讲不清楚,大概率不是因为自己是实干型,而是没想清楚,在晋升场更加明显; 当一个人擅长解决某一场景的问题的时候,时间越久也许越离不开这个场景(被人贴上一个标签很难,撕掉一个标签更难)。 要栓住情绪 # 学会控制情绪,没人会认真听一个愤怒的人在说什么; 再委屈、再愤怒也要保持理智,不要让自己成为需要被哄着的那种人; 足够自信的人才会坦率的承认自己的问题,很多时候我们被激怒了,只是因为对方指出了自己藏在深处的自卑; 伤害我们最深的既不是别人的所作所为,也不是自己犯的错误,而是我们对错误的回应。 成为 Leader # Manager 有下属,Leader 有追随者,管理者不需要很多,但人人都可以是 Leader。\n让你信服、愿意追随的人不是职务上的 Manager,而是在帮助自己的那个人,自己想服众的话道理一样; 不要轻易对人做负面评价,片面认知下的评价可能不准确,不经意的传播更是会给对方带来极大的困扰; Leader 如果不认同公司的使命、愿景、价值观,会过的特别痛苦; 困难时候不要否定自己的队友,多给及时、正向的反馈; 船长最重要的事情不是造船,而是激发水手对大海的向往; Leader 的天然职责是让团队活下去,唯一的途径是实现上级、老板、公司经营者的目标,越是艰难的时候越明显; Leader 的重要职责是识别团队需要被做的事情,并坚定信念,使众人行,越是艰难的时候越要坚定; Leader 应该让自己遇到的每个人都感觉自己很重要、被需要。 "},{"id":452,"href":"/zh/docs/technology/Interview/database/redis/3-commonly-used-cache-read-and-write-strategies/","title":"3种常用的缓存读写策略详解","section":"Redis","content":"看到很多小伙伴简历上写了“熟练使用缓存”,但是被我问到“缓存常用的 3 种读写策略”的时候却一脸懵逼。\n在我看来,造成这个问题的原因是我们在学习 Redis 的时候,可能只是简单写了一些 Demo,并没有去关注缓存的读写策略,或者说压根不知道这回事。\n但是,搞懂 3 种常见的缓存读写策略对于实际工作中使用缓存以及面试中被问到缓存都是非常有帮助的!\n下面介绍到的三种模式各有优劣,不存在最佳模式,根据具体的业务场景选择适合自己的缓存读写模式。\nCache Aside Pattern(旁路缓存模式) # Cache Aside Pattern 是我们平时使用比较多的一个缓存读写模式,比较适合读请求比较多的场景。\nCache Aside Pattern 中服务端需要同时维系 db 和 cache,并且是以 db 的结果为准。\n下面我们来看一下这个策略模式下的缓存读写步骤。\n写:\n先更新 db 然后直接删除 cache 。 简单画了一张图帮助大家理解写的步骤。\n读 :\n从 cache 中读取数据,读取到就直接返回 cache 中读取不到的话,就从 db 中读取数据返回 再把数据放到 cache 中。 简单画了一张图帮助大家理解读的步骤。\n你仅仅了解了上面这些内容的话是远远不够的,我们还要搞懂其中的原理。\n比如说面试官很可能会追问:“在写数据的过程中,可以先删除 cache ,后更新 db 么?”\n答案: 那肯定是不行的!因为这样可能会造成 数据库(db)和缓存(Cache)数据不一致的问题。\n举例:请求 1 先写数据 A,请求 2 随后读数据 A 的话,就很有可能产生数据不一致性的问题。\n这个过程可以简单描述为:\n请求 1 先把 cache 中的 A 数据删除 -\u0026gt; 请求 2 从 db 中读取数据-\u0026gt;请求 1 再把 db 中的 A 数据更新\n当你这样回答之后,面试官可能会紧接着就追问:“在写数据的过程中,先更新 db,后删除 cache 就没有问题了么?”\n答案: 理论上来说还是可能会出现数据不一致性的问题,不过概率非常小,因为缓存的写入速度是比数据库的写入速度快很多。\n举例:请求 1 先读数据 A,请求 2 随后写数据 A,并且数据 A 在请求 1 请求之前不在缓存中的话,也有可能产生数据不一致性的问题。\n这个过程可以简单描述为:\n请求 1 从 db 读数据 A-\u0026gt; 请求 2 更新 db 中的数据 A(此时缓存中无数据 A ,故不用执行删除缓存操作 ) -\u0026gt; 请求 1 将数据 A 写入 cache\n现在我们再来分析一下 Cache Aside Pattern 的缺陷。\n缺陷 1:首次请求数据一定不在 cache 的问题\n解决办法:可以将热点数据可以提前放入 cache 中。\n缺陷 2:写操作比较频繁的话导致 cache 中的数据会被频繁被删除,这样会影响缓存命中率 。\n解决办法:\n数据库和缓存数据强一致场景:更新 db 的时候同样更新 cache,不过我们需要加一个锁/分布式锁来保证更新 cache 的时候不存在线程安全问题。 可以短暂地允许数据库和缓存数据不一致的场景:更新 db 的时候同样更新 cache,但是给缓存加一个比较短的过期时间,这样的话就可以保证即使数据不一致的话影响也比较小。 Read/Write Through Pattern(读写穿透) # Read/Write Through Pattern 中服务端把 cache 视为主要数据存储,从中读取数据并将数据写入其中。cache 服务负责将此数据读取和写入 db,从而减轻了应用程序的职责。\n这种缓存读写策略小伙伴们应该也发现了在平时在开发过程中非常少见。抛去性能方面的影响,大概率是因为我们经常使用的分布式缓存 Redis 并没有提供 cache 将数据写入 db 的功能。\n写(Write Through):\n先查 cache,cache 中不存在,直接更新 db。 cache 中存在,则先更新 cache,然后 cache 服务自己更新 db(同步更新 cache 和 db)。 简单画了一张图帮助大家理解写的步骤。\n读(Read Through):\n从 cache 中读取数据,读取到就直接返回 。 读取不到的话,先从 db 加载,写入到 cache 后返回响应。 简单画了一张图帮助大家理解读的步骤。\nRead-Through Pattern 实际只是在 Cache-Aside Pattern 之上进行了封装。在 Cache-Aside Pattern 下,发生读请求的时候,如果 cache 中不存在对应的数据,是由客户端自己负责把数据写入 cache,而 Read Through Pattern 则是 cache 服务自己来写入缓存的,这对客户端是透明的。\n和 Cache Aside Pattern 一样, Read-Through Pattern 也有首次请求数据一定不再 cache 的问题,对于热点数据可以提前放入缓存中。\nWrite Behind Pattern(异步缓存写入) # Write Behind Pattern 和 Read/Write Through Pattern 很相似,两者都是由 cache 服务来负责 cache 和 db 的读写。\n但是,两个又有很大的不同:Read/Write Through 是同步更新 cache 和 db,而 Write Behind 则是只更新缓存,不直接更新 db,而是改为异步批量的方式来更新 db。\n很明显,这种方式对数据一致性带来了更大的挑战,比如 cache 数据可能还没异步更新 db 的话,cache 服务可能就就挂掉了。\n这种策略在我们平时开发过程中也非常非常少见,但是不代表它的应用场景少,比如消息队列中消息的异步写入磁盘、MySQL 的 Innodb Buffer Pool 机制都用到了这种策略。\nWrite Behind Pattern 下 db 的写性能非常高,非常适合一些数据经常变化又对数据一致性要求没那么高的场景,比如浏览量、点赞量。\n"},{"id":453,"href":"/zh/docs/technology/Interview/distributed-system/api-gateway/","title":"API网关基础知识总结","section":"Distributed System","content":" 什么是网关? # 微服务背景下,一个系统被拆分为多个服务,但是像安全认证,流量控制,日志,监控等功能是每个服务都需要的,没有网关的话,我们就需要在每个服务中单独实现,这使得我们做了很多重复的事情并且没有一个全局的视图来统一管理这些功能。\n一般情况下,网关可以为我们提供请求转发、安全认证(身份/权限认证)、流量控制、负载均衡、降级熔断、日志、监控、参数校验、协议转换等功能。\n上面介绍了这么多功能,实际上,网关主要做了两件事情:请求转发 + 请求过滤。\n由于引入网关之后,会多一步网络转发,因此性能会有一点影响(几乎可以忽略不计,尤其是内网访问的情况下)。 另外,我们需要保障网关服务的高可用,避免单点风险。\n如下图所示,网关服务外层通过 Nginx(其他负载均衡设备/软件也行) 进⾏负载转发以达到⾼可⽤。Nginx 在部署的时候,尽量也要考虑高可用,避免单点风险。\n网关能提供哪些功能? # 绝大部分网关可以提供下面这些功能(有一些功能需要借助其他框架或者中间件):\n请求转发:将请求转发到目标微服务。 负载均衡:根据各个微服务实例的负载情况或者具体的负载均衡策略配置对请求实现动态的负载均衡。 安全认证:对用户请求进行身份验证并仅允许可信客户端访问 API,并且还能够使用类似 RBAC 等方式来授权。 参数校验:支持参数映射与校验逻辑。 日志记录:记录所有请求的行为日志供后续使用。 监控告警:从业务指标、机器指标、JVM 指标等方面进行监控并提供配套的告警机制。 流量控制:对请求的流量进行控制,也就是限制某一时刻内的请求数。 熔断降级:实时监控请求的统计信息,达到配置的失败阈值后,自动熔断,返回默认值。 响应缓存:当用户请求获取的是一些静态的或更新不频繁的数据时,一段时间内多次请求获取到的数据很可能是一样的。对于这种情况可以将响应缓存起来。这样用户请求可以直接在网关层得到响应数据,无需再去访问业务服务,减轻业务服务的负担。 响应聚合:某些情况下用户请求要获取的响应内容可能会来自于多个业务服务。网关作为业务服务的调用方,可以把多个服务的响应整合起来,再一并返回给用户。 灰度发布:将请求动态分流到不同的服务版本(最基本的一种灰度发布)。 异常处理:对于业务服务返回的异常响应,可以在网关层在返回给用户之前做转换处理。这样可以把一些业务侧返回的异常细节隐藏,转换成用户友好的错误提示返回。 API 文档: 如果计划将 API 暴露给组织以外的开发人员,那么必须考虑使用 API 文档,例如 Swagger 或 OpenAPI。 协议转换:通过协议转换整合后台基于 REST、AMQP、Dubbo 等不同风格和实现技术的微服务,面向 Web Mobile、开放平台等特定客户端提供统一服务。 证书管理:将 SSL 证书部署到 API 网关,由一个统一的入口管理接口,降低了证书更换时的复杂度。 下图来源于 百亿规模 API 网关服务 Shepherd 的设计与实现 - 美团技术团队 - 2021这篇文章。\n有哪些常见的网关系统? # Netflix Zuul # Zuul 是 Netflix 开发的一款提供动态路由、监控、弹性、安全的网关服务,基于 Java 技术栈开发,可以和 Eureka、Ribbon、Hystrix 等组件配合使用。\nZuul 核心架构如下:\nZuul 主要通过过滤器(类似于 AOP)来过滤请求,从而实现网关必备的各种功能。\n我们可以自定义过滤器来处理请求,并且,Zuul 生态本身就有很多现成的过滤器供我们使用。就比如限流可以直接用国外朋友写的 spring-cloud-zuul-ratelimit (这里只是举例说明,一般是配合 hystrix 来做限流):\n\u0026lt;dependency\u0026gt; \u0026lt;groupId\u0026gt;org.springframework.cloud\u0026lt;/groupId\u0026gt; \u0026lt;artifactId\u0026gt;spring-cloud-starter-netflix-zuul\u0026lt;/artifactId\u0026gt; \u0026lt;/dependency\u0026gt; \u0026lt;dependency\u0026gt; \u0026lt;groupId\u0026gt;com.marcosbarbero.cloud\u0026lt;/groupId\u0026gt; \u0026lt;artifactId\u0026gt;spring-cloud-zuul-ratelimit\u0026lt;/artifactId\u0026gt; \u0026lt;version\u0026gt;2.2.0.RELEASE\u0026lt;/version\u0026gt; \u0026lt;/dependency\u0026gt; Zuul 1.x 基于同步 IO,性能较差。 Zuul 2.x 基于 Netty 实现了异步 IO,性能得到了大幅改进。\nGitHub 地址: https://github.com/Netflix/zuul 官方 Wiki: https://github.com/Netflix/zuul/wiki Spring Cloud Gateway # SpringCloud Gateway 属于 Spring Cloud 生态系统中的网关,其诞生的目标是为了替代老牌网关 Zuul。准确点来说,应该是 Zuul 1.x。SpringCloud Gateway 起步要比 Zuul 2.x 更早。\n为了提升网关的性能,SpringCloud Gateway 基于 Spring WebFlux 。Spring WebFlux 使用 Reactor 库来实现响应式编程模型,底层基于 Netty 实现同步非阻塞的 I/O。\nSpring Cloud Gateway 不仅提供统一的路由方式,并且基于 Filter 链的方式提供了网关基本的功能,例如:安全,监控/指标,限流。\nSpring Cloud Gateway 和 Zuul 2.x 的差别不大,也是通过过滤器来处理请求。不过,目前更加推荐使用 Spring Cloud Gateway 而非 Zuul,Spring Cloud 生态对其支持更加友好。\nGithub 地址: https://github.com/spring-cloud/spring-cloud-gateway 官网: https://spring.io/projects/spring-cloud-gateway OpenResty # 根据官方介绍:\nOpenResty 是一个基于 Nginx 与 Lua 的高性能 Web 平台,其内部集成了大量精良的 Lua 库、第三方模块以及大多数的依赖项。用于方便地搭建能够处理超高并发、扩展性极高的动态 Web 应用、Web 服务和动态网关。\nOpenResty 基于 Nginx,主要还是看中了其优秀的高并发能力。不过,由于 Nginx 采用 C 语言开发,二次开发门槛较高。如果想在 Nginx 上实现一些自定义的逻辑或功能,就需要编写 C 语言的模块,并重新编译 Nginx。\n为了解决这个问题,OpenResty 通过实现 ngx_lua 和 stream_lua 等 Nginx 模块,把 Lua/LuaJIT 完美地整合进了 Nginx,从而让我们能够在 Nginx 内部里嵌入 Lua 脚本,使得可以通过简单的 Lua 语言来扩展网关的功能,比如实现自定义的路由规则、过滤器、缓存策略等。\nLua 是一种非常快速的动态脚本语言,它的运行速度接近于 C 语言。LuaJIT 是 Lua 的一个即时编译器,它可以显著提高 Lua 代码的执行效率。LuaJIT 将一些常用的 Lua 函数和工具库预编译并缓存,这样在下次调用时就可以直接使用缓存的字节码,从而大大加快了执行速度。\n关于 OpenResty 的入门以及网关安全实战推荐阅读这篇文章: 每个后端都应该了解的 OpenResty 入门以及网关安全实战。\nGithub 地址: https://github.com/openresty/openresty 官网地址: https://openresty.org/ Kong # Kong 是一款基于 OpenResty (Nginx + Lua)的高性能、云原生、可扩展、生态丰富的网关系统,主要由 3 个组件组成:\nKong Server:基于 Nginx 的服务器,用来接收 API 请求。 Apache Cassandra/PostgreSQL:用来存储操作数据。 Kong Dashboard:官方推荐 UI 管理工具,当然,也可以使用 RESTful 方式 管理 Admin api。 由于默认使用 Apache Cassandra/PostgreSQL 存储数据,Kong 的整个架构比较臃肿,并且会带来高可用的问题。\nKong 提供了插件机制来扩展其功能,插件在 API 请求响应循环的生命周期中被执行。比如在服务上启用 Zipkin 插件:\n$ curl -X POST http://kong:8001/services/{service}/plugins \\ --data \u0026#34;name=zipkin\u0026#34; \\ --data \u0026#34;config.http_endpoint=http://your.zipkin.collector:9411/api/v2/spans\u0026#34; \\ --data \u0026#34;config.sample_ratio=0.001\u0026#34; Kong 本身就是一个 Lua 应用程序,并且是在 Openresty 的基础之上做了一层封装的应用。归根结底就是利用 Lua 嵌入 Nginx 的方式,赋予了 Nginx 可编程的能力,这样以插件的形式在 Nginx 这一层能够做到无限想象的事情。例如限流、安全访问策略、路由、负载均衡等等。编写一个 Kong 插件,就是按照 Kong 插件编写规范,写一个自己自定义的 Lua 脚本,然后加载到 Kong 中,最后引用即可。\n除了 Lua,Kong 还可以基于 Go 、JavaScript、Python 等语言开发插件,得益于对应的 PDK(插件开发工具包)。\n关于 Kong 插件的详细介绍,推荐阅读官方文档: https://docs.konghq.com/gateway/latest/kong-plugins/,写的比较详细。\nGithub 地址: https://github.com/Kong/kong 官网地址: https://konghq.com/kong APISIX # APISIX 是一款基于 OpenResty 和 etcd 的高性能、云原生、可扩展的网关系统。\netcd 是使用 Go 语言开发的一个开源的、高可用的分布式 key-value 存储系统,使用 Raft 协议做分布式共识。\n与传统 API 网关相比,APISIX 具有动态路由和插件热加载,特别适合微服务系统下的 API 管理。并且,APISIX 与 SkyWalking(分布式链路追踪系统)、Zipkin(分布式链路追踪系统)、Prometheus(监控系统) 等 DevOps 生态工具对接都十分方便。\n作为 Nginx 和 Kong 的替代项目,APISIX 目前已经是 Apache 顶级开源项目,并且是最快毕业的国产开源项目。国内目前已经有很多知名企业(比如金山、有赞、爱奇艺、腾讯、贝壳)使用 APISIX 处理核心的业务流量。\n根据官网介绍:“APISIX 已经生产可用,功能、性能、架构全面优于 Kong”。\nAPISIX 同样支持定制化的插件开发。开发者除了能够使用 Lua 语言开发插件,还能通过下面两种方式开发来避开 Lua 语言的学习成本:\n通过 Plugin Runner 来支持更多的主流编程语言(比如 Java、Python、Go 等等)。通过这样的方式,可以让后端工程师通过本地 RPC 通信,使用熟悉的编程语言开发 APISIX 的插件。这样做的好处是减少了开发成本,提高了开发效率,但是在性能上会有一些损失。 使用 Wasm(WebAssembly) 开发插件。Wasm 被嵌入到了 APISIX 中,用户可以使用 Wasm 去编译成 Wasm 的字节码在 APISIX 中运行。 Wasm 是基于堆栈的虚拟机的二进制指令格式,一种低级汇编语言,旨在非常接近已编译的机器代码,并且非常接近本机性能。Wasm 最初是为浏览器构建的,但是随着技术的成熟,在服务器端看到了越来越多的用例。\nGithub 地址: https://github.com/apache/apisix 官网地址: https://apisix.apache.org/zh/ 相关阅读:\n为什么说 Apache APISIX 是最好的 API 网关? 有了 NGINX 和 Kong,为什么还需要 Apache APISIX APISIX 技术博客 APISIX 用户案例(推荐) Shenyu # Shenyu 是一款基于 WebFlux 的可扩展、高性能、响应式网关,Apache 顶级开源项目。\nShenyu 通过插件扩展功能,插件是 ShenYu 的灵魂,并且插件也是可扩展和热插拔的。不同的插件实现不同的功能。Shenyu 自带了诸如限流、熔断、转发、重写、重定向、和路由监控等插件。\nGithub 地址: https://github.com/apache/incubator-shenyu 官网地址: https://shenyu.apache.org/ 如何选择? # 上面介绍的几个常见的网关系统,最常用的是 Spring Cloud Gateway、Kong、APISIX 这三个。\n对于公司业务以 Java 为主要开发语言的情况下,Spring Cloud Gateway 通常是个不错的选择,其优点有:简单易用、成熟稳定、与 Spring Cloud 生态系统兼容、Spring 社区成熟等等。不过,Spring Cloud Gateway 也有一些局限性和不足之处, 一般还需要结合其他网关一起使用比如 OpenResty。并且,其性能相比较于 Kong 和 APISIX,还是差一些。如果对性能要求比较高的话,Spring Cloud Gateway 不是一个好的选择。\nKong 和 APISIX 功能更丰富,性能更强大,技术架构更贴合云原生。Kong 是开源 API 网关的鼻祖,生态丰富,用户群体庞大。APISIX 属于后来者,更优秀一些,根据 APISIX 官网介绍:“APISIX 已经生产可用,功能、性能、架构全面优于 Kong”。下面简单对比一下二者:\nAPISIX 基于 etcd 来做配置中心,不存在单点问题,云原生友好;而 Kong 基于 Apache Cassandra/PostgreSQL ,存在单点风险,需要额外的基础设施保障做高可用。 APISIX 支持热更新,并且实现了毫秒级别的热更新响应;而 Kong 不支持热更新。 APISIX 的性能要优于 Kong 。 APISIX 支持的插件更多,功能更丰富。 参考 # Kong 插件开发教程[通俗易懂]: https://cloud.tencent.com/developer/article/2104299 API 网关 Kong 实战: https://xie.infoq.cn/article/10e4dab2de0bdb6f2c3c93da6 Spring Cloud Gateway 原理介绍和应用: https://blog.fintopia.tech/60e27b0e2078082a378ec5ed/ 微服务为什么要用到 API 网关?: https://apisix.apache.org/zh/blog/2023/03/08/why-do-microservices-need-an-api-gateway/ "},{"id":454,"href":"/zh/docs/technology/Interview/java/concurrent/aqs/","title":"AQS 详解","section":"Concurrent","content":" AQS 介绍 # AQS 的全称为 AbstractQueuedSynchronizer ,翻译过来的意思就是抽象队列同步器。这个类在 java.util.concurrent.locks 包下面。\nAQS 就是一个抽象类,主要用来构建锁和同步器。\npublic abstract class AbstractQueuedSynchronizer extends AbstractOwnableSynchronizer implements java.io.Serializable { } AQS 为构建锁和同步器提供了一些通用功能的实现。因此,使用 AQS 能简单且高效地构造出应用广泛的大量的同步器,比如我们提到的 ReentrantLock,Semaphore,其他的诸如 ReentrantReadWriteLock,SynchronousQueue等等皆是基于 AQS 的。\nAQS 原理 # 在面试中被问到并发知识的时候,大多都会被问到“请你说一下自己对于 AQS 原理的理解”。下面给大家一个示例供大家参考,面试不是背题,大家一定要加入自己的思想,即使加入不了自己的思想也要保证自己能够通俗的讲出来而不是背出来。\nAQS 快速了解 # 在真正讲解 AQS 源码之前,需要对 AQS 有一个整体层面的认识。这里会先通过几个问题,从整体层面上认识 AQS,了解 AQS 在整个 Java 并发中所位于的层面,之后在学习 AQS 源码的过程中,才能更加了解同步器和 AQS 之间的关系。\nAQS 的作用是什么? # AQS 解决了开发者在实现同步器时的复杂性问题。它提供了一个通用框架,用于实现各种同步器,例如 可重入锁(ReentrantLock)、信号量(Semaphore)和 倒计时器(CountDownLatch)。通过封装底层的线程同步机制,AQS 将复杂的线程管理逻辑隐藏起来,使开发者只需专注于具体的同步逻辑。\n简单来说,AQS 是一个抽象类,为同步器提供了通用的 执行框架。它定义了 资源获取和释放的通用流程,而具体的资源获取逻辑则由具体同步器通过重写模板方法来实现。 因此,可以将 AQS 看作是同步器的 基础“底座”,而同步器则是基于 AQS 实现的 具体“应用”。\nAQS 为什么使用 CLH 锁队列的变体? # CLH 锁是一种基于 自旋锁 的优化实现。\n先说一下自旋锁存在的问题:自旋锁通过线程不断对一个原子变量执行 compareAndSet(简称 CAS)操作来尝试获取锁。在高并发场景下,多个线程会同时竞争同一个原子变量,容易造成某个线程的 CAS 操作长时间失败,从而导致 “饥饿”问题(某些线程可能永远无法获取锁)。\nCLH 锁通过引入一个队列来组织并发竞争的线程,对自旋锁进行了改进:\n每个线程会作为一个节点加入到队列中,并通过自旋监控前一个线程节点的状态,而不是直接竞争共享变量。 线程按顺序排队,确保公平性,从而避免了 “饥饿” 问题。 AQS(AbstractQueuedSynchronizer)在 CLH 锁的基础上进一步优化,形成了其内部的 CLH 队列变体。主要改进点有以下两方面:\n自旋 + 阻塞: CLH 锁使用纯自旋方式等待锁的释放,但大量的自旋操作会占用过多的 CPU 资源。AQS 引入了 自旋 + 阻塞 的混合机制: 如果线程获取锁失败,会先短暂自旋尝试获取锁; 如果仍然失败,则线程会进入阻塞状态,等待被唤醒,从而减少 CPU 的浪费。 单向队列改为双向队列:CLH 锁使用单向队列,节点只知道前驱节点的状态,而当某个节点释放锁时,需要通过队列唤醒后续节点。AQS 将队列改为 双向队列,新增了 next 指针,使得节点不仅知道前驱节点,也可以直接唤醒后继节点,从而简化了队列操作,提高了唤醒效率。 AQS 的性能比较好,原因是什么? # 因为 AQS 内部大量使用了 CAS 操作。\nAQS 内部通过队列来存储等待的线程节点。由于队列是共享资源,在多线程场景下,需要保证队列的同步访问。\nAQS 内部通过 CAS 操作来控制队列的同步访问,CAS 操作主要用于控制 队列初始化 、 线程节点入队 两个操作的并发安全。虽然利用 CAS 控制并发安全可以保证比较好的性能,但同时会带来比较高的 编码复杂度 。\nAQS 中为什么 Node 节点需要不同的状态? # AQS 中的 waitStatus 状态类似于 状态机 ,通过不同状态来表明 Node 节点的不同含义,并且根据不同操作,来控制状态之间的流转。\n状态 0 :新节点加入队列之后,初始状态为 0 。\n状态 SIGNAL :当有新的节点加入队列,此时新节点的前继节点状态就会由 0 更新为 SIGNAL ,表示前继节点释放锁之后,需要对新节点进行唤醒操作。如果唤醒 SIGNAL 状态节点的后续节点,就会将 SIGNAL 状态更新为 0 。即通过清除 SIGNAL 状态,表示已经执行了唤醒操作。\n状态 CANCELLED :如果一个节点在队列中等待获取锁锁时,因为某种原因失败了,该节点的状态就会变为 CANCELLED ,表明取消获取锁,这种状态的节点是异常的,无法被唤醒,也无法唤醒后继节点。\nAQS 核心思想 # AQS 核心思想是,如果被请求的共享资源空闲,则将当前请求资源的线程设置为有效的工作线程,并且将共享资源设置为锁定状态。如果被请求的共享资源被占用,那么就需要一套线程阻塞等待以及被唤醒时锁分配的机制,这个机制 AQS 是基于 CLH 锁 (Craig, Landin, and Hagersten locks) 进一步优化实现的。\nCLH 锁 对自旋锁进行了改进,是基于单链表的自旋锁。在多线程场景下,会将请求获取锁的线程组织成一个单向队列,每个等待的线程会通过自旋访问前一个线程节点的状态,前一个节点释放锁之后,当前节点才可以获取锁。CLH 锁 的队列结构如下图所示。\nAQS 中使用的 等待队列 是 CLH 锁队列的变体(接下来简称为 CLH 变体队列)。\nAQS 的 CLH 变体队列是一个双向队列,会暂时获取不到锁的线程将被加入到该队列中,CLH 变体队列和原本的 CLH 锁队列的区别主要有两点:\n由 自旋 优化为 自旋 + 阻塞 :自旋操作的性能很高,但大量的自旋操作比较占用 CPU 资源,因此在 CLH 变体队列中会先通过自旋尝试获取锁,如果失败再进行阻塞等待。 由 单向队列 优化为 双向队列 :在 CLH 变体队列中,会对等待的线程进行阻塞操作,当队列前边的线程释放锁之后,需要对后边的线程进行唤醒,因此增加了 next 指针,成为了双向队列。 AQS 将每条请求共享资源的线程封装成一个 CLH 变体队列的一个结点(Node)来实现锁的分配。在 CLH 变体队列中,一个节点表示一个线程,它保存着线程的引用(thread)、 当前节点在队列中的状态(waitStatus)、前驱节点(prev)、后继节点(next)。\nAQS 中的 CLH 变体队列结构如下图所示:\n关于 AQS 核心数据结构-CLH 锁的详细解读,强烈推荐阅读 Java AQS 核心数据结构-CLH 锁 - Qunar 技术沙龙 这篇文章。\nAQS(AbstractQueuedSynchronizer)的核心原理图:\nAQS 使用 int 成员变量 state 表示同步状态,通过内置的 FIFO 线程等待/等待队列 来完成获取资源线程的排队工作。\nstate 变量由 volatile 修饰,用于展示当前临界资源的获取情况。\n// 共享变量,使用volatile修饰保证线程可见性 private volatile int state; 另外,状态信息 state 可以通过 protected 类型的getState()、setState()和compareAndSetState() 进行操作。并且,这几个方法都是 final 修饰的,在子类中无法被重写。\n//返回同步状态的当前值 protected final int getState() { return state; } // 设置同步状态的值 protected final void setState(int newState) { state = newState; } //原子地(CAS操作)将同步状态值设置为给定值update如果当前同步状态的值等于expect(期望值) protected final boolean compareAndSetState(int expect, int update) { return unsafe.compareAndSwapInt(this, stateOffset, expect, update); } 以可重入的互斥锁 ReentrantLock 为例,它的内部维护了一个 state 变量,用来表示锁的占用状态。state 的初始值为 0,表示锁处于未锁定状态。当线程 A 调用 lock() 方法时,会尝试通过 tryAcquire() 方法独占该锁,并让 state 的值加 1。如果成功了,那么线程 A 就获取到了锁。如果失败了,那么线程 A 就会被加入到一个等待队列(CLH 变体队列)中,直到其他线程释放该锁。假设线程 A 获取锁成功了,释放锁之前,A 线程自己是可以重复获取此锁的(state 会累加)。这就是可重入性的体现:一个线程可以多次获取同一个锁而不会被阻塞。但是,这也意味着,一个线程必须释放与获取的次数相同的锁,才能让 state 的值回到 0,也就是让锁恢复到未锁定状态。只有这样,其他等待的线程才能有机会获取该锁。\n线程 A 尝试获取锁的过程如下图所示(图源 从 ReentrantLock 的实现看 AQS 的原理及应用 - 美团技术团队):\n再以倒计时器 CountDownLatch 以例,任务分为 N 个子线程去执行,state 也初始化为 N(注意 N 要与线程个数一致)。这 N 个子线程开始执行任务,每执行完一个子线程,就调用一次 countDown() 方法。该方法会尝试使用 CAS(Compare and Swap) 操作,让 state 的值减少 1。当所有的子线程都执行完毕后(即 state 的值变为 0),CountDownLatch 会调用 unpark() 方法,唤醒主线程。这时,主线程就可以从 await() 方法(CountDownLatch 中的await() 方法而非 AQS 中的)返回,继续执行后续的操作。\nNode 节点 waitStatus 状态含义 # AQS 中的 waitStatus 状态类似于 状态机 ,通过不同状态来表明 Node 节点的不同含义,并且根据不同操作,来控制状态之间的流转。\nNode 节点状态 值 含义 CANCELLED 1 表示线程已经取消获取锁。线程在等待获取资源时被中断、等待资源超时会更新为该状态。 SIGNAL -1 表示后继节点需要当前节点唤醒。在当前线程节点释放锁之后,需要对后继节点进行唤醒。 CONDITION -2 表示节点在等待 Condition。当其他线程调用了 Condition 的 signal() 方法后,节点会从等待队列转移到同步队列中等待获取资源。 PROPAGATE -3 用于共享模式。在共享模式下,可能会出现线程在队列中无法被唤醒的情况,因此引入了 PROPAGATE 状态来解决这个问题。 0 加入队列的新节点的初始状态。 在 AQS 的源码中,经常使用 \u0026gt; 0 、 \u0026lt; 0 来对 waitStatus 进行判断。\n如果 waitStatus \u0026gt; 0 ,表明节点的状态已经取消等待获取资源。\n如果 waitStatus \u0026lt; 0 ,表明节点的状态处于正常的状态,即没有取消等待。\n其中 SIGNAL 状态是最重要的,节点状态流转以及对应操作如下:\n状态流转 对应操作 0 新节点入队时,初始状态为 0 。 0 -\u0026gt; SIGNAL 新节点入队时,它的前继节点状态会由 0 更新为 SIGNAL 。SIGNAL 状态表明该节点的后续节点需要被唤醒。 SIGNAL -\u0026gt; 0 在唤醒后继节点时,需要清除当前节点的状态。通常发生在 head 节点,比如 head 节点的状态由 SIGNAL 更新为 0 ,表示已经对 head 节点的后继节点唤醒了。 0 -\u0026gt; PROPAGATE AQS 内部引入了 PROPAGATE 状态,为了解决并发场景下,可能造成的线程节点无法唤醒的情况。(在 AQS 共享模式获取资源的源码分析会讲到) 自定义同步器 # 基于 AQS 可以实现自定义的同步器, AQS 提供了 5 个模板方法(模板方法模式)。如果需要自定义同步器一般的方式是这样(模板方法模式很经典的一个应用):\n自定义的同步器继承 AbstractQueuedSynchronizer 。 重写 AQS 暴露的模板方法。 AQS 使用了模板方法模式,自定义同步器时需要重写下面几个 AQS 提供的钩子方法:\n//独占方式。尝试获取资源,成功则返回true,失败则返回false。 protected boolean tryAcquire(int) //独占方式。尝试释放资源,成功则返回true,失败则返回false。 protected boolean tryRelease(int) //共享方式。尝试获取资源。负数表示失败;0表示成功,但没有剩余可用资源;正数表示成功,且有剩余资源。 protected int tryAcquireShared(int) //共享方式。尝试释放资源,成功则返回true,失败则返回false。 protected boolean tryReleaseShared(int) //该线程是否正在独占资源。只有用到condition才需要去实现它。 protected boolean isHeldExclusively() 什么是钩子方法呢? 钩子方法是一种被声明在抽象类中的方法,一般使用 protected 关键字修饰,它可以是空方法(由子类实现),也可以是默认实现的方法。模板设计模式通过钩子方法控制固定步骤的实现。\n篇幅问题,这里就不详细介绍模板方法模式了,不太了解的小伙伴可以看看这篇文章: 用 Java8 改造后的模板方法模式真的是 yyds!。\n除了上面提到的钩子方法之外,AQS 类中的其他方法都是 final ,所以无法被其他类重写。\nAQS 资源共享方式 # AQS 定义两种资源共享方式:Exclusive(独占,只有一个线程能执行,如ReentrantLock)和Share(共享,多个线程可同时执行,如Semaphore/CountDownLatch)。\n一般来说,自定义同步器的共享方式要么是独占,要么是共享,他们也只需实现tryAcquire-tryRelease、tryAcquireShared-tryReleaseShared中的一种即可。但 AQS 也支持自定义同步器同时实现独占和共享两种方式,如ReentrantReadWriteLock。\nAQS 资源获取源码分析(独占模式) # AQS 中以独占模式获取资源的入口方法是 acquire() ,如下:\n// AQS public final void acquire(int arg) { if (!tryAcquire(arg) \u0026amp;\u0026amp; acquireQueued(addWaiter(Node.EXCLUSIVE), arg)) selfInterrupt(); } 在 acquire() 中,线程会先尝试获取共享资源;如果获取失败,会将线程封装为 Node 节点加入到 AQS 的等待队列中;加入队列之后,会让等待队列中的线程尝试获取资源,并且会对线程进行阻塞操作。分别对应以下三个方法:\ntryAcquire() :尝试获取锁(模板方法),AQS 不提供具体实现,由子类实现。 addWaiter() :如果获取锁失败,会将当前线程封装为 Node 节点加入到 AQS 的 CLH 变体队列中等待获取锁。 acquireQueued() :对线程进行阻塞,并调用 tryAcquire() 方法让队列中的线程尝试获取锁。 tryAcquire() 分析 # AQS 中对应的 tryAcquire() 模板方法如下:\n// AQS protected boolean tryAcquire(int arg) { throw new UnsupportedOperationException(); } tryAcquire() 方法是 AQS 提供的模板方法,不提供默认实现。\n因此,这里分析 tryAcquire() 方法时,以 ReentrantLock 的非公平锁(独占锁)为例进行分析,ReentrantLock 内部实现的 tryAcquire() 会调用到下边的 nonfairTryAcquire() :\n// ReentrantLock final boolean nonfairTryAcquire(int acquires) { final Thread current = Thread.currentThread(); // 1、获取 AQS 中的 state 状态 int c = getState(); // 2、如果 state 为 0,证明锁没有被其他线程占用 if (c == 0) { // 2.1、通过 CAS 对 state 进行更新 if (compareAndSetState(0, acquires)) { // 2.2、如果 CAS 更新成功,就将锁的持有者设置为当前线程 setExclusiveOwnerThread(current); return true; } } // 3、如果当前线程和锁的持有线程相同,说明发生了「锁的重入」 else if (current == getExclusiveOwnerThread()) { int nextc = c + acquires; if (nextc \u0026lt; 0) // overflow throw new Error(\u0026#34;Maximum lock count exceeded\u0026#34;); // 3.1、将锁的重入次数加 1 setState(nextc); return true; } // 4、如果锁被其他线程占用,就返回 false,表示获取锁失败 return false; } 在 nonfairTryAcquire() 方法内部,主要通过两个核心操作去完成资源的获取:\n通过 CAS 更新 state 变量。state == 0 表示资源没有被占用。state \u0026gt; 0 表示资源被占用,此时 state 表示重入次数。 通过 setExclusiveOwnerThread() 设置持有资源的线程。 如果线程更新 state 变量成功,就表明获取到了资源, 因此将持有资源的线程设置为当前线程即可。\naddWaiter() 分析 # 在通过 tryAcquire() 方法尝试获取资源失败之后,会调用 addWaiter() 方法将当前线程封装为 Node 节点加入 AQS 内部的队列中。addWaite() 代码如下:\n// AQS private Node addWaiter(Node mode) { // 1、将当前线程封装为 Node 节点。 Node node = new Node(Thread.currentThread(), mode); Node pred = tail; // 2、如果 pred != null,则证明 tail 节点已经被初始化,直接将 Node 节点加入队列即可。 if (pred != null) { node.prev = pred; // 2.1、通过 CAS 控制并发安全。 if (compareAndSetTail(pred, node)) { pred.next = node; return node; } } // 3、初始化队列,并将新创建的 Node 节点加入队列。 enq(node); return node; } 节点入队的并发安全:\n在 addWaiter() 方法中,需要执行 Node 节点 入队 的操作。由于是在多线程环境下,因此需要通过 CAS 操作保证并发安全。\n通过 CAS 操作去更新 tail 指针指向新入队的 Node 节点,CAS 可以保证只有一个线程会成功修改 tail 指针,以此来保证 Node 节点入队时的并发安全。\nAQS 内部队列的初始化:\n在执行 addWaiter() 时,如果发现 pred == null ,即 tail 指针为 null,则证明队列没有初始化,需要调用 enq() 方法初始化队列,并将 Node 节点加入到初始化后的队列中,代码如下:\n// AQS private Node enq(final Node node) { for (;;) { Node t = tail; if (t == null) { // 1、通过 CAS 操作保证队列初始化的并发安全 if (compareAndSetHead(new Node())) tail = head; } else { // 2、与 addWaiter() 方法中节点入队的操作相同 node.prev = t; if (compareAndSetTail(t, node)) { t.next = node; return t; } } } } 在 enq() 方法中初始化队列,在初始化过程中,也需要通过 CAS 来保证并发安全。\n初始化队列总共包含两个步骤:初始化 head 节点、tail 指向 head 节点。\n初始化后的队列如下图所示:\nacquireQueued() 分析 # 为了方便阅读,这里再贴一下 AQS 中 acquire() 获取资源的代码:\n// AQS public final void acquire(int arg) { if (!tryAcquire(arg) \u0026amp;\u0026amp; acquireQueued(addWaiter(Node.EXCLUSIVE), arg)) selfInterrupt(); } 在 acquire() 方法中,通过 addWaiter() 方法将 Node 节点加入队列之后,就会调用 acquireQueued() 方法。代码如下:\n// AQS:令队列中的节点尝试获取锁,并且对线程进行阻塞。 final boolean acquireQueued(final Node node, int arg) { boolean failed = true; try { boolean interrupted = false; for (;;) { // 1、尝试获取锁。 final Node p = node.predecessor(); if (p == head \u0026amp;\u0026amp; tryAcquire(arg)) { setHead(node); p.next = null; // help GC failed = false; return interrupted; } // 2、判断线程是否可以阻塞,如果可以,则阻塞当前线程。 if (shouldParkAfterFailedAcquire(p, node) \u0026amp;\u0026amp; parkAndCheckInterrupt()) interrupted = true; } } finally { // 3、如果获取锁失败,就会取消获取锁,将节点状态更新为 CANCELLED。 if (failed) cancelAcquire(node); } } 在 acquireQueued() 方法中,主要做两件事情:\n尝试获取资源: 当前线程加入队列之后,如果发现前继节点是 head 节点,说明当前线程是队列中第一个等待的节点,于是调用 tryAcquire() 尝试获取资源。\n阻塞当前线程 :如果尝试获取资源失败,就需要阻塞当前线程,等待被唤醒之后获取资源。\n1、尝试获取资源\n在 acquireQueued() 方法中,尝试获取资源总共有 2 个步骤:\np == head :表明当前节点的前继节点为 head 节点。此时当前节点为 AQS 队列中的第一个等待节点。 tryAcquire(arg) == true :表明当前线程尝试获取资源成功。 在成功获取资源之后,就需要将当前线程的节点 从等待队列中移除 。移除操作为:将当前等待的线程节点设置为 head 节点(head 节点是虚拟节点,并不参与排队获取资源)。\n2、阻塞当前线程\n在 AQS 中,当前节点的唤醒需要依赖于上一个节点。如果上一个节点取消获取锁,它的状态就会变为 CANCELLED ,CANCELLED 状态的节点没有获取到锁,也就无法执行解锁操作对当前节点进行唤醒。因此在阻塞当前线程之前,需要跳过 CANCELLED 状态的节点。\n通过 shouldParkAfterFailedAcquire() 方法来判断当前线程节点是否可以阻塞,如下:\n// AQS:判断当前线程节点是否可以阻塞。 private static boolean shouldParkAfterFailedAcquire(Node pred, Node node) { int ws = pred.waitStatus; // 1、前继节点状态正常,直接返回 true 即可。 if (ws == Node.SIGNAL) return true; // 2、ws \u0026gt; 0 表示前继节点的状态异常,即为 CANCELLED 状态,需要跳过异常状态的节点。 if (ws \u0026gt; 0) { do { node.prev = pred = pred.prev; } while (pred.waitStatus \u0026gt; 0); pred.next = node; } else { // 3、如果前继节点的状态不是 SIGNAL,也不是 CANCELLED,就将状态设置为 SIGNAL。 compareAndSetWaitStatus(pred, ws, Node.SIGNAL); } return false; } shouldParkAfterFailedAcquire() 方法中的判断逻辑:\n如果发现前继节点的状态是 SIGNAL ,则可以阻塞当前线程。 如果发现前继节点的状态是 CANCELLED ,则需要跳过 CANCELLED 状态的节点。 如果发现前继节点的状态不是 SIGNAL 和 CANCELLED ,表明前继节点的状态处于正常等待资源的状态,因此将前继节点的状态设置为 SIGNAL ,表明该前继节点需要对后续节点进行唤醒。 当判断当前线程可以阻塞之后,通过调用 parkAndCheckInterrupt() 方法来阻塞当前线程。内部使用了 LockSupport 来实现阻塞。LockSupoprt 底层是基于 Unsafe 类来阻塞线程,代码如下:\n// AQS private final boolean parkAndCheckInterrupt() { // 1、线程阻塞到这里 LockSupport.park(this); // 2、线程被唤醒之后,返回线程中断状态 return Thread.interrupted(); } 为什么在线程被唤醒之后,要返回线程的中断状态呢?\n在 parkAndCheckInterrupt() 方法中,当执行完 LockSupport.park(this) ,线程会被阻塞,代码如下:\n// AQS private final boolean parkAndCheckInterrupt() { LockSupport.park(this); // 线程被唤醒之后,需要返回线程中断状态 return Thread.interrupted(); } 当线程被唤醒之后,需要执行 Thread.interrupted() 来返回线程的中断状态,这是为什么呢?\n这个和线程的中断协作机制有关系,线程被唤醒之后,并不确定是被中断唤醒,还是被 LockSupport.unpark() 唤醒,因此需要通过线程的中断状态来判断。\n在 acquire() 方法中,为什么需要调用 selfInterrupt() ?\nacquire() 方法代码如下:\n// AQS public final void acquire(int arg) { if (!tryAcquire(arg) \u0026amp;\u0026amp; acquireQueued(addWaiter(Node.EXCLUSIVE), arg)) selfInterrupt(); } 在 acquire() 方法中,当 if 语句的条件返回 true 后,就会调用 selfInterrupt() ,该方法会中断当前线程,为什么需要中断当前线程呢?\n当 if 判断为 true 时,需要 tryAcquire() 返回 false ,并且 acquireQueued() 返回 true 。\n其中 acquireQueued() 方法返回的是线程被唤醒之后的 中断状态 ,通过执行 Thread.interrupted() 来返回。该方法在返回中断状态的同时,会清除线程的中断状态。\n因此如果 if 判断为 true ,表明线程的中断状态为 true ,但是调用 Thread.interrupted() 之后,线程的中断状态被清除为 false ,因此需要重新执行 selfInterrupt() 来重新设置线程的中断状态。\nAQS 资源释放源码分析(独占模式) # AQS 中以独占模式释放资源的入口方法是 release() ,代码如下:\n// AQS public final boolean release(int arg) { // 1、尝试释放锁 if (tryRelease(arg)) { Node h = head; // 2、唤醒后继节点 if (h != null \u0026amp;\u0026amp; h.waitStatus != 0) unparkSuccessor(h); return true; } return false; } 在 release() 方法中,主要做两件事:尝试释放锁和唤醒后继节点。对应方法如下:\n1、尝试释放锁\n通过 tryRelease() 方法尝试释放锁,该方法为模板方法,由自定义同步器实现,因此这里仍然以 ReentrantLock 为例来讲解。\nReentrantLock 中实现的 tryRelease() 方法如下:\n// ReentrantLock protected final boolean tryRelease(int releases) { int c = getState() - releases; // 1、判断持有锁的线程是否为当前线程 if (Thread.currentThread() != getExclusiveOwnerThread()) throw new IllegalMonitorStateException(); boolean free = false; // 2、如果 state 为 0,则表明当前线程已经没有重入次数。因此将 free 更新为 true,表明该线程会释放锁。 if (c == 0) { free = true; // 3、更新持有资源的线程为 null setExclusiveOwnerThread(null); } // 4、更新 state 值 setState(c); return free; } 在 tryRelease() 方法中,会先计算释放锁之后的 state 值,判断 state 值是否为 0。\n如果 state == 0 ,表明该线程没有重入次数了,更新 free = true ,并修改持有资源的线程为 null,表明该线程完全释放这把锁。 如果 state != 0 ,表明该线程还存在重入次数,因此不更新 free 值,free 值为 false 表明该线程没有完全释放这把锁。 之后更新 state 值,并返回 free 值,free 值表明线程是否完全释放锁。\n2、唤醒后继节点\n如果 tryRelease() 返回 true ,表明线程已经没有重入次数了,锁已经被完全释放,因此需要唤醒后继节点。\n在唤醒后继节点之前,需要判断是否可以唤醒后继节点,判断条件为: h != null \u0026amp;\u0026amp; h.waitStatus != 0 。这里解释一下为什么要这样判断:\nh == null :表明 head 节点还没有被初始化,也就是 AQS 中的队列没有被初始化,因此无法唤醒队列中的线程节点。 h != null \u0026amp;\u0026amp; h.waitStatus == 0 :表明头节点刚刚初始化完毕(节点的初始化状态为 0),后继节点线程还没有成功入队,因此不需要对后续节点进行唤醒。(当后继节点入队之后,会将前继节点的状态修改为 SIGNAL ,表明需要对后继节点进行唤醒) h != null \u0026amp;\u0026amp; h.waitStatus != 0 :其中 waitStatus 有可能大于 0,也有可能小于 0。其中 \u0026gt; 0 表明节点已经取消等待获取资源,\u0026lt; 0 表明节点处于正常等待状态。 接下来进入 unparkSuccessor() 方法查看如何唤醒后继节点:\n// AQS:这里的入参 node 为队列的头节点(虚拟头节点) private void unparkSuccessor(Node node) { int ws = node.waitStatus; // 1、将头节点的状态进行清除,为后续的唤醒做准备。 if (ws \u0026lt; 0) compareAndSetWaitStatus(node, ws, 0); Node s = node.next; // 2、如果后继节点异常,则需要从 tail 向前遍历,找到正常状态的节点进行唤醒。 if (s == null || s.waitStatus \u0026gt; 0) { s = null; for (Node t = tail; t != null \u0026amp;\u0026amp; t != node; t = t.prev) if (t.waitStatus \u0026lt;= 0) s = t; } if (s != null) // 3、唤醒后继节点 LockSupport.unpark(s.thread); } 在 unparkSuccessor() 中,如果头节点的状态 \u0026lt; 0 (在正常情况下,只要有后继节点,头节点的状态应该为 SIGNAL ,即 -1),表示需要对后继节点进行唤醒,因此这里提前清除头节点的状态标识,将状态修改为 0,表示已经执行了对后续节点唤醒的操作。\n如果 s == null 或者 s.waitStatus \u0026gt; 0 ,表明后继节点异常,此时不能唤醒异常节点,而是要找到正常状态的节点进行唤醒。\n因此需要从 tail 指针向前遍历,来找到第一个状态正常(waitStatus \u0026lt;= 0)的节点进行唤醒。\n为什么要从 tail 指针向前遍历,而不是从 head 指针向后遍历,寻找正常状态的节点呢?\n遍历的方向和 节点的入队操作 有关。入队方法如下:\n// AQS:节点入队方法 private Node addWaiter(Node mode) { Node node = new Node(Thread.currentThread(), mode); Node pred = tail; if (pred != null) { // 1、先修改 prev 指针。 node.prev = pred; if (compareAndSetTail(pred, node)) { // 2、再修改 next 指针。 pred.next = node; return node; } } enq(node); return node; } 在 addWaiter() 方法中,node 节点入队需要修改 node.prev 和 pred.next 两个指针,但是这两个操作并不是 原子操作 ,先修改了 node.prev 指针,之后才修改 pred.next 指针。\n在极端情况下,可能会出现 head 节点的下一个节点状态为 CANCELLED ,此时新入队的节点仅更新了 node.prev 指针,还未更新 pred.next 指针,如下图:\n这样如果从 head 指针向后遍历,无法找到新入队的节点,因此需要从 tail 指针向前遍历找到新入队的节点。\n图解 AQS 工作原理(独占模式) # 至此,AQS 中以独占模式获取资源、释放资源的源码就讲完了。为了对 AQS 的工作原理、节点状态变化有一个更加清晰的认识,接下来会通过画图的方式来了解整个 AQS 的工作原理。\n由于 AQS 是底层同步工具,获取和释放资源的方法并没有提供具体实现,因此这里基于 ReentrantLock 来画图进行讲解。\n假设总共有 3 个线程尝试获取锁,线程分别为 T1 、 T2 和 T3 。\n此时,假设线程 T1 先获取到锁,线程 T2 排队等待获取锁。在线程 T2 进入队列之前,需要对 AQS 内部队列进行初始化。head 节点在初始化后状态为 0 。AQS 内部初始化后的队列如下图:\n此时,线程 T2 尝试获取锁。由于线程 T1 持有锁,因此线程 T2 会进入队列中等待获取锁。同时会将前继节点( head 节点)的状态由 0 更新为 SIGNAL ,表示需要对 head 节点的后继节点进行唤醒。此时,AQS 内部队列如下图所示:\n此时,线程 T3 尝试获取锁。由于线程 T1 持有锁,因此线程 T3 会进入队列中等待获取锁。同时会将前继节点(线程 T2 节点)的状态由 0 更新为 SIGNAL ,表示线程 T2 节点需要对后继节点进行唤醒。此时,AQS 内部队列如下图所示:\n此时,假设线程 T1 释放锁,会唤醒后继节点 T2 。线程 T2 被唤醒后获取到锁,并且会从等待队列中退出。\n这里线程 T2 节点退出等待队列并不是直接从队列移除,而是令线程 T2 节点成为新的 head 节点,以此来退出资源获取的等待。此时 AQS 内部队列如下所示:\n此时,假设线程 T2 释放锁,会唤醒后继节点 T3 。线程 T3 获取到锁之后,同样也退出等待队列,即将线程 T3 节点变为 head 节点来退出资源获取的等待。此时 AQS 内部队列如下所示:\nAQS 资源获取源码分析(共享模式) # AQS 中以独占模式获取资源的入口方法是 acquireShared() ,如下:\n// AQS public final void acquireShared(int arg) { if (tryAcquireShared(arg) \u0026lt; 0) doAcquireShared(arg); } 在 acquireShared() 方法中,会先尝试获取共享锁,如果获取失败,则将当前线程加入到队列中阻塞,等待唤醒后尝试获取共享锁,分别对应一下两个方法:tryAcquireShared() 和 doAcquireShared() 。\n其中 tryAcquireShared() 方法是 AQS 提供的模板方法,由同步器来实现具体逻辑。因此这里以 Semaphore 为例,来分析共享模式下,如何获取资源。\ntryAcquireShared() 分析 # Semaphore 中实现了公平锁和非公平锁,接下来以非公平锁为例来分析 tryAcquireShared() 源码。\nSemaphore 中重写的 tryAcquireShared() 方法会调用下边的 nonfairTryAcquireShared() 方法:\n// Semaphore 重写 AQS 的模板方法 protected int tryAcquireShared(int acquires) { return nonfairTryAcquireShared(acquires); } // Semaphore final int nonfairTryAcquireShared(int acquires) { for (;;) { // 1、获取可用资源数量。 int available = getState(); // 2、计算剩余资源数量。 int remaining = available - acquires; // 3、如果剩余资源数量 \u0026lt; 0,则说明资源不足,直接返回;如果 CAS 更新 state 成功,则说明当前线程获取到了共享资源,直接返回。 if (remaining \u0026lt; 0 || compareAndSetState(available, remaining)) return remaining; } } 在共享模式下,AQS 中的 state 值表示共享资源的数量。\n在 nonfairTryAcquireShared() 方法中,会在死循环中不断尝试获取资源,如果 「剩余资源数不足」 或者 「当前线程成功获取资源」 ,就退出死循环。方法返回 剩余的资源数量 ,根据返回值的不同,分为 3 种情况:\n剩余资源数量 \u0026gt; 0 :表示成功获取资源,并且后续的线程也可以成功获取资源。 剩余资源数量 = 0 :表示成功获取资源,但是后续的线程无法成功获取资源。 剩余资源数量 \u0026lt; 0 :表示获取资源失败。 doAcquireShared() 分析 # 为了方便阅读,这里再贴一下获取资源的入口方法 acquireShared() :\n// AQS public final void acquireShared(int arg) { if (tryAcquireShared(arg) \u0026lt; 0) doAcquireShared(arg); } 在 acquireShared() 方法中,会先通过 tryAcquireShared() 尝试获取资源。\n如果发现方法的返回值 \u0026lt; 0 ,即剩余的资源数小于 0,则表明当前线程获取资源失败。因此会进入 doAcquireShared() 方法,将当前线程加入到 AQS 队列进行等待。如下:\n// AQS private void doAcquireShared(int arg) { // 1、将当前线程加入到队列中等待。 final Node node = addWaiter(Node.SHARED); boolean failed = true; try { boolean interrupted = false; for (;;) { final Node p = node.predecessor(); if (p == head) { // 2、如果当前线程是等待队列的第一个节点,则尝试获取资源。 int r = tryAcquireShared(arg); if (r \u0026gt;= 0) { // 3、将当前线程节点移出等待队列,并唤醒后续线程节点。 setHeadAndPropagate(node, r); p.next = null; // help GC if (interrupted) selfInterrupt(); failed = false; return; } } if (shouldParkAfterFailedAcquire(p, node) \u0026amp;\u0026amp; parkAndCheckInterrupt()) interrupted = true; } } finally { // 3、如果获取资源失败,就会取消获取资源,将节点状态更新为 CANCELLED。 if (failed) cancelAcquire(node); } } 由于当前线程已经尝试获取资源失败了,因此在 doAcquireShared() 方法中,需要将当前线程封装为 Node 节点,加入到队列中进行等待。\n以 共享模式 获取资源和 独占模式 获取资源最大的不同之处在于:共享模式下,资源的数量可能会大于 1,即可以多个线程同时持有资源。\n因此在共享模式下,当线程线程被唤醒之后,获取到了资源,如果发现还存在剩余资源,就会尝试唤醒后边的线程去尝试获取资源。对应的 setHeadAndPropagate() 方法如下:\n// AQS private void setHeadAndPropagate(Node node, int propagate) { Node h = head; // 1、将当前线程节点移出等待队列。 setHead(node); // 2、唤醒后续等待节点。 if (propagate \u0026gt; 0 || h == null || h.waitStatus \u0026lt; 0 || (h = head) == null || h.waitStatus \u0026lt; 0) { Node s = node.next; if (s == null || s.isShared()) doReleaseShared(); } } 在 setHeadAndPropagate() 方法中,唤醒后续节点需要满足一定的条件,主要需要满足 2 个条件:\npropagate \u0026gt; 0 :propagate 代表获取资源之后剩余的资源数量,如果 \u0026gt; 0 ,则可以唤醒后续线程去获取资源。 h.waitStatus \u0026lt; 0 :这里的 h 节点是执行 setHead() 之前的 head 节点。判断 head.waitStatus 时使用 \u0026lt; 0 ,主要为了确定 head 节点的状态为 SIGNAL 或 PROPAGATE 。如果 head 节点为 SIGNAL ,则可以唤醒后续节点;如果 head 节点状态为 PROPAGATE ,也可以唤醒后续节点(这是为了解决并发场景下出现的问题,后续会细讲)。 代码中关于 唤醒后续等待节点 的 if 判断稍微复杂一些,这里来讲一下为什么这样写:\nif (propagate \u0026gt; 0 || h == null || h.waitStatus \u0026lt; 0 || (h = head) == null || h.waitStatus \u0026lt; 0) h == null || h.waitStatus \u0026lt; 0 : h == null 用于防止空指针异常。正常情况下 h 不会为 null ,因为执行到这里之前,当前节点已经加入到队列中了,队列不可能还没有初始化。\nh.waitStatus \u0026lt; 0 主要判断 head 节点的状态是否为 SIGNAL 或者 PROPAGATE ,直接使用 \u0026lt; 0 来判断比较方便。\n(h = head) == null || h.waitStatus \u0026lt; 0 :如果到这里说明之前判断的 h.waitStatus \u0026lt; 0 ,说明存在并发。\n同时存在其他线程在唤醒后续节点,已经将 head 节点的值由 SIGNAL 修改为 0 了。因此,这里重新获取新的 head 节点,这次获取的 head 节点为通过 setHead() 设置的当前线程节点,之后再次判断 waitStatus 状态。\n如果 if 条件判断通过,就会走到 doReleaseShared() 方法唤醒后续等待节点,如下:\nprivate void doReleaseShared() { for (;;) { Node h = head; // 1、队列中至少需要一个等待的线程节点。 if (h != null \u0026amp;\u0026amp; h != tail) { int ws = h.waitStatus; // 2、如果 head 节点的状态为 SIGNAL,则可以唤醒后继节点。 if (ws == Node.SIGNAL) { // 2.1 清除 head 节点的 SIGNAL 状态,更新为 0。表示已经唤醒该节点的后继节点了。 if (!compareAndSetWaitStatus(h, Node.SIGNAL, 0)) continue; // 2.2 唤醒后继节点 unparkSuccessor(h); } // 3、如果 head 节点的状态为 0,则更新为 PROPAGATE。这是为了解决并发场景下存在的问题,接下来会细讲。 else if (ws == 0 \u0026amp;\u0026amp; !compareAndSetWaitStatus(h, 0, Node.PROPAGATE)) continue; } if (h == head) break; } } 在 doReleaseShared() 方法中,会判断 head 节点的 waitStatus 状态来决定接下来的操作,有两种情况:\nhead 节点的状态为 SIGNAL :表明 head 节点存在后继节点需要唤醒,因此通过 CAS 操作将 head 节点的 SIGNAL 状态更新为 0 。通过清除 SIGNAL 状态来表示已经对 head 节点的后继节点进行唤醒操作了。 head 节点的状态为 0 :表明存在并发情况,需要将 0 修改为 PROPAGATE 来保证在并发场景下可以正常唤醒线程。 为什么需要 PROPAGATE 状态? # 在 doReleaseShared() 释放资源时,第 3 步不太容易理解,即如果发现 head 节点的状态是 0 ,就将 head 节点的状态由 0 更新为 PROPAGATE 。\nAQS 中,Node 节点的 PROPAGATE 就是为了处理并发场景下可能出现的无法唤醒线程节点的问题。PROPAGATE 只在 doReleaseShared() 方法中用到一次。\n接下来通过案例分析,为什么需要 PROPAGATE 状态?\n在共享模式下,线程获取和释放资源的方法调用链如下:\n线程获取资源的方法调用链为: acquireShared() -\u0026gt; tryAcquireShared() -\u0026gt; 线程阻塞等待唤醒 -\u0026gt; tryAcquireShared() -\u0026gt; setHeadAndPropagate() -\u0026gt; if (剩余资源数 \u0026gt; 0) || (head.waitStatus \u0026lt; 0) 则唤醒后续节点 。\n线程释放资源的方法调用链为: releaseShared() -\u0026gt; tryReleaseShared() -\u0026gt; doReleaseShared() 。\n如果在释放资源时,没有将 head 节点的状态由 0 改为 PROPAGATE :\n假设总共有 4 个线程尝试以共享模式获取资源,总共有 2 个资源。初始 T3 和 T4 线程获取到了资源,T1 和 T2 线程没有获取到,因此在队列中排队等候。\n在时刻 1 时,线程 T1 和 T2 在等待队列中,T3 和 T4 持有资源。此时等待队列内节点以及对应状态为(括号内为节点的 waitStatus 状态):\nhead(-1) -\u0026gt; T1(-1) -\u0026gt; T2(0) 。\n在时刻 2 时,线程 T3 释放资源,通过 doReleaseShared() 方法将 head 节点的状态由 SIGNAL 更新为 0 ,并唤醒线程 T1 ,之后线程 T3 退出。\n线程 T1 被唤醒之后,通过 tryAcquireShared() 获取到资源,但是此时还未来得及执行 setHeadAndPropagate() 将自己设置为 head 节点。此时等待队列内节点状态为:\nhead(0) -\u0026gt; T1(-1) -\u0026gt; T2(0) 。\n在时刻 3 时,线程 T4 释放资源, 由于此时 head 节点的状态为 0 ,因此在 doReleaseShared() 方法中无法唤醒 head 的后继节点, 之后线程 T4 退出。\n在时刻 4 时,线程 T1 继续执行 setHeadAndPropagate() 方法将自己设置为 head 节点。\n但是此时由于线程 T1 执行 tryAcquireShared() 方法返回的剩余资源数为 0 ,并且 head 节点的状态为 0 ,因此线程 T1 并不会在 setHeadAndPropagate() 方法中唤醒后续节点。此时等待队列内节点状态为:\nhead(-1,线程 T1 节点) -\u0026gt; T2(0) 。\n此时,就导致线程 T2 节点在等待队列中,无法被唤醒。对应时刻表如下:\n时刻 线程 T1 线程 T2 线程 T3 线程 T4 等待队列 时刻 1 等待队列 等待队列 持有资源 持有资源 head(-1) -\u0026gt; T1(-1) -\u0026gt; T2(0) 时刻 2 (执行)被唤醒后,获取资源,但未来得及将自己设置为 head 节点 等待队列 (执行)释放资源 持有资源 head(0) -\u0026gt; T1(-1) -\u0026gt; T2(0) 时刻 3 等待队列 已退出 (执行)释放资源。但 head 节点状态为 0 ,无法唤醒后继节点 head(0) -\u0026gt; T1(-1) -\u0026gt; T2(0) 时刻 4 (执行)将自己设置为 head 节点 等待队列 已退出 已退出 head(-1,线程 T1 节点) -\u0026gt; T2(0) 如果在线程释放资源时,将 head 节点的状态由 0 改为 PROPAGATE ,则可以解决上边出现的并发问题,如下:\n在时刻 1 时,线程 T1 和 T2 在等待队列中,T3 和 T4 持有资源。此时等待队列内节点以及对应状态为:\nhead(-1) -\u0026gt; T1(-1) -\u0026gt; T2(0) 。\n在时刻 2 时,线程 T3 释放资源,通过 doReleaseShared() 方法将 head 节点的状态由 SIGNAL 更新为 0 ,并唤醒线程 T1 ,之后线程 T3 退出。\n线程 T1 被唤醒之后,通过 tryAcquireShared() 获取到资源,但是此时还未来得及执行 setHeadAndPropagate() 将自己设置为 head 节点。此时等待队列内节点状态为:\nhead(0) -\u0026gt; T1(-1) -\u0026gt; T2(0) 。\n在时刻 3 时,线程 T4 释放资源, 由于此时 head 节点的状态为 0 ,因此在 doReleaseShared() 方法中会将 head 节点的状态由 0 更新为 PROPAGATE , 之后线程 T4 退出。此时等待队列内节点状态为:\nhead(PROPAGATE) -\u0026gt; T1(-1) -\u0026gt; T2(0) 。\n在时刻 4 时,线程 T1 继续执行 setHeadAndPropagate() 方法将自己设置为 head 节点。此时等待队列内节点状态为:\nhead(-1,线程 T1 节点) -\u0026gt; T2(0) 。\n在时刻 5 时,虽然此时由于线程 T1 执行 tryAcquireShared() 方法返回的剩余资源数为 0 ,但是 head 节点状态为 PROPAGATE \u0026lt; 0 (这里的 head 节点是老的 head 节点,而不是刚成为 head 节点的线程 T1 节点)。\n因此线程 T1 会在 setHeadAndPropagate() 方法中唤醒后续 T2 节点,并将 head 节点的状态由 SIGNAL 更新为 0。此时等待队列内节点状态为:\nhead(0,线程 T1 节点) -\u0026gt; T2(0) 。\n在时刻 6 时,线程 T2 被唤醒后,获取到资源,并将自己设置为 head 节点。此时等待队列内节点状态为:\nhead(0,线程 T2 节点) 。\n有了 PROPAGATE 状态,就可以避免线程 T2 无法被唤醒的情况。对应时刻表如下:\n时刻 线程 T1 线程 T2 线程 T3 线程 T4 等待队列 时刻 1 等待队列 等待队列 持有资源 持有资源 head(-1) -\u0026gt; T1(-1) -\u0026gt; T2(0) 时刻 2 (执行)被唤醒后,获取资源,但未来得及将自己设置为 head 节点 等待队列 (执行)释放资源 持有资源 head(0) -\u0026gt; T1(-1) -\u0026gt; T2(0) 时刻 3 未继续向下执行 等待队列 已退出 (执行)释放资源。此时会将 head 节点状态由 0 更新为 PROPAGATE head(PROPAGATE) -\u0026gt; T1(-1) -\u0026gt; T2(0) 时刻 4 (执行)将自己设置为 head 节点 等待队列 已退出 已退出 head(-1,线程 T1 节点) -\u0026gt; T2(0) 时刻 5 (执行)由于 head 节点状态为 PROPAGATE \u0026lt; 0 ,因此会在 setHeadAndPropagate() 方法中唤醒后续节点,此时将新的 head 节点的状态由 SIGNAL 更新为 0 ,并唤醒线程 T2 等待队列 已退出 已退出 head(0,线程 T1 节点) -\u0026gt; T2(0) 时刻 6 已退出 (执行)线程 T2 被唤醒后,获取到资源,并将自己设置为 head 节点 已退出 已退出 head(0,线程 T2 节点) AQS 资源释放源码分析(共享模式) # AQS 中以共享模式释放资源的入口方法是 releaseShared() ,代码如下:\n// AQS public final boolean releaseShared(int arg) { if (tryReleaseShared(arg)) { doReleaseShared(); return true; } return false; } 其中 tryReleaseShared() 方法是 AQS 提供的模板方法,这里同样以 Semaphore 来讲解,如下:\n// Semaphore protected final boolean tryReleaseShared(int releases) { for (;;) { int current = getState(); int next = current + releases; if (next \u0026lt; current) // overflow throw new Error(\u0026#34;Maximum permit count exceeded\u0026#34;); if (compareAndSetState(current, next)) return true; } } 在 Semaphore 实现的 tryReleaseShared() 方法中,会在死循环内不断尝试释放资源,即通过 CAS 操作来更新 state 值。\n如果更新成功,则证明资源释放成功,会进入到 doReleaseShared() 方法。\ndoReleaseShared() 方法在前文获取资源(共享模式)的部分已进行了详细的源码分析,此处不再重复。\n常见同步工具类 # 下面介绍几个基于 AQS 的常见同步工具类。\nSemaphore(信号量) # 介绍 # synchronized 和 ReentrantLock 都是一次只允许一个线程访问某个资源,而Semaphore(信号量)可以用来控制同时访问特定资源的线程数量。\nSemaphore 的使用简单,我们这里假设有 N(N\u0026gt;5) 个线程来获取 Semaphore 中的共享资源,下面的代码表示同一时刻 N 个线程中只有 5 个线程能获取到共享资源,其他线程都会阻塞,只有获取到共享资源的线程才能执行。等到有线程释放了共享资源,其他阻塞的线程才能获取到。\n// 初始共享资源数量 final Semaphore semaphore = new Semaphore(5); // 获取1个许可 semaphore.acquire(); // 释放1个许可 semaphore.release(); 当初始的资源个数为 1 的时候,Semaphore 退化为排他锁。\nSemaphore 有两种模式:。\n公平模式: 调用 acquire() 方法的顺序就是获取许可证的顺序,遵循 FIFO; 非公平模式: 抢占式的。 Semaphore 对应的两个构造方法如下:\npublic Semaphore(int permits) { sync = new NonfairSync(permits); } public Semaphore(int permits, boolean fair) { sync = fair ? new FairSync(permits) : new NonfairSync(permits); } 这两个构造方法,都必须提供许可的数量,第二个构造方法可以指定是公平模式还是非公平模式,默认非公平模式。\nSemaphore 通常用于那些资源有明确访问数量限制的场景比如限流(仅限于单机模式,实际项目中推荐使用 Redis +Lua 来做限流)。\n原理 # Semaphore 是共享锁的一种实现,它默认构造 AQS 的 state 值为 permits,你可以将 permits 的值理解为许可证的数量,只有拿到许可证的线程才能执行。\n以无参 acquire 方法为例,调用semaphore.acquire() ,线程尝试获取许可证,如果 state \u0026gt; 0 的话,则表示可以获取成功,如果 state \u0026lt;= 0 的话,则表示许可证数量不足,获取失败。\n如果可以获取成功的话(state \u0026gt; 0 ),会尝试使用 CAS 操作去修改 state 的值 state=state-1。如果获取失败则会创建一个 Node 节点加入等待队列,挂起当前线程。\n// 获取1个许可证 public void acquire() throws InterruptedException { sync.acquireSharedInterruptibly(1); } // 获取一个或者多个许可证 public void acquire(int permits) throws InterruptedException { if (permits \u0026lt; 0) throw new IllegalArgumentException(); sync.acquireSharedInterruptibly(permits); } acquireSharedInterruptibly方法是 AbstractQueuedSynchronizer 中的默认实现。\n// 共享模式下获取许可证,获取成功则返回,失败则加入等待队列,挂起线程 public final void acquireSharedInterruptibly(int arg) throws InterruptedException { if (Thread.interrupted()) throw new InterruptedException(); // 尝试获取许可证,arg为获取许可证个数,当获取失败时,则创建一个节点加入等待队列,挂起当前线程。 if (tryAcquireShared(arg) \u0026lt; 0) doAcquireSharedInterruptibly(arg); } 这里再以非公平模式(NonfairSync)的为例,看看 tryAcquireShared 方法的实现。\n// 共享模式下尝试获取资源(在Semaphore中的资源即许可证): protected int tryAcquireShared(int acquires) { return nonfairTryAcquireShared(acquires); } // 非公平的共享模式获取许可证 final int nonfairTryAcquireShared(int acquires) { for (;;) { // 当前可用许可证数量 int available = getState(); /* * 尝试获取许可证,当前可用许可证数量小于等于0时,返回负值,表示获取失败, * 当前可用许可证大于0时才可能获取成功,CAS失败了会循环重新获取最新的值尝试获取 */ int remaining = available - acquires; if (remaining \u0026lt; 0 || compareAndSetState(available, remaining)) return remaining; } } 以无参 release 方法为例,调用semaphore.release(); ,线程尝试释放许可证,并使用 CAS 操作去修改 state 的值 state=state+1。释放许可证成功之后,同时会唤醒等待队列中的一个线程。被唤醒的线程会重新尝试去修改 state 的值 state=state-1 ,如果 state \u0026gt; 0 则获取令牌成功,否则重新进入等待队列,挂起线程。\n// 释放一个许可证 public void release() { sync.releaseShared(1); } // 释放一个或者多个许可证 public void release(int permits) { if (permits \u0026lt; 0) throw new IllegalArgumentException(); sync.releaseShared(permits); } releaseShared方法是 AbstractQueuedSynchronizer 中的默认实现。\n// 释放共享锁 // 如果 tryReleaseShared 返回 true,就唤醒等待队列中的一个或多个线程。 public final boolean releaseShared(int arg) { //释放共享锁 if (tryReleaseShared(arg)) { //释放当前节点的后置等待节点 doReleaseShared(); return true; } return false; } tryReleaseShared 方法是Semaphore 的内部类 Sync 重写的一个方法, AbstractQueuedSynchronizer中的默认实现仅仅抛出 UnsupportedOperationException 异常。\n// 内部类 Sync 中重写的一个方法 // 尝试释放资源 protected final boolean tryReleaseShared(int releases) { for (;;) { int current = getState(); // 可用许可证+1 int next = current + releases; if (next \u0026lt; current) // overflow throw new Error(\u0026#34;Maximum permit count exceeded\u0026#34;); // CAS修改state的值 if (compareAndSetState(current, next)) return true; } } 可以看到,上面提到的几个方法底层基本都是通过同步器 sync 实现的。Sync 是 CountDownLatch 的内部类 , 继承了 AbstractQueuedSynchronizer ,重写了其中的某些方法。并且,Sync 对应的还有两个子类 NonfairSync(对应非公平模式) 和 FairSync(对应公平模式)。\nprivate static final class Sync extends AbstractQueuedSynchronizer { // ... } static final class NonfairSync extends Sync { // ... } static final class FairSync extends Sync { // ... } 实战 # public class SemaphoreExample { // 请求的数量 private static final int threadCount = 550; public static void main(String[] args) throws InterruptedException { // 创建一个具有固定线程数量的线程池对象(如果这里线程池的线程数量给太少的话你会发现执行的很慢) ExecutorService threadPool = Executors.newFixedThreadPool(300); // 初始许可证数量 final Semaphore semaphore = new Semaphore(20); for (int i = 0; i \u0026lt; threadCount; i++) { final int threadnum = i; threadPool.execute(() -\u0026gt; {// Lambda 表达式的运用 try { semaphore.acquire();// 获取一个许可,所以可运行线程数量为20/1=20 test(threadnum); semaphore.release();// 释放一个许可 } catch (InterruptedException e) { // TODO Auto-generated catch block e.printStackTrace(); } }); } threadPool.shutdown(); System.out.println(\u0026#34;finish\u0026#34;); } public static void test(int threadnum) throws InterruptedException { Thread.sleep(1000);// 模拟请求的耗时操作 System.out.println(\u0026#34;threadnum:\u0026#34; + threadnum); Thread.sleep(1000);// 模拟请求的耗时操作 } } 执行 acquire() 方法阻塞,直到有一个许可证可以获得然后拿走一个许可证;每个 release 方法增加一个许可证,这可能会释放一个阻塞的 acquire() 方法。然而,其实并没有实际的许可证这个对象,Semaphore 只是维持了一个可获得许可证的数量。 Semaphore 经常用于限制获取某种资源的线程数量。\n当然一次也可以一次拿取和释放多个许可,不过一般没有必要这样做:\nsemaphore.acquire(5);// 获取5个许可,所以可运行线程数量为20/5=4 test(threadnum); semaphore.release(5);// 释放5个许可 除了 acquire() 方法之外,另一个比较常用的与之对应的方法是 tryAcquire() 方法,该方法如果获取不到许可就立即返回 false。\nissue645 补充内容:\nSemaphore 基于 AQS 实现,用于控制并发访问的线程数量,但它与共享锁的概念有所不同。Semaphore 的构造函数使用 permits 参数初始化 AQS 的 state 变量,该变量表示可用的许可数量。当线程调用 acquire() 方法尝试获取许可时,state 会原子性地减 1。如果 state 减 1 后大于等于 0,则 acquire() 成功返回,线程可以继续执行。如果 state 减 1 后小于 0,表示当前并发访问的线程数量已达到 permits 的限制,该线程会被放入 AQS 的等待队列并阻塞,而不是自旋等待。当其他线程完成任务并调用 release() 方法时,state 会原子性地加 1。release() 操作会唤醒 AQS 等待队列中的一个或多个阻塞线程。这些被唤醒的线程将再次尝试 acquire() 操作,竞争获取可用的许可。因此,Semaphore 通过控制许可数量来限制并发访问的线程数量,而不是通过自旋和共享锁机制。\nCountDownLatch (倒计时器) # 介绍 # CountDownLatch 允许 count 个线程阻塞在一个地方,直至所有线程的任务都执行完毕。\nCountDownLatch 是一次性的,计数器的值只能在构造方法中初始化一次,之后没有任何机制再次对其设置值,当 CountDownLatch 使用完毕后,它不能再次被使用。\n原理 # CountDownLatch 是共享锁的一种实现,它默认构造 AQS 的 state 值为 count。这个我们通过 CountDownLatch 的构造方法即可看出。\npublic CountDownLatch(int count) { if (count \u0026lt; 0) throw new IllegalArgumentException(\u0026#34;count \u0026lt; 0\u0026#34;); this.sync = new Sync(count); } private static final class Sync extends AbstractQueuedSynchronizer { Sync(int count) { setState(count); } //... } 当线程调用 countDown() 时,其实使用了tryReleaseShared方法以 CAS 的操作来减少 state,直至 state 为 0 。当 state 为 0 时,表示所有的线程都调用了 countDown 方法,那么在 CountDownLatch 上等待的线程就会被唤醒并继续执行。\npublic void countDown() { // Sync 是 CountDownLatch 的内部类 , 继承了 AbstractQueuedSynchronizer sync.releaseShared(1); } releaseShared方法是 AbstractQueuedSynchronizer 中的默认实现。\n// 释放共享锁 // 如果 tryReleaseShared 返回 true,就唤醒等待队列中的一个或多个线程。 public final boolean releaseShared(int arg) { //释放共享锁 if (tryReleaseShared(arg)) { //释放当前节点的后置等待节点 doReleaseShared(); return true; } return false; } tryReleaseShared 方法是CountDownLatch 的内部类 Sync 重写的一个方法, AbstractQueuedSynchronizer中的默认实现仅仅抛出 UnsupportedOperationException 异常。\n// 对 state 进行递减,直到 state 变成 0; // 只有 count 递减到 0 时,countDown 才会返回 true protected boolean tryReleaseShared(int releases) { // 自选检查 state 是否为 0 for (;;) { int c = getState(); // 如果 state 已经是 0 了,直接返回 false if (c == 0) return false; // 对 state 进行递减 int nextc = c-1; // CAS 操作更新 state 的值 if (compareAndSetState(c, nextc)) return nextc == 0; } } 以无参 await方法为例,当调用 await() 的时候,如果 state 不为 0,那就证明任务还没有执行完毕,await() 就会一直阻塞,也就是说 await() 之后的语句不会被执行(main 线程被加入到等待队列也就是 变体 CLH 队列中了)。然后,CountDownLatch 会自旋 CAS 判断 state == 0,如果 state == 0 的话,就会释放所有等待的线程,await() 方法之后的语句得到执行。\n// 等待(也可以叫做加锁) public void await() throws InterruptedException { sync.acquireSharedInterruptibly(1); } // 带有超时时间的等待 public boolean await(long timeout, TimeUnit unit) throws InterruptedException { return sync.tryAcquireSharedNanos(1, unit.toNanos(timeout)); } acquireSharedInterruptibly方法是 AbstractQueuedSynchronizer 中的默认实现。\n// 尝试获取锁,获取成功则返回,失败则加入等待队列,挂起线程 public final void acquireSharedInterruptibly(int arg) throws InterruptedException { if (Thread.interrupted()) throw new InterruptedException(); // 尝试获得锁,获取成功则返回 if (tryAcquireShared(arg) \u0026lt; 0) // 获取失败加入等待队列,挂起线程 doAcquireSharedInterruptibly(arg); } tryAcquireShared 方法是CountDownLatch 的内部类 Sync 重写的一个方法,其作用就是判断 state 的值是否为 0,是的话就返回 1,否则返回 -1。\nprotected int tryAcquireShared(int acquires) { return (getState() == 0) ? 1 : -1; } 实战 # CountDownLatch 的两种典型用法:\n某一线程在开始运行前等待 n 个线程执行完毕 : 将 CountDownLatch 的计数器初始化为 n (new CountDownLatch(n)),每当一个任务线程执行完毕,就将计数器减 1 (countdownlatch.countDown()),当计数器的值变为 0 时,在 CountDownLatch 上 await() 的线程就会被唤醒。一个典型应用场景就是启动一个服务时,主线程需要等待多个组件加载完毕,之后再继续执行。 实现多个线程开始执行任务的最大并行性:注意是并行性,不是并发,强调的是多个线程在某一时刻同时开始执行。类似于赛跑,将多个线程放到起点,等待发令枪响,然后同时开跑。做法是初始化一个共享的 CountDownLatch 对象,将其计数器初始化为 1 (new CountDownLatch(1)),多个线程在开始执行任务前首先 coundownlatch.await(),当主线程调用 countDown() 时,计数器变为 0,多个线程同时被唤醒。 CountDownLatch 代码示例:\npublic class CountDownLatchExample { // 请求的数量 private static final int THREAD_COUNT = 550; public static void main(String[] args) throws InterruptedException { // 创建一个具有固定线程数量的线程池对象(如果这里线程池的线程数量给太少的话你会发现执行的很慢) // 只是测试使用,实际场景请手动赋值线程池参数 ExecutorService threadPool = Executors.newFixedThreadPool(300); final CountDownLatch countDownLatch = new CountDownLatch(THREAD_COUNT); for (int i = 0; i \u0026lt; THREAD_COUNT; i++) { final int threadNum = i; threadPool.execute(() -\u0026gt; { try { test(threadNum); } catch (InterruptedException e) { e.printStackTrace(); } finally { // 表示一个请求已经被完成 countDownLatch.countDown(); } }); } countDownLatch.await(); threadPool.shutdown(); System.out.println(\u0026#34;finish\u0026#34;); } public static void test(int threadnum) throws InterruptedException { Thread.sleep(1000); System.out.println(\u0026#34;threadNum:\u0026#34; + threadnum); Thread.sleep(1000); } } 上面的代码中,我们定义了请求的数量为 550,当这 550 个请求被处理完成之后,才会执行System.out.println(\u0026quot;finish\u0026quot;);。\n与 CountDownLatch 的第一次交互是主线程等待其他线程。主线程必须在启动其他线程后立即调用 CountDownLatch.await() 方法。这样主线程的操作就会在这个方法上阻塞,直到其他线程完成各自的任务。\n其他 N 个线程必须引用闭锁对象,因为他们需要通知 CountDownLatch 对象,他们已经完成了各自的任务。这种通知机制是通过 CountDownLatch.countDown()方法来完成的;每调用一次这个方法,在构造函数中初始化的 count 值就减 1。所以当 N 个线程都调 用了这个方法,count 的值等于 0,然后主线程就能通过 await()方法,恢复执行自己的任务。\n再插一嘴:CountDownLatch 的 await() 方法使用不当很容易产生死锁,比如我们上面代码中的 for 循环改为:\nfor (int i = 0; i \u0026lt; threadCount-1; i++) { ....... } 这样就导致 count 的值没办法等于 0,然后就会导致一直等待。\nCyclicBarrier(循环栅栏) # 介绍 # CyclicBarrier 和 CountDownLatch 非常类似,它也可以实现线程间的技术等待,但是它的功能比 CountDownLatch 更加复杂和强大。主要应用场景和 CountDownLatch 类似。\nCountDownLatch 的实现是基于 AQS 的,而 CycliBarrier 是基于 ReentrantLock(ReentrantLock 也属于 AQS 同步器)和 Condition 的。\nCyclicBarrier 的字面意思是可循环使用(Cyclic)的屏障(Barrier)。它要做的事情是:让一组线程到达一个屏障(也可以叫同步点)时被阻塞,直到最后一个线程到达屏障时,屏障才会开门,所有被屏障拦截的线程才会继续干活。\n原理 # CyclicBarrier 内部通过一个 count 变量作为计数器,count 的初始值为 parties 属性的初始化值,每当一个线程到了栅栏这里了,那么就将计数器减 1。如果 count 值为 0 了,表示这是这一代最后一个线程到达栅栏,就尝试执行我们构造方法中输入的任务。\n//每次拦截的线程数 private final int parties; //计数器 private int count; 下面我们结合源码来简单看看。\n1、CyclicBarrier 默认的构造方法是 CyclicBarrier(int parties),其参数表示屏障拦截的线程数量,每个线程调用 await() 方法告诉 CyclicBarrier 我已经到达了屏障,然后当前线程被阻塞。\npublic CyclicBarrier(int parties) { this(parties, null); } public CyclicBarrier(int parties, Runnable barrierAction) { if (parties \u0026lt;= 0) throw new IllegalArgumentException(); this.parties = parties; this.count = parties; this.barrierCommand = barrierAction; } 其中,parties 就代表了有拦截的线程的数量,当拦截的线程数量达到这个值的时候就打开栅栏,让所有线程通过。\n2、当调用 CyclicBarrier 对象调用 await() 方法时,实际上调用的是 dowait(false, 0L)方法。 await() 方法就像树立起一个栅栏的行为一样,将线程挡住了,当拦住的线程数量达到 parties 的值时,栅栏才会打开,线程才得以通过执行。\npublic int await() throws InterruptedException, BrokenBarrierException { try { return dowait(false, 0L); } catch (TimeoutException toe) { throw new Error(toe); // cannot happen } } dowait(false, 0L)方法源码分析如下:\n// 当线程数量或者请求数量达到 count 时 await 之后的方法才会被执行。上面的示例中 count 的值就为 5。 private int count; /** * Main barrier code, covering the various policies. */ private int dowait(boolean timed, long nanos) throws InterruptedException, BrokenBarrierException, TimeoutException { final ReentrantLock lock = this.lock; // 锁住 lock.lock(); try { final Generation g = generation; if (g.broken) throw new BrokenBarrierException(); // 如果线程中断了,抛出异常 if (Thread.interrupted()) { breakBarrier(); throw new InterruptedException(); } // count 减1 int index = --count; // 当 count 数量减为 0 之后说明最后一个线程已经到达栅栏了,也就是达到了可以执行await 方法之后的条件 if (index == 0) { // tripped boolean ranAction = false; try { final Runnable command = barrierCommand; if (command != null) command.run(); ranAction = true; // 将 count 重置为 parties 属性的初始化值 // 唤醒之前等待的线程 // 下一波执行开始 nextGeneration(); return 0; } finally { if (!ranAction) breakBarrier(); } } // loop until tripped, broken, interrupted, or timed out for (;;) { try { if (!timed) trip.await(); else if (nanos \u0026gt; 0L) nanos = trip.awaitNanos(nanos); } catch (InterruptedException ie) { if (g == generation \u0026amp;\u0026amp; ! g.broken) { breakBarrier(); throw ie; } else { // We\u0026#39;re about to finish waiting even if we had not // been interrupted, so this interrupt is deemed to // \u0026#34;belong\u0026#34; to subsequent execution. Thread.currentThread().interrupt(); } } if (g.broken) throw new BrokenBarrierException(); if (g != generation) return index; if (timed \u0026amp;\u0026amp; nanos \u0026lt;= 0L) { breakBarrier(); throw new TimeoutException(); } } } finally { lock.unlock(); } } 实战 # 示例 1:\npublic class CyclicBarrierExample1 { // 请求的数量 private static final int threadCount = 550; // 需要同步的线程数量 private static final CyclicBarrier cyclicBarrier = new CyclicBarrier(5); public static void main(String[] args) throws InterruptedException { // 创建线程池 ExecutorService threadPool = Executors.newFixedThreadPool(10); for (int i = 0; i \u0026lt; threadCount; i++) { final int threadNum = i; Thread.sleep(1000); threadPool.execute(() -\u0026gt; { try { test(threadNum); } catch (InterruptedException e) { // TODO Auto-generated catch block e.printStackTrace(); } catch (BrokenBarrierException e) { // TODO Auto-generated catch block e.printStackTrace(); } }); } threadPool.shutdown(); } public static void test(int threadnum) throws InterruptedException, BrokenBarrierException { System.out.println(\u0026#34;threadnum:\u0026#34; + threadnum + \u0026#34;is ready\u0026#34;); try { /**等待60秒,保证子线程完全执行结束*/ cyclicBarrier.await(60, TimeUnit.SECONDS); } catch (Exception e) { System.out.println(\u0026#34;-----CyclicBarrierException------\u0026#34;); } System.out.println(\u0026#34;threadnum:\u0026#34; + threadnum + \u0026#34;is finish\u0026#34;); } } 运行结果,如下:\nthreadnum:0is ready threadnum:1is ready threadnum:2is ready threadnum:3is ready threadnum:4is ready threadnum:4is finish threadnum:0is finish threadnum:1is finish threadnum:2is finish threadnum:3is finish threadnum:5is ready threadnum:6is ready threadnum:7is ready threadnum:8is ready threadnum:9is ready threadnum:9is finish threadnum:5is finish threadnum:8is finish threadnum:7is finish threadnum:6is finish ...... 可以看到当线程数量也就是请求数量达到我们定义的 5 个的时候, await() 方法之后的方法才被执行。\n另外,CyclicBarrier 还提供一个更高级的构造函数 CyclicBarrier(int parties, Runnable barrierAction),用于在线程到达屏障时,优先执行 barrierAction,方便处理更复杂的业务场景。\n示例 2:\npublic class CyclicBarrierExample2 { // 请求的数量 private static final int threadCount = 550; // 需要同步的线程数量 private static final CyclicBarrier cyclicBarrier = new CyclicBarrier(5, () -\u0026gt; { System.out.println(\u0026#34;------当线程数达到之后,优先执行------\u0026#34;); }); public static void main(String[] args) throws InterruptedException { // 创建线程池 ExecutorService threadPool = Executors.newFixedThreadPool(10); for (int i = 0; i \u0026lt; threadCount; i++) { final int threadNum = i; Thread.sleep(1000); threadPool.execute(() -\u0026gt; { try { test(threadNum); } catch (InterruptedException e) { // TODO Auto-generated catch block e.printStackTrace(); } catch (BrokenBarrierException e) { // TODO Auto-generated catch block e.printStackTrace(); } }); } threadPool.shutdown(); } public static void test(int threadnum) throws InterruptedException, BrokenBarrierException { System.out.println(\u0026#34;threadnum:\u0026#34; + threadnum + \u0026#34;is ready\u0026#34;); cyclicBarrier.await(); System.out.println(\u0026#34;threadnum:\u0026#34; + threadnum + \u0026#34;is finish\u0026#34;); } } 运行结果,如下:\nthreadnum:0is ready threadnum:1is ready threadnum:2is ready threadnum:3is ready threadnum:4is ready ------当线程数达到之后,优先执行------ threadnum:4is finish threadnum:0is finish threadnum:2is finish threadnum:1is finish threadnum:3is finish threadnum:5is ready threadnum:6is ready threadnum:7is ready threadnum:8is ready threadnum:9is ready ------当线程数达到之后,优先执行------ threadnum:9is finish threadnum:5is finish threadnum:6is finish threadnum:8is finish threadnum:7is finish ...... 参考 # Java 并发之 AQS 详解: https://www.cnblogs.com/waterystone/p/4920797.html 从 ReentrantLock 的实现看 AQS 的原理及应用: https://tech.meituan.com/2019/12/05/aqs-theory-and-apply.html "},{"id":455,"href":"/zh/docs/technology/Interview/cs-basics/network/arp/","title":"ARP 协议详解(网络层)","section":"Network","content":"每当我们学习一个新的网络协议的时候,都要把他结合到 OSI 七层模型中,或者是 TCP/IP 协议栈中来学习,一是要学习该协议在整个网络协议栈中的位置,二是要学习该协议解决了什么问题,地位如何?三是要学习该协议的工作原理,以及一些更深入的细节。\nARP 协议,可以说是在协议栈中属于一个偏底层的、非常重要的、又非常简单的通信协议。\n开始阅读这篇文章之前,你可以先看看下面几个问题:\nARP 协议在协议栈中的位置? ARP 协议在协议栈中的位置非常重要,在理解了它的工作原理之后,也很难说它到底是网络层协议,还是链路层协议,因为它恰恰串联起了网络层和链路层。国外的大部分教程通常将 ARP 协议放在网络层。 ARP 协议解决了什么问题,地位如何? ARP 协议,全称 地址解析协议(Address Resolution Protocol),它解决的是网络层地址和链路层地址之间的转换问题。因为一个 IP 数据报在物理上传输的过程中,总是需要知道下一跳(物理上的下一个目的地)该去往何处,但 IP 地址属于逻辑地址,而 MAC 地址才是物理地址,ARP 协议解决了 IP 地址转 MAC 地址的一些问题。 ARP 工作原理? 只希望大家记住几个关键词:ARP 表、广播问询、单播响应。 MAC 地址 # 在介绍 ARP 协议之前,有必要介绍一下 MAC 地址。\nMAC 地址的全称是 媒体访问控制地址(Media Access Control Address)。如果说,互联网中每一个资源都由 IP 地址唯一标识(IP 协议内容),那么一切网络设备都由 MAC 地址唯一标识。\n可以理解为,MAC 地址是一个网络设备真正的身份证号,IP 地址只是一种不重复的定位方式(比如说住在某省某市某街道的张三,这种逻辑定位是 IP 地址,他的身份证号才是他的 MAC 地址),也可以理解为 MAC 地址是身份证号,IP 地址是邮政地址。MAC 地址也有一些别称,如 LAN 地址、物理地址、以太网地址等。\n还有一点要知道的是,不仅仅是网络资源才有 IP 地址,网络设备也有 IP 地址,比如路由器。但从结构上说,路由器等网络设备的作用是组成一个网络,而且通常是内网,所以它们使用的 IP 地址通常是内网 IP,内网的设备在与内网以外的设备进行通信时,需要用到 NAT 协议。\nMAC 地址的长度为 6 字节(48 比特),地址空间大小有 280 万亿之多($2^{48}$),MAC 地址由 IEEE 统一管理与分配,理论上,一个网络设备中的网卡上的 MAC 地址是永久的。不同的网卡生产商从 IEEE 那里购买自己的 MAC 地址空间(MAC 的前 24 比特),也就是前 24 比特由 IEEE 统一管理,保证不会重复。而后 24 比特,由各家生产商自己管理,同样保证生产的两块网卡的 MAC 地址不会重复。\nMAC 地址具有可携带性、永久性,身份证号永久地标识一个人的身份,不论他到哪里都不会改变。而 IP 地址不具有这些性质,当一台设备更换了网络,它的 IP 地址也就可能发生改变,也就是它在互联网中的定位发生了变化。\n最后,记住,MAC 地址有一个特殊地址:FF-FF-FF-FF-FF-FF(全 1 地址),该地址表示广播地址。\nARP 协议工作原理 # ARP 协议工作时有一个大前提,那就是 ARP 表。\n在一个局域网内,每个网络设备都自己维护了一个 ARP 表,ARP 表记录了某些其他网络设备的 IP 地址-MAC 地址映射关系,该映射关系以 \u0026lt;IP, MAC, TTL\u0026gt; 三元组的形式存储。其中,TTL 为该映射关系的生存周期,典型值为 20 分钟,超过该时间,该条目将被丢弃。\nARP 的工作原理将分两种场景讨论:\n同一局域网内的 MAC 寻址; 从一个局域网到另一个局域网中的网络设备的寻址。 同一局域网内的 MAC 寻址 # 假设当前有如下场景:IP 地址为137.196.7.23的主机 A,想要给同一局域网内的 IP 地址为137.196.7.14主机 B,发送 IP 数据报文。\n再次强调,当主机发送 IP 数据报文时(网络层),仅知道目的地的 IP 地址,并不清楚目的地的 MAC 地址,而 ARP 协议就是解决这一问题的。\n为了达成这一目标,主机 A 将不得不通过 ARP 协议来获取主机 B 的 MAC 地址,并将 IP 报文封装成链路层帧,发送到下一跳上。在该局域网内,关于此将按照时间顺序,依次发生如下事件:\n主机 A 检索自己的 ARP 表,发现 ARP 表中并无主机 B 的 IP 地址对应的映射条目,也就无从知道主机 B 的 MAC 地址。\n主机 A 将构造一个 ARP 查询分组,并将其广播到所在的局域网中。\nARP 分组是一种特殊报文,ARP 分组有两类,一种是查询分组,另一种是响应分组,它们具有相同的格式,均包含了发送和接收的 IP 地址、发送和接收的 MAC 地址。当然了,查询分组中,发送的 IP 地址,即为主机 A 的 IP 地址,接收的 IP 地址即为主机 B 的 IP 地址,发送的 MAC 地址也是主机 A 的 MAC 地址,但接收的 MAC 地址绝不会是主机 B 的 MAC 地址(因为这正是我们要问询的!),而是一个特殊值——FF-FF-FF-FF-FF-FF,之前说过,该 MAC 地址是广播地址,也就是说,查询分组将广播给该局域网内的所有设备。\n主机 A 构造的查询分组将在该局域网内广播,理论上,每一个设备都会收到该分组,并检查查询分组的接收 IP 地址是否为自己的 IP 地址,如果是,说明查询分组已经到达了主机 B,否则,该查询分组对当前设备无效,丢弃之。\n主机 B 收到了查询分组之后,验证是对自己的问询,接着构造一个 ARP 响应分组,该分组的目的地只有一个——主机 A,发送给主机 A。同时,主机 B 提取查询分组中的 IP 地址和 MAC 地址信息,在自己的 ARP 表中构造一条主机 A 的 IP-MAC 映射记录。\nARP 响应分组具有和 ARP 查询分组相同的构造,不同的是,发送和接受的 IP 地址恰恰相反,发送的 MAC 地址为发送者本身,目标 MAC 地址为查询分组的发送者,也就是说,ARP 响应分组只有一个目的地,而非广播。\n主机 A 终将收到主机 B 的响应分组,提取出该分组中的 IP 地址和 MAC 地址后,构造映射信息,加入到自己的 ARP 表中。\n在整个过程中,有几点需要补充说明的是:\n主机 A 想要给主机 B 发送 IP 数据报,如果主机 B 的 IP-MAC 映射信息已经存在于主机 A 的 ARP 表中,那么主机 A 无需广播,只需提取 MAC 地址并构造链路层帧发送即可。 ARP 表中的映射信息是有生存周期的,典型值为 20 分钟。 目标主机接收到了问询主机构造的问询报文后,将先把问询主机的 IP-MAC 映射存进自己的 ARP 表中,这样才能获取到响应的目标 MAC 地址,顺利的发送响应分组。 总结来说,ARP 协议是一个广播问询,单播响应协议。\n不同局域网内的 MAC 寻址 # 更复杂的情况是,发送主机 A 和接收主机 B 不在同一个子网中,假设一个一般场景,两台主机所在的子网由一台路由器联通。这里需要注意的是,一般情况下,我们说网络设备都有一个 IP 地址和一个 MAC 地址,这里说的网络设备,更严谨的说法应该是一个接口。路由器作为互联设备,具有多个接口,每个接口同样也应该具备不重复的 IP 地址和 MAC 地址。因此,在讨论 ARP 表时,路由器的多个接口都各自维护一个 ARP 表,而非一个路由器只维护一个 ARP 表。\n接下来,回顾同一子网内的 MAC 寻址,如果主机 A 发送一个广播问询分组,那么 A 所在的子网内所有设备(接口)都将会捕获该分组,因为该分组的目的 IP 与发送主机 A 的 IP 在同一个子网中。但是当目的 IP 与 A 不在同一子网时,A 所在子网内将不会有设备成功接收该分组。那么,主机 A 应该发送怎样的查询分组呢?整个过程按照时间顺序发生的事件如下:\n主机 A 查询 ARP 表,期望寻找到目标路由器的本子网接口的 MAC 地址。\n目标路由器指的是,根据目的主机 B 的 IP 地址,分析出 B 所在的子网,能够把报文转发到 B 所在子网的那个路由器。\n主机 A 未能找到目标路由器的本子网接口的 MAC 地址,将采用 ARP 协议,问询到该 MAC 地址,由于目标接口与主机 A 在同一个子网内,该过程与同一局域网内的 MAC 寻址相同。\n主机 A 获取到目标接口的 MAC 地址,先构造 IP 数据报,其中源 IP 是 A 的 IP 地址,目的 IP 地址是 B 的 IP 地址,再构造链路层帧,其中源 MAC 地址是 A 的 MAC 地址,目的 MAC 地址是本子网内与路由器连接的接口的 MAC 地址。主机 A 将把这个链路层帧,以单播的方式,发送给目标接口。\n目标接口接收到了主机 A 发过来的链路层帧,解析,根据目的 IP 地址,查询转发表,将该 IP 数据报转发到与主机 B 所在子网相连的接口上。\n到此,该帧已经从主机 A 所在的子网,转移到了主机 B 所在的子网了。\n路由器接口查询 ARP 表,期望寻找到主机 B 的 MAC 地址。\n路由器接口如未能找到主机 B 的 MAC 地址,将采用 ARP 协议,广播问询,单播响应,获取到主机 B 的 MAC 地址。\n路由器接口将对 IP 数据报重新封装成链路层帧,目标 MAC 地址为主机 B 的 MAC 地址,单播发送,直到目的地。\n"},{"id":456,"href":"/zh/docs/technology/Interview/java/collection/arrayblockingqueue-source-code/","title":"ArrayBlockingQueue 源码分析","section":"Collection","content":" 阻塞队列简介 # 阻塞队列的历史 # Java 阻塞队列的历史可以追溯到 JDK1.5 版本,当时 Java 平台增加了 java.util.concurrent,即我们常说的 JUC 包,其中包含了各种并发流程控制工具、并发容器、原子类等。这其中自然也包含了我们这篇文章所讨论的阻塞队列。\n为了解决高并发场景下多线程之间数据共享的问题,JDK1.5 版本中出现了 ArrayBlockingQueue 和 LinkedBlockingQueue,它们是带有生产者-消费者模式实现的并发容器。其中,ArrayBlockingQueue 是有界队列,即添加的元素达到上限之后,再次添加就会被阻塞或者抛出异常。而 LinkedBlockingQueue 则由链表构成的队列,正是因为链表的特性,所以 LinkedBlockingQueue 在添加元素上并不会向 ArrayBlockingQueue 那样有着较多的约束,所以 LinkedBlockingQueue 设置队列是否有界是可选的(注意这里的无界并不是指可以添加任务数量的元素,而是说队列的大小默认为 Integer.MAX_VALUE,近乎于无限大)。\n随着 Java 的不断发展,JDK 后续的几个版本又对阻塞队列进行了不少的更新和完善:\nJDK1.6 版本:增加 SynchronousQueue,一个不存储元素的阻塞队列。 JDK1.7 版本:增加 TransferQueue,一个支持更多操作的阻塞队列。 JDK1.8 版本:增加 DelayQueue,一个支持延迟获取元素的阻塞队列。 阻塞队列的思想 # 阻塞队列就是典型的生产者-消费者模型,它可以做到以下几点:\n当阻塞队列数据为空时,所有的消费者线程都会被阻塞,等待队列非空。 当生产者往队列里填充数据后,队列就会通知消费者队列非空,消费者此时就可以进来消费。 当阻塞队列因为消费者消费过慢或者生产者存放元素过快导致队列填满时无法容纳新元素时,生产者就会被阻塞,等待队列非满时继续存放元素。 当消费者从队列中消费一个元素之后,队列就会通知生产者队列非满,生产者可以继续填充数据了。 总结一下:阻塞队列就说基于非空和非满两个条件实现生产者和消费者之间的交互,尽管这些交互流程和等待通知的机制实现非常复杂,好在 Doug Lea 的操刀之下已将阻塞队列的细节屏蔽,我们只需调用 put、take、offer、poll 等 API 即可实现多线程之间的生产和消费。\n这也使得阻塞队列在多线程开发中有着广泛的运用,最常见的例子无非是我们的线程池,从源码中我们就能看出当核心线程无法及时处理任务时,这些任务都会扔到 workQueue 中。\npublic ThreadPoolExecutor(int corePoolSize, int maximumPoolSize, long keepAliveTime, TimeUnit unit, BlockingQueue\u0026lt;Runnable\u0026gt; workQueue, ThreadFactory threadFactory, RejectedExecutionHandler handler) {// ...} ArrayBlockingQueue 常见方法及测试 # 简单了解了阻塞队列的历史之后,我们就开始重点讨论本篇文章所要介绍的并发容器——ArrayBlockingQueue。为了后续更加深入的了解 ArrayBlockingQueue,我们不妨基于下面几个实例了解以下 ArrayBlockingQueue 的使用。\n先看看第一个例子,我们这里会用两个线程分别模拟生产者和消费者,生产者生产完会使用 put 方法生产 10 个元素给消费者进行消费,当队列元素达到我们设置的上限 5 时,put 方法就会阻塞。 同理消费者也会通过 take 方法消费元素,当队列为空时,take 方法就会阻塞消费者线程。这里笔者为了保证消费者能够在消费完 10 个元素后及时退出。便通过倒计时门闩,来控制消费者结束,生产者在这里只会生产 10 个元素。当消费者将 10 个元素消费完成之后,按下倒计时门闩,所有线程都会停止。\npublic class ProducerConsumerExample { public static void main(String[] args) throws InterruptedException { // 创建一个大小为 5 的 ArrayBlockingQueue ArrayBlockingQueue\u0026lt;Integer\u0026gt; queue = new ArrayBlockingQueue\u0026lt;\u0026gt;(5); // 创建生产者线程 Thread producer = new Thread(() -\u0026gt; { try { for (int i = 1; i \u0026lt;= 10; i++) { // 向队列中添加元素,如果队列已满则阻塞等待 queue.put(i); System.out.println(\u0026#34;生产者添加元素:\u0026#34; + i); } } catch (InterruptedException e) { e.printStackTrace(); } }); CountDownLatch countDownLatch = new CountDownLatch(1); // 创建消费者线程 Thread consumer = new Thread(() -\u0026gt; { try { int count = 0; while (true) { // 从队列中取出元素,如果队列为空则阻塞等待 int element = queue.take(); System.out.println(\u0026#34;消费者取出元素:\u0026#34; + element); ++count; if (count == 10) { break; } } countDownLatch.countDown(); } catch (InterruptedException e) { e.printStackTrace(); } }); // 启动线程 producer.start(); consumer.start(); // 等待线程结束 producer.join(); consumer.join(); countDownLatch.await(); producer.interrupt(); consumer.interrupt(); } } 代码输出结果如下,可以看到只有生产者往队列中投放元素之后消费者才能消费,这也就意味着当队列中没有数据的时消费者就会阻塞,等待队列非空再继续消费。\n生产者添加元素:1 生产者添加元素:2 消费者取出元素:1 消费者取出元素:2 生产者添加元素:3 消费者取出元素:3 生产者添加元素:4 生产者添加元素:5 消费者取出元素:4 生产者添加元素:6 消费者取出元素:5 生产者添加元素:7 生产者添加元素:8 生产者添加元素:9 生产者添加元素:10 消费者取出元素:6 消费者取出元素:7 消费者取出元素:8 消费者取出元素:9 消费者取出元素:10 了解了 put、take 这两个会阻塞的存和取方法之后,我我们再来看看阻塞队列中非阻塞的入队和出队方法 offer 和 poll。\n如下所示,我们设置了一个大小为 3 的阻塞队列,我们会尝试在队列用 offer 方法存放 4 个元素,然后再从队列中用 poll 尝试取 4 次。\npublic class OfferPollExample { public static void main(String[] args) { // 创建一个大小为 3 的 ArrayBlockingQueue ArrayBlockingQueue\u0026lt;String\u0026gt; queue = new ArrayBlockingQueue\u0026lt;\u0026gt;(3); // 向队列中添加元素 System.out.println(queue.offer(\u0026#34;A\u0026#34;)); System.out.println(queue.offer(\u0026#34;B\u0026#34;)); System.out.println(queue.offer(\u0026#34;C\u0026#34;)); // 尝试向队列中添加元素,但队列已满,返回 false System.out.println(queue.offer(\u0026#34;D\u0026#34;)); // 从队列中取出元素 System.out.println(queue.poll()); System.out.println(queue.poll()); System.out.println(queue.poll()); // 尝试从队列中取出元素,但队列已空,返回 null System.out.println(queue.poll()); } } 最终代码的输出结果如下,可以看到因为队列的大小为 3 的缘故,我们前 3 次存放到队列的结果为 true,第 4 次存放时,由于队列已满,所以存放结果返回 false。这也是为什么我们后续的 poll 方法只得到了 3 个元素的值。\ntrue true true false A B C null 了解了阻塞存取和非阻塞存取,我们再来看看阻塞队列的一个比较特殊的操作,某些场景下,我们希望能够一次性将阻塞队列的结果存到列表中再进行批量操作,我们就可以使用阻塞队列的 drainTo 方法,这个方法会一次性将队列中所有元素存放到列表,如果队列中有元素,且成功存到 list 中则 drainTo 会返回本次转移到 list 中的元素数,反之若队列为空,drainTo 则直接返回 0。\npublic class DrainToExample { public static void main(String[] args) { // 创建一个大小为 5 的 ArrayBlockingQueue ArrayBlockingQueue\u0026lt;Integer\u0026gt; queue = new ArrayBlockingQueue\u0026lt;\u0026gt;(5); // 向队列中添加元素 queue.add(1); queue.add(2); queue.add(3); queue.add(4); queue.add(5); // 创建一个 List,用于存储从队列中取出的元素 List\u0026lt;Integer\u0026gt; list = new ArrayList\u0026lt;\u0026gt;(); // 从队列中取出所有元素,并添加到 List 中 queue.drainTo(list); // 输出 List 中的元素 System.out.println(list); } } 代码输出结果如下\n[1, 2, 3, 4, 5] ArrayBlockingQueue 源码分析 # 自此我们对阻塞队列的使用有了基本的印象,接下来我们就可以进一步了解一下 ArrayBlockingQueue 的工作机制了。\n整体设计 # 在了解 ArrayBlockingQueue 的具体细节之前,我们先来看看 ArrayBlockingQueue 的类图。\n从图中我们可以看出,ArrayBlockingQueue 继承了阻塞队列 BlockingQueue 这个接口,不难猜出通过继承 BlockingQueue 这个接口之后,ArrayBlockingQueue 就拥有了阻塞队列那些常见的操作行为。\n同时, ArrayBlockingQueue 还继承了 AbstractQueue 这个抽象类,这个继承了 AbstractCollection 和 Queue 的抽象类,从抽象类的特定和语义我们也可以猜出,这个继承关系使得 ArrayBlockingQueue 拥有了队列的常见操作。\n所以我们是否可以得出这样一个结论,通过继承 AbstractQueue 获得队列所有的操作模板,其实现的入队和出队操作的整体框架。然后 ArrayBlockingQueue 通过继承 BlockingQueue 获取到阻塞队列的常见操作并将这些操作实现,填充到 AbstractQueue 模板方法的细节中,由此 ArrayBlockingQueue 成为一个完整的阻塞队列。\n为了印证这一点,我们到源码中一探究竟。首先我们先来看看 AbstractQueue,从类的继承关系我们可以大致得出,它通过 AbstractCollection 获得了集合的常见操作方法,然后通过 Queue 接口获得了队列的特性。\npublic abstract class AbstractQueue\u0026lt;E\u0026gt; extends AbstractCollection\u0026lt;E\u0026gt; implements Queue\u0026lt;E\u0026gt; { //... } 对于集合的操作无非是增删改查,所以我们不妨从添加方法入手,从源码中我们可以看到,它实现了 AbstractCollection 的 add 方法,其内部逻辑如下:\n调用继承 Queue 接口的来的 offer 方法,如果 offer 成功则返回 true。 如果 offer 失败,即代表当前元素入队失败直接抛异常。 public boolean add(E e) { if (offer(e)) return true; else throw new IllegalStateException(\u0026#34;Queue full\u0026#34;); } 而 AbstractQueue 中并没有对 Queue 的 offer 的实现,很明显这样做的目的是定义好了 add 的核心逻辑,将 offer 的细节交由其子类即我们的 ArrayBlockingQueue 实现。\n到此,我们对于抽象类 AbstractQueue 的分析就结束了,我们继续看看 ArrayBlockingQueue 中另一个重要的继承接口 BlockingQueue。\n点开 BlockingQueue 之后,我们可以看到这个接口同样继承了 Queue 接口,这就意味着它也具备了队列所拥有的所有行为。同时,它还定义了自己所需要实现的方法。\npublic interface BlockingQueue\u0026lt;E\u0026gt; extends Queue\u0026lt;E\u0026gt; { //元素入队成功返回true,反之则会抛出异常IllegalStateException boolean add(E e); //元素入队成功返回true,反之返回false boolean offer(E e); //元素入队成功则直接返回,如果队列已满元素不可入队则将线程阻塞,因为阻塞期间可能会被打断,所以这里方法签名抛出了InterruptedException void put(E e) throws InterruptedException; //和上一个方法一样,只不过队列满时只会阻塞单位为unit,时间为timeout的时长,如果在等待时长内没有入队成功则直接返回false。 boolean offer(E e, long timeout, TimeUnit unit) throws InterruptedException; //从队头取出一个元素,如果队列为空则阻塞等待,因为会阻塞线程的缘故,所以该方法可能会被打断,所以签名定义了InterruptedException E take() throws InterruptedException; //取出队头的元素并返回,如果当前队列为空则阻塞等待timeout且单位为unit的时长,如果这个时间段没有元素则直接返回null。 E poll(long timeout, TimeUnit unit) throws InterruptedException; //获取队列剩余元素个数 int remainingCapacity(); //删除我们指定的对象,如果成功返回true,反之返回false。 boolean remove(Object o); //判断队列中是否包含指定元素 public boolean contains(Object o); //将队列中的元素全部存到指定的集合中 int drainTo(Collection\u0026lt;? super E\u0026gt; c); //转移maxElements个元素到集合中 int drainTo(Collection\u0026lt;? super E\u0026gt; c, int maxElements); } 了解了 BlockingQueue 的常见操作后,我们就知道了 ArrayBlockingQueue 通过继承 BlockingQueue 的方法并实现后,填充到 AbstractQueue 的方法上,由此我们便知道了上文中 AbstractQueue 的 add 方法的 offer 方法是哪里是实现的了。\npublic boolean add(E e) { //AbstractQueue的offer来自下层的ArrayBlockingQueue从BlockingQueue继承并实现的offer方法 if (offer(e)) return true; else throw new IllegalStateException(\u0026#34;Queue full\u0026#34;); } 初始化 # 了解 ArrayBlockingQueue 的细节前,我们不妨先看看其构造函数,了解一下其初始化过程。从源码中我们可以看出 ArrayBlockingQueue 有 3 个构造方法,而最核心的构造方法就是下方这一个。\n// capacity 表示队列初始容量,fair 表示 锁的公平性 public ArrayBlockingQueue(int capacity, boolean fair) { //如果设置的队列大小小于0,则直接抛出IllegalArgumentException if (capacity \u0026lt;= 0) throw new IllegalArgumentException(); //初始化一个数组用于存放队列的元素 this.items = new Object[capacity]; //创建阻塞队列流程控制的锁 lock = new ReentrantLock(fair); //用lock锁创建两个条件控制队列生产和消费 notEmpty = lock.newCondition(); notFull = lock.newCondition(); } 这个构造方法里面有两个比较核心的成员变量 notEmpty(非空) 和 notFull (非满) ,需要我们格外留意,它们是实现生产者和消费者有序工作的关键所在,这一点笔者会在后续的源码解析中详细说明,这里我们只需初步了解一下阻塞队列的构造即可。\n另外两个构造方法都是基于上述的构造方法,默认情况下,我们会使用下面这个构造方法,该构造方法就意味着 ArrayBlockingQueue 用的是非公平锁,即各个生产者或者消费者线程收到通知后,对于锁的争抢是随机的。\npublic ArrayBlockingQueue(int capacity) { this(capacity, false); } 还有一个不怎么常用的构造方法,在初始化容量和锁的非公平性之后,它还提供了一个 Collection 参数,从源码中不难看出这个构造方法是将外部传入的集合的元素在初始化时直接存放到阻塞队列中。\npublic ArrayBlockingQueue(int capacity, boolean fair, Collection\u0026lt;? extends E\u0026gt; c) { //初始化容量和锁的公平性 this(capacity, fair); final ReentrantLock lock = this.lock; //上锁并将c中的元素存放到ArrayBlockingQueue底层的数组中 lock.lock(); try { int i = 0; try { //遍历并添加元素到数组中 for (E e : c) { checkNotNull(e); items[i++] = e; } } catch (ArrayIndexOutOfBoundsException ex) { throw new IllegalArgumentException(); } //记录当前队列容量 count = i; //更新下一次put或者offer或用add方法添加到队列底层数组的位置 putIndex = (i == capacity) ? 0 : i; } finally { //完成遍历后释放锁 lock.unlock(); } } 阻塞式获取和新增元素 # ArrayBlockingQueue 阻塞式获取和新增元素对应的就是生产者-消费者模型,虽然它也支持非阻塞式获取和新增元素(例如 poll() 和 offer(E e) 方法,后文会介绍到),但一般不会使用。\nArrayBlockingQueue 阻塞式获取和新增元素的方法为:\nput(E e):将元素插入队列中,如果队列已满,则该方法会一直阻塞,直到队列有空间可用或者线程被中断。 take() :获取并移除队列头部的元素,如果队列为空,则该方法会一直阻塞,直到队列非空或者线程被中断。 这两个方法实现的关键就是在于两个条件对象 notEmpty(非空) 和 notFull (非满),这个我们在上文的构造方法中有提到。\n接下来笔者就通过两张图让大家了解一下这两个条件是如何在阻塞队列中运用的。\n假设我们的代码消费者先启动,当它发现队列中没有数据,那么非空条件就会将这个线程挂起,即等待条件非空时挂起。然后 CPU 执行权到达生产者,生产者发现队列中可以存放数据,于是将数据存放进去,通知此时条件非空,此时消费者就会被唤醒到队列中使用 take 等方法获取值了。\n随后的执行中,生产者生产速度远远大于消费者消费速度,于是生产者将队列塞满后再次尝试将数据存入队列,发现队列已满,于是阻塞队列就将当前线程挂起,等待非满。然后消费者拿着 CPU 执行权进行消费,于是队列可以存放新数据了,发出一个非满的通知,此时挂起的生产者就会等待 CPU 执行权到来时再次尝试将数据存到队列中。\n简单了解阻塞队列的基于两个条件的交互流程之后,我们不妨看看 put 和 take 方法的源码。\npublic void put(E e) throws InterruptedException { //确保插入的元素不为null checkNotNull(e); //加锁 final ReentrantLock lock = this.lock; //这里使用lockInterruptibly()方法而不是lock()方法是为了能够响应中断操作,如果在等待获取锁的过程中被打断则该方法会抛出InterruptedException异常。 lock.lockInterruptibly(); try { //如果count等数组长度则说明队列已满,当前线程将被挂起放到AQS队列中,等待队列非满时插入(非满条件)。 //在等待期间,锁会被释放,其他线程可以继续对队列进行操作。 while (count == items.length) notFull.await(); //如果队列可以存放元素,则调用enqueue将元素入队 enqueue(e); } finally { //释放锁 lock.unlock(); } } put方法内部调用了 enqueue 方法来实现元素入队,我们继续深入查看一下 enqueue 方法的实现细节:\nprivate void enqueue(E x) { //获取队列底层的数组 final Object[] items = this.items; //将putindex位置的值设置为我们传入的x items[putIndex] = x; //更新putindex,如果putindex等于数组长度,则更新为0 if (++putIndex == items.length) putIndex = 0; //队列长度+1 count++; //通知队列非空,那些因为获取元素而阻塞的线程可以继续工作了 notEmpty.signal(); } 从源码中可以看到入队操作的逻辑就是在数组中追加一个新元素,整体执行步骤为:\n获取 ArrayBlockingQueue 底层的数组 items。 将元素存到 putIndex 位置。 更新 putIndex 到下一个位置,如果 putIndex 等于队列长度,则说明 putIndex 已经到达数组末尾了,下一次插入则需要 0 开始。(ArrayBlockingQueue 用到了循环队列的思想,即从头到尾循环复用一个数组) 更新 count 的值,表示当前队列长度+1。 调用 notEmpty.signal() 通知队列非空,消费者可以从队列中获取值了。 自此我们了解了 put 方法的流程,为了更加完整的了解 ArrayBlockingQueue 关于生产者-消费者模型的设计,我们继续看看阻塞获取队列元素的 take 方法。\npublic E take() throws InterruptedException { //获取锁 final ReentrantLock lock = this.lock; lock.lockInterruptibly(); try { //如果队列中元素个数为0,则将当前线程打断并存入AQS队列中,等待队列非空时获取并移除元素(非空条件) while (count == 0) notEmpty.await(); //如果队列不为空则调用dequeue获取元素 return dequeue(); } finally { //释放锁 lock.unlock(); } } 理解了 put 方法再看take 方法就很简单了,其核心逻辑和put 方法正好是相反的,比如put 方法在队列满的时候等待队列非满时插入元素(非满条件),而take 方法等待队列非空时获取并移除元素(非空条件)。\ntake方法内部调用了 dequeue 方法来实现元素出队,其核心逻辑和 enqueue 方法也是相反的。\nprivate E dequeue() { //获取阻塞队列底层的数组 final Object[] items = this.items; @SuppressWarnings(\u0026#34;unchecked\u0026#34;) //从队列中获取takeIndex位置的元素 E x = (E) items[takeIndex]; //将takeIndex置空 items[takeIndex] = null; //takeIndex向后挪动,如果等于数组长度则更新为0 if (++takeIndex == items.length) takeIndex = 0; //队列长度减1 count--; if (itrs != null) itrs.elementDequeued(); //通知那些被打断的线程当前队列状态非满,可以继续存放元素 notFull.signal(); return x; } 由于dequeue 方法(出队)和上面介绍的 enqueue 方法(入队)的步骤大致类似,这里就不重复介绍了。\n为了帮助理解,我专门画了一张图来展示 notEmpty(非空) 和 notFull (非满)这两个条件对象是如何控制 ArrayBlockingQueue 的存和取的。\n消费者:当消费者从队列中 take 或者 poll 等操作取出一个元素之后,就会通知队列非满,此时那些等待非满的生产者就会被唤醒等待获取 CPU 时间片进行入队操作。 生产者:当生产者将元素存到队列中后,就会触发通知队列非空,此时消费者就会被唤醒等待 CPU 时间片尝试获取元素。如此往复,两个条件对象就构成一个环路,控制着多线程之间的存和取。 非阻塞式获取和新增元素 # ArrayBlockingQueue 非阻塞式获取和新增元素的方法为:\noffer(E e):将元素插入队列尾部。如果队列已满,则该方法会直接返回 false,不会等待并阻塞线程。 poll():获取并移除队列头部的元素,如果队列为空,则该方法会直接返回 null,不会等待并阻塞线程。 add(E e):将元素插入队列尾部。如果队列已满则会抛出 IllegalStateException 异常,底层基于 offer(E e) 方法。 remove():移除队列头部的元素,如果队列为空则会抛出 NoSuchElementException 异常,底层基于 poll()。 peek():获取但不移除队列头部的元素,如果队列为空,则该方法会直接返回 null,不会等待并阻塞线程。 先来看看 offer 方法,逻辑和 put 差不多,唯一的区别就是入队失败时不会阻塞当前线程,而是直接返回 false。\npublic boolean offer(E e) { //确保插入的元素不为null checkNotNull(e); //获取锁 final ReentrantLock lock = this.lock; lock.lock(); try { //队列已满直接返回false if (count == items.length) return false; else { //反之将元素入队并直接返回true enqueue(e); return true; } } finally { //释放锁 lock.unlock(); } } poll 方法同理,获取元素失败也是直接返回空,并不会阻塞获取元素的线程。\npublic E poll() { final ReentrantLock lock = this.lock; //上锁 lock.lock(); try { //如果队列为空直接返回null,反之出队返回元素值 return (count == 0) ? null : dequeue(); } finally { lock.unlock(); } } add 方法其实就是对于 offer 做了一层封装,如下代码所示,可以看到 add 会调用没有规定时间的 offer,如果入队失败则直接抛异常。\npublic boolean add(E e) { return super.add(e); } public boolean add(E e) { //调用offer方法如果失败直接抛出异常 if (offer(e)) return true; else throw new IllegalStateException(\u0026#34;Queue full\u0026#34;); } remove 方法同理,调用 poll,如果返回 null 则说明队列没有元素,直接抛出异常。\npublic E remove() { E x = poll(); if (x != null) return x; else throw new NoSuchElementException(); } peek() 方法的逻辑也很简单,内部调用了 itemAt 方法。\npublic E peek() { //加锁 final ReentrantLock lock = this.lock; lock.lock(); try { //当队列为空时返回 null return itemAt(takeIndex); } finally { //释放锁 lock.unlock(); } } //返回队列中指定位置的元素 @SuppressWarnings(\u0026#34;unchecked\u0026#34;) final E itemAt(int i) { return (E) items[i]; } 指定超时时间内阻塞式获取和新增元素 # 在 offer(E e) 和 poll() 非阻塞获取和新增元素的基础上,设计者提供了带有等待时间的 offer(E e, long timeout, TimeUnit unit) 和 poll(long timeout, TimeUnit unit) ,用于在指定的超时时间内阻塞式地添加和获取元素。\npublic boolean offer(E e, long timeout, TimeUnit unit) throws InterruptedException { checkNotNull(e); long nanos = unit.toNanos(timeout); final ReentrantLock lock = this.lock; lock.lockInterruptibly(); try { //队列已满,进入循环 while (count == items.length) { //时间到了队列还是满的,则直接返回false if (nanos \u0026lt;= 0) return false; //阻塞nanos时间,等待非满 nanos = notFull.awaitNanos(nanos); } enqueue(e); return true; } finally { lock.unlock(); } } 可以看到,带有超时时间的 offer 方法在队列已满的情况下,会等待用户所传的时间段,如果规定时间内还不能存放元素则直接返回 false。\npublic E poll(long timeout, TimeUnit unit) throws InterruptedException { long nanos = unit.toNanos(timeout); final ReentrantLock lock = this.lock; lock.lockInterruptibly(); try { //队列为空,循环等待,若时间到还是空的,则直接返回null while (count == 0) { if (nanos \u0026lt;= 0) return null; nanos = notEmpty.awaitNanos(nanos); } return dequeue(); } finally { lock.unlock(); } } 同理,带有超时时间的 poll 也一样,队列为空则在规定时间内等待,若时间到了还是空的,则直接返回 null。\n判断元素是否存在 # ArrayBlockingQueue 提供了 contains(Object o) 来判断指定元素是否存在于队列中。\npublic boolean contains(Object o) { //若目标元素为空,则直接返回 false if (o == null) return false; //获取当前队列的元素数组 final Object[] items = this.items; //加锁 final ReentrantLock lock = this.lock; lock.lock(); try { // 如果队列非空 if (count \u0026gt; 0) { final int putIndex = this.putIndex; //从队列头部开始遍历 int i = takeIndex; do { if (o.equals(items[i])) return true; if (++i == items.length) i = 0; } while (i != putIndex); } return false; } finally { //释放锁 lock.unlock(); } } ArrayBlockingQueue 获取和新增元素的方法对比 # 为了帮助理解 ArrayBlockingQueue ,我们再来对比一下上面提到的这些获取和新增元素的方法。\n新增元素:\n方法 队列满时处理方式 方法返回值 put(E e) 线程阻塞,直到中断或被唤醒 void offer(E e) 直接返回 false boolean offer(E e, long timeout, TimeUnit unit) 指定超时时间内阻塞,超过规定时间还未添加成功则返回 false boolean add(E e) 直接抛出 IllegalStateException 异常 boolean 获取/移除元素:\n方法 队列空时处理方式 方法返回值 take() 线程阻塞,直到中断或被唤醒 E poll() 返回 null E poll(long timeout, TimeUnit unit) 指定超时时间内阻塞,超过规定时间还是空的则返回 null E peek() 返回 null E remove() 直接抛出 NoSuchElementException 异常 boolean ArrayBlockingQueue 相关面试题 # ArrayBlockingQueue 是什么?它的特点是什么? # ArrayBlockingQueue 是 BlockingQueue 接口的有界队列实现类,常用于多线程之间的数据共享,底层采用数组实现,从其名字就能看出来了。\nArrayBlockingQueue 的容量有限,一旦创建,容量不能改变。\n为了保证线程安全,ArrayBlockingQueue 的并发控制采用可重入锁 ReentrantLock ,不管是插入操作还是读取操作,都需要获取到锁才能进行操作。并且,它还支持公平和非公平两种方式的锁访问机制,默认是非公平锁。\nArrayBlockingQueue 虽名为阻塞队列,但也支持非阻塞获取和新增元素(例如 poll() 和 offer(E e) 方法),只是队列满时添加元素会抛出异常,队列为空时获取的元素为 null,一般不会使用。\nArrayBlockingQueue 和 LinkedBlockingQueue 有什么区别? # ArrayBlockingQueue 和 LinkedBlockingQueue 是 Java 并发包中常用的两种阻塞队列实现,它们都是线程安全的。不过,不过它们之间也存在下面这些区别:\n底层实现:ArrayBlockingQueue 基于数组实现,而 LinkedBlockingQueue 基于链表实现。 是否有界:ArrayBlockingQueue 是有界队列,必须在创建时指定容量大小。LinkedBlockingQueue 创建时可以不指定容量大小,默认是Integer.MAX_VALUE,也就是无界的。但也可以指定队列大小,从而成为有界的。 锁是否分离: ArrayBlockingQueue中的锁是没有分离的,即生产和消费用的是同一个锁;LinkedBlockingQueue中的锁是分离的,即生产用的是putLock,消费是takeLock,这样可以防止生产者和消费者线程之间的锁争夺。 内存占用:ArrayBlockingQueue 需要提前分配数组内存,而 LinkedBlockingQueue 则是动态分配链表节点内存。这意味着,ArrayBlockingQueue 在创建时就会占用一定的内存空间,且往往申请的内存比实际所用的内存更大,而LinkedBlockingQueue 则是根据元素的增加而逐渐占用内存空间。 ArrayBlockingQueue 和 ConcurrentLinkedQueue 有什么区别? # ArrayBlockingQueue 和 ConcurrentLinkedQueue 是 Java 并发包中常用的两种队列实现,它们都是线程安全的。不过,不过它们之间也存在下面这些区别:\n底层实现:ArrayBlockingQueue 基于数组实现,而 ConcurrentLinkedQueue 基于链表实现。 是否有界:ArrayBlockingQueue 是有界队列,必须在创建时指定容量大小,而 ConcurrentLinkedQueue 是无界队列,可以动态地增加容量。 是否阻塞:ArrayBlockingQueue 支持阻塞和非阻塞两种获取和新增元素的方式(一般只会使用前者), ConcurrentLinkedQueue 是无界的,仅支持非阻塞式获取和新增元素。 ArrayBlockingQueue 的实现原理是什么? # ArrayBlockingQueue 的实现原理主要分为以下几点(这里以阻塞式获取和新增元素为例介绍):\nArrayBlockingQueue 内部维护一个定长的数组用于存储元素。 通过使用 ReentrantLock 锁对象对读写操作进行同步,即通过锁机制来实现线程安全。 通过 Condition 实现线程间的等待和唤醒操作。 这里再详细介绍一下线程间的等待和唤醒具体的实现(不需要记具体的方法,面试中回答要点即可):\n当队列已满时,生产者线程会调用 notFull.await() 方法让生产者进行等待,等待队列非满时插入(非满条件)。 当队列为空时,消费者线程会调用 notEmpty.await()方法让消费者进行等待,等待队列非空时消费(非空条件)。 当有新的元素被添加时,生产者线程会调用 notEmpty.signal()方法唤醒正在等待消费的消费者线程。 当队列中有元素被取出时,消费者线程会调用 notFull.signal()方法唤醒正在等待插入元素的生产者线程。 关于 Condition接口的补充:\nCondition是 JDK1.5 之后才有的,它具有很好的灵活性,比如可以实现多路通知功能也就是在一个Lock对象中可以创建多个Condition实例(即对象监视器),线程对象可以注册在指定的Condition中,从而可以有选择性的进行线程通知,在调度线程上更加灵活。 在使用notify()/notifyAll()方法进行通知时,被通知的线程是由 JVM 选择的,用ReentrantLock类结合Condition实例可以实现“选择性通知” ,这个功能非常重要,而且是 Condition 接口默认提供的。而synchronized关键字就相当于整个 Lock 对象中只有一个Condition实例,所有的线程都注册在它一个身上。如果执行notifyAll()方法的话就会通知所有处于等待状态的线程,这样会造成很大的效率问题。而Condition实例的signalAll()方法,只会唤醒注册在该Condition实例中的所有等待线程。\n参考文献 # 深入理解 Java 系列 | BlockingQueue 用法详解: https://juejin.cn/post/6999798721269465102 深入浅出阻塞队列 BlockingQueue 及其典型实现 ArrayBlockingQueue: https://zhuanlan.zhihu.com/p/539619957 并发编程大扫盲:ArrayBlockingQueue 底层原理和实战: https://zhuanlan.zhihu.com/p/339662987 "},{"id":457,"href":"/zh/docs/technology/Interview/java/collection/arraylist-source-code/","title":"ArrayList 源码分析","section":"Collection","content":" ArrayList 简介 # ArrayList 的底层是数组队列,相当于动态数组。与 Java 中的数组相比,它的容量能动态增长。在添加大量元素前,应用程序可以使用ensureCapacity操作来增加 ArrayList 实例的容量。这可以减少递增式再分配的数量。\nArrayList 继承于 AbstractList ,实现了 List, RandomAccess, Cloneable, java.io.Serializable 这些接口。\npublic class ArrayList\u0026lt;E\u0026gt; extends AbstractList\u0026lt;E\u0026gt; implements List\u0026lt;E\u0026gt;, RandomAccess, Cloneable, java.io.Serializable{ } List : 表明它是一个列表,支持添加、删除、查找等操作,并且可以通过下标进行访问。 RandomAccess :这是一个标志接口,表明实现这个接口的 List 集合是支持 快速随机访问 的。在 ArrayList 中,我们即可以通过元素的序号快速获取元素对象,这就是快速随机访问。 Cloneable :表明它具有拷贝能力,可以进行深拷贝或浅拷贝操作。 Serializable : 表明它可以进行序列化操作,也就是可以将对象转换为字节流进行持久化存储或网络传输,非常方便。 ArrayList 和 Vector 的区别?(了解即可) # ArrayList 是 List 的主要实现类,底层使用 Object[]存储,适用于频繁的查找工作,线程不安全 。 Vector 是 List 的古老实现类,底层使用Object[] 存储,线程安全。 ArrayList 可以添加 null 值吗? # ArrayList 中可以存储任何类型的对象,包括 null 值。不过,不建议向ArrayList 中添加 null 值, null 值无意义,会让代码难以维护比如忘记做判空处理就会导致空指针异常。\n示例代码:\nArrayList\u0026lt;String\u0026gt; listOfStrings = new ArrayList\u0026lt;\u0026gt;(); listOfStrings.add(null); listOfStrings.add(\u0026#34;java\u0026#34;); System.out.println(listOfStrings); 输出:\n[null, java] Arraylist 与 LinkedList 区别? # 是否保证线程安全: ArrayList 和 LinkedList 都是不同步的,也就是不保证线程安全; 底层数据结构: ArrayList 底层使用的是 Object 数组;LinkedList 底层使用的是 双向链表 数据结构(JDK1.6 之前为循环链表,JDK1.7 取消了循环。注意双向链表和双向循环链表的区别,下面有介绍到!) 插入和删除是否受元素位置的影响: ArrayList 采用数组存储,所以插入和删除元素的时间复杂度受元素位置的影响。 比如:执行add(E e)方法的时候, ArrayList 会默认在将指定的元素追加到此列表的末尾,这种情况时间复杂度就是 O(1)。但是如果要在指定位置 i 插入和删除元素的话(add(int index, E element)),时间复杂度就为 O(n)。因为在进行上述操作的时候集合中第 i 和第 i 个元素之后的(n-i)个元素都要执行向后位/向前移一位的操作。 LinkedList 采用链表存储,所以在头尾插入或者删除元素不受元素位置的影响(add(E e)、addFirst(E e)、addLast(E e)、removeFirst()、 removeLast()),时间复杂度为 O(1),如果是要在指定位置 i 插入和删除元素的话(add(int index, E element),remove(Object o),remove(int index)), 时间复杂度为 O(n) ,因为需要先移动到指定位置再插入和删除。 是否支持快速随机访问: LinkedList 不支持高效的随机元素访问,而 ArrayList(实现了 RandomAccess 接口) 支持。快速随机访问就是通过元素的序号快速获取元素对象(对应于get(int index)方法)。 内存空间占用: ArrayList 的空间浪费主要体现在在 list 列表的结尾会预留一定的容量空间,而 LinkedList 的空间花费则体现在它的每一个元素都需要消耗比 ArrayList 更多的空间(因为要存放直接后继和直接前驱以及数据)。 ArrayList 核心源码解读 # 这里以 JDK1.8 为例,分析一下 ArrayList 的底层源码。\npublic class ArrayList\u0026lt;E\u0026gt; extends AbstractList\u0026lt;E\u0026gt; implements List\u0026lt;E\u0026gt;, RandomAccess, Cloneable, java.io.Serializable { private static final long serialVersionUID = 8683452581122892189L; /** * 默认初始容量大小 */ private static final int DEFAULT_CAPACITY = 10; /** * 空数组(用于空实例)。 */ private static final Object[] EMPTY_ELEMENTDATA = {}; //用于默认大小空实例的共享空数组实例。 //我们把它从EMPTY_ELEMENTDATA数组中区分出来,以知道在添加第一个元素时容量需要增加多少。 private static final Object[] DEFAULTCAPACITY_EMPTY_ELEMENTDATA = {}; /** * 保存ArrayList数据的数组 */ transient Object[] elementData; // non-private to simplify nested class access /** * ArrayList 所包含的元素个数 */ private int size; /** * 带初始容量参数的构造函数(用户可以在创建ArrayList对象时自己指定集合的初始大小) */ public ArrayList(int initialCapacity) { if (initialCapacity \u0026gt; 0) { //如果传入的参数大于0,创建initialCapacity大小的数组 this.elementData = new Object[initialCapacity]; } else if (initialCapacity == 0) { //如果传入的参数等于0,创建空数组 this.elementData = EMPTY_ELEMENTDATA; } else { //其他情况,抛出异常 throw new IllegalArgumentException(\u0026#34;Illegal Capacity: \u0026#34; + initialCapacity); } } /** * 默认无参构造函数 * DEFAULTCAPACITY_EMPTY_ELEMENTDATA 为0.初始化为10,也就是说初始其实是空数组 当添加第一个元素的时候数组容量才变成10 */ public ArrayList() { this.elementData = DEFAULTCAPACITY_EMPTY_ELEMENTDATA; } /** * 构造一个包含指定集合的元素的列表,按照它们由集合的迭代器返回的顺序。 */ public ArrayList(Collection\u0026lt;? extends E\u0026gt; c) { //将指定集合转换为数组 elementData = c.toArray(); //如果elementData数组的长度不为0 if ((size = elementData.length) != 0) { // 如果elementData不是Object类型数据(c.toArray可能返回的不是Object类型的数组所以加上下面的语句用于判断) if (elementData.getClass() != Object[].class) //将原来不是Object类型的elementData数组的内容,赋值给新的Object类型的elementData数组 elementData = Arrays.copyOf(elementData, size, Object[].class); } else { // 其他情况,用空数组代替 this.elementData = EMPTY_ELEMENTDATA; } } /** * 修改这个ArrayList实例的容量是列表的当前大小。 应用程序可以使用此操作来最小化ArrayList实例的存储。 */ public void trimToSize() { modCount++; if (size \u0026lt; elementData.length) { elementData = (size == 0) ? EMPTY_ELEMENTDATA : Arrays.copyOf(elementData, size); } } //下面是ArrayList的扩容机制 //ArrayList的扩容机制提高了性能,如果每次只扩充一个, //那么频繁的插入会导致频繁的拷贝,降低性能,而ArrayList的扩容机制避免了这种情况。 /** * 如有必要,增加此ArrayList实例的容量,以确保它至少能容纳元素的数量 * * @param minCapacity 所需的最小容量 */ public void ensureCapacity(int minCapacity) { // 如果不是默认空数组,则minExpand的值为0; // 如果是默认空数组,则minExpand的值为10 int minExpand = (elementData != DEFAULTCAPACITY_EMPTY_ELEMENTDATA) // 如果不是默认元素表,则可以使用任意大小 ? 0 // 如果是默认空数组,它应该已经是默认大小 : DEFAULT_CAPACITY; // 如果最小容量大于已有的最大容量 if (minCapacity \u0026gt; minExpand) { // 根据需要的最小容量,确保容量足够 ensureExplicitCapacity(minCapacity); } } // 根据给定的最小容量和当前数组元素来计算所需容量。 private static int calculateCapacity(Object[] elementData, int minCapacity) { // 如果当前数组元素为空数组(初始情况),返回默认容量和最小容量中的较大值作为所需容量 if (elementData == DEFAULTCAPACITY_EMPTY_ELEMENTDATA) { return Math.max(DEFAULT_CAPACITY, minCapacity); } // 否则直接返回最小容量 return minCapacity; } // 确保内部容量达到指定的最小容量。 private void ensureCapacityInternal(int minCapacity) { ensureExplicitCapacity(calculateCapacity(elementData, minCapacity)); } //判断是否需要扩容 private void ensureExplicitCapacity(int minCapacity) { modCount++; // overflow-conscious code if (minCapacity - elementData.length \u0026gt; 0) //调用grow方法进行扩容,调用此方法代表已经开始扩容了 grow(minCapacity); } /** * 要分配的最大数组大小 */ private static final int MAX_ARRAY_SIZE = Integer.MAX_VALUE - 8; /** * ArrayList扩容的核心方法。 */ private void grow(int minCapacity) { // oldCapacity为旧容量,newCapacity为新容量 int oldCapacity = elementData.length; //将oldCapacity 右移一位,其效果相当于oldCapacity /2, //我们知道位运算的速度远远快于整除运算,整句运算式的结果就是将新容量更新为旧容量的1.5倍, int newCapacity = oldCapacity + (oldCapacity \u0026gt;\u0026gt; 1); //然后检查新容量是否大于最小需要容量,若还是小于最小需要容量,那么就把最小需要容量当作数组的新容量, if (newCapacity - minCapacity \u0026lt; 0) newCapacity = minCapacity; //再检查新容量是否超出了ArrayList所定义的最大容量, //若超出了,则调用hugeCapacity()来比较minCapacity和 MAX_ARRAY_SIZE, //如果minCapacity大于MAX_ARRAY_SIZE,则新容量则为Integer.MAX_VALUE,否则,新容量大小则为 MAX_ARRAY_SIZE。 if (newCapacity - MAX_ARRAY_SIZE \u0026gt; 0) newCapacity = hugeCapacity(minCapacity); // minCapacity is usually close to size, so this is a win: elementData = Arrays.copyOf(elementData, newCapacity); } //比较minCapacity和 MAX_ARRAY_SIZE private static int hugeCapacity(int minCapacity) { if (minCapacity \u0026lt; 0) // overflow throw new OutOfMemoryError(); return (minCapacity \u0026gt; MAX_ARRAY_SIZE) ? Integer.MAX_VALUE : MAX_ARRAY_SIZE; } /** * 返回此列表中的元素数。 */ public int size() { return size; } /** * 如果此列表不包含元素,则返回 true 。 */ public boolean isEmpty() { //注意=和==的区别 return size == 0; } /** * 如果此列表包含指定的元素,则返回true 。 */ public boolean contains(Object o) { //indexOf()方法:返回此列表中指定元素的首次出现的索引,如果此列表不包含此元素,则为-1 return indexOf(o) \u0026gt;= 0; } /** * 返回此列表中指定元素的首次出现的索引,如果此列表不包含此元素,则为-1 */ public int indexOf(Object o) { if (o == null) { for (int i = 0; i \u0026lt; size; i++) if (elementData[i] == null) return i; } else { for (int i = 0; i \u0026lt; size; i++) //equals()方法比较 if (o.equals(elementData[i])) return i; } return -1; } /** * 返回此列表中指定元素的最后一次出现的索引,如果此列表不包含元素,则返回-1。. */ public int lastIndexOf(Object o) { if (o == null) { for (int i = size - 1; i \u0026gt;= 0; i--) if (elementData[i] == null) return i; } else { for (int i = size - 1; i \u0026gt;= 0; i--) if (o.equals(elementData[i])) return i; } return -1; } /** * 返回此ArrayList实例的浅拷贝。 (元素本身不被复制。) */ public Object clone() { try { ArrayList\u0026lt;?\u0026gt; v = (ArrayList\u0026lt;?\u0026gt;) super.clone(); //Arrays.copyOf功能是实现数组的复制,返回复制后的数组。参数是被复制的数组和复制的长度 v.elementData = Arrays.copyOf(elementData, size); v.modCount = 0; return v; } catch (CloneNotSupportedException e) { // 这不应该发生,因为我们是可以克隆的 throw new InternalError(e); } } /** * 以正确的顺序(从第一个到最后一个元素)返回一个包含此列表中所有元素的数组。 * 返回的数组将是“安全的”,因为该列表不保留对它的引用。 * (换句话说,这个方法必须分配一个新的数组)。 * 因此,调用者可以自由地修改返回的数组结构。 * 注意:如果元素是引用类型,修改元素的内容会影响到原列表中的对象。 * 此方法充当基于数组和基于集合的API之间的桥梁。 */ public Object[] toArray() { return Arrays.copyOf(elementData, size); } /** * 以正确的顺序返回一个包含此列表中所有元素的数组(从第一个到最后一个元素); * 返回的数组的运行时类型是指定数组的运行时类型。 如果列表适合指定的数组,则返回其中。 * 否则,将为指定数组的运行时类型和此列表的大小分配一个新数组。 * 如果列表适用于指定的数组,其余空间(即数组的列表数量多于此元素),则紧跟在集合结束后的数组中的元素设置为null 。 * (这仅在调用者知道列表不包含任何空元素的情况下才能确定列表的长度。) */ @SuppressWarnings(\u0026#34;unchecked\u0026#34;) public \u0026lt;T\u0026gt; T[] toArray(T[] a) { if (a.length \u0026lt; size) // 新建一个运行时类型的数组,但是ArrayList数组的内容 return (T[]) Arrays.copyOf(elementData, size, a.getClass()); //调用System提供的arraycopy()方法实现数组之间的复制 System.arraycopy(elementData, 0, a, 0, size); if (a.length \u0026gt; size) a[size] = null; return a; } // Positional Access Operations @SuppressWarnings(\u0026#34;unchecked\u0026#34;) E elementData(int index) { return (E) elementData[index]; } /** * 返回此列表中指定位置的元素。 */ public E get(int index) { rangeCheck(index); return elementData(index); } /** * 用指定的元素替换此列表中指定位置的元素。 */ public E set(int index, E element) { //对index进行界限检查 rangeCheck(index); E oldValue = elementData(index); elementData[index] = element; //返回原来在这个位置的元素 return oldValue; } /** * 将指定的元素追加到此列表的末尾。 */ public boolean add(E e) { ensureCapacityInternal(size + 1); // Increments modCount!! //这里看到ArrayList添加元素的实质就相当于为数组赋值 elementData[size++] = e; return true; } /** * 在此列表中的指定位置插入指定的元素。 * 先调用 rangeCheckForAdd 对index进行界限检查;然后调用 ensureCapacityInternal 方法保证capacity足够大; * 再将从index开始之后的所有成员后移一个位置;将element插入index位置;最后size加1。 */ public void add(int index, E element) { rangeCheckForAdd(index); ensureCapacityInternal(size + 1); // Increments modCount!! //arraycopy()这个实现数组之间复制的方法一定要看一下,下面就用到了arraycopy()方法实现数组自己复制自己 System.arraycopy(elementData, index, elementData, index + 1, size - index); elementData[index] = element; size++; } /** * 删除该列表中指定位置的元素。 将任何后续元素移动到左侧(从其索引中减去一个元素)。 */ public E remove(int index) { rangeCheck(index); modCount++; E oldValue = elementData(index); int numMoved = size - index - 1; if (numMoved \u0026gt; 0) System.arraycopy(elementData, index + 1, elementData, index, numMoved); elementData[--size] = null; // clear to let GC do its work //从列表中删除的元素 return oldValue; } /** * 从列表中删除指定元素的第一个出现(如果存在)。 如果列表不包含该元素,则它不会更改。 * 返回true,如果此列表包含指定的元素 */ public boolean remove(Object o) { if (o == null) { for (int index = 0; index \u0026lt; size; index++) if (elementData[index] == null) { fastRemove(index); return true; } } else { for (int index = 0; index \u0026lt; size; index++) if (o.equals(elementData[index])) { fastRemove(index); return true; } } return false; } /* * 该方法为私有的移除方法,跳过了边界检查,并且不返回被移除的值。 */ private void fastRemove(int index) { modCount++; int numMoved = size - index - 1; if (numMoved \u0026gt; 0) System.arraycopy(elementData, index + 1, elementData, index, numMoved); elementData[--size] = null; // 在移除元素后,将该位置的元素设为 null,以便垃圾回收器(GC)能够回收该元素。 } /** * 从列表中删除所有元素。 */ public void clear() { modCount++; // 把数组中所有的元素的值设为null for (int i = 0; i \u0026lt; size; i++) elementData[i] = null; size = 0; } /** * 按指定集合的Iterator返回的顺序将指定集合中的所有元素追加到此列表的末尾。 */ public boolean addAll(Collection\u0026lt;? extends E\u0026gt; c) { Object[] a = c.toArray(); int numNew = a.length; ensureCapacityInternal(size + numNew); // Increments modCount System.arraycopy(a, 0, elementData, size, numNew); size += numNew; return numNew != 0; } /** * 将指定集合中的所有元素插入到此列表中,从指定的位置开始。 */ public boolean addAll(int index, Collection\u0026lt;? extends E\u0026gt; c) { rangeCheckForAdd(index); Object[] a = c.toArray(); int numNew = a.length; ensureCapacityInternal(size + numNew); // Increments modCount int numMoved = size - index; if (numMoved \u0026gt; 0) System.arraycopy(elementData, index, elementData, index + numNew, numMoved); System.arraycopy(a, 0, elementData, index, numNew); size += numNew; return numNew != 0; } /** * 从此列表中删除所有索引为fromIndex (含)和toIndex之间的元素。 * 将任何后续元素移动到左侧(减少其索引)。 */ protected void removeRange(int fromIndex, int toIndex) { modCount++; int numMoved = size - toIndex; System.arraycopy(elementData, toIndex, elementData, fromIndex, numMoved); // clear to let GC do its work int newSize = size - (toIndex - fromIndex); for (int i = newSize; i \u0026lt; size; i++) { elementData[i] = null; } size = newSize; } /** * 检查给定的索引是否在范围内。 */ private void rangeCheck(int index) { if (index \u0026gt;= size) throw new IndexOutOfBoundsException(outOfBoundsMsg(index)); } /** * add和addAll使用的rangeCheck的一个版本 */ private void rangeCheckForAdd(int index) { if (index \u0026gt; size || index \u0026lt; 0) throw new IndexOutOfBoundsException(outOfBoundsMsg(index)); } /** * 返回IndexOutOfBoundsException细节信息 */ private String outOfBoundsMsg(int index) { return \u0026#34;Index: \u0026#34; + index + \u0026#34;, Size: \u0026#34; + size; } /** * 从此列表中删除指定集合中包含的所有元素。 */ public boolean removeAll(Collection\u0026lt;?\u0026gt; c) { Objects.requireNonNull(c); //如果此列表被修改则返回true return batchRemove(c, false); } /** * 仅保留此列表中包含在指定集合中的元素。 * 换句话说,从此列表中删除其中不包含在指定集合中的所有元素。 */ public boolean retainAll(Collection\u0026lt;?\u0026gt; c) { Objects.requireNonNull(c); return batchRemove(c, true); } /** * 从列表中的指定位置开始,返回列表中的元素(按正确顺序)的列表迭代器。 * 指定的索引表示初始调用将返回的第一个元素为next 。 初始调用previous将返回指定索引减1的元素。 * 返回的列表迭代器是fail-fast 。 */ public ListIterator\u0026lt;E\u0026gt; listIterator(int index) { if (index \u0026lt; 0 || index \u0026gt; size) throw new IndexOutOfBoundsException(\u0026#34;Index: \u0026#34; + index); return new ListItr(index); } /** * 返回列表中的列表迭代器(按适当的顺序)。 * 返回的列表迭代器是fail-fast 。 */ public ListIterator\u0026lt;E\u0026gt; listIterator() { return new ListItr(0); } /** * 以正确的顺序返回该列表中的元素的迭代器。 * 返回的迭代器是fail-fast 。 */ public Iterator\u0026lt;E\u0026gt; iterator() { return new Itr(); } ArrayList 扩容机制分析 # 先从 ArrayList 的构造函数说起 # ArrayList 有三种方式来初始化,构造方法源码如下(JDK8):\n/** * 默认初始容量大小 */ private static final int DEFAULT_CAPACITY = 10; private static final Object[] DEFAULTCAPACITY_EMPTY_ELEMENTDATA = {}; /** * 默认构造函数,使用初始容量10构造一个空列表(无参数构造) */ public ArrayList() { this.elementData = DEFAULTCAPACITY_EMPTY_ELEMENTDATA; } /** * 带初始容量参数的构造函数。(用户自己指定容量) */ public ArrayList(int initialCapacity) { if (initialCapacity \u0026gt; 0) {//初始容量大于0 //创建initialCapacity大小的数组 this.elementData = new Object[initialCapacity]; } else if (initialCapacity == 0) {//初始容量等于0 //创建空数组 this.elementData = EMPTY_ELEMENTDATA; } else {//初始容量小于0,抛出异常 throw new IllegalArgumentException(\u0026#34;Illegal Capacity: \u0026#34; + initialCapacity); } } /** *构造包含指定collection元素的列表,这些元素利用该集合的迭代器按顺序返回 *如果指定的集合为null,throws NullPointerException。 */ public ArrayList(Collection\u0026lt;? extends E\u0026gt; c) { elementData = c.toArray(); if ((size = elementData.length) != 0) { // c.toArray might (incorrectly) not return Object[] (see 6260652) if (elementData.getClass() != Object[].class) elementData = Arrays.copyOf(elementData, size, Object[].class); } else { // replace with empty array. this.elementData = EMPTY_ELEMENTDATA; } } 细心的同学一定会发现:以无参数构造方法创建 ArrayList 时,实际上初始化赋值的是一个空数组。当真正对数组进行添加元素操作时,才真正分配容量。即向数组中添加第一个元素时,数组容量扩为 10。 下面在我们分析 ArrayList 扩容时会讲到这一点内容!\n补充:JDK6 new 无参构造的 ArrayList 对象时,直接创建了长度是 10 的 Object[] 数组 elementData 。\n一步一步分析 ArrayList 扩容机制 # 这里以无参构造函数创建的 ArrayList 为例分析。\nadd 方法 # /** * 将指定的元素追加到此列表的末尾。 */ public boolean add(E e) { // 加元素之前,先调用ensureCapacityInternal方法 ensureCapacityInternal(size + 1); // Increments modCount!! // 这里看到ArrayList添加元素的实质就相当于为数组赋值 elementData[size++] = e; return true; } 注意:JDK11 移除了 ensureCapacityInternal() 和 ensureExplicitCapacity() 方法\nensureCapacityInternal 方法的源码如下:\n// 根据给定的最小容量和当前数组元素来计算所需容量。 private static int calculateCapacity(Object[] elementData, int minCapacity) { // 如果当前数组元素为空数组(初始情况),返回默认容量和最小容量中的较大值作为所需容量 if (elementData == DEFAULTCAPACITY_EMPTY_ELEMENTDATA) { return Math.max(DEFAULT_CAPACITY, minCapacity); } // 否则直接返回最小容量 return minCapacity; } // 确保内部容量达到指定的最小容量。 private void ensureCapacityInternal(int minCapacity) { ensureExplicitCapacity(calculateCapacity(elementData, minCapacity)); } ensureCapacityInternal 方法非常简单,内部直接调用了 ensureExplicitCapacity 方法:\n//判断是否需要扩容 private void ensureExplicitCapacity(int minCapacity) { modCount++; //判断当前数组容量是否足以存储minCapacity个元素 if (minCapacity - elementData.length \u0026gt; 0) //调用grow方法进行扩容 grow(minCapacity); } 我们来仔细分析一下:\n当我们要 add 进第 1 个元素到 ArrayList 时,elementData.length 为 0 (因为还是一个空的 list),因为执行了 ensureCapacityInternal() 方法 ,所以 minCapacity 此时为 10。此时,minCapacity - elementData.length \u0026gt; 0成立,所以会进入 grow(minCapacity) 方法。 当 add 第 2 个元素时,minCapacity 为 2,此时 elementData.length(容量)在添加第一个元素后扩容成 10 了。此时,minCapacity - elementData.length \u0026gt; 0 不成立,所以不会进入 (执行)grow(minCapacity) 方法。 添加第 3、4···到第 10 个元素时,依然不会执行 grow 方法,数组容量都为 10。 直到添加第 11 个元素,minCapacity(为 11)比 elementData.length(为 10)要大。进入 grow 方法进行扩容。\ngrow 方法 # /** * 要分配的最大数组大小 */ private static final int MAX_ARRAY_SIZE = Integer.MAX_VALUE - 8; /** * ArrayList扩容的核心方法。 */ private void grow(int minCapacity) { // oldCapacity为旧容量,newCapacity为新容量 int oldCapacity = elementData.length; // 将oldCapacity 右移一位,其效果相当于oldCapacity /2, // 我们知道位运算的速度远远快于整除运算,整句运算式的结果就是将新容量更新为旧容量的1.5倍, int newCapacity = oldCapacity + (oldCapacity \u0026gt;\u0026gt; 1); // 然后检查新容量是否大于最小需要容量,若还是小于最小需要容量,那么就把最小需要容量当作数组的新容量, if (newCapacity - minCapacity \u0026lt; 0) newCapacity = minCapacity; // 如果新容量大于 MAX_ARRAY_SIZE,进入(执行) `hugeCapacity()` 方法来比较 minCapacity 和 MAX_ARRAY_SIZE, // 如果minCapacity大于最大容量,则新容量则为`Integer.MAX_VALUE`,否则,新容量大小则为 MAX_ARRAY_SIZE 即为 `Integer.MAX_VALUE - 8`。 if (newCapacity - MAX_ARRAY_SIZE \u0026gt; 0) newCapacity = hugeCapacity(minCapacity); // minCapacity is usually close to size, so this is a win: elementData = Arrays.copyOf(elementData, newCapacity); } int newCapacity = oldCapacity + (oldCapacity \u0026gt;\u0026gt; 1),所以 ArrayList 每次扩容之后容量都会变为原来的 1.5 倍左右(oldCapacity 为偶数就是 1.5 倍,否则是 1.5 倍左右)! 奇偶不同,比如:10+10/2 = 15, 33+33/2=49。如果是奇数的话会丢掉小数.\n\u0026ldquo;\u0026raquo;\u0026quot;(移位运算符):\u0026raquo;1 右移一位相当于除 2,右移 n 位相当于除以 2 的 n 次方。这里 oldCapacity 明显右移了 1 位所以相当于 oldCapacity /2。对于大数据的 2 进制运算,位移运算符比那些普通运算符的运算要快很多,因为程序仅仅移动一下而已,不去计算,这样提高了效率,节省了资源\n我们再来通过例子探究一下grow() 方法:\n当 add 第 1 个元素时,oldCapacity 为 0,经比较后第一个 if 判断成立,newCapacity = minCapacity(为 10)。但是第二个 if 判断不会成立,即 newCapacity 不比 MAX_ARRAY_SIZE 大,则不会进入 hugeCapacity 方法。数组容量为 10,add 方法中 return true,size 增为 1。 当 add 第 11 个元素进入 grow 方法时,newCapacity 为 15,比 minCapacity(为 11)大,第一个 if 判断不成立。新容量没有大于数组最大 size,不会进入 hugeCapacity 方法。数组容量扩为 15,add 方法中 return true,size 增为 11。 以此类推······ 这里补充一点比较重要,但是容易被忽视掉的知识点:\nJava 中的 length属性是针对数组说的,比如说你声明了一个数组,想知道这个数组的长度则用到了 length 这个属性. Java 中的 length() 方法是针对字符串说的,如果想看这个字符串的长度则用到 length() 这个方法. Java 中的 size() 方法是针对泛型集合说的,如果想看这个泛型有多少个元素,就调用此方法来查看! hugeCapacity() 方法 # 从上面 grow() 方法源码我们知道:如果新容量大于 MAX_ARRAY_SIZE,进入(执行) hugeCapacity() 方法来比较 minCapacity 和 MAX_ARRAY_SIZE,如果 minCapacity 大于最大容量,则新容量则为Integer.MAX_VALUE,否则,新容量大小则为 MAX_ARRAY_SIZE 即为 Integer.MAX_VALUE - 8。\nprivate static int hugeCapacity(int minCapacity) { if (minCapacity \u0026lt; 0) // overflow throw new OutOfMemoryError(); // 对minCapacity和MAX_ARRAY_SIZE进行比较 // 若minCapacity大,将Integer.MAX_VALUE作为新数组的大小 // 若MAX_ARRAY_SIZE大,将MAX_ARRAY_SIZE作为新数组的大小 // MAX_ARRAY_SIZE = Integer.MAX_VALUE - 8; return (minCapacity \u0026gt; MAX_ARRAY_SIZE) ? Integer.MAX_VALUE : MAX_ARRAY_SIZE; } System.arraycopy() 和 Arrays.copyOf()方法 # 阅读源码的话,我们就会发现 ArrayList 中大量调用了这两个方法。比如:我们上面讲的扩容操作以及add(int index, E element)、toArray() 等方法中都用到了该方法!\nSystem.arraycopy() 方法 # 源码:\n// 我们发现 arraycopy 是一个 native 方法,接下来我们解释一下各个参数的具体意义 /** * 复制数组 * @param src 源数组 * @param srcPos 源数组中的起始位置 * @param dest 目标数组 * @param destPos 目标数组中的起始位置 * @param length 要复制的数组元素的数量 */ public static native void arraycopy(Object src, int srcPos, Object dest, int destPos, int length); 场景:\n/** * 在此列表中的指定位置插入指定的元素。 *先调用 rangeCheckForAdd 对index进行界限检查;然后调用 ensureCapacityInternal 方法保证capacity足够大; *再将从index开始之后的所有成员后移一个位置;将element插入index位置;最后size加1。 */ public void add(int index, E element) { rangeCheckForAdd(index); ensureCapacityInternal(size + 1); // Increments modCount!! //arraycopy()方法实现数组自己复制自己 //elementData:源数组;index:源数组中的起始位置;elementData:目标数组;index + 1:目标数组中的起始位置; size - index:要复制的数组元素的数量; System.arraycopy(elementData, index, elementData, index + 1, size - index); elementData[index] = element; size++; } 我们写一个简单的方法测试以下:\npublic class ArraycopyTest { public static void main(String[] args) { // TODO Auto-generated method stub int[] a = new int[10]; a[0] = 0; a[1] = 1; a[2] = 2; a[3] = 3; System.arraycopy(a, 2, a, 3, 3); a[2]=99; for (int i = 0; i \u0026lt; a.length; i++) { System.out.print(a[i] + \u0026#34; \u0026#34;); } } } 结果:\n0 1 99 2 3 0 0 0 0 0 Arrays.copyOf()方法 # 源码:\npublic static int[] copyOf(int[] original, int newLength) { // 申请一个新的数组 int[] copy = new int[newLength]; // 调用System.arraycopy,将源数组中的数据进行拷贝,并返回新的数组 System.arraycopy(original, 0, copy, 0, Math.min(original.length, newLength)); return copy; } 场景:\n/** 以正确的顺序返回一个包含此列表中所有元素的数组(从第一个到最后一个元素); 返回的数组的运行时类型是指定数组的运行时类型。 */ public Object[] toArray() { //elementData:要复制的数组;size:要复制的长度 return Arrays.copyOf(elementData, size); } 个人觉得使用 Arrays.copyOf()方法主要是为了给原有数组扩容,测试代码如下:\npublic class ArrayscopyOfTest { public static void main(String[] args) { int[] a = new int[3]; a[0] = 0; a[1] = 1; a[2] = 2; int[] b = Arrays.copyOf(a, 10); System.out.println(\u0026#34;b.length\u0026#34;+b.length); } } 结果:\n10 两者联系和区别 # 联系:\n看两者源代码可以发现 copyOf()内部实际调用了 System.arraycopy() 方法\n区别:\narraycopy() 需要目标数组,将原数组拷贝到你自己定义的数组里或者原数组,而且可以选择拷贝的起点和长度以及放入新数组中的位置 copyOf() 是系统自动在内部新建一个数组,并返回该数组。\nensureCapacity方法 # ArrayList 源码中有一个 ensureCapacity 方法不知道大家注意到没有,这个方法 ArrayList 内部没有被调用过,所以很显然是提供给用户调用的,那么这个方法有什么作用呢?\n/** 如有必要,增加此 ArrayList 实例的容量,以确保它至少可以容纳由minimum capacity参数指定的元素数。 * * @param minCapacity 所需的最小容量 */ public void ensureCapacity(int minCapacity) { int minExpand = (elementData != DEFAULTCAPACITY_EMPTY_ELEMENTDATA) // any size if not default element table ? 0 // larger than default for default empty table. It\u0026#39;s already // supposed to be at default size. : DEFAULT_CAPACITY; if (minCapacity \u0026gt; minExpand) { ensureExplicitCapacity(minCapacity); } } 理论上来说,最好在向 ArrayList 添加大量元素之前用 ensureCapacity 方法,以减少增量重新分配的次数\n我们通过下面的代码实际测试以下这个方法的效果:\npublic class EnsureCapacityTest { public static void main(String[] args) { ArrayList\u0026lt;Object\u0026gt; list = new ArrayList\u0026lt;Object\u0026gt;(); final int N = 10000000; long startTime = System.currentTimeMillis(); for (int i = 0; i \u0026lt; N; i++) { list.add(i); } long endTime = System.currentTimeMillis(); System.out.println(\u0026#34;使用ensureCapacity方法前:\u0026#34;+(endTime - startTime)); } } 运行结果:\n使用ensureCapacity方法前:2158 public class EnsureCapacityTest { public static void main(String[] args) { ArrayList\u0026lt;Object\u0026gt; list = new ArrayList\u0026lt;Object\u0026gt;(); final int N = 10000000; long startTime1 = System.currentTimeMillis(); list.ensureCapacity(N); for (int i = 0; i \u0026lt; N; i++) { list.add(i); } long endTime1 = System.currentTimeMillis(); System.out.println(\u0026#34;使用ensureCapacity方法后:\u0026#34;+(endTime1 - startTime1)); } } 运行结果:\n使用ensureCapacity方法后:1773 通过运行结果,我们可以看出向 ArrayList 添加大量元素之前使用ensureCapacity 方法可以提升性能。不过,这个性能差距几乎可以忽略不计。而且,实际项目根本也不可能往 ArrayList 里面添加这么多元素。\n"},{"id":458,"href":"/zh/docs/technology/Interview/system-design/framework/spring/async1/","title":"Async 注解原理分析","section":"Framework","content":"@Async 注解由 Spring 框架提供,被该注解标注的类或方法会在 异步线程 中执行。这意味着当方法被调用时,调用者将不会等待该方法执行完成,而是可以继续执行后续的代码。\n@Async 注解的使用非常简单,需要两个步骤:\n在启动类上添加注解 @EnableAsync ,开启异步任务。 在需要异步执行的方法或类上添加注解 @Async 。 @SpringBootApplication // 开启异步任务 @EnableAsync public class YourApplication { public static void main(String[] args) { SpringApplication.run(YourApplication.class, args); } } // 异步服务类 @Service public class MyService { // 推荐使用自定义线程池,这里只是演示基本用法 @Async public CompletableFuture\u0026lt;String\u0026gt; doSomethingAsync() { // 这里会有一些业务耗时操作 // ... // 使用 CompletableFuture 可以更方便地处理异步任务的结果,避免阻塞主线程 return CompletableFuture.completedFuture(\u0026#34;Async Task Completed\u0026#34;); } } 接下来,我们一起来看看 @Async 的底层原理。\n@Async 原理分析 # @Async 可以异步执行任务,本质上是使用 动态代理 来实现的。通过 Spring 中的后置处理器 BeanPostProcessor 为使用 @Async 注解的类创建动态代理,之后 @Async 注解方法的调用会被动态代理拦截,在拦截器中将方法的执行封装为异步任务提交给线程池处理。\n接下来,我们来详细分析一下。\n开启异步 # 使用 @Async 之前,需要在启动类上添加 @EnableAsync 来开启异步,@EnableAsync 注解如下:\n// 省略其他注解 ... @Import(AsyncConfigurationSelector.class) public @interface EnableAsync { /* ... */ } 在 @EnableAsync 注解上通过 @Import 注解引入了 AsyncConfigurationSelector ,因此 Spring 会去加载通过 @Import 注解引入的类。\nAsyncConfigurationSelector 类实现了 ImportSelector 接口,因此在该类中会重写 selectImports() 方法来自定义加载 Bean 的逻辑,如下:\npublic class AsyncConfigurationSelector extends AdviceModeImportSelector\u0026lt;EnableAsync\u0026gt; { @Override @Nullable public String[] selectImports(AdviceMode adviceMode) { switch (adviceMode) { // 基于 JDK 代理织入的通知 case PROXY: return new String[] {ProxyAsyncConfiguration.class.getName()}; // 基于 AspectJ 织入的通知 case ASPECTJ: return new String[] {ASYNC_EXECUTION_ASPECT_CONFIGURATION_CLASS_NAME}; default: return null; } } } 在 selectImports() 方法中,会根据通知的不同类型来选择加载不同的类,其中 adviceMode 默认值为 PROXY 。\n这里以基于 JDK 代理的通知为例,此时会加载 ProxyAsyncConfiguration 类,如下:\n@Configuration @Role(BeanDefinition.ROLE_INFRASTRUCTURE) public class ProxyAsyncConfiguration extends AbstractAsyncConfiguration { @Bean(name = TaskManagementConfigUtils.ASYNC_ANNOTATION_PROCESSOR_BEAN_NAME) @Role(BeanDefinition.ROLE_INFRASTRUCTURE) public AsyncAnnotationBeanPostProcessor asyncAdvisor() { // ... // 加载后置处理器 AsyncAnnotationBeanPostProcessor bpp = new AsyncAnnotationBeanPostProcessor(); // ... return bpp; } } 后置处理器 # 在 ProxyAsyncConfiguration 类中,会通过 @Bean 注解加载一个后置处理器 AsyncAnnotationBeanPostProcessor ,这个后置处理器是使 @Async 注解起作用的关键。\n如果某一个类或者方法上使用了 @Async 注解,AsyncAnnotationBeanPostProcessor 处理器就会为该类创建一个动态代理。\n该类的方法在执行时,会被代理对象的拦截器所拦截,其中被 @Async 注解标记的方法会异步执行。\nAsyncAnnotationBeanPostProcessor 代码如下:\npublic class AsyncAnnotationBeanPostProcessor extends AbstractBeanFactoryAwareAdvisingPostProcessor { @Override public void setBeanFactory(BeanFactory beanFactory) { super.setBeanFactory(beanFactory); // 创建 AsyncAnnotationAdvisor,它是一个 Advisor // 用于拦截带有 @Async 注解的方法并将这些方法异步执行。 AsyncAnnotationAdvisor advisor = new AsyncAnnotationAdvisor(this.executor, this.exceptionHandler); // 如果设置了自定义的 asyncAnnotationType,则将其设置到 advisor 中。 // asyncAnnotationType 用于指定自定义的异步注解,例如 @MyAsync。 if (this.asyncAnnotationType != null) { advisor.setAsyncAnnotationType(this.asyncAnnotationType); } advisor.setBeanFactory(beanFactory); this.advisor = advisor; } } AsyncAnnotationBeanPostProcessor 的父类实现了 BeanFactoryAware 接口,因此在该类中重写了 setBeanFactory() 方法作为扩展点,来加载 AsyncAnnotationAdvisor 。\n创建 Advisor # Advisor 是 Spring AOP 对 Advice 和 Pointcut 的抽象。Advice 为执行的通知逻辑,Pointcut 为通知执行的切入点。\n在后置处理器 AsyncAnnotationBeanPostProcessor 中会去创建 AsyncAnnotationAdvisor , 在它的构造方法中,会构建对应的 Advice 和 Pointcut ,如下:\npublic class AsyncAnnotationAdvisor extends AbstractPointcutAdvisor implements BeanFactoryAware { private Advice advice; // 异步执行的 Advice private Pointcut pointcut; // 匹配 @Async 注解方法的切点 // 构造函数 public AsyncAnnotationAdvisor(/* 参数省略 */) { // 1. 创建 Advice,负责异步执行逻辑 this.advice = buildAdvice(executor, exceptionHandler); // 2. 创建 Pointcut,选择要被增强的目标方法 this.pointcut = buildPointcut(asyncAnnotationTypes); } // 创建 Advice protected Advice buildAdvice(/* 参数省略 */) { // 创建处理异步执行的拦截器 AnnotationAsyncExecutionInterceptor interceptor = new AnnotationAsyncExecutionInterceptor(null); // 使用执行器和异常处理器配置拦截器 interceptor.configure(executor, exceptionHandler); return interceptor; } // 创建 Pointcut protected Pointcut buildPointcut(Set\u0026lt;Class\u0026lt;? extends Annotation\u0026gt;\u0026gt; asyncAnnotationTypes) { ComposablePointcut result = null; for (Class\u0026lt;? extends Annotation\u0026gt; asyncAnnotationType : asyncAnnotationTypes) { // 1. 类级别切点:如果类上有注解则匹配 Pointcut cpc = new AnnotationMatchingPointcut(asyncAnnotationType, true); // 2. 方法级别切点:如果方法上有注解则匹配 Pointcut mpc = new AnnotationMatchingPointcut(null, asyncAnnotationType, true); if (result == null) { result = new ComposablePointcut(cpc); } else { // 使用 union 合并之前的切点 result.union(cpc); } // 将方法级别切点添加到组合切点 result = result.union(mpc); } // 返回组合切点,如果没有提供注解类型则返回 Pointcut.TRUE return (result != null ? result : Pointcut.TRUE); } } AsyncAnnotationAdvisor 的核心在于构建 Advice 和 Pointcut :\n构建 Advice :会创建 AnnotationAsyncExecutionInterceptor 拦截器,在拦截器的 invoke() 方法中会执行通知的逻辑。 构建 Pointcut :由 ClassFilter 和 MethodMatcher 组成,用于匹配哪些方法需要执行通知( Advice )的逻辑。 后置处理逻辑 # AsyncAnnotationBeanPostProcessor 后置处理器中实现的 postProcessAfterInitialization() 方法在其父类 AbstractAdvisingBeanPostProcessor 中,在 Bean 初始化之后,会进入到 postProcessAfterInitialization() 方法进行后置处理。\n在后置处理方法中,会判断 Bean 是否符合后置处理器中 Advisor 通知的条件,如果符合,则创建代理对象。如下:\n// AbstractAdvisingBeanPostProcessor public Object postProcessAfterInitialization(Object bean, String beanName) { if (this.advisor == null || bean instanceof AopInfrastructureBean) { return bean; } if (bean instanceof Advised) { Advised advised = (Advised) bean; if (!advised.isFrozen() \u0026amp;\u0026amp; isEligible(AopUtils.getTargetClass(bean))) { if (this.beforeExistingAdvisors) { advised.addAdvisor(0, this.advisor); } else { advised.addAdvisor(this.advisor); } return bean; } } // 判断给定的 Bean 是否符合后置处理器中 Advisor 通知的条件,符合的话,就创建代理对象。 if (isEligible(bean, beanName)) { ProxyFactory proxyFactory = prepareProxyFactory(bean, beanName); if (!proxyFactory.isProxyTargetClass()) { evaluateProxyInterfaces(bean.getClass(), proxyFactory); } // 添加 Advisor。 proxyFactory.addAdvisor(this.advisor); customizeProxyFactory(proxyFactory); // 返回代理对象。 return proxyFactory.getProxy(getProxyClassLoader()); } return bean; } @Async 注解方法的拦截 # @Async 注解方法的执行会在 AnnotationAsyncExecutionInterceptor 中被拦截,在 invoke() 方法中执行拦截器的逻辑。此时会将 @Async 注解标注的方法封装为异步任务,交给执行器来执行。\ninvoke() 方法在 AnnotationAsyncExecutionInterceptor 的父类 AsyncExecutionInterceptor 中定义,如下:\npublic class AsyncExecutionInterceptor extends AsyncExecutionAspectSupport implements MethodInterceptor, Ordered { @Override @Nullable public Object invoke(final MethodInvocation invocation) throws Throwable { Class\u0026lt;?\u0026gt; targetClass = (invocation.getThis() != null ? AopUtils.getTargetClass(invocation.getThis()) : null); Method specificMethod = ClassUtils.getMostSpecificMethod(invocation.getMethod(), targetClass); final Method userDeclaredMethod = BridgeMethodResolver.findBridgedMethod(specificMethod); // 1、确定异步任务执行器 AsyncTaskExecutor executor = determineAsyncExecutor(userDeclaredMethod); // 2、将要执行的方法封装为 Callable 异步任务 Callable\u0026lt;Object\u0026gt; task = () -\u0026gt; { try { // 2.1、执行方法 Object result = invocation.proceed(); // 2.2、如果方法返回值是 Future 类型,阻塞等待结果 if (result instanceof Future) { return ((Future\u0026lt;?\u0026gt;) result).get(); } } catch (ExecutionException ex) { handleError(ex.getCause(), userDeclaredMethod, invocation.getArguments()); } catch (Throwable ex) { handleError(ex, userDeclaredMethod, invocation.getArguments()); } return null; }; // 3、提交任务 return doSubmit(task, executor, invocation.getMethod().getReturnType()); } } 在 invoke() 方法中,主要有 3 个步骤:\n确定执行异步任务的执行器。 将 @Async 注解标注的方法封装为 Callable 异步任务。 将任务提交给执行器执行。 1、获取异步任务执行器 # 在 determineAsyncExecutor() 方法中,会获取异步任务的执行器(即执行异步任务的 线程池 )。代码如下:\n// 确定异步任务的执行器 protected AsyncTaskExecutor determineAsyncExecutor(Method method) { // 1、先从缓存中获取。 AsyncTaskExecutor executor = this.executors.get(method); if (executor == null) { Executor targetExecutor; // 2、获取执行器的限定符。 String qualifier = getExecutorQualifier(method); if (StringUtils.hasLength(qualifier)) { // 3、根据限定符获取对应的执行器。 targetExecutor = findQualifiedExecutor(this.beanFactory, qualifier); } else { // 4、如果没有限定符,则使用默认的执行器。即 Spring 提供的默认线程池:SimpleAsyncTaskExecutor。 targetExecutor = this.defaultExecutor.get(); } if (targetExecutor == null) { return null; } // 5、将执行器包装为 TaskExecutorAdapter 适配器。 // TaskExecutorAdapter 是 Spring 对于 JDK 线程池做的一层抽象,还是继承自 JDK 的线程池 Executor。这里可以不用管太多,只要知道它是线程池就可以了。 executor = (targetExecutor instanceof AsyncListenableTaskExecutor ? (AsyncListenableTaskExecutor) targetExecutor : new TaskExecutorAdapter(targetExecutor)); this.executors.put(method, executor); } return executor; } 在 determineAsyncExecutor() 方法中确定了异步任务的执行器(线程池),主要是通过 @Async 注解的 value 值来获取执行器的限定符,根据限定符再去 BeanFactory 中查找对应的执行器就可以了。\n如果在 @Async 注解中没有指定线程池,则会通过 this.defaultExecutor.get() 来获取默认的线程池,其中 defaultExecutor 在下边方法中进行赋值:\n// AsyncExecutionInterceptor protected Executor getDefaultExecutor(@Nullable BeanFactory beanFactory) { // 1、尝试从 beanFactory 中获取线程池。 Executor defaultExecutor = super.getDefaultExecutor(beanFactory); // 2、如果 beanFactory 中没有,则创建 SimpleAsyncTaskExecutor 线程池。 return (defaultExecutor != null ? defaultExecutor : new SimpleAsyncTaskExecutor()); } 其中 super.getDefaultExecutor() 会在 beanFactory 中尝试获取 Executor 类型的线程池。代码如下:\nprotected Executor getDefaultExecutor(@Nullable BeanFactory beanFactory) { if (beanFactory != null) { try { // 1、从 beanFactory 中获取 TaskExecutor 类型的线程池。 return beanFactory.getBean(TaskExecutor.class); } catch (NoUniqueBeanDefinitionException ex) { try { // 2、如果有多个,则尝试从 beanFactory 中获取执行名称的 Executor 线程池。 return beanFactory.getBean(DEFAULT_TASK_EXECUTOR_BEAN_NAME, Executor.class); } catch (NoSuchBeanDefinitionException ex2) { if (logger.isInfoEnabled()) { // ... } } } catch (NoSuchBeanDefinitionException ex) { try { // 3、如果没有,则尝试从 beanFactory 中获取执行名称的 Executor 线程池。 return beanFactory.getBean(DEFAULT_TASK_EXECUTOR_BEAN_NAME, Executor.class); } catch (NoSuchBeanDefinitionException ex2) { // ... } } } return null; } 在 getDefaultExecutor() 中,如果从 beanFactory 获取线程池失败的话,则会创建 SimpleAsyncTaskExecutor 线程池。\n该线程池的在每次执行异步任务时,都会创建一个新的线程去执行任务,并不会对线程进行复用,从而导致异步任务执行的开销很大。一旦在 @Async 注解标注的方法某一瞬间并发量剧增,应用就会大量创建线程,从而影响服务质量甚至出现服务不可用。\n同一时刻如果向 SimpleAsyncTaskExecutor 线程池提交 10000 个任务,那么该线程池就会创建 10000 个线程,其的 execute() 方法如下:\n// SimpleAsyncTaskExecutor:execute() 内部会调用 doExecute() protected void doExecute(Runnable task) { // 创建新线程 Thread thread = (this.threadFactory != null ? this.threadFactory.newThread(task) : createThread(task)); thread.start(); } 建议:在使用 @Async 时需要自己指定线程池,避免 Spring 默认线程池带来的风险。\n在 @Async 注解中的 value 指定了线程池的限定符,根据限定符可以获取 自定义的线程池 。获取限定符的代码如下:\n// AnnotationAsyncExecutionInterceptor protected String getExecutorQualifier(Method method) { // 1.从方法上获取 Async 注解。 Async async = AnnotatedElementUtils.findMergedAnnotation(method, Async.class); // 2. 如果方法上没有找到 @Async 注解,则尝试从方法所在的类上获取 @Async 注解。 if (async == null) { async = AnnotatedElementUtils.findMergedAnnotation(method.getDeclaringClass(), Async.class); } // 3. 如果找到了 @Async 注解,则获取注解的 value 值并返回,作为线程池的限定符。 // 如果 \u0026#34;value\u0026#34; 属性值为空字符串,则使用默认的线程池。 // 如果没有找到 @Async 注解,则返回 null,同样使用默认的线程池。 return (async != null ? async.value() : null); } 2、将方法封装为异步任务 # 在 invoke() 方法获取执行器之后,会将方法封装为异步任务,代码如下:\n// 将要执行的方法封装为 Callable 异步任务 Callable\u0026lt;Object\u0026gt; task = () -\u0026gt; { try { // 2.1、执行被拦截的方法 (proceed() 方法是 AOP 中的核心方法,用于执行目标方法) Object result = invocation.proceed(); // 2.2、如果被拦截方法的返回值是 Future 类型,则需要阻塞等待结果, // 并将 Future 的结果作为异步任务的结果返回。 这是为了处理异步方法嵌套调用的情况。 // 例如,一个异步方法内部调用了另一个异步方法,则需要等待内部异步方法执行完成, // 才能返回最终的结果。 if (result instanceof Future) { return ((Future\u0026lt;?\u0026gt;) result).get(); // 阻塞等待 Future 的结果 } } catch (ExecutionException ex) { // 2.3、处理 ExecutionException 异常。 ExecutionException 是 Future.get() 方法抛出的异常, handleError(ex.getCause(), userDeclaredMethod, invocation.getArguments()); // 处理原始异常 } catch (Throwable ex) { // 2.4、处理其他类型的异常。 将异常、被拦截的方法和方法参数作为参数调用 handleError() 方法进行处理。 handleError(ex, userDeclaredMethod, invocation.getArguments()); } // 2.5、如果方法返回值不是 Future 类型,或者发生异常,则返回 null。 return null; }; 相比于 Runnable ,Callable 可以返回结果,并且抛出异常。\n将 invocation.proceed() 的执行(原方法的执行)封装为 Callable 异步任务。这里仅仅当 result (方法返回值)类型为 Future 才返回,如果是其他类型则直接返回 null 。\n因此使用 @Async 注解标注的方法如果使用 Future 类型之外的返回值,则无法获取方法的执行结果。\n3、提交异步任务 # 在 AsyncExecutionInterceptor # invoke() 中将要执行的方法封装为 Callable 任务之后,就会将任务交给执行器来执行。提交相关的代码如下:\nprotected Object doSubmit(Callable\u0026lt;Object\u0026gt; task, AsyncTaskExecutor executor, Class\u0026lt;?\u0026gt; returnType) { // 根据方法的返回值类型,选择不同的异步执行方式并返回结果。 // 1. 如果方法返回值是 CompletableFuture 类型 if (CompletableFuture.class.isAssignableFrom(returnType)) { // 使用 CompletableFuture.supplyAsync() 方法异步执行任务。 return CompletableFuture.supplyAsync(() -\u0026gt; { try { return task.call(); } catch (Throwable ex) { throw new CompletionException(ex); // 将异常包装为 CompletionException,以便在 future.get() 时抛出 } }, executor); } // 2. 如果方法返回值是 ListenableFuture 类型 else if (ListenableFuture.class.isAssignableFrom(returnType)) { // 将 AsyncTaskExecutor 强制转换为 AsyncListenableTaskExecutor, // 并调用 submitListenable() 方法提交任务。 // AsyncListenableTaskExecutor 是 ListenableFuture 的专用异步执行器, // 它可以返回一个 ListenableFuture 对象,允许添加回调函数来监听任务的完成。 return ((AsyncListenableTaskExecutor) executor).submitListenable(task); } // 3. 如果方法返回值是 Future 类型 else if (Future.class.isAssignableFrom(returnType)) { // 直接调用 AsyncTaskExecutor 的 submit() 方法提交任务,并返回一个 Future 对象。 return executor.submit(task); } // 4. 如果方法返回值是 void 或其他类型 else { // 直接调用 AsyncTaskExecutor 的 submit() 方法提交任务。 // 由于方法返回值是 void,因此不需要返回任何结果,直接返回 null。 executor.submit(task); return null; } } 在 doSubmit() 方法中,会根据 @Async 注解标注方法的返回值不同,来选择不同的任务提交方式,最后任务会由执行器(线程池)执行。\n总结 # 理解 @Async 原理的核心在于理解 @EnableAsync 注解,该注解开启了异步任务的功能。\n主要流程如上图,会通过后置处理器来创建代理对象,之后代理对象中 @Async 方法的执行会走到 Advice 内部的拦截器中,之后将方法封装为异步任务,并提交线程池进行处理。\n@Async 使用建议 # 自定义线程池 # 如果没有显式地配置线程池,在 @Async 底层会先在 BeanFactory 中尝试获取线程池,如果获取不到,则会创建一个 SimpleAsyncTaskExecutor 实现。SimpleAsyncTaskExecutor 本质上不算是一个真正的线程池,因为它对于每个请求都会启动一个新线程而不重用现有线程,这会带来一些潜在的问题,例如资源消耗过大。\n具体线程池获取可以参考这篇文章: 浅析 Spring 中 Async 注解底层异步线程池原理|得物技术。\n一定要显式配置一个线程池,推荐ThreadPoolTaskExecutor。并且,还可以根据任务的性质和需求,为不同的异步方法指定不同的线程池。\n@Configuration @EnableAsync public class AsyncConfig { @Bean(name = \u0026#34;executor1\u0026#34;) public Executor executor1() { ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor(); executor.setCorePoolSize(3); executor.setMaxPoolSize(5); executor.setQueueCapacity(50); executor.setThreadNamePrefix(\u0026#34;AsyncExecutor1-\u0026#34;); executor.initialize(); return executor; } @Bean(name = \u0026#34;executor2\u0026#34;) public Executor executor2() { ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor(); executor.setCorePoolSize(2); executor.setMaxPoolSize(4); executor.setQueueCapacity(100); executor.setThreadNamePrefix(\u0026#34;AsyncExecutor2-\u0026#34;); executor.initialize(); return executor; } } @Async 注解中指定线程池的 Bean 名称:\n@Service public class AsyncService { @Async(\u0026#34;executor1\u0026#34;) public void performTask1() { // 任务1的逻辑 System.out.println(\u0026#34;Executing Task1 with Executor1\u0026#34;); } @Async(\u0026#34;executor2\u0026#34;) public void performTask2() { // 任务2的逻辑 System.out.println(\u0026#34;Executing Task2 with Executor2\u0026#34;); } } 避免 @Async 注解失效 # @Async 注解会在以下几个场景失效,需要注意:\n1、同一类中调用异步方法\n如果你在同一个类内部调用一个@Async注解的方法,那这个方法将不会异步执行。\n@Service public class MyService { public void myMethod() { // 直接通过 this 引用调用,绕过了 Spring 的代理机制,异步执行失效 asyncMethod(); } @Async public void asyncMethod() { // 异步执行的逻辑 } } 这是因为 Spring 的异步机制是通过 代理 实现的,而在同一个类内部的方法调用会绕过 Spring 的代理机制,也就是绕过了代理对象,直接通过 this 引用调用的。由于没有经过代理,所有的代理相关的处理(即将任务提交线程池异步执行)都不会发生。\n为了避免这个问题,比较推荐的做法是将异步方法移至另一个 Spring Bean 中。\n@Service public class AsyncService { @Async public void asyncMethod() { // 异步执行的逻辑 } } @Service public class MyService { @Autowired private AsyncService asyncService; public void myMethod() { asyncService.asyncMethod(); } } 2、使用 static 关键字修饰异步方法\n如果@Async注解的方法被 static 关键字修饰,那这个方法将不会异步执行。\n这是因为 Spring 的异步机制是通过代理实现的,由于静态方法不属于实例而是属于类且不参与继承,Spring 的代理机制(无论是基于 JDK 还是 CGLIB)无法拦截静态方法来提供如异步执行这样的增强功能。\n篇幅问题,这里没有进一步详细介绍,不了解的代理机制的朋友,可以看看我写的 Java 代理模式详解这篇文章。\n如果你需要异步执行一个静态方法的逻辑,可以考虑设计一个非静态的包装方法,这个包装方法使用 @Async 注解,并在其内部调用静态方法\n@Service public class AsyncService { @Async public void asyncWrapper() { // 调用静态方法 SClass.staticMethod(); } } public class SClass { public static void staticMethod() { // 执行一些操作 } } 3、忘记开启异步支持\nSpring Boot 默认情况下不启用异步支持,确保在主配置类 Application 上添加@EnableAsync注解以启用异步功能。\n@SpringBootApplication @EnableAsync public class Application { public static void main(String[] args) { SpringApplication.run(Application.class, args); } } 4、@Async 注解的方法所在的类必须是 Spring Bean\n@Async 注解的方法必须位于 Spring 管理的 Bean 中,只有这样,Spring 才能在创建 Bean 时应用代理,代理能够拦截方法调用并实现异步执行的逻辑。如果该方法不在 Spring 管理的 bean 中,Spring 就无法创建必要的代理,@Async 注解就不会产生任何效果。\n返回值类型 # 建议将 @Async 注解方法的返回值类型定义为 void 和 Future 。\n如果不需要获取异步方法返回的结果,将返回值类型定义为 void 。 如果需要获取异步方法返回的结果,将返回值类型定义为 Future(例如CompletableFuture 、 ListenableFuture )。 如果将 @Async 注解方法的返回值定义为其他类型(如 Object 、 String 等等),则无法获取方法返回值。\n这种设计符合异步编程的基本原则,即调用者不应立即期待一个结果,而是应该能够在未来某个时间点获取结果。如果返回类型是 Future,调用者可以使用这个返回的 Future 对象来查询任务的状态,取消任务,或者在任务完成时获取结果。\n处理异步方法中的异常 # 异步方法中抛出的异常默认不会被调用者捕获。为了管理这些异常,建议使用CompletableFuture的异常处理功能,或者配置一个全局的AsyncUncaughtExceptionHandler来处理没有正确捕获的异常。\n@Configuration @EnableAsync public class AsyncConfig implements AsyncConfigurer{ @Override public AsyncUncaughtExceptionHandler getAsyncUncaughtExceptionHandler() { return new CustomAsyncExceptionHandler(); } } // 自定义异常处理器 class CustomAsyncExceptionHandler implements AsyncUncaughtExceptionHandler { @Override public void handleUncaughtException(Throwable ex, Method method, Object... params) { // 日志记录或其他处理逻辑 } } 未考虑事务管理 # @Async注解的方法需要事务支持时,务必在该异步方法上独立使用。\n@Service public class AsyncTransactionalService { @Async // Propagation.REQUIRES_NEW 表示 Spring 在执行异步方法时开启一个新的、与当前事务无关的事务 @Transactional(propagation = Propagation.REQUIRES_NEW) public void asyncTransactionalMethod() { // 这里的操作会在新的事务中执行 // 执行一些数据库操作 } } 未指定异步方法执行顺序 # @Async注解的方法执行是非阻塞的,它们可能以任意顺序完成。如果需要按照特定的顺序处理结果,你可以将方法的返回值设定为 Future 或 CompletableFuture ,通过返回值对象来实现一个方法在另一个方法完成后再执行。\n@Async public CompletableFuture\u0026lt;String\u0026gt; fetchDataAsync() { return CompletableFuture.completedFuture(\u0026#34;Data\u0026#34;); } @Async public CompletableFuture\u0026lt;String\u0026gt; processDataAsync(String data) { return CompletableFuture.supplyAsync(() -\u0026gt; \u0026#34;Processed \u0026#34; + data); } processDataAsync 方法在 fetchDataAsync后执行:\nCompletableFuture\u0026lt;String\u0026gt; dataFuture = asyncService.fetchDataAsync(); dataFuture.thenCompose(data -\u0026gt; asyncService.processDataAsync(data)) .thenAccept(result -\u0026gt; System.out.println(result)); # "},{"id":459,"href":"/zh/docs/technology/Interview/system-design/framework/spring/Async/","title":"Async 注解原理分析","section":"Framework","content":"@Async 注解由 Spring 框架提供,被该注解标注的类或方法会在 异步线程 中执行。这意味着当方法被调用时,调用者将不会等待该方法执行完成,而是可以继续执行后续的代码。\n@Async 注解的使用非常简单,需要两个步骤:\n在启动类上添加注解 @EnableAsync ,开启异步任务。 在需要异步执行的方法或类上添加注解 @Async 。 @SpringBootApplication // 开启异步任务 @EnableAsync public class YourApplication { public static void main(String[] args) { SpringApplication.run(YourApplication.class, args); } } // 异步服务类 @Service public class MyService { // 推荐使用自定义线程池,这里只是演示基本用法 @Async public CompletableFuture\u0026lt;String\u0026gt; doSomethingAsync() { // 这里会有一些业务耗时操作 // ... // 使用 CompletableFuture 可以更方便地处理异步任务的结果,避免阻塞主线程 return CompletableFuture.completedFuture(\u0026#34;Async Task Completed\u0026#34;); } } 接下来,我们一起来看看 @Async 的底层原理。\n@Async 原理分析 # @Async 可以异步执行任务,本质上是使用 动态代理 来实现的。通过 Spring 中的后置处理器 BeanPostProcessor 为使用 @Async 注解的类创建动态代理,之后 @Async 注解方法的调用会被动态代理拦截,在拦截器中将方法的执行封装为异步任务提交给线程池处理。\n接下来,我们来详细分析一下。\n开启异步 # 使用 @Async 之前,需要在启动类上添加 @EnableAsync 来开启异步,@EnableAsync 注解如下:\n// 省略其他注解 ... @Import(AsyncConfigurationSelector.class) public @interface EnableAsync { /* ... */ } 在 @EnableAsync 注解上通过 @Import 注解引入了 AsyncConfigurationSelector ,因此 Spring 会去加载通过 @Import 注解引入的类。\nAsyncConfigurationSelector 类实现了 ImportSelector 接口,因此在该类中会重写 selectImports() 方法来自定义加载 Bean 的逻辑,如下:\npublic class AsyncConfigurationSelector extends AdviceModeImportSelector\u0026lt;EnableAsync\u0026gt; { @Override @Nullable public String[] selectImports(AdviceMode adviceMode) { switch (adviceMode) { // 基于 JDK 代理织入的通知 case PROXY: return new String[] {ProxyAsyncConfiguration.class.getName()}; // 基于 AspectJ 织入的通知 case ASPECTJ: return new String[] {ASYNC_EXECUTION_ASPECT_CONFIGURATION_CLASS_NAME}; default: return null; } } } 在 selectImports() 方法中,会根据通知的不同类型来选择加载不同的类,其中 adviceMode 默认值为 PROXY 。\n这里以基于 JDK 代理的通知为例,此时会加载 ProxyAsyncConfiguration 类,如下:\n@Configuration @Role(BeanDefinition.ROLE_INFRASTRUCTURE) public class ProxyAsyncConfiguration extends AbstractAsyncConfiguration { @Bean(name = TaskManagementConfigUtils.ASYNC_ANNOTATION_PROCESSOR_BEAN_NAME) @Role(BeanDefinition.ROLE_INFRASTRUCTURE) public AsyncAnnotationBeanPostProcessor asyncAdvisor() { // ... // 加载后置处理器 AsyncAnnotationBeanPostProcessor bpp = new AsyncAnnotationBeanPostProcessor(); // ... return bpp; } } 后置处理器 # 在 ProxyAsyncConfiguration 类中,会通过 @Bean 注解加载一个后置处理器 AsyncAnnotationBeanPostProcessor ,这个后置处理器是使 @Async 注解起作用的关键。\n如果某一个类或者方法上使用了 @Async 注解,AsyncAnnotationBeanPostProcessor 处理器就会为该类创建一个动态代理。\n该类的方法在执行时,会被代理对象的拦截器所拦截,其中被 @Async 注解标记的方法会异步执行。\nAsyncAnnotationBeanPostProcessor 代码如下:\npublic class AsyncAnnotationBeanPostProcessor extends AbstractBeanFactoryAwareAdvisingPostProcessor { @Override public void setBeanFactory(BeanFactory beanFactory) { super.setBeanFactory(beanFactory); // 创建 AsyncAnnotationAdvisor,它是一个 Advisor // 用于拦截带有 @Async 注解的方法并将这些方法异步执行。 AsyncAnnotationAdvisor advisor = new AsyncAnnotationAdvisor(this.executor, this.exceptionHandler); // 如果设置了自定义的 asyncAnnotationType,则将其设置到 advisor 中。 // asyncAnnotationType 用于指定自定义的异步注解,例如 @MyAsync。 if (this.asyncAnnotationType != null) { advisor.setAsyncAnnotationType(this.asyncAnnotationType); } advisor.setBeanFactory(beanFactory); this.advisor = advisor; } } AsyncAnnotationBeanPostProcessor 的父类实现了 BeanFactoryAware 接口,因此在该类中重写了 setBeanFactory() 方法作为扩展点,来加载 AsyncAnnotationAdvisor 。\n创建 Advisor # Advisor 是 Spring AOP 对 Advice 和 Pointcut 的抽象。Advice 为执行的通知逻辑,Pointcut 为通知执行的切入点。\n在后置处理器 AsyncAnnotationBeanPostProcessor 中会去创建 AsyncAnnotationAdvisor , 在它的构造方法中,会构建对应的 Advice 和 Pointcut ,如下:\npublic class AsyncAnnotationAdvisor extends AbstractPointcutAdvisor implements BeanFactoryAware { private Advice advice; // 异步执行的 Advice private Pointcut pointcut; // 匹配 @Async 注解方法的切点 // 构造函数 public AsyncAnnotationAdvisor(/* 参数省略 */) { // 1. 创建 Advice,负责异步执行逻辑 this.advice = buildAdvice(executor, exceptionHandler); // 2. 创建 Pointcut,选择要被增强的目标方法 this.pointcut = buildPointcut(asyncAnnotationTypes); } // 创建 Advice protected Advice buildAdvice(/* 参数省略 */) { // 创建处理异步执行的拦截器 AnnotationAsyncExecutionInterceptor interceptor = new AnnotationAsyncExecutionInterceptor(null); // 使用执行器和异常处理器配置拦截器 interceptor.configure(executor, exceptionHandler); return interceptor; } // 创建 Pointcut protected Pointcut buildPointcut(Set\u0026lt;Class\u0026lt;? extends Annotation\u0026gt;\u0026gt; asyncAnnotationTypes) { ComposablePointcut result = null; for (Class\u0026lt;? extends Annotation\u0026gt; asyncAnnotationType : asyncAnnotationTypes) { // 1. 类级别切点:如果类上有注解则匹配 Pointcut cpc = new AnnotationMatchingPointcut(asyncAnnotationType, true); // 2. 方法级别切点:如果方法上有注解则匹配 Pointcut mpc = new AnnotationMatchingPointcut(null, asyncAnnotationType, true); if (result == null) { result = new ComposablePointcut(cpc); } else { // 使用 union 合并之前的切点 result.union(cpc); } // 将方法级别切点添加到组合切点 result = result.union(mpc); } // 返回组合切点,如果没有提供注解类型则返回 Pointcut.TRUE return (result != null ? result : Pointcut.TRUE); } } AsyncAnnotationAdvisor 的核心在于构建 Advice 和 Pointcut :\n构建 Advice :会创建 AnnotationAsyncExecutionInterceptor 拦截器,在拦截器的 invoke() 方法中会执行通知的逻辑。 构建 Pointcut :由 ClassFilter 和 MethodMatcher 组成,用于匹配哪些方法需要执行通知( Advice )的逻辑。 后置处理逻辑 # AsyncAnnotationBeanPostProcessor 后置处理器中实现的 postProcessAfterInitialization() 方法在其父类 AbstractAdvisingBeanPostProcessor 中,在 Bean 初始化之后,会进入到 postProcessAfterInitialization() 方法进行后置处理。\n在后置处理方法中,会判断 Bean 是否符合后置处理器中 Advisor 通知的条件,如果符合,则创建代理对象。如下:\n// AbstractAdvisingBeanPostProcessor public Object postProcessAfterInitialization(Object bean, String beanName) { if (this.advisor == null || bean instanceof AopInfrastructureBean) { return bean; } if (bean instanceof Advised) { Advised advised = (Advised) bean; if (!advised.isFrozen() \u0026amp;\u0026amp; isEligible(AopUtils.getTargetClass(bean))) { if (this.beforeExistingAdvisors) { advised.addAdvisor(0, this.advisor); } else { advised.addAdvisor(this.advisor); } return bean; } } // 判断给定的 Bean 是否符合后置处理器中 Advisor 通知的条件,符合的话,就创建代理对象。 if (isEligible(bean, beanName)) { ProxyFactory proxyFactory = prepareProxyFactory(bean, beanName); if (!proxyFactory.isProxyTargetClass()) { evaluateProxyInterfaces(bean.getClass(), proxyFactory); } // 添加 Advisor。 proxyFactory.addAdvisor(this.advisor); customizeProxyFactory(proxyFactory); // 返回代理对象。 return proxyFactory.getProxy(getProxyClassLoader()); } return bean; } @Async 注解方法的拦截 # @Async 注解方法的执行会在 AnnotationAsyncExecutionInterceptor 中被拦截,在 invoke() 方法中执行拦截器的逻辑。此时会将 @Async 注解标注的方法封装为异步任务,交给执行器来执行。\ninvoke() 方法在 AnnotationAsyncExecutionInterceptor 的父类 AsyncExecutionInterceptor 中定义,如下:\npublic class AsyncExecutionInterceptor extends AsyncExecutionAspectSupport implements MethodInterceptor, Ordered { @Override @Nullable public Object invoke(final MethodInvocation invocation) throws Throwable { Class\u0026lt;?\u0026gt; targetClass = (invocation.getThis() != null ? AopUtils.getTargetClass(invocation.getThis()) : null); Method specificMethod = ClassUtils.getMostSpecificMethod(invocation.getMethod(), targetClass); final Method userDeclaredMethod = BridgeMethodResolver.findBridgedMethod(specificMethod); // 1、确定异步任务执行器 AsyncTaskExecutor executor = determineAsyncExecutor(userDeclaredMethod); // 2、将要执行的方法封装为 Callable 异步任务 Callable\u0026lt;Object\u0026gt; task = () -\u0026gt; { try { // 2.1、执行方法 Object result = invocation.proceed(); // 2.2、如果方法返回值是 Future 类型,阻塞等待结果 if (result instanceof Future) { return ((Future\u0026lt;?\u0026gt;) result).get(); } } catch (ExecutionException ex) { handleError(ex.getCause(), userDeclaredMethod, invocation.getArguments()); } catch (Throwable ex) { handleError(ex, userDeclaredMethod, invocation.getArguments()); } return null; }; // 3、提交任务 return doSubmit(task, executor, invocation.getMethod().getReturnType()); } } 在 invoke() 方法中,主要有 3 个步骤:\n确定执行异步任务的执行器。 将 @Async 注解标注的方法封装为 Callable 异步任务。 将任务提交给执行器执行。 1、获取异步任务执行器 # 在 determineAsyncExecutor() 方法中,会获取异步任务的执行器(即执行异步任务的 线程池 )。代码如下:\n// 确定异步任务的执行器 protected AsyncTaskExecutor determineAsyncExecutor(Method method) { // 1、先从缓存中获取。 AsyncTaskExecutor executor = this.executors.get(method); if (executor == null) { Executor targetExecutor; // 2、获取执行器的限定符。 String qualifier = getExecutorQualifier(method); if (StringUtils.hasLength(qualifier)) { // 3、根据限定符获取对应的执行器。 targetExecutor = findQualifiedExecutor(this.beanFactory, qualifier); } else { // 4、如果没有限定符,则使用默认的执行器。即 Spring 提供的默认线程池:SimpleAsyncTaskExecutor。 targetExecutor = this.defaultExecutor.get(); } if (targetExecutor == null) { return null; } // 5、将执行器包装为 TaskExecutorAdapter 适配器。 // TaskExecutorAdapter 是 Spring 对于 JDK 线程池做的一层抽象,还是继承自 JDK 的线程池 Executor。这里可以不用管太多,只要知道它是线程池就可以了。 executor = (targetExecutor instanceof AsyncListenableTaskExecutor ? (AsyncListenableTaskExecutor) targetExecutor : new TaskExecutorAdapter(targetExecutor)); this.executors.put(method, executor); } return executor; } 在 determineAsyncExecutor() 方法中确定了异步任务的执行器(线程池),主要是通过 @Async 注解的 value 值来获取执行器的限定符,根据限定符再去 BeanFactory 中查找对应的执行器就可以了。\n如果在 @Async 注解中没有指定线程池,则会通过 this.defaultExecutor.get() 来获取默认的线程池,其中 defaultExecutor 在下边方法中进行赋值:\n// AsyncExecutionInterceptor protected Executor getDefaultExecutor(@Nullable BeanFactory beanFactory) { // 1、尝试从 beanFactory 中获取线程池。 Executor defaultExecutor = super.getDefaultExecutor(beanFactory); // 2、如果 beanFactory 中没有,则创建 SimpleAsyncTaskExecutor 线程池。 return (defaultExecutor != null ? defaultExecutor : new SimpleAsyncTaskExecutor()); } 其中 super.getDefaultExecutor() 会在 beanFactory 中尝试获取 Executor 类型的线程池。代码如下:\nprotected Executor getDefaultExecutor(@Nullable BeanFactory beanFactory) { if (beanFactory != null) { try { // 1、从 beanFactory 中获取 TaskExecutor 类型的线程池。 return beanFactory.getBean(TaskExecutor.class); } catch (NoUniqueBeanDefinitionException ex) { try { // 2、如果有多个,则尝试从 beanFactory 中获取执行名称的 Executor 线程池。 return beanFactory.getBean(DEFAULT_TASK_EXECUTOR_BEAN_NAME, Executor.class); } catch (NoSuchBeanDefinitionException ex2) { if (logger.isInfoEnabled()) { // ... } } } catch (NoSuchBeanDefinitionException ex) { try { // 3、如果没有,则尝试从 beanFactory 中获取执行名称的 Executor 线程池。 return beanFactory.getBean(DEFAULT_TASK_EXECUTOR_BEAN_NAME, Executor.class); } catch (NoSuchBeanDefinitionException ex2) { // ... } } } return null; } 在 getDefaultExecutor() 中,如果从 beanFactory 获取线程池失败的话,则会创建 SimpleAsyncTaskExecutor 线程池。\n该线程池的在每次执行异步任务时,都会创建一个新的线程去执行任务,并不会对线程进行复用,从而导致异步任务执行的开销很大。一旦在 @Async 注解标注的方法某一瞬间并发量剧增,应用就会大量创建线程,从而影响服务质量甚至出现服务不可用。\n同一时刻如果向 SimpleAsyncTaskExecutor 线程池提交 10000 个任务,那么该线程池就会创建 10000 个线程,其的 execute() 方法如下:\n// SimpleAsyncTaskExecutor:execute() 内部会调用 doExecute() protected void doExecute(Runnable task) { // 创建新线程 Thread thread = (this.threadFactory != null ? this.threadFactory.newThread(task) : createThread(task)); thread.start(); } 建议:在使用 @Async 时需要自己指定线程池,避免 Spring 默认线程池带来的风险。\n在 @Async 注解中的 value 指定了线程池的限定符,根据限定符可以获取 自定义的线程池 。获取限定符的代码如下:\n// AnnotationAsyncExecutionInterceptor protected String getExecutorQualifier(Method method) { // 1.从方法上获取 Async 注解。 Async async = AnnotatedElementUtils.findMergedAnnotation(method, Async.class); // 2. 如果方法上没有找到 @Async 注解,则尝试从方法所在的类上获取 @Async 注解。 if (async == null) { async = AnnotatedElementUtils.findMergedAnnotation(method.getDeclaringClass(), Async.class); } // 3. 如果找到了 @Async 注解,则获取注解的 value 值并返回,作为线程池的限定符。 // 如果 \u0026#34;value\u0026#34; 属性值为空字符串,则使用默认的线程池。 // 如果没有找到 @Async 注解,则返回 null,同样使用默认的线程池。 return (async != null ? async.value() : null); } 2、将方法封装为异步任务 # 在 invoke() 方法获取执行器之后,会将方法封装为异步任务,代码如下:\n// 将要执行的方法封装为 Callable 异步任务 Callable\u0026lt;Object\u0026gt; task = () -\u0026gt; { try { // 2.1、执行被拦截的方法 (proceed() 方法是 AOP 中的核心方法,用于执行目标方法) Object result = invocation.proceed(); // 2.2、如果被拦截方法的返回值是 Future 类型,则需要阻塞等待结果, // 并将 Future 的结果作为异步任务的结果返回。 这是为了处理异步方法嵌套调用的情况。 // 例如,一个异步方法内部调用了另一个异步方法,则需要等待内部异步方法执行完成, // 才能返回最终的结果。 if (result instanceof Future) { return ((Future\u0026lt;?\u0026gt;) result).get(); // 阻塞等待 Future 的结果 } } catch (ExecutionException ex) { // 2.3、处理 ExecutionException 异常。 ExecutionException 是 Future.get() 方法抛出的异常, handleError(ex.getCause(), userDeclaredMethod, invocation.getArguments()); // 处理原始异常 } catch (Throwable ex) { // 2.4、处理其他类型的异常。 将异常、被拦截的方法和方法参数作为参数调用 handleError() 方法进行处理。 handleError(ex, userDeclaredMethod, invocation.getArguments()); } // 2.5、如果方法返回值不是 Future 类型,或者发生异常,则返回 null。 return null; }; 相比于 Runnable ,Callable 可以返回结果,并且抛出异常。\n将 invocation.proceed() 的执行(原方法的执行)封装为 Callable 异步任务。这里仅仅当 result (方法返回值)类型为 Future 才返回,如果是其他类型则直接返回 null 。\n因此使用 @Async 注解标注的方法如果使用 Future 类型之外的返回值,则无法获取方法的执行结果。\n3、提交异步任务 # 在 AsyncExecutionInterceptor # invoke() 中将要执行的方法封装为 Callable 任务之后,就会将任务交给执行器来执行。提交相关的代码如下:\nprotected Object doSubmit(Callable\u0026lt;Object\u0026gt; task, AsyncTaskExecutor executor, Class\u0026lt;?\u0026gt; returnType) { // 根据方法的返回值类型,选择不同的异步执行方式并返回结果。 // 1. 如果方法返回值是 CompletableFuture 类型 if (CompletableFuture.class.isAssignableFrom(returnType)) { // 使用 CompletableFuture.supplyAsync() 方法异步执行任务。 return CompletableFuture.supplyAsync(() -\u0026gt; { try { return task.call(); } catch (Throwable ex) { throw new CompletionException(ex); // 将异常包装为 CompletionException,以便在 future.get() 时抛出 } }, executor); } // 2. 如果方法返回值是 ListenableFuture 类型 else if (ListenableFuture.class.isAssignableFrom(returnType)) { // 将 AsyncTaskExecutor 强制转换为 AsyncListenableTaskExecutor, // 并调用 submitListenable() 方法提交任务。 // AsyncListenableTaskExecutor 是 ListenableFuture 的专用异步执行器, // 它可以返回一个 ListenableFuture 对象,允许添加回调函数来监听任务的完成。 return ((AsyncListenableTaskExecutor) executor).submitListenable(task); } // 3. 如果方法返回值是 Future 类型 else if (Future.class.isAssignableFrom(returnType)) { // 直接调用 AsyncTaskExecutor 的 submit() 方法提交任务,并返回一个 Future 对象。 return executor.submit(task); } // 4. 如果方法返回值是 void 或其他类型 else { // 直接调用 AsyncTaskExecutor 的 submit() 方法提交任务。 // 由于方法返回值是 void,因此不需要返回任何结果,直接返回 null。 executor.submit(task); return null; } } 在 doSubmit() 方法中,会根据 @Async 注解标注方法的返回值不同,来选择不同的任务提交方式,最后任务会由执行器(线程池)执行。\n总结 # 理解 @Async 原理的核心在于理解 @EnableAsync 注解,该注解开启了异步任务的功能。\n主要流程如上图,会通过后置处理器来创建代理对象,之后代理对象中 @Async 方法的执行会走到 Advice 内部的拦截器中,之后将方法封装为异步任务,并提交线程池进行处理。\n@Async 使用建议 # 自定义线程池 # 如果没有显式地配置线程池,在 @Async 底层会先在 BeanFactory 中尝试获取线程池,如果获取不到,则会创建一个 SimpleAsyncTaskExecutor 实现。SimpleAsyncTaskExecutor 本质上不算是一个真正的线程池,因为它对于每个请求都会启动一个新线程而不重用现有线程,这会带来一些潜在的问题,例如资源消耗过大。\n具体线程池获取可以参考这篇文章: 浅析 Spring 中 Async 注解底层异步线程池原理|得物技术。\n一定要显式配置一个线程池,推荐ThreadPoolTaskExecutor。并且,还可以根据任务的性质和需求,为不同的异步方法指定不同的线程池。\n@Configuration @EnableAsync public class AsyncConfig { @Bean(name = \u0026#34;executor1\u0026#34;) public Executor executor1() { ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor(); executor.setCorePoolSize(3); executor.setMaxPoolSize(5); executor.setQueueCapacity(50); executor.setThreadNamePrefix(\u0026#34;AsyncExecutor1-\u0026#34;); executor.initialize(); return executor; } @Bean(name = \u0026#34;executor2\u0026#34;) public Executor executor2() { ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor(); executor.setCorePoolSize(2); executor.setMaxPoolSize(4); executor.setQueueCapacity(100); executor.setThreadNamePrefix(\u0026#34;AsyncExecutor2-\u0026#34;); executor.initialize(); return executor; } } @Async 注解中指定线程池的 Bean 名称:\n@Service public class AsyncService { @Async(\u0026#34;executor1\u0026#34;) public void performTask1() { // 任务1的逻辑 System.out.println(\u0026#34;Executing Task1 with Executor1\u0026#34;); } @Async(\u0026#34;executor2\u0026#34;) public void performTask2() { // 任务2的逻辑 System.out.println(\u0026#34;Executing Task2 with Executor2\u0026#34;); } } 避免 @Async 注解失效 # @Async 注解会在以下几个场景失效,需要注意:\n1、同一类中调用异步方法\n如果你在同一个类内部调用一个@Async注解的方法,那这个方法将不会异步执行。\n@Service public class MyService { public void myMethod() { // 直接通过 this 引用调用,绕过了 Spring 的代理机制,异步执行失效 asyncMethod(); } @Async public void asyncMethod() { // 异步执行的逻辑 } } 这是因为 Spring 的异步机制是通过 代理 实现的,而在同一个类内部的方法调用会绕过 Spring 的代理机制,也就是绕过了代理对象,直接通过 this 引用调用的。由于没有经过代理,所有的代理相关的处理(即将任务提交线程池异步执行)都不会发生。\n为了避免这个问题,比较推荐的做法是将异步方法移至另一个 Spring Bean 中。\n@Service public class AsyncService { @Async public void asyncMethod() { // 异步执行的逻辑 } } @Service public class MyService { @Autowired private AsyncService asyncService; public void myMethod() { asyncService.asyncMethod(); } } 2、使用 static 关键字修饰异步方法\n如果@Async注解的方法被 static 关键字修饰,那这个方法将不会异步执行。\n这是因为 Spring 的异步机制是通过代理实现的,由于静态方法不属于实例而是属于类且不参与继承,Spring 的代理机制(无论是基于 JDK 还是 CGLIB)无法拦截静态方法来提供如异步执行这样的增强功能。\n篇幅问题,这里没有进一步详细介绍,不了解的代理机制的朋友,可以看看我写的 Java 代理模式详解这篇文章。\n如果你需要异步执行一个静态方法的逻辑,可以考虑设计一个非静态的包装方法,这个包装方法使用 @Async 注解,并在其内部调用静态方法\n@Service public class AsyncService { @Async public void asyncWrapper() { // 调用静态方法 SClass.staticMethod(); } } public class SClass { public static void staticMethod() { // 执行一些操作 } } 3、忘记开启异步支持\nSpring Boot 默认情况下不启用异步支持,确保在主配置类 Application 上添加@EnableAsync注解以启用异步功能。\n@SpringBootApplication @EnableAsync public class Application { public static void main(String[] args) { SpringApplication.run(Application.class, args); } } 4、@Async 注解的方法所在的类必须是 Spring Bean\n@Async 注解的方法必须位于 Spring 管理的 Bean 中,只有这样,Spring 才能在创建 Bean 时应用代理,代理能够拦截方法调用并实现异步执行的逻辑。如果该方法不在 Spring 管理的 bean 中,Spring 就无法创建必要的代理,@Async 注解就不会产生任何效果。\n返回值类型 # 建议将 @Async 注解方法的返回值类型定义为 void 和 Future 。\n如果不需要获取异步方法返回的结果,将返回值类型定义为 void 。 如果需要获取异步方法返回的结果,将返回值类型定义为 Future(例如CompletableFuture 、 ListenableFuture )。 如果将 @Async 注解方法的返回值定义为其他类型(如 Object 、 String 等等),则无法获取方法返回值。\n这种设计符合异步编程的基本原则,即调用者不应立即期待一个结果,而是应该能够在未来某个时间点获取结果。如果返回类型是 Future,调用者可以使用这个返回的 Future 对象来查询任务的状态,取消任务,或者在任务完成时获取结果。\n处理异步方法中的异常 # 异步方法中抛出的异常默认不会被调用者捕获。为了管理这些异常,建议使用CompletableFuture的异常处理功能,或者配置一个全局的AsyncUncaughtExceptionHandler来处理没有正确捕获的异常。\n@Configuration @EnableAsync public class AsyncConfig implements AsyncConfigurer{ @Override public AsyncUncaughtExceptionHandler getAsyncUncaughtExceptionHandler() { return new CustomAsyncExceptionHandler(); } } // 自定义异常处理器 class CustomAsyncExceptionHandler implements AsyncUncaughtExceptionHandler { @Override public void handleUncaughtException(Throwable ex, Method method, Object... params) { // 日志记录或其他处理逻辑 } } 未考虑事务管理 # @Async注解的方法需要事务支持时,务必在该异步方法上独立使用。\n@Service public class AsyncTransactionalService { @Async // Propagation.REQUIRES_NEW 表示 Spring 在执行异步方法时开启一个新的、与当前事务无关的事务 @Transactional(propagation = Propagation.REQUIRES_NEW) public void asyncTransactionalMethod() { // 这里的操作会在新的事务中执行 // 执行一些数据库操作 } } 未指定异步方法执行顺序 # @Async注解的方法执行是非阻塞的,它们可能以任意顺序完成。如果需要按照特定的顺序处理结果,你可以将方法的返回值设定为 Future 或 CompletableFuture ,通过返回值对象来实现一个方法在另一个方法完成后再执行。\n@Async public CompletableFuture\u0026lt;String\u0026gt; fetchDataAsync() { return CompletableFuture.completedFuture(\u0026#34;Data\u0026#34;); } @Async public CompletableFuture\u0026lt;String\u0026gt; processDataAsync(String data) { return CompletableFuture.supplyAsync(() -\u0026gt; \u0026#34;Processed \u0026#34; + data); } processDataAsync 方法在 fetchDataAsync后执行:\nCompletableFuture\u0026lt;String\u0026gt; dataFuture = asyncService.fetchDataAsync(); dataFuture.thenCompose(data -\u0026gt; asyncService.processDataAsync(data)) .thenAccept(result -\u0026gt; System.out.println(result)); # "},{"id":460,"href":"/zh/docs/technology/Interview/java/concurrent/atomic-classes/","title":"Atomic 原子类总结","section":"Concurrent","content":" Atomic 原子类介绍 # Atomic 翻译成中文是“原子”的意思。在化学上,原子是构成物质的最小单位,在化学反应中不可分割。在编程中,Atomic 指的是一个操作具有原子性,即该操作不可分割、不可中断。即使在多个线程同时执行时,该操作要么全部执行完成,要么不执行,不会被其他线程看到部分完成的状态。\n原子类简单来说就是具有原子性操作特征的类。\njava.util.concurrent.atomic 包中的 Atomic 原子类提供了一种线程安全的方式来操作单个变量。\nAtomic 类依赖于 CAS(Compare-And-Swap,比较并交换)乐观锁来保证其方法的原子性,而不需要使用传统的锁机制(如 synchronized 块或 ReentrantLock)。\n这篇文章我们只介绍 Atomic 原子类的概念,具体实现原理可以阅读笔者写的这篇文章: CAS 详解。\n根据操作的数据类型,可以将 JUC 包中的原子类分为 4 类:\n1、基本类型\n使用原子的方式更新基本类型\nAtomicInteger:整型原子类 AtomicLong:长整型原子类 AtomicBoolean:布尔型原子类 2、数组类型\n使用原子的方式更新数组里的某个元素\nAtomicIntegerArray:整型数组原子类 AtomicLongArray:长整型数组原子类 AtomicReferenceArray:引用类型数组原子类 3、引用类型\nAtomicReference:引用类型原子类 AtomicMarkableReference:原子更新带有标记的引用类型。该类将 boolean 标记与引用关联起来,也可以解决使用 CAS 进行原子更新时可能出现的 ABA 问题。 AtomicStampedReference:原子更新带有版本号的引用类型。该类将整数值与引用关联起来,可用于解决原子的更新数据和数据的版本号,可以解决使用 CAS 进行原子更新时可能出现的 ABA 问题。 🐛 修正(参见: issue#626) : AtomicMarkableReference 不能解决 ABA 问题。\n4、对象的属性修改类型\nAtomicIntegerFieldUpdater:原子更新整型字段的更新器 AtomicLongFieldUpdater:原子更新长整型字段的更新器 AtomicReferenceFieldUpdater:原子更新引用类型里的字段 基本类型原子类 # 使用原子的方式更新基本类型\nAtomicInteger:整型原子类 AtomicLong:长整型原子类 AtomicBoolean:布尔型原子类 上面三个类提供的方法几乎相同,所以我们这里以 AtomicInteger 为例子来介绍。\nAtomicInteger 类常用方法 :\npublic final int get() //获取当前的值 public final int getAndSet(int newValue)//获取当前的值,并设置新的值 public final int getAndIncrement()//获取当前的值,并自增 public final int getAndDecrement() //获取当前的值,并自减 public final int getAndAdd(int delta) //获取当前的值,并加上预期的值 boolean compareAndSet(int expect, int update) //如果输入的数值等于预期值,则以原子方式将该值设置为输入值(update) public final void lazySet(int newValue)//最终设置为newValue, lazySet 提供了一种比 set 方法更弱的语义,可能导致其他线程在之后的一小段时间内还是可以读到旧的值,但可能更高效。 AtomicInteger 类使用示例 :\n// 初始化 AtomicInteger 对象,初始值为 0 AtomicInteger atomicInt = new AtomicInteger(0); // 使用 getAndSet 方法获取当前值,并设置新值为 3 int tempValue = atomicInt.getAndSet(3); System.out.println(\u0026#34;tempValue: \u0026#34; + tempValue + \u0026#34;; atomicInt: \u0026#34; + atomicInt); // 使用 getAndIncrement 方法获取当前值,并自增 1 tempValue = atomicInt.getAndIncrement(); System.out.println(\u0026#34;tempValue: \u0026#34; + tempValue + \u0026#34;; atomicInt: \u0026#34; + atomicInt); // 使用 getAndAdd 方法获取当前值,并增加指定值 5 tempValue = atomicInt.getAndAdd(5); System.out.println(\u0026#34;tempValue: \u0026#34; + tempValue + \u0026#34;; atomicInt: \u0026#34; + atomicInt); // 使用 compareAndSet 方法进行原子性条件更新,期望值为 9,更新值为 10 boolean updateSuccess = atomicInt.compareAndSet(9, 10); System.out.println(\u0026#34;Update Success: \u0026#34; + updateSuccess + \u0026#34;; atomicInt: \u0026#34; + atomicInt); // 获取当前值 int currentValue = atomicInt.get(); System.out.println(\u0026#34;Current value: \u0026#34; + currentValue); // 使用 lazySet 方法设置新值为 15 atomicInt.lazySet(15); System.out.println(\u0026#34;After lazySet, atomicInt: \u0026#34; + atomicInt); 输出:\ntempValue: 0; atomicInt: 3 tempValue: 3; atomicInt: 4 tempValue: 4; atomicInt: 9 Update Success: true; atomicInt: 10 Current value: 10 After lazySet, atomicInt: 15 数组类型原子类 # 使用原子的方式更新数组里的某个元素\nAtomicIntegerArray:整形数组原子类 AtomicLongArray:长整形数组原子类 AtomicReferenceArray:引用类型数组原子类 上面三个类提供的方法几乎相同,所以我们这里以 AtomicIntegerArray 为例子来介绍。\nAtomicIntegerArray 类常用方法:\npublic final int get(int i) //获取 index=i 位置元素的值 public final int getAndSet(int i, int newValue)//返回 index=i 位置的当前的值,并将其设置为新值:newValue public final int getAndIncrement(int i)//获取 index=i 位置元素的值,并让该位置的元素自增 public final int getAndDecrement(int i) //获取 index=i 位置元素的值,并让该位置的元素自减 public final int getAndAdd(int i, int delta) //获取 index=i 位置元素的值,并加上预期的值 boolean compareAndSet(int i, int expect, int update) //如果输入的数值等于预期值,则以原子方式将 index=i 位置的元素值设置为输入值(update) public final void lazySet(int i, int newValue)//最终 将index=i 位置的元素设置为newValue,使用 lazySet 设置之后可能导致其他线程在之后的一小段时间内还是可以读到旧的值。 AtomicIntegerArray 类使用示例 :\nint[] nums = {1, 2, 3, 4, 5, 6}; // 创建 AtomicIntegerArray AtomicIntegerArray atomicArray = new AtomicIntegerArray(nums); // 打印 AtomicIntegerArray 中的初始值 System.out.println(\u0026#34;Initial values in AtomicIntegerArray:\u0026#34;); for (int j = 0; j \u0026lt; nums.length; j++) { System.out.print(\u0026#34;Index \u0026#34; + j + \u0026#34;: \u0026#34; + atomicArray.get(j) + \u0026#34; \u0026#34;); } // 使用 getAndSet 方法将索引 0 处的值设置为 2,并返回旧值 int tempValue = atomicArray.getAndSet(0, 2); System.out.println(\u0026#34;\\nAfter getAndSet(0, 2):\u0026#34;); System.out.println(\u0026#34;Returned value: \u0026#34; + tempValue); for (int j = 0; j \u0026lt; atomicArray.length(); j++) { System.out.print(\u0026#34;Index \u0026#34; + j + \u0026#34;: \u0026#34; + atomicArray.get(j) + \u0026#34; \u0026#34;); } // 使用 getAndIncrement 方法将索引 0 处的值加 1,并返回旧值 tempValue = atomicArray.getAndIncrement(0); System.out.println(\u0026#34;\\nAfter getAndIncrement(0):\u0026#34;); System.out.println(\u0026#34;Returned value: \u0026#34; + tempValue); for (int j = 0; j \u0026lt; atomicArray.length(); j++) { System.out.print(\u0026#34;Index \u0026#34; + j + \u0026#34;: \u0026#34; + atomicArray.get(j) + \u0026#34; \u0026#34;); } // 使用 getAndAdd 方法将索引 0 处的值增加 5,并返回旧值 tempValue = atomicArray.getAndAdd(0, 5); System.out.println(\u0026#34;\\nAfter getAndAdd(0, 5):\u0026#34;); System.out.println(\u0026#34;Returned value: \u0026#34; + tempValue); for (int j = 0; j \u0026lt; atomicArray.length(); j++) { System.out.print(\u0026#34;Index \u0026#34; + j + \u0026#34;: \u0026#34; + atomicArray.get(j) + \u0026#34; \u0026#34;); } 输出:\nInitial values in AtomicIntegerArray: Index 0: 1 Index 1: 2 Index 2: 3 Index 3: 4 Index 4: 5 Index 5: 6 After getAndSet(0, 2): Returned value: 1 Index 0: 2 Index 1: 2 Index 2: 3 Index 3: 4 Index 4: 5 Index 5: 6 After getAndIncrement(0): Returned value: 2 Index 0: 3 Index 1: 2 Index 2: 3 Index 3: 4 Index 4: 5 Index 5: 6 After getAndAdd(0, 5): Returned value: 3 Index 0: 8 Index 1: 2 Index 2: 3 Index 3: 4 Index 4: 5 Index 5: 6 引用类型原子类 # 基本类型原子类只能更新一个变量,如果需要原子更新多个变量,需要使用 引用类型原子类。\nAtomicReference:引用类型原子类 AtomicStampedReference:原子更新带有版本号的引用类型。该类将整数值与引用关联起来,可用于解决原子的更新数据和数据的版本号,可以解决使用 CAS 进行原子更新时可能出现的 ABA 问题。 AtomicMarkableReference:原子更新带有标记的引用类型。该类将 boolean 标记与引用关联起来,也可以解决使用 CAS 进行原子更新时可能出现的 ABA 问题。 上面三个类提供的方法几乎相同,所以我们这里以 AtomicReference 为例子来介绍。\nAtomicReference 类使用示例 :\n// Person 类 class Person { private String name; private int age; //省略getter/setter和toString } // 创建 AtomicReference 对象并设置初始值 AtomicReference\u0026lt;Person\u0026gt; ar = new AtomicReference\u0026lt;\u0026gt;(new Person(\u0026#34;SnailClimb\u0026#34;, 22)); // 打印初始值 System.out.println(\u0026#34;Initial Person: \u0026#34; + ar.get().toString()); // 更新值 Person updatePerson = new Person(\u0026#34;Daisy\u0026#34;, 20); ar.compareAndSet(ar.get(), updatePerson); // 打印更新后的值 System.out.println(\u0026#34;Updated Person: \u0026#34; + ar.get().toString()); // 尝试再次更新 Person anotherUpdatePerson = new Person(\u0026#34;John\u0026#34;, 30); boolean isUpdated = ar.compareAndSet(updatePerson, anotherUpdatePerson); // 打印是否更新成功及最终值 System.out.println(\u0026#34;Second Update Success: \u0026#34; + isUpdated); System.out.println(\u0026#34;Final Person: \u0026#34; + ar.get().toString()); 输出:\nInitial Person: Person{name=\u0026#39;SnailClimb\u0026#39;, age=22} Updated Person: Person{name=\u0026#39;Daisy\u0026#39;, age=20} Second Update Success: true Final Person: Person{name=\u0026#39;John\u0026#39;, age=30} AtomicStampedReference 类使用示例 :\n// 创建一个 AtomicStampedReference 对象,初始值为 \u0026#34;SnailClimb\u0026#34;,初始版本号为 1 AtomicStampedReference\u0026lt;String\u0026gt; asr = new AtomicStampedReference\u0026lt;\u0026gt;(\u0026#34;SnailClimb\u0026#34;, 1); // 打印初始值和版本号 int[] initialStamp = new int[1]; String initialRef = asr.get(initialStamp); System.out.println(\u0026#34;Initial Reference: \u0026#34; + initialRef + \u0026#34;, Initial Stamp: \u0026#34; + initialStamp[0]); // 更新值和版本号 int oldStamp = initialStamp[0]; String oldRef = initialRef; String newRef = \u0026#34;Daisy\u0026#34;; int newStamp = oldStamp + 1; boolean isUpdated = asr.compareAndSet(oldRef, newRef, oldStamp, newStamp); System.out.println(\u0026#34;Update Success: \u0026#34; + isUpdated); // 打印更新后的值和版本号 int[] updatedStamp = new int[1]; String updatedRef = asr.get(updatedStamp); System.out.println(\u0026#34;Updated Reference: \u0026#34; + updatedRef + \u0026#34;, Updated Stamp: \u0026#34; + updatedStamp[0]); // 尝试用错误的版本号更新 boolean isUpdatedWithWrongStamp = asr.compareAndSet(newRef, \u0026#34;John\u0026#34;, oldStamp, newStamp + 1); System.out.println(\u0026#34;Update with Wrong Stamp Success: \u0026#34; + isUpdatedWithWrongStamp); // 打印最终的值和版本号 int[] finalStamp = new int[1]; String finalRef = asr.get(finalStamp); System.out.println(\u0026#34;Final Reference: \u0026#34; + finalRef + \u0026#34;, Final Stamp: \u0026#34; + finalStamp[0]); 输出结果如下:\nInitial Reference: SnailClimb, Initial Stamp: 1 Update Success: true Updated Reference: Daisy, Updated Stamp: 2 Update with Wrong Stamp Success: false Final Reference: Daisy, Final Stamp: 2 AtomicMarkableReference 类使用示例 :\n// 创建一个 AtomicMarkableReference 对象,初始值为 \u0026#34;SnailClimb\u0026#34;,初始标记为 false AtomicMarkableReference\u0026lt;String\u0026gt; amr = new AtomicMarkableReference\u0026lt;\u0026gt;(\u0026#34;SnailClimb\u0026#34;, false); // 打印初始值和标记 boolean[] initialMark = new boolean[1]; String initialRef = amr.get(initialMark); System.out.println(\u0026#34;Initial Reference: \u0026#34; + initialRef + \u0026#34;, Initial Mark: \u0026#34; + initialMark[0]); // 更新值和标记 String oldRef = initialRef; String newRef = \u0026#34;Daisy\u0026#34;; boolean oldMark = initialMark[0]; boolean newMark = true; boolean isUpdated = amr.compareAndSet(oldRef, newRef, oldMark, newMark); System.out.println(\u0026#34;Update Success: \u0026#34; + isUpdated); // 打印更新后的值和标记 boolean[] updatedMark = new boolean[1]; String updatedRef = amr.get(updatedMark); System.out.println(\u0026#34;Updated Reference: \u0026#34; + updatedRef + \u0026#34;, Updated Mark: \u0026#34; + updatedMark[0]); // 尝试用错误的标记更新 boolean isUpdatedWithWrongMark = amr.compareAndSet(newRef, \u0026#34;John\u0026#34;, oldMark, !newMark); System.out.println(\u0026#34;Update with Wrong Mark Success: \u0026#34; + isUpdatedWithWrongMark); // 打印最终的值和标记 boolean[] finalMark = new boolean[1]; String finalRef = amr.get(finalMark); System.out.println(\u0026#34;Final Reference: \u0026#34; + finalRef + \u0026#34;, Final Mark: \u0026#34; + finalMark[0]); 输出结果如下:\nInitial Reference: SnailClimb, Initial Mark: false Update Success: true Updated Reference: Daisy, Updated Mark: true Update with Wrong Mark Success: false Final Reference: Daisy, Final Mark: true 对象的属性修改类型原子类 # 如果需要原子更新某个类里的某个字段时,需要用到对象的属性修改类型原子类。\nAtomicIntegerFieldUpdater:原子更新整形字段的更新器 AtomicLongFieldUpdater:原子更新长整形字段的更新器 AtomicReferenceFieldUpdater:原子更新引用类型里的字段的更新器 要想原子地更新对象的属性需要两步。第一步,因为对象的属性修改类型原子类都是抽象类,所以每次使用都必须使用静态方法 newUpdater()创建一个更新器,并且需要设置想要更新的类和属性。第二步,更新的对象属性必须使用 public volatile 修饰符。\n上面三个类提供的方法几乎相同,所以我们这里以 AtomicIntegerFieldUpdater为例子来介绍。\nAtomicIntegerFieldUpdater 类使用示例 :\n// Person 类 class Person { private String name; // 要使用 AtomicIntegerFieldUpdater,字段必须是 public volatile private volatile int age; //省略getter/setter和toString } // 创建 AtomicIntegerFieldUpdater 对象 AtomicIntegerFieldUpdater\u0026lt;Person\u0026gt; ageUpdater = AtomicIntegerFieldUpdater.newUpdater(Person.class, \u0026#34;age\u0026#34;); // 创建 Person 对象 Person person = new Person(\u0026#34;SnailClimb\u0026#34;, 22); // 打印初始值 System.out.println(\u0026#34;Initial Person: \u0026#34; + person); // 更新 age 字段 ageUpdater.incrementAndGet(person); // 自增 System.out.println(\u0026#34;After Increment: \u0026#34; + person); ageUpdater.addAndGet(person, 5); // 增加 5 System.out.println(\u0026#34;After Adding 5: \u0026#34; + person); ageUpdater.compareAndSet(person, 28, 30); // 如果当前值是 28,则设置为 30 System.out.println(\u0026#34;After Compare and Set (28 to 30): \u0026#34; + person); // 尝试使用错误的比较值进行更新 boolean isUpdated = ageUpdater.compareAndSet(person, 28, 35); // 这次应该失败 System.out.println(\u0026#34;Compare and Set (28 to 35) Success: \u0026#34; + isUpdated); System.out.println(\u0026#34;Final Person: \u0026#34; + person); 输出结果:\nInitial Person: Name: SnailClimb, Age: 22 After Increment: Name: SnailClimb, Age: 23 After Adding 5: Name: SnailClimb, Age: 28 After Compare and Set (28 to 30): Name: SnailClimb, Age: 30 Compare and Set (28 to 35) Success: false Final Person: Name: SnailClimb, Age: 30 参考 # 《Java 并发编程的艺术》 "},{"id":461,"href":"/zh/docs/technology/Interview/java/basis/bigdecimal/","title":"BigDecimal 详解","section":"Basis","content":"《阿里巴巴 Java 开发手册》中提到:“为了避免精度丢失,可以使用 BigDecimal 来进行浮点数的运算”。\n浮点数的运算竟然还会有精度丢失的风险吗?确实会!\n示例代码:\nfloat a = 2.0f - 1.9f; float b = 1.8f - 1.7f; System.out.println(a);// 0.100000024 System.out.println(b);// 0.099999905 System.out.println(a == b);// false 为什么浮点数 float 或 double 运算的时候会有精度丢失的风险呢?\n这个和计算机保存浮点数的机制有很大关系。我们知道计算机是二进制的,而且计算机在表示一个数字时,宽度是有限的,无限循环的小数存储在计算机时,只能被截断,所以就会导致小数精度发生损失的情况。这也就是解释了为什么浮点数没有办法用二进制精确表示。\n就比如说十进制下的 0.2 就没办法精确转换成二进制小数:\n// 0.2 转换为二进制数的过程为,不断乘以 2,直到不存在小数为止, // 在这个计算过程中,得到的整数部分从上到下排列就是二进制的结果。 0.2 * 2 = 0.4 -\u0026gt; 0 0.4 * 2 = 0.8 -\u0026gt; 0 0.8 * 2 = 1.6 -\u0026gt; 1 0.6 * 2 = 1.2 -\u0026gt; 1 0.2 * 2 = 0.4 -\u0026gt; 0(发生循环) ... 关于浮点数的更多内容,建议看一下 计算机系统基础(四)浮点数这篇文章。\nBigDecimal 介绍 # BigDecimal 可以实现对浮点数的运算,不会造成精度丢失。\n通常情况下,大部分需要浮点数精确运算结果的业务场景(比如涉及到钱的场景)都是通过 BigDecimal 来做的。\n《阿里巴巴 Java 开发手册》中提到:浮点数之间的等值判断,基本数据类型不能用 == 来比较,包装数据类型不能用 equals 来判断。\n具体原因我们在上面已经详细介绍了,这里就不多提了。\n想要解决浮点数运算精度丢失这个问题,可以直接使用 BigDecimal 来定义浮点数的值,然后再进行浮点数的运算操作即可。\nBigDecimal a = new BigDecimal(\u0026#34;1.0\u0026#34;); BigDecimal b = new BigDecimal(\u0026#34;0.9\u0026#34;); BigDecimal c = new BigDecimal(\u0026#34;0.8\u0026#34;); BigDecimal x = a.subtract(b); BigDecimal y = b.subtract(c); System.out.println(x.compareTo(y));// 0 BigDecimal 常见方法 # 创建 # 我们在使用 BigDecimal 时,为了防止精度丢失,推荐使用它的BigDecimal(String val)构造方法或者 BigDecimal.valueOf(double val) 静态方法来创建对象。\n《阿里巴巴 Java 开发手册》对这部分内容也有提到,如下图所示。\n加减乘除 # add 方法用于将两个 BigDecimal 对象相加,subtract 方法用于将两个 BigDecimal 对象相减。multiply 方法用于将两个 BigDecimal 对象相乘,divide 方法用于将两个 BigDecimal 对象相除。\nBigDecimal a = new BigDecimal(\u0026#34;1.0\u0026#34;); BigDecimal b = new BigDecimal(\u0026#34;0.9\u0026#34;); System.out.println(a.add(b));// 1.9 System.out.println(a.subtract(b));// 0.1 System.out.println(a.multiply(b));// 0.90 System.out.println(a.divide(b));// 无法除尽,抛出 ArithmeticException 异常 System.out.println(a.divide(b, 2, RoundingMode.HALF_UP));// 1.11 这里需要注意的是,在我们使用 divide 方法的时候尽量使用 3 个参数版本,并且RoundingMode 不要选择 UNNECESSARY,否则很可能会遇到 ArithmeticException(无法除尽出现无限循环小数的时候),其中 scale 表示要保留几位小数,roundingMode 代表保留规则。\npublic BigDecimal divide(BigDecimal divisor, int scale, RoundingMode roundingMode) { return divide(divisor, scale, roundingMode.oldMode); } 保留规则非常多,这里列举几种:\npublic enum RoundingMode { // 2.5 -\u0026gt; 3 , 1.6 -\u0026gt; 2 // -1.6 -\u0026gt; -2 , -2.5 -\u0026gt; -3 UP(BigDecimal.ROUND_UP), // 2.5 -\u0026gt; 2 , 1.6 -\u0026gt; 1 // -1.6 -\u0026gt; -1 , -2.5 -\u0026gt; -2 DOWN(BigDecimal.ROUND_DOWN), // 2.5 -\u0026gt; 3 , 1.6 -\u0026gt; 2 // -1.6 -\u0026gt; -1 , -2.5 -\u0026gt; -2 CEILING(BigDecimal.ROUND_CEILING), // 2.5 -\u0026gt; 2 , 1.6 -\u0026gt; 1 // -1.6 -\u0026gt; -2 , -2.5 -\u0026gt; -3 FLOOR(BigDecimal.ROUND_FLOOR), // 2.5 -\u0026gt; 3 , 1.6 -\u0026gt; 2 // -1.6 -\u0026gt; -2 , -2.5 -\u0026gt; -3 HALF_UP(BigDecimal.ROUND_HALF_UP), //...... } 大小比较 # a.compareTo(b) : 返回 -1 表示 a 小于 b,0 表示 a 等于 b , 1 表示 a 大于 b。\nBigDecimal a = new BigDecimal(\u0026#34;1.0\u0026#34;); BigDecimal b = new BigDecimal(\u0026#34;0.9\u0026#34;); System.out.println(a.compareTo(b));// 1 保留几位小数 # 通过 setScale方法设置保留几位小数以及保留规则。保留规则有挺多种,不需要记,IDEA 会提示。\nBigDecimal m = new BigDecimal(\u0026#34;1.255433\u0026#34;); BigDecimal n = m.setScale(3,RoundingMode.HALF_DOWN); System.out.println(n);// 1.255 BigDecimal 等值比较问题 # 《阿里巴巴 Java 开发手册》中提到:\nBigDecimal 使用 equals() 方法进行等值比较出现问题的代码示例:\nBigDecimal a = new BigDecimal(\u0026#34;1\u0026#34;); BigDecimal b = new BigDecimal(\u0026#34;1.0\u0026#34;); System.out.println(a.equals(b));//false 这是因为 equals() 方法不仅仅会比较值的大小(value)还会比较精度(scale),而 compareTo() 方法比较的时候会忽略精度。\n1.0 的 scale 是 1,1 的 scale 是 0,因此 a.equals(b) 的结果是 false。\ncompareTo() 方法可以比较两个 BigDecimal 的值,如果相等就返回 0,如果第 1 个数比第 2 个数大则返回 1,反之返回-1。\nBigDecimal a = new BigDecimal(\u0026#34;1\u0026#34;); BigDecimal b = new BigDecimal(\u0026#34;1.0\u0026#34;); System.out.println(a.compareTo(b));//0 BigDecimal 工具类分享 # 网上有一个使用人数比较多的 BigDecimal 工具类,提供了多个静态方法来简化 BigDecimal 的操作。\n我对其进行了简单改进,分享一下源码:\nimport java.math.BigDecimal; import java.math.RoundingMode; /** * 简化BigDecimal计算的小工具类 */ public class BigDecimalUtil { /** * 默认除法运算精度 */ private static final int DEF_DIV_SCALE = 10; private BigDecimalUtil() { } /** * 提供精确的加法运算。 * * @param v1 被加数 * @param v2 加数 * @return 两个参数的和 */ public static double add(double v1, double v2) { BigDecimal b1 = BigDecimal.valueOf(v1); BigDecimal b2 = BigDecimal.valueOf(v2); return b1.add(b2).doubleValue(); } /** * 提供精确的减法运算。 * * @param v1 被减数 * @param v2 减数 * @return 两个参数的差 */ public static double subtract(double v1, double v2) { BigDecimal b1 = BigDecimal.valueOf(v1); BigDecimal b2 = BigDecimal.valueOf(v2); return b1.subtract(b2).doubleValue(); } /** * 提供精确的乘法运算。 * * @param v1 被乘数 * @param v2 乘数 * @return 两个参数的积 */ public static double multiply(double v1, double v2) { BigDecimal b1 = BigDecimal.valueOf(v1); BigDecimal b2 = BigDecimal.valueOf(v2); return b1.multiply(b2).doubleValue(); } /** * 提供(相对)精确的除法运算,当发生除不尽的情况时,精确到 * 小数点以后10位,以后的数字四舍五入。 * * @param v1 被除数 * @param v2 除数 * @return 两个参数的商 */ public static double divide(double v1, double v2) { return divide(v1, v2, DEF_DIV_SCALE); } /** * 提供(相对)精确的除法运算。当发生除不尽的情况时,由scale参数指 * 定精度,以后的数字四舍五入。 * * @param v1 被除数 * @param v2 除数 * @param scale 表示表示需要精确到小数点以后几位。 * @return 两个参数的商 */ public static double divide(double v1, double v2, int scale) { if (scale \u0026lt; 0) { throw new IllegalArgumentException( \u0026#34;The scale must be a positive integer or zero\u0026#34;); } BigDecimal b1 = BigDecimal.valueOf(v1); BigDecimal b2 = BigDecimal.valueOf(v2); return b1.divide(b2, scale, RoundingMode.HALF_EVEN).doubleValue(); } /** * 提供精确的小数位四舍五入处理。 * * @param v 需要四舍五入的数字 * @param scale 小数点后保留几位 * @return 四舍五入后的结果 */ public static double round(double v, int scale) { if (scale \u0026lt; 0) { throw new IllegalArgumentException( \u0026#34;The scale must be a positive integer or zero\u0026#34;); } BigDecimal b = BigDecimal.valueOf(v); BigDecimal one = new BigDecimal(\u0026#34;1\u0026#34;); return b.divide(one, scale, RoundingMode.HALF_UP).doubleValue(); } /** * 提供精确的类型转换(Float) * * @param v 需要被转换的数字 * @return 返回转换结果 */ public static float convertToFloat(double v) { BigDecimal b = new BigDecimal(v); return b.floatValue(); } /** * 提供精确的类型转换(Int)不进行四舍五入 * * @param v 需要被转换的数字 * @return 返回转换结果 */ public static int convertsToInt(double v) { BigDecimal b = new BigDecimal(v); return b.intValue(); } /** * 提供精确的类型转换(Long) * * @param v 需要被转换的数字 * @return 返回转换结果 */ public static long convertsToLong(double v) { BigDecimal b = new BigDecimal(v); return b.longValue(); } /** * 返回两个数中大的一个值 * * @param v1 需要被对比的第一个数 * @param v2 需要被对比的第二个数 * @return 返回两个数中大的一个值 */ public static double returnMax(double v1, double v2) { BigDecimal b1 = new BigDecimal(v1); BigDecimal b2 = new BigDecimal(v2); return b1.max(b2).doubleValue(); } /** * 返回两个数中小的一个值 * * @param v1 需要被对比的第一个数 * @param v2 需要被对比的第二个数 * @return 返回两个数中小的一个值 */ public static double returnMin(double v1, double v2) { BigDecimal b1 = new BigDecimal(v1); BigDecimal b2 = new BigDecimal(v2); return b1.min(b2).doubleValue(); } /** * 精确对比两个数字 * * @param v1 需要被对比的第一个数 * @param v2 需要被对比的第二个数 * @return 如果两个数一样则返回0,如果第一个数比第二个数大则返回1,反之返回-1 */ public static int compareTo(double v1, double v2) { BigDecimal b1 = BigDecimal.valueOf(v1); BigDecimal b2 = BigDecimal.valueOf(v2); return b1.compareTo(b2); } } 相关 issue: 建议对保留规则设置为 RoundingMode.HALF_EVEN,即四舍六入五成双,#2129 。\n总结 # 浮点数没有办法用二进制精确表示,因此存在精度丢失的风险。\n不过,Java 提供了BigDecimal 来操作浮点数。BigDecimal 的实现利用到了 BigInteger (用来操作大整数), 所不同的是 BigDecimal 加入了小数位的概念。\n"},{"id":462,"href":"/zh/docs/technology/Interview/distributed-system/protocol/cap-and-base-theorem/","title":"CAP \u0026 BASE理论详解","section":"Protocol","content":"经历过技术面试的小伙伴想必对 CAP \u0026amp; BASE 这个两个理论已经再熟悉不过了!\n我当年参加面试的时候,不夸张地说,只要问到分布式相关的内容,面试官几乎是必定会问这两个分布式相关的理论。一是因为这两个分布式基础理论是学习分布式知识的必备前置基础,二是因为很多面试官自己比较熟悉这两个理论(方便提问)。\n我们非常有必要将这两个理论搞懂,并且能够用自己的理解给别人讲出来。\nCAP 理论 # CAP 理论/定理起源于 2000 年,由加州大学伯克利分校的 Eric Brewer 教授在分布式计算原理研讨会(PODC)上提出,因此 CAP 定理又被称作 布鲁尔定理(Brewer’s theorem)\n2 年后,麻省理工学院的 Seth Gilbert 和 Nancy Lynch 发表了布鲁尔猜想的证明,CAP 理论正式成为分布式领域的定理。\n简介 # CAP 也就是 Consistency(一致性)、Availability(可用性)、Partition Tolerance(分区容错性) 这三个单词首字母组合。\nCAP 理论的提出者布鲁尔在提出 CAP 猜想的时候,并没有详细定义 Consistency、Availability、Partition Tolerance 三个单词的明确定义。\n因此,对于 CAP 的民间解读有很多,一般比较被大家推荐的是下面 👇 这种版本的解读。\n在理论计算机科学中,CAP 定理(CAP theorem)指出对于一个分布式系统来说,当设计读写操作时,只能同时满足以下三点中的两个:\n一致性(Consistency) : 所有节点访问同一份最新的数据副本 可用性(Availability): 非故障的节点在合理的时间内返回合理的响应(不是错误或者超时的响应)。 分区容错性(Partition Tolerance) : 分布式系统出现网络分区的时候,仍然能够对外提供服务。 什么是网络分区?\n分布式系统中,多个节点之间的网络本来是连通的,但是因为某些故障(比如部分节点网络出了问题)某些节点之间不连通了,整个网络就分成了几块区域,这就叫 网络分区。\n不是所谓的“3 选 2” # 大部分人解释这一定律时,常常简单的表述为:“一致性、可用性、分区容忍性三者你只能同时达到其中两个,不可能同时达到”。实际上这是一个非常具有误导性质的说法,而且在 CAP 理论诞生 12 年之后,CAP 之父也在 2012 年重写了之前的论文。\n当发生网络分区的时候,如果我们要继续服务,那么强一致性和可用性只能 2 选 1。也就是说当网络分区之后 P 是前提,决定了 P 之后才有 C 和 A 的选择。也就是说分区容错性(Partition tolerance)我们是必须要实现的。\n简而言之就是:CAP 理论中分区容错性 P 是一定要满足的,在此基础上,只能满足可用性 A 或者一致性 C。\n因此,分布式系统理论上不可能选择 CA 架构,只能选择 CP 或者 AP 架构。 比如 ZooKeeper、HBase 就是 CP 架构,Cassandra、Eureka 就是 AP 架构,Nacos 不仅支持 CP 架构也支持 AP 架构。\n为啥不可能选择 CA 架构呢? 举个例子:若系统出现“分区”,系统中的某个节点在进行写操作。为了保证 C, 必须要禁止其他节点的读写操作,这就和 A 发生冲突了。如果为了保证 A,其他节点的读写操作正常的话,那就和 C 发生冲突了。\n选择 CP 还是 AP 的关键在于当前的业务场景,没有定论,比如对于需要确保强一致性的场景如银行一般会选择保证 CP 。\n另外,需要补充说明的一点是:如果网络分区正常的话(系统在绝大部分时候所处的状态),也就说不需要保证 P 的时候,C 和 A 能够同时保证。\nCAP 实际应用案例 # 我这里以注册中心来探讨一下 CAP 的实际应用。考虑到很多小伙伴不知道注册中心是干嘛的,这里简单以 Dubbo 为例说一说。\n下图是 Dubbo 的架构图。注册中心 Registry 在其中扮演了什么角色呢?提供了什么服务呢?\n注册中心负责服务地址的注册与查找,相当于目录服务,服务提供者和消费者只在启动时与注册中心交互,注册中心不转发请求,压力较小。\n常见的可以作为注册中心的组件有:ZooKeeper、Eureka、Nacos\u0026hellip;。\nZooKeeper 保证的是 CP。 任何时刻对 ZooKeeper 的读请求都能得到一致性的结果,但是, ZooKeeper 不保证每次请求的可用性比如在 Leader 选举过程中或者半数以上的机器不可用的时候服务就是不可用的。 Eureka 保证的则是 AP。 Eureka 在设计的时候就是优先保证 A (可用性)。在 Eureka 中不存在什么 Leader 节点,每个节点都是一样的、平等的。因此 Eureka 不会像 ZooKeeper 那样出现选举过程中或者半数以上的机器不可用的时候服务就是不可用的情况。 Eureka 保证即使大部分节点挂掉也不会影响正常提供服务,只要有一个节点是可用的就行了。只不过这个节点上的数据可能并不是最新的。 Nacos 不仅支持 CP 也支持 AP。 🐛 修正(参见: issue#1906):\nZooKeeper 通过可线性化(Linearizable)写入、全局 FIFO 顺序访问等机制来保障数据一致性。多节点部署的情况下, ZooKeeper 集群处于 Quorum 模式。Quorum 模式下的 ZooKeeper 集群, 是一组 ZooKeeper 服务器节点组成的集合,其中大多数节点必须同意任何变更才能被视为有效。\n由于 Quorum 模式下的读请求不会触发各个 ZooKeeper 节点之间的数据同步,因此在某些情况下还是可能会存在读取到旧数据的情况,导致不同的客户端视图上看到的结果不同,这可能是由于网络延迟、丢包、重传等原因造成的。ZooKeeper 为了解决这个问题,提供了 Watcher 机制和版本号机制来帮助客户端检测数据的变化和版本号的变更,以保证数据的一致性。\n总结 # 在进行分布式系统设计和开发时,我们不应该仅仅局限在 CAP 问题上,还要关注系统的扩展性、可用性等等\n在系统发生“分区”的情况下,CAP 理论只能满足 CP 或者 AP。要注意的是,这里的前提是系统发生了“分区”\n如果系统没有发生“分区”的话,节点间的网络连接通信正常的话,也就不存在 P 了。这个时候,我们就可以同时保证 C 和 A 了。\n总结:如果系统发生“分区”,我们要考虑选择 CP 还是 AP。如果系统没有发生“分区”的话,我们要思考如何保证 CA 。\n推荐阅读 # CAP 定理简化 (英文,有趣的案例) 神一样的 CAP 理论被应用在何方 (中文,列举了很多实际的例子) 请停止呼叫数据库 CP 或 AP (英文,带给你不一样的思考) BASE 理论 # BASE 理论起源于 2008 年, 由 eBay 的架构师 Dan Pritchett 在 ACM 上发表。\n简介 # BASE 是 Basically Available(基本可用)、Soft-state(软状态) 和 Eventually Consistent(最终一致性) 三个短语的缩写。BASE 理论是对 CAP 中一致性 C 和可用性 A 权衡的结果,其来源于对大规模互联网系统分布式实践的总结,是基于 CAP 定理逐步演化而来的,它大大降低了我们对系统的要求。\nBASE 理论的核心思想 # 即使无法做到强一致性,但每个应用都可以根据自身业务特点,采用适当的方式来使系统达到最终一致性。\n也就是牺牲数据的一致性来满足系统的高可用性,系统中一部分数据不可用或者不一致时,仍需要保持系统整体“主要可用”。\nBASE 理论本质上是对 CAP 的延伸和补充,更具体地说,是对 CAP 中 AP 方案的一个补充。\n为什么这样说呢?\nCAP 理论这节我们也说过了:\n如果系统没有发生“分区”的话,节点间的网络连接通信正常的话,也就不存在 P 了。这个时候,我们就可以同时保证 C 和 A 了。因此,如果系统发生“分区”,我们要考虑选择 CP 还是 AP。如果系统没有发生“分区”的话,我们要思考如何保证 CA 。\n因此,AP 方案只是在系统发生分区的时候放弃一致性,而不是永远放弃一致性。在分区故障恢复后,系统应该达到最终一致性。这一点其实就是 BASE 理论延伸的地方。\nBASE 理论三要素 # 基本可用 # 基本可用是指分布式系统在出现不可预知故障的时候,允许损失部分可用性。但是,这绝不等价于系统不可用。\n什么叫允许损失部分可用性呢?\n响应时间上的损失: 正常情况下,处理用户请求需要 0.5s 返回结果,但是由于系统出现故障,处理用户请求的时间变为 3 s。 系统功能上的损失:正常情况下,用户可以使用系统的全部功能,但是由于系统访问量突然剧增,系统的部分非核心功能无法使用。 软状态 # 软状态指允许系统中的数据存在中间状态(CAP 理论中的数据不一致),并认为该中间状态的存在不会影响系统的整体可用性,即允许系统在不同节点的数据副本之间进行数据同步的过程存在延时。\n最终一致性 # 最终一致性强调的是系统中所有的数据副本,在经过一段时间的同步后,最终能够达到一个一致的状态。因此,最终一致性的本质是需要系统保证最终数据能够达到一致,而不需要实时保证系统数据的强一致性。\n分布式一致性的 3 种级别:\n强一致性:系统写入了什么,读出来的就是什么。 弱一致性:不一定可以读取到最新写入的值,也不保证多少时间之后读取到的数据是最新的,只是会尽量保证某个时刻达到数据一致的状态。 最终一致性:弱一致性的升级版,系统会保证在一定时间内达到数据一致的状态。 业界比较推崇是最终一致性级别,但是某些对数据一致要求十分严格的场景比如银行转账还是要保证强一致性。\n那实现最终一致性的具体方式是什么呢? 《分布式协议与算法实战》 中是这样介绍:\n读时修复 : 在读取数据时,检测数据的不一致,进行修复。比如 Cassandra 的 Read Repair 实现,具体来说,在向 Cassandra 系统查询数据的时候,如果检测到不同节点的副本数据不一致,系统就自动修复数据。 写时修复 : 在写入数据,检测数据的不一致时,进行修复。比如 Cassandra 的 Hinted Handoff 实现。具体来说,Cassandra 集群的节点之间远程写数据的时候,如果写失败 就将数据缓存下来,然后定时重传,修复数据的不一致性。 异步修复 : 这个是最常用的方式,通过定时对账检测副本数据的一致性,并修复。 比较推荐 写时修复,这种方式对性能消耗比较低。\n总结 # ACID 是数据库事务完整性的理论,CAP 是分布式系统设计理论,BASE 是 CAP 理论中 AP 方案的延伸。\n"},{"id":463,"href":"/zh/docs/technology/Interview/java/concurrent/cas/","title":"CAS 详解","section":"Concurrent","content":"乐观锁和悲观锁的介绍以及乐观锁常见实现方式可以阅读笔者写的这篇文章: 乐观锁和悲观锁详解。\n这篇文章主要介绍 :Java 中 CAS 的实现以及 CAS 存在的一些问题。\nJava 中 CAS 是如何实现的? # 在 Java 中,实现 CAS(Compare-And-Swap, 比较并交换)操作的一个关键类是Unsafe。\nUnsafe类位于sun.misc包下,是一个提供低级别、不安全操作的类。由于其强大的功能和潜在的危险性,它通常用于 JVM 内部或一些需要极高性能和底层访问的库中,而不推荐普通开发者在应用程序中使用。关于 Unsafe类的详细介绍,可以阅读这篇文章:📌 Java 魔法类 Unsafe 详解。\nsun.misc包下的Unsafe类提供了compareAndSwapObject、compareAndSwapInt、compareAndSwapLong方法来实现的对Object、int、long类型的 CAS 操作:\n/** * 以原子方式更新对象字段的值。 * * @param o 要操作的对象 * @param offset 对象字段的内存偏移量 * @param expected 期望的旧值 * @param x 要设置的新值 * @return 如果值被成功更新,则返回 true;否则返回 false */ boolean compareAndSwapObject(Object o, long offset, Object expected, Object x); /** * 以原子方式更新 int 类型的对象字段的值。 */ boolean compareAndSwapInt(Object o, long offset, int expected, int x); /** * 以原子方式更新 long 类型的对象字段的值。 */ boolean compareAndSwapLong(Object o, long offset, long expected, long x); Unsafe类中的 CAS 方法是native方法。native关键字表明这些方法是用本地代码(通常是 C 或 C++)实现的,而不是用 Java 实现的。这些方法直接调用底层的硬件指令来实现原子操作。也就是说,Java 语言并没有直接用 Java 实现 CAS。\n更准确点来说,Java 中 CAS 是 C++ 内联汇编的形式实现的,通过 JNI(Java Native Interface) 调用。因此,CAS 的具体实现与操作系统以及 CPU 密切相关。\njava.util.concurrent.atomic 包提供了一些用于原子操作的类。\n关于这些 Atomic 原子类的介绍和使用,可以阅读这篇文章: Atomic 原子类总结。\nAtomic 类依赖于 CAS 乐观锁来保证其方法的原子性,而不需要使用传统的锁机制(如 synchronized 块或 ReentrantLock)。\nAtomicInteger是 Java 的原子类之一,主要用于对 int 类型的变量进行原子操作,它利用Unsafe类提供的低级别原子操作方法实现无锁的线程安全性。\n下面,我们通过解读AtomicInteger的核心源码(JDK1.8),来说明 Java 如何使用Unsafe类的方法来实现原子操作。\nAtomicInteger核心源码如下:\n// 获取 Unsafe 实例 private static final Unsafe unsafe = Unsafe.getUnsafe(); private static final long valueOffset; static { try { // 获取“value”字段在AtomicInteger类中的内存偏移量 valueOffset = unsafe.objectFieldOffset (AtomicInteger.class.getDeclaredField(\u0026#34;value\u0026#34;)); } catch (Exception ex) { throw new Error(ex); } } // 确保“value”字段的可见性 private volatile int value; // 如果当前值等于预期值,则原子地将值设置为newValue // 使用 Unsafe#compareAndSwapInt 方法进行CAS操作 public final boolean compareAndSet(int expect, int update) { return unsafe.compareAndSwapInt(this, valueOffset, expect, update); } // 原子地将当前值加 delta 并返回旧值 public final int getAndAdd(int delta) { return unsafe.getAndAddInt(this, valueOffset, delta); } // 原子地将当前值加 1 并返回加之前的值(旧值) // 使用 Unsafe#getAndAddInt 方法进行CAS操作。 public final int getAndIncrement() { return unsafe.getAndAddInt(this, valueOffset, 1); } // 原子地将当前值减 1 并返回减之前的值(旧值) public final int getAndDecrement() { return unsafe.getAndAddInt(this, valueOffset, -1); } Unsafe#getAndAddInt源码:\n// 原子地获取并增加整数值 public final int getAndAddInt(Object o, long offset, int delta) { int v; do { // 以 volatile 方式获取对象 o 在内存偏移量 offset 处的整数值 v = getIntVolatile(o, offset); } while (!compareAndSwapInt(o, offset, v, v + delta)); // 返回旧值 return v; } 可以看到,getAndAddInt 使用了 do-while 循环:在compareAndSwapInt操作失败时,会不断重试直到成功。也就是说,getAndAddInt方法会通过 compareAndSwapInt 方法来尝试更新 value 的值,如果更新失败(当前值在此期间被其他线程修改),它会重新获取当前值并再次尝试更新,直到操作成功。\n由于 CAS 操作可能会因为并发冲突而失败,因此通常会与while循环搭配使用,在失败后不断重试,直到操作成功。这就是 自旋锁机制 。\nCAS 算法存在哪些问题? # ABA 问题是 CAS 算法最常见的问题。\nABA 问题 # 如果一个变量 V 初次读取的时候是 A 值,并且在准备赋值的时候检查到它仍然是 A 值,那我们就能说明它的值没有被其他线程修改过了吗?很明显是不能的,因为在这段时间它的值可能被改为其他值,然后又改回 A,那 CAS 操作就会误认为它从来没有被修改过。这个问题被称为 CAS 操作的 \u0026ldquo;ABA\u0026quot;问题。\nABA 问题的解决思路是在变量前面追加上版本号或者时间戳。JDK 1.5 以后的 AtomicStampedReference 类就是用来解决 ABA 问题的,其中的 compareAndSet() 方法就是首先检查当前引用是否等于预期引用,并且当前标志是否等于预期标志,如果全部相等,则以原子方式将该引用和该标志的值设置为给定的更新值。\npublic boolean compareAndSet(V expectedReference, V newReference, int expectedStamp, int newStamp) { Pair\u0026lt;V\u0026gt; current = pair; return expectedReference == current.reference \u0026amp;\u0026amp; expectedStamp == current.stamp \u0026amp;\u0026amp; ((newReference == current.reference \u0026amp;\u0026amp; newStamp == current.stamp) || casPair(current, Pair.of(newReference, newStamp))); } 循环时间长开销大 # CAS 经常会用到自旋操作来进行重试,也就是不成功就一直循环执行直到成功。如果长时间不成功,会给 CPU 带来非常大的执行开销。\n如果 JVM 能够支持处理器提供的pause指令,那么自旋操作的效率将有所提升。pause指令有两个重要作用:\n延迟流水线执行指令:pause指令可以延迟指令的执行,从而减少 CPU 的资源消耗。具体的延迟时间取决于处理器的实现版本,在某些处理器上,延迟时间可能为零。 避免内存顺序冲突:在退出循环时,pause指令可以避免由于内存顺序冲突而导致的 CPU 流水线被清空,从而提高 CPU 的执行效率。 只能保证一个共享变量的原子操作 # CAS 操作仅能对单个共享变量有效。当需要操作多个共享变量时,CAS 就显得无能为力。不过,从 JDK 1.5 开始,Java 提供了AtomicReference类,这使得我们能够保证引用对象之间的原子性。通过将多个变量封装在一个对象中,我们可以使用AtomicReference来执行 CAS 操作。\n除了 AtomicReference 这种方式之外,还可以利用加锁来保证。\n总结 # 在 Java 中,CAS 通过 Unsafe 类中的 native 方法实现,这些方法调用底层的硬件指令来完成原子操作。由于其实现依赖于 C++ 内联汇编和 JNI 调用,因此 CAS 的具体实现与操作系统以及 CPU 密切相关。\nCAS 虽然具有高效的无锁特性,但也需要注意 ABA 、循环时间长开销大等问题。\n"},{"id":464,"href":"/zh/docs/technology/Interview/high-performance/cdn/","title":"CDN工作原理详解","section":"High Performance","content":" 什么是 CDN ? # CDN 全称是 Content Delivery Network/Content Distribution Network,翻译过的意思是 内容分发网络 。\n我们可以将内容分发网络拆开来看:\n内容:指的是静态资源比如图片、视频、文档、JS、CSS、HTML。 分发网络:指的是将这些静态资源分发到位于多个不同的地理位置机房中的服务器上,这样,就可以实现静态资源的就近访问比如北京的用户直接访问北京机房的数据。 所以,简单来说,CDN 就是将静态资源分发到多个不同的地方以实现就近访问,进而加快静态资源的访问速度,减轻服务器以及带宽的负担。\n类似于京东建立的庞大的仓储运输体系,京东物流在全国拥有非常多的仓库,仓储网络几乎覆盖全国所有区县。这样的话,用户下单的第一时间,商品就从距离用户最近的仓库,直接发往对应的配送站,再由京东小哥送到你家。\n你可以将 CDN 看作是服务上一层的特殊缓存服务,分布在全国各地,主要用来处理静态资源的请求。\n我们经常拿全站加速和内容分发网络做对比,不要把两者搞混了!全站加速(不同云服务商叫法不同,腾讯云叫 ECDN、阿里云叫 DCDN)既可以加速静态资源又可以加速动态资源,内容分发网络(CDN)主要针对的是 静态资源 。\n绝大部分公司都会在项目开发中使用 CDN 服务,但很少会有自建 CDN 服务的公司。基于成本、稳定性和易用性考虑,建议直接选择专业的云厂商(比如阿里云、腾讯云、华为云、青云)或者 CDN 厂商(比如网宿、蓝汛)提供的开箱即用的 CDN 服务。\n很多朋友可能要问了:既然是就近访问,为什么不直接将服务部署在多个不同的地方呢?\n成本太高,需要部署多份相同的服务。 静态资源通常占用空间比较大且经常会被访问到,如果直接使用服务器或者缓存来处理静态资源请求的话,对系统资源消耗非常大,可能会影响到系统其他服务的正常运行。 同一个服务在在多个不同的地方部署多份(比如同城灾备、异地灾备、同城多活、异地多活)是为了实现系统的高可用而不是就近访问。\nCDN 工作原理是什么? # 搞懂下面 3 个问题也就搞懂了 CDN 的工作原理:\n静态资源是如何被缓存到 CDN 节点中的? 如何找到最合适的 CDN 节点? 如何防止静态资源被盗用? 静态资源是如何被缓存到 CDN 节点中的? # 你可以通过 预热 的方式将源站的资源同步到 CDN 的节点中。这样的话,用户首次请求资源可以直接从 CDN 节点中取,无需回源。这样可以降低源站压力,提升用户体验。\n如果不预热的话,你访问的资源可能不在 CDN 节点中,这个时候 CDN 节点将请求源站获取资源,这个过程是大家经常说的 回源。\n回源:当 CDN 节点上没有用户请求的资源或该资源的缓存已经过期时,CDN 节点需要从原始服务器获取最新的资源内容,这个过程就是回源。当用户请求发生回源的话,会导致该请求的响应速度比未使用 CDN 还慢,因为相比于未使用 CDN 还多了一层 CDN 的调用流程。 预热:预热是指在 CDN 上提前将内容缓存到 CDN 节点上。这样当用户在请求这些资源时,能够快速地从最近的 CDN 节点获取到而不需要回源,进而减少了对源站的访问压力,提高了访问速度。 如果资源有更新的话,你也可以对其 刷新 ,删除 CDN 节点上缓存的旧资源,并强制 CDN 节点回源站获取最新资源。\n几乎所有云厂商提供的 CDN 服务都具备缓存的刷新和预热功能(下图是阿里云 CDN 服务提供的相应功能):\n命中率 和 回源率 是衡量 CDN 服务质量两个重要指标。命中率越高越好,回源率越低越好。\n如何找到最合适的 CDN 节点? # GSLB (Global Server Load Balance,全局负载均衡)是 CDN 的大脑,负责多个 CDN 节点之间相互协作,最常用的是基于 DNS 的 GSLB。\nCDN 会通过 GSLB 找到最合适的 CDN 节点,更具体点来说是下面这样的:\n浏览器向 DNS 服务器发送域名请求; DNS 服务器向根据 CNAME( Canonical Name ) 别名记录向 GSLB 发送请求; GSLB 返回性能最好(通常距离请求地址最近)的 CDN 节点(边缘服务器,真正缓存内容的地方)的地址给浏览器; 浏览器直接访问指定的 CDN 节点。 为了方便理解,上图其实做了一点简化。GSLB 内部可以看作是 CDN 专用 DNS 服务器和负载均衡系统组合。CDN 专用 DNS 服务器会返回负载均衡系统 IP 地址给浏览器,浏览器使用 IP 地址请求负载均衡系统进而找到对应的 CDN 节点。\nGSLB 是如何选择出最合适的 CDN 节点呢? GSLB 会根据请求的 IP 地址、CDN 节点状态(比如负载情况、性能、响应时间、带宽)等指标来综合判断具体返回哪一个 CDN 节点的地址。\n如何防止资源被盗刷? # 如果我们的资源被其他用户或者网站非法盗刷的话,将会是一笔不小的开支。\n解决这个问题最常用最简单的办法设置 Referer 防盗链,具体来说就是根据 HTTP 请求的头信息里面的 Referer 字段对请求进行限制。我们可以通过 Referer 字段获取到当前请求页面的来源页面的网站地址,这样我们就能确定请求是否来自合法的网站。\nCDN 服务提供商几乎都提供了这种比较基础的防盗链机制。\n不过,如果站点的防盗链配置允许 Referer 为空的话,通过隐藏 Referer,可以直接绕开防盗链。\n通常情况下,我们会配合其他机制来确保静态资源被盗用,一种常用的机制是 时间戳防盗链 。相比之下,时间戳防盗链 的安全性更强一些。时间戳防盗链加密的 URL 具有时效性,过期之后就无法再被允许访问。\n时间戳防盗链的 URL 通常会有两个参数一个是签名字符串,一个是过期时间。签名字符串一般是通过对用户设定的加密字符串、请求路径、过期时间通过 MD5 哈希算法取哈希的方式获得。\n时间戳防盗链 URL 示例:\nhttp://cdn.wangsu.com/4/123.mp3? wsSecret=79aead3bd7b5db4adeffb93a010298b5\u0026amp;wsTime=1601026312 wsSecret:签名字符串。 wsTime: 过期时间。 时间戳防盗链的实现也比较简单,并且可靠性较高,推荐使用。并且,绝大部分 CDN 服务提供商都提供了开箱即用的时间戳防盗链机制。\n除了 Referer 防盗链和时间戳防盗链之外,你还可以 IP 黑白名单配置、IP 访问限频配置等机制来防盗刷。\n总结 # CDN 就是将静态资源分发到多个不同的地方以实现就近访问,进而加快静态资源的访问速度,减轻服务器以及带宽的负担。 基于成本、稳定性和易用性考虑,建议直接选择专业的云厂商(比如阿里云、腾讯云、华为云、青云)或者 CDN 厂商(比如网宿、蓝汛)提供的开箱即用的 CDN 服务。 GSLB (Global Server Load Balance,全局负载均衡)是 CDN 的大脑,负责多个 CDN 节点之间相互协作,最常用的是基于 DNS 的 GSLB。CDN 会通过 GSLB 找到最合适的 CDN 节点。 为了防止静态资源被盗用,我们可以利用 Referer 防盗链 + 时间戳防盗链 。 参考 # 时间戳防盗链 - 七牛云 CDN: https://developer.qiniu.com/fusion/kb/1670/timestamp-hotlinking-prevention CDN 是个啥玩意?一文说个明白: https://mp.weixin.qq.com/s/Pp0C8ALUXsmYCUkM5QnkQw 《透视 HTTP 协议》- 37 | CDN:加速我们的网络服务: http://gk.link/a/11yOG "},{"id":465,"href":"/zh/docs/technology/Interview/java/concurrent/completablefuture-intro/","title":"CompletableFuture 详解","section":"Concurrent","content":"实际项目中,一个接口可能需要同时获取多种不同的数据,然后再汇总返回,这种场景还是挺常见的。举个例子:用户请求获取订单信息,可能需要同时获取用户信息、商品详情、物流信息、商品推荐等数据。\n如果是串行(按顺序依次执行每个任务)执行的话,接口的响应速度会非常慢。考虑到这些任务之间有大部分都是 无前后顺序关联 的,可以 并行执行 ,就比如说调用获取商品详情的时候,可以同时调用获取物流信息。通过并行执行多个任务的方式,接口的响应速度会得到大幅优化。\n对于存在前后调用顺序关系的任务,可以进行任务编排。\n获取用户信息之后,才能调用商品详情和物流信息接口。 成功获取商品详情和物流信息之后,才能调用商品推荐接口。 可能会用到多线程异步任务编排的场景(这里只是举例,数据不一定是一次返回,可能会对接口进行拆分):\n首页:例如技术社区的首页可能需要同时获取文章推荐列表、广告栏、文章排行榜、热门话题等信息。 详情页:例如技术社区的文章详情页可能需要同时获取作者信息、文章详情、文章评论等信息。 统计模块:例如技术社区的后台统计模块可能需要同时获取粉丝数汇总、文章数据(阅读量、评论量、收藏量)汇总等信息。 对于 Java 程序来说,Java 8 才被引入的 CompletableFuture 可以帮助我们来做多个任务的编排,功能非常强大。\n这篇文章是 CompletableFuture 的简单入门,带大家看看 CompletableFuture 常用的 API。\nFuture 介绍 # Future 类是异步思想的典型运用,主要用在一些需要执行耗时任务的场景,避免程序一直原地等待耗时任务执行完成,执行效率太低。具体来说是这样的:当我们执行某一耗时的任务时,可以将这个耗时任务交给一个子线程去异步执行,同时我们可以干点其他事情,不用傻傻等待耗时任务执行完成。等我们的事情干完后,我们再通过 Future 类获取到耗时任务的执行结果。这样一来,程序的执行效率就明显提高了。\n这其实就是多线程中经典的 Future 模式,你可以将其看作是一种设计模式,核心思想是异步调用,主要用在多线程领域,并非 Java 语言独有。\n在 Java 中,Future 类只是一个泛型接口,位于 java.util.concurrent 包下,其中定义了 5 个方法,主要包括下面这 4 个功能:\n取消任务; 判断任务是否被取消; 判断任务是否已经执行完成; 获取任务执行结果。 // V 代表了Future执行的任务返回值的类型 public interface Future\u0026lt;V\u0026gt; { // 取消任务执行 // 成功取消返回 true,否则返回 false boolean cancel(boolean mayInterruptIfRunning); // 判断任务是否被取消 boolean isCancelled(); // 判断任务是否已经执行完成 boolean isDone(); // 获取任务执行结果 V get() throws InterruptedException, ExecutionException; // 指定时间内没有返回计算结果就抛出 TimeOutException 异常 V get(long timeout, TimeUnit unit) throws InterruptedException, ExecutionException, TimeoutExceptio } 简单理解就是:我有一个任务,提交给了 Future 来处理。任务执行期间我自己可以去做任何想做的事情。并且,在这期间我还可以取消任务以及获取任务的执行状态。一段时间之后,我就可以 Future 那里直接取出任务执行结果。\nCompletableFuture 介绍 # Future 在实际使用过程中存在一些局限性比如不支持异步任务的编排组合、获取计算结果的 get() 方法为阻塞调用。\nJava 8 才被引入CompletableFuture 类可以解决Future 的这些缺陷。CompletableFuture 除了提供了更为好用和强大的 Future 特性之外,还提供了函数式编程、异步任务编排组合(可以将多个异步任务串联起来,组成一个完整的链式调用)等能力。\n下面我们来简单看看 CompletableFuture 类的定义。\npublic class CompletableFuture\u0026lt;T\u0026gt; implements Future\u0026lt;T\u0026gt;, CompletionStage\u0026lt;T\u0026gt; { } 可以看到,CompletableFuture 同时实现了 Future 和 CompletionStage 接口。\nCompletionStage 接口描述了一个异步计算的阶段。很多计算可以分成多个阶段或步骤,此时可以通过它将所有步骤组合起来,形成异步计算的流水线。\nCompletableFuture 除了提供了更为好用和强大的 Future 特性之外,还提供了函数式编程的能力。\nFuture 接口有 5 个方法:\nboolean cancel(boolean mayInterruptIfRunning):尝试取消执行任务。 boolean isCancelled():判断任务是否被取消。 boolean isDone():判断任务是否已经被执行完成。 get():等待任务执行完成并获取运算结果。 get(long timeout, TimeUnit unit):多了一个超时时间。 CompletionStage 接口描述了一个异步计算的阶段。很多计算可以分成多个阶段或步骤,此时可以通过它将所有步骤组合起来,形成异步计算的流水线。\nCompletionStage 接口中的方法比较多,CompletableFuture 的函数式能力就是这个接口赋予的。从这个接口的方法参数你就可以发现其大量使用了 Java8 引入的函数式编程。\n由于方法众多,所以这里不能一一讲解,下文中我会介绍大部分常见方法的使用。\nCompletableFuture 常见操作 # 创建 CompletableFuture # 常见的创建 CompletableFuture 对象的方法如下:\n通过 new 关键字。 基于 CompletableFuture 自带的静态工厂方法:runAsync()、supplyAsync() 。 new 关键字 # 通过 new 关键字创建 CompletableFuture 对象这种使用方式可以看作是将 CompletableFuture 当做 Future 来使用。\n我在我的开源项目 guide-rpc-framework 中就是这种方式创建的 CompletableFuture 对象。\n下面咱们来看一个简单的案例。\n我们通过创建了一个结果值类型为 RpcResponse\u0026lt;Object\u0026gt; 的 CompletableFuture,你可以把 resultFuture 看作是异步运算结果的载体。\nCompletableFuture\u0026lt;RpcResponse\u0026lt;Object\u0026gt;\u0026gt; resultFuture = new CompletableFuture\u0026lt;\u0026gt;(); 假设在未来的某个时刻,我们得到了最终的结果。这时,我们可以调用 complete() 方法为其传入结果,这表示 resultFuture 已经被完成了。\n// complete() 方法只能调用一次,后续调用将被忽略。 resultFuture.complete(rpcResponse); 你可以通过 isDone() 方法来检查是否已经完成。\npublic boolean isDone() { return result != null; } 获取异步计算的结果也非常简单,直接调用 get() 方法即可。调用 get() 方法的线程会阻塞直到 CompletableFuture 完成运算。\nrpcResponse = completableFuture.get(); 如果你已经知道计算的结果的话,可以使用静态方法 completedFuture() 来创建 CompletableFuture 。\nCompletableFuture\u0026lt;String\u0026gt; future = CompletableFuture.completedFuture(\u0026#34;hello!\u0026#34;); assertEquals(\u0026#34;hello!\u0026#34;, future.get()); completedFuture() 方法底层调用的是带参数的 new 方法,只不过,这个方法不对外暴露。\npublic static \u0026lt;U\u0026gt; CompletableFuture\u0026lt;U\u0026gt; completedFuture(U value) { return new CompletableFuture\u0026lt;U\u0026gt;((value == null) ? NIL : value); } 静态工厂方法 # 这两个方法可以帮助我们封装计算逻辑。\nstatic \u0026lt;U\u0026gt; CompletableFuture\u0026lt;U\u0026gt; supplyAsync(Supplier\u0026lt;U\u0026gt; supplier); // 使用自定义线程池(推荐) static \u0026lt;U\u0026gt; CompletableFuture\u0026lt;U\u0026gt; supplyAsync(Supplier\u0026lt;U\u0026gt; supplier, Executor executor); static CompletableFuture\u0026lt;Void\u0026gt; runAsync(Runnable runnable); // 使用自定义线程池(推荐) static CompletableFuture\u0026lt;Void\u0026gt; runAsync(Runnable runnable, Executor executor); runAsync() 方法接受的参数是 Runnable ,这是一个函数式接口,不允许返回值。当你需要异步操作且不关心返回结果的时候可以使用 runAsync() 方法。\n@FunctionalInterface public interface Runnable { public abstract void run(); } supplyAsync() 方法接受的参数是 Supplier\u0026lt;U\u0026gt; ,这也是一个函数式接口,U 是返回结果值的类型。\n@FunctionalInterface public interface Supplier\u0026lt;T\u0026gt; { /** * Gets a result. * * @return a result */ T get(); } 当你需要异步操作且关心返回结果的时候,可以使用 supplyAsync() 方法。\nCompletableFuture\u0026lt;Void\u0026gt; future = CompletableFuture.runAsync(() -\u0026gt; System.out.println(\u0026#34;hello!\u0026#34;)); future.get();// 输出 \u0026#34;hello!\u0026#34; CompletableFuture\u0026lt;String\u0026gt; future2 = CompletableFuture.supplyAsync(() -\u0026gt; \u0026#34;hello!\u0026#34;); assertEquals(\u0026#34;hello!\u0026#34;, future2.get()); 处理异步结算的结果 # 当我们获取到异步计算的结果之后,还可以对其进行进一步的处理,比较常用的方法有下面几个:\nthenApply() thenAccept() thenRun() whenComplete() thenApply() 方法接受一个 Function 实例,用它来处理结果。\n// 沿用上一个任务的线程池 public \u0026lt;U\u0026gt; CompletableFuture\u0026lt;U\u0026gt; thenApply( Function\u0026lt;? super T,? extends U\u0026gt; fn) { return uniApplyStage(null, fn); } //使用默认的 ForkJoinPool 线程池(不推荐) public \u0026lt;U\u0026gt; CompletableFuture\u0026lt;U\u0026gt; thenApplyAsync( Function\u0026lt;? super T,? extends U\u0026gt; fn) { return uniApplyStage(defaultExecutor(), fn); } // 使用自定义线程池(推荐) public \u0026lt;U\u0026gt; CompletableFuture\u0026lt;U\u0026gt; thenApplyAsync( Function\u0026lt;? super T,? extends U\u0026gt; fn, Executor executor) { return uniApplyStage(screenExecutor(executor), fn); } thenApply() 方法使用示例如下:\nCompletableFuture\u0026lt;String\u0026gt; future = CompletableFuture.completedFuture(\u0026#34;hello!\u0026#34;) .thenApply(s -\u0026gt; s + \u0026#34;world!\u0026#34;); assertEquals(\u0026#34;hello!world!\u0026#34;, future.get()); // 这次调用将被忽略。 future.thenApply(s -\u0026gt; s + \u0026#34;nice!\u0026#34;); assertEquals(\u0026#34;hello!world!\u0026#34;, future.get()); 你还可以进行 流式调用:\nCompletableFuture\u0026lt;String\u0026gt; future = CompletableFuture.completedFuture(\u0026#34;hello!\u0026#34;) .thenApply(s -\u0026gt; s + \u0026#34;world!\u0026#34;).thenApply(s -\u0026gt; s + \u0026#34;nice!\u0026#34;); assertEquals(\u0026#34;hello!world!nice!\u0026#34;, future.get()); 如果你不需要从回调函数中获取返回结果,可以使用 thenAccept() 或者 thenRun()。这两个方法的区别在于 thenRun() 不能访问异步计算的结果。\nthenAccept() 方法的参数是 Consumer\u0026lt;? super T\u0026gt; 。\npublic CompletableFuture\u0026lt;Void\u0026gt; thenAccept(Consumer\u0026lt;? super T\u0026gt; action) { return uniAcceptStage(null, action); } public CompletableFuture\u0026lt;Void\u0026gt; thenAcceptAsync(Consumer\u0026lt;? super T\u0026gt; action) { return uniAcceptStage(defaultExecutor(), action); } public CompletableFuture\u0026lt;Void\u0026gt; thenAcceptAsync(Consumer\u0026lt;? super T\u0026gt; action, Executor executor) { return uniAcceptStage(screenExecutor(executor), action); } 顾名思义,Consumer 属于消费型接口,它可以接收 1 个输入对象然后进行“消费”。\n@FunctionalInterface public interface Consumer\u0026lt;T\u0026gt; { void accept(T t); default Consumer\u0026lt;T\u0026gt; andThen(Consumer\u0026lt;? super T\u0026gt; after) { Objects.requireNonNull(after); return (T t) -\u0026gt; { accept(t); after.accept(t); }; } } thenRun() 的方法是的参数是 Runnable 。\npublic CompletableFuture\u0026lt;Void\u0026gt; thenRun(Runnable action) { return uniRunStage(null, action); } public CompletableFuture\u0026lt;Void\u0026gt; thenRunAsync(Runnable action) { return uniRunStage(defaultExecutor(), action); } public CompletableFuture\u0026lt;Void\u0026gt; thenRunAsync(Runnable action, Executor executor) { return uniRunStage(screenExecutor(executor), action); } thenAccept() 和 thenRun() 使用示例如下:\nCompletableFuture.completedFuture(\u0026#34;hello!\u0026#34;) .thenApply(s -\u0026gt; s + \u0026#34;world!\u0026#34;).thenApply(s -\u0026gt; s + \u0026#34;nice!\u0026#34;).thenAccept(System.out::println);//hello!world!nice! CompletableFuture.completedFuture(\u0026#34;hello!\u0026#34;) .thenApply(s -\u0026gt; s + \u0026#34;world!\u0026#34;).thenApply(s -\u0026gt; s + \u0026#34;nice!\u0026#34;).thenRun(() -\u0026gt; System.out.println(\u0026#34;hello!\u0026#34;));//hello! whenComplete() 的方法的参数是 BiConsumer\u0026lt;? super T, ? super Throwable\u0026gt; 。\npublic CompletableFuture\u0026lt;T\u0026gt; whenComplete( BiConsumer\u0026lt;? super T, ? super Throwable\u0026gt; action) { return uniWhenCompleteStage(null, action); } public CompletableFuture\u0026lt;T\u0026gt; whenCompleteAsync( BiConsumer\u0026lt;? super T, ? super Throwable\u0026gt; action) { return uniWhenCompleteStage(defaultExecutor(), action); } // 使用自定义线程池(推荐) public CompletableFuture\u0026lt;T\u0026gt; whenCompleteAsync( BiConsumer\u0026lt;? super T, ? super Throwable\u0026gt; action, Executor executor) { return uniWhenCompleteStage(screenExecutor(executor), action); } 相对于 Consumer , BiConsumer 可以接收 2 个输入对象然后进行“消费”。\n@FunctionalInterface public interface BiConsumer\u0026lt;T, U\u0026gt; { void accept(T t, U u); default BiConsumer\u0026lt;T, U\u0026gt; andThen(BiConsumer\u0026lt;? super T, ? super U\u0026gt; after) { Objects.requireNonNull(after); return (l, r) -\u0026gt; { accept(l, r); after.accept(l, r); }; } } whenComplete() 使用示例如下:\nCompletableFuture\u0026lt;String\u0026gt; future = CompletableFuture.supplyAsync(() -\u0026gt; \u0026#34;hello!\u0026#34;) .whenComplete((res, ex) -\u0026gt; { // res 代表返回的结果 // ex 的类型为 Throwable ,代表抛出的异常 System.out.println(res); // 这里没有抛出异常所有为 null assertNull(ex); }); assertEquals(\u0026#34;hello!\u0026#34;, future.get()); 异常处理 # 你可以通过 handle() 方法来处理任务执行过程中可能出现的抛出异常的情况。\npublic \u0026lt;U\u0026gt; CompletableFuture\u0026lt;U\u0026gt; handle( BiFunction\u0026lt;? super T, Throwable, ? extends U\u0026gt; fn) { return uniHandleStage(null, fn); } public \u0026lt;U\u0026gt; CompletableFuture\u0026lt;U\u0026gt; handleAsync( BiFunction\u0026lt;? super T, Throwable, ? extends U\u0026gt; fn) { return uniHandleStage(defaultExecutor(), fn); } public \u0026lt;U\u0026gt; CompletableFuture\u0026lt;U\u0026gt; handleAsync( BiFunction\u0026lt;? super T, Throwable, ? extends U\u0026gt; fn, Executor executor) { return uniHandleStage(screenExecutor(executor), fn); } 示例代码如下:\nCompletableFuture\u0026lt;String\u0026gt; future = CompletableFuture.supplyAsync(() -\u0026gt; { if (true) { throw new RuntimeException(\u0026#34;Computation error!\u0026#34;); } return \u0026#34;hello!\u0026#34;; }).handle((res, ex) -\u0026gt; { // res 代表返回的结果 // ex 的类型为 Throwable ,代表抛出的异常 return res != null ? res : \u0026#34;world!\u0026#34;; }); assertEquals(\u0026#34;world!\u0026#34;, future.get()); 你还可以通过 exceptionally() 方法来处理异常情况。\nCompletableFuture\u0026lt;String\u0026gt; future = CompletableFuture.supplyAsync(() -\u0026gt; { if (true) { throw new RuntimeException(\u0026#34;Computation error!\u0026#34;); } return \u0026#34;hello!\u0026#34;; }).exceptionally(ex -\u0026gt; { System.out.println(ex.toString());// CompletionException return \u0026#34;world!\u0026#34;; }); assertEquals(\u0026#34;world!\u0026#34;, future.get()); 如果你想让 CompletableFuture 的结果就是异常的话,可以使用 completeExceptionally() 方法为其赋值。\nCompletableFuture\u0026lt;String\u0026gt; completableFuture = new CompletableFuture\u0026lt;\u0026gt;(); // ... completableFuture.completeExceptionally( new RuntimeException(\u0026#34;Calculation failed!\u0026#34;)); // ... completableFuture.get(); // ExecutionException 组合 CompletableFuture # 你可以使用 thenCompose() 按顺序链接两个 CompletableFuture 对象,实现异步的任务链。它的作用是将前一个任务的返回结果作为下一个任务的输入参数,从而形成一个依赖关系。\npublic \u0026lt;U\u0026gt; CompletableFuture\u0026lt;U\u0026gt; thenCompose( Function\u0026lt;? super T, ? extends CompletionStage\u0026lt;U\u0026gt;\u0026gt; fn) { return uniComposeStage(null, fn); } public \u0026lt;U\u0026gt; CompletableFuture\u0026lt;U\u0026gt; thenComposeAsync( Function\u0026lt;? super T, ? extends CompletionStage\u0026lt;U\u0026gt;\u0026gt; fn) { return uniComposeStage(defaultExecutor(), fn); } public \u0026lt;U\u0026gt; CompletableFuture\u0026lt;U\u0026gt; thenComposeAsync( Function\u0026lt;? super T, ? extends CompletionStage\u0026lt;U\u0026gt;\u0026gt; fn, Executor executor) { return uniComposeStage(screenExecutor(executor), fn); } thenCompose() 方法会使用示例如下:\nCompletableFuture\u0026lt;String\u0026gt; future = CompletableFuture.supplyAsync(() -\u0026gt; \u0026#34;hello!\u0026#34;) .thenCompose(s -\u0026gt; CompletableFuture.supplyAsync(() -\u0026gt; s + \u0026#34;world!\u0026#34;)); assertEquals(\u0026#34;hello!world!\u0026#34;, future.get()); 在实际开发中,这个方法还是非常有用的。比如说,task1 和 task2 都是异步执行的,但 task1 必须执行完成后才能开始执行 task2(task2 依赖 task1 的执行结果)。\n和 thenCompose() 方法类似的还有 thenCombine() 方法, 它同样可以组合两个 CompletableFuture 对象。\nCompletableFuture\u0026lt;String\u0026gt; completableFuture = CompletableFuture.supplyAsync(() -\u0026gt; \u0026#34;hello!\u0026#34;) .thenCombine(CompletableFuture.supplyAsync( () -\u0026gt; \u0026#34;world!\u0026#34;), (s1, s2) -\u0026gt; s1 + s2) .thenCompose(s -\u0026gt; CompletableFuture.supplyAsync(() -\u0026gt; s + \u0026#34;nice!\u0026#34;)); assertEquals(\u0026#34;hello!world!nice!\u0026#34;, completableFuture.get()); 那 thenCompose() 和 thenCombine() 有什么区别呢?\nthenCompose() 可以链接两个 CompletableFuture 对象,并将前一个任务的返回结果作为下一个任务的参数,它们之间存在着先后顺序。 thenCombine() 会在两个任务都执行完成后,把两个任务的结果合并。两个任务是并行执行的,它们之间并没有先后依赖顺序。 除了 thenCompose() 和 thenCombine() 之外, 还有一些其他的组合 CompletableFuture 的方法用于实现不同的效果,满足不同的业务需求。\n例如,如果我们想要实现 task1 和 task2 中的任意一个任务执行完后就执行 task3 的话,可以使用 acceptEither()。\npublic CompletableFuture\u0026lt;Void\u0026gt; acceptEither( CompletionStage\u0026lt;? extends T\u0026gt; other, Consumer\u0026lt;? super T\u0026gt; action) { return orAcceptStage(null, other, action); } public CompletableFuture\u0026lt;Void\u0026gt; acceptEitherAsync( CompletionStage\u0026lt;? extends T\u0026gt; other, Consumer\u0026lt;? super T\u0026gt; action) { return orAcceptStage(asyncPool, other, action); } 简单举一个例子:\nCompletableFuture\u0026lt;String\u0026gt; task = CompletableFuture.supplyAsync(() -\u0026gt; { System.out.println(\u0026#34;任务1开始执行,当前时间:\u0026#34; + System.currentTimeMillis()); try { Thread.sleep(500); } catch (InterruptedException e) { e.printStackTrace(); } System.out.println(\u0026#34;任务1执行完毕,当前时间:\u0026#34; + System.currentTimeMillis()); return \u0026#34;task1\u0026#34;; }); CompletableFuture\u0026lt;String\u0026gt; task2 = CompletableFuture.supplyAsync(() -\u0026gt; { System.out.println(\u0026#34;任务2开始执行,当前时间:\u0026#34; + System.currentTimeMillis()); try { Thread.sleep(1000); } catch (InterruptedException e) { e.printStackTrace(); } System.out.println(\u0026#34;任务2执行完毕,当前时间:\u0026#34; + System.currentTimeMillis()); return \u0026#34;task2\u0026#34;; }); task.acceptEitherAsync(task2, (res) -\u0026gt; { System.out.println(\u0026#34;任务3开始执行,当前时间:\u0026#34; + System.currentTimeMillis()); System.out.println(\u0026#34;上一个任务的结果为:\u0026#34; + res); }); // 增加一些延迟时间,确保异步任务有足够的时间完成 try { Thread.sleep(2000); } catch (InterruptedException e) { e.printStackTrace(); } 输出:\n任务1开始执行,当前时间:1695088058520 任务2开始执行,当前时间:1695088058521 任务1执行完毕,当前时间:1695088059023 任务3开始执行,当前时间:1695088059023 上一个任务的结果为:task1 任务2执行完毕,当前时间:1695088059523 任务组合操作acceptEitherAsync()会在异步任务 1 和异步任务 2 中的任意一个完成时触发执行任务 3,但是需要注意,这个触发时机是不确定的。如果任务 1 和任务 2 都还未完成,那么任务 3 就不能被执行。\n并行运行多个 CompletableFuture # 你可以通过 CompletableFuture 的 allOf()这个静态方法来并行运行多个 CompletableFuture 。\n实际项目中,我们经常需要并行运行多个互不相关的任务,这些任务之间没有依赖关系,可以互相独立地运行。\n比说我们要读取处理 6 个文件,这 6 个任务都是没有执行顺序依赖的任务,但是我们需要返回给用户的时候将这几个文件的处理的结果进行统计整理。像这种情况我们就可以使用并行运行多个 CompletableFuture 来处理。\n示例代码如下:\nCompletableFuture\u0026lt;Void\u0026gt; task1 = CompletableFuture.supplyAsync(()-\u0026gt;{ //自定义业务操作 }); ...... CompletableFuture\u0026lt;Void\u0026gt; task6 = CompletableFuture.supplyAsync(()-\u0026gt;{ //自定义业务操作 }); ...... CompletableFuture\u0026lt;Void\u0026gt; headerFuture=CompletableFuture.allOf(task1,.....,task6); try { headerFuture.join(); } catch (Exception ex) { ...... } System.out.println(\u0026#34;all done. \u0026#34;); 经常和 allOf() 方法拿来对比的是 anyOf() 方法。\nallOf() 方法会等到所有的 CompletableFuture 都运行完成之后再返回\nRandom rand = new Random(); CompletableFuture\u0026lt;String\u0026gt; future1 = CompletableFuture.supplyAsync(() -\u0026gt; { try { Thread.sleep(1000 + rand.nextInt(1000)); } catch (InterruptedException e) { e.printStackTrace(); } finally { System.out.println(\u0026#34;future1 done...\u0026#34;); } return \u0026#34;abc\u0026#34;; }); CompletableFuture\u0026lt;String\u0026gt; future2 = CompletableFuture.supplyAsync(() -\u0026gt; { try { Thread.sleep(1000 + rand.nextInt(1000)); } catch (InterruptedException e) { e.printStackTrace(); } finally { System.out.println(\u0026#34;future2 done...\u0026#34;); } return \u0026#34;efg\u0026#34;; }); 调用 join() 可以让程序等future1 和 future2 都运行完了之后再继续执行。\nCompletableFuture\u0026lt;Void\u0026gt; completableFuture = CompletableFuture.allOf(future1, future2); completableFuture.join(); assertTrue(completableFuture.isDone()); System.out.println(\u0026#34;all futures done...\u0026#34;); 输出:\nfuture1 done... future2 done... all futures done... anyOf() 方法不会等待所有的 CompletableFuture 都运行完成之后再返回,只要有一个执行完成即可!\nCompletableFuture\u0026lt;Object\u0026gt; f = CompletableFuture.anyOf(future1, future2); System.out.println(f.get()); 输出结果可能是:\nfuture2 done... efg 也可能是:\nfuture1 done... abc CompletableFuture 使用建议 # 使用自定义线程池 # 我们上面的代码示例中,为了方便,都没有选择自定义线程池。实际项目中,这是不可取的。\nCompletableFuture 默认使用全局共享的 ForkJoinPool.commonPool() 作为执行器,所有未指定执行器的异步任务都会使用该线程池。这意味着应用程序、多个库或框架(如 Spring、第三方库)若都依赖 CompletableFuture,默认情况下它们都会共享同一个线程池。\n虽然 ForkJoinPool 效率很高,但当同时提交大量任务时,可能会导致资源竞争和线程饥饿,进而影响系统性能。\n为避免这些问题,建议为 CompletableFuture 提供自定义线程池,带来以下优势:\n隔离性:为不同任务分配独立的线程池,避免全局线程池资源争夺。 资源控制:根据任务特性调整线程池大小和队列类型,优化性能表现。 异常处理:通过自定义 ThreadFactory 更好地处理线程中的异常情况。 private ThreadPoolExecutor executor = new ThreadPoolExecutor(10, 10, 0L, TimeUnit.MILLISECONDS, new LinkedBlockingQueue\u0026lt;Runnable\u0026gt;()); CompletableFuture.runAsync(() -\u0026gt; { //... }, executor); 尽量避免使用 get() # CompletableFuture的get()方法是阻塞的,尽量避免使用。如果必须要使用的话,需要添加超时时间,否则可能会导致主线程一直等待,无法执行其他任务。\nCompletableFuture\u0026lt;String\u0026gt; future = CompletableFuture.supplyAsync(() -\u0026gt; { try { Thread.sleep(10_000); } catch (InterruptedException e) { e.printStackTrace(); } return \u0026#34;Hello, world!\u0026#34;; }); // 获取异步任务的返回值,设置超时时间为 5 秒 try { String result = future.get(5, TimeUnit.SECONDS); System.out.println(result); } catch (InterruptedException | ExecutionException | TimeoutException e) { // 处理异常 e.printStackTrace(); } } 上面这段代码在调用 get() 时抛出了 TimeoutException 异常。这样我们就可以在异常处理中进行相应的操作,比如取消任务、重试任务、记录日志等。\n正确进行异常处理 # 使用 CompletableFuture的时候一定要以正确的方式进行异常处理,避免异常丢失或者出现不可控问题。\n下面是一些建议:\n使用 whenComplete 方法可以在任务完成时触发回调函数,并正确地处理异常,而不是让异常被吞噬或丢失。 使用 exceptionally 方法可以处理异常并重新抛出,以便异常能够传播到后续阶段,而不是让异常被忽略或终止。 使用 handle 方法可以处理正常的返回结果和异常,并返回一个新的结果,而不是让异常影响正常的业务逻辑。 使用 CompletableFuture.allOf 方法可以组合多个 CompletableFuture,并统一处理所有任务的异常,而不是让异常处理过于冗长或重复。 …… 合理组合多个异步任务 # 正确使用 thenCompose() 、 thenCombine() 、acceptEither()、allOf()、anyOf()等方法来组合多个异步任务,以满足实际业务的需求,提高程序执行效率。\n实际使用中,我们还可以利用或者参考现成的异步任务编排框架,比如京东的 asyncTool 。\n后记 # 这篇文章只是简单介绍了 CompletableFuture 的核心概念和比较常用的一些 API 。如果想要深入学习的话,还可以多找一些书籍和博客看,比如下面几篇文章就挺不错:\nCompletableFuture 原理与实践-外卖商家端 API 的异步化 - 美团技术团队:这篇文章详细介绍了 CompletableFuture 在实际项目中的运用。参考这篇文章,可以对项目中类似的场景进行优化,也算是一个小亮点了。这种性能优化方式比较简单且效果还不错! 读 RocketMQ 源码,学习并发编程三大神器 - 勇哥 java 实战分享:这篇文章介绍了 RocketMQ 对CompletableFuture的应用。具体来说,从 RocketMQ 4.7 开始,RocketMQ 引入了 CompletableFuture来实现异步消息处理 。 另外,建议 G 友们可以看看京东的 asyncTool 这个并发框架,里面大量使用到了 CompletableFuture 。\n"},{"id":466,"href":"/zh/docs/technology/Interview/java/collection/concurrent-hash-map-source-code/","title":"ConcurrentHashMap 源码分析","section":"Collection","content":" 本文来自公众号:末读代码的投稿,原文地址: https://mp.weixin.qq.com/s/AHWzboztt53ZfFZmsSnMSw 。\n上一篇文章介绍了 HashMap 源码,反响不错,也有很多同学发表了自己的观点,这次又来了,这次是 ConcurrentHashMap 了,作为线程安全的 HashMap ,它的使用频率也是很高。那么它的存储结构和实现原理是怎么样的呢?\n1. ConcurrentHashMap 1.7 # 1. 存储结构 # Java 7 中 ConcurrentHashMap 的存储结构如上图,ConcurrnetHashMap 由很多个 Segment 组合,而每一个 Segment 是一个类似于 HashMap 的结构,所以每一个 HashMap 的内部可以进行扩容。但是 Segment 的个数一旦初始化就不能改变,默认 Segment 的个数是 16 个,你也可以认为 ConcurrentHashMap 默认支持最多 16 个线程并发。\n2. 初始化 # 通过 ConcurrentHashMap 的无参构造探寻 ConcurrentHashMap 的初始化流程。\n/** * Creates a new, empty map with a default initial capacity (16), * load factor (0.75) and concurrencyLevel (16). */ public ConcurrentHashMap() { this(DEFAULT_INITIAL_CAPACITY, DEFAULT_LOAD_FACTOR, DEFAULT_CONCURRENCY_LEVEL); } 无参构造中调用了有参构造,传入了三个参数的默认值,他们的值是。\n/** * 默认初始化容量 */ static final int DEFAULT_INITIAL_CAPACITY = 16; /** * 默认负载因子 */ static final float DEFAULT_LOAD_FACTOR = 0.75f; /** * 默认并发级别 */ static final int DEFAULT_CONCURRENCY_LEVEL = 16; 接着看下这个有参构造函数的内部实现逻辑。\n@SuppressWarnings(\u0026#34;unchecked\u0026#34;) public ConcurrentHashMap(int initialCapacity,float loadFactor, int concurrencyLevel) { // 参数校验 if (!(loadFactor \u0026gt; 0) || initialCapacity \u0026lt; 0 || concurrencyLevel \u0026lt;= 0) throw new IllegalArgumentException(); // 校验并发级别大小,大于 1\u0026lt;\u0026lt;16,重置为 65536 if (concurrencyLevel \u0026gt; MAX_SEGMENTS) concurrencyLevel = MAX_SEGMENTS; // Find power-of-two sizes best matching arguments // 2的多少次方 int sshift = 0; int ssize = 1; // 这个循环可以找到 concurrencyLevel 之上最近的 2的次方值 while (ssize \u0026lt; concurrencyLevel) { ++sshift; ssize \u0026lt;\u0026lt;= 1; } // 记录段偏移量 this.segmentShift = 32 - sshift; // 记录段掩码 this.segmentMask = ssize - 1; // 设置容量 if (initialCapacity \u0026gt; MAXIMUM_CAPACITY) initialCapacity = MAXIMUM_CAPACITY; // c = 容量 / ssize ,默认 16 / 16 = 1,这里是计算每个 Segment 中的类似于 HashMap 的容量 int c = initialCapacity / ssize; if (c * ssize \u0026lt; initialCapacity) ++c; int cap = MIN_SEGMENT_TABLE_CAPACITY; //Segment 中的类似于 HashMap 的容量至少是2或者2的倍数 while (cap \u0026lt; c) cap \u0026lt;\u0026lt;= 1; // create segments and segments[0] // 创建 Segment 数组,设置 segments[0] Segment\u0026lt;K,V\u0026gt; s0 = new Segment\u0026lt;K,V\u0026gt;(loadFactor, (int)(cap * loadFactor), (HashEntry\u0026lt;K,V\u0026gt;[])new HashEntry[cap]); Segment\u0026lt;K,V\u0026gt;[] ss = (Segment\u0026lt;K,V\u0026gt;[])new Segment[ssize]; UNSAFE.putOrderedObject(ss, SBASE, s0); // ordered write of segments[0] this.segments = ss; } 总结一下在 Java 7 中 ConcurrentHashMap 的初始化逻辑。\n必要参数校验。 校验并发级别 concurrencyLevel 大小,如果大于最大值,重置为最大值。无参构造默认值是 16. 寻找并发级别 concurrencyLevel 之上最近的 2 的幂次方值,作为初始化容量大小,默认是 16。 记录 segmentShift 偏移量,这个值为【容量 = 2 的 N 次方】中的 N,在后面 Put 时计算位置时会用到。默认是 32 - sshift = 28. 记录 segmentMask,默认是 ssize - 1 = 16 -1 = 15. 初始化 segments[0],默认大小为 2,负载因子 0.75,扩容阀值是 2*0.75=1.5,插入第二个值时才会进行扩容。 3. put # 接着上面的初始化参数继续查看 put 方法源码。\n/** * Maps the specified key to the specified value in this table. * Neither the key nor the value can be null. * * \u0026lt;p\u0026gt; The value can be retrieved by calling the \u0026lt;tt\u0026gt;get\u0026lt;/tt\u0026gt; method * with a key that is equal to the original key. * * @param key key with which the specified value is to be associated * @param value value to be associated with the specified key * @return the previous value associated with \u0026lt;tt\u0026gt;key\u0026lt;/tt\u0026gt;, or * \u0026lt;tt\u0026gt;null\u0026lt;/tt\u0026gt; if there was no mapping for \u0026lt;tt\u0026gt;key\u0026lt;/tt\u0026gt; * @throws NullPointerException if the specified key or value is null */ public V put(K key, V value) { Segment\u0026lt;K,V\u0026gt; s; if (value == null) throw new NullPointerException(); int hash = hash(key); // hash 值无符号右移 28位(初始化时获得),然后与 segmentMask=15 做与运算 // 其实也就是把高4位与segmentMask(1111)做与运算 int j = (hash \u0026gt;\u0026gt;\u0026gt; segmentShift) \u0026amp; segmentMask; if ((s = (Segment\u0026lt;K,V\u0026gt;)UNSAFE.getObject // nonvolatile; recheck (segments, (j \u0026lt;\u0026lt; SSHIFT) + SBASE)) == null) // in ensureSegment // 如果查找到的 Segment 为空,初始化 s = ensureSegment(j); return s.put(key, hash, value, false); } /** * Returns the segment for the given index, creating it and * recording in segment table (via CAS) if not already present. * * @param k the index * @return the segment */ @SuppressWarnings(\u0026#34;unchecked\u0026#34;) private Segment\u0026lt;K,V\u0026gt; ensureSegment(int k) { final Segment\u0026lt;K,V\u0026gt;[] ss = this.segments; long u = (k \u0026lt;\u0026lt; SSHIFT) + SBASE; // raw offset Segment\u0026lt;K,V\u0026gt; seg; // 判断 u 位置的 Segment 是否为null if ((seg = (Segment\u0026lt;K,V\u0026gt;)UNSAFE.getObjectVolatile(ss, u)) == null) { Segment\u0026lt;K,V\u0026gt; proto = ss[0]; // use segment 0 as prototype // 获取0号 segment 里的 HashEntry\u0026lt;K,V\u0026gt; 初始化长度 int cap = proto.table.length; // 获取0号 segment 里的 hash 表里的扩容负载因子,所有的 segment 的 loadFactor 是相同的 float lf = proto.loadFactor; // 计算扩容阀值 int threshold = (int)(cap * lf); // 创建一个 cap 容量的 HashEntry 数组 HashEntry\u0026lt;K,V\u0026gt;[] tab = (HashEntry\u0026lt;K,V\u0026gt;[])new HashEntry[cap]; if ((seg = (Segment\u0026lt;K,V\u0026gt;)UNSAFE.getObjectVolatile(ss, u)) == null) { // recheck // 再次检查 u 位置的 Segment 是否为null,因为这时可能有其他线程进行了操作 Segment\u0026lt;K,V\u0026gt; s = new Segment\u0026lt;K,V\u0026gt;(lf, threshold, tab); // 自旋检查 u 位置的 Segment 是否为null while ((seg = (Segment\u0026lt;K,V\u0026gt;)UNSAFE.getObjectVolatile(ss, u)) == null) { // 使用CAS 赋值,只会成功一次 if (UNSAFE.compareAndSwapObject(ss, u, null, seg = s)) break; } } } return seg; } 上面的源码分析了 ConcurrentHashMap 在 put 一个数据时的处理流程,下面梳理下具体流程。\n计算要 put 的 key 的位置,获取指定位置的 Segment。\n如果指定位置的 Segment 为空,则初始化这个 Segment.\n初始化 Segment 流程:\n检查计算得到的位置的 Segment 是否为 null. 为 null 继续初始化,使用 Segment[0] 的容量和负载因子创建一个 HashEntry 数组。 再次检查计算得到的指定位置的 Segment 是否为 null. 使用创建的 HashEntry 数组初始化这个 Segment. 自旋判断计算得到的指定位置的 Segment 是否为 null,使用 CAS 在这个位置赋值为 Segment. Segment.put 插入 key,value 值。\n上面探究了获取 Segment 段和初始化 Segment 段的操作。最后一行的 Segment 的 put 方法还没有查看,继续分析。\nfinal V put(K key, int hash, V value, boolean onlyIfAbsent) { // 获取 ReentrantLock 独占锁,获取不到,scanAndLockForPut 获取。 HashEntry\u0026lt;K,V\u0026gt; node = tryLock() ? null : scanAndLockForPut(key, hash, value); V oldValue; try { HashEntry\u0026lt;K,V\u0026gt;[] tab = table; // 计算要put的数据位置 int index = (tab.length - 1) \u0026amp; hash; // CAS 获取 index 坐标的值 HashEntry\u0026lt;K,V\u0026gt; first = entryAt(tab, index); for (HashEntry\u0026lt;K,V\u0026gt; e = first;;) { if (e != null) { // 检查是否 key 已经存在,如果存在,则遍历链表寻找位置,找到后替换 value K k; if ((k = e.key) == key || (e.hash == hash \u0026amp;\u0026amp; key.equals(k))) { oldValue = e.value; if (!onlyIfAbsent) { e.value = value; ++modCount; } break; } e = e.next; } else { // first 有值没说明 index 位置已经有值了,有冲突,链表头插法。 if (node != null) node.setNext(first); else node = new HashEntry\u0026lt;K,V\u0026gt;(hash, key, value, first); int c = count + 1; // 容量大于扩容阀值,小于最大容量,进行扩容 if (c \u0026gt; threshold \u0026amp;\u0026amp; tab.length \u0026lt; MAXIMUM_CAPACITY) rehash(node); else // index 位置赋值 node,node 可能是一个元素,也可能是一个链表的表头 setEntryAt(tab, index, node); ++modCount; count = c; oldValue = null; break; } } } finally { unlock(); } return oldValue; } 由于 Segment 继承了 ReentrantLock,所以 Segment 内部可以很方便的获取锁,put 流程就用到了这个功能。\ntryLock() 获取锁,获取不到使用 scanAndLockForPut 方法继续获取。\n计算 put 的数据要放入的 index 位置,然后获取这个位置上的 HashEntry 。\n遍历 put 新元素,为什么要遍历?因为这里获取的 HashEntry 可能是一个空元素,也可能是链表已存在,所以要区别对待。\n如果这个位置上的 HashEntry 不存在:\n如果当前容量大于扩容阀值,小于最大容量,进行扩容。 直接头插法插入。 如果这个位置上的 HashEntry 存在:\n判断链表当前元素 key 和 hash 值是否和要 put 的 key 和 hash 值一致。一致则替换值 不一致,获取链表下一个节点,直到发现相同进行值替换,或者链表表里完毕没有相同的。 如果当前容量大于扩容阀值,小于最大容量,进行扩容。 直接链表头插法插入。 如果要插入的位置之前已经存在,替换后返回旧值,否则返回 null.\n这里面的第一步中的 scanAndLockForPut 操作这里没有介绍,这个方法做的操作就是不断的自旋 tryLock() 获取锁。当自旋次数大于指定次数时,使用 lock() 阻塞获取锁。在自旋时顺表获取下 hash 位置的 HashEntry。\nprivate HashEntry\u0026lt;K,V\u0026gt; scanAndLockForPut(K key, int hash, V value) { HashEntry\u0026lt;K,V\u0026gt; first = entryForHash(this, hash); HashEntry\u0026lt;K,V\u0026gt; e = first; HashEntry\u0026lt;K,V\u0026gt; node = null; int retries = -1; // negative while locating node // 自旋获取锁 while (!tryLock()) { HashEntry\u0026lt;K,V\u0026gt; f; // to recheck first below if (retries \u0026lt; 0) { if (e == null) { if (node == null) // speculatively create node node = new HashEntry\u0026lt;K,V\u0026gt;(hash, key, value, null); retries = 0; } else if (key.equals(e.key)) retries = 0; else e = e.next; } else if (++retries \u0026gt; MAX_SCAN_RETRIES) { // 自旋达到指定次数后,阻塞等到只到获取到锁 lock(); break; } else if ((retries \u0026amp; 1) == 0 \u0026amp;\u0026amp; (f = entryForHash(this, hash)) != first) { e = first = f; // re-traverse if entry changed retries = -1; } } return node; } 4. 扩容 rehash # ConcurrentHashMap 的扩容只会扩容到原来的两倍。老数组里的数据移动到新的数组时,位置要么不变,要么变为 index+ oldSize,参数里的 node 会在扩容之后使用链表头插法插入到指定位置。\nprivate void rehash(HashEntry\u0026lt;K,V\u0026gt; node) { HashEntry\u0026lt;K,V\u0026gt;[] oldTable = table; // 老容量 int oldCapacity = oldTable.length; // 新容量,扩大两倍 int newCapacity = oldCapacity \u0026lt;\u0026lt; 1; // 新的扩容阀值 threshold = (int)(newCapacity * loadFactor); // 创建新的数组 HashEntry\u0026lt;K,V\u0026gt;[] newTable = (HashEntry\u0026lt;K,V\u0026gt;[]) new HashEntry[newCapacity]; // 新的掩码,默认2扩容后是4,-1是3,二进制就是11。 int sizeMask = newCapacity - 1; for (int i = 0; i \u0026lt; oldCapacity ; i++) { // 遍历老数组 HashEntry\u0026lt;K,V\u0026gt; e = oldTable[i]; if (e != null) { HashEntry\u0026lt;K,V\u0026gt; next = e.next; // 计算新的位置,新的位置只可能是不变或者是老的位置+老的容量。 int idx = e.hash \u0026amp; sizeMask; if (next == null) // Single node on list // 如果当前位置还不是链表,只是一个元素,直接赋值 newTable[idx] = e; else { // Reuse consecutive sequence at same slot // 如果是链表了 HashEntry\u0026lt;K,V\u0026gt; lastRun = e; int lastIdx = idx; // 新的位置只可能是不变或者是老的位置+老的容量。 // 遍历结束后,lastRun 后面的元素位置都是相同的 for (HashEntry\u0026lt;K,V\u0026gt; last = next; last != null; last = last.next) { int k = last.hash \u0026amp; sizeMask; if (k != lastIdx) { lastIdx = k; lastRun = last; } } // ,lastRun 后面的元素位置都是相同的,直接作为链表赋值到新位置。 newTable[lastIdx] = lastRun; // Clone remaining nodes for (HashEntry\u0026lt;K,V\u0026gt; p = e; p != lastRun; p = p.next) { // 遍历剩余元素,头插法到指定 k 位置。 V v = p.value; int h = p.hash; int k = h \u0026amp; sizeMask; HashEntry\u0026lt;K,V\u0026gt; n = newTable[k]; newTable[k] = new HashEntry\u0026lt;K,V\u0026gt;(h, p.key, v, n); } } } } // 头插法插入新的节点 int nodeIndex = node.hash \u0026amp; sizeMask; // add the new node node.setNext(newTable[nodeIndex]); newTable[nodeIndex] = node; table = newTable; } 有些同学可能会对最后的两个 for 循环有疑惑,这里第一个 for 是为了寻找这样一个节点,这个节点后面的所有 next 节点的新位置都是相同的。然后把这个作为一个链表赋值到新位置。第二个 for 循环是为了把剩余的元素通过头插法插入到指定位置链表。这样实现的原因可能是基于概率统计,有深入研究的同学可以发表下意见。\n内部第二个 for 循环中使用了 new HashEntry\u0026lt;K,V\u0026gt;(h, p.key, v, n) 创建了一个新的 HashEntry,而不是复用之前的,是因为如果复用之前的,那么会导致正在遍历(如正在执行 get 方法)的线程由于指针的修改无法遍历下去。正如注释中所说的:\n当它们不再被可能正在并发遍历表的任何读取线程引用时,被替换的节点将被垃圾回收。\nThe nodes they replace will be garbage collectable as soon as they are no longer referenced by any reader thread that may be in the midst of concurrently traversing table\n为什么需要再使用一个 for 循环找到 lastRun ,其实是为了减少对象创建的次数,正如注解中所说的:\n从统计上看,在默认的阈值下,当表容量加倍时,只有大约六分之一的节点需要被克隆。\nStatistically, at the default threshold, only about one-sixth of them need cloning when a table doubles.\n5. get # 到这里就很简单了,get 方法只需要两步即可。\n计算得到 key 的存放位置。 遍历指定位置查找相同 key 的 value 值。 public V get(Object key) { Segment\u0026lt;K,V\u0026gt; s; // manually integrate access methods to reduce overhead HashEntry\u0026lt;K,V\u0026gt;[] tab; int h = hash(key); long u = (((h \u0026gt;\u0026gt;\u0026gt; segmentShift) \u0026amp; segmentMask) \u0026lt;\u0026lt; SSHIFT) + SBASE; // 计算得到 key 的存放位置 if ((s = (Segment\u0026lt;K,V\u0026gt;)UNSAFE.getObjectVolatile(segments, u)) != null \u0026amp;\u0026amp; (tab = s.table) != null) { for (HashEntry\u0026lt;K,V\u0026gt; e = (HashEntry\u0026lt;K,V\u0026gt;) UNSAFE.getObjectVolatile (tab, ((long)(((tab.length - 1) \u0026amp; h)) \u0026lt;\u0026lt; TSHIFT) + TBASE); e != null; e = e.next) { // 如果是链表,遍历查找到相同 key 的 value。 K k; if ((k = e.key) == key || (e.hash == h \u0026amp;\u0026amp; key.equals(k))) return e.value; } } return null; } 2. ConcurrentHashMap 1.8 # 1. 存储结构 # 可以发现 Java8 的 ConcurrentHashMap 相对于 Java7 来说变化比较大,不再是之前的 Segment 数组 + HashEntry 数组 + 链表,而是 Node 数组 + 链表 / 红黑树。当冲突链表达到一定长度时,链表会转换成红黑树。\n2. 初始化 initTable # /** * Initializes table, using the size recorded in sizeCtl. */ private final Node\u0026lt;K,V\u0026gt;[] initTable() { Node\u0026lt;K,V\u0026gt;[] tab; int sc; while ((tab = table) == null || tab.length == 0) { // 如果 sizeCtl \u0026lt; 0 ,说明另外的线程执行CAS 成功,正在进行初始化。 if ((sc = sizeCtl) \u0026lt; 0) // 让出 CPU 使用权 Thread.yield(); // lost initialization race; just spin else if (U.compareAndSwapInt(this, SIZECTL, sc, -1)) { try { if ((tab = table) == null || tab.length == 0) { int n = (sc \u0026gt; 0) ? sc : DEFAULT_CAPACITY; @SuppressWarnings(\u0026#34;unchecked\u0026#34;) Node\u0026lt;K,V\u0026gt;[] nt = (Node\u0026lt;K,V\u0026gt;[])new Node\u0026lt;?,?\u0026gt;[n]; table = tab = nt; sc = n - (n \u0026gt;\u0026gt;\u0026gt; 2); } } finally { sizeCtl = sc; } break; } } return tab; } 从源码中可以发现 ConcurrentHashMap 的初始化是通过自旋和 CAS 操作完成的。里面需要注意的是变量 sizeCtl (sizeControl 的缩写),它的值决定着当前的初始化状态。\n-1 说明正在初始化,其他线程需要自旋等待 -N 说明 table 正在进行扩容,高 16 位表示扩容的标识戳,低 16 位减 1 为正在进行扩容的线程数 0 表示 table 初始化大小,如果 table 没有初始化 \u0026gt;0 表示 table 扩容的阈值,如果 table 已经初始化。 3. put # 直接过一遍 put 源码。\npublic V put(K key, V value) { return putVal(key, value, false); } /** Implementation for put and putIfAbsent */ final V putVal(K key, V value, boolean onlyIfAbsent) { // key 和 value 不能为空 if (key == null || value == null) throw new NullPointerException(); int hash = spread(key.hashCode()); int binCount = 0; for (Node\u0026lt;K,V\u0026gt;[] tab = table;;) { // f = 目标位置元素 Node\u0026lt;K,V\u0026gt; f; int n, i, fh;// fh 后面存放目标位置的元素 hash 值 if (tab == null || (n = tab.length) == 0) // 数组桶为空,初始化数组桶(自旋+CAS) tab = initTable(); else if ((f = tabAt(tab, i = (n - 1) \u0026amp; hash)) == null) { // 桶内为空,CAS 放入,不加锁,成功了就直接 break 跳出 if (casTabAt(tab, i, null,new Node\u0026lt;K,V\u0026gt;(hash, key, value, null))) break; // no lock when adding to empty bin } else if ((fh = f.hash) == MOVED) tab = helpTransfer(tab, f); else { V oldVal = null; // 使用 synchronized 加锁加入节点 synchronized (f) { if (tabAt(tab, i) == f) { // 说明是链表 if (fh \u0026gt;= 0) { binCount = 1; // 循环加入新的或者覆盖节点 for (Node\u0026lt;K,V\u0026gt; e = f;; ++binCount) { K ek; if (e.hash == hash \u0026amp;\u0026amp; ((ek = e.key) == key || (ek != null \u0026amp;\u0026amp; key.equals(ek)))) { oldVal = e.val; if (!onlyIfAbsent) e.val = value; break; } Node\u0026lt;K,V\u0026gt; pred = e; if ((e = e.next) == null) { pred.next = new Node\u0026lt;K,V\u0026gt;(hash, key, value, null); break; } } } else if (f instanceof TreeBin) { // 红黑树 Node\u0026lt;K,V\u0026gt; p; binCount = 2; if ((p = ((TreeBin\u0026lt;K,V\u0026gt;)f).putTreeVal(hash, key, value)) != null) { oldVal = p.val; if (!onlyIfAbsent) p.val = value; } } } } if (binCount != 0) { if (binCount \u0026gt;= TREEIFY_THRESHOLD) treeifyBin(tab, i); if (oldVal != null) return oldVal; break; } } } addCount(1L, binCount); return null; } 根据 key 计算出 hashcode 。\n判断是否需要进行初始化。\n即为当前 key 定位出的 Node,如果为空表示当前位置可以写入数据,利用 CAS 尝试写入,失败则自旋保证成功。\n如果当前位置的 hashcode == MOVED == -1,则需要进行扩容。\n如果都不满足,则利用 synchronized 锁写入数据。\n如果数量大于 TREEIFY_THRESHOLD 则要执行树化方法,在 treeifyBin 中会首先判断当前数组长度 ≥64 时才会将链表转换为红黑树。\n4. get # get 流程比较简单,直接过一遍源码。\npublic V get(Object key) { Node\u0026lt;K,V\u0026gt;[] tab; Node\u0026lt;K,V\u0026gt; e, p; int n, eh; K ek; // key 所在的 hash 位置 int h = spread(key.hashCode()); if ((tab = table) != null \u0026amp;\u0026amp; (n = tab.length) \u0026gt; 0 \u0026amp;\u0026amp; (e = tabAt(tab, (n - 1) \u0026amp; h)) != null) { // 如果指定位置元素存在,头结点hash值相同 if ((eh = e.hash) == h) { if ((ek = e.key) == key || (ek != null \u0026amp;\u0026amp; key.equals(ek))) // key hash 值相等,key值相同,直接返回元素 value return e.val; } else if (eh \u0026lt; 0) // 头结点hash值小于0,说明正在扩容或者是红黑树,find查找 return (p = e.find(h, key)) != null ? p.val : null; while ((e = e.next) != null) { // 是链表,遍历查找 if (e.hash == h \u0026amp;\u0026amp; ((ek = e.key) == key || (ek != null \u0026amp;\u0026amp; key.equals(ek)))) return e.val; } } return null; } 总结一下 get 过程:\n根据 hash 值计算位置。 查找到指定位置,如果头节点就是要找的,直接返回它的 value. 如果头节点 hash 值小于 0 ,说明正在扩容或者是红黑树,查找之。 如果是链表,遍历查找之。 总结:\n总的来说 ConcurrentHashMap 在 Java8 中相对于 Java7 来说变化还是挺大的,\n3. 总结 # Java7 中 ConcurrentHashMap 使用的分段锁,也就是每一个 Segment 上同时只有一个线程可以操作,每一个 Segment 都是一个类似 HashMap 数组的结构,它可以扩容,它的冲突会转化为链表。但是 Segment 的个数一但初始化就不能改变。\nJava8 中的 ConcurrentHashMap 使用的 Synchronized 锁加 CAS 的机制。结构也由 Java7 中的 Segment 数组 + HashEntry 数组 + 链表 进化成了 Node 数组 + 链表 / 红黑树,Node 是类似于一个 HashEntry 的结构。它的冲突再达到一定大小时会转化成红黑树,在冲突小于一定数量时又退回链表。\n有些同学可能对 Synchronized 的性能存在疑问,其实 Synchronized 锁自从引入锁升级策略后,性能不再是问题,有兴趣的同学可以自己了解下 Synchronized 的锁升级。\n"},{"id":467,"href":"/zh/docs/technology/Interview/java/collection/copyonwritearraylist-source-code/","title":"CopyOnWriteArrayList 源码分析","section":"Collection","content":" CopyOnWriteArrayList 简介 # 在 JDK1.5 之前,如果想要使用并发安全的 List 只能选择 Vector。而 Vector 是一种老旧的集合,已经被淘汰。Vector 对于增删改查等方法基本都加了 synchronized,这种方式虽然能够保证同步,但这相当于对整个 Vector 加上了一把大锁,使得每个方法执行的时候都要去获得锁,导致性能非常低下。\nJDK1.5 引入了 Java.util.concurrent(JUC)包,其中提供了很多线程安全且并发性能良好的容器,其中唯一的线程安全 List 实现就是 CopyOnWriteArrayList 。关于java.util.concurrent 包下常见并发容器的总结,可以看我写的这篇文章: Java 常见并发容器总结 。\nCopyOnWriteArrayList 到底有什么厉害之处? # 对于大部分业务场景来说,读取操作往往是远大于写入操作的。由于读取操作不会对原有数据进行修改,因此,对于每次读取都进行加锁其实是一种资源浪费。相比之下,我们应该允许多个线程同时访问 List 的内部数据,毕竟对于读取操作来说是安全的。\n这种思路与 ReentrantReadWriteLock 读写锁的设计思想非常类似,即读读不互斥、读写互斥、写写互斥(只有读读不互斥)。CopyOnWriteArrayList 更进一步地实现了这一思想。为了将读操作性能发挥到极致,CopyOnWriteArrayList 中的读取操作是完全无需加锁的。更加厉害的是,写入操作也不会阻塞读取操作,只有写写才会互斥。这样一来,读操作的性能就可以大幅度提升。\nCopyOnWriteArrayList 线程安全的核心在于其采用了 写时复制(Copy-On-Write) 的策略,从 CopyOnWriteArrayList 的名字就能看出了。\nCopy-On-Write 的思想是什么? # CopyOnWriteArrayList名字中的“Copy-On-Write”即写时复制,简称 COW。\n下面是维基百科对 Copy-On-Write 的介绍,介绍的挺不错:\n写入时复制(英语:Copy-on-write,简称 COW)是一种计算机程序设计领域的优化策略。其核心思想是,如果有多个调用者(callers)同时请求相同资源(如内存或磁盘上的数据存储),他们会共同获取相同的指针指向相同的资源,直到某个调用者试图修改资源的内容时,系统才会真正复制一份专用副本(private copy)给该调用者,而其他调用者所见到的最初的资源仍然保持不变。这过程对其他的调用者都是透明的。此作法主要的优点是如果调用者没有修改该资源,就不会有副本(private copy)被创建,因此多个调用者只是读取操作时可以共享同一份资源。\n这里再以 CopyOnWriteArrayList为例介绍:当需要修改( add,set、remove 等操作) CopyOnWriteArrayList 的内容时,不会直接修改原数组,而是会先创建底层数组的副本,对副本数组进行修改,修改完之后再将修改后的数组赋值回去,这样就可以保证写操作不会影响读操作了。\n可以看出,写时复制机制非常适合读多写少的并发场景,能够极大地提高系统的并发性能。\n不过,写时复制机制并不是银弹,其依然存在一些缺点,下面列举几点:\n内存占用:每次写操作都需要复制一份原始数据,会占用额外的内存空间,在数据量比较大的情况下,可能会导致内存资源不足。 写操作开销:每一次写操作都需要复制一份原始数据,然后再进行修改和替换,所以写操作的开销相对较大,在写入比较频繁的场景下,性能可能会受到影响。 数据一致性问题:修改操作不会立即反映到最终结果中,还需要等待复制完成,这可能会导致一定的数据一致性问题。 …… CopyOnWriteArrayList 源码分析 # 这里以 JDK1.8 为例,分析一下 CopyOnWriteArrayList 的底层核心源码。\nCopyOnWriteArrayList 的类定义如下:\npublic class CopyOnWriteArrayList\u0026lt;E\u0026gt; extends Object implements List\u0026lt;E\u0026gt;, RandomAccess, Cloneable, Serializable { //... } CopyOnWriteArrayList 实现了以下接口:\nList : 表明它是一个列表,支持添加、删除、查找等操作,并且可以通过下标进行访问。 RandomAccess :这是一个标志接口,表明实现这个接口的 List 集合是支持 快速随机访问 的。 Cloneable :表明它具有拷贝能力,可以进行深拷贝或浅拷贝操作。 Serializable : 表明它可以进行序列化操作,也就是可以将对象转换为字节流进行持久化存储或网络传输,非常方便。 初始化 # CopyOnWriteArrayList 中有一个无参构造函数和两个有参构造函数。\n// 创建一个空的 CopyOnWriteArrayList public CopyOnWriteArrayList() { setArray(new Object[0]); } // 按照集合的迭代器返回的顺序创建一个包含指定集合元素的 CopyOnWriteArrayList public CopyOnWriteArrayList(Collection\u0026lt;? extends E\u0026gt; c) { Object[] elements; if (c.getClass() == CopyOnWriteArrayList.class) elements = ((CopyOnWriteArrayList\u0026lt;?\u0026gt;)c).getArray(); else { elements = c.toArray(); // c.toArray might (incorrectly) not return Object[] (see 6260652) if (elements.getClass() != Object[].class) elements = Arrays.copyOf(elements, elements.length, Object[].class); } setArray(elements); } // 创建一个包含指定数组的副本的列表 public CopyOnWriteArrayList(E[] toCopyIn) { setArray(Arrays.copyOf(toCopyIn, toCopyIn.length, Object[].class)); } 插入元素 # CopyOnWriteArrayList 的 add()方法有三个版本:\nadd(E e):在 CopyOnWriteArrayList 的尾部插入元素。 add(int index, E element):在 CopyOnWriteArrayList 的指定位置插入元素。 addIfAbsent(E e):如果指定元素不存在,那么添加该元素。如果成功添加元素则返回 true。 这里以add(E e)为例进行介绍:\n// 插入元素到 CopyOnWriteArrayList 的尾部 public boolean add(E e) { final ReentrantLock lock = this.lock; // 加锁 lock.lock(); try { // 获取原来的数组 Object[] elements = getArray(); // 原来数组的长度 int len = elements.length; // 创建一个长度+1的新数组,并将原来数组的元素复制给新数组 Object[] newElements = Arrays.copyOf(elements, len + 1); // 元素放在新数组末尾 newElements[len] = e; // array指向新数组 setArray(newElements); return true; } finally { // 解锁 lock.unlock(); } } 从上面的源码可以看出:\nadd方法内部用到了 ReentrantLock 加锁,保证了同步,避免了多线程写的时候会复制出多个副本出来。锁被修饰保证了锁的内存地址肯定不会被修改,并且,释放锁的逻辑放在 finally 中,可以保证锁能被释放。 CopyOnWriteArrayList 通过复制底层数组的方式实现写操作,即先创建一个新的数组来容纳新添加的元素,然后在新数组中进行写操作,最后将新数组赋值给底层数组的引用,替换掉旧的数组。这也就证明了我们前面说的:CopyOnWriteArrayList 线程安全的核心在于其采用了 写时复制(Copy-On-Write) 的策略。 每次写操作都需要通过 Arrays.copyOf 复制底层数组,时间复杂度是 O(n) 的,且会占用额外的内存空间。因此,CopyOnWriteArrayList 适用于读多写少的场景,在写操作不频繁且内存资源充足的情况下,可以提升系统的性能表现。 CopyOnWriteArrayList 中并没有类似于 ArrayList 的 grow() 方法扩容的操作。 Arrays.copyOf 方法的时间复杂度是 O(n),其中 n 表示需要复制的数组长度。因为这个方法的实现原理是先创建一个新的数组,然后将源数组中的数据复制到新数组中,最后返回新数组。这个方法会复制整个数组,因此其时间复杂度与数组长度成正比,即 O(n)。值得注意的是,由于底层调用了系统级别的拷贝指令,因此在实际应用中这个方法的性能表现比较优秀,但是也需要注意控制复制的数据量,避免出现内存占用过高的情况。\n读取元素 # CopyOnWriteArrayList 的读取操作是基于内部数组 array 并没有发生实际的修改,因此在读取操作时不需要进行同步控制和锁操作,可以保证数据的安全性。这种机制下,多个线程可以同时读取列表中的元素。\n// 底层数组,只能通过getArray和setArray方法访问 private transient volatile Object[] array; public E get(int index) { return get(getArray(), index); } final Object[] getArray() { return array; } private E get(Object[] a, int index) { return (E) a[index]; } 不过,get方法是弱一致性的,在某些情况下可能读到旧的元素值。\nget(int index)方法是分两步进行的:\n通过getArray()获取当前数组的引用; 直接从数组中获取下标为 index 的元素。 这个过程并没有加锁,所以在并发环境下可能出现如下情况:\n线程 1 调用get(int index)方法获取值,内部通过getArray()方法获取到了 array 属性值; 线程 2 调用CopyOnWriteArrayList的add、set、remove 等修改方法时,内部通过setArray方法修改了array属性的值; 线程 1 还是从旧的 array 数组中取值。 获取列表中元素的个数 # public int size() { return getArray().length; } CopyOnWriteArrayList中的array数组每次复制都刚好能够容纳下所有元素,并不像ArrayList那样会预留一定的空间。因此,CopyOnWriteArrayList中并没有size属性CopyOnWriteArrayList的底层数组的长度就是元素个数,因此size()方法只要返回数组长度就可以了。\n删除元素 # CopyOnWriteArrayList删除元素相关的方法一共有 4 个:\nremove(int index):移除此列表中指定位置上的元素。将任何后续元素向左移动(从它们的索引中减去 1)。 boolean remove(Object o):删除此列表中首次出现的指定元素,如果不存在该元素则返回 false。 boolean removeAll(Collection\u0026lt;?\u0026gt; c):从此列表中删除指定集合中包含的所有元素。 void clear():移除此列表中的所有元素。 这里以remove(int index)为例进行介绍:\npublic E remove(int index) { // 获取可重入锁 final ReentrantLock lock = this.lock; // 加锁 lock.lock(); try { //获取当前array数组 Object[] elements = getArray(); // 获取当前array长度 int len = elements.length; //获取指定索引的元素(旧值) E oldValue = get(elements, index); int numMoved = len - index - 1; // 判断删除的是否是最后一个元素 if (numMoved == 0) // 如果删除的是最后一个元素,直接复制该元素前的所有元素到新的数组 setArray(Arrays.copyOf(elements, len - 1)); else { // 分段复制,将index前的元素和index+1后的元素复制到新数组 // 新数组长度为旧数组长度-1 Object[] newElements = new Object[len - 1]; System.arraycopy(elements, 0, newElements, 0, index); System.arraycopy(elements, index + 1, newElements, index, numMoved); //将新数组赋值给array引用 setArray(newElements); } return oldValue; } finally { // 解锁 lock.unlock(); } } 判断元素是否存在 # CopyOnWriteArrayList提供了两个用于判断指定元素是否在列表中的方法:\ncontains(Object o):判断是否包含指定元素。 containsAll(Collection\u0026lt;?\u0026gt; c):判断是否保证指定集合的全部元素。 // 判断是否包含指定元素 public boolean contains(Object o) { //获取当前array数组 Object[] elements = getArray(); //调用index尝试查找指定元素,如果返回值大于等于0,则返回true,否则返回false return indexOf(o, elements, 0, elements.length) \u0026gt;= 0; } // 判断是否保证指定集合的全部元素 public boolean containsAll(Collection\u0026lt;?\u0026gt; c) { //获取当前array数组 Object[] elements = getArray(); //获取数组长度 int len = elements.length; //遍历指定集合 for (Object e : c) { //循环调用indexOf方法判断,只要有一个没有包含就直接返回false if (indexOf(e, elements, 0, len) \u0026lt; 0) return false; } //最后表示全部包含或者制定集合为空集合,那么返回true return true; } CopyOnWriteArrayList 常用方法测试 # 代码:\n// 创建一个 CopyOnWriteArrayList 对象 CopyOnWriteArrayList\u0026lt;String\u0026gt; list = new CopyOnWriteArrayList\u0026lt;\u0026gt;(); // 向列表中添加元素 list.add(\u0026#34;Java\u0026#34;); list.add(\u0026#34;Python\u0026#34;); list.add(\u0026#34;C++\u0026#34;); System.out.println(\u0026#34;初始列表:\u0026#34; + list); // 使用 get 方法获取指定位置的元素 System.out.println(\u0026#34;列表第二个元素为:\u0026#34; + list.get(1)); // 使用 remove 方法删除指定元素 boolean result = list.remove(\u0026#34;C++\u0026#34;); System.out.println(\u0026#34;删除结果:\u0026#34; + result); System.out.println(\u0026#34;列表删除元素后为:\u0026#34; + list); // 使用 set 方法更新指定位置的元素 list.set(1, \u0026#34;Golang\u0026#34;); System.out.println(\u0026#34;列表更新后为:\u0026#34; + list); // 使用 add 方法在指定位置插入元素 list.add(0, \u0026#34;PHP\u0026#34;); System.out.println(\u0026#34;列表插入元素后为:\u0026#34; + list); // 使用 size 方法获取列表大小 System.out.println(\u0026#34;列表大小为:\u0026#34; + list.size()); // 使用 removeAll 方法删除指定集合中所有出现的元素 result = list.removeAll(List.of(\u0026#34;Java\u0026#34;, \u0026#34;Golang\u0026#34;)); System.out.println(\u0026#34;批量删除结果:\u0026#34; + result); System.out.println(\u0026#34;列表批量删除元素后为:\u0026#34; + list); // 使用 clear 方法清空列表中所有元素 list.clear(); System.out.println(\u0026#34;列表清空后为:\u0026#34; + list); 输出:\n列表更新后为:[Java, Golang] 列表插入元素后为:[PHP, Java, Golang] 列表大小为:3 批量删除结果:true 列表批量删除元素后为:[PHP] 列表清空后为:[] "},{"id":468,"href":"/zh/docs/technology/Interview/java/collection/delayqueue-source-code/","title":"DelayQueue 源码分析","section":"Collection","content":" DelayQueue 简介 # DelayQueue 是 JUC 包(java.util.concurrent)为我们提供的延迟队列,用于实现延时任务比如订单下单 15 分钟未支付直接取消。它是 BlockingQueue 的一种,底层是一个基于 PriorityQueue 实现的一个无界队列,是线程安全的。关于PriorityQueue可以参考笔者编写的这篇文章: PriorityQueue 源码分析 。\nDelayQueue 中存放的元素必须实现 Delayed 接口,并且需要重写 getDelay()方法(计算是否到期)。\npublic interface Delayed extends Comparable\u0026lt;Delayed\u0026gt; { long getDelay(TimeUnit unit); } 默认情况下, DelayQueue 会按照到期时间升序编排任务。只有当元素过期时(getDelay()方法返回值小于等于 0),才能从队列中取出。\nDelayQueue 发展史 # DelayQueue 最早是在 Java 5 中引入的,作为 java.util.concurrent 包中的一部分,用于支持基于时间的任务调度和缓存过期删除等场景,该版本仅仅支持延迟功能的实现,还未解决线程安全问题。 在 Java 6 中,DelayQueue 的实现进行了优化,通过使用 ReentrantLock 和 Condition 解决线程安全及线程间交互的效率,提高了其性能和可靠性。 在 Java 7 中,DelayQueue 的实现进行了进一步的优化,通过使用 CAS 操作实现元素的添加和移除操作,提高了其并发操作性能。 在 Java 8 中,DelayQueue 的实现没有进行重大变化,但是在 java.time 包中引入了新的时间类,如 Duration 和 Instant,使得使用 DelayQueue 进行基于时间的调度更加方便和灵活。 在 Java 9 中,DelayQueue 的实现进行了一些微小的改进,主要是对代码进行了一些优化和精简。 总的来说,DelayQueue 的发展史主要是通过优化其实现方式和提高其性能和可靠性,使其更加适用于基于时间的调度和缓存过期删除等场景。\nDelayQueue 常见使用场景示例 # 我们这里希望任务可以按照我们预期的时间执行,例如提交 3 个任务,分别要求 1s、2s、3s 后执行,即使是乱序添加,1s 后要求 1s 执行的任务会准时执行。\n对此我们可以使用 DelayQueue 来实现,所以我们首先需要继承 Delayed 实现 DelayedTask,实现 getDelay 方法以及优先级比较 compareTo。\n/** * 延迟任务 */ public class DelayedTask implements Delayed { /** * 任务到期时间 */ private long executeTime; /** * 任务 */ private Runnable task; public DelayedTask(long delay, Runnable task) { this.executeTime = System.currentTimeMillis() + delay; this.task = task; } /** * 查看当前任务还有多久到期 * @param unit * @return */ @Override public long getDelay(TimeUnit unit) { return unit.convert(executeTime - System.currentTimeMillis(), TimeUnit.MILLISECONDS); } /** * 延迟队列需要到期时间升序入队,所以我们需要实现compareTo进行到期时间比较 * @param o * @return */ @Override public int compareTo(Delayed o) { return Long.compare(this.executeTime, ((DelayedTask) o).executeTime); } public void execute() { task.run(); } } 完成任务的封装之后,使用就很简单了,设置好多久到期然后将任务提交到延迟队列中即可。\n// 创建延迟队列,并添加任务 DelayQueue \u0026lt; DelayedTask \u0026gt; delayQueue = new DelayQueue \u0026lt; \u0026gt; (); //分别添加1s、2s、3s到期的任务 delayQueue.add(new DelayedTask(2000, () -\u0026gt; System.out.println(\u0026#34;Task 2\u0026#34;))); delayQueue.add(new DelayedTask(1000, () -\u0026gt; System.out.println(\u0026#34;Task 1\u0026#34;))); delayQueue.add(new DelayedTask(3000, () -\u0026gt; System.out.println(\u0026#34;Task 3\u0026#34;))); // 取出任务并执行 while (!delayQueue.isEmpty()) { //阻塞获取最先到期的任务 DelayedTask task = delayQueue.take(); if (task != null) { task.execute(); } } 从输出结果可以看出,即使笔者先提到 2s 到期的任务,1s 到期的任务 Task1 还是优先执行的。\nTask 1 Task 2 Task 3 DelayQueue 源码解析 # 这里以 JDK1.8 为例,分析一下 DelayQueue 的底层核心源码。\nDelayQueue 的类定义如下:\npublic class DelayQueue\u0026lt;E extends Delayed\u0026gt; extends AbstractQueue\u0026lt;E\u0026gt; implements BlockingQueue\u0026lt;E\u0026gt; { //... } DelayQueue 继承了 AbstractQueue 类,实现了 BlockingQueue 接口。\n核心成员变量 # DelayQueue 的 4 个核心成员变量如下:\n//可重入锁,实现线程安全的关键 private final transient ReentrantLock lock = new ReentrantLock(); //延迟队列底层存储数据的集合,确保元素按照到期时间升序排列 private final PriorityQueue\u0026lt;E\u0026gt; q = new PriorityQueue\u0026lt;E\u0026gt;(); //指向准备执行优先级最高的线程 private Thread leader = null; //实现多线程之间等待唤醒的交互 private final Condition available = lock.newCondition(); lock : 我们都知道 DelayQueue 存取是线程安全的,所以为了保证存取元素时线程安全,我们就需要在存取时上锁,而 DelayQueue 就是基于 ReentrantLock 独占锁确保存取操作的线程安全。 q : 延迟队列要求元素按照到期时间进行升序排列,所以元素添加时势必需要进行优先级排序,所以 DelayQueue 底层元素的存取都是通过这个优先队列 PriorityQueue 的成员变量 q 来管理的。 leader : 延迟队列的任务只有到期之后才会执行,对于没有到期的任务只有等待,为了确保优先级最高的任务到期后可以即刻被执行,设计者就用 leader 来管理延迟任务,只有 leader 所指向的线程才具备定时等待任务到期执行的权限,而其他那些优先级低的任务只能无限期等待,直到 leader 线程执行完手头的延迟任务后唤醒它。 available : 上文讲述 leader 线程时提到的等待唤醒操作的交互就是通过 available 实现的,假如线程 1 尝试在空的 DelayQueue 获取任务时,available 就会将其放入等待队列中。直到有一个线程添加一个延迟任务后通过 available 的 signal 方法将其唤醒。 构造方法 # 相较于其他的并发容器,延迟队列的构造方法比较简单,它只有两个构造方法,因为所有成员变量在类加载时都已经初始完成了,所以默认构造方法什么也没做。还有一个传入 Collection 对象的构造方法,它会将调用 addAll()方法将集合元素存到优先队列 q 中。\npublic DelayQueue() {} public DelayQueue(Collection\u0026lt;? extends E\u0026gt; c) { this.addAll(c); } 添加元素 # DelayQueue 添加元素的方法无论是 add、put 还是 offer,本质上就是调用一下 offer ,所以了解延迟队列的添加逻辑我们只需阅读 offer 方法即可。\noffer 方法的整体逻辑为:\n尝试获取 lock 。 如果上锁成功,则调 q 的 offer 方法将元素存放到优先队列中。 调用 peek 方法看看当前队首元素是否就是本次入队的元素,如果是则说明当前这个元素是即将到期的任务(即优先级最高的元素),于是将 leader 设置为空,通知因为队列为空时调用 take 等方法导致阻塞的线程来争抢元素。 上述步骤执行完成,释放 lock。 返回 true。 源码如下,笔者已详细注释,读者可自行参阅:\npublic boolean offer(E e) { //尝试获取lock final ReentrantLock lock = this.lock; lock.lock(); try { //如果上锁成功,则调q的offer方法将元素存放到优先队列中 q.offer(e); //调用peek方法看看当前队首元素是否就是本次入队的元素,如果是则说明当前这个元素是即将到期的任务(即优先级最高的元素) if (q.peek() == e) { //将leader设置为空,通知调用取元素方法而阻塞的线程来争抢这个任务 leader = null; available.signal(); } return true; } finally { //上述步骤执行完成,释放lock lock.unlock(); } } 获取元素 # DelayQueue 中获取元素的方式分为阻塞式和非阻塞式,先来看看逻辑比较复杂的阻塞式获取元素方法 take,为了让读者可以更直观的了解阻塞式获取元素的全流程,笔者将以 3 个线程并发获取元素为例讲述 take 的工作流程。\n想要理解下面的内容,需要用到 AQS 相关的知识,推荐阅读下面这两篇文章:\n图文讲解 AQS ,一起看看 AQS 的源码……(图文较长) AQS 都看完了,Condition 原理可不能少! 1、首先, 3 个线程会尝试获取可重入锁 lock,假设我们现在有 3 个线程分别是 t1、t2、t3,随后 t1 得到了锁,而 t2、t3 没有抢到锁,故将这两个线程存入等待队列中。\n2、紧接着 t1 开始进行元素获取的逻辑。\n3、线程 t1 首先会查看 DelayQueue 队列首元素是否为空。\n4、如果元素为空,则说明当前队列没有任何元素,故 t1 就会被阻塞存到 conditionWaiter 这个队列中。\n注意,调用 await 之后 t1 就会释放 lcok 锁,假如 DelayQueue 持续为空,那么 t2、t3 也会像 t1 一样执行相同的逻辑并进入 conditionWaiter 队列中。\n如果元素不为空,则判断当前任务是否到期,如果元素到期,则直接返回出去。如果元素未到期,则判断当前 leader 线程(DelayQueue 中唯一一个可以等待并获取元素的线程引用)是否为空,若不为空,则说明当前 leader 正在等待执行一个优先级比当前元素还高的元素到期,故当前线程 t1 只能调用 await 进入无限期等待,等到 leader 取得元素后唤醒。反之,若 leader 线程为空,则将当前线程设置为 leader 并进入有限期等待,到期后取出元素并返回。\n自此我们阻塞式获取元素的逻辑都已完成后,源码如下,读者可自行参阅:\npublic E take() throws InterruptedException { // 尝试获取可重入锁,将底层AQS的state设置为1,并设置为独占锁 final ReentrantLock lock = this.lock; lock.lockInterruptibly(); try { for (;;) { //查看队列第一个元素 E first = q.peek(); //若为空,则将当前线程放入ConditionObject的等待队列中,并将底层AQS的state设置为0,表示释放锁并进入无限期等待 if (first == null) available.await(); else { //若元素不为空,则查看当前元素多久到期 long delay = first.getDelay(NANOSECONDS); //如果小于0则说明已到期直接返回出去 if (delay \u0026lt;= 0) return q.poll(); //如果大于0则说明任务还没到期,首先需要释放对这个元素的引用 first = null; // don\u0026#39;t retain ref while waiting //判断leader是否为空,如果不为空,则说明正有线程作为leader并等待一个任务到期,则当前线程进入无限期等待 if (leader != null) available.await(); else { //反之将我们的线程成为leader Thread thisThread = Thread.currentThread(); leader = thisThread; try { //并进入有限期等待 available.awaitNanos(delay); } finally { //等待任务到期时,释放leader引用,进入下一次循环将任务return出去 if (leader == thisThread) leader = null; } } } } } finally { // 收尾逻辑:当leader为null,并且队列中有任务时,唤醒等待的获取元素的线程。 if (leader == null \u0026amp;\u0026amp; q.peek() != null) available.signal(); //释放锁 lock.unlock(); } } 我们再来看看非阻塞的获取元素方法 poll ,逻辑比较简单,整体步骤如下:\n尝试获取可重入锁。 查看队列第一个元素,判断元素是否为空。 若元素为空,或者元素未到期,则直接返回空。 若元素不为空且到期了,直接调用 poll 返回出去。 释放可重入锁 lock 。 源码如下,读者可自行参阅源码及注释:\npublic E poll() { //尝试获取可重入锁 final ReentrantLock lock = this.lock; lock.lock(); try { //查看队列第一个元素,判断元素是否为空 E first = q.peek(); //若元素为空,或者元素未到期,则直接返回空 if (first == null || first.getDelay(NANOSECONDS) \u0026gt; 0) return null; else //若元素不为空且到期了,直接调用poll返回出去 return q.poll(); } finally { //释放可重入锁lock lock.unlock(); } } 查看元素 # 上文获取元素时都会调用到 peek 方法,peek 顾名思义仅仅窥探一下队列中的元素,它的步骤就 4 步:\n上锁。 调用优先队列 q 的 peek 方法查看索引 0 位置的元素。 释放锁。 将元素返回出去。 public E peek() { final ReentrantLock lock = this.lock; lock.lock(); try { return q.peek(); } finally { lock.unlock(); } } DelayQueue 常见面试题 # DelayQueue 的实现原理是什么? # DelayQueue 底层是使用优先队列 PriorityQueue 来存储元素,而 PriorityQueue 采用二叉小顶堆的思想确保值小的元素排在最前面,这就使得 DelayQueue 对于延迟任务优先级的管理就变得十分方便了。同时 DelayQueue 为了保证线程安全还用到了可重入锁 ReentrantLock,确保单位时间内只有一个线程可以操作延迟队列。最后,为了实现多线程之间等待和唤醒的交互效率,DelayQueue 还用到了 Condition,通过 Condition 的 await 和 signal 方法完成多线程之间的等待唤醒。\nDelayQueue 的实现是否线程安全? # DelayQueue 的实现是线程安全的,它通过 ReentrantLock 实现了互斥访问和 Condition 实现了线程间的等待和唤醒操作,可以保证多线程环境下的安全性和可靠性。\nDelayQueue 的使用场景有哪些? # DelayQueue 通常用于实现定时任务调度和缓存过期删除等场景。在定时任务调度中,需要将需要执行的任务封装成延迟任务对象,并将其添加到 DelayQueue 中,DelayQueue 会自动按照剩余延迟时间进行升序排序(默认情况),以保证任务能够按照时间先后顺序执行。对于缓存过期这个场景而言,在数据被缓存到内存之后,我们可以将缓存的 key 封装成一个延迟的删除任务,并将其添加到 DelayQueue 中,当数据过期时,拿到这个任务的 key,将这个 key 从内存中移除。\nDelayQueue 中 Delayed 接口的作用是什么? # Delayed 接口定义了元素的剩余延迟时间(getDelay)和元素之间的比较规则(该接口继承了 Comparable 接口)。若希望元素能够存放到 DelayQueue 中,就必须实现 Delayed 接口的 getDelay() 方法和 compareTo() 方法,否则 DelayQueue 无法得知当前任务剩余时长和任务优先级的比较。\nDelayQueue 和 Timer/TimerTask 的区别是什么? # DelayQueue 和 Timer/TimerTask 都可以用于实现定时任务调度,但是它们的实现方式不同。DelayQueue 是基于优先级队列和堆排序算法实现的,可以实现多个任务按照时间先后顺序执行;而 Timer/TimerTask 是基于单线程实现的,只能按照任务的执行顺序依次执行,如果某个任务执行时间过长,会影响其他任务的执行。另外,DelayQueue 还支持动态添加和移除任务,而 Timer/TimerTask 只能在创建时指定任务。\n参考文献 # 《深入理解高并发编程:JDK 核心技术》: 一口气说出 Java 6 种延时队列的实现方法(面试官也得服): https://www.jb51.net/article/186192.htm 图解 DelayQueue 源码(java 8)——延时队列的小九九: https://blog.csdn.net/every__day/article/details/113810985 "},{"id":469,"href":"/zh/docs/technology/Interview/high-performance/message-queue/disruptor-questions/","title":"Disruptor常见问题总结","section":"High Performance","content":"Disruptor 是一个相对冷门一些的知识点,不过,如果你的项目经历中用到了 Disruptor 的话,那面试中就很可能会被问到。\n一位球友之前投稿的面经(社招)中就涉及一些 Disruptor 的问题,文章传送门: 圆梦!顺利拿到字节、淘宝、拼多多等大厂 offer! 。\n这篇文章可以看作是对 Disruptor 做的一个简单总结,每个问题都不会扯太深入,主要针对面试或者速览 Disruptor。\nDisruptor 是什么? # Disruptor 是一个开源的高性能内存队列,诞生初衷是为了解决内存队列的性能和内存安全问题,由英国外汇交易公司 LMAX 开发。\n根据 Disruptor 官方介绍,基于 Disruptor 开发的系统 LMAX(新的零售金融交易平台),单线程就能支撑每秒 600 万订单。Martin Fowler 在 2011 年写的一篇文章 The LMAX Architecture 中专门介绍过这个 LMAX 系统的架构,感兴趣的可以看看这篇文章。。\nLMAX 公司 2010 年在 QCon 演讲后,Disruptor 获得了业界关注,并获得了 2011 年的 Oracle 官方的 Duke\u0026rsquo;s Choice Awards(Duke 选择大奖)。\n“Duke 选择大奖”旨在表彰过去一年里全球个人或公司开发的、最具影响力的 Java 技术应用,由甲骨文公司主办。含金量非常高!\n我专门找到了 Oracle 官方当年颁布获得 Duke\u0026rsquo;s Choice Awards 项目的那篇文章(文章地址: https://blogs.oracle.com/java/post/and-the-winners-arethe-dukes-choice-award) 。从文中可以看出,同年获得此大奖荣誉的还有大名鼎鼎的 Netty、JRebel 等项目。\nDisruptor 提供的功能优点类似于 Kafka、RocketMQ 这类分布式队列,不过,其作为范围是 JVM(内存)。\nGithub 地址: https://github.com/LMAX-Exchange/disruptor 官方教程: https://lmax-exchange.github.io/disruptor/user-guide/index.html 关于如何在 Spring Boot 项目中使用 Disruptor,可以看这篇文章: Spring Boot + Disruptor 实战入门 。\n为什么要用 Disruptor? # Disruptor 主要解决了 JDK 内置线程安全队列的性能和内存安全问题。\nJDK 中常见的线程安全的队列如下:\n队列名字 锁 是否有界 ArrayBlockingQueue 加锁(ReentrantLock) 有界 LinkedBlockingQueue 加锁(ReentrantLock) 有界 LinkedTransferQueue 无锁(CAS) 无界 ConcurrentLinkedQueue 无锁(CAS) 无界 从上表中可以看出:这些队列要不就是加锁有界,要不就是无锁无界。而加锁的的队列势必会影响性能,无界的队列又存在内存溢出的风险。\n因此,一般情况下,我们都是不建议使用 JDK 内置线程安全队列。\nDisruptor 就不一样了!它在无锁的情况下还能保证队列有界,并且还是线程安全的。\n下面这张图是 Disruptor 官网提供的 Disruptor 和 ArrayBlockingQueue 的延迟直方图对比。\nDisruptor 真的很快,关于它为什么这么快这个问题,会在后文介绍到。\n此外,Disruptor 还提供了丰富的扩展功能比如支持批量操作、支持多种等待策略。\nKafka 和 Disruptor 什么区别? # Kafka:分布式消息队列,一般用在系统或者服务之间的消息传递,还可以被用作流式处理平台。 Disruptor:内存级别的消息队列,一般用在系统内部中线程间的消息传递。 哪些组件用到了 Disruptor? # 用到 Disruptor 的开源项目还是挺多的,这里简单举几个例子:\nLog4j2:Log4j2 是一款常用的日志框架,它基于 Disruptor 来实现异步日志。 SOFATracer:SOFATracer 是蚂蚁金服开源的分布式应用链路追踪工具,它基于 Disruptor 来实现异步日志。 Storm : Storm 是一个开源的分布式实时计算系统,它基于 Disruptor 来实现工作进程内发生的消息传递(同一 Storm 节点上的线程间,无需网络通信)。 HBase:HBase 是一个分布式列存储数据库系统,它基于 Disruptor 来提高写并发性能。 …… Disruptor 核心概念有哪些? # Event:你可以把 Event 理解为存放在队列中等待消费的消息对象。 EventFactory:事件工厂用于生产事件,我们在初始化 Disruptor 类的时候需要用到。 EventHandler:Event 在对应的 Handler 中被处理,你可以将其理解为生产消费者模型中的消费者。 EventProcessor:EventProcessor 持有特定消费者(Consumer)的 Sequence,并提供用于调用事件处理实现的事件循环(Event Loop)。 Disruptor:事件的生产和消费需要用到 Disruptor 对象。 RingBuffer:RingBuffer(环形数组)用于保存事件。 WaitStrategy:等待策略。决定了没有事件可以消费的时候,事件消费者如何等待新事件的到来。 Producer:生产者,只是泛指调用 Disruptor 对象发布事件的用户代码,Disruptor 没有定义特定接口或类型。 ProducerType:指定是单个事件发布者模式还是多个事件发布者模式(发布者和生产者的意思类似,我个人比较喜欢用发布者)。 Sequencer:Sequencer 是 Disruptor 的真正核心。此接口有两个实现类 SingleProducerSequencer、MultiProducerSequencer ,它们定义在生产者和消费者之间快速、正确地传递数据的并发算法。 下面这张图摘自 Disruptor 官网,展示了 LMAX 系统使用 Disruptor 的示例。\nDisruptor 等待策略有哪些? # 等待策略(WaitStrategy) 决定了没有事件可以消费的时候,事件消费者如何等待新事件的到来。\n常见的等待策略有下面这些:\nBlockingWaitStrategy:基于 ReentrantLock+Condition 来实现等待和唤醒操作,实现代码非常简单,是 Disruptor 默认的等待策略。虽然最慢,但也是 CPU 使用率最低和最稳定的选项生产环境推荐使用; BusySpinWaitStrategy:性能很好,存在持续自旋的风险,使用不当会造成 CPU 负载 100%,慎用; LiteBlockingWaitStrategy:基于 BlockingWaitStrategy 的轻量级等待策略,在没有锁竞争的时候会省去唤醒操作,但是作者说测试不充分,因此不建议使用; TimeoutBlockingWaitStrategy:带超时的等待策略,超时后会执行业务指定的处理逻辑; LiteTimeoutBlockingWaitStrategy:基于TimeoutBlockingWaitStrategy的策略,当没有锁竞争的时候会省去唤醒操作; SleepingWaitStrategy:三段式策略,第一阶段自旋,第二阶段执行 Thread.yield 让出 CPU,第三阶段睡眠执行时间,反复的睡眠; YieldingWaitStrategy:二段式策略,第一阶段自旋,第二阶段执行 Thread.yield 交出 CPU; PhasedBackoffWaitStrategy:四段式策略,第一阶段自旋指定次数,第二阶段自旋指定时间,第三阶段执行 Thread.yield 交出 CPU,第四阶段调用成员变量的waitFor方法,该成员变量可以被设置为BlockingWaitStrategy、LiteBlockingWaitStrategy、SleepingWaitStrategy三个中的一个。 Disruptor 为什么这么快? # RingBuffer(环形数组) : Disruptor 内部的 RingBuffer 是通过数组实现的。由于这个数组中的所有元素在初始化时一次性全部创建,因此这些元素的内存地址一般来说是连续的。这样做的好处是,当生产者不断往 RingBuffer 中插入新的事件对象时,这些事件对象的内存地址就能够保持连续,从而利用 CPU 缓存的局部性原理,将相邻的事件对象一起加载到缓存中,提高程序的性能。这类似于 MySQL 的预读机制,将连续的几个页预读到内存里。除此之外,RingBuffer 基于数组还支持批量操作(一次处理多个元素)、还可以避免频繁的内存分配和垃圾回收(RingBuffer 是一个固定大小的数组,当向数组中添加新元素时,如果数组已满,则新元素将覆盖掉最旧的元素)。 避免了伪共享问题:CPU 缓存内部是按照 Cache Line(缓存行)管理的,一般的 Cache Line 大小在 64 字节左右。Disruptor 为了确保目标字段独占一个 Cache Line,会在目标字段前后增加字节填充(前 56 个字节和后 56 个字节),这样可以避免 Cache Line 的伪共享(False Sharing)问题。同时,为了让 RingBuffer 存放数据的数组独占缓存行,数组的设计为 无效填充(128 字节)+ 有效数据。 无锁设计:Disruptor 采用无锁设计,避免了传统锁机制带来的竞争和延迟。Disruptor 的无锁实现起来比较复杂,主要是基于 CAS、内存屏障(Memory Barrier)、RingBuffer 等技术实现的。 综上所述,Disruptor 之所以能够如此快,是基于一系列优化策略的综合作用,既充分利用了现代 CPU 缓存结构的特点,又避免了常见的并发问题和性能瓶颈。\n关于 Disruptor 高性能队列原理的详细介绍,可以查看这篇文章: Disruptor 高性能队列原理浅析 (参考了美团技术团队的 高性能队列——Disruptor这篇文章)。\n🌈 这里额外补充一点:数组中对象元素地址连续为什么可以提高性能?\nCPU 缓存是通过将最近使用的数据存储在高速缓存中来实现更快的读取速度,并使用预取机制提前加载相邻内存的数据以利用局部性原理。\n在计算机系统中,CPU 主要访问高速缓存和内存。高速缓存是一种速度非常快、容量相对较小的内存,通常被分为多级缓存,其中 L1、L2、L3 分别表示一级缓存、二级缓存、三级缓存。越靠近 CPU 的缓存,速度越快,容量也越小。相比之下,内存容量相对较大,但速度较慢。\n为了加速数据的读取过程,CPU 会先将数据从内存中加载到高速缓存中,如果下一次需要访问相同的数据,就可以直接从高速缓存中读取,而不需要再次访问内存。这就是所谓的 缓存命中 。另外,为了利用 局部性原理 ,CPU 还会根据之前访问的内存地址预取相邻的内存数据,因为在程序中,连续的内存地址通常会被频繁访问到,这样做可以提高数据的缓存命中率,进而提高程序的性能。\n参考 # Disruptor 高性能之道-等待策略:\u0026laquo; http://wuwenliang.net/2022/02/28/Disruptor\u003e 高性能之道-等待策略/\u0026gt; 《Java 并发编程实战》- 40 | 案例分析(三):高性能队列 Disruptor: https://time.geekbang.org/column/article/98134 "},{"id":470,"href":"/zh/docs/technology/Interview/cs-basics/network/dns/","title":"DNS 域名系统详解(应用层)","section":"Network","content":"DNS(Domain Name System)域名管理系统,是当用户使用浏览器访问网址之后,使用的第一个重要协议。DNS 要解决的是域名和 IP 地址的映射问题。\n在实际使用中,有一种情况下,浏览器是可以不必动用 DNS 就可以获知域名和 IP 地址的映射的。浏览器在本地会维护一个hosts列表,一般来说浏览器要先查看要访问的域名是否在hosts列表中,如果有的话,直接提取对应的 IP 地址记录,就好了。如果本地hosts列表内没有域名-IP 对应记录的话,那么 DNS 就闪亮登场了。\n目前 DNS 的设计采用的是分布式、层次数据库结构,DNS 是应用层协议,基于 UDP 协议之上,端口为 53 。\nDNS 服务器 # DNS 服务器自底向上可以依次分为以下几个层级(所有 DNS 服务器都属于以下四个类别之一):\n根 DNS 服务器。根 DNS 服务器提供 TLD 服务器的 IP 地址。目前世界上只有 13 组根服务器,我国境内目前仍没有根服务器。 顶级域 DNS 服务器(TLD 服务器)。顶级域是指域名的后缀,如com、org、net和edu等。国家也有自己的顶级域,如uk、fr和ca。TLD 服务器提供了权威 DNS 服务器的 IP 地址。 权威 DNS 服务器。在因特网上具有公共可访问主机的每个组织机构必须提供公共可访问的 DNS 记录,这些记录将这些主机的名字映射为 IP 地址。 本地 DNS 服务器。每个 ISP(互联网服务提供商)都有一个自己的本地 DNS 服务器。当主机发出 DNS 请求时,该请求被发往本地 DNS 服务器,它起着代理的作用,并将该请求转发到 DNS 层次结构中。严格说来,不属于 DNS 层级结构。 世界上并不是只有 13 台根服务器,这是很多人普遍的误解,网上很多文章也是这么写的。实际上,现在根服务器数量远远超过这个数量。最初确实是为 DNS 根服务器分配了 13 个 IP 地址,每个 IP 地址对应一个不同的根 DNS 服务器。然而,由于互联网的快速发展和增长,这个原始的架构变得不太适应当前的需求。为了提高 DNS 的可靠性、安全性和性能,目前这 13 个 IP 地址中的每一个都有多个服务器,截止到 2023 年底,所有根服务器之和达到了 600 多台,未来还会继续增加。\nDNS 工作流程 # 以下图为例,介绍 DNS 的查询解析过程。DNS 的查询解析过程分为两种模式:\n迭代 递归 下图是实践中常采用的方式,从请求主机到本地 DNS 服务器的查询是递归的,其余的查询时迭代的。\n现在,主机cis.poly.edu想知道gaia.cs.umass.edu的 IP 地址。假设主机cis.poly.edu的本地 DNS 服务器为dns.poly.edu,并且gaia.cs.umass.edu的权威 DNS 服务器为dns.cs.umass.edu。\n首先,主机cis.poly.edu向本地 DNS 服务器dns.poly.edu发送一个 DNS 请求,该查询报文包含被转换的域名gaia.cs.umass.edu。 本地 DNS 服务器dns.poly.edu检查本机缓存,发现并无记录,也不知道gaia.cs.umass.edu的 IP 地址该在何处,不得不向根服务器发送请求。 根服务器注意到请求报文中含有edu顶级域,因此告诉本地 DNS,你可以向edu的 TLD DNS 发送请求,因为目标域名的 IP 地址很可能在那里。 本地 DNS 获取到了edu的 TLD DNS 服务器地址,向其发送请求,询问gaia.cs.umass.edu的 IP 地址。 edu的 TLD DNS 服务器仍不清楚请求域名的 IP 地址,但是它注意到该域名有umass.edu前缀,因此返回告知本地 DNS,umass.edu的权威服务器可能记录了目标域名的 IP 地址。 这一次,本地 DNS 将请求发送给权威 DNS 服务器dns.cs.umass.edu。 终于,由于gaia.cs.umass.edu向权威 DNS 服务器备案过,在这里有它的 IP 地址记录,权威 DNS 成功地将 IP 地址返回给本地 DNS。 最后,本地 DNS 获取到了目标域名的 IP 地址,将其返回给请求主机。 除了迭代式查询,还有一种递归式查询如下图,具体过程和上述类似,只是顺序有所不同。\n另外,DNS 的缓存位于本地 DNS 服务器。由于全世界的根服务器甚少,只有 600 多台,分为 13 组,且顶级域的数量也在一个可数的范围内,因此本地 DNS 通常已经缓存了很多 TLD DNS 服务器,所以在实际查找过程中,无需访问根服务器。根服务器通常是被跳过的,不请求的。这样可以提高 DNS 查询的效率和速度,减少对根服务器和 TLD 服务器的负担。\nDNS 报文格式 # DNS 的报文格式如下图所示:\nDNS 报文分为查询和回答报文,两种形式的报文结构相同。\n标识符。16 比特,用于标识该查询。这个标识符会被复制到对查询的回答报文中,以便让客户用它来匹配发送的请求和接收到的回答。 标志。1 比特的”查询/回答“标识位,0表示查询报文,1表示回答报文;1 比特的”权威的“标志位(当某 DNS 服务器是所请求名字的权威 DNS 服务器时,且是回答报文,使用”权威的“标志);1 比特的”希望递归“标志位,显式地要求执行递归查询;1 比特的”递归可用“标志位,用于回答报文中,表示 DNS 服务器支持递归查询。 问题数、回答 RR 数、权威 RR 数、附加 RR 数。分别指示了后面 4 类数据区域出现的数量。 问题区域。包含正在被查询的主机名字,以及正被询问的问题类型。 回答区域。包含了对最初请求的名字的资源记录。在回答报文的回答区域中可以包含多条 RR,因此一个主机名能够有多个 IP 地址。 权威区域。包含了其他权威服务器的记录。 附加区域。包含了其他有帮助的记录。 DNS 记录 # DNS 服务器在响应查询时,需要查询自己的数据库,数据库中的条目被称为 资源记录(Resource Record,RR) 。RR 提供了主机名到 IP 地址的映射。RR 是一个包含了Name, Value, Type, TTL四个字段的四元组。\nTTL是该记录的生存时间,它决定了资源记录应当从缓存中删除的时间。\nName和Value字段的取值取决于Type:\n如果Type=A,则Name是主机名信息,Value 是该主机名对应的 IP 地址。这样的 RR 记录了一条主机名到 IP 地址的映射。 如果 Type=AAAA (与 A 记录非常相似),唯一的区别是 A 记录使用的是 IPv4,而 AAAA 记录使用的是 IPv6。 如果Type=CNAME (Canonical Name Record,真实名称记录) ,则Value是别名为Name的主机对应的规范主机名。Value值才是规范主机名。CNAME 记录将一个主机名映射到另一个主机名。CNAME 记录用于为现有的 A 记录创建别名。下文有示例。 如果Type=NS,则Name是个域,而Value是个知道如何获得该域中主机 IP 地址的权威 DNS 服务器的主机名。通常这样的 RR 是由 TLD 服务器发布的。 如果Type=MX ,则Value是个别名为Name的邮件服务器的规范主机名。既然有了 MX 记录,那么邮件服务器可以和其他服务器使用相同的别名。为了获得邮件服务器的规范主机名,需要请求 MX 记录;为了获得其他服务器的规范主机名,需要请求 CNAME 记录。 CNAME记录总是指向另一则域名,而非 IP 地址。假设有下述 DNS zone:\nNAME TYPE VALUE -------------------------------------------------- bar.example.com. CNAME foo.example.com. foo.example.com. A 192.0.2.23 当用户查询 bar.example.com 的时候,DNS Server 实际返回的是 foo.example.com 的 IP 地址。\n参考 # DNS 服务器类型: https://www.cloudflare.com/zh-cn/learning/dns/dns-server-types/ DNS Message Resource Record Field Formats: http://www.tcpipguide.com/free/t_DNSMessageResourceRecordFieldFormats-2.htm Understanding Different Types of Record in DNS Server: https://www.mustbegeek.com/understanding-different-types-of-record-in-dns-server/ "},{"id":471,"href":"/zh/docs/technology/Interview/tools/docker/docker-intro/","title":"Docker核心概念总结","section":"Docker","content":"本文只是对 Docker 的概念做了较为详细的介绍,并不涉及一些像 Docker 环境的安装以及 Docker 的一些常见操作和命令。\n容器介绍 # Docker 是世界领先的软件容器平台,所以想要搞懂 Docker 的概念我们必须先从容器开始说起。\n什么是容器? # 先来看看容器较为官方的解释 # 一句话概括容器:容器就是将软件打包成标准化单元,以用于开发、交付和部署。\n容器镜像是轻量的、可执行的独立软件包 ,包含软件运行所需的所有内容:代码、运行时环境、系统工具、系统库和设置。 容器化软件适用于基于 Linux 和 Windows 的应用,在任何环境中都能够始终如一地运行。 容器赋予了软件独立性,使其免受外在环境差异(例如,开发和预演环境的差异)的影响,从而有助于减少团队间在相同基础设施上运行不同软件时的冲突。 再来看看容器较为通俗的解释 # 如果需要通俗地描述容器的话,我觉得容器就是一个存放东西的地方,就像书包可以装各种文具、衣柜可以放各种衣服、鞋架可以放各种鞋子一样。我们现在所说的容器存放的东西可能更偏向于应用比如网站、程序甚至是系统环境。\n图解物理机,虚拟机与容器 # 关于虚拟机与容器的对比在后面会详细介绍到,这里只是通过网上的图片加深大家对于物理机、虚拟机与容器这三者的理解(下面的图片来源于网络)。\n物理机:\n虚拟机:\n容器:\n通过上面这三张抽象图,我们可以大概通过类比概括出:容器虚拟化的是操作系统而不是硬件,容器之间是共享同一套操作系统资源的。虚拟机技术是虚拟出一套硬件后,在其上运行一个完整操作系统。因此容器的隔离级别会稍低一些。\n容器 VS 虚拟机 # 每当说起容器,我们不得不将其与虚拟机做一个比较。就我而言,对于两者无所谓谁会取代谁,而是两者可以和谐共存。\n简单来说:容器和虚拟机具有相似的资源隔离和分配优势,但功能有所不同,因为容器虚拟化的是操作系统,而不是硬件,因此容器更容易移植,效率也更高。\n传统虚拟机技术是虚拟出一套硬件后,在其上运行一个完整操作系统,在该系统上再运行所需应用进程;而容器内的应用进程直接运行于宿主的内核,容器内没有自己的内核,而且也没有进行硬件虚拟。因此容器要比传统虚拟机更为轻便。\n容器和虚拟机的对比:\n容器是一个应用层抽象,用于将代码和依赖资源打包在一起。 多个容器可以在同一台机器上运行,共享操作系统内核,但各自作为独立的进程在用户空间中运行 。与虚拟机相比, 容器占用的空间较少(容器镜像大小通常只有几十兆),瞬间就能完成启动 。\n虚拟机 (VM) 是一个物理硬件层抽象,用于将一台服务器变成多台服务器。管理程序允许多个 VM 在一台机器上运行。每个 VM 都包含一整套操作系统、一个或多个应用、必要的二进制文件和库资源,因此 占用大量空间 。而且 VM 启动也十分缓慢 。\n通过 Docker 官网,我们知道了这么多 Docker 的优势,但是大家也没有必要完全否定虚拟机技术,因为两者有不同的使用场景。虚拟机更擅长于彻底隔离整个运行环境。例如,云服务提供商通常采用虚拟机技术隔离不同的用户。而 Docker 通常用于隔离不同的应用 ,例如前端,后端以及数据库。\n就我而言,对于两者无所谓谁会取代谁,而是两者可以和谐共存。\nDocker 介绍 # 什么是 Docker? # 说实话关于 Docker 是什么并不太好说,下面我通过四点向你说明 Docker 到底是个什么东西。\nDocker 是世界领先的软件容器平台。 Docker 使用 Google 公司推出的 Go 语言 进行开发实现,基于 Linux 内核 提供的 CGroup 功能和 namespace 来实现的,以及 AUFS 类的 UnionFS 等技术,对进程进行封装隔离,属于操作系统层面的虚拟化技术。 由于隔离的进程独立于宿主和其它的隔离的进程,因此也称其为容器。 Docker 能够自动执行重复性任务,例如搭建和配置开发环境,从而解放了开发人员以便他们专注在真正重要的事情上:构建杰出的软件。 用户可以方便地创建和使用容器,把自己的应用放入容器。容器还可以进行版本管理、复制、分享、修改,就像管理普通的代码一样。 Docker 思想:\n集装箱:就像海运中的集装箱一样,Docker 容器包含了应用程序及其所有依赖项,确保在任何环境中都能以相同的方式运行。 ==标准化:==运输方式、存储方式、API 接口。 隔离:每个 Docker 容器都在自己的隔离环境中运行,与宿主机和其他容器隔离。 Docker 容器的特点 # 轻量 : 在一台机器上运行的多个 Docker 容器可以共享这台机器的操作系统内核;它们能够迅速启动,只需占用很少的计算和内存资源。镜像是通过文件系统层进行构造的,并共享一些公共文件。这样就能尽量降低磁盘用量,并能更快地下载镜像。 标准 : Docker 容器基于开放式标准,能够在所有主流 Linux 版本、Microsoft Windows 以及包括 VM、裸机服务器和云在内的任何基础设施上运行。 安全 : Docker 赋予应用的隔离性不仅限于彼此隔离,还独立于底层的基础设施。Docker 默认提供最强的隔离,因此应用出现问题,也只是单个容器的问题,而不会波及到整台机器。 为什么要用 Docker ? # Docker 的镜像提供了除内核外完整的运行时环境,确保了应用运行环境一致性,从而不会再出现 “这段代码在我机器上没问题啊” 这类问题;——一致的运行环境 可以做到秒级、甚至毫秒级的启动时间。大大的节约了开发、测试、部署的时间。——更快速的启动时间 避免公用的服务器,资源会容易受到其他用户的影响。——隔离性 善于处理集中爆发的服务器使用压力;——弹性伸缩,快速扩展 可以很轻易的将在一个平台上运行的应用,迁移到另一个平台上,而不用担心运行环境的变化导致应用无法正常运行的情况。——迁移方便 使用 Docker 可以通过定制应用镜像来实现持续集成、持续交付、部署。——持续交付和部署 Docker 基本概念 # Docker 中有非常重要的三个基本概念:镜像(Image)、容器(Container)和仓库(Repository)。\n理解了这三个概念,就理解了 Docker 的整个生命周期。\n镜像(Image):一个特殊的文件系统 # 操作系统分为内核和用户空间。对于 Linux 而言,内核启动后,会挂载 root 文件系统为其提供用户空间支持。而 Docker 镜像(Image),就相当于是一个 root 文件系统。\nDocker 镜像是一个特殊的文件系统,除了提供容器运行时所需的程序、库、资源、配置等文件外,还包含了一些为运行时准备的一些配置参数(如匿名卷、环境变量、用户等)。 镜像不包含任何动态数据,其内容在构建之后也不会被改变。\nDocker 设计时,就充分利用 Union FS 的技术,将其设计为分层存储的架构 。镜像实际是由多层文件系统联合组成。\n镜像构建时,会一层层构建,前一层是后一层的基础。每一层构建完就不会再发生改变,后一层上的任何改变只发生在自己这一层。 比如,删除前一层文件的操作,实际不是真的删除前一层的文件,而是仅在当前层标记为该文件已删除。在最终容器运行的时候,虽然不会看到这个文件,但是实际上该文件会一直跟随镜像。因此,在构建镜像的时候,需要额外小心,每一层尽量只包含该层需要添加的东西,任何额外的东西应该在该层构建结束前清理掉。\n分层存储的特征还使得镜像的复用、定制变的更为容易。甚至可以用之前构建好的镜像作为基础层,然后进一步添加新的层,以定制自己所需的内容,构建新的镜像。\n容器(Container):镜像运行时的实体 # 镜像(Image)和容器(Container)的关系,就像是面向对象程序设计中的 类 和 实例 一样,镜像是静态的定义,容器是镜像运行时的实体。容器可以被创建、启动、停止、删除、暂停等 。\n容器的实质是进程,但与直接在宿主执行的进程不同,容器进程运行于属于自己的独立的 命名空间。前面讲过镜像使用的是分层存储,容器也是如此。\n容器存储层的生存周期和容器一样,容器消亡时,容器存储层也随之消亡。因此,任何保存于容器存储层的信息都会随容器删除而丢失。\n按照 Docker 最佳实践的要求,容器不应该向其存储层内写入任何数据 ,容器存储层要保持无状态化。所有的文件写入操作,都应该使用数据卷(Volume)、或者绑定宿主目录,在这些位置的读写会跳过容器存储层,直接对宿主(或网络存储)发生读写,其性能和稳定性更高。数据卷的生存周期独立于容器,容器消亡,数据卷不会消亡。因此, 使用数据卷后,容器可以随意删除、重新 run ,数据却不会丢失。\n仓库(Repository):集中存放镜像文件的地方 # 镜像构建完成后,可以很容易的在当前宿主上运行,但是, 如果需要在其它服务器上使用这个镜像,我们就需要一个集中的存储、分发镜像的服务,Docker Registry 就是这样的服务。\n一个 Docker Registry 中可以包含多个仓库(Repository);每个仓库可以包含多个标签(Tag);每个标签对应一个镜像。所以说:镜像仓库是 Docker 用来集中存放镜像文件的地方类似于我们之前常用的代码仓库。\n通常,一个仓库会包含同一个软件不同版本的镜像,而标签就常用于对应该软件的各个版本 。我们可以通过\u0026lt;仓库名\u0026gt;:\u0026lt;标签\u0026gt;的格式来指定具体是这个软件哪个版本的镜像。如果不给出标签,将以 latest 作为默认标签.。\n这里补充一下 Docker Registry 公开服务和私有 Docker Registry 的概念:\nDocker Registry 公开服务 是开放给用户使用、允许用户管理镜像的 Registry 服务。一般这类公开服务允许用户免费上传、下载公开的镜像,并可能提供收费服务供用户管理私有镜像。\n最常使用的 Registry 公开服务是官方的 Docker Hub ,这也是默认的 Registry,并拥有大量的高质量的官方镜像,网址为: https://hub.docker.com/ 。官方是这样介绍 Docker Hub 的:\nDocker Hub 是 Docker 官方提供的一项服务,用于与您的团队查找和共享容器镜像。\n比如我们想要搜索自己想要的镜像:\n在 Docker Hub 的搜索结果中,有几项关键的信息有助于我们选择合适的镜像:\nOFFICIAL Image:代表镜像为 Docker 官方提供和维护,相对来说稳定性和安全性较高。 Stars:和点赞差不多的意思,类似 GitHub 的 Star。 Downloads:代表镜像被拉取的次数,基本上能够表示镜像被使用的频度。 当然,除了直接通过 Docker Hub 网站搜索镜像这种方式外,我们还可以通过 docker search 这个命令搜索 Docker Hub 中的镜像,搜索的结果是一致的。\n➜ ~ docker search mysql NAME DESCRIPTION STARS OFFICIAL AUTOMATED mysql MySQL is a widely used, open-source relation… 8763 [OK] mariadb MariaDB is a community-developed fork of MyS… 3073 [OK] mysql/mysql-server Optimized MySQL Server Docker images. Create… 650 [OK] 在国内访问 Docker Hub 可能会比较慢国内也有一些云服务商提供类似于 Docker Hub 的公开服务。比如 时速云镜像库、 网易云镜像服务、 DaoCloud 镜像市场、 阿里云镜像库等。\n除了使用公开服务外,用户还可以在 本地搭建私有 Docker Registry 。Docker 官方提供了 Docker Registry 镜像,可以直接使用做为私有 Registry 服务。开源的 Docker Registry 镜像只提供了 Docker Registry API 的服务端实现,足以支持 Docker 命令,不影响使用。但不包含图形界面,以及镜像维护、用户管理、访问控制等高级功能。\nImage、Container 和 Repository 的关系 # 下面这一张图很形象地展示了 Image、Container、Repository 和 Registry/Hub 这四者的关系:\nDockerfile 是一个文本文件,包含了一系列的指令和参数,用于定义如何构建一个 Docker 镜像。运行 docker build命令并指定一个 Dockerfile 时,Docker 会读取 Dockerfile 中的指令,逐步构建一个新的镜像,并将其保存在本地。 docker pull 命令可以从指定的 Registry/Hub 下载一个镜像到本地,默认使用 Docker Hub。 docker run 命令可以从本地镜像创建一个新的容器并启动它。如果本地没有镜像,Docker 会先尝试从 Registry/Hub 拉取镜像。 docker push 命令可以将本地的 Docker 镜像上传到指定的 Registry/Hub。 上面涉及到了一些 Docker 的基本命令,后面会详细介绍大。\nBuild Ship and Run # Docker 的概念基本上已经讲完,我们再来谈谈:Build, Ship, and Run。\n如果你搜索 Docker 官网,会发现如下的字样:“Docker - Build, Ship, and Run Any App, Anywhere”。那么 Build, Ship, and Run 到底是在干什么呢?\nBuild(构建镜像):镜像就像是集装箱包括文件以及运行环境等等资源。 Ship(运输镜像):主机和仓库间运输,这里的仓库就像是超级码头一样。 Run (运行镜像):运行的镜像就是一个容器,容器就是运行程序的地方。 Docker 运行过程也就是去仓库把镜像拉到本地,然后用一条命令把镜像运行起来变成容器。所以,我们也常常将 Docker 称为码头工人或码头装卸工,这和 Docker 的中文翻译搬运工人如出一辙。\nDocker 常见命令 # 基本命令 # docker version # 查看docker版本 docker images # 查看所有已下载镜像,等价于:docker image ls 命令 docker container ls # 查看所有容器 docker ps #查看正在运行的容器 docker image prune # 清理临时的、没有被使用的镜像文件。-a, --all: 删除所有没有用的镜像,而不仅仅是临时文件; 拉取镜像 # docker pull 命令默认使用的 Registry/Hub 是 Docker Hub。当你执行 docker pull 命令而没有指定任何 Registry/Hub 的地址时,Docker 会从 Docker Hub 拉取镜像。\ndocker search mysql # 查看mysql相关镜像 docker pull mysql:5.7 # 拉取mysql镜像 docker image ls # 查看所有已下载镜像 构建镜像 # 运行 docker build命令并指定一个 Dockerfile 时,Docker 会读取 Dockerfile 中的指令,逐步构建一个新的镜像,并将其保存在本地。\n# # imageName 是镜像名称,1.0.0 是镜像的版本号或标签 docker build -t imageName:1.0.0 . 需要注意:Dockerfile 的文件名不必须为 Dockerfile,也不一定要放在构建上下文的根目录中。使用 -f 或 --file 选项,可以指定任何位置的任何文件作为 Dockerfile。当然,一般大家习惯性的会使用默认的文件名 Dockerfile,以及会将其置于镜像构建上下文目录中。\n删除镜像 # 比如我们要删除我们下载的 mysql 镜像。\n通过 docker rmi [image] (等价于docker image rm [image])删除镜像之前首先要确保这个镜像没有被容器引用(可以通过标签名称或者镜像 ID 删除)。通过我们前面讲的docker ps命令即可查看。\n➜ ~ docker ps CONTAINER ID IMAGE COMMAND CREATED STATUS PORTS NAMES c4cd691d9f80 mysql:5.7 \u0026#34;docker-entrypoint.s…\u0026#34; 7 weeks ago Up 12 days 0.0.0.0:3306-\u0026gt;3306/tcp, 33060/tcp mysql 可以看到 mysql 正在被 id 为 c4cd691d9f80 的容器引用,我们需要首先通过 docker stop c4cd691d9f80 或者 docker stop mysql暂停这个容器。\n然后查看 mysql 镜像的 id\n➜ ~ docker images REPOSITORY TAG IMAGE ID CREATED SIZE mysql 5.7 f6509bac4980 3 months ago 373MB 通过 IMAGE ID 或者 REPOSITORY 名字即可删除\ndocker rmi f6509bac4980 # 或者 docker rmi mysql 镜像推送 # docker push 命令用于将本地的 Docker 镜像上传到指定的 Registry/Hub。\n# 将镜像推送到私有镜像仓库 Harbor # harbor.example.com是私有镜像仓库的地址,ubuntu是镜像的名称,18.04是镜像的版本标签 docker push harbor.example.com/ubuntu:18.04 镜像推送之前,要确保本地已经构建好需要推送的 Docker 镜像。另外,务必先登录到对应的镜像仓库。\nDocker 数据管理 # 在容器中管理数据主要有两种方式:\n数据卷(Volumes) 挂载主机目录 (Bind mounts) 数据卷是由 Docker 管理的数据存储区域,有如下这些特点:\n可以在容器之间共享和重用。 即使容器被删除,数据卷中的数据也不会被自动删除,从而确保数据的持久性。 对数据卷的修改会立马生效。 对数据卷的更新,不会影响镜像。 # 创建一个数据卷 docker volume create my-vol # 查看所有的数据卷 docker volume ls # 查看数据卷的具体信息 docker inspect web # 删除指定的数据卷 docker volume rm my-vol 在用 docker run 命令的时候,使用 --mount 标记来将一个或多个数据卷挂载到容器里。\n还可以通过 --mount 标记将宿主机上的文件或目录挂载到容器中,这使得容器可以直接访问宿主机的文件系统。Docker 挂载主机目录的默认权限是读写,用户也可以通过增加 readonly 指定为只读。\nDocker Compose # 什么是 Docker Compose?有什么用? # Docker Compose 是 Docker 官方编排(Orchestration)项目之一,基于 Python 编写,负责实现对 Docker 容器集群的快速编排。通过 Docker Compose,开发者可以使用 YAML 文件来配置应用的所有服务,然后只需一个简单的命令即可创建和启动所有服务。\nDocker Compose 是开源项目,地址: https://github.com/docker/compose。\nDocker Compose 的核心功能:\n多容器管理:允许用户在一个 YAML 文件中定义和管理多个容器。 服务编排:配置容器间的网络和依赖关系。 一键部署:通过简单的命令,如docker-compose up和docker-compose down,可以轻松地启动和停止整个应用程序。 Docker Compose 简化了多容器应用程序的开发、测试和部署过程,提高了开发团队的生产力,同时降低了应用程序的部署复杂度和管理成本。\nDocker Compose 文件基本结构 # Docker Compose 文件是 Docker Compose 工具的核心,用于定义和配置多容器 Docker 应用。这个文件通常命名为 docker-compose.yml,采用 YAML(YAML Ain\u0026rsquo;t Markup Language)格式编写。\nDocker Compose 文件基本结构如下:\n版本(version): 指定 Compose 文件格式的版本。版本决定了可用的配置选项。 服务(services): 定义了应用中的每个容器(服务)。每个服务可以使用不同的镜像、环境设置和依赖关系。 镜像(image): 从指定的镜像中启动容器,可以是存储仓库、标签以及镜像 ID。 命令(command): 可选,覆盖容器启动后默认执行的命令。在启动服务时运行特定的命令或脚本,常用于启动应用程序、执行初始化脚本等。 端口(ports): 可选,映射容器和宿主机的端口。 依赖(depends_on): 依赖配置的选项,意思是如果服务启动是如果有依赖于其他服务的,先启动被依赖的服务,启动完成后在启动该服务。 环境变量(environment): 可选,设置服务运行所需的环境变量。 重启(restart): 可选,控制容器的重启策略。在容器退出时,根据指定的策略自动重启容器。 服务卷(volumes): 可选,定义服务使用的卷,用于数据持久化或在容器之间共享数据。 构建(build): 指定构建镜像的 dockerfile 的上下文路径,或者详细配置对象。 网络(networks): 定义了容器间的网络连接。 卷(volumes): 用于数据持久化和共享的数据卷定义。常用于数据库存储、配置文件、日志等数据的持久化。 version: \u0026#34;3.8\u0026#34; # 定义版本, 表示当前使用的 docker-compose 语法的版本 services: # 服务,可以存在多个 servicename1: # 服务名字,它也是内部 bridge 网络可以使用的 DNS name,如果不是集群模式相当于 docker run 的时候指定的一个名称, #集群(Swarm)模式是多个容器的逻辑抽象 image: # 镜像的名字 command: # 可选,如果设置,则会覆盖默认镜像里的 CMD 命令 environment: # 可选,等价于 docker container run 里的 --env 选项设置环境变量 volumes: # 可选,等价于 docker container run 里的 -v 选项 绑定数据卷 networks: # 可选,等价于 docker container run 里的 --network 选项指定网络 ports: # 可选,等价于 docker container run 里的 -p 选项指定端口映射 restart: # 可选,控制容器的重启策略 build: #构建目录 depends_on: #服务依赖配置 servicename2: image: command: networks: ports: servicename3: #... volumes: # 可选,需要创建的数据卷,类似 docker volume create db_data: networks: # 可选,等价于 docker network create Docker Compose 常见命令 # 启动 # docker-compose up会根据 docker-compose.yml 文件中定义的服务来创建和启动容器,并将它们连接到默认的网络中。\n# 在当前目录下寻找 docker-compose.yml 文件,并根据其中定义的服务启动应用程序 docker-compose up # 后台启动 docker-compose up -d # 强制重新创建所有容器,即使它们已经存在 docker-compose up --force-recreate # 重新构建镜像 docker-compose up --build # 指定要启动的服务名称,而不是启动所有服务 # 可以同时指定多个服务,用空格分隔。 docker-compose up service_name 另外,如果 Compose 文件名称不是 docker-compose.yml 也没问题,可以通过 -f 参数指定。\ndocker-compose -f docker-compose.prod.yml up 暂停 # docker-compose down用于停止并移除通过 docker-compose up 启动的容器和网络。\n# 在当前目录下寻找 docker-compose.yml 文件 # 根据其中定义移除启动的所有容器,网络和卷。 docker-compose down # 停止容器但不移除 docker-compose down --stop # 指定要停止和移除的特定服务,而不是停止和移除所有服务 # 可以同时指定多个服务,用空格分隔。 docker-compose down service_name 同样地,如果 Compose 文件名称不是 docker-compose.yml 也没问题,可以通过 -f 参数指定。\ndocker-compose -f docker-compose.prod.yml down 查看 # docker-compose ps用于查看通过 docker-compose up 启动的所有容器的状态信息。\n# 查看所有容器的状态信息 docker-compose ps # 只显示服务名称 docker-compose ps --services # 查看指定服务的容器 docker-compose ps service_name 其他 # 命令 介绍 docker-compose version 查看版本 docker-compose images 列出所有容器使用的镜像 docker-compose kill 强制停止服务的容器 docker-compose exec 在容器中执行命令 docker-compose logs 查看日志 docker-compose pause 暂停服务 docker-compose unpause 恢复服务 docker-compose push 推送服务镜像 docker-compose start 启动当前停止的某个容器 docker-compose stop 停止当前运行的某个容器 docker-compose rm 删除服务停止的容器 docker-compose top 查看进程 Docker 底层原理 # 首先,Docker 是基于轻量级虚拟化技术的软件,那什么是虚拟化技术呢?\n简单点来说,虚拟化技术可以这样定义:\n虚拟化技术是一种资源管理技术,是将计算机的各种 实体资源)( CPU、 内存、 磁盘空间、 网络适配器等),予以抽象、转换后呈现出来并可供分割、组合为一个或多个电脑配置环境。由此,打破实体结构间的不可切割的障碍,使用户可以比原本的配置更好的方式来应用这些电脑硬件资源。这些资源的新虚拟部分是不受现有资源的架设方式,地域或物理配置所限制。一般所指的虚拟化资源包括计算能力和数据存储。\nDocker 技术是基于 LXC(Linux container- Linux 容器)虚拟容器技术的。\nLXC,其名称来自 Linux 软件容器(Linux Containers)的缩写,一种操作系统层虚拟化(Operating system–level virtualization)技术,为 Linux 内核容器功能的一个用户空间接口。它将应用软件系统打包成一个软件容器(Container),内含应用软件本身的代码,以及所需要的操作系统核心和库。通过统一的名字空间和共用 API 来分配不同软件容器的可用硬件资源,创造出应用程序的独立沙箱运行环境,使得 Linux 用户可以容易的创建和管理系统或应用容器。\nLXC 技术主要是借助 Linux 内核中提供的 CGroup 功能和 namespace 来实现的,通过 LXC 可以为软件提供一个独立的操作系统运行环境。\ncgroup 和 namespace 介绍:\nnamespace 是 Linux 内核用来隔离内核资源的方式。 通过 namespace 可以让一些进程只能看到与自己相关的一部分资源,而另外一些进程也只能看到与它们自己相关的资源,这两拨进程根本就感觉不到对方的存在。具体的实现方式是把一个或多个进程的相关资源指定在同一个 namespace 中。Linux namespaces 是对全局系统资源的一种封装隔离,使得处于不同 namespace 的进程拥有独立的全局系统资源,改变一个 namespace 中的系统资源只会影响当前 namespace 里的进程,对其他 namespace 中的进程没有影响。\n(以上关于 namespace 介绍内容来自 https://www.cnblogs.com/sparkdev/p/9365405.html ,更多关于 namespace 的内容可以查看这篇文章 )。\nCGroup 是 Control Groups 的缩写,是 Linux 内核提供的一种可以限制、记录、隔离进程组 (process groups) 所使用的物理资源 (如 cpu memory i/o 等等) 的机制。\n(以上关于 CGroup 介绍内容来自 https://www.ibm.com/developerworks/cn/linux/1506_cgroup/index.html ,更多关于 CGroup 的内容可以查看这篇文章 )。\ncgroup 和 namespace 两者对比:\n两者都是将进程进行分组,但是两者的作用还是有本质区别。namespace 是为了隔离进程组之间的资源,而 cgroup 是为了对一组进程进行统一的资源监控和限制。\n总结 # 本文主要把 Docker 中的一些常见概念和命令做了详细的阐述。从零到上手实战可以看 Docker 从入门到上手干事这篇文章,内容非常详细!\n另外,再给大家推荐一本质量非常高的开源书籍 《Docker 从入门到实践》 ,这本书的内容非常新,毕竟书籍的内容是开源的,可以随时改进。\n参考 # Docker Compose:从零基础到实战应用的全面指南 Linux Namespace 和 Cgroup LXC vs Docker: Why Docker is Better CGroup 介绍、应用实例及原理描述 "},{"id":472,"href":"/zh/docs/technology/Interview/tools/docker/docker-in-action/","title":"Docker实战","section":"Docker","content":" Docker 介绍 # 开始之前,还是简单介绍一下 Docker,更多 Docker 概念介绍可以看前一篇文章 Docker 核心概念总结。\n什么是 Docker? # 说实话关于 Docker 是什么并不太好说,下面我通过四点向你说明 Docker 到底是个什么东西。\nDocker 是世界领先的软件容器平台,基于 Go 语言 进行开发实现。 Docker 能够自动执行重复性任务,例如搭建和配置开发环境,从而解放开发人员。 用户可以方便地创建和使用容器,把自己的应用放入容器。容器还可以进行版本管理、复制、分享、修改,就像管理普通的代码一样。 Docker 可以对进程进行封装隔离,属于操作系统层面的虚拟化技术。 由于隔离的进程独立于宿主和其它的隔离的进程,因此也称其为容器。 官网地址: https://www.docker.com/ 。\n为什么要用 Docker? # Docker 可以让开发者打包他们的应用以及依赖包到一个轻量级、可移植的容器中,然后发布到任何流行的 Linux 机器上,也可以实现虚拟化。\n容器是完全使用沙箱机制,相互之间不会有任何接口(类似 iPhone 的 app),更重要的是容器性能开销极低。\n传统的开发流程中,我们的项目通常需要使用 MySQL、Redis、FastDFS 等等环境,这些环境都是需要我们手动去进行下载并配置的,安装配置流程极其复杂,而且不同系统下的操作也不一样。\nDocker 的出现完美地解决了这一问题,我们可以在容器中安装 MySQL、Redis 等软件环境,使得应用和环境架构分开,它的优势在于:\n一致的运行环境,能够更轻松地迁移 对进程进行封装隔离,容器与容器之间互不影响,更高效地利用系统资源 可以通过镜像复制多个一致的容器 另外, 《Docker 从入门到实践》 这本开源书籍中也已经给出了使用 Docker 的原因。\nDocker 的安装 # Windows # 接下来对 Docker 进行安装,以 Windows 系统为例,访问 Docker 的官网:\n然后点击Get Started:\n在此处点击Download for Windows即可进行下载。\n如果你的电脑是Windows 10 64位专业版的操作系统,则在安装 Docker 之前需要开启一下Hyper-V,开启方式如下。打开控制面板,选择程序:\n点击启用或关闭Windows功能:\n勾选上Hyper-V,点击确定即可:\n完成更改后需要重启一下计算机。\n开启了Hyper-V后,我们就可以对 Docker 进行安装了,打开安装程序后,等待片刻点击Ok即可:\n安装完成后,我们仍然需要重启计算机,重启后,若提示如下内容:\n它的意思是询问我们是否使用 WSL2,这是基于 Windows 的一个 Linux 子系统,这里我们取消即可,它就会使用我们之前勾选的Hyper-V虚拟机。\n因为是图形界面的操作,这里就不介绍 Docker Desktop 的具体用法了。\nMac # 直接使用 Homebrew 安装即可\nbrew install --cask docker Linux # 下面来看看 Linux 中如何安装 Docker,这里以 CentOS7 为例。\n在测试或开发环境中,Docker 官方为了简化安装流程,提供了一套便捷的安装脚本,执行这个脚本后就会自动地将一切准备工作做好,并且把 Docker 的稳定版本安装在系统中。\ncurl -fsSL get.docker.com -o get-docker.sh sh get-docker.sh --mirror Aliyun 安装完成后直接启动服务:\nsystemctl start docker 推荐设置开机自启,执行指令:\nsystemctl enable docker Docker 中的几个概念 # 在正式学习 Docker 之前,我们需要了解 Docker 中的几个核心概念:\n镜像 # 镜像就是一个只读的模板,镜像可以用来创建 Docker 容器,一个镜像可以创建多个容器\n容器 # 容器是用镜像创建的运行实例,Docker 利用容器独立运行一个或一组应用。它可以被启动、开始、停止、删除,每个容器都是相互隔离的、保证安全的平台。 可以把容器看作是一个简易的 Linux 环境和运行在其中的应用程序。容器的定义和镜像几乎一模一样,也是一堆层的统一视角,唯一区别在于容器的最上面那一层是可读可写的\n仓库 # 仓库是集中存放镜像文件的场所。仓库和仓库注册服务器是有区别的,仓库注册服务器上往往存放着多个仓库,每个仓库中又包含了多个镜像,每个镜像有不同的标签。 仓库分为公开仓库和私有仓库两种形式,最大的公开仓库是 DockerHub,存放了数量庞大的镜像供用户下载,国内的公开仓库有阿里云、网易云等\n总结 # 通俗点说,一个镜像就代表一个软件;而基于某个镜像运行就是生成一个程序实例,这个程序实例就是容器;而仓库是用来存储 Docker 中所有镜像的。\n其中仓库又分为远程仓库和本地仓库,和 Maven 类似,倘若每次都从远程下载依赖,则会大大降低效率,为此,Maven 的策略是第一次访问依赖时,将其下载到本地仓库,第二次、第三次使用时直接用本地仓库的依赖即可,Docker 的远程仓库和本地仓库的作用也是类似的。\nDocker 初体验 # 下面我们来对 Docker 进行一个初步的使用,这里以下载一个 MySQL 的镜像为例(在CentOS7下进行)。\n和 GitHub 一样,Docker 也提供了一个 DockerHub 用于查询各种镜像的地址和安装教程,为此,我们先访问 DockerHub: https://hub.docker.com/\n在左上角的搜索框中输入MySQL并回车:\n可以看到相关 MySQL 的镜像非常多,若右上角有OFFICIAL IMAGE标识,则说明是官方镜像,所以我们点击第一个 MySQL 镜像:\n右边提供了下载 MySQL 镜像的指令为docker pull MySQL,但该指令始终会下载 MySQL 镜像的最新版本。\n若是想下载指定版本的镜像,则点击下面的View Available Tags:\n这里就可以看到各种版本的镜像,右边有下载的指令,所以若是想下载 5.7.32 版本的 MySQL 镜像,则执行:\ndocker pull MySQL:5.7.32 然而下载镜像的过程是非常慢的,所以我们需要配置一下镜像源加速下载,访问阿里云官网,点击控制台:\n然后点击左上角的菜单,在弹窗的窗口中,将鼠标悬停在产品与服务上,并在右侧搜索容器镜像服务,最后点击容器镜像服务:\n点击左侧的镜像加速器,并依次执行右侧的配置指令即可。\nsudo mkdir -p /etc/docker sudo tee /etc/docker/daemon.json \u0026lt;\u0026lt;-\u0026#39;EOF\u0026#39; { \u0026#34;registry-mirrors\u0026#34;: [\u0026#34;https://679xpnpz.mirror.aliyuncs.com\u0026#34;] } EOF sudo systemctl daemon-reload sudo systemctl restart docker Docker 镜像指令 # Docker 需要频繁地操作相关的镜像,所以我们先来了解一下 Docker 中的镜像指令。\n若想查看 Docker 中当前拥有哪些镜像,则可以使用 docker images 命令。\n[root@izrcf5u3j3q8xaz ~]# docker images REPOSITORY TAG IMAGE ID CREATED SIZE MySQL 5.7.32 f07dfa83b528 11 days ago 448MB tomcat latest feba8d001e3f 2 weeks ago 649MB nginx latest ae2feff98a0c 2 weeks ago 133MB hello-world latest bf756fb1ae65 12 months ago 13.3kB 其中REPOSITORY为镜像名,TAG为版本标志,IMAGE ID为镜像 id(唯一的),CREATED为创建时间,注意这个时间并不是我们将镜像下载到 Docker 中的时间,而是镜像创建者创建的时间,SIZE为镜像大小。\n该指令能够查询指定镜像名:\ndocker image MySQL 若如此做,则会查询出 Docker 中的所有 MySQL 镜像:\n[root@izrcf5u3j3q8xaz ~]# docker images MySQL REPOSITORY TAG IMAGE ID CREATED SIZE MySQL 5.6 0ebb5600241d 11 days ago 302MB MySQL 5.7.32 f07dfa83b528 11 days ago 448MB MySQL 5.5 d404d78aa797 20 months ago 205MB 该指令还能够携带-q参数:docker images -q , -q表示仅显示镜像的 id:\n[root@izrcf5u3j3q8xaz ~]# docker images -q 0ebb5600241d f07dfa83b528 feba8d001e3f d404d78aa797 若是要下载镜像,则使用:\ndocker pull MySQL:5.7 docker pull是固定的,后面写上需要下载的镜像名及版本标志;若是不写版本标志,而是直接执行docker pull MySQL,则会下载镜像的最新版本。\n一般在下载镜像前我们需要搜索一下镜像有哪些版本才能对指定版本进行下载,使用指令:\ndocker search MySQL 不过该指令只能查看 MySQL 相关的镜像信息,而不能知道有哪些版本,若想知道版本,则只能这样查询:\ndocker search MySQL:5.5 若是查询的版本不存在,则结果为空:\n删除镜像使用指令:\ndocker image rm MySQL:5.5 若是不指定版本,则默认删除的也是最新版本。\n还可以通过指定镜像 id 进行删除:\ndocker image rm bf756fb1ae65 然而此时报错了:\n[root@izrcf5u3j3q8xaz ~]# docker image rm bf756fb1ae65 Error response from daemon: conflict: unable to delete bf756fb1ae65 (must be forced) - image is being used by stopped container d5b6c177c151 这是因为要删除的hello-world镜像正在运行中,所以无法删除镜像,此时需要强制执行删除:\ndocker image rm -f bf756fb1ae65 该指令会将镜像和通过该镜像执行的容器全部删除,谨慎使用。\nDocker 还提供了删除镜像的简化版本:docker rmi 镜像名:版本标志 。\n此时我们即可借助rmi和-q进行一些联合操作,比如现在想删除所有的 MySQL 镜像,那么你需要查询出 MySQL 镜像的 id,并根据这些 id 一个一个地执行docker rmi进行删除,但是现在,我们可以这样:\ndocker rmi -f $(docker images MySQL -q) 首先通过docker images MySQL -q查询出 MySQL 的所有镜像 id,-q表示仅查询 id,并将这些 id 作为参数传递给docker rmi -f指令,这样所有的 MySQL 镜像就都被删除了。\nDocker 容器指令 # 掌握了镜像的相关指令之后,我们需要了解一下容器的指令,容器是基于镜像的。\n若需要通过镜像运行一个容器,则使用:\ndocker run tomcat:8.0-jre8 当然了,运行的前提是你拥有这个镜像,所以先下载镜像:\ndocker pull tomcat:8.0-jre8 下载完成后就可以运行了,运行后查看一下当前运行的容器:docker ps 。\n其中CONTAINER_ID为容器的 id,IMAGE为镜像名,COMMAND为容器内执行的命令,CREATED为容器的创建时间,STATUS为容器的状态,PORTS为容器内服务监听的端口,NAMES为容器的名称。\n通过该方式运行的 tomcat 是不能直接被外部访问的,因为容器具有隔离性,若是想直接通过 8080 端口访问容器内部的 tomcat,则需要对宿主机端口与容器内的端口进行映射:\ndocker run -p 8080:8080 tomcat:8.0-jre8 解释一下这两个端口的作用(8080:8080),第一个 8080 为宿主机端口,第二个 8080 为容器内的端口,外部访问 8080 端口就会通过映射访问容器内的 8080 端口。\n此时外部就可以访问 Tomcat 了:\n若是这样进行映射:\ndocker run -p 8088:8080 tomcat:8.0-jre8 则外部需访问 8088 端口才能访问 tomcat,需要注意的是,每次运行的容器都是相互独立的,所以同时运行多个 tomcat 容器并不会产生端口的冲突。\n容器还能够以后台的方式运行,这样就不会占用终端:\ndocker run -d -p 8080:8080 tomcat:8.0-jre8 启动容器时默认会给容器一个名称,但这个名称其实是可以设置的,使用指令:\ndocker run -d -p 8080:8080 --name tomcat01 tomcat:8.0-jre8 此时的容器名称即为 tomcat01,容器名称必须是唯一的。\n再来引申一下docker ps中的几个指令参数,比如-a:\ndocker ps -a 该参数会将运行和非运行的容器全部列举出来。\n-q参数将只查询正在运行的容器 id:docker ps -q 。\n[root@izrcf5u3j3q8xaz ~]# docker ps -q f3aac8ee94a3 074bf575249b 1d557472a708 4421848ba294 若是组合使用,则查询运行和非运行的所有容器 id:docker ps -qa 。\n[root@izrcf5u3j3q8xaz ~]# docker ps -aq f3aac8ee94a3 7f7b0e80c841 074bf575249b a1e830bddc4c 1d557472a708 4421848ba294 b0440c0a219a c2f5d78c5d1a 5831d1bab2a6 d5b6c177c151 接下来是容器的停止、重启指令,因为非常简单,就不过多介绍了。\ndocker start c2f5d78c5d1a 通过该指令能够将已经停止运行的容器运行起来,可以通过容器的 id 启动,也可以通过容器的名称启动。\ndocker restart c2f5d78c5d1a 该指令能够重启指定的容器。\ndocker stop c2f5d78c5d1a 该指令能够停止指定的容器。\ndocker kill c2f5d78c5d1a 该指令能够直接杀死指定的容器。\n以上指令都能够通过容器的 id 和容器名称两种方式配合使用。\n当容器被停止之后,容器虽然不再运行了,但仍然是存在的,若是想删除它,则使用指令:\ndocker rm d5b6c177c151 需要注意的是容器的 id 无需全部写出来,只需唯一标识即可。\n若是想删除正在运行的容器,则需要添加-f参数强制删除:\ndocker rm -f d5b6c177c151 若是想删除所有容器,则可以使用组合指令:\ndocker rm -f $(docker ps -qa) 先通过docker ps -qa查询出所有容器的 id,然后通过docker rm -f进行删除。\n当容器以后台的方式运行时,我们无法知晓容器的运行状态,若此时需要查看容器的运行日志,则使用指令:\ndocker logs 289cc00dc5ed 这样的方式显示的日志并不是实时的,若是想实时显示,需要使用-f参数:\ndocker logs -f 289cc00dc5ed 通过-t参数还能够显示日志的时间戳,通常与-f参数联合使用:\ndocker logs -ft 289cc00dc5ed 查看容器内运行了哪些进程,可以使用指令:\ndocker top 289cc00dc5ed 若是想与容器进行交互,则使用指令:\ndocker exec -it 289cc00dc5ed bash 此时终端将会进入容器内部,执行的指令都将在容器中生效,在容器内只能执行一些比较简单的指令,如:ls、cd 等,若是想退出容器终端,重新回到 CentOS 中,则执行exit即可。\n现在我们已经能够进入容器终端执行相关操作了,那么该如何向 tomcat 容器中部署一个项目呢?\ndocker cp ./test.html 289cc00dc5ed:/usr/local/tomcat/webapps 通过docker cp指令能够将文件从 CentOS 复制到容器中,./test.html为 CentOS 中的资源路径,289cc00dc5ed为容器 id,/usr/local/tomcat/webapps为容器的资源路径,此时test.html文件将会被复制到该路径下。\n[root@izrcf5u3j3q8xaz ~]# docker exec -it 289cc00dc5ed bash root@289cc00dc5ed:/usr/local/tomcat# cd webapps root@289cc00dc5ed:/usr/local/tomcat/webapps# ls test.html root@289cc00dc5ed:/usr/local/tomcat/webapps# 若是想将容器内的文件复制到 CentOS 中,则反过来写即可:\ndocker cp 289cc00dc5ed:/usr/local/tomcat/webapps/test.html ./ 所以现在若是想要部署项目,则先将项目上传到 CentOS,然后将项目从 CentOS 复制到容器内,此时启动容器即可。\n虽然使用 Docker 启动软件环境非常简单,但同时也面临着一个问题,我们无法知晓容器内部具体的细节,比如监听的端口、绑定的 ip 地址等等,好在这些 Docker 都帮我们想到了,只需使用指令:\ndocker inspect 923c969b0d91 Docker 数据卷 # 学习了容器的相关指令之后,我们来了解一下 Docker 中的数据卷,它能够实现宿主机与容器之间的文件共享,它的好处在于我们对宿主机的文件进行修改将直接影响容器,而无需再将宿主机的文件再复制到容器中。\n现在若是想将宿主机中/opt/apps目录与容器中webapps目录做一个数据卷,则应该这样编写指令:\ndocker run -d -p 8080:8080 --name tomcat01 -v /opt/apps:/usr/local/tomcat/webapps tomcat:8.0-jre8 然而此时访问 tomcat 会发现无法访问:\n这就说明我们的数据卷设置成功了,Docker 会将容器内的webapps目录与/opt/apps目录进行同步,而此时/opt/apps目录是空的,导致webapps目录也会变成空目录,所以就访问不到了。\n此时我们只需向/opt/apps目录下添加文件,就会使得webapps目录也会拥有相同的文件,达到文件共享,测试一下:\n[root@centos-7 opt]# cd apps/ [root@centos-7 apps]# vim test.html [root@centos-7 apps]# ls test.html [root@centos-7 apps]# cat test.html \u0026lt;h1\u0026gt;This is a test html!\u0026lt;/h1\u0026gt; 在/opt/apps目录下创建了一个 test.html 文件,那么容器内的webapps目录是否会有该文件呢?进入容器的终端:\n[root@centos-7 apps]# docker exec -it tomcat01 bash root@115155c08687:/usr/local/tomcat# cd webapps/ root@115155c08687:/usr/local/tomcat/webapps# ls test.html 容器内确实已经有了该文件,那接下来我们编写一个简单的 Web 应用:\npublic class HelloServlet extends HttpServlet { @Override protected void doGet(HttpServletRequest req, HttpServletResponse resp) throws ServletException, IOException { resp.getWriter().println(\u0026#34;Hello World!\u0026#34;); } @Override protected void doPost(HttpServletRequest req, HttpServletResponse resp) throws ServletException, IOException { doGet(req,resp); } } 这是一个非常简单的 Servlet,我们将其打包上传到/opt/apps中,那么容器内肯定就会同步到该文件,此时进行访问:\n这种方式设置的数据卷称为自定义数据卷,因为数据卷的目录是由我们自己设置的,Docker 还为我们提供了另外一种设置数据卷的方式:\ndocker run -d -p 8080:8080 --name tomcat01 -v aa:/usr/local/tomcat/webapps tomcat:8.0-jre8 此时的aa并不是数据卷的目录,而是数据卷的别名,Docker 会为我们自动创建一个名为aa的数据卷,并且会将容器内webapps目录下的所有内容复制到数据卷中,该数据卷的位置在/var/lib/docker/volumes目录下:\n[root@centos-7 volumes]# pwd /var/lib/docker/volumes [root@centos-7 volumes]# cd aa/ [root@centos-7 aa]# ls _data [root@centos-7 aa]# cd _data/ [root@centos-7 _data]# ls docs examples host-manager manager ROOT 此时我们只需修改该目录的内容就能能够影响到容器。\n最后再介绍几个容器和镜像相关的指令:\ndocker commit -m \u0026#34;描述信息\u0026#34; -a \u0026#34;镜像作者\u0026#34; tomcat01 my_tomcat:1.0 该指令能够将容器打包成一个镜像,此时查询镜像:\n[root@centos-7 _data]# docker images REPOSITORY TAG IMAGE ID CREATED SIZE my_tomcat 1.0 79ab047fade5 2 seconds ago 463MB tomcat 8 a041be4a5ba5 2 weeks ago 533MB MySQL latest db2b37ec6181 2 months ago 545MB 若是想将镜像备份出来,则可以使用指令:\ndocker save my_tomcat:1.0 -o my-tomcat-1.0.tar [root@centos-7 ~]# docker save my_tomcat:1.0 -o my-tomcat-1.0.tar [root@centos-7 ~]# ls anaconda-ks.cfg initial-setup-ks.cfg 公共 视频 文档 音乐 get-docker.sh my-tomcat-1.0.tar 模板 图片 下载 桌面 若是拥有.tar格式的镜像,该如何将其加载到 Docker 中呢?执行指令:\ndocker load -i my-tomcat-1.0.tar root@centos-7 ~]# docker load -i my-tomcat-1.0.tar b28ef0b6fef8: Loading layer [==================================================\u0026gt;] 105.5MB/105.5MB 0b703c74a09c: Loading layer [==================================================\u0026gt;] 23.99MB/23.99MB ...... Loaded image: my_tomcat:1.0 [root@centos-7 ~]# docker images REPOSITORY TAG IMAGE ID CREATED SIZE my_tomcat 1.0 79ab047fade5 7 minutes ago 463MB "},{"id":473,"href":"/zh/docs/technology/Interview/distributed-system/rpc/dubbo/","title":"Dubbo常见问题总结","section":"Rpc","content":"::: tip\nDubbo3 已经发布,这篇文章是基于 Dubbo2 写的。Dubbo3 基于 Dubbo2 演进而来,在保持原有核心功能特性的同时, Dubbo3 在易用性、超大规模微服务实践、云原生基础设施适配、安全设计等几大方向上进行了全面升级。 本文中的很多链接已经失效,主要原因是因为 Dubbo 官方文档进行了修改导致 URL 失效。 :::\n这篇文章是我根据官方文档以及自己平时的使用情况,对 Dubbo 所做的一个总结。欢迎补充!\nDubbo 基础 # 什么是 Dubbo? # Apache Dubbo |ˈdʌbəʊ| 是一款高性能、轻量级的开源 WEB 和 RPC 框架。\n根据 Dubbo 官方文档的介绍,Dubbo 提供了六大核心能力\n面向接口代理的高性能 RPC 调用。 智能容错和负载均衡。 服务自动注册和发现。 高度可扩展能力。 运行期流量调度。 可视化的服务治理与运维。 简单来说就是:Dubbo 不光可以帮助我们调用远程服务,还提供了一些其他开箱即用的功能比如智能负载均衡。\nDubbo 目前已经有接近 34.4 k 的 Star 。\n在 2020 年度 OSC 中国开源项目 评选活动中,Dubbo 位列开发框架和基础组件类项目的第 7 名。相比几年前来说,热度和排名有所下降。\nDubbo 是由阿里开源,后来加入了 Apache 。正是由于 Dubbo 的出现,才使得越来越多的公司开始使用以及接受分布式架构。\n为什么要用 Dubbo? # 随着互联网的发展,网站的规模越来越大,用户数量越来越多。单一应用架构、垂直应用架构无法满足我们的需求,这个时候分布式服务架构就诞生了。\n分布式服务架构下,系统被拆分成不同的服务比如短信服务、安全服务,每个服务独立提供系统的某个核心服务。\n我们可以使用 Java RMI(Java Remote Method Invocation)、Hessian 这种支持远程调用的框架来简单地暴露和引用远程服务。但是!当服务越来越多之后,服务调用关系越来越复杂。当应用访问压力越来越大后,负载均衡以及服务监控的需求也迫在眉睫。我们可以用 F5 这类硬件来做负载均衡,但这样增加了成本,并且存在单点故障的风险。\n不过,Dubbo 的出现让上述问题得到了解决。Dubbo 帮助我们解决了什么问题呢?\n负载均衡:同一个服务部署在不同的机器时该调用哪一台机器上的服务。 服务调用链路生成:随着系统的发展,服务越来越多,服务间依赖关系变得错踪复杂,甚至分不清哪个应用要在哪个应用之前启动,架构师都不能完整的描述应用的架构关系。Dubbo 可以为我们解决服务之间互相是如何调用的。 服务访问压力以及时长统计、资源调度和治理:基于访问压力实时管理集群容量,提高集群利用率。 …… 另外,Dubbo 除了能够应用在分布式系统中,也可以应用在现在比较火的微服务系统中。不过,由于 Spring Cloud 在微服务中应用更加广泛,所以,我觉得一般我们提 Dubbo 的话,大部分是分布式系统的情况。\n我们刚刚提到了分布式这个概念,下面再给大家介绍一下什么是分布式?为什么要分布式?\n分布式基础 # 什么是分布式? # 分布式或者说 SOA 分布式重要的就是面向服务,说简单的分布式就是我们把整个系统拆分成不同的服务然后将这些服务放在不同的服务器上减轻单体服务的压力提高并发量和性能。比如电商系统可以简单地拆分成订单系统、商品系统、登录系统等等,拆分之后的每个服务可以部署在不同的机器上,如果某一个服务的访问量比较大的话也可以将这个服务同时部署在多台机器上。\n为什么要分布式? # 从开发角度来讲单体应用的代码都集中在一起,而分布式系统的代码根据业务被拆分。所以,每个团队可以负责一个服务的开发,这样提升了开发效率。另外,代码根据业务拆分之后更加便于维护和扩展。\n另外,我觉得将系统拆分成分布式之后不光便于系统扩展和维护,更能提高整个系统的性能。你想一想嘛?把整个系统拆分成不同的服务/系统,然后每个服务/系统 单独部署在一台服务器上,是不是很大程度上提高了系统性能呢?\nDubbo 架构 # Dubbo 架构中的核心角色有哪些? # 官方文档中的框架设计章节 已经介绍的非常详细了,我这里把一些比较重要的点再提一下。\n上述节点简单介绍以及他们之间的关系:\nContainer: 服务运行容器,负责加载、运行服务提供者。必须。 Provider: 暴露服务的服务提供方,会向注册中心注册自己提供的服务。必须。 Consumer: 调用远程服务的服务消费方,会向注册中心订阅自己所需的服务。必须。 Registry: 服务注册与发现的注册中心。注册中心会返回服务提供者地址列表给消费者。非必须。 Monitor: 统计服务的调用次数和调用时间的监控中心。服务消费者和提供者会定时发送统计数据到监控中心。 非必须。 Dubbo 中的 Invoker 概念了解么? # Invoker 是 Dubbo 领域模型中非常重要的一个概念,你如果阅读过 Dubbo 源码的话,你会无数次看到这玩意。就比如下面我要说的负载均衡这块的源码中就有大量 Invoker 的身影。\n简单来说,Invoker 就是 Dubbo 对远程调用的抽象。\n按照 Dubbo 官方的话来说,Invoker 分为\n服务提供 Invoker 服务消费 Invoker 假如我们需要调用一个远程方法,我们需要动态代理来屏蔽远程调用的细节吧!我们屏蔽掉的这些细节就依赖对应的 Invoker 实现, Invoker 实现了真正的远程服务调用。\nDubbo 的工作原理了解么? # 下图是 Dubbo 的整体设计,从下至上分为十层,各层均为单向依赖。\n左边淡蓝背景的为服务消费方使用的接口,右边淡绿色背景的为服务提供方使用的接口,位于中轴线上的为双方都用到的接口。\nconfig 配置层:Dubbo 相关的配置。支持代码配置,同时也支持基于 Spring 来做配置,以 ServiceConfig, ReferenceConfig 为中心 proxy 服务代理层:调用远程方法像调用本地的方法一样简单的一个关键,真实调用过程依赖代理类,以 ServiceProxy 为中心。 registry 注册中心层:封装服务地址的注册与发现。 cluster 路由层:封装多个提供者的路由及负载均衡,并桥接注册中心,以 Invoker 为中心。 monitor 监控层:RPC 调用次数和调用时间监控,以 Statistics 为中心。 protocol 远程调用层:封装 RPC 调用,以 Invocation, Result 为中心。 exchange 信息交换层:封装请求响应模式,同步转异步,以 Request, Response 为中心。 transport 网络传输层:抽象 mina 和 netty 为统一接口,以 Message 为中心。 serialize 数据序列化层:对需要在网络传输的数据进行序列化。 Dubbo 的 SPI 机制了解么? 如何扩展 Dubbo 中的默认实现? # SPI(Service Provider Interface) 机制被大量用在开源项目中,它可以帮助我们动态寻找服务/功能(比如负载均衡策略)的实现。\nSPI 的具体原理是这样的:我们将接口的实现类放在配置文件中,我们在程序运行过程中读取配置文件,通过反射加载实现类。这样,我们可以在运行的时候,动态替换接口的实现类。和 IoC 的解耦思想是类似的。\nJava 本身就提供了 SPI 机制的实现。不过,Dubbo 没有直接用,而是对 Java 原生的 SPI 机制进行了增强,以便更好满足自己的需求。\n那我们如何扩展 Dubbo 中的默认实现呢?\n比如说我们想要实现自己的负载均衡策略,我们创建对应的实现类 XxxLoadBalance 实现 LoadBalance 接口或者 AbstractLoadBalance 类。\npackage com.xxx; import org.apache.dubbo.rpc.cluster.LoadBalance; import org.apache.dubbo.rpc.Invoker; import org.apache.dubbo.rpc.Invocation; import org.apache.dubbo.rpc.RpcException; public class XxxLoadBalance implements LoadBalance { public \u0026lt;T\u0026gt; Invoker\u0026lt;T\u0026gt; select(List\u0026lt;Invoker\u0026lt;T\u0026gt;\u0026gt; invokers, Invocation invocation) throws RpcException { // ... } } 我们将这个实现类的路径写入到resources 目录下的 META-INF/dubbo/org.apache.dubbo.rpc.cluster.LoadBalance文件中即可。\nsrc |-main |-java |-com |-xxx |-XxxLoadBalance.java (实现LoadBalance接口) |-resources |-META-INF |-dubbo |-org.apache.dubbo.rpc.cluster.LoadBalance (纯文本文件,内容为:xxx=com.xxx.XxxLoadBalance) org.apache.dubbo.rpc.cluster.LoadBalance\nxxx=com.xxx.XxxLoadBalance 其他还有很多可供扩展的选择,你可以在 官方文档中找到。\nDubbo 的微内核架构了解吗? # Dubbo 采用 微内核(Microkernel) + 插件(Plugin) 模式,简单来说就是微内核架构。微内核只负责组装插件。\n何为微内核架构呢? 《软件架构模式》 这本书是这样介绍的:\n微内核架构模式(有时被称为插件架构模式)是实现基于产品应用程序的一种自然模式。基于产品的应用程序是已经打包好并且拥有不同版本,可作为第三方插件下载的。然后,很多公司也在开发、发布自己内部商业应用像有版本号、说明及可加载插件式的应用软件(这也是这种模式的特征)。微内核系统可让用户添加额外的应用如插件,到核心应用,继而提供了可扩展性和功能分离的用法。\n微内核架构包含两类组件:核心系统(core system) 和 插件模块(plug-in modules)。\n核心系统提供系统所需核心能力,插件模块可以扩展系统的功能。因此, 基于微内核架构的系统,非常易于扩展功能。\n我们常见的一些 IDE,都可以看作是基于微内核架构设计的。绝大多数 IDE 比如 IDEA、VSCode 都提供了插件来丰富自己的功能。\n正是因为 Dubbo 基于微内核架构,才使得我们可以随心所欲替换 Dubbo 的功能点。比如你觉得 Dubbo 的序列化模块实现的不满足自己要求,没关系啊!你自己实现一个序列化模块就好了啊!\n通常情况下,微核心都会采用 Factory、IoC、OSGi 等方式管理插件生命周期。Dubbo 不想依赖 Spring 等 IoC 容器,也不想自己造一个小的 IoC 容器(过度设计),因此采用了一种最简单的 Factory 方式管理插件:JDK 标准的 SPI 扩展机制 (java.util.ServiceLoader)。\n关于 Dubbo 架构的一些自测小问题 # 注册中心的作用了解么? # 注册中心负责服务地址的注册与查找,相当于目录服务,服务提供者和消费者只在启动时与注册中心交互。\n服务提供者宕机后,注册中心会做什么? # 注册中心会立即推送事件通知消费者。\n监控中心的作用呢? # 监控中心负责统计各服务调用次数,调用时间等。\n注册中心和监控中心都宕机的话,服务都会挂掉吗? # 不会。两者都宕机也不影响已运行的提供者和消费者,消费者在本地缓存了提供者列表。注册中心和监控中心都是可选的,服务消费者可以直连服务提供者。\nDubbo 的负载均衡策略 # 什么是负载均衡? # 先来看一下稍微官方点的解释。下面这段话摘自维基百科对负载均衡的定义:\n负载均衡改善了跨多个计算资源(例如计算机,计算机集群,网络链接,中央处理单元或磁盘驱动)的工作负载分布。负载平衡旨在优化资源使用,最大化吞吐量,最小化响应时间,并避免任何单个资源的过载。使用具有负载平衡而不是单个组件的多个组件可以通过冗余提高可靠性和可用性。负载平衡通常涉及专用软件或硬件。\n上面讲的大家可能不太好理解,再用通俗的话给大家说一下。\n我们的系统中的某个服务的访问量特别大,我们将这个服务部署在了多台服务器上,当客户端发起请求的时候,多台服务器都可以处理这个请求。那么,如何正确选择处理该请求的服务器就很关键。假如,你就要一台服务器来处理该服务的请求,那该服务部署在多台服务器的意义就不复存在了。负载均衡就是为了避免单个服务器响应同一请求,容易造成服务器宕机、崩溃等问题,我们从负载均衡的这四个字就能明显感受到它的意义。\nDubbo 提供的负载均衡策略有哪些? # 在集群负载均衡时,Dubbo 提供了多种均衡策略,默认为 random 随机调用。我们还可以自行扩展负载均衡策略(参考 Dubbo SPI 机制)。\n在 Dubbo 中,所有负载均衡实现类均继承自 AbstractLoadBalance,该类实现了 LoadBalance 接口,并封装了一些公共的逻辑。\npublic abstract class AbstractLoadBalance implements LoadBalance { static int calculateWarmupWeight(int uptime, int warmup, int weight) { } @Override public \u0026lt;T\u0026gt; Invoker\u0026lt;T\u0026gt; select(List\u0026lt;Invoker\u0026lt;T\u0026gt;\u0026gt; invokers, URL url, Invocation invocation) { } protected abstract \u0026lt;T\u0026gt; Invoker\u0026lt;T\u0026gt; doSelect(List\u0026lt;Invoker\u0026lt;T\u0026gt;\u0026gt; invokers, URL url, Invocation invocation); int getWeight(Invoker\u0026lt;?\u0026gt; invoker, Invocation invocation) { } } AbstractLoadBalance 的实现类有下面这些:\n官方文档对负载均衡这部分的介绍非常详细,推荐小伙伴们看看,地址: https://dubbo.apache.org/zh/docs/v2.7/dev/source/loadbalance/#m-zhdocsv27devsourceloadbalance 。\nRandomLoadBalance # 根据权重随机选择(对加权随机算法的实现)。这是 Dubbo 默认采用的一种负载均衡策略。\nRandomLoadBalance 具体的实现原理非常简单,假如有两个提供相同服务的服务器 S1,S2,S1 的权重为 7,S2 的权重为 3。\n我们把这些权重值分布在坐标区间会得到:S1-\u0026gt;[0, 7) ,S2-\u0026gt;[7, 10)。我们生成[0, 10) 之间的随机数,随机数落到对应的区间,我们就选择对应的服务器来处理请求。\nRandomLoadBalance 的源码非常简单,简单花几分钟时间看一下。\n以下源码来自 Dubbo master 分支上的最新的版本 2.7.9。\npublic class RandomLoadBalance extends AbstractLoadBalance { public static final String NAME = \u0026#34;random\u0026#34;; @Override protected \u0026lt;T\u0026gt; Invoker\u0026lt;T\u0026gt; doSelect(List\u0026lt;Invoker\u0026lt;T\u0026gt;\u0026gt; invokers, URL url, Invocation invocation) { int length = invokers.size(); boolean sameWeight = true; int[] weights = new int[length]; int totalWeight = 0; // 下面这个for循环的主要作用就是计算所有该服务的提供者的权重之和 totalWeight(), // 除此之外,还会检测每个服务提供者的权重是否相同 for (int i = 0; i \u0026lt; length; i++) { int weight = getWeight(invokers.get(i), invocation); totalWeight += weight; weights[i] = totalWeight; if (sameWeight \u0026amp;\u0026amp; totalWeight != weight * (i + 1)) { sameWeight = false; } } if (totalWeight \u0026gt; 0 \u0026amp;\u0026amp; !sameWeight) { // 随机生成一个 [0, totalWeight) 区间内的数字 int offset = ThreadLocalRandom.current().nextInt(totalWeight); // 判断会落在哪个服务提供者的区间 for (int i = 0; i \u0026lt; length; i++) { if (offset \u0026lt; weights[i]) { return invokers.get(i); } } return invokers.get(ThreadLocalRandom.current().nextInt(length)); } } LeastActiveLoadBalance # LeastActiveLoadBalance 直译过来就是最小活跃数负载均衡。\n这个名字起得有点不直观,不仔细看官方对活跃数的定义,你压根不知道这玩意是干嘛的。\n我这么说吧!初始状态下所有服务提供者的活跃数均为 0(每个服务提供者的中特定方法都对应一个活跃数,我在后面的源码中会提到),每收到一个请求后,对应的服务提供者的活跃数 +1,当这个请求处理完之后,活跃数 -1。\n因此,Dubbo 就认为谁的活跃数越少,谁的处理速度就越快,性能也越好,这样的话,我就优先把请求给活跃数少的服务提供者处理。\n如果有多个服务提供者的活跃数相等怎么办?\n很简单,那就再走一遍 RandomLoadBalance 。\npublic class LeastActiveLoadBalance extends AbstractLoadBalance { public static final String NAME = \u0026#34;leastactive\u0026#34;; @Override protected \u0026lt;T\u0026gt; Invoker\u0026lt;T\u0026gt; doSelect(List\u0026lt;Invoker\u0026lt;T\u0026gt;\u0026gt; invokers, URL url, Invocation invocation) { int length = invokers.size(); int leastActive = -1; int leastCount = 0; int[] leastIndexes = new int[length]; int[] weights = new int[length]; int totalWeight = 0; int firstWeight = 0; boolean sameWeight = true; // 这个 for 循环的主要作用是遍历 invokers 列表,找出活跃数最小的 Invoker // 如果有多个 Invoker 具有相同的最小活跃数,还会记录下这些 Invoker 在 invokers 集合中的下标,并累加它们的权重,比较它们的权重值是否相等 for (int i = 0; i \u0026lt; length; i++) { Invoker\u0026lt;T\u0026gt; invoker = invokers.get(i); // 获取 invoker 对应的活跃(active)数 int active = RpcStatus.getStatus(invoker.getUrl(), invocation.getMethodName()).getActive(); int afterWarmup = getWeight(invoker, invocation); weights[i] = afterWarmup; if (leastActive == -1 || active \u0026lt; leastActive) { leastActive = active; leastCount = 1; leastIndexes[0] = i; totalWeight = afterWarmup; firstWeight = afterWarmup; sameWeight = true; } else if (active == leastActive) { leastIndexes[leastCount++] = i; totalWeight += afterWarmup; if (sameWeight \u0026amp;\u0026amp; afterWarmup != firstWeight) { sameWeight = false; } } } // 如果只有一个 Invoker 具有最小的活跃数,此时直接返回该 Invoker 即可 if (leastCount == 1) { return invokers.get(leastIndexes[0]); } // 如果有多个 Invoker 具有相同的最小活跃数,但它们之间的权重不同 // 这里的处理方式就和 RandomLoadBalance 一致了 if (!sameWeight \u0026amp;\u0026amp; totalWeight \u0026gt; 0) { int offsetWeight = ThreadLocalRandom.current().nextInt(totalWeight); for (int i = 0; i \u0026lt; leastCount; i++) { int leastIndex = leastIndexes[i]; offsetWeight -= weights[leastIndex]; if (offsetWeight \u0026lt; 0) { return invokers.get(leastIndex); } } } return invokers.get(leastIndexes[ThreadLocalRandom.current().nextInt(leastCount)]); } } 活跃数是通过 RpcStatus 中的一个 ConcurrentMap 保存的,根据 URL 以及服务提供者被调用的方法的名称,我们便可以获取到对应的活跃数。也就是说服务提供者中的每一个方法的活跃数都是互相独立的。\npublic class RpcStatus { private static final ConcurrentMap\u0026lt;String, ConcurrentMap\u0026lt;String, RpcStatus\u0026gt;\u0026gt; METHOD_STATISTICS = new ConcurrentHashMap\u0026lt;String, ConcurrentMap\u0026lt;String, RpcStatus\u0026gt;\u0026gt;(); public static RpcStatus getStatus(URL url, String methodName) { String uri = url.toIdentityString(); ConcurrentMap\u0026lt;String, RpcStatus\u0026gt; map = METHOD_STATISTICS.computeIfAbsent(uri, k -\u0026gt; new ConcurrentHashMap\u0026lt;\u0026gt;()); return map.computeIfAbsent(methodName, k -\u0026gt; new RpcStatus()); } public int getActive() { return active.get(); } } ConsistentHashLoadBalance # ConsistentHashLoadBalance 小伙伴们应该也不会陌生,在分库分表、各种集群中就经常使用这个负载均衡策略。\nConsistentHashLoadBalance 即一致性 Hash 负载均衡策略。 ConsistentHashLoadBalance 中没有权重的概念,具体是哪个服务提供者处理请求是由你的请求的参数决定的,也就是说相同参数的请求总是发到同一个服务提供者。\n另外,Dubbo 为了避免数据倾斜问题(节点不够分散,大量请求落到同一节点),还引入了虚拟节点的概念。通过虚拟节点可以让节点更加分散,有效均衡各个节点的请求量。\n官方有详细的源码分析: https://dubbo.apache.org/zh/docs/v2.7/dev/source/loadbalance/#23-consistenthashloadbalance 。这里还有一个相关的 PR#5440 来修复老版本中 ConsistentHashLoadBalance 存在的一些 Bug。感兴趣的小伙伴,可以多花点时间研究一下。我这里不多分析了,这个作业留给你们!\nRoundRobinLoadBalance # 加权轮询负载均衡。\n轮询就是把请求依次分配给每个服务提供者。加权轮询就是在轮询的基础上,让更多的请求落到权重更大的服务提供者上。比如假如有两个提供相同服务的服务器 S1,S2,S1 的权重为 7,S2 的权重为 3。\n如果我们有 10 次请求,那么 7 次会被 S1 处理,3 次被 S2 处理。\n但是,如果是 RandomLoadBalance 的话,很可能存在 10 次请求有 9 次都被 S1 处理的情况(概率性问题)。\nDubbo 中的 RoundRobinLoadBalance 的代码实现被修改重建了好几次,Dubbo-2.6.5 版本的 RoundRobinLoadBalance 为平滑加权轮询算法。\nDubbo 序列化协议 # Dubbo 支持哪些序列化方式呢? # Dubbo 支持多种序列化方式:JDK 自带的序列化、hessian2、JSON、Kryo、FST、Protostuff,ProtoBuf 等等。\nDubbo 默认使用的序列化方式是 hessian2。\n谈谈你对这些序列化协议了解? # 一般我们不会直接使用 JDK 自带的序列化方式。主要原因有两个:\n不支持跨语言调用 : 如果调用的是其他语言开发的服务的时候就不支持了。 性能差:相比于其他序列化框架性能更低,主要原因是序列化之后的字节数组体积较大,导致传输成本加大。 JSON 序列化由于性能问题,我们一般也不会考虑使用。\n像 Protostuff,ProtoBuf、hessian2 这些都是跨语言的序列化方式,如果有跨语言需求的话可以考虑使用。\nKryo 和 FST 这两种序列化方式是 Dubbo 后来才引入的,性能非常好。不过,这两者都是专门针对 Java 语言的。Dubbo 官网的一篇文章中提到说推荐使用 Kryo 作为生产环境的序列化方式。\nDubbo 官方文档中还有一个关于这些 序列化协议的性能对比图可供参考。\n"},{"id":474,"href":"/zh/docs/technology/Interview/database/elasticsearch/elasticsearch-questions-01/","title":"Elasticsearch常见面试题总结(付费)","section":"Elasticsearch","content":"Elasticsearch 相关的面试题为我的 知识星球(点击链接即可查看详细介绍以及加入方法)专属内容,已经整理到了 《Java 面试指北》中。\n"},{"id":475,"href":"/zh/docs/technology/Interview/tools/git/github-tips/","title":"Github实用小技巧总结","section":"Git","content":"我使用 Github 已经有 6 年多了,今天毫无保留地把自己觉得比较有用的 Github 小技巧送给关注 JavaGuide 的各位小伙伴。\n一键生成 Github 简历 \u0026amp; Github 年报 # 通过 https://resume.github.io/ 这个网站你可以一键生成一个在线的 Github 简历。\n当时我参加的校招的时候,个人信息那里就放了一个在线的 Github 简历。我觉得这样会让面试官感觉你是一个内行,会提高一些印象分。\n但是,如果你的 Github 没有什么项目的话还是不要放在简历里面了。生成后的效果如下图所示。\n通过 https://www.githubtrends.io/wrapped 这个网站,你可以生成一份 Github 个人年报,这个年报会列举出你在这一年的项目贡献情况、最常使用的编程语言、详细的贡献信息。\n个性化 Github 首页 # Github 目前支持在个人主页自定义展示一些内容。展示效果如下图所示。\n想要做到这样非常简单,你只需要创建一个和你的 Github 账户同名的仓库,然后自定义README.md的内容即可。\n展示在你主页的自定义内容就是README.md的内容(不会 Markdown 语法的小伙伴自行面壁 5 分钟)。\n这个也是可以玩出花来的!比如说:通过 github-readme-stats 这个开源项目,你可以 README 中展示动态生成的 GitHub 统计信息。展示效果如下图所示。\n关于个性化首页这个就不多提了,感兴趣的小伙伴自行研究一下。\n自定义项目徽章 # 你在 Github 上看到的项目徽章都是通过 https://shields.io/ 这个网站生成的。我的 JavaGuide 这个项目的徽章如下图所示。\n并且,你不光可以生成静态徽章,shield.io 还可以动态读取你项目的状态并生成对应的徽章。\n生成的描述项目状态的徽章效果如下图所示。\n自动为项目添加贡献情况图标 # 通过 repobeats 这个工具可以为 Github 项目添加如下图所示的项目贡献基本情况图表,挺不错的 👍\n地址: https://repobeats.axiom.co/ 。\nGithub 表情 # 如果你想要在 Github 使用表情的话,可以在这里找找: www.webfx.com/tools/emoji-cheat-sheet/。\n高效阅读 Github 项目的源代码 # Github 前段时间推出的 Codespaces 可以提供类似 VS Code 的在线 IDE,不过目前还没有完全开发使用。\n简单介绍几种我最常用的阅读 Github 项目源代码的方式。\nChrome 插件 Octotree # 这个已经老生常谈了,是我最喜欢的一种方式。使用了 Octotree 之后网页侧边栏会按照树形结构展示项目,为我们带来 IDE 般的阅读源代码的感受。\nChrome 插件 SourceGraph # 我不想将项目 clone 到本地的时候一般就会使用这种方式来阅读项目源代码。SourceGraph 不仅可以让我们在 Github 优雅的查看代码,它还支持一些骚操作,比如:类之间的跳转、代码搜索等功能。\n当你下载了这个插件之后,你的项目主页会多出一个小图标如下图所示。点击这个小图标即可在线阅读项目源代码。\n使用 SourceGraph 阅读代码的就像下面这样,同样是树形结构展示代码,但是我个人感觉没有 Octotree 的手感舒服。不过,SourceGraph 内置了很多插件,而且还支持类之间的跳转!\n克隆项目到本地 # 先把项目克隆到本地,然后使用自己喜欢的 IDE 来阅读。可以说是最酸爽的方式了!\n如果你想要深入了解某个项目的话,首选这种方式。一个git clone 就完事了。\n扩展 Github 的功能 # Enhanced GitHub 可以让你的 Github 更好用。这个 Chrome 插件可以可视化你的 Github 仓库大小,每个文件的大小并且可以让你快速下载单个文件。\n自动为 Markdown 文件生成目录 # 如果你想为 Github 上的 Markdown 文件生成目录的话,通过 VS Code 的 Markdown Preview Enhanced 这个插件就可以了。\n生成的目录效果如下图所示。你直接点击目录中的链接即可跳转到文章对应的位置,可以优化阅读体验。\n不过,目前 Github 已经自动为 Markdown 文件生成了目录,只是需要通过点击的方式才能显示出来。\n善用 Github Explore # 其实,Github 自带的 Explore 是一个非常强大且好用的功能。不过,据我观察,国内很多 Github 用户都不知道这个到底是干啥的。\n简单来说,Github Explore 可以为你带来下面这些服务:\n可以根据你的个人兴趣为你推荐项目; Githunb Topics 按照类别/话题将一些项目进行了分类汇总。比如 Data visualization 汇总了数据可视化相关的一些开源项目, Awesome Lists 汇总了 Awesome 系列的仓库; 通过 Github Trending 我们可以看到最近比较热门的一些开源项目,我们可以按照语言类型以及时间维度对项目进行筛选; Github Collections 类似一个收藏夹集合。比如 Teaching materials for computational social science 这个收藏夹就汇总了计算机课程相关的开源资源, Learn to Code 这个收藏夹就汇总了对你学习编程有帮助的一些仓库; …… GitHub Actions 很强大 # 你可以简单地将 GitHub Actions 理解为 Github 自带的 CI/CD ,通过 GitHub Actions 你可以直接在 GitHub 构建、测试和部署代码,你还可以对代码进行审查、管理 API、分析项目依赖项。总之,GitHub Actions 可以自动化地帮你完成很多事情。\n关于 GitHub Actions 的详细介绍,推荐看一下阮一峰老师写的 GitHub Actions 入门教程 。\nGitHub Actions 有一个官方市场,上面有非常多别人提交的 Actions ,你可以直接拿来使用。\n后记 # 这一篇文章,我毫无保留地把自己这些年总结的 Github 小技巧分享了出来,真心希望对大家有帮助,真心希望大家一定要利用好 Github 这个专属程序员的宝藏。\n另外,这篇文章中,我并没有提到 Github 搜索技巧。在我看来,Github 搜索技巧不必要记网上那些文章说的各种命令啥的,真没啥卵用。你会发现你用的最多的还是关键字搜索以及 Github 自带的筛选功能。\n"},{"id":476,"href":"/zh/docs/technology/Interview/tools/git/git-intro/","title":"Git核心概念总结","section":"Git","content":" 版本控制 # 什么是版本控制 # 版本控制是一种记录一个或若干文件内容变化,以便将来查阅特定版本修订情况的系统。 除了项目源代码,你可以对任何类型的文件进行版本控制。\n为什么要版本控制 # 有了它你就可以将某个文件回溯到之前的状态,甚至将整个项目都回退到过去某个时间点的状态,你可以比较文件的变化细节,查出最后是谁修改了哪个地方,从而找出导致怪异问题出现的原因,又是谁在何时报告了某个功能缺陷等等。\n本地版本控制系统 # 许多人习惯用复制整个项目目录的方式来保存不同的版本,或许还会改名加上备份时间以示区别。 这么做唯一的好处就是简单,但是特别容易犯错。 有时候会混淆所在的工作目录,一不小心会写错文件或者覆盖意想外的文件。\n为了解决这个问题,人们很久以前就开发了许多种本地版本控制系统,大多都是采用某种简单的数据库来记录文件的历次更新差异。\n集中化的版本控制系统 # 接下来人们又遇到一个问题,如何让在不同系统上的开发者协同工作? 于是,集中化的版本控制系统(Centralized Version Control Systems,简称 CVCS)应运而生。\n集中化的版本控制系统都有一个单一的集中管理的服务器,保存所有文件的修订版本,而协同工作的人们都通过客户端连到这台服务器,取出最新的文件或者提交更新。\n这么做虽然解决了本地版本控制系统无法让在不同系统上的开发者协同工作的诟病,但也还是存在下面的问题:\n单点故障: 中央服务器宕机,则其他人无法使用;如果中心数据库磁盘损坏又没有进行备份,你将丢失所有数据。本地版本控制系统也存在类似问题,只要整个项目的历史记录被保存在单一位置,就有丢失所有历史更新记录的风险。 必须联网才能工作: 受网络状况、带宽影响。 分布式版本控制系统 # 于是分布式版本控制系统(Distributed Version Control System,简称 DVCS)面世了。 Git 就是一个典型的分布式版本控制系统。\n这类系统,客户端并不只提取最新版本的文件快照,而是把代码仓库完整地镜像下来。 这么一来,任何一处协同工作用的服务器发生故障,事后都可以用任何一个镜像出来的本地仓库恢复。 因为每一次的克隆操作,实际上都是一次对代码仓库的完整备份。\n分布式版本控制系统可以不用联网就可以工作,因为每个人的电脑上都是完整的版本库,当你修改了某个文件后,你只需要将自己的修改推送给别人就可以了。但是,在实际使用分布式版本控制系统的时候,很少会直接进行推送修改,而是使用一台充当“中央服务器”的东西。这个服务器的作用仅仅是用来方便“交换”大家的修改,没有它大家也一样干活,只是交换修改不方便而已。\n分布式版本控制系统的优势不单是不必联网这么简单,后面我们还会看到 Git 极其强大的分支管理等功能。\n认识 Git # Git 简史 # Linux 内核项目组当时使用分布式版本控制系统 BitKeeper 来管理和维护代码。但是,后来开发 BitKeeper 的商业公司同 Linux 内核开源社区的合作关系结束,他们收回了 Linux 内核社区免费使用 BitKeeper 的权力。 Linux 开源社区(特别是 Linux 的缔造者 Linus Torvalds)基于使用 BitKeeper 时的经验教训,开发出自己的版本系统,而且对新的版本控制系统做了很多改进。\nGit 与其他版本管理系统的主要区别 # Git 在保存和对待各种信息的时候与其它版本控制系统有很大差异,尽管操作起来的命令形式非常相近,理解这些差异将有助于防止你使用中的困惑。\n下面我们主要说一个关于 Git 与其他版本管理系统的主要差别:对待数据的方式。\nGit 采用的是直接记录快照的方式,而非差异比较。我后面会详细介绍这两种方式的差别。\n大部分版本控制系统(CVS、Subversion、Perforce、Bazaar 等等)都是以文件变更列表的方式存储信息,这类系统将它们保存的信息看作是一组基本文件和每个文件随时间逐步累积的差异。\n具体原理如下图所示,理解起来其实很简单,每当我们提交更新一个文件之后,系统都会记录这个文件做了哪些更新,以增量符号 Δ(Delta)表示。\n我们怎样才能得到一个文件的最终版本呢?\n很简单,高中数学的基本知识,我们只需要将这些原文件和这些增加进行相加就行了。\n这种方式有什么问题呢?\n比如我们的增量特别特别多的话,如果我们要得到最终的文件是不是会耗费时间和性能。\nGit 不按照以上方式对待或保存数据。 反之,Git 更像是把数据看作是对小型文件系统的一组快照。 每次你提交更新,或在 Git 中保存项目状态时,它主要对当时的全部文件制作一个快照并保存这个快照的索引。 为了高效,如果文件没有修改,Git 不再重新存储该文件,而是只保留一个链接指向之前存储的文件。 Git 对待数据更像是一个 快照流。\nGit 的三种状态 # Git 有三种状态,你的文件可能处于其中之一:\n已提交(committed):数据已经安全的保存在本地数据库中。 已修改(modified):已修改表示修改了文件,但还没保存到数据库中。 已暂存(staged):表示对一个已修改文件的当前版本做了标记,使之包含在下次提交的快照中。 由此引入 Git 项目的三个工作区域的概念:Git 仓库(.git directory)、工作目录(Working Directory) 以及 暂存区域(Staging Area) 。\n基本的 Git 工作流程如下:\n在工作目录中修改文件。 暂存文件,将文件的快照放入暂存区域。 提交更新,找到暂存区域的文件,将快照永久性存储到 Git 仓库目录。 Git 使用快速入门 # 获取 Git 仓库 # 有两种取得 Git 项目仓库的方法。\n在现有目录中初始化仓库: 进入项目目录运行 git init 命令,该命令将创建一个名为 .git 的子目录。 从一个服务器克隆一个现有的 Git 仓库: git clone [url] 自定义本地仓库的名字: git clone [url] directoryname 记录每次更新到仓库 # 检测当前文件状态 : git status 提出更改(把它们添加到暂存区):git add filename (针对特定文件)、git add *(所有文件)、git add *.txt(支持通配符,所有 .txt 文件) 忽略文件:.gitignore 文件 提交更新: git commit -m \u0026quot;代码提交信息\u0026quot; (每次准备提交前,先用 git status 看下,是不是都已暂存起来了, 然后再运行提交命令 git commit) 跳过使用暂存区域更新的方式 : git commit -a -m \u0026quot;代码提交信息\u0026quot;。 git commit 加上 -a 选项,Git 就会自动把所有已经跟踪过的文件暂存起来一并提交,从而跳过 git add 步骤。 移除文件:git rm filename (从暂存区域移除,然后提交。) 对文件重命名:git mv README.md README(这个命令相当于mv README.md README、git rm README.md、git add README 这三条命令的集合) 一个好的 Git 提交消息 # 一个好的 Git 提交消息如下:\n标题行:用这一行来描述和解释你的这次提交 主体部分可以是很少的几行,来加入更多的细节来解释提交,最好是能给出一些相关的背景或者解释这个提交能修复和解决什么问题。 主体部分当然也可以有几段,但是一定要注意换行和句子不要太长。因为这样在使用 \u0026#34;git log\u0026#34; 的时候会有缩进比较好看。 提交的标题行描述应该尽量的清晰和尽量的一句话概括。这样就方便相关的 Git 日志查看工具显示和其他人的阅读。\n推送改动到远程仓库 # 如果你还没有克隆现有仓库,并欲将你的仓库连接到某个远程服务器,你可以使用如下命令添加:git remote add origin \u0026lt;server\u0026gt; ,比如我们要让本地的一个仓库和 GitHub 上创建的一个仓库关联可以这样git remote add origin https://github.com/Snailclimb/test.git\n将这些改动提交到远端仓库:git push origin master (可以把 master 换成你想要推送的任何分支)\n如此你就能够将你的改动推送到所添加的服务器上去了。\n远程仓库的移除与重命名 # 将 test 重命名为 test1:git remote rename test test1 移除远程仓库 test1:git remote rm test1 查看提交历史 # 在提交了若干更新,又或者克隆了某个项目之后,你也许想回顾下提交历史。 完成这个任务最简单而又有效的工具是 git log 命令。git log 会按提交时间列出所有的更新,最近的更新排在最上面。\n可以添加一些参数来查看自己希望看到的内容:\n只看某个人的提交记录:\ngit log --author=bob 撤销操作 # 有时候我们提交完了才发现漏掉了几个文件没有添加,或者提交信息写错了。 此时,可以运行带有 --amend 选项的提交命令尝试重新提交:\ngit commit --amend 取消暂存的文件\ngit reset filename 撤消对文件的修改:\ngit checkout -- filename 假如你想丢弃你在本地的所有改动与提交,可以到服务器上获取最新的版本历史,并将你本地主分支指向它:\ngit fetch origin git reset --hard origin/master 分支 # 分支是用来将特性开发绝缘开来的。在你创建仓库的时候,master 是“默认”的分支。在其他分支上进行开发,完成后再将它们合并到主分支上。\n我们通常在开发新功能、修复一个紧急 bug 等等时候会选择创建分支。单分支开发好还是多分支开发好,还是要看具体场景来说。\n创建一个名字叫做 test 的分支\ngit branch test 切换当前分支到 test(当你切换分支的时候,Git 会重置你的工作目录,使其看起来像回到了你在那个分支上最后一次提交的样子。 Git 会自动添加、删除、修改文件以确保此时你的工作目录和这个分支最后一次提交时的样子一模一样)\ngit checkout test 你也可以直接这样创建分支并切换过去(上面两条命令的合写)\ngit checkout -b feature_x 切换到主分支\ngit checkout master 合并分支(可能会有冲突)\ngit merge test 把新建的分支删掉\ngit branch -d feature_x 将分支推送到远端仓库(推送成功后其他人可见):\ngit push origin 学习资料推荐 # 在线演示学习工具:\n「补充,来自 issue729」Learn Git Branching https://oschina.gitee.io/learn-git-branching/ 。该网站可以方便的演示基本的 git 操作,讲解得明明白白。每一个基本命令的作用和结果。\n推荐阅读:\nGit 入门图文教程(1.5W 字 40 图):超用心的一篇文章,内容全面且附带详细的图解,强烈推荐! Git - 简明指南:涵盖 Git 常见操作,非常清晰。 图解 Git:图解 Git 中的最常用命令。如果你稍微理解 git 的工作原理,这篇文章能够让你理解的更透彻。 猴子都能懂得 Git 入门:有趣的讲解。 Pro Git book:国外的一本 Git 书籍,被翻译成多国语言,质量很高。 "},{"id":477,"href":"/zh/docs/technology/Interview/distributed-system/protocol/gossip-protocl/","title":"Gossip 协议详解","section":"Protocol","content":" 背景 # 在分布式系统中,不同的节点进行数据/信息共享是一个基本的需求。\n一种比较简单粗暴的方法就是 集中式发散消息,简单来说就是一个主节点同时共享最新信息给其他所有节点,比较适合中心化系统。这种方法的缺陷也很明显,节点多的时候不光同步消息的效率低,还太依赖与中心节点,存在单点风险问题。\n于是,分散式发散消息 的 Gossip 协议 就诞生了。\nGossip 协议介绍 # Gossip 直译过来就是闲话、流言蜚语的意思。流言蜚语有什么特点呢?容易被传播且传播速度还快,你传我我传他,然后大家都知道了。\nGossip 协议 也叫 Epidemic 协议(流行病协议)或者 Epidemic propagation 算法(疫情传播算法),别名很多。不过,这些名字的特点都具有 随机传播特性 (联想一下病毒传播、癌细胞扩散等生活中常见的情景),这也正是 Gossip 协议最主要的特点。\nGossip 协议最早是在 ACM 上的一篇 1987 年发表的论文 《Epidemic Algorithms for Replicated Database Maintenance》中被提出的。根据论文标题,我们大概就能知道 Gossip 协议当时提出的主要应用是在分布式数据库系统中各个副本节点同步数据。\n正如 Gossip 协议其名一样,这是一种随机且带有传染性的方式将信息传播到整个网络中,并在一定时间内,使得系统内的所有节点数据一致。\n在 Gossip 协议下,没有所谓的中心节点,每个节点周期性地随机找一个节点互相同步彼此的信息,理论上来说,各个节点的状态最终会保持一致。\n下面我们来对 Gossip 协议的定义做一个总结:Gossip 协议是一种允许在分布式系统中共享状态的去中心化通信协议,通过这种通信协议,我们可以将信息传播给网络或集群中的所有成员。\nGossip 协议应用 # NoSQL 数据库 Redis 和 Apache Cassandra、服务网格解决方案 Consul 等知名项目都用到了 Gossip 协议,学习 Gossip 协议有助于我们搞清很多技术的底层原理。\n我们这里以 Redis Cluster 为例说明 Gossip 协议的实际应用。\n我们经常使用的分布式缓存 Redis 的官方集群解决方案(3.0 版本引入) Redis Cluster 就是基于 Gossip 协议来实现集群中各个节点数据的最终一致性。\nRedis Cluster 是一个典型的分布式系统,分布式系统中的各个节点需要互相通信。既然要相互通信就要遵循一致的通信协议,Redis Cluster 中的各个节点基于 Gossip 协议 来进行通信共享信息,每个 Redis 节点都维护了一份集群的状态信息。\nRedis Cluster 的节点之间会相互发送多种 Gossip 消息:\nMEET:在 Redis Cluster 中的某个 Redis 节点上执行 CLUSTER MEET ip port 命令,可以向指定的 Redis 节点发送一条 MEET 信息,用于将其添加进 Redis Cluster 成为新的 Redis 节点。 PING/PONG:Redis Cluster 中的节点都会定时地向其他节点发送 PING 消息,来交换各个节点状态信息,检查各个节点状态,包括在线状态、疑似下线状态 PFAIL 和已下线状态 FAIL。 FAIL:Redis Cluster 中的节点 A 发现 B 节点 PFAIL ,并且在下线报告的有效期限内集群中半数以上的节点将 B 节点标记为 PFAIL,节点 A 就会向集群广播一条 FAIL 消息,通知其他节点将故障节点 B 标记为 FAIL 。 …… 下图就是主从架构的 Redis Cluster 的示意图,图中的虚线代表的就是各个节点之间使用 Gossip 进行通信 ,实线表示主从复制。\n有了 Redis Cluster 之后,不需要专门部署 Sentinel 集群服务了。Redis Cluster 相当于是内置了 Sentinel 机制,Redis Cluster 内部的各个 Redis 节点通过 Gossip 协议共享集群内信息。\n关于 Redis Cluster 的详细介绍,可以查看这篇文章 Redis 集群详解(付费) 。\nGossip 协议消息传播模式 # Gossip 设计了两种可能的消息传播模式:反熵(Anti-Entropy) 和 传谣(Rumor-Mongering)。\n反熵(Anti-entropy) # 根据维基百科:\n熵的概念最早起源于 物理学,用于度量一个热力学系统的混乱程度。熵最好理解为不确定性的量度而不是确定性的量度,因为越随机的信源的熵越大。\n在这里,你可以把反熵中的熵理解为节点之间数据的混乱程度/差异性,反熵就是指消除不同节点中数据的差异,提升节点间数据的相似度,从而降低熵值。\n具体是如何反熵的呢?集群中的节点,每隔段时间就随机选择某个其他节点,然后通过互相交换自己的所有数据来消除两者之间的差异,实现数据的最终一致性。\n在实现反熵的时候,主要有推、拉和推拉三种方式:\n推方式,就是将自己的所有副本数据,推给对方,修复对方副本中的熵。 拉方式,就是拉取对方的所有副本数据,修复自己副本中的熵。 推拉就是同时修复自己副本和对方副本中的熵。 伪代码如下:\n在我们实际应用场景中,一般不会采用随机的节点进行反熵,而是可以设计成一个闭环。这样的话,我们能够在一个确定的时间范围内实现各个节点数据的最终一致性,而不是基于随机的概率。像 InfluxDB 就是这样来实现反熵的。\n节点 A 推送数据给节点 B,节点 B 获取到节点 A 中的最新数据。 节点 B 推送数据给 C,节点 C 获取到节点 A,B 中的最新数据。 节点 C 推送数据给 A,节点 A 获取到节点 B,C 中的最新数据。 节点 A 再推送数据给 B 形成闭环,这样节点 B 就获取到节点 C 中的最新数据。 虽然反熵很简单实用,但是,节点过多或者节点动态变化的话,反熵就不太适用了。这个时候,我们想要实现最终一致性就要靠 谣言传播(Rumor mongering) 。\n谣言传播(Rumor mongering) # 谣言传播指的是分布式系统中的一个节点一旦有了新数据之后,就会变为活跃节点,活跃节点会周期性地联系其他节点向其发送新数据,直到所有的节点都存储了该新数据。\n如下图所示(下图来自于 INTRODUCTION TO GOSSIP 这篇文章):\n伪代码如下:\n谣言传播比较适合节点数量比较多的情况,不过,这种模式下要尽量避免传播的信息包不能太大,避免网络消耗太大。\n总结 # 反熵(Anti-Entropy)会传播节点的所有数据,而谣言传播(Rumor-Mongering)只会传播节点新增的数据。 我们一般会给反熵设计一个闭环。 谣言传播(Rumor-Mongering)比较适合节点数量比较多或者节点动态变化的场景。 Gossip 协议优势和缺陷 # 优势:\n1、相比于其他分布式协议/算法来说,Gossip 协议理解起来非常简单。\n2、能够容忍网络上节点的随意地增加或者减少,宕机或者重启,因为 Gossip 协议下这些节点都是平等的,去中心化的。新增加或者重启的节点在理想情况下最终是一定会和其他节点的状态达到一致。\n3、速度相对较快。节点数量比较多的情况下,扩散速度比一个主节点向其他节点传播信息要更快(多播)。\n缺陷 :\n1、消息需要通过多个传播的轮次才能传播到整个网络中,因此,必然会出现各节点状态不一致的情况。毕竟,Gossip 协议强调的是最终一致,至于达到各个节点的状态一致需要多长时间,谁也无从得知。\n2、由于拜占庭将军问题,不允许存在恶意节点。\n3、可能会出现消息冗余的问题。由于消息传播的随机性,同一个节点可能会重复收到相同的消息。\n总结 # Gossip 协议是一种允许在分布式系统中共享状态的通信协议,通过这种通信协议,我们可以将信息传播给网络或集群中的所有成员。 Gossip 协议被 Redis、Apache Cassandra、Consul 等项目应用。 谣言传播(Rumor-Mongering)比较适合节点数量比较多或者节点动态变化的场景。 参考 # 一万字详解 Redis Cluster Gossip 协议: https://segmentfault.com/a/1190000038373546 《分布式协议与算法实战》 《Redis 设计与实现》 "},{"id":478,"href":"/zh/docs/technology/Interview/tools/gradle/gradle-core-concepts/","title":"Gradle核心概念总结","section":"Gradle","content":" 这部分内容主要根据 Gradle 官方文档整理,做了对应的删减,主要保留比较重要的部分,不涉及实战,主要是一些重要概念的介绍。\nGradle 这部分内容属于可选内容,可以根据自身需求决定是否学习,目前国内还是使用 Maven 普遍一些。\nGradle 介绍 # Gradle 官方文档是这样介绍的 Gradle 的:\nGradle is an open-source build automation tool flexible enough to build almost any type of software. Gradle makes few assumptions about what you’re trying to build or how to build it. This makes Gradle particularly flexible.\nGradle 是一个开源的构建自动化工具,它足够灵活,可以构建几乎任何类型的软件。Gradle 对你要构建什么或者如何构建它做了很少的假设。这使得 Gradle 特别灵活。\n简单来说,Gradle 就是一个运行在 JVM 上的自动化的项目构建工具,用来帮助我们自动构建项目。\n对于开发者来说,Gradle 的主要作用主要有 3 个:\n项目构建:提供标准的、跨平台的自动化项目构建方式。 依赖管理:方便快捷的管理项目依赖的资源(jar 包),避免资源间的版本冲突问题。 统一开发结构:提供标准的、统一的项目结构。 Gradle 构建脚本是使用 Groovy 或 Kotlin 语言编写的,表达能力非常强,也足够灵活。\nGroovy 介绍 # Gradle 是运行在 JVM 上的一个程序,它可以使用 Groovy 来编写构建脚本。\nGroovy 是运行在 JVM 上的脚本语言,是基于 Java 扩展的动态语言,它的语法和 Java 非常的相似,可以使用 Java 的类库。Groovy 可以用于面向对象编程,也可以用作纯粹的脚本语言。在语言的设计上它吸纳了 Java、Python、Ruby 和 Smalltalk 语言的优秀特性,比如动态类型转换、闭包和元编程支持。\n我们可以用学习 Java 的方式去学习 Groovy ,学习成本相对来说还是比较低的,即使开发过程中忘记 Groovy 语法,也可以用 Java 语法继续编码。\n基于 JVM 的语言有很多种比如 Groovy,Kotlin,Java,Scala,他们最终都会编译生成 Java 字节码文件并在 JVM 上运行。\nGradle 优势 # Gradle 是新一代的构建系统,具有高效和灵活等诸多优势,广泛用于 Java 开发。不仅 Android 将其作为官方构建系统, 越来越多的 Java 项目比如 Spring Boot 也慢慢迁移到 Gradle。\n在灵活性上,Gradle 支持基于 Groovy 语言编写脚本,侧重于构建过程的灵活性,适合于构建复杂度较高的项目,可以完成非常复杂的构建。 在粒度性上,Gradle 构建的粒度细化到了每一个 task 之中。并且它所有的 Task 源码都是开源的,在我们掌握了这一整套打包流程后,我们就可以通过去修改它的 Task 去动态改变其执行流程。 在扩展性上,Gradle 支持插件机制,所以我们可以复用这些插件,就如同复用库一样简单方便。 Gradle Wrapper 介绍 # Gradle 官方文档是这样介绍的 Gradle Wrapper 的:\nThe recommended way to execute any Gradle build is with the help of the Gradle Wrapper (in short just “Wrapper”). The Wrapper is a script that invokes a declared version of Gradle, downloading it beforehand if necessary. As a result, developers can get up and running with a Gradle project quickly without having to follow manual installation processes saving your company time and money.\n执行 Gradle 构建的推荐方法是借助 Gradle Wrapper(简而言之就是“Wrapper”)。Wrapper 它是一个脚本,调用了已经声明的 Gradle 版本,如果需要的话,可以预先下载它。因此,开发人员可以快速启动并运行 Gradle 项目,而不必遵循手动安装过程,从而为公司节省时间和金钱。\n我们可以称 Gradle Wrapper 为 Gradle 包装器,它将 Gradle 再次包装,让所有的 Gradle 构建方法在 Gradle 包装器的帮助下运行。\nGradle Wrapper 的工作流程图如下(图源 Gradle Wrapper 官方文档介绍):\n整个流程主要分为下面 3 步:\n首先当我们刚创建的时候,如果指定的版本没有被下载,就先会去 Gradle 的服务器中下载对应版本的压缩包; 下载完成后需要先进行解压缩并且执行批处理文件; 后续项目每次构建都会重用这个解压过的 Gradle 版本。 Gradle Wrapper 会给我们带来下面这些好处:\n在给定的 Gradle 版本上标准化项目,从而实现更可靠和健壮的构建。 可以让我们的电脑中不安装 Gradle 环境也可以运行 Gradle 项目。 为不同的用户和执行环境(例如 IDE 或持续集成服务器)提供新的 Gradle 版本就像更改 Wrapper 定义一样简单。 生成 Gradle Wrapper # 如果想要生成 Gradle Wrapper 的话,需要本地配置好 Gradle 环境变量。Gradle 中已经内置了 Wrapper Task,在项目根目录执行执行gradle wrapper命令即可帮助我们生成 Gradle Wrapper。\n执行命令 gradle wrapper 命令时可以指定一些参数来控制 wrapper 的生成。具体有如下两个配置参数:\n--gradle-version 用于指定使用的 Gradle 的版本 --gradle-distribution-url 用于指定下载 Gradle 版本的 URL,该值的规则是 http://services.gradle.org/distributions/gradle-${gradleVersion}-bin.zip 执行gradle wrapper命令之后,Gradle Wrapper 就生成完成了,项目根目录中生成如下文件:\n├── gradle │ └── wrapper │ ├── gradle-wrapper.jar │ └── gradle-wrapper.properties ├── gradlew └── gradlew.bat 每个文件的含义如下:\ngradle-wrapper.jar:包含了 Gradle 运行时的逻辑代码。 gradle-wrapper.properties:定义了 Gradle 的版本号和 Gradle 运行时的行为属性。 gradlew:Linux 平台下,用于执行 Gralde 命令的包装器脚本。 gradlew.bat:Windows 平台下,用于执行 Gralde 命令的包装器脚本。 gradle-wrapper.properties 文件的内容如下:\ndistributionBase=GRADLE_USER_HOME distributionPath=wrapper/dists distributionUrl=https\\://services.gradle.org/distributions/gradle-6.0.1-bin.zip zipStoreBase=GRADLE_USER_HOME zipStorePath=wrapper/dists distributionBase:Gradle 解包后存储的父目录。 distributionPath:distributionBase指定目录的子目录。distributionBase+distributionPath就是 Gradle 解包后的存放的具体目录。 distributionUrl:Gradle 指定版本的压缩包下载地址。 zipStoreBase:Gradle 压缩包下载后存储父目录。 zipStorePath:zipStoreBase指定目录的子目录。zipStoreBase+zipStorePath就是 Gradle 压缩包的存放位置。 更新 Gradle Wrapper # 更新 Gradle Wrapper 有 2 种方式:\n接修改distributionUrl字段,然后执行 Gradle 命令。 执行 gradlew 命令gradlew wrapper –-gradle-version [version]。 下面的命令会将 Gradle 版本升级为 7.6。\ngradlew wrapper --gradle-version 7.6 gradle-wrapper.properties 文件中的 distributionUrl 属性也发生了改变。\ndistributionUrl=https\\://services.gradle.org/distributions/gradle-7.6-all.zip 自定义 Gradle Wrapper # Gradle 已经内置了 Wrapper Task,因此构建 Gradle Wrapper 会生成 Gradle Wrapper 的属性文件,这个属性文件可以通过自定义 Wrapper Task 来设置。比如我们想要修改要下载的 Gralde 版本为 7.6,可以这么设置:\ntask wrapper(type: Wrapper) { gradleVersion = \u0026#39;7.6\u0026#39; } 也可以设置 Gradle 发行版压缩包的下载地址和 Gradle 解包后的本地存储路径等配置。\ntask wrapper(type: Wrapper) { gradleVersion = \u0026#39;7.6\u0026#39; distributionUrl = \u0026#39;../../gradle-7.6-bin.zip\u0026#39; distributionPath=wrapper/dists } distributionUrl 属性可以设置为本地的项目目录,你也可以设置为网络地址。\nGradle 任务 # 在 Gradle 中,任务(Task)是构建执行的单个工作单元。\nGradle 的构建是基于 Task 进行的,当你运行项目的时候,实际就是在执行了一系列的 Task 比如编译 Java 源码的 Task、生成 jar 文件的 Task。\nTask 的声明方式如下(还有其他几种声明方式):\n// 声明一个名字为 helloTask 的 Task task helloTask{ doLast{ println \u0026#34;Hello\u0026#34; } } 创建一个 Task 后,可以根据需要给 Task 添加不同的 Action,上面的“doLast”就是给队列尾增加一个 Action。\n//在Action 队列头部添加Action Task doFirst(Action\u0026lt;? super Task\u0026gt; action); Task doFirst(Closure action); //在Action 队列尾部添加Action Task doLast(Action\u0026lt;? super Task\u0026gt; action); Task doLast(Closure action); //删除所有的Action Task deleteAllActions(); 一个 Task 中可以有多个 Acton,从队列头部开始向队列尾部执行 Acton。\nAction 代表的是一个个函数、方法,每个 Task 都是一堆 Action 按序组成的执行图。\nTask 声明依赖的关键字是dependsOn,支持声明一个或多个依赖:\ntask first { doLast { println \u0026#34;+++++first+++++\u0026#34; } } task second { doLast { println \u0026#34;+++++second+++++\u0026#34; } } // 指定多个 task 依赖 task print(dependsOn :[second,first]) { doLast { logger.quiet \u0026#34;指定多个task依赖\u0026#34; } } // 指定一个 task 依赖 task third(dependsOn : print) { doLast { println \u0026#39;+++++third+++++\u0026#39; } } 执行 Task 之前,会先执行它的依赖 Task。\n我们还可以设置默认 Task,脚本中我们不调用默认 Task ,也会执行。\ndefaultTasks \u0026#39;clean\u0026#39;, \u0026#39;run\u0026#39; task clean { doLast { println \u0026#39;Default Cleaning!\u0026#39; } } task run { doLast { println \u0026#39;Default Running!\u0026#39; } } Gradle 本身也内置了很多 Task 比如 copy(复制文件)、delete(删除文件)。\ntask deleteFile(type: Delete) { delete \u0026#34;C:\\\\Users\\\\guide\\\\Desktop\\\\test\u0026#34; } Gradle 插件 # Gradle 提供的是一套核心的构建机制,而 Gradle 插件则是运行在这套机制上的一些具体构建逻辑,其本质上和 .gradle 文件是相同。你可以将 Gradle 插件看作是封装了一系列 Task 并执行的工具。\nGradle 插件主要分为两类:\n脚本插件:脚本插件就是一个普通的脚本文件,它可以被导入都其他构建脚本中。 二进制插件 / 对象插件:在一个单独的插件模块中定义,其他模块通过 Plugin ID 应用插件。因为这种方式发布和复用更加友好,我们一般接触到的 Gradle 插件都是指二进制插件的形式。 虽然 Gradle 插件与 .gradle 文件本质上没有区别,.gradle 文件也能实现 Gradle 插件类似的功能。但是,Gradle 插件使用了独立模块封装构建逻辑,无论是从开发开始使用来看,Gradle 插件的整体体验都更友好。\n逻辑复用: 将相同的逻辑提供给多个相似项目复用,减少重复维护类似逻辑开销。当然 .gradle 文件也能做到逻辑复用,但 Gradle 插件的封装性更好; 组件发布: 可以将插件发布到 Maven 仓库进行管理,其他项目可以使用插件 ID 依赖。当然 .gradle 文件也可以放到一个远程路径被其他项目引用; 构建配置: Gradle 插件可以声明插件扩展来暴露可配置的属性,提供定制化能力。当然 .gradle 文件也可以做到,但实现会麻烦些。 Gradle 构建生命周期 # Gradle 构建的生命周期有三个阶段:初始化阶段,配置阶段和运行阶段。\n在初始化阶段与配置阶段之间、配置阶段结束之后、执行阶段结束之后,我们都可以加一些定制化的 Hook。\n初始化阶段 # Gradle 支持单项目和多项目构建。在初始化阶段,Gradle 确定哪些项目将参与构建,并为每个项目创建一个 Project 实例 。本质上也就是执行 settings.gradle 脚本,从而读取整个项目中有多少个 Project 实例。\n配置阶段 # 在配置阶段,Gradle 会解析每个工程的 build.gradle 文件,创建要执行的任务子集和确定各种任务之间的关系,以供执行阶段按照顺序执行,并对任务的做一些初始化配置。\n每个 build.gradle 对应一个 Project 对象,配置阶段执行的代码包括 build.gradle 中的各种语句、闭包以及 Task 中的配置语句。\n在配置阶段结束后,Gradle 会根据 Task 的依赖关系会创建一个 有向无环图 。\n运行阶段 # 在运行阶段,Gradle 根据配置阶段创建和配置的要执行的任务子集,执行任务。\n参考 # Gradle 官方文档: https://docs.gradle.org/current/userguide/userguide.html Gradle 入门教程: https://www.imooc.com/wiki/gradlebase Groovy 快速入门看这篇就够了: https://cloud.tencent.com/developer/article/1358357 【Gradle】Gradle 的生命周期详解: https://juejin.cn/post/7067719629874921508 手把手带你自定义 Gradle 插件 —— Gradle 系列(2): https://www.cnblogs.com/pengxurui/p/16281537.html Gradle 爬坑指南 \u0026ndash; 理解 Plugin、Task、构建流程: https://juejin.cn/post/6889090530593112077 "},{"id":479,"href":"/zh/docs/technology/Interview/java/collection/hashmap-source-code/","title":"HashMap 源码分析","section":"Collection","content":" 感谢 changfubai 对本文的改进做出的贡献!\nHashMap 简介 # HashMap 主要用来存放键值对,它基于哈希表的 Map 接口实现,是常用的 Java 集合之一,是非线程安全的。\nHashMap 可以存储 null 的 key 和 value,但 null 作为键只能有一个,null 作为值可以有多个\nJDK1.8 之前 HashMap 由 数组+链表 组成的,数组是 HashMap 的主体,链表则是主要为了解决哈希冲突而存在的(“拉链法”解决冲突)。 JDK1.8 以后的 HashMap 在解决哈希冲突时有了较大的变化,当链表长度大于等于阈值(默认为 8)(将链表转换成红黑树前会判断,如果当前数组的长度小于 64,那么会选择先进行数组扩容,而不是转换为红黑树)时,将链表转化为红黑树,以减少搜索时间。\nHashMap 默认的初始化大小为 16。之后每次扩充,容量变为原来的 2 倍。并且, HashMap 总是使用 2 的幂作为哈希表的大小。\n底层数据结构分析 # JDK1.8 之前 # JDK1.8 之前 HashMap 底层是 数组和链表 结合在一起使用也就是 链表散列。\nHashMap 通过 key 的 hashCode 经过扰动函数处理过后得到 hash 值,然后通过 (n - 1) \u0026amp; hash 判断当前元素存放的位置(这里的 n 指的是数组的长度),如果当前位置存在元素的话,就判断该元素与要存入的元素的 hash 值以及 key 是否相同,如果相同的话,直接覆盖,不相同就通过拉链法解决冲突。\n所谓扰动函数指的就是 HashMap 的 hash 方法。使用 hash 方法也就是扰动函数是为了防止一些实现比较差的 hashCode() 方法 换句话说使用扰动函数之后可以减少碰撞。\nJDK 1.8 HashMap 的 hash 方法源码:\nJDK 1.8 的 hash 方法 相比于 JDK 1.7 hash 方法更加简化,但是原理不变。\nstatic final int hash(Object key) { int h; // key.hashCode():返回散列值也就是hashcode // ^:按位异或 // \u0026gt;\u0026gt;\u0026gt;:无符号右移,忽略符号位,空位都以0补齐 return (key == null) ? 0 : (h = key.hashCode()) ^ (h \u0026gt;\u0026gt;\u0026gt; 16); } 对比一下 JDK1.7 的 HashMap 的 hash 方法源码.\nstatic int hash(int h) { // This function ensures that hashCodes that differ only by // constant multiples at each bit position have a bounded // number of collisions (approximately 8 at default load factor). h ^= (h \u0026gt;\u0026gt;\u0026gt; 20) ^ (h \u0026gt;\u0026gt;\u0026gt; 12); return h ^ (h \u0026gt;\u0026gt;\u0026gt; 7) ^ (h \u0026gt;\u0026gt;\u0026gt; 4); } 相比于 JDK1.8 的 hash 方法 ,JDK 1.7 的 hash 方法的性能会稍差一点点,因为毕竟扰动了 4 次。\n所谓 “拉链法” 就是:将链表和数组相结合。也就是说创建一个链表数组,数组中每一格就是一个链表。若遇到哈希冲突,则将冲突的值加到链表中即可。\nJDK1.8 之后 # 相比于之前的版本,JDK1.8 以后在解决哈希冲突时有了较大的变化。\n当链表长度大于阈值(默认为 8)时,会首先调用 treeifyBin()方法。这个方法会根据 HashMap 数组来决定是否转换为红黑树。只有当数组长度大于或者等于 64 的情况下,才会执行转换红黑树操作,以减少搜索时间。否则,就是只是执行 resize() 方法对数组扩容。相关源码这里就不贴了,重点关注 treeifyBin()方法即可!\n类的属性:\npublic class HashMap\u0026lt;K,V\u0026gt; extends AbstractMap\u0026lt;K,V\u0026gt; implements Map\u0026lt;K,V\u0026gt;, Cloneable, Serializable { // 序列号 private static final long serialVersionUID = 362498820763181265L; // 默认的初始容量是16 static final int DEFAULT_INITIAL_CAPACITY = 1 \u0026lt;\u0026lt; 4; // 最大容量 static final int MAXIMUM_CAPACITY = 1 \u0026lt;\u0026lt; 30; // 默认的负载因子 static final float DEFAULT_LOAD_FACTOR = 0.75f; // 当桶(bucket)上的结点数大于等于这个值时会转成红黑树 static final int TREEIFY_THRESHOLD = 8; // 当桶(bucket)上的结点数小于等于这个值时树转链表 static final int UNTREEIFY_THRESHOLD = 6; // 桶中结构转化为红黑树对应的table的最小容量 static final int MIN_TREEIFY_CAPACITY = 64; // 存储元素的数组,总是2的幂次倍 transient Node\u0026lt;k,v\u0026gt;[] table; // 一个包含了映射中所有键值对的集合视图 transient Set\u0026lt;map.entry\u0026lt;k,v\u0026gt;\u0026gt; entrySet; // 存放元素的个数,注意这个不等于数组的长度。 transient int size; // 每次扩容和更改map结构的计数器 transient int modCount; // 阈值(容量*负载因子) 当实际大小超过阈值时,会进行扩容 int threshold; // 负载因子 final float loadFactor; } loadFactor 负载因子\nloadFactor 负载因子是控制数组存放数据的疏密程度,loadFactor 越趋近于 1,那么 数组中存放的数据(entry)也就越多,也就越密,也就是会让链表的长度增加,loadFactor 越小,也就是趋近于 0,数组中存放的数据(entry)也就越少,也就越稀疏。\nloadFactor 太大导致查找元素效率低,太小导致数组的利用率低,存放的数据会很分散。loadFactor 的默认值为 0.75f 是官方给出的一个比较好的临界值。\n给定的默认容量为 16,负载因子为 0.75。Map 在使用过程中不断的往里面存放数据,当数量超过了 16 * 0.75 = 12 就需要将当前 16 的容量进行扩容,而扩容这个过程涉及到 rehash、复制数据等操作,所以非常消耗性能。\nthreshold\nthreshold = capacity * loadFactor,当 Size\u0026gt;threshold的时候,那么就要考虑对数组的扩增了,也就是说,这个的意思就是 衡量数组是否需要扩增的一个标准。\nNode 节点类源码:\n// 继承自 Map.Entry\u0026lt;K,V\u0026gt; static class Node\u0026lt;K,V\u0026gt; implements Map.Entry\u0026lt;K,V\u0026gt; { final int hash;// 哈希值,存放元素到hashmap中时用来与其他元素hash值比较 final K key;//键 V value;//值 // 指向下一个节点 Node\u0026lt;K,V\u0026gt; next; Node(int hash, K key, V value, Node\u0026lt;K,V\u0026gt; next) { this.hash = hash; this.key = key; this.value = value; this.next = next; } public final K getKey() { return key; } public final V getValue() { return value; } public final String toString() { return key + \u0026#34;=\u0026#34; + value; } // 重写hashCode()方法 public final int hashCode() { return Objects.hashCode(key) ^ Objects.hashCode(value); } public final V setValue(V newValue) { V oldValue = value; value = newValue; return oldValue; } // 重写 equals() 方法 public final boolean equals(Object o) { if (o == this) return true; if (o instanceof Map.Entry) { Map.Entry\u0026lt;?,?\u0026gt; e = (Map.Entry\u0026lt;?,?\u0026gt;)o; if (Objects.equals(key, e.getKey()) \u0026amp;\u0026amp; Objects.equals(value, e.getValue())) return true; } return false; } } 树节点类源码:\nstatic final class TreeNode\u0026lt;K,V\u0026gt; extends LinkedHashMap.Entry\u0026lt;K,V\u0026gt; { TreeNode\u0026lt;K,V\u0026gt; parent; // 父 TreeNode\u0026lt;K,V\u0026gt; left; // 左 TreeNode\u0026lt;K,V\u0026gt; right; // 右 TreeNode\u0026lt;K,V\u0026gt; prev; // needed to unlink next upon deletion boolean red; // 判断颜色 TreeNode(int hash, K key, V val, Node\u0026lt;K,V\u0026gt; next) { super(hash, key, val, next); } // 返回根节点 final TreeNode\u0026lt;K,V\u0026gt; root() { for (TreeNode\u0026lt;K,V\u0026gt; r = this, p;;) { if ((p = r.parent) == null) return r; r = p; } HashMap 源码分析 # 构造方法 # HashMap 中有四个构造方法,它们分别如下:\n// 默认构造函数。 public HashMap() { this.loadFactor = DEFAULT_LOAD_FACTOR; // all other fields defaulted } // 包含另一个“Map”的构造函数 public HashMap(Map\u0026lt;? extends K, ? extends V\u0026gt; m) { this.loadFactor = DEFAULT_LOAD_FACTOR; putMapEntries(m, false);//下面会分析到这个方法 } // 指定“容量大小”的构造函数 public HashMap(int initialCapacity) { this(initialCapacity, DEFAULT_LOAD_FACTOR); } // 指定“容量大小”和“负载因子”的构造函数 public HashMap(int initialCapacity, float loadFactor) { if (initialCapacity \u0026lt; 0) throw new IllegalArgumentException(\u0026#34;Illegal initial capacity: \u0026#34; + initialCapacity); if (initialCapacity \u0026gt; MAXIMUM_CAPACITY) initialCapacity = MAXIMUM_CAPACITY; if (loadFactor \u0026lt;= 0 || Float.isNaN(loadFactor)) throw new IllegalArgumentException(\u0026#34;Illegal load factor: \u0026#34; + loadFactor); this.loadFactor = loadFactor; // 初始容量暂时存放到 threshold ,在resize中再赋值给 newCap 进行table初始化 this.threshold = tableSizeFor(initialCapacity); } 值得注意的是上述四个构造方法中,都初始化了负载因子 loadFactor,由于 HashMap 中没有 capacity 这样的字段,即使指定了初始化容量 initialCapacity ,也只是通过 tableSizeFor 将其扩容到与 initialCapacity 最接近的 2 的幂次方大小,然后暂时赋值给 threshold ,后续通过 resize 方法将 threshold 赋值给 newCap 进行 table 的初始化。\nputMapEntries 方法:\nfinal void putMapEntries(Map\u0026lt;? extends K, ? extends V\u0026gt; m, boolean evict) { int s = m.size(); if (s \u0026gt; 0) { // 判断table是否已经初始化 if (table == null) { // pre-size /* * 未初始化,s为m的实际元素个数,ft=s/loadFactor =\u0026gt; s=ft*loadFactor, 跟我们前面提到的 * 阈值=容量*负载因子 是不是很像,是的,ft指的是要添加s个元素所需的最小的容量 */ float ft = ((float)s / loadFactor) + 1.0F; int t = ((ft \u0026lt; (float)MAXIMUM_CAPACITY) ? (int)ft : MAXIMUM_CAPACITY); /* * 根据构造函数可知,table未初始化,threshold实际上是存放的初始化容量,如果添加s个元素所 * 需的最小容量大于初始化容量,则将最小容量扩容为最接近的2的幂次方大小作为初始化。 * 注意这里不是初始化阈值 */ if (t \u0026gt; threshold) threshold = tableSizeFor(t); } // 已初始化,并且m元素个数大于阈值,进行扩容处理 else if (s \u0026gt; threshold) resize(); // 将m中的所有元素添加至HashMap中,如果table未初始化,putVal中会调用resize初始化或扩容 for (Map.Entry\u0026lt;? extends K, ? extends V\u0026gt; e : m.entrySet()) { K key = e.getKey(); V value = e.getValue(); putVal(hash(key), key, value, false, evict); } } } put 方法 # HashMap 只提供了 put 用于添加元素,putVal 方法只是给 put 方法调用的一个方法,并没有提供给用户使用。\n对 putVal 方法添加元素的分析如下:\n如果定位到的数组位置没有元素 就直接插入。 如果定位到的数组位置有元素就和要插入的 key 比较,如果 key 相同就直接覆盖,如果 key 不相同,就判断 p 是否是一个树节点,如果是就调用e = ((TreeNode\u0026lt;K,V\u0026gt;)p).putTreeVal(this, tab, hash, key, value)将元素添加进入。如果不是就遍历链表插入(插入的是链表尾部)。 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\u0026lt;K,V\u0026gt;[] tab; Node\u0026lt;K,V\u0026gt; p; int n, i; // table未初始化或者长度为0,进行扩容 if ((tab = table) == null || (n = tab.length) == 0) n = (tab = resize()).length; // (n - 1) \u0026amp; hash 确定元素存放在哪个桶中,桶为空,新生成结点放入桶中(此时,这个结点是放在数组中) if ((p = tab[i = (n - 1) \u0026amp; hash]) == null) tab[i] = newNode(hash, key, value, null); // 桶中已经存在元素(处理hash冲突) else { Node\u0026lt;K,V\u0026gt; e; K k; //快速判断第一个节点table[i]的key是否与插入的key一样,若相同就直接使用插入的值p替换掉旧的值e。 if (p.hash == hash \u0026amp;\u0026amp; ((k = p.key) == key || (key != null \u0026amp;\u0026amp; key.equals(k)))) e = p; // 判断插入的是否是红黑树节点 else if (p instanceof TreeNode) // 放入树中 e = ((TreeNode\u0026lt;K,V\u0026gt;)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); // 结点数量达到阈值(默认为 8 ),执行 treeifyBin 方法 // 这个方法会根据 HashMap 数组来决定是否转换为红黑树。 // 只有当数组长度大于或者等于 64 的情况下,才会执行转换红黑树操作,以减少搜索时间。否则,就是只是对数组扩容。 if (binCount \u0026gt;= TREEIFY_THRESHOLD - 1) // -1 for 1st treeifyBin(tab, hash); // 跳出循环 break; } // 判断链表中结点的key值与插入的元素的key值是否相等 if (e.hash == hash \u0026amp;\u0026amp; ((k = e.key) == key || (key != null \u0026amp;\u0026amp; key.equals(k)))) // 相等,跳出循环 break; // 用于遍历桶中的链表,与前面的e = p.next组合,可以遍历链表 p = e; } } // 表示在桶中找到key值、hash值与插入元素相等的结点 if (e != null) { // 记录e的value V oldValue = e.value; // onlyIfAbsent为false或者旧值为null if (!onlyIfAbsent || oldValue == null) //用新值替换旧值 e.value = value; // 访问后回调 afterNodeAccess(e); // 返回旧值 return oldValue; } } // 结构性修改 ++modCount; // 实际大小大于阈值则扩容 if (++size \u0026gt; threshold) resize(); // 插入后回调 afterNodeInsertion(evict); return null; } 我们再来对比一下 JDK1.7 put 方法的代码\n对于 put 方法的分析如下:\n① 如果定位到的数组位置没有元素 就直接插入。 ② 如果定位到的数组位置有元素,遍历以这个元素为头结点的链表,依次和插入的 key 比较,如果 key 相同就直接覆盖,不同就采用头插法插入元素。 public V put(K key, V value) if (table == EMPTY_TABLE) { inflateTable(threshold); } if (key == null) return putForNullKey(value); int hash = hash(key); int i = indexFor(hash, table.length); for (Entry\u0026lt;K,V\u0026gt; e = table[i]; e != null; e = e.next) { // 先遍历 Object k; if (e.hash == hash \u0026amp;\u0026amp; ((k = e.key) == key || key.equals(k))) { V oldValue = e.value; e.value = value; e.recordAccess(this); return oldValue; } } modCount++; addEntry(hash, key, value, i); // 再插入 return null; } get 方法 # public V get(Object key) { Node\u0026lt;K,V\u0026gt; e; return (e = getNode(hash(key), key)) == null ? null : e.value; } final Node\u0026lt;K,V\u0026gt; getNode(int hash, Object key) { Node\u0026lt;K,V\u0026gt;[] tab; Node\u0026lt;K,V\u0026gt; first, e; int n; K k; if ((tab = table) != null \u0026amp;\u0026amp; (n = tab.length) \u0026gt; 0 \u0026amp;\u0026amp; (first = tab[(n - 1) \u0026amp; hash]) != null) { // 数组元素相等 if (first.hash == hash \u0026amp;\u0026amp; // always check first node ((k = first.key) == key || (key != null \u0026amp;\u0026amp; key.equals(k)))) return first; // 桶中不止一个节点 if ((e = first.next) != null) { // 在树中get if (first instanceof TreeNode) return ((TreeNode\u0026lt;K,V\u0026gt;)first).getTreeNode(hash, key); // 在链表中get do { if (e.hash == hash \u0026amp;\u0026amp; ((k = e.key) == key || (key != null \u0026amp;\u0026amp; key.equals(k)))) return e; } while ((e = e.next) != null); } } return null; } resize 方法 # 进行扩容,会伴随着一次重新 hash 分配,并且会遍历 hash 表中所有的元素,是非常耗时的。在编写程序中,要尽量避免 resize。resize 方法实际上是将 table 初始化和 table 扩容 进行了整合,底层的行为都是给 table 赋值一个新的数组。\nfinal Node\u0026lt;K,V\u0026gt;[] resize() { Node\u0026lt;K,V\u0026gt;[] oldTab = table; int oldCap = (oldTab == null) ? 0 : oldTab.length; int oldThr = threshold; int newCap, newThr = 0; if (oldCap \u0026gt; 0) { // 超过最大值就不再扩充了,就只好随你碰撞去吧 if (oldCap \u0026gt;= MAXIMUM_CAPACITY) { threshold = Integer.MAX_VALUE; return oldTab; } // 没超过最大值,就扩充为原来的2倍 else if ((newCap = oldCap \u0026lt;\u0026lt; 1) \u0026lt; MAXIMUM_CAPACITY \u0026amp;\u0026amp; oldCap \u0026gt;= DEFAULT_INITIAL_CAPACITY) newThr = oldThr \u0026lt;\u0026lt; 1; // double threshold } else if (oldThr \u0026gt; 0) // initial capacity was placed in threshold // 创建对象时初始化容量大小放在threshold中,此时只需要将其作为新的数组容量 newCap = oldThr; else { // signifies using defaults 无参构造函数创建的对象在这里计算容量和阈值 newCap = DEFAULT_INITIAL_CAPACITY; newThr = (int)(DEFAULT_LOAD_FACTOR * DEFAULT_INITIAL_CAPACITY); } if (newThr == 0) { // 创建时指定了初始化容量或者负载因子,在这里进行阈值初始化, // 或者扩容前的旧容量小于16,在这里计算新的resize上限 float ft = (float)newCap * loadFactor; newThr = (newCap \u0026lt; MAXIMUM_CAPACITY \u0026amp;\u0026amp; ft \u0026lt; (float)MAXIMUM_CAPACITY ? (int)ft : Integer.MAX_VALUE); } threshold = newThr; @SuppressWarnings({\u0026#34;rawtypes\u0026#34;,\u0026#34;unchecked\u0026#34;}) Node\u0026lt;K,V\u0026gt;[] newTab = (Node\u0026lt;K,V\u0026gt;[])new Node[newCap]; table = newTab; if (oldTab != null) { // 把每个bucket都移动到新的buckets中 for (int j = 0; j \u0026lt; oldCap; ++j) { Node\u0026lt;K,V\u0026gt; e; if ((e = oldTab[j]) != null) { oldTab[j] = null; if (e.next == null) // 只有一个节点,直接计算元素新的位置即可 newTab[e.hash \u0026amp; (newCap - 1)] = e; else if (e instanceof TreeNode) // 将红黑树拆分成2棵子树,如果子树节点数小于等于 UNTREEIFY_THRESHOLD(默认为 6),则将子树转换为链表。 // 如果子树节点数大于 UNTREEIFY_THRESHOLD,则保持子树的树结构。 ((TreeNode\u0026lt;K,V\u0026gt;)e).split(this, newTab, j, oldCap); else { Node\u0026lt;K,V\u0026gt; loHead = null, loTail = null; Node\u0026lt;K,V\u0026gt; hiHead = null, hiTail = null; Node\u0026lt;K,V\u0026gt; next; do { next = e.next; // 原索引 if ((e.hash \u0026amp; oldCap) == 0) { if (loTail == null) loHead = e; else loTail.next = e; loTail = e; } // 原索引+oldCap else { if (hiTail == null) hiHead = e; else hiTail.next = e; hiTail = e; } } while ((e = next) != null); // 原索引放到bucket里 if (loTail != null) { loTail.next = null; newTab[j] = loHead; } // 原索引+oldCap放到bucket里 if (hiTail != null) { hiTail.next = null; newTab[j + oldCap] = hiHead; } } } } } return newTab; } HashMap 常用方法测试 # package map; import java.util.Collection; import java.util.HashMap; import java.util.Set; public class HashMapDemo { public static void main(String[] args) { HashMap\u0026lt;String, String\u0026gt; map = new HashMap\u0026lt;String, String\u0026gt;(); // 键不能重复,值可以重复 map.put(\u0026#34;san\u0026#34;, \u0026#34;张三\u0026#34;); map.put(\u0026#34;si\u0026#34;, \u0026#34;李四\u0026#34;); map.put(\u0026#34;wu\u0026#34;, \u0026#34;王五\u0026#34;); map.put(\u0026#34;wang\u0026#34;, \u0026#34;老王\u0026#34;); map.put(\u0026#34;wang\u0026#34;, \u0026#34;老王2\u0026#34;);// 老王被覆盖 map.put(\u0026#34;lao\u0026#34;, \u0026#34;老王\u0026#34;); System.out.println(\u0026#34;-------直接输出hashmap:-------\u0026#34;); System.out.println(map); /** * 遍历HashMap */ // 1.获取Map中的所有键 System.out.println(\u0026#34;-------foreach获取Map中所有的键:------\u0026#34;); Set\u0026lt;String\u0026gt; keys = map.keySet(); for (String key : keys) { System.out.print(key+\u0026#34; \u0026#34;); } System.out.println();//换行 // 2.获取Map中所有值 System.out.println(\u0026#34;-------foreach获取Map中所有的值:------\u0026#34;); Collection\u0026lt;String\u0026gt; values = map.values(); for (String value : values) { System.out.print(value+\u0026#34; \u0026#34;); } System.out.println();//换行 // 3.得到key的值的同时得到key所对应的值 System.out.println(\u0026#34;-------得到key的值的同时得到key所对应的值:-------\u0026#34;); Set\u0026lt;String\u0026gt; keys2 = map.keySet(); for (String key : keys2) { System.out.print(key + \u0026#34;:\u0026#34; + map.get(key)+\u0026#34; \u0026#34;); } /** * 如果既要遍历key又要value,那么建议这种方式,因为如果先获取keySet然后再执行map.get(key),map内部会执行两次遍历。 * 一次是在获取keySet的时候,一次是在遍历所有key的时候。 */ // 当我调用put(key,value)方法的时候,首先会把key和value封装到 // Entry这个静态内部类对象中,把Entry对象再添加到数组中,所以我们想获取 // map中的所有键值对,我们只要获取数组中的所有Entry对象,接下来 // 调用Entry对象中的getKey()和getValue()方法就能获取键值对了 Set\u0026lt;java.util.Map.Entry\u0026lt;String, String\u0026gt;\u0026gt; entrys = map.entrySet(); for (java.util.Map.Entry\u0026lt;String, String\u0026gt; entry : entrys) { System.out.println(entry.getKey() + \u0026#34;--\u0026#34; + entry.getValue()); } /** * HashMap其他常用方法 */ System.out.println(\u0026#34;after map.size():\u0026#34;+map.size()); System.out.println(\u0026#34;after map.isEmpty():\u0026#34;+map.isEmpty()); System.out.println(map.remove(\u0026#34;san\u0026#34;)); System.out.println(\u0026#34;after map.remove():\u0026#34;+map); System.out.println(\u0026#34;after map.get(si):\u0026#34;+map.get(\u0026#34;si\u0026#34;)); System.out.println(\u0026#34;after map.containsKey(si):\u0026#34;+map.containsKey(\u0026#34;si\u0026#34;)); System.out.println(\u0026#34;after containsValue(李四):\u0026#34;+map.containsValue(\u0026#34;李四\u0026#34;)); System.out.println(map.replace(\u0026#34;si\u0026#34;, \u0026#34;李四2\u0026#34;)); System.out.println(\u0026#34;after map.replace(si, 李四2):\u0026#34;+map); } } "},{"id":480,"href":"/zh/docs/technology/Interview/cs-basics/network/http1.0-vs-http1.1/","title":"HTTP 1.0 vs HTTP 1.1(应用层)","section":"Network","content":"这篇文章会从下面几个维度来对比 HTTP 1.0 和 HTTP 1.1:\n响应状态码 缓存处理 连接方式 Host 头处理 带宽优化 响应状态码 # HTTP/1.0 仅定义了 16 种状态码。HTTP/1.1 中新加入了大量的状态码,光是错误响应状态码就新增了 24 种。比如说,100 (Continue)——在请求大资源前的预热请求,206 (Partial Content)——范围请求的标识码,409 (Conflict)——请求与当前资源的规定冲突,410 (Gone)——资源已被永久转移,而且没有任何已知的转发地址。\n缓存处理 # 缓存技术通过避免用户与源服务器的频繁交互,节约了大量的网络带宽,降低了用户接收信息的延迟。\nHTTP/1.0 # HTTP/1.0 提供的缓存机制非常简单。服务器端使用Expires标签来标志(时间)一个响应体,在Expires标志时间内的请求,都会获得该响应体缓存。服务器端在初次返回给客户端的响应体中,有一个Last-Modified标签,该标签标记了被请求资源在服务器端的最后一次修改。在请求头中,使用If-Modified-Since标签,该标签标志一个时间,意为客户端向服务器进行问询:“该时间之后,我要请求的资源是否有被修改过?”通常情况下,请求头中的If-Modified-Since的值即为上一次获得该资源时,响应体中的Last-Modified的值。\n如果服务器接收到了请求头,并判断If-Modified-Since时间后,资源确实没有修改过,则返回给客户端一个304 not modified响应头,表示”缓冲可用,你从浏览器里拿吧!”。\n如果服务器判断If-Modified-Since时间后,资源被修改过,则返回给客户端一个200 OK的响应体,并附带全新的资源内容,表示”你要的我已经改过的,给你一份新的”。\nHTTP/1.1 # HTTP/1.1 的缓存机制在 HTTP/1.0 的基础上,大大增加了灵活性和扩展性。基本工作原理和 HTTP/1.0 保持不变,而是增加了更多细致的特性。其中,请求头中最常见的特性就是Cache-Control,详见 MDN Web 文档 Cache-Control.\n连接方式 # HTTP/1.0 默认使用短连接 ,也就是说,客户端和服务器每进行一次 HTTP 操作,就建立一次连接,任务结束就中断连接。当客户端浏览器访问的某个 HTML 或其他类型的 Web 页中包含有其他的 Web 资源(如 JavaScript 文件、图像文件、CSS 文件等),每遇到这样一个 Web 资源,浏览器就会重新建立一个 TCP 连接,这样就会导致有大量的“握手报文”和“挥手报文”占用了带宽。\n为了解决 HTTP/1.0 存在的资源浪费的问题, HTTP/1.1 优化为默认长连接模式 。 采用长连接模式的请求报文会通知服务端:“我向你请求连接,并且连接成功建立后,请不要关闭”。因此,该 TCP 连接将持续打开,为后续的客户端-服务端的数据交互服务。也就是说在使用长连接的情况下,当一个网页打开完成后,客户端和服务器之间用于传输 HTTP 数据的 TCP 连接不会关闭,客户端再次访问这个服务器时,会继续使用这一条已经建立的连接。\n如果 TCP 连接一直保持的话也是对资源的浪费,因此,一些服务器软件(如 Apache)还会支持超时时间的时间。在超时时间之内没有新的请求达到,TCP 连接才会被关闭。\n有必要说明的是,HTTP/1.0 仍提供了长连接选项,即在请求头中加入Connection: Keep-alive。同样的,在 HTTP/1.1 中,如果不希望使用长连接选项,也可以在请求头中加入Connection: close,这样会通知服务器端:“我不需要长连接,连接成功后即可关闭”。\nHTTP 协议的长连接和短连接,实质上是 TCP 协议的长连接和短连接。\n实现长连接需要客户端和服务端都支持长连接。\nHost 头处理 # 域名系统(DNS)允许多个主机名绑定到同一个 IP 地址上,但是 HTTP/1.0 并没有考虑这个问题,假设我们有一个资源 URL 是 http://example1.org/home.html,HTTP/1.0 的请求报文中,将会请求的是GET /home.html HTTP/1.0.也就是不会加入主机名。这样的报文送到服务器端,服务器是理解不了客户端想请求的真正网址。\n因此,HTTP/1.1 在请求头中加入了Host字段。加入Host字段的报文头部将会是:\nGET /home.html HTTP/1.1 Host: example1.org 这样,服务器端就可以确定客户端想要请求的真正的网址了。\n带宽优化 # 范围请求 # HTTP/1.1 引入了范围请求(range request)机制,以避免带宽的浪费。当客户端想请求一个文件的一部分,或者需要继续下载一个已经下载了部分但被终止的文件,HTTP/1.1 可以在请求中加入Range头部,以请求(并只能请求字节型数据)数据的一部分。服务器端可以忽略Range头部,也可以返回若干Range响应。\n206 (Partial Content) 状态码的主要作用是确保客户端和代理服务器能正确识别部分内容响应,避免将其误认为完整资源并错误地缓存。这对于正确处理范围请求和缓存管理非常重要。\n一个典型的 HTTP/1.1 范围请求示例:\n# 获取一个文件的前 1024 个字节 GET /z4d4kWk.jpg HTTP/1.1 Host: i.imgur.com Range: bytes=0-1023 206 Partial Content 响应:\nHTTP/1.1 206 Partial Content Content-Range: bytes 0-1023/146515 Content-Length: 1024 … (二进制内容) 简单解释一下 HTTP 范围响应头部中的字段:\nContent-Range 头部:指示返回数据在整个资源中的位置,包括起始和结束字节以及资源的总长度。例如,Content-Range: bytes 0-1023/146515 表示服务器端返回了第 0 到 1023 字节的数据(共 1024 字节),而整个资源的总长度是 146,515 字节。 Content-Length 头部:指示此次响应中实际传输的字节数。例如,Content-Length: 1024 表示服务器端传输了 1024 字节的数据。 Range 请求头不仅可以请求单个字节范围,还可以一次性请求多个范围。这种方式被称为“多重范围请求”(multiple range requests)。\n客户端想要获取资源的第 0 到 499 字节以及第 1000 到 1499 字节:\nGET /path/to/resource HTTP/1.1 Host: example.com Range: bytes=0-499,1000-1499 服务器端返回多个字节范围,每个范围的内容以分隔符分开:\nHTTP/1.1 206 Partial Content Content-Type: multipart/byteranges; boundary=3d6b6a416f9b5 Content-Length: 376 --3d6b6a416f9b5 Content-Type: application/octet-stream Content-Range: bytes 0-99/2000 (第 0 到 99 字节的数据块) --3d6b6a416f9b5 Content-Type: application/octet-stream Content-Range: bytes 500-599/2000 (第 500 到 599 字节的数据块) --3d6b6a416f9b5 Content-Type: application/octet-stream Content-Range: bytes 1000-1099/2000 (第 1000 到 1099 字节的数据块) --3d6b6a416f9b5-- 状态码 100 # HTTP/1.1 中新加入了状态码100。该状态码的使用场景为,存在某些较大的文件请求,服务器可能不愿意响应这种请求,此时状态码100可以作为指示请求是否会被正常响应,过程如下图:\n然而在 HTTP/1.0 中,并没有100 (Continue)状态码,要想触发这一机制,可以发送一个Expect头部,其中包含一个100-continue的值。\n压缩 # 许多格式的数据在传输时都会做预压缩处理。数据的压缩可以大幅优化带宽的利用。然而,HTTP/1.0 对数据压缩的选项提供的不多,不支持压缩细节的选择,也无法区分端到端(end-to-end)压缩或者是逐跳(hop-by-hop)压缩。\nHTTP/1.1 则对内容编码(content-codings)和传输编码(transfer-codings)做了区分。内容编码总是端到端的,传输编码总是逐跳的。\nHTTP/1.0 包含了Content-Encoding头部,对消息进行端到端编码。HTTP/1.1 加入了Transfer-Encoding头部,可以对消息进行逐跳传输编码。HTTP/1.1 还加入了Accept-Encoding头部,是客户端用来指示他能处理什么样的内容编码。\n总结 # 连接方式 : HTTP 1.0 为短连接,HTTP 1.1 支持长连接。 状态响应码 : HTTP/1.1 中新加入了大量的状态码,光是错误响应状态码就新增了 24 种。比如说,100 (Continue)——在请求大资源前的预热请求,206 (Partial Content)——范围请求的标识码,409 (Conflict)——请求与当前资源的规定冲突,410 (Gone)——资源已被永久转移,而且没有任何已知的转发地址。 缓存处理 : 在 HTTP1.0 中主要使用 header 里的 If-Modified-Since,Expires 来做为缓存判断的标准,HTTP1.1 则引入了更多的缓存控制策略例如 Entity tag,If-Unmodified-Since, If-Match, If-None-Match 等更多可供选择的缓存头来控制缓存策略。 带宽优化及网络连接的使用 :HTTP1.0 中,存在一些浪费带宽的现象,例如客户端只是需要某个对象的一部分,而服务器却将整个对象送过来了,并且不支持断点续传功能,HTTP1.1 则在请求头引入了 range 头域,它允许只请求资源的某个部分,即返回码是 206(Partial Content),这样就方便了开发者自由的选择以便于充分利用带宽和连接。 Host 头处理 : HTTP/1.1 在请求头中加入了Host字段。 参考资料 # Key differences between HTTP/1.0 and HTTP/1.1\n"},{"id":481,"href":"/zh/docs/technology/Interview/cs-basics/network/http-vs-https/","title":"HTTP vs HTTPS(应用层)","section":"Network","content":" HTTP 协议 # HTTP 协议介绍 # HTTP 协议,全称超文本传输协议(Hypertext Transfer Protocol)。顾名思义,HTTP 协议就是用来规范超文本的传输,超文本,也就是网络上的包括文本在内的各式各样的消息,具体来说,主要是来规范浏览器和服务器端的行为的。\n并且,HTTP 是一个无状态(stateless)协议,也就是说服务器不维护任何有关客户端过去所发请求的消息。这其实是一种懒政,有状态协议会更加复杂,需要维护状态(历史信息),而且如果客户或服务器失效,会产生状态的不一致,解决这种不一致的代价更高。\nHTTP 协议通信过程 # HTTP 是应用层协议,它以 TCP(传输层)作为底层协议,默认端口为 80. 通信过程主要如下:\n服务器在 80 端口等待客户的请求。 浏览器发起到服务器的 TCP 连接(创建套接字 Socket)。 服务器接收来自浏览器的 TCP 连接。 浏览器(HTTP 客户端)与 Web 服务器(HTTP 服务器)交换 HTTP 消息。 关闭 TCP 连接。 HTTP 协议优点 # 扩展性强、速度快、跨平台支持性好。\nHTTPS 协议 # HTTPS 协议介绍 # HTTPS 协议(Hyper Text Transfer Protocol Secure),是 HTTP 的加强安全版本。HTTPS 是基于 HTTP 的,也是用 TCP 作为底层协议,并额外使用 SSL/TLS 协议用作加密和安全认证。默认端口号是 443.\nHTTPS 协议中,SSL 通道通常使用基于密钥的加密算法,密钥长度通常是 40 比特或 128 比特。\nHTTPS 协议优点 # 保密性好、信任度高。\nHTTPS 的核心—SSL/TLS 协议 # HTTPS 之所以能达到较高的安全性要求,就是结合了 SSL/TLS 和 TCP 协议,对通信数据进行加密,解决了 HTTP 数据透明的问题。接下来重点介绍一下 SSL/TLS 的工作原理。\nSSL 和 TLS 的区别? # SSL 和 TLS 没有太大的区别。\nSSL 指安全套接字协议(Secure Sockets Layer),首次发布与 1996 年。SSL 的首次发布其实已经是他的 3.0 版本,SSL 1.0 从未面世,SSL 2.0 则具有较大的缺陷(DROWN 缺陷——Decrypting RSA with Obsolete and Weakened eNcryption)。很快,在 1999 年,SSL 3.0 进一步升级,新版本被命名为 TLS 1.0。因此,TLS 是基于 SSL 之上的,但由于习惯叫法,通常把 HTTPS 中的核心加密协议混称为 SSL/TLS。\nSSL/TLS 的工作原理 # 非对称加密 # SSL/TLS 的核心要素是非对称加密。非对称加密采用两个密钥——一个公钥,一个私钥。在通信时,私钥仅由解密者保存,公钥由任何一个想与解密者通信的发送者(加密者)所知。可以设想一个场景,\n在某个自助邮局,每个通信信道都是一个邮箱,每一个邮箱所有者都在旁边立了一个牌子,上面挂着一把钥匙:这是我的公钥,发送者请将信件放入我的邮箱,并用公钥锁好。\n但是公钥只能加锁,并不能解锁。解锁只能由邮箱的所有者——因为只有他保存着私钥。\n这样,通信信息就不会被其他人截获了,这依赖于私钥的保密性。\n非对称加密的公钥和私钥需要采用一种复杂的数学机制生成(密码学认为,为了较高的安全性,尽量不要自己创造加密方案)。公私钥对的生成算法依赖于单向陷门函数。\n单向函数:已知单向函数 f,给定任意一个输入 x,易计算输出 y=f(x);而给定一个输出 y,假设存在 f(x)=y,很难根据 f 来计算出 x。\n单向陷门函数:一个较弱的单向函数。已知单向陷门函数 f,陷门 h,给定任意一个输入 x,易计算出输出 y=f(x;h);而给定一个输出 y,假设存在 f(x;h)=y,很难根据 f 来计算出 x,但可以根据 f 和 h 来推导出 x。\n上图就是一个单向函数(不是单项陷门函数),假设有一个绝世秘籍,任何知道了这个秘籍的人都可以把苹果汁榨成苹果,那么这个秘籍就是“陷门”了吧。\n在这里,函数 f 的计算方法相当于公钥,陷门 h 相当于私钥。公钥 f 是公开的,任何人对已有输入,都可以用 f 加密,而要想根据加密信息还原出原信息,必须要有私钥才行。\n对称加密 # 使用 SSL/TLS 进行通信的双方需要使用非对称加密方案来通信,但是非对称加密设计了较为复杂的数学算法,在实际通信过程中,计算的代价较高,效率太低,因此,SSL/TLS 实际对消息的加密使用的是对称加密。\n对称加密:通信双方共享唯一密钥 k,加解密算法已知,加密方利用密钥 k 加密,解密方利用密钥 k 解密,保密性依赖于密钥 k 的保密性。\n对称加密的密钥生成代价比公私钥对的生成代价低得多,那么有的人会问了,为什么 SSL/TLS 还需要使用非对称加密呢?因为对称加密的保密性完全依赖于密钥的保密性。在双方通信之前,需要商量一个用于对称加密的密钥。我们知道网络通信的信道是不安全的,传输报文对任何人是可见的,密钥的交换肯定不能直接在网络信道中传输。因此,使用非对称加密,对对称加密的密钥进行加密,保护该密钥不在网络信道中被窃听。这样,通信双方只需要一次非对称加密,交换对称加密的密钥,在之后的信息通信中,使用绝对安全的密钥,对信息进行对称加密,即可保证传输消息的保密性。\n公钥传输的信赖性 # SSL/TLS 介绍到这里,了解信息安全的朋友又会想到一个安全隐患,设想一个下面的场景:\n客户端 C 和服务器 S 想要使用 SSL/TLS 通信,由上述 SSL/TLS 通信原理,C 需要先知道 S 的公钥,而 S 公钥的唯一获取途径,就是把 S 公钥在网络信道中传输。要注意网络信道通信中有几个前提:\n任何人都可以捕获通信包 通信包的保密性由发送者设计 保密算法设计方案默认为公开,而(解密)密钥默认是安全的 因此,假设 S 公钥不做加密,在信道中传输,那么很有可能存在一个攻击者 A,发送给 C 一个诈包,假装是 S 公钥,其实是诱饵服务器 AS 的公钥。当 C 收获了 AS 的公钥(却以为是 S 的公钥),C 后续就会使用 AS 公钥对数据进行加密,并在公开信道传输,那么 A 将捕获这些加密包,用 AS 的私钥解密,就截获了 C 本要给 S 发送的内容,而 C 和 S 二人全然不知。\n同样的,S 公钥即使做加密,也难以避免这种信任性问题,C 被 AS 拐跑了!\n为了公钥传输的信赖性问题,第三方机构应运而生——证书颁发机构(CA,Certificate Authority)。CA 默认是受信任的第三方。CA 会给各个服务器颁发证书,证书存储在服务器上,并附有 CA 的电子签名(见下节)。\n当客户端(浏览器)向服务器发送 HTTPS 请求时,一定要先获取目标服务器的证书,并根据证书上的信息,检验证书的合法性。一旦客户端检测到证书非法,就会发生错误。客户端获取了服务器的证书后,由于证书的信任性是由第三方信赖机构认证的,而证书上又包含着服务器的公钥信息,客户端就可以放心的信任证书上的公钥就是目标服务器的公钥。\n数字签名 # 好,到这一小节,已经是 SSL/TLS 的尾声了。上一小节提到了数字签名,数字签名要解决的问题,是防止证书被伪造。第三方信赖机构 CA 之所以能被信赖,就是 靠数字签名技术 。\n数字签名,是 CA 在给服务器颁发证书时,使用散列+加密的组合技术,在证书上盖个章,以此来提供验伪的功能。具体行为如下:\nCA 知道服务器的公钥,对证书采用散列技术生成一个摘要。CA 使用 CA 私钥对该摘要进行加密,并附在证书下方,发送给服务器。\n现在服务器将该证书发送给客户端,客户端需要验证该证书的身份。客户端找到第三方机构 CA,获知 CA 的公钥,并用 CA 公钥对证书的签名进行解密,获得了 CA 生成的摘要。\n客户端对证书数据(包含服务器的公钥)做相同的散列处理,得到摘要,并将该摘要与之前从签名中解码出的摘要做对比,如果相同,则身份验证成功;否则验证失败。\n总结来说,带有证书的公钥传输机制如下:\n设有服务器 S,客户端 C,和第三方信赖机构 CA。 S 信任 CA,CA 是知道 S 公钥的,CA 向 S 颁发证书。并附上 CA 私钥对消息摘要的加密签名。 S 获得 CA 颁发的证书,将该证书传递给 C。 C 获得 S 的证书,信任 CA 并知晓 CA 公钥,使用 CA 公钥对 S 证书上的签名解密,同时对消息进行散列处理,得到摘要。比较摘要,验证 S 证书的真实性。 如果 C 验证 S 证书是真实的,则信任 S 的公钥(在 S 证书中)。 对于数字签名,我这里讲的比较简单,如果你没有搞清楚的话,强烈推荐你看看 数字签名及数字证书原理这个视频,这是我看过最清晰的讲解。\n总结 # 端口号:HTTP 默认是 80,HTTPS 默认是 443。 URL 前缀:HTTP 的 URL 前缀是 http://,HTTPS 的 URL 前缀是 https://。 安全性和资源消耗:HTTP 协议运行在 TCP 之上,所有传输的内容都是明文,客户端和服务器端都无法验证对方的身份。HTTPS 是运行在 SSL/TLS 之上的 HTTP 协议,SSL/TLS 运行在 TCP 之上。所有传输的内容都经过加密,加密采用对称加密,但对称加密的密钥用服务器方的证书进行了非对称加密。所以说,HTTP 安全性没有 HTTPS 高,但是 HTTPS 比 HTTP 耗费更多服务器资源。 "},{"id":482,"href":"/zh/docs/technology/Interview/cs-basics/network/http-status-codes/","title":"HTTP 常见状态码总结(应用层)","section":"Network","content":"HTTP 状态码用于描述 HTTP 请求的结果,比如 2xx 就代表请求被成功处理。\n1xx Informational(信息性状态码) # 相比于其他类别状态码来说,1xx 你平时你大概率不会碰到,所以这里直接跳过。\n2xx Success(成功状态码) # 200 OK:请求被成功处理。例如,发送一个查询用户数据的 HTTP 请求到服务端,服务端正确返回了用户数据。这个是我们平时最常见的一个 HTTP 状态码。 201 Created:请求被成功处理并且在服务端创建了一个新的资源。例如,通过 POST 请求创建一个新的用户。 202 Accepted:服务端已经接收到了请求,但是还未处理。例如,发送一个需要服务端花费较长时间处理的请求(如报告生成、Excel 导出),服务端接收了请求但尚未处理完毕。 204 No Content:服务端已经成功处理了请求,但是没有返回任何内容。例如,发送请求删除一个用户,服务器成功处理了删除操作但没有返回任何内容。 🐛 修正(参见: issue#2458):201 Created 状态码更准确点来说是创建一个或多个新的资源,可以参考: https://httpwg.org/specs/rfc9110.html#status.201。\n这里格外提一下 204 状态码,平时学习/工作中见到的次数并不多。\nHTTP RFC 2616 对 204 状态码的描述如下:\nThe server has fulfilled the request but does not need to return an entity-body, and might want to return updated metainformation. The response MAY include new or updated metainformation in the form of entity-headers, which if present SHOULD be associated with the requested variant.\nIf the client is a user agent, it SHOULD NOT change its document view from that which caused the request to be sent. This response is primarily intended to allow input for actions to take place without causing a change to the user agent\u0026rsquo;s active document view, although any new or updated metainformation SHOULD be applied to the document currently in the user agent\u0026rsquo;s active view.\nThe 204 response MUST NOT include a message-body, and thus is always terminated by the first empty line after the header fields.\n简单来说,204 状态码描述的是我们向服务端发送 HTTP 请求之后,只关注处理结果是否成功的场景。也就是说我们需要的就是一个结果:true/false。\n举个例子:你要追一个女孩子,你问女孩子:“我能追你吗?”,女孩子回答:“好!”。我们把这个女孩子当做是服务端就很好理解 204 状态码了。\n3xx Redirection(重定向状态码) # 301 Moved Permanently:资源被永久重定向了。比如你的网站的网址更换了。 302 Found:资源被临时重定向了。比如你的网站的某些资源被暂时转移到另外一个网址。 4xx Client Error(客户端错误状态码) # 400 Bad Request:发送的 HTTP 请求存在问题。比如请求参数不合法、请求方法错误。 401 Unauthorized:未认证却请求需要认证之后才能访问的资源。 403 Forbidden:直接拒绝 HTTP 请求,不处理。一般用来针对非法请求。 404 Not Found:你请求的资源未在服务端找到。比如你请求某个用户的信息,服务端并没有找到指定的用户。 409 Conflict:表示请求的资源与服务端当前的状态存在冲突,请求无法被处理。 5xx Server Error(服务端错误状态码) # 500 Internal Server Error:服务端出问题了(通常是服务端出 Bug 了)。比如你服务端处理请求的时候突然抛出异常,但是异常并未在服务端被正确处理。 502 Bad Gateway:我们的网关将请求转发到服务端,但是服务端返回的却是一个错误的响应。 参考 # https://www.restapitutorial.com/httpstatuscodes.html https://developer.mozilla.org/zh-CN/docs/Web/HTTP/Status https://en.wikipedia.org/wiki/List_of_HTTP_status_codes https://segmentfault.com/a/1190000018264501 "},{"id":483,"href":"/zh/docs/technology/Interview/database/mysql/innodb-implementation-of-mvcc/","title":"InnoDB存储引擎对MVCC的实现","section":"Mysql","content":" 多版本并发控制 (Multi-Version Concurrency Control) # MVCC 是一种并发控制机制,用于在多个并发事务同时读写数据库时保持数据的一致性和隔离性。它是通过在每个数据行上维护多个版本的数据来实现的。当一个事务要对数据库中的数据进行修改时,MVCC 会为该事务创建一个数据快照,而不是直接修改实际的数据行。\n1、读操作(SELECT):\n当一个事务执行读操作时,它会使用快照读取。快照读取是基于事务开始时数据库中的状态创建的,因此事务不会读取其他事务尚未提交的修改。具体工作情况如下:\n对于读取操作,事务会查找符合条件的数据行,并选择符合其事务开始时间的数据版本进行读取。 如果某个数据行有多个版本,事务会选择不晚于其开始时间的最新版本,确保事务只读取在它开始之前已经存在的数据。 事务读取的是快照数据,因此其他并发事务对数据行的修改不会影响当前事务的读取操作。 2、写操作(INSERT、UPDATE、DELETE):\n当一个事务执行写操作时,它会生成一个新的数据版本,并将修改后的数据写入数据库。具体工作情况如下:\n对于写操作,事务会为要修改的数据行创建一个新的版本,并将修改后的数据写入新版本。 新版本的数据会带有当前事务的版本号,以便其他事务能够正确读取相应版本的数据。 原始版本的数据仍然存在,供其他事务使用快照读取,这保证了其他事务不受当前事务的写操作影响。 3、事务提交和回滚:\n当一个事务提交时,它所做的修改将成为数据库的最新版本,并且对其他事务可见。 当一个事务回滚时,它所做的修改将被撤销,对其他事务不可见。 4、版本的回收:\n为了防止数据库中的版本无限增长,MVCC 会定期进行版本的回收。回收机制会删除已经不再需要的旧版本数据,从而释放空间。\nMVCC 通过创建数据的多个版本和使用快照读取来实现并发控制。读操作使用旧版本数据的快照,写操作创建新版本,并确保原始版本仍然可用。这样,不同的事务可以在一定程度上并发执行,而不会相互干扰,从而提高了数据库的并发性能和数据一致性。\n一致性非锁定读和锁定读 # 一致性非锁定读 # 对于 一致性非锁定读(Consistent Nonlocking Reads)的实现,通常做法是加一个版本号或者时间戳字段,在更新数据的同时版本号 + 1 或者更新时间戳。查询时,将当前可见的版本号与对应记录的版本号进行比对,如果记录的版本小于可见版本,则表示该记录可见\n在 InnoDB 存储引擎中, 多版本控制 (multi versioning) 就是对非锁定读的实现。如果读取的行正在执行 DELETE 或 UPDATE 操作,这时读取操作不会去等待行上锁的释放。相反地,InnoDB 存储引擎会去读取行的一个快照数据,对于这种读取历史数据的方式,我们叫它快照读 (snapshot read)\n在 Repeatable Read 和 Read Committed 两个隔离级别下,如果是执行普通的 select 语句(不包括 select ... lock in share mode ,select ... for update)则会使用 一致性非锁定读(MVCC)。并且在 Repeatable Read 下 MVCC 实现了可重复读和防止部分幻读\n锁定读 # 如果执行的是下列语句,就是 锁定读(Locking Reads)\nselect ... lock in share mode select ... for update insert、update、delete 操作 在锁定读下,读取的是数据的最新版本,这种读也被称为 当前读(current read)。锁定读会对读取到的记录加锁:\nselect ... lock in share mode:对记录加 S 锁,其它事务也可以加S锁,如果加 x 锁则会被阻塞\nselect ... for update、insert、update、delete:对记录加 X 锁,且其它事务不能加任何锁\n在一致性非锁定读下,即使读取的记录已被其它事务加上 X 锁,这时记录也是可以被读取的,即读取的快照数据。上面说了,在 Repeatable Read 下 MVCC 防止了部分幻读,这边的 “部分” 是指在 一致性非锁定读 情况下,只能读取到第一次查询之前所插入的数据(根据 Read View 判断数据可见性,Read View 在第一次查询时生成)。但是!如果是 当前读 ,每次读取的都是最新数据,这时如果两次查询中间有其它事务插入数据,就会产生幻读。所以, InnoDB 在实现Repeatable Read 时,如果执行的是当前读,则会对读取的记录使用 Next-key Lock ,来防止其它事务在间隙间插入数据\nInnoDB 对 MVCC 的实现 # MVCC 的实现依赖于:隐藏字段、Read View、undo log。在内部实现中,InnoDB 通过数据行的 DB_TRX_ID 和 Read View 来判断数据的可见性,如不可见,则通过数据行的 DB_ROLL_PTR 找到 undo log 中的历史版本。每个事务读到的数据版本可能是不一样的,在同一个事务中,用户只能看到该事务创建 Read View 之前已经提交的修改和该事务本身做的修改\n隐藏字段 # 在内部,InnoDB 存储引擎为每行数据添加了三个 隐藏字段:\nDB_TRX_ID(6字节):表示最后一次插入或更新该行的事务 id。此外,delete 操作在内部被视为更新,只不过会在记录头 Record header 中的 deleted_flag 字段将其标记为已删除 DB_ROLL_PTR(7字节) 回滚指针,指向该行的 undo log 。如果该行未被更新,则为空 DB_ROW_ID(6字节):如果没有设置主键且该表没有唯一非空索引时,InnoDB 会使用该 id 来生成聚簇索引 ReadView # class ReadView { /* ... */ private: trx_id_t m_low_limit_id; /* 大于等于这个 ID 的事务均不可见 */ trx_id_t m_up_limit_id; /* 小于这个 ID 的事务均可见 */ trx_id_t m_creator_trx_id; /* 创建该 Read View 的事务ID */ trx_id_t m_low_limit_no; /* 事务 Number, 小于该 Number 的 Undo Logs 均可以被 Purge */ ids_t m_ids; /* 创建 Read View 时的活跃事务列表 */ m_closed; /* 标记 Read View 是否 close */ } Read View 主要是用来做可见性判断,里面保存了 “当前对本事务不可见的其他活跃事务”\n主要有以下字段:\nm_low_limit_id:目前出现过的最大的事务 ID+1,即下一个将被分配的事务 ID。大于等于这个 ID 的数据版本均不可见 m_up_limit_id:活跃事务列表 m_ids 中最小的事务 ID,如果 m_ids 为空,则 m_up_limit_id 为 m_low_limit_id。小于这个 ID 的数据版本均可见 m_ids:Read View 创建时其他未提交的活跃事务 ID 列表。创建 Read View时,将当前未提交事务 ID 记录下来,后续即使它们修改了记录行的值,对于当前事务也是不可见的。m_ids 不包括当前事务自己和已提交的事务(正在内存中) m_creator_trx_id:创建该 Read View 的事务 ID 事务可见性示意图( 图源):\nundo-log # undo log 主要有两个作用:\n当事务回滚时用于将数据恢复到修改前的样子 另一个作用是 MVCC ,当读取记录时,若该记录被其他事务占用或当前版本对该事务不可见,则可以通过 undo log 读取之前的版本数据,以此实现非锁定读 在 InnoDB 存储引擎中 undo log 分为两种:insert undo log 和 update undo log:\ninsert undo log:指在 insert 操作中产生的 undo log。因为 insert 操作的记录只对事务本身可见,对其他事务不可见,故该 undo log 可以在事务提交后直接删除。不需要进行 purge 操作 insert 时的数据初始状态:\nupdate undo log:update 或 delete 操作中产生的 undo log。该 undo log可能需要提供 MVCC 机制,因此不能在事务提交时就进行删除。提交时放入 undo log 链表,等待 purge线程 进行最后的删除 数据第一次被修改时:\n数据第二次被修改时:\n不同事务或者相同事务的对同一记录行的修改,会使该记录行的 undo log 成为一条链表,链首就是最新的记录,链尾就是最早的旧记录。\n数据可见性算法 # 在 InnoDB 存储引擎中,创建一个新事务后,执行每个 select 语句前,都会创建一个快照(Read View),快照中保存了当前数据库系统中正处于活跃(没有 commit)的事务的 ID 号。其实简单的说保存的是系统中当前不应该被本事务看到的其他事务 ID 列表(即 m_ids)。当用户在这个事务中要读取某个记录行的时候,InnoDB 会将该记录行的 DB_TRX_ID 与 Read View 中的一些变量及当前事务 ID 进行比较,判断是否满足可见性条件\n具体的比较算法如下( 图源):\n如果记录 DB_TRX_ID \u0026lt; m_up_limit_id,那么表明最新修改该行的事务(DB_TRX_ID)在当前事务创建快照之前就提交了,所以该记录行的值对当前事务是可见的\n如果 DB_TRX_ID \u0026gt;= m_low_limit_id,那么表明最新修改该行的事务(DB_TRX_ID)在当前事务创建快照之后才修改该行,所以该记录行的值对当前事务不可见。跳到步骤 5\nm_ids 为空,则表明在当前事务创建快照之前,修改该行的事务就已经提交了,所以该记录行的值对当前事务是可见的\n如果 m_up_limit_id \u0026lt;= DB_TRX_ID \u0026lt; m_low_limit_id,表明最新修改该行的事务(DB_TRX_ID)在当前事务创建快照的时候可能处于“活动状态”或者“已提交状态”;所以就要对活跃事务列表 m_ids 进行查找(源码中是用的二分查找,因为是有序的)\n如果在活跃事务列表 m_ids 中能找到 DB_TRX_ID,表明:① 在当前事务创建快照前,该记录行的值被事务 ID 为 DB_TRX_ID 的事务修改了,但没有提交;或者 ② 在当前事务创建快照后,该记录行的值被事务 ID 为 DB_TRX_ID 的事务修改了。这些情况下,这个记录行的值对当前事务都是不可见的。跳到步骤 5\n在活跃事务列表中找不到,则表明“id 为 trx_id 的事务”在修改“该记录行的值”后,在“当前事务”创建快照前就已经提交了,所以记录行对当前事务可见\n在该记录行的 DB_ROLL_PTR 指针所指向的 undo log 取出快照记录,用快照记录的 DB_TRX_ID 跳到步骤 1 重新开始判断,直到找到满足的快照版本或返回空\nRC 和 RR 隔离级别下 MVCC 的差异 # 在事务隔离级别 RC 和 RR (InnoDB 存储引擎的默认事务隔离级别)下,InnoDB 存储引擎使用 MVCC(非锁定一致性读),但它们生成 Read View 的时机却不同\n在 RC 隔离级别下的 每次select 查询前都生成一个Read View (m_ids 列表) 在 RR 隔离级别下只在事务开始后 第一次select 数据前生成一个Read View(m_ids 列表) MVCC 解决不可重复读问题 # 虽然 RC 和 RR 都通过 MVCC 来读取快照数据,但由于 生成 Read View 时机不同,从而在 RR 级别下实现可重复读\n举个例子:\n在 RC 下 ReadView 生成情况 # 1. 假设时间线来到 T4 ,那么此时数据行 id = 1 的版本链为:\n由于 RC 级别下每次查询都会生成Read View ,并且事务 101、102 并未提交,此时 103 事务生成的 Read View 中活跃的事务 m_ids 为:[101,102] ,m_low_limit_id为:104,m_up_limit_id为:101,m_creator_trx_id 为:103\n此时最新记录的 DB_TRX_ID 为 101,m_up_limit_id \u0026lt;= 101 \u0026lt; m_low_limit_id,所以要在 m_ids 列表中查找,发现 DB_TRX_ID 存在列表中,那么这个记录不可见 根据 DB_ROLL_PTR 找到 undo log 中的上一版本记录,上一条记录的 DB_TRX_ID 还是 101,不可见 继续找上一条 DB_TRX_ID为 1,满足 1 \u0026lt; m_up_limit_id,可见,所以事务 103 查询到数据为 name = 菜花 2. 时间线来到 T6 ,数据的版本链为:\n因为在 RC 级别下,重新生成 Read View,这时事务 101 已经提交,102 并未提交,所以此时 Read View 中活跃的事务 m_ids:[102] ,m_low_limit_id为:104,m_up_limit_id为:102,m_creator_trx_id为:103\n此时最新记录的 DB_TRX_ID 为 102,m_up_limit_id \u0026lt;= 102 \u0026lt; m_low_limit_id,所以要在 m_ids 列表中查找,发现 DB_TRX_ID 存在列表中,那么这个记录不可见\n根据 DB_ROLL_PTR 找到 undo log 中的上一版本记录,上一条记录的 DB_TRX_ID 为 101,满足 101 \u0026lt; m_up_limit_id,记录可见,所以在 T6 时间点查询到数据为 name = 李四,与时间 T4 查询到的结果不一致,不可重复读!\n3. 时间线来到 T9 ,数据的版本链为:\n重新生成 Read View, 这时事务 101 和 102 都已经提交,所以 m_ids 为空,则 m_up_limit_id = m_low_limit_id = 104,最新版本事务 ID 为 102,满足 102 \u0026lt; m_low_limit_id,可见,查询结果为 name = 赵六\n总结: 在 RC 隔离级别下,事务在每次查询开始时都会生成并设置新的 Read View,所以导致不可重复读\n在 RR 下 ReadView 生成情况 # 在可重复读级别下,只会在事务开始后第一次读取数据时生成一个 Read View(m_ids 列表)\n1. 在 T4 情况下的版本链为:\n在当前执行 select 语句时生成一个 Read View,此时 m_ids:[101,102] ,m_low_limit_id为:104,m_up_limit_id为:101,m_creator_trx_id 为:103\n此时和 RC 级别下一样:\n最新记录的 DB_TRX_ID 为 101,m_up_limit_id \u0026lt;= 101 \u0026lt; m_low_limit_id,所以要在 m_ids 列表中查找,发现 DB_TRX_ID 存在列表中,那么这个记录不可见 根据 DB_ROLL_PTR 找到 undo log 中的上一版本记录,上一条记录的 DB_TRX_ID 还是 101,不可见 继续找上一条 DB_TRX_ID为 1,满足 1 \u0026lt; m_up_limit_id,可见,所以事务 103 查询到数据为 name = 菜花 2. 时间点 T6 情况下:\n在 RR 级别下只会生成一次Read View,所以此时依然沿用 m_ids:[101,102] ,m_low_limit_id为:104,m_up_limit_id为:101,m_creator_trx_id 为:103\n最新记录的 DB_TRX_ID 为 102,m_up_limit_id \u0026lt;= 102 \u0026lt; m_low_limit_id,所以要在 m_ids 列表中查找,发现 DB_TRX_ID 存在列表中,那么这个记录不可见\n根据 DB_ROLL_PTR 找到 undo log 中的上一版本记录,上一条记录的 DB_TRX_ID 为 101,不可见\n继续根据 DB_ROLL_PTR 找到 undo log 中的上一版本记录,上一条记录的 DB_TRX_ID 还是 101,不可见\n继续找上一条 DB_TRX_ID为 1,满足 1 \u0026lt; m_up_limit_id,可见,所以事务 103 查询到数据为 name = 菜花\n3. 时间点 T9 情况下:\n此时情况跟 T6 完全一样,由于已经生成了 Read View,此时依然沿用 m_ids:[101,102] ,所以查询结果依然是 name = 菜花\nMVCC➕Next-key-Lock 防止幻读 # InnoDB存储引擎在 RR 级别下通过 MVCC和 Next-key Lock 来解决幻读问题:\n1、执行普通 select,此时会以 MVCC 快照读的方式读取数据\n在快照读的情况下,RR 隔离级别只会在事务开启后的第一次查询生成 Read View ,并使用至事务提交。所以在生成 Read View 之后其它事务所做的更新、插入记录版本对当前事务并不可见,实现了可重复读和防止快照读下的 “幻读”\n2、执行 select\u0026hellip;for update/lock in share mode、insert、update、delete 等当前读\n在当前读下,读取的都是最新的数据,如果其它事务有插入新的记录,并且刚好在当前事务查询范围内,就会产生幻读!InnoDB 使用 Next-key Lock 来防止这种情况。当执行当前读时,会锁定读取到的记录的同时,锁定它们的间隙,防止其它事务在查询范围内插入数据。只要我不让你插入,就不会发生幻读\n参考 # 《MySQL 技术内幕 InnoDB 存储引擎第 2 版》 Innodb 中的事务隔离级别和锁的关系 MySQL 事务与 MVCC 如何实现的隔离级别 InnoDB 事务分析-MVCC "},{"id":484,"href":"/zh/docs/technology/Interview/system-design/framework/spring/ioc-and-aop/","title":"IoC \u0026 AOP详解(快速搞懂)","section":"Framework","content":"这篇文章会从下面从以下几个问题展开对 IoC \u0026amp; AOP 的解释\n什么是 IoC? IoC 解决了什么问题? IoC 和 DI 的区别? 什么是 AOP? AOP 解决了什么问题? AOP 的应用场景有哪些? AOP 为什么叫做切面编程? AOP 实现方式有哪些? 首先声明:IoC \u0026amp; AOP 不是 Spring 提出来的,它们在 Spring 之前其实已经存在了,只不过当时更加偏向于理论。Spring 在技术层次将这两个思想进行了很好的实现。\nIoC (Inversion of control ) # 什么是 IoC? # IoC (Inversion of Control )即控制反转/反转控制。它是一种思想不是一个技术实现。描述的是:Java 开发领域对象的创建以及管理的问题。\n例如:现有类 A 依赖于类 B\n传统的开发方式 :往往是在类 A 中手动通过 new 关键字来 new 一个 B 的对象出来 使用 IoC 思想的开发方式 :不通过 new 关键字来创建对象,而是通过 IoC 容器(Spring 框架) 来帮助我们实例化对象。我们需要哪个对象,直接从 IoC 容器里面去取即可。 从以上两种开发方式的对比来看:我们 “丧失了一个权力” (创建、管理对象的权力),从而也得到了一个好处(不用再考虑对象的创建、管理等一系列的事情)\n为什么叫控制反转?\n控制 :指的是对象创建(实例化、管理)的权力 反转 :控制权交给外部环境(IoC 容器) IoC 解决了什么问题? # IoC 的思想就是两方之间不互相依赖,由第三方容器来管理相关资源。这样有什么好处呢?\n对象之间的耦合度或者说依赖程度降低; 资源变的容易管理;比如你用 Spring 容器提供的话很容易就可以实现一个单例。 例如:现有一个针对 User 的操作,利用 Service 和 Dao 两层结构进行开发\n在没有使用 IoC 思想的情况下,Service 层想要使用 Dao 层的具体实现的话,需要通过 new 关键字在UserServiceImpl 中手动 new 出 IUserDao 的具体实现类 UserDaoImpl(不能直接 new 接口类)。\n很完美,这种方式也是可以实现的,但是我们想象一下如下场景:\n开发过程中突然接到一个新的需求,针对IUserDao 接口开发出另一个具体实现类。因为 Server 层依赖了IUserDao的具体实现,所以我们需要修改UserServiceImpl中 new 的对象。如果只有一个类引用了IUserDao的具体实现,可能觉得还好,修改起来也不是很费力气,但是如果有许许多多的地方都引用了IUserDao的具体实现的话,一旦需要更换IUserDao 的实现方式,那修改起来将会非常的头疼。\n使用 IoC 的思想,我们将对象的控制权(创建、管理)交由 IoC 容器去管理,我们在使用的时候直接向 IoC 容器 “要” 就可以了\nIoC 和 DI 有区别吗? # IoC(Inverse of Control:控制反转)是一种设计思想或者说是某种模式。这个设计思想就是 将原本在程序中手动创建对象的控制权交给第三方比如 IoC 容器。 对于我们常用的 Spring 框架来说, IoC 容器实际上就是个 Map(key,value),Map 中存放的是各种对象。不过,IoC 在其他语言中也有应用,并非 Spring 特有。\nIoC 最常见以及最合理的实现方式叫做依赖注入(Dependency Injection,简称 DI)。\n老马(Martin Fowler)在一篇文章中提到将 IoC 改名为 DI,原文如下,原文地址: https://martinfowler.com/articles/injection.html 。\n老马的大概意思是 IoC 太普遍并且不表意,很多人会因此而迷惑,所以,使用 DI 来精确指名这个模式比较好。\nAOP(Aspect oriented programming) # 这里不会涉及太多专业的术语,核心目的是将 AOP 的思想说清楚。\n什么是 AOP? # AOP(Aspect Oriented Programming)即面向切面编程,AOP 是 OOP(面向对象编程)的一种延续,二者互补,并不对立。\nAOP 的目的是将横切关注点(如日志记录、事务管理、权限控制、接口限流、接口幂等等)从核心业务逻辑中分离出来,通过动态代理、字节码操作等技术,实现代码的复用和解耦,提高代码的可维护性和可扩展性。OOP 的目的是将业务逻辑按照对象的属性和行为进行封装,通过类、对象、继承、多态等概念,实现代码的模块化和层次化(也能实现代码的复用),提高代码的可读性和可维护性。\nAOP 为什么叫面向切面编程? # AOP 之所以叫面向切面编程,是因为它的核心思想就是将横切关注点从核心业务逻辑中分离出来,形成一个个的切面(Aspect)。\n这里顺带总结一下 AOP 关键术语(不理解也没关系,可以继续往下看):\n横切关注点(cross-cutting concerns) :多个类或对象中的公共行为(如日志记录、事务管理、权限控制、接口限流、接口幂等等)。 切面(Aspect):对横切关注点进行封装的类,一个切面是一个类。切面可以定义多个通知,用来实现具体的功能。 连接点(JoinPoint):连接点是方法调用或者方法执行时的某个特定时刻(如方法调用、异常抛出等)。 通知(Advice):通知就是切面在某个连接点要执行的操作。通知有五种类型,分别是前置通知(Before)、后置通知(After)、返回通知(AfterReturning)、异常通知(AfterThrowing)和环绕通知(Around)。前四种通知都是在目标方法的前后执行,而环绕通知可以控制目标方法的执行过程。 切点(Pointcut):一个切点是一个表达式,它用来匹配哪些连接点需要被切面所增强。切点可以通过注解、正则表达式、逻辑运算等方式来定义。比如 execution(* com.xyz.service..*(..))匹配 com.xyz.service 包及其子包下的类或接口。 织入(Weaving):织入是将切面和目标对象连接起来的过程,也就是将通知应用到切点匹配的连接点上。常见的织入时机有两种,分别是编译期织入(Compile-Time Weaving 如:AspectJ)和运行期织入(Runtime Weaving 如:AspectJ、Spring AOP)。 AOP 常见的通知类型有哪些? # Before(前置通知):目标对象的方法调用之前触发 After (后置通知):目标对象的方法调用之后触发 AfterReturning(返回通知):目标对象的方法调用完成,在返回结果值之后触发 AfterThrowing(异常通知):目标对象的方法运行中抛出 / 触发异常后触发。AfterReturning 和 AfterThrowing 两者互斥。如果方法调用成功无异常,则会有返回值;如果方法抛出了异常,则不会有返回值。 Around (环绕通知):编程式控制目标对象的方法调用。环绕通知是所有通知类型中可操作范围最大的一种,因为它可以直接拿到目标对象,以及要执行的方法,所以环绕通知可以任意的在目标对象的方法调用前后搞事,甚至不调用目标对象的方法 AOP 解决了什么问题? # OOP 不能很好地处理一些分散在多个类或对象中的公共行为(如日志记录、事务管理、权限控制、接口限流、接口幂等等),这些行为通常被称为 横切关注点(cross-cutting concerns) 。如果我们在每个类或对象中都重复实现这些行为,那么会导致代码的冗余、复杂和难以维护。\nAOP 可以将横切关注点(如日志记录、事务管理、权限控制、接口限流、接口幂等等)从 核心业务逻辑(core concerns,核心关注点) 中分离出来,实现关注点的分离。\n以日志记录为例进行介绍,假如我们需要对某些方法进行统一格式的日志记录,没有使用 AOP 技术之前,我们需要挨个写日志记录的逻辑代码,全是重复的的逻辑。\npublic CommonResponse\u0026lt;Object\u0026gt; method1() { // 业务逻辑 xxService.method1(); // 省略具体的业务处理逻辑 // 日志记录 ServletRequestAttributes attributes = (ServletRequestAttributes) RequestContextHolder.getRequestAttributes(); HttpServletRequest request = attributes.getRequest(); // 省略记录日志的具体逻辑 如:获取各种信息,写入数据库等操作... return CommonResponse.success(); } public CommonResponse\u0026lt;Object\u0026gt; method2() { // 业务逻辑 xxService.method2(); // 省略具体的业务处理逻辑 // 日志记录 ServletRequestAttributes attributes = (ServletRequestAttributes) RequestContextHolder.getRequestAttributes(); HttpServletRequest request = attributes.getRequest(); // 省略记录日志的具体逻辑 如:获取各种信息,写入数据库等操作... return CommonResponse.success(); } // ... 使用 AOP 技术之后,我们可以将日志记录的逻辑封装成一个切面,然后通过切入点和通知来指定在哪些方法需要执行日志记录的操作。\n// 日志注解 @Target({ElementType.PARAMETER,ElementType.METHOD}) @Retention(RetentionPolicy.RUNTIME) @Documented public @interface Log { /** * 描述 */ String description() default \u0026#34;\u0026#34;; /** * 方法类型 INSERT DELETE UPDATE OTHER */ MethodType methodType() default MethodType.OTHER; } // 日志切面 @Component @Aspect public class LogAspect { // 切入点,所有被 Log 注解标注的方法 @Pointcut(\u0026#34;@annotation(cn.javaguide.annotation.Log)\u0026#34;) public void webLog() { } /** * 环绕通知 */ @Around(\u0026#34;webLog()\u0026#34;) public Object doAround(ProceedingJoinPoint joinPoint) throws Throwable { // 省略具体的处理逻辑 } // 省略其他代码 } 这样的话,我们一行注解即可实现日志记录:\n@Log(description = \u0026#34;method1\u0026#34;,methodType = MethodType.INSERT) public CommonResponse\u0026lt;Object\u0026gt; method1() { // 业务逻辑 xxService.method1(); // 省略具体的业务处理逻辑 return CommonResponse.success(); } AOP 的应用场景有哪些? # 日志记录:自定义日志记录注解,利用 AOP,一行代码即可实现日志记录。 性能统计:利用 AOP 在目标方法的执行前后统计方法的执行时间,方便优化和分析。 事务管理:@Transactional 注解可以让 Spring 为我们进行事务管理比如回滚异常操作,免去了重复的事务管理逻辑。@Transactional注解就是基于 AOP 实现的。 权限控制:利用 AOP 在目标方法执行前判断用户是否具备所需要的权限,如果具备,就执行目标方法,否则就不执行。例如,SpringSecurity 利用@PreAuthorize 注解一行代码即可自定义权限校验。 接口限流:利用 AOP 在目标方法执行前通过具体的限流算法和实现对请求进行限流处理。 缓存管理:利用 AOP 在目标方法执行前后进行缓存的读取和更新。 …… AOP 实现方式有哪些? # AOP 的常见实现方式有动态代理、字节码操作等方式。\nSpring AOP 就是基于动态代理的,如果要代理的对象,实现了某个接口,那么 Spring AOP 会使用 JDK Proxy,去创建代理对象,而对于没有实现接口的对象,就无法使用 JDK Proxy 去进行代理了,这时候 Spring AOP 会使用 Cglib 生成一个被代理对象的子类来作为代理,如下图所示:\n当然你也可以使用 AspectJ !Spring AOP 已经集成了 AspectJ ,AspectJ 应该算的上是 Java 生态系统中最完整的 AOP 框架了。\nSpring AOP 属于运行时增强,而 AspectJ 是编译时增强。 Spring AOP 基于代理(Proxying),而 AspectJ 基于字节码操作(Bytecode Manipulation)。\nSpring AOP 已经集成了 AspectJ ,AspectJ 应该算的上是 Java 生态系统中最完整的 AOP 框架了。AspectJ 相比于 Spring AOP 功能更加强大,但是 Spring AOP 相对来说更简单,\n如果我们的切面比较少,那么两者性能差异不大。但是,当切面太多的话,最好选择 AspectJ ,它比 Spring AOP 快很多。\n"},{"id":485,"href":"/zh/docs/technology/Interview/java/new-features/java10/","title":"Java 10 新特性概览","section":"New Features","content":"Java 10 发布于 2018 年 3 月 20 日,最知名的特性应该是 var 关键字(局部变量类型推断)的引入了,其他还有垃圾收集器改善、GC 改进、性能提升、线程管控等一批新特性。\n概览(精选了一部分):\nJEP 286:局部变量类型推断 JEP 304:垃圾回收器接口 JEP 307:G1 并行 Full GC JEP 310:应用程序类数据共享(扩展 CDS 功能) JEP 317:实验性的基于 Java 的 JIT 编译器 局部变量类型推断(var) # 由于太多 Java 开发者希望 Java 中引入局部变量推断,于是 Java 10 的时候它来了,也算是众望所归了!\nJava 10 提供了 var 关键字声明局部变量。\nvar id = 0; var codefx = new URL(\u0026#34;https://mp.weixin.qq.com/\u0026#34;); var list = new ArrayList\u0026lt;\u0026gt;(); var list = List.of(1, 2, 3); var map = new HashMap\u0026lt;String, String\u0026gt;(); var p = Paths.of(\u0026#34;src/test/java/Java9FeaturesTest.java\u0026#34;); var numbers = List.of(\u0026#34;a\u0026#34;, \u0026#34;b\u0026#34;, \u0026#34;c\u0026#34;); for (var n : list) System.out.print(n+ \u0026#34; \u0026#34;); var 关键字只能用于带有构造器的局部变量和 for 循环中。\nvar count=null; //❌编译不通过,不能声明为 null var r = () -\u0026gt; Math.random();//❌编译不通过,不能声明为 Lambda表达式 var array = {1,2,3};//❌编译不通过,不能声明数组 var 并不会改变 Java 是一门静态类型语言的事实,编译器负责推断出类型。\n另外,Scala 和 Kotlin 中已经有了 val 关键字 ( final var 组合关键字)。\n相关阅读: 《Java 10 新特性之局部变量类型推断》。\n垃圾回收器接口 # 在早期的 JDK 结构中,组成垃圾收集器 (GC) 实现的组件分散在代码库的各个部分。 Java 10 通过引入一套纯净的垃圾收集器接口来将不同垃圾收集器的源代码分隔开。\nG1 并行 Full GC # 从 Java9 开始 G1 就了默认的垃圾回收器,G1 是以一种低延时的垃圾回收器来设计的,旨在避免进行 Full GC,但是 Java9 的 G1 的 FullGC 依然是使用单线程去完成标记清除算法,这可能会导致垃圾回收期在无法回收内存的时候触发 Full GC。\n为了最大限度地减少 Full GC 造成的应用停顿的影响,从 Java10 开始,G1 的 FullGC 改为并行的标记清除算法,同时会使用与年轻代回收和混合回收相同的并行工作线程数量,从而减少了 Full GC 的发生,以带来更好的性能提升、更大的吞吐量。\n集合增强 # List,Set,Map 提供了静态方法copyOf()返回入参集合的一个不可变拷贝。\nstatic \u0026lt;E\u0026gt; List\u0026lt;E\u0026gt; copyOf(Collection\u0026lt;? extends E\u0026gt; coll) { return ImmutableCollections.listCopy(coll); } 使用 copyOf() 创建的集合为不可变集合,不能进行添加、删除、替换、 排序等操作,不然会报 java.lang.UnsupportedOperationException 异常。 IDEA 也会有相应的提示。\n并且,java.util.stream.Collectors 中新增了静态方法,用于将流中的元素收集为不可变的集合。\nvar list = new ArrayList\u0026lt;\u0026gt;(); list.stream().collect(Collectors.toUnmodifiableList()); list.stream().collect(Collectors.toUnmodifiableSet()); Optional 增强 # Optional 新增了orElseThrow()方法来在没有值时抛出指定的异常。\nOptional.ofNullable(cache.getIfPresent(key)) .orElseThrow(() -\u0026gt; new PrestoException(NOT_FOUND, \u0026#34;Missing entry found for key: \u0026#34; + key)); 应用程序类数据共享(扩展 CDS 功能) # 在 Java 5 中就已经引入了类数据共享机制 (Class Data Sharing,简称 CDS),允许将一组类预处理为共享归档文件,以便在运行时能够进行内存映射以减少 Java 程序的启动时间,当多个 Java 虚拟机(JVM)共享相同的归档文件时,还可以减少动态内存的占用量,同时减少多个虚拟机在同一个物理或虚拟的机器上运行时的资源占用。CDS 在当时还是 Oracle JDK 的商业特性。\nJava 10 在现有的 CDS 功能基础上再次拓展,以允许应用类放置在共享存档中。CDS 特性在原来的 bootstrap 类基础之上,扩展加入了应用类的 CDS 为 (Application Class-Data Sharing,AppCDS) 支持,大大加大了 CDS 的适用范围。其原理为:在启动时记录加载类的过程,写入到文本文件中,再次启动时直接读取此启动文本并加载。设想如果应用环境没有大的变化,启动速度就会得到提升。\n实验性的基于 Java 的 JIT 编译器 # Graal 是一个基于 Java 语言编写的 JIT 编译器,是 JDK 9 中引入的实验性 Ahead-of-Time (AOT) 编译器的基础。\nOracle 的 HotSpot VM 便附带两个用 C++ 实现的 JIT compiler:C1 及 C2。在 Java 10 (Linux/x64, macOS/x64) 中,默认情况下 HotSpot 仍使用 C2,但通过向 java 命令添加 -XX:+UnlockExperimentalVMOptions -XX:+UseJVMCICompiler 参数便可将 C2 替换成 Graal。\n相关阅读: 深入浅出 Java 10 的实验性 JIT 编译器 Graal - 郑雨迪\n其他 # 线程-局部管控:Java 10 中线程管控引入 JVM 安全点的概念,将允许在不运行全局 JVM 安全点的情况下实现线程回调,由线程本身或者 JVM 线程来执行,同时保持线程处于阻塞状态,这种方式使得停止单个线程变成可能,而不是只能启用或停止所有线程 备用存储装置上的堆分配:Java 10 中将使得 JVM 能够使用适用于不同类型的存储机制的堆,在可选内存设备上进行堆内存分配 …… 参考 # Java 10 Features and Enhancements : https://howtodoinjava.com/java10/java10-features/\nGuide to Java10 : https://www.baeldung.com/java-10-overview\n4 Class Data Sharing : https://docs.oracle.com/javase/10/vm/class-data-sharing.htm#JSJVM-GUID-7EAA3411-8CF0-4D19-BD05-DF5E1780AA91\n"},{"id":486,"href":"/zh/docs/technology/Interview/java/new-features/java11/","title":"Java 11 新特性概览","section":"New Features","content":"Java 11 于 2018 年 9 月 25 日正式发布,这是很重要的一个版本!Java 11 和 2017 年 9 月份发布的 Java 9 以及 2018 年 3 月份发布的 Java 10 相比,其最大的区别就是:在长期支持(Long-Term-Support)方面,Oracle 表示会对 Java 11 提供大力支持,这一支持将会持续至 2026 年 9 月。这是据 Java 8 以后支持的首个长期版本。\n下面这张图是 Oracle 官方给出的 Oracle JDK 支持的时间线。\n概览(精选了一部分):\nJEP 321:HTTP Client 标准化 JEP 333:ZGC(可伸缩低延迟垃圾收集器) JEP 323:Lambda 参数的局部变量语法 JEP 330:启动单文件源代码程序 HTTP Client 标准化 # Java 11 对 Java 9 中引入并在 Java 10 中进行了更新的 Http Client API 进行了标准化,在前两个版本中进行孵化的同时,Http Client 几乎被完全重写,并且现在完全支持异步非阻塞。\n并且,Java 11 中,Http Client 的包名由 jdk.incubator.http 改为java.net.http,该 API 通过 CompleteableFuture 提供非阻塞请求和响应语义。使用起来也很简单,如下:\nvar request = HttpRequest.newBuilder() .uri(URI.create(\u0026#34;https://javastack.cn\u0026#34;)) .GET() .build(); var client = HttpClient.newHttpClient(); // 同步 HttpResponse\u0026lt;String\u0026gt; response = client.send(request, HttpResponse.BodyHandlers.ofString()); System.out.println(response.body()); // 异步 client.sendAsync(request, HttpResponse.BodyHandlers.ofString()) .thenApply(HttpResponse::body) .thenAccept(System.out::println); String 增强 # Java 11 增加了一系列的字符串处理方法:\n//判断字符串是否为空 \u0026#34; \u0026#34;.isBlank();//true //去除字符串首尾空格 \u0026#34; Java \u0026#34;.strip();// \u0026#34;Java\u0026#34; //去除字符串首部空格 \u0026#34; Java \u0026#34;.stripLeading(); // \u0026#34;Java \u0026#34; //去除字符串尾部空格 \u0026#34; Java \u0026#34;.stripTrailing(); // \u0026#34; Java\u0026#34; //重复字符串多少次 \u0026#34;Java\u0026#34;.repeat(3); // \u0026#34;JavaJavaJava\u0026#34; //返回由行终止符分隔的字符串集合。 \u0026#34;A\\nB\\nC\u0026#34;.lines().count(); // 3 \u0026#34;A\\nB\\nC\u0026#34;.lines().collect(Collectors.toList()); Optional 增强 # 新增了isEmpty()方法来判断指定的 Optional 对象是否为空。\nvar op = Optional.empty(); System.out.println(op.isEmpty());//判断指定的 Optional 对象是否为空 ZGC(可伸缩低延迟垃圾收集器) # ZGC 即 Z Garbage Collector,是一个可伸缩的、低延迟的垃圾收集器。\nZGC 主要为了满足如下目标进行设计:\nGC 停顿时间不超过 10ms 即能处理几百 MB 的小堆,也能处理几个 TB 的大堆 应用吞吐能力不会下降超过 15%(与 G1 回收算法相比) 方便在此基础上引入新的 GC 特性和利用 colored 针以及 Load barriers 优化奠定基础 当前只支持 Linux/x64 位平台 ZGC 目前 处在实验阶段,只支持 Linux/x64 平台。\n与 CMS 中的 ParNew 和 G1 类似,ZGC 也采用标记-复制算法,不过 ZGC 对该算法做了重大改进。\n在 ZGC 中出现 Stop The World 的情况会更少!\n详情可以看: 《新一代垃圾回收器 ZGC 的探索与实践》\nLambda 参数的局部变量语法 # 从 Java 10 开始,便引入了局部变量类型推断这一关键特性。类型推断允许使用关键字 var 作为局部变量的类型而不是实际类型,编译器根据分配给变量的值推断出类型。\nJava 10 中对 var 关键字存在几个限制\n只能用于局部变量上 声明时必须初始化 不能用作方法参数 不能在 Lambda 表达式中使用 Java11 开始允许开发者在 Lambda 表达式中使用 var 进行参数声明。\n// 下面两者是等价的 Consumer\u0026lt;String\u0026gt; consumer = (var i) -\u0026gt; System.out.println(i); Consumer\u0026lt;String\u0026gt; consumer = (String i) -\u0026gt; System.out.println(i); 启动单文件源代码程序 # 这意味着我们可以运行单一文件的 Java 源代码。此功能允许使用 Java 解释器直接执行 Java 源代码。源代码在内存中编译,然后由解释器执行,不需要在磁盘上生成 .class 文件了。唯一的约束在于所有相关的类必须定义在同一个 Java 文件中。\n对于 Java 初学者并希望尝试简单程序的人特别有用,并且能和 jshell 一起使用。一定能程度上增强了使用 Java 来写脚本程序的能力。\n其他新特性 # 新的垃圾回收器 Epsilon:一个完全消极的 GC 实现,分配有限的内存资源,最大限度的降低内存占用和内存吞吐延迟时间 低开销的 Heap Profiling:Java 11 中提供一种低开销的 Java 堆分配采样方法,能够得到堆分配的 Java 对象信息,并且能够通过 JVMTI 访问堆信息 TLS1.3 协议:Java 11 中包含了传输层安全性(TLS)1.3 规范(RFC 8446)的实现,替换了之前版本中包含的 TLS,包括 TLS 1.2,同时还改进了其他 TLS 功能,例如 OCSP 装订扩展(RFC 6066,RFC 6961),以及会话散列和扩展主密钥扩展(RFC 7627),在安全性和性能方面也做了很多提升 飞行记录器(Java Flight Recorder):飞行记录器之前是商业版 JDK 的一项分析工具,但在 Java 11 中,其代码被包含到公开代码库中,这样所有人都能使用该功能了。 …… 参考 # JDK 11 Release Notes: https://www.oracle.com/java/technologies/javase/11-relnote-issues.html Java 11 – Features and Comparison: https://www.geeksforgeeks.org/java-11-features-and-comparison/ "},{"id":487,"href":"/zh/docs/technology/Interview/java/new-features/java12-13/","title":"Java 12 \u0026 13 新特性概览","section":"New Features","content":" Java12 # String 增强 # Java 12 增加了两个的字符串处理方法,如以下所示。\nindent() 方法可以实现字符串缩进。\nString text = \u0026#34;Java\u0026#34;; // 缩进 4 格 text = text.indent(4); System.out.println(text); text = text.indent(-10); System.out.println(text); 输出:\nJava Java transform() 方法可以用来转变指定字符串。\nString result = \u0026#34;foo\u0026#34;.transform(input -\u0026gt; input + \u0026#34; bar\u0026#34;); System.out.println(result); // foo bar Files 增强(文件比较) # Java 12 添加了以下方法来比较两个文件:\npublic static long mismatch(Path path, Path path2) throws IOException mismatch() 方法用于比较两个文件,并返回第一个不匹配字符的位置,如果文件相同则返回 -1L。\n代码示例(两个文件内容相同的情况):\nPath filePath1 = Files.createTempFile(\u0026#34;file1\u0026#34;, \u0026#34;.txt\u0026#34;); Path filePath2 = Files.createTempFile(\u0026#34;file2\u0026#34;, \u0026#34;.txt\u0026#34;); Files.writeString(filePath1, \u0026#34;Java 12 Article\u0026#34;); Files.writeString(filePath2, \u0026#34;Java 12 Article\u0026#34;); long mismatch = Files.mismatch(filePath1, filePath2); assertEquals(-1, mismatch); 代码示例(两个文件内容不相同的情况):\nPath filePath3 = Files.createTempFile(\u0026#34;file3\u0026#34;, \u0026#34;.txt\u0026#34;); Path filePath4 = Files.createTempFile(\u0026#34;file4\u0026#34;, \u0026#34;.txt\u0026#34;); Files.writeString(filePath3, \u0026#34;Java 12 Article\u0026#34;); Files.writeString(filePath4, \u0026#34;Java 12 Tutorial\u0026#34;); long mismatch = Files.mismatch(filePath3, filePath4); assertEquals(8, mismatch); 数字格式化工具类 # NumberFormat 新增了对复杂的数字进行格式化的支持\nNumberFormat fmt = NumberFormat.getCompactNumberInstance(Locale.US, NumberFormat.Style.SHORT); String result = fmt.format(1000); System.out.println(result); 输出:\n1K Shenandoah GC # Redhat 主导开发的 Pauseless GC 实现,主要目标是 99.9% 的暂停小于 10ms,暂停与堆大小无关等\n和 Java11 开源的 ZGC 相比(需要升级到 JDK11 才能使用),Shenandoah GC 有稳定的 JDK8u 版本,在 Java8 占据主要市场份额的今天有更大的可落地性。\nG1 收集器优化 # Java12 为默认的垃圾收集器 G1 带来了两项更新:\n可中止的混合收集集合:JEP344 的实现,为了达到用户提供的停顿时间目标,JEP 344 通过把要被回收的区域集(混合收集集合)拆分为强制和可选部分,使 G1 垃圾回收器能中止垃圾回收过程。 G1 可以中止可选部分的回收以达到停顿时间目标 及时返回未使用的已分配内存:JEP346 的实现,增强 G1 GC,以便在空闲时自动将 Java 堆内存返回给操作系统 预览新特性 # 作为预览特性加入,需要在javac编译和java运行时增加参数--enable-preview 。\n增强 Switch # 传统的 switch 语法存在容易漏写 break 的问题,而且从代码整洁性层面来看,多个 break 本质也是一种重复。\nJava12 增强了 switch 表达式,使用类似 lambda 语法条件匹配成功后的执行块,不需要多写 break 。\nswitch (day) { case MONDAY, FRIDAY, SUNDAY -\u0026gt; System.out.println(6); case TUESDAY -\u0026gt; System.out.println(7); case THURSDAY, SATURDAY -\u0026gt; System.out.println(8); case WEDNESDAY -\u0026gt; System.out.println(9); } instanceof 模式匹配 # instanceof 主要在类型强转前探测对象的具体类型。\n之前的版本中,我们需要显示地对对象进行类型转换。\nObject obj = \u0026#34;我是字符串\u0026#34;; if(obj instanceof String){ String str = (String) obj; System.out.println(str); } 新版的 instanceof 可以在判断是否属于具体的类型同时完成转换。\nObject obj = \u0026#34;我是字符串\u0026#34;; if(obj instanceof String str){ System.out.println(str); } Java13 # 增强 ZGC(释放未使用内存) # 在 Java 11 中实验性引入的 ZGC 在实际的使用中存在未能主动将未使用的内存释放给操作系统的问题。\nZGC 堆由一组称为 ZPages 的堆区域组成。在 GC 周期中清空 ZPages 区域时,它们将被释放并返回到页面缓存 ZPageCache 中,此缓存中的 ZPages 按最近最少使用(LRU)的顺序,并按照大小进行组织。\n在 Java 13 中,ZGC 将向操作系统返回被标识为长时间未使用的页面,这样它们将可以被其他进程重用。\nSocketAPI 重构 # Java Socket API 终于迎来了重大更新!\nJava 13 将 Socket API 的底层进行了重写, NioSocketImpl 是对 PlainSocketImpl 的直接替代,它使用 java.util.concurrent 包下的锁而不是同步方法。如果要使用旧实现,请使用 -Djdk.net.usePlainSocketImpl=true。\n并且,在 Java 13 中是默认使用新的 Socket 实现。\npublic final class NioSocketImpl extends SocketImpl implements PlatformSocketImpl { } FileSystems # FileSystems 类中添加了以下三种新方法,以便更容易地使用将文件内容视为文件系统的文件系统提供程序:\nnewFileSystem(Path) newFileSystem(Path, Map\u0026lt;String, ?\u0026gt;) newFileSystem(Path, Map\u0026lt;String, ?\u0026gt;, ClassLoader) 动态 CDS 存档 # Java 13 中对 Java 10 中引入的应用程序类数据共享(AppCDS)进行了进一步的简化、改进和扩展,即:允许在 Java 应用程序执行结束时动态进行类归档,具体能够被归档的类包括所有已被加载,但不属于默认基层 CDS 的应用程序类和引用类库中的类。\n这提高了应用程序类数据共享( AppCDS)的可用性。无需用户进行试运行来为每个应用程序创建类列表。\njava -XX:ArchiveClassesAtExit=my_app_cds.jsa -cp my_app.jar java -XX:SharedArchiveFile=my_app_cds.jsa -cp my_app.jar 预览新特性 # 文本块 # 解决 Java 定义多行字符串时只能通过换行转义或者换行连接符来变通支持的问题,引入三重双引号来定义多行文本。\nJava 13 支持两个 \u0026quot;\u0026quot;\u0026quot; 符号中间的任何内容都会被解释为字符串的一部分,包括换行符。\n未支持文本块之前的 HTML 写法:\nString json =\u0026#34;{\\n\u0026#34; + \u0026#34; \\\u0026#34;name\\\u0026#34;:\\\u0026#34;mkyong\\\u0026#34;,\\n\u0026#34; + \u0026#34; \\\u0026#34;age\\\u0026#34;:38\\n\u0026#34; + \u0026#34;}\\n\u0026#34;; 支持文本块之后的 HTML 写法:\nString json = \u0026#34;\u0026#34;\u0026#34; { \u0026#34;name\u0026#34;:\u0026#34;mkyong\u0026#34;, \u0026#34;age\u0026#34;:38 } \u0026#34;\u0026#34;\u0026#34;; 未支持文本块之前的 SQL 写法:\nString query = \u0026#34;SELECT `EMP_ID`, `LAST_NAME` FROM `EMPLOYEE_TB`\\n\u0026#34; + \u0026#34;WHERE `CITY` = \u0026#39;INDIANAPOLIS\u0026#39;\\n\u0026#34; + \u0026#34;ORDER BY `EMP_ID`, `LAST_NAME`;\\n\u0026#34;; 支持文本块之后的 SQL 写法:\nString query = \u0026#34;\u0026#34;\u0026#34; SELECT `EMP_ID`, `LAST_NAME` FROM `EMPLOYEE_TB` WHERE `CITY` = \u0026#39;INDIANAPOLIS\u0026#39; ORDER BY `EMP_ID`, `LAST_NAME`; \u0026#34;\u0026#34;\u0026#34;; 另外,String 类新增加了 3 个新的方法来操作文本块:\nformatted(Object... args):它类似于 String 的format()方法。添加它是为了支持文本块的格式设置。 stripIndent():用于去除文本块中每一行开头和结尾的空格。 translateEscapes():转义序列如 “\\\\t” 转换为 “\\t” 由于文本块是一项预览功能,可以在未来版本中删除,因此这些新方法被标记为弃用。\n@Deprecated(forRemoval=true, since=\u0026#34;13\u0026#34;) public String stripIndent() { } @Deprecated(forRemoval=true, since=\u0026#34;13\u0026#34;) public String formatted(Object... args) { } @Deprecated(forRemoval=true, since=\u0026#34;13\u0026#34;) public String translateEscapes() { } 增强 Switch(引入 yield 关键字到 Switch 中) # Switch 表达式中就多了一个关键字用于跳出 Switch 块的关键字 yield,主要用于返回一个值\nyield和 return 的区别在于:return 会直接跳出当前循环或者方法,而 yield 只会跳出当前 Switch 块,同时在使用 yield 时,需要有 default 条件\nprivate static String descLanguage(String name) { return switch (name) { case \u0026#34;Java\u0026#34;: yield \u0026#34;object-oriented, platform independent and secured\u0026#34;; case \u0026#34;Ruby\u0026#34;: yield \u0026#34;a programmer\u0026#39;s best friend\u0026#34;; default: yield name +\u0026#34; is a good language\u0026#34;; }; } 补充 # 关于预览特性 # 先贴一段 oracle 官网原文:This is a preview feature, which is a feature whose design, specification, and implementation are complete, but is not permanent, which means that the feature may exist in a different form or not at all in future JDK releases. To compile and run code that contains preview features, you must specify additional command-line options.\n这是一个预览功能,该功能的设计,规格和实现是完整的,但不是永久性的,这意味着该功能可能以其他形式存在或在将来的 JDK 版本中根本不存在。 要编译和运行包含预览功能的代码,必须指定其他命令行选项。\n就以switch的增强为例子,从 Java12 中推出,到 Java13 中将继续增强,直到 Java14 才正式转正进入 JDK 可以放心使用,不用考虑后续 JDK 版本对其的改动或修改\n一方面可以看出 JDK 作为标准平台在增加新特性的严谨态度,另一方面个人认为是对于预览特性应该采取审慎使用的态度。特性的设计和实现容易,但是其实际价值依然需要在使用中去验证\nJVM 虚拟机优化 # 每次 Java 版本的发布都伴随着对 JVM 虚拟机的优化,包括对现有垃圾回收算法的改进,引入新的垃圾回收算法,移除老旧的不再适用于今天的垃圾回收算法等\n整体优化的方向是高效,低时延的垃圾回收表现\n对于日常的应用开发者可能比较关注新的语法特性,但是从一个公司角度来说,在考虑是否升级 Java 平台时更加考虑的是JVM 运行时的提升\n参考 # JDK Project Overview: https://openjdk.java.net/projects/jdk/ Oracle Java12 ReleaseNote: https://www.oracle.com/java/technologies/javase/12all-relnotes.htm What is new in Java 12: https://mkyong.com/java/what-is-new-in-java-12/ Oracle Java13 ReleaseNote https://www.oracle.com/technetwork/java/javase/13all-relnotes-5461743.html#NewFeature New Java13 Features https://www.baeldung.com/java-13-new-features Java13 新特性概述 https://www.ibm.com/developerworks/cn/java/the-new-features-of-Java-13/index.html "},{"id":488,"href":"/zh/docs/technology/Interview/java/new-features/java14-15/","title":"Java 14 \u0026 15 新特性概览","section":"New Features","content":" Java14 # 空指针异常精准提示 # 通过 JVM 参数中添加-XX:+ShowCodeDetailsInExceptionMessages,可以在空指针异常中获取更为详细的调用信息,更快的定位和解决问题。\na.b.c.i = 99; // 假设这段代码会发生空指针 Java 14 之前:\nException in thread \u0026#34;main\u0026#34; java.lang.NullPointerException at NullPointerExample.main(NullPointerExample.java:5) Java 14 之后:\n// 增加参数后提示的异常中很明确的告知了哪里为空导致 Exception in thread \u0026#34;main\u0026#34; java.lang.NullPointerException: Cannot read field \u0026#39;c\u0026#39; because \u0026#39;a.b\u0026#39; is null. at Prog.main(Prog.java:5) switch 的增强(转正) # Java12 引入的 switch(预览特性)在 Java14 变为正式版本,不需要增加参数来启用,直接在 JDK14 中就能使用。\nJava12 为 switch 表达式引入了类似 lambda 语法条件匹配成功后的执行块,不需要多写 break ,Java13 提供了 yield 来在 block 中返回值。\nString result = switch (day) { case \u0026#34;M\u0026#34;, \u0026#34;W\u0026#34;, \u0026#34;F\u0026#34; -\u0026gt; \u0026#34;MWF\u0026#34;; case \u0026#34;T\u0026#34;, \u0026#34;TH\u0026#34;, \u0026#34;S\u0026#34; -\u0026gt; \u0026#34;TTS\u0026#34;; default -\u0026gt; { if(day.isEmpty()) yield \u0026#34;Please insert a valid day.\u0026#34;; else yield \u0026#34;Looks like a Sunday.\u0026#34;; } }; System.out.println(result); 预览新特性 # record 关键字 # record 关键字可以简化 数据类(一个 Java 类一旦实例化就不能再修改)的定义方式,使用 record 代替 class 定义的类,只需要声明属性,就可以在获得属性的访问方法,以及 toString(),hashCode(), equals()方法。\n类似于使用 class 定义类,同时使用了 lombok 插件,并打上了@Getter,@ToString,@EqualsAndHashCode注解。\n/** * 这个类具有两个特征 * 1. 所有成员属性都是final * 2. 全部方法由构造方法,和两个成员属性访问器组成(共三个) * 那么这种类就很适合使用record来声明 */ final class Rectangle implements Shape { final double length; final double width; public Rectangle(double length, double width) { this.length = length; this.width = width; } double length() { return length; } double width() { return width; } } /** * 1. 使用record声明的类会自动拥有上面类中的三个方法 * 2. 在这基础上还附赠了equals(),hashCode()方法以及toString()方法 * 3. toString方法中包括所有成员属性的字符串表示形式及其名称 */ record Rectangle(float length, float width) { } 文本块 # Java14 中,文本块依然是预览特性,不过,其引入了两个新的转义字符:\n\\ : 表示行尾,不引入换行符 \\s:表示单个空格 String str = \u0026#34;凡心所向,素履所往,生如逆旅,一苇以航。\u0026#34;; String str2 = \u0026#34;\u0026#34;\u0026#34; 凡心所向,素履所往, \\ 生如逆旅,一苇以航。\u0026#34;\u0026#34;\u0026#34;; System.out.println(str2);// 凡心所向,素履所往, 生如逆旅,一苇以航。 String text = \u0026#34;\u0026#34;\u0026#34; java c++\\sphp \u0026#34;\u0026#34;\u0026#34;; System.out.println(text); //输出: java c++ php instanceof 增强 # 依然是预览特性 , Java 12 新特性中介绍过。\n其他 # 从 Java11 引入的 ZGC 作为继 G1 过后的下一代 GC 算法,从支持 Linux 平台到 Java14 开始支持 MacOS 和 Windows(个人感觉是终于可以在日常开发工具中先体验下 ZGC 的效果了,虽然其实 G1 也够用) 移除了 CMS(Concurrent Mark Sweep) 垃圾收集器(功成而退) 新增了 jpackage 工具,标配将应用打成 jar 包外,还支持不同平台的特性包,比如 linux 下的deb和rpm,window 平台下的msi和exe Java15 # CharSequence # CharSequence 接口添加了一个默认方法 isEmpty() 来判断字符序列为空,如果是则返回 true。\npublic interface CharSequence { default boolean isEmpty() { return this.length() == 0; } } TreeMap # TreeMap 新引入了下面这些方法:\nputIfAbsent() computeIfAbsent() computeIfPresent() compute() merge() ZGC(转正) # Java11 的时候 ,ZGC 还在试验阶段。\n当时,ZGC 的出现让众多 Java 开发者看到了垃圾回收器的另外一种可能,因此备受关注。\n经过多个版本的迭代,不断的完善和修复问题,ZGC 在 Java 15 已经可以正式使用了!\n不过,默认的垃圾回收器依然是 G1。你可以通过下面的参数启动 ZGC:\njava -XX:+UseZGC className EdDSA(数字签名算法) # 新加入了一个安全性和性能都更强的基于 Edwards-Curve Digital Signature Algorithm (EdDSA)实现的数字签名算法。\n虽然其性能优于现有的 ECDSA 实现,不过,它并不会完全取代 JDK 中现有的椭圆曲线数字签名算法( ECDSA)。\nKeyPairGenerator kpg = KeyPairGenerator.getInstance(\u0026#34;Ed25519\u0026#34;); KeyPair kp = kpg.generateKeyPair(); byte[] msg = \u0026#34;test_string\u0026#34;.getBytes(StandardCharsets.UTF_8); Signature sig = Signature.getInstance(\u0026#34;Ed25519\u0026#34;); sig.initSign(kp.getPrivate()); sig.update(msg); byte[] s = sig.sign(); String encodedString = Base64.getEncoder().encodeToString(s); System.out.println(encodedString); 输出:\n0Hc0lxxASZNvS52WsvnncJOH/mlFhnA8Tc6D/k5DtAX5BSsNVjtPF4R4+yMWXVjrvB2mxVXmChIbki6goFBgAg== 文本块(转正) # 在 Java 15 ,文本块是正式的功能特性了。\n隐藏类(Hidden Classes) # 隐藏类是为框架(frameworks)所设计的,隐藏类不能直接被其他类的字节码使用,只能在运行时生成类并通过反射间接使用它们。\n预览新特性 # 密封类 # 密封类(Sealed Classes) 是 Java 15 中的一个预览新特性。\n没有密封类之前,在 Java 中如果想让一个类不能被继承和修改,我们可以使用final 关键字对类进行修饰。不过,这种方式不太灵活,直接把一个类的继承和修改渠道给堵死了。\n密封类可以对继承或者实现它们的类进行限制,这样这个类就只能被指定的类继承。\n// 抽象类 Person 只允许 Employee 和 Manager 继承。 public abstract sealed class Person permits Employee, Manager { //... } 另外,任何扩展密封类的类本身都必须声明为 sealed、non-sealed 或 final。\npublic final class Employee extends Person { } public non-sealed class Manager extends Person { } 如果允许扩展的子类和封闭类在同一个源代码文件里,封闭类可以不使用 permits 语句,Java 编译器将检索源文件,在编译期为封闭类添加上许可的子类。\ninstanceof 模式匹配 # Java 15 并没有对此特性进行调整,继续预览特性,主要用于接受更多的使用反馈。\n在未来的 Java 版本中,Java 的目标是继续完善 instanceof 模式匹配新特性。\n其他 # Nashorn JavaScript 引擎彻底移除:Nashorn 从 Java8 开始引入的 JavaScript 引擎,Java9 对 Nashorn 做了些增强,实现了一些 ES6 的新特性。在 Java 11 中就已经被弃用,到了 Java 15 就彻底被删除了。 DatagramSocket API 重构 禁用和废弃偏向锁(Biased Locking):偏向锁的引入增加了 JVM 的复杂性大于其带来的性能提升。不过,你仍然可以使用 -XX:+UseBiasedLocking 启用偏向锁定,但它会提示这是一个已弃用的 API。 …… "},{"id":489,"href":"/zh/docs/technology/Interview/java/new-features/java16/","title":"Java 16 新特性概览","section":"New Features","content":"Java 16 在 2021 年 3 月 16 日正式发布,非长期支持(LTS)版本。\n相关阅读: OpenJDK Java 16 文档 。\nJEP 338:向量 API(第一次孵化) # 向量(Vector) API 最初由 JEP 338 提出,并作为 孵化 API集成到 Java 16 中。第二轮孵化由 JEP 414 提出并集成到 Java 17 中,第三轮孵化由 JEP 417 提出并集成到 Java 18 中,第四轮由 JEP 426 提出并集成到了 Java 19 中。\n该孵化器 API 提供了一个 API 的初始迭代以表达一些向量计算,这些计算在运行时可靠地编译为支持的 CPU 架构上的最佳向量硬件指令,从而获得优于同等标量计算的性能,充分利用单指令多数据(SIMD)技术(大多数现代 CPU 上都可以使用的一种指令)。尽管 HotSpot 支持自动向量化,但是可转换的标量操作集有限且易受代码更改的影响。该 API 将使开发人员能够轻松地用 Java 编写可移植的高性能向量算法。\n在 Java 18 新特性概览 中,我有详细介绍到向量 API,这里就不再做额外的介绍了。\nJEP 347:启用 C++ 14 语言特性 # Java 16 允许在 JDK 的 C++ 源代码中使用 C++14 语言特性,并提供在 HotSpot 代码中可以使用哪些特性的具体指导。\n在 Java 15 中,JDK 中 C++ 代码使用的语言特性仅限于 C++98/03 语言标准。它要求更新各种平台编译器的最低可接受版本。\nJEP 376:ZGC 并发线程堆栈处理 # Java16 将 ZGC 线程栈处理从安全点转移到一个并发阶段,甚至在大堆上也允许在毫秒内暂停 GC 安全点。消除 ZGC 垃圾收集器中最后一个延迟源可以极大地提高应用程序的性能和效率。\nJEP 387:弹性元空间 # 自从引入了 Metaspace 以来,根据反馈,Metaspace 经常占用过多的堆外内存,从而导致内存浪费。弹性元空间这个特性可将未使用的 HotSpot 类元数据(即元空间,metaspace)内存更快速地返回到操作系统,从而减少元空间的占用空间。\n并且,这个提案还简化了元空间的代码以降低维护成本。\nJEP 390:对基于值的类发出警告 # 以下介绍摘自: 实操 | 剖析 Java16 新语法特性,原文写的很不错,推荐阅读。\n早在 Java9 版本时,Java 的设计者们就对 @Deprecated 注解进行了一次升级,增加了 since 和 forRemoval 等 2 个新元素。其中,since 元素用于指定标记了 @Deprecated 注解的 API 被弃用时的版本,而 forRemoval 则进一步明确了 API 标记 @Deprecated 注解时的语义,如果forRemoval=true时,则表示该 API 在未来版本中肯定会被删除,开发人员应该使用新的 API 进行替代,不再容易产生歧义(Java9 之前,标记 @Deprecated 注解的 API,语义上存在多种可能性,比如:存在使用风险、可能在未来存在兼容性错误、可能在未来版本中被删除,以及应该使用更好的替代方案等)。\n仔细观察原始类型的包装类(比如:java.lang.Integer、java.lang.Double),不难发现,其构造函数上都已经标记有@Deprecated(since=\u0026quot;9\u0026quot;, forRemoval = true)注解,这就意味着其构造函数在将来会被删除,不应该在程序中继续使用诸如new Integer();这样的编码方式(建议使用Integer a = 10;或者Integer.valueOf()函数),如果继续使用,编译期将会产生\u0026rsquo;Integer(int)\u0026rsquo; is deprecated and marked for removal 告警。并且,值得注意的是,这些包装类型已经被指定为同 java.util.Optional 和 java.time.LocalDateTime 一样的值类型。\n其次,如果继续在 synchronized 同步块中使用值类型,将会在编译期和运行期产生警告,甚至是异常。在此大家需要注意,就算编译期和运行期没有产生警告和异常,也不建议在 synchronized 同步块中使用值类型,举个自增的例子。示例 1-5:\npublic void inc(Integer count) { for (int i = 0; i \u0026lt; 10; i++) { new Thread(() -\u0026gt; { synchronized (count) { count++; } }).start(); } } 当执行上述程序示例时,最终的输出结果一定会与你的期望产生差异,这是许多新人经常犯错的一个点,因为在并发环境下,Integer 对象根本无法通过 synchronized 来保证线程安全,这是因为每次的count++操作,所产生的 hashcode 均不同,简而言之,每次加锁都锁在了不同的对象上。因此,如果希望在实际的开发过程中保证其原子性,应该使用 AtomicInteger。\nJEP 392:打包工具 # 在 Java 14 中,JEP 343 引入了打包工具,命令是 jpackage。在 Java 15 中,继续孵化,现在在 Java 16 中,终于成为了正式功能。\n这个打包工具允许打包自包含的 Java 应用程序。它支持原生打包格式,为最终用户提供自然的安装体验,这些格式包括 Windows 上的 msi 和 exe、macOS 上的 pkg 和 dmg,还有 Linux 上的 deb 和 rpm。它还允许在打包时指定启动时参数,并且可以从命令行直接调用,也可以通过 ToolProvider API 以编程方式调用。注意 jpackage 模块名称从 jdk.incubator.jpackage 更改为 jdk.jpackage。这将改善最终用户在安装应用程序时的体验,并简化了“应用商店”模型的部署。\n关于这个打包工具的实际使用,可以看这个视频 Playing with Java 16 jpackage(需要梯子)。\nJEP 393:外部内存访问 API(第三次孵化) # 引入外部内存访问 API 以允许 Java 程序安全有效地访问 Java 堆之外的外部内存。\nJava 14( JEP 370) 的时候,第一次孵化外部内存访问 API,Java 15 中进行了第二次复活( JEP 383),在 Java 16 中进行了第三次孵化。\n引入外部内存访问 API 的目的如下:\n通用:单个 API 应该能够对各种外部内存(如本机内存、持久内存、堆内存等)进行操作。 安全:无论操作何种内存,API 都不应该破坏 JVM 的安全性。 控制:可以自由的选择如何释放内存(显式、隐式等)。 可用:如果需要访问外部内存,API 应该是 sun.misc.Unsafe. JEP 394:instanceof 模式匹配(转正) # JDK 版本 更新类型 JEP 更新内容 Java SE 14 preview JEP 305 首次引入 instanceof 模式匹配。 Java SE 15 Second Preview JEP 375 相比较上个版本无变化,继续收集更多反馈。 Java SE 16 Permanent Release JEP 394 模式变量不再隐式为 final。 从 Java 16 开始,你可以对 instanceof 中的变量值进行修改。\n// Old code if (o instanceof String) { String s = (String)o; ... use s ... } // New code if (o instanceof String s) { ... use s ... } JEP 395:记录类型(转正) # 记录类型变更历史:\nJDK 版本 更新类型 JEP 更新内容 Java SE 14 Preview JEP 359 引入 record 关键字,record 提供一种紧凑的语法来定义类中的不可变数据。 Java SE 15 Second Preview JEP 384 支持在局部方法和接口中使用 record。 Java SE 16 Permanent Release JEP 395 非静态内部类可以定义非常量的静态成员。 从 Java SE 16 开始,非静态内部类可以定义非常量的静态成员。\npublic class Outer { class Inner { static int age; } } 在 JDK 16 之前,如果写上面这种代码,IDE 会提示你静态字段 age 不能在非静态的内部类中定义,除非它用一个常量表达式初始化。(The field age cannot be declared static in a non-static inner type, unless initialized with a constant expression)\nJEP 396:默认强封装 JDK 内部元素 # 此特性会默认强封装 JDK 的所有内部元素,但关键内部 API(例如 sun.misc.Unsafe)除外。默认情况下,使用早期版本成功编译的访问 JDK 内部 API 的代码可能不再起作用。鼓励开发人员从使用内部元素迁移到使用标准 API 的方法上,以便他们及其用户都可以无缝升级到将来的 Java 版本。强封装由 JDK 9 的启动器选项–illegal-access 控制,到 JDK 15 默认改为 warning,从 JDK 16 开始默认为 deny。(目前)仍然可以使用单个命令行选项放宽对所有软件包的封装,将来只有使用–add-opens 打开特定的软件包才行。\nJEP 397:密封类(预览) # 密封类由 JEP 360 提出预览,集成到了 Java 15 中。在 JDK 16 中, 密封类得到了改进(更加严格的引用检查和密封类的继承关系),由 JEP 397 提出了再次预览。\n在 Java 14 \u0026amp; 15 新特性概览 中,我有详细介绍到密封类,这里就不再做额外的介绍了。\n其他优化与改进 # JEP 380:Unix-Domain 套接字通道:Unix-domain 套接字一直是大多数 Unix 平台的一个特性,现在在 Windows 10 和 Windows Server 2019 也提供了支持。此特性为 java.nio.channels 包的套接字通道和服务器套接字通道 API 添加了 Unix-domain(AF_UNIX)套接字支持。它扩展了继承的通道机制以支持 Unix-domain 套接字通道和服务器套接字通道。Unix-domain 套接字用于同一主机上的进程间通信(IPC)。它们在很大程度上类似于 TCP/IP,区别在于套接字是通过文件系统路径名而不是 Internet 协议(IP)地址和端口号寻址的。对于本地进程间通信,Unix-domain 套接字比 TCP/IP 环回连接更安全、更有效 JEP 389:外部链接器 API(孵化): 该孵化器 API 提供了静态类型、纯 Java 访问原生代码的特性,该 API 将大大简化绑定原生库的原本复杂且容易出错的过程。Java 1.1 就已通过 Java 原生接口(JNI)支持了原生方法调用,但并不好用。Java 开发人员应该能够为特定任务绑定特定的原生库。它还提供了外来函数支持,而无需任何中间的 JNI 粘合代码。 JEP 357:从 Mercurial 迁移到 Git:在此之前,OpenJDK 源代码是使用版本管理工具 Mercurial 进行管理,现在迁移到了 Git。 JEP 369:迁移到 GitHub:和 JEP 357 从 Mercurial 迁移到 Git 的改变一致,在把版本管理迁移到 Git 之后,选择了在 GitHub 上托管 OpenJDK 社区的 Git 仓库。不过只对 JDK 11 以及更高版本 JDK 进行了迁移。 JEP 386:移植 Alpine Linux:Alpine Linux 是一个独立的、非商业的 Linux 发行版,它十分的小,一个容器需要不超过 8MB 的空间,最小安装到磁盘只需要大约 130MB 存储空间,并且十分的简单,同时兼顾了安全性。此提案将 JDK 移植到了 Apline Linux,由于 Apline Linux 是基于 musl lib 的轻量级 Linux 发行版,因此其他 x64 和 AArch64 架构上使用 musl lib 的 Linux 发行版也适用。 JEP 388:Windows/AArch64 移植:这些 JEP 的重点不是移植工作本身,而是将它们集成到 JDK 主线存储库中;JEP 386 将 JDK 移植到 Alpine Linux 和其他使用 musl 作为 x64 上主要 C 库的发行版上。此外,JEP 388 将 JDK 移植到 Windows AArch64(ARM64)。 参考文献 # Java Language Changes Consolidated JDK 16 Release Notes Java 16 正式发布,新特性一一解析 实操 | 剖析 Java16 新语法特性(写的很赞) "},{"id":490,"href":"/zh/docs/technology/Interview/java/new-features/java17/","title":"Java 17 新特性概览(重要)","section":"New Features","content":"Java 17 在 2021 年 9 月 14 日正式发布,是一个长期支持(LTS)版本。\n下面这张图是 Oracle 官方给出的 Oracle JDK 支持的时间线。可以看得到,Java\n17 最多可以支持到 2029 年 9 月份。\nJava 17 将是继 Java 8 以来最重要的长期支持(LTS)版本,是 Java 社区八年努力的成果。Spring 6.x 和 Spring Boot 3.x 最低支持的就是 Java 17。\n这次更新共带来 14 个新特性:\nJEP 306:Restore Always-Strict Floating-Point Semantics(恢复始终严格的浮点语义) JEP 356:Enhanced Pseudo-Random Number Generators(增强的伪随机数生成器) JEP 382:New macOS Rendering Pipeline(新的 macOS 渲染管道) JEP 391:macOS/AArch64 Port(支持 macOS AArch64) JEP 398:Deprecate the Applet API for Removal(删除已弃用的 Applet API) JEP 403:Strongly Encapsulate JDK Internals(更强大的封装 JDK 内部元素) JEP 406:Pattern Matching for switch (switch 的类型匹配)(预览) JEP 407:Remove RMI Activation(删除远程方法调用激活机制) JEP 409:Sealed Classes(密封类)(转正) JEP 410:Remove the Experimental AOT and JIT Compiler(删除实验性的 AOT 和 JIT 编译器) JEP 411:Deprecate the Security Manager for Removal(弃用安全管理器以进行删除) JEP 412:Foreign Function \u0026amp; Memory API (外部函数和内存 API)(孵化) JEP 414:Vector(向量) API(第二次孵化) JEP 415:Context-Specific Deserialization Filters 这里只对 356、398、413、406、407、409、410、411、412、414 这几个我觉得比较重要的新特性进行详细介绍。\n相关阅读: OpenJDK Java 17 文档 。\nJEP 356:增强的伪随机数生成器 # JDK 17 之前,我们可以借助 Random、ThreadLocalRandom和SplittableRandom来生成随机数。不过,这 3 个类都各有缺陷,且缺少常见的伪随机算法支持。\nJava 17 为伪随机数生成器 (pseudorandom number generator,PRNG,又称为确定性随机位生成器)增加了新的接口类型和实现,使得开发者更容易在应用程序中互换使用各种 PRNG 算法。\nPRNG 用来生成接近于绝对随机数序列的数字序列。一般来说,PRNG 会依赖于一个初始值,也称为种子,来生成对应的伪随机数序列。只要种子确定了,PRNG 所生成的随机数就是完全确定的,因此其生成的随机数序列并不是真正随机的。\n使用示例:\nRandomGeneratorFactory\u0026lt;RandomGenerator\u0026gt; l128X256MixRandom = RandomGeneratorFactory.of(\u0026#34;L128X256MixRandom\u0026#34;); // 使用时间戳作为随机数种子 RandomGenerator randomGenerator = l128X256MixRandom.create(System.currentTimeMillis()); // 生成随机数 randomGenerator.nextInt(10); JEP 398:弃用 Applet API 以进行删除 # Applet API 用于编写在 Web 浏览器端运行的 Java 小程序,很多年前就已经被淘汰了,已经没有理由使用了。\nApplet API 在 Java 9 时被标记弃用( JEP 289),但不是为了删除。\nJEP 406:switch 的类型匹配(预览) # 正如 instanceof 一样, switch 也紧跟着增加了类型匹配自动转换功能。\ninstanceof 代码示例:\n// Old code if (o instanceof String) { String s = (String)o; ... use s ... } // New code if (o instanceof String s) { ... use s ... } switch 代码示例:\n// Old code static String formatter(Object o) { String formatted = \u0026#34;unknown\u0026#34;; if (o instanceof Integer i) { formatted = String.format(\u0026#34;int %d\u0026#34;, i); } else if (o instanceof Long l) { formatted = String.format(\u0026#34;long %d\u0026#34;, l); } else if (o instanceof Double d) { formatted = String.format(\u0026#34;double %f\u0026#34;, d); } else if (o instanceof String s) { formatted = String.format(\u0026#34;String %s\u0026#34;, s); } return formatted; } // New code static String formatterPatternSwitch(Object o) { return switch (o) { case Integer i -\u0026gt; String.format(\u0026#34;int %d\u0026#34;, i); case Long l -\u0026gt; String.format(\u0026#34;long %d\u0026#34;, l); case Double d -\u0026gt; String.format(\u0026#34;double %f\u0026#34;, d); case String s -\u0026gt; String.format(\u0026#34;String %s\u0026#34;, s); default -\u0026gt; o.toString(); }; } 对于 null 值的判断也进行了优化。\n// Old code static void testFooBar(String s) { if (s == null) { System.out.println(\u0026#34;oops!\u0026#34;); return; } switch (s) { case \u0026#34;Foo\u0026#34;, \u0026#34;Bar\u0026#34; -\u0026gt; System.out.println(\u0026#34;Great\u0026#34;); default -\u0026gt; System.out.println(\u0026#34;Ok\u0026#34;); } } // New code static void testFooBar(String s) { switch (s) { case null -\u0026gt; System.out.println(\u0026#34;Oops\u0026#34;); case \u0026#34;Foo\u0026#34;, \u0026#34;Bar\u0026#34; -\u0026gt; System.out.println(\u0026#34;Great\u0026#34;); default -\u0026gt; System.out.println(\u0026#34;Ok\u0026#34;); } } JEP 407:删除远程方法调用激活机制 # 删除远程方法调用 (RMI) 激活机制,同时保留 RMI 的其余部分。RMI 激活机制已过时且不再使用。\nJEP 409:密封类(转正) # 密封类由 JEP 360 提出预览,集成到了 Java 15 中。在 JDK 16 中, 密封类得到了改进(更加严格的引用检查和密封类的继承关系),由 JEP 397 提出了再次预览。\n在 Java 14 \u0026amp; 15 新特性概览 中,我有详细介绍到密封类,这里就不再做额外的介绍了。\nJEP 410:删除实验性的 AOT 和 JIT 编译器 # 在 Java 9 的 JEP 295 ,引入了实验性的提前 (AOT) 编译器,在启动虚拟机之前将 Java 类编译为本机代码。\nJava 17,删除实验性的提前 (AOT) 和即时 (JIT) 编译器,因为该编译器自推出以来很少使用,维护它所需的工作量很大。保留实验性的 Java 级 JVM 编译器接口 (JVMCI),以便开发人员可以继续使用外部构建的编译器版本进行 JIT 编译。\nJEP 411:弃用安全管理器以进行删除 # 弃用安全管理器以便在将来的版本中删除。\n安全管理器可追溯到 Java 1.0,多年来,它一直不是保护客户端 Java 代码的主要方法,也很少用于保护服务器端代码。为了推动 Java 向前发展,Java 17 弃用安全管理器,以便与旧版 Applet API ( JEP 398 ) 一起移除。\nJEP 412:外部函数和内存 API(孵化) # Java 程序可以通过该 API 与 Java 运行时之外的代码和数据进行互操作。通过高效地调用外部函数(即 JVM 之外的代码)和安全地访问外部内存(即不受 JVM 管理的内存),该 API 使 Java 程序能够调用本机库并处理本机数据,而不会像 JNI 那样危险和脆弱。\n外部函数和内存 API 在 Java 17 中进行了第一轮孵化,由 JEP 412 提出。第二轮孵化由 JEP 419 提出并集成到了 Java 18 中,预览由 JEP 424 提出并集成到了 Java 19 中。\n在 Java 19 新特性概览 中,我有详细介绍到外部函数和内存 API,这里就不再做额外的介绍了。\nJEP 414:向量 API(第二次孵化) # 向量(Vector) API 最初由 JEP 338 提出,并作为 孵化 API集成到 Java 16 中。第二轮孵化由 JEP 414 提出并集成到 Java 17 中,第三轮孵化由 JEP 417 提出并集成到 Java 18 中,第四轮由 JEP 426 提出并集成到了 Java 19 中。\n该孵化器 API 提供了一个 API 的初始迭代以表达一些向量计算,这些计算在运行时可靠地编译为支持的 CPU 架构上的最佳向量硬件指令,从而获得优于同等标量计算的性能,充分利用单指令多数据(SIMD)技术(大多数现代 CPU 上都可以使用的一种指令)。尽管 HotSpot 支持自动向量化,但是可转换的标量操作集有限且易受代码更改的影响。该 API 将使开发人员能够轻松地用 Java 编写可移植的高性能向量算法。\n在 Java 18 新特性概览 中,我有详细介绍到向量 API,这里就不再做额外的介绍了。\n"},{"id":491,"href":"/zh/docs/technology/Interview/java/new-features/java18/","title":"Java 18 新特性概览","section":"New Features","content":"Java 18 在 2022 年 3 月 22 日正式发布,非长期支持版本。\nJava 18 带来了 9 个新特性:\nJEP 400:UTF-8 by Default(默认字符集为 UTF-8) JEP 408:Simple Web Server(简易的 Web 服务器) JEP 413:Code Snippets in Java API Documentation(Java API 文档中的代码片段) JEP 416:Reimplement Core Reflection with Method Handles(使用方法句柄重新实现反射核心) JEP 417:Vector(向量) API(第三次孵化) JEP 418:Internet-Address Resolution(互联网地址解析)SPI JEP 419:Foreign Function \u0026amp; Memory API(外部函数和内存 API)(第二次孵化) JEP 420:Pattern Matching for switch(switch 模式匹配)(第二次预览) JEP 421:Deprecate Finalization for Removal Java 17 中包含 14 个特性,Java 16 中包含 17 个特性,Java 15 中包含 14 个特性,Java 14 中包含 16 个特性。相比于前面发布的版本来说,Java 18 的新特性少了很多。\n这里只对 400、408、413、416、417、418、419 这几个我觉得比较重要的新特性进行详细介绍。\n相关阅读:\nOpenJDK Java 18 文档 IntelliJ IDEA | Java 18 功能支持 JEP 400:默认字符集为 UTF-8 # JDK 终于将 UTF-8 设置为默认字符集。\n在 Java 17 及更早版本中,默认字符集是在 Java 虚拟机运行时才确定的,取决于不同的操作系统、区域设置等因素,因此存在潜在的风险。就比如说你在 Mac 上运行正常的一段打印文字到控制台的 Java 程序到了 Windows 上就会出现乱码,如果你不手动更改字符集的话。\nJEP 408:简易的 Web 服务器 # Java 18 之后,你可以使用 jwebserver 命令启动一个简易的静态 Web 服务器。\n$ jwebserver Binding to loopback by default. For all interfaces use \u0026#34;-b 0.0.0.0\u0026#34; or \u0026#34;-b ::\u0026#34;. Serving /cwd and subdirectories on 127.0.0.1 port 8000 URL: http://127.0.0.1:8000/ 这个服务器不支持 CGI 和 Servlet,只限于静态文件。\nJEP 413:优化 Java API 文档中的代码片段 # 在 Java 18 之前,如果我们想要在 Javadoc 中引入代码片段可以使用 \u0026lt;pre\u0026gt;{@code ...}\u0026lt;/pre\u0026gt; 。\n\u0026lt;pre\u0026gt;{@code lines of source code }\u0026lt;/pre\u0026gt; \u0026lt;pre\u0026gt;{@code ...}\u0026lt;/pre\u0026gt; 这种方式生成的效果比较一般。\n在 Java 18 之后,可以通过 @snippet 标签来做这件事情。\n/** * The following code shows how to use {@code Optional.isPresent}: * {@snippet : * if (v.isPresent()) { * System.out.println(\u0026#34;v: \u0026#34; + v.get()); * } * } */ @snippet 这种方式生成的效果更好且使用起来更方便一些。\nJEP 416:使用方法句柄重新实现反射核心 # Java 18 改进了 java.lang.reflect.Method、Constructor 的实现逻辑,使之性能更好,速度更快。这项改动不会改动相关 API ,这意味着开发中不需要改动反射相关代码,就可以体验到性能更好反射。\nOpenJDK 官方给出了新老实现的反射性能基准测试结果。\nJEP 417: 向量 API(第三次孵化) # 向量(Vector) API 最初由 JEP 338 提出,并作为 孵化 API集成到 Java 16 中。第二轮孵化由 JEP 414 提出并集成到 Java 17 中,第三轮孵化由 JEP 417 提出并集成到 Java 18 中,第四轮由 JEP 426 提出并集成到了 Java 19 中。\n向量计算由对向量的一系列操作组成。向量 API 用来表达向量计算,该计算可以在运行时可靠地编译为支持的 CPU 架构上的最佳向量指令,从而实现优于等效标量计算的性能。\n向量 API 的目标是为用户提供简洁易用且与平台无关的表达范围广泛的向量计算。\n这是对数组元素的简单标量计算:\nvoid scalarComputation(float[] a, float[] b, float[] c) { for (int i = 0; i \u0026lt; a.length; i++) { c[i] = (a[i] * a[i] + b[i] * b[i]) * -1.0f; } } 这是使用 Vector API 进行的等效向量计算:\nstatic final VectorSpecies\u0026lt;Float\u0026gt; SPECIES = FloatVector.SPECIES_PREFERRED; void vectorComputation(float[] a, float[] b, float[] c) { int i = 0; int upperBound = SPECIES.loopBound(a.length); for (; i \u0026lt; upperBound; i += SPECIES.length()) { // FloatVector va, vb, vc; var va = FloatVector.fromArray(SPECIES, a, i); var vb = FloatVector.fromArray(SPECIES, b, i); var vc = va.mul(va) .add(vb.mul(vb)) .neg(); vc.intoArray(c, i); } for (; i \u0026lt; a.length; i++) { c[i] = (a[i] * a[i] + b[i] * b[i]) * -1.0f; } } 在 JDK 18 中,向量 API 的性能得到了进一步的优化。\nJEP 418:互联网地址解析 SPI # Java 18 定义了一个全新的 SPI(service-provider interface),用于主要名称和地址的解析,以便 java.net.InetAddress 可以使用平台之外的第三方解析器。\nJEP 419:Foreign Function \u0026amp; Memory API(第二次孵化) # Java 程序可以通过该 API 与 Java 运行时之外的代码和数据进行互操作。通过高效地调用外部函数(即 JVM 之外的代码)和安全地访问外部内存(即不受 JVM 管理的内存),该 API 使 Java 程序能够调用本机库并处理本机数据,而不会像 JNI 那样危险和脆弱。\n外部函数和内存 API 在 Java 17 中进行了第一轮孵化,由 JEP 412 提出。第二轮孵化由 JEP 419 提出并集成到了 Java 18 中,预览由 JEP 424 提出并集成到了 Java 19 中。\n在 Java 19 新特性概览 中,我有详细介绍到外部函数和内存 API,这里就不再做额外的介绍了。\n"},{"id":492,"href":"/zh/docs/technology/Interview/java/new-features/java19/","title":"Java 19 新特性概览","section":"New Features","content":"JDK 19 定于 2022 年 9 月 20 日正式发布以供生产使用,非长期支持版本。不过,JDK 19 中有一些比较重要的新特性值得关注。\nJDK 19 只有 7 个新特性:\nJEP 405: Record Patterns(记录模式)(预览) JEP 422: Linux/RISC-V Port JEP 424: Foreign Function \u0026amp; Memory API(外部函数和内存 API)(预览) JEP 425: Virtual Threads(虚拟线程)(预览) JEP 426: Vector(向量)API(第四次孵化) JEP 427: Pattern Matching for switch(switch 模式匹配) JEP 428: Structured Concurrency(结构化并发)(孵化) 这里只对 424、425、426、428 这 4 个我觉得比较重要的新特性进行详细介绍。\n相关阅读: OpenJDK Java 19 文档\nJEP 424: 外部函数和内存 API(预览) # Java 程序可以通过该 API 与 Java 运行时之外的代码和数据进行互操作。通过高效地调用外部函数(即 JVM 之外的代码)和安全地访问外部内存(即不受 JVM 管理的内存),该 API 使 Java 程序能够调用本机库并处理本机数据,而不会像 JNI 那样危险和脆弱。\n外部函数和内存 API 在 Java 17 中进行了第一轮孵化,由 JEP 412 提出。第二轮孵化由 JEP 419 提出并集成到了 Java 18 中,预览由 JEP 424 提出并集成到了 Java 19 中。\n在没有外部函数和内存 API 之前:\nJava 通过 sun.misc.Unsafe 提供一些执行低级别、不安全操作的方法(如直接访问系统内存资源、自主管理内存资源等),Unsafe 类让 Java 语言拥有了类似 C 语言指针一样操作内存空间的能力的同时,也增加了 Java 语言的不安全性,不正确使用 Unsafe 类会使得程序出错的概率变大。 Java 1.1 就已通过 Java 原生接口(JNI)支持了原生方法调用,但并不好用。JNI 实现起来过于复杂,步骤繁琐(具体的步骤可以参考这篇文章: Guide to JNI (Java Native Interface) ),不受 JVM 的语言安全机制控制,影响 Java 语言的跨平台特性。并且,JNI 的性能也不行,因为 JNI 方法调用不能从许多常见的 JIT 优化(如内联)中受益。虽然 JNA、 JNR和 JavaCPP等框架对 JNI 进行了改进,但效果还是不太理想。 引入外部函数和内存 API 就是为了解决 Java 访问外部函数和外部内存存在的一些痛点。\nForeign Function \u0026amp; Memory API (FFM API) 定义了类和接口:\n分配外部内存:MemorySegment、MemoryAddress和SegmentAllocator; 操作和访问结构化的外部内存:MemoryLayout, VarHandle; 控制外部内存的分配和释放:MemorySession; 调用外部函数:Linker、FunctionDescriptor和SymbolLookup。 下面是 FFM API 使用示例,这段代码获取了 C 库函数的 radixsort 方法句柄,然后使用它对 Java 数组中的四个字符串进行排序。\n// 1. 在C库路径上查找外部函数 Linker linker = Linker.nativeLinker(); SymbolLookup stdlib = linker.defaultLookup(); MethodHandle radixSort = linker.downcallHandle( stdlib.lookup(\u0026#34;radixsort\u0026#34;), ...); // 2. 分配堆上内存以存储四个字符串 String[] javaStrings = { \u0026#34;mouse\u0026#34;, \u0026#34;cat\u0026#34;, \u0026#34;dog\u0026#34;, \u0026#34;car\u0026#34; }; // 3. 分配堆外内存以存储四个指针 SegmentAllocator allocator = implicitAllocator(); MemorySegment offHeap = allocator.allocateArray(ValueLayout.ADDRESS, javaStrings.length); // 4. 将字符串从堆上复制到堆外 for (int i = 0; i \u0026lt; javaStrings.length; i++) { // 在堆外分配一个字符串,然后存储指向它的指针 MemorySegment cString = allocator.allocateUtf8String(javaStrings[i]); offHeap.setAtIndex(ValueLayout.ADDRESS, i, cString); } // 5. 通过调用外部函数对堆外数据进行排序 radixSort.invoke(offHeap, javaStrings.length, MemoryAddress.NULL, \u0026#39;\\0\u0026#39;); // 6. 将(重新排序的)字符串从堆外复制到堆上 for (int i = 0; i \u0026lt; javaStrings.length; i++) { MemoryAddress cStringPtr = offHeap.getAtIndex(ValueLayout.ADDRESS, i); javaStrings[i] = cStringPtr.getUtf8String(0); } assert Arrays.equals(javaStrings, new String[] {\u0026#34;car\u0026#34;, \u0026#34;cat\u0026#34;, \u0026#34;dog\u0026#34;, \u0026#34;mouse\u0026#34;}); // true JEP 425: 虚拟线程(预览) # 虚拟线程(Virtual Thread-)是 JDK 而不是 OS 实现的轻量级线程(Lightweight Process,LWP),许多虚拟线程共享同一个操作系统线程,虚拟线程的数量可以远大于操作系统线程的数量。\n虚拟线程在其他多线程语言中已经被证实是十分有用的,比如 Go 中的 Goroutine、Erlang 中的进程。\n虚拟线程避免了上下文切换的额外耗费,兼顾了多线程的优点,简化了高并发程序的复杂,可以有效减少编写、维护和观察高吞吐量并发应用程序的工作量。\n知乎有一个关于 Java 19 虚拟线程的讨论,感兴趣的可以去看看: https://www.zhihu.com/question/536743167 。\nJava 虚拟线程的详细解读和原理可以看下面这两篇文章:\n虚拟线程原理及性能分析|得物技术 Java19 正式 GA!看虚拟线程如何大幅提高系统吞吐量 虚拟线程 - VirtualThread 源码透视 JEP 426: 向量 API(第四次孵化) # 向量(Vector) API 最初由 JEP 338 提出,并作为 孵化 API集成到 Java 16 中。第二轮孵化由 JEP 414 提出并集成到 Java 17 中,第三轮孵化由 JEP 417 提出并集成到 Java 18 中,第四轮由 JEP 426 提出并集成到了 Java 19 中。\n在 Java 18 新特性概览 中,我有详细介绍到向量 API,这里就不再做额外的介绍了。\nJEP 428: 结构化并发(孵化) # JDK 19 引入了结构化并发,一种多线程编程方法,目的是为了通过结构化并发 API 来简化多线程编程,并不是为了取代java.util.concurrent,目前处于孵化器阶段。\n结构化并发将不同线程中运行的多个任务视为单个工作单元,从而简化错误处理、提高可靠性并增强可观察性。也就是说,结构化并发保留了单线程代码的可读性、可维护性和可观察性。\n结构化并发的基本 API 是 StructuredTaskScope。StructuredTaskScope 支持将任务拆分为多个并发子任务,在它们自己的线程中执行,并且子任务必须在主任务继续之前完成。\nStructuredTaskScope 的基本用法如下:\ntry (var scope = new StructuredTaskScope\u0026lt;Object\u0026gt;()) { // 使用fork方法派生线程来执行子任务 Future\u0026lt;Integer\u0026gt; future1 = scope.fork(task1); Future\u0026lt;String\u0026gt; future2 = scope.fork(task2); // 等待线程完成 scope.join(); // 结果的处理可能包括处理或重新抛出异常 ... process results/exceptions ... } // close 结构化并发非常适合虚拟线程,虚拟线程是 JDK 实现的轻量级线程。许多虚拟线程共享同一个操作系统线程,从而允许非常多的虚拟线程。\n"},{"id":493,"href":"/zh/docs/technology/Interview/java/new-features/java20/","title":"Java 20 新特性概览","section":"New Features","content":"JDK 20 于 2023 年 3 月 21 日发布,非长期支持版本。\n根据开发计划,下一个 LTS 版本就是将于 2023 年 9 月发布的 JDK 21。\nJDK 20 只有 7 个新特性:\nJEP 429:Scoped Values(作用域值)(第一次孵化) JEP 432:Record Patterns(记录模式)(第二次预览) JEP 433:switch 模式匹配(第四次预览) JEP 434: Foreign Function \u0026amp; Memory API(外部函数和内存 API)(第二次预览) JEP 436: Virtual Threads(虚拟线程)(第二次预览) JEP 437:Structured Concurrency(结构化并发)(第二次孵化) JEP 432:向量 API(第五次孵化) JEP 429:作用域值(第一次孵化) # 作用域值(Scoped Values)它可以在线程内和线程间共享不可变的数据,优于线程局部变量,尤其是在使用大量虚拟线程时。\nfinal static ScopedValue\u0026lt;...\u0026gt; V = new ScopedValue\u0026lt;\u0026gt;(); // In some method ScopedValue.where(V, \u0026lt;value\u0026gt;) .run(() -\u0026gt; { ... V.get() ... call methods ... }); // In a method called directly or indirectly from the lambda expression ... V.get() ... 作用域值允许在大型程序中的组件之间安全有效地共享数据,而无需求助于方法参数。\n关于作用域值的详细介绍,推荐阅读 作用域值常见问题解答这篇文章。\nJEP 432:记录模式(第二次预览) # 记录模式(Record Patterns) 可对 record 的值进行解构,也就是更方便地从记录类(Record Class)中提取数据。并且,还可以嵌套记录模式和类型模式结合使用,以实现强大的、声明性的和可组合的数据导航和处理形式。\n记录模式不能单独使用,而是要与 instanceof 或 switch 模式匹配一同使用。\n先以 instanceof 为例简单演示一下。\n简单定义一个记录类:\nrecord Shape(String type, long unit){} 没有记录模式之前:\nShape circle = new Shape(\u0026#34;Circle\u0026#34;, 10); if (circle instanceof Shape shape) { System.out.println(\u0026#34;Area of \u0026#34; + shape.type() + \u0026#34; is : \u0026#34; + Math.PI * Math.pow(shape.unit(), 2)); } 有了记录模式之后:\nShape circle = new Shape(\u0026#34;Circle\u0026#34;, 10); if (circle instanceof Shape(String type, long unit)) { System.out.println(\u0026#34;Area of \u0026#34; + type + \u0026#34; is : \u0026#34; + Math.PI * Math.pow(unit, 2)); } 再看看记录模式与 switch 的配合使用。\n定义一些类:\ninterface Shape {} record Circle(double radius) implements Shape { } record Square(double side) implements Shape { } record Rectangle(double length, double width) implements Shape { } 没有记录模式之前:\nShape shape = new Circle(10); switch (shape) { case Circle c: System.out.println(\u0026#34;The shape is Circle with area: \u0026#34; + Math.PI * c.radius() * c.radius()); break; case Square s: System.out.println(\u0026#34;The shape is Square with area: \u0026#34; + s.side() * s.side()); break; case Rectangle r: System.out.println(\u0026#34;The shape is Rectangle with area: + \u0026#34; + r.length() * r.width()); break; default: System.out.println(\u0026#34;Unknown Shape\u0026#34;); break; } 有了记录模式之后:\nShape shape = new Circle(10); switch(shape) { case Circle(double radius): System.out.println(\u0026#34;The shape is Circle with area: \u0026#34; + Math.PI * radius * radius); break; case Square(double side): System.out.println(\u0026#34;The shape is Square with area: \u0026#34; + side * side); break; case Rectangle(double length, double width): System.out.println(\u0026#34;The shape is Rectangle with area: + \u0026#34; + length * width); break; default: System.out.println(\u0026#34;Unknown Shape\u0026#34;); break; } 记录模式可以避免不必要的转换,使得代码更建简洁易读。而且,用了记录模式后不必再担心 null 或者 NullPointerException,代码更安全可靠。\n记录模式在 Java 19 进行了第一次预览, 由 JEP 405 提出。JDK 20 中是第二次预览,由 JEP 432 提出。这次的改进包括:\n添加对通用记录模式类型参数推断的支持, 添加对记录模式的支持以出现在增强语句的标题中for 删除对命名记录模式的支持。 注意:不要把记录模式和 JDK16 正式引入的记录类搞混了。\nJEP 433:switch 模式匹配(第四次预览) # 正如 instanceof 一样, switch 也紧跟着增加了类型匹配自动转换功能。\ninstanceof 代码示例:\n// Old code if (o instanceof String) { String s = (String)o; ... use s ... } // New code if (o instanceof String s) { ... use s ... } switch 代码示例:\n// Old code static String formatter(Object o) { String formatted = \u0026#34;unknown\u0026#34;; if (o instanceof Integer i) { formatted = String.format(\u0026#34;int %d\u0026#34;, i); } else if (o instanceof Long l) { formatted = String.format(\u0026#34;long %d\u0026#34;, l); } else if (o instanceof Double d) { formatted = String.format(\u0026#34;double %f\u0026#34;, d); } else if (o instanceof String s) { formatted = String.format(\u0026#34;String %s\u0026#34;, s); } return formatted; } // New code static String formatterPatternSwitch(Object o) { return switch (o) { case Integer i -\u0026gt; String.format(\u0026#34;int %d\u0026#34;, i); case Long l -\u0026gt; String.format(\u0026#34;long %d\u0026#34;, l); case Double d -\u0026gt; String.format(\u0026#34;double %f\u0026#34;, d); case String s -\u0026gt; String.format(\u0026#34;String %s\u0026#34;, s); default -\u0026gt; o.toString(); }; } switch 模式匹配分别在 Java17、Java18、Java19 中进行了预览,Java20 是第四次预览了。每一次的预览基本都会有一些小改进,这里就不细提了。\nJEP 434: 外部函数和内存 API(第二次预览) # Java 程序可以通过该 API 与 Java 运行时之外的代码和数据进行互操作。通过高效地调用外部函数(即 JVM 之外的代码)和安全地访问外部内存(即不受 JVM 管理的内存),该 API 使 Java 程序能够调用本机库并处理本机数据,而不会像 JNI 那样危险和脆弱。\n外部函数和内存 API 在 Java 17 中进行了第一轮孵化,由 JEP 412 提出。Java 18 中进行了第二次孵化,由 JEP 419 提出。Java 19 中是第一次预览,由 JEP 424 提出。\nJDK 20 中是第二次预览,由 JEP 434 提出,这次的改进包括:\nMemorySegment 和 MemoryAddress 抽象的统一 增强的 MemoryLayout 层次结构 MemorySession拆分为Arena和SegmentScope,以促进跨维护边界的段共享。 在 Java 19 新特性概览 中,我有详细介绍到外部函数和内存 API,这里就不再做额外的介绍了。\nJEP 436: 虚拟线程(第二次预览) # 虚拟线程(Virtual Thread)是 JDK 而不是 OS 实现的轻量级线程(Lightweight Process,LWP),由 JVM 调度。许多虚拟线程共享同一个操作系统线程,虚拟线程的数量可以远大于操作系统线程的数量。\n在引入虚拟线程之前,java.lang.Thread 包已经支持所谓的平台线程,也就是没有虚拟线程之前,我们一直使用的线程。JVM 调度程序通过平台线程(载体线程)来管理虚拟线程,一个平台线程可以在不同的时间执行不同的虚拟线程(多个虚拟线程挂载在一个平台线程上),当虚拟线程被阻塞或等待时,平台线程可以切换到执行另一个虚拟线程。\n虚拟线程、平台线程和系统内核线程的关系图如下所示(图源: How to Use Java 19 Virtual Threads):\n关于平台线程和系统内核线程的对应关系多提一点:在 Windows 和 Linux 等主流操作系统中,Java 线程采用的是一对一的线程模型,也就是一个平台线程对应一个系统内核线程。Solaris 系统是一个特例,HotSpot VM 在 Solaris 上支持多对多和一对一。具体可以参考 R 大的回答: JVM 中的线程模型是用户级的么?。\n相比较于平台线程来说,虚拟线程是廉价且轻量级的,使用完后立即被销毁,因此它们不需要被重用或池化,每个任务可以有自己专属的虚拟线程来运行。虚拟线程暂停和恢复来实现线程之间的切换,避免了上下文切换的额外耗费,兼顾了多线程的优点,简化了高并发程序的复杂,可以有效减少编写、维护和观察高吞吐量并发应用程序的工作量。\n虚拟线程在其他多线程语言中已经被证实是十分有用的,比如 Go 中的 Goroutine、Erlang 中的进程。\n知乎有一个关于 Java 19 虚拟线程的讨论,感兴趣的可以去看看: https://www.zhihu.com/question/536743167 。\nJava 虚拟线程的详细解读和原理可以看下面这几篇文章:\n虚拟线程极简入门 Java19 正式 GA!看虚拟线程如何大幅提高系统吞吐量 虚拟线程 - VirtualThread 源码透视 虚拟线程在 Java 19 中进行了第一次预览,由 JEP 425提出。JDK 20 中是第二次预览,做了一些细微变化,这里就不细提了。\n最后,我们来看一下四种创建虚拟线程的方法:\n// 1、通过 Thread.ofVirtual() 创建 Runnable fn = () -\u0026gt; { // your code here }; Thread thread = Thread.ofVirtual(fn) .start(); // 2、通过 Thread.startVirtualThread() 、创建 Thread thread = Thread.startVirtualThread(() -\u0026gt; { // your code here }); // 3、通过 Executors.newVirtualThreadPerTaskExecutor() 创建 var executorService = Executors.newVirtualThreadPerTaskExecutor(); executorService.submit(() -\u0026gt; { // your code here }); class CustomThread implements Runnable { @Override public void run() { System.out.println(\u0026#34;CustomThread run\u0026#34;); } } //4、通过 ThreadFactory 创建 CustomThread customThread = new CustomThread(); // 获取线程工厂类 ThreadFactory factory = Thread.ofVirtual().factory(); // 创建虚拟线程 Thread thread = factory.newThread(customThread); // 启动线程 thread.start(); 通过上述列举的 4 种创建虚拟线程的方式可以看出,官方为了降低虚拟线程的门槛,尽力复用原有的 Thread 线程类,这样可以平滑的过渡到虚拟线程的使用。\nJEP 437: 结构化并发(第二次孵化) # Java 19 引入了结构化并发,一种多线程编程方法,目的是为了通过结构化并发 API 来简化多线程编程,并不是为了取代java.util.concurrent,目前处于孵化器阶段。\n结构化并发将不同线程中运行的多个任务视为单个工作单元,从而简化错误处理、提高可靠性并增强可观察性。也就是说,结构化并发保留了单线程代码的可读性、可维护性和可观察性。\n结构化并发的基本 API 是 StructuredTaskScope。StructuredTaskScope 支持将任务拆分为多个并发子任务,在它们自己的线程中执行,并且子任务必须在主任务继续之前完成。\nStructuredTaskScope 的基本用法如下:\ntry (var scope = new StructuredTaskScope\u0026lt;Object\u0026gt;()) { // 使用fork方法派生线程来执行子任务 Future\u0026lt;Integer\u0026gt; future1 = scope.fork(task1); Future\u0026lt;String\u0026gt; future2 = scope.fork(task2); // 等待线程完成 scope.join(); // 结果的处理可能包括处理或重新抛出异常 ... process results/exceptions ... } // close 结构化并发非常适合虚拟线程,虚拟线程是 JDK 实现的轻量级线程。许多虚拟线程共享同一个操作系统线程,从而允许非常多的虚拟线程。\nJDK 20 中对结构化并发唯一变化是更新为支持在任务范围内创建的线程StructuredTaskScope继承范围值 这简化了跨线程共享不可变数据,详见 JEP 429。\nJEP 432:向量 API(第五次孵化) # 向量计算由对向量的一系列操作组成。向量 API 用来表达向量计算,该计算可以在运行时可靠地编译为支持的 CPU 架构上的最佳向量指令,从而实现优于等效标量计算的性能。\n向量 API 的目标是为用户提供简洁易用且与平台无关的表达范围广泛的向量计算。\n向量(Vector) API 最初由 JEP 338 提出,并作为 孵化 API集成到 Java 16 中。第二轮孵化由 JEP 414 提出并集成到 Java 17 中,第三轮孵化由 JEP 417 提出并集成到 Java 18 中,第四轮由 JEP 426 提出并集成到了 Java 19 中。\nJava20 的这次孵化基本没有改变向量 API ,只是进行了一些错误修复和性能增强,详见 JEP 438。\n"},{"id":494,"href":"/zh/docs/technology/Interview/java/new-features/java21/","title":"Java 21 新特性概览(重要)","section":"New Features","content":"JDK 21 于 2023 年 9 月 19 日 发布,这是一个非常重要的版本,里程碑式。\nJDK21 是 LTS(长期支持版),至此为止,目前有 JDK8、JDK11、JDK17 和 JDK21 这四个长期支持版了。\nJDK 21 共有 15 个新特性,这篇文章会挑选其中较为重要的一些新特性进行详细介绍:\nJEP 430:String Templates(字符串模板)(预览)\nJEP 431:Sequenced Collections(序列化集合)\nJEP 439:Generational ZGC(分代 ZGC)\nJEP 440:Record Patterns(记录模式)\nJEP 441:Pattern Matching for switch(switch 的模式匹配)\nJEP 442:Foreign Function \u0026amp; Memory API(外部函数和内存 API)(第三次预览)\nJEP 443:Unnamed Patterns and Variables(未命名模式和变量(预览)\nJEP 444:Virtual Threads(虚拟线程)\nJEP 445:Unnamed Classes and Instance Main Methods(未命名类和实例 main 方法 )(预览)\nJEP 430:字符串模板(预览) # String Templates(字符串模板) 目前仍然是 JDK 21 中的一个预览功能。\nString Templates 提供了一种更简洁、更直观的方式来动态构建字符串。通过使用占位符${},我们可以将变量的值直接嵌入到字符串中,而不需要手动处理。在运行时,Java 编译器会将这些占位符替换为实际的变量值。并且,表达式支持局部变量、静态/非静态字段甚至方法、计算结果等特性。\n实际上,String Templates(字符串模板)再大多数编程语言中都存在:\n\u0026#34;Greetings {{ name }}!\u0026#34;; //Angular `Greetings ${ name }!`; //Typescript $\u0026#34;Greetings { name }!\u0026#34; //Visual basic f\u0026#34;Greetings { name }!\u0026#34; //Python Java 在没有 String Templates 之前,我们通常使用字符串拼接或格式化方法来构建字符串:\n//concatenation message = \u0026#34;Greetings \u0026#34; + name + \u0026#34;!\u0026#34;; //String.format() message = String.format(\u0026#34;Greetings %s!\u0026#34;, name); //concatenation //MessageFormat message = new MessageFormat(\u0026#34;Greetings {0}!\u0026#34;).format(name); //StringBuilder message = new StringBuilder().append(\u0026#34;Greetings \u0026#34;).append(name).append(\u0026#34;!\u0026#34;).toString(); 这些方法或多或少都存在一些缺点,比如难以阅读、冗长、复杂。\nJava 使用 String Templates 进行字符串拼接,可以直接在字符串中嵌入表达式,而无需进行额外的处理:\nString message = STR.\u0026#34;Greetings \\{name}!\u0026#34;; 在上面的模板表达式中:\nSTR 是模板处理器。 \\{name}为表达式,运行时,这些表达式将被相应的变量值替换。 Java 目前支持三种模板处理器:\nSTR:自动执行字符串插值,即将模板中的每个嵌入式表达式替换为其值(转换为字符串)。 FMT:和 STR 类似,但是它还可以接受格式说明符,这些格式说明符出现在嵌入式表达式的左边,用来控制输出的样式。 RAW:不会像 STR 和 FMT 模板处理器那样自动处理字符串模板,而是返回一个 StringTemplate 对象,这个对象包含了模板中的文本和表达式的信息。 String name = \u0026#34;Lokesh\u0026#34;; //STR String message = STR.\u0026#34;Greetings \\{name}.\u0026#34;; //FMT String message = STR.\u0026#34;Greetings %-12s\\{name}.\u0026#34;; //RAW StringTemplate st = RAW.\u0026#34;Greetings \\{name}.\u0026#34;; String message = STR.process(st); 除了 JDK 自带的三种模板处理器外,你还可以实现 StringTemplate.Processor 接口来创建自己的模板处理器,只需要继承 StringTemplate.Processor接口,然后实现 process 方法即可。\n我们可以使用局部变量、静态/非静态字段甚至方法作为嵌入表达式:\n//variable message = STR.\u0026#34;Greetings \\{name}!\u0026#34;; //method message = STR.\u0026#34;Greetings \\{getName()}!\u0026#34;; //field message = STR.\u0026#34;Greetings \\{this.name}!\u0026#34;; 还可以在表达式中执行计算并打印结果:\nint x = 10, y = 20; String s = STR.\u0026#34;\\{x} + \\{y} = \\{x + y}\u0026#34;; //\u0026#34;10 + 20 = 30\u0026#34; 为了提高可读性,我们可以将嵌入的表达式分成多行:\nString time = STR.\u0026#34;The current time is \\{ //sample comment - current time in HH:mm:ss DateTimeFormatter .ofPattern(\u0026#34;HH:mm:ss\u0026#34;) .format(LocalTime.now()) }.\u0026#34;; JEP431:序列化集合 # JDK 21 引入了一种新的集合类型:Sequenced Collections(序列化集合,也叫有序集合),这是一种具有确定出现顺序(encounter order)的集合(无论我们遍历这样的集合多少次,元素的出现顺序始终是固定的)。序列化集合提供了处理集合的第一个和最后一个元素以及反向视图(与原始集合相反的顺序)的简单方法。\nSequenced Collections 包括以下三个接口:\nSequencedCollection SequencedSet SequencedMap SequencedCollection 接口继承了 Collection接口, 提供了在集合两端访问、添加或删除元素以及获取集合的反向视图的方法。\ninterface SequencedCollection\u0026lt;E\u0026gt; extends Collection\u0026lt;E\u0026gt; { // New Method SequencedCollection\u0026lt;E\u0026gt; reversed(); // Promoted methods from Deque\u0026lt;E\u0026gt; void addFirst(E); void addLast(E); E getFirst(); E getLast(); E removeFirst(); E removeLast(); } List 和 Deque 接口实现了SequencedCollection 接口。\n这里以 ArrayList 为例,演示一下实际使用效果:\nArrayList\u0026lt;Integer\u0026gt; arrayList = new ArrayList\u0026lt;\u0026gt;(); arrayList.add(1); // List contains: [1] arrayList.addFirst(0); // List contains: [0, 1] arrayList.addLast(2); // List contains: [0, 1, 2] Integer firstElement = arrayList.getFirst(); // 0 Integer lastElement = arrayList.getLast(); // 2 List\u0026lt;Integer\u0026gt; reversed = arrayList.reversed(); System.out.println(reversed); // Prints [2, 1, 0] SequencedSet接口直接继承了 SequencedCollection 接口并重写了 reversed() 方法。\ninterface SequencedSet\u0026lt;E\u0026gt; extends SequencedCollection\u0026lt;E\u0026gt;, Set\u0026lt;E\u0026gt; { SequencedSet\u0026lt;E\u0026gt; reversed(); } SortedSet 和 LinkedHashSet 实现了SequencedSet接口。\n这里以 LinkedHashSet 为例,演示一下实际使用效果:\nLinkedHashSet\u0026lt;Integer\u0026gt; linkedHashSet = new LinkedHashSet\u0026lt;\u0026gt;(List.of(1, 2, 3)); Integer firstElement = linkedHashSet.getFirst(); // 1 Integer lastElement = linkedHashSet.getLast(); // 3 linkedHashSet.addFirst(0); //List contains: [0, 1, 2, 3] linkedHashSet.addLast(4); //List contains: [0, 1, 2, 3, 4] System.out.println(linkedHashSet.reversed()); //Prints [5, 3, 2, 1, 0] SequencedMap 接口继承了 Map接口, 提供了在集合两端访问、添加或删除键值对、获取包含 key 的 SequencedSet、包含 value 的 SequencedCollection、包含 entry(键值对) 的 SequencedSet以及获取集合的反向视图的方法。\ninterface SequencedMap\u0026lt;K,V\u0026gt; extends Map\u0026lt;K,V\u0026gt; { // New Methods SequencedMap\u0026lt;K,V\u0026gt; reversed(); SequencedSet\u0026lt;K\u0026gt; sequencedKeySet(); SequencedCollection\u0026lt;V\u0026gt; sequencedValues(); SequencedSet\u0026lt;Entry\u0026lt;K,V\u0026gt;\u0026gt; sequencedEntrySet(); V putFirst(K, V); V putLast(K, V); // Promoted Methods from NavigableMap\u0026lt;K, V\u0026gt; Entry\u0026lt;K, V\u0026gt; firstEntry(); Entry\u0026lt;K, V\u0026gt; lastEntry(); Entry\u0026lt;K, V\u0026gt; pollFirstEntry(); Entry\u0026lt;K, V\u0026gt; pollLastEntry(); } SortedMap 和LinkedHashMap 实现了SequencedMap 接口。\n这里以 LinkedHashMap 为例,演示一下实际使用效果:\nLinkedHashMap\u0026lt;Integer, String\u0026gt; map = new LinkedHashMap\u0026lt;\u0026gt;(); map.put(1, \u0026#34;One\u0026#34;); map.put(2, \u0026#34;Two\u0026#34;); map.put(3, \u0026#34;Three\u0026#34;); map.firstEntry(); //1=One map.lastEntry(); //3=Three System.out.println(map); //{1=One, 2=Two, 3=Three} Map.Entry\u0026lt;Integer, String\u0026gt; first = map.pollFirstEntry(); //1=One Map.Entry\u0026lt;Integer, String\u0026gt; last = map.pollLastEntry(); //3=Three System.out.println(map); //{2=Two} map.putFirst(1, \u0026#34;One\u0026#34;); //{1=One, 2=Two} map.putLast(3, \u0026#34;Three\u0026#34;); //{1=One, 2=Two, 3=Three} System.out.println(map); //{1=One, 2=Two, 3=Three} System.out.println(map.reversed()); //{3=Three, 2=Two, 1=One} JEP 439:分代 ZGC # JDK21 中对 ZGC 进行了功能扩展,增加了分代 GC 功能。不过,默认是关闭的,需要通过配置打开:\n// 启用分代ZGC java -XX:+UseZGC -XX:+ZGenerational ... 在未来的版本中,官方会把 ZGenerational 设为默认值,即默认打开 ZGC 的分代 GC。在更晚的版本中,非分代 ZGC 就被移除。\nIn a future release we intend to make Generational ZGC the default, at which point -XX:-ZGenerational will select non-generational ZGC. In an even later release we intend to remove non-generational ZGC, at which point the ZGenerational option will become obsolete.\n在将来的版本中,我们打算将 Generational ZGC 作为默认选项,此时-XX:-ZGenerational 将选择非分代 ZGC。在更晚的版本中,我们打算移除非分代 ZGC,此时 ZGenerational 选项将变得过时。\n分代 ZGC 可以显著减少垃圾回收过程中的停顿时间,并提高应用程序的响应性能。这对于大型 Java 应用程序和高并发场景下的性能优化非常有价值。\nJEP 440:记录模式 # 记录模式在 Java 19 进行了第一次预览, 由 JEP 405 提出。JDK 20 中是第二次预览,由 JEP 432 提出。最终,记录模式在 JDK21 顺利转正。\nJava 20 新特性概览已经详细介绍过记录模式,这里就不重复了。\nJEP 441:switch 的模式匹配 # 增强 Java 中的 switch 表达式和语句,允许在 case 标签中使用模式。当模式匹配时,执行 case 标签对应的代码。\n在下面的代码中,switch 表达式使用了类型模式来进行匹配。\nstatic String formatterPatternSwitch(Object obj) { return switch (obj) { case Integer i -\u0026gt; String.format(\u0026#34;int %d\u0026#34;, i); case Long l -\u0026gt; String.format(\u0026#34;long %d\u0026#34;, l); case Double d -\u0026gt; String.format(\u0026#34;double %f\u0026#34;, d); case String s -\u0026gt; String.format(\u0026#34;String %s\u0026#34;, s); default -\u0026gt; obj.toString(); }; } JEP 442:外部函数和内存 API(第三次预览) # Java 程序可以通过该 API 与 Java 运行时之外的代码和数据进行互操作。通过高效地调用外部函数(即 JVM 之外的代码)和安全地访问外部内存(即不受 JVM 管理的内存),该 API 使 Java 程序能够调用本机库并处理本机数据,而不会像 JNI 那样危险和脆弱。\n外部函数和内存 API 在 Java 17 中进行了第一轮孵化,由 JEP 412 提出。Java 18 中进行了第二次孵化,由 JEP 419 提出。Java 19 中是第一次预览,由 JEP 424 提出。JDK 20 中是第二次预览,由 JEP 434 提出。JDK 21 中是第三次预览,由 JEP 442 提出。\n在 Java 19 新特性概览 中,我有详细介绍到外部函数和内存 API,这里就不再做额外的介绍了。\nJEP 443:未命名模式和变量(预览) # 未命名模式和变量使得我们可以使用下划线 _ 表示未命名的变量以及模式匹配时不使用的组件,旨在提高代码的可读性和可维护性。\n未命名变量的典型场景是 try-with-resources 语句、 catch 子句中的异常变量和for循环。当变量不需要使用的时候就可以使用下划线 _代替,这样清晰标识未被使用的变量。\ntry (var _ = ScopedContext.acquire()) { // No use of acquired resource } try { ... } catch (Exception _) { ... } catch (Throwable _) { ... } for (int i = 0, _ = runOnce(); i \u0026lt; arr.length; i++) { ... } 未命名模式是一个无条件的模式,并不绑定任何值。未命名模式变量出现在类型模式中。\nif (r instanceof ColoredPoint(_, Color c)) { ... c ... } switch (b) { case Box(RedBall _), Box(BlueBall _) -\u0026gt; processBox(b); case Box(GreenBall _) -\u0026gt; stopProcessing(); case Box(_) -\u0026gt; pickAnotherBox(); } JEP 444:虚拟线程 # 虚拟线程是一项重量级的更新,一定一定要重视!\n虚拟线程在 Java 19 中进行了第一次预览,由 JEP 425提出。JDK 20 中是第二次预览。最终,虚拟线程在 JDK21 顺利转正。\nJava 20 新特性概览已经详细介绍过虚拟线程,这里就不重复了。\nJEP 445:未命名类和实例 main 方法 (预览) # 这个特性主要简化了 main 方法的的声明。对于 Java 初学者来说,这个 main 方法的声明引入了太多的 Java 语法概念,不利于初学者快速上手。\n没有使用该特性之前定义一个 main 方法:\npublic class HelloWorld { public static void main(String[] args) { System.out.println(\u0026#34;Hello, World!\u0026#34;); } } 使用该新特性之后定义一个 main 方法:\nclass HelloWorld { void main() { System.out.println(\u0026#34;Hello, World!\u0026#34;); } } 进一步精简(未命名的类允许我们不定义类名):\nvoid main() { System.out.println(\u0026#34;Hello, World!\u0026#34;); } 参考 # Java 21 String Templates: https://howtodoinjava.com/java/java-string-templates/ Java 21 Sequenced Collections: https://howtodoinjava.com/java/sequenced-collections/ "},{"id":495,"href":"/zh/docs/technology/Interview/java/new-features/java22-23/","title":"Java 22 \u0026 23 新特性概览","section":"New Features","content":"JDK 23 和 JDK 22 一样,这也是一个非 LTS(长期支持)版本,Oracle 仅提供六个月的支持。下一个长期支持版是 JDK 25,预计明年 9 月份发布。\n由于 JDK 22 和 JDK 23 重合的新特性较多,这里主要以 JDK 23 为主介绍,会补充 JDK 22 独有的一些特性。\nJDK 23 一共有 12 个新特性:\nJEP 455: 模式中的原始类型、instanceof 和 switch(预览) JEP 456: 类文件 API(第二次预览) JEP 467:Markdown 文档注释 JEP 469:向量 API(第八次孵化) JEP 473:流收集器(第二次预览) JEP 471:弃用 sun.misc.Unsafe 中的内存访问方法 JEP 474:ZGC:默认的分代模式 JEP 476:模块导入声明 (预览) JEP 477:未命名类和实例 main 方法 (第三次预览) JEP 480:结构化并发 (第三次预览) JEP 481: 作用域值 (第三次预览) JEP 482:灵活的构造函数体(第二次预览) JDK 22 的新特性如下:\n其中,下面这 3 条新特性我会单独拎出来详细介绍一下:\nJEP 423:G1 垃圾收集器区域固定 JEP 454:外部函数与内存 API JEP 456:未命名模式和变量 JEP 458:启动多文件源代码程序 JDK 23 # JEP 455: 模式中的原始类型、instanceof 和 switch(预览) # 在 JEP 455 之前, instanceof 只支持引用类型,switch 表达式和语句的 case 标签只能使用整数字面量、枚举常量和字符串字面量。\nJEP 455 的预览特性中,instanceof 和 switch 全面支持所有原始类型,包括 byte, short, char, int, long, float, double, boolean。\n// 传统写法 if (i \u0026gt;= -128 \u0026amp;\u0026amp; i \u0026lt;= 127) { byte b = (byte)i; ... b ... } // 使用 instanceof 改进 if (i instanceof byte b) { ... b ... } long v = ...; // 传统写法 if (v == 1L) { // ... } else if (v == 2L) { // ... } else if (v == 10_000_000_000L) { // ... } // 使用 long 类型的 case 标签 switch (v) { case 1L: // ... break; case 2L: // ... break; case 10_000_000_000L: // ... break; default: // ... } JEP 456: 类文件 API(第二次预览) # 类文件 API 在 JDK 22 进行了第一次预览,由 JEP 457 提出。\n类文件 API 的目标是提供一套标准化的 API,用于解析、生成和转换 Java 类文件,取代过去对第三方库(如 ASM)在类文件处理上的依赖。\n// 创建一个 ClassFile 对象,这是操作类文件的入口。 ClassFile cf = ClassFile.of(); // 解析字节数组为 ClassModel ClassModel classModel = cf.parse(bytes); // 构建新的类文件,移除以 \u0026#34;debug\u0026#34; 开头的所有方法 byte[] newBytes = cf.build(classModel.thisClass().asSymbol(), classBuilder -\u0026gt; { // 遍历所有类元素 for (ClassElement ce : classModel) { // 判断是否为方法 且 方法名以 \u0026#34;debug\u0026#34; 开头 if (!(ce instanceof MethodModel mm \u0026amp;\u0026amp; mm.methodName().stringValue().startsWith(\u0026#34;debug\u0026#34;))) { // 添加到新的类文件中 classBuilder.with(ce); } } }); JEP 467:Markdown 文档注释 # 在 JavaDoc 文档注释中可以使用 Markdown 语法,取代原本只能使用 HTML 和 JavaDoc 标签的方式。\nMarkdown 更简洁易读,减少了手动编写 HTML 的繁琐,同时保留了对 HTML 元素和 JavaDoc 标签的支持。这个增强旨在让 API 文档注释的编写和阅读变得更加轻松,同时不会影响现有注释的解释。Markdown 提供了对常见文档元素(如段落、列表、链接等)的简化表达方式,提升了文档注释的可维护性和开发者体验。\nJEP 469:向量 API(第八次孵化) # 向量计算由对向量的一系列操作组成。向量 API 用来表达向量计算,该计算可以在运行时可靠地编译为支持的 CPU 架构上的最佳向量指令,从而实现优于等效标量计算的性能。\n向量 API 的目标是为用户提供简洁易用且与平台无关的表达范围广泛的向量计算。\n这是对数组元素的简单标量计算:\nvoid scalarComputation(float[] a, float[] b, float[] c) { for (int i = 0; i \u0026lt; a.length; i++) { c[i] = (a[i] * a[i] + b[i] * b[i]) * -1.0f; } } 这是使用 Vector API 进行的等效向量计算:\nstatic final VectorSpecies\u0026lt;Float\u0026gt; SPECIES = FloatVector.SPECIES_PREFERRED; void vectorComputation(float[] a, float[] b, float[] c) { int i = 0; int upperBound = SPECIES.loopBound(a.length); for (; i \u0026lt; upperBound; i += SPECIES.length()) { // FloatVector va, vb, vc; var va = FloatVector.fromArray(SPECIES, a, i); var vb = FloatVector.fromArray(SPECIES, b, i); var vc = va.mul(va) .add(vb.mul(vb)) .neg(); vc.intoArray(c, i); } for (; i \u0026lt; a.length; i++) { c[i] = (a[i] * a[i] + b[i] * b[i]) * -1.0f; } } JEP 473:流收集器(第二次预览) # 流收集器在 JDK 22 进行了第一次预览,由 JEP 461 提出。\n这个改进使得 Stream API 可以支持自定义中间操作。\nsource.gather(a).gather(b).gather(c).collect(...) JEP 471:弃用 sun.misc.Unsafe 中的内存访问方法 # JEP 471 提议弃用 sun.misc.Unsafe 中的内存访问方法,这些方法将来的版本中会被移除。\n这些不安全的方法已有安全高效的替代方案:\njava.lang.invoke.VarHandle :JDK 9 (JEP 193) 中引入,提供了一种安全有效地操作堆内存的方法,包括对象的字段、类的静态字段以及数组元素。 java.lang.foreign.MemorySegment :JDK 22 (JEP 454) 中引入,提供了一种安全有效地访问堆外内存的方法,有时会与 VarHandle 协同工作。 这两个类是 Foreign Function \u0026amp; Memory API(外部函数和内存 API) 的核心组件,分别用于管理和操作堆外内存。Foreign Function \u0026amp; Memory API 在 JDK 22 中正式转正,成为标准特性。\nimport jdk.incubator.foreign.*; import java.lang.invoke.VarHandle; // 管理堆外整数数组的类 class OffHeapIntBuffer { // 用于访问整数元素的VarHandle private static final VarHandle ELEM_VH = ValueLayout.JAVA_INT.arrayElementVarHandle(); // 内存管理器 private final Arena arena; // 堆外内存段 private final MemorySegment buffer; // 构造函数,分配指定数量的整数空间 public OffHeapIntBuffer(long size) { this.arena = Arena.ofShared(); this.buffer = arena.allocate(ValueLayout.JAVA_INT, size); } // 释放内存 public void deallocate() { arena.close(); } // 以volatile方式设置指定索引的值 public void setVolatile(long index, int value) { ELEM_VH.setVolatile(buffer, 0L, index, value); } // 初始化指定范围的元素为0 public void initialize(long start, long n) { buffer.asSlice(ValueLayout.JAVA_INT.byteSize() * start, ValueLayout.JAVA_INT.byteSize() * n) .fill((byte) 0); } // 将指定范围的元素复制到新数组 public int[] copyToNewArray(long start, int n) { return buffer.asSlice(ValueLayout.JAVA_INT.byteSize() * start, ValueLayout.JAVA_INT.byteSize() * n) .toArray(ValueLayout.JAVA_INT); } } JEP 474:ZGC:默认的分代模式 # Z 垃圾回收器 (ZGC) 的默认模式切换为分代模式,并弃用非分代模式,计划在未来版本中移除。这是因为分代 ZGC 是大多数场景下的更优选择。\nJEP 476:模块导入声明 (预览) # 模块导入声明允许在 Java 代码中简洁地导入整个模块的所有导出包,而无需逐个声明包的导入。这一特性简化了模块化库的重用,特别是在使用多个模块时,避免了大量的包导入声明,使得开发者可以更方便地访问第三方库和 Java 基本类。\n此特性对初学者和原型开发尤为有用,因为它无需开发者将自己的代码模块化,同时保留了对传统导入方式的兼容性,提升了开发效率和代码可读性。\n// 导入整个 java.base 模块,开发者可以直接访问 List、Map、Stream 等类,而无需每次手动导入相关包 import module java.base; public class Example { public static void main(String[] args) { String[] fruits = { \u0026#34;apple\u0026#34;, \u0026#34;berry\u0026#34;, \u0026#34;citrus\u0026#34; }; Map\u0026lt;String, String\u0026gt; fruitMap = Stream.of(fruits) .collect(Collectors.toMap( s -\u0026gt; s.toUpperCase().substring(0, 1), Function.identity())); System.out.println(fruitMap); } } JEP 477:未命名类和实例 main 方法 (第三次预览) # 这个特性主要简化了 main 方法的的声明。对于 Java 初学者来说,这个 main 方法的声明引入了太多的 Java 语法概念,不利于初学者快速上手。\n没有使用该特性之前定义一个 main 方法:\npublic class HelloWorld { public static void main(String[] args) { System.out.println(\u0026#34;Hello, World!\u0026#34;); } } 使用该新特性之后定义一个 main 方法:\nclass HelloWorld { void main() { System.out.println(\u0026#34;Hello, World!\u0026#34;); } } 进一步简化(未命名的类允许我们省略类名)\nvoid main() { System.out.println(\u0026#34;Hello, World!\u0026#34;); } JEP 480:结构化并发 (第三次预览) # Java 19 引入了结构化并发,一种多线程编程方法,目的是为了通过结构化并发 API 来简化多线程编程,并不是为了取代java.util.concurrent,目前处于孵化器阶段。\n结构化并发将不同线程中运行的多个任务视为单个工作单元,从而简化错误处理、提高可靠性并增强可观察性。也就是说,结构化并发保留了单线程代码的可读性、可维护性和可观察性。\n结构化并发的基本 API 是 StructuredTaskScope。StructuredTaskScope 支持将任务拆分为多个并发子任务,在它们自己的线程中执行,并且子任务必须在主任务继续之前完成。\nStructuredTaskScope 的基本用法如下:\ntry (var scope = new StructuredTaskScope\u0026lt;Object\u0026gt;()) { // 使用fork方法派生线程来执行子任务 Future\u0026lt;Integer\u0026gt; future1 = scope.fork(task1); Future\u0026lt;String\u0026gt; future2 = scope.fork(task2); // 等待线程完成 scope.join(); // 结果的处理可能包括处理或重新抛出异常 ... process results/exceptions ... } // close 结构化并发非常适合虚拟线程,虚拟线程是 JDK 实现的轻量级线程。许多虚拟线程共享同一个操作系统线程,从而允许非常多的虚拟线程。\nJEP 481:作用域值 (第三次预览) # 作用域值(Scoped Values)可以在线程内和线程间共享不可变的数据,优于线程局部变量,尤其是在使用大量虚拟线程时。\nfinal static ScopedValue\u0026lt;...\u0026gt; V = new ScopedValue\u0026lt;\u0026gt;(); // In some method ScopedValue.where(V, \u0026lt;value\u0026gt;) .run(() -\u0026gt; { ... V.get() ... call methods ... }); // In a method called directly or indirectly from the lambda expression ... V.get() ... 作用域值允许在大型程序中的组件之间安全有效地共享数据,而无需求助于方法参数。\nJEP 482:灵活的构造函数体(第二次预览) # 这个特性最初在 JDK 22 由 JEP 447: Statements before super(\u0026hellip;) (Preview)提出。\nJava 要求在构造函数中,super(...) 或 this(...) 调用必须作为第一条语句出现。这意味着我们无法在调用父类构造函数之前在子类构造函数中直接初始化字段。\n灵活的构造函数体解决了这一问题,它允许在构造函数体内,在调用 super(..) 或 this(..) 之前编写语句,这些语句可以初始化字段,但不能引用正在构造的实例。这样可以防止在父类构造函数中调用子类方法时,子类的字段未被正确初始化,增强了类构造的可靠性。\n这一特性解决了之前 Java 语法限制了构造函数代码组织的问题,让开发者能够更自由、更自然地表达构造函数的行为,例如在构造函数中直接进行参数验证、准备和共享,而无需依赖辅助方法或构造函数,提高了代码的可读性和可维护性。\nclass Person { private final String name; private int age; public Person(String name, int age) { if (age \u0026lt; 0) { throw new IllegalArgumentException(\u0026#34;Age cannot be negative.\u0026#34;); } this.name = name; // 在调用父类构造函数之前初始化字段 this.age = age; // ... 其他初始化代码 } } class Employee extends Person { private final int employeeId; public Employee(String name, int age, int employeeId) { this.employeeId = employeeId; // 在调用父类构造函数之前初始化字段 super(name, age); // 调用父类构造函数 // ... 其他初始化代码 } } JDK 22 # JEP 423:G1 垃圾收集器区域固定 # JEP 423 提出在 G1 垃圾收集器中实现区域固定(Region Pinning)功能,旨在减少由于 Java Native Interface (JNI) 关键区域导致的延迟问题。\nJNI 关键区域内的对象不能在垃圾收集时被移动,因此 G1 以往通过禁用垃圾收集解决该问题,导致线程阻塞及严重的延迟。通过在 G1 的老年代和年轻代中引入区域固定机制,允许在关键区域内固定对象所在的内存区域,同时继续回收未固定的区域,避免了禁用垃圾回收的需求。这种改进有助于显著降低延迟,提升系统在与 JNI 交互时的吞吐量和稳定性。\nJEP 454:外部函数和内存 API # Java 程序可以通过该 API 与 Java 运行时之外的代码和数据进行互操作。通过高效地调用外部函数(即 JVM 之外的代码)和安全地访问外部内存(即不受 JVM 管理的内存),该 API 使 Java 程序能够调用本机库并处理本机数据,而不会像 JNI 那样危险和脆弱。\n外部函数和内存 API 在 Java 17 中进行了第一轮孵化,由 JEP 412 提出。Java 18 中进行了第二次孵化,由 JEP 419 提出。Java 19 中是第一次预览,由 JEP 424 提出。JDK 20 中是第二次预览,由 JEP 434 提出。JDK 21 中是第三次预览,由 JEP 442 提出。\n最终,该特性在 JDK 22 中顺利转正。\n在 Java 19 新特性概览 中,我有详细介绍到外部函数和内存 API,这里就不再做额外的介绍了。\nJEP 456:未命名模式和变量 # 未命名模式和变量在 JDK 21 中由 JEP 443提出预览,JDK 22 中就已经转正。\n关于这个新特性的详细介绍,可以看看 Java 21 新特性概览(重要)这篇文章中的介绍。\nJEP 458:启动多文件源代码程序 # Java 11 引入了 JEP 330:启动单文件源代码程序,增强了 java 启动器的功能,使其能够直接运行单个 Java 源文件。通过命令 java HelloWorld.java,Java 可以在内存中隐式编译源代码并立即执行,而不需要在磁盘上生成 .class 文件。这简化了开发者在编写小型工具程序或学习 Java 时的工作流程,避免了手动编译的额外步骤。\n假设文件Prog.java声明了两个类:\nclass Prog { public static void main(String[] args) { Helper.run(); } } class Helper { static void run() { System.out.println(\u0026#34;Hello!\u0026#34;); } } java Prog.java命令会在内存中编译两个类并执行main该文件中声明的第一个类的方法。\n这种方式有一个限制,程序的所有源代码必须放在一个.java文件中。\nJEP 458:启动多文件源代码程序 是对 JEP 330 功能的扩展,允许直接运行由多个 Java 源文件组成的程序,而无需显式的编译步骤。\n假设一个目录中有两个 Java 源文件 Prog.java 和 Helper.java,每个文件各自声明了一个类:\n// Prog.java class Prog { public static void main(String[] args) { Helper.run(); } } // Helper.java class Helper { static void run() { System.out.println(\u0026#34;Hello!\u0026#34;); } } 当你运行命令 java Prog.java 时,Java 启动器会在内存中编译并执行 Prog 类的 main 方法。由于 Prog 类中的代码引用了 Helper 类,启动器会自动在文件系统中找到 Helper.java 文件,编译其中的 Helper 类,并在内存中执行它。这个过程是自动的,开发者无需显式调用 javac 来编译所有源文件。\n这一特性使得从小型项目到大型项目的过渡更加平滑,开发者可以自由选择何时引入构建工具,避免在快速迭代时被迫设置复杂的项目结构。该特性消除了单文件的限制,进一步简化了从单一文件到多文件程序的开发过程,特别适合原型开发、快速实验以及早期项目的探索阶段。\n"},{"id":496,"href":"/zh/docs/technology/Interview/java/new-features/java9/","title":"Java 9 新特性概览","section":"New Features","content":"Java 9 发布于 2017 年 9 月 21 日 。作为 Java 8 之后 3 年半才发布的新版本,Java 9 带来了很多重大的变化其中最重要的改动是 Java 平台模块系统的引入,其他还有诸如集合、Stream 流……。\n你可以在 Archived OpenJDK General-Availability Releases 上下载自己需要的 JDK 版本!官方的新特性说明文档地址: https://openjdk.java.net/projects/jdk/ 。\n概览(精选了一部分):\nJEP 222: Java 命令行工具 JEP 261: 模块化系统 JEP 248:G1 成为默认垃圾回收器 JEP 193: 变量句柄 JEP 254:字符串存储结构优化 JShell # JShell 是 Java 9 新增的一个实用工具。为 Java 提供了类似于 Python 的实时命令行交互工具。\n在 JShell 中可以直接输入表达式并查看其执行结果。\nJShell 为我们带来了哪些好处呢?\n降低了输出第一行 Java 版\u0026quot;Hello World!\u0026ldquo;的门槛,能够提高新手的学习热情。 在处理简单的小逻辑,验证简单的小问题时,比 IDE 更有效率(并不是为了取代 IDE,对于复杂逻辑的验证,IDE 更合适,两者互补)。 …… JShell 的代码和普通的可编译代码,有什么不一样?\n一旦语句输入完成,JShell 立即就能返回执行的结果,而不再需要编辑器、编译器、解释器。 JShell 支持变量的重复声明,后面声明的会覆盖前面声明的。 JShell 支持独立的表达式比如普通的加法运算 1 + 1。 …… 模块化系统 # 模块系统是 Jigsaw Project的一部分,把模块化开发实践引入到了 Java 平台中,可以让我们的代码可重用性更好!\n什么是模块系统? 官方的定义是:\nA uniquely named, reusable group of related packages, as well as resources (such as images and XML files) and a module descriptor。\n简单来说,你可以将一个模块看作是一组唯一命名、可重用的包、资源和模块描述文件(module-info.java)。\n任意一个 jar 文件,只要加上一个模块描述文件(module-info.java),就可以升级为一个模块。\n在引入了模块系统之后,JDK 被重新组织成 94 个模块。Java 应用可以通过新增的 jlink 工具 (Jlink 是随 Java 9 一起发布的新命令行工具。它允许开发人员为基于模块的 Java 应用程序创建自己的轻量级、定制的 JRE),创建出只包含所依赖的 JDK 模块的自定义运行时镜像。这样可以极大的减少 Java 运行时环境的大小。\n我们可以通过 exports 关键词精准控制哪些类可以对外开放使用,哪些类只能内部使用。\nmodule my.module { //exports 公开指定包的所有公共成员 exports com.my.package.name; } module my.module { //exports…to 限制访问的成员范围 export com.my.package.name to com.specific.package; } 想要深入了解 Java 9 的模块化,可以参考下面这几篇文章:\n《Project Jigsaw: Module System Quick-Start Guide》 《Java 9 Modules: part 1》 Java 9 揭秘(2. 模块化系统) G1 成为默认垃圾回收器 # 在 Java 8 的时候,默认垃圾回收器是 Parallel Scavenge(新生代)+Parallel Old(老年代)。到了 Java 9, CMS 垃圾回收器被废弃了,G1(Garbage-First Garbage Collector) 成为了默认垃圾回收器。\nG1 还是在 Java 7 中被引入的,经过两个版本优异的表现成为成为默认垃圾回收器。\n快速创建不可变集合 # 增加了List.of()、Set.of()、Map.of() 和 Map.ofEntries()等工厂方法来创建不可变集合(有点参考 Guava 的味道):\nList.of(\u0026#34;Java\u0026#34;, \u0026#34;C++\u0026#34;); Set.of(\u0026#34;Java\u0026#34;, \u0026#34;C++\u0026#34;); Map.of(\u0026#34;Java\u0026#34;, 1, \u0026#34;C++\u0026#34;, 2); 使用 of() 创建的集合为不可变集合,不能进行添加、删除、替换、 排序等操作,不然会报 java.lang.UnsupportedOperationException 异常。\nString 存储结构优化 # Java 8 及之前的版本,String 一直是用 char[] 存储。在 Java 9 之后,String 的实现改用 byte[] 数组存储字符串,节省了空间。\npublic final class String implements java.io.Serializable,Comparable\u0026lt;String\u0026gt;, CharSequence { // @Stable 注解表示变量最多被修改一次,称为“稳定的”。 @Stable private final byte[] value; } 接口私有方法 # Java 9 允许在接口中使用私有方法。这样的话,接口的使用就更加灵活了,有点像是一个简化版的抽象类。\npublic interface MyInterface { private void methodPrivate(){ } } try-with-resources 增强 # 在 Java 9 之前,我们只能在 try-with-resources 块中声明变量:\ntry (Scanner scanner = new Scanner(new File(\u0026#34;testRead.txt\u0026#34;)); PrintWriter writer = new PrintWriter(new File(\u0026#34;testWrite.txt\u0026#34;))) { // omitted } 在 Java 9 之后,在 try-with-resources 语句中可以使用 effectively-final 变量。\nfinal Scanner scanner = new Scanner(new File(\u0026#34;testRead.txt\u0026#34;)); PrintWriter writer = new PrintWriter(new File(\u0026#34;testWrite.txt\u0026#34;)) try (scanner;writer) { // omitted } 什么是 effectively-final 变量? 简单来说就是没有被 final 修饰但是值在初始化后从未更改的变量。\n正如上面的代码所演示的那样,即使 writer 变量没有被显示声明为 final,但它在第一次被赋值后就不会改变了,因此,它就是 effectively-final 变量。\nStream \u0026amp; Optional 增强 # Stream 中增加了新的方法 ofNullable()、dropWhile()、takeWhile() 以及 iterate() 方法的重载方法。\nJava 9 中的 ofNullable() 方 法允许我们创建一个单元素的 Stream,可以包含一个非空元素,也可以创建一个空 Stream。 而在 Java 8 中则不可以创建空的 Stream 。\nStream\u0026lt;String\u0026gt; stringStream = Stream.ofNullable(\u0026#34;Java\u0026#34;); System.out.println(stringStream.count());// 1 Stream\u0026lt;String\u0026gt; nullStream = Stream.ofNullable(null); System.out.println(nullStream.count());//0 takeWhile() 方法可以从 Stream 中依次获取满足条件的元素,直到不满足条件为止结束获取。\nList\u0026lt;Integer\u0026gt; integerList = List.of(11, 33, 66, 8, 9, 13); integerList.stream().takeWhile(x -\u0026gt; x \u0026lt; 50).forEach(System.out::println);// 11 33 dropWhile() 方法的效果和 takeWhile() 相反。\nList\u0026lt;Integer\u0026gt; integerList2 = List.of(11, 33, 66, 8, 9, 13); integerList2.stream().dropWhile(x -\u0026gt; x \u0026lt; 50).forEach(System.out::println);// 66 8 9 13 iterate() 方法的新重载方法提供了一个 Predicate 参数 (判断条件)来决定什么时候结束迭代\npublic static\u0026lt;T\u0026gt; Stream\u0026lt;T\u0026gt; iterate(final T seed, final UnaryOperator\u0026lt;T\u0026gt; f) { } // 新增加的重载方法 public static\u0026lt;T\u0026gt; Stream\u0026lt;T\u0026gt; iterate(T seed, Predicate\u0026lt;? super T\u0026gt; hasNext, UnaryOperator\u0026lt;T\u0026gt; next) { } 两者的使用对比如下,新的 iterate() 重载方法更加灵活一些。\n// 使用原始 iterate() 方法输出数字 1~10 Stream.iterate(1, i -\u0026gt; i + 1).limit(10).forEach(System.out::println); // 使用新的 iterate() 重载方法输出数字 1~10 Stream.iterate(1, i -\u0026gt; i \u0026lt;= 10, i -\u0026gt; i + 1).forEach(System.out::println); Optional 类中新增了 ifPresentOrElse()、or() 和 stream() 等方法\nifPresentOrElse() 方法接受两个参数 Consumer 和 Runnable ,如果 Optional 不为空调用 Consumer 参数,为空则调用 Runnable 参数。\npublic void ifPresentOrElse(Consumer\u0026lt;? super T\u0026gt; action, Runnable emptyAction) Optional\u0026lt;Object\u0026gt; objectOptional = Optional.empty(); objectOptional.ifPresentOrElse(System.out::println, () -\u0026gt; System.out.println(\u0026#34;Empty!!!\u0026#34;));// Empty!!! or() 方法接受一个 Supplier 参数 ,如果 Optional 为空则返回 Supplier 参数指定的 Optional 值。\npublic Optional\u0026lt;T\u0026gt; or(Supplier\u0026lt;? extends Optional\u0026lt;? extends T\u0026gt;\u0026gt; supplier) Optional\u0026lt;Object\u0026gt; objectOptional = Optional.empty(); objectOptional.or(() -\u0026gt; Optional.of(\u0026#34;java\u0026#34;)).ifPresent(System.out::println);//java 进程 API # Java 9 增加了 java.lang.ProcessHandle 接口来实现对原生进程进行管理,尤其适合于管理长时间运行的进程。\n// 获取当前正在运行的 JVM 的进程 ProcessHandle currentProcess = ProcessHandle.current(); // 输出进程的 id System.out.println(currentProcess.pid()); // 输出进程的信息 System.out.println(currentProcess.info()); ProcessHandle 接口概览:\n响应式流 ( Reactive Streams ) # 在 Java 9 中的 java.util.concurrent.Flow 类中新增了反应式流规范的核心接口 。\nFlow 中包含了 Flow.Publisher、Flow.Subscriber、Flow.Subscription 和 Flow.Processor 等 4 个核心接口。Java 9 还提供了SubmissionPublisher 作为Flow.Publisher 的一个实现。\n关于 Java 9 响应式流更详细的解读,推荐你看 Java 9 揭秘(17. Reactive Streams )- 林本托 这篇文章。\n变量句柄 # 变量句柄是一个变量或一组变量的引用,包括静态域,非静态域,数组元素和堆外数据结构中的组成部分等。\n变量句柄的含义类似于已有的方法句柄 MethodHandle ,由 Java 类 java.lang.invoke.VarHandle 来表示,可以使用类 java.lang.invoke.MethodHandles.Lookup 中的静态工厂方法来创建 VarHandle 对象。\nVarHandle 的出现替代了 java.util.concurrent.atomic 和 sun.misc.Unsafe 的部分操作。并且提供了一系列标准的内存屏障操作,用于更加细粒度的控制内存排序。在安全性、可用性、性能上都要优于现有的 API。\n其它 # 平台日志 API 改进:Java 9 允许为 JDK 和应用配置同样的日志实现。新增了 System.LoggerFinder 用来管理 JDK 使 用的日志记录器实现。JVM 在运行时只有一个系统范围的 LoggerFinder 实例。我们可以通过添加自己的 System.LoggerFinder 实现来让 JDK 和应用使用 SLF4J 等其他日志记录框架。 CompletableFuture类增强:新增了几个新的方法(completeAsync ,orTimeout 等)。 Nashorn 引擎的增强:Nashorn 是从 Java8 开始引入的 JavaScript 引擎,Java9 对 Nashorn 做了些增强,实现了一些 ES6 的新特性(Java 11 中已经被弃用)。 I/O 流的新特性:增加了新的方法来读取和复制 InputStream 中包含的数据。 改进应用的安全性能:Java 9 新增了 4 个 SHA- 3 哈希算法,SHA3-224、SHA3-256、SHA3-384 和 SHA3-512。 改进方法句柄(Method Handle):方法句柄从 Java7 开始引入,Java9 在类java.lang.invoke.MethodHandles 中新增了更多的静态方法来创建不同类型的方法句柄。 …… 参考 # Java version history: https://en.wikipedia.org/wiki/Java_version_history Release Notes for JDK 9 and JDK 9 Update Releases : https://www.oracle.com/java/technologies/javase/9-all-relnotes.html 《深入剖析 Java 新特性》-极客时间 - JShell:怎么快速验证简单的小问题? New Features in Java 9: https://www.baeldung.com/new-java-9 Java – Try with Resources: https://www.baeldung.com/java-try-with-resources "},{"id":497,"href":"/zh/docs/technology/Interview/java/io/io-basis/","title":"Java IO 基础知识总结","section":"Io","content":" IO 流简介 # IO 即 Input/Output,输入和输出。数据输入到计算机内存的过程即输入,反之输出到外部存储(比如数据库,文件,远程主机)的过程即输出。数据传输过程类似于水流,因此称为 IO 流。IO 流在 Java 中分为输入流和输出流,而根据数据的处理方式又分为字节流和字符流。\nJava IO 流的 40 多个类都是从如下 4 个抽象类基类中派生出来的。\nInputStream/Reader: 所有的输入流的基类,前者是字节输入流,后者是字符输入流。 OutputStream/Writer: 所有输出流的基类,前者是字节输出流,后者是字符输出流。 字节流 # InputStream(字节输入流) # InputStream用于从源头(通常是文件)读取数据(字节信息)到内存中,java.io.InputStream抽象类是所有字节输入流的父类。\nInputStream 常用方法:\nread():返回输入流中下一个字节的数据。返回的值介于 0 到 255 之间。如果未读取任何字节,则代码返回 -1 ,表示文件结束。 read(byte b[ ]) : 从输入流中读取一些字节存储到数组 b 中。如果数组 b 的长度为零,则不读取。如果没有可用字节读取,返回 -1。如果有可用字节读取,则最多读取的字节数最多等于 b.length , 返回读取的字节数。这个方法等价于 read(b, 0, b.length)。 read(byte b[], int off, int len):在read(byte b[ ]) 方法的基础上增加了 off 参数(偏移量)和 len 参数(要读取的最大字节数)。 skip(long n):忽略输入流中的 n 个字节 ,返回实际忽略的字节数。 available():返回输入流中可以读取的字节数。 close():关闭输入流释放相关的系统资源。 从 Java 9 开始,InputStream 新增加了多个实用的方法:\nreadAllBytes():读取输入流中的所有字节,返回字节数组。 readNBytes(byte[] b, int off, int len):阻塞直到读取 len 个字节。 transferTo(OutputStream out):将所有字节从一个输入流传递到一个输出流。 FileInputStream 是一个比较常用的字节输入流对象,可直接指定文件路径,可以直接读取单字节数据,也可以读取至字节数组中。\nFileInputStream 代码示例:\ntry (InputStream fis = new FileInputStream(\u0026#34;input.txt\u0026#34;)) { System.out.println(\u0026#34;Number of remaining bytes:\u0026#34; + fis.available()); int content; long skip = fis.skip(2); System.out.println(\u0026#34;The actual number of bytes skipped:\u0026#34; + skip); System.out.print(\u0026#34;The content read from file:\u0026#34;); while ((content = fis.read()) != -1) { System.out.print((char) content); } } catch (IOException e) { e.printStackTrace(); } input.txt 文件内容:\n输出:\nNumber of remaining bytes:11 The actual number of bytes skipped:2 The content read from file:JavaGuide 不过,一般我们是不会直接单独使用 FileInputStream ,通常会配合 BufferedInputStream(字节缓冲输入流,后文会讲到)来使用。\n像下面这段代码在我们的项目中就比较常见,我们通过 readAllBytes() 读取输入流所有字节并将其直接赋值给一个 String 对象。\n// 新建一个 BufferedInputStream 对象 BufferedInputStream bufferedInputStream = new BufferedInputStream(new FileInputStream(\u0026#34;input.txt\u0026#34;)); // 读取文件的内容并复制到 String 对象中 String result = new String(bufferedInputStream.readAllBytes()); System.out.println(result); DataInputStream 用于读取指定类型数据,不能单独使用,必须结合其它流,比如 FileInputStream 。\nFileInputStream fileInputStream = new FileInputStream(\u0026#34;input.txt\u0026#34;); //必须将fileInputStream作为构造参数才能使用 DataInputStream dataInputStream = new DataInputStream(fileInputStream); //可以读取任意具体的类型数据 dataInputStream.readBoolean(); dataInputStream.readInt(); dataInputStream.readUTF(); ObjectInputStream 用于从输入流中读取 Java 对象(反序列化),ObjectOutputStream 用于将对象写入到输出流(序列化)。\nObjectInputStream input = new ObjectInputStream(new FileInputStream(\u0026#34;object.data\u0026#34;)); MyClass object = (MyClass) input.readObject(); input.close(); 另外,用于序列化和反序列化的类必须实现 Serializable 接口,对象中如果有属性不想被序列化,使用 transient 修饰。\nOutputStream(字节输出流) # OutputStream用于将数据(字节信息)写入到目的地(通常是文件),java.io.OutputStream抽象类是所有字节输出流的父类。\nOutputStream 常用方法:\nwrite(int b):将特定字节写入输出流。 write(byte b[ ]) : 将数组b 写入到输出流,等价于 write(b, 0, b.length) 。 write(byte[] b, int off, int len) : 在write(byte b[ ]) 方法的基础上增加了 off 参数(偏移量)和 len 参数(要读取的最大字节数)。 flush():刷新此输出流并强制写出所有缓冲的输出字节。 close():关闭输出流释放相关的系统资源。 FileOutputStream 是最常用的字节输出流对象,可直接指定文件路径,可以直接输出单字节数据,也可以输出指定的字节数组。\nFileOutputStream 代码示例:\ntry (FileOutputStream output = new FileOutputStream(\u0026#34;output.txt\u0026#34;)) { byte[] array = \u0026#34;JavaGuide\u0026#34;.getBytes(); output.write(array); } catch (IOException e) { e.printStackTrace(); } 运行结果:\n类似于 FileInputStream,FileOutputStream 通常也会配合 BufferedOutputStream(字节缓冲输出流,后文会讲到)来使用。\nFileOutputStream fileOutputStream = new FileOutputStream(\u0026#34;output.txt\u0026#34;); BufferedOutputStream bos = new BufferedOutputStream(fileOutputStream) DataOutputStream 用于写入指定类型数据,不能单独使用,必须结合其它流,比如 FileOutputStream 。\n// 输出流 FileOutputStream fileOutputStream = new FileOutputStream(\u0026#34;out.txt\u0026#34;); DataOutputStream dataOutputStream = new DataOutputStream(fileOutputStream); // 输出任意数据类型 dataOutputStream.writeBoolean(true); dataOutputStream.writeByte(1); ObjectInputStream 用于从输入流中读取 Java 对象(ObjectInputStream,反序列化),ObjectOutputStream将对象写入到输出流(ObjectOutputStream,序列化)。\nObjectOutputStream output = new ObjectOutputStream(new FileOutputStream(\u0026#34;file.txt\u0026#34;) Person person = new Person(\u0026#34;Guide哥\u0026#34;, \u0026#34;JavaGuide作者\u0026#34;); output.writeObject(person); 字符流 # 不管是文件读写还是网络发送接收,信息的最小存储单元都是字节。 那为什么 I/O 流操作要分为字节流操作和字符流操作呢?\n个人认为主要有两点原因:\n字符流是由 Java 虚拟机将字节转换得到的,这个过程还算是比较耗时。 如果我们不知道编码类型就很容易出现乱码问题。 乱码问题这个很容易就可以复现,我们只需要将上面提到的 FileInputStream 代码示例中的 input.txt 文件内容改为中文即可,原代码不需要改动。\n输出:\nNumber of remaining bytes:9 The actual number of bytes skipped:2 The content read from file:§å®¶å¥½ 可以很明显地看到读取出来的内容已经变成了乱码。\n因此,I/O 流就干脆提供了一个直接操作字符的接口,方便我们平时对字符进行流操作。如果音频文件、图片等媒体文件用字节流比较好,如果涉及到字符的话使用字符流比较好。\n字符流默认采用的是 Unicode 编码,我们可以通过构造方法自定义编码。\nUnicode 本身只是一种字符集,它为每个字符分配一个唯一的数字编号,并没有规定具体的存储方式。UTF-8、UTF-16、UTF-32 都是 Unicode 的编码方式,它们使用不同的字节数来表示 Unicode 字符。例如,UTF-8 :英文占 1 字节,中文占 3 字节。\nReader(字符输入流) # Reader用于从源头(通常是文件)读取数据(字符信息)到内存中,java.io.Reader抽象类是所有字符输入流的父类。\nReader 用于读取文本, InputStream 用于读取原始字节。\nReader 常用方法:\nread() : 从输入流读取一个字符。 read(char[] cbuf) : 从输入流中读取一些字符,并将它们存储到字符数组 cbuf中,等价于 read(cbuf, 0, cbuf.length) 。 read(char[] cbuf, int off, int len):在read(char[] cbuf) 方法的基础上增加了 off 参数(偏移量)和 len 参数(要读取的最大字符数)。 skip(long n):忽略输入流中的 n 个字符 ,返回实际忽略的字符数。 close() : 关闭输入流并释放相关的系统资源。 InputStreamReader 是字节流转换为字符流的桥梁,其子类 FileReader 是基于该基础上的封装,可以直接操作字符文件。\n// 字节流转换为字符流的桥梁 public class InputStreamReader extends Reader { } // 用于读取字符文件 public class FileReader extends InputStreamReader { } FileReader 代码示例:\ntry (FileReader fileReader = new FileReader(\u0026#34;input.txt\u0026#34;);) { int content; long skip = fileReader.skip(3); System.out.println(\u0026#34;The actual number of bytes skipped:\u0026#34; + skip); System.out.print(\u0026#34;The content read from file:\u0026#34;); while ((content = fileReader.read()) != -1) { System.out.print((char) content); } } catch (IOException e) { e.printStackTrace(); } input.txt 文件内容:\n输出:\nThe actual number of bytes skipped:3 The content read from file:我是Guide。 Writer(字符输出流) # Writer用于将数据(字符信息)写入到目的地(通常是文件),java.io.Writer抽象类是所有字符输出流的父类。\nWriter 常用方法:\nwrite(int c) : 写入单个字符。 write(char[] cbuf):写入字符数组 cbuf,等价于write(cbuf, 0, cbuf.length)。 write(char[] cbuf, int off, int len):在write(char[] cbuf) 方法的基础上增加了 off 参数(偏移量)和 len 参数(要读取的最大字符数)。 write(String str):写入字符串,等价于 write(str, 0, str.length()) 。 write(String str, int off, int len):在write(String str) 方法的基础上增加了 off 参数(偏移量)和 len 参数(要读取的最大字符数)。 append(CharSequence csq):将指定的字符序列附加到指定的 Writer 对象并返回该 Writer 对象。 append(char c):将指定的字符附加到指定的 Writer 对象并返回该 Writer 对象。 flush():刷新此输出流并强制写出所有缓冲的输出字符。 close():关闭输出流释放相关的系统资源。 OutputStreamWriter 是字符流转换为字节流的桥梁,其子类 FileWriter 是基于该基础上的封装,可以直接将字符写入到文件。\n// 字符流转换为字节流的桥梁 public class OutputStreamWriter extends Writer { } // 用于写入字符到文件 public class FileWriter extends OutputStreamWriter { } FileWriter 代码示例:\ntry (Writer output = new FileWriter(\u0026#34;output.txt\u0026#34;)) { output.write(\u0026#34;你好,我是Guide。\u0026#34;); } catch (IOException e) { e.printStackTrace(); } 输出结果:\n字节缓冲流 # IO 操作是很消耗性能的,缓冲流将数据加载至缓冲区,一次性读取/写入多个字节,从而避免频繁的 IO 操作,提高流的传输效率。\n字节缓冲流这里采用了装饰器模式来增强 InputStream 和OutputStream子类对象的功能。\n举个例子,我们可以通过 BufferedInputStream(字节缓冲输入流)来增强 FileInputStream 的功能。\n// 新建一个 BufferedInputStream 对象 BufferedInputStream bufferedInputStream = new BufferedInputStream(new FileInputStream(\u0026#34;input.txt\u0026#34;)); 字节流和字节缓冲流的性能差别主要体现在我们使用两者的时候都是调用 write(int b) 和 read() 这两个一次只读取一个字节的方法的时候。由于字节缓冲流内部有缓冲区(字节数组),因此,字节缓冲流会先将读取到的字节存放在缓存区,大幅减少 IO 次数,提高读取效率。\n我使用 write(int b) 和 read() 方法,分别通过字节流和字节缓冲流复制一个 524.9 mb 的 PDF 文件耗时对比如下:\n使用缓冲流复制PDF文件总耗时:15428 毫秒 使用普通字节流复制PDF文件总耗时:2555062 毫秒 两者耗时差别非常大,缓冲流耗费的时间是字节流的 1/165。\n测试代码如下:\n@Test void copy_pdf_to_another_pdf_buffer_stream() { // 记录开始时间 long start = System.currentTimeMillis(); try (BufferedInputStream bis = new BufferedInputStream(new FileInputStream(\u0026#34;深入理解计算机操作系统.pdf\u0026#34;)); BufferedOutputStream bos = new BufferedOutputStream(new FileOutputStream(\u0026#34;深入理解计算机操作系统-副本.pdf\u0026#34;))) { int content; while ((content = bis.read()) != -1) { bos.write(content); } } catch (IOException e) { e.printStackTrace(); } // 记录结束时间 long end = System.currentTimeMillis(); System.out.println(\u0026#34;使用缓冲流复制PDF文件总耗时:\u0026#34; + (end - start) + \u0026#34; 毫秒\u0026#34;); } @Test void copy_pdf_to_another_pdf_stream() { // 记录开始时间 long start = System.currentTimeMillis(); try (FileInputStream fis = new FileInputStream(\u0026#34;深入理解计算机操作系统.pdf\u0026#34;); FileOutputStream fos = new FileOutputStream(\u0026#34;深入理解计算机操作系统-副本.pdf\u0026#34;)) { int content; while ((content = fis.read()) != -1) { fos.write(content); } } catch (IOException e) { e.printStackTrace(); } // 记录结束时间 long end = System.currentTimeMillis(); System.out.println(\u0026#34;使用普通流复制PDF文件总耗时:\u0026#34; + (end - start) + \u0026#34; 毫秒\u0026#34;); } 如果是调用 read(byte b[]) 和 write(byte b[], int off, int len) 这两个写入一个字节数组的方法的话,只要字节数组的大小合适,两者的性能差距其实不大,基本可以忽略。\n这次我们使用 read(byte b[]) 和 write(byte b[], int off, int len) 方法,分别通过字节流和字节缓冲流复制一个 524.9 mb 的 PDF 文件耗时对比如下:\n使用缓冲流复制PDF文件总耗时:695 毫秒 使用普通字节流复制PDF文件总耗时:989 毫秒 两者耗时差别不是很大,缓冲流的性能要略微好一点点。\n测试代码如下:\n@Test void copy_pdf_to_another_pdf_with_byte_array_buffer_stream() { // 记录开始时间 long start = System.currentTimeMillis(); try (BufferedInputStream bis = new BufferedInputStream(new FileInputStream(\u0026#34;深入理解计算机操作系统.pdf\u0026#34;)); BufferedOutputStream bos = new BufferedOutputStream(new FileOutputStream(\u0026#34;深入理解计算机操作系统-副本.pdf\u0026#34;))) { int len; byte[] bytes = new byte[4 * 1024]; while ((len = bis.read(bytes)) != -1) { bos.write(bytes, 0, len); } } catch (IOException e) { e.printStackTrace(); } // 记录结束时间 long end = System.currentTimeMillis(); System.out.println(\u0026#34;使用缓冲流复制PDF文件总耗时:\u0026#34; + (end - start) + \u0026#34; 毫秒\u0026#34;); } @Test void copy_pdf_to_another_pdf_with_byte_array_stream() { // 记录开始时间 long start = System.currentTimeMillis(); try (FileInputStream fis = new FileInputStream(\u0026#34;深入理解计算机操作系统.pdf\u0026#34;); FileOutputStream fos = new FileOutputStream(\u0026#34;深入理解计算机操作系统-副本.pdf\u0026#34;)) { int len; byte[] bytes = new byte[4 * 1024]; while ((len = fis.read(bytes)) != -1) { fos.write(bytes, 0, len); } } catch (IOException e) { e.printStackTrace(); } // 记录结束时间 long end = System.currentTimeMillis(); System.out.println(\u0026#34;使用普通流复制PDF文件总耗时:\u0026#34; + (end - start) + \u0026#34; 毫秒\u0026#34;); } BufferedInputStream(字节缓冲输入流) # BufferedInputStream 从源头(通常是文件)读取数据(字节信息)到内存的过程中不会一个字节一个字节的读取,而是会先将读取到的字节存放在缓存区,并从内部缓冲区中单独读取字节。这样大幅减少了 IO 次数,提高了读取效率。\nBufferedInputStream 内部维护了一个缓冲区,这个缓冲区实际就是一个字节数组,通过阅读 BufferedInputStream 源码即可得到这个结论。\npublic class BufferedInputStream extends FilterInputStream { // 内部缓冲区数组 protected volatile byte buf[]; // 缓冲区的默认大小 private static int DEFAULT_BUFFER_SIZE = 8192; // 使用默认的缓冲区大小 public BufferedInputStream(InputStream in) { this(in, DEFAULT_BUFFER_SIZE); } // 自定义缓冲区大小 public BufferedInputStream(InputStream in, int size) { super(in); if (size \u0026lt;= 0) { throw new IllegalArgumentException(\u0026#34;Buffer size \u0026lt;= 0\u0026#34;); } buf = new byte[size]; } } 缓冲区的大小默认为 8192 字节,当然了,你也可以通过 BufferedInputStream(InputStream in, int size) 这个构造方法来指定缓冲区的大小。\nBufferedOutputStream(字节缓冲输出流) # BufferedOutputStream 将数据(字节信息)写入到目的地(通常是文件)的过程中不会一个字节一个字节的写入,而是会先将要写入的字节存放在缓存区,并从内部缓冲区中单独写入字节。这样大幅减少了 IO 次数,提高了读取效率\ntry (BufferedOutputStream bos = new BufferedOutputStream(new FileOutputStream(\u0026#34;output.txt\u0026#34;))) { byte[] array = \u0026#34;JavaGuide\u0026#34;.getBytes(); bos.write(array); } catch (IOException e) { e.printStackTrace(); } 类似于 BufferedInputStream ,BufferedOutputStream 内部也维护了一个缓冲区,并且,这个缓存区的大小也是 8192 字节。\n字符缓冲流 # BufferedReader (字符缓冲输入流)和 BufferedWriter(字符缓冲输出流)类似于 BufferedInputStream(字节缓冲输入流)和BufferedOutputStream(字节缓冲输入流),内部都维护了一个字节数组作为缓冲区。不过,前者主要是用来操作字符信息。\n打印流 # 下面这段代码大家经常使用吧?\nSystem.out.print(\u0026#34;Hello!\u0026#34;); System.out.println(\u0026#34;Hello!\u0026#34;); System.out 实际是用于获取一个 PrintStream 对象,print方法实际调用的是 PrintStream 对象的 write 方法。\nPrintStream 属于字节打印流,与之对应的是 PrintWriter (字符打印流)。PrintStream 是 OutputStream 的子类,PrintWriter 是 Writer 的子类。\npublic class PrintStream extends FilterOutputStream implements Appendable, Closeable { } public class PrintWriter extends Writer { } 随机访问流 # 这里要介绍的随机访问流指的是支持随意跳转到文件的任意位置进行读写的 RandomAccessFile 。\nRandomAccessFile 的构造方法如下,我们可以指定 mode(读写模式)。\n// openAndDelete 参数默认为 false 表示打开文件并且这个文件不会被删除 public RandomAccessFile(File file, String mode) throws FileNotFoundException { this(file, mode, false); } // 私有方法 private RandomAccessFile(File file, String mode, boolean openAndDelete) throws FileNotFoundException{ // 省略大部分代码 } 读写模式主要有下面四种:\nr : 只读模式。 rw: 读写模式 rws: 相对于 rw,rws 同步更新对“文件的内容”或“元数据”的修改到外部存储设备。 rwd : 相对于 rw,rwd 同步更新对“文件的内容”的修改到外部存储设备。 文件内容指的是文件中实际保存的数据,元数据则是用来描述文件属性比如文件的大小信息、创建和修改时间。\nRandomAccessFile 中有一个文件指针用来表示下一个将要被写入或者读取的字节所处的位置。我们可以通过 RandomAccessFile 的 seek(long pos) 方法来设置文件指针的偏移量(距文件开头 pos 个字节处)。如果想要获取文件指针当前的位置的话,可以使用 getFilePointer() 方法。\nRandomAccessFile 代码示例:\nRandomAccessFile randomAccessFile = new RandomAccessFile(new File(\u0026#34;input.txt\u0026#34;), \u0026#34;rw\u0026#34;); System.out.println(\u0026#34;读取之前的偏移量:\u0026#34; + randomAccessFile.getFilePointer() + \u0026#34;,当前读取到的字符\u0026#34; + (char) randomAccessFile.read() + \u0026#34;,读取之后的偏移量:\u0026#34; + randomAccessFile.getFilePointer()); // 指针当前偏移量为 6 randomAccessFile.seek(6); System.out.println(\u0026#34;读取之前的偏移量:\u0026#34; + randomAccessFile.getFilePointer() + \u0026#34;,当前读取到的字符\u0026#34; + (char) randomAccessFile.read() + \u0026#34;,读取之后的偏移量:\u0026#34; + randomAccessFile.getFilePointer()); // 从偏移量 7 的位置开始往后写入字节数据 randomAccessFile.write(new byte[]{\u0026#39;H\u0026#39;, \u0026#39;I\u0026#39;, \u0026#39;J\u0026#39;, \u0026#39;K\u0026#39;}); // 指针当前偏移量为 0,回到起始位置 randomAccessFile.seek(0); System.out.println(\u0026#34;读取之前的偏移量:\u0026#34; + randomAccessFile.getFilePointer() + \u0026#34;,当前读取到的字符\u0026#34; + (char) randomAccessFile.read() + \u0026#34;,读取之后的偏移量:\u0026#34; + randomAccessFile.getFilePointer()); input.txt 文件内容:\n输出:\n读取之前的偏移量:0,当前读取到的字符A,读取之后的偏移量:1 读取之前的偏移量:6,当前读取到的字符G,读取之后的偏移量:7 读取之前的偏移量:0,当前读取到的字符A,读取之后的偏移量:1 input.txt 文件内容变为 ABCDEFGHIJK 。\nRandomAccessFile 的 write 方法在写入对象的时候如果对应的位置已经有数据的话,会将其覆盖掉。\nRandomAccessFile randomAccessFile = new RandomAccessFile(new File(\u0026#34;input.txt\u0026#34;), \u0026#34;rw\u0026#34;); randomAccessFile.write(new byte[]{\u0026#39;H\u0026#39;, \u0026#39;I\u0026#39;, \u0026#39;J\u0026#39;, \u0026#39;K\u0026#39;}); 假设运行上面这段程序之前 input.txt 文件内容变为 ABCD ,运行之后则变为 HIJK 。\nRandomAccessFile 比较常见的一个应用就是实现大文件的 断点续传 。何谓断点续传?简单来说就是上传文件中途暂停或失败(比如遇到网络问题)之后,不需要重新上传,只需要上传那些未成功上传的文件分片即可。分片(先将文件切分成多个文件分片)上传是断点续传的基础。\nRandomAccessFile 可以帮助我们合并文件分片,示例代码如下:\n我在 《Java 面试指北》中详细介绍了大文件的上传问题。\nRandomAccessFile 的实现依赖于 FileDescriptor (文件描述符) 和 FileChannel (内存映射文件)。\n"},{"id":498,"href":"/zh/docs/technology/Interview/java/io/io-model/","title":"Java IO 模型详解","section":"Io","content":"IO 模型这块确实挺难理解的,需要太多计算机底层知识。写这篇文章用了挺久,就非常希望能把我所知道的讲出来吧!希望朋友们能有收获!为了写这篇文章,还翻看了一下《UNIX 网络编程》这本书,太难了,我滴乖乖!心痛~\n个人能力有限。如果文章有任何需要补充/完善/修改的地方,欢迎在评论区指出,共同进步!\n前言 # I/O 一直是很多小伙伴难以理解的一个知识点,这篇文章我会将我所理解的 I/O 讲给你听,希望可以对你有所帮助。\nI/O # 何为 I/O? # I/O(Input/Output) 即输入/输出 。\n我们先从计算机结构的角度来解读一下 I/O。\n根据冯.诺依曼结构,计算机结构分为 5 大部分:运算器、控制器、存储器、输入设备、输出设备。\n输入设备(比如键盘)和输出设备(比如显示器)都属于外部设备。网卡、硬盘这种既可以属于输入设备,也可以属于输出设备。\n输入设备向计算机输入数据,输出设备接收计算机输出的数据。\n从计算机结构的视角来看的话, I/O 描述了计算机系统与外部设备之间通信的过程。\n我们再先从应用程序的角度来解读一下 I/O。\n根据大学里学到的操作系统相关的知识:为了保证操作系统的稳定性和安全性,一个进程的地址空间划分为 用户空间(User space) 和 内核空间(Kernel space ) 。\n像我们平常运行的应用程序都是运行在用户空间,只有内核空间才能进行系统态级别的资源有关的操作,比如文件管理、进程通信、内存管理等等。也就是说,我们想要进行 IO 操作,一定是要依赖内核空间的能力。\n并且,用户空间的程序不能直接访问内核空间。\n当想要执行 IO 操作时,由于没有执行这些操作的权限,只能发起系统调用请求操作系统帮忙完成。\n因此,用户进程想要执行 IO 操作的话,必须通过 系统调用 来间接访问内核空间\n我们在平常开发过程中接触最多的就是 磁盘 IO(读写文件) 和 网络 IO(网络请求和响应)。\n从应用程序的视角来看的话,我们的应用程序对操作系统的内核发起 IO 调用(系统调用),操作系统负责的内核执行具体的 IO 操作。也就是说,我们的应用程序实际上只是发起了 IO 操作的调用而已,具体 IO 的执行是由操作系统的内核来完成的。\n当应用程序发起 I/O 调用后,会经历两个步骤:\n内核等待 I/O 设备准备好数据 内核将数据从内核空间拷贝到用户空间。 有哪些常见的 IO 模型? # UNIX 系统下, IO 模型一共有 5 种:同步阻塞 I/O、同步非阻塞 I/O、I/O 多路复用、信号驱动 I/O 和异步 I/O。\n这也是我们经常提到的 5 种 IO 模型。\nJava 中 3 种常见 IO 模型 # BIO (Blocking I/O) # BIO 属于同步阻塞 IO 模型 。\n同步阻塞 IO 模型中,应用程序发起 read 调用后,会一直阻塞,直到内核把数据拷贝到用户空间。\n在客户端连接数量不高的情况下,是没问题的。但是,当面对十万甚至百万级连接的时候,传统的 BIO 模型是无能为力的。因此,我们需要一种更高效的 I/O 处理模型来应对更高的并发量。\nNIO (Non-blocking/New I/O) # Java 中的 NIO 于 Java 1.4 中引入,对应 java.nio 包,提供了 Channel , Selector,Buffer 等抽象。NIO 中的 N 可以理解为 Non-blocking,不单纯是 New。它是支持面向缓冲的,基于通道的 I/O 操作方法。 对于高负载、高并发的(网络)应用,应使用 NIO 。\nJava 中的 NIO 可以看作是 I/O 多路复用模型。也有很多人认为,Java 中的 NIO 属于同步非阻塞 IO 模型。\n跟着我的思路往下看看,相信你会得到答案!\n我们先来看看 同步非阻塞 IO 模型。\n同步非阻塞 IO 模型中,应用程序会一直发起 read 调用,等待数据从内核空间拷贝到用户空间的这段时间里,线程依然是阻塞的,直到在内核把数据拷贝到用户空间。\n相比于同步阻塞 IO 模型,同步非阻塞 IO 模型确实有了很大改进。通过轮询操作,避免了一直阻塞。\n但是,这种 IO 模型同样存在问题:应用程序不断进行 I/O 系统调用轮询数据是否已经准备好的过程是十分消耗 CPU 资源的。\n这个时候,I/O 多路复用模型 就上场了。\nIO 多路复用模型中,线程首先发起 select 调用,询问内核数据是否准备就绪,等内核把数据准备好了,用户线程再发起 read 调用。read 调用的过程(数据从内核空间 -\u0026gt; 用户空间)还是阻塞的。\n目前支持 IO 多路复用的系统调用,有 select,epoll 等等。select 系统调用,目前几乎在所有的操作系统上都有支持。\nselect 调用:内核提供的系统调用,它支持一次查询多个系统调用的可用状态。几乎所有的操作系统都支持。 epoll 调用:linux 2.6 内核,属于 select 调用的增强版本,优化了 IO 的执行效率。 IO 多路复用模型,通过减少无效的系统调用,减少了对 CPU 资源的消耗。\nJava 中的 NIO ,有一个非常重要的选择器 ( Selector ) 的概念,也可以被称为 多路复用器。通过它,只需要一个线程便可以管理多个客户端连接。当客户端数据到了之后,才会为其服务。\nAIO (Asynchronous I/O) # AIO 也就是 NIO 2。Java 7 中引入了 NIO 的改进版 NIO 2,它是异步 IO 模型。\n异步 IO 是基于事件和回调机制实现的,也就是应用操作之后会直接返回,不会堵塞在那里,当后台处理完成,操作系统会通知相应的线程进行后续的操作。\n目前来说 AIO 的应用还不是很广泛。Netty 之前也尝试使用过 AIO,不过又放弃了。这是因为,Netty 使用了 AIO 之后,在 Linux 系统上的性能并没有多少提升。\n最后,来一张图,简单总结一下 Java 中的 BIO、NIO、AIO。\n参考 # 《深入拆解 Tomcat \u0026amp; Jetty》 如何完成一次 IO: https://llc687.top/126.html 程序员应该这样理解 IO: https://www.jianshu.com/p/fa7bdc4f3de7 10 分钟看懂, Java NIO 底层原理: https://www.cnblogs.com/crazymakercircle/p/10225159.html IO 模型知多少 | 理论篇: https://www.cnblogs.com/sheng-jie/p/how-much-you-know-about-io-models.html 《UNIX 网络编程 卷 1;套接字联网 API 》6.2 节 IO 模型 "},{"id":499,"href":"/zh/docs/technology/Interview/java/io/io-design-patterns/","title":"Java IO 设计模式总结","section":"Io","content":"这篇文章我们简单来看看我们从 IO 中能够学习到哪些设计模式的应用。\n装饰器模式 # 装饰器(Decorator)模式 可以在不改变原有对象的情况下拓展其功能。\n装饰器模式通过组合替代继承来扩展原始类的功能,在一些继承关系比较复杂的场景(IO 这一场景各种类的继承关系就比较复杂)更加实用。\n对于字节流来说, FilterInputStream (对应输入流)和FilterOutputStream(对应输出流)是装饰器模式的核心,分别用于增强 InputStream 和OutputStream子类对象的功能。\n我们常见的BufferedInputStream(字节缓冲输入流)、DataInputStream 等等都是FilterInputStream 的子类,BufferedOutputStream(字节缓冲输出流)、DataOutputStream等等都是FilterOutputStream的子类。\n举个例子,我们可以通过 BufferedInputStream(字节缓冲输入流)来增强 FileInputStream 的功能。\nBufferedInputStream 构造函数如下:\npublic BufferedInputStream(InputStream in) { this(in, DEFAULT_BUFFER_SIZE); } public BufferedInputStream(InputStream in, int size) { super(in); if (size \u0026lt;= 0) { throw new IllegalArgumentException(\u0026#34;Buffer size \u0026lt;= 0\u0026#34;); } buf = new byte[size]; } 可以看出,BufferedInputStream 的构造函数其中的一个参数就是 InputStream 。\nBufferedInputStream 代码示例:\ntry (BufferedInputStream bis = new BufferedInputStream(new FileInputStream(\u0026#34;input.txt\u0026#34;))) { int content; long skip = bis.skip(2); while ((content = bis.read()) != -1) { System.out.print((char) content); } } catch (IOException e) { e.printStackTrace(); } 这个时候,你可以会想了:为啥我们直接不弄一个BufferedFileInputStream(字符缓冲文件输入流)呢?\nBufferedFileInputStream bfis = new BufferedFileInputStream(\u0026#34;input.txt\u0026#34;); 如果 InputStream的子类比较少的话,这样做是没问题的。不过, InputStream的子类实在太多,继承关系也太复杂了。如果我们为每一个子类都定制一个对应的缓冲输入流,那岂不是太麻烦了。\n如果你对 IO 流比较熟悉的话,你会发现ZipInputStream 和ZipOutputStream 还可以分别增强 BufferedInputStream 和 BufferedOutputStream 的能力。\nBufferedInputStream bis = new BufferedInputStream(new FileInputStream(fileName)); ZipInputStream zis = new ZipInputStream(bis); BufferedOutputStream bos = new BufferedOutputStream(new FileOutputStream(fileName)); ZipOutputStream zipOut = new ZipOutputStream(bos); ZipInputStream 和ZipOutputStream 分别继承自InflaterInputStream 和DeflaterOutputStream。\npublic class InflaterInputStream extends FilterInputStream { } public class DeflaterOutputStream extends FilterOutputStream { } 这也是装饰器模式很重要的一个特征,那就是可以对原始类嵌套使用多个装饰器。\n为了实现这一效果,装饰器类需要跟原始类继承相同的抽象类或者实现相同的接口。上面介绍到的这些 IO 相关的装饰类和原始类共同的父类是 InputStream 和OutputStream。\n对于字符流来说,BufferedReader 可以用来增加 Reader (字符输入流)子类的功能,BufferedWriter 可以用来增加 Writer (字符输出流)子类的功能。\nBufferedWriter bw = new BufferedWriter(new OutputStreamWriter(new FileOutputStream(fileName), \u0026#34;UTF-8\u0026#34;)); IO 流中的装饰器模式应用的例子实在是太多了,不需要特意记忆,完全没必要哈!搞清了装饰器模式的核心之后,你在使用的时候自然就会知道哪些地方运用到了装饰器模式。\n适配器模式 # 适配器(Adapter Pattern)模式 主要用于接口互不兼容的类的协调工作,你可以将其联想到我们日常经常使用的电源适配器。\n适配器模式中存在被适配的对象或者类称为 适配者(Adaptee) ,作用于适配者的对象或者类称为适配器(Adapter) 。适配器分为对象适配器和类适配器。类适配器使用继承关系来实现,对象适配器使用组合关系来实现。\nIO 流中的字符流和字节流的接口不同,它们之间可以协调工作就是基于适配器模式来做的,更准确点来说是对象适配器。通过适配器,我们可以将字节流对象适配成一个字符流对象,这样我们可以直接通过字节流对象来读取或者写入字符数据。\nInputStreamReader 和 OutputStreamWriter 就是两个适配器(Adapter), 同时,它们两个也是字节流和字符流之间的桥梁。InputStreamReader 使用 StreamDecoder (流解码器)对字节进行解码,实现字节流到字符流的转换, OutputStreamWriter 使用StreamEncoder(流编码器)对字符进行编码,实现字符流到字节流的转换。\nInputStream 和 OutputStream 的子类是被适配者, InputStreamReader 和 OutputStreamWriter是适配器。\n// InputStreamReader 是适配器,FileInputStream 是被适配的类 InputStreamReader isr = new InputStreamReader(new FileInputStream(fileName), \u0026#34;UTF-8\u0026#34;); // BufferedReader 增强 InputStreamReader 的功能(装饰器模式) BufferedReader bufferedReader = new BufferedReader(isr); java.io.InputStreamReader 部分源码:\npublic class InputStreamReader extends Reader { //用于解码的对象 private final StreamDecoder sd; public InputStreamReader(InputStream in) { super(in); try { // 获取 StreamDecoder 对象 sd = StreamDecoder.forInputStreamReader(in, this, (String)null); } catch (UnsupportedEncodingException e) { throw new Error(e); } } // 使用 StreamDecoder 对象做具体的读取工作 public int read() throws IOException { return sd.read(); } } java.io.OutputStreamWriter 部分源码:\npublic class OutputStreamWriter extends Writer { // 用于编码的对象 private final StreamEncoder se; public OutputStreamWriter(OutputStream out) { super(out); try { // 获取 StreamEncoder 对象 se = StreamEncoder.forOutputStreamWriter(out, this, (String)null); } catch (UnsupportedEncodingException e) { throw new Error(e); } } // 使用 StreamEncoder 对象做具体的写入工作 public void write(int c) throws IOException { se.write(c); } } 适配器模式和装饰器模式有什么区别呢?\n装饰器模式 更侧重于动态地增强原始类的功能,装饰器类需要跟原始类继承相同的抽象类或者实现相同的接口。并且,装饰器模式支持对原始类嵌套使用多个装饰器。\n适配器模式 更侧重于让接口不兼容而不能交互的类可以一起工作,当我们调用适配器对应的方法时,适配器内部会调用适配者类或者和适配类相关的类的方法,这个过程透明的。就比如说 StreamDecoder (流解码器)和StreamEncoder(流编码器)就是分别基于 InputStream 和 OutputStream 来获取 FileChannel对象并调用对应的 read 方法和 write 方法进行字节数据的读取和写入。\nStreamDecoder(InputStream in, Object lock, CharsetDecoder dec) { // 省略大部分代码 // 根据 InputStream 对象获取 FileChannel 对象 ch = getChannel((FileInputStream)in); } 适配器和适配者两者不需要继承相同的抽象类或者实现相同的接口。\n另外,FutureTask 类使用了适配器模式,Executors 的内部类 RunnableAdapter 实现属于适配器,用于将 Runnable 适配成 Callable。\nFutureTask参数包含 Runnable 的一个构造方法:\npublic FutureTask(Runnable runnable, V result) { // 调用 Executors 类的 callable 方法 this.callable = Executors.callable(runnable, result); this.state = NEW; } Executors中对应的方法和适配器:\n// 实际调用的是 Executors 的内部类 RunnableAdapter 的构造方法 public static \u0026lt;T\u0026gt; Callable\u0026lt;T\u0026gt; callable(Runnable task, T result) { if (task == null) throw new NullPointerException(); return new RunnableAdapter\u0026lt;T\u0026gt;(task, result); } // 适配器 static final class RunnableAdapter\u0026lt;T\u0026gt; implements Callable\u0026lt;T\u0026gt; { final Runnable task; final T result; RunnableAdapter(Runnable task, T result) { this.task = task; this.result = result; } public T call() { task.run(); return result; } } 工厂模式 # 工厂模式用于创建对象,NIO 中大量用到了工厂模式,比如 Files 类的 newInputStream 方法用于创建 InputStream 对象(静态工厂)、 Paths 类的 get 方法创建 Path 对象(静态工厂)、ZipFileSystem 类(sun.nio包下的类,属于 java.nio 相关的一些内部实现)的 getPath 的方法创建 Path 对象(简单工厂)。\nInputStream is = Files.newInputStream(Paths.get(generatorLogoPath)) 观察者模式 # NIO 中的文件目录监听服务使用到了观察者模式。\nNIO 中的文件目录监听服务基于 WatchService 接口和 Watchable 接口。WatchService 属于观察者,Watchable 属于被观察者。\nWatchable 接口定义了一个用于将对象注册到 WatchService(监控服务) 并绑定监听事件的方法 register 。\npublic interface Path extends Comparable\u0026lt;Path\u0026gt;, Iterable\u0026lt;Path\u0026gt;, Watchable{ } public interface Watchable { WatchKey register(WatchService watcher, WatchEvent.Kind\u0026lt;?\u0026gt;[] events, WatchEvent.Modifier... modifiers) throws IOException; } WatchService 用于监听文件目录的变化,同一个 WatchService 对象能够监听多个文件目录。\n// 创建 WatchService 对象 WatchService watchService = FileSystems.getDefault().newWatchService(); // 初始化一个被监控文件夹的 Path 类: Path path = Paths.get(\u0026#34;workingDirectory\u0026#34;); // 将这个 path 对象注册到 WatchService(监控服务) 中去 WatchKey watchKey = path.register( watchService, StandardWatchEventKinds...); Path 类 register 方法的第二个参数 events (需要监听的事件)为可变长参数,也就是说我们可以同时监听多种事件。\nWatchKey register(WatchService watcher, WatchEvent.Kind\u0026lt;?\u0026gt;... events) throws IOException; 常用的监听事件有 3 种:\nStandardWatchEventKinds.ENTRY_CREATE:文件创建。 StandardWatchEventKinds.ENTRY_DELETE : 文件删除。 StandardWatchEventKinds.ENTRY_MODIFY : 文件修改。 register 方法返回 WatchKey 对象,通过WatchKey 对象可以获取事件的具体信息比如文件目录下是创建、删除还是修改了文件、创建、删除或者修改的文件的具体名称是什么。\nWatchKey key; while ((key = watchService.take()) != null) { for (WatchEvent\u0026lt;?\u0026gt; event : key.pollEvents()) { // 可以调用 WatchEvent 对象的方法做一些事情比如输出事件的具体上下文信息 } key.reset(); } WatchService 内部是通过一个 daemon thread(守护线程)采用定期轮询的方式来检测文件的变化,简化后的源码如下所示。\nclass PollingWatchService extends AbstractWatchService { // 定义一个 daemon thread(守护线程)轮询检测文件变化 private final ScheduledExecutorService scheduledExecutor; PollingWatchService() { scheduledExecutor = Executors .newSingleThreadScheduledExecutor(new ThreadFactory() { @Override public Thread newThread(Runnable r) { Thread t = new Thread(r); t.setDaemon(true); return t; }}); } void enable(Set\u0026lt;? extends WatchEvent.Kind\u0026lt;?\u0026gt;\u0026gt; events, long period) { synchronized (this) { // 更新监听事件 this.events = events; // 开启定期轮询 Runnable thunk = new Runnable() { public void run() { poll(); }}; this.poller = scheduledExecutor .scheduleAtFixedRate(thunk, period, period, TimeUnit.SECONDS); } } } 参考 # Patterns in Java APIs: http://cecs.wright.edu/~tkprasad/courses/ceg860/paper/node26.html 装饰器模式:通过剖析 Java IO 类库源码学习装饰器模式: https://time.geekbang.org/column/article/204845 sun.nio 包是什么,是 java 代码么? - RednaxelaFX https://www.zhihu.com/question/29237781/answer/43653953 "},{"id":500,"href":"/zh/docs/technology/Interview/java/io/nio-basis/","title":"Java NIO 核心知识总结","section":"Io","content":"在学习 NIO 之前,需要先了解一下计算机 I/O 模型的基础理论知识。还不了解的话,可以参考我写的这篇文章: Java IO 模型详解。\nNIO 简介 # 在传统的 Java I/O 模型(BIO)中,I/O 操作是以阻塞的方式进行的。也就是说,当一个线程执行一个 I/O 操作时,它会被阻塞直到操作完成。这种阻塞模型在处理多个并发连接时可能会导致性能瓶颈,因为需要为每个连接创建一个线程,而线程的创建和切换都是有开销的。\n为了解决这个问题,在 Java1.4 版本引入了一种新的 I/O 模型 — NIO (New IO,也称为 Non-blocking IO) 。NIO 弥补了同步阻塞 I/O 的不足,它在标准 Java 代码中提供了非阻塞、面向缓冲、基于通道的 I/O,可以使用少量的线程来处理多个连接,大大提高了 I/O 效率和并发。\n下图是 BIO、NIO 和 AIO 处理客户端请求的简单对比图(关于 AIO 的介绍,可以看我写的这篇文章: Java IO 模型详解,不是重点,了解即可)。\n⚠️需要注意:使用 NIO 并不一定意味着高性能,它的性能优势主要体现在高并发和高延迟的网络环境下。当连接数较少、并发程度较低或者网络传输速度较快时,NIO 的性能并不一定优于传统的 BIO 。\nNIO 核心组件 # NIO 主要包括以下三个核心组件:\nBuffer(缓冲区):NIO 读写数据都是通过缓冲区进行操作的。读操作的时候将 Channel 中的数据填充到 Buffer 中,而写操作时将 Buffer 中的数据写入到 Channel 中。 Channel(通道):Channel 是一个双向的、可读可写的数据传输通道,NIO 通过 Channel 来实现数据的输入输出。通道是一个抽象的概念,它可以代表文件、套接字或者其他数据源之间的连接。 Selector(选择器):允许一个线程处理多个 Channel,基于事件驱动的 I/O 多路复用模型。所有的 Channel 都可以注册到 Selector 上,由 Selector 来分配线程来处理事件。 三者的关系如下图所示(暂时不理解没关系,后文会详细介绍):\n下面详细介绍一下这三个组件。\nBuffer(缓冲区) # 在传统的 BIO 中,数据的读写是面向流的, 分为字节流和字符流。\n在 Java 1.4 的 NIO 库中,所有数据都是用缓冲区处理的,这是新库和之前的 BIO 的一个重要区别,有点类似于 BIO 中的缓冲流。NIO 在读取数据时,它是直接读到缓冲区中的。在写入数据时,写入到缓冲区中。 使用 NIO 在读写数据时,都是通过缓冲区进行操作。\nBuffer 的子类如下图所示。其中,最常用的是 ByteBuffer,它可以用来存储和操作字节数据。\n你可以将 Buffer 理解为一个数组,IntBuffer、FloatBuffer、CharBuffer 等分别对应 int[]、float[]、char[] 等。\n为了更清晰地认识缓冲区,我们来简单看看Buffer 类中定义的四个成员变量:\npublic abstract class Buffer { // Invariants: mark \u0026lt;= position \u0026lt;= limit \u0026lt;= capacity private int mark = -1; private int position = 0; private int limit; private int capacity; } 这四个成员变量的具体含义如下:\n容量(capacity):Buffer可以存储的最大数据量,Buffer创建时设置且不可改变; 界限(limit):Buffer 中可以读/写数据的边界。写模式下,limit 代表最多能写入的数据,一般等于 capacity(可以通过limit(int newLimit)方法设置);读模式下,limit 等于 Buffer 中实际写入的数据大小。 位置(position):下一个可以被读写的数据的位置(索引)。从写操作模式到读操作模式切换的时候(flip),position 都会归零,这样就可以从头开始读写了。 标记(mark):Buffer允许将位置直接定位到该标记处,这是一个可选属性; 并且,上述变量满足如下的关系:0 \u0026lt;= mark \u0026lt;= position \u0026lt;= limit \u0026lt;= capacity 。\n另外,Buffer 有读模式和写模式这两种模式,分别用于从 Buffer 中读取数据或者向 Buffer 中写入数据。Buffer 被创建之后默认是写模式,调用 flip() 可以切换到读模式。如果要再次切换回写模式,可以调用 clear() 或者 compact() 方法。\nBuffer 对象不能通过 new 调用构造方法创建对象 ,只能通过静态方法实例化 Buffer。\n这里以 ByteBuffer为例进行介绍:\n// 分配堆内存 public static ByteBuffer allocate(int capacity); // 分配直接内存 public static ByteBuffer allocateDirect(int capacity); Buffer 最核心的两个方法:\nget : 读取缓冲区的数据 put :向缓冲区写入数据 除上述两个方法之外,其他的重要方法:\nflip :将缓冲区从写模式切换到读模式,它会将 limit 的值设置为当前 position 的值,将 position 的值设置为 0。 clear: 清空缓冲区,将缓冲区从读模式切换到写模式,并将 position 的值设置为 0,将 limit 的值设置为 capacity 的值。 …… Buffer 中数据变化的过程:\nimport java.nio.*; public class CharBufferDemo { public static void main(String[] args) { // 分配一个容量为8的CharBuffer CharBuffer buffer = CharBuffer.allocate(8); System.out.println(\u0026#34;初始状态:\u0026#34;); printState(buffer); // 向buffer写入3个字符 buffer.put(\u0026#39;a\u0026#39;).put(\u0026#39;b\u0026#39;).put(\u0026#39;c\u0026#39;); System.out.println(\u0026#34;写入3个字符后的状态:\u0026#34;); printState(buffer); // 调用flip()方法,准备读取buffer中的数据,将 position 置 0,limit 的置 3 buffer.flip(); System.out.println(\u0026#34;调用flip()方法后的状态:\u0026#34;); printState(buffer); // 读取字符 while (buffer.hasRemaining()) { System.out.print(buffer.get()); } // 调用clear()方法,清空缓冲区,将 position 的值置为 0,将 limit 的值置为 capacity 的值 buffer.clear(); System.out.println(\u0026#34;调用clear()方法后的状态:\u0026#34;); printState(buffer); } // 打印buffer的capacity、limit、position、mark的位置 private static void printState(CharBuffer buffer) { System.out.print(\u0026#34;capacity: \u0026#34; + buffer.capacity()); System.out.print(\u0026#34;, limit: \u0026#34; + buffer.limit()); System.out.print(\u0026#34;, position: \u0026#34; + buffer.position()); System.out.print(\u0026#34;, mark 开始读取的字符: \u0026#34; + buffer.mark()); System.out.println(\u0026#34;\\n\u0026#34;); } } 输出:\n初始状态: capacity: 8, limit: 8, position: 0 写入3个字符后的状态: capacity: 8, limit: 8, position: 3 准备读取buffer中的数据! 调用flip()方法后的状态: capacity: 8, limit: 3, position: 0 读取到的数据:abc 调用clear()方法后的状态: capacity: 8, limit: 8, position: 0 为了帮助理解,我绘制了一张图片展示 capacity、limit和position每一阶段的变化。\nChannel(通道) # Channel 是一个通道,它建立了与数据源(如文件、网络套接字等)之间的连接。我们可以利用它来读取和写入数据,就像打开了一条自来水管,让数据在 Channel 中自由流动。\nBIO 中的流是单向的,分为各种 InputStream(输入流)和 OutputStream(输出流),数据只是在一个方向上传输。通道与流的不同之处在于通道是双向的,它可以用于读、写或者同时用于读写。\nChannel 与前面介绍的 Buffer 打交道,读操作的时候将 Channel 中的数据填充到 Buffer 中,而写操作时将 Buffer 中的数据写入到 Channel 中。\n另外,因为 Channel 是全双工的,所以它可以比流更好地映射底层操作系统的 API。特别是在 UNIX 网络编程模型中,底层操作系统的通道都是全双工的,同时支持读写操作。\nChannel 的子类如下图所示。\n其中,最常用的是以下几种类型的通道:\nFileChannel:文件访问通道; SocketChannel、ServerSocketChannel:TCP 通信通道; DatagramChannel:UDP 通信通道; Channel 最核心的两个方法:\nread :读取数据并写入到 Buffer 中。 write :将 Buffer 中的数据写入到 Channel 中。 这里我们以 FileChannel 为例演示一下是读取文件数据的。\nRandomAccessFile reader = new RandomAccessFile(\u0026#34;/Users/guide/Documents/test_read.in\u0026#34;, \u0026#34;r\u0026#34;)) FileChannel channel = reader.getChannel(); ByteBuffer buffer = ByteBuffer.allocate(1024); channel.read(buffer); Selector(选择器) # Selector(选择器) 是 NIO 中的一个关键组件,它允许一个线程处理多个 Channel。Selector 是基于事件驱动的 I/O 多路复用模型,主要运作原理是:通过 Selector 注册通道的事件,Selector 会不断地轮询注册在其上的 Channel。当事件发生时,比如:某个 Channel 上面有新的 TCP 连接接入、读和写事件,这个 Channel 就处于就绪状态,会被 Selector 轮询出来。Selector 会将相关的 Channel 加入到就绪集合中。通过 SelectionKey 可以获取就绪 Channel 的集合,然后对这些就绪的 Channel 进行相应的 I/O 操作。\n一个多路复用器 Selector 可以同时轮询多个 Channel,由于 JDK 使用了 epoll() 代替传统的 select 实现,所以它并没有最大连接句柄 1024/2048 的限制。这也就意味着只需要一个线程负责 Selector 的轮询,就可以接入成千上万的客户端。\nSelector 可以监听以下四种事件类型:\nSelectionKey.OP_ACCEPT:表示通道接受连接的事件,这通常用于 ServerSocketChannel。 SelectionKey.OP_CONNECT:表示通道完成连接的事件,这通常用于 SocketChannel。 SelectionKey.OP_READ:表示通道准备好进行读取的事件,即有数据可读。 SelectionKey.OP_WRITE:表示通道准备好进行写入的事件,即可以写入数据。 Selector是抽象类,可以通过调用此类的 open() 静态方法来创建 Selector 实例。Selector 可以同时监控多个 SelectableChannel 的 IO 状况,是非阻塞 IO 的核心。\n一个 Selector 实例有三个 SelectionKey 集合:\n所有的 SelectionKey 集合:代表了注册在该 Selector 上的 Channel,这个集合可以通过 keys() 方法返回。 被选择的 SelectionKey 集合:代表了所有可通过 select() 方法获取的、需要进行 IO 处理的 Channel,这个集合可以通过 selectedKeys() 返回。 被取消的 SelectionKey 集合:代表了所有被取消注册关系的 Channel,在下一次执行 select() 方法时,这些 Channel 对应的 SelectionKey 会被彻底删除,程序通常无须直接访问该集合,也没有暴露访问的方法。 简单演示一下如何遍历被选择的 SelectionKey 集合并进行处理:\nSet\u0026lt;SelectionKey\u0026gt; selectedKeys = selector.selectedKeys(); Iterator\u0026lt;SelectionKey\u0026gt; keyIterator = selectedKeys.iterator(); while (keyIterator.hasNext()) { SelectionKey key = keyIterator.next(); if (key != null) { if (key.isAcceptable()) { // ServerSocketChannel 接收了一个新连接 } else if (key.isConnectable()) { // 表示一个新连接建立 } else if (key.isReadable()) { // Channel 有准备好的数据,可以读取 } else if (key.isWritable()) { // Channel 有空闲的 Buffer,可以写入数据 } } keyIterator.remove(); } Selector 还提供了一系列和 select() 相关的方法:\nint select():监控所有注册的 Channel,当它们中间有需要处理的 IO 操作时,该方法返回,并将对应的 SelectionKey 加入被选择的 SelectionKey 集合中,该方法返回这些 Channel 的数量。 int select(long timeout):可以设置超时时长的 select() 操作。 int selectNow():执行一个立即返回的 select() 操作,相对于无参数的 select() 方法而言,该方法不会阻塞线程。 Selector wakeup():使一个还未返回的 select() 方法立刻返回。 …… 使用 Selector 实现网络读写的简单示例:\nimport java.io.IOException; import java.net.InetSocketAddress; import java.nio.ByteBuffer; import java.nio.channels.SelectionKey; import java.nio.channels.Selector; import java.nio.channels.ServerSocketChannel; import java.nio.channels.SocketChannel; import java.util.Iterator; import java.util.Set; public class NioSelectorExample { public static void main(String[] args) { try { ServerSocketChannel serverSocketChannel = ServerSocketChannel.open(); serverSocketChannel.configureBlocking(false); serverSocketChannel.socket().bind(new InetSocketAddress(8080)); Selector selector = Selector.open(); // 将 ServerSocketChannel 注册到 Selector 并监听 OP_ACCEPT 事件 serverSocketChannel.register(selector, SelectionKey.OP_ACCEPT); while (true) { int readyChannels = selector.select(); if (readyChannels == 0) { continue; } Set\u0026lt;SelectionKey\u0026gt; selectedKeys = selector.selectedKeys(); Iterator\u0026lt;SelectionKey\u0026gt; keyIterator = selectedKeys.iterator(); while (keyIterator.hasNext()) { SelectionKey key = keyIterator.next(); if (key.isAcceptable()) { // 处理连接事件 ServerSocketChannel server = (ServerSocketChannel) key.channel(); SocketChannel client = server.accept(); client.configureBlocking(false); // 将客户端通道注册到 Selector 并监听 OP_READ 事件 client.register(selector, SelectionKey.OP_READ); } else if (key.isReadable()) { // 处理读事件 SocketChannel client = (SocketChannel) key.channel(); ByteBuffer buffer = ByteBuffer.allocate(1024); int bytesRead = client.read(buffer); if (bytesRead \u0026gt; 0) { buffer.flip(); System.out.println(\u0026#34;收到数据:\u0026#34; +new String(buffer.array(), 0, bytesRead)); // 将客户端通道注册到 Selector 并监听 OP_WRITE 事件 client.register(selector, SelectionKey.OP_WRITE); } else if (bytesRead \u0026lt; 0) { // 客户端断开连接 client.close(); } } else if (key.isWritable()) { // 处理写事件 SocketChannel client = (SocketChannel) key.channel(); ByteBuffer buffer = ByteBuffer.wrap(\u0026#34;Hello, Client!\u0026#34;.getBytes()); client.write(buffer); // 将客户端通道注册到 Selector 并监听 OP_READ 事件 client.register(selector, SelectionKey.OP_READ); } keyIterator.remove(); } } } catch (IOException e) { e.printStackTrace(); } } } 在示例中,我们创建了一个简单的服务器,监听 8080 端口,使用 Selector 处理连接、读取和写入事件。当接收到客户端的数据时,服务器将读取数据并将其打印到控制台,然后向客户端回复 \u0026ldquo;Hello, Client!\u0026quot;。\nNIO 零拷贝 # 零拷贝是提升 IO 操作性能的一个常用手段,像 ActiveMQ、Kafka 、RocketMQ、QMQ、Netty 等顶级开源项目都用到了零拷贝。\n零拷贝是指计算机执行 IO 操作时,CPU 不需要将数据从一个存储区域复制到另一个存储区域,从而可以减少上下文切换以及 CPU 的拷贝时间。也就是说,零拷贝主要解决操作系统在处理 I/O 操作时频繁复制数据的问题。零拷贝的常见实现技术有: mmap+write、sendfile和 sendfile + DMA gather copy 。\n下图展示了各种零拷贝技术的对比图:\nCPU 拷贝 DMA 拷贝 系统调用 上下文切换 传统方法 2 2 read+write 4 mmap+write 1 2 mmap+write 4 sendfile 1 2 sendfile 2 sendfile + DMA gather copy 0 2 sendfile 2 可以看出,无论是传统的 I/O 方式,还是引入了零拷贝之后,2 次 DMA(Direct Memory Access) 拷贝是都少不了的。因为两次 DMA 都是依赖硬件完成的。零拷贝主要是减少了 CPU 拷贝及上下文的切换。\nJava 对零拷贝的支持:\nMappedByteBuffer 是 NIO 基于内存映射(mmap)这种零拷⻉⽅式的提供的⼀种实现,底层实际是调用了 Linux 内核的 mmap 系统调用。它可以将一个文件或者文件的一部分映射到内存中,形成一个虚拟内存文件,这样就可以直接操作内存中的数据,而不需要通过系统调用来读写文件。 FileChannel 的transferTo()/transferFrom()是 NIO 基于发送文件(sendfile)这种零拷贝方式的提供的一种实现,底层实际是调用了 Linux 内核的 sendfile系统调用。它可以直接将文件数据从磁盘发送到网络,而不需要经过用户空间的缓冲区。关于FileChannel的用法可以看看这篇文章: Java NIO 文件通道 FileChannel 用法。 代码示例:\nprivate void loadFileIntoMemory(File xmlFile) throws IOException { FileInputStream fis = new FileInputStream(xmlFile); // 创建 FileChannel 对象 FileChannel fc = fis.getChannel(); // FileChannel.map() 将文件映射到直接内存并返回 MappedByteBuffer 对象 MappedByteBuffer mmb = fc.map(FileChannel.MapMode.READ_ONLY, 0, fc.size()); xmlFileBuffer = new byte[(int)fc.size()]; mmb.get(xmlFileBuffer); fis.close(); } 总结 # 这篇文章我们主要介绍了 NIO 的核心知识点,包括 NIO 的核心组件和零拷贝。\n如果我们需要使用 NIO 构建网络程序的话,不建议直接使用原生 NIO,编程复杂且功能性太弱,推荐使用一些成熟的基于 NIO 的网络编程框架比如 Netty。Netty 在 NIO 的基础上进行了一些优化和扩展比如支持多种协议、支持 SSL/TLS 等等。\n参考 # Java NIO 浅析: https://tech.meituan.com/2016/11/04/nio.html\n面试官:Java NIO 了解? https://mp.weixin.qq.com/s/mZobf-U8OSYQfHfYBEB6KA\nJava NIO:Buffer、Channel 和 Selector: https://www.javadoop.com/post/java-nio\n"},{"id":501,"href":"/zh/docs/technology/Interview/java/basis/spi/","title":"Java SPI 机制详解","section":"Basis","content":" 本文来自 Kingshion 投稿。欢迎更多朋友参与到 JavaGuide 的维护工作,这是一件非常有意义的事情。详细信息请看: JavaGuide 贡献指南 。\n面向对象设计鼓励模块间基于接口而非具体实现编程,以降低模块间的耦合,遵循依赖倒置原则,并支持开闭原则(对扩展开放,对修改封闭)。然而,直接依赖具体实现会导致在替换实现时需要修改代码,违背了开闭原则。为了解决这个问题,SPI 应运而生,它提供了一种服务发现机制,允许在程序外部动态指定具体实现。这与控制反转(IoC)的思想相似,将组件装配的控制权移交给了程序之外。\nSPI 机制也解决了 Java 类加载体系中双亲委派模型带来的限制。 双亲委派模型虽然保证了核心库的安全性和一致性,但也限制了核心库或扩展库加载应用程序类路径上的类(通常由第三方实现)。SPI 允许核心或扩展库定义服务接口,第三方开发者提供并部署实现,SPI 服务加载机制则在运行时动态发现并加载这些实现。例如,JDBC 4.0 及之后版本利用 SPI 自动发现和加载数据库驱动,开发者只需将驱动 JAR 包放置在类路径下即可,无需使用Class.forName()显式加载驱动类。\nSPI 介绍 # 何谓 SPI? # SPI 即 Service Provider Interface ,字面意思就是:“服务提供者的接口”,我的理解是:专门提供给服务提供者或者扩展框架功能的开发者去使用的一个接口。\nSPI 将服务接口和具体的服务实现分离开来,将服务调用方和服务实现者解耦,能够提升程序的扩展性、可维护性。修改或者替换服务实现并不需要修改调用方。\n很多框架都使用了 Java 的 SPI 机制,比如:Spring 框架、数据库加载驱动、日志接口、以及 Dubbo 的扩展实现等等。 SPI 和 API 有什么区别? # 那 SPI 和 API 有啥区别?\n说到 SPI 就不得不说一下 API(Application Programming Interface) 了,从广义上来说它们都属于接口,而且很容易混淆。下面先用一张图说明一下:\n一般模块之间都是通过接口进行通讯,因此我们在服务调用方和服务实现方(也称服务提供者)之间引入一个“接口”。\n当实现方提供了接口和实现,我们可以通过调用实现方的接口从而拥有实现方给我们提供的能力,这就是 API。这种情况下,接口和实现都是放在实现方的包中。调用方通过接口调用实现方的功能,而不需要关心具体的实现细节。 当接口存在于调用方这边时,这就是 SPI 。由接口调用方确定接口规则,然后由不同的厂商根据这个规则对这个接口进行实现,从而提供服务。 举个通俗易懂的例子:公司 H 是一家科技公司,新设计了一款芯片,然后现在需要量产了,而市面上有好几家芯片制造业公司,这个时候,只要 H 公司指定好了这芯片生产的标准(定义好了接口标准),那么这些合作的芯片公司(服务提供者)就按照标准交付自家特色的芯片(提供不同方案的实现,但是给出来的结果是一样的)。\n实战演示 # SLF4J (Simple Logging Facade for Java)是 Java 的一个日志门面(接口),其具体实现有几种,比如:Logback、Log4j、Log4j2 等等,而且还可以切换,在切换日志具体实现的时候我们是不需要更改项目代码的,只需要在 Maven 依赖里面修改一些 pom 依赖就好了。\n这就是依赖 SPI 机制实现的,那我们接下来就实现一个简易版本的日志框架。\nService Provider Interface # 新建一个 Java 项目 service-provider-interface 目录结构如下:(注意直接新建 Java 项目就好了,不用新建 Maven 项目,Maven 项目会涉及到一些编译配置,如果有私服的话,直接 deploy 会比较方便,但是没有的话,在过程中可能会遇到一些奇怪的问题。)\n│ service-provider-interface.iml │ ├─.idea │ │ .gitignore │ │ misc.xml │ │ modules.xml │ └─ workspace.xml │ └─src └─edu └─jiangxuan └─up └─spi Logger.java LoggerService.java Main.class 新建 Logger 接口,这个就是 SPI , 服务提供者接口,后面的服务提供者就要针对这个接口进行实现。\npackage edu.jiangxuan.up.spi; public interface Logger { void info(String msg); void debug(String msg); } 接下来就是 LoggerService 类,这个主要是为服务使用者(调用方)提供特定功能的。这个类也是实现 Java SPI 机制的关键所在,如果存在疑惑的话可以先往后面继续看。\npackage edu.jiangxuan.up.spi; import java.util.ArrayList; import java.util.List; import java.util.ServiceLoader; public class LoggerService { private static final LoggerService SERVICE = new LoggerService(); private final Logger logger; private final List\u0026lt;Logger\u0026gt; loggerList; private LoggerService() { ServiceLoader\u0026lt;Logger\u0026gt; loader = ServiceLoader.load(Logger.class); List\u0026lt;Logger\u0026gt; list = new ArrayList\u0026lt;\u0026gt;(); for (Logger log : loader) { list.add(log); } // LoggerList 是所有 ServiceProvider loggerList = list; if (!list.isEmpty()) { // Logger 只取一个 logger = list.get(0); } else { logger = null; } } public static LoggerService getService() { return SERVICE; } public void info(String msg) { if (logger == null) { System.out.println(\u0026#34;info 中没有发现 Logger 服务提供者\u0026#34;); } else { logger.info(msg); } } public void debug(String msg) { if (loggerList.isEmpty()) { System.out.println(\u0026#34;debug 中没有发现 Logger 服务提供者\u0026#34;); } loggerList.forEach(log -\u0026gt; log.debug(msg)); } } 新建 Main 类(服务使用者,调用方),启动程序查看结果。\npackage org.spi.service; public class Main { public static void main(String[] args) { LoggerService service = LoggerService.getService(); service.info(\u0026#34;Hello SPI\u0026#34;); service.debug(\u0026#34;Hello SPI\u0026#34;); } } 程序结果:\ninfo 中没有发现 Logger 服务提供者 debug 中没有发现 Logger 服务提供者\n此时我们只是空有接口,并没有为 Logger 接口提供任何的实现,所以输出结果中没有按照预期打印相应的结果。\n你可以使用命令或者直接使用 IDEA 将整个程序直接打包成 jar 包。\nService Provider # 接下来新建一个项目用来实现 Logger 接口\n新建项目 service-provider 目录结构如下:\n│ service-provider.iml │ ├─.idea │ │ .gitignore │ │ misc.xml │ │ modules.xml │ └─ workspace.xml │ ├─lib │ service-provider-interface.jar | └─src ├─edu │ └─jiangxuan │ └─up │ └─spi │ └─service │ Logback.java │ └─META-INF └─services edu.jiangxuan.up.spi.Logger 新建 Logback 类\npackage edu.jiangxuan.up.spi.service; import edu.jiangxuan.up.spi.Logger; public class Logback implements Logger { @Override public void info(String s) { System.out.println(\u0026#34;Logback info 打印日志:\u0026#34; + s); } @Override public void debug(String s) { System.out.println(\u0026#34;Logback debug 打印日志:\u0026#34; + s); } } 将 service-provider-interface 的 jar 导入项目中。\n新建 lib 目录,然后将 jar 包拷贝过来,再添加到项目中。\n再点击 OK 。\n接下来就可以在项目中导入 jar 包里面的一些类和方法了,就像 JDK 工具类导包一样的。\n实现 Logger 接口,在 src 目录下新建 META-INF/services 文件夹,然后新建文件 edu.jiangxuan.up.spi.Logger (SPI 的全类名),文件里面的内容是:edu.jiangxuan.up.spi.service.Logback (Logback 的全类名,即 SPI 的实现类的包名 + 类名)。\n这是 JDK SPI 机制 ServiceLoader 约定好的标准。\n这里先大概解释一下:Java 中的 SPI 机制就是在每次类加载的时候会先去找到 class 相对目录下的 META-INF 文件夹下的 services 文件夹下的文件,将这个文件夹下面的所有文件先加载到内存中,然后根据这些文件的文件名和里面的文件内容找到相应接口的具体实现类,找到实现类后就可以通过反射去生成对应的对象,保存在一个 list 列表里面,所以可以通过迭代或者遍历的方式拿到对应的实例对象,生成不同的实现。\n所以会提出一些规范要求:文件名一定要是接口的全类名,然后里面的内容一定要是实现类的全类名,实现类可以有多个,直接换行就好了,多个实现类的时候,会一个一个的迭代加载。\n接下来同样将 service-provider 项目打包成 jar 包,这个 jar 包就是服务提供方的实现。通常我们导入 maven 的 pom 依赖就有点类似这种,只不过我们现在没有将这个 jar 包发布到 maven 公共仓库中,所以在需要使用的地方只能手动的添加到项目中。\n效果展示 # 为了更直观的展示效果,我这里再新建一个专门用来测试的工程项目:java-spi-test\n然后先导入 Logger 的接口 jar 包,再导入具体的实现类的 jar 包。\n新建 Main 方法测试:\npackage edu.jiangxuan.up.service; import edu.jiangxuan.up.spi.LoggerService; public class TestJavaSPI { public static void main(String[] args) { LoggerService loggerService = LoggerService.getService(); loggerService.info(\u0026#34;你好\u0026#34;); loggerService.debug(\u0026#34;测试Java SPI 机制\u0026#34;); } } 运行结果如下:\nLogback info 打印日志:你好 Logback debug 打印日志:测试 Java SPI 机制\n说明导入 jar 包中的实现类生效了。\n如果我们不导入具体的实现类的 jar 包,那么此时程序运行的结果就会是:\ninfo 中没有发现 Logger 服务提供者 debug 中没有发现 Logger 服务提供者\n通过使用 SPI 机制,可以看出服务(LoggerService)和 服务提供者两者之间的耦合度非常低,如果说我们想要换一种实现,那么其实只需要修改 service-provider 项目中针对 Logger 接口的具体实现就可以了,只需要换一个 jar 包即可,也可以有在一个项目里面有多个实现,这不就是 SLF4J 原理吗?\n如果某一天需求变更了,此时需要将日志输出到消息队列,或者做一些别的操作,这个时候完全不需要更改 Logback 的实现,只需要新增一个服务实现(service-provider)可以通过在本项目里面新增实现也可以从外部引入新的服务实现 jar 包。我们可以在服务(LoggerService)中选择一个具体的 服务实现(service-provider) 来完成我们需要的操作。\n那么接下来我们具体来说说 Java SPI 工作的重点原理—— ServiceLoader 。\nServiceLoader # ServiceLoader 具体实现 # 想要使用 Java 的 SPI 机制是需要依赖 ServiceLoader 来实现的,那么我们接下来看看 ServiceLoader 具体是怎么做的:\nServiceLoader 是 JDK 提供的一个工具类, 位于package java.util;包下。\nA facility to load implementations of a service. 这是 JDK 官方给的注释:一种加载服务实现的工具。\n再往下看,我们发现这个类是一个 final 类型的,所以是不可被继承修改,同时它实现了 Iterable 接口。之所以实现了迭代器,是为了方便后续我们能够通过迭代的方式得到对应的服务实现。\npublic final class ServiceLoader\u0026lt;S\u0026gt; implements Iterable\u0026lt;S\u0026gt;{ xxx...} 可以看到一个熟悉的常量定义:\nprivate static final String PREFIX = \u0026quot;META-INF/services/\u0026quot;;\n下面是 load 方法:可以发现 load 方法支持两种重载后的入参;\npublic static \u0026lt;S\u0026gt; ServiceLoader\u0026lt;S\u0026gt; load(Class\u0026lt;S\u0026gt; service) { ClassLoader cl = Thread.currentThread().getContextClassLoader(); return ServiceLoader.load(service, cl); } public static \u0026lt;S\u0026gt; ServiceLoader\u0026lt;S\u0026gt; load(Class\u0026lt;S\u0026gt; service, ClassLoader loader) { return new ServiceLoader\u0026lt;\u0026gt;(service, loader); } private ServiceLoader(Class\u0026lt;S\u0026gt; svc, ClassLoader cl) { service = Objects.requireNonNull(svc, \u0026#34;Service interface cannot be null\u0026#34;); loader = (cl == null) ? ClassLoader.getSystemClassLoader() : cl; acc = (System.getSecurityManager() != null) ? AccessController.getContext() : null; reload(); } public void reload() { providers.clear(); lookupIterator = new LazyIterator(service, loader); } 其解决第三方类加载的机制其实就蕴含在 ClassLoader cl = Thread.currentThread().getContextClassLoader(); 中,cl 就是线程上下文类加载器(Thread Context ClassLoader)。这是每个线程持有的类加载器,JDK 的设计允许应用程序或容器(如 Web 应用服务器)设置这个类加载器,以便核心类库能够通过它来加载应用程序类。\n线程上下文类加载器默认情况下是应用程序类加载器(Application ClassLoader),它负责加载 classpath 上的类。当核心库需要加载应用程序提供的类时,它可以使用线程上下文类加载器来完成。这样,即使是由引导类加载器加载的核心库代码,也能够加载并使用由应用程序类加载器加载的类。\n根据代码的调用顺序,在 reload() 方法中是通过一个内部类 LazyIterator 实现的。先继续往下面看。\nServiceLoader 实现了 Iterable 接口的方法后,具有了迭代的能力,在这个 iterator 方法被调用时,首先会在 ServiceLoader 的 Provider 缓存中进行查找,如果缓存中没有命中那么则在 LazyIterator 中进行查找。\npublic Iterator\u0026lt;S\u0026gt; iterator() { return new Iterator\u0026lt;S\u0026gt;() { Iterator\u0026lt;Map.Entry\u0026lt;String, S\u0026gt;\u0026gt; knownProviders = providers.entrySet().iterator(); public boolean hasNext() { if (knownProviders.hasNext()) return true; return lookupIterator.hasNext(); // 调用 LazyIterator } public S next() { if (knownProviders.hasNext()) return knownProviders.next().getValue(); return lookupIterator.next(); // 调用 LazyIterator } public void remove() { throw new UnsupportedOperationException(); } }; } 在调用 LazyIterator 时,具体实现如下:\npublic boolean hasNext() { if (acc == null) { return hasNextService(); } else { PrivilegedAction\u0026lt;Boolean\u0026gt; action = new PrivilegedAction\u0026lt;Boolean\u0026gt;() { public Boolean run() { return hasNextService(); } }; return AccessController.doPrivileged(action, acc); } } private boolean hasNextService() { if (nextName != null) { return true; } if (configs == null) { try { //通过PREFIX(META-INF/services/)和类名 获取对应的配置文件,得到具体的实现类 String fullName = PREFIX + service.getName(); if (loader == null) configs = ClassLoader.getSystemResources(fullName); else configs = loader.getResources(fullName); } catch (IOException x) { fail(service, \u0026#34;Error locating configuration files\u0026#34;, x); } } while ((pending == null) || !pending.hasNext()) { if (!configs.hasMoreElements()) { return false; } pending = parse(service, configs.nextElement()); } nextName = pending.next(); return true; } public S next() { if (acc == null) { return nextService(); } else { PrivilegedAction\u0026lt;S\u0026gt; action = new PrivilegedAction\u0026lt;S\u0026gt;() { public S run() { return nextService(); } }; return AccessController.doPrivileged(action, acc); } } private S nextService() { if (!hasNextService()) throw new NoSuchElementException(); String cn = nextName; nextName = null; Class\u0026lt;?\u0026gt; c = null; try { c = Class.forName(cn, false, loader); } catch (ClassNotFoundException x) { fail(service, \u0026#34;Provider \u0026#34; + cn + \u0026#34; not found\u0026#34;); } if (!service.isAssignableFrom(c)) { fail(service, \u0026#34;Provider \u0026#34; + cn + \u0026#34; not a subtype\u0026#34;); } try { S p = service.cast(c.newInstance()); providers.put(cn, p); return p; } catch (Throwable x) { fail(service, \u0026#34;Provider \u0026#34; + cn + \u0026#34; could not be instantiated\u0026#34;, x); } throw new Error(); // This cannot happen } 可能很多人看这个会觉得有点复杂,没关系,我这边实现了一个简单的 ServiceLoader 的小模型,流程和原理都是保持一致的,可以先从自己实现一个简易版本的开始学:\n自己实现一个 ServiceLoader # 我先把代码贴出来:\npackage edu.jiangxuan.up.service; import java.io.BufferedReader; import java.io.InputStream; import java.io.InputStreamReader; import java.lang.reflect.Constructor; import java.net.URL; import java.net.URLConnection; import java.util.ArrayList; import java.util.Enumeration; import java.util.List; public class MyServiceLoader\u0026lt;S\u0026gt; { // 对应的接口 Class 模板 private final Class\u0026lt;S\u0026gt; service; // 对应实现类的 可以有多个,用 List 进行封装 private final List\u0026lt;S\u0026gt; providers = new ArrayList\u0026lt;\u0026gt;(); // 类加载器 private final ClassLoader classLoader; // 暴露给外部使用的方法,通过调用这个方法可以开始加载自己定制的实现流程。 public static \u0026lt;S\u0026gt; MyServiceLoader\u0026lt;S\u0026gt; load(Class\u0026lt;S\u0026gt; service) { return new MyServiceLoader\u0026lt;\u0026gt;(service); } // 构造方法私有化 private MyServiceLoader(Class\u0026lt;S\u0026gt; service) { this.service = service; this.classLoader = Thread.currentThread().getContextClassLoader(); doLoad(); } // 关键方法,加载具体实现类的逻辑 private void doLoad() { try { // 读取所有 jar 包里面 META-INF/services 包下面的文件,这个文件名就是接口名,然后文件里面的内容就是具体的实现类的路径加全类名 Enumeration\u0026lt;URL\u0026gt; urls = classLoader.getResources(\u0026#34;META-INF/services/\u0026#34; + service.getName()); // 挨个遍历取到的文件 while (urls.hasMoreElements()) { // 取出当前的文件 URL url = urls.nextElement(); System.out.println(\u0026#34;File = \u0026#34; + url.getPath()); // 建立链接 URLConnection urlConnection = url.openConnection(); urlConnection.setUseCaches(false); // 获取文件输入流 InputStream inputStream = urlConnection.getInputStream(); // 从文件输入流获取缓存 BufferedReader bufferedReader = new BufferedReader(new InputStreamReader(inputStream)); // 从文件内容里面得到实现类的全类名 String className = bufferedReader.readLine(); while (className != null) { // 通过反射拿到实现类的实例 Class\u0026lt;?\u0026gt; clazz = Class.forName(className, false, classLoader); // 如果声明的接口跟这个具体的实现类是属于同一类型,(可以理解为Java的一种多态,接口跟实现类、父类和子类等等这种关系。)则构造实例 if (service.isAssignableFrom(clazz)) { Constructor\u0026lt;? extends S\u0026gt; constructor = (Constructor\u0026lt;? extends S\u0026gt;) clazz.getConstructor(); S instance = constructor.newInstance(); // 把当前构造的实例对象添加到 Provider的列表里面 providers.add(instance); } // 继续读取下一行的实现类,可以有多个实现类,只需要换行就可以了。 className = bufferedReader.readLine(); } } } catch (Exception e) { System.out.println(\u0026#34;读取文件异常。。。\u0026#34;); } } // 返回spi接口对应的具体实现类列表 public List\u0026lt;S\u0026gt; getProviders() { return providers; } } 关键信息基本已经通过代码注释描述出来了,\n主要的流程就是:\n通过 URL 工具类从 jar 包的 /META-INF/services 目录下面找到对应的文件, 读取这个文件的名称找到对应的 spi 接口, 通过 InputStream 流将文件里面的具体实现类的全类名读取出来, 根据获取到的全类名,先判断跟 spi 接口是否为同一类型,如果是的,那么就通过反射的机制构造对应的实例对象, 将构造出来的实例对象添加到 Providers 的列表中。 总结 # 其实不难发现,SPI 机制的具体实现本质上还是通过反射完成的。即:我们按照规定将要暴露对外使用的具体实现类在 META-INF/services/ 文件下声明。\n另外,SPI 机制在很多框架中都有应用:Spring 框架的基本原理也是类似的方式。还有 Dubbo 框架提供同样的 SPI 扩展机制,只不过 Dubbo 和 spring 框架中的 SPI 机制具体实现方式跟咱们今天学得这个有些细微的区别,不过整体的原理都是一致的,相信大家通过对 JDK 中 SPI 机制的学习,能够一通百通,加深对其他高深框架的理解。\n通过 SPI 机制能够大大地提高接口设计的灵活性,但是 SPI 机制也存在一些缺点,比如:\n遍历加载所有的实现类,这样效率还是相对较低的; 当多个 ServiceLoader 同时 load 时,会有并发问题。 "},{"id":502,"href":"/zh/docs/technology/Interview/java/concurrent/java-concurrent-collections/","title":"Java 常见并发容器总结","section":"Concurrent","content":"JDK 提供的这些容器大部分在 java.util.concurrent 包中。\nConcurrentHashMap : 线程安全的 HashMap CopyOnWriteArrayList : 线程安全的 List,在读多写少的场合性能非常好,远远好于 Vector。 ConcurrentLinkedQueue : 高效的并发队列,使用链表实现。可以看做一个线程安全的 LinkedList,这是一个非阻塞队列。 BlockingQueue : 这是一个接口,JDK 内部通过链表、数组等方式实现了这个接口。表示阻塞队列,非常适合用于作为数据共享的通道。 ConcurrentSkipListMap : 跳表的实现。这是一个 Map,使用跳表的数据结构进行快速查找。 ConcurrentHashMap # 我们知道,HashMap 是线程不安全的,如果在并发场景下使用,一种常见的解决方式是通过 Collections.synchronizedMap() 方法对 HashMap 进行包装,使其变为线程安全。不过,这种方式是通过一个全局锁来同步不同线程间的并发访问,会导致严重的性能瓶颈,尤其是在高并发场景下。\n为了解决这一问题,ConcurrentHashMap 应运而生,作为 HashMap 的线程安全版本,它提供了更高效的并发处理能力。\n在 JDK1.7 的时候,ConcurrentHashMap 对整个桶数组进行了分割分段(Segment,分段锁),每一把锁只锁容器其中一部分数据(下面有示意图),多线程访问容器里不同数据段的数据,就不会存在锁竞争,提高并发访问率。\n到了 JDK1.8 的时候,ConcurrentHashMap 取消了 Segment 分段锁,采用 Node + CAS + synchronized 来保证并发安全。数据结构跟 HashMap 1.8 的结构类似,数组+链表/红黑二叉树。Java 8 在链表长度超过一定阈值(8)时将链表(寻址时间复杂度为 O(N))转换为红黑树(寻址时间复杂度为 O(log(N)))。\nJava 8 中,锁粒度更细,synchronized 只锁定当前链表或红黑二叉树的首节点,这样只要 hash 不冲突,就不会产生并发,就不会影响其他 Node 的读写,效率大幅提升。\n关于 ConcurrentHashMap 的详细介绍,请看我写的这篇文章: ConcurrentHashMap 源码分析。\nCopyOnWriteArrayList # 在 JDK1.5 之前,如果想要使用并发安全的 List 只能选择 Vector。而 Vector 是一种老旧的集合,已经被淘汰。Vector 对于增删改查等方法基本都加了 synchronized,这种方式虽然能够保证同步,但这相当于对整个 Vector 加上了一把大锁,使得每个方法执行的时候都要去获得锁,导致性能非常低下。\nJDK1.5 引入了 Java.util.concurrent(JUC)包,其中提供了很多线程安全且并发性能良好的容器,其中唯一的线程安全 List 实现就是 CopyOnWriteArrayList 。\n对于大部分业务场景来说,读取操作往往是远大于写入操作的。由于读取操作不会对原有数据进行修改,因此,对于每次读取都进行加锁其实是一种资源浪费。相比之下,我们应该允许多个线程同时访问 List 的内部数据,毕竟对于读取操作来说是安全的。\n这种思路与 ReentrantReadWriteLock 读写锁的设计思想非常类似,即读读不互斥、读写互斥、写写互斥(只有读读不互斥)。CopyOnWriteArrayList 更进一步地实现了这一思想。为了将读操作性能发挥到极致,CopyOnWriteArrayList 中的读取操作是完全无需加锁的。更加厉害的是,写入操作也不会阻塞读取操作,只有写写才会互斥。这样一来,读操作的性能就可以大幅度提升。\nCopyOnWriteArrayList 线程安全的核心在于其采用了 写时复制(Copy-On-Write) 的策略,从 CopyOnWriteArrayList 的名字就能看出了。\n当需要修改( add,set、remove 等操作) CopyOnWriteArrayList 的内容时,不会直接修改原数组,而是会先创建底层数组的副本,对副本数组进行修改,修改完之后再将修改后的数组赋值回去,这样就可以保证写操作不会影响读操作了。\n关于 CopyOnWriteArrayList 的详细介绍,请看我写的这篇文章: CopyOnWriteArrayList 源码分析。\nConcurrentLinkedQueue # Java 提供的线程安全的 Queue 可以分为阻塞队列和非阻塞队列,其中阻塞队列的典型例子是 BlockingQueue,非阻塞队列的典型例子是 ConcurrentLinkedQueue,在实际应用中要根据实际需要选用阻塞队列或者非阻塞队列。 阻塞队列可以通过加锁来实现,非阻塞队列可以通过 CAS 操作实现。\n从名字可以看出,ConcurrentLinkedQueue这个队列使用链表作为其数据结构.ConcurrentLinkedQueue 应该算是在高并发环境中性能最好的队列了。它之所有能有很好的性能,是因为其内部复杂的实现。\nConcurrentLinkedQueue 内部代码我们就不分析了,大家知道 ConcurrentLinkedQueue 主要使用 CAS 非阻塞算法来实现线程安全就好了。\nConcurrentLinkedQueue 适合在对性能要求相对较高,同时对队列的读写存在多个线程同时进行的场景,即如果对队列加锁的成本较高则适合使用无锁的 ConcurrentLinkedQueue 来替代。\nBlockingQueue # BlockingQueue 简介 # 上面我们己经提到了 ConcurrentLinkedQueue 作为高性能的非阻塞队列。下面我们要讲到的是阻塞队列——BlockingQueue。阻塞队列(BlockingQueue)被广泛使用在“生产者-消费者”问题中,其原因是 BlockingQueue 提供了可阻塞的插入和移除的方法。当队列容器已满,生产者线程会被阻塞,直到队列未满;当队列容器为空时,消费者线程会被阻塞,直至队列非空时为止。\nBlockingQueue 是一个接口,继承自 Queue,所以其实现类也可以作为 Queue 的实现来使用,而 Queue 又继承自 Collection 接口。下面是 BlockingQueue 的相关实现类:\n下面主要介绍一下 3 个常见的 BlockingQueue 的实现类:ArrayBlockingQueue、LinkedBlockingQueue、PriorityBlockingQueue 。\nArrayBlockingQueue # ArrayBlockingQueue 是 BlockingQueue 接口的有界队列实现类,底层采用数组来实现。\npublic class ArrayBlockingQueue\u0026lt;E\u0026gt; extends AbstractQueue\u0026lt;E\u0026gt; implements BlockingQueue\u0026lt;E\u0026gt;, Serializable{} ArrayBlockingQueue 一旦创建,容量不能改变。其并发控制采用可重入锁 ReentrantLock ,不管是插入操作还是读取操作,都需要获取到锁才能进行操作。当队列容量满时,尝试将元素放入队列将导致操作阻塞;尝试从一个空队列中取一个元素也会同样阻塞。\nArrayBlockingQueue 默认情况下不能保证线程访问队列的公平性,所谓公平性是指严格按照线程等待的绝对时间顺序,即最先等待的线程能够最先访问到 ArrayBlockingQueue。而非公平性则是指访问 ArrayBlockingQueue 的顺序不是遵守严格的时间顺序,有可能存在,当 ArrayBlockingQueue 可以被访问时,长时间阻塞的线程依然无法访问到 ArrayBlockingQueue。如果保证公平性,通常会降低吞吐量。如果需要获得公平性的 ArrayBlockingQueue,可采用如下代码:\nprivate static ArrayBlockingQueue\u0026lt;Integer\u0026gt; blockingQueue = new ArrayBlockingQueue\u0026lt;Integer\u0026gt;(10,true); LinkedBlockingQueue # LinkedBlockingQueue 底层基于单向链表实现的阻塞队列,可以当做无界队列也可以当做有界队列来使用,同样满足 FIFO 的特性,与 ArrayBlockingQueue 相比起来具有更高的吞吐量,为了防止 LinkedBlockingQueue 容量迅速增,损耗大量内存。通常在创建 LinkedBlockingQueue 对象时,会指定其大小,如果未指定,容量等于 Integer.MAX_VALUE 。\n相关构造方法:\n/** *某种意义上的无界队列 * Creates a {@code LinkedBlockingQueue} with a capacity of * {@link Integer#MAX_VALUE}. */ public LinkedBlockingQueue() { this(Integer.MAX_VALUE); } /** *有界队列 * Creates a {@code LinkedBlockingQueue} with the given (fixed) capacity. * * @param capacity the capacity of this queue * @throws IllegalArgumentException if {@code capacity} is not greater * than zero */ public LinkedBlockingQueue(int capacity) { if (capacity \u0026lt;= 0) throw new IllegalArgumentException(); this.capacity = capacity; last = head = new Node\u0026lt;E\u0026gt;(null); } PriorityBlockingQueue # PriorityBlockingQueue 是一个支持优先级的无界阻塞队列。默认情况下元素采用自然顺序进行排序,也可以通过自定义类实现 compareTo() 方法来指定元素排序规则,或者初始化时通过构造器参数 Comparator 来指定排序规则。\nPriorityBlockingQueue 并发控制采用的是可重入锁 ReentrantLock,队列为无界队列(ArrayBlockingQueue 是有界队列,LinkedBlockingQueue 也可以通过在构造函数中传入 capacity 指定队列最大的容量,但是 PriorityBlockingQueue 只能指定初始的队列大小,后面插入元素的时候,如果空间不够的话会自动扩容)。\n简单地说,它就是 PriorityQueue 的线程安全版本。不可以插入 null 值,同时,插入队列的对象必须是可比较大小的(comparable),否则报 ClassCastException 异常。它的插入操作 put 方法不会 block,因为它是无界队列(take 方法在队列为空的时候会阻塞)。\n推荐文章: 《解读 Java 并发队列 BlockingQueue》\nConcurrentSkipListMap # 下面这部分内容参考了极客时间专栏 《数据结构与算法之美》以及《实战 Java 高并发程序设计》。\n为了引出 ConcurrentSkipListMap,先带着大家简单理解一下跳表。\n对于一个单链表,即使链表是有序的,如果我们想要在其中查找某个数据,也只能从头到尾遍历链表,这样效率自然就会很低,跳表就不一样了。跳表是一种可以用来快速查找的数据结构,有点类似于平衡树。它们都可以对元素进行快速的查找。但一个重要的区别是:对平衡树的插入和删除往往很可能导致平衡树进行一次全局的调整。而对跳表的插入和删除只需要对整个数据结构的局部进行操作即可。这样带来的好处是:在高并发的情况下,你会需要一个全局锁来保证整个平衡树的线程安全。而对于跳表,你只需要部分锁即可。这样,在高并发环境下,你就可以拥有更好的性能。而就查询的性能而言,跳表的时间复杂度也是 O(logn) 所以在并发数据结构中,JDK 使用跳表来实现一个 Map。\n跳表的本质是同时维护了多个链表,并且链表是分层的,\n最低层的链表维护了跳表内所有的元素,每上面一层链表都是下面一层的子集。\n跳表内的所有链表的元素都是排序的。查找时,可以从顶级链表开始找。一旦发现被查找的元素大于当前链表中的取值,就会转入下一层链表继续找。这也就是说在查找过程中,搜索是跳跃式的。如上图所示,在跳表中查找元素 18。\n查找 18 的时候原来需要遍历 18 次,现在只需要 7 次即可。针对链表长度比较大的时候,构建索引查找效率的提升就会非常明显。\n从上面很容易看出,跳表是一种利用空间换时间的算法。\n使用跳表实现 Map 和使用哈希算法实现 Map 的另外一个不同之处是:哈希并不会保存元素的顺序,而跳表内所有的元素都是排序的。因此在对跳表进行遍历时,你会得到一个有序的结果。所以,如果你的应用需要有序性,那么跳表就是你不二的选择。JDK 中实现这一数据结构的类是 ConcurrentSkipListMap。\n参考 # 《实战 Java 高并发程序设计》 https://javadoop.com/post/java-concurrent-queue https://juejin.im/post/5aeebd02518825672f19c546 "},{"id":503,"href":"/zh/docs/technology/Interview/java/basis/proxy/","title":"Java 代理模式详解","section":"Basis","content":" 1. 代理模式 # 代理模式是一种比较好理解的设计模式。简单来说就是 我们使用代理对象来代替对真实对象(real object)的访问,这样就可以在不修改原目标对象的前提下,提供额外的功能操作,扩展目标对象的功能。\n代理模式的主要作用是扩展目标对象的功能,比如说在目标对象的某个方法执行前后你可以增加一些自定义的操作。\n举个例子:新娘找来了自己的姨妈来代替自己处理新郎的提问,新娘收到的提问都是经过姨妈处理过滤之后的。姨妈在这里就可以看作是代理你的代理对象,代理的行为(方法)是接收和回复新郎的提问。\nhttps://medium.com/@mithunsasidharan/understanding-the-proxy-design-pattern-5e63fe38052a\n代理模式有静态代理和动态代理两种实现方式,我们 先来看一下静态代理模式的实现。\n2. 静态代理 # 静态代理中,我们对目标对象的每个方法的增强都是手动完成的(后面会具体演示代码),非常不灵活(比如接口一旦新增加方法,目标对象和代理对象都要进行修改)且麻烦(需要对每个目标类都单独写一个代理类)。 实际应用场景非常非常少,日常开发几乎看不到使用静态代理的场景。\n上面我们是从实现和应用角度来说的静态代理,从 JVM 层面来说, 静态代理在编译时就将接口、实现类、代理类这些都变成了一个个实际的 class 文件。\n静态代理实现步骤:\n定义一个接口及其实现类; 创建一个代理类同样实现这个接口 将目标对象注入进代理类,然后在代理类的对应方法调用目标类中的对应方法。这样的话,我们就可以通过代理类屏蔽对目标对象的访问,并且可以在目标方法执行前后做一些自己想做的事情。 下面通过代码展示!\n1.定义发送短信的接口\npublic interface SmsService { String send(String message); } 2.实现发送短信的接口\npublic class SmsServiceImpl implements SmsService { public String send(String message) { System.out.println(\u0026#34;send message:\u0026#34; + message); return message; } } 3.创建代理类并同样实现发送短信的接口\npublic class SmsProxy implements SmsService { private final SmsService smsService; public SmsProxy(SmsService smsService) { this.smsService = smsService; } @Override public String send(String message) { //调用方法之前,我们可以添加自己的操作 System.out.println(\u0026#34;before method send()\u0026#34;); smsService.send(message); //调用方法之后,我们同样可以添加自己的操作 System.out.println(\u0026#34;after method send()\u0026#34;); return null; } } 4.实际使用\npublic class Main { public static void main(String[] args) { SmsService smsService = new SmsServiceImpl(); SmsProxy smsProxy = new SmsProxy(smsService); smsProxy.send(\u0026#34;java\u0026#34;); } } 运行上述代码之后,控制台打印出:\nbefore method send() send message:java after method send() 可以输出结果看出,我们已经增加了 SmsServiceImpl 的send()方法。\n3. 动态代理 # 相比于静态代理来说,动态代理更加灵活。我们不需要针对每个目标类都单独创建一个代理类,并且也不需要我们必须实现接口,我们可以直接代理实现类( CGLIB 动态代理机制)。\n从 JVM 角度来说,动态代理是在运行时动态生成类字节码,并加载到 JVM 中的。\n说到动态代理,Spring AOP、RPC 框架应该是两个不得不提的,它们的实现都依赖了动态代理。\n动态代理在我们日常开发中使用的相对较少,但是在框架中的几乎是必用的一门技术。学会了动态代理之后,对于我们理解和学习各种框架的原理也非常有帮助。\n就 Java 来说,动态代理的实现方式有很多种,比如 JDK 动态代理、CGLIB 动态代理等等。\nguide-rpc-framework 使用的是 JDK 动态代理,我们先来看看 JDK 动态代理的使用。\n另外,虽然 guide-rpc-framework 没有用到 CGLIB 动态代理 ,我们这里还是简单介绍一下其使用以及和JDK 动态代理的对比。\n3.1. JDK 动态代理机制 # 3.1.1. 介绍 # 在 Java 动态代理机制中 InvocationHandler 接口和 Proxy 类是核心。\nProxy 类中使用频率最高的方法是:newProxyInstance() ,这个方法主要用来生成一个代理对象。\npublic static Object newProxyInstance(ClassLoader loader, Class\u0026lt;?\u0026gt;[] interfaces, InvocationHandler h) throws IllegalArgumentException { ...... } 这个方法一共有 3 个参数:\nloader :类加载器,用于加载代理对象。 interfaces : 被代理类实现的一些接口; h : 实现了 InvocationHandler 接口的对象; 要实现动态代理的话,还必须需要实现InvocationHandler 来自定义处理逻辑。 当我们的动态代理对象调用一个方法时,这个方法的调用就会被转发到实现InvocationHandler 接口类的 invoke 方法来调用。\npublic interface InvocationHandler { /** * 当你使用代理对象调用方法的时候实际会调用到这个方法 */ public Object invoke(Object proxy, Method method, Object[] args) throws Throwable; } invoke() 方法有下面三个参数:\nproxy :动态生成的代理类 method : 与代理类对象调用的方法相对应 args : 当前 method 方法的参数 也就是说:你通过Proxy 类的 newProxyInstance() 创建的代理对象在调用方法的时候,实际会调用到实现InvocationHandler 接口的类的 invoke()方法。 你可以在 invoke() 方法中自定义处理逻辑,比如在方法执行前后做什么事情。\n3.1.2. JDK 动态代理类使用步骤 # 定义一个接口及其实现类; 自定义 InvocationHandler 并重写invoke方法,在 invoke 方法中我们会调用原生方法(被代理类的方法)并自定义一些处理逻辑; 通过 Proxy.newProxyInstance(ClassLoader loader,Class\u0026lt;?\u0026gt;[] interfaces,InvocationHandler h) 方法创建代理对象; 3.1.3. 代码示例 # 这样说可能会有点空洞和难以理解,我上个例子,大家感受一下吧!\n1.定义发送短信的接口\npublic interface SmsService { String send(String message); } 2.实现发送短信的接口\npublic class SmsServiceImpl implements SmsService { public String send(String message) { System.out.println(\u0026#34;send message:\u0026#34; + message); return message; } } 3.定义一个 JDK 动态代理类\nimport java.lang.reflect.InvocationHandler; import java.lang.reflect.InvocationTargetException; import java.lang.reflect.Method; /** * @author shuang.kou * @createTime 2020年05月11日 11:23:00 */ public class DebugInvocationHandler implements InvocationHandler { /** * 代理类中的真实对象 */ private final Object target; public DebugInvocationHandler(Object target) { this.target = target; } @Override public Object invoke(Object proxy, Method method, Object[] args) throws InvocationTargetException, IllegalAccessException { //调用方法之前,我们可以添加自己的操作 System.out.println(\u0026#34;before method \u0026#34; + method.getName()); Object result = method.invoke(target, args); //调用方法之后,我们同样可以添加自己的操作 System.out.println(\u0026#34;after method \u0026#34; + method.getName()); return result; } } invoke() 方法: 当我们的动态代理对象调用原生方法的时候,最终实际上调用到的是 invoke() 方法,然后 invoke() 方法代替我们去调用了被代理对象的原生方法。\n4.获取代理对象的工厂类\npublic class JdkProxyFactory { public static Object getProxy(Object target) { return Proxy.newProxyInstance( target.getClass().getClassLoader(), // 目标类的类加载器 target.getClass().getInterfaces(), // 代理需要实现的接口,可指定多个 new DebugInvocationHandler(target) // 代理对象对应的自定义 InvocationHandler ); } } getProxy():主要通过Proxy.newProxyInstance()方法获取某个类的代理对象\n5.实际使用\nSmsService smsService = (SmsService) JdkProxyFactory.getProxy(new SmsServiceImpl()); smsService.send(\u0026#34;java\u0026#34;); 运行上述代码之后,控制台打印出:\nbefore method send send message:java after method send 3.2. CGLIB 动态代理机制 # 3.2.1. 介绍 # JDK 动态代理有一个最致命的问题是其只能代理实现了接口的类。\n为了解决这个问题,我们可以用 CGLIB 动态代理机制来避免。\nCGLIB(Code Generation Library)是一个基于 ASM的字节码生成库,它允许我们在运行时对字节码进行修改和动态生成。CGLIB 通过继承方式实现代理。很多知名的开源框架都使用到了 CGLIB, 例如 Spring 中的 AOP 模块中:如果目标对象实现了接口,则默认采用 JDK 动态代理,否则采用 CGLIB 动态代理。\n在 CGLIB 动态代理机制中 MethodInterceptor 接口和 Enhancer 类是核心。\n你需要自定义 MethodInterceptor 并重写 intercept 方法,intercept 用于拦截增强被代理类的方法。\npublic interface MethodInterceptor extends Callback{ // 拦截被代理类中的方法 public Object intercept(Object obj, java.lang.reflect.Method method, Object[] args,MethodProxy proxy) throws Throwable; } obj : 被代理的对象(需要增强的对象) method : 被拦截的方法(需要增强的方法) args : 方法入参 proxy : 用于调用原始方法 你可以通过 Enhancer类来动态获取被代理类,当代理类调用方法的时候,实际调用的是 MethodInterceptor 中的 intercept 方法。\n3.2.2. CGLIB 动态代理类使用步骤 # 定义一个类; 自定义 MethodInterceptor 并重写 intercept 方法,intercept 用于拦截增强被代理类的方法,和 JDK 动态代理中的 invoke 方法类似; 通过 Enhancer 类的 create()创建代理类; 3.2.3. 代码示例 # 不同于 JDK 动态代理不需要额外的依赖。 CGLIB(Code Generation Library) 实际是属于一个开源项目,如果你要使用它的话,需要手动添加相关依赖。\n\u0026lt;dependency\u0026gt; \u0026lt;groupId\u0026gt;cglib\u0026lt;/groupId\u0026gt; \u0026lt;artifactId\u0026gt;cglib\u0026lt;/artifactId\u0026gt; \u0026lt;version\u0026gt;3.3.0\u0026lt;/version\u0026gt; \u0026lt;/dependency\u0026gt; 1.实现一个使用阿里云发送短信的类\npackage github.javaguide.dynamicProxy.cglibDynamicProxy; public class AliSmsService { public String send(String message) { System.out.println(\u0026#34;send message:\u0026#34; + message); return message; } } 2.自定义 MethodInterceptor(方法拦截器)\nimport net.sf.cglib.proxy.MethodInterceptor; import net.sf.cglib.proxy.MethodProxy; import java.lang.reflect.Method; /** * 自定义MethodInterceptor */ public class DebugMethodInterceptor implements MethodInterceptor { /** * @param o 被代理的对象(需要增强的对象) * @param method 被拦截的方法(需要增强的方法) * @param args 方法入参 * @param methodProxy 用于调用原始方法 */ @Override public Object intercept(Object o, Method method, Object[] args, MethodProxy methodProxy) throws Throwable { //调用方法之前,我们可以添加自己的操作 System.out.println(\u0026#34;before method \u0026#34; + method.getName()); Object object = methodProxy.invokeSuper(o, args); //调用方法之后,我们同样可以添加自己的操作 System.out.println(\u0026#34;after method \u0026#34; + method.getName()); return object; } } 3.获取代理类\nimport net.sf.cglib.proxy.Enhancer; public class CglibProxyFactory { public static Object getProxy(Class\u0026lt;?\u0026gt; clazz) { // 创建动态代理增强类 Enhancer enhancer = new Enhancer(); // 设置类加载器 enhancer.setClassLoader(clazz.getClassLoader()); // 设置被代理类 enhancer.setSuperclass(clazz); // 设置方法拦截器 enhancer.setCallback(new DebugMethodInterceptor()); // 创建代理类 return enhancer.create(); } } 4.实际使用\nAliSmsService aliSmsService = (AliSmsService) CglibProxyFactory.getProxy(AliSmsService.class); aliSmsService.send(\u0026#34;java\u0026#34;); 运行上述代码之后,控制台打印出:\nbefore method send send message:java after method send 3.3. JDK 动态代理和 CGLIB 动态代理对比 # JDK 动态代理只能代理实现了接口的类或者直接代理接口,而 CGLIB 可以代理未实现任何接口的类。 另外, CGLIB 动态代理是通过生成一个被代理类的子类来拦截被代理类的方法调用,因此不能代理声明为 final 类型的类和方法。 就二者的效率来说,大部分情况都是 JDK 动态代理更优秀,随着 JDK 版本的升级,这个优势更加明显。 4. 静态代理和动态代理的对比 # 灵活性:动态代理更加灵活,不需要必须实现接口,可以直接代理实现类,并且可以不需要针对每个目标类都创建一个代理类。另外,静态代理中,接口一旦新增加方法,目标对象和代理对象都要进行修改,这是非常麻烦的! JVM 层面:静态代理在编译时就将接口、实现类、代理类这些都变成了一个个实际的 class 文件。而动态代理是在运行时动态生成类字节码,并加载到 JVM 中的。 5. 总结 # 这篇文章中主要介绍了代理模式的两种实现:静态代理以及动态代理。涵盖了静态代理和动态代理实战、静态代理和动态代理的区别、JDK 动态代理和 Cglib 动态代理区别等内容。\n文中涉及到的所有源码,你可以在这里找到: https://github.com/Snailclimb/guide-rpc-framework-learning/tree/master/src/main/java/github/javaguide/proxy 。\n"},{"id":504,"href":"/zh/docs/technology/Interview/system-design/schedule-task/","title":"Java 定时任务详解","section":"System Design","content":" 为什么需要定时任务? # 我们来看一下几个非常常见的业务场景:\n某系统凌晨 1 点要进行数据备份。 某电商平台,用户下单半个小时未支付的情况下需要自动取消订单。 某媒体聚合平台,每 10 分钟动态抓取某某网站的数据为自己所用。 某博客平台,支持定时发送文章。 某基金平台,每晚定时计算用户当日收益情况并推送给用户最新的数据。 …… 这些场景往往都要求我们在某个特定的时间去做某个事情,也就是定时或者延时去做某个事情。\n定时任务:在指定时间点执行特定的任务,例如每天早上 8 点,每周一下午 3 点等。定时任务可以用来做一些周期性的工作,如数据备份,日志清理,报表生成等。 延时任务:一定的延迟时间后执行特定的任务,例如 10 分钟后,3 小时后等。延时任务可以用来做一些异步的工作,如订单取消,推送通知,红包撤回等。 尽管二者的适用场景有所区别,但它们的核心思想都是将任务的执行时间安排在未来的某个点上,以达到预期的调度效果。\n单机定时任务 # Timer # java.util.Timer是 JDK 1.3 开始就已经支持的一种定时任务的实现方式。\nTimer 内部使用一个叫做 TaskQueue 的类存放定时任务,它是一个基于最小堆实现的优先级队列。TaskQueue 会按照任务距离下一次执行时间的大小将任务排序,保证在堆顶的任务最先执行。这样在需要执行任务时,每次只需要取出堆顶的任务运行即可!\nTimer 使用起来比较简单,通过下面的方式我们就能创建一个 1s 之后执行的定时任务。\n// 示例代码: TimerTask task = new TimerTask() { public void run() { System.out.println(\u0026#34;当前时间: \u0026#34; + new Date() + \u0026#34;n\u0026#34; + \u0026#34;线程名称: \u0026#34; + Thread.currentThread().getName()); } }; System.out.println(\u0026#34;当前时间: \u0026#34; + new Date() + \u0026#34;n\u0026#34; + \u0026#34;线程名称: \u0026#34; + Thread.currentThread().getName()); Timer timer = new Timer(\u0026#34;Timer\u0026#34;); long delay = 1000L; timer.schedule(task, delay); //输出: 当前时间: Fri May 28 15:18:47 CST 2021n线程名称: main 当前时间: Fri May 28 15:18:48 CST 2021n线程名称: Timer 不过其缺陷较多,比如一个 Timer 一个线程,这就导致 Timer 的任务的执行只能串行执行,一个任务执行时间过长的话会影响其他任务(性能非常差),再比如发生异常时任务直接停止(Timer 只捕获了 InterruptedException )。\nTimer 类上的有一段注释是这样写的:\n* This class does not offer real-time guarantees: it schedules * tasks using the \u0026lt;tt\u0026gt;Object.wait(long)\u0026lt;/tt\u0026gt; method. *Java 5.0 introduced the {@code java.util.concurrent} package and * one of the concurrency utilities therein is the {@link * java.util.concurrent.ScheduledThreadPoolExecutor * ScheduledThreadPoolExecutor} which is a thread pool for repeatedly * executing tasks at a given rate or delay. It is effectively a more * versatile replacement for the {@code Timer}/{@code TimerTask} * combination, as it allows multiple service threads, accepts various * time units, and doesn\u0026#39;t require subclassing {@code TimerTask} (just * implement {@code Runnable}). Configuring {@code * ScheduledThreadPoolExecutor} with one thread makes it equivalent to * {@code Timer}. 大概的意思就是:ScheduledThreadPoolExecutor 支持多线程执行定时任务并且功能更强大,是 Timer 的替代品。\nScheduledExecutorService # ScheduledExecutorService 是一个接口,有多个实现类,比较常用的是 ScheduledThreadPoolExecutor 。\nScheduledThreadPoolExecutor 本身就是一个线程池,支持任务并发执行。并且,其内部使用 DelayedWorkQueue 作为任务队列。\n// 示例代码: TimerTask repeatedTask = new TimerTask() { @SneakyThrows public void run() { System.out.println(\u0026#34;当前时间: \u0026#34; + new Date() + \u0026#34;n\u0026#34; + \u0026#34;线程名称: \u0026#34; + Thread.currentThread().getName()); } }; System.out.println(\u0026#34;当前时间: \u0026#34; + new Date() + \u0026#34;n\u0026#34; + \u0026#34;线程名称: \u0026#34; + Thread.currentThread().getName()); ScheduledExecutorService executor = Executors.newScheduledThreadPool(3); long delay = 1000L; long period = 1000L; executor.scheduleAtFixedRate(repeatedTask, delay, period, TimeUnit.MILLISECONDS); Thread.sleep(delay + period * 5); executor.shutdown(); //输出: 当前时间: Fri May 28 15:40:46 CST 2021n线程名称: main 当前时间: Fri May 28 15:40:47 CST 2021n线程名称: pool-1-thread-1 当前时间: Fri May 28 15:40:48 CST 2021n线程名称: pool-1-thread-1 当前时间: Fri May 28 15:40:49 CST 2021n线程名称: pool-1-thread-2 当前时间: Fri May 28 15:40:50 CST 2021n线程名称: pool-1-thread-2 当前时间: Fri May 28 15:40:51 CST 2021n线程名称: pool-1-thread-2 当前时间: Fri May 28 15:40:52 CST 2021n线程名称: pool-1-thread-2 不论是使用 Timer 还是 ScheduledExecutorService 都无法使用 Cron 表达式指定任务执行的具体时间。\nDelayQueue # DelayQueue 是 JUC 包(java.util.concurrent)为我们提供的延迟队列,用于实现延时任务比如订单下单 15 分钟未支付直接取消。它是 BlockingQueue 的一种,底层是一个基于 PriorityQueue 实现的一个无界队列,是线程安全的。关于PriorityQueue可以参考笔者编写的这篇文章: PriorityQueue 源码分析 。\nDelayQueue 和 Timer/TimerTask 都可以用于实现定时任务调度,但是它们的实现方式不同。DelayQueue 是基于优先级队列和堆排序算法实现的,可以实现多个任务按照时间先后顺序执行;而 Timer/TimerTask 是基于单线程实现的,只能按照任务的执行顺序依次执行,如果某个任务执行时间过长,会影响其他任务的执行。另外,DelayQueue 还支持动态添加和移除任务,而 Timer/TimerTask 只能在创建时指定任务。\n关于 DelayQueue 的详细介绍,请参考我写的这篇文章: DelayQueue 源码分析。\nSpring Task # 我们直接通过 Spring 提供的 @Scheduled 注解即可定义定时任务,非常方便!\n/** * cron:使用Cron表达式。 每分钟的1,2秒运行 */ @Scheduled(cron = \u0026#34;1-2 * * * * ? \u0026#34;) public void reportCurrentTimeWithCronExpression() { log.info(\u0026#34;Cron Expression: The time is now {}\u0026#34;, dateFormat.format(new Date())); } 我在大学那会做的一个 SSM 的企业级项目,就是用的 Spring Task 来做的定时任务。\n并且,Spring Task 还是支持 Cron 表达式 的。Cron 表达式主要用于定时作业(定时任务)系统定义执行时间或执行频率的表达式,非常厉害,你可以通过 Cron 表达式进行设置定时任务每天或者每个月什么时候执行等等操作。咱们要学习定时任务的话,Cron 表达式是一定是要重点关注的。推荐一个在线 Cron 表达式生成器: http://cron.qqe2.com/ 。\n但是,Spring 自带的定时调度只支持单机,并且提供的功能比较单一。之前写过一篇文章: 《5 分钟搞懂如何在 Spring Boot 中 Schedule Tasks》 ,不了解的小伙伴可以参考一下。\nSpring Task 底层是基于 JDK 的 ScheduledThreadPoolExecutor 线程池来实现的。\n优缺点总结:\n优点:简单,轻量,支持 Cron 表达式 缺点:功能单一 时间轮 # Kafka、Dubbo、ZooKeeper、Netty、Caffeine、Akka 中都有对时间轮的实现。\n时间轮简单来说就是一个环形的队列(底层一般基于数组实现),队列中的每一个元素(时间格)都可以存放一个定时任务列表。\n时间轮中的每个时间格代表了时间轮的基本时间跨度或者说时间精度,假如时间一秒走一个时间格的话,那么这个时间轮的最高精度就是 1 秒(也就是说 3 s 和 3.9s 会在同一个时间格中)。\n下图是一个有 12 个时间格的时间轮,转完一圈需要 12 s。当我们需要新建一个 3s 后执行的定时任务,只需要将定时任务放在下标为 3 的时间格中即可。当我们需要新建一个 9s 后执行的定时任务,只需要将定时任务放在下标为 9 的时间格中即可。\n那当我们需要创建一个 13s 后执行的定时任务怎么办呢?这个时候可以引入一叫做 圈数/轮数 的概念,也就是说这个任务还是放在下标为 1 的时间格中, 不过它的圈数为 2 。\n除了增加圈数这种方法之外,还有一种 多层次时间轮 (类似手表),Kafka 采用的就是这种方案。\n针对下图的时间轮,我来举一个例子便于大家理解。\n上图的时间轮(ms -\u0026gt; s),第 1 层的时间精度为 1 ,第 2 层的时间精度为 20 ,第 3 层的时间精度为 400。假如我们需要添加一个 350s 后执行的任务 A 的话(当前时间是 0s),这个任务会被放在第 2 层(因为第二层的时间跨度为 20*20=400\u0026gt;350)的第 350/20=17 个时间格子。\n当第一层转了 17 圈之后,时间过去了 340s ,第 2 层的指针此时来到第 17 个时间格子。此时,第 2 层第 17 个格子的任务会被移动到第 1 层。\n任务 A 当前是 10s 之后执行,因此它会被移动到第 1 层的第 10 个时间格子。\n这里在层与层之间的移动也叫做时间轮的升降级。参考手表来理解就好!\n时间轮比较适合任务数量比较多的定时任务场景,它的任务写入和执行的时间复杂度都是 0(1)。\n分布式定时任务 # Redis # Redis 是可以用来做延时任务的,基于 Redis 实现延时任务的功能无非就下面两种方案:\nRedis 过期事件监听 Redisson 内置的延时队列 这部分内容的详细介绍我放在了 《后端面试高频系统设计\u0026amp;场景题》中,有需要的同学可以进入星球后阅读学习。篇幅太多,这里就不重复分享了。\nMQ # 大部分消息队列,例如 RocketMQ、RabbitMQ,都支持定时/延时消息。定时消息和延时消息本质其实是相同的,都是服务端根据消息设置的定时时间在某一固定时刻将消息投递给消费者消费。\n不过,在使用 MQ 定时消息之前一定要看清楚其使用限制,以免不适合项目需求,例如 RocketMQ 定时时长最大值默认为 24 小时且不支持自定义修改、只支持 18 个 Level 的延时并不支持任意时间。\n优缺点总结:\n优点:可以与 Spring 集成、支持分布式、支持集群、性能不错 缺点:功能性较差、不灵活、需要保障消息可靠性 分布式任务调度框架 # 如果我们需要一些高级特性比如支持任务在分布式场景下的分片和高可用的话,我们就需要用到分布式任务调度框架了。\n通常情况下,一个分布式定时任务的执行往往涉及到下面这些角色:\n任务:首先肯定是要执行的任务,这个任务就是具体的业务逻辑比如定时发送文章。 调度器:其次是调度中心,调度中心主要负责任务管理,会分配任务给执行器。 执行器:最后就是执行器,执行器接收调度器分派的任务并执行。 Quartz # 一个很火的开源任务调度框架,完全由 Java 写成。Quartz 可以说是 Java 定时任务领域的老大哥或者说参考标准,其他的任务调度框架基本都是基于 Quartz 开发的,比如当当网的elastic-job就是基于 Quartz 二次开发之后的分布式调度解决方案。\n使用 Quartz 可以很方便地与 Spring 集成,并且支持动态添加任务和集群。但是,Quartz 使用起来也比较麻烦,API 繁琐。\n并且,Quartz 并没有内置 UI 管理控制台,不过你可以使用 quartzui 这个开源项目来解决这个问题。\n另外,Quartz 虽然也支持分布式任务。但是,它是在数据库层面,通过数据库的锁机制做的,有非常多的弊端比如系统侵入性严重、节点负载不均衡。有点伪分布式的味道。\n优缺点总结:\n优点:可以与 Spring 集成,并且支持动态添加任务和集群。 缺点:分布式支持不友好,不支持任务可视化管理、使用麻烦(相比于其他同类型框架来说) Elastic-Job # ElasticJob 当当网开源的一个面向互联网生态和海量任务的分布式调度解决方案,由两个相互独立的子项目 ElasticJob-Lite 和 ElasticJob-Cloud 组成。\nElasticJob-Lite 和 ElasticJob-Cloud 两者的对比如下:\nElasticJob-Lite ElasticJob-Cloud 无中心化 是 否 资源分配 不支持 支持 作业模式 常驻 常驻 + 瞬时 部署依赖 ZooKeeper ZooKeeper + Mesos ElasticJob 支持任务在分布式场景下的分片和高可用、任务可视化管理等功能。\nElasticJob-Lite 的架构设计如下图所示:\n从上图可以看出,Elastic-Job 没有调度中心这一概念,而是使用 ZooKeeper 作为注册中心,注册中心负责协调分配任务到不同的节点上。\nElastic-Job 中的定时调度都是由执行器自行触发,这种设计也被称为去中心化设计(调度和处理都是执行器单独完成)。\n@Component @ElasticJobConf(name = \u0026#34;dayJob\u0026#34;, cron = \u0026#34;0/10 * * * * ?\u0026#34;, shardingTotalCount = 2, shardingItemParameters = \u0026#34;0=AAAA,1=BBBB\u0026#34;, description = \u0026#34;简单任务\u0026#34;, failover = true) public class TestJob implements SimpleJob { @Override public void execute(ShardingContext shardingContext) { log.info(\u0026#34;TestJob任务名:【{}】, 片数:【{}】, param=【{}】\u0026#34;, shardingContext.getJobName(), shardingContext.getShardingTotalCount(), shardingContext.getShardingParameter()); } } 相关地址:\nGitHub 地址: https://github.com/apache/shardingsphere-elasticjob。 官方网站: https://shardingsphere.apache.org/elasticjob/index_zh.html 。 优缺点总结:\n优点:可以与 Spring 集成、支持分布式、支持集群、性能不错、支持任务可视化管理 缺点:依赖了额外的中间件比如 Zookeeper(复杂度增加,可靠性降低、维护成本变高) XXL-JOB # XXL-JOB 于 2015 年开源,是一款优秀的轻量级分布式任务调度框架,支持任务可视化管理、弹性扩容缩容、任务失败重试和告警、任务分片等功能,\n根据 XXL-JOB 官网介绍,其解决了很多 Quartz 的不足。\nQuartz 作为开源作业调度中的佼佼者,是作业调度的首选。但是集群环境中 Quartz 采用 API 的方式对任务进行管理,从而可以避免上述问题,但是同样存在以下问题:\n问题一:调用 API 的的方式操作任务,不人性化; 问题二:需要持久化业务 QuartzJobBean 到底层数据表中,系统侵入性相当严重。 问题三:调度逻辑和 QuartzJobBean 耦合在同一个项目中,这将导致一个问题,在调度任务数量逐渐增多,同时调度任务逻辑逐渐加重的情况下,此时调度系统的性能将大大受限于业务; 问题四:quartz 底层以“抢占式”获取 DB 锁并由抢占成功节点负责运行任务,会导致节点负载悬殊非常大;而 XXL-JOB 通过执行器实现“协同分配式”运行任务,充分发挥集群优势,负载各节点均衡。 XXL-JOB 弥补了 quartz 的上述不足之处。\nXXL-JOB 的架构设计如下图所示:\n从上图可以看出,XXL-JOB 由 调度中心 和 执行器 两大部分组成。调度中心主要负责任务管理、执行器管理以及日志管理。执行器主要是接收调度信号并处理。另外,调度中心进行任务调度时,是通过自研 RPC 来实现的。\n不同于 Elastic-Job 的去中心化设计, XXL-JOB 的这种设计也被称为中心化设计(调度中心调度多个执行器执行任务)。\n和 Quzrtz 类似 XXL-JOB 也是基于数据库锁调度任务,存在性能瓶颈。不过,一般在任务量不是特别大的情况下,没有什么影响的,可以满足绝大部分公司的要求。\n不要被 XXL-JOB 的架构图给吓着了,实际上,我们要用 XXL-JOB 的话,只需要重写 IJobHandler 自定义任务执行逻辑就可以了,非常易用!\n@JobHandler(value=\u0026#34;myApiJobHandler\u0026#34;) @Component public class MyApiJobHandler extends IJobHandler { @Override public ReturnT\u0026lt;String\u0026gt; execute(String param) throws Exception { //...... return ReturnT.SUCCESS; } } 还可以直接基于注解定义任务。\n@XxlJob(\u0026#34;myAnnotationJobHandler\u0026#34;) public ReturnT\u0026lt;String\u0026gt; myAnnotationJobHandler(String param) throws Exception { //...... return ReturnT.SUCCESS; } 相关地址:\nGitHub 地址: https://github.com/xuxueli/xxl-job/。 官方介绍: https://www.xuxueli.com/xxl-job/ 。 优缺点总结:\n优点:开箱即用(学习成本比较低)、与 Spring 集成、支持分布式、支持集群、支持任务可视化管理。 缺点:不支持动态添加任务(如果一定想要动态创建任务也是支持的,参见: xxl-job issue277)。 PowerJob # 非常值得关注的一个分布式任务调度框架,分布式任务调度领域的新星。目前,已经有很多公司接入比如 OPPO、京东、中通、思科。\n这个框架的诞生也挺有意思的,PowerJob 的作者当时在阿里巴巴实习过,阿里巴巴那会使用的是内部自研的 SchedulerX(阿里云付费产品)。实习期满之后,PowerJob 的作者离开了阿里巴巴。想着说自研一个 SchedulerX,防止哪天 SchedulerX 满足不了需求,于是 PowerJob 就诞生了。\n更多关于 PowerJob 的故事,小伙伴们可以去看看 PowerJob 作者的视频 《我和我的任务调度中间件》。简单点概括就是:“游戏没啥意思了,我要扛起了新一代分布式任务调度与计算框架的大旗!”。\n由于 SchedulerX 属于人民币产品,我这里就不过多介绍。PowerJob 官方也对比过其和 QuartZ、XXL-JOB 以及 SchedulerX。\nQuartZ xxl-job SchedulerX 2.0 PowerJob 定时类型 CRON CRON CRON、固定频率、固定延迟、OpenAPI CRON、固定频率、固定延迟、OpenAPI 任务类型 内置 Java 内置 Java、GLUE Java、Shell、Python 等脚本 内置 Java、外置 Java(FatJar)、Shell、Python 等脚本 内置 Java、外置 Java(容器)、Shell、Python 等脚本 分布式计算 无 静态分片 MapReduce 动态分片 MapReduce 动态分片 在线任务治理 不支持 支持 支持 支持 日志白屏化 不支持 支持 不支持 支持 调度方式及性能 基于数据库锁,有性能瓶颈 基于数据库锁,有性能瓶颈 不详 无锁化设计,性能强劲无上限 报警监控 无 邮件 短信 WebHook、邮件、钉钉与自定义扩展 系统依赖 JDBC 支持的关系型数据库(MySQL、Oracle\u0026hellip;) MySQL 人民币 任意 Spring Data Jpa 支持的关系型数据库(MySQL、Oracle\u0026hellip;) DAG 工作流 不支持 不支持 支持 支持 定时任务方案总结 # 单机定时任务的常见解决方案有 Timer、ScheduledExecutorService、DelayQueue、Spring Task 和时间轮,其中最常用也是比较推荐使用的是时间轮。另外,这几种单机定时任务解决方案同样可以实现延时任务。\nRedis 和 MQ 虽然可以实现分布式定时任务,但这两者本身不是专门用来做分布式定时任务的,它们并不提供较为完整和强大的分布式定时任务的功能。而且,两者不太适合执行周期性的定时任务,因为它们只能保证消息被消费一次,而不能保证消息被消费多次。因此,它们更适合执行一次性的延时任务,例如订单取消、红包撤回。实际项目中,MQ 延时任务用的更多一些,可以降低业务之间的耦合度。\nQuartz、Elastic-Job、XXL-JOB 和 PowerJob 这几个是专门用来做分布式调度的框架,提供的分布式定时任务的功能更为完善和强大,更加适合执行周期性的定时任务。除了 Quartz 之外,另外三者都是支持任务可视化管理的。\nXXL-JOB 2015 年推出,已经经过了很多年的考验。XXL-JOB 轻量级,并且使用起来非常简单。虽然存在性能瓶颈,但是,在绝大多数情况下,对于企业的基本需求来说是没有影响的。PowerJob 属于分布式任务调度领域里的新星,其稳定性还有待继续考察。ElasticJob 由于在架构设计上是基于 Zookeeper ,而 XXL-JOB 是基于数据库,性能方面的话,ElasticJob 略胜一筹。\n这篇文章并没有介绍到实际使用,但是,并不代表实际使用不重要。我在写这篇文章之前,已经动手写过相应的 Demo。像 Quartz,我在大学那会就用过。不过,当时用的是 Spring 。为了能够更好地体验,我自己又在 Spring Boot 上实际体验了一下。如果你并没有实际使用某个框架,就直接说它并不好用的话,是站不住脚的。\n"},{"id":505,"href":"/zh/docs/technology/Interview/java/basis/reflection/","title":"Java 反射机制详解","section":"Basis","content":" 何为反射? # 如果说大家研究过框架的底层原理或者咱们自己写过框架的话,一定对反射这个概念不陌生。\n反射之所以被称为框架的灵魂,主要是因为它赋予了我们在运行时分析类以及执行类中方法的能力。\n通过反射你可以获取任意一个类的所有属性和方法,你还可以调用这些方法和属性。\n反射的应用场景了解么? # 像咱们平时大部分时候都是在写业务代码,很少会接触到直接使用反射机制的场景。\n但是,这并不代表反射没有用。相反,正是因为反射,你才能这么轻松地使用各种框架。像 Spring/Spring Boot、MyBatis 等等框架中都大量使用了反射机制。\n这些框架中也大量使用了动态代理,而动态代理的实现也依赖反射。\n比如下面是通过 JDK 实现动态代理的示例代码,其中就使用了反射类 Method 来调用指定的方法。\npublic class DebugInvocationHandler implements InvocationHandler { /** * 代理类中的真实对象 */ private final Object target; public DebugInvocationHandler(Object target) { this.target = target; } public Object invoke(Object proxy, Method method, Object[] args) throws InvocationTargetException, IllegalAccessException { System.out.println(\u0026#34;before method \u0026#34; + method.getName()); Object result = method.invoke(target, args); System.out.println(\u0026#34;after method \u0026#34; + method.getName()); return result; } } 另外,像 Java 中的一大利器 注解 的实现也用到了反射。\n为什么你使用 Spring 的时候 ,一个@Component注解就声明了一个类为 Spring Bean 呢?为什么你通过一个 @Value注解就读取到配置文件中的值呢?究竟是怎么起作用的呢?\n这些都是因为你可以基于反射分析类,然后获取到类/属性/方法/方法的参数上的注解。你获取到注解之后,就可以做进一步的处理。\n谈谈反射机制的优缺点 # 优点:可以让咱们的代码更加灵活、为各种框架提供开箱即用的功能提供了便利\n缺点:让我们在运行时有了分析操作类的能力,这同样也增加了安全问题。比如可以无视泛型参数的安全检查(泛型参数的安全检查发生在编译时)。另外,反射的性能也要稍差点,不过,对于框架来说实际是影响不大的。相关阅读: Java Reflection: Why is it so slow?\n反射实战 # 获取 Class 对象的四种方式 # 如果我们动态获取到这些信息,我们需要依靠 Class 对象。Class 类对象将一个类的方法、变量等信息告诉运行的程序。Java 提供了四种方式获取 Class 对象:\n1. 知道具体类的情况下可以使用:\nClass alunbarClass = TargetObject.class; 但是我们一般是不知道具体类的,基本都是通过遍历包下面的类来获取 Class 对象,通过此方式获取 Class 对象不会进行初始化\n2. 通过 Class.forName()传入类的全路径获取:\nClass alunbarClass1 = Class.forName(\u0026#34;cn.javaguide.TargetObject\u0026#34;); 3. 通过对象实例instance.getClass()获取:\nTargetObject o = new TargetObject(); Class alunbarClass2 = o.getClass(); 4. 通过类加载器xxxClassLoader.loadClass()传入类路径获取:\nClassLoader.getSystemClassLoader().loadClass(\u0026#34;cn.javaguide.TargetObject\u0026#34;); 通过类加载器获取 Class 对象不会进行初始化,意味着不进行包括初始化等一系列步骤,静态代码块和静态对象不会得到执行\n反射的一些基本操作 # 创建一个我们要使用反射操作的类 TargetObject。 package cn.javaguide; public class TargetObject { private String value; public TargetObject() { value = \u0026#34;JavaGuide\u0026#34;; } public void publicMethod(String s) { System.out.println(\u0026#34;I love \u0026#34; + s); } private void privateMethod() { System.out.println(\u0026#34;value is \u0026#34; + value); } } 使用反射操作这个类的方法以及属性 package cn.javaguide; import java.lang.reflect.Field; import java.lang.reflect.InvocationTargetException; import java.lang.reflect.Method; public class Main { public static void main(String[] args) throws ClassNotFoundException, NoSuchMethodException, IllegalAccessException, InstantiationException, InvocationTargetException, NoSuchFieldException { /** * 获取 TargetObject 类的 Class 对象并且创建 TargetObject 类实例 */ Class\u0026lt;?\u0026gt; targetClass = Class.forName(\u0026#34;cn.javaguide.TargetObject\u0026#34;); TargetObject targetObject = (TargetObject) targetClass.newInstance(); /** * 获取 TargetObject 类中定义的所有方法 */ Method[] methods = targetClass.getDeclaredMethods(); for (Method method : methods) { System.out.println(method.getName()); } /** * 获取指定方法并调用 */ Method publicMethod = targetClass.getDeclaredMethod(\u0026#34;publicMethod\u0026#34;, String.class); publicMethod.invoke(targetObject, \u0026#34;JavaGuide\u0026#34;); /** * 获取指定参数并对参数进行修改 */ Field field = targetClass.getDeclaredField(\u0026#34;value\u0026#34;); //为了对类中的参数进行修改我们取消安全检查 field.setAccessible(true); field.set(targetObject, \u0026#34;JavaGuide\u0026#34;); /** * 调用 private 方法 */ Method privateMethod = targetClass.getDeclaredMethod(\u0026#34;privateMethod\u0026#34;); //为了调用private方法我们取消安全检查 privateMethod.setAccessible(true); privateMethod.invoke(targetObject); } } 输出内容:\npublicMethod privateMethod I love JavaGuide value is JavaGuide 注意 : 有读者提到上面代码运行会抛出 ClassNotFoundException 异常,具体原因是你没有下面把这段代码的包名替换成自己创建的 TargetObject 所在的包 。 可以参考: https://www.cnblogs.com/chanshuyi/p/head_first_of_reflection.html 这篇文章。\nClass\u0026lt;?\u0026gt; targetClass = Class.forName(\u0026#34;cn.javaguide.TargetObject\u0026#34;); "},{"id":506,"href":"/zh/docs/technology/Interview/java/basis/unsafe/","title":"Java 魔法类 Unsafe 详解","section":"Basis","content":" 本文整理完善自下面这两篇优秀的文章:\nJava 魔法类:Unsafe 应用解析 - 美团技术团队 -2019 Java 双刃剑之 Unsafe 类详解 - 码农参上 - 2021 阅读过 JUC 源码的同学,一定会发现很多并发工具类都调用了一个叫做 Unsafe 的类。\n那这个类主要是用来干什么的呢?有什么使用场景呢?这篇文章就带你搞清楚!\nUnsafe 介绍 # Unsafe 是位于 sun.misc 包下的一个类,主要提供一些用于执行低级别、不安全操作的方法,如直接访问系统内存资源、自主管理内存资源等,这些方法在提升 Java 运行效率、增强 Java 语言底层资源操作能力方面起到了很大的作用。但由于 Unsafe 类使 Java 语言拥有了类似 C 语言指针一样操作内存空间的能力,这无疑也增加了程序发生相关指针问题的风险。在程序中过度、不正确使用 Unsafe 类会使得程序出错的概率变大,使得 Java 这种安全的语言变得不再“安全”,因此对 Unsafe 的使用一定要慎重。\n另外,Unsafe 提供的这些功能的实现需要依赖本地方法(Native Method)。你可以将本地方法看作是 Java 中使用其他编程语言编写的方法。本地方法使用 native 关键字修饰,Java 代码中只是声明方法头,具体的实现则交给 本地代码。\n为什么要使用本地方法呢?\n需要用到 Java 中不具备的依赖于操作系统的特性,Java 在实现跨平台的同时要实现对底层的控制,需要借助其他语言发挥作用。 对于其他语言已经完成的一些现成功能,可以使用 Java 直接调用。 程序对时间敏感或对性能要求非常高时,有必要使用更加底层的语言,例如 C/C++甚至是汇编。 在 JUC 包的很多并发工具类在实现并发机制时,都调用了本地方法,通过它们打破了 Java 运行时的界限,能够接触到操作系统底层的某些功能。对于同一本地方法,不同的操作系统可能会通过不同的方式来实现,但是对于使用者来说是透明的,最终都会得到相同的结果。\nUnsafe 创建 # sun.misc.Unsafe 部分源码如下:\npublic final class Unsafe { // 单例对象 private static final Unsafe theUnsafe; ...... private Unsafe() { } @CallerSensitive public static Unsafe getUnsafe() { Class var0 = Reflection.getCallerClass(); // 仅在引导类加载器`BootstrapClassLoader`加载时才合法 if(!VM.isSystemDomainLoader(var0.getClassLoader())) { throw new SecurityException(\u0026#34;Unsafe\u0026#34;); } else { return theUnsafe; } } } Unsafe 类为一单例实现,提供静态方法 getUnsafe 获取 Unsafe实例。这个看上去貌似可以用来获取 Unsafe 实例。但是,当我们直接调用这个静态方法的时候,会抛出 SecurityException 异常:\nException in thread \u0026#34;main\u0026#34; java.lang.SecurityException: Unsafe at sun.misc.Unsafe.getUnsafe(Unsafe.java:90) at com.cn.test.GetUnsafeTest.main(GetUnsafeTest.java:12) 为什么 public static 方法无法被直接调用呢?\n这是因为在getUnsafe方法中,会对调用者的classLoader进行检查,判断当前类是否由Bootstrap classLoader加载,如果不是的话那么就会抛出一个SecurityException异常。也就是说,只有启动类加载器加载的类才能够调用 Unsafe 类中的方法,来防止这些方法在不可信的代码中被调用。\n为什么要对 Unsafe 类进行这么谨慎的使用限制呢?\nUnsafe 提供的功能过于底层(如直接访问系统内存资源、自主管理内存资源等),安全隐患也比较大,使用不当的话,很容易出现很严重的问题。\n如若想使用 Unsafe 这个类的话,应该如何获取其实例呢?\n这里介绍两个可行的方案。\n1、利用反射获得 Unsafe 类中已经实例化完成的单例对象 theUnsafe 。\nprivate static Unsafe reflectGetUnsafe() { try { Field field = Unsafe.class.getDeclaredField(\u0026#34;theUnsafe\u0026#34;); field.setAccessible(true); return (Unsafe) field.get(null); } catch (Exception e) { log.error(e.getMessage(), e); return null; } } 2、从getUnsafe方法的使用限制条件出发,通过 Java 命令行命令-Xbootclasspath/a把调用 Unsafe 相关方法的类 A 所在 jar 包路径追加到默认的 bootstrap 路径中,使得 A 被引导类加载器加载,从而通过Unsafe.getUnsafe方法安全的获取 Unsafe 实例。\njava -Xbootclasspath/a: ${path} // 其中path为调用Unsafe相关方法的类所在jar包路径 Unsafe 功能 # 概括的来说,Unsafe 类实现功能可以被分为下面 8 类:\n内存操作 内存屏障 对象操作 数据操作 CAS 操作 线程调度 Class 操作 系统信息 内存操作 # 介绍 # 如果你是一个写过 C 或者 C++ 的程序员,一定对内存操作不会陌生,而在 Java 中是不允许直接对内存进行操作的,对象内存的分配和回收都是由 JVM 自己实现的。但是在 Unsafe 中,提供的下列接口可以直接进行内存操作:\n//分配新的本地空间 public native long allocateMemory(long bytes); //重新调整内存空间的大小 public native long reallocateMemory(long address, long bytes); //将内存设置为指定值 public native void setMemory(Object o, long offset, long bytes, byte value); //内存拷贝 public native void copyMemory(Object srcBase, long srcOffset,Object destBase, long destOffset,long bytes); //清除内存 public native void freeMemory(long address); 使用下面的代码进行测试:\nprivate void memoryTest() { int size = 4; long addr = unsafe.allocateMemory(size); long addr3 = unsafe.reallocateMemory(addr, size * 2); System.out.println(\u0026#34;addr: \u0026#34;+addr); System.out.println(\u0026#34;addr3: \u0026#34;+addr3); try { unsafe.setMemory(null,addr ,size,(byte)1); for (int i = 0; i \u0026lt; 2; i++) { unsafe.copyMemory(null,addr,null,addr3+size*i,4); } System.out.println(unsafe.getInt(addr)); System.out.println(unsafe.getLong(addr3)); }finally { unsafe.freeMemory(addr); unsafe.freeMemory(addr3); } } 先看结果输出:\naddr: 2433733895744 addr3: 2433733894944 16843009 72340172838076673 分析一下运行结果,首先使用allocateMemory方法申请 4 字节长度的内存空间,调用setMemory方法向每个字节写入内容为byte类型的 1,当使用 Unsafe 调用getInt方法时,因为一个int型变量占 4 个字节,会一次性读取 4 个字节,组成一个int的值,对应的十进制结果为 16843009。\n你可以通过下图理解这个过程:\n在代码中调用reallocateMemory方法重新分配了一块 8 字节长度的内存空间,通过比较addr和addr3可以看到和之前申请的内存地址是不同的。在代码中的第二个 for 循环里,调用copyMemory方法进行了两次内存的拷贝,每次拷贝内存地址addr开始的 4 个字节,分别拷贝到以addr3和addr3+4开始的内存空间上:\n拷贝完成后,使用getLong方法一次性读取 8 个字节,得到long类型的值为 72340172838076673。\n需要注意,通过这种方式分配的内存属于 堆外内存 ,是无法进行垃圾回收的,需要我们把这些内存当做一种资源去手动调用freeMemory方法进行释放,否则会产生内存泄漏。通用的操作内存方式是在try中执行对内存的操作,最终在finally块中进行内存的释放。\n为什么要使用堆外内存?\n对垃圾回收停顿的改善。由于堆外内存是直接受操作系统管理而不是 JVM,所以当我们使用堆外内存时,即可保持较小的堆内内存规模。从而在 GC 时减少回收停顿对于应用的影响。 提升程序 I/O 操作的性能。通常在 I/O 通信过程中,会存在堆内内存到堆外内存的数据拷贝操作,对于需要频繁进行内存间数据拷贝且生命周期较短的暂存数据,都建议存储到堆外内存。 典型应用 # DirectByteBuffer 是 Java 用于实现堆外内存的一个重要类,通常用在通信过程中做缓冲池,如在 Netty、MINA 等 NIO 框架中应用广泛。DirectByteBuffer 对于堆外内存的创建、使用、销毁等逻辑均由 Unsafe 提供的堆外内存 API 来实现。\n下图为 DirectByteBuffer 构造函数,创建 DirectByteBuffer 的时候,通过 Unsafe.allocateMemory 分配内存、Unsafe.setMemory 进行内存初始化,而后构建 Cleaner 对象用于跟踪 DirectByteBuffer 对象的垃圾回收,以实现当 DirectByteBuffer 被垃圾回收时,分配的堆外内存一起被释放。\nDirectByteBuffer(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 { // 分配内存并返回基地址 base = unsafe.allocateMemory(size); } catch (OutOfMemoryError x) { Bits.unreserveMemory(size, cap); throw x; } // 内存初始化 unsafe.setMemory(base, size, (byte) 0); if (pa \u0026amp;\u0026amp; (base % ps != 0)) { // Round up to page boundary address = base + ps - (base \u0026amp; (ps - 1)); } else { address = base; } // 跟踪 DirectByteBuffer 对象的垃圾回收,以实现堆外内存释放 cleaner = Cleaner.create(this, new Deallocator(base, size, cap)); att = null; } 内存屏障 # 介绍 # 在介绍内存屏障前,需要知道编译器和 CPU 会在保证程序输出结果一致的情况下,会对代码进行重排序,从指令优化角度提升性能。而指令重排序可能会带来一个不好的结果,导致 CPU 的高速缓存和内存中数据的不一致,而内存屏障(Memory Barrier)就是通过阻止屏障两边的指令重排序从而避免编译器和硬件的不正确优化情况。\n在硬件层面上,内存屏障是 CPU 为了防止代码进行重排序而提供的指令,不同的硬件平台上实现内存屏障的方法可能并不相同。在 Java8 中,引入了 3 个内存屏障的函数,它屏蔽了操作系统底层的差异,允许在代码中定义、并统一由 JVM 来生成内存屏障指令,来实现内存屏障的功能。\nUnsafe 中提供了下面三个内存屏障相关方法:\n//内存屏障,禁止load操作重排序。屏障前的load操作不能被重排序到屏障后,屏障后的load操作不能被重排序到屏障前 public native void loadFence(); //内存屏障,禁止store操作重排序。屏障前的store操作不能被重排序到屏障后,屏障后的store操作不能被重排序到屏障前 public native void storeFence(); //内存屏障,禁止load、store操作重排序 public native void fullFence(); 内存屏障可以看做对内存随机访问的操作中的一个同步点,使得此点之前的所有读写操作都执行后才可以开始执行此点之后的操作。以loadFence方法为例,它会禁止读操作重排序,保证在这个屏障之前的所有读操作都已经完成,并且将缓存数据设为无效,重新从主存中进行加载。\n看到这估计很多小伙伴们会想到volatile关键字了,如果在字段上添加了volatile关键字,就能够实现字段在多线程下的可见性。基于读内存屏障,我们也能实现相同的功能。下面定义一个线程方法,在线程中去修改flag标志位,注意这里的flag是没有被volatile修饰的:\n@Getter class ChangeThread implements Runnable{ /==volatile==/ boolean flag=false; @Override public void run() { try { Thread.sleep(3000); } catch (InterruptedException e) { e.printStackTrace(); } System.out.println(\u0026#34;subThread change flag to:\u0026#34; + flag); flag = true; } } 在主线程的while循环中,加入内存屏障,测试是否能够感知到flag的修改变化:\npublic static void main(String[] args){ ChangeThread changeThread = new ChangeThread(); new Thread(changeThread).start(); while (true) { boolean flag = changeThread.isFlag(); unsafe.loadFence(); //加入读内存屏障 if (flag){ System.out.println(\u0026#34;detected flag changed\u0026#34;); break; } } System.out.println(\u0026#34;main thread end\u0026#34;); } 运行结果:\nsubThread change flag to:false detected flag changed main thread end 而如果删掉上面代码中的loadFence方法,那么主线程将无法感知到flag发生的变化,会一直在while中循环。可以用图来表示上面的过程:\n了解 Java 内存模型(JMM)的小伙伴们应该清楚,运行中的线程不是直接读取主内存中的变量的,只能操作自己工作内存中的变量,然后同步到主内存中,并且线程的工作内存是不能共享的。上面的图中的流程就是子线程借助于主内存,将修改后的结果同步给了主线程,进而修改主线程中的工作空间,跳出循环。\n典型应用 # 在 Java 8 中引入了一种锁的新机制——StampedLock,它可以看成是读写锁的一个改进版本。StampedLock 提供了一种乐观读锁的实现,这种乐观读锁类似于无锁的操作,完全不会阻塞写线程获取写锁,从而缓解读多写少时写线程“饥饿”现象。由于 StampedLock 提供的乐观读锁不阻塞写线程获取读锁,当线程共享变量从主内存 load 到线程工作内存时,会存在数据不一致问题。\n为了解决这个问题,StampedLock 的 validate 方法会通过 Unsafe 的 loadFence 方法加入一个 load 内存屏障。\npublic boolean validate(long stamp) { U.loadFence(); return (stamp \u0026amp; SBITS) == (state \u0026amp; SBITS); } 对象操作 # 介绍 # 例子\nimport sun.misc.Unsafe; import java.lang.reflect.Field; public class Main { private int value; public static void main(String[] args) throws Exception{ Unsafe unsafe = reflectGetUnsafe(); assert unsafe != null; long offset = unsafe.objectFieldOffset(Main.class.getDeclaredField(\u0026#34;value\u0026#34;)); Main main = new Main(); System.out.println(\u0026#34;value before putInt: \u0026#34; + main.value); unsafe.putInt(main, offset, 42); System.out.println(\u0026#34;value after putInt: \u0026#34; + main.value); System.out.println(\u0026#34;value after putInt: \u0026#34; + unsafe.getInt(main, offset)); } private static Unsafe reflectGetUnsafe() { try { Field field = Unsafe.class.getDeclaredField(\u0026#34;theUnsafe\u0026#34;); field.setAccessible(true); return (Unsafe) field.get(null); } catch (Exception e) { e.printStackTrace(); return null; } } } 输出结果:\nvalue before putInt: 0 value after putInt: 42 value after putInt: 42 对象属性\n对象成员属性的内存偏移量获取,以及字段属性值的修改,在上面的例子中我们已经测试过了。除了前面的putInt、getInt方法外,Unsafe 提供了全部 8 种基础数据类型以及Object的put和get方法,并且所有的put方法都可以越过访问权限,直接修改内存中的数据。阅读 openJDK 源码中的注释发现,基础数据类型和Object的读写稍有不同,基础数据类型是直接操作的属性值(value),而Object的操作则是基于引用值(reference value)。下面是Object的读写方法:\n//在对象的指定偏移地址获取一个对象引用 public native Object getObject(Object o, long offset); //在对象指定偏移地址写入一个对象引用 public native void putObject(Object o, long offset, Object x); 除了对象属性的普通读写外,Unsafe 还提供了 volatile 读写和有序写入方法。volatile读写方法的覆盖范围与普通读写相同,包含了全部基础数据类型和Object类型,以int类型为例:\n//在对象的指定偏移地址处读取一个int值,支持volatile load语义 public native int getIntVolatile(Object o, long offset); //在对象指定偏移地址处写入一个int,支持volatile store语义 public native void putIntVolatile(Object o, long offset, int x); 相对于普通读写来说,volatile读写具有更高的成本,因为它需要保证可见性和有序性。在执行get操作时,会强制从主存中获取属性值,在使用put方法设置属性值时,会强制将值更新到主存中,从而保证这些变更对其他线程是可见的。\n有序写入的方法有以下三个:\npublic native void putOrderedObject(Object o, long offset, Object x); public native void putOrderedInt(Object o, long offset, int x); public native void putOrderedLong(Object o, long offset, long x); 有序写入的成本相对volatile较低,因为它只保证写入时的有序性,而不保证可见性,也就是一个线程写入的值不能保证其他线程立即可见。为了解决这里的差异性,需要对内存屏障的知识点再进一步进行补充,首先需要了解两个指令的概念:\nLoad:将主内存中的数据拷贝到处理器的缓存中 Store:将处理器缓存的数据刷新到主内存中 顺序写入与volatile写入的差别在于,在顺序写时加入的内存屏障类型为StoreStore类型,而在volatile写入时加入的内存屏障是StoreLoad类型,如下图所示:\n在有序写入方法中,使用的是StoreStore屏障,该屏障确保Store1立刻刷新数据到内存,这一操作先于Store2以及后续的存储指令操作。而在volatile写入中,使用的是StoreLoad屏障,该屏障确保Store1立刻刷新数据到内存,这一操作先于Load2及后续的装载指令,并且,StoreLoad屏障会使该屏障之前的所有内存访问指令,包括存储指令和访问指令全部完成之后,才执行该屏障之后的内存访问指令。\n综上所述,在上面的三类写入方法中,在写入效率方面,按照put、putOrder、putVolatile的顺序效率逐渐降低。\n对象实例化\n使用 Unsafe 的 allocateInstance 方法,允许我们使用非常规的方式进行对象的实例化,首先定义一个实体类,并且在构造函数中对其成员变量进行赋值操作:\n@Data public class A { private int b; public A(){ this.b =1; } } 分别基于构造函数、反射以及 Unsafe 方法的不同方式创建对象进行比较:\npublic void objTest() throws Exception{ A a1=new A(); System.out.println(a1.getB()); A a2 = A.class.newInstance(); System.out.println(a2.getB()); A a3= (A) unsafe.allocateInstance(A.class); System.out.println(a3.getB()); } 打印结果分别为 1、1、0,说明通过allocateInstance方法创建对象过程中,不会调用类的构造方法。使用这种方式创建对象时,只用到了Class对象,所以说如果想要跳过对象的初始化阶段或者跳过构造器的安全检查,就可以使用这种方法。在上面的例子中,如果将 A 类的构造函数改为private类型,将无法通过构造函数和反射创建对象(可以通过构造函数对象 setAccessible 后创建对象),但allocateInstance方法仍然有效。\n典型应用 # 常规对象实例化方式:我们通常所用到的创建对象的方式,从本质上来讲,都是通过 new 机制来实现对象的创建。但是,new 机制有个特点就是当类只提供有参的构造函数且无显式声明无参构造函数时,则必须使用有参构造函数进行对象构造,而使用有参构造函数时,必须传递相应个数的参数才能完成对象实例化。 非常规的实例化方式:而 Unsafe 中提供 allocateInstance 方法,仅通过 Class 对象就可以创建此类的实例对象,而且不需要调用其构造函数、初始化代码、JVM 安全检查等。它抑制修饰符检测,也就是即使构造器是 private 修饰的也能通过此方法实例化,只需提类对象即可创建相应的对象。由于这种特性,allocateInstance 在 java.lang.invoke、Objenesis(提供绕过类构造器的对象生成方式)、Gson(反序列化时用到)中都有相应的应用。 数组操作 # 介绍 # arrayBaseOffset 与 arrayIndexScale 这两个方法配合起来使用,即可定位数组中每个元素在内存中的位置。\n//返回数组中第一个元素的偏移地址 public native int arrayBaseOffset(Class\u0026lt;?\u0026gt; arrayClass); //返回数组中一个元素占用的大小 public native int arrayIndexScale(Class\u0026lt;?\u0026gt; arrayClass); 典型应用 # 这两个与数据操作相关的方法,在 java.util.concurrent.atomic 包下的 AtomicIntegerArray(可以实现对 Integer 数组中每个元素的原子性操作)中有典型的应用,如下图 AtomicIntegerArray 源码所示,通过 Unsafe 的 arrayBaseOffset、arrayIndexScale 分别获取数组首元素的偏移地址 base 及单个元素大小因子 scale 。后续相关原子性操作,均依赖于这两个值进行数组中元素的定位,如下图二所示的 getAndAdd 方法即通过 checkedByteOffset 方法获取某数组元素的偏移地址,而后通过 CAS 实现原子性操作。\nCAS 操作 # 介绍 # 这部分主要为 CAS 相关操作的方法。\n/** * CAS * @param o 包含要修改field的对象 * @param offset 对象中某field的偏移量 * @param expected 期望值 * @param update 更新值 * @return true | false */ public final native boolean compareAndSwapObject(Object o, long offset, Object expected, Object update); public final native boolean compareAndSwapInt(Object o, long offset, int expected,int update); public final native boolean compareAndSwapLong(Object o, long offset, long expected, long update); 什么是 CAS? CAS 即比较并替换(Compare And Swap),是实现并发算法时常用到的一种技术。CAS 操作包含三个操作数——内存位置、预期原值及新值。执行 CAS 操作的时候,将内存位置的值与预期原值比较,如果相匹配,那么处理器会自动将该位置值更新为新值,否则,处理器不做任何操作。我们都知道,CAS 是一条 CPU 的原子指令(cmpxchg 指令),不会造成所谓的数据不一致问题,Unsafe 提供的 CAS 方法(如 compareAndSwapXXX)底层实现即为 CPU 指令 cmpxchg 。\n典型应用 # 在 JUC 包的并发工具类中大量地使用了 CAS 操作,像在前面介绍synchronized和AQS的文章中也多次提到了 CAS,其作为乐观锁在并发工具类中广泛发挥了作用。在 Unsafe 类中,提供了compareAndSwapObject、compareAndSwapInt、compareAndSwapLong方法来实现的对Object、int、long类型的 CAS 操作。以compareAndSwapInt方法为例:\npublic final native boolean compareAndSwapInt(Object o, long offset,int expected,int x); 参数中o为需要更新的对象,offset是对象o中整形字段的偏移量,如果这个字段的值与expected相同,则将字段的值设为x这个新值,并且此更新是不可被中断的,也就是一个原子操作。下面是一个使用compareAndSwapInt的例子:\nprivate volatile int a; public static void main(String[] args){ CasTest casTest=new CasTest(); new Thread(()-\u0026gt;{ for (int i = 1; i \u0026lt; 5; i++) { casTest.increment(i); System.out.print(casTest.a+\u0026#34; \u0026#34;); } }).start(); new Thread(()-\u0026gt;{ for (int i = 5 ; i \u0026lt;10 ; i++) { casTest.increment(i); System.out.print(casTest.a+\u0026#34; \u0026#34;); } }).start(); } private void increment(int x){ while (true){ try { long fieldOffset = unsafe.objectFieldOffset(CasTest.class.getDeclaredField(\u0026#34;a\u0026#34;)); if (unsafe.compareAndSwapInt(this,fieldOffset,x-1,x)) break; } catch (NoSuchFieldException e) { e.printStackTrace(); } } } 运行代码会依次输出:\n1 2 3 4 5 6 7 8 9 在上面的例子中,使用两个线程去修改int型属性a的值,并且只有在a的值等于传入的参数x减一时,才会将a的值变为x,也就是实现对a的加一的操作。流程如下所示:\n需要注意的是,在调用compareAndSwapInt方法后,会直接返回true或false的修改结果,因此需要我们在代码中手动添加自旋的逻辑。在AtomicInteger类的设计中,也是采用了将compareAndSwapInt的结果作为循环条件,直至修改成功才退出死循环的方式来实现的原子性的自增操作。\n线程调度 # 介绍 # Unsafe 类中提供了park、unpark、monitorEnter、monitorExit、tryMonitorEnter方法进行线程调度。\n//取消阻塞线程 public native void unpark(Object thread); //阻塞线程 public native void park(boolean isAbsolute, long time); //获得对象锁(可重入锁) @Deprecated public native void monitorEnter(Object o); //释放对象锁 @Deprecated public native void monitorExit(Object o); //尝试获取对象锁 @Deprecated public native boolean tryMonitorEnter(Object o); 方法 park、unpark 即可实现线程的挂起与恢复,将一个线程进行挂起是通过 park 方法实现的,调用 park 方法后,线程将一直阻塞直到超时或者中断等条件出现;unpark 可以终止一个挂起的线程,使其恢复正常。\n此外,Unsafe 源码中monitor相关的三个方法已经被标记为deprecated,不建议被使用:\n//获得对象锁 @Deprecated public native void monitorEnter(Object var1); //释放对象锁 @Deprecated public native void monitorExit(Object var1); //尝试获得对象锁 @Deprecated public native boolean tryMonitorEnter(Object var1); monitorEnter方法用于获得对象锁,monitorExit用于释放对象锁,如果对一个没有被monitorEnter加锁的对象执行此方法,会抛出IllegalMonitorStateException异常。tryMonitorEnter方法尝试获取对象锁,如果成功则返回true,反之返回false。\n典型应用 # Java 锁和同步器框架的核心类 AbstractQueuedSynchronizer (AQS),就是通过调用LockSupport.park()和LockSupport.unpark()实现线程的阻塞和唤醒的,而 LockSupport 的 park、unpark 方法实际是调用 Unsafe 的 park、unpark 方式实现的。\npublic static void park(Object blocker) { Thread t = Thread.currentThread(); setBlocker(t, blocker); UNSAFE.park(false, 0L); setBlocker(t, null); } public static void unpark(Thread thread) { if (thread != null) UNSAFE.unpark(thread); } LockSupport 的park方法调用了 Unsafe 的park方法来阻塞当前线程,此方法将线程阻塞后就不会继续往后执行,直到有其他线程调用unpark方法唤醒当前线程。下面的例子对 Unsafe 的这两个方法进行测试:\npublic static void main(String[] args) { Thread mainThread = Thread.currentThread(); new Thread(()-\u0026gt;{ try { TimeUnit.SECONDS.sleep(5); System.out.println(\u0026#34;subThread try to unpark mainThread\u0026#34;); unsafe.unpark(mainThread); } catch (InterruptedException e) { e.printStackTrace(); } }).start(); System.out.println(\u0026#34;park main mainThread\u0026#34;); unsafe.park(false,0L); System.out.println(\u0026#34;unpark mainThread success\u0026#34;); } 程序输出为:\npark main mainThread subThread try to unpark mainThread unpark mainThread success 程序运行的流程也比较容易看懂,子线程开始运行后先进行睡眠,确保主线程能够调用park方法阻塞自己,子线程在睡眠 5 秒后,调用unpark方法唤醒主线程,使主线程能继续向下执行。整个流程如下图所示:\nClass 操作 # 介绍 # Unsafe 对Class的相关操作主要包括类加载和静态变量的操作方法。\n静态属性读取相关的方法\n//获取静态属性的偏移量 public native long staticFieldOffset(Field f); //获取静态属性的对象指针 public native Object staticFieldBase(Field f); //判断类是否需要初始化(用于获取类的静态属性前进行检测) public native boolean shouldBeInitialized(Class\u0026lt;?\u0026gt; c); 创建一个包含静态属性的类,进行测试:\n@Data public class User { public static String name=\u0026#34;Hydra\u0026#34;; int age; } private void staticTest() throws Exception { User user=new User(); // 也可以用下面的语句触发类初始化 // 1. // unsafe.ensureClassInitialized(User.class); // 2. // System.out.println(User.name); System.out.println(unsafe.shouldBeInitialized(User.class)); Field sexField = User.class.getDeclaredField(\u0026#34;name\u0026#34;); long fieldOffset = unsafe.staticFieldOffset(sexField); Object fieldBase = unsafe.staticFieldBase(sexField); Object object = unsafe.getObject(fieldBase, fieldOffset); System.out.println(object); } 运行结果:\nfalse Hydra 在 Unsafe 的对象操作中,我们学习了通过objectFieldOffset方法获取对象属性偏移量并基于它对变量的值进行存取,但是它不适用于类中的静态属性,这时候就需要使用staticFieldOffset方法。在上面的代码中,只有在获取Field对象的过程中依赖到了Class,而获取静态变量的属性时不再依赖于Class。\n在上面的代码中首先创建一个User对象,这是因为如果一个类没有被初始化,那么它的静态属性也不会被初始化,最后获取的字段属性将是null。所以在获取静态属性前,需要调用shouldBeInitialized方法,判断在获取前是否需要初始化这个类。如果删除创建 User 对象的语句,运行结果会变为:\ntrue null 使用defineClass方法允许程序在运行时动态地创建一个类\npublic native Class\u0026lt;?\u0026gt; defineClass(String name, byte[] b, int off, int len, ClassLoader loader,ProtectionDomain protectionDomain); 在实际使用过程中,可以只传入字节数组、起始字节的下标以及读取的字节长度,默认情况下,类加载器(ClassLoader)和保护域(ProtectionDomain)来源于调用此方法的实例。下面的例子中实现了反编译生成后的 class 文件的功能:\nprivate static void defineTest() { String fileName=\u0026#34;F:\\\\workspace\\\\unsafe-test\\\\target\\\\classes\\\\com\\\\cn\\\\model\\\\User.class\u0026#34;; File file = new File(fileName); try(FileInputStream fis = new FileInputStream(file)) { byte[] content=new byte[(int)file.length()]; fis.read(content); Class clazz = unsafe.defineClass(null, content, 0, content.length, null, null); Object o = clazz.newInstance(); Object age = clazz.getMethod(\u0026#34;getAge\u0026#34;).invoke(o, null); System.out.println(age); } catch (Exception e) { e.printStackTrace(); } } 在上面的代码中,首先读取了一个class文件并通过文件流将它转化为字节数组,之后使用defineClass方法动态的创建了一个类,并在后续完成了它的实例化工作,流程如下图所示,并且通过这种方式创建的类,会跳过 JVM 的所有安全检查。\n除了defineClass方法外,Unsafe 还提供了一个defineAnonymousClass方法:\npublic native Class\u0026lt;?\u0026gt; defineAnonymousClass(Class\u0026lt;?\u0026gt; hostClass, byte[] data, Object[] cpPatches); 使用该方法可以用来动态的创建一个匿名类,在Lambda表达式中就是使用 ASM 动态生成字节码,然后利用该方法定义实现相应的函数式接口的匿名类。在 JDK 15 发布的新特性中,在隐藏类(Hidden classes)一条中,指出将在未来的版本中弃用 Unsafe 的defineAnonymousClass方法。\n典型应用 # Lambda 表达式实现需要依赖 Unsafe 的 defineAnonymousClass 方法定义实现相应的函数式接口的匿名类。\n系统信息 # 介绍 # 这部分包含两个获取系统相关信息的方法。\n//返回系统指针的大小。返回值为4(32位系统)或 8(64位系统)。 public native int addressSize(); //内存页的大小,此值为2的幂次方。 public native int pageSize(); 典型应用 # 这两个方法的应用场景比较少,在java.nio.Bits类中,在使用pageCount计算所需的内存页的数量时,调用了pageSize方法获取内存页的大小。另外,在使用copySwapMemory方法拷贝内存时,调用了addressSize方法,检测 32 位系统的情况。\n总结 # 在本文中,我们首先介绍了 Unsafe 的基本概念、工作原理,并在此基础上,对它的 API 进行了说明与实践。相信大家通过这一过程,能够发现 Unsafe 在某些场景下,确实能够为我们提供编程中的便利。但是回到开头的话题,在使用这些便利时,确实存在着一些安全上的隐患,在我看来,一项技术具有不安全因素并不可怕,可怕的是它在使用过程中被滥用。尽管之前有传言说会在 Java9 中移除 Unsafe 类,不过它还是照样已经存活到了 Java16。按照存在即合理的逻辑,只要使用得当,它还是能给我们带来不少的帮助,因此最后还是建议大家,在使用 Unsafe 的过程中一定要做到使用谨慎使用、避免滥用。\n"},{"id":507,"href":"/zh/docs/technology/Interview/java/concurrent/java-thread-pool-summary/","title":"Java 线程池详解","section":"Concurrent","content":"池化技术想必大家已经屡见不鲜了,线程池、数据库连接池、HTTP 连接池等等都是对这个思想的应用。池化技术的思想主要是为了减少每次获取资源的消耗,提高对资源的利用率。\n这篇文章我会详细介绍一下线程池的基本概念以及核心原理。\n线程池介绍 # 顾名思义,线程池就是管理一系列线程的资源池,其提供了一种限制和管理线程资源的方式。每个线程池还维护一些基本统计信息,例如已完成任务的数量。\n这里借用《Java 并发编程的艺术》书中的部分内容来总结一下使用线程池的好处:\n降低资源消耗。通过重复利用已创建的线程降低线程创建和销毁造成的消耗。 提高响应速度。当任务到达时,任务可以不需要等到线程创建就能立即执行。 提高线程的可管理性。线程是稀缺资源,如果无限制的创建,不仅会消耗系统资源,还会降低系统的稳定性,使用线程池可以进行统一的分配,调优和监控。 线程池一般用于执行多个不相关联的耗时任务,没有多线程的情况下,任务顺序执行,使用了线程池的话可让多个不相关联的任务同时执行。\nExecutor 框架介绍 # Executor 框架是 Java5 之后引进的,在 Java 5 之后,通过 Executor 来启动线程比使用 Thread 的 start 方法更好,除了更易管理,效率更好(用线程池实现,节约开销)外,还有关键的一点:有助于避免 this 逃逸问题。\nthis 逃逸是指在构造函数返回之前其他线程就持有该对象的引用,调用尚未构造完全的对象的方法可能引发令人疑惑的错误。\nExecutor 框架不仅包括了线程池的管理,还提供了线程工厂、队列以及拒绝策略等,Executor 框架让并发编程变得更加简单。\nExecutor 框架结构主要由三大部分组成:\n1、任务(Runnable /Callable)\n执行任务需要实现的 Runnable 接口 或 Callable接口。Runnable 接口或 Callable 接口 实现类都可以被 ThreadPoolExecutor 或 ScheduledThreadPoolExecutor 执行。\n2、任务的执行(Executor)\n如下图所示,包括任务执行机制的核心接口 Executor ,以及继承自 Executor 接口的 ExecutorService 接口。ThreadPoolExecutor 和 ScheduledThreadPoolExecutor 这两个关键类实现了 ExecutorService 接口。\n这里提了很多底层的类关系,但是,实际上我们需要更多关注的是 ThreadPoolExecutor 这个类,这个类在我们实际使用线程池的过程中,使用频率还是非常高的。\n注意: 通过查看 ScheduledThreadPoolExecutor 源代码我们发现 ScheduledThreadPoolExecutor 实际上是继承了 ThreadPoolExecutor 并实现了 ScheduledExecutorService ,而 ScheduledExecutorService 又实现了 ExecutorService,正如我们上面给出的类关系图显示的一样。\nThreadPoolExecutor 类描述:\n//AbstractExecutorService实现了ExecutorService接口 public class ThreadPoolExecutor extends AbstractExecutorService ScheduledThreadPoolExecutor 类描述:\n//ScheduledExecutorService继承ExecutorService接口 public class ScheduledThreadPoolExecutor extends ThreadPoolExecutor implements ScheduledExecutorService 3、异步计算的结果(Future)\nFuture 接口以及 Future 接口的实现类 FutureTask 类都可以代表异步计算的结果。\n当我们把 Runnable接口 或 Callable 接口 的实现类提交给 ThreadPoolExecutor 或 ScheduledThreadPoolExecutor 执行。(调用 submit() 方法时会返回一个 FutureTask 对象)\nExecutor 框架的使用示意图:\n主线程首先要创建实现 Runnable 或者 Callable 接口的任务对象。 把创建完成的实现 Runnable/Callable接口的 对象直接交给 ExecutorService 执行: ExecutorService.execute(Runnable command))或者也可以把 Runnable 对象或Callable 对象提交给 ExecutorService 执行(ExecutorService.submit(Runnable task)或 ExecutorService.submit(Callable \u0026lt;T\u0026gt; task))。 如果执行 ExecutorService.submit(…),ExecutorService 将返回一个实现Future接口的对象(我们刚刚也提到过了执行 execute()方法和 submit()方法的区别,submit()会返回一个 FutureTask 对象)。由于 FutureTask 实现了 Runnable,我们也可以创建 FutureTask,然后直接交给 ExecutorService 执行。 最后,主线程可以执行 FutureTask.get()方法来等待任务执行完成。主线程也可以执行 FutureTask.cancel(boolean mayInterruptIfRunning)来取消此任务的执行。 ThreadPoolExecutor 类介绍(重要) # 线程池实现类 ThreadPoolExecutor 是 Executor 框架最核心的类。\n线程池参数分析 # ThreadPoolExecutor 类中提供的四个构造方法。我们来看最长的那个,其余三个都是在这个构造方法的基础上产生(其他几个构造方法说白点都是给定某些默认参数的构造方法比如默认制定拒绝策略是什么)。\n/** * 用给定的初始参数创建一个新的ThreadPoolExecutor。 */ public ThreadPoolExecutor(int corePoolSize,//线程池的核心线程数量 int maximumPoolSize,//线程池的最大线程数 long keepAliveTime,//当线程数大于核心线程数时,多余的空闲线程存活的最长时间 TimeUnit unit,//时间单位 BlockingQueue\u0026lt;Runnable\u0026gt; workQueue,//任务队列,用来储存等待执行任务的队列 ThreadFactory threadFactory,//线程工厂,用来创建线程,一般默认即可 RejectedExecutionHandler handler//拒绝策略,当提交的任务过多而不能及时处理时,我们可以定制策略来处理任务 ) { if (corePoolSize \u0026lt; 0 || maximumPoolSize \u0026lt;= 0 || maximumPoolSize \u0026lt; corePoolSize || keepAliveTime \u0026lt; 0) throw new IllegalArgumentException(); if (workQueue == null || threadFactory == null || handler == null) throw new NullPointerException(); this.corePoolSize = corePoolSize; this.maximumPoolSize = maximumPoolSize; this.workQueue = workQueue; this.keepAliveTime = unit.toNanos(keepAliveTime); this.threadFactory = threadFactory; this.handler = handler; } 下面这些参数非常重要,在后面使用线程池的过程中你一定会用到!所以,务必拿着小本本记清楚。\nThreadPoolExecutor 3 个最重要的参数:\ncorePoolSize : 任务队列未达到队列容量时,最大可以同时运行的线程数量。 maximumPoolSize : 任务队列中存放的任务达到队列容量的时候,当前可以同时运行的线程数量变为最大线程数。 workQueue: 新任务来的时候会先判断当前运行的线程数量是否达到核心线程数,如果达到的话,新任务就会被存放在队列中。 ThreadPoolExecutor其他常见参数 :\nkeepAliveTime:线程池中的线程数量大于 corePoolSize 的时候,如果这时没有新的任务提交,核心线程外的线程不会立即销毁,而是会等待,直到等待的时间超过了 keepAliveTime才会被回收销毁。 unit : keepAliveTime 参数的时间单位。 threadFactory :executor 创建新线程的时候会用到。 handler :拒绝策略(后面会单独详细介绍一下)。 下面这张图可以加深你对线程池中各个参数的相互关系的理解(图片来源:《Java 性能调优实战》):\nThreadPoolExecutor 拒绝策略定义:\n如果当前同时运行的线程数量达到最大线程数量并且队列也已经被放满了任务时,ThreadPoolExecutor 定义一些策略:\nThreadPoolExecutor.AbortPolicy:抛出 RejectedExecutionException来拒绝新任务的处理。 ThreadPoolExecutor.CallerRunsPolicy:调用执行自己的线程运行任务,也就是直接在调用execute方法的线程中运行(run)被拒绝的任务,如果执行程序已关闭,则会丢弃该任务。因此这种策略会降低对于新任务提交速度,影响程序的整体性能。如果您的应用程序可以承受此延迟并且你要求任何一个任务请求都要被执行的话,你可以选择这个策略。 ThreadPoolExecutor.DiscardPolicy:不处理新任务,直接丢弃掉。 ThreadPoolExecutor.DiscardOldestPolicy:此策略将丢弃最早的未处理的任务请求。 举个例子:\n举个例子:Spring 通过 ThreadPoolTaskExecutor 或者我们直接通过 ThreadPoolExecutor 的构造函数创建线程池的时候,当我们不指定 RejectedExecutionHandler 拒绝策略来配置线程池的时候,默认使用的是 AbortPolicy。在这种拒绝策略下,如果队列满了,ThreadPoolExecutor 将抛出 RejectedExecutionException 异常来拒绝新来的任务 ,这代表你将丢失对这个任务的处理。如果不想丢弃任务的话,可以使用CallerRunsPolicy。CallerRunsPolicy 和其他的几个策略不同,它既不会抛弃任务,也不会抛出异常,而是将任务回退给调用者,使用调用者的线程来执行任务\npublic static class CallerRunsPolicy implements RejectedExecutionHandler { public CallerRunsPolicy() { } public void rejectedExecution(Runnable r, ThreadPoolExecutor e) { if (!e.isShutdown()) { // 直接主线程执行,而不是线程池中的线程执行 r.run(); } } } 线程池创建的两种方式 # 方式一:通过ThreadPoolExecutor构造函数来创建(推荐)。\n方式二:通过 Executor 框架的工具类 Executors 来创建。\nExecutors工具类提供的创建线程池的方法如下图所示:\n可以看出,通过Executors工具类可以创建多种类型的线程池,包括:\nFixedThreadPool:固定线程数量的线程池。该线程池中的线程数量始终不变。当有一个新的任务提交时,线程池中若有空闲线程,则立即执行。若没有,则新的任务会被暂存在一个任务队列中,待有线程空闲时,便处理在任务队列中的任务。 SingleThreadExecutor: 只有一个线程的线程池。若多余一个任务被提交到该线程池,任务会被保存在一个任务队列中,待线程空闲,按先入先出的顺序执行队列中的任务。 CachedThreadPool: 可根据实际情况调整线程数量的线程池。线程池的线程数量不确定,但若有空闲线程可以复用,则会优先使用可复用的线程。若所有线程均在工作,又有新的任务提交,则会创建新的线程处理任务。所有线程在当前任务执行完毕后,将返回线程池进行复用。 ScheduledThreadPool:给定的延迟后运行任务或者定期执行任务的线程池。 《阿里巴巴 Java 开发手册》强制线程池不允许使用 Executors 去创建,而是通过 ThreadPoolExecutor 构造函数的方式,这样的处理方式让写的同学更加明确线程池的运行规则,规避资源耗尽的风险\nExecutors 返回线程池对象的弊端如下(后文会详细介绍到):\nFixedThreadPool 和 SingleThreadExecutor:使用的是无界的 LinkedBlockingQueue,任务队列最大长度为 Integer.MAX_VALUE,可能堆积大量的请求,从而导致 OOM。 CachedThreadPool:使用的是同步队列 SynchronousQueue, 允许创建的线程数量为 Integer.MAX_VALUE ,如果任务数量过多且执行速度较慢,可能会创建大量的线程,从而导致 OOM。 ScheduledThreadPool 和 SingleThreadScheduledExecutor:使用的无界的延迟阻塞队列DelayedWorkQueue,任务队列最大长度为 Integer.MAX_VALUE,可能堆积大量的请求,从而导致 OOM。 // 无界队列 LinkedBlockingQueue public static ExecutorService newFixedThreadPool(int nThreads) { return new ThreadPoolExecutor(nThreads, nThreads,0L, TimeUnit.MILLISECONDS,new LinkedBlockingQueue\u0026lt;Runnable\u0026gt;()); } // 无界队列 LinkedBlockingQueue public static ExecutorService newSingleThreadExecutor() { return new FinalizableDelegatedExecutorService (new ThreadPoolExecutor(1, 1,0L, TimeUnit.MILLISECONDS,new LinkedBlockingQueue\u0026lt;Runnable\u0026gt;())); } // 同步队列 SynchronousQueue,没有容量,最大线程数是 Integer.MAX_VALUE` public static ExecutorService newCachedThreadPool() { return new ThreadPoolExecutor(0, Integer.MAX_VALUE,60L, TimeUnit.SECONDS,new SynchronousQueue\u0026lt;Runnable\u0026gt;()); } // DelayedWorkQueue(延迟阻塞队列) public static ScheduledExecutorService newScheduledThreadPool(int corePoolSize) { return new ScheduledThreadPoolExecutor(corePoolSize); } public ScheduledThreadPoolExecutor(int corePoolSize) { super(corePoolSize, Integer.MAX_VALUE, 0, NANOSECONDS, new DelayedWorkQueue()); } 线程池常用的阻塞队列总结 # 新任务来的时候会先判断当前运行的线程数量是否达到核心线程数,如果达到的话,新任务就会被存放在队列中。\n不同的线程池会选用不同的阻塞队列,我们可以结合内置线程池来分析。\n容量为 Integer.MAX_VALUE 的 LinkedBlockingQueue(无界队列):FixedThreadPool 和 SingleThreadExector 。FixedThreadPool最多只能创建核心线程数的线程(核心线程数和最大线程数相等),SingleThreadExector只能创建一个线程(核心线程数和最大线程数都是 1),二者的任务队列永远不会被放满。 SynchronousQueue(同步队列):CachedThreadPool 。SynchronousQueue 没有容量,不存储元素,目的是保证对于提交的任务,如果有空闲线程,则使用空闲线程来处理;否则新建一个线程来处理任务。也就是说,CachedThreadPool 的最大线程数是 Integer.MAX_VALUE ,可以理解为线程数是可以无限扩展的,可能会创建大量线程,从而导致 OOM。 DelayedWorkQueue(延迟阻塞队列):ScheduledThreadPool 和 SingleThreadScheduledExecutor 。DelayedWorkQueue 的内部元素并不是按照放入的时间排序,而是会按照延迟的时间长短对任务进行排序,内部采用的是“堆”的数据结构,可以保证每次出队的任务都是当前队列中执行时间最靠前的。DelayedWorkQueue 添加元素满了之后会自动扩容原来容量的 1/2,即永远不会阻塞,最大扩容可达 Integer.MAX_VALUE,所以最多只能创建核心线程数的线程。 线程池原理分析(重要) # 我们上面讲解了 Executor框架以及 ThreadPoolExecutor 类,下面让我们实战一下,来通过写一个 ThreadPoolExecutor 的小 Demo 来回顾上面的内容。\n线程池示例代码 # 首先创建一个 Runnable 接口的实现类(当然也可以是 Callable 接口,我们后面会介绍两者的区别。)\nMyRunnable.java\nimport java.util.Date; /** * 这是一个简单的Runnable类,需要大约5秒钟来执行其任务。 * @author shuang.kou */ public class MyRunnable implements Runnable { private String command; public MyRunnable(String s) { this.command = s; } @Override public void run() { System.out.println(Thread.currentThread().getName() + \u0026#34; Start. Time = \u0026#34; + new Date()); processCommand(); System.out.println(Thread.currentThread().getName() + \u0026#34; End. Time = \u0026#34; + new Date()); } private void processCommand() { try { Thread.sleep(5000); } catch (InterruptedException e) { e.printStackTrace(); } } @Override public String toString() { return this.command; } } 编写测试程序,我们这里以阿里巴巴推荐的使用 ThreadPoolExecutor 构造函数自定义参数的方式来创建线程池。\nThreadPoolExecutorDemo.java\nimport java.util.concurrent.ArrayBlockingQueue; import java.util.concurrent.ThreadPoolExecutor; import java.util.concurrent.TimeUnit; public class ThreadPoolExecutorDemo { private static final int CORE_POOL_SIZE = 5; private static final int MAX_POOL_SIZE = 10; private static final int QUEUE_CAPACITY = 100; private static final Long KEEP_ALIVE_TIME = 1L; public static void main(String[] args) { //使用阿里巴巴推荐的创建线程池的方式 //通过ThreadPoolExecutor构造函数自定义参数创建 ThreadPoolExecutor executor = new ThreadPoolExecutor( CORE_POOL_SIZE, MAX_POOL_SIZE, KEEP_ALIVE_TIME, TimeUnit.SECONDS, new ArrayBlockingQueue\u0026lt;\u0026gt;(QUEUE_CAPACITY), new ThreadPoolExecutor.CallerRunsPolicy()); for (int i = 0; i \u0026lt; 10; i++) { //创建WorkerThread对象(WorkerThread类实现了Runnable 接口) Runnable worker = new MyRunnable(\u0026#34;\u0026#34; + i); //执行Runnable executor.execute(worker); } //终止线程池 executor.shutdown(); while (!executor.isTerminated()) { } System.out.println(\u0026#34;Finished all threads\u0026#34;); } } 可以看到我们上面的代码指定了:\ncorePoolSize: 核心线程数为 5。 maximumPoolSize:最大线程数 10 keepAliveTime : 等待时间为 1L。 unit: 等待时间的单位为 TimeUnit.SECONDS。 workQueue:任务队列为 ArrayBlockingQueue,并且容量为 100; handler:拒绝策略为 CallerRunsPolicy。 输出结构:\npool-1-thread-3 Start. Time = Sun Apr 12 11:14:37 CST 2020 pool-1-thread-5 Start. Time = Sun Apr 12 11:14:37 CST 2020 pool-1-thread-2 Start. Time = Sun Apr 12 11:14:37 CST 2020 pool-1-thread-1 Start. Time = Sun Apr 12 11:14:37 CST 2020 pool-1-thread-4 Start. Time = Sun Apr 12 11:14:37 CST 2020 pool-1-thread-3 End. Time = Sun Apr 12 11:14:42 CST 2020 pool-1-thread-4 End. Time = Sun Apr 12 11:14:42 CST 2020 pool-1-thread-1 End. Time = Sun Apr 12 11:14:42 CST 2020 pool-1-thread-5 End. Time = Sun Apr 12 11:14:42 CST 2020 pool-1-thread-1 Start. Time = Sun Apr 12 11:14:42 CST 2020 pool-1-thread-2 End. Time = Sun Apr 12 11:14:42 CST 2020 pool-1-thread-5 Start. Time = Sun Apr 12 11:14:42 CST 2020 pool-1-thread-4 Start. Time = Sun Apr 12 11:14:42 CST 2020 pool-1-thread-3 Start. Time = Sun Apr 12 11:14:42 CST 2020 pool-1-thread-2 Start. Time = Sun Apr 12 11:14:42 CST 2020 pool-1-thread-1 End. Time = Sun Apr 12 11:14:47 CST 2020 pool-1-thread-4 End. Time = Sun Apr 12 11:14:47 CST 2020 pool-1-thread-5 End. Time = Sun Apr 12 11:14:47 CST 2020 pool-1-thread-3 End. Time = Sun Apr 12 11:14:47 CST 2020 pool-1-thread-2 End. Time = Sun Apr 12 11:14:47 CST 2020 Finished all threads // 任务全部执行完了才会跳出来,因为executor.isTerminated()判断为true了才会跳出while循环,当且仅当调用 shutdown() 方法后,并且所有提交的任务完成后返回为 true 线程池原理分析 # 我们通过前面的代码输出结果可以看出:线程池首先会先执行 5 个任务,然后这些任务有任务被执行完的话,就会去拿新的任务执行。 大家可以先通过上面讲解的内容,分析一下到底是咋回事?(自己独立思考一会)\n现在,我们就分析上面的输出内容来简单分析一下线程池原理。\n为了搞懂线程池的原理,我们需要首先分析一下 execute方法。 在示例代码中,我们使用 executor.execute(worker)来提交一个任务到线程池中去。\n这个方法非常重要,下面我们来看看它的源码:\n// 存放线程池的运行状态 (runState) 和线程池内有效线程的数量 (workerCount) private final AtomicInteger ctl = new AtomicInteger(ctlOf(RUNNING, 0)); private static int workerCountOf(int c) { return c \u0026amp; CAPACITY; } //任务队列 private final BlockingQueue\u0026lt;Runnable\u0026gt; workQueue; public void execute(Runnable command) { // 如果任务为null,则抛出异常。 if (command == null) throw new NullPointerException(); // ctl 中保存的线程池当前的一些状态信息 int c = ctl.get(); // 下面会涉及到 3 步 操作 // 1.首先判断当前线程池中执行的任务数量是否小于 corePoolSize // 如果小于的话,通过addWorker(command, true)新建一个线程,并将任务(command)添加到该线程中;然后,启动该线程从而执行任务。 if (workerCountOf(c) \u0026lt; corePoolSize) { if (addWorker(command, true)) return; c = ctl.get(); } // 2.如果当前执行的任务数量大于等于 corePoolSize 的时候就会走到这里,表明创建新的线程失败。 // 通过 isRunning 方法判断线程池状态,线程池处于 RUNNING 状态并且队列可以加入任务,该任务才会被加入进去 if (isRunning(c) \u0026amp;\u0026amp; workQueue.offer(command)) { int recheck = ctl.get(); // 再次获取线程池状态,如果线程池状态不是 RUNNING 状态就需要从任务队列中移除任务,并尝试判断线程是否全部执行完毕。同时执行拒绝策略。 if (!isRunning(recheck) \u0026amp;\u0026amp; remove(command)) reject(command); // 如果当前工作线程数量为0,新创建一个线程并执行。 else if (workerCountOf(recheck) == 0) addWorker(null, false); } //3. 通过addWorker(command, false)新建一个线程,并将任务(command)添加到该线程中;然后,启动该线程从而执行任务。 // 传入 false 代表增加线程时判断当前线程数是否少于 maxPoolSize //如果addWorker(command, false)执行失败,则通过reject()执行相应的拒绝策略的内容。 else if (!addWorker(command, false)) reject(command); } 这里简单分析一下整个流程(对整个逻辑进行了简化,方便理解):\n如果当前运行的线程数小于核心线程数,那么就会新建一个线程来执行任务。 如果当前运行的线程数等于或大于核心线程数,但是小于最大线程数,那么就把该任务放入到任务队列里等待执行。 如果向任务队列投放任务失败(任务队列已经满了),但是当前运行的线程数是小于最大线程数的,就新建一个线程来执行任务。 如果当前运行的线程数已经等同于最大线程数了,新建线程将会使当前运行的线程超出最大线程数,那么当前任务会被拒绝,拒绝策略会调用RejectedExecutionHandler.rejectedExecution()方法。 在 execute 方法中,多次调用 addWorker 方法。addWorker 这个方法主要用来创建新的工作线程,如果返回 true 说明创建和启动工作线程成功,否则的话返回的就是 false。\n// 全局锁,并发操作必备 private final ReentrantLock mainLock = new ReentrantLock(); // 跟踪线程池的最大大小,只有在持有全局锁mainLock的前提下才能访问此集合 private int largestPoolSize; // 工作线程集合,存放线程池中所有的(活跃的)工作线程,只有在持有全局锁mainLock的前提下才能访问此集合 private final HashSet\u0026lt;Worker\u0026gt; workers = new HashSet\u0026lt;\u0026gt;(); //获取线程池状态 private static int runStateOf(int c) { return c \u0026amp; ~CAPACITY; } //判断线程池的状态是否为 Running private static boolean isRunning(int c) { return c \u0026lt; SHUTDOWN; } /** * 添加新的工作线程到线程池 * @param firstTask 要执行 * @param core参数为true的话表示使用线程池的基本大小,为false使用线程池最大大小 * @return 添加成功就返回true否则返回false */ private boolean addWorker(Runnable firstTask, boolean core) { retry: for (;;) { //这两句用来获取线程池的状态 int c = ctl.get(); int rs = runStateOf(c); // Check if queue empty only if necessary. if (rs \u0026gt;= SHUTDOWN \u0026amp;\u0026amp; ! (rs == SHUTDOWN \u0026amp;\u0026amp; firstTask == null \u0026amp;\u0026amp; ! workQueue.isEmpty())) return false; for (;;) { //获取线程池中工作的线程的数量 int wc = workerCountOf(c); // core参数为false的话表明队列也满了,线程池大小变为 maximumPoolSize if (wc \u0026gt;= CAPACITY || wc \u0026gt;= (core ? corePoolSize : maximumPoolSize)) return false; //原子操作将workcount的数量加1 if (compareAndIncrementWorkerCount(c)) break retry; // 如果线程的状态改变了就再次执行上述操作 c = ctl.get(); if (runStateOf(c) != rs) continue retry; // else CAS failed due to workerCount change; retry inner loop } } // 标记工作线程是否启动成功 boolean workerStarted = false; // 标记工作线程是否创建成功 boolean workerAdded = false; Worker w = null; try { w = new Worker(firstTask); final Thread t = w.thread; if (t != null) { // 加锁 final ReentrantLock mainLock = this.mainLock; mainLock.lock(); try { //获取线程池状态 int rs = runStateOf(ctl.get()); //rs \u0026lt; SHUTDOWN 如果线程池状态依然为RUNNING,并且线程的状态是存活的话,就会将工作线程添加到工作线程集合中 //(rs=SHUTDOWN \u0026amp;\u0026amp; firstTask == null)如果线程池状态小于STOP,也就是RUNNING或者SHUTDOWN状态下,同时传入的任务实例firstTask为null,则需要添加到工作线程集合和启动新的Worker // firstTask == null证明只新建线程而不执行任务 if (rs \u0026lt; SHUTDOWN || (rs == SHUTDOWN \u0026amp;\u0026amp; firstTask == null)) { if (t.isAlive()) // precheck that t is startable throw new IllegalThreadStateException(); workers.add(w); //更新当前工作线程的最大容量 int s = workers.size(); if (s \u0026gt; largestPoolSize) largestPoolSize = s; // 工作线程是否启动成功 workerAdded = true; } } finally { // 释放锁 mainLock.unlock(); } //// 如果成功添加工作线程,则调用Worker内部的线程实例t的Thread#start()方法启动真实的线程实例 if (workerAdded) { t.start(); /// 标记线程启动成功 workerStarted = true; } } } finally { // 线程启动失败,需要从工作线程中移除对应的Worker if (! workerStarted) addWorkerFailed(w); } return workerStarted; } 更多关于线程池源码分析的内容推荐这篇文章:硬核干货: 4W 字从源码上分析 JUC 线程池 ThreadPoolExecutor 的实现原理\n现在,让我们在回到示例代码, 现在应该是不是很容易就可以搞懂它的原理了呢?\n没搞懂的话,也没关系,可以看看我的分析:\n我们在代码中模拟了 10 个任务,我们配置的核心线程数为 5、等待队列容量为 100 ,所以每次只可能存在 5 个任务同时执行,剩下的 5 个任务会被放到等待队列中去。当前的 5 个任务中如果有任务被执行完了,线程池就会去拿新的任务执行。\n几个常见的对比 # Runnable vs Callable # Runnable自 Java 1.0 以来一直存在,但Callable仅在 Java 1.5 中引入,目的就是为了来处理Runnable不支持的用例。Runnable 接口不会返回结果或抛出检查异常,但是 Callable 接口可以。所以,如果任务不需要返回结果或抛出异常推荐使用 Runnable 接口,这样代码看起来会更加简洁。\n工具类 Executors 可以实现将 Runnable 对象转换成 Callable 对象。(Executors.callable(Runnable task) 或 Executors.callable(Runnable task, Object result))。\nRunnable.java\n@FunctionalInterface public interface Runnable { /** * 被线程执行,没有返回值也无法抛出异常 */ public abstract void run(); } Callable.java\n@FunctionalInterface public interface Callable\u0026lt;V\u0026gt; { /** * 计算结果,或在无法这样做时抛出异常。 * @return 计算得出的结果 * @throws 如果无法计算结果,则抛出异常 */ V call() throws Exception; } execute() vs submit() # execute() 和 submit()是两种提交任务到线程池的方法,有一些区别:\n返回值:execute() 方法用于提交不需要返回值的任务。通常用于执行 Runnable 任务,无法判断任务是否被线程池成功执行。submit() 方法用于提交需要返回值的任务。可以提交 Runnable 或 Callable 任务。submit() 方法返回一个 Future 对象,通过这个 Future 对象可以判断任务是否执行成功,并获取任务的返回值(get()方法会阻塞当前线程直到任务完成, get(long timeout,TimeUnit unit)多了一个超时时间,如果在 timeout 时间内任务还没有执行完,就会抛出 java.util.concurrent.TimeoutException)。 异常处理:在使用 submit() 方法时,可以通过 Future 对象处理任务执行过程中抛出的异常;而在使用 execute() 方法时,异常处理需要通过自定义的 ThreadFactory (在线程工厂创建线程的时候设置UncaughtExceptionHandler对象来 处理异常)或 ThreadPoolExecutor 的 afterExecute() 方法来处理 示例 1:使用 get()方法获取返回值。\n// 这里只是为了演示使用,推荐使用 `ThreadPoolExecutor` 构造方法来创建线程池。 ExecutorService executorService = Executors.newFixedThreadPool(3); Future\u0026lt;String\u0026gt; submit = executorService.submit(() -\u0026gt; { try { Thread.sleep(5000L); } catch (InterruptedException e) { e.printStackTrace(); } return \u0026#34;abc\u0026#34;; }); String s = submit.get(); System.out.println(s); executorService.shutdown(); 输出:\nabc 示例 2:使用 get(long timeout,TimeUnit unit)方法获取返回值。\nExecutorService executorService = Executors.newFixedThreadPool(3); Future\u0026lt;String\u0026gt; submit = executorService.submit(() -\u0026gt; { try { Thread.sleep(5000L); } catch (InterruptedException e) { e.printStackTrace(); } return \u0026#34;abc\u0026#34;; }); String s = submit.get(3, TimeUnit.SECONDS); System.out.println(s); executorService.shutdown(); 输出:\nException in thread \u0026#34;main\u0026#34; java.util.concurrent.TimeoutException at java.util.concurrent.FutureTask.get(FutureTask.java:205) shutdown()VSshutdownNow() # shutdown() :关闭线程池,线程池的状态变为 SHUTDOWN。线程池不再接受新任务了,但是队列里的任务得执行完毕。 shutdownNow() :关闭线程池,线程池的状态变为 STOP。线程池会终止当前正在运行的任务,并停止处理排队的任务并返回正在等待执行的 List。 isTerminated() VS isShutdown() # isShutDown 当调用 shutdown() 方法后返回为 true。 isTerminated 当调用 shutdown() 方法后,并且所有提交的任务完成后返回为 true 几种常见的内置线程池 # FixedThreadPool # 介绍 # FixedThreadPool 被称为可重用固定线程数的线程池。通过 Executors 类中的相关源代码来看一下相关实现:\n/** * 创建一个可重用固定数量线程的线程池 */ public static ExecutorService newFixedThreadPool(int nThreads, ThreadFactory threadFactory) { return new ThreadPoolExecutor(nThreads, nThreads, 0L, TimeUnit.MILLISECONDS, new LinkedBlockingQueue\u0026lt;Runnable\u0026gt;(), threadFactory); } 另外还有一个 FixedThreadPool 的实现方法,和上面的类似,所以这里不多做阐述:\npublic static ExecutorService newFixedThreadPool(int nThreads) { return new ThreadPoolExecutor(nThreads, nThreads, 0L, TimeUnit.MILLISECONDS, new LinkedBlockingQueue\u0026lt;Runnable\u0026gt;()); } 从上面源代码可以看出新创建的 FixedThreadPool 的 corePoolSize 和 maximumPoolSize 都被设置为 nThreads,这个 nThreads 参数是我们使用的时候自己传递的。\n即使 maximumPoolSize 的值比 corePoolSize 大,也至多只会创建 corePoolSize 个线程。这是因为FixedThreadPool 使用的是容量为 Integer.MAX_VALUE 的 LinkedBlockingQueue(无界队列),队列永远不会被放满。\n执行任务过程介绍 # FixedThreadPool 的 execute() 方法运行示意图(该图片来源:《Java 并发编程的艺术》):\n上图说明:\n如果当前运行的线程数小于 corePoolSize, 如果再来新任务的话,就创建新的线程来执行任务; 当前运行的线程数等于 corePoolSize 后, 如果再来新任务的话,会将任务加入 LinkedBlockingQueue; 线程池中的线程执行完 手头的任务后,会在循环中反复从 LinkedBlockingQueue 中获取任务来执行; 为什么不推荐使用FixedThreadPool? # FixedThreadPool 使用无界队列 LinkedBlockingQueue(队列的容量为 Integer.MAX_VALUE)作为线程池的工作队列会对线程池带来如下影响:\n当线程池中的线程数达到 corePoolSize 后,新任务将在无界队列中等待,因此线程池中的线程数不会超过 corePoolSize; 由于使用无界队列时 maximumPoolSize 将是一个无效参数,因为不可能存在任务队列满的情况。所以,通过创建 FixedThreadPool的源码可以看出创建的 FixedThreadPool 的 corePoolSize 和 maximumPoolSize 被设置为同一个值。 由于 1 和 2,使用无界队列时 keepAliveTime 将是一个无效参数; 运行中的 FixedThreadPool(未执行 shutdown()或 shutdownNow())不会拒绝任务,在任务比较多的时候会导致 OOM(内存溢出)。 SingleThreadExecutor # 介绍 # SingleThreadExecutor 是只有一个线程的线程池。下面看看SingleThreadExecutor 的实现:\n/** *返回只有一个线程的线程池 */ public static ExecutorService newSingleThreadExecutor(ThreadFactory threadFactory) { return new FinalizableDelegatedExecutorService (new ThreadPoolExecutor(1, 1, 0L, TimeUnit.MILLISECONDS, new LinkedBlockingQueue\u0026lt;Runnable\u0026gt;(), threadFactory)); } public static ExecutorService newSingleThreadExecutor() { return new FinalizableDelegatedExecutorService (new ThreadPoolExecutor(1, 1, 0L, TimeUnit.MILLISECONDS, new LinkedBlockingQueue\u0026lt;Runnable\u0026gt;())); } 从上面源代码可以看出新创建的 SingleThreadExecutor 的 corePoolSize 和 maximumPoolSize 都被设置为 1,其他参数和 FixedThreadPool 相同。\n执行任务过程介绍 # SingleThreadExecutor 的运行示意图(该图片来源:《Java 并发编程的艺术》):\n上图说明 :\n如果当前运行的线程数少于 corePoolSize,则创建一个新的线程执行任务; 当前线程池中有一个运行的线程后,将任务加入 LinkedBlockingQueue 线程执行完当前的任务后,会在循环中反复从LinkedBlockingQueue 中获取任务来执行; 为什么不推荐使用SingleThreadExecutor? # SingleThreadExecutor 和 FixedThreadPool 一样,使用的都是容量为 Integer.MAX_VALUE 的 LinkedBlockingQueue(无界队列)作为线程池的工作队列。SingleThreadExecutor 使用无界队列作为线程池的工作队列会对线程池带来的影响与 FixedThreadPool 相同。说简单点,就是可能会导致 OOM。\nCachedThreadPool # 介绍 # CachedThreadPool 是一个会根据需要创建新线程的线程池。下面通过源码来看看 CachedThreadPool 的实现:\n/** * 创建一个线程池,根据需要创建新线程,但会在先前构建的线程可用时重用它。 */ public static ExecutorService newCachedThreadPool(ThreadFactory threadFactory) { return new ThreadPoolExecutor(0, Integer.MAX_VALUE, 60L, TimeUnit.SECONDS, new SynchronousQueue\u0026lt;Runnable\u0026gt;(), threadFactory); } public static ExecutorService newCachedThreadPool() { return new ThreadPoolExecutor(0, Integer.MAX_VALUE, 60L, TimeUnit.SECONDS, new SynchronousQueue\u0026lt;Runnable\u0026gt;()); } CachedThreadPool 的corePoolSize 被设置为空(0),maximumPoolSize被设置为 Integer.MAX.VALUE,即它是无界的,这也就意味着如果主线程提交任务的速度高于 maximumPool 中线程处理任务的速度时,CachedThreadPool 会不断创建新的线程。极端情况下,这样会导致耗尽 cpu 和内存资源。\n执行任务过程介绍 # CachedThreadPool 的 execute() 方法的执行示意图(该图片来源:《Java 并发编程的艺术》):\n上图说明:\n首先执行 SynchronousQueue.offer(Runnable task) 提交任务到任务队列。如果当前 maximumPool 中有闲线程正在执行 SynchronousQueue.poll(keepAliveTime,TimeUnit.NANOSECONDS),那么主线程执行 offer 操作与空闲线程执行的 poll 操作配对成功,主线程把任务交给空闲线程执行,execute()方法执行完成,否则执行下面的步骤 2; 当初始 maximumPool 为空,或者 maximumPool 中没有空闲线程时,将没有线程执行 SynchronousQueue.poll(keepAliveTime,TimeUnit.NANOSECONDS)。这种情况下,步骤 1 将失败,此时 CachedThreadPool 会创建新线程执行任务,execute 方法执行完成; 为什么不推荐使用CachedThreadPool? # CachedThreadPool 使用的是同步队列 SynchronousQueue, 允许创建的线程数量为 Integer.MAX_VALUE ,可能会创建大量线程,从而导致 OOM。\nScheduledThreadPool # 介绍 # ScheduledThreadPool 用来在给定的延迟后运行任务或者定期执行任务。这个在实际项目中基本不会被用到,也不推荐使用,大家只需要简单了解一下即可。\npublic static ScheduledExecutorService newScheduledThreadPool(int corePoolSize) { return new ScheduledThreadPoolExecutor(corePoolSize); } public ScheduledThreadPoolExecutor(int corePoolSize) { super(corePoolSize, Integer.MAX_VALUE, 0, NANOSECONDS, new DelayedWorkQueue()); } ScheduledThreadPool 是通过 ScheduledThreadPoolExecutor 创建的,使用的DelayedWorkQueue(延迟阻塞队列)作为线程池的任务队列。\nDelayedWorkQueue 的内部元素并不是按照放入的时间排序,而是会按照延迟的时间长短对任务进行排序,内部采用的是“堆”的数据结构,可以保证每次出队的任务都是当前队列中执行时间最靠前的。DelayedWorkQueue 添加元素满了之后会自动扩容原来容量的 1/2,即永远不会阻塞,最大扩容可达 Integer.MAX_VALUE,所以最多只能创建核心线程数的线程。\nScheduledThreadPoolExecutor 继承了 ThreadPoolExecutor,所以创建 ScheduledThreadExecutor 本质也是创建一个 ThreadPoolExecutor 线程池,只是传入的参数不相同。\npublic class ScheduledThreadPoolExecutor extends ThreadPoolExecutor implements ScheduledExecutorService ScheduledThreadPoolExecutor 和 Timer 对比 # Timer 对系统时钟的变化敏感,ScheduledThreadPoolExecutor不是; Timer 只有一个执行线程,因此长时间运行的任务可以延迟其他任务。 ScheduledThreadPoolExecutor 可以配置任意数量的线程。 此外,如果你想(通过提供 ThreadFactory),你可以完全控制创建的线程; 在TimerTask 中抛出的运行时异常会杀死一个线程,从而导致 Timer 死机即计划任务将不再运行。ScheduledThreadExecutor 不仅捕获运行时异常,还允许您在需要时处理它们(通过重写 afterExecute 方法ThreadPoolExecutor)。抛出异常的任务将被取消,但其他任务将继续运行。 关于定时任务的详细介绍,可以看这篇文章: Java 定时任务详解 。\n线程池最佳实践 # Java 线程池最佳实践这篇文章总结了一些使用线程池的时候应该注意的东西,实际项目使用线程池之前可以看看。\n参考 # 《Java 并发编程的艺术》 Java Scheduler ScheduledExecutorService ScheduledThreadPoolExecutor Example java.util.concurrent.ScheduledThreadPoolExecutor Example ThreadPoolExecutor – Java Thread Pool Example "},{"id":508,"href":"/zh/docs/technology/Interview/java/concurrent/java-thread-pool-best-practices/","title":"Java 线程池最佳实践","section":"Concurrent","content":"简单总结一下我了解的使用线程池的时候应该注意的东西,网上似乎还没有专门写这方面的文章。\n1、正确声明线程池 # 线程池必须手动通过 ThreadPoolExecutor 的构造函数来声明,避免使用Executors 类创建线程池,会有 OOM 风险。\nExecutors 返回线程池对象的弊端如下(后文会详细介绍到):\nFixedThreadPool 和 SingleThreadExecutor:使用的是有界阻塞队列 LinkedBlockingQueue,任务队列的默认长度和最大长度为 Integer.MAX_VALUE,可能堆积大量的请求,从而导致 OOM。 CachedThreadPool:使用的是同步队列 SynchronousQueue,允许创建的线程数量为 Integer.MAX_VALUE ,可能会创建大量线程,从而导致 OOM。 ScheduledThreadPool 和 SingleThreadScheduledExecutor : 使用的无界的延迟阻塞队列DelayedWorkQueue,任务队列最大长度为 Integer.MAX_VALUE,可能堆积大量的请求,从而导致 OOM。 说白了就是:使用有界队列,控制线程创建数量。\n除了避免 OOM 的原因之外,不推荐使用 Executors提供的两种快捷的线程池的原因还有:\n实际使用中需要根据自己机器的性能、业务场景来手动配置线程池的参数比如核心线程数、使用的任务队列、饱和策略等等。 我们应该显示地给我们的线程池命名,这样有助于我们定位问题。 2、监测线程池运行状态 # 你可以通过一些手段来检测线程池的运行状态比如 SpringBoot 中的 Actuator 组件。\n除此之外,我们还可以利用 ThreadPoolExecutor 的相关 API 做一个简陋的监控。从下图可以看出, ThreadPoolExecutor提供了获取线程池当前的线程数和活跃线程数、已经执行完成的任务数、正在排队中的任务数等等。\n下面是一个简单的 Demo。printThreadPoolStatus()会每隔一秒打印出线程池的线程数、活跃线程数、完成的任务数、以及队列中的任务数。\n/** * 打印线程池的状态 * * @param threadPool 线程池对象 */ public static void printThreadPoolStatus(ThreadPoolExecutor threadPool) { ScheduledExecutorService scheduledExecutorService = new ScheduledThreadPoolExecutor(1, createThreadFactory(\u0026#34;print-images/thread-pool-status\u0026#34;, false)); scheduledExecutorService.scheduleAtFixedRate(() -\u0026gt; { log.info(\u0026#34;=========================\u0026#34;); log.info(\u0026#34;ThreadPool Size: [{}]\u0026#34;, threadPool.getPoolSize()); log.info(\u0026#34;Active Threads: {}\u0026#34;, threadPool.getActiveCount()); log.info(\u0026#34;Number of Tasks : {}\u0026#34;, threadPool.getCompletedTaskCount()); log.info(\u0026#34;Number of Tasks in Queue: {}\u0026#34;, threadPool.getQueue().size()); log.info(\u0026#34;=========================\u0026#34;); }, 0, 1, TimeUnit.SECONDS); } 3、建议不同类别的业务用不同的线程池 # 很多人在实际项目中都会有类似这样的问题:我的项目中多个业务需要用到线程池,是为每个线程池都定义一个还是说定义一个公共的线程池呢?\n一般建议是不同的业务使用不同的线程池,配置线程池的时候根据当前业务的情况对当前线程池进行配置,因为不同的业务的并发以及对资源的使用情况都不同,重心优化系统性能瓶颈相关的业务。\n我们再来看一个真实的事故案例! (本案例来源自: 《线程池运用不当的一次线上事故》 ,很精彩的一个案例)\n上面的代码可能会存在死锁的情况,为什么呢?画个图给大家捋一捋。\n试想这样一种极端情况:假如我们线程池的核心线程数为 n,父任务(扣费任务)数量为 n,父任务下面有两个子任务(扣费任务下的子任务),其中一个已经执行完成,另外一个被放在了任务队列中。由于父任务把线程池核心线程资源用完,所以子任务因为无法获取到线程资源无法正常执行,一直被阻塞在队列中。父任务等待子任务执行完成,而子任务等待父任务释放线程池资源,这也就造成了 \u0026ldquo;死锁\u0026rdquo; 。\n解决方法也很简单,就是新增加一个用于执行子任务的线程池专门为其服务。\n4、别忘记给线程池命名 # 初始化线程池的时候需要显示命名(设置线程池名称前缀),有利于定位问题。\n默认情况下创建的线程名字类似 pool-1-thread-n 这样的,没有业务含义,不利于我们定位问题。\n给线程池里的线程命名通常有下面两种方式:\n1、利用 guava 的 ThreadFactoryBuilder\nThreadFactory threadFactory = new ThreadFactoryBuilder() .setNameFormat(threadNamePrefix + \u0026#34;-%d\u0026#34;) .setDaemon(true).build(); ExecutorService threadPool = new ThreadPoolExecutor(corePoolSize, maximumPoolSize, keepAliveTime, TimeUnit.MINUTES, workQueue, threadFactory) 2、自己实现 ThreadFactory。\nimport java.util.concurrent.ThreadFactory; import java.util.concurrent.atomic.AtomicInteger; /** * 线程工厂,它设置线程名称,有利于我们定位问题。 */ public final class NamingThreadFactory implements ThreadFactory { private final AtomicInteger threadNum = new AtomicInteger(); private final String name; /** * 创建一个带名字的线程池生产工厂 */ public NamingThreadFactory(String name) { this.name = name; } @Override public Thread newThread(Runnable r) { Thread t = new Thread(r); t.setName(name + \u0026#34; [#\u0026#34; + threadNum.incrementAndGet() + \u0026#34;]\u0026#34;); return t; } } 5、正确配置线程池参数 # 说到如何给线程池配置参数,美团的骚操作至今让我难忘(后面会提到)!\n我们先来看一下各种书籍和博客上一般推荐的配置线程池参数的方式,可以作为参考。\n常规操作 # 很多人甚至可能都会觉得把线程池配置过大一点比较好!我觉得这明显是有问题的。就拿我们生活中非常常见的一例子来说:并不是人多就能把事情做好,增加了沟通交流成本。你本来一件事情只需要 3 个人做,你硬是拉来了 6 个人,会提升做事效率嘛?我想并不会。 线程数量过多的影响也是和我们分配多少人做事情一样,对于多线程这个场景来说主要是增加了上下文切换 成本。不清楚什么是上下文切换的话,可以看我下面的介绍。\n上下文切换:\n多线程编程中一般线程的个数都大于 CPU 核心的个数,而一个 CPU 核心在任意时刻只能被一个线程使用,为了让这些线程都能得到有效执行,CPU 采取的策略是为每个线程分配时间片并轮转的形式。当一个线程的时间片用完的时候就会重新处于就绪状态让给其他线程使用,这个过程就属于一次上下文切换。概括来说就是:当前任务在执行完 CPU 时间片切换到另一个任务之前会先保存自己的状态,以便下次再切换回这个任务时,可以再加载这个任务的状态。任务从保存到再加载的过程就是一次上下文切换。\n上下文切换通常是计算密集型的。也就是说,它需要相当可观的处理器时间,在每秒几十上百次的切换中,每次切换都需要纳秒量级的时间。所以,上下文切换对系统来说意味着消耗大量的 CPU 时间,事实上,可能是操作系统中时间消耗最大的操作。\nLinux 相比与其他操作系统(包括其他类 Unix 系统)有很多的优点,其中有一项就是,其上下文切换和模式切换的时间消耗非常少。\n类比于现实世界中的人类通过合作做某件事情,我们可以肯定的一点是线程池大小设置过大或者过小都会有问题,合适的才是最好。\n如果我们设置的线程池数量太小的话,如果同一时间有大量任务/请求需要处理,可能会导致大量的请求/任务在任务队列中排队等待执行,甚至会出现任务队列满了之后任务/请求无法处理的情况,或者大量任务堆积在任务队列导致 OOM。这样很明显是有问题的,CPU 根本没有得到充分利用。 如果我们设置线程数量太大,大量线程可能会同时在争取 CPU 资源,这样会导致大量的上下文切换,从而增加线程的执行时间,影响了整体执行效率。 有一个简单并且适用面比较广的公式:\nCPU 密集型任务 (N): 这种任务消耗的主要是 CPU 资源,线程数应设置为 N(CPU 核心数)。由于任务主要瓶颈在于 CPU 计算能力,与核心数相等的线程数能够最大化 CPU 利用率,过多线程反而会导致竞争和上下文切换开销。 I/O 密集型任务(M * N): 这类任务大部分时间处理 I/O 交互,线程在等待 I/O 时不占用 CPU。 为了充分利用 CPU 资源,线程数可以设置为 M * N,其中 N 是 CPU 核心数,M 是一个大于 1 的倍数,建议默认设置为 2 ,具体取值取决于 I/O 等待时间和任务特点,需要通过测试和监控找到最佳平衡点。 CPU 密集型任务不再推荐 N+1,原因如下:\n\u0026ldquo;N+1\u0026rdquo; 的初衷是希望预留线程处理突发暂停,但实际上,处理缺页中断等情况仍然需要占用 CPU 核心。 CPU 密集场景下,CPU 始终是瓶颈,预留线程并不能凭空增加 CPU 处理能力,反而可能加剧竞争。 如何判断是 CPU 密集任务还是 IO 密集任务?\nCPU 密集型简单理解就是利用 CPU 计算能力的任务比如你在内存中对大量数据进行排序。但凡涉及到网络读取,文件读取这类都是 IO 密集型,这类任务的特点是 CPU 计算耗费时间相比于等待 IO 操作完成的时间来说很少,大部分时间都花在了等待 IO 操作完成上。\n🌈 拓展一下(参见: issue#1737):\n线程数更严谨的计算的方法应该是:最佳线程数 = N(CPU 核心数)∗(1+WT(线程等待时间)/ST(线程计算时间)),其中 WT(线程等待时间)=线程运行总时间 - ST(线程计算时间)。\n线程等待时间所占比例越高,需要越多线程。线程计算时间所占比例越高,需要越少线程。\n我们可以通过 JDK 自带的工具 VisualVM 来查看 WT/ST 比例。\nCPU 密集型任务的 WT/ST 接近或者等于 0,因此, 线程数可以设置为 N(CPU 核心数)∗(1+0)= N,和我们上面说的 N(CPU 核心数)+1 差不多。\nIO 密集型任务下,几乎全是线程等待时间,从理论上来说,你就可以将线程数设置为 2N(按道理来说,WT/ST 的结果应该比较大,这里选择 2N 的原因应该是为了避免创建过多线程吧)。\n注意:上面提到的公示也只是参考,实际项目不太可能直接按照公式来设置线程池参数,毕竟不同的业务场景对应的需求不同,具体还是要根据项目实际线上运行情况来动态调整。接下来介绍的美团的线程池参数动态配置这种方案就非常不错,很实用!\n美团的骚操作 # 美团技术团队在 《Java 线程池实现原理及其在美团业务中的实践》这篇文章中介绍到对线程池参数实现可自定义配置的思路和方法。\n美团技术团队的思路是主要对线程池的核心参数实现自定义可配置。这三个核心参数是:\ncorePoolSize : 核心线程数定义了最小可以同时运行的线程数量。 maximumPoolSize : 当队列中存放的任务达到队列容量的时候,当前可以同时运行的线程数量变为最大线程数。 workQueue: 当新任务来的时候会先判断当前运行的线程数量是否达到核心线程数,如果达到的话,新任务就会被存放在队列中。 为什么是这三个参数?\n我在这篇 《新手也能看懂的线程池学习总结》 中就说过这三个参数是 ThreadPoolExecutor 最重要的参数,它们基本决定了线程池对于任务的处理策略。\n如何支持参数动态配置? 且看 ThreadPoolExecutor 提供的下面这些方法。\n格外需要注意的是corePoolSize, 程序运行期间的时候,我们调用 setCorePoolSize()这个方法的话,线程池会首先判断当前工作线程数是否大于corePoolSize,如果大于的话就会回收工作线程。\n另外,你也看到了上面并没有动态指定队列长度的方法,美团的方式是自定义了一个叫做 ResizableCapacityLinkedBlockIngQueue 的队列(主要就是把LinkedBlockingQueue的 capacity 字段的 final 关键字修饰给去掉了,让它变为可变的)。\n最终实现的可动态修改线程池参数效果如下。👏👏👏\n如果我们的项目也想要实现这种效果的话,可以借助现成的开源项目:\nHippo4j:异步线程池框架,支持线程池动态变更\u0026amp;监控\u0026amp;报警,无需修改代码轻松引入。支持多种使用模式,轻松引入,致力于提高系统运行保障能力。 Dynamic TP:轻量级动态线程池,内置监控告警功能,集成三方中间件线程池管理,基于主流配置中心(已支持 Nacos、Apollo,Zookeeper、Consul、Etcd,可通过 SPI 自定义实现)。 6、别忘记关闭线程池 # 当线程池不再需要使用时,应该显式地关闭线程池,释放线程资源。\n线程池提供了两个关闭方法:\nshutdown() :关闭线程池,线程池的状态变为 SHUTDOWN。线程池不再接受新任务了,但是队列里的任务得执行完毕。 shutdownNow() :关闭线程池,线程池的状态变为 STOP。线程池会终止当前正在运行的任务,停止处理排队的任务并返回正在等待执行的 List。 调用完 shutdownNow 和 shuwdown 方法后,并不代表线程池已经完成关闭操作,它只是异步的通知线程池进行关闭处理。如果要同步等待线程池彻底关闭后才继续往下执行,需要调用awaitTermination方法进行同步等待。\n在调用 awaitTermination() 方法时,应该设置合理的超时时间,以避免程序长时间阻塞而导致性能问题。另外。由于线程池中的任务可能会被取消或抛出异常,因此在使用 awaitTermination() 方法时还需要进行异常处理。awaitTermination() 方法会抛出 InterruptedException 异常,需要捕获并处理该异常,以避免程序崩溃或者无法正常退出。\n// ... // 关闭线程池 executor.shutdown(); try { // 等待线程池关闭,最多等待5分钟 if (!executor.awaitTermination(5, TimeUnit.MINUTES)) { // 如果等待超时,则打印日志 System.err.println(\u0026#34;线程池未能在5分钟内完全关闭\u0026#34;); } } catch (InterruptedException e) { // 异常处理 } 7、线程池尽量不要放耗时任务 # 线程池本身的目的是为了提高任务执行效率,避免因频繁创建和销毁线程而带来的性能开销。如果将耗时任务提交到线程池中执行,可能会导致线程池中的线程被长时间占用,无法及时响应其他任务,甚至会导致线程池崩溃或者程序假死。\n因此,在使用线程池时,我们应该尽量避免将耗时任务提交到线程池中执行。对于一些比较耗时的操作,如网络请求、文件读写等,可以采用 CompletableFuture 等其他异步操作的方式来处理,以避免阻塞线程池中的线程。\n8、线程池使用的一些小坑 # 重复创建线程池的坑 # 线程池是可以复用的,一定不要频繁创建线程池比如一个用户请求到了就单独创建一个线程池。\n@GetMapping(\u0026#34;wrong\u0026#34;) public String wrong() throws InterruptedException { // 自定义线程池 ThreadPoolExecutor executor = new ThreadPoolExecutor(5,10,1L,TimeUnit.SECONDS,new ArrayBlockingQueue\u0026lt;\u0026gt;(100),new ThreadPoolExecutor.CallerRunsPolicy()); // 处理任务 executor.execute(() -\u0026gt; { // ...... } return \u0026#34;OK\u0026#34;; } 出现这种问题的原因还是对于线程池认识不够,需要加强线程池的基础知识。\nSpring 内部线程池的坑 # 使用 Spring 内部线程池时,一定要手动自定义线程池,配置合理的参数,不然会出现生产问题(一个请求创建一个线程)。\n@Configuration @EnableAsync public class ThreadPoolExecutorConfig { @Bean(name=\u0026#34;threadPoolExecutor\u0026#34;) public Executor threadPoolExecutor(){ ThreadPoolTaskExecutor threadPoolExecutor = new ThreadPoolTaskExecutor(); int processNum = Runtime.getRuntime().availableProcessors(); // 返回可用处理器的Java虚拟机的数量 int corePoolSize = (int) (processNum / (1 - 0.2)); int maxPoolSize = (int) (processNum / (1 - 0.5)); threadPoolExecutor.setCorePoolSize(corePoolSize); // 核心池大小 threadPoolExecutor.setMaxPoolSize(maxPoolSize); // 最大线程数 threadPoolExecutor.setQueueCapacity(maxPoolSize * 1000); // 队列程度 threadPoolExecutor.setThreadPriority(Thread.MAX_PRIORITY); threadPoolExecutor.setDaemon(false); threadPoolExecutor.setKeepAliveSeconds(300);// 线程空闲时间 threadPoolExecutor.setThreadNamePrefix(\u0026#34;test-Executor-\u0026#34;); // 线程名字前缀 return threadPoolExecutor; } } 线程池和 ThreadLocal 共用的坑 # 线程池和 ThreadLocal共用,可能会导致线程从ThreadLocal获取到的是旧值/脏数据。这是因为线程池会复用线程对象,与线程对象绑定的类的静态属性 ThreadLocal 变量也会被重用,这就导致一个线程可能获取到其他线程的ThreadLocal 值。\n不要以为代码中没有显示使用线程池就不存在线程池了,像常用的 Web 服务器 Tomcat 处理任务为了提高并发量,就使用到了线程池,并且使用的是基于原生 Java 线程池改进完善得到的自定义线程池。\n当然了,你可以将 Tomcat 设置为单线程处理任务。不过,这并不合适,会严重影响其处理任务的速度。\nserver.tomcat.max-threads=1 解决上述问题比较建议的办法是使用阿里巴巴开源的 TransmittableThreadLocal(TTL)。TransmittableThreadLocal类继承并加强了 JDK 内置的InheritableThreadLocal类,在使用线程池等会池化复用线程的执行组件情况下,提供ThreadLocal值的传递功能,解决异步执行时上下文传递的问题。\nTransmittableThreadLocal 项目地址: https://github.com/alibaba/transmittable-thread-local 。\n"},{"id":509,"href":"/zh/docs/technology/Interview/java/basis/serialization/","title":"Java 序列化详解","section":"Basis","content":" 什么是序列化和反序列化? # 如果我们需要持久化 Java 对象比如将 Java 对象保存在文件中,或者在网络传输 Java 对象,这些场景都需要用到序列化。\n简单来说:\n序列化:将数据结构或对象转换成可以存储或传输的形式,通常是二进制字节流,也可以是 JSON, XML 等文本格式 反序列化:将在序列化过程中所生成的数据转换为原始数据结构或者对象的过程 对于 Java 这种面向对象编程语言来说,我们序列化的都是对象(Object)也就是实例化后的类(Class),但是在 C++这种半面向对象的语言中,struct(结构体)定义的是数据结构类型,而 class 对应的是对象类型。\n下面是序列化和反序列化常见应用场景:\n对象在进行网络传输(比如远程方法调用 RPC 的时候)之前需要先被序列化,接收到序列化的对象之后需要再进行反序列化; 将对象存储到文件之前需要进行序列化,将对象从文件中读取出来需要进行反序列化; 将对象存储到数据库(如 Redis)之前需要用到序列化,将对象从缓存数据库中读取出来需要反序列化; 将对象存储到内存之前需要进行序列化,从内存中读取出来之后需要进行反序列化。 维基百科是如是介绍序列化的:\n序列化(serialization)在计算机科学的数据处理中,是指将数据结构或对象状态转换成可取用格式(例如存成文件,存于缓冲,或经由网络中发送),以留待后续在相同或另一台计算机环境中,能恢复原先状态的过程。依照序列化格式重新获取字节的结果时,可以利用它来产生与原始对象相同语义的副本。对于许多对象,像是使用大量引用的复杂对象,这种序列化重建的过程并不容易。面向对象中的对象序列化,并不概括之前原始对象所关系的函数。这种过程也称为对象编组(marshalling)。从一系列字节提取数据结构的反向操作,是反序列化(也称为解编组、deserialization、unmarshalling)。\n综上:序列化的主要目的是通过网络传输对象或者说是将对象存储到文件系统、数据库、内存中。\nhttps://www.corejavaguru.com/java/serialization/interview-questions-1\n序列化协议对应于 TCP/IP 4 层模型的哪一层?\n我们知道网络通信的双方必须要采用和遵守相同的协议。TCP/IP 四层模型是下面这样的,序列化协议属于哪一层呢?\n应用层 传输层 网络层 网络接口层 如上图所示,OSI 七层协议模型中,表示层做的事情主要就是对应用层的用户数据进行处理转换为二进制流。反过来的话,就是将二进制流转换成应用层的用户数据。这不就对应的是序列化和反序列化么?\n因为,OSI 七层协议模型中的应用层、表示层和会话层对应的都是 TCP/IP 四层模型中的应用层,所以序列化协议属于 TCP/IP 协议应用层的一部分。\n常见序列化协议有哪些? # JDK 自带的序列化方式一般不会用 ,因为序列化效率低并且存在安全问题。比较常用的序列化协议有 Hessian、Kryo、Protobuf、ProtoStuff,这些都是基于二进制的序列化协议。\n像 JSON 和 XML 这种属于文本类序列化方式。虽然可读性比较好,但是性能较差,一般不会选择。\nJDK 自带的序列化方式 # JDK 自带的序列化,只需实现 java.io.Serializable接口即可。\n@AllArgsConstructor @NoArgsConstructor @Getter @Builder @ToString public class RpcRequest implements Serializable { private static final long serialVersionUID = 1905122041950251207L; private String requestId; private String interfaceName; private String methodName; private Object[] parameters; private Class\u0026lt;?\u0026gt;[] paramTypes; private RpcMessageTypeEnum rpcMessageTypeEnum; } serialVersionUID 有什么作用?\n序列化号 serialVersionUID 属于版本控制的作用。反序列化时,会检查 serialVersionUID 是否和当前类的 serialVersionUID 一致。如果 serialVersionUID 不一致则会抛出 InvalidClassException 异常。强烈推荐每个序列化类都手动指定其 serialVersionUID,如果不手动指定,那么编译器会动态生成默认的 serialVersionUID。\nserialVersionUID 不是被 static 变量修饰了吗?为什么还会被“序列化”?\nstatic 修饰的变量是静态变量,位于方法区,本身是不会被序列化的。 static 变量是属于类的而不是对象。你反序列之后,static 变量的值就像是默认赋予给了对象一样,看着就像是 static 变量被序列化,实际只是假象罢了。\n🐛 修正(参见: issue#2174):static 修饰的变量是静态变量,属于类而非类的实例,本身是不会被序列化的。然而,serialVersionUID 是一个特例,serialVersionUID 的序列化做了特殊处理。当一个对象被序列化时,serialVersionUID 会被写入到序列化的二进制流中;在反序列化时,也会解析它并做一致性判断,以此来验证序列化对象的版本一致性。如果两者不匹配,反序列化过程将抛出 InvalidClassException,因为这通常意味着序列化的类的定义已经发生了更改,可能不再兼容。\n官方说明如下:\nA serializable class can declare its own serialVersionUID explicitly by declaring a field named \u0026quot;serialVersionUID\u0026quot; that must be static, final, and of type long;\n如果想显式指定 serialVersionUID ,则需要在类中使用 static 和 final 关键字来修饰一个 long 类型的变量,变量名字必须为 \u0026quot;serialVersionUID\u0026quot; 。\n也就是说,serialVersionUID 只是用来被 JVM 识别,实际并没有被序列化。\n如果有些字段不想进行序列化怎么办?\n对于不想进行序列化的变量,可以使用 transient 关键字修饰。\ntransient 关键字的作用是:阻止实例中那些用此关键字修饰的的变量序列化;当对象被反序列化时,被 transient 修饰的变量值不会被持久化和恢复。\n关于 transient 还有几点注意:\ntransient 只能修饰变量,不能修饰类和方法。 transient 修饰的变量,在反序列化后变量值将会被置成类型的默认值。例如,如果是修饰 int 类型,那么反序列后结果就是 0。 static 变量因为不属于任何对象(Object),所以无论有没有 transient 关键字修饰,均不会被序列化。 为什么不推荐使用 JDK 自带的序列化?\n我们很少或者说几乎不会直接使用 JDK 自带的序列化方式,主要原因有下面这些原因:\n不支持跨语言调用 : 如果调用的是其他语言开发的服务的时候就不支持了。 性能差:相比于其他序列化框架性能更低,主要原因是序列化之后的字节数组体积较大,导致传输成本加大。 存在安全问题:序列化和反序列化本身并不存在问题。但当输入的反序列化的数据可被用户控制,那么攻击者即可通过构造恶意输入,让反序列化产生非预期的对象,在此过程中执行构造的任意代码。相关阅读: 应用安全:JAVA 反序列化漏洞之殇 - Cryin、 Java 反序列化安全漏洞怎么回事? - Monica。 Kryo # Kryo 是一个高性能的序列化/反序列化工具,由于其变长存储特性并使用了字节码生成机制,拥有较高的运行速度和较小的字节码体积。\n另外,Kryo 已经是一种非常成熟的序列化实现了,已经在 Twitter、Groupon、Yahoo 以及多个著名开源项目(如 Hive、Storm)中广泛的使用。\nguide-rpc-framework 就是使用的 kryo 进行序列化,序列化和反序列化相关的代码如下:\n/** * Kryo serialization class, Kryo serialization efficiency is very high, but only compatible with Java language * * @author shuang.kou * @createTime 2020年05月13日 19:29:00 */ @Slf4j public class KryoSerializer implements Serializer { /** * Because Kryo is not thread safe. So, use ThreadLocal to store Kryo objects */ private final ThreadLocal\u0026lt;Kryo\u0026gt; kryoThreadLocal = ThreadLocal.withInitial(() -\u0026gt; { Kryo kryo = new Kryo(); kryo.register(RpcResponse.class); kryo.register(RpcRequest.class); return kryo; }); @Override public byte[] serialize(Object obj) { try (ByteArrayOutputStream byteArrayOutputStream = new ByteArrayOutputStream(); Output output = new Output(byteArrayOutputStream)) { Kryo kryo = kryoThreadLocal.get(); // Object-\u0026gt;byte:将对象序列化为byte数组 kryo.writeObject(output, obj); kryoThreadLocal.remove(); return output.toBytes(); } catch (Exception e) { throw new SerializeException(\u0026#34;Serialization failed\u0026#34;); } } @Override public \u0026lt;T\u0026gt; T deserialize(byte[] bytes, Class\u0026lt;T\u0026gt; clazz) { try (ByteArrayInputStream byteArrayInputStream = new ByteArrayInputStream(bytes); Input input = new Input(byteArrayInputStream)) { Kryo kryo = kryoThreadLocal.get(); // byte-\u0026gt;Object:从byte数组中反序列化出对象 Object o = kryo.readObject(input, clazz); kryoThreadLocal.remove(); return clazz.cast(o); } catch (Exception e) { throw new SerializeException(\u0026#34;Deserialization failed\u0026#34;); } } } GitHub 地址: https://github.com/EsotericSoftware/kryo 。\nProtobuf # Protobuf 出自于 Google,性能还比较优秀,也支持多种语言,同时还是跨平台的。就是在使用中过于繁琐,因为你需要自己定义 IDL 文件和生成对应的序列化代码。这样虽然不灵活,但是,另一方面导致 protobuf 没有序列化漏洞的风险。\nProtobuf 包含序列化格式的定义、各种语言的库以及一个 IDL 编译器。正常情况下你需要定义 proto 文件,然后使用 IDL 编译器编译成你需要的语言\n一个简单的 proto 文件如下:\n// protobuf的版本 syntax = \u0026#34;proto3\u0026#34;; // SearchRequest会被编译成不同的编程语言的相应对象,比如Java中的class、Go中的struct message Person { //string类型字段 string name = 1; // int 类型字段 int32 age = 2; } GitHub 地址: https://github.com/protocolbuffers/protobuf。\nProtoStuff # 由于 Protobuf 的易用性较差,它的哥哥 Protostuff 诞生了。\nprotostuff 基于 Google protobuf,但是提供了更多的功能和更简易的用法。虽然更加易用,但是不代表 ProtoStuff 性能更差。\nGitHub 地址: https://github.com/protostuff/protostuff。\nHessian # Hessian 是一个轻量级的,自定义描述的二进制 RPC 协议。Hessian 是一个比较老的序列化实现了,并且同样也是跨语言的。\nDubbo2.x 默认启用的序列化方式是 Hessian2 ,但是,Dubbo 对 Hessian2 进行了修改,不过大体结构还是差不多。\n总结 # Kryo 是专门针对 Java 语言序列化方式并且性能非常好,如果你的应用是专门针对 Java 语言的话可以考虑使用,并且 Dubbo 官网的一篇文章中提到说推荐使用 Kryo 作为生产环境的序列化方式。(文章地址: https://cn.dubbo.apache.org/zh-cn/docsv2.7/user/serialization/)。\n像 Protobuf、 ProtoStuff、hessian 这类都是跨语言的序列化方式,如果有跨语言需求的话可以考虑使用。\n除了我上面介绍到的序列化方式的话,还有像 Thrift,Avro 这些。\n"},{"id":510,"href":"/zh/docs/technology/Interview/java/basis/syntactic-sugar/","title":"Java 语法糖详解","section":"Basis","content":" 作者:Hollis\n原文: https://mp.weixin.qq.com/s/o4XdEMq1DL-nBS-f8Za5Aw\n语法糖是大厂 Java 面试常问的一个知识点。\n本文从 Java 编译原理角度,深入字节码及 class 文件,抽丝剥茧,了解 Java 中的语法糖原理及用法,帮助大家在学会如何使用 Java 语法糖的同时,了解这些语法糖背后的原理。\n什么是语法糖? # 语法糖(Syntactic Sugar) 也称糖衣语法,是英国计算机学家 Peter.J.Landin 发明的一个术语,指在计算机语言中添加的某种语法,这种语法对语言的功能并没有影响,但是更方便程序员使用。简而言之,语法糖让程序更加简洁,有更高的可读性。\n有意思的是,在编程领域,除了语法糖,还有语法盐和语法糖精的说法,篇幅有限这里不做扩展了。\n我们所熟知的编程语言中几乎都有语法糖。作者认为,语法糖的多少是评判一个语言够不够牛逼的标准之一。很多人说 Java 是一个“低糖语言”,其实从 Java 7 开始 Java 语言层面上一直在添加各种糖,主要是在“Project Coin”项目下研发。尽管现在 Java 有人还是认为现在的 Java 是低糖,未来还会持续向着“高糖”的方向发展。\nJava 中有哪些常见的语法糖? # 前面提到过,语法糖的存在主要是方便开发人员使用。但其实, Java 虚拟机并不支持这些语法糖。这些语法糖在编译阶段就会被还原成简单的基础语法结构,这个过程就是解语法糖。\n说到编译,大家肯定都知道,Java 语言中,javac命令可以将后缀名为.java的源文件编译为后缀名为.class的可以运行于 Java 虚拟机的字节码。如果你去看com.sun.tools.javac.main.JavaCompiler的源码,你会发现在compile()中有一个步骤就是调用desugar(),这个方法就是负责解语法糖的实现的。\nJava 中最常用的语法糖主要有泛型、变长参数、条件编译、自动拆装箱、内部类等。本文主要来分析下这些语法糖背后的原理。一步一步剥去糖衣,看看其本质。\n我们这里会用到 反编译,你可以通过 Decompilers online 对 Class 文件进行在线反编译。\nswitch 支持 String 与枚举 # 前面提到过,从 Java 7 开始,Java 语言中的语法糖在逐渐丰富,其中一个比较重要的就是 Java 7 中switch开始支持String。\n在开始之前先科普下,Java 中的switch自身原本就支持基本类型。比如int、char等。对于int类型,直接进行数值的比较。对于char类型则是比较其 ascii 码。所以,对于编译器来说,switch中其实只能使用整型,任何类型的比较都要转换成整型。比如byte。short,char(ascii 码是整型)以及int。\n那么接下来看下switch对String的支持,有以下代码:\npublic class switchDemoString { public static void main(String[] args) { String str = \u0026#34;world\u0026#34;; switch (str) { case \u0026#34;hello\u0026#34;: System.out.println(\u0026#34;hello\u0026#34;); break; case \u0026#34;world\u0026#34;: System.out.println(\u0026#34;world\u0026#34;); break; default: break; } } } 反编译后内容如下:\npublic class switchDemoString { public switchDemoString() { } public static void main(String args[]) { String str = \u0026#34;world\u0026#34;; String s; switch((s = str).hashCode()) { default: break; case 99162322: if(s.equals(\u0026#34;hello\u0026#34;)) System.out.println(\u0026#34;hello\u0026#34;); break; case 113318802: if(s.equals(\u0026#34;world\u0026#34;)) System.out.println(\u0026#34;world\u0026#34;); break; } } } 看到这个代码,你知道原来 字符串的 switch 是通过equals()和hashCode()方法来实现的。 还好hashCode()方法返回的是int,而不是long。\n仔细看下可以发现,进行switch的实际是哈希值,然后通过使用equals方法比较进行安全检查,这个检查是必要的,因为哈希可能会发生碰撞。因此它的性能是不如使用枚举进行 switch 或者使用纯整数常量,但这也不是很差。\n泛型 # 我们都知道,很多语言都是支持泛型的,但是很多人不知道的是,不同的编译器对于泛型的处理方式是不同的,通常情况下,一个编译器处理泛型有两种方式:Code specialization和Code sharing。C++和 C#是使用Code specialization的处理机制,而 Java 使用的是Code sharing的机制。\nCode sharing 方式为每个泛型类型创建唯一的字节码表示,并且将该泛型类型的实例都映射到这个唯一的字节码表示上。将多种泛型类形实例映射到唯一的字节码表示是通过类型擦除(type erasue)实现的。\n也就是说,对于 Java 虚拟机来说,他根本不认识Map\u0026lt;String, String\u0026gt; map这样的语法。需要在编译阶段通过类型擦除的方式进行解语法糖。\n类型擦除的主要过程如下:1.将所有的泛型参数用其最左边界(最顶级的父类型)类型替换。 2.移除所有的类型参数。\n以下代码:\nMap\u0026lt;String, String\u0026gt; map = new HashMap\u0026lt;String, String\u0026gt;(); map.put(\u0026#34;name\u0026#34;, \u0026#34;hollis\u0026#34;); map.put(\u0026#34;wechat\u0026#34;, \u0026#34;Hollis\u0026#34;); map.put(\u0026#34;blog\u0026#34;, \u0026#34;www.hollischuang.com\u0026#34;); 解语法糖之后会变成:\nMap map = new HashMap(); map.put(\u0026#34;name\u0026#34;, \u0026#34;hollis\u0026#34;); map.put(\u0026#34;wechat\u0026#34;, \u0026#34;Hollis\u0026#34;); map.put(\u0026#34;blog\u0026#34;, \u0026#34;www.hollischuang.com\u0026#34;); 以下代码:\npublic static \u0026lt;A extends Comparable\u0026lt;A\u0026gt;\u0026gt; A max(Collection\u0026lt;A\u0026gt; xs) { Iterator\u0026lt;A\u0026gt; xi = xs.iterator(); A w = xi.next(); while (xi.hasNext()) { A x = xi.next(); if (w.compareTo(x) \u0026lt; 0) w = x; } return w; } 类型擦除后会变成:\npublic static Comparable max(Collection xs){ Iterator xi = xs.iterator(); Comparable w = (Comparable)xi.next(); while(xi.hasNext()) { Comparable x = (Comparable)xi.next(); if(w.compareTo(x) \u0026lt; 0) w = x; } return w; } 虚拟机中没有泛型,只有普通类和普通方法,所有泛型类的类型参数在编译时都会被擦除,泛型类并没有自己独有的Class类对象。比如并不存在List\u0026lt;String\u0026gt;.class或是List\u0026lt;Integer\u0026gt;.class,而只有List.class。\n自动装箱与拆箱 # 自动装箱就是 Java 自动将原始类型值转换成对应的对象,比如将 int 的变量转换成 Integer 对象,这个过程叫做装箱,反之将 Integer 对象转换成 int 类型值,这个过程叫做拆箱。因为这里的装箱和拆箱是自动进行的非人为转换,所以就称作为自动装箱和拆箱。原始类型 byte, short, char, int, long, float, double 和 boolean 对应的封装类为 Byte, Short, Character, Integer, Long, Float, Double, Boolean。\n先来看个自动装箱的代码:\npublic static void main(String[] args) { int i = 10; Integer n = i; } 反编译后代码如下:\npublic static void main(String args[]) { int i = 10; Integer n = Integer.valueOf(i); } 再来看个自动拆箱的代码:\npublic static void main(String[] args) { Integer i = 10; int n = i; } 反编译后代码如下:\npublic static void main(String args[]) { Integer i = Integer.valueOf(10); int n = i.intValue(); } 从反编译得到内容可以看出,在装箱的时候自动调用的是Integer的valueOf(int)方法。而在拆箱的时候自动调用的是Integer的intValue方法。\n所以,装箱过程是通过调用包装器的 valueOf 方法实现的,而拆箱过程是通过调用包装器的 xxxValue 方法实现的。\n可变长参数 # 可变参数(variable arguments)是在 Java 1.5 中引入的一个特性。它允许一个方法把任意数量的值作为参数。\n看下以下可变参数代码,其中 print 方法接收可变参数:\npublic static void main(String[] args) { print(\u0026#34;Holis\u0026#34;, \u0026#34;公众号:Hollis\u0026#34;, \u0026#34;博客:www.hollischuang.com\u0026#34;, \u0026#34;QQ:907607222\u0026#34;); } public static void print(String... strs) { for (int i = 0; i \u0026lt; strs.length; i++) { System.out.println(strs[i]); } } 反编译后代码:\npublic static void main(String args[]) { print(new String[] { \u0026#34;Holis\u0026#34;, \u0026#34;\\u516C\\u4F17\\u53F7:Hollis\u0026#34;, \u0026#34;\\u535A\\u5BA2\\uFF1Awww.hollischuang.com\u0026#34;, \u0026#34;QQ\\uFF1A907607222\u0026#34; }); } public static transient void print(String strs[]) { for(int i = 0; i \u0026lt; strs.length; i++) System.out.println(strs[i]); } 从反编译后代码可以看出,可变参数在被使用的时候,他首先会创建一个数组,数组的长度就是调用该方法是传递的实参的个数,然后再把参数值全部放到这个数组当中,然后再把这个数组作为参数传递到被调用的方法中。(注:trasient 仅在修饰成员变量时有意义,此处 “修饰方法” 是由于在 javassist 中使用相同数值分别表示 trasient 以及 vararg,见 此处。)\n枚举 # Java SE5 提供了一种新的类型-Java 的枚举类型,关键字enum可以将一组具名的值的有限集合创建为一种新的类型,而这些具名的值可以作为常规的程序组件使用,这是一种非常有用的功能。\n要想看源码,首先得有一个类吧,那么枚举类型到底是什么类呢?是enum吗?答案很明显不是,enum就和class一样,只是一个关键字,他并不是一个类,那么枚举是由什么类维护的呢,我们简单的写一个枚举:\npublic enum t { SPRING,SUMMER; } 然后我们使用反编译,看看这段代码到底是怎么实现的,反编译后代码内容如下:\npublic final class T extends Enum { private T(String s, int i) { super(s, i); } public static T[] values() { T at[]; int i; T at1[]; System.arraycopy(at = ENUM$VALUES, 0, at1 = new T[i = at.length], 0, i); return at1; } public static T valueOf(String s) { return (T)Enum.valueOf(demo/T, s); } public static final T SPRING; public static final T SUMMER; private static final T ENUM$VALUES[]; static { SPRING = new T(\u0026#34;SPRING\u0026#34;, 0); SUMMER = new T(\u0026#34;SUMMER\u0026#34;, 1); ENUM$VALUES = (new T[] { SPRING, SUMMER }); } } 通过反编译后代码我们可以看到,public final class T extends Enum,说明,该类是继承了Enum类的,同时final关键字告诉我们,这个类也是不能被继承的。\n当我们使用enum来定义一个枚举类型的时候,编译器会自动帮我们创建一个final类型的类继承Enum类,所以枚举类型不能被继承。\n内部类 # 内部类又称为嵌套类,可以把内部类理解为外部类的一个普通成员。\n内部类之所以也是语法糖,是因为它仅仅是一个编译时的概念,outer.java里面定义了一个内部类inner,一旦编译成功,就会生成两个完全不同的.class文件了,分别是outer.class和outer$inner.class。所以内部类的名字完全可以和它的外部类名字相同。\npublic class OutterClass { private String userName; public String getUserName() { return userName; } public void setUserName(String userName) { this.userName = userName; } public static void main(String[] args) { } class InnerClass{ private String name; public String getName() { return name; } public void setName(String name) { this.name = name; } } } 以上代码编译后会生成两个 class 文件:OutterClass$InnerClass.class、OutterClass.class 。当我们尝试对OutterClass.class文件进行反编译的时候,命令行会打印以下内容:Parsing OutterClass.class...Parsing inner class OutterClass$InnerClass.class... Generating OutterClass.jad 。他会把两个文件全部进行反编译,然后一起生成一个OutterClass.jad文件。文件内容如下:\npublic class OutterClass { class InnerClass { public String getName() { return name; } public void setName(String name) { this.name = name; } private String name; final OutterClass this$0; InnerClass() { this.this$0 = OutterClass.this; super(); } } public OutterClass() { } public String getUserName() { return userName; } public void setUserName(String userName){ this.userName = userName; } public static void main(String args1[]) { } private String userName; } 为什么内部类可以使用外部类的 private 属性:\n我们在 InnerClass 中增加一个方法,打印外部类的 userName 属性\n//省略其他属性 public class OutterClass { private String userName; ...... class InnerClass{ ...... public void printOut(){ System.out.println(\u0026#34;Username from OutterClass:\u0026#34;+userName); } } } // 此时,使用javap -p命令对OutterClass反编译结果: public classOutterClass { private String userName; ...... static String access$000(OutterClass); } // 此时,InnerClass的反编译结果: class OutterClass$InnerClass { final OutterClass this$0; ...... public void printOut(); } 实际上,在编译完成之后,inner 实例内部会有指向 outer 实例的引用this$0,但是简单的outer.name是无法访问 private 属性的。从反编译的结果可以看到,outer 中会有一个桥方法static String access$000(OutterClass),恰好返回 String 类型,即 userName 属性。正是通过这个方法实现内部类访问外部类私有属性。所以反编译后的printOut()方法大致如下:\npublic void printOut() { System.out.println(\u0026#34;Username from OutterClass:\u0026#34; + OutterClass.access$000(this.this$0)); } 补充:\n匿名内部类、局部内部类、静态内部类也是通过桥方法来获取 private 属性。 静态内部类没有this$0的引用 匿名内部类、局部内部类通过复制使用局部变量,该变量初始化之后就不能被修改。以下是一个案例: public class OutterClass { private String userName; public void test(){ //这里i初始化为1后就不能再被修改 int i=1; class Inner{ public void printName(){ System.out.println(userName); System.out.println(i); } } } } 反编译后:\n//javap命令反编译Inner的结果 //i被复制进内部类,且为final class OutterClass$1Inner { final int val$i; final OutterClass this$0; OutterClass$1Inner(); public void printName(); } 条件编译 # —般情况下,程序中的每一行代码都要参加编译。但有时候出于对程序代码优化的考虑,希望只对其中一部分内容进行编译,此时就需要在程序中加上条件,让编译器只对满足条件的代码进行编译,将不满足条件的代码舍弃,这就是条件编译。\n如在 C 或 CPP 中,可以通过预处理语句来实现条件编译。其实在 Java 中也可实现条件编译。我们先来看一段代码:\npublic class ConditionalCompilation { public static void main(String[] args) { final boolean DEBUG = true; if(DEBUG) { System.out.println(\u0026#34;Hello, DEBUG!\u0026#34;); } final boolean ONLINE = false; if(ONLINE){ System.out.println(\u0026#34;Hello, ONLINE!\u0026#34;); } } } 反编译后代码如下:\npublic class ConditionalCompilation { public ConditionalCompilation() { } public static void main(String args[]) { boolean DEBUG = true; System.out.println(\u0026#34;Hello, DEBUG!\u0026#34;); boolean ONLINE = false; } } 首先,我们发现,在反编译后的代码中没有System.out.println(\u0026quot;Hello, ONLINE!\u0026quot;);,这其实就是条件编译。当if(ONLINE)为 false 的时候,编译器就没有对其内的代码进行编译。\n所以,Java 语法的条件编译,是通过判断条件为常量的 if 语句实现的。其原理也是 Java 语言的语法糖。根据 if 判断条件的真假,编译器直接把分支为 false 的代码块消除。通过该方式实现的条件编译,必须在方法体内实现,而无法在整个 Java 类的结构或者类的属性上进行条件编译,这与 C/C++的条件编译相比,确实更有局限性。在 Java 语言设计之初并没有引入条件编译的功能,虽有局限,但是总比没有更强。\n断言 # 在 Java 中,assert关键字是从 JAVA SE 1.4 引入的,为了避免和老版本的 Java 代码中使用了assert关键字导致错误,Java 在执行的时候默认是不启动断言检查的(这个时候,所有的断言语句都将忽略!),如果要开启断言检查,则需要用开关-enableassertions或-ea来开启。\n看一段包含断言的代码:\npublic class AssertTest { public static void main(String args[]) { int a = 1; int b = 1; assert a == b; System.out.println(\u0026#34;公众号:Hollis\u0026#34;); assert a != b : \u0026#34;Hollis\u0026#34;; System.out.println(\u0026#34;博客:www.hollischuang.com\u0026#34;); } } 反编译后代码如下:\npublic class AssertTest { public AssertTest() { } public static void main(String args[]) { int a = 1; int b = 1; if(!$assertionsDisabled \u0026amp;\u0026amp; a != b) throw new AssertionError(); System.out.println(\u0026#34;\\u516C\\u4F17\\u53F7\\uFF1AHollis\u0026#34;); if(!$assertionsDisabled \u0026amp;\u0026amp; a == b) { throw new AssertionError(\u0026#34;Hollis\u0026#34;); } else { System.out.println(\u0026#34;\\u535A\\u5BA2\\uFF1Awww.hollischuang.com\u0026#34;); return; } } static final boolean $assertionsDisabled = !com/hollis/suguar/AssertTest.desiredAssertionStatus(); } 很明显,反编译之后的代码要比我们自己的代码复杂的多。所以,使用了 assert 这个语法糖我们节省了很多代码。其实断言的底层实现就是 if 语言,如果断言结果为 true,则什么都不做,程序继续执行,如果断言结果为 false,则程序抛出 AssertError 来打断程序的执行。-enableassertions会设置$assertionsDisabled 字段的值。\n数值字面量 # 在 java 7 中,数值字面量,不管是整数还是浮点数,都允许在数字之间插入任意多个下划线。这些下划线不会对字面量的数值产生影响,目的就是方便阅读。\n比如:\npublic class Test { public static void main(String... args) { int i = 10_000; System.out.println(i); } } 反编译后:\npublic class Test { public static void main(String[] args) { int i = 10000; System.out.println(i); } } 反编译后就是把_删除了。也就是说 编译器并不认识在数字字面量中的_,需要在编译阶段把他去掉。\nfor-each # 增强 for 循环(for-each)相信大家都不陌生,日常开发经常会用到的,他会比 for 循环要少写很多代码,那么这个语法糖背后是如何实现的呢?\npublic static void main(String... args) { String[] strs = {\u0026#34;Hollis\u0026#34;, \u0026#34;公众号:Hollis\u0026#34;, \u0026#34;博客:www.hollischuang.com\u0026#34;}; for (String s : strs) { System.out.println(s); } List\u0026lt;String\u0026gt; strList = ImmutableList.of(\u0026#34;Hollis\u0026#34;, \u0026#34;公众号:Hollis\u0026#34;, \u0026#34;博客:www.hollischuang.com\u0026#34;); for (String s : strList) { System.out.println(s); } } 反编译后代码如下:\npublic static transient void main(String args[]) { String strs[] = { \u0026#34;Hollis\u0026#34;, \u0026#34;\\u516C\\u4F17\\u53F7\\uFF1AHollis\u0026#34;, \u0026#34;\\u535A\\u5BA2\\uFF1Awww.hollischuang.com\u0026#34; }; String args1[] = strs; int i = args1.length; for(int j = 0; j \u0026lt; i; j++) { String s = args1[j]; System.out.println(s); } List strList = ImmutableList.of(\u0026#34;Hollis\u0026#34;, \u0026#34;\\u516C\\u4F17\\u53F7\\uFF1AHollis\u0026#34;, \u0026#34;\\u535A\\u5BA2\\uFF1Awww.hollischuang.com\u0026#34;); String s; for(Iterator iterator = strList.iterator(); iterator.hasNext(); System.out.println(s)) s = (String)iterator.next(); } 代码很简单,for-each 的实现原理其实就是使用了普通的 for 循环和迭代器。\ntry-with-resource # Java 里,对于文件操作 IO 流、数据库连接等开销非常昂贵的资源,用完之后必须及时通过 close 方法将其关闭,否则资源会一直处于打开状态,可能会导致内存泄露等问题。\n关闭资源的常用方式就是在finally块里是释放,即调用close方法。比如,我们经常会写这样的代码:\npublic static void main(String[] args) { BufferedReader br = null; try { String line; br = new BufferedReader(new FileReader(\u0026#34;d:\\\\hollischuang.xml\u0026#34;)); while ((line = br.readLine()) != null) { System.out.println(line); } } catch (IOException e) { // handle exception } finally { try { if (br != null) { br.close(); } } catch (IOException ex) { // handle exception } } } 从 Java 7 开始,jdk 提供了一种更好的方式关闭资源,使用try-with-resources语句,改写一下上面的代码,效果如下:\npublic static void main(String... args) { try (BufferedReader br = new BufferedReader(new FileReader(\u0026#34;d:\\\\ hollischuang.xml\u0026#34;))) { String line; while ((line = br.readLine()) != null) { System.out.println(line); } } catch (IOException e) { // handle exception } } 看,这简直是一大福音啊,虽然我之前一般使用IOUtils去关闭流,并不会使用在finally中写很多代码的方式,但是这种新的语法糖看上去好像优雅很多呢。看下他的背后:\npublic static transient void main(String args[]) { BufferedReader br; Throwable throwable; br = new BufferedReader(new FileReader(\u0026#34;d:\\\\ hollischuang.xml\u0026#34;)); throwable = null; String line; try { while((line = br.readLine()) != null) System.out.println(line); } catch(Throwable throwable2) { throwable = throwable2; throw throwable2; } if(br != null) if(throwable != null) try { br.close(); } catch(Throwable throwable1) { throwable.addSuppressed(throwable1); } else br.close(); break MISSING_BLOCK_LABEL_113; Exception exception; exception; if(br != null) if(throwable != null) try { br.close(); } catch(Throwable throwable3) { throwable.addSuppressed(throwable3); } else br.close(); throw exception; IOException ioexception; ioexception; } } 其实背后的原理也很简单,那些我们没有做的关闭资源的操作,编译器都帮我们做了。所以,再次印证了,语法糖的作用就是方便程序员的使用,但最终还是要转成编译器认识的语言。\nLambda 表达式 # 关于 lambda 表达式,有人可能会有质疑,因为网上有人说他并不是语法糖。其实我想纠正下这个说法。Lambda 表达式不是匿名内部类的语法糖,但是他也是一个语法糖。实现方式其实是依赖了几个 JVM 底层提供的 lambda 相关 api。\n先来看一个简单的 lambda 表达式。遍历一个 list:\npublic static void main(String... args) { List\u0026lt;String\u0026gt; strList = ImmutableList.of(\u0026#34;Hollis\u0026#34;, \u0026#34;公众号:Hollis\u0026#34;, \u0026#34;博客:www.hollischuang.com\u0026#34;); strList.forEach( s -\u0026gt; { System.out.println(s); } ); } 为啥说他并不是内部类的语法糖呢,前面讲内部类我们说过,内部类在编译之后会有两个 class 文件,但是,包含 lambda 表达式的类编译后只有一个文件。\n反编译后代码如下:\npublic static /* varargs */ void main(String ... args) { ImmutableList strList = ImmutableList.of((Object)\u0026#34;Hollis\u0026#34;, (Object)\u0026#34;\\u516c\\u4f17\\u53f7\\uff1aHollis\u0026#34;, (Object)\u0026#34;\\u535a\\u5ba2\\uff1awww.hollischuang.com\u0026#34;); strList.forEach((Consumer\u0026lt;String\u0026gt;)LambdaMetafactory.metafactory(null, null, null, (Ljava/lang/Object;)V, lambda$main$0(java.lang.String ), (Ljava/lang/String;)V)()); } private static /* synthetic */ void lambda$main$0(String s) { System.out.println(s); } 可以看到,在forEach方法中,其实是调用了java.lang.invoke.LambdaMetafactory#metafactory方法,该方法的第四个参数 implMethod 指定了方法实现。可以看到这里其实是调用了一个lambda$main$0方法进行了输出。\n再来看一个稍微复杂一点的,先对 List 进行过滤,然后再输出:\npublic static void main(String... args) { List\u0026lt;String\u0026gt; strList = ImmutableList.of(\u0026#34;Hollis\u0026#34;, \u0026#34;公众号:Hollis\u0026#34;, \u0026#34;博客:www.hollischuang.com\u0026#34;); List HollisList = strList.stream().filter(string -\u0026gt; string.contains(\u0026#34;Hollis\u0026#34;)).collect(Collectors.toList()); HollisList.forEach( s -\u0026gt; { System.out.println(s); } ); } 反编译后代码如下:\npublic static /* varargs */ void main(String ... args) { ImmutableList strList = ImmutableList.of((Object)\u0026#34;Hollis\u0026#34;, (Object)\u0026#34;\\u516c\\u4f17\\u53f7\\uff1aHollis\u0026#34;, (Object)\u0026#34;\\u535a\\u5ba2\\uff1awww.hollischuang.com\u0026#34;); List\u0026lt;Object\u0026gt; HollisList = strList.stream().filter((Predicate\u0026lt;String\u0026gt;)LambdaMetafactory.metafactory(null, null, null, (Ljava/lang/Object;)Z, lambda$main$0(java.lang.String ), (Ljava/lang/String;)Z)()).collect(Collectors.toList()); HollisList.forEach((Consumer\u0026lt;Object\u0026gt;)LambdaMetafactory.metafactory(null, null, null, (Ljava/lang/Object;)V, lambda$main$1(java.lang.Object ), (Ljava/lang/Object;)V)()); } private static /* synthetic */ void lambda$main$1(Object s) { System.out.println(s); } private static /* synthetic */ boolean lambda$main$0(String string) { return string.contains(\u0026#34;Hollis\u0026#34;); } 两个 lambda 表达式分别调用了lambda$main$1和lambda$main$0两个方法。\n所以,lambda 表达式的实现其实是依赖了一些底层的 api,在编译阶段,编译器会把 lambda 表达式进行解糖,转换成调用内部 api 的方式。\n可能遇到的坑 # 泛型 # 一、当泛型遇到重载\npublic class GenericTypes { public static void method(List\u0026lt;String\u0026gt; list) { System.out.println(\u0026#34;invoke method(List\u0026lt;String\u0026gt; list)\u0026#34;); } public static void method(List\u0026lt;Integer\u0026gt; list) { System.out.println(\u0026#34;invoke method(List\u0026lt;Integer\u0026gt; list)\u0026#34;); } } 上面这段代码,有两个重载的函数,因为他们的参数类型不同,一个是List\u0026lt;String\u0026gt;另一个是List\u0026lt;Integer\u0026gt; ,但是,这段代码是编译通不过的。因为我们前面讲过,参数List\u0026lt;Integer\u0026gt;和List\u0026lt;String\u0026gt;编译之后都被擦除了,变成了一样的原生类型 List,擦除动作导致这两个方法的特征签名变得一模一样。\n二、当泛型遇到 catch\n泛型的类型参数不能用在 Java 异常处理的 catch 语句中。因为异常处理是由 JVM 在运行时刻来进行的。由于类型信息被擦除,JVM 是无法区分两个异常类型MyException\u0026lt;String\u0026gt;和MyException\u0026lt;Integer\u0026gt;的\n三、当泛型内包含静态变量\npublic class StaticTest{ public static void main(String[] args){ GT\u0026lt;Integer\u0026gt; gti = new GT\u0026lt;Integer\u0026gt;(); gti.var=1; GT\u0026lt;String\u0026gt; gts = new GT\u0026lt;String\u0026gt;(); gts.var=2; System.out.println(gti.var); } } class GT\u0026lt;T\u0026gt;{ public static int var=0; public void nothing(T x){} } 以上代码输出结果为:2!\n有些同学可能会误认为泛型类是不同的类,对应不同的字节码,其实 由于经过类型擦除,所有的泛型类实例都关联到同一份字节码上,泛型类的静态变量是共享的。上面例子里的GT\u0026lt;Integer\u0026gt;.var和GT\u0026lt;String\u0026gt;.var其实是一个变量。\n自动装箱与拆箱 # 对象相等比较\npublic static void main(String[] args) { Integer a = 1000; Integer b = 1000; Integer c = 100; Integer d = 100; System.out.println(\u0026#34;a == b is \u0026#34; + (a == b)); System.out.println((\u0026#34;c == d is \u0026#34; + (c == d))); } 输出结果:\na == b is false c == d is true 在 Java 5 中,在 Integer 的操作上引入了一个新功能来节省内存和提高性能。整型对象通过使用相同的对象引用实现了缓存和重用。\n适用于整数值区间-128 至 +127。\n只适用于自动装箱。使用构造函数创建对象不适用。\n增强 for 循环 # for (Student stu : students) { if (stu.getId() == 2) students.remove(stu); } 会抛出ConcurrentModificationException异常。\nIterator 是工作在一个独立的线程中,并且拥有一个 mutex 锁。 Iterator 被创建之后会建立一个指向原来对象的单链索引表,当原来的对象数量发生变化时,这个索引表的内容不会同步改变,所以当索引指针往后移动的时候就找不到要迭代的对象,所以按照 fail-fast 原则 Iterator 会马上抛出java.util.ConcurrentModificationException异常。\n所以 Iterator 在工作的时候是不允许被迭代的对象被改变的。但你可以使用 Iterator 本身的方法remove()来删除对象,Iterator.remove() 方法会在删除当前迭代对象的同时维护索引的一致性。\n总结 # 前面介绍了 12 种 Java 中常用的语法糖。所谓语法糖就是提供给开发人员便于开发的一种语法而已。但是这种语法只有开发人员认识。要想被执行,需要进行解糖,即转成 JVM 认识的语法。当我们把语法糖解糖之后,你就会发现其实我们日常使用的这些方便的语法,其实都是一些其他更简单的语法构成的。\n有了这些语法糖,我们在日常开发的时候可以大大提升效率,但是同时也要避过度使用。使用之前最好了解下原理,避免掉坑。\n"},{"id":511,"href":"/zh/docs/technology/Interview/java/basis/why-there-only-value-passing-in-java/","title":"Java 值传递详解","section":"Basis","content":"开始之前,我们先来搞懂下面这两个概念:\n形参\u0026amp;实参 值传递\u0026amp;引用传递 形参\u0026amp;实参 # 方法的定义可能会用到 参数(有参的方法),参数在程序语言中分为:\n实参(实际参数,Arguments):用于传递给函数/方法的参数,必须有确定的值。 形参(形式参数,Parameters):用于定义函数/方法,接收实参,不需要有确定的值。 String hello = \u0026#34;Hello!\u0026#34;; // hello 为实参 sayHello(hello); // str 为形参 void sayHello(String str) { System.out.println(str); } 值传递\u0026amp;引用传递 # 程序设计语言将实参传递给方法(或函数)的方式分为两种:\n值传递:方法接收的是实参值的拷贝,会创建副本。 引用传递:方法接收的直接是实参所引用的对象在堆中的地址,不会创建副本,对形参的修改将影响到实参。 很多程序设计语言(比如 C++、 Pascal )提供了两种参数传递的方式,不过,在 Java 中只有值传递。\n为什么 Java 只有值传递? # 为什么说 Java 只有值传递呢? 不需要太多废话,我通过 3 个例子来给大家证明。\n案例 1:传递基本类型参数 # 代码:\npublic static void main(String[] args) { int num1 = 10; int num2 = 20; swap(num1, num2); System.out.println(\u0026#34;num1 = \u0026#34; + num1); System.out.println(\u0026#34;num2 = \u0026#34; + num2); } public static void swap(int a, int b) { int temp = a; a = b; b = temp; System.out.println(\u0026#34;a = \u0026#34; + a); System.out.println(\u0026#34;b = \u0026#34; + b); } 输出:\na = 20 b = 10 num1 = 10 num2 = 20 解析:\n在 swap() 方法中,a、b 的值进行交换,并不会影响到 num1、num2。因为,a、b 的值,只是从 num1、num2 的复制过来的。也就是说,a、b 相当于 num1、num2 的副本,副本的内容无论怎么修改,都不会影响到原件本身。\n通过上面例子,我们已经知道了一个方法不能修改一个基本数据类型的参数,而对象引用作为参数就不一样,请看案例 2。\n案例 2:传递引用类型参数 1 # 代码:\npublic static void main(String[] args) { int[] arr = { 1, 2, 3, 4, 5 }; System.out.println(arr[0]); change(arr); System.out.println(arr[0]); } public static void change(int[] array) { // 将数组的第一个元素变为0 array[0] = 0; } 输出:\n1 0 解析:\n看了这个案例很多人肯定觉得 Java 对引用类型的参数采用的是引用传递。\n实际上,并不是的,这里传递的还是值,不过,这个值是实参的地址罢了!\n也就是说 change 方法的参数拷贝的是 arr (实参)的地址,因此,它和 arr 指向的是同一个数组对象。这也就说明了为什么方法内部对形参的修改会影响到实参。\n为了更强有力地反驳 Java 对引用类型的参数采用的不是引用传递,我们再来看下面这个案例!\n案例 3:传递引用类型参数 2 # public class Person { private String name; // 省略构造函数、Getter\u0026amp;Setter方法 } public static void main(String[] args) { Person xiaoZhang = new Person(\u0026#34;小张\u0026#34;); Person xiaoLi = new Person(\u0026#34;小李\u0026#34;); swap(xiaoZhang, xiaoLi); System.out.println(\u0026#34;xiaoZhang:\u0026#34; + xiaoZhang.getName()); System.out.println(\u0026#34;xiaoLi:\u0026#34; + xiaoLi.getName()); } public static void swap(Person person1, Person person2) { Person temp = person1; person1 = person2; person2 = temp; System.out.println(\u0026#34;person1:\u0026#34; + person1.getName()); System.out.println(\u0026#34;person2:\u0026#34; + person2.getName()); } 输出:\nperson1:小李 person2:小张 xiaoZhang:小张 xiaoLi:小李 解析:\n怎么回事???两个引用类型的形参互换并没有影响实参啊!\nswap 方法的参数 person1 和 person2 只是拷贝的实参 xiaoZhang 和 xiaoLi 的地址。因此, person1 和 person2 的互换只是拷贝的两个地址的互换罢了,并不会影响到实参 xiaoZhang 和 xiaoLi 。\n引用传递是怎么样的? # 看到这里,相信你已经知道了 Java 中只有值传递,是没有引用传递的。 但是,引用传递到底长什么样呢?下面以 C++ 的代码为例,让你看一下引用传递的庐山真面目。\n#include \u0026lt;iostream\u0026gt; void incr(int\u0026amp; num) { std::cout \u0026lt;\u0026lt; \u0026#34;incr before: \u0026#34; \u0026lt;\u0026lt; num \u0026lt;\u0026lt; \u0026#34;\\n\u0026#34;; num++; std::cout \u0026lt;\u0026lt; \u0026#34;incr after: \u0026#34; \u0026lt;\u0026lt; num \u0026lt;\u0026lt; \u0026#34;\\n\u0026#34;; } int main() { int age = 10; std::cout \u0026lt;\u0026lt; \u0026#34;invoke before: \u0026#34; \u0026lt;\u0026lt; age \u0026lt;\u0026lt; \u0026#34;\\n\u0026#34;; incr(age); std::cout \u0026lt;\u0026lt; \u0026#34;invoke after: \u0026#34; \u0026lt;\u0026lt; age \u0026lt;\u0026lt; \u0026#34;\\n\u0026#34;; } 输出结果:\ninvoke before: 10 incr before: 10 incr after: 11 invoke after: 11 分析:可以看到,在 incr 函数中对形参的修改,可以影响到实参的值。要注意:这里的 incr 形参的数据类型用的是 int\u0026amp; 才为引用传递,如果是用 int 的话还是值传递哦!\n为什么 Java 不引入引用传递呢? # 引用传递看似很好,能在方法内就直接把实参的值修改了,但是,为什么 Java 不引入引用传递呢?\n注意:以下为个人观点看法,并非来自于 Java 官方:\n出于安全考虑,方法内部对值进行的操作,对于调用者都是未知的(把方法定义为接口,调用方不关心具体实现)。你也想象一下,如果拿着银行卡去取钱,取的是 100,扣的是 200,是不是很可怕。 Java 之父 James Gosling 在设计之初就看到了 C、C++ 的许多弊端,所以才想着去设计一门新的语言 Java。在他设计 Java 的时候就遵循了简单易用的原则,摒弃了许多开发者一不留意就会造成问题的“特性”,语言本身的东西少了,开发者要学习的东西也少了。 总结 # Java 中将实参传递给方法(或函数)的方式是 值传递:\n如果参数是基本类型的话,很简单,传递的就是基本类型的字面量值的拷贝,会创建副本。 如果参数是引用类型,传递的就是实参所引用的对象在堆中地址值的拷贝,同样也会创建副本。 参考 # 《Java 核心技术卷 Ⅰ》基础知识第十版第四章 4.5 小节 Java 到底是值传递还是引用传递? - Hollis 的回答 - 知乎 Oracle Java Tutorials - Passing Information to a Method or a Constructor Interview with James Gosling, Father of Java "},{"id":512,"href":"/zh/docs/technology/Interview/java/new-features/java8-common-new-features/","title":"Java8 新特性实战","section":"New Features","content":" 本文来自 cowbi的投稿~\nOracle 于 2014 发布了 Java8(jdk1.8),诸多原因使它成为目前市场上使用最多的 jdk 版本。虽然发布距今已将近 7 年,但很多程序员对其新特性还是不够了解,尤其是用惯了 Java8 之前版本的老程序员,比如我。\n为了不脱离队伍太远,还是有必要对这些新特性做一些总结梳理。它较 jdk.7 有很多变化或者说是优化,比如 interface 里可以有静态方法,并且可以有方法体,这一点就颠覆了之前的认知;java.util.HashMap 数据结构里增加了红黑树;还有众所周知的 Lambda 表达式等等。本文不能把所有的新特性都给大家一一分享,只列出比较常用的新特性给大家做详细讲解。更多相关内容请看 官网关于 Java8 的新特性的介绍。\nInterface # interface 的设计初衷是面向抽象,提高扩展性。这也留有一点遗憾,Interface 修改的时候,实现它的类也必须跟着改。\n为了解决接口的修改与现有的实现不兼容的问题。新 interface 的方法可以用default 或 static修饰,这样就可以有方法体,实现类也不必重写此方法。\n一个 interface 中可以有多个方法被它们修饰,这 2 个修饰符的区别主要也是普通方法和静态方法的区别。\ndefault修饰的方法,是普通实例方法,可以用this调用,可以被子类继承、重写。 static修饰的方法,使用上和一般类静态方法一样。但它不能被子类继承,只能用Interface调用。 我们来看一个实际的例子。\npublic interface InterfaceNew { static void sm() { System.out.println(\u0026#34;interface提供的方式实现\u0026#34;); } static void sm2() { System.out.println(\u0026#34;interface提供的方式实现\u0026#34;); } default void def() { System.out.println(\u0026#34;interface default方法\u0026#34;); } default void def2() { System.out.println(\u0026#34;interface default2方法\u0026#34;); } //须要实现类重写 void f(); } public interface InterfaceNew1 { default void def() { System.out.println(\u0026#34;InterfaceNew1 default方法\u0026#34;); } } 如果有一个类既实现了 InterfaceNew 接口又实现了 InterfaceNew1接口,它们都有def(),并且 InterfaceNew 接口和 InterfaceNew1接口没有继承关系的话,这时就必须重写def()。不然的话,编译的时候就会报错。\npublic class InterfaceNewImpl implements InterfaceNew , InterfaceNew1{ public static void main(String[] args) { InterfaceNewImpl interfaceNew = new InterfaceNewImpl(); interfaceNew.def(); } @Override public void def() { InterfaceNew1.super.def(); } @Override public void f() { } } 在 Java 8 ,接口和抽象类有什么区别的?\n很多小伙伴认为:“既然 interface 也可以有自己的方法实现,似乎和 abstract class 没多大区别了。”\n其实它们还是有区别的\ninterface 和 class 的区别,好像是废话,主要有:\n接口多实现,类单继承 接口的方法是 public abstract 修饰,变量是 public static final 修饰。 abstract class 可以用其他修饰符 interface 的方法是更像是一个扩展插件。而 abstract class 的方法是要继承的。\n开始我们也提到,interface 新增default和static修饰的方法,为了解决接口的修改与现有的实现不兼容的问题,并不是为了要替代abstract class。在使用上,该用 abstract class 的地方还是要用 abstract class,不要因为 interface 的新特性而将之替换。\n记住接口永远和类不一样。\nfunctional interface 函数式接口 # 定义:也称 SAM 接口,即 Single Abstract Method interfaces,有且只有一个抽象方法,但可以有多个非抽象方法的接口。\n在 java 8 中专门有一个包放函数式接口java.util.function,该包下的所有接口都有 @FunctionalInterface 注解,提供函数式编程。\n在其他包中也有函数式接口,其中一些没有@FunctionalInterface 注解,但是只要符合函数式接口的定义就是函数式接口,与是否有\n@FunctionalInterface注解无关,注解只是在编译时起到强制规范定义的作用。其在 Lambda 表达式中有广泛的应用。\nLambda 表达式 # 接下来谈众所周知的 Lambda 表达式。它是推动 Java 8 发布的最重要新特性。是继泛型(Generics)和注解(Annotation)以来最大的变化。\n使用 Lambda 表达式可以使代码变的更加简洁紧凑。让 java 也能支持简单的函数式编程。\nLambda 表达式是一个匿名函数,java 8 允许把函数作为参数传递进方法中。\n语法格式 # (parameters) -\u0026gt; expression 或 (parameters) -\u0026gt;{ statements; } Lambda 实战 # 我们用常用的实例来感受 Lambda 带来的便利\n替代匿名内部类 # 过去给方法传动态参数的唯一方法是使用内部类。比如\n1.Runnable 接口\nnew Thread(new Runnable() { @Override public void run() { System.out.println(\u0026#34;The runable now is using!\u0026#34;); } }).start(); //用lambda new Thread(() -\u0026gt; System.out.println(\u0026#34;It\u0026#39;s a lambda function!\u0026#34;)).start(); 2.Comparator 接口\nList\u0026lt;Integer\u0026gt; strings = Arrays.asList(1, 2, 3); Collections.sort(strings, new Comparator\u0026lt;Integer\u0026gt;() { @Override public int compare(Integer o1, Integer o2) { return o1 - o2;} }); //Lambda Collections.sort(strings, (Integer o1, Integer o2) -\u0026gt; o1 - o2); //分解开 Comparator\u0026lt;Integer\u0026gt; comparator = (Integer o1, Integer o2) -\u0026gt; o1 - o2; Collections.sort(strings, comparator); 3.Listener 接口\nJButton button = new JButton(); button.addItemListener(new ItemListener() { @Override public void itemStateChanged(ItemEvent e) { e.getItem(); } }); //lambda button.addItemListener(e -\u0026gt; e.getItem()); 4.自定义接口\n上面的 3 个例子是我们在开发过程中最常见的,从中也能体会到 Lambda 带来的便捷与清爽。它只保留实际用到的代码,把无用代码全部省略。那它对接口有没有要求呢?我们发现这些匿名内部类只重写了接口的一个方法,当然也只有一个方法须要重写。这就是我们上文提到的函数式接口,也就是说只要方法的参数是函数式接口都可以用 Lambda 表达式。\n@FunctionalInterface public interface Comparator\u0026lt;T\u0026gt;{} @FunctionalInterface public interface Runnable{} 我们自定义一个函数式接口\n@FunctionalInterface public interface LambdaInterface { void f(); } //使用 public class LambdaClass { public static void forEg() { lambdaInterfaceDemo(()-\u0026gt; System.out.println(\u0026#34;自定义函数式接口\u0026#34;)); } //函数式接口参数 static void lambdaInterfaceDemo(LambdaInterface i){ i.f(); } } 集合迭代 # void lamndaFor() { List\u0026lt;String\u0026gt; strings = Arrays.asList(\u0026#34;1\u0026#34;, \u0026#34;2\u0026#34;, \u0026#34;3\u0026#34;); //传统foreach for (String s : strings) { System.out.println(s); } //Lambda foreach strings.forEach((s) -\u0026gt; System.out.println(s)); //or strings.forEach(System.out::println); //map Map\u0026lt;Integer, String\u0026gt; map = new HashMap\u0026lt;\u0026gt;(); map.forEach((k,v)-\u0026gt;System.out.println(v)); } 方法的引用 # Java 8 允许使用 :: 关键字来传递方法或者构造函数引用,无论如何,表达式返回的类型必须是 functional-interface。\npublic class LambdaClassSuper { LambdaInterface sf(){ return null; } } public class LambdaClass extends LambdaClassSuper { public static LambdaInterface staticF() { return null; } public LambdaInterface f() { return null; } void show() { //1.调用静态函数,返回类型必须是functional-interface LambdaInterface t = LambdaClass::staticF; //2.实例方法调用 LambdaClass lambdaClass = new LambdaClass(); LambdaInterface lambdaInterface = lambdaClass::f; //3.超类上的方法调用 LambdaInterface superf = super::sf; //4. 构造方法调用 LambdaInterface tt = LambdaClassSuper::new; } } 访问变量 # int i = 0; Collections.sort(strings, (Integer o1, Integer o2) -\u0026gt; o1 - i); //i =3; lambda 表达式可以引用外边变量,但是该变量默认拥有 final 属性,不能被修改,如果修改,编译时就报错。\nStream # java 新增了 java.util.stream 包,它和之前的流大同小异。之前接触最多的是资源流,比如java.io.FileInputStream,通过流把文件从一个地方输入到另一个地方,它只是内容搬运工,对文件内容不做任何CRUD。\nStream依然不存储数据,不同的是它可以检索(Retrieve)和逻辑处理集合数据、包括筛选、排序、统计、计数等。可以想象成是 Sql 语句。\n它的源数据可以是 Collection、Array 等。由于它的方法参数都是函数式接口类型,所以一般和 Lambda 配合使用。\n流类型 # stream 串行流 parallelStream 并行流,可多线程执行 常用方法 # 接下来我们看java.util.stream.Stream常用方法\n/** * 返回一个串行流 */ default Stream\u0026lt;E\u0026gt; stream() /** * 返回一个并行流 */ default Stream\u0026lt;E\u0026gt; parallelStream() /** * 返回T的流 */ public static\u0026lt;T\u0026gt; Stream\u0026lt;T\u0026gt; of(T t) /** * 返回其元素是指定值的顺序流。 */ public static\u0026lt;T\u0026gt; Stream\u0026lt;T\u0026gt; of(T... values) { return Arrays.stream(values); } /** * 过滤,返回由与给定predicate匹配的该流的元素组成的流 */ Stream\u0026lt;T\u0026gt; filter(Predicate\u0026lt;? super T\u0026gt; predicate); /** * 此流的所有元素是否与提供的predicate匹配。 */ boolean allMatch(Predicate\u0026lt;? super T\u0026gt; predicate) /** * 此流任意元素是否有与提供的predicate匹配。 */ boolean anyMatch(Predicate\u0026lt;? super T\u0026gt; predicate); /** * 返回一个 Stream的构建器。 */ public static\u0026lt;T\u0026gt; Builder\u0026lt;T\u0026gt; builder(); /** * 使用 Collector对此流的元素进行归纳 */ \u0026lt;R, A\u0026gt; R collect(Collector\u0026lt;? super T, A, R\u0026gt; collector); /** * 返回此流中的元素数。 */ long count(); /** * 返回由该流的不同元素(根据 Object.equals(Object) )组成的流。 */ Stream\u0026lt;T\u0026gt; distinct(); /** * 遍历 */ void forEach(Consumer\u0026lt;? super T\u0026gt; action); /** * 用于获取指定数量的流,截短长度不能超过 maxSize 。 */ Stream\u0026lt;T\u0026gt; limit(long maxSize); /** * 用于映射每个元素到对应的结果 */ \u0026lt;R\u0026gt; Stream\u0026lt;R\u0026gt; map(Function\u0026lt;? super T, ? extends R\u0026gt; mapper); /** * 根据提供的 Comparator进行排序。 */ Stream\u0026lt;T\u0026gt; sorted(Comparator\u0026lt;? super T\u0026gt; comparator); /** * 在丢弃流的第一个 n元素后,返回由该流的 n元素组成的流。 */ Stream\u0026lt;T\u0026gt; skip(long n); /** * 返回一个包含此流的元素的数组。 */ Object[] toArray(); /** * 使用提供的 generator函数返回一个包含此流的元素的数组,以分配返回的数组,以及分区执行或调整大小可能需要的任何其他数组。 */ \u0026lt;A\u0026gt; A[] toArray(IntFunction\u0026lt;A[]\u0026gt; generator); /** * 合并流 */ public static \u0026lt;T\u0026gt; Stream\u0026lt;T\u0026gt; concat(Stream\u0026lt;? extends T\u0026gt; a, Stream\u0026lt;? extends T\u0026gt; b) 实战 # 本文列出 Stream 具有代表性的方法之使用,更多的使用方法还是要看 Api。\n@Test public void test() { List\u0026lt;String\u0026gt; strings = Arrays.asList(\u0026#34;abc\u0026#34;, \u0026#34;def\u0026#34;, \u0026#34;gkh\u0026#34;, \u0026#34;abc\u0026#34;); //返回符合条件的stream Stream\u0026lt;String\u0026gt; stringStream = strings.stream().filter(s -\u0026gt; \u0026#34;abc\u0026#34;.equals(s)); //计算流符合条件的流的数量 long count = stringStream.count(); //forEach遍历-\u0026gt;打印元素 strings.stream().forEach(System.out::println); //limit 获取到1个元素的stream Stream\u0026lt;String\u0026gt; limit = strings.stream().limit(1); //toArray 比如我们想看这个limitStream里面是什么,比如转换成String[],比如循环 String[] array = limit.toArray(String[]::new); //map 对每个元素进行操作返回新流 Stream\u0026lt;String\u0026gt; map = strings.stream().map(s -\u0026gt; s + \u0026#34;22\u0026#34;); //sorted 排序并打印 strings.stream().sorted().forEach(System.out::println); //Collectors collect 把abc放入容器中 List\u0026lt;String\u0026gt; collect = strings.stream().filter(string -\u0026gt; \u0026#34;abc\u0026#34;.equals(string)).collect(Collectors.toList()); //把list转为string,各元素用,号隔开 String mergedString = strings.stream().filter(string -\u0026gt; !string.isEmpty()).collect(Collectors.joining(\u0026#34;,\u0026#34;)); //对数组的统计,比如用 List\u0026lt;Integer\u0026gt; number = Arrays.asList(1, 2, 5, 4); IntSummaryStatistics statistics = number.stream().mapToInt((x) -\u0026gt; x).summaryStatistics(); System.out.println(\u0026#34;列表中最大的数 : \u0026#34;+statistics.getMax()); System.out.println(\u0026#34;列表中最小的数 : \u0026#34;+statistics.getMin()); System.out.println(\u0026#34;平均数 : \u0026#34;+statistics.getAverage()); System.out.println(\u0026#34;所有数之和 : \u0026#34;+statistics.getSum()); //concat 合并流 List\u0026lt;String\u0026gt; strings2 = Arrays.asList(\u0026#34;xyz\u0026#34;, \u0026#34;jqx\u0026#34;); Stream.concat(strings2.stream(),strings.stream()).count(); //注意 一个Stream只能操作一次,不能断开,否则会报错。 Stream stream = strings.stream(); //第一次使用 stream.limit(2); //第二次使用 stream.forEach(System.out::println); //报错 java.lang.IllegalStateException: stream has already been operated upon or closed //但是可以这样, 连续使用 stream.limit(2).forEach(System.out::println); } 延迟执行 # 在执行返回 Stream 的方法时,并不立刻执行,而是等返回一个非 Stream 的方法后才执行。因为拿到 Stream 并不能直接用,而是需要处理成一个常规类型。这里的 Stream 可以想象成是二进制流(2 个完全不一样的东东),拿到也看不懂。\n我们下面分解一下 filter 方法。\n@Test public void laziness(){ List\u0026lt;String\u0026gt; strings = Arrays.asList(\u0026#34;abc\u0026#34;, \u0026#34;def\u0026#34;, \u0026#34;gkh\u0026#34;, \u0026#34;abc\u0026#34;); Stream\u0026lt;Integer\u0026gt; stream = strings.stream().filter(new Predicate() { @Override public boolean test(Object o) { System.out.println(\u0026#34;Predicate.test 执行\u0026#34;); return true; } }); System.out.println(\u0026#34;count 执行\u0026#34;); stream.count(); } /*-------执行结果--------*/ count 执行 Predicate.test 执行 Predicate.test 执行 Predicate.test 执行 Predicate.test 执行 按执行顺序应该是先打印 4 次「Predicate.test 执行」,再打印「count 执行」。实际结果恰恰相反。说明 filter 中的方法并没有立刻执行,而是等调用count()方法后才执行。\n上面都是串行 Stream 的实例。并行 parallelStream 在使用方法上和串行一样。主要区别是 parallelStream 可多线程执行,是基于 ForkJoin 框架实现的,有时间大家可以了解一下 ForkJoin 框架和 ForkJoinPool。这里可以简单的理解它是通过线程池来实现的,这样就会涉及到线程安全,线程消耗等问题。下面我们通过代码来体验一下并行流的多线程执行。\n@Test public void parallelStreamTest(){ List\u0026lt;Integer\u0026gt; numbers = Arrays.asList(1, 2, 5, 4); numbers.parallelStream() .forEach(num-\u0026gt;System.out.println(Thread.currentThread().getName()+\u0026#34;\u0026gt;\u0026gt;\u0026#34;+num)); } //执行结果 main\u0026gt;\u0026gt;5 ForkJoinPool.commonPool-worker-2\u0026gt;\u0026gt;4 ForkJoinPool.commonPool-worker-11\u0026gt;\u0026gt;1 ForkJoinPool.commonPool-worker-9\u0026gt;\u0026gt;2 从结果中我们看到,for-each 用到的是多线程。\n小结 # 从源码和实例中我们可以总结出一些 stream 的特点\n通过简单的链式编程,使得它可以方便地对遍历处理后的数据进行再处理。 方法参数都是函数式接口类型 一个 Stream 只能操作一次,操作完就关闭了,继续使用这个 stream 会报错。 Stream 不保存数据,不改变数据源 Optional # 在 阿里巴巴开发手册关于 Optional 的介绍中这样写到:\n防止 NPE,是程序员的基本修养,注意 NPE 产生的场景:\n1) 返回类型为基本数据类型,return 包装数据类型的对象时,自动拆箱有可能产生 NPE。\n反例:public int f() { return Integer 对象}, 如果为 null,自动解箱抛 NPE。\n2) 数据库的查询结果可能为 null。\n3) 集合里的元素即使 isNotEmpty,取出的数据元素也可能为 null。\n4) 远程调用返回对象时,一律要求进行空指针判断,防止 NPE。\n5) 对于 Session 中获取的数据,建议进行 NPE 检查,避免空指针。\n6) 级联调用 obj.getA().getB().getC();一连串调用,易产生 NPE。\n正例:使用 JDK8 的 Optional 类来防止 NPE 问题。\n他建议使用 Optional 解决 NPE(java.lang.NullPointerException)问题,它就是为 NPE 而生的,其中可以包含空值或非空值。下面我们通过源码逐步揭开 Optional 的红盖头。\n假设有一个 Zoo 类,里面有个属性 Dog,需求要获取 Dog 的 age。\nclass Zoo { private Dog dog; } class Dog { private int age; } 传统解决 NPE 的办法如下:\nZoo zoo = getZoo(); if(zoo != null){ Dog dog = zoo.getDog(); if(dog != null){ int age = dog.getAge(); System.out.println(age); } } 层层判断对象非空,有人说这种方式很丑陋不优雅,我并不这么认为。反而觉得很整洁,易读,易懂。你们觉得呢?\nOptional 是这样的实现的:\nOptional.ofNullable(zoo).map(o -\u0026gt; o.getDog()).map(d -\u0026gt; d.getAge()).ifPresent(age -\u0026gt; System.out.println(age) ); 是不是简洁了很多呢?\n如何创建一个 Optional # 上例中Optional.ofNullable是其中一种创建 Optional 的方式。我们先看一下它的含义和其他创建 Optional 的源码方法。\n/** * Common instance for {@code empty()}. 全局EMPTY对象 */ private static final Optional\u0026lt;?\u0026gt; EMPTY = new Optional\u0026lt;\u0026gt;(); /** * Optional维护的值 */ private final T value; /** * 如果value是null就返回EMPTY,否则就返回of(T) */ public static \u0026lt;T\u0026gt; Optional\u0026lt;T\u0026gt; ofNullable(T value) { return value == null ? empty() : of(value); } /** * 返回 EMPTY 对象 */ public static\u0026lt;T\u0026gt; Optional\u0026lt;T\u0026gt; empty() { Optional\u0026lt;T\u0026gt; t = (Optional\u0026lt;T\u0026gt;) EMPTY; return t; } /** * 返回Optional对象 */ public static \u0026lt;T\u0026gt; Optional\u0026lt;T\u0026gt; of(T value) { return new Optional\u0026lt;\u0026gt;(value); } /** * 私有构造方法,给value赋值 */ private Optional(T value) { this.value = Objects.requireNonNull(value); } /** * 所以如果of(T value) 的value是null,会抛出NullPointerException异常,这样貌似就没处理NPE问题 */ public static \u0026lt;T\u0026gt; T requireNonNull(T obj) { if (obj == null) throw new NullPointerException(); return obj; } ofNullable 方法和of方法唯一区别就是当 value 为 null 时,ofNullable 返回的是EMPTY,of 会抛出 NullPointerException 异常。如果需要把 NullPointerException 暴漏出来就用 of,否则就用 ofNullable。\nmap() 和 flatMap() 有什么区别的?\nmap 和 flatMap 都是将一个函数应用于集合中的每个元素,但不同的是map返回一个新的集合,flatMap是将每个元素都映射为一个集合,最后再将这个集合展平。\n在实际应用场景中,如果map返回的是数组,那么最后得到的是一个二维数组,使用flatMap就是为了将这个二维数组展平变成一个一维数组。\npublic class MapAndFlatMapExample { public static void main(String[] args) { List\u0026lt;String[]\u0026gt; listOfArrays = Arrays.asList( new String[]{\u0026#34;apple\u0026#34;, \u0026#34;banana\u0026#34;, \u0026#34;cherry\u0026#34;}, new String[]{\u0026#34;orange\u0026#34;, \u0026#34;grape\u0026#34;, \u0026#34;pear\u0026#34;}, new String[]{\u0026#34;kiwi\u0026#34;, \u0026#34;melon\u0026#34;, \u0026#34;pineapple\u0026#34;} ); List\u0026lt;String[]\u0026gt; mapResult = listOfArrays.stream() .map(array -\u0026gt; Arrays.stream(array).map(String::toUpperCase).toArray(String[]::new)) .collect(Collectors.toList()); System.out.println(\u0026#34;Using map:\u0026#34;); mapResult.forEach(arrays-\u0026gt; System.out.println(Arrays.toString(arrays))); List\u0026lt;String\u0026gt; flatMapResult = listOfArrays.stream() .flatMap(array -\u0026gt; Arrays.stream(array).map(String::toUpperCase)) .collect(Collectors.toList()); System.out.println(\u0026#34;Using flatMap:\u0026#34;); System.out.println(flatMapResult); } } 运行结果:\nUsing map: [[APPLE, BANANA, CHERRY], [ORANGE, GRAPE, PEAR], [KIWI, MELON, PINEAPPLE]] Using flatMap: [APPLE, BANANA, CHERRY, ORANGE, GRAPE, PEAR, KIWI, MELON, PINEAPPLE] 最简单的理解就是flatMap()可以将map()的结果展开。\n在Optional里面,当使用map()时,如果映射函数返回的是一个普通值,它会将这个值包装在一个新的Optional中。而使用flatMap时,如果映射函数返回的是一个Optional,它会将这个返回的Optional展平,不再包装成嵌套的Optional。\n下面是一个对比的示例代码:\npublic static void main(String[] args) { int userId = 1; // 使用flatMap的代码 String cityUsingFlatMap = getUserById(userId) .flatMap(OptionalExample::getAddressByUser) .map(Address::getCity) .orElse(\u0026#34;Unknown\u0026#34;); System.out.println(\u0026#34;User\u0026#39;s city using flatMap: \u0026#34; + cityUsingFlatMap); // 不使用flatMap的代码 Optional\u0026lt;Optional\u0026lt;Address\u0026gt;\u0026gt; optionalAddress = getUserById(userId) .map(OptionalExample::getAddressByUser); String cityWithoutFlatMap; if (optionalAddress.isPresent()) { Optional\u0026lt;Address\u0026gt; addressOptional = optionalAddress.get(); if (addressOptional.isPresent()) { Address address = addressOptional.get(); cityWithoutFlatMap = address.getCity(); } else { cityWithoutFlatMap = \u0026#34;Unknown\u0026#34;; } } else { cityWithoutFlatMap = \u0026#34;Unknown\u0026#34;; } System.out.println(\u0026#34;User\u0026#39;s city without flatMap: \u0026#34; + cityWithoutFlatMap); } 在Stream和Optional中正确使用flatMap可以减少很多不必要的代码。\n判断 value 是否为 null # /** * value是否为null */ public boolean isPresent() { return value != null; } /** * 如果value不为null执行consumer.accept */ public void ifPresent(Consumer\u0026lt;? super T\u0026gt; consumer) { if (value != null) consumer.accept(value); } 获取 value # /** * Return the value if present, otherwise invoke {@code other} and return * the result of that invocation. * 如果value != null 返回value,否则返回other的执行结果 */ public T orElseGet(Supplier\u0026lt;? extends T\u0026gt; other) { return value != null ? value : other.get(); } /** * 如果value != null 返回value,否则返回T */ public T orElse(T other) { return value != null ? value : other; } /** * 如果value != null 返回value,否则抛出参数返回的异常 */ public \u0026lt;X extends Throwable\u0026gt; T orElseThrow(Supplier\u0026lt;? extends X\u0026gt; exceptionSupplier) throws X { if (value != null) { return value; } else { throw exceptionSupplier.get(); } } /** * value为null抛出NoSuchElementException,不为空返回value。 */ public T get() { if (value == null) { throw new NoSuchElementException(\u0026#34;No value present\u0026#34;); } return value; } 过滤值 # /** * 1. 如果是empty返回empty * 2. predicate.test(value)==true 返回this,否则返回empty */ public Optional\u0026lt;T\u0026gt; filter(Predicate\u0026lt;? super T\u0026gt; predicate) { Objects.requireNonNull(predicate); if (!isPresent()) return this; else return predicate.test(value) ? this : empty(); } 小结 # 看完 Optional 源码,Optional 的方法真的非常简单,值得注意的是如果坚决不想看见 NPE,就不要用 of()、 get()、flatMap(..)。最后再综合用一下 Optional 的高频方法。\nOptional.ofNullable(zoo).map(o -\u0026gt; o.getDog()).map(d -\u0026gt; d.getAge()).filter(v-\u0026gt;v==1).orElse(3); Date-Time API # 这是对java.util.Date强有力的补充,解决了 Date 类的大部分痛点:\n非线程安全 时区处理麻烦 各种格式化、和时间计算繁琐 设计有缺陷,Date 类同时包含日期和时间;还有一个 java.sql.Date,容易混淆。 我们从常用的时间实例来对比 java.util.Date 和新 Date 有什么区别。用java.util.Date的代码该改改了。\njava.time 主要类 # java.util.Date 既包含日期又包含时间,而 java.time 把它们进行了分离\nLocalDateTime.class //日期+时间 format: yyyy-MM-ddTHH:mm:ss.SSS LocalDate.class //日期 format: yyyy-MM-dd LocalTime.class //时间 format: HH:mm:ss 格式化 # Java 8 之前:\npublic void oldFormat(){ Date now = new Date(); //format yyyy-MM-dd SimpleDateFormat sdf = new SimpleDateFormat(\u0026#34;yyyy-MM-dd\u0026#34;); String date = sdf.format(now); System.out.println(String.format(\u0026#34;date format : %s\u0026#34;, date)); //format HH:mm:ss SimpleDateFormat sdft = new SimpleDateFormat(\u0026#34;HH:mm:ss\u0026#34;); String time = sdft.format(now); System.out.println(String.format(\u0026#34;time format : %s\u0026#34;, time)); //format yyyy-MM-dd HH:mm:ss SimpleDateFormat sdfdt = new SimpleDateFormat(\u0026#34;yyyy-MM-dd HH:mm:ss\u0026#34;); String datetime = sdfdt.format(now); System.out.println(String.format(\u0026#34;dateTime format : %s\u0026#34;, datetime)); } Java 8 之后:\npublic void newFormat(){ //format yyyy-MM-dd LocalDate date = LocalDate.now(); System.out.println(String.format(\u0026#34;date format : %s\u0026#34;, date)); //format HH:mm:ss LocalTime time = LocalTime.now().withNano(0); System.out.println(String.format(\u0026#34;time format : %s\u0026#34;, time)); //format yyyy-MM-dd HH:mm:ss LocalDateTime dateTime = LocalDateTime.now(); DateTimeFormatter dateTimeFormatter = DateTimeFormatter.ofPattern(\u0026#34;yyyy-MM-dd HH:mm:ss\u0026#34;); String dateTimeStr = dateTime.format(dateTimeFormatter); System.out.println(String.format(\u0026#34;dateTime format : %s\u0026#34;, dateTimeStr)); } 字符串转日期格式 # Java 8 之前:\n//已弃用 Date date = new Date(\u0026#34;2021-01-26\u0026#34;); //替换为 SimpleDateFormat sdf = new SimpleDateFormat(\u0026#34;yyyy-MM-dd\u0026#34;); Date date1 = sdf.parse(\u0026#34;2021-01-26\u0026#34;); Java 8 之后:\nLocalDate date = LocalDate.of(2021, 1, 26); LocalDate.parse(\u0026#34;2021-01-26\u0026#34;); LocalDateTime dateTime = LocalDateTime.of(2021, 1, 26, 12, 12, 22); LocalDateTime.parse(\u0026#34;2021-01-26 12:12:22\u0026#34;); LocalTime time = LocalTime.of(12, 12, 22); LocalTime.parse(\u0026#34;12:12:22\u0026#34;); Java 8 之前 转换都需要借助 SimpleDateFormat 类,而Java 8 之后只需要 LocalDate、LocalTime、LocalDateTime的 of 或 parse 方法。\n日期计算 # 下面仅以一周后日期为例,其他单位(年、月、日、1/2 日、时等等)大同小异。另外,这些单位都在 java.time.temporal.ChronoUnit 枚举中定义。\nJava 8 之前:\npublic void afterDay(){ //一周后的日期 SimpleDateFormat formatDate = new SimpleDateFormat(\u0026#34;yyyy-MM-dd\u0026#34;); Calendar ca = Calendar.getInstance(); ca.add(Calendar.DATE, 7); Date d = ca.getTime(); String after = formatDate.format(d); System.out.println(\u0026#34;一周后日期:\u0026#34; + after); //算两个日期间隔多少天,计算间隔多少年,多少月方法类似 String dates1 = \u0026#34;2021-12-23\u0026#34;; String dates2 = \u0026#34;2021-02-26\u0026#34;; SimpleDateFormat format = new SimpleDateFormat(\u0026#34;yyyy-MM-dd\u0026#34;); Date date1 = format.parse(dates1); Date date2 = format.parse(dates2); int day = (int) ((date1.getTime() - date2.getTime()) / (1000 * 3600 * 24)); System.out.println(dates1 + \u0026#34;和\u0026#34; + dates2 + \u0026#34;相差\u0026#34; + day + \u0026#34;天\u0026#34;); //结果:2021-02-26和2021-12-23相差300天 } Java 8 之后:\npublic void pushWeek(){ //一周后的日期 LocalDate localDate = LocalDate.now(); //方法1 LocalDate after = localDate.plus(1, ChronoUnit.WEEKS); //方法2 LocalDate after2 = localDate.plusWeeks(1); System.out.println(\u0026#34;一周后日期:\u0026#34; + after); //算两个日期间隔多少天,计算间隔多少年,多少月 LocalDate date1 = LocalDate.parse(\u0026#34;2021-02-26\u0026#34;); LocalDate date2 = LocalDate.parse(\u0026#34;2021-12-23\u0026#34;); Period period = Period.between(date1, date2); System.out.println(\u0026#34;date1 到 date2 相隔:\u0026#34; + period.getYears() + \u0026#34;年\u0026#34; + period.getMonths() + \u0026#34;月\u0026#34; + period.getDays() + \u0026#34;天\u0026#34;); //打印结果是 “date1 到 date2 相隔:0年9月27天” //这里period.getDays()得到的天是抛去年月以外的天数,并不是总天数 //如果要获取纯粹的总天数应该用下面的方法 long day = date2.toEpochDay() - date1.toEpochDay(); System.out.println(date1 + \u0026#34;和\u0026#34; + date2 + \u0026#34;相差\u0026#34; + day + \u0026#34;天\u0026#34;); //打印结果:2021-02-26和2021-12-23相差300天 } 获取指定日期 # 除了日期计算繁琐,获取特定一个日期也很麻烦,比如获取本月最后一天,第一天。\nJava 8 之前:\npublic void getDay() { SimpleDateFormat format = new SimpleDateFormat(\u0026#34;yyyy-MM-dd\u0026#34;); //获取当前月第一天: Calendar c = Calendar.getInstance(); c.set(Calendar.DAY_OF_MONTH, 1); String first = format.format(c.getTime()); System.out.println(\u0026#34;first day:\u0026#34; + first); //获取当前月最后一天 Calendar ca = Calendar.getInstance(); ca.set(Calendar.DAY_OF_MONTH, ca.getActualMaximum(Calendar.DAY_OF_MONTH)); String last = format.format(ca.getTime()); System.out.println(\u0026#34;last day:\u0026#34; + last); //当年最后一天 Calendar currCal = Calendar.getInstance(); Calendar calendar = Calendar.getInstance(); calendar.clear(); calendar.set(Calendar.YEAR, currCal.get(Calendar.YEAR)); calendar.roll(Calendar.DAY_OF_YEAR, -1); Date time = calendar.getTime(); System.out.println(\u0026#34;last day:\u0026#34; + format.format(time)); } Java 8 之后:\npublic void getDayNew() { LocalDate today = LocalDate.now(); //获取当前月第一天: LocalDate firstDayOfThisMonth = today.with(TemporalAdjusters.firstDayOfMonth()); // 取本月最后一天 LocalDate lastDayOfThisMonth = today.with(TemporalAdjusters.lastDayOfMonth()); //取下一天: LocalDate nextDay = lastDayOfThisMonth.plusDays(1); //当年最后一天 LocalDate lastday = today.with(TemporalAdjusters.lastDayOfYear()); //2021年最后一个周日,如果用Calendar是不得烦死。 LocalDate lastMondayOf2021 = LocalDate.parse(\u0026#34;2021-12-31\u0026#34;).with(TemporalAdjusters.lastInMonth(DayOfWeek.SUNDAY)); } java.time.temporal.TemporalAdjusters 里面还有很多便捷的算法,这里就不带大家看 Api 了,都很简单,看了秒懂。\nJDBC 和 java8 # 现在 jdbc 时间类型和 java8 时间类型对应关系是\nDate \u0026mdash;\u0026gt; LocalDate Time \u0026mdash;\u0026gt; LocalTime Timestamp \u0026mdash;\u0026gt; LocalDateTime 而之前统统对应 Date,也只有 Date。\n时区 # 时区:正式的时区划分为每隔经度 15° 划分一个时区,全球共 24 个时区,每个时区相差 1 小时。但为了行政上的方便,常将 1 个国家或 1 个省份划在一起,比如我国幅员宽广,大概横跨 5 个时区,实际上只用东八时区的标准时即北京时间为准。\njava.util.Date 对象实质上存的是 1970 年 1 月 1 日 0 点( GMT)至 Date 对象所表示时刻所经过的毫秒数。也就是说不管在哪个时区 new Date,它记录的毫秒数都一样,和时区无关。但在使用上应该把它转换成当地时间,这就涉及到了时间的国际化。java.util.Date 本身并不支持国际化,需要借助 TimeZone。\n//北京时间:Wed Jan 27 14:05:29 CST 2021 Date date = new Date(); SimpleDateFormat bjSdf = new SimpleDateFormat(\u0026#34;yyyy-MM-dd HH:mm:ss\u0026#34;); //北京时区 bjSdf.setTimeZone(TimeZone.getTimeZone(\u0026#34;Asia/Shanghai\u0026#34;)); System.out.println(\u0026#34;毫秒数:\u0026#34; + date.getTime() + \u0026#34;, 北京时间:\u0026#34; + bjSdf.format(date)); //东京时区 SimpleDateFormat tokyoSdf = new SimpleDateFormat(\u0026#34;yyyy-MM-dd HH:mm:ss\u0026#34;); tokyoSdf.setTimeZone(TimeZone.getTimeZone(\u0026#34;Asia/Tokyo\u0026#34;)); // 设置东京时区 System.out.println(\u0026#34;毫秒数:\u0026#34; + date.getTime() + \u0026#34;, 东京时间:\u0026#34; + tokyoSdf.format(date)); //如果直接print会自动转成当前时区的时间 System.out.println(date); //Wed Jan 27 14:05:29 CST 2021 在新特性中引入了 java.time.ZonedDateTime 来表示带时区的时间。它可以看成是 LocalDateTime + ZoneId。\n//当前时区时间 ZonedDateTime zonedDateTime = ZonedDateTime.now(); System.out.println(\u0026#34;当前时区时间: \u0026#34; + zonedDateTime); //东京时间 ZoneId zoneId = ZoneId.of(ZoneId.SHORT_IDS.get(\u0026#34;JST\u0026#34;)); ZonedDateTime tokyoTime = zonedDateTime.withZoneSameInstant(zoneId); System.out.println(\u0026#34;东京时间: \u0026#34; + tokyoTime); // ZonedDateTime 转 LocalDateTime LocalDateTime localDateTime = tokyoTime.toLocalDateTime(); System.out.println(\u0026#34;东京时间转当地时间: \u0026#34; + localDateTime); //LocalDateTime 转 ZonedDateTime ZonedDateTime localZoned = localDateTime.atZone(ZoneId.systemDefault()); System.out.println(\u0026#34;本地时区时间: \u0026#34; + localZoned); //打印结果 当前时区时间: 2021-01-27T14:43:58.735+08:00[Asia/Shanghai] 东京时间: 2021-01-27T15:43:58.735+09:00[Asia/Tokyo] 东京时间转当地时间: 2021-01-27T15:43:58.735 当地时区时间: 2021-01-27T15:53:35.618+08:00[Asia/Shanghai] 小结 # 通过上面比较新老 Date 的不同,当然只列出部分功能上的区别,更多功能还得自己去挖掘。总之 date-time-api 给日期操作带来了福利。在日常工作中遇到 date 类型的操作,第一考虑的是 date-time-api,实在解决不了再考虑老的 Date。\n总结 # 我们梳理总结的 java 8 新特性有\nInterface \u0026amp; functional Interface Lambda Stream Optional Date time-api 这些都是开发当中比较常用的特性。梳理下来发现它们真香,而我却没有更早的应用。总觉得学习 java 8 新特性比较麻烦,一直使用老的实现方式。其实这些新特性几天就可以掌握,一但掌握,效率会有很大的提高。其实我们涨工资也是涨的学习的钱,不学习终究会被淘汰,35 岁危机会提前来临。\n"},{"id":513,"href":"/zh/docs/technology/Interview/java/concurrent/java-concurrent-questions-01/","title":"Java并发常见面试题总结(上)","section":"Concurrent","content":" 线程 # ⭐️什么是线程和进程? # 何为进程? # 进程是程序的一次执行过程,是系统运行程序的基本单位,因此进程是动态的。系统运行一个程序即是一个进程从创建,运行到消亡的过程。\n在 Java 中,当我们启动 main 函数时其实就是启动了一个 JVM 的进程,而 main 函数所在的线程就是这个进程中的一个线程,也称主线程。\n如下图所示,在 Windows 中通过查看任务管理器的方式,我们就可以清楚看到 Windows 当前运行的进程(.exe 文件的运行)。\n何为线程? # 线程与进程相似,但线程是一个比进程更小的执行单位。一个进程在其执行的过程中可以产生多个线程。与进程不同的是同类的多个线程共享进程的堆和方法区资源,但每个线程有自己的程序计数器、虚拟机栈和本地方法栈,所以系统在产生一个线程,或是在各个线程之间做切换工作时,负担要比进程小得多,也正因为如此,线程也被称为轻量级进程。\nJava 程序天生就是多线程程序,我们可以通过 JMX 来看看一个普通的 Java 程序有哪些线程,代码如下。\npublic class MultiThread { public static void main(String[] args) { // 获取 Java 线程管理 MXBean ThreadMXBean threadMXBean = ManagementFactory.getThreadMXBean(); // 不需要获取同步的 monitor 和 synchronizer 信息,仅获取线程和线程堆栈信息 ThreadInfo[] threadInfos = threadMXBean.dumpAllThreads(false, false); // 遍历线程信息,仅打印线程 ID 和线程名称信息 for (ThreadInfo threadInfo : threadInfos) { System.out.println(\u0026#34;[\u0026#34; + threadInfo.getThreadId() + \u0026#34;] \u0026#34; + threadInfo.getThreadName()); } } } 上述程序输出如下(输出内容可能不同,不用太纠结下面每个线程的作用,只用知道 main 线程执行 main 方法即可):\n[5] Attach Listener //添加事件 [4] Signal Dispatcher // 分发处理给 JVM 信号的线程 [3] Finalizer //调用对象 finalize 方法的线程 [2] Reference Handler //清除 reference 线程 [1] main //main 线程,程序入口 从上面的输出内容可以看出:一个 Java 程序的运行是 main 线程和多个其他线程同时运行。\nJava 线程和操作系统的线程有啥区别? # JDK 1.2 之前,Java 线程是基于绿色线程(Green Threads)实现的,这是一种用户级线程(用户线程),也就是说 JVM 自己模拟了多线程的运行,而不依赖于操作系统。由于绿色线程和原生线程比起来在使用时有一些限制(比如绿色线程不能直接使用操作系统提供的功能如异步 I/O、只能在一个内核线程上运行无法利用多核),在 JDK 1.2 及以后,Java 线程改为基于原生线程(Native Threads)实现,也就是说 JVM 直接使用操作系统原生的内核级线程(内核线程)来实现 Java 线程,由操作系统内核进行线程的调度和管理。\n我们上面提到了用户线程和内核线程,考虑到很多读者不太了解二者的区别,这里简单介绍一下:\n用户线程:由用户空间程序管理和调度的线程,运行在用户空间(专门给应用程序使用)。 内核线程:由操作系统内核管理和调度的线程,运行在内核空间(只有内核程序可以访问)。 顺便简单总结一下用户线程和内核线程的区别和特点:用户线程创建和切换成本低,但不可以利用多核。内核态线程,创建和切换成本高,可以利用多核。\n一句话概括 Java 线程和操作系统线程的关系:现在的 Java 线程的本质其实就是操作系统的线程。\n线程模型是用户线程和内核线程之间的关联方式,常见的线程模型有这三种:\n一对一(一个用户线程对应一个内核线程) 多对一(多个用户线程映射到一个内核线程) 多对多(多个用户线程映射到多个内核线程) 在 Windows 和 Linux 等主流操作系统中,Java 线程采用的是一对一的线程模型,也就是一个 Java 线程对应一个系统内核线程。Solaris 系统是一个特例(Solaris 系统本身就支持多对多的线程模型),HotSpot VM 在 Solaris 上支持多对多和一对一。具体可以参考 R 大的回答: JVM 中的线程模型是用户级的么?。\n⭐️请简要描述线程与进程的关系,区别及优缺点? # 下图是 Java 内存区域,通过下图我们从 JVM 的角度来说一下线程和进程之间的关系。\n从上图可以看出:一个进程中可以有多个线程,多个线程共享进程的堆和方法区 (JDK1.8 之后的元空间)资源,但是每个线程有自己的程序计数器、虚拟机栈 和 本地方法栈。\n总结: 线程是进程划分成的更小的运行单位。线程和进程最大的不同在于基本上各进程是独立的,而各线程则不一定,因为同一进程中的线程极有可能会相互影响。线程执行开销小,但不利于资源的管理和保护;而进程正相反。\n下面是该知识点的扩展内容!\n下面来思考这样一个问题:为什么程序计数器、虚拟机栈和本地方法栈是线程私有的呢?为什么堆和方法区是线程共享的呢?\n程序计数器为什么是私有的? # 程序计数器主要有下面两个作用:\n字节码解释器通过改变程序计数器来依次读取指令,从而实现代码的流程控制,如:顺序执行、选择、循环、异常处理。 在多线程的情况下,程序计数器用于记录当前线程执行的位置,从而当线程被切换回来的时候能够知道该线程上次运行到哪儿了。 需要注意的是,如果执行的是 native 方法,那么程序计数器记录的是 undefined 地址,只有执行的是 Java 代码时程序计数器记录的才是下一条指令的地址。\n所以,程序计数器私有主要是为了线程切换后能恢复到正确的执行位置。\n虚拟机栈和本地方法栈为什么是私有的? # 虚拟机栈: 每个 Java 方法在执行之前会创建一个栈帧用于存储局部变量表、操作数栈、常量池引用等信息。从方法调用直至执行完成的过程,就对应着一个栈帧在 Java 虚拟机栈中入栈和出栈的过程。 本地方法栈: 和虚拟机栈所发挥的作用非常相似,区别是:虚拟机栈为虚拟机执行 Java 方法 (也就是字节码)服务,而本地方法栈则为虚拟机使用到的 Native 方法服务。 在 HotSpot 虚拟机中和 Java 虚拟机栈合二为一。 所以,为了保证线程中的局部变量不被别的线程访问到,虚拟机栈和本地方法栈是线程私有的。\n一句话简单了解堆和方法区 # 堆和方法区是所有线程共享的资源,其中堆是进程中最大的一块内存,主要用于存放新创建的对象 (几乎所有对象都在这里分配内存),方法区主要用于存放已被加载的类信息、常量、静态变量、即时编译器编译后的代码等数据。\n如何创建线程? # 一般来说,创建线程有很多种方式,例如继承Thread类、实现Runnable接口、实现Callable接口、使用线程池、使用CompletableFuture类等等。\n不过,这些方式其实并没有真正创建出线程。准确点来说,这些都属于是在 Java 代码中使用多线程的方法。\n严格来说,Java 就只有一种方式可以创建线程,那就是通过new Thread().start()创建。不管是哪种方式,最终还是依赖于new Thread().start()。\n关于这个问题的详细分析可以查看这篇文章: 大家都说 Java 有三种创建线程的方式!并发编程中的惊天骗局!。\n⭐️说说线程的生命周期和状态? # Java 线程在运行的生命周期中的指定时刻只可能处于下面 6 种不同状态的其中一个状态:\nNEW: 初始状态,线程被创建出来但没有被调用 start() 。 RUNNABLE: 运行状态,线程被调用了 start()等待运行的状态。 BLOCKED:阻塞状态,需要等待锁释放。 WAITING:等待状态,表示该线程需要等待其他线程做出一些特定动作(通知或中断)。 TIME_WAITING:超时等待状态,可以在指定的时间后自行返回而不是像 WAITING 那样一直等待。 TERMINATED:终止状态,表示该线程已经运行完毕。 线程在生命周期中并不是固定处于某一个状态而是随着代码的执行在不同状态之间切换。\nJava 线程状态变迁图(图源: 挑错 |《Java 并发编程的艺术》中关于线程状态的三处错误):\n由上图可以看出:线程创建之后它将处于 NEW(新建) 状态,调用 start() 方法后开始运行,线程这时候处于 READY(可运行) 状态。可运行状态的线程获得了 CPU 时间片(timeslice)后就处于 RUNNING(运行) 状态。\n在操作系统层面,线程有 READY 和 RUNNING 状态;而在 JVM 层面,只能看到 RUNNABLE 状态(图源: HowToDoInJava: Java Thread Life Cycle and Thread States),所以 Java 系统一般将这两个状态统称为 RUNNABLE(运行中) 状态 。\n为什么 JVM 没有区分这两种状态呢? (摘自: Java 线程运行怎么有第六种状态? - Dawell 的回答 ) 现在的时分(time-sharing)多任务(multi-task)操作系统架构通常都是用所谓的“时间分片(time quantum or time slice)”方式进行抢占式(preemptive)轮转调度(round-robin 式)。这个时间分片通常是很小的,一个线程一次最多只能在 CPU 上运行比如 10-20ms 的时间(此时处于 running 状态),也即大概只有 0.01 秒这一量级,时间片用后就要被切换下来放入调度队列的末尾等待再次调度。(也即回到 ready 状态)。线程切换的如此之快,区分这两种状态就没什么意义了。\n当线程执行 wait()方法之后,线程进入 WAITING(等待) 状态。进入等待状态的线程需要依靠其他线程的通知才能够返回到运行状态。 TIMED_WAITING(超时等待) 状态相当于在等待状态的基础上增加了超时限制,比如通过 sleep(long millis)方法或 wait(long millis)方法可以将线程置于 TIMED_WAITING 状态。当超时时间结束后,线程将会返回到 RUNNABLE 状态。 当线程进入 synchronized 方法/块或者调用 wait 后(被 notify)重新进入 synchronized 方法/块,但是锁被其它线程占有,这个时候线程就会进入 BLOCKED(阻塞) 状态。 线程在执行完了 run()方法之后将会进入到 TERMINATED(终止) 状态。 相关阅读: 线程的几种状态你真的了解么? 。\n什么是线程上下文切换? # 线程在执行过程中会有自己的运行条件和状态(也称上下文),比如上文所说到过的程序计数器,栈信息等。当出现如下情况的时候,线程会从占用 CPU 状态中退出。\n主动让出 CPU,比如调用了 sleep(), wait() 等。 时间片用完,因为操作系统要防止一个线程或者进程长时间占用 CPU 导致其他线程或者进程饿死。 调用了阻塞类型的系统中断,比如请求 IO,线程被阻塞。 被终止或结束运行 这其中前三种都会发生线程切换,线程切换意味着需要保存当前线程的上下文,留待线程下次占用 CPU 的时候恢复现场。并加载下一个将要占用 CPU 的线程上下文。这就是所谓的 上下文切换。\n上下文切换是现代操作系统的基本功能,因其每次需要保存信息恢复信息,这将会占用 CPU,内存等系统资源进行处理,也就意味着效率会有一定损耗,如果频繁切换就会造成整体效率低下。\nThread#sleep() 方法和 Object#wait() 方法对比 # 共同点:两者都可以暂停线程的执行。\n区别:\nsleep() 方法没有释放锁,而 wait() 方法释放了锁 。 wait() 通常被用于线程间交互/通信,sleep()通常被用于暂停执行。 wait() 方法被调用后,线程不会自动苏醒,需要别的线程调用同一个对象上的 notify()或者 notifyAll() 方法。sleep()方法执行完成后,线程会自动苏醒,或者也可以使用 wait(long timeout) 超时后线程会自动苏醒。 sleep() 是 Thread 类的静态本地方法,wait() 则是 Object 类的本地方法。为什么这样设计呢?下一个问题就会聊到。 为什么 wait() 方法不定义在 Thread 中? # wait() 是让获得对象锁的线程实现等待,会自动释放当前线程占有的对象锁。每个对象(Object)都拥有对象锁,既然要释放当前线程占有的对象锁并让其进入 WAITING 状态,自然是要操作对应的对象(Object)而非当前的线程(Thread)。\n类似的问题:为什么 sleep() 方法定义在 Thread 中?\n因为 sleep() 是让当前线程暂停执行,不涉及到对象类,也不需要获得对象锁。\n可以直接调用 Thread 类的 run 方法吗? # 这是另一个非常经典的 Java 多线程面试问题,而且在面试中会经常被问到。很简单,但是很多人都会答不上来!\nnew 一个 Thread,线程进入了新建状态。调用 start()方法,会启动一个线程并使线程进入了就绪状态,当分配到时间片后就可以开始运行了。 start() 会执行线程的相应准备工作,然后自动执行 run() 方法的内容,这是真正的多线程工作。 但是,直接执行 run() 方法,会把 run() 方法当成一个 main 线程下的普通方法去执行,并不会在某个线程中执行它,所以这并不是多线程工作。\n总结:调用 start() 方法方可启动线程并使线程进入就绪状态,直接执行 run() 方法的话不会以多线程的方式执行。\n多线程 # 并发与并行的区别 # 并发:两个及两个以上的作业在同一 时间段 内执行。 并行:两个及两个以上的作业在同一 时刻 执行。 最关键的点是:是否是 同时 执行。\n同步和异步的区别 # 同步:发出一个调用之后,在没有得到结果之前, 该调用就不可以返回,一直等待。 异步:调用在发出之后,不用等待返回结果,该调用直接返回。 ⭐️为什么要使用多线程? # 先从总体上来说:\n从计算机底层来说: 线程可以比作是轻量级的进程,是程序执行的最小单位,线程间的切换和调度的成本远远小于进程。另外,多核 CPU 时代意味着多个线程可以同时运行,这减少了线程上下文切换的开销。 从当代互联网发展趋势来说: 现在的系统动不动就要求百万级甚至千万级的并发量,而多线程并发编程正是开发高并发系统的基础,利用好多线程机制可以大大提高系统整体的并发能力以及性能。 再深入到计算机底层来探讨:\n单核时代:在单核时代多线程主要是为了提高单进程利用 CPU 和 IO 系统的效率。 假设只运行了一个 Java 进程的情况,当我们请求 IO 的时候,如果 Java 进程中只有一个线程,此线程被 IO 阻塞则整个进程被阻塞。CPU 和 IO 设备只有一个在运行,那么可以简单地说系统整体效率只有 50%。当使用多线程的时候,一个线程被 IO 阻塞,其他线程还可以继续使用 CPU。从而提高了 Java 进程利用系统资源的整体效率。 多核时代: 多核时代多线程主要是为了提高进程利用多核 CPU 的能力。举个例子:假如我们要计算一个复杂的任务,我们只用一个线程的话,不论系统有几个 CPU 核心,都只会有一个 CPU 核心被利用到。而创建多个线程,这些线程可以被映射到底层多个 CPU 核心上执行,在任务中的多个线程没有资源竞争的情况下,任务执行的效率会有显著性的提高,约等于(单核时执行时间/CPU 核心数)。 ⭐️单核 CPU 支持 Java 多线程吗? # 单核 CPU 是支持 Java 多线程的。操作系统通过时间片轮转的方式,将 CPU 的时间分配给不同的线程。尽管单核 CPU 一次只能执行一个任务,但通过快速在多个线程之间切换,可以让用户感觉多个任务是同时进行的。\n这里顺带提一下 Java 使用的线程调度方式。\n操作系统主要通过两种线程调度方式来管理多线程的执行:\n抢占式调度(Preemptive Scheduling):操作系统决定何时暂停当前正在运行的线程,并切换到另一个线程执行。这种切换通常是由系统时钟中断(时间片轮转)或其他高优先级事件(如 I/O 操作完成)触发的。这种方式存在上下文切换开销,但公平性和 CPU 资源利用率较好,不易阻塞。 协同式调度(Cooperative Scheduling):线程执行完毕后,主动通知系统切换到另一个线程。这种方式可以减少上下文切换带来的性能开销,但公平性较差,容易阻塞。 Java 使用的线程调度是抢占式的。也就是说,JVM 本身不负责线程的调度,而是将线程的调度委托给操作系统。操作系统通常会基于线程优先级和时间片来调度线程的执行,高优先级的线程通常获得 CPU 时间片的机会更多。\n⭐️单核 CPU 上运行多个线程效率一定会高吗? # 单核 CPU 同时运行多个线程的效率是否会高,取决于线程的类型和任务的性质。一般来说,有两种类型的线程:\nCPU 密集型:CPU 密集型的线程主要进行计算和逻辑处理,需要占用大量的 CPU 资源。 IO 密集型:IO 密集型的线程主要进行输入输出操作,如读写文件、网络通信等,需要等待 IO 设备的响应,而不占用太多的 CPU 资源。 在单核 CPU 上,同一时刻只能有一个线程在运行,其他线程需要等待 CPU 的时间片分配。如果线程是 CPU 密集型的,那么多个线程同时运行会导致频繁的线程切换,增加了系统的开销,降低了效率。如果线程是 IO 密集型的,那么多个线程同时运行可以利用 CPU 在等待 IO 时的空闲时间,提高了效率。\n因此,对于单核 CPU 来说,如果任务是 CPU 密集型的,那么开很多线程会影响效率;如果任务是 IO 密集型的,那么开很多线程会提高效率。当然,这里的“很多”也要适度,不能超过系统能够承受的上限。\n使用多线程可能带来什么问题? # 并发编程的目的就是为了能提高程序的执行效率进而提高程序的运行速度,但是并发编程并不总是能提高程序运行速度的,而且并发编程可能会遇到很多问题,比如:内存泄漏、死锁、线程不安全等等。\n如何理解线程安全和不安全? # 线程安全和不安全是在多线程环境下对于同一份数据的访问是否能够保证其正确性和一致性的描述。\n线程安全指的是在多线程环境下,对于同一份数据,不管有多少个线程同时访问,都能保证这份数据的正确性和一致性。 线程不安全则表示在多线程环境下,对于同一份数据,多个线程同时访问时可能会导致数据混乱、错误或者丢失。 ⭐️死锁 # 什么是线程死锁? # 线程死锁描述的是这样一种情况:多个线程同时被阻塞,它们中的一个或者全部都在等待某个资源被释放。由于线程被无限期地阻塞,因此程序不可能正常终止。\n如下图所示,线程 A 持有资源 2,线程 B 持有资源 1,他们同时都想申请对方的资源,所以这两个线程就会互相等待而进入死锁状态。\n下面通过一个例子来说明线程死锁,代码模拟了上图的死锁的情况 (代码来源于《并发编程之美》):\npublic class DeadLockDemo { private static Object resource1 = new Object();//资源 1 private static Object resource2 = new Object();//资源 2 public static void main(String[] args) { new Thread(() -\u0026gt; { synchronized (resource1) { System.out.println(Thread.currentThread() + \u0026#34;get resource1\u0026#34;); try { Thread.sleep(1000); } catch (InterruptedException e) { e.printStackTrace(); } System.out.println(Thread.currentThread() + \u0026#34;waiting get resource2\u0026#34;); synchronized (resource2) { System.out.println(Thread.currentThread() + \u0026#34;get resource2\u0026#34;); } } }, \u0026#34;线程 1\u0026#34;).start(); new Thread(() -\u0026gt; { synchronized (resource2) { System.out.println(Thread.currentThread() + \u0026#34;get resource2\u0026#34;); try { Thread.sleep(1000); } catch (InterruptedException e) { e.printStackTrace(); } System.out.println(Thread.currentThread() + \u0026#34;waiting get resource1\u0026#34;); synchronized (resource1) { System.out.println(Thread.currentThread() + \u0026#34;get resource1\u0026#34;); } } }, \u0026#34;线程 2\u0026#34;).start(); } } Output\nThread[线程 1,5,main]get resource1 Thread[线程 2,5,main]get resource2 Thread[线程 1,5,main]waiting get resource2 Thread[线程 2,5,main]waiting get resource1 线程 A 通过 synchronized (resource1) 获得 resource1 的监视器锁,然后通过Thread.sleep(1000);让线程 A 休眠 1s 为的是让线程 B 得到执行然后获取到 resource2 的监视器锁。线程 A 和线程 B 休眠结束了都开始企图请求获取对方的资源,然后这两个线程就会陷入互相等待的状态,这也就产生了死锁。\n上面的例子符合产生死锁的四个必要条件:\n互斥条件:该资源任意一个时刻只由一个线程占用。 请求与保持条件:一个线程因请求资源而阻塞时,对已获得的资源保持不放。 不剥夺条件:线程已获得的资源在未使用完之前不能被其他线程强行剥夺,只有自己使用完毕后才释放资源。 循环等待条件:若干线程之间形成一种头尾相接的循环等待资源关系。 如何检测死锁? # 使用jmap、jstack等命令查看 JVM 线程栈和堆内存的情况。如果有死锁,jstack 的输出中通常会有 Found one Java-level deadlock:的字样,后面会跟着死锁相关的线程信息。另外,实际项目中还可以搭配使用top、df、free等命令查看操作系统的基本情况,出现死锁可能会导致 CPU、内存等资源消耗过高。 采用 VisualVM、JConsole 等工具进行排查。 这里以 JConsole 工具为例进行演示。\n首先,我们要找到 JDK 的 bin 目录,找到 jconsole 并双击打开。\n对于 MAC 用户来说,可以通过 /usr/libexec/java_home -V查看 JDK 安装目录,找到后通过 open . + 文件夹地址打开即可。例如,我本地的某个 JDK 的路径是:\nopen . /Users/guide/Library/Java/JavaVirtualMachines/corretto-1.8.0_252/Contents/Home 打开 jconsole 后,连接对应的程序,然后进入线程界面选择检测死锁即可!\n如何预防和避免线程死锁? # 如何预防死锁? 破坏死锁的产生的必要条件即可:\n破坏请求与保持条件:一次性申请所有的资源。 破坏不剥夺条件:占用部分资源的线程进一步申请其他资源时,如果申请不到,可以主动释放它占有的资源。 破坏循环等待条件:靠按序申请资源来预防。按某一顺序申请资源,释放资源则反序释放。破坏循环等待条件。 如何避免死锁?\n避免死锁就是在资源分配时,借助于算法(比如银行家算法)对资源分配进行计算评估,使其进入安全状态。\n安全状态 指的是系统能够按照某种线程推进顺序(P1、P2、P3……Pn)来为每个线程分配所需资源,直到满足每个线程对资源的最大需求,使每个线程都可顺利完成。称 \u0026lt;P1、P2、P3.....Pn\u0026gt; 序列为安全序列。\n我们对线程 2 的代码修改成下面这样就不会产生死锁了。\nnew Thread(() -\u0026gt; { synchronized (resource1) { System.out.println(Thread.currentThread() + \u0026#34;get resource1\u0026#34;); try { Thread.sleep(1000); } catch (InterruptedException e) { e.printStackTrace(); } System.out.println(Thread.currentThread() + \u0026#34;waiting get resource2\u0026#34;); synchronized (resource2) { System.out.println(Thread.currentThread() + \u0026#34;get resource2\u0026#34;); } } }, \u0026#34;线程 2\u0026#34;).start(); 输出:\nThread[线程 1,5,main]get resource1 Thread[线程 1,5,main]waiting get resource2 Thread[线程 1,5,main]get resource2 Thread[线程 2,5,main]get resource1 Thread[线程 2,5,main]waiting get resource2 Thread[线程 2,5,main]get resource2 Process finished with exit code 0 我们分析一下上面的代码为什么避免了死锁的发生?\n线程 1 首先获得到 resource1 的监视器锁,这时候线程 2 就获取不到了。然后线程 1 再去获取 resource2 的监视器锁,可以获取到。然后线程 1 释放了对 resource1、resource2 的监视器锁的占用,线程 2 获取到就可以执行了。这样就破坏了破坏循环等待条件,因此避免了死锁。\n虚拟线程 # 虚拟线程在 Java 21 正式发布,这是一项重量级的更新。我写了一篇文章来总结虚拟线程常见的问题: 虚拟线程常见问题总结,包含下面这些问题:\n什么是虚拟线程? 虚拟线程和平台线程有什么关系? 虚拟线程有什么优点和缺点? 如何创建虚拟线程? 虚拟线程的底层原理是什么? "},{"id":514,"href":"/zh/docs/technology/Interview/java/concurrent/java-concurrent-questions-03/","title":"Java并发常见面试题总结(下)","section":"Concurrent","content":" ThreadLocal # ThreadLocal 有什么用? # 通常情况下,我们创建的变量可以被任何一个线程访问和修改。这在多线程环境中可能导致数据竞争和线程安全问题。那么,如果想让每个线程都有自己的专属本地变量,该如何实现呢?\nJDK 中提供的 ThreadLocal 类正是为了解决这个问题。ThreadLocal 类允许每个线程绑定自己的值,可以将其形象地比喻为一个“存放数据的盒子”。每个线程都有自己独立的盒子,用于存储私有数据,确保不同线程之间的数据互不干扰。\n当你创建一个 ThreadLocal 变量时,每个访问该变量的线程都会拥有一个独立的副本。这也是 ThreadLocal 名称的由来。线程可以通过 get() 方法获取自己线程的本地副本,或通过 set() 方法修改该副本的值,从而避免了线程安全问题。\n举个简单的例子:假设有两个人去宝屋收集宝物。如果他们共用一个袋子,必然会产生争执;但如果每个人都有一个独立的袋子,就不会有这个问题。如果将这两个人比作线程,那么 ThreadLocal 就是用来避免这两个线程竞争同一个资源的方法。\npublic class ThreadLocalExample { private static ThreadLocal\u0026lt;Integer\u0026gt; threadLocal = ThreadLocal.withInitial(() -\u0026gt; 0); public static void main(String[] args) { Runnable task = () -\u0026gt; { int value = threadLocal.get(); value += 1; threadLocal.set(value); System.out.println(Thread.currentThread().getName() + \u0026#34; Value: \u0026#34; + threadLocal.get()); }; Thread thread1 = new Thread(task, \u0026#34;Thread-1\u0026#34;); Thread thread2 = new Thread(task, \u0026#34;Thread-2\u0026#34;); thread1.start(); // 输出: Thread-1 Value: 1 thread2.start(); // 输出: Thread-2 Value: 1 } } ⭐️ThreadLocal 原理了解吗? # 从 Thread类源代码入手。\npublic class Thread implements Runnable { //...... //与此线程有关的ThreadLocal值。由ThreadLocal类维护 ThreadLocal.ThreadLocalMap threadLocals = null; //与此线程有关的InheritableThreadLocal值。由InheritableThreadLocal类维护 ThreadLocal.ThreadLocalMap inheritableThreadLocals = null; //...... } 从上面Thread类 源代码可以看出Thread 类中有一个 threadLocals 和 一个 inheritableThreadLocals 变量,它们都是 ThreadLocalMap 类型的变量,我们可以把 ThreadLocalMap 理解为ThreadLocal 类实现的定制化的 HashMap。默认情况下这两个变量都是 null,只有当前线程调用 ThreadLocal 类的 set或get方法时才创建它们,实际上调用这两个方法的时候,我们调用的是ThreadLocalMap类对应的 get()、set()方法。\nThreadLocal类的set()方法\npublic void set(T value) { //获取当前请求的线程 Thread t = Thread.currentThread(); //取出 Thread 类内部的 threadLocals 变量(哈希表结构) ThreadLocalMap map = getMap(t); if (map != null) // 将需要存储的值放入到这个哈希表中 map.set(this, value); else createMap(t, value); } ThreadLocalMap getMap(Thread t) { return t.threadLocals; } 通过上面这些内容,我们足以通过猜测得出结论:最终的变量是放在了当前线程的 ThreadLocalMap 中,并不是存在 ThreadLocal 上,ThreadLocal 可以理解为只是ThreadLocalMap的封装,传递了变量值。 ThrealLocal 类中可以通过Thread.currentThread()获取到当前线程对象后,直接通过getMap(Thread t)可以访问到该线程的ThreadLocalMap对象。\n每个Thread中都具备一个ThreadLocalMap,而ThreadLocalMap可以存储以ThreadLocal为 key ,Object 对象为 value 的键值对。\nThreadLocalMap(ThreadLocal\u0026lt;?\u0026gt; firstKey, Object firstValue) { //...... } 比如我们在同一个线程中声明了两个 ThreadLocal 对象的话, Thread内部都是使用仅有的那个ThreadLocalMap 存放数据的,ThreadLocalMap的 key 就是 ThreadLocal对象,value 就是 ThreadLocal 对象调用set方法设置的值。\nThreadLocal 数据结构如下图所示:\nThreadLocalMap是ThreadLocal的静态内部类。\n⭐️ThreadLocal 内存泄露问题是怎么导致的? # ThreadLocal 内存泄漏的根本原因在于其内部实现机制。\n通过上面的内容我们已经知道:每个线程维护一个名为 ThreadLocalMap 的 map。 当你使用 ThreadLocal 存储值时,实际上是将值存储在当前线程的 ThreadLocalMap 中,其中 ThreadLocal 实例本身作为 key,而你要存储的值作为 value。\nThreadLocalMap 的 key 和 value 引用机制:\nkey 是弱引用:ThreadLocalMap 中的 key 是 ThreadLocal 的弱引用 (WeakReference\u0026lt;ThreadLocal\u0026lt;?\u0026gt;\u0026gt;)。 这意味着,如果 ThreadLocal 实例不再被任何强引用指向,垃圾回收器会在下次 GC 时回收该实例,导致 ThreadLocalMap 中对应的 key 变为 null。 value 是强引用:ThreadLocalMap 中的 value 是强引用。 即使 key 被回收(变为 null),value 仍然存在于 ThreadLocalMap 中,被强引用,不会被回收。 static class Entry extends WeakReference\u0026lt;ThreadLocal\u0026lt;?\u0026gt;\u0026gt; { /** The value associated with this ThreadLocal. */ Object value; Entry(ThreadLocal\u0026lt;?\u0026gt; k, Object v) { super(k); value = v; } } 当 ThreadLocal 实例失去强引用后,其对应的 value 仍然存在于 ThreadLocalMap 中,因为 Entry 对象强引用了它。如果线程持续存活(例如线程池中的线程),ThreadLocalMap 也会一直存在,导致 key 为 null 的 entry 无法被垃圾回收,机会造成内存泄漏。\n也就是说,内存泄漏的发生需要同时满足两个条件:\nThreadLocal 实例不再被强引用; 线程持续存活,导致 ThreadLocalMap 长期存在。 虽然 ThreadLocalMap 在 get(), set() 和 remove() 操作时会尝试清理 key 为 null 的 entry,但这种清理机制是被动的,并不完全可靠。\n如何避免内存泄漏的发生?\n在使用完 ThreadLocal 后,务必调用 remove() 方法。 这是最安全和最推荐的做法。 remove() 方法会从 ThreadLocalMap 中显式地移除对应的 entry,彻底解决内存泄漏的风险。 即使将 ThreadLocal 定义为 static final,也强烈建议在每次使用后调用 remove()。 在线程池等线程复用的场景下,使用 try-finally 块可以确保即使发生异常,remove() 方法也一定会被执行。 如何跨线程传递 ThreadLocal 的值? # 由于 ThreadLocal 的变量值存放在 Thread 里,而父子线程属于不同的 Thread 的。因此在异步场景下,父子线程的 ThreadLocal 值无法进行传递。\n如果想要在异步场景下传递 ThreadLocal 值,有两种解决方案:\nInheritableThreadLocal :InheritableThreadLocal 是 JDK1.2 提供的工具,继承自 ThreadLocal 。使用 InheritableThreadLocal 时,会在创建子线程时,令子线程继承父线程中的 ThreadLocal 值,但是无法支持线程池场景下的 ThreadLocal 值传递。 TransmittableThreadLocal : TransmittableThreadLocal (简称 TTL) 是阿里巴巴开源的工具类,继承并加强了InheritableThreadLocal类,可以在线程池的场景下支持 ThreadLocal 值传递。项目地址: https://github.com/alibaba/transmittable-thread-local。 InheritableThreadLocal 原理扩展 # InheritableThreadLocal 实现了创建异步线程时,继承父线程 ThreadLocal 值的功能。该类是 JDK 团队提供的,通过改造 JDK 源码包中的 Thread 类来实现创建线程时,ThreadLocal 值的传递。\nInheritableThreadLocal 的值存储在哪里?\n在 Thread 类中添加了一个新的 ThreadLocalMap ,命名为 inheritableThreadLocals ,该变量用于存储需要跨线程传递的 ThreadLocal 值。如下:\nclass Thread implements Runnable { ThreadLocal.ThreadLocalMap threadLocals = null; ThreadLocal.ThreadLocalMap inheritableThreadLocals = null; } 如何完成 ThreadLocal 值的传递?\n通过改造 Thread 类的构造方法来实现,在创建 Thread 线程时,拿到父线程的 inheritableThreadLocals 变量赋值给子线程即可。相关代码如下:\n// Thread 的构造方法会调用 init() 方法 private void init(/* ... */) { // 1、获取父线程 Thread parent = currentThread(); // 2、将父线程的 inheritableThreadLocals 赋值给子线程 if (inheritThreadLocals \u0026amp;\u0026amp; parent.inheritableThreadLocals != null) this.inheritableThreadLocals = ThreadLocal.createInheritedMap(parent.inheritableThreadLocals); } TransmittableThreadLocal 原理扩展 # JDK 默认没有支持线程池场景下 ThreadLocal 值传递的功能,因此阿里巴巴开源了一套工具 TransmittableThreadLocal 来实现该功能。\n阿里巴巴无法改动 JDK 的源码,因此他内部通过 装饰器模式 在原有的功能上做增强,以此来实现线程池场景下的 ThreadLocal 值传递。\nTTL 改造的地方有两处:\n实现自定义的 Thread ,在 run() 方法内部做 ThreadLocal 变量的赋值操作。\n基于 线程池 进行装饰,在 execute() 方法中,不提交 JDK 内部的 Thread ,而是提交自定义的 Thread 。\n如果想要查看相关源码,可以引入 Maven 依赖进行下载。\n\u0026lt;dependency\u0026gt; \u0026lt;groupId\u0026gt;com.alibaba\u0026lt;/groupId\u0026gt; \u0026lt;artifactId\u0026gt;transmittable-thread-local\u0026lt;/artifactId\u0026gt; \u0026lt;version\u0026gt;2.12.0\u0026lt;/version\u0026gt; \u0026lt;/dependency\u0026gt; 应用场景 # 压测流量标记: 在压测场景中,使用 ThreadLocal 存储压测标记,用于区分压测流量和真实流量。如果标记丢失,可能导致压测流量被错误地当成线上流量处理。 上下文传递:在分布式系统中,传递链路追踪信息(如 Trace ID)或用户上下文信息。 线程池 # 什么是线程池? # 顾名思义,线程池就是管理一系列线程的资源池。当有任务要处理时,直接从线程池中获取线程来处理,处理完之后线程并不会立即被销毁,而是等待下一个任务。\n⭐️为什么要用线程池? # 池化技术想必大家已经屡见不鲜了,线程池、数据库连接池、HTTP 连接池等等都是对这个思想的应用。池化技术的思想主要是为了减少每次获取资源的消耗,提高对资源的利用率。\n线程池提供了一种限制和管理资源(包括执行一个任务)的方式。 每个线程池还维护一些基本统计信息,例如已完成任务的数量。\n这里借用《Java 并发编程的艺术》提到的来说一下使用线程池的好处:\n降低资源消耗。通过重复利用已创建的线程降低线程创建和销毁造成的消耗。 提高响应速度。当任务到达时,任务可以不需要等到线程创建就能立即执行。 提高线程的可管理性。线程是稀缺资源,如果无限制的创建,不仅会消耗系统资源,还会降低系统的稳定性,使用线程池可以进行统一的分配,调优和监控。 如何创建线程池? # 方式一:通过ThreadPoolExecutor构造函数来创建(推荐)。\n方式二:通过 Executor 框架的工具类 Executors 来创建。\nExecutors工具类提供的创建线程池的方法如下图所示:\n可以看出,通过Executors工具类可以创建多种类型的线程池,包括:\nFixedThreadPool:固定线程数量的线程池。该线程池中的线程数量始终不变。当有一个新的任务提交时,线程池中若有空闲线程,则立即执行。若没有,则新的任务会被暂存在一个任务队列中,待有线程空闲时,便处理在任务队列中的任务。 SingleThreadExecutor: 只有一个线程的线程池。若多余一个任务被提交到该线程池,任务会被保存在一个任务队列中,待线程空闲,按先入先出的顺序执行队列中的任务。 CachedThreadPool: 可根据实际情况调整线程数量的线程池。线程池的线程数量不确定,但若有空闲线程可以复用,则会优先使用可复用的线程。若所有线程均在工作,又有新的任务提交,则会创建新的线程处理任务。所有线程在当前任务执行完毕后,将返回线程池进行复用。 ScheduledThreadPool:给定的延迟后运行任务或者定期执行任务的线程池。 ⭐️为什么不推荐使用内置线程池? # 在《阿里巴巴 Java 开发手册》“并发处理”这一章节,明确指出线程资源必须通过线程池提供,不允许在应用中自行显式创建线程。\n为什么呢?\n使用线程池的好处是减少在创建和销毁线程上所消耗的时间以及系统资源开销,解决资源不足的问题。如果不使用线程池,有可能会造成系统创建大量同类线程而导致消耗完内存或者“过度切换”的问题。\n另外,《阿里巴巴 Java 开发手册》中强制线程池不允许使用 Executors 去创建,而是通过 ThreadPoolExecutor 构造函数的方式,这样的处理方式让写的同学更加明确线程池的运行规则,规避资源耗尽的风险\nExecutors 返回线程池对象的弊端如下:\nFixedThreadPool 和 SingleThreadExecutor:使用的是有界阻塞队列是 LinkedBlockingQueue ,其任务队列的最大长度为 Integer.MAX_VALUE ,可能堆积大量的请求,从而导致 OOM。 CachedThreadPool:使用的是同步队列 SynchronousQueue, 允许创建的线程数量为 Integer.MAX_VALUE ,如果任务数量过多且执行速度较慢,可能会创建大量的线程,从而导致 OOM。 ScheduledThreadPool 和 SingleThreadScheduledExecutor :使用的无界的延迟阻塞队列 DelayedWorkQueue ,任务队列最大长度为 Integer.MAX_VALUE ,可能堆积大量的请求,从而导致 OOM。 // 有界队列 LinkedBlockingQueue public static ExecutorService newFixedThreadPool(int nThreads) { return new ThreadPoolExecutor(nThreads, nThreads,0L, TimeUnit.MILLISECONDS,new LinkedBlockingQueue\u0026lt;Runnable\u0026gt;()); } // 无界队列 LinkedBlockingQueue public static ExecutorService newSingleThreadExecutor() { return new FinalizableDelegatedExecutorService (new ThreadPoolExecutor(1, 1,0L, TimeUnit.MILLISECONDS,new LinkedBlockingQueue\u0026lt;Runnable\u0026gt;())); } // 同步队列 SynchronousQueue,没有容量,最大线程数是 Integer.MAX_VALUE` public static ExecutorService newCachedThreadPool() { return new ThreadPoolExecutor(0, Integer.MAX_VALUE,60L, TimeUnit.SECONDS,new SynchronousQueue\u0026lt;Runnable\u0026gt;()); } // DelayedWorkQueue(延迟阻塞队列) public static ScheduledExecutorService newScheduledThreadPool(int corePoolSize) { return new ScheduledThreadPoolExecutor(corePoolSize); } public ScheduledThreadPoolExecutor(int corePoolSize) { super(corePoolSize, Integer.MAX_VALUE, 0, NANOSECONDS, new DelayedWorkQueue()); } ⭐️线程池常见参数有哪些?如何解释? # /** * 用给定的初始参数创建一个新的ThreadPoolExecutor。 */ public ThreadPoolExecutor(int corePoolSize,//线程池的核心线程数量 int maximumPoolSize,//线程池的最大线程数 long keepAliveTime,//当线程数大于核心线程数时,多余的空闲线程存活的最长时间 TimeUnit unit,//时间单位 BlockingQueue\u0026lt;Runnable\u0026gt; workQueue,//任务队列,用来储存等待执行任务的队列 ThreadFactory threadFactory,//线程工厂,用来创建线程,一般默认即可 RejectedExecutionHandler handler//拒绝策略,当提交的任务过多而不能及时处理时,我们可以定制策略来处理任务 ) { if (corePoolSize \u0026lt; 0 || maximumPoolSize \u0026lt;= 0 || maximumPoolSize \u0026lt; corePoolSize || keepAliveTime \u0026lt; 0) throw new IllegalArgumentException(); if (workQueue == null || threadFactory == null || handler == null) throw new NullPointerException(); this.corePoolSize = corePoolSize; this.maximumPoolSize = maximumPoolSize; this.workQueue = workQueue; this.keepAliveTime = unit.toNanos(keepAliveTime); this.threadFactory = threadFactory; this.handler = handler; } ThreadPoolExecutor 3 个最重要的参数:\ncorePoolSize : 任务队列未达到队列容量时,最大可以同时运行的线程数量。 maximumPoolSize : 任务队列中存放的任务达到队列容量的时候,当前可以同时运行的线程数量变为最大线程数。 workQueue: 新任务来的时候会先判断当前运行的线程数量是否达到核心线程数,如果达到的话,新任务就会被存放在队列中。 ThreadPoolExecutor其他常见参数 :\nkeepAliveTime:当线程池中的线程数量大于 corePoolSize ,即有非核心线程(线程池中核心线程以外的线程)时,这些非核心线程空闲后不会立即销毁,而是会等待,直到等待的时间超过了 keepAliveTime才会被回收销毁。 unit : keepAliveTime 参数的时间单位。 threadFactory :executor 创建新线程的时候会用到。 handler :拒绝策略(后面会单独详细介绍一下)。 下面这张图可以加深你对线程池中各个参数的相互关系的理解(图片来源:《Java 性能调优实战》):\n线程池的核心线程会被回收吗? # ThreadPoolExecutor 默认不会回收核心线程,即使它们已经空闲了。这是为了减少创建线程的开销,因为核心线程通常是要长期保持活跃的。但是,如果线程池是被用于周期性使用的场景,且频率不高(周期之间有明显的空闲时间),可以考虑将 allowCoreThreadTimeOut(boolean value) 方法的参数设置为 true,这样就会回收空闲(时间间隔由 keepAliveTime 指定)的核心线程了。\npublic void allowCoreThreadTimeOut(boolean value) { // 核心线程的 keepAliveTime 必须大于 0 才能启用超时机制 if (value \u0026amp;\u0026amp; keepAliveTime \u0026lt;= 0) { throw new IllegalArgumentException(\u0026#34;Core threads must have nonzero keep alive times\u0026#34;); } // 设置 allowCoreThreadTimeOut 的值 if (value != allowCoreThreadTimeOut) { allowCoreThreadTimeOut = value; // 如果启用了超时机制,清理所有空闲的线程,包括核心线程 if (value) { interruptIdleWorkers(); } } } ⭐️线程池的拒绝策略有哪些? # 如果当前同时运行的线程数量达到最大线程数量并且队列也已经被放满了任务时,ThreadPoolExecutor 定义一些策略:\nThreadPoolExecutor.AbortPolicy:抛出 RejectedExecutionException来拒绝新任务的处理。 ThreadPoolExecutor.CallerRunsPolicy:调用执行者自己的线程运行任务,也就是直接在调用execute方法的线程中运行(run)被拒绝的任务,如果执行程序已关闭,则会丢弃该任务。因此这种策略会降低对于新任务提交速度,影响程序的整体性能。如果你的应用程序可以承受此延迟并且你要求任何一个任务请求都要被执行的话,你可以选择这个策略。 ThreadPoolExecutor.DiscardPolicy:不处理新任务,直接丢弃掉。 ThreadPoolExecutor.DiscardOldestPolicy:此策略将丢弃最早的未处理的任务请求。 举个例子:Spring 通过 ThreadPoolTaskExecutor 或者我们直接通过 ThreadPoolExecutor 的构造函数创建线程池的时候,当我们不指定 RejectedExecutionHandler 拒绝策略来配置线程池的时候,默认使用的是 AbortPolicy。在这种拒绝策略下,如果队列满了,ThreadPoolExecutor 将抛出 RejectedExecutionException 异常来拒绝新来的任务 ,这代表你将丢失对这个任务的处理。如果不想丢弃任务的话,可以使用CallerRunsPolicy。CallerRunsPolicy 和其他的几个策略不同,它既不会抛弃任务,也不会抛出异常,而是将任务回退给调用者,使用调用者的线程来执行任务。\npublic static class CallerRunsPolicy implements RejectedExecutionHandler { public CallerRunsPolicy() { } public void rejectedExecution(Runnable r, ThreadPoolExecutor e) { if (!e.isShutdown()) { // 直接主线程执行,而不是线程池中的线程执行 r.run(); } } } 如果不允许丢弃任务任务,应该选择哪个拒绝策略? # 根据上面对线程池拒绝策略的介绍,相信大家很容易能够得出答案是:CallerRunsPolicy 。\n这里我们再来结合CallerRunsPolicy 的源码来看看:\npublic static class CallerRunsPolicy implements RejectedExecutionHandler { public CallerRunsPolicy() { } public void rejectedExecution(Runnable r, ThreadPoolExecutor e) { //只要当前程序没有关闭,就用执行execute方法的线程执行该任务 if (!e.isShutdown()) { r.run(); } } } 从源码可以看出,只要当前程序不关闭就会使用执行execute方法的线程执行该任务。\nCallerRunsPolicy 拒绝策略有什么风险?如何解决? # 我们上面也提到了:如果想要保证任何一个任务请求都要被执行的话,那选择 CallerRunsPolicy 拒绝策略更合适一些。\n不过,如果走到CallerRunsPolicy的任务是个非常耗时的任务,且处理提交任务的线程是主线程,可能会导致主线程阻塞,影响程序的正常运行。\n这里简单举一个例子,该线程池限定了最大线程数为 2,阻塞队列大小为 1(这意味着第 4 个任务就会走到拒绝策略),ThreadUtil为 Hutool 提供的工具类:\npublic class ThreadPoolTest { private static final Logger log = LoggerFactory.getLogger(ThreadPoolTest.class); public static void main(String[] args) { // 创建一个线程池,核心线程数为1,最大线程数为2 // 当线程数大于核心线程数时,多余的空闲线程存活的最长时间为60秒, // 任务队列为容量为1的ArrayBlockingQueue,饱和策略为CallerRunsPolicy。 ThreadPoolExecutor threadPoolExecutor = new ThreadPoolExecutor(1, 2, 60, TimeUnit.SECONDS, new ArrayBlockingQueue\u0026lt;\u0026gt;(1), new ThreadPoolExecutor.CallerRunsPolicy()); // 提交第一个任务,由核心线程执行 threadPoolExecutor.execute(() -\u0026gt; { log.info(\u0026#34;核心线程执行第一个任务\u0026#34;); ThreadUtil.sleep(1, TimeUnit.MINUTES); }); // 提交第二个任务,由于核心线程被占用,任务将进入队列等待 threadPoolExecutor.execute(() -\u0026gt; { log.info(\u0026#34;非核心线程处理入队的第二个任务\u0026#34;); ThreadUtil.sleep(1, TimeUnit.MINUTES); }); // 提交第三个任务,由于核心线程被占用且队列已满,创建非核心线程处理 threadPoolExecutor.execute(() -\u0026gt; { log.info(\u0026#34;非核心线程处理第三个任务\u0026#34;); ThreadUtil.sleep(1, TimeUnit.MINUTES); }); // 提交第四个任务,由于核心线程和非核心线程都被占用,队列也满了,根据CallerRunsPolicy策略,任务将由提交任务的线程(即主线程)来执行 threadPoolExecutor.execute(() -\u0026gt; { log.info(\u0026#34;主线程处理第四个任务\u0026#34;); ThreadUtil.sleep(2, TimeUnit.MINUTES); }); // 提交第五个任务,主线程被第四个任务卡住,该任务必须等到主线程执行完才能提交 threadPoolExecutor.execute(() -\u0026gt; { log.info(\u0026#34;核心线程执行第五个任务\u0026#34;); }); // 关闭线程池 threadPoolExecutor.shutdown(); } } 输出:\n18:19:48.203 INFO [pool-1-thread-1] c.j.concurrent.ThreadPoolTest - 核心线程执行第一个任务 18:19:48.203 INFO [pool-1-thread-2] c.j.concurrent.ThreadPoolTest - 非核心线程处理第三个任务 18:19:48.203 INFO [main] c.j.concurrent.ThreadPoolTest - 主线程处理第四个任务 18:20:48.212 INFO [pool-1-thread-2] c.j.concurrent.ThreadPoolTest - 非核心线程处理入队的第二个任务 18:21:48.219 INFO [pool-1-thread-2] c.j.concurrent.ThreadPoolTest - 核心线程执行第五个任务 从输出结果可以看出,因为CallerRunsPolicy这个拒绝策略,导致耗时的任务用了主线程执行,导致线程池阻塞,进而导致后续任务无法及时执行,严重的情况下很可能导致 OOM。\n我们从问题的本质入手,调用者采用CallerRunsPolicy是希望所有的任务都能够被执行,暂时无法处理的任务又被保存在阻塞队列BlockingQueue中。这样的话,在内存允许的情况下,我们可以增加阻塞队列BlockingQueue的大小并调整堆内存以容纳更多的任务,确保任务能够被准确执行。\n为了充分利用 CPU,我们还可以调整线程池的maximumPoolSize (最大线程数)参数,这样可以提高任务处理速度,避免累计在 BlockingQueue的任务过多导致内存用完。\n如果服务器资源以达到可利用的极限,这就意味我们要在设计策略上改变线程池的调度了,我们都知道,导致主线程卡死的本质就是因为我们不希望任何一个任务被丢弃。换个思路,有没有办法既能保证任务不被丢弃且在服务器有余力时及时处理呢?\n这里提供的一种任务持久化的思路,这里所谓的任务持久化,包括但不限于:\n设计一张任务表将任务存储到 MySQL 数据库中。 Redis 缓存任务。 将任务提交到消息队列中。 这里以方案一为例,简单介绍一下实现逻辑:\n实现RejectedExecutionHandler接口自定义拒绝策略,自定义拒绝策略负责将线程池暂时无法处理(此时阻塞队列已满)的任务入库(保存到 MySQL 中)。注意:线程池暂时无法处理的任务会先被放在阻塞队列中,阻塞队列满了才会触发拒绝策略。 继承BlockingQueue实现一个混合式阻塞队列,该队列包含 JDK 自带的ArrayBlockingQueue。另外,该混合式阻塞队列需要修改取任务处理的逻辑,也就是重写take()方法,取任务时优先从数据库中读取最早的任务,数据库中无任务时再从 ArrayBlockingQueue中去取任务。 整个实现逻辑还是比较简单的,核心在于自定义拒绝策略和阻塞队列。如此一来,一旦我们的线程池中线程以达到满载时,我们就可以通过拒绝策略将最新任务持久化到 MySQL 数据库中,等到线程池有了有余力处理所有任务时,让其优先处理数据库中的任务以避免\u0026quot;饥饿\u0026quot;问题。\n当然,对于这个问题,我们也可以参考其他主流框架的做法,以 Netty 为例,它的拒绝策略则是直接创建一个线程池以外的线程处理这些任务,为了保证任务的实时处理,这种做法可能需要良好的硬件设备且临时创建的线程无法做到准确的监控:\nprivate static final class NewThreadRunsPolicy implements RejectedExecutionHandler { NewThreadRunsPolicy() { super(); } public void rejectedExecution(Runnable r, ThreadPoolExecutor executor) { try { //创建一个临时线程处理任务 final Thread t = new Thread(r, \u0026#34;Temporary task executor\u0026#34;); t.start(); } catch (Throwable e) { throw new RejectedExecutionException( \u0026#34;Failed to start a new thread\u0026#34;, e); } } } ActiveMQ 则是尝试在指定的时效内尽可能的争取将任务入队,以保证最大交付:\nnew RejectedExecutionHandler() { @Override public void rejectedExecution(final Runnable r, final ThreadPoolExecutor executor) { try { //限时阻塞等待,实现尽可能交付 executor.getQueue().offer(r, 60, TimeUnit.SECONDS); } catch (InterruptedException e) { throw new RejectedExecutionException(\u0026#34;Interrupted waiting for BrokerService.worker\u0026#34;); } throw new RejectedExecutionException(\u0026#34;Timed Out while attempting to enqueue Task.\u0026#34;); } }); 线程池常用的阻塞队列有哪些? # 新任务来的时候会先判断当前运行的线程数量是否达到核心线程数,如果达到的话,新任务就会被存放在队列中。\n不同的线程池会选用不同的阻塞队列,我们可以结合内置线程池来分析。\n容量为 Integer.MAX_VALUE 的 LinkedBlockingQueue(有界阻塞队列):FixedThreadPool 和 SingleThreadExecutor 。FixedThreadPool最多只能创建核心线程数的线程(核心线程数和最大线程数相等),SingleThreadExecutor只能创建一个线程(核心线程数和最大线程数都是 1),二者的任务队列永远不会被放满。 SynchronousQueue(同步队列):CachedThreadPool 。SynchronousQueue 没有容量,不存储元素,目的是保证对于提交的任务,如果有空闲线程,则使用空闲线程来处理;否则新建一个线程来处理任务。也就是说,CachedThreadPool 的最大线程数是 Integer.MAX_VALUE ,可以理解为线程数是可以无限扩展的,可能会创建大量线程,从而导致 OOM。 DelayedWorkQueue(延迟队列):ScheduledThreadPool 和 SingleThreadScheduledExecutor 。DelayedWorkQueue 的内部元素并不是按照放入的时间排序,而是会按照延迟的时间长短对任务进行排序,内部采用的是“堆”的数据结构,可以保证每次出队的任务都是当前队列中执行时间最靠前的。DelayedWorkQueue 添加元素满了之后会自动扩容,增加原来容量的 50%,即永远不会阻塞,最大扩容可达 Integer.MAX_VALUE,所以最多只能创建核心线程数的线程。 ArrayBlockingQueue(有界阻塞队列):底层由数组实现,容量一旦创建,就不能修改。 ⭐️线程池处理任务的流程了解吗? # 如果当前运行的线程数小于核心线程数,那么就会新建一个线程来执行任务。 如果当前运行的线程数等于或大于核心线程数,但是小于最大线程数,那么就把该任务放入到任务队列里等待执行。 如果向任务队列投放任务失败(任务队列已经满了),但是当前运行的线程数是小于最大线程数的,就新建一个线程来执行任务。 如果当前运行的线程数已经等同于最大线程数了,新建线程将会使当前运行的线程超出最大线程数,那么当前任务会被拒绝,拒绝策略会调用RejectedExecutionHandler.rejectedExecution()方法。 再提一个有意思的小问题:线程池在提交任务前,可以提前创建线程吗?\n答案是可以的!ThreadPoolExecutor 提供了两个方法帮助我们在提交任务之前,完成核心线程的创建,从而实现线程池预热的效果:\nprestartCoreThread():启动一个线程,等待任务,如果已达到核心线程数,这个方法返回 false,否则返回 true; prestartAllCoreThreads():启动所有的核心线程,并返回启动成功的核心线程数。 ⭐️线程池中线程异常后,销毁还是复用? # 直接说结论,需要分两种情况:\n使用execute()提交任务:当任务通过execute()提交到线程池并在执行过程中抛出异常时,如果这个异常没有在任务内被捕获,那么该异常会导致当前线程终止,并且异常会被打印到控制台或日志文件中。线程池会检测到这种线程终止,并创建一个新线程来替换它,从而保持配置的线程数不变。 使用submit()提交任务:对于通过submit()提交的任务,如果在任务执行中发生异常,这个异常不会直接打印出来。相反,异常会被封装在由submit()返回的Future对象中。当调用Future.get()方法时,可以捕获到一个ExecutionException。在这种情况下,线程不会因为异常而终止,它会继续存在于线程池中,准备执行后续的任务。 简单来说:使用execute()时,未捕获异常导致线程终止,线程池创建新线程替代;使用submit()时,异常被封装在Future中,线程继续复用。\n这种设计允许submit()提供更灵活的错误处理机制,因为它允许调用者决定如何处理异常,而execute()则适用于那些不需要关注执行结果的场景。\n具体的源码分析可以参考这篇: 线程池中线程异常后:销毁还是复用? - 京东技术。\n⭐️如何给线程池命名? # 初始化线程池的时候需要显示命名(设置线程池名称前缀),有利于定位问题。\n默认情况下创建的线程名字类似 pool-1-thread-n 这样的,没有业务含义,不利于我们定位问题。\n给线程池里的线程命名通常有下面两种方式:\n1、利用 guava 的 ThreadFactoryBuilder\nThreadFactory threadFactory = new ThreadFactoryBuilder() .setNameFormat(threadNamePrefix + \u0026#34;-%d\u0026#34;) .setDaemon(true).build(); ExecutorService threadPool = new ThreadPoolExecutor(corePoolSize, maximumPoolSize, keepAliveTime, TimeUnit.MINUTES, workQueue, threadFactory); 2、自己实现 ThreadFactory。\nimport java.util.concurrent.ThreadFactory; import java.util.concurrent.atomic.AtomicInteger; /** * 线程工厂,它设置线程名称,有利于我们定位问题。 */ public final class NamingThreadFactory implements ThreadFactory { private final AtomicInteger threadNum = new AtomicInteger(); private final String name; /** * 创建一个带名字的线程池生产工厂 */ public NamingThreadFactory(String name) { this.name = name; } @Override public Thread newThread(Runnable r) { Thread t = new Thread(r); t.setName(name + \u0026#34; [#\u0026#34; + threadNum.incrementAndGet() + \u0026#34;]\u0026#34;); return t; } } 如何设定线程池的大小? # 很多人甚至可能都会觉得把线程池配置过大一点比较好!我觉得这明显是有问题的。就拿我们生活中非常常见的一例子来说:并不是人多就能把事情做好,增加了沟通交流成本。你本来一件事情只需要 3 个人做,你硬是拉来了 6 个人,会提升做事效率嘛?我想并不会。 线程数量过多的影响也是和我们分配多少人做事情一样,对于多线程这个场景来说主要是增加了上下文切换成本。不清楚什么是上下文切换的话,可以看我下面的介绍。\n上下文切换:\n多线程编程中一般线程的个数都大于 CPU 核心的个数,而一个 CPU 核心在任意时刻只能被一个线程使用,为了让这些线程都能得到有效执行,CPU 采取的策略是为每个线程分配时间片并轮转的形式。当一个线程的时间片用完的时候就会重新处于就绪状态让给其他线程使用,这个过程就属于一次上下文切换。概括来说就是:当前任务在执行完 CPU 时间片切换到另一个任务之前会先保存自己的状态,以便下次再切换回这个任务时,可以再加载这个任务的状态。任务从保存到再加载的过程就是一次上下文切换。\n上下文切换通常是计算密集型的。也就是说,它需要相当可观的处理器时间,在每秒几十上百次的切换中,每次切换都需要纳秒量级的时间。所以,上下文切换对系统来说意味着消耗大量的 CPU 时间,事实上,可能是操作系统中时间消耗最大的操作。\nLinux 相比与其他操作系统(包括其他类 Unix 系统)有很多的优点,其中有一项就是,其上下文切换和模式切换的时间消耗非常少。\n类比于现实世界中的人类通过合作做某件事情,我们可以肯定的一点是线程池大小设置过大或者过小都会有问题,合适的才是最好。\n如果我们设置的线程池数量太小的话,如果同一时间有大量任务/请求需要处理,可能会导致大量的请求/任务在任务队列中排队等待执行,甚至会出现任务队列满了之后任务/请求无法处理的情况,或者大量任务堆积在任务队列导致 OOM。这样很明显是有问题的,CPU 根本没有得到充分利用。 如果我们设置线程数量太大,大量线程可能会同时在争取 CPU 资源,这样会导致大量的上下文切换,从而增加线程的执行时间,影响了整体执行效率。 有一个简单并且适用面比较广的公式:\nCPU 密集型任务(N+1): 这种任务消耗的主要是 CPU 资源,可以将线程数设置为 N(CPU 核心数)+1。比 CPU 核心数多出来的一个线程是为了防止线程偶发的缺页中断,或者其它原因导致的任务暂停而带来的影响。一旦任务暂停,CPU 就会处于空闲状态,而在这种情况下多出来的一个线程就可以充分利用 CPU 的空闲时间。 I/O 密集型任务(2N): 这种任务应用起来,系统会用大部分的时间来处理 I/O 交互,而线程在处理 I/O 的时间段内不会占用 CPU 来处理,这时就可以将 CPU 交出给其它线程使用。因此在 I/O 密集型任务的应用中,我们可以多配置一些线程,具体的计算方法是 2N。 如何判断是 CPU 密集任务还是 IO 密集任务?\nCPU 密集型简单理解就是利用 CPU 计算能力的任务比如你在内存中对大量数据进行排序。但凡涉及到网络读取,文件读取这类都是 IO 密集型,这类任务的特点是 CPU 计算耗费时间相比于等待 IO 操作完成的时间来说很少,大部分时间都花在了等待 IO 操作完成上。\n🌈 拓展一下(参见: issue#1737):\n线程数更严谨的计算的方法应该是:最佳线程数 = N(CPU 核心数)∗(1+WT(线程等待时间)/ST(线程计算时间)),其中 WT(线程等待时间)=线程运行总时间 - ST(线程计算时间)。\n线程等待时间所占比例越高,需要越多线程。线程计算时间所占比例越高,需要越少线程。\n我们可以通过 JDK 自带的工具 VisualVM 来查看 WT/ST 比例。\nCPU 密集型任务的 WT/ST 接近或者等于 0,因此, 线程数可以设置为 N(CPU 核心数)∗(1+0)= N,和我们上面说的 N(CPU 核心数)+1 差不多。\nIO 密集型任务下,几乎全是线程等待时间,从理论上来说,你就可以将线程数设置为 2N(按道理来说,WT/ST 的结果应该比较大,这里选择 2N 的原因应该是为了避免创建过多线程吧)。\n公式也只是参考,具体还是要根据项目实际线上运行情况来动态调整。我在后面介绍的美团的线程池参数动态配置这种方案就非常不错,很实用!\n⭐️如何动态修改线程池的参数? # 美团技术团队在 《Java 线程池实现原理及其在美团业务中的实践》这篇文章中介绍到对线程池参数实现可自定义配置的思路和方法。\n美团技术团队的思路是主要对线程池的核心参数实现自定义可配置。这三个核心参数是:\ncorePoolSize : 核心线程数线程数定义了最小可以同时运行的线程数量。 maximumPoolSize : 当队列中存放的任务达到队列容量的时候,当前可以同时运行的线程数量变为最大线程数。 workQueue: 当新任务来的时候会先判断当前运行的线程数量是否达到核心线程数,如果达到的话,新任务就会被存放在队列中。 为什么是这三个参数?\n我在 Java 线程池详解 这篇文章中就说过这三个参数是 ThreadPoolExecutor 最重要的参数,它们基本决定了线程池对于任务的处理策略。\n如何支持参数动态配置? 且看 ThreadPoolExecutor 提供的下面这些方法。\n格外需要注意的是corePoolSize, 程序运行期间的时候,我们调用 setCorePoolSize()这个方法的话,线程池会首先判断当前工作线程数是否大于corePoolSize,如果大于的话就会回收工作线程。\n另外,你也看到了上面并没有动态指定队列长度的方法,美团的方式是自定义了一个叫做 ResizableCapacityLinkedBlockIngQueue 的队列(主要就是把LinkedBlockingQueue的 capacity 字段的 final 关键字修饰给去掉了,让它变为可变的)。\n最终实现的可动态修改线程池参数效果如下。👏👏👏\n还没看够?我在 《后端面试高频系统设计\u0026amp;场景题》中详细介绍了如何设计一个动态线程池,这也是面试中常问的一道系统设计题。\n如果我们的项目也想要实现这种效果的话,可以借助现成的开源项目:\nHippo4j:异步线程池框架,支持线程池动态变更\u0026amp;监控\u0026amp;报警,无需修改代码轻松引入。支持多种使用模式,轻松引入,致力于提高系统运行保障能力。 Dynamic TP:轻量级动态线程池,内置监控告警功能,集成三方中间件线程池管理,基于主流配置中心(已支持 Nacos、Apollo,Zookeeper、Consul、Etcd,可通过 SPI 自定义实现)。 ⭐️如何设计一个能够根据任务的优先级来执行的线程池? # 这是一个常见的面试问题,本质其实还是在考察求职者对于线程池以及阻塞队列的掌握。\n我们上面也提到了,不同的线程池会选用不同的阻塞队列作为任务队列,比如FixedThreadPool 使用的是LinkedBlockingQueue(有界队列),默认构造器初始的队列长度为 Integer.MAX_VALUE ,由于队列永远不会被放满,因此FixedThreadPool最多只能创建核心线程数的线程。\n假如我们需要实现一个优先级任务线程池的话,那可以考虑使用 PriorityBlockingQueue (优先级阻塞队列)作为任务队列(ThreadPoolExecutor 的构造函数有一个 workQueue 参数可以传入任务队列)。\nPriorityBlockingQueue 是一个支持优先级的无界阻塞队列,可以看作是线程安全的 PriorityQueue,两者底层都是使用小顶堆形式的二叉堆,即值最小的元素优先出队。不过,PriorityQueue 不支持阻塞操作。\n要想让 PriorityBlockingQueue 实现对任务的排序,传入其中的任务必须是具备排序能力的,方式有两种:\n提交到线程池的任务实现 Comparable 接口,并重写 compareTo 方法来指定任务之间的优先级比较规则。 创建 PriorityBlockingQueue 时传入一个 Comparator 对象来指定任务之间的排序规则(推荐)。 不过,这存在一些风险和问题,比如:\nPriorityBlockingQueue 是无界的,可能堆积大量的请求,从而导致 OOM。 可能会导致饥饿问题,即低优先级的任务长时间得不到执行。 由于需要对队列中的元素进行排序操作以及保证线程安全(并发控制采用的是可重入锁 ReentrantLock),因此会降低性能。 对于 OOM 这个问题的解决比较简单粗暴,就是继承PriorityBlockingQueue 并重写一下 offer 方法(入队)的逻辑,当插入的元素数量超过指定值就返回 false 。\n饥饿问题这个可以通过优化设计来解决(比较麻烦),比如等待时间过长的任务会被移除并重新添加到队列中,但是优先级会被提升。\n对于性能方面的影响,是没办法避免的,毕竟需要对任务进行排序操作。并且,对于大部分业务场景来说,这点性能影响是可以接受的。\nFuture # 重点是要掌握 CompletableFuture 的使用以及常见面试题。\n除了下面的面试题之外,还推荐你看看我写的这篇文章: CompletableFuture 详解。\nFuture 类有什么用? # Future 类是异步思想的典型运用,主要用在一些需要执行耗时任务的场景,避免程序一直原地等待耗时任务执行完成,执行效率太低。具体来说是这样的:当我们执行某一耗时的任务时,可以将这个耗时任务交给一个子线程去异步执行,同时我们可以干点其他事情,不用傻傻等待耗时任务执行完成。等我们的事情干完后,我们再通过 Future 类获取到耗时任务的执行结果。这样一来,程序的执行效率就明显提高了。\n这其实就是多线程中经典的 Future 模式,你可以将其看作是一种设计模式,核心思想是异步调用,主要用在多线程领域,并非 Java 语言独有。\n在 Java 中,Future 类只是一个泛型接口,位于 java.util.concurrent 包下,其中定义了 5 个方法,主要包括下面这 4 个功能:\n取消任务; 判断任务是否被取消; 判断任务是否已经执行完成; 获取任务执行结果。 // V 代表了Future执行的任务返回值的类型 public interface Future\u0026lt;V\u0026gt; { // 取消任务执行 // 成功取消返回 true,否则返回 false boolean cancel(boolean mayInterruptIfRunning); // 判断任务是否被取消 boolean isCancelled(); // 判断任务是否已经执行完成 boolean isDone(); // 获取任务执行结果 V get() throws InterruptedException, ExecutionException; // 指定时间内没有返回计算结果就抛出 TimeOutException 异常 V get(long timeout, TimeUnit unit) throws InterruptedException, ExecutionException, TimeoutExceptio } 简单理解就是:我有一个任务,提交给了 Future 来处理。任务执行期间我自己可以去做任何想做的事情。并且,在这期间我还可以取消任务以及获取任务的执行状态。一段时间之后,我就可以 Future 那里直接取出任务执行结果。\nCallable 和 Future 有什么关系? # 我们可以通过 FutureTask 来理解 Callable 和 Future 之间的关系。\nFutureTask 提供了 Future 接口的基本实现,常用来封装 Callable 和 Runnable,具有取消任务、查看任务是否执行完成以及获取任务执行结果的方法。ExecutorService.submit() 方法返回的其实就是 Future 的实现类 FutureTask 。\n\u0026lt;T\u0026gt; Future\u0026lt;T\u0026gt; submit(Callable\u0026lt;T\u0026gt; task); Future\u0026lt;?\u0026gt; submit(Runnable task); FutureTask 不光实现了 Future接口,还实现了Runnable 接口,因此可以作为任务直接被线程执行。\nFutureTask 有两个构造函数,可传入 Callable 或者 Runnable 对象。实际上,传入 Runnable 对象也会在方法内部转换为Callable 对象。\npublic FutureTask(Callable\u0026lt;V\u0026gt; callable) { if (callable == null) throw new NullPointerException(); this.callable = callable; this.state = NEW; } public FutureTask(Runnable runnable, V result) { // 通过适配器RunnableAdapter来将Runnable对象runnable转换成Callable对象 this.callable = Executors.callable(runnable, result); this.state = NEW; } FutureTask相当于对Callable 进行了封装,管理着任务执行的情况,存储了 Callable 的 call 方法的任务执行结果。\nCompletableFuture 类有什么用? # Future 在实际使用过程中存在一些局限性比如不支持异步任务的编排组合、获取计算结果的 get() 方法为阻塞调用。\nJava 8 才被引入CompletableFuture 类可以解决Future 的这些缺陷。CompletableFuture 除了提供了更为好用和强大的 Future 特性之外,还提供了函数式编程、异步任务编排组合(可以将多个异步任务串联起来,组成一个完整的链式调用)等能力。\n下面我们来简单看看 CompletableFuture 类的定义。\npublic class CompletableFuture\u0026lt;T\u0026gt; implements Future\u0026lt;T\u0026gt;, CompletionStage\u0026lt;T\u0026gt; { } 可以看到,CompletableFuture 同时实现了 Future 和 CompletionStage 接口。\nCompletionStage 接口描述了一个异步计算的阶段。很多计算可以分成多个阶段或步骤,此时可以通过它将所有步骤组合起来,形成异步计算的流水线。\nCompletionStage 接口中的方法比较多,CompletableFuture 的函数式能力就是这个接口赋予的。从这个接口的方法参数你就可以发现其大量使用了 Java8 引入的函数式编程。\n⭐️一个任务需要依赖另外两个任务执行完之后再执行,怎么设计? # 这种任务编排场景非常适合通过CompletableFuture实现。这里假设要实现 T3 在 T2 和 T1 执行完后执行。\n代码如下(这里为了简化代码,用到了 Hutool 的线程工具类 ThreadUtil 和日期时间工具类 DateUtil):\n// T1 CompletableFuture\u0026lt;Void\u0026gt; futureT1 = CompletableFuture.runAsync(() -\u0026gt; { System.out.println(\u0026#34;T1 is executing. Current time:\u0026#34; + DateUtil.now()); // 模拟耗时操作 ThreadUtil.sleep(1000); }); // T2 CompletableFuture\u0026lt;Void\u0026gt; futureT2 = CompletableFuture.runAsync(() -\u0026gt; { System.out.println(\u0026#34;T2 is executing. Current time:\u0026#34; + DateUtil.now()); ThreadUtil.sleep(1000); }); // 使用allOf()方法合并T1和T2的CompletableFuture,等待它们都完成 CompletableFuture\u0026lt;Void\u0026gt; bothCompleted = CompletableFuture.allOf(futureT1, futureT2); // 当T1和T2都完成后,执行T3 bothCompleted.thenRunAsync(() -\u0026gt; System.out.println(\u0026#34;T3 is executing after T1 and T2 have completed.Current time:\u0026#34; + DateUtil.now())); // 等待所有任务完成,验证效果 ThreadUtil.sleep(3000); 通过 CompletableFuture 的 allOf()这个静态方法来并行运行 T1 和 T2 。当 T1 和\n⭐️使用 CompletableFuture,有一个任务失败,如何处理异常? # 使用 CompletableFuture的时候一定要以正确的方式进行异常处理,避免异常丢失或者出现不可控问题。\n下面是一些建议:\n使用 whenComplete 方法可以在任务完成时触发回调函数,并正确地处理异常,而不是让异常被吞噬或丢失。 使用 exceptionally 方法可以处理异常并重新抛出,以便异常能够传播到后续阶段,而不是让异常被忽略或终止。 使用 handle 方法可以处理正常的返回结果和异常,并返回一个新的结果,而不是让异常影响正常的业务逻辑。 使用 CompletableFuture.allOf 方法可以组合多个 CompletableFuture,并统一处理所有任务的异常,而不是让异常处理过于冗长或重复。 …… ⭐️在使用 CompletableFuture 的时候为什么要自定义线程池? # CompletableFuture 默认使用全局共享的 ForkJoinPool.commonPool() 作为执行器,所有未指定执行器的异步任务都会使用该线程池。这意味着应用程序、多个库或框架(如 Spring、第三方库)若都依赖 CompletableFuture,默认情况下它们都会共享同一个线程池。\n虽然 ForkJoinPool 效率很高,但当同时提交大量任务时,可能会导致资源竞争和线程饥饿,进而影响系统性能。\n为避免这些问题,建议为 CompletableFuture 提供自定义线程池,带来以下优势:\n隔离性:为不同任务分配独立的线程池,避免全局线程池资源争夺。 资源控制:根据任务特性调整线程池大小和队列类型,优化性能表现。 异常处理:通过自定义 ThreadFactory 更好地处理线程中的异常情况。 private ThreadPoolExecutor executor = new ThreadPoolExecutor(10, 10, 0L, TimeUnit.MILLISECONDS, new LinkedBlockingQueue\u0026lt;Runnable\u0026gt;()); CompletableFuture.runAsync(() -\u0026gt; { //... }, executor); AQS # 关于 AQS 源码的详细分析,可以看看这一篇文章: AQS 详解。\nAQS 是什么? # AQS (AbstractQueuedSynchronizer ,抽象队列同步器)是从 JDK1.5 开始提供的 Java 并发核心组件。\nAQS 解决了开发者在实现同步器时的复杂性问题。它提供了一个通用框架,用于实现各种同步器,例如 可重入锁(ReentrantLock)、信号量(Semaphore)和 倒计时器(CountDownLatch)。通过封装底层的线程同步机制,AQS 将复杂的线程管理逻辑隐藏起来,使开发者只需专注于具体的同步逻辑。\n简单来说,AQS 是一个抽象类,为同步器提供了通用的 执行框架。它定义了 资源获取和释放的通用流程,而具体的资源获取逻辑则由具体同步器通过重写模板方法来实现。 因此,可以将 AQS 看作是同步器的 基础“底座”,而同步器则是基于 AQS 实现的 具体“应用”。\n⭐️AQS 的原理是什么? # AQS 核心思想是,如果被请求的共享资源空闲,则将当前请求资源的线程设置为有效的工作线程,并且将共享资源设置为锁定状态。如果被请求的共享资源被占用,那么就需要一套线程阻塞等待以及被唤醒时锁分配的机制,这个机制 AQS 是基于 CLH 锁 (Craig, Landin, and Hagersten locks) 进一步优化实现的。\nCLH 锁 对自旋锁进行了改进,是基于单链表的自旋锁。在多线程场景下,会将请求获取锁的线程组织成一个单向队列,每个等待的线程会通过自旋访问前一个线程节点的状态,前一个节点释放锁之后,当前节点才可以获取锁。CLH 锁 的队列结构如下图所示。\nAQS 中使用的 等待队列 是 CLH 锁队列的变体(接下来简称为 CLH 变体队列)。\nAQS 的 CLH 变体队列是一个双向队列,会暂时获取不到锁的线程将被加入到该队列中,CLH 变体队列和原本的 CLH 锁队列的区别主要有两点:\n由 自旋 优化为 自旋 + 阻塞 :自旋操作的性能很高,但大量的自旋操作比较占用 CPU 资源,因此在 CLH 变体队列中会先通过自旋尝试获取锁,如果失败再进行阻塞等待。 由 单向队列 优化为 双向队列 :在 CLH 变体队列中,会对等待的线程进行阻塞操作,当队列前边的线程释放锁之后,需要对后边的线程进行唤醒,因此增加了 next 指针,成为了双向队列。 AQS 将每条请求共享资源的线程封装成一个 CLH 变体队列的一个结点(Node)来实现锁的分配。在 CLH 变体队列中,一个节点表示一个线程,它保存着线程的引用(thread)、 当前节点在队列中的状态(waitStatus)、前驱节点(prev)、后继节点(next)。\nAQS 中的 CLH 变体队列结构如下图所示:\nAQS(AbstractQueuedSynchronizer)的核心原理图:\nAQS 使用 int 成员变量 state 表示同步状态,通过内置的 线程等待队列 来完成获取资源线程的排队工作。\nstate 变量由 volatile 修饰,用于展示当前临界资源的获锁情况。\n// 共享变量,使用volatile修饰保证线程可见性 private volatile int state; 另外,状态信息 state 可以通过 protected 类型的getState()、setState()和compareAndSetState() 进行操作。并且,这几个方法都是 final 修饰的,在子类中无法被重写。\n//返回同步状态的当前值 protected final int getState() { return state; } // 设置同步状态的值 protected final void setState(int newState) { state = newState; } //原子地(CAS操作)将同步状态值设置为给定值update如果当前同步状态的值等于expect(期望值) protected final boolean compareAndSetState(int expect, int update) { return unsafe.compareAndSwapInt(this, stateOffset, expect, update); } 以 ReentrantLock 为例,state 初始值为 0,表示未锁定状态。A 线程 lock() 时,会调用 tryAcquire() 独占该锁并将 state+1 。此后,其他线程再 tryAcquire() 时就会失败,直到 A 线程 unlock() 到 state=0(即释放锁)为止,其它线程才有机会获取该锁。当然,释放锁之前,A 线程自己是可以重复获取此锁的(state 会累加),这就是可重入的概念。但要注意,获取多少次就要释放多少次,这样才能保证 state 是能回到零态的。\n再以 CountDownLatch 以例,任务分为 N 个子线程去执行,state 也初始化为 N(注意 N 要与线程个数一致)。这 N 个子线程是并行执行的,每个子线程执行完后countDown() 一次,state 会 CAS(Compare and Swap) 减 1。等到所有子线程都执行完后(即 state=0 ),会 unpark() 主调用线程,然后主调用线程就会从 await() 函数返回,继续后续动作。\nSemaphore 有什么用? # synchronized 和 ReentrantLock 都是一次只允许一个线程访问某个资源,而Semaphore(信号量)可以用来控制同时访问特定资源的线程数量。\nSemaphore 的使用简单,我们这里假设有 N(N\u0026gt;5) 个线程来获取 Semaphore 中的共享资源,下面的代码表示同一时刻 N 个线程中只有 5 个线程能获取到共享资源,其他线程都会阻塞,只有获取到共享资源的线程才能执行。等到有线程释放了共享资源,其他阻塞的线程才能获取到。\n// 初始共享资源数量 final Semaphore semaphore = new Semaphore(5); // 获取1个许可 semaphore.acquire(); // 释放1个许可 semaphore.release(); 当初始的资源个数为 1 的时候,Semaphore 退化为排他锁。\nSemaphore 有两种模式:。\n公平模式: 调用 acquire() 方法的顺序就是获取许可证的顺序,遵循 FIFO; 非公平模式: 抢占式的。 Semaphore 对应的两个构造方法如下:\npublic Semaphore(int permits) { sync = new NonfairSync(permits); } public Semaphore(int permits, boolean fair) { sync = fair ? new FairSync(permits) : new NonfairSync(permits); } 这两个构造方法,都必须提供许可的数量,第二个构造方法可以指定是公平模式还是非公平模式,默认非公平模式。\nSemaphore 通常用于那些资源有明确访问数量限制的场景比如限流(仅限于单机模式,实际项目中推荐使用 Redis +Lua 来做限流)。\nSemaphore 的原理是什么? # Semaphore 是共享锁的一种实现,它默认构造 AQS 的 state 值为 permits,你可以将 permits 的值理解为许可证的数量,只有拿到许可证的线程才能执行。\n调用semaphore.acquire() ,线程尝试获取许可证,如果 state \u0026gt;= 0 的话,则表示可以获取成功。如果获取成功的话,使用 CAS 操作去修改 state 的值 state=state-1。如果 state\u0026lt;0 的话,则表示许可证数量不足。此时会创建一个 Node 节点加入阻塞队列,挂起当前线程。\n/** * 获取1个许可证 */ public void acquire() throws InterruptedException { sync.acquireSharedInterruptibly(1); } /** * 共享模式下获取许可证,获取成功则返回,失败则加入阻塞队列,挂起线程 */ public final void acquireSharedInterruptibly(int arg) throws InterruptedException { if (Thread.interrupted()) throw new InterruptedException(); // 尝试获取许可证,arg为获取许可证个数,当可用许可证数减当前获取的许可证数结果小于0,则创建一个节点加入阻塞队列,挂起当前线程。 if (tryAcquireShared(arg) \u0026lt; 0) doAcquireSharedInterruptibly(arg); } 调用semaphore.release(); ,线程尝试释放许可证,并使用 CAS 操作去修改 state 的值 state=state+1。释放许可证成功之后,同时会唤醒同步队列中的一个线程。被唤醒的线程会重新尝试去修改 state 的值 state=state-1 ,如果 state\u0026gt;=0 则获取令牌成功,否则重新进入阻塞队列,挂起线程。\n// 释放一个许可证 public void release() { sync.releaseShared(1); } // 释放共享锁,同时会唤醒同步队列中的一个线程。 public final boolean releaseShared(int arg) { //释放共享锁 if (tryReleaseShared(arg)) { //唤醒同步队列中的一个线程 doReleaseShared(); return true; } return false; } CountDownLatch 有什么用? # CountDownLatch 允许 count 个线程阻塞在一个地方,直至所有线程的任务都执行完毕。\nCountDownLatch 是一次性的,计数器的值只能在构造方法中初始化一次,之后没有任何机制再次对其设置值,当 CountDownLatch 使用完毕后,它不能再次被使用。\nCountDownLatch 的原理是什么? # CountDownLatch 是共享锁的一种实现,它默认构造 AQS 的 state 值为 count。当线程使用 countDown() 方法时,其实使用了tryReleaseShared方法以 CAS 的操作来减少 state,直至 state 为 0 。当调用 await() 方法的时候,如果 state 不为 0,那就证明任务还没有执行完毕,await() 方法就会一直阻塞,也就是说 await() 方法之后的语句不会被执行。直到count 个线程调用了countDown()使 state 值被减为 0,或者调用await()的线程被中断,该线程才会从阻塞中被唤醒,await() 方法之后的语句得到执行。\n用过 CountDownLatch 么?什么场景下用的? # CountDownLatch 的作用就是 允许 count 个线程阻塞在一个地方,直至所有线程的任务都执行完毕。之前在项目中,有一个使用多线程读取多个文件处理的场景,我用到了 CountDownLatch 。具体场景是下面这样的:\n我们要读取处理 6 个文件,这 6 个任务都是没有执行顺序依赖的任务,但是我们需要返回给用户的时候将这几个文件的处理的结果进行统计整理。\n为此我们定义了一个线程池和 count 为 6 的CountDownLatch对象 。使用线程池处理读取任务,每一个线程处理完之后就将 count-1,调用CountDownLatch对象的 await()方法,直到所有文件读取完之后,才会接着执行后面的逻辑。\n伪代码是下面这样的:\npublic class CountDownLatchExample1 { // 处理文件的数量 private static final int threadCount = 6; public static void main(String[] args) throws InterruptedException { // 创建一个具有固定线程数量的线程池对象(推荐使用构造方法创建) ExecutorService threadPool = Executors.newFixedThreadPool(10); final CountDownLatch countDownLatch = new CountDownLatch(threadCount); for (int i = 0; i \u0026lt; threadCount; i++) { final int threadnum = i; threadPool.execute(() -\u0026gt; { try { //处理文件的业务操作 //...... } catch (InterruptedException e) { e.printStackTrace(); } finally { //表示一个文件已经被完成 countDownLatch.countDown(); } }); } countDownLatch.await(); threadPool.shutdown(); System.out.println(\u0026#34;finish\u0026#34;); } } 有没有可以改进的地方呢?\n可以使用 CompletableFuture 类来改进!Java8 的 CompletableFuture 提供了很多对多线程友好的方法,使用它可以很方便地为我们编写多线程程序,什么异步、串行、并行或者等待所有线程执行完任务什么的都非常方便。\nCompletableFuture\u0026lt;Void\u0026gt; task1 = CompletableFuture.supplyAsync(()-\u0026gt;{ //自定义业务操作 }); ...... CompletableFuture\u0026lt;Void\u0026gt; task6 = CompletableFuture.supplyAsync(()-\u0026gt;{ //自定义业务操作 }); ...... CompletableFuture\u0026lt;Void\u0026gt; headerFuture=CompletableFuture.allOf(task1,.....,task6); try { headerFuture.join(); } catch (Exception ex) { //...... } System.out.println(\u0026#34;all done. \u0026#34;); 上面的代码还可以继续优化,当任务过多的时候,把每一个 task 都列出来不太现实,可以考虑通过循环来添加任务。\n//文件夹位置 List\u0026lt;String\u0026gt; filePaths = Arrays.asList(...) // 异步处理所有文件 List\u0026lt;CompletableFuture\u0026lt;String\u0026gt;\u0026gt; fileFutures = filePaths.stream() .map(filePath -\u0026gt; doSomeThing(filePath)) .collect(Collectors.toList()); // 将他们合并起来 CompletableFuture\u0026lt;Void\u0026gt; allFutures = CompletableFuture.allOf( fileFutures.toArray(new CompletableFuture[fileFutures.size()]) ); CyclicBarrier 有什么用? # CyclicBarrier 和 CountDownLatch 非常类似,它也可以实现线程间的技术等待,但是它的功能比 CountDownLatch 更加复杂和强大。主要应用场景和 CountDownLatch 类似。\nCountDownLatch 的实现是基于 AQS 的,而 CycliBarrier 是基于 ReentrantLock(ReentrantLock 也属于 AQS 同步器)和 Condition 的。\nCyclicBarrier 的字面意思是可循环使用(Cyclic)的屏障(Barrier)。它要做的事情是:让一组线程到达一个屏障(也可以叫同步点)时被阻塞,直到最后一个线程到达屏障时,屏障才会开门,所有被屏障拦截的线程才会继续干活。\nCyclicBarrier 的原理是什么? # CyclicBarrier 内部通过一个 count 变量作为计数器,count 的初始值为 parties 属性的初始化值,每当一个线程到了栅栏这里了,那么就将计数器减 1。如果 count 值为 0 了,表示这是这一代最后一个线程到达栅栏,就尝试执行我们构造方法中输入的任务。\n//每次拦截的线程数 private final int parties; //计数器 private int count; 下面我们结合源码来简单看看。\n1、CyclicBarrier 默认的构造方法是 CyclicBarrier(int parties),其参数表示屏障拦截的线程数量,每个线程调用 await() 方法告诉 CyclicBarrier 我已经到达了屏障,然后当前线程被阻塞。\npublic CyclicBarrier(int parties) { this(parties, null); } public CyclicBarrier(int parties, Runnable barrierAction) { if (parties \u0026lt;= 0) throw new IllegalArgumentException(); this.parties = parties; this.count = parties; this.barrierCommand = barrierAction; } 其中,parties 就代表了有拦截的线程的数量,当拦截的线程数量达到这个值的时候就打开栅栏,让所有线程通过。\n2、当调用 CyclicBarrier 对象调用 await() 方法时,实际上调用的是 dowait(false, 0L)方法。 await() 方法就像树立起一个栅栏的行为一样,将线程挡住了,当拦住的线程数量达到 parties 的值时,栅栏才会打开,线程才得以通过执行。\npublic int await() throws InterruptedException, BrokenBarrierException { try { return dowait(false, 0L); } catch (TimeoutException toe) { throw new Error(toe); // cannot happen } } dowait(false, 0L)方法源码分析如下:\n// 当线程数量或者请求数量达到 count 时 await 之后的方法才会被执行。上面的示例中 count 的值就为 5。 private int count; /** * Main barrier code, covering the various policies. */ private int dowait(boolean timed, long nanos) throws InterruptedException, BrokenBarrierException, TimeoutException { final ReentrantLock lock = this.lock; // 锁住 lock.lock(); try { final Generation g = generation; if (g.broken) throw new BrokenBarrierException(); // 如果线程中断了,抛出异常 if (Thread.interrupted()) { breakBarrier(); throw new InterruptedException(); } // cout减1 int index = --count; // 当 count 数量减为 0 之后说明最后一个线程已经到达栅栏了,也就是达到了可以执行await 方法之后的条件 if (index == 0) { // tripped boolean ranAction = false; try { final Runnable command = barrierCommand; if (command != null) command.run(); ranAction = true; // 将 count 重置为 parties 属性的初始化值 // 唤醒之前等待的线程 // 下一波执行开始 nextGeneration(); return 0; } finally { if (!ranAction) breakBarrier(); } } // loop until tripped, broken, interrupted, or timed out for (;;) { try { if (!timed) trip.await(); else if (nanos \u0026gt; 0L) nanos = trip.awaitNanos(nanos); } catch (InterruptedException ie) { if (g == generation \u0026amp;\u0026amp; ! g.broken) { breakBarrier(); throw ie; } else { // We\u0026#39;re about to finish waiting even if we had not // been interrupted, so this interrupt is deemed to // \u0026#34;belong\u0026#34; to subsequent execution. Thread.currentThread().interrupt(); } } if (g.broken) throw new BrokenBarrierException(); if (g != generation) return index; if (timed \u0026amp;\u0026amp; nanos \u0026lt;= 0L) { breakBarrier(); throw new TimeoutException(); } } } finally { lock.unlock(); } } 虚拟线程 # 虚拟线程在 Java 21 正式发布,这是一项重量级的更新。\n虽然目前面试中问的不多,但还是建议大家去简单了解一下,具体可以阅读这篇文章: 虚拟线程极简入门 。重点搞清楚虚拟线程和平台线程的关系以及虚拟线程的优势即可。\n参考 # 《深入理解 Java 虚拟机》 《实战 Java 高并发程序设计》 Java 线程池的实现原理及其在业务中的最佳实践:阿里云开发者: https://mp.weixin.qq.com/s/icrrxEsbABBvEU0Gym7D5Q 带你了解下 SynchronousQueue(并发队列专题): https://juejin.cn/post/7031196740128768037 阻塞队列 — DelayedWorkQueue 源码分析: https://zhuanlan.zhihu.com/p/310621485 Java 多线程(三)——FutureTask/CompletableFuture: https://www.cnblogs.com/iwehdio/p/14285282.html Java 并发之 AQS 详解: https://www.cnblogs.com/waterystone/p/4920797.html Java 并发包基石-AQS 详解: https://www.cnblogs.com/chengxiao/archive/2017/07/24/7141160.html "},{"id":515,"href":"/zh/docs/technology/Interview/java/concurrent/java-concurrent-questions-02/","title":"Java并发常见面试题总结(中)","section":"Concurrent","content":" ⭐️JMM(Java 内存模型) # JMM(Java 内存模型)相关的问题比较多,也比较重要,于是我单独抽了一篇文章来总结 JMM 相关的知识点和问题: JMM(Java 内存模型)详解 。\n⭐️volatile 关键字 # 如何保证变量的可见性? # 在 Java 中,volatile 关键字可以保证变量的可见性,如果我们将变量声明为 volatile ,这就指示 JVM,这个变量是共享且不稳定的,每次使用它都到主存中进行读取。\nvolatile 关键字其实并非是 Java 语言特有的,在 C 语言里也有,它最原始的意义就是禁用 CPU 缓存。如果我们将一个变量使用 volatile 修饰,这就指示 编译器,这个变量是共享且不稳定的,每次使用它都到主存中进行读取。\nvolatile 关键字能保证数据的可见性,但不能保证数据的原子性。synchronized 关键字两者都能保证。\n如何禁止指令重排序? # 在 Java 中,volatile 关键字除了可以保证变量的可见性,还有一个重要的作用就是防止 JVM 的指令重排序。 如果我们将变量声明为 volatile ,在对这个变量进行读写操作的时候,会通过插入特定的 内存屏障 的方式来禁止指令重排序。\n在 Java 中,Unsafe 类提供了三个开箱即用的内存屏障相关的方法,屏蔽了操作系统底层的差异:\npublic native void loadFence(); public native void storeFence(); public native void fullFence(); 理论上来说,你通过这个三个方法也可以实现和volatile禁止重排序一样的效果,只是会麻烦一些。\n下面我以一个常见的面试题为例讲解一下 volatile 关键字禁止指令重排序的效果。\n面试中面试官经常会说:“单例模式了解吗?来给我手写一下!给我解释一下双重检验锁方式实现单例模式的原理呗!”\n双重校验锁实现对象单例(线程安全):\npublic class Singleton { private volatile static Singleton uniqueInstance; private Singleton() { } public static Singleton getUniqueInstance() { //先判断对象是否已经实例过,没有实例化过才进入加锁代码 if (uniqueInstance == null) { //类对象加锁 synchronized (Singleton.class) { if (uniqueInstance == null) { uniqueInstance = new Singleton(); } } } return uniqueInstance; } } uniqueInstance 采用 volatile 关键字修饰也是很有必要的, uniqueInstance = new Singleton(); 这段代码其实是分为三步执行:\n为 uniqueInstance 分配内存空间 初始化 uniqueInstance 将 uniqueInstance 指向分配的内存地址 但是由于 JVM 具有指令重排的特性,执行顺序有可能变成 1-\u0026gt;3-\u0026gt;2。指令重排在单线程环境下不会出现问题,但是在多线程环境下会导致一个线程获得还没有初始化的实例。例如,线程 T1 执行了 1 和 3,此时 T2 调用 getUniqueInstance() 后发现 uniqueInstance 不为空,因此返回 uniqueInstance,但此时 uniqueInstance 还未被初始化。\nvolatile 可以保证原子性么? # volatile 关键字能保证变量的可见性,但不能保证对变量的操作是原子性的。\n我们通过下面的代码即可证明:\n/** * 微信搜 JavaGuide 回复\u0026#34;面试突击\u0026#34;即可免费领取个人原创的 Java 面试手册 * * @author Guide哥 * @date 2022/08/03 13:40 **/ public class VolatileAtomicityDemo { public volatile static int inc = 0; public void increase() { inc++; } public static void main(String[] args) throws InterruptedException { ExecutorService threadPool = Executors.newFixedThreadPool(5); VolatileAtomicityDemo volatileAtomicityDemo = new VolatileAtomicityDemo(); for (int i = 0; i \u0026lt; 5; i++) { threadPool.execute(() -\u0026gt; { for (int j = 0; j \u0026lt; 500; j++) { volatileAtomicityDemo.increase(); } }); } // 等待1.5秒,保证上面程序执行完成 Thread.sleep(1500); System.out.println(inc); threadPool.shutdown(); } } 正常情况下,运行上面的代码理应输出 2500。但你真正运行了上面的代码之后,你会发现每次输出结果都小于 2500。\n为什么会出现这种情况呢?不是说好了,volatile 可以保证变量的可见性嘛!\n也就是说,如果 volatile 能保证 inc++ 操作的原子性的话。每个线程中对 inc 变量自增完之后,其他线程可以立即看到修改后的值。5 个线程分别进行了 500 次操作,那么最终 inc 的值应该是 5*500=2500。\n很多人会误认为自增操作 inc++ 是原子性的,实际上,inc++ 其实是一个复合操作,包括三步:\n读取 inc 的值。 对 inc 加 1。 将 inc 的值写回内存。 volatile 是无法保证这三个操作是具有原子性的,有可能导致下面这种情况出现:\n线程 1 对 inc 进行读取操作之后,还未对其进行修改。线程 2 又读取了 inc的值并对其进行修改(+1),再将inc 的值写回内存。 线程 2 操作完毕后,线程 1 对 inc的值进行修改(+1),再将inc 的值写回内存。 这也就导致两个线程分别对 inc 进行了一次自增操作后,inc 实际上只增加了 1。\n其实,如果想要保证上面的代码运行正确也非常简单,利用 synchronized、Lock或者AtomicInteger都可以。\n使用 synchronized 改进:\npublic synchronized void increase() { inc++; } 使用 AtomicInteger 改进:\npublic AtomicInteger inc = new AtomicInteger(); public void increase() { inc.getAndIncrement(); } 使用 ReentrantLock 改进:\nLock lock = new ReentrantLock(); public void increase() { lock.lock(); try { inc++; } finally { lock.unlock(); } } ⭐️乐观锁和悲观锁 # 什么是悲观锁? # 悲观锁总是假设最坏的情况,认为共享资源每次被访问的时候就会出现问题(比如共享数据被修改),所以每次在获取资源操作的时候都会上锁,这样其他线程想拿到这个资源就会阻塞直到锁被上一个持有者释放。也就是说,共享资源每次只给一个线程使用,其它线程阻塞,用完后再把资源转让给其它线程。\n像 Java 中synchronized和ReentrantLock等独占锁就是悲观锁思想的实现。\npublic void performSynchronisedTask() { synchronized (this) { // 需要同步的操作 } } private Lock lock = new ReentrantLock(); lock.lock(); try { // 需要同步的操作 } finally { lock.unlock(); } 高并发的场景下,激烈的锁竞争会造成线程阻塞,大量阻塞线程会导致系统的上下文切换,增加系统的性能开销。并且,悲观锁还可能会存在死锁问题,影响代码的正常运行。\n什么是乐观锁? # 乐观锁总是假设最好的情况,认为共享资源每次被访问的时候不会出现问题,线程可以不停地执行,无需加锁也无需等待,只是在提交修改的时候去验证对应的资源(也就是数据)是否被其它线程修改了(具体方法可以使用版本号机制或 CAS 算法)。\n在 Java 中java.util.concurrent.atomic包下面的原子变量类(比如AtomicInteger、LongAdder)就是使用了乐观锁的一种实现方式 CAS 实现的。 // LongAdder 在高并发场景下会比 AtomicInteger 和 AtomicLong 的性能更好 // 代价就是会消耗更多的内存空间(空间换时间) LongAdder sum = new LongAdder(); sum.increment(); 高并发的场景下,乐观锁相比悲观锁来说,不存在锁竞争造成线程阻塞,也不会有死锁的问题,在性能上往往会更胜一筹。但是,如果冲突频繁发生(写占比非常多的情况),会频繁失败和重试,这样同样会非常影响性能,导致 CPU 飙升。\n不过,大量失败重试的问题也是可以解决的,像我们前面提到的 LongAdder以空间换时间的方式就解决了这个问题。\n理论上来说:\n悲观锁通常多用于写比较多的情况(多写场景,竞争激烈),这样可以避免频繁失败和重试影响性能,悲观锁的开销是固定的。不过,如果乐观锁解决了频繁失败和重试这个问题的话(比如LongAdder),也是可以考虑使用乐观锁的,要视实际情况而定。 乐观锁通常多用于写比较少的情况(多读场景,竞争较少),这样可以避免频繁加锁影响性能。不过,乐观锁主要针对的对象是单个共享变量(参考java.util.concurrent.atomic包下面的原子变量类)。 如何实现乐观锁? # 乐观锁一般会使用版本号机制或 CAS 算法实现,CAS 算法相对来说更多一些,这里需要格外注意。\n版本号机制 # 一般是在数据表中加上一个数据版本号 version 字段,表示数据被修改的次数。当数据被修改时,version 值会加一。当线程 A 要更新数据值时,在读取数据的同时也会读取 version 值,在提交更新时,若刚才读取到的 version 值为当前数据库中的 version 值相等时才更新,否则重试更新操作,直到更新成功。\n举一个简单的例子:假设数据库中帐户信息表中有一个 version 字段,当前值为 1 ;而当前帐户余额字段( balance )为 $100 。\n操作员 A 此时将其读出( version=1 ),并从其帐户余额中扣除 $50( $100-$50 )。 在操作员 A 操作的过程中,操作员 B 也读入此用户信息( version=1 ),并从其帐户余额中扣除 $20 ( $100-$20 )。 操作员 A 完成了修改工作,将数据版本号( version=1 ),连同帐户扣除后余额( balance=$50 ),提交至数据库更新,此时由于提交数据版本等于数据库记录当前版本,数据被更新,数据库记录 version 更新为 2 。 操作员 B 完成了操作,也将版本号( version=1 )试图向数据库提交数据( balance=$80 ),但此时比对数据库记录版本时发现,操作员 B 提交的数据版本号为 1 ,数据库记录当前版本也为 2 ,不满足 “ 提交版本必须等于当前版本才能执行更新 “ 的乐观锁策略,因此,操作员 B 的提交被驳回。 这样就避免了操作员 B 用基于 version=1 的旧数据修改的结果覆盖操作员 A 的操作结果的可能。\nCAS 算法 # CAS 的全称是 Compare And Swap(比较与交换) ,用于实现乐观锁,被广泛应用于各大框架中。CAS 的思想很简单,就是用一个预期值和要更新的变量值进行比较,两值相等才会进行更新。\nCAS 是一个原子操作,底层依赖于一条 CPU 的原子指令。\n原子操作 即最小不可拆分的操作,也就是说操作一旦开始,就不能被打断,直到操作完成。\nCAS 涉及到三个操作数:\nV:要更新的变量值(Var) E:预期值(Expected) N:拟写入的新值(New) 当且仅当 V 的值等于 E 时,CAS 通过原子方式用新值 N 来更新 V 的值。如果不等,说明已经有其它线程更新了 V,则当前线程放弃更新。\n举一个简单的例子:线程 A 要修改变量 i 的值为 6,i 原值为 1(V = 1,E=1,N=6,假设不存在 ABA 问题)。\ni 与 1 进行比较,如果相等, 则说明没被其他线程修改,可以被设置为 6 。 i 与 1 进行比较,如果不相等,则说明被其他线程修改,当前线程放弃更新,CAS 操作失败。 当多个线程同时使用 CAS 操作一个变量时,只有一个会胜出,并成功更新,其余均会失败,但失败的线程并不会被挂起,仅是被告知失败,并且允许再次尝试,当然也允许失败的线程放弃操作。\nJava 语言并没有直接实现 CAS,CAS 相关的实现是通过 C++ 内联汇编的形式实现的(JNI 调用)。因此, CAS 的具体实现和操作系统以及 CPU 都有关系。\nsun.misc包下的Unsafe类提供了compareAndSwapObject、compareAndSwapInt、compareAndSwapLong方法来实现的对Object、int、long类型的 CAS 操作\n/** * CAS * @param o 包含要修改field的对象 * @param offset 对象中某field的偏移量 * @param expected 期望值 * @param update 更新值 * @return true | false */ public final native boolean compareAndSwapObject(Object o, long offset, Object expected, Object update); public final native boolean compareAndSwapInt(Object o, long offset, int expected,int update); public final native boolean compareAndSwapLong(Object o, long offset, long expected, long update); 关于 Unsafe 类的详细介绍可以看这篇文章: Java 魔法类 Unsafe 详解 - JavaGuide - 2022 。\nJava 中 CAS 是如何实现的? # 在 Java 中,实现 CAS(Compare-And-Swap, 比较并交换)操作的一个关键类是Unsafe。\nUnsafe类位于sun.misc包下,是一个提供低级别、不安全操作的类。由于其强大的功能和潜在的危险性,它通常用于 JVM 内部或一些需要极高性能和底层访问的库中,而不推荐普通开发者在应用程序中使用。关于 Unsafe类的详细介绍,可以阅读这篇文章:📌 Java 魔法类 Unsafe 详解。\nsun.misc包下的Unsafe类提供了compareAndSwapObject、compareAndSwapInt、compareAndSwapLong方法来实现的对Object、int、long类型的 CAS 操作:\n/** * 以原子方式更新对象字段的值。 * * @param o 要操作的对象 * @param offset 对象字段的内存偏移量 * @param expected 期望的旧值 * @param x 要设置的新值 * @return 如果值被成功更新,则返回 true;否则返回 false */ boolean compareAndSwapObject(Object o, long offset, Object expected, Object x); /** * 以原子方式更新 int 类型的对象字段的值。 */ boolean compareAndSwapInt(Object o, long offset, int expected, int x); /** * 以原子方式更新 long 类型的对象字段的值。 */ boolean compareAndSwapLong(Object o, long offset, long expected, long x); Unsafe类中的 CAS 方法是native方法。native关键字表明这些方法是用本地代码(通常是 C 或 C++)实现的,而不是用 Java 实现的。这些方法直接调用底层的硬件指令来实现原子操作。也就是说,Java 语言并没有直接用 Java 实现 CAS,而是通过 C++ 内联汇编的形式实现的(通过 JNI 调用)。因此,CAS 的具体实现与操作系统以及 CPU 密切相关。\njava.util.concurrent.atomic 包提供了一些用于原子操作的类。这些类利用底层的原子指令,确保在多线程环境下的操作是线程安全的。\n关于这些 Atomic 原子类的介绍和使用,可以阅读这篇文章: Atomic 原子类总结。\nAtomicInteger是 Java 的原子类之一,主要用于对 int 类型的变量进行原子操作,它利用Unsafe类提供的低级别原子操作方法实现无锁的线程安全性。\n下面,我们通过解读AtomicInteger的核心源码(JDK1.8),来说明 Java 如何使用Unsafe类的方法来实现原子操作。\nAtomicInteger核心源码如下:\n// 获取 Unsafe 实例 private static final Unsafe unsafe = Unsafe.getUnsafe(); private static final long valueOffset; static { try { // 获取“value”字段在AtomicInteger类中的内存偏移量 valueOffset = unsafe.objectFieldOffset (AtomicInteger.class.getDeclaredField(\u0026#34;value\u0026#34;)); } catch (Exception ex) { throw new Error(ex); } } // 确保“value”字段的可见性 private volatile int value; // 如果当前值等于预期值,则原子地将值设置为newValue // 使用 Unsafe#compareAndSwapInt 方法进行CAS操作 public final boolean compareAndSet(int expect, int update) { return unsafe.compareAndSwapInt(this, valueOffset, expect, update); } // 原子地将当前值加 delta 并返回旧值 public final int getAndAdd(int delta) { return unsafe.getAndAddInt(this, valueOffset, delta); } // 原子地将当前值加 1 并返回加之前的值(旧值) // 使用 Unsafe#getAndAddInt 方法进行CAS操作。 public final int getAndIncrement() { return unsafe.getAndAddInt(this, valueOffset, 1); } // 原子地将当前值减 1 并返回减之前的值(旧值) public final int getAndDecrement() { return unsafe.getAndAddInt(this, valueOffset, -1); } Unsafe#getAndAddInt源码:\n// 原子地获取并增加整数值 public final int getAndAddInt(Object o, long offset, int delta) { int v; do { // 以 volatile 方式获取对象 o 在内存偏移量 offset 处的整数值 v = getIntVolatile(o, offset); } while (!compareAndSwapInt(o, offset, v, v + delta)); // 返回旧值 return v; } 可以看到,getAndAddInt 使用了 do-while 循环:在compareAndSwapInt操作失败时,会不断重试直到成功。也就是说,getAndAddInt方法会通过 compareAndSwapInt 方法来尝试更新 value 的值,如果更新失败(当前值在此期间被其他线程修改),它会重新获取当前值并再次尝试更新,直到操作成功。\n由于 CAS 操作可能会因为并发冲突而失败,因此通常会与while循环搭配使用,在失败后不断重试,直到操作成功。这就是 自旋锁机制 。\nCAS 算法存在哪些问题? # ABA 问题是 CAS 算法最常见的问题。\nABA 问题 # 如果一个变量 V 初次读取的时候是 A 值,并且在准备赋值的时候检查到它仍然是 A 值,那我们就能说明它的值没有被其他线程修改过了吗?很明显是不能的,因为在这段时间它的值可能被改为其他值,然后又改回 A,那 CAS 操作就会误认为它从来没有被修改过。这个问题被称为 CAS 操作的 \u0026ldquo;ABA\u0026quot;问题。\nABA 问题的解决思路是在变量前面追加上版本号或者时间戳。JDK 1.5 以后的 AtomicStampedReference 类就是用来解决 ABA 问题的,其中的 compareAndSet() 方法就是首先检查当前引用是否等于预期引用,并且当前标志是否等于预期标志,如果全部相等,则以原子方式将该引用和该标志的值设置为给定的更新值。\npublic boolean compareAndSet(V expectedReference, V newReference, int expectedStamp, int newStamp) { Pair\u0026lt;V\u0026gt; current = pair; return expectedReference == current.reference \u0026amp;\u0026amp; expectedStamp == current.stamp \u0026amp;\u0026amp; ((newReference == current.reference \u0026amp;\u0026amp; newStamp == current.stamp) || casPair(current, Pair.of(newReference, newStamp))); } 循环时间长开销大 # CAS 经常会用到自旋操作来进行重试,也就是不成功就一直循环执行直到成功。如果长时间不成功,会给 CPU 带来非常大的执行开销。\n如果 JVM 能够支持处理器提供的pause指令,那么自旋操作的效率将有所提升。pause指令有两个重要作用:\n延迟流水线执行指令:pause指令可以延迟指令的执行,从而减少 CPU 的资源消耗。具体的延迟时间取决于处理器的实现版本,在某些处理器上,延迟时间可能为零。 避免内存顺序冲突:在退出循环时,pause指令可以避免由于内存顺序冲突而导致的 CPU 流水线被清空,从而提高 CPU 的执行效率。 只能保证一个共享变量的原子操作 # CAS 操作仅能对单个共享变量有效。当需要操作多个共享变量时,CAS 就显得无能为力。不过,从 JDK 1.5 开始,Java 提供了AtomicReference类,这使得我们能够保证引用对象之间的原子性。通过将多个变量封装在一个对象中,我们可以使用AtomicReference来执行 CAS 操作。\n除了 AtomicReference 这种方式之外,还可以利用加锁来保证。\nsynchronized 关键字 # synchronized 是什么?有什么用? # synchronized 是 Java 中的一个关键字,翻译成中文是同步的意思,主要解决的是多个线程之间访问资源的同步性,可以保证被它修饰的方法或者代码块在任意时刻只能有一个线程执行。\n在 Java 早期版本中,synchronized 属于 重量级锁,效率低下。这是因为监视器锁(monitor)是依赖于底层的操作系统的 Mutex Lock 来实现的,Java 的线程是映射到操作系统的原生线程之上的。如果要挂起或者唤醒一个线程,都需要操作系统帮忙完成,而操作系统实现线程之间的切换时需要从用户态转换到内核态,这个状态之间的转换需要相对比较长的时间,时间成本相对较高。\n不过,在 Java 6 之后, synchronized 引入了大量的优化如自旋锁、适应性自旋锁、锁消除、锁粗化、偏向锁、轻量级锁等技术来减少锁操作的开销,这些优化让 synchronized 锁的效率提升了很多。因此, synchronized 还是可以在实际项目中使用的,像 JDK 源码、很多开源框架都大量使用了 synchronized 。\n关于偏向锁多补充一点:由于偏向锁增加了 JVM 的复杂性,同时也并没有为所有应用都带来性能提升。因此,在 JDK15 中,偏向锁被默认关闭(仍然可以使用 -XX:+UseBiasedLocking 启用偏向锁),在 JDK18 中,偏向锁已经被彻底废弃(无法通过命令行打开)。\n如何使用 synchronized? # synchronized 关键字的使用方式主要有下面 3 种:\n修饰实例方法 修饰静态方法 修饰代码块 1、修饰实例方法 (锁当前对象实例)\n给当前对象实例加锁,进入同步代码前要获得 当前对象实例的锁 。\nsynchronized void method() { //业务代码 } 2、修饰静态方法 (锁当前类)\n给当前类加锁,会作用于类的所有对象实例 ,进入同步代码前要获得 当前 class 的锁。\n这是因为静态成员不属于任何一个实例对象,归整个类所有,不依赖于类的特定实例,被类的所有实例共享。\nsynchronized static void method() { //业务代码 } 静态 synchronized 方法和非静态 synchronized 方法之间的调用互斥么?不互斥!如果一个线程 A 调用一个实例对象的非静态 synchronized 方法,而线程 B 需要调用这个实例对象所属类的静态 synchronized 方法,是允许的,不会发生互斥现象,因为访问静态 synchronized 方法占用的锁是当前类的锁,而访问非静态 synchronized 方法占用的锁是当前实例对象锁。\n3、修饰代码块 (锁指定对象/类)\n对括号里指定的对象/类加锁:\nsynchronized(object) 表示进入同步代码库前要获得 给定对象的锁。 synchronized(类.class) 表示进入同步代码前要获得 给定 Class 的锁 synchronized(this) { //业务代码 } 总结:\nsynchronized 关键字加到 static 静态方法和 synchronized(class) 代码块上都是是给 Class 类上锁; synchronized 关键字加到实例方法上是给对象实例上锁; 尽量不要使用 synchronized(String a) 因为 JVM 中,字符串常量池具有缓存功能。 构造方法可以用 synchronized 修饰么? # 构造方法不能使用 synchronized 关键字修饰。不过,可以在构造方法内部使用 synchronized 代码块。\n另外,构造方法本身是线程安全的,但如果在构造方法中涉及到共享资源的操作,就需要采取适当的同步措施来保证整个构造过程的线程安全。\n⭐️synchronized 底层原理了解吗? # synchronized 关键字底层原理属于 JVM 层面的东西。\nsynchronized 同步语句块的情况 # public class SynchronizedDemo { public void method() { synchronized (this) { System.out.println(\u0026#34;synchronized 代码块\u0026#34;); } } } 通过 JDK 自带的 javap 命令查看 SynchronizedDemo 类的相关字节码信息:首先切换到类的对应目录执行 javac SynchronizedDemo.java 命令生成编译后的 .class 文件,然后执行javap -c -s -v -l SynchronizedDemo.class。\n从上面我们可以看出:synchronized 同步语句块的实现使用的是 monitorenter 和 monitorexit 指令,其中 monitorenter 指令指向同步代码块的开始位置,monitorexit 指令则指明同步代码块的结束位置。\n上面的字节码中包含一个 monitorenter 指令以及两个 monitorexit 指令,这是为了保证锁在同步代码块代码正常执行以及出现异常的这两种情况下都能被正确释放。\n当执行 monitorenter 指令时,线程试图获取锁也就是获取 对象监视器 monitor 的持有权。\n在 Java 虚拟机(HotSpot)中,Monitor 是基于 C++实现的,由 ObjectMonitor实现的。每个对象中都内置了一个 ObjectMonitor对象。\n另外,wait/notify等方法也依赖于monitor对象,这就是为什么只有在同步的块或者方法中才能调用wait/notify等方法,否则会抛出java.lang.IllegalMonitorStateException的异常的原因。\n在执行monitorenter时,会尝试获取对象的锁,如果锁的计数器为 0 则表示锁可以被获取,获取后将锁计数器设为 1 也就是加 1。\n对象锁的的拥有者线程才可以执行 monitorexit 指令来释放锁。在执行 monitorexit 指令后,将锁计数器设为 0,表明锁被释放,其他线程可以尝试获取锁。\n如果获取对象锁失败,那当前线程就要阻塞等待,直到锁被另外一个线程释放为止。\nsynchronized 修饰方法的的情况 # public class SynchronizedDemo2 { public synchronized void method() { System.out.println(\u0026#34;synchronized 方法\u0026#34;); } } synchronized 修饰的方法并没有 monitorenter 指令和 monitorexit 指令,取而代之的是 ACC_SYNCHRONIZED 标识,该标识指明了该方法是一个同步方法。JVM 通过该 ACC_SYNCHRONIZED 访问标志来辨别一个方法是否声明为同步方法,从而执行相应的同步调用。\n如果是实例方法,JVM 会尝试获取实例对象的锁。如果是静态方法,JVM 会尝试获取当前 class 的锁。\n总结 # synchronized 同步语句块的实现使用的是 monitorenter 和 monitorexit 指令,其中 monitorenter 指令指向同步代码块的开始位置,monitorexit 指令则指明同步代码块的结束位置。\nsynchronized 修饰的方法并没有 monitorenter 指令和 monitorexit 指令,取而代之的是 ACC_SYNCHRONIZED 标识,该标识指明了该方法是一个同步方法。\n不过,两者的本质都是对对象监视器 monitor 的获取。\n相关推荐: Java 锁与线程的那些事 - 有赞技术团队 。\n🧗🏻 进阶一下:学有余力的小伙伴可以抽时间详细研究一下对象监视器 monitor。\nJDK1.6 之后的 synchronized 底层做了哪些优化?锁升级原理了解吗? # 在 Java 6 之后, synchronized 引入了大量的优化如自旋锁、适应性自旋锁、锁消除、锁粗化、偏向锁、轻量级锁等技术来减少锁操作的开销,这些优化让 synchronized 锁的效率提升了很多(JDK18 中,偏向锁已经被彻底废弃,前面已经提到过了)。\n锁主要存在四种状态,依次是:无锁状态、偏向锁状态、轻量级锁状态、重量级锁状态,他们会随着竞争的激烈而逐渐升级。注意锁可以升级不可降级,这种策略是为了提高获得锁和释放锁的效率。\nsynchronized 锁升级是一个比较复杂的过程,面试也很少问到,如果你想要详细了解的话,可以看看这篇文章: 浅析 synchronized 锁升级的原理与实现。\nsynchronized 的偏向锁为什么被废弃了? # Open JDK 官方声明: JEP 374: Deprecate and Disable Biased Locking\n在 JDK15 中,偏向锁被默认关闭(仍然可以使用 -XX:+UseBiasedLocking 启用偏向锁),在 JDK18 中,偏向锁已经被彻底废弃(无法通过命令行打开)。\n在官方声明中,主要原因有两个方面:\n性能收益不明显: 偏向锁是 HotSpot 虚拟机的一项优化技术,可以提升单线程对同步代码块的访问性能。\n受益于偏向锁的应用程序通常使用了早期的 Java 集合 API,例如 HashTable、Vector,在这些集合类中通过 synchronized 来控制同步,这样在单线程频繁访问时,通过偏向锁会减少同步开销。\n随着 JDK 的发展,出现了 ConcurrentHashMap 高性能的集合类,在集合类内部进行了许多性能优化,此时偏向锁带来的性能收益就不明显了。\n偏向锁仅仅在单线程访问同步代码块的场景中可以获得性能收益。\n如果存在多线程竞争,就需要 撤销偏向锁 ,这个操作的性能开销是比较昂贵的。偏向锁的撤销需要等待进入到全局安全点(safe point),该状态下所有线程都是暂停的,此时去检查线程状态并进行偏向锁的撤销。\nJVM 内部代码维护成本太高: 偏向锁将许多复杂代码引入到同步子系统,并且对其他的 HotSpot 组件也具有侵入性。这种复杂性为理解代码、系统重构带来了困难,因此, OpenJDK 官方希望禁用、废弃并删除偏向锁。\n⭐️synchronized 和 volatile 有什么区别? # synchronized 关键字和 volatile 关键字是两个互补的存在,而不是对立的存在!\nvolatile 关键字是线程同步的轻量级实现,所以 volatile性能肯定比synchronized关键字要好 。但是 volatile 关键字只能用于变量而 synchronized 关键字可以修饰方法以及代码块 。 volatile 关键字能保证数据的可见性,但不能保证数据的原子性。synchronized 关键字两者都能保证。 volatile关键字主要用于解决变量在多个线程之间的可见性,而 synchronized 关键字解决的是多个线程之间访问资源的同步性。 ReentrantLock # ReentrantLock 是什么? # ReentrantLock 实现了 Lock 接口,是一个可重入且独占式的锁,和 synchronized 关键字类似。不过,ReentrantLock 更灵活、更强大,增加了轮询、超时、中断、公平锁和非公平锁等高级功能。\npublic class ReentrantLock implements Lock, java.io.Serializable {} ReentrantLock 里面有一个内部类 Sync,Sync 继承 AQS(AbstractQueuedSynchronizer),添加锁和释放锁的大部分操作实际上都是在 Sync 中实现的。Sync 有公平锁 FairSync 和非公平锁 NonfairSync 两个子类。\nReentrantLock 默认使用非公平锁,也可以通过构造器来显式的指定使用公平锁。\n// 传入一个 boolean 值,true 时为公平锁,false 时为非公平锁 public ReentrantLock(boolean fair) { sync = fair ? new FairSync() : new NonfairSync(); } 从上面的内容可以看出, ReentrantLock 的底层就是由 AQS 来实现的。关于 AQS 的相关内容推荐阅读 AQS 详解 这篇文章。\n公平锁和非公平锁有什么区别? # 公平锁 : 锁被释放之后,先申请的线程先得到锁。性能较差一些,因为公平锁为了保证时间上的绝对顺序,上下文切换更频繁。 非公平锁:锁被释放之后,后申请的线程可能会先获取到锁,是随机或者按照其他优先级排序的。性能更好,但可能会导致某些线程永远无法获取到锁。 ⭐️synchronized 和 ReentrantLock 有什么区别? # 两者都是可重入锁 # 可重入锁 也叫递归锁,指的是线程可以再次获取自己的内部锁。比如一个线程获得了某个对象的锁,此时这个对象锁还没有释放,当其再次想要获取这个对象的锁的时候还是可以获取的,如果是不可重入锁的话,就会造成死锁。\nJDK 提供的所有现成的 Lock 实现类,包括 synchronized 关键字锁都是可重入的。\n在下面的代码中,method1() 和 method2()都被 synchronized 关键字修饰,method1()调用了method2()。\npublic class SynchronizedDemo { public synchronized void method1() { System.out.println(\u0026#34;方法1\u0026#34;); method2(); } public synchronized void method2() { System.out.println(\u0026#34;方法2\u0026#34;); } } 由于 synchronized锁是可重入的,同一个线程在调用method1() 时可以直接获得当前对象的锁,执行 method2() 的时候可以再次获取这个对象的锁,不会产生死锁问题。假如synchronized是不可重入锁的话,由于该对象的锁已被当前线程所持有且无法释放,这就导致线程在执行 method2()时获取锁失败,会出现死锁问题。\nsynchronized 依赖于 JVM 而 ReentrantLock 依赖于 API # synchronized 是依赖于 JVM 实现的,前面我们也讲到了 虚拟机团队在 JDK1.6 为 synchronized 关键字进行了很多优化,但是这些优化都是在虚拟机层面实现的,并没有直接暴露给我们。\nReentrantLock 是 JDK 层面实现的(也就是 API 层面,需要 lock() 和 unlock() 方法配合 try/finally 语句块来完成),所以我们可以通过查看它的源代码,来看它是如何实现的。\nReentrantLock 比 synchronized 增加了一些高级功能 # 相比synchronized,ReentrantLock增加了一些高级功能。主要来说主要有三点:\n等待可中断 : ReentrantLock提供了一种能够中断等待锁的线程的机制,通过 lock.lockInterruptibly() 来实现这个机制。也就是说当前线程在等待获取锁的过程中,如果其他线程中断当前线程「 interrupt() 」,当前线程就会抛出 InterruptedException 异常,可以捕捉该异常进行相应处理。 可实现公平锁 : ReentrantLock可以指定是公平锁还是非公平锁。而synchronized只能是非公平锁。所谓的公平锁就是先等待的线程先获得锁。ReentrantLock默认情况是非公平的,可以通过 ReentrantLock类的ReentrantLock(boolean fair)构造方法来指定是否是公平的。 可实现选择性通知(锁可以绑定多个条件): synchronized关键字与wait()和notify()/notifyAll()方法相结合可以实现等待/通知机制。ReentrantLock类当然也可以实现,但是需要借助于Condition接口与newCondition()方法。 支持超时 :ReentrantLock 提供了 tryLock(timeout) 的方法,可以指定等待获取锁的最长等待时间,如果超过了等待时间,就会获取锁失败,不会一直等待。 如果你想使用上述功能,那么选择 ReentrantLock 是一个不错的选择。\n关于 Condition接口的补充:\nCondition是 JDK1.5 之后才有的,它具有很好的灵活性,比如可以实现多路通知功能也就是在一个Lock对象中可以创建多个Condition实例(即对象监视器),线程对象可以注册在指定的Condition中,从而可以有选择性的进行线程通知,在调度线程上更加灵活。 在使用notify()/notifyAll()方法进行通知时,被通知的线程是由 JVM 选择的,用ReentrantLock类结合Condition实例可以实现“选择性通知” ,这个功能非常重要,而且是 Condition 接口默认提供的。而synchronized关键字就相当于整个 Lock 对象中只有一个Condition实例,所有的线程都注册在它一个身上。如果执行notifyAll()方法的话就会通知所有处于等待状态的线程,这样会造成很大的效率问题。而Condition实例的signalAll()方法,只会唤醒注册在该Condition实例中的所有等待线程。\n关于 等待可中断 的补充:\nlockInterruptibly() 会让获取锁的线程在阻塞等待的过程中可以响应中断,即当前线程在获取锁的时候,发现锁被其他线程持有,就会阻塞等待。\n在阻塞等待的过程中,如果其他线程中断当前线程 interrupt() ,就会抛出 InterruptedException 异常,可以捕获该异常,做一些处理操作。\n为了更好理解这个方法,借用 Stack Overflow 上的一个案例,可以更好地理解 lockInterruptibly() 可以响应中断:\npublic class MyRentrantlock { Thread t = new Thread() { @Override public void run() { ReentrantLock r = new ReentrantLock(); // 1.1、第一次尝试获取锁,可以获取成功 r.lock(); // 1.2、此时锁的重入次数为 1 System.out.println(\u0026#34;lock() : lock count :\u0026#34; + r.getHoldCount()); // 2、中断当前线程,通过 Thread.currentThread().isInterrupted() 可以看到当前线程的中断状态为 true interrupt(); System.out.println(\u0026#34;Current thread is intrupted\u0026#34;); // 3.1、尝试获取锁,可以成功获取 r.tryLock(); // 3.2、此时锁的重入次数为 2 System.out.println(\u0026#34;tryLock() on intrupted thread lock count :\u0026#34; + r.getHoldCount()); try { // 4、打印线程的中断状态为 true,那么调用 lockInterruptibly() 方法就会抛出 InterruptedException 异常 System.out.println(\u0026#34;Current Thread isInterrupted:\u0026#34; + Thread.currentThread().isInterrupted()); r.lockInterruptibly(); System.out.println(\u0026#34;lockInterruptibly() --NOt executable statement\u0026#34; + r.getHoldCount()); } catch (InterruptedException e) { r.lock(); System.out.println(\u0026#34;Error\u0026#34;); } finally { r.unlock(); } // 5、打印锁的重入次数,可以发现 lockInterruptibly() 方法并没有成功获取到锁 System.out.println(\u0026#34;lockInterruptibly() not able to Acqurie lock: lock count :\u0026#34; + r.getHoldCount()); r.unlock(); System.out.println(\u0026#34;lock count :\u0026#34; + r.getHoldCount()); r.unlock(); System.out.println(\u0026#34;lock count :\u0026#34; + r.getHoldCount()); } }; public static void main(String str[]) { MyRentrantlock m = new MyRentrantlock(); m.t.start(); } } 输出:\nlock() : lock count :1 Current thread is intrupted tryLock() on intrupted thread lock count :2 Current Thread isInterrupted:true Error lockInterruptibly() not able to Acqurie lock: lock count :2 lock count :1 lock count :0 关于 支持超时 的补充:\n为什么需要 tryLock(timeout) 这个功能呢?\ntryLock(timeout) 方法尝试在指定的超时时间内获取锁。如果成功获取锁,则返回 true;如果在锁可用之前超时,则返回 false。此功能在以下几种场景中非常有用:\n防止死锁: 在复杂的锁场景中,tryLock(timeout) 可以通过允许线程在合理的时间内放弃并重试来帮助防止死锁。 提高响应速度: 防止线程无限期阻塞。 处理时间敏感的操作: 对于具有严格时间限制的操作,tryLock(timeout) 允许线程在无法及时获取锁时继续执行替代操作。 可中断锁和不可中断锁有什么区别? # 可中断锁:获取锁的过程中可以被中断,不需要一直等到获取锁之后 才能进行其他逻辑处理。ReentrantLock 就属于是可中断锁。 不可中断锁:一旦线程申请了锁,就只能等到拿到锁以后才能进行其他的逻辑处理。 synchronized 就属于是不可中断锁。 ReentrantReadWriteLock # ReentrantReadWriteLock 在实际项目中使用的并不多,面试中也问的比较少,简单了解即可。JDK 1.8 引入了性能更好的读写锁 StampedLock 。\nReentrantReadWriteLock 是什么? # ReentrantReadWriteLock 实现了 ReadWriteLock ,是一个可重入的读写锁,既可以保证多个线程同时读的效率,同时又可以保证有写入操作时的线程安全。\npublic class ReentrantReadWriteLock implements ReadWriteLock, java.io.Serializable{ } public interface ReadWriteLock { Lock readLock(); Lock writeLock(); } 一般锁进行并发控制的规则:读读互斥、读写互斥、写写互斥。 读写锁进行并发控制的规则:读读不互斥、读写互斥、写写互斥(只有读读不互斥)。 ReentrantReadWriteLock 其实是两把锁,一把是 WriteLock (写锁),一把是 ReadLock(读锁) 。读锁是共享锁,写锁是独占锁。读锁可以被同时读,可以同时被多个线程持有,而写锁最多只能同时被一个线程持有。\n和 ReentrantLock 一样,ReentrantReadWriteLock 底层也是基于 AQS 实现的。\nReentrantReadWriteLock 也支持公平锁和非公平锁,默认使用非公平锁,可以通过构造器来显示的指定。\n// 传入一个 boolean 值,true 时为公平锁,false 时为非公平锁 public ReentrantReadWriteLock(boolean fair) { sync = fair ? new FairSync() : new NonfairSync(); readerLock = new ReadLock(this); writerLock = new WriteLock(this); } ReentrantReadWriteLock 适合什么场景? # 由于 ReentrantReadWriteLock 既可以保证多个线程同时读的效率,同时又可以保证有写入操作时的线程安全。因此,在读多写少的情况下,使用 ReentrantReadWriteLock 能够明显提升系统性能。\n共享锁和独占锁有什么区别? # 共享锁:一把锁可以被多个线程同时获得。 独占锁:一把锁只能被一个线程获得。 线程持有读锁还能获取写锁吗? # 在线程持有读锁的情况下,该线程不能取得写锁(因为获取写锁的时候,如果发现当前的读锁被占用,就马上获取失败,不管读锁是不是被当前线程持有)。 在线程持有写锁的情况下,该线程可以继续获取读锁(获取读锁时如果发现写锁被占用,只有写锁没有被当前线程占用的情况才会获取失败)。 读写锁的源码分析,推荐阅读 聊聊 Java 的几把 JVM 级锁 - 阿里巴巴中间件 这篇文章,写的很不错。\n读锁为什么不能升级为写锁? # 写锁可以降级为读锁,但是读锁却不能升级为写锁。这是因为读锁升级为写锁会引起线程的争夺,毕竟写锁属于是独占锁,这样的话,会影响性能。\n另外,还可能会有死锁问题发生。举个例子:假设两个线程的读锁都想升级写锁,则需要对方都释放自己锁,而双方都不释放,就会产生死锁。\nStampedLock # StampedLock 面试中问的比较少,不是很重要,简单了解即可。\nStampedLock 是什么? # StampedLock 是 JDK 1.8 引入的性能更好的读写锁,不可重入且不支持条件变量 Condition。\n不同于一般的 Lock 类,StampedLock 并不是直接实现 Lock或 ReadWriteLock接口,而是基于 CLH 锁 独立实现的(AQS 也是基于这玩意)。\npublic class StampedLock implements java.io.Serializable { } StampedLock 提供了三种模式的读写控制模式:读锁、写锁和乐观读。\n写锁:独占锁,一把锁只能被一个线程获得。当一个线程获取写锁后,其他请求读锁和写锁的线程必须等待。类似于 ReentrantReadWriteLock 的写锁,不过这里的写锁是不可重入的。 读锁 (悲观读):共享锁,没有线程获取写锁的情况下,多个线程可以同时持有读锁。如果己经有线程持有写锁,则其他线程请求获取该读锁会被阻塞。类似于 ReentrantReadWriteLock 的读锁,不过这里的读锁是不可重入的。 乐观读:允许多个线程获取乐观读以及读锁。同时允许一个写线程获取写锁。 另外,StampedLock 还支持这三种锁在一定条件下进行相互转换 。\nlong tryConvertToWriteLock(long stamp){} long tryConvertToReadLock(long stamp){} long tryConvertToOptimisticRead(long stamp){} StampedLock 在获取锁的时候会返回一个 long 型的数据戳,该数据戳用于稍后的锁释放参数,如果返回的数据戳为 0 则表示锁获取失败。当前线程持有了锁再次获取锁还是会返回一个新的数据戳,这也是StampedLock不可重入的原因。\n// 写锁 public long writeLock() { long s, next; // bypass acquireWrite in fully unlocked case only return ((((s = state) \u0026amp; ABITS) == 0L \u0026amp;\u0026amp; U.compareAndSwapLong(this, STATE, s, next = s + WBIT)) ? next : acquireWrite(false, 0L)); } // 读锁 public long readLock() { long s = state, next; // bypass acquireRead on common uncontended case return ((whead == wtail \u0026amp;\u0026amp; (s \u0026amp; ABITS) \u0026lt; RFULL \u0026amp;\u0026amp; U.compareAndSwapLong(this, STATE, s, next = s + RUNIT)) ? next : acquireRead(false, 0L)); } // 乐观读 public long tryOptimisticRead() { long s; return (((s = state) \u0026amp; WBIT) == 0L) ? (s \u0026amp; SBITS) : 0L; } StampedLock 的性能为什么更好? # 相比于传统读写锁多出来的乐观读是StampedLock比 ReadWriteLock 性能更好的关键原因。StampedLock 的乐观读允许一个写线程获取写锁,所以不会导致所有写线程阻塞,也就是当读多写少的时候,写线程有机会获取写锁,减少了线程饥饿的问题,吞吐量大大提高。\nStampedLock 适合什么场景? # 和 ReentrantReadWriteLock 一样,StampedLock 同样适合读多写少的业务场景,可以作为 ReentrantReadWriteLock的替代品,性能更好。\n不过,需要注意的是StampedLock不可重入,不支持条件变量 Condition,对中断操作支持也不友好(使用不当容易导致 CPU 飙升)。如果你需要用到 ReentrantLock 的一些高级性能,就不太建议使用 StampedLock 了。\n另外,StampedLock 性能虽好,但使用起来相对比较麻烦,一旦使用不当,就会出现生产问题。强烈建议你在使用StampedLock 之前,看看 StampedLock 官方文档中的案例。\nStampedLock 的底层原理了解吗? # StampedLock 不是直接实现 Lock或 ReadWriteLock接口,而是基于 CLH 锁 实现的(AQS 也是基于这玩意),CLH 锁是对自旋锁的一种改良,是一种隐式的链表队列。StampedLock 通过 CLH 队列进行线程的管理,通过同步状态值 state 来表示锁的状态和类型。\nStampedLock 的原理和 AQS 原理比较类似,这里就不详细介绍了,感兴趣的可以看看下面这两篇文章:\nAQS 详解 StampedLock 底层原理分析 如果你只是准备面试的话,建议多花点精力搞懂 AQS 原理即可,StampedLock 底层原理在面试中遇到的概率非常小。\nAtomic 原子类 # Atomic 原子类部分的内容我单独写了一篇文章来总结: Atomic 原子类总结 。\n参考 # 《深入理解 Java 虚拟机》 《实战 Java 高并发程序设计》 Guide to the Volatile Keyword in Java - Baeldung: https://www.baeldung.com/java-volatile 不可不说的 Java“锁”事 - 美团技术团队: https://tech.meituan.com/2018/11/15/java-lock.html 在 ReadWriteLock 类中读锁为什么不能升级为写锁?: https://cloud.tencent.com/developer/article/1176230 高性能解决线程饥饿的利器 StampedLock: https://mp.weixin.qq.com/s/2Acujjr4BHIhlFsCLGwYSg 理解 Java 中的 ThreadLocal - 技术小黑屋: https://droidyue.com/blog/2016/03/13/learning-threadlocal-in-java/ ThreadLocal (Java Platform SE 8 ) - Oracle Help Center: https://docs.oracle.com/javase/8/docs/api/java/lang/ThreadLocal.html "},{"id":516,"href":"/zh/docs/technology/Interview/java/basis/java-basic-questions-01/","title":"Java基础常见面试题总结(上)","section":"Basis","content":" 基础概念与常识 # Java 语言有哪些特点? # 简单易学(语法简单,上手容易); 面向对象(封装,继承,多态); 平台无关性( Java 虚拟机实现平台无关性); 支持多线程( C++ 语言没有内置的多线程机制,因此必须调用操作系统的多线程功能来进行多线程程序设计,而 Java 语言却提供了多线程支持); 可靠性(具备异常处理和自动内存管理机制); 安全性(Java 语言本身的设计就提供了多重安全防护机制如访问权限修饰符、限制程序直接访问操作系统资源); 高效性(通过 Just In Time 编译器等技术的优化,Java 语言的运行效率还是非常不错的); 支持网络编程并且很方便; 编译与解释并存; …… 🐛 修正(参见: issue#544):C11 开始(2011 年的时候),C就引入了多线程库,在 windows、linux、macos 都可以使用std::thread和std::async来创建线程。参考链接: http://www.cplusplus.com/reference/thread/thread/?kw=thread\n🌈 拓展一下:\n“Write Once, Run Anywhere(一次编写,随处运行)”这句宣传口号,真心经典,流传了好多年!以至于,直到今天,依然有很多人觉得跨平台是 Java 语言最大的优势。实际上,跨平台已经不是 Java 最大的卖点了,各种 JDK 新特性也不是。目前市面上虚拟化技术已经非常成熟,比如你通过 Docker 就很容易实现跨平台了。在我看来,Java 强大的生态才是!\nJava SE vs Java EE # Java SE(Java Platform,Standard Edition): Java 平台标准版,Java 编程语言的基础,它包含了支持 Java 应用程序开发和运行的核心类库以及虚拟机等核心组件。Java SE 可以用于构建桌面应用程序或简单的服务器应用程序。 Java EE(Java Platform, Enterprise Edition ):Java 平台企业版,建立在 Java SE 的基础上,包含了支持企业级应用程序开发和部署的标准和规范(比如 Servlet、JSP、EJB、JDBC、JPA、JTA、JavaMail、JMS)。 Java EE 可以用于构建分布式、可移植、健壮、可伸缩和安全的服务端 Java 应用程序,例如 Web 应用程序。 简单来说,Java SE 是 Java 的基础版本,Java EE 是 Java 的高级版本。Java SE 更适合开发桌面应用程序或简单的服务器应用程序,Java EE 更适合开发复杂的企业级应用程序或 Web 应用程序。\n除了 Java SE 和 Java EE,还有一个 Java ME(Java Platform,Micro Edition)。Java ME 是 Java 的微型版本,主要用于开发嵌入式消费电子设备的应用程序,例如手机、PDA、机顶盒、冰箱、空调等。Java ME 无需重点关注,知道有这个东西就好了,现在已经用不上了。\nJVM vs JDK vs JRE # JVM # Java 虚拟机(Java Virtual Machine, JVM)是运行 Java 字节码的虚拟机。JVM 有针对不同系统的特定实现(Windows,Linux,macOS),目的是使用相同的字节码,它们都会给出相同的结果。字节码和不同系统的 JVM 实现是 Java 语言“一次编译,随处可以运行”的关键所在。\n如下图所示,不同编程语言(Java、Groovy、Kotlin、JRuby、Clojure \u0026hellip;)通过各自的编译器编译成 .class 文件,并最终通过 JVM 在不同平台(Windows、Mac、Linux)上运行。\nJVM 并不是只有一种!只要满足 JVM 规范,每个公司、组织或者个人都可以开发自己的专属 JVM。 也就是说我们平时接触到的 HotSpot VM 仅仅是是 JVM 规范的一种实现而已。\n除了我们平时最常用的 HotSpot VM 外,还有 J9 VM、Zing VM、JRockit VM 等 JVM 。维基百科上就有常见 JVM 的对比: Comparison of Java virtual machines ,感兴趣的可以去看看。并且,你可以在 Java SE Specifications 上找到各个版本的 JDK 对应的 JVM 规范。\nJDK 和 JRE # JDK(Java Development Kit)是一个功能齐全的 Java 开发工具包,供开发者使用,用于创建和编译 Java 程序。它包含了 JRE(Java Runtime Environment),以及编译器 javac 和其他工具,如 javadoc(文档生成器)、jdb(调试器)、jconsole(监控工具)、javap(反编译工具)等。\nJRE 是运行已编译 Java 程序所需的环境,主要包含以下两个部分:\nJVM : 也就是我们上面提到的 Java 虚拟机。 Java 基础类库(Class Library):一组标准的类库,提供常用的功能和 API(如 I/O 操作、网络通信、数据结构等)。 简单来说,JRE 只包含运行 Java 程序所需的环境和类库,而 JDK 不仅包含 JRE,还包括用于开发和调试 Java 程序的工具。\n如果需要编写、编译 Java 程序或使用 Java API 文档,就需要安装 JDK。某些需要 Java 特性的应用程序(如 JSP 转换为 Servlet 或使用反射)也可能需要 JDK 来编译和运行 Java 代码。因此,即使不进行 Java 开发工作,有时也可能需要安装 JDK。\n下图清晰展示了 JDK、JRE 和 JVM 的关系。\n不过,从 JDK 9 开始,就不需要区分 JDK 和 JRE 的关系了,取而代之的是模块系统(JDK 被重新组织成 94 个模块)+ jlink 工具 (随 Java 9 一起发布的新命令行工具,用于生成自定义 Java 运行时映像,该映像仅包含给定应用程序所需的模块) 。并且,从 JDK 11 开始,Oracle 不再提供单独的 JRE 下载。\n在 Java 9 新特性概览这篇文章中,我在介绍模块化系统的时候提到:\n在引入了模块系统之后,JDK 被重新组织成 94 个模块。Java 应用可以通过新增的 jlink 工具,创建出只包含所依赖的 JDK 模块的自定义运行时镜像。这样可以极大的减少 Java 运行时环境的大小。\n也就是说,可以用 jlink 根据自己的需求,创建一个更小的 runtime(运行时),而不是不管什么应用,都是同样的 JRE。\n定制的、模块化的 Java 运行时映像有助于简化 Java 应用的部署和节省内存并增强安全性和可维护性。这对于满足现代应用程序架构的需求,如虚拟化、容器化、微服务和云原生开发,是非常重要的。\n什么是字节码?采用字节码的好处是什么? # 在 Java 中,JVM 可以理解的代码就叫做字节码(即扩展名为 .class 的文件),它不面向任何特定的处理器,只面向虚拟机。Java 语言通过字节码的方式,在一定程度上解决了传统解释型语言执行效率低的问题,同时又保留了解释型语言可移植的特点。所以, Java 程序运行时相对来说还是高效的(不过,和 C、 C++,Rust,Go 等语言还是有一定差距的),而且,由于字节码并不针对一种特定的机器,因此,Java 程序无须重新编译便可在多种不同操作系统的计算机上运行。\nJava 程序从源代码到运行的过程如下图所示:\n我们需要格外注意的是 .class-\u0026gt;机器码 这一步。在这一步 JVM 类加载器首先加载字节码文件,然后通过解释器逐行解释执行,这种方式的执行速度会相对比较慢。而且,有些方法和代码块是经常需要被调用的(也就是所谓的热点代码),所以后面引进了 JIT(Just in Time Compilation) 编译器,而 JIT 属于运行时编译。当 JIT 编译器完成第一次编译后,其会将字节码对应的机器码保存下来,下次可以直接使用。而我们知道,机器码的运行效率肯定是高于 Java 解释器的。这也解释了我们为什么经常会说 Java 是编译与解释共存的语言 。\n🌈 拓展阅读:\n基本功 | Java 即时编译器原理解析及实践 - 美团技术团队 基于静态编译构建微服务应用 - 阿里巴巴中间件 HotSpot 采用了惰性评估(Lazy Evaluation)的做法,根据二八定律,消耗大部分系统资源的只有那一小部分的代码(热点代码),而这也就是 JIT 所需要编译的部分。JVM 会根据代码每次被执行的情况收集信息并相应地做出一些优化,因此执行的次数越多,它的速度就越快。\nJDK、JRE、JVM、JIT 这四者的关系如下图所示。\n下面这张图是 JVM 的大致结构模型。\n为什么说 Java 语言“编译与解释并存”? # 其实这个问题我们讲字节码的时候已经提到过,因为比较重要,所以我们这里再提一下。\n我们可以将高级编程语言按照程序的执行方式分为两种:\n编译型: 编译型语言 会通过 编译器将源代码一次性翻译成可被该平台执行的机器码。一般情况下,编译语言的执行速度比较快,开发效率比较低。常见的编译性语言有 C、C++、Go、Rust 等等。 解释型: 解释型语言会通过 解释器一句一句的将代码解释(interpret)为机器代码后再执行。解释型语言开发效率比较快,执行速度比较慢。常见的解释性语言有 Python、JavaScript、PHP 等等。 根据维基百科介绍:\n为了改善解释语言的效率而发展出的 即时编译技术,已经缩小了这两种语言间的差距。这种技术混合了编译语言与解释型语言的优点,它像编译语言一样,先把程序源代码编译成 字节码。到执行期时,再将字节码直译,之后执行。 Java与 LLVM是这种技术的代表产物。\n相关阅读: 基本功 | Java 即时编译器原理解析及实践\n为什么说 Java 语言“编译与解释并存”?\n这是因为 Java 语言既具有编译型语言的特征,也具有解释型语言的特征。因为 Java 程序要经过先编译,后解释两个步骤,由 Java 编写的程序需要先经过编译步骤,生成字节码(.class 文件),这种字节码必须由 Java 解释器来解释执行。\nAOT 有什么优点?为什么不全部使用 AOT 呢? # JDK 9 引入了一种新的编译模式 AOT(Ahead of Time Compilation) 。和 JIT 不同的是,这种编译模式会在程序被执行前就将其编译成机器码,属于静态编译(C、 C++,Rust,Go 等语言就是静态编译)。AOT 避免了 JIT 预热等各方面的开销,可以提高 Java 程序的启动速度,避免预热时间长。并且,AOT 还能减少内存占用和增强 Java 程序的安全性(AOT 编译后的代码不容易被反编译和修改),特别适合云原生场景。\nJIT 与 AOT 两者的关键指标对比:\n可以看出,AOT 的主要优势在于启动时间、内存占用和打包体积。JIT 的主要优势在于具备更高的极限处理能力,可以降低请求的最大延迟。\n提到 AOT 就不得不提 GraalVM 了!GraalVM 是一种高性能的 JDK(完整的 JDK 发行版本),它可以运行 Java 和其他 JVM 语言,以及 JavaScript、Python 等非 JVM 语言。 GraalVM 不仅能提供 AOT 编译,还能提供 JIT 编译。感兴趣的同学,可以去看看 GraalVM 的官方文档: https://www.graalvm.org/latest/docs/。如果觉得官方文档看着比较难理解的话,也可以找一些文章来看看,比如:\n基于静态编译构建微服务应用 走向 Native 化:Spring\u0026amp;Dubbo AOT 技术示例与原理讲解 既然 AOT 这么多优点,那为什么不全部使用这种编译方式呢?\n我们前面也对比过 JIT 与 AOT,两者各有优点,只能说 AOT 更适合当下的云原生场景,对微服务架构的支持也比较友好。除此之外,AOT 编译无法支持 Java 的一些动态特性,如反射、动态代理、动态加载、JNI(Java Native Interface)等。然而,很多框架和库(如 Spring、CGLIB)都用到了这些特性。如果只使用 AOT 编译,那就没办法使用这些框架和库了,或者说需要针对性地去做适配和优化。举个例子,CGLIB 动态代理使用的是 ASM 技术,而这种技术大致原理是运行时直接在内存中生成并加载修改后的字节码文件也就是 .class 文件,如果全部使用 AOT 提前编译,也就不能使用 ASM 技术了。为了支持类似的动态特性,所以选择使用 JIT 即时编译器。\nOracle JDK vs OpenJDK # 可能在看这个问题之前很多人和我一样并没有接触和使用过 OpenJDK 。那么 Oracle JDK 和 OpenJDK 之间是否存在重大差异?下面我通过收集到的一些资料,为你解答这个被很多人忽视的问题。\n首先,2006 年 SUN 公司将 Java 开源,也就有了 OpenJDK。2009 年 Oracle 收购了 Sun 公司,于是自己在 OpenJDK 的基础上搞了一个 Oracle JDK。Oracle JDK 是不开源的,并且刚开始的几个版本(Java8 ~ Java11)还会相比于 OpenJDK 添加一些特有的功能和工具。\n其次,对于 Java 7 而言,OpenJDK 和 Oracle JDK 是十分接近的。 Oracle JDK 是基于 OpenJDK 7 构建的,只添加了一些小功能,由 Oracle 工程师参与维护。\n下面这段话摘自 Oracle 官方在 2012 年发表的一个博客:\n问:OpenJDK 存储库中的源代码与用于构建 Oracle JDK 的代码之间有什么区别?\n答:非常接近 - 我们的 Oracle JDK 版本构建过程基于 OpenJDK 7 构建,只添加了几个部分,例如部署代码,其中包括 Oracle 的 Java 插件和 Java WebStart 的实现,以及一些闭源的第三方组件,如图形光栅化器,一些开源的第三方组件,如 Rhino,以及一些零碎的东西,如附加文档或第三方字体。展望未来,我们的目的是开源 Oracle JDK 的所有部分,除了我们考虑商业功能的部分。\n最后,简单总结一下 Oracle JDK 和 OpenJDK 的区别:\n是否开源:OpenJDK 是一个参考模型并且是完全开源的,而 Oracle JDK 是基于 OpenJDK 实现的,并不是完全开源的(个人观点:众所周知,JDK 原来是 SUN 公司开发的,后来 SUN 公司又卖给了 Oracle 公司,Oracle 公司以 Oracle 数据库而著名,而 Oracle 数据库又是闭源的,这个时候 Oracle 公司就不想完全开源了,但是原来的 SUN 公司又把 JDK 给开源了,如果这个时候 Oracle 收购回来之后就把他给闭源,必然会引起很多 Java 开发者的不满,导致大家对 Java 失去信心,那 Oracle 公司收购回来不就把 Java 烂在手里了吗!然后,Oracle 公司就想了个骚操作,这样吧,我把一部分核心代码开源出来给你们玩,并且我要和你们自己搞的 JDK 区分下,你们叫 OpenJDK,我叫 Oracle JDK,我发布我的,你们继续玩你们的,要是你们搞出来什么好玩的东西,我后续发布 Oracle JDK 也会拿来用一下,一举两得!)OpenJDK 开源项目: https://github.com/openjdk/jdk 。 是否免费:Oracle JDK 会提供免费版本,但一般有时间限制。JDK17 之后的版本可以免费分发和商用,但是仅有 3 年时间,3 年后无法免费商用。不过,JDK8u221 之前只要不升级可以无限期免费。OpenJDK 是完全免费的。 功能性:Oracle JDK 在 OpenJDK 的基础上添加了一些特有的功能和工具,比如 Java Flight Recorder(JFR,一种监控工具)、Java Mission Control(JMC,一种监控工具)等工具。不过,在 Java 11 之后,OracleJDK 和 OpenJDK 的功能基本一致,之前 OracleJDK 中的私有组件大多数也已经被捐赠给开源组织。 稳定性:OpenJDK 不提供 LTS 服务,而 OracleJDK 大概每三年都会推出一个 LTS 版进行长期支持。不过,很多公司都基于 OpenJDK 提供了对应的和 OracleJDK 周期相同的 LTS 版。因此,两者稳定性其实也是差不多的。 协议:Oracle JDK 使用 BCL/OTN 协议获得许可,而 OpenJDK 根据 GPL v2 许可获得许可。 既然 Oracle JDK 这么好,那为什么还要有 OpenJDK?\n答:\nOpenJDK 是开源的,开源意味着你可以对它根据你自己的需要进行修改、优化,比如 Alibaba 基于 OpenJDK 开发了 Dragonwell8: https://github.com/alibaba/dragonwell8 OpenJDK 是商业免费的(这也是为什么通过 yum 包管理器上默认安装的 JDK 是 OpenJDK 而不是 Oracle JDK)。虽然 Oracle JDK 也是商业免费(比如 JDK 8),但并不是所有版本都是免费的。 OpenJDK 更新频率更快。Oracle JDK 一般是每 6 个月发布一个新版本,而 OpenJDK 一般是每 3 个月发布一个新版本。(现在你知道为啥 Oracle JDK 更稳定了吧,先在 OpenJDK 试试水,把大部分问题都解决掉了才在 Oracle JDK 上发布) 基于以上这些原因,OpenJDK 还是有存在的必要的!\nOracle JDK 和 OpenJDK 如何选择?\n建议选择 OpenJDK 或者基于 OpenJDK 的发行版,比如 AWS 的 Amazon Corretto,阿里巴巴的 Alibaba Dragonwell。\n🌈 拓展一下:\nBCL 协议(Oracle Binary Code License Agreement):可以使用 JDK(支持商用),但是不能进行修改。 OTN 协议(Oracle Technology Network License Agreement):11 及之后新发布的 JDK 用的都是这个协议,可以自己私下用,但是商用需要付费。 Java 和 C++ 的区别? # 我知道很多人没学过 C++,但是面试官就是没事喜欢拿咱们 Java 和 C++ 比呀!没办法!!!就算没学过 C++,也要记下来。\n虽然,Java 和 C++ 都是面向对象的语言,都支持封装、继承和多态,但是,它们还是有挺多不相同的地方:\nJava 不提供指针来直接访问内存,程序内存更加安全 Java 的类是单继承的,C++ 支持多重继承;虽然 Java 的类不可以多继承,但是接口可以多继承。 Java 有自动内存管理垃圾回收机制(GC),不需要程序员手动释放无用内存。 C ++同时支持方法重载和操作符重载,但是 Java 只支持方法重载(操作符重载增加了复杂性,这与 Java 最初的设计思想不符)。 …… 基本语法 # 注释有哪几种形式? # Java 中的注释有三种:\n单行注释:通常用于解释方法内某单行代码的作用。\n多行注释:通常用于解释一段代码的作用。\n文档注释:通常用于生成 Java 开发文档。\n用的比较多的还是单行注释和文档注释,多行注释在实际开发中使用的相对较少。\n在我们编写代码的时候,如果代码量比较少,我们自己或者团队其他成员还可以很轻易地看懂代码,但是当项目结构一旦复杂起来,我们就需要用到注释了。注释并不会执行(编译器在编译代码之前会把代码中的所有注释抹掉,字节码中不保留注释),是我们程序员写给自己看的,注释是你的代码说明书,能够帮助看代码的人快速地理清代码之间的逻辑关系。因此,在写程序的时候随手加上注释是一个非常好的习惯。\n《Clean Code》这本书明确指出:\n代码的注释不是越详细越好。实际上好的代码本身就是注释,我们要尽量规范和美化自己的代码来减少不必要的注释。\n若编程语言足够有表达力,就不需要注释,尽量通过代码来阐述。\n举个例子:\n去掉下面复杂的注释,只需要创建一个与注释所言同一事物的函数即可\n// check to see if the employee is eligible for full benefits if ((employee.flags \u0026amp; HOURLY_FLAG) \u0026amp;\u0026amp; (employee.age \u0026gt; 65)) 应替换为\nif (employee.isEligibleForFullBenefits()) 标识符和关键字的区别是什么? # 在我们编写程序的时候,需要大量地为程序、类、变量、方法等取名字,于是就有了 标识符 。简单来说, 标识符就是一个名字 。\n有一些标识符,Java 语言已经赋予了其特殊的含义,只能用于特定的地方,这些特殊的标识符就是 关键字 。简单来说,关键字是被赋予特殊含义的标识符 。比如,在我们的日常生活中,如果我们想要开一家店,则要给这个店起一个名字,起的这个“名字”就叫标识符。但是我们店的名字不能叫“警察局”,因为“警察局”这个名字已经被赋予了特殊的含义,而“警察局”就是我们日常生活中的关键字。\nJava 语言关键字有哪些? # 分类 关键字 访问控制 private protected public 类,方法和变量修饰符 abstract class extends final implements interface native new static strictfp synchronized transient volatile enum 程序控制 break continue return do while if else for instanceof switch case default assert 错误处理 try catch throw throws finally 包相关 import package 基本类型 boolean byte char double float int long short 变量引用 super this void 保留字 goto const Tips:所有的关键字都是小写的,在 IDE 中会以特殊颜色显示。\ndefault 这个关键字很特殊,既属于程序控制,也属于类,方法和变量修饰符,还属于访问控制。\n在程序控制中,当在 switch 中匹配不到任何情况时,可以使用 default 来编写默认匹配的情况。 在类,方法和变量修饰符中,从 JDK8 开始引入了默认方法,可以使用 default 关键字来定义一个方法的默认实现。 在访问控制中,如果一个方法前没有任何修饰符,则默认会有一个修饰符 default,但是这个修饰符加上了就会报错。 ⚠️ 注意:虽然 true, false, 和 null 看起来像关键字但实际上他们是字面值,同时你也不可以作为标识符来使用。\n官方文档: https://docs.oracle.com/javase/tutorial/java/nutsandbolts/_keywords.html\n自增自减运算符 # 在写代码的过程中,常见的一种情况是需要某个整数类型变量增加 1 或减少 1。Java 提供了自增运算符 (++) 和自减运算符 (--) 来简化这种操作。\n++ 和 -- 运算符可以放在变量之前,也可以放在变量之后:\n前缀形式(例如 ++a 或 --a):先自增/自减变量的值,然后再使用该变量,例如,b = ++a 先将 a 增加 1,然后把增加后的值赋给 b。 后缀形式(例如 a++ 或 a--):先使用变量的当前值,然后再自增/自减变量的值。例如,b = a++ 先将 a 的当前值赋给 b,然后再将 a 增加 1。 为了方便记忆,可以使用下面的口诀:符号在前就先加/减,符号在后就后加/减。\n下面来看一个考察自增自减运算符的高频笔试题:执行下面的代码后,a 、b 、 c 、d和e的值是?\nint a = 9; int b = a++; int c = ++a; int d = c--; int e = --d; 答案:a = 11 、b = 9 、 c = 10 、 d = 10 、 e = 10。\n移位运算符 # 移位运算符是最基本的运算符之一,几乎每种编程语言都包含这一运算符。移位操作中,被操作的数据被视为二进制数,移位就是将其向左或向右移动若干位的运算。\n移位运算符在各种框架以及 JDK 自身的源码中使用还是挺广泛的,HashMap(JDK1.8) 中的 hash 方法的源码就用到了移位运算符:\nstatic final int hash(Object key) { int h; // key.hashCode():返回散列值也就是hashcode // ^:按位异或 // \u0026gt;\u0026gt;\u0026gt;:无符号右移,忽略符号位,空位都以0补齐 return (key == null) ? 0 : (h = key.hashCode()) ^ (h \u0026gt;\u0026gt;\u0026gt; 16); } 使用移位运算符的主要原因:\n高效:移位运算符直接对应于处理器的移位指令。现代处理器具有专门的硬件指令来执行这些移位操作,这些指令通常在一个时钟周期内完成。相比之下,乘法和除法等算术运算在硬件层面上需要更多的时钟周期来完成。 节省内存:通过移位操作,可以使用一个整数(如 int 或 long)来存储多个布尔值或标志位,从而节省内存。 移位运算符最常用于快速乘以或除以 2 的幂次方。除此之外,它还在以下方面发挥着重要作用:\n位字段管理:例如存储和操作多个布尔值。 哈希算法和加密解密:通过移位和与、或等操作来混淆数据。 数据压缩:例如霍夫曼编码通过移位运算符可以快速处理和操作二进制数据,以生成紧凑的压缩格式。 数据校验:例如 CRC(循环冗余校验)通过移位和多项式除法生成和校验数据完整性。。 内存对齐:通过移位操作,可以轻松计算和调整数据的对齐地址。 掌握最基本的移位运算符知识还是很有必要的,这不光可以帮助我们在代码中使用,还可以帮助我们理解源码中涉及到移位运算符的代码。\nJava 中有三种移位运算符:\n\u0026lt;\u0026lt; :左移运算符,向左移若干位,高位丢弃,低位补零。x \u0026lt;\u0026lt; n,相当于 x 乘以 2 的 n 次方(不溢出的情况下)。 \u0026gt;\u0026gt; :带符号右移,向右移若干位,高位补符号位,低位丢弃。正数高位补 0,负数高位补 1。x \u0026gt;\u0026gt; n,相当于 x 除以 2 的 n 次方。 \u0026gt;\u0026gt;\u0026gt; :无符号右移,忽略符号位,空位都以 0 补齐。 虽然移位运算本质上可以分为左移和右移,但在实际应用中,右移操作需要考虑符号位的处理方式。\n由于 double,float 在二进制中的表现比较特殊,因此不能来进行移位操作。\n移位操作符实际上支持的类型只有int和long,编译器在对short、byte、char类型进行移位前,都会将其转换为int类型再操作。\n如果移位的位数超过数值所占有的位数会怎样?\n当 int 类型左移/右移位数大于等于 32 位操作时,会先求余(%)后再进行左移/右移操作。也就是说左移/右移 32 位相当于不进行移位操作(32%32=0),左移/右移 42 位相当于左移/右移 10 位(42%32=10)。当 long 类型进行左移/右移操作时,由于 long 对应的二进制是 64 位,因此求余操作的基数也变成了 64。\n也就是说:x\u0026lt;\u0026lt;42等同于x\u0026lt;\u0026lt;10,x\u0026gt;\u0026gt;42等同于x\u0026gt;\u0026gt;10,x \u0026gt;\u0026gt;\u0026gt;42等同于x \u0026gt;\u0026gt;\u0026gt; 10。\n左移运算符代码示例:\nint i = -1; System.out.println(\u0026#34;初始数据:\u0026#34; + i); System.out.println(\u0026#34;初始数据对应的二进制字符串:\u0026#34; + Integer.toBinaryString(i)); i \u0026lt;\u0026lt;= 10; System.out.println(\u0026#34;左移 10 位后的数据 \u0026#34; + i); System.out.println(\u0026#34;左移 10 位后的数据对应的二进制字符 \u0026#34; + Integer.toBinaryString(i)); 输出:\n初始数据:-1 初始数据对应的二进制字符串:11111111111111111111111111111111 左移 10 位后的数据 -1024 左移 10 位后的数据对应的二进制字符 11111111111111111111110000000000 由于左移位数大于等于 32 位操作时,会先求余(%)后再进行左移操作,所以下面的代码左移 42 位相当于左移 10 位(42%32=10),输出结果和前面的代码一样。\nint i = -1; System.out.println(\u0026#34;初始数据:\u0026#34; + i); System.out.println(\u0026#34;初始数据对应的二进制字符串:\u0026#34; + Integer.toBinaryString(i)); i \u0026lt;\u0026lt;= 42; System.out.println(\u0026#34;左移 10 位后的数据 \u0026#34; + i); System.out.println(\u0026#34;左移 10 位后的数据对应的二进制字符 \u0026#34; + Integer.toBinaryString(i)); 右移运算符使用类似,篇幅问题,这里就不做演示了。\ncontinue、break 和 return 的区别是什么? # 在循环结构中,当循环条件不满足或者循环次数达到要求时,循环会正常结束。但是,有时候可能需要在循环的过程中,当发生了某种条件之后 ,提前终止循环,这就需要用到下面几个关键词:\ncontinue:指跳出当前的这一次循环,继续下一次循环。 break:指跳出整个循环体,继续执行循环下面的语句。 return 用于跳出所在方法,结束该方法的运行。return 一般有两种用法:\nreturn;:直接使用 return 结束方法执行,用于没有返回值函数的方法 return value;:return 一个特定值,用于有返回值函数的方法 思考一下:下列语句的运行结果是什么?\npublic static void main(String[] args) { boolean flag = false; for (int i = 0; i \u0026lt;= 3; i++) { if (i == 0) { System.out.println(\u0026#34;0\u0026#34;); } else if (i == 1) { System.out.println(\u0026#34;1\u0026#34;); continue; } else if (i == 2) { System.out.println(\u0026#34;2\u0026#34;); flag = true; } else if (i == 3) { System.out.println(\u0026#34;3\u0026#34;); break; } else if (i == 4) { System.out.println(\u0026#34;4\u0026#34;); } System.out.println(\u0026#34;xixi\u0026#34;); } if (flag) { System.out.println(\u0026#34;haha\u0026#34;); return; } System.out.println(\u0026#34;heihei\u0026#34;); } 运行结果:\n0 xixi 1 2 xixi 3 haha 基本数据类型 # Java 中的几种基本数据类型了解么? # Java 中有 8 种基本数据类型,分别为:\n6 种数字类型: 4 种整数型:byte、short、int、long 2 种浮点型:float、double 1 种字符类型:char 1 种布尔型:boolean。 这 8 种基本数据类型的默认值以及所占空间的大小如下:\n基本类型 位数 字节 默认值 取值范围 byte 8 1 0 -128 ~ 127 short 16 2 0 -32768(-215) ~ 32767(215 - 1) int 32 4 0 -2147483648 ~ 2147483647 long 64 8 0L -9223372036854775808(-263) ~ 9223372036854775807(263 -1) char 16 2 \u0026lsquo;u0000\u0026rsquo; 0 ~ 65535(2^16 - 1) float 32 4 0f 1.4E-45 ~ 3.4028235E38 double 64 8 0d 4.9E-324 ~ 1.7976931348623157E308 boolean 1 false true、false 可以看到,像 byte、short、int、long能表示的最大正数都减 1 了。这是为什么呢?这是因为在二进制补码表示法中,最高位是用来表示符号的(0 表示正数,1 表示负数),其余位表示数值部分。所以,如果我们要表示最大的正数,我们需要把除了最高位之外的所有位都设为 1。如果我们再加 1,就会导致溢出,变成一个负数。\n对于 boolean,官方文档未明确定义,它依赖于 JVM 厂商的具体实现。逻辑上理解是占用 1 位,但是实际中会考虑计算机高效存储因素。\n另外,Java 的每种基本类型所占存储空间的大小不会像其他大多数语言那样随机器硬件架构的变化而变化。这种所占存储空间大小的不变性是 Java 程序比用其他大多数语言编写的程序更具可移植性的原因之一(《Java 编程思想》2.2 节有提到)。\n注意:\nJava 里使用 long 类型的数据一定要在数值后面加上 L,否则将作为整型解析。 Java 里使用 float 类型的数据一定要在数值后面加上 f 或 F,否则将无法通过编译。 char a = 'h'char :单引号,String a = \u0026quot;hello\u0026quot; :双引号。 这八种基本类型都有对应的包装类分别为:Byte、Short、Integer、Long、Float、Double、Character、Boolean 。\n基本类型和包装类型的区别? # 用途:除了定义一些常量和局部变量之外,我们在其他地方比如方法参数、对象属性中很少会使用基本类型来定义变量。并且,包装类型可用于泛型,而基本类型不可以。 存储方式:基本数据类型的局部变量存放在 Java 虚拟机栈中的局部变量表中,基本数据类型的成员变量(未被 static 修饰 )存放在 Java 虚拟机的堆中。包装类型属于对象类型,我们知道几乎所有对象实例都存在于堆中。 占用空间:相比于包装类型(对象类型), 基本数据类型占用的空间往往非常小。 默认值:成员变量包装类型不赋值就是 null ,而基本类型有默认值且不是 null。 比较方式:对于基本数据类型来说,== 比较的是值。对于包装数据类型来说,== 比较的是对象的内存地址。所有整型包装类对象之间值的比较,全部使用 equals() 方法。 为什么说是几乎所有对象实例都存在于堆中呢? 这是因为 HotSpot 虚拟机引入了 JIT 优化之后,会对对象进行逃逸分析,如果发现某一个对象并没有逃逸到方法外部,那么就可能通过标量替换来实现栈上分配,而避免堆上分配内存\n⚠️ 注意:基本数据类型存放在栈中是一个常见的误区! 基本数据类型的存储位置取决于它们的作用域和声明方式。如果它们是局部变量,那么它们会存放在栈中;如果它们是成员变量,那么它们会存放在堆/方法区/元空间中。\npublic class Test { // 成员变量,存放在堆中 int a = 10; // 被 static 修饰的成员变量,JDK 1.7 及之前位于方法区,1.8 后存放于元空间,均不存放于堆中。 // 变量属于类,不属于对象。 static int b = 20; public void method() { // 局部变量,存放在栈中 int c = 30; static int d = 40; // 编译错误,不能在方法中使用 static 修饰局部变量 } } 包装类型的缓存机制了解么? # Java 基本数据类型的包装类型的大部分都用到了缓存机制来提升性能。\nByte,Short,Integer,Long 这 4 种包装类默认创建了数值 [-128,127] 的相应类型的缓存数据,Character 创建了数值在 [0,127] 范围的缓存数据,Boolean 直接返回 True or False。\nInteger 缓存源码:\npublic static Integer valueOf(int i) { if (i \u0026gt;= IntegerCache.low \u0026amp;\u0026amp; i \u0026lt;= IntegerCache.high) return IntegerCache.cache[i + (-IntegerCache.low)]; return new Integer(i); } private static class IntegerCache { static final int low = -128; static final int high; static { // high value may be configured by property int h = 127; } } Character 缓存源码:\npublic static Character valueOf(char c) { if (c \u0026lt;= 127) { // must cache return CharacterCache.cache[(int)c]; } return new Character(c); } private static class CharacterCache { private CharacterCache(){} static final Character cache[] = new Character[127 + 1]; static { for (int i = 0; i \u0026lt; cache.length; i++) cache[i] = new Character((char)i); } } Boolean 缓存源码:\npublic static Boolean valueOf(boolean b) { return (b ? TRUE : FALSE); } 如果超出对应范围仍然会去创建新的对象,缓存的范围区间的大小只是在性能和资源之间的权衡。\n两种浮点数类型的包装类 Float,Double 并没有实现缓存机制。\nInteger i1 = 33; Integer i2 = 33; System.out.println(i1 == i2);// 输出 true Float i11 = 333f; Float i22 = 333f; System.out.println(i11 == i22);// 输出 false Double i3 = 1.2; Double i4 = 1.2; System.out.println(i3 == i4);// 输出 false 下面我们来看一个问题:下面的代码的输出结果是 true 还是 false 呢?\nInteger i1 = 40; Integer i2 = new Integer(40); System.out.println(i1==i2); Integer i1=40 这一行代码会发生装箱,也就是说这行代码等价于 Integer i1=Integer.valueOf(40) 。因此,i1 直接使用的是缓存中的对象。而Integer i2 = new Integer(40) 会直接创建新的对象。\n因此,答案是 false 。你答对了吗?\n记住:所有整型包装类对象之间值的比较,全部使用 equals 方法比较。\n自动装箱与拆箱了解吗?原理是什么? # 什么是自动拆装箱?\n装箱:将基本类型用它们对应的引用类型包装起来; 拆箱:将包装类型转换为基本数据类型; 举例:\nInteger i = 10; //装箱 int n = i; //拆箱 上面这两行代码对应的字节码为:\nL1 LINENUMBER 8 L1 ALOAD 0 BIPUSH 10 INVOKESTATIC java/lang/Integer.valueOf (I)Ljava/lang/Integer; PUTFIELD AutoBoxTest.i : Ljava/lang/Integer; L2 LINENUMBER 9 L2 ALOAD 0 ALOAD 0 GETFIELD AutoBoxTest.i : Ljava/lang/Integer; INVOKEVIRTUAL java/lang/Integer.intValue ()I PUTFIELD AutoBoxTest.n : I RETURN 从字节码中,我们发现装箱其实就是调用了 包装类的valueOf()方法,拆箱其实就是调用了 xxxValue()方法。\n因此,\nInteger i = 10 等价于 Integer i = Integer.valueOf(10) int n = i 等价于 int n = i.intValue(); 注意:如果频繁拆装箱的话,也会严重影响系统的性能。我们应该尽量避免不必要的拆装箱操作。\nprivate static long sum() { // 应该使用 long 而不是 Long Long sum = 0L; for (long i = 0; i \u0026lt;= Integer.MAX_VALUE; i++) sum += i; return sum; } 为什么浮点数运算的时候会有精度丢失的风险? # 浮点数运算精度丢失代码演示:\nfloat a = 2.0f - 1.9f; float b = 1.8f - 1.7f; System.out.printf(\u0026#34;%.9f\u0026#34;,a);// 0.100000024 System.out.println(b);// 0.099999905 System.out.println(a == b);// false 为什么会出现这个问题呢?\n这个和计算机保存浮点数的机制有很大关系。我们知道计算机是二进制的,而且计算机在表示一个数字时,宽度是有限的,无限循环的小数存储在计算机时,只能被截断,所以就会导致小数精度发生损失的情况。这也就是解释了为什么浮点数没有办法用二进制精确表示。\n就比如说十进制下的 0.2 就没办法精确转换成二进制小数:\n// 0.2 转换为二进制数的过程为,不断乘以 2,直到不存在小数为止, // 在这个计算过程中,得到的整数部分从上到下排列就是二进制的结果。 0.2 * 2 = 0.4 -\u0026gt; 0 0.4 * 2 = 0.8 -\u0026gt; 0 0.8 * 2 = 1.6 -\u0026gt; 1 0.6 * 2 = 1.2 -\u0026gt; 1 0.2 * 2 = 0.4 -\u0026gt; 0(发生循环) ... 关于浮点数的更多内容,建议看一下 计算机系统基础(四)浮点数这篇文章。\n如何解决浮点数运算的精度丢失问题? # BigDecimal 可以实现对浮点数的运算,不会造成精度丢失。通常情况下,大部分需要浮点数精确运算结果的业务场景(比如涉及到钱的场景)都是通过 BigDecimal 来做的。\nBigDecimal a = new BigDecimal(\u0026#34;1.0\u0026#34;); BigDecimal b = new BigDecimal(\u0026#34;1.00\u0026#34;); BigDecimal c = new BigDecimal(\u0026#34;0.8\u0026#34;); BigDecimal x = a.subtract(c); BigDecimal y = b.subtract(c); System.out.println(x); /* 0.2 */ System.out.println(y); /* 0.20 */ // 比较内容,不是比较值 System.out.println(Objects.equals(x, y)); /* false */ // 比较值相等用相等compareTo,相等返回0 System.out.println(0 == x.compareTo(y)); /* true */ 关于 BigDecimal 的详细介绍,可以看看我写的这篇文章: BigDecimal 详解。\n超过 long 整型的数据应该如何表示? # 基本数值类型都有一个表达范围,如果超过这个范围就会有数值溢出的风险。\n在 Java 中,64 位 long 整型是最大的整数类型。\nlong l = Long.MAX_VALUE; System.out.println(l + 1); // -9223372036854775808 System.out.println(l + 1 == Long.MIN_VALUE); // true BigInteger 内部使用 int[] 数组来存储任意大小的整形数据。\n相对于常规整数类型的运算来说,BigInteger 运算的效率会相对较低。\n变量 # 成员变量与局部变量的区别? # 语法形式:从语法形式上看,成员变量是属于类的,而局部变量是在代码块或方法中定义的变量或是方法的参数;成员变量可以被 public,private,static 等修饰符所修饰,而局部变量不能被访问控制修饰符及 static 所修饰;但是,成员变量和局部变量都能被 final 所修饰。 存储方式:从变量在内存中的存储方式来看,如果成员变量是使用 static 修饰的,那么这个成员变量是属于类的,如果没有使用 static 修饰,这个成员变量是属于实例的。而对象存在于堆内存,局部变量则存在于栈内存。 生存时间:从变量在内存中的生存时间上看,成员变量是对象的一部分,它随着对象的创建而存在,而局部变量随着方法的调用而自动生成,随着方法的调用结束而消亡。 默认值:从变量是否有默认值来看,成员变量如果没有被赋初始值,则会自动以类型的默认值而赋值(一种情况例外:被 final 修饰的成员变量也必须显式地赋值),而局部变量则不会自动赋值。 为什么成员变量有默认值?\n先不考虑变量类型,如果没有默认值会怎样?变量存储的是内存地址对应的任意随机值,程序读取该值运行会出现意外。\n默认值有两种设置方式:手动和自动,根据第一点,没有手动赋值一定要自动赋值。成员变量在运行时可借助反射等方法手动赋值,而局部变量不行。\n对于编译器(javac)来说,局部变量没赋值很好判断,可以直接报错。而成员变量可能是运行时赋值,无法判断,误报“没默认值”又会影响用户体验,所以采用自动赋默认值。\n成员变量与局部变量代码示例:\npublic class VariableExample { // 成员变量 private String name; private int age; // 方法中的局部变量 public void method() { int num1 = 10; // 栈中分配的局部变量 String str = \u0026#34;Hello, world!\u0026#34;; // 栈中分配的局部变量 System.out.println(num1); System.out.println(str); } // 带参数的方法中的局部变量 public void method2(int num2) { int sum = num2 + 10; // 栈中分配的局部变量 System.out.println(sum); } // 构造方法中的局部变量 public VariableExample(String name, int age) { this.name = name; // 对成员变量进行赋值 this.age = age; // 对成员变量进行赋值 int num3 = 20; // 栈中分配的局部变量 String str2 = \u0026#34;Hello, \u0026#34; + this.name + \u0026#34;!\u0026#34;; // 栈中分配的局部变量 System.out.println(num3); System.out.println(str2); } } 静态变量有什么作用? # 静态变量也就是被 static 关键字修饰的变量。它可以被类的所有实例共享,无论一个类创建了多少个对象,它们都共享同一份静态变量。也就是说,静态变量只会被分配一次内存,即使创建多个对象,这样可以节省内存。\n静态变量是通过类名来访问的,例如StaticVariableExample.staticVar(如果被 private关键字修饰就无法这样访问了)。\npublic class StaticVariableExample { // 静态变量 public static int staticVar = 0; } 通常情况下,静态变量会被 final 关键字修饰成为常量。\npublic class ConstantVariableExample { // 常量 public static final int constantVar = 0; } 字符型常量和字符串常量的区别? # 形式 : 字符常量是单引号引起的一个字符,字符串常量是双引号引起的 0 个或若干个字符。 含义 : 字符常量相当于一个整型值( ASCII 值),可以参加表达式运算; 字符串常量代表一个地址值(该字符串在内存中存放位置)。 占内存大小:字符常量只占 2 个字节; 字符串常量占若干个字节。 ⚠️ 注意 char 在 Java 中占两个字节。\n字符型常量和字符串常量代码示例:\npublic class StringExample { // 字符型常量 public static final char LETTER_A = \u0026#39;A\u0026#39;; // 字符串常量 public static final String GREETING_MESSAGE = \u0026#34;Hello, world!\u0026#34;; public static void main(String[] args) { System.out.println(\u0026#34;字符型常量占用的字节数为:\u0026#34;+Character.BYTES); System.out.println(\u0026#34;字符串常量占用的字节数为:\u0026#34;+GREETING_MESSAGE.getBytes().length); } } 输出:\n字符型常量占用的字节数为:2 字符串常量占用的字节数为:13 方法 # 什么是方法的返回值?方法有哪几种类型? # 方法的返回值 是指我们获取到的某个方法体中的代码执行后产生的结果!(前提是该方法可能产生结果)。返回值的作用是接收出结果,使得它可以用于其他的操作!\n我们可以按照方法的返回值和参数类型将方法分为下面这几种:\n1、无参数无返回值的方法\npublic void f1() { //...... } // 下面这个方法也没有返回值,虽然用到了 return public void f(int a) { if (...) { // 表示结束方法的执行,下方的输出语句不会执行 return; } System.out.println(a); } 2、有参数无返回值的方法\npublic void f2(Parameter 1, ..., Parameter n) { //...... } 3、有返回值无参数的方法\npublic int f3() { //...... return x; } 4、有返回值有参数的方法\npublic int f4(int a, int b) { return a * b; } 静态方法为什么不能调用非静态成员? # 这个需要结合 JVM 的相关知识,主要原因如下:\n静态方法是属于类的,在类加载的时候就会分配内存,可以通过类名直接访问。而非静态成员属于实例对象,只有在对象实例化之后才存在,需要通过类的实例对象去访问。 在类的非静态成员不存在的时候静态方法就已经存在了,此时调用在内存中还不存在的非静态成员,属于非法操作。 public class Example { // 定义一个字符型常量 public static final char LETTER_A = \u0026#39;A\u0026#39;; // 定义一个字符串常量 public static final String GREETING_MESSAGE = \u0026#34;Hello, world!\u0026#34;; public static void main(String[] args) { // 输出字符型常量的值 System.out.println(\u0026#34;字符型常量的值为:\u0026#34; + LETTER_A); // 输出字符串常量的值 System.out.println(\u0026#34;字符串常量的值为:\u0026#34; + GREETING_MESSAGE); } } 静态方法和实例方法有何不同? # 1、调用方式\n在外部调用静态方法时,可以使用 类名.方法名 的方式,也可以使用 对象.方法名 的方式,而实例方法只有后面这种方式。也就是说,调用静态方法可以无需创建对象 。\n不过,需要注意的是一般不建议使用 对象.方法名 的方式来调用静态方法。这种方式非常容易造成混淆,静态方法不属于类的某个对象而是属于这个类。\n因此,一般建议使用 类名.方法名 的方式来调用静态方法。\npublic class Person { public void method() { //...... } public static void staicMethod(){ //...... } public static void main(String[] args) { Person person = new Person(); // 调用实例方法 person.method(); // 调用静态方法 Person.staicMethod() } } 2、访问类成员是否存在限制\n静态方法在访问本类的成员时,只允许访问静态成员(即静态成员变量和静态方法),不允许访问实例成员(即实例成员变量和实例方法),而实例方法不存在这个限制。\n重载和重写有什么区别? # 重载就是同样的一个方法能够根据输入数据的不同,做出不同的处理\n重写就是当子类继承自父类的相同方法,输入数据一样,但要做出有别于父类的响应时,你就要覆盖父类方法\n重载 # 发生在同一个类中(或者父类和子类之间),方法名必须相同,参数类型不同、个数不同、顺序不同,方法返回值和访问修饰符可以不同。\n《Java 核心技术》这本书是这样介绍重载的:\n如果多个方法(比如 StringBuilder 的构造方法)有相同的名字、不同的参数, 便产生了重载。\nStringBuilder sb = new StringBuilder(); StringBuilder sb2 = new StringBuilder(\u0026#34;HelloWorld\u0026#34;); 编译器必须挑选出具体执行哪个方法,它通过用各个方法给出的参数类型与特定方法调用所使用的值类型进行匹配来挑选出相应的方法。 如果编译器找不到匹配的参数, 就会产生编译时错误, 因为根本不存在匹配, 或者没有一个比其他的更好(这个过程被称为重载解析(overloading resolution))。\nJava 允许重载任何方法, 而不只是构造器方法。\n综上:重载就是同一个类中多个同名方法根据不同的传参来执行不同的逻辑处理。\n重写 # 重写发生在运行期,是子类对父类的允许访问的方法的实现过程进行重新编写。\n方法名、参数列表必须相同,子类方法返回值类型应比父类方法返回值类型更小或相等,抛出的异常范围小于等于父类,访问修饰符范围大于等于父类。 如果父类方法访问修饰符为 private/final/static 则子类就不能重写该方法,但是被 static 修饰的方法能够被再次声明。 构造方法无法被重写 总结 # 综上:重写就是子类对父类方法的重新改造,外部样子不能改变,内部逻辑可以改变。\n区别点 重载方法 重写方法 发生范围 同一个类 子类 参数列表 必须修改 一定不能修改 返回类型 可修改 子类方法返回值类型应比父类方法返回值类型更小或相等 异常 可修改 子类方法声明抛出的异常类应比父类方法声明抛出的异常类更小或相等; 访问修饰符 可修改 一定不能做更严格的限制(可以降低限制) 发生阶段 编译期 运行期 方法的重写要遵循“两同两小一大”(以下内容摘录自《疯狂 Java 讲义》, issue#892 ):\n“两同”即方法名相同、形参列表相同; “两小”指的是子类方法返回值类型应比父类方法返回值类型更小或相等,子类方法声明抛出的异常类应比父类方法声明抛出的异常类更小或相等; “一大”指的是子类方法的访问权限应比父类方法的访问权限更大或相等。 ⭐️ 关于 重写的返回值类型 这里需要额外多说明一下,上面的表述不太清晰准确:如果方法的返回类型是 void 和基本数据类型,则返回值重写时不可修改。但是如果方法的返回值是引用类型,重写时是可以返回该引用类型的子类的。\npublic class Hero { public String name() { return \u0026#34;超级英雄\u0026#34;; } } public class SuperMan extends Hero{ @Override public String name() { return \u0026#34;超人\u0026#34;; } public Hero hero() { return new Hero(); } } public class SuperSuperMan extends SuperMan { @Override public String name() { return \u0026#34;超级超级英雄\u0026#34;; } @Override public SuperMan hero() { return new SuperMan(); } } 什么是可变长参数? # 从 Java5 开始,Java 支持定义可变长参数,所谓可变长参数就是允许在调用方法时传入不定长度的参数。就比如下面这个方法就可以接受 0 个或者多个参数。\npublic static void method1(String... args) { //...... } 另外,可变参数只能作为函数的最后一个参数,但其前面可以有也可以没有任何其他参数。\npublic static void method2(String arg1, String... args) { //...... } 遇到方法重载的情况怎么办呢?会优先匹配固定参数还是可变参数的方法呢?\n答案是会优先匹配固定参数的方法,因为固定参数的方法匹配度更高。\n我们通过下面这个例子来证明一下。\n/** * 微信搜 JavaGuide 回复\u0026#34;面试突击\u0026#34;即可免费领取个人原创的 Java 面试手册 * * @author Guide哥 * @date 2021/12/13 16:52 **/ public class VariableLengthArgument { public static void printVariable(String... args) { for (String s : args) { System.out.println(s); } } public static void printVariable(String arg1, String arg2) { System.out.println(arg1 + arg2); } public static void main(String[] args) { printVariable(\u0026#34;a\u0026#34;, \u0026#34;b\u0026#34;); printVariable(\u0026#34;a\u0026#34;, \u0026#34;b\u0026#34;, \u0026#34;c\u0026#34;, \u0026#34;d\u0026#34;); } } 输出:\nab a b c d 另外,Java 的可变参数编译后实际会被转换成一个数组,我们看编译后生成的 class文件就可以看出来了。\npublic class VariableLengthArgument { public static void printVariable(String... args) { String[] var1 = args; int var2 = args.length; for(int var3 = 0; var3 \u0026lt; var2; ++var3) { String s = var1[var3]; System.out.println(s); } } // ...... } 参考 # What is the difference between JDK and JRE?: https://stackoverflow.com/questions/1906445/what-is-the-difference-between-jdk-and-jre Oracle vs OpenJDK: https://www.educba.com/oracle-vs-openjdk/ Differences between Oracle JDK and OpenJDK: https://stackoverflow.com/questions/22358071/differences-between-oracle-jdk-and-openjdk 彻底弄懂 Java 的移位操作符: https://juejin.cn/post/6844904025880526861 "},{"id":517,"href":"/zh/docs/technology/Interview/java/basis/java-basic-questions-03/","title":"Java基础常见面试题总结(下)","section":"Basis","content":" 异常 # Java 异常类层次结构图概览:\nException 和 Error 有什么区别? # 在 Java 中,所有的异常都有一个共同的祖先 java.lang 包中的 Throwable 类。Throwable 类有两个重要的子类:\nException :程序本身可以处理的异常,可以通过 catch 来进行捕获。Exception 又可以分为 Checked Exception (受检查异常,必须处理) 和 Unchecked Exception (不受检查异常,可以不处理)。 Error:Error 属于程序无法处理的错误 ,我们没办法通过 catch 来进行捕获不建议通过catch捕获 。例如 Java 虚拟机运行错误(Virtual MachineError)、虚拟机内存不够错误(OutOfMemoryError)、类定义错误(NoClassDefFoundError)等 。这些异常发生时,Java 虚拟机(JVM)一般会选择线程终止。 Checked Exception 和 Unchecked Exception 有什么区别? # Checked Exception 即 受检查异常 ,Java 代码在编译过程中,如果受检查异常没有被 catch或者throws 关键字处理的话,就没办法通过编译。\n比如下面这段 IO 操作的代码:\n除了RuntimeException及其子类以外,其他的Exception类及其子类都属于受检查异常 。常见的受检查异常有:IO 相关的异常、ClassNotFoundException、SQLException\u0026hellip;。\nUnchecked Exception 即 不受检查异常 ,Java 代码在编译过程中 ,我们即使不处理不受检查异常也可以正常通过编译。\nRuntimeException 及其子类都统称为非受检查异常,常见的有(建议记下来,日常开发中会经常用到):\nNullPointerException(空指针错误) IllegalArgumentException(参数错误比如方法入参类型错误) NumberFormatException(字符串转换为数字格式错误,IllegalArgumentException的子类) ArrayIndexOutOfBoundsException(数组越界错误) ClassCastException(类型转换错误) ArithmeticException(算术错误) SecurityException (安全错误比如权限不够) UnsupportedOperationException(不支持的操作错误比如重复创建同一用户) …… Throwable 类常用方法有哪些? # String getMessage(): 返回异常发生时的详细信息 String toString(): 返回异常发生时的简要描述 String getLocalizedMessage(): 返回异常对象的本地化信息。使用 Throwable 的子类覆盖这个方法,可以生成本地化信息。如果子类没有覆盖该方法,则该方法返回的信息与 getMessage()返回的结果相同 void printStackTrace(): 在控制台上打印 Throwable 对象封装的异常信息 try-catch-finally 如何使用? # try块:用于捕获异常。其后可接零个或多个 catch 块,如果没有 catch 块,则必须跟一个 finally 块。 catch块:用于处理 try 捕获到的异常。 finally 块:无论是否捕获或处理异常,finally 块里的语句都会被执行。当在 try 块或 catch 块中遇到 return 语句时,finally 语句块将在方法返回之前被执行。 代码示例:\ntry { System.out.println(\u0026#34;Try to do something\u0026#34;); throw new RuntimeException(\u0026#34;RuntimeException\u0026#34;); } catch (Exception e) { System.out.println(\u0026#34;Catch Exception -\u0026gt; \u0026#34; + e.getMessage()); } finally { System.out.println(\u0026#34;Finally\u0026#34;); } 输出:\nTry to do something Catch Exception -\u0026gt; RuntimeException Finally 注意:不要在 finally 语句块中使用 return! 当 try 语句和 finally 语句中都有 return 语句时,try 语句块中的 return 语句会被忽略。这是因为 try 语句中的 return 返回值会先被暂存在一个本地变量中,当执行到 finally 语句中的 return 之后,这个本地变量的值就变为了 finally 语句中的 return 返回值。\njvm 官方文档中有明确提到:\nIf the try clause executes a return, the compiled code does the following:\nSaves the return value (if any) in a local variable. Executes a jsr to the code for the finally clause. Upon return from the finally clause, returns the value saved in the local variable. 代码示例:\npublic static void main(String[] args) { System.out.println(f(2)); } public static int f(int value) { try { return value * value; } finally { if (value == 2) { return 0; } } } 输出:\n0 finally 中的代码一定会执行吗? # 不一定的!在某些情况下,finally 中的代码不会被执行。\n就比如说 finally 之前虚拟机被终止运行的话,finally 中的代码就不会被执行。\ntry { System.out.println(\u0026#34;Try to do something\u0026#34;); throw new RuntimeException(\u0026#34;RuntimeException\u0026#34;); } catch (Exception e) { System.out.println(\u0026#34;Catch Exception -\u0026gt; \u0026#34; + e.getMessage()); // 终止当前正在运行的Java虚拟机 System.exit(1); } finally { System.out.println(\u0026#34;Finally\u0026#34;); } 输出:\nTry to do something Catch Exception -\u0026gt; RuntimeException 另外,在以下 2 种特殊情况下,finally 块的代码也不会被执行:\n程序所在的线程死亡。 关闭 CPU。 相关 issue: https://github.com/Snailclimb/JavaGuide/issues/190。\n🧗🏻 进阶一下:从字节码角度分析try catch finally这个语法糖背后的实现原理。\n如何使用 try-with-resources 代替try-catch-finally? # 适用范围(资源的定义): 任何实现 java.lang.AutoCloseable或者 java.io.Closeable 的对象 关闭资源和 finally 块的执行顺序: 在 try-with-resources 语句中,任何 catch 或 finally 块在声明的资源关闭后运行 《Effective Java》中明确指出:\n面对必须要关闭的资源,我们总是应该优先使用 try-with-resources 而不是try-finally。随之产生的代码更简短,更清晰,产生的异常对我们也更有用。try-with-resources语句让我们更容易编写必须要关闭的资源的代码,若采用try-finally则几乎做不到这点。\nJava 中类似于InputStream、OutputStream、Scanner、PrintWriter等的资源都需要我们调用close()方法来手动关闭,一般情况下我们都是通过try-catch-finally语句来实现这个需求,如下:\n//读取文本文件的内容 Scanner scanner = null; try { scanner = new Scanner(new File(\u0026#34;D://read.txt\u0026#34;)); while (scanner.hasNext()) { System.out.println(scanner.nextLine()); } } catch (FileNotFoundException e) { e.printStackTrace(); } finally { if (scanner != null) { scanner.close(); } } 使用 Java 7 之后的 try-with-resources 语句改造上面的代码:\ntry (Scanner scanner = new Scanner(new File(\u0026#34;test.txt\u0026#34;))) { while (scanner.hasNext()) { System.out.println(scanner.nextLine()); } } catch (FileNotFoundException fnfe) { fnfe.printStackTrace(); } 当然多个资源需要关闭的时候,使用 try-with-resources 实现起来也非常简单,如果你还是用try-catch-finally可能会带来很多问题。\n通过使用分号分隔,可以在try-with-resources块中声明多个资源。\ntry (BufferedInputStream bin = new BufferedInputStream(new FileInputStream(new File(\u0026#34;test.txt\u0026#34;))); BufferedOutputStream bout = new BufferedOutputStream(new FileOutputStream(new File(\u0026#34;out.txt\u0026#34;)))) { int b; while ((b = bin.read()) != -1) { bout.write(b); } } catch (IOException e) { e.printStackTrace(); } 异常使用有哪些需要注意的地方? # 不要把异常定义为静态变量,因为这样会导致异常栈信息错乱。每次手动抛出异常,我们都需要手动 new 一个异常对象抛出。 抛出的异常信息一定要有意义。 建议抛出更加具体的异常比如字符串转换为数字格式错误的时候应该抛出NumberFormatException而不是其父类IllegalArgumentException。 避免重复记录日志:如果在捕获异常的地方已经记录了足够的信息(包括异常类型、错误信息和堆栈跟踪等),那么在业务代码中再次抛出这个异常时,就不应该再次记录相同的错误信息。重复记录日志会使得日志文件膨胀,并且可能会掩盖问题的实际原因,使得问题更难以追踪和解决。 …… 泛型 # 什么是泛型?有什么作用? # Java 泛型(Generics) 是 JDK 5 中引入的一个新特性。使用泛型参数,可以增强代码的可读性以及稳定性。\n编译器可以对泛型参数进行检测,并且通过泛型参数可以指定传入的对象类型。比如 ArrayList\u0026lt;Person\u0026gt; persons = new ArrayList\u0026lt;Person\u0026gt;() 这行代码就指明了该 ArrayList 对象只能传入 Person 对象,如果传入其他类型的对象就会报错。\nArrayList\u0026lt;E\u0026gt; extends AbstractList\u0026lt;E\u0026gt; 并且,原生 List 返回类型是 Object ,需要手动转换类型才能使用,使用泛型后编译器自动转换。\n泛型的使用方式有哪几种? # 泛型一般有三种使用方式:泛型类、泛型接口、泛型方法。\n1.泛型类:\n//此处T可以随便写为任意标识,常见的如T、E、K、V等形式的参数常用于表示泛型 //在实例化泛型类时,必须指定T的具体类型 public class Generic\u0026lt;T\u0026gt;{ private T key; public Generic(T key) { this.key = key; } public T getKey(){ return key; } } 如何实例化泛型类:\nGeneric\u0026lt;Integer\u0026gt; genericInteger = new Generic\u0026lt;Integer\u0026gt;(123456); 2.泛型接口:\npublic interface Generator\u0026lt;T\u0026gt; { public T method(); } 实现泛型接口,不指定类型:\nclass GeneratorImpl\u0026lt;T\u0026gt; implements Generator\u0026lt;T\u0026gt;{ @Override public T method() { return null; } } 实现泛型接口,指定类型:\nclass GeneratorImpl implements Generator\u0026lt;String\u0026gt; { @Override public String method() { return \u0026#34;hello\u0026#34;; } } 3.泛型方法:\npublic static \u0026lt; E \u0026gt; void printArray( E[] inputArray ) { for ( E element : inputArray ){ System.out.printf( \u0026#34;%s \u0026#34;, element ); } System.out.println(); } 使用:\n// 创建不同类型数组:Integer, Double 和 Character Integer[] intArray = { 1, 2, 3 }; String[] stringArray = { \u0026#34;Hello\u0026#34;, \u0026#34;World\u0026#34; }; printArray( intArray ); printArray( stringArray ); 注意: public static \u0026lt; E \u0026gt; void printArray( E[] inputArray ) 一般被称为静态泛型方法;在 java 中泛型只是一个占位符,必须在传递类型后才能使用。类在实例化时才能真正的传递类型参数,由于静态方法的加载先于类的实例化,也就是说类中的泛型还没有传递真正的类型参数,静态的方法的加载就已经完成了,所以静态泛型方法是没有办法使用类上声明的泛型的。只能使用自己声明的 \u0026lt;E\u0026gt;\n项目中哪里用到了泛型? # 自定义接口通用返回结果 CommonResult\u0026lt;T\u0026gt; 通过参数 T 可根据具体的返回类型动态指定结果的数据类型 定义 Excel 处理类 ExcelUtil\u0026lt;T\u0026gt; 用于动态指定 Excel 导出的数据类型 构建集合工具类(参考 Collections 中的 sort, binarySearch 方法)。 …… 反射 # 关于反射的详细解读,请看这篇文章 Java 反射机制详解 。\n何谓反射? # 如果说大家研究过框架的底层原理或者咱们自己写过框架的话,一定对反射这个概念不陌生。反射之所以被称为框架的灵魂,主要是因为它赋予了我们在运行时分析类以及执行类中方法的能力。通过反射你可以获取任意一个类的所有属性和方法,你还可以调用这些方法和属性。\n反射的优缺点? # 反射可以让我们的代码更加灵活、为各种框架提供开箱即用的功能提供了便利。\n不过,反射让我们在运行时有了分析操作类的能力的同时,也增加了安全问题,比如可以无视泛型参数的安全检查(泛型参数的安全检查发生在编译时)。另外,反射的性能也要稍差点,不过,对于框架来说实际是影响不大的。\n相关阅读: Java Reflection: Why is it so slow? 。\n反射的应用场景? # 像咱们平时大部分时候都是在写业务代码,很少会接触到直接使用反射机制的场景。但是!这并不代表反射没有用。相反,正是因为反射,你才能这么轻松地使用各种框架。像 Spring/Spring Boot、MyBatis 等等框架中都大量使用了反射机制。\n这些框架中也大量使用了动态代理,而动态代理的实现也依赖反射。\n比如下面是通过 JDK 实现动态代理的示例代码,其中就使用了反射类 Method 来调用指定的方法。\npublic class DebugInvocationHandler implements InvocationHandler { /** * 代理类中的真实对象 */ private final Object target; public DebugInvocationHandler(Object target) { this.target = target; } public Object invoke(Object proxy, Method method, Object[] args) throws InvocationTargetException, IllegalAccessException { System.out.println(\u0026#34;before method \u0026#34; + method.getName()); Object result = method.invoke(target, args); System.out.println(\u0026#34;after method \u0026#34; + method.getName()); return result; } } 另外,像 Java 中的一大利器 注解 的实现也用到了反射。\n为什么你使用 Spring 的时候 ,一个@Component注解就声明了一个类为 Spring Bean 呢?为什么你通过一个 @Value注解就读取到配置文件中的值呢?究竟是怎么起作用的呢?\n这些都是因为你可以基于反射分析类,然后获取到类/属性/方法/方法的参数上的注解。你获取到注解之后,就可以做进一步的处理。\n注解 # 何谓注解? # Annotation (注解) 是 Java5 开始引入的新特性,可以看作是一种特殊的注释,主要用于修饰类、方法或者变量,提供某些信息供程序在编译或者运行时使用。\n注解本质是一个继承了Annotation 的特殊接口:\n@Target(ElementType.METHOD) @Retention(RetentionPolicy.SOURCE) public @interface Override { } public interface Override extends Annotation{ } JDK 提供了很多内置的注解(比如 @Override、@Deprecated),同时,我们还可以自定义注解。\n注解的解析方法有哪几种? # 注解只有被解析之后才会生效,常见的解析方法有两种:\n编译期直接扫描:编译器在编译 Java 代码的时候扫描对应的注解并处理,比如某个方法使用@Override 注解,编译器在编译的时候就会检测当前的方法是否重写了父类对应的方法。 运行期通过反射处理:像框架中自带的注解(比如 Spring 框架的 @Value、@Component)都是通过反射来进行处理的。 SPI # 关于 SPI 的详细解读,请看这篇文章 Java SPI 机制详解 。\n何谓 SPI? # SPI 即 Service Provider Interface ,字面意思就是:“服务提供者的接口”,我的理解是:专门提供给服务提供者或者扩展框架功能的开发者去使用的一个接口。\nSPI 将服务接口和具体的服务实现分离开来,将服务调用方和服务实现者解耦,能够提升程序的扩展性、可维护性。修改或者替换服务实现并不需要修改调用方。\n很多框架都使用了 Java 的 SPI 机制,比如:Spring 框架、数据库加载驱动、日志接口、以及 Dubbo 的扩展实现等等。\nSPI 和 API 有什么区别? # 那 SPI 和 API 有啥区别?\n说到 SPI 就不得不说一下 API(Application Programming Interface) 了,从广义上来说它们都属于接口,而且很容易混淆。下面先用一张图说明一下:\n一般模块之间都是通过接口进行通讯,因此我们在服务调用方和服务实现方(也称服务提供者)之间引入一个“接口”。\n当实现方提供了接口和实现,我们可以通过调用实现方的接口从而拥有实现方给我们提供的能力,这就是 API。这种情况下,接口和实现都是放在实现方的包中。调用方通过接口调用实现方的功能,而不需要关心具体的实现细节。 当接口存在于调用方这边时,这就是 SPI 。由接口调用方确定接口规则,然后由不同的厂商根据这个规则对这个接口进行实现,从而提供服务。 举个通俗易懂的例子:公司 H 是一家科技公司,新设计了一款芯片,然后现在需要量产了,而市面上有好几家芯片制造业公司,这个时候,只要 H 公司指定好了这芯片生产的标准(定义好了接口标准),那么这些合作的芯片公司(服务提供者)就按照标准交付自家特色的芯片(提供不同方案的实现,但是给出来的结果是一样的)。\nSPI 的优缺点? # 通过 SPI 机制能够大大地提高接口设计的灵活性,但是 SPI 机制也存在一些缺点,比如:\n需要遍历加载所有的实现类,不能做到按需加载,这样效率还是相对较低的。 当多个 ServiceLoader 同时 load 时,会有并发问题。 序列化和反序列化 # 关于序列化和反序列化的详细解读,请看这篇文章 Java 序列化详解 ,里面涉及到的知识点和面试题更全面。\n什么是序列化?什么是反序列化? # 如果我们需要持久化 Java 对象比如将 Java 对象保存在文件中,或者在网络传输 Java 对象,这些场景都需要用到序列化。\n简单来说:\n序列化:将数据结构或对象转换成可以存储或传输的形式,通常是二进制字节流,也可以是 JSON, XML 等文本格式 反序列化:将在序列化过程中所生成的数据转换为原始数据结构或者对象的过程 对于 Java 这种面向对象编程语言来说,我们序列化的都是对象(Object)也就是实例化后的类(Class),但是在 C++这种半面向对象的语言中,struct(结构体)定义的是数据结构类型,而 class 对应的是对象类型。\n下面是序列化和反序列化常见应用场景:\n对象在进行网络传输(比如远程方法调用 RPC 的时候)之前需要先被序列化,接收到序列化的对象之后需要再进行反序列化; 将对象存储到文件之前需要进行序列化,将对象从文件中读取出来需要进行反序列化; 将对象存储到数据库(如 Redis)之前需要用到序列化,将对象从缓存数据库中读取出来需要反序列化; 将对象存储到内存之前需要进行序列化,从内存中读取出来之后需要进行反序列化。 维基百科是如是介绍序列化的:\n序列化(serialization)在计算机科学的数据处理中,是指将数据结构或对象状态转换成可取用格式(例如存成文件,存于缓冲,或经由网络中发送),以留待后续在相同或另一台计算机环境中,能恢复原先状态的过程。依照序列化格式重新获取字节的结果时,可以利用它来产生与原始对象相同语义的副本。对于许多对象,像是使用大量引用的复杂对象,这种序列化重建的过程并不容易。面向对象中的对象序列化,并不概括之前原始对象所关系的函数。这种过程也称为对象编组(marshalling)。从一系列字节提取数据结构的反向操作,是反序列化(也称为解编组、deserialization、unmarshalling)。\n综上:序列化的主要目的是通过网络传输对象或者说是将对象存储到文件系统、数据库、内存中。\nhttps://www.corejavaguru.com/java/serialization/interview-questions-1\n序列化协议对应于 TCP/IP 4 层模型的哪一层?\n我们知道网络通信的双方必须要采用和遵守相同的协议。TCP/IP 四层模型是下面这样的,序列化协议属于哪一层呢?\n应用层 传输层 网络层 网络接口层 如上图所示,OSI 七层协议模型中,表示层做的事情主要就是对应用层的用户数据进行处理转换为二进制流。反过来的话,就是将二进制流转换成应用层的用户数据。这不就对应的是序列化和反序列化么?\n因为,OSI 七层协议模型中的应用层、表示层和会话层对应的都是 TCP/IP 四层模型中的应用层,所以序列化协议属于 TCP/IP 协议应用层的一部分。\n如果有些字段不想进行序列化怎么办? # 对于不想进行序列化的变量,使用 transient 关键字修饰。\ntransient 关键字的作用是:阻止实例中那些用此关键字修饰的的变量序列化;当对象被反序列化时,被 transient 修饰的变量值不会被持久化和恢复。\n关于 transient 还有几点注意:\ntransient 只能修饰变量,不能修饰类和方法。 transient 修饰的变量,在反序列化后变量值将会被置成类型的默认值。例如,如果是修饰 int 类型,那么反序列后结果就是 0。 static 变量因为不属于任何对象(Object),所以无论有没有 transient 关键字修饰,均不会被序列化。 常见序列化协议有哪些? # JDK 自带的序列化方式一般不会用 ,因为序列化效率低并且存在安全问题。比较常用的序列化协议有 Hessian、Kryo、Protobuf、ProtoStuff,这些都是基于二进制的序列化协议。\n像 JSON 和 XML 这种属于文本类序列化方式。虽然可读性比较好,但是性能较差,一般不会选择。\n为什么不推荐使用 JDK 自带的序列化? # 我们很少或者说几乎不会直接使用 JDK 自带的序列化方式,主要原因有下面这些原因:\n不支持跨语言调用 : 如果调用的是其他语言开发的服务的时候就不支持了。 性能差:相比于其他序列化框架性能更低,主要原因是序列化之后的字节数组体积较大,导致传输成本加大。 存在安全问题:序列化和反序列化本身并不存在问题。但当输入的反序列化的数据可被用户控制,那么攻击者即可通过构造恶意输入,让反序列化产生非预期的对象,在此过程中执行构造的任意代码。相关阅读: 应用安全:JAVA 反序列化漏洞之殇 。 I/O # 关于 I/O 的详细解读,请看下面这几篇文章,里面涉及到的知识点和面试题更全面。\nJava IO 基础知识总结 Java IO 设计模式总结 Java IO 模型详解 Java IO 流了解吗? # IO 即 Input/Output,输入和输出。数据输入到计算机内存的过程即输入,反之输出到外部存储(比如数据库,文件,远程主机)的过程即输出。数据传输过程类似于水流,因此称为 IO 流。IO 流在 Java 中分为输入流和输出流,而根据数据的处理方式又分为字节流和字符流。\nJava IO 流的 40 多个类都是从如下 4 个抽象类基类中派生出来的。\nInputStream/Reader: 所有的输入流的基类,前者是字节输入流,后者是字符输入流。 OutputStream/Writer: 所有输出流的基类,前者是字节输出流,后者是字符输出流。 I/O 流为什么要分为字节流和字符流呢? # 问题本质想问:不管是文件读写还是网络发送接收,信息的最小存储单元都是字节,那为什么 I/O 流操作要分为字节流操作和字符流操作呢?\n个人认为主要有两点原因:\n字符流是由 Java 虚拟机将字节转换得到的,这个过程还算是比较耗时; 如果我们不知道编码类型的话,使用字节流的过程中很容易出现乱码问题。 Java IO 中的设计模式有哪些? # 参考答案: Java IO 设计模式总结\nBIO、NIO 和 AIO 的区别? # 参考答案: Java IO 模型详解\n语法糖 # 什么是语法糖? # 语法糖(Syntactic sugar) 代指的是编程语言为了方便程序员开发程序而设计的一种特殊语法,这种语法对编程语言的功能并没有影响。实现相同的功能,基于语法糖写出来的代码往往更简单简洁且更易阅读。\n举个例子,Java 中的 for-each 就是一个常用的语法糖,其原理其实就是基于普通的 for 循环和迭代器。\nString[] strs = {\u0026#34;JavaGuide\u0026#34;, \u0026#34;公众号:JavaGuide\u0026#34;, \u0026#34;博客:https://javaguide.cn/\u0026#34;}; for (String s : strs) { System.out.println(s); } 不过,JVM 其实并不能识别语法糖,Java 语法糖要想被正确执行,需要先通过编译器进行解糖,也就是在程序编译阶段将其转换成 JVM 认识的基本语法。这也侧面说明,Java 中真正支持语法糖的是 Java 编译器而不是 JVM。如果你去看com.sun.tools.javac.main.JavaCompiler的源码,你会发现在compile()中有一个步骤就是调用desugar(),这个方法就是负责解语法糖的实现的。\nJava 中有哪些常见的语法糖? # Java 中最常用的语法糖主要有泛型、自动拆装箱、变长参数、枚举、内部类、增强 for 循环、try-with-resources 语法、lambda 表达式等。\n关于这些语法糖的详细解读,请看这篇文章 Java 语法糖详解 。\n"},{"id":518,"href":"/zh/docs/technology/Interview/java/basis/java-basic-questions-02/","title":"Java基础常见面试题总结(中)","section":"Basis","content":" 面向对象基础 # 面向对象和面向过程的区别 # 面向过程编程(Procedural-Oriented Programming,POP)和面向对象编程(Object-Oriented Programming,OOP)是两种常见的编程范式,两者的主要区别在于解决问题的方式不同:\n面向过程编程(POP):面向过程把解决问题的过程拆成一个个方法,通过一个个方法的执行解决问题。 面向对象编程(OOP):面向对象会先抽象出对象,然后用对象执行方法的方式解决问题。 相比较于 POP,OOP 开发的程序一般具有下面这些优点:\n易维护:由于良好的结构和封装性,OOP 程序通常更容易维护。 易复用:通过继承和多态,OOP 设计使得代码更具复用性,方便扩展功能。 易扩展:模块化设计使得系统扩展变得更加容易和灵活。 POP 的编程方式通常更为简单和直接,适合处理一些较简单的任务。\nPOP 和 OOP 的性能差异主要取决于它们的运行机制,而不仅仅是编程范式本身。因此,简单地比较两者的性能是一个常见的误区(相关 issue : 面向过程:面向过程性能比面向对象高?? )。\n在选择编程范式时,性能并不是唯一的考虑因素。代码的可维护性、可扩展性和开发效率同样重要。\n现代编程语言基本都支持多种编程范式,既可以用来进行面向过程编程,也可以进行面向对象编程。\n下面是一个求圆的面积和周长的示例,简单分别展示了面向对象和面向过程两种不同的解决方案。\n面向对象:\npublic class Circle { // 定义圆的半径 private double radius; // 构造函数 public Circle(double radius) { this.radius = radius; } // 计算圆的面积 public double getArea() { return Math.PI * radius * radius; } // 计算圆的周长 public double getPerimeter() { return 2 * Math.PI * radius; } public static void main(String[] args) { // 创建一个半径为3的圆 Circle circle = new Circle(3.0); // 输出圆的面积和周长 System.out.println(\u0026#34;圆的面积为:\u0026#34; + circle.getArea()); System.out.println(\u0026#34;圆的周长为:\u0026#34; + circle.getPerimeter()); } } 我们定义了一个 Circle 类来表示圆,该类包含了圆的半径属性和计算面积、周长的方法。\n面向过程:\npublic class Main { public static void main(String[] args) { // 定义圆的半径 double radius = 3.0; // 计算圆的面积和周长 double area = Math.PI * radius * radius; double perimeter = 2 * Math.PI * radius; // 输出圆的面积和周长 System.out.println(\u0026#34;圆的面积为:\u0026#34; + area); System.out.println(\u0026#34;圆的周长为:\u0026#34; + perimeter); } } 我们直接定义了圆的半径,并使用该半径直接计算出圆的面积和周长。\n创建一个对象用什么运算符?对象实体与对象引用有何不同? # new 运算符,new 创建对象实例(对象实例在堆内存中),对象引用指向对象实例(对象引用存放在栈内存中)。\n一个对象引用可以指向 0 个或 1 个对象(一根绳子可以不系气球,也可以系一个气球); 一个对象可以有 n 个引用指向它(可以用 n 条绳子系住一个气球)。 对象的相等和引用相等的区别 # 对象的相等一般比较的是内存中存放的内容是否相等。 引用相等一般比较的是他们指向的内存地址是否相等。 这里举一个例子:\nString str1 = \u0026#34;hello\u0026#34;; String str2 = new String(\u0026#34;hello\u0026#34;); String str3 = \u0026#34;hello\u0026#34;; // 使用 == 比较字符串的引用相等 System.out.println(str1 == str2); System.out.println(str1 == str3); // 使用 equals 方法比较字符串的相等 System.out.println(str1.equals(str2)); System.out.println(str1.equals(str3)); 输出结果:\nfalse true true true 从上面的代码输出结果可以看出:\nstr1 和 str2 不相等,而 str1 和 str3 相等。这是因为 == 运算符比较的是字符串的引用是否相等。 str1、 str2、str3 三者的内容都相等。这是因为equals 方法比较的是字符串的内容,即使这些字符串的对象引用不同,只要它们的内容相等,就认为它们是相等的。 如果一个类没有声明构造方法,该程序能正确执行吗? # 构造方法是一种特殊的方法,主要作用是完成对象的初始化工作。\n如果一个类没有声明构造方法,也可以执行!因为一个类即使没有声明构造方法也会有默认的不带参数的构造方法。如果我们自己添加了类的构造方法(无论是否有参),Java 就不会添加默认的无参数的构造方法了。\n我们一直在不知不觉地使用构造方法,这也是为什么我们在创建对象的时候后面要加一个括号(因为要调用无参的构造方法)。如果我们重载了有参的构造方法,记得都要把无参的构造方法也写出来(无论是否用到),因为这可以帮助我们在创建对象的时候少踩坑。\n构造方法有哪些特点?是否可被 override? # 构造方法具有以下特点:\n名称与类名相同:构造方法的名称必须与类名完全一致。 没有返回值:构造方法没有返回类型,且不能使用 void 声明。 自动执行:在生成类的对象时,构造方法会自动执行,无需显式调用。 构造方法不能被重写(override),但可以被重载(overload)。因此,一个类中可以有多个构造方法,这些构造方法可以具有不同的参数列表,以提供不同的对象初始化方式。\n面向对象三大特征 # 封装 # 封装是指把一个对象的状态信息(也就是属性)隐藏在对象内部,不允许外部对象直接访问对象的内部信息。但是可以提供一些可以被外界访问的方法来操作属性。就好像我们看不到挂在墙上的空调的内部的零件信息(也就是属性),但是可以通过遥控器(方法)来控制空调。如果属性不想被外界访问,我们大可不必提供方法给外界访问。但是如果一个类没有提供给外界访问的方法,那么这个类也没有什么意义了。就好像如果没有空调遥控器,那么我们就无法操控空凋制冷,空调本身就没有意义了(当然现在还有很多其他方法 ,这里只是为了举例子)。\npublic class Student { private int id;//id属性私有化 private String name;//name属性私有化 //获取id的方法 public int getId() { return id; } //设置id的方法 public void setId(int id) { this.id = id; } //获取name的方法 public String getName() { return name; } //设置name的方法 public void setName(String name) { this.name = name; } } 继承 # 不同类型的对象,相互之间经常有一定数量的共同点。例如,小明同学、小红同学、小李同学,都共享学生的特性(班级、学号等)。同时,每一个对象还定义了额外的特性使得他们与众不同。例如小明的数学比较好,小红的性格惹人喜爱;小李的力气比较大。继承是使用已存在的类的定义作为基础建立新类的技术,新类的定义可以增加新的数据或新的功能,也可以用父类的功能,但不能选择性地继承父类。通过使用继承,可以快速地创建新的类,可以提高代码的重用,程序的可维护性,节省大量创建新类的时间 ,提高我们的开发效率。\n关于继承如下 3 点请记住:\n子类拥有父类对象所有的属性和方法(包括私有属性和私有方法),但是父类中的私有属性和方法子类是无法访问,只是拥有。 子类可以拥有自己属性和方法,即子类可以对父类进行扩展。 子类可以用自己的方式实现父类的方法。(以后介绍)。 多态 # 多态,顾名思义,表示一个对象具有多种的状态,具体表现为父类的引用指向子类的实例。\n多态的特点:\n对象类型和引用类型之间具有继承(类)/实现(接口)的关系; 引用类型变量发出的方法调用的到底是哪个类中的方法,必须在程序运行期间才能确定; 多态不能调用“只在子类存在但在父类不存在”的方法; 如果子类重写了父类的方法,真正执行的是子类重写的方法,如果子类没有重写父类的方法,执行的是父类的方法。 接口和抽象类有什么共同点和区别? # 接口和抽象类的共同点 # 实例化:接口和抽象类都不能直接实例化,只能被实现(接口)或继承(抽象类)后才能创建具体的对象。 抽象方法:接口和抽象类都可以包含抽象方法。抽象方法没有方法体,必须在子类或实现类中实现。 接口和抽象类的区别 # 设计目的:接口主要用于对类的行为进行约束,你实现了某个接口就具有了对应的行为。抽象类主要用于代码复用,强调的是所属关系。 继承和实现:一个类只能继承一个类(包括抽象类),因为 Java 不支持多继承。但一个类可以实现多个接口,一个接口也可以继承多个其他接口。 成员变量:接口中的成员变量只能是 public static final 类型的,不能被修改且必须有初始值。抽象类的成员变量可以有任何修饰符(private, protected, public),可以在子类中被重新定义或赋值。 方法: Java 8 之前,接口中的方法默认是 public abstract ,也就是只能有方法声明。自 Java 8 起,可以在接口中定义 default(默认) 方法和 static (静态)方法。 自 Java 9 起,接口可以包含 private 方法。 抽象类可以包含抽象方法和非抽象方法。抽象方法没有方法体,必须在子类中实现。非抽象方法有具体实现,可以直接在抽象类中使用或在子类中重写。 在 Java 8 及以上版本中,接口引入了新的方法类型:default 方法、static 方法和 private 方法。这些方法让接口的使用更加灵活。\nJava 8 引入的default 方法用于提供接口方法的默认实现,可以在实现类中被覆盖。这样就可以在不修改实现类的情况下向现有接口添加新功能,从而增强接口的扩展性和向后兼容性。\npublic interface MyInterface { default void defaultMethod() { System.out.println(\u0026#34;This is a default method.\u0026#34;); } } Java 8 引入的static 方法无法在实现类中被覆盖,只能通过接口名直接调用( MyInterface.staticMethod()),类似于类中的静态方法。static 方法通常用于定义一些通用的、与接口相关的工具方法,一般很少用。\npublic interface MyInterface { static void staticMethod() { System.out.println(\u0026#34;This is a static method in the interface.\u0026#34;); } } Java 9 允许在接口中使用 private 方法。private方法可以用于在接口内部共享代码,不对外暴露。\npublic interface MyInterface { // default 方法 default void defaultMethod() { commonMethod(); } // static 方法 static void staticMethod() { commonMethod(); } // 私有静态方法,可以被 static 和 default 方法调用 private static void commonMethod() { System.out.println(\u0026#34;This is a private method used internally.\u0026#34;); } // 实例私有方法,只能被 default 方法调用。 private void instanceCommonMethod() { System.out.println(\u0026#34;This is a private instance method used internally.\u0026#34;); } } 深拷贝和浅拷贝区别了解吗?什么是引用拷贝? # 关于深拷贝和浅拷贝区别,我这里先给结论:\n浅拷贝:浅拷贝会在堆上创建一个新的对象(区别于引用拷贝的一点),不过,如果原对象内部的属性是引用类型的话,浅拷贝会直接复制内部对象的引用地址,也就是说拷贝对象和原对象共用同一个内部对象。 深拷贝:深拷贝会完全复制整个对象,包括这个对象所包含的内部对象。 上面的结论没有完全理解的话也没关系,我们来看一个具体的案例!\n浅拷贝 # 浅拷贝的示例代码如下,我们这里实现了 Cloneable 接口,并重写了 clone() 方法。\nclone() 方法的实现很简单,直接调用的是父类 Object 的 clone() 方法。\npublic class Address implements Cloneable{ private String name; // 省略构造函数、Getter\u0026amp;Setter方法 @Override public Address clone() { try { return (Address) super.clone(); } catch (CloneNotSupportedException e) { throw new AssertionError(); } } } public class Person implements Cloneable { private Address address; // 省略构造函数、Getter\u0026amp;Setter方法 @Override public Person clone() { try { Person person = (Person) super.clone(); return person; } catch (CloneNotSupportedException e) { throw new AssertionError(); } } } 测试:\nPerson person1 = new Person(new Address(\u0026#34;武汉\u0026#34;)); Person person1Copy = person1.clone(); // true System.out.println(person1.getAddress() == person1Copy.getAddress()); 从输出结构就可以看出, person1 的克隆对象和 person1 使用的仍然是同一个 Address 对象。\n深拷贝 # 这里我们简单对 Person 类的 clone() 方法进行修改,连带着要把 Person 对象内部的 Address 对象一起复制。\n@Override public Person clone() { try { Person person = (Person) super.clone(); person.setAddress(person.getAddress().clone()); return person; } catch (CloneNotSupportedException e) { throw new AssertionError(); } } 测试:\nPerson person1 = new Person(new Address(\u0026#34;武汉\u0026#34;)); Person person1Copy = person1.clone(); // false System.out.println(person1.getAddress() == person1Copy.getAddress()); 从输出结构就可以看出,显然 person1 的克隆对象和 person1 包含的 Address 对象已经是不同的了。\n那什么是引用拷贝呢? 简单来说,引用拷贝就是两个不同的引用指向同一个对象。\n我专门画了一张图来描述浅拷贝、深拷贝、引用拷贝:\nObject # Object 类的常见方法有哪些? # Object 类是一个特殊的类,是所有类的父类,主要提供了以下 11 个方法:\n/** * native 方法,用于返回当前运行时对象的 Class 对象,使用了 final 关键字修饰,故不允许子类重写。 */ public final native Class\u0026lt;?\u0026gt; getClass() /** * native 方法,用于返回对象的哈希码,主要使用在哈希表中,比如 JDK 中的HashMap。 */ public native int hashCode() /** * 用于比较 2 个对象的内存地址是否相等,String 类对该方法进行了重写以用于比较字符串的值是否相等。 */ public boolean equals(Object obj) /** * native 方法,用于创建并返回当前对象的一份拷贝。 */ protected native Object clone() throws CloneNotSupportedException /** * 返回类的名字实例的哈希码的 16 进制的字符串。建议 Object 所有的子类都重写这个方法。 */ public String toString() /** * native 方法,并且不能重写。唤醒一个在此对象监视器上等待的线程(监视器相当于就是锁的概念)。如果有多个线程在等待只会任意唤醒一个。 */ public final native void notify() /** * native 方法,并且不能重写。跟 notify 一样,唯一的区别就是会唤醒在此对象监视器上等待的所有线程,而不是一个线程。 */ public final native void notifyAll() /** * native方法,并且不能重写。暂停线程的执行。注意:sleep 方法没有释放锁,而 wait 方法释放了锁 ,timeout 是等待时间。 */ public final native void wait(long timeout) throws InterruptedException /** * 多了 nanos 参数,这个参数表示额外时间(以纳秒为单位,范围是 0-999999)。 所以超时的时间还需要加上 nanos 纳秒。。 */ public final void wait(long timeout, int nanos) throws InterruptedException /** * 跟之前的2个wait方法一样,只不过该方法一直等待,没有超时时间这个概念 */ public final void wait() throws InterruptedException /** * 实例被垃圾回收器回收的时候触发的操作 */ protected void finalize() throws Throwable { } == 和 equals() 的区别 # == 对于基本类型和引用类型的作用效果是不同的:\n对于基本数据类型来说,== 比较的是值。 对于引用数据类型来说,== 比较的是对象的内存地址。 因为 Java 只有值传递,所以,对于 == 来说,不管是比较基本数据类型,还是引用数据类型的变量,其本质比较的都是值,只是引用类型变量存的值是对象的地址。\nequals() 不能用于判断基本数据类型的变量,只能用来判断两个对象是否相等。equals()方法存在于Object类中,而Object类是所有类的直接或间接父类,因此所有的类都有equals()方法。\nObject 类 equals() 方法:\npublic boolean equals(Object obj) { return (this == obj); } equals() 方法存在两种使用情况:\n类没有重写 equals()方法:通过equals()比较该类的两个对象时,等价于通过“==”比较这两个对象,使用的默认是 Object类equals()方法。 类重写了 equals()方法:一般我们都重写 equals()方法来比较两个对象中的属性是否相等;若它们的属性相等,则返回 true(即,认为这两个对象相等)。 举个例子(这里只是为了举例。实际上,你按照下面这种写法的话,像 IDEA 这种比较智能的 IDE 都会提示你将 == 换成 equals() ):\nString a = new String(\u0026#34;ab\u0026#34;); // a 为一个引用 String b = new String(\u0026#34;ab\u0026#34;); // b为另一个引用,对象的内容一样 String aa = \u0026#34;ab\u0026#34;; // 放在常量池中 String bb = \u0026#34;ab\u0026#34;; // 从常量池中查找 System.out.println(aa == bb);// true System.out.println(a == b);// false System.out.println(a.equals(b));// true System.out.println(42 == 42.0);// true String 中的 equals 方法是被重写过的,因为 Object 的 equals 方法是比较的对象的内存地址,而 String 的 equals 方法比较的是对象的值。\n当创建 String 类型的对象时,虚拟机会在常量池中查找有没有已经存在的值和要创建的值相同的对象,如果有就把它赋给当前引用。如果没有就在常量池中重新创建一个 String 对象。\nString类equals()方法:\npublic boolean equals(Object anObject) { if (this == anObject) { return true; } if (anObject instanceof String) { String anotherString = (String)anObject; int n = value.length; if (n == anotherString.value.length) { char v1[] = value; char v2[] = anotherString.value; int i = 0; while (n-- != 0) { if (v1[i] != v2[i]) return false; i++; } return true; } } return false; } hashCode() 有什么用? # hashCode() 的作用是获取哈希码(int 整数),也称为散列码。这个哈希码的作用是确定该对象在哈希表中的索引位置。\nhashCode() 定义在 JDK 的 Object 类中,这就意味着 Java 中的任何类都包含有 hashCode() 函数。另外需要注意的是:Object 的 hashCode() 方法是本地方法,也就是用 C 语言或 C++ 实现的。\n⚠️ 注意:该方法在 Oracle OpenJDK8 中默认是 \u0026ldquo;使用线程局部状态来实现 Marsaglia\u0026rsquo;s xor-shift 随机数生成\u0026rdquo;, 并不是 \u0026ldquo;地址\u0026rdquo; 或者 \u0026ldquo;地址转换而来\u0026rdquo;, 不同 JDK/VM 可能不同。在 Oracle OpenJDK8 中有六种生成方式 (其中第五种是返回地址), 通过添加 VM 参数: -XX:hashCode=4 启用第五种。参考源码:\nhttps://hg.openjdk.org/jdk8u/jdk8u/hotspot/file/87ee5ee27509/src/share/vm/runtime/globals.hpp(1127 行) https://hg.openjdk.org/jdk8u/jdk8u/hotspot/file/87ee5ee27509/src/share/vm/runtime/synchronizer.cpp(537 行开始) public native int hashCode(); 散列表存储的是键值对(key-value),它的特点是:能根据“键”快速的检索出对应的“值”。这其中就利用到了散列码!(可以快速找到所需要的对象)\n为什么要有 hashCode? # 我们以“HashSet 如何检查重复”为例子来说明为什么要有 hashCode?\n下面这段内容摘自我的 Java 启蒙书《Head First Java》:\n当你把对象加入 HashSet 时,HashSet 会先计算对象的 hashCode 值来判断对象加入的位置,同时也会与其他已经加入的对象的 hashCode 值作比较,如果没有相符的 hashCode,HashSet 会假设对象没有重复出现。但是如果发现有相同 hashCode 值的对象,这时会调用 equals() 方法来检查 hashCode 相等的对象是否真的相同。如果两者相同,HashSet 就不会让其加入操作成功。如果不同的话,就会重新散列到其他位置。这样我们就大大减少了 equals 的次数,相应就大大提高了执行速度。\n其实, hashCode() 和 equals()都是用于比较两个对象是否相等。\n那为什么 JDK 还要同时提供这两个方法呢?\n这是因为在一些容器(比如 HashMap、HashSet)中,有了 hashCode() 之后,判断元素是否在对应容器中的效率会更高(参考添加元素进HashSet的过程)!\n我们在前面也提到了添加元素进HashSet的过程,如果 HashSet 在对比的时候,同样的 hashCode 有多个对象,它会继续使用 equals() 来判断是否真的相同。也就是说 hashCode 帮助我们大大缩小了查找成本。\n那为什么不只提供 hashCode() 方法呢?\n这是因为两个对象的hashCode 值相等并不代表两个对象就相等。\n那为什么两个对象有相同的 hashCode 值,它们也不一定是相等的?\n因为 hashCode() 所使用的哈希算法也许刚好会让多个对象传回相同的哈希值。越糟糕的哈希算法越容易碰撞,但这也与数据值域分布的特性有关(所谓哈希碰撞也就是指的是不同的对象得到相同的 hashCode )。\n总结下来就是:\n如果两个对象的hashCode 值相等,那这两个对象不一定相等(哈希碰撞)。 如果两个对象的hashCode 值相等并且equals()方法也返回 true,我们才认为这两个对象相等。 如果两个对象的hashCode 值不相等,我们就可以直接认为这两个对象不相等。 相信大家看了我前面对 hashCode() 和 equals() 的介绍之后,下面这个问题已经难不倒你们了。\n为什么重写 equals() 时必须重写 hashCode() 方法? # 因为两个相等的对象的 hashCode 值必须是相等。也就是说如果 equals 方法判断两个对象是相等的,那这两个对象的 hashCode 值也要相等。\n如果重写 equals() 时没有重写 hashCode() 方法的话就可能会导致 equals 方法判断是相等的两个对象,hashCode 值却不相等。\n思考:重写 equals() 时没有重写 hashCode() 方法的话,使用 HashMap 可能会出现什么问题。\n总结:\nequals 方法判断两个对象是相等的,那这两个对象的 hashCode 值也要相等。 两个对象有相同的 hashCode 值,他们也不一定是相等的(哈希碰撞)。 更多关于 hashCode() 和 equals() 的内容可以查看: Java hashCode() 和 equals()的若干问题解答\nString # String、StringBuffer、StringBuilder 的区别? # 可变性\nString 是不可变的(后面会详细分析原因)。\nStringBuilder 与 StringBuffer 都继承自 AbstractStringBuilder 类,在 AbstractStringBuilder 中也是使用字符数组保存字符串,不过没有使用 final 和 private 关键字修饰,最关键的是这个 AbstractStringBuilder 类还提供了很多修改字符串的方法比如 append 方法。\nabstract class AbstractStringBuilder implements Appendable, CharSequence { char[] value; public AbstractStringBuilder append(String str) { if (str == null) return appendNull(); int len = str.length(); ensureCapacityInternal(count + len); str.getChars(0, len, value, count); count += len; return this; } //... } 线程安全性\nString 中的对象是不可变的,也就可以理解为常量,线程安全。AbstractStringBuilder 是 StringBuilder 与 StringBuffer 的公共父类,定义了一些字符串的基本操作,如 expandCapacity、append、insert、indexOf 等公共方法。StringBuffer 对方法加了同步锁或者对调用的方法加了同步锁,所以是线程安全的。StringBuilder 并没有对方法进行加同步锁,所以是非线程安全的。\n性能\n每次对 String 类型进行改变的时候,都会生成一个新的 String 对象,然后将指针指向新的 String 对象。StringBuffer 每次都会对 StringBuffer 对象本身进行操作,而不是生成新的对象并改变对象引用。相同情况下使用 StringBuilder 相比使用 StringBuffer 仅能获得 10%~15% 左右的性能提升,但却要冒多线程不安全的风险。\n对于三者使用的总结:\n操作少量的数据: 适用 String 单线程操作字符串缓冲区下操作大量数据: 适用 StringBuilder 多线程操作字符串缓冲区下操作大量数据: 适用 StringBuffer String 为什么是不可变的? # String 类中使用 final 关键字修饰字符数组来保存字符串,所以String 对象是不可变的。\npublic final class String implements java.io.Serializable, Comparable\u0026lt;String\u0026gt;, CharSequence { private final char value[]; //... } 🐛 修正:我们知道被 final 关键字修饰的类不能被继承,修饰的方法不能被重写,修饰的变量是基本数据类型则值不能改变,修饰的变量是引用类型则不能再指向其他对象。因此,final 关键字修饰的数组保存字符串并不是 String 不可变的根本原因,因为这个数组保存的字符串是可变的(final 修饰引用类型变量的情况)。\nString 真正不可变有下面几点原因:\n保存字符串的数组被 final 修饰且为私有的,并且String 类没有提供/暴露修改这个字符串的方法。 String 类被 final 修饰导致其不能被继承,进而避免了子类破坏 String 不可变。 相关阅读: 如何理解 String 类型值的不可变? - 知乎提问\n补充(来自 issue 675):在 Java 9 之后,String、StringBuilder 与 StringBuffer 的实现改用 byte 数组存储字符串。\npublic final class String implements java.io.Serializable,Comparable\u0026lt;String\u0026gt;, CharSequence { // @Stable 注解表示变量最多被修改一次,称为“稳定的”。 @Stable private final byte[] value; } abstract class AbstractStringBuilder implements Appendable, CharSequence { byte[] value; } Java 9 为何要将 String 的底层实现由 char[] 改成了 byte[] ?\n新版的 String 其实支持两个编码方案:Latin-1 和 UTF-16。如果字符串中包含的汉字没有超过 Latin-1 可表示范围内的字符,那就会使用 Latin-1 作为编码方案。Latin-1 编码方案下,byte 占一个字节(8 位),char 占用 2 个字节(16),byte 相较 char 节省一半的内存空间。\nJDK 官方就说了绝大部分字符串对象只包含 Latin-1 可表示的字符。\n如果字符串中包含的汉字超过 Latin-1 可表示范围内的字符,byte 和 char 所占用的空间是一样的。\n这是官方的介绍: https://openjdk.java.net/jeps/254 。\n字符串拼接用“+” 还是 StringBuilder? # Java 语言本身并不支持运算符重载,“+”和“+=”是专门为 String 类重载过的运算符,也是 Java 中仅有的两个重载过的运算符。\nString str1 = \u0026#34;he\u0026#34;; String str2 = \u0026#34;llo\u0026#34;; String str3 = \u0026#34;world\u0026#34;; String str4 = str1 + str2 + str3; 上面的代码对应的字节码如下:\n可以看出,字符串对象通过“+”的字符串拼接方式,实际上是通过 StringBuilder 调用 append() 方法实现的,拼接完成之后调用 toString() 得到一个 String 对象 。\n不过,在循环内使用“+”进行字符串的拼接的话,存在比较明显的缺陷:编译器不会创建单个 StringBuilder 以复用,会导致创建过多的 StringBuilder 对象。\nString[] arr = {\u0026#34;he\u0026#34;, \u0026#34;llo\u0026#34;, \u0026#34;world\u0026#34;}; String s = \u0026#34;\u0026#34;; for (int i = 0; i \u0026lt; arr.length; i++) { s += arr[i]; } System.out.println(s); StringBuilder 对象是在循环内部被创建的,这意味着每循环一次就会创建一个 StringBuilder 对象。\n如果直接使用 StringBuilder 对象进行字符串拼接的话,就不会存在这个问题了。\nString[] arr = {\u0026#34;he\u0026#34;, \u0026#34;llo\u0026#34;, \u0026#34;world\u0026#34;}; StringBuilder s = new StringBuilder(); for (String value : arr) { s.append(value); } System.out.println(s); 如果你使用 IDEA 的话,IDEA 自带的代码检查机制也会提示你修改代码。\n在 JDK 9 中,字符串相加“+”改为用动态方法 makeConcatWithConstants() 来实现,通过提前分配空间从而减少了部分临时对象的创建。然而这种优化主要针对简单的字符串拼接,如: a+b+c 。对于循环中的大量拼接操作,仍然会逐个动态分配内存(类似于两个两个 append 的概念),并不如手动使用 StringBuilder 来进行拼接效率高。这个改进是 JDK9 的 JEP 280 提出的,关于这部分改进的详细介绍,推荐阅读这篇文章:还在无脑用 StringBuilder?来重温一下字符串拼接吧 以及参考 issue#2442。\nString#equals() 和 Object#equals() 有何区别? # String 中的 equals 方法是被重写过的,比较的是 String 字符串的值是否相等。 Object 的 equals 方法是比较的对象的内存地址。\n字符串常量池的作用了解吗? # 字符串常量池 是 JVM 为了提升性能和减少内存消耗针对字符串(String 类)专门开辟的一块区域,主要目的是为了避免字符串的重复创建。\n// 在字符串常量池中创建字符串对象 ”ab“ // 将字符串对象 ”ab“ 的引用赋值给 aa String aa = \u0026#34;ab\u0026#34;; // 直接返回字符串常量池中字符串对象 ”ab“,赋值给引用 bb String bb = \u0026#34;ab\u0026#34;; System.out.println(aa==bb); // true 更多关于字符串常量池的介绍可以看一下 Java 内存区域详解 这篇文章。\nString s1 = new String(\u0026ldquo;abc\u0026rdquo;);这句话创建了几个字符串对象? # 先说答案:会创建 1 或 2 个字符串对象。\n字符串常量池中不存在 \u0026ldquo;abc\u0026rdquo;:会创建 2 个 字符串对象。一个在字符串常量池中,由 ldc 指令触发创建。一个在堆中,由 new String() 创建,并使用常量池中的 \u0026ldquo;abc\u0026rdquo; 进行初始化。 字符串常量池中已存在 \u0026ldquo;abc\u0026rdquo;:会创建 1 个 字符串对象。该对象在堆中,由 new String() 创建,并使用常量池中的 \u0026ldquo;abc\u0026rdquo; 进行初始化。 下面开始详细分析。\n1、如果字符串常量池中不存在字符串对象 “abc”,那么它首先会在字符串常量池中创建字符串对象 \u0026ldquo;abc\u0026rdquo;,然后在堆内存中再创建其中一个字符串对象 \u0026ldquo;abc\u0026rdquo;。\n示例代码(JDK 1.8):\nString s1 = new String(\u0026#34;abc\u0026#34;); 对应的字节码:\n// 在堆内存中分配一个尚未初始化的 String 对象。 // #2 是常量池中的一个符号引用,指向 java/lang/String 类。 // 在类加载的解析阶段,这个符号引用会被解析成直接引用,即指向实际的 java/lang/String 类。 0 new #2 \u0026lt;java/lang/String\u0026gt; // 复制栈顶的 String 对象引用,为后续的构造函数调用做准备。 // 此时操作数栈中有两个相同的对象引用:一个用于传递给构造函数,另一个用于保持对新对象的引用,后续将其存储到局部变量表。 3 dup // JVM 先检查字符串常量池中是否存在 \u0026#34;abc\u0026#34;。 // 如果常量池中已存在 \u0026#34;abc\u0026#34;,则直接返回该字符串的引用; // 如果常量池中不存在 \u0026#34;abc\u0026#34;,则 JVM 会在常量池中创建该字符串字面量并返回它的引用。 // 这个引用被压入操作数栈,用作构造函数的参数。 4 ldc #3 \u0026lt;abc\u0026gt; // 调用构造方法,使用从常量池中加载的 \u0026#34;abc\u0026#34; 初始化堆中的 String 对象 // 新的 String 对象将包含与常量池中的 \u0026#34;abc\u0026#34; 相同的内容,但它是一个独立的对象,存储于堆中。 6 invokespecial #4 \u0026lt;java/lang/String.\u0026lt;init\u0026gt; : (Ljava/lang/String;)V\u0026gt; // 将堆中的 String 对象引用存储到局部变量表 9 astore_1 // 返回,结束方法 10 return ldc (load constant) 指令的确是从常量池中加载各种类型的常量,包括字符串常量、整数常量、浮点数常量,甚至类引用等。对于字符串常量,ldc 指令的行为如下:\n从常量池加载字符串:ldc 首先检查字符串常量池中是否已经有内容相同的字符串对象。 复用已有字符串对象:如果字符串常量池中已经存在内容相同的字符串对象,ldc 会将该对象的引用加载到操作数栈上。 没有则创建新对象并加入常量池:如果字符串常量池中没有相同内容的字符串对象,JVM 会在常量池中创建一个新的字符串对象,并将其引用加载到操作数栈中。 2、如果字符串常量池中已存在字符串对象“abc”,则只会在堆中创建 1 个字符串对象“abc”。\n示例代码(JDK 1.8):\n// 字符串常量池中已存在字符串对象“abc” String s1 = \u0026#34;abc\u0026#34;; // 下面这段代码只会在堆中创建 1 个字符串对象“abc” String s2 = new String(\u0026#34;abc\u0026#34;); 对应的字节码:\n0 ldc #2 \u0026lt;abc\u0026gt; 2 astore_1 3 new #3 \u0026lt;java/lang/String\u0026gt; 6 dup 7 ldc #2 \u0026lt;abc\u0026gt; 9 invokespecial #4 \u0026lt;java/lang/String.\u0026lt;init\u0026gt; : (Ljava/lang/String;)V\u0026gt; 12 astore_2 13 return 这里就不对上面的字节码进行详细注释了,7 这个位置的 ldc 命令不会在堆中创建新的字符串对象“abc”,这是因为 0 这个位置已经执行了一次 ldc 命令,已经在堆中创建过一次字符串对象“abc”了。7 这个位置执行 ldc 命令会直接返回字符串常量池中字符串对象“abc”对应的引用。\nString#intern 方法有什么作用? # String.intern() 是一个 native (本地) 方法,用来处理字符串常量池中的字符串对象引用。它的工作流程可以概括为以下两种情况:\n常量池中已有相同内容的字符串对象:如果字符串常量池中已经有一个与调用 intern() 方法的字符串内容相同的 String 对象,intern() 方法会直接返回常量池中该对象的引用。 常量池中没有相同内容的字符串对象:如果字符串常量池中还没有一个与调用 intern() 方法的字符串内容相同的对象,intern() 方法会将当前字符串对象的引用添加到字符串常量池中,并返回该引用。 总结:\nintern() 方法的主要作用是确保字符串引用在常量池中的唯一性。 当调用 intern() 时,如果常量池中已经存在相同内容的字符串,则返回常量池中已有对象的引用;否则,将该字符串添加到常量池并返回其引用。 示例代码(JDK 1.8) :\n// s1 指向字符串常量池中的 \u0026#34;Java\u0026#34; 对象 String s1 = \u0026#34;Java\u0026#34;; // s2 也指向字符串常量池中的 \u0026#34;Java\u0026#34; 对象,和 s1 是同一个对象 String s2 = s1.intern(); // 在堆中创建一个新的 \u0026#34;Java\u0026#34; 对象,s3 指向它 String s3 = new String(\u0026#34;Java\u0026#34;); // s4 指向字符串常量池中的 \u0026#34;Java\u0026#34; 对象,和 s1 是同一个对象 String s4 = s3.intern(); // s1 和 s2 指向的是同一个常量池中的对象 System.out.println(s1 == s2); // true // s3 指向堆中的对象,s4 指向常量池中的对象,所以不同 System.out.println(s3 == s4); // false // s1 和 s4 都指向常量池中的同一个对象 System.out.println(s1 == s4); // true String 类型的变量和常量做“+”运算时发生了什么? # 先来看字符串不加 final 关键字拼接的情况(JDK1.8):\nString str1 = \u0026#34;str\u0026#34;; String str2 = \u0026#34;ing\u0026#34;; String str3 = \u0026#34;str\u0026#34; + \u0026#34;ing\u0026#34;; String str4 = str1 + str2; String str5 = \u0026#34;string\u0026#34;; System.out.println(str3 == str4);//false System.out.println(str3 == str5);//true System.out.println(str4 == str5);//false 注意:比较 String 字符串的值是否相等,可以使用 equals() 方法。 String 中的 equals 方法是被重写过的。 Object 的 equals 方法是比较的对象的内存地址,而 String 的 equals 方法比较的是字符串的值是否相等。如果你使用 == 比较两个字符串是否相等的话,IDEA 还是提示你使用 equals() 方法替换。\n对于编译期可以确定值的字符串,也就是常量字符串 ,jvm 会将其存入字符串常量池。并且,字符串常量拼接得到的字符串常量在编译阶段就已经被存放字符串常量池,这个得益于编译器的优化。\n在编译过程中,Javac 编译器(下文中统称为编译器)会进行一个叫做 常量折叠(Constant Folding) 的代码优化。《深入理解 Java 虚拟机》中是也有介绍到:\n常量折叠会把常量表达式的值求出来作为常量嵌在最终生成的代码中,这是 Javac 编译器会对源代码做的极少量优化措施之一(代码优化几乎都在即时编译器中进行)。\n对于 String str3 = \u0026quot;str\u0026quot; + \u0026quot;ing\u0026quot;; 编译器会给你优化成 String str3 = \u0026quot;string\u0026quot;; 。\n并不是所有的常量都会进行折叠,只有编译器在程序编译期就可以确定值的常量才可以:\n基本数据类型( byte、boolean、short、char、int、float、long、double)以及字符串常量。 final 修饰的基本数据类型和字符串变量 字符串通过 “+”拼接得到的字符串、基本数据类型之间算数运算(加减乘除)、基本数据类型的位运算(\u0026laquo;、\u0026gt;\u0026gt;、\u0026gt;\u0026raquo; ) 引用的值在程序编译期是无法确定的,编译器无法对其进行优化。\n对象引用和“+”的字符串拼接方式,实际上是通过 StringBuilder 调用 append() 方法实现的,拼接完成之后调用 toString() 得到一个 String 对象 。\nString str4 = new StringBuilder().append(str1).append(str2).toString(); 我们在平时写代码的时候,尽量避免多个字符串对象拼接,因为这样会重新创建对象。如果需要改变字符串的话,可以使用 StringBuilder 或者 StringBuffer。\n不过,字符串使用 final 关键字声明之后,可以让编译器当做常量来处理。\n示例代码:\nfinal String str1 = \u0026#34;str\u0026#34;; final String str2 = \u0026#34;ing\u0026#34;; // 下面两个表达式其实是等价的 String c = \u0026#34;str\u0026#34; + \u0026#34;ing\u0026#34;;// 常量池中的对象 String d = str1 + str2; // 常量池中的对象 System.out.println(c == d);// true 被 final 关键字修饰之后的 String 会被编译器当做常量来处理,编译器在程序编译期就可以确定它的值,其效果就相当于访问常量。\n如果 ,编译器在运行时才能知道其确切值的话,就无法对其优化。\n示例代码(str2 在运行时才能确定其值):\nfinal String str1 = \u0026#34;str\u0026#34;; final String str2 = getStr(); String c = \u0026#34;str\u0026#34; + \u0026#34;ing\u0026#34;;// 常量池中的对象 String d = str1 + str2; // 在堆上创建的新的对象 System.out.println(c == d);// false public static String getStr() { return \u0026#34;ing\u0026#34;; } 参考 # 深入解析 String#intern: https://tech.meituan.com/2014/03/06/in-depth-understanding-string-intern.html Java String 源码解读: http://keaper.cn/2020/09/08/java-string-mian-mian-guan/ R 大(RednaxelaFX)关于常量折叠的回答: https://www.zhihu.com/question/55976094/answer/147302764 "},{"id":519,"href":"/zh/docs/technology/Interview/java/collection/java-collection-questions-01/","title":"Java集合常见面试题总结(上)","section":"Collection","content":" 集合概述 # Java 集合概览 # Java 集合,也叫作容器,主要是由两大接口派生而来:一个是 Collection接口,主要用于存放单一元素;另一个是 Map 接口,主要用于存放键值对。对于Collection 接口,下面又有三个主要的子接口:List、Set 、 Queue。\nJava 集合框架如下图所示:\n注:图中只列举了主要的继承派生关系,并没有列举所有关系。比方省略了AbstractList, NavigableSet等抽象类以及其他的一些辅助类,如想深入了解,可自行查看源码。\n说说 List, Set, Queue, Map 四者的区别? # List(对付顺序的好帮手): 存储的元素是有序的、可重复的。 Set(注重独一无二的性质): 存储的元素不可重复的。 Queue(实现排队功能的叫号机): 按特定的排队规则来确定先后顺序,存储的元素是有序的、可重复的。 Map(用 key 来搜索的专家): 使用键值对(key-value)存储,类似于数学上的函数 y=f(x),\u0026ldquo;x\u0026rdquo; 代表 key,\u0026ldquo;y\u0026rdquo; 代表 value,key 是无序的、不可重复的,value 是无序的、可重复的,每个键最多映射到一个值。 集合框架底层数据结构总结 # 先来看一下 Collection 接口下面的集合。\nList # ArrayList:Object[] 数组。详细可以查看: ArrayList 源码分析。 Vector:Object[] 数组。 LinkedList:双向链表(JDK1.6 之前为循环链表,JDK1.7 取消了循环)。详细可以查看: LinkedList 源码分析。 Set # HashSet(无序,唯一): 基于 HashMap 实现的,底层采用 HashMap 来保存元素。 LinkedHashSet: LinkedHashSet 是 HashSet 的子类,并且其内部是通过 LinkedHashMap 来实现的。 TreeSet(有序,唯一): 红黑树(自平衡的排序二叉树)。 Queue # PriorityQueue: Object[] 数组来实现小顶堆。详细可以查看: PriorityQueue 源码分析。 DelayQueue:PriorityQueue。详细可以查看: DelayQueue 源码分析。 ArrayDeque: 可扩容动态双向数组。 再来看看 Map 接口下面的集合。\nMap # HashMap:JDK1.8 之前 HashMap 由数组+链表组成的,数组是 HashMap 的主体,链表则是主要为了解决哈希冲突而存在的(“拉链法”解决冲突)。JDK1.8 以后在解决哈希冲突时有了较大的变化,当链表长度大于阈值(默认为 8)(将链表转换成红黑树前会判断,如果当前数组的长度小于 64,那么会选择先进行数组扩容,而不是转换为红黑树)时,将链表转化为红黑树,以减少搜索时间。详细可以查看: HashMap 源码分析。 LinkedHashMap:LinkedHashMap 继承自 HashMap,所以它的底层仍然是基于拉链式散列结构即由数组和链表或红黑树组成。另外,LinkedHashMap 在上面结构的基础上,增加了一条双向链表,使得上面的结构可以保持键值对的插入顺序。同时通过对链表进行相应的操作,实现了访问顺序相关逻辑。详细可以查看: LinkedHashMap 源码分析 Hashtable:数组+链表组成的,数组是 Hashtable 的主体,链表则是主要为了解决哈希冲突而存在的。 TreeMap:红黑树(自平衡的排序二叉树)。 如何选用集合? # 我们主要根据集合的特点来选择合适的集合。比如:\n我们需要根据键值获取到元素值时就选用 Map 接口下的集合,需要排序时选择 TreeMap,不需要排序时就选择 HashMap,需要保证线程安全就选用 ConcurrentHashMap。 我们只需要存放元素值时,就选择实现Collection 接口的集合,需要保证元素唯一时选择实现 Set 接口的集合比如 TreeSet 或 HashSet,不需要就选择实现 List 接口的比如 ArrayList 或 LinkedList,然后再根据实现这些接口的集合的特点来选用。 为什么要使用集合? # 当我们需要存储一组类型相同的数据时,数组是最常用且最基本的容器之一。但是,使用数组存储对象存在一些不足之处,因为在实际开发中,存储的数据类型多种多样且数量不确定。这时,Java 集合就派上用场了。与数组相比,Java 集合提供了更灵活、更有效的方法来存储多个数据对象。Java 集合框架中的各种集合类和接口可以存储不同类型和数量的对象,同时还具有多样化的操作方式。相较于数组,Java 集合的优势在于它们的大小可变、支持泛型、具有内建算法等。总的来说,Java 集合提高了数据的存储和处理灵活性,可以更好地适应现代软件开发中多样化的数据需求,并支持高质量的代码编写。\nList # ArrayList 和 Array(数组)的区别? # ArrayList 内部基于动态数组实现,比 Array(静态数组) 使用起来更加灵活:\nArrayList会根据实际存储的元素动态地扩容或缩容,而 Array 被创建之后就不能改变它的长度了。 ArrayList 允许你使用泛型来确保类型安全,Array 则不可以。 ArrayList 中只能存储对象。对于基本类型数据,需要使用其对应的包装类(如 Integer、Double 等)。Array 可以直接存储基本类型数据,也可以存储对象。 ArrayList 支持插入、删除、遍历等常见操作,并且提供了丰富的 API 操作方法,比如 add()、remove()等。Array 只是一个固定长度的数组,只能按照下标访问其中的元素,不具备动态添加、删除元素的能力。 ArrayList创建时不需要指定大小,而Array创建时必须指定大小。 下面是二者使用的简单对比:\nArray:\n// 初始化一个 String 类型的数组 String[] stringArr = new String[]{\u0026#34;hello\u0026#34;, \u0026#34;world\u0026#34;, \u0026#34;!\u0026#34;}; // 修改数组元素的值 stringArr[0] = \u0026#34;goodbye\u0026#34;; System.out.println(Arrays.toString(stringArr));// [goodbye, world, !] // 删除数组中的元素,需要手动移动后面的元素 for (int i = 0; i \u0026lt; stringArr.length - 1; i++) { stringArr[i] = stringArr[i + 1]; } stringArr[stringArr.length - 1] = null; System.out.println(Arrays.toString(stringArr));// [world, !, null] ArrayList :\n// 初始化一个 String 类型的 ArrayList ArrayList\u0026lt;String\u0026gt; stringList = new ArrayList\u0026lt;\u0026gt;(Arrays.asList(\u0026#34;hello\u0026#34;, \u0026#34;world\u0026#34;, \u0026#34;!\u0026#34;)); // 添加元素到 ArrayList 中 stringList.add(\u0026#34;goodbye\u0026#34;); System.out.println(stringList);// [hello, world, !, goodbye] // 修改 ArrayList 中的元素 stringList.set(0, \u0026#34;hi\u0026#34;); System.out.println(stringList);// [hi, world, !, goodbye] // 删除 ArrayList 中的元素 stringList.remove(0); System.out.println(stringList); // [world, !, goodbye] ArrayList 和 Vector 的区别?(了解即可) # ArrayList 是 List 的主要实现类,底层使用 Object[]存储,适用于频繁的查找工作,线程不安全 。 Vector 是 List 的古老实现类,底层使用Object[] 存储,线程安全。 Vector 和 Stack 的区别?(了解即可) # Vector 和 Stack 两者都是线程安全的,都是使用 synchronized 关键字进行同步处理。 Stack 继承自 Vector,是一个后进先出的栈,而 Vector 是一个列表。 随着 Java 并发编程的发展,Vector 和 Stack 已经被淘汰,推荐使用并发集合类(例如 ConcurrentHashMap、CopyOnWriteArrayList 等)或者手动实现线程安全的方法来提供安全的多线程操作支持。\nArrayList 可以添加 null 值吗? # ArrayList 中可以存储任何类型的对象,包括 null 值。不过,不建议向ArrayList 中添加 null 值, null 值无意义,会让代码难以维护比如忘记做判空处理就会导致空指针异常。\n示例代码:\nArrayList\u0026lt;String\u0026gt; listOfStrings = new ArrayList\u0026lt;\u0026gt;(); listOfStrings.add(null); listOfStrings.add(\u0026#34;java\u0026#34;); System.out.println(listOfStrings); 输出:\n[null, java] ArrayList 插入和删除元素的时间复杂度? # 对于插入:\n头部插入:由于需要将所有元素都依次向后移动一个位置,因此时间复杂度是 O(n)。 尾部插入:当 ArrayList 的容量未达到极限时,往列表末尾插入元素的时间复杂度是 O(1),因为它只需要在数组末尾添加一个元素即可;当容量已达到极限并且需要扩容时,则需要执行一次 O(n) 的操作将原数组复制到新的更大的数组中,然后再执行 O(1) 的操作添加元素。 指定位置插入:需要将目标位置之后的所有元素都向后移动一个位置,然后再把新元素放入指定位置。这个过程需要移动平均 n/2 个元素,因此时间复杂度为 O(n)。 对于删除:\n头部删除:由于需要将所有元素依次向前移动一个位置,因此时间复杂度是 O(n)。 尾部删除:当删除的元素位于列表末尾时,时间复杂度为 O(1)。 指定位置删除:需要将目标元素之后的所有元素向前移动一个位置以填补被删除的空白位置,因此需要移动平均 n/2 个元素,时间复杂度为 O(n)。 这里简单列举一个例子:\n// ArrayList的底层数组大小为10,此时存储了7个元素 +---+---+---+---+---+---+---+---+---+---+ | 1 | 2 | 3 | 4 | 5 | 6 | 7 | | | | +---+---+---+---+---+---+---+---+---+---+ 0 1 2 3 4 5 6 7 8 9 // 在索引为1的位置插入一个元素8,该元素后面的所有元素都要向右移动一位 +---+---+---+---+---+---+---+---+---+---+ | 1 | 8 | 2 | 3 | 4 | 5 | 6 | 7 | | | +---+---+---+---+---+---+---+---+---+---+ 0 1 2 3 4 5 6 7 8 9 // 删除索引为1的位置的元素,该元素后面的所有元素都要向左移动一位 +---+---+---+---+---+---+---+---+---+---+ | 1 | 2 | 3 | 4 | 5 | 6 | 7 | | | | +---+---+---+---+---+---+---+---+---+---+ 0 1 2 3 4 5 6 7 8 9 LinkedList 插入和删除元素的时间复杂度? # 头部插入/删除:只需要修改头结点的指针即可完成插入/删除操作,因此时间复杂度为 O(1)。 尾部插入/删除:只需要修改尾结点的指针即可完成插入/删除操作,因此时间复杂度为 O(1)。 指定位置插入/删除:需要先移动到指定位置,再修改指定节点的指针完成插入/删除,不过由于有头尾指针,可以从较近的指针出发,因此需要遍历平均 n/4 个元素,时间复杂度为 O(n)。 这里简单列举一个例子:假如我们要删除节点 9 的话,需要先遍历链表找到该节点。然后,再执行相应节点指针指向的更改,具体的源码可以参考: LinkedList 源码分析 。\nLinkedList 为什么不能实现 RandomAccess 接口? # RandomAccess 是一个标记接口,用来表明实现该接口的类支持随机访问(即可以通过索引快速访问元素)。由于 LinkedList 底层数据结构是链表,内存地址不连续,只能通过指针来定位,不支持随机快速访问,所以不能实现 RandomAccess 接口。\nArrayList 与 LinkedList 区别? # 是否保证线程安全: ArrayList 和 LinkedList 都是不同步的,也就是不保证线程安全; 底层数据结构: ArrayList 底层使用的是 Object 数组;LinkedList 底层使用的是 双向链表 数据结构(JDK1.6 之前为循环链表,JDK1.7 取消了循环。注意双向链表和双向循环链表的区别,下面有介绍到!) 插入和删除是否受元素位置的影响: ArrayList 采用数组存储,所以插入和删除元素的时间复杂度受元素位置的影响。 比如:执行add(E e)方法的时候, ArrayList 会默认在将指定的元素追加到此列表的末尾,这种情况时间复杂度就是 O(1)。但是如果要在指定位置 i 插入和删除元素的话(add(int index, E element)),时间复杂度就为 O(n)。因为在进行上述操作的时候集合中第 i 和第 i 个元素之后的(n-i)个元素都要执行向后位/向前移一位的操作。 LinkedList 采用链表存储,所以在头尾插入或者删除元素不受元素位置的影响(add(E e)、addFirst(E e)、addLast(E e)、removeFirst()、 removeLast()),时间复杂度为 O(1),如果是要在指定位置 i 插入和删除元素的话(add(int index, E element),remove(Object o),remove(int index)), 时间复杂度为 O(n) ,因为需要先移动到指定位置再插入和删除。 是否支持快速随机访问: LinkedList 不支持高效的随机元素访问,而 ArrayList(实现了 RandomAccess 接口) 支持。快速随机访问就是通过元素的序号快速获取元素对象(对应于get(int index)方法)。 内存空间占用: ArrayList 的空间浪费主要体现在在 list 列表的结尾会预留一定的容量空间,而 LinkedList 的空间花费则体现在它的每一个元素都需要消耗比 ArrayList 更多的空间(因为要存放直接后继和直接前驱以及数据)。 我们在项目中一般是不会使用到 LinkedList 的,需要用到 LinkedList 的场景几乎都可以使用 ArrayList 来代替,并且,性能通常会更好!就连 LinkedList 的作者约书亚 · 布洛克(Josh Bloch)自己都说从来不会使用 LinkedList 。\n另外,不要下意识地认为 LinkedList 作为链表就最适合元素增删的场景。我在上面也说了,LinkedList 仅仅在头尾插入或者删除元素的时候时间复杂度近似 O(1),其他情况增删元素的平均时间复杂度都是 O(n) 。\n补充内容: 双向链表和双向循环链表 # 双向链表: 包含两个指针,一个 prev 指向前一个节点,一个 next 指向后一个节点。\n双向循环链表: 最后一个节点的 next 指向 head,而 head 的 prev 指向最后一个节点,构成一个环。\n补充内容:RandomAccess 接口 # public interface RandomAccess { } 查看源码我们发现实际上 RandomAccess 接口中什么都没有定义。所以,在我看来 RandomAccess 接口不过是一个标识罢了。标识什么? 标识实现这个接口的类具有随机访问功能。\n在 binarySearch() 方法中,它要判断传入的 list 是否 RandomAccess 的实例,如果是,调用indexedBinarySearch()方法,如果不是,那么调用iteratorBinarySearch()方法\npublic static \u0026lt;T\u0026gt; int binarySearch(List\u0026lt;? extends Comparable\u0026lt;? super T\u0026gt;\u0026gt; list, T key) { if (list instanceof RandomAccess || list.size()\u0026lt;BINARYSEARCH_THRESHOLD) return Collections.indexedBinarySearch(list, key); else return Collections.iteratorBinarySearch(list, key); } ArrayList 实现了 RandomAccess 接口, 而 LinkedList 没有实现。为什么呢?我觉得还是和底层数据结构有关!ArrayList 底层是数组,而 LinkedList 底层是链表。数组天然支持随机访问,时间复杂度为 O(1),所以称为快速随机访问。链表需要遍历到特定位置才能访问特定位置的元素,时间复杂度为 O(n),所以不支持快速随机访问。ArrayList 实现了 RandomAccess 接口,就表明了他具有快速随机访问功能。 RandomAccess 接口只是标识,并不是说 ArrayList 实现 RandomAccess 接口才具有快速随机访问功能的!\n说一说 ArrayList 的扩容机制吧 # 详见笔主的这篇文章: ArrayList 扩容机制分析。\n说说集合中的 fail-fast 和 fail-safe 是什么 # 关于fail-fast引用medium中一篇文章关于fail-fast和fail-safe的说法:\nFail-fast systems are designed to immediately stop functioning upon encountering an unexpected condition. This immediate failure helps to catch errors early, making debugging more straightforward.\n快速失败的思想即针对可能发生的异常进行提前表明故障并停止运行,通过尽早的发现和停止错误,降低故障系统级联的风险。\n在java.util包下的大部分集合是不支持线程安全的,为了能够提前发现并发操作导致线程安全风险,提出通过维护一个modCount记录修改的次数,迭代期间通过比对预期修改次数expectedModCount和modCount是否一致来判断是否存在并发操作,从而实现快速失败,由此保证在避免在异常时执行非必要的复杂代码。\n对应的我们给出下面这样一段在示例,我们首先插入100个操作元素,一个线程迭代元素,一个线程删除元素,最终输出结果如愿抛出ConcurrentModificationException:\n// 使用线程安全的 CopyOnWriteArrayList 避免 ConcurrentModificationException List\u0026lt;Integer\u0026gt; list = new CopyOnWriteArrayList\u0026lt;\u0026gt;(); CountDownLatch countDownLatch = new CountDownLatch(2); // 添加元素 for (int i = 0; i \u0026lt; 100; i++) { list.add(i); } Thread t1 = new Thread(() -\u0026gt; { // 迭代元素 (注意:Integer 是不可变的,这里的 i++ 不会修改 list 中的值) for (Integer i : list) { i++; // 这行代码实际上没有修改list中的元素 } countDownLatch.countDown(); }); Thread t2 = new Thread(() -\u0026gt; { System.out.println(\u0026#34;删除元素1\u0026#34;); list.remove(Integer.valueOf(1)); // 使用 Integer.valueOf(1) 删除指定值的对象 countDownLatch.countDown(); }); t1.start(); t2.start(); countDownLatch.await(); 我们在初始化时插入了100个元素,此时对应的修改modCount次数为100,随后线程 2 在线程 1 迭代期间进行元素删除操作,此时对应的modCount就变为101。 线程 1 在随后foreach第 2 轮循环发现modCount 为101,与预期的expectedModCount(值为100因为初始化插入了元素100个)不等,判定为并发操作异常,于是便快速失败,抛出ConcurrentModificationException:\n对此我们也给出for循环底层迭代器获取下一个元素时的next方法,可以看到其内部的checkForComodification具有针对修改次数比对的逻辑:\npublic E next() { //检查是否存在并发修改 checkForComodification(); //...... //返回下一个元素 return (E) elementData[lastRet = i]; } final void checkForComodification() { //当前循环遍历次数和预期修改次数不一致时,就会抛出ConcurrentModificationException if (modCount != expectedModCount) throw new ConcurrentModificationException(); } 而fail-safe也就是安全失败的含义,它旨在即使面对意外情况也能恢复并继续运行,这使得它特别适用于不确定或者不稳定的环境:\nFail-safe systems take a different approach, aiming to recover and continue even in the face of unexpected conditions. This makes them particularly suited for uncertain or volatile environments.\n该思想常运用于并发容器,最经典的实现就是CopyOnWriteArrayList的实现,通过写时复制的思想保证在进行修改操作时复制出一份快照,基于这份快照完成添加或者删除操作后,将CopyOnWriteArrayList底层的数组引用指向这个新的数组空间,由此避免迭代时被并发修改所干扰所导致并发操作安全问题,当然这种做法也存缺点即进行遍历操作时无法获得实时结果:\n对应我们也给出CopyOnWriteArrayList实现fail-safe的核心代码,可以看到它的实现就是通过getArray获取数组引用然后通过Arrays.copyOf得到一个数组的快照,基于这个快照完成添加操作后,修改底层array变量指向的引用地址由此完成写时复制:\npublic 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; //array指向新的数组 setArray(newElements); return true; } finally { lock.unlock(); } } Set # Comparable 和 Comparator 的区别 # Comparable 接口和 Comparator 接口都是 Java 中用于排序的接口,它们在实现类对象之间比较大小、排序等方面发挥了重要作用:\nComparable 接口实际上是出自java.lang包 它有一个 compareTo(Object obj)方法用来排序 Comparator接口实际上是出自 java.util 包它有一个compare(Object obj1, Object obj2)方法用来排序 一般我们需要对一个集合使用自定义排序时,我们就要重写compareTo()方法或compare()方法,当我们需要对某一个集合实现两种排序方式,比如一个 song 对象中的歌名和歌手名分别采用一种排序方法的话,我们可以重写compareTo()方法和使用自制的Comparator方法或者以两个 Comparator 来实现歌名排序和歌星名排序,第二种代表我们只能使用两个参数版的 Collections.sort().\nComparator 定制排序 # ArrayList\u0026lt;Integer\u0026gt; arrayList = new ArrayList\u0026lt;Integer\u0026gt;(); arrayList.add(-1); arrayList.add(3); arrayList.add(3); arrayList.add(-5); arrayList.add(7); arrayList.add(4); arrayList.add(-9); arrayList.add(-7); System.out.println(\u0026#34;原始数组:\u0026#34;); System.out.println(arrayList); // void reverse(List list):反转 Collections.reverse(arrayList); System.out.println(\u0026#34;Collections.reverse(arrayList):\u0026#34;); System.out.println(arrayList); // void sort(List list),按自然排序的升序排序 Collections.sort(arrayList); System.out.println(\u0026#34;Collections.sort(arrayList):\u0026#34;); System.out.println(arrayList); // 定制排序的用法 Collections.sort(arrayList, new Comparator\u0026lt;Integer\u0026gt;() { @Override public int compare(Integer o1, Integer o2) { return o2.compareTo(o1); } }); System.out.println(\u0026#34;定制排序后:\u0026#34;); System.out.println(arrayList); Output:\n原始数组: [-1, 3, 3, -5, 7, 4, -9, -7] Collections.reverse(arrayList): [-7, -9, 4, 7, -5, 3, 3, -1] Collections.sort(arrayList): [-9, -7, -5, -1, 3, 3, 4, 7] 定制排序后: [7, 4, 3, 3, -1, -5, -7, -9] 重写 compareTo 方法实现按年龄来排序 # // person对象没有实现Comparable接口,所以必须实现,这样才不会出错,才可以使treemap中的数据按顺序排列 // 前面一个例子的String类已经默认实现了Comparable接口,详细可以查看String类的API文档,另外其他 // 像Integer类等都已经实现了Comparable接口,所以不需要另外实现了 public class Person implements Comparable\u0026lt;Person\u0026gt; { private String name; private int age; public Person(String name, int age) { super(); this.name = name; this.age = age; } public String getName() { return name; } public void setName(String name) { this.name = name; } public int getAge() { return age; } public void setAge(int age) { this.age = age; } /** * T重写compareTo方法实现按年龄来排序 */ @Override public int compareTo(Person o) { if (this.age \u0026gt; o.getAge()) { return 1; } if (this.age \u0026lt; o.getAge()) { return -1; } return 0; } } public static void main(String[] args) { TreeMap\u0026lt;Person, String\u0026gt; pdata = new TreeMap\u0026lt;Person, String\u0026gt;(); pdata.put(new Person(\u0026#34;张三\u0026#34;, 30), \u0026#34;zhangsan\u0026#34;); pdata.put(new Person(\u0026#34;李四\u0026#34;, 20), \u0026#34;lisi\u0026#34;); pdata.put(new Person(\u0026#34;王五\u0026#34;, 10), \u0026#34;wangwu\u0026#34;); pdata.put(new Person(\u0026#34;小红\u0026#34;, 5), \u0026#34;xiaohong\u0026#34;); // 得到key的值的同时得到key所对应的值 Set\u0026lt;Person\u0026gt; keys = pdata.keySet(); for (Person key : keys) { System.out.println(key.getAge() + \u0026#34;-\u0026#34; + key.getName()); } } Output:\n5-小红 10-王五 20-李四 30-张三 无序性和不可重复性的含义是什么 # 无序性不等于随机性 ,无序性是指存储的数据在底层数组中并非按照数组索引的顺序添加 ,而是根据数据的哈希值决定的。 不可重复性是指添加的元素按照 equals() 判断时 ,返回 false,需要同时重写 equals() 方法和 hashCode() 方法。 比较 HashSet、LinkedHashSet 和 TreeSet 三者的异同 # HashSet、LinkedHashSet 和 TreeSet 都是 Set 接口的实现类,都能保证元素唯一,并且都不是线程安全的。 HashSet、LinkedHashSet 和 TreeSet 的主要区别在于底层数据结构不同。HashSet 的底层数据结构是哈希表(基于 HashMap 实现)。LinkedHashSet 的底层数据结构是链表和哈希表,元素的插入和取出顺序满足 FIFO。TreeSet 底层数据结构是红黑树,元素是有序的,排序的方式有自然排序和定制排序。 底层数据结构不同又导致这三者的应用场景不同。HashSet 用于不需要保证元素插入和取出顺序的场景,LinkedHashSet 用于保证元素的插入和取出顺序满足 FIFO 的场景,TreeSet 用于支持对元素自定义排序规则的场景。 Queue # Queue 与 Deque 的区别 # Queue 是单端队列,只能从一端插入元素,另一端删除元素,实现上一般遵循 先进先出(FIFO) 规则。\nQueue 扩展了 Collection 的接口,根据 因为容量问题而导致操作失败后处理方式的不同 可以分为两类方法: 一种在操作失败后会抛出异常,另一种则会返回特殊值。\nQueue 接口 抛出异常 返回特殊值 插入队尾 add(E e) offer(E e) 删除队首 remove() poll() 查询队首元素 element() peek() Deque 是双端队列,在队列的两端均可以插入或删除元素。\nDeque 扩展了 Queue 的接口, 增加了在队首和队尾进行插入和删除的方法,同样根据失败后处理方式的不同分为两类:\nDeque 接口 抛出异常 返回特殊值 插入队首 addFirst(E e) offerFirst(E e) 插入队尾 addLast(E e) offerLast(E e) 删除队首 removeFirst() pollFirst() 删除队尾 removeLast() pollLast() 查询队首元素 getFirst() peekFirst() 查询队尾元素 getLast() peekLast() 事实上,Deque 还提供有 push() 和 pop() 等其他方法,可用于模拟栈。\nArrayDeque 与 LinkedList 的区别 # ArrayDeque 和 LinkedList 都实现了 Deque 接口,两者都具有队列的功能,但两者有什么区别呢?\nArrayDeque 是基于可变长的数组和双指针来实现,而 LinkedList 则通过链表来实现。\nArrayDeque 不支持存储 NULL 数据,但 LinkedList 支持。\nArrayDeque 是在 JDK1.6 才被引入的,而LinkedList 早在 JDK1.2 时就已经存在。\nArrayDeque 插入时可能存在扩容过程, 不过均摊后的插入操作依然为 O(1)。虽然 LinkedList 不需要扩容,但是每次插入数据时均需要申请新的堆空间,均摊性能相比更慢。\n从性能的角度上,选用 ArrayDeque 来实现队列要比 LinkedList 更好。此外,ArrayDeque 也可以用于实现栈。\n说一说 PriorityQueue # PriorityQueue 是在 JDK1.5 中被引入的, 其与 Queue 的区别在于元素出队顺序是与优先级相关的,即总是优先级最高的元素先出队。\n这里列举其相关的一些要点:\nPriorityQueue 利用了二叉堆的数据结构来实现的,底层使用可变长的数组来存储数据 PriorityQueue 通过堆元素的上浮和下沉,实现了在 O(logn) 的时间复杂度内插入元素和删除堆顶元素。 PriorityQueue 是非线程安全的,且不支持存储 NULL 和 non-comparable 的对象。 PriorityQueue 默认是小顶堆,但可以接收一个 Comparator 作为构造参数,从而来自定义元素优先级的先后。 PriorityQueue 在面试中可能更多的会出现在手撕算法的时候,典型例题包括堆排序、求第 K 大的数、带权图的遍历等,所以需要会熟练使用才行。\n什么是 BlockingQueue? # BlockingQueue (阻塞队列)是一个接口,继承自 Queue。BlockingQueue阻塞的原因是其支持当队列没有元素时一直阻塞,直到有元素;还支持如果队列已满,一直等到队列可以放入新元素时再放入。\npublic interface BlockingQueue\u0026lt;E\u0026gt; extends Queue\u0026lt;E\u0026gt; { // ... } BlockingQueue 常用于生产者-消费者模型中,生产者线程会向队列中添加数据,而消费者线程会从队列中取出数据进行处理。\nBlockingQueue 的实现类有哪些? # Java 中常用的阻塞队列实现类有以下几种:\nArrayBlockingQueue:使用数组实现的有界阻塞队列。在创建时需要指定容量大小,并支持公平和非公平两种方式的锁访问机制。 LinkedBlockingQueue:使用单向链表实现的可选有界阻塞队列。在创建时可以指定容量大小,如果不指定则默认为Integer.MAX_VALUE。和ArrayBlockingQueue不同的是, 它仅支持非公平的锁访问机制。 PriorityBlockingQueue:支持优先级排序的无界阻塞队列。元素必须实现Comparable接口或者在构造函数中传入Comparator对象,并且不能插入 null 元素。 SynchronousQueue:同步队列,是一种不存储元素的阻塞队列。每个插入操作都必须等待对应的删除操作,反之删除操作也必须等待插入操作。因此,SynchronousQueue通常用于线程之间的直接传递数据。 DelayQueue:延迟队列,其中的元素只有到了其指定的延迟时间,才能够从队列中出队。 …… 日常开发中,这些队列使用的其实都不多,了解即可。\nArrayBlockingQueue 和 LinkedBlockingQueue 有什么区别? # ArrayBlockingQueue 和 LinkedBlockingQueue 是 Java 并发包中常用的两种阻塞队列实现,它们都是线程安全的。不过,不过它们之间也存在下面这些区别:\n底层实现:ArrayBlockingQueue 基于数组实现,而 LinkedBlockingQueue 基于链表实现。 是否有界:ArrayBlockingQueue 是有界队列,必须在创建时指定容量大小。LinkedBlockingQueue 创建时可以不指定容量大小,默认是Integer.MAX_VALUE,也就是无界的。但也可以指定队列大小,从而成为有界的。 锁是否分离: ArrayBlockingQueue中的锁是没有分离的,即生产和消费用的是同一个锁;LinkedBlockingQueue中的锁是分离的,即生产用的是putLock,消费是takeLock,这样可以防止生产者和消费者线程之间的锁争夺。 内存占用:ArrayBlockingQueue 需要提前分配数组内存,而 LinkedBlockingQueue 则是动态分配链表节点内存。这意味着,ArrayBlockingQueue 在创建时就会占用一定的内存空间,且往往申请的内存比实际所用的内存更大,而LinkedBlockingQueue 则是根据元素的增加而逐渐占用内存空间。 "},{"id":520,"href":"/zh/docs/technology/Interview/java/collection/java-collection-questions-02/","title":"Java集合常见面试题总结(下)","section":"Collection","content":" Map(重要) # HashMap 和 Hashtable 的区别 # 线程是否安全: HashMap 是非线程安全的,Hashtable 是线程安全的,因为 Hashtable 内部的方法基本都经过synchronized 修饰。(如果你要保证线程安全的话就使用 ConcurrentHashMap 吧!); 效率: 因为线程安全的问题,HashMap 要比 Hashtable 效率高一点。另外,Hashtable 基本被淘汰,不要在代码中使用它; 对 Null key 和 Null value 的支持: HashMap 可以存储 null 的 key 和 value,但 null 作为键只能有一个,null 作为值可以有多个;Hashtable 不允许有 null 键和 null 值,否则会抛出 NullPointerException。 初始容量大小和每次扩充容量大小的不同: ① 创建时如果不指定容量初始值,Hashtable 默认的初始大小为 11,之后每次扩充,容量变为原来的 2n+1。HashMap 默认的初始化大小为 16。之后每次扩充,容量变为原来的 2 倍。② 创建时如果给定了容量初始值,那么 Hashtable 会直接使用你给定的大小,而 HashMap 会将其扩充为 2 的幂次方大小(HashMap 中的tableSizeFor()方法保证,下面给出了源代码)。也就是说 HashMap 总是使用 2 的幂作为哈希表的大小,后面会介绍到为什么是 2 的幂次方。 底层数据结构: JDK1.8 以后的 HashMap 在解决哈希冲突时有了较大的变化,当链表长度大于阈值(默认为 8)时,将链表转化为红黑树(将链表转换成红黑树前会判断,如果当前数组的长度小于 64,那么会选择先进行数组扩容,而不是转换为红黑树),以减少搜索时间(后文中我会结合源码对这一过程进行分析)。Hashtable 没有这样的机制。 哈希函数的实现:HashMap 对哈希值进行了高位和低位的混合扰动处理以减少冲突,而 Hashtable 直接使用键的 hashCode() 值。 HashMap 中带有初始容量的构造函数:\npublic HashMap(int initialCapacity, float loadFactor) { if (initialCapacity \u0026lt; 0) throw new IllegalArgumentException(\u0026#34;Illegal initial capacity: \u0026#34; + initialCapacity); if (initialCapacity \u0026gt; MAXIMUM_CAPACITY) initialCapacity = MAXIMUM_CAPACITY; if (loadFactor \u0026lt;= 0 || Float.isNaN(loadFactor)) throw new IllegalArgumentException(\u0026#34;Illegal load factor: \u0026#34; + loadFactor); this.loadFactor = loadFactor; this.threshold = tableSizeFor(initialCapacity); } public HashMap(int initialCapacity) { this(initialCapacity, DEFAULT_LOAD_FACTOR); } 下面这个方法保证了 HashMap 总是使用 2 的幂作为哈希表的大小。\n/** * Returns a power of two size for the given target capacity. */ static final int tableSizeFor(int cap) { int n = cap - 1; n |= n \u0026gt;\u0026gt;\u0026gt; 1; n |= n \u0026gt;\u0026gt;\u0026gt; 2; n |= n \u0026gt;\u0026gt;\u0026gt; 4; n |= n \u0026gt;\u0026gt;\u0026gt; 8; n |= n \u0026gt;\u0026gt;\u0026gt; 16; return (n \u0026lt; 0) ? 1 : (n \u0026gt;= MAXIMUM_CAPACITY) ? MAXIMUM_CAPACITY : n + 1; } HashMap 和 HashSet 区别 # 如果你看过 HashSet 源码的话就应该知道:HashSet 底层就是基于 HashMap 实现的。(HashSet 的源码非常非常少,因为除了 clone()、writeObject()、readObject()是 HashSet 自己不得不实现之外,其他方法都是直接调用 HashMap 中的方法。\nHashMap HashSet 实现了 Map 接口 实现 Set 接口 存储键值对 仅存储对象 调用 put()向 map 中添加元素 调用 add()方法向 Set 中添加元素 HashMap 使用键(Key)计算 hashcode HashSet 使用成员对象来计算 hashcode 值,对于两个对象来说 hashcode 可能相同,所以equals()方法用来判断对象的相等性 HashMap 和 TreeMap 区别 # TreeMap 和HashMap 都继承自AbstractMap ,但是需要注意的是TreeMap它还实现了NavigableMap接口和SortedMap 接口。\n实现 NavigableMap 接口让 TreeMap 有了对集合内元素的搜索的能力。\nNavigableMap 接口提供了丰富的方法来探索和操作键值对:\n定向搜索: ceilingEntry(), floorEntry(), higherEntry()和 lowerEntry() 等方法可以用于定位大于等于、小于等于、严格大于、严格小于给定键的最接近的键值对。 子集操作: subMap(), headMap()和 tailMap() 方法可以高效地创建原集合的子集视图,而无需复制整个集合。 逆序视图:descendingMap() 方法返回一个逆序的 NavigableMap 视图,使得可以反向迭代整个 TreeMap。 边界操作: firstEntry(), lastEntry(), pollFirstEntry()和 pollLastEntry() 等方法可以方便地访问和移除元素。 这些方法都是基于红黑树数据结构的属性实现的,红黑树保持平衡状态,从而保证了搜索操作的时间复杂度为 O(log n),这让 TreeMap 成为了处理有序集合搜索问题的强大工具。\n实现SortedMap接口让 TreeMap 有了对集合中的元素根据键排序的能力。默认是按 key 的升序排序,不过我们也可以指定排序的比较器。示例代码如下:\n/** * @author shuang.kou * @createTime 2020年06月15日 17:02:00 */ public class Person { private Integer age; public Person(Integer age) { this.age = age; } public Integer getAge() { return age; } public static void main(String[] args) { TreeMap\u0026lt;Person, String\u0026gt; treeMap = new TreeMap\u0026lt;\u0026gt;(new Comparator\u0026lt;Person\u0026gt;() { @Override public int compare(Person person1, Person person2) { int num = person1.getAge() - person2.getAge(); return Integer.compare(num, 0); } }); treeMap.put(new Person(3), \u0026#34;person1\u0026#34;); treeMap.put(new Person(18), \u0026#34;person2\u0026#34;); treeMap.put(new Person(35), \u0026#34;person3\u0026#34;); treeMap.put(new Person(16), \u0026#34;person4\u0026#34;); treeMap.entrySet().stream().forEach(personStringEntry -\u0026gt; { System.out.println(personStringEntry.getValue()); }); } } 输出:\nperson1 person4 person2 person3 可以看出,TreeMap 中的元素已经是按照 Person 的 age 字段的升序来排列了。\n上面,我们是通过传入匿名内部类的方式实现的,你可以将代码替换成 Lambda 表达式实现的方式:\nTreeMap\u0026lt;Person, String\u0026gt; treeMap = new TreeMap\u0026lt;\u0026gt;((person1, person2) -\u0026gt; { int num = person1.getAge() - person2.getAge(); return Integer.compare(num, 0); }); 综上,相比于HashMap来说, TreeMap 主要多了对集合中的元素根据键排序的能力以及对集合内元素的搜索的能力。\nHashSet 如何检查重复? # 以下内容摘自我的 Java 启蒙书《Head first java》第二版:\n当你把对象加入HashSet时,HashSet 会先计算对象的hashcode值来判断对象加入的位置,同时也会与其他加入的对象的 hashcode 值作比较,如果没有相符的 hashcode,HashSet 会假设对象没有重复出现。但是如果发现有相同 hashcode 值的对象,这时会调用equals()方法来检查 hashcode 相等的对象是否真的相同。如果两者相同,HashSet 就不会让加入操作成功。\n在 JDK1.8 中,HashSet的add()方法只是简单的调用了HashMap的put()方法,并且判断了一下返回值以确保是否有重复元素。直接看一下HashSet中的源码:\n// Returns: true if this set did not already contain the specified element // 返回值:当 set 中没有包含 add 的元素时返回真 public boolean add(E e) { return map.put(e, PRESENT)==null; } 而在HashMap的putVal()方法中也能看到如下说明:\n// Returns : previous value, or null if none // 返回值:如果插入位置没有元素返回null,否则返回上一个元素 final V putVal(int hash, K key, V value, boolean onlyIfAbsent, boolean evict) { ... } 也就是说,在 JDK1.8 中,实际上无论HashSet中是否已经存在了某元素,HashSet都会直接插入,只是会在add()方法的返回值处告诉我们插入前是否存在相同元素。\nHashMap 的底层实现 # JDK1.8 之前 # JDK1.8 之前 HashMap 底层是 数组和链表 结合在一起使用也就是 链表散列。HashMap 通过 key 的 hashcode 经过扰动函数处理过后得到 hash 值,然后通过 (n - 1) \u0026amp; hash 判断当前元素存放的位置(这里的 n 指的是数组的长度),如果当前位置存在元素的话,就判断该元素与要存入的元素的 hash 值以及 key 是否相同,如果相同的话,直接覆盖,不相同就通过拉链法解决冲突。\nHashMap 中的扰动函数(hash 方法)是用来优化哈希值的分布。通过对原始的 hashCode() 进行额外处理,扰动函数可以减小由于糟糕的 hashCode() 实现导致的碰撞,从而提高数据的分布均匀性。\nJDK 1.8 HashMap 的 hash 方法源码:\nJDK 1.8 的 hash 方法 相比于 JDK 1.7 hash 方法更加简化,但是原理不变。\nstatic final int hash(Object key) { int h; // key.hashCode():返回散列值也就是hashcode // ^:按位异或 // \u0026gt;\u0026gt;\u0026gt;:无符号右移,忽略符号位,空位都以0补齐 return (key == null) ? 0 : (h = key.hashCode()) ^ (h \u0026gt;\u0026gt;\u0026gt; 16); } 对比一下 JDK1.7 的 HashMap 的 hash 方法源码.\nstatic int hash(int h) { // This function ensures that hashCodes that differ only by // constant multiples at each bit position have a bounded // number of collisions (approximately 8 at default load factor). h ^= (h \u0026gt;\u0026gt;\u0026gt; 20) ^ (h \u0026gt;\u0026gt;\u0026gt; 12); return h ^ (h \u0026gt;\u0026gt;\u0026gt; 7) ^ (h \u0026gt;\u0026gt;\u0026gt; 4); } 相比于 JDK1.8 的 hash 方法 ,JDK 1.7 的 hash 方法的性能会稍差一点点,因为毕竟扰动了 4 次。\n所谓 “拉链法” 就是:将链表和数组相结合。也就是说创建一个链表数组,数组中每一格就是一个链表。若遇到哈希冲突,则将冲突的值加到链表中即可。\nJDK1.8 之后 # 相比于之前的版本, JDK1.8 之后在解决哈希冲突时有了较大的变化,当链表长度大于阈值(默认为 8)(将链表转换成红黑树前会判断,如果当前数组的长度小于 64,那么会选择先进行数组扩容,而不是转换为红黑树)时,将链表转化为红黑树。\n这样做的目的是减少搜索时间:链表的查询效率为 O(n)(n 是链表的长度),红黑树是一种自平衡二叉搜索树,其查询效率为 O(log n)。当链表较短时,O(n) 和 O(log n) 的性能差异不明显。但当链表变长时,查询性能会显著下降。\n为什么优先扩容而非直接转为红黑树?\n数组扩容能减少哈希冲突的发生概率(即将元素重新分散到新的、更大的数组中),这在多数情况下比直接转换为红黑树更高效。\n红黑树需要保持自平衡,维护成本较高。并且,过早引入红黑树反而会增加复杂度。\n为什么选择阈值 8 和 64?\n泊松分布表明,链表长度达到 8 的概率极低(小于千万分之一)。在绝大多数情况下,链表长度都不会超过 8。阈值设置为 8,可以保证性能和空间效率的平衡。 数组长度阈值 64 同样是经过实践验证的经验值。在小数组中扩容成本低,优先扩容可以避免过早引入红黑树。数组大小达到 64 时,冲突概率较高,此时红黑树的性能优势开始显现。 TreeMap、TreeSet 以及 JDK1.8 之后的 HashMap 底层都用到了红黑树。红黑树就是为了解决二叉查找树的缺陷,因为二叉查找树在某些情况下会退化成一个线性结构。\n我们来结合源码分析一下 HashMap 链表到红黑树的转换。\n1、 putVal 方法中执行链表转红黑树的判断逻辑。\n链表的长度大于 8 的时候,就执行 treeifyBin (转换红黑树)的逻辑。\n// 遍历链表 for (int binCount = 0; ; ++binCount) { // 遍历到链表最后一个节点 if ((e = p.next) == null) { p.next = newNode(hash, key, value, null); // 如果链表元素个数大于TREEIFY_THRESHOLD(8) if (binCount \u0026gt;= TREEIFY_THRESHOLD - 1) // -1 for 1st // 红黑树转换(并不会直接转换成红黑树) treeifyBin(tab, hash); break; } if (e.hash == hash \u0026amp;\u0026amp; ((k = e.key) == key || (key != null \u0026amp;\u0026amp; key.equals(k)))) break; p = e; } 2、treeifyBin 方法中判断是否真的转换为红黑树。\nfinal void treeifyBin(Node\u0026lt;K,V\u0026gt;[] tab, int hash) { int n, index; Node\u0026lt;K,V\u0026gt; e; // 判断当前数组的长度是否小于 64 if (tab == null || (n = tab.length) \u0026lt; MIN_TREEIFY_CAPACITY) // 如果当前数组的长度小于 64,那么会选择先进行数组扩容 resize(); else if ((e = tab[index = (n - 1) \u0026amp; hash]) != null) { // 否则才将列表转换为红黑树 TreeNode\u0026lt;K,V\u0026gt; hd = null, tl = null; do { TreeNode\u0026lt;K,V\u0026gt; p = replacementTreeNode(e, null); if (tl == null) hd = p; else { p.prev = tl; tl.next = p; } tl = p; } while ((e = e.next) != null); if ((tab[index] = hd) != null) hd.treeify(tab); } } 将链表转换成红黑树前会判断,如果当前数组的长度小于 64,那么会选择先进行数组扩容,而不是转换为红黑树。\nHashMap 的长度为什么是 2 的幂次方 # 为了让 HashMap 存取高效并减少碰撞,我们需要确保数据尽量均匀分布。哈希值在 Java 中通常使用 int 表示,其范围是 -2147483648 ~ 2147483647前后加起来大概 40 亿的映射空间,只要哈希函数映射得比较均匀松散,一般应用是很难出现碰撞的。但是,问题是一个 40 亿长度的数组,内存是放不下的。所以,这个散列值是不能直接拿来用的。用之前还要先做对数组的长度取模运算,得到的余数才能用来要存放的位置也就是对应的数组下标。\n这个算法应该如何设计呢?\n我们首先可能会想到采用 % 取余的操作来实现。但是,重点来了:“取余(%)操作中如果除数是 2 的幂次则等价于与其除数减一的与(\u0026amp;)操作(也就是说 hash%length==hash\u0026amp;(length-1) 的前提是 length 是 2 的 n 次方)。” 并且,采用二进制位操作 \u0026amp; 相对于 % 能够提高运算效率。\n除了上面所说的位运算比取余效率高之外,我觉得更重要的一个原因是:长度是 2 的幂次方,可以让 HashMap 在扩容的时候更均匀。例如:\nlength = 8 时,length - 1 = 7 的二进制位0111 length = 16 时,length - 1 = 15 的二进制位1111 这时候原本存在 HashMap 中的元素计算新的数组位置时 hash\u0026amp;(length-1),取决 hash 的第四个二进制位(从右数),会出现两种情况:\n第四个二进制位为 0,数组位置不变,也就是说当前元素在新数组和旧数组的位置相同。 第四个二进制位为 1,数组位置在新数组扩容之后的那一部分。 这里列举一个例子:\n假设有一个元素的哈希值为 10101100 旧数组元素位置计算: hash = 10101100 length - 1 = 00000111 \u0026amp; ----------------- index = 00000100 (4) 新数组元素位置计算: hash = 10101100 length - 1 = 00001111 \u0026amp; ----------------- index = 00001100 (12) 看第四位(从右数): 1.高位为 0:位置不变。 2.高位为 1:移动到新位置(原索引位置+原容量)。 ⚠️注意:这里列举的场景看的是第四个二进制位,更准确点来说看的是高位(从右数),例如 length = 32 时,length - 1 = 31,二进制为 11111,这里看的就是第五个二进制位。\n也就是说扩容之后,在旧数组元素 hash 值比较均匀(至于 hash 值均不均匀,取决于前面讲的对象的 hashcode() 方法和扰动函数)的情况下,新数组元素也会被分配的比较均匀,最好的情况是会有一半在新数组的前半部分,一半在新数组后半部分。\n这样也使得扩容机制变得简单和高效,扩容后只需检查哈希值高位的变化来决定元素的新位置,要么位置不变(高位为 0),要么就是移动到新位置(高位为 1,原索引位置+原容量)。\n最后,简单总结一下 HashMap 的长度是 2 的幂次方的原因:\n位运算效率更高:位运算(\u0026amp;)比取余运算(%)更高效。当长度为 2 的幂次方时,hash % length 等价于 hash \u0026amp; (length - 1)。 可以更好地保证哈希值的均匀分布:扩容之后,在旧数组元素 hash 值比较均匀的情况下,新数组元素也会被分配的比较均匀,最好的情况是会有一半在新数组的前半部分,一半在新数组后半部分。 扩容机制变得简单和高效:扩容后只需检查哈希值高位的变化来决定元素的新位置,要么位置不变(高位为 0),要么就是移动到新位置(高位为 1,原索引位置+原容量)。 HashMap 多线程操作导致死循环问题 # JDK1.7 及之前版本的 HashMap 在多线程环境下扩容操作可能存在死循环问题,这是由于当一个桶位中有多个元素需要进行扩容时,多个线程同时对链表进行操作,头插法可能会导致链表中的节点指向错误的位置,从而形成一个环形链表,进而使得查询元素的操作陷入死循环无法结束。\n为了解决这个问题,JDK1.8 版本的 HashMap 采用了尾插法而不是头插法来避免链表倒置,使得插入的节点永远都是放在链表的末尾,避免了链表中的环形结构。但是还是不建议在多线程下使用 HashMap,因为多线程下使用 HashMap 还是会存在数据覆盖的问题。并发环境下,推荐使用 ConcurrentHashMap 。\n一般面试中这样介绍就差不多,不需要记各种细节,个人觉得也没必要记。如果想要详细了解 HashMap 扩容导致死循环问题,可以看看耗子叔的这篇文章: Java HashMap 的死循环。\nHashMap 为什么线程不安全? # JDK1.7 及之前版本,在多线程环境下,HashMap 扩容时会造成死循环和数据丢失的问题。\n数据丢失这个在 JDK1.7 和 JDK 1.8 中都存在,这里以 JDK 1.8 为例进行介绍。\nJDK 1.8 后,在 HashMap 中,多个键值对可能会被分配到同一个桶(bucket),并以链表或红黑树的形式存储。多个线程对 HashMap 的 put 操作会导致线程不安全,具体来说会有数据覆盖的风险。\n举个例子:\n两个线程 1,2 同时进行 put 操作,并且发生了哈希冲突(hash 函数计算出的插入下标是相同的)。 不同的线程可能在不同的时间片获得 CPU 执行的机会,当前线程 1 执行完哈希冲突判断后,由于时间片耗尽挂起。线程 2 先完成了插入操作。 随后,线程 1 获得时间片,由于之前已经进行过 hash 碰撞的判断,所有此时会直接进行插入,这就导致线程 2 插入的数据被线程 1 覆盖了。 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) { // ... // 判断是否出现 hash 碰撞 // (n - 1) \u0026amp; hash 确定元素存放在哪个桶中,桶为空,新生成结点放入桶中(此时,这个结点是放在数组中) if ((p = tab[i = (n - 1) \u0026amp; hash]) == null) tab[i] = newNode(hash, key, value, null); // 桶中已经存在元素(处理hash冲突) else { // ... } 还有一种情况是这两个线程同时 put 操作导致 size 的值不正确,进而导致数据覆盖的问题:\n线程 1 执行 if(++size \u0026gt; threshold) 判断时,假设获得 size 的值为 10,由于时间片耗尽挂起。 线程 2 也执行 if(++size \u0026gt; threshold) 判断,获得 size 的值也为 10,并将元素插入到该桶位中,并将 size 的值更新为 11。 随后,线程 1 获得时间片,它也将元素放入桶位中,并将 size 的值更新为 11。 线程 1、2 都执行了一次 put 操作,但是 size 的值只增加了 1,也就导致实际上只有一个元素被添加到了 HashMap 中。 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) { // ... // 实际大小大于阈值则扩容 if (++size \u0026gt; threshold) resize(); // 插入后回调 afterNodeInsertion(evict); return null; } HashMap 常见的遍历方式? # HashMap 的 7 种遍历方式与性能分析!\n🐛 修正(参见: issue#1411):\n这篇文章对于 parallelStream 遍历方式的性能分析有误,先说结论:存在阻塞时 parallelStream 性能最高, 非阻塞时 parallelStream 性能最低 。\n当遍历不存在阻塞时, parallelStream 的性能是最低的:\nBenchmark Mode Cnt Score Error Units Test.entrySet avgt 5 288.651 ± 10.536 ns/op Test.keySet avgt 5 584.594 ± 21.431 ns/op Test.lambda avgt 5 221.791 ± 10.198 ns/op Test.parallelStream avgt 5 6919.163 ± 1116.139 ns/op 加入阻塞代码Thread.sleep(10)后, parallelStream 的性能才是最高的:\nBenchmark Mode Cnt Score Error Units Test.entrySet avgt 5 1554828440.000 ± 23657748.653 ns/op Test.keySet avgt 5 1550612500.000 ± 6474562.858 ns/op Test.lambda avgt 5 1551065180.000 ± 19164407.426 ns/op Test.parallelStream avgt 5 186345456.667 ± 3210435.590 ns/op ConcurrentHashMap 和 Hashtable 的区别 # ConcurrentHashMap 和 Hashtable 的区别主要体现在实现线程安全的方式上不同。\n底层数据结构: JDK1.7 的 ConcurrentHashMap 底层采用 分段的数组+链表 实现,JDK1.8 采用的数据结构跟 HashMap1.8 的结构一样,数组+链表/红黑二叉树。Hashtable 和 JDK1.8 之前的 HashMap 的底层数据结构类似都是采用 数组+链表 的形式,数组是 HashMap 的主体,链表则是主要为了解决哈希冲突而存在的; 实现线程安全的方式(重要): 在 JDK1.7 的时候,ConcurrentHashMap 对整个桶数组进行了分割分段(Segment,分段锁),每一把锁只锁容器其中一部分数据(下面有示意图),多线程访问容器里不同数据段的数据,就不会存在锁竞争,提高并发访问率。 到了 JDK1.8 的时候,ConcurrentHashMap 已经摒弃了 Segment 的概念,而是直接用 Node 数组+链表+红黑树的数据结构来实现,并发控制使用 synchronized 和 CAS 来操作。(JDK1.6 以后 synchronized 锁做了很多优化) 整个看起来就像是优化过且线程安全的 HashMap,虽然在 JDK1.8 中还能看到 Segment 的数据结构,但是已经简化了属性,只是为了兼容旧版本; Hashtable(同一把锁) :使用 synchronized 来保证线程安全,效率非常低下。当一个线程访问同步方法时,其他线程也访问同步方法,可能会进入阻塞或轮询状态,如使用 put 添加元素,另一个线程不能使用 put 添加元素,也不能使用 get,竞争会越来越激烈效率越低。 下面,我们再来看看两者底层数据结构的对比图。\nHashtable :\nhttps://www.cnblogs.com/chengxiao/p/6842045.html\u003e\nJDK1.7 的 ConcurrentHashMap:\nConcurrentHashMap 是由 Segment 数组结构和 HashEntry 数组结构组成。\nSegment 数组中的每个元素包含一个 HashEntry 数组,每个 HashEntry 数组属于链表结构。\nJDK1.8 的 ConcurrentHashMap:\nJDK1.8 的 ConcurrentHashMap 不再是 Segment 数组 + HashEntry 数组 + 链表,而是 Node 数组 + 链表 / 红黑树。不过,Node 只能用于链表的情况,红黑树的情况需要使用 TreeNode。当冲突链表达到一定长度时,链表会转换成红黑树。\nTreeNode是存储红黑树节点,被TreeBin包装。TreeBin通过root属性维护红黑树的根结点,因为红黑树在旋转的时候,根结点可能会被它原来的子节点替换掉,在这个时间点,如果有其他线程要写这棵红黑树就会发生线程不安全问题,所以在 ConcurrentHashMap 中TreeBin通过waiter属性维护当前使用这棵红黑树的线程,来防止其他线程的进入。\nstatic final class TreeBin\u0026lt;K,V\u0026gt; extends Node\u0026lt;K,V\u0026gt; { TreeNode\u0026lt;K,V\u0026gt; root; volatile TreeNode\u0026lt;K,V\u0026gt; first; volatile Thread waiter; volatile int lockState; // values for lockState static final int WRITER = 1; // set while holding write lock static final int WAITER = 2; // set when waiting for write lock static final int READER = 4; // increment value for setting read lock ... } ConcurrentHashMap 线程安全的具体实现方式/底层具体实现 # JDK1.8 之前 # 首先将数据分为一段一段(这个“段”就是 Segment)的存储,然后给每一段数据配一把锁,当一个线程占用锁访问其中一个段数据时,其他段的数据也能被其他线程访问。\nConcurrentHashMap 是由 Segment 数组结构和 HashEntry 数组结构组成。\nSegment 继承了 ReentrantLock,所以 Segment 是一种可重入锁,扮演锁的角色。HashEntry 用于存储键值对数据。\nstatic class Segment\u0026lt;K,V\u0026gt; extends ReentrantLock implements Serializable { } 一个 ConcurrentHashMap 里包含一个 Segment 数组,Segment 的个数一旦初始化就不能改变。 Segment 数组的大小默认是 16,也就是说默认可以同时支持 16 个线程并发写。\nSegment 的结构和 HashMap 类似,是一种数组和链表结构,一个 Segment 包含一个 HashEntry 数组,每个 HashEntry 是一个链表结构的元素,每个 Segment 守护着一个 HashEntry 数组里的元素,当对 HashEntry 数组的数据进行修改时,必须首先获得对应的 Segment 的锁。也就是说,对同一 Segment 的并发写入会被阻塞,不同 Segment 的写入是可以并发执行的。\nJDK1.8 之后 # Java 8 几乎完全重写了 ConcurrentHashMap,代码量从原来 Java 7 中的 1000 多行,变成了现在的 6000 多行。\nConcurrentHashMap 取消了 Segment 分段锁,采用 Node + CAS + synchronized 来保证并发安全。数据结构跟 HashMap 1.8 的结构类似,数组+链表/红黑二叉树。Java 8 在链表长度超过一定阈值(8)时将链表(寻址时间复杂度为 O(N))转换为红黑树(寻址时间复杂度为 O(log(N)))。\nJava 8 中,锁粒度更细,synchronized 只锁定当前链表或红黑二叉树的首节点,这样只要 hash 不冲突,就不会产生并发,就不会影响其他 Node 的读写,效率大幅提升。\nJDK 1.7 和 JDK 1.8 的 ConcurrentHashMap 实现有什么不同? # 线程安全实现方式:JDK 1.7 采用 Segment 分段锁来保证安全, Segment 是继承自 ReentrantLock。JDK1.8 放弃了 Segment 分段锁的设计,采用 Node + CAS + synchronized 保证线程安全,锁粒度更细,synchronized 只锁定当前链表或红黑二叉树的首节点。 Hash 碰撞解决方法 : JDK 1.7 采用拉链法,JDK1.8 采用拉链法结合红黑树(链表长度超过一定阈值时,将链表转换为红黑树)。 并发度:JDK 1.7 最大并发度是 Segment 的个数,默认是 16。JDK 1.8 最大并发度是 Node 数组的大小,并发度更大。 ConcurrentHashMap 为什么 key 和 value 不能为 null? # ConcurrentHashMap 的 key 和 value 不能为 null 主要是为了避免二义性。null 是一个特殊的值,表示没有对象或没有引用。如果你用 null 作为键,那么你就无法区分这个键是否存在于 ConcurrentHashMap 中,还是根本没有这个键。同样,如果你用 null 作为值,那么你就无法区分这个值是否是真正存储在 ConcurrentHashMap 中的,还是因为找不到对应的键而返回的。\n拿 get 方法取值来说,返回的结果为 null 存在两种情况:\n值没有在集合中 ; 值本身就是 null。 这也就是二义性的由来。\n具体可以参考 ConcurrentHashMap 源码分析 。\n多线程环境下,存在一个线程操作该 ConcurrentHashMap 时,其他的线程将该 ConcurrentHashMap 修改的情况,所以无法通过 containsKey(key) 来判断否存在这个键值对,也就没办法解决二义性问题了。\n与此形成对比的是,HashMap 可以存储 null 的 key 和 value,但 null 作为键只能有一个,null 作为值可以有多个。如果传入 null 作为参数,就会返回 hash 值为 0 的位置的值。单线程环境下,不存在一个线程操作该 HashMap 时,其他的线程将该 HashMap 修改的情况,所以可以通过 contains(key)来做判断是否存在这个键值对,从而做相应的处理,也就不存在二义性问题。\n也就是说,多线程下无法正确判定键值对是否存在(存在其他线程修改的情况),单线程是可以的(不存在其他线程修改的情况)。\n如果你确实需要在 ConcurrentHashMap 中使用 null 的话,可以使用一个特殊的静态空对象来代替 null。\npublic static final Object NULL = new Object(); 最后,再分享一下 ConcurrentHashMap 作者本人 (Doug Lea)对于这个问题的回答:\nThe main reason that nulls aren\u0026rsquo;t allowed in ConcurrentMaps (ConcurrentHashMaps, ConcurrentSkipListMaps) is that ambiguities that may be just barely tolerable in non-concurrent maps can\u0026rsquo;t be accommodated. The main one is that if map.get(key) returns null, you can\u0026rsquo;t detect whether the key explicitly maps to null vs the key isn\u0026rsquo;t mapped. In a non-concurrent map, you can check this via map.contains(key), but in a concurrent one, the map might have changed between calls.\n翻译过来之后的,大致意思还是单线程下可以容忍歧义,而多线程下无法容忍。\nConcurrentHashMap 能保证复合操作的原子性吗? # ConcurrentHashMap 是线程安全的,意味着它可以保证多个线程同时对它进行读写操作时,不会出现数据不一致的情况,也不会导致 JDK1.7 及之前版本的 HashMap 多线程操作导致死循环问题。但是,这并不意味着它可以保证所有的复合操作都是原子性的,一定不要搞混了!\n复合操作是指由多个基本操作(如put、get、remove、containsKey等)组成的操作,例如先判断某个键是否存在containsKey(key),然后根据结果进行插入或更新put(key, value)。这种操作在执行过程中可能会被其他线程打断,导致结果不符合预期。\n例如,有两个线程 A 和 B 同时对 ConcurrentHashMap 进行复合操作,如下:\n// 线程 A if (!map.containsKey(key)) { map.put(key, value); } // 线程 B if (!map.containsKey(key)) { map.put(key, anotherValue); } 如果线程 A 和 B 的执行顺序是这样:\n线程 A 判断 map 中不存在 key 线程 B 判断 map 中不存在 key 线程 B 将 (key, anotherValue) 插入 map 线程 A 将 (key, value) 插入 map 那么最终的结果是 (key, value),而不是预期的 (key, anotherValue)。这就是复合操作的非原子性导致的问题。\n那如何保证 ConcurrentHashMap 复合操作的原子性呢?\nConcurrentHashMap 提供了一些原子性的复合操作,如 putIfAbsent、compute、computeIfAbsent 、computeIfPresent、merge等。这些方法都可以接受一个函数作为参数,根据给定的 key 和 value 来计算一个新的 value,并且将其更新到 map 中。\n上面的代码可以改写为:\n// 线程 A map.putIfAbsent(key, value); // 线程 B map.putIfAbsent(key, anotherValue); 或者:\n// 线程 A map.computeIfAbsent(key, k -\u0026gt; value); // 线程 B map.computeIfAbsent(key, k -\u0026gt; anotherValue); 很多同学可能会说了,这种情况也能加锁同步呀!确实可以,但不建议使用加锁的同步机制,违背了使用 ConcurrentHashMap 的初衷。在使用 ConcurrentHashMap 的时候,尽量使用这些原子性的复合操作方法来保证原子性。\nCollections 工具类(不重要) # Collections 工具类常用方法:\n排序 查找,替换操作 同步控制(不推荐,需要线程安全的集合类型时请考虑使用 JUC 包下的并发集合) 排序操作 # void reverse(List list)//反转 void shuffle(List list)//随机排序 void sort(List list)//按自然排序的升序排序 void sort(List list, Comparator c)//定制排序,由Comparator控制排序逻辑 void swap(List list, int i , int j)//交换两个索引位置的元素 void rotate(List list, int distance)//旋转。当distance为正数时,将list后distance个元素整体移到前面。当distance为负数时,将 list的前distance个元素整体移到后面 查找,替换操作 # int binarySearch(List list, Object key)//对List进行二分查找,返回索引,注意List必须是有序的 int max(Collection coll)//根据元素的自然顺序,返回最大的元素。 类比int min(Collection coll) int max(Collection coll, Comparator c)//根据定制排序,返回最大元素,排序规则由Comparatator类控制。类比int min(Collection coll, Comparator c) void fill(List list, Object obj)//用指定的元素代替指定list中的所有元素 int frequency(Collection c, Object o)//统计元素出现次数 int indexOfSubList(List list, List target)//统计target在list中第一次出现的索引,找不到则返回-1,类比int lastIndexOfSubList(List source, list target) boolean replaceAll(List list, Object oldVal, Object newVal)//用新元素替换旧元素 同步控制 # Collections 提供了多个synchronizedXxx()方法·,该方法可以将指定集合包装成线程同步的集合,从而解决多线程并发访问集合时的线程安全问题。\n我们知道 HashSet,TreeSet,ArrayList,LinkedList,HashMap,TreeMap 都是线程不安全的。Collections 提供了多个静态方法可以把他们包装成线程同步的集合。\n最好不要用下面这些方法,效率非常低,需要线程安全的集合类型时请考虑使用 JUC 包下的并发集合。\n方法如下:\nsynchronizedCollection(Collection\u0026lt;T\u0026gt; c) //返回指定 collection 支持的同步(线程安全的)collection。 synchronizedList(List\u0026lt;T\u0026gt; list)//返回指定列表支持的同步(线程安全的)List。 synchronizedMap(Map\u0026lt;K,V\u0026gt; m) //返回由指定映射支持的同步(线程安全的)Map。 synchronizedSet(Set\u0026lt;T\u0026gt; s) //返回指定 set 支持的同步(线程安全的)set。 "},{"id":521,"href":"/zh/docs/technology/Interview/java/collection/java-collection-precautions-for-use/","title":"Java集合使用注意事项总结","section":"Collection","content":"这篇文章我根据《阿里巴巴 Java 开发手册》总结了关于集合使用常见的注意事项以及其具体原理。\n强烈建议小伙伴们多多阅读几遍,避免自己写代码的时候出现这些低级的问题。\n集合判空 # 《阿里巴巴 Java 开发手册》的描述如下:\n判断所有集合内部的元素是否为空,使用 isEmpty() 方法,而不是 size()==0 的方式。\n这是因为 isEmpty() 方法的可读性更好,并且时间复杂度为 O(1)。\n绝大部分我们使用的集合的 size() 方法的时间复杂度也是 O(1),不过,也有很多复杂度不是 O(1) 的,比如 java.util.concurrent 包下的 ConcurrentLinkedQueue。ConcurrentLinkedQueue 的 isEmpty() 方法通过 first() 方法进行判断,其中 first() 方法返回的是队列中第一个值不为 null 的节点(节点值为null的原因是在迭代器中使用的逻辑删除)\npublic boolean isEmpty() { return first() == null; } Node\u0026lt;E\u0026gt; first() { restartFromHead: for (;;) { for (Node\u0026lt;E\u0026gt; h = head, p = h, q;;) { boolean hasItem = (p.item != null); if (hasItem || (q = p.next) == null) { // 当前节点值不为空 或 到达队尾 updateHead(h, p); // 将head设置为p return hasItem ? p : null; } else if (p == q) continue restartFromHead; else p = q; // p = p.next } } } 由于在插入与删除元素时,都会执行updateHead(h, p)方法,所以该方法的执行的时间复杂度可以近似为O(1)。而 size() 方法需要遍历整个链表,时间复杂度为O(n)\npublic int size() { int count = 0; for (Node\u0026lt;E\u0026gt; p = first(); p != null; p = succ(p)) if (p.item != null) if (++count == Integer.MAX_VALUE) break; return count; } 此外,在ConcurrentHashMap 1.7 中 size() 方法和 isEmpty() 方法的时间复杂度也不太一样。ConcurrentHashMap 1.7 将元素数量存储在每个Segment 中,size() 方法需要统计每个 Segment 的数量,而 isEmpty() 只需要找到第一个不为空的 Segment 即可。但是在ConcurrentHashMap 1.8 中的 size() 方法和 isEmpty() 都需要调用 sumCount() 方法,其时间复杂度与 Node 数组的大小有关。下面是 sumCount() 方法的源码:\nfinal long sumCount() { CounterCell[] as = counterCells; CounterCell a; long sum = baseCount; if (as != null) for (int i = 0; i \u0026lt; as.length; ++i) if ((a = as[i]) != null) sum += a.value; return sum; } 这是因为在并发的环境下,ConcurrentHashMap 将每个 Node 中节点的数量存储在 CounterCell[] 数组中。在 ConcurrentHashMap 1.7 中,将元素数量存储在每个Segment 中,size() 方法需要统计每个 Segment 的数量,而 isEmpty() 只需要找到第一个不为空的 Segment 即可。\n集合转 Map # 《阿里巴巴 Java 开发手册》的描述如下:\n在使用 java.util.stream.Collectors 类的 toMap() 方法转为 Map 集合时,一定要注意当 value 为 null 时会抛 NPE 异常。\nclass Person { private String name; private String phoneNumber; // getters and setters } List\u0026lt;Person\u0026gt; bookList = new ArrayList\u0026lt;\u0026gt;(); bookList.add(new Person(\u0026#34;jack\u0026#34;,\u0026#34;18163138123\u0026#34;)); bookList.add(new Person(\u0026#34;martin\u0026#34;,null)); // 空指针异常 bookList.stream().collect(Collectors.toMap(Person::getName, Person::getPhoneNumber)); 下面我们来解释一下原因。\n首先,我们来看 java.util.stream.Collectors 类的 toMap() 方法 ,可以看到其内部调用了 Map 接口的 merge() 方法。\npublic static \u0026lt;T, K, U, M extends Map\u0026lt;K, U\u0026gt;\u0026gt; Collector\u0026lt;T, ?, M\u0026gt; toMap(Function\u0026lt;? super T, ? extends K\u0026gt; keyMapper, Function\u0026lt;? super T, ? extends U\u0026gt; valueMapper, BinaryOperator\u0026lt;U\u0026gt; mergeFunction, Supplier\u0026lt;M\u0026gt; mapSupplier) { BiConsumer\u0026lt;M, T\u0026gt; accumulator = (map, element) -\u0026gt; map.merge(keyMapper.apply(element), valueMapper.apply(element), mergeFunction); return new CollectorImpl\u0026lt;\u0026gt;(mapSupplier, accumulator, mapMerger(mergeFunction), CH_ID); } Map 接口的 merge() 方法如下,这个方法是接口中的默认实现。\n如果你还不了解 Java 8 新特性的话,请看这篇文章: 《Java8 新特性总结》 。\ndefault V merge(K key, V value, BiFunction\u0026lt;? super V, ? super V, ? extends V\u0026gt; remappingFunction) { Objects.requireNonNull(remappingFunction); Objects.requireNonNull(value); V oldValue = get(key); V newValue = (oldValue == null) ? value : remappingFunction.apply(oldValue, value); if(newValue == null) { remove(key); } else { put(key, newValue); } return newValue; } merge() 方法会先调用 Objects.requireNonNull() 方法判断 value 是否为空。\npublic static \u0026lt;T\u0026gt; T requireNonNull(T obj) { if (obj == null) throw new NullPointerException(); return obj; } 集合遍历 # 《阿里巴巴 Java 开发手册》的描述如下:\n不要在 foreach 循环里进行元素的 remove/add 操作。remove 元素请使用 Iterator 方式,如果并发操作,需要对 Iterator 对象加锁。\n通过反编译你会发现 foreach 语法底层其实还是依赖 Iterator 。不过, remove/add 操作直接调用的是集合自己的方法,而不是 Iterator 的 remove/add方法\n这就导致 Iterator 莫名其妙地发现自己有元素被 remove/add ,然后,它就会抛出一个 ConcurrentModificationException 来提示用户发生了并发修改异常。这就是单线程状态下产生的 fail-fast 机制。\nfail-fast 机制:多个线程对 fail-fast 集合进行修改的时候,可能会抛出ConcurrentModificationException。 即使是单线程下也有可能会出现这种情况,上面已经提到过。\n相关阅读: 什么是 fail-fast 。\nJava8 开始,可以使用 Collection#removeIf()方法删除满足特定条件的元素,如\nList\u0026lt;Integer\u0026gt; list = new ArrayList\u0026lt;\u0026gt;(); for (int i = 1; i \u0026lt;= 10; ++i) { list.add(i); } list.removeIf(filter -\u0026gt; filter % 2 == 0); /* 删除list中的所有偶数 */ System.out.println(list); /* [1, 3, 5, 7, 9] */ 除了上面介绍的直接使用 Iterator 进行遍历操作之外,你还可以:\n使用普通的 for 循环 使用 fail-safe 的集合类。java.util包下面的所有的集合类都是 fail-fast 的,而java.util.concurrent包下面的所有的类都是 fail-safe 的。 …… 集合去重 # 《阿里巴巴 Java 开发手册》的描述如下:\n可以利用 Set 元素唯一的特性,可以快速对一个集合进行去重操作,避免使用 List 的 contains() 进行遍历去重或者判断包含操作。\n这里我们以 HashSet 和 ArrayList 为例说明。\n// Set 去重代码示例 public static \u0026lt;T\u0026gt; Set\u0026lt;T\u0026gt; removeDuplicateBySet(List\u0026lt;T\u0026gt; data) { if (CollectionUtils.isEmpty(data)) { return new HashSet\u0026lt;\u0026gt;(); } return new HashSet\u0026lt;\u0026gt;(data); } // List 去重代码示例 public static \u0026lt;T\u0026gt; List\u0026lt;T\u0026gt; removeDuplicateByList(List\u0026lt;T\u0026gt; data) { if (CollectionUtils.isEmpty(data)) { return new ArrayList\u0026lt;\u0026gt;(); } List\u0026lt;T\u0026gt; result = new ArrayList\u0026lt;\u0026gt;(data.size()); for (T current : data) { if (!result.contains(current)) { result.add(current); } } return result; } 两者的核心差别在于 contains() 方法的实现。\nHashSet 的 contains() 方法底部依赖的 HashMap 的 containsKey() 方法,时间复杂度接近于 O(1)(没有出现哈希冲突的时候为 O(1))。\nprivate transient HashMap\u0026lt;E,Object\u0026gt; map; public boolean contains(Object o) { return map.containsKey(o); } 我们有 N 个元素插入进 Set 中,那时间复杂度就接近是 O (n)。\nArrayList 的 contains() 方法是通过遍历所有元素的方法来做的,时间复杂度接近是 O(n)。\npublic boolean contains(Object o) { return indexOf(o) \u0026gt;= 0; } public int indexOf(Object o) { if (o == null) { for (int i = 0; i \u0026lt; size; i++) if (elementData[i]==null) return i; } else { for (int i = 0; i \u0026lt; size; i++) if (o.equals(elementData[i])) return i; } return -1; } 集合转数组 # 《阿里巴巴 Java 开发手册》的描述如下:\n使用集合转数组的方法,必须使用集合的 toArray(T[] array),传入的是类型完全一致、长度为 0 的空数组。\ntoArray(T[] array) 方法的参数是一个泛型数组,如果 toArray 方法中没有传递任何参数的话返回的是 Object类 型数组。\nString [] s= new String[]{ \u0026#34;dog\u0026#34;, \u0026#34;lazy\u0026#34;, \u0026#34;a\u0026#34;, \u0026#34;over\u0026#34;, \u0026#34;jumps\u0026#34;, \u0026#34;fox\u0026#34;, \u0026#34;brown\u0026#34;, \u0026#34;quick\u0026#34;, \u0026#34;A\u0026#34; }; List\u0026lt;String\u0026gt; list = Arrays.asList(s); Collections.reverse(list); //没有指定类型的话会报错 s=list.toArray(new String[0]); 由于 JVM 优化,new String[0]作为Collection.toArray()方法的参数现在使用更好,new String[0]就是起一个模板的作用,指定了返回数组的类型,0 是为了节省空间,因为它只是为了说明返回的类型。详见: https://shipilev.net/blog/2016/arrays-wisdom-ancients/\n数组转集合 # 《阿里巴巴 Java 开发手册》的描述如下:\n使用工具类 Arrays.asList() 把数组转换成集合时,不能使用其修改集合相关的方法, 它的 add/remove/clear 方法会抛出 UnsupportedOperationException 异常。\n我在之前的一个项目中就遇到一个类似的坑。\nArrays.asList()在平时开发中还是比较常见的,我们可以使用它将一个数组转换为一个 List 集合。\nString[] myArray = {\u0026#34;Apple\u0026#34;, \u0026#34;Banana\u0026#34;, \u0026#34;Orange\u0026#34;}; List\u0026lt;String\u0026gt; myList = Arrays.asList(myArray); //上面两个语句等价于下面一条语句 List\u0026lt;String\u0026gt; myList = Arrays.asList(\u0026#34;Apple\u0026#34;,\u0026#34;Banana\u0026#34;, \u0026#34;Orange\u0026#34;); JDK 源码对于这个方法的说明:\n/** *返回由指定数组支持的固定大小的列表。此方法作为基于数组和基于集合的API之间的桥梁, * 与 Collection.toArray()结合使用。返回的List是可序列化并实现RandomAccess接口。 */ public static \u0026lt;T\u0026gt; List\u0026lt;T\u0026gt; asList(T... a) { return new ArrayList\u0026lt;\u0026gt;(a); } 下面我们来总结一下使用注意事项。\n1、Arrays.asList()是泛型方法,传递的数组必须是对象数组,而不是基本类型。\nint[] myArray = {1, 2, 3}; List myList = Arrays.asList(myArray); System.out.println(myList.size());//1 System.out.println(myList.get(0));//数组地址值 System.out.println(myList.get(1));//报错:ArrayIndexOutOfBoundsException int[] array = (int[]) myList.get(0); System.out.println(array[0]);//1 当传入一个原生数据类型数组时,Arrays.asList() 的真正得到的参数就不是数组中的元素,而是数组对象本身!此时 List 的唯一元素就是这个数组,这也就解释了上面的代码。\n我们使用包装类型数组就可以解决这个问题。\nInteger[] myArray = {1, 2, 3}; 2、使用集合的修改方法: add()、remove()、clear()会抛出异常。\nList myList = Arrays.asList(1, 2, 3); myList.add(4);//运行时报错:UnsupportedOperationException myList.remove(1);//运行时报错:UnsupportedOperationException myList.clear();//运行时报错:UnsupportedOperationException Arrays.asList() 方法返回的并不是 java.util.ArrayList ,而是 java.util.Arrays 的一个内部类,这个内部类并没有实现集合的修改方法或者说并没有重写这些方法。\nList myList = Arrays.asList(1, 2, 3); System.out.println(myList.getClass());//class java.util.Arrays$ArrayList 下图是 java.util.Arrays$ArrayList 的简易源码,我们可以看到这个类重写的方法有哪些。\nprivate static class ArrayList\u0026lt;E\u0026gt; extends AbstractList\u0026lt;E\u0026gt; implements RandomAccess, java.io.Serializable { ... @Override public E get(int index) { ... } @Override public E set(int index, E element) { ... } @Override public int indexOf(Object o) { ... } @Override public boolean contains(Object o) { ... } @Override public void forEach(Consumer\u0026lt;? super E\u0026gt; action) { ... } @Override public void replaceAll(UnaryOperator\u0026lt;E\u0026gt; operator) { ... } @Override public void sort(Comparator\u0026lt;? super E\u0026gt; c) { ... } } 我们再看一下java.util.AbstractList的 add/remove/clear 方法就知道为什么会抛出 UnsupportedOperationException 了。\npublic E remove(int index) { throw new UnsupportedOperationException(); } public boolean add(E e) { add(size(), e); return true; } public void add(int index, E element) { throw new UnsupportedOperationException(); } public void clear() { removeRange(0, size()); } protected void removeRange(int fromIndex, int toIndex) { ListIterator\u0026lt;E\u0026gt; it = listIterator(fromIndex); for (int i=0, n=toIndex-fromIndex; i\u0026lt;n; i++) { it.next(); it.remove(); } } 那我们如何正确的将数组转换为 ArrayList ?\n1、手动实现工具类\n//JDK1.5+ static \u0026lt;T\u0026gt; List\u0026lt;T\u0026gt; arrayToList(final T[] array) { final List\u0026lt;T\u0026gt; l = new ArrayList\u0026lt;T\u0026gt;(array.length); for (final T s : array) { l.add(s); } return l; } Integer [] myArray = { 1, 2, 3 }; System.out.println(arrayToList(myArray).getClass());//class java.util.ArrayList 2、最简便的方法\nList list = new ArrayList\u0026lt;\u0026gt;(Arrays.asList(\u0026#34;a\u0026#34;, \u0026#34;b\u0026#34;, \u0026#34;c\u0026#34;)) 3、使用 Java8 的 Stream(推荐)\nInteger [] myArray = { 1, 2, 3 }; List myList = Arrays.stream(myArray).collect(Collectors.toList()); //基本类型也可以实现转换(依赖boxed的装箱操作) int [] myArray2 = { 1, 2, 3 }; List myList = Arrays.stream(myArray2).boxed().collect(Collectors.toList()); 4、使用 Guava\n对于不可变集合,你可以使用 ImmutableList类及其 of()与 copyOf()工厂方法:(参数不能为空)\nList\u0026lt;String\u0026gt; il = ImmutableList.of(\u0026#34;string\u0026#34;, \u0026#34;elements\u0026#34;); // from varargs List\u0026lt;String\u0026gt; il = ImmutableList.copyOf(aStringArray); // from array 对于可变集合,你可以使用 Lists类及其 newArrayList()工厂方法:\nList\u0026lt;String\u0026gt; l1 = Lists.newArrayList(anotherListOrCollection); // from collection List\u0026lt;String\u0026gt; l2 = Lists.newArrayList(aStringArray); // from array List\u0026lt;String\u0026gt; l3 = Lists.newArrayList(\u0026#34;or\u0026#34;, \u0026#34;string\u0026#34;, \u0026#34;elements\u0026#34;); // from varargs 5、使用 Apache Commons Collections\nList\u0026lt;String\u0026gt; list = new ArrayList\u0026lt;String\u0026gt;(); CollectionUtils.addAll(list, str); 6、 使用 Java9 的 List.of()方法\nInteger[] array = {1, 2, 3}; List\u0026lt;Integer\u0026gt; list = List.of(array); "},{"id":522,"href":"/zh/docs/technology/Interview/java/jvm/memory-area/","title":"Java内存区域详解(重点)","section":"Jvm","content":" 如果没有特殊说明,都是针对的是 HotSpot 虚拟机。\n本文基于《深入理解 Java 虚拟机:JVM 高级特性与最佳实践》进行总结补充。\n常见面试题:\n介绍下 Java 内存区域(运行时数据区) Java 对象的创建过程(五步,建议能默写出来并且要知道每一步虚拟机做了什么) 对象的访问定位的两种方式(句柄和直接指针两种方式) 前言 # 对于 Java 程序员来说,在虚拟机自动内存管理机制下,不再需要像 C/C++程序开发程序员这样为每一个 new 操作去写对应的 delete/free 操作,不容易出现内存泄漏和内存溢出问题。正是因为 Java 程序员把内存控制权利交给 Java 虚拟机,一旦出现内存泄漏和溢出方面的问题,如果不了解虚拟机是怎样使用内存的,那么排查错误将会是一个非常艰巨的任务。\n运行时数据区域 # Java 虚拟机在执行 Java 程序的过程中会把它管理的内存划分成若干个不同的数据区域。\nJDK 1.8 和之前的版本略有不同,我们这里以 JDK 1.7 和 JDK 1.8 这两个版本为例介绍。\nJDK 1.7:\nJDK 1.8:\n线程私有的:\n程序计数器 虚拟机栈 本地方法栈 线程共享的:\n堆 方法区 直接内存 (非运行时数据区的一部分) Java 虚拟机规范对于运行时数据区域的规定是相当宽松的。以堆为例:堆可以是连续空间,也可以不连续。堆的大小可以固定,也可以在运行时按需扩展 。虚拟机实现者可以使用任何垃圾回收算法管理堆,甚至完全不进行垃圾收集也是可以的。\n程序计数器 # 程序计数器是一块较小的内存空间,可以看作是当前线程所执行的字节码的行号指示器。字节码解释器工作时通过改变这个计数器的值来选取下一条需要执行的字节码指令,分支、循环、跳转、异常处理、线程恢复等功能都需要依赖这个计数器来完成。\n另外,为了线程切换后能恢复到正确的执行位置,每条线程都需要有一个独立的程序计数器,各线程之间计数器互不影响,独立存储,我们称这类内存区域为“线程私有”的内存。\n从上面的介绍中我们知道了程序计数器主要有两个作用:\n字节码解释器通过改变程序计数器来依次读取指令,从而实现代码的流程控制,如:顺序执行、选择、循环、异常处理。 在多线程的情况下,程序计数器用于记录当前线程执行的位置,从而当线程被切换回来的时候能够知道该线程上次运行到哪儿了。 ⚠️ 注意:程序计数器是唯一一个不会出现 OutOfMemoryError 的内存区域,它的生命周期随着线程的创建而创建,随着线程的结束而死亡。\nJava 虚拟机栈 # 与程序计数器一样,Java 虚拟机栈(后文简称栈)也是线程私有的,它的生命周期和线程相同,随着线程的创建而创建,随着线程的死亡而死亡。\n栈绝对算的上是 JVM 运行时数据区域的一个核心,除了一些 Native 方法调用是通过本地方法栈实现的(后面会提到),其他所有的 Java 方法调用都是通过栈来实现的(也需要和其他运行时数据区域比如程序计数器配合)。\n方法调用的数据需要通过栈进行传递,每一次方法调用都会有一个对应的栈帧被压入栈中,每一个方法调用结束后,都会有一个栈帧被弹出。\n栈由一个个栈帧组成,而每个栈帧中都拥有:局部变量表、操作数栈、动态链接、方法返回地址。和数据结构上的栈类似,两者都是先进后出的数据结构,只支持出栈和入栈两种操作。\n局部变量表 主要存放了编译期可知的各种数据类型(boolean、byte、char、short、int、float、long、double)、对象引用(reference 类型,它不同于对象本身,可能是一个指向对象起始地址的引用指针,也可能是指向一个代表对象的句柄或其他与此对象相关的位置)。\n操作数栈 主要作为方法调用的中转站使用,用于存放方法执行过程中产生的中间计算结果。另外,计算过程中产生的临时变量也会放在操作数栈中。\n动态链接 主要服务一个方法需要调用其他方法的场景。Class 文件的常量池里保存有大量的符号引用比如方法引用的符号引用。当一个方法要调用其他方法,需要将常量池中指向方法的符号引用转化为其在内存地址中的直接引用。动态链接的作用就是为了将符号引用转换为调用方法的直接引用,这个过程也被称为 动态连接 。\n栈空间虽然不是无限的,但一般正常调用的情况下是不会出现问题的。不过,如果函数调用陷入无限循环的话,就会导致栈中被压入太多栈帧而占用太多空间,导致栈空间过深。那么当线程请求栈的深度超过当前 Java 虚拟机栈的最大深度的时候,就抛出 StackOverFlowError 错误。\nJava 方法有两种返回方式,一种是 return 语句正常返回,一种是抛出异常。不管哪种返回方式,都会导致栈帧被弹出。也就是说, 栈帧随着方法调用而创建,随着方法结束而销毁。无论方法正常完成还是异常完成都算作方法结束。\n除了 StackOverFlowError 错误之外,栈还可能会出现OutOfMemoryError错误,这是因为如果栈的内存大小可以动态扩展, 如果虚拟机在动态扩展栈时无法申请到足够的内存空间,则抛出OutOfMemoryError异常。\n简单总结一下程序运行中栈可能会出现两种错误:\nStackOverFlowError: 若栈的内存大小不允许动态扩展,那么当线程请求栈的深度超过当前 Java 虚拟机栈的最大深度的时候,就抛出 StackOverFlowError 错误。 OutOfMemoryError: 如果栈的内存大小可以动态扩展, 如果虚拟机在动态扩展栈时无法申请到足够的内存空间,则抛出OutOfMemoryError异常。 本地方法栈 # 和虚拟机栈所发挥的作用非常相似,区别是:虚拟机栈为虚拟机执行 Java 方法 (也就是字节码)服务,而本地方法栈则为虚拟机使用到的 Native 方法服务。 在 HotSpot 虚拟机中和 Java 虚拟机栈合二为一。\n本地方法被执行的时候,在本地方法栈也会创建一个栈帧,用于存放该本地方法的局部变量表、操作数栈、动态链接、出口信息。\n方法执行完毕后相应的栈帧也会出栈并释放内存空间,也会出现 StackOverFlowError 和 OutOfMemoryError 两种错误。\n堆 # Java 虚拟机所管理的内存中最大的一块,Java 堆是所有线程共享的一块内存区域,在虚拟机启动时创建。此内存区域的唯一目的就是存放对象实例,几乎所有的对象实例以及数组都在这里分配内存。\nJava 世界中“几乎”所有的对象都在堆中分配,但是,随着 JIT 编译器的发展与逃逸分析技术逐渐成熟,栈上分配、标量替换优化技术将会导致一些微妙的变化,所有的对象都分配到堆上也渐渐变得不那么“绝对”了。从 JDK 1.7 开始已经默认开启逃逸分析,如果某些方法中的对象引用没有被返回或者未被外面使用(也就是未逃逸出去),那么对象可以直接在栈上分配内存。\nJava 堆是垃圾收集器管理的主要区域,因此也被称作 GC 堆(Garbage Collected Heap)。从垃圾回收的角度,由于现在收集器基本都采用分代垃圾收集算法,所以 Java 堆还可以细分为:新生代和老年代;再细致一点有:Eden、Survivor、Old 等空间。进一步划分的目的是更好地回收内存,或者更快地分配内存。\n在 JDK 7 版本及 JDK 7 版本之前,堆内存被通常分为下面三部分:\n新生代内存(Young Generation) 老生代(Old Generation) 永久代(Permanent Generation) 下图所示的 Eden 区、两个 Survivor 区 S0 和 S1 都属于新生代,中间一层属于老年代,最下面一层属于永久代。\nJDK 8 版本之后 PermGen(永久代) 已被 Metaspace(元空间) 取代,元空间使用的是本地内存。 (我会在方法区这部分内容详细介绍到)。\n大部分情况,对象都会首先在 Eden 区域分配,在一次新生代垃圾回收后,如果对象还存活,则会进入 S0 或者 S1,并且对象的年龄还会加 1(Eden 区-\u0026gt;Survivor 区后对象的初始年龄变为 1),当它的年龄增加到一定程度(默认为 15 岁),就会被晋升到老年代中。对象晋升到老年代的年龄阈值,可以通过参数 -XX:MaxTenuringThreshold 来设置。不过,设置的值应该在 0-15,否则会爆出以下错误:\nMaxTenuringThreshold of 20 is invalid; must be between 0 and 15 为什么年龄只能是 0-15?\n因为记录年龄的区域在对象头中,这个区域的大小通常是 4 位。这 4 位可以表示的最大二进制数字是 1111,即十进制的 15。因此,对象的年龄被限制为 0 到 15。\n这里我们简单结合对象布局来详细介绍一下。\n在 HotSpot 虚拟机中,对象在内存中存储的布局可以分为 3 块区域:对象头(Header)、实例数据(Instance Data)和对齐填充(Padding)。其中,对象头包括两部分:标记字段(Mark Word)和类型指针(Klass Word)。关于对象内存布局的详细介绍,后文会介绍到,这里就不重复提了。\n这个年龄信息就是在标记字段中存放的(标记字段还存放了对象自身的其他信息比如哈希码、锁状态信息等等)。markOop.hpp定义了标记字(mark word)的结构:\n可以看到对象年龄占用的大小确实是 4 位。\n🐛 修正(参见: issue552):“Hotspot 遍历所有对象时,按照年龄从小到大对其所占用的大小进行累加,当累加到某个年龄时,所累加的大小超过了 Survivor 区的一半,则取这个年龄和 MaxTenuringThreshold 中更小的一个值,作为新的晋升年龄阈值”。\n动态年龄计算的代码如下\nuint ageTable::compute_tenuring_threshold(size_t survivor_capacity) { //survivor_capacity是survivor空间的大小 size_t desired_survivor_size = (size_t)((((double) survivor_capacity)*TargetSurvivorRatio)/100);//TargetSurvivorRatio 为50 size_t total = 0; uint age = 1; while (age \u0026lt; table_size) { total += sizes[age];//sizes数组是每个年龄段对象大小 if (total \u0026gt; desired_survivor_size) break; age++; } uint result = age \u0026lt; MaxTenuringThreshold ? age : MaxTenuringThreshold; ... } 堆这里最容易出现的就是 OutOfMemoryError 错误,并且出现这种错误之后的表现形式还会有几种,比如:\njava.lang.OutOfMemoryError: GC Overhead Limit Exceeded:当 JVM 花太多时间执行垃圾回收并且只能回收很少的堆空间时,就会发生此错误。 java.lang.OutOfMemoryError: Java heap space :假如在创建新的对象时, 堆内存中的空间不足以存放新创建的对象, 就会引发此错误。(和配置的最大堆内存有关,且受制于物理内存大小。最大堆内存可通过-Xmx参数配置,若没有特别配置,将会使用默认值,详见: Default Java 8 max heap size) …… 方法区 # 方法区属于是 JVM 运行时数据区域的一块逻辑区域,是各个线程共享的内存区域。\n《Java 虚拟机规范》只是规定了有方法区这么个概念和它的作用,方法区到底要如何实现那就是虚拟机自己要考虑的事情了。也就是说,在不同的虚拟机实现上,方法区的实现是不同的。\n当虚拟机要使用一个类时,它需要读取并解析 Class 文件获取相关信息,再将信息存入到方法区。方法区会存储已被虚拟机加载的 类信息、字段信息、方法信息、常量、静态变量、即时编译器编译后的代码缓存等数据。\n方法区和永久代以及元空间是什么关系呢? 方法区和永久代以及元空间的关系很像 Java 中接口和类的关系,类实现了接口,这里的类就可以看作是永久代和元空间,接口可以看作是方法区,也就是说永久代以及元空间是 HotSpot 虚拟机对虚拟机规范中方法区的两种实现方式。并且,永久代是 JDK 1.8 之前的方法区实现,JDK 1.8 及以后方法区的实现变成了元空间。\n为什么要将永久代 (PermGen) 替换为元空间 (MetaSpace) 呢?\n下图来自《深入理解 Java 虚拟机》第 3 版 2.2.5\n1、整个永久代有一个 JVM 本身设置的固定大小上限,无法进行调整(也就是受到 JVM 内存的限制),而元空间使用的是本地内存,受本机可用内存的限制,虽然元空间仍旧可能溢出,但是比原来出现的几率会更小。\n当元空间溢出时会得到如下错误:java.lang.OutOfMemoryError: MetaSpace\n你可以使用 -XX:MaxMetaspaceSize 标志设置最大元空间大小,默认值为 unlimited,这意味着它只受系统内存的限制。-XX:MetaspaceSize 调整标志定义元空间的初始大小如果未指定此标志,则 Metaspace 将根据运行时的应用程序需求动态地重新调整大小。\n2、元空间里面存放的是类的元数据,这样加载多少类的元数据就不由 MaxPermSize 控制了, 而由系统的实际可用空间来控制,这样能加载的类就更多了。\n3、在 JDK8,合并 HotSpot 和 JRockit 的代码时, JRockit 从来没有一个叫永久代的东西, 合并之后就没有必要额外的设置这么一个永久代的地方了。\n4、永久代会为 GC 带来不必要的复杂度,并且回收效率偏低。\n方法区常用参数有哪些?\nJDK 1.8 之前永久代还没被彻底移除的时候通常通过下面这些参数来调节方法区大小。\n-XX:PermSize=N //方法区 (永久代) 初始大小 -XX:MaxPermSize=N //方法区 (永久代) 最大大小,超过这个值将会抛出 OutOfMemoryError 异常:java.lang.OutOfMemoryError: PermGen 相对而言,垃圾收集行为在这个区域是比较少出现的,但并非数据进入方法区后就“永久存在”了。\nJDK 1.8 的时候,方法区(HotSpot 的永久代)被彻底移除了(JDK1.7 就已经开始了),取而代之是元空间,元空间使用的是本地内存。下面是一些常用参数:\n-XX:MetaspaceSize=N //设置 Metaspace 的初始(和最小大小) -XX:MaxMetaspaceSize=N //设置 Metaspace 的最大大小 与永久代很大的不同就是,如果不指定大小的话,随着更多类的创建,虚拟机会耗尽所有可用的系统内存。\n运行时常量池 # Class 文件中除了有类的版本、字段、方法、接口等描述信息外,还有用于存放编译期生成的各种字面量(Literal)和符号引用(Symbolic Reference)的 常量池表(Constant Pool Table) 。\n字面量是源代码中的固定值的表示法,即通过字面我们就能知道其值的含义。字面量包括整数、浮点数和字符串字面量。常见的符号引用包括类符号引用、字段符号引用、方法符号引用、接口方法符号。\n《深入理解 Java 虚拟机》7.34 节第三版对符号引用和直接引用的解释如下:\n常量池表会在类加载后存放到方法区的运行时常量池中。\n运行时常量池的功能类似于传统编程语言的符号表,尽管它包含了比典型符号表更广泛的数据。\n既然运行时常量池是方法区的一部分,自然受到方法区内存的限制,当常量池无法再申请到内存时会抛出 OutOfMemoryError 错误。\n字符串常量池 # 字符串常量池 是 JVM 为了提升性能和减少内存消耗针对字符串(String 类)专门开辟的一块区域,主要目的是为了避免字符串的重复创建。\n// 在字符串常量池中创建字符串对象 ”ab“ // 将字符串对象 ”ab“ 的引用赋值给给 aa String aa = \u0026#34;ab\u0026#34;; // 直接返回字符串常量池中字符串对象 ”ab“,赋值给引用 bb String bb = \u0026#34;ab\u0026#34;; System.out.println(aa==bb); // true HotSpot 虚拟机中字符串常量池的实现是 src/hotspot/share/classfile/stringTable.cpp ,StringTable 可以简单理解为一个固定大小的HashTable ,容量为 StringTableSize(可以通过 -XX:StringTableSize 参数来设置),保存的是字符串(key)和 字符串对象的引用(value)的映射关系,字符串对象的引用指向堆中的字符串对象。\nJDK1.7 之前,字符串常量池存放在永久代。JDK1.7 字符串常量池和静态变量从永久代移动到了 Java 堆中。\nJDK 1.7 为什么要将字符串常量池移动到堆中?\n主要是因为永久代(方法区实现)的 GC 回收效率太低,只有在整堆收集 (Full GC)的时候才会被执行 GC。Java 程序中通常会有大量的被创建的字符串等待回收,将字符串常量池放到堆中,能够更高效及时地回收字符串内存。\n相关问题: JVM 常量池中存储的是对象还是引用呢? - RednaxelaFX - 知乎\n最后再来分享一段周志明老师在 《深入理解 Java 虚拟机(第 3 版)》样例代码\u0026amp;勘误 GitHub 仓库的 issue#112 中说过的话:\n运行时常量池、方法区、字符串常量池这些都是不随虚拟机实现而改变的逻辑概念,是公共且抽象的,Metaspace、Heap 是与具体某种虚拟机实现相关的物理概念,是私有且具体的。\n直接内存 # 直接内存是一种特殊的内存缓冲区,并不在 Java 堆或方法区中分配的,而是通过 JNI 的方式在本地内存上分配的。\n直接内存并不是虚拟机运行时数据区的一部分,也不是虚拟机规范中定义的内存区域,但是这部分内存也被频繁地使用。而且也可能导致 OutOfMemoryError 错误出现。\nJDK1.4 中新加入的 NIO(Non-Blocking I/O,也被称为 New I/O),引入了一种基于通道(Channel)与缓存区(Buffer)的 I/O 方式,它可以直接使用 Native 函数库直接分配堆外内存,然后通过一个存储在 Java 堆中的 DirectByteBuffer 对象作为这块内存的引用进行操作。这样就能在一些场景中显著提高性能,因为避免了在 Java 堆和 Native 堆之间来回复制数据。\n直接内存的分配不会受到 Java 堆的限制,但是,既然是内存就会受到本机总内存大小以及处理器寻址空间的限制。\n类似的概念还有 堆外内存 。在一些文章中将直接内存等价于堆外内存,个人觉得不是特别准确。\n堆外内存就是把内存对象分配在堆外的内存,这些内存直接受操作系统管理(而不是虚拟机),这样做的结果就是能够在一定程度上减少垃圾回收对应用程序造成的影响。\nHotSpot 虚拟机对象探秘 # 通过上面的介绍我们大概知道了虚拟机的内存情况,下面我们来详细的了解一下 HotSpot 虚拟机在 Java 堆中对象分配、布局和访问的全过程。\n对象的创建 # Java 对象的创建过程我建议最好是能默写出来,并且要掌握每一步在做什么。\nStep1:类加载检查 # 虚拟机遇到一条 new 指令时,首先将去检查这个指令的参数是否能在常量池中定位到这个类的符号引用,并且检查这个符号引用代表的类是否已被加载过、解析和初始化过。如果没有,那必须先执行相应的类加载过程。\nStep2:分配内存 # 在类加载检查通过后,接下来虚拟机将为新生对象分配内存。对象所需的内存大小在类加载完成后便可确定,为对象分配空间的任务等同于把一块确定大小的内存从 Java 堆中划分出来。分配方式有 “指针碰撞” 和 “空闲列表” 两种,选择哪种分配方式由 Java 堆是否规整决定,而 Java 堆是否规整又由所采用的垃圾收集器是否带有压缩整理功能决定。\n内存分配的两种方式 (补充内容,需要掌握):\n指针碰撞: 适用场合:堆内存规整(即没有内存碎片)的情况下。 原理:用过的内存全部整合到一边,没有用过的内存放在另一边,中间有一个分界指针,只需要向着没用过的内存方向将该指针移动对象内存大小位置即可。 使用该分配方式的 GC 收集器:Serial, ParNew 空闲列表: 适用场合:堆内存不规整的情况下。 原理:虚拟机会维护一个列表,该列表中会记录哪些内存块是可用的,在分配的时候,找一块儿足够大的内存块儿来划分给对象实例,最后更新列表记录。 使用该分配方式的 GC 收集器:CMS 选择以上两种方式中的哪一种,取决于 Java 堆内存是否规整。而 Java 堆内存是否规整,取决于 GC 收集器的算法是\u0026quot;标记-清除\u0026quot;,还是\u0026quot;标记-整理\u0026quot;(也称作\u0026quot;标记-压缩\u0026quot;),值得注意的是,复制算法内存也是规整的。\n内存分配并发问题(补充内容,需要掌握)\n在创建对象的时候有一个很重要的问题,就是线程安全,因为在实际开发过程中,创建对象是很频繁的事情,作为虚拟机来说,必须要保证线程是安全的,通常来讲,虚拟机采用两种方式来保证线程安全:\nCAS+失败重试: CAS 是乐观锁的一种实现方式。所谓乐观锁就是,每次不加锁而是假设没有冲突而去完成某项操作,如果因为冲突失败就重试,直到成功为止。虚拟机采用 CAS 配上失败重试的方式保证更新操作的原子性。 TLAB: 为每一个线程预先在 Eden 区分配一块儿内存,JVM 在给线程中的对象分配内存时,首先在 TLAB 分配,当对象大于 TLAB 中的剩余内存或 TLAB 的内存已用尽时,再采用上述的 CAS 进行内存分配 Step3:初始化零值 # 内存分配完成后,虚拟机需要将分配到的内存空间都初始化为零值(不包括对象头),这一步操作保证了对象的实例字段在 Java 代码中可以不赋初始值就直接使用,程序能访问到这些字段的数据类型所对应的零值。\nStep4:设置对象头 # 初始化零值完成之后,虚拟机要对对象进行必要的设置,例如这个对象是哪个类的实例、如何才能找到类的元数据信息、对象的哈希码、对象的 GC 分代年龄等信息。 这些信息存放在对象头中。 另外,根据虚拟机当前运行状态的不同,如是否启用偏向锁等,对象头会有不同的设置方式。\nStep5:执行 init 方法 # 在上面工作都完成之后,从虚拟机的视角来看,一个新的对象已经产生了,但从 Java 程序的视角来看,对象创建才刚开始,\u0026lt;init\u0026gt; 方法还没有执行,所有的字段都还为零。所以一般来说,执行 new 指令之后会接着执行 \u0026lt;init\u0026gt; 方法,把对象按照程序员的意愿进行初始化,这样一个真正可用的对象才算完全产生出来。\n对象的内存布局 # 在 Hotspot 虚拟机中,对象在内存中的布局可以分为 3 块区域:对象头(Header)、实例数据(Instance Data)和对齐填充(Padding)。\n对象头包括两部分信息:\n标记字段(Mark Word):用于存储对象自身的运行时数据, 如哈希码(HashCode)、GC 分代年龄、锁状态标志、线程持有的锁、偏向线程 ID、偏向时间戳等等。 类型指针(Klass pointer):对象指向它的类元数据的指针,虚拟机通过这个指针来确定这个对象是哪个类的实例。 实例数据部分是对象真正存储的有效信息,也是在程序中所定义的各种类型的字段内容。\n对齐填充部分不是必然存在的,也没有什么特别的含义,仅仅起占位作用。 因为 Hotspot 虚拟机的自动内存管理系统要求对象起始地址必须是 8 字节的整数倍,换句话说就是对象的大小必须是 8 字节的整数倍。而对象头部分正好是 8 字节的倍数(1 倍或 2 倍),因此,当对象实例数据部分没有对齐时,就需要通过对齐填充来补全。\n对象的访问定位 # 建立对象就是为了使用对象,我们的 Java 程序通过栈上的 reference 数据来操作堆上的具体对象。对象的访问方式由虚拟机实现而定,目前主流的访问方式有:使用句柄、直接指针。\n句柄 # 如果使用句柄的话,那么 Java 堆中将会划分出一块内存来作为句柄池,reference 中存储的就是对象的句柄地址,而句柄中包含了对象实例数据与对象类型数据各自的具体地址信息。\n直接指针 # 如果使用直接指针访问,reference 中存储的直接就是对象的地址。\n这两种对象访问方式各有优势。使用句柄来访问的最大好处是 reference 中存储的是稳定的句柄地址,在对象被移动时只会改变句柄中的实例数据指针,而 reference 本身不需要修改。使用直接指针访问方式最大的好处就是速度快,它节省了一次指针定位的时间开销。\nHotSpot 虚拟机主要使用的就是这种方式来进行对象访问。\n参考 # 《深入理解 Java 虚拟机:JVM 高级特性与最佳实践(第二版》 《自己动手写 Java 虚拟机》 Chapter 2. The Structure of the Java Virtual Machine: https://docs.oracle.com/javase/specs/jvms/se8/html/jvms-2.html JVM 栈帧内部结构-动态链接: https://chenxitag.com/archives/368 Java 中 new String(\u0026ldquo;字面量\u0026rdquo;) 中 \u0026ldquo;字面量\u0026rdquo; 是何时进入字符串常量池的? - 木女孩的回答 - 知乎: https://www.zhihu.com/question/55994121/answer/147296098 JVM 常量池中存储的是对象还是引用呢? - RednaxelaFX 的回答 - 知乎: https://www.zhihu.com/question/57109429/answer/151717241 http://www.pointsoftware.ch/en/under-the-hood-runtime-data-areas-javas-memory-model/ https://dzone.com/articles/jvm-permgen-%E2%80%93-where-art-thou https://stackoverflow.com/questions/9095748/method-area-and-permgen "},{"id":523,"href":"/zh/docs/technology/Interview/java/jvm/jdk-monitoring-and-troubleshooting-tools/","title":"JDK监控和故障处理工具总结","section":"Jvm","content":" JDK 命令行工具 # 这些命令在 JDK 安装目录下的 bin 目录下:\njps (JVM Process Status): 类似 UNIX 的 ps 命令。用于查看所有 Java 进程的启动类、传入参数和 Java 虚拟机参数等信息; jstat(JVM Statistics Monitoring Tool): 用于收集 HotSpot 虚拟机各方面的运行数据; jinfo (Configuration Info for Java) : Configuration Info for Java,显示虚拟机配置信息; jmap (Memory Map for Java) : 生成堆转储快照; jhat (JVM Heap Dump Browser) : 用于分析 heapdump 文件,它会建立一个 HTTP/HTML 服务器,让用户可以在浏览器上查看分析结果。JDK9 移除了 jhat; jstack (Stack Trace for Java) : 生成虚拟机当前时刻的线程快照,线程快照就是当前虚拟机内每一条线程正在执行的方法堆栈的集合。 jps:查看所有 Java 进程 # jps(JVM Process Status) 命令类似 UNIX 的 ps 命令。\njps:显示虚拟机执行主类名称以及这些进程的本地虚拟机唯一 ID(Local Virtual Machine Identifier,LVMID)。jps -q:只输出进程的本地虚拟机唯一 ID。\nC:\\Users\\SnailClimb\u0026gt;jps 7360 NettyClient2 17396 7972 Launcher 16504 Jps 17340 NettyServer jps -l:输出主类的全名,如果进程执行的是 Jar 包,输出 Jar 路径。\nC:\\Users\\SnailClimb\u0026gt;jps -l 7360 firstNettyDemo.NettyClient2 17396 7972 org.jetbrains.jps.cmdline.Launcher 16492 sun.tools.jps.Jps 17340 firstNettyDemo.NettyServer jps -v:输出虚拟机进程启动时 JVM 参数。\njps -m:输出传递给 Java 进程 main() 函数的参数。\njstat: 监视虚拟机各种运行状态信息 # jstat(JVM Statistics Monitoring Tool) 使用于监视虚拟机各种运行状态信息的命令行工具。 它可以显示本地或者远程(需要远程主机提供 RMI 支持)虚拟机进程中的类信息、内存、垃圾收集、JIT 编译等运行数据,在没有 GUI,只提供了纯文本控制台环境的服务器上,它将是运行期间定位虚拟机性能问题的首选工具。\njstat 命令使用格式:\njstat -\u0026lt;option\u0026gt; [-t] [-h\u0026lt;lines\u0026gt;] \u0026lt;vmid\u0026gt; [\u0026lt;interval\u0026gt; [\u0026lt;count\u0026gt;]] 比如 jstat -gc -h3 31736 1000 10表示分析进程 id 为 31736 的 gc 情况,每隔 1000ms 打印一次记录,打印 10 次停止,每 3 行后打印指标头部。\n常见的 option 如下:\njstat -class vmid:显示 ClassLoader 的相关信息; jstat -compiler vmid:显示 JIT 编译的相关信息; jstat -gc vmid:显示与 GC 相关的堆信息; jstat -gccapacity vmid:显示各个代的容量及使用情况; jstat -gcnew vmid:显示新生代信息; jstat -gcnewcapcacity vmid:显示新生代大小与使用情况; jstat -gcold vmid:显示老年代和永久代的行为统计,从 jdk1.8 开始,该选项仅表示老年代,因为永久代被移除了; jstat -gcoldcapacity vmid:显示老年代的大小; jstat -gcpermcapacity vmid:显示永久代大小,从 jdk1.8 开始,该选项不存在了,因为永久代被移除了; jstat -gcutil vmid:显示垃圾收集信息; 另外,加上 -t参数可以在输出信息上加一个 Timestamp 列,显示程序的运行时间。\njinfo: 实时地查看和调整虚拟机各项参数 # jinfo vmid :输出当前 jvm 进程的全部参数和系统属性 (第一部分是系统的属性,第二部分是 JVM 的参数)。\njinfo -flag name vmid :输出对应名称的参数的具体值。比如输出 MaxHeapSize、查看当前 jvm 进程是否开启打印 GC 日志 ( -XX:PrintGCDetails :详细 GC 日志模式,这两个都是默认关闭的)。\nC:\\Users\\SnailClimb\u0026gt;jinfo -flag MaxHeapSize 17340 -XX:MaxHeapSize=2124414976 C:\\Users\\SnailClimb\u0026gt;jinfo -flag PrintGC 17340 -XX:-PrintGC 使用 jinfo 可以在不重启虚拟机的情况下,可以动态的修改 jvm 的参数。尤其在线上的环境特别有用,请看下面的例子:\njinfo -flag [+|-]name vmid 开启或者关闭对应名称的参数。\nC:\\Users\\SnailClimb\u0026gt;jinfo -flag PrintGC 17340 -XX:-PrintGC C:\\Users\\SnailClimb\u0026gt;jinfo -flag +PrintGC 17340 C:\\Users\\SnailClimb\u0026gt;jinfo -flag PrintGC 17340 -XX:+PrintGC jmap:生成堆转储快照 # jmap(Memory Map for Java)命令用于生成堆转储快照。 如果不使用 jmap 命令,要想获取 Java 堆转储,可以使用 “-XX:+HeapDumpOnOutOfMemoryError” 参数,可以让虚拟机在 OOM 异常出现之后自动生成 dump 文件,Linux 命令下可以通过 kill -3 发送进程退出信号也能拿到 dump 文件。\njmap 的作用并不仅仅是为了获取 dump 文件,它还可以查询 finalizer 执行队列、Java 堆和永久代的详细信息,如空间使用率、当前使用的是哪种收集器等。和jinfo一样,jmap有不少功能在 Windows 平台下也是受限制的。\n示例:将指定应用程序的堆快照输出到桌面。后面,可以通过 jhat、Visual VM 等工具分析该堆文件。\nC:\\Users\\SnailClimb\u0026gt;jmap -dump:format=b,file=C:\\Users\\SnailClimb\\Desktop\\heap.hprof 17340 Dumping heap to C:\\Users\\SnailClimb\\Desktop\\heap.hprof ... Heap dump file created jhat: 分析 heapdump 文件 # jhat 用于分析 heapdump 文件,它会建立一个 HTTP/HTML 服务器,让用户可以在浏览器上查看分析结果。\nC:\\Users\\SnailClimb\u0026gt;jhat C:\\Users\\SnailClimb\\Desktop\\heap.hprof Reading from C:\\Users\\SnailClimb\\Desktop\\heap.hprof... Dump file created Sat May 04 12:30:31 CST 2019 Snapshot read, resolving... Resolving 131419 objects... Chasing references, expect 26 dots.......................... Eliminating duplicate references.......................... Snapshot resolved. Started HTTP server on port 7000 Server is ready. 访问 http://localhost:7000/\n注意⚠️:JDK9 移除了 jhat( JEP 241: Remove the jhat Tool),你可以使用其替代品 Eclipse Memory Analyzer Tool (MAT) 和 VisualVM,这也是官方所推荐的。\njstack :生成虚拟机当前时刻的线程快照 # jstack(Stack Trace for Java)命令用于生成虚拟机当前时刻的线程快照。线程快照就是当前虚拟机内每一条线程正在执行的方法堆栈的集合.\n生成线程快照的目的主要是定位线程长时间出现停顿的原因,如线程间死锁、死循环、请求外部资源导致的长时间等待等都是导致线程长时间停顿的原因。线程出现停顿的时候通过jstack来查看各个线程的调用堆栈,就可以知道没有响应的线程到底在后台做些什么事情,或者在等待些什么资源。\n下面是一个线程死锁的代码。我们下面会通过 jstack 命令进行死锁检查,输出死锁信息,找到发生死锁的线程。\npublic class DeadLockDemo { private static Object resource1 = new Object();//资源 1 private static Object resource2 = new Object();//资源 2 public static void main(String[] args) { new Thread(() -\u0026gt; { synchronized (resource1) { System.out.println(Thread.currentThread() + \u0026#34;get resource1\u0026#34;); try { Thread.sleep(1000); } catch (InterruptedException e) { e.printStackTrace(); } System.out.println(Thread.currentThread() + \u0026#34;waiting get resource2\u0026#34;); synchronized (resource2) { System.out.println(Thread.currentThread() + \u0026#34;get resource2\u0026#34;); } } }, \u0026#34;线程 1\u0026#34;).start(); new Thread(() -\u0026gt; { synchronized (resource2) { System.out.println(Thread.currentThread() + \u0026#34;get resource2\u0026#34;); try { Thread.sleep(1000); } catch (InterruptedException e) { e.printStackTrace(); } System.out.println(Thread.currentThread() + \u0026#34;waiting get resource1\u0026#34;); synchronized (resource1) { System.out.println(Thread.currentThread() + \u0026#34;get resource1\u0026#34;); } } }, \u0026#34;线程 2\u0026#34;).start(); } } Output\nThread[线程 1,5,main]get resource1 Thread[线程 2,5,main]get resource2 Thread[线程 1,5,main]waiting get resource2 Thread[线程 2,5,main]waiting get resource1 线程 A 通过 synchronized (resource1) 获得 resource1 的监视器锁,然后通过Thread.sleep(1000);让线程 A 休眠 1s 为的是让线程 B 得到执行然后获取到 resource2 的监视器锁。线程 A 和线程 B 休眠结束了都开始企图请求获取对方的资源,然后这两个线程就会陷入互相等待的状态,这也就产生了死锁。\n通过 jstack 命令分析:\nC:\\Users\\SnailClimb\u0026gt;jps 13792 KotlinCompileDaemon 7360 NettyClient2 17396 7972 Launcher 8932 Launcher 9256 DeadLockDemo 10764 Jps 17340 NettyServer C:\\Users\\SnailClimb\u0026gt;jstack 9256 输出的部分内容如下:\nFound one Java-level deadlock: ============================= \u0026#34;线程 2\u0026#34;: waiting to lock monitor 0x000000000333e668 (object 0x00000000d5efe1c0, a java.lang.Object), which is held by \u0026#34;线程 1\u0026#34; \u0026#34;线程 1\u0026#34;: waiting to lock monitor 0x000000000333be88 (object 0x00000000d5efe1d0, a java.lang.Object), which is held by \u0026#34;线程 2\u0026#34; Java stack information for the threads listed above: =================================================== \u0026#34;线程 2\u0026#34;: at DeadLockDemo.lambda$main$1(DeadLockDemo.java:31) - waiting to lock \u0026lt;0x00000000d5efe1c0\u0026gt; (a java.lang.Object) - locked \u0026lt;0x00000000d5efe1d0\u0026gt; (a java.lang.Object) at DeadLockDemo$$Lambda$2/1078694789.run(Unknown Source) at java.lang.Thread.run(Thread.java:748) \u0026#34;线程 1\u0026#34;: at DeadLockDemo.lambda$main$0(DeadLockDemo.java:16) - waiting to lock \u0026lt;0x00000000d5efe1d0\u0026gt; (a java.lang.Object) - locked \u0026lt;0x00000000d5efe1c0\u0026gt; (a java.lang.Object) at DeadLockDemo$$Lambda$1/1324119927.run(Unknown Source) at java.lang.Thread.run(Thread.java:748) Found 1 deadlock. 可以看到 jstack 命令已经帮我们找到发生死锁的线程的具体信息。\nJDK 可视化分析工具 # JConsole:Java 监视与管理控制台 # JConsole 是基于 JMX 的可视化监视、管理工具。可以很方便的监视本地及远程服务器的 java 进程的内存使用情况。你可以在控制台输入jconsole命令启动或者在 JDK 目录下的 bin 目录找到jconsole.exe然后双击启动。\n连接 Jconsole # 如果需要使用 JConsole 连接远程进程,可以在远程 Java 程序启动时加上下面这些参数:\n-Djava.rmi.server.hostname=外网访问 ip 地址 -Dcom.sun.management.jmxremote.port=60001 //监控的端口号 -Dcom.sun.management.jmxremote.authenticate=false //关闭认证 -Dcom.sun.management.jmxremote.ssl=false 在使用 JConsole 连接时,远程进程地址如下:\n外网访问 ip 地址:60001 查看 Java 程序概况 # 内存监控 # JConsole 可以显示当前内存的详细信息。不仅包括堆内存/非堆内存的整体信息,还可以细化到 eden 区、survivor 区等的使用情况,如下图所示。\n点击右边的“执行 GC(G)”按钮可以强制应用程序执行一个 Full GC。\n新生代 GC(Minor GC):指发生新生代的的垃圾收集动作,Minor GC 非常频繁,回收速度一般也比较快。 老年代 GC(Major GC/Full GC):指发生在老年代的 GC,出现了 Major GC 经常会伴随至少一次的 Minor GC(并非绝对),Major GC 的速度一般会比 Minor GC 的慢 10 倍以上。 线程监控 # 类似我们前面讲的 jstack 命令,不过这个是可视化的。\n最下面有一个\u0026quot;检测死锁 (D)\u0026ldquo;按钮,点击这个按钮可以自动为你找到发生死锁的线程以及它们的详细信息 。\nVisual VM:多合一故障处理工具 # VisualVM 提供在 Java 虚拟机 (Java Virtual Machine, JVM) 上运行的 Java 应用程序的详细信息。在 VisualVM 的图形用户界面中,您可以方便、快捷地查看多个 Java 应用程序的相关信息。Visual VM 官网: https://visualvm.github.io/ 。Visual VM 中文文档: https://visualvm.github.io/documentation.html。\n下面这段话摘自《深入理解 Java 虚拟机》。\nVisualVM(All-in-One Java Troubleshooting Tool)是到目前为止随 JDK 发布的功能最强大的运行监视和故障处理程序,官方在 VisualVM 的软件说明中写上了“All-in-One”的描述字样,预示着他除了运行监视、故障处理外,还提供了很多其他方面的功能,如性能分析(Profiling)。VisualVM 的性能分析功能甚至比起 JProfiler、YourKit 等专业且收费的 Profiling 工具都不会逊色多少,而且 VisualVM 还有一个很大的优点:不需要被监视的程序基于特殊 Agent 运行,因此他对应用程序的实际性能的影响很小,使得他可以直接应用在生产环境中。这个优点是 JProfiler、YourKit 等工具无法与之媲美的。\nVisualVM 基于 NetBeans 平台开发,因此他一开始就具备了插件扩展功能的特性,通过插件扩展支持,VisualVM 可以做到:\n显示虚拟机进程以及进程的配置、环境信息(jps、jinfo)。 监视应用程序的 CPU、GC、堆、方法区以及线程的信息(jstat、jstack)。 dump 以及分析堆转储快照(jmap、jhat)。 方法级的程序运行性能分析,找到被调用最多、运行时间最长的方法。 离线程序快照:收集程序的运行时配置、线程 dump、内存 dump 等信息建立一个快照,可以将快照发送开发者处进行 Bug 反馈。 其他 plugins 的无限的可能性…… 这里就不具体介绍 VisualVM 的使用,如果想了解的话可以看:\nhttps://visualvm.github.io/documentation.html https://www.ibm.com/developerworks/cn/java/j-lo-visualvm/index.html MAT:内存分析器工具 # MAT(Memory Analyzer Tool)是一款快速便捷且功能强大丰富的 JVM 堆内存离线分析工具。其通过展现 JVM 异常时所记录的运行时堆转储快照(Heap dump)状态(正常运行时也可以做堆转储分析),帮助定位内存泄漏问题或优化大内存消耗逻辑。\n在遇到 OOM 和 GC 问题的时候,我一般会首选使用 MAT 分析 dump 文件在,这也是该工具应用最多的一个场景。\n关于 MAT 的详细介绍推荐下面这两篇文章,写的很不错:\nJVM 内存分析工具 MAT 的深度讲解与实践—入门篇 JVM 内存分析工具 MAT 的深度讲解与实践—进阶篇 "},{"id":524,"href":"/zh/docs/technology/Interview/java/concurrent/jmm/","title":"JMM(Java 内存模型)详解","section":"Concurrent","content":"JMM(Java 内存模型)主要定义了对于一个共享变量,当另一个线程对这个共享变量执行写操作后,这个线程对这个共享变量的可见性。\n要想理解透彻 JMM(Java 内存模型),我们先要从 CPU 缓存模型和指令重排序 说起!\n从 CPU 缓存模型说起 # 为什么要弄一个 CPU 高速缓存呢? 类比我们开发网站后台系统使用的缓存(比如 Redis)是为了解决程序处理速度和访问常规关系型数据库速度不对等的问题。 CPU 缓存则是为了解决 CPU 处理速度和内存处理速度不对等的问题。\n我们甚至可以把 内存看作外存的高速缓存,程序运行的时候我们把外存的数据复制到内存,由于内存的处理速度远远高于外存,这样提高了处理速度。\n总结:CPU Cache 缓存的是内存数据用于解决 CPU 处理速度和内存不匹配的问题,内存缓存的是硬盘数据用于解决硬盘访问速度过慢的问题。\n为了更好地理解,我画了一个简单的 CPU Cache 示意图如下所示。\n🐛 修正(参见: issue#1848):对 CPU 缓存模型绘图不严谨的地方进行完善。\n现代的 CPU Cache 通常分为三层,分别叫 L1,L2,L3 Cache。有些 CPU 可能还有 L4 Cache,这里不做讨论,并不常见\nCPU Cache 的工作方式: 先复制一份数据到 CPU Cache 中,当 CPU 需要用到的时候就可以直接从 CPU Cache 中读取数据,当运算完成后,再将运算得到的数据写回 Main Memory 中。但是,这样存在 内存缓存不一致性的问题 !比如我执行一个 i++ 操作的话,如果两个线程同时执行的话,假设两个线程从 CPU Cache 中读取的 i=1,两个线程做了 i++ 运算完之后再写回 Main Memory 之后 i=2,而正确结果应该是 i=3。\nCPU 为了解决内存缓存不一致性问题可以通过制定缓存一致协议(比如 MESI 协议)或者其他手段来解决。 这个缓存一致性协议指的是在 CPU 高速缓存与主内存交互的时候需要遵守的原则和规范。不同的 CPU 中,使用的缓存一致性协议通常也会有所不同。\n我们的程序运行在操作系统之上,操作系统屏蔽了底层硬件的操作细节,将各种硬件资源虚拟化。于是,操作系统也就同样需要解决内存缓存不一致性问题。\n操作系统通过 内存模型(Memory Model) 定义一系列规范来解决这个问题。无论是 Windows 系统,还是 Linux 系统,它们都有特定的内存模型。\n指令重排序 # 说完了 CPU 缓存模型,我们再来看看另外一个比较重要的概念 指令重排序 。\n为了提升执行速度/性能,计算机在执行程序代码的时候,会对指令进行重排序。\n什么是指令重排序? 简单来说就是系统在执行代码的时候并不一定是按照你写的代码的顺序依次执行。\n常见的指令重排序有下面 2 种情况:\n编译器优化重排:编译器(包括 JVM、JIT 编译器等)在不改变单线程程序语义的前提下,重新安排语句的执行顺序。 指令并行重排:现代处理器采用了指令级并行技术(Instruction-Level Parallelism,ILP)来将多条指令重叠执行。如果不存在数据依赖性,处理器可以改变语句对应机器指令的执行顺序。 另外,内存系统也会有“重排序”,但又不是真正意义上的重排序。在 JMM 里表现为主存和本地内存的内容可能不一致,进而导致程序在多线程下执行可能出现问题。\nJava 源代码会经历 编译器优化重排 —\u0026gt; 指令并行重排 —\u0026gt; 内存系统重排 的过程,最终才变成操作系统可执行的指令序列。\n指令重排序可以保证串行语义一致,但是没有义务保证多线程间的语义也一致 ,所以在多线程下,指令重排序可能会导致一些问题。\n对于编译器优化重排和处理器的指令重排序(指令并行重排和内存系统重排都属于是处理器级别的指令重排序),处理该问题的方式不一样。\n对于编译器,通过禁止特定类型的编译器重排序的方式来禁止重排序。\n对于处理器,通过插入内存屏障(Memory Barrier,或有时叫做内存栅栏,Memory Fence)的方式来禁止特定类型的处理器重排序。\n内存屏障(Memory Barrier,或有时叫做内存栅栏,Memory Fence)是一种 CPU 指令,用来禁止处理器指令发生重排序(像屏障一样),从而保障指令执行的有序性。另外,为了达到屏障的效果,它也会使处理器写入、读取值之前,将主内存的值写入高速缓存,清空无效队列,从而保障变量的可见性。\nJMM(Java Memory Model) # 什么是 JMM?为什么需要 JMM? # Java 是最早尝试提供内存模型的编程语言。由于早期内存模型存在一些缺陷(比如非常容易削弱编译器的优化能力),从 Java5 开始,Java 开始使用新的内存模型 《JSR-133:Java Memory Model and Thread Specification》 。\n一般来说,编程语言也可以直接复用操作系统层面的内存模型。不过,不同的操作系统内存模型不同。如果直接复用操作系统层面的内存模型,就可能会导致同样一套代码换了一个操作系统就无法执行了。Java 语言是跨平台的,它需要自己提供一套内存模型以屏蔽系统差异。\n这只是 JMM 存在的其中一个原因。实际上,对于 Java 来说,你可以把 JMM 看作是 Java 定义的并发编程相关的一组规范,除了抽象了线程和主内存之间的关系之外,其还规定了从 Java 源代码到 CPU 可执行指令的这个转化过程要遵守哪些和并发相关的原则和规范,其主要目的是为了简化多线程编程,增强程序可移植性的。\n为什么要遵守这些并发相关的原则和规范呢? 这是因为并发编程下,像 CPU 多级缓存和指令重排这类设计可能会导致程序运行出现一些问题。就比如说我们上面提到的指令重排序就可能会让多线程程序的执行出现问题,为此,JMM 抽象了 happens-before 原则(后文会详细介绍到)来解决这个指令重排序问题。\nJMM 说白了就是定义了一些规范来解决这些问题,开发者可以利用这些规范更方便地开发多线程程序。对于 Java 开发者说,你不需要了解底层原理,直接使用并发相关的一些关键字和类(比如 volatile、synchronized、各种 Lock)即可开发出并发安全的程序。\nJMM 是如何抽象线程和主内存之间的关系? # Java 内存模型(JMM) 抽象了线程和主内存之间的关系,就比如说线程之间的共享变量必须存储在主内存中。\n在 JDK1.2 之前,Java 的内存模型实现总是从 主存 (即共享内存)读取变量,是不需要进行特别的注意的。而在当前的 Java 内存模型下,线程可以把变量保存 本地内存 (比如机器的寄存器)中,而不是直接在主存中进行读写。这就可能造成一个线程在主存中修改了一个变量的值,而另外一个线程还继续使用它在寄存器中的变量值的拷贝,造成数据的不一致。\n这和我们上面讲到的 CPU 缓存模型非常相似。\n什么是主内存?什么是本地内存?\n主内存:所有线程创建的实例对象都存放在主内存中,不管该实例对象是成员变量,还是局部变量,类信息、常量、静态变量都是放在主内存中。为了获取更好的运行速度,虚拟机及硬件系统可能会让工作内存优先存储于寄存器和高速缓存中。 本地内存:每个线程都有一个私有的本地内存,本地内存存储了该线程以读 / 写共享变量的副本。每个线程只能操作自己本地内存中的变量,无法直接访问其他线程的本地内存。如果线程间需要通信,必须通过主内存来进行。本地内存是 JMM 抽象出来的一个概念,并不真实存在,它涵盖了缓存、写缓冲区、寄存器以及其他的硬件和编译器优化。 Java 内存模型的抽象示意图如下:\n从上图来看,线程 1 与线程 2 之间如果要进行通信的话,必须要经历下面 2 个步骤:\n线程 1 把本地内存中修改过的共享变量副本的值同步到主内存中去。 线程 2 到主存中读取对应的共享变量的值。 也就是说,JMM 为共享变量提供了可见性的保障。\n不过,多线程下,对主内存中的一个共享变量进行操作有可能诱发线程安全问题。举个例子:\n线程 1 和线程 2 分别对同一个共享变量进行操作,一个执行修改,一个执行读取。 线程 2 读取到的是线程 1 修改之前的值还是修改后的值并不确定,都有可能,因为线程 1 和线程 2 都是先将共享变量从主内存拷贝到对应线程的工作内存中。 关于主内存与工作内存直接的具体交互协议,即一个变量如何从主内存拷贝到工作内存,如何从工作内存同步到主内存之间的实现细节,Java 内存模型定义来以下八种同步操作(了解即可,无需死记硬背):\n锁定(lock): 作用于主内存中的变量,将他标记为一个线程独享变量。 解锁(unlock): 作用于主内存中的变量,解除变量的锁定状态,被解除锁定状态的变量才能被其他线程锁定。 read(读取):作用于主内存的变量,它把一个变量的值从主内存传输到线程的工作内存中,以便随后的 load 动作使用。 load(载入):把 read 操作从主内存中得到的变量值放入工作内存的变量的副本中。 use(使用):把工作内存中的一个变量的值传给执行引擎,每当虚拟机遇到一个使用到变量的指令时都会使用该指令。 assign(赋值):作用于工作内存的变量,它把一个从执行引擎接收到的值赋给工作内存的变量,每当虚拟机遇到一个给变量赋值的字节码指令时执行这个操作。 store(存储):作用于工作内存的变量,它把工作内存中一个变量的值传送到主内存中,以便随后的 write 操作使用。 write(写入):作用于主内存的变量,它把 store 操作从工作内存中得到的变量的值放入主内存的变量中。 除了这 8 种同步操作之外,还规定了下面这些同步规则来保证这些同步操作的正确执行(了解即可,无需死记硬背):\n不允许一个线程无原因地(没有发生过任何 assign 操作)把数据从线程的工作内存同步回主内存中。 一个新的变量只能在主内存中 “诞生”,不允许在工作内存中直接使用一个未被初始化(load 或 assign)的变量,换句话说就是对一个变量实施 use 和 store 操作之前,必须先执行过了 assign 和 load 操作。 一个变量在同一个时刻只允许一条线程对其进行 lock 操作,但 lock 操作可以被同一条线程重复执行多次,多次执行 lock 后,只有执行相同次数的 unlock 操作,变量才会被解锁。 如果对一个变量执行 lock 操作,将会清空工作内存中此变量的值,在执行引擎使用这个变量前,需要重新执行 load 或 assign 操作初始化变量的值。 如果一个变量事先没有被 lock 操作锁定,则不允许对它执行 unlock 操作,也不允许去 unlock 一个被其他线程锁定住的变量。 …… Java 内存区域和 JMM 有何区别? # 这是一个比较常见的问题,很多初学者非常容易搞混。 Java 内存区域和内存模型是完全不一样的两个东西:\nJVM 内存结构和 Java 虚拟机的运行时区域相关,定义了 JVM 在运行时如何分区存储程序数据,就比如说堆主要用于存放对象实例。 Java 内存模型和 Java 的并发编程相关,抽象了线程和主内存之间的关系就比如说线程之间的共享变量必须存储在主内存中,规定了从 Java 源代码到 CPU 可执行指令的这个转化过程要遵守哪些和并发相关的原则和规范,其主要目的是为了简化多线程编程,增强程序可移植性的。 happens-before 原则是什么? # happens-before 这个概念最早诞生于 Leslie Lamport 于 1978 年发表的论文 《Time,Clocks and the Ordering of Events in a Distributed System》。在这篇论文中,Leslie Lamport 提出了 逻辑时钟的概念,这也成了第一个逻辑时钟算法 。在分布式环境中,通过一系列规则来定义逻辑时钟的变化,从而能通过逻辑时钟来对分布式系统中的事件的先后顺序进行判断。逻辑时钟并不度量时间本身,仅区分事件发生的前后顺序,其本质就是定义了一种 happens-before 关系。\n上面提到的 happens-before 这个概念诞生的背景并不是重点,简单了解即可。\nJSR 133 引入了 happens-before 这个概念来描述两个操作之间的内存可见性。\n为什么需要 happens-before 原则? happens-before 原则的诞生是为了程序员和编译器、处理器之间的平衡。程序员追求的是易于理解和编程的强内存模型,遵守既定规则编码即可。编译器和处理器追求的是较少约束的弱内存模型,让它们尽己所能地去优化性能,让性能最大化。happens-before 原则的设计思想其实非常简单:\n为了对编译器和处理器的约束尽可能少,只要不改变程序的执行结果(单线程程序和正确执行的多线程程序),编译器和处理器怎么进行重排序优化都行。 对于会改变程序执行结果的重排序,JMM 要求编译器和处理器必须禁止这种重排序。 下面这张是 《Java 并发编程的艺术》这本书中的一张 JMM 设计思想的示意图,非常清晰。\n了解了 happens-before 原则的设计思想,我们再来看看 JSR-133 对 happens-before 原则的定义:\n如果一个操作 happens-before 另一个操作,那么第一个操作的执行结果将对第二个操作可见,并且第一个操作的执行顺序排在第二个操作之前。 两个操作之间存在 happens-before 关系,并不意味着 Java 平台的具体实现必须要按照 happens-before 关系指定的顺序来执行。如果重排序之后的执行结果,与按 happens-before 关系来执行的结果一致,那么 JMM 也允许这样的重排序。 我们看下面这段代码:\nint userNum = getUserNum(); // 1 int teacherNum = getTeacherNum(); // 2 int totalNum = userNum + teacherNum; // 3 1 happens-before 2 2 happens-before 3 1 happens-before 3 虽然 1 happens-before 2,但对 1 和 2 进行重排序不会影响代码的执行结果,所以 JMM 是允许编译器和处理器执行这种重排序的。但 1 和 2 必须是在 3 执行之前,也就是说 1,2 happens-before 3 。\nhappens-before 原则表达的意义其实并不是一个操作发生在另外一个操作的前面,虽然这从程序员的角度上来说也并无大碍。更准确地来说,它更想表达的意义是前一个操作的结果对于后一个操作是可见的,无论这两个操作是否在同一个线程里。\n举个例子:操作 1 happens-before 操作 2,即使操作 1 和操作 2 不在同一个线程内,JMM 也会保证操作 1 的结果对操作 2 是可见的。\nhappens-before 常见规则有哪些?谈谈你的理解? # happens-before 的规则就 8 条,说多不多,重点了解下面列举的 5 条即可。全记是不可能的,很快就忘记了,意义不大,随时查阅即可。\n程序顺序规则:一个线程内,按照代码顺序,书写在前面的操作 happens-before 于书写在后面的操作; 解锁规则:解锁 happens-before 于加锁; volatile 变量规则:对一个 volatile 变量的写操作 happens-before 于后面对这个 volatile 变量的读操作。说白了就是对 volatile 变量的写操作的结果对于发生于其后的任何操作都是可见的。 传递规则:如果 A happens-before B,且 B happens-before C,那么 A happens-before C; 线程启动规则:Thread 对象的 start()方法 happens-before 于此线程的每一个动作。 如果两个操作不满足上述任意一个 happens-before 规则,那么这两个操作就没有顺序的保障,JVM 可以对这两个操作进行重排序。\nhappens-before 和 JMM 什么关系? # happens-before 与 JMM 的关系用《Java 并发编程的艺术》这本书中的一张图就可以非常好的解释清楚。\n再看并发编程三个重要特性 # 原子性 # 一次操作或者多次操作,要么所有的操作全部都得到执行并且不会受到任何因素的干扰而中断,要么都不执行。\n在 Java 中,可以借助synchronized、各种 Lock 以及各种原子类实现原子性。\nsynchronized 和各种 Lock 可以保证任一时刻只有一个线程访问该代码块,因此可以保障原子性。各种原子类是利用 CAS (compare and swap) 操作(可能也会用到 volatile或者final关键字)来保证原子操作。\n可见性 # 当一个线程对共享变量进行了修改,那么另外的线程都是立即可以看到修改后的最新值。\n在 Java 中,可以借助synchronized、volatile 以及各种 Lock 实现可见性。\n如果我们将变量声明为 volatile ,这就指示 JVM,这个变量是共享且不稳定的,每次使用它都到主存中进行读取。\n有序性 # 由于指令重排序问题,代码的执行顺序未必就是编写代码时候的顺序。\n我们上面讲重排序的时候也提到过:\n指令重排序可以保证串行语义一致,但是没有义务保证多线程间的语义也一致 ,所以在多线程下,指令重排序可能会导致一些问题。\n在 Java 中,volatile 关键字可以禁止指令进行重排序优化。\n总结 # Java 是最早尝试提供内存模型的语言,其主要目的是为了简化多线程编程,增强程序可移植性的。 CPU 可以通过制定缓存一致协议(比如 MESI 协议)来解决内存缓存不一致性问题。 为了提升执行速度/性能,计算机在执行程序代码的时候,会对指令进行重排序。 简单来说就是系统在执行代码的时候并不一定是按照你写的代码的顺序依次执行。指令重排序可以保证串行语义一致,但是没有义务保证多线程间的语义也一致 ,所以在多线程下,指令重排序可能会导致一些问题。 你可以把 JMM 看作是 Java 定义的并发编程相关的一组规范,除了抽象了线程和主内存之间的关系之外,其还规定了从 Java 源代码到 CPU 可执行指令的这个转化过程要遵守哪些和并发相关的原则和规范,其主要目的是为了简化多线程编程,增强程序可移植性的。 JSR 133 引入了 happens-before 这个概念来描述两个操作之间的内存可见性。 参考 # 《Java 并发编程的艺术》第三章 Java 内存模型 《深入浅出 Java 多线程》: http://concurrent.redspider.group/RedSpider.html Java 内存访问重排序的研究: https://tech.meituan.com/2014/09/23/java-memory-reordering.html 嘿,同学,你要的 Java 内存模型 (JMM) 来了: https://xie.infoq.cn/article/739920a92d0d27e2053174ef2 JSR 133 (Java Memory Model) FAQ: https://www.cs.umd.edu/~pugh/java/memoryModel/jsr-133-faq.html "},{"id":525,"href":"/zh/docs/technology/Interview/java/jvm/jvm-garbage-collection/","title":"JVM垃圾回收详解(重点)","section":"Jvm","content":" 如果没有特殊说明,都是针对的是 HotSpot 虚拟机。\n本文基于《深入理解 Java 虚拟机:JVM 高级特性与最佳实践》进行总结补充。\n常见面试题:\n如何判断对象是否死亡(两种方法)。 简单的介绍一下强引用、软引用、弱引用、虚引用(虚引用与软引用和弱引用的区别、使用软引用能带来的好处)。 如何判断一个常量是废弃常量 如何判断一个类是无用的类 垃圾收集有哪些算法,各自的特点? HotSpot 为什么要分为新生代和老年代? 常见的垃圾回收器有哪些? 介绍一下 CMS,G1 收集器。 Minor Gc 和 Full GC 有什么不同呢? 前言 # 当需要排查各种内存溢出问题、当垃圾收集成为系统达到更高并发的瓶颈时,我们就需要对这些“自动化”的技术实施必要的监控和调节。\n堆空间的基本结构 # Java 的自动内存管理主要是针对对象内存的回收和对象内存的分配。同时,Java 自动内存管理最核心的功能是 堆 内存中对象的分配与回收。\nJava 堆是垃圾收集器管理的主要区域,因此也被称作 GC 堆(Garbage Collected Heap)。\n从垃圾回收的角度来说,由于现在收集器基本都采用分代垃圾收集算法,所以 Java 堆被划分为了几个不同的区域,这样我们就可以根据各个区域的特点选择合适的垃圾收集算法。\n在 JDK 7 版本及 JDK 7 版本之前,堆内存被通常分为下面三部分:\n新生代内存(Young Generation) 老生代(Old Generation) 永久代(Permanent Generation) 下图所示的 Eden 区、两个 Survivor 区 S0 和 S1 都属于新生代,中间一层属于老年代,最下面一层属于永久代。\nJDK 8 版本之后 PermGen(永久) 已被 Metaspace(元空间) 取代,元空间使用的是直接内存 。\n关于堆空间结构更详细的介绍,可以回过头看看 Java 内存区域详解 这篇文章。\n内存分配和回收原则 # 对象优先在 Eden 区分配 # 大多数情况下,对象在新生代中 Eden 区分配。当 Eden 区没有足够空间进行分配时,虚拟机将发起一次 Minor GC。下面我们来进行实际测试一下。\n测试代码:\npublic class GCTest { public static void main(String[] args) { byte[] allocation1, allocation2; allocation1 = new byte[30900*1024]; } } 通过以下方式运行: 添加的参数:-XX:+PrintGCDetails 运行结果 (红色字体描述有误,应该是对应于 JDK1.7 的永久代):\n从上图我们可以看出 Eden 区内存几乎已经被分配完全(即使程序什么也不做,新生代也会使用 2000 多 k 内存)。\n假如我们再为 allocation2 分配内存会出现什么情况呢?\nallocation2 = new byte[900*1024]; 给 allocation2 分配内存的时候 Eden 区内存几乎已经被分配完了\n当 Eden 区没有足够空间进行分配时,虚拟机将发起一次 Minor GC。GC 期间虚拟机又发现 allocation1 无法存入 Survivor 空间,所以只好通过 分配担保机制 把新生代的对象提前转移到老年代中去,老年代上的空间足够存放 allocation1,所以不会出现 Full GC。执行 Minor GC 后,后面分配的对象如果能够存在 Eden 区的话,还是会在 Eden 区分配内存。可以执行如下代码验证:\npublic class GCTest { public static void main(String[] args) { byte[] allocation1, allocation2,allocation3,allocation4,allocation5; allocation1 = new byte[32000*1024]; allocation2 = new byte[1000*1024]; allocation3 = new byte[1000*1024]; allocation4 = new byte[1000*1024]; allocation5 = new byte[1000*1024]; } } 大对象直接进入老年代 # 大对象就是需要大量连续内存空间的对象(比如:字符串、数组)。\n大对象直接进入老年代的行为是由虚拟机动态决定的,它与具体使用的垃圾回收器和相关参数有关。大对象直接进入老年代是一种优化策略,旨在避免将大对象放入新生代,从而减少新生代的垃圾回收频率和成本。\nG1 垃圾回收器会根据 -XX:G1HeapRegionSize 参数设置的堆区域大小和 -XX:G1MixedGCLiveThresholdPercent 参数设置的阈值,来决定哪些对象会直接进入老年代。 Parallel Scavenge 垃圾回收器中,默认情况下,并没有一个固定的阈值(XX:ThresholdTolerance是动态调整的)来决定何时直接在老年代分配大对象。而是由虚拟机根据当前的堆内存情况和历史数据动态决定。 长期存活的对象将进入老年代 # 既然虚拟机采用了分代收集的思想来管理内存,那么内存回收时就必须能识别哪些对象应放在新生代,哪些对象应放在老年代中。为了做到这一点,虚拟机给每个对象一个对象年龄(Age)计数器。\n大部分情况,对象都会首先在 Eden 区域分配。如果对象在 Eden 出生并经过第一次 Minor GC 后仍然能够存活,并且能被 Survivor 容纳的话,将被移动到 Survivor 空间(s0 或者 s1)中,并将对象年龄设为 1(Eden 区-\u0026gt;Survivor 区后对象的初始年龄变为 1)。\n对象在 Survivor 中每熬过一次 MinorGC,年龄就增加 1 岁,当它的年龄增加到一定程度(默认为 15 岁),就会被晋升到老年代中。对象晋升到老年代的年龄阈值,可以通过参数 -XX:MaxTenuringThreshold 来设置。\n修正( issue552):“Hotspot 遍历所有对象时,按照年龄从小到大对其所占用的大小进行累积,当累积的某个年龄大小超过了 survivor 区的 50% 时(默认值是 50%,可以通过 -XX:TargetSurvivorRatio=percent 来设置,参见 issue1199 ),取这个年龄和 MaxTenuringThreshold 中更小的一个值,作为新的晋升年龄阈值”。\njdk8 官方文档引用: https://docs.oracle.com/javase/8/docs/technotes/tools/unix/java.html。\n动态年龄计算的代码如下:\nuint ageTable::compute_tenuring_threshold(size_t survivor_capacity) { //survivor_capacity是survivor空间的大小 size_t desired_survivor_size = (size_t)((((double)survivor_capacity)*TargetSurvivorRatio)/100); size_t total = 0; uint age = 1; while (age \u0026lt; table_size) { //sizes数组是每个年龄段对象大小 total += sizes[age]; if (total \u0026gt; desired_survivor_size) { break; } age++; } uint result = age \u0026lt; MaxTenuringThreshold ? age : MaxTenuringThreshold; ... } 额外补充说明( issue672):关于默认的晋升年龄是 15,这个说法的来源大部分都是《深入理解 Java 虚拟机》这本书。 如果你去 Oracle 的官网阅读 相关的虚拟机参数,你会发现-XX:MaxTenuringThreshold=threshold这里有个说明\nSets the maximum tenuring threshold for use in adaptive GC sizing. The largest value is 15. The default value is 15 for the parallel (throughput) collector, and 6 for the CMS collector.默认晋升年龄并不都是 15,这个是要区分垃圾收集器的,CMS 就是 6.\n主要进行 gc 的区域 # 周志明先生在《深入理解 Java 虚拟机》第二版中 P92 如是写道:\n“老年代 GC(Major GC/Full GC),指发生在老年代的 GC……”\n上面的说法已经在《深入理解 Java 虚拟机》第三版中被改正过来了。感谢 R 大的回答:\n总结:\n针对 HotSpot VM 的实现,它里面的 GC 其实准确分类只有两大种:\n部分收集 (Partial GC):\n新生代收集(Minor GC / Young GC):只对新生代进行垃圾收集; 老年代收集(Major GC / Old GC):只对老年代进行垃圾收集。需要注意的是 Major GC 在有的语境中也用于指代整堆收集; 混合收集(Mixed GC):对整个新生代和部分老年代进行垃圾收集。 整堆收集 (Full GC):收集整个 Java 堆和方法区。\n空间分配担保 # 空间分配担保是为了确保在 Minor GC 之前老年代本身还有容纳新生代所有对象的剩余空间。\n《深入理解 Java 虚拟机》第三章对于空间分配担保的描述如下:\nJDK 6 Update 24 之前,在发生 Minor GC 之前,虚拟机必须先检查老年代最大可用的连续空间是否大于新生代所有对象总空间,如果这个条件成立,那这一次 Minor GC 可以确保是安全的。如果不成立,则虚拟机会先查看 -XX:HandlePromotionFailure 参数的设置值是否允许担保失败(Handle Promotion Failure);如果允许,那会继续检查老年代最大可用的连续空间是否大于历次晋升到老年代对象的平均大小,如果大于,将尝试进行一次 Minor GC,尽管这次 Minor GC 是有风险的;如果小于,或者 -XX: HandlePromotionFailure 设置不允许冒险,那这时就要改为进行一次 Full GC。\nJDK 6 Update 24 之后的规则变为只要老年代的连续空间大于新生代对象总大小或者历次晋升的平均大小,就会进行 Minor GC,否则将进行 Full GC。\n死亡对象判断方法 # 堆中几乎放着所有的对象实例,对堆垃圾回收前的第一步就是要判断哪些对象已经死亡(即不能再被任何途径使用的对象)。\n引用计数法 # 给对象中添加一个引用计数器:\n每当有一个地方引用它,计数器就加 1; 当引用失效,计数器就减 1; 任何时候计数器为 0 的对象就是不可能再被使用的。 这个方法实现简单,效率高,但是目前主流的虚拟机中并没有选择这个算法来管理内存,其最主要的原因是它很难解决对象之间循环引用的问题。\n所谓对象之间的相互引用问题,如下面代码所示:除了对象 objA 和 objB 相互引用着对方之外,这两个对象之间再无任何引用。但是他们因为互相引用对方,导致它们的引用计数器都不为 0,于是引用计数算法无法通知 GC 回收器回收他们。\npublic class ReferenceCountingGc { Object instance = null; public static void main(String[] args) { ReferenceCountingGc objA = new ReferenceCountingGc(); ReferenceCountingGc objB = new ReferenceCountingGc(); objA.instance = objB; objB.instance = objA; objA = null; objB = null; } } 可达性分析算法 # 这个算法的基本思想就是通过一系列的称为 “GC Roots” 的对象作为起点,从这些节点开始向下搜索,节点所走过的路径称为引用链,当一个对象到 GC Roots 没有任何引用链相连的话,则证明此对象是不可用的,需要被回收。\n下图中的 Object 6 ~ Object 10 之间虽有引用关系,但它们到 GC Roots 不可达,因此为需要被回收的对象。\n哪些对象可以作为 GC Roots 呢?\n虚拟机栈(栈帧中的局部变量表)中引用的对象 本地方法栈(Native 方法)中引用的对象 方法区中类静态属性引用的对象 方法区中常量引用的对象 所有被同步锁持有的对象 JNI(Java Native Interface)引用的对象 对象可以被回收,就代表一定会被回收吗?\n即使在可达性分析法中不可达的对象,也并非是“非死不可”的,这时候它们暂时处于“缓刑阶段”,要真正宣告一个对象死亡,至少要经历两次标记过程;可达性分析法中不可达的对象被第一次标记并且进行一次筛选,筛选的条件是此对象是否有必要执行 finalize 方法。当对象没有覆盖 finalize 方法,或 finalize 方法已经被虚拟机调用过时,虚拟机将这两种情况视为没有必要执行。\n被判定为需要执行的对象将会被放在一个队列中进行第二次标记,除非这个对象与引用链上的任何一个对象建立关联,否则就会被真的回收。\nObject 类中的 finalize 方法一直被认为是一个糟糕的设计,成为了 Java 语言的负担,影响了 Java 语言的安全和 GC 的性能。JDK9 版本及后续版本中各个类中的 finalize 方法会被逐渐弃用移除。忘掉它的存在吧!\n参考:\nJEP 421: Deprecate Finalization for Removal 是时候忘掉 finalize 方法了 引用类型总结 # 无论是通过引用计数法判断对象引用数量,还是通过可达性分析法判断对象的引用链是否可达,判定对象的存活都与“引用”有关。\nJDK1.2 之前,Java 中引用的定义很传统:如果 reference 类型的数据存储的数值代表的是另一块内存的起始地址,就称这块内存代表一个引用。\nJDK1.2 以后,Java 对引用的概念进行了扩充,将引用分为强引用、软引用、弱引用、虚引用四种(引用强度逐渐减弱)\n1.强引用(StrongReference)\n以前我们使用的大部分引用实际上都是强引用,这是使用最普遍的引用。如果一个对象具有强引用,那就类似于必不可少的生活用品,垃圾回收器绝不会回收它。当内存空间不足,Java 虚拟机宁愿抛出 OutOfMemoryError 错误,使程序异常终止,也不会靠随意回收具有强引用的对象来解决内存不足问题。\n2.软引用(SoftReference)\n如果一个对象只具有软引用,那就类似于可有可无的生活用品。如果内存空间足够,垃圾回收器就不会回收它,如果内存空间不足了,就会回收这些对象的内存。只要垃圾回收器没有回收它,该对象就可以被程序使用。软引用可用来实现内存敏感的高速缓存。\n软引用可以和一个引用队列(ReferenceQueue)联合使用,如果软引用所引用的对象被垃圾回收,JAVA 虚拟机就会把这个软引用加入到与之关联的引用队列中。\n3.弱引用(WeakReference)\n如果一个对象只具有弱引用,那就类似于可有可无的生活用品。弱引用与软引用的区别在于:只具有弱引用的对象拥有更短暂的生命周期。在垃圾回收器线程扫描它所管辖的内存区域的过程中,一旦发现了只具有弱引用的对象,不管当前内存空间足够与否,都会回收它的内存。不过,由于垃圾回收器是一个优先级很低的线程, 因此不一定会很快发现那些只具有弱引用的对象。\n弱引用可以和一个引用队列(ReferenceQueue)联合使用,如果弱引用所引用的对象被垃圾回收,Java 虚拟机就会把这个弱引用加入到与之关联的引用队列中。\n4.虚引用(PhantomReference)\n\u0026ldquo;虚引用\u0026quot;顾名思义,就是形同虚设,与其他几种引用都不同,虚引用并不会决定对象的生命周期。如果一个对象仅持有虚引用,那么它就和没有任何引用一样,在任何时候都可能被垃圾回收。\n虚引用主要用来跟踪对象被垃圾回收的活动。\n虚引用与软引用和弱引用的一个区别在于: 虚引用必须和引用队列(ReferenceQueue)联合使用。当垃圾回收器准备回收一个对象时,如果发现它还有虚引用,就会在回收对象的内存之前,把这个虚引用加入到与之关联的引用队列中。程序可以通过判断引用队列中是否已经加入了虚引用,来了解被引用的对象是否将要被垃圾回收。程序如果发现某个虚引用已经被加入到引用队列,那么就可以在所引用的对象的内存被回收之前采取必要的行动。\n特别注意,在程序设计中一般很少使用弱引用与虚引用,使用软引用的情况较多,这是因为软引用可以加速 JVM 对垃圾内存的回收速度,可以维护系统的运行安全,防止内存溢出(OutOfMemory)等问题的产生。\n如何判断一个常量是废弃常量? # 运行时常量池主要回收的是废弃的常量。那么,我们如何判断一个常量是废弃常量呢?\nJDK1.7 及之后版本的 JVM 已经将运行时常量池从方法区中移了出来,在 Java 堆(Heap)中开辟了一块区域存放运行时常量池。\n🐛 修正(参见: issue747, reference):\nJDK1.7 之前运行时常量池逻辑包含字符串常量池存放在方法区, 此时 hotspot 虚拟机对方法区的实现为永久代 JDK1.7 字符串常量池被从方法区拿到了堆中, 这里没有提到运行时常量池,也就是说字符串常量池被单独拿到堆,运行时常量池剩下的东西还在方法区, 也就是 hotspot 中的永久代 。 JDK1.8 hotspot 移除了永久代用元空间(Metaspace)取而代之, 这时候字符串常量池还在堆, 运行时常量池还在方法区, 只不过方法区的实现从永久代变成了元空间(Metaspace) 假如在字符串常量池中存在字符串 \u0026ldquo;abc\u0026rdquo;,如果当前没有任何 String 对象引用该字符串常量的话,就说明常量 \u0026ldquo;abc\u0026rdquo; 就是废弃常量,如果这时发生内存回收的话而且有必要的话,\u0026ldquo;abc\u0026rdquo; 就会被系统清理出常量池了。\n如何判断一个类是无用的类? # 方法区主要回收的是无用的类,那么如何判断一个类是无用的类的呢?\n判定一个常量是否是“废弃常量”比较简单,而要判定一个类是否是“无用的类”的条件则相对苛刻许多。类需要同时满足下面 3 个条件才能算是 “无用的类”:\n该类所有的实例都已经被回收,也就是 Java 堆中不存在该类的任何实例。 加载该类的 ClassLoader 已经被回收。 该类对应的 java.lang.Class 对象没有在任何地方被引用,无法在任何地方通过反射访问该类的方法。 虚拟机可以对满足上述 3 个条件的无用类进行回收,这里说的仅仅是“可以”,而并不是和对象一样不使用了就会必然被回收。\n垃圾收集算法 # 标记-清除算法 # 标记-清除(Mark-and-Sweep)算法分为“标记(Mark)”和“清除(Sweep)”阶段:首先标记出所有不需要回收的对象,在标记完成后统一回收掉所有没有被标记的对象。\n它是最基础的收集算法,后续的算法都是对其不足进行改进得到。这种垃圾收集算法会带来两个明显的问题:\n效率问题:标记和清除两个过程效率都不高。 空间问题:标记清除后会产生大量不连续的内存碎片。 关于具体是标记可回收对象(不可达对象)还是不可回收对象(可达对象),众说纷纭,两种说法其实都没问题,我个人更倾向于是后者。\n如果按照前者的理解,整个标记-清除过程大致是这样的:\n当一个对象被创建时,给一个标记位,假设为 0 (false); 在标记阶段,我们将所有可达对象(或用户可以引用的对象)的标记位设置为 1 (true); 扫描阶段清除的就是标记位为 0 (false)的对象。 复制算法 # 为了解决标记-清除算法的效率和内存碎片问题,复制(Copying)收集算法出现了。它可以将内存分为大小相同的两块,每次使用其中的一块。当这一块的内存使用完后,就将还存活的对象复制到另一块去,然后再把使用的空间一次清理掉。这样就使每次的内存回收都是对内存区间的一半进行回收。\n虽然改进了标记-清除算法,但依然存在下面这些问题:\n可用内存变小:可用内存缩小为原来的一半。 不适合老年代:如果存活对象数量比较大,复制性能会变得很差。 标记-整理算法 # 标记-整理(Mark-and-Compact)算法是根据老年代的特点提出的一种标记算法,标记过程仍然与“标记-清除”算法一样,但后续步骤不是直接对可回收对象回收,而是让所有存活的对象向一端移动,然后直接清理掉端边界以外的内存。\n由于多了整理这一步,因此效率也不高,适合老年代这种垃圾回收频率不是很高的场景。\n分代收集算法 # 当前虚拟机的垃圾收集都采用分代收集算法,这种算法没有什么新的思想,只是根据对象存活周期的不同将内存分为几块。一般将 Java 堆分为新生代和老年代,这样我们就可以根据各个年代的特点选择合适的垃圾收集算法。\n比如在新生代中,每次收集都会有大量对象死去,所以可以选择“复制”算法,只需要付出少量对象的复制成本就可以完成每次垃圾收集。而老年代的对象存活几率是比较高的,而且没有额外的空间对它进行分配担保,所以我们必须选择“标记-清除”或“标记-整理”算法进行垃圾收集。\n延伸面试问题: HotSpot 为什么要分为新生代和老年代?\n根据上面的对分代收集算法的介绍回答。\n垃圾收集器 # 如果说收集算法是内存回收的方法论,那么垃圾收集器就是内存回收的具体实现。\n虽然我们对各个收集器进行比较,但并非要挑选出一个最好的收集器。因为直到现在为止还没有最好的垃圾收集器出现,更加没有万能的垃圾收集器,我们能做的就是根据具体应用场景选择适合自己的垃圾收集器。试想一下:如果有一种四海之内、任何场景下都适用的完美收集器存在,那么我们的 HotSpot 虚拟机就不会实现那么多不同的垃圾收集器了。\nJDK 默认垃圾收集器(使用 java -XX:+PrintCommandLineFlags -version 命令查看):\nJDK 8: Parallel Scavenge(新生代)+ Parallel Old(老年代) JDK 9 ~ JDK22: G1 Serial 收集器 # Serial(串行)收集器是最基本、历史最悠久的垃圾收集器了。大家看名字就知道这个收集器是一个单线程收集器了。它的 “单线程” 的意义不仅仅意味着它只会使用一条垃圾收集线程去完成垃圾收集工作,更重要的是它在进行垃圾收集工作的时候必须暂停其他所有的工作线程( \u0026ldquo;Stop The World\u0026rdquo; ),直到它收集结束。\n新生代采用标记-复制算法,老年代采用标记-整理算法。\n虚拟机的设计者们当然知道 Stop The World 带来的不良用户体验,所以在后续的垃圾收集器设计中停顿时间在不断缩短(仍然还有停顿,寻找最优秀的垃圾收集器的过程仍然在继续)。\n但是 Serial 收集器有没有优于其他垃圾收集器的地方呢?当然有,它简单而高效(与其他收集器的单线程相比)。Serial 收集器由于没有线程交互的开销,自然可以获得很高的单线程收集效率。Serial 收集器对于运行在 Client 模式下的虚拟机来说是个不错的选择。\nParNew 收集器 # ParNew 收集器其实就是 Serial 收集器的多线程版本,除了使用多线程进行垃圾收集外,其余行为(控制参数、收集算法、回收策略等等)和 Serial 收集器完全一样。\n新生代采用标记-复制算法,老年代采用标记-整理算法。\n它是许多运行在 Server 模式下的虚拟机的首要选择,除了 Serial 收集器外,只有它能与 CMS 收集器(真正意义上的并发收集器,后面会介绍到)配合工作。\n并行和并发概念补充:\n并行(Parallel):指多条垃圾收集线程并行工作,但此时用户线程仍然处于等待状态。\n并发(Concurrent):指用户线程与垃圾收集线程同时执行(但不一定是并行,可能会交替执行),用户程序在继续运行,而垃圾收集器运行在另一个 CPU 上。\nParallel Scavenge 收集器 # Parallel Scavenge 收集器也是使用标记-复制算法的多线程收集器,它看上去几乎和 ParNew 都一样。 那么它有什么特别之处呢?\n-XX:+UseParallelGC 使用 Parallel 收集器+ 老年代串行 -XX:+UseParallelOldGC 使用 Parallel 收集器+ 老年代并行 Parallel Scavenge 收集器关注点是吞吐量(高效率的利用 CPU)。CMS 等垃圾收集器的关注点更多的是用户线程的停顿时间(提高用户体验)。所谓吞吐量就是 CPU 中用于运行用户代码的时间与 CPU 总消耗时间的比值。 Parallel Scavenge 收集器提供了很多参数供用户找到最合适的停顿时间或最大吞吐量,如果对于收集器运作不太了解,手工优化存在困难的时候,使用 Parallel Scavenge 收集器配合自适应调节策略,把内存管理优化交给虚拟机去完成也是一个不错的选择。\n新生代采用标记-复制算法,老年代采用标记-整理算法。\n这是 JDK1.8 默认收集器\n使用 java -XX:+PrintCommandLineFlags -version 命令查看\n-XX:InitialHeapSize=262921408 -XX:MaxHeapSize=4206742528 -XX:+PrintCommandLineFlags -XX:+UseCompressedClassPointers -XX:+UseCompressedOops -XX:+UseParallelGC java version \u0026#34;1.8.0_211\u0026#34; Java(TM) SE Runtime Environment (build 1.8.0_211-b12) Java HotSpot(TM) 64-Bit Server VM (build 25.211-b12, mixed mode) JDK1.8 默认使用的是 Parallel Scavenge + Parallel Old,如果指定了-XX:+UseParallelGC 参数,则默认指定了-XX:+UseParallelOldGC,可以使用-XX:-UseParallelOldGC 来禁用该功能\nSerial Old 收集器 # Serial 收集器的老年代版本,它同样是一个单线程收集器。它主要有两大用途:一种用途是在 JDK1.5 以及以前的版本中与 Parallel Scavenge 收集器搭配使用,另一种用途是作为 CMS 收集器的后备方案。\nParallel Old 收集器 # Parallel Scavenge 收集器的老年代版本。使用多线程和“标记-整理”算法。在注重吞吐量以及 CPU 资源的场合,都可以优先考虑 Parallel Scavenge 收集器和 Parallel Old 收集器。\nCMS 收集器 # CMS(Concurrent Mark Sweep)收集器是一种以获取最短回收停顿时间为目标的收集器。它非常符合在注重用户体验的应用上使用。\nCMS(Concurrent Mark Sweep)收集器是 HotSpot 虚拟机第一款真正意义上的并发收集器,它第一次实现了让垃圾收集线程与用户线程(基本上)同时工作。\n从名字中的Mark Sweep这两个词可以看出,CMS 收集器是一种 “标记-清除”算法实现的,它的运作过程相比于前面几种垃圾收集器来说更加复杂一些。整个过程分为四个步骤:\n初始标记: 短暂停顿,标记直接与 root 相连的对象(根对象); 并发标记: 同时开启 GC 和用户线程,用一个闭包结构去记录可达对象。但在这个阶段结束,这个闭包结构并不能保证包含当前所有的可达对象。因为用户线程可能会不断的更新引用域,所以 GC 线程无法保证可达性分析的实时性。所以这个算法里会跟踪记录这些发生引用更新的地方。 重新标记: 重新标记阶段就是为了修正并发标记期间因为用户程序继续运行而导致标记产生变动的那一部分对象的标记记录,这个阶段的停顿时间一般会比初始标记阶段的时间稍长,远远比并发标记阶段时间短 并发清除: 开启用户线程,同时 GC 线程开始对未标记的区域做清扫。 从它的名字就可以看出它是一款优秀的垃圾收集器,主要优点:并发收集、低停顿。但是它有下面三个明显的缺点:\n对 CPU 资源敏感; 无法处理浮动垃圾; 它使用的回收算法-“标记-清除”算法会导致收集结束时会有大量空间碎片产生。 CMS 垃圾回收器在 Java 9 中已经被标记为过时(deprecated),并在 Java 14 中被移除。\nG1 收集器 # G1 (Garbage-First) 是一款面向服务器的垃圾收集器,主要针对配备多颗处理器及大容量内存的机器. 以极高概率满足 GC 停顿时间要求的同时,还具备高吞吐量性能特征。\n被视为 JDK1.7 中 HotSpot 虚拟机的一个重要进化特征。它具备以下特点:\n并行与并发:G1 能充分利用 CPU、多核环境下的硬件优势,使用多个 CPU(CPU 或者 CPU 核心)来缩短 Stop-The-World 停顿时间。部分其他收集器原本需要停顿 Java 线程执行的 GC 动作,G1 收集器仍然可以通过并发的方式让 java 程序继续执行。 分代收集:虽然 G1 可以不需要其他收集器配合就能独立管理整个 GC 堆,但是还是保留了分代的概念。 空间整合:与 CMS 的“标记-清除”算法不同,G1 从整体来看是基于“标记-整理”算法实现的收集器;从局部上来看是基于“标记-复制”算法实现的。 可预测的停顿:这是 G1 相对于 CMS 的另一个大优势,降低停顿时间是 G1 和 CMS 共同的关注点,但 G1 除了追求低停顿外,还能建立可预测的停顿时间模型,能让使用者明确指定在一个长度为 M 毫秒的时间片段内,消耗在垃圾收集上的时间不得超过 N 毫秒。 G1 收集器的运作大致分为以下几个步骤:\n初始标记: 短暂停顿(Stop-The-World,STW),标记从 GC Roots 可直接引用的对象,即标记所有直接可达的活跃对象 并发标记:与应用并发运行,标记所有可达对象。 这一阶段可能持续较长时间,取决于堆的大小和对象的数量。 最终标记: 短暂停顿(STW),处理并发标记阶段结束后残留的少量未处理的引用变更。 筛选回收:根据标记结果,选择回收价值高的区域,复制存活对象到新区域,回收旧区域内存。这一阶段包含一个或多个停顿(STW),具体取决于回收的复杂度。 G1 收集器在后台维护了一个优先列表,每次根据允许的收集时间,优先选择回收价值最大的 Region(这也就是它的名字 Garbage-First 的由来) 。这种使用 Region 划分内存空间以及有优先级的区域回收方式,保证了 G1 收集器在有限时间内可以尽可能高的收集效率(把内存化整为零)。\n从 JDK9 开始,G1 垃圾收集器成为了默认的垃圾收集器。\nZGC 收集器 # 与 CMS 中的 ParNew 和 G1 类似,ZGC 也采用标记-复制算法,不过 ZGC 对该算法做了重大改进。\nZGC 可以将暂停时间控制在几毫秒以内,且暂停时间不受堆内存大小的影响,出现 Stop The World 的情况会更少,但代价是牺牲了一些吞吐量。ZGC 最大支持 16TB 的堆内存。\nZGC 在 Java11 中引入,处于试验阶段。经过多个版本的迭代,不断的完善和修复问题,ZGC 在 Java15 已经可以正式使用了。\n不过,默认的垃圾回收器依然是 G1。你可以通过下面的参数启用 ZGC:\njava -XX:+UseZGC className 在 Java21 中,引入了分代 ZGC,暂停时间可以缩短到 1 毫秒以内。\n你可以通过下面的参数启用分代 ZGC:\njava -XX:+UseZGC -XX:+ZGenerational className 关于 ZGC 收集器的详细介绍推荐看看这几篇文章:\n从历代 GC 算法角度剖析 ZGC - 京东技术 新一代垃圾回收器 ZGC 的探索与实践 - 美团技术团队 极致八股文之 JVM 垃圾回收器 G1\u0026amp;ZGC 详解 - 阿里云开发者 参考 # 《深入理解 Java 虚拟机:JVM 高级特性与最佳实践(第二版》 The Java® Virtual Machine Specification - Java SE 8 Edition: https://docs.oracle.com/javase/specs/jvms/se8/html/index.html "},{"id":526,"href":"/zh/docs/technology/Interview/java/jvm/jvm-in-action/","title":"JVM线上问题排查和性能调优案例","section":"Jvm","content":"JVM 线上问题排查和性能调优也是面试常问的一个问题,尤其是社招中大厂的面试。\n这篇文章,我会分享一些我看到的相关的案例。\n下面是正文。\n一次线上 OOM 问题分析 - 艾小仙 - 2023\n现象:线上某个服务有接口非常慢,通过监控链路查看发现,中间的 GAP 时间非常大,实际接口并没有消耗很多时间,并且在那段时间里有很多这样的请求。 分析:使用 JDK 自带的jvisualvm分析 dump 文件(MAT 也能分析)。 建议:对于 SQL 语句,如果监测到没有where条件的全表查询应该默认增加一个合适的limit作为限制,防止这种问题拖垮整个系统 资料: 实战案例:记一次 dump 文件分析历程转载 - HeapDump - 2022。 生产事故-记一次特殊的 OOM 排查 - 程语有云 - 2023\n现象:网络没有问题的情况下,系统某开放接口从 2023 年 3 月 10 日 14 时许开始无法访问和使用。 临时解决办法:紧急回滚至上一稳定版本。 分析:使用 MAT (Memory Analyzer Tool)工具分析 dump 文件。 建议:正常情况下,-Xmn参数(控制 Young 区的大小)总是应当小于-Xmx参数(控制堆内存的最大大小),否则就会触发 OOM 错误。 资料: 最重要的 JVM 参数总结 - JavaGuide - 2023 一次大量 JVM Native 内存泄露的排查分析(64M 问题) - 掘金 - 2022\n现象:线上项目刚启动完使用 top 命令查看 RES 占用了超过 1.5G。 分析:整个分析流程用到了较多工作,可以跟着作者思路一步一步来,值得学习借鉴。 建议:远离 Hibernate。 资料: Linux top 命令里的内存相关字段(VIRT, RES, SHR, CODE, DATA) YGC 问题排查,又让我涨姿势了! - IT 人的职场进阶 - 2021\n现象:广告服务在新版本上线后,收到了大量的服务超时告警。 分析:使用 MAT (Memory Analyzer Tool) 工具分析 dump 文件。 建议:学会 YGC(Young GC) 问题的排查思路,掌握 YGC 的相关知识点。 听说 JVM 性能优化很难?今天我小试了一把! - 陈树义 - 2021\n通过观察 GC 频率和停顿时间,来进行 JVM 内存空间调整,使其达到最合理的状态。调整过程记得小步快跑,避免内存剧烈波动影响线上服务。 这其实是最为简单的一种 JVM 性能调优方式了,可以算是粗调吧。\n你们要的线上 GC 问题案例来啦 - 编了个程 - 2021\n案例 1:使用 guava cache 的时候,没有设置最大缓存数量和弱引用,导致频繁触发 Young GC 案例 2: 对于一个查询和排序分页的 SQL,同时这个 SQL 需要 join 多张表,在分库分表下,直接调用 SQL 性能很差。于是,查单表,再在内存排序分页,用了一个 List 来保存数据,而有些数据量大,造成了这个现象。 Java 中 9 种常见的 CMS GC 问题分析与解决 - 美团技术团 - 2020\n这篇文章共 2w+ 字,详细介绍了 GC 基础,总结了 CMS GC 的一些常见问题分析与解决办法。\n给祖传系统做了点 GC 调优,暂停时间降低了 90% - 京东云技术团队 - 2023\n这篇文章提到了一个在规则引擎系统中遇到的 GC(垃圾回收)问题,主要表现为系统在启动后发生了一次较长的 Young GC(年轻代垃圾回收)导致性能下降。经过分析,问题的核心在于动态对象年龄判定机制,它导致了过早的对象晋升,引起了长时间的垃圾回收。\n"},{"id":527,"href":"/zh/docs/technology/Interview/system-design/security/jwt-intro/","title":"JWT 基础概念详解","section":"Security","content":" 什么是 JWT? # JWT (JSON Web Token) 是目前最流行的跨域认证解决方案,是一种基于 Token 的认证授权机制。 从 JWT 的全称可以看出,JWT 本身也是 Token,一种规范化之后的 JSON 结构的 Token。\nJWT 自身包含了身份验证所需要的所有信息,因此,我们的服务器不需要存储 Session 信息。这显然增加了系统的可用性和伸缩性,大大减轻了服务端的压力。\n可以看出,JWT 更符合设计 RESTful API 时的「Stateless(无状态)」原则 。\n并且, 使用 JWT 认证可以有效避免 CSRF 攻击,因为 JWT 一般是存在在 localStorage 中,使用 JWT 进行身份验证的过程中是不会涉及到 Cookie 的。\n我在 JWT 优缺点分析这篇文章中有详细介绍到使用 JWT 做身份认证的优势和劣势。\n下面是 RFC 7519 对 JWT 做的较为正式的定义。\nJSON Web Token (JWT) is a compact, URL-safe means of representing claims to be transferred between two parties. The claims in a JWT are encoded as a JSON object that is used as the payload of a JSON Web Signature (JWS) structure or as the plaintext of a JSON Web Encryption (JWE) structure, enabling the claims to be digitally signed or integrity protected with a Message Authentication Code (MAC) and/or encrypted. —— JSON Web Token (JWT)\nJWT 由哪些部分组成? # JWT 本质上就是一组字串,通过(.)切分成三个为 Base64 编码的部分:\nHeader(头部) : 描述 JWT 的元数据,定义了生成签名的算法以及 Token 的类型。Header 被 Base64Url 编码后成为 JWT 的第一部分。 Payload(载荷) : 用来存放实际需要传递的数据,包含声明(Claims),如sub(subject,主题)、jti(JWT ID)。Payload 被 Base64Url 编码后成为 JWT 的第二部分。 Signature(签名):服务器通过 Payload、Header 和一个密钥(Secret)使用 Header 里面指定的签名算法(默认是 HMAC SHA256)生成。生成的签名会成为 JWT 的第三部分。 JWT 通常是这样的:xxxxx.yyyyy.zzzzz。\n示例:\neyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9. eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiaWF0IjoxNTE2MjM5MDIyfQ. SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c 你可以在 jwt.io 这个网站上对其 JWT 进行解码,解码之后得到的就是 Header、Payload、Signature 这三部分。\nHeader 和 Payload 都是 JSON 格式的数据,Signature 由 Payload、Header 和 Secret(密钥)通过特定的计算公式和加密算法得到。\nHeader # Header 通常由两部分组成:\ntyp(Type):令牌类型,也就是 JWT。 alg(Algorithm):签名算法,比如 HS256。 示例:\n{ \u0026#34;alg\u0026#34;: \u0026#34;HS256\u0026#34;, \u0026#34;typ\u0026#34;: \u0026#34;JWT\u0026#34; } JSON 形式的 Header 被转换成 Base64 编码,成为 JWT 的第一部分。\nPayload # Payload 也是 JSON 格式数据,其中包含了 Claims(声明,包含 JWT 的相关信息)。\nClaims 分为三种类型:\nRegistered Claims(注册声明):预定义的一些声明,建议使用,但不是强制性的。 Public Claims(公有声明):JWT 签发方可以自定义的声明,但是为了避免冲突,应该在 IANA JSON Web Token Registry 中定义它们。 Private Claims(私有声明):JWT 签发方因为项目需要而自定义的声明,更符合实际项目场景使用。 下面是一些常见的注册声明:\niss(issuer):JWT 签发方。 iat(issued at time):JWT 签发时间。 sub(subject):JWT 主题。 aud(audience):JWT 接收方。 exp(expiration time):JWT 的过期时间。 nbf(not before time):JWT 生效时间,早于该定义的时间的 JWT 不能被接受处理。 jti(JWT ID):JWT 唯一标识。 示例:\n{ \u0026#34;uid\u0026#34;: \u0026#34;ff1212f5-d8d1-4496-bf41-d2dda73de19a\u0026#34;, \u0026#34;sub\u0026#34;: \u0026#34;1234567890\u0026#34;, \u0026#34;name\u0026#34;: \u0026#34;John Doe\u0026#34;, \u0026#34;exp\u0026#34;: 15323232, \u0026#34;iat\u0026#34;: 1516239022, \u0026#34;scope\u0026#34;: [\u0026#34;admin\u0026#34;, \u0026#34;user\u0026#34;] } Payload 部分默认是不加密的,一定不要将隐私信息存放在 Payload 当中!!!\nJSON 形式的 Payload 被转换成 Base64 编码,成为 JWT 的第二部分。\nSignature # Signature 部分是对前两部分的签名,作用是防止 JWT(主要是 payload) 被篡改。\n这个签名的生成需要用到:\nHeader + Payload。 存放在服务端的密钥(一定不要泄露出去)。 签名算法。 签名的计算公式如下:\nHMACSHA256( base64UrlEncode(header) + \u0026#34;.\u0026#34; + base64UrlEncode(payload), secret) 算出签名以后,把 Header、Payload、Signature 三个部分拼成一个字符串,每个部分之间用\u0026quot;点\u0026quot;(.)分隔,这个字符串就是 JWT 。\n如何基于 JWT 进行身份验证? # 在基于 JWT 进行身份验证的的应用程序中,服务器通过 Payload、Header 和 Secret(密钥)创建 JWT 并将 JWT 发送给客户端。客户端接收到 JWT 之后,会将其保存在 Cookie 或者 localStorage 里面,以后客户端发出的所有请求都会携带这个令牌。\n简化后的步骤如下:\n用户向服务器发送用户名、密码以及验证码用于登陆系统。 如果用户用户名、密码以及验证码校验正确的话,服务端会返回已经签名的 Token,也就是 JWT。 用户以后每次向后端发请求都在 Header 中带上这个 JWT 。 服务端检查 JWT 并从中获取用户相关信息。 两点建议:\n建议将 JWT 存放在 localStorage 中,放在 Cookie 中会有 CSRF 风险。 请求服务端并携带 JWT 的常见做法是将其放在 HTTP Header 的 Authorization 字段中(Authorization: Bearer Token)。 spring-security-jwt-guide 就是一个基于 JWT 来做身份认证的简单案例,感兴趣的可以看看。\n如何防止 JWT 被篡改? # 有了签名之后,即使 JWT 被泄露或者截获,黑客也没办法同时篡改 Signature、Header、Payload。\n这是为什么呢?因为服务端拿到 JWT 之后,会解析出其中包含的 Header、Payload 以及 Signature 。服务端会根据 Header、Payload、密钥再次生成一个 Signature。拿新生成的 Signature 和 JWT 中的 Signature 作对比,如果一样就说明 Header 和 Payload 没有被修改。\n不过,如果服务端的秘钥也被泄露的话,黑客就可以同时篡改 Signature、Header、Payload 了。黑客直接修改了 Header 和 Payload 之后,再重新生成一个 Signature 就可以了。\n密钥一定保管好,一定不要泄露出去。JWT 安全的核心在于签名,签名安全的核心在密钥。\n如何加强 JWT 的安全性? # 使用安全系数高的加密算法。 使用成熟的开源库,没必要造轮子。 JWT 存放在 localStorage 中而不是 Cookie 中,避免 CSRF 风险。 一定不要将隐私信息存放在 Payload 当中。 密钥一定保管好,一定不要泄露出去。JWT 安全的核心在于签名,签名安全的核心在密钥。 Payload 要加入 exp (JWT 的过期时间),永久有效的 JWT 不合理。并且,JWT 的过期时间不宜过长。 …… "},{"id":528,"href":"/zh/docs/technology/Interview/system-design/security/advantages-and-disadvantages-of-jwt/","title":"JWT 身份认证优缺点分析","section":"Security","content":"校招面试中,遇到大部分的候选者认证登录这块用的都是 JWT。提问 JWT 的概念性问题以及使用 JWT 的原因,基本都能回答一些,但当问到 JWT 存在的一些问题和解决方案时,只有一小部分候选者回答的还可以。\nJWT 不是银弹,也有很多缺陷,很多时候并不是最优的选择。这篇文章,我们一起探讨一下 JWT 身份认证的优缺点以及常见问题的解决办法,来看看为什么很多人不再推荐使用 JWT 了。\n关于 JWT 的基本概念介绍请看我写的这篇文章: JWT 基本概念详解。\nJWT 的优势 # 相比于 Session 认证的方式来说,使用 JWT 进行身份认证主要有下面 4 个优势。\n无状态 # JWT 自身包含了身份验证所需要的所有信息,因此,我们的服务器不需要存储 JWT 信息。这显然增加了系统的可用性和伸缩性,大大减轻了服务端的压力。\n不过,也正是由于 JWT 的无状态,也导致了它最大的缺点:不可控!\n就比如说,我们想要在 JWT 有效期内废弃一个 JWT 或者更改它的权限的话,并不会立即生效,通常需要等到有效期过后才可以。再比如说,当用户 Logout 的话,JWT 也还有效。除非,我们在后端增加额外的处理逻辑比如将失效的 JWT 存储起来,后端先验证 JWT 是否有效再进行处理。具体的解决办法,我们会在后面的内容中详细介绍到,这里只是简单提一下。\n有效避免了 CSRF 攻击 # CSRF(Cross Site Request Forgery) 一般被翻译为 跨站请求伪造,属于网络攻击领域范围。相比于 SQL 脚本注入、XSS 等安全攻击方式,CSRF 的知名度并没有它们高。但是,它的确是我们开发系统时必须要考虑的安全隐患。就连业内技术标杆 Google 的产品 Gmail 也曾在 2007 年的时候爆出过 CSRF 漏洞,这给 Gmail 的用户造成了很大的损失。\n那么究竟什么是跨站请求伪造呢? 简单来说就是用你的身份去做一些不好的事情(发送一些对你不友好的请求比如恶意转账)。\n举个简单的例子:小壮登录了某网上银行,他来到了网上银行的帖子区,看到一个帖子下面有一个链接写着“科学理财,年盈利率过万”,小壮好奇的点开了这个链接,结果发现自己的账户少了 10000 元。这是这么回事呢?原来黑客在链接中藏了一个请求,这个请求直接利用小壮的身份给银行发送了一个转账请求,也就是通过你的 Cookie 向银行发出请求。\n\u0026lt;a src=\u0026#34;http://www.mybank.com/Transfer?bankId=11\u0026amp;money=10000\u0026#34; \u0026gt;科学理财,年盈利率过万\u0026lt;/a \u0026gt; CSRF 攻击需要依赖 Cookie ,Session 认证中 Cookie 中的 SessionID 是由浏览器发送到服务端的,只要发出请求,Cookie 就会被携带。借助这个特性,即使黑客无法获取你的 SessionID,只要让你误点攻击链接,就可以达到攻击效果。\n另外,并不是必须点击链接才可以达到攻击效果,很多时候,只要你打开了某个页面,CSRF 攻击就会发生。\n那为什么 JWT 不会存在这种问题呢?\n一般情况下我们使用 JWT 的话,在我们登录成功获得 JWT 之后,一般会选择存放在 localStorage 中。前端的每一个请求后续都会附带上这个 JWT,整个过程压根不会涉及到 Cookie。因此,即使你点击了非法链接发送了请求到服务端,这个非法请求也是不会携带 JWT 的,所以这个请求将是非法的。\n总结来说就一句话:使用 JWT 进行身份验证不需要依赖 Cookie ,因此可以避免 CSRF 攻击。\n不过,这样也会存在 XSS 攻击的风险。为了避免 XSS 攻击,你可以选择将 JWT 存储在标记为httpOnly 的 Cookie 中。但是,这样又导致了你必须自己提供 CSRF 保护,因此,实际项目中我们通常也不会这么做。\n常见的避免 XSS 攻击的方式是过滤掉请求中存在 XSS 攻击风险的可疑字符串。\n在 Spring 项目中,我们一般是通过创建 XSS 过滤器来实现的。\n@Component @Order(Ordered.HIGHEST_PRECEDENCE) public class XSSFilter implements Filter { @Override public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain) throws IOException, ServletException { XSSRequestWrapper wrappedRequest = new XSSRequestWrapper((HttpServletRequest) request); chain.doFilter(wrappedRequest, response); } // other methods } 适合移动端应用 # 使用 Session 进行身份认证的话,需要保存一份信息在服务器端,而且这种方式会依赖到 Cookie(需要 Cookie 保存 SessionId),所以不适合移动端。\n但是,使用 JWT 进行身份认证就不会存在这种问题,因为只要 JWT 可以被客户端存储就能够使用,而且 JWT 还可以跨语言使用。\n为什么使用 Session 进行身份认证的话不适合移动端 ?\n状态管理: Session 基于服务器端的状态管理,而移动端应用通常是无状态的。移动设备的连接可能不稳定或中断,因此难以维护长期的会话状态。如果使用 Session 进行身份认证,移动应用需要频繁地与服务器进行会话维护,增加了网络开销和复杂性; 兼容性: 移动端应用通常会面向多个平台,如 iOS、Android 和 Web。每个平台对于 Session 的管理和存储方式可能不同,可能导致跨平台兼容性的问题; 安全性: 移动设备通常处于不受信任的网络环境,存在数据泄露和攻击的风险。将敏感的会话信息存储在移动设备上增加了被攻击的潜在风险。 单点登录友好 # 使用 Session 进行身份认证的话,实现单点登录,需要我们把用户的 Session 信息保存在一台电脑上,并且还会遇到常见的 Cookie 跨域的问题。但是,使用 JWT 进行认证的话, JWT 被保存在客户端,不会存在这些问题。\nJWT 身份认证常见问题及解决办法 # 注销登录等场景下 JWT 还有效 # 与之类似的具体相关场景有:\n退出登录; 修改密码; 服务端修改了某个用户具有的权限或者角色; 用户的帐户被封禁/删除; 用户被服务端强制注销; 用户被踢下线; …… 这个问题不存在于 Session 认证方式中,因为在 Session 认证方式中,遇到这种情况的话服务端删除对应的 Session 记录即可。但是,使用 JWT 认证的方式就不好解决了。我们也说过了,JWT 一旦派发出去,如果后端不增加其他逻辑的话,它在失效之前都是有效的。\n那我们如何解决这个问题呢?查阅了很多资料,我简单总结了下面 4 种方案:\n1、将 JWT 存入数据库\n将有效的 JWT 存入数据库中,更建议使用内存数据库比如 Redis。如果需要让某个 JWT 失效就直接从 Redis 中删除这个 JWT 即可。但是,这样会导致每次使用 JWT 都要先从 Redis 中查询 JWT 是否存在的步骤,而且违背了 JWT 的无状态原则。\n2、黑名单机制\n和上面的方式类似,使用内存数据库比如 Redis 维护一个黑名单,如果想让某个 JWT 失效的话就直接将这个 JWT 加入到 黑名单 即可。然后,每次使用 JWT 进行请求的话都会先判断这个 JWT 是否存在于黑名单中。\n前两种方案的核心在于将有效的 JWT 存储起来或者将指定的 JWT 拉入黑名单。\n虽然这两种方案都违背了 JWT 的无状态原则,但是一般实际项目中我们通常还是会使用这两种方案。\n3、修改密钥 (Secret) :\n我们为每个用户都创建一个专属密钥,如果我们想让某个 JWT 失效,我们直接修改对应用户的密钥即可。但是,这样相比于前两种引入内存数据库带来了危害更大:\n如果服务是分布式的,则每次发出新的 JWT 时都必须在多台机器同步密钥。为此,你需要将密钥存储在数据库或其他外部服务中,这样和 Session 认证就没太大区别了。 如果用户同时在两个浏览器打开系统,或者在手机端也打开了系统,如果它从一个地方将账号退出,那么其他地方都要重新进行登录,这是不可取的。 4、保持令牌的有效期限短并经常轮换\n很简单的一种方式。但是,会导致用户登录状态不会被持久记录,而且需要用户经常登录。\n另外,对于修改密码后 JWT 还有效问题的解决还是比较容易的。说一种我觉得比较好的方式:使用用户的密码的哈希值对 JWT 进行签名。因此,如果密码更改,则任何先前的令牌将自动无法验证。\nJWT 的续签问题 # JWT 有效期一般都建议设置的不太长,那么 JWT 过期后如何认证,如何实现动态刷新 JWT,避免用户经常需要重新登录?\n我们先来看看在 Session 认证中一般的做法:假如 Session 的有效期 30 分钟,如果 30 分钟内用户有访问,就把 Session 有效期延长 30 分钟。\nJWT 认证的话,我们应该如何解决续签问题呢?查阅了很多资料,我简单总结了下面 4 种方案:\n1、类似于 Session 认证中的做法(不推荐)\n这种方案满足于大部分场景。假设服务端给的 JWT 有效期设置为 30 分钟,服务端每次进行校验时,如果发现 JWT 的有效期马上快过期了,服务端就重新生成 JWT 给客户端。客户端每次请求都检查新旧 JWT,如果不一致,则更新本地的 JWT。这种做法的问题是仅仅在快过期的时候请求才会更新 JWT ,对客户端不是很友好。\n2、每次请求都返回新 JWT(不推荐)\n这种方案的的思路很简单,但是,开销会比较大,尤其是在服务端要存储维护 JWT 的情况下。\n3、JWT 有效期设置到半夜(不推荐)\n这种方案是一种折衷的方案,保证了大部分用户白天可以正常登录,适用于对安全性要求不高的系统。\n4、用户登录返回两个 JWT(推荐)\n第一个是 accessJWT ,它的过期时间 JWT 本身的过期时间比如半个小时,另外一个是 refreshJWT 它的过期时间更长一点比如为 1 天。refreshJWT 只用来获取 accessJWT,不容易被泄露。\n客户端登录后,将 accessJWT 和 refreshJWT 保存在本地,每次访问将 accessJWT 传给服务端。服务端校验 accessJWT 的有效性,如果过期的话,就将 refreshJWT 传给服务端。如果有效,服务端就生成新的 accessJWT 给客户端。否则,客户端就重新登录即可。\n这种方案的不足是:\n需要客户端来配合; 用户注销的时候需要同时保证两个 JWT 都无效; 重新请求获取 JWT 的过程中会有短暂 JWT 不可用的情况(可以通过在客户端设置定时器,当 accessJWT 快过期的时候,提前去通过 refreshJWT 获取新的 accessJWT); 存在安全问题,只要拿到了未过期的 refreshJWT 就一直可以获取到 accessJWT。不过,由于 refreshJWT 只用来获取 accessJWT,不容易被泄露。 JWT 体积太大 # JWT 结构复杂(Header、Payload 和 Signature),包含了更多额外的信息,还需要进行 Base64Url 编码,这会使得 JWT 体积较大,增加了网络传输的开销。\nJWT 组成:\nJWT 示例:\neyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9. eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiaWF0IjoxNTE2MjM5MDIyfQ. SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c 解决办法:\n尽量减少 JWT Payload(载荷)中的信息,只保留必要的用户和权限信息。 在传输 JWT 之前,使用压缩算法(如 GZIP)对 JWT 进行压缩以减少体积。 在某些情况下,使用传统的 Token 可能更合适。传统的 Token 通常只是一个唯一标识符,对应的信息(例如用户 ID、Token 过期时间、权限信息)存储在服务端,通常会通过 Redis 保存。 总结 # JWT 其中一个很重要的优势是无状态,但实际上,我们想要在实际项目中合理使用 JWT 做认证登录的话,也还是需要保存 JWT 信息。\nJWT 也不是银弹,也有很多缺陷,具体是选择 JWT 还是 Session 方案还是要看项目的具体需求。万万不可尬吹 JWT,而看不起其他身份认证方案。\n另外,不用 JWT 直接使用普通的 Token(随机生成的 ID,不包含具体的信息) 结合 Redis 来做身份认证也是可以的。\n参考 # JWT 超详细分析: https://learnku.com/articles/17883 How to log out when using JWT: https://medium.com/devgorilla/how-to-log-out-when-using-jwt-a8c7823e8a6 CSRF protection with JSON Web JWTs: https://medium.com/@agungsantoso/csrf-protection-with-json-web-JWTs-83e0f2fcbcc Invalidating JSON Web JWTs: https://stackoverflow.com/questions/21978658/invalidating-json-web-JWTs "},{"id":529,"href":"/zh/docs/technology/Interview/high-performance/message-queue/kafka-questions-01/","title":"Kafka常见问题总结","section":"High Performance","content":" Kafka 基础 # Kafka 是什么?主要应用场景有哪些? # Kafka 是一个分布式流式处理平台。这到底是什么意思呢?\n流平台具有三个关键功能:\n消息队列:发布和订阅消息流,这个功能类似于消息队列,这也是 Kafka 也被归类为消息队列的原因。 容错的持久方式存储记录消息流:Kafka 会把消息持久化到磁盘,有效避免了消息丢失的风险。 流式处理平台: 在消息发布的时候进行处理,Kafka 提供了一个完整的流式处理类库。 Kafka 主要有两大应用场景:\n消息队列:建立实时流数据管道,以可靠地在系统或应用程序之间获取数据。 数据处理: 构建实时的流数据处理程序来转换或处理数据流。 和其他消息队列相比,Kafka 的优势在哪里? # 我们现在经常提到 Kafka 的时候就已经默认它是一个非常优秀的消息队列了,我们也会经常拿它跟 RocketMQ、RabbitMQ 对比。我觉得 Kafka 相比其他消息队列主要的优势如下:\n极致的性能:基于 Scala 和 Java 语言开发,设计中大量使用了批量处理和异步的思想,最高可以每秒处理千万级别的消息。 生态系统兼容性无可匹敌:Kafka 与周边生态系统的兼容性是最好的没有之一,尤其在大数据和流计算领域。 实际上在早期的时候 Kafka 并不是一个合格的消息队列,早期的 Kafka 在消息队列领域就像是一个衣衫褴褛的孩子一样,功能不完备并且有一些小问题比如丢失消息、不保证消息可靠性等等。当然,这也和 LinkedIn 最早开发 Kafka 用于处理海量的日志有很大关系,哈哈哈,人家本来最开始就不是为了作为消息队列滴,谁知道后面误打误撞在消息队列领域占据了一席之地。\n随着后续的发展,这些短板都被 Kafka 逐步修复完善。所以,Kafka 作为消息队列不可靠这个说法已经过时!\n队列模型了解吗?Kafka 的消息模型知道吗? # 题外话:早期的 JMS 和 AMQP 属于消息服务领域权威组织所做的相关的标准,我在 JavaGuide的 《消息队列其实很简单》这篇文章中介绍过。但是,这些标准的进化跟不上消息队列的演进速度,这些标准实际上已经属于废弃状态。所以,可能存在的情况是:不同的消息队列都有自己的一套消息模型。\n队列模型:早期的消息模型 # 使用队列(Queue)作为消息通信载体,满足生产者与消费者模式,一条消息只能被一个消费者使用,未被消费的消息在队列中保留直到被消费或超时。 比如:我们生产者发送 100 条消息的话,两个消费者来消费一般情况下两个消费者会按照消息发送的顺序各自消费一半(也就是你一个我一个的消费。)\n队列模型存在的问题:\n假如我们存在这样一种情况:我们需要将生产者产生的消息分发给多个消费者,并且每个消费者都能接收到完整的消息内容。\n这种情况,队列模型就不好解决了。很多比较杠精的人就说:我们可以为每个消费者创建一个单独的队列,让生产者发送多份。这是一种非常愚蠢的做法,浪费资源不说,还违背了使用消息队列的目的。\n发布-订阅模型:Kafka 消息模型 # 发布-订阅模型主要是为了解决队列模型存在的问题。\n发布订阅模型(Pub-Sub) 使用主题(Topic) 作为消息通信载体,类似于广播模式;发布者发布一条消息,该消息通过主题传递给所有的订阅者,在一条消息广播之后才订阅的用户则是收不到该条消息的。\n在发布 - 订阅模型中,如果只有一个订阅者,那它和队列模型就基本是一样的了。所以说,发布 - 订阅模型在功能层面上是可以兼容队列模型的。\nKafka 采用的就是发布 - 订阅模型。\nRocketMQ 的消息模型和 Kafka 基本是完全一样的。唯一的区别是 Kafka 中没有队列这个概念,与之对应的是 Partition(分区)。\nKafka 核心概念 # 什么是 Producer、Consumer、Broker、Topic、Partition? # Kafka 将生产者发布的消息发送到 Topic(主题) 中,需要这些消息的消费者可以订阅这些 Topic(主题),如下图所示:\n上面这张图也为我们引出了,Kafka 比较重要的几个概念:\nProducer(生产者) : 产生消息的一方。 Consumer(消费者) : 消费消息的一方。 Broker(代理) : 可以看作是一个独立的 Kafka 实例。多个 Kafka Broker 组成一个 Kafka Cluster。 同时,你一定也注意到每个 Broker 中又包含了 Topic 以及 Partition 这两个重要的概念:\nTopic(主题) : Producer 将消息发送到特定的主题,Consumer 通过订阅特定的 Topic(主题) 来消费消息。 Partition(分区) : Partition 属于 Topic 的一部分。一个 Topic 可以有多个 Partition ,并且同一 Topic 下的 Partition 可以分布在不同的 Broker 上,这也就表明一个 Topic 可以横跨多个 Broker 。这正如我上面所画的图一样。 划重点:Kafka 中的 Partition(分区) 实际上可以对应成为消息队列中的队列。这样是不是更好理解一点?\nKafka 的多副本机制了解吗?带来了什么好处? # 还有一点我觉得比较重要的是 Kafka 为分区(Partition)引入了多副本(Replica)机制。分区(Partition)中的多个副本之间会有一个叫做 leader 的家伙,其他副本称为 follower。我们发送的消息会被发送到 leader 副本,然后 follower 副本才能从 leader 副本中拉取消息进行同步。\n生产者和消费者只与 leader 副本交互。你可以理解为其他副本只是 leader 副本的拷贝,它们的存在只是为了保证消息存储的安全性。当 leader 副本发生故障时会从 follower 中选举出一个 leader,但是 follower 中如果有和 leader 同步程度达不到要求的参加不了 leader 的竞选。\nKafka 的多分区(Partition)以及多副本(Replica)机制有什么好处呢?\nKafka 通过给特定 Topic 指定多个 Partition, 而各个 Partition 可以分布在不同的 Broker 上, 这样便能提供比较好的并发能力(负载均衡)。 Partition 可以指定对应的 Replica 数, 这也极大地提高了消息存储的安全性, 提高了容灾能力,不过也相应的增加了所需要的存储空间。 Zookeeper 和 Kafka # Zookeeper 在 Kafka 中的作用是什么? # 要想搞懂 zookeeper 在 Kafka 中的作用 一定要自己搭建一个 Kafka 环境然后自己进 zookeeper 去看一下有哪些文件夹和 Kafka 有关,每个节点又保存了什么信息。 一定不要光看不实践,这样学来的也终会忘记!这部分内容参考和借鉴了这篇文章: https://www.jianshu.com/p/a036405f989c 。\n下图就是我的本地 Zookeeper ,它成功和我本地的 Kafka 关联上(以下文件夹结构借助 idea 插件 Zookeeper tool 实现)。\nZooKeeper 主要为 Kafka 提供元数据的管理的功能。\n从图中我们可以看出,Zookeeper 主要为 Kafka 做了下面这些事情:\nBroker 注册:在 Zookeeper 上会有一个专门用来进行 Broker 服务器列表记录的节点。每个 Broker 在启动时,都会到 Zookeeper 上进行注册,即到 /brokers/ids 下创建属于自己的节点。每个 Broker 就会将自己的 IP 地址和端口等信息记录到该节点中去 Topic 注册:在 Kafka 中,同一个Topic 的消息会被分成多个分区并将其分布在多个 Broker 上,这些分区信息及与 Broker 的对应关系也都是由 Zookeeper 在维护。比如我创建了一个名字为 my-topic 的主题并且它有两个分区,对应到 zookeeper 中会创建这些文件夹:/brokers/topics/my-topic/Partitions/0、/brokers/topics/my-topic/Partitions/1 负载均衡:上面也说过了 Kafka 通过给特定 Topic 指定多个 Partition, 而各个 Partition 可以分布在不同的 Broker 上, 这样便能提供比较好的并发能力。 对于同一个 Topic 的不同 Partition,Kafka 会尽力将这些 Partition 分布到不同的 Broker 服务器上。当生产者产生消息后也会尽量投递到不同 Broker 的 Partition 里面。当 Consumer 消费的时候,Zookeeper 可以根据当前的 Partition 数量以及 Consumer 数量来实现动态负载均衡。 …… 使用 Kafka 能否不引入 Zookeeper? # 在 Kafka 2.8 之前,Kafka 最被大家诟病的就是其重度依赖于 Zookeeper。在 Kafka 2.8 之后,引入了基于 Raft 协议的 KRaft 模式,不再依赖 Zookeeper,大大简化了 Kafka 的架构,让你可以以一种轻量级的方式来使用 Kafka。\n不过,要提示一下:如果要使用 KRaft 模式的话,建议选择较高版本的 Kafka,因为这个功能还在持续完善优化中。Kafka 3.3.1 版本是第一个将 KRaft(Kafka Raft)共识协议标记为生产就绪的版本。\nKafka 消费顺序、消息丢失和重复消费 # Kafka 如何保证消息的消费顺序? # 我们在使用消息队列的过程中经常有业务场景需要严格保证消息的消费顺序,比如我们同时发了 2 个消息,这 2 个消息对应的操作分别对应的数据库操作是:\n更改用户会员等级。 根据会员等级计算订单价格。 假如这两条消息的消费顺序不一样造成的最终结果就会截然不同。\n我们知道 Kafka 中 Partition(分区)是真正保存消息的地方,我们发送的消息都被放在了这里。而我们的 Partition(分区) 又存在于 Topic(主题) 这个概念中,并且我们可以给特定 Topic 指定多个 Partition。\n每次添加消息到 Partition(分区) 的时候都会采用尾加法,如上图所示。 Kafka 只能为我们保证 Partition(分区) 中的消息有序。\n消息在被追加到 Partition(分区)的时候都会分配一个特定的偏移量(offset)。Kafka 通过偏移量(offset)来保证消息在分区内的顺序性。\n所以,我们就有一种很简单的保证消息消费顺序的方法:1 个 Topic 只对应一个 Partition。这样当然可以解决问题,但是破坏了 Kafka 的设计初衷。\nKafka 中发送 1 条消息的时候,可以指定 topic, partition, key,data(数据) 4 个参数。如果你发送消息的时候指定了 Partition 的话,所有消息都会被发送到指定的 Partition。并且,同一个 key 的消息可以保证只发送到同一个 partition,这个我们可以采用表/对象的 id 来作为 key 。\n总结一下,对于如何保证 Kafka 中消息消费的顺序,有了下面两种方法:\n1 个 Topic 只对应一个 Partition。 (推荐)发送消息的时候指定 key/Partition。 当然不仅仅只有上面两种方法,上面两种方法是我觉得比较好理解的,\nKafka 如何保证消息不丢失? # 生产者丢失消息的情况 # 生产者(Producer) 调用send方法发送消息之后,消息可能因为网络问题并没有发送过去。\n所以,我们不能默认在调用send方法发送消息之后消息发送成功了。为了确定消息是发送成功,我们要判断消息发送的结果。但是要注意的是 Kafka 生产者(Producer) 使用 send 方法发送消息实际上是异步的操作,我们可以通过 get()方法获取调用结果,但是这样也让它变为了同步操作,示例代码如下:\n详细代码见我的这篇文章: Kafka 系列第三篇!10 分钟学会如何在 Spring Boot 程序中使用 Kafka 作为消息队列?\nSendResult\u0026lt;String, Object\u0026gt; sendResult = kafkaTemplate.send(topic, o).get(); if (sendResult.getRecordMetadata() != null) { logger.info(\u0026#34;生产者成功发送消息到\u0026#34; + sendResult.getProducerRecord().topic() + \u0026#34;-\u0026gt; \u0026#34; + sendRe sult.getProducerRecord().value().toString()); } 但是一般不推荐这么做!可以采用为其添加回调函数的形式,示例代码如下:\nListenableFuture\u0026lt;SendResult\u0026lt;String, Object\u0026gt;\u0026gt; future = kafkaTemplate.send(topic, o); future.addCallback(result -\u0026gt; logger.info(\u0026#34;生产者成功发送消息到topic:{} partition:{}的消息\u0026#34;, result.getRecordMetadata().topic(), result.getRecordMetadata().partition()), ex -\u0026gt; logger.error(\u0026#34;生产者发送消失败,原因:{}\u0026#34;, ex.getMessage())); 如果消息发送失败的话,我们检查失败的原因之后重新发送即可!\n另外,这里推荐为 Producer 的retries(重试次数)设置一个比较合理的值,一般是 3 ,但是为了保证消息不丢失的话一般会设置比较大一点。设置完成之后,当出现网络问题之后能够自动重试消息发送,避免消息丢失。另外,建议还要设置重试间隔,因为间隔太小的话重试的效果就不明显了,网络波动一次你 3 次一下子就重试完了。\n消费者丢失消息的情况 # 我们知道消息在被追加到 Partition(分区)的时候都会分配一个特定的偏移量(offset)。偏移量(offset)表示 Consumer 当前消费到的 Partition(分区)的所在的位置。Kafka 通过偏移量(offset)可以保证消息在分区内的顺序性。\n当消费者拉取到了分区的某个消息之后,消费者会自动提交了 offset。自动提交的话会有一个问题,试想一下,当消费者刚拿到这个消息准备进行真正消费的时候,突然挂掉了,消息实际上并没有被消费,但是 offset 却被自动提交了。\n解决办法也比较粗暴,我们手动关闭自动提交 offset,每次在真正消费完消息之后再自己手动提交 offset 。 但是,细心的朋友一定会发现,这样会带来消息被重新消费的问题。比如你刚刚消费完消息之后,还没提交 offset,结果自己挂掉了,那么这个消息理论上就会被消费两次。\nKafka 弄丢了消息 # 我们知道 Kafka 为分区(Partition)引入了多副本(Replica)机制。分区(Partition)中的多个副本之间会有一个叫做 leader 的家伙,其他副本称为 follower。我们发送的消息会被发送到 leader 副本,然后 follower 副本才能从 leader 副本中拉取消息进行同步。生产者和消费者只与 leader 副本交互。你可以理解为其他副本只是 leader 副本的拷贝,它们的存在只是为了保证消息存储的安全性。\n试想一种情况:假如 leader 副本所在的 broker 突然挂掉,那么就要从 follower 副本重新选出一个 leader ,但是 leader 的数据还有一些没有被 follower 副本的同步的话,就会造成消息丢失。\n设置 acks = all\n解决办法就是我们设置 acks = all。acks 是 Kafka 生产者(Producer) 很重要的一个参数。\nacks 的默认值即为 1,代表我们的消息被 leader 副本接收之后就算被成功发送。当我们配置 acks = all 表示只有所有 ISR 列表的副本全部收到消息时,生产者才会接收到来自服务器的响应. 这种模式是最高级别的,也是最安全的,可以确保不止一个 Broker 接收到了消息. 该模式的延迟会很高.\n设置 replication.factor \u0026gt;= 3\n为了保证 leader 副本能有 follower 副本能同步消息,我们一般会为 topic 设置 replication.factor \u0026gt;= 3。这样就可以保证每个 分区(partition) 至少有 3 个副本。虽然造成了数据冗余,但是带来了数据的安全性。\n设置 min.insync.replicas \u0026gt; 1\n一般情况下我们还需要设置 min.insync.replicas\u0026gt; 1 ,这样配置代表消息至少要被写入到 2 个副本才算是被成功发送。min.insync.replicas 的默认值为 1 ,在实际生产中应尽量避免默认值 1。\n但是,为了保证整个 Kafka 服务的高可用性,你需要确保 replication.factor \u0026gt; min.insync.replicas 。为什么呢?设想一下假如两者相等的话,只要是有一个副本挂掉,整个分区就无法正常工作了。这明显违反高可用性!一般推荐设置成 replication.factor = min.insync.replicas + 1。\n设置 unclean.leader.election.enable = false\nKafka 0.11.0.0 版本开始 unclean.leader.election.enable 参数的默认值由原来的 true 改为 false\n我们最开始也说了我们发送的消息会被发送到 leader 副本,然后 follower 副本才能从 leader 副本中拉取消息进行同步。多个 follower 副本之间的消息同步情况不一样,当我们配置了 unclean.leader.election.enable = false 的话,当 leader 副本发生故障时就不会从 follower 副本中和 leader 同步程度达不到要求的副本中选择出 leader ,这样降低了消息丢失的可能性。\nKafka 如何保证消息不重复消费? # kafka 出现消息重复消费的原因:\n服务端侧已经消费的数据没有成功提交 offset(根本原因)。 Kafka 侧 由于服务端处理业务时间长或者网络链接等等原因让 Kafka 认为服务假死,触发了分区 rebalance。 解决方案:\n消费消息服务做幂等校验,比如 Redis 的 set、MySQL 的主键等天然的幂等功能。这种方法最有效。 将 enable.auto.commit 参数设置为 false,关闭自动提交,开发者在代码中手动提交 offset。那么这里会有个问题:什么时候提交 offset 合适? 处理完消息再提交:依旧有消息重复消费的风险,和自动提交一样 拉取到消息即提交:会有消息丢失的风险。允许消息延时的场景,一般会采用这种方式。然后,通过定时任务在业务不繁忙(比如凌晨)的时候做数据兜底。 Kafka 重试机制 # 在 Kafka 如何保证消息不丢失这里,我们提到了 Kafka 的重试机制。由于这部分内容较为重要,我们这里再来详细介绍一下。\n网上关于 Spring Kafka 的默认重试机制文章很多,但大多都是过时的,和实际运行结果完全不一样。以下是根据 spring-kafka-2.9.3 源码重新梳理一下。\n消费失败会怎么样? # 在消费过程中,当其中一个消息消费异常时,会不会卡住后续队列消息的消费?这样业务岂不是卡住了?\n生产者代码:\nfor (int i = 0; i \u0026lt; 10; i++) { kafkaTemplate.send(KafkaConst.TEST_TOPIC, String.valueOf(i)) } 消费者消代码:\n@KafkaListener(topics = {KafkaConst.TEST_TOPIC},groupId = \u0026#34;apple\u0026#34;) private void customer(String message) throws InterruptedException { log.info(\u0026#34;kafka customer:{}\u0026#34;,message); Integer n = Integer.parseInt(message); if (n%5==0){ throw new RuntimeException(); } } 在默认配置下,当消费异常会进行重试,重试多次后会跳过当前消息,继续进行后续消息的消费,不会一直卡在当前消息。下面是一段消费的日志,可以看出当 test-0@95 重试多次后会被跳过。\n2023-08-10 12:03:32.918 DEBUG 9700 --- [ntainer#0-0-C-1] o.s.kafka.listener.DefaultErrorHandler : Skipping seek of: test-0@95 2023-08-10 12:03:32.918 TRACE 9700 --- [ntainer#0-0-C-1] o.s.kafka.listener.DefaultErrorHandler : Seeking: test-0 to: 96 2023-08-10 12:03:32.918 INFO 9700 --- [ntainer#0-0-C-1] o.a.k.clients.consumer.KafkaConsumer : [Consumer clientId=consumer-apple-1, groupId=apple] Seeking to offset 96 for partition test-0 因此,即使某个消息消费异常,Kafka 消费者仍然能够继续消费后续的消息,不会一直卡在当前消息,保证了业务的正常进行。\n默认会重试多少次? # 默认配置下,消费异常会进行重试,重试次数是多少, 重试是否有时间间隔?\n看源码 FailedRecordTracker 类有个 recovered 函数,返回 Boolean 值判断是否要进行重试,下面是这个函数中判断是否重试的逻辑:\n@Override public boolean recovered(ConsumerRecord \u0026lt;\u0026lt; ? , ? \u0026gt; record, Exception exception, @Nullable MessageListenerContainer container, @Nullable Consumer \u0026lt;\u0026lt; ? , ? \u0026gt; consumer) throws InterruptedException { if (this.noRetries) { // 不支持重试 attemptRecovery(record, exception, null, consumer); return true; } // 取已经失败的消费记录集合 Map \u0026lt; TopicPartition, FailedRecord \u0026gt; map = this.failures.get(); if (map == null) { this.failures.set(new HashMap \u0026lt; \u0026gt; ()); map = this.failures.get(); } // 获取消费记录所在的Topic和Partition TopicPartition topicPartition = new TopicPartition(record.topic(), record.partition()); FailedRecord failedRecord = getFailedRecordInstance(record, exception, map, topicPartition); // 通知注册的重试监听器,消息投递失败 this.retryListeners.forEach(rl - \u0026gt; rl.failedDelivery(record, exception, failedRecord.getDeliveryAttempts().get())); // 获取下一次重试的时间间隔 long nextBackOff = failedRecord.getBackOffExecution().nextBackOff(); if (nextBackOff != BackOffExecution.STOP) { this.backOffHandler.onNextBackOff(container, exception, nextBackOff); return false; } else { attemptRecovery(record, exception, topicPartition, consumer); map.remove(topicPartition); if (map.isEmpty()) { this.failures.remove(); } return true; } } 其中, BackOffExecution.STOP 的值为 -1。\n@FunctionalInterface public interface BackOffExecution { long STOP = -1; long nextBackOff(); } nextBackOff 的值调用 BackOff 类的 nextBackOff() 函数。如果当前执行次数大于最大执行次数则返回 STOP,既超过这个最大执行次数后才会停止重试。\npublic long nextBackOff() { this.currentAttempts++; if (this.currentAttempts \u0026lt;= getMaxAttempts()) { return getInterval(); } else { return STOP; } } 那么这个 getMaxAttempts 的值又是多少呢?回到最开始,当执行出错会进入 DefaultErrorHandler 。DefaultErrorHandler 默认的构造函数是:\npublic DefaultErrorHandler() { this(null, SeekUtils.DEFAULT_BACK_OFF); } SeekUtils.DEFAULT_BACK_OFF 定义的是:\npublic static final int DEFAULT_MAX_FAILURES = 10; public static final FixedBackOff DEFAULT_BACK_OFF = new FixedBackOff(0, DEFAULT_MAX_FAILURES - 1); DEFAULT_MAX_FAILURES 的值是 10,currentAttempts 从 0 到 9,所以总共会执行 10 次,每次重试的时间间隔为 0。\n最后,简单总结一下:Kafka 消费者在默认配置下会进行最多 10 次 的重试,每次重试的时间间隔为 0,即立即进行重试。如果在 10 次重试后仍然无法成功消费消息,则不再进行重试,消息将被视为消费失败。\n如何自定义重试次数以及时间间隔? # 从上面的代码可以知道,默认错误处理器的重试次数以及时间间隔是由 FixedBackOff 控制的,FixedBackOff 是 DefaultErrorHandler 初始化时默认的。所以自定义重试次数以及时间间隔,只需要在 DefaultErrorHandler 初始化的时候传入自定义的 FixedBackOff 即可。重新实现一个 KafkaListenerContainerFactory ,调用 setCommonErrorHandler 设置新的自定义的错误处理器就可以实现。\n@Bean public KafkaListenerContainerFactory kafkaListenerContainerFactory(ConsumerFactory\u0026lt;String, String\u0026gt; consumerFactory) { ConcurrentKafkaListenerContainerFactory factory = new ConcurrentKafkaListenerContainerFactory(); // 自定义重试时间间隔以及次数 FixedBackOff fixedBackOff = new FixedBackOff(1000, 5); factory.setCommonErrorHandler(new DefaultErrorHandler(fixedBackOff)); factory.setConsumerFactory(consumerFactory); return factory; } 如何在重试失败后进行告警? # 自定义重试失败后逻辑,需要手动实现,以下是一个简单的例子,重写 DefaultErrorHandler 的 handleRemaining 函数,加上自定义的告警等操作。\n@Slf4j public class DelErrorHandler extends DefaultErrorHandler { public DelErrorHandler(FixedBackOff backOff) { super(null,backOff); } @Override public void handleRemaining(Exception thrownException, List\u0026lt;ConsumerRecord\u0026lt;?, ?\u0026gt;\u0026gt; records, Consumer\u0026lt;?, ?\u0026gt; consumer, MessageListenerContainer container) { super.handleRemaining(thrownException, records, consumer, container); log.info(\u0026#34;重试多次失败\u0026#34;); // 自定义操作 } } DefaultErrorHandler 只是默认的一个错误处理器,Spring Kafka 还提供了 CommonErrorHandler 接口。手动实现 CommonErrorHandler 就可以实现更多的自定义操作,有很高的灵活性。例如根据不同的错误类型,实现不同的重试逻辑以及业务逻辑等。\n重试失败后的数据如何再次处理? # 当达到最大重试次数后,数据会直接被跳过,继续向后进行。当代码修复后,如何重新消费这些重试失败的数据呢?\n死信队列(Dead Letter Queue,简称 DLQ) 是消息中间件中的一种特殊队列。它主要用于处理无法被消费者正确处理的消息,通常是因为消息格式错误、处理失败、消费超时等情况导致的消息被\u0026quot;丢弃\u0026quot;或\u0026quot;死亡\u0026quot;的情况。当消息进入队列后,消费者会尝试处理它。如果处理失败,或者超过一定的重试次数仍无法被成功处理,消息可以发送到死信队列中,而不是被永久性地丢弃。在死信队列中,可以进一步分析、处理这些无法正常消费的消息,以便定位问题、修复错误,并采取适当的措施。\n@RetryableTopic 是 Spring Kafka 中的一个注解,它用于配置某个 Topic 支持消息重试,更推荐使用这个注解来完成重试。\n// 重试 5 次,重试间隔 100 毫秒,最大间隔 1 秒 @RetryableTopic( attempts = \u0026#34;5\u0026#34;, backoff = @Backoff(delay = 100, maxDelay = 1000) ) @KafkaListener(topics = {KafkaConst.TEST_TOPIC}, groupId = \u0026#34;apple\u0026#34;) private void customer(String message) { log.info(\u0026#34;kafka customer:{}\u0026#34;, message); Integer n = Integer.parseInt(message); if (n % 5 == 0) { throw new RuntimeException(); } System.out.println(n); } 当达到最大重试次数后,如果仍然无法成功处理消息,消息会被发送到对应的死信队列中。对于死信队列的处理,既可以用 @DltHandler 处理,也可以使用 @KafkaListener 重新消费。\n参考 # Kafka 官方文档: https://kafka.apache.org/documentation/ 极客时间—《Kafka 核心技术与实战》第 11 节:无消息丢失配置怎么实现? "},{"id":530,"href":"/zh/docs/technology/Interview/java/collection/linkedhashmap-source-code/","title":"LinkedHashMap 源码分析","section":"Collection","content":" LinkedHashMap 简介 # LinkedHashMap 是 Java 提供的一个集合类,它继承自 HashMap,并在 HashMap 基础上维护一条双向链表,使得具备如下特性:\n支持遍历时会按照插入顺序有序进行迭代。 支持按照元素访问顺序排序,适用于封装 LRU 缓存工具。 因为内部使用双向链表维护各个节点,所以遍历时的效率和元素个数成正比,相较于和容量成正比的 HashMap 来说,迭代效率会高很多。 LinkedHashMap 逻辑结构如下图所示,它是在 HashMap 基础上在各个节点之间维护一条双向链表,使得原本散列在不同 bucket 上的节点、链表、红黑树有序关联起来。\nLinkedHashMap 使用示例 # 插入顺序遍历 # 如下所示,我们按照顺序往 LinkedHashMap 添加元素然后进行遍历。\nHashMap \u0026lt; String, String \u0026gt; map = new LinkedHashMap \u0026lt; \u0026gt; (); map.put(\u0026#34;a\u0026#34;, \u0026#34;2\u0026#34;); map.put(\u0026#34;g\u0026#34;, \u0026#34;3\u0026#34;); map.put(\u0026#34;r\u0026#34;, \u0026#34;1\u0026#34;); map.put(\u0026#34;e\u0026#34;, \u0026#34;23\u0026#34;); for (Map.Entry \u0026lt; String, String \u0026gt; entry: map.entrySet()) { System.out.println(entry.getKey() + \u0026#34;:\u0026#34; + entry.getValue()); } 输出:\na:2 g:3 r:1 e:23 可以看出,LinkedHashMap 的迭代顺序是和插入顺序一致的,这一点是 HashMap 所不具备的。\n访问顺序遍历 # LinkedHashMap 定义了排序模式 accessOrder(boolean 类型,默认为 false),访问顺序则为 true,插入顺序则为 false。\n为了实现访问顺序遍历,我们可以使用传入 accessOrder 属性的 LinkedHashMap 构造方法,并将 accessOrder 设置为 true,表示其具备访问有序性。\nLinkedHashMap\u0026lt;Integer, String\u0026gt; map = new LinkedHashMap\u0026lt;\u0026gt;(16, 0.75f, true); map.put(1, \u0026#34;one\u0026#34;); map.put(2, \u0026#34;two\u0026#34;); map.put(3, \u0026#34;three\u0026#34;); map.put(4, \u0026#34;four\u0026#34;); map.put(5, \u0026#34;five\u0026#34;); //访问元素2,该元素会被移动至链表末端 map.get(2); //访问元素3,该元素会被移动至链表末端 map.get(3); for (Map.Entry\u0026lt;Integer, String\u0026gt; entry : map.entrySet()) { System.out.println(entry.getKey() + \u0026#34; : \u0026#34; + entry.getValue()); } 输出:\n1 : one 4 : four 5 : five 2 : two 3 : three 可以看出,LinkedHashMap 的迭代顺序是和访问顺序一致的。\nLRU 缓存 # 从上一个我们可以了解到通过 LinkedHashMap 我们可以封装一个简易版的 LRU(Least Recently Used,最近最少使用) 缓存,确保当存放的元素超过容器容量时,将最近最少访问的元素移除。\n具体实现思路如下:\n继承 LinkedHashMap; 构造方法中指定 accessOrder 为 true ,这样在访问元素时就会把该元素移动到链表尾部,链表首元素就是最近最少被访问的元素; 重写removeEldestEntry 方法,该方法会返回一个 boolean 值,告知 LinkedHashMap 是否需要移除链表首元素(缓存容量有限)。 public class LRUCache\u0026lt;K, V\u0026gt; extends LinkedHashMap\u0026lt;K, V\u0026gt; { private final int capacity; public LRUCache(int capacity) { super(capacity, 0.75f, true); this.capacity = capacity; } /** * 判断size超过容量时返回true,告知LinkedHashMap移除最老的缓存项(即链表的第一个元素) */ @Override protected boolean removeEldestEntry(Map.Entry\u0026lt;K, V\u0026gt; eldest) { return size() \u0026gt; capacity; } } 测试代码如下,笔者初始化缓存容量为 3,然后按照次序先后添加 4 个元素。\nLRUCache\u0026lt;Integer, String\u0026gt; cache = new LRUCache\u0026lt;\u0026gt;(3); cache.put(1, \u0026#34;one\u0026#34;); cache.put(2, \u0026#34;two\u0026#34;); cache.put(3, \u0026#34;three\u0026#34;); cache.put(4, \u0026#34;four\u0026#34;); cache.put(5, \u0026#34;five\u0026#34;); for (int i = 1; i \u0026lt;= 5; i++) { System.out.println(cache.get(i)); } 输出:\nnull null three four five 从输出结果来看,由于缓存容量为 3 ,因此,添加第 4 个元素时,第 1 个元素会被删除。添加第 5 个元素时,第 2 个元素会被删除。\nLinkedHashMap 源码解析 # Node 的设计 # 在正式讨论 LinkedHashMap 前,我们先来聊聊 LinkedHashMap 节点 Entry 的设计,我们都知道 HashMap 的 bucket 上的因为冲突转为链表的节点会在符合以下两个条件时会将链表转为红黑树:\n链表上的节点个数达到树化的阈值 7,即TREEIFY_THRESHOLD - 1。 bucket 的容量达到最小的树化容量即MIN_TREEIFY_CAPACITY。 🐛 修正(参见: issue#2147):\n链表上的节点个数达到树化的阈值是 8 而非 7。因为源码的判断是从链表初始元素开始遍历,下标是从 0 开始的,所以判断条件设置为 8-1=7,其实是迭代到尾部元素时再判断整个链表长度大于等于 8 才进行树化操作。\n而 LinkedHashMap 是在 HashMap 的基础上为 bucket 上的每一个节点建立一条双向链表,这就使得转为红黑树的树节点也需要具备双向链表节点的特性,即每一个树节点都需要拥有两个引用存储前驱节点和后继节点的地址,所以对于树节点类 TreeNode 的设计就是一个比较棘手的问题。\n对此我们不妨来看看两者之间节点类的类图,可以看到:\nLinkedHashMap 的节点内部类 Entry 基于 HashMap 的基础上,增加 before 和 after 指针使节点具备双向链表的特性。 HashMap 的树节点 TreeNode 继承了具备双向链表特性的 LinkedHashMap 的 Entry。 很多读者此时就会有这样一个疑问,为什么 HashMap 的树节点 TreeNode 要通过 LinkedHashMap 获取双向链表的特性呢?为什么不直接在 Node 上实现前驱和后继指针呢?\n先来回答第一个问题,我们都知道 LinkedHashMap 是在 HashMap 基础上对节点增加双向指针实现双向链表的特性,所以 LinkedHashMap 内部链表转红黑树时,对应的节点会转为树节点 TreeNode,为了保证使用 LinkedHashMap 时树节点具备双向链表的特性,所以树节点 TreeNode 需要继承 LinkedHashMap 的 Entry。\n再来说说第二个问题,我们直接在 HashMap 的节点 Node 上直接实现前驱和后继指针,然后 TreeNode 直接继承 Node 获取双向链表的特性为什么不行呢?其实这样做也是可以的。只不过这种做法会使得使用 HashMap 时存储键值对的节点类 Node 多了两个没有必要的引用,占用没必要的内存空间。\n所以,为了保证 HashMap 底层的节点类 Node 没有多余的引用,又要保证 LinkedHashMap 的节点类 Entry 拥有存储链表的引用,设计者就让 LinkedHashMap 的节点 Entry 去继承 Node 并增加存储前驱后继节点的引用 before、after,让需要用到链表特性的节点去实现需要的逻辑。然后树节点 TreeNode 再通过继承 Entry 获取 before、after 两个指针。\nstatic class Entry\u0026lt;K,V\u0026gt; extends HashMap.Node\u0026lt;K,V\u0026gt; { Entry\u0026lt;K,V\u0026gt; before, after; Entry(int hash, K key, V value, Node\u0026lt;K,V\u0026gt; next) { super(hash, key, value, next); } } 但是这样做,不也使得使用 HashMap 时的 TreeNode 多了两个没有必要的引用吗?这不也是一种空间的浪费吗?\nstatic final class TreeNode\u0026lt;K,V\u0026gt; extends LinkedHashMap.Entry\u0026lt;K,V\u0026gt; { //略 } 对于这个问题,引用作者的一段注释,作者们认为在良好的 hashCode 算法时,HashMap 转红黑树的概率不大。就算转为红黑树变为树节点,也可能会因为移除或者扩容将 TreeNode 变为 Node,所以 TreeNode 的使用概率不算很大,对于这一点资源空间的浪费是可以接受的。\nBecause TreeNodes are about twice the size of regular nodes, we use them only when bins contain enough nodes to warrant use (see TREEIFY_THRESHOLD). And when they become too small (due to removal or resizing) they are converted back to plain bins. In usages with well-distributed user hashCodes, tree bins are rarely used. Ideally, under random hashCodes, the frequency of nodes in bins follows a Poisson distribution 构造方法 # LinkedHashMap 构造方法有 4 个实现也比较简单,直接调用父类即 HashMap 的构造方法完成初始化。\npublic LinkedHashMap() { super(); accessOrder = false; } public LinkedHashMap(int initialCapacity) { super(initialCapacity); accessOrder = false; } public LinkedHashMap(int initialCapacity, float loadFactor) { super(initialCapacity, loadFactor); accessOrder = false; } public LinkedHashMap(int initialCapacity, float loadFactor, boolean accessOrder) { super(initialCapacity, loadFactor); this.accessOrder = accessOrder; } 我们上面也提到了,默认情况下 accessOrder 为 false,如果我们要让 LinkedHashMap 实现键值对按照访问顺序排序(即将最近未访问的元素排在链表首部、最近访问的元素移动到链表尾部),需要调用第 4 个构造方法将 accessOrder 设置为 true。\nget 方法 # get 方法是 LinkedHashMap 增删改查操作中唯一一个重写的方法, accessOrder 为 true 的情况下, 它会在元素查询完成之后,将当前访问的元素移到链表的末尾。\npublic V get(Object key) { Node \u0026lt; K, V \u0026gt; e; //获取key的键值对,若为空直接返回 if ((e = getNode(hash(key), key)) == null) return null; //若accessOrder为true,则调用afterNodeAccess将当前元素移到链表末尾 if (accessOrder) afterNodeAccess(e); //返回键值对的值 return e.value; } 从源码可以看出,get 的执行步骤非常简单:\n调用父类即 HashMap 的 getNode 获取键值对,若为空则直接返回。 判断 accessOrder 是否为 true,若为 true 则说明需要保证 LinkedHashMap 的链表访问有序性,执行步骤 3。 调用 LinkedHashMap 重写的 afterNodeAccess 将当前元素添加到链表末尾。 关键点在于 afterNodeAccess 方法的实现,这个方法负责将元素移动到链表末尾。\nvoid afterNodeAccess(Node \u0026lt; K, V \u0026gt; e) { // move node to last LinkedHashMap.Entry \u0026lt; K, V \u0026gt; last; //如果accessOrder 且当前节点不为链表尾节点 if (accessOrder \u0026amp;\u0026amp; (last = tail) != e) { //获取当前节点、以及前驱节点和后继节点 LinkedHashMap.Entry \u0026lt; K, V \u0026gt; p = (LinkedHashMap.Entry \u0026lt; K, V \u0026gt; ) e, b = p.before, a = p.after; //将当前节点的后继节点指针指向空,使其和后继节点断开联系 p.after = null; //如果前驱节点为空,则说明当前节点是链表的首节点,故将后继节点设置为首节点 if (b == null) head = a; else //如果前驱节点不为空,则让前驱节点指向后继节点 b.after = a; //如果后继节点不为空,则让后继节点指向前驱节点 if (a != null) a.before = b; else //如果后继节点为空,则说明当前节点在链表最末尾,直接让last 指向前驱节点,这个 else其实 没有意义,因为最开头if已经确保了p不是尾结点了,自然after不会是null last = b; //如果last为空,则说明当前链表只有一个节点p,则将head指向p if (last == null) head = p; else { //反之让p的前驱指针指向尾节点,再让尾节点的前驱指针指向p p.before = last; last.after = p; } //tail指向p,自此将节点p移动到链表末尾 tail = p; ++modCount; } } 从源码可以看出, afterNodeAccess 方法完成了下面这些操作:\n如果 accessOrder 为 true 且链表尾部不为当前节点 p,我们则需要将当前节点移到链表尾部。 获取当前节点 p、以及它的前驱节点 b 和后继节点 a。 将当前节点 p 的后继指针设置为 null,使其和后继节点 p 断开联系。 尝试将前驱节点指向后继节点,若前驱节点为空,则说明当前节点 p 就是链表首节点,故直接将后继节点 a 设置为首节点,随后我们再将 p 追加到 a 的末尾。 再尝试让后继节点 a 指向前驱节点 b。 上述操作让前驱节点和后继节点完成关联,并将当前节点 p 独立出来,这一步则是将当前节点 p 追加到链表末端,如果链表末端为空,则说明当前链表只有一个节点 p,所以直接让 head 指向 p 即可。 上述操作已经将 p 成功到达链表末端,最后我们将 tail 指针即指向链表末端的指针指向 p 即可。 可以结合这张图理解,展示了 key 为 13 的元素被移动到了链表尾部。\n看不太懂也没关系,知道这个方法的作用就够了,后续有时间再慢慢消化。\nremove 方法后置操作——afterNodeRemoval # LinkedHashMap 并没有对 remove 方法进行重写,而是直接继承 HashMap 的 remove 方法,为了保证键值对移除后双向链表中的节点也会同步被移除,LinkedHashMap 重写了 HashMap 的空实现方法 afterNodeRemoval。\nfinal Node\u0026lt;K,V\u0026gt; removeNode(int hash, Object key, Object value, boolean matchValue, boolean movable) { //略 if (node != null \u0026amp;\u0026amp; (!matchValue || (v = node.value) == value || (value != null \u0026amp;\u0026amp; value.equals(v)))) { if (node instanceof TreeNode) ((TreeNode\u0026lt;K,V\u0026gt;)node).removeTreeNode(this, tab, movable); else if (node == p) tab[index] = node.next; else p.next = node.next; ++modCount; --size; //HashMap的removeNode完成元素移除后会调用afterNodeRemoval进行移除后置操作 afterNodeRemoval(node); return node; } } return null; } //空实现 void afterNodeRemoval(Node\u0026lt;K,V\u0026gt; p) { } 我们可以看到从 HashMap 继承来的 remove 方法内部调用的 removeNode 方法将节点从 bucket 删除后,调用了 afterNodeRemoval。\nvoid afterNodeRemoval(Node\u0026lt;K,V\u0026gt; e) { // unlink //获取当前节点p、以及e的前驱节点b和后继节点a LinkedHashMap.Entry\u0026lt;K,V\u0026gt; p = (LinkedHashMap.Entry\u0026lt;K,V\u0026gt;)e, b = p.before, a = p.after; //将p的前驱和后继指针都设置为null,使其和前驱、后继节点断开联系 p.before = p.after = null; //如果前驱节点为空,则说明当前节点p是链表首节点,让head指针指向后继节点a即可 if (b == null) head = a; else //如果前驱节点b不为空,则让b直接指向后继节点a b.after = a; //如果后继节点为空,则说明当前节点p在链表末端,所以直接让tail指针指向前驱节点a即可 if (a == null) tail = b; else //反之后继节点的前驱指针直接指向前驱节点 a.before = b; } 从源码可以看出, afterNodeRemoval 方法的整体操作就是让当前节点 p 和前驱节点、后继节点断开联系,等待 gc 回收,整体步骤为:\n获取当前节点 p、以及 p 的前驱节点 b 和后继节点 a。 让当前节点 p 和其前驱、后继节点断开联系。 尝试让前驱节点 b 指向后继节点 a,若 b 为空则说明当前节点 p 在链表首部,我们直接将 head 指向后继节点 a 即可。 尝试让后继节点 a 指向前驱节点 b,若 a 为空则说明当前节点 p 在链表末端,所以直接让 tail 指针指向前驱节点 b 即可。 可以结合这张图理解,展示了 key 为 13 的元素被删除,也就是从链表中移除了这个元素。\n看不太懂也没关系,知道这个方法的作用就够了,后续有时间再慢慢消化。\nput 方法后置操作——afterNodeInsertion # 同样的 LinkedHashMap 并没有实现插入方法,而是直接继承 HashMap 的所有插入方法交由用户使用,但为了维护双向链表访问的有序性,它做了这样两件事:\n重写 afterNodeAccess(上文提到过),如果当前被插入的 key 已存在与 map 中,因为 LinkedHashMap 的插入操作会将新节点追加至链表末尾,所以对于存在的 key 则调用 afterNodeAccess 将其放到链表末端。 重写了 HashMap 的 afterNodeInsertion 方法,当 removeEldestEntry 返回 true 时,会将链表首节点移除。 这一点我们可以在 HashMap 的插入操作核心方法 putVal 中看到。\nfinal V putVal(int hash, K key, V value, boolean onlyIfAbsent, boolean evict) { //略 if (e != null) { // existing mapping for key V oldValue = e.value; if (!onlyIfAbsent || oldValue == null) e.value = value; //如果当前的key在map中存在,则调用afterNodeAccess afterNodeAccess(e); return oldValue; } } ++modCount; if (++size \u0026gt; threshold) resize(); //调用插入后置方法,该方法被LinkedHashMap重写 afterNodeInsertion(evict); return null; } 上述步骤的源码上文已经解释过了,所以这里我们着重了解一下 afterNodeInsertion 的工作流程,假设我们的重写了 removeEldestEntry,当链表 size 超过 capacity 时,就返回 true。\n/** * 判断size超过容量时返回true,告知LinkedHashMap移除最老的缓存项(即链表的第一个元素) */ protected boolean removeEldestEntry(Map.Entry \u0026lt; K, V \u0026gt; eldest) { return size() \u0026gt; capacity; } 以下图为例,假设笔者最后新插入了一个不存在的节点 19,假设 capacity 为 4,所以 removeEldestEntry 返回 true,我们要将链表首节点移除。\n移除的步骤很简单,查看链表首节点是否存在,若存在则断开首节点和后继节点的关系,并让首节点指针指向下一节点,所以 head 指针指向了 12,节点 10 成为没有任何引用指向的空对象,等待 GC。\nvoid afterNodeInsertion(boolean evict) { // possibly remove eldest LinkedHashMap.Entry\u0026lt;K,V\u0026gt; first; //如果evict为true且队首元素不为空以及removeEldestEntry返回true,则说明我们需要最老的元素(即在链表首部的元素)移除。 if (evict \u0026amp;\u0026amp; (first = head) != null \u0026amp;\u0026amp; removeEldestEntry(first)) { //获取链表首部的键值对的key K key = first.key; //调用removeNode将元素从HashMap的bucket中移除,并和LinkedHashMap的双向链表断开,等待gc回收 removeNode(hash(key), key, null, false, true); } } 从源码可以看出, afterNodeInsertion 方法完成了下面这些操作:\n判断 eldest 是否为 true,只有为 true 才能说明可能需要将最年长的键值对(即链表首部的元素)进行移除,具体是否具体要进行移除,还得确定链表是否为空((first = head) != null),以及 removeEldestEntry 方法是否返回 true,只有这两个方法返回 true 才能确定当前链表不为空,且链表需要进行移除操作了。 获取链表第一个元素的 key。 调用 HashMap 的 removeNode 方法,该方法我们上文提到过,它会将节点从 HashMap 的 bucket 中移除,并且 LinkedHashMap 还重写了 removeNode 中的 afterNodeRemoval 方法,所以这一步将通过调用 removeNode 将元素从 HashMap 的 bucket 中移除,并和 LinkedHashMap 的双向链表断开,等待 gc 回收。 LinkedHashMap 和 HashMap 遍历性能比较 # LinkedHashMap 维护了一个双向链表来记录数据插入的顺序,因此在迭代遍历生成的迭代器的时候,是按照双向链表的路径进行遍历的。这一点相比于 HashMap 那种遍历整个 bucket 的方式来说,高效许多。\n这一点我们可以从两者的迭代器中得以印证,先来看看 HashMap 的迭代器,可以看到 HashMap 迭代键值对时会用到一个 nextNode 方法,该方法会返回 next 指向的下一个元素,并会从 next 开始遍历 bucket 找到下一个 bucket 中不为空的元素 Node。\nfinal class EntryIterator extends HashIterator implements Iterator \u0026lt; Map.Entry \u0026lt; K, V \u0026gt;\u0026gt; { public final Map.Entry \u0026lt; K, V \u0026gt; next() { return nextNode(); } } //获取下一个Node final Node \u0026lt; K, V \u0026gt; nextNode() { Node \u0026lt; K, V \u0026gt; [] t; //获取下一个元素next Node \u0026lt; K, V \u0026gt; e = next; if (modCount != expectedModCount) throw new ConcurrentModificationException(); if (e == null) throw new NoSuchElementException(); //将next指向bucket中下一个不为空的Node if ((next = (current = e).next) == null \u0026amp;\u0026amp; (t = table) != null) { do {} while (index \u0026lt; t.length \u0026amp;\u0026amp; (next = t[index++]) == null); } return e; } 相比之下 LinkedHashMap 的迭代器则是直接使用通过 after 指针快速定位到当前节点的后继节点,简洁高效许多。\nfinal class LinkedEntryIterator extends LinkedHashIterator implements Iterator \u0026lt; Map.Entry \u0026lt; K, V \u0026gt;\u0026gt; { public final Map.Entry \u0026lt; K, V \u0026gt; next() { return nextNode(); } } //获取下一个Node final LinkedHashMap.Entry \u0026lt; K, V \u0026gt; nextNode() { //获取下一个节点next LinkedHashMap.Entry \u0026lt; K, V \u0026gt; e = next; if (modCount != expectedModCount) throw new ConcurrentModificationException(); if (e == null) throw new NoSuchElementException(); //current 指针指向当前节点 current = e; //next直接当前节点的after指针快速定位到下一个节点 next = e.after; return e; } 为了验证笔者所说的观点,笔者对这两个容器进行了压测,测试插入 1000w 和迭代 1000w 条数据的耗时,代码如下:\nint count = 1000_0000; Map\u0026lt;Integer, Integer\u0026gt; hashMap = new HashMap\u0026lt;\u0026gt;(); Map\u0026lt;Integer, Integer\u0026gt; linkedHashMap = new LinkedHashMap\u0026lt;\u0026gt;(); long start, end; start = System.currentTimeMillis(); for (int i = 0; i \u0026lt; count; i++) { hashMap.put(ThreadLocalRandom.current().nextInt(1, count), ThreadLocalRandom.current().nextInt(0, count)); } end = System.currentTimeMillis(); System.out.println(\u0026#34;map time putVal: \u0026#34; + (end - start)); start = System.currentTimeMillis(); for (int i = 0; i \u0026lt; count; i++) { linkedHashMap.put(ThreadLocalRandom.current().nextInt(1, count), ThreadLocalRandom.current().nextInt(0, count)); } end = System.currentTimeMillis(); System.out.println(\u0026#34;linkedHashMap putVal time: \u0026#34; + (end - start)); start = System.currentTimeMillis(); long num = 0; for (Integer v : hashMap.values()) { num = num + v; } end = System.currentTimeMillis(); System.out.println(\u0026#34;map get time: \u0026#34; + (end - start)); start = System.currentTimeMillis(); for (Integer v : linkedHashMap.values()) { num = num + v; } end = System.currentTimeMillis(); System.out.println(\u0026#34;linkedHashMap get time: \u0026#34; + (end - start)); System.out.println(num); 从输出结果来看,因为 LinkedHashMap 需要维护双向链表的缘故,插入元素相较于 HashMap 会更耗时,但是有了双向链表明确的前后节点关系,迭代效率相对于前者高效了许多。不过,总体来说却别不大,毕竟数据量这么庞大。\nmap time putVal: 5880 linkedHashMap putVal time: 7567 map get time: 143 linkedHashMap get time: 67 63208969074998 LinkedHashMap 常见面试题 # 什么是 LinkedHashMap? # LinkedHashMap 是 Java 集合框架中 HashMap 的一个子类,它继承了 HashMap 的所有属性和方法,并且在 HashMap 的基础重写了 afterNodeRemoval、afterNodeInsertion、afterNodeAccess 方法。使之拥有顺序插入和访问有序的特性。\nLinkedHashMap 如何按照插入顺序迭代元素? # LinkedHashMap 按照插入顺序迭代元素是它的默认行为。LinkedHashMap 内部维护了一个双向链表,用于记录元素的插入顺序。因此,当使用迭代器迭代元素时,元素的顺序与它们最初插入的顺序相同。\nLinkedHashMap 如何按照访问顺序迭代元素? # LinkedHashMap 可以通过构造函数中的 accessOrder 参数指定按照访问顺序迭代元素。当 accessOrder 为 true 时,每次访问一个元素时,该元素会被移动到链表的末尾,因此下次访问该元素时,它就会成为链表中的最后一个元素,从而实现按照访问顺序迭代元素。\nLinkedHashMap 如何实现 LRU 缓存? # 将 accessOrder 设置为 true 并重写 removeEldestEntry 方法当链表大小超过容量时返回 true,使得每次访问一个元素时,该元素会被移动到链表的末尾。一旦插入操作让 removeEldestEntry 返回 true 时,视为缓存已满,LinkedHashMap 就会将链表首元素移除,由此我们就能实现一个 LRU 缓存。\nLinkedHashMap 和 HashMap 有什么区别? # LinkedHashMap 和 HashMap 都是 Java 集合框架中的 Map 接口的实现类。它们的最大区别在于迭代元素的顺序。HashMap 迭代元素的顺序是不确定的,而 LinkedHashMap 提供了按照插入顺序或访问顺序迭代元素的功能。此外,LinkedHashMap 内部维护了一个双向链表,用于记录元素的插入顺序或访问顺序,而 HashMap 则没有这个链表。因此,LinkedHashMap 的插入性能可能会比 HashMap 略低,但它提供了更多的功能并且迭代效率相较于 HashMap 更加高效。\n参考文献 # LinkedHashMap 源码详细分析(JDK1.8): https://www.imooc.com/article/22931 HashMap 与 LinkedHashMap: https://www.cnblogs.com/Spground/p/8536148.html 源于 LinkedHashMap 源码: https://leetcode.cn/problems/lru-cache/solution/yuan-yu-linkedhashmapyuan-ma-by-jeromememory/ "},{"id":531,"href":"/zh/docs/technology/Interview/java/collection/linkedlist-source-code/","title":"LinkedList 源码分析","section":"Collection","content":" LinkedList 简介 # LinkedList 是一个基于双向链表实现的集合类,经常被拿来和 ArrayList 做比较。关于 LinkedList 和ArrayList的详细对比,我们 Java 集合常见面试题总结(上)有详细介绍到。\n不过,我们在项目中一般是不会使用到 LinkedList 的,需要用到 LinkedList 的场景几乎都可以使用 ArrayList 来代替,并且,性能通常会更好!就连 LinkedList 的作者约书亚 · 布洛克(Josh Bloch)自己都说从来不会使用 LinkedList 。\n另外,不要下意识地认为 LinkedList 作为链表就最适合元素增删的场景。我在上面也说了,LinkedList 仅仅在头尾插入或者删除元素的时候时间复杂度近似 O(1),其他情况增删元素的平均时间复杂度都是 O(n) 。\nLinkedList 插入和删除元素的时间复杂度? # 头部插入/删除:只需要修改头结点的指针即可完成插入/删除操作,因此时间复杂度为 O(1)。 尾部插入/删除:只需要修改尾结点的指针即可完成插入/删除操作,因此时间复杂度为 O(1)。 指定位置插入/删除:需要先移动到指定位置,再修改指定节点的指针完成插入/删除,不过由于有头尾指针,可以从较近的指针出发,因此需要遍历平均 n/4 个元素,时间复杂度为 O(n)。 LinkedList 为什么不能实现 RandomAccess 接口? # RandomAccess 是一个标记接口,用来表明实现该接口的类支持随机访问(即可以通过索引快速访问元素)。由于 LinkedList 底层数据结构是链表,内存地址不连续,只能通过指针来定位,不支持随机快速访问,所以不能实现 RandomAccess 接口。\nLinkedList 源码分析 # 这里以 JDK1.8 为例,分析一下 LinkedList 的底层核心源码。\nLinkedList 的类定义如下:\npublic class LinkedList\u0026lt;E\u0026gt; extends AbstractSequentialList\u0026lt;E\u0026gt; implements List\u0026lt;E\u0026gt;, Deque\u0026lt;E\u0026gt;, Cloneable, java.io.Serializable { //... } LinkedList 继承了 AbstractSequentialList ,而 AbstractSequentialList 又继承于 AbstractList 。\n阅读过 ArrayList 的源码我们就知道,ArrayList 同样继承了 AbstractList , 所以 LinkedList 会有大部分方法和 ArrayList 相似。\nLinkedList 实现了以下接口:\nList : 表明它是一个列表,支持添加、删除、查找等操作,并且可以通过下标进行访问。 Deque :继承自 Queue 接口,具有双端队列的特性,支持从两端插入和删除元素,方便实现栈和队列等数据结构。需要注意,Deque 的发音为 \u0026ldquo;deck\u0026rdquo; [dɛk],这个大部分人都会读错。 Cloneable :表明它具有拷贝能力,可以进行深拷贝或浅拷贝操作。 Serializable : 表明它可以进行序列化操作,也就是可以将对象转换为字节流进行持久化存储或网络传输,非常方便。 LinkedList 中的元素是通过 Node 定义的:\nprivate static class Node\u0026lt;E\u0026gt; { E item;// 节点值 Node\u0026lt;E\u0026gt; next; // 指向的下一个节点(后继节点) Node\u0026lt;E\u0026gt; prev; // 指向的前一个节点(前驱结点) // 初始化参数顺序分别是:前驱结点、本身节点值、后继节点 Node(Node\u0026lt;E\u0026gt; prev, E element, Node\u0026lt;E\u0026gt; next) { this.item = element; this.next = next; this.prev = prev; } } 初始化 # LinkedList 中有一个无参构造函数和一个有参构造函数。\n// 创建一个空的链表对象 public LinkedList() { } // 接收一个集合类型作为参数,会创建一个与传入集合相同元素的链表对象 public LinkedList(Collection\u0026lt;? extends E\u0026gt; c) { this(); addAll(c); } 插入元素 # LinkedList 除了实现了 List 接口相关方法,还实现了 Deque 接口的很多方法,所以我们有很多种方式插入元素。\n我们这里以 List 接口中相关的插入方法为例进行源码讲解,对应的是add() 方法。\nadd() 方法有两个版本:\nadd(E e):用于在 LinkedList 的尾部插入元素,即将新元素作为链表的最后一个元素,时间复杂度为 O(1)。 add(int index, E element):用于在指定位置插入元素。这种插入方式需要先移动到指定位置,再修改指定节点的指针完成插入/删除,因此需要移动平均 n/4 个元素,时间复杂度为 O(n)。 // 在链表尾部插入元素 public boolean add(E e) { linkLast(e); return true; } // 在链表指定位置插入元素 public void add(int index, E element) { // 下标越界检查 checkPositionIndex(index); // 判断 index 是不是链表尾部位置 if (index == size) // 如果是就直接调用 linkLast 方法将元素节点插入链表尾部即可 linkLast(element); else // 如果不是则调用 linkBefore 方法将其插入指定元素之前 linkBefore(element, node(index)); } // 将元素节点插入到链表尾部 void linkLast(E e) { // 将最后一个元素赋值(引用传递)给节点 l final Node\u0026lt;E\u0026gt; l = last; // 创建节点,并指定节点前驱为链表尾节点 last,后继引用为空 final Node\u0026lt;E\u0026gt; newNode = new Node\u0026lt;\u0026gt;(l, e, null); // 将 last 引用指向新节点 last = newNode; // 判断尾节点是否为空 // 如果 l 是null 意味着这是第一次添加元素 if (l == null) // 如果是第一次添加,将first赋值为新节点,此时链表只有一个元素 first = newNode; else // 如果不是第一次添加,将新节点赋值给l(添加前的最后一个元素)的next l.next = newNode; size++; modCount++; } // 在指定元素之前插入元素 void linkBefore(E e, Node\u0026lt;E\u0026gt; succ) { // assert succ != null;断言 succ不为 null // 定义一个节点元素保存 succ 的 prev 引用,也就是它的前一节点信息 final Node\u0026lt;E\u0026gt; pred = succ.prev; // 初始化节点,并指明前驱和后继节点 final Node\u0026lt;E\u0026gt; newNode = new Node\u0026lt;\u0026gt;(pred, e, succ); // 将 succ 节点前驱引用 prev 指向新节点 succ.prev = newNode; // 判断前驱节点是否为空,为空表示 succ 是第一个节点 if (pred == null) // 新节点成为第一个节点 first = newNode; else // succ 节点前驱的后继引用指向新节点 pred.next = newNode; size++; modCount++; } 获取元素 # LinkedList获取元素相关的方法一共有 3 个:\ngetFirst():获取链表的第一个元素。 getLast():获取链表的最后一个元素。 get(int index):获取链表指定位置的元素。 // 获取链表的第一个元素 public E getFirst() { final Node\u0026lt;E\u0026gt; f = first; if (f == null) throw new NoSuchElementException(); return f.item; } // 获取链表的最后一个元素 public E getLast() { final Node\u0026lt;E\u0026gt; l = last; if (l == null) throw new NoSuchElementException(); return l.item; } // 获取链表指定位置的元素 public E get(int index) { // 下标越界检查,如果越界就抛异常 checkElementIndex(index); // 返回链表中对应下标的元素 return node(index).item; } 这里的核心在于 node(int index) 这个方法:\n// 返回指定下标的非空节点 Node\u0026lt;E\u0026gt; node(int index) { // 断言下标未越界 // assert isElementIndex(index); // 如果index小于size的二分之一 从前开始查找(向后查找) 反之向前查找 if (index \u0026lt; (size \u0026gt;\u0026gt; 1)) { Node\u0026lt;E\u0026gt; x = first; // 遍历,循环向后查找,直至 i == index for (int i = 0; i \u0026lt; index; i++) x = x.next; return x; } else { Node\u0026lt;E\u0026gt; x = last; for (int i = size - 1; i \u0026gt; index; i--) x = x.prev; return x; } } get(int index) 或 remove(int index) 等方法内部都调用了该方法来获取对应的节点。\n从这个方法的源码可以看出,该方法通过比较索引值与链表 size 的一半大小来确定从链表头还是尾开始遍历。如果索引值小于 size 的一半,就从链表头开始遍历,反之从链表尾开始遍历。这样可以在较短的时间内找到目标节点,充分利用了双向链表的特性来提高效率。\n删除元素 # LinkedList删除元素相关的方法一共有 5 个:\nremoveFirst():删除并返回链表的第一个元素。 removeLast():删除并返回链表的最后一个元素。 remove(E e):删除链表中首次出现的指定元素,如果不存在该元素则返回 false。 remove(int index):删除指定索引处的元素,并返回该元素的值。 void clear():移除此链表中的所有元素。 // 删除并返回链表的第一个元素 public E removeFirst() { final Node\u0026lt;E\u0026gt; f = first; if (f == null) throw new NoSuchElementException(); return unlinkFirst(f); } // 删除并返回链表的最后一个元素 public E removeLast() { final Node\u0026lt;E\u0026gt; l = last; if (l == null) throw new NoSuchElementException(); return unlinkLast(l); } // 删除链表中首次出现的指定元素,如果不存在该元素则返回 false public boolean remove(Object o) { // 如果指定元素为 null,遍历链表找到第一个为 null 的元素进行删除 if (o == null) { for (Node\u0026lt;E\u0026gt; x = first; x != null; x = x.next) { if (x.item == null) { unlink(x); return true; } } } else { // 如果不为 null ,遍历链表找到要删除的节点 for (Node\u0026lt;E\u0026gt; x = first; x != null; x = x.next) { if (o.equals(x.item)) { unlink(x); return true; } } } return false; } // 删除链表指定位置的元素 public E remove(int index) { // 下标越界检查,如果越界就抛异常 checkElementIndex(index); return unlink(node(index)); } 这里的核心在于 unlink(Node\u0026lt;E\u0026gt; x) 这个方法:\nE unlink(Node\u0026lt;E\u0026gt; x) { // 断言 x 不为 null // assert x != null; // 获取当前节点(也就是待删除节点)的元素 final E element = x.item; // 获取当前节点的下一个节点 final Node\u0026lt;E\u0026gt; next = x.next; // 获取当前节点的前一个节点 final Node\u0026lt;E\u0026gt; prev = x.prev; // 如果前一个节点为空,则说明当前节点是头节点 if (prev == null) { // 直接让链表头指向当前节点的下一个节点 first = next; } else { // 如果前一个节点不为空 // 将前一个节点的 next 指针指向当前节点的下一个节点 prev.next = next; // 将当前节点的 prev 指针置为 null,,方便 GC 回收 x.prev = null; } // 如果下一个节点为空,则说明当前节点是尾节点 if (next == null) { // 直接让链表尾指向当前节点的前一个节点 last = prev; } else { // 如果下一个节点不为空 // 将下一个节点的 prev 指针指向当前节点的前一个节点 next.prev = prev; // 将当前节点的 next 指针置为 null,方便 GC 回收 x.next = null; } // 将当前节点元素置为 null,方便 GC 回收 x.item = null; size--; modCount++; return element; } unlink() 方法的逻辑如下:\n首先获取待删除节点 x 的前驱和后继节点; 判断待删除节点是否为头节点或尾节点: 如果 x 是头节点,则将 first 指向 x 的后继节点 next 如果 x 是尾节点,则将 last 指向 x 的前驱节点 prev 如果 x 不是头节点也不是尾节点,执行下一步操作 将待删除节点 x 的前驱的后继指向待删除节点的后继 next,断开 x 和 x.prev 之间的链接; 将待删除节点 x 的后继的前驱指向待删除节点的前驱 prev,断开 x 和 x.next 之间的链接; 将待删除节点 x 的元素置空,修改链表长度。 可以参考下图理解(图源: LinkedList 源码分析(JDK 1.8)):\n遍历链表 # 推荐使用for-each 循环来遍历 LinkedList 中的元素, for-each 循环最终会转换成迭代器形式。\nLinkedList\u0026lt;String\u0026gt; list = new LinkedList\u0026lt;\u0026gt;(); list.add(\u0026#34;apple\u0026#34;); list.add(\u0026#34;banana\u0026#34;); list.add(\u0026#34;pear\u0026#34;); for (String fruit : list) { System.out.println(fruit); } LinkedList 的遍历的核心就是它的迭代器的实现。\n// 双向迭代器 private class ListItr implements ListIterator\u0026lt;E\u0026gt; { // 表示上一次调用 next() 或 previous() 方法时经过的节点; private Node\u0026lt;E\u0026gt; lastReturned; // 表示下一个要遍历的节点; private Node\u0026lt;E\u0026gt; next; // 表示下一个要遍历的节点的下标,也就是当前节点的后继节点的下标; private int nextIndex; // 表示当前遍历期望的修改计数值,用于和 LinkedList 的 modCount 比较,判断链表是否被其他线程修改过。 private int expectedModCount = modCount; ………… } 下面我们对迭代器 ListItr 中的核心方法进行详细介绍。\n我们先来看下从头到尾方向的迭代:\n// 判断还有没有下一个节点 public boolean hasNext() { // 判断下一个节点的下标是否小于链表的大小,如果是则表示还有下一个元素可以遍历 return nextIndex \u0026lt; size; } // 获取下一个节点 public E next() { // 检查在迭代过程中链表是否被修改过 checkForComodification(); // 判断是否还有下一个节点可以遍历,如果没有则抛出 NoSuchElementException 异常 if (!hasNext()) throw new NoSuchElementException(); // 将 lastReturned 指向当前节点 lastReturned = next; // 将 next 指向下一个节点 next = next.next; nextIndex++; return lastReturned.item; } 再来看一下从尾到头方向的迭代:\n// 判断是否还有前一个节点 public boolean hasPrevious() { return nextIndex \u0026gt; 0; } // 获取前一个节点 public E previous() { // 检查是否在迭代过程中链表被修改 checkForComodification(); // 如果没有前一个节点,则抛出异常 if (!hasPrevious()) throw new NoSuchElementException(); // 将 lastReturned 和 next 指针指向上一个节点 lastReturned = next = (next == null) ? last : next.prev; nextIndex--; return lastReturned.item; } 如果需要删除或插入元素,也可以使用迭代器进行操作。\nLinkedList\u0026lt;String\u0026gt; list = new LinkedList\u0026lt;\u0026gt;(); list.add(\u0026#34;apple\u0026#34;); list.add(null); list.add(\u0026#34;banana\u0026#34;); // Collection 接口的 removeIf 方法底层依然是基于迭代器 list.removeIf(Objects::isNull); for (String fruit : list) { System.out.println(fruit); } 迭代器对应的移除元素的方法如下:\n// 从列表中删除上次被返回的元素 public void remove() { // 检查是否在迭代过程中链表被修改 checkForComodification(); // 如果上次返回的节点为空,则抛出异常 if (lastReturned == null) throw new IllegalStateException(); // 获取当前节点的下一个节点 Node\u0026lt;E\u0026gt; lastNext = lastReturned.next; // 从链表中删除上次返回的节点 unlink(lastReturned); // 修改指针 if (next == lastReturned) next = lastNext; else nextIndex--; // 将上次返回的节点引用置为 null,方便 GC 回收 lastReturned = null; expectedModCount++; } LinkedList 常用方法测试 # 代码:\n// 创建 LinkedList 对象 LinkedList\u0026lt;String\u0026gt; list = new LinkedList\u0026lt;\u0026gt;(); // 添加元素到链表末尾 list.add(\u0026#34;apple\u0026#34;); list.add(\u0026#34;banana\u0026#34;); list.add(\u0026#34;pear\u0026#34;); System.out.println(\u0026#34;链表内容:\u0026#34; + list); // 在指定位置插入元素 list.add(1, \u0026#34;orange\u0026#34;); System.out.println(\u0026#34;链表内容:\u0026#34; + list); // 获取指定位置的元素 String fruit = list.get(2); System.out.println(\u0026#34;索引为 2 的元素:\u0026#34; + fruit); // 修改指定位置的元素 list.set(3, \u0026#34;grape\u0026#34;); System.out.println(\u0026#34;链表内容:\u0026#34; + list); // 删除指定位置的元素 list.remove(0); System.out.println(\u0026#34;链表内容:\u0026#34; + list); // 删除第一个出现的指定元素 list.remove(\u0026#34;banana\u0026#34;); System.out.println(\u0026#34;链表内容:\u0026#34; + list); // 获取链表的长度 int size = list.size(); System.out.println(\u0026#34;链表长度:\u0026#34; + size); // 清空链表 list.clear(); System.out.println(\u0026#34;清空后的链表:\u0026#34; + list); 输出:\n索引为 2 的元素:banana 链表内容:[apple, orange, banana, grape] 链表内容:[orange, banana, grape] 链表内容:[orange, grape] 链表长度:2 清空后的链表:[] "},{"id":532,"href":"/zh/docs/technology/Interview/cs-basics/operating-system/linux-intro/","title":"Linux 基础知识总结","section":"Operating System","content":"简单介绍一下 Java 程序员必知的 Linux 的一些概念以及常见命令。\n初探 Linux # Linux 简介 # 通过以下三点可以概括 Linux 到底是什么:\n类 Unix 系统:Linux 是一种自由、开放源码的类似 Unix 的操作系统 Linux 本质是指 Linux 内核:严格来讲,Linux 这个词本身只表示 Linux 内核,单独的 Linux 内核并不能成为一个可以正常工作的操作系统。所以,就有了各种 Linux 发行版。 Linux 之父(林纳斯·本纳第克特·托瓦兹 Linus Benedict Torvalds):一个编程领域的传奇式人物,真大佬!我辈崇拜敬仰之楷模。他是 Linux 内核 的最早作者,随后发起了这个开源项目,担任 Linux 内核的首要架构师。他还发起了 Git 这个开源项目,并为主要的开发者。 Linux 诞生 # 1989 年,Linus Torvalds 进入芬兰陆军新地区旅,服 11 个月的国家义务兵役,军衔为少尉,主要服务于计算机部门,任务是弹道计算。服役期间,购买了安德鲁·斯图尔特·塔能鲍姆所著的教科书及 minix 源代码,开始研究操作系统。1990 年,他退伍后回到大学,开始接触 Unix。\nMinix 是一个迷你版本的类 Unix 操作系统,由塔能鲍姆教授为了教学之用而创作,采用微核心设计。它启发了 Linux 内核的创作。\n1991 年,Linus Torvalds 开源了 Linux 内核。Linux 以一只可爱的企鹅作为标志,象征着敢作敢为、热爱生活。\n常见的 Linux 发行版本 # Linus Torvalds 开源的只是 Linux 内核,我们上面也提到了操作系统内核的作用。一些组织或厂商将 Linux 内核与各种软件和文档包装起来,并提供系统安装界面和系统配置、设定与管理工具,就构成了 Linux 的发行版本。\n内核主要负责系统的内存管理,硬件设备的管理,文件系统的管理以及应用程序的管理。\nLinux 的发行版本可以大体分为两类:\n商业公司维护的发行版本:比如 Red Hat 公司维护支持的 Red Hat Enterprise Linux (RHEL)。 社区组织维护的发行版本:比如基于 Red Hat Enterprise Linux(RHEL)的 CentOS、基于 Debian 的 Ubuntu。 对于初学者学习 Linux ,推荐选择 CentOS,原因如下:\nCentOS 免费且开放源代码; CentOS 基于 RHEL,功能与 RHEL 高度一致,安全稳定、性能优秀。 Linux 文件系统 # Linux 文件系统简介 # 在 Linux 操作系统中,一切被操作系统管理的资源,如网络接口卡、磁盘驱动器、打印机、输入输出设备、普通文件或目录等,都被视为文件。这是 Linux 系统中一个重要的概念,即\u0026quot;一切都是文件\u0026quot;。\n这种概念源自 UNIX 哲学,即将所有资源都抽象为文件的方式来进行管理和访问。Linux 的文件系统也借鉴了 UNIX 文件系统的设计理念。这种设计使得 Linux 系统可以通过统一的文件接口来管理和操作不同类型的资源,从而实现了一种统一的文件操作方式。例如,可以使用类似于读写文件的方式来对待网络接口、磁盘驱动器、设备文件等,使得操作和管理这些资源更加统一和简便。\n这种文件为中心的设计理念为 Linux 系统带来了灵活性和可扩展性,使得 Linux 成为一种强大的操作系统。同时,这也是 Linux 系统的一大特点,深受广大用户和开发者的喜欢和推崇。\ninode 介绍 # inode 是 Linux/Unix 文件系统的基础。那 inode 到是什么?有什么作用呢?\n通过以下五点可以概括 inode 到底是什么:\n硬盘以扇区 (Sector) 为最小物理存储单位,而操作系统和文件系统以块 (Block) 为单位进行读写,块由多个扇区组成。文件数据存储在这些块中。现代硬盘扇区通常为 4KB,与一些常见块大小相同,但操作系统也支持更大的块大小,以提升大文件读写性能。文件元信息(例如权限、大小、修改时间以及数据块位置)存储在 inode(索引节点)中。每个文件都有唯一的 inode。inode 本身不存储文件数据,而是存储指向数据块的指针,操作系统通过这些指针找到并读取文件数据。 固态硬盘 (SSD) 虽然没有物理扇区,但使用逻辑块,其概念与传统硬盘的块类似。 inode 是一种固定大小的数据结构,其大小在文件系统创建时就确定了,并且在文件的生命周期内保持不变。 inode 的访问速度非常快,因为系统可以直接通过 inode 号码定位到文件的元数据信息,无需遍历整个文件系统。 inode 的数量是有限的,每个文件系统只能包含固定数量的 inode。这意味着当文件系统中的 inode 用完时,无法再创建新的文件或目录,即使磁盘上还有可用空间。因此,在创建文件系统时,需要根据文件和目录的预期数量来合理分配 inode 的数量。 可以使用 stat 命令可以查看文件的 inode 信息,包括文件的 inode 号、文件类型、权限、所有者、文件大小、修改时间。 简单来说:inode 就是用来维护某个文件被分成几块、每一块在的地址、文件拥有者,创建时间,权限,大小等信息。\n再总结一下 inode 和 block:\ninode:记录文件的属性信息,可以使用 stat 命令查看 inode 信息。 block:实际文件的内容,如果一个文件大于一个块时候,那么将占用多个 block,但是一个块只能存放一个文件。(因为数据是由 inode 指向的,如果有两个文件的数据存放在同一个块中,就会乱套了) 可以看出,Linux/Unix 操作系统使用 inode 区分不同的文件。这样做的好处是,即使文件名被修改或删除,文件的 inode 号码不会改变,从而可以避免一些因文件重命名、移动或删除导致的错误。同时,inode 也可以提供更高的文件系统性能,因为 inode 的访问速度非常快,可以直接通过 inode 号码定位到文件的元数据信息,无需遍历整个文件系统。\n不过,使用 inode 号码也使得文件系统在用户和应用程序层面更加抽象和复杂,需要通过系统命令或文件系统接口来访问和管理文件的 inode 信息。\n硬链接和软链接 # 在 Linux/类 Unix 系统上,文件链接(File Link)是一种特殊的文件类型,可以在文件系统中指向另一个文件。常见的文件链接类型有两种:\n1、硬链接(Hard Link)\n在 Linux/类 Unix 文件系统中,每个文件和目录都有一个唯一的索引节点(inode)号,用来标识该文件或目录。硬链接通过 inode 节点号建立连接,硬链接和源文件的 inode 节点号相同,两者对文件系统来说是完全平等的(可以看作是互为硬链接,源头是同一份文件),删除其中任何一个对另外一个没有影响,可以通过给文件设置硬链接文件来防止重要文件被误删。 只有删除了源文件和所有对应的硬链接文件,该文件才会被真正删除。 硬链接具有一些限制,不能对目录以及不存在的文件创建硬链接,并且,硬链接也不能跨越文件系统。 ln 命令用于创建硬链接。 2、软链接(Symbolic Link 或 Symlink)\n软链接和源文件的 inode 节点号不同,而是指向一个文件路径。 源文件删除后,软链接依然存在,但是指向的是一个无效的文件路径。 软连接类似于 Windows 系统中的快捷方式。 不同于硬链接,可以对目录或者不存在的文件创建软链接,并且,软链接可以跨越文件系统。 ln -s 命令用于创建软链接。 硬链接为什么不能跨文件系统?\n我们之前提到过,硬链接是通过 inode 节点号建立连接的,而硬链接和源文件共享相同的 inode 节点号。\n然而,每个文件系统都有自己的独立 inode 表,且每个 inode 表只维护该文件系统内的 inode。如果在不同的文件系统之间创建硬链接,可能会导致 inode 节点号冲突的问题,即目标文件的 inode 节点号已经在该文件系统中被使用。\nLinux 文件类型 # Linux 支持很多文件类型,其中非常重要的文件类型有: 普通文件,目录文件,链接文件,设备文件,管道文件,Socket 套接字文件 等。\n普通文件(-):用于存储信息和数据, Linux 用户可以根据访问权限对普通文件进行查看、更改和删除。比如:图片、声音、PDF、text、视频、源代码等等。 目录文件(d,directory file):目录也是文件的一种,用于表示和管理系统中的文件,目录文件中包含一些文件名和子目录名。打开目录事实上就是打开目录文件。 符号链接文件(l,symbolic link):保留了指向文件的地址而不是文件本身。 字符设备(c,char):用来访问字符设备比如键盘。 设备文件(b,block):用来访问块设备比如硬盘、软盘。 管道文件(p,pipe) : 一种特殊类型的文件,用于进程之间的通信。 套接字文件(s,socket):用于进程间的网络通信,也可以用于本机之间的非网络通信。 每种文件类型都有不同的用途和属性,可以通过命令如ls、file等来查看文件的类型信息。\n# 普通文件(-) -rw-r--r-- 1 user group 1024 Apr 14 10:00 file.txt # 目录文件(d,directory file)* drwxr-xr-x 2 user group 4096 Apr 14 10:00 directory/ # 套接字文件(s,socket) srwxrwxrwx 1 user group 0 Apr 14 10:00 socket Linux 目录树 # Linux 使用一种称为目录树的层次结构来组织文件和目录。目录树由根目录(/)作为起始点,向下延伸,形成一系列的目录和子目录。每个目录可以包含文件和其他子目录。结构层次鲜明,就像一棵倒立的树。 常见目录说明:\n/bin: 存放二进制可执行文件(ls、cat、mkdir 等),常用命令一般都在这里; /etc: 存放系统管理和配置文件; /home: 存放所有用户文件的根目录,是用户主目录的基点,比如用户 user 的主目录就是/home/user,可以用~user 表示; /usr: 用于存放系统应用程序; /opt: 额外安装的可选应用程序包所放置的位置。一般情况下,我们可以把 tomcat 等都安装到这里; /proc: 虚拟文件系统目录,是系统内存的映射。可直接访问这个目录来获取系统信息; /root: 超级用户(系统管理员)的主目录(特权阶级o); /sbin: 存放二进制可执行文件,只有 root 才能访问。这里存放的是系统管理员使用的系统级别的管理命令和程序。如 ifconfig 等; /dev: 用于存放设备文件; /mnt: 系统管理员安装临时文件系统的安装点,系统提供这个目录是让用户临时挂载其他的文件系统; /boot: 存放用于系统引导时使用的各种文件; /lib 和/lib64: 存放着和系统运行相关的库文件 ; /tmp: 用于存放各种临时文件,是公用的临时文件存储点; /var: 用于存放运行时需要改变数据的文件,也是某些大文件的溢出区,比方说各种服务的日志文件(系统启动日志等。)等; /lost+found: 这个目录平时是空的,系统非正常关机而留下“无家可归”的文件(windows 下叫什么.chk)就在这里。 Linux 常用命令 # 下面只是给出了一些比较常用的命令。\n推荐一个 Linux 命令快查网站,非常不错,大家如果遗忘某些命令或者对某些命令不理解都可以在这里得到解决。Linux 命令在线速查手册: https://wangchujiang.com/linux-command/ 。\n另外, shell.how 这个网站可以用来解释常见命令的意思,对你学习 Linux 基本命令以及其他常用命令(如 Git、NPM)。\n目录切换 # cd usr:切换到该目录下 usr 目录 cd ..(或cd../):切换到上一层目录 cd /:切换到系统根目录 cd ~:切换到用户主目录 cd -: 切换到上一个操作所在目录 目录操作 # ls:显示目录中的文件和子目录的列表。例如:ls /home,显示 /home 目录下的文件和子目录列表。 ll:ll 是 ls -l 的别名,ll 命令可以看到该目录下的所有目录和文件的详细信息 mkdir [选项] 目录名:创建新目录(增)。例如:mkdir -m 755 my_directory,创建一个名为 my_directory 的新目录,并将其权限设置为 755,即所有用户对该目录有读、写和执行的权限。 find [路径] [表达式]:在指定目录及其子目录中搜索文件或目录(查),非常强大灵活。例如:① 列出当前目录及子目录下所有文件和文件夹: find .;② 在/home目录下查找以 .txt 结尾的文件名:find /home -name \u0026quot;*.txt\u0026quot; ,忽略大小写: find /home -i name \u0026quot;*.txt\u0026quot; ;③ 当前目录及子目录下查找所有以 .txt 和 .pdf 结尾的文件:find . \\( -name \u0026quot;*.txt\u0026quot; -o -name \u0026quot;*.pdf\u0026quot; \\)或find . -name \u0026quot;*.txt\u0026quot; -o -name \u0026quot;*.pdf\u0026quot;。 pwd:显示当前工作目录的路径。 rmdir [选项] 目录名:删除空目录(删)。例如:rmdir -p my_directory,删除名为 my_directory 的空目录,并且会递归删除my_directory的空父目录,直到遇到非空目录或根目录。 rm [选项] 文件或目录名:删除文件/目录(删)。例如:rm -r my_directory,删除名为 my_directory 的目录,-r(recursive,递归) 表示会递归删除指定目录及其所有子目录和文件。 cp [选项] 源文件/目录 目标文件/目录:复制文件或目录(移)。例如:cp file.txt /home/file.txt,将 file.txt 文件复制到 /home 目录下,并重命名为 file.txt。cp -r source destination,将 source 目录及其下的所有子目录和文件复制到 destination 目录下,并保留源文件的属性和目录结构。 mv [选项] 源文件/目录 目标文件/目录:移动文件或目录(移),也可以用于重命名文件或目录。例如:mv file.txt /home/file.txt,将 file.txt 文件移动到 /home 目录下,并重命名为 file.txt。mv 与 cp 的结果不同,mv 好像文件“搬家”,文件个数并未增加。而 cp 对文件进行复制,文件个数增加了。 文件操作 # 像 mv、cp、rm 等文件和目录都适用的命令,这里就不重复列举了。\ntouch [选项] 文件名..:创建新文件或更新已存在文件(增)。例如:touch file1.txt file2.txt file3.txt ,创建 3 个文件。 ln [选项] \u0026lt;源文件\u0026gt; \u0026lt;硬链接/软链接文件\u0026gt;:创建硬链接/软链接。例如:ln -s file.txt file_link,创建名为 file_link 的软链接,指向 file.txt 文件。-s 选项代表的就是创建软链接,s 即 symbolic(软链接又名符号链接) 。 cat/more/less/tail 文件名:文件的查看(查) 。命令 tail -f 文件 可以对某个文件进行动态监控,例如 Tomcat 的日志文件, 会随着程序的运行,日志会变化,可以使用 tail -f catalina-2016-11-11.log 监控 文 件的变化 。 vim 文件名:修改文件的内容(改)。vim 编辑器是 Linux 中的强大组件,是 vi 编辑器的加强版,vim 编辑器的命令和快捷方式有很多,但此处不一一阐述,大家也无需研究的很透彻,使用 vim 编辑修改文件的方式基本会使用就可以了。在实际开发中,使用 vim 编辑器主要作用就是修改配置文件,下面是一般步骤:vim 文件------\u0026gt;进入文件-----\u0026gt;命令模式------\u0026gt;按i进入编辑模式-----\u0026gt;编辑文件 -------\u0026gt;按Esc进入底行模式-----\u0026gt;输入:wq/q! (输入 wq 代表写入内容并退出,即保存;输入 q!代表强制退出不保存)。 文件压缩 # 1)打包并压缩文件:\nLinux 中的打包文件一般是以 .tar 结尾的,压缩的命令一般是以 .gz 结尾的。而一般情况下打包和压缩是一起进行的,打包并压缩后的文件的后缀名一般 .tar.gz。\n命令:tar -zcvf 打包压缩后的文件名 要打包压缩的文件 ,其中:\nz:调用 gzip 压缩命令进行压缩 c:打包文件 v:显示运行过程 f:指定文件名 比如:假如 test 目录下有三个文件分别是:aaa.txt、 bbb.txt、ccc.txt,如果我们要打包 test 目录并指定压缩后的压缩包名称为 test.tar.gz 可以使用命令:tar -zcvf test.tar.gz aaa.txt bbb.txt ccc.txt 或 tar -zcvf test.tar.gz /test/ 。\n2)解压压缩包:\n命令:tar [-xvf] 压缩文件\n其中 x 代表解压\n示例:\n将 /test 下的 test.tar.gz 解压到当前目录下可以使用命令:tar -xvf test.tar.gz 将 /test 下的 test.tar.gz 解压到根目录/usr 下:tar -xvf test.tar.gz -C /usr(-C 代表指定解压的位置) 文件传输 # scp [选项] 源文件 远程文件 (scp 即 secure copy,安全复制):用于通过 SSH 协议进行安全的文件传输,可以实现从本地到远程主机的上传和从远程主机到本地的下载。例如:scp -r my_directory user@remote:/home/user ,将本地目录my_directory上传到远程服务器 /home/user 目录下。scp -r user@remote:/home/user/my_directory ,将远程服务器的 /home/user 目录下的my_directory目录下载到本地。需要注意的是,scp 命令需要在本地和远程系统之间建立 SSH 连接进行文件传输,因此需要确保远程服务器已经配置了 SSH 服务,并且具有正确的权限和认证方式。 rsync [选项] 源文件 远程文件 : 可以在本地和远程系统之间高效地进行文件复制,并且能够智能地处理增量复制,节省带宽和时间。例如:rsync -r my_directory user@remote:/home/user,将本地目录my_directory上传到远程服务器 /home/user 目录下。 ftp (File Transfer Protocol):提供了一种简单的方式来连接到远程 FTP 服务器并进行文件上传、下载、删除等操作。使用之前需要先连接登录远程 FTP 服务器,进入 FTP 命令行界面后,可以使用 put 命令将本地文件上传到远程主机,可以使用get命令将远程主机的文件下载到本地,可以使用 delete 命令删除远程主机的文件。这里就不进行演示了。 文件权限 # 操作系统中每个文件都拥有特定的权限、所属用户和所属组。权限是操作系统用来限制资源访问的机制,在 Linux 中权限一般分为读(readable)、写(writable)和执行(executable),分为三组。分别对应文件的属主(owner),属组(group)和其他用户(other),通过这样的机制来限制哪些用户、哪些组可以对特定的文件进行什么样的操作。\n通过 ls -l 命令我们可以 查看某个目录下的文件或目录的权限\n示例:在随意某个目录下ls -l\n第一列的内容的信息解释如下:\n下面将详细讲解文件的类型、Linux 中权限以及文件有所有者、所在组、其它组具体是什么?\n文件的类型:\nd:代表目录 -:代表文件 l:代表软链接(可以认为是 window 中的快捷方式) Linux 中权限分为以下几种:\nr:代表权限是可读,r 也可以用数字 4 表示 w:代表权限是可写,w 也可以用数字 2 表示 x:代表权限是可执行,x 也可以用数字 1 表示 文件和目录权限的区别:\n对文件和目录而言,读写执行表示不同的意义。\n对于文件:\n权限名称 可执行操作 r 可以使用 cat 查看文件的内容 w 可以修改文件的内容 x 可以将其运行为二进制文件 对于目录:\n权限名称 可执行操作 r 可以查看目录下列表 w 可以创建和删除目录下文件 x 可以使用 cd 进入目录 需要注意的是:超级用户可以无视普通用户的权限,即使文件目录权限是 000,依旧可以访问。\n在 linux 中的每个用户必须属于一个组,不能独立于组外。在 linux 中每个文件有所有者、所在组、其它组的概念。\n所有者(u):一般为文件的创建者,谁创建了该文件,就天然的成为该文件的所有者,用 ls ‐ahl 命令可以看到文件的所有者 也可以使用 chown 用户名 文件名来修改文件的所有者 。 文件所在组(g):当某个用户创建了一个文件后,这个文件的所在组就是该用户所在的组用 ls ‐ahl命令可以看到文件的所有组也可以使用 chgrp 组名 文件名来修改文件所在的组。 其它组(o):除开文件的所有者和所在组的用户外,系统的其它用户都是文件的其它组。 我们再来看看如何修改文件/目录的权限。\n修改文件/目录的权限的命令:chmod\n示例:修改/test 下的 aaa.txt 的权限为文件所有者有全部权限,文件所有者所在的组有读写权限,其他用户只有读的权限。\nchmod u=rwx,g=rw,o=r aaa.txt 或者 chmod 764 aaa.txt\n补充一个比较常用的东西:\n假如我们装了一个 zookeeper,我们每次开机到要求其自动启动该怎么办?\n新建一个脚本 zookeeper 为新建的脚本 zookeeper 添加可执行权限,命令是:chmod +x zookeeper 把 zookeeper 这个脚本添加到开机启动项里面,命令是:chkconfig --add zookeeper 如果想看看是否添加成功,命令是:chkconfig --list 用户管理 # Linux 系统是一个多用户多任务的分时操作系统,任何一个要使用系统资源的用户,都必须首先向系统管理员申请一个账号,然后以这个账号的身份进入系统。\n用户的账号一方面可以帮助系统管理员对使用系统的用户进行跟踪,并控制他们对系统资源的访问;另一方面也可以帮助用户组织文件,并为用户提供安全性保护。\nLinux 用户管理相关命令:\nuseradd [选项] 用户名:创建用户账号。使用useradd指令所建立的帐号,实际上是保存在 /etc/passwd文本文件中。 userdel [选项] 用户名:删除用户帐号。 usermod [选项] 用户名:修改用户账号的属性和配置比如用户名、用户 ID、家目录。 passwd [选项] 用户名: 设置用户的认证信息,包括用户密码、密码过期时间等。。例如:passwd -S 用户名 ,显示用户账号密码信息。passwd -d 用户名: 清除用户密码,会导致用户无法登录。passwd 用户名,修改用户密码,随后系统会提示输入新密码并确认密码。 su [选项] 用户名(su 即 Switch User,切换用户):在当前登录的用户和其他用户之间切换身份。 用户组管理 # 每个用户都有一个用户组,系统可以对一个用户组中的所有用户进行集中管理。不同 Linux 系统对用户组的规定有所不同,如 Linux 下的用户属于与它同名的用户组,这个用户组在创建用户时同时创建。\n用户组的管理涉及用户组的添加、删除和修改。组的增加、删除和修改实际上就是对/etc/group文件的更新。\nLinux 系统用户组的管理相关命令:\ngroupadd [选项] 用户组 :增加一个新的用户组。 groupdel 用户组:要删除一个已有的用户组。 groupmod [选项] 用户组 : 修改用户组的属性。 系统状态 # top [选项]:用于实时查看系统的 CPU 使用率、内存使用率、进程信息等。 htop [选项]:类似于 top,但提供了更加交互式和友好的界面,可让用户交互式操作,支持颜色主题,可横向或纵向滚动浏览进程列表,并支持鼠标操作。 uptime [选项]:用于查看系统总共运行了多长时间、系统的平均负载等信息。 vmstat [间隔时间] [重复次数]:vmstat (Virtual Memory Statistics) 的含义为显示虚拟内存状态,但是它可以报告关于进程、内存、I/O 等系统整体运行状态。 free [选项]:用于查看系统的内存使用情况,包括已用内存、可用内存、缓冲区和缓存等。 df [选项] [文件系统]:用于查看系统的磁盘空间使用情况,包括磁盘空间的总量、已使用量和可用量等,可以指定文件系统上。例如:df -a,查看全部文件系统。 du [选项] [文件]:用于查看指定目录或文件的磁盘空间使用情况,可以指定不同的选项来控制输出格式和单位。 sar [选项] [时间间隔] [重复次数]:用于收集、报告和分析系统的性能统计信息,包括系统的 CPU 使用、内存使用、磁盘 I/O、网络活动等详细信息。它的特点是可以连续对系统取样,获得大量的取样数据。取样数据和分析的结果都可以存入文件,使用它时消耗的系统资源很小。 ps [选项]:用于查看系统中的进程信息,包括进程的 ID、状态、资源使用情况等。ps -ef/ps -aux:这两个命令都是查看当前系统正在运行进程,两者的区别是展示格式不同。如果想要查看特定的进程可以使用这样的格式:ps aux|grep redis (查看包括 redis 字符串的进程),也可使用 pgrep redis -a。 systemctl [命令] [服务名称]:用于管理系统的服务和单元,可以查看系统服务的状态、启动、停止、重启等。 网络通信 # ping [选项] 目标主机:测试与目标主机的网络连接。 ifconfig 或 ip:用于查看系统的网络接口信息,包括网络接口的 IP 地址、MAC 地址、状态等。 netstat [选项]:用于查看系统的网络连接状态和网络统计信息,可以查看当前的网络连接情况、监听端口、网络协议等。 ss [选项]:比 netstat 更好用,提供了更快速、更详细的网络连接信息。 其他 # sudo + 其他命令:以系统管理者的身份执行指令,也就是说,经由 sudo 所执行的指令就好像是 root 亲自执行。 grep 要搜索的字符串 要搜索的文件 --color:搜索命令,\u0026ndash;color 代表高亮显示。 kill -9 进程的pid:杀死进程(-9 表示强制终止)先用 ps 查找进程,然后用 kill 杀掉。 shutdown:shutdown -h now:指定现在立即关机;shutdown +5 \u0026quot;System will shutdown after 5 minutes\u0026quot;:指定 5 分钟后关机,同时送出警告信息给登入用户。 reboot:reboot:重开机。reboot -w:做个重开机的模拟(只有纪录并不会真的重开机)。 Linux 环境变量 # 在 Linux 系统中,环境变量是用来定义系统运行环境的一些参数,比如每个用户不同的主目录(HOME)。\n环境变量分类 # 按照作用域来分,环境变量可以简单的分成:\n用户级别环境变量 : ~/.bashrc、~/.bash_profile。 系统级别环境变量 : /etc/bashrc、/etc/environment、/etc/profile、/etc/profile.d。 上述配置文件执行先后顺序为:/etc/environment –\u0026gt; /etc/profile –\u0026gt; /etc/profile.d –\u0026gt; ~/.bash_profile –\u0026gt; /etc/bashrc –\u0026gt; ~/.bashrc\n如果要修改系统级别环境变量文件,需要管理员具备对该文件的写入权限。\n建议用户级别环境变量在 ~/.bash_profile中配置,系统级别环境变量在 /etc/profile.d 中配置。\n按照生命周期来分,环境变量可以简单的分成:\n永久的:需要用户修改相关的配置文件,变量永久生效。 临时的:用户利用 export 命令,在当前终端下声明环境变量,关闭 shell 终端失效。 读取环境变量 # 通过 export 命令可以输出当前系统定义的所有环境变量。\n# 列出当前的环境变量值 export -p 除了 export 命令之外, env 命令也可以列出所有环境变量。\necho 命令可以输出指定环境变量的值。\n# 输出当前的PATH环境变量的值 echo $PATH # 输出当前的HOME环境变量的值 echo $HOME 环境变量修改 # 通过 export命令可以修改指定的环境变量。不过,这种方式修改环境变量仅仅对当前 shell 终端生效,关闭 shell 终端就会失效。修改完成之后,立即生效。\nexport CLASSPATH=./JAVA_HOME/lib;$JAVA_HOME/jre/lib 通过 vim 命令修改环境变量配置文件。这种方式修改环境变量永久有效。\nvim ~/.bash_profile 如果修改的是系统级别环境变量则对所有用户生效,如果修改的是用户级别环境变量则仅对当前用户生效。\n修改完成之后,需要 source 命令让其生效或者关闭 shell 终端重新登录。\nsource /etc/profile "},{"id":533,"href":"/zh/docs/technology/Interview/tools/maven/maven-core-concepts/","title":"Maven核心概念总结","section":"Maven","content":" 这部分内容主要根据 Maven 官方文档整理,做了对应的删减,主要保留比较重要的部分,不涉及实战,主要是一些重要概念的介绍。\nMaven 介绍 # Maven 官方文档是这样介绍的 Maven 的:\nApache Maven is a software project management and comprehension tool. Based on the concept of a project object model (POM), Maven can manage a project\u0026rsquo;s build, reporting and documentation from a central piece of information.\nApache Maven 的本质是一个软件项目管理和理解工具。基于项目对象模型 (Project Object Model,POM) 的概念,Maven 可以从一条中心信息管理项目的构建、报告和文档。\n什么是 POM? 每一个 Maven 工程都有一个 pom.xml 文件,位于根目录中,包含项目构建生命周期的详细信息。通过 pom.xml 文件,我们可以定义项目的坐标、项目依赖、项目信息、插件信息等等配置。\n对于开发者来说,Maven 的主要作用主要有 3 个:\n项目构建:提供标准的、跨平台的自动化项目构建方式。 依赖管理:方便快捷的管理项目依赖的资源(jar 包),避免资源间的版本冲突问题。 统一开发结构:提供标准的、统一的项目结构。 关于 Maven 的基本使用这里就不介绍了,建议看看官网的 5 分钟上手 Maven 的教程: Maven in 5 Minutes 。\nMaven 坐标 # 项目中依赖的第三方库以及插件可统称为构件。每一个构件都可以使用 Maven 坐标唯一标识,坐标元素包括:\ngroupId(必须): 定义了当前 Maven 项目隶属的组织或公司。groupId 一般分为多段,通常情况下,第一段为域,第二段为公司名称。域又分为 org、com、cn 等,其中 org 为非营利组织,com 为商业组织,cn 表示中国。以 apache 开源社区的 tomcat 项目为例,这个项目的 groupId 是 org.apache,它的域是 org(因为 tomcat 是非营利项目),公司名称是 apache,artifactId 是 tomcat。 artifactId(必须):定义了当前 Maven 项目的名称,项目的唯一的标识符,对应项目根目录的名称。 version(必须):定义了 Maven 项目当前所处版本。 packaging(可选):定义了 Maven 项目的打包方式(比如 jar,war\u0026hellip;),默认使用 jar。 classifier(可选):常用于区分从同一 POM 构建的具有不同内容的构件,可以是任意的字符串,附加在版本号之后。 只要你提供正确的坐标,就能从 Maven 仓库中找到相应的构件供我们使用。\n举个例子(引入阿里巴巴开源的 EasyExcel):\n\u0026lt;dependency\u0026gt; \u0026lt;groupId\u0026gt;com.alibaba\u0026lt;/groupId\u0026gt; \u0026lt;artifactId\u0026gt;easyexcel\u0026lt;/artifactId\u0026gt; \u0026lt;version\u0026gt;3.1.1\u0026lt;/version\u0026gt; \u0026lt;/dependency\u0026gt; 你可以在 https://mvnrepository.com/ 这个网站上找到几乎所有可用的构件,如果你的项目使用的是 Maven 作为构建工具,那这个网站你一定会经常接触。\nMaven 依赖 # 如果使用 Maven 构建产生的构件(例如 Jar 文件)被其他的项目引用,那么该构件就是其他项目的依赖。\n依赖配置 # 配置信息示例:\n\u0026lt;project\u0026gt; \u0026lt;dependencies\u0026gt; \u0026lt;dependency\u0026gt; \u0026lt;groupId\u0026gt;\u0026lt;/groupId\u0026gt; \u0026lt;artifactId\u0026gt;\u0026lt;/artifactId\u0026gt; \u0026lt;version\u0026gt;\u0026lt;/version\u0026gt; \u0026lt;type\u0026gt;...\u0026lt;/type\u0026gt; \u0026lt;scope\u0026gt;...\u0026lt;/scope\u0026gt; \u0026lt;optional\u0026gt;...\u0026lt;/optional\u0026gt; \u0026lt;exclusions\u0026gt; \u0026lt;exclusion\u0026gt; \u0026lt;groupId\u0026gt;...\u0026lt;/groupId\u0026gt; \u0026lt;artifactId\u0026gt;...\u0026lt;/artifactId\u0026gt; \u0026lt;/exclusion\u0026gt; \u0026lt;/exclusions\u0026gt; \u0026lt;/dependency\u0026gt; \u0026lt;/dependencies\u0026gt; \u0026lt;/project\u0026gt; 配置说明:\ndependencies:一个 pom.xml 文件中只能存在一个这样的标签,是用来管理依赖的总标签。 dependency:包含在 dependencies 标签中,可以有多个,每一个表示项目的一个依赖。 groupId,artifactId,version(必要):依赖的基本坐标,对于任何一个依赖来说,基本坐标是最重要的,Maven 根据坐标才能找到需要的依赖。我们在上面解释过这些元素的具体意思,这里就不重复提了。 type(可选):依赖的类型,对应于项目坐标定义的 packaging。大部分情况下,该元素不必声明,其默认值是 jar。 scope(可选):依赖的范围,默认值是 compile。 optional(可选):标记依赖是否可选 exclusions(可选):用来排除传递性依赖,例如 jar 包冲突 依赖范围 # classpath 用于指定 .class 文件存放的位置,类加载器会从该路径中加载所需的 .class 文件到内存中。\nMaven 在编译、执行测试、实际运行有着三套不同的 classpath:\n编译 classpath:编译主代码有效 测试 classpath:编译、运行测试代码有效 运行 classpath:项目运行时有效 Maven 的依赖范围如下:\ncompile:编译依赖范围(默认),使用此依赖范围对于编译、测试、运行三种都有效,即在编译、测试和运行的时候都要使用该依赖 Jar 包。 test:测试依赖范围,从字面意思就可以知道此依赖范围只能用于测试,而在编译和运行项目时无法使用此类依赖,典型的是 JUnit,它只用于编译测试代码和运行测试代码的时候才需要。 provided:此依赖范围,对于编译和测试有效,而对运行时无效。比如 servlet-api.jar 在 Tomcat 中已经提供了,我们只需要的是编译期提供而已。 runtime:运行时依赖范围,对于测试和运行有效,但是在编译主代码时无效,典型的就是 JDBC 驱动实现。 system:系统依赖范围,使用 system 范围的依赖时必须通过 systemPath 元素显示地指定依赖文件的路径,不依赖 Maven 仓库解析,所以可能会造成建构的不可移植。 传递依赖性 # 依赖冲突 # 1、对于 Maven 而言,同一个 groupId 同一个 artifactId 下,只能使用一个 version。\n\u0026lt;dependency\u0026gt; \u0026lt;groupId\u0026gt;in.hocg.boot\u0026lt;/groupId\u0026gt; \u0026lt;artifactId\u0026gt;mybatis-plus-spring-boot-starter\u0026lt;/artifactId\u0026gt; \u0026lt;version\u0026gt;1.0.48\u0026lt;/version\u0026gt; \u0026lt;/dependency\u0026gt; \u0026lt;!-- 只会使用 1.0.49 这个版本的依赖 --\u0026gt; \u0026lt;dependency\u0026gt; \u0026lt;groupId\u0026gt;in.hocg.boot\u0026lt;/groupId\u0026gt; \u0026lt;artifactId\u0026gt;mybatis-plus-spring-boot-starter\u0026lt;/artifactId\u0026gt; \u0026lt;version\u0026gt;1.0.49\u0026lt;/version\u0026gt; \u0026lt;/dependency\u0026gt; 若相同类型但版本不同的依赖存在于同一个 pom 文件,只会引入后一个声明的依赖。\n2、项目的两个依赖同时引入了某个依赖。\n举个例子,项目存在下面这样的依赖关系:\n依赖链路一:A -\u0026gt; B -\u0026gt; C -\u0026gt; X(1.0) 依赖链路二:A -\u0026gt; D -\u0026gt; X(2.0) 这两条依赖路径上有两个版本的 X,为了避免依赖重复,Maven 只会选择其中的一个进行解析。\n哪个版本的 X 会被 Maven 解析使用呢?\nMaven 在遇到这种问题的时候,会遵循 路径最短优先 和 声明顺序优先 两大原则。解决这个问题的过程也被称为 Maven 依赖调解 。\n路径最短优先\n依赖链路一:A -\u0026gt; B -\u0026gt; C -\u0026gt; X(1.0) // dist = 3 依赖链路二:A -\u0026gt; D -\u0026gt; X(2.0) // dist = 2 依赖链路二的路径最短,因此,X(2.0)会被解析使用。\n不过,你也可以发现。路径最短优先原则并不是通用的,像下面这种路径长度相等的情况就不能单单通过其解决了:\n依赖链路一:A -\u0026gt; B -\u0026gt; X(1.0) // dist = 2 依赖链路二:A -\u0026gt; D -\u0026gt; X(2.0) // dist = 2 因此,Maven 又定义了声明顺序优先原则。\n依赖调解第一原则不能解决所有问题,比如这样的依赖关系:A-\u0026gt;B-\u0026gt;Y(1.0)、A-\u0026gt; C-\u0026gt;Y(2.0),Y(1.0)和 Y(2.0)的依赖路径长度是一样的,都为 2。Maven 定义了依赖调解的第二原则:\n声明顺序优先\n在依赖路径长度相等的前提下,在 pom.xml 中依赖声明的顺序决定了谁会被解析使用,顺序最前的那个依赖优胜。该例中,如果 B 的依赖声明在 D 之前,那么 X (1.0)就会被解析使用。\n\u0026lt;!-- A pom.xml --\u0026gt; \u0026lt;dependencies\u0026gt; ... dependency B ... dependency D \u0026lt;/dependencies\u0026gt; 排除依赖 # 单纯依赖 Maven 来进行依赖调解,在很多情况下是不适用的,需要我们手动排除依赖。\n举个例子,当前项目存在下面这样的依赖关系:\n依赖链路一:A -\u0026gt; B -\u0026gt; C -\u0026gt; X(1.5) // dist = 3 依赖链路二:A -\u0026gt; D -\u0026gt; X(1.0) // dist = 2 根据路径最短优先原则,X(1.0) 会被解析使用,也就是说实际用的是 1.0 版本的 X。\n但是!!!这会一些问题:如果 C 依赖用到了 1.5 版本的 X 中才有的一个类,运行项目就会报NoClassDefFoundError错误。如果 C 依赖用到了 1.5 版本的 X 中才有的一个方法,运行项目就会报NoSuchMethodError错误。\n现在知道为什么你的 Maven 项目总是会报NoClassDefFoundError和NoSuchMethodError错误了吧?\n如何解决呢? 我们可以通过exclusion标签手动将 X(1.0) 给排除。\n\u0026lt;dependency\u0026gt; ...... \u0026lt;exclusions\u0026gt; \u0026lt;exclusion\u0026gt; \u0026lt;artifactId\u0026gt;x\u0026lt;/artifactId\u0026gt; \u0026lt;groupId\u0026gt;org.apache.x\u0026lt;/groupId\u0026gt; \u0026lt;/exclusion\u0026gt; \u0026lt;/exclusions\u0026gt; \u0026lt;/dependency\u0026gt; 一般我们在解决依赖冲突的时候,都会优先保留版本较高的。这是因为大部分 jar 在升级的时候都会做到向下兼容。\n如果高版本修改了低版本的一些类或者方法的话,这个时候就不能直接保留高版本了,而是应该考虑优化上层依赖,比如升级上层依赖的版本。\n还是上面的例子:\n依赖链路一:A -\u0026gt; B -\u0026gt; C -\u0026gt; X(1.5) // dist = 3 依赖链路二:A -\u0026gt; D -\u0026gt; X(1.0) // dist = 2 我们保留了 1.5 版本的 X,但是这个版本的 X 删除了 1.0 版本中的某些类。这个时候,我们可以考虑升级 D 的版本到一个 X 兼容的版本。\nMaven 仓库 # 在 Maven 世界中,任何一个依赖、插件或者项目构建的输出,都可以称为 构件 。\n坐标和依赖是构件在 Maven 世界中的逻辑表示方式,构件的物理表示方式是文件,Maven 通过仓库来统一管理这些文件。 任何一个构件都有一组坐标唯一标识。有了仓库之后,无需手动引入构件,我们直接给定构件的坐标即可在 Maven 仓库中找到该构件。\nMaven 仓库分为:\n本地仓库:运行 Maven 的计算机上的一个目录,它缓存远程下载的构件并包含尚未发布的临时构件。settings.xml 文件中可以看到 Maven 的本地仓库路径配置,默认本地仓库路径是在 ${user.home}/.m2/repository。 远程仓库:官方或者其他组织维护的 Maven 仓库。 Maven 远程仓库可以分为:\n中央仓库:这个仓库是由 Maven 社区来维护的,里面存放了绝大多数开源软件的包,并且是作为 Maven 的默认配置,不需要开发者额外配置。另外为了方便查询,还提供了一个 查询地址,开发者可以通过这个地址更快的搜索需要构件的坐标。 私服:私服是一种特殊的远程 Maven 仓库,它是架设在局域网内的仓库服务,私服一般被配置为互联网远程仓库的镜像,供局域网内的 Maven 用户使用。 其他的公共仓库:有一些公共仓库是为了加速访问(比如阿里云 Maven 镜像仓库)或者部分构件不存在于中央仓库中。 Maven 依赖包寻找顺序:\n先去本地仓库找寻,有的话,直接使用。 本地仓库没有找到的话,会去远程仓库找寻,下载包到本地仓库。 远程仓库没有找到的话,会报错。 Maven 生命周期 # Maven 的生命周期就是为了对所有的构建过程进行抽象和统一,包含了项目的清理、初始化、编译、测试、打包、集成测试、验证、部署和站点生成等几乎所有构建步骤。\nMaven 定义了 3 个生命周期META-INF/plexus/components.xml:\ndefault 生命周期 clean生命周期 site生命周期 这些生命周期是相互独立的,每个生命周期包含多个阶段(phase)。并且,这些阶段是有序的,也就是说,后面的阶段依赖于前面的阶段。当执行某个阶段的时候,会先执行它前面的阶段。\n执行 Maven 生命周期的命令格式如下:\nmvn 阶段 [阶段2] ...[阶段n] default 生命周期 # default生命周期是在没有任何关联插件的情况下定义的,是 Maven 的主要生命周期,用于构建应用程序,共包含 23 个阶段。\n\u0026lt;phases\u0026gt; \u0026lt;!-- 验证项目是否正确,并且所有必要的信息可用于完成构建过程 --\u0026gt; \u0026lt;phase\u0026gt;validate\u0026lt;/phase\u0026gt; \u0026lt;!-- 建立初始化状态,例如设置属性 --\u0026gt; \u0026lt;phase\u0026gt;initialize\u0026lt;/phase\u0026gt; \u0026lt;!-- 生成要包含在编译阶段的源代码 --\u0026gt; \u0026lt;phase\u0026gt;generate-sources\u0026lt;/phase\u0026gt; \u0026lt;!-- 处理源代码 --\u0026gt; \u0026lt;phase\u0026gt;process-sources\u0026lt;/phase\u0026gt; \u0026lt;!-- 生成要包含在包中的资源 --\u0026gt; \u0026lt;phase\u0026gt;generate-resources\u0026lt;/phase\u0026gt; \u0026lt;!-- 将资源复制并处理到目标目录中,为打包阶段做好准备。 --\u0026gt; \u0026lt;phase\u0026gt;process-resources\u0026lt;/phase\u0026gt; \u0026lt;!-- 编译项目的源代码 --\u0026gt; \u0026lt;phase\u0026gt;compile\u0026lt;/phase\u0026gt; \u0026lt;!-- 对编译生成的文件进行后处理,例如对 Java 类进行字节码增强/优化 --\u0026gt; \u0026lt;phase\u0026gt;process-classes\u0026lt;/phase\u0026gt; \u0026lt;!-- 生成要包含在编译阶段的任何测试源代码 --\u0026gt; \u0026lt;phase\u0026gt;generate-test-sources\u0026lt;/phase\u0026gt; \u0026lt;!-- 处理测试源代码 --\u0026gt; \u0026lt;phase\u0026gt;process-test-sources\u0026lt;/phase\u0026gt; \u0026lt;!-- 生成要包含在编译阶段的测试源代码 --\u0026gt; \u0026lt;phase\u0026gt;generate-test-resources\u0026lt;/phase\u0026gt; \u0026lt;!-- 处理从测试代码文件编译生成的文件 --\u0026gt; \u0026lt;phase\u0026gt;process-test-resources\u0026lt;/phase\u0026gt; \u0026lt;!-- 编译测试源代码 --\u0026gt; \u0026lt;phase\u0026gt;test-compile\u0026lt;/phase\u0026gt; \u0026lt;!-- 处理从测试代码文件编译生成的文件 --\u0026gt; \u0026lt;phase\u0026gt;process-test-classes\u0026lt;/phase\u0026gt; \u0026lt;!-- 使用合适的单元测试框架(Junit 就是其中之一)运行测试 --\u0026gt; \u0026lt;phase\u0026gt;test\u0026lt;/phase\u0026gt; \u0026lt;!-- 在实际打包之前,执行任何的必要的操作为打包做准备 --\u0026gt; \u0026lt;phase\u0026gt;prepare-package\u0026lt;/phase\u0026gt; \u0026lt;!-- 获取已编译的代码并将其打包成可分发的格式,例如 JAR、WAR 或 EAR 文件 --\u0026gt; \u0026lt;phase\u0026gt;package\u0026lt;/phase\u0026gt; \u0026lt;!-- 在执行集成测试之前执行所需的操作。 例如,设置所需的环境 --\u0026gt; \u0026lt;phase\u0026gt;pre-integration-test\u0026lt;/phase\u0026gt; \u0026lt;!-- 处理并在必要时部署软件包到集成测试可以运行的环境 --\u0026gt; \u0026lt;phase\u0026gt;integration-test\u0026lt;/phase\u0026gt; \u0026lt;!-- 执行集成测试后执行所需的操作。 例如,清理环境 --\u0026gt; \u0026lt;phase\u0026gt;post-integration-test\u0026lt;/phase\u0026gt; \u0026lt;!-- 运行任何检查以验证打的包是否有效并符合质量标准。 --\u0026gt; \u0026lt;phase\u0026gt;verify\u0026lt;/phase\u0026gt; \u0026lt;!-- 将包安装到本地仓库中,可以作为本地其他项目的依赖 --\u0026gt; \u0026lt;phase\u0026gt;install\u0026lt;/phase\u0026gt; \u0026lt;!-- 将最终的项目包复制到远程仓库中与其他开发者和项目共享 --\u0026gt; \u0026lt;phase\u0026gt;deploy\u0026lt;/phase\u0026gt; \u0026lt;/phases\u0026gt; 根据前面提到的阶段间依赖关系理论,当我们执行 mvn test命令的时候,会执行从 validate 到 test 的所有阶段,这也就解释了为什么执行测试的时候,项目的代码能够自动编译。\nclean 生命周期 # clean 生命周期的目的是清理项目,共包含 3 个阶段:\npre-clean clean post-clean \u0026lt;phases\u0026gt; \u0026lt;!-- 执行一些需要在clean之前完成的工作 --\u0026gt; \u0026lt;phase\u0026gt;pre-clean\u0026lt;/phase\u0026gt; \u0026lt;!-- 移除所有上一次构建生成的文件 --\u0026gt; \u0026lt;phase\u0026gt;clean\u0026lt;/phase\u0026gt; \u0026lt;!-- 执行一些需要在clean之后立刻完成的工作 --\u0026gt; \u0026lt;phase\u0026gt;post-clean\u0026lt;/phase\u0026gt; \u0026lt;/phases\u0026gt; \u0026lt;default-phases\u0026gt; \u0026lt;clean\u0026gt; org.apache.maven.plugins:maven-clean-plugin:2.5:clean \u0026lt;/clean\u0026gt; \u0026lt;/default-phases\u0026gt; 根据前面提到的阶段间依赖关系理论,当我们执行 mvn clean 的时候,会执行 clean 生命周期中的 pre-clean 和 clean 阶段。\nsite 生命周期 # site 生命周期的目的是建立和发布项目站点,共包含 4 个阶段:\npre-site site post-site site-deploy \u0026lt;phases\u0026gt; \u0026lt;!-- 执行一些需要在生成站点文档之前完成的工作 --\u0026gt; \u0026lt;phase\u0026gt;pre-site\u0026lt;/phase\u0026gt; \u0026lt;!-- 生成项目的站点文档作 --\u0026gt; \u0026lt;phase\u0026gt;site\u0026lt;/phase\u0026gt; \u0026lt;!-- 执行一些需要在生成站点文档之后完成的工作,并且为部署做准备 --\u0026gt; \u0026lt;phase\u0026gt;post-site\u0026lt;/phase\u0026gt; \u0026lt;!-- 将生成的站点文档部署到特定的服务器上 --\u0026gt; \u0026lt;phase\u0026gt;site-deploy\u0026lt;/phase\u0026gt; \u0026lt;/phases\u0026gt; \u0026lt;default-phases\u0026gt; \u0026lt;site\u0026gt; org.apache.maven.plugins:maven-site-plugin:3.3:site \u0026lt;/site\u0026gt; \u0026lt;site-deploy\u0026gt; org.apache.maven.plugins:maven-site-plugin:3.3:deploy \u0026lt;/site-deploy\u0026gt; \u0026lt;/default-phases\u0026gt; Maven 能够基于 pom.xml 所包含的信息,自动生成一个友好的站点,方便团队交流和发布项目信息。\nMaven 插件 # Maven 本质上是一个插件执行框架,所有的执行过程,都是由一个一个插件独立完成的。像咱们日常使用到的 install、clean、deploy 等命令,其实底层都是一个一个的 Maven 插件。关于 Maven 的核心插件可以参考官方的这篇文档: https://maven.apache.org/plugins/index.html 。\n本地默认插件路径: ${user.home}/.m2/repository/org/apache/maven/plugins\n除了 Maven 自带的插件之外,还有一些三方提供的插件比如单测覆盖率插件 jacoco-maven-plugin、帮助开发检测代码中不合规范的地方的插件 maven-checkstyle-plugin、分析代码质量的 sonar-maven-plugin。并且,我们还可以自定义插件来满足自己的需求。\njacoco-maven-plugin 使用示例:\n\u0026lt;build\u0026gt; \u0026lt;plugins\u0026gt; \u0026lt;plugin\u0026gt; \u0026lt;groupId\u0026gt;org.jacoco\u0026lt;/groupId\u0026gt; \u0026lt;artifactId\u0026gt;jacoco-maven-plugin\u0026lt;/artifactId\u0026gt; \u0026lt;version\u0026gt;0.8.8\u0026lt;/version\u0026gt; \u0026lt;executions\u0026gt; \u0026lt;execution\u0026gt; \u0026lt;goals\u0026gt; \u0026lt;goal\u0026gt;prepare-agent\u0026lt;/goal\u0026gt; \u0026lt;/goals\u0026gt; \u0026lt;/execution\u0026gt; \u0026lt;execution\u0026gt; \u0026lt;id\u0026gt;generate-code-coverage-report\u0026lt;/id\u0026gt; \u0026lt;phase\u0026gt;test\u0026lt;/phase\u0026gt; \u0026lt;goals\u0026gt; \u0026lt;goal\u0026gt;report\u0026lt;/goal\u0026gt; \u0026lt;/goals\u0026gt; \u0026lt;/execution\u0026gt; \u0026lt;/executions\u0026gt; \u0026lt;/plugin\u0026gt; \u0026lt;/plugins\u0026gt; \u0026lt;/build\u0026gt; 你可以将 Maven 插件理解为一组任务的集合,用户可以通过命令行直接运行指定插件的任务,也可以将插件任务挂载到构建生命周期,随着生命周期运行。\nMaven 插件被分为下面两种类型:\nBuild plugins:在构建时执行。 Reporting plugins:在网站生成过程中执行。 Maven 多模块管理 # 多模块管理简单地来说就是将一个项目分为多个模块,每个模块只负责单一的功能实现。直观的表现就是一个 Maven 项目中不止有一个 pom.xml 文件,会在不同的目录中有多个 pom.xml 文件,进而实现多模块管理。\n多模块管理除了可以更加便于项目开发和管理,还有如下好处:\n降低代码之间的耦合性(从类级别的耦合提升到 jar 包级别的耦合); 减少重复,提升复用性; 每个模块都可以是自解释的(通过模块名或者模块文档); 模块还规范了代码边界的划分,开发者很容易通过模块确定自己所负责的内容。 多模块管理下,会有一个父模块,其他的都是子模块。父模块通常只有一个 pom.xml,没有其他内容。父模块的 pom.xml 一般只定义了各个依赖的版本号、包含哪些子模块以及插件有哪些。不过,要注意的是,如果依赖只在某个子项目中使用,则可以在子项目的 pom.xml 中直接引入,防止父 pom 的过于臃肿。\n如下图所示,Dubbo 项目就被分成了多个子模块比如 dubbo-common(公共逻辑模块)、dubbo-remoting(远程通讯模块)、dubbo-rpc(远程调用模块)。\n文章推荐 # 安全同学讲 Maven 间接依赖场景的仲裁机制 - 阿里开发者 - 2022 高效使用 Java 构建工具| Maven 篇 - 阿里开发者 - 2022 安全同学讲 Maven 重打包的故事 - 阿里开发者 - 2022 参考 # 《Maven 实战》 Introduction to Repositories - Maven 官方文档: https://maven.apache.org/guides/introduction/introduction-to-repositories.html Introduction to the Build Lifecycle - Maven 官方文档: https://maven.apache.org/guides/introduction/introduction-to-the-lifecycle.html#Lifecycle_Reference Maven 依赖范围: http://www.mvnbook.com/maven-dependency.html 解决 maven 依赖冲突,这篇就够了!: https://www.cnblogs.com/qdhxhz/p/16363532.html Multi-Module Project with Maven: https://www.baeldung.com/maven-multi-module "},{"id":534,"href":"/zh/docs/technology/Interview/tools/maven/maven-best-practices/","title":"Maven最佳实践","section":"Maven","content":" 本文由 JavaGuide 翻译并完善,原文地址: https://medium.com/@AlexanderObregon/maven-best-practices-tips-and-tricks-for-java-developers-438eca03f72b 。\nMaven 是一种广泛使用的 Java 项目构建自动化工具。它简化了构建过程并帮助管理依赖关系,使开发人员的工作更轻松。Maven 详细介绍可以参考我写的这篇 Maven 核心概念总结 。\n这篇文章不会涉及到 Maven 概念的介绍,主要讨论一些最佳实践、建议和技巧,以优化我们在项目中对 Maven 的使用并改善我们的开发体验。\nMaven 标准目录结构 # Maven 遵循标准目录结构来保持项目之间的一致性。遵循这种结构可以让其他开发人员更轻松地理解我们的项目。\nMaven 项目的标准目录结构如下:\nsrc/ main/ java/ resources/ test/ java/ resources/ pom.xml src/main/java:源代码目录 src/main/resources:资源文件目录 src/test/java:测试代码目录 src/test/resources:测试资源文件目录 这只是一个最简单的 Maven 项目目录示例。实际项目中,我们还会根据项目规范去做进一步的细分。\n指定 Maven 编译器插件 # 默认情况下,Maven 使用 Java5 编译我们的项目。要使用不同的 JDK 版本,请在 pom.xml 文件中配置 Maven 编译器插件。\n例如,如果你想要使用 Java8 来编译你的项目,你可以在\u0026lt;build\u0026gt;标签下添加以下的代码片段:\n\u0026lt;build\u0026gt; \u0026lt;plugins\u0026gt; \u0026lt;plugin\u0026gt; \u0026lt;groupId\u0026gt;org.apache.maven.plugins\u0026lt;/groupId\u0026gt; \u0026lt;artifactId\u0026gt;maven-compiler-plugin\u0026lt;/artifactId\u0026gt; \u0026lt;version\u0026gt;3.8.1\u0026lt;/version\u0026gt; \u0026lt;configuration\u0026gt; \u0026lt;source\u0026gt;1.8\u0026lt;/source\u0026gt; \u0026lt;target\u0026gt;1.8\u0026lt;/target\u0026gt; \u0026lt;/configuration\u0026gt; \u0026lt;/plugin\u0026gt; \u0026lt;/plugins\u0026gt; \u0026lt;/build\u0026gt; 这样,Maven 就会使用 Java8 的编译器来编译你的项目。如果你想要使用其他版本的 JDK,你只需要修改\u0026lt;source\u0026gt;和\u0026lt;target\u0026gt;标签的值即可。例如,如果你想要使用 Java11,你可以将它们的值改为 11。\n有效管理依赖关系 # Maven 的依赖管理系统是其最强大的功能之一。在顶层 pom 文件中,通过标签 dependencyManagement 定义公共的依赖关系,这有助于避免冲突并确保所有模块使用相同版本的依赖项。\n例如,假设我们有一个父模块和两个子模块 A 和 B,我们想要在所有模块中使用 JUnit 5.7.2 作为测试框架。我们可以在父模块的pom.xml文件中使用\u0026lt;dependencyManagement\u0026gt;标签来定义 JUnit 的版本:\n\u0026lt;dependencyManagement\u0026gt; \u0026lt;dependencies\u0026gt; \u0026lt;dependency\u0026gt; \u0026lt;groupId\u0026gt;org.junit.jupiter\u0026lt;/groupId\u0026gt; \u0026lt;artifactId\u0026gt;junit-jupiter\u0026lt;/artifactId\u0026gt; \u0026lt;version\u0026gt;5.7.2\u0026lt;/version\u0026gt; \u0026lt;scope\u0026gt;test\u0026lt;/scope\u0026gt; \u0026lt;/dependency\u0026gt; \u0026lt;/dependencies\u0026gt; \u0026lt;/dependencyManagement\u0026gt; 在子模块 A 和 B 的 pom.xml 文件中,我们只需要引用 JUnit 的 groupId 和 artifactId 即可:\n\u0026lt;dependencies\u0026gt; \u0026lt;dependency\u0026gt; \u0026lt;groupId\u0026gt;org.junit.jupiter\u0026lt;/groupId\u0026gt; \u0026lt;artifactId\u0026gt;junit-jupiter\u0026lt;/artifactId\u0026gt; \u0026lt;/dependency\u0026gt; \u0026lt;/dependencies\u0026gt; 针对不同环境使用配置文件 # Maven 配置文件允许我们配置不同环境的构建设置,例如开发、测试和生产。在 pom.xml 文件中定义配置文件并使用命令行参数激活它们:\n\u0026lt;profiles\u0026gt; \u0026lt;profile\u0026gt; \u0026lt;id\u0026gt;development\u0026lt;/id\u0026gt; \u0026lt;activation\u0026gt; \u0026lt;activeByDefault\u0026gt;true\u0026lt;/activeByDefault\u0026gt; \u0026lt;/activation\u0026gt; \u0026lt;properties\u0026gt; \u0026lt;environment\u0026gt;dev\u0026lt;/environment\u0026gt; \u0026lt;/properties\u0026gt; \u0026lt;/profile\u0026gt; \u0026lt;profile\u0026gt; \u0026lt;id\u0026gt;production\u0026lt;/id\u0026gt; \u0026lt;properties\u0026gt; \u0026lt;environment\u0026gt;prod\u0026lt;/environment\u0026gt; \u0026lt;/properties\u0026gt; \u0026lt;/profile\u0026gt; \u0026lt;/profiles\u0026gt; 使用命令行激活配置文件:\nmvn clean install -P production 保持 pom.xml 干净且井然有序 # 组织良好的 pom.xml 文件更易于维护和理解。以下是维护干净的 pom.xml 的一些技巧:\n将相似的依赖项和插件组合在一起。 使用注释来描述特定依赖项或插件的用途。 将插件和依赖项的版本号保留在 \u0026lt;properties\u0026gt; 标签内以便于管理。 \u0026lt;properties\u0026gt; \u0026lt;junit.version\u0026gt;5.7.0\u0026lt;/junit.version\u0026gt; \u0026lt;mockito.version\u0026gt;3.9.0\u0026lt;/mockito.version\u0026gt; \u0026lt;/properties\u0026gt; 使用 Maven Wrapper # Maven Wrapper 是一个用于管理和使用 Maven 的工具,它允许在没有预先安装 Maven 的情况下运行和构建 Maven 项目。\nMaven 官方文档是这样介绍 Maven Wrapper 的:\nThe Maven Wrapper is an easy way to ensure a user of your Maven build has everything necessary to run your Maven build.\nMaven Wrapper 是一种简单的方法,可以确保 Maven 构建的用户拥有运行 Maven 构建所需的一切。\nMaven Wrapper 可以确保构建过程使用正确的 Maven 版本,非常方便。要使用 Maven Wrapper,请在项目目录中运行以下命令:\nmvn wrapper:wrapper 此命令会在我们的项目中生成 Maven Wrapper 文件。现在我们可以使用 ./mvnw (或 Windows 上的 ./mvnw.cmd)而不是 mvn 来执行 Maven 命令。\n通过持续集成实现构建自动化 # 将 Maven 项目与持续集成 (CI) 系统(例如 Jenkins 或 GitHub Actions)集成,可确保自动构建、测试和部署我们的代码。CI 有助于及早发现问题并在整个团队中提供一致的构建流程。以下是 Maven 项目的简单 GitHub Actions 工作流程示例:\nname: Java CI with Maven on: [push] jobs: build: runs-on: ubuntu-latest steps: - name: Checkout code uses: actions/checkout@v2 - name: Set up JDK 11 uses: actions/setup-java@v2 with: java-version: \u0026#39;11\u0026#39; distribution: \u0026#39;adopt\u0026#39; - name: Build with Maven run: ./mvnw clean install 利用 Maven 插件获得附加功能 # 有许多 Maven 插件可用于扩展 Maven 的功能。一些流行的插件包括(前三个是 Maven 自带的插件,后三个是第三方提供的插件):\nmaven-surefire-plugin:配置并执行单元测试。 maven-failsafe-plugin:配置并执行集成测试。 maven-javadoc-plugin:生成 Javadoc 格式的项目文档。 maven-checkstyle-plugin:强制执行编码标准和最佳实践。 jacoco-maven-plugin: 单测覆盖率。 sonar-maven-plugin:分析代码质量。 …… jacoco-maven-plugin 使用示例:\n\u0026lt;build\u0026gt; \u0026lt;plugins\u0026gt; \u0026lt;plugin\u0026gt; \u0026lt;groupId\u0026gt;org.jacoco\u0026lt;/groupId\u0026gt; \u0026lt;artifactId\u0026gt;jacoco-maven-plugin\u0026lt;/artifactId\u0026gt; \u0026lt;version\u0026gt;0.8.8\u0026lt;/version\u0026gt; \u0026lt;executions\u0026gt; \u0026lt;execution\u0026gt; \u0026lt;goals\u0026gt; \u0026lt;goal\u0026gt;prepare-agent\u0026lt;/goal\u0026gt; \u0026lt;/goals\u0026gt; \u0026lt;/execution\u0026gt; \u0026lt;execution\u0026gt; \u0026lt;id\u0026gt;generate-code-coverage-report\u0026lt;/id\u0026gt; \u0026lt;phase\u0026gt;test\u0026lt;/phase\u0026gt; \u0026lt;goals\u0026gt; \u0026lt;goal\u0026gt;report\u0026lt;/goal\u0026gt; \u0026lt;/goals\u0026gt; \u0026lt;/execution\u0026gt; \u0026lt;/executions\u0026gt; \u0026lt;/plugin\u0026gt; \u0026lt;/plugins\u0026gt; \u0026lt;/build\u0026gt; 如果这些已有的插件无法满足我们的需求,我们还可以自定义插件。\n探索可用的插件并在 pom.xml 文件中配置它们以增强我们的开发过程。\n总结 # Maven 是一个强大的工具,可以简化 Java 项目的构建过程和依赖关系管理。通过遵循这些最佳实践和技巧,我们可以优化 Maven 的使用并改善我们的 Java 开发体验。请记住使用标准目录结构,有效管理依赖关系,利用不同环境的配置文件,并将项目与持续集成系统集成,以确保构建一致。\n"},{"id":535,"href":"/zh/docs/technology/Interview/database/mongodb/mongodb-questions-01/","title":"MongoDB常见面试题总结(上)","section":"Mongodb","content":" 少部分内容参考了 MongoDB 官方文档的描述,在此说明一下。\nMongoDB 基础 # MongoDB 是什么? # MongoDB 是一个基于 分布式文件存储 的开源 NoSQL 数据库系统,由 C++ 编写的。MongoDB 提供了 面向文档 的存储方式,操作起来比较简单和容易,支持“无模式”的数据建模,可以存储比较复杂的数据类型,是一款非常流行的 文档类型数据库 。\n在高负载的情况下,MongoDB 天然支持水平扩展和高可用,可以很方便地添加更多的节点/实例,以保证服务性能和可用性。在许多场景下,MongoDB 可以用于代替传统的关系型数据库或键/值存储方式,皆在为 Web 应用提供可扩展的高可用高性能数据存储解决方案。\nMongoDB 的存储结构是什么? # MongoDB 的存储结构区别于传统的关系型数据库,主要由如下三个单元组成:\n文档(Document):MongoDB 中最基本的单元,由 BSON 键值对(key-value)组成,类似于关系型数据库中的行(Row)。 集合(Collection):一个集合可以包含多个文档,类似于关系型数据库中的表(Table)。 数据库(Database):一个数据库中可以包含多个集合,可以在 MongoDB 中创建多个数据库,类似于关系型数据库中的数据库(Database)。 也就是说,MongoDB 将数据记录存储为文档 (更具体来说是 BSON 文档),这些文档在集合中聚集在一起,数据库中存储一个或多个文档集合。\nSQL 与 MongoDB 常见术语对比:\nSQL MongoDB 表(Table) 集合(Collection) 行(Row) 文档(Document) 列(Col) 字段(Field) 主键(Primary Key) 对象 ID(Objectid) 索引(Index) 索引(Index) 嵌套表(Embedded Table) 嵌入式文档(Embedded Document) 数组(Array) 数组(Array) 文档 # MongoDB 中的记录就是一个 BSON 文档,它是由键值对组成的数据结构,类似于 JSON 对象,是 MongoDB 中的基本数据单元。字段的值可能包括其他文档、数组和文档数组。\n文档的键是字符串。除了少数例外情况,键可以使用任意 UTF-8 字符。\n键不能含有 \\0(空字符)。这个字符用来表示键的结尾。 . 和 $ 有特别的意义,只有在特定环境下才能使用。 以下划线_开头的键是保留的(不是严格要求的)。 BSON [bee·sahn] 是 Binary JSON的简称,是 JSON 文档的二进制表示,支持将文档和数组嵌入到其他文档和数组中,还包含允许表示不属于 JSON 规范的数据类型的扩展。有关 BSON 规范的内容,可以参考 bsonspec.org,另见 BSON 类型。\n根据维基百科对 BJSON 的介绍,BJSON 的遍历速度优于 JSON,这也是 MongoDB 选择 BSON 的主要原因,但 BJSON 需要更多的存储空间。\n与 JSON 相比,BSON 着眼于提高存储和扫描效率。BSON 文档中的大型元素以长度字段为前缀以便于扫描。在某些情况下,由于长度前缀和显式数组索引的存在,BSON 使用的空间会多于 JSON。\n集合 # MongoDB 集合存在于数据库中,没有固定的结构,也就是 无模式 的,这意味着可以往集合插入不同格式和类型的数据。不过,通常情况下,插入集合中的数据都会有一定的关联性。\n集合不需要事先创建,当第一个文档插入或者第一个索引创建时,如果该集合不存在,则会创建一个新的集合。\n集合名可以是满足下列条件的任意 UTF-8 字符串:\n集合名不能是空字符串\u0026quot;\u0026quot;。 集合名不能含有 \\0 (空字符),这个字符表示集合名的结尾。 集合名不能以\u0026quot;system.\u0026ldquo;开头,这是为系统集合保留的前缀。例如 system.users 这个集合保存着数据库的用户信息,system.namespaces 集合保存着所有数据库集合的信息。 集合名必须以下划线或者字母符号开始,并且不能包含 $。 数据库 # 数据库用于存储所有集合,而集合又用于存储所有文档。一个 MongoDB 中可以创建多个数据库,每一个数据库都有自己的集合和权限。\nMongoDB 预留了几个特殊的数据库。\nadmin : admin 数据库主要是保存 root 用户和角色。例如,system.users 表存储用户,system.roles 表存储角色。一般不建议用户直接操作这个数据库。将一个用户添加到这个数据库,且使它拥有 admin 库上的名为 dbAdminAnyDatabase 的角色权限,这个用户自动继承所有数据库的权限。一些特定的服务器端命令也只能从这个数据库运行,比如关闭服务器。 local : local 数据库是不会被复制到其他分片的,因此可以用来存储本地单台服务器的任意 collection。一般不建议用户直接使用 local 库存储任何数据,也不建议进行 CRUD 操作,因为数据无法被正常备份与恢复。 config : 当 MongoDB 使用分片设置时,config 数据库可用来保存分片的相关信息。 test : 默认创建的测试库,连接 mongod 服务时,如果不指定连接的具体数据库,默认就会连接到 test 数据库。 数据库名可以是满足以下条件的任意 UTF-8 字符串:\n不能是空字符串\u0026quot;\u0026quot;。 不得含有' '(空格)、.、$、/、\\和 \\0 (空字符)。 应全部小写。 最多 64 字节。 数据库名最终会变成文件系统里的文件,这也就是有如此多限制的原因。\nMongoDB 有什么特点? # 数据记录被存储为文档:MongoDB 中的记录就是一个 BSON 文档,它是由键值对组成的数据结构,类似于 JSON 对象,是 MongoDB 中的基本数据单元。 模式自由:集合的概念类似 MySQL 里的表,但它不需要定义任何模式,能够用更少的数据对象表现复杂的领域模型对象。 支持多种查询方式:MongoDB 查询 API 支持读写操作 (CRUD)以及数据聚合、文本搜索和地理空间查询。 支持 ACID 事务:NoSQL 数据库通常不支持事务,为了可扩展和高性能进行了权衡。不过,也有例外,MongoDB 就支持事务。与关系型数据库一样,MongoDB 事务同样具有 ACID 特性。MongoDB 单文档原生支持原子性,也具备事务的特性。MongoDB 4.0 加入了对多文档事务的支持,但只支持复制集部署模式下的事务,也就是说事务的作用域限制为一个副本集内。MongoDB 4.2 引入了分布式事务,增加了对分片集群上多文档事务的支持,并合并了对副本集上多文档事务的现有支持。 高效的二进制存储:存储在集合中的文档,是以键值对的形式存在的。键用于唯一标识一个文档,一般是 ObjectId 类型,值是以 BSON 形式存在的。BSON = Binary JSON, 是在 JSON 基础上加了一些类型及元数据描述的格式。 自带数据压缩功能:存储同样的数据所需的资源更少。 支持 mapreduce:通过分治的方式完成复杂的聚合任务。不过,从 MongoDB 5.0 开始,map-reduce 已经不被官方推荐使用了,替代方案是 聚合管道。聚合管道提供比 map-reduce 更好的性能和可用性。 支持多种类型的索引:MongoDB 支持多种类型的索引,包括单字段索引、复合索引、多键索引、哈希索引、文本索引、 地理位置索引等,每种类型的索引有不同的使用场合。 支持 failover:提供自动故障恢复的功能,主节点发生故障时,自动从从节点中选举出一个新的主节点,确保集群的正常使用,这对于客户端来说是无感知的。 支持分片集群:MongoDB 支持集群自动切分数据,让集群存储更多的数据,具备更强的性能。在数据插入和更新时,能够自动路由和存储。 支持存储大文件:MongoDB 的单文档存储空间要求不超过 16MB。对于超过 16MB 的大文件,MongoDB 提供了 GridFS 来进行存储,通过 GridFS,可以将大型数据进行分块处理,然后将这些切分后的小文档保存在数据库中。 MongoDB 适合什么应用场景? # MongoDB 的优势在于其数据模型和存储引擎的灵活性、架构的可扩展性以及对强大的索引支持。\n选用 MongoDB 应该充分考虑 MongoDB 的优势,结合实际项目的需求来决定:\n随着项目的发展,使用类 JSON 格式(BSON)保存数据是否满足项目需求?MongoDB 中的记录就是一个 BSON 文档,它是由键值对组成的数据结构,类似于 JSON 对象,是 MongoDB 中的基本数据单元。 是否需要大数据量的存储?是否需要快速水平扩展?MongoDB 支持分片集群,可以很方便地添加更多的节点(实例),让集群存储更多的数据,具备更强的性能。 是否需要更多类型索引来满足更多应用场景?MongoDB 支持多种类型的索引,包括单字段索引、复合索引、多键索引、哈希索引、文本索引、 地理位置索引等,每种类型的索引有不同的使用场合。 …… MongoDB 存储引擎 # MongoDB 支持哪些存储引擎? # 存储引擎(Storage Engine)是数据库的核心组件,负责管理数据在内存和磁盘中的存储方式。\n与 MySQL 一样,MongoDB 采用的也是 插件式的存储引擎架构 ,支持不同类型的存储引擎,不同的存储引擎解决不同场景的问题。在创建数据库或集合时,可以指定存储引擎。\n插件式的存储引擎架构可以实现 Server 层和存储引擎层的解耦,可以支持多种存储引擎,如 MySQL 既可以支持 B-Tree 结构的 InnoDB 存储引擎,还可以支持 LSM 结构的 RocksDB 存储引擎。\n在存储引擎刚出来的时候,默认是使用 MMAPV1 存储引擎,MongoDB4.x 版本不再支持 MMAPv1 存储引擎。\n现在主要有下面这两种存储引擎:\nWiredTiger 存储引擎:自 MongoDB 3.2 以后,默认的存储引擎为 WiredTiger 存储引擎 。非常适合大多数工作负载,建议用于新部署。WiredTiger 提供文档级并发模型、检查点和数据压缩(后文会介绍到)等功能。 In-Memory 存储引擎: In-Memory 存储引擎在 MongoDB Enterprise 中可用。它不是将文档存储在磁盘上,而是将它们保留在内存中以获得更可预测的数据延迟。 此外,MongoDB 3.0 提供了 可插拔的存储引擎 API ,允许第三方为 MongoDB 开发存储引擎,这点和 MySQL 也比较类似。\nWiredTiger 基于 LSM Tree 还是 B+ Tree? # 目前绝大部分流行的数据库存储引擎都是基于 B/B+ Tree 或者 LSM(Log Structured Merge) Tree 来实现的。对于 NoSQL 数据库来说,绝大部分(比如 HBase、Cassandra、RocksDB)都是基于 LSM 树,MongoDB 不太一样。\n上面也说了,自 MongoDB 3.2 以后,默认的存储引擎为 WiredTiger 存储引擎。在 WiredTiger 引擎官网上,我们发现 WiredTiger 使用的是 B+ 树作为其存储结构:\nWiredTiger maintains a table\u0026#39;s data in memory using a data structure called a B-Tree ( B+ Tree to be specific), referring to the nodes of a B-Tree as pages. Internal pages carry only keys. The leaf pages store both keys and values. 此外,WiredTiger 还支持 LSM(Log Structured Merge) 树作为存储结构,MongoDB 在使用 WiredTiger 作为存储引擎时,默认使用的是 B+ 树。\n如果想要了解 MongoDB 使用 B+ 树的原因,可以看看这篇文章: 【驳斥八股文系列】别瞎分析了,MongoDB 使用的是 B+ 树,不是你们以为的 B 树。\n使用 B+ 树时,WiredTiger 以 page 为基本单位往磁盘读写数据。B+ 树的每个节点为一个 page,共有三种类型的 page:\nroot page(根节点):B+ 树的根节点。 internal page(内部节点):不实际存储数据的中间索引节点。 leaf page(叶子节点):真正存储数据的叶子节点,包含一个页头(page header)、块头(block header)和真正的数据(key/value),其中页头定义了页的类型、页中实际载荷数据的大小、页中记录条数等信息;块头定义了此页的 checksum、块在磁盘上的寻址位置等信息。 其整体结构如下图所示:\n如果想要深入研究学习 WiredTiger 存储引擎,推荐阅读 MongoDB 中文社区的 WiredTiger 存储引擎系列。\nMongoDB 聚合 # MongoDB 聚合有什么用? # 实际项目中,我们经常需要将多个文档甚至是多个集合汇总到一起计算分析(比如求和、取最大值)并返回计算后的结果,这个过程被称为 聚合操作 。\n根据官方文档介绍,我们可以使用聚合操作来:\n将来自多个文档的值组合在一起。 对集合中的数据进行的一系列运算。 分析数据随时间的变化。 MongoDB 提供了哪几种执行聚合的方法? # MongoDB 提供了两种执行聚合的方法:\n聚合管道(Aggregation Pipeline):执行聚合操作的首选方法。 单一目的聚合方法(Single purpose aggregation methods):也就是单一作用的聚合函数比如 count()、distinct()、estimatedDocumentCount()。 绝大部分文章中还提到了 map-reduce 这种聚合方法。不过,从 MongoDB 5.0 开始,map-reduce 已经不被官方推荐使用了,替代方案是 聚合管道。聚合管道提供比 map-reduce 更好的性能和可用性。\nMongoDB 聚合管道由多个阶段组成,每个阶段在文档通过管道时转换文档。每个阶段接收前一个阶段的输出,进一步处理数据,并将其作为输入数据发送到下一个阶段。\n每个管道的工作流程是:\n接受一系列原始数据文档 对这些文档进行一系列运算 结果文档输出给下一个阶段 常用阶段操作符:\n操作符 简述 $match 匹配操作符,用于对文档集合进行筛选 $project 投射操作符,用于重构每一个文档的字段,可以提取字段,重命名字段,甚至可以对原有字段进行操作后新增字段 $sort 排序操作符,用于根据一个或多个字段对文档进行排序 $limit 限制操作符,用于限制返回文档的数量 $skip 跳过操作符,用于跳过指定数量的文档 $count 统计操作符,用于统计文档的数量 $group 分组操作符,用于对文档集合进行分组 $unwind 拆分操作符,用于将数组中的每一个值拆分为单独的文档 $lookup 连接操作符,用于连接同一个数据库中另一个集合,并获取指定的文档,类似于 populate 更多操作符介绍详见官方文档: https://docs.mongodb.com/manual/reference/operator/aggregation/\n阶段操作符用于 db.collection.aggregate 方法里面,数组参数中的第一层。\ndb.collection.aggregate( [ { 阶段操作符:表述 }, { 阶段操作符:表述 }, ... ] ) 下面是 MongoDB 官方文档中的一个例子:\ndb.orders.aggregate([ # 第一阶段:$match阶段按status字段过滤文档,并将status等于\u0026#34;A\u0026#34;的文档传递到下一阶段。 { $match: { status: \u0026#34;A\u0026#34; } }, # 第二阶段:$group阶段按cust_id字段将文档分组,以计算每个cust_id唯一值的金额总和。 { $group: { _id: \u0026#34;$cust_id\u0026#34;, total: { $sum: \u0026#34;$amount\u0026#34; } } } ]) MongoDB 事务 # MongoDB 事务想要搞懂原理还是比较花费时间的,我自己也没有搞太明白。因此,我这里只是简单介绍一下 MongoDB 事务,想要了解原理的小伙伴,可以自行搜索查阅相关资料。\n这里推荐几篇文章,供大家参考:\n技术干货| MongoDB 事务原理 MongoDB 一致性模型设计与实现 MongoDB 官方文档对事务的介绍 我们在介绍 NoSQL 数据的时候也说过,NoSQL 数据库通常不支持事务,为了可扩展和高性能进行了权衡。不过,也有例外,MongoDB 就支持事务。\n与关系型数据库一样,MongoDB 事务同样具有 ACID 特性:\n原子性(Atomicity):事务是最小的执行单位,不允许分割。事务的原子性确保动作要么全部完成,要么完全不起作用; 一致性(Consistency):执行事务前后,数据保持一致,例如转账业务中,无论事务是否成功,转账者和收款人的总额应该是不变的; 隔离性(Isolation):并发访问数据库时,一个用户的事务不被其他事务所干扰,各并发事务之间数据库是独立的。WiredTiger 存储引擎支持读未提交( read-uncommitted )、读已提交( read-committed )和快照( snapshot )隔离,MongoDB 启动时默认选快照隔离。在不同隔离级别下,一个事务的生命周期内,可能出现脏读、不可重复读、幻读等现象。 持久性(Durability):一个事务被提交之后。它对数据库中数据的改变是持久的,即使数据库发生故障也不应该对其有任何影响。 关于事务的详细介绍这篇文章就不多说了,感兴趣的可以看看我写的 MySQL 常见面试题总结这篇文章,里面有详细介绍到。\nMongoDB 单文档原生支持原子性,也具备事务的特性。当谈论 MongoDB 事务的时候,通常指的是 多文档 。MongoDB 4.0 加入了对多文档 ACID 事务的支持,但只支持复制集部署模式下的 ACID 事务,也就是说事务的作用域限制为一个副本集内。MongoDB 4.2 引入了 分布式事务 ,增加了对分片集群上多文档事务的支持,并合并了对副本集上多文档事务的现有支持。\n根据官方文档介绍:\n从 MongoDB 4.2 开始,分布式事务和多文档事务在 MongoDB 中是一个意思。分布式事务是指分片集群和副本集上的多文档事务。从 MongoDB 4.2 开始,多文档事务(无论是在分片集群还是副本集上)也称为分布式事务。\n在大多数情况下,多文档事务比单文档写入会产生更大的性能成本。对于大部分场景来说, 非规范化数据模型(嵌入式文档和数组) 依然是最佳选择。也就是说,适当地对数据进行建模可以最大限度地减少对多文档事务的需求。\n注意:\n从 MongoDB 4.2 开始,多文档事务支持副本集和分片集群,其中:主节点使用 WiredTiger 存储引擎,同时从节点使用 WiredTiger 存储引擎或 In-Memory 存储引擎。在 MongoDB 4.0 中,只有使用 WiredTiger 存储引擎的副本集支持事务。 在 MongoDB 4.2 及更早版本中,你无法在事务中创建集合。从 MongoDB 4.4 开始,您可以在事务中创建集合和索引。有关详细信息,请参阅 在事务中创建集合和索引。 MongoDB 数据压缩 # 借助 WiredTiger 存储引擎( MongoDB 3.2 后的默认存储引擎),MongoDB 支持对所有集合和索引进行压缩。压缩以额外的 CPU 为代价最大限度地减少存储使用。\n默认情况下,WiredTiger 使用 Snappy 压缩算法(谷歌开源,旨在实现非常高的速度和合理的压缩,压缩比 3 ~ 5 倍)对所有集合使用块压缩,对所有索引使用前缀压缩。\n除了 Snappy 之外,对于集合还有下面这些压缩算法:\nzlib:高度压缩算法,压缩比 5 ~ 7 倍 Zstandard(简称 zstd):Facebook 开源的一种快速无损压缩算法,针对 zlib 级别的实时压缩场景和更好的压缩比,提供更高的压缩率和更低的 CPU 使用率,MongoDB 4.2 开始可用。 WiredTiger 日志也会被压缩,默认使用的也是 Snappy 压缩算法。如果日志记录小于或等于 128 字节,WiredTiger 不会压缩该记录。\nAmazon Document 与 MongoDB 的差异 # Amazon DocumentDB(与 MongoDB 兼容) 是一种快速、可靠、完全托管的数据库服务。Amazon DocumentDB 可在云中轻松设置、操作和扩展与 MongoDB 兼容的数据库。\n$vectorSearch 运算符 # Amazon DocumentDB 不支持$vectorSearch作为独立运营商。相反,我们在$search运营商vectorSearch内部支持。有关更多信息,请参阅 向量搜索 Amazon DocumentDB。\nOpCountersCommand # Amazon DocumentDB 的OpCountersCommand行为偏离于 MongoDB 的opcounters.command 如下:\nMongoDB 的opcounters.command 计入除插入、更新和删除之外的所有命令,而 Amazon DocumentDB 的 OpCountersCommand 也排除 find 命令。 Amazon DocumentDB 将内部命令(例如getCloudWatchMetricsV2)对 OpCountersCommand 计入。 管理数据库和集合 # Amazon DocumentDB 不支持管理或本地数据库,MongoDB system.* 或 startup_log 集合也不支持。\ncursormaxTimeMS # 在 Amazon DocumentDB 中,cursor.maxTimeMS 重置每个请求的计数器。getMore因此,如果指定了 3000MS maxTimeMS,则该查询耗时 2800MS,而每个后续getMore请求耗时 300MS,则游标不会超时。游标仅在单个操作(无论是查询还是单个getMore请求)耗时超过指定值时才将超时maxTimeMS。此外,检查游标执行时间的扫描器以五 (5) 分钟间隔尺寸运行。\nexplain() # Amazon DocumentDB 在利用分布式、容错、自修复的存储系统的专用数据库引擎上模拟 MongoDB 4.0 API。因此,查询计划和explain() 的输出在 Amazon DocumentDB 和 MongoDB 之间可能有所不同。希望控制其查询计划的客户可以使用 $hint 运算符强制选择首选索引。\n字段名称限制 # Amazon DocumentDB 不支持点“。” 例如,文档字段名称中 db.foo.insert({‘x.1’:1})。\nAmazon DocumentDB 也不支持字段名称中的 $ 前缀。\n例如,在 Amazon DocumentDB 或 MongoDB 中尝试以下命令:\nrs0:PRIMARY\u0026lt; db.foo.insert({\u0026#34;a\u0026#34;:{\u0026#34;$a\u0026#34;:1}}) MongoDB 将返回以下内容:\nWriteResult({ \u0026#34;nInserted\u0026#34; : 1 }) Amazon DocumentDB 将返回一个错误:\nWriteResult({ \u0026#34;nInserted\u0026#34; : 0, \u0026#34;writeError\u0026#34; : { \u0026#34;code\u0026#34; : 2, \u0026#34;errmsg\u0026#34; : \u0026#34;Document can\u0026#39;t have $ prefix field names: $a\u0026#34; } }) 参考 # MongoDB 官方文档(主要参考资料,以官方文档为准): https://www.mongodb.com/docs/manual/ 《MongoDB 权威指南》 技术干货| MongoDB 事务原理 - MongoDB 中文社区: https://mongoing.com/archives/82187 Transactions - MongoDB 官方文档: https://www.mongodb.com/docs/manual/core/transactions/ WiredTiger Storage Engine - MongoDB 官方文档: https://www.mongodb.com/docs/manual/core/wiredtiger/ WiredTiger 存储引擎之一:基础数据结构分析: https://mongoing.com/topic/archives-35143 "},{"id":536,"href":"/zh/docs/technology/Interview/database/mongodb/mongodb-questions-02/","title":"MongoDB常见面试题总结(下)","section":"Mongodb","content":" MongoDB 索引 # MongoDB 索引有什么用? # 和关系型数据库类似,MongoDB 中也有索引。索引的目的主要是用来提高查询效率,如果没有索引的话,MongoDB 必须执行 集合扫描 ,即扫描集合中的每个文档,以选择与查询语句匹配的文档。如果查询存在合适的索引,MongoDB 可以使用该索引来限制它必须检查的文档数量。并且,MongoDB 可以使用索引中的排序返回排序后的结果。\n虽然索引可以显著缩短查询时间,但是使用索引、维护索引是有代价的。在执行写入操作时,除了要更新文档之外,还必须更新索引,这必然会影响写入的性能。因此,当有大量写操作而读操作少时,或者不考虑读操作的性能时,都不推荐建立索引。\nMongoDB 支持哪些类型的索引? # MongoDB 支持多种类型的索引,包括单字段索引、复合索引、多键索引、哈希索引、文本索引、 地理位置索引等,每种类型的索引有不同的使用场合。\n单字段索引: 建立在单个字段上的索引,索引创建的排序顺序无所谓,MongoDB 可以头/尾开始遍历。 复合索引: 建立在多个字段上的索引,也可以称之为组合索引、联合索引。 多键索引:MongoDB 的一个字段可能是数组,在对这种字段创建索引时,就是多键索引。MongoDB 会为数组的每个值创建索引。就是说你可以按照数组里面的值做条件来查询,这个时候依然会走索引。 哈希索引:按数据的哈希值索引,用在哈希分片集群上。 文本索引: 支持对字符串内容的文本搜索查询。文本索引可以包含任何值为字符串或字符串元素数组的字段。一个集合只能有一个文本搜索索引,但该索引可以覆盖多个字段。MongoDB 虽然支持全文索引,但是性能低下,暂时不建议使用。 地理位置索引: 基于经纬度的索引,适合 2D 和 3D 的位置查询。 唯一索引:确保索引字段不会存储重复值。如果集合已经存在了违反索引的唯一约束的文档,则后台创建唯一索引会失败。 TTL 索引:TTL 索引提供了一个过期机制,允许为每一个文档设置一个过期时间,当一个文档达到预设的过期时间之后就会被删除。 …… 复合索引中字段的顺序有影响吗? # 复合索引中字段的顺序非常重要,例如下图中的复合索引由{userid:1, score:-1}组成,则该复合索引首先按照userid升序排序;然后再每个userid的值内,再按照score降序排序。\n在复合索引中,按照何种方式排序,决定了该索引在查询中是否能被应用到。\n走复合索引的排序:\ndb.s2.find().sort({\u0026#34;userid\u0026#34;: 1, \u0026#34;score\u0026#34;: -1}) db.s2.find().sort({\u0026#34;userid\u0026#34;: -1, \u0026#34;score\u0026#34;: 1}) 不走复合索引的排序:\ndb.s2.find().sort({\u0026#34;userid\u0026#34;: 1, \u0026#34;score\u0026#34;: 1}) db.s2.find().sort({\u0026#34;userid\u0026#34;: -1, \u0026#34;score\u0026#34;: -1}) db.s2.find().sort({\u0026#34;score\u0026#34;: 1, \u0026#34;userid\u0026#34;: -1}) db.s2.find().sort({\u0026#34;score\u0026#34;: 1, \u0026#34;userid\u0026#34;: 1}) db.s2.find().sort({\u0026#34;score\u0026#34;: -1, \u0026#34;userid\u0026#34;: -1}) db.s2.find().sort({\u0026#34;score\u0026#34;: -1, \u0026#34;userid\u0026#34;: 1}) 我们可以通过 explain 进行分析:\ndb.s2.find().sort({\u0026#34;score\u0026#34;: -1, \u0026#34;userid\u0026#34;: 1}).explain() 复合索引遵循左前缀原则吗? # MongoDB 的复合索引遵循左前缀原则:拥有多个键的索引,可以同时得到所有这些键的前缀组成的索引,但不包括除左前缀之外的其他子集。比如说,有一个类似 {a: 1, b: 1, c: 1, ..., z: 1} 这样的索引,那么实际上也等于有了 {a: 1}、{a: 1, b: 1}、{a: 1, b: 1, c: 1} 等一系列索引,但是不会有 {b: 1} 这样的非左前缀的索引。\n什么是 TTL 索引? # TTL 索引提供了一个过期机制,允许为每一个文档设置一个过期时间 expireAfterSeconds ,当一个文档达到预设的过期时间之后就会被删除。TTL 索引除了有 expireAfterSeconds 属性外,和普通索引一样。\n数据过期对于某些类型的信息很有用,比如机器生成的事件数据、日志和会话信息,这些信息只需要在数据库中保存有限的时间。\nTTL 索引运行原理:\nMongoDB 会开启一个后台线程读取该 TTL 索引的值来判断文档是否过期,但不会保证已过期的数据会立马被删除,因后台线程每 60 秒触发一次删除任务,且如果删除的数据量较大,会存在上一次的删除未完成,而下一次的任务已经开启的情况,导致过期的数据也会出现超过了数据保留时间 60 秒以上的现象。 对于副本集而言,TTL 索引的后台进程只会在 Primary 节点开启,在从节点会始终处于空闲状态,从节点的数据删除是由主库删除后产生的 oplog 来做同步。 TTL 索引限制:\nTTL 索引是单字段索引。复合索引不支持 TTL _id字段不支持 TTL 索引。 无法在上限集合(Capped Collection)上创建 TTL 索引,因为 MongoDB 无法从上限集合中删除文档。 如果某个字段已经存在非 TTL 索引,那么在该字段上无法再创建 TTL 索引。 什么是覆盖索引查询? # 根据官方文档介绍,覆盖查询是以下的查询:\n所有的查询字段是索引的一部分。 结果中返回的所有字段都在同一索引中。 查询中没有字段等于null。 由于所有出现在查询中的字段是索引的一部分, MongoDB 无需在整个数据文档中检索匹配查询条件和返回使用相同索引的查询结果。因为索引存在于内存中,从索引中获取数据比通过扫描文档读取数据要快得多。\n举个例子:我们有如下 users 集合:\n{ \u0026#34;_id\u0026#34;: ObjectId(\u0026#34;53402597d852426020000002\u0026#34;), \u0026#34;contact\u0026#34;: \u0026#34;987654321\u0026#34;, \u0026#34;dob\u0026#34;: \u0026#34;01-01-1991\u0026#34;, \u0026#34;gender\u0026#34;: \u0026#34;M\u0026#34;, \u0026#34;name\u0026#34;: \u0026#34;Tom Benzamin\u0026#34;, \u0026#34;user_name\u0026#34;: \u0026#34;tombenzamin\u0026#34; } 我们在 users 集合中创建联合索引,字段为 gender 和 user_name :\ndb.users.ensureIndex({gender:1,user_name:1}) 现在,该索引会覆盖以下查询:\ndb.users.find({gender:\u0026#34;M\u0026#34;},{user_name:1,_id:0}) 为了让指定的索引覆盖查询,必须显式地指定 _id: 0 来从结果中排除 _id 字段,因为索引不包括 _id 字段。\nMongoDB 高可用 # 复制集群 # 什么是复制集群? # MongoDB 的复制集群又称为副本集群,是一组维护相同数据集合的 mongod 进程。\n客户端连接到整个 Mongodb 复制集群,主节点机负责整个复制集群的写,从节点可以进行读操作,但默认还是主节点负责整个复制集群的读。主节点发生故障时,自动从从节点中选举出一个新的主节点,确保集群的正常使用,这对于客户端来说是无感知的。\n通常来说,一个复制集群包含 1 个主节点(Primary),多个从节点(Secondary)以及零个或 1 个仲裁节点(Arbiter)。\n主节点:整个集群的写操作入口,接收所有的写操作,并将集合所有的变化记录到操作日志中,即 oplog。主节点挂掉之后会自动选出新的主节点。 从节点:从主节点同步数据,在主节点挂掉之后选举新节点。不过,从节点可以配置成 0 优先级,阻止它在选举中成为主节点。 仲裁节点:这个是为了节约资源或者多机房容灾用,只负责主节点选举时投票不存数据,保证能有节点获得多数赞成票。 下图是一个典型的三成员副本集群:\n主节点与备节点之间是通过 oplog(操作日志) 来同步数据的。oplog 是 local 库下的一个特殊的 上限集合(Capped Collection) ,用来保存写操作所产生的增量日志,类似于 MySQL 中 的 Binlog。\n上限集合类似于定长的循环队列,数据顺序追加到集合的尾部,当集合空间达到上限时,它会覆盖集合中最旧的文档。上限集合的数据将会被顺序写入到磁盘的固定空间内,所以,I/O 速度非常快,如果不建立索引,性能更好。\n当主节点上的一个写操作完成后,会向 oplog 集合写入一条对应的日志,而从节点则通过这个 oplog 不断拉取到新的日志,在本地进行回放以达到数据同步的目的。\n副本集最多有一个主节点。 如果当前主节点不可用,一个选举会抉择出新的主节点。MongoDB 的节点选举规则能够保证在 Primary 挂掉之后选取的新节点一定是集群中数据最全的一个。\n为什么要用复制集群? # 实现 failover:提供自动故障恢复的功能,主节点发生故障时,自动从从节点中选举出一个新的主节点,确保集群的正常使用,这对于客户端来说是无感知的。 实现读写分离:我们可以设置从节点上可以读取数据,主节点负责写入数据,这样的话就实现了读写分离,减轻了主节点读写压力过大的问题。MongoDB 4.0 之前版本如果主库压力不大,不建议读写分离,因为写会阻塞读,除非业务对响应时间不是非常关注以及读取历史数据接受一定时间延迟。 分片集群 # 什么是分片集群? # 分片集群是 MongoDB 的分布式版本,相较副本集,分片集群数据被均衡的分布在不同分片中, 不仅大幅提升了整个集群的数据容量上限,也将读写的压力分散到不同分片,以解决副本集性能瓶颈的难题。\nMongoDB 的分片集群由如下三个部分组成(下图来源于 官方文档对分片集群的介绍):\nConfig Servers:配置服务器,本质上是一个 MongoDB 的副本集,负责存储集群的各种元数据和配置,如分片地址、Chunks 等 Mongos:路由服务,不存具体数据,从 Config 获取集群配置讲请求转发到特定的分片,并且整合分片结果返回给客户端。 Shard:每个分片是整体数据的一部分子集,从 MongoDB3.6 版本开始,每个 Shard 必须部署为副本集(replica set)架构 为什么要用分片集群? # 随着系统数据量以及吞吐量的增长,常见的解决办法有两种:垂直扩展和水平扩展。\n垂直扩展通过增加单个服务器的能力来实现,比如磁盘空间、内存容量、CPU 数量等;水平扩展则通过将数据存储到多个服务器上来实现,根据需要添加额外的服务器以增加容量。\n类似于 Redis Cluster,MongoDB 也可以通过分片实现 水平扩展 。水平扩展这种方式更灵活,可以满足更大数据量的存储需求,支持更高吞吐量。并且,水平扩展所需的整体成本更低,仅仅需要相对较低配置的单机服务器即可,代价是增加了部署的基础设施和维护的复杂性。\n也就是说当你遇到如下问题时,可以使用分片集群解决:\n存储容量受单机限制,即磁盘资源遭遇瓶颈。 读写能力受单机限制,可能是 CPU、内存或者网卡等资源遭遇瓶颈,导致读写能力无法扩展。 什么是分片键? # 分片键(Shard Key) 是数据分区的前提, 从而实现数据分发到不同服务器上,减轻服务器的负担。也就是说,分片键决定了集合内的文档如何在集群的多个分片间的分布状况。\n分片键就是文档里面的一个字段,但是这个字段不是普通的字段,有一定的要求:\n它必须在所有文档中都出现。 它必须是集合的一个索引,可以是单索引或复合索引的前缀索引,不能是多索引、文本索引或地理空间位置索引。 MongoDB 4.2 之前的版本,文档的分片键字段值不可变。MongoDB 4.2 版本开始,除非分片键字段是不可变的 _id 字段,否则您可以更新文档的分片键值。MongoDB 5.0 版本开始,实现了实时重新分片(live resharding),可以实现分片键的完全重新选择。 它的大小不能超过 512 字节。 如何选择分片键? # 选择合适的片键对 sharding 效率影响很大,主要基于如下四个因素(摘自 分片集群使用注意事项 - - 腾讯云文档):\n取值基数 取值基数建议尽可能大,如果用小基数的片键,因为备选值有限,那么块的总数量就有限,随着数据增多,块的大小会越来越大,导致水平扩展时移动块会非常困难。 例如:选择年龄做一个基数,范围最多只有 100 个,随着数据量增多,同一个值分布过多时,导致 chunck 的增长超出 chuncksize 的范围,引起 jumbo chunk,从而无法迁移,导致数据分布不均匀,性能瓶颈。 取值分布 取值分布建议尽量均匀,分布不均匀的片键会造成某些块的数据量非常大,同样有上面数据分布不均匀,性能瓶颈的问题。 查询带分片 查询时建议带上分片,使用分片键进行条件查询时,mongos 可以直接定位到具体分片,否则 mongos 需要将查询分发到所有分片,再等待响应返回。 避免单调递增或递减 单调递增的 sharding key,数据文件挪动小,但写入会集中,导致最后一篇的数据量持续增大,不断发生迁移,递减同理。 综上,在选择片键时要考虑以上 4 个条件,尽可能满足更多的条件,才能降低 MoveChunks 对性能的影响,从而获得最优的性能体验。\n分片策略有哪些? # MongoDB 支持两种分片算法来满足不同的查询需求(摘自 MongoDB 分片集群介绍 - 阿里云文档):\n1、基于范围的分片:\nMongoDB 按照分片键(Shard Key)的值的范围将数据拆分为不同的块(Chunk),每个块包含了一段范围内的数据。当分片键的基数大、频率低且值非单调变更时,范围分片更高效。\n优点:Mongos 可以快速定位请求需要的数据,并将请求转发到相应的 Shard 节点中。 缺点:可能导致数据在 Shard 节点上分布不均衡,容易造成读写热点,且不具备写分散性。 适用场景:分片键的值不是单调递增或单调递减、分片键的值基数大且重复的频率低、需要范围查询等业务场景。 2、基于 Hash 值的分片\nMongoDB 计算单个字段的哈希值作为索引值,并以哈希值的范围将数据拆分为不同的块(Chunk)。\n优点:可以将数据更加均衡地分布在各 Shard 节点中,具备写分散性。 缺点:不适合进行范围查询,进行范围查询时,需要将读请求分发到所有的 Shard 节点。 适用场景:分片键的值存在单调递增或递减、片键的值基数大且重复的频率低、需要写入的数据随机分发、数据读取随机性较大等业务场景。 除了上述两种分片策略,您还可以配置 复合片键 ,例如由一个低基数的键和一个单调递增的键组成。\n分片数据如何存储? # Chunk(块) 是 MongoDB 分片集群的一个核心概念,其本质上就是由一组 Document 组成的逻辑数据单元。每个 Chunk 包含一定范围片键的数据,互不相交且并集为全部数据,即离散数学中划分的概念。\n分片集群不会记录每条数据在哪个分片上,而是记录 Chunk 在哪个分片上一级这个 Chunk 包含哪些数据。\n默认情况下,一个 Chunk 的最大值默认为 64MB(可调整,取值范围为 1~1024 MB。如无特殊需求,建议保持默认值),进行数据插入、更新、删除时,如果此时 Mongos 感知到了目标 Chunk 的大小或者其中的数据量超过上限,则会触发 Chunk 分裂。\n数据的增长会让 Chunk 分裂得越来越多。这个时候,各个分片上的 Chunk 数量可能会不平衡。Mongos 中的 均衡器(Balancer) 组件就会执行自动平衡,尝试使各个 Shard 上 Chunk 的数量保持均衡,这个过程就是 再平衡(Rebalance)。默认情况下,数据库和集合的 Rebalance 是开启的。\n如下图所示,随着数据插入,导致 Chunk 分裂,让 AB 两个分片有 3 个 Chunk,C 分片只有一个,这个时候就会把 B 分配的迁移一个到 C 分片实现集群数据均衡。\nBalancer 是 MongoDB 的一个运行在 Config Server 的 Primary 节点上(自 MongoDB 3.4 版本起)的后台进程,它监控每个分片上 Chunk 数量,并在某个分片上 Chunk 数量达到阈值进行迁移。\nChunk 只会分裂,不会合并,即使 chunkSize 的值变大。\nRebalance 操作是比较耗费系统资源的,我们可以通过在业务低峰期执行、预分片或者设置 Rebalance 时间窗等方式来减少其对 MongoDB 正常使用所带来的影响。\nChunk 迁移原理是什么? # 关于 Chunk 迁移原理的详细介绍,推荐阅读 MongoDB 中文社区的 一文读懂 MongoDB chunk 迁移这篇文章。\n学习资料推荐 # MongoDB 中文手册|官方文档中文版(推荐):基于 4.2 版本,不断与官方最新版保持同步。 MongoDB 初学者教程——7 天学习 MongoDB:快速入门。 SpringBoot 整合 MongoDB 实战 - 2022:很不错的一篇 MongoDB 入门文章,主要围绕 MongoDB 的 Java 客户端使用进行基本的增删改查操作介绍。 参考 # MongoDB 官方文档(主要参考资料,以官方文档为准): https://www.mongodb.com/docs/manual/ 《MongoDB 权威指南》 Indexes - MongoDB 官方文档: https://www.mongodb.com/docs/manual/indexes/ MongoDB - 索引知识 - 程序员翔仔 - 2022: https://fatedeity.cn/posts/database/mongodb-index-knowledge.html MongoDB - 索引: https://www.cnblogs.com/Neeo/articles/14325130.html Sharding - MongoDB 官方文档: https://www.mongodb.com/docs/manual/sharding/ MongoDB 分片集群介绍 - 阿里云文档: https://help.aliyun.com/document_detail/64561.html 分片集群使用注意事项 - - 腾讯云文档: https://cloud.tencent.com/document/product/240/44611 "},{"id":537,"href":"/zh/docs/technology/Interview/system-design/framework/mybatis/mybatis-interview/","title":"MyBatis常见面试题总结","section":"Framework","content":" 本篇文章由 JavaGuide 收集自网络,原出处不明。\n比起这些枯燥的面试题,我更建议你看看文末推荐的 MyBatis 优质好文。\n#{} 和 ${} 的区别是什么? # 注:这道题是面试官面试我同事的。\n答:\n${}是 Properties 文件中的变量占位符,它可以用于标签属性值和 sql 内部,属于原样文本替换,可以替换任意内容,比如${driver}会被原样替换为com.mysql.jdbc. Driver。 一个示例:根据参数按任意字段排序:\nselect * from users order by ${orderCols} orderCols可以是 name、name desc、name,sex asc等,实现灵活的排序。\n#{}是 sql 的参数占位符,MyBatis 会将 sql 中的#{}替换为? 号,在 sql 执行前会使用 PreparedStatement 的参数设置方法,按序给 sql 的? 号占位符设置参数值,比如 ps.setInt(0, parameterValue),#{item.name} 的取值方式为使用反射从参数对象中获取 item 对象的 name 属性值,相当于 param.getItem().getName()。 xml 映射文件中,除了常见的 select、insert、update、delete 标签之外,还有哪些标签? # 注:这道题是京东面试官面试我时问的。\n答:还有很多其他的标签, \u0026lt;resultMap\u0026gt;、 \u0026lt;parameterMap\u0026gt;、 \u0026lt;sql\u0026gt;、 \u0026lt;include\u0026gt;、 \u0026lt;selectKey\u0026gt; ,加上动态 sql 的 9 个标签, trim|where|set|foreach|if|choose|when|otherwise|bind 等,其中 \u0026lt;sql\u0026gt; 为 sql 片段标签,通过 \u0026lt;include\u0026gt; 标签引入 sql 片段, \u0026lt;selectKey\u0026gt; 为不支持自增的主键生成策略标签。\nDao 接口的工作原理是什么?Dao 接口里的方法,参数不同时,方法能重载吗? # 注:这道题也是京东面试官面试我被问的。\n答:最佳实践中,通常一个 xml 映射文件,都会写一个 Dao 接口与之对应。Dao 接口就是人们常说的 Mapper 接口,接口的全限名,就是映射文件中的 namespace 的值,接口的方法名,就是映射文件中 MappedStatement 的 id 值,接口方法内的参数,就是传递给 sql 的参数。 Mapper 接口是没有实现类的,当调用接口方法时,接口全限名+方法名拼接字符串作为 key 值,可唯一定位一个 MappedStatement ,举例:com.mybatis3.mappers. StudentDao.findStudentById ,可以唯一找到 namespace 为 com.mybatis3.mappers. StudentDao 下面 id = findStudentById 的 MappedStatement 。在 MyBatis 中,每一个 \u0026lt;select\u0026gt;、 \u0026lt;insert\u0026gt;、 \u0026lt;update\u0026gt;、 \u0026lt;delete\u0026gt; 标签,都会被解析为一个 MappedStatement 对象。\nDao 接口里的方法,是不能重载的,因为是全限名+方法名的保存和寻找策略。\nDao 接口里的方法可以重载,但是 Mybatis 的 xml 里面的 ID 不允许重复。\nMybatis 版本 3.3.0,亲测如下:\n/** * Mapper接口里面方法重载 */ public interface StuMapper { List\u0026lt;Student\u0026gt; getAllStu(); List\u0026lt;Student\u0026gt; getAllStu(@Param(\u0026#34;id\u0026#34;) Integer id); } 然后在 StuMapper.xml 中利用 Mybatis 的动态 sql 就可以实现。\n\u0026lt;select id=\u0026#34;getAllStu\u0026#34; resultType=\u0026#34;com.pojo.Student\u0026#34;\u0026gt; select * from student \u0026lt;where\u0026gt; \u0026lt;if test=\u0026#34;id != null\u0026#34;\u0026gt; id = #{id} \u0026lt;/if\u0026gt; \u0026lt;/where\u0026gt; \u0026lt;/select\u0026gt; 能正常运行,并能得到相应的结果,这样就实现了在 Dao 接口中写重载方法。\nMybatis 的 Dao 接口可以有多个重载方法,但是多个接口对应的映射必须只有一个,否则启动会报错。\n相关 issue: 更正:Dao 接口里的方法可以重载,但是 Mybatis 的 xml 里面的 ID 不允许重复!。\nDao 接口的工作原理是 JDK 动态代理,MyBatis 运行时会使用 JDK 动态代理为 Dao 接口生成代理 proxy 对象,代理对象 proxy 会拦截接口方法,转而执行 MappedStatement 所代表的 sql,然后将 sql 执行结果返回。\n补充:\nDao 接口方法可以重载,但是需要满足以下条件:\n仅有一个无参方法和一个有参方法 多个有参方法时,参数数量必须一致。且使用相同的 @Param ,或者使用 param1 这种 测试如下:\nPersonDao.java\nPerson queryById(); Person queryById(@Param(\u0026#34;id\u0026#34;) Long id); Person queryById(@Param(\u0026#34;id\u0026#34;) Long id, @Param(\u0026#34;name\u0026#34;) String name); PersonMapper.xml\n\u0026lt;select id=\u0026#34;queryById\u0026#34; resultMap=\u0026#34;PersonMap\u0026#34;\u0026gt; select id, name, age, address from person \u0026lt;where\u0026gt; \u0026lt;if test=\u0026#34;id != null\u0026#34;\u0026gt; id = #{id} \u0026lt;/if\u0026gt; \u0026lt;if test=\u0026#34;name != null and name != \u0026#39;\u0026#39;\u0026#34;\u0026gt; name = #{name} \u0026lt;/if\u0026gt; \u0026lt;/where\u0026gt; limit 1 \u0026lt;/select\u0026gt; org.apache.ibatis.scripting.xmltags. DynamicContext. ContextAccessor#getProperty 方法用于获取 \u0026lt;if\u0026gt; 标签中的条件值\npublic Object getProperty(Map context, Object target, Object name) { Map map = (Map) target; Object result = map.get(name); if (map.containsKey(name) || result != null) { return result; } Object parameterObject = map.get(PARAMETER_OBJECT_KEY); if (parameterObject instanceof Map) { return ((Map)parameterObject).get(name); } return null; } parameterObject 为 map,存放的是 Dao 接口中参数相关信息。\n((Map)parameterObject).get(name) 方法如下\npublic V get(Object key) { if (!super.containsKey(key)) { throw new BindingException(\u0026#34;Parameter \u0026#39;\u0026#34; + key + \u0026#34;\u0026#39; not found. Available parameters are \u0026#34; + keySet()); } return super.get(key); } queryById()方法执行时,parameterObject为 null,getProperty方法返回 null 值,\u0026lt;if\u0026gt;标签获取的所有条件值都为 null,所有条件不成立,动态 sql 可以正常执行。 queryById(1L)方法执行时,parameterObject为 map,包含了id和param1两个 key 值。当获取\u0026lt;if\u0026gt;标签中name的属性值时,进入((Map)parameterObject).get(name)方法中,map 中 key 不包含name,所以抛出异常。 queryById(1L,\u0026quot;1\u0026quot;)方法执行时,parameterObject中包含id,param1,name,param2四个 key 值,id和name属性都可以获取到,动态 sql 正常执行。 MyBatis 是如何进行分页的?分页插件的原理是什么? # 注:我出的。\n答:(1) MyBatis 使用 RowBounds 对象进行分页,它是针对 ResultSet 结果集执行的内存分页,而非物理分页;(2) 可以在 sql 内直接书写带有物理分页的参数来完成物理分页功能,(3) 也可以使用分页插件来完成物理分页。\n分页插件的基本原理是使用 MyBatis 提供的插件接口,实现自定义插件,在插件的拦截方法内拦截待执行的 sql,然后重写 sql,根据 dialect 方言,添加对应的物理分页语句和物理分页参数。\n举例:select _ from student ,拦截 sql 后重写为:select t._ from (select \\* from student)t limit 0,10\n简述 MyBatis 的插件运行原理,以及如何编写一个插件 # 注:我出的。\n答:MyBatis 仅可以编写针对 ParameterHandler、 ResultSetHandler、 StatementHandler、 Executor 这 4 种接口的插件,MyBatis 使用 JDK 的动态代理,为需要拦截的接口生成代理对象以实现接口方法拦截功能,每当执行这 4 种接口对象的方法时,就会进入拦截方法,具体就是 InvocationHandler 的 invoke() 方法,当然,只会拦截那些你指定需要拦截的方法。\n实现 MyBatis 的 Interceptor 接口并复写 intercept() 方法,然后在给插件编写注解,指定要拦截哪一个接口的哪些方法即可,记住,别忘了在配置文件中配置你编写的插件。\nMyBatis 执行批量插入,能返回数据库主键列表吗? # 注:我出的。\n答:能,JDBC 都能,MyBatis 当然也能。\nMyBatis 动态 sql 是做什么的?都有哪些动态 sql?能简述一下动态 sql 的执行原理不? # 注:我出的。\n答:MyBatis 动态 sql 可以让我们在 xml 映射文件内,以标签的形式编写动态 sql,完成逻辑判断和动态拼接 sql 的功能。其执行原理为,使用 OGNL 从 sql 参数对象中计算表达式的值,根据表达式的值动态拼接 sql,以此来完成动态 sql 的功能。\nMyBatis 提供了 9 种动态 sql 标签:\n\u0026lt;if\u0026gt;\u0026lt;/if\u0026gt; \u0026lt;where\u0026gt;\u0026lt;/where\u0026gt;(trim,set) \u0026lt;choose\u0026gt;\u0026lt;/choose\u0026gt;(when, otherwise) \u0026lt;foreach\u0026gt;\u0026lt;/foreach\u0026gt; \u0026lt;bind/\u0026gt; 关于 MyBatis 动态 SQL 的详细介绍,请看这篇文章: Mybatis 系列全解(八):Mybatis 的 9 大动态 SQL 标签你知道几个? 。\n关于这些动态 SQL 的具体使用方法,请看这篇文章: Mybatis【13】\u0026ndash; Mybatis 动态 sql 标签怎么使用?\nMyBatis 是如何将 sql 执行结果封装为目标对象并返回的?都有哪些映射形式? # 注:我出的。\n答:第一种是使用 \u0026lt;resultMap\u0026gt; 标签,逐一定义列名和对象属性名之间的映射关系。第二种是使用 sql 列的别名功能,将列别名书写为对象属性名,比如 T_NAME AS NAME,对象属性名一般是 name,小写,但是列名不区分大小写,MyBatis 会忽略列名大小写,智能找到与之对应对象属性名,你甚至可以写成 T_NAME AS NaMe,MyBatis 一样可以正常工作。\n有了列名与属性名的映射关系后,MyBatis 通过反射创建对象,同时使用反射给对象的属性逐一赋值并返回,那些找不到映射关系的属性,是无法完成赋值的。\nMyBatis 能执行一对一、一对多的关联查询吗?都有哪些实现方式,以及它们之间的区别 # 注:我出的。\n答:能,MyBatis 不仅可以执行一对一、一对多的关联查询,还可以执行多对一,多对多的关联查询,多对一查询,其实就是一对一查询,只需要把 selectOne() 修改为 selectList() 即可;多对多查询,其实就是一对多查询,只需要把 selectOne() 修改为 selectList() 即可。\n关联对象查询,有两种实现方式,一种是单独发送一个 sql 去查询关联对象,赋给主对象,然后返回主对象。另一种是使用嵌套查询,嵌套查询的含义为使用 join 查询,一部分列是 A 对象的属性值,另外一部分列是关联对象 B 的属性值,好处是只发一个 sql 查询,就可以把主对象和其关联对象查出来。\n那么问题来了,join 查询出来 100 条记录,如何确定主对象是 5 个,而不是 100 个?其去重复的原理是 \u0026lt;resultMap\u0026gt; 标签内的 \u0026lt;id\u0026gt; 子标签,指定了唯一确定一条记录的 id 列,MyBatis 根据 \u0026lt;id\u0026gt; 列值来完成 100 条记录的去重复功能, \u0026lt;id\u0026gt; 可以有多个,代表了联合主键的语意。\n同样主对象的关联对象,也是根据这个原理去重复的,尽管一般情况下,只有主对象会有重复记录,关联对象一般不会重复。\n举例:下面 join 查询出来 6 条记录,一、二列是 Teacher 对象列,第三列为 Student 对象列,MyBatis 去重复处理后,结果为 1 个老师 6 个学生,而不是 6 个老师 6 个学生。\nt_id t_name s_id 1 teacher 38 1 teacher 39 1 teacher 40 1 teacher 41 1 teacher 42 1 teacher 43 MyBatis 是否支持延迟加载?如果支持,它的实现原理是什么? # 注:我出的。\n答:MyBatis 仅支持 association 关联对象和 collection 关联集合对象的延迟加载,association 指的就是一对一,collection 指的就是一对多查询。在 MyBatis 配置文件中,可以配置是否启用延迟加载 lazyLoadingEnabled=true|false。\n它的原理是,使用 CGLIB 创建目标对象的代理对象,当调用目标方法时,进入拦截器方法,比如调用 a.getB().getName() ,拦截器 invoke() 方法发现 a.getB() 是 null 值,那么就会单独发送事先保存好的查询关联 B 对象的 sql,把 B 查询上来,然后调用 a.setB(b),于是 a 的对象 b 属性就有值了,接着完成 a.getB().getName() 方法的调用。这就是延迟加载的基本原理。\n当然了,不光是 MyBatis,几乎所有的包括 Hibernate,支持延迟加载的原理都是一样的。\nMyBatis 的 xml 映射文件中,不同的 xml 映射文件,id 是否可以重复? # 注:我出的。\n答:不同的 xml 映射文件,如果配置了 namespace,那么 id 可以重复;如果没有配置 namespace,那么 id 不能重复;毕竟 namespace 不是必须的,只是最佳实践而已。\n原因就是 namespace+id 是作为 Map\u0026lt;String, MappedStatement\u0026gt; 的 key 使用的,如果没有 namespace,就剩下 id,那么,id 重复会导致数据互相覆盖。有了 namespace,自然 id 就可以重复,namespace 不同,namespace+id 自然也就不同。\nMyBatis 中如何执行批处理? # 注:我出的。\n答:使用 BatchExecutor 完成批处理。\nMyBatis 都有哪些 Executor 执行器?它们之间的区别是什么? # 注:我出的\n答:MyBatis 有三种基本的 Executor 执行器:\nSimpleExecutor: 每执行一次 update 或 select,就开启一个 Statement 对象,用完立刻关闭 Statement 对象。 ReuseExecutor: 执行 update 或 select,以 sql 作为 key 查找 Statement 对象,存在就使用,不存在就创建,用完后,不关闭 Statement 对象,而是放置于 Map\u0026lt;String, Statement\u0026gt;内,供下一次使用。简言之,就是重复使用 Statement 对象。 BatchExecutor:执行 update(没有 select,JDBC 批处理不支持 select),将所有 sql 都添加到批处理中(addBatch()),等待统一执行(executeBatch()),它缓存了多个 Statement 对象,每个 Statement 对象都是 addBatch()完毕后,等待逐一执行 executeBatch()批处理。与 JDBC 批处理相同。 作用范围:Executor 的这些特点,都严格限制在 SqlSession 生命周期范围内。\nMyBatis 中如何指定使用哪一种 Executor 执行器? # 注:我出的\n答:在 MyBatis 配置文件中,可以指定默认的 ExecutorType 执行器类型,也可以手动给 DefaultSqlSessionFactory 的创建 SqlSession 的方法传递 ExecutorType 类型参数。\nMyBatis 是否可以映射 Enum 枚举类? # 注:我出的\n答:MyBatis 可以映射枚举类,不单可以映射枚举类,MyBatis 可以映射任何对象到表的一列上。映射方式为自定义一个 TypeHandler ,实现 TypeHandler 的 setParameter() 和 getResult() 接口方法。 TypeHandler 有两个作用:\n一是完成从 javaType 至 jdbcType 的转换; 二是完成 jdbcType 至 javaType 的转换,体现为 setParameter() 和 getResult() 两个方法,分别代表设置 sql 问号占位符参数和获取列查询结果。 MyBatis 映射文件中,如果 A 标签通过 include 引用了 B 标签的内容,请问,B 标签能否定义在 A 标签的后面,还是说必须定义在 A 标签的前面? # 注:我出的\n答:虽然 MyBatis 解析 xml 映射文件是按照顺序解析的,但是,被引用的 B 标签依然可以定义在任何地方,MyBatis 都可以正确识别。\n原理是,MyBatis 解析 A 标签,发现 A 标签引用了 B 标签,但是 B 标签尚未解析到,尚不存在,此时,MyBatis 会将 A 标签标记为未解析状态,然后继续解析余下的标签,包含 B 标签,待所有标签解析完毕,MyBatis 会重新解析那些被标记为未解析的标签,此时再解析 A 标签时,B 标签已经存在,A 标签也就可以正常解析完成了。\n简述 MyBatis 的 xml 映射文件和 MyBatis 内部数据结构之间的映射关系? # 注:我出的\n答:MyBatis 将所有 xml 配置信息都封装到 All-In-One 重量级对象 Configuration 内部。在 xml 映射文件中, \u0026lt;parameterMap\u0026gt; 标签会被解析为 ParameterMap 对象,其每个子元素会被解析为 ParameterMapping 对象。 \u0026lt;resultMap\u0026gt; 标签会被解析为 ResultMap 对象,其每个子元素会被解析为 ResultMapping 对象。每一个 \u0026lt;select\u0026gt;、\u0026lt;insert\u0026gt;、\u0026lt;update\u0026gt;、\u0026lt;delete\u0026gt; 标签均会被解析为 MappedStatement 对象,标签内的 sql 会被解析为 BoundSql 对象。\n为什么说 MyBatis 是半自动 ORM 映射工具?它与全自动的区别在哪里? # 注:我出的\n答:Hibernate 属于全自动 ORM 映射工具,使用 Hibernate 查询关联对象或者关联集合对象时,可以根据对象关系模型直接获取,所以它是全自动的。而 MyBatis 在查询关联对象或关联集合对象时,需要手动编写 sql 来完成,所以,称之为半自动 ORM 映射工具。\n面试题看似都很简单,但是想要能正确回答上来,必定是研究过源码且深入的人,而不是仅会使用的人或者用的很熟的人,以上所有面试题及其答案所涉及的内容,在我的 MyBatis 系列博客中都有详细讲解和原理分析。\n文章推荐 # 2W 字全面剖析 Mybatis 中的 9 种设计模式 从零开始实现一个 MyBatis 加解密插件 MyBatis 最全使用指南 脑洞打开!第一次看到这样使用 MyBatis 的,看得我一愣一愣的。 MyBatis 居然也有并发问题 "},{"id":538,"href":"/zh/docs/technology/Interview/database/mysql/mysql-query-cache/","title":"MySQL查询缓存详解","section":"Mysql","content":"缓存是一个有效且实用的系统性能优化的手段,不论是操作系统还是各种软件和网站或多或少都用到了缓存。\n然而,有经验的 DBA 都建议生产环境中把 MySQL 自带的 Query Cache(查询缓存)给关掉。而且,从 MySQL 5.7.20 开始,就已经默认弃用查询缓存了。在 MySQL 8.0 及之后,更是直接删除了查询缓存的功能。\n这又是为什么呢?查询缓存真就这么鸡肋么?\n带着如下几个问题,我们正式进入本文。\nMySQL 查询缓存是什么?适用范围? MySQL 缓存规则是什么? MySQL 缓存的优缺点是什么? MySQL 缓存对性能有什么影响? MySQL 查询缓存介绍 # MySQL 体系架构如下图所示:\n为了提高完全相同的查询语句的响应速度,MySQL Server 会对查询语句进行 Hash 计算得到一个 Hash 值。MySQL Server 不会对 SQL 做任何处理,SQL 必须完全一致 Hash 值才会一样。得到 Hash 值之后,通过该 Hash 值到查询缓存中匹配该查询的结果。\n如果匹配(命中),则将查询的结果集直接返回给客户端,不必再解析、执行查询。 如果没有匹配(未命中),则将 Hash 值和结果集保存在查询缓存中,以便以后使用。 也就是说,一个查询语句(select)到了 MySQL Server 之后,会先到查询缓存看看,如果曾经执行过的话,就直接返回结果集给客户端。\nMySQL 查询缓存管理和配置 # 通过 show variables like '%query_cache%'命令可以查看查询缓存相关的信息。\n8.0 版本之前的话,打印的信息可能是下面这样的:\nmysql\u0026gt; show variables like \u0026#39;%query_cache%\u0026#39;; +------------------------------+---------+ | Variable_name | Value | +------------------------------+---------+ | have_query_cache | YES | | query_cache_limit | 1048576 | | query_cache_min_res_unit | 4096 | | query_cache_size | 599040 | | query_cache_type | ON | | query_cache_wlock_invalidate | OFF | +------------------------------+---------+ 6 rows in set (0.02 sec) 8.0 以及之后版本之后,打印的信息是下面这样的:\nmysql\u0026gt; show variables like \u0026#39;%query_cache%\u0026#39;; +------------------+-------+ | Variable_name | Value | +------------------+-------+ | have_query_cache | NO | +------------------+-------+ 1 row in set (0.01 sec) 我们这里对 8.0 版本之前show variables like '%query_cache%';命令打印出来的信息进行解释。\nhave_query_cache: 该 MySQL Server 是否支持查询缓存,如果是 YES 表示支持,否则则是不支持。 query_cache_limit: MySQL 查询缓存的最大查询结果,查询结果大于该值时不会被缓存。 query_cache_min_res_unit: 查询缓存分配的最小块的大小(字节)。当查询进行的时候,MySQL 把查询结果保存在查询缓存中,但如果要保存的结果比较大,超过 query_cache_min_res_unit 的值 ,这时候 MySQL 将一边检索结果,一边进行保存结果,也就是说,有可能在一次查询中,MySQL 要进行多次内存分配的操作。适当的调节 query_cache_min_res_unit 可以优化内存。 query_cache_size: 为缓存查询结果分配的内存的数量,单位是字节,且数值必须是 1024 的整数倍。默认值是 0,即禁用查询缓存。 query_cache_type: 设置查询缓存类型,默认为 ON。设置 GLOBAL 值可以设置后面的所有客户端连接的类型。客户端可以设置 SESSION 值以影响他们自己对查询缓存的使用。 query_cache_wlock_invalidate:如果某个表被锁住,是否返回缓存中的数据,默认关闭,也是建议的。 query_cache_type 可能的值(修改 query_cache_type 需要重启 MySQL Server):\n0 或 OFF:关闭查询功能。 1 或 ON:开启查询缓存功能,但不缓存 Select SQL_NO_CACHE 开头的查询。 2 或 DEMAND:开启查询缓存功能,但仅缓存 Select SQL_CACHE 开头的查询。 建议:\nquery_cache_size不建议设置的过大。过大的空间不但挤占实例其他内存结构的空间,而且会增加在缓存中搜索的开销。建议根据实例规格,初始值设置为 10MB 到 100MB 之间的值,而后根据运行使用情况调整。\n建议通过调整 query_cache_size 的值来开启、关闭查询缓存,因为修改query_cache_type 参数需要重启 MySQL Server 生效。\n8.0 版本之前,my.cnf 加入以下配置,重启 MySQL 开启查询缓存\nquery_cache_type=1 query_cache_size=600000 或者,MySQL 执行以下命令也可以开启查询缓存\nset global query_cache_type=1; set global query_cache_size=600000; 手动清理缓存可以使用下面三个 SQL:\nflush query cache;:清理查询缓存内存碎片。 reset query cache;:从查询缓存中移除所有查询。 flush tables; 关闭所有打开的表,同时该操作会清空查询缓存中的内容。 MySQL 缓存机制 # 缓存规则 # 查询缓存会将查询语句和结果集保存到内存(一般是 key-value 的形式,key 是查询语句,value 是查询的结果集),下次再查直接从内存中取。 缓存的结果是通过 sessions 共享的,所以一个 client 查询的缓存结果,另一个 client 也可以使用。 SQL 必须完全一致才会导致查询缓存命中(大小写、空格、使用的数据库、协议版本、字符集等必须一致)。检查查询缓存时,MySQL Server 不会对 SQL 做任何处理,它精确的使用客户端传来的查询。 不缓存查询中的子查询结果集,仅缓存查询最终结果集。 不确定的函数将永远不会被缓存, 比如 now()、curdate()、last_insert_id()、rand() 等。 不缓存产生告警(Warnings)的查询。 太大的结果集不会被缓存 (\u0026lt; query_cache_limit)。 如果查询中包含任何用户自定义函数、存储函数、用户变量、临时表、MySQL 库中的系统表,其查询结果也不会被缓存。 缓存建立之后,MySQL 的查询缓存系统会跟踪查询中涉及的每张表,如果这些表(数据或结构)发生变化,那么和这张表相关的所有缓存数据都将失效。 MySQL 缓存在分库分表环境下是不起作用的。 不缓存使用 SQL_NO_CACHE 的查询。 …… 查询缓存 SELECT 选项示例:\nSELECT SQL_CACHE id, name FROM customer;# 会缓存 SELECT SQL_NO_CACHE id, name FROM customer;# 不会缓存 缓存机制中的内存管理 # 查询缓存是完全存储在内存中的,所以在配置和使用它之前,我们需要先了解它是如何使用内存的。\nMySQL 查询缓存使用内存池技术,自己管理内存释放和分配,而不是通过操作系统。内存池使用的基本单位是变长的 block, 用来存储类型、大小、数据等信息。一个结果集的缓存通过链表把这些 block 串起来。block 最短长度为 query_cache_min_res_unit。\n当服务器启动的时候,会初始化缓存需要的内存,是一个完整的空闲块。当查询结果需要缓存的时候,先从空闲块中申请一个数据块为参数 query_cache_min_res_unit 配置的空间,即使缓存数据很小,申请数据块也是这个,因为查询开始返回结果的时候就分配空间,此时无法预知结果多大。\n分配内存块需要先锁住空间块,所以操作很慢,MySQL 会尽量避免这个操作,选择尽可能小的内存块,如果不够,继续申请,如果存储完时有空余则释放多余的。\n但是如果并发的操作,余下的需要回收的空间很小,小于 query_cache_min_res_unit,不能再次被使用,就会产生碎片。\nMySQL 查询缓存的优缺点 # 优点:\n查询缓存的查询,发生在 MySQL 接收到客户端的查询请求、查询权限验证之后和查询 SQL 解析之前。也就是说,当 MySQL 接收到客户端的查询 SQL 之后,仅仅只需要对其进行相应的权限验证之后,就会通过查询缓存来查找结果,甚至都不需要经过 Optimizer 模块进行执行计划的分析优化,更不需要发生任何存储引擎的交互。 由于查询缓存是基于内存的,直接从内存中返回相应的查询结果,因此减少了大量的磁盘 I/O 和 CPU 计算,导致效率非常高。 缺点:\nMySQL 会对每条接收到的 SELECT 类型的查询进行 Hash 计算,然后查找这个查询的缓存结果是否存在。虽然 Hash 计算和查找的效率已经足够高了,一条查询语句所带来的开销可以忽略,但一旦涉及到高并发,有成千上万条查询语句时,hash 计算和查找所带来的开销就必须重视了。 查询缓存的失效问题。如果表的变更比较频繁,则会造成查询缓存的失效率非常高。表的变更不仅仅指表中的数据发生变化,还包括表结构或者索引的任何变化。 查询语句不同,但查询结果相同的查询都会被缓存,这样便会造成内存资源的过度消耗。查询语句的字符大小写、空格或者注释的不同,查询缓存都会认为是不同的查询(因为他们的 Hash 值会不同)。 相关系统变量设置不合理会造成大量的内存碎片,这样便会导致查询缓存频繁清理内存。 MySQL 查询缓存对性能的影响 # 在 MySQL Server 中打开查询缓存对数据库的读和写都会带来额外的消耗:\n读查询开始之前必须检查是否命中缓存。 如果读查询可以缓存,那么执行完查询操作后,会查询结果和查询语句写入缓存。 当向某个表写入数据的时候,必须将这个表所有的缓存设置为失效,如果缓存空间很大,则消耗也会很大,可能使系统僵死一段时间,因为这个操作是靠全局锁操作来保护的。 对 InnoDB 表,当修改一个表时,设置了缓存失效,但是多版本特性会暂时将这修改对其他事务屏蔽,在这个事务提交之前,所有查询都无法使用缓存,直到这个事务被提交,所以长时间的事务,会大大降低查询缓存的命中。 总结 # MySQL 中的查询缓存虽然能够提升数据库的查询性能,但是查询同时也带来了额外的开销,每次查询后都要做一次缓存操作,失效后还要销毁。\n查询缓存是一个适用较少情况的缓存机制。如果你的应用对数据库的更新很少,那么查询缓存将会作用显著。比较典型的如博客系统,一般博客更新相对较慢,数据表相对稳定不变,这时候查询缓存的作用会比较明显。\n简单总结一下查询缓存的适用场景:\n表数据修改不频繁、数据较静态。 查询(Select)重复度高。 查询结果集小于 1 MB。 对于一个更新频繁的系统来说,查询缓存缓存的作用是很微小的,在某些情况下开启查询缓存会带来性能的下降。\n简单总结一下查询缓存不适用的场景:\n表中的数据、表结构或者索引变动频繁 重复的查询很少 查询的结果集很大 《高性能 MySQL》这样写到:\n根据我们的经验,在高并发压力环境中查询缓存会导致系统性能的下降,甚至僵死。如果你一 定要使用查询缓存,那么不要设置太大内存,而且只有在明确收益的时候才使用(数据库内容修改次数较少)。\n确实是这样的!实际项目中,更建议使用本地缓存(比如 Caffeine)或者分布式缓存(比如 Redis) ,性能更好,更通用一些。\n参考 # 《高性能 MySQL》 MySQL 缓存机制: https://zhuanlan.zhihu.com/p/55947158 RDS MySQL 查询缓存(Query Cache)的设置和使用 - 阿里元云数据库 RDS 文档: https://help.aliyun.com/document_detail/41717.html 8.10.3 The MySQL Query Cache - MySQL 官方文档: https://dev.mysql.com/doc/refman/5.7/en/query-cache.html "},{"id":539,"href":"/zh/docs/technology/Interview/database/mysql/mysql-questions-01/","title":"MySQL常见面试题总结","section":"Mysql","content":" MySQL 基础 # 什么是关系型数据库? # 顾名思义,关系型数据库(RDB,Relational Database)就是一种建立在关系模型的基础上的数据库。关系模型表明了数据库中所存储的数据之间的联系(一对一、一对多、多对多)。\n关系型数据库中,我们的数据都被存放在了各种表中(比如用户表),表中的每一行就存放着一条数据(比如一个用户的信息)。\n大部分关系型数据库都使用 SQL 来操作数据库中的数据。并且,大部分关系型数据库都支持事务的四大特性(ACID)。\n有哪些常见的关系型数据库呢?\nMySQL、PostgreSQL、Oracle、SQL Server、SQLite(微信本地的聊天记录的存储就是用的 SQLite) ……。\n什么是 SQL? # SQL 是一种结构化查询语言(Structured Query Language),专门用来与数据库打交道,目的是提供一种从数据库中读写数据的简单有效的方法。\n几乎所有的主流关系数据库都支持 SQL ,适用性非常强。并且,一些非关系型数据库也兼容 SQL 或者使用的是类似于 SQL 的查询语言。\nSQL 可以帮助我们:\n新建数据库、数据表、字段; 在数据库中增加,删除,修改,查询数据; 新建视图、函数、存储过程; 对数据库中的数据进行简单的数据分析; 搭配 Hive,Spark SQL 做大数据; 搭配 SQLFlow 做机器学习; …… 什么是 MySQL? # MySQL 是一种关系型数据库,主要用于持久化存储我们的系统中的一些数据比如用户信息。\n由于 MySQL 是开源免费并且比较成熟的数据库,因此,MySQL 被大量使用在各种系统中。任何人都可以在 GPL(General Public License) 的许可下下载并根据个性化的需要对其进行修改。MySQL 的默认端口号是3306。\nMySQL 有什么优点? # 这个问题本质上是在问 MySQL 如此流行的原因。\nMySQL 主要具有下面这些优点:\n成熟稳定,功能完善。 开源免费。 文档丰富,既有详细的官方文档,又有非常多优质文章可供参考学习。 开箱即用,操作简单,维护成本低。 兼容性好,支持常见的操作系统,支持多种开发语言。 社区活跃,生态完善。 事务支持优秀, InnoDB 存储引擎默认使用 REPEATABLE-READ 并不会有任何性能损失,并且,InnoDB 实现的 REPEATABLE-READ 隔离级别其实是可以解决幻读问题发生的。 支持分库分表、读写分离、高可用。 MySQL 字段类型 # MySQL 字段类型可以简单分为三大类:\n数值类型:整型(TINYINT、SMALLINT、MEDIUMINT、INT 和 BIGINT)、浮点型(FLOAT 和 DOUBLE)、定点型(DECIMAL) 字符串类型:CHAR、VARCHAR、TINYTEXT、TEXT、MEDIUMTEXT、LONGTEXT、TINYBLOB、BLOB、MEDIUMBLOB 和 LONGBLOB 等,最常用的是 CHAR 和 VARCHAR。 日期时间类型:YEAR、TIME、DATE、DATETIME 和 TIMESTAMP 等。 下面这张图不是我画的,忘记是从哪里保存下来的了,总结的还蛮不错的。\nMySQL 字段类型比较多,我这里会挑选一些日常开发使用很频繁且面试常问的字段类型,以面试问题的形式来详细介绍。如无特殊说明,针对的都是 InnoDB 存储引擎。\n另外,推荐阅读一下《高性能 MySQL(第三版)》的第四章,有详细介绍 MySQL 字段类型优化。\n整数类型的 UNSIGNED 属性有什么用? # MySQL 中的整数类型可以使用可选的 UNSIGNED 属性来表示不允许负值的无符号整数。使用 UNSIGNED 属性可以将正整数的上限提高一倍,因为它不需要存储负数值。\n例如, TINYINT UNSIGNED 类型的取值范围是 0 ~ 255,而普通的 TINYINT 类型的值范围是 -128 ~ 127。INT UNSIGNED 类型的取值范围是 0 ~ 4,294,967,295,而普通的 INT 类型的值范围是 -2,147,483,648 ~ 2,147,483,647。\n对于从 0 开始递增的 ID 列,使用 UNSIGNED 属性可以非常适合,因为不允许负值并且可以拥有更大的上限范围,提供了更多的 ID 值可用。\nCHAR 和 VARCHAR 的区别是什么? # CHAR 和 VARCHAR 是最常用到的字符串类型,两者的主要区别在于:CHAR 是定长字符串,VARCHAR 是变长字符串。\nCHAR 在存储时会在右边填充空格以达到指定的长度,检索时会去掉空格;VARCHAR 在存储时需要使用 1 或 2 个额外字节记录字符串的长度,检索时不需要处理。\nCHAR 更适合存储长度较短或者长度都差不多的字符串,例如 Bcrypt 算法、MD5 算法加密后的密码、身份证号码。VARCHAR 类型适合存储长度不确定或者差异较大的字符串,例如用户昵称、文章标题等。\nCHAR(M) 和 VARCHAR(M) 的 M 都代表能够保存的字符数的最大值,无论是字母、数字还是中文,每个都只占用一个字符。\nVARCHAR(100)和 VARCHAR(10)的区别是什么? # VARCHAR(100)和 VARCHAR(10)都是变长类型,表示能存储最多 100 个字符和 10 个字符。因此,VARCHAR (100) 可以满足更大范围的字符存储需求,有更好的业务拓展性。而 VARCHAR(10)存储超过 10 个字符时,就需要修改表结构才可以。\n虽说 VARCHAR(100)和 VARCHAR(10)能存储的字符范围不同,但二者存储相同的字符串,所占用磁盘的存储空间其实是一样的,这也是很多人容易误解的一点。\n不过,VARCHAR(100) 会消耗更多的内存。这是因为 VARCHAR 类型在内存中操作时,通常会分配固定大小的内存块来保存值,即使用字符类型中定义的长度。例如在进行排序的时候,VARCHAR(100)是按照 100 这个长度来进行的,也就会消耗更多内存。\nDECIMAL 和 FLOAT/DOUBLE 的区别是什么? # DECIMAL 和 FLOAT 的区别是:DECIMAL 是定点数,FLOAT/DOUBLE 是浮点数。DECIMAL 可以存储精确的小数值,FLOAT/DOUBLE 只能存储近似的小数值。\nDECIMAL 用于存储具有精度要求的小数,例如与货币相关的数据,可以避免浮点数带来的精度损失。\n在 Java 中,MySQL 的 DECIMAL 类型对应的是 Java 类 java.math.BigDecimal。\n为什么不推荐使用 TEXT 和 BLOB? # TEXT 类型类似于 CHAR(0-255 字节)和 VARCHAR(0-65,535 字节),但可以存储更长的字符串,即长文本数据,例如博客内容。\n类型 可存储大小 用途 TINYTEXT 0-255 字节 一般文本字符串 TEXT 0-65,535 字节 长文本字符串 MEDIUMTEXT 0-16,772,150 字节 较大文本数据 LONGTEXT 0-4,294,967,295 字节 极大文本数据 BLOB 类型主要用于存储二进制大对象,例如图片、音视频等文件。\n类型 可存储大小 用途 TINYBLOB 0-255 字节 短文本二进制字符串 BLOB 0-65KB 二进制字符串 MEDIUMBLOB 0-16MB 二进制形式的长文本数据 LONGBLOB 0-4GB 二进制形式的极大文本数据 在日常开发中,很少使用 TEXT 类型,但偶尔会用到,而 BLOB 类型则基本不常用。如果预期长度范围可以通过 VARCHAR 来满足,建议避免使用 TEXT。\n数据库规范通常不推荐使用 BLOB 和 TEXT 类型,这两种类型具有一些缺点和限制,例如:\n不能有默认值。 在使用临时表时无法使用内存临时表,只能在磁盘上创建临时表(《高性能 MySQL》书中有提到)。 检索效率较低。 不能直接创建索引,需要指定前缀长度。 可能会消耗大量的网络和 IO 带宽。 可能导致表上的 DML 操作变慢。 …… DATETIME 和 TIMESTAMP 的区别是什么? # DATETIME 类型没有时区信息,TIMESTAMP 和时区有关。\nTIMESTAMP 只需要使用 4 个字节的存储空间,但是 DATETIME 需要耗费 8 个字节的存储空间。但是,这样同样造成了一个问题,Timestamp 表示的时间范围更小。\nDATETIME:1000-01-01 00:00:00 ~ 9999-12-31 23:59:59 Timestamp:1970-01-01 00:00:01 ~ 2037-12-31 23:59:59 关于两者的详细对比,请参考我写的 MySQL 时间类型数据存储建议。\nNULL 和 \u0026rsquo;\u0026rsquo; 的区别是什么? # NULL 跟 ''(空字符串)是两个完全不一样的值,区别如下:\nNULL 代表一个不确定的值,就算是两个 NULL,它俩也不一定相等。例如,SELECT NULL=NULL的结果为 false,但是在我们使用DISTINCT,GROUP BY,ORDER BY时,NULL又被认为是相等的。 ''的长度是 0,是不占用空间的,而NULL 是需要占用空间的。 NULL 会影响聚合函数的结果。例如,SUM、AVG、MIN、MAX 等聚合函数会忽略 NULL 值。 COUNT 的处理方式取决于参数的类型。如果参数是 *(COUNT(*)),则会统计所有的记录数,包括 NULL 值;如果参数是某个字段名(COUNT(列名)),则会忽略 NULL 值,只统计非空值的个数。 查询 NULL 值时,必须使用 IS NULL 或 IS NOT NULLl 来判断,而不能使用 =、!=、 \u0026lt;、\u0026gt; 之类的比较运算符。而''是可以使用这些比较运算符的。 看了上面的介绍之后,相信你对另外一个高频面试题:“为什么 MySQL 不建议使用 NULL 作为列默认值?”也有了答案。\nBoolean 类型如何表示? # MySQL 中没有专门的布尔类型,而是用 TINYINT(1) 类型来表示布尔值。TINYINT(1) 类型可以存储 0 或 1,分别对应 false 或 true。\nMySQL 基础架构 # 建议配合 SQL 语句在 MySQL 中的执行过程 这篇文章来理解 MySQL 基础架构。另外,“一个 SQL 语句在 MySQL 中的执行流程”也是面试中比较常问的一个问题。\n下图是 MySQL 的一个简要架构图,从下图你可以很清晰的看到客户端的一条 SQL 语句在 MySQL 内部是如何执行的。\n从上图可以看出, MySQL 主要由下面几部分构成:\n连接器: 身份认证和权限相关(登录 MySQL 的时候)。 查询缓存: 执行查询语句的时候,会先查询缓存(MySQL 8.0 版本后移除,因为这个功能不太实用)。 分析器: 没有命中缓存的话,SQL 语句就会经过分析器,分析器说白了就是要先看你的 SQL 语句要干嘛,再检查你的 SQL 语句语法是否正确。 优化器: 按照 MySQL 认为最优的方案去执行。 执行器: 执行语句,然后从存储引擎返回数据。 执行语句之前会先判断是否有权限,如果没有权限的话,就会报错。 插件式存储引擎:主要负责数据的存储和读取,采用的是插件式架构,支持 InnoDB、MyISAM、Memory 等多种存储引擎。InnoDB 是 MySQL 的默认存储引擎,绝大部分场景使用 InnoDB 就是最好的选择。 MySQL 存储引擎 # MySQL 核心在于存储引擎,想要深入学习 MySQL,必定要深入研究 MySQL 存储引擎。\nMySQL 支持哪些存储引擎?默认使用哪个? # MySQL 支持多种存储引擎,你可以通过 SHOW ENGINES 命令来查看 MySQL 支持的所有存储引擎。\n从上图我们可以查看出, MySQL 当前默认的存储引擎是 InnoDB。并且,所有的存储引擎中只有 InnoDB 是事务性存储引擎,也就是说只有 InnoDB 支持事务。\n我这里使用的 MySQL 版本是 8.x,不同的 MySQL 版本之间可能会有差别。\nMySQL 5.5.5 之前,MyISAM 是 MySQL 的默认存储引擎。5.5.5 版本之后,InnoDB 是 MySQL 的默认存储引擎。\n你可以通过 SELECT VERSION() 命令查看你的 MySQL 版本。\nmysql\u0026gt; SELECT VERSION(); +-----------+ | VERSION() | +-----------+ | 8.0.27 | +-----------+ 1 row in set (0.00 sec) 你也可以通过 SHOW VARIABLES LIKE '%storage_engine%' 命令直接查看 MySQL 当前默认的存储引擎。\nmysql\u0026gt; SHOW VARIABLES LIKE \u0026#39;%storage_engine%\u0026#39;; +---------------------------------+-----------+ | Variable_name | Value | +---------------------------------+-----------+ | default_storage_engine | InnoDB | | default_tmp_storage_engine | InnoDB | | disabled_storage_engines | | | internal_tmp_mem_storage_engine | TempTable | +---------------------------------+-----------+ 4 rows in set (0.00 sec) 如果你想要深入了解每个存储引擎以及它们之间的区别,推荐你去阅读以下 MySQL 官方文档对应的介绍(面试不会问这么细,了解即可):\nInnoDB 存储引擎详细介绍: https://dev.mysql.com/doc/refman/8.0/en/innodb-storage-engine.html 。 其他存储引擎详细介绍: https://dev.mysql.com/doc/refman/8.0/en/storage-engines.html 。 MySQL 存储引擎架构了解吗? # MySQL 存储引擎采用的是 插件式架构 ,支持多种存储引擎,我们甚至可以为不同的数据库表设置不同的存储引擎以适应不同场景的需要。存储引擎是基于表的,而不是数据库。\n下图展示了具有可插拔存储引擎的 MySQL 架构():\n你还可以根据 MySQL 定义的存储引擎实现标准接口来编写一个属于自己的存储引擎。这些非官方提供的存储引擎可以称为第三方存储引擎,区别于官方存储引擎。像目前最常用的 InnoDB 其实刚开始就是一个第三方存储引擎,后面由于过于优秀,其被 Oracle 直接收购了。\nMySQL 官方文档也有介绍到如何编写一个自定义存储引擎,地址: https://dev.mysql.com/doc/internals/en/custom-engine.html 。\nMyISAM 和 InnoDB 有什么区别? # MySQL 5.5 之前,MyISAM 引擎是 MySQL 的默认存储引擎,可谓是风光一时。\n虽然,MyISAM 的性能还行,各种特性也还不错(比如全文索引、压缩、空间函数等)。但是,MyISAM 不支持事务和行级锁,而且最大的缺陷就是崩溃后无法安全恢复。\nMySQL 5.5 版本之后,InnoDB 是 MySQL 的默认存储引擎。\n言归正传!咱们下面还是来简单对比一下两者:\n1、是否支持行级锁\nMyISAM 只有表级锁(table-level locking),而 InnoDB 支持行级锁(row-level locking)和表级锁,默认为行级锁。\n也就说,MyISAM 一锁就是锁住了整张表,这在并发写的情况下是多么滴憨憨啊!这也是为什么 InnoDB 在并发写的时候,性能更牛皮了!\n2、是否支持事务\nMyISAM 不提供事务支持。\nInnoDB 提供事务支持,实现了 SQL 标准定义了四个隔离级别,具有提交(commit)和回滚(rollback)事务的能力。并且,InnoDB 默认使用的 REPEATABLE-READ(可重读)隔离级别是可以解决幻读问题发生的(基于 MVCC 和 Next-Key Lock)。\n关于 MySQL 事务的详细介绍,可以看看我写的这篇文章: MySQL 事务隔离级别详解。\n3、是否支持外键\nMyISAM 不支持,而 InnoDB 支持。\n外键对于维护数据一致性非常有帮助,但是对性能有一定的损耗。因此,通常情况下,我们是不建议在实际生产项目中使用外键的,在业务代码中进行约束即可!\n阿里的《Java 开发手册》也是明确规定禁止使用外键的。\n不过,在代码中进行约束的话,对程序员的能力要求更高,具体是否要采用外键还是要根据你的项目实际情况而定。\n总结:一般我们也是不建议在数据库层面使用外键的,应用层面可以解决。不过,这样会对数据的一致性造成威胁。具体要不要使用外键还是要根据你的项目来决定。\n4、是否支持数据库异常崩溃后的安全恢复\nMyISAM 不支持,而 InnoDB 支持。\n使用 InnoDB 的数据库在异常崩溃后,数据库重新启动的时候会保证数据库恢复到崩溃前的状态。这个恢复的过程依赖于 redo log 。\n5、是否支持 MVCC\nMyISAM 不支持,而 InnoDB 支持。\n讲真,这个对比有点废话,毕竟 MyISAM 连行级锁都不支持。MVCC 可以看作是行级锁的一个升级,可以有效减少加锁操作,提高性能。\n6、索引实现不一样。\n虽然 MyISAM 引擎和 InnoDB 引擎都是使用 B+Tree 作为索引结构,但是两者的实现方式不太一样。\nInnoDB 引擎中,其数据文件本身就是索引文件。相比 MyISAM,索引文件和数据文件是分离的,其表数据文件本身就是按 B+Tree 组织的一个索引结构,树的叶节点 data 域保存了完整的数据记录。\n详细区别,推荐你看看我写的这篇文章: MySQL 索引详解。\n7、性能有差别。\nInnoDB 的性能比 MyISAM 更强大,不管是在读写混合模式下还是只读模式下,随着 CPU 核数的增加,InnoDB 的读写能力呈线性增长。MyISAM 因为读写不能并发,它的处理能力跟核数没关系。\n8、数据缓存策略和机制实现不同。\nInnoDB 使用缓冲池(Buffer Pool)缓存数据页和索引页,MyISAM 使用键缓存(Key Cache)仅缓存索引页而不缓存数据页。\n总结:\nInnoDB 支持行级别的锁粒度,MyISAM 不支持,只支持表级别的锁粒度。 MyISAM 不提供事务支持。InnoDB 提供事务支持,实现了 SQL 标准定义了四个隔离级别。 MyISAM 不支持外键,而 InnoDB 支持。 MyISAM 不支持 MVCC,而 InnoDB 支持。 虽然 MyISAM 引擎和 InnoDB 引擎都是使用 B+Tree 作为索引结构,但是两者的实现方式不太一样。 MyISAM 不支持数据库异常崩溃后的安全恢复,而 InnoDB 支持。 InnoDB 的性能比 MyISAM 更强大。 最后,再分享一张图片给你,这张图片详细对比了常见的几种 MySQL 存储引擎。\nMyISAM 和 InnoDB 如何选择? # 大多数时候我们使用的都是 InnoDB 存储引擎,在某些读密集的情况下,使用 MyISAM 也是合适的。不过,前提是你的项目不介意 MyISAM 不支持事务、崩溃恢复等缺点(可是~我们一般都会介意啊)。\n《MySQL 高性能》上面有一句话这样写到:\n不要轻易相信“MyISAM 比 InnoDB 快”之类的经验之谈,这个结论往往不是绝对的。在很多我们已知场景中,InnoDB 的速度都可以让 MyISAM 望尘莫及,尤其是用到了聚簇索引,或者需要访问的数据都可以放入内存的应用。\n因此,对于咱们日常开发的业务系统来说,你几乎找不到什么理由使用 MyISAM 了,老老实实用默认的 InnoDB 就可以了!\nMySQL 索引 # MySQL 索引相关的问题比较多,对于面试和工作都比较重要,于是,我单独抽了一篇文章专门来总结 MySQL 索引相关的知识点和问题: MySQL 索引详解 。\nMySQL 查询缓存 # MySQL 查询缓存是查询结果缓存。执行查询语句的时候,会先查询缓存,如果缓存中有对应的查询结果,就会直接返回。\nmy.cnf 加入以下配置,重启 MySQL 开启查询缓存\nquery_cache_type=1 query_cache_size=600000 MySQL 执行以下命令也可以开启查询缓存\nset global query_cache_type=1; set global query_cache_size=600000; 查询缓存会在同样的查询条件和数据情况下,直接返回缓存中的结果。但需要注意的是,查询缓存的匹配条件非常严格,任何细微的差异都会导致缓存无法命中。这里的查询条件包括查询语句本身、当前使用的数据库、以及其他可能影响结果的因素,如客户端协议版本号等。\n查询缓存不命中的情况:\n任何两个查询在任何字符上的不同都会导致缓存不命中。 如果查询中包含任何用户自定义函数、存储函数、用户变量、临时表、MySQL 库中的系统表,其查询结果也不会被缓存。 缓存建立之后,MySQL 的查询缓存系统会跟踪查询中涉及的每张表,如果这些表(数据或结构)发生变化,那么和这张表相关的所有缓存数据都将失效。 缓存虽然能够提升数据库的查询性能,但是缓存同时也带来了额外的开销,每次查询后都要做一次缓存操作,失效后还要销毁。 因此,开启查询缓存要谨慎,尤其对于写密集的应用来说更是如此。如果开启,要注意合理控制缓存空间大小,一般来说其大小设置为几十 MB 比较合适。此外,还可以通过 sql_cache 和 sql_no_cache 来控制某个查询语句是否需要缓存:\nSELECT sql_no_cache COUNT(*) FROM usr; MySQL 5.6 开始,查询缓存已默认禁用。MySQL 8.0 开始,已经不再支持查询缓存了(具体可以参考这篇文章: MySQL 8.0: Retiring Support for the Query Cache)。\nMySQL 日志 # MySQL 日志常见的面试题有:\nMySQL 中常见的日志有哪些? 慢查询日志有什么用? binlog 主要记录了什么? redo log 如何保证事务的持久性? 页修改之后为什么不直接刷盘呢? binlog 和 redolog 有什么区别? undo log 如何保证事务的原子性? …… 上诉问题的答案可以在 《Java 面试指北》(付费) 的 「技术面试题篇」 中找到。\nMySQL 事务 # 何谓事务? # 我们设想一个场景,这个场景中我们需要插入多条相关联的数据到数据库,不幸的是,这个过程可能会遇到下面这些问题:\n数据库中途突然因为某些原因挂掉了。 客户端突然因为网络原因连接不上数据库了。 并发访问数据库时,多个线程同时写入数据库,覆盖了彼此的更改。 …… 上面的任何一个问题都可能会导致数据的不一致性。为了保证数据的一致性,系统必须能够处理这些问题。事务就是我们抽象出来简化这些问题的首选机制。事务的概念起源于数据库,目前,已经成为一个比较广泛的概念。\n何为事务? 一言蔽之,事务是逻辑上的一组操作,要么都执行,要么都不执行。\n事务最经典也经常被拿出来说例子就是转账了。假如小明要给小红转账 1000 元,这个转账会涉及到两个关键操作,这两个操作必须都成功或者都失败。\n将小明的余额减少 1000 元 将小红的余额增加 1000 元。 事务会把这两个操作就可以看成逻辑上的一个整体,这个整体包含的操作要么都成功,要么都要失败。这样就不会出现小明余额减少而小红的余额却并没有增加的情况。\n何谓数据库事务? # 大多数情况下,我们在谈论事务的时候,如果没有特指分布式事务,往往指的就是数据库事务。\n数据库事务在我们日常开发中接触的最多了。如果你的项目属于单体架构的话,你接触到的往往就是数据库事务了。\n那数据库事务有什么作用呢?\n简单来说,数据库事务可以保证多个对数据库的操作(也就是 SQL 语句)构成一个逻辑上的整体。构成这个逻辑上的整体的这些数据库操作遵循:要么全部执行成功,要么全部不执行 。\n# 开启一个事务 START TRANSACTION; # 多条 SQL 语句 SQL1,SQL2... ## 提交事务 COMMIT; 另外,关系型数据库(例如:MySQL、SQL Server、Oracle 等)事务都有 ACID 特性:\n原子性(Atomicity):事务是最小的执行单位,不允许分割。事务的原子性确保动作要么全部完成,要么完全不起作用; 一致性(Consistency):执行事务前后,数据保持一致,例如转账业务中,无论事务是否成功,转账者和收款人的总额应该是不变的; 隔离性(Isolation):并发访问数据库时,一个用户的事务不被其他事务所干扰,各并发事务之间数据库是独立的; 持久性(Durability):一个事务被提交之后。它对数据库中数据的改变是持久的,即使数据库发生故障也不应该对其有任何影响。 🌈 这里要额外补充一点:只有保证了事务的持久性、原子性、隔离性之后,一致性才能得到保障。也就是说 A、I、D 是手段,C 是目的! 想必大家也和我一样,被 ACID 这个概念被误导了很久! 我也是看周志明老师的公开课 《周志明的软件架构课》才搞清楚的(多看好书!!!)。\n另外,DDIA 也就是 《Designing Data-Intensive Application(数据密集型应用系统设计)》 的作者在他的这本书中如是说:\nAtomicity, isolation, and durability are properties of the database, whereas consis‐ tency (in the ACID sense) is a property of the application. The application may rely on the database’s atomicity and isolation properties in order to achieve consistency, but it’s not up to the database alone.\n翻译过来的意思是:原子性,隔离性和持久性是数据库的属性,而一致性(在 ACID 意义上)是应用程序的属性。应用可能依赖数据库的原子性和隔离属性来实现一致性,但这并不仅取决于数据库。因此,字母 C 不属于 ACID 。\n《Designing Data-Intensive Application(数据密集型应用系统设计)》这本书强推一波,值得读很多遍!豆瓣有接近 90% 的人看了这本书之后给了五星好评。另外,中文翻译版本已经在 GitHub 开源,地址: https://github.com/Vonng/ddia 。\n并发事务带来了哪些问题? # 在典型的应用程序中,多个事务并发运行,经常会操作相同的数据来完成各自的任务(多个用户对同一数据进行操作)。并发虽然是必须的,但可能会导致以下的问题。\n脏读(Dirty read) # 一个事务读取数据并且对数据进行了修改,这个修改对其他事务来说是可见的,即使当前事务没有提交。这时另外一个事务读取了这个还未提交的数据,但第一个事务突然回滚,导致数据并没有被提交到数据库,那第二个事务读取到的就是脏数据,这也就是脏读的由来。\n例如:事务 1 读取某表中的数据 A=20,事务 1 修改 A=A-1,事务 2 读取到 A = 19,事务 1 回滚导致对 A 的修改并未提交到数据库, A 的值还是 20。\n丢失修改(Lost to modify) # 在一个事务读取一个数据时,另外一个事务也访问了该数据,那么在第一个事务中修改了这个数据后,第二个事务也修改了这个数据。这样第一个事务内的修改结果就被丢失,因此称为丢失修改。\n例如:事务 1 读取某表中的数据 A=20,事务 2 也读取 A=20,事务 1 先修改 A=A-1,事务 2 后来也修改 A=A-1,最终结果 A=19,事务 1 的修改被丢失。\n不可重复读(Unrepeatable read) # 指在一个事务内多次读同一数据。在这个事务还没有结束时,另一个事务也访问该数据。那么,在第一个事务中的两次读数据之间,由于第二个事务的修改导致第一个事务两次读取的数据可能不太一样。这就发生了在一个事务内两次读到的数据是不一样的情况,因此称为不可重复读。\n例如:事务 1 读取某表中的数据 A=20,事务 2 也读取 A=20,事务 1 修改 A=A-1,事务 2 再次读取 A =19,此时读取的结果和第一次读取的结果不同。\n幻读(Phantom read) # 幻读与不可重复读类似。它发生在一个事务读取了几行数据,接着另一个并发事务插入了一些数据时。在随后的查询中,第一个事务就会发现多了一些原本不存在的记录,就好像发生了幻觉一样,所以称为幻读。\n例如:事务 2 读取某个范围的数据,事务 1 在这个范围插入了新的数据,事务 2 再次读取这个范围的数据发现相比于第一次读取的结果多了新的数据。\n不可重复读和幻读有什么区别? # 不可重复读的重点是内容修改或者记录减少比如多次读取一条记录发现其中某些记录的值被修改; 幻读的重点在于记录新增比如多次执行同一条查询语句(DQL)时,发现查到的记录增加了。 幻读其实可以看作是不可重复读的一种特殊情况,单独把幻读区分出来的原因主要是解决幻读和不可重复读的方案不一样。\n举个例子:执行 delete 和 update 操作的时候,可以直接对记录加锁,保证事务安全。而执行 insert 操作的时候,由于记录锁(Record Lock)只能锁住已经存在的记录,为了避免插入新记录,需要依赖间隙锁(Gap Lock)。也就是说执行 insert 操作的时候需要依赖 Next-Key Lock(Record Lock+Gap Lock) 进行加锁来保证不出现幻读。\n并发事务的控制方式有哪些? # MySQL 中并发事务的控制方式无非就两种:锁 和 MVCC。锁可以看作是悲观控制的模式,多版本并发控制(MVCC,Multiversion concurrency control)可以看作是乐观控制的模式。\n锁 控制方式下会通过锁来显式控制共享资源而不是通过调度手段,MySQL 中主要是通过 读写锁 来实现并发控制。\n共享锁(S 锁):又称读锁,事务在读取记录的时候获取共享锁,允许多个事务同时获取(锁兼容)。 排他锁(X 锁):又称写锁/独占锁,事务在修改记录的时候获取排他锁,不允许多个事务同时获取。如果一个记录已经被加了排他锁,那其他事务不能再对这条记录加任何类型的锁(锁不兼容)。 读写锁可以做到读读并行,但是无法做到写读、写写并行。另外,根据根据锁粒度的不同,又被分为 表级锁(table-level locking) 和 行级锁(row-level locking) 。InnoDB 不光支持表级锁,还支持行级锁,默认为行级锁。行级锁的粒度更小,仅对相关的记录上锁即可(对一行或者多行记录加锁),所以对于并发写入操作来说, InnoDB 的性能更高。不论是表级锁还是行级锁,都存在共享锁(Share Lock,S 锁)和排他锁(Exclusive Lock,X 锁)这两类。\nMVCC 是多版本并发控制方法,即对一份数据会存储多个版本,通过事务的可见性来保证事务能看到自己应该看到的版本。通常会有一个全局的版本分配器来为每一行数据设置版本号,版本号是唯一的。\nMVCC 在 MySQL 中实现所依赖的手段主要是: 隐藏字段、read view、undo log。\nundo log : undo log 用于记录某行数据的多个版本的数据。 read view 和 隐藏字段 : 用来判断当前版本数据的可见性。 关于 InnoDB 对 MVCC 的具体实现可以看这篇文章: InnoDB 存储引擎对 MVCC 的实现 。\nSQL 标准定义了哪些事务隔离级别? # SQL 标准定义了四个隔离级别:\nREAD-UNCOMMITTED(读取未提交) :最低的隔离级别,允许读取尚未提交的数据变更,可能会导致脏读、幻读或不可重复读。 READ-COMMITTED(读取已提交) :允许读取并发事务已经提交的数据,可以阻止脏读,但是幻读或不可重复读仍有可能发生。 REPEATABLE-READ(可重复读) :对同一字段的多次读取结果都是一致的,除非数据是被本身事务自己所修改,可以阻止脏读和不可重复读,但幻读仍有可能发生。 SERIALIZABLE(可串行化) :最高的隔离级别,完全服从 ACID 的隔离级别。所有的事务依次逐个执行,这样事务之间就完全不可能产生干扰,也就是说,该级别可以防止脏读、不可重复读以及幻读。 隔离级别 脏读 不可重复读 幻读 READ-UNCOMMITTED √ √ √ READ-COMMITTED × √ √ REPEATABLE-READ × × √ SERIALIZABLE × × × MySQL 的隔离级别是基于锁实现的吗? # MySQL 的隔离级别基于锁和 MVCC 机制共同实现的。\nSERIALIZABLE 隔离级别是通过锁来实现的,READ-COMMITTED 和 REPEATABLE-READ 隔离级别是基于 MVCC 实现的。不过, SERIALIZABLE 之外的其他隔离级别可能也需要用到锁机制,就比如 REPEATABLE-READ 在当前读情况下需要使用加锁读来保证不会出现幻读。\nMySQL 的默认隔离级别是什么? # MySQL InnoDB 存储引擎的默认支持的隔离级别是 REPEATABLE-READ(可重读)。我们可以通过SELECT @@tx_isolation;命令来查看,MySQL 8.0 该命令改为SELECT @@transaction_isolation;\nmysql\u0026gt; SELECT @@tx_isolation; +-----------------+ | @@tx_isolation | +-----------------+ | REPEATABLE-READ | +-----------------+ 关于 MySQL 事务隔离级别的详细介绍,可以看看我写的这篇文章: MySQL 事务隔离级别详解。\nMySQL 锁 # 锁是一种常见的并发事务的控制方式。\n表级锁和行级锁了解吗?有什么区别? # MyISAM 仅仅支持表级锁(table-level locking),一锁就锁整张表,这在并发写的情况下性非常差。InnoDB 不光支持表级锁(table-level locking),还支持行级锁(row-level locking),默认为行级锁。\n行级锁的粒度更小,仅对相关的记录上锁即可(对一行或者多行记录加锁),所以对于并发写入操作来说, InnoDB 的性能更高。\n表级锁和行级锁对比:\n表级锁: MySQL 中锁定粒度最大的一种锁(全局锁除外),是针对非索引字段加的锁,对当前操作的整张表加锁,实现简单,资源消耗也比较少,加锁快,不会出现死锁。不过,触发锁冲突的概率最高,高并发下效率极低。表级锁和存储引擎无关,MyISAM 和 InnoDB 引擎都支持表级锁。 行级锁: MySQL 中锁定粒度最小的一种锁,是 针对索引字段加的锁 ,只针对当前操作的行记录进行加锁。 行级锁能大大减少数据库操作的冲突。其加锁粒度最小,并发度高,但加锁的开销也最大,加锁慢,会出现死锁。行级锁和存储引擎有关,是在存储引擎层面实现的。 行级锁的使用有什么注意事项? # InnoDB 的行锁是针对索引字段加的锁,表级锁是针对非索引字段加的锁。当我们执行 UPDATE、DELETE 语句时,如果 WHERE条件中字段没有命中唯一索引或者索引失效的话,就会导致扫描全表对表中的所有行记录进行加锁。这个在我们日常工作开发中经常会遇到,一定要多多注意!!!\n不过,很多时候即使用了索引也有可能会走全表扫描,这是因为 MySQL 优化器的原因。\nInnoDB 有哪几类行锁? # InnoDB 行锁是通过对索引数据页上的记录加锁实现的,MySQL InnoDB 支持三种行锁定方式:\n记录锁(Record Lock):属于单个行记录上的锁。 间隙锁(Gap Lock):锁定一个范围,不包括记录本身。 临键锁(Next-Key Lock):Record Lock+Gap Lock,锁定一个范围,包含记录本身,主要目的是为了解决幻读问题(MySQL 事务部分提到过)。记录锁只能锁住已经存在的记录,为了避免插入新记录,需要依赖间隙锁。 在 InnoDB 默认的隔离级别 REPEATABLE-READ 下,行锁默认使用的是 Next-Key Lock。但是,如果操作的索引是唯一索引或主键,InnoDB 会对 Next-Key Lock 进行优化,将其降级为 Record Lock,即仅锁住索引本身,而不是范围。\n一些大厂面试中可能会问到 Next-Key Lock 的加锁范围,这里推荐一篇文章: MySQL next-key lock 加锁范围是什么? - 程序员小航 - 2021 。\n共享锁和排他锁呢? # 不论是表级锁还是行级锁,都存在共享锁(Share Lock,S 锁)和排他锁(Exclusive Lock,X 锁)这两类:\n共享锁(S 锁):又称读锁,事务在读取记录的时候获取共享锁,允许多个事务同时获取(锁兼容)。 排他锁(X 锁):又称写锁/独占锁,事务在修改记录的时候获取排他锁,不允许多个事务同时获取。如果一个记录已经被加了排他锁,那其他事务不能再对这条事务加任何类型的锁(锁不兼容)。 排他锁与任何的锁都不兼容,共享锁仅和共享锁兼容。\nS 锁 X 锁 S 锁 不冲突 冲突 X 锁 冲突 冲突 由于 MVCC 的存在,对于一般的 SELECT 语句,InnoDB 不会加任何锁。不过, 你可以通过以下语句显式加共享锁或排他锁。\n# 共享锁 可以在 MySQL 5.7 和 MySQL 8.0 中使用 SELECT ... LOCK IN SHARE MODE; # 共享锁 可以在 MySQL 8.0 中使用 SELECT ... FOR SHARE; # 排他锁 SELECT ... FOR UPDATE; 意向锁有什么作用? # 如果需要用到表锁的话,如何判断表中的记录没有行锁呢,一行一行遍历肯定是不行,性能太差。我们需要用到一个叫做意向锁的东东来快速判断是否可以对某个表使用表锁。\n意向锁是表级锁,共有两种:\n意向共享锁(Intention Shared Lock,IS 锁):事务有意向对表中的某些记录加共享锁(S 锁),加共享锁前必须先取得该表的 IS 锁。 意向排他锁(Intention Exclusive Lock,IX 锁):事务有意向对表中的某些记录加排他锁(X 锁),加排他锁之前必须先取得该表的 IX 锁。 意向锁是由数据引擎自己维护的,用户无法手动操作意向锁,在为数据行加共享/排他锁之前,InnoDB 会先获取该数据行所在在数据表的对应意向锁。\n意向锁之间是互相兼容的。\nIS 锁 IX 锁 IS 锁 兼容 兼容 IX 锁 兼容 兼容 意向锁和共享锁和排它锁互斥(这里指的是表级别的共享锁和排他锁,意向锁不会与行级的共享锁和排他锁互斥)。\nIS 锁 IX 锁 S 锁 兼容 互斥 X 锁 互斥 互斥 《MySQL 技术内幕 InnoDB 存储引擎》这本书对应的描述应该是笔误了。\n当前读和快照读有什么区别? # 快照读(一致性非锁定读)就是单纯的 SELECT 语句,但不包括下面这两类 SELECT 语句:\nSELECT ... FOR UPDATE # 共享锁 可以在 MySQL 5.7 和 MySQL 8.0 中使用 SELECT ... LOCK IN SHARE MODE; # 共享锁 可以在 MySQL 8.0 中使用 SELECT ... FOR SHARE; 快照即记录的历史版本,每行记录可能存在多个历史版本(多版本技术)。\n快照读的情况下,如果读取的记录正在执行 UPDATE/DELETE 操作,读取操作不会因此去等待记录上 X 锁的释放,而是会去读取行的一个快照。\n只有在事务隔离级别 RC(读取已提交) 和 RR(可重读)下,InnoDB 才会使用一致性非锁定读:\n在 RC 级别下,对于快照数据,一致性非锁定读总是读取被锁定行的最新一份快照数据。 在 RR 级别下,对于快照数据,一致性非锁定读总是读取本事务开始时的行数据版本。 快照读比较适合对于数据一致性要求不是特别高且追求极致性能的业务场景。\n当前读 (一致性锁定读)就是给行记录加 X 锁或 S 锁。\n当前读的一些常见 SQL 语句类型如下:\n# 对读的记录加一个X锁 SELECT...FOR UPDATE # 对读的记录加一个S锁 SELECT...LOCK IN SHARE MODE # 对读的记录加一个S锁 SELECT...FOR SHARE # 对修改的记录加一个X锁 INSERT... UPDATE... DELETE... 自增锁有了解吗? # 不太重要的一个知识点,简单了解即可。\n关系型数据库设计表的时候,通常会有一列作为自增主键。InnoDB 中的自增主键会涉及一种比较特殊的表级锁— 自增锁(AUTO-INC Locks) 。\nCREATE TABLE `sequence_id` ( `id` BIGINT(20) UNSIGNED NOT NULL AUTO_INCREMENT, `stub` CHAR(10) NOT NULL DEFAULT \u0026#39;\u0026#39;, PRIMARY KEY (`id`), UNIQUE KEY `stub` (`stub`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; 更准确点来说,不仅仅是自增主键,AUTO_INCREMENT的列都会涉及到自增锁,毕竟非主键也可以设置自增长。\n如果一个事务正在插入数据到有自增列的表时,会先获取自增锁,拿不到就可能会被阻塞住。这里的阻塞行为只是自增锁行为的其中一种,可以理解为自增锁就是一个接口,其具体的实现有多种。具体的配置项为 innodb_autoinc_lock_mode (MySQL 5.1.22 引入),可以选择的值如下:\ninnodb_autoinc_lock_mode 介绍 0 传统模式 1 连续模式(MySQL 8.0 之前默认) 2 交错模式(MySQL 8.0 之后默认) 交错模式下,所有的“INSERT-LIKE”语句(所有的插入语句,包括:INSERT、REPLACE、INSERT…SELECT、REPLACE…SELECT、LOAD DATA等)都不使用表级锁,使用的是轻量级互斥锁实现,多条插入语句可以并发执行,速度更快,扩展性也更好。\n不过,如果你的 MySQL 数据库有主从同步需求并且 Binlog 存储格式为 Statement 的话,不要将 InnoDB 自增锁模式设置为交叉模式,不然会有数据不一致性问题。这是因为并发情况下插入语句的执行顺序就无法得到保障。\n如果 MySQL 采用的格式为 Statement ,那么 MySQL 的主从同步实际上同步的就是一条一条的 SQL 语句。\n最后,再推荐一篇文章: 为什么 MySQL 的自增主键不单调也不连续 。\nMySQL 性能优化 # 关于 MySQL 性能优化的建议总结,请看这篇文章: MySQL 高性能优化规范建议总结 。\n能用 MySQL 直接存储文件(比如图片)吗? # 可以是可以,直接存储文件对应的二进制数据即可。不过,还是建议不要在数据库中存储文件,会严重影响数据库性能,消耗过多存储空间。\n可以选择使用云服务厂商提供的开箱即用的文件存储服务,成熟稳定,价格也比较低。\n也可以选择自建文件存储服务,实现起来也不难,基于 FastDFS、MinIO(推荐) 等开源项目就可以实现分布式文件服务。\n数据库只存储文件地址信息,文件由文件存储服务负责存储。\n相关阅读: Spring Boot 整合 MinIO 实现分布式文件服务 。\nMySQL 如何存储 IP 地址? # 可以将 IP 地址转换成整形数据存储,性能更好,占用空间也更小。\nMySQL 提供了两个方法来处理 ip 地址\nINET_ATON():把 ip 转为无符号整型 (4-8 位) INET_NTOA() :把整型的 ip 转为地址 插入数据前,先用 INET_ATON() 把 ip 地址转为整型,显示数据时,使用 INET_NTOA() 把整型的 ip 地址转为地址显示即可。\n有哪些常见的 SQL 优化手段? # 《Java 面试指北》(付费) 的 「技术面试题篇」 有一篇文章详细介绍了常见的 SQL 优化手段,非常全面,清晰易懂!\n如何分析 SQL 的性能? # 我们可以使用 EXPLAIN 命令来分析 SQL 的 执行计划 。执行计划是指一条 SQL 语句在经过 MySQL 查询优化器的优化会后,具体的执行方式。\nEXPLAIN 并不会真的去执行相关的语句,而是通过 查询优化器 对语句进行分析,找出最优的查询方案,并显示对应的信息。\nEXPLAIN 适用于 SELECT, DELETE, INSERT, REPLACE, 和 UPDATE语句,我们一般分析 SELECT 查询较多。\n我们这里简单来演示一下 EXPLAIN 的使用。\nEXPLAIN 的输出格式如下:\nmysql\u0026gt; EXPLAIN SELECT `score`,`name` FROM `cus_order` ORDER BY `score` DESC; +----+-------------+-----------+------------+------+---------------+------+---------+------+--------+----------+----------------+ | id | select_type | table | partitions | type | possible_keys | key | key_len | ref | rows | filtered | Extra | +----+-------------+-----------+------------+------+---------------+------+---------+------+--------+----------+----------------+ | 1 | SIMPLE | cus_order | NULL | ALL | NULL | NULL | NULL | NULL | 997572 | 100.00 | Using filesort | +----+-------------+-----------+------------+------+---------------+------+---------+------+--------+----------+----------------+ 1 row in set, 1 warning (0.00 sec) 各个字段的含义如下:\n列名 含义 id SELECT 查询的序列标识符 select_type SELECT 关键字对应的查询类型 table 用到的表名 partitions 匹配的分区,对于未分区的表,值为 NULL type 表的访问方法 possible_keys 可能用到的索引 key 实际用到的索引 key_len 所选索引的长度 ref 当使用索引等值查询时,与索引作比较的列或常量 rows 预计要读取的行数 filtered 按表条件过滤后,留存的记录数的百分比 Extra 附加信息 篇幅问题,我这里只是简单介绍了一下 MySQL 执行计划,详细介绍请看: SQL 的执行计划这篇文章。\n读写分离和分库分表了解吗? # 读写分离和分库分表相关的问题比较多,于是,我单独写了一篇文章来介绍: 读写分离和分库分表详解。\n深度分页如何优化? # 深度分页介绍及优化建议\n数据冷热分离如何做? # 数据冷热分离详解\nMySQL 性能怎么优化? # MySQL 性能优化是一个系统性工程,涉及多个方面,在面试中不可能面面俱到。因此,建议按照“点-线-面”的思路展开,从核心问题入手,再逐步扩展,展示出你对问题的思考深度和解决能力。\n1. 抓住核心:慢 SQL 定位与分析\n性能优化的第一步永远是找到瓶颈。面试时,建议先从 慢 SQL 定位和分析 入手,这不仅能展示你解决问题的思路,还能体现你对数据库性能监控的熟练掌握:\n监控工具: 介绍常用的慢 SQL 监控工具,如 MySQL 慢查询日志、Performance Schema 等,说明你对这些工具的熟悉程度以及如何通过它们定位问题。 EXPLAIN 命令: 详细说明 EXPLAIN 命令的使用,分析查询计划、索引使用情况,可以结合实际案例展示如何解读分析结果,比如执行顺序、索引使用情况、全表扫描等。 2. 由点及面:索引、表结构和 SQL 优化\n定位到慢 SQL 后,接下来就要针对具体问题进行优化。 这里可以重点介绍索引、表结构和 SQL 编写规范等方面的优化技巧:\n索引优化: 这是 MySQL 性能优化的重点,可以介绍索引的创建原则、覆盖索引、最左前缀匹配原则等。如果能结合你项目的实际应用来说明如何选择合适的索引,会更加分一些。 表结构优化: 优化表结构设计,包括选择合适的字段类型、避免冗余字段、合理使用范式和反范式设计等等。 SQL 优化: 避免使用 SELECT *、尽量使用具体字段、使用连接查询代替子查询、合理使用分页查询、批量操作等,都是 SQL 编写过程中需要注意的细节。 3. 进阶方案:架构优化\n当面试官对基础优化知识比较满意时,可能会深入探讨一些架构层面的优化方案。以下是一些常见的架构优化策略:\n读写分离: 将读操作和写操作分离到不同的数据库实例,提升数据库的并发处理能力。 分库分表: 将数据分散到多个数据库实例或数据表中,降低单表数据量,提升查询效率。但要权衡其带来的复杂性和维护成本,谨慎使用。 数据冷热分离:根据数据的访问频率和业务重要性,将数据分为冷数据和热数据,冷数据一般存储在存储在低成本、低性能的介质中,热数据高性能存储介质中。 缓存机制: 使用 Redis 等缓存中间件,将热点数据缓存到内存中,减轻数据库压力。这个非常常用,提升效果非常明显,性价比极高! 4. 其他优化手段\n除了慢 SQL 定位、索引优化和架构优化,还可以提及一些其他优化手段,展示你对 MySQL 性能调优的全面理解:\n连接池配置: 配置合理的数据库连接池(如 连接池大小、超时时间 等),能够有效提升数据库连接的效率,避免频繁的连接开销。 硬件配置: 提升硬件性能也是优化的重要手段之一。使用高性能服务器、增加内存、使用 SSD 硬盘等硬件升级,都可以有效提升数据库的整体性能。 5.总结\n在面试中,建议按优先级依次介绍慢 SQL 定位、 索引优化、表结构设计和 SQL 优化等内容。架构层面的优化,如 读写分离和分库分表、 数据冷热分离 应作为最后的手段,除非在特定场景下有明显的性能瓶颈,否则不应轻易使用,因其引入的复杂性会带来额外的维护成本。\nMySQL 学习资料推荐 # 书籍推荐 。\n文章推荐 :\n一树一溪的 MySQL 系列教程 Yes 的 MySQL 系列教程 写完这篇 我的 SQL 优化能力直接进入新层次 - 变成派大星 - 2022 两万字详解!InnoDB 锁专题! - 捡田螺的小男孩 - 2022 MySQL 的自增主键一定是连续的吗? - 飞天小牛肉 - 2022 深入理解 MySQL 索引底层原理 - 腾讯技术工程 - 2020 参考 # 《高性能 MySQL》第 7 章 MySQL 高级特性 《MySQL 技术内幕 InnoDB 存储引擎》第 6 章 锁 Relational Database: https://www.omnisci.com/technical-glossary/relational-database 一篇文章看懂 mysql 中 varchar 能存多少汉字、数字,以及 varchar(100)和 varchar(10)的区别: https://www.cnblogs.com/zhuyeshen/p/11642211.html 技术分享 | 隔离级别:正确理解幻读: https://opensource.actionsky.com/20210818-mysql/ MySQL Server Logs - MySQL 5.7 Reference Manual: https://dev.mysql.com/doc/refman/5.7/en/server-logs.html Redo Log - MySQL 5.7 Reference Manual: https://dev.mysql.com/doc/refman/5.7/en/innodb-redo-log.html Locking Reads - MySQL 5.7 Reference Manual: https://dev.mysql.com/doc/refman/5.7/en/innodb-locking-reads.html 深入理解数据库行锁与表锁 https://zhuanlan.zhihu.com/p/52678870 详解 MySQL InnoDB 中意向锁的作用: https://juejin.cn/post/6844903666332368909 深入剖析 MySQL 自增锁: https://juejin.cn/post/6968420054287253540 在数据库中不可重复读和幻读到底应该怎么分?: https://www.zhihu.com/question/392569386 "},{"id":540,"href":"/zh/docs/technology/Interview/database/mysql/mysql-high-performance-optimization-specification-recommendations/","title":"MySQL高性能优化规范建议总结","section":"Mysql","content":" 作者: 听风 原文地址: https://www.cnblogs.com/huchong/p/10219318.html。\nJavaGuide 已获得作者授权,并对原文内容进行了完善补充。\n数据库命名规范 # 所有数据库对象名称必须使用小写字母并用下划线分割 所有数据库对象名称禁止使用 MySQL 保留关键字(如果表名中包含关键字查询时,需要将其用单引号括起来) 数据库对象的命名要能做到见名识意,并且最后不要超过 32 个字符 临时库表必须以 tmp_ 为前缀并以日期为后缀,备份表必须以 bak_ 为前缀并以日期 (时间戳) 为后缀 所有存储相同数据的列名和列类型必须一致(一般作为关联列,如果查询时关联列类型不一致会自动进行数据类型隐式转换,会造成列上的索引失效,导致查询效率降低) 数据库基本设计规范 # 所有表必须使用 InnoDB 存储引擎 # 没有特殊要求(即 InnoDB 无法满足的功能如:列存储,存储空间数据等)的情况下,所有表必须使用 InnoDB 存储引擎(MySQL5.5 之前默认使用 Myisam,5.6 以后默认的为 InnoDB)。\nInnoDB 支持事务,支持行级锁,更好的恢复性,高并发下性能更好。\n数据库和表的字符集统一使用 UTF8 # 兼容性更好,统一字符集可以避免由于字符集转换产生的乱码,不同的字符集进行比较前需要进行转换会造成索引失效,如果数据库中有存储 emoji 表情的需要,字符集需要采用 utf8mb4 字符集。\n推荐阅读一下我写的这篇文章: MySQL 字符集详解 。\n所有表和字段都需要添加注释 # 使用 comment 从句添加表和列的备注,从一开始就进行数据字典的维护\n尽量控制单表数据量的大小,建议控制在 500 万以内 # 500 万并不是 MySQL 数据库的限制,过大会造成修改表结构,备份,恢复都会有很大的问题。\n可以用历史数据归档(应用于日志数据),分库分表(应用于业务数据)等手段来控制数据量大小\n谨慎使用 MySQL 分区表 # 分区表在物理上表现为多个文件,在逻辑上表现为一个表;\n谨慎选择分区键,跨分区查询效率可能更低;\n建议采用物理分表的方式管理大数据。\n经常一起使用的列放到一个表中 # 避免更多的关联操作。\n禁止在表中建立预留字段 # 预留字段的命名很难做到见名识义。 预留字段无法确认存储的数据类型,所以无法选择合适的类型。 对预留字段类型的修改,会对表进行锁定。 禁止在数据库中存储文件(比如图片)这类大的二进制数据 # 在数据库中存储文件会严重影响数据库性能,消耗过多存储空间。\n文件(比如图片)这类大的二进制数据通常存储于文件服务器,数据库只存储文件地址信息。\n不要被数据库范式所束缚 # 一般来说,设计关系数据库时需要满足第三范式,但为了满足第三范式,我们可能会拆分出多张表。而在进行查询时需要对多张表进行关联查询,有时为了提高查询效率,会降低范式的要求,在表中保存一定的冗余信息,也叫做反范式。但要注意反范式一定要适度。\n禁止在线上做数据库压力测试 # 禁止从开发环境,测试环境直接连接生产环境数据库 # 安全隐患极大,要对生产环境抱有敬畏之心!\n数据库字段设计规范 # 优先选择符合存储需要的最小的数据类型 # 存储字节越小,占用也就空间越小,性能也越好。\na.某些字符串可以转换成数字类型存储比如可以将 IP 地址转换成整型数据。\n数字是连续的,性能更好,占用空间也更小。\nMySQL 提供了两个方法来处理 ip 地址\nINET_ATON():把 ip 转为无符号整型 (4-8 位) INET_NTOA() :把整型的 ip 转为地址 插入数据前,先用 INET_ATON() 把 ip 地址转为整型,显示数据时,使用 INET_NTOA() 把整型的 ip 地址转为地址显示即可。\nb.对于非负型的数据 (如自增 ID,整型 IP,年龄) 来说,要优先使用无符号整型来存储。\n无符号相对于有符号可以多出一倍的存储空间\nSIGNED INT -2147483648~2147483647 UNSIGNED INT 0~4294967295 c.小数值类型(比如年龄、状态表示如 0/1)优先使用 TINYINT 类型。\n避免使用 TEXT,BLOB 数据类型,最常见的 TEXT 类型可以存储 64k 的数据 # a. 建议把 BLOB 或是 TEXT 列分离到单独的扩展表中。\nMySQL 内存临时表不支持 TEXT、BLOB 这样的大数据类型,如果查询中包含这样的数据,在排序等操作时,就不能使用内存临时表,必须使用磁盘临时表进行。而且对于这种数据,MySQL 还是要进行二次查询,会使 sql 性能变得很差,但是不是说一定不能使用这样的数据类型。\n如果一定要使用,建议把 BLOB 或是 TEXT 列分离到单独的扩展表中,查询时一定不要使用 select *而只需要取出必要的列,不需要 TEXT 列的数据时不要对该列进行查询。\n2、TEXT 或 BLOB 类型只能使用前缀索引\n因为 MySQL 对索引字段长度是有限制的,所以 TEXT 类型只能使用前缀索引,并且 TEXT 列上是不能有默认值的\n避免使用 ENUM 类型 # 修改 ENUM 值需要使用 ALTER 语句; ENUM 类型的 ORDER BY 操作效率低,需要额外操作; ENUM 数据类型存在一些限制比如建议不要使用数值作为 ENUM 的枚举值。 相关阅读: 是否推荐使用 MySQL 的 enum 类型? - 架构文摘 - 知乎 。\n尽可能把所有列定义为 NOT NULL # 除非有特别的原因使用 NULL 值,应该总是让字段保持 NOT NULL。\n索引 NULL 列需要额外的空间来保存,所以要占用更多的空间; 进行比较和计算时要对 NULL 值做特别的处理。 相关阅读: 技术分享 | MySQL 默认值选型(是空,还是 NULL) 。\n一定不要用字符串存储日期 # 对于日期类型来说, 一定不要用字符串存储日期。可以考虑 DATETIME、TIMESTAMP 和 数值型时间戳。\n这三种种方式都有各自的优势,根据实际场景选择最合适的才是王道。下面再对这三种方式做一个简单的对比,以供大家实际开发中选择正确的存放时间的数据类型:\n类型 存储空间 日期格式 日期范围 是否带时区信息 DATETIME 5~8 字节 YYYY-MM-DD hh:mm:ss[.fraction] 1000-01-01 00:00:00[.000000] ~ 9999-12-31 23:59:59[.999999] 否 TIMESTAMP 4~7 字节 YYYY-MM-DD hh:mm:ss[.fraction] 1970-01-01 00:00:01[.000000] ~ 2038-01-19 03:14:07[.999999] 是 数值型时间戳 4 字节 全数字如 1578707612 1970-01-01 00:00:01 之后的时间 否 MySQL 时间类型选择的详细介绍请看这篇: MySQL 时间类型数据存储建议。\n同财务相关的金额类数据必须使用 decimal 类型 # 非精准浮点:float,double 精准浮点:decimal decimal 类型为精准浮点数,在计算时不会丢失精度。占用空间由定义的宽度决定,每 4 个字节可以存储 9 位数字,并且小数点要占用一个字节。并且,decimal 可用于存储比 bigint 更大的整型数据\n不过, 由于 decimal 需要额外的空间和计算开销,应该尽量只在需要对数据进行精确计算时才使用 decimal 。\n单表不要包含过多字段 # 如果一个表包含过多字段的话,可以考虑将其分解成多个表,必要时增加中间表进行关联。\n索引设计规范 # 限制每张表上的索引数量,建议单张表索引不超过 5 个 # 索引并不是越多越好!索引可以提高效率同样可以降低效率。\n索引可以增加查询效率,但同样也会降低插入和更新的效率,甚至有些情况下会降低查询效率。\n因为 MySQL 优化器在选择如何优化查询时,会根据统一信息,对每一个可以用到的索引来进行评估,以生成出一个最好的执行计划,如果同时有很多个索引都可以用于查询,就会增加 MySQL 优化器生成执行计划的时间,同样会降低查询性能。\n禁止使用全文索引 # 全文索引不适用于 OLTP 场景。\n禁止给表中的每一列都建立单独的索引 # 5.6 版本之前,一个 sql 只能使用到一个表中的一个索引,5.6 以后,虽然有了合并索引的优化方式,但是还是远远没有使用一个联合索引的查询方式好。\n每个 InnoDB 表必须有个主键 # InnoDB 是一种索引组织表:数据的存储的逻辑顺序和索引的顺序是相同的。每个表都可以有多个索引,但是表的存储顺序只能有一种。\nInnoDB 是按照主键索引的顺序来组织表的\n不要使用更新频繁的列作为主键,不使用多列主键(相当于联合索引) 不要使用 UUID,MD5,HASH,字符串列作为主键(无法保证数据的顺序增长) 主键建议使用自增 ID 值 常见索引列建议 # 出现在 SELECT、UPDATE、DELETE 语句的 WHERE 从句中的列 包含在 ORDER BY、GROUP BY、DISTINCT 中的字段 并不要将符合 1 和 2 中的字段的列都建立一个索引, 通常将 1、2 中的字段建立联合索引效果更好 多表 join 的关联列 如何选择索引列的顺序 # 建立索引的目的是:希望通过索引进行数据查找,减少随机 IO,增加查询性能 ,索引能过滤出越少的数据,则从磁盘中读入的数据也就越少。\n区分度最高的列放在联合索引的最左侧: 这是最重要的原则。区分度越高,通过索引筛选出的数据就越少,I/O 操作也就越少。计算区分度的方法是 count(distinct column) / count(*)。 最频繁使用的列放在联合索引的左侧: 这符合最左前缀匹配原则。将最常用的查询条件列放在最左侧,可以最大程度地利用索引。 字段长度: 字段长度对联合索引非叶子节点的影响很小,因为它存储了所有联合索引字段的值。字段长度主要影响主键和包含在其他索引中的字段的存储空间,以及这些索引的叶子节点的大小。因此,在选择联合索引列的顺序时,字段长度的优先级最低。 对于主键和包含在其他索引中的字段,选择较短的字段长度可以节省存储空间和提高 I/O 性能。 避免建立冗余索引和重复索引(增加了查询优化器生成执行计划的时间) # 重复索引示例:primary key(id)、index(id)、unique index(id) 冗余索引示例:index(a,b,c)、index(a,b)、index(a) 对于频繁的查询优先考虑使用覆盖索引 # 覆盖索引:就是包含了所有查询字段 (where,select,order by,group by 包含的字段) 的索引\n覆盖索引的好处:\n避免 InnoDB 表进行索引的二次查询,也就是回表操作: InnoDB 是以聚集索引的顺序来存储的,对于 InnoDB 来说,二级索引在叶子节点中所保存的是行的主键信息,如果是用二级索引查询数据的话,在查找到相应的键值后,还要通过主键进行二次查询才能获取我们真实所需要的数据。而在覆盖索引中,二级索引的键值中可以获取所有的数据,避免了对主键的二次查询(回表),减少了 IO 操作,提升了查询效率。 可以把随机 IO 变成顺序 IO 加快查询效率: 由于覆盖索引是按键值的顺序存储的,对于 IO 密集型的范围查找来说,对比随机从磁盘读取每一行的数据 IO 要少的多,因此利用覆盖索引在访问时也可以把磁盘的随机读取的 IO 转变成索引查找的顺序 IO。 索引 SET 规范 # 尽量避免使用外键约束\n不建议使用外键约束(foreign key),但一定要在表与表之间的关联键上建立索引 外键可用于保证数据的参照完整性,但建议在业务端实现 外键会影响父表和子表的写操作从而降低性能 数据库 SQL 开发规范 # 尽量不在数据库做运算,复杂运算需移到业务应用里完成 # 尽量不在数据库做运算,复杂运算需移到业务应用里完成。这样可以避免数据库的负担过重,影响数据库的性能和稳定性。数据库的主要作用是存储和管理数据,而不是处理数据。\n优化对性能影响较大的 SQL 语句 # 要找到最需要优化的 SQL 语句。要么是使用最频繁的语句,要么是优化后提高最明显的语句,可以通过查询 MySQL 的慢查询日志来发现需要进行优化的 SQL 语句。\n充分利用表上已经存在的索引 # 避免使用双%号的查询条件。如:a like '%123%',(如果无前置%,只有后置%,是可以用到列上的索引的)\n一个 SQL 只能利用到复合索引中的一列进行范围查询。如:有 a,b,c 列的联合索引,在查询条件中有 a 列的范围查询,则在 b,c 列上的索引将不会被用到。\n在定义联合索引时,如果 a 列要用到范围查找的话,就要把 a 列放到联合索引的右侧,使用 left join 或 not exists 来优化 not in 操作,因为 not in 也通常会使用索引失效。\n禁止使用 SELECT * 必须使用 SELECT \u0026lt;字段列表\u0026gt; 查询 # SELECT * 会消耗更多的 CPU。 SELECT * 无用字段增加网络带宽资源消耗,增加数据传输时间,尤其是大字段(如 varchar、blob、text)。 SELECT * 无法使用 MySQL 优化器覆盖索引的优化(基于 MySQL 优化器的“覆盖索引”策略又是速度极快,效率极高,业界极为推荐的查询优化方式) SELECT \u0026lt;字段列表\u0026gt; 可减少表结构变更带来的影响、 禁止使用不含字段列表的 INSERT 语句 # 如:\ninsert into t values (\u0026#39;a\u0026#39;,\u0026#39;b\u0026#39;,\u0026#39;c\u0026#39;); 应使用:\ninsert into t(c1,c2,c3) values (\u0026#39;a\u0026#39;,\u0026#39;b\u0026#39;,\u0026#39;c\u0026#39;); 建议使用预编译语句进行数据库操作 # 预编译语句可以重复使用这些计划,减少 SQL 编译所需要的时间,还可以解决动态 SQL 所带来的 SQL 注入的问题。 只传参数,比传递 SQL 语句更高效。 相同语句可以一次解析,多次使用,提高处理效率。 避免数据类型的隐式转换 # 隐式转换会导致索引失效如:\nselect name,phone from customer where id = \u0026#39;111\u0026#39;; 详细解读可以看: MySQL 中的隐式转换造成的索引失效 这篇文章。\n避免使用子查询,可以把子查询优化为 join 操作 # 通常子查询在 in 子句中,且子查询中为简单 SQL(不包含 union、group by、order by、limit 从句) 时,才可以把子查询转化为关联查询进行优化。\n子查询性能差的原因: 子查询的结果集无法使用索引,通常子查询的结果集会被存储到临时表中,不论是内存临时表还是磁盘临时表都不会存在索引,所以查询性能会受到一定的影响。特别是对于返回结果集比较大的子查询,其对查询性能的影响也就越大。由于子查询会产生大量的临时表也没有索引,所以会消耗过多的 CPU 和 IO 资源,产生大量的慢查询。\n避免使用 JOIN 关联太多的表 # 对于 MySQL 来说,是存在关联缓存的,缓存的大小可以由 join_buffer_size 参数进行设置。\n在 MySQL 中,对于同一个 SQL 多关联(join)一个表,就会多分配一个关联缓存,如果在一个 SQL 中关联的表越多,所占用的内存也就越大。\n如果程序中大量的使用了多表关联的操作,同时 join_buffer_size 设置的也不合理的情况下,就容易造成服务器内存溢出的情况,就会影响到服务器数据库性能的稳定性。\n同时对于关联操作来说,会产生临时表操作,影响查询效率,MySQL 最多允许关联 61 个表,建议不超过 5 个。\n减少同数据库的交互次数 # 数据库更适合处理批量操作,合并多个相同的操作到一起,可以提高处理效率。\n对应同一列进行 or 判断时,使用 in 代替 or # in 的值不要超过 500 个,in 操作可以更有效的利用索引,or 大多数情况下很少能利用到索引。\n禁止使用 order by rand() 进行随机排序 # order by rand() 会把表中所有符合条件的数据装载到内存中,然后在内存中对所有数据根据随机生成的值进行排序,并且可能会对每一行都生成一个随机值,如果满足条件的数据集非常大,就会消耗大量的 CPU 和 IO 及内存资源。\n推荐在程序中获取一个随机值,然后从数据库中获取数据的方式。\nWHERE 从句中禁止对列进行函数转换和计算 # 对列进行函数转换或计算时会导致无法使用索引\n不推荐:\nwhere date(create_time)=\u0026#39;20190101\u0026#39; 推荐:\nwhere create_time \u0026gt;= \u0026#39;20190101\u0026#39; and create_time \u0026lt; \u0026#39;20190102\u0026#39; 在明显不会有重复值时使用 UNION ALL 而不是 UNION # UNION 会把两个结果集的所有数据放到临时表中后再进行去重操作 UNION ALL 不会再对结果集进行去重操作 拆分复杂的大 SQL 为多个小 SQL # 大 SQL 逻辑上比较复杂,需要占用大量 CPU 进行计算的 SQL MySQL 中,一个 SQL 只能使用一个 CPU 进行计算 SQL 拆分后可以通过并行执行来提高处理效率 程序连接不同的数据库使用不同的账号,禁止跨库查询 # 为数据库迁移和分库分表留出余地 降低业务耦合度 避免权限过大而产生的安全风险 数据库操作行为规范 # 超 100 万行的批量写 (UPDATE,DELETE,INSERT) 操作,要分批多次进行操作 # 大批量操作可能会造成严重的主从延迟\n主从环境中,大批量操作可能会造成严重的主从延迟,大批量的写操作一般都需要执行一定长的时间,而只有当主库上执行完成后,才会在其他从库上执行,所以会造成主库与从库长时间的延迟情况\nbinlog 日志为 row 格式时会产生大量的日志\n大批量写操作会产生大量日志,特别是对于 row 格式二进制数据而言,由于在 row 格式中会记录每一行数据的修改,我们一次修改的数据越多,产生的日志量也就会越多,日志的传输和恢复所需要的时间也就越长,这也是造成主从延迟的一个原因\n避免产生大事务操作\n大批量修改数据,一定是在一个事务中进行的,这就会造成表中大批量数据进行锁定,从而导致大量的阻塞,阻塞会对 MySQL 的性能产生非常大的影响。\n特别是长时间的阻塞会占满所有数据库的可用连接,这会使生产环境中的其他应用无法连接到数据库,因此一定要注意大批量写操作要进行分批\n对于大表使用 pt-online-schema-change 修改表结构 # 避免大表修改产生的主从延迟 避免在对表字段进行修改时进行锁表 对大表数据结构的修改一定要谨慎,会造成严重的锁表操作,尤其是生产环境,是不能容忍的。\npt-online-schema-change 它会首先建立一个与原表结构相同的新表,并且在新表上进行表结构的修改,然后再把原表中的数据复制到新表中,并在原表中增加一些触发器。把原表中新增的数据也复制到新表中,在行所有数据复制完成之后,把新表命名成原表,并把原来的表删除掉。把原来一个 DDL 操作,分解成多个小的批次进行。\n禁止为程序使用的账号赋予 super 权限 # 当达到最大连接数限制时,还运行 1 个有 super 权限的用户连接 super 权限只能留给 DBA 处理问题的账号使用 对于程序连接数据库账号,遵循权限最小原则 # 程序使用数据库账号只能在一个 DB 下使用,不准跨库 程序使用的账号原则上不准有 drop 权限 推荐阅读 # 技术同学必会的 MySQL 设计规约,都是惨痛的教训 - 阿里开发者 聊聊数据库建表的 15 个小技巧 "},{"id":541,"href":"/zh/docs/technology/Interview/database/mysql/some-thoughts-on-database-storage-time/","title":"MySQL日期类型选择建议","section":"Mysql","content":"我们平时开发中不可避免的就是要存储时间,比如我们要记录操作表中这条记录的时间、记录转账的交易时间、记录出发时间、用户下单时间等等。你会发现时间这个东西与我们开发的联系还是非常紧密的,用的好与不好会给我们的业务甚至功能带来很大的影响。所以,我们有必要重新出发,好好认识一下这个东西。\n不要用字符串存储日期 # 和绝大部分对数据库不太了解的新手一样,我在大学的时候就这样干过,甚至认为这样是一个不错的表示日期的方法。毕竟简单直白,容易上手。\n但是,这是不正确的做法,主要会有下面两个问题:\n字符串占用的空间更大! 字符串存储的日期效率比较低(逐个字符进行比对),无法用日期相关的 API 进行计算和比较。 Datetime 和 Timestamp 之间的抉择 # Datetime 和 Timestamp 是 MySQL 提供的两种比较相似的保存时间的数据类型,可以精确到秒。他们两者究竟该如何选择呢?\n下面我们来简单对比一下二者。\n时区信息 # DateTime 类型是没有时区信息的(时区无关) ,DateTime 类型保存的时间都是当前会话所设置的时区对应的时间。这样就会有什么问题呢?当你的时区更换之后,比如你的服务器更换地址或者更换客户端连接时区设置的话,就会导致你从数据库中读出的时间错误。\nTimestamp 和时区有关。Timestamp 类型字段的值会随着服务器时区的变化而变化,自动换算成相应的时间,说简单点就是在不同时区,查询到同一个条记录此字段的值会不一样。\n下面实际演示一下!\n建表 SQL 语句:\nCREATE TABLE `time_zone_test` ( `id` bigint(20) NOT NULL AUTO_INCREMENT, `date_time` datetime DEFAULT NULL, `time_stamp` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, PRIMARY KEY (`id`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8; 插入数据:\nINSERT INTO time_zone_test(date_time,time_stamp) VALUES(NOW(),NOW()); 查看数据:\nselect date_time,time_stamp from time_zone_test; 结果:\n+---------------------+---------------------+ | date_time | time_stamp | +---------------------+---------------------+ | 2020-01-11 09:53:32 | 2020-01-11 09:53:32 | +---------------------+---------------------+ 现在我们运行\n修改当前会话的时区:\nset time_zone=\u0026#39;+8:00\u0026#39;; 再次查看数据:\n+---------------------+---------------------+ | date_time | time_stamp | +---------------------+---------------------+ | 2020-01-11 09:53:32 | 2020-01-11 17:53:32 | +---------------------+---------------------+ 扩展:一些关于 MySQL 时区设置的一个常用 sql 命令\n# 查看当前会话时区 SELECT @@session.time_zone; # 设置当前会话时区 SET time_zone = \u0026#39;Europe/Helsinki\u0026#39;; SET time_zone = \u0026#34;+00:00\u0026#34;; # 数据库全局时区设置 SELECT @@global.time_zone; # 设置全局时区 SET GLOBAL time_zone = \u0026#39;+8:00\u0026#39;; SET GLOBAL time_zone = \u0026#39;Europe/Helsinki\u0026#39;; 占用空间 # 下图是 MySQL 日期类型所占的存储空间(官方文档传送门: https://dev.mysql.com/doc/refman/8.0/en/storage-requirements.html):\n在 MySQL 5.6.4 之前,DateTime 和 Timestamp 的存储空间是固定的,分别为 8 字节和 4 字节。但是从 MySQL 5.6.4 开始,它们的存储空间会根据毫秒精度的不同而变化,DateTime 的范围是 58 字节,Timestamp 的范围是 47 字节。\n表示范围 # Timestamp 表示的时间范围更小,只能到 2038 年:\nDateTime:1000-01-01 00:00:00.000000 ~ 9999-12-31 23:59:59.499999 Timestamp:1970-01-01 00:00:01.000000 ~ 2038-01-19 03:14:07.499999 性能 # 由于 TIMESTAMP 需要根据时区进行转换,所以从毫秒数转换到 TIMESTAMP 时,不仅要调用一个简单的函数,还要调用操作系统底层的系统函数。这个系统函数为了保证操作系统时区的一致性,需要进行加锁操作,这就降低了效率。\nDATETIME 不涉及时区转换,所以不会有这个问题。\n为了避免 TIMESTAMP 的时区转换问题,建议使用指定的时区,而不是依赖于操作系统时区。\n数值时间戳是更好的选择吗? # 很多时候,我们也会使用 int 或者 bigint 类型的数值也就是数值时间戳来表示时间。\n这种存储方式的具有 Timestamp 类型的所具有一些优点,并且使用它的进行日期排序以及对比等操作的效率会更高,跨系统也很方便,毕竟只是存放的数值。缺点也很明显,就是数据的可读性太差了,你无法直观的看到具体时间。\n时间戳的定义如下:\n时间戳的定义是从一个基准时间开始算起,这个基准时间是「1970-1-1 00:00:00 +0:00」,从这个时间开始,用整数表示,以秒计时,随着时间的流逝这个时间整数不断增加。这样一来,我只需要一个数值,就可以完美地表示时间了,而且这个数值是一个绝对数值,即无论的身处地球的任何角落,这个表示时间的时间戳,都是一样的,生成的数值都是一样的,并且没有时区的概念,所以在系统的中时间的传输中,都不需要进行额外的转换了,只有在显示给用户的时候,才转换为字符串格式的本地时间。\n数据库中实际操作:\nmysql\u0026gt; select UNIX_TIMESTAMP(\u0026#39;2020-01-11 09:53:32\u0026#39;); +---------------------------------------+ | UNIX_TIMESTAMP(\u0026#39;2020-01-11 09:53:32\u0026#39;) | +---------------------------------------+ | 1578707612 | +---------------------------------------+ 1 row in set (0.00 sec) mysql\u0026gt; select FROM_UNIXTIME(1578707612); +---------------------------+ | FROM_UNIXTIME(1578707612) | +---------------------------+ | 2020-01-11 09:53:32 | +---------------------------+ 1 row in set (0.01 sec) 总结 # MySQL 中时间到底怎么存储才好?Datetime?Timestamp?还是数值时间戳?\n并没有一个银弹,很多程序员会觉得数值型时间戳是真的好,效率又高还各种兼容,但是很多人又觉得它表现的不够直观。\n《高性能 MySQL 》这本神书的作者就是推荐 Timestamp,原因是数值表示时间不够直观。下面是原文:\n每种方式都有各自的优势,根据实际场景选择最合适的才是王道。下面再对这三种方式做一个简单的对比,以供大家实际开发中选择正确的存放时间的数据类型:\n类型 存储空间 日期格式 日期范围 是否带时区信息 DATETIME 5~8 字节 YYYY-MM-DD hh:mm:ss[.fraction] 1000-01-01 00:00:00[.000000] ~ 9999-12-31 23:59:59[.999999] 否 TIMESTAMP 4~7 字节 YYYY-MM-DD hh:mm:ss[.fraction] 1970-01-01 00:00:01[.000000] ~ 2038-01-19 03:14:07[.999999] 是 数值型时间戳 4 字节 全数字如 1578707612 1970-01-01 00:00:01 之后的时间 否 "},{"id":542,"href":"/zh/docs/technology/Interview/database/mysql/mysql-logs/","title":"MySQL三大日志(binlog、redo log和undo log)详解","section":"Mysql","content":" 本文来自公号程序猿阿星投稿,JavaGuide 对其做了补充完善。\n前言 # MySQL 日志 主要包括错误日志、查询日志、慢查询日志、事务日志、二进制日志几大类。其中,比较重要的还要属二进制日志 binlog(归档日志)和事务日志 redo log(重做日志)和 undo log(回滚日志)。\n今天就来聊聊 redo log(重做日志)、binlog(归档日志)、两阶段提交、undo log(回滚日志)。\nredo log # redo log(重做日志)是 InnoDB 存储引擎独有的,它让 MySQL 拥有了崩溃恢复能力。\n比如 MySQL 实例挂了或宕机了,重启时,InnoDB 存储引擎会使用 redo log 恢复数据,保证数据的持久性与完整性。\nMySQL 中数据是以页为单位,你查询一条记录,会从硬盘把一页的数据加载出来,加载出来的数据叫数据页,会放入到 Buffer Pool 中。\n后续的查询都是先从 Buffer Pool 中找,没有命中再去硬盘加载,减少硬盘 IO 开销,提升性能。\n更新表数据的时候,也是如此,发现 Buffer Pool 里存在要更新的数据,就直接在 Buffer Pool 里更新。\n然后会把“在某个数据页上做了什么修改”记录到重做日志缓存(redo log buffer)里,接着刷盘到 redo log 文件里。\n图片笔误提示:第 4 步 “清空 redo log buffe 刷盘到 redo 日志中”这句话中的 buffe 应该是 buffer。\n理想情况,事务一提交就会进行刷盘操作,但实际上,刷盘的时机是根据策略来进行的。\n小贴士:每条 redo 记录由“表空间号+数据页号+偏移量+修改数据长度+具体修改的数据”组成\n刷盘时机 # InnoDB 刷新重做日志的时机有几种情况:\nInnoDB 将 redo log 刷到磁盘上有几种情况:\n事务提交:当事务提交时,log buffer 里的 redo log 会被刷新到磁盘(可以通过innodb_flush_log_at_trx_commit参数控制,后文会提到)。 log buffer 空间不足时:log buffer 中缓存的 redo log 已经占满了 log buffer 总容量的大约一半左右,就需要把这些日志刷新到磁盘上。 事务日志缓冲区满:InnoDB 使用一个事务日志缓冲区(transaction log buffer)来暂时存储事务的重做日志条目。当缓冲区满时,会触发日志的刷新,将日志写入磁盘。 Checkpoint(检查点):InnoDB 定期会执行检查点操作,将内存中的脏数据(已修改但尚未写入磁盘的数据)刷新到磁盘,并且会将相应的重做日志一同刷新,以确保数据的一致性。 后台刷新线程:InnoDB 启动了一个后台线程,负责周期性(每隔 1 秒)地将脏页(已修改但尚未写入磁盘的数据页)刷新到磁盘,并将相关的重做日志一同刷新。 正常关闭服务器:MySQL 关闭的时候,redo log 都会刷入到磁盘里去。 总之,InnoDB 在多种情况下会刷新重做日志,以保证数据的持久性和一致性。\n我们要注意设置正确的刷盘策略innodb_flush_log_at_trx_commit 。根据 MySQL 配置的刷盘策略的不同,MySQL 宕机之后可能会存在轻微的数据丢失问题。\ninnodb_flush_log_at_trx_commit 的值有 3 种,也就是共有 3 种刷盘策略:\n0:设置为 0 的时候,表示每次事务提交时不进行刷盘操作。这种方式性能最高,但是也最不安全,因为如果 MySQL 挂了或宕机了,可能会丢失最近 1 秒内的事务。 1:设置为 1 的时候,表示每次事务提交时都将进行刷盘操作。这种方式性能最低,但是也最安全,因为只要事务提交成功,redo log 记录就一定在磁盘里,不会有任何数据丢失。 2:设置为 2 的时候,表示每次事务提交时都只把 log buffer 里的 redo log 内容写入 page cache(文件系统缓存)。page cache 是专门用来缓存文件的,这里被缓存的文件就是 redo log 文件。这种方式的性能和安全性都介于前两者中间。 刷盘策略innodb_flush_log_at_trx_commit 的默认值为 1,设置为 1 的时候才不会丢失任何数据。为了保证事务的持久性,我们必须将其设置为 1。\n另外,InnoDB 存储引擎有一个后台线程,每隔1 秒,就会把 redo log buffer 中的内容写到文件系统缓存(page cache),然后调用 fsync 刷盘。\n也就是说,一个没有提交事务的 redo log 记录,也可能会刷盘。\n为什么呢?\n因为在事务执行过程 redo log 记录是会写入redo log buffer 中,这些 redo log 记录会被后台线程刷盘。\n除了后台线程每秒1次的轮询操作,还有一种情况,当 redo log buffer 占用的空间即将达到 innodb_log_buffer_size 一半的时候,后台线程会主动刷盘。\n下面是不同刷盘策略的流程图。\ninnodb_flush_log_at_trx_commit=0 # 为0时,如果 MySQL 挂了或宕机可能会有1秒数据的丢失。\ninnodb_flush_log_at_trx_commit=1 # 为1时, 只要事务提交成功,redo log 记录就一定在硬盘里,不会有任何数据丢失。\n如果事务执行期间 MySQL 挂了或宕机,这部分日志丢了,但是事务并没有提交,所以日志丢了也不会有损失。\ninnodb_flush_log_at_trx_commit=2 # 为2时, 只要事务提交成功,redo log buffer中的内容只写入文件系统缓存(page cache)。\n如果仅仅只是 MySQL 挂了不会有任何数据丢失,但是宕机可能会有1秒数据的丢失。\n日志文件组 # 硬盘上存储的 redo log 日志文件不只一个,而是以一个日志文件组的形式出现的,每个的redo日志文件大小都是一样的。\n比如可以配置为一组4个文件,每个文件的大小是 1GB,整个 redo log 日志文件组可以记录4G的内容。\n它采用的是环形数组形式,从头开始写,写到末尾又回到头循环写,如下图所示。\n在这个日志文件组中还有两个重要的属性,分别是 write pos、checkpoint\nwrite pos 是当前记录的位置,一边写一边后移 checkpoint 是当前要擦除的位置,也是往后推移 每次刷盘 redo log 记录到日志文件组中,write pos 位置就会后移更新。\n每次 MySQL 加载日志文件组恢复数据时,会清空加载过的 redo log 记录,并把 checkpoint 后移更新。\nwrite pos 和 checkpoint 之间的还空着的部分可以用来写入新的 redo log 记录。\n如果 write pos 追上 checkpoint ,表示日志文件组满了,这时候不能再写入新的 redo log 记录,MySQL 得停下来,清空一些记录,把 checkpoint 推进一下。\n注意从 MySQL 8.0.30 开始,日志文件组有了些许变化:\nThe innodb_redo_log_capacity variable supersedes the innodb_log_files_in_group and innodb_log_file_size variables, which are deprecated. When the innodb_redo_log_capacity setting is defined, the innodb_log_files_in_group and innodb_log_file_size settings are ignored; otherwise, these settings are used to compute the innodb_redo_log_capacity setting (innodb_log_files_in_group * innodb_log_file_size = innodb_redo_log_capacity). If none of those variables are set, redo log capacity is set to the innodb_redo_log_capacity default value, which is 104857600 bytes (100MB). The maximum redo log capacity is 128GB.\nRedo log files reside in the #innodb_redo directory in the data directory unless a different directory was specified by the innodb_log_group_home_dir variable. If innodb_log_group_home_dir was defined, the redo log files reside in the #innodb_redo directory in that directory. There are two types of redo log files, ordinary and spare. Ordinary redo log files are those being used. Spare redo log files are those waiting to be used. InnoDB tries to maintain 32 redo log files in total, with each file equal in size to 1/32 * innodb_redo_log_capacity; however, file sizes may differ for a time after modifying the innodb_redo_log_capacity setting.\n意思是在 MySQL 8.0.30 之前可以通过 innodb_log_files_in_group 和 innodb_log_file_size 配置日志文件组的文件数和文件大小,但在 MySQL 8.0.30 及之后的版本中,这两个变量已被废弃,即使被指定也是用来计算 innodb_redo_log_capacity 的值。而日志文件组的文件数则固定为 32,文件大小则为 innodb_redo_log_capacity / 32 。\n关于这一点变化,我们可以验证一下。\n首先创建一个配置文件,里面配置一下 innodb_log_files_in_group 和 innodb_log_file_size 的值:\n[mysqld] innodb_log_file_size = 10485760 innodb_log_files_in_group = 64 docker 启动一个 MySQL 8.0.32 的容器:\ndocker run -d -p 3312:3309 -e MYSQL_ROOT_PASSWORD=your-password -v /path/to/your/conf:/etc/mysql/conf.d --name MySQL830 mysql:8.0.32 现在我们来看一下启动日志:\n2023-08-03T02:05:11.720357Z 0 [Warning] [MY-013907] [InnoDB] Deprecated configuration parameters innodb_log_file_size and/or innodb_log_files_in_group have been used to compute innodb_redo_log_capacity=671088640. Please use innodb_redo_log_capacity instead. 这里也表明了 innodb_log_files_in_group 和 innodb_log_file_size 这两个变量是用来计算 innodb_redo_log_capacity ,且已经被废弃。\n我们再看下日志文件组的文件数是多少:\n可以看到刚好是 32 个,并且每个日志文件的大小是 671088640 / 32 = 20971520\n所以在使用 MySQL 8.0.30 及之后的版本时,推荐使用 innodb_redo_log_capacity 变量配置日志文件组\nredo log 小结 # 相信大家都知道 redo log 的作用和它的刷盘时机、存储形式。\n现在我们来思考一个问题:只要每次把修改后的数据页直接刷盘不就好了,还有 redo log 什么事?\n它们不都是刷盘么?差别在哪里?\n1 Byte = 8bit 1 KB = 1024 Byte 1 MB = 1024 KB 1 GB = 1024 MB 1 TB = 1024 GB 实际上,数据页大小是16KB,刷盘比较耗时,可能就修改了数据页里的几 Byte 数据,有必要把完整的数据页刷盘吗?\n而且数据页刷盘是随机写,因为一个数据页对应的位置可能在硬盘文件的随机位置,所以性能是很差。\n如果是写 redo log,一行记录可能就占几十 Byte,只包含表空间号、数据页号、磁盘文件偏移 量、更新值,再加上是顺序写,所以刷盘速度很快。\n所以用 redo log 形式记录修改内容,性能会远远超过刷数据页的方式,这也让数据库的并发能力更强。\n其实内存的数据页在一定时机也会刷盘,我们把这称为页合并,讲 Buffer Pool的时候会对这块细说\nbinlog # redo log 它是物理日志,记录内容是“在某个数据页上做了什么修改”,属于 InnoDB 存储引擎。\n而 binlog 是逻辑日志,记录内容是语句的原始逻辑,类似于“给 ID=2 这一行的 c 字段加 1”,属于MySQL Server 层。\n不管用什么存储引擎,只要发生了表数据更新,都会产生 binlog 日志。\n那 binlog 到底是用来干嘛的?\n可以说 MySQL 数据库的数据备份、主备、主主、主从都离不开 binlog,需要依靠 binlog 来同步数据,保证数据一致性。\nbinlog 会记录所有涉及更新数据的逻辑操作,并且是顺序写。\n记录格式 # binlog 日志有三种格式,可以通过binlog_format参数指定。\nstatement row mixed 指定statement,记录的内容是SQL语句原文,比如执行一条update T set update_time=now() where id=1,记录的内容如下。\n同步数据时,会执行记录的SQL语句,但是有个问题,update_time=now()这里会获取当前系统时间,直接执行会导致与原库的数据不一致。\n为了解决这种问题,我们需要指定为row,记录的内容不再是简单的SQL语句了,还包含操作的具体数据,记录内容如下。\nrow格式记录的内容看不到详细信息,要通过mysqlbinlog工具解析出来。\nupdate_time=now()变成了具体的时间update_time=1627112756247,条件后面的@1、@2、@3 都是该行数据第 1 个~3 个字段的原始值(假设这张表只有 3 个字段)。\n这样就能保证同步数据的一致性,通常情况下都是指定为row,这样可以为数据库的恢复与同步带来更好的可靠性。\n但是这种格式,需要更大的容量来记录,比较占用空间,恢复与同步时会更消耗 IO 资源,影响执行速度。\n所以就有了一种折中的方案,指定为mixed,记录的内容是前两者的混合。\nMySQL 会判断这条SQL语句是否可能引起数据不一致,如果是,就用row格式,否则就用statement格式。\n写入机制 # binlog 的写入时机也非常简单,事务执行过程中,先把日志写到binlog cache,事务提交的时候,再把binlog cache写到 binlog 文件中。\n因为一个事务的 binlog 不能被拆开,无论这个事务多大,也要确保一次性写入,所以系统会给每个线程分配一个块内存作为binlog cache。\n我们可以通过binlog_cache_size参数控制单个线程 binlog cache 大小,如果存储内容超过了这个参数,就要暂存到磁盘(Swap)。\nbinlog 日志刷盘流程如下\n上图的 write,是指把日志写入到文件系统的 page cache,并没有把数据持久化到磁盘,所以速度比较快 上图的 fsync,才是将数据持久化到磁盘的操作 write和fsync的时机,可以由参数sync_binlog控制,默认是1。\n为0的时候,表示每次提交事务都只write,由系统自行判断什么时候执行fsync。\n虽然性能得到提升,但是机器宕机,page cache里面的 binlog 会丢失。\n为了安全起见,可以设置为1,表示每次提交事务都会执行fsync,就如同 redo log 日志刷盘流程 一样。\n最后还有一种折中方式,可以设置为N(N\u0026gt;1),表示每次提交事务都write,但累积N个事务后才fsync。\n在出现 IO 瓶颈的场景里,将sync_binlog设置成一个比较大的值,可以提升性能。\n同样的,如果机器宕机,会丢失最近N个事务的 binlog 日志。\n两阶段提交 # redo log(重做日志)让 InnoDB 存储引擎拥有了崩溃恢复能力。\nbinlog(归档日志)保证了 MySQL 集群架构的数据一致性。\n虽然它们都属于持久化的保证,但是侧重点不同。\n在执行更新语句过程,会记录 redo log 与 binlog 两块日志,以基本的事务为单位,redo log 在事务执行过程中可以不断写入,而 binlog 只有在提交事务时才写入,所以 redo log 与 binlog 的写入时机不一样。\n回到正题,redo log 与 binlog 两份日志之间的逻辑不一致,会出现什么问题?\n我们以update语句为例,假设id=2的记录,字段c值是0,把字段c值更新成1,SQL语句为update T set c=1 where id=2。\n假设执行过程中写完 redo log 日志后,binlog 日志写期间发生了异常,会出现什么情况呢?\n由于 binlog 没写完就异常,这时候 binlog 里面没有对应的修改记录。因此,之后用 binlog 日志恢复数据时,就会少这一次更新,恢复出来的这一行c值是0,而原库因为 redo log 日志恢复,这一行c值是1,最终数据不一致。\n为了解决两份日志之间的逻辑一致问题,InnoDB 存储引擎使用两阶段提交方案。\n原理很简单,将 redo log 的写入拆成了两个步骤prepare和commit,这就是两阶段提交。\n使用两阶段提交后,写入 binlog 时发生异常也不会有影响,因为 MySQL 根据 redo log 日志恢复数据时,发现 redo log 还处于prepare阶段,并且没有对应 binlog 日志,就会回滚该事务。\n再看一个场景,redo log 设置commit阶段发生异常,那会不会回滚事务呢?\n并不会回滚事务,它会执行上图框住的逻辑,虽然 redo log 是处于prepare阶段,但是能通过事务id找到对应的 binlog 日志,所以 MySQL 认为是完整的,就会提交事务恢复数据。\nundo log # 这部分内容为 JavaGuide 的补充:\n每一个事务对数据的修改都会被记录到 undo log ,当执行事务过程中出现错误或者需要执行回滚操作的话,MySQL 可以利用 undo log 将数据恢复到事务开始之前的状态。\nundo log 属于逻辑日志,记录的是 SQL 语句,比如说事务执行一条 DELETE 语句,那 undo log 就会记录一条相对应的 INSERT 语句。同时,undo log 的信息也会被记录到 redo log 中,因为 undo log 也要实现持久性保护。并且,undo-log 本身是会被删除清理的,例如 INSERT 操作,在事务提交之后就可以清除掉了;UPDATE/DELETE 操作在事务提交不会立即删除,会加入 history list,由后台线程 purge 进行清理。\nundo log 是采用 segment(段)的方式来记录的,每个 undo 操作在记录的时候占用一个 undo log segment(undo 日志段),undo log segment 包含在 rollback segment(回滚段)中。事务开始时,需要为其分配一个 rollback segment。每个 rollback segment 有 1024 个 undo log segment,这有助于管理多个并发事务的回滚需求。\n通常情况下, rollback segment header(通常在回滚段的第一个页)负责管理 rollback segment。rollback segment header 是 rollback segment 的一部分,通常在回滚段的第一个页。history list 是 rollback segment header 的一部分,它的主要作用是记录所有已经提交但还没有被清理(purge)的事务的 undo log。这个列表使得 purge 线程能够找到并清理那些不再需要的 undo log 记录。\n另外,MVCC 的实现依赖于:隐藏字段、Read View、undo log。在内部实现中,InnoDB 通过数据行的 DB_TRX_ID 和 Read View 来判断数据的可见性,如不可见,则通过数据行的 DB_ROLL_PTR 找到 undo log 中的历史版本。每个事务读到的数据版本可能是不一样的,在同一个事务中,用户只能看到该事务创建 Read View 之前已经提交的修改和该事务本身做的修改\n总结 # 这部分内容为 JavaGuide 的补充:\nMySQL InnoDB 引擎使用 redo log(重做日志) 保证事务的持久性,使用 undo log(回滚日志) 来保证事务的原子性。\nMySQL 数据库的数据备份、主备、主主、主从都离不开 binlog,需要依靠 binlog 来同步数据,保证数据一致性。\n参考 # 《MySQL 实战 45 讲》 《从零开始带你成为 MySQL 实战优化高手》 《MySQL 是怎样运行的:从根儿上理解 MySQL》 《MySQL 技术 Innodb 存储引擎》 "},{"id":543,"href":"/zh/docs/technology/Interview/database/mysql/transaction-isolation-level/","title":"MySQL事务隔离级别详解","section":"Mysql","content":" 本文由 SnailClimb 和 guang19 共同完成。\n关于事务基本概览的介绍,请看这篇文章的介绍: MySQL 常见知识点\u0026amp;面试题总结\n事务隔离级别总结 # SQL 标准定义了四个隔离级别:\nREAD-UNCOMMITTED(读取未提交) :最低的隔离级别,允许读取尚未提交的数据变更,可能会导致脏读、幻读或不可重复读。 READ-COMMITTED(读取已提交) :允许读取并发事务已经提交的数据,可以阻止脏读,但是幻读或不可重复读仍有可能发生。 REPEATABLE-READ(可重复读) :对同一字段的多次读取结果都是一致的,除非数据是被本身事务自己所修改,可以阻止脏读和不可重复读,但幻读仍有可能发生。 SERIALIZABLE(可串行化) :最高的隔离级别,完全服从 ACID 的隔离级别。所有的事务依次逐个执行,这样事务之间就完全不可能产生干扰,也就是说,该级别可以防止脏读、不可重复读以及幻读。 隔离级别 脏读 不可重复读 幻读 READ-UNCOMMITTED √ √ √ READ-COMMITTED × √ √ REPEATABLE-READ × × √ SERIALIZABLE × × × MySQL InnoDB 存储引擎的默认支持的隔离级别是 REPEATABLE-READ(可重读)。我们可以通过SELECT @@tx_isolation;命令来查看,MySQL 8.0 该命令改为SELECT @@transaction_isolation;\nMySQL\u0026gt; SELECT @@tx_isolation; +-----------------+ | @@tx_isolation | +-----------------+ | REPEATABLE-READ | +-----------------+ 从上面对 SQL 标准定义了四个隔离级别的介绍可以看出,标准的 SQL 隔离级别定义里,REPEATABLE-READ(可重复读)是不可以防止幻读的。\n但是!InnoDB 实现的 REPEATABLE-READ 隔离级别其实是可以解决幻读问题发生的,主要有下面两种情况:\n快照读:由 MVCC 机制来保证不出现幻读。 当前读:使用 Next-Key Lock 进行加锁来保证不出现幻读,Next-Key Lock 是行锁(Record Lock)和间隙锁(Gap Lock)的结合,行锁只能锁住已经存在的行,为了避免插入新行,需要依赖间隙锁。 因为隔离级别越低,事务请求的锁越少,所以大部分数据库系统的隔离级别都是 READ-COMMITTED ,但是你要知道的是 InnoDB 存储引擎默认使用 REPEATABLE-READ 并不会有任何性能损失。\nInnoDB 存储引擎在分布式事务的情况下一般会用到 SERIALIZABLE 隔离级别。\n《MySQL 技术内幕:InnoDB 存储引擎(第 2 版)》7.7 章这样写到:\nInnoDB 存储引擎提供了对 XA 事务的支持,并通过 XA 事务来支持分布式事务的实现。分布式事务指的是允许多个独立的事务资源(transactional resources)参与到一个全局的事务中。事务资源通常是关系型数据库系统,但也可以是其他类型的资源。全局事务要求在其中的所有参与的事务要么都提交,要么都回滚,这对于事务原有的 ACID 要求又有了提高。另外,在使用分布式事务时,InnoDB 存储引擎的事务隔离级别必须设置为 SERIALIZABLE。\n实际情况演示 # 在下面我会使用 2 个命令行 MySQL ,模拟多线程(多事务)对同一份数据的脏读问题。\nMySQL 命令行的默认配置中事务都是自动提交的,即执行 SQL 语句后就会马上执行 COMMIT 操作。如果要显式地开启一个事务需要使用命令:START TRANSACTION。\n我们可以通过下面的命令来设置隔离级别。\nSET [SESSION|GLOBAL] TRANSACTION ISOLATION LEVEL [READ UNCOMMITTED|READ COMMITTED|REPEATABLE READ|SERIALIZABLE] 我们再来看一下我们在下面实际操作中使用到的一些并发控制语句:\nSTART TRANSACTION |BEGIN:显式地开启一个事务。 COMMIT:提交事务,使得对数据库做的所有修改成为永久性。 ROLLBACK:回滚会结束用户的事务,并撤销正在进行的所有未提交的修改。 脏读(读未提交) # 避免脏读(读已提交) # 不可重复读 # 还是刚才上面的读已提交的图,虽然避免了读未提交,但是却出现了,一个事务还没有结束,就发生了 不可重复读问题。\n可重复读 # 幻读 # 演示幻读出现的情况 # SQL 脚本 1 在第一次查询工资为 500 的记录时只有一条,SQL 脚本 2 插入了一条工资为 500 的记录,提交之后;SQL 脚本 1 在同一个事务中再次使用当前读查询发现出现了两条工资为 500 的记录这种就是幻读。\n解决幻读的方法 # 解决幻读的方式有很多,但是它们的核心思想就是一个事务在操作某张表数据的时候,另外一个事务不允许新增或者删除这张表中的数据了。解决幻读的方式主要有以下几种:\n将事务隔离级别调整为 SERIALIZABLE 。 在可重复读的事务级别下,给事务操作的这张表添加表锁。 在可重复读的事务级别下,给事务操作的这张表添加 Next-key Lock(Record Lock+Gap Lock)。 参考 # 《MySQL 技术内幕:InnoDB 存储引擎》 https://dev.MySQL.com/doc/refman/5.7/en/ Mysql 锁:灵魂七拷问 Innodb 中的事务隔离级别和锁的关系 "},{"id":544,"href":"/zh/docs/technology/Interview/database/mysql/mysql-index/","title":"MySQL索引详解","section":"Mysql","content":" 感谢 WT-AHA对本文的完善,相关 PR: https://github.com/Snailclimb/JavaGuide/pull/1648 。\n但凡经历过几场面试的小伙伴,应该都清楚,数据库索引这个知识点在面试中出现的频率高到离谱。\n除了对于准备面试来说非常重要之外,善用索引对 SQL 的性能提升非常明显,是一个性价比较高的 SQL 优化手段。\n索引介绍 # 索引是一种用于快速查询和检索数据的数据结构,其本质可以看成是一种排序好的数据结构。\n索引的作用就相当于书的目录。打个比方: 我们在查字典的时候,如果没有目录,那我们就只能一页一页的去找我们需要查的那个字,速度很慢。如果有目录了,我们只需要先去目录里查找字的位置,然后直接翻到那一页就行了。\n索引底层数据结构存在很多种类型,常见的索引结构有: B 树, B+树 和 Hash、红黑树。在 MySQL 中,无论是 Innodb 还是 MyIsam,都使用了 B+树作为索引结构。\n索引的优缺点 # 优点:\n使用索引可以大大加快数据的检索速度(大大减少检索的数据量), 减少 IO 次数,这也是创建索引的最主要的原因。 通过创建唯一性索引,可以保证数据库表中每一行数据的唯一性。 缺点:\n创建索引和维护索引需要耗费许多时间。当对表中的数据进行增删改的时候,如果数据有索引,那么索引也需要动态的修改,会降低 SQL 执行效率。 索引需要使用物理文件存储,也会耗费一定空间。 但是,使用索引一定能提高查询性能吗?\n大多数情况下,索引查询都是比全表扫描要快的。但是如果数据库的数据量不大,那么使用索引也不一定能够带来很大提升。\n索引底层数据结构选型 # Hash 表 # 哈希表是键值对的集合,通过键(key)即可快速取出对应的值(value),因此哈希表可以快速检索数据(接近 O(1))。\n为何能够通过 key 快速取出 value 呢? 原因在于 哈希算法(也叫散列算法)。通过哈希算法,我们可以快速找到 key 对应的 index,找到了 index 也就找到了对应的 value。\nhash = hashfunc(key) index = hash % array_size 但是!哈希算法有个 Hash 冲突 问题,也就是说多个不同的 key 最后得到的 index 相同。通常情况下,我们常用的解决办法是 链地址法。链地址法就是将哈希冲突数据存放在链表中。就比如 JDK1.8 之前 HashMap 就是通过链地址法来解决哈希冲突的。不过,JDK1.8 以后HashMap为了减少链表过长的时候搜索时间过长引入了红黑树。\n为了减少 Hash 冲突的发生,一个好的哈希函数应该“均匀地”将数据分布在整个可能的哈希值集合中。\nMySQL 的 InnoDB 存储引擎不直接支持常规的哈希索引,但是,InnoDB 存储引擎中存在一种特殊的“自适应哈希索引”(Adaptive Hash Index),自适应哈希索引并不是传统意义上的纯哈希索引,而是结合了 B+Tree 和哈希索引的特点,以便更好地适应实际应用中的数据访问模式和性能需求。自适应哈希索引的每个哈希桶实际上是一个小型的 B+Tree 结构。这个 B+Tree 结构可以存储多个键值对,而不仅仅是一个键。这有助于减少哈希冲突链的长度,提高了索引的效率。关于 Adaptive Hash Index 的详细介绍,可以查看 MySQL 各种“Buffer”之 Adaptive Hash Index 这篇文章。\n既然哈希表这么快,为什么 MySQL 没有使用其作为索引的数据结构呢? 主要是因为 Hash 索引不支持顺序和范围查询。假如我们要对表中的数据进行排序或者进行范围查询,那 Hash 索引可就不行了。并且,每次 IO 只能取一个。\n试想一种情况:\nSELECT * FROM tb1 WHERE id \u0026lt; 500; 在这种范围查询中,优势非常大,直接遍历比 500 小的叶子节点就够了。而 Hash 索引是根据 hash 算法来定位的,难不成还要把 1 - 499 的数据,每个都进行一次 hash 计算来定位吗?这就是 Hash 最大的缺点了。\n二叉查找树(BST) # 二叉查找树(Binary Search Tree)是一种基于二叉树的数据结构,它具有以下特点:\n左子树所有节点的值均小于根节点的值。 右子树所有节点的值均大于根节点的值。 左右子树也分别为二叉查找树。 当二叉查找树是平衡的时候,也就是树的每个节点的左右子树深度相差不超过 1 的时候,查询的时间复杂度为 O(log2(N)),具有比较高的效率。然而,当二叉查找树不平衡时,例如在最坏情况下(有序插入节点),树会退化成线性链表(也被称为斜树),导致查询效率急剧下降,时间复杂退化为 O(N)。\n也就是说,二叉查找树的性能非常依赖于它的平衡程度,这就导致其不适合作为 MySQL 底层索引的数据结构。\n为了解决这个问题,并提高查询效率,人们发明了多种在二叉查找树基础上的改进型数据结构,如平衡二叉树、B-Tree、B+Tree 等。\nAVL 树 # AVL 树是计算机科学中最早被发明的自平衡二叉查找树,它的名称来自于发明者 G.M. Adelson-Velsky 和 E.M. Landis 的名字缩写。AVL 树的特点是保证任何节点的左右子树高度之差不超过 1,因此也被称为高度平衡二叉树,它的查找、插入和删除在平均和最坏情况下的时间复杂度都是 O(logn)。\nAVL 树采用了旋转操作来保持平衡。主要有四种旋转操作:LL 旋转、RR 旋转、LR 旋转和 RL 旋转。其中 LL 旋转和 RR 旋转分别用于处理左左和右右失衡,而 LR 旋转和 RL 旋转则用于处理左右和右左失衡。\n由于 AVL 树需要频繁地进行旋转操作来保持平衡,因此会有较大的计算开销进而降低了数据库写操作的性能。并且, 在使用 AVL 树时,每个树节点仅存储一个数据,而每次进行磁盘 IO 时只能读取一个节点的数据,如果需要查询的数据分布在多个节点上,那么就需要进行多次磁盘 IO。 磁盘 IO 是一项耗时的操作,在设计数据库索引时,我们需要优先考虑如何最大限度地减少磁盘 IO 操作的次数。\n实际应用中,AVL 树使用的并不多。\n红黑树 # 红黑树是一种自平衡二叉查找树,通过在插入和删除节点时进行颜色变换和旋转操作,使得树始终保持平衡状态,它具有以下特点:\n每个节点非红即黑; 根节点总是黑色的; 每个叶子节点都是黑色的空节点(NIL 节点); 如果节点是红色的,则它的子节点必须是黑色的(反之不一定); 从任意节点到它的叶子节点或空子节点的每条路径,必须包含相同数目的黑色节点(即相同的黑色高度)。 和 AVL 树不同的是,红黑树并不追求严格的平衡,而是大致的平衡。正因如此,红黑树的查询效率稍有下降,因为红黑树的平衡性相对较弱,可能会导致树的高度较高,这可能会导致一些数据需要进行多次磁盘 IO 操作才能查询到,这也是 MySQL 没有选择红黑树的主要原因。也正因如此,红黑树的插入和删除操作效率大大提高了,因为红黑树在插入和删除节点时只需进行 O(1) 次数的旋转和变色操作,即可保持基本平衡状态,而不需要像 AVL 树一样进行 O(logn) 次数的旋转操作。\n红黑树的应用还是比较广泛的,TreeMap、TreeSet 以及 JDK1.8 的 HashMap 底层都用到了红黑树。对于数据在内存中的这种情况来说,红黑树的表现是非常优异的。\nB 树\u0026amp; B+树 # B 树也称 B-树,全称为 多路平衡查找树 ,B+ 树是 B 树的一种变体。B 树和 B+树中的 B 是 Balanced (平衡)的意思。\n目前大部分数据库系统及文件系统都采用 B-Tree 或其变种 B+Tree 作为索引结构。\nB 树\u0026amp; B+树两者有何异同呢?\nB 树的所有节点既存放键(key) 也存放数据(data),而 B+树只有叶子节点存放 key 和 data,其他内节点只存放 key。 B 树的叶子节点都是独立的;B+树的叶子节点有一条引用链指向与它相邻的叶子节点。 B 树的检索的过程相当于对范围内的每个节点的关键字做二分查找,可能还没有到达叶子节点,检索就结束了。而 B+树的检索效率就很稳定了,任何查找都是从根节点到叶子节点的过程,叶子节点的顺序检索很明显。 在 B 树中进行范围查询时,首先找到要查找的下限,然后对 B 树进行中序遍历,直到找到查找的上限;而 B+树的范围查询,只需要对链表进行遍历即可。 综上,B+树与 B 树相比,具备更少的 IO 次数、更稳定的查询效率和更适于范围查询这些优势。\n在 MySQL 中,MyISAM 引擎和 InnoDB 引擎都是使用 B+Tree 作为索引结构,但是,两者的实现方式不太一样。(下面的内容整理自《Java 工程师修炼之道》)\nMyISAM 引擎中,B+Tree 叶节点的 data 域存放的是数据记录的地址。在索引检索的时候,首先按照 B+Tree 搜索算法搜索索引,如果指定的 Key 存在,则取出其 data 域的值,然后以 data 域的值为地址读取相应的数据记录。这被称为“非聚簇索引(非聚集索引)”。\nInnoDB 引擎中,其数据文件本身就是索引文件。相比 MyISAM,索引文件和数据文件是分离的,其表数据文件本身就是按 B+Tree 组织的一个索引结构,树的叶节点 data 域保存了完整的数据记录。这个索引的 key 是数据表的主键,因此 InnoDB 表数据文件本身就是主索引。这被称为“聚簇索引(聚集索引)”,而其余的索引都作为 辅助索引 ,辅助索引的 data 域存储相应记录主键的值而不是地址,这也是和 MyISAM 不同的地方。在根据主索引搜索时,直接找到 key 所在的节点即可取出数据;在根据辅助索引查找时,则需要先取出主键的值,再走一遍主索引。 因此,在设计表的时候,不建议使用过长的字段作为主键,也不建议使用非单调的字段作为主键,这样会造成主索引频繁分裂。\n索引类型总结 # 按照数据结构维度划分:\nBTree 索引:MySQL 里默认和最常用的索引类型。只有叶子节点存储 value,非叶子节点只有指针和 key。存储引擎 MyISAM 和 InnoDB 实现 BTree 索引都是使用 B+Tree,但二者实现方式不一样(前面已经介绍了)。 哈希索引:类似键值对的形式,一次即可定位。 RTree 索引:一般不会使用,仅支持 geometry 数据类型,优势在于范围查找,效率较低,通常使用搜索引擎如 ElasticSearch 代替。 全文索引:对文本的内容进行分词,进行搜索。目前只有 CHAR、VARCHAR ,TEXT 列上可以创建全文索引。一般不会使用,效率较低,通常使用搜索引擎如 ElasticSearch 代替。 按照底层存储方式角度划分:\n聚簇索引(聚集索引):索引结构和数据一起存放的索引,InnoDB 中的主键索引就属于聚簇索引。 非聚簇索引(非聚集索引):索引结构和数据分开存放的索引,二级索引(辅助索引)就属于非聚簇索引。MySQL 的 MyISAM 引擎,不管主键还是非主键,使用的都是非聚簇索引。 按照应用维度划分:\n主键索引:加速查询 + 列值唯一(不可以有 NULL)+ 表中只有一个。 普通索引:仅加速查询。 唯一索引:加速查询 + 列值唯一(可以有 NULL)。 覆盖索引:一个索引包含(或者说覆盖)所有需要查询的字段的值。 联合索引:多列值组成一个索引,专门用于组合搜索,其效率大于索引合并。 全文索引:对文本的内容进行分词,进行搜索。目前只有 CHAR、VARCHAR ,TEXT 列上可以创建全文索引。一般不会使用,效率较低,通常使用搜索引擎如 ElasticSearch 代替。 前缀索引:对文本的前几个字符创建索引,相比普通索引建立的数据更小,因为只取前几个字符。 MySQL 8.x 中实现的索引新特性:\n隐藏索引:也称为不可见索引,不会被优化器使用,但是仍然需要维护,通常会软删除和灰度发布的场景中使用。主键不能设置为隐藏(包括显式设置或隐式设置)。 降序索引:之前的版本就支持通过 desc 来指定索引为降序,但实际上创建的仍然是常规的升序索引。直到 MySQL 8.x 版本才开始真正支持降序索引。另外,在 MySQL 8.x 版本中,不再对 GROUP BY 语句进行隐式排序。 函数索引:从 MySQL 8.0.13 版本开始支持在索引中使用函数或者表达式的值,也就是在索引中可以包含函数或者表达式。 主键索引(Primary Key) # 数据表的主键列使用的就是主键索引。\n一张数据表有只能有一个主键,并且主键不能为 null,不能重复。\n在 MySQL 的 InnoDB 的表中,当没有显示的指定表的主键时,InnoDB 会自动先检查表中是否有唯一索引且不允许存在 null 值的字段,如果有,则选择该字段为默认的主键,否则 InnoDB 将会自动创建一个 6Byte 的自增主键。\n二级索引 # 二级索引(Secondary Index)的叶子节点存储的数据是主键的值,也就是说,通过二级索引可以定位主键的位置,二级索引又称为辅助索引/非主键索引。\n唯一索引,普通索引,前缀索引等索引都属于二级索引。\nPS: 不懂的同学可以暂存疑,慢慢往下看,后面会有答案的,也可以自行搜索。\n唯一索引(Unique Key):唯一索引也是一种约束。唯一索引的属性列不能出现重复的数据,但是允许数据为 NULL,一张表允许创建多个唯一索引。 建立唯一索引的目的大部分时候都是为了该属性列的数据的唯一性,而不是为了查询效率。 普通索引(Index):普通索引的唯一作用就是为了快速查询数据,一张表允许创建多个普通索引,并允许数据重复和 NULL。 前缀索引(Prefix):前缀索引只适用于字符串类型的数据。前缀索引是对文本的前几个字符创建索引,相比普通索引建立的数据更小,因为只取前几个字符。 全文索引(Full Text):全文索引主要是为了检索大文本数据中的关键字的信息,是目前搜索引擎数据库使用的一种技术。Mysql5.6 之前只有 MYISAM 引擎支持全文索引,5.6 之后 InnoDB 也支持了全文索引。 二级索引:\n聚簇索引与非聚簇索引 # 聚簇索引(聚集索引) # 聚簇索引介绍 # 聚簇索引(Clustered Index)即索引结构和数据一起存放的索引,并不是一种单独的索引类型。InnoDB 中的主键索引就属于聚簇索引。\n在 MySQL 中,InnoDB 引擎的表的 .ibd文件就包含了该表的索引和数据,对于 InnoDB 引擎表来说,该表的索引(B+树)的每个非叶子节点存储索引,叶子节点存储索引和索引对应的数据。\n聚簇索引的优缺点 # 优点:\n查询速度非常快:聚簇索引的查询速度非常的快,因为整个 B+树本身就是一颗多叉平衡树,叶子节点也都是有序的,定位到索引的节点,就相当于定位到了数据。相比于非聚簇索引, 聚簇索引少了一次读取数据的 IO 操作。 对排序查找和范围查找优化:聚簇索引对于主键的排序查找和范围查找速度非常快。 缺点:\n依赖于有序的数据:因为 B+树是多路平衡树,如果索引的数据不是有序的,那么就需要在插入时排序,如果数据是整型还好,否则类似于字符串或 UUID 这种又长又难比较的数据,插入或查找的速度肯定比较慢。 更新代价大:如果对索引列的数据被修改时,那么对应的索引也将会被修改,而且聚簇索引的叶子节点还存放着数据,修改代价肯定是较大的,所以对于主键索引来说,主键一般都是不可被修改的。 非聚簇索引(非聚集索引) # 非聚簇索引介绍 # 非聚簇索引(Non-Clustered Index)即索引结构和数据分开存放的索引,并不是一种单独的索引类型。二级索引(辅助索引)就属于非聚簇索引。MySQL 的 MyISAM 引擎,不管主键还是非主键,使用的都是非聚簇索引。\n非聚簇索引的叶子节点并不一定存放数据的指针,因为二级索引的叶子节点就存放的是主键,根据主键再回表查数据。\n非聚簇索引的优缺点 # 优点:\n更新代价比聚簇索引要小 。非聚簇索引的更新代价就没有聚簇索引那么大了,非聚簇索引的叶子节点是不存放数据的。\n缺点:\n依赖于有序的数据:跟聚簇索引一样,非聚簇索引也依赖于有序的数据 可能会二次查询(回表):这应该是非聚簇索引最大的缺点了。 当查到索引对应的指针或主键后,可能还需要根据指针或主键再到数据文件或表中查询。 这是 MySQL 的表的文件截图:\n聚簇索引和非聚簇索引:\n非聚簇索引一定回表查询吗(覆盖索引)? # 非聚簇索引不一定回表查询。\n试想一种情况,用户准备使用 SQL 查询用户名,而用户名字段正好建立了索引。\nSELECT name FROM table WHERE name=\u0026#39;guang19\u0026#39;; 那么这个索引的 key 本身就是 name,查到对应的 name 直接返回就行了,无需回表查询。\n即使是 MYISAM 也是这样,虽然 MYISAM 的主键索引确实需要回表,因为它的主键索引的叶子节点存放的是指针。但是!如果 SQL 查的就是主键呢?\nSELECT id FROM table WHERE id=1; 主键索引本身的 key 就是主键,查到返回就行了。这种情况就称之为覆盖索引了。\n覆盖索引和联合索引 # 覆盖索引 # 如果一个索引包含(或者说覆盖)所有需要查询的字段的值,我们就称之为 覆盖索引(Covering Index) 。\n在 InnoDB 存储引擎中,非主键索引的叶子节点包含的是主键的值。这意味着,当使用非主键索引进行查询时,数据库会先找到对应的主键值,然后再通过主键索引来定位和检索完整的行数据。这个过程被称为“回表”。\n覆盖索引即需要查询的字段正好是索引的字段,那么直接根据该索引,就可以查到数据了,而无需回表查询。\n如主键索引,如果一条 SQL 需要查询主键,那么正好根据主键索引就可以查到主键。再如普通索引,如果一条 SQL 需要查询 name,name 字段正好有索引, 那么直接根据这个索引就可以查到数据,也无需回表。\n我们这里简单演示一下覆盖索引的效果。\n1、创建一个名为 cus_order 的表,来实际测试一下这种排序方式。为了测试方便, cus_order 这张表只有 id、score、name这 3 个字段。\nCREATE TABLE `cus_order` ( `id` int(11) unsigned NOT NULL AUTO_INCREMENT, `score` int(11) NOT NULL, `name` varchar(11) NOT NULL DEFAULT \u0026#39;\u0026#39;, PRIMARY KEY (`id`) ) ENGINE=InnoDB AUTO_INCREMENT=100000 DEFAULT CHARSET=utf8mb4; 2、定义一个简单的存储过程(PROCEDURE)来插入 100w 测试数据。\nDELIMITER ;; CREATE DEFINER=`root`@`%` PROCEDURE `BatchinsertDataToCusOder`(IN start_num INT,IN max_num INT) BEGIN DECLARE i INT default start_num; WHILE i \u0026lt; max_num DO insert into `cus_order`(`id`, `score`, `name`) values (i,RAND() * 1000000,CONCAT(\u0026#39;user\u0026#39;, i)); SET i = i + 1; END WHILE; END;; DELIMITER ; 存储过程定义完成之后,我们执行存储过程即可!\nCALL BatchinsertDataToCusOder(1, 1000000); # 插入100w+的随机数据 等待一会,100w 的测试数据就插入完成了!\n3、创建覆盖索引并使用 EXPLAIN 命令分析。\n为了能够对这 100w 数据按照 score 进行排序,我们需要执行下面的 SQL 语句。\n#降序排序 SELECT `score`,`name` FROM `cus_order` ORDER BY `score` DESC; 使用 EXPLAIN 命令分析这条 SQL 语句,通过 Extra 这一列的 Using filesort ,我们发现是没有用到覆盖索引的。\n不过这也是理所应当,毕竟我们现在还没有创建索引呢!\n我们这里以 score 和 name 两个字段建立联合索引:\nALTER TABLE `cus_order` ADD INDEX id_score_name(score, name); 创建完成之后,再用 EXPLAIN 命令分析再次分析这条 SQL 语句。\n通过 Extra 这一列的 Using index ,说明这条 SQL 语句成功使用了覆盖索引。\n关于 EXPLAIN 命令的详细介绍请看: MySQL 执行计划分析这篇文章。\n联合索引 # 使用表中的多个字段创建索引,就是 联合索引,也叫 组合索引 或 复合索引。\n以 score 和 name 两个字段建立联合索引:\nALTER TABLE `cus_order` ADD INDEX id_score_name(score, name); 最左前缀匹配原则 # 最左前缀匹配原则指的是在使用联合索引时,MySQL 会根据索引中的字段顺序,从左到右依次匹配查询条件中的字段。如果查询条件与索引中的最左侧字段相匹配,那么 MySQL 就会使用索引来过滤数据,这样可以提高查询效率。\n最左匹配原则会一直向右匹配,直到遇到范围查询(如 \u0026gt;、\u0026lt;)为止。对于 \u0026gt;=、\u0026lt;=、BETWEEN 以及前缀匹配 LIKE 的范围查询,不会停止匹配(相关阅读: 联合索引的最左匹配原则全网都在说的一个错误结论)。\n假设有一个联合索引(column1, column2, column3),其从左到右的所有前缀为(column1)、(column1, column2)、(column1, column2, column3)(创建 1 个联合索引相当于创建了 3 个索引),包含这些列的所有查询都会走索引而不会全表扫描。\n我们在使用联合索引时,可以将区分度高的字段放在最左边,这也可以过滤更多数据。\n我们这里简单演示一下最左前缀匹配的效果。\n1、创建一个名为 student 的表,这张表只有 id、name、class这 3 个字段。\nCREATE TABLE `student` ( `id` int NOT NULL, `name` varchar(100) DEFAULT NULL, `class` varchar(100) DEFAULT NULL, PRIMARY KEY (`id`), KEY `name_class_idx` (`name`,`class`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; 2、下面我们分别测试三条不同的 SQL 语句。\n# 可以命中索引 SELECT * FROM student WHERE name = \u0026#39;Anne Henry\u0026#39;; EXPLAIN SELECT * FROM student WHERE name = \u0026#39;Anne Henry\u0026#39; AND class = \u0026#39;lIrm08RYVk\u0026#39;; # 无法命中索引 SELECT * FROM student WHERE class = \u0026#39;lIrm08RYVk\u0026#39;; 再来看一个常见的面试题:如果有索引 联合索引(a,b,c),查询 a=1 AND c=1会走索引么?c=1 呢?b=1 AND c=1呢?\n先不要往下看答案,给自己 3 分钟时间想一想。\n查询 a=1 AND c=1:根据最左前缀匹配原则,查询可以使用索引的前缀部分。因此,该查询仅在 a=1 上使用索引,然后对结果进行 c=1 的过滤。 查询 c=1 :由于查询中不包含最左列 a,根据最左前缀匹配原则,整个索引都无法被使用。 查询b=1 AND c=1:和第二种一样的情况,整个索引都不会使用。 MySQL 8.0.13 版本引入了索引跳跃扫描(Index Skip Scan,简称 ISS),它可以在某些索引查询场景下提高查询效率。在没有 ISS 之前,不满足最左前缀匹配原则的联合索引查询中会执行全表扫描。而 ISS 允许 MySQL 在某些情况下避免全表扫描,即使查询条件不符合最左前缀。不过,这个功能比较鸡肋, 和 Oracle 中的没法比,MySQL 8.0.31 还报告了一个 bug: Bug #109145 Using index for skip scan cause incorrect result(后续版本已经修复)。个人建议知道有这个东西就好,不需要深究,实际项目也不一定能用上。\n索引下推 # 索引下推(Index Condition Pushdown,简称 ICP) 是 MySQL 5.6 版本中提供的一项索引优化功能,它允许存储引擎在索引遍历过程中,执行部分 WHERE字句的判断条件,直接过滤掉不满足条件的记录,从而减少回表次数,提高查询效率。\n假设我们有一个名为 user 的表,其中包含 id, username, zipcode和 birthdate 4 个字段,创建了联合索引(zipcode, birthdate)。\nCREATE TABLE `user` ( `id` int NOT NULL AUTO_INCREMENT, `username` varchar(20) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NOT NULL, `zipcode` varchar(20) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NOT NULL, `birthdate` date NOT NULL, PRIMARY KEY (`id`), KEY `idx_username_birthdate` (`zipcode`,`birthdate`) ) ENGINE=InnoDB AUTO_INCREMENT=1001 DEFAULT CHARSET=utf8mb4; # 查询 zipcode 为 431200 且生日在 3 月的用户 # birthdate 字段使用函数索引失效 SELECT * FROM user WHERE zipcode = \u0026#39;431200\u0026#39; AND MONTH(birthdate) = 3; 没有索引下推之前,即使 zipcode 字段利用索引可以帮助我们快速定位到 zipcode = '431200' 的用户,但我们仍然需要对每一个找到的用户进行回表操作,获取完整的用户数据,再去判断 MONTH(birthdate) = 3。 有了索引下推之后,存储引擎会在使用zipcode 字段索引查找zipcode = '431200' 的用户时,同时判断MONTH(birthdate) = 3。这样,只有同时满足条件的记录才会被返回,减少了回表次数。 再来讲讲索引下推的具体原理,先看下面这张 MySQL 简要架构图。\nMySQL 可以简单分为 Server 层和存储引擎层这两层。Server 层处理查询解析、分析、优化、缓存以及与客户端的交互等操作,而存储引擎层负责数据的存储和读取,MySQL 支持 InnoDB、MyISAM、Memory 等多种存储引擎。\n索引下推的下推其实就是指将部分上层(Server 层)负责的事情,交给了下层(存储引擎层)去处理。\n我们这里结合索引下推原理再对上面提到的例子进行解释。\n没有索引下推之前:\n存储引擎层先根据 zipcode 索引字段找到所有 zipcode = '431200' 的用户的主键 ID,然后二次回表查询,获取完整的用户数据; 存储引擎层把所有 zipcode = '431200' 的用户数据全部交给 Server 层,Server 层根据MONTH(birthdate) = 3这一条件再进一步做筛选。 有了索引下推之后:\n存储引擎层先根据 zipcode 索引字段找到所有 zipcode = '431200' 的用户,然后直接判断 MONTH(birthdate) = 3,筛选出符合条件的主键 ID; 二次回表查询,根据符合条件的主键 ID 去获取完整的用户数据; 存储引擎层把符合条件的用户数据全部交给 Server 层。 可以看出,除了可以减少回表次数之外,索引下推还可以减少存储引擎层和 Server 层的数据传输量。\n最后,总结一下索引下推应用范围:\n适用于 InnoDB 引擎和 MyISAM 引擎的查询。 适用于执行计划是 range, ref, eq_ref, ref_or_null 的范围查询。 对于 InnoDB 表,仅用于非聚簇索引。索引下推的目标是减少全行读取次数,从而减少 I/O 操作。对于 InnoDB 聚集索引,完整的记录已经读入 InnoDB 缓冲区。在这种情况下使用索引下推 不会减少 I/O。 子查询不能使用索引下推,因为子查询通常会创建临时表来处理结果,而这些临时表是没有索引的。 存储过程不能使用索引下推,因为存储引擎无法调用存储函数。 正确使用索引的一些建议 # 选择合适的字段创建索引 # 不为 NULL 的字段:索引字段的数据应该尽量不为 NULL,因为对于数据为 NULL 的字段,数据库较难优化。如果字段频繁被查询,但又避免不了为 NULL,建议使用 0,1,true,false 这样语义较为清晰的短值或短字符作为替代。 被频繁查询的字段:我们创建索引的字段应该是查询操作非常频繁的字段。 被作为条件查询的字段:被作为 WHERE 条件查询的字段,应该被考虑建立索引。 频繁需要排序的字段:索引已经排序,这样查询可以利用索引的排序,加快排序查询时间。 被经常频繁用于连接的字段:经常用于连接的字段可能是一些外键列,对于外键列并不一定要建立外键,只是说该列涉及到表与表的关系。对于频繁被连接查询的字段,可以考虑建立索引,提高多表连接查询的效率。 被频繁更新的字段应该慎重建立索引 # 虽然索引能带来查询上的效率,但是维护索引的成本也是不小的。 如果一个字段不被经常查询,反而被经常修改,那么就更不应该在这种字段上建立索引了。\n限制每张表上的索引数量 # 索引并不是越多越好,建议单张表索引不超过 5 个!索引可以提高效率同样可以降低效率。\n索引可以增加查询效率,但同样也会降低插入和更新的效率,甚至有些情况下会降低查询效率。\n因为 MySQL 优化器在选择如何优化查询时,会根据统一信息,对每一个可以用到的索引来进行评估,以生成出一个最好的执行计划,如果同时有很多个索引都可以用于查询,就会增加 MySQL 优化器生成执行计划的时间,同样会降低查询性能。\n尽可能的考虑建立联合索引而不是单列索引 # 因为索引是需要占用磁盘空间的,可以简单理解为每个索引都对应着一颗 B+树。如果一个表的字段过多,索引过多,那么当这个表的数据达到一个体量后,索引占用的空间也是很多的,且修改索引时,耗费的时间也是较多的。如果是联合索引,多个字段在一个索引上,那么将会节约很大磁盘空间,且修改数据的操作效率也会提升。\n注意避免冗余索引 # 冗余索引指的是索引的功能相同,能够命中索引(a, b)就肯定能命中索引(a) ,那么索引(a)就是冗余索引。如(name,city )和(name )这两个索引就是冗余索引,能够命中前者的查询肯定是能够命中后者的 在大多数情况下,都应该尽量扩展已有的索引而不是创建新索引。\n字符串类型的字段使用前缀索引代替普通索引 # 前缀索引仅限于字符串类型,较普通索引会占用更小的空间,所以可以考虑使用前缀索引带替普通索引。\n避免索引失效 # 索引失效也是慢查询的主要原因之一,常见的导致索引失效的情况有下面这些:\n使用 SELECT * 进行查询; SELECT * 不会直接导致索引失效(如果不走索引大概率是因为 where 查询范围过大导致的),但它可能会带来一些其他的性能问题比如造成网络传输和数据处理的浪费、无法使用索引覆盖; 创建了组合索引,但查询条件未遵守最左匹配原则; 在索引列上进行计算、函数、类型转换等操作; 以 % 开头的 LIKE 查询比如 LIKE '%abc';; 查询条件中使用 OR,且 OR 的前后条件中有一个列没有索引,涉及的索引都不会被使用到; IN 的取值范围较大时会导致索引失效,走全表扫描(NOT IN 和 IN 的失效场景相同); 发生 隐式转换; …… 推荐阅读这篇文章: 美团暑期实习一面:MySQl 索引失效的场景有哪些?。\n删除长期未使用的索引 # 删除长期未使用的索引,不用的索引的存在会造成不必要的性能损耗。\nMySQL 5.7 可以通过查询 sys 库的 schema_unused_indexes 视图来查询哪些索引从未被使用。\n知道如何分析 SQL 语句是否走索引查询 # 我们可以使用 EXPLAIN 命令来分析 SQL 的 执行计划 ,这样就知道语句是否命中索引了。执行计划是指一条 SQL 语句在经过 MySQL 查询优化器的优化会后,具体的执行方式。\nEXPLAIN 并不会真的去执行相关的语句,而是通过 查询优化器 对语句进行分析,找出最优的查询方案,并显示对应的信息。\nEXPLAIN 的输出格式如下:\nmysql\u0026gt; EXPLAIN SELECT `score`,`name` FROM `cus_order` ORDER BY `score` DESC; +----+-------------+-----------+------------+------+---------------+------+---------+------+--------+----------+----------------+ | id | select_type | table | partitions | type | possible_keys | key | key_len | ref | rows | filtered | Extra | +----+-------------+-----------+------------+------+---------------+------+---------+------+--------+----------+----------------+ | 1 | SIMPLE | cus_order | NULL | ALL | NULL | NULL | NULL | NULL | 997572 | 100.00 | Using filesort | +----+-------------+-----------+------------+------+---------------+------+---------+------+--------+----------+----------------+ 1 row in set, 1 warning (0.00 sec) 各个字段的含义如下:\n列名 含义 id SELECT 查询的序列标识符 select_type SELECT 关键字对应的查询类型 table 用到的表名 partitions 匹配的分区,对于未分区的表,值为 NULL type 表的访问方法 possible_keys 可能用到的索引 key 实际用到的索引 key_len 所选索引的长度 ref 当使用索引等值查询时,与索引作比较的列或常量 rows 预计要读取的行数 filtered 按表条件过滤后,留存的记录数的百分比 Extra 附加信息 篇幅问题,我这里只是简单介绍了一下 MySQL 执行计划,详细介绍请看: MySQL 执行计划分析这篇文章。\n"},{"id":545,"href":"/zh/docs/technology/Interview/database/mysql/index-invalidation-caused-by-implicit-conversion/","title":"MySQL隐式转换造成索引失效","section":"Mysql","content":" 本次测试使用的 MySQL 版本是 5.7.26,随着 MySQL 版本的更新某些特性可能会发生改变,本文不代表所述观点和结论于 MySQL 所有版本均准确无误,版本差异请自行甄别。\n原文: https://www.guitu18.com/post/2019/11/24/61.html\n前言 # 数据库优化是一个任重而道远的任务,想要做优化必须深入理解数据库的各种特性。在开发过程中我们经常会遇到一些原因很简单但造成的后果却很严重的疑难杂症,这类问题往往还不容易定位,排查费时费力最后发现是一个很小的疏忽造成的,又或者是因为不了解某个技术特性产生的。\n于数据库层面,最常见的恐怕就是索引失效了,且一开始因为数据量小还不易被发现。但随着业务的拓展数据量的提升,性能问题慢慢的就体现出来了,处理不及时还很容易造成雪球效应,最终导致数据库卡死甚至瘫痪。造成索引失效的原因可能有很多种,相关技术博客已经有太多了,今天我要记录的是隐式转换造成的索引失效。\n数据准备 # 首先使用存储过程生成 1000 万条测试数据, 测试表一共建立了 7 个字段(包括主键),num1和num2保存的是和ID一样的顺序数字,其中num2是字符串类型。 type1和type2保存的都是主键对 5 的取模,目的是模拟实际应用中常用类似 type 类型的数据,但是type2是没有建立索引的。 str1和str2都是保存了一个 20 位长度的随机字符串,str1不能为NULL,str2允许为NULL,相应的生成测试数据的时候我也会在str2字段生产少量NULL值(每 100 条数据产生一个NULL值)。\n-- 创建测试数据表 DROP TABLE IF EXISTS test1; CREATE TABLE `test1` ( `id` int(11) NOT NULL, `num1` int(11) NOT NULL DEFAULT \u0026#39;0\u0026#39;, `num2` varchar(11) NOT NULL DEFAULT \u0026#39;\u0026#39;, `type1` int(4) NOT NULL DEFAULT \u0026#39;0\u0026#39;, `type2` int(4) NOT NULL DEFAULT \u0026#39;0\u0026#39;, `str1` varchar(100) NOT NULL DEFAULT \u0026#39;\u0026#39;, `str2` varchar(100) DEFAULT NULL, PRIMARY KEY (`id`), KEY `num1` (`num1`), KEY `num2` (`num2`), KEY `type1` (`type1`), KEY `str1` (`str1`), KEY `str2` (`str2`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8; -- 创建存储过程 DROP PROCEDURE IF EXISTS pre_test1; DELIMITER // CREATE PROCEDURE `pre_test1`() BEGIN DECLARE i INT DEFAULT 0; SET autocommit = 0; WHILE i \u0026lt; 10000000 DO SET i = i + 1; SET @str1 = SUBSTRING(MD5(RAND()),1,20); -- 每100条数据str2产生一个null值 IF i % 100 = 0 THEN SET @str2 = NULL; ELSE SET @str2 = @str1; END IF; INSERT INTO test1 (`id`, `num1`, `num2`, `type1`, `type2`, `str1`, `str2`) VALUES (CONCAT(\u0026#39;\u0026#39;, i), CONCAT(\u0026#39;\u0026#39;, i), CONCAT(\u0026#39;\u0026#39;, i), i%5, i%5, @str1, @str2); -- 事务优化,每一万条数据提交一次事务 IF i % 10000 = 0 THEN COMMIT; END IF; END WHILE; END; // DELIMITER ; -- 执行存储过程 CALL pre_test1(); 数据量比较大,还涉及使用MD5生成随机字符串,所以速度有点慢,稍安勿躁,耐心等待即可。\n1000 万条数据,我用了 33 分钟才跑完(实际时间跟你电脑硬件配置有关)。这里贴几条生成的数据,大致长这样。\nSQL 测试 # 先来看这组 SQL,一共四条,我们的测试数据表num1是int类型,num2是varchar类型,但是存储的数据都是跟主键id一样的顺序数字,两个字段都建立有索引。\n1: SELECT * FROM `test1` WHERE num1 = 10000; 2: SELECT * FROM `test1` WHERE num1 = \u0026#39;10000\u0026#39;; 3: SELECT * FROM `test1` WHERE num2 = 10000; 4: SELECT * FROM `test1` WHERE num2 = \u0026#39;10000\u0026#39;; 这四条 SQL 都是有针对性写的,12 查询的字段是 int 类型,34 查询的字段是varchar类型。12 或 34 查询的字段虽然都相同,但是一个条件是数字,一个条件是用引号引起来的字符串。这样做有什么区别呢?先不看下边的测试结果你能猜出这四条 SQL 的效率顺序吗?\n经测试这四条 SQL 最后的执行结果却相差很大,其中 124 三条 SQL 基本都是瞬间出结果,大概在 0.0010.005 秒,在千万级的数据量下这样的结果可以判定这三条 SQL 性能基本没差别了。但是第三条 SQL,多次测试耗时基本在 4.54.8 秒之间。\n为什么 34 两条 SQL 效率相差那么大,但是同样做对比的 12 两条 SQL 却没什么差别呢?查看一下执行计划,下边分别 1234 条 SQL 的执行计划数据:\n可以看到,124 三条 SQL 都能使用到索引,连接类型都为ref,扫描行数都为 1,所以效率非常高。再看看第三条 SQL,没有用上索引,所以为全表扫描,rows直接到达 1000 万了,所以性能差别才那么大。\n仔细观察你会发现,34 两条 SQL 查询的字段num2是varchar类型的,查询条件等号右边加引号的第 4 条 SQL 是用到索引的,那么是查询的数据类型和字段数据类型不一致造成的吗?如果是这样那 12 两条 SQL 查询的字段num1是int类型,但是第 2 条 SQL 查询条件右边加了引号为什么还能用上索引呢。\n查阅 MySQL 相关文档发现是隐式转换造成的,看一下官方的描述:\n官方文档: 12.2 Type Conversion in Expression Evaluation\n当操作符与不同类型的操作数一起使用时,会发生类型转换以使操作数兼容。某些转换是隐式发生的。例如,MySQL 会根据需要自动将字符串转换为数字,反之亦然。以下规则描述了比较操作的转换方式:\n两个参数至少有一个是NULL时,比较的结果也是NULL,特殊的情况是使用\u0026lt;=\u0026gt;对两个NULL做比较时会返回1,这两种情况都不需要做类型转换 两个参数都是字符串,会按照字符串来比较,不做类型转换 两个参数都是整数,按照整数来比较,不做类型转换 十六进制的值和非数字做比较时,会被当做二进制串 有一个参数是TIMESTAMP或DATETIME,并且另外一个参数是常量,常量会被转换为timestamp 有一个参数是decimal类型,如果另外一个参数是decimal或者整数,会将整数转换为decimal后进行比较,如果另外一个参数是浮点数,则会把decimal转换为浮点数进行比较 所有其他情况下,两个参数都会被转换为浮点数再进行比较 根据官方文档的描述,我们的第 23 两条 SQL 都发生了隐式转换,第 2 条 SQL 的查询条件num1 = '10000',左边是int类型右边是字符串,第 3 条 SQL 相反,那么根据官方转换规则第 7 条,左右两边都会转换为浮点数再进行比较。\n先看第 2 条 SQL:SELECT * FROM `test1` WHERE num1 = '10000'; 左边为 int 类型10000,转换为浮点数还是10000,右边字符串类型'10000',转换为浮点数也是10000。两边的转换结果都是唯一确定的,所以不影响使用索引。\n第 3 条 SQL:SELECT * FROM `test1` WHERE num2 = 10000; 左边是字符串类型'10000',转浮点数为 10000 是唯一的,右边int类型10000转换结果也是唯一的。但是,因为左边是检索条件,'10000'转到10000虽然是唯一,但是其他字符串也可以转换为10000,比如'10000a','010000','10000'等等都能转为浮点数10000,这样的情况下,是不能用到索引的。\n关于这个隐式转换我们可以通过查询测试验证一下,先插入几条数据,其中num2='10000a'、'010000'和'10000':\nINSERT INTO `test1` (`id`, `num1`, `num2`, `type1`, `type2`, `str1`, `str2`) VALUES (\u0026#39;10000001\u0026#39;, \u0026#39;10000\u0026#39;, \u0026#39;10000a\u0026#39;, \u0026#39;0\u0026#39;, \u0026#39;0\u0026#39;, \u0026#39;2df3d9465ty2e4hd523\u0026#39;, \u0026#39;2df3d9465ty2e4hd523\u0026#39;); INSERT INTO `test1` (`id`, `num1`, `num2`, `type1`, `type2`, `str1`, `str2`) VALUES (\u0026#39;10000002\u0026#39;, \u0026#39;10000\u0026#39;, \u0026#39;010000\u0026#39;, \u0026#39;0\u0026#39;, \u0026#39;0\u0026#39;, \u0026#39;2df3d9465ty2e4hd523\u0026#39;, \u0026#39;2df3d9465ty2e4hd523\u0026#39;); INSERT INTO `test1` (`id`, `num1`, `num2`, `type1`, `type2`, `str1`, `str2`) VALUES (\u0026#39;10000003\u0026#39;, \u0026#39;10000\u0026#39;, \u0026#39; 10000\u0026#39;, \u0026#39;0\u0026#39;, \u0026#39;0\u0026#39;, \u0026#39;2df3d9465ty2e4hd523\u0026#39;, \u0026#39;2df3d9465ty2e4hd523\u0026#39;); 然后使用第三条 SQL 语句SELECT * FROM `test1` WHERE num2 = 10000;进行查询:\n从结果可以看到,后面插入的三条数据也都匹配上了。那么这个字符串隐式转换的规则是什么呢?为什么num2='10000a'、'010000'和'10000'这三种情形都能匹配上呢?查阅相关资料发现规则如下:\n不以数字开头的字符串都将转换为0。如'abc'、'a123bc'、'abc123'都会转化为0; 以数字开头的字符串转换时会进行截取,从第一个字符截取到第一个非数字内容为止。比如'123abc'会转换为123,'012abc'会转换为012也就是12,'5.3a66b78c'会转换为5.3,其他同理。 现对以上规则做如下测试验证:\n如此也就印证了之前的查询结果了。\n再次写一条 SQL 查询 str1 字段:SELECT * FROM `test1` WHERE str1 = 1234;\n分析和总结 # 通过上面的测试我们发现 MySQL 使用操作符的一些特性:\n当操作符左右两边的数据类型不一致时,会发生隐式转换。 当 where 查询操作符左边为数值类型时发生了隐式转换,那么对效率影响不大,但还是不推荐这么做。 当 where 查询操作符左边为字符类型时发生了隐式转换,那么会导致索引失效,造成全表扫描效率极低。 字符串转换为数值类型时,非数字开头的字符串会转化为0,以数字开头的字符串会截取从第一个字符到第一个非数字内容为止的值为转化结果。 所以,我们在写 SQL 时一定要养成良好的习惯,查询的字段是什么类型,等号右边的条件就写成对应的类型。特别当查询的字段是字符串时,等号右边的条件一定要用引号引起来标明这是一个字符串,否则会造成索引失效触发全表扫描。\n"},{"id":546,"href":"/zh/docs/technology/Interview/database/mysql/mysql-query-execution-plan/","title":"MySQL执行计划分析","section":"Mysql","content":" 本文来自公号 MySQL 技术,JavaGuide 对其做了补充完善。原文地址: https://mp.weixin.qq.com/s/d5OowNLtXBGEAbT31sSH4g\n优化 SQL 的第一步应该是读懂 SQL 的执行计划。本篇文章,我们一起来学习下 MySQL EXPLAIN 执行计划相关知识。\n什么是执行计划? # 执行计划 是指一条 SQL 语句在经过 MySQL 查询优化器 的优化后,具体的执行方式。\n执行计划通常用于 SQL 性能分析、优化等场景。通过 EXPLAIN 的结果,可以了解到如数据表的查询顺序、数据查询操作的操作类型、哪些索引可以被命中、哪些索引实际会命中、每个数据表有多少行记录被查询等信息。\n如何获取执行计划? # MySQL 为我们提供了 EXPLAIN 命令,来获取执行计划的相关信息。\n需要注意的是,EXPLAIN 语句并不会真的去执行相关的语句,而是通过查询优化器对语句进行分析,找出最优的查询方案,并显示对应的信息。\nEXPLAIN 执行计划支持 SELECT、DELETE、INSERT、REPLACE 以及 UPDATE 语句。我们一般多用于分析 SELECT 查询语句,使用起来非常简单,语法如下:\nEXPLAIN + SELECT 查询语句; 我们简单来看下一条查询语句的执行计划:\nmysql\u0026gt; explain SELECT * FROM dept_emp WHERE emp_no IN (SELECT emp_no FROM dept_emp GROUP BY emp_no HAVING COUNT(emp_no)\u0026gt;1); +----+-------------+----------+------------+-------+-----------------+---------+---------+------+--------+----------+-------------+ | id | select_type | table | partitions | type | possible_keys | key | key_len | ref | rows | filtered | Extra | +----+-------------+----------+------------+-------+-----------------+---------+---------+------+--------+----------+-------------+ | 1 | PRIMARY | dept_emp | NULL | ALL | NULL | NULL | NULL | NULL | 331143 | 100.00 | Using where | | 2 | SUBQUERY | dept_emp | NULL | index | PRIMARY,dept_no | PRIMARY | 16 | NULL | 331143 | 100.00 | Using index | +----+-------------+----------+------------+-------+-----------------+---------+---------+------+--------+----------+-------------+ 可以看到,执行计划结果中共有 12 列,各列代表的含义总结如下表:\n列名 含义 id SELECT 查询的序列标识符 select_type SELECT 关键字对应的查询类型 table 用到的表名 partitions 匹配的分区,对于未分区的表,值为 NULL type 表的访问方法 possible_keys 可能用到的索引 key 实际用到的索引 key_len 所选索引的长度 ref 当使用索引等值查询时,与索引作比较的列或常量 rows 预计要读取的行数 filtered 按表条件过滤后,留存的记录数的百分比 Extra 附加信息 如何分析 EXPLAIN 结果? # 为了分析 EXPLAIN 语句的执行结果,我们需要搞懂执行计划中的重要字段。\nid # SELECT 标识符,用于标识每个 SELECT 语句的执行顺序。\nid 如果相同,从上往下依次执行。id 不同,id 值越大,执行优先级越高,如果行引用其他行的并集结果,则该值可以为 NULL。\nselect_type # 查询的类型,主要用于区分普通查询、联合查询、子查询等复杂的查询,常见的值有:\nSIMPLE:简单查询,不包含 UNION 或者子查询。 PRIMARY:查询中如果包含子查询或其他部分,外层的 SELECT 将被标记为 PRIMARY。 SUBQUERY:子查询中的第一个 SELECT。 UNION:在 UNION 语句中,UNION 之后出现的 SELECT。 DERIVED:在 FROM 中出现的子查询将被标记为 DERIVED。 UNION RESULT:UNION 查询的结果。 table # 查询用到的表名,每行都有对应的表名,表名除了正常的表之外,也可能是以下列出的值:\n\u0026lt;unionM,N\u0026gt; : 本行引用了 id 为 M 和 N 的行的 UNION 结果; \u0026lt;derivedN\u0026gt; : 本行引用了 id 为 N 的表所产生的的派生表结果。派生表有可能产生自 FROM 语句中的子查询。 \u0026lt;subqueryN\u0026gt; : 本行引用了 id 为 N 的表所产生的的物化子查询结果。 type(重要) # 查询执行的类型,描述了查询是如何执行的。所有值的顺序从最优到最差排序为:\nsystem \u0026gt; const \u0026gt; eq_ref \u0026gt; ref \u0026gt; fulltext \u0026gt; ref_or_null \u0026gt; index_merge \u0026gt; unique_subquery \u0026gt; index_subquery \u0026gt; range \u0026gt; index \u0026gt; ALL\n常见的几种类型具体含义如下:\nsystem:如果表使用的引擎对于表行数统计是精确的(如:MyISAM),且表中只有一行记录的情况下,访问方法是 system ,是 const 的一种特例。 const:表中最多只有一行匹配的记录,一次查询就可以找到,常用于使用主键或唯一索引的所有字段作为查询条件。 eq_ref:当连表查询时,前一张表的行在当前这张表中只有一行与之对应。是除了 system 与 const 之外最好的 join 方式,常用于使用主键或唯一索引的所有字段作为连表条件。 ref:使用普通索引作为查询条件,查询结果可能找到多个符合条件的行。 index_merge:当查询条件使用了多个索引时,表示开启了 Index Merge 优化,此时执行计划中的 key 列列出了使用到的索引。 range:对索引列进行范围查询,执行计划中的 key 列表示哪个索引被使用了。 index:查询遍历了整棵索引树,与 ALL 类似,只不过扫描的是索引,而索引一般在内存中,速度更快。 ALL:全表扫描。 possible_keys # possible_keys 列表示 MySQL 执行查询时可能用到的索引。如果这一列为 NULL ,则表示没有可能用到的索引;这种情况下,需要检查 WHERE 语句中所使用的的列,看是否可以通过给这些列中某个或多个添加索引的方法来提高查询性能。\nkey(重要) # key 列表示 MySQL 实际使用到的索引。如果为 NULL,则表示未用到索引。\nkey_len # key_len 列表示 MySQL 实际使用的索引的最大长度;当使用到联合索引时,有可能是多个列的长度和。在满足需求的前提下越短越好。如果 key 列显示 NULL ,则 key_len 列也显示 NULL 。\nrows # rows 列表示根据表统计信息及选用情况,大致估算出找到所需的记录或所需读取的行数,数值越小越好。\nExtra(重要) # 这列包含了 MySQL 解析查询的额外信息,通过这些信息,可以更准确的理解 MySQL 到底是如何执行查询的。常见的值如下:\nUsing filesort:在排序时使用了外部的索引排序,没有用到表内索引进行排序。 Using temporary:MySQL 需要创建临时表来存储查询的结果,常见于 ORDER BY 和 GROUP BY。 Using index:表明查询使用了覆盖索引,不用回表,查询效率非常高。 Using index condition:表示查询优化器选择使用了索引条件下推这个特性。 Using where:表明查询使用了 WHERE 子句进行条件过滤。一般在没有使用到索引的时候会出现。 Using join buffer (Block Nested Loop):连表查询的方式,表示当被驱动表的没有使用索引的时候,MySQL 会先将驱动表读出来放到 join buffer 中,再遍历被驱动表与驱动表进行查询。 这里提醒下,当 Extra 列包含 Using filesort 或 Using temporary 时,MySQL 的性能可能会存在问题,需要尽可能避免。\n参考 # https://dev.mysql.com/doc/refman/5.7/en/explain-output.html https://juejin.cn/post/6953444668973514789 "},{"id":547,"href":"/zh/docs/technology/Interview/database/mysql/mysql-auto-increment-primary-key-continuous/","title":"MySQL自增主键一定是连续的吗","section":"Mysql","content":" 作者:飞天小牛肉\n原文: https://mp.weixin.qq.com/s/qci10h9rJx_COZbHV3aygQ\n众所周知,自增主键可以让聚集索引尽量地保持递增顺序插入,避免了随机查询,从而提高了查询效率。\n但实际上,MySQL 的自增主键并不能保证一定是连续递增的。\n下面举个例子来看下,如下所示创建一张表:\n自增值保存在哪里? # 使用 insert into test_pk values(null, 1, 1) 插入一行数据,再执行 show create table 命令来看一下表的结构定义:\n上述表的结构定义存放在后缀名为 .frm 的本地文件中,在 MySQL 安装目录下的 data 文件夹下可以找到这个 .frm 文件:\n从上述表结构可以看到,表定义里面出现了一个 AUTO_INCREMENT=2,表示下一次插入数据时,如果需要自动生成自增值,会生成 id = 2。\n但需要注意的是,自增值并不会保存在这个表结构也就是 .frm 文件中,不同的引擎对于自增值的保存策略不同:\n1)MyISAM 引擎的自增值保存在数据文件中\n2)InnoDB 引擎的自增值,其实是保存在了内存里,并没有持久化。第一次打开表的时候,都会去找自增值的最大值 max(id),然后将 max(id)+1 作为这个表当前的自增值。\n举个例子:我们现在表里当前数据行里最大的 id 是 1,AUTO_INCREMENT=2,对吧。这时候,我们删除 id=1 的行,AUTO_INCREMENT 还是 2。\n但如果马上重启 MySQL 实例,重启后这个表的 AUTO_INCREMENT 就会变成 1。 也就是说,MySQL 重启可能会修改一个表的 AUTO_INCREMENT 的值。\n以上,是在我本地 MySQL 5.x 版本的实验,实际上,到了 MySQL 8.0 版本后,自增值的变更记录被放在了 redo log 中,提供了自增值持久化的能力 ,也就是实现了“如果发生重启,表的自增值可以根据 redo log 恢复为 MySQL 重启前的值”\n也就是说对于上面这个例子来说,重启实例后这个表的 AUTO_INCREMENT 仍然是 2。\n理解了 MySQL 自增值到底保存在哪里以后,我们再来看看自增值的修改机制,并以此引出第一种自增值不连续的场景。\n自增值不连续的场景 # 自增值不连续场景 1 # 在 MySQL 里面,如果字段 id 被定义为 AUTO_INCREMENT,在插入一行数据的时候,自增值的行为如下:\n如果插入数据时 id 字段指定为 0、null 或未指定值,那么就把这个表当前的 AUTO_INCREMENT 值填到自增字段; 如果插入数据时 id 字段指定了具体的值,就直接使用语句里指定的值。 根据要插入的值和当前自增值的大小关系,自增值的变更结果也会有所不同。假设某次要插入的值是 insert_num,当前的自增值是 autoIncrement_num:\n如果 insert_num \u0026lt; autoIncrement_num,那么这个表的自增值不变 如果 insert_num \u0026gt;= autoIncrement_num,就需要把当前自增值修改为新的自增值 也就是说,如果插入的 id 是 100,当前的自增值是 90,insert_num \u0026gt;= autoIncrement_num,那么自增值就会被修改为新的自增值即 101\n一定是这样吗?\n非也~\n了解过分布式 id 的小伙伴一定知道,为了避免两个库生成的主键发生冲突,我们可以让一个库的自增 id 都是奇数,另一个库的自增 id 都是偶数\n这个奇数偶数其实是通过 auto_increment_offset 和 auto_increment_increment 这两个参数来决定的,这俩分别用来表示自增的初始值和步长,默认值都是 1。\n所以,上面的例子中生成新的自增值的步骤实际是这样的:从 auto_increment_offset 开始,以 auto_increment_increment 为步长,持续叠加,直到找到第一个大于 100 的值,作为新的自增值。\n所以,这种情况下,自增值可能会是 102,103 等等之类的,就会导致不连续的主键 id。\n更遗憾的是,即使在自增初始值和步长这两个参数都设置为 1 的时候,自增主键 id 也不一定能保证主键是连续的\n自增值不连续场景 2 # 举个例子,我们现在往表里插入一条 (null,1,1) 的记录,生成的主键是 1,AUTO_INCREMENT= 2,对吧\n这时我再执行一条插入 (null,1,1) 的命令,很显然会报错 Duplicate entry,因为我们设置了一个唯一索引字段 a:\n但是,你会惊奇的发现,虽然插入失败了,但自增值仍然从 2 增加到了 3!\n这是为啥?\n我们来分析下这个 insert 语句的执行流程:\n执行器调用 InnoDB 引擎接口准备插入一行记录 (null,1,1); InnoDB 发现用户没有指定自增 id 的值,则获取表 test_pk 当前的自增值 2; 将传入的记录改成 (2,1,1); 将表的自增值改成 3; 继续执行插入数据操作,由于已经存在 a=1 的记录,所以报 Duplicate key error,语句返回 可以看到,自增值修改的这个操作,是在真正执行插入数据的操作之前。\n这个语句真正执行的时候,因为碰到唯一键 a 冲突,所以 id = 2 这一行并没有插入成功,但也没有将自增值再改回去。所以,在这之后,再插入新的数据行时,拿到的自增 id 就是 3。也就是说,出现了自增主键不连续的情况。\n至此,我们已经罗列了两种自增主键不连续的情况:\n自增初始值和自增步长设置不为 1 唯一键冲突 除此之外,事务回滚也会导致这种情况\n自增值不连续场景 3 # 我们现在表里有一行 (1,1,1) 的记录,AUTO_INCREMENT = 3:\n我们先插入一行数据 (null, 2, 2),也就是 (3, 2, 2) 嘛,并且 AUTO_INCREMENT 变为 4:\n再去执行这样一段 SQL:\n虽然我们插入了一条 (null, 3, 3) 记录,但是使用 rollback 进行回滚了,所以数据库中是没有这条记录的:\n在这种事务回滚的情况下,自增值并没有同样发生回滚!如下图所示,自增值仍然固执地从 4 增加到了 5:\n所以这时候我们再去插入一条数据(null, 3, 3)的时候,主键 id 就会被自动赋为 5 了:\n那么,为什么在出现唯一键冲突或者回滚的时候,MySQL 没有把表的自增值改回去呢?回退回去的话不就不会发生自增 id 不连续了吗?\n事实上,这么做的主要原因是为了提高性能。\n我们直接用反证法来验证:假设 MySQL 在事务回滚的时候会把自增值改回去,会发生什么?\n现在有两个并行执行的事务 A 和 B,在申请自增值的时候,为了避免两个事务申请到相同的自增 id,肯定要加锁,然后顺序申请,对吧。\n假设事务 A 申请到了 id = 1, 事务 B 申请到 id=2,那么这时候表 t 的自增值是 3,之后继续执行。 事务 B 正确提交了,但事务 A 出现了唯一键冲突,也就是 id = 1 的那行记录插入失败了,那如果允许事务 A 把自增 id 回退,也就是把表的当前自增值改回 1,那么就会出现这样的情况:表里面已经有 id = 2 的行,而当前的自增 id 值是 1。 接下来,继续执行的其他事务就会申请到 id=2。这时,就会出现插入语句报错“主键冲突”。 而为了解决这个主键冲突,有两种方法:\n每次申请 id 之前,先判断表里面是否已经存在这个 id,如果存在,就跳过这个 id 把自增 id 的锁范围扩大,必须等到一个事务执行完成并提交,下一个事务才能再申请自增 id 很显然,上述两个方法的成本都比较高,会导致性能问题。而究其原因呢,是我们假设的这个 “允许自增 id 回退”。\n因此,InnoDB 放弃了这个设计,语句执行失败也不回退自增 id。也正是因为这样,所以才只保证了自增 id 是递增的,但不保证是连续的。\n综上,已经分析了三种自增值不连续的场景,还有第四种场景:批量插入数据。\n自增值不连续场景 4 # 对于批量插入数据的语句,MySQL 有一个批量申请自增 id 的策略:\n语句执行过程中,第一次申请自增 id,会分配 1 个; 1 个用完以后,这个语句第二次申请自增 id,会分配 2 个; 2 个用完以后,还是这个语句,第三次申请自增 id,会分配 4 个; 依此类推,同一个语句去申请自增 id,每次申请到的自增 id 个数都是上一次的两倍。 注意,这里说的批量插入数据,不是在普通的 insert 语句里面包含多个 value 值!!!,因为这类语句在申请自增 id 的时候,是可以精确计算出需要多少个 id 的,然后一次性申请,申请完成后锁就可以释放了。\n而对于 insert … select、replace …… select 和 load data 这种类型的语句来说,MySQL 并不知道到底需要申请多少 id,所以就采用了这种批量申请的策略,毕竟一个一个申请的话实在太慢了。\n举个例子,假设我们现在这个表有下面这些数据:\n我们创建一个和当前表 test_pk 有相同结构定义的表 test_pk2:\n然后使用 insert...select 往 teset_pk2 表中批量插入数据:\n可以看到,成功导入了数据。\n再来看下 test_pk2 的自增值是多少:\n如上分析,是 8 而不是 6\n具体来说,insert……select 实际上往表中插入了 5 行数据 (1 1)(2 2)(3 3)(4 4)(5 5)。但是,这五行数据是分三次申请的自增 id,结合批量申请策略,每次申请到的自增 id 个数都是上一次的两倍,所以:\n第一次申请到了一个 id:id=1 第二次被分配了两个 id:id=2 和 id=3 第三次被分配到了 4 个 id:id=4、id = 5、id = 6、id=7 由于这条语句实际只用上了 5 个 id,所以 id=6 和 id=7 就被浪费掉了。之后,再执行 insert into test_pk2 values(null,6,6),实际上插入的数据就是(8,6,6):\n小结 # 本文总结下自增值不连续的 4 个场景:\n自增初始值和自增步长设置不为 1 唯一键冲突 事务回滚 批量插入(如 insert...select 语句) "},{"id":548,"href":"/zh/docs/technology/Interview/cs-basics/network/nat/","title":"NAT 协议详解(网络层)","section":"Network","content":" 应用场景 # NAT 协议(Network Address Translation) 的应用场景如同它的名称——网络地址转换,应用于内部网到外部网的地址转换过程中。具体地说,在一个小的子网(局域网,Local Area Network,LAN)内,各主机使用的是同一个 LAN 下的 IP 地址,但在该 LAN 以外,在广域网(Wide Area Network,WAN)中,需要一个统一的 IP 地址来标识该 LAN 在整个 Internet 上的位置。\n这个场景其实不难理解。随着一个个小型办公室、家庭办公室(Small Office, Home Office, SOHO)的出现,为了管理这些 SOHO,一个个子网被设计出来,从而在整个 Internet 中的主机数量将非常庞大。如果每个主机都有一个“绝对唯一”的 IP 地址,那么 IPv4 地址的表达能力可能很快达到上限($2^{32}$)。因此,实际上,SOHO 子网中的 IP 地址是“相对的”,这在一定程度上也缓解了 IPv4 地址的分配压力。\nSOHO 子网的“代理人”,也就是和外界的窗口,通常由路由器扮演。路由器的 LAN 一侧管理着一个小子网,而它的 WAN 接口才是真正参与到 Internet 中的接口,也就有一个“绝对唯一的地址”。NAT 协议,正是在 LAN 中的主机在与 LAN 外界通信时,起到了地址转换的关键作用。\n细节 # 假设当前场景如上图。中间是一个路由器,它的右侧组织了一个 LAN,网络号为10.0.0/24。LAN 侧接口的 IP 地址为10.0.0.4,并且该子网内有至少三台主机,分别是10.0.0.1,10.0.0.2和10.0.0.3。路由器的左侧连接的是 WAN,WAN 侧接口的 IP 地址为138.76.29.7。\n首先,针对以上信息,我们有如下事实需要说明:\n路由器的右侧子网的网络号为10.0.0/24,主机号为10.0.0/8,三台主机地址,以及路由器的 LAN 侧接口地址,均由 DHCP 协议规定。而且,该 DHCP 运行在路由器内部(路由器自维护一个小 DHCP 服务器),从而为子网内提供 DHCP 服务。 路由器的 WAN 侧接口地址同样由 DHCP 协议规定,但该地址是路由器从 ISP(网络服务提供商)处获得,也就是该 DHCP 通常运行在路由器所在区域的 DHCP 服务器上。 现在,路由器内部还运行着 NAT 协议,从而为 LAN-WAN 间通信提供地址转换服务。为此,一个很重要的结构是 NAT 转换表。为了说明 NAT 的运行细节,假设有以下请求发生:\n主机10.0.0.1向 IP 地址为128.119.40.186的 Web 服务器(端口 80)发送了 HTTP 请求(如请求页面)。此时,主机10.0.0.1将随机指派一个端口,如3345,作为本次请求的源端口号,将该请求发送到路由器中(目的地址将是128.119.40.186,但会先到达10.0.0.4)。 10.0.0.4即路由器的 LAN 接口收到10.0.0.1的请求。路由器将为该请求指派一个新的源端口号,如5001,并将请求报文发送给 WAN 接口138.76.29.7。同时,在 NAT 转换表中记录一条转换记录138.76.29.7:5001——10.0.0.1:3345。 请求报文到达 WAN 接口,继续向目的主机128.119.40.186发送。 之后,将会有如下响应发生:\n主机128.119.40.186收到请求,构造响应报文,并将其发送给目的地138.76.29.7:5001。 响应报文到达路由器的 WAN 接口。路由器查询 NAT 转换表,发现138.76.29.7:5001在转换表中有记录,从而将其目的地址和目的端口转换成为10.0.0.1:3345,再发送到10.0.0.4上。 被转换的响应报文到达路由器的 LAN 接口,继而被转发至目的地10.0.0.1。 🐛 修正(参见: issue#2009):上图第四步的 Dest 值应该为 10.0.0.1:3345 而不是~~138.76.29.7:5001~~,这里笔误了。\n划重点 # 针对以上过程,有以下几个重点需要强调:\n当请求报文到达路由器,并被指定了新端口号时,由于端口号有 16 位,因此,通常来说,一个路由器管理的 LAN 中的最大主机数 $≈65500$($2^{16}$ 的地址空间),但通常 SOHO 子网内不会有如此多的主机数量。 对于目的服务器来说,从来不知道“到底是哪个主机给我发送的请求”,它只知道是来自138.76.29.7:5001的路由器转发的请求。因此,可以说,==路由器在 WAN 和 LAN 之间起到了屏蔽作用,==所有内部主机发送到外部的报文,都具有同一个 IP 地址(不同的端口号),所有外部发送到内部的报文,也都只有一个目的地(不同端口号),是经过了 NAT 转换后,外部报文才得以正确地送达内部主机。 在报文穿过路由器,发生 NAT 转换时,如果 LAN 主机 IP 已经在 NAT 转换表中注册过了,则不需要路由器新指派端口,而是直接按照转换记录穿过路由器。同理,外部报文发送至内部时也如此。 总结 NAT 协议的特点,有以下几点:\nNAT 协议通过对 WAN 屏蔽 LAN,有效地缓解了 IPv4 地址分配压力。 LAN 主机 IP 地址的变更,无需通告 WAN。 WAN 的 ISP 变更接口地址时,无需通告 LAN 内主机。 LAN 主机对 WAN 不可见,不可直接寻址,可以保证一定程度的安全性。 然而,NAT 协议由于其独特性,存在着一些争议。比如,可能你已经注意到了,==NAT 协议在 LAN 以外,标识一个内部主机时,使用的是端口号,因为 IP 地址都是相同的。==这种将端口号作为主机寻址的行为,可能会引发一些误会。此外,路由器作为网络层的设备,修改了传输层的分组内容(修改了源 IP 地址和端口号),同样是不规范的行为。但是,尽管如此,NAT 协议作为 IPv4 时代的产物,极大地方便了一些本来棘手的问题,一直被沿用至今。\n"},{"id":549,"href":"/zh/docs/technology/Interview/system-design/framework/netty/","title":"Netty常见面试题总结(付费)","section":"Framework","content":"Netty 相关的面试题为我的 知识星球(点击链接即可查看详细介绍以及加入方法)专属内容,已经整理到了 《Java 面试指北》中。\n"},{"id":550,"href":"/zh/docs/technology/Interview/database/nosql/","title":"NoSQL基础知识总结","section":"Database","content":" NoSQL 是什么? # NoSQL(Not Only SQL 的缩写)泛指非关系型的数据库,主要针对的是键值、文档以及图形类型数据存储。并且,NoSQL 数据库天生支持分布式,数据冗余和数据分片等特性,旨在提供可扩展的高可用高性能数据存储解决方案。\n一个常见的误解是 NoSQL 数据库或非关系型数据库不能很好地存储关系型数据。NoSQL 数据库可以存储关系型数据—它们与关系型数据库的存储方式不同。\nNoSQL 数据库代表:HBase、Cassandra、MongoDB、Redis。\nSQL 和 NoSQL 有什么区别? # SQL 数据库 NoSQL 数据库 数据存储模型 结构化存储,具有固定行和列的表格 非结构化存储。文档:JSON 文档,键值:键值对,宽列:包含行和动态列的表,图:节点和边 发展历程 开发于 1970 年代,重点是减少数据重复 开发于 2000 年代后期,重点是提升可扩展性,减少大规模数据的存储成本 例子 Oracle、MySQL、Microsoft SQL Server、PostgreSQL 文档:MongoDB、CouchDB,键值:Redis、DynamoDB,宽列:Cassandra、 HBase,图表:Neo4j、 Amazon Neptune、Giraph ACID 属性 提供原子性、一致性、隔离性和持久性 (ACID) 属性 通常不支持 ACID 事务,为了可扩展、高性能进行了权衡,少部分支持比如 MongoDB 。不过,MongoDB 对 ACID 事务 的支持和 MySQL 还是有所区别的。 性能 性能通常取决于磁盘子系统。要获得最佳性能,通常需要优化查询、索引和表结构。 性能通常由底层硬件集群大小、网络延迟以及调用应用程序来决定。 扩展 垂直(使用性能更强大的服务器进行扩展)、读写分离、分库分表 横向(增加服务器的方式横向扩展,通常是基于分片机制) 用途 普通企业级的项目的数据存储 用途广泛比如图数据库支持分析和遍历连接数据之间的关系、键值数据库可以处理大量数据扩展和极高的状态变化 查询语法 结构化查询语言 (SQL) 数据访问语法可能因数据库而异 NoSQL 数据库有什么优势? # NoSQL 数据库非常适合许多现代应用程序,例如移动、Web 和游戏等应用程序,它们需要灵活、可扩展、高性能和功能强大的数据库以提供卓越的用户体验。\n灵活性: NoSQL 数据库通常提供灵活的架构,以实现更快速、更多的迭代开发。灵活的数据模型使 NoSQL 数据库成为半结构化和非结构化数据的理想之选。 可扩展性: NoSQL 数据库通常被设计为通过使用分布式硬件集群来横向扩展,而不是通过添加昂贵和强大的服务器来纵向扩展。 高性能: NoSQL 数据库针对特定的数据模型和访问模式进行了优化,这与尝试使用关系数据库完成类似功能相比可实现更高的性能。 强大的功能: NoSQL 数据库提供功能强大的 API 和数据类型,专门针对其各自的数据模型而构建。 NoSQL 数据库有哪些类型? # NoSQL 数据库主要可以分为下面四种类型:\n键值:键值数据库是一种较简单的数据库,其中每个项目都包含键和值。这是极为灵活的 NoSQL 数据库类型,因为应用可以完全控制 value 字段中存储的内容,没有任何限制。Redis 和 DynanoDB 是两款非常流行的键值数据库。 文档:文档数据库中的数据被存储在类似于 JSON(JavaScript 对象表示法)对象的文档中,非常清晰直观。每个文档包含成对的字段和值。这些值通常可以是各种类型,包括字符串、数字、布尔值、数组或对象等,并且它们的结构通常与开发者在代码中使用的对象保持一致。MongoDB 就是一款非常流行的文档数据库。 图形:图形数据库旨在轻松构建和运行与高度连接的数据集一起使用的应用程序。图形数据库的典型使用案例包括社交网络、推荐引擎、欺诈检测和知识图形。Neo4j 和 Giraph 是两款非常流行的图形数据库。 宽列:宽列存储数据库非常适合需要存储大量的数据。Cassandra 和 HBase 是两款非常流行的宽列存储数据库。 下面这张图片来源于 微软的官方文档 | 关系数据与 NoSQL 数据。\n参考 # NoSQL 是什么?- MongoDB 官方文档: https://www.mongodb.com/zh-cn/nosql-explained 什么是 NoSQL? - AWS: https://aws.amazon.com/cn/nosql/ NoSQL vs. SQL Databases - MongoDB 官方文档: https://www.mongodb.com/zh-cn/nosql-explained/nosql-vs-sql "},{"id":551,"href":"/zh/docs/technology/Interview/cs-basics/network/osi-and-tcp-ip-model/","title":"OSI 和 TCP/IP 网络分层模型详解(基础)","section":"Network","content":" OSI 七层模型 # OSI 七层模型 是国际标准化组织提出一个网络分层模型,其大体结构以及每一层提供的功能如下图所示:\n每一层都专注做一件事情,并且每一层都需要使用下一层提供的功能比如传输层需要使用网络层提供的路由和寻址功能,这样传输层才知道把数据传输到哪里去。\nOSI 的七层体系结构概念清楚,理论也很完整,但是它比较复杂而且不实用,而且有些功能在多个层中重复出现。\n上面这种图可能比较抽象,再来一个比较生动的图片。下面这个图片是我在国外的一个网站上看到的,非常赞!\n既然 OSI 七层模型这么厉害,为什么干不过 TCP/IP 四 层模型呢?\n的确,OSI 七层模型当时一直被一些大公司甚至一些国家政府支持。这样的背景下,为什么会失败呢?我觉得主要有下面几方面原因:\nOSI 的专家缺乏实际经验,他们在完成 OSI 标准时缺乏商业驱动力 OSI 的协议实现起来过分复杂,而且运行效率很低 OSI 制定标准的周期太长,因而使得按 OSI 标准生产的设备无法及时进入市场(20 世纪 90 年代初期,虽然整套的 OSI 国际标准都已经制定出来,但基于 TCP/IP 的互联网已经抢先在全球相当大的范围成功运行了) OSI 的层次划分不太合理,有些功能在多个层次中重复出现。 OSI 七层模型虽然失败了,但是却提供了很多不错的理论基础。为了更好地去了解网络分层,OSI 七层模型还是非常有必要学习的。\n最后再分享一个关于 OSI 七层模型非常不错的总结图片!\nTCP/IP 四层模型 # TCP/IP 四层模型 是目前被广泛采用的一种模型,我们可以将 TCP / IP 模型看作是 OSI 七层模型的精简版本,由以下 4 层组成:\n应用层 传输层 网络层 网络接口层 需要注意的是,我们并不能将 TCP/IP 四层模型 和 OSI 七层模型完全精确地匹配起来,不过可以简单将两者对应起来,如下图所示:\n应用层(Application layer) # 应用层位于传输层之上,主要提供两个终端设备上的应用程序之间信息交换的服务,它定义了信息交换的格式,消息会交给下一层传输层来传输。 我们把应用层交互的数据单元称为报文。\n应用层协议定义了网络通信规则,对于不同的网络应用需要不同的应用层协议。在互联网中应用层协议很多,如支持 Web 应用的 HTTP 协议,支持电子邮件的 SMTP 协议等等。\n应用层常见协议:\nHTTP(Hypertext Transfer Protocol,超文本传输协议):基于 TCP 协议,是一种用于传输超文本和多媒体内容的协议,主要是为 Web 浏览器与 Web 服务器之间的通信而设计的。当我们使用浏览器浏览网页的时候,我们网页就是通过 HTTP 请求进行加载的。 SMTP(Simple Mail Transfer Protocol,简单邮件发送协议):基于 TCP 协议,是一种用于发送电子邮件的协议。注意 ⚠️:SMTP 协议只负责邮件的发送,而不是接收。要从邮件服务器接收邮件,需要使用 POP3 或 IMAP 协议。 POP3/IMAP(邮件接收协议):基于 TCP 协议,两者都是负责邮件接收的协议。IMAP 协议是比 POP3 更新的协议,它在功能和性能上都更加强大。IMAP 支持邮件搜索、标记、分类、归档等高级功能,而且可以在多个设备之间同步邮件状态。几乎所有现代电子邮件客户端和服务器都支持 IMAP。 FTP(File Transfer Protocol,文件传输协议) : 基于 TCP 协议,是一种用于在计算机之间传输文件的协议,可以屏蔽操作系统和文件存储方式。注意 ⚠️:FTP 是一种不安全的协议,因为它在传输过程中不会对数据进行加密。建议在传输敏感数据时使用更安全的协议,如 SFTP。 Telnet(远程登陆协议):基于 TCP 协议,用于通过一个终端登陆到其他服务器。Telnet 协议的最大缺点之一是所有数据(包括用户名和密码)均以明文形式发送,这有潜在的安全风险。这就是为什么如今很少使用 Telnet,而是使用一种称为 SSH 的非常安全的网络传输协议的主要原因。 SSH(Secure Shell Protocol,安全的网络传输协议):基于 TCP 协议,通过加密和认证机制实现安全的访问和文件传输等业务 RTP(Real-time Transport Protocol,实时传输协议):通常基于 UDP 协议,但也支持 TCP 协议。它提供了端到端的实时传输数据的功能,但不包含资源预留存、不保证实时传输质量,这些功能由 WebRTC 实现。 DNS(Domain Name System,域名管理系统): 基于 UDP 协议,用于解决域名和 IP 地址的映射问题。 关于这些协议的详细介绍请看 应用层常见协议总结(应用层) 这篇文章。\n传输层(Transport layer) # 传输层的主要任务就是负责向两台终端设备进程之间的通信提供通用的数据传输服务。 应用进程利用该服务传送应用层报文。“通用的”是指并不针对某一个特定的网络应用,而是多种应用可以使用同一个运输层服务。\n传输层常见协议:\nTCP(Transmission Control Protocol,传输控制协议 ):提供 面向连接 的,可靠 的数据传输服务。 UDP(User Datagram Protocol,用户数据协议):提供 无连接 的,尽最大努力 的数据传输服务(不保证数据传输的可靠性),简单高效。 网络层(Network layer) # 网络层负责为分组交换网上的不同主机提供通信服务。 在发送数据时,网络层把运输层产生的报文段或用户数据报封装成分组和包进行传送。在 TCP/IP 体系结构中,由于网络层使用 IP 协议,因此分组也叫 IP 数据报,简称数据报。\n⚠️ 注意:不要把运输层的“用户数据报 UDP”和网络层的“IP 数据报”弄混。\n网络层的还有一个任务就是选择合适的路由,使源主机运输层所传下来的分组,能通过网络层中的路由器找到目的主机。\n这里强调指出,网络层中的“网络”二字已经不是我们通常谈到的具体网络,而是指计算机网络体系结构模型中第三层的名称。\n互联网是由大量的异构(heterogeneous)网络通过路由器(router)相互连接起来的。互联网使用的网络层协议是无连接的网际协议(Internet Protocol)和许多路由选择协议,因此互联网的网络层也叫做 网际层 或 IP 层。\n网络层常见协议:\nIP(Internet Protocol,网际协议):TCP/IP 协议中最重要的协议之一,主要作用是定义数据包的格式、对数据包进行路由和寻址,以便它们可以跨网络传播并到达正确的目的地。目前 IP 协议主要分为两种,一种是过去的 IPv4,另一种是较新的 IPv6,目前这两种协议都在使用,但后者已经被提议来取代前者。 ARP(Address Resolution Protocol,地址解析协议):ARP 协议解决的是网络层地址和链路层地址之间的转换问题。因为一个 IP 数据报在物理上传输的过程中,总是需要知道下一跳(物理上的下一个目的地)该去往何处,但 IP 地址属于逻辑地址,而 MAC 地址才是物理地址,ARP 协议解决了 IP 地址转 MAC 地址的一些问题。 ICMP(Internet Control Message Protocol,互联网控制报文协议):一种用于传输网络状态和错误消息的协议,常用于网络诊断和故障排除。例如,Ping 工具就使用了 ICMP 协议来测试网络连通性。 NAT(Network Address Translation,网络地址转换协议):NAT 协议的应用场景如同它的名称——网络地址转换,应用于内部网到外部网的地址转换过程中。具体地说,在一个小的子网(局域网,LAN)内,各主机使用的是同一个 LAN 下的 IP 地址,但在该 LAN 以外,在广域网(WAN)中,需要一个统一的 IP 地址来标识该 LAN 在整个 Internet 上的位置。 OSPF(Open Shortest Path First,开放式最短路径优先) ):一种内部网关协议(Interior Gateway Protocol,IGP),也是广泛使用的一种动态路由协议,基于链路状态算法,考虑了链路的带宽、延迟等因素来选择最佳路径。 RIP(Routing Information Protocol,路由信息协议):一种内部网关协议(Interior Gateway Protocol,IGP),也是一种动态路由协议,基于距离向量算法,使用固定的跳数作为度量标准,选择跳数最少的路径作为最佳路径。 BGP(Border Gateway Protocol,边界网关协议):一种用来在路由选择域之间交换网络层可达性信息(Network Layer Reachability Information,NLRI)的路由选择协议,具有高度的灵活性和可扩展性。 网络接口层(Network interface layer) # 我们可以把网络接口层看作是数据链路层和物理层的合体。\n数据链路层(data link layer)通常简称为链路层( 两台主机之间的数据传输,总是在一段一段的链路上传送的)。数据链路层的作用是将网络层交下来的 IP 数据报组装成帧,在两个相邻节点间的链路上传送帧。每一帧包括数据和必要的控制信息(如同步信息,地址信息,差错控制等)。 物理层的作用是实现相邻计算机节点之间比特流的透明传送,尽可能屏蔽掉具体传输介质和物理设备的差异 网络接口层重要功能和协议如下图所示:\n总结 # 简单总结一下每一层包含的协议和核心技术:\n应用层协议 :\nHTTP(Hypertext Transfer Protocol,超文本传输协议) SMTP(Simple Mail Transfer Protocol,简单邮件发送协议) POP3/IMAP(邮件接收协议) FTP(File Transfer Protocol,文件传输协议) Telnet(远程登陆协议) SSH(Secure Shell Protocol,安全的网络传输协议) RTP(Real-time Transport Protocol,实时传输协议) DNS(Domain Name System,域名管理系统) …… 传输层协议 :\nTCP 协议 报文段结构 可靠数据传输 流量控制 拥塞控制 UDP 协议 报文段结构 RDT(可靠数据传输协议) 网络层协议 :\nIP(Internet Protocol,网际协议) ARP(Address Resolution Protocol,地址解析协议) ICMP 协议(控制报文协议,用于发送控制消息) NAT(Network Address Translation,网络地址转换协议) OSPF(Open Shortest Path First,开放式最短路径优先) RIP(Routing Information Protocol,路由信息协议) BGP(Border Gateway Protocol,边界网关协议) …… 网络接口层 :\n差错检测技术 多路访问协议(信道复用技术) CSMA/CD 协议 MAC 协议 以太网技术 …… 网络分层的原因 # 在这篇文章的最后,我想聊聊:“为什么网络要分层?”。\n说到分层,我们先从我们平时使用框架开发一个后台程序来说,我们往往会按照每一层做不同的事情的原则将系统分为三层(复杂的系统分层会更多):\nRepository(数据库操作) Service(业务操作) Controller(前后端数据交互) 复杂的系统需要分层,因为每一层都需要专注于一类事情。网络分层的原因也是一样,每一层只专注于做一类事情。\n好了,再来说回:“为什么网络要分层?”。我觉得主要有 3 方面的原因:\n各层之间相互独立:各层之间相互独立,各层之间不需要关心其他层是如何实现的,只需要知道自己如何调用下层提供好的功能就可以了(可以简单理解为接口调用)。这个和我们对开发时系统进行分层是一个道理。 提高了整体灵活性:每一层都可以使用最适合的技术来实现,你只需要保证你提供的功能以及暴露的接口的规则没有改变就行了。这个和我们平时开发系统的时候要求的高内聚、低耦合的原则也是可以对应上的。 大问题化小:分层可以将复杂的网络问题分解为许多比较小的、界线比较清晰简单的小问题来处理和解决。这样使得复杂的计算机网络系统变得易于设计,实现和标准化。 这个和我们平时开发的时候,一般会将系统功能分解,然后将复杂的问题分解为容易理解的更小的问题是相对应的,这些较小的问题具有更好的边界(目标和接口)定义。 我想到了计算机世界非常非常有名的一句话,这里分享一下:\n计算机科学领域的任何问题都可以通过增加一个间接的中间层来解决,计算机整个体系从上到下都是按照严格的层次结构设计的。\n参考 # TCP/IP model vs OSI model: https://fiberbit.com.tw/tcpip-model-vs-osi-model/ Data Encapsulation and the TCP/IP Protocol Stack: https://docs.oracle.com/cd/E19683-01/806-4075/ipov-32/index.html "},{"id":552,"href":"/zh/docs/technology/Interview/distributed-system/protocol/paxos-algorithm/","title":"Paxos 算法详解","section":"Protocol","content":" 背景 # Paxos 算法是 Leslie Lamport( 莱斯利·兰伯特)在 1990 年提出了一种分布式系统 共识 算法。这也是第一个被证明完备的共识算法(前提是不存在拜占庭将军问题,也就是没有恶意节点)。\n为了介绍 Paxos 算法,兰伯特专门写了一篇幽默风趣的论文。在这篇论文中,他虚拟了一个叫做 Paxos 的希腊城邦来更形象化地介绍 Paxos 算法。\n不过,审稿人并不认可这篇论文的幽默。于是,他们就给兰伯特说:“如果你想要成功发表这篇论文的话,必须删除所有 Paxos 相关的故事背景”。兰伯特一听就不开心了:“我凭什么修改啊,你们这些审稿人就是缺乏幽默细胞,发不了就不发了呗!”。\n于是乎,提出 Paxos 算法的那篇论文在当时并没有被成功发表。\n直到 1998 年,系统研究中心 (Systems Research Center,SRC)的两个技术研究员需要找一些合适的分布式算法来服务他们正在构建的分布式系统,Paxos 算法刚好可以解决他们的部分需求。因此,兰伯特就把论文发给了他们。在看了论文之后,这俩大佬觉得论文还是挺不错的。于是,兰伯特在 1998 年重新发表论文 《The Part-Time Parliament》。\n论文发表之后,各路学者直呼看不懂,言语中还略显调侃之意。这谁忍得了,在 2001 年的时候,兰伯特专门又写了一篇 《Paxos Made Simple》 的论文来简化对 Paxos 的介绍,主要讲述两阶段共识协议部分,顺便还不忘嘲讽一下这群学者。\n《Paxos Made Simple》这篇论文就 14 页,相比于 《The Part-Time Parliament》的 33 页精简了不少。最关键的是这篇论文的摘要就一句话:\nThe Paxos algorithm, when presented in plain English, is very simple.\n翻译过来的意思大概就是:当我用无修饰的英文来描述时,Paxos 算法真心简单!\n有没有感觉到来自兰伯特大佬满满地嘲讽的味道?\n介绍 # Paxos 算法是第一个被证明完备的分布式系统共识算法。共识算法的作用是让分布式系统中的多个节点之间对某个提案(Proposal)达成一致的看法。提案的含义在分布式系统中十分宽泛,像哪一个节点是 Leader 节点、多个事件发生的顺序等等都可以是一个提案。\n兰伯特当时提出的 Paxos 算法主要包含 2 个部分:\nBasic Paxos 算法:描述的是多节点之间如何就某个值(提案 Value)达成共识。 Multi-Paxos 思想:描述的是执行多个 Basic Paxos 实例,就一系列值达成共识。Multi-Paxos 说白了就是执行多次 Basic Paxos ,核心还是 Basic Paxos 。 由于 Paxos 算法在国际上被公认的非常难以理解和实现,因此不断有人尝试简化这一算法。到了 2013 年才诞生了一个比 Paxos 算法更易理解和实现的共识算法— Raft 算法 。更具体点来说,Raft 是 Multi-Paxos 的一个变种,其简化了 Multi-Paxos 的思想,变得更容易被理解以及工程实现。\n针对没有恶意节点的情况,除了 Raft 算法之外,当前最常用的一些共识算法比如 ZAB 协议、 Fast Paxos 算法都是基于 Paxos 算法改进的。\n针对存在恶意节点的情况,一般使用的是 工作量证明(POW,Proof-of-Work)、 权益证明(PoS,Proof-of-Stake ) 等共识算法。这类共识算法最典型的应用就是区块链,就比如说前段时间以太坊官方宣布其共识机制正在从工作量证明(PoW)转变为权益证明(PoS)。\n区块链系统使用的共识算法需要解决的核心问题是 拜占庭将军问题 ,这和我们日常接触到的 ZooKeeper、Etcd、Consul 等分布式中间件不太一样。\n下面我们来对 Paxos 算法的定义做一个总结:\nPaxos 算法是兰伯特在 1990 年提出了一种分布式系统共识算法。 兰伯特当时提出的 Paxos 算法主要包含 2 个部分: Basic Paxos 算法和 Multi-Paxos 思想。 Raft 算法、ZAB 协议、 Fast Paxos 算法都是基于 Paxos 算法改进而来。 Basic Paxos 算法 # Basic Paxos 中存在 3 个重要的角色:\n提议者(Proposer):也可以叫做协调者(coordinator),提议者负责接受客户端的请求并发起提案。提案信息通常包括提案编号 (Proposal ID) 和提议的值 (Value)。 接受者(Acceptor):也可以叫做投票员(voter),负责对提议者的提案进行投票,同时需要记住自己的投票历史; 学习者(Learner):如果有超过半数接受者就某个提议达成了共识,那么学习者就需要接受这个提议,并就该提议作出运算,然后将运算结果返回给客户端。 为了减少实现该算法所需的节点数,一个节点可以身兼多个角色。并且,一个提案被选定需要被半数以上的 Acceptor 接受。这样的话,Basic Paxos 算法还具备容错性,在少于一半的节点出现故障时,集群仍能正常工作。\nMulti Paxos 思想 # Basic Paxos 算法的仅能就单个值达成共识,为了能够对一系列的值达成共识,我们需要用到 Multi Paxos 思想。\n⚠️注意:Multi-Paxos 只是一种思想,这种思想的核心就是通过多个 Basic Paxos 实例就一系列值达成共识。也就是说,Basic Paxos 是 Multi-Paxos 思想的核心,Multi-Paxos 就是多执行几次 Basic Paxos。\n由于兰伯特提到的 Multi-Paxos 思想缺少代码实现的必要细节(比如怎么选举领导者),所以在理解和实现上比较困难。\n不过,也不需要担心,我们并不需要自己实现基于 Multi-Paxos 思想的共识算法,业界已经有了比较出名的实现。像 Raft 算法就是 Multi-Paxos 的一个变种,其简化了 Multi-Paxos 的思想,变得更容易被理解以及工程实现,实际项目中可以优先考虑 Raft 算法。\n参考 # https://zh.wikipedia.org/wiki/Paxos 分布式系统中的一致性与共识算法: http://www.xuyasong.com/?p=1970 "},{"id":553,"href":"/zh/docs/technology/Interview/java/collection/priorityqueue-source-code/","title":"PriorityQueue 源码分析(付费)","section":"Collection","content":"PriorityQueue 源码分析 为我的 知识星球(点击链接即可查看详细介绍以及加入方法)专属内容,已经整理到了 《Java 必读源码系列》中。\n"},{"id":554,"href":"/zh/docs/technology/Interview/high-performance/message-queue/rabbitmq-questions/","title":"RabbitMQ常见问题总结","section":"High Performance","content":" 本篇文章由 JavaGuide 收集自网络,原出处不明。\nRabbitMQ 是什么? # RabbitMQ 是一个在 AMQP(Advanced Message Queuing Protocol )基础上实现的,可复用的企业消息系统。它可以用于大型软件系统各个模块之间的高效通信,支持高并发,支持可扩展。它支持多种客户端如:Python、Ruby、.NET、Java、JMS、C、PHP、ActionScript、XMPP、STOMP 等,支持 AJAX,持久化,用于在分布式系统中存储转发消息,在易用性、扩展性、高可用性等方面表现不俗。\nRabbitMQ 是使用 Erlang 编写的一个开源的消息队列,本身支持很多的协议:AMQP,XMPP, SMTP, STOMP,也正是如此,使的它变的非常重量级,更适合于企业级的开发。它同时实现了一个 Broker 构架,这意味着消息在发送给客户端时先在中心队列排队,对路由(Routing)、负载均衡(Load balance)或者数据持久化都有很好的支持。\nPS:也可能直接问什么是消息队列?消息队列就是一个使用队列来通信的组件。\nRabbitMQ 特点? # 可靠性: RabbitMQ 使用一些机制来保证可靠性, 如持久化、传输确认及发布确认等。 灵活的路由 : 在消息进入队列之前,通过交换器来路由消息。对于典型的路由功能, RabbitMQ 己经提供了一些内置的交换器来实现。针对更复杂的路由功能,可以将多个交换器绑定在一起, 也可以通过插件机制来实现自己的交换器。 扩展性: 多个 RabbitMQ 节点可以组成一个集群,也可以根据实际业务情况动态地扩展 集群中节点。 高可用性 : 队列可以在集群中的机器上设置镜像,使得在部分节点出现问题的情况下队 列仍然可用。 多种协议: RabbitMQ 除了原生支持 AMQP 协议,还支持 STOMP, MQTT 等多种消息 中间件协议。 多语言客户端 :RabbitMQ 几乎支持所有常用语言,比如 Java、 Python、 Ruby、 PHP、 C#、 JavaScript 等。 管理界面 : RabbitMQ 提供了一个易用的用户界面,使得用户可以监控和管理消息、集 群中的节点等。 插件机制 : RabbitMQ 提供了许多插件 , 以实现从多方面进行扩展,当然也可以编写自 己的插件。 RabbitMQ 核心概念? # RabbitMQ 整体上是一个生产者与消费者模型,主要负责接收、存储和转发消息。可以把消息传递的过程想象成:当你将一个包裹送到邮局,邮局会暂存并最终将邮件通过邮递员送到收件人的手上,RabbitMQ 就好比由邮局、邮箱和邮递员组成的一个系统。从计算机术语层面来说,RabbitMQ 模型更像是一种交换机模型。\nRabbitMQ 的整体模型架构如下:\n下面我会一一介绍上图中的一些概念。\nProducer(生产者) 和 Consumer(消费者) # Producer(生产者) :生产消息的一方(邮件投递者) Consumer(消费者) :消费消息的一方(邮件收件人) 消息一般由 2 部分组成:消息头(或者说是标签 Label)和 消息体。消息体也可以称为 payLoad ,消息体是不透明的,而消息头则由一系列的可选属性组成,这些属性包括 routing-key(路由键)、priority(相对于其他消息的优先权)、delivery-mode(指出该消息可能需要持久性存储)等。生产者把消息交由 RabbitMQ 后,RabbitMQ 会根据消息头把消息发送给感兴趣的 Consumer(消费者)。\nExchange(交换器) # 在 RabbitMQ 中,消息并不是直接被投递到 Queue(消息队列) 中的,中间还必须经过 Exchange(交换器) 这一层,Exchange(交换器) 会把我们的消息分配到对应的 Queue(消息队列) 中。\nExchange(交换器) 用来接收生产者发送的消息并将这些消息路由给服务器中的队列中,如果路由不到,或许会返回给 Producer(生产者) ,或许会被直接丢弃掉 。这里可以将 RabbitMQ 中的交换器看作一个简单的实体。\nRabbitMQ 的 Exchange(交换器) 有 4 种类型,不同的类型对应着不同的路由策略:direct(默认),fanout, topic, 和 headers,不同类型的 Exchange 转发消息的策略有所区别。这个会在介绍 Exchange Types(交换器类型) 的时候介绍到。\nExchange(交换器) 示意图如下:\n生产者将消息发给交换器的时候,一般会指定一个 RoutingKey(路由键),用来指定这个消息的路由规则,而这个 RoutingKey 需要与交换器类型和绑定键(BindingKey)联合使用才能最终生效。\nRabbitMQ 中通过 Binding(绑定) 将 Exchange(交换器) 与 Queue(消息队列) 关联起来,在绑定的时候一般会指定一个 BindingKey(绑定建) ,这样 RabbitMQ 就知道如何正确将消息路由到队列了,如下图所示。一个绑定就是基于路由键将交换器和消息队列连接起来的路由规则,所以可以将交换器理解成一个由绑定构成的路由表。Exchange 和 Queue 的绑定可以是多对多的关系。\nBinding(绑定) 示意图:\n生产者将消息发送给交换器时,需要一个 RoutingKey,当 BindingKey 和 RoutingKey 相匹配时,消息会被路由到对应的队列中。在绑定多个队列到同一个交换器的时候,这些绑定允许使用相同的 BindingKey。BindingKey 并不是在所有的情况下都生效,它依赖于交换器类型,比如 fanout 类型的交换器就会无视,而是将消息路由到所有绑定到该交换器的队列中。\nQueue(消息队列) # Queue(消息队列) 用来保存消息直到发送给消费者。它是消息的容器,也是消息的终点。一个消息可投入一个或多个队列。消息一直在队列里面,等待消费者连接到这个队列将其取走。\nRabbitMQ 中消息只能存储在 队列 中,这一点和 Kafka 这种消息中间件相反。Kafka 将消息存储在 topic(主题) 这个逻辑层面,而相对应的队列逻辑只是 topic 实际存储文件中的位移标识。 RabbitMQ 的生产者生产消息并最终投递到队列中,消费者可以从队列中获取消息并消费。\n多个消费者可以订阅同一个队列,这时队列中的消息会被平均分摊(Round-Robin,即轮询)给多个消费者进行处理,而不是每个消费者都收到所有的消息并处理,这样避免消息被重复消费。\nRabbitMQ 不支持队列层面的广播消费,如果有广播消费的需求,需要在其上进行二次开发,这样会很麻烦,不建议这样做。\nBroker(消息中间件的服务节点) # 对于 RabbitMQ 来说,一个 RabbitMQ Broker 可以简单地看作一个 RabbitMQ 服务节点,或者 RabbitMQ 服务实例。大多数情况下也可以将一个 RabbitMQ Broker 看作一台 RabbitMQ 服务器。\n下图展示了生产者将消息存入 RabbitMQ Broker,以及消费者从 Broker 中消费数据的整个流程。\n这样图 1 中的一些关于 RabbitMQ 的基本概念我们就介绍完毕了,下面再来介绍一下 Exchange Types(交换器类型) 。\nExchange Types(交换器类型) # RabbitMQ 常用的 Exchange Type 有 fanout、direct、topic、headers 这四种(AMQP 规范里还提到两种 Exchange Type,分别为 system 与 自定义,这里不予以描述)。\n1、fanout\nfanout 类型的 Exchange 路由规则非常简单,它会把所有发送到该 Exchange 的消息路由到所有与它绑定的 Queue 中,不需要做任何判断操作,所以 fanout 类型是所有的交换机类型里面速度最快的。fanout 类型常用来广播消息。\n2、direct\ndirect 类型的 Exchange 路由规则也很简单,它会把消息路由到那些 Bindingkey 与 RoutingKey 完全匹配的 Queue 中。\n以上图为例,如果发送消息的时候设置路由键为“warning”,那么消息会路由到 Queue1 和 Queue2。如果在发送消息的时候设置路由键为\u0026quot;Info”或者\u0026quot;debug”,消息只会路由到 Queue2。如果以其他的路由键发送消息,则消息不会路由到这两个队列中。\ndirect 类型常用在处理有优先级的任务,根据任务的优先级把消息发送到对应的队列,这样可以指派更多的资源去处理高优先级的队列。\n3、topic\n前面讲到 direct 类型的交换器路由规则是完全匹配 BindingKey 和 RoutingKey ,但是这种严格的匹配方式在很多情况下不能满足实际业务的需求。topic 类型的交换器在匹配规则上进行了扩展,它与 direct 类型的交换器相似,也是将消息路由到 BindingKey 和 RoutingKey 相匹配的队列中,但这里的匹配规则有些不同,它约定:\nRoutingKey 为一个点号“.”分隔的字符串(被点号“.”分隔开的每一段独立的字符串称为一个单词),如 “com.rabbitmq.client”、“java.util.concurrent”、“com.hidden.client”; BindingKey 和 RoutingKey 一样也是点号“.”分隔的字符串; BindingKey 中可以存在两种特殊字符串“*”和“#”,用于做模糊匹配,其中“*”用于匹配一个单词,“#”用于匹配多个单词(可以是零个)。 以上图为例:\n路由键为 “com.rabbitmq.client” 的消息会同时路由到 Queue1 和 Queue2; 路由键为 “com.hidden.client” 的消息只会路由到 Queue2 中; 路由键为 “com.hidden.demo” 的消息只会路由到 Queue2 中; 路由键为 “java.rabbitmq.demo” 的消息只会路由到 Queue1 中; 路由键为 “java.util.concurrent” 的消息将会被丢弃或者返回给生产者(需要设置 mandatory 参数),因为它没有匹配任何路由键。 4、headers(不推荐)\nheaders 类型的交换器不依赖于路由键的匹配规则来路由消息,而是根据发送的消息内容中的 headers 属性进行匹配。在绑定队列和交换器时指定一组键值对,当发送消息到交换器时,RabbitMQ 会获取到该消息的 headers(也是一个键值对的形式),对比其中的键值对是否完全匹配队列和交换器绑定时指定的键值对,如果完全匹配则消息会路由到该队列,否则不会路由到该队列。headers 类型的交换器性能会很差,而且也不实用,基本上不会看到它的存在。\nAMQP 是什么? # RabbitMQ 就是 AMQP 协议的 Erlang 的实现(当然 RabbitMQ 还支持 STOMP2、 MQTT3 等协议 ) AMQP 的模型架构 和 RabbitMQ 的模型架构是一样的,生产者将消息发送给交换器,交换器和队列绑定 。\nRabbitMQ 中的交换器、交换器类型、队列、绑定、路由键等都是遵循的 AMQP 协议中相 应的概念。目前 RabbitMQ 最新版本默认支持的是 AMQP 0-9-1。\nAMQP 协议的三层:\nModule Layer:协议最高层,主要定义了一些客户端调用的命令,客户端可以用这些命令实现自己的业务逻辑。 Session Layer:中间层,主要负责客户端命令发送给服务器,再将服务端应答返回客户端,提供可靠性同步机制和错误处理。 TransportLayer:最底层,主要传输二进制数据流,提供帧的处理、信道复用、错误检测和数据表示等。 AMQP 模型的三大组件:\n交换器 (Exchange):消息代理服务器中用于把消息路由到队列的组件。 队列 (Queue):用来存储消息的数据结构,位于硬盘或内存中。 绑定 (Binding):一套规则,告知交换器消息应该将消息投递给哪个队列。 说说生产者 Producer 和消费者 Consumer? # 生产者 :\n消息生产者,就是投递消息的一方。 消息一般包含两个部分:消息体(payload)和标签(Label)。 消费者:\n消费消息,也就是接收消息的一方。 消费者连接到 RabbitMQ 服务器,并订阅到队列上。消费消息时只消费消息体,丢弃标签。 说说 Broker 服务节点、Queue 队列、Exchange 交换器? # Broker:可以看做 RabbitMQ 的服务节点。一般情况下一个 Broker 可以看做一个 RabbitMQ 服务器。 Queue:RabbitMQ 的内部对象,用于存储消息。多个消费者可以订阅同一队列,这时队列中的消息会被平摊(轮询)给多个消费者进行处理。 Exchange:生产者将消息发送到交换器,由交换器将消息路由到一个或者多个队列中。当路由不到时,或返回给生产者或直接丢弃。 什么是死信队列?如何导致的? # DLX,全称为 Dead-Letter-Exchange,死信交换器,死信邮箱。当消息在一个队列中变成死信 (dead message) 之后,它能被重新发送到另一个交换器中,这个交换器就是 DLX,绑定 DLX 的队列就称之为死信队列。\n导致的死信的几种原因:\n消息被拒(Basic.Reject /Basic.Nack) 且 requeue = false。 消息 TTL 过期。 队列满了,无法再添加。 什么是延迟队列?RabbitMQ 怎么实现延迟队列? # 延迟队列指的是存储对应的延迟消息,消息被发送以后,并不想让消费者立刻拿到消息,而是等待特定时间后,消费者才能拿到这个消息进行消费。\nRabbitMQ 本身是没有延迟队列的,要实现延迟消息,一般有两种方式:\n通过 RabbitMQ 本身队列的特性来实现,需要使用 RabbitMQ 的死信交换机(Exchange)和消息的存活时间 TTL(Time To Live)。 在 RabbitMQ 3.5.7 及以上的版本提供了一个插件(rabbitmq-delayed-message-exchange)来实现延迟队列功能。同时,插件依赖 Erlang/OPT 18.0 及以上。 也就是说,AMQP 协议以及 RabbitMQ 本身没有直接支持延迟队列的功能,但是可以通过 TTL 和 DLX 模拟出延迟队列的功能。\n什么是优先级队列? # RabbitMQ 自 V3.5.0 有优先级队列实现,优先级高的队列会先被消费。\n可以通过x-max-priority参数来实现优先级队列。不过,当消费速度大于生产速度且 Broker 没有堆积的情况下,优先级显得没有意义。\nRabbitMQ 有哪些工作模式? # 简单模式 work 工作模式 pub/sub 发布订阅模式 Routing 路由模式 Topic 主题模式 RabbitMQ 消息怎么传输? # 由于 TCP 链接的创建和销毁开销较大,且并发数受系统资源限制,会造成性能瓶颈,所以 RabbitMQ 使用信道的方式来传输数据。信道(Channel)是生产者、消费者与 RabbitMQ 通信的渠道,信道是建立在 TCP 链接上的虚拟链接,且每条 TCP 链接上的信道数量没有限制。就是说 RabbitMQ 在一条 TCP 链接上建立成百上千个信道来达到多个线程处理,这个 TCP 被多个线程共享,每个信道在 RabbitMQ 都有唯一的 ID,保证了信道私有性,每个信道对应一个线程使用。\n如何保证消息的可靠性? # 消息到 MQ 的过程中搞丢,MQ 自己搞丢,MQ 到消费过程中搞丢。\n生产者到 RabbitMQ:事务机制和 Confirm 机制,注意:事务机制和 Confirm 机制是互斥的,两者不能共存,会导致 RabbitMQ 报错。 RabbitMQ 自身:持久化、集群、普通模式、镜像模式。 RabbitMQ 到消费者:basicAck 机制、死信队列、消息补偿机制。 如何保证 RabbitMQ 消息的顺序性? # 拆分多个 queue(消息队列),每个 queue(消息队列) 一个 consumer(消费者),就是多一些 queue (消息队列)而已,确实是麻烦点; 或者就一个 queue (消息队列)但是对应一个 consumer(消费者),然后这个 consumer(消费者)内部用内存队列做排队,然后分发给底层不同的 worker 来处理。 如何保证 RabbitMQ 高可用的? # RabbitMQ 是比较有代表性的,因为是基于主从(非分布式)做高可用性的,我们就以 RabbitMQ 为例子讲解第一种 MQ 的高可用性怎么实现。RabbitMQ 有三种模式:单机模式、普通集群模式、镜像集群模式。\n单机模式\nDemo 级别的,一般就是你本地启动了玩玩儿的?,没人生产用单机模式。\n普通集群模式\n意思就是在多台机器上启动多个 RabbitMQ 实例,每个机器启动一个。你创建的 queue,只会放在一个 RabbitMQ 实例上,但是每个实例都同步 queue 的元数据(元数据可以认为是 queue 的一些配置信息,通过元数据,可以找到 queue 所在实例)。\n你消费的时候,实际上如果连接到了另外一个实例,那么那个实例会从 queue 所在实例上拉取数据过来。这方案主要是提高吞吐量的,就是说让集群中多个节点来服务某个 queue 的读写操作。\n镜像集群模式\n这种模式,才是所谓的 RabbitMQ 的高可用模式。跟普通集群模式不一样的是,在镜像集群模式下,你创建的 queue,无论元数据还是 queue 里的消息都会存在于多个实例上,就是说,每个 RabbitMQ 节点都有这个 queue 的一个完整镜像,包含 queue 的全部数据的意思。然后每次你写消息到 queue 的时候,都会自动把消息同步到多个实例的 queue 上。RabbitMQ 有很好的管理控制台,就是在后台新增一个策略,这个策略是镜像集群模式的策略,指定的时候是可以要求数据同步到所有节点的,也可以要求同步到指定数量的节点,再次创建 queue 的时候,应用这个策略,就会自动将数据同步到其他的节点上去了。\n这样的好处在于,你任何一个机器宕机了,没事儿,其它机器(节点)还包含了这个 queue 的完整数据,别的 consumer 都可以到其它节点上去消费数据。坏处在于,第一,这个性能开销也太大了吧,消息需要同步到所有机器上,导致网络带宽压力和消耗很重!RabbitMQ 一个 queue 的数据都是放在一个节点里的,镜像集群下,也是每个节点都放这个 queue 的完整数据。\n如何解决消息队列的延时以及过期失效问题? # RabbtiMQ 是可以设置过期时间的,也就是 TTL。如果消息在 queue 中积压超过一定的时间就会被 RabbitMQ 给清理掉,这个数据就没了。那这就是第二个坑了。这就不是说数据会大量积压在 mq 里,而是大量的数据会直接搞丢。我们可以采取一个方案,就是批量重导,这个我们之前线上也有类似的场景干过。就是大量积压的时候,我们当时就直接丢弃数据了,然后等过了高峰期以后,比如大家一起喝咖啡熬夜到晚上 12 点以后,用户都睡觉了。这个时候我们就开始写程序,将丢失的那批数据,写个临时程序,一点一点的查出来,然后重新灌入 mq 里面去,把白天丢的数据给他补回来。也只能是这样了。假设 1 万个订单积压在 mq 里面,没有处理,其中 1000 个订单都丢了,你只能手动写程序把那 1000 个订单给查出来,手动发到 mq 里去再补一次。\n"},{"id":555,"href":"/zh/docs/technology/Interview/distributed-system/protocol/raft-algorithm/","title":"Raft 算法详解","section":"Protocol","content":" 本文由 SnailClimb 和 Xieqijun 共同完成。\n1 背景 # 当今的数据中心和应用程序在高度动态的环境中运行,为了应对高度动态的环境,它们通过额外的服务器进行横向扩展,并且根据需求进行扩展和收缩。同时,服务器和网络故障也很常见。\n因此,系统必须在正常操作期间处理服务器的上下线。它们必须对变故做出反应并在几秒钟内自动适应;对客户来说的话,明显的中断通常是不可接受的。\n幸运的是,分布式共识可以帮助应对这些挑战。\n1.1 拜占庭将军 # 在介绍共识算法之前,先介绍一个简化版拜占庭将军的例子来帮助理解共识算法。\n假设多位拜占庭将军中没有叛军,信使的信息可靠但有可能被暗杀的情况下,将军们如何达成是否要进攻的一致性决定?\n解决方案大致可以理解成:先在所有的将军中选出一个大将军,用来做出所有的决定。\n举例如下:假如现在一共有 3 个将军 A,B 和 C,每个将军都有一个随机时间的倒计时器,倒计时一结束,这个将军就把自己当成大将军候选人,然后派信使传递选举投票的信息给将军 B 和 C,如果将军 B 和 C 还没有把自己当作候选人(自己的倒计时还没有结束),并且没有把选举票投给其他人,它们就会把票投给将军 A,信使回到将军 A 时,将军 A 知道自己收到了足够的票数,成为大将军。在有了大将军之后,是否需要进攻就由大将军 A 决定,然后再去派信使通知另外两个将军,自己已经成为了大将军。如果一段时间还没收到将军 B 和 C 的回复(信使可能会被暗杀),那就再重派一个信使,直到收到回复。\n1.2 共识算法 # 共识是可容错系统中的一个基本问题:即使面对故障,服务器也可以在共享状态上达成一致。\n共识算法允许一组节点像一个整体一样一起工作,即使其中的一些节点出现故障也能够继续工作下去,其正确性主要是源于复制状态机的性质:一组Server的状态机计算相同状态的副本,即使有一部分的Server宕机了它们仍然能够继续运行。\n图-1 复制状态机架构\n一般通过使用复制日志来实现复制状态机。每个Server存储着一份包括命令序列的日志文件,状态机会按顺序执行这些命令。因为每个日志包含相同的命令,并且顺序也相同,所以每个状态机处理相同的命令序列。由于状态机是确定性的,所以处理相同的状态,得到相同的输出。\n因此共识算法的工作就是保持复制日志的一致性。服务器上的共识模块从客户端接收命令并将它们添加到日志中。它与其他服务器上的共识模块通信,以确保即使某些服务器发生故障。每个日志最终包含相同顺序的请求。一旦命令被正确地复制,它们就被称为已提交。每个服务器的状态机按照日志顺序处理已提交的命令,并将输出返回给客户端,因此,这些服务器形成了一个单一的、高度可靠的状态机。\n适用于实际系统的共识算法通常具有以下特性:\n安全。确保在非拜占庭条件(也就是上文中提到的简易版拜占庭)下的安全性,包括网络延迟、分区、包丢失、复制和重新排序。\n高可用。只要大多数服务器都是可操作的,并且可以相互通信,也可以与客户端进行通信,那么这些服务器就可以看作完全功能可用的。因此,一个典型的由五台服务器组成的集群可以容忍任何两台服务器端故障。假设服务器因停止而发生故障;它们稍后可能会从稳定存储上的状态中恢复并重新加入集群。\n一致性不依赖时序。错误的时钟和极端的消息延迟,在最坏的情况下也只会造成可用性问题,而不会产生一致性问题。\n在集群中大多数服务器响应,命令就可以完成,不会被少数运行缓慢的服务器来影响整体系统性能。\n2 基础 # 2.1 节点类型 # 一个 Raft 集群包括若干服务器,以典型的 5 服务器集群举例。在任意的时间,每个服务器一定会处于以下三个状态中的一个:\nLeader:负责发起心跳,响应客户端,创建日志,同步日志。 Candidate:Leader 选举过程中的临时角色,由 Follower 转化而来,发起投票参与竞选。 Follower:接受 Leader 的心跳和日志同步数据,投票给 Candidate。 在正常的情况下,只有一个服务器是 Leader,剩下的服务器是 Follower。Follower 是被动的,它们不会发送任何请求,只是响应来自 Leader 和 Candidate 的请求。\n图-2:服务器的状态\n2.2 任期 # 图-3:任期\n如图 3 所示,raft 算法将时间划分为任意长度的任期(term),任期用连续的数字表示,看作当前 term 号。每一个任期的开始都是一次选举,在选举开始时,一个或多个 Candidate 会尝试成为 Leader。如果一个 Candidate 赢得了选举,它就会在该任期内担任 Leader。如果没有选出 Leader,将会开启另一个任期,并立刻开始下一次选举。raft 算法保证在给定的一个任期最少要有一个 Leader。\n每个节点都会存储当前的 term 号,当服务器之间进行通信时会交换当前的 term 号;如果有服务器发现自己的 term 号比其他人小,那么他会更新到较大的 term 值。如果一个 Candidate 或者 Leader 发现自己的 term 过期了,他会立即退回成 Follower。如果一台服务器收到的请求的 term 号是过期的,那么它会拒绝此次请求。\n2.3 日志 # entry:每一个事件成为 entry,只有 Leader 可以创建 entry。entry 的内容为\u0026lt;term,index,cmd\u0026gt;其中 cmd 是可以应用到状态机的操作。 log:由 entry 构成的数组,每一个 entry 都有一个表明自己在 log 中的 index。只有 Leader 才可以改变其他节点的 log。entry 总是先被 Leader 添加到自己的 log 数组中,然后再发起共识请求,获得同意后才会被 Leader 提交给状态机。Follower 只能从 Leader 获取新日志和当前的 commitIndex,然后把对应的 entry 应用到自己的状态机中。 3 领导人选举 # raft 使用心跳机制来触发 Leader 的选举。\n如果一台服务器能够收到来自 Leader 或者 Candidate 的有效信息,那么它会一直保持为 Follower 状态,并且刷新自己的 electionElapsed,重新计时。\nLeader 会向所有的 Follower 周期性发送心跳来保证自己的 Leader 地位。如果一个 Follower 在一个周期内没有收到心跳信息,就叫做选举超时,然后它就会认为此时没有可用的 Leader,并且开始进行一次选举以选出一个新的 Leader。\n为了开始新的选举,Follower 会自增自己的 term 号并且转换状态为 Candidate。然后他会向所有节点发起 RequestVoteRPC 请求, Candidate 的状态会持续到以下情况发生:\n赢得选举 其他节点赢得选举 一轮选举结束,无人胜出 赢得选举的条件是:一个 Candidate 在一个任期内收到了来自集群内的多数选票(N/2+1),就可以成为 Leader。\n在 Candidate 等待选票的时候,它可能收到其他节点声明自己是 Leader 的心跳,此时有两种情况:\n该 Leader 的 term 号大于等于自己的 term 号,说明对方已经成为 Leader,则自己回退为 Follower。 该 Leader 的 term 号小于自己的 term 号,那么会拒绝该请求并让该节点更新 term。 由于可能同一时刻出现多个 Candidate,导致没有 Candidate 获得大多数选票,如果没有其他手段来重新分配选票的话,那么可能会无限重复下去。\nraft 使用了随机的选举超时时间来避免上述情况。每一个 Candidate 在发起选举后,都会随机化一个新的选举超时时间,这种机制使得各个服务器能够分散开来,在大多数情况下只有一个服务器会率先超时;它会在其他服务器超时之前赢得选举。\n4 日志复制 # 一旦选出了 Leader,它就开始接受客户端的请求。每一个客户端的请求都包含一条需要被复制状态机(Replicated State Machine)执行的命令。\nLeader 收到客户端请求后,会生成一个 entry,包含\u0026lt;index,term,cmd\u0026gt;,再将这个 entry 添加到自己的日志末尾后,向所有的节点广播该 entry,要求其他服务器复制这条 entry。\n如果 Follower 接受该 entry,则会将 entry 添加到自己的日志后面,同时返回给 Leader 同意。\n如果 Leader 收到了多数的成功响应,Leader 会将这个 entry 应用到自己的状态机中,之后可以称这个 entry 是 committed 的,并且向客户端返回执行结果。\nraft 保证以下两个性质:\n在两个日志里,有两个 entry 拥有相同的 index 和 term,那么它们一定有相同的 cmd 在两个日志里,有两个 entry 拥有相同的 index 和 term,那么它们前面的 entry 也一定相同 通过“仅有 Leader 可以生成 entry”来保证第一个性质,第二个性质需要一致性检查来进行保证。\n一般情况下,Leader 和 Follower 的日志保持一致,然后,Leader 的崩溃会导致日志不一样,这样一致性检查会产生失败。Leader 通过强制 Follower 复制自己的日志来处理日志的不一致。这就意味着,在 Follower 上的冲突日志会被领导者的日志覆盖。\n为了使得 Follower 的日志和自己的日志一致,Leader 需要找到 Follower 与它日志一致的地方,然后删除 Follower 在该位置之后的日志,接着把这之后的日志发送给 Follower。\nLeader 给每一个Follower 维护了一个 nextIndex,它表示 Leader 将要发送给该追随者的下一条日志条目的索引。当一个 Leader 开始掌权时,它会将 nextIndex 初始化为它的最新的日志条目索引数+1。如果一个 Follower 的日志和 Leader 的不一致,AppendEntries 一致性检查会在下一次 AppendEntries RPC 时返回失败。在失败之后,Leader 会将 nextIndex 递减然后重试 AppendEntries RPC。最终 nextIndex 会达到一个 Leader 和 Follower 日志一致的地方。这时,AppendEntries 会返回成功,Follower 中冲突的日志条目都被移除了,并且添加所缺少的上了 Leader 的日志条目。一旦 AppendEntries 返回成功,Follower 和 Leader 的日志就一致了,这样的状态会保持到该任期结束。\n5 安全性 # 5.1 选举限制 # Leader 需要保证自己存储全部已经提交的日志条目。这样才可以使日志条目只有一个流向:从 Leader 流向 Follower,Leader 永远不会覆盖已经存在的日志条目。\n每个 Candidate 发送 RequestVoteRPC 时,都会带上最后一个 entry 的信息。所有节点收到投票信息时,会对该 entry 进行比较,如果发现自己的更新,则拒绝投票给该 Candidate。\n判断日志新旧的方式:如果两个日志的 term 不同,term 大的更新;如果 term 相同,更长的 index 更新。\n5.2 节点崩溃 # 如果 Leader 崩溃,集群中的节点在 electionTimeout 时间内没有收到 Leader 的心跳信息就会触发新一轮的选主,在选主期间整个集群对外是不可用的。\n如果 Follower 和 Candidate 崩溃,处理方式会简单很多。之后发送给它的 RequestVoteRPC 和 AppendEntriesRPC 会失败。由于 raft 的所有请求都是幂等的,所以失败的话会无限的重试。如果崩溃恢复后,就可以收到新的请求,然后选择追加或者拒绝 entry。\n5.3 时间与可用性 # raft 的要求之一就是安全性不依赖于时间:系统不能仅仅因为一些事件发生的比预想的快一些或者慢一些就产生错误。为了保证上述要求,最好能满足以下的时间条件:\nbroadcastTime \u0026lt;\u0026lt; electionTimeout \u0026lt;\u0026lt; MTBF\nbroadcastTime:向其他节点并发发送消息的平均响应时间; electionTimeout:选举超时时间; MTBF(mean time between failures):单台机器的平均健康时间; broadcastTime应该比electionTimeout小一个数量级,为的是使Leader能够持续发送心跳信息(heartbeat)来阻止Follower开始选举;\nelectionTimeout也要比MTBF小几个数量级,为的是使得系统稳定运行。当Leader崩溃时,大约会在整个electionTimeout的时间内不可用;我们希望这种情况仅占全部时间的很小一部分。\n由于broadcastTime和MTBF是由系统决定的属性,因此需要决定electionTimeout的时间。\n一般来说,broadcastTime 一般为 0.5~20ms,electionTimeout 可以设置为 10~500ms,MTBF 一般为一两个月。\n6 参考 # https://tanxinyu.work/raft/ https://github.com/OneSizeFitsQuorum/raft-thesis-zh_cn/blob/master/raft-thesis-zh_cn.md https://github.com/ongardie/dissertation/blob/master/stanford.pdf https://knowledge-sharing.gitbooks.io/raft/content/chapter5.html "},{"id":556,"href":"/zh/docs/technology/Interview/database/redis/redis-data-structures-02/","title":"Redis 3 种特殊数据类型详解","section":"Redis","content":"除了 5 种基本的数据类型之外,Redis 还支持 3 种特殊的数据类型:Bitmap、HyperLogLog、GEO。\nBitmap (位图) # 介绍 # 根据官网介绍:\nBitmaps are not an actual data type, but a set of bit-oriented operations defined on the String type which is treated like a bit vector. Since strings are binary safe blobs and their maximum length is 512 MB, they are suitable to set up to 2^32 different bits.\nBitmap 不是 Redis 中的实际数据类型,而是在 String 类型上定义的一组面向位的操作,将其视为位向量。由于字符串是二进制安全的块,且最大长度为 512 MB,它们适合用于设置最多 2^32 个不同的位。\nBitmap 存储的是连续的二进制数字(0 和 1),通过 Bitmap, 只需要一个 bit 位来表示某个元素对应的值或者状态,key 就是对应元素本身 。我们知道 8 个 bit 可以组成一个 byte,所以 Bitmap 本身会极大的节省储存空间。\n你可以将 Bitmap 看作是一个存储二进制数字(0 和 1)的数组,数组中每个元素的下标叫做 offset(偏移量)。\n常用命令 # 命令 介绍 SETBIT key offset value 设置指定 offset 位置的值 GETBIT key offset 获取指定 offset 位置的值 BITCOUNT key start end 获取 start 和 end 之间值为 1 的元素个数 BITOP operation destkey key1 key2 \u0026hellip; 对一个或多个 Bitmap 进行运算,可用运算符有 AND, OR, XOR 以及 NOT Bitmap 基本操作演示:\n# SETBIT 会返回之前位的值(默认是 0)这里会生成 7 个位 \u0026gt; SETBIT mykey 7 1 (integer) 0 \u0026gt; SETBIT mykey 7 0 (integer) 1 \u0026gt; GETBIT mykey 7 (integer) 0 \u0026gt; SETBIT mykey 6 1 (integer) 0 \u0026gt; SETBIT mykey 8 1 (integer) 0 # 通过 bitcount 统计被被设置为 1 的位的数量。 \u0026gt; BITCOUNT mykey (integer) 2 应用场景 # 需要保存状态信息(0/1 即可表示)的场景\n举例:用户签到情况、活跃用户情况、用户行为统计(比如是否点赞过某个视频)。 相关命令:SETBIT、GETBIT、BITCOUNT、BITOP。 HyperLogLog(基数统计) # 介绍 # HyperLogLog 是一种有名的基数计数概率算法 ,基于 LogLog Counting(LLC)优化改进得来,并不是 Redis 特有的,Redis 只是实现了这个算法并提供了一些开箱即用的 API。\nRedis 提供的 HyperLogLog 占用空间非常非常小,只需要 12k 的空间就能存储接近2^64个不同元素。这是真的厉害,这就是数学的魅力么!并且,Redis 对 HyperLogLog 的存储结构做了优化,采用两种方式计数:\n稀疏矩阵:计数较少的时候,占用空间很小。 稠密矩阵:计数达到某个阈值的时候,占用 12k 的空间。 Redis 官方文档中有对应的详细说明:\n基数计数概率算法为了节省内存并不会直接存储元数据,而是通过一定的概率统计方法预估基数值(集合中包含元素的个数)。因此, HyperLogLog 的计数结果并不是一个精确值,存在一定的误差(标准误差为 0.81% )。\nHyperLogLog 的使用非常简单,但原理非常复杂。HyperLogLog 的原理以及在 Redis 中的实现可以看这篇文章: HyperLogLog 算法的原理讲解以及 Redis 是如何应用它的 。\n再推荐一个可以帮助理解 HyperLogLog 原理的工具: Sketch of the Day: HyperLogLog — Cornerstone of a Big Data Infrastructure 。\n除了 HyperLogLog 之外,Redis 还提供了其他的概率数据结构,对应的官方文档地址: https://redis.io/docs/data-types/probabilistic/ 。\n常用命令 # HyperLogLog 相关的命令非常少,最常用的也就 3 个。\n命令 介绍 PFADD key element1 element2 \u0026hellip; 添加一个或多个元素到 HyperLogLog 中 PFCOUNT key1 key2 获取一个或者多个 HyperLogLog 的唯一计数。 PFMERGE destkey sourcekey1 sourcekey2 \u0026hellip; 将多个 HyperLogLog 合并到 destkey 中,destkey 会结合多个源,算出对应的唯一计数。 HyperLogLog 基本操作演示:\n\u0026gt; PFADD hll foo bar zap (integer) 1 \u0026gt; PFADD hll zap zap zap (integer) 0 \u0026gt; PFADD hll foo bar (integer) 0 \u0026gt; PFCOUNT hll (integer) 3 \u0026gt; PFADD some-other-hll 1 2 3 (integer) 1 \u0026gt; PFCOUNT hll some-other-hll (integer) 6 \u0026gt; PFMERGE desthll hll some-other-hll \u0026#34;OK\u0026#34; \u0026gt; PFCOUNT desthll (integer) 6 应用场景 # 数量巨大(百万、千万级别以上)的计数场景\n举例:热门网站每日/每周/每月访问 ip 数统计、热门帖子 uv 统计。 相关命令:PFADD、PFCOUNT 。 Geospatial (地理位置) # 介绍 # Geospatial index(地理空间索引,简称 GEO) 主要用于存储地理位置信息,基于 Sorted Set 实现。\n通过 GEO 我们可以轻松实现两个位置距离的计算、获取指定位置附近的元素等功能。\n常用命令 # 命令 介绍 GEOADD key longitude1 latitude1 member1 \u0026hellip; 添加一个或多个元素对应的经纬度信息到 GEO 中 GEOPOS key member1 member2 \u0026hellip; 返回给定元素的经纬度信息 GEODIST key member1 member2 M/KM/FT/MI 返回两个给定元素之间的距离 GEORADIUS key longitude latitude radius distance 获取指定位置附近 distance 范围内的其他元素,支持 ASC(由近到远)、DESC(由远到近)、Count(数量) 等参数 GEORADIUSBYMEMBER key member radius distance 类似于 GEORADIUS 命令,只是参照的中心点是 GEO 中的元素 基本操作:\n\u0026gt; GEOADD personLocation 116.33 39.89 user1 116.34 39.90 user2 116.35 39.88 user3 3 \u0026gt; GEOPOS personLocation user1 116.3299986720085144 39.89000061669732844 \u0026gt; GEODIST personLocation user1 user2 km 1.4018 通过 Redis 可视化工具查看 personLocation ,果不其然,底层就是 Sorted Set。\nGEO 中存储的地理位置信息的经纬度数据通过 GeoHash 算法转换成了一个整数,这个整数作为 Sorted Set 的 score(权重参数)使用。\n获取指定位置范围内的其他元素:\n\u0026gt; GEORADIUS personLocation 116.33 39.87 3 km user3 user1 \u0026gt; GEORADIUS personLocation 116.33 39.87 2 km \u0026gt; GEORADIUS personLocation 116.33 39.87 5 km user3 user1 user2 \u0026gt; GEORADIUSBYMEMBER personLocation user1 5 km user3 user1 user2 \u0026gt; GEORADIUSBYMEMBER personLocation user1 2 km user1 user2 GEORADIUS 命令的底层原理解析可以看看阿里的这篇文章: Redis 到底是怎么实现“附近的人”这个功能的呢? 。\n移除元素:\nGEO 底层是 Sorted Set ,你可以对 GEO 使用 Sorted Set 相关的命令。\n\u0026gt; ZREM personLocation user1 1 \u0026gt; ZRANGE personLocation 0 -1 user3 user2 \u0026gt; ZSCORE personLocation user2 4069879562983946 应用场景 # 需要管理使用地理空间数据的场景\n举例:附近的人。 相关命令: GEOADD、GEORADIUS、GEORADIUSBYMEMBER 。 总结 # 数据类型 说明 Bitmap 你可以将 Bitmap 看作是一个存储二进制数字(0 和 1)的数组,数组中每个元素的下标叫做 offset(偏移量)。通过 Bitmap, 只需要一个 bit 位来表示某个元素对应的值或者状态,key 就是对应元素本身 。我们知道 8 个 bit 可以组成一个 byte,所以 Bitmap 本身会极大的节省储存空间。 HyperLogLog Redis 提供的 HyperLogLog 占用空间非常非常小,只需要 12k 的空间就能存储接近2^64个不同元素。不过,HyperLogLog 的计数结果并不是一个精确值,存在一定的误差(标准误差为 0.81% )。 Geospatial index Geospatial index(地理空间索引,简称 GEO) 主要用于存储地理位置信息,基于 Sorted Set 实现。 参考 # Redis Data Structures: https://redis.com/redis-enterprise/data-structures/ 。 《Redis 深度历险:核心原理与应用实践》1.6 四两拨千斤——HyperLogLog 布隆过滤器,位图,HyperLogLog: https://hogwartsrico.github.io/2020/06/08/BloomFilter-HyperLogLog-BitMap/index.html "},{"id":557,"href":"/zh/docs/technology/Interview/database/redis/redis-data-structures-01/","title":"Redis 5 种基本数据类型详解","section":"Redis","content":"Redis 共有 5 种基本数据类型:String(字符串)、List(列表)、Set(集合)、Hash(散列)、Zset(有序集合)。\n这 5 种数据类型是直接提供给用户使用的,是数据的保存形式,其底层实现主要依赖这 8 种数据结构:简单动态字符串(SDS)、LinkedList(双向链表)、Dict(哈希表/字典)、SkipList(跳跃表)、Intset(整数集合)、ZipList(压缩列表)、QuickList(快速列表)。\nRedis 5 种基本数据类型对应的底层数据结构实现如下表所示:\nString List Hash Set Zset SDS LinkedList/ZipList/QuickList Dict、ZipList Dict、Intset ZipList、SkipList Redis 3.2 之前,List 底层实现是 LinkedList 或者 ZipList。 Redis 3.2 之后,引入了 LinkedList 和 ZipList 的结合 QuickList,List 的底层实现变为 QuickList。从 Redis 7.0 开始, ZipList 被 ListPack 取代。\n你可以在 Redis 官网上找到 Redis 数据类型/结构非常详细的介绍:\nRedis Data Structures Redis Data types tutorial 未来随着 Redis 新版本的发布,可能会有新的数据结构出现,通过查阅 Redis 官网对应的介绍,你总能获取到最靠谱的信息。\nString(字符串) # 介绍 # String 是 Redis 中最简单同时也是最常用的一个数据类型。\nString 是一种二进制安全的数据类型,可以用来存储任何类型的数据比如字符串、整数、浮点数、图片(图片的 base64 编码或者解码或者图片的路径)、序列化后的对象。\n虽然 Redis 是用 C 语言写的,但是 Redis 并没有使用 C 的字符串表示,而是自己构建了一种 简单动态字符串(Simple Dynamic String,SDS)。相比于 C 的原生字符串,Redis 的 SDS 不光可以保存文本数据还可以保存二进制数据,并且获取字符串长度复杂度为 O(1)(C 字符串为 O(N)),除此之外,Redis 的 SDS API 是安全的,不会造成缓冲区溢出。\n常用命令 # 命令 介绍 SET key value 设置指定 key 的值 SETNX key value 只有在 key 不存在时设置 key 的值 GET key 获取指定 key 的值 MSET key1 value1 key2 value2 …… 设置一个或多个指定 key 的值 MGET key1 key2 \u0026hellip; 获取一个或多个指定 key 的值 STRLEN key 返回 key 所储存的字符串值的长度 INCR key 将 key 中储存的数字值增一 DECR key 将 key 中储存的数字值减一 EXISTS key 判断指定 key 是否存在 DEL key(通用) 删除指定的 key EXPIRE key seconds(通用) 给指定 key 设置过期时间 更多 Redis String 命令以及详细使用指南,请查看 Redis 官网对应的介绍: https://redis.io/commands/?group=string 。\n基本操作:\n\u0026gt; SET key value OK \u0026gt; GET key \u0026#34;value\u0026#34; \u0026gt; EXISTS key (integer) 1 \u0026gt; STRLEN key (integer) 5 \u0026gt; DEL key (integer) 1 \u0026gt; GET key (nil) 批量设置:\n\u0026gt; MSET key1 value1 key2 value2 OK \u0026gt; MGET key1 key2 # 批量获取多个 key 对应的 value 1) \u0026#34;value1\u0026#34; 2) \u0026#34;value2\u0026#34; 计数器(字符串的内容为整数的时候可以使用):\n\u0026gt; SET number 1 OK \u0026gt; INCR number # 将 key 中储存的数字值增一 (integer) 2 \u0026gt; GET number \u0026#34;2\u0026#34; \u0026gt; DECR number # 将 key 中储存的数字值减一 (integer) 1 \u0026gt; GET number \u0026#34;1\u0026#34; 设置过期时间(默认为永不过期):\n\u0026gt; EXPIRE key 60 (integer) 1 \u0026gt; SETEX key 60 value # 设置值并设置过期时间 OK \u0026gt; TTL key (integer) 56 应用场景 # 需要存储常规数据的场景\n举例:缓存 Session、Token、图片地址、序列化后的对象(相比较于 Hash 存储更节省内存)。 相关命令:SET、GET。 需要计数的场景\n举例:用户单位时间的请求数(简单限流可以用到)、页面单位时间的访问数。 相关命令:SET、GET、 INCR、DECR 。 分布式锁\n利用 SETNX key value 命令可以实现一个最简易的分布式锁(存在一些缺陷,通常不建议这样实现分布式锁)。\nList(列表) # 介绍 # Redis 中的 List 其实就是链表数据结构的实现。我在 线性数据结构 :数组、链表、栈、队列 这篇文章中详细介绍了链表这种数据结构,我这里就不多做介绍了。\n许多高级编程语言都内置了链表的实现比如 Java 中的 LinkedList,但是 C 语言并没有实现链表,所以 Redis 实现了自己的链表数据结构。Redis 的 List 的实现为一个 双向链表,即可以支持反向查找和遍历,更方便操作,不过带来了部分额外的内存开销。\n常用命令 # 命令 介绍 RPUSH key value1 value2 \u0026hellip; 在指定列表的尾部(右边)添加一个或多个元素 LPUSH key value1 value2 \u0026hellip; 在指定列表的头部(左边)添加一个或多个元素 LSET key index value 将指定列表索引 index 位置的值设置为 value LPOP key 移除并获取指定列表的第一个元素(最左边) RPOP key 移除并获取指定列表的最后一个元素(最右边) LLEN key 获取列表元素数量 LRANGE key start end 获取列表 start 和 end 之间 的元素 更多 Redis List 命令以及详细使用指南,请查看 Redis 官网对应的介绍: https://redis.io/commands/?group=list 。\n通过 RPUSH/LPOP 或者 LPUSH/RPOP实现队列:\n\u0026gt; RPUSH myList value1 (integer) 1 \u0026gt; RPUSH myList value2 value3 (integer) 3 \u0026gt; LPOP myList \u0026#34;value1\u0026#34; \u0026gt; LRANGE myList 0 1 1) \u0026#34;value2\u0026#34; 2) \u0026#34;value3\u0026#34; \u0026gt; LRANGE myList 0 -1 1) \u0026#34;value2\u0026#34; 2) \u0026#34;value3\u0026#34; 通过 RPUSH/RPOP或者LPUSH/LPOP 实现栈:\n\u0026gt; RPUSH myList2 value1 value2 value3 (integer) 3 \u0026gt; RPOP myList2 # 将 list的最右边的元素取出 \u0026#34;value3\u0026#34; 我专门画了一个图方便大家理解 RPUSH , LPOP , lpush , RPOP 命令:\n通过 LRANGE 查看对应下标范围的列表元素:\n\u0026gt; RPUSH myList value1 value2 value3 (integer) 3 \u0026gt; LRANGE myList 0 1 1) \u0026#34;value1\u0026#34; 2) \u0026#34;value2\u0026#34; \u0026gt; LRANGE myList 0 -1 1) \u0026#34;value1\u0026#34; 2) \u0026#34;value2\u0026#34; 3) \u0026#34;value3\u0026#34; 通过 LRANGE 命令,你可以基于 List 实现分页查询,性能非常高!\n通过 LLEN 查看链表长度:\n\u0026gt; LLEN myList (integer) 3 应用场景 # 信息流展示\n举例:最新文章、最新动态。 相关命令:LPUSH、LRANGE。 消息队列\nList 可以用来做消息队列,只是功能过于简单且存在很多缺陷,不建议这样做。\n相对来说,Redis 5.0 新增加的一个数据结构 Stream 更适合做消息队列一些,只是功能依然非常简陋。和专业的消息队列相比,还是有很多欠缺的地方比如消息丢失和堆积问题不好解决。\nHash(哈希) # 介绍 # Redis 中的 Hash 是一个 String 类型的 field-value(键值对) 的映射表,特别适合用于存储对象,后续操作的时候,你可以直接修改这个对象中的某些字段的值。\nHash 类似于 JDK1.8 前的 HashMap,内部实现也差不多(数组 + 链表)。不过,Redis 的 Hash 做了更多优化。\n常用命令 # 命令 介绍 HSET key field value 设置指定哈希表中指定字段的值 HSETNX key field value 只有指定字段不存在时设置指定字段的值 HMSET key field1 value1 field2 value2 \u0026hellip; 同时将一个或多个 field-value (域-值)对设置到指定哈希表中 HGET key field 获取指定哈希表中指定字段的值 HMGET key field1 field2 \u0026hellip; 获取指定哈希表中一个或者多个指定字段的值 HGETALL key 获取指定哈希表中所有的键值对 HEXISTS key field 查看指定哈希表中指定的字段是否存在 HDEL key field1 field2 \u0026hellip; 删除一个或多个哈希表字段 HLEN key 获取指定哈希表中字段的数量 HINCRBY key field increment 对指定哈希中的指定字段做运算操作(正数为加,负数为减) 更多 Redis Hash 命令以及详细使用指南,请查看 Redis 官网对应的介绍: https://redis.io/commands/?group=hash 。\n模拟对象数据存储:\n\u0026gt; HMSET userInfoKey name \u0026#34;guide\u0026#34; description \u0026#34;dev\u0026#34; age 24 OK \u0026gt; HEXISTS userInfoKey name # 查看 key 对应的 value中指定的字段是否存在。 (integer) 1 \u0026gt; HGET userInfoKey name # 获取存储在哈希表中指定字段的值。 \u0026#34;guide\u0026#34; \u0026gt; HGET userInfoKey age \u0026#34;24\u0026#34; \u0026gt; HGETALL userInfoKey # 获取在哈希表中指定 key 的所有字段和值 1) \u0026#34;name\u0026#34; 2) \u0026#34;guide\u0026#34; 3) \u0026#34;description\u0026#34; 4) \u0026#34;dev\u0026#34; 5) \u0026#34;age\u0026#34; 6) \u0026#34;24\u0026#34; \u0026gt; HSET userInfoKey name \u0026#34;GuideGeGe\u0026#34; \u0026gt; HGET userInfoKey name \u0026#34;GuideGeGe\u0026#34; \u0026gt; HINCRBY userInfoKey age 2 (integer) 26 应用场景 # 对象数据存储场景\n举例:用户信息、商品信息、文章信息、购物车信息。 相关命令:HSET (设置单个字段的值)、HMSET(设置多个字段的值)、HGET(获取单个字段的值)、HMGET(获取多个字段的值)。 Set(集合) # 介绍 # Redis 中的 Set 类型是一种无序集合,集合中的元素没有先后顺序但都唯一,有点类似于 Java 中的 HashSet 。当你需要存储一个列表数据,又不希望出现重复数据时,Set 是一个很好的选择,并且 Set 提供了判断某个元素是否在一个 Set 集合内的重要接口,这个也是 List 所不能提供的。\n你可以基于 Set 轻易实现交集、并集、差集的操作,比如你可以将一个用户所有的关注人存在一个集合中,将其所有粉丝存在一个集合。这样的话,Set 可以非常方便的实现如共同关注、共同粉丝、共同喜好等功能。这个过程也就是求交集的过程。\n常用命令 # 命令 介绍 SADD key member1 member2 \u0026hellip; 向指定集合添加一个或多个元素 SMEMBERS key 获取指定集合中的所有元素 SCARD key 获取指定集合的元素数量 SISMEMBER key member 判断指定元素是否在指定集合中 SINTER key1 key2 \u0026hellip; 获取给定所有集合的交集 SINTERSTORE destination key1 key2 \u0026hellip; 将给定所有集合的交集存储在 destination 中 SUNION key1 key2 \u0026hellip; 获取给定所有集合的并集 SUNIONSTORE destination key1 key2 \u0026hellip; 将给定所有集合的并集存储在 destination 中 SDIFF key1 key2 \u0026hellip; 获取给定所有集合的差集 SDIFFSTORE destination key1 key2 \u0026hellip; 将给定所有集合的差集存储在 destination 中 SPOP key count 随机移除并获取指定集合中一个或多个元素 SRANDMEMBER key count 随机获取指定集合中指定数量的元素 更多 Redis Set 命令以及详细使用指南,请查看 Redis 官网对应的介绍: https://redis.io/commands/?group=set 。\n基本操作:\n\u0026gt; SADD mySet value1 value2 (integer) 2 \u0026gt; SADD mySet value1 # 不允许有重复元素,因此添加失败 (integer) 0 \u0026gt; SMEMBERS mySet 1) \u0026#34;value1\u0026#34; 2) \u0026#34;value2\u0026#34; \u0026gt; SCARD mySet (integer) 2 \u0026gt; SISMEMBER mySet value1 (integer) 1 \u0026gt; SADD mySet2 value2 value3 (integer) 2 mySet : value1、value2 。 mySet2:value2、value3 。 求交集:\n\u0026gt; SINTERSTORE mySet3 mySet mySet2 (integer) 1 \u0026gt; SMEMBERS mySet3 1) \u0026#34;value2\u0026#34; 求并集:\n\u0026gt; SUNION mySet mySet2 1) \u0026#34;value3\u0026#34; 2) \u0026#34;value2\u0026#34; 3) \u0026#34;value1\u0026#34; 求差集:\n\u0026gt; SDIFF mySet mySet2 # 差集是由所有属于 mySet 但不属于 A 的元素组成的集合 1) \u0026#34;value1\u0026#34; 应用场景 # 需要存放的数据不能重复的场景\n举例:网站 UV 统计(数据量巨大的场景还是 HyperLogLog更适合一些)、文章点赞、动态点赞等场景。 相关命令:SCARD(获取集合数量) 。 需要获取多个数据源交集、并集和差集的场景\n举例:共同好友(交集)、共同粉丝(交集)、共同关注(交集)、好友推荐(差集)、音乐推荐(差集)、订阅号推荐(差集+交集) 等场景。 相关命令:SINTER(交集)、SINTERSTORE (交集)、SUNION (并集)、SUNIONSTORE(并集)、SDIFF(差集)、SDIFFSTORE (差集)。 需要随机获取数据源中的元素的场景\n举例:抽奖系统、随机点名等场景。 相关命令:SPOP(随机获取集合中的元素并移除,适合不允许重复中奖的场景)、SRANDMEMBER(随机获取集合中的元素,适合允许重复中奖的场景)。 Sorted Set(有序集合) # 介绍 # Sorted Set 类似于 Set,但和 Set 相比,Sorted Set 增加了一个权重参数 score,使得集合中的元素能够按 score 进行有序排列,还可以通过 score 的范围来获取元素的列表。有点像是 Java 中 HashMap 和 TreeSet 的结合体。\n常用命令 # 命令 介绍 ZADD key score1 member1 score2 member2 \u0026hellip; 向指定有序集合添加一个或多个元素 ZCARD KEY 获取指定有序集合的元素数量 ZSCORE key member 获取指定有序集合中指定元素的 score 值 ZINTERSTORE destination numkeys key1 key2 \u0026hellip; 将给定所有有序集合的交集存储在 destination 中,对相同元素对应的 score 值进行 SUM 聚合操作,numkeys 为集合数量 ZUNIONSTORE destination numkeys key1 key2 \u0026hellip; 求并集,其它和 ZINTERSTORE 类似 ZDIFFSTORE destination numkeys key1 key2 \u0026hellip; 求差集,其它和 ZINTERSTORE 类似 ZRANGE key start end 获取指定有序集合 start 和 end 之间的元素(score 从低到高) ZREVRANGE key start end 获取指定有序集合 start 和 end 之间的元素(score 从高到底) ZREVRANK key member 获取指定有序集合中指定元素的排名(score 从大到小排序) 更多 Redis Sorted Set 命令以及详细使用指南,请查看 Redis 官网对应的介绍: https://redis.io/commands/?group=sorted-set 。\n基本操作:\n\u0026gt; ZADD myZset 2.0 value1 1.0 value2 (integer) 2 \u0026gt; ZCARD myZset 2 \u0026gt; ZSCORE myZset value1 2.0 \u0026gt; ZRANGE myZset 0 1 1) \u0026#34;value2\u0026#34; 2) \u0026#34;value1\u0026#34; \u0026gt; ZREVRANGE myZset 0 1 1) \u0026#34;value1\u0026#34; 2) \u0026#34;value2\u0026#34; \u0026gt; ZADD myZset2 4.0 value2 3.0 value3 (integer) 2 myZset : value1(2.0)、value2(1.0) 。 myZset2:value2 (4.0)、value3(3.0) 。 获取指定元素的排名:\n\u0026gt; ZREVRANK myZset value1 0 \u0026gt; ZREVRANK myZset value2 1 求交集:\n\u0026gt; ZINTERSTORE myZset3 2 myZset myZset2 1 \u0026gt; ZRANGE myZset3 0 1 WITHSCORES value2 5 求并集:\n\u0026gt; ZUNIONSTORE myZset4 2 myZset myZset2 3 \u0026gt; ZRANGE myZset4 0 2 WITHSCORES value1 2 value3 3 value2 5 求差集:\n\u0026gt; ZDIFF 2 myZset myZset2 WITHSCORES value1 2 应用场景 # 需要随机获取数据源中的元素根据某个权重进行排序的场景\n举例:各种排行榜比如直播间送礼物的排行榜、朋友圈的微信步数排行榜、王者荣耀中的段位排行榜、话题热度排行榜等等。 相关命令:ZRANGE (从小到大排序)、 ZREVRANGE (从大到小排序)、ZREVRANK (指定元素排名)。 《Java 面试指北》 的「技术面试题篇」就有一篇文章详细介绍如何使用 Sorted Set 来设计制作一个排行榜。\n需要存储的数据有优先级或者重要程度的场景 比如优先级任务队列。\n举例:优先级任务队列。 相关命令:ZRANGE (从小到大排序)、 ZREVRANGE (从大到小排序)、ZREVRANK (指定元素排名)。 总结 # 数据类型 说明 String 一种二进制安全的数据类型,可以用来存储任何类型的数据比如字符串、整数、浮点数、图片(图片的 base64 编码或者解码或者图片的路径)、序列化后的对象。 List Redis 的 List 的实现为一个双向链表,即可以支持反向查找和遍历,更方便操作,不过带来了部分额外的内存开销。 Hash 一个 String 类型的 field-value(键值对) 的映射表,特别适合用于存储对象,后续操作的时候,你可以直接修改这个对象中的某些字段的值。 Set 无序集合,集合中的元素没有先后顺序但都唯一,有点类似于 Java 中的 HashSet 。 Zset 和 Set 相比,Sorted Set 增加了一个权重参数 score,使得集合中的元素能够按 score 进行有序排列,还可以通过 score 的范围来获取元素的列表。有点像是 Java 中 HashMap 和 TreeSet 的结合体。 参考 # Redis Data Structures: https://redis.com/redis-enterprise/data-structures/ 。 Redis Commands: https://redis.io/commands/ 。 Redis Data types tutorial: https://redis.io/docs/manual/data-types/data-types-tutorial/ 。 Redis 存储对象信息是用 Hash 还是 String : https://segmentfault.com/a/1190000040032006 "},{"id":558,"href":"/zh/docs/technology/Interview/database/redis/redis-questions-01/","title":"Redis常见面试题总结(上)","section":"Redis","content":" Redis 基础 # 什么是 Redis? # Redis (REmote DIctionary Server)是一个基于 C 语言开发的开源 NoSQL 数据库(BSD 许可)。与传统数据库不同的是,Redis 的数据是保存在内存中的(内存数据库,支持持久化),因此读写速度非常快,被广泛应用于分布式缓存方向。并且,Redis 存储的是 KV 键值对数据。\n为了满足不同的业务场景,Redis 内置了多种数据类型实现(比如 String、Hash、Sorted Set、Bitmap、HyperLogLog、GEO)。并且,Redis 还支持事务、持久化、Lua 脚本、发布订阅模型、多种开箱即用的集群方案(Redis Sentinel、Redis Cluster)。\nRedis 没有外部依赖,Linux 和 OS X 是 Redis 开发和测试最多的两个操作系统,官方推荐生产环境使用 Linux 部署 Redis。\n个人学习的话,你可以自己本机安装 Redis 或者通过 Redis 官网提供的 在线 Redis 环境(少部分命令无法使用)来实际体验 Redis。\n全世界有非常多的网站使用到了 Redis , techstacks.io 专门维护了一个 使用 Redis 的热门站点列表 ,感兴趣的话可以看看。\nRedis 为什么这么快? # Redis 内部做了非常多的性能优化,比较重要的有下面 3 点:\nRedis 基于内存,内存的访问速度比磁盘快很多; Redis 基于 Reactor 模式设计开发了一套高效的事件处理模型,主要是单线程事件循环和 IO 多路复用(Redis 线程模式后面会详细介绍到); Redis 内置了多种优化过后的数据类型/结构实现,性能非常高。 Redis 通信协议实现简单且解析高效。 下面这张图片总结的挺不错的,分享一下,出自 Why is Redis so fast? 。\n那既然都这么快了,为什么不直接用 Redis 当主数据库呢?主要是因为内存成本太高且 Redis 提供的数据持久化仍然有数据丢失的风险。\n除了 Redis,你还知道其他分布式缓存方案吗? # 如果面试中被问到这个问题的话,面试官主要想看看:\n你在选择 Redis 作为分布式缓存方案时,是否是经过严谨的调研和思考,还是只是因为 Redis 是当前的“热门”技术。 你在分布式缓存方向的技术广度。 如果你了解其他方案,并且能解释为什么最终选择了 Redis(更进一步!),这会对你面试表现加分不少!\n下面简单聊聊常见的分布式缓存技术选型。\n分布式缓存的话,比较老牌同时也是使用的比较多的还是 Memcached 和 Redis。不过,现在基本没有看过还有项目使用 Memcached 来做缓存,都是直接用 Redis。\nMemcached 是分布式缓存最开始兴起的那会,比较常用的。后来,随着 Redis 的发展,大家慢慢都转而使用更加强大的 Redis 了。\n有一些大厂也开源了类似于 Redis 的分布式高性能 KV 存储数据库,例如,腾讯开源的 Tendis 。Tendis 基于知名开源项目 RocksDB 作为存储引擎 ,100% 兼容 Redis 协议和 Redis4.0 所有数据模型。关于 Redis 和 Tendis 的对比,腾讯官方曾经发过一篇文章: Redis vs Tendis:冷热混合存储版架构揭秘 ,可以简单参考一下。\n不过,从 Tendis 这个项目的 Github 提交记录可以看出,Tendis 开源版几乎已经没有被维护更新了,加上其关注度并不高,使用的公司也比较少。因此,不建议你使用 Tendis 来实现分布式缓存。\n目前,比较业界认可的 Redis 替代品还是下面这两个开源分布式缓存(都是通过碰瓷 Redis 火的):\nDragonfly:一种针对现代应用程序负荷需求而构建的内存数据库,完全兼容 Redis 和 Memcached 的 API,迁移时无需修改任何代码,号称全世界最快的内存数据库。 KeyDB: Redis 的一个高性能分支,专注于多线程、内存效率和高吞吐量。 不过,个人还是建议分布式缓存首选 Redis ,毕竟经过这么多年的生考验,生态也这么优秀,资料也很全面!\nPS:篇幅问题,我这并没有对上面提到的分布式缓存选型做详细介绍和对比,感兴趣的话,可以自行研究一下。\n说一下 Redis 和 Memcached 的区别和共同点 # 现在公司一般都是用 Redis 来实现缓存,而且 Redis 自身也越来越强大了!不过,了解 Redis 和 Memcached 的区别和共同点,有助于我们在做相应的技术选型的时候,能够做到有理有据!\n共同点:\n都是基于内存的数据库,一般都用来当做缓存使用。 都有过期策略。 两者的性能都非常高。 区别:\n数据类型:Redis 支持更丰富的数据类型(支持更复杂的应用场景)。Redis 不仅仅支持简单的 k/v 类型的数据,同时还提供 list,set,zset,hash 等数据结构的存储。Memcached 只支持最简单的 k/v 数据类型。 数据持久化:Redis 支持数据的持久化,可以将内存中的数据保持在磁盘中,重启的时候可以再次加载进行使用,而 Memcached 把数据全部存在内存之中。也就是说,Redis 有灾难恢复机制而 Memcached 没有。 集群模式支持:Memcached 没有原生的集群模式,需要依靠客户端来实现往集群中分片写入数据;但是 Redis 自 3.0 版本起是原生支持集群模式的。 线程模型:Memcached 是多线程,非阻塞 IO 复用的网络模型;Redis 使用单线程的多路 IO 复用模型。 (Redis 6.0 针对网络数据的读写引入了多线程) 特性支持:Redis 支持发布订阅模型、Lua 脚本、事务等功能,而 Memcached 不支持。并且,Redis 支持更多的编程语言。 过期数据删除:Memcached 过期数据的删除策略只用了惰性删除,而 Redis 同时使用了惰性删除与定期删除。 相信看了上面的对比之后,我们已经没有什么理由可以选择使用 Memcached 来作为自己项目的分布式缓存了。\n为什么要用 Redis? # 1、访问速度更快\n传统数据库数据保存在磁盘,而 Redis 基于内存,内存的访问速度比磁盘快很多。引入 Redis 之后,我们可以把一些高频访问的数据放到 Redis 中,这样下次就可以直接从内存中读取,速度可以提升几十倍甚至上百倍。\n2、高并发\n一般像 MySQL 这类的数据库的 QPS 大概都在 4k 左右(4 核 8g) ,但是使用 Redis 缓存之后很容易达到 5w+,甚至能达到 10w+(就单机 Redis 的情况,Redis 集群的话会更高)。\nQPS(Query Per Second):服务器每秒可以执行的查询次数;\n由此可见,直接操作缓存能够承受的数据库请求数量是远远大于直接访问数据库的,所以我们可以考虑把数据库中的部分数据转移到缓存中去,这样用户的一部分请求会直接到缓存这里而不用经过数据库。进而,我们也就提高了系统整体的并发。\n3、功能全面\nRedis 除了可以用作缓存之外,还可以用于分布式锁、限流、消息队列、延时队列等场景,功能强大!\n常见的缓存读写策略有哪些? # 关于常见的缓存读写策略的详细介绍,可以看我写的这篇文章: 3 种常用的缓存读写策略详解 。\n什么是 Redis Module?有什么用? # Redis 从 4.0 版本开始,支持通过 Module 来扩展其功能以满足特殊的需求。这些 Module 以动态链接库(so 文件)的形式被加载到 Redis 中,这是一种非常灵活的动态扩展功能的实现方式,值得借鉴学习!\n我们每个人都可以基于 Redis 去定制化开发自己的 Module,比如实现搜索引擎功能、自定义分布式锁和分布式限流。\n目前,被 Redis 官方推荐的 Module 有:\nRediSearch:用于实现搜索引擎的模块。 RedisJSON:用于处理 JSON 数据的模块。 RedisGraph:用于实现图形数据库的模块。 RedisTimeSeries:用于处理时间序列数据的模块。 RedisBloom:用于实现布隆过滤器的模块。 RedisAI:用于执行深度学习/机器学习模型并管理其数据的模块。 RedisCell:用于实现分布式限流的模块。 …… 关于 Redis 模块的详细介绍,可以查看官方文档: https://redis.io/modules。\nRedis 应用 # Redis 除了做缓存,还能做什么? # 分布式锁:通过 Redis 来做分布式锁是一种比较常见的方式。通常情况下,我们都是基于 Redisson 来实现分布式锁。关于 Redis 实现分布式锁的详细介绍,可以看我写的这篇文章: 分布式锁详解 。 限流:一般是通过 Redis + Lua 脚本的方式来实现限流。如果不想自己写 Lua 脚本的话,也可以直接利用 Redisson 中的 RRateLimiter 来实现分布式限流,其底层实现就是基于 Lua 代码+令牌桶算法。 消息队列:Redis 自带的 List 数据结构可以作为一个简单的队列使用。Redis 5.0 中增加的 Stream 类型的数据结构更加适合用来做消息队列。它比较类似于 Kafka,有主题和消费组的概念,支持消息持久化以及 ACK 机制。 延时队列:Redisson 内置了延时队列(基于 Sorted Set 实现的)。 分布式 Session :利用 String 或者 Hash 数据类型保存 Session 数据,所有的服务器都可以访问。 复杂业务场景:通过 Redis 以及 Redis 扩展(比如 Redisson)提供的数据结构,我们可以很方便地完成很多复杂的业务场景比如通过 Bitmap 统计活跃用户、通过 Sorted Set 维护排行榜、通过 HyperLogLog 统计网站 UV 和 PV。 …… 如何基于 Redis 实现分布式锁? # 关于 Redis 实现分布式锁的详细介绍,可以看我写的这篇文章: 分布式锁详解 。\nRedis 可以做消息队列么? # 实际项目中使用 Redis 来做消息队列的非常少,毕竟有更成熟的消息队列中间件可以用。\n先说结论:可以是可以,但不建议使用 Redis 来做消息队列。和专业的消息队列相比,还是有很多欠缺的地方。\nRedis 2.0 之前,如果想要使用 Redis 来做消息队列的话,只能通过 List 来实现。\n通过 RPUSH/LPOP 或者 LPUSH/RPOP即可实现简易版消息队列:\n# 生产者生产消息 \u0026gt; RPUSH myList msg1 msg2 (integer) 2 \u0026gt; RPUSH myList msg3 (integer) 3 # 消费者消费消息 \u0026gt; LPOP myList \u0026#34;msg1\u0026#34; 不过,通过 RPUSH/LPOP 或者 LPUSH/RPOP这样的方式存在性能问题,我们需要不断轮询去调用 RPOP 或 LPOP 来消费消息。当 List 为空时,大部分的轮询的请求都是无效请求,这种方式大量浪费了系统资源。\n因此,Redis 还提供了 BLPOP、BRPOP 这种阻塞式读取的命令(带 B-Blocking 的都是阻塞式),并且还支持一个超时参数。如果 List 为空,Redis 服务端不会立刻返回结果,它会等待 List 中有新数据后再返回或者是等待最多一个超时时间后返回空。如果将超时时间设置为 0 时,即可无限等待,直到弹出消息\n# 超时时间为 10s # 如果有数据立刻返回,否则最多等待10秒 \u0026gt; BRPOP myList 10 null List 实现消息队列功能太简单,像消息确认机制等功能还需要我们自己实现,最要命的是没有广播机制,消息也只能被消费一次。\nRedis 2.0 引入了发布订阅 (pub/sub) 功能,解决了 List 实现消息队列没有广播机制的问题。\npub/sub 中引入了一个概念叫 channel(频道),发布订阅机制的实现就是基于这个 channel 来做的。\npub/sub 涉及发布者(Publisher)和订阅者(Subscriber,也叫消费者)两个角色:\n发布者通过 PUBLISH 投递消息给指定 channel。 订阅者通过SUBSCRIBE订阅它关心的 channel。并且,订阅者可以订阅一个或者多个 channel。 我们这里启动 3 个 Redis 客户端来简单演示一下:\npub/sub 既能单播又能广播,还支持 channel 的简单正则匹配。不过,消息丢失(客户端断开连接或者 Redis 宕机都会导致消息丢失)、消息堆积(发布者发布消息的时候不会管消费者的具体消费能力如何)等问题依然没有一个比较好的解决办法。\n为此,Redis 5.0 新增加的一个数据结构 Stream 来做消息队列。Stream 支持:\n发布 / 订阅模式 按照消费者组进行消费(借鉴了 Kafka 消费者组的概念) 消息持久化( RDB 和 AOF) ACK 机制(通过确认机制来告知已经成功处理了消息) 阻塞式获取消息 Stream 的结构如下:\n这是一个有序的消息链表,每个消息都有一个唯一的 ID 和对应的内容。ID 是一个时间戳和序列号的组合,用来保证消息的唯一性和递增性。内容是一个或多个键值对(类似 Hash 基本数据类型),用来存储消息的数据。\n这里再对图中涉及到的一些概念,进行简单解释:\nConsumer Group:消费者组用于组织和管理多个消费者。消费者组本身不处理消息,而是再将消息分发给消费者,由消费者进行真正的消费 last_delivered_id:标识消费者组当前消费位置的游标,消费者组中任意一个消费者读取了消息都会使 last_delivered_id 往前移动。 pending_ids:记录已经被客户端消费但没有 ack 的消息的 ID。 下面是Stream 用作消息队列时常用的命令:\nXADD:向流中添加新的消息。 XREAD:从流中读取消息。 XREADGROUP:从消费组中读取消息。 XRANGE:根据消息 ID 范围读取流中的消息。 XREVRANGE:与 XRANGE 类似,但以相反顺序返回结果。 XDEL:从流中删除消息。 XTRIM:修剪流的长度,可以指定修建策略(MAXLEN/MINID)。 XLEN:获取流的长度。 XGROUP CREATE:创建消费者组。 XGROUP DESTROY : 删除消费者组 XGROUP DELCONSUMER:从消费者组中删除一个消费者。 XGROUP SETID:为消费者组设置新的最后递送消息 ID XACK:确认消费组中的消息已被处理。 XPENDING:查询消费组中挂起(未确认)的消息。 XCLAIM:将挂起的消息从一个消费者转移到另一个消费者。 XINFO:获取流(XINFO STREAM)、消费组(XINFO GROUPS)或消费者(XINFO CONSUMERS)的详细信息。 Stream 使用起来相对要麻烦一些,这里就不演示了。\n总的来说,Stream 已经可以满足一个消息队列的基本要求了。不过,Stream 在实际使用中依然会有一些小问题不太好解决比如在 Redis 发生故障恢复后不能保证消息至少被消费一次。\n综上,和专业的消息队列相比,使用 Redis 来实现消息队列还是有很多欠缺的地方比如消息丢失和堆积问题不好解决。因此,我们通常建议不要使用 Redis 来做消息队列,你完全可以选择市面上比较成熟的一些消息队列比如 RocketMQ、Kafka。不过,如果你就是想要用 Redis 来做消息队列的话,那我建议你优先考虑 Stream,这是目前相对最优的 Redis 消息队列实现。\n相关阅读: Redis 消息队列发展历程 - 阿里开发者 - 2022。\nRedis 可以做搜索引擎么? # Redis 是可以实现全文搜索引擎功能的,需要借助 RediSearch ,这是一个基于 Redis 的搜索引擎模块。\nRediSearch 支持中文分词、聚合统计、停用词、同义词、拼写检查、标签查询、向量相似度查询、多关键词搜索、分页搜索等功能,算是一个功能比较完善的全文搜索引擎了。\n相比较于 Elasticsearch 来说,RediSearch 主要在下面两点上表现更优异一些:\n性能更优秀:依赖 Redis 自身的高性能,基于内存操作(Elasticsearch 基于磁盘)。 较低内存占用实现快速索引:RediSearch 内部使用压缩的倒排索引,所以可以用较低的内存占用来实现索引的快速构建。 对于小型项目的简单搜索场景来说,使用 RediSearch 来作为搜索引擎还是没有问题的(搭配 RedisJSON 使用)。\n对于比较复杂或者数据规模较大的搜索场景还是不太建议使用 RediSearch 来作为搜索引擎,主要是因为下面这些限制和问题:\n数据量限制:Elasticsearch 可以支持 PB 级别的数据量,可以轻松扩展到多个节点,利用分片机制提高可用性和性能。RedisSearch 是基于 Redis 实现的,其能存储的数据量受限于 Redis 的内存容量,不太适合存储大规模的数据(内存昂贵,扩展能力较差)。 分布式能力较差:Elasticsearch 是为分布式环境设计的,可以轻松扩展到多个节点。虽然 RedisSearch 支持分布式部署,但在实际应用中可能会面临一些挑战,如数据分片、节点间通信、数据一致性等问题。 聚合功能较弱:Elasticsearch 提供了丰富的聚合功能,而 RediSearch 的聚合功能相对较弱,只支持简单的聚合操作。 生态较差:Elasticsearch 可以轻松和常见的一些系统/软件集成比如 Hadoop、Spark、Kibana,而 RedisSearch 则不具备该优势。 Elasticsearch 适用于全文搜索、复杂查询、实时数据分析和聚合的场景,而 RediSearch 适用于快速数据存储、缓存和简单查询的场景。\n如何基于 Redis 实现延时任务? # 类似的问题:\n订单在 10 分钟后未支付就失效,如何用 Redis 实现? 红包 24 小时未被查收自动退还,如何用 Redis 实现? 基于 Redis 实现延时任务的功能无非就下面两种方案:\nRedis 过期事件监听 Redisson 内置的延时队列 Redis 过期事件监听的存在时效性较差、丢消息、多服务实例下消息重复消费等问题,不被推荐使用。\nRedisson 内置的延时队列具备下面这些优势:\n减少了丢消息的可能:DelayedQueue 中的消息会被持久化,即使 Redis 宕机了,根据持久化机制,也只可能丢失一点消息,影响不大。当然了,你也可以使用扫描数据库的方法作为补偿机制。 消息不存在重复消费问题:每个客户端都是从同一个目标队列中获取任务的,不存在重复消费的问题。 关于 Redis 实现延时任务的详细介绍,可以看我写的这篇文章: 如何基于 Redis 实现延时任务?。\nRedis 数据类型 # 关于 Redis 5 种基础数据类型和 3 种特殊数据类型的详细介绍请看下面这两篇文章以及 Redis 官方文档 :\nRedis 5 种基本数据类型详解 Redis 3 种特殊数据类型详解 Redis 常用的数据类型有哪些? # Redis 中比较常见的数据类型有下面这些:\n5 种基础数据类型:String(字符串)、List(列表)、Set(集合)、Hash(散列)、Zset(有序集合)。 3 种特殊数据类型:HyperLogLog(基数统计)、Bitmap (位图)、Geospatial (地理位置)。 除了上面提到的之外,还有一些其他的比如 Bloom filter(布隆过滤器)、Bitfield(位域)。\nString 的应用场景有哪些? # String 是 Redis 中最简单同时也是最常用的一个数据类型。它是一种二进制安全的数据类型,可以用来存储任何类型的数据比如字符串、整数、浮点数、图片(图片的 base64 编码或者解码或者图片的路径)、序列化后的对象。\nString 的常见应用场景如下:\n常规数据(比如 Session、Token、序列化后的对象、图片的路径)的缓存; 计数比如用户单位时间的请求数(简单限流可以用到)、页面单位时间的访问数; 分布式锁(利用 SETNX key value 命令可以实现一个最简易的分布式锁); …… 关于 String 的详细介绍请看这篇文章: Redis 5 种基本数据类型详解。\nString 还是 Hash 存储对象数据更好呢? # 简单对比一下二者:\n对象存储方式:String 存储的是序列化后的对象数据,存放的是整个对象,操作简单直接。Hash 是对对象的每个字段单独存储,可以获取部分字段的信息,也可以修改或者添加部分字段,节省网络流量。如果对象中某些字段需要经常变动或者经常需要单独查询对象中的个别字段信息,Hash 就非常适合。 内存消耗:Hash 通常比 String 更节省内存,特别是在字段较多且字段长度较短时。Redis 对小型 Hash 进行优化(如使用 ziplist 存储),进一步降低内存占用。 复杂对象存储:String 在处理多层嵌套或复杂结构的对象时更方便,因为无需处理每个字段的独立存储和操作。 性能:String 的操作通常具有 O(1) 的时间复杂度,因为它存储的是整个对象,操作简单直接,整体读写的性能较好。Hash 由于需要处理多个字段的增删改查操作,在字段较多且经常变动的情况下,可能会带来额外的性能开销。 总结:\n在绝大多数情况下,String 更适合存储对象数据,尤其是当对象结构简单且整体读写是主要操作时。 如果你需要频繁操作对象的部分字段或节省内存,Hash 可能是更好的选择。 String 的底层实现是什么? # Redis 是基于 C 语言编写的,但 Redis 的 String 类型的底层实现并不是 C 语言中的字符串(即以空字符 \\0 结尾的字符数组),而是自己编写了 SDS(Simple Dynamic String,简单动态字符串) 来作为底层实现。\nSDS 最早是 Redis 作者为日常 C 语言开发而设计的 C 字符串,后来被应用到了 Redis 上,并经过了大量的修改完善以适合高性能操作。\nRedis7.0 的 SDS 的部分源码如下( https://github.com/redis/redis/blob/7.0/src/sds.h):\n/* Note: sdshdr5 is never used, we just access the flags byte directly. * However is here to document the layout of type 5 SDS strings. */ struct __attribute__ ((__packed__)) sdshdr5 { unsigned char flags; /* 3 lsb of type, and 5 msb of string length */ char buf[]; }; struct __attribute__ ((__packed__)) sdshdr8 { uint8_t len; /* used */ uint8_t alloc; /* excluding the header and null terminator */ unsigned char flags; /* 3 lsb of type, 5 unused bits */ char buf[]; }; struct __attribute__ ((__packed__)) sdshdr16 { uint16_t len; /* used */ uint16_t alloc; /* excluding the header and null terminator */ unsigned char flags; /* 3 lsb of type, 5 unused bits */ char buf[]; }; struct __attribute__ ((__packed__)) sdshdr32 { uint32_t len; /* used */ uint32_t alloc; /* excluding the header and null terminator */ unsigned char flags; /* 3 lsb of type, 5 unused bits */ char buf[]; }; struct __attribute__ ((__packed__)) sdshdr64 { uint64_t len; /* used */ uint64_t alloc; /* excluding the header and null terminator */ unsigned char flags; /* 3 lsb of type, 5 unused bits */ char buf[]; }; 通过源码可以看出,SDS 共有五种实现方式 SDS_TYPE_5(并未用到)、SDS_TYPE_8、SDS_TYPE_16、SDS_TYPE_32、SDS_TYPE_64,其中只有后四种实际用到。Redis 会根据初始化的长度决定使用哪种类型,从而减少内存的使用。\n类型 字节 位 sdshdr5 \u0026lt; 1 \u0026lt;8 sdshdr8 1 8 sdshdr16 2 16 sdshdr32 4 32 sdshdr64 8 64 对于后四种实现都包含了下面这 4 个属性:\nlen:字符串的长度也就是已经使用的字节数 alloc:总共可用的字符空间大小,alloc-len 就是 SDS 剩余的空间大小 buf[]:实际存储字符串的数组 flags:低三位保存类型标志 SDS 相比于 C 语言中的字符串有如下提升:\n可以避免缓冲区溢出:C 语言中的字符串被修改(比如拼接)时,一旦没有分配足够长度的内存空间,就会造成缓冲区溢出。SDS 被修改时,会先根据 len 属性检查空间大小是否满足要求,如果不满足,则先扩展至所需大小再进行修改操作。 获取字符串长度的复杂度较低:C 语言中的字符串的长度通常是经过遍历计数来实现的,时间复杂度为 O(n)。SDS 的长度获取直接读取 len 属性即可,时间复杂度为 O(1)。 减少内存分配次数:为了避免修改(增加/减少)字符串时,每次都需要重新分配内存(C 语言的字符串是这样的),SDS 实现了空间预分配和惰性空间释放两种优化策略。当 SDS 需要增加字符串时,Redis 会为 SDS 分配好内存,并且根据特定的算法分配多余的内存,这样可以减少连续执行字符串增长操作所需的内存重分配次数。当 SDS 需要减少字符串时,这部分内存不会立即被回收,会被记录下来,等待后续使用(支持手动释放,有对应的 API)。 二进制安全:C 语言中的字符串以空字符 \\0 作为字符串结束的标识,这存在一些问题,像一些二进制文件(比如图片、视频、音频)就可能包括空字符,C 字符串无法正确保存。SDS 使用 len 属性判断字符串是否结束,不存在这个问题。 🤐 多提一嘴,很多文章里 SDS 的定义是下面这样的:\nstruct sdshdr { unsigned int len; unsigned int free; char buf[]; }; 这个也没错,Redis 3.2 之前就是这样定义的。后来,由于这种方式的定义存在问题,len 和 free 的定义用了 4 个字节,造成了浪费。Redis 3.2 之后,Redis 改进了 SDS 的定义,将其划分为了现在的 5 种类型。\n购物车信息用 String 还是 Hash 存储更好呢? # 由于购物车中的商品频繁修改和变动,购物车信息建议使用 Hash 存储:\n用户 id 为 key 商品 id 为 field,商品数量为 value 那用户购物车信息的维护具体应该怎么操作呢?\n用户添加商品就是往 Hash 里面增加新的 field 与 value; 查询购物车信息就是遍历对应的 Hash; 更改商品数量直接修改对应的 value 值(直接 set 或者做运算皆可); 删除商品就是删除 Hash 中对应的 field; 清空购物车直接删除对应的 key 即可。 这里只是以业务比较简单的购物车场景举例,实际电商场景下,field 只保存一个商品 id 是没办法满足需求的。\n使用 Redis 实现一个排行榜怎么做? # Redis 中有一个叫做 Sorted Set (有序集合)的数据类型经常被用在各种排行榜的场景,比如直播间送礼物的排行榜、朋友圈的微信步数排行榜、王者荣耀中的段位排行榜、话题热度排行榜等等。\n相关的一些 Redis 命令: ZRANGE (从小到大排序)、 ZREVRANGE (从大到小排序)、ZREVRANK (指定元素排名)。\n《Java 面试指北》 的「技术面试题篇」就有一篇文章详细介绍如何使用 Sorted Set 来设计制作一个排行榜,感兴趣的小伙伴可以看看。\nRedis 的有序集合底层为什么要用跳表,而不用平衡树、红黑树或者 B+树? # 这道面试题很多大厂比较喜欢问,难度还是有点大的。\n平衡树 vs 跳表:平衡树的插入、删除和查询的时间复杂度和跳表一样都是 O(log n)。对于范围查询来说,平衡树也可以通过中序遍历的方式达到和跳表一样的效果。但是它的每一次插入或者删除操作都需要保证整颗树左右节点的绝对平衡,只要不平衡就要通过旋转操作来保持平衡,这个过程是比较耗时的。跳表诞生的初衷就是为了克服平衡树的一些缺点。跳表使用概率平衡而不是严格强制的平衡,因此,跳表中的插入和删除算法比平衡树的等效算法简单得多,速度也快得多。 红黑树 vs 跳表:相比较于红黑树来说,跳表的实现也更简单一些,不需要通过旋转和染色(红黑变换)来保证黑平衡。并且,按照区间来查找数据这个操作,红黑树的效率没有跳表高。 B+树 vs 跳表:B+树更适合作为数据库和文件系统中常用的索引结构之一,它的核心思想是通过可能少的 IO 定位到尽可能多的索引来获得查询数据。对于 Redis 这种内存数据库来说,它对这些并不感冒,因为 Redis 作为内存数据库它不可能存储大量的数据,所以对于索引不需要通过 B+树这种方式进行维护,只需按照概率进行随机维护即可,节约内存。而且使用跳表实现 zset 时相较前者来说更简单一些,在进行插入时只需通过索引将数据插入到链表中合适的位置再随机维护一定高度的索引即可,也不需要像 B+树那样插入时发现失衡时还需要对节点分裂与合并。 另外,我还单独写了一篇文章从有序集合的基本使用到跳表的源码分析和实现,让你会对 Redis 的有序集合底层实现的跳表有着更深刻的理解和掌握 : Redis 为什么用跳表实现有序集合。\nSet 的应用场景是什么? # Redis 中 Set 是一种无序集合,集合中的元素没有先后顺序但都唯一,有点类似于 Java 中的 HashSet 。\nSet 的常见应用场景如下:\n存放的数据不能重复的场景:网站 UV 统计(数据量巨大的场景还是 HyperLogLog更适合一些)、文章点赞、动态点赞等等。 需要获取多个数据源交集、并集和差集的场景:共同好友(交集)、共同粉丝(交集)、共同关注(交集)、好友推荐(差集)、音乐推荐(差集)、订阅号推荐(差集+交集) 等等。 需要随机获取数据源中的元素的场景:抽奖系统、随机点名等等。 使用 Set 实现抽奖系统怎么做? # 如果想要使用 Set 实现一个简单的抽奖系统的话,直接使用下面这几个命令就可以了:\nSADD key member1 member2 ...:向指定集合添加一个或多个元素。 SPOP key count:随机移除并获取指定集合中一个或多个元素,适合不允许重复中奖的场景。 SRANDMEMBER key count : 随机获取指定集合中指定数量的元素,适合允许重复中奖的场景。 使用 Bitmap 统计活跃用户怎么做? # Bitmap 存储的是连续的二进制数字(0 和 1),通过 Bitmap, 只需要一个 bit 位来表示某个元素对应的值或者状态,key 就是对应元素本身 。我们知道 8 个 bit 可以组成一个 byte,所以 Bitmap 本身会极大的节省储存空间。\n你可以将 Bitmap 看作是一个存储二进制数字(0 和 1)的数组,数组中每个元素的下标叫做 offset(偏移量)。\n如果想要使用 Bitmap 统计活跃用户的话,可以使用日期(精确到天)作为 key,然后用户 ID 为 offset,如果当日活跃过就设置为 1。\n初始化数据:\n\u0026gt; SETBIT 20210308 1 1 (integer) 0 \u0026gt; SETBIT 20210308 2 1 (integer) 0 \u0026gt; SETBIT 20210309 1 1 (integer) 0 统计 20210308~20210309 总活跃用户数:\n\u0026gt; BITOP and desk1 20210308 20210309 (integer) 1 \u0026gt; BITCOUNT desk1 (integer) 1 统计 20210308~20210309 在线活跃用户数:\n\u0026gt; BITOP or desk2 20210308 20210309 (integer) 1 \u0026gt; BITCOUNT desk2 (integer) 2 使用 HyperLogLog 统计页面 UV 怎么做? # 使用 HyperLogLog 统计页面 UV 主要需要用到下面这两个命令:\nPFADD key element1 element2 ...:添加一个或多个元素到 HyperLogLog 中。 PFCOUNT key1 key2:获取一个或者多个 HyperLogLog 的唯一计数。 1、将访问指定页面的每个用户 ID 添加到 HyperLogLog 中。\nPFADD PAGE_1:UV USER1 USER2 ...... USERn 2、统计指定页面的 UV。\nPFCOUNT PAGE_1:UV Redis 持久化机制(重要) # Redis 持久化机制(RDB 持久化、AOF 持久化、RDB 和 AOF 的混合持久化) 相关的问题比较多,也比较重要,于是我单独抽了一篇文章来总结 Redis 持久化机制相关的知识点和问题: Redis 持久化机制详解 。\nRedis 线程模型(重要) # 对于读写命令来说,Redis 一直是单线程模型。不过,在 Redis 4.0 版本之后引入了多线程来执行一些大键值对的异步删除操作, Redis 6.0 版本之后引入了多线程来处理网络请求(提高网络 IO 读写性能)。\nRedis 单线程模型了解吗? # Redis 基于 Reactor 模式设计开发了一套高效的事件处理模型 (Netty 的线程模型也基于 Reactor 模式,Reactor 模式不愧是高性能 IO 的基石),这套事件处理模型对应的是 Redis 中的文件事件处理器(file event handler)。由于文件事件处理器(file event handler)是单线程方式运行的,所以我们一般都说 Redis 是单线程模型。\n《Redis 设计与实现》有一段话是这样介绍文件事件处理器的,我觉得写得挺不错。\nRedis 基于 Reactor 模式开发了自己的网络事件处理器:这个处理器被称为文件事件处理器(file event handler)。\n文件事件处理器使用 I/O 多路复用(multiplexing)程序来同时监听多个套接字,并根据套接字目前执行的任务来为套接字关联不同的事件处理器。 当被监听的套接字准备好执行连接应答(accept)、读取(read)、写入(write)、关 闭(close)等操作时,与操作相对应的文件事件就会产生,这时文件事件处理器就会调用套接字之前关联好的事件处理器来处理这些事件。 虽然文件事件处理器以单线程方式运行,但通过使用 I/O 多路复用程序来监听多个套接字,文件事件处理器既实现了高性能的网络通信模型,又可以很好地与 Redis 服务器中其他同样以单线程方式运行的模块进行对接,这保持了 Redis 内部单线程设计的简单性。\n既然是单线程,那怎么监听大量的客户端连接呢?\nRedis 通过 IO 多路复用程序 来监听来自客户端的大量连接(或者说是监听多个 socket),它会将感兴趣的事件及类型(读、写)注册到内核中并监听每个事件是否发生。\n这样的好处非常明显:I/O 多路复用技术的使用让 Redis 不需要额外创建多余的线程来监听客户端的大量连接,降低了资源的消耗(和 NIO 中的 Selector 组件很像)。\n文件事件处理器(file event handler)主要是包含 4 个部分:\n多个 socket(客户端连接) IO 多路复用程序(支持多个客户端连接的关键) 文件事件分派器(将 socket 关联到相应的事件处理器) 事件处理器(连接应答处理器、命令请求处理器、命令回复处理器) 相关阅读: Redis 事件机制详解 。\nRedis6.0 之前为什么不使用多线程? # 虽然说 Redis 是单线程模型,但实际上,Redis 在 4.0 之后的版本中就已经加入了对多线程的支持。\n不过,Redis 4.0 增加的多线程主要是针对一些大键值对的删除操作的命令,使用这些命令就会使用主线程之外的其他线程来“异步处理”,从而减少对主线程的影响。\n为此,Redis 4.0 之后新增了几个异步命令:\nUNLINK:可以看作是 DEL 命令的异步版本。 FLUSHALL ASYNC:用于清空所有数据库的所有键,不限于当前 SELECT 的数据库。 FLUSHDB ASYNC:用于清空当前 SELECT 数据库中的所有键。 总的来说,直到 Redis 6.0 之前,Redis 的主要操作仍然是单线程处理的。\n那 Redis6.0 之前为什么不使用多线程? 我觉得主要原因有 3 点:\n单线程编程容易并且更容易维护; Redis 的性能瓶颈不在 CPU ,主要在内存和网络; 多线程就会存在死锁、线程上下文切换等问题,甚至会影响性能。 相关阅读: 为什么 Redis 选择单线程模型? 。\nRedis6.0 之后为何引入了多线程? # Redis6.0 引入多线程主要是为了提高网络 IO 读写性能,因为这个算是 Redis 中的一个性能瓶颈(Redis 的瓶颈主要受限于内存和网络)。\n虽然,Redis6.0 引入了多线程,但是 Redis 的多线程只是在网络数据的读写这类耗时操作上使用了,执行命令仍然是单线程顺序执行。因此,你也不需要担心线程安全问题。\nRedis6.0 的多线程默认是禁用的,只使用主线程。如需开启需要设置 IO 线程数 \u0026gt; 1,需要修改 redis 配置文件 redis.conf:\nio-threads 4 #设置1的话只会开启主线程,官网建议4核的机器建议设置为2或3个线程,8核的建议设置为6个线程 另外:\nio-threads 的个数一旦设置,不能通过 config 动态设置。 当设置 ssl 后,io-threads 将不工作。 开启多线程后,默认只会使用多线程进行 IO 写入 writes,即发送数据给客户端,如果需要开启多线程 IO 读取 reads,同样需要修改 redis 配置文件 redis.conf :\nio-threads-do-reads yes 但是官网描述开启多线程读并不能有太大提升,因此一般情况下并不建议开启\n相关阅读:\nRedis 6.0 新特性-多线程连环 13 问! Redis 多线程网络模型全面揭秘(推荐) Redis 后台线程了解吗? # 我们虽然经常说 Redis 是单线程模型(主要逻辑是单线程完成的),但实际还有一些后台线程用于执行一些比较耗时的操作:\n通过 bio_close_file 后台线程来释放 AOF / RDB 等过程中产生的临时文件资源。 通过 bio_aof_fsync 后台线程调用 fsync 函数将系统内核缓冲区还未同步到到磁盘的数据强制刷到磁盘( AOF 文件)。 通过 bio_lazy_free后台线程释放大对象(已删除)占用的内存空间. 在bio.h 文件中有定义(Redis 6.0 版本,源码地址: https://github.com/redis/redis/blob/6.0/src/bio.h):\n#ifndef __BIO_H #define __BIO_H /* Exported API */ void bioInit(void); void bioCreateBackgroundJob(int type, void *arg1, void *arg2, void *arg3); unsigned long long bioPendingJobsOfType(int type); unsigned long long bioWaitStepOfType(int type); time_t bioOlderJobOfType(int type); void bioKillThreads(void); /* Background job opcodes */ #define BIO_CLOSE_FILE 0 /* Deferred close(2) syscall. */ #define BIO_AOF_FSYNC 1 /* Deferred AOF fsync. */ #define BIO_LAZY_FREE 2 /* Deferred objects freeing. */ #define BIO_NUM_OPS 3 #endif 关于 Redis 后台线程的详细介绍可以查看 Redis 6.0 后台线程有哪些? 这篇就文章。\nRedis 内存管理 # Redis 给缓存数据设置过期时间有什么用? # 一般情况下,我们设置保存的缓存数据的时候都会设置一个过期时间。为什么呢?\n内存是有限且珍贵的,如果不对缓存数据设置过期时间,那内存占用就会一直增长,最终可能会导致 OOM 问题。通过设置合理的过期时间,Redis 会自动删除暂时不需要的数据,为新的缓存数据腾出空间。\nRedis 自带了给缓存数据设置过期时间的功能,比如:\n127.0.0.1:6379\u0026gt; expire key 60 # 数据在 60s 后过期 (integer) 1 127.0.0.1:6379\u0026gt; setex key 60 value # 数据在 60s 后过期 (setex:[set] + [ex]pire) OK 127.0.0.1:6379\u0026gt; ttl key # 查看数据还有多久过期 (integer) 56 注意 ⚠️:Redis 中除了字符串类型有自己独有设置过期时间的命令 setex 外,其他方法都需要依靠 expire 命令来设置过期时间 。另外, persist 命令可以移除一个键的过期时间。\n过期时间除了有助于缓解内存的消耗,还有什么其他用么?\n很多时候,我们的业务场景就是需要某个数据只在某一时间段内存在,比如我们的短信验证码可能只在 1 分钟内有效,用户登录的 Token 可能只在 1 天内有效。\n如果使用传统的数据库来处理的话,一般都是自己判断过期,这样更麻烦并且性能要差很多。\nRedis 是如何判断数据是否过期的呢? # Redis 通过一个叫做过期字典(可以看作是 hash 表)来保存数据过期的时间。过期字典的键指向 Redis 数据库中的某个 key(键),过期字典的值是一个 long long 类型的整数,这个整数保存了 key 所指向的数据库键的过期时间(毫秒精度的 UNIX 时间戳)。\n过期字典是存储在 redisDb 这个结构里的:\ntypedef struct redisDb { ... dict *dict; //数据库键空间,保存着数据库中所有键值对 dict *expires // 过期字典,保存着键的过期时间 ... } redisDb; 在查询一个 key 的时候,Redis 首先检查该 key 是否存在于过期字典中(时间复杂度为 O(1)),如果不在就直接返回,在的话需要判断一下这个 key 是否过期,过期直接删除 key 然后返回 null。\nRedis 过期 key 删除策略了解么? # 如果假设你设置了一批 key 只能存活 1 分钟,那么 1 分钟后,Redis 是怎么对这批 key 进行删除的呢?\n常用的过期数据的删除策略就下面这几种:\n惰性删除:只会在取出/查询 key 的时候才对数据进行过期检查。这种方式对 CPU 最友好,但是可能会造成太多过期 key 没有被删除。 定期删除:周期性地随机从设置了过期时间的 key 中抽查一批,然后逐个检查这些 key 是否过期,过期就删除 key。相比于惰性删除,定期删除对内存更友好,对 CPU 不太友好。 延迟队列:把设置过期时间的 key 放到一个延迟队列里,到期之后就删除 key。这种方式可以保证每个过期 key 都能被删除,但维护延迟队列太麻烦,队列本身也要占用资源。 定时删除:每个设置了过期时间的 key 都会在设置的时间到达时立即被删除。这种方法可以确保内存中不会有过期的键,但是它对 CPU 的压力最大,因为它需要为每个键都设置一个定时器。 Redis 采用的那种删除策略呢?\nRedis 采用的是 定期删除+惰性/懒汉式删除 结合的策略,这也是大部分缓存框架的选择。定期删除对内存更加友好,惰性删除对 CPU 更加友好。两者各有千秋,结合起来使用既能兼顾 CPU 友好,又能兼顾内存友好。\n下面是我们详细介绍一下 Redis 中的定期删除具体是如何做的。\nRedis 的定期删除过程是随机的(周期性地随机从设置了过期时间的 key 中抽查一批),所以并不保证所有过期键都会被立即删除。这也就解释了为什么有的 key 过期了,并没有被删除。并且,Redis 底层会通过限制删除操作执行的时长和频率来减少删除操作对 CPU 时间的影响。\n另外,定期删除还会受到执行时间和过期 key 的比例的影响:\n执行时间已经超过了阈值,那么就中断这一次定期删除循环,以避免使用过多的 CPU 时间。 如果这一批过期的 key 比例超过一个比例,就会重复执行此删除流程,以更积极地清理过期 key。相应地,如果过期的 key 比例低于这个比例,就会中断这一次定期删除循环,避免做过多的工作而获得很少的内存回收。 Redis 7.2 版本的执行时间阈值是 25ms,过期 key 比例设定值是 10%。\n#define ACTIVE_EXPIRE_CYCLE_FAST_DURATION 1000 /* Microseconds. */ #define ACTIVE_EXPIRE_CYCLE_SLOW_TIME_PERC 25 /* Max % of CPU to use. */ #define ACTIVE_EXPIRE_CYCLE_ACCEPTABLE_STALE 10 /* % of stale keys after which we do extra efforts. */ 每次随机抽查数量是多少?\nexpire.c中定义了每次随机抽查的数量,Redis 7.2 版本为 20 ,也就是说每次会随机选择 20 个设置了过期时间的 key 判断是否过期。\n#define ACTIVE_EXPIRE_CYCLE_KEYS_PER_LOOP 20 /* Keys for each DB loop. */ 如何控制定期删除的执行频率?\n在 Redis 中,定期删除的频率是由 hz 参数控制的。hz 默认为 10,代表每秒执行 10 次,也就是每秒钟进行 10 次尝试来查找并删除过期的 key。\nhz 的取值范围为 1~500。增大 hz 参数的值会提升定期删除的频率。如果你想要更频繁地执行定期删除任务,可以适当增加 hz 的值,但这会加 CPU 的使用率。根据 Redis 官方建议,hz 的值不建议超过 100,对于大部分用户使用默认的 10 就足够了。\n下面是 hz 参数的官方注释,我翻译了其中的重要信息(Redis 7.2 版本)。\n类似的参数还有一个 dynamic-hz,这个参数开启之后 Redis 就会在 hz 的基础上动态计算一个值。Redis 提供并默认启用了使用自适应 hz 值的能力,\n这两个参数都在 Redis 配置文件 redis.conf中:\n# 默认为 10 hz 10 # 默认开启 dynamic-hz yes 多提一嘴,除了定期删除过期 key 这个定期任务之外,还有一些其他定期任务例如关闭超时的客户端连接、更新统计信息,这些定期任务的执行频率也是通过 hz 参数决定。\n为什么定期删除不是把所有过期 key 都删除呢?\n这样会对性能造成太大的影响。如果我们 key 数量非常庞大的话,挨个遍历检查是非常耗时的,会严重影响性能。Redis 设计这种策略的目的是为了平衡内存和性能。\n为什么 key 过期之后不立马把它删掉呢?这样不是会浪费很多内存空间吗?\n因为不太好办到,或者说这种删除方式的成本太高了。假如我们使用延迟队列作为删除策略,这样存在下面这些问题:\n队列本身的开销可能很大:key 多的情况下,一个延迟队列可能无法容纳。 维护延迟队列太麻烦:修改 key 的过期时间就需要调整期在延迟队列中的位置,并且,还需要引入并发控制。 大量 key 集中过期怎么办? # 当 Redis 中存在大量 key 在同一时间点集中过期时,可能会导致以下问题:\n请求延迟增加: Redis 在处理过期 key 时需要消耗 CPU 资源,如果过期 key 数量庞大,会导致 Redis 实例的 CPU 占用率升高,进而影响其他请求的处理速度,造成延迟增加。 内存占用过高: 过期的 key 虽然已经失效,但在 Redis 真正删除它们之前,仍然会占用内存空间。如果过期 key 没有及时清理,可能会导致内存占用过高,甚至引发内存溢出。 为了避免这些问题,可以采取以下方案:\n尽量避免 key 集中过期: 在设置键的过期时间时尽量随机一点。 开启 lazy free 机制: 修改 redis.conf 配置文件,将 lazyfree-lazy-expire 参数设置为 yes,即可开启 lazy free 机制。开启 lazy free 机制后,Redis 会在后台异步删除过期的 key,不会阻塞主线程的运行,从而降低对 Redis 性能的影响。 Redis 内存淘汰策略了解么? # 相关问题:MySQL 里有 2000w 数据,Redis 中只存 20w 的数据,如何保证 Redis 中的数据都是热点数据?\nRedis 的内存淘汰策略只有在运行内存达到了配置的最大内存阈值时才会触发,这个阈值是通过redis.conf的maxmemory参数来定义的。64 位操作系统下,maxmemory 默认为 0 ,表示不限制内存大小。32 位操作系统下,默认的最大内存值是 3GB。\n你可以使用命令 config get maxmemory 来查看 maxmemory的值。\n\u0026gt; config get maxmemory maxmemory 0 Redis 提供了 6 种内存淘汰策略:\nvolatile-lru(least recently used):从已设置过期时间的数据集(server.db[i].expires)中挑选最近最少使用的数据淘汰。 volatile-ttl:从已设置过期时间的数据集(server.db[i].expires)中挑选将要过期的数据淘汰。 volatile-random:从已设置过期时间的数据集(server.db[i].expires)中任意选择数据淘汰。 allkeys-lru(least recently used):从数据集(server.db[i].dict)中移除最近最少使用的数据淘汰。 allkeys-random:从数据集(server.db[i].dict)中任意选择数据淘汰。 no-eviction(默认内存淘汰策略):禁止驱逐数据,当内存不足以容纳新写入数据时,新写入操作会报错。 4.0 版本后增加以下两种:\nvolatile-lfu(least frequently used):从已设置过期时间的数据集(server.db[i].expires)中挑选最不经常使用的数据淘汰。 allkeys-lfu(least frequently used):从数据集(server.db[i].dict)中移除最不经常使用的数据淘汰。 allkeys-xxx 表示从所有的键值中淘汰数据,而 volatile-xxx 表示从设置了过期时间的键值中淘汰数据。\nconfig.c中定义了内存淘汰策略的枚举数组:\nconfigEnum maxmemory_policy_enum[] = { {\u0026#34;volatile-lru\u0026#34;, MAXMEMORY_VOLATILE_LRU}, {\u0026#34;volatile-lfu\u0026#34;, MAXMEMORY_VOLATILE_LFU}, {\u0026#34;volatile-random\u0026#34;,MAXMEMORY_VOLATILE_RANDOM}, {\u0026#34;volatile-ttl\u0026#34;,MAXMEMORY_VOLATILE_TTL}, {\u0026#34;allkeys-lru\u0026#34;,MAXMEMORY_ALLKEYS_LRU}, {\u0026#34;allkeys-lfu\u0026#34;,MAXMEMORY_ALLKEYS_LFU}, {\u0026#34;allkeys-random\u0026#34;,MAXMEMORY_ALLKEYS_RANDOM}, {\u0026#34;noeviction\u0026#34;,MAXMEMORY_NO_EVICTION}, {NULL, 0} }; 你可以使用 config get maxmemory-policy 命令来查看当前 Redis 的内存淘汰策略。\n\u0026gt; config get maxmemory-policy maxmemory-policy noeviction 可以通过config set maxmemory-policy 内存淘汰策略 命令修改内存淘汰策略,立即生效,但这种方式重启 Redis 之后就失效了。修改 redis.conf 中的 maxmemory-policy 参数不会因为重启而失效,不过,需要重启之后修改才能生效。\nmaxmemory-policy noeviction 关于淘汰策略的详细说明可以参考 Redis 官方文档: https://redis.io/docs/reference/eviction/。\n参考 # 《Redis 开发与运维》 《Redis 设计与实现》 《Redis 核心原理与实战》 Redis 命令手册: https://www.redis.com.cn/commands.html RedisSearch 终极使用指南,你值得拥有!: https://mp.weixin.qq.com/s/FA4XVAXJksTOHUXMsayy2g WHY Redis choose single thread (vs multi threads): https://medium.com/@jychen7/sharing-redis-single-thread-vs-multi-threads-5870bd44d153 "},{"id":559,"href":"/zh/docs/technology/Interview/database/redis/redis-questions-02/","title":"Redis常见面试题总结(下)","section":"Redis","content":" Redis 事务 # 什么是 Redis 事务? # 你可以将 Redis 中的事务理解为:Redis 事务提供了一种将多个命令请求打包的功能。然后,再按顺序执行打包的所有命令,并且不会被中途打断。\nRedis 事务实际开发中使用的非常少,功能比较鸡肋,不要将其和我们平时理解的关系型数据库的事务混淆了。\n除了不满足原子性和持久性之外,事务中的每条命令都会与 Redis 服务器进行网络交互,这是比较浪费资源的行为。明明一次批量执行多个命令就可以了,这种操作实在是看不懂。\n因此,Redis 事务是不建议在日常开发中使用的。\n如何使用 Redis 事务? # Redis 可以通过 MULTI,EXEC,DISCARD 和 WATCH 等命令来实现事务(Transaction)功能。\n\u0026gt; MULTI OK \u0026gt; SET PROJECT \u0026#34;JavaGuide\u0026#34; QUEUED \u0026gt; GET PROJECT QUEUED \u0026gt; EXEC 1) OK 2) \u0026#34;JavaGuide\u0026#34; MULTI 命令后可以输入多个命令,Redis 不会立即执行这些命令,而是将它们放到队列,当调用了 EXEC 命令后,再执行所有的命令。\n这个过程是这样的:\n开始事务(MULTI); 命令入队(批量操作 Redis 的命令,先进先出(FIFO)的顺序执行); 执行事务(EXEC)。 你也可以通过 DISCARD 命令取消一个事务,它会清空事务队列中保存的所有命令。\n\u0026gt; MULTI OK \u0026gt; SET PROJECT \u0026#34;JavaGuide\u0026#34; QUEUED \u0026gt; GET PROJECT QUEUED \u0026gt; DISCARD OK 你可以通过 WATCH 命令监听指定的 Key,当调用 EXEC 命令执行事务时,如果一个被 WATCH 命令监视的 Key 被 其他客户端/Session 修改的话,整个事务都不会被执行。\n# 客户端 1 \u0026gt; SET PROJECT \u0026#34;RustGuide\u0026#34; OK \u0026gt; WATCH PROJECT OK \u0026gt; MULTI OK \u0026gt; SET PROJECT \u0026#34;JavaGuide\u0026#34; QUEUED # 客户端 2 # 在客户端 1 执行 EXEC 命令提交事务之前修改 PROJECT 的值 \u0026gt; SET PROJECT \u0026#34;GoGuide\u0026#34; # 客户端 1 # 修改失败,因为 PROJECT 的值被客户端2修改了 \u0026gt; EXEC (nil) \u0026gt; GET PROJECT \u0026#34;GoGuide\u0026#34; 不过,如果 WATCH 与 事务 在同一个 Session 里,并且被 WATCH 监视的 Key 被修改的操作发生在事务内部,这个事务是可以被执行成功的(相关 issue: WATCH 命令碰到 MULTI 命令时的不同效果)。\n事务内部修改 WATCH 监视的 Key:\n\u0026gt; SET PROJECT \u0026#34;JavaGuide\u0026#34; OK \u0026gt; WATCH PROJECT OK \u0026gt; MULTI OK \u0026gt; SET PROJECT \u0026#34;JavaGuide1\u0026#34; QUEUED \u0026gt; SET PROJECT \u0026#34;JavaGuide2\u0026#34; QUEUED \u0026gt; SET PROJECT \u0026#34;JavaGuide3\u0026#34; QUEUED \u0026gt; EXEC 1) OK 2) OK 3) OK 127.0.0.1:6379\u0026gt; GET PROJECT \u0026#34;JavaGuide3\u0026#34; 事务外部修改 WATCH 监视的 Key:\n\u0026gt; SET PROJECT \u0026#34;JavaGuide\u0026#34; OK \u0026gt; WATCH PROJECT OK \u0026gt; SET PROJECT \u0026#34;JavaGuide2\u0026#34; OK \u0026gt; MULTI OK \u0026gt; GET USER QUEUED \u0026gt; EXEC (nil) Redis 官网相关介绍 https://redis.io/topics/transactions 如下:\nRedis 事务支持原子性吗? # Redis 的事务和我们平时理解的关系型数据库的事务不同。我们知道事务具有四大特性:1. 原子性,2. 隔离性,3. 持久性,4. 一致性。\n原子性(Atomicity): 事务是最小的执行单位,不允许分割。事务的原子性确保动作要么全部完成,要么完全不起作用; 隔离性(Isolation): 并发访问数据库时,一个用户的事务不被其他事务所干扰,各并发事务之间数据库是独立的; 持久性(Durability): 一个事务被提交之后。它对数据库中数据的改变是持久的,即使数据库发生故障也不应该对其有任何影响。 一致性(Consistency): 执行事务前后,数据保持一致,多个事务对同一个数据读取的结果是相同的; Redis 事务在运行错误的情况下,除了执行过程中出现错误的命令外,其他命令都能正常执行。并且,Redis 事务是不支持回滚(roll back)操作的。因此,Redis 事务其实是不满足原子性的。\nRedis 官网也解释了自己为啥不支持回滚。简单来说就是 Redis 开发者们觉得没必要支持回滚,这样更简单便捷并且性能更好。Redis 开发者觉得即使命令执行错误也应该在开发过程中就被发现而不是生产过程中。\n相关 issue :\nissue#452: 关于 Redis 事务不满足原子性的问题 。 Issue#491:关于 Redis 没有事务回滚? Redis 事务支持持久性吗? # Redis 不同于 Memcached 的很重要一点就是,Redis 支持持久化,而且支持 3 种持久化方式:\n快照(snapshotting,RDB) 只追加文件(append-only file, AOF) RDB 和 AOF 的混合持久化(Redis 4.0 新增) 与 RDB 持久化相比,AOF 持久化的实时性更好。在 Redis 的配置文件中存在三种不同的 AOF 持久化方式( fsync策略),它们分别是:\nappendfsync always #每次有数据修改发生时都会调用fsync函数同步AOF文件,fsync完成后线程返回,这样会严重降低Redis的速度 appendfsync everysec #每秒钟调用fsync函数同步一次AOF文件 appendfsync no #让操作系统决定何时进行同步,一般为30秒一次 AOF 持久化的fsync策略为 no、everysec 时都会存在数据丢失的情况 。always 下可以基本是可以满足持久性要求的,但性能太差,实际开发过程中不会使用。\n因此,Redis 事务的持久性也是没办法保证的。\n如何解决 Redis 事务的缺陷? # Redis 从 2.6 版本开始支持执行 Lua 脚本,它的功能和事务非常类似。我们可以利用 Lua 脚本来批量执行多条 Redis 命令,这些 Redis 命令会被提交到 Redis 服务器一次性执行完成,大幅减小了网络开销。\n一段 Lua 脚本可以视作一条命令执行,一段 Lua 脚本执行过程中不会有其他脚本或 Redis 命令同时执行,保证了操作不会被其他指令插入或打扰。\n不过,如果 Lua 脚本运行时出错并中途结束,出错之后的命令是不会被执行的。并且,出错之前执行的命令是无法被撤销的,无法实现类似关系型数据库执行失败可以回滚的那种原子性效果。因此, 严格来说的话,通过 Lua 脚本来批量执行 Redis 命令实际也是不完全满足原子性的。\n如果想要让 Lua 脚本中的命令全部执行,必须保证语句语法和命令都是对的。\n另外,Redis 7.0 新增了 Redis functions 特性,你可以将 Redis functions 看作是比 Lua 更强大的脚本。\nRedis 性能优化(重要) # 除了下面介绍的内容之外,再推荐两篇不错的文章:\n你的 Redis 真的变慢了吗?性能优化如何做 - 阿里开发者 Redis 常见阻塞原因总结 - JavaGuide 使用批量操作减少网络传输 # 一个 Redis 命令的执行可以简化为以下 4 步:\n发送命令 命令排队 命令执行 返回结果 其中,第 1 步和第 4 步耗费时间之和称为 Round Trip Time (RTT,往返时间) ,也就是数据在网络上传输的时间。\n使用批量操作可以减少网络传输次数,进而有效减小网络开销,大幅减少 RTT。\n另外,除了能减少 RTT 之外,发送一次命令的 socket I/O 成本也比较高(涉及上下文切换,存在read()和write()系统调用),批量操作还可以减少 socket I/O 成本。这个在官方对 pipeline 的介绍中有提到: https://redis.io/docs/manual/pipelining/ 。\n原生批量操作命令 # Redis 中有一些原生支持批量操作的命令,比如:\nMGET(获取一个或多个指定 key 的值)、MSET(设置一个或多个指定 key 的值)、 HMGET(获取指定哈希表中一个或者多个指定字段的值)、HMSET(同时将一个或多个 field-value 对设置到指定哈希表中)、 SADD(向指定集合添加一个或多个元素) …… 不过,在 Redis 官方提供的分片集群解决方案 Redis Cluster 下,使用这些原生批量操作命令可能会存在一些小问题需要解决。就比如说 MGET 无法保证所有的 key 都在同一个 hash slot(哈希槽)上,MGET可能还是需要多次网络传输,原子操作也无法保证了。不过,相较于非批量操作,还是可以节省不少网络传输次数。\n整个步骤的简化版如下(通常由 Redis 客户端实现,无需我们自己再手动实现):\n找到 key 对应的所有 hash slot; 分别向对应的 Redis 节点发起 MGET 请求获取数据; 等待所有请求执行结束,重新组装结果数据,保持跟入参 key 的顺序一致,然后返回结果。 如果想要解决这个多次网络传输的问题,比较常用的办法是自己维护 key 与 slot 的关系。不过这样不太灵活,虽然带来了性能提升,但同样让系统复杂性提升。\nRedis Cluster 并没有使用一致性哈希,采用的是 哈希槽分区 ,每一个键值对都属于一个 hash slot(哈希槽) 。当客户端发送命令请求的时候,需要先根据 key 通过上面的计算公示找到的对应的哈希槽,然后再查询哈希槽和节点的映射关系,即可找到目标 Redis 节点。\n我在 Redis 集群详解(付费) 这篇文章中详细介绍了 Redis Cluster 这部分的内容,感兴趣地可以看看。\npipeline # 对于不支持批量操作的命令,我们可以利用 pipeline(流水线) 将一批 Redis 命令封装成一组,这些 Redis 命令会被一次性提交到 Redis 服务器,只需要一次网络传输。不过,需要注意控制一次批量操作的 元素个数(例如 500 以内,实际也和元素字节数有关),避免网络传输的数据量过大。\n与MGET、MSET等原生批量操作命令一样,pipeline 同样在 Redis Cluster 上使用会存在一些小问题。原因类似,无法保证所有的 key 都在同一个 hash slot(哈希槽)上。如果想要使用的话,客户端需要自己维护 key 与 slot 的关系。\n原生批量操作命令和 pipeline 的是有区别的,使用的时候需要注意:\n原生批量操作命令是原子操作,pipeline 是非原子操作。 pipeline 可以打包不同的命令,原生批量操作命令不可以。 原生批量操作命令是 Redis 服务端支持实现的,而 pipeline 需要服务端和客户端的共同实现。 顺带补充一下 pipeline 和 Redis 事务的对比:\n事务是原子操作,pipeline 是非原子操作。两个不同的事务不会同时运行,而 pipeline 可以同时以交错方式执行。 Redis 事务中每个命令都需要发送到服务端,而 Pipeline 只需要发送一次,请求次数更少。 事务可以看作是一个原子操作,但其实并不满足原子性。当我们提到 Redis 中的原子操作时,主要指的是这个操作(比如事务、Lua 脚本)不会被其他操作(比如其他事务、Lua 脚本)打扰,并不能完全保证这个操作中的所有写命令要么都执行要么都不执行。这主要也是因为 Redis 是不支持回滚操作。\n另外,pipeline 不适用于执行顺序有依赖关系的一批命令。就比如说,你需要将前一个命令的结果给后续的命令使用,pipeline 就没办法满足你的需求了。对于这种需求,我们可以使用 Lua 脚本 。\nLua 脚本 # Lua 脚本同样支持批量操作多条命令。一段 Lua 脚本可以视作一条命令执行,可以看作是 原子操作 。也就是说,一段 Lua 脚本执行过程中不会有其他脚本或 Redis 命令同时执行,保证了操作不会被其他指令插入或打扰,这是 pipeline 所不具备的。\n并且,Lua 脚本中支持一些简单的逻辑处理比如使用命令读取值并在 Lua 脚本中进行处理,这同样是 pipeline 所不具备的。\n不过, Lua 脚本依然存在下面这些缺陷:\n如果 Lua 脚本运行时出错并中途结束,之后的操作不会进行,但是之前已经发生的写操作不会撤销,所以即使使用了 Lua 脚本,也不能实现类似数据库回滚的原子性。 Redis Cluster 下 Lua 脚本的原子操作也无法保证了,原因同样是无法保证所有的 key 都在同一个 hash slot(哈希槽)上。 大量 key 集中过期问题 # 我在前面提到过:对于过期 key,Redis 采用的是 定期删除+惰性/懒汉式删除 策略。\n定期删除执行过程中,如果突然遇到大量过期 key 的话,客户端请求必须等待定期清理过期 key 任务线程执行完成,因为这个这个定期任务线程是在 Redis 主线程中执行的。这就导致客户端请求没办法被及时处理,响应速度会比较慢。\n如何解决呢? 下面是两种常见的方法:\n给 key 设置随机过期时间。 开启 lazy-free(惰性删除/延迟释放) 。lazy-free 特性是 Redis 4.0 开始引入的,指的是让 Redis 采用异步方式延迟释放 key 使用的内存,将该操作交给单独的子线程处理,避免阻塞主线程。 个人建议不管是否开启 lazy-free,我们都尽量给 key 设置随机过期时间。\nRedis bigkey(大 Key) # 什么是 bigkey? # 简单来说,如果一个 key 对应的 value 所占用的内存比较大,那这个 key 就可以看作是 bigkey。具体多大才算大呢?有一个不是特别精确的参考标准:\nString 类型的 value 超过 1MB 复合类型(List、Hash、Set、Sorted Set 等)的 value 包含的元素超过 5000 个(不过,对于复合类型的 value 来说,不一定包含的元素越多,占用的内存就越多)。 bigkey 是怎么产生的?有什么危害? # bigkey 通常是由于下面这些原因产生的:\n程序设计不当,比如直接使用 String 类型存储较大的文件对应的二进制数据。 对于业务的数据规模考虑不周到,比如使用集合类型的时候没有考虑到数据量的快速增长。 未及时清理垃圾数据,比如哈希中冗余了大量的无用键值对。 bigkey 除了会消耗更多的内存空间和带宽,还会对性能造成比较大的影响。\n在 Redis 常见阻塞原因总结这篇文章中我们提到:大 key 还会造成阻塞问题。具体来说,主要体现在下面三个方面:\n客户端超时阻塞:由于 Redis 执行命令是单线程处理,然后在操作大 key 时会比较耗时,那么就会阻塞 Redis,从客户端这一视角看,就是很久很久都没有响应。 网络阻塞:每次获取大 key 产生的网络流量较大,如果一个 key 的大小是 1 MB,每秒访问量为 1000,那么每秒会产生 1000MB 的流量,这对于普通千兆网卡的服务器来说是灾难性的。 工作线程阻塞:如果使用 del 删除大 key 时,会阻塞工作线程,这样就没办法处理后续的命令。 大 key 造成的阻塞问题还会进一步影响到主从同步和集群扩容。\n综上,大 key 带来的潜在问题是非常多的,我们应该尽量避免 Redis 中存在 bigkey。\n如何发现 bigkey? # 1、使用 Redis 自带的 --bigkeys 参数来查找。\n# redis-cli -p 6379 --bigkeys # Scanning the entire keyspace to find biggest keys as well as # average sizes per key type. You can use -i 0.1 to sleep 0.1 sec # per 100 SCAN commands (not usually needed). [00.00%] Biggest string found so far \u0026#39;\u0026#34;ballcat:oauth:refresh_auth:f6cdb384-9a9d-4f2f-af01-dc3f28057c20\u0026#34;\u0026#39; with 4437 bytes [00.00%] Biggest list found so far \u0026#39;\u0026#34;my-list\u0026#34;\u0026#39; with 17 items -------- summary ------- Sampled 5 keys in the keyspace! Total key length in bytes is 264 (avg len 52.80) Biggest list found \u0026#39;\u0026#34;my-list\u0026#34;\u0026#39; has 17 items Biggest string found \u0026#39;\u0026#34;ballcat:oauth:refresh_auth:f6cdb384-9a9d-4f2f-af01-dc3f28057c20\u0026#34;\u0026#39; has 4437 bytes 1 lists with 17 items (20.00% of keys, avg size 17.00) 0 hashs with 0 fields (00.00% of keys, avg size 0.00) 4 strings with 4831 bytes (80.00% of keys, avg size 1207.75) 0 streams with 0 entries (00.00% of keys, avg size 0.00) 0 sets with 0 members (00.00% of keys, avg size 0.00) 0 zsets with 0 members (00.00% of keys, avg size 0.00 从这个命令的运行结果,我们可以看出:这个命令会扫描(Scan) Redis 中的所有 key ,会对 Redis 的性能有一点影响。并且,这种方式只能找出每种数据结构 top 1 bigkey(占用内存最大的 String 数据类型,包含元素最多的复合数据类型)。然而,一个 key 的元素多并不代表占用内存也多,需要我们根据具体的业务情况来进一步判断。\n在线上执行该命令时,为了降低对 Redis 的影响,需要指定 -i 参数控制扫描的频率。redis-cli -p 6379 --bigkeys -i 3 表示扫描过程中每次扫描后休息的时间间隔为 3 秒。\n2、使用 Redis 自带的 SCAN 命令\nSCAN 命令可以按照一定的模式和数量返回匹配的 key。获取了 key 之后,可以利用 STRLEN、HLEN、LLEN等命令返回其长度或成员数量。\n数据结构 命令 复杂度 结果(对应 key) String STRLEN O(1) 字符串值的长度 Hash HLEN O(1) 哈希表中字段的数量 List LLEN O(1) 列表元素数量 Set SCARD O(1) 集合元素数量 Sorted Set ZCARD O(1) 有序集合的元素数量 对于集合类型还可以使用 MEMORY USAGE 命令(Redis 4.0+),这个命令会返回键值对占用的内存空间。\n3、借助开源工具分析 RDB 文件。\n通过分析 RDB 文件来找出 big key。这种方案的前提是你的 Redis 采用的是 RDB 持久化。\n网上有现成的代码/工具可以直接拿来使用:\nredis-rdb-tools:Python 语言写的用来分析 Redis 的 RDB 快照文件用的工具 rdb_bigkeys : Go 语言写的用来分析 Redis 的 RDB 快照文件用的工具,性能更好。 4、借助公有云的 Redis 分析服务。\n如果你用的是公有云的 Redis 服务的话,可以看看其是否提供了 key 分析功能(一般都提供了)。\n这里以阿里云 Redis 为例说明,它支持 bigkey 实时分析、发现,文档地址: https://www.alibabacloud.com/help/zh/apsaradb-for-redis/latest/use-the-real-time-key-statistics-feature 。\n如何处理 bigkey? # bigkey 的常见处理以及优化办法如下(这些方法可以配合起来使用):\n分割 bigkey:将一个 bigkey 分割为多个小 key。例如,将一个含有上万字段数量的 Hash 按照一定策略(比如二次哈希)拆分为多个 Hash。 手动清理:Redis 4.0+ 可以使用 UNLINK 命令来异步删除一个或多个指定的 key。Redis 4.0 以下可以考虑使用 SCAN 命令结合 DEL 命令来分批次删除。 采用合适的数据结构:例如,文件二进制数据不使用 String 保存、使用 HyperLogLog 统计页面 UV、Bitmap 保存状态信息(0/1)。 开启 lazy-free(惰性删除/延迟释放) :lazy-free 特性是 Redis 4.0 开始引入的,指的是让 Redis 采用异步方式延迟释放 key 使用的内存,将该操作交给单独的子线程处理,避免阻塞主线程。 Redis hotkey(热 Key) # 什么是 hotkey? # 如果一个 key 的访问次数比较多且明显多于其他 key 的话,那这个 key 就可以看作是 hotkey(热 Key)。例如在 Redis 实例的每秒处理请求达到 5000 次,而其中某个 key 的每秒访问量就高达 2000 次,那这个 key 就可以看作是 hotkey。\nhotkey 出现的原因主要是某个热点数据访问量暴增,如重大的热搜事件、参与秒杀的商品。\nhotkey 有什么危害? # 处理 hotkey 会占用大量的 CPU 和带宽,可能会影响 Redis 实例对其他请求的正常处理。此外,如果突然访问 hotkey 的请求超出了 Redis 的处理能力,Redis 就会直接宕机。这种情况下,大量请求将落到后面的数据库上,可能会导致数据库崩溃。\n因此,hotkey 很可能成为系统性能的瓶颈点,需要单独对其进行优化,以确保系统的高可用性和稳定性。\n如何发现 hotkey? # 1、使用 Redis 自带的 --hotkeys 参数来查找。\nRedis 4.0.3 版本中新增了 hotkeys 参数,该参数能够返回所有 key 的被访问次数。\n使用该方案的前提条件是 Redis Server 的 maxmemory-policy 参数设置为 LFU 算法,不然就会出现如下所示的错误。\n# redis-cli -p 6379 --hotkeys # Scanning the entire keyspace to find hot keys as well as # average sizes per key type. You can use -i 0.1 to sleep 0.1 sec # per 100 SCAN commands (not usually needed). Error: ERR An LFU maxmemory policy is not selected, access frequency not tracked. Please note that when switching between policies at runtime LRU and LFU data will take some time to adjust. Redis 中有两种 LFU 算法:\nvolatile-lfu(least frequently used):从已设置过期时间的数据集(server.db[i].expires)中挑选最不经常使用的数据淘汰。 allkeys-lfu(least frequently used):当内存不足以容纳新写入数据时,在键空间中,移除最不经常使用的 key。 以下是配置文件 redis.conf 中的示例:\n# 使用 volatile-lfu 策略 maxmemory-policy volatile-lfu # 或者使用 allkeys-lfu 策略 maxmemory-policy allkeys-lfu 需要注意的是,hotkeys 参数命令也会增加 Redis 实例的 CPU 和内存消耗(全局扫描),因此需要谨慎使用。\n2、使用MONITOR 命令。\nMONITOR 命令是 Redis 提供的一种实时查看 Redis 的所有操作的方式,可以用于临时监控 Redis 实例的操作情况,包括读写、删除等操作。\n由于该命令对 Redis 性能的影响比较大,因此禁止长时间开启 MONITOR(生产环境中建议谨慎使用该命令)。\n# redis-cli 127.0.0.1:6379\u0026gt; MONITOR OK 1683638260.637378 [0 172.17.0.1:61516] \u0026#34;ping\u0026#34; 1683638267.144236 [0 172.17.0.1:61518] \u0026#34;smembers\u0026#34; \u0026#34;mySet\u0026#34; 1683638268.941863 [0 172.17.0.1:61518] \u0026#34;smembers\u0026#34; \u0026#34;mySet\u0026#34; 1683638269.551671 [0 172.17.0.1:61518] \u0026#34;smembers\u0026#34; \u0026#34;mySet\u0026#34; 1683638270.646256 [0 172.17.0.1:61516] \u0026#34;ping\u0026#34; 1683638270.849551 [0 172.17.0.1:61518] \u0026#34;smembers\u0026#34; \u0026#34;mySet\u0026#34; 1683638271.926945 [0 172.17.0.1:61518] \u0026#34;smembers\u0026#34; \u0026#34;mySet\u0026#34; 1683638274.276599 [0 172.17.0.1:61518] \u0026#34;smembers\u0026#34; \u0026#34;mySet2\u0026#34; 1683638276.327234 [0 172.17.0.1:61518] \u0026#34;smembers\u0026#34; \u0026#34;mySet\u0026#34; 在发生紧急情况时,我们可以选择在合适的时机短暂执行 MONITOR 命令并将输出重定向至文件,在关闭 MONITOR 命令后通过对文件中请求进行归类分析即可找出这段时间中的 hotkey。\n3、借助开源项目。\n京东零售的 hotkey 这个项目不光支持 hotkey 的发现,还支持 hotkey 的处理。\n4、根据业务情况提前预估。\n可以根据业务情况来预估一些 hotkey,比如参与秒杀活动的商品数据等。不过,我们无法预估所有 hotkey 的出现,比如突发的热点新闻事件等。\n5、业务代码中记录分析。\n在业务代码中添加相应的逻辑对 key 的访问情况进行记录分析。不过,这种方式会让业务代码的复杂性增加,一般也不会采用。\n6、借助公有云的 Redis 分析服务。\n如果你用的是公有云的 Redis 服务的话,可以看看其是否提供了 key 分析功能(一般都提供了)。\n这里以阿里云 Redis 为例说明,它支持 hotkey 实时分析、发现,文档地址: https://www.alibabacloud.com/help/zh/apsaradb-for-redis/latest/use-the-real-time-key-statistics-feature 。\n如何解决 hotkey? # hotkey 的常见处理以及优化办法如下(这些方法可以配合起来使用):\n读写分离:主节点处理写请求,从节点处理读请求。 使用 Redis Cluster:将热点数据分散存储在多个 Redis 节点上。 二级缓存:hotkey 采用二级缓存的方式进行处理,将 hotkey 存放一份到 JVM 本地内存中(可以用 Caffeine)。 除了这些方法之外,如果你使用的公有云的 Redis 服务话,还可以留意其提供的开箱即用的解决方案。\n这里以阿里云 Redis 为例说明,它支持通过代理查询缓存功能(Proxy Query Cache)优化热点 Key 问题。\n慢查询命令 # 为什么会有慢查询命令? # 我们知道一个 Redis 命令的执行可以简化为以下 4 步:\n发送命令 命令排队 命令执行 返回结果 Redis 慢查询统计的是命令执行这一步骤的耗时,慢查询命令也就是那些命令执行时间较长的命令。\nRedis 为什么会有慢查询命令呢?\nRedis 中的大部分命令都是 O(1)时间复杂度,但也有少部分 O(n) 时间复杂度的命令,例如:\nKEYS *:会返回所有符合规则的 key。 HGETALL:会返回一个 Hash 中所有的键值对。 LRANGE:会返回 List 中指定范围内的元素。 SMEMBERS:返回 Set 中的所有元素。 SINTER/SUNION/SDIFF:计算多个 Set 的交集/并集/差集。 …… 由于这些命令时间复杂度是 O(n),有时候也会全表扫描,随着 n 的增大,执行耗时也会越长。不过, 这些命令并不是一定不能使用,但是需要明确 N 的值。另外,有遍历的需求可以使用 HSCAN、SSCAN、ZSCAN 代替。\n除了这些 O(n)时间复杂度的命令可能会导致慢查询之外, 还有一些时间复杂度可能在 O(N) 以上的命令,例如:\nZRANGE/ZREVRANGE:返回指定 Sorted Set 中指定排名范围内的所有元素。时间复杂度为 O(log(n)+m),n 为所有元素的数量, m 为返回的元素数量,当 m 和 n 相当大时,O(n) 的时间复杂度更小。 ZREMRANGEBYRANK/ZREMRANGEBYSCORE:移除 Sorted Set 中指定排名范围/指定 score 范围内的所有元素。时间复杂度为 O(log(n)+m),n 为所有元素的数量, m 被删除元素的数量,当 m 和 n 相当大时,O(n) 的时间复杂度更小。 …… 如何找到慢查询命令? # 在 redis.conf 文件中,我们可以使用 slowlog-log-slower-than 参数设置耗时命令的阈值,并使用 slowlog-max-len 参数设置耗时命令的最大记录条数。\n当 Redis 服务器检测到执行时间超过 slowlog-log-slower-than阈值的命令时,就会将该命令记录在慢查询日志(slow log) 中,这点和 MySQL 记录慢查询语句类似。当慢查询日志超过设定的最大记录条数之后,Redis 会把最早的执行命令依次舍弃。\n⚠️注意:由于慢查询日志会占用一定内存空间,如果设置最大记录条数过大,可能会导致内存占用过高的问题。\nslowlog-log-slower-than和slowlog-max-len的默认配置如下(可以自行修改):\n# The following time is expressed in microseconds, so 1000000 is equivalent # to one second. Note that a negative number disables the slow log, while # a value of zero forces the logging of every command. slowlog-log-slower-than 10000 # There is no limit to this length. Just be aware that it will consume memory. # You can reclaim memory used by the slow log with SLOWLOG RESET. slowlog-max-len 128 除了修改配置文件之外,你也可以直接通过 CONFIG 命令直接设置:\n# 命令执行耗时超过 10000 微妙(即10毫秒)就会被记录 CONFIG SET slowlog-log-slower-than 10000 # 只保留最近 128 条耗时命令 CONFIG SET slowlog-max-len 128 获取慢查询日志的内容很简单,直接使用SLOWLOG GET 命令即可。\n127.0.0.1:6379\u0026gt; SLOWLOG GET #慢日志查询 1) 1) (integer) 5 2) (integer) 1684326682 3) (integer) 12000 4) 1) \u0026#34;KEYS\u0026#34; 2) \u0026#34;*\u0026#34; 5) \u0026#34;172.17.0.1:61152\u0026#34; 6) \u0026#34;\u0026#34; // ... 慢查询日志中的每个条目都由以下六个值组成:\n唯一渐进的日志标识符。 处理记录命令的 Unix 时间戳。 执行所需的时间量,以微秒为单位。 组成命令参数的数组。 客户端 IP 地址和端口。 客户端名称。 SLOWLOG GET 命令默认返回最近 10 条的的慢查询命令,你也自己可以指定返回的慢查询命令的数量 SLOWLOG GET N。\n下面是其他比较常用的慢查询相关的命令:\n# 返回慢查询命令的数量 127.0.0.1:6379\u0026gt; SLOWLOG LEN (integer) 128 # 清空慢查询命令 127.0.0.1:6379\u0026gt; SLOWLOG RESET OK Redis 内存碎片 # 相关问题:\n什么是内存碎片?为什么会有 Redis 内存碎片? 如何清理 Redis 内存碎片? 参考答案: Redis 内存碎片详解。\nRedis 生产问题(重要) # 缓存穿透 # 什么是缓存穿透? # 缓存穿透说简单点就是大量请求的 key 是不合理的,根本不存在于缓存中,也不存在于数据库中 。这就导致这些请求直接到了数据库上,根本没有经过缓存这一层,对数据库造成了巨大的压力,可能直接就被这么多请求弄宕机了。\n举个例子:某个黑客故意制造一些非法的 key 发起大量请求,导致大量请求落到数据库,结果数据库上也没有查到对应的数据。也就是说这些请求最终都落到了数据库上,对数据库造成了巨大的压力。\n有哪些解决办法? # 最基本的就是首先做好参数校验,一些不合法的参数请求直接抛出异常信息返回给客户端。比如查询的数据库 id 不能小于 0、传入的邮箱格式不对的时候直接返回错误消息给客户端等等。\n1)缓存无效 key\n如果缓存和数据库都查不到某个 key 的数据就写一个到 Redis 中去并设置过期时间,具体命令如下:SET key value EX 10086 。这种方式可以解决请求的 key 变化不频繁的情况,如果黑客恶意攻击,每次构建不同的请求 key,会导致 Redis 中缓存大量无效的 key 。很明显,这种方案并不能从根本上解决此问题。如果非要用这种方式来解决穿透问题的话,尽量将无效的 key 的过期时间设置短一点比如 1 分钟。\n另外,这里多说一嘴,一般情况下我们是这样设计 key 的:表名:列名:主键名:主键值 。\n如果用 Java 代码展示的话,差不多是下面这样的:\npublic Object getObjectInclNullById(Integer id) { // 从缓存中获取数据 Object cacheValue = cache.get(id); // 缓存为空 if (cacheValue == null) { // 从数据库中获取 Object storageValue = storage.get(key); // 缓存空对象 cache.set(key, storageValue); // 如果存储数据为空,需要设置一个过期时间(300秒) if (storageValue == null) { // 必须设置过期时间,否则有被攻击的风险 cache.expire(key, 60 * 5); } return storageValue; } return cacheValue; } 2)布隆过滤器\n布隆过滤器是一个非常神奇的数据结构,通过它我们可以非常方便地判断一个给定数据是否存在于海量数据中。我们可以把它看作由二进制向量(或者说位数组)和一系列随机映射函数(哈希函数)两部分组成的数据结构。相比于我们平时常用的 List、Map、Set 等数据结构,它占用空间更少并且效率更高,但是缺点是其返回的结果是概率性的,而不是非常准确的。理论情况下添加到集合中的元素越多,误报的可能性就越大。并且,存放在布隆过滤器的数据不容易删除。\nBloom Filter 会使用一个较大的 bit 数组来保存所有的数据,数组中的每个元素都只占用 1 bit ,并且每个元素只能是 0 或者 1(代表 false 或者 true),这也是 Bloom Filter 节省内存的核心所在。这样来算的话,申请一个 100w 个元素的位数组只占用 1000000Bit / 8 = 125000 Byte = 125000/1024 KB ≈ 122KB 的空间。\n具体是这样做的:把所有可能存在的请求的值都存放在布隆过滤器中,当用户请求过来,先判断用户发来的请求的值是否存在于布隆过滤器中。不存在的话,直接返回请求参数错误信息给客户端,存在的话才会走下面的流程。\n加入布隆过滤器之后的缓存处理流程图如下。\n更多关于布隆过滤器的详细介绍可以看看我的这篇原创: 不了解布隆过滤器?一文给你整的明明白白! ,强烈推荐。\n3)接口限流\n根据用户或者 IP 对接口进行限流,对于异常频繁的访问行为,还可以采取黑名单机制,例如将异常 IP 列入黑名单。\n后面提到的缓存击穿和雪崩都可以配合接口限流来解决,毕竟这些问题的关键都是有很多请求落到了数据库上造成数据库压力过大。\n限流的具体方案可以参考这篇文章: 服务限流详解。\n缓存击穿 # 什么是缓存击穿? # 缓存击穿中,请求的 key 对应的是 热点数据 ,该数据 存在于数据库中,但不存在于缓存中(通常是因为缓存中的那份数据已经过期) 。这就可能会导致瞬时大量的请求直接打到了数据库上,对数据库造成了巨大的压力,可能直接就被这么多请求弄宕机了。\n举个例子:秒杀进行过程中,缓存中的某个秒杀商品的数据突然过期,这就导致瞬时大量对该商品的请求直接落到数据库上,对数据库造成了巨大的压力。\n有哪些解决办法? # 永不过期(不推荐):设置热点数据永不过期或者过期时间比较长。 提前预热(推荐):针对热点数据提前预热,将其存入缓存中并设置合理的过期时间比如秒杀场景下的数据在秒杀结束之前不过期。 加锁(看情况):在缓存失效后,通过设置互斥锁确保只有一个请求去查询数据库并更新缓存。 缓存穿透和缓存击穿有什么区别? # 缓存穿透中,请求的 key 既不存在于缓存中,也不存在于数据库中。\n缓存击穿中,请求的 key 对应的是 热点数据 ,该数据 存在于数据库中,但不存在于缓存中(通常是因为缓存中的那份数据已经过期) 。\n缓存雪崩 # 什么是缓存雪崩? # 我发现缓存雪崩这名字起的有点意思,哈哈。\n实际上,缓存雪崩描述的就是这样一个简单的场景:缓存在同一时间大面积的失效,导致大量的请求都直接落到了数据库上,对数据库造成了巨大的压力。 这就好比雪崩一样,摧枯拉朽之势,数据库的压力可想而知,可能直接就被这么多请求弄宕机了。\n另外,缓存服务宕机也会导致缓存雪崩现象,导致所有的请求都落到了数据库上。\n举个例子:数据库中的大量数据在同一时间过期,这个时候突然有大量的请求需要访问这些过期的数据。这就导致大量的请求直接落到数据库上,对数据库造成了巨大的压力。\n有哪些解决办法? # 针对 Redis 服务不可用的情况:\nRedis 集群:采用 Redis 集群,避免单机出现问题整个缓存服务都没办法使用。Redis Cluster 和 Redis Sentinel 是两种最常用的 Redis 集群实现方案,详细介绍可以参考: Redis 集群详解(付费)。 多级缓存:设置多级缓存,例如本地缓存+Redis 缓存的二级缓存组合,当 Redis 缓存出现问题时,还可以从本地缓存中获取到部分数据。 针对大量缓存同时失效的情况:\n设置随机失效时间(可选):为缓存设置随机的失效时间,例如在固定过期时间的基础上加上一个随机值,这样可以避免大量缓存同时到期,从而减少缓存雪崩的风险。 提前预热(推荐):针对热点数据提前预热,将其存入缓存中并设置合理的过期时间比如秒杀场景下的数据在秒杀结束之前不过期。 持久缓存策略(看情况):虽然一般不推荐设置缓存永不过期,但对于某些关键性和变化不频繁的数据,可以考虑这种策略。 缓存预热如何实现? # 常见的缓存预热方式有两种:\n使用定时任务,比如 xxl-job,来定时触发缓存预热的逻辑,将数据库中的热点数据查询出来并存入缓存中。 使用消息队列,比如 Kafka,来异步地进行缓存预热,将数据库中的热点数据的主键或者 ID 发送到消息队列中,然后由缓存服务消费消息队列中的数据,根据主键或者 ID 查询数据库并更新缓存。 缓存雪崩和缓存击穿有什么区别? # 缓存雪崩和缓存击穿比较像,但缓存雪崩导致的原因是缓存中的大量或者所有数据失效,缓存击穿导致的原因主要是某个热点数据不存在与缓存中(通常是因为缓存中的那份数据已经过期)。\n如何保证缓存和数据库数据的一致性? # 细说的话可以扯很多,但是我觉得其实没太大必要(小声 BB:很多解决方案我也没太弄明白)。我个人觉得引入缓存之后,如果为了短时间的不一致性问题,选择让系统设计变得更加复杂的话,完全没必要。\n下面单独对 Cache Aside Pattern(旁路缓存模式) 来聊聊。\nCache Aside Pattern 中遇到写请求是这样的:更新数据库,然后直接删除缓存 。\n如果更新数据库成功,而删除缓存这一步失败的情况的话,简单说有两个解决方案:\n缓存失效时间变短(不推荐,治标不治本):我们让缓存数据的过期时间变短,这样的话缓存就会从数据库中加载数据。另外,这种解决办法对于先操作缓存后操作数据库的场景不适用。 增加缓存更新重试机制(常用):如果缓存服务当前不可用导致缓存删除失败的话,我们就隔一段时间进行重试,重试次数可以自己定。不过,这里更适合引入消息队列实现异步重试,将删除缓存重试的消息投递到消息队列,然后由专门的消费者来重试,直到成功。虽然说多引入了一个消息队列,但其整体带来的收益还是要更高一些。 相关文章推荐: 缓存和数据库一致性问题,看这篇就够了 - 水滴与银弹。\n哪些情况可能会导致 Redis 阻塞? # 单独抽了一篇文章来总结可能会导致 Redis 阻塞的情况: Redis 常见阻塞原因总结。\nRedis 集群 # Redis Sentinel:\n什么是 Sentinel? 有什么用? Sentinel 如何检测节点是否下线?主观下线与客观下线的区别? Sentinel 是如何实现故障转移的? 为什么建议部署多个 sentinel 节点(哨兵集群)? Sentinel 如何选择出新的 master(选举机制)? 如何从 Sentinel 集群中选择出 Leader ? Sentinel 可以防止脑裂吗? Redis Cluster:\n为什么需要 Redis Cluster?解决了什么问题?有什么优势? Redis Cluster 是如何分片的? 为什么 Redis Cluster 的哈希槽是 16384 个? 如何确定给定 key 的应该分布到哪个哈希槽中? Redis Cluster 支持重新分配哈希槽吗? Redis Cluster 扩容缩容期间可以提供服务吗? Redis Cluster 中的节点是怎么进行通信的? 参考答案: Redis 集群详解(付费)。\nRedis 使用规范 # 实际使用 Redis 的过程中,我们尽量要准守一些常见的规范,比如:\n使用连接池:避免频繁创建关闭客户端连接。 尽量不使用 O(n)指令,使用 O(n) 命令时要关注 n 的数量:像 KEYS *、HGETALL、LRANGE、SMEMBERS、SINTER/SUNION/SDIFF等 O(n) 命令并非不能使用,但是需要明确 n 的值。另外,有遍历的需求可以使用 HSCAN、SSCAN、ZSCAN 代替。 使用批量操作减少网络传输:原生批量操作命令(比如 MGET、MSET等等)、pipeline、Lua 脚本。 尽量不适用 Redis 事务:Redis 事务实现的功能比较鸡肋,可以使用 Lua 脚本代替。 禁止长时间开启 monitor:对性能影响比较大。 控制 key 的生命周期:避免 Redis 中存放了太多不经常被访问的数据。 …… 相关文章推荐: 阿里云 Redis 开发规范 。\n参考 # 《Redis 开发与运维》 《Redis 设计与实现》 Redis Transactions : https://redis.io/docs/manual/transactions/ What is Redis Pipeline: https://buildatscale.tech/what-is-redis-pipeline/ 一文详解 Redis 中 BigKey、HotKey 的发现与处理: https://mp.weixin.qq.com/s/FPYE1B839_8Yk1-YSiW-1Q Bigkey 问题的解决思路与方式探索: https://mp.weixin.qq.com/s/Sej7D9TpdAobcCmdYdMIyA Redis 延迟问题全面排障指南: https://mp.weixin.qq.com/s/mIc6a9mfEGdaNDD3MmfFsg "},{"id":560,"href":"/zh/docs/technology/Interview/database/redis/redis-common-blocking-problems-summary/","title":"Redis常见阻塞原因总结","section":"Redis","content":" 本文整理完善自: https://mp.weixin.qq.com/s/0Nqfq_eQrUb12QH6eBbHXA ,作者:阿 Q 说代码\n这篇文章会详细总结一下可能导致 Redis 阻塞的情况,这些情况也是影响 Redis 性能的关键因素,使用 Redis 的时候应该格外注意!\nO(n) 命令 # Redis 中的大部分命令都是 O(1)时间复杂度,但也有少部分 O(n) 时间复杂度的命令,例如:\nKEYS *:会返回所有符合规则的 key。 HGETALL:会返回一个 Hash 中所有的键值对。 LRANGE:会返回 List 中指定范围内的元素。 SMEMBERS:返回 Set 中的所有元素。 SINTER/SUNION/SDIFF:计算多个 Set 的交集/并集/差集。 …… 由于这些命令时间复杂度是 O(n),有时候也会全表扫描,随着 n 的增大,执行耗时也会越长,从而导致客户端阻塞。不过, 这些命令并不是一定不能使用,但是需要明确 N 的值。另外,有遍历的需求可以使用 HSCAN、SSCAN、ZSCAN 代替。\n除了这些 O(n)时间复杂度的命令可能会导致阻塞之外, 还有一些时间复杂度可能在 O(N) 以上的命令,例如:\nZRANGE/ZREVRANGE:返回指定 Sorted Set 中指定排名范围内的所有元素。时间复杂度为 O(log(n)+m),n 为所有元素的数量, m 为返回的元素数量,当 m 和 n 相当大时,O(n) 的时间复杂度更小。 ZREMRANGEBYRANK/ZREMRANGEBYSCORE:移除 Sorted Set 中指定排名范围/指定 score 范围内的所有元素。时间复杂度为 O(log(n)+m),n 为所有元素的数量, m 被删除元素的数量,当 m 和 n 相当大时,O(n) 的时间复杂度更小。 …… SAVE 创建 RDB 快照 # Redis 提供了两个命令来生成 RDB 快照文件:\nsave : 同步保存操作,会阻塞 Redis 主线程; bgsave : fork 出一个子进程,子进程执行,不会阻塞 Redis 主线程,默认选项。 默认情况下,Redis 默认配置会使用 bgsave 命令。如果手动使用 save 命令生成 RDB 快照文件的话,就会阻塞主线程。\nAOF # AOF 日志记录阻塞 # Redis AOF 持久化机制是在执行完命令之后再记录日志,这和关系型数据库(如 MySQL)通常都是执行命令之前记录日志(方便故障恢复)不同。\n为什么是在执行完命令之后记录日志呢?\n避免额外的检查开销,AOF 记录日志不会对命令进行语法检查; 在命令执行完之后再记录,不会阻塞当前的命令执行。 这样也带来了风险(我在前面介绍 AOF 持久化的时候也提到过):\n如果刚执行完命令 Redis 就宕机会导致对应的修改丢失; 可能会阻塞后续其他命令的执行(AOF 记录日志是在 Redis 主线程中进行的)。 AOF 刷盘阻塞 # 开启 AOF 持久化后每执行一条会更改 Redis 中的数据的命令,Redis 就会将该命令写入到 AOF 缓冲区 server.aof_buf 中,然后再根据 appendfsync 配置来决定何时将其同步到硬盘中的 AOF 文件。\n在 Redis 的配置文件中存在三种不同的 AOF 持久化方式( fsync策略),它们分别是:\nappendfsync always:主线程调用 write 执行写操作后,后台线程( aof_fsync 线程)立即会调用 fsync 函数同步 AOF 文件(刷盘),fsync 完成后线程返回,这样会严重降低 Redis 的性能(write + fsync)。 appendfsync everysec:主线程调用 write 执行写操作后立即返回,由后台线程( aof_fsync 线程)每秒钟调用 fsync 函数(系统调用)同步一次 AOF 文件(write+fsync,fsync间隔为 1 秒) appendfsync no:主线程调用 write 执行写操作后立即返回,让操作系统决定何时进行同步,Linux 下一般为 30 秒一次(write但不fsync,fsync 的时机由操作系统决定)。 当后台线程( aof_fsync 线程)调用 fsync 函数同步 AOF 文件时,需要等待,直到写入完成。当磁盘压力太大的时候,会导致 fsync 操作发生阻塞,主线程调用 write 函数时也会被阻塞。fsync 完成后,主线程执行 write 才能成功返回。\n关于 AOF 工作流程的详细介绍可以查看: Redis 持久化机制详解,有助于理解 AOF 刷盘阻塞。\nAOF 重写阻塞 # fork 出一条子线程来将文件重写,在执行 BGREWRITEAOF 命令时,Redis 服务器会维护一个 AOF 重写缓冲区,该缓冲区会在子线程创建新 AOF 文件期间,记录服务器执行的所有写命令。 当子线程完成创建新 AOF 文件的工作之后,服务器会将重写缓冲区中的所有内容追加到新 AOF 文件的末尾,使得新的 AOF 文件保存的数据库状态与现有的数据库状态一致。 最后,服务器用新的 AOF 文件替换旧的 AOF 文件,以此来完成 AOF 文件重写操作。 阻塞就是出现在第 2 步的过程中,将缓冲区中新数据写到新文件的过程中会产生阻塞。\n相关阅读: Redis AOF 重写阻塞问题分析。\n大 Key # 如果一个 key 对应的 value 所占用的内存比较大,那这个 key 就可以看作是 bigkey。具体多大才算大呢?有一个不是特别精确的参考标准:\nstring 类型的 value 超过 1MB 复合类型(列表、哈希、集合、有序集合等)的 value 包含的元素超过 5000 个(对于复合类型的 value 来说,不一定包含的元素越多,占用的内存就越多)。 大 key 造成的阻塞问题如下:\n客户端超时阻塞:由于 Redis 执行命令是单线程处理,然后在操作大 key 时会比较耗时,那么就会阻塞 Redis,从客户端这一视角看,就是很久很久都没有响应。 引发网络阻塞:每次获取大 key 产生的网络流量较大,如果一个 key 的大小是 1 MB,每秒访问量为 1000,那么每秒会产生 1000MB 的流量,这对于普通千兆网卡的服务器来说是灾难性的。 阻塞工作线程:如果使用 del 删除大 key 时,会阻塞工作线程,这样就没办法处理后续的命令。 查找大 key # 当我们在使用 Redis 自带的 --bigkeys 参数查找大 key 时,最好选择在从节点上执行该命令,因为主节点上执行时,会阻塞主节点。\n我们还可以使用 SCAN 命令来查找大 key;\n通过分析 RDB 文件来找出 big key,这种方案的前提是 Redis 采用的是 RDB 持久化。网上有现成的工具:\nredis-rdb-tools:Python 语言写的用来分析 Redis 的 RDB 快照文件用的工具 rdb_bigkeys:Go 语言写的用来分析 Redis 的 RDB 快照文件用的工具,性能更好。 删除大 key # 删除操作的本质是要释放键值对占用的内存空间。\n释放内存只是第一步,为了更加高效地管理内存空间,在应用程序释放内存时,操作系统需要把释放掉的内存块插入一个空闲内存块的链表,以便后续进行管理和再分配。这个过程本身需要一定时间,而且会阻塞当前释放内存的应用程序。\n所以,如果一下子释放了大量内存,空闲内存块链表操作时间就会增加,相应地就会造成 Redis 主线程的阻塞,如果主线程发生了阻塞,其他所有请求可能都会超时,超时越来越多,会造成 Redis 连接耗尽,产生各种异常。\n删除大 key 时建议采用分批次删除和异步删除的方式进行。\n清空数据库 # 清空数据库和上面 bigkey 删除也是同样道理,flushdb、flushall 也涉及到删除和释放所有的键值对,也是 Redis 的阻塞点。\n集群扩容 # Redis 集群可以进行节点的动态扩容缩容,这一过程目前还处于半自动状态,需要人工介入。\n在扩缩容的时候,需要进行数据迁移。而 Redis 为了保证迁移的一致性,迁移所有操作都是同步操作。\n执行迁移时,两端的 Redis 均会进入时长不等的阻塞状态,对于小 Key,该时间可以忽略不计,但如果一旦 Key 的内存使用过大,严重的时候会触发集群内的故障转移,造成不必要的切换。\nSwap(内存交换) # 什么是 Swap? Swap 直译过来是交换的意思,Linux 中的 Swap 常被称为内存交换或者交换分区。类似于 Windows 中的虚拟内存,就是当内存不足的时候,把一部分硬盘空间虚拟成内存使用,从而解决内存容量不足的情况。因此,Swap 分区的作用就是牺牲硬盘,增加内存,解决 VPS 内存不够用或者爆满的问题。\nSwap 对于 Redis 来说是非常致命的,Redis 保证高性能的一个重要前提是所有的数据在内存中。如果操作系统把 Redis 使用的部分内存换出硬盘,由于内存与硬盘的读写速度差几个数量级,会导致发生交换后的 Redis 性能急剧下降。\n识别 Redis 发生 Swap 的检查方法如下:\n1、查询 Redis 进程号\nredis-cli -p 6383 info server | grep process_id process_id: 4476 2、根据进程号查询内存交换信息\ncat /proc/4476/smaps | grep Swap Swap: 0kB Swap: 0kB Swap: 4kB Swap: 0kB Swap: 0kB ..... 如果交换量都是 0KB 或者个别的是 4KB,则正常。\n预防内存交换的方法:\n保证机器充足的可用内存 确保所有 Redis 实例设置最大可用内存(maxmemory),防止极端情况 Redis 内存不可控的增长 降低系统使用 swap 优先级,如echo 10 \u0026gt; /proc/sys/vm/swappiness CPU 竞争 # Redis 是典型的 CPU 密集型应用,不建议和其他多核 CPU 密集型服务部署在一起。当其他进程过度消耗 CPU 时,将严重影响 Redis 的吞吐量。\n可以通过redis-cli --stat获取当前 Redis 使用情况。通过top命令获取进程对 CPU 的利用率等信息 通过info commandstats统计信息分析出命令不合理开销时间,查看是否是因为高算法复杂度或者过度的内存优化问题。\n网络问题 # 连接拒绝、网络延迟,网卡软中断等网络问题也可能会导致 Redis 阻塞。\n参考 # Redis 阻塞的 6 大类场景分析与总结: https://mp.weixin.qq.com/s/eaZCEtTjTuEmXfUubVHjew Redis 开发与运维笔记-Redis 的噩梦-阻塞: https://mp.weixin.qq.com/s/TDbpz9oLH6ifVv6ewqgSgA "},{"id":561,"href":"/zh/docs/technology/Interview/database/redis/redis-persistence/","title":"Redis持久化机制详解","section":"Redis","content":"使用缓存的时候,我们经常需要对内存中的数据进行持久化也就是将内存中的数据写入到硬盘中。大部分原因是为了之后重用数据(比如重启机器、机器故障之后恢复数据),或者是为了做数据同步(比如 Redis 集群的主从节点通过 RDB 文件同步数据)。\nRedis 不同于 Memcached 的很重要一点就是,Redis 支持持久化,而且支持 3 种持久化方式:\n快照(snapshotting,RDB) 只追加文件(append-only file, AOF) RDB 和 AOF 的混合持久化(Redis 4.0 新增) 官方文档地址: https://redis.io/topics/persistence 。\nRDB 持久化 # 什么是 RDB 持久化? # Redis 可以通过创建快照来获得存储在内存里面的数据在 某个时间点 上的副本。Redis 创建快照之后,可以对快照进行备份,可以将快照复制到其他服务器从而创建具有相同数据的服务器副本(Redis 主从结构,主要用来提高 Redis 性能),还可以将快照留在原地以便重启服务器的时候使用。\n快照持久化是 Redis 默认采用的持久化方式,在 redis.conf 配置文件中默认有此下配置:\nsave 900 1 #在900秒(15分钟)之后,如果至少有1个key发生变化,Redis就会自动触发bgsave命令创建快照。 save 300 10 #在300秒(5分钟)之后,如果至少有10个key发生变化,Redis就会自动触发bgsave命令创建快照。 save 60 10000 #在60秒(1分钟)之后,如果至少有10000个key发生变化,Redis就会自动触发bgsave命令创建快照。 RDB 创建快照时会阻塞主线程吗? # Redis 提供了两个命令来生成 RDB 快照文件:\nsave : 同步保存操作,会阻塞 Redis 主线程; bgsave : fork 出一个子进程,子进程执行,不会阻塞 Redis 主线程,默认选项。 这里说 Redis 主线程而不是主进程的主要是因为 Redis 启动之后主要是通过单线程的方式完成主要的工作。如果你想将其描述为 Redis 主进程,也没毛病。\nAOF 持久化 # 什么是 AOF 持久化? # 与快照持久化相比,AOF 持久化的实时性更好。默认情况下 Redis 没有开启 AOF(append only file)方式的持久化(Redis 6.0 之后已经默认是开启了),可以通过 appendonly 参数开启:\nappendonly yes 开启 AOF 持久化后每执行一条会更改 Redis 中的数据的命令,Redis 就会将该命令写入到 AOF 缓冲区 server.aof_buf 中,然后再写入到 AOF 文件中(此时还在系统内核缓存区未同步到磁盘),最后再根据持久化方式( fsync策略)的配置来决定何时将系统内核缓存区的数据同步到硬盘中的。\n只有同步到磁盘中才算持久化保存了,否则依然存在数据丢失的风险,比如说:系统内核缓存区的数据还未同步,磁盘机器就宕机了,那这部分数据就算丢失了。\nAOF 文件的保存位置和 RDB 文件的位置相同,都是通过 dir 参数设置的,默认的文件名是 appendonly.aof。\nAOF 工作基本流程是怎样的? # AOF 持久化功能的实现可以简单分为 5 步:\n命令追加(append):所有的写命令会追加到 AOF 缓冲区中。 文件写入(write):将 AOF 缓冲区的数据写入到 AOF 文件中。这一步需要调用write函数(系统调用),write将数据写入到了系统内核缓冲区之后直接返回了(延迟写)。注意!!!此时并没有同步到磁盘。 文件同步(fsync):AOF 缓冲区根据对应的持久化方式( fsync 策略)向硬盘做同步操作。这一步需要调用 fsync 函数(系统调用), fsync 针对单个文件操作,对其进行强制硬盘同步,fsync 将阻塞直到写入磁盘完成后返回,保证了数据持久化。 文件重写(rewrite):随着 AOF 文件越来越大,需要定期对 AOF 文件进行重写,达到压缩的目的。 重启加载(load):当 Redis 重启时,可以加载 AOF 文件进行数据恢复。 Linux 系统直接提供了一些函数用于对文件和设备进行访问和控制,这些函数被称为 系统调用(syscall)。\n这里对上面提到的一些 Linux 系统调用再做一遍解释:\nwrite:写入系统内核缓冲区之后直接返回(仅仅是写到缓冲区),不会立即同步到硬盘。虽然提高了效率,但也带来了数据丢失的风险。同步硬盘操作通常依赖于系统调度机制,Linux 内核通常为 30s 同步一次,具体值取决于写出的数据量和 I/O 缓冲区的状态。 fsync:fsync用于强制刷新系统内核缓冲区(同步到到磁盘),确保写磁盘操作结束才会返回。 AOF 工作流程图如下:\nAOF 持久化方式有哪些? # 在 Redis 的配置文件中存在三种不同的 AOF 持久化方式( fsync策略),它们分别是:\nappendfsync always:主线程调用 write 执行写操作后,后台线程( aof_fsync 线程)立即会调用 fsync 函数同步 AOF 文件(刷盘),fsync 完成后线程返回,这样会严重降低 Redis 的性能(write + fsync)。 appendfsync everysec:主线程调用 write 执行写操作后立即返回,由后台线程( aof_fsync 线程)每秒钟调用 fsync 函数(系统调用)同步一次 AOF 文件(write+fsync,fsync间隔为 1 秒) appendfsync no:主线程调用 write 执行写操作后立即返回,让操作系统决定何时进行同步,Linux 下一般为 30 秒一次(write但不fsync,fsync 的时机由操作系统决定)。 可以看出:这 3 种持久化方式的主要区别在于 fsync 同步 AOF 文件的时机(刷盘)。\n为了兼顾数据和写入性能,可以考虑 appendfsync everysec 选项 ,让 Redis 每秒同步一次 AOF 文件,Redis 性能受到的影响较小。而且这样即使出现系统崩溃,用户最多只会丢失一秒之内产生的数据。当硬盘忙于执行写入操作的时候,Redis 还会优雅的放慢自己的速度以便适应硬盘的最大写入速度。\n从 Redis 7.0.0 开始,Redis 使用了 Multi Part AOF 机制。顾名思义,Multi Part AOF 就是将原来的单个 AOF 文件拆分成多个 AOF 文件。在 Multi Part AOF 中,AOF 文件被分为三种类型,分别为:\nBASE:表示基础 AOF 文件,它一般由子进程通过重写产生,该文件最多只有一个。 INCR:表示增量 AOF 文件,它一般会在 AOFRW 开始执行时被创建,该文件可能存在多个。 HISTORY:表示历史 AOF 文件,它由 BASE 和 INCR AOF 变化而来,每次 AOFRW 成功完成时,本次 AOFRW 之前对应的 BASE 和 INCR AOF 都将变为 HISTORY,HISTORY 类型的 AOF 会被 Redis 自动删除。 Multi Part AOF 不是重点,了解即可,详细介绍可以看看阿里开发者的 Redis 7.0 Multi Part AOF 的设计和实现 这篇文章。\n相关 issue: Redis 的 AOF 方式 #783。\nAOF 为什么是在执行完命令之后记录日志? # 关系型数据库(如 MySQL)通常都是执行命令之前记录日志(方便故障恢复),而 Redis AOF 持久化机制是在执行完命令之后再记录日志。\n为什么是在执行完命令之后记录日志呢?\n避免额外的检查开销,AOF 记录日志不会对命令进行语法检查; 在命令执行完之后再记录,不会阻塞当前的命令执行。 这样也带来了风险(我在前面介绍 AOF 持久化的时候也提到过):\n如果刚执行完命令 Redis 就宕机会导致对应的修改丢失; 可能会阻塞后续其他命令的执行(AOF 记录日志是在 Redis 主线程中进行的)。 AOF 重写了解吗? # 当 AOF 变得太大时,Redis 能够在后台自动重写 AOF 产生一个新的 AOF 文件,这个新的 AOF 文件和原有的 AOF 文件所保存的数据库状态一样,但体积更小。\nAOF 重写(rewrite) 是一个有歧义的名字,该功能是通过读取数据库中的键值对来实现的,程序无须对现有 AOF 文件进行任何读入、分析或者写入操作。\n由于 AOF 重写会进行大量的写入操作,为了避免对 Redis 正常处理命令请求造成影响,Redis 将 AOF 重写程序放到子进程里执行。\nAOF 文件重写期间,Redis 还会维护一个 AOF 重写缓冲区,该缓冲区会在子进程创建新 AOF 文件期间,记录服务器执行的所有写命令。当子进程完成创建新 AOF 文件的工作之后,服务器会将重写缓冲区中的所有内容追加到新 AOF 文件的末尾,使得新的 AOF 文件保存的数据库状态与现有的数据库状态一致。最后,服务器用新的 AOF 文件替换旧的 AOF 文件,以此来完成 AOF 文件重写操作。\n开启 AOF 重写功能,可以调用 BGREWRITEAOF 命令手动执行,也可以设置下面两个配置项,让程序自动决定触发时机:\nauto-aof-rewrite-min-size:如果 AOF 文件大小小于该值,则不会触发 AOF 重写。默认值为 64 MB; auto-aof-rewrite-percentage:执行 AOF 重写时,当前 AOF 大小(aof_current_size)和上一次重写时 AOF 大小(aof_base_size)的比值。如果当前 AOF 文件大小增加了这个百分比值,将触发 AOF 重写。将此值设置为 0 将禁用自动 AOF 重写。默认值为 100。 Redis 7.0 版本之前,如果在重写期间有写入命令,AOF 可能会使用大量内存,重写期间到达的所有写入命令都会写入磁盘两次。\nRedis 7.0 版本之后,AOF 重写机制得到了优化改进。下面这段内容摘自阿里开发者的 从 Redis7.0 发布看 Redis 的过去与未来 这篇文章。\nAOF 重写期间的增量数据如何处理一直是个问题,在过去写期间的增量数据需要在内存中保留,写结束后再把这部分增量数据写入新的 AOF 文件中以保证数据完整性。可以看出来 AOF 写会额外消耗内存和磁盘 IO,这也是 Redis AOF 写的痛点,虽然之前也进行过多次改进但是资源消耗的本质问题一直没有解决。\n阿里云的 Redis 企业版在最初也遇到了这个问题,在内部经过多次迭代开发,实现了 Multi-part AOF 机制来解决,同时也贡献给了社区并随此次 7.0 发布。具体方法是采用 base(全量数据)+inc(增量数据)独立文件存储的方式,彻底解决内存和 IO 资源的浪费,同时也支持对历史 AOF 文件的保存管理,结合 AOF 文件中的时间信息还可以实现 PITR 按时间点恢复(阿里云企业版 Tair 已支持),这进一步增强了 Redis 的数据可靠性,满足用户数据回档等需求。\n相关 issue: Redis AOF 重写描述不准确 #1439。\nAOF 校验机制了解吗? # AOF 校验机制是 Redis 在启动时对 AOF 文件进行检查,以判断文件是否完整,是否有损坏或者丢失的数据。这个机制的原理其实非常简单,就是通过使用一种叫做 校验和(checksum) 的数字来验证 AOF 文件。这个校验和是通过对整个 AOF 文件内容进行 CRC64 算法计算得出的数字。如果文件内容发生了变化,那么校验和也会随之改变。因此,Redis 在启动时会比较计算出的校验和与文件末尾保存的校验和(计算的时候会把最后一行保存校验和的内容给忽略点),从而判断 AOF 文件是否完整。如果发现文件有问题,Redis 就会拒绝启动并提供相应的错误信息。AOF 校验机制十分简单有效,可以提高 Redis 数据的可靠性。\n类似地,RDB 文件也有类似的校验机制来保证 RDB 文件的正确性,这里就不重复进行介绍了。\nRedis 4.0 对于持久化机制做了什么优化? # 由于 RDB 和 AOF 各有优势,于是,Redis 4.0 开始支持 RDB 和 AOF 的混合持久化(默认关闭,可以通过配置项 aof-use-rdb-preamble 开启)。\n如果把混合持久化打开,AOF 重写的时候就直接把 RDB 的内容写到 AOF 文件开头。这样做的好处是可以结合 RDB 和 AOF 的优点, 快速加载同时避免丢失过多的数据。当然缺点也是有的, AOF 里面的 RDB 部分是压缩格式不再是 AOF 格式,可读性较差。\n官方文档地址: https://redis.io/topics/persistence\n如何选择 RDB 和 AOF? # 关于 RDB 和 AOF 的优缺点,官网上面也给了比较详细的说明 Redis persistence,这里结合自己的理解简单总结一下。\nRDB 比 AOF 优秀的地方:\nRDB 文件存储的内容是经过压缩的二进制数据, 保存着某个时间点的数据集,文件很小,适合做数据的备份,灾难恢复。AOF 文件存储的是每一次写命令,类似于 MySQL 的 binlog 日志,通常会比 RDB 文件大很多。当 AOF 变得太大时,Redis 能够在后台自动重写 AOF。新的 AOF 文件和原有的 AOF 文件所保存的数据库状态一样,但体积更小。不过, Redis 7.0 版本之前,如果在重写期间有写入命令,AOF 可能会使用大量内存,重写期间到达的所有写入命令都会写入磁盘两次。 使用 RDB 文件恢复数据,直接解析还原数据即可,不需要一条一条地执行命令,速度非常快。而 AOF 则需要依次执行每个写命令,速度非常慢。也就是说,与 AOF 相比,恢复大数据集的时候,RDB 速度更快。 AOF 比 RDB 优秀的地方:\nRDB 的数据安全性不如 AOF,没有办法实时或者秒级持久化数据。生成 RDB 文件的过程是比较繁重的, 虽然 BGSAVE 子进程写入 RDB 文件的工作不会阻塞主线程,但会对机器的 CPU 资源和内存资源产生影响,严重的情况下甚至会直接把 Redis 服务干宕机。AOF 支持秒级数据丢失(取决 fsync 策略,如果是 everysec,最多丢失 1 秒的数据),仅仅是追加命令到 AOF 文件,操作轻量。 RDB 文件是以特定的二进制格式保存的,并且在 Redis 版本演进中有多个版本的 RDB,所以存在老版本的 Redis 服务不兼容新版本的 RDB 格式的问题。 AOF 以一种易于理解和解析的格式包含所有操作的日志。你可以轻松地导出 AOF 文件进行分析,你也可以直接操作 AOF 文件来解决一些问题。比如,如果执行FLUSHALL命令意外地刷新了所有内容后,只要 AOF 文件没有被重写,删除最新命令并重启即可恢复之前的状态。 综上:\nRedis 保存的数据丢失一些也没什么影响的话,可以选择使用 RDB。 不建议单独使用 AOF,因为时不时地创建一个 RDB 快照可以进行数据库备份、更快的重启以及解决 AOF 引擎错误。 如果保存的数据要求安全性比较高的话,建议同时开启 RDB 和 AOF 持久化或者开启 RDB 和 AOF 混合持久化。 参考 # 《Redis 设计与实现》 Redis persistence - Redis 官方文档: https://redis.io/docs/management/persistence/ The difference between AOF and RDB persistence: https://www.sobyte.net/post/2022-04/redis-rdb-and-aof/ Redis AOF 持久化详解 - 程序员历小冰: http://remcarpediem.net/article/376c55d8/ Redis RDB 与 AOF 持久化 · Analyze: https://wingsxdu.com/posts/database/redis/rdb-and-aof/ "},{"id":562,"href":"/zh/docs/technology/Interview/database/redis/redis-cluster/","title":"Redis集群详解(付费)","section":"Redis","content":"Redis 集群 相关的面试题为我的 知识星球(点击链接即可查看详细介绍以及加入方法)专属内容,已经整理到了 《Java 面试指北》中。\n"},{"id":563,"href":"/zh/docs/technology/Interview/database/redis/redis-memory-fragmentation/","title":"Redis内存碎片详解","section":"Redis","content":" 什么是内存碎片? # 你可以将内存碎片简单地理解为那些不可用的空闲内存。\n举个例子:操作系统为你分配了 32 字节的连续内存空间,而你存储数据实际只需要使用 24 字节内存空间,那这多余出来的 8 字节内存空间如果后续没办法再被分配存储其他数据的话,就可以被称为内存碎片。\nRedis 内存碎片虽然不会影响 Redis 性能,但是会增加内存消耗。\n为什么会有 Redis 内存碎片? # Redis 内存碎片产生比较常见的 2 个原因:\n1、Redis 存储数据的时候向操作系统申请的内存空间可能会大于数据实际需要的存储空间。\n以下是这段 Redis 官方的原话:\nTo store user keys, Redis allocates at most as much memory as the maxmemory setting enables (however there are small extra allocations possible).\nRedis 使用 zmalloc 方法(Redis 自己实现的内存分配方法)进行内存分配的时候,除了要分配 size 大小的内存之外,还会多分配 PREFIX_SIZE 大小的内存。\nzmalloc 方法源码如下(源码地址: https://github.com/antirez/redis-tools/blob/master/zmalloc.c):\nvoid *zmalloc(size_t size) { // 分配指定大小的内存 void *ptr = malloc(size+PREFIX_SIZE); if (!ptr) zmalloc_oom_handler(size); #ifdef HAVE_MALLOC_SIZE update_zmalloc_stat_alloc(zmalloc_size(ptr)); return ptr; #else *((size_t*)ptr) = size; update_zmalloc_stat_alloc(size+PREFIX_SIZE); return (char*)ptr+PREFIX_SIZE; #endif } 另外,Redis 可以使用多种内存分配器来分配内存( libc、jemalloc、tcmalloc),默认使用 jemalloc,而 jemalloc 按照一系列固定的大小(8 字节、16 字节、32 字节……)来分配内存的。jemalloc 划分的内存单元如下图所示:\n当程序申请的内存最接近某个固定值时,jemalloc 会给它分配相应大小的空间,就比如说程序需要申请 17 字节的内存,jemalloc 会直接给它分配 32 字节的内存,这样会导致有 15 字节内存的浪费。不过,jemalloc 专门针对内存碎片问题做了优化,一般不会存在过度碎片化的问题。\n2、频繁修改 Redis 中的数据也会产生内存碎片。\n当 Redis 中的某个数据删除时,Redis 通常不会轻易释放内存给操作系统。\n这个在 Redis 官方文档中也有对应的原话:\n文档地址: https://redis.io/topics/memory-optimization 。\n如何查看 Redis 内存碎片的信息? # 使用 info memory 命令即可查看 Redis 内存相关的信息。下图中每个参数具体的含义,Redis 官方文档有详细的介绍: https://redis.io/commands/INFO 。\nRedis 内存碎片率的计算公式:mem_fragmentation_ratio (内存碎片率)= used_memory_rss (操作系统实际分配给 Redis 的物理内存空间大小)/ used_memory(Redis 内存分配器为了存储数据实际申请使用的内存空间大小)\n也就是说,mem_fragmentation_ratio (内存碎片率)的值越大代表内存碎片率越严重。\n一定不要误认为used_memory_rss 减去 used_memory值就是内存碎片的大小!!!这不仅包括内存碎片,还包括其他进程开销,以及共享库、堆栈等的开销。\n很多小伙伴可能要问了:“多大的内存碎片率才是需要清理呢?”。\n通常情况下,我们认为 mem_fragmentation_ratio \u0026gt; 1.5 的话才需要清理内存碎片。 mem_fragmentation_ratio \u0026gt; 1.5 意味着你使用 Redis 存储实际大小 2G 的数据需要使用大于 3G 的内存。\n如果想要快速查看内存碎片率的话,你还可以通过下面这个命令:\n\u0026gt; redis-cli -p 6379 info | grep mem_fragmentation_ratio 另外,内存碎片率可能存在小于 1 的情况。这种情况我在日常使用中还没有遇到过,感兴趣的小伙伴可以看看这篇文章 故障分析 | Redis 内存碎片率太低该怎么办?- 爱可生开源社区 。\n如何清理 Redis 内存碎片? # Redis4.0-RC3 版本以后自带了内存整理,可以避免内存碎片率过大的问题。\n直接通过 config set 命令将 activedefrag 配置项设置为 yes 即可。\nconfig set activedefrag yes 具体什么时候清理需要通过下面两个参数控制:\n# 内存碎片占用空间达到 500mb 的时候开始清理 config set active-defrag-ignore-bytes 500mb # 内存碎片率大于 1.5 的时候开始清理 config set active-defrag-threshold-lower 50 通过 Redis 自动内存碎片清理机制可能会对 Redis 的性能产生影响,我们可以通过下面两个参数来减少对 Redis 性能的影响:\n# 内存碎片清理所占用 CPU 时间的比例不低于 20% config set active-defrag-cycle-min 20 # 内存碎片清理所占用 CPU 时间的比例不高于 50% config set active-defrag-cycle-max 50 另外,重启节点可以做到内存碎片重新整理。如果你采用的是高可用架构的 Redis 集群的话,你可以将碎片率过高的主节点转换为从节点,以便进行安全重启。\n参考 # Redis 官方文档: https://redis.io/topics/memory-optimization Redis 核心技术与实战 - 极客时间 - 删除数据后,为什么内存占用率还是很高?: https://time.geekbang.org/column/article/289140 Redis 源码解析——内存分配:\u0026laquo; https://shinerio.cc/2020/05/17/redis/Redis\u003e 源码解析——内存管理\u0026gt; "},{"id":564,"href":"/zh/docs/technology/Interview/database/redis/redis-skiplist/","title":"Redis为什么用跳表实现有序集合","section":"Redis","content":" 前言 # 近几年针对 Redis 面试时会涉及常见数据结构的底层设计,其中就有这么一道比较有意思的面试题:“Redis 的有序集合底层为什么要用跳表,而不用平衡树、红黑树或者 B+树?”。\n本文就以这道大厂常问的面试题为切入点,带大家详细了解一下跳表这个数据结构。\n本文整体脉络如下图所示,笔者会从有序集合的基本使用到跳表的源码分析和实现,让你会对 Redis 的有序集合底层实现的跳表有着更深刻的理解和掌握。\n跳表在 Redis 中的运用 # 这里我们需要先了解一下 Redis 用到跳表的数据结构有序集合的使用,Redis 有个比较常用的数据结构叫有序集合(sorted set,简称 zset),正如其名它是一个可以保证有序且元素唯一的集合,所以它经常用于排行榜等需要进行统计排列的场景。\n这里我们通过命令行的形式演示一下排行榜的实现,可以看到笔者分别输入 3 名用户:xiaoming、xiaohong、xiaowang,它们的score分别是 60、80、60,最终按照成绩升级降序排列。\n127.0.0.1:6379\u0026gt; zadd rankList 60 xiaoming (integer) 1 127.0.0.1:6379\u0026gt; zadd rankList 80 xiaohong (integer) 1 127.0.0.1:6379\u0026gt; zadd rankList 60 xiaowang (integer) 1 # 返回有序集中指定区间内的成员,通过索引,分数从高到低 127.0.0.1:6379\u0026gt; ZREVRANGE rankList 0 100 WITHSCORES 1) \u0026#34;xiaohong\u0026#34; 2) \u0026#34;80\u0026#34; 3) \u0026#34;xiaowang\u0026#34; 4) \u0026#34;60\u0026#34; 5) \u0026#34;xiaoming\u0026#34; 6) \u0026#34;60\u0026#34; 此时我们通过 object 指令查看 zset 的数据结构,可以看到当前有序集合存储的还是ziplist(压缩列表)。\n127.0.0.1:6379\u0026gt; object encoding rankList \u0026#34;ziplist\u0026#34; 因为设计者考虑到 Redis 数据存放于内存,为了节约宝贵的内存空间,在有序集合元素小于 64 字节且个数小于 128 的时候,会使用 ziplist,而这个阈值的默认值的设置就来自下面这两个配置项。\nzset-max-ziplist-value 64 zset-max-ziplist-entries 128 一旦有序集合中的某个元素超出这两个其中的一个阈值它就会转为 skiplist(实际是 dict+skiplist,还会借用字典来提高获取指定元素的效率)。\n我们不妨在添加一个大于 64 字节的元素,可以看到有序集合的底层存储转为 skiplist。\n127.0.0.1:6379\u0026gt; zadd rankList 90 yigemingzihuichaoguo64zijiedeyonghumingchengyongyuceshitiaobiaodeshijiyunyong (integer) 1 # 超过阈值,转为跳表 127.0.0.1:6379\u0026gt; object encoding rankList \u0026#34;skiplist\u0026#34; 也就是说,ZSet 有两种不同的实现,分别是 ziplist 和 skiplist,具体使用哪种结构进行存储的规则如下:\n当有序集合对象同时满足以下两个条件时,使用 ziplist: ZSet 保存的键值对数量少于 128 个; 每个元素的长度小于 64 字节。 如果不满足上述两个条件,那么使用 skiplist 。 手写一个跳表 # 为了更好的回答上述问题以及更好的理解和掌握跳表,这里可以通过手写一个简单的跳表的形式来帮助读者理解跳表这个数据结构。\n我们都知道有序链表在添加、查询、删除的平均时间复杂都都是 O(n) 即线性增长,所以一旦节点数量达到一定体量后其性能表现就会非常差劲。而跳表我们完全可以理解为在原始链表基础上,建立多级索引,通过多级索引检索定位将增删改查的时间复杂度变为 O(log n) 。\n可能这里说的有些抽象,我们举个例子,以下图跳表为例,其原始链表存储按序存储 1-10,有 2 级索引,每级索引的索引个数都是基于下层元素个数的一半。\n假如我们需要查询元素 6,其工作流程如下:\n从 2 级索引开始,先来到节点 4。 查看 4 的后继节点,是 8 的 2 级索引,这个值大于 6,说明 2 级索引后续的索引都是大于 6 的,没有再往后搜寻的必要,我们索引向下查找。 来到 4 的 1 级索引,比对其后继节点为 6,查找结束。 相较于原始有序链表需要 6 次,我们的跳表通过建立多级索引,我们只需两次就直接定位到了目标元素,其查寻的复杂度被直接优化为O(log n)。\n对应的添加也是一个道理,假如我们需要在这个有序集合中添加一个元素 7,那么我们就需要通过跳表找到小于元素 7 的最大值,也就是下图元素 6 的位置,将其插入到元素 6 的后面,让元素 6 的索引指向新插入的节点 7,其工作流程如下:\n从 2 级索引开始定位到了元素 4 的索引。 查看索引 4 的后继索引为 8,索引向下推进。 来到 1 级索引,发现索引 4 后继索引为 6,小于插入元素 7,指针推进到索引 6 位置。 继续比较 6 的后继节点为索引 8,大于元素 7,索引继续向下。 最终我们来到 6 的原始节点,发现其后继节点为 7,指针没有继续向下的空间,自此我们可知元素 6 就是小于插入元素 7 的最大值,于是便将元素 7 插入。 这里我们又面临一个问题,我们是否需要为元素 7 建立索引,索引多高合适?\n我们上文提到,理想情况是每一层索引是下一层元素个数的二分之一,假设我们的总共有 16 个元素,对应各级索引元素个数应该是:\n1. 一级索引:16/2=8 2. 二级索引:8/2 =4 3. 三级索引:4/2=2 由此我们用数学归纳法可知:\n1. 一级索引:16/2=16/2^1=8 2. 二级索引:8/2 =\u0026gt; 16/2^2 =4 3. 三级索引:4/2=\u0026gt;16/2^3=2 假设元素个数为 n,那么对应 k 层索引的元素个数 r 计算公式为:\nr=n/2^k 同理我们再来推断以下索引的最大高度,一般来说最高级索引的元素个数为 2,我们设元素总个数为 n,索引高度为 h,代入上述公式可得:\n2= n/2^h =\u0026gt; 2*2^h=n =\u0026gt; 2^(h+1)=n =\u0026gt; h+1=log2^n =\u0026gt; h=log2^n -1 而 Redis 又是内存数据库,我们假设元素最大个数是65536,我们把65536代入上述公式可知最大高度为 16。所以我们建议添加一个元素后为其建立的索引高度不超过 16。\n因为我们要求尽可能保证每一个上级索引都是下级索引的一半,在实现高度生成算法时,我们可以这样设计:\n跳表的高度计算从原始链表开始,即默认情况下插入的元素的高度为 1,代表没有索引,只有元素节点。 设计一个为插入元素生成节点索引高度 level 的方法。 进行一次随机运算,随机数值范围为 0-1 之间。 如果随机数大于 0.5 则为当前元素添加一级索引,自此我们保证生成一级索引的概率为 50% ,这也就保证了 1 级索引理想情况下只有一半的元素会生成索引。 同理后续每次随机算法得到的值大于 0.5 时,我们的索引高度就加 1,这样就可以保证节点生成的 2 级索引概率为 25% ,3 级索引为 12.5% …… 我们回过头,上述插入 7 之后,我们通过随机算法得到 2,即要为其建立 1 级索引:\n最后我们再来说说删除,假设我们这里要删除元素 10,我们必须定位到当前跳表各层元素小于 10 的最大值,索引执行步骤为:\n2 级索引 4 的后继节点为 8,指针推进。 索引 8 无后继节点,该层无要删除的元素,指针直接向下。 1 级索引 8 后继节点为 10,说明 1 级索引 8 在进行删除时需要将自己的指针和 1 级索引 10 断开联系,将 10 删除。 1 级索引完成定位后,指针向下,后继节点为 9,指针推进。 9 的后继节点为 10,同理需要让其指向 null,将 10 删除。 模板定义 # 有了整体的思路之后,我们可以开始实现一个跳表了,首先定义一下跳表中的节点Node,从上文的演示中可以看出每一个Node它都包含以下几个元素:\n存储的value值。 后继节点的地址。 多级索引。 为了更方便统一管理Node后继节点地址和多级索引指向的元素地址,笔者在Node中设置了一个forwards数组,用于记录原始链表节点的后继节点和多级索引的后继节点指向。\n以下图为例,我们forwards数组长度为 5,其中索引 0记录的是原始链表节点的后继节点地址,而其余自底向上表示从 1 级索引到 4 级索引的后继节点指向。\n于是我们的就有了这样一个代码定义,可以看出笔者对于数组的长度设置为固定的 16==(上文的推算最大高度建议是 16),默认data为-1,节点最大高度maxLevel初始化为 1,注意这个maxLevel==的值代表原始链表加上索引的总高度。\n/** * 跳表索引最大高度为16 */ private static final int MAX_LEVEL = 16; class Node { private int data = -1; private Node[] forwards = new Node[MAX_LEVEL]; private int maxLevel = 0; } 元素添加 # 定义好节点之后,我们先实现以下元素的添加,添加元素时首先自然是设置data这一步我们直接根据将传入的value设置到data上即可。\n然后就是高度maxLevel的设置 ,我们在上文也已经给出了思路,默认高度为 1,即只有一个原始链表节点,通过随机算法每次大于 0.5 索引高度加 1,由此我们得出高度计算的算法randomLevel():\n/** * 理论来讲,一级索引中元素个数应该占原始数据的 50%,二级索引中元素个数占 25%,三级索引12.5% ,一直到最顶层。 * 因为这里每一层的晋升概率是 50%。对于每一个新插入的节点,都需要调用 randomLevel 生成一个合理的层数。 * 该 randomLevel 方法会随机生成 1~MAX_LEVEL 之间的数,且 : * 50%的概率返回 1 * 25%的概率返回 2 * 12.5%的概率返回 3 ... * @return */ private int randomLevel() { int level = 1; while (Math.random() \u0026gt; PROB \u0026amp;\u0026amp; level \u0026lt; MAX_LEVEL) { ++level; } return level; } 然后再设置当前要插入的Node和Node索引的后继节点地址,这一步稍微复杂一点,我们假设当前节点的高度为 4,即 1 个节点加 3 个索引,所以我们创建一个长度为 4 的数组maxOfMinArr ,遍历各级索引节点中小于当前value的最大值。\n假设我们要插入的value为 5,我们的数组查找结果当前节点的前驱节点和 1 级索引、2 级索引的前驱节点都为 4,三级索引为空。\n然后我们基于这个数组maxOfMinArr 定位到各级的后继节点,让插入的元素 5 指向这些后继节点,而maxOfMinArr指向 5,结果如下图:\n转化成代码就是下面这个形式,是不是很简单呢?我们继续:\n/** * 默认情况下的高度为1,即只有自己一个节点 */ private int levelCount = 1; /** * 跳表最底层的节点,即头节点 */ private Node h = new Node(); public void add(int value) { //随机生成高度 int level = randomLevel(); Node newNode = new Node(); newNode.data = value; newNode.maxLevel = level; //创建一个node数组,用于记录小于当前value的最大值 Node[] maxOfMinArr = new Node[level]; //默认情况下指向头节点 for (int i = 0; i \u0026lt; level; i++) { maxOfMinArr[i] = h; } //基于上述结果拿到当前节点的后继节点 Node p = h; for (int i = level - 1; i \u0026gt;= 0; i--) { while (p.forwards[i] != null \u0026amp;\u0026amp; p.forwards[i].data \u0026lt; value) { p = p.forwards[i]; } maxOfMinArr[i] = p; } //更新前驱节点的后继节点为当前节点newNode for (int i = 0; i \u0026lt; level; i++) { newNode.forwards[i] = maxOfMinArr[i].forwards[i]; maxOfMinArr[i].forwards[i] = newNode; } //如果当前newNode高度大于跳表最高高度则更新levelCount if (levelCount \u0026lt; level) { levelCount = level; } } 元素查询 # 查询逻辑比较简单,从跳表最高级的索引开始定位找到小于要查的 value 的最大值,以下图为例,我们希望查找到节点 8:\n跳表的 3 级索引首先找找到 5 的索引,5 的 3 级索引 forwards[3] 指向空,索引直接向下。 来到 5 的 2 级索引,其后继 forwards[2] 指向 8,继续向下。 5 的 1 级索引 forwards[1] 指向索引 6,继续向前。 索引 6 的 forwards[1] 指向索引 8,继续向下。 我们在原始节点向前找到节点 7。 节点 7 后续就是节点 8,继续向前为节点 8,无法继续向下,结束搜寻。 判断 7 的前驱,等于 8,查找结束。 所以我们的代码实现也很上述步骤差不多,从最高级索引开始向前查找,如果不为空且小于要查找的值,则继续向前搜寻,遇到不小于的节点则继续向下,如此往复,直到得到当前跳表中小于查找值的最大节点,查看其前驱是否等于要查找的值:\npublic Node get(int value) { Node p = h; //找到小于value的最大值 for (int i = levelCount - 1; i \u0026gt;= 0; i--) { while (p.forwards[i] != null \u0026amp;\u0026amp; p.forwards[i].data \u0026lt; value) { p = p.forwards[i]; } } //如果p的前驱节点等于value则直接返回 if (p.forwards[0] != null \u0026amp;\u0026amp; p.forwards[0].data == value) { return p.forwards[0]; } return null; } 元素删除 # 最后是删除逻辑,需要查找各层级小于要删除节点的最大值,假设我们要删除 10:\n3 级索引得到小于 10 的最大值为 5,继续向下。 2 级索引从索引 5 开始查找,发现小于 10 的最大值为 8,继续向下。 同理 1 级索引得到 8,继续向下。 原始节点找到 9。 从最高级索引开始,查看每个小于 10 的节点后继节点是否为 10,如果等于 10,则让这个节点指向 10 的后继节点,将节点 10 及其索引交由 GC 回收。 /** * 删除 * * @param value */ public void delete(int value) { Node p = h; //找到各级节点小于value的最大值 Node[] updateArr = new Node[levelCount]; for (int i = levelCount - 1; i \u0026gt;= 0; i--) { while (p.forwards[i] != null \u0026amp;\u0026amp; p.forwards[i].data \u0026lt; value) { p = p.forwards[i]; } updateArr[i] = p; } //查看原始层节点前驱是否等于value,若等于则说明存在要删除的值 if (p.forwards[0] != null \u0026amp;\u0026amp; p.forwards[0].data == value) { //从最高级索引开始查看其前驱是否等于value,若等于则将当前节点指向value节点的后继节点 for (int i = levelCount - 1; i \u0026gt;= 0; i--) { if (updateArr[i].forwards[i] != null \u0026amp;\u0026amp; updateArr[i].forwards[i].data == value) { updateArr[i].forwards[i] = updateArr[i].forwards[i].forwards[i]; } } } //从最高级开始查看是否有一级索引为空,若为空则层级减1 while (levelCount \u0026gt; 1 \u0026amp;\u0026amp; h.forwards[levelCount - 1] == null) { levelCount--; } } 完整代码以及测试 # 完整代码如下,读者可自行参阅:\npublic class SkipList { /** * 跳表索引最大高度为16 */ private static final int MAX_LEVEL = 16; /** * 每个节点添加一层索引高度的概率为二分之一 */ private static final float PROB = 0.5 f; /** * 默认情况下的高度为1,即只有自己一个节点 */ private int levelCount = 1; /** * 跳表最底层的节点,即头节点 */ private Node h = new Node(); public SkipList() {} public class Node { private int data = -1; /** * */ private Node[] forwards = new Node[MAX_LEVEL]; private int maxLevel = 0; @Override public String toString() { return \u0026#34;Node{\u0026#34; + \u0026#34;data=\u0026#34; + data + \u0026#34;, maxLevel=\u0026#34; + maxLevel + \u0026#39;}\u0026#39;; } } public void add(int value) { //随机生成高度 int level = randomLevel(); Node newNode = new Node(); newNode.data = value; newNode.maxLevel = level; //创建一个node数组,用于记录小于当前value的最大值 Node[] maxOfMinArr = new Node[level]; //默认情况下指向头节点 for (int i = 0; i \u0026lt; level; i++) { maxOfMinArr[i] = h; } //基于上述结果拿到当前节点的后继节点 Node p = h; for (int i = level - 1; i \u0026gt;= 0; i--) { while (p.forwards[i] != null \u0026amp;\u0026amp; p.forwards[i].data \u0026lt; value) { p = p.forwards[i]; } maxOfMinArr[i] = p; } //更新前驱节点的后继节点为当前节点newNode for (int i = 0; i \u0026lt; level; i++) { newNode.forwards[i] = maxOfMinArr[i].forwards[i]; maxOfMinArr[i].forwards[i] = newNode; } //如果当前newNode高度大于跳表最高高度则更新levelCount if (levelCount \u0026lt; level) { levelCount = level; } } /** * 理论来讲,一级索引中元素个数应该占原始数据的 50%,二级索引中元素个数占 25%,三级索引12.5% ,一直到最顶层。 * 因为这里每一层的晋升概率是 50%。对于每一个新插入的节点,都需要调用 randomLevel 生成一个合理的层数。 * 该 randomLevel 方法会随机生成 1~MAX_LEVEL 之间的数,且 : * 50%的概率返回 1 * 25%的概率返回 2 * 12.5%的概率返回 3 ... * @return */ private int randomLevel() { int level = 1; while (Math.random() \u0026gt; PROB \u0026amp;\u0026amp; level \u0026lt; MAX_LEVEL) { ++level; } return level; } public Node get(int value) { Node p = h; //找到小于value的最大值 for (int i = levelCount - 1; i \u0026gt;= 0; i--) { while (p.forwards[i] != null \u0026amp;\u0026amp; p.forwards[i].data \u0026lt; value) { p = p.forwards[i]; } } //如果p的前驱节点等于value则直接返回 if (p.forwards[0] != null \u0026amp;\u0026amp; p.forwards[0].data == value) { return p.forwards[0]; } return null; } /** * 删除 * * @param value */ public void delete(int value) { Node p = h; //找到各级节点小于value的最大值 Node[] updateArr = new Node[levelCount]; for (int i = levelCount - 1; i \u0026gt;= 0; i--) { while (p.forwards[i] != null \u0026amp;\u0026amp; p.forwards[i].data \u0026lt; value) { p = p.forwards[i]; } updateArr[i] = p; } //查看原始层节点前驱是否等于value,若等于则说明存在要删除的值 if (p.forwards[0] != null \u0026amp;\u0026amp; p.forwards[0].data == value) { //从最高级索引开始查看其前驱是否等于value,若等于则将当前节点指向value节点的后继节点 for (int i = levelCount - 1; i \u0026gt;= 0; i--) { if (updateArr[i].forwards[i] != null \u0026amp;\u0026amp; updateArr[i].forwards[i].data == value) { updateArr[i].forwards[i] = updateArr[i].forwards[i].forwards[i]; } } } //从最高级开始查看是否有一级索引为空,若为空则层级减1 while (levelCount \u0026gt; 1 \u0026amp;\u0026amp; h.forwards[levelCount - 1] == null) { levelCount--; } } public void printAll() { Node p = h; //基于最底层的非索引层进行遍历,只要后继节点不为空,则速速出当前节点,并移动到后继节点 while (p.forwards[0] != null) { System.out.println(p.forwards[0]); p = p.forwards[0]; } } } 对应测试代码和输出结果如下:\npublic static void main(String[] args) { SkipList skipList = new SkipList(); for (int i = 0; i \u0026lt; 24; i++) { skipList.add(i); } System.out.println(\u0026#34;==========输出添加结果==========\u0026#34;); skipList.printAll(); SkipList.Node node = skipList.get(22); System.out.println(\u0026#34;==========查询结果:\u0026#34; + node+\u0026#34; ==========\u0026#34;); skipList.delete(22); System.out.println(\u0026#34;==========删除结果==========\u0026#34;); skipList.printAll(); } 输出结果:\n==========输出添加结果========== Node{data=0, maxLevel=2} Node{data=1, maxLevel=3} Node{data=2, maxLevel=1} Node{data=3, maxLevel=1} Node{data=4, maxLevel=2} Node{data=5, maxLevel=2} Node{data=6, maxLevel=2} Node{data=7, maxLevel=2} Node{data=8, maxLevel=4} Node{data=9, maxLevel=1} Node{data=10, maxLevel=1} Node{data=11, maxLevel=1} Node{data=12, maxLevel=1} Node{data=13, maxLevel=1} Node{data=14, maxLevel=1} Node{data=15, maxLevel=3} Node{data=16, maxLevel=4} Node{data=17, maxLevel=2} Node{data=18, maxLevel=1} Node{data=19, maxLevel=1} Node{data=20, maxLevel=1} Node{data=21, maxLevel=3} Node{data=22, maxLevel=1} Node{data=23, maxLevel=1} ==========查询结果:Node{data=22, maxLevel=1} ========== ==========删除结果========== Node{data=0, maxLevel=2} Node{data=1, maxLevel=3} Node{data=2, maxLevel=1} Node{data=3, maxLevel=1} Node{data=4, maxLevel=2} Node{data=5, maxLevel=2} Node{data=6, maxLevel=2} Node{data=7, maxLevel=2} Node{data=8, maxLevel=4} Node{data=9, maxLevel=1} Node{data=10, maxLevel=1} Node{data=11, maxLevel=1} Node{data=12, maxLevel=1} Node{data=13, maxLevel=1} Node{data=14, maxLevel=1} Node{data=15, maxLevel=3} Node{data=16, maxLevel=4} Node{data=17, maxLevel=2} Node{data=18, maxLevel=1} Node{data=19, maxLevel=1} Node{data=20, maxLevel=1} Node{data=21, maxLevel=3} Node{data=23, maxLevel=1} Redis 跳表的特点:\n采用双向链表,不同于上面的示例,存在一个回退指针。主要用于简化操作,例如删除某个元素时,还需要找到该元素的前驱节点,使用回退指针会非常方便。 score 值可以重复,如果 score 值一样,则按照 ele(节点存储的值,为 sds)字典排序 Redis 跳跃表默认允许最大的层数是 32,被源码中 ZSKIPLIST_MAXLEVEL 定义。 和其余三种数据结构的比较 # 最后,我们再来回答一下文章开头的那道面试题: “Redis 的有序集合底层为什么要用跳表,而不用平衡树、红黑树或者 B+树?”。\n平衡树 vs 跳表 # 先来说说它和平衡树的比较,平衡树我们又会称之为 AVL 树,是一个严格的平衡二叉树,平衡条件必须满足(所有节点的左右子树高度差不超过 1,即平衡因子为范围为 [-1,1])。平衡树的插入、删除和查询的时间复杂度和跳表一样都是 O(log n) 。\n对于范围查询来说,它也可以通过中序遍历的方式达到和跳表一样的效果。但是它的每一次插入或者删除操作都需要保证整颗树左右节点的绝对平衡,只要不平衡就要通过旋转操作来保持平衡,这个过程是比较耗时的。\n跳表诞生的初衷就是为了克服平衡树的一些缺点,跳表的发明者在论文 《Skip lists: a probabilistic alternative to balanced trees》中有详细提到:\nSkip lists are a data structure that can be used in place of balanced trees. Skip lists use probabilistic balancing rather than strictly enforced balancing and as a result the algorithms for insertion and deletion in skip lists are much simpler and significantly faster than equivalent algorithms for balanced trees.\n跳表是一种可以用来代替平衡树的数据结构。跳表使用概率平衡而不是严格强制的平衡,因此,跳表中的插入和删除算法比平衡树的等效算法简单得多,速度也快得多。\n笔者这里也贴出了 AVL 树插入操作的核心代码,可以看出每一次添加操作都需要进行一次递归定位插入位置,然后还需要根据回溯到根节点检查沿途的各层节点是否失衡,再通过旋转节点的方式进行调整。\n// 向二分搜索树中添加新的元素(key, value) public void add(K key, V value) { root = add(root, key, value); } // 向以node为根的二分搜索树中插入元素(key, value),递归算法 // 返回插入新节点后二分搜索树的根 private Node add(Node node, K key, V value) { if (node == null) { size++; return new Node(key, value); } if (key.compareTo(node.key) \u0026lt; 0) node.left = add(node.left, key, value); else if (key.compareTo(node.key) \u0026gt; 0) node.right = add(node.right, key, value); else // key.compareTo(node.key) == 0 node.value = value; node.height = 1 + Math.max(getHeight(node.left), getHeight(node.right)); int balanceFactor = getBalanceFactor(node); // LL型需要右旋 if (balanceFactor \u0026gt; 1 \u0026amp;\u0026amp; getBalanceFactor(node.left) \u0026gt;= 0) { return rightRotate(node); } //RR型失衡需要左旋 if (balanceFactor \u0026lt; -1 \u0026amp;\u0026amp; getBalanceFactor(node.right) \u0026lt;= 0) { return leftRotate(node); } //LR需要先左旋成LL型,然后再右旋 if (balanceFactor \u0026gt; 1 \u0026amp;\u0026amp; getBalanceFactor(node.left) \u0026lt; 0) { node.left = leftRotate(node.left); return rightRotate(node); } //RL if (balanceFactor \u0026lt; -1 \u0026amp;\u0026amp; getBalanceFactor(node.right) \u0026gt; 0) { node.right = rightRotate(node.right); return leftRotate(node); } return node; } 红黑树 vs 跳表 # 红黑树(Red Black Tree)也是一种自平衡二叉查找树,它的查询性能略微逊色于 AVL 树,但插入和删除效率更高。红黑树的插入、删除和查询的时间复杂度和跳表一样都是 O(log n) 。\n红黑树是一个黑平衡树,即从任意节点到另外一个叶子叶子节点,它所经过的黑节点是一样的。当对它进行插入操作时,需要通过旋转和染色(红黑变换)来保证黑平衡。不过,相较于 AVL 树为了维持平衡的开销要小一些。关于红黑树的详细介绍,可以查看这篇文章: 红黑树。\n相比较于红黑树来说,跳表的实现也更简单一些。并且,按照区间来查找数据这个操作,红黑树的效率没有跳表高。\n对应红黑树添加的核心代码如下,读者可自行参阅理解:\nprivate Node \u0026lt; K, V \u0026gt; add(Node \u0026lt; K, V \u0026gt; node, K key, V val) { if (node == null) { size++; return new Node(key, val); } if (key.compareTo(node.key) \u0026lt; 0) { node.left = add(node.left, key, val); } else if (key.compareTo(node.key) \u0026gt; 0) { node.right = add(node.right, key, val); } else { node.val = val; } //左节点不为红,右节点为红,左旋 if (isRed(node.right) \u0026amp;\u0026amp; !isRed(node.left)) { node = leftRotate(node); } //左链右旋 if (isRed(node.left) \u0026amp;\u0026amp; isRed(node.left.left)) { node = rightRotate(node); } //颜色翻转 if (isRed(node.left) \u0026amp;\u0026amp; isRed(node.right)) { flipColors(node); } return node; } B+树 vs 跳表 # 想必使用 MySQL 的读者都知道 B+树这个数据结构,B+树是一种常用的数据结构,具有以下特点:\n多叉树结构:它是一棵多叉树,每个节点可以包含多个子节点,减小了树的高度,查询效率高。 存储效率高:其中非叶子节点存储多个 key,叶子节点存储 value,使得每个节点更够存储更多的键,根据索引进行范围查询时查询效率更高。- 平衡性:它是绝对的平衡,即树的各个分支高度相差不大,确保查询和插入时间复杂度为 O(log n) 。 顺序访问:叶子节点间通过链表指针相连,范围查询表现出色。 数据均匀分布:B+树插入时可能会导致数据重新分布,使得数据在整棵树分布更加均匀,保证范围查询和删除效率。 所以,B+树更适合作为数据库和文件系统中常用的索引结构之一,它的核心思想是通过可能少的 IO 定位到尽可能多的索引来获得查询数据。对于 Redis 这种内存数据库来说,它对这些并不感冒,因为 Redis 作为内存数据库它不可能存储大量的数据,所以对于索引不需要通过 B+树这种方式进行维护,只需按照概率进行随机维护即可,节约内存。而且使用跳表实现 zset 时相较前者来说更简单一些,在进行插入时只需通过索引将数据插入到链表中合适的位置再随机维护一定高度的索引即可,也不需要像 B+树那样插入时发现失衡时还需要对节点分裂与合并。\nRedis 作者给出的理由 # 当然我们也可以通过 Redis 的作者自己给出的理由:\nThere are a few reasons: 1、They are not very memory intensive. It\u0026rsquo;s up to you basically. Changing parameters about the probability of a node to have a given number of levels will make then less memory intensive than btrees. 2、A sorted set is often target of many ZRANGE or ZREVRANGE operations, that is, traversing the skip list as a linked list. With this operation the cache locality of skip lists is at least as good as with other kind of balanced trees. 3、They are simpler to implement, debug, and so forth. For instance thanks to the skip list simplicity I received a patch (already in Redis master) with augmented skip lists implementing ZRANK in O(log(N)). It required little changes to the code.\n翻译过来的意思就是:\n有几个原因:\n1、它们不是很占用内存。这主要取决于你。改变节点拥有给定层数的概率的参数,会使它们比 B 树更节省内存。\n2、有序集合经常是许多 ZRANGE 或 ZREVRANGE 操作的目标,也就是说,以链表的方式遍历跳表。通过这种操作,跳表的缓存局部性至少和其他类型的平衡树一样好。\n3、它们更容易实现、调试等等。例如,由于跳表的简单性,我收到了一个补丁(已经在 Redis 主分支中),用增强的跳表实现了 O(log(N))的 ZRANK。它只需要对代码做很少的修改。\n小结 # 本文通过大量篇幅介绍跳表的工作原理和实现,帮助读者更进一步的熟悉跳表这一数据结构的优劣,最后再结合各个数据结构操作的特点进行比对,从而帮助读者更好的理解这道面试题,建议读者实现理解跳表时,尽可能配合执笔模拟来了解跳表的增删改查详细过程。\n参考 # 为啥 redis 使用跳表(skiplist)而不是使用 red-black?: https://www.zhihu.com/question/20202931/answer/16086538 Skip List\u0026ndash;跳表(全网最详细的跳表文章没有之一): https://www.jianshu.com/p/9d8296562806 Redis 对象与底层数据结构详解: https://blog.csdn.net/shark_chili3007/article/details/104171986 Redis 有序集合(sorted set): https://www.runoob.com/redis/redis-sorted-sets.html 红黑树和跳表比较: https://zhuanlan.zhihu.com/p/576984787 为什么 redis 的 zset 用跳跃表而不用 b+ tree?: https://blog.csdn.net/f80407515/article/details/129136998 "},{"id":565,"href":"/zh/docs/technology/Interview/system-design/basis/RESTfulAPI/","title":"RestFul API 简明教程","section":"Basis","content":"\n这篇文章简单聊聊后端程序员必备的 RESTful API 相关的知识。\n开始正式介绍 RESTful API 之前,我们需要首先搞清:API 到底是什么?\n何为 API? # API(Application Programming Interface) 翻译过来是应用程序编程接口的意思。\n我们在进行后端开发的时候,主要的工作就是为前端或者其他后端服务提供 API 比如查询用户数据的 API 。\n但是, API 不仅仅代表后端系统暴露的接口,像框架中提供的方法也属于 API 的范畴。\n为了方便大家理解,我再列举几个例子 🌰:\n你通过某电商网站搜索某某商品,电商网站的前端就调用了后端提供了搜索商品相关的 API。 你使用 JDK 开发 Java 程序,想要读取用户的输入的话,你就需要使用 JDK 提供的 IO 相关的 API。 …… 你可以把 API 理解为程序与程序之间通信的桥梁,其本质就是一个函数而已。另外,API 的使用也不是没有章法的,它的规则由(比如数据输入和输出的格式)API 提供方制定。\n何为 RESTful API? # RESTful API 经常也被叫做 REST API,它是基于 REST 构建的 API。这个 REST 到底是什么,我们后文在讲,涉及到的概念比较多。\n如果你看 RESTful API 相关的文章的话一般都比较晦涩难懂,主要是因为 REST 涉及到的一些概念比较难以理解。但是,实际上,我们平时开发用到的 RESTful API 的知识非常简单也很容易概括!\n举个例子,如果我给你下面两个 API 你是不是立马能知道它们是干什么用的!这就是 RESTful API 的强大之处!\nGET /classes:列出所有班级 POST /classes:新建一个班级 RESTful API 可以让你看到 URL+Http Method 就知道这个 URL 是干什么的,让你看到了 HTTP 状态码(status code)就知道请求结果如何。\n像咱们在开发过程中设计 API 的时候也应该至少要满足 RESTful API 的最基本的要求(比如接口中尽量使用名词,使用 POST 请求创建资源,DELETE 请求删除资源等等,示例:GET /notes/id:获取某个指定 id 的笔记的信息)。\n解读 REST # REST 是 REpresentational State Transfer 的缩写。这个词组的翻译过来就是“表现层状态转化”。\n这样理解起来甚是晦涩,实际上 REST 的全称是 Resource Representational State Transfer ,直白地翻译过来就是 “资源”在网络传输中以某种“表现形式”进行“状态转移” 。如果还是不能继续理解,请继续往下看,相信下面的讲解一定能让你理解到底啥是 REST 。\n我们分别对上面涉及到的概念进行解读,以便加深理解,实际上你不需要搞懂下面这些概念,也能看懂我下一部分要介绍到的内容。不过,为了更好地能跟别人扯扯 “RESTful API”我建议你还是要好好理解一下!\n资源(Resource):我们可以把真实的对象数据称为资源。一个资源既可以是一个集合,也可以是单个个体。比如我们的班级 classes 是代表一个集合形式的资源,而特定的 class 代表单个个体资源。每一种资源都有特定的 URI(统一资源标识符)与之对应,如果我们需要获取这个资源,访问这个 URI 就可以了,比如获取特定的班级:/class/12。另外,资源也可以包含子资源,比如 /classes/classId/teachers:列出某个指定班级的所有老师的信息 表现形式(Representational):\u0026ldquo;资源\u0026quot;是一种信息实体,它可以有多种外在表现形式。我们把\u0026quot;资源\u0026quot;具体呈现出来的形式比如 json,xml,image,txt 等等叫做它的\u0026quot;表现层/表现形式\u0026rdquo;。 状态转移(State Transfer):大家第一眼看到这个词语一定会很懵逼?内心 BB:这尼玛是啥啊? 大白话来说 REST 中的状态转移更多地描述的服务器端资源的状态,比如你通过增删改查(通过 HTTP 动词实现)引起资源状态的改变。ps:互联网通信协议 HTTP 协议,是一个无状态协议,所有的资源状态都保存在服务器端。 综合上面的解释,我们总结一下什么是 RESTful 架构:\n每一个 URI 代表一种资源; 客户端和服务器之间,传递这种资源的某种表现形式比如 json,xml,image,txt 等等; 客户端通过特定的 HTTP 动词,对服务器端资源进行操作,实现\u0026quot;表现层状态转化\u0026quot;。 RESTful API 规范 # 动作 # GET:请求从服务器获取特定资源。举个例子:GET /classes(获取所有班级) POST:在服务器上创建一个新的资源。举个例子:POST /classes(创建班级) PUT:更新服务器上的资源(客户端提供更新后的整个资源)。举个例子:PUT /classes/12(更新编号为 12 的班级) DELETE:从服务器删除特定的资源。举个例子:DELETE /classes/12(删除编号为 12 的班级) PATCH:更新服务器上的资源(客户端提供更改的属性,可以看做作是部分更新),使用的比较少,这里就不举例子了。 路径(接口命名) # 路径又称\u0026quot;终点\u0026quot;(endpoint),表示 API 的具体网址。实际开发中常见的规范如下:\n网址中不能有动词,只能有名词,API 中的名词也应该使用复数。 因为 REST 中的资源往往和数据库中的表对应,而数据库中的表都是同种记录的\u0026quot;集合\u0026quot;(collection)。如果 API 调用并不涉及资源(如计算,翻译等操作)的话,可以用动词。比如:GET /calculate?param1=11\u0026amp;param2=33 。 不用大写字母,建议用中杠 - 不用下杠 _ 。比如邀请码写成 invitation-code而不是 invitation_code 。 善用版本化 API。当我们的 API 发生了重大改变而不兼容前期版本的时候,我们可以通过 URL 来实现版本化,比如 http://api.example.com/v1、http://apiv1.example.com 。版本不必非要是数字,只是数字用的最多,日期、季节都可以作为版本标识符,项目团队达成共识就可。 接口尽量使用名词,避免使用动词。 RESTful API 操作(HTTP Method)的是资源(名词)而不是动作(动词)。 Talk is cheap!来举个实际的例子来说明一下吧!现在有这样一个 API 提供班级(class)的信息,还包括班级中的学生和教师的信息,则它的路径应该设计成下面这样。\nGET /classes:列出所有班级 POST /classes:新建一个班级 GET /classes/{classId}:获取某个指定班级的信息 PUT /classes/{classId}:更新某个指定班级的信息(一般倾向整体更新) PATCH /classes/{classId}:更新某个指定班级的信息(一般倾向部分更新) DELETE /classes/{classId}:删除某个班级 GET /classes/{classId}/teachers:列出某个指定班级的所有老师的信息 GET /classes/{classId}/students:列出某个指定班级的所有学生的信息 DELETE /classes/{classId}/teachers/{ID}:删除某个指定班级下的指定的老师的信息 反例:\n/getAllclasses /createNewclass /deleteAllActiveclasses 理清资源的层次结构,比如业务针对的范围是学校,那么学校会是一级资源:/schools,老师: /schools/teachers,学生: /schools/students 就是二级资源。\n过滤信息(Filtering) # 如果我们在查询的时候需要添加特定条件的话,建议使用 url 参数的形式。比如我们要查询 state 状态为 active 并且 name 为 guidegege 的班级:\nGET /classes?state=active\u0026amp;name=guidegege 比如我们要实现分页查询:\nGET /classes?page=1\u0026amp;size=10 //指定第1页,每页10个数据 状态码(Status Codes) # 状态码范围:\n2xx:成功 3xx:重定向 4xx:客户端错误 5xx:服务器错误 200 成功 301 永久重定向 400 错误请求 500 服务器错误 201 创建 304 资源未修改 401 未授权 502 网关错误 403 禁止访问 504 网关超时 404 未找到 405 请求方法不对 RESTful 的极致 HATEOAS # RESTful 的极致是 hateoas ,但是这个基本不会在实际项目中用到。\n上面是 RESTful API 最基本的东西,也是我们平时开发过程中最容易实践到的。实际上,RESTful API 最好做到 Hypermedia,即返回结果中提供链接,连向其他 API 方法,使得用户不查文档,也知道下一步应该做什么。\n比如,当用户向 api.example.com 的根目录发出请求,会得到这样一个返回结果\n{\u0026#34;link\u0026#34;: { \u0026#34;rel\u0026#34;: \u0026#34;collection https://www.example.com/classes\u0026#34;, \u0026#34;href\u0026#34;: \u0026#34;https://api.example.com/classes\u0026#34;, \u0026#34;title\u0026#34;: \u0026#34;List of classes\u0026#34;, \u0026#34;type\u0026#34;: \u0026#34;application/vnd.yourformat+json\u0026#34; }} 上面代码表示,文档中有一个 link 属性,用户读取这个属性就知道下一步该调用什么 API 了。rel 表示这个 API 与当前网址的关系(collection 关系,并给出该 collection 的网址),href 表示 API 的路径,title 表示 API 的标题,type 表示返回类型 Hypermedia API 的设计被称为 HATEOAS。\n在 Spring 中有一个叫做 HATEOAS 的 API 库,通过它我们可以更轻松的创建出符合 HATEOAS 设计的 API。相关文章:\n在 Spring Boot 中使用 HATEOAS Building REST services with Spring (Spring 官网 ) An Intro to Spring HATEOAS spring-hateoas-examples Spring HATEOAS (Spring 官网 ) 参考 # https://RESTfulapi.net/\nhttps://www.ruanyifeng.com/blog/2014/05/restful_api.html\nhttps://juejin.im/entry/59e460c951882542f578f2f0\nhttps://phauer.com/2016/testing-RESTful-services-java-best-practices/\nhttps://www.seobility.net/en/wiki/REST_API\nhttps://dev.to/duomly/rest-api-vs-graphql-comparison-3j6g\n"},{"id":566,"href":"/zh/docs/technology/Interview/high-performance/message-queue/rocketmq-questions/","title":"RocketMQ常见问题总结","section":"High Performance","content":" 本文由 FrancisQ 投稿! 相比原文主要进行了下面这些完善:\n分析了 RocketMQ 高性能读写的原因和顺序消费的具体实现 增加了消息类型、消费者类型、消费者组和生产者组的介绍 消息队列扫盲 # 消息队列顾名思义就是存放消息的队列,队列我就不解释了,别告诉我你连队列都不知道是啥吧?\n所以问题并不是消息队列是什么,而是 消息队列为什么会出现?消息队列能用来干什么?用它来干这些事会带来什么好处?消息队列会带来副作用吗?\n消息队列为什么会出现? # 消息队``列算是作为后端程序员的一个必备技能吧,因为分布式应用必定涉及到各个系统之间的通信问题,这个时候消息队列也应运而生了。可以说分布式的产生是消息队列的基础,而分布式怕是一个很古老的概念了吧,所以消息队列也是一个很古老的中间件了。\n消息队列能用来干什么? # 异步 # 你可能会反驳我,应用之间的通信又不是只能由消息队列解决,好好的通信为什么中间非要插一个消息队列呢?我不能直接进行通信吗?\n很好 👍,你又提出了一个概念,同步通信。就比如现在业界使用比较多的 Dubbo 就是一个适用于各个系统之间同步通信的 RPC 框架。\n我来举个 🌰 吧,比如我们有一个购票系统,需求是用户在购买完之后能接收到购买完成的短信。\n我们省略中间的网络通信时间消耗,假如购票系统处理需要 150ms ,短信系统处理需要 200ms ,那么整个处理流程的时间消耗就是 150ms + 200ms = 350ms。\n当然,乍看没什么问题。可是仔细一想你就感觉有点问题,我用户购票在购票系统的时候其实就已经完成了购买,而我现在通过同步调用非要让整个请求拉长时间,而短信系统这玩意又不是很有必要,它仅仅是一个辅助功能增强用户体验感而已。我现在整个调用流程就有点 头重脚轻 的感觉了,购票是一个不太耗时的流程,而我现在因为同步调用,非要等待发送短信这个比较耗时的操作才返回结果。那我如果再加一个发送邮件呢?\n这样整个系统的调用链又变长了,整个时间就变成了 550ms。\n当我们在学生时代需要在食堂排队的时候,我们和食堂大妈就是一个同步的模型。\n我们需要告诉食堂大妈:“姐姐,给我加个鸡腿,再加个酸辣土豆丝,帮我浇点汁上去,多打点饭哦 😋😋😋” 咦~~~ 为了多吃点,真恶心。\n然后大妈帮我们打饭配菜,我们看着大妈那颤抖的手和掉落的土豆丝不禁咽了咽口水。\n最终我们从大妈手中接过饭菜然后去寻找座位了\u0026hellip;\n回想一下,我们在给大妈发送需要的信息之后我们是 同步等待大妈给我配好饭菜 的,上面我们只是加了鸡腿和土豆丝,万一我再加一个番茄牛腩,韭菜鸡蛋,这样是不是大妈打饭配菜的流程就会变长,我们等待的时间也会相应的变长。\n那后来,我们工作赚钱了有钱去饭店吃饭了,我们告诉服务员来一碗牛肉面加个荷包蛋 (传达一个消息) ,然后我们就可以在饭桌上安心的玩手机了 (干自己其他事情) ,等到我们的牛肉面上了我们就可以吃了。这其中我们也就传达了一个消息,然后我们又转过头干其他事情了。这其中虽然做面的时间没有变短,但是我们只需要传达一个消息就可以干其他事情了,这是一个 异步 的概念。\n所以,为了解决这一个问题,聪明的程序员在中间也加了个类似于服务员的中间件——消息队列。这个时候我们就可以把模型给改造了。\n这样,我们在将消息存入消息队列之后我们就可以直接返回了(我们告诉服务员我们要吃什么然后玩手机),所以整个耗时只是 150ms + 10ms = 160ms。\n但是你需要注意的是,整个流程的时长是没变的,就像你仅仅告诉服务员要吃什么是不会影响到做面的速度的。\n解耦 # 回到最初同步调用的过程,我们写个伪代码简单概括一下。\n那么第二步,我们又添加了一个发送邮件,我们就得重新去修改代码,如果我们又加一个需求:用户购买完还需要给他加积分,这个时候我们是不是又得改代码?\n如果你觉得还行,那么我这个时候不要发邮件这个服务了呢,我是不是又得改代码,又得重启应用?\n这样改来改去是不是很麻烦,那么 此时我们就用一个消息队列在中间进行解耦 。你需要注意的是,我们后面的发送短信、发送邮件、添加积分等一些操作都依赖于上面的 result ,这东西抽象出来就是购票的处理结果呀,比如订单号,用户账号等等,也就是说我们后面的一系列服务都是需要同样的消息来进行处理。既然这样,我们是不是可以通过 “广播消息” 来实现。\n我上面所讲的“广播”并不是真正的广播,而是接下来的系统作为消费者去 订阅 特定的主题。比如我们这里的主题就可以叫做 订票 ,我们购买系统作为一个生产者去生产这条消息放入消息队列,然后消费者订阅了这个主题,会从消息队列中拉取消息并消费。就比如我们刚刚画的那张图,你会发现,在生产者这边我们只需要关注 生产消息到指定主题中 ,而 消费者只需要关注从指定主题中拉取消息 就行了。\n如果没有消息队列,每当一个新的业务接入,我们都要在主系统调用新接口、或者当我们取消某些业务,我们也得在主系统删除某些接口调用。有了消息队列,我们只需要关心消息是否送达了队列,至于谁希望订阅,接下来收到消息如何处理,是下游的事情,无疑极大地减少了开发和联调的工作量。\n削峰 # 我们再次回到一开始我们使用同步调用系统的情况,并且思考一下,如果此时有大量用户请求购票整个系统会变成什么样?\n如果,此时有一万的请求进入购票系统,我们知道运行我们主业务的服务器配置一般会比较好,所以这里我们假设购票系统能承受这一万的用户请求,那么也就意味着我们同时也会出现一万调用发短信服务的请求。而对于短信系统来说并不是我们的主要业务,所以我们配备的硬件资源并不会太高,那么你觉得现在这个短信系统能承受这一万的峰值么,且不说能不能承受,系统会不会 直接崩溃 了?\n短信业务又不是我们的主业务,我们能不能 折中处理 呢?如果我们把购买完成的信息发送到消息队列中,而短信系统 尽自己所能地去消息队列中取消息和消费消息 ,即使处理速度慢一点也无所谓,只要我们的系统没有崩溃就行了。\n留得江山在,还怕没柴烧?你敢说每次发送验证码的时候是一发你就收到了的么?\n消息队列能带来什么好处? # 其实上面我已经说了。异步、解耦、削峰。 哪怕你上面的都没看懂也千万要记住这六个字,因为他不仅是消息队列的精华,更是编程和架构的精华。\n消息队列会带来副作用吗? # 没有哪一门技术是“银弹”,消息队列也有它的副作用。\n比如,本来好好的两个系统之间的调用,我中间加了个消息队列,如果消息队列挂了怎么办呢?是不是 降低了系统的可用性 ?\n那这样是不是要保证 HA(高可用)?是不是要搞集群?那么我 整个系统的复杂度是不是上升了 ?\n抛开上面的问题不讲,万一我发送方发送失败了,然后执行重试,这样就可能产生重复的消息。\n或者我消费端处理失败了,请求重发,这样也会产生重复的消息。\n对于一些微服务来说,消费重复消息会带来更大的麻烦,比如增加积分,这个时候我加了多次是不是对其他用户不公平?\n那么,又 如何解决重复消费消息的问题 呢?\n如果我们此时的消息需要保证严格的顺序性怎么办呢?比如生产者生产了一系列的有序消息(对一个 id 为 1 的记录进行删除增加修改),但是我们知道在发布订阅模型中,对于主题是无顺序的,那么这个时候就会导致对于消费者消费消息的时候没有按照生产者的发送顺序消费,比如这个时候我们消费的顺序为修改删除增加,如果该记录涉及到金额的话是不是会出大事情?\n那么,又 如何解决消息的顺序消费问题 呢?\n就拿我们上面所讲的分布式系统来说,用户购票完成之后是不是需要增加账户积分?在同一个系统中我们一般会使用事务来进行解决,如果用 Spring 的话我们在上面伪代码中加入 @Transactional 注解就好了。但是在不同系统中如何保证事务呢?总不能这个系统我扣钱成功了你那积分系统积分没加吧?或者说我这扣钱明明失败了,你那积分系统给我加了积分。\n那么,又如何 解决分布式事务问题 呢?\n我们刚刚说了,消息队列可以进行削峰操作,那如果我的消费者如果消费很慢或者生产者生产消息很快,这样是不是会将消息堆积在消息队列中?\n那么,又如何 解决消息堆积的问题 呢?\n可用性降低,复杂度上升,又带来一系列的重复消费,顺序消费,分布式事务,消息堆积的问题,这消息队列还怎么用啊 😵?\n别急,办法总是有的。\nRocketMQ 是什么? # 哇,你个混蛋!上面给我抛出那么多问题,你现在又讲 RocketMQ ,还让不让人活了?!🤬\n别急别急,话说你现在清楚 MQ 的构造吗,我还没讲呢,我们先搞明白 MQ 的内部构造,再来看看如何解决上面的一系列问题吧,不过你最好带着问题去阅读和了解喔。\nRocketMQ 是一个 队列模型 的消息中间件,具有高性能、高可靠、高实时、分布式 的特点。它是一个采用 Java 语言开发的分布式的消息系统,由阿里巴巴团队开发,在 2016 年底贡献给 Apache,成为了 Apache 的一个顶级项目。 在阿里内部,RocketMQ 很好地服务了集团大大小小上千个应用,在每年的双十一当天,更有不可思议的万亿级消息通过 RocketMQ 流转。\n废话不多说,想要了解 RocketMQ 历史的同学可以自己去搜寻资料。听完上面的介绍,你只要知道 RocketMQ 很快、很牛、而且经历过双十一的实践就行了!\n队列模型和主题模型是什么? # 在谈 RocketMQ 的技术架构之前,我们先来了解一下两个名词概念——队列模型 和 主题模型 。\n首先我问一个问题,消息队列为什么要叫消息队列?\n你可能觉得很弱智,这玩意不就是存放消息的队列嘛?不叫消息队列叫什么?\n的确,早期的消息中间件是通过 队列 这一模型来实现的,可能是历史原因,我们都习惯把消息中间件成为消息队列。\n但是,如今例如 RocketMQ、Kafka 这些优秀的消息中间件不仅仅是通过一个 队列 来实现消息存储的。\n队列模型 # 就像我们理解队列一样,消息中间件的队列模型就真的只是一个队列。。。我画一张图给大家理解。\n在一开始我跟你提到了一个 “广播” 的概念,也就是说如果我们此时我们需要将一个消息发送给多个消费者(比如此时我需要将信息发送给短信系统和邮件系统),这个时候单个队列即不能满足需求了。\n当然你可以让 Producer 生产消息放入多个队列中,然后每个队列去对应每一个消费者。问题是可以解决,创建多个队列并且复制多份消息是会很影响资源和性能的。而且,这样子就会导致生产者需要知道具体消费者个数然后去复制对应数量的消息队列,这就违背我们消息中间件的 解耦 这一原则。\n主题模型 # 那么有没有好的方法去解决这一个问题呢?有,那就是 主题模型 或者可以称为 发布订阅模型 。\n感兴趣的同学可以去了解一下设计模式里面的观察者模式并且手动实现一下,我相信你会有所收获的。\n在主题模型中,消息的生产者称为 发布者(Publisher) ,消息的消费者称为 订阅者(Subscriber) ,存放消息的容器称为 主题(Topic) 。\n其中,发布者将消息发送到指定主题中,订阅者需要 提前订阅主题 才能接受特定主题的消息。\nRocketMQ 中的消息模型 # RocketMQ 中的消息模型就是按照 主题模型 所实现的。你可能会好奇这个 主题 到底是怎么实现的呢?你上面也没有讲到呀!\n其实对于主题模型的实现来说每个消息中间件的底层设计都是不一样的,就比如 Kafka 中的 分区 ,RocketMQ 中的 队列 ,RabbitMQ 中的 Exchange 。我们可以理解为 主题模型/发布订阅模型 就是一个标准,那些中间件只不过照着这个标准去实现而已。\n所以,RocketMQ 中的 主题模型 到底是如何实现的呢?首先我画一张图,大家尝试着去理解一下。\n我们可以看到在整个图中有 Producer Group、Topic、Consumer Group 三个角色,我来分别介绍一下他们。\nProducer Group 生产者组:代表某一类的生产者,比如我们有多个秒杀系统作为生产者,这多个合在一起就是一个 Producer Group 生产者组,它们一般生产相同的消息。 Consumer Group 消费者组:代表某一类的消费者,比如我们有多个短信系统作为消费者,这多个合在一起就是一个 Consumer Group 消费者组,它们一般消费相同的消息。 Topic 主题:代表一类消息,比如订单消息,物流消息等等。 你可以看到图中生产者组中的生产者会向主题发送消息,而 主题中存在多个队列,生产者每次生产消息之后是指定主题中的某个队列发送消息的。\n每个主题中都有多个队列(分布在不同的 Broker中,如果是集群的话,Broker又分布在不同的服务器中),集群消费模式下,一个消费者集群多台机器共同消费一个 topic 的多个队列,一个队列只会被一个消费者消费。如果某个消费者挂掉,分组内其它消费者会接替挂掉的消费者继续消费。就像上图中 Consumer1 和 Consumer2 分别对应着两个队列,而 Consumer3 是没有队列对应的,所以一般来讲要控制 消费者组中的消费者个数和主题中队列个数相同 。\n当然也可以消费者个数小于队列个数,只不过不太建议。如下图。\n每个消费组在每个队列上维护一个消费位置 ,为什么呢?\n因为我们刚刚画的仅仅是一个消费者组,我们知道在发布订阅模式中一般会涉及到多个消费者组,而每个消费者组在每个队列中的消费位置都是不同的。如果此时有多个消费者组,那么消息被一个消费者组消费完之后是不会删除的(因为其它消费者组也需要呀),它仅仅是为每个消费者组维护一个 消费位移(offset) ,每次消费者组消费完会返回一个成功的响应,然后队列再把维护的消费位移加一,这样就不会出现刚刚消费过的消息再一次被消费了。\n可能你还有一个问题,为什么一个主题中需要维护多个队列 ?\n答案是 提高并发能力 。的确,每个主题中只存在一个队列也是可行的。你想一下,如果每个主题中只存在一个队列,这个队列中也维护着每个消费者组的消费位置,这样也可以做到 发布订阅模式 。如下图。\n但是,这样我生产者是不是只能向一个队列发送消息?又因为需要维护消费位置所以一个队列只能对应一个消费者组中的消费者,这样是不是其他的 Consumer 就没有用武之地了?从这两个角度来讲,并发度一下子就小了很多。\n所以总结来说,RocketMQ 通过使用在一个 Topic 中配置多个队列并且每个队列维护每个消费者组的消费位置 实现了 主题模式/发布订阅模式 。\nRocketMQ 的架构图 # 讲完了消息模型,我们理解起 RocketMQ 的技术架构起来就容易多了。\nRocketMQ 技术架构中有四大角色 NameServer、Broker、Producer、Consumer 。我来向大家分别解释一下这四个角色是干啥的。\nBroker:主要负责消息的存储、投递和查询以及服务高可用保证。说白了就是消息队列服务器嘛,生产者生产消息到 Broker ,消费者从 Broker 拉取消息并消费。\n这里,我还得普及一下关于 Broker、Topic 和 队列的关系。上面我讲解了 Topic 和队列的关系——一个 Topic 中存在多个队列,那么这个 Topic 和队列存放在哪呢?\n一个 Topic 分布在多个 Broker上,一个 Broker 可以配置多个 Topic ,它们是多对多的关系。\n如果某个 Topic 消息量很大,应该给它多配置几个队列(上文中提到了提高并发能力),并且 尽量多分布在不同 Broker 上,以减轻某个 Broker 的压力 。\nTopic 消息量都比较均匀的情况下,如果某个 broker 上的队列越多,则该 broker 压力越大。\n所以说我们需要配置多个 Broker。\nNameServer:不知道你们有没有接触过 ZooKeeper 和 Spring Cloud 中的 Eureka ,它其实也是一个 注册中心 ,主要提供两个功能:Broker 管理 和 路由信息管理 。说白了就是 Broker 会将自己的信息注册到 NameServer 中,此时 NameServer 就存放了很多 Broker 的信息(Broker 的路由表),消费者和生产者就从 NameServer 中获取路由表然后照着路由表的信息和对应的 Broker 进行通信(生产者和消费者定期会向 NameServer 去查询相关的 Broker 的信息)。\nProducer:消息发布的角色,支持分布式集群方式部署。说白了就是生产者。\nConsumer:消息消费的角色,支持分布式集群方式部署。支持以 push 推,pull 拉两种模式对消息进行消费。同时也支持集群方式和广播方式的消费,它提供实时消息订阅机制。说白了就是消费者。\n听完了上面的解释你可能会觉得,这玩意好简单。不就是这样的么?\n嗯?你可能会发现一个问题,这老家伙 NameServer 干啥用的,这不多余吗?直接 Producer、Consumer 和 Broker 直接进行生产消息,消费消息不就好了么?\n但是,我们上文提到过 Broker 是需要保证高可用的,如果整个系统仅仅靠着一个 Broker 来维持的话,那么这个 Broker 的压力会不会很大?所以我们需要使用多个 Broker 来保证 负载均衡 。\n如果说,我们的消费者和生产者直接和多个 Broker 相连,那么当 Broker 修改的时候必定会牵连着每个生产者和消费者,这样就会产生耦合问题,而 NameServer 注册中心就是用来解决这个问题的。\n如果还不是很理解的话,可以去看我介绍 Spring Cloud 的那篇文章,其中介绍了 Eureka 注册中心。\n当然,RocketMQ 中的技术架构肯定不止前面那么简单,因为上面图中的四个角色都是需要做集群的。我给出一张官网的架构图,大家尝试理解一下。\n其实和我们最开始画的那张乞丐版的架构图也没什么区别,主要是一些细节上的差别。听我细细道来 🤨。\n第一、我们的 Broker 做了集群并且还进行了主从部署 ,由于消息分布在各个 Broker 上,一旦某个 Broker 宕机,则该Broker 上的消息读写都会受到影响。所以 Rocketmq 提供了 master/slave 的结构,salve 定时从 master 同步数据(同步刷盘或者异步刷盘),如果 master 宕机,则 slave 提供消费服务,但是不能写入消息 (后面我还会提到哦)。\n第二、为了保证 HA ,我们的 NameServer 也做了集群部署,但是请注意它是 去中心化 的。也就意味着它没有主节点,你可以很明显地看出 NameServer 的所有节点是没有进行 Info Replicate 的,在 RocketMQ 中是通过 单个 Broker 和所有 NameServer 保持长连接 ,并且在每隔 30 秒 Broker 会向所有 Nameserver 发送心跳,心跳包含了自身的 Topic 配置信息,这个步骤就对应这上面的 Routing Info 。\n第三、在生产者需要向 Broker 发送消息的时候,需要先从 NameServer 获取关于 Broker 的路由信息,然后通过 轮询 的方法去向每个队列中生产数据以达到 负载均衡 的效果。\n第四、消费者通过 NameServer 获取所有 Broker 的路由信息后,向 Broker 发送 Pull 请求来获取消息数据。Consumer 可以以两种模式启动—— 广播(Broadcast)和集群(Cluster)。广播模式下,一条消息会发送给 同一个消费组中的所有消费者 ,集群模式下消息只会发送给一个消费者。\nRocketMQ 功能特性 # 消息 # 普通消息 # 普通消息一般应用于微服务解耦、事件驱动、数据集成等场景,这些场景大多数要求数据传输通道具有可靠传输的能力,且对消息的处理时机、处理顺序没有特别要求。以在线的电商交易场景为例,上游订单系统将用户下单支付这一业务事件封装成独立的普通消息并发送至 RocketMQ 服务端,下游按需从服务端订阅消息并按照本地消费逻辑处理下游任务。每个消息之间都是相互独立的,且不需要产生关联。另外还有日志系统,以离线的日志收集场景为例,通过埋点组件收集前端应用的相关操作日志,并转发到 RocketMQ 。\n普通消息生命周期\n初始化:消息被生产者构建并完成初始化,待发送到服务端的状态。 待消费:消息被发送到服务端,对消费者可见,等待消费者消费的状态。 消费中:消息被消费者获取,并按照消费者本地的业务逻辑进行处理的过程。 此时服务端会等待消费者完成消费并提交消费结果,如果一定时间后没有收到消费者的响应,RocketMQ 会对消息进行重试处理。 消费提交:消费者完成消费处理,并向服务端提交消费结果,服务端标记当前消息已经被处理(包括消费成功和失败)。RocketMQ 默认支持保留所有消息,此时消息数据并不会立即被删除,只是逻辑标记已消费。消息在保存时间到期或存储空间不足被删除前,消费者仍然可以回溯消息重新消费。 消息删除:RocketMQ 按照消息保存机制滚动清理最早的消息数据,将消息从物理文件中删除。 定时消息 # 在分布式定时调度触发、任务超时处理等场景,需要实现精准、可靠的定时事件触发。使用 RocketMQ 的定时消息可以简化定时调度任务的开发逻辑,实现高性能、可扩展、高可靠的定时触发能力。定时消息仅支持在 MessageType 为 Delay 的主题内使用,即定时消息只能发送至类型为定时消息的主题中,发送的消息的类型必须和主题的类型一致。在 4.x 版本中,只支持延时消息,默认分为 18 个等级分别为:1s 5s 10s 30s 1m 2m 3m 4m 5m 6m 7m 8m 9m 10m 20m 30m 1h 2h,也可以在配置文件中增加自定义的延时等级和时长。在 5.x 版本中,开始支持定时消息,在构造消息时提供了 3 个 API 来指定延迟时间或定时时间。\n基于定时消息的超时任务处理具备如下优势:\n精度高、开发门槛低:基于消息通知方式不存在定时阶梯间隔。可以轻松实现任意精度事件触发,无需业务去重。 高性能可扩展:传统的数据库扫描方式较为复杂,需要频繁调用接口扫描,容易产生性能瓶颈。RocketMQ 的定时消息具有高并发和水平扩展的能力。 定时消息生命周期\n初始化:消息被生产者构建并完成初始化,待发送到服务端的状态。 定时中:消息被发送到服务端,和普通消息不同的是,服务端不会直接构建消息索引,而是会将定时消息单独存储在定时存储系统中,等待定时时刻到达。 待消费:定时时刻到达后,服务端将消息重新写入普通存储引擎,对下游消费者可见,等待消费者消费的状态。 消费中:消息被消费者获取,并按照消费者本地的业务逻辑进行处理的过程。 此时服务端会等待消费者完成消费并提交消费结果,如果一定时间后没有收到消费者的响应,RocketMQ 会对消息进行重试处理。 消费提交:消费者完成消费处理,并向服务端提交消费结果,服务端标记当前消息已经被处理(包括消费成功和失败)。RocketMQ 默认支持保留所有消息,此时消息数据并不会立即被删除,只是逻辑标记已消费。消息在保存时间到期或存储空间不足被删除前,消费者仍然可以回溯消息重新消费。 消息删除:Apache RocketMQ 按照消息保存机制滚动清理最早的消息数据,将消息从物理文件中删除。 定时消息的实现逻辑需要先经过定时存储等待触发,定时时间到达后才会被投递给消费者。因此,如果将大量定时消息的定时时间设置为同一时刻,则到达该时刻后会有大量消息同时需要被处理,会造成系统压力过大,导致消息分发延迟,影响定时精度。\n顺序消息 # 顺序消息仅支持使用 MessageType 为 FIFO 的主题,即顺序消息只能发送至类型为顺序消息的主题中,发送的消息的类型必须和主题的类型一致。和普通消息发送相比,顺序消息发送必须要设置消息组。(推荐实现 MessageQueueSelector 的方式,见下文)。要保证消息的顺序性需要单一生产者串行发送。\n单线程使用 MessageListenerConcurrently 可以顺序消费,多线程环境下使用 MessageListenerOrderly 才能顺序消费。\n事务消息 # 事务消息是 Apache RocketMQ 提供的一种高级消息类型,支持在分布式场景下保障消息生产和本地事务的最终一致性。简单来讲,就是将本地事务(数据库的 DML 操作)与发送消息合并在同一个事务中。例如,新增一个订单。在事务未提交之前,不发送订阅的消息。发送消息的动作随着事务的成功提交而发送,随着事务的回滚而取消。当然真正地处理过程不止这么简单,包含了半消息、事务监听和事务回查等概念,下面有更详细的说明。\n关于发送消息 # 不建议单一进程创建大量生产者 # Apache RocketMQ 的生产者和主题是多对多的关系,支持同一个生产者向多个主题发送消息。对于生产者的创建和初始化,建议遵循够用即可、最大化复用原则,如果有需要发送消息到多个主题的场景,无需为每个主题都创建一个生产者。\n不建议频繁创建和销毁生产者 # Apache RocketMQ 的生产者是可以重复利用的底层资源,类似数据库的连接池。因此不需要在每次发送消息时动态创建生产者,且在发送结束后销毁生产者。这样频繁的创建销毁会在服务端产生大量短连接请求,严重影响系统性能。\n正确示例:\nProducer p = ProducerBuilder.build(); for (int i =0;i\u0026lt;n;i++){ Message m= MessageBuilder.build(); p.send(m); } p.shutdown(); 消费者分类 # PushConsumer # 高度封装的消费者类型,消费消息仅仅通过消费监听器监听并返回结果。消息的获取、消费状态提交以及消费重试都通过 RocketMQ 的客户端 SDK 完成。\nPushConsumer 的消费监听器执行结果分为以下三种情况:\n返回消费成功:以 Java SDK 为例,返回ConsumeResult.SUCCESS,表示该消息处理成功,服务端按照消费结果更新消费进度。 返回消费失败:以 Java SDK 为例,返回ConsumeResult.FAILURE,表示该消息处理失败,需要根据消费重试逻辑判断是否进行重试消费。 出现非预期失败:例如抛异常等行为,该结果按照消费失败处理,需要根据消费重试逻辑判断是否进行重试消费。 具体实现可以参见这篇文章 RocketMQ 对 pull 和 push 的实现。\n使用 PushConsumer 消费者消费时,不允许使用以下方式处理消息,否则 RocketMQ 无法保证消息的可靠性。\n错误方式一:消息还未处理完成,就提前返回消费成功结果。此时如果消息消费失败,RocketMQ 服务端是无法感知的,因此不会进行消费重试。 错误方式二:在消费监听器内将消息再次分发到自定义的其他线程,消费监听器提前返回消费结果。此时如果消息消费失败,RocketMQ 服务端同样无法感知,因此也不会进行消费重试。 PushConsumer 严格限制了消息同步处理及每条消息的处理超时时间,适用于以下场景: 消息处理时间可预估:如果不确定消息处理耗时,经常有预期之外的长时间耗时的消息,PushConsumer 的可靠性保证会频繁触发消息重试机制造成大量重复消息。 无异步化、高级定制场景:PushConsumer 限制了消费逻辑的线程模型,由客户端 SDK 内部按最大吞吐量触发消息处理。该模型开发逻辑简单,但是不允许使用异步化和自定义处理流程。 SimpleConsumer # SimpleConsumer 是一种接口原子型的消费者类型,消息的获取、消费状态提交以及消费重试都是通过消费者业务逻辑主动发起调用完成。\n一个来自官网的例子:\n// 消费示例:使用 SimpleConsumer 消费普通消息,主动获取消息处理并提交。 ClientServiceProvider provider = ClientServiceProvider.loadService(); String topic = \u0026#34;YourTopic\u0026#34;; FilterExpression filterExpression = new FilterExpression(\u0026#34;YourFilterTag\u0026#34;, FilterExpressionType.TAG); SimpleConsumer simpleConsumer = provider.newSimpleConsumerBuilder() // 设置消费者分组。 .setConsumerGroup(\u0026#34;YourConsumerGroup\u0026#34;) // 设置接入点。 .setClientConfiguration(ClientConfiguration.newBuilder().setEndpoints(\u0026#34;YourEndpoint\u0026#34;).build()) // 设置预绑定的订阅关系。 .setSubscriptionExpressions(Collections.singletonMap(topic, filterExpression)) // 设置从服务端接受消息的最大等待时间 .setAwaitDuration(Duration.ofSeconds(1)) .build(); try { // SimpleConsumer 需要主动获取消息,并处理。 List\u0026lt;MessageView\u0026gt; messageViewList = simpleConsumer.receive(10, Duration.ofSeconds(30)); messageViewList.forEach(messageView -\u0026gt; { System.out.println(messageView); // 消费处理完成后,需要主动调用 ACK 提交消费结果。 try { simpleConsumer.ack(messageView); } catch (ClientException e) { logger.error(\u0026#34;Failed to ack message, messageId={}\u0026#34;, messageView.getMessageId(), e); } }); } catch (ClientException e) { // 如果遇到系统流控等原因造成拉取失败,需要重新发起获取消息请求。 logger.error(\u0026#34;Failed to receive message\u0026#34;, e); } SimpleConsumer 适用于以下场景:\n消息处理时长不可控:如果消息处理时长无法预估,经常有长时间耗时的消息处理情况。建议使用 SimpleConsumer 消费类型,可以在消费时自定义消息的预估处理时长,若实际业务中预估的消息处理时长不符合预期,也可以通过接口提前修改。 需要异步化、批量消费等高级定制场景:SimpleConsumer 在 SDK 内部没有复杂的线程封装,完全由业务逻辑自由定制,可以实现异步分发、批量消费等高级定制场景。 需要自定义消费速率:SimpleConsumer 是由业务逻辑主动调用接口获取消息,因此可以自由调整获取消息的频率,自定义控制消费速率。 PullConsumer # 施工中。。。\n消费者分组和生产者分组 # 生产者分组 # RocketMQ 服务端 5.x 版本开始,生产者是匿名的,无需管理生产者分组(ProducerGroup);对于历史版本服务端 3.x 和 4.x 版本,已经使用的生产者分组可以废弃无需再设置,且不会对当前业务产生影响。\n消费者分组 # 消费者分组是多个消费行为一致的消费者的负载均衡分组。消费者分组不是具体实体而是一个逻辑资源。通过消费者分组实现消费性能的水平扩展以及高可用容灾。\n消费者分组中的订阅关系、投递顺序性、消费重试策略是一致的。\n订阅关系:Apache RocketMQ 以消费者分组的粒度管理订阅关系,实现订阅关系的管理和追溯。 投递顺序性:Apache RocketMQ 的服务端将消息投递给消费者消费时,支持顺序投递和并发投递,投递方式在消费者分组中统一配置。 消费重试策略: 消费者消费消息失败时的重试策略,包括重试次数、死信队列设置等。 RocketMQ 服务端 5.x 版本:上述消费者的消费行为从关联的消费者分组中统一获取,因此,同一分组内所有消费者的消费行为必然是一致的,客户端无需关注。\nRocketMQ 服务端 3.x/4.x 历史版本:上述消费逻辑由消费者客户端接口定义,因此,您需要自己在消费者客户端设置时保证同一分组下的消费者的消费行为一致。(来自官方网站)\n如何解决顺序消费和重复消费? # 其实,这些东西都是我在介绍消息队列带来的一些副作用的时候提到的,也就是说,这些问题不仅仅挂钩于 RocketMQ ,而是应该每个消息中间件都需要去解决的。\n在上面我介绍 RocketMQ 的技术架构的时候我已经向你展示了 它是如何保证高可用的 ,这里不涉及运维方面的搭建,如果你感兴趣可以自己去官网上照着例子搭建属于你自己的 RocketMQ 集群。\n其实 Kafka 的架构基本和 RocketMQ 类似,只是它注册中心使用了 Zookeeper、它的 分区 就相当于 RocketMQ 中的 队列 。还有一些小细节不同会在后面提到。\n顺序消费 # 在上面的技术架构介绍中,我们已经知道了 RocketMQ 在主题上是无序的、它只有在队列层面才是保证有序 的。\n这又扯到两个概念——普通顺序 和 严格顺序 。\n所谓普通顺序是指 消费者通过 同一个消费队列收到的消息是有顺序的 ,不同消息队列收到的消息则可能是无顺序的。普通顺序消息在 Broker 重启情况下不会保证消息顺序性 (短暂时间) 。\n所谓严格顺序是指 消费者收到的 所有消息 均是有顺序的。严格顺序消息 即使在异常情况下也会保证消息的顺序性 。\n但是,严格顺序看起来虽好,实现它可会付出巨大的代价。如果你使用严格顺序模式,Broker 集群中只要有一台机器不可用,则整个集群都不可用。你还用啥?现在主要场景也就在 binlog 同步。\n一般而言,我们的 MQ 都是能容忍短暂的乱序,所以推荐使用普通顺序模式。\n那么,我们现在使用了 普通顺序模式 ,我们从上面学习知道了在 Producer 生产消息的时候会进行轮询(取决你的负载均衡策略)来向同一主题的不同消息队列发送消息。那么如果此时我有几个消息分别是同一个订单的创建、支付、发货,在轮询的策略下这 三个消息会被发送到不同队列 ,因为在不同的队列此时就无法使用 RocketMQ 带来的队列有序特性来保证消息有序性了。\n那么,怎么解决呢?\n其实很简单,我们需要处理的仅仅是将同一语义下的消息放入同一个队列(比如这里是同一个订单),那我们就可以使用 Hash 取模法 来保证同一个订单在同一个队列中就行了。\nRocketMQ 实现了两种队列选择算法,也可以自己实现\n轮询算法\n轮询算法就是向消息指定的 topic 所在队列中依次发送消息,保证消息均匀分布 是 RocketMQ 默认队列选择算法 最小投递延迟算法\n每次消息投递的时候统计消息投递的延迟,选择队列时优先选择消息延时小的队列,导致消息分布不均匀,按照如下设置即可。\nproducer.setSendLatencyFaultEnable(true); 继承 MessageQueueSelector 实现\nSendResult sendResult = producer.send(msg, new MessageQueueSelector() { @Override public MessageQueue select(List\u0026lt;MessageQueue\u0026gt; mqs, Message msg, Object arg) { //从mqs中选择一个队列,可以根据msg特点选择 return null; } }, new Object()); 特殊情况处理 # 发送异常 # 选择队列后会与 Broker 建立连接,通过网络请求将消息发送到 Broker 上,如果 Broker 挂了或者网络波动发送消息超时此时 RocketMQ 会进行重试。\n重新选择其他 Broker 中的消息队列进行发送,默认重试两次,可以手动设置。\nproducer.setRetryTimesWhenSendFailed(5); 消息过大 # 消息超过 4k 时 RocketMQ 会将消息压缩后在发送到 Broker 上,减少网络资源的占用。\n重复消费 # emmm,就两个字—— 幂等 。在编程中一个幂等 操作的特点是其任意多次执行所产生的影响均与一次执行的影响相同。比如说,这个时候我们有一个订单的处理积分的系统,每当来一个消息的时候它就负责为创建这个订单的用户的积分加上相应的数值。可是有一次,消息队列发送给订单系统 FrancisQ 的订单信息,其要求是给 FrancisQ 的积分加上 500。但是积分系统在收到 FrancisQ 的订单信息处理完成之后返回给消息队列处理成功的信息的时候出现了网络波动(当然还有很多种情况,比如 Broker 意外重启等等),这条回应没有发送成功。\n那么,消息队列没收到积分系统的回应会不会尝试重发这个消息?问题就来了,我再发这个消息,万一它又给 FrancisQ 的账户加上 500 积分怎么办呢?\n所以我们需要给我们的消费者实现 幂等 ,也就是对同一个消息的处理结果,执行多少次都不变。\n那么如何给业务实现幂等呢?这个还是需要结合具体的业务的。你可以使用 写入 Redis 来保证,因为 Redis 的 key 和 value 就是天然支持幂等的。当然还有使用 数据库插入法 ,基于数据库的唯一键来保证重复数据不会被插入多条。\n不过最主要的还是需要 根据特定场景使用特定的解决方案 ,你要知道你的消息消费是否是完全不可重复消费还是可以忍受重复消费的,然后再选择强校验和弱校验的方式。毕竟在 CS 领域还是很少有技术银弹的说法。\n而在整个互联网领域,幂等不仅仅适用于消息队列的重复消费问题,这些实现幂等的方法,也同样适用于,在其他场景中来解决重复请求或者重复调用的问题 。比如将 HTTP 服务设计成幂等的,解决前端或者 APP 重复提交表单数据的问题 ,也可以将一个微服务设计成幂等的,解决 RPC 框架自动重试导致的 重复调用问题 。\nRocketMQ 如何实现分布式事务? # 如何解释分布式事务呢?事务大家都知道吧?要么都执行要么都不执行 。在同一个系统中我们可以轻松地实现事务,但是在分布式架构中,我们有很多服务是部署在不同系统之间的,而不同服务之间又需要进行调用。比如此时我下订单然后增加积分,如果保证不了分布式事务的话,就会出现 A 系统下了订单,但是 B 系统增加积分失败或者 A 系统没有下订单,B 系统却增加了积分。前者对用户不友好,后者对运营商不利,这是我们都不愿意见到的。\n那么,如何去解决这个问题呢?\n如今比较常见的分布式事务实现有 2PC、TCC 和事务消息(half 半消息机制)。每一种实现都有其特定的使用场景,但是也有各自的问题,都不是完美的解决方案。\n在 RocketMQ 中使用的是 事务消息加上事务反查机制 来解决分布式事务问题的。我画了张图,大家可以对照着图进行理解。\n在第一步发送的 half 消息 ,它的意思是 在事务提交之前,对于消费者来说,这个消息是不可见的 。\n那么,如何做到写入消息但是对用户不可见呢?RocketMQ 事务消息的做法是:如果消息是 half 消息,将备份原消息的主题与消息消费队列,然后 改变主题 为 RMQ_SYS_TRANS_HALF_TOPIC。由于消费组未订阅该主题,故消费端无法消费 half 类型的消息,然后 RocketMQ 会开启一个定时任务,从 Topic 为 RMQ_SYS_TRANS_HALF_TOPIC 中拉取消息进行消费,根据生产者组获取一个服务提供者发送回查事务状态请求,根据事务状态来决定是提交或回滚消息。\n你可以试想一下,如果没有从第 5 步开始的 事务反查机制 ,如果出现网路波动第 4 步没有发送成功,这样就会产生 MQ 不知道是不是需要给消费者消费的问题,他就像一个无头苍蝇一样。在 RocketMQ 中就是使用的上述的事务反查来解决的,而在 Kafka 中通常是直接抛出一个异常让用户来自行解决。\n你还需要注意的是,在 MQ Server 指向系统 B 的操作已经和系统 A 不相关了,也就是说在消息队列中的分布式事务是——本地事务和存储消息到消息队列才是同一个事务。这样也就产生了事务的最终一致性,因为整个过程是异步的,每个系统只要保证它自己那一部分的事务就行了。\n实践中会遇到的问题:事务消息需要一个事务监听器来监听本地事务是否成功,并且事务监听器接口只允许被实现一次。那就意味着需要把各种事务消息的本地事务都写在一个接口方法里面,必将会产生大量的耦合和类型判断。采用函数 Function 接口来包装整个业务过程,作为一个参数传递到监听器的接口方法中。再调用 Function 的 apply() 方法来执行业务,事务也会在 apply() 方法中执行。让监听器与业务之间实现解耦,使之具备了真实生产环境中的可行性。\n1.模拟一个添加用户浏览记录的需求\n@PostMapping(\u0026#34;/add\u0026#34;) @ApiOperation(\u0026#34;添加用户浏览记录\u0026#34;) public Result\u0026lt;TransactionSendResult\u0026gt; add(Long userId, Long forecastLogId) { // 函数式编程:浏览记录入库 Function\u0026lt;String, Boolean\u0026gt; function = transactionId -\u0026gt; viewHistoryHandler.addViewHistory(transactionId, userId, forecastLogId); Map\u0026lt;String, Long\u0026gt; hashMap = new HashMap\u0026lt;\u0026gt;(); hashMap.put(\u0026#34;userId\u0026#34;, userId); hashMap.put(\u0026#34;forecastLogId\u0026#34;, forecastLogId); String jsonString = JSON.toJSONString(hashMap); // 发送事务消息;将本地的事务操作,用函数Function接口接收,作为一个参数传入到方法中 TransactionSendResult transactionSendResult = mqProducerService.sendTransactionMessage(jsonString, MQDestination.TAG_ADD_VIEW_HISTORY, function); return Result.success(transactionSendResult); } 2.发送事务消息的方法\n/** * 发送事务消息 * * @param msgBody * @param tag * @param function * @return */ public TransactionSendResult sendTransactionMessage(String msgBody, String tag, Function\u0026lt;String, Boolean\u0026gt; function) { // 构建消息体 Message\u0026lt;String\u0026gt; message = buildMessage(msgBody); // 构建消息投递信息 String destination = buildDestination(tag); TransactionSendResult result = rocketMQTemplate.sendMessageInTransaction(destination, message, function); return result; } 3.生产者消息监听器,只允许一个类去实现该监听器\n@Slf4j @RocketMQTransactionListener public class TransactionMsgListener implements RocketMQLocalTransactionListener { @Autowired private RedisService redisService; /** * 执行本地事务(在发送消息成功时执行) * * @param message * @param o * @return commit or rollback or unknown */ @Override public RocketMQLocalTransactionState executeLocalTransaction(Message message, Object o) { // 1、获取事务ID String transactionId = null; try { transactionId = message.getHeaders().get(\u0026#34;rocketmq_TRANSACTION_ID\u0026#34;).toString(); // 2、判断传入函数对象是否为空,如果为空代表没有要执行的业务直接抛弃消息 if (o == null) { //返回ROLLBACK状态的消息会被丢弃 log.info(\u0026#34;事务消息回滚,没有需要处理的业务 transactionId={}\u0026#34;, transactionId); return RocketMQLocalTransactionState.ROLLBACK; } // 将Object o转换成Function对象 Function\u0026lt;String, Boolean\u0026gt; function = (Function\u0026lt;String, Boolean\u0026gt;) o; // 执行业务 事务也会在function.apply中执行 Boolean apply = function.apply(transactionId); if (apply) { log.info(\u0026#34;事务提交,消息正常处理 transactionId={}\u0026#34;, transactionId); //返回COMMIT状态的消息会立即被消费者消费到 return RocketMQLocalTransactionState.COMMIT; } } catch (Exception e) { log.info(\u0026#34;出现异常 返回ROLLBACK transactionId={}\u0026#34;, transactionId); return RocketMQLocalTransactionState.ROLLBACK; } return RocketMQLocalTransactionState.ROLLBACK; } /** * 事务回查机制,检查本地事务的状态 * * @param message * @return */ @Override public RocketMQLocalTransactionState checkLocalTransaction(Message message) { String transactionId = message.getHeaders().get(\u0026#34;rocketmq_TRANSACTION_ID\u0026#34;).toString(); // 查redis MqTransaction mqTransaction = redisService.getCacheObject(\u0026#34;mqTransaction:\u0026#34; + transactionId); if (Objects.isNull(mqTransaction)) { return RocketMQLocalTransactionState.ROLLBACK; } return RocketMQLocalTransactionState.COMMIT; } } 4.模拟的业务场景,这里的方法必须提取出来,放在别的类里面.如果调用方与被调用方在同一个类中,会发生事务失效的问题.\n@Component public class ViewHistoryHandler { @Autowired private IViewHistoryService viewHistoryService; @Autowired private IMqTransactionService mqTransactionService; @Autowired private RedisService redisService; /** * 浏览记录入库 * * @param transactionId * @param userId * @param forecastLogId * @return */ @Transactional public Boolean addViewHistory(String transactionId, Long userId, Long forecastLogId) { // 构建浏览记录 ViewHistory viewHistory = new ViewHistory(); viewHistory.setUserId(userId); viewHistory.setForecastLogId(forecastLogId); viewHistory.setCreateTime(LocalDateTime.now()); boolean save = viewHistoryService.save(viewHistory); // 本地事务信息 MqTransaction mqTransaction = new MqTransaction(); mqTransaction.setTransactionId(transactionId); mqTransaction.setCreateTime(new Date()); mqTransaction.setStatus(MqTransaction.StatusEnum.VALID.getStatus()); // 1.可以把事务信息存数据库 mqTransactionService.save(mqTransaction); // 2.也可以选择存redis,4个小时有效期,\u0026#39;4个小时\u0026#39;是RocketMQ内置的最大回查超时时长,过期未确认将强制回滚 redisService.setCacheObject(\u0026#34;mqTransaction:\u0026#34; + transactionId, mqTransaction, 4L, TimeUnit.HOURS); // 放开注释,模拟异常,事务回滚 // int i = 10 / 0; return save; } } 5.消费消息,以及幂等处理\n@Service @RocketMQMessageListener(topic = MQDestination.TOPIC, selectorExpression = MQDestination.TAG_ADD_VIEW_HISTORY, consumerGroup = MQDestination.TAG_ADD_VIEW_HISTORY) public class ConsumerAddViewHistory implements RocketMQListener\u0026lt;Message\u0026gt; { // 监听到消息就会执行此方法 @Override public void onMessage(Message message) { // 幂等校验 String transactionId = message.getTransactionId(); // 查redis MqTransaction mqTransaction = redisService.getCacheObject(\u0026#34;mqTransaction:\u0026#34; + transactionId); // 不存在事务记录 if (Objects.isNull(mqTransaction)) { return; } // 已消费 if (Objects.equals(mqTransaction.getStatus(), MqTransaction.StatusEnum.CONSUMED.getStatus())) { return; } String msg = new String(message.getBody()); Map\u0026lt;String, Long\u0026gt; map = JSON.parseObject(msg, new TypeReference\u0026lt;HashMap\u0026lt;String, Long\u0026gt;\u0026gt;() { }); Long userId = map.get(\u0026#34;userId\u0026#34;); Long forecastLogId = map.get(\u0026#34;forecastLogId\u0026#34;); // 下游的业务处理 // TODO 记录用户喜好,更新用户画像 // TODO 更新\u0026#39;证券预测文章\u0026#39;的浏览量,重新计算文章的曝光排序 // 更新状态为已消费 mqTransaction.setUpdateTime(new Date()); mqTransaction.setStatus(MqTransaction.StatusEnum.CONSUMED.getStatus()); redisService.setCacheObject(\u0026#34;mqTransaction:\u0026#34; + transactionId, mqTransaction, 4L, TimeUnit.HOURS); log.info(\u0026#34;监听到消息:msg={}\u0026#34;, JSON.toJSONString(map)); } } 如何解决消息堆积问题? # 在上面我们提到了消息队列一个很重要的功能——削峰 。那么如果这个峰值太大了导致消息堆积在队列中怎么办呢?\n其实这个问题可以将它广义化,因为产生消息堆积的根源其实就只有两个——生产者生产太快或者消费者消费太慢。\n我们可以从多个角度去思考解决这个问题,当流量到峰值的时候是因为生产者生产太快,我们可以使用一些 限流降级 的方法,当然你也可以增加多个消费者实例去水平扩展增加消费能力来匹配生产的激增。如果消费者消费过慢的话,我们可以先检查 是否是消费者出现了大量的消费错误 ,或者打印一下日志查看是否是哪一个线程卡死,出现了锁资源不释放等等的问题。\n当然,最快速解决消息堆积问题的方法还是增加消费者实例,不过 同时你还需要增加每个主题的队列数量 。\n别忘了在 RocketMQ 中,一个队列只会被一个消费者消费 ,如果你仅仅是增加消费者实例就会出现我一开始给你画架构图的那种情况。\n什么是回溯消费? # 回溯消费是指 Consumer 已经消费成功的消息,由于业务上需求需要重新消费,在RocketMQ 中, Broker 在向Consumer 投递成功消息后,消息仍然需要保留 。并且重新消费一般是按照时间维度,例如由于 Consumer 系统故障,恢复后需要重新消费 1 小时前的数据,那么 Broker 要提供一种机制,可以按照时间维度来回退消费进度。RocketMQ 支持按照时间回溯消费,时间维度精确到毫秒。\n这是官方文档的解释,我直接照搬过来就当科普了 😁😁😁。\nRocketMQ 如何保证高性能读写 # 传统 IO 方式 # 传统的 IO 读写其实就是 read + write 的操作,整个过程会分为如下几步\n用户调用 read()方法,开始读取数据,此时发生一次上下文从用户态到内核态的切换,也就是图示的切换 1 将磁盘数据通过 DMA 拷贝到内核缓存区 将内核缓存区的数据拷贝到用户缓冲区,这样用户,也就是我们写的代码就能拿到文件的数据 read()方法返回,此时就会从内核态切换到用户态,也就是图示的切换 2 当我们拿到数据之后,就可以调用 write()方法,此时上下文会从用户态切换到内核态,即图示切换 3 CPU 将用户缓冲区的数据拷贝到 Socket 缓冲区 将 Socket 缓冲区数据拷贝至网卡 write()方法返回,上下文重新从内核态切换到用户态,即图示切换 4 整个过程发生了 4 次上下文切换和 4 次数据的拷贝,这在高并发场景下肯定会严重影响读写性能故引入了零拷贝技术\n零拷贝技术 # mmap # mmap(memory map)是一种内存映射文件的方法,即将一个文件或者其它对象映射到进程的地址空间,实现文件磁盘地址和进程虚拟地址空间中一段虚拟地址的一一对映关系。\n简单地说就是内核缓冲区和应用缓冲区共享,从而减少了从读缓冲区到用户缓冲区的一次 CPU 拷贝。基于此上述架构图可变为:\n基于 mmap IO 读写其实就变成 mmap + write 的操作,也就是用 mmap 替代传统 IO 中的 read 操作。\n当用户发起 mmap 调用的时候会发生上下文切换 1,进行内存映射,然后数据被拷贝到内核缓冲区,mmap 返回,发生上下文切换 2;随后用户调用 write,发生上下文切换 3,将内核缓冲区的数据拷贝到 Socket 缓冲区,write 返回,发生上下文切换 4。\n发生 4 次上下文切换和 3 次 IO 拷贝操作,在 Java 中的实现:\nFileChannel fileChannel = new RandomAccessFile(\u0026#34;test.txt\u0026#34;, \u0026#34;rw\u0026#34;).getChannel(); MappedByteBuffer mappedByteBuffer = fileChannel.map(FileChannel.MapMode.READ_WRITE, 0, fileChannel.size()); sendfile # sendfile()跟 mmap()一样,也会减少一次 CPU 拷贝,但是它同时也会减少两次上下文切换。\n如图,用户在发起 sendfile()调用时会发生切换 1,之后数据通过 DMA 拷贝到内核缓冲区,之后再将内核缓冲区的数据 CPU 拷贝到 Socket 缓冲区,最后拷贝到网卡,sendfile()返回,发生切换 2。发生了 3 次拷贝和两次切换。Java 也提供了相应 api:\nFileChannel channel = FileChannel.open(Paths.get(\u0026#34;./test.txt\u0026#34;), StandardOpenOption.WRITE, StandardOpenOption.CREATE); //调用transferTo方法向目标数据传输 channel.transferTo(position, len, target); 在如上代码中,并没有文件的读写操作,而是直接将文件的数据传输到 target 目标缓冲区,也就是说,sendfile 是无法知道文件的具体的数据的;但是 mmap 不一样,他是可以修改内核缓冲区的数据的。假设如果需要对文件的内容进行修改之后再传输,只有 mmap 可以满足。\n通过上面的一些介绍,结论是基于零拷贝技术,可以减少 CPU 的拷贝次数和上下文切换次数,从而可以实现文件高效的读写操作。\nRocketMQ 内部主要是使用基于 mmap 实现的零拷贝(其实就是调用上述提到的 api),用来读写文件,这也是 RocketMQ 为什么快的一个很重要原因。\nRocketMQ 的刷盘机制 # 上面我讲了那么多的 RocketMQ 的架构和设计原理,你有没有好奇\n在 Topic 中的 队列是以什么样的形式存在的?\n队列中的消息又是如何进行存储持久化的呢?\n我在上文中提到的 同步刷盘 和 异步刷盘 又是什么呢?它们会给持久化带来什么样的影响呢?\n下面我将给你们一一解释。\n同步刷盘和异步刷盘 # 如上图所示,在同步刷盘中需要等待一个刷盘成功的 ACK ,同步刷盘对 MQ 消息可靠性来说是一种不错的保障,但是 性能上会有较大影响 ,一般地适用于金融等特定业务场景。\n而异步刷盘往往是开启一个线程去异步地执行刷盘操作。消息刷盘采用后台异步线程提交的方式进行, 降低了读写延迟 ,提高了 MQ 的性能和吞吐量,一般适用于如发验证码等对于消息保证要求不太高的业务场景。\n一般地,异步刷盘只有在 Broker 意外宕机的时候会丢失部分数据,你可以设置 Broker 的参数 FlushDiskType 来调整你的刷盘策略(ASYNC_FLUSH 或者 SYNC_FLUSH)。\n同步复制和异步复制 # 上面的同步刷盘和异步刷盘是在单个结点层面的,而同步复制和异步复制主要是指的 Borker 主从模式下,主节点返回消息给客户端的时候是否需要同步从节点。\n同步复制:也叫 “同步双写”,也就是说,只有消息同步双写到主从节点上时才返回写入成功 。 异步复制:消息写入主节点之后就直接返回写入成功 。 然而,很多事情是没有完美的方案的,就比如我们进行消息写入的节点越多就更能保证消息的可靠性,但是随之的性能也会下降,所以需要程序员根据特定业务场景去选择适应的主从复制方案。\n那么,异步复制会不会也像异步刷盘那样影响消息的可靠性呢?\n答案是不会的,因为两者就是不同的概念,对于消息可靠性是通过不同的刷盘策略保证的,而像异步同步复制策略仅仅是影响到了 可用性 。为什么呢?其主要原因是 RocketMQ 是不支持自动主从切换的,当主节点挂掉之后,生产者就不能再给这个主节点生产消息了。\n比如这个时候采用异步复制的方式,在主节点还未发送完需要同步的消息的时候主节点挂掉了,这个时候从节点就少了一部分消息。但是此时生产者无法再给主节点生产消息了,消费者可以自动切换到从节点进行消费(仅仅是消费),所以在主节点挂掉的时间只会产生主从结点短暂的消息不一致的情况,降低了可用性,而当主节点重启之后,从节点那部分未来得及复制的消息还会继续复制。\n在单主从架构中,如果一个主节点挂掉了,那么也就意味着整个系统不能再生产了。那么这个可用性的问题能否解决呢?一个主从不行那就多个主从的呗,别忘了在我们最初的架构图中,每个 Topic 是分布在不同 Broker 中的。\n但是这种复制方式同样也会带来一个问题,那就是无法保证 严格顺序 。在上文中我们提到了如何保证的消息顺序性是通过将一个语义的消息发送在同一个队列中,使用 Topic 下的队列来保证顺序性的。如果此时我们主节点 A 负责的是订单 A 的一系列语义消息,然后它挂了,这样其他节点是无法代替主节点 A 的,如果我们任意节点都可以存入任何消息,那就没有顺序性可言了。\n而在 RocketMQ 中采用了 Dledger 解决这个问题。他要求在写入消息的时候,要求至少消息复制到半数以上的节点之后,才给客⼾端返回写⼊成功,并且它是⽀持通过选举来动态切换主节点的。这里我就不展开说明了,读者可以自己去了解。\n也不是说 Dledger 是个完美的方案,至少在 Dledger 选举过程中是无法提供服务的,而且他必须要使用三个节点或以上,如果多数节点同时挂掉他也是无法保证可用性的,而且要求消息复制半数以上节点的效率和直接异步复制还是有一定的差距的。\n存储机制 # 还记得上面我们一开始的三个问题吗?到这里第三个问题已经解决了。\n但是,在 Topic 中的 队列是以什么样的形式存在的?队列中的消息又是如何进行存储持久化的呢? 还未解决,其实这里涉及到了 RocketMQ 是如何设计它的存储结构了。我首先想大家介绍 RocketMQ 消息存储架构中的三大角色——CommitLog、ConsumeQueue 和 IndexFile 。\nCommitLog:消息主体以及元数据的存储主体,存储 Producer 端写入的消息主体内容,消息内容不是定长的。单个文件大小默认 1G ,文件名长度为 20 位,左边补零,剩余为起始偏移量,比如 00000000000000000000 代表了第一个文件,起始偏移量为 0,文件大小为 1G=1073741824;当第一个文件写满了,第二个文件为 00000000001073741824,起始偏移量为 1073741824,以此类推。消息主要是顺序写入日志文件,当文件满了,写入下一个文件。 ConsumeQueue:消息消费队列,引入的目的主要是提高消息消费的性能(我们再前面也讲了),由于RocketMQ 是基于主题 Topic 的订阅模式,消息消费是针对主题进行的,如果要遍历 commitlog 文件中根据 Topic 检索消息是非常低效的。Consumer 即可根据 ConsumeQueue 来查找待消费的消息。其中,ConsumeQueue(逻辑消费队列)作为消费消息的索引,保存了指定 Topic 下的队列消息在 CommitLog 中的起始物理偏移量 offset ,消息大小 size 和消息 Tag 的 HashCode 值。consumequeue 文件可以看成是基于 topic 的 commitlog 索引文件,故 consumequeue 文件夹的组织方式如下:topic/queue/file 三层组织结构,具体存储路径为:$HOME/store/consumequeue/{topic}/{queueId}/{fileName}。同样 consumequeue 文件采取定长设计,每一个条目共 20 个字节,分别为 8 字节的 commitlog 物理偏移量、4 字节的消息长度、8 字节 tag hashcode,单个文件由 30W 个条目组成,可以像数组一样随机访问每一个条目,每个 ConsumeQueue文件大小约 5.72M; IndexFile:IndexFile(索引文件)提供了一种可以通过 key 或时间区间来查询消息的方法。这里只做科普不做详细介绍。 总结来说,整个消息存储的结构,最主要的就是 CommitLoq 和 ConsumeQueue 。而 ConsumeQueue 你可以大概理解为 Topic 中的队列。\nRocketMQ 采用的是 混合型的存储结构 ,即为 Broker 单个实例下所有的队列共用一个日志数据文件来存储消息。有意思的是在同样高并发的 Kafka 中会为每个 Topic 分配一个存储文件。这就有点类似于我们有一大堆书需要装上书架,RocketMQ 是不分书的种类直接成批的塞上去的,而 Kafka 是将书本放入指定的分类区域的。\n而 RocketMQ 为什么要这么做呢?原因是 提高数据的写入效率 ,不分 Topic 意味着我们有更大的几率获取 成批 的消息进行数据写入,但也会带来一个麻烦就是读取消息的时候需要遍历整个大文件,这是非常耗时的。\n所以,在 RocketMQ 中又使用了 ConsumeQueue 作为每个队列的索引文件来 提升读取消息的效率。我们可以直接根据队列的消息序号,计算出索引的全局位置(索引序号*索引固定⻓度 20),然后直接读取这条索引,再根据索引中记录的消息的全局位置,找到消息。\n讲到这里,你可能对 RocketMQ 的存储架构还有些模糊,没事,我们结合着图来理解一下。\nemmm,是不是有一点复杂 🤣,看英文图片和英文文档的时候就不要怂,硬着头皮往下看就行。\n如果上面没看懂的读者一定要认真看下面的流程分析!\n首先,在最上面的那一块就是我刚刚讲的你现在可以直接 把 ConsumerQueue 理解为 Queue。\n在图中最左边说明了红色方块代表被写入的消息,虚线方块代表等待被写入的。左边的生产者发送消息会指定 Topic、QueueId 和具体消息内容,而在 Broker 中管你是哪门子消息,他直接 全部顺序存储到了 CommitLog。而根据生产者指定的 Topic 和 QueueId 将这条消息本身在 CommitLog 的偏移(offset),消息本身大小,和 tag 的 hash 值存入对应的 ConsumeQueue 索引文件中。而在每个队列中都保存了 ConsumeOffset 即每个消费者组的消费位置(我在架构那里提到了,忘了的同学可以回去看一下),而消费者拉取消息进行消费的时候只需要根据 ConsumeOffset 获取下一个未被消费的消息就行了。\n上述就是我对于整个消息存储架构的大概理解(这里不涉及到一些细节讨论,比如稀疏索引等等问题),希望对你有帮助。\n因为有一个知识点因为写嗨了忘讲了,想想在哪里加也不好,所以我留给大家去思考 🤔🤔 一下吧。\n为什么 CommitLog 文件要设计成固定大小的长度呢?提醒:内存映射机制。\n总结 # 总算把这篇博客写完了。我讲的你们还记得吗 😅?\n这篇文章中我主要想大家介绍了\n消息队列出现的原因 消息队列的作用(异步,解耦,削峰) 消息队列带来的一系列问题(消息堆积、重复消费、顺序消费、分布式事务等等) 消息队列的两种消息模型——队列和主题模式 分析了 RocketMQ 的技术架构(NameServer、Broker、Producer、Consumer) 结合 RocketMQ 回答了消息队列副作用的解决方案 介绍了 RocketMQ 的存储机制和刷盘策略。 等等。。。\n"},{"id":567,"href":"/zh/docs/technology/Interview/distributed-system/rpc/rpc-intro/","title":"RPC基础知识总结","section":"Rpc","content":"这篇文章会简单介绍一下 RPC 相关的基础概念。\nRPC 是什么? # RPC(Remote Procedure Call) 即远程过程调用,通过名字我们就能看出 RPC 关注的是远程调用而非本地调用。\n为什么要 RPC ? 因为,两个不同的服务器上的服务提供的方法不在一个内存空间,所以,需要通过网络编程才能传递方法调用所需要的参数。并且,方法调用的结果也需要通过网络编程来接收。但是,如果我们自己手动网络编程来实现这个调用过程的话工作量是非常大的,因为,我们需要考虑底层传输方式(TCP 还是 UDP)、序列化方式等等方面。\nRPC 能帮助我们做什么呢? 简单来说,通过 RPC 可以帮助我们调用远程计算机上某个服务的方法,这个过程就像调用本地方法一样简单。并且!我们不需要了解底层网络编程的具体细节。\n举个例子:两个不同的服务 A、B 部署在两台不同的机器上,服务 A 如果想要调用服务 B 中的某个方法的话就可以通过 RPC 来做。\n一言蔽之:RPC 的出现就是为了让你调用远程方法像调用本地方法一样简单。\nRPC 的原理是什么? # 为了能够帮助小伙伴们理解 RPC 原理,我们可以将整个 RPC 的 核心功能看作是下面 👇 5 个部分实现的:\n客户端(服务消费端):调用远程方法的一端。 客户端 Stub(桩):这其实就是一代理类。代理类主要做的事情很简单,就是把你调用方法、类、方法参数等信息传递到服务端。 网络传输:网络传输就是你要把你调用的方法的信息比如说参数啊这些东西传输到服务端,然后服务端执行完之后再把返回结果通过网络传输给你传输回来。网络传输的实现方式有很多种比如最基本的 Socket 或者性能以及封装更加优秀的 Netty(推荐)。 服务端 Stub(桩):这个桩就不是代理类了。我觉得理解为桩实际不太好,大家注意一下就好。这里的服务端 Stub 实际指的就是接收到客户端执行方法的请求后,去执行对应的方法然后返回结果给客户端的类。 服务端(服务提供端):提供远程方法的一端。 具体原理图如下,后面我会串起来将整个 RPC 的过程给大家说一下。\n服务消费端(client)以本地调用的方式调用远程服务; 客户端 Stub(client stub) 接收到调用后负责将方法、参数等组装成能够进行网络传输的消息体(序列化):RpcRequest; 客户端 Stub(client stub) 找到远程服务的地址,并将消息发送到服务提供端; 服务端 Stub(桩)收到消息将消息反序列化为 Java 对象: RpcRequest; 服务端 Stub(桩)根据RpcRequest中的类、方法、方法参数等信息调用本地的方法; 服务端 Stub(桩)得到方法执行结果并将组装成能够进行网络传输的消息体:RpcResponse(序列化)发送至消费方; 客户端 Stub(client stub)接收到消息并将消息反序列化为 Java 对象:RpcResponse ,这样也就得到了最终结果。over! 相信小伙伴们看完上面的讲解之后,已经了解了 RPC 的原理。\n虽然篇幅不多,但是基本把 RPC 框架的核心原理讲清楚了!另外,对于上面的技术细节,我会在后面的章节介绍到。\n最后,对于 RPC 的原理,希望小伙伴不单单要理解,还要能够自己画出来并且能够给别人讲出来。因为,在面试中这个问题在面试官问到 RPC 相关内容的时候基本都会碰到。\n有哪些常见的 RPC 框架? # 我们这里说的 RPC 框架指的是可以让客户端直接调用服务端方法,就像调用本地方法一样简单的框架,比如我下面介绍的 Dubbo、Motan、gRPC 这些。 如果需要和 HTTP 协议打交道,解析和封装 HTTP 请求和响应。这类框架并不能算是“RPC 框架”,比如 Feign。\nDubbo # Apache Dubbo 是一款微服务框架,为大规模微服务实践提供高性能 RPC 通信、流量治理、可观测性等解决方案, 涵盖 Java、Golang 等多种语言 SDK 实现。\nDubbo 提供了从服务定义、服务发现、服务通信到流量管控等几乎所有的服务治理能力,支持 Triple 协议(基于 HTTP/2 之上定义的下一代 RPC 通信协议)、应用级服务发现、Dubbo Mesh (Dubbo3 赋予了很多云原生友好的新特性)等特性。\nDubbo 是由阿里开源,后来加入了 Apache 。正是由于 Dubbo 的出现,才使得越来越多的公司开始使用以及接受分布式架构。\nDubbo 算的是比较优秀的国产开源项目了,它的源码也是非常值得学习和阅读的!\nGitHub: https://github.com/apache/incubator-dubbo 官网: https://dubbo.apache.org/zh/ Motan # Motan 是新浪微博开源的一款 RPC 框架,据说在新浪微博正支撑着千亿次调用。不过笔者倒是很少看到有公司使用,而且网上的资料也比较少。\n很多人喜欢拿 Motan 和 Dubbo 作比较,毕竟都是国内大公司开源的。笔者在查阅了很多资料,以及简单查看了其源码之后发现:Motan 更像是一个精简版的 Dubbo,可能是借鉴了 Dubbo 的思想,Motan 的设计更加精简,功能更加纯粹。\n不过,我不推荐你在实际项目中使用 Motan。如果你要是公司实际使用的话,还是推荐 Dubbo ,其社区活跃度以及生态都要好很多。\n从 Motan 看 RPC 框架设计: http://kriszhang.com/motan-rpc-impl/ Motan 中文文档: https://github.com/weibocom/motan/wiki/zh_overview gRPC # gRPC 是 Google 开源的一个高性能、通用的开源 RPC 框架。其由主要面向移动应用开发并基于 HTTP/2 协议标准而设计(支持双向流、消息头压缩等功能,更加节省带宽),基于 ProtoBuf 序列化协议开发,并且支持众多开发语言。\n何谓 ProtoBuf? ProtoBuf( Protocol Buffer) 是一种更加灵活、高效的数据格式,可用于通讯协议、数据存储等领域,基本支持所有主流编程语言且与平台无关。不过,通过 ProtoBuf 定义接口和数据类型还挺繁琐的,这是一个小问题。\n不得不说,gRPC 的通信层的设计还是非常优秀的, Dubbo-go 3.0 的通信层改进主要借鉴了 gRPC。\n不过,gRPC 的设计导致其几乎没有服务治理能力。如果你想要解决这个问题的话,就需要依赖其他组件比如腾讯的 PolarisMesh(北极星)了。\nGitHub: https://github.com/grpc/grpc 官网: https://grpc.io/ Thrift # Apache Thrift 是 Facebook 开源的跨语言的 RPC 通信框架,目前已经捐献给 Apache 基金会管理,由于其跨语言特性和出色的性能,在很多互联网公司得到应用,有能力的公司甚至会基于 thrift 研发一套分布式服务框架,增加诸如服务注册、服务发现等功能。\nThrift支持多种不同的编程语言,包括C++、Java、Python、PHP、Ruby等(相比于 gRPC 支持的语言更多 )。\n官网: https://thrift.apache.org/ Thrift 简单介绍: https://www.jianshu.com/p/8f25d057a5a9 总结 # gRPC 和 Thrift 虽然支持跨语言的 RPC 调用,但是它们只提供了最基本的 RPC 框架功能,缺乏一系列配套的服务化组件和服务治理功能的支撑。\nDubbo 不论是从功能完善程度、生态系统还是社区活跃度来说都是最优秀的。而且,Dubbo 在国内有很多成功的案例比如当当网、滴滴等等,是一款经得起生产考验的成熟稳定的 RPC 框架。最重要的是你还能找到非常多的 Dubbo 参考资料,学习成本相对也较低。\n下图展示了 Dubbo 的生态系统。\nDubbo 也是 Spring Cloud Alibaba 里面的一个组件。\n但是,Dubbo 和 Motan 主要是给 Java 语言使用。虽然,Dubbo 和 Motan 目前也能兼容部分语言,但是不太推荐。如果需要跨多种语言调用的话,可以考虑使用 gRPC。\n综上,如果是 Java 后端技术栈,并且你在纠结选择哪一种 RPC 框架的话,我推荐你考虑一下 Dubbo。\n如何设计并实现一个 RPC 框架? # 《手写 RPC 框架》 是我的 知识星球的一个内部小册,我写了 12 篇文章来讲解如何从零开始基于 Netty+Kyro+Zookeeper 实现一个简易的 RPC 框架。\n麻雀虽小五脏俱全,项目代码注释详细,结构清晰,并且集成了 Check Style 规范代码结构,非常适合阅读和学习。\n内容概览:\n既然有了 HTTP 协议,为什么还要有 RPC ? # 关于这个问题的详细答案,请看这篇文章: 有了 HTTP 协议,为什么还要有 RPC ? 。\n"},{"id":568,"href":"/zh/docs/technology/Interview/cs-basics/operating-system/shell-intro/","title":"Shell 编程基础知识总结","section":"Operating System","content":"Shell 编程在我们的日常开发工作中非常实用,目前 Linux 系统下最流行的运维自动化语言就是 Shell 和 Python 了。\n这篇文章我会简单总结一下 Shell 编程基础知识,带你入门 Shell 编程!\n走进 Shell 编程的大门 # 为什么要学 Shell? # 学一个东西,我们大部分情况都是往实用性方向着想。从工作角度来讲,学习 Shell 是为了提高我们自己工作效率,提高产出,让我们在更少的时间完成更多的事情。\n很多人会说 Shell 编程属于运维方面的知识了,应该是运维人员来做,我们做后端开发的没必要学。我觉得这种说法大错特错,相比于专门做 Linux 运维的人员来说,我们对 Shell 编程掌握程度的要求要比他们低,但是 Shell 编程也是我们必须要掌握的!\n目前 Linux 系统下最流行的运维自动化语言就是 Shell 和 Python 了。\n两者之间,Shell 几乎是 IT 企业必须使用的运维自动化编程语言,特别是在运维工作中的服务监控、业务快速部署、服务启动停止、数据备份及处理、日志分析等环节里,shell 是不可缺的。Python 更适合处理复杂的业务逻辑,以及开发复杂的运维软件工具,实现通过 web 访问等。Shell 是一个命令解释器,解释执行用户所输入的命令和程序。一输入命令,就立即回应的交互的对话方式。\n另外,了解 shell 编程也是大部分互联网公司招聘后端开发人员的要求。下图是我截取的一些知名互联网公司对于 Shell 编程的要求。\n什么是 Shell? # 简单来说“Shell 编程就是对一堆 Linux 命令的逻辑化处理”。\nW3Cschool 上的一篇文章是这样介绍 Shell 的,如下图所示。 Shell 编程的 Hello World # 学习任何一门编程语言第一件事就是输出 HelloWorld 了!下面我会从新建文件到 shell 代码编写来说下 Shell 编程如何输出 Hello World。\n(1)新建一个文件 helloworld.sh :touch helloworld.sh,扩展名为 sh(sh 代表 Shell)(扩展名并不影响脚本执行,见名知意就好,如果你用 php 写 shell 脚本,扩展名就用 php 好了)\n(2) 使脚本具有执行权限:chmod +x helloworld.sh\n(3) 使用 vim 命令修改 helloworld.sh 文件:vim helloworld.sh(vim 文件\u0026mdash;\u0026mdash;\u0026gt;进入文件\u0026mdash;\u0026ndash;\u0026gt;命令模式\u0026mdash;\u0026mdash;\u0026gt;按 i 进入编辑模式\u0026mdash;\u0026ndash;\u0026gt;编辑文件 \u0026mdash;\u0026mdash;-\u0026gt;按 Esc 进入底行模式\u0026mdash;\u0026ndash;\u0026gt;输入:wq/q! (输入 wq 代表写入内容并退出,即保存;输入 q!代表强制退出不保存。))\nhelloworld.sh 内容如下:\n#!/bin/bash #第一个shell小程序,echo 是linux中的输出命令。 echo \u0026#34;helloworld!\u0026#34; shell 中 # 符号表示注释。shell 的第一行比较特殊,一般都会以#!开始来指定使用的 shell 类型。在 linux 中,除了 bash shell 以外,还有很多版本的 shell, 例如 zsh、dash 等等\u0026hellip;不过 bash shell 还是我们使用最多的。\n(4) 运行脚本:./helloworld.sh 。(注意,一定要写成 ./helloworld.sh ,而不是 helloworld.sh ,运行其它二进制的程序也一样,直接写 helloworld.sh ,linux 系统会去 PATH 里寻找有没有叫 helloworld.sh 的,而只有 /bin, /sbin, /usr/bin,/usr/sbin 等在 PATH 里,你的当前目录通常不在 PATH 里,所以写成 helloworld.sh 是会找不到命令的,要用./helloworld.sh 告诉系统说,就在当前目录找。)\nShell 变量 # Shell 编程中的变量介绍 # Shell 编程中一般分为三种变量:\n我们自己定义的变量(自定义变量): 仅在当前 Shell 实例中有效,其他 Shell 启动的程序不能访问局部变量。 Linux 已定义的环境变量(环境变量, 例如:PATH, ​HOME 等\u0026hellip;, 这类变量我们可以直接使用),使用 env 命令可以查看所有的环境变量,而 set 命令既可以查看环境变量也可以查看自定义变量。 Shell 变量:Shell 变量是由 Shell 程序设置的特殊变量。Shell 变量中有一部分是环境变量,有一部分是局部变量,这些变量保证了 Shell 的正常运行 常用的环境变量:\nPATH 决定了 shell 将到哪些目录中寻找命令或程序\nHOME 当前用户主目录\nHISTSIZE 历史记录数\nLOGNAME 当前用户的登录名\nHOSTNAME 指主机的名称\nSHELL 当前用户 Shell 类型\nLANGUAGE 语言相关的环境变量,多语言可以修改此环境变量\nMAIL 当前用户的邮件存放目录\nPS1 基本提示符,对于 root 用户是#,对于普通用户是$\n使用 Linux 已定义的环境变量:\n比如我们要看当前用户目录可以使用:echo $HOME命令;如果我们要看当前用户 Shell 类型 可以使用echo $SHELL命令。可以看出,使用方法非常简单。\n使用自己定义的变量:\n#!/bin/bash #自定义变量hello hello=\u0026#34;hello world\u0026#34; echo $hello echo \u0026#34;helloworld!\u0026#34; Shell 编程中的变量名的命名的注意事项:\n命名只能使用英文字母,数字和下划线,首个字符不能以数字开头,但是可以使用下划线(_)开头。 中间不能有空格,可以使用下划线(_)。 不能使用标点符号。 不能使用 bash 里的关键字(可用 help 命令查看保留关键字)。 Shell 字符串入门 # 字符串是 shell 编程中最常用最有用的数据类型(除了数字和字符串,也没啥其它类型好用了),字符串可以用单引号,也可以用双引号。这点和 Java 中有所不同。\n在单引号中所有的特殊符号,如$和反引号都没有特殊含义。在双引号中,除了\u0026quot;$\u0026quot;、\u0026quot;\\\u0026quot;、反引号和感叹号(需开启 history expansion),其他的字符没有特殊含义。\n单引号字符串:\n#!/bin/bash name=\u0026#39;SnailClimb\u0026#39; hello=\u0026#39;Hello, I am $name!\u0026#39; echo $hello 输出内容:\nHello, I am $name! 双引号字符串:\n#!/bin/bash name=\u0026#39;SnailClimb\u0026#39; hello=\u0026#34;Hello, I am $name!\u0026#34; echo $hello 输出内容:\nHello, I am SnailClimb! Shell 字符串常见操作 # 拼接字符串:\n#!/bin/bash name=\u0026#34;SnailClimb\u0026#34; # 使用双引号拼接 greeting=\u0026#34;hello, \u0026#34;$name\u0026#34; !\u0026#34; greeting_1=\u0026#34;hello, ${name} !\u0026#34; echo $greeting $greeting_1 # 使用单引号拼接 greeting_2=\u0026#39;hello, \u0026#39;$name\u0026#39; !\u0026#39; greeting_3=\u0026#39;hello, ${name} !\u0026#39; echo $greeting_2 $greeting_3 输出结果:\n获取字符串长度:\n#!/bin/bash #获取字符串长度 name=\u0026#34;SnailClimb\u0026#34; # 第一种方式 echo ${#name} #输出 10 # 第二种方式 expr length \u0026#34;$name\u0026#34;; 输出结果:\n10 10 使用 expr 命令时,表达式中的运算符左右必须包含空格,如果不包含空格,将会输出表达式本身:\nexpr 5+6 // 直接输出 5+6 expr 5 + 6 // 输出 11 对于某些运算符,还需要我们使用符号\\进行转义,否则就会提示语法错误。\nexpr 5 * 6 // 输出错误 expr 5 \\* 6 // 输出30 截取子字符串:\n简单的字符串截取:\n#从字符串第 1 个字符开始往后截取 10 个字符 str=\u0026#34;SnailClimb is a great man\u0026#34; echo ${str:0:10} #输出:SnailClimb 根据表达式截取:\n#!bin/bash #author:amau var=\u0026#34;https://www.runoob.com/linux/linux-shell-variable.html\u0026#34; # %表示删除从后匹配, 最短结果 # %%表示删除从后匹配, 最长匹配结果 # #表示删除从头匹配, 最短结果 # ##表示删除从头匹配, 最长匹配结果 # 注: *为通配符, 意为匹配任意数量的任意字符 s1=${var%%t*} #h s2=${var%t*} #https://www.runoob.com/linux/linux-shell-variable.h s3=${var%%.*} #https://www s4=${var#*/} #/www.runoob.com/linux/linux-shell-variable.html s5=${var##*/} #linux-shell-variable.html Shell 数组 # bash 支持一维数组(不支持多维数组),并且没有限定数组的大小。我下面给了大家一个关于数组操作的 Shell 代码示例,通过该示例大家可以知道如何创建数组、获取数组长度、获取/删除特定位置的数组元素、删除整个数组以及遍历数组。\n#!/bin/bash array=(1 2 3 4 5); # 获取数组长度 length=${#array[@]} # 或者 length2=${#array[*]} #输出数组长度 echo $length #输出:5 echo $length2 #输出:5 # 输出数组第三个元素 echo ${array[2]} #输出:3 unset array[1]# 删除下标为1的元素也就是删除第二个元素 for i in ${array[@]};do echo $i ;done # 遍历数组,输出:1 3 4 5 unset array; # 删除数组中的所有元素 for i in ${array[@]};do echo $i ;done # 遍历数组,数组元素为空,没有任何输出内容 Shell 基本运算符 # 说明:图片来自《菜鸟教程》\nShell 编程支持下面几种运算符\n算数运算符 关系运算符 布尔运算符 字符串运算符 文件测试运算符 算数运算符 # 我以加法运算符做一个简单的示例(注意:不是单引号,是反引号):\n#!/bin/bash a=3;b=3; val=`expr $a + $b` #输出:Total value : 6 echo \u0026#34;Total value : $val\u0026#34; 关系运算符 # 关系运算符只支持数字,不支持字符串,除非字符串的值是数字。\n通过一个简单的示例演示关系运算符的使用,下面 shell 程序的作用是当 score=100 的时候输出 A 否则输出 B。\n#!/bin/bash score=90; maxscore=100; if [ $score -eq $maxscore ] then echo \u0026#34;A\u0026#34; else echo \u0026#34;B\u0026#34; fi 输出结果:\nB 逻辑运算符 # 示例:\n#!/bin/bash a=$(( 1 \u0026amp;\u0026amp; 0)) # 输出:0;逻辑与运算只有相与的两边都是1,与的结果才是1;否则与的结果是0 echo $a; 布尔运算符 # 这里就不做演示了,应该挺简单的。\n字符串运算符 # 简单示例:\n#!/bin/bash a=\u0026#34;abc\u0026#34;; b=\u0026#34;efg\u0026#34;; if [ $a = $b ] then echo \u0026#34;a 等于 b\u0026#34; else echo \u0026#34;a 不等于 b\u0026#34; fi 输出:\na 不等于 b 文件相关运算符 # 使用方式很简单,比如我们定义好了一个文件路径file=\u0026quot;/usr/learnshell/test.sh\u0026quot; 如果我们想判断这个文件是否可读,可以这样if [ -r $file ] 如果想判断这个文件是否可写,可以这样-w $file,是不是很简单。\nShell 流程控制 # if 条件语句 # 简单的 if else-if else 的条件语句示例\n#!/bin/bash a=3; b=9; if [ $a -eq $b ] then echo \u0026#34;a 等于 b\u0026#34; elif [ $a -gt $b ] then echo \u0026#34;a 大于 b\u0026#34; else echo \u0026#34;a 小于 b\u0026#34; fi 输出结果:\na 小于 b 相信大家通过上面的示例就已经掌握了 shell 编程中的 if 条件语句。不过,还要提到的一点是,不同于我们常见的 Java 以及 PHP 中的 if 条件语句,shell if 条件语句中不能包含空语句也就是什么都不做的语句。\nfor 循环语句 # 通过下面三个简单的示例认识 for 循环语句最基本的使用,实际上 for 循环语句的功能比下面你看到的示例展现的要大得多。\n输出当前列表中的数据:\nfor loop in 1 2 3 4 5 do echo \u0026#34;The value is: $loop\u0026#34; done 产生 10 个随机数:\n#!/bin/bash for i in {0..9}; do echo $RANDOM; done 输出 1 到 5:\n通常情况下 shell 变量调用需要加 $,但是 for 的 (()) 中不需要,下面来看一个例子:\n#!/bin/bash length=5 for((i=1;i\u0026lt;=length;i++));do echo $i; done; while 语句 # 基本的 while 循环语句:\n#!/bin/bash int=1 while(( $int\u0026lt;=5 )) do echo $int let \u0026#34;int++\u0026#34; done while 循环可用于读取键盘信息:\necho \u0026#39;按下 \u0026lt;CTRL-D\u0026gt; 退出\u0026#39; echo -n \u0026#39;输入你最喜欢的电影: \u0026#39; while read FILM do echo \u0026#34;是的!$FILM 是一个好电影\u0026#34; done 输出内容:\n按下 \u0026lt;CTRL-D\u0026gt; 退出 输入你最喜欢的电影: 变形金刚 是的!变形金刚 是一个好电影 无限循环:\nwhile true do command done Shell 函数 # 不带参数没有返回值的函数 # #!/bin/bash hello(){ echo \u0026#34;这是我的第一个 shell 函数!\u0026#34; } echo \u0026#34;-----函数开始执行-----\u0026#34; hello echo \u0026#34;-----函数执行完毕-----\u0026#34; 输出结果:\n-----函数开始执行----- 这是我的第一个 shell 函数! -----函数执行完毕----- 有返回值的函数 # 输入两个数字之后相加并返回结果:\n#!/bin/bash funWithReturn(){ echo \u0026#34;输入第一个数字: \u0026#34; read aNum echo \u0026#34;输入第二个数字: \u0026#34; read anotherNum echo \u0026#34;两个数字分别为 $aNum 和 $anotherNum !\u0026#34; return $(($aNum+$anotherNum)) } funWithReturn echo \u0026#34;输入的两个数字之和为 $?\u0026#34; 输出结果:\n输入第一个数字: 1 输入第二个数字: 2 两个数字分别为 1 和 2 ! 输入的两个数字之和为 3 带参数的函数 # #!/bin/bash funWithParam(){ echo \u0026#34;第一个参数为 $1 !\u0026#34; echo \u0026#34;第二个参数为 $2 !\u0026#34; echo \u0026#34;第十个参数为 $10 !\u0026#34; echo \u0026#34;第十个参数为 ${10} !\u0026#34; echo \u0026#34;第十一个参数为 ${11} !\u0026#34; echo \u0026#34;参数总数有 $# 个!\u0026#34; echo \u0026#34;作为一个字符串输出所有参数 $* !\u0026#34; } funWithParam 1 2 3 4 5 6 7 8 9 34 73 输出结果:\n第一个参数为 1 ! 第二个参数为 2 ! 第十个参数为 10 ! 第十个参数为 34 ! 第十一个参数为 73 ! 参数总数有 11 个! 作为一个字符串输出所有参数 1 2 3 4 5 6 7 8 9 34 73 ! "},{"id":569,"href":"/zh/docs/technology/Interview/system-design/framework/spring/springboot-source-code/","title":"Spring Boot核心源码解读(付费)","section":"Framework","content":"Spring Boot 核心源码解读 为我的 知识星球(点击链接即可查看详细介绍以及加入方法)专属内容,已经整理到了 《Java 必读源码系列》中。\n"},{"id":570,"href":"/zh/docs/technology/Interview/distributed-system/spring-cloud-gateway-questions/","title":"Spring Cloud Gateway常见问题总结","section":"Distributed System","content":" 本文重构完善自 6000 字 | 16 图 | 深入理解 Spring Cloud Gateway 的原理 - 悟空聊架构这篇文章。\n什么是 Spring Cloud Gateway? # Spring Cloud Gateway 属于 Spring Cloud 生态系统中的网关,其诞生的目标是为了替代老牌网关 Zuul。准确点来说,应该是 Zuul 1.x。Spring Cloud Gateway 起步要比 Zuul 2.x 更早。\n为了提升网关的性能,Spring Cloud Gateway 基于 Spring WebFlux 。Spring WebFlux 使用 Reactor 库来实现响应式编程模型,底层基于 Netty 实现同步非阻塞的 I/O。\nSpring Cloud Gateway 不仅提供统一的路由方式,并且基于 Filter 链的方式提供了网关基本的功能,例如:安全,监控/指标,限流。\nSpring Cloud Gateway 和 Zuul 2.x 的差别不大,也是通过过滤器来处理请求。不过,目前更加推荐使用 Spring Cloud Gateway 而非 Zuul,Spring Cloud 生态对其支持更加友好。\nGitHub 地址: https://github.com/spring-cloud/spring-cloud-gateway 官网: https://spring.io/projects/spring-cloud-gateway Spring Cloud Gateway 的工作流程? # Spring Cloud Gateway 的工作流程如下图所示:\n这是 Spring 官方博客中的一张图,原文地址: https://spring.io/blog/2022/08/26/creating-a-custom-spring-cloud-gateway-filter。\n具体的流程分析:\n路由判断:客户端的请求到达网关后,先经过 Gateway Handler Mapping 处理,这里面会做断言(Predicate)判断,看下符合哪个路由规则,这个路由映射后端的某个服务。 请求过滤:然后请求到达 Gateway Web Handler,这里面有很多过滤器,组成过滤器链(Filter Chain),这些过滤器可以对请求进行拦截和修改,比如添加请求头、参数校验等等,有点像净化污水。然后将请求转发到实际的后端服务。这些过滤器逻辑上可以称作 Pre-Filters,Pre 可以理解为“在\u0026hellip;之前”。 服务处理:后端服务会对请求进行处理。 响应过滤:后端处理完结果后,返回给 Gateway 的过滤器再次做处理,逻辑上可以称作 Post-Filters,Post 可以理解为“在\u0026hellip;之后”。 响应返回:响应经过过滤处理后,返回给客户端。 总结:客户端的请求先通过匹配规则找到合适的路由,就能映射到具体的服务。然后请求经过过滤器处理后转发给具体的服务,服务处理后,再次经过过滤器处理,最后返回给客户端。\nSpring Cloud Gateway 的断言是什么? # 断言(Predicate)这个词听起来极其深奥,它是一种编程术语,我们生活中根本就不会用它。说白了它就是对一个表达式进行 if 判断,结果为真或假,如果为真则做这件事,否则做那件事。\n在 Gateway 中,如果客户端发送的请求满足了断言的条件,则映射到指定的路由器,就能转发到指定的服务上进行处理。\n断言配置的示例如下,配置了两个路由规则,有一个 predicates 断言配置,当请求 url 中包含 api/thirdparty,就匹配到了第一个路由 route_thirdparty。\n常见的路由断言规则如下图所示:\nSpring Cloud Gateway 的路由和断言是什么关系? # Route 路由和 Predicate 断言的对应关系如下::\n一对多:一个路由规则可以包含多个断言。如上图中路由 Route1 配置了三个断言 Predicate。 同时满足:如果一个路由规则中有多个断言,则需要同时满足才能匹配。如上图中路由 Route2 配置了两个断言,客户端发送的请求必须同时满足这两个断言,才能匹配路由 Route2。 第一个匹配成功:如果一个请求可以匹配多个路由,则映射第一个匹配成功的路由。如上图所示,客户端发送的请求满足 Route3 和 Route4 的断言,但是 Route3 的配置在配置文件中靠前,所以只会匹配 Route3。 Spring Cloud Gateway 如何实现动态路由? # 在使用 Spring Cloud Gateway 的时候,官方文档提供的方案总是基于配置文件或代码配置的方式。\nSpring Cloud Gateway 作为微服务的入口,需要尽量避免重启,而现在配置更改需要重启服务不能满足实际生产过程中的动态刷新、实时变更的业务需求,所以我们需要在 Spring Cloud Gateway 运行时动态配置网关。\n实现动态路由的方式有很多种,其中一种推荐的方式是基于 Nacos 注册中心来做。 Spring Cloud Gateway 可以从注册中心获取服务的元数据(例如服务名称、路径等),然后根据这些信息自动生成路由规则。这样,当你添加、移除或更新服务实例时,网关会自动感知并相应地调整路由规则,无需手动维护路由配置。\n其实这些复杂的步骤并不需要我们手动实现,通过 Nacos Server 和 Spring Cloud Alibaba Nacos Config 即可实现配置的动态变更,官方文档地址: https://github.com/alibaba/spring-cloud-alibaba/wiki/Nacos-config 。\nSpring Cloud Gateway 的过滤器有哪些? # 过滤器 Filter 按照请求和响应可以分为两种:\nPre 类型:在请求被转发到微服务之前,对请求进行拦截和修改,例如参数校验、权限校验、流量监控、日志输出以及协议转换等操作。 Post 类型:微服务处理完请求后,返回响应给网关,网关可以再次进行处理,例如修改响应内容或响应头、日志输出、流量监控等。 另外一种分类是按照过滤器 Filter 作用的范围进行划分:\nGatewayFilter:局部过滤器,应用在单个路由或一组路由上的过滤器。标红色表示比较常用的过滤器。 GlobalFilter:全局过滤器,应用在所有路由上的过滤器。 局部过滤器 # 常见的局部过滤器如下图所示:\n具体怎么用呢?这里有个示例,如果 URL 匹配成功,则去掉 URL 中的 “api”。\nfilters: #过滤器 - RewritePath=/api/(?\u0026lt;segment\u0026gt;.*),/$\\{segment} # 将跳转路径中包含的 “api” 替换成空 当然我们也可以自定义过滤器,本篇不做展开。\n全局过滤器 # 常见的全局过滤器如下图所示:\n全局过滤器最常见的用法是进行负载均衡。配置如下所示:\nspring: cloud: gateway: routes: - id: route_member # 第三方微服务路由规则 uri: lb://passjava-member # 负载均衡,将请求转发到注册中心注册的 passjava-member 服务 predicates: # 断言 - Path=/api/member/** # 如果前端请求路径包含 api/member,则应用这条路由规则 filters: #过滤器 - RewritePath=/api/(?\u0026lt;segment\u0026gt;.*),/$\\{segment} # 将跳转路径中包含的api替换成空 这里有个关键字 lb,用到了全局过滤器 LoadBalancerClientFilter,当匹配到这个路由后,会将请求转发到 passjava-member 服务,且支持负载均衡转发,也就是先将 passjava-member 解析成实际的微服务的 host 和 port,然后再转发给实际的微服务。\nSpring Cloud Gateway 支持限流吗? # Spring Cloud Gateway 自带了限流过滤器,对应的接口是 RateLimiter,RateLimiter 接口只有一个实现类 RedisRateLimiter (基于 Redis + Lua 实现的限流),提供的限流功能比较简易且不易使用。\n从 Sentinel 1.6.0 版本开始,Sentinel 引入了 Spring Cloud Gateway 的适配模块,可以提供两种资源维度的限流:route 维度和自定义 API 维度。也就是说,Spring Cloud Gateway 可以结合 Sentinel 实现更强大的网关流量控制。\nSpring Cloud Gateway 如何自定义全局异常处理? # 在 SpringBoot 项目中,我们捕获全局异常只需要在项目中配置 @RestControllerAdvice和 @ExceptionHandler就可以了。不过,这种方式在 Spring Cloud Gateway 下不适用。\nSpring Cloud Gateway 提供了多种全局处理的方式,比较常用的一种是实现ErrorWebExceptionHandler并重写其中的handle方法。\n@Order(-1) @Component @RequiredArgsConstructor public class GlobalErrorWebExceptionHandler implements ErrorWebExceptionHandler { private final ObjectMapper objectMapper; @Override public Mono\u0026lt;Void\u0026gt; handle(ServerWebExchange exchange, Throwable ex) { // ... } } 参考 # Spring Cloud Gateway 官方文档: https://cloud.spring.io/spring-cloud-gateway/reference/html/ Creating a custom Spring Cloud Gateway Filter: https://spring.io/blog/2022/08/26/creating-a-custom-spring-cloud-gateway-filter 全局异常处理: https://zhuanlan.zhihu.com/p/347028665 "},{"id":571,"href":"/zh/docs/technology/Interview/system-design/framework/spring/spring-transaction/","title":"Spring 事务详解","section":"Framework","content":"前段时间答应读者的 Spring 事务 分析总结终于来了。这部分内容比较重要,不论是对于工作还是面试,但是网上比较好的参考资料比较少。\n什么是事务? # 事务是逻辑上的一组操作,要么都执行,要么都不执行。\n相信大家应该都能背上面这句话了,下面我结合我们日常的真实开发来谈一谈。\n我们系统的每个业务方法可能包括了多个原子性的数据库操作,比如下面的 savePerson() 方法中就有两个原子性的数据库操作。这些原子性的数据库操作是有依赖的,它们要么都执行,要不就都不执行。\npublic void savePerson() { personDao.save(person); personDetailDao.save(personDetail); } 另外,需要格外注意的是:事务能否生效数据库引擎是否支持事务是关键。比如常用的 MySQL 数据库默认使用支持事务的 innodb引擎。但是,如果把数据库引擎变为 myisam,那么程序也就不再支持事务了!\n事务最经典也经常被拿出来说例子就是转账了。假如小明要给小红转账 1000 元,这个转账会涉及到两个关键操作就是:\n将小明的余额减少 1000 元。 将小红的余额增加 1000 元。 万一在这两个操作之间突然出现错误比如银行系统崩溃或者网络故障,导致小明余额减少而小红的余额没有增加,这样就不对了。事务就是保证这两个关键操作要么都成功,要么都要失败。\npublic class OrdersService { private AccountDao accountDao; public void setOrdersDao(AccountDao accountDao) { this.accountDao = accountDao; } @Transactional(propagation = Propagation.REQUIRED, isolation = Isolation.DEFAULT, readOnly = false, timeout = -1) public void accountMoney() { //小红账户多1000 accountDao.addMoney(1000,xiaohong); //模拟突然出现的异常,比如银行中可能为突然停电等等 //如果没有配置事务管理的话会造成,小红账户多了1000而小明账户没有少钱 int i = 10 / 0; //小王账户少1000 accountDao.reduceMoney(1000,xiaoming); } } 另外,数据库事务的 ACID 四大特性是事务的基础,下面简单来了解一下。\n事务的特性(ACID)了解么? # 原子性(Atomicity):事务是最小的执行单位,不允许分割。事务的原子性确保动作要么全部完成,要么完全不起作用; 一致性(Consistency):执行事务前后,数据保持一致,例如转账业务中,无论事务是否成功,转账者和收款人的总额应该是不变的; 隔离性(Isolation):并发访问数据库时,一个用户的事务不被其他事务所干扰,各并发事务之间数据库是独立的; 持久性(Durability):一个事务被提交之后。它对数据库中数据的改变是持久的,即使数据库发生故障也不应该对其有任何影响。 🌈 这里要额外补充一点:只有保证了事务的持久性、原子性、隔离性之后,一致性才能得到保障。也就是说 A、I、D 是手段,C 是目的! 想必大家也和我一样,被 ACID 这个概念被误导了很久! 我也是看周志明老师的公开课 《周志明的软件架构课》才搞清楚的(多看好书!!!)。\n另外,DDIA 也就是 《Designing Data-Intensive Application(数据密集型应用系统设计)》 的作者在他的这本书中如是说:\nAtomicity, isolation, and durability are properties of the database, whereas consis‐ tency (in the ACID sense) is a property of the application. The application may rely on the database’s atomicity and isolation properties in order to achieve consistency, but it’s not up to the database alone.\n翻译过来的意思是:原子性,隔离性和持久性是数据库的属性,而一致性(在 ACID 意义上)是应用程序的属性。应用可能依赖数据库的原子性和隔离属性来实现一致性,但这并不仅取决于数据库。因此,字母 C 不属于 ACID 。\n《Designing Data-Intensive Application(数据密集型应用系统设计)》这本书强推一波,值得读很多遍!豆瓣有接近 90% 的人看了这本书之后给了五星好评。另外,中文翻译版本已经在 GitHub 开源,地址: https://github.com/Vonng/ddia 。\n详谈 Spring 对事务的支持 # ⚠️ 再提醒一次:你的程序是否支持事务首先取决于数据库 ,比如使用 MySQL 的话,如果你选择的是 innodb 引擎,那么恭喜你,是可以支持事务的。但是,如果你的 MySQL 数据库使用的是 myisam 引擎的话,那不好意思,从根上就是不支持事务的。\n这里再多提一下一个非常重要的知识点:MySQL 怎么保证原子性的?\n我们知道如果想要保证事务的原子性,就需要在异常发生时,对已经执行的操作进行回滚,在 MySQL 中,恢复机制是通过 回滚日志(undo log) 实现的,所有事务进行的修改都会先记录到这个回滚日志中,然后再执行相关的操作。如果执行过程中遇到异常的话,我们直接利用 回滚日志 中的信息将数据回滚到修改之前的样子即可!并且,回滚日志会先于数据持久化到磁盘上。这样就保证了即使遇到数据库突然宕机等情况,当用户再次启动数据库的时候,数据库还能够通过查询回滚日志来回滚之前未完成的事务。\nSpring 支持两种方式的事务管理 # 编程式事务管理 # 通过 TransactionTemplate或者TransactionManager手动管理事务,实际应用中很少使用,但是对于你理解 Spring 事务管理原理有帮助。\n使用TransactionTemplate 进行编程式事务管理的示例代码如下:\n@Autowired private TransactionTemplate transactionTemplate; public void testTransaction() { transactionTemplate.execute(new TransactionCallbackWithoutResult() { @Override protected void doInTransactionWithoutResult(TransactionStatus transactionStatus) { try { // .... 业务代码 } catch (Exception e){ //回滚 transactionStatus.setRollbackOnly(); } } }); } 使用 TransactionManager 进行编程式事务管理的示例代码如下:\n@Autowired private PlatformTransactionManager transactionManager; public void testTransaction() { TransactionStatus status = transactionManager.getTransaction(new DefaultTransactionDefinition()); try { // .... 业务代码 transactionManager.commit(status); } catch (Exception e) { transactionManager.rollback(status); } } 声明式事务管理 # 推荐使用(代码侵入性最小),实际是通过 AOP 实现(基于@Transactional 的全注解方式使用最多)。\n使用 @Transactional注解进行事务管理的示例代码如下:\n@Transactional(propagation = Propagation.REQUIRED) public void aMethod { //do something B b = new B(); C c = new C(); b.bMethod(); c.cMethod(); } Spring 事务管理接口介绍 # Spring 框架中,事务管理相关最重要的 3 个接口如下:\nPlatformTransactionManager:(平台)事务管理器,Spring 事务策略的核心。 TransactionDefinition:事务定义信息(事务隔离级别、传播行为、超时、只读、回滚规则)。 TransactionStatus:事务运行状态。 我们可以把 PlatformTransactionManager 接口可以被看作是事务上层的管理者,而 TransactionDefinition 和 TransactionStatus 这两个接口可以看作是事务的描述。\nPlatformTransactionManager 会根据 TransactionDefinition 的定义比如事务超时时间、隔离级别、传播行为等来进行事务管理 ,而 TransactionStatus 接口则提供了一些方法来获取事务相应的状态比如是否新事务、是否可以回滚等等。\nPlatformTransactionManager:事务管理接口 # Spring 并不直接管理事务,而是提供了多种事务管理器 。Spring 事务管理器的接口是:PlatformTransactionManager 。\n通过这个接口,Spring 为各个平台如:JDBC(DataSourceTransactionManager)、Hibernate(HibernateTransactionManager)、JPA(JpaTransactionManager)等都提供了对应的事务管理器,但是具体的实现就是各个平台自己的事情了。\nPlatformTransactionManager 接口的具体实现如下:\nPlatformTransactionManager接口中定义了三个方法:\npackage org.springframework.transaction; import org.springframework.lang.Nullable; public interface PlatformTransactionManager { //获得事务 TransactionStatus getTransaction(@Nullable TransactionDefinition var1) throws TransactionException; //提交事务 void commit(TransactionStatus var1) throws TransactionException; //回滚事务 void rollback(TransactionStatus var1) throws TransactionException; } 这里多插一嘴。为什么要定义或者说抽象出来PlatformTransactionManager这个接口呢?\n主要是因为要将事务管理行为抽象出来,然后不同的平台去实现它,这样我们可以保证提供给外部的行为不变,方便我们扩展。\n我前段时间在我的 知识星球分享过:“为什么我们要用接口?” 。\n《设计模式》(GOF 那本)这本书在很多年前都提到过说要基于接口而非实现编程,你真的知道为什么要基于接口编程么?\n纵观开源框架和项目的源码,接口是它们不可或缺的重要组成部分。要理解为什么要用接口,首先要搞懂接口提供了什么功能。我们可以把接口理解为提供了一系列功能列表的约定,接口本身不提供功能,它只定义行为。但是谁要用,就要先实现我,遵守我的约定,然后再自己去实现我定义的要实现的功能。\n举个例子,我上个项目有发送短信的需求,为此,我们定了一个接口,接口只有两个方法:\n1.发送短信 2.处理发送结果的方法。\n刚开始我们用的是阿里云短信服务,然后我们实现这个接口完成了一个阿里云短信的服务。后来,我们突然又换到了别的短信服务平台,我们这个时候只需要再实现这个接口即可。这样保证了我们提供给外部的行为不变。几乎不需要改变什么代码,我们就轻松完成了需求的转变,提高了代码的灵活性和可扩展性。\n什么时候用接口?当你要实现的功能模块设计抽象行为的时候,比如发送短信的服务,图床的存储服务等等。\nTransactionDefinition:事务属性 # 事务管理器接口 PlatformTransactionManager 通过 getTransaction(TransactionDefinition definition) 方法来得到一个事务,这个方法里面的参数是 TransactionDefinition 类 ,这个类就定义了一些基本的事务属性。\n什么是事务属性呢? 事务属性可以理解成事务的一些基本配置,描述了事务策略如何应用到方法上。\n事务属性包含了 5 个方面:\n隔离级别 传播行为 回滚规则 是否只读 事务超时 TransactionDefinition 接口中定义了 5 个方法以及一些表示事务属性的常量比如隔离级别、传播行为等等。\npackage org.springframework.transaction; import org.springframework.lang.Nullable; public interface TransactionDefinition { int PROPAGATION_REQUIRED = 0; int PROPAGATION_SUPPORTS = 1; int PROPAGATION_MANDATORY = 2; int PROPAGATION_REQUIRES_NEW = 3; int PROPAGATION_NOT_SUPPORTED = 4; int PROPAGATION_NEVER = 5; int PROPAGATION_NESTED = 6; int ISOLATION_DEFAULT = -1; int ISOLATION_READ_UNCOMMITTED = 1; int ISOLATION_READ_COMMITTED = 2; int ISOLATION_REPEATABLE_READ = 4; int ISOLATION_SERIALIZABLE = 8; int TIMEOUT_DEFAULT = -1; // 返回事务的传播行为,默认值为 REQUIRED。 int getPropagationBehavior(); //返回事务的隔离级别,默认值是 DEFAULT int getIsolationLevel(); // 返回事务的超时时间,默认值为-1。如果超过该时间限制但事务还没有完成,则自动回滚事务。 int getTimeout(); // 返回是否为只读事务,默认值为 false boolean isReadOnly(); @Nullable String getName(); } TransactionStatus:事务状态 # TransactionStatus接口用来记录事务的状态 该接口定义了一组方法,用来获取或判断事务的相应状态信息。\nPlatformTransactionManager.getTransaction(…)方法返回一个 TransactionStatus 对象。\nTransactionStatus 接口内容如下:\npublic interface TransactionStatus{ boolean isNewTransaction(); // 是否是新的事务 boolean hasSavepoint(); // 是否有恢复点 void setRollbackOnly(); // 设置为只回滚 boolean isRollbackOnly(); // 是否为只回滚 boolean isCompleted; // 是否已完成 } 事务属性详解 # 实际业务开发中,大家一般都是使用 @Transactional 注解来开启事务,很多人并不清楚这个注解里面的参数是什么意思,有什么用。为了更好的在项目中使用事务管理,强烈推荐好好阅读一下下面的内容。\n事务传播行为 # 事务传播行为是为了解决业务层方法之间互相调用的事务问题。\n当事务方法被另一个事务方法调用时,必须指定事务应该如何传播。例如:方法可能继续在现有事务中运行,也可能开启一个新事务,并在自己的事务中运行。\n举个例子:我们在 A 类的aMethod()方法中调用了 B 类的 bMethod() 方法。这个时候就涉及到业务层方法之间互相调用的事务问题。如果我们的 bMethod()如果发生异常需要回滚,如何配置事务传播行为才能让 aMethod()也跟着回滚呢?这个时候就需要事务传播行为的知识了,如果你不知道的话一定要好好看一下。\n@Service Class A { @Autowired B b; @Transactional(propagation = Propagation.xxx) public void aMethod { //do something b.bMethod(); } } @Service Class B { @Transactional(propagation = Propagation.xxx) public void bMethod { //do something } } 在TransactionDefinition定义中包括了如下几个表示传播行为的常量:\npublic interface TransactionDefinition { int PROPAGATION_REQUIRED = 0; int PROPAGATION_SUPPORTS = 1; int PROPAGATION_MANDATORY = 2; int PROPAGATION_REQUIRES_NEW = 3; int PROPAGATION_NOT_SUPPORTED = 4; int PROPAGATION_NEVER = 5; int PROPAGATION_NESTED = 6; ...... } 不过,为了方便使用,Spring 相应地定义了一个枚举类:Propagation\npackage org.springframework.transaction.annotation; import org.springframework.transaction.TransactionDefinition; public enum Propagation { REQUIRED(TransactionDefinition.PROPAGATION_REQUIRED), SUPPORTS(TransactionDefinition.PROPAGATION_SUPPORTS), MANDATORY(TransactionDefinition.PROPAGATION_MANDATORY), REQUIRES_NEW(TransactionDefinition.PROPAGATION_REQUIRES_NEW), NOT_SUPPORTED(TransactionDefinition.PROPAGATION_NOT_SUPPORTED), NEVER(TransactionDefinition.PROPAGATION_NEVER), NESTED(TransactionDefinition.PROPAGATION_NESTED); private final int value; Propagation(int value) { this.value = value; } public int value() { return this.value; } } 正确的事务传播行为可能的值如下:\n1.TransactionDefinition.PROPAGATION_REQUIRED\n使用的最多的一个事务传播行为,我们平时经常使用的@Transactional注解默认使用就是这个事务传播行为。如果当前存在事务,则加入该事务;如果当前没有事务,则创建一个新的事务。也就是说:\n如果外部方法没有开启事务的话,Propagation.REQUIRED修饰的内部方法会新开启自己的事务,且开启的事务相互独立,互不干扰。 如果外部方法开启事务并且被Propagation.REQUIRED的话,所有Propagation.REQUIRED修饰的内部方法和外部方法均属于同一事务 ,只要一个方法回滚,整个事务均回滚。 举个例子:如果我们上面的aMethod()和bMethod()使用的都是PROPAGATION_REQUIRED传播行为的话,两者使用的就是同一个事务,只要其中一个方法回滚,整个事务均回滚。\n@Service Class A { @Autowired B b; @Transactional(propagation = Propagation.REQUIRED) public void aMethod { //do something b.bMethod(); } } @Service Class B { @Transactional(propagation = Propagation.REQUIRED) public void bMethod { //do something } } 2.TransactionDefinition.PROPAGATION_REQUIRES_NEW\n创建一个新的事务,如果当前存在事务,则把当前事务挂起。也就是说不管外部方法是否开启事务,Propagation.REQUIRES_NEW修饰的内部方法会新开启自己的事务,且开启的事务相互独立,互不干扰。\n举个例子:如果我们上面的bMethod()使用PROPAGATION_REQUIRES_NEW事务传播行为修饰,aMethod还是用PROPAGATION_REQUIRED修饰的话。如果aMethod()发生异常回滚,bMethod()不会跟着回滚,因为 bMethod()开启了独立的事务。但是,如果 bMethod()抛出了未被捕获的异常并且这个异常满足事务回滚规则的话,aMethod()同样也会回滚,因为这个异常被 aMethod()的事务管理机制检测到了。\n@Service Class A { @Autowired B b; @Transactional(propagation = Propagation.REQUIRED) public void aMethod { //do something b.bMethod(); } } @Service Class B { @Transactional(propagation = Propagation.REQUIRES_NEW) public void bMethod { //do something } } 3.TransactionDefinition.PROPAGATION_NESTED:\n如果当前存在事务,就在嵌套事务内执行;如果当前没有事务,就执行与TransactionDefinition.PROPAGATION_REQUIRED类似的操作。也就是说:\n在外部方法开启事务的情况下,在内部开启一个新的事务,作为嵌套事务存在。 如果外部方法无事务,则单独开启一个事务,与 PROPAGATION_REQUIRED 类似。 这里还是简单举个例子:如果 bMethod() 回滚的话,aMethod()不会回滚。如果 aMethod() 回滚的话,bMethod()会回滚。\n@Service Class A { @Autowired B b; @Transactional(propagation = Propagation.REQUIRED) public void aMethod { //do something b.bMethod(); } } @Service Class B { @Transactional(propagation = Propagation.NESTED) public void bMethod { //do something } } 4.TransactionDefinition.PROPAGATION_MANDATORY\n如果当前存在事务,则加入该事务;如果当前没有事务,则抛出异常。(mandatory:强制性)\n这个使用的很少,就不举例子来说了。\n若是错误的配置以下 3 种事务传播行为,事务将不会发生回滚,这里不对照案例讲解了,使用的很少。\nTransactionDefinition.PROPAGATION_SUPPORTS: 如果当前存在事务,则加入该事务;如果当前没有事务,则以非事务的方式继续运行。 TransactionDefinition.PROPAGATION_NOT_SUPPORTED: 以非事务方式运行,如果当前存在事务,则把当前事务挂起。 TransactionDefinition.PROPAGATION_NEVER: 以非事务方式运行,如果当前存在事务,则抛出异常。 更多关于事务传播行为的内容请看这篇文章: 《太难了~面试官让我结合案例讲讲自己对 Spring 事务传播行为的理解。》\n事务隔离级别 # TransactionDefinition 接口中定义了五个表示隔离级别的常量:\npublic interface TransactionDefinition { ...... int ISOLATION_DEFAULT = -1; int ISOLATION_READ_UNCOMMITTED = 1; int ISOLATION_READ_COMMITTED = 2; int ISOLATION_REPEATABLE_READ = 4; int ISOLATION_SERIALIZABLE = 8; ...... } 和事务传播行为那块一样,为了方便使用,Spring 也相应地定义了一个枚举类:Isolation\npublic enum Isolation { DEFAULT(TransactionDefinition.ISOLATION_DEFAULT), READ_UNCOMMITTED(TransactionDefinition.ISOLATION_READ_UNCOMMITTED), READ_COMMITTED(TransactionDefinition.ISOLATION_READ_COMMITTED), REPEATABLE_READ(TransactionDefinition.ISOLATION_REPEATABLE_READ), SERIALIZABLE(TransactionDefinition.ISOLATION_SERIALIZABLE); private final int value; Isolation(int value) { this.value = value; } public int value() { return this.value; } } 下面我依次对每一种事务隔离级别进行介绍:\nTransactionDefinition.ISOLATION_DEFAULT :使用后端数据库默认的隔离级别,MySQL 默认采用的 REPEATABLE_READ 隔离级别 Oracle 默认采用的 READ_COMMITTED 隔离级别. TransactionDefinition.ISOLATION_READ_UNCOMMITTED :最低的隔离级别,使用这个隔离级别很少,因为它允许读取尚未提交的数据变更,可能会导致脏读、幻读或不可重复读 TransactionDefinition.ISOLATION_READ_COMMITTED : 允许读取并发事务已经提交的数据,可以阻止脏读,但是幻读或不可重复读仍有可能发生 TransactionDefinition.ISOLATION_REPEATABLE_READ : 对同一字段的多次读取结果都是一致的,除非数据是被本身事务自己所修改,可以阻止脏读和不可重复读,但幻读仍有可能发生。 TransactionDefinition.ISOLATION_SERIALIZABLE : 最高的隔离级别,完全服从 ACID 的隔离级别。所有的事务依次逐个执行,这样事务之间就完全不可能产生干扰,也就是说,该级别可以防止脏读、不可重复读以及幻读。但是这将严重影响程序的性能。通常情况下也不会用到该级别。 相关阅读: MySQL 事务隔离级别详解。\n事务超时属性 # 所谓事务超时,就是指一个事务所允许执行的最长时间,如果超过该时间限制但事务还没有完成,则自动回滚事务。在 TransactionDefinition 中以 int 的值来表示超时时间,其单位是秒,默认值为-1,这表示事务的超时时间取决于底层事务系统或者没有超时时间。\n事务只读属性 # package org.springframework.transaction; import org.springframework.lang.Nullable; public interface TransactionDefinition { ...... // 返回是否为只读事务,默认值为 false boolean isReadOnly(); } 对于只有读取数据查询的事务,可以指定事务类型为 readonly,即只读事务。只读事务不涉及数据的修改,数据库会提供一些优化手段,适合用在有多条数据库查询操作的方法中。\n很多人就会疑问了,为什么我一个数据查询操作还要启用事务支持呢?\n拿 MySQL 的 innodb 举例子,根据官网 https://dev.mysql.com/doc/refman/5.7/en/innodb-autocommit-commit-rollback.html 描述:\nMySQL 默认对每一个新建立的连接都启用了autocommit模式。在该模式下,每一个发送到 MySQL 服务器的sql语句都会在一个单独的事务中进行处理,执行结束后会自动提交事务,并开启一个新的事务。\n但是,如果你给方法加上了Transactional注解的话,这个方法执行的所有sql会被放在一个事务中。如果声明了只读事务的话,数据库就会去优化它的执行,并不会带来其他的什么收益。\n如果不加Transactional,每条sql会开启一个单独的事务,中间被其它事务改了数据,都会实时读取到最新值。\n分享一下关于事务只读属性,其他人的解答:\n如果你一次执行单条查询语句,则没有必要启用事务支持,数据库默认支持 SQL 执行期间的读一致性; 如果你一次执行多条查询语句,例如统计查询,报表查询,在这种场景下,多条查询 SQL 必须保证整体的读一致性,否则,在前条 SQL 查询之后,后条 SQL 查询之前,数据被其他用户改变,则该次整体的统计查询将会出现读数据不一致的状态,此时,应该启用事务支持 事务回滚规则 # 这些规则定义了哪些异常会导致事务回滚而哪些不会。默认情况下,事务只有遇到运行期异常(RuntimeException 的子类)时才会回滚,Error 也会导致事务回滚,但是,在遇到检查型(Checked)异常时不会回滚。\n如果你想要回滚你定义的特定的异常类型的话,可以这样:\n@Transactional(rollbackFor= MyException.class) @Transactional 注解使用详解 # @Transactional 的作用范围 # 方法:推荐将注解使用于方法上,不过需要注意的是:该注解只能应用到 public 方法上,否则不生效。 类:如果这个注解使用在类上的话,表明该注解对该类中所有的 public 方法都生效。 接口:不推荐在接口上使用。 @Transactional 的常用配置参数 # @Transactional注解源码如下,里面包含了基本事务属性的配置:\n@Target({ElementType.TYPE, ElementType.METHOD}) @Retention(RetentionPolicy.RUNTIME) @Inherited @Documented public @interface Transactional { @AliasFor(\u0026#34;transactionManager\u0026#34;) String value() default \u0026#34;\u0026#34;; @AliasFor(\u0026#34;value\u0026#34;) String transactionManager() default \u0026#34;\u0026#34;; Propagation propagation() default Propagation.REQUIRED; Isolation isolation() default Isolation.DEFAULT; int timeout() default TransactionDefinition.TIMEOUT_DEFAULT; boolean readOnly() default false; Class\u0026lt;? extends Throwable\u0026gt;[] rollbackFor() default {}; String[] rollbackForClassName() default {}; Class\u0026lt;? extends Throwable\u0026gt;[] noRollbackFor() default {}; String[] noRollbackForClassName() default {}; } @Transactional 的常用配置参数总结(只列出了 5 个我平时比较常用的):\n属性名 说明 propagation 事务的传播行为,默认值为 REQUIRED,可选的值在上面介绍过 isolation 事务的隔离级别,默认值采用 DEFAULT,可选的值在上面介绍过 timeout 事务的超时时间,默认值为-1(不会超时)。如果超过该时间限制但事务还没有完成,则自动回滚事务。 readOnly 指定事务是否为只读事务,默认值为 false。 rollbackFor 用于指定能够触发事务回滚的异常类型,并且可以指定多个异常类型。 @Transactional 事务注解原理 # 面试中在问 AOP 的时候可能会被问到的一个问题。简单说下吧!\n我们知道,@Transactional 的工作机制是基于 AOP 实现的,AOP 又是使用动态代理实现的。如果目标对象实现了接口,默认情况下会采用 JDK 的动态代理,如果目标对象没有实现了接口,会使用 CGLIB 动态代理。\n🤐 多提一嘴:createAopProxy() 方法 决定了是使用 JDK 还是 Cglib 来做动态代理,源码如下:\npublic class DefaultAopProxyFactory implements AopProxyFactory, Serializable { @Override public AopProxy createAopProxy(AdvisedSupport config) throws AopConfigException { if (config.isOptimize() || config.isProxyTargetClass() || hasNoUserSuppliedProxyInterfaces(config)) { Class\u0026lt;?\u0026gt; targetClass = config.getTargetClass(); if (targetClass == null) { throw new AopConfigException(\u0026#34;TargetSource cannot determine target class: \u0026#34; + \u0026#34;Either an interface or a target is required for proxy creation.\u0026#34;); } if (targetClass.isInterface() || Proxy.isProxyClass(targetClass)) { return new JdkDynamicAopProxy(config); } return new ObjenesisCglibAopProxy(config); } else { return new JdkDynamicAopProxy(config); } } ....... } 如果一个类或者一个类中的 public 方法上被标注@Transactional 注解的话,Spring 容器就会在启动的时候为其创建一个代理类,在调用被@Transactional 注解的 public 方法的时候,实际调用的是,TransactionInterceptor 类中的 invoke()方法。这个方法的作用就是在目标方法之前开启事务,方法执行过程中如果遇到异常的时候回滚事务,方法调用完成之后提交事务。\nTransactionInterceptor 类中的 invoke()方法内部实际调用的是 TransactionAspectSupport 类的 invokeWithinTransaction()方法。由于新版本的 Spring 对这部分重写很大,而且用到了很多响应式编程的知识,这里就不列源码了。\nSpring AOP 自调用问题 # 当一个方法被标记了@Transactional 注解的时候,Spring 事务管理器只会在被其他类方法调用的时候生效,而不会在一个类中方法调用生效。\n这是因为 Spring AOP 工作原理决定的。因为 Spring AOP 使用动态代理来实现事务的管理,它会在运行的时候为带有 @Transactional 注解的方法生成代理对象,并在方法调用的前后应用事物逻辑。如果该方法被其他类调用我们的代理对象就会拦截方法调用并处理事务。但是在一个类中的其他方法内部调用的时候,我们代理对象就无法拦截到这个内部调用,因此事务也就失效了。\nMyService 类中的method1()调用method2()就会导致method2()的事务失效。\n@Service public class MyService { private void method1() { method2(); //...... } @Transactional public void method2() { //...... } } 解决办法就是避免同一类中自调用或者使用 AspectJ 取代 Spring AOP 代理。\nissue #2091补充了一个例子:\n@Service public class MyService { private void method1() { ((MyService)AopContext.currentProxy()).method2(); // 先获取该类的代理对象,然后通过代理对象调用method2。 //...... } @Transactional public void method2() { //...... } } 上面的代码确实可以在自调用的时候开启事务,但是这是因为使用了 AopContext.currentProxy() 方法来获取当前类的代理对象,然后通过代理对象调用 method2()。这样就相当于从外部调用了 method2(),所以事务注解才会生效。我们一般也不会在代码中这么写,所以可以忽略这个特殊的例子。\n@Transactional 的使用注意事项总结 # @Transactional 注解只有作用到 public 方法上事务才生效,不推荐在接口上使用; 避免同一个类中调用 @Transactional 注解的方法,这样会导致事务失效; 正确的设置 @Transactional 的 rollbackFor 和 propagation 属性,否则事务可能会回滚失败; 被 @Transactional 注解的方法所在的类必须被 Spring 管理,否则不生效; 底层使用的数据库必须支持事务机制,否则不生效; …… 参考 # [总结]Spring 事务管理中@Transactional 的参数: http://www.mobabel.net/spring 事务管理中 transactional 的参数/ Spring 官方文档: https://docs.spring.io/spring/docs/4.2.x/spring-framework-reference/html/transaction.html 《Spring5 高级编程》 透彻的掌握 Spring 中@transactional 的使用: https://www.ibm.com/developerworks/cn/java/j-master-spring-transactional-use/index.html Spring 事务的传播特性: https://github.com/love-somnus/Spring/wiki/Spring 事务的传播特性 Spring 事务传播行为详解: https://segmentfault.com/a/1190000013341344 全面分析 Spring 的编程式事务管理及声明式事务管理: https://www.ibm.com/developerworks/cn/education/opensource/os-cn-spring-trans/index.html "},{"id":572,"href":"/zh/docs/technology/Interview/system-design/framework/spring/spring-design-patterns-summary/","title":"Spring 中的设计模式详解","section":"Framework","content":"“JDK 中用到了哪些设计模式? Spring 中用到了哪些设计模式? ”这两个问题,在面试中比较常见。\n我在网上搜索了一下关于 Spring 中设计模式的讲解几乎都是千篇一律,而且大部分都年代久远。所以,花了几天时间自己总结了一下。\n由于我的个人能力有限,文中如有任何错误各位都可以指出。另外,文章篇幅有限,对于设计模式以及一些源码的解读我只是一笔带过,这篇文章的主要目的是回顾一下 Spring 中的设计模式。\n控制反转(IoC)和依赖注入(DI) # IoC(Inversion of Control,控制反转) 是 Spring 中一个非常非常重要的概念,它不是什么技术,而是一种解耦的设计思想。IoC 的主要目的是借助于“第三方”(Spring 中的 IoC 容器) 实现具有依赖关系的对象之间的解耦(IOC 容器管理对象,你只管使用即可),从而降低代码之间的耦合度。\nIoC 是一个原则,而不是一个模式,以下模式(但不限于)实现了 IoC 原则。\nSpring IoC 容器就像是一个工厂一样,当我们需要创建一个对象的时候,只需要配置好配置文件/注解即可,完全不用考虑对象是如何被创建出来的。 IoC 容器负责创建对象,将对象连接在一起,配置这些对象,并从创建中处理这些对象的整个生命周期,直到它们被完全销毁。\n在实际项目中一个 Service 类如果有几百甚至上千个类作为它的底层,我们需要实例化这个 Service,你可能要每次都要搞清这个 Service 所有底层类的构造函数,这可能会把人逼疯。如果利用 IOC 的话,你只需要配置好,然后在需要的地方引用就行了,这大大增加了项目的可维护性且降低了开发难度。\n关于 Spring IOC 的理解,推荐看这一下知乎的一个回答: https://www.zhihu.com/question/23277575/answer/169698662 ,非常不错。\n控制反转怎么理解呢? 举个例子:\u0026ldquo;对象 a 依赖了对象 b,当对象 a 需要使用 对象 b 的时候必须自己去创建。但是当系统引入了 IOC 容器后, 对象 a 和对象 b 之间就失去了直接的联系。这个时候,当对象 a 需要使用 对象 b 的时候, 我们可以指定 IOC 容器去创建一个对象 b 注入到对象 a 中\u0026rdquo;。 对象 a 获得依赖对象 b 的过程,由主动行为变为了被动行为,控制权反转,这就是控制反转名字的由来。\nDI(Dependency Inject,依赖注入)是实现控制反转的一种设计模式,依赖注入就是将实例变量传入到一个对象中去。\n工厂设计模式 # Spring 使用工厂模式可以通过 BeanFactory 或 ApplicationContext 创建 bean 对象。\n两者对比:\nBeanFactory:延迟注入(使用到某个 bean 的时候才会注入),相比于ApplicationContext 来说会占用更少的内存,程序启动速度更快。 ApplicationContext:容器启动的时候,不管你用没用到,一次性创建所有 bean 。BeanFactory 仅提供了最基本的依赖注入支持,ApplicationContext 扩展了 BeanFactory ,除了有BeanFactory的功能还有额外更多功能,所以一般开发人员使用ApplicationContext会更多。 ApplicationContext 的三个实现类:\nClassPathXmlApplication:把上下文文件当成类路径资源。 FileSystemXmlApplication:从文件系统中的 XML 文件载入上下文定义信息。 XmlWebApplicationContext:从 Web 系统中的 XML 文件载入上下文定义信息。 Example:\nimport org.springframework.context.ApplicationContext; import org.springframework.context.support.FileSystemXmlApplicationContext; public class App { public static void main(String[] args) { ApplicationContext context = new FileSystemXmlApplicationContext( \u0026#34;C:/work/IOC Containers/springframework.applicationcontext/src/main/resources/bean-factory-config.xml\u0026#34;); HelloApplicationContext obj = (HelloApplicationContext) context.getBean(\u0026#34;helloApplicationContext\u0026#34;); obj.getMsg(); } } 单例设计模式 # 在我们的系统中,有一些对象其实我们只需要一个,比如说:线程池、缓存、对话框、注册表、日志对象、充当打印机、显卡等设备驱动程序的对象。事实上,这一类对象只能有一个实例,如果制造出多个实例就可能会导致一些问题的产生,比如:程序的行为异常、资源使用过量、或者不一致性的结果。\n使用单例模式的好处 :\n对于频繁使用的对象,可以省略创建对象所花费的时间,这对于那些重量级对象而言,是非常可观的一笔系统开销; 由于 new 操作的次数减少,因而对系统内存的使用频率也会降低,这将减轻 GC 压力,缩短 GC 停顿时间。 Spring 中 bean 的默认作用域就是 singleton(单例)的。 除了 singleton 作用域,Spring 中 bean 还有下面几种作用域:\nprototype : 每次获取都会创建一个新的 bean 实例。也就是说,连续 getBean() 两次,得到的是不同的 Bean 实例。 request (仅 Web 应用可用): 每一次 HTTP 请求都会产生一个新的 bean(请求 bean),该 bean 仅在当前 HTTP request 内有效。 session (仅 Web 应用可用) : 每一次来自新 session 的 HTTP 请求都会产生一个新的 bean(会话 bean),该 bean 仅在当前 HTTP session 内有效。 application/global-session (仅 Web 应用可用):每个 Web 应用在启动时创建一个 Bean(应用 Bean),,该 bean 仅在当前应用启动时间内有效。 websocket (仅 Web 应用可用):每一次 WebSocket 会话产生一个新的 bean。 Spring 通过 ConcurrentHashMap 实现单例注册表的特殊方式实现单例模式。\nSpring 实现单例的核心代码如下:\n// 通过 ConcurrentHashMap(线程安全) 实现单例注册表 private final Map\u0026lt;String, Object\u0026gt; singletonObjects = new ConcurrentHashMap\u0026lt;String, Object\u0026gt;(64); public Object getSingleton(String beanName, ObjectFactory\u0026lt;?\u0026gt; singletonFactory) { Assert.notNull(beanName, \u0026#34;\u0026#39;beanName\u0026#39; must not be null\u0026#34;); synchronized (this.singletonObjects) { // 检查缓存中是否存在实例 Object singletonObject = this.singletonObjects.get(beanName); if (singletonObject == null) { //...省略了很多代码 try { singletonObject = singletonFactory.getObject(); } //...省略了很多代码 // 如果实例对象在不存在,我们注册到单例注册表中。 addSingleton(beanName, singletonObject); } return (singletonObject != NULL_OBJECT ? singletonObject : null); } } //将对象添加到单例注册表 protected void addSingleton(String beanName, Object singletonObject) { synchronized (this.singletonObjects) { this.singletonObjects.put(beanName, (singletonObject != null ? singletonObject : NULL_OBJECT)); } } } 单例 Bean 存在线程安全问题吗?\n大部分时候我们并没有在项目中使用多线程,所以很少有人会关注这个问题。单例 Bean 存在线程问题,主要是因为当多个线程操作同一个对象的时候是存在资源竞争的。\n常见的有两种解决办法:\n在 Bean 中尽量避免定义可变的成员变量。 在类中定义一个 ThreadLocal 成员变量,将需要的可变成员变量保存在 ThreadLocal 中(推荐的一种方式)。 不过,大部分 Bean 实际都是无状态(没有实例变量)的(比如 Dao、Service),这种情况下, Bean 是线程安全的。\n代理设计模式 # 代理模式在 AOP 中的应用 # AOP(Aspect-Oriented Programming,面向切面编程) 能够将那些与业务无关,却为业务模块所共同调用的逻辑或责任(例如事务处理、日志管理、权限控制等)封装起来,便于减少系统的重复代码,降低模块间的耦合度,并有利于未来的可拓展性和可维护性。\nSpring AOP 就是基于动态代理的,如果要代理的对象,实现了某个接口,那么 Spring AOP 会使用 JDK Proxy 去创建代理对象,而对于没有实现接口的对象,就无法使用 JDK Proxy 去进行代理了,这时候 Spring AOP 会使用 Cglib 生成一个被代理对象的子类来作为代理,如下图所示:\n当然,你也可以使用 AspectJ ,Spring AOP 已经集成了 AspectJ ,AspectJ 应该算的上是 Java 生态系统中最完整的 AOP 框架了。\n使用 AOP 之后我们可以把一些通用功能抽象出来,在需要用到的地方直接使用即可,这样大大简化了代码量。我们需要增加新功能时也方便,这样也提高了系统扩展性。日志功能、事务管理等等场景都用到了 AOP 。\nSpring AOP 和 AspectJ AOP 有什么区别? # Spring AOP 属于运行时增强,而 AspectJ 是编译时增强。 Spring AOP 基于代理(Proxying),而 AspectJ 基于字节码操作(Bytecode Manipulation)。\nSpring AOP 已经集成了 AspectJ ,AspectJ 应该算的上是 Java 生态系统中最完整的 AOP 框架了。AspectJ 相比于 Spring AOP 功能更加强大,但是 Spring AOP 相对来说更简单,\n如果我们的切面比较少,那么两者性能差异不大。但是,当切面太多的话,最好选择 AspectJ ,它比 Spring AOP 快很多。\n模板方法 # 模板方法模式是一种行为设计模式,它定义一个操作中的算法的骨架,而将一些步骤延迟到子类中。 模板方法使得子类可以不改变一个算法的结构即可重定义该算法的某些特定步骤的实现方式。\npublic abstract class Template { //这是我们的模板方法 public final void TemplateMethod(){ PrimitiveOperation1(); PrimitiveOperation2(); PrimitiveOperation3(); } protected void PrimitiveOperation1(){ //当前类实现 } //被子类实现的方法 protected abstract void PrimitiveOperation2(); protected abstract void PrimitiveOperation3(); } public class TemplateImpl extends Template { @Override public void PrimitiveOperation2() { //当前类实现 } @Override public void PrimitiveOperation3() { //当前类实现 } } Spring 中 JdbcTemplate、HibernateTemplate 等以 Template 结尾的对数据库操作的类,它们就使用到了模板模式。一般情况下,我们都是使用继承的方式来实现模板模式,但是 Spring 并没有使用这种方式,而是使用 Callback 模式与模板方法模式配合,既达到了代码复用的效果,同时增加了灵活性。\n观察者模式 # 观察者模式是一种对象行为型模式。它表示的是一种对象与对象之间具有依赖关系,当一个对象发生改变的时候,依赖这个对象的所有对象也会做出反应。Spring 事件驱动模型就是观察者模式很经典的一个应用。Spring 事件驱动模型非常有用,在很多场景都可以解耦我们的代码。比如我们每次添加商品的时候都需要重新更新商品索引,这个时候就可以利用观察者模式来解决这个问题。\nSpring 事件驱动模型中的三种角色 # 事件角色 # ApplicationEvent (org.springframework.context包下)充当事件的角色,这是一个抽象类,它继承了java.util.EventObject并实现了 java.io.Serializable接口。\nSpring 中默认存在以下事件,他们都是对 ApplicationContextEvent 的实现(继承自ApplicationContextEvent):\nContextStartedEvent:ApplicationContext 启动后触发的事件; ContextStoppedEvent:ApplicationContext 停止后触发的事件; ContextRefreshedEvent:ApplicationContext 初始化或刷新完成后触发的事件; ContextClosedEvent:ApplicationContext 关闭后触发的事件。 事件监听者角色 # ApplicationListener 充当了事件监听者角色,它是一个接口,里面只定义了一个 onApplicationEvent()方法来处理ApplicationEvent。ApplicationListener接口类源码如下,可以看出接口定义看出接口中的事件只要实现了 ApplicationEvent就可以了。所以,在 Spring 中我们只要实现 ApplicationListener 接口的 onApplicationEvent() 方法即可完成监听事件\npackage org.springframework.context; import java.util.EventListener; @FunctionalInterface public interface ApplicationListener\u0026lt;E extends ApplicationEvent\u0026gt; extends EventListener { void onApplicationEvent(E var1); } 事件发布者角色 # ApplicationEventPublisher 充当了事件的发布者,它也是一个接口。\n@FunctionalInterface public interface ApplicationEventPublisher { default void publishEvent(ApplicationEvent event) { this.publishEvent((Object)event); } void publishEvent(Object var1); } ApplicationEventPublisher 接口的publishEvent()这个方法在AbstractApplicationContext类中被实现,阅读这个方法的实现,你会发现实际上事件真正是通过ApplicationEventMulticaster来广播出去的。具体内容过多,就不在这里分析了,后面可能会单独写一篇文章提到。\nSpring 的事件流程总结 # 定义一个事件: 实现一个继承自 ApplicationEvent,并且写相应的构造函数; 定义一个事件监听者:实现 ApplicationListener 接口,重写 onApplicationEvent() 方法; 使用事件发布者发布消息: 可以通过 ApplicationEventPublisher 的 publishEvent() 方法发布消息。 Example:\n// 定义一个事件,继承自ApplicationEvent并且写相应的构造函数 public class DemoEvent extends ApplicationEvent{ private static final long serialVersionUID = 1L; private String message; public DemoEvent(Object source,String message){ super(source); this.message = message; } public String getMessage() { return message; } // 定义一个事件监听者,实现ApplicationListener接口,重写 onApplicationEvent() 方法; @Component public class DemoListener implements ApplicationListener\u0026lt;DemoEvent\u0026gt;{ //使用onApplicationEvent接收消息 @Override public void onApplicationEvent(DemoEvent event) { String msg = event.getMessage(); System.out.println(\u0026#34;接收到的信息是:\u0026#34;+msg); } } // 发布事件,可以通过ApplicationEventPublisher 的 publishEvent() 方法发布消息。 @Component public class DemoPublisher { @Autowired ApplicationContext applicationContext; public void publish(String message){ //发布事件 applicationContext.publishEvent(new DemoEvent(this, message)); } } 当调用 DemoPublisher 的 publish() 方法的时候,比如 demoPublisher.publish(\u0026quot;你好\u0026quot;) ,控制台就会打印出:接收到的信息是:你好 。\n适配器模式 # 适配器模式(Adapter Pattern) 将一个接口转换成客户希望的另一个接口,适配器模式使接口不兼容的那些类可以一起工作。\nSpring AOP 中的适配器模式 # 我们知道 Spring AOP 的实现是基于代理模式,但是 Spring AOP 的增强或通知(Advice)使用到了适配器模式,与之相关的接口是AdvisorAdapter 。\nAdvice 常用的类型有:BeforeAdvice(目标方法调用前,前置通知)、AfterAdvice(目标方法调用后,后置通知)、AfterReturningAdvice(目标方法执行结束后,return 之前)等等。每个类型 Advice(通知)都有对应的拦截器:MethodBeforeAdviceInterceptor、AfterReturningAdviceInterceptor、ThrowsAdviceInterceptor 等等。\nSpring 预定义的通知要通过对应的适配器,适配成 MethodInterceptor 接口(方法拦截器)类型的对象(如:MethodBeforeAdviceAdapter 通过调用 getInterceptor 方法,将 MethodBeforeAdvice 适配成 MethodBeforeAdviceInterceptor )。\nSpring MVC 中的适配器模式 # 在 Spring MVC 中,DispatcherServlet 根据请求信息调用 HandlerMapping,解析请求对应的 Handler。解析到对应的 Handler(也就是我们平常说的 Controller 控制器)后,开始由HandlerAdapter 适配器处理。HandlerAdapter 作为期望接口,具体的适配器实现类用于对目标类进行适配,Controller 作为需要适配的类。\n为什么要在 Spring MVC 中使用适配器模式?\nSpring MVC 中的 Controller 种类众多,不同类型的 Controller 通过不同的方法来对请求进行处理。如果不利用适配器模式的话,DispatcherServlet 直接获取对应类型的 Controller,需要的自行来判断,像下面这段代码一样:\nif(mappedHandler.getHandler() instanceof MultiActionController){ ((MultiActionController)mappedHandler.getHandler()).xxx }else if(mappedHandler.getHandler() instanceof XXX){ ... }else if(...){ ... } 假如我们再增加一个 Controller类型就要在上面代码中再加入一行 判断语句,这种形式就使得程序难以维护,也违反了设计模式中的开闭原则 – 对扩展开放,对修改关闭。\n装饰者模式 # 装饰者模式可以动态地给对象添加一些额外的属性或行为。相比于使用继承,装饰者模式更加灵活。简单点儿说就是当我们需要修改原有的功能,但我们又不愿直接去修改原有的代码时,设计一个 Decorator 套在原有代码外面。其实在 JDK 中就有很多地方用到了装饰者模式,比如 InputStream家族,InputStream 类下有 FileInputStream (读取文件)、BufferedInputStream (增加缓存,使读取文件速度大大提升)等子类都在不修改InputStream 代码的情况下扩展了它的功能。\nSpring 中配置 DataSource 的时候,DataSource 可能是不同的数据库和数据源。我们能否根据客户的需求在少修改原有类的代码下动态切换不同的数据源?这个时候就要用到装饰者模式(这一点我自己还没太理解具体原理)。Spring 中用到的包装器模式在类名上含有 Wrapper或者 Decorator。这些类基本上都是动态地给一个对象添加一些额外的职责\n总结 # Spring 框架中用到了哪些设计模式?\n工厂设计模式 : Spring 使用工厂模式通过 BeanFactory、ApplicationContext 创建 bean 对象。 代理设计模式 : Spring AOP 功能的实现。 单例设计模式 : Spring 中的 Bean 默认都是单例的。 模板方法模式 : Spring 中 jdbcTemplate、hibernateTemplate 等以 Template 结尾的对数据库操作的类,它们就使用到了模板模式。 包装器设计模式 : 我们的项目需要连接多个数据库,而且不同的客户在每次访问中根据需要会去访问不同的数据库。这种模式让我们可以根据客户的需求能够动态切换不同的数据源。 观察者模式: Spring 事件驱动模型就是观察者模式很经典的一个应用。 适配器模式 :Spring AOP 的增强或通知(Advice)使用到了适配器模式、spring MVC 中也是用到了适配器模式适配Controller。 …… 参考 # 《Spring 技术内幕》 https://blog.eduonix.com/java-programming-2/learn-design-patterns-used-spring-framework/ https://www.tutorialsteacher.com/ioc/inversion-of-control https://design-patterns.readthedocs.io/zh_CN/latest/behavioral_patterns/observer.html https://juejin.im/post/5a8eb261f265da4e9e307230 https://juejin.im/post/5ba28986f265da0abc2b6084 "},{"id":573,"href":"/zh/docs/technology/Interview/system-design/framework/spring/spring-common-annotations/","title":"Spring\u0026SpringBoot常用注解总结","section":"Framework","content":" 0.前言 # 可以毫不夸张地说,这篇文章介绍的 Spring/SpringBoot 常用注解基本已经涵盖你工作中遇到的大部分常用的场景。对于每一个注解我都说了具体用法,掌握搞懂,使用 SpringBoot 来开发项目基本没啥大问题了!\n为什么要写这篇文章?\n最近看到网上有一篇关于 SpringBoot 常用注解的文章被转载的比较多,我看了文章内容之后属实觉得质量有点低,并且有点会误导没有太多实际使用经验的人(这些人又占据了大多数)。所以,自己索性花了大概 两天时间简单总结一下了。\n因为我个人的能力和精力有限,如果有任何不对或者需要完善的地方,请帮忙指出!Guide 感激不尽!\n1. @SpringBootApplication # 这里先单独拎出@SpringBootApplication 注解说一下,虽然我们一般不会主动去使用它。\nGuide:这个注解是 Spring Boot 项目的基石,创建 SpringBoot 项目之后会默认在主类加上。\n@SpringBootApplication public class SpringSecurityJwtGuideApplication { public static void main(java.lang.String[] args) { SpringApplication.run(SpringSecurityJwtGuideApplication.class, args); } } 我们可以把 @SpringBootApplication看作是 @Configuration、@EnableAutoConfiguration、@ComponentScan 注解的集合。\npackage org.springframework.boot.autoconfigure; @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 { ...... } package org.springframework.boot; @Target(ElementType.TYPE) @Retention(RetentionPolicy.RUNTIME) @Documented @Configuration public @interface SpringBootConfiguration { } 根据 SpringBoot 官网,这三个注解的作用分别是:\n@EnableAutoConfiguration:启用 SpringBoot 的自动配置机制 @ComponentScan:扫描被@Component (@Repository,@Service,@Controller)注解的 bean,注解默认会扫描该类所在的包下所有的类。 @Configuration:允许在 Spring 上下文中注册额外的 bean 或导入其他配置类 2. Spring Bean 相关 # 2.1. @Autowired # 自动导入对象到类中,被注入进的类同样要被 Spring 容器管理比如:Service 类注入到 Controller 类中。\n@Service public class UserService { ...... } @RestController @RequestMapping(\u0026#34;/users\u0026#34;) public class UserController { @Autowired private UserService userService; ...... } 2.2. @Component,@Repository,@Service, @Controller # 我们一般使用 @Autowired 注解让 Spring 容器帮我们自动装配 bean。要想把类标识成可用于 @Autowired 注解自动装配的 bean 的类,可以采用以下注解实现:\n@Component:通用的注解,可标注任意类为 Spring 组件。如果一个 Bean 不知道属于哪个层,可以使用@Component 注解标注。 @Repository : 对应持久层即 Dao 层,主要用于数据库相关操作。 @Service : 对应服务层,主要涉及一些复杂的逻辑,需要用到 Dao 层。 @Controller : 对应 Spring MVC 控制层,主要用于接受用户请求并调用 Service 层返回数据给前端页面。 2.3. @RestController # @RestController注解是@Controller和@ResponseBody的合集,表示这是个控制器 bean,并且是将函数的返回值直接填入 HTTP 响应体中,是 REST 风格的控制器。\nGuide:现在都是前后端分离,说实话我已经很久没有用过@Controller。如果你的项目太老了的话,就当我没说。\n单独使用 @Controller 不加 @ResponseBody的话一般是用在要返回一个视图的情况,这种情况属于比较传统的 Spring MVC 的应用,对应于前后端不分离的情况。@Controller +@ResponseBody 返回 JSON 或 XML 形式数据\n关于@RestController 和 @Controller的对比,请看这篇文章: @RestController vs @Controller。\n2.4. @Scope # 声明 Spring Bean 的作用域,使用方法:\n@Bean @Scope(\u0026#34;singleton\u0026#34;) public Person personSingleton() { return new Person(); } 四种常见的 Spring Bean 的作用域:\nsingleton : 唯一 bean 实例,Spring 中的 bean 默认都是单例的。 prototype : 每次请求都会创建一个新的 bean 实例。 request : 每一次 HTTP 请求都会产生一个新的 bean,该 bean 仅在当前 HTTP request 内有效。 session : 每一个 HTTP Session 会产生一个新的 bean,该 bean 仅在当前 HTTP session 内有效。 2.5. @Configuration # 一般用来声明配置类,可以使用 @Component注解替代,不过使用@Configuration注解声明配置类更加语义化。\n@Configuration public class AppConfig { @Bean public TransferService transferService() { return new TransferServiceImpl(); } } 3. 处理常见的 HTTP 请求类型 # 5 种常见的请求类型:\nGET:请求从服务器获取特定资源。举个例子:GET /users(获取所有学生) POST:在服务器上创建一个新的资源。举个例子:POST /users(创建学生) PUT:更新服务器上的资源(客户端提供更新后的整个资源)。举个例子:PUT /users/12(更新编号为 12 的学生) DELETE:从服务器删除特定的资源。举个例子:DELETE /users/12(删除编号为 12 的学生) PATCH:更新服务器上的资源(客户端提供更改的属性,可以看做作是部分更新),使用的比较少,这里就不举例子了。 3.1. GET 请求 # @GetMapping(\u0026quot;users\u0026quot;) 等价于@RequestMapping(value=\u0026quot;/users\u0026quot;,method=RequestMethod.GET)\n@GetMapping(\u0026#34;/users\u0026#34;) public ResponseEntity\u0026lt;List\u0026lt;User\u0026gt;\u0026gt; getAllUsers() { return userRepository.findAll(); } 3.2. POST 请求 # @PostMapping(\u0026quot;users\u0026quot;) 等价于@RequestMapping(value=\u0026quot;/users\u0026quot;,method=RequestMethod.POST)\n关于@RequestBody注解的使用,在下面的“前后端传值”这块会讲到。\n@PostMapping(\u0026#34;/users\u0026#34;) public ResponseEntity\u0026lt;User\u0026gt; createUser(@Valid @RequestBody UserCreateRequest userCreateRequest) { return userRepository.save(userCreateRequest); } 3.3. PUT 请求 # @PutMapping(\u0026quot;/users/{userId}\u0026quot;) 等价于@RequestMapping(value=\u0026quot;/users/{userId}\u0026quot;,method=RequestMethod.PUT)\n@PutMapping(\u0026#34;/users/{userId}\u0026#34;) public ResponseEntity\u0026lt;User\u0026gt; updateUser(@PathVariable(value = \u0026#34;userId\u0026#34;) Long userId, @Valid @RequestBody UserUpdateRequest userUpdateRequest) { ...... } 3.4. DELETE 请求 # @DeleteMapping(\u0026quot;/users/{userId}\u0026quot;)等价于@RequestMapping(value=\u0026quot;/users/{userId}\u0026quot;,method=RequestMethod.DELETE)\n@DeleteMapping(\u0026#34;/users/{userId}\u0026#34;) public ResponseEntity deleteUser(@PathVariable(value = \u0026#34;userId\u0026#34;) Long userId){ ...... } 3.5. PATCH 请求 # 一般实际项目中,我们都是 PUT 不够用了之后才用 PATCH 请求去更新数据。\n@PatchMapping(\u0026#34;/profile\u0026#34;) public ResponseEntity updateStudent(@RequestBody StudentUpdateRequest studentUpdateRequest) { studentRepository.updateDetail(studentUpdateRequest); return ResponseEntity.ok().build(); } 4. 前后端传值 # 掌握前后端传值的正确姿势,是你开始 CRUD 的第一步!\n4.1. @PathVariable 和 @RequestParam # @PathVariable用于获取路径参数,@RequestParam用于获取查询参数。\n举个简单的例子:\n@GetMapping(\u0026#34;/klasses/{klassId}/teachers\u0026#34;) public List\u0026lt;Teacher\u0026gt; getKlassRelatedTeachers( @PathVariable(\u0026#34;klassId\u0026#34;) Long klassId, @RequestParam(value = \u0026#34;type\u0026#34;, required = false) String type ) { ... } 如果我们请求的 url 是:/klasses/123456/teachers?type=web\n那么我们服务获取到的数据就是:klassId=123456,type=web。\n4.2. @RequestBody # 用于读取 Request 请求(可能是 POST,PUT,DELETE,GET 请求)的 body 部分并且Content-Type 为 application/json 格式的数据,接收到数据之后会自动将数据绑定到 Java 对象上去。系统会使用HttpMessageConverter或者自定义的HttpMessageConverter将请求的 body 中的 json 字符串转换为 java 对象。\n我用一个简单的例子来给演示一下基本使用!\n我们有一个注册的接口:\n@PostMapping(\u0026#34;/sign-up\u0026#34;) public ResponseEntity signUp(@RequestBody @Valid UserRegisterRequest userRegisterRequest) { userService.save(userRegisterRequest); return ResponseEntity.ok().build(); } UserRegisterRequest对象:\n@Data @AllArgsConstructor @NoArgsConstructor public class UserRegisterRequest { @NotBlank private String userName; @NotBlank private String password; @NotBlank private String fullName; } 我们发送 post 请求到这个接口,并且 body 携带 JSON 数据:\n{ \u0026#34;userName\u0026#34;: \u0026#34;coder\u0026#34;, \u0026#34;fullName\u0026#34;: \u0026#34;shuangkou\u0026#34;, \u0026#34;password\u0026#34;: \u0026#34;123456\u0026#34; } 这样我们的后端就可以直接把 json 格式的数据映射到我们的 UserRegisterRequest 类上。\n👉 需要注意的是:一个请求方法只可以有一个@RequestBody,但是可以有多个@RequestParam和@PathVariable。 如果你的方法必须要用两个 @RequestBody来接受数据的话,大概率是你的数据库设计或者系统设计出问题了!\n5. 读取配置信息 # 很多时候我们需要将一些常用的配置信息比如阿里云 oss、发送短信、微信认证的相关配置信息等等放到配置文件中。\n下面我们来看一下 Spring 为我们提供了哪些方式帮助我们从配置文件中读取这些配置信息。\n我们的数据源application.yml内容如下:\nwuhan2020: 2020年初武汉爆发了新型冠状病毒,疫情严重,但是,我相信一切都会过去!武汉加油!中国加油! my-profile: name: Guide哥 email: koushuangbwcx@163.com library: location: 湖北武汉加油中国加油 books: - name: 天才基本法 description: 二十二岁的林朝夕在父亲确诊阿尔茨海默病这天,得知自己暗恋多年的校园男神裴之即将出国深造的消息——对方考取的学校,恰是父亲当年为她放弃的那所。 - name: 时间的秩序 description: 为什么我们记得过去,而非未来?时间“流逝”意味着什么?是我们存在于时间之内,还是时间存在于我们之中?卡洛·罗韦利用诗意的文字,邀请我们思考这一亘古难题——时间的本质。 - name: 了不起的我 description: 如何养成一个新习惯?如何让心智变得更成熟?如何拥有高质量的关系? 如何走出人生的艰难时刻? 5.1. @Value(常用) # 使用 @Value(\u0026quot;${property}\u0026quot;) 读取比较简单的配置信息:\n@Value(\u0026#34;${wuhan2020}\u0026#34;) String wuhan2020; 5.2. @ConfigurationProperties(常用) # 通过@ConfigurationProperties读取配置信息并与 bean 绑定。\n@Component @ConfigurationProperties(prefix = \u0026#34;library\u0026#34;) class LibraryProperties { @NotEmpty private String location; private List\u0026lt;Book\u0026gt; books; @Setter @Getter @ToString static class Book { String name; String description; } 省略getter/setter ...... } 你可以像使用普通的 Spring bean 一样,将其注入到类中使用。\n5.3. @PropertySource(不常用) # @PropertySource读取指定 properties 文件\n@Component @PropertySource(\u0026#34;classpath:website.properties\u0026#34;) class WebSite { @Value(\u0026#34;${url}\u0026#34;) private String url; 省略getter/setter ...... } 更多内容请查看我的这篇文章: 《10 分钟搞定 SpringBoot 如何优雅读取配置文件?》 。\n6. 参数校验 # 数据的校验的重要性就不用说了,即使在前端对数据进行校验的情况下,我们还是要对传入后端的数据再进行一遍校验,避免用户绕过浏览器直接通过一些 HTTP 工具直接向后端请求一些违法数据。\nBean Validation 是一套定义 JavaBean 参数校验标准的规范 (JSR 303, 349, 380),它提供了一系列注解,可以直接用于 JavaBean 的属性上,从而实现便捷的参数校验。\nJSR 303 (Bean Validation 1.0): 奠定了基础,引入了核心校验注解(如 @NotNull、@Size、@Min、@Max 等),定义了如何通过注解的方式对 JavaBean 的属性进行校验,并支持嵌套对象校验和自定义校验器。 JSR 349 (Bean Validation 1.1): 在 1.0 基础上进行扩展,例如引入了对方法参数和返回值校验的支持、增强了对分组校验(Group Validation)的处理。 JSR 380 (Bean Validation 2.0): 拥抱 Java 8 的新特性,并进行了一些改进,例如支持 java.time 包中的日期和时间类型、引入了一些新的校验注解(如 @NotEmpty, @NotBlank等)。 校验的时候我们实际用的是 Hibernate Validator 框架。Hibernate Validator 是 Hibernate 团队最初的数据校验框架,Hibernate Validator 4.x 是 Bean Validation 1.0(JSR 303)的参考实现,Hibernate Validator 5.x 是 Bean Validation 1.1(JSR 349)的参考实现,目前最新版的 Hibernate Validator 6.x 是 Bean Validation 2.0(JSR 380)的参考实现。\nSpringBoot 项目的 spring-boot-starter-web 依赖中已经有 hibernate-validator 包,不需要引用相关依赖。如下图所示(通过 idea 插件—Maven Helper 生成):\n注:更新版本的 spring-boot-starter-web 依赖中不再有 hibernate-validator 包(如 2.3.11.RELEASE),需要自己引入 spring-boot-starter-validation 依赖。\n非 SpringBoot 项目需要自行引入相关依赖包,这里不多做讲解,具体可以查看我的这篇文章:《 如何在 Spring/Spring Boot 中做参数校验?你需要了解的都在这里!》。\n👉 需要注意的是:所有的注解,推荐使用 JSR 注解,即javax.validation.constraints,而不是org.hibernate.validator.constraints\n6.1. 一些常用的字段验证的注解 # @NotEmpty 被注释的字符串的不能为 null 也不能为空 @NotBlank 被注释的字符串非 null,并且必须包含一个非空白字符 @Null 被注释的元素必须为 null @NotNull 被注释的元素必须不为 null @AssertTrue 被注释的元素必须为 true @AssertFalse 被注释的元素必须为 false @Pattern(regex=,flag=)被注释的元素必须符合指定的正则表达式 @Email 被注释的元素必须是 Email 格式。 @Min(value)被注释的元素必须是一个数字,其值必须大于等于指定的最小值 @Max(value)被注释的元素必须是一个数字,其值必须小于等于指定的最大值 @DecimalMin(value)被注释的元素必须是一个数字,其值必须大于等于指定的最小值 @DecimalMax(value) 被注释的元素必须是一个数字,其值必须小于等于指定的最大值 @Size(max=, min=)被注释的元素的大小必须在指定的范围内 @Digits(integer, fraction)被注释的元素必须是一个数字,其值必须在可接受的范围内 @Past被注释的元素必须是一个过去的日期 @Future 被注释的元素必须是一个将来的日期 …… 6.2. 验证请求体(RequestBody) # @Data @AllArgsConstructor @NoArgsConstructor public class Person { @NotNull(message = \u0026#34;classId 不能为空\u0026#34;) private String classId; @Size(max = 33) @NotNull(message = \u0026#34;name 不能为空\u0026#34;) private String name; @Pattern(regexp = \u0026#34;((^Man$|^Woman$|^UGM$))\u0026#34;, message = \u0026#34;sex 值不在可选范围\u0026#34;) @NotNull(message = \u0026#34;sex 不能为空\u0026#34;) private String sex; @Email(message = \u0026#34;email 格式不正确\u0026#34;) @NotNull(message = \u0026#34;email 不能为空\u0026#34;) private String email; } 我们在需要验证的参数上加上了@Valid注解,如果验证失败,它将抛出MethodArgumentNotValidException。\n@RestController @RequestMapping(\u0026#34;/api\u0026#34;) public class PersonController { @PostMapping(\u0026#34;/person\u0026#34;) public ResponseEntity\u0026lt;Person\u0026gt; getPerson(@RequestBody @Valid Person person) { return ResponseEntity.ok().body(person); } } 6.3. 验证请求参数(Path Variables 和 Request Parameters) # 一定一定不要忘记在类上加上 @Validated 注解了,这个参数可以告诉 Spring 去校验方法参数。\n@RestController @RequestMapping(\u0026#34;/api\u0026#34;) @Validated public class PersonController { @GetMapping(\u0026#34;/person/{id}\u0026#34;) public ResponseEntity\u0026lt;Integer\u0026gt; getPersonByID(@Valid @PathVariable(\u0026#34;id\u0026#34;) @Max(value = 5,message = \u0026#34;超过 id 的范围了\u0026#34;) Integer id) { return ResponseEntity.ok().body(id); } } 更多关于如何在 Spring 项目中进行参数校验的内容,请看《 如何在 Spring/Spring Boot 中做参数校验?你需要了解的都在这里!》这篇文章。\n7. 全局处理 Controller 层异常 # 介绍一下我们 Spring 项目必备的全局处理 Controller 层异常。\n相关注解:\n@ControllerAdvice :注解定义全局异常处理类 @ExceptionHandler :注解声明异常处理方法 如何使用呢?拿我们在第 5 节参数校验这块来举例子。如果方法参数不对的话就会抛出MethodArgumentNotValidException,我们来处理这个异常。\n@ControllerAdvice @ResponseBody public class GlobalExceptionHandler { /** * 请求参数异常处理 */ @ExceptionHandler(MethodArgumentNotValidException.class) public ResponseEntity\u0026lt;?\u0026gt; handleMethodArgumentNotValidException(MethodArgumentNotValidException ex, HttpServletRequest request) { ...... } } 更多关于 Spring Boot 异常处理的内容,请看我的这两篇文章:\nSpringBoot 处理异常的几种常见姿势 使用枚举简单封装一个优雅的 Spring Boot 全局异常处理! 8. JPA 相关 # 8.1. 创建表 # @Entity声明一个类对应一个数据库实体。\n@Table 设置表名\n@Entity @Table(name = \u0026#34;role\u0026#34;) public class Role { @Id @GeneratedValue(strategy = GenerationType.IDENTITY) private Long id; private String name; private String description; 省略getter/setter...... } 8.2. 创建主键 # @Id:声明一个字段为主键。\n使用@Id声明之后,我们还需要定义主键的生成策略。我们可以使用 @GeneratedValue 指定主键生成策略。\n1.通过 @GeneratedValue直接使用 JPA 内置提供的四种主键生成策略来指定主键生成策略。\n@Id @GeneratedValue(strategy = GenerationType.IDENTITY) private Long id; JPA 使用枚举定义了 4 种常见的主键生成策略,如下:\nGuide:枚举替代常量的一种用法\npublic enum GenerationType { /** * 使用一个特定的数据库表格来保存主键 * 持久化引擎通过关系数据库的一张特定的表格来生成主键, */ TABLE, /** *在某些数据库中,不支持主键自增长,比如Oracle、PostgreSQL其提供了一种叫做\u0026#34;序列(sequence)\u0026#34;的机制生成主键 */ SEQUENCE, /** * 主键自增长 */ IDENTITY, /** *把主键生成策略交给持久化引擎(persistence engine), *持久化引擎会根据数据库在以上三种主键生成 策略中选择其中一种 */ AUTO } @GeneratedValue注解默认使用的策略是GenerationType.AUTO\npublic @interface GeneratedValue { GenerationType strategy() default AUTO; String generator() default \u0026#34;\u0026#34;; } 一般使用 MySQL 数据库的话,使用GenerationType.IDENTITY策略比较普遍一点(分布式系统的话需要另外考虑使用分布式 ID)。\n2.通过 @GenericGenerator声明一个主键策略,然后 @GeneratedValue使用这个策略\n@Id @GeneratedValue(generator = \u0026#34;IdentityIdGenerator\u0026#34;) @GenericGenerator(name = \u0026#34;IdentityIdGenerator\u0026#34;, strategy = \u0026#34;identity\u0026#34;) private Long id; 等价于:\n@Id @GeneratedValue(strategy = GenerationType.IDENTITY) private Long id; jpa 提供的主键生成策略有如下几种:\npublic class DefaultIdentifierGeneratorFactory implements MutableIdentifierGeneratorFactory, Serializable, ServiceRegistryAwareService { @SuppressWarnings(\u0026#34;deprecation\u0026#34;) public DefaultIdentifierGeneratorFactory() { register( \u0026#34;uuid2\u0026#34;, UUIDGenerator.class ); register( \u0026#34;guid\u0026#34;, GUIDGenerator.class ); // can be done with UUIDGenerator + strategy register( \u0026#34;uuid\u0026#34;, UUIDHexGenerator.class ); // \u0026#34;deprecated\u0026#34; for new use register( \u0026#34;uuid.hex\u0026#34;, UUIDHexGenerator.class ); // uuid.hex is deprecated register( \u0026#34;assigned\u0026#34;, Assigned.class ); register( \u0026#34;identity\u0026#34;, IdentityGenerator.class ); register( \u0026#34;select\u0026#34;, SelectGenerator.class ); register( \u0026#34;sequence\u0026#34;, SequenceStyleGenerator.class ); register( \u0026#34;seqhilo\u0026#34;, SequenceHiLoGenerator.class ); register( \u0026#34;increment\u0026#34;, IncrementGenerator.class ); register( \u0026#34;foreign\u0026#34;, ForeignGenerator.class ); register( \u0026#34;sequence-identity\u0026#34;, SequenceIdentityGenerator.class ); register( \u0026#34;enhanced-sequence\u0026#34;, SequenceStyleGenerator.class ); register( \u0026#34;enhanced-table\u0026#34;, TableGenerator.class ); } public void register(String strategy, Class generatorClass) { LOG.debugf( \u0026#34;Registering IdentifierGenerator strategy [%s] -\u0026gt; [%s]\u0026#34;, strategy, generatorClass.getName() ); final Class previous = generatorStrategyToClassNameMap.put( strategy, generatorClass ); if ( previous != null ) { LOG.debugf( \u0026#34; - overriding [%s]\u0026#34;, previous.getName() ); } } } 8.3. 设置字段类型 # @Column 声明字段。\n示例:\n设置属性 userName 对应的数据库字段名为 user_name,长度为 32,非空\n@Column(name = \u0026#34;user_name\u0026#34;, nullable = false, length=32) private String userName; 设置字段类型并且加默认值,这个还是挺常用的。\n@Column(columnDefinition = \u0026#34;tinyint(1) default 1\u0026#34;) private Boolean enabled; 8.4. 指定不持久化特定字段 # @Transient:声明不需要与数据库映射的字段,在保存的时候不需要保存进数据库 。\n如果我们想让secrect 这个字段不被持久化,可以使用 @Transient关键字声明。\n@Entity(name=\u0026#34;USER\u0026#34;) public class User { ...... @Transient private String secrect; // not persistent because of @Transient } 除了 @Transient关键字声明, 还可以采用下面几种方法:\nstatic String secrect; // not persistent because of static final String secrect = \u0026#34;Satish\u0026#34;; // not persistent because of final transient String secrect; // not persistent because of transient 一般使用注解的方式比较多。\n8.5. 声明大字段 # @Lob:声明某个字段为大字段。\n@Lob private String content; 更详细的声明:\n@Lob //指定 Lob 类型数据的获取策略, FetchType.EAGER 表示非延迟加载,而 FetchType.LAZY 表示延迟加载 ; @Basic(fetch = FetchType.EAGER) //columnDefinition 属性指定数据表对应的 Lob 字段类型 @Column(name = \u0026#34;content\u0026#34;, columnDefinition = \u0026#34;LONGTEXT NOT NULL\u0026#34;) private String content; 8.6. 创建枚举类型的字段 # 可以使用枚举类型的字段,不过枚举字段要用@Enumerated注解修饰。\npublic enum Gender { MALE(\u0026#34;男性\u0026#34;), FEMALE(\u0026#34;女性\u0026#34;); private String value; Gender(String str){ value=str; } } @Entity @Table(name = \u0026#34;role\u0026#34;) public class Role { @Id @GeneratedValue(strategy = GenerationType.IDENTITY) private Long id; private String name; private String description; @Enumerated(EnumType.STRING) private Gender gender; 省略getter/setter...... } 数据库里面对应存储的是 MALE/FEMALE。\n8.7. 增加审计功能 # 只要继承了 AbstractAuditBase的类都会默认加上下面四个字段。\n@Data @AllArgsConstructor @NoArgsConstructor @MappedSuperclass @EntityListeners(value = AuditingEntityListener.class) public abstract class AbstractAuditBase { @CreatedDate @Column(updatable = false) @JsonIgnore private Instant createdAt; @LastModifiedDate @JsonIgnore private Instant updatedAt; @CreatedBy @Column(updatable = false) @JsonIgnore private String createdBy; @LastModifiedBy @JsonIgnore private String updatedBy; } 我们对应的审计功能对应地配置类可能是下面这样的(Spring Security 项目):\n@Configuration @EnableJpaAuditing public class AuditSecurityConfiguration { @Bean AuditorAware\u0026lt;String\u0026gt; auditorAware() { return () -\u0026gt; Optional.ofNullable(SecurityContextHolder.getContext()) .map(SecurityContext::getAuthentication) .filter(Authentication::isAuthenticated) .map(Authentication::getName); } } 简单介绍一下上面涉及到的一些注解:\n@CreatedDate: 表示该字段为创建时间字段,在这个实体被 insert 的时候,会设置值\n@CreatedBy :表示该字段为创建人,在这个实体被 insert 的时候,会设置值\n@LastModifiedDate、@LastModifiedBy同理。\n@EnableJpaAuditing:开启 JPA 审计功能。\n8.8. 删除/修改数据 # @Modifying 注解提示 JPA 该操作是修改操作,注意还要配合@Transactional注解使用。\n@Repository public interface UserRepository extends JpaRepository\u0026lt;User, Integer\u0026gt; { @Modifying @Transactional(rollbackFor = Exception.class) void deleteByUserName(String userName); } 8.9. 关联关系 # @OneToOne 声明一对一关系 @OneToMany 声明一对多关系 @ManyToOne 声明多对一关系 @ManyToMany 声明多对多关系 更多关于 Spring Boot JPA 的文章请看我的这篇文章: 一文搞懂如何在 Spring Boot 正确中使用 JPA 。\n9. 事务 @Transactional # 在要开启事务的方法上使用@Transactional注解即可!\n@Transactional(rollbackFor = Exception.class) public void save() { ...... } 我们知道 Exception 分为运行时异常 RuntimeException 和非运行时异常。在@Transactional注解中如果不配置rollbackFor属性,那么事务只会在遇到RuntimeException的时候才会回滚,加上rollbackFor=Exception.class,可以让事务在遇到非运行时异常时也回滚。\n@Transactional 注解一般可以作用在类或者方法上。\n作用于类:当把@Transactional 注解放在类上时,表示所有该类的 public 方法都配置相同的事务属性信息。 作用于方法:当类配置了@Transactional,方法也配置了@Transactional,方法的事务会覆盖类的事务配置信息。 更多关于 Spring 事务的内容请查看我的这篇文章: 可能是最漂亮的 Spring 事务管理详解 。\n10. json 数据处理 # 10.1. 过滤 json 数据 # @JsonIgnoreProperties 作用在类上用于过滤掉特定字段不返回或者不解析。\n//生成json时将userRoles属性过滤 @JsonIgnoreProperties({\u0026#34;userRoles\u0026#34;}) public class User { private String userName; private String fullName; private String password; private List\u0026lt;UserRole\u0026gt; userRoles = new ArrayList\u0026lt;\u0026gt;(); } @JsonIgnore一般用于类的属性上,作用和上面的@JsonIgnoreProperties 一样。\npublic class User { private String userName; private String fullName; private String password; //生成json时将userRoles属性过滤 @JsonIgnore private List\u0026lt;UserRole\u0026gt; userRoles = new ArrayList\u0026lt;\u0026gt;(); } 10.2. 格式化 json 数据 # @JsonFormat一般用来格式化 json 数据。\n比如:\n@JsonFormat(shape=JsonFormat.Shape.STRING, pattern=\u0026#34;yyyy-MM-dd\u0026#39;T\u0026#39;HH:mm:ss.SSS\u0026#39;Z\u0026#39;\u0026#34;, timezone=\u0026#34;GMT\u0026#34;) private Date date; 10.3. 扁平化对象 # @Getter @Setter @ToString public class Account { private Location location; private PersonInfo personInfo; @Getter @Setter @ToString public static class Location { private String provinceName; private String countyName; } @Getter @Setter @ToString public static class PersonInfo { private String userName; private String fullName; } } 未扁平化之前:\n{ \u0026#34;location\u0026#34;: { \u0026#34;provinceName\u0026#34;: \u0026#34;湖北\u0026#34;, \u0026#34;countyName\u0026#34;: \u0026#34;武汉\u0026#34; }, \u0026#34;personInfo\u0026#34;: { \u0026#34;userName\u0026#34;: \u0026#34;coder1234\u0026#34;, \u0026#34;fullName\u0026#34;: \u0026#34;shaungkou\u0026#34; } } 使用@JsonUnwrapped 扁平对象之后:\n@Getter @Setter @ToString public class Account { @JsonUnwrapped private Location location; @JsonUnwrapped private PersonInfo personInfo; ...... } { \u0026#34;provinceName\u0026#34;: \u0026#34;湖北\u0026#34;, \u0026#34;countyName\u0026#34;: \u0026#34;武汉\u0026#34;, \u0026#34;userName\u0026#34;: \u0026#34;coder1234\u0026#34;, \u0026#34;fullName\u0026#34;: \u0026#34;shaungkou\u0026#34; } 11. 测试相关 # @ActiveProfiles一般作用于测试类上, 用于声明生效的 Spring 配置文件。\n@SpringBootTest(webEnvironment = RANDOM_PORT) @ActiveProfiles(\u0026#34;test\u0026#34;) @Slf4j public abstract class TestBase { ...... } @Test声明一个方法为测试方法\n@Transactional被声明的测试方法的数据会回滚,避免污染测试数据。\n@WithMockUser Spring Security 提供的,用来模拟一个真实用户,并且可以赋予权限。\n@Test @Transactional @WithMockUser(username = \u0026#34;user-id-18163138155\u0026#34;, authorities = \u0026#34;ROLE_TEACHER\u0026#34;) void should_import_student_success() throws Exception { ...... } 暂时总结到这里吧!虽然花了挺长时间才写完,不过可能还是会一些常用的注解的被漏掉,所以,我将文章也同步到了 Github 上去,Github 地址: 欢迎完善!\n本文已经收录进我的 75K Star 的 Java 开源项目 JavaGuide: https://github.com/Snailclimb/JavaGuide。\n"},{"id":574,"href":"/zh/docs/technology/Interview/system-design/framework/spring/spring-boot-auto-assembly-principles/","title":"SpringBoot 自动装配原理详解","section":"Framework","content":" 作者: Miki-byte-1024 \u0026amp; Snailclimb\n每次问到 Spring Boot, 面试官非常喜欢问这个问题:“讲述一下 SpringBoot 自动装配原理?”。\n我觉得我们可以从以下几个方面回答:\n什么是 SpringBoot 自动装配? SpringBoot 是如何实现自动装配的?如何实现按需加载? 如何实现一个 Starter? 篇幅问题,这篇文章并没有深入,小伙伴们也可以直接使用 debug 的方式去看看 SpringBoot 自动装配部分的源代码。\n前言 # 使用过 Spring 的小伙伴,一定有被 XML 配置统治的恐惧。即使 Spring 后面引入了基于注解的配置,我们在开启某些 Spring 特性或者引入第三方依赖的时候,还是需要用 XML 或 Java 进行显式配置。\n举个例子。没有 Spring Boot 的时候,我们写一个 RestFul Web 服务,还首先需要进行如下配置。\n@Configuration public class RESTConfiguration { @Bean public View jsonTemplate() { MappingJackson2JsonView view = new MappingJackson2JsonView(); view.setPrettyPrint(true); return view; } @Bean public ViewResolver viewResolver() { return new BeanNameViewResolver(); } } spring-servlet.xml\n\u0026lt;beans xmlns=\u0026#34;http://www.springframework.org/schema/beans\u0026#34; xmlns:xsi=\u0026#34;http://www.w3.org/2001/XMLSchema-instance\u0026#34; xmlns:context=\u0026#34;http://www.springframework.org/schema/context\u0026#34; xmlns:mvc=\u0026#34;http://www.springframework.org/schema/mvc\u0026#34; xsi:schemaLocation=\u0026#34;http://www.springframework.org/schema/beans http://www.springframework.org/schema/beans/spring-beans.xsd http://www.springframework.org/schema/context/ http://www.springframework.org/schema/context/spring-context.xsd http://www.springframework.org/schema/mvc/ http://www.springframework.org/schema/mvc/spring-mvc.xsd\u0026#34;\u0026gt; \u0026lt;context:component-scan base-package=\u0026#34;com.howtodoinjava.demo\u0026#34; /\u0026gt; \u0026lt;mvc:annotation-driven /\u0026gt; \u0026lt;!-- JSON Support --\u0026gt; \u0026lt;bean name=\u0026#34;viewResolver\u0026#34; class=\u0026#34;org.springframework.web.servlet.view.BeanNameViewResolver\u0026#34;/\u0026gt; \u0026lt;bean name=\u0026#34;jsonTemplate\u0026#34; class=\u0026#34;org.springframework.web.servlet.view.json.MappingJackson2JsonView\u0026#34;/\u0026gt; \u0026lt;/beans\u0026gt; 但是,Spring Boot 项目,我们只需要添加相关依赖,无需配置,通过启动下面的 main 方法即可。\n@SpringBootApplication public class DemoApplication { public static void main(String[] args) { SpringApplication.run(DemoApplication.class, args); } } 并且,我们通过 Spring Boot 的全局配置文件 application.properties或application.yml即可对项目进行设置比如更换端口号,配置 JPA 属性等等。\n为什么 Spring Boot 使用起来这么酸爽呢? 这得益于其自动装配。自动装配可以说是 Spring Boot 的核心,那究竟什么是自动装配呢?\n什么是 SpringBoot 自动装配? # 我们现在提到自动装配的时候,一般会和 Spring Boot 联系在一起。但是,实际上 Spring Framework 早就实现了这个功能。Spring Boot 只是在其基础上,通过 SPI 的方式,做了进一步优化。\nSpringBoot 定义了一套接口规范,这套规范规定:SpringBoot 在启动时会扫描外部引用 jar 包中的META-INF/spring.factories文件,将文件中配置的类型信息加载到 Spring 容器(此处涉及到 JVM 类加载机制与 Spring 的容器知识),并执行类中定义的各种操作。对于外部 jar 来说,只需要按照 SpringBoot 定义的标准,就能将自己的功能装置进 SpringBoot。 自 Spring Boot 3.0 开始,自动配置包的路径从META-INF/spring.factories 修改为 META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports。\n没有 Spring Boot 的情况下,如果我们需要引入第三方依赖,需要手动配置,非常麻烦。但是,Spring Boot 中,我们直接引入一个 starter 即可。比如你想要在项目中使用 redis 的话,直接在项目中引入对应的 starter 即可。\n\u0026lt;dependency\u0026gt; \u0026lt;groupId\u0026gt;org.springframework.boot\u0026lt;/groupId\u0026gt; \u0026lt;artifactId\u0026gt;spring-boot-starter-data-redis\u0026lt;/artifactId\u0026gt; \u0026lt;/dependency\u0026gt; 引入 starter 之后,我们通过少量注解和一些简单的配置就能使用第三方组件提供的功能了。\n在我看来,自动装配可以简单理解为:通过注解或者一些简单的配置就能在 Spring Boot 的帮助下实现某块功能。\nSpringBoot 是如何实现自动装配的? # 我们先看一下 SpringBoot 的核心注解 SpringBootApplication 。\n@Target({ElementType.TYPE}) @Retention(RetentionPolicy.RUNTIME) @Documented @Inherited \u0026lt;1.\u0026gt;@SpringBootConfiguration \u0026lt;2.\u0026gt;@ComponentScan \u0026lt;3.\u0026gt;@EnableAutoConfiguration public @interface SpringBootApplication { } @Target({ElementType.TYPE}) @Retention(RetentionPolicy.RUNTIME) @Documented @Configuration //实际上它也是一个配置类 public @interface SpringBootConfiguration { } 大概可以把 @SpringBootApplication看作是 @Configuration、@EnableAutoConfiguration、@ComponentScan 注解的集合。根据 SpringBoot 官网,这三个注解的作用分别是:\n@EnableAutoConfiguration:启用 SpringBoot 的自动配置机制 @Configuration:允许在上下文中注册额外的 bean 或导入其他配置类 @ComponentScan:扫描被@Component (@Service,@Controller)注解的 bean,注解默认会扫描启动类所在的包下所有的类 ,可以自定义不扫描某些 bean。如下图所示,容器中将排除TypeExcludeFilter和AutoConfigurationExcludeFilter。 @EnableAutoConfiguration 是实现自动装配的重要注解,我们以这个注解入手。\n@EnableAutoConfiguration:实现自动装配的核心注解 # EnableAutoConfiguration 只是一个简单地注解,自动装配核心功能的实现实际是通过 AutoConfigurationImportSelector类。\n@Target({ElementType.TYPE}) @Retention(RetentionPolicy.RUNTIME) @Documented @Inherited @AutoConfigurationPackage //作用:将main包下的所有组件注册到容器中 @Import({AutoConfigurationImportSelector.class}) //加载自动装配类 xxxAutoconfiguration public @interface EnableAutoConfiguration { String ENABLED_OVERRIDE_PROPERTY = \u0026#34;spring.boot.enableautoconfiguration\u0026#34;; Class\u0026lt;?\u0026gt;[] exclude() default {}; String[] excludeName() default {}; } 我们现在重点分析下AutoConfigurationImportSelector 类到底做了什么?\nAutoConfigurationImportSelector:加载自动装配类 # AutoConfigurationImportSelector类的继承体系如下:\npublic class AutoConfigurationImportSelector implements DeferredImportSelector, BeanClassLoaderAware, ResourceLoaderAware, BeanFactoryAware, EnvironmentAware, Ordered { } public interface DeferredImportSelector extends ImportSelector { } public interface ImportSelector { String[] selectImports(AnnotationMetadata var1); } 可以看出,AutoConfigurationImportSelector 类实现了 ImportSelector接口,也就实现了这个接口中的 selectImports方法,该方法主要用于获取所有符合条件的类的全限定类名,这些类需要被加载到 IoC 容器中。\nprivate static final String[] NO_IMPORTS = new String[0]; public String[] selectImports(AnnotationMetadata annotationMetadata) { // \u0026lt;1\u0026gt;.判断自动装配开关是否打开 if (!this.isEnabled(annotationMetadata)) { return NO_IMPORTS; } else { //\u0026lt;2\u0026gt;.获取所有需要装配的bean AutoConfigurationMetadata autoConfigurationMetadata = AutoConfigurationMetadataLoader.loadMetadata(this.beanClassLoader); AutoConfigurationImportSelector.AutoConfigurationEntry autoConfigurationEntry = this.getAutoConfigurationEntry(autoConfigurationMetadata, annotationMetadata); return StringUtils.toStringArray(autoConfigurationEntry.getConfigurations()); } } 这里我们需要重点关注一下getAutoConfigurationEntry()方法,这个方法主要负责加载自动配置类的。\n该方法调用链如下:\n现在我们结合getAutoConfigurationEntry()的源码来详细分析一下:\nprivate static final AutoConfigurationEntry EMPTY_ENTRY = new AutoConfigurationEntry(); AutoConfigurationEntry getAutoConfigurationEntry(AutoConfigurationMetadata autoConfigurationMetadata, AnnotationMetadata annotationMetadata) { //\u0026lt;1\u0026gt;. if (!this.isEnabled(annotationMetadata)) { return EMPTY_ENTRY; } else { //\u0026lt;2\u0026gt;. AnnotationAttributes attributes = this.getAttributes(annotationMetadata); //\u0026lt;3\u0026gt;. List\u0026lt;String\u0026gt; configurations = this.getCandidateConfigurations(annotationMetadata, attributes); //\u0026lt;4\u0026gt;. configurations = this.removeDuplicates(configurations); Set\u0026lt;String\u0026gt; exclusions = this.getExclusions(annotationMetadata, attributes); this.checkExcludedClasses(configurations, exclusions); configurations.removeAll(exclusions); configurations = this.filter(configurations, autoConfigurationMetadata); this.fireAutoConfigurationImportEvents(configurations, exclusions); return new AutoConfigurationImportSelector.AutoConfigurationEntry(configurations, exclusions); } } 第 1 步:\n判断自动装配开关是否打开。默认spring.boot.enableautoconfiguration=true,可在 application.properties 或 application.yml 中设置\n第 2 步:\n用于获取EnableAutoConfiguration注解中的 exclude 和 excludeName。\n第 3 步\n获取需要自动装配的所有配置类,读取META-INF/spring.factories\nspring-boot/spring-boot-project/spring-boot-autoconfigure/src/main/resources/META-INF/spring.factories 从下图可以看到这个文件的配置内容都被我们读取到了。XXXAutoConfiguration的作用就是按需加载组件。\n不光是这个依赖下的META-INF/spring.factories被读取到,所有 Spring Boot Starter 下的META-INF/spring.factories都会被读取到。\n所以,你可以清楚滴看到, druid 数据库连接池的 Spring Boot Starter 就创建了META-INF/spring.factories文件。\n如果,我们自己要创建一个 Spring Boot Starter,这一步是必不可少的。\n第 4 步:\n到这里可能面试官会问你:“spring.factories中这么多配置,每次启动都要全部加载么?”。\n很明显,这是不现实的。我们 debug 到后面你会发现,configurations 的值变小了。\n因为,这一步有经历了一遍筛选,@ConditionalOnXXX 中的所有条件都满足,该类才会生效。\n@Configuration // 检查相关的类:RabbitTemplate 和 Channel是否存在 // 存在才会加载 @ConditionalOnClass({ RabbitTemplate.class, Channel.class }) @EnableConfigurationProperties(RabbitProperties.class) @Import(RabbitAnnotationDrivenConfiguration.class) public class RabbitAutoConfiguration { } 有兴趣的童鞋可以详细了解下 Spring Boot 提供的条件注解\n@ConditionalOnBean:当容器里有指定 Bean 的条件下 @ConditionalOnMissingBean:当容器里没有指定 Bean 的情况下 @ConditionalOnSingleCandidate:当指定 Bean 在容器中只有一个,或者虽然有多个但是指定首选 Bean @ConditionalOnClass:当类路径下有指定类的条件下 @ConditionalOnMissingClass:当类路径下没有指定类的条件下 @ConditionalOnProperty:指定的属性是否有指定的值 @ConditionalOnResource:类路径是否有指定的值 @ConditionalOnExpression:基于 SpEL 表达式作为判断条件 @ConditionalOnJava:基于 Java 版本作为判断条件 @ConditionalOnJndi:在 JNDI 存在的条件下差在指定的位置 @ConditionalOnNotWebApplication:当前项目不是 Web 项目的条件下 @ConditionalOnWebApplication:当前项目是 Web 项 目的条件下 如何实现一个 Starter # 光说不练假把式,现在就来撸一个 starter,实现自定义线程池\n第一步,创建threadpool-spring-boot-starter工程\n第二步,引入 Spring Boot 相关依赖\n第三步,创建ThreadPoolAutoConfiguration\n第四步,在threadpool-spring-boot-starter工程的 resources 包下创建META-INF/spring.factories文件\n最后新建工程引入threadpool-spring-boot-starter\n测试通过!!!\n总结 # Spring Boot 通过@EnableAutoConfiguration开启自动装配,通过 SpringFactoriesLoader 最终加载META-INF/spring.factories中的自动配置类实现自动装配,自动配置类其实就是通过@Conditional按需加载的配置类,想要其生效必须引入spring-boot-starter-xxx包实现起步依赖\n"},{"id":575,"href":"/zh/docs/technology/Interview/system-design/framework/spring/springboot-knowledge-and-questions-summary/","title":"SpringBoot常见面试题总结(付费)","section":"Framework","content":"Spring Boot 相关的面试题为我的 知识星球(点击链接即可查看详细介绍以及加入方法)专属内容,已经整理到了 《Java 面试指北》中。\n"},{"id":576,"href":"/zh/docs/technology/Interview/system-design/framework/spring/spring-knowledge-and-questions-summary/","title":"Spring常见面试题总结","section":"Framework","content":"这篇文章主要是想通过一些问题,加深大家对于 Spring 的理解,所以不会涉及太多的代码!\n下面的很多问题我自己在使用 Spring 的过程中也并没有注意,自己也是临时查阅了很多资料和书籍补上的。网上也有一些很多关于 Spring 常见问题/面试题整理的文章,我感觉大部分都是互相 copy,而且很多问题也不是很好,有些回答也存在问题。所以,自己花了一周的业余时间整理了一下,希望对大家有帮助。\nSpring 基础 # 什么是 Spring 框架? # Spring 是一款开源的轻量级 Java 开发框架,旨在提高开发人员的开发效率以及系统的可维护性。\n我们一般说 Spring 框架指的都是 Spring Framework,它是很多模块的集合,使用这些模块可以很方便地协助我们进行开发,比如说 Spring 支持 IoC(Inversion of Control:控制反转) 和 AOP(Aspect-Oriented Programming:面向切面编程)、可以很方便地对数据库进行访问、可以很方便地集成第三方组件(电子邮件,任务,调度,缓存等等)、对单元测试支持比较好、支持 RESTful Java 应用程序的开发。\nSpring 最核心的思想就是不重新造轮子,开箱即用,提高开发效率。\nSpring 翻译过来就是春天的意思,可见其目标和使命就是为 Java 程序员带来春天啊!感动!\n🤐 多提一嘴:语言的流行通常需要一个杀手级的应用,Spring 就是 Java 生态的一个杀手级的应用框架。\nSpring 提供的核心功能主要是 IoC 和 AOP。学习 Spring ,一定要把 IoC 和 AOP 的核心思想搞懂!\nSpring 官网: https://spring.io/ GitHub 地址: https://github.com/spring-projects/spring-framework Spring 包含的模块有哪些? # Spring4.x 版本:\nSpring5.x 版本:\nSpring5.x 版本中 Web 模块的 Portlet 组件已经被废弃掉,同时增加了用于异步响应式处理的 WebFlux 组件。\nSpring 各个模块的依赖关系如下:\nCore Container # Spring 框架的核心模块,也可以说是基础模块,主要提供 IoC 依赖注入功能的支持。Spring 其他所有的功能基本都需要依赖于该模块,我们从上面那张 Spring 各个模块的依赖关系图就可以看出来。\nspring-core:Spring 框架基本的核心工具类。 spring-beans:提供对 bean 的创建、配置和管理等功能的支持。 spring-context:提供对国际化、事件传播、资源加载等功能的支持。 spring-expression:提供对表达式语言(Spring Expression Language) SpEL 的支持,只依赖于 core 模块,不依赖于其他模块,可以单独使用。 AOP # spring-aspects:该模块为与 AspectJ 的集成提供支持。 spring-aop:提供了面向切面的编程实现。 spring-instrument:提供了为 JVM 添加代理(agent)的功能。 具体来讲,它为 Tomcat 提供了一个织入代理,能够为 Tomcat 传递类文 件,就像这些文件是被类加载器加载的一样。没有理解也没关系,这个模块的使用场景非常有限。 Data Access/Integration # spring-jdbc:提供了对数据库访问的抽象 JDBC。不同的数据库都有自己独立的 API 用于操作数据库,而 Java 程序只需要和 JDBC API 交互,这样就屏蔽了数据库的影响。 spring-tx:提供对事务的支持。 spring-orm:提供对 Hibernate、JPA、iBatis 等 ORM 框架的支持。 spring-oxm:提供一个抽象层支撑 OXM(Object-to-XML-Mapping),例如:JAXB、Castor、XMLBeans、JiBX 和 XStream 等。 spring-jms : 消息服务。自 Spring Framework 4.1 以后,它还提供了对 spring-messaging 模块的继承。 Spring Web # spring-web:对 Web 功能的实现提供一些最基础的支持。 spring-webmvc:提供对 Spring MVC 的实现。 spring-websocket:提供了对 WebSocket 的支持,WebSocket 可以让客户端和服务端进行双向通信。 spring-webflux:提供对 WebFlux 的支持。WebFlux 是 Spring Framework 5.0 中引入的新的响应式框架。与 Spring MVC 不同,它不需要 Servlet API,是完全异步。 Messaging # spring-messaging 是从 Spring4.0 开始新加入的一个模块,主要职责是为 Spring 框架集成一些基础的报文传送应用。\nSpring Test # Spring 团队提倡测试驱动开发(TDD)。有了控制反转 (IoC)的帮助,单元测试和集成测试变得更简单。\nSpring 的测试模块对 JUnit(单元测试框架)、TestNG(类似 JUnit)、Mockito(主要用来 Mock 对象)、PowerMock(解决 Mockito 的问题比如无法模拟 final, static, private 方法)等等常用的测试框架支持的都比较好。\nSpring,Spring MVC,Spring Boot 之间什么关系? # 很多人对 Spring,Spring MVC,Spring Boot 这三者傻傻分不清楚!这里简单介绍一下这三者,其实很简单,没有什么高深的东西。\nSpring 包含了多个功能模块(上面刚刚提到过),其中最重要的是 Spring-Core(主要提供 IoC 依赖注入功能的支持) 模块, Spring 中的其他模块(比如 Spring MVC)的功能实现基本都需要依赖于该模块。\n下图对应的是 Spring4.x 版本。目前最新的 5.x 版本中 Web 模块的 Portlet 组件已经被废弃掉,同时增加了用于异步响应式处理的 WebFlux 组件。\nSpring MVC 是 Spring 中的一个很重要的模块,主要赋予 Spring 快速构建 MVC 架构的 Web 程序的能力。MVC 是模型(Model)、视图(View)、控制器(Controller)的简写,其核心思想是通过将业务逻辑、数据、显示分离来组织代码。\n使用 Spring 进行开发各种配置过于麻烦比如开启某些 Spring 特性时,需要用 XML 或 Java 进行显式配置。于是,Spring Boot 诞生了!\nSpring 旨在简化 J2EE 企业应用程序开发。Spring Boot 旨在简化 Spring 开发(减少配置文件,开箱即用!)。\nSpring Boot 只是简化了配置,如果你需要构建 MVC 架构的 Web 程序,你还是需要使用 Spring MVC 作为 MVC 框架,只是说 Spring Boot 帮你简化了 Spring MVC 的很多配置,真正做到开箱即用!\nSpring IoC # 谈谈自己对于 Spring IoC 的了解 # IoC(Inversion of Control:控制反转) 是一种设计思想,而不是一个具体的技术实现。IoC 的思想就是将原本在程序中手动创建对象的控制权,交由 Spring 框架来管理。不过, IoC 并非 Spring 特有,在其他语言中也有应用。\n为什么叫控制反转?\n控制:指的是对象创建(实例化、管理)的权力 反转:控制权交给外部环境(Spring 框架、IoC 容器) 将对象之间的相互依赖关系交给 IoC 容器来管理,并由 IoC 容器完成对象的注入。这样可以很大程度上简化应用的开发,把应用从复杂的依赖关系中解放出来。 IoC 容器就像是一个工厂一样,当我们需要创建一个对象的时候,只需要配置好配置文件/注解即可,完全不用考虑对象是如何被创建出来的。\n在实际项目中一个 Service 类可能依赖了很多其他的类,假如我们需要实例化这个 Service,你可能要每次都要搞清这个 Service 所有底层类的构造函数,这可能会把人逼疯。如果利用 IoC 的话,你只需要配置好,然后在需要的地方引用就行了,这大大增加了项目的可维护性且降低了开发难度。\n在 Spring 中, IoC 容器是 Spring 用来实现 IoC 的载体, IoC 容器实际上就是个 Map(key,value),Map 中存放的是各种对象。\nSpring 时代我们一般通过 XML 文件来配置 Bean,后来开发人员觉得 XML 文件来配置不太好,于是 SpringBoot 注解配置就慢慢开始流行起来。\n相关阅读:\nIoC 源码阅读 IoC \u0026amp; AOP 详解(快速搞懂) 什么是 Spring Bean? # 简单来说,Bean 代指的就是那些被 IoC 容器所管理的对象。\n我们需要告诉 IoC 容器帮助我们管理哪些对象,这个是通过配置元数据来定义的。配置元数据可以是 XML 文件、注解或者 Java 配置类。\n\u0026lt;!-- Constructor-arg with \u0026#39;value\u0026#39; attribute --\u0026gt; \u0026lt;bean id=\u0026#34;...\u0026#34; class=\u0026#34;...\u0026#34;\u0026gt; \u0026lt;constructor-arg value=\u0026#34;...\u0026#34;/\u0026gt; \u0026lt;/bean\u0026gt; 下图简单地展示了 IoC 容器如何使用配置元数据来管理对象。\norg.springframework.beans和 org.springframework.context 这两个包是 IoC 实现的基础,如果想要研究 IoC 相关的源码的话,可以去看看\n将一个类声明为 Bean 的注解有哪些? # @Component:通用的注解,可标注任意类为 Spring 组件。如果一个 Bean 不知道属于哪个层,可以使用@Component 注解标注。 @Repository : 对应持久层即 Dao 层,主要用于数据库相关操作。 @Service : 对应服务层,主要涉及一些复杂的逻辑,需要用到 Dao 层。 @Controller : 对应 Spring MVC 控制层,主要用于接受用户请求并调用 Service 层返回数据给前端页面。 @Component 和 @Bean 的区别是什么? # @Component 注解作用于类,而@Bean注解作用于方法。 @Component通常是通过类路径扫描来自动侦测以及自动装配到 Spring 容器中(我们可以使用 @ComponentScan 注解定义要扫描的路径从中找出标识了需要装配的类自动装配到 Spring 的 bean 容器中)。@Bean 注解通常是我们在标有该注解的方法中定义产生这个 bean,@Bean告诉了 Spring 这是某个类的实例,当我需要用它的时候还给我。 @Bean 注解比 @Component 注解的自定义性更强,而且很多地方我们只能通过 @Bean 注解来注册 bean。比如当我们引用第三方库中的类需要装配到 Spring容器时,则只能通过 @Bean来实现。 @Bean注解使用示例:\n@Configuration public class AppConfig { @Bean public TransferService transferService() { return new TransferServiceImpl(); } } 上面的代码相当于下面的 xml 配置\n\u0026lt;beans\u0026gt; \u0026lt;bean id=\u0026#34;transferService\u0026#34; class=\u0026#34;com.acme.TransferServiceImpl\u0026#34;/\u0026gt; \u0026lt;/beans\u0026gt; 下面这个例子是通过 @Component 无法实现的。\n@Bean public OneService getService(status) { case (status) { when 1: return new serviceImpl1(); when 2: return new serviceImpl2(); when 3: return new serviceImpl3(); } } 注入 Bean 的注解有哪些? # Spring 内置的 @Autowired 以及 JDK 内置的 @Resource 和 @Inject 都可以用于注入 Bean。\nAnnotation Package Source @Autowired org.springframework.bean.factory Spring 2.5+ @Resource javax.annotation Java JSR-250 @Inject javax.inject Java JSR-330 @Autowired 和@Resource使用的比较多一些。\n@Autowired 和 @Resource 的区别是什么? # Autowired 属于 Spring 内置的注解,默认的注入方式为byType(根据类型进行匹配),也就是说会优先根据接口类型去匹配并注入 Bean (接口的实现类)。\n这会有什么问题呢? 当一个接口存在多个实现类的话,byType这种方式就无法正确注入对象了,因为这个时候 Spring 会同时找到多个满足条件的选择,默认情况下它自己不知道选择哪一个。\n这种情况下,注入方式会变为 byName(根据名称进行匹配),这个名称通常就是类名(首字母小写)。就比如说下面代码中的 smsService 就是我这里所说的名称,这样应该比较好理解了吧。\n// smsService 就是我们上面所说的名称 @Autowired private SmsService smsService; 举个例子,SmsService 接口有两个实现类: SmsServiceImpl1和 SmsServiceImpl2,且它们都已经被 Spring 容器所管理。\n// 报错,byName 和 byType 都无法匹配到 bean @Autowired private SmsService smsService; // 正确注入 SmsServiceImpl1 对象对应的 bean @Autowired private SmsService smsServiceImpl1; // 正确注入 SmsServiceImpl1 对象对应的 bean // smsServiceImpl1 就是我们上面所说的名称 @Autowired @Qualifier(value = \u0026#34;smsServiceImpl1\u0026#34;) private SmsService smsService; 我们还是建议通过 @Qualifier 注解来显式指定名称而不是依赖变量的名称。\n@Resource属于 JDK 提供的注解,默认注入方式为 byName。如果无法通过名称匹配到对应的 Bean 的话,注入方式会变为byType。\n@Resource 有两个比较重要且日常开发常用的属性:name(名称)、type(类型)。\npublic @interface Resource { String name() default \u0026#34;\u0026#34;; Class\u0026lt;?\u0026gt; type() default Object.class; } 如果仅指定 name 属性则注入方式为byName,如果仅指定type属性则注入方式为byType,如果同时指定name 和type属性(不建议这么做)则注入方式为byType+byName。\n// 报错,byName 和 byType 都无法匹配到 bean @Resource private SmsService smsService; // 正确注入 SmsServiceImpl1 对象对应的 bean @Resource private SmsService smsServiceImpl1; // 正确注入 SmsServiceImpl1 对象对应的 bean(比较推荐这种方式) @Resource(name = \u0026#34;smsServiceImpl1\u0026#34;) private SmsService smsService; 简单总结一下:\n@Autowired 是 Spring 提供的注解,@Resource 是 JDK 提供的注解。 Autowired 默认的注入方式为byType(根据类型进行匹配),@Resource默认注入方式为 byName(根据名称进行匹配)。 当一个接口存在多个实现类的情况下,@Autowired 和@Resource都需要通过名称才能正确匹配到对应的 Bean。Autowired 可以通过 @Qualifier 注解来显式指定名称,@Resource可以通过 name 属性来显式指定名称。 @Autowired 支持在构造函数、方法、字段和参数上使用。@Resource 主要用于字段和方法上的注入,不支持在构造函数或参数上使用。 注入 Bean 的方式有哪些? # 依赖注入 (Dependency Injection, DI) 的常见方式:\n构造函数注入:通过类的构造函数来注入依赖项。 Setter 注入:通过类的 Setter 方法来注入依赖项。 Field(字段) 注入:直接在类的字段上使用注解(如 @Autowired 或 @Resource)来注入依赖项。 构造函数注入示例:\n@Service public class UserService { private final UserRepository userRepository; public UserService(UserRepository userRepository) { this.userRepository = userRepository; } //... } Setter 注入示例:\n@Service public class UserService { private UserRepository userRepository; // 在 Spring 4.3 及以后的版本,特定情况下 @Autowired 可以省略不写 @Autowired public void setUserRepository(UserRepository userRepository) { this.userRepository = userRepository; } //... } Field 注入示例:\n@Service public class UserService { @Autowired private UserRepository userRepository; //... } 构造函数注入还是 Setter 注入? # Spring 官方有对这个问题的回答: https://docs.spring.io/spring-framework/reference/core/beans/dependencies/factory-collaborators.html#beans-setter-injection。\n我这里主要提取总结完善一下 Spring 官方的建议。\nSpring 官方推荐构造函数注入,这种注入方式的优势如下:\n依赖完整性:确保所有必需依赖在对象创建时就被注入,避免了空指针异常的风险。 不可变性:有助于创建不可变对象,提高了线程安全性。 初始化保证:组件在使用前已完全初始化,减少了潜在的错误。 测试便利性:在单元测试中,可以直接通过构造函数传入模拟的依赖项,而不必依赖 Spring 容器进行注入。 构造函数注入适合处理必需的依赖项,而 Setter 注入 则更适合可选的依赖项,这些依赖项可以有默认值或在对象生命周期中动态设置。虽然 @Autowired 可以用于 Setter 方法来处理必需的依赖项,但构造函数注入仍然是更好的选择。\n在某些情况下(例如第三方类不提供 Setter 方法),构造函数注入可能是唯一的选择。\nBean 的作用域有哪些? # Spring 中 Bean 的作用域通常有下面几种:\nsingleton : IoC 容器中只有唯一的 bean 实例。Spring 中的 bean 默认都是单例的,是对单例设计模式的应用。 prototype : 每次获取都会创建一个新的 bean 实例。也就是说,连续 getBean() 两次,得到的是不同的 Bean 实例。 request (仅 Web 应用可用): 每一次 HTTP 请求都会产生一个新的 bean(请求 bean),该 bean 仅在当前 HTTP request 内有效。 session (仅 Web 应用可用) : 每一次来自新 session 的 HTTP 请求都会产生一个新的 bean(会话 bean),该 bean 仅在当前 HTTP session 内有效。 application/global-session (仅 Web 应用可用):每个 Web 应用在启动时创建一个 Bean(应用 Bean),该 bean 仅在当前应用启动时间内有效。 websocket (仅 Web 应用可用):每一次 WebSocket 会话产生一个新的 bean。 如何配置 bean 的作用域呢?\nxml 方式:\n\u0026lt;bean id=\u0026#34;...\u0026#34; class=\u0026#34;...\u0026#34; scope=\u0026#34;singleton\u0026#34;\u0026gt;\u0026lt;/bean\u0026gt; 注解方式:\n@Bean @Scope(value = ConfigurableBeanFactory.SCOPE_PROTOTYPE) public Person personPrototype() { return new Person(); } Bean 是线程安全的吗? # Spring 框架中的 Bean 是否线程安全,取决于其作用域和状态。\n我们这里以最常用的两种作用域 prototype 和 singleton 为例介绍。几乎所有场景的 Bean 作用域都是使用默认的 singleton ,重点关注 singleton 作用域即可。\nprototype 作用域下,每次获取都会创建一个新的 bean 实例,不存在资源竞争问题,所以不存在线程安全问题。singleton 作用域下,IoC 容器中只有唯一的 bean 实例,可能会存在资源竞争问题(取决于 Bean 是否有状态)。如果这个 bean 是有状态的话,那就存在线程安全问题(有状态 Bean 是指包含可变的成员变量的对象)。\n有状态 Bean 示例:\n// 定义了一个购物车类,其中包含一个保存用户的购物车里商品的 List @Component public class ShoppingCart { private List\u0026lt;String\u0026gt; items = new ArrayList\u0026lt;\u0026gt;(); public void addItem(String item) { items.add(item); } public List\u0026lt;String\u0026gt; getItems() { return items; } } 不过,大部分 Bean 实际都是无状态(没有定义可变的成员变量)的(比如 Dao、Service),这种情况下, Bean 是线程安全的。\n无状态 Bean 示例:\n// 定义了一个用户服务,它仅包含业务逻辑而不保存任何状态。 @Component public class UserService { public User findUserById(Long id) { //... } //... } 对于有状态单例 Bean 的线程安全问题,常见的三种解决办法是:\n避免可变成员变量: 尽量设计 Bean 为无状态。 使用ThreadLocal: 将可变成员变量保存在 ThreadLocal 中,确保线程独立。 使用同步机制: 利用 synchronized 或 ReentrantLock 来进行同步控制,确保线程安全。 这里以 ThreadLocal为例,演示一下ThreadLocal 保存用户登录信息的场景:\npublic class UserThreadLocal { private UserThreadLocal() {} private static final ThreadLocal\u0026lt;SysUser\u0026gt; LOCAL = ThreadLocal.withInitial(() -\u0026gt; null); public static void put(SysUser sysUser) { LOCAL.set(sysUser); } public static SysUser get() { return LOCAL.get(); } public static void remove() { LOCAL.remove(); } } Bean 的生命周期了解么? # 创建 Bean 的实例:Bean 容器首先会找到配置文件中的 Bean 定义,然后使用 Java 反射 API 来创建 Bean 的实例。 Bean 属性赋值/填充:为 Bean 设置相关属性和依赖,例如@Autowired 等注解注入的对象、@Value 注入的值、setter方法或构造函数注入依赖和值、@Resource注入的各种资源。 Bean 初始化: 如果 Bean 实现了 BeanNameAware 接口,调用 setBeanName()方法,传入 Bean 的名字。 如果 Bean 实现了 BeanClassLoaderAware 接口,调用 setBeanClassLoader()方法,传入 ClassLoader对象的实例。 如果 Bean 实现了 BeanFactoryAware 接口,调用 setBeanFactory()方法,传入 BeanFactory对象的实例。 与上面的类似,如果实现了其他 *.Aware接口,就调用相应的方法。 如果有和加载这个 Bean 的 Spring 容器相关的 BeanPostProcessor 对象,执行postProcessBeforeInitialization() 方法 如果 Bean 实现了InitializingBean接口,执行afterPropertiesSet()方法。 如果 Bean 在配置文件中的定义包含 init-method 属性,执行指定的方法。 如果有和加载这个 Bean 的 Spring 容器相关的 BeanPostProcessor 对象,执行postProcessAfterInitialization() 方法。 销毁 Bean:销毁并不是说要立马把 Bean 给销毁掉,而是把 Bean 的销毁方法先记录下来,将来需要销毁 Bean 或者销毁容器的时候,就调用这些方法去释放 Bean 所持有的资源。 如果 Bean 实现了 DisposableBean 接口,执行 destroy() 方法。 如果 Bean 在配置文件中的定义包含 destroy-method 属性,执行指定的 Bean 销毁方法。或者,也可以直接通过@PreDestroy 注解标记 Bean 销毁之前执行的方法。 AbstractAutowireCapableBeanFactory 的 doCreateBean() 方法中能看到依次执行了这 4 个阶段:\nprotected Object doCreateBean(final String beanName, final RootBeanDefinition mbd, final @Nullable Object[] args) throws BeanCreationException { // 1. 创建 Bean 的实例 BeanWrapper instanceWrapper = null; if (instanceWrapper == null) { instanceWrapper = createBeanInstance(beanName, mbd, args); } Object exposedObject = bean; try { // 2. Bean 属性赋值/填充 populateBean(beanName, mbd, instanceWrapper); // 3. Bean 初始化 exposedObject = initializeBean(beanName, exposedObject, mbd); } // 4. 销毁 Bean-注册回调接口 try { registerDisposableBeanIfNecessary(beanName, bean, mbd); } return exposedObject; } Aware 接口能让 Bean 能拿到 Spring 容器资源。\nSpring 中提供的 Aware 接口主要有:\nBeanNameAware:注入当前 bean 对应 beanName; BeanClassLoaderAware:注入加载当前 bean 的 ClassLoader; BeanFactoryAware:注入当前 BeanFactory 容器的引用。 BeanPostProcessor 接口是 Spring 为修改 Bean 提供的强大扩展点。\npublic interface BeanPostProcessor { // 初始化前置处理 default Object postProcessBeforeInitialization(Object bean, String beanName) throws BeansException { return bean; } // 初始化后置处理 default Object postProcessAfterInitialization(Object bean, String beanName) throws BeansException { return bean; } } postProcessBeforeInitialization:Bean 实例化、属性注入完成后,InitializingBean#afterPropertiesSet方法以及自定义的 init-method 方法之前执行; postProcessAfterInitialization:类似于上面,不过是在 InitializingBean#afterPropertiesSet方法以及自定义的 init-method 方法之后执行。 InitializingBean 和 init-method 是 Spring 为 Bean 初始化提供的扩展点。\npublic interface InitializingBean { // 初始化逻辑 void afterPropertiesSet() throws Exception; } 指定 init-method 方法,指定初始化方法:\n\u0026lt;?xml version=\u0026#34;1.0\u0026#34; encoding=\u0026#34;UTF-8\u0026#34;?\u0026gt; \u0026lt;beans xmlns=\u0026#34;http://www.springframework.org/schema/beans\u0026#34; xmlns:xsi=\u0026#34;http://www.w3.org/2001/XMLSchema-instance\u0026#34; xsi:schemaLocation=\u0026#34;http://www.springframework.org/schema/beans http://www.springframework.org/schema/beans/spring-beans.xsd\u0026#34;\u0026gt; \u0026lt;bean id=\u0026#34;demo\u0026#34; class=\u0026#34;com.chaycao.Demo\u0026#34; init-method=\u0026#34;init()\u0026#34;/\u0026gt; \u0026lt;/beans\u0026gt; 如何记忆呢?\n整体上可以简单分为四步:实例化 —\u0026gt; 属性赋值 —\u0026gt; 初始化 —\u0026gt; 销毁。 初始化这一步涉及到的步骤比较多,包含 Aware 接口的依赖注入、BeanPostProcessor 在初始化前后的处理以及 InitializingBean 和 init-method 的初始化操作。 销毁这一步会注册相关销毁回调接口,最后通过DisposableBean 和 destory-method 进行销毁。 最后,再分享一张清晰的图解(图源: 如何记忆 Spring Bean 的生命周期)。\nSpring AOP # 谈谈自己对于 AOP 的了解 # AOP(Aspect-Oriented Programming:面向切面编程)能够将那些与业务无关,却为业务模块所共同调用的逻辑或责任(例如事务处理、日志管理、权限控制等)封装起来,便于减少系统的重复代码,降低模块间的耦合度,并有利于未来的可拓展性和可维护性。\nSpring AOP 就是基于动态代理的,如果要代理的对象,实现了某个接口,那么 Spring AOP 会使用 JDK Proxy,去创建代理对象,而对于没有实现接口的对象,就无法使用 JDK Proxy 去进行代理了,这时候 Spring AOP 会使用 Cglib 生成一个被代理对象的子类来作为代理,如下图所示:\n当然你也可以使用 AspectJ !Spring AOP 已经集成了 AspectJ ,AspectJ 应该算的上是 Java 生态系统中最完整的 AOP 框架了。\nAOP 切面编程涉及到的一些专业术语:\n术语 含义 目标(Target) 被通知的对象 代理(Proxy) 向目标对象应用通知之后创建的代理对象 连接点(JoinPoint) 目标对象的所属类中,定义的所有方法均为连接点 切入点(Pointcut) 被切面拦截 / 增强的连接点(切入点一定是连接点,连接点不一定是切入点) 通知(Advice) 增强的逻辑 / 代码,也即拦截到目标对象的连接点之后要做的事情 切面(Aspect) 切入点(Pointcut)+通知(Advice) Weaving(织入) 将通知应用到目标对象,进而生成代理对象的过程动作 Spring AOP 和 AspectJ AOP 有什么区别? # Spring AOP 属于运行时增强,而 AspectJ 是编译时增强。 Spring AOP 基于代理(Proxying),而 AspectJ 基于字节码操作(Bytecode Manipulation)。\nSpring AOP 已经集成了 AspectJ ,AspectJ 应该算的上是 Java 生态系统中最完整的 AOP 框架了。AspectJ 相比于 Spring AOP 功能更加强大,但是 Spring AOP 相对来说更简单,\n如果我们的切面比较少,那么两者性能差异不大。但是,当切面太多的话,最好选择 AspectJ ,它比 Spring AOP 快很多。\nAOP 常见的通知类型有哪些? # Before(前置通知):目标对象的方法调用之前触发 After (后置通知):目标对象的方法调用之后触发 AfterReturning(返回通知):目标对象的方法调用完成,在返回结果值之后触发 AfterThrowing(异常通知):目标对象的方法运行中抛出 / 触发异常后触发。AfterReturning 和 AfterThrowing 两者互斥。如果方法调用成功无异常,则会有返回值;如果方法抛出了异常,则不会有返回值。 Around (环绕通知):编程式控制目标对象的方法调用。环绕通知是所有通知类型中可操作范围最大的一种,因为它可以直接拿到目标对象,以及要执行的方法,所以环绕通知可以任意的在目标对象的方法调用前后搞事,甚至不调用目标对象的方法 多个切面的执行顺序如何控制? # 1、通常使用@Order 注解直接定义切面顺序\n// 值越小优先级越高 @Order(3) @Component @Aspect public class LoggingAspect implements Ordered { 2、实现Ordered 接口重写 getOrder 方法。\n@Component @Aspect public class LoggingAspect implements Ordered { // .... @Override public int getOrder() { // 返回值越小优先级越高 return 1; } } Spring MVC # 说说自己对于 Spring MVC 了解? # MVC 是模型(Model)、视图(View)、控制器(Controller)的简写,其核心思想是通过将业务逻辑、数据、显示分离来组织代码。\n网上有很多人说 MVC 不是设计模式,只是软件设计规范,我个人更倾向于 MVC 同样是众多设计模式中的一种。 java-design-patterns 项目中就有关于 MVC 的相关介绍。\n想要真正理解 Spring MVC,我们先来看看 Model 1 和 Model 2 这两个没有 Spring MVC 的时代。\nModel 1 时代\n很多学 Java 后端比较晚的朋友可能并没有接触过 Model 1 时代下的 JavaWeb 应用开发。在 Model1 模式下,整个 Web 应用几乎全部用 JSP 页面组成,只用少量的 JavaBean 来处理数据库连接、访问等操作。\n这个模式下 JSP 即是控制层(Controller)又是表现层(View)。显而易见,这种模式存在很多问题。比如控制逻辑和表现逻辑混杂在一起,导致代码重用率极低;再比如前端和后端相互依赖,难以进行测试维护并且开发效率极低。\nModel 2 时代\n学过 Servlet 并做过相关 Demo 的朋友应该了解“Java Bean(Model)+ JSP(View)+Servlet(Controller) ”这种开发模式,这就是早期的 JavaWeb MVC 开发模式。\nModel:系统涉及的数据,也就是 dao 和 bean。 View:展示模型中的数据,只是用来展示。 Controller:接受用户请求,并将请求发送至 Model,最后返回数据给 JSP 并展示给用户 Model2 模式下还存在很多问题,Model2 的抽象和封装程度还远远不够,使用 Model2 进行开发时不可避免地会重复造轮子,这就大大降低了程序的可维护性和复用性。\n于是,很多 JavaWeb 开发相关的 MVC 框架应运而生比如 Struts2,但是 Struts2 比较笨重。\nSpring MVC 时代\n随着 Spring 轻量级开发框架的流行,Spring 生态圈出现了 Spring MVC 框架, Spring MVC 是当前最优秀的 MVC 框架。相比于 Struts2 , Spring MVC 使用更加简单和方便,开发效率更高,并且 Spring MVC 运行速度更快。\nMVC 是一种设计模式,Spring MVC 是一款很优秀的 MVC 框架。Spring MVC 可以帮助我们进行更简洁的 Web 层的开发,并且它天生与 Spring 框架集成。Spring MVC 下我们一般把后端项目分为 Service 层(处理业务)、Dao 层(数据库操作)、Entity 层(实体类)、Controller 层(控制层,返回数据给前台页面)。\nSpring MVC 的核心组件有哪些? # 记住了下面这些组件,也就记住了 SpringMVC 的工作原理。\nDispatcherServlet:核心的中央处理器,负责接收请求、分发,并给予客户端响应。 HandlerMapping:处理器映射器,根据 URL 去匹配查找能处理的 Handler ,并会将请求涉及到的拦截器和 Handler 一起封装。 HandlerAdapter:处理器适配器,根据 HandlerMapping 找到的 Handler ,适配执行对应的 Handler; Handler:请求处理器,处理实际请求的处理器。 ViewResolver:视图解析器,根据 Handler 返回的逻辑视图 / 视图,解析并渲染真正的视图,并传递给 DispatcherServlet 响应客户端 SpringMVC 工作原理了解吗? # Spring MVC 原理如下图所示:\nSpringMVC 工作原理的图解我没有自己画,直接图省事在网上找了一个非常清晰直观的,原出处不明。\n流程说明(重要):\n客户端(浏览器)发送请求, DispatcherServlet拦截请求。 DispatcherServlet 根据请求信息调用 HandlerMapping 。HandlerMapping 根据 URL 去匹配查找能处理的 Handler(也就是我们平常说的 Controller 控制器) ,并会将请求涉及到的拦截器和 Handler 一起封装。 DispatcherServlet 调用 HandlerAdapter适配器执行 Handler 。 Handler 完成对用户请求的处理后,会返回一个 ModelAndView 对象给DispatcherServlet,ModelAndView 顾名思义,包含了数据模型以及相应的视图的信息。Model 是返回的数据对象,View 是个逻辑上的 View。 ViewResolver 会根据逻辑 View 查找实际的 View。 DispaterServlet 把返回的 Model 传给 View(视图渲染)。 把 View 返回给请求者(浏览器) 上述流程是传统开发模式(JSP,Thymeleaf 等)的工作原理。然而现在主流的开发方式是前后端分离,这种情况下 Spring MVC 的 View 概念发生了一些变化。由于 View 通常由前端框架(Vue, React 等)来处理,后端不再负责渲染页面,而是只负责提供数据,因此:\n前后端分离时,后端通常不再返回具体的视图,而是返回纯数据(通常是 JSON 格式),由前端负责渲染和展示。 View 的部分在前后端分离的场景下往往不需要设置,Spring MVC 的控制器方法只需要返回数据,不再返回 ModelAndView,而是直接返回数据,Spring 会自动将其转换为 JSON 格式。相应的,ViewResolver 也将不再被使用。 怎么做到呢?\n使用 @RestController 注解代替传统的 @Controller 注解,这样所有方法默认会返回 JSON 格式的数据,而不是试图解析视图。 如果你使用的是 @Controller,可以结合 @ResponseBody 注解来返回 JSON。 统一异常处理怎么做? # 推荐使用注解的方式统一异常处理,具体会使用到 @ControllerAdvice + @ExceptionHandler 这两个注解 。\n@ControllerAdvice @ResponseBody public class GlobalExceptionHandler { @ExceptionHandler(BaseException.class) public ResponseEntity\u0026lt;?\u0026gt; handleAppException(BaseException ex, HttpServletRequest request) { //...... } @ExceptionHandler(value = ResourceNotFoundException.class) public ResponseEntity\u0026lt;ErrorReponse\u0026gt; handleResourceNotFoundException(ResourceNotFoundException ex, HttpServletRequest request) { //...... } } 这种异常处理方式下,会给所有或者指定的 Controller 织入异常处理的逻辑(AOP),当 Controller 中的方法抛出异常的时候,由被@ExceptionHandler 注解修饰的方法进行处理。\nExceptionHandlerMethodResolver 中 getMappedMethod 方法决定了异常具体被哪个被 @ExceptionHandler 注解修饰的方法处理异常。\n@Nullable private Method getMappedMethod(Class\u0026lt;? extends Throwable\u0026gt; exceptionType) { List\u0026lt;Class\u0026lt;? extends Throwable\u0026gt;\u0026gt; matches = new ArrayList\u0026lt;\u0026gt;(); //找到可以处理的所有异常信息。mappedMethods 中存放了异常和处理异常的方法的对应关系 for (Class\u0026lt;? extends Throwable\u0026gt; mappedException : this.mappedMethods.keySet()) { if (mappedException.isAssignableFrom(exceptionType)) { matches.add(mappedException); } } // 不为空说明有方法处理异常 if (!matches.isEmpty()) { // 按照匹配程度从小到大排序 matches.sort(new ExceptionDepthComparator(exceptionType)); // 返回处理异常的方法 return this.mappedMethods.get(matches.get(0)); } else { return null; } } 从源代码看出:getMappedMethod()会首先找到可以匹配处理异常的所有方法信息,然后对其进行从小到大的排序,最后取最小的那一个匹配的方法(即匹配度最高的那个)。\nSpring 框架中用到了哪些设计模式? # 关于下面这些设计模式的详细介绍,可以看我写的 Spring 中的设计模式详解 这篇文章。\n工厂设计模式 : Spring 使用工厂模式通过 BeanFactory、ApplicationContext 创建 bean 对象。 代理设计模式 : Spring AOP 功能的实现。 单例设计模式 : Spring 中的 Bean 默认都是单例的。 模板方法模式 : Spring 中 jdbcTemplate、hibernateTemplate 等以 Template 结尾的对数据库操作的类,它们就使用到了模板模式。 包装器设计模式 : 我们的项目需要连接多个数据库,而且不同的客户在每次访问中根据需要会去访问不同的数据库。这种模式让我们可以根据客户的需求能够动态切换不同的数据源。 观察者模式: Spring 事件驱动模型就是观察者模式很经典的一个应用。 适配器模式 : Spring AOP 的增强或通知(Advice)使用到了适配器模式、spring MVC 中也是用到了适配器模式适配Controller。 …… Spring 的循环依赖 # Spring 循环依赖了解吗,怎么解决? # 循环依赖是指 Bean 对象循环引用,是两个或多个 Bean 之间相互持有对方的引用,例如 CircularDependencyA → CircularDependencyB → CircularDependencyA。\n@Component public class CircularDependencyA { @Autowired private CircularDependencyB circB; } @Component public class CircularDependencyB { @Autowired private CircularDependencyA circA; } 单个对象的自我依赖也会出现循环依赖,但这种概率极低,属于是代码编写错误。\n@Component public class CircularDependencyA { @Autowired private CircularDependencyA circA; } Spring 框架通过使用三级缓存来解决这个问题,确保即使在循环依赖的情况下也能正确创建 Bean。\nSpring 中的三级缓存其实就是三个 Map,如下:\n// 一级缓存 /** Cache of singleton objects: bean name to bean instance. */ private final Map\u0026lt;String, Object\u0026gt; singletonObjects = new ConcurrentHashMap\u0026lt;\u0026gt;(256); // 二级缓存 /** Cache of early singleton objects: bean name to bean instance. */ private final Map\u0026lt;String, Object\u0026gt; earlySingletonObjects = new HashMap\u0026lt;\u0026gt;(16); // 三级缓存 /** Cache of singleton factories: bean name to ObjectFactory. */ private final Map\u0026lt;String, ObjectFactory\u0026lt;?\u0026gt;\u0026gt; singletonFactories = new HashMap\u0026lt;\u0026gt;(16); 简单来说,Spring 的三级缓存包括:\n一级缓存(singletonObjects):存放最终形态的 Bean(已经实例化、属性填充、初始化),单例池,为“Spring 的单例属性”⽽⽣。一般情况我们获取 Bean 都是从这里获取的,但是并不是所有的 Bean 都在单例池里面,例如原型 Bean 就不在里面。 二级缓存(earlySingletonObjects):存放过渡 Bean(半成品,尚未属性填充),也就是三级缓存中ObjectFactory产生的对象,与三级缓存配合使用的,可以防止 AOP 的情况下,每次调用ObjectFactory#getObject()都是会产生新的代理对象的。 三级缓存(singletonFactories):存放ObjectFactory,ObjectFactory的getObject()方法(最终调用的是getEarlyBeanReference()方法)可以生成原始 Bean 对象或者代理对象(如果 Bean 被 AOP 切面代理)。三级缓存只会对单例 Bean 生效。 接下来说一下 Spring 创建 Bean 的流程:\n先去 一级缓存 singletonObjects 中获取,存在就返回; 如果不存在或者对象正在创建中,于是去 二级缓存 earlySingletonObjects 中获取; 如果还没有获取到,就去 三级缓存 singletonFactories 中获取,通过执行 ObjectFacotry 的 getObject() 就可以获取该对象,获取成功之后,从三级缓存移除,并将该对象加入到二级缓存中。 在三级缓存中存储的是 ObjectFacoty :\npublic interface ObjectFactory\u0026lt;T\u0026gt; { T getObject() throws BeansException; } Spring 在创建 Bean 的时候,如果允许循环依赖的话,Spring 就会将刚刚实例化完成,但是属性还没有初始化完的 Bean 对象给提前暴露出去,这里通过 addSingletonFactory 方法,向三级缓存中添加一个 ObjectFactory 对象:\n// AbstractAutowireCapableBeanFactory # doCreateBean # public abstract class AbstractAutowireCapableBeanFactory ... { protected Object doCreateBean(...) { //... // 支撑循环依赖:将 ()-\u0026gt;getEarlyBeanReference 作为一个 ObjectFactory 对象的 getObject() 方法加入到三级缓存中 addSingletonFactory(beanName, () -\u0026gt; getEarlyBeanReference(beanName, mbd, bean)); } } 那么上边在说 Spring 创建 Bean 的流程时说了,如果一级缓存、二级缓存都取不到对象时,会去三级缓存中通过 ObjectFactory 的 getObject 方法获取对象。\nclass A { // 使用了 B private B b; } class B { // 使用了 A private A a; } 以上面的循环依赖代码为例,整个解决循环依赖的流程如下:\n当 Spring 创建 A 之后,发现 A 依赖了 B ,又去创建 B,B 依赖了 A ,又去创建 A; 在 B 创建 A 的时候,那么此时 A 就发生了循环依赖,由于 A 此时还没有初始化完成,因此在 一二级缓存 中肯定没有 A; 那么此时就去三级缓存中调用 getObject() 方法去获取 A 的 前期暴露的对象 ,也就是调用上边加入的 getEarlyBeanReference() 方法,生成一个 A 的 前期暴露对象; 然后就将这个 ObjectFactory 从三级缓存中移除,并且将前期暴露对象放入到二级缓存中,那么 B 就将这个前期暴露对象注入到依赖,来支持循环依赖。 只用两级缓存够吗? 在没有 AOP 的情况下,确实可以只使用一级和三级缓存来解决循环依赖问题。但是,当涉及到 AOP 时,二级缓存就显得非常重要了,因为它确保了即使在 Bean 的创建过程中有多次对早期引用的请求,也始终只返回同一个代理对象,从而避免了同一个 Bean 有多个代理对象的问题。\n最后总结一下 Spring 如何解决三级缓存:\n在三级缓存这一块,主要记一下 Spring 是如何支持循环依赖的即可,也就是如果发生循环依赖的话,就去 三级缓存 singletonFactories 中拿到三级缓存中存储的 ObjectFactory 并调用它的 getObject() 方法来获取这个循环依赖对象的前期暴露对象(虽然还没初始化完成,但是可以拿到该对象在堆中的存储地址了),并且将这个前期暴露对象放到二级缓存中,这样在循环依赖时,就不会重复初始化了!\n不过,这种机制也有一些缺点,比如增加了内存开销(需要维护三级缓存,也就是三个 Map),降低了性能(需要进行多次检查和转换)。并且,还有少部分情况是不支持循环依赖的,比如非单例的 bean 和@Async注解的 bean 无法支持循环依赖。\n@Lazy 能解决循环依赖吗? # @Lazy 用来标识类是否需要懒加载/延迟加载,可以作用在类上、方法上、构造器上、方法参数上、成员变量中。\nSpring Boot 2.2 新增了全局懒加载属性,开启后全局 bean 被设置为懒加载,需要时再去创建。\n配置文件配置全局懒加载:\n#默认false spring.main.lazy-initialization=true 编码的方式设置全局懒加载:\nSpringApplication springApplication=new SpringApplication(Start.class); springApplication.setLazyInitialization(false); springApplication.run(args); 如非必要,尽量不要用全局懒加载。全局懒加载会让 Bean 第一次使用的时候加载会变慢,并且它会延迟应用程序问题的发现(当 Bean 被初始化时,问题才会出现)。\n如果一个 Bean 没有被标记为懒加载,那么它会在 Spring IoC 容器启动的过程中被创建和初始化。如果一个 Bean 被标记为懒加载,那么它不会在 Spring IoC 容器启动时立即实例化,而是在第一次被请求时才创建。这可以帮助减少应用启动时的初始化时间,也可以用来解决循环依赖问题。\n循环依赖问题是如何通过@Lazy 解决的呢?这里举一个例子,比如说有两个 Bean,A 和 B,他们之间发生了循环依赖,那么 A 的构造器上添加 @Lazy 注解之后(延迟 Bean B 的实例化),加载的流程如下:\n首先 Spring 会去创建 A 的 Bean,创建时需要注入 B 的属性; 由于在 A 上标注了 @Lazy 注解,因此 Spring 会去创建一个 B 的代理对象,将这个代理对象注入到 A 中的 B 属性; 之后开始执行 B 的实例化、初始化,在注入 B 中的 A 属性时,此时 A 已经创建完毕了,就可以将 A 给注入进去。 从上面的加载流程可以看出: @Lazy 解决循环依赖的关键点在于代理对象的使用。\n没有 @Lazy 的情况下:在 Spring 容器初始化 A 时会立即尝试创建 B,而在创建 B 的过程中又会尝试创建 A,最终导致循环依赖(即无限递归,最终抛出异常)。 使用 @Lazy 的情况下:Spring 不会立即创建 B,而是会注入一个 B 的代理对象。由于此时 B 仍未被真正初始化,A 的初始化可以顺利完成。等到 A 实例实际调用 B 的方法时,代理对象才会触发 B 的真正初始化。 @Lazy 能够在一定程度上打破循环依赖链,允许 Spring 容器顺利地完成 Bean 的创建和注入。但这并不是一个根本性的解决方案,尤其是在构造函数注入、复杂的多级依赖等场景中,@Lazy 无法有效地解决问题。因此,最佳实践仍然是尽量避免设计上的循环依赖。\nSpringBoot 允许循环依赖发生么? # SpringBoot 2.6.x 以前是默认允许循环依赖的,也就是说你的代码出现了循环依赖问题,一般情况下也不会报错。SpringBoot 2.6.x 以后官方不再推荐编写存在循环依赖的代码,建议开发者自己写代码的时候去减少不必要的互相依赖。这其实也是我们最应该去做的,循环依赖本身就是一种设计缺陷,我们不应该过度依赖 Spring 而忽视了编码的规范和质量,说不定未来某个 SpringBoot 版本就彻底禁止循环依赖的代码了。\nSpringBoot 2.6.x 以后,如果你不想重构循环依赖的代码的话,也可以采用下面这些方法:\n在全局配置文件中设置允许循环依赖存在:spring.main.allow-circular-references=true。最简单粗暴的方式,不太推荐。 在导致循环依赖的 Bean 上添加 @Lazy 注解,这是一种比较推荐的方式。@Lazy 用来标识类是否需要懒加载/延迟加载,可以作用在类上、方法上、构造器上、方法参数上、成员变量中。 …… Spring 事务 # 关于 Spring 事务的详细介绍,可以看我写的 Spring 事务详解 这篇文章。\nSpring 管理事务的方式有几种? # 编程式事务:在代码中硬编码(在分布式系统中推荐使用) : 通过 TransactionTemplate或者 TransactionManager 手动管理事务,事务范围过大会出现事务未提交导致超时,因此事务要比锁的粒度更小。 声明式事务:在 XML 配置文件中配置或者直接基于注解(单体应用或者简单业务系统推荐使用) : 实际是通过 AOP 实现(基于@Transactional 的全注解方式使用最多) Spring 事务中哪几种事务传播行为? # 事务传播行为是为了解决业务层方法之间互相调用的事务问题。\n当事务方法被另一个事务方法调用时,必须指定事务应该如何传播。例如:方法可能继续在现有事务中运行,也可能开启一个新事务,并在自己的事务中运行。\n正确的事务传播行为可能的值如下:\n1.TransactionDefinition.PROPAGATION_REQUIRED\n使用的最多的一个事务传播行为,我们平时经常使用的@Transactional注解默认使用就是这个事务传播行为。如果当前存在事务,则加入该事务;如果当前没有事务,则创建一个新的事务。\n2.TransactionDefinition.PROPAGATION_REQUIRES_NEW\n创建一个新的事务,如果当前存在事务,则把当前事务挂起。也就是说不管外部方法是否开启事务,Propagation.REQUIRES_NEW修饰的内部方法会新开启自己的事务,且开启的事务相互独立,互不干扰。\n3.TransactionDefinition.PROPAGATION_NESTED\n如果当前存在事务,则创建一个事务作为当前事务的嵌套事务来运行;如果当前没有事务,则该取值等价于TransactionDefinition.PROPAGATION_REQUIRED。\n4.TransactionDefinition.PROPAGATION_MANDATORY\n如果当前存在事务,则加入该事务;如果当前没有事务,则抛出异常。(mandatory:强制性)\n这个使用的很少。\n若是错误的配置以下 3 种事务传播行为,事务将不会发生回滚:\nTransactionDefinition.PROPAGATION_SUPPORTS: 如果当前存在事务,则加入该事务;如果当前没有事务,则以非事务的方式继续运行。 TransactionDefinition.PROPAGATION_NOT_SUPPORTED: 以非事务方式运行,如果当前存在事务,则把当前事务挂起。 TransactionDefinition.PROPAGATION_NEVER: 以非事务方式运行,如果当前存在事务,则抛出异常。 Spring 事务中的隔离级别有哪几种? # 和事务传播行为这块一样,为了方便使用,Spring 也相应地定义了一个枚举类:Isolation\npublic enum Isolation { DEFAULT(TransactionDefinition.ISOLATION_DEFAULT), READ_UNCOMMITTED(TransactionDefinition.ISOLATION_READ_UNCOMMITTED), READ_COMMITTED(TransactionDefinition.ISOLATION_READ_COMMITTED), REPEATABLE_READ(TransactionDefinition.ISOLATION_REPEATABLE_READ), SERIALIZABLE(TransactionDefinition.ISOLATION_SERIALIZABLE); private final int value; Isolation(int value) { this.value = value; } public int value() { return this.value; } } 下面我依次对每一种事务隔离级别进行介绍:\nTransactionDefinition.ISOLATION_DEFAULT :使用后端数据库默认的隔离级别,MySQL 默认采用的 REPEATABLE_READ 隔离级别 Oracle 默认采用的 READ_COMMITTED 隔离级别. TransactionDefinition.ISOLATION_READ_UNCOMMITTED :最低的隔离级别,使用这个隔离级别很少,因为它允许读取尚未提交的数据变更,可能会导致脏读、幻读或不可重复读 TransactionDefinition.ISOLATION_READ_COMMITTED : 允许读取并发事务已经提交的数据,可以阻止脏读,但是幻读或不可重复读仍有可能发生 TransactionDefinition.ISOLATION_REPEATABLE_READ : 对同一字段的多次读取结果都是一致的,除非数据是被本身事务自己所修改,可以阻止脏读和不可重复读,但幻读仍有可能发生。 TransactionDefinition.ISOLATION_SERIALIZABLE : 最高的隔离级别,完全服从 ACID 的隔离级别。所有的事务依次逐个执行,这样事务之间就完全不可能产生干扰,也就是说,该级别可以防止脏读、不可重复读以及幻读。但是这将严重影响程序的性能。通常情况下也不会用到该级别。 @Transactional(rollbackFor = Exception.class)注解了解吗? # Exception 分为运行时异常 RuntimeException 和非运行时异常。事务管理对于企业应用来说是至关重要的,即使出现异常情况,它也可以保证数据的一致性。\n当 @Transactional 注解作用于类上时,该类的所有 public 方法将都具有该类型的事务属性,同时,我们也可以在方法级别使用该标注来覆盖类级别的定义。\n@Transactional 注解默认回滚策略是只有在遇到RuntimeException(运行时异常) 或者 Error 时才会回滚事务,而不会回滚 Checked Exception(受检查异常)。这是因为 Spring 认为RuntimeException和 Error 是不可预期的错误,而受检异常是可预期的错误,可以通过业务逻辑来处理。\n如果想要修改默认的回滚策略,可以使用 @Transactional 注解的 rollbackFor 和 noRollbackFor 属性来指定哪些异常需要回滚,哪些异常不需要回滚。例如,如果想要让所有的异常都回滚事务,可以使用如下的注解:\n@Transactional(rollbackFor = Exception.class) public void someMethod() { // some business logic } 如果想要让某些特定的异常不回滚事务,可以使用如下的注解:\n@Transactional(noRollbackFor = CustomException.class) public void someMethod() { // some business logic } Spring Data JPA # JPA 重要的是实战,这里仅对小部分知识点进行总结。\n如何使用 JPA 在数据库中非持久化一个字段? # 假如我们有下面一个类:\n@Entity(name=\u0026#34;USER\u0026#34;) public class User { @Id @GeneratedValue(strategy = GenerationType.AUTO) @Column(name = \u0026#34;ID\u0026#34;) private Long id; @Column(name=\u0026#34;USER_NAME\u0026#34;) private String userName; @Column(name=\u0026#34;PASSWORD\u0026#34;) private String password; private String secrect; } 如果我们想让secrect 这个字段不被持久化,也就是不被数据库存储怎么办?我们可以采用下面几种方法:\nstatic String transient1; // not persistent because of static final String transient2 = \u0026#34;Satish\u0026#34;; // not persistent because of final transient String transient3; // not persistent because of transient @Transient String transient4; // not persistent because of @Transient 一般使用后面两种方式比较多,我个人使用注解的方式比较多。\nJPA 的审计功能是做什么的?有什么用? # 审计功能主要是帮助我们记录数据库操作的具体行为比如某条记录是谁创建的、什么时间创建的、最后修改人是谁、最后修改时间是什么时候。\n@Data @AllArgsConstructor @NoArgsConstructor @MappedSuperclass @EntityListeners(value = AuditingEntityListener.class) public abstract class AbstractAuditBase { @CreatedDate @Column(updatable = false) @JsonIgnore private Instant createdAt; @LastModifiedDate @JsonIgnore private Instant updatedAt; @CreatedBy @Column(updatable = false) @JsonIgnore private String createdBy; @LastModifiedBy @JsonIgnore private String updatedBy; } @CreatedDate: 表示该字段为创建时间字段,在这个实体被 insert 的时候,会设置值\n@CreatedBy :表示该字段为创建人,在这个实体被 insert 的时候,会设置值\n@LastModifiedDate、@LastModifiedBy同理。\n实体之间的关联关系注解有哪些? # @OneToOne : 一对一。 @ManyToMany:多对多。 @OneToMany : 一对多。 @ManyToOne:多对一。 利用 @ManyToOne 和 @OneToMany 也可以表达多对多的关联关系。\nSpring Security # Spring Security 重要的是实战,这里仅对小部分知识点进行总结。\n有哪些控制请求访问权限的方法? # permitAll():无条件允许任何形式访问,不管你登录还是没有登录。 anonymous():允许匿名访问,也就是没有登录才可以访问。 denyAll():无条件决绝任何形式的访问。 authenticated():只允许已认证的用户访问。 fullyAuthenticated():只允许已经登录或者通过 remember-me 登录的用户访问。 hasRole(String) : 只允许指定的角色访问。 hasAnyRole(String) : 指定一个或者多个角色,满足其一的用户即可访问。 hasAuthority(String):只允许具有指定权限的用户访问 hasAnyAuthority(String):指定一个或者多个权限,满足其一的用户即可访问。 hasIpAddress(String) : 只允许指定 ip 的用户访问。 hasRole 和 hasAuthority 有区别吗? # 可以看看松哥的这篇文章: Spring Security 中的 hasRole 和 hasAuthority 有区别吗?,介绍的比较详细。\n如何对密码进行加密? # 如果我们需要保存密码这类敏感数据到数据库的话,需要先加密再保存。\nSpring Security 提供了多种加密算法的实现,开箱即用,非常方便。这些加密算法实现类的接口是 PasswordEncoder ,如果你想要自己实现一个加密算法的话,也需要实现 PasswordEncoder 接口。\nPasswordEncoder 接口一共也就 3 个必须实现的方法。\npublic interface PasswordEncoder { // 加密也就是对原始密码进行编码 String encode(CharSequence var1); // 比对原始密码和数据库中保存的密码 boolean matches(CharSequence var1, String var2); // 判断加密密码是否需要再次进行加密,默认返回 false default boolean upgradeEncoding(String encodedPassword) { return false; } } 官方推荐使用基于 bcrypt 强哈希函数的加密算法实现类。\n如何优雅更换系统使用的加密算法? # 如果我们在开发过程中,突然发现现有的加密算法无法满足我们的需求,需要更换成另外一个加密算法,这个时候应该怎么办呢?\n推荐的做法是通过 DelegatingPasswordEncoder 兼容多种不同的密码加密方案,以适应不同的业务需求。\n从名字也能看出来,DelegatingPasswordEncoder 其实就是一个代理类,并非是一种全新的加密算法,它做的事情就是代理上面提到的加密算法实现类。在 Spring Security 5.0 之后,默认就是基于 DelegatingPasswordEncoder 进行密码加密的。\n参考 # 《Spring 技术内幕》 《从零开始深入学习 Spring》: https://juejin.cn/book/6857911863016390663 http://www.cnblogs.com/wmyskxz/p/8820371.html https://www.journaldev.com/2696/spring-interview-questions-and-answers https://www.edureka.co/blog/interview-questions/spring-interview-questions/ https://www.cnblogs.com/clwydjgs/p/9317849.html https://howtodoinjava.com/interview-questions/top-spring-interview-questions-with-answers/ http://www.tomaszezula.com/2014/02/09/spring-series-part-5-component-vs-bean/ https://stackoverflow.com/questions/34172888/difference-between-bean-and-autowired "},{"id":577,"href":"/zh/docs/technology/Interview/database/sql/sql-questions-01/","title":"SQL常见面试题总结(1)","section":"SQL","content":" 题目来源于: 牛客题霸 - SQL 必知必会\n检索数据 # SELECT 用于从数据库中查询数据。\n从 Customers 表中检索所有的 ID # 现有表 Customers 如下:\ncust_id A B C 编写 SQL 语句,从 Customers 表中检索所有的 cust_id。\n答案:\nSELECT cust_id FROM Customers 检索并列出已订购产品的清单 # 表 OrderItems 含有非空的列 prod_id 代表商品 id,包含了所有已订购的商品(有些已被订购多次)。\nprod_id a1 a2 a3 a4 a5 a6 a7 编写 SQL 语句,检索并列出所有已订购商品(prod_id)的去重后的清单。\n答案:\nSELECT DISTINCT prod_id FROM OrderItems 知识点:DISTINCT 用于返回列中的唯一不同值。\n检索所有列 # 现在有 Customers 表(表中含有列 cust_id 代表客户 id,cust_name 代表客户姓名)\ncust_id cust_name a1 andy a2 ben a3 tony a4 tom a5 an a6 lee a7 hex 需要编写 SQL 语句,检索所有列。\n答案:\nSELECT cust_id, cust_name FROM Customers 排序检索数据 # ORDER BY 用于对结果集按照一个列或者多个列进行排序。默认按照升序对记录进行排序,如果需要按照降序对记录进行排序,可以使用 DESC 关键字。\n检索顾客名称并且排序 # 有表 Customers,cust_id 代表客户 id,cust_name 代表客户姓名。\ncust_id cust_name a1 andy a2 ben a3 tony a4 tom a5 an a6 lee a7 hex 从 Customers 中检索所有的顾客名称(cust_name),并按从 Z 到 A 的顺序显示结果。\n答案:\nSELECT cust_name FROM Customers ORDER BY cust_name DESC 对顾客 ID 和日期排序 # 有 Orders 表:\ncust_id order_num order_date andy aaaa 2021-01-01 00:00:00 andy bbbb 2021-01-01 12:00:00 bob cccc 2021-01-10 12:00:00 dick dddd 2021-01-11 00:00:00 编写 SQL 语句,从 Orders 表中检索顾客 ID(cust_id)和订单号(order_num),并先按顾客 ID 对结果进行排序,再按订单日期倒序排列。\n答案:\n# 根据列名排序 # 注意:是 order_date 降序,而不是 order_num SELECT cust_id, order_num FROM Orders ORDER BY cust_id,order_date DESC 知识点:order by 对多列排序的时候,先排序的列放前面,后排序的列放后面。并且,不同的列可以有不同的排序规则。\n按照数量和价格排序 # 假设有一个 OrderItems 表:\nquantity item_price 1 100 10 1003 2 500 编写 SQL 语句,显示 OrderItems 表中的数量(quantity)和价格(item_price),并按数量由多到少、价格由高到低排序。\n答案:\nSELECT quantity, item_price FROM OrderItems ORDER BY quantity DESC,item_price DESC 检查 SQL 语句 # 有 Vendors 表:\nvend_name 海底捞 小龙坎 大龙燚 下面的 SQL 语句有问题吗?尝试将它改正确,使之能够正确运行,并且返回结果根据vend_name 逆序排列。\nSELECT vend_name, FROM Vendors ORDER vend_name DESC 改正后:\nSELECT vend_name FROM Vendors ORDER BY vend_name DESC 知识点:\n逗号作用是用来隔开列与列之间的。 ORDER BY 是有 BY 的,需要撰写完整,且位置正确。 过滤数据 # WHERE 可以过滤返回的数据。\n下面的运算符可以在 WHERE 子句中使用:\n运算符 描述 = 等于 \u0026lt;\u0026gt; 不等于。 注释: 在 SQL 的一些版本中,该操作符可被写成 != \u0026gt; 大于 \u0026lt; 小于 \u0026gt;= 大于等于 \u0026lt;= 小于等于 BETWEEN 在某个范围内 LIKE 搜索某种模式 IN 指定针对某个列的多个可能值 返回固定价格的产品 # 有表 Products:\nprod_id prod_name prod_price a0018 sockets 9.49 a0019 iphone13 600 b0018 gucci t-shirts 1000 【问题】从 Products 表中检索产品 ID(prod_id)和产品名称(prod_name),只返回价格为 9.49 美元的产品。\n答案:\nSELECT prod_id, prod_name FROM Products WHERE prod_price = 9.49 返回更高价格的产品 # 有表 Products:\nprod_id prod_name prod_price a0018 sockets 9.49 a0019 iphone13 600 b0019 gucci t-shirts 1000 【问题】编写 SQL 语句,从 Products 表中检索产品 ID(prod_id)和产品名称(prod_name),只返回价格为 9 美元或更高的产品。\n答案:\nSELECT prod_id, prod_name FROM Products WHERE prod_price \u0026gt;= 9 返回产品并且按照价格排序 # 有表 Products:\nprod_id prod_name prod_price a0011 egg 3 a0019 sockets 4 b0019 coffee 15 【问题】编写 SQL 语句,返回 Products 表中所有价格在 3 美元到 6 美元之间的产品的名称(prod_name)和价格(prod_price),然后按价格对结果进行排序。\n答案:\nSELECT prod_name, prod_price FROM Products WHERE prod_price BETWEEN 3 AND 6 ORDER BY prod_price # 或者 SELECT prod_name, prod_price FROM Products WHERE prod_price \u0026gt;= 3 AND prod_price \u0026lt;= 6 ORDER BY prod_price 返回更多的产品 # OrderItems 表含有:订单号 order_num,quantity产品数量\norder_num quantity a1 105 a2 1100 a2 200 a4 1121 a5 10 a2 19 a7 5 【问题】从 OrderItems 表中检索出所有不同且不重复的订单号(order_num),其中每个订单都要包含 100 个或更多的产品。\n答案:\nSELECT order_num FROM OrderItems GROUP BY order_num HAVING SUM(quantity) \u0026gt;= 100 高级数据过滤 # AND 和 OR 运算符用于基于一个以上的条件对记录进行过滤,两者可以结合使用。AND 必须 2 个条件都成立,OR只要 2 个条件中的一个成立即可。\n检索供应商名称 # Vendors 表有字段供应商名称(vend_name)、供应商国家(vend_country)、供应商州(vend_state)\nvend_name vend_country vend_state apple USA CA vivo CNA shenzhen huawei CNA xian 【问题】编写 SQL 语句,从 Vendors 表中检索供应商名称(vend_name),仅返回加利福尼亚州的供应商(这需要按国家[USA]和州[CA]进行过滤,没准其他国家也存在一个 CA)\n答案:\nSELECT vend_name FROM Vendors WHERE vend_country = \u0026#39;USA\u0026#39; AND vend_state = \u0026#39;CA\u0026#39; 检索并列出已订购产品的清单 # OrderItems 表包含了所有已订购的产品(有些已被订购多次)。\nprod_id order_num quantity BR01 a1 105 BR02 a2 1100 BR02 a2 200 BR03 a4 1121 BR017 a5 10 BR02 a2 19 BR017 a7 5 【问题】编写 SQL 语句,查找所有订购了数量至少 100 个的 BR01、BR02 或 BR03 的订单。你需要返回 OrderItems 表的订单号(order_num)、产品 ID(prod_id)和数量(quantity),并按产品 ID 和数量进行过滤。\n答案:\nSELECT order_num, prod_id, quantity FROM OrderItems WHERE prod_id IN (\u0026#39;BR01\u0026#39;, \u0026#39;BR02\u0026#39;, \u0026#39;BR03\u0026#39;) AND quantity \u0026gt;= 100 返回所有价格在 3 美元到 6 美元之间的产品的名称和价格 # 有表 Products:\nprod_id prod_name prod_price a0011 egg 3 a0019 sockets 4 b0019 coffee 15 【问题】编写 SQL 语句,返回所有价格在 3 美元到 6 美元之间的产品的名称(prod_name)和价格(prod_price),使用 AND 操作符,然后按价格对结果进行升序排序。\n答案:\nSELECT prod_name, prod_price FROM Products WHERE prod_price \u0026gt;= 3 and prod_price \u0026lt;= 6 ORDER BY prod_price 检查 SQL 语句 # 供应商表 Vendors 有字段供应商名称 vend_name、供应商国家 vend_country、供应商省份 vend_state\nvend_name vend_country vend_state apple USA CA vivo CNA shenzhen huawei CNA xian 【问题】修改正确下面 sql,使之正确返回。\nSELECT vend_name FROM Vendors ORDER BY vend_name WHERE vend_country = \u0026#39;USA\u0026#39; AND vend_state = \u0026#39;CA\u0026#39;; 修改后:\nSELECT vend_name FROM Vendors WHERE vend_country = \u0026#39;USA\u0026#39; AND vend_state = \u0026#39;CA\u0026#39; ORDER BY vend_name ORDER BY 语句必须放在 WHERE 之后。\n用通配符进行过滤 # SQL 通配符必须与 LIKE 运算符一起使用\n在 SQL 中,可使用以下通配符:\n通配符 描述 % 代表零个或多个字符 _ 仅替代一个字符 [charlist] 字符列中的任何单一字符 [^charlist] 或者 [!charlist] 不在字符列中的任何单一字符 检索产品名称和描述(一) # Products 表如下:\nprod_name prod_desc a0011 usb a0019 iphone13 b0019 gucci t-shirts c0019 gucci toy d0019 lego toy 【问题】编写 SQL 语句,从 Products 表中检索产品名称(prod_name)和描述(prod_desc),仅返回描述中包含 toy 一词的产品名称。\n答案:\nSELECT prod_name, prod_desc FROM Products WHERE prod_desc LIKE \u0026#39;%toy%\u0026#39; 检索产品名称和描述(二) # Products 表如下:\nprod_name prod_desc a0011 usb a0019 iphone13 b0019 gucci t-shirts c0019 gucci toy d0019 lego toy 【问题】编写 SQL 语句,从 Products 表中检索产品名称(prod_name)和描述(prod_desc),仅返回描述中未出现 toy 一词的产品,最后按”产品名称“对结果进行排序。\n答案:\nSELECT prod_name, prod_desc FROM Products WHERE prod_desc NOT LIKE \u0026#39;%toy%\u0026#39; ORDER BY prod_name 检索产品名称和描述(三) # Products 表如下:\nprod_name prod_desc a0011 usb a0019 iphone13 b0019 gucci t-shirts c0019 gucci toy d0019 lego carrots toy 【问题】编写 SQL 语句,从 Products 表中检索产品名称(prod_name)和描述(prod_desc),仅返回描述中同时出现 toy 和 carrots 的产品。有好几种方法可以执行此操作,但对于这个挑战题,请使用 AND 和两个 LIKE 比较。\n答案:\nSELECT prod_name, prod_desc FROM Products WHERE prod_desc LIKE \u0026#39;%toy%\u0026#39; AND prod_desc LIKE \u0026#34;%carrots%\u0026#34; 检索产品名称和描述(四) # Products 表如下:\nprod_name prod_desc a0011 usb a0019 iphone13 b0019 gucci t-shirts c0019 gucci toy d0019 lego toy carrots 【问题】编写 SQL 语句,从 Products 表中检索产品名称(prod_name)和描述(prod_desc),仅返回在描述中以先后顺序同时出现 toy 和 carrots 的产品。提示:只需要用带有三个 % 符号的 LIKE 即可。\n答案:\nSELECT prod_name, prod_desc FROM Products WHERE prod_desc LIKE \u0026#39;%toy%carrots%\u0026#39; 创建计算字段 # 别名 # 别名的常见用法是在检索出的结果中重命名表的列字段(为了符合特定的报表要求或客户需求)。有表 Vendors 代表供应商信息,vend_id 供应商 id、vend_name 供应商名称、vend_address 供应商地址、vend_city 供应商城市。\nvend_id vend_name vend_address vend_city a001 tencent cloud address1 shenzhen a002 huawei cloud address2 dongguan a003 aliyun cloud address3 hangzhou a003 netease cloud address4 guangzhou 【问题】编写 SQL 语句,从 Vendors 表中检索 vend_id、vend_name、vend_address 和 vend_city,将 vend_name 重命名为 vname,将 vend_city 重命名为 vcity,将 vend_address 重命名为 vaddress,按供应商名称对结果进行升序排序。\n答案:\nSELECT vend_id, vend_name AS vname, vend_address AS vaddress, vend_city AS vcity FROM Vendors ORDER BY vname # as 可以省略 SELECT vend_id, vend_name vname, vend_address vaddress, vend_city vcity FROM Vendors ORDER BY vname 打折 # 我们的示例商店正在进行打折促销,所有产品均降价 10%。Products 表包含 prod_id 产品 id、prod_price 产品价格。\n【问题】编写 SQL 语句,从 Products 表中返回 prod_id、prod_price 和 sale_price。sale_price 是一个包含促销价格的计算字段。提示:可以乘以 0.9,得到原价的 90%(即 10%的折扣)。\n答案:\nSELECT prod_id, prod_price, prod_price * 0.9 AS sale_price FROM Products 注意:sale_price 是对计算结果的命名,而不是原有的列名。\n使用函数处理数据 # 顾客登录名 # 我们的商店已经上线了,正在创建顾客账户。所有用户都需要登录名,默认登录名是其名称和所在城市的组合。\n给出 Customers 表 如下:\ncust_id cust_name cust_contact cust_city a1 Andy Li Andy Li Oak Park a2 Ben Liu Ben Liu Oak Park a3 Tony Dai Tony Dai Oak Park a4 Tom Chen Tom Chen Oak Park a5 An Li An Li Oak Park a6 Lee Chen Lee Chen Oak Park a7 Hex Liu Hex Liu Oak Park 【问题】编写 SQL 语句,返回顾客 ID(cust_id)、顾客名称(cust_name)和登录名(user_login),其中登录名全部为大写字母,并由顾客联系人的前两个字符(cust_contact)和其所在城市的前三个字符(cust_city)组成。提示:需要使用函数、拼接和别名。\n答案:\nSELECT cust_id, cust_name, UPPER(CONCAT(SUBSTRING(cust_contact, 1, 2), SUBSTRING(cust_city, 1, 3))) AS user_login FROM Customers 知识点:\n截取函数SUBSTRING():截取字符串,substring(str ,n ,m)(n 表示起始截取位置,m 表示要截取的字符个数)表示返回字符串 str 从第 n 个字符开始截取 m 个字符;\n拼接函数CONCAT():将两个或多个字符串连接成一个字符串,select concat(A,B):连接字符串 A 和 B。\n大写函数 UPPER():将指定字符串转换为大写。\n返回 2020 年 1 月的所有订单的订单号和订单日期 # Orders 订单表如下:\norder_num order_date a0001 2020-01-01 00:00:00 a0002 2020-01-02 00:00:00 a0003 2020-01-01 12:00:00 a0004 2020-02-01 00:00:00 a0005 2020-03-01 00:00:00 【问题】编写 SQL 语句,返回 2020 年 1 月的所有订单的订单号(order_num)和订单日期(order_date),并按订单日期升序排序\n答案:\nSELECT order_num, order_date FROM Orders WHERE month(order_date) = \u0026#39;01\u0026#39; AND YEAR(order_date) = \u0026#39;2020\u0026#39; ORDER BY order_date 也可以用通配符来做:\nSELECT order_num, order_date FROM Orders WHERE order_date LIKE \u0026#39;2020-01%\u0026#39; ORDER BY order_date 知识点:\n日期格式:YYYY-MM-DD 时间格式:HH:MM:SS 日期和时间处理相关的常用函数:\n函 数 说 明 ADDDATE() 增加一个日期(天、周等) ADDTIME() 增加一个时间(时、分等) CURDATE() 返回当前日期 CURTIME() 返回当前时间 DATE() 返回日期时间的日期部分 DATEDIFF 计算两个日期之差 DATE_FORMAT() 返回一个格式化的日期或时间串 DAY() 返回一个日期的天数部分 DAYOFWEEK() 对于一个日期,返回对应的星期几 HOUR() 返回一个时间的小时部分 MINUTE() 返回一个时间的分钟部分 MONTH() 返回一个日期的月份部分 NOW() 返回当前日期和时间 SECOND() 返回一个时间的秒部分 TIME() 返回一个日期时间的时间部分 YEAR() 返回一个日期的年份部分 汇总数据 # 汇总数据相关的函数:\n函 数 说 明 AVG() 返回某列的平均值 COUNT() 返回某列的行数 MAX() 返回某列的最大值 MIN() 返回某列的最小值 SUM() 返回某列值之和 确定已售出产品的总数 # OrderItems 表代表售出的产品,quantity 代表售出商品数量。\nquantity 10 100 1000 10001 2 15 【问题】编写 SQL 语句,确定已售出产品的总数。\n答案:\nSELECT Sum(quantity) AS items_ordered FROM OrderItems 确定已售出产品项 BR01 的总数 # OrderItems 表代表售出的产品,quantity 代表售出商品数量,产品项为 prod_id。\nquantity prod_id 10 AR01 100 AR10 1000 BR01 10001 BR010 【问题】修改创建的语句,确定已售出产品项(prod_id)为\u0026quot;BR01\u0026quot;的总数。\n答案:\nSELECT Sum(quantity) AS items_ordered FROM OrderItems WHERE prod_id = \u0026#39;BR01\u0026#39; 确定 Products 表中价格不超过 10 美元的最贵产品的价格 # Products 表如下,prod_price 代表商品的价格。\nprod_price 9.49 600 1000 【问题】编写 SQL 语句,确定 Products 表中价格不超过 10 美元的最贵产品的价格(prod_price)。将计算所得的字段命名为 max_price。\n答案:\nSELECT Max(prod_price) AS max_price FROM Products WHERE prod_price \u0026lt;= 10 分组数据 # GROUP BY:\nGROUP BY 子句将记录分组到汇总行中。 GROUP BY 为每个组返回一个记录。 GROUP BY 通常还涉及聚合COUNT,MAX,SUM,AVG 等。 GROUP BY 可以按一列或多列进行分组。 GROUP BY 按分组字段进行排序后,ORDER BY 可以以汇总字段来进行排序。 HAVING:\nHAVING 用于对汇总的 GROUP BY 结果进行过滤。 HAVING 必须要与 GROUP BY 连用。 WHERE 和 HAVING 可以在相同的查询中。 HAVING vs WHERE:\nWHERE:过滤指定的行,后面不能加聚合函数(分组函数)。 HAVING:过滤分组,必须要与 GROUP BY 连用,不能单独使用。 返回每个订单号各有多少行数 # OrderItems 表包含每个订单的每个产品\norder_num a002 a002 a002 a004 a007 【问题】编写 SQL 语句,返回每个订单号(order_num)各有多少行数(order_lines),并按 order_lines 对结果进行升序排序。\n答案:\nSELECT order_num, Count(order_num) AS order_lines FROM OrderItems GROUP BY order_num ORDER BY order_lines 知识点:\ncount(*),count(列名)都可以,区别在于,count(列名)是统计非 NULL 的行数; order by 最后执行,所以可以使用列别名; 分组聚合一定不要忘记加上 group by ,不然只会有一行结果。 每个供应商成本最低的产品 # 有 Products 表,含有字段 prod_price 代表产品价格,vend_id 代表供应商 id\nvend_id prod_price a0011 100 a0019 0.1 b0019 1000 b0019 6980 b0019 20 【问题】编写 SQL 语句,返回名为 cheapest_item 的字段,该字段包含每个供应商成本最低的产品(使用 Products 表中的 prod_price),然后从最低成本到最高成本对结果进行升序排序。\n答案:\nSELECT vend_id, Min(prod_price) AS cheapest_item FROM Products GROUP BY vend_id ORDER BY cheapest_item 返回订单数量总和不小于 100 的所有订单的订单号 # OrderItems 代表订单商品表,包括:订单号 order_num 和订单数量 quantity。\norder_num quantity a1 105 a2 1100 a2 200 a4 1121 a5 10 a2 19 a7 5 【问题】请编写 SQL 语句,返回订单数量总和不小于 100 的所有订单号,最后结果按照订单号升序排序。\n答案:\n# 直接聚合 SELECT order_num FROM OrderItems GROUP BY order_num HAVING Sum(quantity) \u0026gt;= 100 ORDER BY order_num # 子查询 SELECT a.order_num FROM (SELECT order_num, Sum(quantity) AS sum_num FROM OrderItems GROUP BY order_num HAVING sum_num \u0026gt;= 100) a ORDER BY a.order_num 知识点:\nwhere:过滤过滤指定的行,后面不能加聚合函数(分组函数)。 having:过滤分组,与 group by 连用,不能单独使用。 计算总和 # OrderItems 表代表订单信息,包括字段:订单号 order_num 和 item_price 商品售出价格、quantity 商品数量。\norder_num item_price quantity a1 10 105 a2 1 1100 a2 1 200 a4 2 1121 a5 5 10 a2 1 19 a7 7 5 【问题】编写 SQL 语句,根据订单号聚合,返回订单总价不小于 1000 的所有订单号,最后的结果按订单号进行升序排序。\n提示:总价 = item_price 乘以 quantity\n答案:\nSELECT order_num, Sum(item_price * quantity) AS total_price FROM OrderItems GROUP BY order_num HAVING total_price \u0026gt;= 1000 ORDER BY order_num 检查 SQL 语句 # OrderItems 表含有 order_num 订单号\norder_num a002 a002 a002 a004 a007 【问题】将下面代码修改正确后执行\nSELECT order_num, COUNT(*) AS items FROM OrderItems GROUP BY items HAVING COUNT(*) \u0026gt;= 3 ORDER BY items, order_num; 修改后:\nSELECT order_num, COUNT(*) AS items FROM OrderItems GROUP BY order_num HAVING items \u0026gt;= 3 ORDER BY items, order_num; 使用子查询 # 子查询是嵌套在较大查询中的 SQL 查询,也称内部查询或内部选择,包含子查询的语句也称为外部查询或外部选择。简单来说,子查询就是指将一个 SELECT 查询(子查询)的结果作为另一个 SQL 语句(主查询)的数据来源或者判断条件。\n子查询可以嵌入 SELECT、INSERT、UPDATE 和 DELETE 语句中,也可以和 =、\u0026lt;、\u0026gt;、IN、BETWEEN、EXISTS 等运算符一起使用。\n子查询常用在 WHERE 子句和 FROM 子句后边:\n当用于 WHERE 子句时,根据不同的运算符,子查询可以返回单行单列、多行单列、单行多列数据。子查询就是要返回能够作为 WHERE 子句查询条件的值。 当用于 FROM 子句时,一般返回多行多列数据,相当于返回一张临时表,这样才符合 FROM 后面是表的规则。这种做法能够实现多表联合查询。 注意:MySQL 数据库从 4.1 版本才开始支持子查询,早期版本是不支持的。\n用于 WHERE 子句的子查询的基本语法如下:\nSELECT column_name [, column_name ] FROM table1 [, table2 ] WHERE column_name operator (SELECT column_name [, column_name ] FROM table1 [, table2 ] [WHERE]) 子查询需要放在括号( )内。 operator 表示用于 WHERE 子句的运算符,可以是比较运算符(如 =, \u0026lt;, \u0026gt;, \u0026lt;\u0026gt; 等)或逻辑运算符(如 IN, NOT IN, EXISTS, NOT EXISTS 等),具体根据需求来确定。 用于 FROM 子句的子查询的基本语法如下:\nSELECT column_name [, column_name ] FROM (SELECT column_name [, column_name ] FROM table1 [, table2 ] [WHERE]) AS temp_table_name [, ...] [JOIN type JOIN table_name ON condition] WHERE condition; 用于 FROM 的子查询返回的结果相当于一张临时表,所以需要使用 AS 关键字为该临时表起一个名字。 子查询需要放在括号 ( ) 内。 可以指定多个临时表名,并使用 JOIN 语句连接这些表。 返回购买价格为 10 美元或以上产品的顾客列表 # OrderItems 表示订单商品表,含有字段订单号:order_num、订单价格:item_price;Orders 表代表订单信息表,含有顾客 id:cust_id 和订单号:order_num\nOrderItems 表:\norder_num item_price a1 10 a2 1 a2 1 a4 2 a5 5 a2 1 a7 7 Orders 表:\norder_num cust_id a1 cust10 a2 cust1 a2 cust1 a4 cust2 a5 cust5 a2 cust1 a7 cust7 【问题】使用子查询,返回购买价格为 10 美元或以上产品的顾客列表,结果无需排序。\n答案:\nSELECT cust_id FROM Orders WHERE order_num IN (SELECT DISTINCT order_num FROM OrderItems where item_price \u0026gt;= 10) 确定哪些订单购买了 prod_id 为 BR01 的产品(一) # 表 OrderItems 代表订单商品信息表,prod_id 为产品 id;Orders 表代表订单表有 cust_id 代表顾客 id 和订单日期 order_date\nOrderItems 表:\nprod_id order_num BR01 a0001 BR01 a0002 BR02 a0003 BR02 a0013 Orders 表:\norder_num cust_id order_date a0001 cust10 2022-01-01 00:00:00 a0002 cust1 2022-01-01 00:01:00 a0003 cust1 2022-01-02 00:00:00 a0013 cust2 2022-01-01 00:20:00 【问题】\n编写 SQL 语句,使用子查询来确定哪些订单(在 OrderItems 中)购买了 prod_id 为 \u0026ldquo;BR01\u0026rdquo; 的产品,然后从 Orders 表中返回每个产品对应的顾客 ID(cust_id)和订单日期(order_date),按订购日期对结果进行升序排序。\n答案:\n# 写法 1:子查询 SELECT cust_id,order_date FROM Orders WHERE order_num IN (SELECT order_num FROM OrderItems WHERE prod_id = \u0026#39;BR01\u0026#39; ) ORDER BY order_date; # 写法 2: 连接表 SELECT b.cust_id, b.order_date FROM OrderItems a,Orders b WHERE a.order_num = b.order_num AND a.prod_id = \u0026#39;BR01\u0026#39; ORDER BY order_date 返回购买 prod_id 为 BR01 的产品的所有顾客的电子邮件(一) # 你想知道订购 BR01 产品的日期,有表 OrderItems 代表订单商品信息表,prod_id 为产品 id;Orders 表代表订单表有 cust_id 代表顾客 id 和订单日期 order_date;Customers 表含有 cust_email 顾客邮件和 cust_id 顾客 id\nOrderItems 表:\nprod_id order_num BR01 a0001 BR01 a0002 BR02 a0003 BR02 a0013 Orders 表:\norder_num cust_id order_date a0001 cust10 2022-01-01 00:00:00 a0002 cust1 2022-01-01 00:01:00 a0003 cust1 2022-01-02 00:00:00 a0013 cust2 2022-01-01 00:20:00 Customers 表代表顾客信息,cust_id 为顾客 id,cust_email 为顾客 email\ncust_id cust_email cust10 cust10@cust.com cust1 cust1@cust.com cust2 cust2@cust.com 【问题】返回购买 prod_id 为 BR01 的产品的所有顾客的电子邮件(Customers 表中的 cust_email),结果无需排序。\n提示:这涉及 SELECT 语句,最内层的从 OrderItems 表返回 order_num,中间的从 Customers 表返回 cust_id。\n答案:\n# 写法 1:子查询 SELECT cust_email FROM Customers WHERE cust_id IN (SELECT cust_id FROM Orders WHERE order_num IN (SELECT order_num FROM OrderItems WHERE prod_id = \u0026#39;BR01\u0026#39;)) # 写法 2: 连接表(inner join) SELECT c.cust_email FROM OrderItems a,Orders b,Customers c WHERE a.order_num = b.order_num AND b.cust_id = c.cust_id AND a.prod_id = \u0026#39;BR01\u0026#39; # 写法 3:连接表(left join) SELECT c.cust_email FROM Orders a LEFT JOIN OrderItems b ON a.order_num = b.order_num LEFT JOIN Customers c ON a.cust_id = c.cust_id WHERE b.prod_id = \u0026#39;BR01\u0026#39; 返回每个顾客不同订单的总金额 # 我们需要一个顾客 ID 列表,其中包含他们已订购的总金额。\nOrderItems 表代表订单信息,OrderItems 表有订单号:order_num 和商品售出价格:item_price、商品数量:quantity。\norder_num item_price quantity a0001 10 105 a0002 1 1100 a0002 1 200 a0013 2 1121 a0003 5 10 a0003 1 19 a0003 7 5 Orders 表订单号:order_num、顾客 id:cust_id\norder_num cust_id a0001 cust10 a0002 cust1 a0003 cust1 a0013 cust2 【问题】\n编写 SQL 语句,返回顾客 ID(Orders 表中的 cust_id),并使用子查询返回 total_ordered 以便返回每个顾客的订单总数,将结果按金额从大到小排序。\n答案:\n# 写法 1:子查询 SELECT o.cust_id, SUM(tb.total_ordered) AS `total_ordered` FROM (SELECT order_num, SUM(item_price * quantity) AS total_ordered FROM OrderItems GROUP BY order_num) AS tb, Orders o WHERE tb.order_num = o.order_num GROUP BY o.cust_id ORDER BY total_ordered DESC; # 写法 2:连接表 SELECT b.cust_id, Sum(a.quantity * a.item_price) AS total_ordered FROM OrderItems a,Orders b WHERE a.order_num = b.order_num GROUP BY cust_id ORDER BY total_ordered DESC 关于写法一详细介绍可以参考: issue#2402:写法 1 存在的错误以及修改方法。\n从 Products 表中检索所有的产品名称以及对应的销售总数 # Products 表中检索所有的产品名称:prod_name、产品 id:prod_id\nprod_id prod_name a0001 egg a0002 sockets a0013 coffee a0003 cola OrderItems 代表订单商品表,订单产品:prod_id、售出数量:quantity\nprod_id quantity a0001 105 a0002 1100 a0002 200 a0013 1121 a0003 10 a0003 19 a0003 5 【问题】\n编写 SQL 语句,从 Products 表中检索所有的产品名称(prod_name),以及名为 quant_sold 的计算列,其中包含所售产品的总数(在 OrderItems 表上使用子查询和 SUM(quantity) 检索)。\n答案:\n# 写法 1:子查询 SELECT p.prod_name, tb.quant_sold FROM (SELECT prod_id, Sum(quantity) AS quant_sold FROM OrderItems GROUP BY prod_id) AS tb, Products p WHERE tb.prod_id = p.prod_id # 写法 2:连接表 SELECT p.prod_name, Sum(o.quantity) AS quant_sold FROM Products p, OrderItems o WHERE p.prod_id = o.prod_id GROUP BY p.prod_name(这里不能用 p.prod_id,会报错) 连接表 # JOIN 是“连接”的意思,顾名思义,SQL JOIN 子句用于将两个或者多个表联合起来进行查询。\n连接表时需要在每个表中选择一个字段,并对这些字段的值进行比较,值相同的两条记录将合并为一条。连接表的本质就是将不同表的记录合并起来,形成一张新表。当然,这张新表只是临时的,它仅存在于本次查询期间。\n使用 JOIN 连接两个表的基本语法如下:\nSELECT table1.column1, table2.column2... FROM table1 JOIN table2 ON table1.common_column1 = table2.common_column2; table1.common_column1 = table2.common_column2 是连接条件,只有满足此条件的记录才会合并为一行。您可以使用多个运算符来连接表,例如 =、\u0026gt;、\u0026lt;、\u0026lt;\u0026gt;、\u0026lt;=、\u0026gt;=、!=、between、like 或者 not,但是最常见的是使用 =。\n当两个表中有同名的字段时,为了帮助数据库引擎区分是哪个表的字段,在书写同名字段名时需要加上表名。当然,如果书写的字段名在两个表中是唯一的,也可以不使用以上格式,只写字段名即可。\n另外,如果两张表的关联字段名相同,也可以使用 USING子句来代替 ON,举个例子:\n# join....on SELECT c.cust_name, o.order_num FROM Customers c INNER JOIN Orders o ON c.cust_id = o.cust_id ORDER BY c.cust_name # 如果两张表的关联字段名相同,也可以使用USING子句:JOIN....USING() SELECT c.cust_name, o.order_num FROM Customers c INNER JOIN Orders o USING(cust_id) ORDER BY c.cust_name ON 和 WHERE 的区别:\n连接表时,SQL 会根据连接条件生成一张新的临时表。ON 就是连接条件,它决定临时表的生成。 WHERE 是在临时表生成以后,再对临时表中的数据进行过滤,生成最终的结果集,这个时候已经没有 JOIN-ON 了。 所以总结来说就是:SQL 先根据 ON 生成一张临时表,然后再根据 WHERE 对临时表进行筛选。\nSQL 允许在 JOIN 左边加上一些修饰性的关键词,从而形成不同类型的连接,如下表所示:\n连接类型 说明 INNER JOIN 内连接 (默认连接方式)只有当两个表都存在满足条件的记录时才会返回行。 LEFT JOIN / LEFT OUTER JOIN 左(外)连接 返回左表中的所有行,即使右表中没有满足条件的行也是如此。 RIGHT JOIN / RIGHT OUTER JOIN 右(外)连接 返回右表中的所有行,即使左表中没有满足条件的行也是如此。 FULL JOIN / FULL OUTER JOIN 全(外)连接 只要其中有一个表存在满足条件的记录,就返回行。 SELF JOIN 将一个表连接到自身,就像该表是两个表一样。为了区分两个表,在 SQL 语句中需要至少重命名一个表。 CROSS JOIN 交叉连接,从两个或者多个连接表中返回记录集的笛卡尔积。 下图展示了 LEFT JOIN、RIGHT JOIN、INNER JOIN、OUTER JOIN 相关的 7 种用法。\n如果不加任何修饰词,只写 JOIN,那么默认为 INNER JOIN\n对于 INNER JOIN 来说,还有一种隐式的写法,称为 “隐式内连接”,也就是没有 INNER JOIN 关键字,使用 WHERE 语句实现内连接的功能\n# 隐式内连接 SELECT c.cust_name, o.order_num FROM Customers c,Orders o WHERE c.cust_id = o.cust_id ORDER BY c.cust_name # 显式内连接 SELECT c.cust_name, o.order_num FROM Customers c INNER JOIN Orders o USING(cust_id) ORDER BY c.cust_name; 返回顾客名称和相关订单号 # Customers 表有字段顾客名称 cust_name、顾客 id cust_id\ncust_id cust_name cust10 andy cust1 ben cust2 tony cust22 tom cust221 an cust2217 hex Orders 订单信息表,含有字段 order_num 订单号、cust_id 顾客 id\norder_num cust_id a1 cust10 a2 cust1 a3 cust2 a4 cust22 a5 cust221 a7 cust2217 【问题】编写 SQL 语句,返回 Customers 表中的顾客名称(cust_name)和 Orders 表中的相关订单号(order_num),并按顾客名称再按订单号对结果进行升序排序。你可以尝试用两个不同的写法,一个使用简单的等连接语法,另外一个使用 INNER JOIN。\n答案:\n# 隐式内连接 SELECT c.cust_name, o.order_num FROM Customers c,Orders o WHERE c.cust_id = o.cust_id ORDER BY c.cust_name,o.order_num # 显式内连接 SELECT c.cust_name, o.order_num FROM Customers c INNER JOIN Orders o USING(cust_id) ORDER BY c.cust_name,o.order_num; 返回顾客名称和相关订单号以及每个订单的总价 # Customers 表有字段,顾客名称:cust_name、顾客 id:cust_id\ncust_id cust_name cust10 andy cust1 ben cust2 tony cust22 tom cust221 an cust2217 hex Orders 订单信息表,含有字段,订单号:order_num、顾客 id:cust_id\norder_num cust_id a1 cust10 a2 cust1 a3 cust2 a4 cust22 a5 cust221 a7 cust2217 OrderItems 表有字段,商品订单号:order_num、商品数量:quantity、商品价格:item_price\norder_num quantity item_price a1 1000 10 a2 200 10 a3 10 15 a4 25 50 a5 15 25 a7 7 7 【问题】除了返回顾客名称和订单号,返回 Customers 表中的顾客名称(cust_name)和 Orders 表中的相关订单号(order_num),添加第三列 OrderTotal,其中包含每个订单的总价,并按顾客名称再按订单号对结果进行升序排序。\n# 简单的等连接语法 SELECT c.cust_name, o.order_num, SUM(quantity * item_price) AS OrderTotal FROM Customers c,Orders o,OrderItems oi WHERE c.cust_id = o.cust_id AND o.order_num = oi.order_num GROUP BY c.cust_name, o.order_num ORDER BY c.cust_name, o.order_num 注意,可能有小伙伴会这样写:\nSELECT c.cust_name, o.order_num, SUM(quantity * item_price) AS OrderTotal FROM Customers c,Orders o,OrderItems oi WHERE c.cust_id = o.cust_id AND o.order_num = oi.order_num GROUP BY c.cust_name ORDER BY c.cust_name,o.order_num 这是错误的!只对 cust_name 进行聚类确实符合题意,但是不符合 GROUP BY 的语法。\nselect 语句中,如果没有 GROUP BY 语句,那么 cust_name、order_num 会返回若干个值,而 sum(quantity * item_price) 只返回一个值,通过 group by cust_name 可以让 cust_name 和 sum(quantity * item_price) 一一对应起来,或者说聚类,所以同样的,也要对 order_num 进行聚类。\n一句话,select 中的字段要么都聚类,要么都不聚类\n确定哪些订单购买了 prod_id 为 BR01 的产品(二) # 表 OrderItems 代表订单商品信息表,prod_id 为产品 id;Orders 表代表订单表有 cust_id 代表顾客 id 和订单日期 order_date\nOrderItems 表:\nprod_id order_num BR01 a0001 BR01 a0002 BR02 a0003 BR02 a0013 Orders 表:\norder_num cust_id order_date a0001 cust10 2022-01-01 00:00:00 a0002 cust1 2022-01-01 00:01:00 a0003 cust1 2022-01-02 00:00:00 a0013 cust2 2022-01-01 00:20:00 【问题】\n编写 SQL 语句,使用子查询来确定哪些订单(在 OrderItems 中)购买了 prod_id 为 \u0026ldquo;BR01\u0026rdquo; 的产品,然后从 Orders 表中返回每个产品对应的顾客 ID(cust_id)和订单日期(order_date),按订购日期对结果进行升序排序。\n提示:这一次使用连接和简单的等连接语法。\n# 写法 1:子查询 SELECT cust_id, order_date FROM Orders WHERE order_num IN (SELECT order_num FROM OrderItems WHERE prod_id = \u0026#39;BR01\u0026#39;) ORDER BY order_date # 写法 2:连接表 inner join SELECT cust_id, order_date FROM Orders o INNER JOIN (SELECT order_num FROM OrderItems WHERE prod_id = \u0026#39;BR01\u0026#39;) tb ON o.order_num = tb.order_num ORDER BY order_date # 写法 3:写法 2 的简化版 SELECT cust_id, order_date FROM Orders INNER JOIN OrderItems USING(order_num) WHERE OrderItems.prod_id = \u0026#39;BR01\u0026#39; ORDER BY order_date 返回购买 prod_id 为 BR01 的产品的所有顾客的电子邮件(二) # 有表 OrderItems 代表订单商品信息表,prod_id 为产品 id;Orders 表代表订单表有 cust_id 代表顾客 id 和订单日期 order_date;Customers 表含有 cust_email 顾客邮件和 cust_id 顾客 id\nOrderItems 表:\nprod_id order_num BR01 a0001 BR01 a0002 BR02 a0003 BR02 a0013 Orders 表:\norder_num cust_id order_date a0001 cust10 2022-01-01 00:00:00 a0002 cust1 2022-01-01 00:01:00 a0003 cust1 2022-01-02 00:00:00 a0013 cust2 2022-01-01 00:20:00 Customers 表代表顾客信息,cust_id 为顾客 id,cust_email 为顾客 email\ncust_id cust_email cust10 cust10@cust.com cust1 cust1@cust.com cust2 cust2@cust.com 【问题】返回购买 prod_id 为 BR01 的产品的所有顾客的电子邮件(Customers 表中的 cust_email),结果无需排序。\n提示:涉及到 SELECT 语句,最内层的从 OrderItems 表返回 order_num,中间的从 Customers 表返回 cust_id,但是必须使用 INNER JOIN 语法。\nSELECT cust_email FROM Customers INNER JOIN Orders using(cust_id) INNER JOIN OrderItems using(order_num) WHERE OrderItems.prod_id = \u0026#39;BR01\u0026#39; 确定最佳顾客的另一种方式(二) # OrderItems 表代表订单信息,确定最佳顾客的另一种方式是看他们花了多少钱,OrderItems 表有订单号 order_num 和 item_price 商品售出价格、quantity 商品数量\norder_num item_price quantity a1 10 105 a2 1 1100 a2 1 200 a4 2 1121 a5 5 10 a2 1 19 a7 7 5 Orders 表含有字段 order_num 订单号、cust_id 顾客 id\norder_num cust_id a1 cust10 a2 cust1 a3 cust2 a4 cust22 a5 cust221 a7 cust2217 顾客表 Customers 有字段 cust_id 客户 id、cust_name 客户姓名\ncust_id cust_name cust10 andy cust1 ben cust2 tony cust22 tom cust221 an cust2217 hex 【问题】编写 SQL 语句,返回订单总价不小于 1000 的客户名称和总额(OrderItems 表中的 order_num)。\n提示:需要计算总和(item_price 乘以 quantity)。按总额对结果进行排序,请使用 INNER JOIN语法。\nSELECT cust_name, SUM(item_price * quantity) AS total_price FROM Customers INNER JOIN Orders USING(cust_id) INNER JOIN OrderItems USING(order_num) GROUP BY cust_name HAVING total_price \u0026gt;= 1000 ORDER BY total_price 创建高级连接 # 检索每个顾客的名称和所有的订单号(一) # Customers 表代表顾客信息含有顾客 id cust_id 和 顾客名称 cust_name\ncust_id cust_name cust10 andy cust1 ben cust2 tony cust22 tom cust221 an cust2217 hex Orders 表代表订单信息含有订单号 order_num 和顾客 id cust_id\norder_num cust_id a1 cust10 a2 cust1 a3 cust2 a4 cust22 a5 cust221 a7 cust2217 【问题】使用 INNER JOIN 编写 SQL 语句,检索每个顾客的名称(Customers 表中的 cust_name)和所有的订单号(Orders 表中的 order_num),最后根据顾客姓名 cust_name 升序返回。\nSELECT cust_name, order_num FROM Customers INNER JOIN Orders USING(cust_id) ORDER BY cust_name 检索每个顾客的名称和所有的订单号(二) # Orders 表代表订单信息含有订单号 order_num 和顾客 id cust_id\norder_num cust_id a1 cust10 a2 cust1 a3 cust2 a4 cust22 a5 cust221 a7 cust2217 Customers 表代表顾客信息含有顾客 id cust_id 和 顾客名称 cust_name\ncust_id cust_name cust10 andy cust1 ben cust2 tony cust22 tom cust221 an cust2217 hex cust40 ace 【问题】检索每个顾客的名称(Customers 表中的 cust_name)和所有的订单号(Orders 表中的 order_num),列出所有的顾客,即使他们没有下过订单。最后根据顾客姓名 cust_name 升序返回。\nSELECT cust_name, order_num FROM Customers LEFT JOIN Orders USING(cust_id) ORDER BY cust_name 返回产品名称和与之相关的订单号 # Products 表为产品信息表含有字段 prod_id 产品 id、prod_name 产品名称\nprod_id prod_name a0001 egg a0002 sockets a0013 coffee a0003 cola a0023 soda OrderItems 表为订单信息表含有字段 order_num 订单号和产品 id prod_id\nprod_id order_num a0001 a105 a0002 a1100 a0002 a200 a0013 a1121 a0003 a10 a0003 a19 a0003 a5 【问题】使用外连接(left join、 right join、full join)联结 Products 表和 OrderItems 表,返回产品名称(prod_name)和与之相关的订单号(order_num)的列表,并按照产品名称升序排序。\nSELECT prod_name, order_num FROM Products LEFT JOIN OrderItems USING(prod_id) ORDER BY prod_name 返回产品名称和每一项产品的总订单数 # Products 表为产品信息表含有字段 prod_id 产品 id、prod_name 产品名称\nprod_id prod_name a0001 egg a0002 sockets a0013 coffee a0003 cola a0023 soda OrderItems 表为订单信息表含有字段 order_num 订单号和产品 id prod_id\nprod_id order_num a0001 a105 a0002 a1100 a0002 a200 a0013 a1121 a0003 a10 a0003 a19 a0003 a5 【问题】\n使用 OUTER JOIN 联结 Products 表和 OrderItems 表,返回产品名称(prod_name)和每一项产品的总订单数(不是订单号),并按产品名称升序排序。\nSELECT prod_name, COUNT(order_num) AS orders FROM Products LEFT JOIN OrderItems USING(prod_id) GROUP BY prod_name ORDER BY prod_name 列出供应商及其可供产品的数量 # 有 Vendors 表含有 vend_id (供应商 id)\nvend_id a0002 a0013 a0003 a0010 有 Products 表含有 vend_id(供应商 id)和 prod_id(供应产品 id)\nvend_id prod_id a0001 egg a0002 prod_id_iphone a00113 prod_id_tea a0003 prod_id_vivo phone a0010 prod_id_huawei phone 【问题】列出供应商(Vendors 表中的 vend_id)及其可供产品的数量,包括没有产品的供应商。你需要使用 OUTER JOIN 和 COUNT()聚合函数来计算 Products 表中每种产品的数量,最后根据 vend_id 升序排序。\n注意:vend_id 列会显示在多个表中,因此在每次引用它时都需要完全限定它。\nSELECT v.vend_id, COUNT(prod_id) AS prod_id FROM Vendors v LEFT JOIN Products p USING(vend_id) GROUP BY v.vend_id ORDER BY v.vend_id 组合查询 # UNION 运算符将两个或更多查询的结果组合起来,并生成一个结果集,其中包含来自 UNION 中参与查询的提取行。\nUNION 基本规则:\n所有查询的列数和列顺序必须相同。 每个查询中涉及表的列的数据类型必须相同或兼容。 通常返回的列名取自第一个查询。 默认地,UNION 操作符选取不同的值。如果允许重复的值,请使用 UNION ALL。\nSELECT column_name(s) FROM table1 UNION ALL SELECT column_name(s) FROM table2; UNION 结果集中的列名总是等于 UNION 中第一个 SELECT 语句中的列名。\nJOIN vs UNION:\nJOIN 中连接表的列可能不同,但在 UNION 中,所有查询的列数和列顺序必须相同。 UNION 将查询之后的行放在一起(垂直放置),但 JOIN 将查询之后的列放在一起(水平放置),即它构成一个笛卡尔积。 将两个 SELECT 语句结合起来(一) # 表 OrderItems 包含订单产品信息,字段 prod_id 代表产品 id、quantity 代表产品数量\nprod_id quantity a0001 105 a0002 100 a0002 200 a0013 1121 a0003 10 a0003 19 a0003 5 BNBG 10002 【问题】将两个 SELECT 语句结合起来,以便从 OrderItems 表中检索产品 id(prod_id)和 quantity。其中,一个 SELECT 语句过滤数量为 100 的行,另一个 SELECT 语句过滤 id 以 BNBG 开头的产品,最后按产品 id 对结果进行升序排序。\nSELECT prod_id, quantity FROM OrderItems WHERE quantity = 100 UNION SELECT prod_id, quantity FROM OrderItems WHERE prod_id LIKE \u0026#39;BNBG%\u0026#39; 将两个 SELECT 语句结合起来(二) # 表 OrderItems 包含订单产品信息,字段 prod_id 代表产品 id、quantity 代表产品数量。\nprod_id quantity a0001 105 a0002 100 a0002 200 a0013 1121 a0003 10 a0003 19 a0003 5 BNBG 10002 【问题】将两个 SELECT 语句结合起来,以便从 OrderItems 表中检索产品 id(prod_id)和 quantity。其中,一个 SELECT 语句过滤数量为 100 的行,另一个 SELECT 语句过滤 id 以 BNBG 开头的产品,最后按产品 id 对结果进行升序排序。 注意:这次仅使用单个 SELECT 语句。\n答案:\n要求只用一条 select 语句,那就用 or 不用 union 了。\nSELECT prod_id, quantity FROM OrderItems WHERE quantity = 100 OR prod_id LIKE \u0026#39;BNBG%\u0026#39; 组合 Products 表中的产品名称和 Customers 表中的顾客名称 # Products 表含有字段 prod_name 代表产品名称\nprod_name flower rice ring umbrella Customers 表代表顾客信息,cust_name 代表顾客名称\ncust_name andy ben tony tom an lee hex 【问题】编写 SQL 语句,组合 Products 表中的产品名称(prod_name)和 Customers 表中的顾客名称(cust_name)并返回,然后按产品名称对结果进行升序排序。\n# UNION 结果集中的列名总是等于 UNION 中第一个 SELECT 语句中的列名。 SELECT prod_name FROM Products UNION SELECT cust_name FROM Customers ORDER BY prod_name 检查 SQL 语句 # 表 Customers 含有字段 cust_name 顾客名、cust_contact 顾客联系方式、cust_state 顾客州、cust_email 顾客 email\ncust_name cust_contact cust_state cust_email cust10 8695192 MI cust10@cust.com cust1 8695193 MI cust1@cust.com cust2 8695194 IL cust2@cust.com 【问题】修正下面错误的 SQL\nSELECT cust_name, cust_contact, cust_email FROM Customers WHERE cust_state = \u0026#39;MI\u0026#39; ORDER BY cust_name; UNION SELECT cust_name, cust_contact, cust_email FROM Customers WHERE cust_state = \u0026#39;IL\u0026#39;ORDER BY cust_name; 修正后:\nSELECT cust_name, cust_contact, cust_email FROM Customers WHERE cust_state = \u0026#39;MI\u0026#39; UNION SELECT cust_name, cust_contact, cust_email FROM Customers WHERE cust_state = \u0026#39;IL\u0026#39; ORDER BY cust_name; 使用 union 组合查询时,只能使用一条 order by 字句,他必须位于最后一条 select 语句之后\n或者直接用 or 来做:\nSELECT cust_name, cust_contact, cust_email FROM Customers WHERE cust_state = \u0026#39;MI\u0026#39; or cust_state = \u0026#39;IL\u0026#39; ORDER BY cust_name; "},{"id":578,"href":"/zh/docs/technology/Interview/database/sql/sql-questions-02/","title":"SQL常见面试题总结(2)","section":"SQL","content":" 题目来源于: 牛客题霸 - SQL 进阶挑战\n增删改操作 # SQL 插入记录的方式汇总:\n普通插入(全字段) :INSERT INTO table_name VALUES (value1, value2, ...) 普通插入(限定字段) :INSERT INTO table_name (column1, column2, ...) VALUES (value1, value2, ...) 多条一次性插入 :INSERT INTO table_name (column1, column2, ...) VALUES (value1_1, value1_2, ...), (value2_1, value2_2, ...), ... 从另一个表导入 :INSERT INTO table_name SELECT * FROM table_name2 [WHERE key=value] 带更新的插入 :REPLACE INTO table_name VALUES (value1, value2, ...)(注意这种原理是检测到主键或唯一性索引键重复就删除原记录后重新插入) 插入记录(一) # 描述:牛客后台会记录每个用户的试卷作答记录到 exam_record 表,现在有两个用户的作答记录详情如下:\n用户 1001 在 2021 年 9 月 1 日晚上 10 点 11 分 12 秒开始作答试卷 9001,并在 50 分钟后提交,得了 90 分; 用户 1002 在 2021 年 9 月 4 日上午 7 点 1 分 2 秒开始作答试卷 9002,并在 10 分钟后退出了平台。 试卷作答记录表exam_record中,表已建好,其结构如下,请用一条语句将这两条记录插入表中。\nFiled Type Null Key Extra Default Comment id int(11) NO PRI auto_increment (NULL) 自增 ID uid int(11) NO (NULL) 用户 ID exam_id int(11) NO (NULL) 试卷 ID start_time datetime NO (NULL) 开始时间 submit_time datetime YES (NULL) 提交时间 score tinyint(4) YES (NULL) 得分 答案:\n// 存在自增主键,无需手动赋值 INSERT INTO exam_record (uid, exam_id, start_time, submit_time, score) VALUES (1001, 9001, \u0026#39;2021-09-01 22:11:12\u0026#39;, \u0026#39;2021-09-01 23:01:12\u0026#39;, 90), (1002, 9002, \u0026#39;2021-09-04 07:01:02\u0026#39;, NULL, NULL); 插入记录(二) # 描述:现有一张试卷作答记录表exam_record,结构如下表,其中包含多年来的用户作答试卷记录,由于数据越来越多,维护难度越来越大,需要对数据表内容做精简,历史数据做备份。\n表exam_record:\nFiled Type Null Key Extra Default Comment id int(11) NO PRI auto_increment (NULL) 自增 ID uid int(11) NO (NULL) 用户 ID exam_id int(11) NO (NULL) 试卷 ID start_time datetime NO (NULL) 开始时间 submit_time datetime YES (NULL) 提交时间 score tinyint(4) YES (NULL) 得分 我们已经创建了一张新表exam_record_before_2021用来备份 2021 年之前的试题作答记录,结构和exam_record表一致,请将 2021 年之前的已完成了的试题作答纪录导入到该表。\n答案:\nINSERT INTO exam_record_before_2021 (uid, exam_id, start_time, submit_time, score) SELECT uid,exam_id,start_time,submit_time,score FROM exam_record WHERE YEAR(submit_time) \u0026lt; 2021; 插入记录(三) # 描述:现在有一套 ID 为 9003 的高难度 SQL 试卷,时长为一个半小时,请你将 2021-01-01 00:00:00 作为发布时间插入到试题信息表examination_info,不管该 ID 试卷是否存在,都要插入成功,请尝试插入它。\n试题信息表examination_info:\nFiled Type Null Key Extra Default Comment id int(11) NO PRI auto_increment (NULL) 自增 ID exam_id int(11) NO UNI (NULL) 试卷 ID tag varchar(32) YES (NULL) 类别标签 difficulty varchar(8) YES (NULL) 难度 duration int(11) NO (NULL) 时长(分钟数) release_time datetime YES (NULL) 发布时间 答案:\nREPLACE INTO examination_info VALUES (NULL, 9003, \u0026#34;SQL\u0026#34;, \u0026#34;hard\u0026#34;, 90, \u0026#34;2021-01-01 00:00:00\u0026#34;); 更新记录(一) # 描述:现在有一张试卷信息表 examination_info, 表结构如下图所示:\nFiled Type Null Key Extra Default Comment id int(11) NO PRI auto_increment (NULL) 自增 ID exam_id int(11) NO UNI (NULL) 试卷 ID tag char(32) YES (NULL) 类别标签 difficulty char(8) YES (NULL) 难度 duration int(11) NO (NULL) 时长 release_time datetime YES (NULL) 发布时间 请把examination_info表中tag为PYTHON的tag字段全部修改为Python。\n思路:这题有两种解题思路,最容易想到的是直接update + where来指定条件更新,第二种就是根据要修改的字段进行查找替换\n答案一:\nUPDATE examination_info SET tag = \u0026#39;Python\u0026#39; WHERE tag=\u0026#39;PYTHON\u0026#39; 答案二:\nUPDATE examination_info SET tag = REPLACE(tag,\u0026#39;PYTHON\u0026#39;,\u0026#39;Python\u0026#39;) # REPLACE (目标字段,\u0026#34;查找内容\u0026#34;,\u0026#34;替换内容\u0026#34;) 更新记录(二) # 描述:现有一张试卷作答记录表 exam_record,其中包含多年来的用户作答试卷记录,结构如下表:作答记录表 exam_record: submit_time 为 完成时间 (注意这句话)\nFiled Type Null Key Extra Default Comment id int(11) NO PRI auto_increment (NULL) 自增 ID uid int(11) NO (NULL) 用户 ID exam_id int(11) NO (NULL) 试卷 ID start_time datetime NO (NULL) 开始时间 submit_time datetime YES (NULL) 提交时间 score tinyint(4) YES (NULL) 得分 题目要求:请把 exam_record 表中 2021 年 9 月 1 日之前开始作答的未完成记录全部改为被动完成,即:将完成时间改为'2099-01-01 00:00:00\u0026rsquo;,分数改为 0。\n思路:注意题干中的关键字(已经高亮) \u0026quot; xxx 时间 \u0026quot;之前这个条件, 那么这里马上就要想到要进行时间的比较 可以直接 xxx_time \u0026lt; \u0026quot;2021-09-01 00:00:00\u0026quot;, 也可以采用date()函数来进行比较;第二个条件就是 \u0026quot;未完成\u0026quot;, 即完成时间为 NULL,也就是题目中的提交时间 \u0026mdash;\u0026ndash; submit_time 为 NULL。\n答案:\nUPDATE exam_record SET submit_time = \u0026#39;2099-01-01 00:00:00\u0026#39;, score = 0 WHERE DATE(start_time) \u0026lt; \u0026#34;2021-09-01\u0026#34; AND submit_time IS null 删除记录(一) # 描述:现有一张试卷作答记录表 exam_record,其中包含多年来的用户作答试卷记录,结构如下表:\n作答记录表exam_record: start_time 是试卷开始时间submit_time 是交卷,即结束时间。\nFiled Type Null Key Extra Default Comment id int(11) NO PRI auto_increment (NULL) 自增 ID uid int(11) NO (NULL) 用户 ID exam_id int(11) NO (NULL) 试卷 ID start_time datetime NO (NULL) 开始时间 submit_time datetime YES (NULL) 提交时间 score tinyint(4) YES (NULL) 得分 要求:请删除exam_record表中作答时间小于 5 分钟整且分数不及格(及格线为 60 分)的记录;\n思路:这一题虽然是练习删除,仔细看确是考察对时间函数的用法,这里提及的分钟数比较,常用的函数有 TIMEDIFF和TIMESTAMPDIFF ,两者用法稍有区别,后者更为灵活,这都是看个人习惯。\n1. TIMEDIFF:两个时间之间的差值\nTIMEDIFF(time1, time2) 两者参数都是必须的,都是一个时间或者日期时间表达式。如果指定的参数不合法或者是 NULL,那么函数将返回 NULL。\n对于这题而言,可以用在 minute 函数里面,因为 TIMEDIFF 计算出来的是时间的差值,在外面套一个 MINUTE 函数,计算出来的就是分钟数。\nTIMESTAMPDIFF:用于计算两个日期的时间差 TIMESTAMPDIFF(unit,datetime_expr1,datetime_expr2) # 参数说明 #unit: 日期比较返回的时间差单位,常用可选值如下: SECOND:秒 MINUTE:分钟 HOUR:小时 DAY:天 WEEK:星期 MONTH:月 QUARTER:季度 YEAR:年 # TIMESTAMPDIFF函数返回datetime_expr2 - datetime_expr1的结果(人话: 后面的 - 前面的 即2-1),其中datetime_expr1和datetime_expr2可以是DATE或DATETIME类型值(人话:可以是“2023-01-01”, 也可以是“2023-01-01- 00:00:00”) 这题需要进行分钟的比较,那么就是 TIMESTAMPDIFF(MINUTE, 开始时间, 结束时间) \u0026lt; 5\n答案:\nDELETE FROM exam_record WHERE MINUTE (TIMEDIFF(submit_time , start_time)) \u0026lt; 5 AND score \u0026lt; 60 DELETE FROM exam_record WHERE TIMESTAMPDIFF(MINUTE, start_time, submit_time) \u0026lt; 5 AND score \u0026lt; 60 删除记录(二) # 描述:现有一张试卷作答记录表exam_record,其中包含多年来的用户作答试卷记录,结构如下表:\n作答记录表exam_record:start_time 是试卷开始时间,submit_time 是交卷时间,即结束时间,如果未完成的话,则为空。\nFiled Type Null Key Extra Default Comment id int(11) NO PRI auto_increment (NULL) 自增 ID uid int(11) NO (NULL) 用户 ID exam_id int(11) NO (NULL) 试卷 ID start_time datetime NO (NULL) 开始时间 submit_time datetime YES (NULL) 提交时间 score tinyint(4) YES (NULL) 分数 要求:请删除exam_record表中未完成作答或作答时间小于 5 分钟整的记录中,开始作答时间最早的 3 条记录。\n思路:这题比较简单,但是要注意题干中给出的信息,结束时间,如果未完成的话,则为空,这个其实就是一个条件\n还有一个条件就是小于 5 分钟,跟上题类似,但是这里是或,即两个条件满足一个就行;另外就是稍微考察到了排序和 limit 的用法。\n答案:\nDELETE FROM exam_record WHERE submit_time IS null OR TIMESTAMPDIFF(MINUTE, start_time, submit_time) \u0026lt; 5 ORDER BY start_time LIMIT 3 # 默认就是asc, desc是降序排列 删除记录(三) # 描述:现有一张试卷作答记录表 exam_record,其中包含多年来的用户作答试卷记录,结构如下表:\nFiled Type Null Key Extra Default Comment id int(11) NO PRI auto_increment (NULL) 自增 ID uid int(11) NO (NULL) 用户 ID exam_id int(11) NO (NULL) 试卷 ID start_time datetime NO (NULL) 开始时间 submit_time datetime YES (NULL) 提交时间 score tinyint(4) YES (NULL) 分数 要求:请删除exam_record表中所有记录,并重置自增主键\n思路:这题考察对三种删除语句的区别,注意高亮部分,要求重置主键;\nDROP: 清空表,删除表结构,不可逆 TRUNCATE: 格式化表,不删除表结构,不可逆 DELETE:删除数据,可逆 这里选用TRUNCATE的原因是:TRUNCATE 只能作用于表;TRUNCATE会清空表中的所有行,但表结构及其约束、索引等保持不变;TRUNCATE会重置表的自增值;使用TRUNCATE后会使表和索引所占用的空间会恢复到初始大小。\n这题也可以采用DELETE来做,但是在删除后,还需要手动ALTER表结构来设置主键初始值;\n同理也可以采用DROP来做,直接删除整张表,包括表结构,然后再新建表即可。\n答案:\nTRUNCATE exam_record; 表与索引操作 # 创建一张新表 # 描述:现有一张用户信息表,其中包含多年来在平台注册过的用户信息,随着牛客平台的不断壮大,用户量飞速增长,为了高效地为高活跃用户提供服务,现需要将部分用户拆分出一张新表。\n原来的用户信息表:\nFiled Type Null Key Default Extra Comment id int(11) NO PRI (NULL) auto_increment 自增 ID uid int(11) NO UNI (NULL) 用户 ID nick_name varchar(64) YES (NULL) 昵称 achievement int(11) YES 0 成就值 level int(11) YES (NULL) 用户等级 job varchar(32) YES (NULL) 职业方向 register_time datetime YES CURRENT_TIMESTAMP 注册时间 作为数据分析师,请创建一张优质用户信息表 user_info_vip,表结构和用户信息表一致。\n你应该返回的输出如下表格所示,请写出建表语句将表格中所有限制和说明记录到表里。\nFiled Type Null Key Default Extra Comment id int(11) NO PRI (NULL) auto_increment 自增 ID uid int(11) NO UNI (NULL) 用户 ID nick_name varchar(64) YES (NULL) 昵称 achievement int(11) YES 0 成就值 level int(11) YES (NULL) 用户等级 job varchar(32) YES (NULL) 职业方向 register_time datetime YES CURRENT_TIMESTAMP 注册时间 思路:如果这题给出了旧表的名称,可直接create table 新表 as select * from 旧表; 但是这题并没有给出旧表名称,所以需要自己创建,注意默认值和键的创建即可,比较简单。(注意:如果是在牛客网上面执行,请注意 comment 中要和题目中的 comment 保持一致,包括大小写,否则不通过,还有字符也要设置)\n答案:\nCREATE TABLE IF NOT EXISTS user_info_vip( id INT(11) PRIMARY KEY AUTO_INCREMENT COMMENT\u0026#39;自增ID\u0026#39;, uid INT(11) UNIQUE NOT NULL COMMENT \u0026#39;用户ID\u0026#39;, nick_name VARCHAR(64) COMMENT\u0026#39;昵称\u0026#39;, achievement INT(11) DEFAULT 0 COMMENT \u0026#39;成就值\u0026#39;, `level` INT(11) COMMENT \u0026#39;用户等级\u0026#39;, job VARCHAR(32) COMMENT \u0026#39;职业方向\u0026#39;, register_time DATETIME DEFAULT CURRENT_TIMESTAMP COMMENT \u0026#39;注册时间\u0026#39; )CHARACTER SET UTF8 修改表 # 描述: 现有一张用户信息表user_info,其中包含多年来在平台注册过的用户信息。\n用户信息表 user_info:\nFiled Type Null Key Default Extra Comment id int(11) NO PRI (NULL) auto_increment 自增 ID uid int(11) NO UNI (NULL) 用户 ID nick_name varchar(64) YES (NULL) 昵称 achievement int(11) YES 0 成就值 level int(11) YES (NULL) 用户等级 job varchar(32) YES (NULL) 职业方向 register_time datetime YES CURRENT_TIMESTAMP 注册时间 ==要求:==请在用户信息表,字段 level 的后面增加一列最多可保存 15 个汉字的字段 school;并将表中 job 列名改为 profession,同时 varchar 字段长度变为 10;achievement 的默认值设置为 0。\n思路:首先做这题之前,需要了解 ALTER 语句的基本用法:\n添加一列:ALTER TABLE 表名 ADD COLUMN 列名 类型 【first | after 字段名】;(first : 在某列之前添加,after 反之) 修改列的类型或约束:ALTER TABLE 表名 MODIFY COLUMN 列名 新类型 【新约束】; 修改列名:ALTER TABLE 表名 change COLUMN 旧列名 新列名 类型; 删除列:ALTER TABLE 表名 drop COLUMN 列名; 修改表名:ALTER TABLE 表名 rename 【to】 新表名; 将某一列放到第一列:ALTER TABLE 表名 MODIFY COLUMN 列名 类型 first; COLUMN 关键字其实可以省略不写,这里基于规范还是罗列出来了。\n在修改时,如果有多个修改项,可以写到一起,但要注意格式\n答案:\nALTER TABLE user_info ADD school VARCHAR(15) AFTER level, CHANGE job profession VARCHAR(10), MODIFY achievement INT(11) DEFAULT 0; 删除表 # 描述:现有一张试卷作答记录表 exam_record,其中包含多年来的用户作答试卷记录。一般每年都会为 exam_record 表建立一张备份表 exam_record_{YEAR},{YEAR} 为对应年份。\n现在随着数据越来越多,存储告急,请你把很久前的(2011 到 2014 年)备份表都删掉(如果存在的话)。\n思路:这题很简单,直接删就行,如果嫌麻烦,可以将要删除的表用逗号隔开,写到一行;这里肯定会有小伙伴问:如果要删除很多张表呢?放心,如果要删除很多张表,可以写脚本来进行删除。\n答案:\nDROP TABLE IF EXISTS exam_record_2011; DROP TABLE IF EXISTS exam_record_2012; DROP TABLE IF EXISTS exam_record_2013; DROP TABLE IF EXISTS exam_record_2014; 创建索引 # 描述:现有一张试卷信息表 examination_info,其中包含各种类型试卷的信息。为了对表更方便快捷地查询,需要在 examination_info 表创建以下索引,\n规则如下:在 duration 列创建普通索引 idx_duration、在 exam_id 列创建唯一性索引 uniq_idx_exam_id、在 tag 列创建全文索引 full_idx_tag。\n根据题意,将返回如下结果:\nexamination_info 0 PRIMARY 1 id A 0 BTREE examination_info 0 uniq_idx_exam_id 1 exam_id A 0 YES BTREE examination_info 1 idx_duration 1 duration A 0 BTREE examination_info 1 full_idx_tag 1 tag 0 YES FULLTEXT 备注:后台会通过 SHOW INDEX FROM examination_info 语句来对比输出结果\n思路:做这题首先需要了解常见的索引类型:\nB-Tree 索引:B-Tree(或称为平衡树)索引是最常见和默认的索引类型。它适用于各种查询条件,可以快速定位到符合条件的数据。B-Tree 索引适用于普通的查找操作,支持等值查询、范围查询和排序。 唯一索引:唯一索引与普通的 B-Tree 索引类似,不同之处在于它要求被索引的列的值是唯一的。这意味着在插入或更新数据时,MySQL 会验证索引列的唯一性。 主键索引:主键索引是一种特殊的唯一索引,它用于唯一标识表中的每一行数据。每个表只能有一个主键索引,它可以帮助提高数据的访问速度和数据完整性。 全文索引:全文索引用于在文本数据中进行全文搜索。它支持在文本字段中进行关键字搜索,而不仅仅是简单的等值或范围查找。全文索引适用于需要进行全文搜索的应用场景。 -- 示例: -- 添加B-Tree索引: CREATE INDEX idx_name(索引名) ON 表名 (字段名); -- idx_name为索引名,以下都是 -- 创建唯一索引: CREATE UNIQUE INDEX idx_name ON 表名 (字段名); -- 创建一个主键索引: ALTER TABLE 表名 ADD PRIMARY KEY (字段名); -- 创建一个全文索引 ALTER TABLE 表名 ADD FULLTEXT INDEX idx_name (字段名); -- 通过以上示例,可以看出create 和 alter 都可以添加索引 有了以上的基础知识之后,该题答案也就浮出水面了。\n答案:\nALTER TABLE examination_info ADD INDEX idx_duration(duration), ADD UNIQUE INDEX uniq_idx_exam_id(exam_id), ADD FULLTEXT INDEX full_idx_tag(tag); 删除索引 # 描述:请删除examination_info表上的唯一索引 uniq_idx_exam_id 和全文索引 full_idx_tag。\n思路:该题考察删除索引的基本语法:\n-- 使用 DROP INDEX 删除索引 DROP INDEX idx_name ON 表名; -- 使用 ALTER TABLE 删除索引 ALTER TABLE employees DROP INDEX idx_email; 这里需要注意的是:在 MySQL 中,一次删除多个索引的操作是不支持的。每次删除索引时,只能指定一个索引名称进行删除。\n而且 DROP 命令需要慎用!!!\n答案:\nDROP INDEX uniq_idx_exam_id ON examination_info; DROP INDEX full_idx_tag ON examination_info; "},{"id":579,"href":"/zh/docs/technology/Interview/database/sql/sql-questions-03/","title":"SQL常见面试题总结(3)","section":"SQL","content":" 题目来源于: 牛客题霸 - SQL 进阶挑战\n较难或者困难的题目可以根据自身实际情况和面试需要来决定是否要跳过。\n聚合函数 # SQL 类别高难度试卷得分的截断平均值(较难) # 描述: 牛客的运营同学想要查看大家在 SQL 类别中高难度试卷的得分情况。\n请你帮她从exam_record数据表中计算所有用户完成 SQL 类别高难度试卷得分的截断平均值(去掉一个最大值和一个最小值后的平均值)。\n示例数据:examination_info(exam_id 试卷 ID, tag 试卷类别, difficulty 试卷难度, duration 考试时长, release_time 发布时间)\nid exam_id tag difficulty duration release_time 1 9001 SQL hard 60 2020-01-01 10:00:00 2 9002 算法 medium 80 2020-08-02 10:00:00 示例数据:exam_record(uid 用户 ID, exam_id 试卷 ID, start_time 开始作答时间, submit_time 交卷时间, score 得分)\nid uid exam_id start_time submit_time score 1 1001 9001 2020-01-02 09:01:01 2020-01-02 09:21:01 80 2 1001 9001 2021-05-02 10:01:01 2021-05-02 10:30:01 81 3 1001 9001 2021-06-02 19:01:01 2021-06-02 19:31:01 84 4 1001 9002 2021-09-05 19:01:01 2021-09-05 19:40:01 89 5 1001 9001 2021-09-02 12:01:01 (NULL) (NULL) 6 1001 9002 2021-09-01 12:01:01 (NULL) (NULL) 7 1002 9002 2021-02-02 19:01:01 2021-02-02 19:30:01 87 8 1002 9001 2021-05-05 18:01:01 2021-05-05 18:59:02 90 9 1003 9001 2021-09-07 12:01:01 2021-09-07 10:31:01 50 10 1004 9001 2021-09-06 10:01:01 (NULL) (NULL) 根据输入你的查询结果如下:\ntag difficulty clip_avg_score SQL hard 81.7 从examination_info表可知,试卷 9001 为高难度 SQL 试卷,该试卷被作答的得分有[80,81,84,90,50],去除最高分和最低分后为[80,81,84],平均分为 81.6666667,保留一位小数后为 81.7\n输入描述:\n输入数据中至少有 3 个有效分数\n思路一: 要找出高难度 sql 试卷,肯定需要联 examination_info 这张表,然后找出高难度的课程,由 examination_info 得知,高难度 sql 的 exam_id 为 9001,那么等下就以 exam_id = 9001 作为条件去查询;\n先找出 9001 号考试 select * from exam_record where exam_id = 9001\n然后,找出最高分 select max(score) 最高分 from exam_record where exam_id = 9001\n接着,找出最低分 select min(score) 最低分 from exam_record where exam_id = 9001\n在查询出来的分数结果集当中,去掉最高分和最低分,最直观能想到的就是 NOT IN 或者 用 NOT EXISTS 也行,这里以 NOT IN 来做\n首先将主体写出来select tag, difficulty, round(avg(score), 1) clip_avg_score from examination_info info INNER JOIN exam_record record\n小 tips : MYSQL 的 ROUND() 函数 ,ROUND(X)返回参数 X 最近似的整数 ROUND(X,D)返回 X ,其值保留到小数点后 D 位,第 D 位的保留方式为四舍五入。\n再将上面的 \u0026ldquo;碎片\u0026rdquo; 语句拼凑起来即可, 注意在 NOT IN 中两个子查询用 UNION ALL 来关联,用 union 把 max 和 min 的结果集中在一行当中,这样形成一列多行的效果。\n答案一:\nSELECT tag, difficulty, ROUND(AVG(score), 1) clip_avg_score FROM examination_info info INNER JOIN exam_record record WHERE info.exam_id = record.exam_id AND record.exam_id = 9001 AND record.score NOT IN( SELECT MAX(score) FROM exam_record WHERE exam_id = 9001 UNION ALL SELECT MIN(score) FROM exam_record WHERE exam_id = 9001 ) 这是最直观,也是最容易想到的解法,但是还有待改进,这算是投机取巧过关,其实严格按照题目要求应该这么写:\nSELECT tag, difficulty, ROUND(AVG(score), 1) clip_avg_score FROM examination_info info INNER JOIN exam_record record WHERE info.exam_id = record.exam_id AND record.exam_id = (SELECT examination_info.exam_id FROM examination_info WHERE tag = \u0026#39;SQL\u0026#39; AND difficulty = \u0026#39;hard\u0026#39; ) AND record.score NOT IN (SELECT MAX(score) FROM exam_record WHERE exam_id = (SELECT examination_info.exam_id FROM examination_info WHERE tag = \u0026#39;SQL\u0026#39; AND difficulty = \u0026#39;hard\u0026#39; ) UNION ALL SELECT MIN(score) FROM exam_record WHERE exam_id = (SELECT examination_info.exam_id FROM examination_info WHERE tag = \u0026#39;SQL\u0026#39; AND difficulty = \u0026#39;hard\u0026#39; ) ) 然而你会发现,重复的语句非常多,所以可以利用WITH来抽取公共部分\nWITH 子句介绍:\nWITH 子句,也称为公共表表达式(Common Table Expression,CTE),是在 SQL 查询中定义临时表的方式。它可以让我们在查询中创建一个临时命名的结果集,并且可以在同一查询中引用该结果集。\n基本用法:\nWITH cte_name (column1, column2, ..., columnN) AS ( -- 查询体 SELECT ... FROM ... WHERE ... ) -- 主查询 SELECT ... FROM cte_name WHERE ... WITH 子句由以下几个部分组成:\ncte_name: 给临时表起一个名称,可以在主查询中引用。 (column1, column2, ..., columnN): 可选,指定临时表的列名。 AS: 必需,表示开始定义临时表。 CTE 查询体: 实际的查询语句,用于定义临时表中的数据。 WITH 子句的主要用途之一是增强查询的可读性和可维护性,尤其在涉及多个嵌套子查询或需要重复使用相同的查询逻辑时。通过将这些逻辑放在一个命名的临时表中,我们可以更清晰地组织查询,并消除重复代码。\n此外,WITH 子句还可以在复杂的查询中实现递归查询。递归查询允许我们在单个查询中执行对同一表的多次迭代,逐步构建结果集。这在处理层次结构数据、组织结构和树状结构等场景中非常有用。\n小细节:MySQL 5.7 版本以及之前的版本不支持在 WITH 子句中直接使用别名。\n下面是改进后的答案:\nWITH t1 AS (SELECT record.*, info.tag, info.difficulty FROM exam_record record INNER JOIN examination_info info ON record.exam_id = info.exam_id WHERE info.tag = \u0026#34;SQL\u0026#34; AND info.difficulty = \u0026#34;hard\u0026#34; ) SELECT tag, difficulty, ROUND(AVG(score), 1) FROM t1 WHERE score NOT IN (SELECT max(score) FROM t1 UNION SELECT min(score) FROM t1) 思路二:\n筛选 SQL 高难度试卷:where tag=\u0026quot;SQL\u0026quot; and difficulty=\u0026quot;hard\u0026quot; 计算截断平均值:(和-最大值-最小值) / (总个数-2): (sum(score) - max(score) - min(score)) / (count(score) - 2) 有一个缺点就是,如果最大值和最小值有多个,这个方法就很难筛选出来, 但是题目中说了\u0026mdash;\u0026ndash;\u0026gt;去掉一个最大值和一个最小值后的平均值, 所以这里可以用这个公式。 答案二:\nSELECT info.tag, info.difficulty, ROUND((SUM(record.score)- MIN(record.score)- MAX(record.score)) / (COUNT(record.score)- 2), 1) AS clip_avg_score FROM examination_info info, exam_record record WHERE info.exam_id = record.exam_id AND info.tag = \u0026#34;SQL\u0026#34; AND info.difficulty = \u0026#34;hard\u0026#34;; 统计作答次数 # 有一个试卷作答记录表 exam_record,请从中统计出总作答次数 total_pv、试卷已完成作答数 complete_pv、已完成的试卷数 complete_exam_cnt。\n示例数据 exam_record 表(uid 用户 ID, exam_id 试卷 ID, start_time 开始作答时间, submit_time 交卷时间, score 得分):\nid uid exam_id start_time submit_time score 1 1001 9001 2020-01-02 09:01:01 2020-01-02 09:21:01 80 2 1001 9001 2021-05-02 10:01:01 2021-05-02 10:30:01 81 3 1001 9001 2021-06-02 19:01:01 2021-06-02 19:31:01 84 4 1001 9002 2021-09-05 19:01:01 2021-09-05 19:40:01 89 5 1001 9001 2021-09-02 12:01:01 (NULL) (NULL) 6 1001 9002 2021-09-01 12:01:01 (NULL) (NULL) 7 1002 9002 2021-02-02 19:01:01 2021-02-02 19:30:01 87 8 1002 9001 2021-05-05 18:01:01 2021-05-05 18:59:02 90 9 1003 9001 2021-09-07 12:01:01 2021-09-07 10:31:01 50 10 1004 9001 2021-09-06 10:01:01 (NULL) (NULL) 示例输出:\ntotal_pv complete_pv complete_exam_cnt 10 7 2 解释:表示截止当前,有 10 次试卷作答记录,已完成的作答次数为 7 次(中途退出的为未完成状态,其交卷时间和份数为 NULL),已完成的试卷有 9001 和 9002 两份。\n思路: 这题一看到统计次数,肯定第一时间就要想到用COUNT这个函数来解决,问题是要统计不同的记录,该怎么来写?使用子查询就能解决这个题目(这题用 case when 也能写出来,解法类似,逻辑不同而已);首先在做这个题之前,让我们先来了解一下COUNT的基本用法;\nCOUNT() 函数的基本语法如下所示:\nCOUNT(expression) 其中,expression 可以是列名、表达式、常量或通配符。下面是一些常见的用法示例:\n计算表中所有行的数量: SELECT COUNT(*) FROM table_name; 计算特定列非空(不为 NULL)值的数量: SELECT COUNT(column_name) FROM table_name; 计算满足条件的行数: SELECT COUNT(*) FROM table_name WHERE condition; 结合 GROUP BY 使用,计算分组后每个组的行数: SELECT column_name, COUNT(*) FROM table_name GROUP BY column_name; 计算不同列组合的唯一组合数: SELECT COUNT(DISTINCT column_name1, column_name2) FROM table_name; 在使用 COUNT() 函数时,如果不指定任何参数或者使用 COUNT(*),将会计算所有行的数量。而如果使用列名,则只会计算该列非空值的数量。\n另外,COUNT() 函数的结果是一个整数值。即使结果是零,也不会返回 NULL,这点需要谨记。\n答案:\nSELECT count(*) total_pv, ( SELECT count(*) FROM exam_record WHERE submit_time IS NOT NULL ) complete_pv, ( SELECT COUNT( DISTINCT exam_id, score IS NOT NULL OR NULL ) FROM exam_record ) complete_exam_cnt FROM exam_record 这里着重说一下COUNT( DISTINCT exam_id, score IS NOT NULL OR NULL )这一句,判断 score 是否为 null ,如果是即为真,如果不是返回 null;注意这里如果不加 or null 在不是 null 的情况下只会返回 false 也就是返回 0;\nCOUNT本身是不可以对多列求行数的,distinct的加入是的多列成为一个整体,可以求出现的行数了;count distinct在计算时只返回非 null 的行, 这个也要注意;\n另外通过本题 get 到了\u0026mdash;\u0026mdash;\u0026gt;count 加条件常用句式count( 列判断 or null)\n得分不小于平均分的最低分 # 描述: 请从试卷作答记录表中找到 SQL 试卷得分不小于该类试卷平均得分的用户最低得分。\n示例数据 exam_record 表(uid 用户 ID, exam_id 试卷 ID, start_time 开始作答时间, submit_time 交卷时间, score 得分):\nid uid exam_id start_time submit_time score 1 1001 9001 2020-01-02 09:01:01 2020-01-02 09:21:01 80 2 1002 9001 2021-09-05 19:01:01 2021-09-05 19:40:01 89 3 1002 9002 2021-09-02 12:01:01 (NULL) (NULL) 4 1002 9003 2021-09-01 12:01:01 (NULL) (NULL) 5 1002 9001 2021-02-02 19:01:01 2021-02-02 19:30:01 87 6 1002 9002 2021-05-05 18:01:01 2021-05-05 18:59:02 90 7 1003 9002 2021-02-06 12:01:01 (NULL) (NULL) 8 1003 9003 2021-09-07 10:01:01 2021-09-07 10:31:01 86 9 1004 9003 2021-09-06 12:01:01 (NULL) (NULL) examination_info 表(exam_id 试卷 ID, tag 试卷类别, difficulty 试卷难度, duration 考试时长, release_time 发布时间)\nid exam_id tag difficulty duration release_time 1 9001 SQL hard 60 2020-01-01 10:00:00 2 9002 SQL easy 60 2020-02-01 10:00:00 3 9003 算法 medium 80 2020-08-02 10:00:00 示例输出数据:\nmin_score_over_avg 87 解释:试卷 9001 和 9002 为 SQL 类别,作答这两份试卷的得分有[80,89,87,90],平均分为 86.5,不小于平均分的最小分数为 87\n思路:这类题目第一眼看确实很复杂, 因为不知道从哪入手,但是当我们仔细读题审题后,要学会抓住题干中的关键信息。以本题为例:请从试卷作答记录表中找到SQL试卷得分不小于该类试卷平均得分的用户最低得分。你能一眼从中提取哪些有效信息来作为解题思路?\n第一条:找到SQL试卷得分\n第二条:该类试卷平均得分\n第三条:该类试卷的用户最低得分\n然后中间的 “桥梁” 就是不小于\n将条件拆分后,先逐步完成\n-- 找出tag为‘SQL’的得分 【80, 89,87,90】 -- 再算出这一组的平均得分 select ROUND(AVG(score), 1) from examination_info info INNER JOIN exam_record record where info.exam_id = record.exam_id and tag= \u0026#39;SQL\u0026#39; 然后再找出该类试卷的最低得分,接着将结果集【80, 89,87,90】 去和平均分数作比较,方可得出最终答案。\n答案:\nSELECT MIN(score) AS min_score_over_avg FROM examination_info info INNER JOIN exam_record record WHERE info.exam_id = record.exam_id AND tag= \u0026#39;SQL\u0026#39; AND score \u0026gt;= (SELECT ROUND(AVG(score), 1) FROM examination_info info INNER JOIN exam_record record WHERE info.exam_id = record.exam_id AND tag= \u0026#39;SQL\u0026#39; ) 其实这类题目给出的要求看似很 “绕”,但其实仔细梳理一遍,将大条件拆分成小条件,逐个拆分完以后,最后将所有条件拼凑起来。反正只要记住:抓主干,理分支,问题便迎刃而解。\n分组查询 # 平均活跃天数和月活人数 # 描述:用户在牛客试卷作答区作答记录存储在表 exam_record 中,内容如下:\nexam_record 表(uid 用户 ID, exam_id 试卷 ID, start_time 开始作答时间, submit_time 交卷时间, score 得分)\nid uid exam_id start_time submit_time score 1 1001 9001 2021-07-02 09:01:01 2021-07-02 09:21:01 80 2 1002 9001 2021-09-05 19:01:01 2021-09-05 19:40:01 81 3 1002 9002 2021-09-02 12:01:01 (NULL) (NULL) 4 1002 9003 2021-09-01 12:01:01 (NULL) (NULL) 5 1002 9001 2021-07-02 19:01:01 2021-07-02 19:30:01 82 6 1002 9002 2021-07-05 18:01:01 2021-07-05 18:59:02 90 7 1003 9002 2021-07-06 12:01:01 (NULL) (NULL) 8 1003 9003 2021-09-07 10:01:01 2021-09-07 10:31:01 86 9 1004 9003 2021-09-06 12:01:01 (NULL) (NULL) 10 1002 9003 2021-09-01 12:01:01 2021-09-01 12:31:01 81 11 1005 9001 2021-09-01 12:01:01 2021-09-01 12:31:01 88 12 1006 9002 2021-09-02 12:11:01 2021-09-02 12:31:01 89 13 1007 9002 2020-09-02 12:11:01 2020-09-02 12:31:01 89 请计算 2021 年每个月里试卷作答区用户平均月活跃天数 avg_active_days 和月度活跃人数 mau,上面数据的示例输出如下:\nmonth avg_active_days mau 202107 1.50 2 202109 1.25 4 解释:2021 年 7 月有 2 人活跃,共活跃了 3 天(1001 活跃 1 天,1002 活跃 2 天),平均活跃天数 1.5;2021 年 9 月有 4 人活跃,共活跃了 5 天,平均活跃天数 1.25,结果保留 2 位小数。\n注:此处活跃指有交卷行为。\n思路:读完题先注意高亮部分;一般求天数和月活跃人数马上就要想到相关的日期函数;这一题我们同样来进行拆分,把问题细化再解决;首先求活跃人数,肯定要用到COUNT(),那这里首先就有一个坑,不知道大家注意了没有?用户 1002 在 9 月份做了两种不同的试卷,所以这里要注意去重,不然在统计的时候,活跃人数是错的;第二个就是要知道日期的格式化,如上表,题目要求以202107这种日期格式展现,要用到DATE_FORMAT来进行格式化。\n基本用法:\nDATE_FORMAT(date_value, format)\ndate_value 参数是待格式化的日期或时间值。 format 参数是指定的日期或时间格式(这个和 Java 里面的日期格式一样)。 答案:\nSELECT DATE_FORMAT(submit_time, \u0026#39;%Y%m\u0026#39;) MONTH, round(count(DISTINCT UID, DATE_FORMAT(submit_time, \u0026#39;%Y%m%d\u0026#39;)) / count(DISTINCT UID), 2) avg_active_days, COUNT(DISTINCT UID) mau FROM exam_record WHERE YEAR (submit_time) = 2021 GROUP BY MONTH 这里多说一句, 使用COUNT(DISTINCT uid, DATE_FORMAT(submit_time, '%Y%m%d')) 可以统计在 uid 列和 submit_time 列按照年份、月份和日期进行格式化后的组合值的数量。\n月总刷题数和日均刷题数 # 描述:现有一张题目练习记录表 practice_record,示例内容如下:\nid uid question_id submit_time score 1 1001 8001 2021-08-02 11:41:01 60 2 1002 8001 2021-09-02 19:30:01 50 3 1002 8001 2021-09-02 19:20:01 70 4 1002 8002 2021-09-02 19:38:01 70 5 1003 8002 2021-08-01 19:38:01 80 请从中统计出 2021 年每个月里用户的月总刷题数 month_q_cnt 和日均刷题数 avg_day_q_cnt(按月份升序排序)以及该年的总体情况,示例数据输出如下:\nsubmit_month month_q_cnt avg_day_q_cnt 202108 2 0.065 202109 3 0.100 2021 汇总 5 0.161 解释:2021 年 8 月共有 2 次刷题记录,日均刷题数为 2/31=0.065(保留 3 位小数);2021 年 9 月共有 3 次刷题记录,日均刷题数为 3/30=0.100;2021 年共有 5 次刷题记录(年度汇总平均无实际意义,这里我们按照 31 天来算 5/31=0.161)\n牛客已经采用最新的 Mysql 版本,如果您运行结果出现错误:ONLY_FULL_GROUP_BY,意思是:对于 GROUP BY 聚合操作,如果在 SELECT 中的列,没有在 GROUP BY 中出现,那么这个 SQL 是不合法的,因为列不在 GROUP BY 从句中,也就是说查出来的列必须在 group by 后面出现否则就会报错,或者这个字段出现在聚合函数里面。\n思路:\n看到实例数据就要马上联想到相关的函数,比如submit_month就要用到DATE_FORMAT来格式化日期。然后查出每月的刷题数量。\n每月的刷题数量\nSELECT MONTH ( submit_time ), COUNT( question_id ) FROM practice_record GROUP BY MONTH (submit_time) 接着第三列这里要用到DAY(LAST_DAY(date_value))函数来查找给定日期的月份中的天数。\n示例代码如下:\nSELECT DAY(LAST_DAY(\u0026#39;2023-07-08\u0026#39;)) AS days_in_month; -- 输出:31 SELECT DAY(LAST_DAY(\u0026#39;2023-02-01\u0026#39;)) AS days_in_month; -- 输出:28 (闰年中的二月份) SELECT DAY(LAST_DAY(NOW())) AS days_in_current_month; -- 输出:31 (当前月份的天数) 使用 LAST_DAY() 函数获取给定日期的当月最后一天,然后使用 DAY() 函数提取该日期的天数。这样就能获得指定月份的天数。\n需要注意的是,LAST_DAY() 函数返回的是日期值,而 DAY() 函数用于提取日期值中的天数部分。\n有了上述的分析之后,即可马上写出答案,这题复杂就复杂在处理日期上,其中的逻辑并不难。\n答案:\nSELECT DATE_FORMAT(submit_time, \u0026#39;%Y%m\u0026#39;) submit_month, count(question_id) month_q_cnt, ROUND(COUNT(question_id) / DAY (LAST_DAY(submit_time)), 3) avg_day_q_cnt FROM practice_record WHERE DATE_FORMAT(submit_time, \u0026#39;%Y\u0026#39;) = \u0026#39;2021\u0026#39; GROUP BY submit_month UNION ALL SELECT \u0026#39;2021汇总\u0026#39; AS submit_month, count(question_id) month_q_cnt, ROUND(COUNT(question_id) / 31, 3) avg_day_q_cnt FROM practice_record WHERE DATE_FORMAT(submit_time, \u0026#39;%Y\u0026#39;) = \u0026#39;2021\u0026#39; ORDER BY submit_month 在实例数据输出中因为最后一行需要得出汇总数据,所以这里要 UNION ALL加到结果集中;别忘了最后要排序!\n未完成试卷数大于 1 的有效用户(较难) # 描述:现有试卷作答记录表 exam_record(uid 用户 ID, exam_id 试卷 ID, start_time 开始作答时间, submit_time 交卷时间, score 得分),示例数据如下:\nid uid exam_id start_time submit_time score 1 1001 9001 2021-07-02 09:01:01 2021-07-02 09:21:01 80 2 1002 9001 2021-09-05 19:01:01 2021-09-05 19:40:01 81 3 1002 9002 2021-09-02 12:01:01 (NULL) (NULL) 4 1002 9003 2021-09-01 12:01:01 (NULL) (NULL) 5 1002 9001 2021-07-02 19:01:01 2021-07-02 19:30:01 82 6 1002 9002 2021-07-05 18:01:01 2021-07-05 18:59:02 90 7 1003 9002 2021-07-06 12:01:01 (NULL) (NULL) 8 1003 9003 2021-09-07 10:01:01 2021-09-07 10:31:01 86 9 1004 9003 2021-09-06 12:01:01 (NULL) (NULL) 10 1002 9003 2021-09-01 12:01:01 2021-09-01 12:31:01 81 11 1005 9001 2021-09-01 12:01:01 2021-09-01 12:31:01 88 12 1006 9002 2021-09-02 12:11:01 2021-09-02 12:31:01 89 13 1007 9002 2020-09-02 12:11:01 2020-09-02 12:31:01 89 还有一张试卷信息表 examination_info(exam_id 试卷 ID, tag 试卷类别, difficulty 试卷难度, duration 考试时长, release_time 发布时间),示例数据如下:\nid exam_id tag difficulty duration release_time 1 9001 SQL hard 60 2020-01-01 10:00:00 2 9002 SQL easy 60 2020-02-01 10:00:00 3 9003 算法 medium 80 2020-08-02 10:00:00 请统计 2021 年每个未完成试卷作答数大于 1 的有效用户的数据(有效用户指完成试卷作答数至少为 1 且未完成数小于 5),输出用户 ID、未完成试卷作答数、完成试卷作答数、作答过的试卷 tag 集合,按未完成试卷数量由多到少排序。示例数据的输出结果如下:\nuid incomplete_cnt complete_cnt detail 1002 2 4 2021-09-01:算法;2021-07-02:SQL;2021-09-02:SQL;2021-09-05:SQL;2021-07-05:SQL 解释:2021 年的作答记录中,除了 1004,其他用户均满足有效用户定义,但只有 1002 未完成试卷数大于 1,因此只输出 1002,detail 中是 1002 作答过的试卷{日期:tag}集合,日期和 tag 间用 : 连接,多元素间用 ; 连接。\n思路:\n仔细读题后,分析出:首先要联表,因为后面要输出tag;\n筛选出 2021 年的数据\nSELECT * FROM exam_record er LEFT JOIN examination_info ei ON er.exam_id = ei.exam_id WHERE YEAR (er.start_time)= 2021 根据 uid 进行分组,然后对每个用户进行条件进行判断,题目中要求完成试卷数至少为1,未完成试卷数要大于1,小于5\n那么等会儿写 sql 的时候条件应该是:未完成 \u0026gt; 1 and 已完成 \u0026gt;=1 and 未完成 \u0026lt; 5\n因为最后要用到字符串的拼接,而且还要组合拼接,这个可以用GROUP_CONCAT函数,下面简单介绍一下该函数的用法:\n基本格式:\nGROUP_CONCAT([DISTINCT] expr [ORDER BY {unsigned_integer | col_name | expr} [ASC | DESC] [, ...]] [SEPARATOR sep]) expr:要连接的列或表达式。 DISTINCT:可选参数,用于去重。当指定了 DISTINCT,相同的值只会出现一次。 ORDER BY:可选参数,用于排序连接后的值。可以选择升序 (ASC) 或降序 (DESC) 排序。 SEPARATOR sep:可选参数,用于设置连接后的值的分隔符。(本题要用这个参数设置 ; 号 ) GROUP_CONCAT() 函数常用于 GROUP BY 子句中,将一组行的值连接为一个字符串,并在结果集中以聚合的形式返回。\n答案:\nSELECT a.uid, SUM(CASE WHEN a.submit_time IS NULL THEN 1 END) AS incomplete_cnt, SUM(CASE WHEN a.submit_time IS NOT NULL THEN 1 END) AS complete_cnt, GROUP_CONCAT(DISTINCT CONCAT(DATE_FORMAT(a.start_time, \u0026#39;%Y-%m-%d\u0026#39;), \u0026#39;:\u0026#39;, b.tag) ORDER BY start_time SEPARATOR \u0026#34;;\u0026#34;) AS detail FROM exam_record a LEFT JOIN examination_info b ON a.exam_id = b.exam_id WHERE YEAR (a.start_time)= 2021 GROUP BY a.uid HAVING incomplete_cnt \u0026gt; 1 AND complete_cnt \u0026gt;= 1 AND incomplete_cnt \u0026lt; 5 ORDER BY incomplete_cnt DESC SUM(CASE WHEN a.submit_time IS NULL THEN 1 END) 统计了每个用户未完成的记录数量。 SUM(CASE WHEN a.submit_time IS NOT NULL THEN 1 END) 统计了每个用户已完成的记录数量。 GROUP_CONCAT(DISTINCT CONCAT(DATE_FORMAT(a.start_time, '%Y-%m-%d'), ':', b.tag) ORDER BY a.start_time SEPARATOR ';') 将每个用户的考试日期和标签以逗号分隔的形式连接成一个字符串,并按考试开始时间进行排序。 嵌套子查询 # 月均完成试卷数不小于 3 的用户爱作答的类别(较难) # 描述:现有试卷作答记录表 exam_record(uid:用户 ID, exam_id:试卷 ID, start_time:开始作答时间, submit_time:交卷时间,没提交的话为 NULL, score:得分),示例数据如下:\nid uid exam_id start_time submit_time score 1 1001 9001 2021-07-02 09:01:01 (NULL) (NULL) 2 1002 9003 2021-09-01 12:01:01 2021-09-01 12:21:01 60 3 1002 9002 2021-09-02 12:01:01 2021-09-02 12:31:01 70 4 1002 9001 2021-09-05 19:01:01 2021-09-05 19:40:01 81 5 1002 9002 2021-07-06 12:01:01 (NULL) (NULL) 6 1003 9003 2021-09-07 10:01:01 2021-09-07 10:31:01 86 7 1003 9003 2021-09-08 12:01:01 2021-09-08 12:11:01 40 8 1003 9001 2021-09-08 13:01:01 (NULL) (NULL) 9 1003 9002 2021-09-08 14:01:01 (NULL) (NULL) 10 1003 9003 2021-09-08 15:01:01 (NULL) (NULL) 11 1005 9001 2021-09-01 12:01:01 2021-09-01 12:31:01 88 12 1005 9002 2021-09-01 12:01:01 2021-09-01 12:31:01 88 13 1005 9002 2021-09-02 12:11:01 2021-09-02 12:31:01 89 试卷信息表 examination_info(exam_id:试卷 ID, tag:试卷类别, difficulty:试卷难度, duration:考试时长, release_time:发布时间),示例数据如下:\nid exam_id tag difficulty duration release_time 1 9001 SQL hard 60 2020-01-01 10:00:00 2 9002 C++ easy 60 2020-02-01 10:00:00 3 9003 算法 medium 80 2020-08-02 10:00:00 请从表中统计出 “当月均完成试卷数”不小于 3 的用户们爱作答的类别及作答次数,按次数降序输出,示例输出如下:\ntag tag_cnt C++ 4 SQL 2 算法 1 解释:用户 1002 和 1005 在 2021 年 09 月的完成试卷数目均为 3,其他用户均小于 3;然后用户 1002 和 1005 作答过的试卷 tag 分布结果按作答次数降序排序依次为 C++、SQL、算法。\n思路:这题考察联合子查询,重点在于月均回答\u0026gt;=3, 但是个人认为这里没有表述清楚,应该直接说查 9 月的就容易理解多了;这里不是每个月都要\u0026gt;=3 或者是所有答题次数/答题月份。不要理解错误了。\n先查询出哪些用户月均答题大于三次\nSELECT UID FROM exam_record record GROUP BY UID, MONTH (start_time) HAVING count(submit_time) \u0026gt;= 3 有了这一步之后再进行深入,只要能理解上一步(我的意思是不被题目中的月均所困扰),然后再套一个子查询,查哪些用户包含其中,然后查出题目中所需的列即可。记得排序!!\nSELECT tag, count(start_time) AS tag_cnt FROM exam_record record INNER JOIN examination_info info ON record.exam_id = info.exam_id WHERE UID IN (SELECT UID FROM exam_record record GROUP BY UID, MONTH (start_time) HAVING count(submit_time) \u0026gt;= 3) GROUP BY tag ORDER BY tag_cnt DESC 试卷发布当天作答人数和平均分 # 描述:现有用户信息表 user_info(uid 用户 ID,nick_name 昵称, achievement 成就值, level 等级, job 职业方向, register_time 注册时间),示例数据如下:\nid uid nick_name achievement level job register_time 1 1001 牛客 1 号 3100 7 算法 2020-01-01 10:00:00 2 1002 牛客 2 号 2100 6 算法 2020-01-01 10:00:00 3 1003 牛客 3 号 1500 5 算法 2020-01-01 10:00:00 4 1004 牛客 4 号 1100 4 算法 2020-01-01 10:00:00 5 1005 牛客 5 号 1600 6 C++ 2020-01-01 10:00:00 6 1006 牛客 6 号 3000 6 C++ 2020-01-01 10:00:00 释义:用户 1001 昵称为牛客 1 号,成就值为 3100,用户等级是 7 级,职业方向为算法,注册时间 2020-01-01 10:00:00\n试卷信息表 examination_info(exam_id 试卷 ID, tag 试卷类别, difficulty 试卷难度, duration 考试时长, release_time 发布时间) 示例数据如下:\nid exam_id tag difficulty duration release_time 1 9001 SQL hard 60 2021-09-01 06:00:00 2 9002 C++ easy 60 2020-02-01 10:00:00 3 9003 算法 medium 80 2020-08-02 10:00:00 试卷作答记录表 exam_record(uid 用户 ID, exam_id 试卷 ID, start_time 开始作答时间, submit_time 交卷时间, score 得分) 示例数据如下:\nid uid exam_id start_time submit_time score 1 1001 9001 2021-07-02 09:01:01 2021-09-01 09:41:01 70 2 1002 9003 2021-09-01 12:01:01 2021-09-01 12:21:01 60 3 1002 9002 2021-09-02 12:01:01 2021-09-02 12:31:01 70 4 1002 9001 2021-09-01 19:01:01 2021-09-01 19:40:01 80 5 1002 9003 2021-08-01 12:01:01 2021-08-01 12:21:01 60 6 1002 9002 2021-08-02 12:01:01 2021-08-02 12:31:01 70 7 1002 9001 2021-09-01 19:01:01 2021-09-01 19:40:01 85 8 1002 9002 2021-07-06 12:01:01 (NULL) (NULL) 9 1003 9002 2021-09-07 10:01:01 2021-09-07 10:31:01 86 10 1003 9003 2021-09-08 12:01:01 2021-09-08 12:11:01 40 11 1003 9003 2021-09-01 13:01:01 2021-09-01 13:41:01 70 12 1003 9001 2021-09-08 14:01:01 (NULL) (NULL) 13 1003 9002 2021-09-08 15:01:01 (NULL) (NULL) 14 1005 9001 2021-09-01 12:01:01 2021-09-01 12:31:01 90 15 1005 9002 2021-09-01 12:01:01 2021-09-01 12:31:01 88 16 1005 9002 2021-09-02 12:11:01 2021-09-02 12:31:01 89 请计算每张 SQL 类别试卷发布后,当天 5 级以上的用户作答的人数 uv 和平均分 avg_score,按人数降序,相同人数的按平均分升序,示例数据结果输出如下:\nexam_id uv avg_score 9001 3 81.3 解释:只有一张 SQL 类别的试卷,试卷 ID 为 9001,发布当天(2021-09-01)有 1001、1002、1003、1005 作答过,但是 1003 是 5 级用户,其他 3 位为 5 级以上,他们三的得分有[70,80,85,90],平均分为 81.3(保留 1 位小数)。\n思路:这题看似很复杂,但是先逐步将“外边”条件拆分,然后合拢到一起,答案就出来,多表查询反正记住:由外向里,抽丝剥茧。\n先把三种表连起来,同时给定一些条件,比如题目中要求等级\u0026gt; 5的用户,那么可以先查出来\nSELECT DISTINCT u_info.uid FROM examination_info e_info INNER JOIN exam_record record INNER JOIN user_info u_info WHERE e_info.exam_id = record.exam_id AND u_info.uid = record.uid AND u_info.LEVEL \u0026gt; 5 接着注意题目中要求:每张sql类别试卷发布后,当天作答用户,注意其中的当天,那我们马上就要想到要用到时间的比较。\n对试卷发布日期和开始考试日期进行比较:DATE(e_info.release_time) = DATE(record.start_time);不用担心submit_time 为 null 的问题,后续在 where 中会给过滤掉。\n答案:\nSELECT record.exam_id AS exam_id, COUNT(DISTINCT u_info.uid) AS uv, ROUND(SUM(record.score) / COUNT(u_info.uid), 1) AS avg_score FROM examination_info e_info INNER JOIN exam_record record INNER JOIN user_info u_info WHERE e_info.exam_id = record.exam_id AND u_info.uid = record.uid AND DATE (e_info.release_time) = DATE (record.start_time) AND submit_time IS NOT NULL AND tag = \u0026#39;SQL\u0026#39; AND u_info.LEVEL \u0026gt; 5 GROUP BY record.exam_id ORDER BY uv DESC, avg_score ASC 注意最后的分组排序!先按人数排,若一致,按平均分排。\n作答试卷得分大于过 80 的人的用户等级分布 # 描述:\n现有用户信息表 user_info(uid 用户 ID,nick_name 昵称, achievement 成就值, level 等级, job 职业方向, register_time 注册时间):\nid uid nick_name achievement level job register_time 1 1001 牛客 1 号 3100 7 算法 2020-01-01 10:00:00 2 1002 牛客 2 号 2100 6 算法 2020-01-01 10:00:00 3 1003 牛客 3 号 1500 5 算法 2020-01-01 10:00:00 4 1004 牛客 4 号 1100 4 算法 2020-01-01 10:00:00 5 1005 牛客 5 号 1600 6 C++ 2020-01-01 10:00:00 6 1006 牛客 6 号 3000 6 C++ 2020-01-01 10:00:00 试卷信息表 examination_info(exam_id 试卷 ID, tag 试卷类别, difficulty 试卷难度, duration 考试时长, release_time 发布时间):\nid exam_id tag difficulty duration release_time 1 9001 SQL hard 60 2021-09-01 06:00:00 2 9002 C++ easy 60 2021-09-01 06:00:00 3 9003 算法 medium 80 2021-09-01 10:00:00 试卷作答信息表 exam_record(uid 用户 ID, exam_id 试卷 ID, start_time 开始作答时间, submit_time 交卷时间, score 得分):\nid uid exam_id start_time submit_time score 1 1001 9001 2021-09-01 09:01:01 2021-09-01 09:41:01 79 2 1002 9003 2021-09-01 12:01:01 2021-09-01 12:21:01 60 3 1002 9002 2021-09-01 12:01:01 2021-09-01 12:31:01 70 4 1002 9001 2021-09-01 19:01:01 2021-09-01 19:40:01 80 5 1002 9003 2021-08-01 12:01:01 2021-08-01 12:21:01 60 6 1002 9002 2021-09-01 12:01:01 2021-09-01 12:31:01 70 7 1002 9001 2021-09-01 19:01:01 2021-09-01 19:40:01 85 8 1002 9002 2021-09-01 12:01:01 (NULL) (NULL) 9 1003 9002 2021-09-07 10:01:01 2021-09-07 10:31:01 86 10 1003 9003 2021-09-08 12:01:01 2021-09-08 12:11:01 40 11 1003 9003 2021-09-01 13:01:01 2021-09-01 13:41:01 81 12 1003 9001 2021-09-01 14:01:01 (NULL) (NULL) 13 1003 9002 2021-09-08 15:01:01 (NULL) (NULL) 14 1005 9001 2021-09-01 12:01:01 2021-09-01 12:31:01 90 15 1005 9002 2021-09-01 12:01:01 2021-09-01 12:31:01 88 16 1005 9002 2021-09-02 12:11:01 2021-09-02 12:31:01 89 统计作答 SQL 类别的试卷得分大于过 80 的人的用户等级分布,按数量降序排序(保证数量都不同)。示例数据结果输出如下:\nlevel level_cnt 6 2 5 1 解释:9001 为 SQL 类试卷,作答该试卷大于 80 分的人有 1002、1003、1005 共 3 人,6 级两人,5 级一人。\n==思路:==这题和上一题都是一样的数据,只是查询条件改变了而已,上一题理解了,这题分分钟做出来。\n答案:\nSELECT u_info.LEVEL AS LEVEL, count(u_info.uid) AS level_cnt FROM examination_info e_info INNER JOIN exam_record record INNER JOIN user_info u_info WHERE e_info.exam_id = record.exam_id AND u_info.uid = record.uid AND record.score \u0026gt; 80 AND submit_time IS NOT NULL AND tag = \u0026#39;SQL\u0026#39; GROUP BY LEVEL ORDER BY level_cnt DESC 合并查询 # 每个题目和每份试卷被作答的人数和次数 # 描述:\n现有试卷作答记录表 exam_record(uid 用户 ID, exam_id 试卷 ID, start_time 开始作答时间, submit_time 交卷时间, score 得分):\nid uid exam_id start_time submit_time score 1 1001 9001 2021-09-01 09:01:01 2021-09-01 09:41:01 81 2 1002 9002 2021-09-01 12:01:01 2021-09-01 12:31:01 70 3 1002 9001 2021-09-01 19:01:01 2021-09-01 19:40:01 80 4 1002 9002 2021-09-01 12:01:01 2021-09-01 12:31:01 70 5 1004 9001 2021-09-01 19:01:01 2021-09-01 19:40:01 85 6 1002 9002 2021-09-01 12:01:01 (NULL) (NULL) 题目练习表 practice_record(uid 用户 ID, question_id 题目 ID, submit_time 提交时间, score 得分):\nid uid question_id submit_time score 1 1001 8001 2021-08-02 11:41:01 60 2 1002 8001 2021-09-02 19:30:01 50 3 1002 8001 2021-09-02 19:20:01 70 4 1002 8002 2021-09-02 19:38:01 70 5 1003 8001 2021-08-02 19:38:01 70 6 1003 8001 2021-08-02 19:48:01 90 7 1003 8002 2021-08-01 19:38:01 80 请统计每个题目和每份试卷被作答的人数和次数,分别按照\u0026quot;试卷\u0026quot;和\u0026quot;题目\u0026quot;的 uv \u0026amp; pv 降序显示,示例数据结果输出如下:\ntid uv pv 9001 3 3 9002 1 3 8001 3 5 8002 2 2 解释:“试卷”有 3 人共练习 3 次试卷 9001,1 人作答 3 次 9002;“刷题”有 3 人刷 5 次 8001,有 2 人刷 2 次 8002\n思路:这题的难点和易错点在于UNION和ORDER BY 同时使用的问题\n有以下几种情况:使用union和多个order by不加括号,报错!\norder by在union连接的子句中不起作用;\n比如不加括号:\nSELECT exam_id AS tid, COUNT(DISTINCT UID) AS uv, COUNT(UID) AS pv FROM exam_record GROUP BY exam_id ORDER BY uv DESC, pv DESC UNION SELECT question_id AS tid, COUNT(DISTINCT UID) AS uv, COUNT(UID) AS pv FROM practice_record GROUP BY question_id ORDER BY uv DESC, pv DESC 直接报语法错误,如果没有括号,只能有一个order by\n还有一种order by不起作用的情况,但是能在子句的子句中起作用,这里的解决方案就是在外面再套一层查询。\n答案:\nSELECT * FROM (SELECT exam_id AS tid, COUNT(DISTINCT exam_record.uid) uv, COUNT(*) pv FROM exam_record GROUP BY exam_id ORDER BY uv DESC, pv DESC) t1 UNION SELECT * FROM (SELECT question_id AS tid, COUNT(DISTINCT practice_record.uid) uv, COUNT(*) pv FROM practice_record GROUP BY question_id ORDER BY uv DESC, pv DESC) t2; 分别满足两个活动的人 # 描述: 为了促进更多用户在牛客平台学习和刷题进步,我们会经常给一些既活跃又表现不错的用户发放福利。假使以前我们有两拨运营活动,分别给每次试卷得分都能到 85 分的人(activity1)、至少有一次用了一半时间就完成高难度试卷且分数大于 80 的人(activity2)发了福利券。\n现在,需要你一次性将这两个活动满足的人筛选出来,交给运营同学。请写出一个 SQL 实现:输出 2021 年里,所有每次试卷得分都能到 85 分的人以及至少有一次用了一半时间就完成高难度试卷且分数大于 80 的人的 id 和活动号,按用户 ID 排序输出。\n现有试卷信息表 examination_info(exam_id 试卷 ID, tag 试卷类别, difficulty 试卷难度, duration 考试时长, release_time 发布时间):\nid exam_id tag difficulty duration release_time 1 9001 SQL hard 60 2021-09-01 06:00:00 2 9002 C++ easy 60 2021-09-01 06:00:00 3 9003 算法 medium 80 2021-09-01 10:00:00 试卷作答记录表 exam_record(uid 用户 ID, exam_id 试卷 ID, start_time 开始作答时间, submit_time 交卷时间, score 得分):\nid uid exam_id start_time submit_time score 1 1001 9001 2021-09-01 09:01:01 2021-09-01 09:31:00 81 2 1002 9002 2021-09-01 12:01:01 2021-09-01 12:31:01 70 3 1003 9001 2021-09-01 19:01:01 2021-09-01 19:40:01 86 4 1003 9002 2021-09-01 12:01:01 2021-09-01 12:31:01 89 5 1004 9001 2021-09-01 19:01:01 2021-09-01 19:30:01 85 示例数据输出结果:\nuid activity 1001 activity2 1003 activity1 1004 activity1 1004 activity2 解释:用户 1001 最小分数 81 不满足活动 1,但 29 分 59 秒完成了 60 分钟长的试卷得分 81,满足活动 2;1003 最小分数 86 满足活动 1,完成时长都大于试卷时长的一半,不满足活动 2;用户 1004 刚好用了一半时间(30 分钟整)完成了试卷得分 85,满足活动 1 和活动 2。\n思路: 这一题需要涉及到时间的减法,需要用到 TIMESTAMPDIFF() 函数计算两个时间戳之间的分钟差值。\n下面我们来看一下基本用法\n示例:\nTIMESTAMPDIFF(MINUTE, start_time, end_time) TIMESTAMPDIFF() 函数的第一个参数是时间单位,这里我们选择 MINUTE 表示返回分钟差值。第二个参数是较早的时间戳,第三个参数是较晚的时间戳。函数会返回它们之间的分钟差值\n了解了这个函数的用法之后,我们再回过头来看activity1的要求,求分数大于 85 即可,那我们还是先把这个写出来,后续思路就会清晰很多\nSELECT DISTINCT UID FROM exam_record WHERE score \u0026gt;= 85 AND YEAR (start_time) = \u0026#39;2021\u0026#39; 根据条件 2,接着写出在一半时间内完成高难度试卷且分数大于80的人\nSELECT UID FROM examination_info info INNER JOIN exam_record record WHERE info.exam_id = record.exam_id AND (TIMESTAMPDIFF(MINUTE, start_time, submit_time)) \u0026lt; (info.duration / 2) AND difficulty = \u0026#39;hard\u0026#39; AND score \u0026gt;= 80 然后再把两者UNION 起来即可。(这里特别要注意括号问题和order by位置,具体用法在上一篇中已提及)\n答案:\nSELECT DISTINCT UID UID, \u0026#39;activity1\u0026#39; activity FROM exam_record WHERE UID not in (SELECT UID FROM exam_record WHERE score\u0026lt;85 AND YEAR(submit_time) = 2021 ) UNION SELECT DISTINCT UID UID, \u0026#39;activity2\u0026#39; activity FROM exam_record e_r LEFT JOIN examination_info e_i ON e_r.exam_id = e_i.exam_id WHERE YEAR(submit_time) = 2021 AND difficulty = \u0026#39;hard\u0026#39; AND TIMESTAMPDIFF(SECOND, start_time, submit_time) \u0026lt;= duration *30 AND score\u0026gt;80 ORDER BY UID 连接查询 # 满足条件的用户的试卷完成数和题目练习数(困难) # 描述:\n现有用户信息表 user_info(uid 用户 ID,nick_name 昵称, achievement 成就值, level 等级, job 职业方向, register_time 注册时间):\nid uid nick_name achievement level job register_time 1 1001 牛客 1 号 3100 7 算法 2020-01-01 10:00:00 2 1002 牛客 2 号 2300 7 算法 2020-01-01 10:00:00 3 1003 牛客 3 号 2500 7 算法 2020-01-01 10:00:00 4 1004 牛客 4 号 1200 5 算法 2020-01-01 10:00:00 5 1005 牛客 5 号 1600 6 C++ 2020-01-01 10:00:00 6 1006 牛客 6 号 2000 6 C++ 2020-01-01 10:00:00 试卷信息表 examination_info(exam_id 试卷 ID, tag 试卷类别, difficulty 试卷难度, duration 考试时长, release_time 发布时间):\nid exam_id tag difficulty duration release_time 1 9001 SQL hard 60 2021-09-01 06:00:00 2 9002 C++ hard 60 2021-09-01 06:00:00 3 9003 算法 medium 80 2021-09-01 10:00:00 试卷作答记录表 exam_record(uid 用户 ID, exam_id 试卷 ID, start_time 开始作答时间, submit_time 交卷时间, score 得分):\nid uid exam_id start_time submit_time score 1 1001 9001 2021-09-01 09:01:01 2021-09-01 09:31:00 81 2 1002 9002 2021-09-01 12:01:01 2021-09-01 12:31:01 81 3 1003 9001 2021-09-01 19:01:01 2021-09-01 19:40:01 86 4 1003 9002 2021-09-01 12:01:01 2021-09-01 12:31:51 89 5 1004 9001 2021-09-01 19:01:01 2021-09-01 19:30:01 85 6 1005 9002 2021-09-01 12:01:01 2021-09-01 12:31:02 85 7 1006 9003 2021-09-07 10:01:01 2021-09-07 10:21:01 84 8 1006 9001 2021-09-07 10:01:01 2021-09-07 10:21:01 80 题目练习记录表 practice_record(uid 用户 ID, question_id 题目 ID, submit_time 提交时间, score 得分):\nid uid question_id submit_time score 1 1001 8001 2021-08-02 11:41:01 60 2 1002 8001 2021-09-02 19:30:01 50 3 1002 8001 2021-09-02 19:20:01 70 4 1002 8002 2021-09-02 19:38:01 70 5 1004 8001 2021-08-02 19:38:01 70 6 1004 8002 2021-08-02 19:48:01 90 7 1001 8002 2021-08-02 19:38:01 70 8 1004 8002 2021-08-02 19:48:01 90 9 1004 8002 2021-08-02 19:58:01 94 10 1004 8003 2021-08-02 19:38:01 70 11 1004 8003 2021-08-02 19:48:01 90 12 1004 8003 2021-08-01 19:38:01 80 请你找到高难度 SQL 试卷得分平均值大于 80 并且是 7 级的红名大佬,统计他们的 2021 年试卷总完成次数和题目总练习次数,只保留 2021 年有试卷完成记录的用户。结果按试卷完成数升序,按题目练习数降序。\n示例数据输出如下:\nuid exam_cnt question_cnt 1001 1 2 1003 2 0 解释:用户 1001、1003、1004、1006 满足高难度 SQL 试卷得分平均值大于 80,但只有 1001、1003 是 7 级红名大佬;1001 完成了 1 次试卷 1001,练习了 2 次题目;1003 完成了 2 次试卷 9001、9002,未练习题目(因此计数为 0)\n思路:\n先将条件进行初步筛选,比如先查出做过高难度 sql 试卷的用户\nSELECT record.uid FROM exam_record record INNER JOIN examination_info e_info ON record.exam_id = e_info.exam_id JOIN user_info u_info ON record.uid = u_info.uid WHERE e_info.tag = \u0026#39;SQL\u0026#39; AND e_info.difficulty = \u0026#39;hard\u0026#39; 然后根据题目要求,接着再往里叠条件即可;\n但是这里又要注意:\n第一:不能YEAR(submit_time)= 2021这个条件放到最后,要在ON条件里,因为左连接存在返回左表全部行,右表为 null 的情形,放在 JOIN条件的 ON 子句中的目的是为了确保在连接两个表时,只有满足年份条件的记录会进行连接。这样可以避免其他年份的记录被包含在结果中。即 1001 做过 2021 年的试卷,但没有练习过,如果把条件放到最后,就会排除掉这种情况。\n第二,必须是COUNT(distinct er.exam_id) exam_cnt, COUNT(distinct pr.id) question_cnt,要加 distinct,因为有左连接产生很多重复值。\n答案:\nSELECT er.uid AS UID, count(DISTINCT er.exam_id) AS exam_cnt, count(DISTINCT pr.id) AS question_cnt FROM exam_record er LEFT JOIN practice_record pr ON er.uid = pr.uid AND YEAR (er.submit_time)= 2021 AND YEAR (pr.submit_time)= 2021 WHERE er.uid IN (SELECT er.uid FROM exam_record er LEFT JOIN examination_info ei ON er.exam_id = ei.exam_id LEFT JOIN user_info ui ON er.uid = ui.uid WHERE tag = \u0026#39;SQL\u0026#39; AND difficulty = \u0026#39;hard\u0026#39; AND LEVEL = 7 GROUP BY er.uid HAVING avg(score) \u0026gt; 80) GROUP BY er.uid ORDER BY exam_cnt, question_cnt DESC 可能细心的小伙伴会发现,为什么明明将条件限制了tag = 'SQL' AND difficulty = 'hard',但是用户 1003 仍然能查出两条考试记录,其中一条的考试tag为 C++; 这是由于LEFT JOIN的特性,即使没有与右表匹配的行,左表的所有记录仍然会被保留。\n每个 6/7 级用户活跃情况(困难) # 描述:\n现有用户信息表 user_info(uid 用户 ID,nick_name 昵称, achievement 成就值, level 等级, job 职业方向, register_time 注册时间):\nid uid nick_name achievement level job register_time 1 1001 牛客 1 号 3100 7 算法 2020-01-01 10:00:00 2 1002 牛客 2 号 2300 7 算法 2020-01-01 10:00:00 3 1003 牛客 3 号 2500 7 算法 2020-01-01 10:00:00 4 1004 牛客 4 号 1200 5 算法 2020-01-01 10:00:00 5 1005 牛客 5 号 1600 6 C++ 2020-01-01 10:00:00 6 1006 牛客 6 号 2600 7 C++ 2020-01-01 10:00:00 试卷信息表 examination_info(exam_id 试卷 ID, tag 试卷类别, difficulty 试卷难度, duration 考试时长, release_time 发布时间):\nid exam_id tag difficulty duration release_time 1 9001 SQL hard 60 2021-09-01 06:00:00 2 9002 C++ easy 60 2021-09-01 06:00:00 3 9003 算法 medium 80 2021-09-01 10:00:00 试卷作答记录表 exam_record(uid 用户 ID, exam_id 试卷 ID, start_time 开始作答时间, submit_time 交卷时间, score 得分):\nuid exam_id start_time submit_time score 1001 9001 2021-09-01 09:01:01 2021-09-01 09:31:00 78 1001 9001 2021-09-01 09:01:01 2021-09-01 09:31:00 81 1005 9001 2021-09-01 19:01:01 2021-09-01 19:30:01 85 1005 9002 2021-09-01 12:01:01 2021-09-01 12:31:02 85 1006 9003 2021-09-07 10:01:01 2021-09-07 10:21:59 84 1006 9001 2021-09-07 10:01:01 2021-09-07 10:21:01 81 1002 9001 2020-09-01 13:01:01 2020-09-01 13:41:01 81 1005 9001 2021-09-01 14:01:01 (NULL) (NULL) 题目练习记录表 practice_record(uid 用户 ID, question_id 题目 ID, submit_time 提交时间, score 得分):\nuid question_id submit_time score 1001 8001 2021-08-02 11:41:01 60 1004 8001 2021-08-02 19:38:01 70 1004 8002 2021-08-02 19:48:01 90 1001 8002 2021-08-02 19:38:01 70 1004 8002 2021-08-02 19:48:01 90 1006 8002 2021-08-04 19:58:01 94 1006 8003 2021-08-03 19:38:01 70 1006 8003 2021-08-02 19:48:01 90 1006 8003 2020-08-01 19:38:01 80 请统计每个 6/7 级用户总活跃月份数、2021 年活跃天数、2021 年试卷作答活跃天数、2021 年答题活跃天数,按照总活跃月份数、2021 年活跃天数降序排序。由示例数据结果输出如下:\nuid act_month_total act_days_2021 act_days_2021_exam 1006 3 4 1 1001 2 2 1 1005 1 1 1 1002 1 0 0 1003 0 0 0 解释:6/7 级用户共有 5 个,其中 1006 在 202109、202108、202008 共 3 个月活跃过,2021 年活跃的日期有 20210907、20210804、20210803、20210802 共 4 天,2021 年在试卷作答区 20210907 活跃 1 天,在题目练习区活跃了 3 天。\n思路:\n这题的关键在于CASE WHEN THEN的使用,不然要写很多的left join 因为会产生很多的结果集。\nCASE WHEN THEN语句是一种条件表达式,用于在 SQL 中根据条件执行不同的操作或返回不同的结果。\n语法结构如下:\nCASE WHEN condition1 THEN result1 WHEN condition2 THEN result2 ... ELSE result END 在这个结构中,可以根据需要添加多个WHEN子句,每个WHEN子句后面跟着一个条件(condition)和一个结果(result)。条件可以是任何逻辑表达式,如果满足条件,将返回对应的结果。\n最后的ELSE子句是可选的,用于指定当所有前面的条件都不满足时的默认返回结果。如果没有提供ELSE子句,则默认返回NULL。\n例如:\nSELECT score, CASE WHEN score \u0026gt;= 90 THEN \u0026#39;优秀\u0026#39; WHEN score \u0026gt;= 80 THEN \u0026#39;良好\u0026#39; WHEN score \u0026gt;= 60 THEN \u0026#39;及格\u0026#39; ELSE \u0026#39;不及格\u0026#39; END AS grade FROM student_scores; 在上述示例中,根据学生成绩(score)的不同范围,使用 CASE WHEN THEN 语句返回相应的等级(grade)。如果成绩大于等于 90,则返回\u0026quot;优秀\u0026quot;;如果成绩大于等于 80,则返回\u0026quot;良好\u0026quot;;如果成绩大于等于 60,则返回\u0026quot;及格\u0026quot;;否则返回\u0026quot;不及格\u0026quot;。\n那了解到了上述的用法之后,回过头看看该题,要求列出不同的活跃天数。\ncount(distinct act_month) as act_month_total, count(distinct case when year(act_time)=\u0026#39;2021\u0026#39;then act_day end) as act_days_2021, count(distinct case when year(act_time)=\u0026#39;2021\u0026#39; and tag=\u0026#39;exam\u0026#39; then act_day end) as act_days_2021_exam, count(distinct case when year(act_time)=\u0026#39;2021\u0026#39; and tag=\u0026#39;question\u0026#39;then act_day end) as act_days_2021_question 这里的 tag 是先给标记,方便对查询进行区分,将考试和答题分开。\n找出试卷作答区的用户\nSELECT uid, exam_id AS ans_id, start_time AS act_time, date_format( start_time, \u0026#39;%Y%m\u0026#39; ) AS act_month, date_format( start_time, \u0026#39;%Y%m%d\u0026#39; ) AS act_day, \u0026#39;exam\u0026#39; AS tag FROM exam_record 紧接着就是答题作答区的用户\nSELECT uid, question_id AS ans_id, submit_time AS act_time, date_format( submit_time, \u0026#39;%Y%m\u0026#39; ) AS act_month, date_format( submit_time, \u0026#39;%Y%m%d\u0026#39; ) AS act_day, \u0026#39;question\u0026#39; AS tag FROM practice_record 最后将两个结果进行UNION 最后别忘了将结果进行排序 (这题有点类似于分治法的思想)\n答案:\nSELECT user_info.uid, count(DISTINCT act_month) AS act_month_total, count(DISTINCT CASE WHEN YEAR (act_time)= \u0026#39;2021\u0026#39; THEN act_day END) AS act_days_2021, count(DISTINCT CASE WHEN YEAR (act_time)= \u0026#39;2021\u0026#39; AND tag = \u0026#39;exam\u0026#39; THEN act_day END) AS act_days_2021_exam, count(DISTINCT CASE WHEN YEAR (act_time)= \u0026#39;2021\u0026#39; AND tag = \u0026#39;question\u0026#39; THEN act_day END) AS act_days_2021_question FROM (SELECT UID, exam_id AS ans_id, start_time AS act_time, date_format(start_time, \u0026#39;%Y%m\u0026#39;) AS act_month, date_format(start_time, \u0026#39;%Y%m%d\u0026#39;) AS act_day, \u0026#39;exam\u0026#39; AS tag FROM exam_record UNION ALL SELECT UID, question_id AS ans_id, submit_time AS act_time, date_format(submit_time, \u0026#39;%Y%m\u0026#39;) AS act_month, date_format(submit_time, \u0026#39;%Y%m%d\u0026#39;) AS act_day, \u0026#39;question\u0026#39; AS tag FROM practice_record) total RIGHT JOIN user_info ON total.uid = user_info.uid WHERE user_info.LEVEL IN (6, 7) GROUP BY user_info.uid ORDER BY act_month_total DESC, act_days_2021 DESC "},{"id":580,"href":"/zh/docs/technology/Interview/database/sql/sql-questions-04/","title":"SQL常见面试题总结(4)","section":"SQL","content":" 题目来源于: 牛客题霸 - SQL 进阶挑战\n较难或者困难的题目可以根据自身实际情况和面试需要来决定是否要跳过。\n专用窗口函数 # MySQL 8.0 版本引入了窗口函数的支持,下面是 MySQL 中常见的窗口函数及其用法:\nROW_NUMBER(): 为查询结果集中的每一行分配一个唯一的整数值。 SELECT col1, col2, ROW_NUMBER() OVER (ORDER BY col1) AS row_num FROM table; RANK(): 计算每一行在排序结果中的排名。 SELECT col1, col2, RANK() OVER (ORDER BY col1 DESC) AS ranking FROM table; DENSE_RANK(): 计算每一行在排序结果中的排名,保留相同的排名。 SELECT col1, col2, DENSE_RANK() OVER (ORDER BY col1 DESC) AS ranking FROM table; NTILE(n): 将结果分成 n 个基本均匀的桶,并为每个桶分配一个标识号。 SELECT col1, col2, NTILE(4) OVER (ORDER BY col1) AS bucket FROM table; SUM(), AVG(),COUNT(), MIN(), MAX(): 这些聚合函数也可以与窗口函数结合使用,计算窗口内指定列的汇总、平均值、计数、最小值和最大值。 SELECT col1, col2, SUM(col1) OVER () AS sum_col FROM table; LEAD() 和 LAG(): LEAD 函数用于获取当前行之后的某个偏移量的行的值,而 LAG 函数用于获取当前行之前的某个偏移量的行的值。 SELECT col1, col2, LEAD(col1, 1) OVER (ORDER BY col1) AS next_col1, LAG(col1, 1) OVER (ORDER BY col1) AS prev_col1 FROM table; FIRST_VALUE() 和 LAST_VALUE(): FIRST_VALUE 函数用于获取窗口内指定列的第一个值,LAST_VALUE 函数用于获取窗口内指定列的最后一个值。 SELECT col1, col2, FIRST_VALUE(col2) OVER (PARTITION BY col1 ORDER BY col2) AS first_val, LAST_VALUE(col2) OVER (PARTITION BY col1 ORDER BY col2) AS last_val FROM table; 窗口函数通常需要配合 OVER 子句一起使用,用于定义窗口的大小、排序规则和分组方式。\n每类试卷得分前三名 # 描述:\n现有试卷信息表 examination_info(exam_id 试卷 ID, tag 试卷类别, difficulty 试卷难度, duration 考试时长, release_time 发布时间):\nid exam_id tag difficulty duration release_time 1 9001 SQL hard 60 2021-09-01 06:00:00 2 9002 SQL hard 60 2021-09-01 06:00:00 3 9003 算法 medium 80 2021-09-01 10:00:00 试卷作答记录表 exam_record(uid 用户 ID, exam_id 试卷 ID, start_time 开始作答时间, submit_time 交卷时间, score 得分):\nid uid exam_id start_time submit_time score 1 1001 9001 2021-09-01 09:01:01 2021-09-01 09:31:00 78 2 1002 9001 2021-09-01 09:01:01 2021-09-01 09:31:00 81 3 1002 9002 2021-09-01 12:01:01 2021-09-01 12:31:01 81 4 1003 9001 2021-09-01 19:01:01 2021-09-01 19:40:01 86 5 1003 9002 2021-09-01 12:01:01 2021-09-01 12:31:51 89 6 1004 9001 2021-09-01 19:01:01 2021-09-01 19:30:01 85 7 1005 9003 2021-09-01 12:01:01 2021-09-01 12:31:02 85 8 1006 9003 2021-09-07 10:01:01 2021-09-07 10:21:01 84 9 1003 9003 2021-09-08 12:01:01 2021-09-08 12:11:01 40 10 1003 9002 2021-09-01 14:01:01 (NULL) (NULL) 找到每类试卷得分的前 3 名,如果两人最大分数相同,选择最小分数大者,如果还相同,选择 uid 大者。由示例数据结果输出如下:\ntid uid ranking SQL 1003 1 SQL 1004 2 SQL 1002 3 算法 1005 1 算法 1006 2 算法 1003 3 解释:有作答得分记录的试卷 tag 有 SQL 和算法,SQL 试卷用户 1001、1002、1003、1004 有作答得分,最高得分分别为 81、81、89、85,最低得分分别为 78、81、86、40,因此先按最高得分排名再按最低得分排名取前三为 1003、1004、1002。\n答案:\nSELECT tag, UID, ranking FROM (SELECT b.tag AS tag, a.uid AS UID, ROW_NUMBER() OVER (PARTITION BY b.tag ORDER BY b.tag, max(a.score) DESC, min(a.score) DESC, a.uid DESC) AS ranking FROM exam_record a LEFT JOIN examination_info b ON a.exam_id = b.exam_id GROUP BY b.tag, a.uid) t WHERE ranking \u0026lt;= 3 第二快/慢用时之差大于试卷时长一半的试卷(较难) # 描述:\n现有试卷信息表 examination_info(exam_id 试卷 ID, tag 试卷类别, difficulty 试卷难度, duration 考试时长, release_time 发布时间):\nid exam_id tag difficulty duration release_time 1 9001 SQL hard 60 2021-09-01 06:00:00 2 9002 C++ hard 60 2021-09-01 06:00:00 3 9003 算法 medium 80 2021-09-01 10:00:00 试卷作答记录表 exam_record(uid 用户 ID, exam_id 试卷 ID, start_time 开始作答时间, submit_time 交卷时间, score 得分):\nid uid exam_id start_time submit_time score 1 1001 9001 2021-09-01 09:01:01 2021-09-01 09:51:01 78 2 1001 9002 2021-09-01 09:01:01 2021-09-01 09:31:00 81 3 1002 9002 2021-09-01 12:01:01 2021-09-01 12:31:01 81 4 1003 9001 2021-09-01 19:01:01 2021-09-01 19:59:01 86 5 1003 9002 2021-09-01 12:01:01 2021-09-01 12:31:51 89 6 1004 9002 2021-09-01 19:01:01 2021-09-01 19:30:01 85 7 1005 9001 2021-09-01 12:01:01 2021-09-01 12:31:02 85 8 1006 9001 2021-09-07 10:02:01 2021-09-07 10:21:01 84 9 1003 9001 2021-09-08 12:01:01 2021-09-08 12:11:01 40 10 1003 9002 2021-09-01 14:01:01 (NULL) (NULL) 11 1005 9001 2021-09-01 14:01:01 (NULL) (NULL) 12 1003 9003 2021-09-08 15:01:01 (NULL) (NULL) 找到第二快和第二慢用时之差大于试卷时长的一半的试卷信息,按试卷 ID 降序排序。由示例数据结果输出如下:\nexam_id duration release_time 9001 60 2021-09-01 06:00:00 解释:试卷 9001 被作答用时有 50 分钟、58 分钟、30 分 1 秒、19 分钟、10 分钟,第二快和第二慢用时之差为 50 分钟-19 分钟=31 分钟,试卷时长为 60 分钟,因此满足大于试卷时长一半的条件,输出试卷 ID、时长、发布时间。\n思路:\n第一步,找到每张试卷完成时间的顺序排名和倒序排名 也就是表 a;\n第二步,与通过试卷信息表 b 建立内连接,并根据试卷 id 分组,利用having筛选排名为第二个数据,将秒转化为分钟并进行比较,最后再根据试卷 id 倒序排序就行\n答案:\nSELECT a.exam_id, b.duration, b.release_time FROM (SELECT exam_id, row_number() OVER (PARTITION BY exam_id ORDER BY timestampdiff(SECOND, start_time, submit_time) DESC) rn1, row_number() OVER (PARTITION BY exam_id ORDER BY timestampdiff(SECOND, start_time, submit_time) ASC) rn2, timestampdiff(SECOND, start_time, submit_time) timex FROM exam_record WHERE score IS NOT NULL ) a INNER JOIN examination_info b ON a.exam_id = b.exam_id GROUP BY a.exam_id HAVING (max(IF (rn1 = 2, a.timex, 0))- max(IF (rn2 = 2, a.timex, 0)))/ 60 \u0026gt; b.duration / 2 ORDER BY a.exam_id DESC 连续两次作答试卷的最大时间窗(较难) # 描述\n现有试卷作答记录表 exam_record(uid 用户 ID, exam_id 试卷 ID, start_time 开始作答时间, submit_time 交卷时间, score 得分):\nid uid exam_id start_time submit_time score 1 1006 9003 2021-09-07 10:01:01 2021-09-07 10:21:02 84 2 1006 9001 2021-09-01 12:11:01 2021-09-01 12:31:01 89 3 1006 9002 2021-09-06 10:01:01 2021-09-06 10:21:01 81 4 1005 9002 2021-09-05 10:01:01 2021-09-05 10:21:01 81 5 1005 9001 2021-09-05 10:31:01 2021-09-05 10:51:01 81 请计算在 2021 年至少有两天作答过试卷的人中,计算该年连续两次作答试卷的最大时间窗 days_window,那么根据该年的历史规律他在 days_window 天里平均会做多少套试卷,按最大时间窗和平均做答试卷套数倒序排序。由示例数据结果输出如下:\nuid days_window avg_exam_cnt 1006 6 2.57 解释:用户 1006 分别在 20210901、20210906、20210907 作答过 3 次试卷,连续两次作答最大时间窗为 6 天(1 号到 6 号),他 1 号到 7 号这 7 天里共做了 3 张试卷,平均每天 3/7=0.428571 张,那么 6 天里平均会做 0.428571*6=2.57 张试卷(保留两位小数);用户 1005 在 20210905 做了两张试卷,但是只有一天的作答记录,过滤掉。\n思路:\n上面这个解释中提示要对作答记录去重,千万别被骗了,不要去重!去重就通不过测试用例。注意限制时间是 2021 年;\n而且要注意时间差要+1 天;还要注意没交卷也算在内!!!! (反正感觉这题描述不清,出的不是很好)\n答案:\nSELECT UID, max(datediff(next_time, start_time)) + 1 AS days_window, round(count(start_time)/(datediff(max(start_time), min(start_time))+ 1) * (max(datediff(next_time, start_time))+ 1), 2) AS avg_exam_cnt FROM (SELECT UID, start_time, lead(start_time, 1) OVER (PARTITION BY UID ORDER BY start_time) AS next_time FROM exam_record WHERE YEAR (start_time) = \u0026#39;2021\u0026#39; ) a GROUP BY UID HAVING count(DISTINCT date(start_time)) \u0026gt; 1 ORDER BY days_window DESC, avg_exam_cnt DESC 近三个月未完成为 0 的用户完成情况 # 描述:\n现有试卷作答记录表 exam_record(uid:用户 ID, exam_id:试卷 ID, start_time:开始作答时间, submit_time:交卷时间,为空的话则代表未完成, score:得分):\nid uid exam_id start_time submit_time score 1 1006 9003 2021-09-06 10:01:01 2021-09-06 10:21:02 84 2 1006 9001 2021-08-02 12:11:01 2021-08-02 12:31:01 89 3 1006 9002 2021-06-06 10:01:01 2021-06-06 10:21:01 81 4 1006 9002 2021-05-06 10:01:01 2021-05-06 10:21:01 81 5 1006 9001 2021-05-01 12:01:01 (NULL) (NULL) 6 1001 9001 2021-09-05 10:31:01 2021-09-05 10:51:01 81 7 1001 9003 2021-08-01 09:01:01 2021-08-01 09:51:11 78 8 1001 9002 2021-07-01 09:01:01 2021-07-01 09:31:00 81 9 1001 9002 2021-07-01 12:01:01 2021-07-01 12:31:01 81 10 1001 9002 2021-07-01 12:01:01 (NULL) (NULL) 找到每个人近三个有试卷作答记录的月份中没有试卷是未完成状态的用户的试卷作答完成数,按试卷完成数和用户 ID 降序排名。由示例数据结果输出如下:\nuid exam_complete_cnt 1006 3 解释:用户 1006 近三个有作答试卷的月份为 202109、202108、202106,作答试卷数为 3,全部完成;用户 1001 近三个有作答试卷的月份为 202109、202108、202107,作答试卷数为 5,完成试卷数为 4,因为有未完成试卷,故过滤掉。\n思路:\n找到每个人近三个有试卷作答记录的月份中没有试卷是未完成状态的用户的试卷作答完成数首先看这句话,肯定要先根据人进行分组 最近三个月,可以采用连续重复排名,倒序排列,排名\u0026lt;=3 统计作答数 拼装剩余条件 排序 答案:\nSELECT UID, count(score) exam_complete_cnt FROM (SELECT *, DENSE_RANK() OVER (PARTITION BY UID ORDER BY date_format(start_time, \u0026#39;%Y%m\u0026#39;) DESC) dr FROM exam_record) t1 WHERE dr \u0026lt;= 3 GROUP BY UID HAVING count(dr)= count(score) ORDER BY exam_complete_cnt DESC, UID DESC 未完成率较高的 50%用户近三个月答卷情况(困难) # 描述:\n现有用户信息表 user_info(uid 用户 ID,nick_name 昵称, achievement 成就值, level 等级, job 职业方向, register_time 注册时间):\nid uid nick_name achievement level job register_time 1 1001 牛客 1 号 3200 7 算法 2020-01-01 10:00:00 2 1002 牛客 2 号 2500 6 算法 2020-01-01 10:00:00 3 1003 牛客 3 号 ♂ 2200 5 算法 2020-01-01 10:00:00 试卷信息表 examination_info(exam_id 试卷 ID, tag 试卷类别, difficulty 试卷难度, duration 考试时长, release_time 发布时间):\nid exam_id tag difficulty duration release_time 1 9001 SQL hard 60 2020-01-01 10:00:00 2 9002 SQL hard 80 2020-01-01 10:00:00 3 9003 算法 hard 80 2020-01-01 10:00:00 4 9004 PYTHON medium 70 2020-01-01 10:00:00 试卷作答记录表 exam_record(uid 用户 ID, exam_id 试卷 ID, start_time 开始作答时间, submit_time 交卷时间, score 得分):\nid uid exam_id start_time submit_time score 1 1001 9001 2020-01-01 09:01:01 2020-01-01 09:21:59 90 15 1002 9001 2020-01-01 18:01:01 2020-01-01 18:59:02 90 13 1001 9001 2020-01-02 10:01:01 2020-01-02 10:31:01 89 2 1002 9001 2020-01-20 10:01:01 3 1002 9001 2020-02-01 12:11:01 5 1001 9001 2020-03-01 12:01:01 6 1002 9001 2020-03-01 12:01:01 2020-03-01 12:41:01 90 4 1003 9001 2020-03-01 19:01:01 7 1002 9001 2020-05-02 19:01:01 2020-05-02 19:32:00 90 14 1001 9002 2020-01-01 12:11:01 8 1001 9002 2020-01-02 19:01:01 2020-01-02 19:59:01 69 9 1001 9002 2020-02-02 12:01:01 2020-02-02 12:20:01 99 10 1002 9002 2020-02-02 12:01:01 11 1002 9002 2020-02-02 12:01:01 2020-02-02 12:43:01 81 12 1002 9002 2020-03-02 12:11:01 17 1001 9002 2020-05-05 18:01:01 16 1002 9003 2020-05-06 12:01:01 请统计 SQL 试卷上未完成率较高的 50%用户中,6 级和 7 级用户在有试卷作答记录的近三个月中,每个月的答卷数目和完成数目。按用户 ID、月份升序排序。\n由示例数据结果输出如下:\nuid start_month total_cnt complete_cnt 1002 202002 3 1 1002 202003 2 1 1002 202005 2 1 解释:各个用户对 SQL 试卷的未完成数、作答总数、未完成率如下:\nuid incomplete_cnt total_cnt incomplete_rate 1001 3 7 0.4286 1002 4 8 0.5000 1003 1 1 1.0000 1001、1002、1003 分别排在 1.0、0.5、0.0 的位置,因此较高的 50%用户(排位\u0026lt;=0.5)为 1002、1003;\n1003 不是 6 级或 7 级;\n有试卷作答记录的近三个月为 202005、202003、202002;\n这三个月里 1002 的作答题数分别为 3、2、2,完成数目分别为 1、1、1。\n思路:\n注意点:这题注意求的是所有的答题次数和完成次数,而 sql 类别的试卷是限制未完成率排名,6, 7 级用户限制的是做题记录。\n先求出未完成率的排名\nSELECT UID, count(submit_time IS NULL OR NULL)/ count(start_time) AS num, PERCENT_RANK() OVER ( ORDER BY count(submit_time IS NULL OR NULL)/ count(start_time)) AS ranking FROM exam_record LEFT JOIN examination_info USING (exam_id) WHERE tag = \u0026#39;SQL\u0026#39; GROUP BY UID 再求出最近三个月的练习记录\nSELECT UID, date_format(start_time, \u0026#39;%Y%m\u0026#39;) AS month_d, submit_time, exam_id, dense_rank() OVER (PARTITION BY UID ORDER BY date_format(start_time, \u0026#39;%Y%m\u0026#39;) DESC) AS ranking FROM exam_record LEFT JOIN user_info USING (UID) WHERE LEVEL IN (6,7) 答案:\nSELECT t1.uid, t1.month_d, count(*) AS total_cnt, count(t1.submit_time) AS complete_cnt FROM-- 先求出未完成率的排名 (SELECT UID, count(submit_time IS NULL OR NULL)/ count(start_time) AS num, PERCENT_RANK() OVER ( ORDER BY count(submit_time IS NULL OR NULL)/ count(start_time)) AS ranking FROM exam_record LEFT JOIN examination_info USING (exam_id) WHERE tag = \u0026#39;SQL\u0026#39; GROUP BY UID) t INNER JOIN (-- 再求出近三个月的练习记录 SELECT UID, date_format(start_time, \u0026#39;%Y%m\u0026#39;) AS month_d, submit_time, exam_id, dense_rank() OVER (PARTITION BY UID ORDER BY date_format(start_time, \u0026#39;%Y%m\u0026#39;) DESC) AS ranking FROM exam_record LEFT JOIN user_info USING (UID) WHERE LEVEL IN (6,7) ) t1 USING (UID) WHERE t1.ranking \u0026lt;= 3 AND t.ranking \u0026gt;= 0.5 -- 使用限制找到符合条件的记录 GROUP BY t1.uid, t1.month_d ORDER BY t1.uid, t1.month_d 试卷完成数同比 2020 年的增长率及排名变化(困难) # 描述:\n现有试卷信息表 examination_info(exam_id 试卷 ID, tag 试卷类别, difficulty 试卷难度, duration 考试时长, release_time 发布时间):\nid exam_id tag difficulty duration release_time 1 9001 SQL hard 60 2021-01-01 10:00:00 2 9002 C++ hard 80 2021-01-01 10:00:00 3 9003 算法 hard 80 2021-01-01 10:00:00 4 9004 PYTHON medium 70 2021-01-01 10:00:00 试卷作答记录表 exam_record(uid 用户 ID, exam_id 试卷 ID, start_time 开始作答时间, submit_time 交卷时间, score 得分):\nid uid exam_id start_time submit_time score 1 1001 9001 2020-08-02 10:01:01 2020-08-02 10:31:01 89 2 1002 9001 2020-04-01 18:01:01 2020-04-01 18:59:02 90 3 1001 9001 2020-04-01 09:01:01 2020-04-01 09:21:59 80 5 1002 9001 2021-03-02 19:01:01 2021-03-02 19:32:00 20 8 1003 9001 2021-05-02 12:01:01 2021-05-02 12:31:01 98 13 1003 9001 2020-01-02 10:01:01 2020-01-02 10:31:01 89 9 1001 9002 2020-02-02 12:01:01 2020-02-02 12:20:01 99 10 1002 9002 2021-02-02 12:01:01 2020-02-02 12:43:01 81 11 1001 9002 2020-01-02 19:01:01 2020-01-02 19:59:01 69 16 1002 9002 2020-02-02 12:01:01 17 1002 9002 2020-03-02 12:11:01 18 1001 9002 2021-05-05 18:01:01 4 1002 9003 2021-01-20 10:01:01 2021-01-20 10:10:01 81 6 1001 9003 2021-04-02 19:01:01 2021-04-02 19:40:01 89 15 1002 9003 2021-01-01 18:01:01 2021-01-01 18:59:02 90 7 1004 9004 2020-05-02 12:01:01 2020-05-02 12:20:01 99 12 1001 9004 2021-09-02 12:11:01 14 1002 9004 2020-01-01 12:11:01 2020-01-01 12:31:01 83 请计算 2021 年上半年各类试卷的做完次数相比 2020 年上半年同期的增长率(百分比格式,保留 1 位小数),以及做完次数排名变化,按增长率和 21 年排名降序输出。\n由示例数据结果输出如下:\ntag exam_cnt_20 exam_cnt_21 growth_rate exam_cnt_rank_20 exam_cnt_rank_21 rank_delta SQL 3 2 -33.3% 1 2 1 解释:2020 年上半年有 3 个 tag 有作答完成的记录,分别是 C++、SQL、PYTHON,它们被做完的次数分别是 3、3、2,做完次数排名为 1、1(并列)、3;\n2021 年上半年有 2 个 tag 有作答完成的记录,分别是算法、SQL,它们被做完的次数分别是 3、2,做完次数排名为 1、2;具体如下:\ntag start_year exam_cnt exam_cnt_rank C++ 2020 3 1 SQL 2020 3 1 PYTHON 2020 2 3 算法 2021 3 1 SQL 2021 2 2 因此能输出同比结果的 tag 只有 SQL,从 2020 到 2021 年,做完次数 3=\u0026gt;2,减少 33.3%(保留 1 位小数);排名 1=\u0026gt;2,后退 1 名。\n思路:\n本题难点在于长整型的数据类型要求不能有负号产生,用 cast 函数转换数据类型为 signed。\n以及用到的增长率计算公式:(exam_cnt_21-exam_cnt_20)/exam_cnt_20\n做完次数排名变化(2021 年和 2020 年比排名升了或者降了多少)\n计算公式:exam_cnt_rank_21 - exam_cnt_rank_20\n在 MySQL 中,CAST() 函数用于将一个表达式的数据类型转换为另一个数据类型。它的基本语法如下:\nCAST(expression AS data_type) -- 将一个字符串转换成整数 SELECT CAST(\u0026#39;123\u0026#39; AS INT); 示例就不一一举例了,这个函数很简单\n答案:\nSELECT tag, exam_cnt_20, exam_cnt_21, concat( round( 100 * (exam_cnt_21 - exam_cnt_20) / exam_cnt_20, 1 ), \u0026#39;%\u0026#39; ) AS growth_rate, exam_cnt_rank_20, exam_cnt_rank_21, cast(exam_cnt_rank_21 AS signed) - cast(exam_cnt_rank_20 AS signed) AS rank_delta FROM ( #2020年、2021年上半年各类试卷的做完次数和做完次数排名 SELECT tag, count( IF ( date_format(start_time, \u0026#39;%Y%m%d\u0026#39;) BETWEEN \u0026#39;20200101\u0026#39; AND \u0026#39;20200630\u0026#39;, start_time, NULL ) ) AS exam_cnt_20, count( IF ( substring(start_time, 1, 10) BETWEEN \u0026#39;2021-01-01\u0026#39; AND \u0026#39;2021-06-30\u0026#39;, start_time, NULL ) ) AS exam_cnt_21, rank() over ( ORDER BY count( IF ( date_format(start_time, \u0026#39;%Y%m%d\u0026#39;) BETWEEN \u0026#39;20200101\u0026#39; AND \u0026#39;20200630\u0026#39;, start_time, NULL ) ) DESC ) AS exam_cnt_rank_20, rank() over ( ORDER BY count( IF ( substring(start_time, 1, 10) BETWEEN \u0026#39;2021-01-01\u0026#39; AND \u0026#39;2021-06-30\u0026#39;, start_time, NULL ) ) DESC ) AS exam_cnt_rank_21 FROM examination_info JOIN exam_record USING (exam_id) WHERE submit_time IS NOT NULL GROUP BY tag ) main WHERE exam_cnt_21 * exam_cnt_20 \u0026lt;\u0026gt; 0 ORDER BY growth_rate DESC, exam_cnt_rank_21 DESC 聚合窗口函数 # 对试卷得分做 min-max 归一化 # 描述:\n现有试卷信息表 examination_info(exam_id 试卷 ID, tag 试卷类别, difficulty 试卷难度, duration 考试时长, release_time 发布时间):\nid exam_id tag difficulty duration release_time 1 9001 SQL hard 60 2020-01-01 10:00:00 2 9002 C++ hard 80 2020-01-01 10:00:00 3 9003 算法 hard 80 2020-01-01 10:00:00 4 9004 PYTHON medium 70 2020-01-01 10:00:00 试卷作答记录表 exam_record(uid 用户 ID, exam_id 试卷 ID, start_time 开始作答时间, submit_time 交卷时间, score 得分):\nid uid exam_id start_time submit_time score 6 1003 9001 2020-01-02 12:01:01 2020-01-02 12:31:01 68 9 1001 9001 2020-01-02 10:01:01 2020-01-02 10:31:01 89 1 1001 9001 2020-01-01 09:01:01 2020-01-01 09:21:59 90 12 1002 9002 2021-05-05 18:01:01 (NULL) (NULL) 3 1004 9002 2020-01-01 12:01:01 2020-01-01 12:11:01 60 2 1003 9002 2020-01-01 19:01:01 2020-01-01 19:30:01 75 7 1001 9002 2020-01-02 12:01:01 2020-01-02 12:43:01 81 10 1002 9002 2020-01-01 12:11:01 2020-01-01 12:31:01 83 4 1003 9002 2020-01-01 12:01:01 2020-01-01 12:41:01 90 5 1002 9002 2020-01-02 19:01:01 2020-01-02 19:32:00 90 11 1002 9004 2021-09-06 12:01:01 (NULL) (NULL) 8 1001 9005 2020-01-02 12:11:01 (NULL) (NULL) 在物理学及统计学数据计算时,有个概念叫 min-max 标准化,也被称为离差标准化,是对原始数据的线性变换,使结果值映射到[0 - 1]之间。\n转换函数为:\n请你将用户作答高难度试卷的得分在每份试卷作答记录内执行 min-max 归一化后缩放到[0,100]区间,并输出用户 ID、试卷 ID、归一化后分数平均值;最后按照试卷 ID 升序、归一化分数降序输出。(注:得分区间默认为[0,100],如果某个试卷作答记录中只有一个得分,那么无需使用公式,归一化并缩放后分数仍为原分数)。\n由示例数据结果输出如下:\nuid exam_id avg_new_score 1001 9001 98 1003 9001 0 1002 9002 88 1003 9002 75 1001 9002 70 1004 9002 0 解释:高难度试卷有 9001、9002、9003;\n作答了 9001 的记录有 3 条,分数分别为 68、89、90,按给定公式归一化后分数为:0、95、100,而后两个得分都是用户 1001 作答的,因此用户 1001 对试卷 9001 的新得分为(95+100)/2≈98(只保留整数部分),用户 1003 对于试卷 9001 的新得分为 0。最后结果按照试卷 ID 升序、归一化分数降序输出。\n思路:\n注意点:\n将高难度的试卷,按每类试卷的得分,利用 max/min (col) over()窗口函数求得各组内最大最小值,然后进行归一化公式计算,缩放区间为[0,100],即 min_max*100 若某类试卷只有一个得分,则无需使用归一化公式,因只有一个分 max_score=min_score,score,公式后结果可能会变成 0。 最后结果按 uid、exam_id 分组求归一化后均值,score 为 NULL 的要过滤掉。 最后就是仔细看上面公式 (说实话,这题看起来就很绕)\n答案:\nSELECT uid, exam_id, round(sum(min_max) / count(score), 0) AS avg_new_score FROM ( SELECT *, IF ( max_score = min_score, score, (score - min_score) / (max_score - min_score) * 100 ) AS min_max FROM ( SELECT uid, a.exam_id, score, max(score) over (PARTITION BY a.exam_id) AS max_score, min(score) over (PARTITION BY a.exam_id) AS min_score FROM exam_record a LEFT JOIN examination_info b USING (exam_id) WHERE difficulty = \u0026#39;hard\u0026#39; ) t WHERE score IS NOT NULL ) t1 GROUP BY uid, exam_id ORDER BY exam_id ASC, avg_new_score DESC; 每份试卷每月作答数和截止当月的作答总数 # 描述:\n现有试卷作答记录表 exam_record(uid 用户 ID, exam_id 试卷 ID, start_time 开始作答时间, submit_time 交卷时间, score 得分):\nid uid exam_id start_time submit_time score 1 1001 9001 2020-01-01 09:01:01 2020-01-01 09:21:59 90 2 1002 9001 2020-01-20 10:01:01 2020-01-20 10:10:01 89 3 1002 9001 2020-02-01 12:11:01 2020-02-01 12:31:01 83 4 1003 9001 2020-03-01 19:01:01 2020-03-01 19:30:01 75 5 1004 9001 2020-03-01 12:01:01 2020-03-01 12:11:01 60 6 1003 9001 2020-03-01 12:01:01 2020-03-01 12:41:01 90 7 1002 9001 2020-05-02 19:01:01 2020-05-02 19:32:00 90 8 1001 9002 2020-01-02 19:01:01 2020-01-02 19:59:01 69 9 1004 9002 2020-02-02 12:01:01 2020-02-02 12:20:01 99 10 1003 9002 2020-02-02 12:01:01 2020-02-02 12:31:01 68 11 1001 9002 2020-02-02 12:01:01 2020-02-02 12:43:01 81 12 1001 9002 2020-03-02 12:11:01 (NULL) (NULL) 请输出每份试卷每月作答数和截止当月的作答总数。 由示例数据结果输出如下:\nexam_id start_month month_cnt cum_exam_cnt 9001 202001 2 2 9001 202002 1 3 9001 202003 3 6 9001 202005 1 7 9002 202001 1 1 9002 202002 3 4 9002 202003 1 5 解释:试卷 9001 在 202001、202002、202003、202005 共 4 个月有被作答记录,每个月被作答数分别为 2、1、3、1,截止当月累积作答总数为 2、3、6、7。\n思路:\n这题就两个关键点:统计截止当月的作答总数、输出每份试卷每月作答数和截止当月的作答总数\n这个是关键==sum(count(*)) over(partition by exam_id order by date_format(start_time,'%Y%m'))==\n答案:\nSELECT exam_id, date_format(start_time, \u0026#39;%Y%m\u0026#39;) AS start_month, count(*) AS month_cnt, sum(count(*)) OVER (PARTITION BY exam_id ORDER BY date_format(start_time, \u0026#39;%Y%m\u0026#39;)) AS cum_exam_cnt FROM exam_record GROUP BY exam_id, start_month 每月及截止当月的答题情况(较难) # 描述:现有试卷作答记录表 exam_record(uid 用户 ID, exam_id 试卷 ID, start_time 开始作答时间, submit_time 交卷时间, score 得分):\nid uid exam_id start_time submit_time score 1 1001 9001 2020-01-01 09:01:01 2020-01-01 09:21:59 90 2 1002 9001 2020-01-20 10:01:01 2020-01-20 10:10:01 89 3 1002 9001 2020-02-01 12:11:01 2020-02-01 12:31:01 83 4 1003 9001 2020-03-01 19:01:01 2020-03-01 19:30:01 75 5 1004 9001 2020-03-01 12:01:01 2020-03-01 12:11:01 60 6 1003 9001 2020-03-01 12:01:01 2020-03-01 12:41:01 90 7 1002 9001 2020-05-02 19:01:01 2020-05-02 19:32:00 90 8 1001 9002 2020-01-02 19:01:01 2020-01-02 19:59:01 69 9 1004 9002 2020-02-02 12:01:01 2020-02-02 12:20:01 99 10 1003 9002 2020-02-02 12:01:01 2020-02-02 12:31:01 68 11 1001 9002 2020-01-02 19:01:01 2020-02-02 12:43:01 81 12 1001 9002 2020-03-02 12:11:01 (NULL) (NULL) 请输出自从有用户作答记录以来,每月的试卷作答记录中月活用户数、新增用户数、截止当月的单月最大新增用户数、截止当月的累积用户数。结果按月份升序输出。\n由示例数据结果输出如下:\nstart_month mau month_add_uv max_month_add_uv cum_sum_uv 202001 2 2 2 2 202002 4 2 2 4 202003 3 0 2 4 202005 1 0 2 4 month 1001 1002 1003 1004 202001 1 1 202002 1 1 1 1 202003 1 1 1 202005 1 由上述矩阵可以看出,2020 年 1 月有 2 个用户活跃(mau=2),当月新增用户数为 2;\n2020 年 2 月有 4 个用户活跃,当月新增用户数为 2,最大单月新增用户数为 2,当前累积用户数为 4。\n思路:\n难点:\n1.如何求每月新增用户\n2.截至当月的答题情况\n大致流程:\n(1)统计每个人的首次登陆月份 min()\n(2)统计每月的月活和新增用户数:先得到每个人的首次登陆月份,再对首次登陆月份分组求和是该月份的新增人数\n(3)统计截止当月的单月最大新增用户数、截止当月的累积用户数 ,最终按照按月份升序输出\n答案:\n-- 截止当月的单月最大新增用户数、截止当月的累积用户数,按月份升序输出 SELECT start_month, mau, month_add_uv, max( month_add_uv ) over ( ORDER BY start_month ), sum( month_add_uv ) over ( ORDER BY start_month ) FROM ( -- 统计每月的月活和新增用户数 SELECT date_format( a.start_time, \u0026#39;%Y%m\u0026#39; ) AS start_month, count( DISTINCT a.uid ) AS mau, count( DISTINCT b.uid ) AS month_add_uv FROM exam_record a LEFT JOIN ( -- 统计每个人的首次登陆月份 SELECT uid, min( date_format( start_time, \u0026#39;%Y%m\u0026#39; )) AS first_month FROM exam_record GROUP BY uid ) b ON date_format( a.start_time, \u0026#39;%Y%m\u0026#39; ) = b.first_month GROUP BY start_month ) main ORDER BY start_month "},{"id":581,"href":"/zh/docs/technology/Interview/database/sql/sql-questions-05/","title":"SQL常见面试题总结(5)","section":"SQL","content":" 题目来源于: 牛客题霸 - SQL 进阶挑战\n较难或者困难的题目可以根据自身实际情况和面试需要来决定是否要跳过。\n空值处理 # 统计有未完成状态的试卷的未完成数和未完成率 # 描述:\n现有试卷作答记录表 exam_record(uid 用户 ID, exam_id 试卷 ID, start_time 开始作答时间, submit_time 交卷时间, score 得分),数据如下:\nid uid exam_id start_time submit_time score 1 1001 9001 2020-01-02 09:01:01 2020-01-02 09:21:01 80 2 1001 9001 2021-05-02 10:01:01 2021-05-02 10:30:01 81 3 1001 9001 2021-09-02 12:01:01 (NULL) (NULL) 请统计有未完成状态的试卷的未完成数 incomplete_cnt 和未完成率 incomplete_rate。由示例数据结果输出如下:\nexam_id incomplete_cnt complete_rate 9001 1 0.333 解释:试卷 9001 有 3 次被作答的记录,其中两次完成,1 次未完成,因此未完成数为 1,未完成率为 0.333(保留 3 位小数)\n思路:\n这题只需要注意一个是有条件限制,一个是没条件限制的;要么分别查询条件,然后合并;要么直接在 select 里面进行条件判断。\n答案:\n写法 1:\nSELECT exam_id, count(submit_time IS NULL OR NULL) incomplete_cnt, ROUND(count(submit_time IS NULL OR NULL) / count(*), 3) complete_rate FROM exam_record GROUP BY exam_id HAVING incomplete_cnt \u0026lt;\u0026gt; 0 写法 2:\nSELECT exam_id, count(submit_time IS NULL OR NULL) incomplete_cnt, ROUND(count(submit_time IS NULL OR NULL) / count(*), 3) complete_rate FROM exam_record GROUP BY exam_id HAVING incomplete_cnt \u0026lt;\u0026gt; 0 两种写法都可以,只有中间的写法不一样,一个是对符合条件的才COUNT,一个是直接上IF,后者更为直观,最后这个having解释一下, 无论是 complete_rate 还是 incomplete_cnt,只要不为 0 即可,不为 0 就意味着有未完成的。\n0 级用户高难度试卷的平均用时和平均得分 # 描述:\n现有用户信息表 user_info(uid 用户 ID,nick_name 昵称, achievement 成就值, level 等级, job 职业方向, register_time 注册时间),数据如下:\nid uid nick_name achievement level job register_time 1 1001 牛客 1 号 10 0 算法 2020-01-01 10:00:00 2 1002 牛客 2 号 2100 6 算法 2020-01-01 10:00:00 试卷信息表 examination_info(exam_id 试卷 ID, tag 试卷类别, difficulty 试卷难度, duration 考试时长, release_time 发布时间),数据如下:\nid exam_id tag difficulty duration release_time 1 9001 SQL hard 60 2020-01-01 10:00:00 2 9002 SQL easy 60 2020-01-01 10:00:00 3 9004 算法 medium 80 2020-01-01 10:00:00 试卷作答记录表 exam_record(uid 用户 ID, exam_id 试卷 ID, start_time 开始作答时间, submit_time 交卷时间, score 得分),数据如下:\nid uid exam_id start_time submit_time score 1 1001 9001 2020-01-02 09:01:01 2020-01-02 09:21:59 80 2 1001 9001 2021-05-02 10:01:01 (NULL) (NULL) 3 1001 9002 2021-02-02 19:01:01 2021-02-02 19:30:01 87 4 1001 9001 2021-06-02 19:01:01 2021-06-02 19:32:00 20 5 1001 9002 2021-09-05 19:01:01 2021-09-05 19:40:01 89 6 1001 9002 2021-09-01 12:01:01 (NULL) (NULL) 7 1002 9002 2021-05-05 18:01:01 2021-05-05 18:59:02 90 请输出每个 0 级用户所有的高难度试卷考试平均用时和平均得分,未完成的默认试卷最大考试时长和 0 分处理。由示例数据结果输出如下:\nuid avg_score avg_time_took 1001 33 36.7 解释:0 级用户有 1001,高难度试卷有 9001,1001 作答 9001 的记录有 3 条,分别用时 20 分钟、未完成(试卷时长 60 分钟)、30 分钟(未满 31 分钟),分别得分为 80 分、未完成(0 分处理)、20 分。因此他的平均用时为 110/3=36.7(保留一位小数),平均得分为 33 分(取整)\n思路:这题用IF是判断的最方便的,因为涉及到 NULL 值的判断。当然 case when也可以,大同小异。这题的难点就在于空值的处理,其他的这些查询条件什么的,我相信难不倒大家。\n答案:\nSELECT UID, round(avg(new_socre)) AS avg_score, round(avg(time_diff), 1) AS avg_time_took FROM (SELECT er.uid, IF (er.submit_time IS NOT NULL, TIMESTAMPDIFF(MINUTE, start_time, submit_time), ef.duration) AS time_diff, IF (er.submit_time IS NOT NULL,er.score,0) AS new_socre FROM exam_record er LEFT JOIN user_info uf ON er.uid = uf.uid LEFT JOIN examination_info ef ON er.exam_id = ef.exam_id WHERE uf.LEVEL = 0 AND ef.difficulty = \u0026#39;hard\u0026#39; ) t GROUP BY UID ORDER BY UID 高级条件语句 # 筛选限定昵称成就值活跃日期的用户(较难) # 描述:\n现有用户信息表 user_info(uid 用户 ID,nick_name 昵称, achievement 成就值, level 等级, job 职业方向, register_time 注册时间):\nid uid nick_name achievement level job register_time 1 1001 牛客 1 号 1000 2 算法 2020-01-01 10:00:00 2 1002 牛客 2 号 1200 3 算法 2020-01-01 10:00:00 3 1003 进击的 3 号 2200 5 算法 2020-01-01 10:00:00 4 1004 牛客 4 号 2500 6 算法 2020-01-01 10:00:00 5 1005 牛客 5 号 3000 7 C++ 2020-01-01 10:00:00 试卷作答记录表 exam_record(uid 用户 ID, exam_id 试卷 ID, start_time 开始作答时间, submit_time 交卷时间, score 得分):\nid uid exam_id start_time submit_time score 1 1001 9001 2020-01-02 09:01:01 2020-01-02 09:21:59 80 3 1001 9002 2021-02-02 19:01:01 2021-02-02 19:30:01 87 2 1001 9001 2021-05-02 10:01:01 (NULL) (NULL) 4 1001 9001 2021-06-02 19:01:01 2021-06-02 19:32:00 20 6 1001 9002 2021-09-01 12:01:01 (NULL) (NULL) 5 1001 9002 2021-09-05 19:01:01 2021-09-05 19:40:01 89 11 1002 9001 2020-01-01 12:01:01 2020-01-01 12:31:01 81 12 1002 9002 2020-02-01 12:01:01 2020-02-01 12:31:01 82 13 1002 9002 2020-02-02 12:11:01 2020-02-02 12:31:01 83 7 1002 9002 2021-05-05 18:01:01 2021-05-05 18:59:02 90 16 1002 9001 2021-09-06 12:01:01 2021-09-06 12:21:01 80 17 1002 9001 2021-09-06 12:01:01 (NULL) (NULL) 18 1002 9001 2021-09-07 12:01:01 (NULL) (NULL) 8 1003 9003 2021-02-06 12:01:01 (NULL) (NULL) 9 1003 9001 2021-09-07 10:01:01 2021-09-07 10:31:01 89 10 1004 9002 2021-08-06 12:01:01 (NULL) (NULL) 14 1005 9001 2021-02-01 11:01:01 2021-02-01 11:31:01 84 15 1006 9001 2021-02-01 11:01:01 2021-02-01 11:31:01 84 题目练习记录表 practice_record(uid 用户 ID, question_id 题目 ID, submit_time 提交时间, score 得分):\nid uid question_id submit_time score 1 1001 8001 2021-08-02 11:41:01 60 2 1002 8001 2021-09-02 19:30:01 50 3 1002 8001 2021-09-02 19:20:01 70 4 1002 8002 2021-09-02 19:38:01 70 5 1003 8002 2021-09-01 19:38:01 80 请找到昵称以『牛客』开头『号』结尾、成就值在 1200~2500 之间,且最近一次活跃(答题或作答试卷)在 2021 年 9 月的用户信息。\n由示例数据结果输出如下:\nuid nick_name achievement 1002 牛客 2 号 1200 解释:昵称以『牛客』开头『号』结尾且成就值在 1200~2500 之间的有 1002、1004;\n1002 最近一次试卷区活跃为 2021 年 9 月,最近一次题目区活跃为 2021 年 9 月;1004 最近一次试卷区活跃为 2021 年 8 月,题目区未活跃。\n因此最终满足条件的只有 1002。\n思路:\n先根据条件列出主要查询语句\n昵称以『牛客』开头『号』结尾: nick_name LIKE \u0026quot;牛客%号\u0026quot;\n成就值在 1200~2500 之间:achievement BETWEEN 1200 AND 2500\n第三个条件因为限定了为 9 月,所以直接写就行:( date_format( record.submit_time, '%Y%m' )= 202109 OR date_format( pr.submit_time, '%Y%m' )= 202109 )\n答案:\nSELECT DISTINCT u_info.uid, u_info.nick_name, u_info.achievement FROM user_info u_info LEFT JOIN exam_record record ON record.uid = u_info.uid LEFT JOIN practice_record pr ON u_info.uid = pr.uid WHERE u_info.nick_name LIKE \u0026#34;牛客%号\u0026#34; AND u_info.achievement BETWEEN 1200 AND 2500 AND (date_format(record.submit_time, \u0026#39;%Y%m\u0026#39;)= 202109 OR date_format(pr.submit_time, \u0026#39;%Y%m\u0026#39;)= 202109) GROUP BY u_info.uid 筛选昵称规则和试卷规则的作答记录(较难) # 描述:\n现有用户信息表 user_info(uid 用户 ID,nick_name 昵称, achievement 成就值, level 等级, job 职业方向, register_time 注册时间):\nid uid nick_name achievement level job register_time 1 1001 牛客 1 号 1900 2 算法 2020-01-01 10:00:00 2 1002 牛客 2 号 1200 3 算法 2020-01-01 10:00:00 3 1003 牛客 3 号 ♂ 2200 5 算法 2020-01-01 10:00:00 4 1004 牛客 4 号 2500 6 算法 2020-01-01 10:00:00 5 1005 牛客 555 号 2000 7 C++ 2020-01-01 10:00:00 6 1006 666666 3000 6 C++ 2020-01-01 10:00:00 试卷信息表 examination_info(exam_id 试卷 ID, tag 试卷类别, difficulty 试卷难度, duration 考试时长, release_time 发布时间):\nid exam_id tag difficulty duration release_time 1 9001 C++ hard 60 2020-01-01 10:00:00 2 9002 c# hard 80 2020-01-01 10:00:00 3 9003 SQL medium 70 2020-01-01 10:00:00 试卷作答记录表 exam_record(uid 用户 ID, exam_id 试卷 ID, start_time 开始作答时间, submit_time 交卷时间, score 得分):\nid uid exam_id start_time submit_time score 1 1001 9001 2020-01-02 09:01:01 2020-01-02 09:21:59 80 2 1001 9001 2021-05-02 10:01:01 (NULL) (NULL) 4 1001 9001 2021-06-02 19:01:01 2021-06-02 19:32:00 20 3 1001 9002 2021-02-02 19:01:01 2021-02-02 19:30:01 87 5 1001 9002 2021-09-05 19:01:01 2021-09-05 19:40:01 89 6 1001 9002 2021-09-01 12:01:01 (NULL) (NULL) 11 1002 9001 2020-01-01 12:01:01 2020-01-01 12:31:01 81 16 1002 9001 2021-09-06 12:01:01 2021-09-06 12:21:01 80 17 1002 9001 2021-09-06 12:01:01 (NULL) (NULL) 18 1002 9001 2021-09-07 12:01:01 (NULL) (NULL) 7 1002 9002 2021-05-05 18:01:01 2021-05-05 18:59:02 90 12 1002 9002 2020-02-01 12:01:01 2020-02-01 12:31:01 82 13 1002 9002 2020-02-02 12:11:01 2020-02-02 12:31:01 83 9 1003 9001 2021-09-07 10:01:01 2021-09-07 10:31:01 89 8 1003 9003 2021-02-06 12:01:01 (NULL) (NULL) 10 1004 9002 2021-08-06 12:01:01 (NULL) (NULL) 14 1005 9001 2021-02-01 11:01:01 2021-02-01 11:31:01 84 15 1006 9001 2021-02-01 11:01:01 2021-09-01 11:31:01 84 找到昵称以\u0026quot;牛客\u0026quot;+纯数字+\u0026ldquo;号\u0026quot;或者纯数字组成的用户对于字母 c 开头的试卷类别(如 C,C++,c#等)的已完成的试卷 ID 和平均得分,按用户 ID、平均分升序排序。由示例数据结果输出如下:\nuid exam_id avg_score 1002 9001 81 1002 9002 85 1005 9001 84 1006 9001 84 解释:昵称满足条件的用户有 1002、1004、1005、1006;\nc 开头的试卷有 9001、9002;\n满足上述条件的作答记录中,1002 完成 9001 的得分有 81、80,平均分为 81(80.5 取整四舍五入得 81);\n1002 完成 9002 的得分有 90、82、83,平均分为 85;\n思路:\n还是老样子,既然给出了条件,就先把各个条件先写出来\n找到昵称以\u0026quot;牛客\u0026rdquo;+纯数字+\u0026ldquo;号\u0026quot;或者纯数字组成的用户: 我最开始是这么写的:nick_name LIKE '牛客%号' OR nick_name REGEXP '^[0-9]+$',如果表中有个 “牛客 H 号” ,那也能通过。\n所以这里还得用正则: nick_name LIKE '^牛客[0-9]+号'\n对于字母 c 开头的试卷类别: e_info.tag LIKE 'c%' 或者 tag regexp '^c|^C' 第一个也能匹配到大写 C\n答案:\nSELECT UID, exam_id, ROUND(AVG(score), 0) avg_score FROM exam_record WHERE UID IN (SELECT UID FROM user_info WHERE nick_name RLIKE \u0026#34;^牛客[0-9]+号 $\u0026#34; OR nick_name RLIKE \u0026#34;^[0-9]+$\u0026#34;) AND exam_id IN (SELECT exam_id FROM examination_info WHERE tag RLIKE \u0026#34;^[cC]\u0026#34;) AND score IS NOT NULL GROUP BY UID,exam_id ORDER BY UID,avg_score; 根据指定记录是否存在输出不同情况(困难) # 描述:\n现有用户信息表 user_info(uid 用户 ID,nick_name 昵称, achievement 成就值, level 等级, job 职业方向, register_time 注册时间):\nid uid nick_name achievement level job register_time 1 1001 牛客 1 号 19 0 算法 2020-01-01 10:00:00 2 1002 牛客 2 号 1200 3 算法 2020-01-01 10:00:00 3 1003 进击的 3 号 22 0 算法 2020-01-01 10:00:00 4 1004 牛客 4 号 25 0 算法 2020-01-01 10:00:00 5 1005 牛客 555 号 2000 7 C++ 2020-01-01 10:00:00 6 1006 666666 3000 6 C++ 2020-01-01 10:00:00 试卷作答记录表 exam_record(uid 用户 ID, exam_id 试卷 ID, start_time 开始作答时间, submit_time 交卷时间, score 得分):\nid uid exam_id start_time submit_time score 1 1001 9001 2020-01-02 09:01:01 2020-01-02 09:21:59 80 2 1001 9001 2021-05-02 10:01:01 (NULL) (NULL) 3 1001 9002 2021-02-02 19:01:01 2021-02-02 19:30:01 87 4 1001 9002 2021-09-01 12:01:01 (NULL) (NULL) 5 1001 9003 2021-09-02 12:01:01 (NULL) (NULL) 6 1001 9004 2021-09-03 12:01:01 (NULL) (NULL) 7 1002 9001 2020-01-01 12:01:01 2020-01-01 12:31:01 99 8 1002 9003 2020-02-01 12:01:01 2020-02-01 12:31:01 82 9 1002 9003 2020-02-02 12:11:01 (NULL) (NULL) 10 1002 9002 2021-05-05 18:01:01 (NULL) (NULL) 11 1002 9001 2021-09-06 12:01:01 (NULL) (NULL) 12 1003 9003 2021-02-06 12:01:01 (NULL) (NULL) 13 1003 9001 2021-09-07 10:01:01 2021-09-07 10:31:01 89 请你筛选表中的数据,当有任意一个 0 级用户未完成试卷数大于 2 时,输出每个 0 级用户的试卷未完成数和未完成率(保留 3 位小数);若不存在这样的用户,则输出所有有作答记录的用户的这两个指标。结果按未完成率升序排序。\n由示例数据结果输出如下:\nuid incomplete_cnt incomplete_rate 1004 0 0.000 1003 1 0.500 1001 4 0.667 解释:0 级用户有 1001、1003、1004;他们作答试卷数和未完成数分别为:6:4、2:1、0:0;\n存在 1001 这个 0 级用户未完成试卷数大于 2,因此输出这三个用户的未完成数和未完成率(1004 未作答过试卷,未完成率默认填 0,保留 3 位小数后是 0.000);\n结果按照未完成率升序排序。\n附:如果 1001 不满足『未完成试卷数大于 2』,则需要输出 1001、1002、1003 的这两个指标,因为试卷作答记录表里只有这三个用户的作答记录。\n思路:\n先把可能满足条件==“0 级用户未完成试卷数大于 2”==的 SQL 写出来\nSELECT ui.uid UID FROM user_info ui LEFT JOIN exam_record er ON ui.uid = er.uid WHERE ui.uid IN (SELECT ui.uid FROM user_info ui LEFT JOIN exam_record er ON ui.uid = er.uid WHERE er.submit_time IS NULL AND ui.LEVEL = 0 ) GROUP BY ui.uid HAVING sum(IF(er.submit_time IS NULL, 1, 0)) \u0026gt; 2 然后再分别写出两种情况的 SQL 查询语句:\n情况 1. 查询存在条件要求的 0 级用户的试卷未完成率\nSELECT tmp1.uid uid, sum( IF ( er.submit_time IS NULL AND er.start_time IS NOT NULL, 1, 0 )) incomplete_cnt, round( sum( IF ( er.submit_time IS NULL AND er.start_time IS NOT NULL, 1, 0 ))/ count( tmp1.uid ), 3 ) incomplete_rate FROM ( SELECT DISTINCT ui.uid FROM user_info ui LEFT JOIN exam_record er ON ui.uid = er.uid WHERE er.submit_time IS NULL AND ui.LEVEL = 0 ) tmp1 LEFT JOIN exam_record er ON tmp1.uid = er.uid GROUP BY tmp1.uid ORDER BY incomplete_rate 情况 2. 查询不存在条件要求时所有有作答记录的 yong 用户的试卷未完成率\nSELECT ui.uid uid, sum( CASE WHEN er.submit_time IS NULL AND er.start_time IS NOT NULL THEN 1 ELSE 0 END ) incomplete_cnt, round( sum( IF ( er.submit_time IS NULL AND er.start_time IS NOT NULL, 1, 0 ))/ count( ui.uid ), 3 ) incomplete_rate FROM user_info ui JOIN exam_record er ON ui.uid = er.uid GROUP BY ui.uid ORDER BY incomplete_rate 拼在一起,就是答案\nWITH host_user AS (SELECT ui.uid UID FROM user_info ui LEFT JOIN exam_record er ON ui.uid = er.uid WHERE ui.uid IN (SELECT ui.uid FROM user_info ui LEFT JOIN exam_record er ON ui.uid = er.uid WHERE er.submit_time IS NULL AND ui.LEVEL = 0 ) GROUP BY ui.uid HAVING sum(IF (er.submit_time IS NULL, 1, 0))\u0026gt; 2), tt1 AS (SELECT tmp1.uid UID, sum(IF (er.submit_time IS NULL AND er.start_time IS NOT NULL, 1, 0)) incomplete_cnt, round(sum(IF (er.submit_time IS NULL AND er.start_time IS NOT NULL, 1, 0))/ count(tmp1.uid), 3) incomplete_rate FROM (SELECT DISTINCT ui.uid FROM user_info ui LEFT JOIN exam_record er ON ui.uid = er.uid WHERE er.submit_time IS NULL AND ui.LEVEL = 0 ) tmp1 LEFT JOIN exam_record er ON tmp1.uid = er.uid GROUP BY tmp1.uid ORDER BY incomplete_rate), tt2 AS (SELECT ui.uid UID, sum(CASE WHEN er.submit_time IS NULL AND er.start_time IS NOT NULL THEN 1 ELSE 0 END) incomplete_cnt, round(sum(IF (er.submit_time IS NULL AND er.start_time IS NOT NULL, 1, 0))/ count(ui.uid), 3) incomplete_rate FROM user_info ui JOIN exam_record er ON ui.uid = er.uid GROUP BY ui.uid ORDER BY incomplete_rate) (SELECT tt1.* FROM tt1 LEFT JOIN (SELECT UID FROM host_user) t1 ON 1 = 1 WHERE t1.uid IS NOT NULL ) UNION ALL (SELECT tt2.* FROM tt2 LEFT JOIN (SELECT UID FROM host_user) t2 ON 1 = 1 WHERE t2.uid IS NULL) V2 版本(根据上面做出的改进,答案缩短了,逻辑更强):\nSELECT ui.uid, SUM( IF ( start_time IS NOT NULL AND score IS NULL, 1, 0 )) AS incomplete_cnt,#3.试卷未完成数 ROUND( AVG( IF ( start_time IS NOT NULL AND score IS NULL, 1, 0 )), 3 ) AS incomplete_rate #4.未完成率 FROM user_info ui LEFT JOIN exam_record USING ( uid ) WHERE CASE WHEN (#1.当有任意一个0级用户未完成试卷数大于2时 SELECT MAX( lv0_incom_cnt ) FROM ( SELECT SUM( IF ( score IS NULL, 1, 0 )) AS lv0_incom_cnt FROM user_info JOIN exam_record USING ( uid ) WHERE LEVEL = 0 GROUP BY uid ) table1 )\u0026gt; 2 THEN uid IN ( #1.1找出每个0级用户 SELECT uid FROM user_info WHERE LEVEL = 0 ) ELSE uid IN ( #2.若不存在这样的用户,找出有作答记录的用户 SELECT DISTINCT uid FROM exam_record ) END GROUP BY ui.uid ORDER BY incomplete_rate #5.结果按未完成率升序排序 各用户等级的不同得分表现占比(较难) # 描述:\n现有用户信息表 user_info(uid 用户 ID,nick_name 昵称, achievement 成就值, level 等级, job 职业方向, register_time 注册时间):\nid uid nick_name achievement level job register_time 1 1001 牛客 1 号 19 0 算法 2020-01-01 10:00:00 2 1002 牛客 2 号 1200 3 算法 2020-01-01 10:00:00 3 1003 牛客 3 号 ♂ 22 0 算法 2020-01-01 10:00:00 4 1004 牛客 4 号 25 0 算法 2020-01-01 10:00:00 5 1005 牛客 555 号 2000 7 C++ 2020-01-01 10:00:00 6 1006 666666 3000 6 C++ 2020-01-01 10:00:00 试卷作答记录表 exam_record(uid 用户 ID, exam_id 试卷 ID, start_time 开始作答时间, submit_time 交卷时间, score 得分):\nid uid exam_id start_time submit_time score 1 1001 9001 2020-01-02 09:01:01 2020-01-02 09:21:59 80 2 1001 9001 2021-05-02 10:01:01 (NULL) (NULL) 3 1001 9002 2021-02-02 19:01:01 2021-02-02 19:30:01 75 4 1001 9002 2021-09-01 12:01:01 2021-09-01 12:11:01 60 5 1001 9003 2021-09-02 12:01:01 2021-09-02 12:41:01 90 6 1001 9001 2021-06-02 19:01:01 2021-06-02 19:32:00 20 7 1001 9002 2021-09-05 19:01:01 2021-09-05 19:40:01 89 8 1001 9004 2021-09-03 12:01:01 (NULL) (NULL) 9 1002 9001 2020-01-01 12:01:01 2020-01-01 12:31:01 99 10 1002 9003 2020-02-01 12:01:01 2020-02-01 12:31:01 82 11 1002 9003 2020-02-02 12:11:01 2020-02-02 12:41:01 76 为了得到用户试卷作答的定性表现,我们将试卷得分按分界点[90,75,60]分为优良中差四个得分等级(分界点划分到左区间),请统计不同用户等级的人在完成过的试卷中各得分等级占比(结果保留 3 位小数),未完成过试卷的用户无需输出,结果按用户等级降序、占比降序排序。\n由示例数据结果输出如下:\nlevel score_grade ratio 3 良 0.667 3 优 0.333 0 良 0.500 0 中 0.167 0 优 0.167 0 差 0.167 解释:完成过试卷的用户有 1001、1002;完成了的试卷对应的用户等级和分数等级如下:\nuid exam_id score level score_grade 1001 9001 80 0 良 1001 9002 75 0 良 1001 9002 60 0 中 1001 9003 90 0 优 1001 9001 20 0 差 1001 9002 89 0 良 1002 9001 99 3 优 1002 9003 82 3 良 1002 9003 76 3 良 因此 0 级用户(只有 1001)的各分数等级比例为:优 1/6,良 1/6,中 1/6,差 3/6;3 级用户(只有 1002)各分数等级比例为:优 1/3,良 2/3。结果保留 3 位小数。\n思路:\n先把 ==“将试卷得分按分界点[90,75,60]分为优良中差四个得分等级”==这个条件写出来,这里可以用到case when\nCASE WHEN a.score \u0026gt;= 90 THEN \u0026#39;优\u0026#39; WHEN a.score \u0026lt; 90 AND a.score \u0026gt;= 75 THEN \u0026#39;良\u0026#39; WHEN a.score \u0026lt; 75 AND a.score \u0026gt;= 60 THEN \u0026#39;中\u0026#39; ELSE \u0026#39;差\u0026#39; END 这题的关键点就在于这,其他剩下的就是条件拼接了\n答案:\nSELECT a.LEVEL, a.score_grade, ROUND(a.cur_count / b.total_num, 3) AS ratio FROM (SELECT b.LEVEL AS LEVEL, (CASE WHEN a.score \u0026gt;= 90 THEN \u0026#39;优\u0026#39; WHEN a.score \u0026lt; 90 AND a.score \u0026gt;= 75 THEN \u0026#39;良\u0026#39; WHEN a.score \u0026lt; 75 AND a.score \u0026gt;= 60 THEN \u0026#39;中\u0026#39; ELSE \u0026#39;差\u0026#39; END) AS score_grade, count(1) AS cur_count FROM exam_record a LEFT JOIN user_info b ON a.uid = b.uid WHERE a.submit_time IS NOT NULL GROUP BY b.LEVEL, score_grade) a LEFT JOIN (SELECT b.LEVEL AS LEVEL, count(b.LEVEL) AS total_num FROM exam_record a LEFT JOIN user_info b ON a.uid = b.uid WHERE a.submit_time IS NOT NULL GROUP BY b.LEVEL) b ON a.LEVEL = b.LEVEL ORDER BY a.LEVEL DESC, ratio DESC 限量查询 # 注册时间最早的三个人 # 描述:\n现有用户信息表 user_info(uid 用户 ID,nick_name 昵称, achievement 成就值, level 等级, job 职业方向, register_time 注册时间):\nid uid nick_name achievement level job register_time 1 1001 牛客 1 号 19 0 算法 2020-01-01 10:00:00 2 1002 牛客 2 号 1200 3 算法 2020-02-01 10:00:00 3 1003 牛客 3 号 ♂ 22 0 算法 2020-01-02 10:00:00 4 1004 牛客 4 号 25 0 算法 2020-01-02 11:00:00 5 1005 牛客 555 号 4000 7 C++ 2020-01-11 10:00:00 6 1006 666666 3000 6 C++ 2020-11-01 10:00:00 请从中找到注册时间最早的 3 个人。由示例数据结果输出如下:\nuid nick_name register_time 1001 牛客 1 2020-01-01 10:00:00 1003 牛客 3 号 ♂ 2020-01-02 10:00:00 1004 牛客 4 号 2020-01-02 11:00:00 解释:按注册时间排序后选取前三名,输出其用户 ID、昵称、注册时间。\n答案:\nSELECT uid, nick_name, register_time FROM user_info ORDER BY register_time LIMIT 3 注册当天就完成了试卷的名单第三页(较难) # 描述:现有用户信息表 user_info(uid 用户 ID,nick_name 昵称, achievement 成就值, level 等级, job 职业方向, register_time 注册时间):\nid uid nick_name achievement level job register_time 1 1001 牛客 1 19 0 算法 2020-01-01 10:00:00 2 1002 牛客 2 号 1200 3 算法 2020-01-01 10:00:00 3 1003 牛客 3 号 ♂ 22 0 算法 2020-01-01 10:00:00 4 1004 牛客 4 号 25 0 算法 2020-01-01 10:00:00 5 1005 牛客 555 号 4000 7 算法 2020-01-11 10:00:00 6 1006 牛客 6 号 25 0 算法 2020-01-02 11:00:00 7 1007 牛客 7 号 25 0 算法 2020-01-02 11:00:00 8 1008 牛客 8 号 25 0 算法 2020-01-02 11:00:00 9 1009 牛客 9 号 25 0 算法 2020-01-02 11:00:00 10 1010 牛客 10 号 25 0 算法 2020-01-02 11:00:00 11 1011 666666 3000 6 C++ 2020-01-02 10:00:00 试卷信息表 examination_info(exam_id 试卷 ID, tag 试卷类别, difficulty 试卷难度, duration 考试时长, release_time 发布时间):\nid exam_id tag difficulty duration release_time 1 9001 算法 hard 60 2020-01-01 10:00:00 2 9002 算法 hard 80 2020-01-01 10:00:00 3 9003 SQL medium 70 2020-01-01 10:00:00 试卷作答记录表 exam_record(uid 用户 ID, exam_id 试卷 ID, start_time 开始作答时间, submit_time 交卷时间, score 得分):\nid uid exam_id start_time submit_time score 1 1001 9001 2020-01-02 09:01:01 2020-01-02 09:21:59 80 2 1002 9003 2020-01-20 10:01:01 2020-01-20 10:10:01 81 3 1002 9002 2020-01-01 12:11:01 2020-01-01 12:31:01 83 4 1003 9002 2020-01-01 19:01:01 2020-01-01 19:30:01 75 5 1004 9002 2020-01-01 12:01:01 2020-01-01 12:11:01 60 6 1005 9002 2020-01-01 12:01:01 2020-01-01 12:41:01 90 7 1006 9001 2020-01-02 19:01:01 2020-01-02 19:32:00 20 8 1007 9002 2020-01-02 19:01:01 2020-01-02 19:40:01 89 9 1008 9003 2020-01-02 12:01:01 2020-01-02 12:20:01 99 10 1008 9001 2020-01-02 12:01:01 2020-01-02 12:31:01 98 11 1009 9002 2020-01-02 12:01:01 2020-01-02 12:31:01 82 12 1010 9002 2020-01-02 12:11:01 2020-01-02 12:41:01 76 13 1011 9001 2020-01-02 10:01:01 2020-01-02 10:31:01 89 找到求职方向为算法工程师,且注册当天就完成了算法类试卷的人,按参加过的所有考试最高得分排名。排名榜很长,我们将采用分页展示,每页 3 条,现在需要你取出第 3 页(页码从 1 开始)的人的信息。\n由示例数据结果输出如下:\nuid level register_time max_score 1010 0 2020-01-02 11:00:00 76 1003 0 2020-01-01 10:00:00 75 1004 0 2020-01-01 11:00:00 60 解释:除了 1011 其他用户的求职方向都为算法工程师;算法类试卷有 9001 和 9002,11 个用户注册当天都完成了算法类试卷;计算他们的所有考试最大分时,只有 1002 和 1008 完成了两次考试,其他人只完成了一场考试,1002 两场考试最高分为 81,1008 最高分为 99。\n按最高分排名如下:\nuid level register_time max_score 1008 0 2020-01-02 11:00:00 99 1005 7 2020-01-01 10:00:00 90 1007 0 2020-01-02 11:00:00 89 1002 3 2020-01-01 10:00:00 83 1009 0 2020-01-02 11:00:00 82 1001 0 2020-01-01 10:00:00 80 1010 0 2020-01-02 11:00:00 76 1003 0 2020-01-01 10:00:00 75 1004 0 2020-01-01 11:00:00 60 1006 0 2020-01-02 11:00:00 20 每页 3 条,第三页也就是第 7~9 条,返回 1010、1003、1004 的行记录即可。\n思路:\n每页三条,即需要取出第三页的人的信息,要用到limit\n统计求职方向为算法工程师且注册当天就完成了算法类试卷的人的信息和每次记录的得分,先求满足条件的用户,后用 left join 做连接查找信息和每次记录的得分\n答案:\nSELECT t1.uid, LEVEL, register_time, max(score) AS max_score FROM exam_record t JOIN examination_info USING (exam_id) JOIN user_info t1 ON t.uid = t1.uid AND date(t.submit_time) = date(t1.register_time) WHERE job = \u0026#39;算法\u0026#39; AND tag = \u0026#39;算法\u0026#39; GROUP BY t1.uid, LEVEL, register_time ORDER BY max_score DESC LIMIT 6,3 文本转换函数 # 修复串列了的记录 # 描述:现有试卷信息表 examination_info(exam_id 试卷 ID, tag 试卷类别, difficulty 试卷难度, duration 考试时长, release_time 发布时间):\nid exam_id tag difficulty duration release_time 1 9001 算法 hard 60 2021-01-01 10:00:00 2 9002 算法 hard 80 2021-01-01 10:00:00 3 9003 SQL medium 70 2021-01-01 10:00:00 4 9004 算法,medium,80 0 2021-01-01 10:00:00 录题同学有一次手误将部分记录的试题类别 tag、难度、时长同时录入到了 tag 字段,请帮忙找出这些录错了的记录,并拆分后按正确的列类型输出。\n由示例数据结果输出如下:\nexam_id tag difficulty duration 9004 算法 medium 80 思路:\n先来学习下本题要用到的函数\nSUBSTRING_INDEX 函数用于提取字符串中指定分隔符的部分。它接受三个参数:原始字符串、分隔符和指定要返回的部分的数量。\n以下是 SUBSTRING_INDEX 函数的语法:\nSUBSTRING_INDEX(str, delimiter, count) str:要进行分割的原始字符串。 delimiter:用作分割的字符串或字符。 count:指定要返回的部分的数量。 如果 count 大于 0,则返回从左边开始的前 count 个部分(以分隔符为界)。 如果 count 小于 0,则返回从右边开始的前 count 个部分(以分隔符为界),即从右侧向左计数。 下面是一些示例,演示了 SUBSTRING_INDEX 函数的使用:\n提取字符串中的第一个部分:\nSELECT SUBSTRING_INDEX(\u0026#39;apple,banana,cherry\u0026#39;, \u0026#39;,\u0026#39;, 1); -- 输出结果:\u0026#39;apple\u0026#39; 提取字符串中的最后一个部分:\nSELECT SUBSTRING_INDEX(\u0026#39;apple,banana,cherry\u0026#39;, \u0026#39;,\u0026#39;, -1); -- 输出结果:\u0026#39;cherry\u0026#39; 提取字符串中的前两个部分:\nSELECT SUBSTRING_INDEX(\u0026#39;apple,banana,cherry\u0026#39;, \u0026#39;,\u0026#39;, 2); -- 输出结果:\u0026#39;apple,banana\u0026#39; 提取字符串中的最后两个部分:\nSELECT SUBSTRING_INDEX(\u0026#39;apple,banana,cherry\u0026#39;, \u0026#39;,\u0026#39;, -2); -- 输出结果:\u0026#39;banana,cherry\u0026#39; 答案:\nSELECT exam_id, substring_index( tag, \u0026#39;,\u0026#39;, 1 ) tag, substring_index( substring_index( tag, \u0026#39;,\u0026#39;, 2 ), \u0026#39;,\u0026#39;,- 1 ) difficulty, substring_index( tag, \u0026#39;,\u0026#39;,- 1 ) duration FROM examination_info WHERE difficulty = \u0026#39;\u0026#39; 对过长的昵称截取处理 # 描述:现有用户信息表 user_info(uid 用户 ID,nick_name 昵称, achievement 成就值, level 等级, job 职业方向, register_time 注册时间):\nid uid nick_name achievement level job register_time 1 1001 牛客 1 19 0 算法 2020-01-01 10:00:00 2 1002 牛客 2 号 1200 3 算法 2020-01-01 10:00:00 3 1003 牛客 3 号 ♂ 22 0 算法 2020-01-01 10:00:00 4 1004 牛客 4 号 25 0 算法 2020-01-01 11:00:00 5 1005 牛客 5678901234 号 4000 7 算法 2020-01-11 10:00:00 6 1006 牛客 67890123456789 号 25 0 算法 2020-01-02 11:00:00 有的用户的昵称特别长,在一些展示场景会导致样式混乱,因此需要将特别长的昵称转换一下再输出,请输出字符数大于 10 的用户信息,对于字符数大于 13 的用户输出前 10 个字符然后加上三个点号:『\u0026hellip;』。\n由示例数据结果输出如下:\nuid nick_name 1005 牛客 5678901234 号 1006 牛客 67890123\u0026hellip; 解释:字符数大于 10 的用户有 1005 和 1006,长度分别为 13、17;因此需要对 1006 的昵称截断输出。\n思路:\n这题涉及到字符的计算,要计算字符串的字符数(即字符串的长度),可以使用 LENGTH 函数或 CHAR_LENGTH 函数。这两个函数的区别在于对待多字节字符的方式。\nLENGTH 函数:它返回给定字符串的字节数。对于包含多字节字符的字符串,每个字符都会被当作一个字节来计算。 示例:\nSELECT LENGTH(\u0026#39;你好\u0026#39;); -- 输出结果:6,因为 \u0026#39;你好\u0026#39; 中的每个汉字每个占3个字节 CHAR_LENGTH 函数:它返回给定字符串的字符数。对于包含多字节字符的字符串,每个字符会被当作一个字符来计算。 示例:\nSELECT CHAR_LENGTH(\u0026#39;你好\u0026#39;); -- 输出结果:2,因为 \u0026#39;你好\u0026#39; 中有两个字符,即两个汉字 答案:\nSELECT uid, CASE WHEN CHAR_LENGTH( nick_name ) \u0026gt; 13 THEN CONCAT( SUBSTR( nick_name, 1, 10 ), \u0026#39;...\u0026#39; ) ELSE nick_name END AS nick_name FROM user_info WHERE CHAR_LENGTH( nick_name ) \u0026gt; 10 GROUP BY uid; 大小写混乱时的筛选统计(较难) # 描述:\n现有试卷信息表 examination_info(exam_id 试卷 ID, tag 试卷类别, difficulty 试卷难度, duration 考试时长, release_time 发布时间):\nid exam_id tag difficulty duration release_time 1 9001 算法 hard 60 2021-01-01 10:00:00 2 9002 C++ hard 80 2021-01-01 10:00:00 3 9003 C++ hard 80 2021-01-01 10:00:00 4 9004 sql medium 70 2021-01-01 10:00:00 5 9005 C++ hard 80 2021-01-01 10:00:00 6 9006 C++ hard 80 2021-01-01 10:00:00 7 9007 C++ hard 80 2021-01-01 10:00:00 8 9008 SQL medium 70 2021-01-01 10:00:00 9 9009 SQL medium 70 2021-01-01 10:00:00 10 9010 SQL medium 70 2021-01-01 10:00:00 试卷作答信息表 exam_record(uid 用户 ID, exam_id 试卷 ID, start_time 开始作答时间, submit_time 交卷时间, score 得分):\nid uid exam_id start_time submit_time score 1 1001 9001 2020-01-01 09:01:01 2020-01-01 09:21:59 80 2 1002 9003 2020-01-20 10:01:01 2020-01-20 10:10:01 81 3 1002 9002 2020-02-01 12:11:01 2020-02-01 12:31:01 83 4 1003 9002 2020-03-01 19:01:01 2020-03-01 19:30:01 75 5 1004 9002 2020-03-01 12:01:01 2020-03-01 12:11:01 60 6 1005 9002 2020-03-01 12:01:01 2020-03-01 12:41:01 90 7 1006 9001 2020-05-02 19:01:01 2020-05-02 19:32:00 20 8 1007 9003 2020-01-02 19:01:01 2020-01-02 19:40:01 89 9 1008 9004 2020-02-02 12:01:01 2020-02-02 12:20:01 99 10 1008 9001 2020-02-02 12:01:01 2020-02-02 12:31:01 98 11 1009 9002 2020-02-02 12:01:01 2020-01-02 12:43:01 81 12 1010 9001 2020-01-02 12:11:01 (NULL) (NULL) 13 1010 9001 2020-02-02 12:01:01 2020-01-02 10:31:01 89 试卷的类别 tag 可能出现大小写混乱的情况,请先筛选出试卷作答数小于 3 的类别 tag,统计将其转换为大写后对应的原本试卷作答数。\n如果转换后 tag 并没有发生变化,不输出该条结果。\n由示例数据结果输出如下:\ntag answer_cnt C++ 6 解释:被作答过的试卷有 9001、9002、9003、9004,他们的 tag 和被作答次数如下:\nexam_id tag answer_cnt 9001 算法 4 9002 C++ 6 9003 c++ 2 9004 sql 2 作答次数小于 3 的 tag 有 c和 sql,而转为大写后只有 C本来就有作答数,于是输出 c++转化大写后的作答次数为 6。\n思路:\n首先,这题有点混乱,9004 根据示例数据查出来只有 1 次,这里显示有 2 次。\n先看一下大小写转换函数:\n1.UPPER(s)或UCASE(s)函数可以将字符串 s 中的字母字符全部转换成大写字母;\n2.LOWER(s)或者LCASE(s)函数可以将字符串 s 中的字母字符全部转换成小写字母。\n难点在于相同表做连接要查询不同的值\n答案:\nWITH a AS (SELECT tag, COUNT(start_time) AS answer_cnt FROM exam_record er JOIN examination_info ei ON er.exam_id = ei.exam_id GROUP BY tag) SELECT a.tag, b.answer_cnt FROM a INNER JOIN a AS b ON UPPER(a.tag)= b.tag #a小写 b大写 AND a.tag != b.tag WHERE a.answer_cnt \u0026lt; 3; "},{"id":582,"href":"/zh/docs/technology/Interview/database/sql/sql-syntax-summary/","title":"SQL语法基础知识总结","section":"SQL","content":" 本文整理完善自下面这两份资料:\nSQL 语法速成手册 MySQL 超全教程 基本概念 # 数据库术语 # 数据库(database) - 保存有组织的数据的容器(通常是一个文件或一组文件)。 数据表(table) - 某种特定类型数据的结构化清单。 模式(schema) - 关于数据库和表的布局及特性的信息。模式定义了数据在表中如何存储,包含存储什么样的数据,数据如何分解,各部分信息如何命名等信息。数据库和表都有模式。 列(column) - 表中的一个字段。所有表都是由一个或多个列组成的。 行(row) - 表中的一个记录。 主键(primary key) - 一列(或一组列),其值能够唯一标识表中每一行。 SQL 语法 # SQL(Structured Query Language),标准 SQL 由 ANSI 标准委员会管理,从而称为 ANSI SQL。各个 DBMS 都有自己的实现,如 PL/SQL、Transact-SQL 等。\nSQL 语法结构 # SQL 语法结构包括:\n子句 - 是语句和查询的组成成分。(在某些情况下,这些都是可选的。) 表达式 - 可以产生任何标量值,或由列和行的数据库表 谓词 - 给需要评估的 SQL 三值逻辑(3VL)(true/false/unknown)或布尔真值指定条件,并限制语句和查询的效果,或改变程序流程。 查询 - 基于特定条件检索数据。这是 SQL 的一个重要组成部分。 语句 - 可以持久地影响纲要和数据,也可以控制数据库事务、程序流程、连接、会话或诊断。 SQL 语法要点 # SQL 语句不区分大小写,但是数据库表名、列名和值是否区分,依赖于具体的 DBMS 以及配置。例如:SELECT 与 select、Select 是相同的。 多条 SQL 语句必须以分号(;)分隔。 处理 SQL 语句时,所有空格都被忽略。 SQL 语句可以写成一行,也可以分写为多行。\n-- 一行 SQL 语句 UPDATE user SET username=\u0026#39;robot\u0026#39;, password=\u0026#39;robot\u0026#39; WHERE username = \u0026#39;root\u0026#39;; -- 多行 SQL 语句 UPDATE user SET username=\u0026#39;robot\u0026#39;, password=\u0026#39;robot\u0026#39; WHERE username = \u0026#39;root\u0026#39;; SQL 支持三种注释:\n## 注释1 -- 注释2 /* 注释3 */ SQL 分类 # 数据定义语言(DDL) # 数据定义语言(Data Definition Language,DDL)是 SQL 语言集中负责数据结构定义与数据库对象定义的语言。\nDDL 的主要功能是定义数据库对象。\nDDL 的核心指令是 CREATE、ALTER、DROP。\n数据操纵语言(DML) # 数据操纵语言(Data Manipulation Language, DML)是用于数据库操作,对数据库其中的对象和数据运行访问工作的编程语句。\nDML 的主要功能是 访问数据,因此其语法都是以读写数据库为主。\nDML 的核心指令是 INSERT、UPDATE、DELETE、SELECT。这四个指令合称 CRUD(Create, Read, Update, Delete),即增删改查。\n事务控制语言(TCL) # 事务控制语言 (Transaction Control Language, TCL) 用于管理数据库中的事务。这些用于管理由 DML 语句所做的更改。它还允许将语句分组为逻辑事务。\nTCL 的核心指令是 COMMIT、ROLLBACK。\n数据控制语言(DCL) # 数据控制语言 (Data Control Language, DCL) 是一种可对数据访问权进行控制的指令,它可以控制特定用户账户对数据表、查看表、预存程序、用户自定义函数等数据库对象的控制权。\nDCL 的核心指令是 GRANT、REVOKE。\nDCL 以控制用户的访问权限为主,因此其指令作法并不复杂,可利用 DCL 控制的权限有:CONNECT、SELECT、INSERT、UPDATE、DELETE、EXECUTE、USAGE、REFERENCES。\n根据不同的 DBMS 以及不同的安全性实体,其支持的权限控制也有所不同。\n我们先来介绍 DML 语句用法。 DML 的主要功能是读写数据库实现增删改查。\n增删改查 # 增删改查,又称为 CRUD,数据库基本操作中的基本操作。\n插入数据 # INSERT INTO 语句用于向表中插入新记录。\n插入完整的行\n# 插入一行 INSERT INTO user VALUES (10, \u0026#39;root\u0026#39;, \u0026#39;root\u0026#39;, \u0026#39;xxxx@163.com\u0026#39;); # 插入多行 INSERT INTO user VALUES (10, \u0026#39;root\u0026#39;, \u0026#39;root\u0026#39;, \u0026#39;xxxx@163.com\u0026#39;), (12, \u0026#39;user1\u0026#39;, \u0026#39;user1\u0026#39;, \u0026#39;xxxx@163.com\u0026#39;), (18, \u0026#39;user2\u0026#39;, \u0026#39;user2\u0026#39;, \u0026#39;xxxx@163.com\u0026#39;); 插入行的一部分\nINSERT INTO user(username, password, email) VALUES (\u0026#39;admin\u0026#39;, \u0026#39;admin\u0026#39;, \u0026#39;xxxx@163.com\u0026#39;); 插入查询出来的数据\nINSERT INTO user(username) SELECT name FROM account; 更新数据 # UPDATE 语句用于更新表中的记录。\nUPDATE user SET username=\u0026#39;robot\u0026#39;, password=\u0026#39;robot\u0026#39; WHERE username = \u0026#39;root\u0026#39;; 删除数据 # DELETE 语句用于删除表中的记录。 TRUNCATE TABLE 可以清空表,也就是删除所有行。说明:TRUNCATE 语句不属于 DML 语法而是 DDL 语法。 删除表中的指定数据\nDELETE FROM user WHERE username = \u0026#39;robot\u0026#39;; 清空表中的数据\nTRUNCATE TABLE user; 查询数据 # SELECT 语句用于从数据库中查询数据。\nDISTINCT 用于返回唯一不同的值。它作用于所有列,也就是说所有列的值都相同才算相同。\nLIMIT 限制返回的行数。可以有两个参数,第一个参数为起始行,从 0 开始;第二个参数为返回的总行数。\nASC:升序(默认) DESC:降序 查询单列\nSELECT prod_name FROM products; 查询多列\nSELECT prod_id, prod_name, prod_price FROM products; 查询所有列\nSELECT * FROM products; 查询不同的值\nSELECT DISTINCT vend_id FROM products; 限制查询结果\n-- 返回前 5 行 SELECT * FROM mytable LIMIT 5; SELECT * FROM mytable LIMIT 0, 5; -- 返回第 3 ~ 5 行 SELECT * FROM mytable LIMIT 2, 3; 排序 # order by 用于对结果集按照一个列或者多个列进行排序。默认按照升序对记录进行排序,如果需要按照降序对记录进行排序,可以使用 desc 关键字。\norder by 对多列排序的时候,先排序的列放前面,后排序的列放后面。并且,不同的列可以有不同的排序规则。\nSELECT * FROM products ORDER BY prod_price DESC, prod_name ASC; 分组 # group by:\ngroup by 子句将记录分组到汇总行中。 group by 为每个组返回一个记录。 group by 通常还涉及聚合count,max,sum,avg 等。 group by 可以按一列或多列进行分组。 group by 按分组字段进行排序后,order by 可以以汇总字段来进行排序。 分组\nSELECT cust_name, COUNT(cust_address) AS addr_num FROM Customers GROUP BY cust_name; 分组后排序\nSELECT cust_name, COUNT(cust_address) AS addr_num FROM Customers GROUP BY cust_name ORDER BY cust_name DESC; having:\nhaving 用于对汇总的 group by 结果进行过滤。 having 一般都是和 group by 连用。 where 和 having 可以在相同的查询中。 使用 WHERE 和 HAVING 过滤数据\nSELECT cust_name, COUNT(*) AS NumberOfOrders FROM Customers WHERE cust_email IS NOT NULL GROUP BY cust_name HAVING COUNT(*) \u0026gt; 1; having vs where:\nwhere:过滤过滤指定的行,后面不能加聚合函数(分组函数)。where 在group by 前。 having:过滤分组,一般都是和 group by 连用,不能单独使用。having 在 group by 之后。 子查询 # 子查询是嵌套在较大查询中的 SQL 查询,也称内部查询或内部选择,包含子查询的语句也称为外部查询或外部选择。简单来说,子查询就是指将一个 select 查询(子查询)的结果作为另一个 SQL 语句(主查询)的数据来源或者判断条件。\n子查询可以嵌入 SELECT、INSERT、UPDATE 和 DELETE 语句中,也可以和 =、\u0026lt;、\u0026gt;、IN、BETWEEN、EXISTS 等运算符一起使用。\n子查询常用在 WHERE 子句和 FROM 子句后边:\n当用于 WHERE 子句时,根据不同的运算符,子查询可以返回单行单列、多行单列、单行多列数据。子查询就是要返回能够作为 WHERE 子句查询条件的值。 当用于 FROM 子句时,一般返回多行多列数据,相当于返回一张临时表,这样才符合 FROM 后面是表的规则。这种做法能够实现多表联合查询。 注意:MYSQL 数据库从 4.1 版本才开始支持子查询,早期版本是不支持的。\n用于 WHERE 子句的子查询的基本语法如下:\nselect column_name [, column_name ] from table1 [, table2 ] where column_name operator (select column_name [, column_name ] from table1 [, table2 ] [where]) 子查询需要放在括号( )内。 operator 表示用于 where 子句的运算符。 用于 FROM 子句的子查询的基本语法如下:\nselect column_name [, column_name ] from (select column_name [, column_name ] from table1 [, table2 ] [where]) as temp_table_name where condition 用于 FROM 的子查询返回的结果相当于一张临时表,所以需要使用 AS 关键字为该临时表起一个名字。\n子查询的子查询\nSELECT cust_name, cust_contact FROM customers WHERE cust_id IN (SELECT cust_id FROM orders WHERE order_num IN (SELECT order_num FROM orderitems WHERE prod_id = \u0026#39;RGAN01\u0026#39;)); 内部查询首先在其父查询之前执行,以便可以将内部查询的结果传递给外部查询。执行过程可以参考下图:\nWHERE # WHERE 子句用于过滤记录,即缩小访问数据的范围。 WHERE 后跟一个返回 true 或 false 的条件。 WHERE 可以与 SELECT,UPDATE 和 DELETE 一起使用。 可以在 WHERE 子句中使用的操作符。 运算符 描述 = 等于 \u0026lt;\u0026gt; 不等于。注释:在 SQL 的一些版本中,该操作符可被写成 != \u0026gt; 大于 \u0026lt; 小于 \u0026gt;= 大于等于 \u0026lt;= 小于等于 BETWEEN 在某个范围内 LIKE 搜索某种模式 IN 指定针对某个列的多个可能值 SELECT 语句中的 WHERE 子句\nSELECT * FROM Customers WHERE cust_name = \u0026#39;Kids Place\u0026#39;; UPDATE 语句中的 WHERE 子句\nUPDATE Customers SET cust_name = \u0026#39;Jack Jones\u0026#39; WHERE cust_name = \u0026#39;Kids Place\u0026#39;; DELETE 语句中的 WHERE 子句\nDELETE FROM Customers WHERE cust_name = \u0026#39;Kids Place\u0026#39;; IN 和 BETWEEN # IN 操作符在 WHERE 子句中使用,作用是在指定的几个特定值中任选一个值。 BETWEEN 操作符在 WHERE 子句中使用,作用是选取介于某个范围内的值。 IN 示例\nSELECT * FROM products WHERE vend_id IN (\u0026#39;DLL01\u0026#39;, \u0026#39;BRS01\u0026#39;); BETWEEN 示例\nSELECT * FROM products WHERE prod_price BETWEEN 3 AND 5; AND、OR、NOT # AND、OR、NOT 是用于对过滤条件的逻辑处理指令。 AND 优先级高于 OR,为了明确处理顺序,可以使用 ()。 AND 操作符表示左右条件都要满足。 OR 操作符表示左右条件满足任意一个即可。 NOT 操作符用于否定一个条件。 AND 示例\nSELECT prod_id, prod_name, prod_price FROM products WHERE vend_id = \u0026#39;DLL01\u0026#39; AND prod_price \u0026lt;= 4; OR 示例\nSELECT prod_id, prod_name, prod_price FROM products WHERE vend_id = \u0026#39;DLL01\u0026#39; OR vend_id = \u0026#39;BRS01\u0026#39;; NOT 示例\nSELECT * FROM products WHERE prod_price NOT BETWEEN 3 AND 5; LIKE # LIKE 操作符在 WHERE 子句中使用,作用是确定字符串是否匹配模式。 只有字段是文本值时才使用 LIKE。 LIKE 支持两个通配符匹配选项:% 和 _。 不要滥用通配符,通配符位于开头处匹配会非常慢。 % 表示任何字符出现任意次数。 _ 表示任何字符出现一次。 % 示例\nSELECT prod_id, prod_name, prod_price FROM products WHERE prod_name LIKE \u0026#39;%bean bag%\u0026#39;; _ 示例\nSELECT prod_id, prod_name, prod_price FROM products WHERE prod_name LIKE \u0026#39;__ inch teddy bear\u0026#39;; 连接 # JOIN 是“连接”的意思,顾名思义,SQL JOIN 子句用于将两个或者多个表联合起来进行查询。\n连接表时需要在每个表中选择一个字段,并对这些字段的值进行比较,值相同的两条记录将合并为一条。连接表的本质就是将不同表的记录合并起来,形成一张新表。当然,这张新表只是临时的,它仅存在于本次查询期间。\n使用 JOIN 连接两个表的基本语法如下:\nselect table1.column1, table2.column2... from table1 join table2 on table1.common_column1 = table2.common_column2; table1.common_column1 = table2.common_column2 是连接条件,只有满足此条件的记录才会合并为一行。您可以使用多个运算符来连接表,例如 =、\u0026gt;、\u0026lt;、\u0026lt;\u0026gt;、\u0026lt;=、\u0026gt;=、!=、between、like 或者 not,但是最常见的是使用 =。\n当两个表中有同名的字段时,为了帮助数据库引擎区分是哪个表的字段,在书写同名字段名时需要加上表名。当然,如果书写的字段名在两个表中是唯一的,也可以不使用以上格式,只写字段名即可。\n另外,如果两张表的关联字段名相同,也可以使用 USING子句来代替 ON,举个例子:\n# join....on select c.cust_name, o.order_num from Customers c inner join Orders o on c.cust_id = o.cust_id order by c.cust_name; # 如果两张表的关联字段名相同,也可以使用USING子句:join....using() select c.cust_name, o.order_num from Customers c inner join Orders o using(cust_id) order by c.cust_name; ON 和 WHERE 的区别:\n连接表时,SQL 会根据连接条件生成一张新的临时表。ON 就是连接条件,它决定临时表的生成。 WHERE 是在临时表生成以后,再对临时表中的数据进行过滤,生成最终的结果集,这个时候已经没有 JOIN-ON 了。 所以总结来说就是:SQL 先根据 ON 生成一张临时表,然后再根据 WHERE 对临时表进行筛选。\nSQL 允许在 JOIN 左边加上一些修饰性的关键词,从而形成不同类型的连接,如下表所示:\n连接类型 说明 INNER JOIN 内连接 (默认连接方式)只有当两个表都存在满足条件的记录时才会返回行。 LEFT JOIN / LEFT OUTER JOIN 左(外)连接 返回左表中的所有行,即使右表中没有满足条件的行也是如此。 RIGHT JOIN / RIGHT OUTER JOIN 右(外)连接 返回右表中的所有行,即使左表中没有满足条件的行也是如此。 FULL JOIN / FULL OUTER JOIN 全(外)连接 只要其中有一个表存在满足条件的记录,就返回行。 SELF JOIN 将一个表连接到自身,就像该表是两个表一样。为了区分两个表,在 SQL 语句中需要至少重命名一个表。 CROSS JOIN 交叉连接,从两个或者多个连接表中返回记录集的笛卡尔积。 下图展示了 LEFT JOIN、RIGHT JOIN、INNER JOIN、OUTER JOIN 相关的 7 种用法。\n如果不加任何修饰词,只写 JOIN,那么默认为 INNER JOIN\n对于 INNER JOIN 来说,还有一种隐式的写法,称为 “隐式内连接”,也就是没有 INNER JOIN 关键字,使用 WHERE 语句实现内连接的功能\n# 隐式内连接 select c.cust_name, o.order_num from Customers c, Orders o where c.cust_id = o.cust_id order by c.cust_name; # 显式内连接 select c.cust_name, o.order_num from Customers c inner join Orders o using(cust_id) order by c.cust_name; 组合 # UNION 运算符将两个或更多查询的结果组合起来,并生成一个结果集,其中包含来自 UNION 中参与查询的提取行。\nUNION 基本规则:\n所有查询的列数和列顺序必须相同。 每个查询中涉及表的列的数据类型必须相同或兼容。 通常返回的列名取自第一个查询。 默认地,UNION 操作符选取不同的值。如果允许重复的值,请使用 UNION ALL。\nSELECT column_name(s) FROM table1 UNION ALL SELECT column_name(s) FROM table2; UNION 结果集中的列名总是等于 UNION 中第一个 SELECT 语句中的列名。\nJOIN vs UNION:\nJOIN 中连接表的列可能不同,但在 UNION 中,所有查询的列数和列顺序必须相同。 UNION 将查询之后的行放在一起(垂直放置),但 JOIN 将查询之后的列放在一起(水平放置),即它构成一个笛卡尔积。 函数 # 不同数据库的函数往往各不相同,因此不可移植。本节主要以 MySQL 的函数为例。\n文本处理 # 函数 说明 LEFT()、RIGHT() 左边或者右边的字符 LOWER()、UPPER() 转换为小写或者大写 LTRIM()、RTRIM() 去除左边或者右边的空格 LENGTH() 长度,以字节为单位 SOUNDEX() 转换为语音值 其中, SOUNDEX() 可以将一个字符串转换为描述其语音表示的字母数字模式。\nSELECT * FROM mytable WHERE SOUNDEX(col1) = SOUNDEX(\u0026#39;apple\u0026#39;) 日期和时间处理 # 日期格式:YYYY-MM-DD 时间格式:HH:MM:SS 函 数 说 明 AddDate() 增加一个日期(天、周等) AddTime() 增加一个时间(时、分等) CurDate() 返回当前日期 CurTime() 返回当前时间 Date() 返回日期时间的日期部分 DateDiff() 计算两个日期之差 Date_Add() 高度灵活的日期运算函数 Date_Format() 返回一个格式化的日期或时间串 Day() 返回一个日期的天数部分 DayOfWeek() 对于一个日期,返回对应的星期几 Hour() 返回一个时间的小时部分 Minute() 返回一个时间的分钟部分 Month() 返回一个日期的月份部分 Now() 返回当前日期和时间 Second() 返回一个时间的秒部分 Time() 返回一个日期时间的时间部分 Year() 返回一个日期的年份部分 数值处理 # 函数 说明 SIN() 正弦 COS() 余弦 TAN() 正切 ABS() 绝对值 SQRT() 平方根 MOD() 余数 EXP() 指数 PI() 圆周率 RAND() 随机数 汇总 # 函 数 说 明 AVG() 返回某列的平均值 COUNT() 返回某列的行数 MAX() 返回某列的最大值 MIN() 返回某列的最小值 SUM() 返回某列值之和 AVG() 会忽略 NULL 行。\n使用 DISTINCT 可以让汇总函数值汇总不同的值。\nSELECT AVG(DISTINCT col1) AS avg_col FROM mytable 接下来,我们来介绍 DDL 语句用法。DDL 的主要功能是定义数据库对象(如:数据库、数据表、视图、索引等)\n数据定义 # 数据库(DATABASE) # 创建数据库 # CREATE DATABASE test; 删除数据库 # DROP DATABASE test; 选择数据库 # USE test; 数据表(TABLE) # 创建数据表 # 普通创建\nCREATE TABLE user ( id int(10) unsigned NOT NULL COMMENT \u0026#39;Id\u0026#39;, username varchar(64) NOT NULL DEFAULT \u0026#39;default\u0026#39; COMMENT \u0026#39;用户名\u0026#39;, password varchar(64) NOT NULL DEFAULT \u0026#39;default\u0026#39; COMMENT \u0026#39;密码\u0026#39;, email varchar(64) NOT NULL DEFAULT \u0026#39;default\u0026#39; COMMENT \u0026#39;邮箱\u0026#39; ) COMMENT=\u0026#39;用户表\u0026#39;; 根据已有的表创建新表\nCREATE TABLE vip_user AS SELECT * FROM user; 删除数据表 # DROP TABLE user; 修改数据表 # 添加列\nALTER TABLE user ADD age int(3); 删除列\nALTER TABLE user DROP COLUMN age; 修改列\nALTER TABLE `user` MODIFY COLUMN age tinyint; 添加主键\nALTER TABLE user ADD PRIMARY KEY (id); 删除主键\nALTER TABLE user DROP PRIMARY KEY; 视图(VIEW) # 定义:\n视图是基于 SQL 语句的结果集的可视化的表。 视图是虚拟的表,本身不包含数据,也就不能对其进行索引操作。对视图的操作和对普通表的操作一样。 作用:\n简化复杂的 SQL 操作,比如复杂的联结; 只使用实际表的一部分数据; 通过只给用户访问视图的权限,保证数据的安全性; 更改数据格式和表示。 创建视图 # CREATE VIEW top_10_user_view AS SELECT id, username FROM user WHERE id \u0026lt; 10; 删除视图 # DROP VIEW top_10_user_view; 索引(INDEX) # 索引是一种用于快速查询和检索数据的数据结构,其本质可以看成是一种排序好的数据结构。\n索引的作用就相当于书的目录。打个比方: 我们在查字典的时候,如果没有目录,那我们就只能一页一页的去找我们需要查的那个字,速度很慢。如果有目录了,我们只需要先去目录里查找字的位置,然后直接翻到那一页就行了。\n优点:\n使用索引可以大大加快 数据的检索速度(大大减少检索的数据量), 这也是创建索引的最主要的原因。 通过创建唯一性索引,可以保证数据库表中每一行数据的唯一性。 缺点:\n创建索引和维护索引需要耗费许多时间。当对表中的数据进行增删改的时候,如果数据有索引,那么索引也需要动态的修改,会降低 SQL 执行效率。 索引需要使用物理文件存储,也会耗费一定空间。 但是,使用索引一定能提高查询性能吗?\n大多数情况下,索引查询都是比全表扫描要快的。但是如果数据库的数据量不大,那么使用索引也不一定能够带来很大提升。\n关于索引的详细介绍,请看我写的 MySQL 索引详解 这篇文章。\n创建索引 # CREATE INDEX user_index ON user (id); 添加索引 # ALTER table user ADD INDEX user_index(id) 创建唯一索引 # CREATE UNIQUE INDEX user_index ON user (id); 删除索引 # ALTER TABLE user DROP INDEX user_index; 约束 # SQL 约束用于规定表中的数据规则。\n如果存在违反约束的数据行为,行为会被约束终止。\n约束可以在创建表时规定(通过 CREATE TABLE 语句),或者在表创建之后规定(通过 ALTER TABLE 语句)。\n约束类型:\nNOT NULL - 指示某列不能存储 NULL 值。 UNIQUE - 保证某列的每行必须有唯一的值。 PRIMARY KEY - NOT NULL 和 UNIQUE 的结合。确保某列(或两个列多个列的结合)有唯一标识,有助于更容易更快速地找到表中的一个特定的记录。 FOREIGN KEY - 保证一个表中的数据匹配另一个表中的值的参照完整性。 CHECK - 保证列中的值符合指定的条件。 DEFAULT - 规定没有给列赋值时的默认值。 创建表时使用约束条件:\nCREATE TABLE Users ( Id INT(10) UNSIGNED NOT NULL AUTO_INCREMENT COMMENT \u0026#39;自增Id\u0026#39;, Username VARCHAR(64) NOT NULL UNIQUE DEFAULT \u0026#39;default\u0026#39; COMMENT \u0026#39;用户名\u0026#39;, Password VARCHAR(64) NOT NULL DEFAULT \u0026#39;default\u0026#39; COMMENT \u0026#39;密码\u0026#39;, Email VARCHAR(64) NOT NULL DEFAULT \u0026#39;default\u0026#39; COMMENT \u0026#39;邮箱地址\u0026#39;, Enabled TINYINT(4) DEFAULT NULL COMMENT \u0026#39;是否有效\u0026#39;, PRIMARY KEY (Id) ) ENGINE=InnoDB AUTO_INCREMENT=2 DEFAULT CHARSET=utf8mb4 COMMENT=\u0026#39;用户表\u0026#39;; 接下来,我们来介绍 TCL 语句用法。TCL 的主要功能是管理数据库中的事务。\n事务处理 # 不能回退 SELECT 语句,回退 SELECT 语句也没意义;也不能回退 CREATE 和 DROP 语句。\nMySQL 默认是隐式提交,每执行一条语句就把这条语句当成一个事务然后进行提交。当出现 START TRANSACTION 语句时,会关闭隐式提交;当 COMMIT 或 ROLLBACK 语句执行后,事务会自动关闭,重新恢复隐式提交。\n通过 set autocommit=0 可以取消自动提交,直到 set autocommit=1 才会提交;autocommit 标记是针对每个连接而不是针对服务器的。\n指令:\nSTART TRANSACTION - 指令用于标记事务的起始点。 SAVEPOINT - 指令用于创建保留点。 ROLLBACK TO - 指令用于回滚到指定的保留点;如果没有设置保留点,则回退到 START TRANSACTION 语句处。 COMMIT - 提交事务。 -- 开始事务 START TRANSACTION; -- 插入操作 A INSERT INTO `user` VALUES (1, \u0026#39;root1\u0026#39;, \u0026#39;root1\u0026#39;, \u0026#39;xxxx@163.com\u0026#39;); -- 创建保留点 updateA SAVEPOINT updateA; -- 插入操作 B INSERT INTO `user` VALUES (2, \u0026#39;root2\u0026#39;, \u0026#39;root2\u0026#39;, \u0026#39;xxxx@163.com\u0026#39;); -- 回滚到保留点 updateA ROLLBACK TO updateA; -- 提交事务,只有操作 A 生效 COMMIT; 接下来,我们来介绍 DCL 语句用法。DCL 的主要功能是控制用户的访问权限。\n权限控制 # 要授予用户帐户权限,可以用GRANT命令。要撤销用户的权限,可以用REVOKE命令。这里以 MySQL 为例,介绍权限控制实际应用。\nGRANT授予权限语法:\nGRANT privilege,[privilege],.. ON privilege_level TO user [IDENTIFIED BY password] [REQUIRE tsl_option] [WITH [GRANT_OPTION | resource_option]]; 简单解释一下:\n在GRANT关键字后指定一个或多个权限。如果授予用户多个权限,则每个权限由逗号分隔。 ON privilege_level 确定权限应用级别。MySQL 支持 global(*.*),database(database.*),table(database.table)和列级别。如果使用列权限级别,则必须在每个权限之后指定一个或逗号分隔列的列表。 user 是要授予权限的用户。如果用户已存在,则GRANT语句将修改其权限。否则,GRANT语句将创建一个新用户。可选子句IDENTIFIED BY允许您为用户设置新的密码。 REQUIRE tsl_option指定用户是否必须通过 SSL,X059 等安全连接连接到数据库服务器。 可选 WITH GRANT OPTION 子句允许您授予其他用户或从其他用户中删除您拥有的权限。此外,您可以使用WITH子句分配 MySQL 数据库服务器的资源,例如,设置用户每小时可以使用的连接数或语句数。这在 MySQL 共享托管等共享环境中非常有用。 REVOKE 撤销权限语法:\nREVOKE privilege_type [(column_list)] [, priv_type [(column_list)]]... ON [object_type] privilege_level FROM user [, user]... 简单解释一下:\n在 REVOKE 关键字后面指定要从用户撤消的权限列表。您需要用逗号分隔权限。 指定在 ON 子句中撤销特权的特权级别。 指定要撤消 FROM 子句中的权限的用户帐户。 GRANT 和 REVOKE 可在几个层次上控制访问权限:\n整个服务器,使用 GRANT ALL 和 REVOKE ALL; 整个数据库,使用 ON database.*; 特定的表,使用 ON database.table; 特定的列; 特定的存储过程。 新创建的账户没有任何权限。账户用 username@host 的形式定义,username@% 使用的是默认主机名。MySQL 的账户信息保存在 mysql 这个数据库中。\nUSE mysql; SELECT user FROM user; 下表说明了可用于GRANT和REVOKE语句的所有允许权限:\n特权 说明 级别 全局 数据库 表 列 程序 代理 ALL [PRIVILEGES] 授予除 GRANT OPTION 之外的指定访问级别的所有权限 ALTER 允许用户使用 ALTER TABLE 语句 X X X ALTER ROUTINE 允许用户更改或删除存储的例程 X X X CREATE 允许用户创建数据库和表 X X X CREATE ROUTINE 允许用户创建存储的例程 X X CREATE TABLESPACE 允许用户创建,更改或删除表空间和日志文件组 X CREATE TEMPORARY TABLES 允许用户使用 CREATE TEMPORARY TABLE 创建临时表 X X CREATE USER 允许用户使用 CREATE USER,DROP USER,RENAME USER 和 REVOKE ALL PRIVILEGES 语句。 X CREATE VIEW 允许用户创建或修改视图。 X X X DELETE 允许用户使用 DELETE X X X DROP 允许用户删除数据库,表和视图 X X X EVENT 启用事件计划程序的事件使用。 X X EXECUTE 允许用户执行存储的例程 X X X FILE 允许用户读取数据库目录中的任何文件。 X GRANT OPTION 允许用户拥有授予或撤消其他帐户权限的权限。 X X X X X INDEX 允许用户创建或删除索引。 X X X INSERT 允许用户使用 INSERT 语句 X X X X LOCK TABLES 允许用户对具有 SELECT 权限的表使用 LOCK TABLES X X PROCESS 允许用户使用 SHOW PROCESSLIST 语句查看所有进程。 X PROXY 启用用户代理。 REFERENCES 允许用户创建外键 X X X X RELOAD 允许用户使用 FLUSH 操作 X REPLICATION CLIENT 允许用户查询以查看主服务器或从属服务器的位置 X REPLICATION SLAVE 允许用户使用复制从属从主服务器读取二进制日志事件。 X SELECT 允许用户使用 SELECT 语句 X X X X SHOW DATABASES 允许用户显示所有数据库 X SHOW VIEW 允许用户使用 SHOW CREATE VIEW 语句 X X X SHUTDOWN 允许用户使用 mysqladmin shutdown 命令 X SUPER 允许用户使用其他管理操作,例如 CHANGE MASTER TO,KILL,PURGE BINARY LOGS,SET GLOBAL 和 mysqladmin 命令 X TRIGGER 允许用户使用 TRIGGER 操作。 X X X UPDATE 允许用户使用 UPDATE 语句 X X X X USAGE 相当于“没有特权” 创建账户 # CREATE USER myuser IDENTIFIED BY \u0026#39;mypassword\u0026#39;; 修改账户名 # UPDATE user SET user=\u0026#39;newuser\u0026#39; WHERE user=\u0026#39;myuser\u0026#39;; FLUSH PRIVILEGES; 删除账户 # DROP USER myuser; 查看权限 # SHOW GRANTS FOR myuser; 授予权限 # GRANT SELECT, INSERT ON *.* TO myuser; 删除权限 # REVOKE SELECT, INSERT ON *.* FROM myuser; 更改密码 # SET PASSWORD FOR myuser = \u0026#39;mypass\u0026#39;; 存储过程 # 存储过程可以看成是对一系列 SQL 操作的批处理。存储过程可以由触发器,其他存储过程以及 Java, Python,PHP 等应用程序调用。\n使用存储过程的好处:\n代码封装,保证了一定的安全性; 代码复用; 由于是预先编译,因此具有很高的性能。 创建存储过程:\n命令行中创建存储过程需要自定义分隔符,因为命令行是以 ; 为结束符,而存储过程中也包含了分号,因此会错误把这部分分号当成是结束符,造成语法错误。 包含 in、out 和 inout 三种参数。 给变量赋值都需要用 select into 语句。 每次只能给一个变量赋值,不支持集合的操作。 需要注意的是:阿里巴巴《Java 开发手册》强制禁止使用存储过程。因为存储过程难以调试和扩展,更没有移植性。\n至于到底要不要在项目中使用,还是要看项目实际需求,权衡好利弊即可!\n创建存储过程 # DROP PROCEDURE IF EXISTS `proc_adder`; DELIMITER ;; CREATE DEFINER=`root`@`localhost` PROCEDURE `proc_adder`(IN a int, IN b int, OUT sum int) BEGIN DECLARE c int; if a is null then set a = 0; end if; if b is null then set b = 0; end if; set sum = a + b; END ;; DELIMITER ; 使用存储过程 # set @b=5; call proc_adder(2,@b,@s); select @s as sum; 游标 # 游标(cursor)是一个存储在 DBMS 服务器上的数据库查询,它不是一条 SELECT 语句,而是被该语句检索出来的结果集。\n在存储过程中使用游标可以对一个结果集进行移动遍历。\n游标主要用于交互式应用,其中用户需要滚动屏幕上的数据,并对数据进行浏览或做出更改。\n使用游标的几个明确步骤:\n在使用游标前,必须声明(定义)它。这个过程实际上没有检索数据, 它只是定义要使用的 SELECT 语句和游标选项。\n一旦声明,就必须打开游标以供使用。这个过程用前面定义的 SELECT 语句把数据实际检索出来。\n对于填有数据的游标,根据需要取出(检索)各行。\n在结束游标使用时,必须关闭游标,可能的话,释放游标(有赖于具\n体的 DBMS)。\nDELIMITER $ CREATE PROCEDURE getTotal() BEGIN DECLARE total INT; -- 创建接收游标数据的变量 DECLARE sid INT; DECLARE sname VARCHAR(10); -- 创建总数变量 DECLARE sage INT; -- 创建结束标志变量 DECLARE done INT DEFAULT false; -- 创建游标 DECLARE cur CURSOR FOR SELECT id,name,age from cursor_table where age\u0026gt;30; -- 指定游标循环结束时的返回值 DECLARE CONTINUE HANDLER FOR NOT FOUND SET done = true; SET total = 0; OPEN cur; FETCH cur INTO sid, sname, sage; WHILE(NOT done) DO SET total = total + 1; FETCH cur INTO sid, sname, sage; END WHILE; CLOSE cur; SELECT total; END $ DELIMITER ; -- 调用存储过程 call getTotal(); 触发器 # 触发器是一种与表操作有关的数据库对象,当触发器所在表上出现指定事件时,将调用该对象,即表的操作事件触发表上的触发器的执行。\n我们可以使用触发器来进行审计跟踪,把修改记录到另外一张表中。\n使用触发器的优点:\nSQL 触发器提供了另一种检查数据完整性的方法。 SQL 触发器可以捕获数据库层中业务逻辑中的错误。 SQL 触发器提供了另一种运行计划任务的方法。通过使用 SQL 触发器,您不必等待运行计划任务,因为在对表中的数据进行更改之前或之后会自动调用触发器。 SQL 触发器对于审计表中数据的更改非常有用。 使用触发器的缺点:\nSQL 触发器只能提供扩展验证,并且不能替换所有验证。必须在应用程序层中完成一些简单的验证。例如,您可以使用 JavaScript 在客户端验证用户的输入,或者使用服务器端脚本语言(如 JSP,PHP,ASP.NET,Perl)在服务器端验证用户的输入。 从客户端应用程序调用和执行 SQL 触发器是不可见的,因此很难弄清楚数据库层中发生了什么。 SQL 触发器可能会增加数据库服务器的开销。 MySQL 不允许在触发器中使用 CALL 语句 ,也就是不能调用存储过程。\n注意:在 MySQL 中,分号 ; 是语句结束的标识符,遇到分号表示该段语句已经结束,MySQL 可以开始执行了。因此,解释器遇到触发器执行动作中的分号后就开始执行,然后会报错,因为没有找到和 BEGIN 匹配的 END。\n这时就会用到 DELIMITER 命令(DELIMITER 是定界符,分隔符的意思)。它是一条命令,不需要语句结束标识,语法为:DELIMITER new_delimiter。new_delimiter 可以设为 1 个或多个长度的符号,默认的是分号 ;,我们可以把它修改为其他符号,如 $ - DELIMITER $ 。在这之后的语句,以分号结束,解释器不会有什么反应,只有遇到了 $,才认为是语句结束。注意,使用完之后,我们还应该记得把它给修改回来。\n在 MySQL 5.7.2 版之前,可以为每个表定义最多六个触发器。\nBEFORE INSERT - 在将数据插入表格之前激活。 AFTER INSERT - 将数据插入表格后激活。 BEFORE UPDATE - 在更新表中的数据之前激活。 AFTER UPDATE - 更新表中的数据后激活。 BEFORE DELETE - 在从表中删除数据之前激活。 AFTER DELETE - 从表中删除数据后激活。 但是,从 MySQL 版本 5.7.2+开始,可以为同一触发事件和操作时间定义多个触发器。\nNEW 和 OLD:\nMySQL 中定义了 NEW 和 OLD 关键字,用来表示触发器的所在表中,触发了触发器的那一行数据。 在 INSERT 型触发器中,NEW 用来表示将要(BEFORE)或已经(AFTER)插入的新数据; 在 UPDATE 型触发器中,OLD 用来表示将要或已经被修改的原数据,NEW 用来表示将要或已经修改为的新数据; 在 DELETE 型触发器中,OLD 用来表示将要或已经被删除的原数据; 使用方法:NEW.columnName (columnName 为相应数据表某一列名) 创建触发器 # 提示:为了理解触发器的要点,有必要先了解一下创建触发器的指令。\nCREATE TRIGGER 指令用于创建触发器。\n语法:\nCREATE TRIGGER trigger_name trigger_time trigger_event ON table_name FOR EACH ROW BEGIN trigger_statements END; 说明:\ntrigger_name:触发器名 trigger_time : 触发器的触发时机。取值为 BEFORE 或 AFTER。 trigger_event : 触发器的监听事件。取值为 INSERT、UPDATE 或 DELETE。 table_name : 触发器的监听目标。指定在哪张表上建立触发器。 FOR EACH ROW: 行级监视,Mysql 固定写法,其他 DBMS 不同。 trigger_statements: 触发器执行动作。是一条或多条 SQL 语句的列表,列表内的每条语句都必须用分号 ; 来结尾。 当触发器的触发条件满足时,将会执行 BEGIN 和 END 之间的触发器执行动作。\n示例:\nDELIMITER $ CREATE TRIGGER `trigger_insert_user` AFTER INSERT ON `user` FOR EACH ROW BEGIN INSERT INTO `user_history`(user_id, operate_type, operate_time) VALUES (NEW.id, \u0026#39;add a user\u0026#39;, now()); END $ DELIMITER ; 查看触发器 # SHOW TRIGGERS; 删除触发器 # DROP TRIGGER IF EXISTS trigger_insert_user; 文章推荐 # 后端程序员必备:SQL 高性能优化指南!35+条优化建议立马 GET! 后端程序员必备:书写高质量 SQL 的 30 条建议 "},{"id":583,"href":"/zh/docs/technology/Interview/database/mysql/how-sql-executed-in-mysql/","title":"SQL语句在MySQL中的执行过程","section":"Mysql","content":" 本文来自 木木匠投稿。\n本篇文章会分析下一个 SQL 语句在 MySQL 中的执行流程,包括 SQL 的查询在 MySQL 内部会怎么流转,SQL 语句的更新是怎么完成的。\n在分析之前我会先带着你看看 MySQL 的基础架构,知道了 MySQL 由那些组件组成以及这些组件的作用是什么,可以帮助我们理解和解决这些问题。\n一 MySQL 基础架构分析 # 1.1 MySQL 基本架构概览 # 下图是 MySQL 的一个简要架构图,从下图你可以很清晰的看到用户的 SQL 语句在 MySQL 内部是如何执行的。\n先简单介绍一下下图涉及的一些组件的基本作用帮助大家理解这幅图,在 1.2 节中会详细介绍到这些组件的作用。\n连接器: 身份认证和权限相关(登录 MySQL 的时候)。 查询缓存: 执行查询语句的时候,会先查询缓存(MySQL 8.0 版本后移除,因为这个功能不太实用)。 分析器: 没有命中缓存的话,SQL 语句就会经过分析器,分析器说白了就是要先看你的 SQL 语句要干嘛,再检查你的 SQL 语句语法是否正确。 优化器: 按照 MySQL 认为最优的方案去执行。 执行器: 执行语句,然后从存储引擎返回数据。 - 简单来说 MySQL 主要分为 Server 层和存储引擎层:\nServer 层:主要包括连接器、查询缓存、分析器、优化器、执行器等,所有跨存储引擎的功能都在这一层实现,比如存储过程、触发器、视图,函数等,还有一个通用的日志模块 binlog 日志模块。 存储引擎:主要负责数据的存储和读取,采用可以替换的插件式架构,支持 InnoDB、MyISAM、Memory 等多个存储引擎,其中 InnoDB 引擎有自有的日志模块 redolog 模块。现在最常用的存储引擎是 InnoDB,它从 MySQL 5.5 版本开始就被当做默认存储引擎了。 1.2 Server 层基本组件介绍 # 1) 连接器 # 连接器主要和身份认证和权限相关的功能相关,就好比一个级别很高的门卫一样。\n主要负责用户登录数据库,进行用户的身份认证,包括校验账户密码,权限等操作,如果用户账户密码已通过,连接器会到权限表中查询该用户的所有权限,之后在这个连接里的权限逻辑判断都是会依赖此时读取到的权限数据,也就是说,后续只要这个连接不断开,即使管理员修改了该用户的权限,该用户也是不受影响的。\n2) 查询缓存(MySQL 8.0 版本后移除) # 查询缓存主要用来缓存我们所执行的 SELECT 语句以及该语句的结果集。\n连接建立后,执行查询语句的时候,会先查询缓存,MySQL 会先校验这个 SQL 是否执行过,以 Key-Value 的形式缓存在内存中,Key 是查询语句,Value 是结果集。如果缓存 key 被命中,就会直接返回给客户端,如果没有命中,就会执行后续的操作,完成后也会把结果缓存起来,方便下一次调用。当然在真正执行缓存查询的时候还是会校验用户的权限,是否有该表的查询条件。\nMySQL 查询不建议使用缓存,因为查询缓存失效在实际业务场景中可能会非常频繁,假如你对一个表更新的话,这个表上的所有的查询缓存都会被清空。对于不经常更新的数据来说,使用缓存还是可以的。\n所以,一般在大多数情况下我们都是不推荐去使用查询缓存的。\nMySQL 8.0 版本后删除了缓存的功能,官方也是认为该功能在实际的应用场景比较少,所以干脆直接删掉了。\n3) 分析器 # MySQL 没有命中缓存,那么就会进入分析器,分析器主要是用来分析 SQL 语句是来干嘛的,分析器也会分为几步:\n第一步,词法分析,一条 SQL 语句有多个字符串组成,首先要提取关键字,比如 select,提出查询的表,提出字段名,提出查询条件等等。做完这些操作后,就会进入第二步。\n第二步,语法分析,主要就是判断你输入的 SQL 是否正确,是否符合 MySQL 的语法。\n完成这 2 步之后,MySQL 就准备开始执行了,但是如何执行,怎么执行是最好的结果呢?这个时候就需要优化器上场了。\n4) 优化器 # 优化器的作用就是它认为的最优的执行方案去执行(有时候可能也不是最优,这篇文章涉及对这部分知识的深入讲解),比如多个索引的时候该如何选择索引,多表查询的时候如何选择关联顺序等。\n可以说,经过了优化器之后可以说这个语句具体该如何执行就已经定下来。\n5) 执行器 # 当选择了执行方案后,MySQL 就准备开始执行了,首先执行前会校验该用户有没有权限,如果没有权限,就会返回错误信息,如果有权限,就会去调用引擎的接口,返回接口执行的结果。\n二 语句分析 # 2.1 查询语句 # 说了以上这么多,那么究竟一条 SQL 语句是如何执行的呢?其实我们的 SQL 可以分为两种,一种是查询,一种是更新(增加,修改,删除)。我们先分析下查询语句,语句如下:\nselect * from tb_student A where A.age=\u0026#39;18\u0026#39; and A.name=\u0026#39; 张三 \u0026#39;; 结合上面的说明,我们分析下这个语句的执行流程:\n先检查该语句是否有权限,如果没有权限,直接返回错误信息,如果有权限,在 MySQL8.0 版本以前,会先查询缓存,以这条 SQL 语句为 key 在内存中查询是否有结果,如果有直接缓存,如果没有,执行下一步。\n通过分析器进行词法分析,提取 SQL 语句的关键元素,比如提取上面这个语句是查询 select,提取需要查询的表名为 tb_student,需要查询所有的列,查询条件是这个表的 id=\u0026lsquo;1\u0026rsquo;。然后判断这个 SQL 语句是否有语法错误,比如关键词是否正确等等,如果检查没问题就执行下一步。\n接下来就是优化器进行确定执行方案,上面的 SQL 语句,可以有两种执行方案:a.先查询学生表中姓名为“张三”的学生,然后判断是否年龄是 18。b.先找出学生中年龄 18 岁的学生,然后再查询姓名为“张三”的学生。那么优化器根据自己的优化算法进行选择执行效率最好的一个方案(优化器认为,有时候不一定最好)。那么确认了执行计划后就准备开始执行了。\n进行权限校验,如果没有权限就会返回错误信息,如果有权限就会调用数据库引擎接口,返回引擎的执行结果。\n2.2 更新语句 # 以上就是一条查询 SQL 的执行流程,那么接下来我们看看一条更新语句如何执行的呢?SQL 语句如下:\nupdate tb_student A set A.age=\u0026#39;19\u0026#39; where A.name=\u0026#39; 张三 \u0026#39;; 我们来给张三修改下年龄,在实际数据库肯定不会设置年龄这个字段的,不然要被技术负责人打的。其实这条语句也基本上会沿着上一个查询的流程走,只不过执行更新的时候肯定要记录日志啦,这就会引入日志模块了,MySQL 自带的日志模块是 binlog(归档日志) ,所有的存储引擎都可以使用,我们常用的 InnoDB 引擎还自带了一个日志模块 redo log(重做日志),我们就以 InnoDB 模式下来探讨这个语句的执行流程。流程如下:\n先查询到张三这一条数据,不会走查询缓存,因为更新语句会导致与该表相关的查询缓存失效。 然后拿到查询的语句,把 age 改为 19,然后调用引擎 API 接口,写入这一行数据,InnoDB 引擎把数据保存在内存中,同时记录 redo log,此时 redo log 进入 prepare 状态,然后告诉执行器,执行完成了,随时可以提交。 执行器收到通知后记录 binlog,然后调用引擎接口,提交 redo log 为提交状态。 更新完成。 这里肯定有同学会问,为什么要用两个日志模块,用一个日志模块不行吗?\n这是因为最开始 MySQL 并没有 InnoDB 引擎(InnoDB 引擎是其他公司以插件形式插入 MySQL 的),MySQL 自带的引擎是 MyISAM,但是我们知道 redo log 是 InnoDB 引擎特有的,其他存储引擎都没有,这就导致会没有 crash-safe 的能力(crash-safe 的能力即使数据库发生异常重启,之前提交的记录都不会丢失),binlog 日志只能用来归档。\n并不是说只用一个日志模块不可以,只是 InnoDB 引擎就是通过 redo log 来支持事务的。那么,又会有同学问,我用两个日志模块,但是不要这么复杂行不行,为什么 redo log 要引入 prepare 预提交状态?这里我们用反证法来说明下为什么要这么做?\n先写 redo log 直接提交,然后写 binlog,假设写完 redo log 后,机器挂了,binlog 日志没有被写入,那么机器重启后,这台机器会通过 redo log 恢复数据,但是这个时候 binlog 并没有记录该数据,后续进行机器备份的时候,就会丢失这一条数据,同时主从同步也会丢失这一条数据。 先写 binlog,然后写 redo log,假设写完了 binlog,机器异常重启了,由于没有 redo log,本机是无法恢复这一条记录的,但是 binlog 又有记录,那么和上面同样的道理,就会产生数据不一致的情况。 如果采用 redo log 两阶段提交的方式就不一样了,写完 binlog 后,然后再提交 redo log 就会防止出现上述的问题,从而保证了数据的一致性。那么问题来了,有没有一个极端的情况呢?假设 redo log 处于预提交状态,binlog 也已经写完了,这个时候发生了异常重启会怎么样呢? 这个就要依赖于 MySQL 的处理机制了,MySQL 的处理过程如下:\n判断 redo log 是否完整,如果判断是完整的,就立即提交。 如果 redo log 只是预提交但不是 commit 状态,这个时候就会去判断 binlog 是否完整,如果完整就提交 redo log, 不完整就回滚事务。 这样就解决了数据一致性的问题。\n三 总结 # MySQL 主要分为 Server 层和引擎层,Server 层主要包括连接器、查询缓存、分析器、优化器、执行器,同时还有一个日志模块(binlog),这个日志模块所有执行引擎都可以共用,redolog 只有 InnoDB 有。 引擎层是插件式的,目前主要包括,MyISAM,InnoDB,Memory 等。 查询语句的执行流程如下:权限校验(如果命中缓存)\u0026mdash;\u0026gt;查询缓存\u0026mdash;\u0026gt;分析器\u0026mdash;\u0026gt;优化器\u0026mdash;\u0026gt;权限校验\u0026mdash;\u0026gt;执行器\u0026mdash;\u0026gt;引擎 更新语句执行流程如下:分析器\u0026mdash;-\u0026gt;权限校验\u0026mdash;-\u0026gt;执行器\u0026mdash;\u0026gt;引擎\u0026mdash;redo log(prepare 状态)\u0026mdash;\u0026gt;binlog\u0026mdash;\u0026gt;redo log(commit 状态) 四 参考 # 《MySQL 实战 45 讲》 MySQL 5.6 参考手册: https://dev.MySQL.com/doc/refman/5.6/en/ "},{"id":584,"href":"/zh/docs/technology/Interview/system-design/security/sso-intro/","title":"SSO 单点登录详解","section":"Security","content":" 本文授权转载自: https://ken.io/note/sso-design-implement 作者:ken.io\nSSO 介绍 # 什么是 SSO? # SSO 英文全称 Single Sign On,单点登录。SSO 是在多个应用系统中,用户只需要登录一次就可以访问所有相互信任的应用系统。\n例如你登录网易账号中心( https://reg.163.com/ )之后访问以下站点都是登录状态。\n网易直播 https://v.163.com 网易博客 https://blog.163.com 网易花田 https://love.163.com 网易考拉 https://www.kaola.com 网易 Lofter http://www.lofter.com SSO 有什么好处? # 用户角度 :用户能够做到一次登录多次使用,无需记录多套用户名和密码,省心。 系统管理员角度 : 管理员只需维护好一个统一的账号中心就可以了,方便。 新系统开发角度: 新系统开发时只需直接对接统一的账号中心即可,简化开发流程,省时。 SSO 设计与实现 # 本篇文章也主要是为了探讨如何设计\u0026amp;实现一个 SSO 系统\n以下为需要实现的核心功能:\n单点登录 单点登出 支持跨域单点登录 支持跨域单点登出 核心应用与依赖 # 应用/模块/对象 说明 前台站点 需要登录的站点 SSO 站点-登录 提供登录的页面 SSO 站点-登出 提供注销登录的入口 SSO 服务-登录 提供登录服务 SSO 服务-登录状态 提供登录状态校验/登录信息查询的服务 SSO 服务-登出 提供用户注销登录的服务 数据库 存储用户账户信息 缓存 存储用户的登录信息,通常使用 Redis 用户登录状态的存储与校验 # 常见的 Web 框架对于 Session 的实现都是生成一个 SessionId 存储在浏览器 Cookie 中。然后将 Session 内容存储在服务器端内存中,这个 ken.io 在之前 Session 工作原理中也提到过。整体也是借鉴这个思路。\n用户登录成功之后,生成 AuthToken 交给客户端保存。如果是浏览器,就保存在 Cookie 中。如果是手机 App 就保存在 App 本地缓存中。本篇主要探讨基于 Web 站点的 SSO。\n用户在浏览需要登录的页面时,客户端将 AuthToken 提交给 SSO 服务校验登录状态/获取用户登录信息\n对于登录信息的存储,建议采用 Redis,使用 Redis 集群来存储登录信息,既可以保证高可用,又可以线性扩充。同时也可以让 SSO 服务满足负载均衡/可伸缩的需求。\n对象 说明 AuthToken 直接使用 UUID/GUID 即可,如果有验证 AuthToken 合法性需求,可以将 UserName+时间戳加密生成,服务端解密之后验证合法性 登录信息 通常是将 UserId,UserName 缓存起来 用户登录/登录校验 # 登录时序图\n按照上图,用户登录后 AuthToken 保存在 Cookie 中。 domain=test.com 浏览器会将 domain 设置成 .test.com,\n这样访问所有 *.test.com 的 web 站点,都会将 AuthToken 携带到服务器端。 然后通过 SSO 服务,完成对用户状态的校验/用户登录信息的获取\n登录信息获取/登录状态校验\n用户登出 # 用户登出时要做的事情很简单:\n服务端清除缓存(Redis)中的登录状态 客户端清除存储的 AuthToken 登出时序图\n跨域登录、登出 # 前面提到过,核心思路是客户端存储 AuthToken,服务器端通过 Redis 存储登录信息。由于客户端是将 AuthToken 存储在 Cookie 中的。所以跨域要解决的问题,就是如何解决 Cookie 的跨域读写问题。\n解决跨域的核心思路就是:\n登录完成之后通过回调的方式,将 AuthToken 传递给主域名之外的站点,该站点自行将 AuthToken 保存在当前域下的 Cookie 中。 登出完成之后通过回调的方式,调用非主域名站点的登出页面,完成设置 Cookie 中的 AuthToken 过期的操作。 跨域登录(主域名已登录)\n跨域登录(主域名未登录)\n跨域登出\n说明 # 关于方案:这次设计方案更多是提供实现思路。如果涉及到 APP 用户登录等情况,在访问 SSO 服务时,增加对 APP 的签名验证就好了。当然,如果有无线网关,验证签名不是问题。 关于时序图:时序图中并没有包含所有场景,只列举了核心/主要场景,另外对于一些不影响理解思路的消息能省就省了。 "},{"id":585,"href":"/zh/docs/technology/Interview/cs-basics/network/tcp-reliability-guarantee/","title":"TCP 传输可靠性保障(传输层)","section":"Network","content":" TCP 如何保证传输的可靠性? # 基于数据块传输:应用数据被分割成 TCP 认为最适合发送的数据块,再传输给网络层,数据块被称为报文段或段。 对失序数据包重新排序以及去重:TCP 为了保证不发生丢包,就给每个包一个序列号,有了序列号能够将接收到的数据根据序列号排序,并且去掉重复序列号的数据就可以实现数据包去重。 校验和 : TCP 将保持它首部和数据的检验和。这是一个端到端的检验和,目的是检测数据在传输过程中的任何变化。如果收到段的检验和有差错,TCP 将丢弃这个报文段和不确认收到此报文段。 重传机制 : 在数据包丢失或延迟的情况下,重新发送数据包,直到收到对方的确认应答(ACK)。TCP 重传机制主要有:基于计时器的重传(也就是超时重传)、快速重传(基于接收端的反馈信息来引发重传)、SACK(在快速重传的基础上,返回最近收到的报文段的序列号范围,这样客户端就知道,哪些数据包已经到达服务器了)、D-SACK(重复 SACK,在 SACK 的基础上,额外携带信息,告知发送方有哪些数据包自己重复接收了)。关于重传机制的详细介绍,可以查看 详解 TCP 超时与重传机制这篇文章。 流量控制 : TCP 连接的每一方都有固定大小的缓冲空间,TCP 的接收端只允许发送端发送接收端缓冲区能接纳的数据。当接收方来不及处理发送方的数据,能提示发送方降低发送的速率,防止包丢失。TCP 使用的流量控制协议是可变大小的滑动窗口协议(TCP 利用滑动窗口实现流量控制)。 拥塞控制 : 当网络拥塞时,减少数据的发送。TCP 在发送数据的时候,需要考虑两个因素:一是接收方的接收能力,二是网络的拥塞程度。接收方的接收能力由滑动窗口表示,表示接收方还有多少缓冲区可以用来接收数据。网络的拥塞程度由拥塞窗口表示,它是发送方根据网络状况自己维护的一个值,表示发送方认为可以在网络中传输的数据量。发送方发送数据的大小是滑动窗口和拥塞窗口的最小值,这样可以保证发送方既不会超过接收方的接收能力,也不会造成网络的过度拥塞。 TCP 如何实现流量控制? # TCP 利用滑动窗口实现流量控制。流量控制是为了控制发送方发送速率,保证接收方来得及接收。 接收方发送的确认报文中的窗口字段可以用来控制发送方窗口大小,从而影响发送方的发送速率。将窗口字段设置为 0,则发送方不能发送数据。\n为什么需要流量控制? 这是因为双方在通信的时候,发送方的速率与接收方的速率是不一定相等,如果发送方的发送速率太快,会导致接收方处理不过来。如果接收方处理不过来的话,就只能把处理不过来的数据存在 接收缓冲区(Receiving Buffers) 里(失序的数据包也会被存放在缓存区里)。如果缓存区满了发送方还在狂发数据的话,接收方只能把收到的数据包丢掉。出现丢包问题的同时又疯狂浪费着珍贵的网络资源。因此,我们需要控制发送方的发送速率,让接收方与发送方处于一种动态平衡才好。\n这里需要注意的是(常见误区):\n发送端不等同于客户端 接收端不等同于服务端 TCP 为全双工(Full-Duplex, FDX)通信,双方可以进行双向通信,客户端和服务端既可能是发送端又可能是服务端。因此,两端各有一个发送缓冲区与接收缓冲区,两端都各自维护一个发送窗口和一个接收窗口。接收窗口大小取决于应用、系统、硬件的限制(TCP 传输速率不能大于应用的数据处理速率)。通信双方的发送窗口和接收窗口的要求相同\nTCP 发送窗口可以划分成四个部分:\n已经发送并且确认的 TCP 段(已经发送并确认); 已经发送但是没有确认的 TCP 段(已经发送未确认); 未发送但是接收方准备接收的 TCP 段(可以发送); 未发送并且接收方也并未准备接受的 TCP 段(不可发送)。 TCP 发送窗口结构图示:\nSND.WND:发送窗口。 SND.UNA:Send Unacknowledged 指针,指向发送窗口的第一个字节。 SND.NXT:Send Next 指针,指向可用窗口的第一个字节。 可用窗口大小 = SND.UNA + SND.WND - SND.NXT 。\nTCP 接收窗口可以划分成三个部分:\n已经接收并且已经确认的 TCP 段(已经接收并确认); 等待接收且允许发送方发送 TCP 段(可以接收未确认); 不可接收且不允许发送方发送 TCP 段(不可接收)。 TCP 接收窗口结构图示:\n接收窗口的大小是根据接收端处理数据的速度动态调整的。 如果接收端读取数据快,接收窗口可能会扩大。 否则,它可能会缩小。\n另外,这里的滑动窗口大小只是为了演示使用,实际窗口大小通常会远远大于这个值。\nTCP 的拥塞控制是怎么实现的? # 在某段时间,若对网络中某一资源的需求超过了该资源所能提供的可用部分,网络的性能就要变坏。这种情况就叫拥塞。拥塞控制就是为了防止过多的数据注入到网络中,这样就可以使网络中的路由器或链路不致过载。拥塞控制所要做的都有一个前提,就是网络能够承受现有的网络负荷。拥塞控制是一个全局性的过程,涉及到所有的主机,所有的路由器,以及与降低网络传输性能有关的所有因素。相反,流量控制往往是点对点通信量的控制,是个端到端的问题。流量控制所要做到的就是抑制发送端发送数据的速率,以便使接收端来得及接收。\n为了进行拥塞控制,TCP 发送方要维持一个 拥塞窗口(cwnd) 的状态变量。拥塞控制窗口的大小取决于网络的拥塞程度,并且动态变化。发送方让自己的发送窗口取为拥塞窗口和接收方的接受窗口中较小的一个。\nTCP 的拥塞控制采用了四种算法,即 慢开始、 拥塞避免、快重传 和 快恢复。在网络层也可以使路由器采用适当的分组丢弃策略(如主动队列管理 AQM),以减少网络拥塞的发生。\n慢开始: 慢开始算法的思路是当主机开始发送数据时,如果立即把大量数据字节注入到网络,那么可能会引起网络阻塞,因为现在还不知道网络的符合情况。经验表明,较好的方法是先探测一下,即由小到大逐渐增大发送窗口,也就是由小到大逐渐增大拥塞窗口数值。cwnd 初始值为 1,每经过一个传播轮次,cwnd 加倍。 拥塞避免: 拥塞避免算法的思路是让拥塞窗口 cwnd 缓慢增大,即每经过一个往返时间 RTT 就把发送方的 cwnd 加 1. 快重传与快恢复: 在 TCP/IP 中,快速重传和恢复(fast retransmit and recovery,FRR)是一种拥塞控制算法,它能快速恢复丢失的数据包。没有 FRR,如果数据包丢失了,TCP 将会使用定时器来要求传输暂停。在暂停的这段时间内,没有新的或复制的数据包被发送。有了 FRR,如果接收机接收到一个不按顺序的数据段,它会立即给发送机发送一个重复确认。如果发送机接收到三个重复确认,它会假定确认件指出的数据段丢失了,并立即重传这些丢失的数据段。有了 FRR,就不会因为重传时要求的暂停被耽误。 当有单独的数据包丢失时,快速重传和恢复(FRR)能最有效地工作。当有多个数据信息包在某一段很短的时间内丢失时,它则不能很有效地工作。 ARQ 协议了解吗? # 自动重传请求(Automatic Repeat-reQuest,ARQ)是 OSI 模型中数据链路层和传输层的错误纠正协议之一。它通过使用确认和超时这两个机制,在不可靠服务的基础上实现可靠的信息传输。如果发送方在发送后一段时间之内没有收到确认信息(Acknowledgements,就是我们常说的 ACK),它通常会重新发送,直到收到确认或者重试超过一定的次数。\nARQ 包括停止等待 ARQ 协议和连续 ARQ 协议。\n停止等待 ARQ 协议 # 停止等待协议是为了实现可靠传输的,它的基本原理就是每发完一个分组就停止发送,等待对方确认(回复 ACK)。如果过了一段时间(超时时间后),还是没有收到 ACK 确认,说明没有发送成功,需要重新发送,直到收到确认后再发下一个分组;\n在停止等待协议中,若接收方收到重复分组,就丢弃该分组,但同时还要发送确认。\n1) 无差错情况:\n发送方发送分组,接收方在规定时间内收到,并且回复确认.发送方再次发送。\n2) 出现差错情况(超时重传):\n停止等待协议中超时重传是指只要超过一段时间仍然没有收到确认,就重传前面发送过的分组(认为刚才发送过的分组丢失了)。因此每发送完一个分组需要设置一个超时计时器,其重传时间应比数据在分组传输的平均往返时间更长一些。这种自动重传方式常称为 自动重传请求 ARQ 。另外在停止等待协议中若收到重复分组,就丢弃该分组,但同时还要发送确认。\n3) 确认丢失和确认迟到\n确认丢失:确认消息在传输过程丢失。当 A 发送 M1 消息,B 收到后,B 向 A 发送了一个 M1 确认消息,但却在传输过程中丢失。而 A 并不知道,在超时计时过后,A 重传 M1 消息,B 再次收到该消息后采取以下两点措施:1. 丢弃这个重复的 M1 消息,不向上层交付。 2. 向 A 发送确认消息。(不会认为已经发送过了,就不再发送。A 能重传,就证明 B 的确认消息丢失)。 确认迟到:确认消息在传输过程中迟到。A 发送 M1 消息,B 收到并发送确认。在超时时间内没有收到确认消息,A 重传 M1 消息,B 仍然收到并继续发送确认消息(B 收到了 2 份 M1)。此时 A 收到了 B 第二次发送的确认消息。接着发送其他数据。过了一会,A 收到了 B 第一次发送的对 M1 的确认消息(A 也收到了 2 份确认消息)。处理如下:1. A 收到重复的确认后,直接丢弃。2. B 收到重复的 M1 后,也直接丢弃重复的 M1。 连续 ARQ 协议 # 连续 ARQ 协议可提高信道利用率。发送方维持一个发送窗口,凡位于发送窗口内的分组可以连续发送出去,而不需要等待对方确认。接收方一般采用累计确认,对按序到达的最后一个分组发送确认,表明到这个分组为止的所有分组都已经正确收到了。\n优点: 信道利用率高,容易实现,即使确认丢失,也不必重传。 缺点: 不能向发送方反映出接收方已经正确收到的所有分组的信息。 比如:发送方发送了 5 条 消息,中间第三条丢失(3 号),这时接收方只能对前两个发送确认。发送方无法知道后三个分组的下落,而只好把后三个全部重传一次。这也叫 Go-Back-N(回退 N),表示需要退回来重传已经发送过的 N 个消息。 超时重传如何实现?超时重传时间怎么确定? # 当发送方发送数据之后,它启动一个定时器,等待目的端确认收到这个报文段。接收端实体对已成功收到的包发回一个相应的确认信息(ACK)。如果发送端实体在合理的往返时延(RTT)内未收到确认消息,那么对应的数据包就被假设为 已丢失并进行重传。\nRTT(Round Trip Time):往返时间,也就是数据包从发出去到收到对应 ACK 的时间。 RTO(Retransmission Time Out):重传超时时间,即从数据发送时刻算起,超过这个时间便执行重传。 RTO 的确定是一个关键问题,因为它直接影响到 TCP 的性能和效率。如果 RTO 设置得太小,会导致不必要的重传,增加网络负担;如果 RTO 设置得太大,会导致数据传输的延迟,降低吞吐量。因此,RTO 应该根据网络的实际状况,动态地进行调整。\nRTT 的值会随着网络的波动而变化,所以 TCP 不能直接使用 RTT 作为 RTO。为了动态地调整 RTO,TCP 协议采用了一些算法,如加权移动平均(EWMA)算法,Karn 算法,Jacobson 算法等,这些算法都是根据往返时延(RTT)的测量和变化来估计 RTO 的值。\n参考 # 《计算机网络(第 7 版)》 《图解 HTTP》 https://www.9tut.com/tcp-and-udp-tutorial https://github.com/wolverinn/Waking-Up/blob/master/Computer%20Network.md TCP Flow Control— https://www.brianstorti.com/tcp-flow-control/ TCP 流量控制(Flow Control): https://notfalse.net/24/tcp-flow-control TCP 之滑动窗口原理 : https://cloud.tencent.com/developer/article/1857363 "},{"id":586,"href":"/zh/docs/technology/Interview/cs-basics/network/tcp-connection-and-disconnection/","title":"TCP 三次握手和四次挥手(传输层)","section":"Network","content":"为了准确无误地把数据送达目标处,TCP 协议采用了三次握手策略。\n建立连接-TCP 三次握手 # 建立一个 TCP 连接需要“三次握手”,缺一不可:\n一次握手:客户端发送带有 SYN(SEQ=x) 标志的数据包 -\u0026gt; 服务端,然后客户端进入 SYN_SEND 状态,等待服务端的确认; 二次握手:服务端发送带有 SYN+ACK(SEQ=y,ACK=x+1) 标志的数据包 –\u0026gt; 客户端,然后服务端进入 SYN_RECV 状态; 三次握手:客户端发送带有 ACK(ACK=y+1) 标志的数据包 –\u0026gt; 服务端,然后客户端和服务端都进入ESTABLISHED 状态,完成 TCP 三次握手。 当建立了 3 次握手之后,客户端和服务端就可以传输数据啦!\n什么是半连接队列和全连接队列? # 在 TCP 三次握手过程中,Linux 内核会维护两个队列来管理连接请求:\n半连接队列(也称 SYN Queue):当服务端收到客户端的 SYN 请求时,此时双方还没有完全建立连接,它会把半连接状态的连接放在半连接队列。 全连接队列(也称 Accept Queue):当服务端收到客户端对 ACK 响应时,意味着三次握手成功完成,服务端会将该连接从半连接队列移动到全连接队列。如果未收到客户端的 ACK 响应,会进行重传,重传的等待时间通常是指数增长的。如果重传次数超过系统规定的最大重传次数,系统将从半连接队列中删除该连接信息。 这两个队列的存在是为了处理并发连接请求,确保服务端能够有效地管理新的连接请求。另外,新的连接请求被拒绝或忽略除了和每个队列的大小限制有关系之外,还和很多其他因素有关系,这里就不详细介绍了,整体逻辑比较复杂。\n为什么要三次握手? # 三次握手的目的是建立可靠的通信信道,说到通讯,简单来说就是数据的发送与接收,而三次握手最主要的目的就是双方确认自己与对方的发送与接收是正常的。\n第一次握手:Client 什么都不能确认;Server 确认了对方发送正常,自己接收正常 第二次握手:Client 确认了:自己发送、接收正常,对方发送、接收正常;Server 确认了:对方发送正常,自己接收正常 第三次握手:Client 确认了:自己发送、接收正常,对方发送、接收正常;Server 确认了:自己发送、接收正常,对方发送、接收正常 三次握手就能确认双方收发功能都正常,缺一不可。\n更详细的解答可以看这个: TCP 为什么是三次握手,而不是两次或四次? - 车小胖的回答 - 知乎 。\n第 2 次握手传回了 ACK,为什么还要传回 SYN? # 服务端传回发送端所发送的 ACK 是为了告诉客户端:“我接收到的信息确实就是你所发送的信号了”,这表明从客户端到服务端的通信是正常的。回传 SYN 则是为了建立并确认从服务端到客户端的通信。\nSYN 同步序列编号(Synchronize Sequence Numbers) 是 TCP/IP 建立连接时使用的握手信号。在客户机和服务端之间建立正常的 TCP 网络连接时,客户机首先发出一个 SYN 消息,服务端使用 SYN-ACK 应答表示接收到了这个消息,最后客户机再以 ACK(Acknowledgement)消息响应。这样在客户机和服务端之间才能建立起可靠的 TCP 连接,数据才可以在客户机和服务端之间传递。\n三次握手过程中可以携带数据吗? # 在 TCP 三次握手过程中,第三次握手是可以携带数据的(客户端发送完 ACK 确认包之后就进入 ESTABLISHED 状态了),这一点在 RFC 793 文档中有提到。也就是说,一旦完成了前两次握手,TCP 协议允许数据在第三次握手时开始传输。\n如果第三次握手的 ACK 确认包丢失,但是客户端已经开始发送携带数据的包,那么服务端在收到这个携带数据的包时,如果该包中包含了 ACK 标记,服务端会将其视为有效的第三次握手确认。这样,连接就被认为是建立的,服务端会处理该数据包,并继续正常的数据传输流程。\n断开连接-TCP 四次挥手 # 断开一个 TCP 连接则需要“四次挥手”,缺一不可:\n第一次挥手:客户端发送一个 FIN(SEQ=x) 标志的数据包-\u0026gt;服务端,用来关闭客户端到服务端的数据传送。然后客户端进入 FIN-WAIT-1 状态。 第二次挥手:服务端收到这个 FIN(SEQ=X) 标志的数据包,它发送一个 ACK (ACK=x+1)标志的数据包-\u0026gt;客户端 。然后服务端进入 CLOSE-WAIT 状态,客户端进入 FIN-WAIT-2 状态。 第三次挥手:服务端发送一个 FIN (SEQ=y)标志的数据包-\u0026gt;客户端,请求关闭连接,然后服务端进入 LAST-ACK 状态。 第四次挥手:客户端发送 ACK (ACK=y+1)标志的数据包-\u0026gt;服务端,然后客户端进入TIME-WAIT状态,服务端在收到 ACK (ACK=y+1)标志的数据包后进入 CLOSE 状态。此时如果客户端等待 2MSL 后依然没有收到回复,就证明服务端已正常关闭,随后客户端也可以关闭连接了。 只要四次挥手没有结束,客户端和服务端就可以继续传输数据!\n为什么要四次挥手? # TCP 是全双工通信,可以双向传输数据。任何一方都可以在数据传送结束后发出连接释放的通知,待对方确认后进入半关闭状态。当另一方也没有数据再发送的时候,则发出连接释放通知,对方确认后就完全关闭了 TCP 连接。\n举个例子:A 和 B 打电话,通话即将结束后。\n第一次挥手:A 说“我没啥要说的了” 第二次挥手:B 回答“我知道了”,但是 B 可能还会有要说的话,A 不能要求 B 跟着自己的节奏结束通话 第三次挥手:于是 B 可能又巴拉巴拉说了一通,最后 B 说“我说完了” 第四次挥手:A 回答“知道了”,这样通话才算结束。 为什么不能把服务端发送的 ACK 和 FIN 合并起来,变成三次挥手? # 因为服务端收到客户端断开连接的请求时,可能还有一些数据没有发完,这时先回复 ACK,表示接收到了断开连接的请求。等到数据发完之后再发 FIN,断开服务端到客户端的数据传送。\n如果第二次挥手时服务端的 ACK 没有送达客户端,会怎样? # 客户端没有收到 ACK 确认,会重新发送 FIN 请求。\n为什么第四次挥手客户端需要等待 2*MSL(报文段最长寿命)时间后才进入 CLOSED 状态? # 第四次挥手时,客户端发送给服务端的 ACK 有可能丢失,如果服务端因为某些原因而没有收到 ACK 的话,服务端就会重发 FIN,如果客户端在 2*MSL 的时间内收到了 FIN,就会重新发送 ACK 并再次等待 2MSL,防止 Server 没有收到 ACK 而不断重发 FIN。\nMSL(Maximum Segment Lifetime) : 一个片段在网络中最大的存活时间,2MSL 就是一个发送和一个回复所需的最大时间。如果直到 2MSL,Client 都没有再次收到 FIN,那么 Client 推断 ACK 已经被成功接收,则结束 TCP 连接。\n参考 # 《计算机网络(第 7 版)》\n《图解 HTTP》\nTCP and UDP Tutorial: https://www.9tut.com/tcp-and-udp-tutorial\n从一次线上问题说起,详解 TCP 半连接队列、全连接队列: https://mp.weixin.qq.com/s/YpSlU1yaowTs-pF6R43hMw\n"},{"id":587,"href":"/zh/docs/technology/Interview/java/concurrent/threadlocal/","title":"ThreadLocal 详解","section":"Concurrent","content":" 本文来自一枝花算不算浪漫投稿, 原文地址: https://juejin.cn/post/6844904151567040519。\n前言 # 全文共 10000+字,31 张图,这篇文章同样耗费了不少的时间和精力才创作完成,原创不易,请大家点点关注+在看,感谢。\n对于ThreadLocal,大家的第一反应可能是很简单呀,线程的变量副本,每个线程隔离。那这里有几个问题大家可以思考一下:\nThreadLocal的 key 是弱引用,那么在 ThreadLocal.get()的时候,发生GC之后,key 是否为null? ThreadLocal中ThreadLocalMap的数据结构? ThreadLocalMap的Hash 算法? ThreadLocalMap中Hash 冲突如何解决? ThreadLocalMap的扩容机制? ThreadLocalMap中过期 key 的清理机制?探测式清理和启发式清理流程? ThreadLocalMap.set()方法实现原理? ThreadLocalMap.get()方法实现原理? 项目中ThreadLocal使用情况?遇到的坑? …… 上述的一些问题你是否都已经掌握的很清楚了呢?本文将围绕这些问题使用图文方式来剖析ThreadLocal的点点滴滴。\n目录 # 注明: 本文源码基于JDK 1.8\nThreadLocal代码演示 # 我们先看下ThreadLocal使用示例:\npublic class ThreadLocalTest { private List\u0026lt;String\u0026gt; messages = Lists.newArrayList(); public static final ThreadLocal\u0026lt;ThreadLocalTest\u0026gt; holder = ThreadLocal.withInitial(ThreadLocalTest::new); public static void add(String message) { holder.get().messages.add(message); } public static List\u0026lt;String\u0026gt; clear() { List\u0026lt;String\u0026gt; messages = holder.get().messages; holder.remove(); System.out.println(\u0026#34;size: \u0026#34; + holder.get().messages.size()); return messages; } public static void main(String[] args) { ThreadLocalTest.add(\u0026#34;一枝花算不算浪漫\u0026#34;); System.out.println(holder.get().messages); ThreadLocalTest.clear(); } } 打印结果:\n[一枝花算不算浪漫] size: 0 ThreadLocal对象可以提供线程局部变量,每个线程Thread拥有一份自己的副本变量,多个线程互不干扰。\nThreadLocal的数据结构 # Thread类有一个类型为ThreadLocal.ThreadLocalMap的实例变量threadLocals,也就是说每个线程有一个自己的ThreadLocalMap。\nThreadLocalMap有自己的独立实现,可以简单地将它的key视作ThreadLocal,value为代码中放入的值(实际上key并不是ThreadLocal本身,而是它的一个弱引用)。\n每个线程在往ThreadLocal里放值的时候,都会往自己的ThreadLocalMap里存,读也是以ThreadLocal作为引用,在自己的map里找对应的key,从而实现了线程隔离。\nThreadLocalMap有点类似HashMap的结构,只是HashMap是由数组+链表实现的,而ThreadLocalMap中并没有链表结构。\n我们还要注意Entry, 它的key是ThreadLocal\u0026lt;?\u0026gt; k ,继承自WeakReference, 也就是我们常说的弱引用类型。\nGC 之后 key 是否为 null? # 回应开头的那个问题, ThreadLocal 的key是弱引用,那么在ThreadLocal.get()的时候,发生GC之后,key是否是null?\n为了搞清楚这个问题,我们需要搞清楚Java的四种引用类型:\n强引用:我们常常 new 出来的对象就是强引用类型,只要强引用存在,垃圾回收器将永远不会回收被引用的对象,哪怕内存不足的时候 软引用:使用 SoftReference 修饰的对象被称为软引用,软引用指向的对象在内存要溢出的时候被回收 弱引用:使用 WeakReference 修饰的对象被称为弱引用,只要发生垃圾回收,若这个对象只被弱引用指向,那么就会被回收 虚引用:虚引用是最弱的引用,在 Java 中使用 PhantomReference 进行定义。虚引用中唯一的作用就是用队列接收对象即将死亡的通知 接着再来看下代码,我们使用反射的方式来看看GC后ThreadLocal中的数据情况:(下面代码来源自: https://blog.csdn.net/thewindkee/article/details/103726942 本地运行演示 GC 回收场景)\npublic class ThreadLocalDemo { public static void main(String[] args) throws NoSuchFieldException, IllegalAccessException, InterruptedException { Thread t = new Thread(()-\u0026gt;test(\u0026#34;abc\u0026#34;,false)); t.start(); t.join(); System.out.println(\u0026#34;--gc后--\u0026#34;); Thread t2 = new Thread(() -\u0026gt; test(\u0026#34;def\u0026#34;, true)); t2.start(); t2.join(); } private static void test(String s,boolean isGC) { try { new ThreadLocal\u0026lt;\u0026gt;().set(s); if (isGC) { System.gc(); } Thread t = Thread.currentThread(); Class\u0026lt;? extends Thread\u0026gt; clz = t.getClass(); Field field = clz.getDeclaredField(\u0026#34;threadLocals\u0026#34;); field.setAccessible(true); Object ThreadLocalMap = field.get(t); Class\u0026lt;?\u0026gt; tlmClass = ThreadLocalMap.getClass(); Field tableField = tlmClass.getDeclaredField(\u0026#34;table\u0026#34;); tableField.setAccessible(true); Object[] arr = (Object[]) tableField.get(ThreadLocalMap); for (Object o : arr) { if (o != null) { Class\u0026lt;?\u0026gt; entryClass = o.getClass(); Field valueField = entryClass.getDeclaredField(\u0026#34;value\u0026#34;); Field referenceField = entryClass.getSuperclass().getSuperclass().getDeclaredField(\u0026#34;referent\u0026#34;); valueField.setAccessible(true); referenceField.setAccessible(true); System.out.println(String.format(\u0026#34;弱引用key:%s,值:%s\u0026#34;, referenceField.get(o), valueField.get(o))); } } } catch (Exception e) { e.printStackTrace(); } } } 结果如下:\n弱引用key:java.lang.ThreadLocal@433619b6,值:abc 弱引用key:java.lang.ThreadLocal@418a15e3,值:java.lang.ref.SoftReference@bf97a12 --gc后-- 弱引用key:null,值:def 如图所示,因为这里创建的ThreadLocal并没有指向任何值,也就是没有任何引用:\nnew ThreadLocal\u0026lt;\u0026gt;().set(s); 所以这里在GC之后,key就会被回收,我们看到上面debug中的referent=null, 如果改动一下代码:\n这个问题刚开始看,如果没有过多思考,弱引用,还有垃圾回收,那么肯定会觉得是null。\n其实是不对的,因为题目说的是在做 ThreadLocal.get() 操作,证明其实还是有强引用存在的,所以 key 并不为 null,如下图所示,ThreadLocal的强引用仍然是存在的。\n如果我们的强引用不存在的话,那么 key 就会被回收,也就是会出现我们 value 没被回收,key 被回收,导致 value 永远存在,出现内存泄漏。\nThreadLocal.set()方法源码详解 # ThreadLocal中的set方法原理如上图所示,很简单,主要是判断ThreadLocalMap是否存在,然后使用ThreadLocal中的set方法进行数据处理。\n代码如下:\npublic void set(T value) { Thread t = Thread.currentThread(); ThreadLocalMap map = getMap(t); if (map != null) map.set(this, value); else createMap(t, value); } void createMap(Thread t, T firstValue) { t.threadLocals = new ThreadLocalMap(this, firstValue); } 主要的核心逻辑还是在ThreadLocalMap中的,一步步往下看,后面还有更详细的剖析。\nThreadLocalMap Hash 算法 # 既然是Map结构,那么ThreadLocalMap当然也要实现自己的hash算法来解决散列表数组冲突问题。\nint i = key.threadLocalHashCode \u0026amp; (len-1); ThreadLocalMap中hash算法很简单,这里i就是当前 key 在散列表中对应的数组下标位置。\n这里最关键的就是threadLocalHashCode值的计算,ThreadLocal中有一个属性为HASH_INCREMENT = 0x61c88647\npublic class ThreadLocal\u0026lt;T\u0026gt; { private final int threadLocalHashCode = nextHashCode(); private static AtomicInteger nextHashCode = new AtomicInteger(); private static final int HASH_INCREMENT = 0x61c88647; private static int nextHashCode() { return nextHashCode.getAndAdd(HASH_INCREMENT); } static class ThreadLocalMap { ThreadLocalMap(ThreadLocal\u0026lt;?\u0026gt; firstKey, Object firstValue) { table = new Entry[INITIAL_CAPACITY]; int i = firstKey.threadLocalHashCode \u0026amp; (INITIAL_CAPACITY - 1); table[i] = new Entry(firstKey, firstValue); size = 1; setThreshold(INITIAL_CAPACITY); } } } 每当创建一个ThreadLocal对象,这个ThreadLocal.nextHashCode 这个值就会增长 0x61c88647 。\n这个值很特殊,它是斐波那契数 也叫 黄金分割数。hash增量为 这个数字,带来的好处就是 hash 分布非常均匀。\n我们自己可以尝试下:\n可以看到产生的哈希码分布很均匀,这里不去细纠斐波那契具体算法,感兴趣的可以自行查阅相关资料。\nThreadLocalMap Hash 冲突 # 注明: 下面所有示例图中,绿色块Entry代表正常数据,灰色块代表Entry的key值为null,已被垃圾回收。白色块表示Entry为null。\n虽然ThreadLocalMap中使用了黄金分割数来作为hash计算因子,大大减少了Hash冲突的概率,但是仍然会存在冲突。\nHashMap中解决冲突的方法是在数组上构造一个链表结构,冲突的数据挂载到链表上,如果链表长度超过一定数量则会转化成红黑树。\n而 ThreadLocalMap 中并没有链表结构,所以这里不能使用 HashMap 解决冲突的方式了。\n如上图所示,如果我们插入一个value=27的数据,通过 hash 计算后应该落入槽位 4 中,而槽位 4 已经有了 Entry 数据。\n此时就会线性向后查找,一直找到 Entry 为 null 的槽位才会停止查找,将当前元素放入此槽位中。当然迭代过程中还有其他的情况,比如遇到了 Entry 不为 null 且 key 值相等的情况,还有 Entry 中的 key 值为 null 的情况等等都会有不同的处理,后面会一一详细讲解。\n这里还画了一个Entry中的key为null的数据(Entry=2 的灰色块数据),因为key值是弱引用类型,所以会有这种数据存在。在set过程中,如果遇到了key过期的Entry数据,实际上是会进行一轮探测式清理操作的,具体操作方式后面会讲到。\nThreadLocalMap.set()详解 # ThreadLocalMap.set()原理图解 # 看完了ThreadLocal hash 算法后,我们再来看set是如何实现的。\n往ThreadLocalMap中set数据(新增或者更新数据)分为好几种情况,针对不同的情况我们画图来说明。\n第一种情况: 通过hash计算后的槽位对应的Entry数据为空:\n这里直接将数据放到该槽位即可。\n第二种情况: 槽位数据不为空,key值与当前ThreadLocal通过hash计算获取的key值一致:\n这里直接更新该槽位的数据。\n第三种情况: 槽位数据不为空,往后遍历过程中,在找到Entry为null的槽位之前,没有遇到key过期的Entry:\n遍历散列数组,线性往后查找,如果找到Entry为null的槽位,则将数据放入该槽位中,或者往后遍历过程中,遇到了key 值相等的数据,直接更新即可。\n第四种情况: 槽位数据不为空,往后遍历过程中,在找到Entry为null的槽位之前,遇到key过期的Entry,如下图,往后遍历过程中,遇到了index=7的槽位数据Entry的key=null:\n散列数组下标为 7 位置对应的Entry数据key为null,表明此数据key值已经被垃圾回收掉了,此时就会执行replaceStaleEntry()方法,该方法含义是替换过期数据的逻辑,以index=7位起点开始遍历,进行探测式数据清理工作。\n初始化探测式清理过期数据扫描的开始位置:slotToExpunge = staleSlot = 7\n以当前staleSlot开始 向前迭代查找,找其他过期的数据,然后更新过期数据起始扫描下标slotToExpunge。for循环迭代,直到碰到Entry为null结束。\n如果找到了过期的数据,继续向前迭代,直到遇到Entry=null的槽位才停止迭代,如下图所示,slotToExpunge 被更新为 0:\n以当前节点(index=7)向前迭代,检测是否有过期的Entry数据,如果有则更新slotToExpunge值。碰到null则结束探测。以上图为例slotToExpunge被更新为 0。\n上面向前迭代的操作是为了更新探测清理过期数据的起始下标slotToExpunge的值,这个值在后面会讲解,它是用来判断当前过期槽位staleSlot之前是否还有过期元素。\n接着开始以staleSlot位置(index=7)向后迭代,如果找到了相同 key 值的 Entry 数据:\n从当前节点staleSlot向后查找key值相等的Entry元素,找到后更新Entry的值并交换staleSlot元素的位置(staleSlot位置为过期元素),更新Entry数据,然后开始进行过期Entry的清理工作,如下图所示:\n向后遍历过程中,如果没有找到相同 key 值的 Entry 数据:\n从当前节点staleSlot向后查找key值相等的Entry元素,直到Entry为null则停止寻找。通过上图可知,此时table中没有key值相同的Entry。\n创建新的Entry,替换table[stableSlot]位置:\n替换完成后也是进行过期元素清理工作,清理工作主要是有两个方法:expungeStaleEntry()和cleanSomeSlots(),具体细节后面会讲到,请继续往后看。\nThreadLocalMap.set()源码详解 # 上面已经用图的方式解析了set()实现的原理,其实已经很清晰了,我们接着再看下源码:\njava.lang.ThreadLocal.ThreadLocalMap.set():\nprivate void set(ThreadLocal\u0026lt;?\u0026gt; key, Object value) { Entry[] tab = table; int len = tab.length; int i = key.threadLocalHashCode \u0026amp; (len-1); for (Entry e = tab[i]; e != null; e = tab[i = nextIndex(i, len)]) { ThreadLocal\u0026lt;?\u0026gt; k = e.get(); if (k == key) { e.value = value; return; } if (k == null) { replaceStaleEntry(key, value, i); return; } } tab[i] = new Entry(key, value); int sz = ++size; if (!cleanSomeSlots(i, sz) \u0026amp;\u0026amp; sz \u0026gt;= threshold) rehash(); } 这里会通过key来计算在散列表中的对应位置,然后以当前key对应的桶的位置向后查找,找到可以使用的桶。\nEntry[] tab = table; int len = tab.length; int i = key.threadLocalHashCode \u0026amp; (len-1); 什么情况下桶才是可以使用的呢?\nk = key 说明是替换操作,可以使用 碰到一个过期的桶,执行替换逻辑,占用过期桶 查找过程中,碰到桶中Entry=null的情况,直接使用 接着就是执行for循环遍历,向后查找,我们先看下nextIndex()、prevIndex()方法实现:\nprivate static int nextIndex(int i, int len) { return ((i + 1 \u0026lt; len) ? i + 1 : 0); } private static int prevIndex(int i, int len) { return ((i - 1 \u0026gt;= 0) ? i - 1 : len - 1); } 接着看剩下for循环中的逻辑:\n遍历当前key值对应的桶中Entry数据为空,这说明散列数组这里没有数据冲突,跳出for循环,直接set数据到对应的桶中 如果key值对应的桶中Entry数据不为空\n2.1 如果k = key,说明当前set操作是一个替换操作,做替换逻辑,直接返回\n2.2 如果key = null,说明当前桶位置的Entry是过期数据,执行replaceStaleEntry()方法(核心方法),然后返回 for循环执行完毕,继续往下执行说明向后迭代的过程中遇到了entry为null的情况\n3.1 在Entry为null的桶中创建一个新的Entry对象\n3.2 执行++size操作 调用cleanSomeSlots()做一次启发式清理工作,清理散列数组中Entry的key过期的数据\n4.1 如果清理工作完成后,未清理到任何数据,且size超过了阈值(数组长度的 2/3),进行rehash()操作\n4.2 rehash()中会先进行一轮探测式清理,清理过期key,清理完成后如果size \u0026gt;= threshold - threshold / 4,就会执行真正的扩容逻辑(扩容逻辑往后看) 接着重点看下replaceStaleEntry()方法,replaceStaleEntry()方法提供替换过期数据的功能,我们可以对应上面第四种情况的原理图来再回顾下,具体代码如下:\njava.lang.ThreadLocal.ThreadLocalMap.replaceStaleEntry():\nprivate void replaceStaleEntry(ThreadLocal\u0026lt;?\u0026gt; key, Object value, int staleSlot) { Entry[] tab = table; int len = tab.length; Entry e; int slotToExpunge = staleSlot; for (int i = prevIndex(staleSlot, len); (e = tab[i]) != null; i = prevIndex(i, len)) if (e.get() == null) slotToExpunge = i; for (int i = nextIndex(staleSlot, len); (e = tab[i]) != null; i = nextIndex(i, len)) { ThreadLocal\u0026lt;?\u0026gt; k = e.get(); if (k == key) { e.value = value; tab[i] = tab[staleSlot]; tab[staleSlot] = e; if (slotToExpunge == staleSlot) slotToExpunge = i; cleanSomeSlots(expungeStaleEntry(slotToExpunge), len); return; } if (k == null \u0026amp;\u0026amp; slotToExpunge == staleSlot) slotToExpunge = i; } tab[staleSlot].value = null; tab[staleSlot] = new Entry(key, value); if (slotToExpunge != staleSlot) cleanSomeSlots(expungeStaleEntry(slotToExpunge), len); } slotToExpunge表示开始探测式清理过期数据的开始下标,默认从当前的staleSlot开始。以当前的staleSlot开始,向前迭代查找,找到没有过期的数据,for循环一直碰到Entry为null才会结束。如果向前找到了过期数据,更新探测清理过期数据的开始下标为 i,即slotToExpunge=i\nfor (int i = prevIndex(staleSlot, len); (e = tab[i]) != null; i = prevIndex(i, len)){ if (e.get() == null){ slotToExpunge = i; } } 接着开始从staleSlot向后查找,也是碰到Entry为null的桶结束。 如果迭代过程中,碰到 k == key,这说明这里是替换逻辑,替换新数据并且交换当前staleSlot位置。如果slotToExpunge == staleSlot,这说明replaceStaleEntry()一开始向前查找过期数据时并未找到过期的Entry数据,接着向后查找过程中也未发现过期数据,修改开始探测式清理过期数据的下标为当前循环的 index,即slotToExpunge = i。最后调用cleanSomeSlots(expungeStaleEntry(slotToExpunge), len);进行启发式过期数据清理。\nif (k == key) { e.value = value; tab[i] = tab[staleSlot]; tab[staleSlot] = e; if (slotToExpunge == staleSlot) slotToExpunge = i; cleanSomeSlots(expungeStaleEntry(slotToExpunge), len); return; } cleanSomeSlots()和expungeStaleEntry()方法后面都会细讲,这两个是和清理相关的方法,一个是过期key相关Entry的启发式清理(Heuristically scan),另一个是过期key相关Entry的探测式清理。\n如果 k != key则会接着往下走,k == null说明当前遍历的Entry是一个过期数据,slotToExpunge == staleSlot说明,一开始的向前查找数据并未找到过期的Entry。如果条件成立,则更新slotToExpunge 为当前位置,这个前提是前驱节点扫描时未发现过期数据。\nif (k == null \u0026amp;\u0026amp; slotToExpunge == staleSlot) slotToExpunge = i; 往后迭代的过程中如果没有找到k == key的数据,且碰到Entry为null的数据,则结束当前的迭代操作。此时说明这里是一个添加的逻辑,将新的数据添加到table[staleSlot] 对应的slot中。\ntab[staleSlot].value = null; tab[staleSlot] = new Entry(key, value); 最后判断除了staleSlot以外,还发现了其他过期的slot数据,就要开启清理数据的逻辑:\nif (slotToExpunge != staleSlot) cleanSomeSlots(expungeStaleEntry(slotToExpunge), len); ThreadLocalMap过期 key 的探测式清理流程 # 上面我们有提及ThreadLocalMap的两种过期key数据清理方式:探测式清理和启发式清理。\n我们先讲下探测式清理,也就是expungeStaleEntry方法,遍历散列数组,从开始位置向后探测清理过期数据,将过期数据的Entry设置为null,沿途中碰到未过期的数据则将此数据rehash后重新在table数组中定位,如果定位的位置已经有了数据,则会将未过期的数据放到最靠近此位置的Entry=null的桶中,使rehash后的Entry数据距离正确的桶的位置更近一些。操作逻辑如下:\n如上图,set(27) 经过 hash 计算后应该落到index=4的桶中,由于index=4桶已经有了数据,所以往后迭代最终数据放入到index=7的桶中,放入后一段时间后index=5中的Entry数据key变为了null\n如果再有其他数据set到map中,就会触发探测式清理操作。\n如上图,执行探测式清理后,index=5的数据被清理掉,继续往后迭代,到index=7的元素时,经过rehash后发现该元素正确的index=4,而此位置已经有了数据,往后查找离index=4最近的Entry=null的节点(刚被探测式清理掉的数据:index=5),找到后移动index= 7的数据到index=5中,此时桶的位置离正确的位置index=4更近了。\n经过一轮探测式清理后,key过期的数据会被清理掉,没过期的数据经过rehash重定位后所处的桶位置理论上更接近i= key.hashCode \u0026amp; (tab.len - 1)的位置。这种优化会提高整个散列表查询性能。\n接着看下expungeStaleEntry()具体流程,我们还是以先原理图后源码讲解的方式来一步步梳理:\n我们假设expungeStaleEntry(3) 来调用此方法,如上图所示,我们可以看到ThreadLocalMap中table的数据情况,接着执行清理操作:\n第一步是清空当前staleSlot位置的数据,index=3位置的Entry变成了null。然后接着往后探测:\n执行完第二步后,index=4 的元素挪到 index=3 的槽位中。\n继续往后迭代检查,碰到正常数据,计算该数据位置是否偏移,如果被偏移,则重新计算slot位置,目的是让正常数据尽可能存放在正确位置或离正确位置更近的位置\n在往后迭代的过程中碰到空的槽位,终止探测,这样一轮探测式清理工作就完成了,接着我们继续看看具体实现源代码:\nprivate int expungeStaleEntry(int staleSlot) { Entry[] tab = table; int len = tab.length; tab[staleSlot].value = null; tab[staleSlot] = null; size--; Entry e; int i; for (i = nextIndex(staleSlot, len); (e = tab[i]) != null; i = nextIndex(i, len)) { ThreadLocal\u0026lt;?\u0026gt; k = e.get(); if (k == null) { e.value = null; tab[i] = null; size--; } else { int h = k.threadLocalHashCode \u0026amp; (len - 1); if (h != i) { tab[i] = null; while (tab[h] != null) h = nextIndex(h, len); tab[h] = e; } } } return i; } 这里我们还是以staleSlot=3 来做示例说明,首先是将tab[staleSlot]槽位的数据清空,然后设置size-- 接着以staleSlot位置往后迭代,如果遇到k==null的过期数据,也是清空该槽位数据,然后size--\nThreadLocal\u0026lt;?\u0026gt; k = e.get(); if (k == null) { e.value = null; tab[i] = null; size--; } 如果key没有过期,重新计算当前key的下标位置是不是当前槽位下标位置,如果不是,那么说明产生了hash冲突,此时以新计算出来正确的槽位位置往后迭代,找到最近一个可以存放entry的位置。\nint h = k.threadLocalHashCode \u0026amp; (len - 1); if (h != i) { tab[i] = null; while (tab[h] != null) h = nextIndex(h, len); tab[h] = e; } 这里是处理正常的产生Hash冲突的数据,经过迭代后,有过Hash冲突数据的Entry位置会更靠近正确位置,这样的话,查询的时候 效率才会更高。\nThreadLocalMap扩容机制 # 在ThreadLocalMap.set()方法的最后,如果执行完启发式清理工作后,未清理到任何数据,且当前散列数组中Entry的数量已经达到了列表的扩容阈值(len*2/3),就开始执行rehash()逻辑:\nif (!cleanSomeSlots(i, sz) \u0026amp;\u0026amp; sz \u0026gt;= threshold) rehash(); 接着看下rehash()具体实现:\nprivate void rehash() { expungeStaleEntries(); if (size \u0026gt;= threshold - threshold / 4) resize(); } private void expungeStaleEntries() { Entry[] tab = table; int len = tab.length; for (int j = 0; j \u0026lt; len; j++) { Entry e = tab[j]; if (e != null \u0026amp;\u0026amp; e.get() == null) expungeStaleEntry(j); } } 这里首先是会进行探测式清理工作,从table的起始位置往后清理,上面有分析清理的详细流程。清理完成之后,table中可能有一些key为null的Entry数据被清理掉,所以此时通过判断size \u0026gt;= threshold - threshold / 4 也就是size \u0026gt;= threshold * 3/4 来决定是否扩容。\n我们还记得上面进行rehash()的阈值是size \u0026gt;= threshold,所以当面试官套路我们ThreadLocalMap扩容机制的时候 我们一定要说清楚这两个步骤:\n接着看看具体的resize()方法,为了方便演示,我们以oldTab.len=8来举例:\n扩容后的tab的大小为oldLen * 2,然后遍历老的散列表,重新计算hash位置,然后放到新的tab数组中,如果出现hash冲突则往后寻找最近的entry为null的槽位,遍历完成之后,oldTab中所有的entry数据都已经放入到新的tab中了。重新计算tab下次扩容的阈值,具体代码如下:\nprivate void resize() { Entry[] oldTab = table; int oldLen = oldTab.length; int newLen = oldLen * 2; Entry[] newTab = new Entry[newLen]; int count = 0; for (int j = 0; j \u0026lt; oldLen; ++j) { Entry e = oldTab[j]; if (e != null) { ThreadLocal\u0026lt;?\u0026gt; k = e.get(); if (k == null) { e.value = null; } else { int h = k.threadLocalHashCode \u0026amp; (newLen - 1); while (newTab[h] != null) h = nextIndex(h, newLen); newTab[h] = e; count++; } } } setThreshold(newLen); size = count; table = newTab; } ThreadLocalMap.get()详解 # 上面已经看完了set()方法的源码,其中包括set数据、清理数据、优化数据桶的位置等操作,接着看看get()操作的原理。\nThreadLocalMap.get()图解 # 第一种情况: 通过查找key值计算出散列表中slot位置,然后该slot位置中的Entry.key和查找的key一致,则直接返回:\n第二种情况: slot位置中的Entry.key和要查找的key不一致:\n我们以get(ThreadLocal1)为例,通过hash计算后,正确的slot位置应该是 4,而index=4的槽位已经有了数据,且key值不等于ThreadLocal1,所以需要继续往后迭代查找。\n迭代到index=5的数据时,此时Entry.key=null,触发一次探测式数据回收操作,执行expungeStaleEntry()方法,执行完后,index 5,8的数据都会被回收,而index 6,7的数据都会前移。index 6,7前移之后,继续从 index=5 往后迭代,于是就在 index=6 找到了key值相等的Entry数据,如下图所示:\nThreadLocalMap.get()源码详解 # java.lang.ThreadLocal.ThreadLocalMap.getEntry():\nprivate Entry getEntry(ThreadLocal\u0026lt;?\u0026gt; key) { int i = key.threadLocalHashCode \u0026amp; (table.length - 1); Entry e = table[i]; if (e != null \u0026amp;\u0026amp; e.get() == key) return e; else return getEntryAfterMiss(key, i, e); } private Entry getEntryAfterMiss(ThreadLocal\u0026lt;?\u0026gt; key, int i, Entry e) { Entry[] tab = table; int len = tab.length; while (e != null) { ThreadLocal\u0026lt;?\u0026gt; k = e.get(); if (k == key) return e; if (k == null) expungeStaleEntry(i); else i = nextIndex(i, len); e = tab[i]; } return null; } ThreadLocalMap过期 key 的启发式清理流程 # 上面多次提及到ThreadLocalMap过期 key 的两种清理方式:探测式清理(expungeStaleEntry())、启发式清理(cleanSomeSlots())\n探测式清理是以当前Entry 往后清理,遇到值为null则结束清理,属于线性探测清理。\n而启发式清理被作者定义为:Heuristically scan some cells looking for stale entries.\n具体代码如下:\nprivate boolean cleanSomeSlots(int i, int n) { boolean removed = false; Entry[] tab = table; int len = tab.length; do { i = nextIndex(i, len); Entry e = tab[i]; if (e != null \u0026amp;\u0026amp; e.get() == null) { n = len; removed = true; i = expungeStaleEntry(i); } } while ( (n \u0026gt;\u0026gt;\u0026gt;= 1) != 0); return removed; } InheritableThreadLocal # 我们使用ThreadLocal的时候,在异步场景下是无法给子线程共享父线程中创建的线程副本数据的。\n为了解决这个问题,JDK 中还有一个InheritableThreadLocal类,我们来看一个例子:\npublic class InheritableThreadLocalDemo { public static void main(String[] args) { ThreadLocal\u0026lt;String\u0026gt; ThreadLocal = new ThreadLocal\u0026lt;\u0026gt;(); ThreadLocal\u0026lt;String\u0026gt; inheritableThreadLocal = new InheritableThreadLocal\u0026lt;\u0026gt;(); ThreadLocal.set(\u0026#34;父类数据:threadLocal\u0026#34;); inheritableThreadLocal.set(\u0026#34;父类数据:inheritableThreadLocal\u0026#34;); new Thread(new Runnable() { @Override public void run() { System.out.println(\u0026#34;子线程获取父类ThreadLocal数据:\u0026#34; + ThreadLocal.get()); System.out.println(\u0026#34;子线程获取父类inheritableThreadLocal数据:\u0026#34; + inheritableThreadLocal.get()); } }).start(); } } 打印结果:\n子线程获取父类ThreadLocal数据:null 子线程获取父类inheritableThreadLocal数据:父类数据:inheritableThreadLocal 实现原理是子线程是通过在父线程中通过调用new Thread()方法来创建子线程,Thread#init方法在Thread的构造方法中被调用。在init方法中拷贝父线程数据到子线程中:\nprivate void init(ThreadGroup g, Runnable target, String name, long stackSize, AccessControlContext acc, boolean inheritThreadLocals) { if (name == null) { throw new NullPointerException(\u0026#34;name cannot be null\u0026#34;); } if (inheritThreadLocals \u0026amp;\u0026amp; parent.inheritableThreadLocals != null) this.inheritableThreadLocals = ThreadLocal.createInheritedMap(parent.inheritableThreadLocals); this.stackSize = stackSize; tid = nextThreadID(); } 但InheritableThreadLocal仍然有缺陷,一般我们做异步化处理都是使用的线程池,而InheritableThreadLocal是在new Thread中的init()方法给赋值的,而线程池是线程复用的逻辑,所以这里会存在问题。\n当然,有问题出现就会有解决问题的方案,阿里巴巴开源了一个TransmittableThreadLocal组件就可以解决这个问题,这里就不再延伸,感兴趣的可自行查阅资料。\nThreadLocal项目中使用实战 # ThreadLocal使用场景 # 我们现在项目中日志记录用的是ELK+Logstash,最后在Kibana中进行展示和检索。\n现在都是分布式系统统一对外提供服务,项目间调用的关系可以通过 traceId 来关联,但是不同项目之间如何传递 traceId 呢?\n这里我们使用 org.slf4j.MDC 来实现此功能,内部就是通过 ThreadLocal 来实现的,具体实现如下:\n当前端发送请求到服务 A时,服务 A会生成一个类似UUID的traceId字符串,将此字符串放入当前线程的ThreadLocal中,在调用服务 B的时候,将traceId写入到请求的Header中,服务 B在接收请求时会先判断请求的Header中是否有traceId,如果存在则写入自己线程的ThreadLocal中。\n图中的requestId即为我们各个系统链路关联的traceId,系统间互相调用,通过这个requestId即可找到对应链路,这里还有会有一些其他场景:\n针对于这些场景,我们都可以有相应的解决方案,如下所示\nFeign 远程调用解决方案 # 服务发送请求:\n@Component @Slf4j public class FeignInvokeInterceptor implements RequestInterceptor { @Override public void apply(RequestTemplate template) { String requestId = MDC.get(\u0026#34;requestId\u0026#34;); if (StringUtils.isNotBlank(requestId)) { template.header(\u0026#34;requestId\u0026#34;, requestId); } } } 服务接收请求:\n@Slf4j @Component public class LogInterceptor extends HandlerInterceptorAdapter { @Override public void afterCompletion(HttpServletRequest arg0, HttpServletResponse arg1, Object arg2, Exception arg3) { MDC.remove(\u0026#34;requestId\u0026#34;); } @Override public void postHandle(HttpServletRequest arg0, HttpServletResponse arg1, Object arg2, ModelAndView arg3) { } @Override public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception { String requestId = request.getHeader(BaseConstant.REQUEST_ID_KEY); if (StringUtils.isBlank(requestId)) { requestId = UUID.randomUUID().toString().replace(\u0026#34;-\u0026#34;, \u0026#34;\u0026#34;); } MDC.put(\u0026#34;requestId\u0026#34;, requestId); return true; } } 线程池异步调用,requestId 传递 # 因为MDC是基于ThreadLocal去实现的,异步过程中,子线程并没有办法获取到父线程ThreadLocal存储的数据,所以这里可以自定义线程池执行器,修改其中的run()方法:\npublic class MyThreadPoolTaskExecutor extends ThreadPoolTaskExecutor { @Override public void execute(Runnable runnable) { Map\u0026lt;String, String\u0026gt; context = MDC.getCopyOfContextMap(); super.execute(() -\u0026gt; run(runnable, context)); } @Override private void run(Runnable runnable, Map\u0026lt;String, String\u0026gt; context) { if (context != null) { MDC.setContextMap(context); } try { runnable.run(); } finally { MDC.remove(); } } } 使用 MQ 发送消息给第三方系统 # 在 MQ 发送的消息体中自定义属性requestId,接收方消费消息后,自己解析requestId使用即可。\n"},{"id":588,"href":"/zh/docs/technology/Interview/system-design/web-real-time-message-push/","title":"Web 实时消息推送详解","section":"System Design","content":" 原文地址: https://juejin.cn/post/7122014462181113887,JavaGuide 对本文进行了完善总结。\n我有一个朋友做了一个小破站,现在要实现一个站内信 Web 消息推送的功能,对,就是下图这个小红点,一个很常用的功能。\n不过他还没想好用什么方式做,这里我帮他整理了一下几种方案,并简单做了实现。\n什么是消息推送? # 推送的场景比较多,比如有人关注我的公众号,这时我就会收到一条推送消息,以此来吸引我点击打开应用。\n消息推送通常是指网站的运营工作等人员,通过某种工具对用户当前网页或移动设备 APP 进行的主动消息推送。\n消息推送一般又分为 Web 端消息推送和移动端消息推送。\n移动端消息推送示例:\nWeb 端消息推送示例:\n在具体实现之前,咱们再来分析一下前边的需求,其实功能很简单,只要触发某个事件(主动分享了资源或者后台主动推送消息),Web 页面的通知小红点就会实时的 +1 就可以了。\n通常在服务端会有若干张消息推送表,用来记录用户触发不同事件所推送不同类型的消息,前端主动查询(拉)或者被动接收(推)用户所有未读的消息数。\n消息推送无非是推(push)和拉(pull)两种形式,下边我们逐个了解下。\n消息推送常见方案 # 短轮询 # 轮询(polling) 应该是实现消息推送方案中最简单的一种,这里我们暂且将轮询分为短轮询和长轮询。\n短轮询很好理解,指定的时间间隔,由浏览器向服务器发出 HTTP 请求,服务器实时返回未读消息数据给客户端,浏览器再做渲染显示。\n一个简单的 JS 定时器就可以搞定,每秒钟请求一次未读消息数接口,返回的数据展示即可。\nsetInterval(() =\u0026gt; { // 方法请求 messageCount().then((res) =\u0026gt; { if (res.code === 200) { this.messageCount = res.data; } }); }, 1000); 效果还是可以的,短轮询实现固然简单,缺点也是显而易见,由于推送数据并不会频繁变更,无论后端此时是否有新的消息产生,客户端都会进行请求,势必会对服务端造成很大压力,浪费带宽和服务器资源。\n长轮询 # 长轮询是对上边短轮询的一种改进版本,在尽可能减少对服务器资源浪费的同时,保证消息的相对实时性。长轮询在中间件中应用的很广泛,比如 Nacos 和 Apollo 配置中心,消息队列 Kafka、RocketMQ 中都有用到长轮询。\nNacos 配置中心交互模型是 push 还是 pull?一文中我详细介绍过 Nacos 长轮询的实现原理,感兴趣的小伙伴可以瞅瞅。\n长轮询其实原理跟轮询差不多,都是采用轮询的方式。不过,如果服务端的数据没有发生变更,会 一直 hold 住请求,直到服务端的数据发生变化,或者等待一定时间超时才会返回。返回后,客户端又会立即再次发起下一次长轮询。\n这次我使用 Apollo 配置中心实现长轮询的方式,应用了一个类DeferredResult,它是在 Servlet3.0 后经过 Spring 封装提供的一种异步请求机制,直意就是延迟结果。\nDeferredResult可以允许容器线程快速释放占用的资源,不阻塞请求线程,以此接受更多的请求提升系统的吞吐量,然后启动异步工作线程处理真正的业务逻辑,处理完成调用DeferredResult.setResult(200)提交响应结果。\n下边我们用长轮询来实现消息推送。\n因为一个 ID 可能会被多个长轮询请求监听,所以我采用了 Guava 包提供的Multimap结构存放长轮询,一个 key 可以对应多个 value。一旦监听到 key 发生变化,对应的所有长轮询都会响应。前端得到非请求超时的状态码,知晓数据变更,主动查询未读消息数接口,更新页面数据。\n@Controller @RequestMapping(\u0026#34;/polling\u0026#34;) public class PollingController { // 存放监听某个Id的长轮询集合 // 线程同步结构 public static Multimap\u0026lt;String, DeferredResult\u0026lt;String\u0026gt;\u0026gt; watchRequests = Multimaps.synchronizedMultimap(HashMultimap.create()); /** * 设置监听 */ @GetMapping(path = \u0026#34;watch/{id}\u0026#34;) @ResponseBody public DeferredResult\u0026lt;String\u0026gt; watch(@PathVariable String id) { // 延迟对象设置超时时间 DeferredResult\u0026lt;String\u0026gt; deferredResult = new DeferredResult\u0026lt;\u0026gt;(TIME_OUT); // 异步请求完成时移除 key,防止内存溢出 deferredResult.onCompletion(() -\u0026gt; { watchRequests.remove(id, deferredResult); }); // 注册长轮询请求 watchRequests.put(id, deferredResult); return deferredResult; } /** * 变更数据 */ @GetMapping(path = \u0026#34;publish/{id}\u0026#34;) @ResponseBody public String publish(@PathVariable String id) { // 数据变更 取出监听ID的所有长轮询请求,并一一响应处理 if (watchRequests.containsKey(id)) { Collection\u0026lt;DeferredResult\u0026lt;String\u0026gt;\u0026gt; deferredResults = watchRequests.get(id); for (DeferredResult\u0026lt;String\u0026gt; deferredResult : deferredResults) { deferredResult.setResult(\u0026#34;我更新了\u0026#34; + new Date()); } } return \u0026#34;success\u0026#34;; } 当请求超过设置的超时时间,会抛出AsyncRequestTimeoutException异常,这里直接用@ControllerAdvice全局捕获统一返回即可,前端获取约定好的状态码后再次发起长轮询请求,如此往复调用。\n@ControllerAdvice public class AsyncRequestTimeoutHandler { @ResponseStatus(HttpStatus.NOT_MODIFIED) @ResponseBody @ExceptionHandler(AsyncRequestTimeoutException.class) public String asyncRequestTimeoutHandler(AsyncRequestTimeoutException e) { System.out.println(\u0026#34;异步请求超时\u0026#34;); return \u0026#34;304\u0026#34;; } } 我们来测试一下,首先页面发起长轮询请求/polling/watch/10086监听消息更变,请求被挂起,不变更数据直至超时,再次发起了长轮询请求;紧接着手动变更数据/polling/publish/10086,长轮询得到响应,前端处理业务逻辑完成后再次发起请求,如此循环往复。\n长轮询相比于短轮询在性能上提升了很多,但依然会产生较多的请求,这是它的一点不完美的地方。\niframe 流 # iframe 流就是在页面中插入一个隐藏的\u0026lt;iframe\u0026gt;标签,通过在src中请求消息数量 API 接口,由此在服务端和客户端之间创建一条长连接,服务端持续向iframe传输数据。\n传输的数据通常是 HTML、或是内嵌的 JavaScript 脚本,来达到实时更新页面的效果。\n这种方式实现简单,前端只要一个\u0026lt;iframe\u0026gt;标签搞定了\n\u0026lt;iframe src=\u0026#34;/iframe/message\u0026#34; style=\u0026#34;display:none\u0026#34;\u0026gt;\u0026lt;/iframe\u0026gt; 服务端直接组装 HTML、JS 脚本数据向 response 写入就行了\n@Controller @RequestMapping(\u0026#34;/iframe\u0026#34;) public class IframeController { @GetMapping(path = \u0026#34;message\u0026#34;) public void message(HttpServletResponse response) throws IOException, InterruptedException { while (true) { response.setHeader(\u0026#34;Pragma\u0026#34;, \u0026#34;no-cache\u0026#34;); response.setDateHeader(\u0026#34;Expires\u0026#34;, 0); response.setHeader(\u0026#34;Cache-Control\u0026#34;, \u0026#34;no-cache,no-store\u0026#34;); response.setStatus(HttpServletResponse.SC_OK); response.getWriter().print(\u0026#34; \u0026lt;script type=\\\u0026#34;text/javascript\\\u0026#34;\u0026gt;\\n\u0026#34; + \u0026#34;parent.document.getElementById(\u0026#39;clock\u0026#39;).innerHTML = \\\u0026#34;\u0026#34; + count.get() + \u0026#34;\\\u0026#34;;\u0026#34; + \u0026#34;parent.document.getElementById(\u0026#39;count\u0026#39;).innerHTML = \\\u0026#34;\u0026#34; + count.get() + \u0026#34;\\\u0026#34;;\u0026#34; + \u0026#34;\u0026lt;/script\u0026gt;\u0026#34;); } } } iframe 流的服务器开销很大,而且 IE、Chrome 等浏览器一直会处于 loading 状态,图标会不停旋转,简直是强迫症杀手。\niframe 流非常不友好,强烈不推荐。\nSSE (推荐) # 很多人可能不知道,服务端向客户端推送消息,其实除了可以用WebSocket这种耳熟能详的机制外,还有一种服务器发送事件(Server-Sent Events),简称 SSE。这是一种服务器端到客户端(浏览器)的单向消息推送。\n大名鼎鼎的 ChatGPT 就是采用的 SSE。对于需要长时间等待响应的对话场景,ChatGPT 采用了一种巧妙的策略:它会将已经计算出的数据“推送”给用户,并利用 SSE 技术在计算过程中持续返回数据。这样做的好处是可以避免用户因等待时间过长而选择关闭页面。\nSSE 基于 HTTP 协议的,我们知道一般意义上的 HTTP 协议是无法做到服务端主动向客户端推送消息的,但 SSE 是个例外,它变换了一种思路。\nSSE 在服务器和客户端之间打开一个单向通道,服务端响应的不再是一次性的数据包而是text/event-stream类型的数据流信息,在有数据变更时从服务器流式传输到客户端。\n整体的实现思路有点类似于在线视频播放,视频流会连续不断的推送到浏览器,你也可以理解成,客户端在完成一次用时很长(网络不畅)的下载。\nSSE 与 WebSocket 作用相似,都可以建立服务端与浏览器之间的通信,实现服务端向客户端推送消息,但还是有些许不同:\nSSE 是基于 HTTP 协议的,它们不需要特殊的协议或服务器实现即可工作;WebSocket 需单独服务器来处理协议。 SSE 单向通信,只能由服务端向客户端单向通信;WebSocket 全双工通信,即通信的双方可以同时发送和接受信息。 SSE 实现简单开发成本低,无需引入其他组件;WebSocket 传输数据需做二次解析,开发门槛高一些。 SSE 默认支持断线重连;WebSocket 则需要自己实现。 SSE 只能传送文本消息,二进制数据需要经过编码后传送;WebSocket 默认支持传送二进制数据。 SSE 与 WebSocket 该如何选择?\n技术并没有好坏之分,只有哪个更合适\nSSE 好像一直不被大家所熟知,一部分原因是出现了 WebSocket,这个提供了更丰富的协议来执行双向、全双工通信。对于游戏、即时通信以及需要双向近乎实时更新的场景,拥有双向通道更具吸引力。\n但是,在某些情况下,不需要从客户端发送数据。而你只需要一些服务器操作的更新。比如:站内信、未读消息数、状态更新、股票行情、监控数量等场景,SEE 不管是从实现的难易和成本上都更加有优势。此外,SSE 具有 WebSocket 在设计上缺乏的多种功能,例如:自动重新连接、事件 ID 和发送任意事件的能力。\n前端只需进行一次 HTTP 请求,带上唯一 ID,打开事件流,监听服务端推送的事件就可以了\n\u0026lt;script\u0026gt; let source = null; let userId = 7777 if (window.EventSource) { // 建立连接 source = new EventSource(\u0026#39;http://localhost:7777/sse/sub/\u0026#39;+userId); setMessageInnerHTML(\u0026#34;连接用户=\u0026#34; + userId); /** * 连接一旦建立,就会触发open事件 * 另一种写法:source.onopen = function (event) {} */ source.addEventListener(\u0026#39;open\u0026#39;, function (e) { setMessageInnerHTML(\u0026#34;建立连接。。。\u0026#34;); }, false); /** * 客户端收到服务器发来的数据 * 另一种写法:source.onmessage = function (event) {} */ source.addEventListener(\u0026#39;message\u0026#39;, function (e) { setMessageInnerHTML(e.data); }); } else { setMessageInnerHTML(\u0026#34;你的浏览器不支持SSE\u0026#34;); } \u0026lt;/script\u0026gt; 服务端的实现更简单,创建一个SseEmitter对象放入sseEmitterMap进行管理\nprivate static Map\u0026lt;String, SseEmitter\u0026gt; sseEmitterMap = new ConcurrentHashMap\u0026lt;\u0026gt;(); /** * 创建连接 */ public static SseEmitter connect(String userId) { try { // 设置超时时间,0表示不过期。默认30秒 SseEmitter sseEmitter = new SseEmitter(0L); // 注册回调 sseEmitter.onCompletion(completionCallBack(userId)); sseEmitter.onError(errorCallBack(userId)); sseEmitter.onTimeout(timeoutCallBack(userId)); sseEmitterMap.put(userId, sseEmitter); count.getAndIncrement(); return sseEmitter; } catch (Exception e) { log.info(\u0026#34;创建新的sse连接异常,当前用户:{}\u0026#34;, userId); } return null; } /** * 给指定用户发送消息 */ public static void sendMessage(String userId, String message) { if (sseEmitterMap.containsKey(userId)) { try { sseEmitterMap.get(userId).send(message); } catch (IOException e) { log.error(\u0026#34;用户[{}]推送异常:{}\u0026#34;, userId, e.getMessage()); removeUser(userId); } } } 注意: SSE 不支持 IE 浏览器,对其他主流浏览器兼容性做的还不错。\nWebsocket # Websocket 应该是大家都比较熟悉的一种实现消息推送的方式,上边我们在讲 SSE 的时候也和 Websocket 进行过比较。\n这是一种在 TCP 连接上进行全双工通信的协议,建立客户端和服务器之间的通信渠道。浏览器和服务器仅需一次握手,两者之间就直接可以创建持久性的连接,并进行双向数据传输。\nWebSocket 的工作过程可以分为以下几个步骤:\n客户端向服务器发送一个 HTTP 请求,请求头中包含 Upgrade: websocket 和 Sec-WebSocket-Key 等字段,表示要求升级协议为 WebSocket; 服务器收到这个请求后,会进行升级协议的操作,如果支持 WebSocket,它将回复一个 HTTP 101 状态码,响应头中包含 ,Connection: Upgrade和 Sec-WebSocket-Accept: xxx 等字段、表示成功升级到 WebSocket 协议。 客户端和服务器之间建立了一个 WebSocket 连接,可以进行双向的数据传输。数据以帧(frames)的形式进行传送,而不是传统的 HTTP 请求和响应。WebSocket 的每条消息可能会被切分成多个数据帧(最小单位)。发送端会将消息切割成多个帧发送给接收端,接收端接收消息帧,并将关联的帧重新组装成完整的消息。 客户端或服务器可以主动发送一个关闭帧,表示要断开连接。另一方收到后,也会回复一个关闭帧,然后双方关闭 TCP 连接。 另外,建立 WebSocket 连接之后,通过心跳机制来保持 WebSocket 连接的稳定性和活跃性。\nSpringBoot 整合 WebSocket,先引入 WebSocket 相关的工具包,和 SSE 相比有额外的开发成本。\n\u0026lt;!-- 引入websocket --\u0026gt; \u0026lt;dependency\u0026gt; \u0026lt;groupId\u0026gt;org.springframework.boot\u0026lt;/groupId\u0026gt; \u0026lt;artifactId\u0026gt;spring-boot-starter-websocket\u0026lt;/artifactId\u0026gt; \u0026lt;/dependency\u0026gt; 服务端使用@ServerEndpoint注解标注当前类为一个 WebSocket 服务器,客户端可以通过ws://localhost:7777/webSocket/10086来连接到 WebSocket 服务器端。\n@Component @Slf4j @ServerEndpoint(\u0026#34;/websocket/{userId}\u0026#34;) public class WebSocketServer { //与某个客户端的连接会话,需要通过它来给客户端发送数据 private Session session; private static final CopyOnWriteArraySet\u0026lt;WebSocketServer\u0026gt; webSockets = new CopyOnWriteArraySet\u0026lt;\u0026gt;(); // 用来存在线连接数 private static final Map\u0026lt;String, Session\u0026gt; sessionPool = new HashMap\u0026lt;String, Session\u0026gt;(); /** * 链接成功调用的方法 */ @OnOpen public void onOpen(Session session, @PathParam(value = \u0026#34;userId\u0026#34;) String userId) { try { this.session = session; webSockets.add(this); sessionPool.put(userId, session); log.info(\u0026#34;websocket消息: 有新的连接,总数为:\u0026#34; + webSockets.size()); } catch (Exception e) { } } /** * 收到客户端消息后调用的方法 */ @OnMessage public void onMessage(String message) { log.info(\u0026#34;websocket消息: 收到客户端消息:\u0026#34; + message); } /** * 此为单点消息 */ public void sendOneMessage(String userId, String message) { Session session = sessionPool.get(userId); if (session != null \u0026amp;\u0026amp; session.isOpen()) { try { log.info(\u0026#34;websocket消: 单点消息:\u0026#34; + message); session.getAsyncRemote().sendText(message); } catch (Exception e) { e.printStackTrace(); } } } } 服务端还需要注入ServerEndpointerExporter,这个 Bean 就会自动注册使用了@ServerEndpoint注解的 WebSocket 服务器。\n@Configuration public class WebSocketConfiguration { /** * 用于注册使用了 @ServerEndpoint 注解的 WebSocket 服务器 */ @Bean public ServerEndpointExporter serverEndpointExporter() { return new ServerEndpointExporter(); } } 前端初始化打开 WebSocket 连接,并监听连接状态,接收服务端数据或向服务端发送数据。\n\u0026lt;script\u0026gt; var ws = new WebSocket(\u0026#39;ws://localhost:7777/webSocket/10086\u0026#39;); // 获取连接状态 console.log(\u0026#39;ws连接状态:\u0026#39; + ws.readyState); //监听是否连接成功 ws.onopen = function () { console.log(\u0026#39;ws连接状态:\u0026#39; + ws.readyState); //连接成功则发送一个数据 ws.send(\u0026#39;test1\u0026#39;); } // 接听服务器发回的信息并处理展示 ws.onmessage = function (data) { console.log(\u0026#39;接收到来自服务器的消息:\u0026#39;); console.log(data); //完成通信后关闭WebSocket连接 ws.close(); } // 监听连接关闭事件 ws.onclose = function () { // 监听整个过程中websocket的状态 console.log(\u0026#39;ws连接状态:\u0026#39; + ws.readyState); } // 监听并处理error事件 ws.onerror = function (error) { console.log(error); } function sendMessage() { var content = $(\u0026#34;#message\u0026#34;).val(); $.ajax({ url: \u0026#39;/socket/publish?userId=10086\u0026amp;message=\u0026#39; + content, type: \u0026#39;GET\u0026#39;, data: { \u0026#34;id\u0026#34;: \u0026#34;7777\u0026#34;, \u0026#34;content\u0026#34;: content }, success: function (data) { console.log(data) } }) } \u0026lt;/script\u0026gt; 页面初始化建立 WebSocket 连接,之后就可以进行双向通信了,效果还不错。\nMQTT # 什么是 MQTT 协议?\nMQTT (Message Queue Telemetry Transport)是一种基于发布/订阅(publish/subscribe)模式的轻量级通讯协议,通过订阅相应的主题来获取消息,是物联网(Internet of Thing)中的一个标准传输协议。\n该协议将消息的发布者(publisher)与订阅者(subscriber)进行分离,因此可以在不可靠的网络环境中,为远程连接的设备提供可靠的消息服务,使用方式与传统的 MQ 有点类似。\nTCP 协议位于传输层,MQTT 协议位于应用层,MQTT 协议构建于 TCP/IP 协议上,也就是说只要支持 TCP/IP 协议栈的地方,都可以使用 MQTT 协议。\n为什么要用 MQTT 协议?\nMQTT 协议为什么在物联网(IOT)中如此受偏爱?而不是其它协议,比如我们更为熟悉的 HTTP 协议呢?\n首先 HTTP 协议它是一种同步协议,客户端请求后需要等待服务器的响应。而在物联网(IOT)环境中,设备会很受制于环境的影响,比如带宽低、网络延迟高、网络通信不稳定等,显然异步消息协议更为适合 IOT 应用程序。 HTTP 是单向的,如果要获取消息客户端必须发起连接,而在物联网(IOT)应用程序中,设备或传感器往往都是客户端,这意味着它们无法被动地接收来自网络的命令。 通常需要将一条命令或者消息,发送到网络上的所有设备上。HTTP 要实现这样的功能不但很困难,而且成本极高。 具体的 MQTT 协议介绍和实践,这里我就不再赘述了,大家可以参考我之前的两篇文章,里边写的也都很详细了。\nMQTT 协议的介绍: 我也没想到 SpringBoot + RabbitMQ 做智能家居,会这么简单 MQTT 实现消息推送: 未读消息(小红点),前端 与 RabbitMQ 实时消息推送实践,贼简单~ 总结 # 以下内容为 JavaGuide 补充\n介绍 优点 缺点 短轮询 客户端定时向服务端发送请求,服务端直接返回响应数据(即使没有数据更新) 简单、易理解、易实现 实时性太差,无效请求太多,频繁建立连接太耗费资源 长轮询 与短轮询不同是,长轮询接收到客户端请求之后等到有数据更新才返回请求 减少了无效请求 挂起请求会导致资源浪费 iframe 流 服务端和客户端之间创建一条长连接,服务端持续向iframe传输数据。 简单、易理解、易实现 维护一个长连接会增加开销,效果太差(图标会不停旋转) SSE 一种服务器端到客户端(浏览器)的单向消息推送。 简单、易实现,功能丰富 不支持双向通信 WebSocket 除了最初建立连接时用 HTTP 协议,其他时候都是直接基于 TCP 协议进行通信的,可以实现客户端和服务端的全双工通信。 性能高、开销小 对开发人员要求更高,实现相对复杂一些 MQTT 基于发布/订阅(publish/subscribe)模式的轻量级通讯协议,通过订阅相应的主题来获取消息。 成熟稳定,轻量级 对开发人员要求更高,实现相对复杂一些 "},{"id":589,"href":"/zh/docs/technology/Interview/distributed-system/distributed-process-coordination/zookeeper/zookeeper-in-action/","title":"ZooKeeper 实战","section":"Distributed Process Coordination","content":"这篇文章简单给演示一下 ZooKeeper 常见命令的使用以及 ZooKeeper Java 客户端 Curator 的基本使用。介绍到的内容都是最基本的操作,能满足日常工作的基本需要。\n如果文章有任何需要改善和完善的地方,欢迎在评论区指出,共同进步!\nZooKeeper 安装 # 使用 Docker 安装 zookeeper # a.使用 Docker 下载 ZooKeeper\ndocker pull zookeeper:3.5.8 b.运行 ZooKeeper\ndocker run -d --name zookeeper -p 2181:2181 zookeeper:3.5.8 连接 ZooKeeper 服务 # a.进入 ZooKeeper 容器中\n先使用 docker ps 查看 ZooKeeper 的 ContainerID,然后使用 docker exec -it ContainerID /bin/bash 命令进入容器中。\nb.先进入 bin 目录,然后通过 ./zkCli.sh -server 127.0.0.1:2181命令连接 ZooKeeper 服务\nroot@eaf70fc620cb:/apache-zookeeper-3.5.8-bin# cd bin 如果你看到控制台成功打印出如下信息的话,说明你已经成功连接 ZooKeeper 服务。\nZooKeeper 常用命令演示 # 查看常用命令(help 命令) # 通过 help 命令查看 ZooKeeper 常用命令\n创建节点(create 命令) # 通过 create 命令在根目录创建了 node1 节点,与它关联的字符串是\u0026quot;node1\u0026quot;\n[zk: 127.0.0.1:2181(CONNECTED) 34] create /node1 “node1” 通过 create 命令在根目录创建了 node1 节点,与它关联的内容是数字 123\n[zk: 127.0.0.1:2181(CONNECTED) 1] create /node1/node1.1 123 Created /node1/node1.1 更新节点数据内容(set 命令) # [zk: 127.0.0.1:2181(CONNECTED) 11] set /node1 \u0026#34;set node1\u0026#34; 获取节点的数据(get 命令) # get 命令可以获取指定节点的数据内容和节点的状态,可以看出我们通过 set 命令已经将节点数据内容改为 \u0026ldquo;set node1\u0026rdquo;。\n[zk: zookeeper(CONNECTED) 12] get -s /node1 set node1 cZxid = 0x47 ctime = Sun Jan 20 10:22:59 CST 2019 mZxid = 0x4b mtime = Sun Jan 20 10:41:10 CST 2019 pZxid = 0x4a cversion = 1 dataVersion = 1 aclVersion = 0 ephemeralOwner = 0x0 dataLength = 9 numChildren = 1 查看某个目录下的子节点(ls 命令) # 通过 ls 命令查看根目录下的节点\n[zk: 127.0.0.1:2181(CONNECTED) 37] ls / [dubbo, ZooKeeper, node1] 通过 ls 命令查看 node1 目录下的节点\n[zk: 127.0.0.1:2181(CONNECTED) 5] ls /node1 [node1.1] ZooKeeper 中的 ls 命令和 linux 命令中的 ls 类似, 这个命令将列出绝对路径 path 下的所有子节点信息(列出 1 级,并不递归)\n查看节点状态(stat 命令) # 通过 stat 命令查看节点状态\n[zk: 127.0.0.1:2181(CONNECTED) 10] stat /node1 cZxid = 0x47 ctime = Sun Jan 20 10:22:59 CST 2019 mZxid = 0x47 mtime = Sun Jan 20 10:22:59 CST 2019 pZxid = 0x4a cversion = 1 dataVersion = 0 aclVersion = 0 ephemeralOwner = 0x0 dataLength = 11 numChildren = 1 上面显示的一些信息比如 cversion、aclVersion、numChildren 等等,我在上面 “ ZooKeeper 相关概念总结(入门)” 这篇文章中已经介绍到。\n查看节点信息和状态(ls2 命令) # ls2 命令更像是 ls 命令和 stat 命令的结合。 ls2 命令返回的信息包括 2 部分:\n子节点列表 当前节点的 stat 信息。 [zk: 127.0.0.1:2181(CONNECTED) 7] ls2 /node1 [node1.1] cZxid = 0x47 ctime = Sun Jan 20 10:22:59 CST 2019 mZxid = 0x47 mtime = Sun Jan 20 10:22:59 CST 2019 pZxid = 0x4a cversion = 1 dataVersion = 0 aclVersion = 0 ephemeralOwner = 0x0 dataLength = 11 numChildren = 1 删除节点(delete 命令) # 这个命令很简单,但是需要注意的一点是如果你要删除某一个节点,那么这个节点必须无子节点才行。\n[zk: 127.0.0.1:2181(CONNECTED) 3] delete /node1/node1.1 在后面我会介绍到 Java 客户端 API 的使用以及开源 ZooKeeper 客户端 ZkClient 和 Curator 的使用。\nZooKeeper Java 客户端 Curator 简单使用 # Curator 是 Netflix 公司开源的一套 ZooKeeper Java 客户端框架,相比于 Zookeeper 自带的客户端 zookeeper 来说,Curator 的封装更加完善,各种 API 都可以比较方便地使用。\n下面我们就来简单地演示一下 Curator 的使用吧!\nCurator4.0+版本对 ZooKeeper 3.5.x 支持比较好。开始之前,请先将下面的依赖添加进你的项目。\n\u0026lt;dependency\u0026gt; \u0026lt;groupId\u0026gt;org.apache.curator\u0026lt;/groupId\u0026gt; \u0026lt;artifactId\u0026gt;curator-framework\u0026lt;/artifactId\u0026gt; \u0026lt;version\u0026gt;4.2.0\u0026lt;/version\u0026gt; \u0026lt;/dependency\u0026gt; \u0026lt;dependency\u0026gt; \u0026lt;groupId\u0026gt;org.apache.curator\u0026lt;/groupId\u0026gt; \u0026lt;artifactId\u0026gt;curator-recipes\u0026lt;/artifactId\u0026gt; \u0026lt;version\u0026gt;4.2.0\u0026lt;/version\u0026gt; \u0026lt;/dependency\u0026gt; 连接 ZooKeeper 客户端 # 通过 CuratorFrameworkFactory 创建 CuratorFramework 对象,然后再调用 CuratorFramework 对象的 start() 方法即可!\nprivate static final int BASE_SLEEP_TIME = 1000; private static final int MAX_RETRIES = 3; // Retry strategy. Retry 3 times, and will increase the sleep time between retries. RetryPolicy retryPolicy = new ExponentialBackoffRetry(BASE_SLEEP_TIME, MAX_RETRIES); CuratorFramework zkClient = CuratorFrameworkFactory.builder() // the server to connect to (can be a server list) .connectString(\u0026#34;127.0.0.1:2181\u0026#34;) .retryPolicy(retryPolicy) .build(); zkClient.start(); 对于一些基本参数的说明:\nbaseSleepTimeMs:重试之间等待的初始时间 maxRetries:最大重试次数 connectString:要连接的服务器列表 retryPolicy:重试策略 数据节点的增删改查 # 创建节点 # 我们在 ZooKeeper 常见概念解读 中介绍到,我们通常是将 znode 分为 4 大类:\n持久(PERSISTENT)节点:一旦创建就一直存在即使 ZooKeeper 集群宕机,直到将其删除。 临时(EPHEMERAL)节点:临时节点的生命周期是与 客户端会话(session) 绑定的,会话消失则节点消失 。并且,临时节点 只能做叶子节点 ,不能创建子节点。 持久顺序(PERSISTENT_SEQUENTIAL)节点:除了具有持久(PERSISTENT)节点的特性之外, 子节点的名称还具有顺序性。比如 /node1/app0000000001、/node1/app0000000002 。 临时顺序(EPHEMERAL_SEQUENTIAL)节点:除了具备临时(EPHEMERAL)节点的特性之外,子节点的名称还具有顺序性。 你在使用的 ZooKeeper 的时候,会发现 CreateMode 类中实际有 7 种 znode 类型 ,但是用的最多的还是上面介绍的 4 种。\na.创建持久化节点\n你可以通过下面两种方式创建持久化的节点。\n//注意:下面的代码会报错,下文说了具体原因 zkClient.create().forPath(\u0026#34;/node1/00001\u0026#34;); zkClient.create().withMode(CreateMode.PERSISTENT).forPath(\u0026#34;/node1/00002\u0026#34;); 但是,你运行上面的代码会报错,这是因为的父节点node1还未创建。\n你可以先创建父节点 node1 ,然后再执行上面的代码就不会报错了。\nzkClient.create().forPath(\u0026#34;/node1\u0026#34;); 更推荐的方式是通过下面这行代码, creatingParentsIfNeeded() 可以保证父节点不存在的时候自动创建父节点,这是非常有用的。\nzkClient.create().creatingParentsIfNeeded().withMode(CreateMode.PERSISTENT).forPath(\u0026#34;/node1/00001\u0026#34;); b.创建临时节点\nzkClient.create().creatingParentsIfNeeded().withMode(CreateMode.EPHEMERAL).forPath(\u0026#34;/node1/00001\u0026#34;); c.创建节点并指定数据内容\nzkClient.create().creatingParentsIfNeeded().withMode(CreateMode.EPHEMERAL).forPath(\u0026#34;/node1/00001\u0026#34;,\u0026#34;java\u0026#34;.getBytes()); zkClient.getData().forPath(\u0026#34;/node1/00001\u0026#34;);//获取节点的数据内容,获取到的是 byte数组 d.检测节点是否创建成功\nzkClient.checkExists().forPath(\u0026#34;/node1/00001\u0026#34;);//不为null的话,说明节点创建成功 删除节点 # a.删除一个子节点\nzkClient.delete().forPath(\u0026#34;/node1/00001\u0026#34;); b.删除一个节点以及其下的所有子节点\nzkClient.delete().deletingChildrenIfNeeded().forPath(\u0026#34;/node1\u0026#34;); 获取/更新节点数据内容 # zkClient.create().creatingParentsIfNeeded().withMode(CreateMode.EPHEMERAL).forPath(\u0026#34;/node1/00001\u0026#34;,\u0026#34;java\u0026#34;.getBytes()); zkClient.getData().forPath(\u0026#34;/node1/00001\u0026#34;);//获取节点的数据内容 zkClient.setData().forPath(\u0026#34;/node1/00001\u0026#34;,\u0026#34;c++\u0026#34;.getBytes());//更新节点数据内容 获取某个节点的所有子节点路径 # List\u0026lt;String\u0026gt; childrenPaths = zkClient.getChildren().forPath(\u0026#34;/node1\u0026#34;); "},{"id":590,"href":"/zh/docs/technology/Interview/distributed-system/distributed-process-coordination/zookeeper/zookeeper-plus/","title":"ZooKeeper相关概念总结(进阶)","section":"Distributed Process Coordination","content":" FrancisQ 投稿。\n什么是 ZooKeeper # ZooKeeper 由 Yahoo 开发,后来捐赠给了 Apache ,现已成为 Apache 顶级项目。ZooKeeper 是一个开源的分布式应用程序协调服务器,其为分布式系统提供一致性服务。其一致性是通过基于 Paxos 算法的 ZAB 协议完成的。其主要功能包括:配置维护、分布式同步、集群管理等。\n简单来说, ZooKeeper 是一个 分布式协调服务框架 。分布式?协调服务?这啥玩意?🤔🤔\n其实解释到分布式这个概念的时候,我发现有些同学并不是能把 分布式和集群 这两个概念很好的理解透。前段时间有同学和我探讨起分布式的东西,他说分布式不就是加机器吗?一台机器不够用再加一台抗压呗。当然加机器这种说法也无可厚非,你一个分布式系统必定涉及到多个机器,但是你别忘了,计算机学科中还有一个相似的概念—— Cluster ,集群不也是加机器吗?但是 集群 和 分布式 其实就是两个完全不同的概念。\n比如,我现在有一个秒杀服务,并发量太大单机系统承受不住,那我加几台服务器也 一样 提供秒杀服务,这个时候就是 Cluster 集群 。\n但是,我现在换一种方式,我将一个秒杀服务 拆分成多个子服务 ,比如创建订单服务,增加积分服务,扣优惠券服务等等,然后我将这些子服务都部署在不同的服务器上 ,这个时候就是 Distributed 分布式 。\n而我为什么反驳同学所说的分布式就是加机器呢?因为我认为加机器更加适用于构建集群,因为它真是只有加机器。而对于分布式来说,你首先需要将业务进行拆分,然后再加机器(不仅仅是加机器那么简单),同时你还要去解决分布式带来的一系列问题。\n比如各个分布式组件如何协调起来,如何减少各个系统之间的耦合度,分布式事务的处理,如何去配置整个分布式系统等等。ZooKeeper 主要就是解决这些问题的。\n一致性问题 # 设计一个分布式系统必定会遇到一个问题—— 因为分区容忍性(partition tolerance)的存在,就必定要求我们需要在系统可用性(availability)和数据一致性(consistency)中做出权衡 。这就是著名的 CAP 定理。\n理解起来其实很简单,比如说把一个班级作为整个系统,而学生是系统中的一个个独立的子系统。这个时候班里的小红小明偷偷谈恋爱被班里的大嘴巴小花发现了,小花欣喜若狂告诉了周围的人,然后小红小明谈恋爱的消息在班级里传播起来了。当在消息的传播(散布)过程中,你抓到一个同学问他们的情况,如果回答你不知道,那么说明整个班级系统出现了数据不一致的问题(因为小花已经知道这个消息了)。而如果他直接不回答你,因为整个班级有消息在进行传播(为了保证一致性,需要所有人都知道才可提供服务),这个时候就出现了系统的可用性问题。\n而上述前者就是 Eureka 的处理方式,它保证了 AP(可用性),后者就是我们今天所要讲的 ZooKeeper 的处理方式,它保证了 CP(数据一致性)。\n一致性协议和算法 # 而为了解决数据一致性问题,在科学家和程序员的不断探索中,就出现了很多的一致性协议和算法。比如 2PC(两阶段提交),3PC(三阶段提交),Paxos 算法等等。\n这时候请你思考一个问题,同学之间如果采用传纸条的方式去传播消息,那么就会出现一个问题——我咋知道我的小纸条有没有传到我想要传递的那个人手中呢?万一被哪个小家伙给劫持篡改了呢,对吧?\n这个时候就引申出一个概念—— 拜占庭将军问题 。它意指 在不可靠信道上试图通过消息传递的方式达到一致性是不可能的, 所以所有的一致性算法的 必要前提 就是安全可靠的消息通道。\n而为什么要去解决数据一致性的问题?你想想,如果一个秒杀系统将服务拆分成了下订单和加积分服务,这两个服务部署在不同的机器上了,万一在消息的传播过程中积分系统宕机了,总不能你这边下了订单却没加积分吧?你总得保证两边的数据需要一致吧?\n2PC(两阶段提交) # 两阶段提交是一种保证分布式系统数据一致性的协议,现在很多数据库都是采用的两阶段提交协议来完成 分布式事务 的处理。\n在介绍 2PC 之前,我们先来想想分布式事务到底有什么问题呢?\n还拿秒杀系统的下订单和加积分两个系统来举例吧(我想你们可能都吐了 🤮🤮🤮),我们此时下完订单会发个消息给积分系统告诉它下面该增加积分了。如果我们仅仅是发送一个消息也不收回复,那么我们的订单系统怎么能知道积分系统的收到消息的情况呢?如果我们增加一个收回复的过程,那么当积分系统收到消息后返回给订单系统一个 Response ,但在中间出现了网络波动,那个回复消息没有发送成功,订单系统是不是以为积分系统消息接收失败了?它是不是会回滚事务?但此时积分系统是成功收到消息的,它就会去处理消息然后给用户增加积分,这个时候就会出现积分加了但是订单没下成功。\n所以我们所需要解决的是在分布式系统中,整个调用链中,我们所有服务的数据处理要么都成功要么都失败,即所有服务的 原子性问题 。\n在两阶段提交中,主要涉及到两个角色,分别是协调者和参与者。\n第一阶段:当要执行一个分布式事务的时候,事务发起者首先向协调者发起事务请求,然后协调者会给所有参与者发送 prepare 请求(其中包括事务内容)告诉参与者你们需要执行事务了,如果能执行我发的事务内容那么就先执行但不提交,执行后请给我回复。然后参与者收到 prepare 消息后,他们会开始执行事务(但不提交),并将 Undo 和 Redo 信息记入事务日志中,之后参与者就向协调者反馈是否准备好了。\n第二阶段:第二阶段主要是协调者根据参与者反馈的情况来决定接下来是否可以进行事务的提交操作,即提交事务或者回滚事务。\n比如这个时候 所有的参与者 都返回了准备好了的消息,这个时候就进行事务的提交,协调者此时会给所有的参与者发送 Commit 请求 ,当参与者收到 Commit 请求的时候会执行前面执行的事务的 提交操作 ,提交完毕之后将给协调者发送提交成功的响应。\n而如果在第一阶段并不是所有参与者都返回了准备好了的消息,那么此时协调者将会给所有参与者发送 回滚事务的 rollback 请求,参与者收到之后将会 回滚它在第一阶段所做的事务处理 ,然后再将处理情况返回给协调者,最终协调者收到响应后便给事务发起者返回处理失败的结果。\n个人觉得 2PC 实现得还是比较鸡肋的,因为事实上它只解决了各个事务的原子性问题,随之也带来了很多的问题。\n单点故障问题,如果协调者挂了那么整个系统都处于不可用的状态了。 阻塞问题,即当协调者发送 prepare 请求,参与者收到之后如果能处理那么它将会进行事务的处理但并不提交,这个时候会一直占用着资源不释放,如果此时协调者挂了,那么这些资源都不会再释放了,这会极大影响性能。 数据不一致问题,比如当第二阶段,协调者只发送了一部分的 commit 请求就挂了,那么也就意味着,收到消息的参与者会进行事务的提交,而后面没收到的则不会进行事务提交,那么这时候就会产生数据不一致性问题。 3PC(三阶段提交) # 因为 2PC 存在的一系列问题,比如单点,容错机制缺陷等等,从而产生了 3PC(三阶段提交) 。那么这三阶段又分别是什么呢?\n千万不要吧 PC 理解成个人电脑了,其实他们是 phase-commit 的缩写,即阶段提交。\nCanCommit 阶段:协调者向所有参与者发送 CanCommit 请求,参与者收到请求后会根据自身情况查看是否能执行事务,如果可以则返回 YES 响应并进入预备状态,否则返回 NO 。 PreCommit 阶段:协调者根据参与者返回的响应来决定是否可以进行下面的 PreCommit 操作。如果上面参与者返回的都是 YES,那么协调者将向所有参与者发送 PreCommit 预提交请求,参与者收到预提交请求后,会进行事务的执行操作,并将 Undo 和 Redo 信息写入事务日志中 ,最后如果参与者顺利执行了事务则给协调者返回成功的响应。如果在第一阶段协调者收到了 任何一个 NO 的信息,或者 在一定时间内 并没有收到全部的参与者的响应,那么就会中断事务,它会向所有参与者发送中断请求(abort),参与者收到中断请求之后会立即中断事务,或者在一定时间内没有收到协调者的请求,它也会中断事务。 DoCommit 阶段:这个阶段其实和 2PC 的第二阶段差不多,如果协调者收到了所有参与者在 PreCommit 阶段的 YES 响应,那么协调者将会给所有参与者发送 DoCommit 请求,参与者收到 DoCommit 请求后则会进行事务的提交工作,完成后则会给协调者返回响应,协调者收到所有参与者返回的事务提交成功的响应之后则完成事务。若协调者在 PreCommit 阶段 收到了任何一个 NO 或者在一定时间内没有收到所有参与者的响应 ,那么就会进行中断请求的发送,参与者收到中断请求后则会 通过上面记录的回滚日志 来进行事务的回滚操作,并向协调者反馈回滚状况,协调者收到参与者返回的消息后,中断事务。 这里是 3PC 在成功的环境下的流程图,你可以看到 3PC 在很多地方进行了超时中断的处理,比如协调者在指定时间内未收到全部的确认消息则进行事务中断的处理,这样能 减少同步阻塞的时间 。还有需要注意的是,3PC 在 DoCommit 阶段参与者如未收到协调者发送的提交事务的请求,它会在一定时间内进行事务的提交。为什么这么做呢?是因为这个时候我们肯定保证了在第一阶段所有的协调者全部返回了可以执行事务的响应,这个时候我们有理由相信其他系统都能进行事务的执行和提交,所以不管协调者有没有发消息给参与者,进入第三阶段参与者都会进行事务的提交操作。\n总之,3PC 通过一系列的超时机制很好的缓解了阻塞问题,但是最重要的一致性并没有得到根本的解决,比如在 DoCommit 阶段,当一个参与者收到了请求之后其他参与者和协调者挂了或者出现了网络分区,这个时候收到消息的参与者都会进行事务提交,这就会出现数据不一致性问题。\n所以,要解决一致性问题还需要靠 Paxos 算法 ⭐️ ⭐️ ⭐️ 。\nPaxos 算法 # Paxos 算法是基于消息传递且具有高度容错特性的一致性算法,是目前公认的解决分布式一致性问题最有效的算法之一,其解决的问题就是在分布式系统中如何就某个值(决议)达成一致 。\n在 Paxos 中主要有三个角色,分别为 Proposer提案者、Acceptor表决者、Learner学习者。Paxos 算法和 2PC 一样,也有两个阶段,分别为 Prepare 和 accept 阶段。\nprepare 阶段 # Proposer提案者:负责提出 proposal,每个提案者在提出提案时都会首先获取到一个 具有全局唯一性的、递增的提案编号 N,即在整个集群中是唯一的编号 N,然后将该编号赋予其要提出的提案,在第一阶段是只将提案编号发送给所有的表决者。 Acceptor表决者:每个表决者在 accept 某提案后,会将该提案编号 N 记录在本地,这样每个表决者中保存的已经被 accept 的提案中会存在一个编号最大的提案,其编号假设为 maxN。每个表决者仅会 accept 编号大于自己本地 maxN 的提案,在批准提案时表决者会将以前接受过的最大编号的提案作为响应反馈给 Proposer 。 下面是 prepare 阶段的流程图,你可以对照着参考一下。\naccept 阶段 # 当一个提案被 Proposer 提出后,如果 Proposer 收到了超过半数的 Acceptor 的批准(Proposer 本身同意),那么此时 Proposer 会给所有的 Acceptor 发送真正的提案(你可以理解为第一阶段为试探),这个时候 Proposer 就会发送提案的内容和提案编号。\n表决者收到提案请求后会再次比较本身已经批准过的最大提案编号和该提案编号,如果该提案编号 大于等于 已经批准过的最大提案编号,那么就 accept 该提案(此时执行提案内容但不提交),随后将情况返回给 Proposer 。如果不满足则不回应或者返回 NO 。\n当 Proposer 收到超过半数的 accept ,那么它这个时候会向所有的 acceptor 发送提案的提交请求。需要注意的是,因为上述仅仅是超过半数的 acceptor 批准执行了该提案内容,其他没有批准的并没有执行该提案内容,所以这个时候需要向未批准的 acceptor 发送提案内容和提案编号并让它无条件执行和提交,而对于前面已经批准过该提案的 acceptor 来说 仅仅需要发送该提案的编号 ,让 acceptor 执行提交就行了。\n而如果 Proposer 如果没有收到超过半数的 accept 那么它将会将 递增 该 Proposal 的编号,然后 重新进入 Prepare 阶段 。\n对于 Learner 来说如何去学习 Acceptor 批准的提案内容,这有很多方式,读者可以自己去了解一下,这里不做过多解释。\npaxos 算法的死循环问题 # 其实就有点类似于两个人吵架,小明说我是对的,小红说我才是对的,两个人据理力争的谁也不让谁 🤬🤬。\n比如说,此时提案者 P1 提出一个方案 M1,完成了 Prepare 阶段的工作,这个时候 acceptor 则批准了 M1,但是此时提案者 P2 同时也提出了一个方案 M2,它也完成了 Prepare 阶段的工作。然后 P1 的方案已经不能在第二阶段被批准了(因为 acceptor 已经批准了比 M1 更大的 M2),所以 P1 自增方案变为 M3 重新进入 Prepare 阶段,然后 acceptor ,又批准了新的 M3 方案,它又不能批准 M2 了,这个时候 M2 又自增进入 Prepare 阶段。。。\n就这样无休无止的永远提案下去,这就是 paxos 算法的死循环问题。\n那么如何解决呢?很简单,人多了容易吵架,我现在 就允许一个能提案 就行了。\n引出 ZAB # Zookeeper 架构 # 作为一个优秀高效且可靠的分布式协调框架,ZooKeeper 在解决分布式数据一致性问题时并没有直接使用 Paxos ,而是专门定制了一致性协议叫做 ZAB(ZooKeeper Atomic Broadcast) 原子广播协议,该协议能够很好地支持 崩溃恢复 。\nZAB 中的三个角色 # 和介绍 Paxos 一样,在介绍 ZAB 协议之前,我们首先来了解一下在 ZAB 中三个主要的角色,Leader 领导者、Follower跟随者、Observer观察者 。\nLeader:集群中 唯一的写请求处理者 ,能够发起投票(投票也是为了进行写请求)。 Follower:能够接收客户端的请求,如果是读请求则可以自己处理,如果是写请求则要转发给 Leader 。在选举过程中会参与投票,有选举权和被选举权 。 Observer:就是没有选举权和被选举权的 Follower 。 在 ZAB 协议中对 zkServer(即上面我们说的三个角色的总称) 还有两种模式的定义,分别是 消息广播 和 崩溃恢复 。\n消息广播模式 # 说白了就是 ZAB 协议是如何处理写请求的,上面我们不是说只有 Leader 能处理写请求嘛?那么我们的 Follower 和 Observer 是不是也需要 同步更新数据 呢?总不能数据只在 Leader 中更新了,其他角色都没有得到更新吧?\n不就是 在整个集群中保持数据的一致性 嘛?如果是你,你会怎么做呢?\n废话,第一步肯定需要 Leader 将写请求 广播 出去呀,让 Leader 问问 Followers 是否同意更新,如果超过半数以上的同意那么就进行 Follower 和 Observer 的更新(和 Paxos 一样)。当然这么说有点虚,画张图理解一下。\n嗯。。。看起来很简单,貌似懂了 🤥🤥🤥。这两个 Queue 哪冒出来的?答案是 ZAB 需要让 Follower 和 Observer 保证顺序性 。何为顺序性,比如我现在有一个写请求 A,此时 Leader 将请求 A 广播出去,因为只需要半数同意就行,所以可能这个时候有一个 Follower F1 因为网络原因没有收到,而 Leader 又广播了一个请求 B,因为网络原因,F1 竟然先收到了请求 B 然后才收到了请求 A,这个时候请求处理的顺序不同就会导致数据的不同,从而 产生数据不一致问题 。\n所以在 Leader 这端,它为每个其他的 zkServer 准备了一个 队列 ,采用先进先出的方式发送消息。由于协议是 通过 TCP 来进行网络通信的,保证了消息的发送顺序性,接受顺序性也得到了保证。\n除此之外,在 ZAB 中还定义了一个 全局单调递增的事务 ID ZXID ,它是一个 64 位 long 型,其中高 32 位表示 epoch 年代,低 32 位表示事务 id。epoch 是会根据 Leader 的变化而变化的,当一个 Leader 挂了,新的 Leader 上位的时候,年代(epoch)就变了。而低 32 位可以简单理解为递增的事务 id。\n定义这个的原因也是为了顺序性,每个 proposal 在 Leader 中生成后需要 通过其 ZXID 来进行排序 ,才能得到处理。\n崩溃恢复模式 # 说到崩溃恢复我们首先要提到 ZAB 中的 Leader 选举算法,当系统出现崩溃影响最大应该是 Leader 的崩溃,因为我们只有一个 Leader ,所以当 Leader 出现问题的时候我们势必需要重新选举 Leader 。\nLeader 选举可以分为两个不同的阶段,第一个是我们提到的 Leader 宕机需要重新选举,第二则是当 Zookeeper 启动时需要进行系统的 Leader 初始化选举。下面我先来介绍一下 ZAB 是如何进行初始化选举的。\n假设我们集群中有 3 台机器,那也就意味着我们需要两台以上同意(超过半数)。比如这个时候我们启动了 server1 ,它会首先 投票给自己 ,投票内容为服务器的 myid 和 ZXID ,因为初始化所以 ZXID 都为 0,此时 server1 发出的投票为 (1,0)。但此时 server1 的投票仅为 1,所以不能作为 Leader ,此时还在选举阶段所以整个集群处于 Looking 状态。\n接着 server2 启动了,它首先也会将投票选给自己(2,0),并将投票信息广播出去(server1也会,只是它那时没有其他的服务器了),server1 在收到 server2 的投票信息后会将投票信息与自己的作比较。首先它会比较 ZXID ,ZXID 大的优先为 Leader,如果相同则比较 myid,myid 大的优先作为 Leader。所以此时server1 发现 server2 更适合做 Leader,它就会将自己的投票信息更改为(2,0)然后再广播出去,之后server2 收到之后发现和自己的一样无需做更改,并且自己的 投票已经超过半数 ,则 确定 server2 为 Leader,server1 也会将自己服务器设置为 Following 变为 Follower。整个服务器就从 Looking 变为了正常状态。\n当 server3 启动发现集群没有处于 Looking 状态时,它会直接以 Follower 的身份加入集群。\n还是前面三个 server 的例子,如果在整个集群运行的过程中 server2 挂了,那么整个集群会如何重新选举 Leader 呢?其实和初始化选举差不多。\n首先毫无疑问的是剩下的两个 Follower 会将自己的状态 从 Following 变为 Looking 状态 ,然后每个 server 会向初始化投票一样首先给自己投票(这不过这里的 zxid 可能不是 0 了,这里为了方便随便取个数字)。\n假设 server1 给自己投票为(1,99),然后广播给其他 server,server3 首先也会给自己投票(3,95),然后也广播给其他 server。server1 和 server3 此时会收到彼此的投票信息,和一开始选举一样,他们也会比较自己的投票和收到的投票(zxid 大的优先,如果相同那么就 myid 大的优先)。这个时候 server1 收到了 server3 的投票发现没自己的合适故不变,server3 收到 server1 的投票结果后发现比自己的合适于是更改投票为(1,99)然后广播出去,最后 server1 收到了发现自己的投票已经超过半数就把自己设为 Leader,server3 也随之变为 Follower。\n请注意 ZooKeeper 为什么要设置奇数个结点?比如这里我们是三个,挂了一个我们还能正常工作,挂了两个我们就不能正常工作了(已经没有超过半数的节点数了,所以无法进行投票等操作了)。而假设我们现在有四个,挂了一个也能工作,但是挂了两个也不能正常工作了,这是和三个一样的,而三个比四个还少一个,带来的效益是一样的,所以 Zookeeper 推荐奇数个 server 。\n那么说完了 ZAB 中的 Leader 选举方式之后我们再来了解一下 崩溃恢复 是什么玩意?\n其实主要就是 当集群中有机器挂了,我们整个集群如何保证数据一致性?\n如果只是 Follower 挂了,而且挂的没超过半数的时候,因为我们一开始讲了在 Leader 中会维护队列,所以不用担心后面的数据没接收到导致数据不一致性。\n如果 Leader 挂了那就麻烦了,我们肯定需要先暂停服务变为 Looking 状态然后进行 Leader 的重新选举(上面我讲过了),但这个就要分为两种情况了,分别是 确保已经被 Leader 提交的提案最终能够被所有的 Follower 提交 和 跳过那些已经被丢弃的提案 。\n确保已经被 Leader 提交的提案最终能够被所有的 Follower 提交是什么意思呢?\n假设 Leader (server2) 发送 commit 请求(忘了请看上面的消息广播模式),他发送给了 server3,然后要发给 server1 的时候突然挂了。这个时候重新选举的时候我们如果把 server1 作为 Leader 的话,那么肯定会产生数据不一致性,因为 server3 肯定会提交刚刚 server2 发送的 commit 请求的提案,而 server1 根本没收到所以会丢弃。\n那怎么解决呢?\n聪明的同学肯定会质疑,这个时候 server1 已经不可能成为 Leader 了,因为 server1 和 server3 进行投票选举的时候会比较 ZXID ,而此时 server3 的 ZXID 肯定比 server1 的大了。(不理解可以看前面的选举算法)\n那么跳过那些已经被丢弃的提案又是什么意思呢?\n假设 Leader (server2) 此时同意了提案 N1,自身提交了这个事务并且要发送给所有 Follower 要 commit 的请求,却在这个时候挂了,此时肯定要重新进行 Leader 的选举,比如说此时选 server1 为 Leader (这无所谓)。但是过了一会,这个 挂掉的 Leader 又重新恢复了 ,此时它肯定会作为 Follower 的身份进入集群中,需要注意的是刚刚 server2 已经同意提交了提案 N1,但其他 server 并没有收到它的 commit 信息,所以其他 server 不可能再提交这个提案 N1 了,这样就会出现数据不一致性问题了,所以 该提案 N1 最终需要被抛弃掉 。\nZookeeper 的几个理论知识 # 了解了 ZAB 协议还不够,它仅仅是 Zookeeper 内部实现的一种方式,而我们如何通过 Zookeeper 去做一些典型的应用场景呢?比如说集群管理,分布式锁,Master 选举等等。\n这就涉及到如何使用 Zookeeper 了,但在使用之前我们还需要掌握几个概念。比如 Zookeeper 的 数据模型、会话机制、ACL、Watcher 机制 等等。\n数据模型 # zookeeper 数据存储结构与标准的 Unix 文件系统非常相似,都是在根节点下挂很多子节点(树型)。但是 zookeeper 中没有文件系统中目录与文件的概念,而是 使用了 znode 作为数据节点 。znode 是 zookeeper 中的最小数据单元,每个 znode 上都可以保存数据,同时还可以挂载子节点,形成一个树形化命名空间。\n每个 znode 都有自己所属的 节点类型 和 节点状态。\n其中节点类型可以分为 持久节点、持久顺序节点、临时节点 和 临时顺序节点。\n持久节点:一旦创建就一直存在,直到将其删除。 持久顺序节点:一个父节点可以为其子节点 维护一个创建的先后顺序 ,这个顺序体现在 节点名称 上,是节点名称后自动添加一个由 10 位数字组成的数字串,从 0 开始计数。 临时节点:临时节点的生命周期是与 客户端会话 绑定的,会话消失则节点消失 。临时节点 只能做叶子节点 ,不能创建子节点。 临时顺序节点:父节点可以创建一个维持了顺序的临时节点(和前面的持久顺序性节点一样)。 节点状态中包含了很多节点的属性比如 czxid、mzxid 等等,在 zookeeper 中是使用 Stat 这个类来维护的。下面我列举一些属性解释。\nczxid:Created ZXID,该数据节点被 创建 时的事务 ID。 mzxid:Modified ZXID,节点 最后一次被更新时 的事务 ID。 ctime:Created Time,该节点被创建的时间。 mtime:Modified Time,该节点最后一次被修改的时间。 version:节点的版本号。 cversion:子节点 的版本号。 aversion:节点的 ACL 版本号。 ephemeralOwner:创建该节点的会话的 sessionID ,如果该节点为持久节点,该值为 0。 dataLength:节点数据内容的长度。 numChildre:该节点的子节点个数,如果为临时节点为 0。 pzxid:该节点子节点列表最后一次被修改时的事务 ID,注意是子节点的 列表 ,不是内容。 会话 # 我想这个对于后端开发的朋友肯定不陌生,不就是 session 吗?只不过 zk 客户端和服务端是通过 TCP 长连接 维持的会话机制,其实对于会话来说你可以理解为 保持连接状态 。\n在 zookeeper 中,会话还有对应的事件,比如 CONNECTION_LOSS 连接丢失事件、SESSION_MOVED 会话转移事件、SESSION_EXPIRED 会话超时失效事件 。\nACL # ACL 为 Access Control Lists ,它是一种权限控制。在 zookeeper 中定义了 5 种权限,它们分别为:\nCREATE:创建子节点的权限。 READ:获取节点数据和子节点列表的权限。 WRITE:更新节点数据的权限。 DELETE:删除子节点的权限。 ADMIN:设置节点 ACL 的权限。 Watcher 机制 # Watcher 为事件监听器,是 zk 非常重要的一个特性,很多功能都依赖于它,它有点类似于订阅的方式,即客户端向服务端 注册 指定的 watcher ,当服务端符合了 watcher 的某些事件或要求则会 向客户端发送事件通知 ,客户端收到通知后找到自己定义的 Watcher 然后 执行相应的回调方法 。\nZookeeper 的几个典型应用场景 # 前面说了这么多的理论知识,你可能听得一头雾水,这些玩意有啥用?能干啥事?别急,听我慢慢道来。\n选主 # 还记得上面我们的所说的临时节点吗?因为 Zookeeper 的强一致性,能够很好地在保证 在高并发的情况下保证节点创建的全局唯一性 (即无法重复创建同样的节点)。\n利用这个特性,我们可以 让多个客户端创建一个指定的节点 ,创建成功的就是 master。\n但是,如果这个 master 挂了怎么办???\n你想想为什么我们要创建临时节点?还记得临时节点的生命周期吗?master 挂了是不是代表会话断了?会话断了是不是意味着这个节点没了?还记得 watcher 吗?我们是不是可以 让其他不是 master 的节点监听节点的状态 ,比如说我们监听这个临时节点的父节点,如果子节点个数变了就代表 master 挂了,这个时候我们 触发回调函数进行重新选举 ,或者我们直接监听节点的状态,我们可以通过节点是否已经失去连接来判断 master 是否挂了等等。\n总的来说,我们可以完全 利用 临时节点、节点状态 和 watcher 来实现选主的功能,临时节点主要用来选举,节点状态和watcher 可以用来判断 master 的活性和进行重新选举。\n数据发布/订阅 # 还记得 Zookeeper 的 Watcher 机制吗? Zookeeper 通过这种推拉相结合的方式实现客户端与服务端的交互:客户端向服务端注册节点,一旦相应节点的数据变更,服务端就会向“监听”该节点的客户端发送 Watcher 事件通知,客户端接收到通知后需要 主动 到服务端获取最新的数据。基于这种方式,Zookeeper 实现了 数据发布/订阅 功能。\n一个典型的应用场景为 全局配置信息的集中管理。 客户端在启动时会主动到 Zookeeper 服务端获取配置信息,同时 在指定节点注册一个 Watcher 监听。当配置信息发生变更,服务端通知所有订阅的客户端重新获取配置信息,实现配置信息的实时更新。\n上面所提到的全局配置信息通常包括机器列表信息、运行时的开关配置、数据库配置信息等。需要注意的是,这类全局配置信息通常具备以下特性:\n数据量较小 数据内容在运行时动态变化 集群中机器共享一致配置 负载均衡 # 可以通过 Zookeeper 的 临时节点 实现负载均衡。回顾一下临时节点的特性:当创建节点的客户端与服务端之间断开连接,即客户端会话(session)消失时,对应节点也会自动消失。因此,我们可以使用临时节点来维护 Server 的地址列表,从而保证请求不会被分配到已停机的服务上。\n具体地,我们需要在集群的每一个 Server 中都使用 Zookeeper 客户端连接 Zookeeper 服务端,同时用 Server 自身的地址信息在服务端指定目录下创建临时节点。当客户端请求调用集群服务时,首先通过 Zookeeper 获取该目录下的节点列表 (即所有可用的 Server),随后根据不同的负载均衡策略将请求转发到某一具体的 Server。\n分布式锁 # 分布式锁的实现方式有很多种,比如 Redis、数据库、zookeeper 等。个人认为 zookeeper 在实现分布式锁这方面是非常非常简单的。\n上面我们已经提到过了 zk 在高并发的情况下保证节点创建的全局唯一性,这玩意一看就知道能干啥了。实现互斥锁呗,又因为能在分布式的情况下,所以能实现分布式锁呗。\n如何实现呢?这玩意其实跟选主基本一样,我们也可以利用临时节点的创建来实现。\n首先肯定是如何获取锁,因为创建节点的唯一性,我们可以让多个客户端同时创建一个临时节点,创建成功的就说明获取到了锁 。然后没有获取到锁的客户端也像上面选主的非主节点创建一个 watcher 进行节点状态的监听,如果这个互斥锁被释放了(可能获取锁的客户端宕机了,或者那个客户端主动释放了锁)可以调用回调函数重新获得锁。\nzk 中不需要向 redis 那样考虑锁得不到释放的问题了,因为当客户端挂了,节点也挂了,锁也释放了。是不是很简单?\n那能不能使用 zookeeper 同时实现 共享锁和独占锁 呢?答案是可以的,不过稍微有点复杂而已。\n还记得 有序的节点 吗?\n这个时候我规定所有创建节点必须有序,当你是读请求(要获取共享锁)的话,如果 没有比自己更小的节点,或比自己小的节点都是读请求 ,则可以获取到读锁,然后就可以开始读了。若比自己小的节点中有写请求 ,则当前客户端无法获取到读锁,只能等待前面的写请求完成。\n如果你是写请求(获取独占锁),若 没有比自己更小的节点 ,则表示当前客户端可以直接获取到写锁,对数据进行修改。若发现 有比自己更小的节点,无论是读操作还是写操作,当前客户端都无法获取到写锁 ,等待所有前面的操作完成。\n这就很好地同时实现了共享锁和独占锁,当然还有优化的地方,比如当一个锁得到释放它会通知所有等待的客户端从而造成 羊群效应 。此时你可以通过让等待的节点只监听他们前面的节点。\n具体怎么做呢?其实也很简单,你可以让 读请求监听比自己小的最后一个写请求节点,写请求只监听比自己小的最后一个节点 ,感兴趣的小伙伴可以自己去研究一下。\n命名服务 # 如何给一个对象设置 ID,大家可能都会想到 UUID,但是 UUID 最大的问题就在于它太长了。。。(太长不一定是好事,嘿嘿嘿)。那么在条件允许的情况下,我们能不能使用 zookeeper 来实现呢?\n我们之前提到过 zookeeper 是通过 树形结构 来存储数据节点的,那也就是说,对于每个节点的 全路径,它必定是唯一的,我们可以使用节点的全路径作为命名方式了。而且更重要的是,路径是我们可以自己定义的,这对于我们对有些有语意的对象的 ID 设置可以更加便于理解。\n集群管理和注册中心 # 看到这里是不是觉得 zookeeper 实在是太强大了,它怎么能这么能干!\n别急,它能干的事情还很多呢。可能我们会有这样的需求,我们需要了解整个集群中有多少机器在工作,我们想对集群中的每台机器的运行时状态进行数据采集,对集群中机器进行上下线操作等等。\n而 zookeeper 天然支持的 watcher 和 临时节点能很好的实现这些需求。我们可以为每条机器创建临时节点,并监控其父节点,如果子节点列表有变动(我们可能创建删除了临时节点),那么我们可以使用在其父节点绑定的 watcher 进行状态监控和回调。\n至于注册中心也很简单,我们同样也是让 服务提供者 在 zookeeper 中创建一个临时节点并且将自己的 ip、port、调用方式 写入节点,当 服务消费者 需要进行调用的时候会 通过注册中心找到相应的服务的地址列表(IP 端口什么的) ,并缓存到本地(方便以后调用),当消费者调用服务时,不会再去请求注册中心,而是直接通过负载均衡算法从地址列表中取一个服务提供者的服务器调用服务。\n当服务提供者的某台服务器宕机或下线时,相应的地址会从服务提供者地址列表中移除。同时,注册中心会将新的服务地址列表发送给服务消费者的机器并缓存在消费者本机(当然你可以让消费者进行节点监听,我记得 Eureka 会先试错,然后再更新)。\n总结 # 看到这里的同学实在是太有耐心了 👍👍👍 不知道大家是否还记得我讲了什么 😒。\n这篇文章中我带大家入门了 zookeeper 这个强大的分布式协调框架。现在我们来简单梳理一下整篇文章的内容。\n分布式与集群的区别\n2PC、3PC 以及 paxos 算法这些一致性框架的原理和实现。\nzookeeper 专门的一致性算法 ZAB 原子广播协议的内容(Leader 选举、崩溃恢复、消息广播)。\nzookeeper 中的一些基本概念,比如 ACL,数据节点,会话,watcher机制等等。\nzookeeper 的典型应用场景,比如选主,注册中心等等。\n如果忘了可以回去看看再次理解一下,如果有疑问和建议欢迎提出 🤝🤝🤝。\n"},{"id":591,"href":"/zh/docs/technology/Interview/distributed-system/distributed-process-coordination/zookeeper/zookeeper-intro/","title":"ZooKeeper相关概念总结(入门)","section":"Distributed Process Coordination","content":"相信大家对 ZooKeeper 应该不算陌生。但是你真的了解 ZooKeeper 到底有啥用不?如果别人/面试官让你给他讲讲对于 ZooKeeper 的认识,你能回答到什么地步呢?\n拿我自己来说吧!我本人在大学曾经使用 Dubbo 来做分布式项目的时候,使用了 ZooKeeper 作为注册中心。为了保证分布式系统能够同步访问某个资源,我还使用 ZooKeeper 做过分布式锁。另外,我在学习 Kafka 的时候,知道 Kafka 很多功能的实现依赖了 ZooKeeper。\n前几天,总结项目经验的时候,我突然问自己 ZooKeeper 到底是个什么东西?想了半天,脑海中只是简单的能浮现出几句话:\nZooKeeper 可以被用作注册中心、分布式锁; ZooKeeper 是 Hadoop 生态系统的一员; 构建 ZooKeeper 集群的时候,使用的服务器最好是奇数台。 由此可见,我对于 ZooKeeper 的理解仅仅是停留在了表面。\n所以,通过本文,希望带大家稍微详细的了解一下 ZooKeeper 。如果没有学过 ZooKeeper ,那么本文将会是你进入 ZooKeeper 大门的垫脚砖。如果你已经接触过 ZooKeeper ,那么本文将带你回顾一下 ZooKeeper 的一些基础概念。\n另外,本文不光会涉及到 ZooKeeper 的一些概念,后面的文章会介绍到 ZooKeeper 常见命令的使用以及使用 Apache Curator 作为 ZooKeeper 的客户端。\n如果文章有任何需要改善和完善的地方,欢迎在评论区指出,共同进步!\nZooKeeper 介绍 # ZooKeeper 由来 # 正式介绍 ZooKeeper 之前,我们先来看看 ZooKeeper 的由来,还挺有意思的。\n下面这段内容摘自《从 Paxos 到 ZooKeeper》第四章第一节,推荐大家阅读一下:\nZooKeeper 最早起源于雅虎研究院的一个研究小组。在当时,研究人员发现,在雅虎内部很多大型系统基本都需要依赖一个类似的系统来进行分布式协调,但是这些系统往往都存在分布式单点问题。所以,雅虎的开发人员就试图开发一个通用的无单点问题的分布式协调框架,以便让开发人员将精力集中在处理业务逻辑上。\n关于“ZooKeeper”这个项目的名字,其实也有一段趣闻。在立项初期,考虑到之前内部很多项目都是使用动物的名字来命名的(例如著名的 Pig 项目),雅虎的工程师希望给这个项目也取一个动物的名字。时任研究院的首席科学家 RaghuRamakrishnan 开玩笑地说:“在这样下去,我们这儿就变成动物园了!”此话一出,大家纷纷表示就叫动物园管理员吧一一一因为各个以动物命名的分布式组件放在一起,雅虎的整个分布式系统看上去就像一个大型的动物园了,而 ZooKeeper 正好要用来进行分布式环境的协调一一于是,ZooKeeper 的名字也就由此诞生了。\nZooKeeper 概览 # ZooKeeper 是一个开源的分布式协调服务,它的设计目标是将那些复杂且容易出错的分布式一致性服务封装起来,构成一个高效可靠的原语集,并以一系列简单易用的接口提供给用户使用。\n原语: 操作系统或计算机网络用语范畴。是由若干条指令组成的,用于完成一定功能的一个过程。具有不可分割性,即原语的执行必须是连续的,在执行过程中不允许被中断。\nZooKeeper 为我们提供了高可用、高性能、稳定的分布式数据一致性解决方案,通常被用于实现诸如数据发布/订阅、负载均衡、命名服务、分布式协调/通知、集群管理、Master 选举、分布式锁和分布式队列等功能。这些功能的实现主要依赖于 ZooKeeper 提供的 数据存储+事件监听 功能(后文会详细介绍到) 。\nZooKeeper 将数据保存在内存中,性能是不错的。 在“读”多于“写”的应用程序中尤其地高性能,因为“写”会导致所有的服务器间同步状态。(“读”多于“写”是协调服务的典型场景)。\n另外,很多顶级的开源项目都用到了 ZooKeeper,比如:\nKafka : ZooKeeper 主要为 Kafka 提供 Broker 和 Topic 的注册以及多个 Partition 的负载均衡等功能。不过,在 Kafka 2.8 之后,引入了基于 Raft 协议的 KRaft 模式,不再依赖 Zookeeper,大大简化了 Kafka 的架构。 Hbase : ZooKeeper 为 Hbase 提供确保整个集群只有一个 Master 以及保存和提供 regionserver 状态信息(是否在线)等功能。 Hadoop : ZooKeeper 为 Namenode 提供高可用支持。 ZooKeeper 特点 # 顺序一致性: 从同一客户端发起的事务请求,最终将会严格地按照顺序被应用到 ZooKeeper 中去。 原子性: 所有事务请求的处理结果在整个集群中所有机器上的应用情况是一致的,也就是说,要么整个集群中所有的机器都成功应用了某一个事务,要么都没有应用。 单一系统映像: 无论客户端连到哪一个 ZooKeeper 服务器上,其看到的服务端数据模型都是一致的。 可靠性: 一旦一次更改请求被应用,更改的结果就会被持久化,直到被下一次更改覆盖。 实时性: 一旦数据发生变更,其他节点会实时感知到。每个客户端的系统视图都是最新的。 集群部署:3~5 台(最好奇数台)机器就可以组成一个集群,每台机器都在内存保存了 ZooKeeper 的全部数据,机器之间互相通信同步数据,客户端连接任何一台机器都可以。 ==高可用:==如果某台机器宕机,会保证数据不丢失。集群中挂掉不超过一半的机器,都能保证集群可用。比如 3 台机器可以挂 1 台,5 台机器可以挂 2 台。 ZooKeeper 应用场景 # ZooKeeper 概览中,我们介绍到使用其通常被用于实现诸如数据发布/订阅、负载均衡、命名服务、分布式协调/通知、集群管理、Master 选举、分布式锁和分布式队列等功能。\n下面选 3 个典型的应用场景来专门说说:\n命名服务:可以通过 ZooKeeper 的顺序节点生成全局唯一 ID。 数据发布/订阅:通过 Watcher 机制 可以很方便地实现数据发布/订阅。当你将数据发布到 ZooKeeper 被监听的节点上,其他机器可通过监听 ZooKeeper 上节点的变化来实现配置的动态更新。 分布式锁:通过创建唯一节点获得分布式锁,当获得锁的一方执行完相关代码或者是挂掉之后就释放锁。分布式锁的实现也需要用到 Watcher 机制 ,我在 分布式锁详解 这篇文章中有详细介绍到如何基于 ZooKeeper 实现分布式锁。 实际上,这些功能的实现基本都得益于 ZooKeeper 可以保存数据的功能,但是 ZooKeeper 不适合保存大量数据,这一点需要注意。\nZooKeeper 重要概念 # 破音:拿出小本本,下面的内容非常重要哦!\nData model(数据模型) # ZooKeeper 数据模型采用层次化的多叉树形结构,每个节点上都可以存储数据,这些数据可以是数字、字符串或者是二进制序列。并且。每个节点还可以拥有 N 个子节点,最上层是根节点以“/”来代表。每个数据节点在 ZooKeeper 中被称为 znode,它是 ZooKeeper 中数据的最小单元。并且,每个 znode 都有一个唯一的路径标识。\n强调一句:ZooKeeper 主要是用来协调服务的,而不是用来存储业务数据的,所以不要放比较大的数据在 znode 上,ZooKeeper 给出的每个节点的数据大小上限是 1M 。\n从下图可以更直观地看出:ZooKeeper 节点路径标识方式和 Unix 文件系统路径非常相似,都是由一系列使用斜杠\u0026quot;/\u0026ldquo;进行分割的路径表示,开发人员可以向这个节点中写入数据,也可以在节点下面创建子节点。这些操作我们后面都会介绍到。\nznode(数据节点) # 介绍了 ZooKeeper 树形数据模型之后,我们知道每个数据节点在 ZooKeeper 中被称为 znode,它是 ZooKeeper 中数据的最小单元。你要存放的数据就放在上面,是你使用 ZooKeeper 过程中经常需要接触到的一个概念。\n我们通常是将 znode 分为 4 大类:\n持久(PERSISTENT)节点:一旦创建就一直存在即使 ZooKeeper 集群宕机,直到将其删除。 临时(EPHEMERAL)节点:临时节点的生命周期是与 客户端会话(session) 绑定的,会话消失则节点消失。并且,临时节点只能做叶子节点 ,不能创建子节点。 持久顺序(PERSISTENT_SEQUENTIAL)节点:除了具有持久(PERSISTENT)节点的特性之外, 子节点的名称还具有顺序性。比如 /node1/app0000000001、/node1/app0000000002 。 临时顺序(EPHEMERAL_SEQUENTIAL)节点:除了具备临时(EPHEMERAL)节点的特性之外,子节点的名称还具有顺序性 每个 znode 由 2 部分组成:\nstat:状态信息 data:节点存放的数据的具体内容 如下所示,我通过 get 命令来获取 根目录下的 dubbo 节点的内容。(get 命令在下面会介绍到)。\n[zk: 127.0.0.1:2181(CONNECTED) 6] get /dubbo # 该数据节点关联的数据内容为空 null # 下面是该数据节点的一些状态信息,其实就是 Stat 对象的格式化输出 cZxid = 0x2 ctime = Tue Nov 27 11:05:34 CST 2018 mZxid = 0x2 mtime = Tue Nov 27 11:05:34 CST 2018 pZxid = 0x3 cversion = 1 dataVersion = 0 aclVersion = 0 ephemeralOwner = 0x0 dataLength = 0 numChildren = 1 Stat 类中包含了一个数据节点的所有状态信息的字段,包括事务 ID(cZxid)、节点创建时间(ctime) 和子节点个数(numChildren) 等等。\n下面我们来看一下每个 znode 状态信息究竟代表的是什么吧!(下面的内容来源于《从 Paxos 到 ZooKeeper 分布式一致性原理与实践》,因为 Guide 确实也不是特别清楚,要学会参考资料的嘛! ):\nznode 状态信息 解释 cZxid create ZXID,即该数据节点被创建时的事务 id ctime create time,即该节点的创建时间 mZxid modified ZXID,即该节点最终一次更新时的事务 id mtime modified time,即该节点最后一次的更新时间 pZxid 该节点的子节点列表最后一次修改时的事务 id,只有子节点列表变更才会更新 pZxid,子节点内容变更不会更新 cversion 子节点版本号,当前节点的子节点每次变化时值增加 1 dataVersion 数据节点内容版本号,节点创建时为 0,每更新一次节点内容(不管内容有无变化)该版本号的值增加 1 aclVersion 节点的 ACL 版本号,表示该节点 ACL 信息变更次数 ephemeralOwner 创建该临时节点的会话的 sessionId;如果当前节点为持久节点,则 ephemeralOwner=0 dataLength 数据节点内容长度 numChildren 当前节点的子节点个数 版本(version) # 在前面我们已经提到,对应于每个 znode,ZooKeeper 都会为其维护一个叫作 Stat 的数据结构,Stat 中记录了这个 znode 的三个相关的版本:\ndataVersion:当前 znode 节点的版本号 cversion:当前 znode 子节点的版本 aclVersion:当前 znode 的 ACL 的版本。 ACL(权限控制) # ZooKeeper 采用 ACL(AccessControlLists)策略来进行权限控制,类似于 UNIX 文件系统的权限控制。\n对于 znode 操作的权限,ZooKeeper 提供了以下 5 种:\nCREATE : 能创建子节点 READ:能获取节点数据和列出其子节点 WRITE : 能设置/更新节点数据 DELETE : 能删除子节点 ADMIN : 能设置节点 ACL 的权限 其中尤其需要注意的是,CREATE 和 DELETE 这两种权限都是针对 子节点 的权限控制。\n对于身份认证,提供了以下几种方式:\nworld:默认方式,所有用户都可无条件访问。 auth :不使用任何 id,代表任何已认证的用户。 digest :用户名:密码认证方式:username:password 。 ip : 对指定 ip 进行限制。 Watcher(事件监听器) # Watcher(事件监听器),是 ZooKeeper 中的一个很重要的特性。ZooKeeper 允许用户在指定节点上注册一些 Watcher,并且在一些特定事件触发的时候,ZooKeeper 服务端会将事件通知到感兴趣的客户端上去,该机制是 ZooKeeper 实现分布式协调服务的重要特性。\n破音:非常有用的一个特性,都拿出小本本记好了,后面用到 ZooKeeper 基本离不开 Watcher(事件监听器)机制。\n会话(Session) # Session 可以看作是 ZooKeeper 服务器与客户端的之间的一个 TCP 长连接,通过这个连接,客户端能够通过心跳检测与服务器保持有效的会话,也能够向 ZooKeeper 服务器发送请求并接受响应,同时还能够通过该连接接收来自服务器的 Watcher 事件通知。\nSession 有一个属性叫做:sessionTimeout ,sessionTimeout 代表会话的超时时间。当由于服务器压力太大、网络故障或是客户端主动断开连接等各种原因导致客户端连接断开时,只要在sessionTimeout规定的时间内能够重新连接上集群中任意一台服务器,那么之前创建的会话仍然有效。\n另外,在为客户端创建会话之前,服务端首先会为每个客户端都分配一个 sessionID。由于 sessionID是 ZooKeeper 会话的一个重要标识,许多与会话相关的运行机制都是基于这个 sessionID 的,因此,无论是哪台服务器为客户端分配的 sessionID,都务必保证全局唯一。\nZooKeeper 集群 # 为了保证高可用,最好是以集群形态来部署 ZooKeeper,这样只要集群中大部分机器是可用的(能够容忍一定的机器故障),那么 ZooKeeper 本身仍然是可用的。通常 3 台服务器就可以构成一个 ZooKeeper 集群了。ZooKeeper 官方提供的架构图就是一个 ZooKeeper 集群整体对外提供服务。\n上图中每一个 Server 代表一个安装 ZooKeeper 服务的服务器。组成 ZooKeeper 服务的服务器都会在内存中维护当前的服务器状态,并且每台服务器之间都互相保持着通信。集群间通过 ZAB 协议(ZooKeeper Atomic Broadcast)来保持数据的一致性。\n最典型集群模式:Master/Slave 模式(主备模式)。在这种模式中,通常 Master 服务器作为主服务器提供写服务,其他的 Slave 服务器从服务器通过异步复制的方式获取 Master 服务器最新的数据提供读服务。\nZooKeeper 集群角色 # 但是,在 ZooKeeper 中没有选择传统的 Master/Slave 概念,而是引入了 Leader、Follower 和 Observer 三种角色。如下图所示\nZooKeeper 集群中的所有机器通过一个 Leader 选举过程 来选定一台称为 “Leader” 的机器,Leader 既可以为客户端提供写服务又能提供读服务。除了 Leader 外,Follower 和 Observer 都只能提供读服务。Follower 和 Observer 唯一的区别在于 Observer 机器不参与 Leader 的选举过程,也不参与写操作的“过半写成功”策略,因此 Observer 机器可以在不影响写性能的情况下提升集群的读性能。\n角色 说明 Leader 为客户端提供读和写的服务,负责投票的发起和决议,更新系统状态。 Follower 为客户端提供读服务,如果是写服务则转发给 Leader。参与选举过程中的投票。 Observer 为客户端提供读服务,如果是写服务则转发给 Leader。不参与选举过程中的投票,也不参与“过半写成功”策略。在不影响写性能的情况下提升集群的读性能。此角色于 ZooKeeper3.3 系列新增的角色。 ZooKeeper 集群 Leader 选举过程 # 当 Leader 服务器出现网络中断、崩溃退出与重启等异常情况时,就会进入 Leader 选举过程,这个过程会选举产生新的 Leader 服务器。\n这个过程大致是这样的:\nLeader election(选举阶段):节点在一开始都处于选举阶段,只要有一个节点得到超半数节点的票数,它就可以当选准 leader。 Discovery(发现阶段):在这个阶段,followers 跟准 leader 进行通信,同步 followers 最近接收的事务提议。 Synchronization(同步阶段):同步阶段主要是利用 leader 前一阶段获得的最新提议历史,同步集群中所有的副本。同步完成之后准 leader 才会成为真正的 leader。 Broadcast(广播阶段):到了这个阶段,ZooKeeper 集群才能正式对外提供事务服务,并且 leader 可以进行消息广播。同时如果有新的节点加入,还需要对新节点进行同步。 ZooKeeper 集群中的服务器状态有下面几种:\nLOOKING:寻找 Leader。 LEADING:Leader 状态,对应的节点为 Leader。 FOLLOWING:Follower 状态,对应的节点为 Follower。 OBSERVING:Observer 状态,对应节点为 Observer,该节点不参与 Leader 选举。 ZooKeeper 集群为啥最好奇数台? # ZooKeeper 集群在宕掉几个 ZooKeeper 服务器之后,如果剩下的 ZooKeeper 服务器个数大于宕掉的个数的话整个 ZooKeeper 才依然可用。假如我们的集群中有 n 台 ZooKeeper 服务器,那么也就是剩下的服务数必须大于 n/2。先说一下结论,2n 和 2n-1 的容忍度是一样的,都是 n-1,大家可以先自己仔细想一想,这应该是一个很简单的数学问题了。\n比如假如我们有 3 台,那么最大允许宕掉 1 台 ZooKeeper 服务器,如果我们有 4 台的的时候也同样只允许宕掉 1 台。 假如我们有 5 台,那么最大允许宕掉 2 台 ZooKeeper 服务器,如果我们有 6 台的的时候也同样只允许宕掉 2 台。\n综上,何必增加那一个不必要的 ZooKeeper 呢?\nZooKeeper 选举的过半机制防止脑裂 # 何为集群脑裂?\n对于一个集群,通常多台机器会部署在不同机房,来提高这个集群的可用性。保证可用性的同时,会发生一种机房间网络线路故障,导致机房间网络不通,而集群被割裂成几个小集群。这时候子集群各自选主导致“脑裂”的情况。\n举例说明:比如现在有一个由 6 台服务器所组成的一个集群,部署在了 2 个机房,每个机房 3 台。正常情况下只有 1 个 leader,但是当两个机房中间网络断开的时候,每个机房的 3 台服务器都会认为另一个机房的 3 台服务器下线,而选出自己的 leader 并对外提供服务。若没有过半机制,当网络恢复的时候会发现有 2 个 leader。仿佛是 1 个大脑(leader)分散成了 2 个大脑,这就发生了脑裂现象。脑裂期间 2 个大脑都可能对外提供了服务,这将会带来数据一致性等问题。\n过半机制是如何防止脑裂现象产生的?\nZooKeeper 的过半机制导致不可能产生 2 个 leader,因为少于等于一半是不可能产生 leader 的,这就使得不论机房的机器如何分配都不可能发生脑裂。\nZAB 协议和 Paxos 算法 # Paxos 算法应该可以说是 ZooKeeper 的灵魂了。但是,ZooKeeper 并没有完全采用 Paxos 算法 ,而是使用 ZAB 协议作为其保证数据一致性的核心算法。另外,在 ZooKeeper 的官方文档中也指出,ZAB 协议并不像 Paxos 算法那样,是一种通用的分布式一致性算法,它是一种特别为 Zookeeper 设计的崩溃可恢复的原子消息广播算法。\nZAB 协议介绍 # ZAB(ZooKeeper Atomic Broadcast,原子广播) 协议是为分布式协调服务 ZooKeeper 专门设计的一种支持崩溃恢复的原子广播协议。 在 ZooKeeper 中,主要依赖 ZAB 协议来实现分布式数据一致性,基于该协议,ZooKeeper 实现了一种主备模式的系统架构来保持集群中各个副本之间的数据一致性。\nZAB 协议两种基本的模式:崩溃恢复和消息广播 # ZAB 协议包括两种基本的模式,分别是\n崩溃恢复:当整个服务框架在启动过程中,或是当 Leader 服务器出现网络中断、崩溃退出与重启等异常情况时,ZAB 协议就会进入恢复模式并选举产生新的 Leader 服务器。当选举产生了新的 Leader 服务器,同时集群中已经有过半的机器与该 Leader 服务器完成了状态同步之后,ZAB 协议就会退出恢复模式。其中,所谓的状态同步是指数据同步,用来保证集群中存在过半的机器能够和 Leader 服务器的数据状态保持一致。 消息广播:当集群中已经有过半的 Follower 服务器完成了和 Leader 服务器的状态同步,那么整个服务框架就可以进入消息广播模式了。 当一台同样遵守 ZAB 协议的服务器启动后加入到集群中时,如果此时集群中已经存在一个 Leader 服务器在负责进行消息广播,那么新加入的服务器就会自觉地进入数据恢复模式:找到 Leader 所在的服务器,并与其进行数据同步,然后一起参与到消息广播流程中去。 ZAB 协议\u0026amp;Paxos 算法文章推荐 # 关于 ZAB 协议\u0026amp;Paxos 算法 需要讲和理解的东西太多了,具体可以看下面这几篇文章:\nPaxos 算法详解 ZooKeeper 与 Zab 协议 · Analyze Raft 算法详解 ZooKeeper VS ETCD # ETCD 是一种强一致性的分布式键值存储,它提供了一种可靠的方式来存储需要由分布式系统或机器集群访问的数据。ETCD 内部采用 Raft 算法作为一致性算法,基于 Go 语言实现。\n与 ZooKeeper 类似,ETCD 也可用于数据发布/订阅、负载均衡、命名服务、分布式协调/通知、分布式锁等场景。那二者如何选择呢?\n得物技术的 浅析如何基于 ZooKeeper 实现高可用架构这篇文章给出了如下的对比表格(我进一步做了优化),可以作为参考:\nZooKeeper ETCD 语言 Java Go 协议 TCP Grpc 接口调用 必须要使用自己的 client 进行调用 可通过 HTTP 传输,即可通过 CURL 等命令实现调用 一致性算法 Zab 协议 Raft 算法 Watcher 机制 较局限,一次性触发器 一次 Watch 可以监听所有的事件 数据模型 基于目录的层次模式 参考了 zk 的数据模型,是个扁平的 kv 模型 存储 kv 存储,使用的是 ConcurrentHashMap,内存存储,一般不建议存储较多数据 kv 存储,使用 bbolt 存储引擎,可以处理几个 GB 的数据。 MVCC 不支持 支持,通过两个 B+ Tree 进行版本控制 全局 Session 存在缺陷 实现更灵活,避免了安全性问题 权限校验 ACL RBAC 事务能力 提供了简易的事务能力 只提供了版本号的检查能力 部署维护 复杂 简单 ZooKeeper 在存储性能、全局 Session、Watcher 机制等方面存在一定局限性,越来越多的开源项目在替换 ZooKeeper 为 Raft 实现或其它分布式协调服务,例如: Kafka Needs No Keeper - Removing ZooKeeper Dependency (confluent.io)、 Moving Toward a ZooKeeper-Less Apache Pulsar (streamnative.io)。\nETCD 相对来说更优秀一些,提供了更稳定的高负载读写能力,对 ZooKeeper 暴露的许多问题进行了改进优化。并且,ETCD 基本能够覆盖 ZooKeeper 的所有应用场景,实现对其的替代。\n总结 # ZooKeeper 本身就是一个分布式程序(只要半数以上节点存活,ZooKeeper 就能正常服务)。 为了保证高可用,最好是以集群形态来部署 ZooKeeper,这样只要集群中大部分机器是可用的(能够容忍一定的机器故障),那么 ZooKeeper 本身仍然是可用的。 ZooKeeper 将数据保存在内存中,这也就保证了 高吞吐量和低延迟(但是内存限制了能够存储的容量不太大,此限制也是保持 znode 中存储的数据量较小的进一步原因)。 ZooKeeper 是高性能的。 在“读”多于“写”的应用程序中尤其地明显,因为“写”会导致所有的服务器间同步状态。(“读”多于“写”是协调服务的典型场景。) ZooKeeper 有临时节点的概念。 当创建临时节点的客户端会话一直保持活动,瞬时节点就一直存在。而当会话终结时,瞬时节点被删除。持久节点是指一旦这个 znode 被创建了,除非主动进行 znode 的移除操作,否则这个 znode 将一直保存在 ZooKeeper 上。 ZooKeeper 底层其实只提供了两个功能:① 管理(存储、读取)用户程序提交的数据;② 为用户程序提供数据节点监听服务。 参考 # 《从 Paxos 到 ZooKeeper 分布式一致性原理与实践》 谈谈 ZooKeeper 的局限性: https://wingsxdu.com/posts/database/zookeeper-limitations/ "},{"id":592,"href":"/zh/docs/technology/Interview/high-quality-technical-articles/interview/some-secrets-about-alibaba-interview/","title":"阿里技术面试的一些秘密","section":"Interview","content":" 推荐语:详细介绍了求职者在面试中应该具备哪些能力才会有更大概率脱颖而出。\n原文地址: https://mp.weixin.qq.com/s/M2M808PwQ2JcMqfLQfXQMw\n最近我的工作稍微轻松些,就被安排去校招面试了\n当时还是有些激动的,以前都是被面试的,现在我自己也成为一个面试别人的面试官\n接下来就谈谈我的面试心得(谈谈阿里面试的秘籍)\n我是怎么筛选简历的? # 面试之前都是要筛选简历,这个大家应该知道\n阿里对待招聘非常负责任,面试官必须对每位同学的简历进行查看和筛选,如果不合适还需要写清楚理由\n对于校招生来说,第一份工作非常重要,而且校招的面试机会也只有一次,一旦收到大家的简历意味着大家非常认可和喜爱阿里这家公司\n所以我们对每份简历都会认真看,大家可以非常放心,不会无缘无故挂掉大家的简历\n尽管我们报以非常负责任的态度,但有些同学们的简历实在是难以下看\n关于如何写简历,我之前写过类似的文章,这里就把之前的文章放这里让大家看看 一份好的简历应该有哪些内容\n在筛选简历的时候会有以下信息非常重要,大家一定要认真写\n项目经历,具体写法可以看上面提到的文章 个人含金量比较高的奖项,比如 ACM 奖牌、计算机竞赛等 个人技能 这块会看,但是大多数简历写法都差不多,尽量写得言简意赅 重要期刊论文发表、开源项目 加分项 这些信息非常重要,我筛选简历的时候这些信息占整份简历的比重 4/5 左右\n面试的时候我会注重哪些方面? # 表达要清楚 # 这点是硬伤,在面试的时候有些同学半天说不清楚自己做的项目,我都在替你着急\n描述项目有个简单的方法论,我自己总结的 大家看看适不适合自己\n最好言简意赅的描述一下你的项目背景,让面试官很快知道项目干了啥(让面试官很快对项目感兴趣) 说下项目用了哪些技术,做技术的用了哪些技术得说清楚,面试官会对你的技术比较感兴趣 解决了什么问题,做项目肯定是为了解决问题,总不能为了做项目而做项目吧(解决问题的能力非常重要) 遇到哪些难题,如何突破这些难题,项目遇到困难问题很正常,突破困难才是一次好的成长 项目还有哪些完善的地方,不可能设计出完美的执行方案,有待改进说明你对项目认识深刻,思考深入 一场面试时间一般 60—80 分钟,好的表达有助于彼此之间了解更多的问题\n基础知识要扎实 # 校招非常注重基础知识,所以这块问的问题比较多,我一般会结合你项目去问,看看同学对技术是停留在用的阶段还是有自己的深入思考\n每个方向对基础知识要求不同,但有些基础知识是通用的\n比如数据结构与算法、操作系统、计算机网络 等\n这些基础技术知识一定要掌握扎实,技术岗位都会或多或少去问这些基础\n动手能力很重要 # action,action,action ,重要的事情说三遍,做技术的不可能光靠一张嘴,能落地才是最重要的\n面试官除了问你基础知识和项目还会去考考你的动手能力,面试时间一般不会太长,根据岗位的不同一般会让同学们写一些算法题目\n阿里面试,不会给你出非常变态的算法题目\n主要还是考察大家的动手能力、思考问题的能力、数据结构的应用能力\n在写代码的过程中,我也总结了自己的方法论:\n上来不要先写,审题、问清楚题目意图,不要自以为是的去理解思路,工作中 沟通需求、明确需求、提出质疑和建议是非常好的习惯 接下来说思路 思路错了写到一半再去改会非常浪费时间 描述清楚之后,先写代码思路的步骤注释,一边写注释,脑子里迭代一遍自己的思路是否正确,是否是最优解 最后,代码规范 除了上面这些常规的方面 # 其实,现在面试已经非常卷了,上面说的这些很多都是 八股文\n有些学生会拿到很多面试题目和答案,反复的去记忆,面试官问问题他就开始在脑子里面检索答案\n我一般问几个问题就知道该学生是不是在背八股文了。\n对于背八股文的同学,我真的非常难过。\n尽管你背的很好,但不能给你过啊,得对得起自己职责,得对公司负责啊!\n背的在好,不如理解一个知识点,理解一个知识点会有助于你去理解很多其他的知识点,很多知识点连起来就是一个知识体系。\n当面试官问你体系中的任何一个问题,都可以把这个体系讲给他听,不是背诵 。\n深入理解问题,我会比较关注。\n我在面试过程中,会通过一个问题去问一串问题,慢慢就把整体体系串起来。\n你的比赛和论文是你的亮点,这些东西是非常重要的加分项。\n我也会在面试中穿插一些开放性题目,都是思考题 考验一个同学思考问题的方式。\n最后 # 作为一个面试官,我很想对大家说,每个企业都非常渴望人才,都希望找到最适合企业发展的人\n面试的时候面试官会尽量去挖掘你的价值。\n但是,面试时间有限,同学们一定要在有限的时间里展现出自己的能力和无限的潜力 。\n最后,祝愿优秀的你能找到自己理想的工作!\n"},{"id":593,"href":"/zh/docs/technology/Interview/cs-basics/data-structure/bloom-filter/","title":"布隆过滤器","section":"Data Structure","content":"布隆过滤器相信大家没用过的话,也已经听过了。\n布隆过滤器主要是为了解决海量数据的存在性问题。对于海量数据中判定某个数据是否存在且容忍轻微误差这一场景(比如缓存穿透、海量数据去重)来说,非常适合。\n文章内容概览:\n什么是布隆过滤器? 布隆过滤器的原理介绍。 布隆过滤器使用场景。 通过 Java 编程手动实现布隆过滤器。 利用 Google 开源的 Guava 中自带的布隆过滤器。 Redis 中的布隆过滤器。 什么是布隆过滤器? # 首先,我们需要了解布隆过滤器的概念。\n布隆过滤器(Bloom Filter,BF)是一个叫做 Bloom 的老哥于 1970 年提出的。我们可以把它看作由二进制向量(或者说位数组)和一系列随机映射函数(哈希函数)两部分组成的数据结构。相比于我们平时常用的 List、Map、Set 等数据结构,它占用空间更少并且效率更高,但是缺点是其返回的结果是概率性的,而不是非常准确的。理论情况下添加到集合中的元素越多,误报的可能性就越大。并且,存放在布隆过滤器的数据不容易删除。\nBloom Filter 会使用一个较大的 bit 数组来保存所有的数据,数组中的每个元素都只占用 1 bit ,并且每个元素只能是 0 或者 1(代表 false 或者 true),这也是 Bloom Filter 节省内存的核心所在。这样来算的话,申请一个 100w 个元素的位数组只占用 1000000Bit / 8 = 125000 Byte = 125000/1024 KB ≈ 122KB 的空间。\n总结:一个名叫 Bloom 的人提出了一种来检索元素是否在给定大集合中的数据结构,这种数据结构是高效且性能很好的,但缺点是具有一定的错误识别率和删除难度。并且,理论情况下,添加到集合中的元素越多,误报的可能性就越大。\n布隆过滤器的原理介绍 # 当一个元素加入布隆过滤器中的时候,会进行如下操作:\n使用布隆过滤器中的哈希函数对元素值进行计算,得到哈希值(有几个哈希函数得到几个哈希值)。 根据得到的哈希值,在位数组中把对应下标的值置为 1。 当我们需要判断一个元素是否存在于布隆过滤器的时候,会进行如下操作:\n对给定元素再次进行相同的哈希计算; 得到值之后判断位数组中的每个元素是否都为 1,如果值都为 1,那么说明这个值在布隆过滤器中,如果存在一个值不为 1,说明该元素不在布隆过滤器中。 Bloom Filter 的简单原理图如下:\n如图所示,当字符串存储要加入到布隆过滤器中时,该字符串首先由多个哈希函数生成不同的哈希值,然后将对应的位数组的下标设置为 1(当位数组初始化时,所有位置均为 0)。当第二次存储相同字符串时,因为先前的对应位置已设置为 1,所以很容易知道此值已经存在(去重非常方便)。\n如果我们需要判断某个字符串是否在布隆过滤器中时,只需要对给定字符串再次进行相同的哈希计算,得到值之后判断位数组中的每个元素是否都为 1,如果值都为 1,那么说明这个值在布隆过滤器中,如果存在一个值不为 1,说明该元素不在布隆过滤器中。\n不同的字符串可能哈希出来的位置相同,这种情况我们可以适当增加位数组大小或者调整我们的哈希函数。\n综上,我们可以得出:布隆过滤器说某个元素存在,小概率会误判。布隆过滤器说某个元素不在,那么这个元素一定不在。\n布隆过滤器使用场景 # 判断给定数据是否存在:比如判断一个数字是否存在于包含大量数字的数字集中(数字集很大,上亿)、 防止缓存穿透(判断请求的数据是否有效避免直接绕过缓存请求数据库)等等、邮箱的垃圾邮件过滤(判断一个邮件地址是否在垃圾邮件列表中)、黑名单功能(判断一个 IP 地址或手机号码是否在黑名单中)等等。 去重:比如爬给定网址的时候对已经爬取过的 URL 去重、对巨量的 QQ 号/订单号去重。 去重场景也需要用到判断给定数据是否存在,因此布隆过滤器主要是为了解决海量数据的存在性问题。\n编码实战 # 通过 Java 编程手动实现布隆过滤器 # 我们上面已经说了布隆过滤器的原理,知道了布隆过滤器的原理之后就可以自己手动实现一个了。\n如果你想要手动实现一个的话,你需要:\n一个合适大小的位数组保存数据 几个不同的哈希函数 添加元素到位数组(布隆过滤器)的方法实现 判断给定元素是否存在于位数组(布隆过滤器)的方法实现。 下面给出一个我觉得写的还算不错的代码(参考网上已有代码改进得到,对于所有类型对象皆适用):\nimport java.util.BitSet; public class MyBloomFilter { /** * 位数组的大小 */ private static final int DEFAULT_SIZE = 2 \u0026lt;\u0026lt; 24; /** * 通过这个数组可以创建 6 个不同的哈希函数 */ private static final int[] SEEDS = new int[]{3, 13, 46, 71, 91, 134}; /** * 位数组。数组中的元素只能是 0 或者 1 */ private BitSet bits = new BitSet(DEFAULT_SIZE); /** * 存放包含 hash 函数的类的数组 */ private SimpleHash[] func = new SimpleHash[SEEDS.length]; /** * 初始化多个包含 hash 函数的类的数组,每个类中的 hash 函数都不一样 */ public MyBloomFilter() { // 初始化多个不同的 Hash 函数 for (int i = 0; i \u0026lt; SEEDS.length; i++) { func[i] = new SimpleHash(DEFAULT_SIZE, SEEDS[i]); } } /** * 添加元素到位数组 */ public void add(Object value) { for (SimpleHash f : func) { bits.set(f.hash(value), true); } } /** * 判断指定元素是否存在于位数组 */ public boolean contains(Object value) { boolean ret = true; for (SimpleHash f : func) { ret = ret \u0026amp;\u0026amp; bits.get(f.hash(value)); } return ret; } /** * 静态内部类。用于 hash 操作! */ public static class SimpleHash { private int cap; private int seed; public SimpleHash(int cap, int seed) { this.cap = cap; this.seed = seed; } /** * 计算 hash 值 */ public int hash(Object value) { int h; return (value == null) ? 0 : Math.abs((cap - 1) \u0026amp; seed * ((h = value.hashCode()) ^ (h \u0026gt;\u0026gt;\u0026gt; 16))); } } } 测试:\nString value1 = \u0026#34;https://javaguide.cn/\u0026#34;; String value2 = \u0026#34;https://github.com/Snailclimb\u0026#34;; MyBloomFilter filter = new MyBloomFilter(); System.out.println(filter.contains(value1)); System.out.println(filter.contains(value2)); filter.add(value1); filter.add(value2); System.out.println(filter.contains(value1)); System.out.println(filter.contains(value2)); Output:\nfalse false true true 测试:\nInteger value1 = 13423; Integer value2 = 22131; MyBloomFilter filter = new MyBloomFilter(); System.out.println(filter.contains(value1)); System.out.println(filter.contains(value2)); filter.add(value1); filter.add(value2); System.out.println(filter.contains(value1)); System.out.println(filter.contains(value2)); Output:\nfalse false true true 利用 Google 开源的 Guava 中自带的布隆过滤器 # 自己实现的目的主要是为了让自己搞懂布隆过滤器的原理,Guava 中布隆过滤器的实现算是比较权威的,所以实际项目中我们不需要手动实现一个布隆过滤器。\n首先我们需要在项目中引入 Guava 的依赖:\n\u0026lt;dependency\u0026gt; \u0026lt;groupId\u0026gt;com.google.guava\u0026lt;/groupId\u0026gt; \u0026lt;artifactId\u0026gt;guava\u0026lt;/artifactId\u0026gt; \u0026lt;version\u0026gt;28.0-jre\u0026lt;/version\u0026gt; \u0026lt;/dependency\u0026gt; 实际使用如下:\n我们创建了一个最多存放 最多 1500 个整数的布隆过滤器,并且我们可以容忍误判的概率为百分之(0.01)\n// 创建布隆过滤器对象 BloomFilter\u0026lt;Integer\u0026gt; filter = BloomFilter.create( Funnels.integerFunnel(), 1500, 0.01); // 判断指定元素是否存在 System.out.println(filter.mightContain(1)); System.out.println(filter.mightContain(2)); // 将元素添加进布隆过滤器 filter.put(1); filter.put(2); System.out.println(filter.mightContain(1)); System.out.println(filter.mightContain(2)); 在我们的示例中,当 mightContain() 方法返回 true 时,我们可以 99%确定该元素在过滤器中,当过滤器返回 false 时,我们可以 100%确定该元素不存在于过滤器中。\nGuava 提供的布隆过滤器的实现还是很不错的(想要详细了解的可以看一下它的源码实现),但是它有一个重大的缺陷就是只能单机使用(另外,容量扩展也不容易),而现在互联网一般都是分布式的场景。为了解决这个问题,我们就需要用到 Redis 中的布隆过滤器了。\nRedis 中的布隆过滤器 # 介绍 # Redis v4.0 之后有了 Module(模块/插件) 功能,Redis Modules 让 Redis 可以使用外部模块扩展其功能 。布隆过滤器就是其中的 Module。详情可以查看 Redis 官方对 Redis Modules 的介绍: https://redis.io/modules\n另外,官网推荐了一个 RedisBloom 作为 Redis 布隆过滤器的 Module,地址: https://github.com/RedisBloom/RedisBloom 其他还有:\nredis-lua-scaling-bloom-filter(lua 脚本实现): https://github.com/erikdubbelboer/redis-lua-scaling-bloom-filter pyreBloom(Python 中的快速 Redis 布隆过滤器): https://github.com/seomoz/pyreBloom …… RedisBloom 提供了多种语言的客户端支持,包括:Python、Java、JavaScript 和 PHP。\n使用 Docker 安装 # 如果我们需要体验 Redis 中的布隆过滤器非常简单,通过 Docker 就可以了!我们直接在 Google 搜索 docker redis bloomfilter 然后在排除广告的第一条搜素结果就找到了我们想要的答案(这是我平常解决问题的一种方式,分享一下),具体地址: https://hub.docker.com/r/redislabs/rebloom/ (介绍的很详细 )。\n具体操作如下:\n➜ ~ docker run -p 6379:6379 --name redis-redisbloom redislabs/rebloom:latest ➜ ~ docker exec -it redis-redisbloom bash root@21396d02c252:/data# redis-cli 127.0.0.1:6379\u0026gt; 注意:当前 rebloom 镜像已经被废弃,官方推荐使用 redis-stack\n常用命令一览 # 注意:key : 布隆过滤器的名称,item : 添加的元素。\nBF.ADD:将元素添加到布隆过滤器中,如果该过滤器尚不存在,则创建该过滤器。格式:BF.ADD {key} {item}。 BF.MADD : 将一个或多个元素添加到“布隆过滤器”中,并创建一个尚不存在的过滤器。该命令的操作方式BF.ADD与之相同,只不过它允许多个输入并返回多个值。格式:BF.MADD {key} {item} [item ...] 。 BF.EXISTS : 确定元素是否在布隆过滤器中存在。格式:BF.EXISTS {key} {item}。 BF.MEXISTS:确定一个或者多个元素是否在布隆过滤器中存在格式:BF.MEXISTS {key} {item} [item ...]。 另外, BF.RESERVE 命令需要单独介绍一下:\n这个命令的格式如下:\nBF.RESERVE {key} {error_rate} {capacity} [EXPANSION expansion] 。\n下面简单介绍一下每个参数的具体含义:\nkey:布隆过滤器的名称 error_rate : 期望的误报率。该值必须介于 0 到 1 之间。例如,对于期望的误报率 0.1%(1000 中为 1),error_rate 应该设置为 0.001。该数字越接近零,则每个项目的内存消耗越大,并且每个操作的 CPU 使用率越高。 capacity: 过滤器的容量。当实际存储的元素个数超过这个值之后,性能将开始下降。实际的降级将取决于超出限制的程度。随着过滤器元素数量呈指数增长,性能将线性下降。 可选参数:\nexpansion:如果创建了一个新的子过滤器,则其大小将是当前过滤器的大小乘以expansion。默认扩展值为 2。这意味着每个后续子过滤器将是前一个子过滤器的两倍。 实际使用 # 127.0.0.1:6379\u0026gt; BF.ADD myFilter java (integer) 1 127.0.0.1:6379\u0026gt; BF.ADD myFilter javaguide (integer) 1 127.0.0.1:6379\u0026gt; BF.EXISTS myFilter java (integer) 1 127.0.0.1:6379\u0026gt; BF.EXISTS myFilter javaguide (integer) 1 127.0.0.1:6379\u0026gt; BF.EXISTS myFilter github (integer) 0 "},{"id":594,"href":"/zh/docs/technology/Interview/cs-basics/operating-system/operating-system-basic-questions-01/","title":"操作系统常见面试题总结(上)","section":"Operating System","content":"很多读者抱怨计算操作系统的知识点比较繁杂,自己也没有多少耐心去看,但是面试的时候又经常会遇到。所以,我带着我整理好的操作系统的常见问题来啦!这篇文章总结了一些我觉得比较重要的操作系统相关的问题比如 用户态和内核态、系统调用、进程和线程、死锁、内存管理、虚拟内存、文件系统等等。\n这篇文章只是对一些操作系统比较重要概念的一个概览,深入学习的话,建议大家还是老老实实地去看书。另外, 这篇文章的很多内容参考了《现代操作系统》第三版这本书,非常感谢。\n开始本文的内容之前,我们先聊聊为什么要学习操作系统。\n从对个人能力方面提升来说:操作系统中的很多思想、很多经典的算法,你都可以在我们日常开发使用的各种工具或者框架中找到它们的影子。比如说我们开发的系统使用的缓存(比如 Redis)和操作系统的高速缓存就很像。CPU 中的高速缓存有很多种,不过大部分都是为了解决 CPU 处理速度和内存处理速度不对等的问题。我们还可以把内存看作外存的高速缓存,程序运行的时候我们把外存的数据复制到内存,由于内存的处理速度远远高于外存,这样提高了处理速度。同样地,我们使用的 Redis 缓存就是为了解决程序处理速度和访问常规关系型数据库速度不对等的问题。高速缓存一般会按照局部性原理(2-8 原则)根据相应的淘汰算法保证缓存中的数据是经常会被访问的。我们平常使用的 Redis 缓存很多时候也会按照 2-8 原则去做,很多淘汰算法都和操作系统中的类似。既说了 2-8 原则,那就不得不提命中率了,这是所有缓存概念都通用的。简单来说也就是你要访问的数据有多少能直接在缓存中直接找到。命中率高的话,一般表明你的缓存设计比较合理,系统处理速度也相对较快。 从面试角度来说:尤其是校招,对于操作系统方面知识的考察是非常非常多的。 简单来说,学习操作系统能够提高自己思考的深度以及对技术的理解力,并且,操作系统方面的知识也是面试必备。\n操作系统基础 # 什么是操作系统? # 通过以下四点可以概括操作系统到底是什么:\n操作系统(Operating System,简称 OS)是管理计算机硬件与软件资源的程序,是计算机的基石。 操作系统本质上是一个运行在计算机上的软件程序 ,主要用于管理计算机硬件和软件资源。 举例:运行在你电脑上的所有应用程序都通过操作系统来调用系统内存以及磁盘等等硬件。 操作系统存在屏蔽了硬件层的复杂性。 操作系统就像是硬件使用的负责人,统筹着各种相关事项。 操作系统的内核(Kernel)是操作系统的核心部分,它负责系统的内存管理,硬件设备的管理,文件系统的管理以及应用程序的管理。 内核是连接应用程序和硬件的桥梁,决定着系统的性能和稳定性。 很多人容易把操作系统的内核(Kernel)和中央处理器(CPU,Central Processing Unit)弄混。你可以简单从下面两点来区别:\n操作系统的内核(Kernel)属于操作系统层面,而 CPU 属于硬件。 CPU 主要提供运算,处理各种指令的能力。内核(Kernel)主要负责系统管理比如内存管理,它屏蔽了对硬件的操作。 下图清晰说明了应用程序、内核、CPU 这三者的关系。\n操作系统主要有哪些功能? # 从资源管理的角度来看,操作系统有 6 大功能:\n进程和线程的管理:进程的创建、撤销、阻塞、唤醒,进程间的通信等。 存储管理:内存的分配和管理、外存(磁盘等)的分配和管理等。 文件管理:文件的读、写、创建及删除等。 设备管理:完成设备(输入输出设备和外部存储设备等)的请求或释放,以及设备启动等功能。 网络管理:操作系统负责管理计算机网络的使用。网络是计算机系统中连接不同计算机的方式,操作系统需要管理计算机网络的配置、连接、通信和安全等,以提供高效可靠的网络服务。 安全管理:用户的身份认证、访问控制、文件加密等,以防止非法用户对系统资源的访问和操作。 常见的操作系统有哪些? # Windows # 目前最流行的个人桌面操作系统 ,不做多的介绍,大家都清楚。界面简单易操作,软件生态非常好。\n玩玩电脑游戏还是必须要有 Windows 的,所以我现在是一台 Windows 用于玩游戏,一台 Mac 用于平时日常开发和学习使用。\nUnix # 最早的多用户、多任务操作系统 。后面崛起的 Linux 在很多方面都参考了 Unix。\n目前这款操作系统已经逐渐逐渐退出操作系统的舞台。\nLinux # Linux 是一套免费使用、开源的类 Unix 操作系统。 Linux 存在着许多不同的发行版本,但它们都使用了 Linux 内核 。\n严格来讲,Linux 这个词本身只表示 Linux 内核,在 GNU/Linux 系统中,Linux 实际就是 Linux 内核,而该系统的其余部分主要是由 GNU 工程编写和提供的程序组成。单独的 Linux 内核并不能成为一个可以正常工作的操作系统。\n很多人更倾向使用 “GNU/Linux” 一词来表达人们通常所说的 “Linux”。\nMac OS # 苹果自家的操作系统,编程体验和 Linux 相当,但是界面、软件生态以及用户体验各方面都要比 Linux 操作系统更好。\n用户态和内核态 # 什么是用户态和内核态? # 根据进程访问资源的特点,我们可以把进程在系统上的运行分为两个级别:\n用户态(User Mode) : 用户态运行的进程可以直接读取用户程序的数据,拥有较低的权限。当应用程序需要执行某些需要特殊权限的操作,例如读写磁盘、网络通信等,就需要向操作系统发起系统调用请求,进入内核态。 内核态(Kernel Mode):内核态运行的进程几乎可以访问计算机的任何资源包括系统的内存空间、设备、驱动程序等,不受限制,拥有非常高的权限。当操作系统接收到进程的系统调用请求时,就会从用户态切换到内核态,执行相应的系统调用,并将结果返回给进程,最后再从内核态切换回用户态。 内核态相比用户态拥有更高的特权级别,因此能够执行更底层、更敏感的操作。不过,由于进入内核态需要付出较高的开销(需要进行一系列的上下文切换和权限检查),应该尽量减少进入内核态的次数,以提高系统的性能和稳定性。\n为什么要有用户态和内核态?只有一个内核态不行么? # 在 CPU 的所有指令中,有一些指令是比较危险的比如内存分配、设置时钟、IO 处理等,如果所有的程序都能使用这些指令的话,会对系统的正常运行造成灾难性地影响。因此,我们需要限制这些危险指令只能内核态运行。这些只能由操作系统内核态执行的指令也被叫做 特权指令 。 如果计算机系统中只有一个内核态,那么所有程序或进程都必须共享系统资源,例如内存、CPU、硬盘等,这将导致系统资源的竞争和冲突,从而影响系统性能和效率。并且,这样也会让系统的安全性降低,毕竟所有程序或进程都具有相同的特权级别和访问权限。 因此,同时具有用户态和内核态主要是为了保证计算机系统的安全性、稳定性和性能。\n用户态和内核态是如何切换的? # 用户态切换到内核态的 3 种方式:\n系统调用(Trap):用户态进程 主动 要求切换到内核态的一种方式,主要是为了使用内核态才能做的事情比如读取磁盘资源。系统调用的机制其核心还是使用了操作系统为用户特别开放的一个中断来实现。 中断(Interrupt):当外围设备完成用户请求的操作后,会向 CPU 发出相应的中断信号,这时 CPU 会暂停执行下一条即将要执行的指令转而去执行与中断信号对应的处理程序,如果先前执行的指令是用户态下的程序,那么这个转换的过程自然也就发生了由用户态到内核态的切换。比如硬盘读写操作完成,系统会切换到硬盘读写的中断处理程序中执行后续操作等。 异常(Exception):当 CPU 在执行运行在用户态下的程序时,发生了某些事先不可知的异常,这时会触发由当前运行进程切换到处理此异常的内核相关程序中,也就转到了内核态,比如缺页异常。 在系统的处理上,中断和异常类似,都是通过中断向量表来找到相应的处理程序进行处理。区别在于,中断来自处理器外部,不是由任何一条专门的指令造成,而异常是执行当前指令的结果。\n系统调用 # 什么是系统调用? # 我们运行的程序基本都是运行在用户态,如果我们调用操作系统提供的内核态级别的子功能咋办呢?那就需要系统调用了!\n也就是说在我们运行的用户程序中,凡是与系统态级别的资源有关的操作(如文件管理、进程控制、内存管理等),都必须通过系统调用方式向操作系统提出服务请求,并由操作系统代为完成。\n这些系统调用按功能大致可分为如下几类:\n设备管理:完成设备(输入输出设备和外部存储设备等)的请求或释放,以及设备启动等功能。 文件管理:完成文件的读、写、创建及删除等功能。 进程管理:进程的创建、撤销、阻塞、唤醒,进程间的通信等功能。 内存管理:完成内存的分配、回收以及获取作业占用内存区大小及地址等功能。 系统调用和普通库函数调用非常相似,只是系统调用由操作系统内核提供,运行于内核态,而普通的库函数调用由函数库或用户自己提供,运行于用户态。\n总结:系统调用是应用程序与操作系统之间进行交互的一种方式,通过系统调用,应用程序可以访问操作系统底层资源例如文件、设备、网络等。\n系统调用的过程了解吗? # 系统调用的过程可以简单分为以下几个步骤:\n用户态的程序发起系统调用,因为系统调用中涉及一些特权指令(只能由操作系统内核态执行的指令),用户态程序权限不足,因此会中断执行,也就是 Trap(Trap 是一种中断)。 发生中断后,当前 CPU 执行的程序会中断,跳转到中断处理程序。内核程序开始执行,也就是开始处理系统调用。 内核处理完成后,主动触发 Trap,这样会再次发生中断,切换回用户态工作。 进程和线程 # 什么是进程和线程? # 进程(Process) 是指计算机中正在运行的一个程序实例。举例:你打开的微信就是一个进程。 线程(Thread) 也被称为轻量级进程,更加轻量。多个线程可以在同一个进程中同时执行,并且共享进程的资源比如内存空间、文件句柄、网络连接等。举例:你打开的微信里就有一个线程专门用来拉取别人发你的最新的消息。 进程和线程的区别是什么? # 下图是 Java 内存区域,我们从 JVM 的角度来说一下线程和进程之间的关系吧!\n从上图可以看出:一个进程中可以有多个线程,多个线程共享进程的堆和方法区 (JDK1.8 之后的元空间)资源,但是每个线程有自己的程序计数器、虚拟机栈 和 本地方法栈。\n总结:\n线程是进程划分成的更小的运行单位,一个进程在其执行的过程中可以产生多个线程。 线程和进程最大的不同在于基本上各进程是独立的,而各线程则不一定,因为同一进程中的线程极有可能会相互影响。 线程执行开销小,但不利于资源的管理和保护;而进程正相反。 有了进程为什么还需要线程? # 进程切换是一个开销很大的操作,线程切换的成本较低。 线程更轻量,一个进程可以创建多个线程。 多个线程可以并发处理不同的任务,更有效地利用了多处理器和多核计算机。而进程只能在一个时间干一件事,如果在执行过程中遇到阻塞问题比如 IO 阻塞就会挂起直到结果返回。 同一进程内的线程共享内存和文件,因此它们之间相互通信无须调用内核。 为什么要使用多线程? # 先从总体上来说:\n从计算机底层来说: 线程可以比作是轻量级的进程,是程序执行的最小单位,线程间的切换和调度的成本远远小于进程。另外,多核 CPU 时代意味着多个线程可以同时运行,这减少了线程上下文切换的开销。 从当代互联网发展趋势来说: 现在的系统动不动就要求百万级甚至千万级的并发量,而多线程并发编程正是开发高并发系统的基础,利用好多线程机制可以大大提高系统整体的并发能力以及性能。 再深入到计算机底层来探讨:\n单核时代:在单核时代多线程主要是为了提高单进程利用 CPU 和 IO 系统的效率。 假设只运行了一个 Java 进程的情况,当我们请求 IO 的时候,如果 Java 进程中只有一个线程,此线程被 IO 阻塞则整个进程被阻塞。CPU 和 IO 设备只有一个在运行,那么可以简单地说系统整体效率只有 50%。当使用多线程的时候,一个线程被 IO 阻塞,其他线程还可以继续使用 CPU。从而提高了 Java 进程利用系统资源的整体效率。 多核时代: 多核时代多线程主要是为了提高进程利用多核 CPU 的能力。举个例子:假如我们要计算一个复杂的任务,我们只用一个线程的话,不论系统有几个 CPU 核心,都只会有一个 CPU 核心被利用到。而创建多个线程,这些线程可以被映射到底层多个 CPU 上执行,在任务中的多个线程没有资源竞争的情况下,任务执行的效率会有显著性的提高,约等于(单核时执行时间/CPU 核心数)。 线程间的同步的方式有哪些? # 线程同步是两个或多个共享关键资源的线程的并发执行。应该同步线程以避免关键的资源使用冲突。\n下面是几种常见的线程同步的方式:\n互斥锁(Mutex) :采用互斥对象机制,只有拥有互斥对象的线程才有访问公共资源的权限。因为互斥对象只有一个,所以可以保证公共资源不会被多个线程同时访问。比如 Java 中的 synchronized 关键词和各种 Lock 都是这种机制。 读写锁(Read-Write Lock) :允许多个线程同时读取共享资源,但只有一个线程可以对共享资源进行写操作。 信号量(Semaphore) :它允许同一时刻多个线程访问同一资源,但是需要控制同一时刻访问此资源的最大线程数量。 屏障(Barrier) :屏障是一种同步原语,用于等待多个线程到达某个点再一起继续执行。当一个线程到达屏障时,它会停止执行并等待其他线程到达屏障,直到所有线程都到达屏障后,它们才会一起继续执行。比如 Java 中的 CyclicBarrier 是这种机制。 事件(Event) :Wait/Notify:通过通知操作的方式来保持多线程同步,还可以方便的实现多线程优先级的比较操作。 PCB 是什么?包含哪些信息? # PCB(Process Control Block) 即进程控制块,是操作系统中用来管理和跟踪进程的数据结构,每个进程都对应着一个独立的 PCB。你可以将 PCB 视为进程的大脑。\n当操作系统创建一个新进程时,会为该进程分配一个唯一的进程 ID,并且为该进程创建一个对应的进程控制块。当进程执行时,PCB 中的信息会不断变化,操作系统会根据这些信息来管理和调度进程。\nPCB 主要包含下面几部分的内容:\n进程的描述信息,包括进程的名称、标识符等等; 进程的调度信息,包括进程阻塞原因、进程状态(就绪、运行、阻塞等)、进程优先级(标识进程的重要程度)等等; 进程对资源的需求情况,包括 CPU 时间、内存空间、I/O 设备等等。 进程打开的文件信息,包括文件描述符、文件类型、打开模式等等。 处理机的状态信息(由处理机的各种寄存器中的内容组成的),包括通用寄存器、指令计数器、程序状态字 PSW、用户栈指针。 …… 进程有哪几种状态? # 我们一般把进程大致分为 5 种状态,这一点和线程很像!\n创建状态(new):进程正在被创建,尚未到就绪状态。 就绪状态(ready):进程已处于准备运行状态,即进程获得了除了处理器之外的一切所需资源,一旦得到处理器资源(处理器分配的时间片)即可运行。 运行状态(running):进程正在处理器上运行(单核 CPU 下任意时刻只有一个进程处于运行状态)。 阻塞状态(waiting):又称为等待状态,进程正在等待某一事件而暂停运行如等待某资源为可用或等待 IO 操作完成。即使处理器空闲,该进程也不能运行。 结束状态(terminated):进程正在从系统中消失。可能是进程正常结束或其他原因中断退出运行。 进程间的通信方式有哪些? # 下面这部分总结参考了: 《进程间通信 IPC (InterProcess Communication)》 这篇文章,推荐阅读,总结的非常不错。\n管道/匿名管道(Pipes) :用于具有亲缘关系的父子进程间或者兄弟进程之间的通信。 有名管道(Named Pipes) : 匿名管道由于没有名字,只能用于亲缘关系的进程间通信。为了克服这个缺点,提出了有名管道。有名管道严格遵循 先进先出(First In First Out) 。有名管道以磁盘文件的方式存在,可以实现本机任意两个进程通信。 信号(Signal) :信号是一种比较复杂的通信方式,用于通知接收进程某个事件已经发生; 消息队列(Message Queuing) :消息队列是消息的链表,具有特定的格式,存放在内存中并由消息队列标识符标识。管道和消息队列的通信数据都是先进先出的原则。与管道(无名管道:只存在于内存中的文件;命名管道:存在于实际的磁盘介质或者文件系统)不同的是消息队列存放在内核中,只有在内核重启(即,操作系统重启)或者显式地删除一个消息队列时,该消息队列才会被真正的删除。消息队列可以实现消息的随机查询,消息不一定要以先进先出的次序读取,也可以按消息的类型读取.比 FIFO 更有优势。消息队列克服了信号承载信息量少,管道只能承载无格式字 节流以及缓冲区大小受限等缺点。 信号量(Semaphores) :信号量是一个计数器,用于多进程对共享数据的访问,信号量的意图在于进程间同步。这种通信方式主要用于解决与同步相关的问题并避免竞争条件。 共享内存(Shared memory) :使得多个进程可以访问同一块内存空间,不同进程可以及时看到对方进程中对共享内存中数据的更新。这种方式需要依靠某种同步操作,如互斥锁和信号量等。可以说这是最有用的进程间通信方式。 套接字(Sockets) : 此方法主要用于在客户端和服务器之间通过网络进行通信。套接字是支持 TCP/IP 的网络通信的基本操作单元,可以看做是不同主机之间的进程进行双向通信的端点,简单的说就是通信的两方的一种约定,用套接字中的相关函数来完成通信过程。 进程的调度算法有哪些? # 这是一个很重要的知识点!为了确定首先执行哪个进程以及最后执行哪个进程以实现最大 CPU 利用率,计算机科学家已经定义了一些算法,它们是:\n先到先服务调度算法(FCFS,First Come, First Served) : 从就绪队列中选择一个最先进入该队列的进程为之分配资源,使它立即执行并一直执行到完成或发生某事件而被阻塞放弃占用 CPU 时再重新调度。 短作业优先的调度算法(SJF,Shortest Job First) : 从就绪队列中选出一个估计运行时间最短的进程为之分配资源,使它立即执行并一直执行到完成或发生某事件而被阻塞放弃占用 CPU 时再重新调度。 时间片轮转调度算法(RR,Round-Robin) : 时间片轮转调度是一种最古老,最简单,最公平且使用最广的算法。每个进程被分配一个时间段,称作它的时间片,即该进程允许运行的时间。 多级反馈队列调度算法(MFQ,Multi-level Feedback Queue):前面介绍的几种进程调度的算法都有一定的局限性。如短进程优先的调度算法,仅照顾了短进程而忽略了长进程 。多级反馈队列调度算法既能使高优先级的作业得到响应又能使短作业(进程)迅速完成,因而它是目前被公认的一种较好的进程调度算法,UNIX 操作系统采取的便是这种调度算法。 优先级调度算法(Priority):为每个流程分配优先级,首先执行具有最高优先级的进程,依此类推。具有相同优先级的进程以 FCFS 方式执行。可以根据内存要求,时间要求或任何其他资源要求来确定优先级。 什么是僵尸进程和孤儿进程? # 在 Unix/Linux 系统中,子进程通常是通过 fork()系统调用创建的,该调用会创建一个新的进程,该进程是原有进程的一个副本。子进程和父进程的运行是相互独立的,它们各自拥有自己的 PCB,即使父进程结束了,子进程仍然可以继续运行。\n当一个进程调用 exit()系统调用结束自己的生命时,内核会释放该进程的所有资源,包括打开的文件、占用的内存等,但是该进程对应的 PCB 依然存在于系统中。这些信息只有在父进程调用 wait()或 waitpid()系统调用时才会被释放,以便让父进程得到子进程的状态信息。\n这样的设计可以让父进程在子进程结束时得到子进程的状态信息,并且可以防止出现“僵尸进程”(即子进程结束后 PCB 仍然存在但父进程无法得到状态信息的情况)。\n僵尸进程:子进程已经终止,但是其父进程仍在运行,且父进程没有调用 wait()或 waitpid()等系统调用来获取子进程的状态信息,释放子进程占用的资源,导致子进程的 PCB 依然存在于系统中,但无法被进一步使用。这种情况下,子进程被称为“僵尸进程”。避免僵尸进程的产生,父进程需要及时调用 wait()或 waitpid()系统调用来回收子进程。 孤儿进程:一个进程的父进程已经终止或者不存在,但是该进程仍在运行。这种情况下,该进程就是孤儿进程。孤儿进程通常是由于父进程意外终止或未及时调用 wait()或 waitpid()等系统调用来回收子进程导致的。为了避免孤儿进程占用系统资源,操作系统会将孤儿进程的父进程设置为 init 进程(进程号为 1),由 init 进程来回收孤儿进程的资源。 如何查看是否有僵尸进程? # Linux 下可以使用 Top 命令查找,zombie 值表示僵尸进程的数量,为 0 则代表没有僵尸进程。\n下面这个命令可以定位僵尸进程以及该僵尸进程的父进程:\nps -A -ostat,ppid,pid,cmd |grep -e \u0026#39;^[Zz]\u0026#39; 死锁 # 什么是死锁? # 死锁(Deadlock)描述的是这样一种情况:多个进程/线程同时被阻塞,它们中的一个或者全部都在等待某个资源被释放。由于进程/线程被无限期地阻塞,因此程序不可能正常终止。\n能列举一个操作系统发生死锁的例子吗? # 假设有两个进程 A 和 B,以及两个资源 X 和 Y,它们的分配情况如下:\n进程 占用资源 需求资源 A X Y B Y X 此时,进程 A 占用资源 X 并且请求资源 Y,而进程 B 已经占用了资源 Y 并请求资源 X。两个进程都在等待对方释放资源,无法继续执行,陷入了死锁状态。\n产生死锁的四个必要条件是什么? # 互斥:资源必须处于非共享模式,即一次只有一个进程可以使用。如果另一进程申请该资源,那么必须等待直到该资源被释放为止。 占有并等待:一个进程至少应该占有一个资源,并等待另一资源,而该资源被其他进程所占有。 非抢占:资源不能被抢占。只能在持有资源的进程完成任务后,该资源才会被释放。 循环等待:有一组等待进程 {P0, P1,..., Pn}, P0 等待的资源被 P1 占有,P1 等待的资源被 P2 占有,……,Pn-1 等待的资源被 Pn 占有,Pn 等待的资源被 P0 占有。 注意 ⚠️:这四个条件是产生死锁的 必要条件 ,也就是说只要系统发生死锁,这些条件必然成立,而只要上述条件之一不满足,就不会发生死锁。\n下面是百度百科对必要条件的解释:\n如果没有事物情况 A,则必然没有事物情况 B,也就是说如果有事物情况 B 则一定有事物情况 A,那么 A 就是 B 的必要条件。从逻辑学上看,B 能推导出 A,A 就是 B 的必要条件,等价于 B 是 A 的充分条件。\n能写一个模拟产生死锁的代码吗? # 下面通过一个实际的例子来模拟下图展示的线程死锁:\npublic class DeadLockDemo { private static Object resource1 = new Object();//资源 1 private static Object resource2 = new Object();//资源 2 public static void main(String[] args) { new Thread(() -\u0026gt; { synchronized (resource1) { System.out.println(Thread.currentThread() + \u0026#34;get resource1\u0026#34;); try { Thread.sleep(1000); } catch (InterruptedException e) { e.printStackTrace(); } System.out.println(Thread.currentThread() + \u0026#34;waiting get resource2\u0026#34;); synchronized (resource2) { System.out.println(Thread.currentThread() + \u0026#34;get resource2\u0026#34;); } } }, \u0026#34;线程 1\u0026#34;).start(); new Thread(() -\u0026gt; { synchronized (resource2) { System.out.println(Thread.currentThread() + \u0026#34;get resource2\u0026#34;); try { Thread.sleep(1000); } catch (InterruptedException e) { e.printStackTrace(); } System.out.println(Thread.currentThread() + \u0026#34;waiting get resource1\u0026#34;); synchronized (resource1) { System.out.println(Thread.currentThread() + \u0026#34;get resource1\u0026#34;); } } }, \u0026#34;线程 2\u0026#34;).start(); } } Output\nThread[线程 1,5,main]get resource1 Thread[线程 2,5,main]get resource2 Thread[线程 1,5,main]waiting get resource2 Thread[线程 2,5,main]waiting get resource1 线程 A 通过 synchronized (resource1) 获得 resource1 的监视器锁,然后通过Thread.sleep(1000);让线程 A 休眠 1s 为的是让线程 B 得到执行然后获取到 resource2 的监视器锁。线程 A 和线程 B 休眠结束了都开始企图请求获取对方的资源,然后这两个线程就会陷入互相等待的状态,这也就产生了死锁。\n解决死锁的方法 # 解决死锁的方法可以从多个角度去分析,一般的情况下,有预防,避免,检测和解除四种。\n预防 是采用某种策略,限制并发进程对资源的请求,从而使得死锁的必要条件在系统执行的任何时间上都不满足。\n避免则是系统在分配资源时,根据资源的使用情况提前做出预测,从而避免死锁的发生\n检测是指系统设有专门的机构,当死锁发生时,该机构能够检测死锁的发生,并精确地确定与死锁有关的进程和资源。\n解除 是与检测相配套的一种措施,用于将进程从死锁状态下解脱出来。\n死锁的预防 # 死锁四大必要条件上面都已经列出来了,很显然,只要破坏四个必要条件中的任何一个就能够预防死锁的发生。\n破坏第一个条件 互斥条件:使得资源是可以同时访问的,这是种简单的方法,磁盘就可以用这种方法管理,但是我们要知道,有很多资源 往往是不能同时访问的 ,所以这种做法在大多数的场合是行不通的。\n破坏第三个条件 非抢占:也就是说可以采用 剥夺式调度算法,但剥夺式调度方法目前一般仅适用于 主存资源 和 处理器资源 的分配,并不适用于所有的资源,会导致 资源利用率下降。\n所以一般比较实用的 预防死锁的方法,是通过考虑破坏第二个条件和第四个条件。\n1、静态分配策略\n静态分配策略可以破坏死锁产生的第二个条件(占有并等待)。所谓静态分配策略,就是指一个进程必须在执行前就申请到它所需要的全部资源,并且知道它所要的资源都得到满足之后才开始执行。进程要么占有所有的资源然后开始执行,要么不占有资源,不会出现占有一些资源等待一些资源的情况。\n静态分配策略逻辑简单,实现也很容易,但这种策略 严重地降低了资源利用率,因为在每个进程所占有的资源中,有些资源是在比较靠后的执行时间里采用的,甚至有些资源是在额外的情况下才使用的,这样就可能造成一个进程占有了一些 几乎不用的资源而使其他需要该资源的进程产生等待 的情况。\n2、层次分配策略\n层次分配策略破坏了产生死锁的第四个条件(循环等待)。在层次分配策略下,所有的资源被分成了多个层次,一个进程得到某一次的一个资源后,它只能再申请较高一层的资源;当一个进程要释放某层的一个资源时,必须先释放所占用的较高层的资源,按这种策略,是不可能出现循环等待链的,因为那样的话,就出现了已经申请了较高层的资源,反而去申请了较低层的资源,不符合层次分配策略,证明略。\n死锁的避免 # 上面提到的 破坏 死锁产生的四个必要条件之一就可以成功 预防系统发生死锁 ,但是会导致 低效的进程运行 和 资源使用率 。而死锁的避免相反,它的角度是允许系统中同时存在四个必要条件 ,只要掌握并发进程中与每个进程有关的资源动态申请情况,做出 明智和合理的选择 ,仍然可以避免死锁,因为四大条件仅仅是产生死锁的必要条件。\n我们将系统的状态分为 安全状态 和 不安全状态 ,每当在为申请者分配资源前先测试系统状态,若把系统资源分配给申请者会产生死锁,则拒绝分配,否则接受申请,并为它分配资源。\n如果操作系统能够保证所有的进程在有限的时间内得到需要的全部资源,则称系统处于安全状态,否则说系统是不安全的。很显然,系统处于安全状态则不会发生死锁,系统若处于不安全状态则可能发生死锁。\n那么如何保证系统保持在安全状态呢?通过算法,其中最具有代表性的 避免死锁算法 就是 Dijkstra 的银行家算法,银行家算法用一句话表达就是:当一个进程申请使用资源的时候,银行家算法 通过先 试探 分配给该进程资源,然后通过 安全性算法 判断分配后系统是否处于安全状态,若不安全则试探分配作废,让该进程继续等待,若能够进入到安全的状态,则就 真的分配资源给该进程。\n银行家算法详情可见: 《一句话+一张图说清楚——银行家算法》 。\n操作系统教程书中讲述的银行家算法也比较清晰,可以一看.\n死锁的避免(银行家算法)改善了 资源使用率低的问题 ,但是它要不断地检测每个进程对各类资源的占用和申请情况,以及做 安全性检查 ,需要花费较多的时间。\n死锁的检测 # 对资源的分配加以限制可以 预防和避免 死锁的发生,但是都不利于各进程对系统资源的充分共享。解决死锁问题的另一条途径是 死锁检测和解除 (这里突然联想到了乐观锁和悲观锁,感觉死锁的检测和解除就像是 乐观锁 ,分配资源时不去提前管会不会发生死锁了,等到真的死锁出现了再来解决嘛,而 死锁的预防和避免 更像是悲观锁,总是觉得死锁会出现,所以在分配资源的时候就很谨慎)。\n这种方法对资源的分配不加以任何限制,也不采取死锁避免措施,但系统 定时地运行一个 “死锁检测” 的程序,判断系统内是否出现死锁,如果检测到系统发生了死锁,再采取措施去解除它。\n进程-资源分配图 # 操作系统中的每一刻时刻的系统状态都可以用进程-资源分配图来表示,进程-资源分配图是描述进程和资源申请及分配关系的一种有向图,可用于检测系统是否处于死锁状态。\n用一个方框表示每一个资源类,方框中的黑点表示该资源类中的各个资源,用一个圆圈表示每一个进程,用 有向边 来表示进程申请资源和资源被分配的情况。\n图中 2-21 是进程-资源分配图的一个例子,其中共有三个资源类,每个进程的资源占有和申请情况已清楚地表示在图中。在这个例子中,由于存在 占有和等待资源的环路 ,导致一组进程永远处于等待资源的状态,发生了 死锁。\n进程-资源分配图中存在环路并不一定是发生了死锁。因为循环等待资源仅仅是死锁发生的必要条件,而不是充分条件。图 2-22 便是一个有环路而无死锁的例子。虽然进程 P1 和进程 P3 分别占用了一个资源 R1 和一个资源 R2,并且因为等待另一个资源 R2 和另一个资源 R1 形成了环路,但进程 P2 和进程 P4 分别占有了一个资源 R1 和一个资源 R2,它们申请的资源得到了满足,在有限的时间里会归还资源,于是进程 P1 或 P3 都能获得另一个所需的资源,环路自动解除,系统也就不存在死锁状态了。\n死锁检测步骤 # 知道了死锁检测的原理,我们可以利用下列步骤编写一个 死锁检测 程序,检测系统是否产生了死锁。\n如果进程-资源分配图中无环路,则此时系统没有发生死锁 如果进程-资源分配图中有环路,且每个资源类仅有一个资源,则系统中已经发生了死锁。 如果进程-资源分配图中有环路,且涉及到的资源类有多个资源,此时系统未必会发生死锁。如果能在进程-资源分配图中找出一个 既不阻塞又非独立的进程 ,该进程能够在有限的时间内归还占有的资源,也就是把边给消除掉了,重复此过程,直到能在有限的时间内 消除所有的边 ,则不会发生死锁,否则会发生死锁。(消除边的过程类似于 拓扑排序) 死锁的解除 # 当死锁检测程序检测到存在死锁发生时,应设法让其解除,让系统从死锁状态中恢复过来,常用的解除死锁的方法有以下四种:\n立即结束所有进程的执行,重新启动操作系统:这种方法简单,但以前所在的工作全部作废,损失很大。 撤销涉及死锁的所有进程,解除死锁后继续运行:这种方法能彻底打破死锁的循环等待条件,但将付出很大代价,例如有些进程可能已经计算了很长时间,由于被撤销而使产生的部分结果也被消除了,再重新执行时还要再次进行计算。 逐个撤销涉及死锁的进程,回收其资源直至死锁解除。 抢占资源:从涉及死锁的一个或几个进程中抢占资源,把夺得的资源再分配给涉及死锁的进程直至死锁解除。 参考 # 《计算机操作系统—汤小丹》第四版 《深入理解计算机系统》 《重学操作系统》 操作系统为什么要分用户态和内核态: https://blog.csdn.net/chen134225/article/details/81783980 从根上理解用户态与内核态: https://juejin.cn/post/6923863670132850701 什么是僵尸进程与孤儿进程: https://blog.csdn.net/a745233700/article/details/120715371 "},{"id":595,"href":"/zh/docs/technology/Interview/cs-basics/operating-system/operating-system-basic-questions-02/","title":"操作系统常见面试题总结(下)","section":"Operating System","content":" 内存管理 # 内存管理主要做了什么? # 操作系统的内存管理非常重要,主要负责下面这些事情:\n内存的分配与回收:对进程所需的内存进行分配和释放,malloc 函数:申请内存,free 函数:释放内存。 地址转换:将程序中的虚拟地址转换成内存中的物理地址。 内存扩充:当系统没有足够的内存时,利用虚拟内存技术或自动覆盖技术,从逻辑上扩充内存。 内存映射:将一个文件直接映射到进程的进程空间中,这样可以通过内存指针用读写内存的办法直接存取文件内容,速度更快。 内存优化:通过调整内存分配策略和回收算法来优化内存使用效率。 内存安全:保证进程之间使用内存互不干扰,避免一些恶意程序通过修改内存来破坏系统的安全性。 …… 什么是内存碎片? # 内存碎片是由内存的申请和释放产生的,通常分为下面两种:\n内部内存碎片(Internal Memory Fragmentation,简称为内存碎片):已经分配给进程使用但未被使用的内存。导致内部内存碎片的主要原因是,当采用固定比例比如 2 的幂次方进行内存分配时,进程所分配的内存可能会比其实际所需要的大。举个例子,一个进程只需要 65 字节的内存,但为其分配了 128(2^7) 大小的内存,那 63 字节的内存就成为了内部内存碎片。 外部内存碎片(External Memory Fragmentation,简称为外部碎片):由于未分配的连续内存区域太小,以至于不能满足任意进程所需要的内存分配请求,这些小片段且不连续的内存空间被称为外部碎片。也就是说,外部内存碎片指的是那些并未分配给进程但又不能使用的内存。我们后面介绍的分段机制就会导致外部内存碎片。 内存碎片会导致内存利用率下降,如何减少内存碎片是内存管理要非常重视的一件事情。\n常见的内存管理方式有哪些? # 内存管理方式可以简单分为下面两种:\n连续内存管理:为一个用户程序分配一个连续的内存空间,内存利用率一般不高。 非连续内存管理:允许一个程序使用的内存分布在离散或者说不相邻的内存中,相对更加灵活一些。 连续内存管理 # 块式管理 是早期计算机操作系统的一种连续内存管理方式,存在严重的内存碎片问题。块式管理会将内存分为几个固定大小的块,每个块中只包含一个进程。如果程序运行需要内存的话,操作系统就分配给它一块,如果程序运行只需要很小的空间的话,分配的这块内存很大一部分几乎被浪费了。这些在每个块中未被利用的空间,我们称之为内部内存碎片。除了内部内存碎片之外,由于两个内存块之间可能还会有外部内存碎片,这些不连续的外部内存碎片由于太小了无法再进行分配。\n在 Linux 系统中,连续内存管理采用了 伙伴系统(Buddy System)算法 来实现,这是一种经典的连续内存分配算法,可以有效解决外部内存碎片的问题。伙伴系统的主要思想是将内存按 2 的幂次划分(每一块内存大小都是 2 的幂次比如 2^6=64 KB),并将相邻的内存块组合成一对伙伴(注意:必须是相邻的才是伙伴)。\n当进行内存分配时,伙伴系统会尝试找到大小最合适的内存块。如果找到的内存块过大,就将其一分为二,分成两个大小相等的伙伴块。如果还是大的话,就继续切分,直到到达合适的大小为止。\n假设两块相邻的内存块都被释放,系统会将这两个内存块合并,进而形成一个更大的内存块,以便后续的内存分配。这样就可以减少内存碎片的问题,提高内存利用率。\n虽然解决了外部内存碎片的问题,但伙伴系统仍然存在内存利用率不高的问题(内部内存碎片)。这主要是因为伙伴系统只能分配大小为 2n 的内存块,因此当需要分配的内存大小不是 2n 的整数倍时,会浪费一定的内存空间。举个例子:如果要分配 65 大小的内存快,依然需要分配 2^7=128 大小的内存块。\n对于内部内存碎片的问题,Linux 采用 SLAB 进行解决。由于这部分内容不是本篇文章的重点,这里就不详细介绍了。\n非连续内存管理 # 非连续内存管理存在下面 3 种方式:\n段式管理:以段(一段连续的物理内存)的形式管理/分配物理内存。应用程序的虚拟地址空间被分为大小不等的段,段是有实际意义的,每个段定义了一组逻辑信息,例如有主程序段 MAIN、子程序段 X、数据段 D 及栈段 S 等。 页式管理:把物理内存分为连续等长的物理页,应用程序的虚拟地址空间也被划分为连续等长的虚拟页,是现代操作系统广泛使用的一种内存管理方式。 段页式管理机制:结合了段式管理和页式管理的一种内存管理机制,把物理内存先分成若干段,每个段又继续分成若干大小相等的页。 虚拟内存 # 什么是虚拟内存?有什么用? # 虚拟内存(Virtual Memory) 是计算机系统内存管理非常重要的一个技术,本质上来说它只是逻辑存在的,是一个假想出来的内存空间,主要作用是作为进程访问主存(物理内存)的桥梁并简化内存管理。\n总结来说,虚拟内存主要提供了下面这些能力:\n隔离进程:物理内存通过虚拟地址空间访问,虚拟地址空间与进程一一对应。每个进程都认为自己拥有了整个物理内存,进程之间彼此隔离,一个进程中的代码无法更改正在由另一进程或操作系统使用的物理内存。 提升物理内存利用率:有了虚拟地址空间后,操作系统只需要将进程当前正在使用的部分数据或指令加载入物理内存。 简化内存管理:进程都有一个一致且私有的虚拟地址空间,程序员不用和真正的物理内存打交道,而是借助虚拟地址空间访问物理内存,从而简化了内存管理。 多个进程共享物理内存:进程在运行过程中,会加载许多操作系统的动态库。这些库对于每个进程而言都是公用的,它们在内存中实际只会加载一份,这部分称为共享内存。 提高内存使用安全性:控制进程对物理内存的访问,隔离不同进程的访问权限,提高系统的安全性。 提供更大的可使用内存空间:可以让程序拥有超过系统物理内存大小的可用内存空间。这是因为当物理内存不够用时,可以利用磁盘充当,将物理内存页(通常大小为 4 KB)保存到磁盘文件(会影响读写速度),数据或代码页会根据需要在物理内存与磁盘之间移动。 没有虚拟内存有什么问题? # 如果没有虚拟内存的话,程序直接访问和操作的都是物理内存,看似少了一层中介,但多了很多问题。\n具体有什么问题呢? 这里举几个例子说明(参考虚拟内存提供的能力回答这个问题):\n用户程序可以访问任意物理内存,可能会不小心操作到系统运行必需的内存,进而造成操作系统崩溃,严重影响系统的安全。 同时运行多个程序容易崩溃。比如你想同时运行一个微信和一个 QQ 音乐,微信在运行的时候给内存地址 1xxx 赋值后,QQ 音乐也同样给内存地址 1xxx 赋值,那么 QQ 音乐对内存的赋值就会覆盖微信之前所赋的值,这就可能会造成微信这个程序会崩溃。 程序运行过程中使用的所有数据或指令都要载入物理内存,根据局部性原理,其中很大一部分可能都不会用到,白白占用了宝贵的物理内存资源。 …… 什么是虚拟地址和物理地址? # 物理地址(Physical Address) 是真正的物理内存中地址,更具体点来说是内存地址寄存器中的地址。程序中访问的内存地址不是物理地址,而是 虚拟地址(Virtual Address) 。\n也就是说,我们编程开发的时候实际就是在和虚拟地址打交道。比如在 C 语言中,指针里面存储的数值就可以理解成为内存里的一个地址,这个地址也就是我们说的虚拟地址。\n操作系统一般通过 CPU 芯片中的一个重要组件 MMU(Memory Management Unit,内存管理单元) 将虚拟地址转换为物理地址,这个过程被称为 地址翻译/地址转换(Address Translation) 。\n通过 MMU 将虚拟地址转换为物理地址后,再通过总线传到物理内存设备,进而完成相应的物理内存读写请求。\nMMU 将虚拟地址翻译为物理地址的主要机制有两种: 分段机制 和 分页机制 。\n什么是虚拟地址空间和物理地址空间? # 虚拟地址空间是虚拟地址的集合,是虚拟内存的范围。每一个进程都有一个一致且私有的虚拟地址空间。 物理地址空间是物理地址的集合,是物理内存的范围。 虚拟地址与物理内存地址是如何映射的? # MMU 将虚拟地址翻译为物理地址的主要机制有 3 种:\n分段机制 分页机制 段页机制 其中,现代操作系统广泛采用分页机制,需要重点关注!\n分段机制 # 分段机制(Segmentation) 以段(一段 连续 的物理内存)的形式管理/分配物理内存。应用程序的虚拟地址空间被分为大小不等的段,段是有实际意义的,每个段定义了一组逻辑信息,例如有主程序段 MAIN、子程序段 X、数据段 D 及栈段 S 等。\n段表有什么用?地址翻译过程是怎样的? # 分段管理通过 段表(Segment Table) 映射虚拟地址和物理地址。\n分段机制下的虚拟地址由两部分组成:\n段号:标识着该虚拟地址属于整个虚拟地址空间中的哪一个段。 段内偏移量:相对于该段起始地址的偏移量。 具体的地址翻译过程如下:\nMMU 首先解析得到虚拟地址中的段号; 通过段号去该应用程序的段表中取出对应的段信息(找到对应的段表项); 从段信息中取出该段的起始地址(物理地址)加上虚拟地址中的段内偏移量得到最终的物理地址。 段表中还存有诸如段长(可用于检查虚拟地址是否超出合法范围)、段类型(该段的类型,例如代码段、数据段等)等信息。\n通过段号一定要找到对应的段表项吗?得到最终的物理地址后对应的物理内存一定存在吗?\n不一定。段表项可能并不存在:\n段表项被删除:软件错误、软件恶意行为等情况可能会导致段表项被删除。 段表项还未创建:如果系统内存不足或者无法分配到连续的物理内存块就会导致段表项无法被创建。 分段机制为什么会导致内存外部碎片? # 分段机制容易出现外部内存碎片,即在段与段之间留下碎片空间(不足以映射给虚拟地址空间中的段)。从而造成物理内存资源利用率的降低。\n举个例子:假设可用物理内存为 5G 的系统使用分段机制分配内存。现在有 4 个进程,每个进程的内存占用情况如下:\n进程 1:0~1G(第 1 段) 进程 2:1~3G(第 2 段) 进程 3:3~4.5G(第 3 段) 进程 4:4.5~5G(第 4 段) 此时,我们关闭了进程 1 和进程 4,则第 1 段和第 4 段的内存会被释放,空闲物理内存还有 1.5G。由于这 1.5G 物理内存并不是连续的,导致没办法将空闲的物理内存分配给一个需要 1.5G 物理内存的进程。\n分页机制 # 分页机制(Paging) 把主存(物理内存)分为连续等长的物理页,应用程序的虚拟地址空间划也被分为连续等长的虚拟页。现代操作系统广泛采用分页机制。\n注意:这里的页是连续等长的,不同于分段机制下不同长度的段。\n在分页机制下,应用程序虚拟地址空间中的任意虚拟页可以被映射到物理内存中的任意物理页上,因此可以实现物理内存资源的离散分配。分页机制按照固定页大小分配物理内存,使得物理内存资源易于管理,可有效避免分段机制中外部内存碎片的问题。\n页表有什么用?地址翻译过程是怎样的? # 分页管理通过 页表(Page Table) 映射虚拟地址和物理地址。我这里画了一张基于单级页表进行地址翻译的示意图。\n在分页机制下,每个进程都会有一个对应的页表。\n分页机制下的虚拟地址由两部分组成:\n页号:通过虚拟页号可以从页表中取出对应的物理页号; 页内偏移量:物理页起始地址+页内偏移量=物理内存地址。 具体的地址翻译过程如下:\nMMU 首先解析得到虚拟地址中的虚拟页号; 通过虚拟页号去该应用程序的页表中取出对应的物理页号(找到对应的页表项); 用该物理页号对应的物理页起始地址(物理地址)加上虚拟地址中的页内偏移量得到最终的物理地址。 页表中还存有诸如访问标志(标识该页面有没有被访问过)、脏数据标识位等信息。\n通过虚拟页号一定要找到对应的物理页号吗?找到了物理页号得到最终的物理地址后对应的物理页一定存在吗?\n不一定!可能会存在 页缺失 。也就是说,物理内存中没有对应的物理页或者物理内存中有对应的物理页但虚拟页还未和物理页建立映射(对应的页表项不存在)。关于页缺失的内容,后面会详细介绍到。\n单级页表有什么问题?为什么需要多级页表? # 以 32 位的环境为例,虚拟地址空间范围共有 232(4G)。假设 一个页的大小是 212(4KB),那页表项共有 4G / 4K = 2^20 个。每个页表项为一个地址,占用 4 字节,2^20 * 2^2 / 1024 * 1024= 4MB。也就是说一个程序啥都不干,页表大小就得占用 4M。\n系统运行的应用程序多起来的话,页表的开销还是非常大的。而且,绝大部分应用程序可能只能用到页表中的几项,其他的白白浪费了。\n为了解决这个问题,操作系统引入了 多级页表 ,多级页表对应多个页表,每个页表与前一个页表相关联。32 位系统一般为二级页表,64 位系统一般为四级页表。\n这里以二级页表为例进行介绍:二级列表分为一级页表和二级页表。一级页表共有 1024 个页表项,一级页表又关联二级页表,二级页表同样共有 1024 个页表项。二级页表中的一级页表项是一对多的关系,二级页表按需加载(只会用到很少一部分二级页表),进而节省空间占用。\n假设只需要 2 个二级页表,那两级页表的内存占用情况为: 4KB(一级页表占用) + 4KB * 2(二级页表占用) = 12 KB。\n多级页表属于时间换空间的典型场景,利用增加页表查询的次数减少页表占用的空间。\nTLB 有什么用?使用 TLB 之后的地址翻译流程是怎样的? # 为了提高虚拟地址到物理地址的转换速度,操作系统在 页表方案 基础之上引入了 转址旁路缓存(Translation Lookaside Buffer,TLB,也被称为快表) 。\n在主流的 AArch64 和 x86-64 体系结构下,TLB 属于 (Memory Management Unit,内存管理单元) 内部的单元,本质上就是一块高速缓存(Cache),缓存了虚拟页号到物理页号的映射关系,你可以将其简单看作是存储着键(虚拟页号)值(物理页号)对的哈希表。\n使用 TLB 之后的地址翻译流程是这样的:\n用虚拟地址中的虚拟页号作为 key 去 TLB 中查询; 如果能查到对应的物理页的话,就不用再查询页表了,这种情况称为 TLB 命中(TLB hit)。 如果不能查到对应的物理页的话,还是需要去查询主存中的页表,同时将页表中的该映射表项添加到 TLB 中,这种情况称为 TLB 未命中(TLB miss)。 当 TLB 填满后,又要登记新页时,就按照一定的淘汰策略淘汰掉快表中的一个页。 由于页表也在主存中,因此在没有 TLB 之前,每次读写内存数据时 CPU 要访问两次主存。有了 TLB 之后,对于存在于 TLB 中的页表数据只需要访问一次主存即可。\nTLB 的设计思想非常简单,但命中率往往非常高,效果很好。这就是因为被频繁访问的页就是其中的很小一部分。\n看完了之后你会发现快表和我们平时经常在开发系统中使用的缓存(比如 Redis)很像,的确是这样的,操作系统中的很多思想、很多经典的算法,你都可以在我们日常开发使用的各种工具或者框架中找到它们的影子。\n换页机制有什么用? # 换页机制的思想是当物理内存不够用的时候,操作系统选择将一些物理页的内容放到磁盘上去,等要用到的时候再将它们读取到物理内存中。也就是说,换页机制利用磁盘这种较低廉的存储设备扩展的物理内存。\n这也就解释了一个日常使用电脑常见的问题:为什么操作系统中所有进程运行所需的物理内存即使比真实的物理内存要大一些,这些进程也是可以正常运行的,只是运行速度会变慢。\n这同样是一种时间换空间的策略,你用 CPU 的计算时间,页的调入调出花费的时间,换来了一个虚拟的更大的物理内存空间来支持程序的运行。\n什么是页缺失? # 根据维基百科:\n页缺失(Page Fault,又名硬错误、硬中断、分页错误、寻页缺失、缺页中断、页故障等)指的是当软件试图访问已映射在虚拟地址空间中,但是目前并未被加载在物理内存中的一个分页时,由 MMU 所发出的中断。\n常见的页缺失有下面这两种:\n硬性页缺失(Hard Page Fault):物理内存中没有对应的物理页。于是,Page Fault Handler 会指示 CPU 从已经打开的磁盘文件中读取相应的内容到物理内存,而后交由 MMU 建立相应的虚拟页和物理页的映射关系。 软性页缺失(Soft Page Fault):物理内存中有对应的物理页,但虚拟页还未和物理页建立映射。于是,Page Fault Handler 会指示 MMU 建立相应的虚拟页和物理页的映射关系。 发生上面这两种缺页错误的时候,应用程序访问的是有效的物理内存,只是出现了物理页缺失或者虚拟页和物理页的映射关系未建立的问题。如果应用程序访问的是无效的物理内存的话,还会出现 无效缺页错误(Invalid Page Fault) 。\n常见的页面置换算法有哪些? # 当发生硬性页缺失时,如果物理内存中没有空闲的物理页面可用的话。操作系统就必须将物理内存中的一个物理页淘汰出去,这样就可以腾出空间来加载新的页面了。\n用来选择淘汰哪一个物理页的规则叫做 页面置换算法 ,我们可以把页面置换算法看成是淘汰物物理页的规则。\n页缺失太频繁的发生会非常影响性能,一个好的页面置换算法应该是可以减少页缺失出现的次数。\n常见的页面置换算法有下面这 5 种(其他还有很多页面置换算法都是基于这些算法改进得来的):\n最佳页面置换算法(OPT,Optimal):优先选择淘汰的页面是以后永不使用的,或者是在最长时间内不再被访问的页面,这样可以保证获得最低的缺页率。但由于人们目前无法预知进程在内存下的若干页面中哪个是未来最长时间内不再被访问的,因而该算法无法实现,只是理论最优的页面置换算法,可以作为衡量其他置换算法优劣的标准。 先进先出页面置换算法(FIFO,First In First Out) : 最简单的一种页面置换算法,总是淘汰最先进入内存的页面,即选择在内存中驻留时间最久的页面进行淘汰。该算法易于实现和理解,一般只需要通过一个 FIFO 队列即可满足需求。不过,它的性能并不是很好。 最近最久未使用页面置换算法(LRU ,Least Recently Used):LRU 算法赋予每个页面一个访问字段,用来记录一个页面自上次被访问以来所经历的时间 T,当须淘汰一个页面时,选择现有页面中其 T 值最大的,即最近最久未使用的页面予以淘汰。LRU 算法是根据各页之前的访问情况来实现,因此是易于实现的。OPT 算法是根据各页未来的访问情况来实现,因此是不可实现的。 最少使用页面置换算法(LFU,Least Frequently Used) : 和 LRU 算法比较像,不过该置换算法选择的是之前一段时间内使用最少的页面作为淘汰页。 时钟页面置换算法(Clock):可以认为是一种最近未使用算法,即逐出的页面都是最近没有使用的那个。 FIFO 页面置换算法性能为何不好?\n主要原因主要有二:\n经常访问或者需要长期存在的页面会被频繁调入调出:较早调入的页往往是经常被访问或者需要长期存在的页,这些页会被反复调入和调出。 存在 Belady 现象:被置换的页面并不是进程不会访问的,有时就会出现分配的页面数增多但缺页率反而提高的异常现象。出现该异常的原因是因为 FIFO 算法只考虑了页面进入内存的顺序,而没有考虑页面访问的频率和紧迫性。 哪一种页面置换算法实际用的比较多?\nLRU 算法是实际使用中应用的比较多,也被认为是最接近 OPT 的页面置换算法。\n不过,需要注意的是,实际应用中这些算法会被做一些改进,就比如 InnoDB Buffer Pool( InnoDB 缓冲池,MySQL 数据库中用于管理缓存页面的机制)就改进了传统的 LRU 算法,使用了一种称为\u0026quot;Adaptive LRU\u0026quot;的算法(同时结合了 LRU 和 LFU 算法的思想)。\n分页机制和分段机制有哪些共同点和区别? # 共同点:\n都是非连续内存管理的方式。 都采用了地址映射的方法,将虚拟地址映射到物理地址,以实现对内存的管理和保护。 区别:\n分页机制以页面为单位进行内存管理,而分段机制以段为单位进行内存管理。页的大小是固定的,由操作系统决定,通常为 2 的幂次方。而段的大小不固定,取决于我们当前运行的程序。 页是物理单位,即操作系统将物理内存划分成固定大小的页面,每个页面的大小通常是 2 的幂次方,例如 4KB、8KB 等等。而段则是逻辑单位,是为了满足程序对内存空间的逻辑需求而设计的,通常根据程序中数据和代码的逻辑结构来划分。 分段机制容易出现外部内存碎片,即在段与段之间留下碎片空间(不足以映射给虚拟地址空间中的段)。分页机制解决了外部内存碎片的问题,但仍然可能会出现内部内存碎片。 分页机制采用了页表来完成虚拟地址到物理地址的映射,页表通过一级页表和二级页表来实现多级映射;而分段机制则采用了段表来完成虚拟地址到物理地址的映射,每个段表项中记录了该段的起始地址和长度信息。 分页机制对程序没有任何要求,程序只需要按照虚拟地址进行访问即可;而分段机制需要程序员将程序分为多个段,并且显式地使用段寄存器来访问不同的段。 段页机制 # 结合了段式管理和页式管理的一种内存管理机制,把物理内存先分成若干段,每个段又继续分成若干大小相等的页。\n在段页式机制下,地址翻译的过程分为两个步骤:\n段式地址映射。 页式地址映射。 局部性原理 # 要想更好地理解虚拟内存技术,必须要知道计算机中著名的 局部性原理(Locality Principle)。另外,局部性原理既适用于程序结构,也适用于数据结构,是非常重要的一个概念。\n局部性原理是指在程序执行过程中,数据和指令的访问存在一定的空间和时间上的局部性特点。其中,时间局部性是指一个数据项或指令在一段时间内被反复使用的特点,空间局部性是指一个数据项或指令在一段时间内与其相邻的数据项或指令被反复使用的特点。\n在分页机制中,页表的作用是将虚拟地址转换为物理地址,从而完成内存访问。在这个过程中,局部性原理的作用体现在两个方面:\n时间局部性:由于程序中存在一定的循环或者重复操作,因此会反复访问同一个页或一些特定的页,这就体现了时间局部性的特点。为了利用时间局部性,分页机制中通常采用缓存机制来提高页面的命中率,即将最近访问过的一些页放入缓存中,如果下一次访问的页已经在缓存中,就不需要再次访问内存,而是直接从缓存中读取。 空间局部性:由于程序中数据和指令的访问通常是具有一定的空间连续性的,因此当访问某个页时,往往会顺带访问其相邻的一些页。为了利用空间局部性,分页机制中通常采用预取技术来预先将相邻的一些页读入内存缓存中,以便在未来访问时能够直接使用,从而提高访问速度。 总之,局部性原理是计算机体系结构设计的重要原则之一,也是许多优化算法的基础。在分页机制中,利用时间局部性和空间局部性,采用缓存和预取技术,可以提高页面的命中率,从而提高内存访问效率\n文件系统 # 文件系统主要做了什么? # 文件系统主要负责管理和组织计算机存储设备上的文件和目录,其功能包括以下几个方面:\n存储管理:将文件数据存储到物理存储介质中,并且管理空间分配,以确保每个文件都有足够的空间存储,并避免文件之间发生冲突。 文件管理:文件的创建、删除、移动、重命名、压缩、加密、共享等等。 目录管理:目录的创建、删除、移动、重命名等等。 文件访问控制:管理不同用户或进程对文件的访问权限,以确保用户只能访问其被授权访问的文件,以保证文件的安全性和保密性。 硬链接和软链接有什么区别? # 在 Linux/类 Unix 系统上,文件链接(File Link)是一种特殊的文件类型,可以在文件系统中指向另一个文件。常见的文件链接类型有两种:\n1、硬链接(Hard Link)\n在 Linux/类 Unix 文件系统中,每个文件和目录都有一个唯一的索引节点(inode)号,用来标识该文件或目录。硬链接通过 inode 节点号建立连接,硬链接和源文件的 inode 节点号相同,两者对文件系统来说是完全平等的(可以看作是互为硬链接,源头是同一份文件),删除其中任何一个对另外一个没有影响,可以通过给文件设置硬链接文件来防止重要文件被误删。 只有删除了源文件和所有对应的硬链接文件,该文件才会被真正删除。 硬链接具有一些限制,不能对目录以及不存在的文件创建硬链接,并且,硬链接也不能跨越文件系统。 ln 命令用于创建硬链接。 2、软链接(Symbolic Link 或 Symlink)\n软链接和源文件的 inode 节点号不同,而是指向一个文件路径。 源文件删除后,软链接依然存在,但是指向的是一个无效的文件路径。 软连接类似于 Windows 系统中的快捷方式。 不同于硬链接,可以对目录或者不存在的文件创建软链接,并且,软链接可以跨越文件系统。 ln -s 命令用于创建软链接。 硬链接为什么不能跨文件系统? # 我们之前提到过,硬链接是通过 inode 节点号建立连接的,而硬链接和源文件共享相同的 inode 节点号。\n然而,每个文件系统都有自己的独立 inode 表,且每个 inode 表只维护该文件系统内的 inode。如果在不同的文件系统之间创建硬链接,可能会导致 inode 节点号冲突的问题,即目标文件的 inode 节点号已经在该文件系统中被使用。\n提高文件系统性能的方式有哪些? # 优化硬件:使用高速硬件设备(如 SSD、NVMe)替代传统的机械硬盘,使用 RAID(Redundant Array of Inexpensive Disks)等技术提高磁盘性能。 选择合适的文件系统选型:不同的文件系统具有不同的特性,对于不同的应用场景选择合适的文件系统可以提高系统性能。 运用缓存:访问磁盘的效率比较低,可以运用缓存来减少磁盘的访问次数。不过,需要注意缓存命中率,缓存命中率过低的话,效果太差。 避免磁盘过度使用:注意磁盘的使用率,避免将磁盘用满,尽量留一些剩余空间,以免对文件系统的性能产生负面影响。 对磁盘进行合理的分区:合理的磁盘分区方案,能够使文件系统在不同的区域存储文件,从而减少文件碎片,提高文件读写性能。 常见的磁盘调度算法有哪些? # 磁盘调度算法是操作系统中对磁盘访问请求进行排序和调度的算法,其目的是提高磁盘的访问效率。\n一次磁盘读写操作的时间由磁盘寻道/寻找时间、延迟时间和传输时间决定。磁盘调度算法可以通过改变到达磁盘请求的处理顺序,减少磁盘寻道时间和延迟时间。\n常见的磁盘调度算法有下面这 6 种(其他还有很多磁盘调度算法都是基于这些算法改进得来的):\n先来先服务算法(First-Come First-Served,FCFS):按照请求到达磁盘调度器的顺序进行处理,先到达的请求的先被服务。FCFS 算法实现起来比较简单,不存在算法开销。不过,由于没有考虑磁头移动的路径和方向,平均寻道时间较长。同时,该算法容易出现饥饿问题,即一些后到的磁盘请求可能需要等待很长时间才能得到服务。 最短寻道时间优先算法(Shortest Seek Time First,SSTF):也被称为最佳服务优先(Shortest Service Time First,SSTF)算法,优先选择距离当前磁头位置最近的请求进行服务。SSTF 算法能够最小化磁头的寻道时间,但容易出现饥饿问题,即磁头附近的请求不断被服务,远离磁头的请求长时间得不到响应。实际应用中,需要优化一下该算法的实现,避免出现饥饿问题。 扫描算法(SCAN):也被称为电梯(Elevator)算法,基本思想和电梯非常类似。磁头沿着一个方向扫描磁盘,如果经过的磁道有请求就处理,直到到达磁盘的边界,然后改变移动方向,依此往复。SCAN 算法能够保证所有的请求得到服务,解决了饥饿问题。但是,如果磁头从一个方向刚扫描完,请求才到的话。这个请求就需要等到磁头从相反方向过来之后才能得到处理。 循环扫描算法(Circular Scan,C-SCAN):SCAN 算法的变体,只在磁盘的一侧进行扫描,并且只按照一个方向扫描,直到到达磁盘边界,然后回到磁盘起点,重新开始循环。 边扫描边观察算法(LOOK):SCAN 算法中磁头到了磁盘的边界才改变移动方向,这样可能会做很多无用功,因为磁头移动方向上可能已经没有请求需要处理了。LOOK 算法对 SCAN 算法进行了改进,如果磁头移动方向上已经没有别的请求,就可以立即改变磁头移动方向,依此往复。也就是边扫描边观察指定方向上还有无请求,因此叫 LOOK。 均衡循环扫描算法(C-LOOK):C-SCAN 只有到达磁盘边界时才能改变磁头移动方向,并且磁头返回时也需要返回到磁盘起点,这样可能会做很多无用功。C-LOOK 算法对 C-SCAN 算法进行了改进,如果磁头移动的方向上已经没有磁道访问请求了,就可以立即让磁头返回,并且磁头只需要返回到有磁道访问请求的位置即可。 参考 # 《计算机操作系统—汤小丹》第四版 《深入理解计算机系统》 《重学操作系统》 《现代操作系统原理与实现》 王道考研操作系统知识点整理: https://wizardforcel.gitbooks.io/wangdaokaoyan-os/content/13.html 内存管理之伙伴系统与 SLAB: https://blog.csdn.net/qq_44272681/article/details/124199068 为什么 Linux 需要虚拟内存: https://draveness.me/whys-the-design-os-virtual-memory/ 程序员的自我修养(七):内存缺页错误: https://liam.page/2017/09/01/page-fault/ 虚拟内存的那点事儿: https://juejin.cn/post/6844903507594575886 "},{"id":596,"href":"/zh/docs/technology/Interview/high-performance/sql-optimization/","title":"常见SQL优化手段总结(付费)","section":"High Performance","content":"常见 SQL 优化手段总结 相关的内容为我的 知识星球(点击链接即可查看详细介绍以及加入方法)专属内容,已经整理到了 《Java 面试指北》中。\n"},{"id":597,"href":"/zh/docs/technology/Interview/system-design/security/encryption-algorithms/","title":"常见加密算法总结","section":"Security","content":"加密算法是一种用数学方法对数据进行变换的技术,目的是保护数据的安全,防止被未经授权的人读取或修改。加密算法可以分为三大类:对称加密算法、非对称加密算法和哈希算法(也叫摘要算法)。\n日常开发中常见的需要用到加密算法的场景:\n保存在数据库中的密码需要加盐之后使用哈希算法(比如 BCrypt)进行加密。 保存在数据库中的银行卡号、身份号这类敏感数据需要使用对称加密算法(比如 AES)保存。 网络传输的敏感数据比如银行卡号、身份号需要用 HTTPS + 非对称加密算法(如 RSA)来保证传输数据的安全性。 …… ps: 严格上来说,哈希算法其实不属于加密算法,只是可以用到某些加密场景中(例如密码加密),两者可以看作是并列关系。加密算法通常指的是可以将明文转换为密文,并且能够通过某种方式(如密钥)再将密文还原为明文的算法。而哈希算法是一种单向过程,它将输入信息转换成一个固定长度的、看似随机的哈希值,但这个过程是不可逆的,也就是说,不能从哈希值还原出原始信息。\n哈希算法 # 哈希算法也叫散列函数或摘要算法,它的作用是对任意长度的数据生成一个固定长度的唯一标识,也叫哈希值、散列值或消息摘要(后文统称为哈希值)。\n哈希算法的是不可逆的,你无法通过哈希之后的值再得到原值。\n哈希值的作用是可以用来验证数据的完整性和一致性。\n举两个实际的例子:\n保存密码到数据库时使用哈希算法进行加密,可以通过比较用户输入密码的哈希值和数据库保存的哈希值是否一致,来判断密码是否正确。 我们下载一个文件时,可以通过比较文件的哈希值和官方提供的哈希值是否一致,来判断文件是否被篡改或损坏; 这种算法的特点是不可逆:\n不能从哈希值还原出原始数据。 原始数据的任何改变都会导致哈希值的巨大变化。 哈希算法可以简单分为两类:\n加密哈希算法:安全性较高的哈希算法,它可以提供一定的数据完整性保护和数据防篡改能力,能够抵御一定的攻击手段,安全性相对较高,但性能较差,适用于对安全性要求较高的场景。例如 SHA2、SHA3、SM3、RIPEMD-160、BLAKE2、SipHash 等等。 非加密哈希算法:安全性相对较低的哈希算法,易受到暴力破解、冲突攻击等攻击手段的影响,但性能较高,适用于对安全性没有要求的业务场景。例如 CRC32、MurMurHash3、SipHash 等等。 除了这两种之外,还有一些特殊的哈希算法,例如安全性更高的慢哈希算法。\n常见的哈希算法有:\nMD(Message Digest,消息摘要算法):MD2、MD4、MD5 等,已经不被推荐使用。 SHA(Secure Hash Algorithm,安全哈希算法):SHA-1 系列安全性低,SHA2,SHA3 系列安全性较高。 国密算法:例如 SM2、SM3、SM4,其中 SM2 为非对称加密算法,SM4 为对称加密算法,SM3 为哈希算法(安全性及效率和 SHA-256 相当,但更适合国内的应用环境)。 Bcrypt(密码哈希算法):基于 Blowfish 加密算法的密码哈希算法,专门为密码加密而设计,安全性高,属于慢哈希算法。 MAC(Message Authentication Code,消息认证码算法):HMAC 是一种基于哈希的 MAC,可以与任何安全的哈希算法结合使用,例如 SHA-256。 CRC:(Cyclic Redundancy Check,循环冗余校验):CRC32 是一种 CRC 算法,它的特点是生成 32 位的校验值,通常用于数据完整性校验、文件校验等场景。 SipHash:加密哈希算法,它的设计目的是在速度和安全性之间达到一个平衡,用于防御 哈希泛洪 DoS 攻击。Rust 默认使用 SipHash 作为哈希算法,从 Redis4.0 开始,哈希算法被替换为 SipHash。 MurMurHash:经典快速的非加密哈希算法,目前最新的版本是 MurMurHash3,可以生成 32 位或者 128 位哈希值; …… 哈希算法一般是不需要密钥的,但也存在部分特殊哈希算法需要密钥。例如,MAC 和 SipHash 就是一种基于密钥的哈希算法,它在哈希算法的基础上增加了一个密钥,使得只有知道密钥的人才能验证数据的完整性和来源。\nMD # MD 算法有多个版本,包括 MD2、MD4、MD5 等,其中 MD5 是最常用的版本,它可以生成一个 128 位(16 字节)的哈希值。从安全性上说:MD5 \u0026gt; MD4 \u0026gt; MD2。除了这些版本,还有一些基于 MD4 或 MD5 改进的算法,如 RIPEMD、HAVAL 等。\n即使是最安全 MD 算法 MD5 也存在被破解的风险,攻击者可以通过暴力破解或彩虹表攻击等方式,找到与原始数据相同的哈希值,从而破解数据。\n为了增加破解难度,通常可以选择加盐。盐(Salt)在密码学中,是指通过在密码任意固定位置插入特定的字符串,让哈希后的结果和使用原始密码的哈希结果不相符,这种过程称之为“加盐”。\n加盐之后就安全了吗?并不一定,这只是增加了破解难度,不代表无法破解。而且,MD5 算法本身就存在弱碰撞(Collision)问题,即多个不同的输入产生相同的 MD5 值。\n因此,MD 算法已经不被推荐使用,建议使用更安全的哈希算法比如 SHA-2、Bcrypt。\nJava 提供了对 MD 算法系列的支持,包括 MD2、MD5。\nMD5 代码示例(未加盐):\nString originalString = \u0026#34;Java学习 + 面试指南:javaguide.cn\u0026#34;; // 创建MD5摘要对象 MessageDigest messageDigest = MessageDigest.getInstance(\u0026#34;MD5\u0026#34;); messageDigest.update(originalString.getBytes(StandardCharsets.UTF_8)); // 计算哈希值 byte[] result = messageDigest.digest(); // 将哈希值转换为十六进制字符串 String hexString = new HexBinaryAdapter().marshal(result); System.out.println(\u0026#34;Original String: \u0026#34; + originalString); System.out.println(\u0026#34;MD5 Hash: \u0026#34; + hexString.toLowerCase()); 输出:\nOriginal String: Java学习 + 面试指南:javaguide.cn MD5 Hash: fb246796f5b1b60d4d0268c817c608fa SHA # SHA(Secure Hash Algorithm)系列算法是一组密码哈希算法,用于将任意长度的数据映射为固定长度的哈希值。SHA 系列算法由美国国家安全局(NSA)于 1993 年设计,目前共有 SHA-1、SHA-2、SHA-3 三种版本。\nSHA-1 算法将任意长度的数据映射为 160 位的哈希值。然而,SHA-1 算法存在一些严重的缺陷,比如安全性低,容易受到碰撞攻击和长度扩展攻击。因此,SHA-1 算法已经不再被推荐使用。 SHA-2 家族(如 SHA-256、SHA-384、SHA-512 等)和 SHA-3 系列是 SHA-1 算法的替代方案,它们都提供了更高的安全性和更长的哈希值长度。\nSHA-2 家族是在 SHA-1 算法的基础上改进而来的,它们采用了更复杂的运算过程和更多的轮次,使得攻击者更难以通过预计算或巧合找到碰撞。\n为了寻找一种更安全和更先进的密码哈希算法,美国国家标准与技术研究院(National Institute of Standards and Technology,简称 NIST)在 2007 年公开征集 SHA-3 的候选算法。NIST 一共收到了 64 个算法方案,经过多轮的评估和筛选,最终在 2012 年宣布 Keccak 算法胜出,成为 SHA-3 的标准算法(SHA-3 与 SHA-2 算法没有直接的关系)。 Keccak 算法具有与 MD 和 SHA-1/2 完全不同的设计思路,即海绵结构(Sponge Construction),使得传统攻击方法无法直接应用于 SHA-3 的攻击中(能够抵抗目前已知的所有攻击方式包括碰撞攻击、长度扩展攻击、差分攻击等)。\n由于 SHA-2 算法还没有出现重大的安全漏洞,而且在软件中的效率更高,所以大多数人还是倾向于使用 SHA-2 算法。\n相比 MD5 算法,SHA-2 算法之所以更强,主要有两个原因:\n哈希值长度更长:例如 SHA-256 算法的哈希值长度为 256 位,而 MD5 算法的哈希值长度为 128 位,这就提高了攻击者暴力破解或者彩虹表攻击的难度。 更强的碰撞抗性:SHA 算法采用了更复杂的运算过程和更多的轮次,使得攻击者更难以通过预计算或巧合找到碰撞。目前还没有找到任何两个不同的数据,它们的 SHA-256 哈希值相同。 当然,SHA-2 也不是绝对安全的,也有被暴力破解或者彩虹表攻击的风险,所以,在实际的应用中,加盐还是必不可少的。\nJava 提供了对 SHA 算法系列的支持,包括 SHA-1、SHA-256、SHA-384 和 SHA-512。\nSHA-256 代码示例(未加盐):\nString originalString = \u0026#34;Java学习 + 面试指南:javaguide.cn\u0026#34;; // 创建SHA-256摘要对象 MessageDigest messageDigest = MessageDigest.getInstance(\u0026#34;SHA-256\u0026#34;); messageDigest.update(originalString.getBytes()); // 计算哈希值 byte[] result = messageDigest.digest(); // 将哈希值转换为十六进制字符串 String hexString = new HexBinaryAdapter().marshal(result); System.out.println(\u0026#34;Original String: \u0026#34; + originalString); System.out.println(\u0026#34;SHA-256 Hash: \u0026#34; + hexString.toLowerCase()); 输出:\nOriginal String: Java学习 + 面试指南:javaguide.cn SHA-256 Hash: 184eb7e1d7fb002444098c9bde3403c6f6722c93ecfac242c0e35cd9ed3b41cd Bcrypt # Bcrypt 算法是一种基于 Blowfish 加密算法的密码哈希算法,专门为密码加密而设计,安全性高。\n由于 Bcrypt 采用了 salt(盐) 和 cost(成本) 两种机制,它可以有效地防止彩虹表攻击和暴力破解攻击,从而保证密码的安全性。salt 是一个随机生成的字符串,用于和密码混合,增加密码的复杂度和唯一性。cost 是一个数值参数,用于控制 Bcrypt 算法的迭代次数,增加密码哈希的计算时间和资源消耗。\nBcrypt 算法可以根据实际情况进行调整加密的复杂度,可以设置不同的 cost 值和 salt 值,从而满足不同的安全需求,灵活性很高。\nJava 应用程序的安全框架 Spring Security 支持多种密码编码器,其中 BCryptPasswordEncoder 是官方推荐的一种,它使用 BCrypt 算法对用户的密码进行加密存储。\n@Bean public PasswordEncoder passwordEncoder(){ return new BCryptPasswordEncoder(); } 对称加密 # 对称加密算法是指加密和解密使用同一个密钥的算法,也叫共享密钥加密算法。\n常见的对称加密算法有 DES、3DES、AES 等。\nDES 和 3DES # DES(Data Encryption Standard)使用 64 位的密钥(有效秘钥长度为 56 位,8 位奇偶校验位)和 64 位的明文进行加密。\n虽然 DES 一次只能加密 64 位,但我们只需要把明文划分成 64 位一组的块,就可以实现任意长度明文的加密。如果明文长度不是 64 位的倍数,必须进行填充,常用的模式有 PKCS5Padding, PKCS7Padding, NOPADDING。\nDES 加密算法的基本思想是将 64 位的明文分成两半,然后对每一半进行多轮的变换,最后再合并成 64 位的密文。这些变换包括置换、异或、选择、移位等操作,每一轮都使用了一个子密钥,而这些子密钥都是由同一个 56 位的主密钥生成的。DES 加密算法总共进行了 16 轮变换,最后再进行一次逆置换,得到最终的密文。\n这是一个经典的对称加密算法,但也有明显的缺陷,即 56 位的密钥安全性不足,已被证实可以在短时间内破解。\n为了提高 DES 算法的安全性,人们提出了一些变种或者替代方案,例如 3DES(Triple DES)。\n3DES(Triple DES)是 DES 向 AES 过渡的加密算法,它使用 2 个或者 3 个 56 位的密钥对数据进行三次加密。3DES 相当于是对每个数据块应用三次 DES 的对称加密算法。\n为了兼容普通的 DES,3DES 并没有直接使用 加密-\u0026gt;加密-\u0026gt;加密 的方式,而是采用了加密-\u0026gt;解密-\u0026gt;加密 的方式。当三种密钥均相同时,前两步相互抵消,相当于仅实现了一次加密,因此可实现对普通 DES 加密算法的兼容。3DES 比 DES 更为安全,但其处理速度不高。\nAES # AES(Advanced Encryption Standard)算法是一种更先进的对称密钥加密算法,它使用 128 位、192 位或 256 位的密钥对数据进行加密或解密,密钥越长,安全性越高。\nAES 也是一种分组(或者叫块)密码,分组长度只能是 128 位,也就是说,每个分组为 16 个字节。AES 加密算法有多种工作模式(mode of operation),如:ECB、CBC、OFB、CFB、CTR、XTS、OCB、GCM(目前使用最广泛的模式)。不同的模式参数和加密流程不同,但是核心仍然是 AES 算法。\n和 DES 类似,对于不是 128 位倍数的明文需要进行填充,常用的填充模式有 PKCS5Padding, PKCS7Padding, NOPADDING。不过,AES-GCM 是流加密算法,可以对任意长度的明文进行加密,所以对应的填充模式为 NoPadding,即无需填充。\nAES 的速度比 3DES 快,而且更安全。\nDES 算法和 AES 算法简单对比(图片来自于: RSA vs. AES Encryption: Key Differences Explained):\n基于 Java 实现 AES 算法代码示例:\nprivate static final String AES_ALGORITHM = \u0026#34;AES\u0026#34;; // AES密钥 private static final String AES_SECRET_KEY = \u0026#34;4128D9CDAC7E2F82951CBAF7FDFE675B\u0026#34;; // AES加密模式为GCM,填充方式为NoPadding // AES-GCM 是流加密(Stream cipher)算法,所以对应的填充模式为 NoPadding,即无需填充。 private static final String AES_TRANSFORMATION = \u0026#34;AES/GCM/NoPadding\u0026#34;; // 加密器 private static Cipher encryptionCipher; // 解密器 private static Cipher decryptionCipher; /** * 完成一些初始化工作 */ public static void init() throws Exception { // 将AES密钥转换为SecretKeySpec对象 SecretKeySpec secretKeySpec = new SecretKeySpec(AES_SECRET_KEY.getBytes(), AES_ALGORITHM); // 使用指定的AES加密模式和填充方式获取对应的加密器并初始化 encryptionCipher = Cipher.getInstance(AES_TRANSFORMATION); encryptionCipher.init(Cipher.ENCRYPT_MODE, secretKeySpec); // 使用指定的AES加密模式和填充方式获取对应的解密器并初始化 decryptionCipher = Cipher.getInstance(AES_TRANSFORMATION); decryptionCipher.init(Cipher.DECRYPT_MODE, secretKeySpec, new GCMParameterSpec(128, encryptionCipher.getIV())); } /** * 加密 */ public static String encrypt(String data) throws Exception { byte[] dataInBytes = data.getBytes(); // 加密数据 byte[] encryptedBytes = encryptionCipher.doFinal(dataInBytes); return Base64.getEncoder().encodeToString(encryptedBytes); } /** * 解密 */ public static String decrypt(String encryptedData) throws Exception { byte[] dataInBytes = Base64.getDecoder().decode(encryptedData); // 解密数据 byte[] decryptedBytes = decryptionCipher.doFinal(dataInBytes); return new String(decryptedBytes, StandardCharsets.UTF_8); } public static void main(String[] args) throws Exception { String originalString = \u0026#34;Java学习 + 面试指南:javaguide.cn\u0026#34;; init(); String encryptedData = encrypt(originalString); String decryptedData = decrypt(encryptedData); System.out.println(\u0026#34;Original String: \u0026#34; + originalString); System.out.println(\u0026#34;AES Encrypted Data : \u0026#34; + encryptedData); System.out.println(\u0026#34;AES Decrypted Data : \u0026#34; + decryptedData); } 输出:\nOriginal String: Java学习 + 面试指南:javaguide.cn AES Encrypted Data : E1qTkK91suBqToag7WCyoFP9uK5hR1nSfM6p+oBlYj71bFiIVnk5TsQRT+zpjv8stha7oyKi3jQ= AES Decrypted Data : Java学习 + 面试指南:javaguide.cn 非对称加密 # 非对称加密算法是指加密和解密使用不同的密钥的算法,也叫公开密钥加密算法。这两个密钥互不相同,一个称为公钥,另一个称为私钥。公钥可以公开给任何人使用,私钥则要保密。\n如果用公钥加密数据,只能用对应的私钥解密(加密);如果用私钥加密数据,只能用对应的公钥解密(签名)。这样就可以实现数据的安全传输和身份认证。\n常见的非对称加密算法有 RSA、DSA、ECC 等。\nRSA # RSA(Rivest–Shamir–Adleman algorithm)算法是一种基于大数分解的困难性的非对称加密算法,它需要选择两个大素数作为私钥的一部分,然后计算出它们的乘积作为公钥的一部分(寻求两个大素数比较简单,而将它们的乘积进行因式分解却极其困难)。RSA 算法原理的详细介绍,可以参考这篇文章: 你真的了解 RSA 加密算法吗? - 小傅哥。\nRSA 算法的安全性依赖于大数分解的难度,目前已经有 512 位和 768 位的 RSA 公钥被成功分解,因此建议使用 2048 位或以上的密钥长度。\nRSA 算法的优点是简单易用,可以用于数据加密和数字签名;缺点是运算速度慢,不适合大量数据的加密。\nRSA 算法是是目前应用最广泛的非对称加密算法,像 SSL/TLS、SSH 等协议中就用到了 RSA 算法。\n基于 Java 实现 RSA 算法代码示例:\nprivate static final String RSA_ALGORITHM = \u0026#34;RSA\u0026#34;; /** * 生成RSA密钥对 */ public static KeyPair generateKeyPair() throws NoSuchAlgorithmException { KeyPairGenerator keyPairGenerator = KeyPairGenerator.getInstance(RSA_ALGORITHM); // 密钥大小为2048位 keyPairGenerator.initialize(2048); return keyPairGenerator.generateKeyPair(); } /** * 使用公钥加密数据 */ public static String encrypt(String data, PublicKey publicKey) throws Exception { Cipher cipher = Cipher.getInstance(RSA_ALGORITHM); cipher.init(Cipher.ENCRYPT_MODE, publicKey); byte[] encryptedData = cipher.doFinal(data.getBytes(StandardCharsets.UTF_8)); return Base64.getEncoder().encodeToString(encryptedData); } /** * 使用私钥解密数据 */ public static String decrypt(String encryptedData, PrivateKey privateKey) throws Exception { byte[] decodedData = Base64.getDecoder().decode(encryptedData); Cipher cipher = Cipher.getInstance(RSA_ALGORITHM); cipher.init(Cipher.DECRYPT_MODE, privateKey); byte[] decryptedData = cipher.doFinal(decodedData); return new String(decryptedData, StandardCharsets.UTF_8); } public static void main(String[] args) throws Exception { KeyPair keyPair = generateKeyPair(); PublicKey publicKey = keyPair.getPublic(); PrivateKey privateKey = keyPair.getPrivate(); String originalString = \u0026#34;Java学习 + 面试指南:javaguide.cn\u0026#34;; String encryptedData = encrypt(originalString, publicKey); String decryptedData = decrypt(encryptedData, privateKey); System.out.println(\u0026#34;Original String: \u0026#34; + originalString); System.out.println(\u0026#34;RSA Encrypted Data : \u0026#34; + encryptedData); System.out.println(\u0026#34;RSA Decrypted Data : \u0026#34; + decryptedData); } 输出:\nOriginal String: Java学习 + 面试指南:javaguide.cn RSA Encrypted Data : T9ey/CEPUAhZm4UJjuVNIg8RPd1fQ32S9w6+rvOKxmuMumkJY2daFfWuCn8A73Mk5bL6TigOJI0GHfKOt/W2x968qLM3pBGCcPX17n4pR43f32IIIz9iPdgF/INOqDxP5ZAtCDvTiuzcSgDHXqiBSK5TDjtj7xoGjfudYAXICa8pWitnqDgJYoo2J0F8mKzxoi8D8eLE455MEx8ZT1s7FUD/z7/H8CfShLRbO9zq/zFI06TXn123ufg+F4lDaq/5jaIxGVEUB/NFeX4N6OZCFHtAV32mw71BYUadzI9TgvkkUr1rSKmQ0icNhnRdKedJokGUh8g9QQ768KERu92Ibg== RSA Decrypted Data : Java学习 + 面试指南:javaguide.cn DSA # DSA(Digital Signature Algorithm)算法是一种基于离散对数的困难性的非对称加密算法,它需要选择一个素数 q 和一个 q 的倍数 p 作为私钥的一部分,然后计算出一个模 p 的原根 g 和一个模 q 的整数 y 作为公钥的一部分。DSA 算法的安全性依赖于离散对数的难度,目前已经有 1024 位的 DSA 公钥被成功破解,因此建议使用 2048 位或以上的密钥长度。\nDSA 算法的优点是数字签名速度快,适合生成数字证书;缺点是不能用于数据加密,且签名过程需要随机数。\nDSA 算法签名过程:\n使用消息摘要算法对要发送的数据进行加密,生成一个信息摘要,也就是一个短的、唯一的、不可逆的数据表示。 发送方用自己的 DSA 私钥对信息摘要再进行加密,形成一个数字签名,也就是一个可以证明数据来源和完整性的数据附加。 将原始数据和数字签名一起通过互联网传送给接收方。 接收方用发送方的公钥对数字签名进行解密,得到信息摘要。同时,接收方也用消息摘要算法对收到的原始数据进行加密,得到另一个信息摘要。接收方将两个信息摘要进行比较,如果两者一致,则说明在传送过程中数据没有被篡改或损坏;否则,则说明数据已经失去了安全性和保密性。 总结 # 这篇文章介绍了三种加密算法:哈希算法、对称加密算法和非对称加密算法。\n哈希算法是一种用数学方法对数据生成一个固定长度的唯一标识的技术,可以用来验证数据的完整性和一致性,常见的哈希算法有 MD、SHA、MAC 等。 对称加密算法是一种加密和解密使用同一个密钥的算法,可以用来保护数据的安全性和保密性,常见的对称加密算法有 DES、3DES、AES 等。 非对称加密算法是一种加密和解密使用不同的密钥的算法,可以用来实现数据的安全传输和身份认证,常见的非对称加密算法有 RSA、DSA、ECC 等。 参考 # 深入理解完美哈希 - 腾讯技术工程: https://mp.weixin.qq.com/s/M8Wcj8sZ7UF1CMr887Puog 写给开发人员的实用密码学(二)—— 哈希函数: https://thiscute.world/posts/practical-cryptography-basics-2-hash/ 奇妙的安全旅行之 DSA 算法: https://zhuanlan.zhihu.com/p/347025157 AES-GCM 加密简介: https://juejin.cn/post/6844904122676690951 Java AES 256 GCM Encryption and Decryption Example | JCE Unlimited Strength: https://www.javainterviewpoint.com/java-aes-256-gcm-encryption-and-decryption/ "},{"id":598,"href":"/zh/docs/technology/Interview/cs-basics/algorithms/common-data-structures-leetcode-recommendations/","title":"常见数据结构经典LeetCode题目推荐","section":"Algorithms","content":" 数组 # 704.二分查找: https://leetcode.cn/problems/binary-search/\n80.删除有序数组中的重复项 II: https://leetcode.cn/problems/remove-duplicates-from-sorted-array-ii\n977.有序数组的平方: https://leetcode.cn/problems/squares-of-a-sorted-array/\n链表 # 707.设计链表: https://leetcode.cn/problems/design-linked-list/\n206.反转链表: https://leetcode.cn/problems/reverse-linked-list/\n92.反转链表 II: https://leetcode.cn/problems/reverse-linked-list-ii/\n61.旋转链表: https://leetcode.cn/problems/rotate-list/\n栈与队列 # 232.用栈实现队列: https://leetcode.cn/problems/implement-queue-using-stacks/\n225.用队列实现栈: https://leetcode.cn/problems/implement-stack-using-queues/\n347.前 K 个高频元素: https://leetcode.cn/problems/top-k-frequent-elements/\n239.滑动窗口最大值: https://leetcode.cn/problems/sliding-window-maximum/\n二叉树 # 105.从前序与中序遍历构造二叉树: https://leetcode.cn/problems/construct-binary-tree-from-preorder-and-inorder-traversal/\n117.填充每个节点的下一个右侧节点指针 II: https://leetcode.cn/problems/populating-next-right-pointers-in-each-node-ii\n236.二叉树的最近公共祖先: https://leetcode.cn/problems/lowest-common-ancestor-of-a-binary-tree/\n129.求根节点到叶节点数字之和: https://leetcode.cn/problems/sum-root-to-leaf-numbers/\n102.二叉树的层序遍历: https://leetcode.cn/problems/binary-tree-level-order-traversal/\n530.二叉搜索树的最小绝对差: https://leetcode.cn/problems/minimum-absolute-difference-in-bst/\n图 # 200.岛屿数量: https://leetcode.cn/problems/number-of-islands/\n207.课程表: https://leetcode.cn/problems/course-schedule/\n210.课程表 II: https://leetcode.cn/problems/course-schedule-ii/\n堆 # 215.数组中的第 K 个最大元素: https://leetcode.cn/problems/kth-largest-element-in-an-array/\n216.数据流的中位数: https://leetcode.cn/problems/find-median-from-data-stream/\n217.前 K 个高频元素: https://leetcode.cn/problems/top-k-frequent-elements/\n"},{"id":599,"href":"/zh/docs/technology/Interview/high-availability/timeout-and-retry/","title":"超时\u0026重试详解","section":"High Availability","content":"由于网络问题、系统或者服务内部的 Bug、服务器宕机、操作系统崩溃等问题的不确定性,我们的系统或者服务永远不可能保证时刻都是可用的状态。\n为了最大限度的减小系统或者服务出现故障之后带来的影响,我们需要用到的 超时(Timeout) 和 重试(Retry) 机制。\n想要把超时和重试机制讲清楚其实很简单,因为它俩本身就不是什么高深的概念。\n虽然超时和重试机制的思想很简单,但是它俩是真的非常实用。你平时接触到的绝大部分涉及到远程调用的系统或者服务都会应用超时和重试机制。尤其是对于微服务系统来说,正确设置超时和重试非常重要。单体服务通常只涉及数据库、缓存、第三方 API、中间件等的网络调用,而微服务系统内部各个服务之间还存在着网络调用。\n超时机制 # 什么是超时机制? # 超时机制说的是当一个请求超过指定的时间(比如 1s)还没有被处理的话,这个请求就会直接被取消并抛出指定的异常或者错误(比如 504 Gateway Timeout)。\n我们平时接触到的超时可以简单分为下面 2 种:\n连接超时(ConnectTimeout):客户端与服务端建立连接的最长等待时间。 读取超时(ReadTimeout):客户端和服务端已经建立连接,客户端等待服务端处理完请求的最长时间。实际项目中,我们关注比较多的还是读取超时。 一些连接池客户端框架中可能还会有获取连接超时和空闲连接清理超时。\n如果没有设置超时的话,就可能会导致服务端连接数爆炸和大量请求堆积的问题。\n这些堆积的连接和请求会消耗系统资源,影响新收到的请求的处理。严重的情况下,甚至会拖垮整个系统或者服务。\n我之前在实际项目就遇到过类似的问题,整个网站无法正常处理请求,服务器负载直接快被拉满。后面发现原因是项目超时设置错误加上客户端请求处理异常,导致服务端连接数直接接近 40w+,这么多堆积的连接直接把系统干趴了。\n超时时间应该如何设置? # 超时到底设置多长时间是一个难题!超时值设置太高或者太低都有风险。如果设置太高的话,会降低超时机制的有效性,比如你设置超时为 10s 的话,那设置超时就没啥意义了,系统依然可能会出现大量慢请求堆积的问题。如果设置太低的话,就可能会导致在系统或者服务在某些处理请求速度变慢的情况下(比如请求突然增多),大量请求重试(超时通常会结合重试)继续加重系统或者服务的压力,进而导致整个系统或者服务被拖垮的问题。\n通常情况下,我们建议读取超时设置为 1500ms ,这是一个比较普适的值。如果你的系统或者服务对于延迟比较敏感的话,那读取超时值可以适当在 1500ms 的基础上进行缩短。反之,读取超时值也可以在 1500ms 的基础上进行加长,不过,尽量还是不要超过 1500ms 。连接超时可以适当设置长一些,建议在 1000ms ~ 5000ms 之内。\n没有银弹!超时值具体该设置多大,还是要根据实际项目的需求和情况慢慢调整优化得到。\n更上一层,参考 美团的 Java 线程池参数动态配置思想,我们也可以将超时弄成可配置化的参数而不是固定的,比较简单的一种办法就是将超时的值放在配置中心中。这样的话,我们就可以根据系统或者服务的状态动态调整超时值了。\n重试机制 # 什么是重试机制? # 重试机制一般配合超时机制一起使用,指的是多次发送相同的请求来避免瞬态故障和偶然性故障。\n瞬态故障可以简单理解为某一瞬间系统偶然出现的故障,并不会持久。偶然性故障可以理解为哪些在某些情况下偶尔出现的故障,频率通常较低。\n重试的核心思想是通过消耗服务器的资源来尽可能获得请求更大概率被成功处理。由于瞬态故障和偶然性故障是很少发生的,因此,重试对于服务器的资源消耗几乎是可以被忽略的。\n常见的重试策略有哪些? # 常见的重试策略有两种:\n固定间隔时间重试:每次重试之间都使用相同的时间间隔,比如每隔 1.5 秒进行一次重试。这种重试策略的优点是实现起来比较简单,不需要考虑重试次数和时间的关系,也不需要维护额外的状态信息。但是这种重试策略的缺点是可能会导致重试过于频繁或过于稀疏,从而影响系统的性能和效率。如果重试间隔太短,可能会对目标系统造成过大的压力,导致雪崩效应;如果重试间隔太长,可能会导致用户等待时间过长,影响用户体验。 梯度间隔重试:根据重试次数的增加去延长下次重试时间,比如第一次重试间隔为 1 秒,第二次为 2 秒,第三次为 4 秒,以此类推。这种重试策略的优点是能够有效提高重试成功的几率(随着重试次数增加,但是重试依然不成功,说明目标系统恢复时间比较长,因此可以根据重试次数延长下次重试时间),也能通过柔性化的重试避免对下游系统造成更大压力。但是这种重试策略的缺点是实现起来比较复杂,需要考虑重试次数和时间的关系,以及设置合理的上限和下限值。另外,这种重试策略也可能会导致用户等待时间过长,影响用户体验。 这两种适合的场景各不相同。固定间隔时间重试适用于目标系统恢复时间比较稳定和可预测的场景,比如网络波动或服务重启。梯度间隔重试适用于目标系统恢复时间比较长或不可预测的场景,比如网络故障和服务故障。\n重试的次数如何设置? # 重试的次数不宜过多,否则依然会对系统负载造成比较大的压力。\n重试的次数通常建议设为 3 次。大部分情况下,我们还是更建议使用梯度间隔重试策略,比如说我们要重试 3 次的话,第 1 次请求失败后,等待 1 秒再进行重试,第 2 次请求失败后,等待 2 秒再进行重试,第 3 次请求失败后,等待 3 秒再进行重试。\n什么是重试幂等? # 超时和重试机制在实际项目中使用的话,需要注意保证同一个请求没有被多次执行。\n什么情况下会出现一个请求被多次执行呢?客户端等待服务端完成请求完成超时但此时服务端已经执行了请求,只是由于短暂的网络波动导致响应在发送给客户端的过程中延迟了。\n举个例子:用户支付购买某个课程,结果用户支付的请求由于重试的问题导致用户购买同一门课程支付了两次。对于这种情况,我们在执行用户购买课程的请求的时候需要判断一下用户是否已经购买过。这样的话,就不会因为重试的问题导致重复购买了。\nJava 中如何实现重试? # 如果要手动编写代码实现重试逻辑的话,可以通过循环(例如 while 或 for 循环)或者递归实现。不过,一般不建议自己动手实现,有很多第三方开源库提供了更完善的重试机制实现,例如 Spring Retry、Resilience4j、Guava Retrying。\n参考 # 微服务之间调用超时的设置治理: https://www.infoq.cn/article/eyrslar53l6hjm5yjgyx 超时、重试和抖动回退: https://aws.amazon.com/cn/builders-library/timeouts-retries-and-backoff-with-jitter/ "},{"id":600,"href":"/zh/docs/technology/Interview/high-quality-technical-articles/advanced-programmer/the-growth-strategy-of-the-technological-giant/","title":"程序员的技术成长战略","section":"Advanced Programmer","content":" 推荐语:波波老师的一篇文章,写的非常好,不光是对技术成长有帮助,其他领域也是同样适用的!建议反复阅读,形成一套自己的技术成长策略。\n原文地址: https://mp.weixin.qq.com/s/YrN8T67s801-MRo01lCHXA\n1. 前言 # 在波波的微信技术交流群里头,经常有学员问关于技术人该如何学习成长的问题,虽然是微信交流,但我依然可以感受到小伙伴们焦虑的心情。\n技术人为啥焦虑? 恕我直言,说白了是胆识不足格局太小。胆就是胆量,焦虑的人一般对未来的不确定性怀有恐惧。识就是见识,焦虑的人一般看不清楚周围世界,也看不清自己和适合自己的道路。格局也称志向,容易焦虑的人通常视野窄志向小。如果从战略和管理的视角来看,就是对自己和周围世界的认知不足,没有一个清晰和长期的学习成长战略,也没有可执行的阶段性目标计划+严格的执行。\n因为问此类问题的学员很多,让我感觉有点烦了,为了避免重复回答,所以我专门总结梳理了这篇长文,试图统一来回答这类问题。如果后面还有学员问类似问题,我会引导他们来读这篇文章,然后让他们用三个月、一年甚至更长的时间,去思考和回答这样一个问题:你的技术成长战略究竟是什么? 如果你想清楚了这个问题,有清晰和可落地的答案,那么恭喜你,你只需按部就班执行就好,根本无需焦虑,你实现自己的战略目标并做出成就只是一个时间问题;否则,你仍然需要通过不断磨炼+思考,务必去搞清楚这个人生的大问题!!!\n下面我们来看一些行业技术大牛是怎么做的。\n二. 跟技术大牛学成长战略 # 我们知道软件设计是有设计模式(Design Pattern)的,其实技术人的成长也是有成长模式(Growth Pattern)的。波波经常在 Linkedin 上看一些技术大牛的成长履历,探究其中的成长模式,从而启发制定自己的技术成长战略。\n当然,很少有技术大牛会清晰地告诉你他们的技术成长战略,以及每一年的细分落地计划。但是,这并不妨碍我们通过他们的过往履历和产出成果,去溯源他们的技术成长战略。实际上, 越是牛逼的技术人,他们的技术成长战略和路径越是清晰,我们越容易从中探究出一些成功的模式。\n2.1 系统性能专家案例 # 国内的开发者大都热衷于系统性能优化,有些人甚至三句话离不开高性能/高并发,但真正能深入这个领域,做到专家级水平的却寥寥无几。\n我这边要特别介绍的这个技术大牛叫 Brendan Gregg ,他是系统性能领域经典书《System Performance: Enterprise and the Cloud》(中文版 《性能之巅:洞悉系统、企业和云计算》)的作者,也是著名的 性能分析利器火焰图(Flame Graph)的作者。\nBrendan Gregg 之前是 Netflix 公司的高级性能架构师,在 Netflix 工作近 7 年。2022 年 4 月,他离开了 Netflix 去了 Intel,担任院士职位。\n总体上,他已经在系统性能领域深耕超过 10 年, Brendan Gregg 的过往履历可以在 linkedin 上看到。在这 10 年间,除了书籍以外,Brendan Gregg 还产出了超过上百份和系统性能相关的技术文档,演讲视频/ppt,还有各种工具软件,相关内容都整整齐齐地分享在 他的技术博客上,可以说他是一个非常高产的技术大牛。\n上图来自 Brendan Gregg 的新书《BPF Performance Tools: Linux System and Application Observability》。从这个图可以看出,Brendan Gregg 对系统性能领域的掌握程度,已经深挖到了硬件、操作系统和应用的每一个角落,可以说是 360 度无死角,整个计算机系统对他来说几乎都是透明的。波波认为,Brendan Gregg 是名副其实的,世界级的,系统性能领域的大神级人物。\n2.2 从开源到企业案例 # 我要分享的第二个技术大牛是 Jay Kreps,他是知名的开源消息中间件 Kafka 的创始人/架构师,也是 Confluent 公司的联合创始人和 CEO,Confluent 公司是围绕 Kafka 开发企业级产品和服务的技术公司。\n从 Jay Kreps 的 Linkedin 的履历上我们可以看出,Jay Kreps 之前在 Linkedin 工作了 7 年多(2007.6 ~ 2014. 9),从高级工程师、工程主管,一直做到首席资深工程师。Kafka 大致是在 2010 年,Jay Kreps 在 Linkedin 发起的一个项目,解决 Linkedin 内部的大数据采集、存储和消费问题。之后,他和他的团队一直专注 Kafka 的打磨,开源(2011 年初)和社区生态的建设。\n到 2014 年底,Kafka 在社区已经非常成功,有了一个比较大的用户群,于是 Jay Kreps 就和几个早期作者一起离开了 Linkedin,成立了 Confluent 公司,开始了 Kafka 和周边产品的企业化服务道路。今年(2020.4 月),Confluent 公司已经获得 E 轮 2.5 亿美金融资,公司估值达到 45 亿美金。从 Kafka 诞生到现在,Jay Kreps 差不多在这个产品和公司上投入了整整 10 年。\n上图是 Confluent 创始人三人组,一个非常有意思的组合,一个中国人(左),一个印度人(右),中间的 Jay Kreps 是美国人。\n我之所以对 Kafka 和 Jay Kreps 的印象特别深刻,是因为在 2012 年下半年,我在携程框架部也是专门搞大数据采集的,我还开发过一套功能类似 Kafka 的 Log Collector + Agent 产品。我记得同时期有不止 4 个同类型的开源产品:Facebook Scribe、Apache Chukwa、Apache Flume 和 Apache Kafka。现在回头看,只有 Kafka 走到现在发展得最好,这个和创始人的专注和持续投入是分不开的,当然背后和几个创始人的技术大格局也是分不开的。\n当年我对战略性思维几乎没有概念,还处在什么技术都想学、认为各种项目做得越多越牛的阶段。搞了半年的数据采集以后,我就掉头搞其它“更有趣的”项目去了(从这个事情的侧面,也可以看出我当年的技术格局是很小的)。中间我陆续关注过 Jay 的一些创业动向,但是没想到他能把 Confluent 公司发展到目前这个规模。现在回想,其实在十年前,Jay Kreps 对自己的技术成长就有比较明确的战略性思考,也具有大的技术格局和成事的一些必要特质。Jay Kreps 和 Kafka 给我上了一堂生动的技术战略和实践课。\n2.3 技术媒体大 V 案例 # 介绍到这里,有些同学可能会反驳说:波波你讲的这些大牛都是学历背景好,功底扎实起点高,所以他们才更能成功。其实不然,这里我再要介绍一位技术媒体界的大 V 叫 Brad Traversy,大家可以看 他的 Linkedin 简历,背景很一般,学历差不多是一个非正规的社区大学(相当于大专),没有正规大厂工作经历,有限几份工作一直是在做网站外包。\n但是!!!Brad Traversy 目前是技术媒体领域的一个大 V,当前 他在 Youtube 上的频道有 138 万多的订阅量,10 年累计输出 Web 开发和编程相关教学视频超过 800 个。Brad Traversy 也是 Udemy 上的一个成功讲师,目前已经在 Udemy 上累计输出课程 19 门,购课学生数量近 42 万。\nBrad Traversy 目前是自由职业者,他的 Youtube 广告+Udemy 课程的收入相当不错。\n就是这样一位技术媒体大 V,你很难想象,在年轻的时候,贴在他身上的标签是:不良少年,酗酒,抽烟,吸毒,纹身,进监狱。。。直\n到结婚后的第一个孩子诞生,他才开始担起责任做出改变,然后凭借对技术的一腔热情,开始在 Youtube 平台上持续输出免费课程。从此他找到了适合自己的战略目标,然后人生开始发生各种积极的变化。。。如果大家对 Brad Traversy 的过往经历感兴趣,推荐观看他在 Youtube 上的自述视频 《My Struggles \u0026amp; Success》。\n我粗略浏览了 Brad Traversy 在 Youtube 上的所有视频,10 年总计输出 800+视频,平均每年 80+。第一个视频提交于 2010 年 8 月,刚开始几年几乎没有订阅量,2017 年 1 月订阅量才到 50k,这中间差不多隔了 6 年。2017.10 月订阅量猛增到 200k,2018 年 3 月订阅量到 300k。当前 2021.1 月,订阅量达到 138 万。可以认为从 2017 开始,也就是在积累了 6 ~ 7 年后,他的订阅量开始出现拐点。如果把这些数据画出来,将会是一条非常漂亮的复利曲线。\n2.4 案例小结 # Brendan Gregg,Jay Kreps 和 Brad Traversy 三个人走的技术路线各不相同,但是他们的成功具有共性或者说模式:\n1、找到了适合自己的长期战略目标。\nBrendan Gregg: 成为系统性能领域顶级专家 Jay Kreps:开创基于 Kafka 开源消息队列的企业服务公司,并将公司做到上市 Brad Traversy: 成为技术媒体领域大 V 和课程讲师,并以此作为自己的职业 2、专注深耕一个(或有限几个相关的)细分领域(Niche),保持定力,不随便切换领域。\nBrendan Gregg:系统性能领域 Jay Kreps: 消息中间件/实时计算领域+创业 Brad Traversy: 技术媒体/教学领域,方向 Web 开发 + 编程语言 3、长期投入,三人都持续投入了 10 年。\n4、年度细分计划+持续可量化的价值产出(Persistent \u0026amp; Measurable Value Output)。\nBrendan Gregg:除公司日常工作产出以外,每年有超过 10 份以上的技术文档和演讲视频产出,平均每年有 2.5 个开源工具产出。十年共产出书籍 2 本,其中《System Performance》已经更新到第二版。 Jay Kreps:总体有开源产品+公司产出,1 本书产出,每年有 Kafka 和周边产品发版若干。 Brad Traversy: 每年有 Youtube 免费视频产出(平均每年 80+)+Udemy 收费视频课产出(平均每年 1.5 门)。 5、以终为始是牛人和普通人的一大区别。\n普通人通常走一步算一步,很少长远规划。牛人通 常是先有远大目标,然后采用倒推法,将大目标细化到每年/月/周的详细落地计划。Brendan Gregg,Jay Kreps 和 Brad Traversy 三人都是以终为始的典型。\n上面总结了几位技术大牛的成长模式,其中一个重点就是:这些大牛的成长都是通过 持续有价值产出(Persistent Valuable Output) 来驱动的。持续产出为啥如此重要,这个还要从下面的学习金字塔说起。\n三、学习金字塔和刻意训练 # 学习金字塔是美国缅因州国家训练实验室的研究成果,它认为:\n我们平时上课听讲之后,学习内容平均留存率大致只有 5%左右; 书本阅读的平均留存率大致只有 10%左右; 学习配上视听效果的课程,平均留存率大致在 20%左右; 老师实际动手做实验演示后的平均留存率大致在 30%左右; 小组讨论(尤其是辩论后)的平均留存率可以达到 50%左右; 在实践中实际应用所学之后,平均留存率可以达到 75%左右; 在实践的基础上,再把所学梳理出来,转而再传授给他人后,平均留存率可以达到 90%左右。 上面列出的 7 种学习方法,前四种称为 被动学习 ,后三种称为 主动学习。\n拿学游泳做个类比,被动学习相当于你看别人游泳,而主动学习则是你自己要下水去游。我们知道游泳或者跑步之类的运动是要燃烧身体卡路里的,这样才能达到锻炼身体和长肌肉的效果(肌肉是卡路里燃烧的结果)。如果你只是看别人游泳,自己不实际去游,是不会长肌肉的。同样的,主动学习也是要燃烧脑部卡路里的,这样才能达到训练大脑和长脑部“肌肉”的效果。\n我们也知道,燃烧身体的卡路里,通常会让人感觉不舒适,如果燃烧身体卡路里会让人感觉舒适的话,估计这个世界上应该不会有胖子这类人。同样,燃烧脑部卡路里也会让人感觉不适、紧张、出汗或语无伦次,如果燃烧脑部卡路里会让人感觉舒适的话,估计这个世界上人人都很聪明,人人都能发挥最大潜能。当然,这些不舒适是短期的,长期会使你更健康和聪明。波波一直认为, 人与人之间的先天身体其实都差不多,但是后天身体素质和能力有差异,这些差异,很大程度是由后天对身体和大脑的训练质量、频度和强度所造成的。\n明白这个道理之后,心智成熟和自律的人就会对自己进行持续地 刻意训练 。这个刻意训练包括对身体的训练,比如波波现在每天坚持跑步 3km,走 3km,每天做 60 个仰卧起坐,5 分钟平板撑等等,每天保持让身体燃烧一定量的卡路里。刻意训练也包括对大脑的训练,比如波波现在每天做项目写代码 coding(训练脑+手),平均每天在 B 站上输出十分钟免费视频(训练脑+口头表达),另外有定期总结输出公众号文章(训练脑+文字表达),还有每天打半小时左右的平衡球(下图)或古墓丽影游戏(训练小脑+手),每天保持让大脑燃烧一定量的卡路里,并保持一定强度(适度不适感)。\n关于刻意训练的专业原理和方法论,推荐看书籍《刻意练习》。\n注意,如果你平时从来不做举重锻炼的,那么某天突然做举重会很不适应甚至受伤。脑部训练也是一样的,如果你从来没有做过视频输出,那么刚开始做会很不适应,做出来的视频质量会很差。不过没有关系,任何训练都是一个循序渐进,不断强化的过程。等大脑相关区域的\u0026quot;肌肉\u0026quot;长出来以后,会逐步进入正循环,后面会越来越顺畅,相关\u0026quot;肌肉\u0026quot;会越来越发达。所以,和健身一样,健脑也不能遇到困难就放弃,需要循序渐进(Incremental)+持续地(Persistent)刻意训练。\n理解了学习金字塔和刻意训练以后,现在再来看 Brendan Gregg,Jay Kreps 和 Brad Traversy 这些大牛的做法,他们的学习成长都是建立在持续有价值产出的基础上的,这些产出都是刻意训练+燃烧脑部卡路里的成果。他们的产出要么是建立在实践基础上的产出,例如 Jay Kreps 的 Kafka 开源项目和 Confluent 公司;要么是在实践的基础上,再整理传授给其他人的产出,例如,Brendan Greeg 的技术演讲 ppt/视频,书籍,还有 Brad Traversy 的教学视频等等。换句话说,他们一直在学习金字塔的 5 ~ 7 层主动和高效地学习。并且,他们的学习产出还可以获得用户使用,有客户价值(Customer Value),有用户就有反馈和度量。记住,有反馈和度量的学习,也称闭环学习,它是能够不断改进提升的;反之,没有反馈和度量的学习,无法改进提升。\n现在,你也应该明白,晒个书单秀个技能图谱很简单,读个书上个课也不难。但是要你给出 5 ~ 10 年的总体技术成长战略,再基于这个战略给出每年的细分落地计划(尤其是产出计划),然后再严格按计划执行,这的确是很难的事情。这需要大量的实践训练+深度思考,要燃烧大量的脑部卡路里!但这是上天设置的进化法则,成长为真正的技术大牛如同成长为一流的运动员,是需要通过燃烧与之相匹配量的卡路里来交换的。成长为真正的技术大牛,也是需要通过产出与之匹配的社会价值来交换的,只有这样社会才能正常进化。你推进了社会进化,社会才会回馈你。如果不是这样,社会就无法正常进化。\n四、战略思维的诞生 # 一般毕业生刚进入企业工作的时候,思考大都是以天/星期/月为单位的,基本上都是今天学个什么技术,明天学个什么语言,很少会去思考一年甚至更长的目标。这是个眼前漆黑看不到的懵懂时期,捕捉到机会点的能力和概率都非常小。\n工作了三年以后,悟性好的人通常会以一年为思考周期,制定和实施一些年度计划。这个时期是相信天赋和比拼能力的阶段,可以捕捉到一些小机会。\n工作了五年以后,一些悟性好的人会产生出一定的胆识和眼光,他们会以 3 ~ 5 年为周期来制定和实施计划,开始主动布局去捕捉一些中型机会点。\n工作了十年以后,悟性高的人会看到模式和规则变化,例如看出行业发展模式,还有人才的成长模式等,于是开始诞生出战略性思维。然后他们会以 5 ~ 10 年为周期来制定和实施自己的战略计划,开始主动布局去捕捉一些中大机会点。Brendan Gregg,Jay Kreps 和 Brad Traversy 都是属于这个阶段的人。\n当然还有很少一些更牛的时代精英,他们能够看透时代和人性,他们的思考是以一生甚至更长时间为单位的,这些超人不在本文讨论范围内。\n五、建议 # 1、以 5 ~ 10 年为周期去布局谋划你的战略。\n现在大学生毕业的年龄一般在 22 ~ 23 岁,那么在工作了十年后,也就是在你 32 ~ 33 岁的时候,你也差不多看了十年了,应该对自己和周围的世界(你的行业和领域)有一个比较深刻的领悟了。如果你到这个年纪还懵懵懂懂,今天抓东明天抓西,那么只能说你的胆识格局是相当的低。在当前 IT 行业竞争这么激烈的情况下,到 35 岁被下岗可能就在眼前了。\n有了战略性思考,你应该以 5 ~ 10 年为周期去布局谋划你的战略。以 Brendan Gregg,Jay Kreps 和 Brad Traversy 这些大牛为例,人生若真的要干点成就出来,投入周期一般都要十年的。从 33 岁开始,你大致有 3 个十年,因为到 60 岁以后,一般人都老眼昏花干不了大事了。如果你悟性差一点,到 40 岁才开始规划,那么你大致还有 2 个十年。如果你规划好了,这 2 ~ 3 个十年可以成就不小的事业。否则,你很可能一生都成就不了什么事业,或者一直在帮助别人成就别人的事业。\n2、专注自己的精力。\n考虑到人生能干事业的时间也就是 2 ~ 3 个十年,你会发现人生其实很短暂,这时候你会把精力都投入到实现你的十年战略上去,没有时间再浪费在比如网上的闲聊和扯皮争论上去。\n3、细分落地计划尤其是产出计划。\n有了十年战略方向,下一步是每年的细分落地计划,尤其是产出计划。这些计划主要应该工作在学习金字塔的 5/6/7 层。产出应该是刻意训练+燃烧卡路里的结果,每天让身体和大脑都保持燃烧一定量的卡路里。\n4、产出有价值的东西形成正反馈。\n产出应该有客户价值,自己能学习(自己成长进化),对别人还有用(推动社会成长进化),这样可以得到用户回馈和度量,形成一个闭环,可以持续改进和提升你的学习。\n5、少即是多。\n深耕一个(或有限几个相关的)领域。所有细分计划应该紧密围绕你的战略展开。克制内心欲望,不要贪多和分心,不要被喧嚣的世界所迷惑。\n6、战略方向+细分计划都要写下来,定期 review 优化。\n7、要有定力,持续努力。\n曲则全、枉则直,战略实现是不可能直线的。战略方向和细分计划通常要按需调整,尤其在早期,但是最终要收敛。如果老是变不收敛,就是缺乏战略定力,是个必须思考和解决的大问题。\n别人的成长战略可以参考,但是不要刻意去模仿,你有你自己的颜色,你应该成为独一无二的你。\n战略方向和细分计划明确了,接下来就是按部就班执行,十年如一日铁打不动。\n8、慢就是快。\n战略目标的实现也和种树一样是生长出来的,需要时间耐心栽培,记住==慢就是快。==焦虑纠结的时候,像念经一样默念王阳明《传习录》中的教诲:\n立志用功,如种树然。方其根芽,犹未有干;及其有干,尚未有枝;枝而后叶,叶而后花实。初种根时,只管栽培灌溉。勿作枝想,勿作花想,勿作实想。悬想何益?但不忘栽培之功,怕没有枝叶花实?\n译文:\n实现战略目标,就像种树一样。刚开始只是一个小根芽,树干还没有长出来;树干长出来了,枝叶才能慢慢长出来;树枝长出来,然后才能开花和结果。刚开始种树的时候,只管栽培灌溉,别老是纠结枝什么时候长出来,花什么时候开,果实什么时候结出来。纠结有什么好处呢?只要你坚持投入栽培,还怕没有枝叶花实吗?\n"},{"id":601,"href":"/zh/docs/technology/Interview/high-quality-technical-articles/programmer/efficient-book-publishing-and-practice-guide/","title":"程序员高效出书避坑和实践指南","section":"Programmer","content":" 推荐语:详细介绍了程序员出书的一些常见问题,强烈建议有出书想法的朋友看看这篇文章。\n原文地址: https://www.cnblogs.com/JavaArchitect/p/14128202.html\n古有三不朽, 所谓立德、立功、立言。程序员出一本属于自己的书,如果说是立言,可能过于高大上,但终究也算一件雅事。\n出书其实不挣钱,而且从写作到最终拿钱的周期也不短。但程序员如果有一本属于自己的技术书,那至少在面试中能很好地证明自己,也能渐渐地在业内积累自己的名气,面试和做其它事情时也能有不少底气。在本文里,本人就将结合自己的经验和自己踩过的坑,和大家聊聊程序员出书的那些事。\n1.出书的稿酬收益和所需要的时间 # 先说下出书的收益和需要付出的代价,这里姑且先不谈“出书带来的无形资产”,先谈下真金白银的稿酬。\n如果直接和出版社联系,一般稿酬是版税,是书价格的 8%乘以印刷数(或者实际销售数),如果你是大牛的话,还可以往上加,不过一般版税估计也就 10%到 12%。请注意这里的价格是书的全价,不是打折后的价格。\n比如一本书全价是 70 块,在京东等地打 7 折销售,那么版税是 70 块的 8%,也就是说卖出一本作者能有 5.6 的收益,当然真实拿到手以后还再要扣税。\n同时也请注意合同的约定是支付稿酬的方式是印刷数还是实际销售数,我和出版社谈的,一般是印刷数量,这有什么差别呢?现在计算机类的图书一般是首印 2500 册,那么实际拿到手的钱数是 70*8%*2500,当然还要扣税。但如果是按实际销售数量算的话,如果首印才销了 1800 本的话,那么就得按这个数量算钱了。\n现在一本 300 页的书,定价一般在 70 左右,按版税 8%和 2500 册算的话,税前收益是 14000,税后估计是 12000 左右,对新手作者的话,300 的书至少要写 8 个月,由此大家可以算下平均每个月的收益,算下来其实每月也就 1500 的收益,真不多。\n别人的情况我不敢说,但我出书以后,除了稿酬,还有哪些其它的收益呢?\n在当下和之前的公司面试时,告诉面试官我在相关方面出过书以后,面试官就直接会认为我很资深,帮我省了不少事情。 我还在做线下的培训,我就直接拿我最近出的 Python 书做教材了,省得我再备课了。 和别人谈项目,能用我的书证明自己的技术实力,如果是第一次和别人打交道,那么这种证明能立杆见效。 尤其是第一点,其实对一些小公司或者是一些外派开发岗而言,如果候选人在这个方面出过书,甚至都有可能免面试直接录取,本人之前面试过一个大公司的外派岗,就得到过这种待遇。\n2.支付稿酬的时间点和加印后的收益 # 我是和出版社直接联系出书,支付稿酬的时间点一般是在首印后的 3 个月内拿到首印部分稿酬的一部分(具体是 50%到 90%),然后在图书出版后的一年后再拿到其它部分的稿酬。当下有不少书,能销掉首印的册数就不错了,不过也有不少书能加印,甚至出第二和第三版,一般加印册数的版税会在加印后的半年到一年内结清。\n从支付稿酬的时间点上来,对作者确实会有延迟,外加上稿酬也不算高,相对于作者的辛勤劳动,所以出书真不是挣钱的事,而且拿钱的周期还长。如果个别图书公司工作人员一方面在出书阶段对作者没什么帮助, 另一方面还要在中间再挣个差价,那么真有些作践作者的辛勤劳动了。\n3.同图书公司打交道的所见所闻 # 在和出版社编辑沟通前,我也和图书公司的工作人员交流过,不少工作人员对我也是比较尊重,交流虽然不算深入,但也算客气。不过最终对比出版社给出的稿酬等条件,我还是没有通过图书公司出书,这也是比较可惜的事情。下面我给出些具体的经历。\n我经常在博客园等地收到一些图书公司工作人员的留言,问要不要出书,一般我不问,他们不会说自己是出版社编辑还是图书公司的工作人员。有个别图书公司的工作人员,会向作者,尤其是新手作者,说些“出版社编辑一般不会直接和作者联系”,以及“出书一般是通过图书公司”等的话。其实这些话不能算错,比如你不联系出版社编辑,那么对方自然不会直接联系你,但相反如果作者直接和出版社编辑联系,第一没难度,第二可能更直接。 我和出版社编辑交流大纲时,即使大纲有不足,他们也能直接给出具体的修改意见,比如某个章节该写什么,某个小节的大纲该怎么写。而我和个别图书公司的工作人员交流过大纲时,得到的反馈大多是“要重写”,怎么个重写法?这些工作人员可能只能给出抽象的意见,什么都要我自己琢磨。在我之前的博文 程序员怎样出版一本技术书里,我就给出过具体的经历。 由于交流不深,所以我没有和图书公司签订过出书协议,但我知道,只有出版社能出书。由于没有经历过,所以我也不知道图书公司在合同里是否有避规风险等条款,但我见过一位图书公司人员人员给出的一些退稿案例,并隐约流露出对作者的责备之意。细思感觉不妥,对接的工作人员第一不能在出问题的第一时间及时发现并向作者反馈,第二在出问题之后不能对应协调最终导致退稿,第三在退稿之后,作者在付出劳动的情况下图书公司不仅不用承担任何风险,并还能指摘作者。对此,退稿固然有作者的因素,但同是作者的我未免有兔死狐悲之谈。而我在出版社出书时,编辑有时候甚至会主动关心,主动给素材,哪怕有问题也会第一时间修改,所以甚至大范围修改稿件的情况都基本没有出现。 再说下图书公司给作者的稿酬。我见过按页给钱,比如一页 30 到 50 块,并卖断版权,即书重印后作者也无法再得到稿酬,如果是按版税给钱,我也见过给 6%,至于图书公司能否给到 8 个点甚至更高,我没见到过,所以不知道,也不敢擅拟。 我交流过的图书公司工作人员不多,交流也不深,因为我现在主要是和出版社的编辑交流。所以以上只是我对个别图书公司编辑的感受,我无意以偏概全,而和我交流的一些图书公司工作人员至少态度上对我很尊重。所以大家也可以对比尝试下和图书公司以及出版社合作的不同方式。不管怎样,你在写书甚至在签出书协议前,你需要问清楚如下的事项,并且对方有义务让你了解如下的事实。\n你得问清楚,对方的身份是出版社编辑还是图书公司工作人员,这其实应当是对方主动告之。 你的书在哪个出版社出版?这点需要在出书协议里明确给出,不能是先完稿再定出版社。而且,最终能出版书的,一定是出版社,而不是图书公司。 稿酬的支付方式,哪怕图书公司中间可能挣差价,但至少你得了解出版社能给到的稿酬。如果你是通过图书公司出的书,不管图书公司怎么和你谈的,但出版社给图书公司的钱一分不会少,中间部分应该就是图书公司的盈利。 最终和你签订出书合同的,是图书公司还是出版社,这一定得在你签字前搞明白,哪怕你最终是和图书公司签协议,但至少得知道你还能直接和出版社签协议。 你不能存有“在图书公司出书要求低”的想法,更不应该存有“我能力一般,所以只能在图书公司出书”的想法。图书公司自己是没有资格出书的,所以他们也是会把稿件交给出版社,所以该有的要求一点也不会低。你的大纲在出版社编辑那边通不过,那么在图书公司的工作人员那边同样通不过,哪怕你索要的稿酬少,图书公司方面对应的要求一定也不会降低。 如果你明知“图书公司和出版社的差别”,并还是和图书公司合作,这个是两厢情愿的事情。但如果对方“不主动告知”,而你在不了解两者差异的基础上同图书公司合作,那么对方也无可指摘。不过兼听则明,大家如果要出书,不妨和出版社和图书公司都去打打交道对比下。\n4.如何直接同国内计算机图书的知名出版社编辑联系 # 我在清华大学出版社、机械工业出版社、北京大学出版社和电子工业出版社出过书,出书流程也比较顺畅,和编辑打交道也比较愉快。我个人无意把国内出版社划分成三六九等,但计算机行业,比较知名的出版社有清华、机工、电子工业和人邮这四家,当然其它出版社在计算机方面也出版过精品书。\n如何同这些知名出版社的编辑直接打交道?\n直接到官网,一般官网上都直接有联系方式。 你在博客园等地发表文章,会有人找你出书,其中除了图书公司的工作人员外,也有出版社编辑,一般出版社的编辑会直接说明身份,比如我是 xx 出版社的编辑 xx。 本人也和些出版社的编辑联系过,大家如果要,我可以给。 那怎么去找图书公司的工作人员?一般不用主动找,你发表若干博文后,他们会主动找你。如果你细问,“您是出版社编辑还是图书公司的编辑”,他们会表明身份,如果你再细问,那么他们可能会站在图书公司的立场上解释出版社和图书公司的差异。\n从中大家可以看到,不管你最终是否写成书,但去找知名出版社的编辑,并不难。并且,你找到后,他们还会进一步和你交流选题。\n5.定选题和出书的流程 # 这里给出我和出版社编辑交流合作,最终出书的流程。\n第一,联系上出版社编辑后,先讨论选题,你可以选择一个你比较熟悉的方向,或者你愿意专攻的方向,这个方向可以是 java 分布式组件,Spring cloud 全家桶,微服务,或者是 Python 数据分析,机器学习或深度学习等。这方面你如果有扎实的项目经验那最好,如果你当下虽然不熟悉,但你有毅力经过短时间的系统学习确保你写的内容能成系统或者能帮到别人,那么你也可以在这方面出书。\n第二,定好选题方向后,你可以先列出大纲,比如以 Python 数据分析为例,你可以定 12 个章节,第一章讲语法,第二章讲 numpy 类等等,以此类推,你定大纲的时候,可以参考别人书的目录,从而制定你的写作内容。定好大纲以后,你可以和编辑交流,当编辑也认可这个大纲以后,就可以定出版协议。\n对一般作者而言,出版协议其实差不多,稿酬一般是 8 个点,写作周期是和出版社协商,支付周期可能也大同小异,然后出版社会买断这本书的电子以及各种文字的版权。但如果作者是大牛,那么这些细节都可以和出版社协商。\n然后是写书,这是很枯燥的,尤其是写最后几章的时候。我一般是工作日每天用半小时,两天周末周末用 4,5 个小时写,这样一般半年能写完一本 300 页的书,关于高效写书的技巧,后文会详细提及。\n在写书时,一般建议每写好一个章节就交给编辑审阅,这样就不会导致太大问题的出现,而且如果是新手作者,刚开始的措辞和写作技巧都需要积累,这样出版社的编辑在开始阶段也能及时帮到作者。\n当你写完把稿件交到编辑以后,可能会有三校三审的事情,在其中同我合作的编辑会帮助我修改语法和错别字等问题,然后会形成一个修改意见让我确认和修改。我了解下来,如果在图书公司出书,退稿的风险一般就发生在这个阶段,因为图书公司可能是会一次性地把稿件提交给出版社。但由于我会把每个章节都直接提交给出版社编辑审阅,所以即使有大问题,那么在写开始几个章节时都已经暴露并修改,所以最后的修改意见一般不会太长。也就是说,如果是直接和出版社沟通,在三校三审阶段,工作量可能未必大,我一般是在提交一本书以后,由编辑做这个事情,然后我就继续策划并开始写后一本书。\n最后就是拿稿酬,之前已经说了,作者其实不应该对稿酬有太大的期望,也就是聊胜于无。但如果一不小心写了本销量在 5000 乃至 10000 本左右的畅销书,那么可能在一年内也能有 5 万左右的额外收益,并能在业内积累些名气。\n6.出案例书比出经验书要快 # 对一些作者而言,尤其是新手作者,出书不容易,往往是开始几个章节干劲十足,后面发现问题越积越多,外加工作一忙,就不了了之了,或者用 1 年以上的时间才能完成一本书。对此,我的感受是,一本 300 到 400 书的写作周期最长是 8 个月。为了能在这个时间段里完成一本书,我对应给出的建议是,新手作者可以写案例书,别先写介绍经验类的书。\n什么叫案例书?比如一本书里用一个大案例贯穿,系统介绍一个知识点,比如小程序开发,或者全栈开发等。或者一本书一个章节放一个案例,在一本书里给出 10 个左右 Python 深度学习方面的案例。什么叫经验类书呢?比如介绍面试经验的书就属于这这种,或者一些技术大牛写的介绍分布式高并发开发经验的书也算经验类书。\n请注意这里并没有区分两类书的差异,只是对新手作者而言,案例书好写。因为在其中,更多的是看图说话,先给出案例(比如 Python 深度学习里的图像识别案例),然后通过案例介绍 API 的用法(比如 Python 对应库的用法),以及技术的综合要点(比如如何用 Python 库综合实现图像识别功能)。并且案例书里需要作者主观发挥的点比较少,作者无需用自己的话整理相关的经验。对新手作者而言,在组织文字介绍经验时,可能会有自己明白但说不上来的感觉,这样一方面就无法达到预期的效果,另一方面还有可能因为无法有效表述而导致进度的延迟。\n但相反对于案例书,第一案例一般可以借鉴别人的,第二介绍现存的技术总比介绍自己的经验要容易,第三一般还有同类的书可以供作者参考,所以作者不大需要斟酌措辞,新手作者用半年到八个月的时间也有可能写完一本。当作者通过写几本书积累一定经验后,再去挑战经验类书,在这种情况下,写出来的经验类书就有可能畅销了。\n那么具体而言,怎么高效出一本案例书呢?\n对整本书而言,先用少量章节介绍搭建环境和通用基本语法的内容。 在写每个章节案例时,用到总分总的结构,先总体介绍下你这个案例的需求功能,以及要用的技术点,再分开介绍每个功能点的代码实现,最后再总结下这些功能点的使用要点。 在介绍案例中具体代码时,也可以用到总分总的结构,即先总体介绍下这段代码的结构,再分别给出关键代码的说明,最后再给出运行效果并综述其中技术的实现要点。 这样的话,刚开始可以是 1 个月一个章节,写到后面熟练以后估计一个月能写两个章节,这样 8 个月完成一本书,也就不是不可能了。\n7.如何在参考现有内容的基础上避免版权问题 # 写书时,一般多少都需要参考现有的代码和现有的书,但这绝不是重复劳动。比如某位作者整合了不同网站上多个案例,然后系统地讲述了 Python 数据分析,这样虽然现成资料都有,但对读者来说,就能一站式学习。同样地,比如在 Python 神经网络方面,现有 2,3 本书分别给出了若干人脸识别等若干案例,但如果你有效整合到一起,并加他人的基础上加上你的功能,那对读者来说也是有价值的。\n这里就涉及到版权问题,先要说明,作者不能抱有任何幻想,如果出了版权问题,书没出版还好,如果已经出版了,作者不仅要赔钱,而且在业内就会有不好的名声,可谓身败名裂。但其实要避免版权问题一点也不难。\n不能抄袭网上现有的内容,哪怕一句也不行。对此,作者可以在理解人家语句含义的基础上改写。不能抄袭人家书上现有的目录,更不能抄袭人家书上的话,同样一句也不行,对应的解决方法同样是在理解的基础上改写。 不能抄袭 GitHub 上或者任何地方别人的代码,哪怕这个代码是开源的。对此,你可以在理解对方代码的基础上,先运行通,然后一定得自己新建一个项目,在你的项目里参考别人的代码实现你的功能,在这个过程中不能有大段的复制粘贴操作。也就是说,你的代码和别人的代码,在注释,变量命名,类名和方法名上不能有雷同的地方,当然你还可以额外加上你自己的功能。 至于在写技术和案例介绍时,你就可以用你自己的话来说,这样也不会出现版权问题。 用了上述办法以后,作者就可以在参考现有资料的基础上,充分加上属于你的功能,写上你独到的理解,从而高效地出版属于你自己的书。\n8.新手作者需要着着重避免的问题 # 在上文里详细给出了出书的流程,并通过案例书,给出了具体的习作方法,这里就特别针对新手作者,给出些需要注意的实践要点。\n技术书不同于文艺书,在其中首先要确保把技能知识点讲清楚,然后再此基础上可以适当加上些风趣生动的措辞。所以对新手作者而言,甚至可以直接用朴素的文字介绍案例技术,而无需过多考虑文字上的生动性。 内容需要针对初学者,在介绍技术时,从最基本的零基础讲起,别讲太深的。这里以 Python 机器学习为例,可以从什么是机器学习以及 Python 如何实现机器学习讲起,但如果首先就讲机器学习里的实践经验,就未必能确保初学者能学会。 新手作者恨不得把自己知道的都写出来。这种态度非常好,但需要考虑读者的客观接受水平所以需要在写书前设置个预期效果,比如零基础的 Python 开发人员读了我的书以后至少能干活。这个预期效果别不可行,比如不能是“零基础的 Python 开发人员读了我书以后能达到 3 年开发的水准”。这样就可以根据预先制定的效果,制定写作内容,从在你的书就能更着重讲基础知识,这样读者就能有真正有收获。 不过话说回来,如果新手作者直接和出版社编辑联系,找个热门点的方向,并根据案例仔细讲解技术,甚至都有可能写出销量过万的畅销书。\n9.总结:在国内知名出版社出书,其实是个体力活 # 可能当下,写公众号和录视频等的方式,挣钱收益要高于出书,不过话可以这样说,经营公众号和录制视频也是个长期的事情,在短时间里可能未必有收益,如果不是系统地发表内容的话,可能甚至不会有收益。所以出书可能是个非常好的前期准备工作,你靠出书系统积累了素材,靠出书整合了你的知识体系,那么在此基础上,靠公众号或者录视频挣钱可能就会事半功倍。\n从上文里大家可以看到,在出书前期,联系出版社编辑和定选题并不难,如果要写案例书,那么在参考别人内容的基础上,要写完一般书可能也不是高不可攀的事情。甚至可以这样说,出书是个体力活,只要坚持,要出本书并不难,只是你愿不愿意坚持下去的问题。但一旦你有了属于自己的技术书,那么在找工作时,你就能自信地和面试官说你是这方面的专家,在你的视频、公众号和文字里,你也能正大光明地说,你是计算机图书的作者。更为重要的是,和名校、大厂经历一样,属于你的技术书同样是证明程序员能力的重要证据,当你通过出书有效整合了相关方面的知识体系后,那么在这方面,不管是找工作,或者是干私活,或者是接项目做,你都能理直气壮地和别人说:我能行!\n"},{"id":602,"href":"/zh/docs/technology/Interview/high-quality-technical-articles/advanced-programmer/programmer-quickly-learn-new-technology/","title":"程序员如何快速学习新技术","section":"Advanced Programmer","content":" 推荐语:这是 《Java 面试指北》练级攻略篇中的一篇文章,分享了我对于如何快速学习一门新技术的看法。\n很多时候,我们因为工作原因需要快速学习某项技术,进而在项目中应用。或者说,我们想要去面试的公司要求的某项技术我们之前没有接触过,为了应对面试需要,我们需要快速掌握这项技术。\n作为一个人纯自学出生的程序员,这篇文章简单聊聊自己对于如何快速学习某项技术的看法。\n学习任何一门技术的时候,一定要先搞清楚这个技术是为了解决什么问题的。深入学习这个技术的之前,一定先从全局的角度来了解这个技术,思考一下它是由哪些模块构成的,提供了哪些功能,和同类的技术想必它有什么优势。\n比如说我们在学习 Spring 的时候,通过 Spring 官方文档你就可以知道 Spring 最新的技术动态,Spring 包含哪些模块 以及 Spring 可以帮你解决什么问题。\n再比如说我在学习消息队列的时候,我会先去了解这个消息队列一般在系统中有什么作用,帮助我们解决了什么问题。消息队列的种类很多,具体学习研究某个消息队列的时候,我会将其和自己已经学习过的消息队列作比较。像我自己在学习 RocketMQ 的时候,就会先将其和自己曾经学习过的第 1 个消息队列 ActiveMQ 进行比较,思考 RocketMQ 相对于 ActiveMQ 有了哪些提升,解决了 ActiveMQ 的哪些痛点,两者有哪些相似的地方,又有哪些不同的地方。\n学习一个技术最有效最快的办法就是将这个技术和自己之前学到的技术建立连接,形成一个网络。\n然后,我建议你先去看看官方文档的教程,运行一下相关的 Demo ,做一些小项目。\n不过,官方文档通常是英文的,通常只有国产项目以及少部分国外的项目提供了中文文档。并且,官方文档介绍的往往也比较粗糙,不太适合初学者作为学习资料。\n如果你看不太懂官网的文档,你也可以搜索相关的关键词找一些高质量的博客或者视频来看。 一定不要一上来就想着要搞懂这个技术的原理。\n就比如说我们在学习 Spring 框架的时候,我建议你在搞懂 Spring 框架所解决的问题之后,不是直接去开始研究 Spring 框架的原理或者源码,而是先实际去体验一下 Spring 框架提供的核心功能 IoC(Inverse of Control:控制反转) 和 AOP(Aspect-Oriented Programming:面向切面编程),使用 Spring 框架写一些 Demo,甚至是使用 Spring 框架做一些小项目。\n一言以蔽之, 在研究这个技术的原理之前,先要搞懂这个技术是怎么使用的。\n这样的循序渐进的学习过程,可以逐渐帮你建立学习的快感,获得即时的成就感,避免直接研究原理性的知识而被劝退。\n研究某个技术原理的时候,为了避免内容过于抽象,我们同样可以动手实践。\n比如说我们学习 Tomcat 原理的时候,我们发现 Tomcat 的自定义线程池挺有意思,那我们自己也可以手写一个定制版的线程池。再比如我们学习 Dubbo 原理的时候,可以自己动手造一个简易版的 RPC 框架。\n另外,学习项目中需要用到的技术和面试中需要用到的技术其实还是有一些差别的。\n如果你学习某一项技术是为了在实际项目中使用的话,那你的侧重点就是学习这项技术的使用以及最佳实践,了解这项技术在使用过程中可能会遇到的问题。你的最终目标就是这项技术为项目带来了实际的效果,并且,这个效果是正面的。\n如果你学习某一项技术仅仅是为了面试的话,那你的侧重点就应该放在这项技术在面试中最常见的一些问题上,也就是我们常说的八股文。\n很多人一提到八股文,就是一脸不屑。在我看来,如果你不是死记硬背八股文,而是去所思考这些面试题的本质。那你在准备八股文的过程中,同样也能让你加深对这项技术的了解。\n最后,最重要同时也是最难的还是 知行合一!知行合一!知行合一! 不论是编程还是其他领域,最重要不是你知道的有多少,而是要尽量做到知行合一。\n"},{"id":603,"href":"/zh/docs/technology/Interview/high-quality-technical-articles/programmer/how-do-programmers-publish-a-technical-book/","title":"程序员怎样出版一本技术书","section":"Programmer","content":" 推荐语:详细介绍了程序员应该如何从头开始出一本自己的书籍。\n原文地址: https://www.cnblogs.com/JavaArchitect/p/12195219.html\n在面试或联系副业的时候,如果能令人信服地证明自己的实力,那么很有可能事半功倍。如何证明自己的实力?最有信服力的是大公司职位背景背书,没有之一,比如在 BAT 担任资深架构,那么其它话甚至都不用讲了。\n不过,不是每个人入职后马上就是大公司架构师,在上进的路上,还可以通过公众号,专栏博文,GitHub 代码量和出书出视频等方式来证明自己。和其它方式相比,属于自己的技术图书由于经过了国家级出版社的加持,相对更能让别人认可自己的实力,而对于一些小公司而言,一本属于自己的书甚至可以说是免面试的通行证。所以在本文里,就将和广大程序员朋友聊聊出版技术书的那些事。\n1.不是有能力了再出书,而是在出书过程中升能力 # 我知道的不少朋友,是在工作 3 年内出了第一本书,有些优秀的,甚至在校阶段就出书了。\n与之相比还有另外一种态度,不少同学可能想,要等到技术积累到一定程度再写。其实这或许就不怎么积极了,边写书,边升技术,而且写出的书对人还有帮助,这绝对可以做到的。\n比如有同学向深入了解最近比较热门的 Python 数据分析和机器学习,那么就可以在系统性的学习之后,整理之前学习到的爬虫,数据分析和机器学习的案例,根据自己的理解,用适合于初学者的方式整理一下,然后就能出书了。这种书,对资深的人帮助未必大,但由于包含案例,对入门级的读者绝对有帮助,因为这属于现身说法。而且话说回来,如果没有出书这个动力,或者学习过程也就是浅尝辄止,或者未必能全身心地投入,有了出书这个目标,更能保证学习的效果。\n2.适合初级开发,高级开发和架构师写的书 # 之前也提到了,初级开发适合写案例书,就拿 Python 爬虫数据分析机器学习题材为例,可以先找几本这方面现成的书,这些书里,或者章节内容不同,但一起集成看的话,应该可以包含这方面的内容。然后就参考别人书的思路,比如一章写爬虫,一章写 pandas,一章写 matplotlib 等等,整合起来,就可以用 若干个章节构成一本书了。总之,别人书里包含什么内容,你别照抄,但可以参考别人写哪些技术点。\n定好章节后,再定下每个章节的小节,比如第三章讲爬虫案例,那么可以定 3.1 讲爬虫概念,3.2 讲如何搭建 Scrapy 库,3.3 讲如何开发 Scrapy 爬虫案例,通过先章再节的次序,就可以定好一本书的框架。由于是案例书,所以是先给运行通的代码,再用这些代码案例教别人入门,所以案例未必很深,但需要让初学者看了就能懂,而且按照你给出的知识体系逐步学习之后,能理解这个主题的内容。并且,能在看完你这本书以后,能通过调通你给出的爬虫,机器学习等的案例,掌握这一领域的知识,并能从事这方面的基本开发。这个目标,对初级开发而言,稍微用点心,费点时间,应该不难达到。\n而对于高级开发和架构师而言,除了写存粹案例书以外,还可以在书里给出你在大公司里总结出来的开发经验,也就是所谓踩过的坑,比如 Python 在用 matplotlib 会图例时,在设置坐标轴方面有哪些技巧,设置时会遇到哪些常见问题,如果在书里大量包含这种经验,你的书含金量更高。\n此外,高级开发和架构师还可以写一些技术含量更高的书,比如就讲高并发场景下的实践经验,或者 k8s+docker 应对高并发的经验,这种书里,可以给出代码,更可以给出实施方案和架构实施技巧,比如就讲高并发场景里,缓存该如何选型,如何避免击穿,雪崩等场景,如何排查线上 redis 问题,如何设计故障应对预案。除了这条路之外,还可以深入细节,比如通过讲 dubbo 底层代码,告诉大家如何高效配置 dubbo,出了问题该如何排查。如果架构师或高级开发有这类书作为背书,外带大厂工作经验,那么就更可以打出自己的知名度。\n3.可以直接找出版社,也可以找出版公司 # 在我的这篇博文里, 程序员副业那些事:聊聊出书和录视频,给出了通过出版社出书和图书公司出书的差别,供大家参考,大家看了以后可以自行决定出书方式。\n不过不管怎么选,在出书前你得搞明白一些事,或许个别图书出版公司的工作人员不会主动说,这需要你自己问清楚。\n你的合作方是谁?图书出版公司还是出版社? 你的书将在哪个出版社出版?国内比较有名的是清华,人邮,电子和机械,同时其它出版社不能说不好,但业内比较认这四个。 和你沟通的人,是最终有决定权的图书编辑吗?还是图书公司里的工作人员?再啰嗦下,最后能决定书能否出版,以及确定修改意见的,是出版社的编辑。 通过对比出版社和图书出版公司,在搞清楚诸多细节后,大家可以自己斟酌考虑合作的方式。而且,出版社和图书公司的联系方式,在官网上都有,大家可以自行通过邮件等方式联系。\n4.如果别人拿你做试错对象,或有不尊重,赶紧止损 # 我之前看到有图书出版公司招募面向 Java 初学者图书的作者,并且也主动联系过相关人员,得到的反馈大多是:“要重写”。\n比如我列了大纲发过去,反馈是“要重写”,原因是对方没学过 Java,但作为零基础的人看了我的大纲,发现学不会。至于要重写成什么样子 ,对方也说不上来,总之让我再给个大纲,再给一版后,同样没过,这次好些,给了我几本其它类似书的大纲,让我自行看别人有什么好的点。总之不提(或者说提不出)具体的改进点,要我自行尝试各种改进点,试到对方感觉可以为止。\n相比我和几位出版社专业的编辑沟通时,哪怕大纲或稿件有问题,对方会指明到点,并给出具体的修改意见。我不知道图书出版公司里的组织结构,但出版社里,计算机图书有专门的部门,专门的编辑,对方提出的意见都是比较专业,且修改起来很有操作性。\n另外,我在各种渠道,时不时看到有图书出版公司的人员,晒出别人交付的稿件,在众目睽睽之下,说其中有什么问题,意思让大家引以为戒。姑且不论这样做的动机,并且这位工作人员也涂掉了能表面作者身份的信息。但作者出于信任把稿件交到你手上,在不征得作者同意就公开稿件,说“不把作者当回事”,这并不为过。不然,完全可以用私信的方式和作者交流,而不是把作者无心之过公示于众。\n我在和出版社合作时,这类事绝没发生过,而且我认识的出版社编辑,都对各位作者保持着足够的尊重。而且我和我的朋友和多位图书出版公司的朋友交流时,也能得到尊重和礼遇。所以,如果大家在写书时,尤其在写第一本书时,如果遇到被试错,或者从言辞等方面感觉对方不把你当会事,那么可以当即止损。其实也没有什么“损失”,你把当前的大纲和稿件再和出版社编辑交流时,或许你的收益还能提升。\n5.如何写好 30 页篇幅的章节? # 在和出版社定好写作合同后,就可以创作了。书是由章节构成,这里讲下如何构思并创作一个章节。\n比如写爬虫章节,大概 30 页,先定节和目,比如 3.1 搭建爬虫环境是小节,3.1.1 下载 Python Scrapy 包,则是目。先定要写的内容,具体到爬虫小节,可以写 3.1 搭建环境,3.2 Scrapy 的重要模块,3.3 如何开发 Scrapy 爬虫,3.4 开发好以后如何运行,3.5 如何把爬到的信息放入数据库,这些都是小节。\n再具体到目,比如 3.5 里,3.5.1 里写如何搭建数据库环境 3.5.2 里写如何在 Scrapy 里连接数据库 3.5.3 里给出实际案例 3.5.4 里给出运行步骤和示例效果。\n这样可以搭建好一个章的框架,在每个小节里,先给出可以运行通的,而且能说明问题的代码,再给出对代码的说明,再写下代码如何配置,开发时该注意哪些问题,必要时用表格和图来说明,用这样的条理,最多 3 个星期可以完成一个章节,快的话一周半就一个章节。\n以此类推,一本书大概有 12 个章节,第一章可以讲如何安装环境,以及基础语法,后面就可以由浅入深,一个章节一个主题,比如讲 Python 爬虫,第二章可以是讲基础语法,第三章讲 http 协议以及爬虫知识点,以此深入,讲全爬虫,数据分析,数据展示和机器学习等技能。\n按这样算,如果出第一本书,平均下来一个月 2 个章节,大概半年到八个月可以完成一本书,思路就是先搭建书的知识体系,写每个章节时再搭建某个知识点的框架,在小节和目里,用代码结合说明的方式,这样从简到难,大家就可以完成第一本属于自己的书了。\n6.如何写出一本销量过 5 千的书 # 目前纸质书一般一次印刷在 2500 册,大多数书一般就一次印刷,买完为止。如果能销调 5000 本,就属于受欢迎了,如果销量过万,就可以说是大神级书的。这里先不论大神级书,就说下如何写一本过 5000 的畅销书。\n1 最好贴近热点,比如当前热点是全栈开发和机器学习等,如何找热点,就到京东等处去看热销书的关键字。具体操作起来,多和出版社编辑沟通,或许作者更多是从技术角度分析,但出版社的编辑是从市场角度来考虑问题。\n2 如果你的书能被培训机构用作教材,那想不热都不行。培训机构一般用哪些教材呢?第一面向初学者,第二代码全面,第三在这个领域里涵盖知识点全。如果要达成这点,大家可以和出版社的编辑直接沟通,问下相关细节。\n3 可以文字生动,但不能用过于花哨的文字来掩盖书的内涵不足,也就是说畅销书一定要有干货,能解决初学者实际问题,比如 Python 机器学习方向,就写一本用案例涵盖目前常用的机器学习算法,一个章节一种算法,并且案例中有可视化,数据分析,爬虫等要素,可视化的效果如果再吸引人,这本书畅销的可能性也很大。\n4 一定不能心存敷衍,代码调通不算,更力求简洁,说明文字多面向读者,内容上,确保读者一看就会,而且看了有收获,或许这点说起来很抽象,但我写了几本书以后切身体会,要做到这很难,同时做到了,书哪怕不畅想,但至少不误人子弟。\n7.总结,出书仅是一个里程碑,程序员在上进路上应永不停息 # 出书不简单,因为不是每个人都愿意在半年到八个月里,每个晚上每个周末都费时费力写书。但出书也不难,毕竟时间用上去了,出书也只是调试代码加写文字的活,最多再外加些和人沟通的成本。\n其实出书收益并不高,算下来月入大概能在 3k 左右,如果是和图书出版公司合作,估计更少,但这好歹能证明自己的实力。不过在出书后不能止步于此,因为在大厂里有太多的牛人,甚至不用靠出书来证明自己的实力。\n那么如何让出书带来的利益最大化呢?第一可以靠这进大厂,面试时有自己的书绝对是加分项。第二可以用这个去各大网站开专栏,录视频,或者开公众号,毕竟有出版社的背书,能更让别人信服你的能力。第三更得用写书时积累的学习方法和上进的态势继续专研更高深技术,技术有了,不仅能到大厂挣更多的钱,还能通过企业培训等方式更高效地挣钱。\n"},{"id":604,"href":"/zh/docs/technology/Interview/high-quality-technical-articles/programmer/high-value-certifications-for-programmers/","title":"程序员最该拿的几种高含金量证书","section":"Programmer","content":"证书是能有效证明自己能力的好东西,它就是你实力的象征。在短短的面试时间内,证书可以为你加不少分。通过考证来提升自己,是一种性价比很高的办法。不过,相比金融、建筑、医疗等行业,IT 行业的职业资格证书并没有那么多。\n下面我总结了一下程序员可以考的一些常见证书。\n软考 # 全国计算机技术与软件专业技术资格(水平)考试,简称“软考”,是国内认可度较高的一项计算机技术资格认证。尽管一些人吐槽其实际价值,但在特定领域和情况下,它还是非常有用的,例如软考证书在国企和事业单位中具有较高的认可度、在某些城市软考证书可以用于积分落户、可用于个税补贴。\n软考有初、中、高三个级别,建议直接考高级。相比于 PMP(项目管理专业人士认证),软考高项的难度更大,特别是论文部分,绝大部分人都挂在了论文部分。过了软考高项,在一些单位可以内部挂证,每个月多拿几百。\n官网地址: https://www.ruankao.org.cn/。\n备考建议: 2024 年上半年,一次通过软考高级架构师考试的备考秘诀 - 阿里云开发者\nPAT # 攀拓计算机能力测评(PAT)是一个专注于考察算法能力的测评体系,由浙江大学主办。该测评分为四个级别:基础级、乙级、甲级和顶级。\n通过 PAT 测评并达到联盟企业规定的相应评级和分数,可以跳过学历门槛,免除筛选简历和笔试环节,直接获得面试机会。具体有哪些公司可以去官网看看: https://www.patest.cn/company 。\n对于考研浙江大学的同学来说,PAT(甲级)成绩在一年内可以作为硕士研究生招生考试上机复试成绩。\nPMP # PMP(Project Management Professional)认证由美国项目管理协会(PMI)提供,是全球范围内认可度最高的项目管理专业人士资格认证。PMP 认证旨在提升项目管理专业人士的知识和技能,确保项目顺利完成。\nPMP 是“一证在手,全球通用”的资格认证,对项目管理人士来说,PMP 证书含金量还是比较高的。放眼全球,很多成功企业都会将 PMP 认证作为项目经理的入职标准。\n但是!真正有价值的不是 PMP 证书,而是《PMBOK》 那套项目管理体系,在《PMBOK》(PMP 考试指定用书)中也包含了非常多商业活动、实业项目、组织规划、建筑行业等各个领域的项目案例。\n另外,PMP 证书不是一个高大上的证书,而是一个基础的证书。\nACP # ACP(Agile Certified Practitioner)认证同样由美国项目管理协会(PMI)提供,是项目管理领域的另一个重要认证。与 PMP(Project Management Professional)注重传统的瀑布方法论不同,ACP 专注于敏捷项目管理方法论,如 Scrum、Kanban、Lean、Extreme Programming(XP)等。\nOCP # Oracle Certified Professional(OCP)是 Oracle 公司提供的一项专业认证,专注于 Oracle 数据库及相关技术。这个认证旨在验证和认证个人在使用和管理 Oracle 数据库方面的专业知识和技能。\n下图展示了 Oracle 认证的不同路径和相应的认证级别,分别是核心路径(Core Track)和专业路径(Speciality Track)。\n阿里云认证 # 阿里云(Alibaba Cloud)提供的专业认证,认证方向包括云计算、大数据、人工智能、Devops 等。职业认证分为 ACA、ACP、ACE 三个等级,除了职业认证之外,还有一个开发者 Clouder 认证,这是专门为开发者设立的专项技能认证。\n官网地址: https://edu.aliyun.com/certification/。\n华为认证 # 华为认证是由华为技术有限公司提供的面向 ICT(信息与通信技术)领域的专业认证,认证方向包括网络、存储、云计算、大数据、人工智能等,非常庞大的认证体系。\nAWS 认证 # AWS 云认证考试是 AWS 云计算服务的官方认证考试,旨在验证 IT 专业人士在设计、部署和管理 AWS 基础架构方面的技能。\nAWS 认证分为多个级别,包括基础级、从业者级、助理级、专业级和专家级(Specialty),涵盖多个角色和技能:\n基础级别:AWS Certified Cloud Practitioner,适合初学者,验证对 AWS 基础知识的理解,是最简单的入门认证。 助理级别:包括 AWS Certified Solutions Architect – Associate、AWS Certified Developer – Associate 和 AWS Certified SysOps Administrator – Associate,适合中级专业人士,验证其设计、开发和管理 AWS 应用的能力。 专业级别:包括 AWS Certified Solutions Architect – Professional 和 AWS Certified DevOps Engineer – Professional,适合高级专业人士,验证其在复杂和大规模 AWS 环境中的能力。 专家级别:包括 AWS Certified Advanced Networking – Specialty、AWS Certified Big Data – Specialty 等,专注于特定技术领域的深度知识和技能。 备考建议: 小白入门云计算的最佳方式,是去考一张 AWS 的证书(附备考经验)\nGoogle Cloud 认证 # 与 AWS 认证不同,Google Cloud 认证只有一门助理级认证(Associate Cloud Engineer),其他大部分为专业级(专家级)认证。\n备考建议: 如何备考谷歌云认证\n官网地址: https://cloud.google.com/certification\n微软认证 # 微软的认证体系主要针对其 Azure 云平台,分为基础级别、助理级别和专家级别,认证方向包括云计算、数据管理、开发、生产力工具等。\nElastic 认证 # Elastic 认证是由 Elastic 公司提供的一系列专业认证,旨在验证个人在使用 Elastic Stack(包括 Elasticsearch、Logstash、Kibana 、Beats 等)方面的技能和知识。\n如果你在日常开发核心工作是负责 ElasticSearch 相关业务的话,还是比较建议考的,含金量挺高。\n目前 Elastic 认证证书分为四类:Elastic Certified Engineer、Elastic Certified Analyst、Elastic Certified Observability Engineer、Elastic Certified SIEM Specialist。\n比较建议考 Elastic Certified Engineer,这个是 Elastic Stack 的基础认证,考察安装、配置、管理和维护 Elasticsearch 集群等核心技能。\n其他 # PostgreSQL 认证:国内的 PostgreSQL 认证分为专员级(PCA)、专家级(PCP)和大师级(PCM),主要考查 PostgreSQL 数据库管理和优化,价格略贵,不是很推荐。 Kubernetes 认证:Cloud Native Computing Foundation (CNCF) 提供了几个官方认证,例如 Certified Kubernetes Administrator (CKA)、Certified Kubernetes Application Developer (CKAD),主要考察 Kubernetes 方面的技能和知识。 "},{"id":605,"href":"/zh/docs/technology/Interview/java/concurrent/reentrantlock/","title":"从ReentrantLock的实现看AQS的原理及应用","section":"Concurrent","content":" 本文转载自: https://tech.meituan.com/2019/12/05/aqs-theory-and-apply.html\n作者:美团技术团队\nJava 中的大部分同步类(Semaphore、ReentrantLock 等)都是基于 AbstractQueuedSynchronizer(简称为 AQS)实现的。AQS 是一种提供了原子式管理同步状态、阻塞和唤醒线程功能以及队列模型的简单框架。\n本文会从应用层逐渐深入到原理层,并通过 ReentrantLock 的基本特性和 ReentrantLock 与 AQS 的关联,来深入解读 AQS 相关独占锁的知识点,同时采取问答的模式来帮助大家理解 AQS。由于篇幅原因,本篇文章主要阐述 AQS 中独占锁的逻辑和 Sync Queue,不讲述包含共享锁和 Condition Queue 的部分(本篇文章核心为 AQS 原理剖析,只是简单介绍了 ReentrantLock,感兴趣同学可以阅读一下 ReentrantLock 的源码)。\n1 ReentrantLock # 1.1 ReentrantLock 特性概览 # ReentrantLock 意思为可重入锁,指的是一个线程能够对一个临界资源重复加锁。为了帮助大家更好地理解 ReentrantLock 的特性,我们先将 ReentrantLock 跟常用的 Synchronized 进行比较,其特性如下(蓝色部分为本篇文章主要剖析的点):\n下面通过伪代码,进行更加直观的比较:\n// ==========================Synchronized的使用方式========================== // 1.用于代码块 synchronized (this) {} // 2.用于对象 synchronized (object) {} // 3.用于方法 public synchronized void test () {} // 4.可重入 for (int i = 0; i \u0026lt; 100; i++) { synchronized (this) {} } // ==========================ReentrantLock的使用方式========================== public void test () throw Exception { // 1.初始化选择公平锁、非公平锁 ReentrantLock lock = new ReentrantLock(true); // 2.可用于代码块 lock.lock(); try { try { // 3.支持多种加锁方式,比较灵活; 具有可重入特性 if(lock.tryLock(100, TimeUnit.MILLISECONDS)){ } } finally { // 4.手动释放锁 lock.unlock() } } finally { lock.unlock(); } } 1.2 ReentrantLock 与 AQS 的关联 # 通过上文我们已经了解,ReentrantLock 支持公平锁和非公平锁(关于公平锁和非公平锁的原理分析,可参考《 不可不说的 Java“锁”事》),并且 ReentrantLock 的底层就是由 AQS 来实现的。那么 ReentrantLock 是如何通过公平锁和非公平锁与 AQS 关联起来呢? 我们着重从这两者的加锁过程来理解一下它们与 AQS 之间的关系(加锁过程中与 AQS 的关联比较明显,解锁流程后续会介绍)。\n非公平锁源码中的加锁流程如下:\n// java.util.concurrent.locks.ReentrantLock#NonfairSync // 非公平锁 static final class NonfairSync extends Sync { ... final void lock() { if (compareAndSetState(0, 1)) setExclusiveOwnerThread(Thread.currentThread()); else acquire(1); } ... } 这块代码的含义为:\n若通过 CAS 设置变量 State(同步状态)成功,也就是获取锁成功,则将当前线程设置为独占线程。 若通过 CAS 设置变量 State(同步状态)失败,也就是获取锁失败,则进入 Acquire 方法进行后续处理。 第一步很好理解,但第二步获取锁失败后,后续的处理策略是怎么样的呢?这块可能会有以下思考:\n某个线程获取锁失败的后续流程是什么呢?有以下两种可能: (1) 将当前线程获锁结果设置为失败,获取锁流程结束。这种设计会极大降低系统的并发度,并不满足我们实际的需求。所以就需要下面这种流程,也就是 AQS 框架的处理流程。\n(2) 存在某种排队等候机制,线程继续等待,仍然保留获取锁的可能,获取锁流程仍在继续。\n对于问题 1 的第二种情况,既然说到了排队等候机制,那么就一定会有某种队列形成,这样的队列是什么数据结构呢? 处于排队等候机制中的线程,什么时候可以有机会获取锁呢? 如果处于排队等候机制中的线程一直无法获取锁,还是需要一直等待吗,还是有别的策略来解决这一问题? 带着非公平锁的这些问题,再看下公平锁源码中获锁的方式:\n// java.util.concurrent.locks.ReentrantLock#FairSync static final class FairSync extends Sync { ... final void lock() { acquire(1); } ... } 看到这块代码,我们可能会存在这种疑问:Lock 函数通过 Acquire 方法进行加锁,但是具体是如何加锁的呢?\n结合公平锁和非公平锁的加锁流程,虽然流程上有一定的不同,但是都调用了 Acquire 方法,而 Acquire 方法是 FairSync 和 UnfairSync 的父类 AQS 中的核心方法。\n对于上边提到的问题,其实在 ReentrantLock 类源码中都无法解答,而这些问题的答案,都是位于 Acquire 方法所在的类 AbstractQueuedSynchronizer 中,也就是本文的核心——AQS。下面我们会对 AQS 以及 ReentrantLock 和 AQS 的关联做详细介绍(相关问题答案会在 2.3.5 小节中解答)。\n2 AQS # 首先,我们通过下面的架构图来整体了解一下 AQS 框架:\n上图中有颜色的为 Method,无颜色的为 Attribution。 总的来说,AQS 框架共分为五层,自上而下由浅入深,从 AQS 对外暴露的 API 到底层基础数据。 当有自定义同步器接入时,只需重写第一层所需要的部分方法即可,不需要关注底层具体的实现流程。当自定义同步器进行加锁或者解锁操作时,先经过第一层的 API 进入 AQS 内部方法,然后经过第二层进行锁的获取,接着对于获取锁失败的流程,进入第三层和第四层的等待队列处理,而这些处理方式均依赖于第五层的基础数据提供层。 下面我们会从整体到细节,从流程到方法逐一剖析 AQS 框架,主要分析过程如下:\n2.1 原理概览 # AQS 核心思想是,如果被请求的共享资源空闲,那么就将当前请求资源的线程设置为有效的工作线程,将共享资源设置为锁定状态;如果共享资源被占用,就需要一定的阻塞等待唤醒机制来保证锁分配。这个机制主要用的是 CLH 队列的变体实现的,将暂时获取不到锁的线程加入到队列中。\nCLH:Craig、Landin and Hagersten 队列,是单向链表,AQS 中的队列是 CLH 变体的虚拟双向队列(FIFO),AQS 是通过将每条请求共享资源的线程封装成一个节点来实现锁的分配。\n主要原理图如下:\nAQS 使用一个 Volatile 的 int 类型的成员变量来表示同步状态,通过内置的 FIFO 队列来完成资源获取的排队工作,通过 CAS 完成对 State 值的修改。\n2.1.1 AQS 数据结构 # 先来看下 AQS 中最基本的数据结构——Node,Node 即为上面 CLH 变体队列中的节点。\n解释一下几个方法和属性值的含义:\n方法和属性值 含义 waitStatus 当前节点在队列中的状态 thread 表示处于该节点的线程 prev 前驱指针 predecessor 返回前驱节点,没有的话抛出 npe nextWaiter 指向下一个处于 CONDITION 状态的节点(由于本篇文章不讲述 Condition Queue 队列,这个指针不多介绍) next 后继指针 线程两种锁的模式:\n模式 含义 SHARED 表示线程以共享的模式等待锁 EXCLUSIVE 表示线程正在以独占的方式等待锁 waitStatus 有下面几个枚举值:\n枚举 含义 0 当一个 Node 被初始化的时候的默认值 CANCELLED 为 1,表示线程获取锁的请求已经取消了 CONDITION 为-2,表示节点在等待队列中,节点线程等待唤醒 PROPAGATE 为-3,当前线程处在 SHARED 情况下,该字段才会使用 SIGNAL 为-1,表示线程已经准备好了,就等资源释放了 2.1.2 同步状态 State # 在了解数据结构后,接下来了解一下 AQS 的同步状态——State。AQS 中维护了一个名为 state 的字段,意为同步状态,是由 Volatile 修饰的,用于展示当前临界资源的获锁情况。\n// java.util.concurrent.locks.AbstractQueuedSynchronizer private volatile int state; 下面提供了几个访问这个字段的方法:\n方法名 描述 protected final int getState() 获取 State 的值 protected final void setState(int newState) 设置 State 的值 protected final boolean compareAndSetState(int expect, int update) 使用 CAS 方式更新 State 这几个方法都是 Final 修饰的,说明子类中无法重写它们。我们可以通过修改 State 字段表示的同步状态来实现多线程的独占模式和共享模式(加锁过程)。\n对于我们自定义的同步工具,需要自定义获取同步状态和释放状态的方式,也就是 AQS 架构图中的第一层:API 层。\n2.2 AQS 重要方法与 ReentrantLock 的关联 # 从架构图中可以得知,AQS 提供了大量用于自定义同步器实现的 Protected 方法。自定义同步器实现的相关方法也只是为了通过修改 State 字段来实现多线程的独占模式或者共享模式。自定义同步器需要实现以下方法(ReentrantLock 需要实现的方法如下,并不是全部):\n方法名 描述 protected boolean isHeldExclusively() 该线程是否正在独占资源。只有用到 Condition 才需要去实现它。 protected boolean tryAcquire(int arg) 独占方式。arg 为获取锁的次数,尝试获取资源,成功则返回 True,失败则返回 False。 protected boolean tryRelease(int arg) 独占方式。arg 为释放锁的次数,尝试释放资源,成功则返回 True,失败则返回 False。 protected int tryAcquireShared(int arg) 共享方式。arg 为获取锁的次数,尝试获取资源。负数表示失败;0 表示成功,但没有剩余可用资源;正数表示成功,且有剩余资源。 protected boolean tryReleaseShared(int arg) 共享方式。arg 为释放锁的次数,尝试释放资源,如果释放后允许唤醒后续等待结点返回 True,否则返回 False。 一般来说,自定义同步器要么是独占方式,要么是共享方式,它们也只需实现 tryAcquire-tryRelease、tryAcquireShared-tryReleaseShared 中的一种即可。AQS 也支持自定义同步器同时实现独占和共享两种方式,如 ReentrantReadWriteLock。ReentrantLock 是独占锁,所以实现了 tryAcquire-tryRelease。\n以非公平锁为例,这里主要阐述一下非公平锁与 AQS 之间方法的关联之处,具体每一处核心方法的作用会在文章后面详细进行阐述。\n🐛 修正(参见: issue#1761): 图中的一处小错误,(AQS)CAS 修改共享资源 State 成功之后应该是获取锁成功(非公平锁)。\n对应的源码如下:\nfinal boolean nonfairTryAcquire(int acquires) { final Thread current = Thread.currentThread();//获取当前线程 int c = getState(); if (c == 0) { if (compareAndSetState(0, acquires)) {//CAS抢锁 setExclusiveOwnerThread(current);//设置当前线程为独占线程 return true;//抢锁成功 } } else if (current == getExclusiveOwnerThread()) { int nextc = c + acquires; if (nextc \u0026lt; 0) // overflow throw new Error(\u0026#34;Maximum lock count exceeded\u0026#34;); setState(nextc); return true; } return false; } 为了帮助大家理解 ReentrantLock 和 AQS 之间方法的交互过程,以非公平锁为例,我们将加锁和解锁的交互流程单独拎出来强调一下,以便于对后续内容的理解。\n加锁:\n通过 ReentrantLock 的加锁方法 Lock 进行加锁操作。 会调用到内部类 Sync 的 Lock 方法,由于 Sync#lock 是抽象方法,根据 ReentrantLock 初始化选择的公平锁和非公平锁,执行相关内部类的 Lock 方法,本质上都会执行 AQS 的 Acquire 方法。 AQS 的 Acquire 方法会执行 tryAcquire 方法,但是由于 tryAcquire 需要自定义同步器实现,因此执行了 ReentrantLock 中的 tryAcquire 方法,由于 ReentrantLock 是通过公平锁和非公平锁内部类实现的 tryAcquire 方法,因此会根据锁类型不同,执行不同的 tryAcquire。 tryAcquire 是获取锁逻辑,获取失败后,会执行框架 AQS 的后续逻辑,跟 ReentrantLock 自定义同步器无关。 解锁:\n通过 ReentrantLock 的解锁方法 Unlock 进行解锁。 Unlock 会调用内部类 Sync 的 Release 方法,该方法继承于 AQS。 Release 中会调用 tryRelease 方法,tryRelease 需要自定义同步器实现,tryRelease 只在 ReentrantLock 中的 Sync 实现,因此可以看出,释放锁的过程,并不区分是否为公平锁。 释放成功后,所有处理由 AQS 框架完成,与自定义同步器无关。 通过上面的描述,大概可以总结出 ReentrantLock 加锁解锁时 API 层核心方法的映射关系。\n3 通过 ReentrantLock 理解 AQS # ReentrantLock 中公平锁和非公平锁在底层是相同的,这里以非公平锁为例进行分析。\n在非公平锁中,有一段这样的代码:\n// java.util.concurrent.locks.ReentrantLock static final class NonfairSync extends Sync { ... final void lock() { if (compareAndSetState(0, 1)) setExclusiveOwnerThread(Thread.currentThread()); else acquire(1); } ... } 看一下这个 Acquire 是怎么写的:\n// java.util.concurrent.locks.AbstractQueuedSynchronizer public final void acquire(int arg) { if (!tryAcquire(arg) \u0026amp;\u0026amp; acquireQueued(addWaiter(Node.EXCLUSIVE), arg)) selfInterrupt(); } 再看一下 tryAcquire 方法:\n// java.util.concurrent.locks.AbstractQueuedSynchronizer protected boolean tryAcquire(int arg) { throw new UnsupportedOperationException(); } 可以看出,这里只是 AQS 的简单实现,具体获取锁的实现方法是由各自的公平锁和非公平锁单独实现的(以 ReentrantLock 为例)。如果该方法返回了 True,则说明当前线程获取锁成功,就不用往后执行了;如果获取失败,就需要加入到等待队列中。下面会详细解释线程是何时以及怎样被加入进等待队列中的。\n3.1 线程加入等待队列 # 3.1.1 加入队列的时机 # 当执行 Acquire(1)时,会通过 tryAcquire 获取锁。在这种情况下,如果获取锁失败,就会调用 addWaiter 加入到等待队列中去。\n3.1.2 如何加入队列 # 获取锁失败后,会执行 addWaiter(Node.EXCLUSIVE)加入等待队列,具体实现方法如下:\n// java.util.concurrent.locks.AbstractQueuedSynchronizer private Node addWaiter(Node mode) { Node node = new Node(Thread.currentThread(), mode); // Try the fast path of enq; backup to full enq on failure Node pred = tail; if (pred != null) { node.prev = pred; if (compareAndSetTail(pred, node)) { pred.next = node; return node; } } enq(node); return node; } private final boolean compareAndSetTail(Node expect, Node update) { return unsafe.compareAndSwapObject(this, tailOffset, expect, update); } 主要的流程如下:\n通过当前的线程和锁模式新建一个节点。 Pred 指针指向尾节点 Tail。 将 New 中 Node 的 Prev 指针指向 Pred。 通过 compareAndSetTail 方法,完成尾节点的设置。这个方法主要是对 tailOffset 和 Expect 进行比较,如果 tailOffset 的 Node 和 Expect 的 Node 地址是相同的,那么设置 Tail 的值为 Update 的值。 // java.util.concurrent.locks.AbstractQueuedSynchronizer static { try { stateOffset = unsafe.objectFieldOffset(AbstractQueuedSynchronizer.class.getDeclaredField(\u0026#34;state\u0026#34;)); headOffset = unsafe.objectFieldOffset(AbstractQueuedSynchronizer.class.getDeclaredField(\u0026#34;head\u0026#34;)); tailOffset = unsafe.objectFieldOffset(AbstractQueuedSynchronizer.class.getDeclaredField(\u0026#34;tail\u0026#34;)); waitStatusOffset = unsafe.objectFieldOffset(Node.class.getDeclaredField(\u0026#34;waitStatus\u0026#34;)); nextOffset = unsafe.objectFieldOffset(Node.class.getDeclaredField(\u0026#34;next\u0026#34;)); } catch (Exception ex) { throw new Error(ex); } } 从 AQS 的静态代码块可以看出,都是获取一个对象的属性相对于该对象在内存当中的偏移量,这样我们就可以根据这个偏移量在对象内存当中找到这个属性。tailOffset 指的是 tail 对应的偏移量,所以这个时候会将 new 出来的 Node 置为当前队列的尾节点。同时,由于是双向链表,也需要将前一个节点指向尾节点。\n如果 Pred 指针是 Null(说明等待队列中没有元素),或者当前 Pred 指针和 Tail 指向的位置不同(说明被别的线程已经修改),就需要看一下 Enq 的方法。 // java.util.concurrent.locks.AbstractQueuedSynchronizer private Node enq(final Node node) { for (;;) { Node t = tail; if (t == null) { // Must initialize if (compareAndSetHead(new Node())) tail = head; } else { node.prev = t; if (compareAndSetTail(t, node)) { t.next = node; return t; } } } } 如果没有被初始化,需要进行初始化一个头结点出来。但请注意,初始化的头结点并不是当前线程节点,而是调用了无参构造函数的节点。如果经历了初始化或者并发导致队列中有元素,则与之前的方法相同。其实,addWaiter 就是一个在双端链表添加尾节点的操作,需要注意的是,双端链表的头结点是一个无参构造函数的头结点。\n总结一下,线程获取锁的时候,过程大体如下:\n1、当没有线程获取到锁时,线程 1 获取锁成功。\n2、线程 2 申请锁,但是锁被线程 1 占有。\n3、如果再有线程要获取锁,依次在队列中往后排队即可。\n回到上边的代码,hasQueuedPredecessors 是公平锁加锁时判断等待队列中是否存在有效节点的方法。如果返回 False,说明当前线程可以争取共享资源;如果返回 True,说明队列中存在有效节点,当前线程必须加入到等待队列中。\n// java.util.concurrent.locks.ReentrantLock public final boolean hasQueuedPredecessors() { // The correctness of this depends on head being initialized // before tail and on head.next being accurate if the current // thread is first in queue. Node t = tail; // Read fields in reverse initialization order Node h = head; Node s; return h != t \u0026amp;\u0026amp; ((s = h.next) == null || s.thread != Thread.currentThread()); } 看到这里,我们理解一下 h != t \u0026amp;\u0026amp; ((s = h.next) == null || s.thread != Thread.currentThread());为什么要判断的头结点的下一个节点?第一个节点储存的数据是什么?\n双向链表中,第一个节点为虚节点,其实并不存储任何信息,只是占位。真正的第一个有数据的节点,是在第二个节点开始的。当 h != t 时:如果(s = h.next) == null,等待队列正在有线程进行初始化,但只是进行到了 Tail 指向 Head,没有将 Head 指向 Tail,此时队列中有元素,需要返回 True(这块具体见下边代码分析)。 如果(s = h.next) != null,说明此时队列中至少有一个有效节点。如果此时 s.thread == Thread.currentThread(),说明等待队列的第一个有效节点中的线程与当前线程相同,那么当前线程是可以获取资源的;如果 s.thread != Thread.currentThread(),说明等待队列的第一个有效节点线程与当前线程不同,当前线程必须加入进等待队列。\n// java.util.concurrent.locks.AbstractQueuedSynchronizer#enq if (t == null) { // Must initialize if (compareAndSetHead(new Node())) tail = head; } else { node.prev = t; if (compareAndSetTail(t, node)) { t.next = node; return t; } } 节点入队不是原子操作,所以会出现短暂的 head != tail,此时 Tail 指向最后一个节点,而且 Tail 指向 Head。如果 Head 没有指向 Tail(可见 5、6、7 行),这种情况下也需要将相关线程加入队列中。所以这块代码是为了解决极端情况下的并发问题。\n3.1.3 等待队列中线程出队列时机 # 回到最初的源码:\n// java.util.concurrent.locks.AbstractQueuedSynchronizer public final void acquire(int arg) { if (!tryAcquire(arg) \u0026amp;\u0026amp; acquireQueued(addWaiter(Node.EXCLUSIVE), arg)) selfInterrupt(); } 上文解释了 addWaiter 方法,这个方法其实就是把对应的线程以 Node 的数据结构形式加入到双端队列里,返回的是一个包含该线程的 Node。而这个 Node 会作为参数,进入到 acquireQueued 方法中。acquireQueued 方法可以对排队中的线程进行“获锁”操作。\n总的来说,一个线程获取锁失败了,被放入等待队列,acquireQueued 会把放入队列中的线程不断去获取锁,直到获取成功或者不再需要获取(中断)。\n下面我们从“何时出队列?”和“如何出队列?”两个方向来分析一下 acquireQueued 源码:\n// java.util.concurrent.locks.AbstractQueuedSynchronizer final boolean acquireQueued(final Node node, int arg) { // 标记是否成功拿到资源 boolean failed = true; try { // 标记等待过程中是否中断过 boolean interrupted = false; // 开始自旋,要么获取锁,要么中断 for (;;) { // 获取当前节点的前驱节点 final Node p = node.predecessor(); // 如果p是头结点,说明当前节点在真实数据队列的首部,就尝试获取锁(别忘了头结点是虚节点) if (p == head \u0026amp;\u0026amp; tryAcquire(arg)) { // 获取锁成功,头指针移动到当前node setHead(node); p.next = null; // help GC failed = false; return interrupted; } // 说明p为头节点且当前没有获取到锁(可能是非公平锁被抢占了)或者是p不为头结点,这个时候就要判断当前node是否要被阻塞(被阻塞条件:前驱节点的waitStatus为-1),防止无限循环浪费资源。具体两个方法下面细细分析 if (shouldParkAfterFailedAcquire(p, node) \u0026amp;\u0026amp; parkAndCheckInterrupt()) interrupted = true; } } finally { if (failed) cancelAcquire(node); } } 注:setHead 方法是把当前节点置为虚节点,但并没有修改 waitStatus,因为它是一直需要用的数据。\n// java.util.concurrent.locks.AbstractQueuedSynchronizer private void setHead(Node node) { head = node; node.thread = null; node.prev = null; } // java.util.concurrent.locks.AbstractQueuedSynchronizer // 靠前驱节点判断当前线程是否应该被阻塞 private static boolean shouldParkAfterFailedAcquire(Node pred, Node node) { // 获取头结点的节点状态 int ws = pred.waitStatus; // 说明头结点处于唤醒状态 if (ws == Node.SIGNAL) return true; // 通过枚举值我们知道waitStatus\u0026gt;0是取消状态 if (ws \u0026gt; 0) { do { // 循环向前查找取消节点,把取消节点从队列中剔除 node.prev = pred = pred.prev; } while (pred.waitStatus \u0026gt; 0); pred.next = node; } else { // 设置前任节点等待状态为SIGNAL compareAndSetWaitStatus(pred, ws, Node.SIGNAL); } return false; } parkAndCheckInterrupt 主要用于挂起当前线程,阻塞调用栈,返回当前线程的中断状态。\n// java.util.concurrent.locks.AbstractQueuedSynchronizer private final boolean parkAndCheckInterrupt() { LockSupport.park(this); return Thread.interrupted(); } 上述方法的流程图如下:\n从上图可以看出,跳出当前循环的条件是当“前置节点是头结点,且当前线程获取锁成功”。为了防止因死循环导致 CPU 资源被浪费,我们会判断前置节点的状态来决定是否要将当前线程挂起,具体挂起流程用流程图表示如下(shouldParkAfterFailedAcquire 流程):\n从队列中释放节点的疑虑打消了,那么又有新问题了:\nshouldParkAfterFailedAcquire 中取消节点是怎么生成的呢?什么时候会把一个节点的 waitStatus 设置为-1? 是在什么时间释放节点通知到被挂起的线程呢? 3.2 CANCELLED 状态节点生成 # acquireQueued 方法中的 Finally 代码:\n// java.util.concurrent.locks.AbstractQueuedSynchronizer final boolean acquireQueued(final Node node, int arg) { boolean failed = true; try { ... for (;;) { final Node p = node.predecessor(); if (p == head \u0026amp;\u0026amp; tryAcquire(arg)) { ... failed = false; ... } ... } finally { if (failed) cancelAcquire(node); } } 通过 cancelAcquire 方法,将 Node 的状态标记为 CANCELLED。接下来,我们逐行来分析这个方法的原理:\n// java.util.concurrent.locks.AbstractQueuedSynchronizer private void cancelAcquire(Node node) { // 将无效节点过滤 if (node == null) return; // 设置该节点不关联任何线程,也就是虚节点 node.thread = null; Node pred = node.prev; // 通过前驱节点,跳过取消状态的node while (pred.waitStatus \u0026gt; 0) node.prev = pred = pred.prev; // 获取过滤后的前驱节点的后继节点 Node predNext = pred.next; // 把当前node的状态设置为CANCELLED node.waitStatus = Node.CANCELLED; // 如果当前节点是尾节点,将从后往前的第一个非取消状态的节点设置为尾节点 // 更新失败的话,则进入else,如果更新成功,将tail的后继节点设置为null if (node == tail \u0026amp;\u0026amp; compareAndSetTail(node, pred)) { compareAndSetNext(pred, predNext, null); } else { int ws; // 如果当前节点不是head的后继节点,1:判断当前节点前驱节点的是否为SIGNAL,2:如果不是,则把前驱节点设置为SIGNAL看是否成功 // 如果1和2中有一个为true,再判断当前节点的线程是否为null // 如果上述条件都满足,把当前节点的前驱节点的后继指针指向当前节点的后继节点 if (pred != head \u0026amp;\u0026amp; ((ws = pred.waitStatus) == Node.SIGNAL || (ws \u0026lt;= 0 \u0026amp;\u0026amp; compareAndSetWaitStatus(pred, ws, Node.SIGNAL))) \u0026amp;\u0026amp; pred.thread != null) { Node next = node.next; if (next != null \u0026amp;\u0026amp; next.waitStatus \u0026lt;= 0) compareAndSetNext(pred, predNext, next); } else { // 如果当前节点是head的后继节点,或者上述条件不满足,那就唤醒当前节点的后继节点 unparkSuccessor(node); } node.next = node; // help GC } } 当前的流程:\n获取当前节点的前驱节点,如果前驱节点的状态是 CANCELLED,那就一直往前遍历,找到第一个 waitStatus \u0026lt;= 0 的节点,将找到的 Pred 节点和当前 Node 关联,将当前 Node 设置为 CANCELLED。 根据当前节点的位置,考虑以下三种情况: (1) 当前节点是尾节点。\n(2) 当前节点是 Head 的后继节点。\n(3) 当前节点不是 Head 的后继节点,也不是尾节点。\n根据上述第二条,我们来分析每一种情况的流程。\n当前节点是尾节点。\n当前节点是 Head 的后继节点。\n当前节点不是 Head 的后继节点,也不是尾节点。\n通过上面的流程,我们对于 CANCELLED 节点状态的产生和变化已经有了大致的了解,但是为什么所有的变化都是对 Next 指针进行了操作,而没有对 Prev 指针进行操作呢?什么情况下会对 Prev 指针进行操作?\n执行 cancelAcquire 的时候,当前节点的前置节点可能已经从队列中出去了(已经执行过 Try 代码块中的 shouldParkAfterFailedAcquire 方法了),如果此时修改 Prev 指针,有可能会导致 Prev 指向另一个已经移除队列的 Node,因此这块变化 Prev 指针不安全。 shouldParkAfterFailedAcquire 方法中,会执行下面的代码,其实就是在处理 Prev 指针。shouldParkAfterFailedAcquire 是获取锁失败的情况下才会执行,进入该方法后,说明共享资源已被获取,当前节点之前的节点都不会出现变化,因此这个时候变更 Prev 指针比较安全。\ndo { node.prev = pred = pred.prev; } while (pred.waitStatus \u0026gt; 0); 3.3 如何解锁 # 我们已经剖析了加锁过程中的基本流程,接下来再对解锁的基本流程进行分析。由于 ReentrantLock 在解锁的时候,并不区分公平锁和非公平锁,所以我们直接看解锁的源码:\n// java.util.concurrent.locks.ReentrantLock public void unlock() { sync.release(1); } 可以看到,本质释放锁的地方,是通过框架来完成的。\n// java.util.concurrent.locks.AbstractQueuedSynchronizer public final boolean release(int arg) { if (tryRelease(arg)) { Node h = head; if (h != null \u0026amp;\u0026amp; h.waitStatus != 0) unparkSuccessor(h); return true; } return false; } 在 ReentrantLock 里面的公平锁和非公平锁的父类 Sync 定义了可重入锁的释放锁机制。\n// java.util.concurrent.locks.ReentrantLock.Sync // 方法返回当前锁是不是没有被线程持有 protected final boolean tryRelease(int releases) { // 减少可重入次数 int c = getState() - releases; // 当前线程不是持有锁的线程,抛出异常 if (Thread.currentThread() != getExclusiveOwnerThread()) throw new IllegalMonitorStateException(); boolean free = false; // 如果持有线程全部释放,将当前独占锁所有线程设置为null,并更新state if (c == 0) { free = true; setExclusiveOwnerThread(null); } setState(c); return free; } 我们来解释下述源码:\n// java.util.concurrent.locks.AbstractQueuedSynchronizer public final boolean release(int arg) { // 上边自定义的tryRelease如果返回true,说明该锁没有被任何线程持有 if (tryRelease(arg)) { // 获取头结点 Node h = head; // 头结点不为空并且头结点的waitStatus不是初始化节点情况,解除线程挂起状态 if (h != null \u0026amp;\u0026amp; h.waitStatus != 0) unparkSuccessor(h); return true; } return false; } 这里的判断条件为什么是 h != null \u0026amp;\u0026amp; h.waitStatus != 0?\nh == null Head 还没初始化。初始情况下,head == null,第一个节点入队,Head 会被初始化一个虚拟节点。所以说,这里如果还没来得及入队,就会出现 head == null 的情况。\nh != null \u0026amp;\u0026amp; waitStatus == 0 表明后继节点对应的线程仍在运行中,不需要唤醒。\nh != null \u0026amp;\u0026amp; waitStatus \u0026lt; 0 表明后继节点可能被阻塞了,需要唤醒。\n再看一下 unparkSuccessor 方法:\n// java.util.concurrent.locks.AbstractQueuedSynchronizer private void unparkSuccessor(Node node) { // 获取头结点waitStatus int ws = node.waitStatus; if (ws \u0026lt; 0) compareAndSetWaitStatus(node, ws, 0); // 获取当前节点的下一个节点 Node s = node.next; // 如果下个节点是null或者下个节点被cancelled,就找到队列最开始的非cancelled的节点 if (s == null || s.waitStatus \u0026gt; 0) { s = null; // 就从尾部节点开始找,到队首,找到队列第一个waitStatus\u0026lt;0的节点。 for (Node t = tail; t != null \u0026amp;\u0026amp; t != node; t = t.prev) if (t.waitStatus \u0026lt;= 0) s = t; } // 如果当前节点的下个节点不为空,而且状态\u0026lt;=0,就把当前节点unpark if (s != null) LockSupport.unpark(s.thread); } 为什么要从后往前找第一个非 Cancelled 的节点呢?原因如下。\n之前的 addWaiter 方法:\n// java.util.concurrent.locks.AbstractQueuedSynchronizer private Node addWaiter(Node mode) { Node node = new Node(Thread.currentThread(), mode); // Try the fast path of enq; backup to full enq on failure Node pred = tail; if (pred != null) { node.prev = pred; if (compareAndSetTail(pred, node)) { pred.next = node; return node; } } enq(node); return node; } 我们从这里可以看到,节点入队并不是原子操作,也就是说,node.prev = pred; compareAndSetTail(pred, node) 这两个地方可以看作 Tail 入队的原子操作,但是此时 pred.next = node;还没执行,如果这个时候执行了 unparkSuccessor 方法,就没办法从前往后找了,所以需要从后往前找。还有一点原因,在产生 CANCELLED 状态节点的时候,先断开的是 Next 指针,Prev 指针并未断开,因此也是必须要从后往前遍历才能够遍历完全部的 Node。\n综上所述,如果是从前往后找,由于极端情况下入队的非原子操作和 CANCELLED 节点产生过程中断开 Next 指针的操作,可能会导致无法遍历所有的节点。所以,唤醒对应的线程后,对应的线程就会继续往下执行。继续执行 acquireQueued 方法以后,中断如何处理?\n3.4 中断恢复后的执行流程 # 唤醒后,会执行 return Thread.interrupted();,这个函数返回的是当前执行线程的中断状态,并清除。\n// java.util.concurrent.locks.AbstractQueuedSynchronizer private final boolean parkAndCheckInterrupt() { LockSupport.park(this); return Thread.interrupted(); } 再回到 acquireQueued 代码,当 parkAndCheckInterrupt 返回 True 或者 False 的时候,interrupted 的值不同,但都会执行下次循环。如果这个时候获取锁成功,就会把当前 interrupted 返回。\n// java.util.concurrent.locks.AbstractQueuedSynchronizer final boolean acquireQueued(final Node node, int arg) { boolean failed = true; try { boolean interrupted = false; for (;;) { final Node p = node.predecessor(); if (p == head \u0026amp;\u0026amp; tryAcquire(arg)) { setHead(node); p.next = null; // help GC failed = false; return interrupted; } if (shouldParkAfterFailedAcquire(p, node) \u0026amp;\u0026amp; parkAndCheckInterrupt()) interrupted = true; } } finally { if (failed) cancelAcquire(node); } } 如果 acquireQueued 为 True,就会执行 selfInterrupt 方法。\n// java.util.concurrent.locks.AbstractQueuedSynchronizer static void selfInterrupt() { Thread.currentThread().interrupt(); } 该方法其实是为了中断线程。但为什么获取了锁以后还要中断线程呢?这部分属于 Java 提供的协作式中断知识内容,感兴趣同学可以查阅一下。这里简单介绍一下:\n当中断线程被唤醒时,并不知道被唤醒的原因,可能是当前线程在等待中被中断,也可能是释放了锁以后被唤醒。因此我们通过 Thread.interrupted()方法检查中断标记(该方法返回了当前线程的中断状态,并将当前线程的中断标识设置为 False),并记录下来,如果发现该线程被中断过,就再中断一次。 线程在等待资源的过程中被唤醒,唤醒后还是会不断地去尝试获取锁,直到抢到锁为止。也就是说,在整个流程中,并不响应中断,只是记录中断记录。最后抢到锁返回了,那么如果被中断过的话,就需要补充一次中断。 这里的处理方式主要是运用线程池中基本运作单元 Worder 中的 runWorker,通过 Thread.interrupted()进行额外的判断处理,感兴趣的同学可以看下 ThreadPoolExecutor 源码。\n3.5 小结 # 我们在 1.3 小节中提出了一些问题,现在来回答一下。\nQ:某个线程获取锁失败的后续流程是什么呢?\nA:存在某种排队等候机制,线程继续等待,仍然保留获取锁的可能,获取锁流程仍在继续。\nQ:既然说到了排队等候机制,那么就一定会有某种队列形成,这样的队列是什么数据结构呢?\nA:是 CLH 变体的 FIFO 双端队列。\nQ:处于排队等候机制中的线程,什么时候可以有机会获取锁呢?\nA:可以详细看下 2.3.1.3 小节。\nQ:如果处于排队等候机制中的线程一直无法获取锁,需要一直等待么?还是有别的策略来解决这一问题?\nA:线程所在节点的状态会变成取消状态,取消状态的节点会从队列中释放,具体可见 2.3.2 小节。\nQ:Lock 函数通过 Acquire 方法进行加锁,但是具体是如何加锁的呢?\nA:AQS 的 Acquire 会调用 tryAcquire 方法,tryAcquire 由各个自定义同步器实现,通过 tryAcquire 完成加锁过程。\n4 AQS 应用 # 4.1 ReentrantLock 的可重入应用 # ReentrantLock 的可重入性是 AQS 很好的应用之一,在了解完上述知识点以后,我们很容易得知 ReentrantLock 实现可重入的方法。在 ReentrantLock 里面,不管是公平锁还是非公平锁,都有一段逻辑。\n公平锁:\n// java.util.concurrent.locks.ReentrantLock.FairSync#tryAcquire if (c == 0) { if (!hasQueuedPredecessors() \u0026amp;\u0026amp; compareAndSetState(0, acquires)) { setExclusiveOwnerThread(current); return true; } } else if (current == getExclusiveOwnerThread()) { int nextc = c + acquires; if (nextc \u0026lt; 0) throw new Error(\u0026#34;Maximum lock count exceeded\u0026#34;); setState(nextc); return true; } 非公平锁:\n// java.util.concurrent.locks.ReentrantLock.Sync#nonfairTryAcquire if (c == 0) { if (compareAndSetState(0, acquires)){ setExclusiveOwnerThread(current); return true; } } else if (current == getExclusiveOwnerThread()) { int nextc = c + acquires; if (nextc \u0026lt; 0) // overflow throw new Error(\u0026#34;Maximum lock count exceeded\u0026#34;); setState(nextc); return true; } 从上面这两段都可以看到,有一个同步状态 State 来控制整体可重入的情况。State 是 Volatile 修饰的,用于保证一定的可见性和有序性。\n// java.util.concurrent.locks.AbstractQueuedSynchronizer private volatile int state; 接下来看 State 这个字段主要的过程:\nState 初始化的时候为 0,表示没有任何线程持有锁。 当有线程持有该锁时,值就会在原来的基础上+1,同一个线程多次获得锁是,就会多次+1,这里就是可重入的概念。 解锁也是对这个字段-1,一直到 0,此线程对锁释放。 4.2 JUC 中的应用场景 # 除了上边 ReentrantLock 的可重入性的应用,AQS 作为并发编程的框架,为很多其他同步工具提供了良好的解决方案。下面列出了 JUC 中的几种同步工具,大体介绍一下 AQS 的应用场景:\n同步工具 同步工具与 AQS 的关联 ReentrantLock 使用 AQS 保存锁重复持有的次数。当一个线程获取锁时,ReentrantLock 记录当前获得锁的线程标识,用于检测是否重复获取,以及错误线程试图解锁操作时异常情况的处理。 Semaphore 使用 AQS 同步状态来保存信号量的当前计数。tryRelease 会增加计数,acquireShared 会减少计数。 CountDownLatch 使用 AQS 同步状态来表示计数。计数为 0 时,所有的 Acquire 操作(CountDownLatch 的 await 方法)才可以通过。 ReentrantReadWriteLock 使用 AQS 同步状态中的 16 位保存写锁持有的次数,剩下的 16 位用于保存读锁的持有次数。 ThreadPoolExecutor Worker 利用 AQS 同步状态实现对独占线程变量的设置(tryAcquire 和 tryRelease)。 4.3 自定义同步工具 # 了解 AQS 基本原理以后,按照上面所说的 AQS 知识点,自己实现一个同步工具。\npublic class LeeLock { private static class Sync extends AbstractQueuedSynchronizer { @Override protected boolean tryAcquire (int arg) { return compareAndSetState(0, 1); } @Override protected boolean tryRelease (int arg) { setState(0); return true; } @Override protected boolean isHeldExclusively () { return getState() == 1; } } private Sync sync = new Sync(); public void lock () { sync.acquire(1); } public void unlock () { sync.release(1); } } 通过我们自己定义的 Lock 完成一定的同步功能。\npublic class LeeMain { static int count = 0; static LeeLock leeLock = new LeeLock(); public static void main (String[] args) throws InterruptedException { Runnable runnable = new Runnable() { @Override public void run () { try { leeLock.lock(); for (int i = 0; i \u0026lt; 10000; i++) { count++; } } catch (Exception e) { e.printStackTrace(); } finally { leeLock.unlock(); } } }; Thread thread1 = new Thread(runnable); Thread thread2 = new Thread(runnable); thread1.start(); thread2.start(); thread1.join(); thread2.join(); System.out.println(count); } } 上述代码每次运行结果都会是 20000。通过简单的几行代码就能实现同步功能,这就是 AQS 的强大之处。\n5 总结 # 我们日常开发中使用并发的场景太多,但是对并发内部的基本框架原理了解的人却不多。由于篇幅原因,本文仅介绍了可重入锁 ReentrantLock 的原理和 AQS 原理,希望能够成为大家了解 AQS 和 ReentrantLock 等同步器的“敲门砖”。\n参考资料 # Lea D. The java. util. concurrent synchronizer framework[J]. Science of Computer Programming, 2005, 58(3): 293-309. 《Java 并发编程实战》 不可不说的 Java“锁”事 "},{"id":606,"href":"/zh/docs/technology/Interview/high-quality-technical-articles/interview/technical-preliminary-preparation/","title":"从面试官和候选者的角度谈如何准备技术初试","section":"Interview","content":" 推荐语:从面试官和面试者两个角度探讨了技术面试!非常不错!\n内容概览:\n通过技术基础考察候选者,才能考察到候选者的真实技术实力:技术深度和广度。 实战与理论结合。比如,候选人叙述 JVM 内存模型布局之后,可以接着问:有哪些原因可能会导致 OOM , 有哪些预防措施? 你是否遇到过内存泄露的问题? 如何排查和解决这类问题? 项目经历考察不宜超过两个。因为要深入考察一个项目的详情,所占用的时间还是比较大的。一般来说,会让候选人挑选一个他或她觉得最有收获的/最有挑战的/印象最深刻的/自己觉得特有意思的项目。然后围绕这个项目进行发问。通常是从项目背景出发,考察项目的技术栈、项目模块及交互的整体理解、项目中遇到的有挑战性的技术问题及解决方案、排查和解决问题、代码可维护性问题、工程质量保障等。 多问少说,让候选者多表现。根据候选者的回答适当地引导或递进或横向移动。 原文地址: https://www.cnblogs.com/lovesqcc/p/15169365.html\n考察目标和思路 # 首先明确,技术初试的考察目标:\n候选人的技术基础; 候选人解决问题的思路和能力。 技术基础是基石(冰山之下的东西),占七分, 解决问题的思路和能力是落地(冰山之上露出的部分),占三分。 业务和技术基础考察,三七开。\n技术基础考察 # 为什么要考察技术基础? # 程序员最重要的两种技术思维能力,是逻辑思维能力和抽象设计能力。逻辑思维能力是基础,抽象设计能力是高阶。 考察技术基础,正好可以同时考察这两种思维能力。能不能理解基础技术概念及关联,是考察逻辑思维能力;能不能把业务问题抽象成技术问题并合理的组织映射,是考察抽象设计能力。\n绝大部分业务问题,都可以抽象成技术问题。在某种意义上,业务问题只是技术问题的领域化表述。\n因此,通过技术基础考察候选者,才能考察到候选者的真实技术实力:技术深度和广度。\n技术基础怎么考察? # 技术基础怎么考察?通过有效的多角度的发问模式来考察。\n是什么-为什么 # 是什么考察对概念的基本理解,为什么考察对概念的实现原理。\n比如:索引是什么? 索引是如何实现的?\n引导-横向发问-深入发问 # 引导性,比如 “你对 Java 同步工具熟悉吗?” 作个试探,得到肯定答复后,可以进一步问:“你熟悉哪些同步工具类?” 了解候选者的广度;\n获取候选者的回答后,可以进一步问:“ 谈谈 ConcurrentHashMap 或 AQS 的实现原理?”\n一个人在多大程度上把技术原理能讲得清晰,包括思路和细节,说明他对技术的掌握能力有多强。\n跳跃式/交叉式发问 # 比如:讲到哈希高效查找,可以谈谈哈希一致性算法 。 两者既有关联又有很多不同点。也是一种技术广度的考察方法。\n总结性发问 # 比如:你在做 XXX 中,获得了哪些可以分享的经验? 考察候选人的归纳总结能力。\n实战与理论结合 # 比如,候选人叙述 JVM 内存模型布局之后,可以接着问:有哪些原因可能会导致 OOM , 有哪些预防措施? 你是否遇到过内存泄露的问题? 如何排查和解决这类问题?\n比如,候选人有谈到 SQL 优化和索引优化,那就正好谈谈索引的实现原理,如何建立最佳索引?\n再比如,候选人有谈到事务,那就正好谈谈事务实现原理,隔离级别,快照实现等;\n熟悉与不熟悉结合 # 针对候选人简历上写的熟悉的部分,和没有写出的都问下。比如候选人简历上写着:熟悉 JVM 内存模型, 那我就考察下内存管理相关(熟悉部分),再考察下 Java 并发工具类(不确定是否熟悉部分)。\n死知识与活知识结合 # 比如,查找算法有哪些?顺序查找、二分查找、哈希查找。这些大家通常能说出来,也是“死知识”。\n这些查找算法各适用于什么场景?在你工作中,有哪些场景用到了哪些查找算法?为什么? 这些是“活知识”。\n学习或工作中遇到的 # 有时,在学习和工作中遇到的问题,也可以作为面试题。\n比如,最近在学习《操作系统导论》并发部分,有一章节是如何使数据结构成为线程安全的。这里就有一些可以提问的地方:如何实现一个锁?如何实现一个线程安全的计数器?如何实现一个线程安全的链表?如何实现一个线程安全的 Map ?如何提升并发的性能?\n工作中遇到的问题,也可以抽象提炼出来,作为技术基础面试题。\n技术栈适配度发问 # 如果候选人(简历上所写的)使用的某些技术与本公司的技术栈比较契合,则可以针对这些技术点进行深入提问,考察候选人在这些技术点的掌握程度。如果掌握程度比较好,则技术适配度相对更高一些。\n当然,这一点并不能作为筛掉那些没有使用该技术栈的候选人的依据。比如本公司使用 MongoDB 和 MySQL, 而一个候选人没有用过 Mongodb, 但使用过 MySQL, Redis, ES, HBase 等多种存储系统,那么适配度并不比仅使用过 MySQL 和 MongoDB 的候选人逊色,因为他所涉及的技术广度更大,可以推断出他有足够能力掌握 Mongodb。\n创造有个性的面试题库 # 每个技术面试官都会有一个面试题库。持续积累面试题库,日常中突然想到的问题,就随手记录下来。\n业务维度考察 # 为什么要考察业务维度? # 技术基础考察,容易错过的地方是,候选人的非技术能力特质,比如沟通组织能力、带项目能力、抗压能力、解决实际问题的能力、团队影响力、其它性格特质等。\n为什么不能单考察业务维度? # 因为业务方面通常比较熟悉,可能就直接按照现有方案说出来了,很难考察到候选人的深入理解、横向拓展和归纳总结能力。\n这一点,建议有针对性地考察下候选人的归纳总结能力:比如, 微服务搭建或开发或维护/保证系统稳定性或性能方面的过程中,你收获了哪些可以分享的经验?\n解决问题能力考察 # 仅仅只是技术基础还不够,通常最好结合实际业务,针对他项目里的业务,抽象出技术问题进行考察。\n解决思路重在层层递进。这一点对于面试官的要求也比较高,兼具良好的倾听能力、技术深度和业务经验。首先要仔细倾听候选人的阐述,找到适当的技术切入点,然后进行发问。如果进不去,那就容易考察失败。\n设计问题 # 比如多个机器间共享大量业务对象,这些业务对象之间有些联合字段是重复的,如何去重? 如果瞬时有大量请求涌入,如何保证服务器的稳定性? 项目经历 # 项目经历考察不宜超过两个。因为要深入考察一个项目的详情,所占用的时间还是比较大的。\n一般来说,会让候选人挑选一个他或她觉得最有收获的/最有挑战的/印象最深刻的/自己觉得特有意思的项目。然后围绕这个项目进行发问。通常是从项目背景出发,考察项目的技术栈、项目模块及交互的整体理解、项目中遇到的有挑战性的技术问题及解决方案、排查和解决问题、代码可维护性问题、工程质量保障等。\n面试官如何做好一场面试? # 预先准备 # 面试官也需要做一些准备。比如熟悉候选者的技能优势、工作经历等,做一个面试设计。\n在面试将要开始时,做好面试准备。此外,面试官也需要对公司的一些基本情况有所了解,尤其是公司所使用技术栈、业务全景及方向、工作内容、晋升制度等,这一点技术型候选人问得比较多。\n面试启动 # 一般以候选人自我介绍启动,不过候选人往往会谈得比较散,因此,我会直接提问:谈谈你有哪些优势以及自己觉得可以改进的地方?\n然后以一个相对简单的基础题作为技术提问的开始:你熟悉哪些查找算法?大多数人是能答上顺序查找、二分查找、哈希查找的。\n问题设计 # 提前阅读候选人简历,从简历中筛选出关键词,根据这些关键词进行有针对性地问题设计。\n比如候选人简历里提到 MVVM ,可以问 MVVM 与 MVC 的区别; 提到了观察者模式,可以谈谈观察者模式,顺便问问他还熟悉哪些设计模式。\n宽松氛围 # 即使问的问题比较多比较难,也要注意保持宽松氛围。\n在面试前,根据候选人基本信息适当调侃一下,比如一位候选人叫汪奎,那我就说:之前我们团队有位叫袁奎,我们都喊他奎爷。\n在面试过程中,适当提示,或者给出少量自己的看法,也能缓解候选人的紧张情绪。\n学会倾听 # 多问少说,让候选者多表现。根据候选者的回答适当地引导或递进或横向移动。\n引导候选人表现他最优势的一面,让他或她感觉好一些:毕竟一场面试双方都付出了时间和精力,不应该是面试官 Diss 候选人的场合,而应该让彼此有更好的交流。很大可能,你也能从候选人那里学到不少东西。\n面试这件事,只不过双方的角色和立场有所不同,但并不代表面试官的水平就一定高于候选人。\n记录重点 # 认真客观地记录候选人的回答,尽可能避免任何主观评价,亦不作任何加工(比如自己给总结一下,总结能力也是候选人的一个特质)。\n作出判断 # 面试过程是一种铺垫,关键的是作出判断。\n作出判断最容易陷入误区的是:贪深求全。总希望候选人技术又深入又全面。实际上,这是一种奢望。如果候选人的技术能力又深入又全面,很可能也会面临两种情况:\n候选人有更好的选择; 候选人在其它方面可能存在不足,比如团队协作方面。 一个比较合适的尺度是:\n他或她的技术水平能否胜任当前工作; 他或她的技术水平与同组团队成员水平如何; 他或她的技术水平是否与年限相对匹配,是否有潜力胜任更复杂的任务。 不同年龄看重的东西不一样。\n对于三年以下的工程师,应当更看重其技术基础,因为这代表着他的未来潜能;同时也考察下他在实际开发中的体现,比如团队协作、业务经验、抗压能力、主动学习的热情和能力等。\n对于三年以上的工程师,应当更看重其业务经验、解决问题能力,看看他或她是如何分析具体问题,在业务范畴内考察其技术基础的深度和广度。\n如何判断一个候选人的真实技术水平及是否适合所需,这方面,我也在学习中。\n给候选人的话 # 关注技术基础 # 一个常见的疑惑是:开发业务系统的大多数时候,基本不涉及数据结构与算法的设计与实现,为什么要考察 HashMap 的实现原理?为什么要学好数据结构与算法、操作系统、网络通信这些基础课程?\n现在我可以给出一个答案了:\n正如上面所述,绝大多数的业务问题,实际上最终都会映射到基础技术问题上:数据结构与算法的实现、内存管理、并发控制、网络通信等;这些是理解现代互联网大规模程序以及解决程序疑难问题的基石,—— 除非能祝福自己永远都不会遇到疑难问题,永远都只满足于编写 CRUD; 这些技术基础正是程序世界里最有趣最激动人心的地方。如果对这些不感兴趣,就很难在这个领域里深入进去,不如及早转行从事其它职业,非技术的世界一直都很精彩广阔(有时我也想多出去走走,不想局限于技术世界); 技术基础是程序员的内功,而具体技术则是招式。徒有招式而内功不深,遇到高手(优秀同行从业者的竞争及疑难杂症)容易不堪一击; 具备扎实的专业技术基础,能达到的上限更高,未来更有可能胜任复杂的技术问题求解,或者在同样的问题上能够做到更好的方案; 人们喜欢跟与自己相似的人合作,牛人倾向于与牛人合作能得到更好的效果;如果一个团队大部分人技术基础比较好,那么进来一个技术基础比较薄弱的人,协作成本会变高;如果你想和牛人一起合作拿到更好的结果,那就要让自己至少在技术基础上能够与牛人搭配的上; 在 CRUD 的基础上拓展其它才能也不失为一种好的选择,但这不会是一个真正的程序员的姿态,顶多是有技术基础的产品经理、项目经理、HR、运营、客满等其它岗位人才。这是职业选择的问题,已经超出了考察程序员的范畴。 不要在意某个问题回答不上来 # 如果面试官问你很多问题,而有些没有回答上来,不要在意。面试官很可能只是在测试你的技术深度和广度,然后判断你是否达到某个水位线。\n重点是:有些问题你答得很有深度,也体现了你的深度思考能力。\n这一点是我当了技术面试官才领会到的。当然,并不是每位技术面试官都是这么想的,但我觉得这应该是个更合适的方式。\n"},{"id":607,"href":"/zh/docs/technology/Interview/high-quality-technical-articles/personal-experience/four-year-work-in-tencent-summary/","title":"从校招入职腾讯的四年工作总结","section":"Personal Experience","content":"程序员是一个流动性很大的职业,经常会有新面孔的到来,也经常会有老面孔的离开,有主动离开的,也有被动离职的。\n再加上这几年卷得厉害,做的事更多了,拿到的却更少了,互联网好像也没有那么香了。\n人来人往,变动无常的状态,其实也早已习惯。\n打工人的唯一出路,无外乎精进自己的专业技能,提升自己的核心竞争力,这样无论有什么变动,走到哪里,都能有口饭吃。\n今天分享一位博主,校招入职腾讯,工作四年后,离开的故事。\n至于为什么离开,我也不清楚,可能是有其他更好的选择,或者是觉得当前的工作对自己的提升有限。\n下文中的“我”,指这位作者本人。\n原文地址: https://zhuanlan.zhihu.com/p/602517682\n研究生毕业后, 一直在腾讯工作,不知不觉就过了四年。个人本身没有刻意总结的习惯,以前只顾着往前奔跑了,忘了停下来思考总结。记得看过一个职业规划文档,说的三年一个阶段,五年一个阶段的说法,现在恰巧是四年,同时又从腾讯离开,该做一个总结了。\n先对自己这四年做一个简单的评价吧:个人认为,没有完全的浪费和辜负这四年的光阴。为何要这么说了?因为我发现和别人对比,好像意义不大,比我混的好的人很多;比我混的差的人也不少。说到底,我只是一个普普通通的人,才不惊人,技不压众,接受自己的平凡,然后看自己做的,是否让自己满意就好。\n下面具体谈几点吧,我主要想聊下工作,绩效,EPC,嫡系看法,最后再谈下收获。\n工作情况 # 我在腾讯内部没有转过岗,但是做过的项目也还是比较丰富的,包括:BUGLY、分布式调用链(Huskie)、众包系统(SOHO),EPC 度量系统。其中一些是对外的,一些是内部系统,可能有些大家不知道。还是比较感谢这些项目经历,既有纯业务的系统,也有偏框架的系统,让我学到了不少知识。\n接下来,简单介绍一下每个项目吧,毕竟每一个项目都付出了很多心血的:\nBUGLY,这是一个终端 Crash 联网上报的系统,很多 APP 都接入了。Huskie,这是一个基于 zipkin 搭建的分布式调用链跟踪项目。SOHO,这是一个众包系统,主要是将数据标准和语音采集任务众包出去,让人家做。EPC 度量系统,这是研发效能度量系统,主要是度量研发效能情况的。这里我谈一下对于业务开发的理解和认识,很多人可能都跟我最开始一样,有一个疑惑,整天做业务开发如何成长?换句话说,就是说整天做 CRUD,如何成长?我开始也有这样的疑惑,后来我转变了观念。\n我觉得对于系统的复杂度,可以粗略的分为技术复杂度和业务复杂度,对于业务系统,就是业务复杂度高一些,对于框架系统就是技术复杂度偏高一些。解决这两种复杂度,都具有很大的挑战。\n此前做过的众包系统,就是各种业务逻辑,搞过去,搞过来,其实这就是业务复杂度高。为了解决这个问题,我们开始探索和实践领域驱动(DDD),确实带来了一些帮助,不至于系统那么混乱了。同时,我觉得这个过程中,自己对于 DDD 的感悟,对于我后来的项目系统划分和设计以及开发都带来了帮助。\n当然 DDD 不是银弹,我也不是吹嘘它有多好,只是了解了它后,有时候设计和开发时,能换一种思路。\n可以发现,其实平时咱们做业务,想做好,其实也没那么容易,如果可以多探索多实践,将一些好的方法或思想或架构引入进来,与个人和业务都会有有帮助。\n绩效情况 # 我在腾讯工作四年,腾讯半年考核一次,一共考核八次,回想了下,四年来的绩效情况为:三星,三星,五星,三星,五星,四星,四星,三星。统计一下, 四五星占比刚好一半。\nPS:还好以前有奖杯,不然一点念想都没了。(现在腾讯似乎不发了)\n印象比较深的是两次五星获得经历。第一次五星是工作的第二年,那一年是在做众包项目,因为项目本身难度不大,因此我把一些精力投入到了团队的基础建设中,帮团队搭建了 java 以及 golang 的项目脚手架,又做了几次中心技术分享,最终 Leader 觉得我表现比较突出,因此给了我五星。看来,主动一些,与个人与团队都是有好处的,最终也能获得一些回报。\n第二次五星,就是与 EPC 有关了。说一个搞笑的事,我也是后来才知道的,项目初期,总监去汇报时,给老板演示系统,加载了很久指标才刷出来,总监很不好意思的说正在优化;过了一段时间,又去汇报演示,结果又很尴尬的刷了很久才出来,总监无赖表示还是在优化。没想到,自己曾经让总监这么丢脸,哈哈。好吧,说一下结果,最终,我自己写了一个查询引擎替换了 Mondrian,之后再也没有出现那种尴尬的情况了。随之而来,也给了好绩效鼓励。做 EPC 度量项目,我觉得自己成长很大,比如抗压能力,当你从零到一搭建一个系统时,会有一个先扛住再优化的过程,此外如果你的项目很重要,尤其是数据相关,那么任何一点问题,都可能让你神经紧绷,得想尽办法降低风险和故障。此外,另一个不同的感受就是,以前得项目,我大多是开发者,而这个系统,我是 Owner 负责人,当你 Owner 一个系统时,你得时刻负责,同时还需要思考系统的规划和方向,此外还需要分配好需求和把控进度,角色体验跟以前完全不一样。\n谈谈 EPC # 很多人都骂 EPC,或者笑 EPC,作为度量平台核心开发者之一,我来谈谈客观的看法。\n其实 EPC 初衷是好的,希望通过全方位多维度的研效指标,来度量研发效能各环节的质量,进而反推业务,提升研发效能。然而,最终在实践的过程中,才发现,客观条件并不支持(工具还没建设好);此外,一味的追求指标数据,使得下面的人想方设法让指标好看,最终违背了初衷。\n为什么,说 EPC 好了,其实如果你仔细了解下 EPC,你就会发现,他是一套相当完善且比较先进的指标度量体系。覆盖了需求,代码,缺陷,测试,持续集成,运营部署各个环节。\n此外,这个过程中,虽然一些人和一些业务做弊,但绝大多数业务还是做出了改变的,比如微视那边的人反馈是,以前的代码写的跟屎一样,当有了 EPC 后,代码质量好了很多。虽然最后微视还是亡了,但是大厦将倾,EPC 是救不了的,亡了也更不能怪 EPC。\n谈谈嫡系 # 大家都说腾讯,嫡系文化盛行。但其实我觉得在那个公司都一样吧。这也符合事物的基本规律,人们只相信自己信任并熟悉的人。作为领导,你难道会把把重要的事情交给自己不熟悉的人吗?\n其实我也不知道我算不算嫡系,脉脉上有人问过”怎么知道自己算不算嫡系”,下面有一个回答,我觉得很妙:如果你不知道你是不是嫡系,那你就不是。哈哈,这么说来,我可能不是。\n但另一方面,后来我负责了团队内很重要的事情,应该是中心内都算很重要的事,我独自负责一个方向,直接向总监汇报,似乎又有点像。\n网上也有其他说法,一针见血,是不是嫡系,就看钱到不到位,这么说也有道理。我在 7 级时,就发了股票,自我感觉,还是不错的。我当时以为不出意外的话,我以后的钱途和发展是不是就会一帆风顺。不出意外就出了意外,第二年,EPC 不达预期,部门总经理和总监都被换了,中心来了一个新的的总监。\n好吧,又要重新建立信任了。再到后来,是不是嫡系已经不重要了,因为大环境不好,又加上裁员,大家主动的被动的差不多都走了。\n总结一下,嫡系的存在,其实情有可原。怎么样成为嫡系了?其实我也不知道。不过,我觉得,与其思考怎么成为嫡系,不如思考怎么展现自己的价值和能力,当别人发现你的价值和能力了,那自然更多的机会就会给予你,有了机会,只要把握住了,那就有更多的福利了。\n再谈收获 # 收获,什么叫做收获了?个人觉得无论是外在的物质,技能,职级;还是内在的感悟,认识,都算收获。\n先说一些可量化的吧,我觉得有:\n级别上,升上了九级,高级工程师。虽然大家都在说腾讯职级缩水,但是有没有高工的能力自己其实是知道的,我个人感觉,通过我这几年的努力,我算是达到了我当时认为的我需要在高工时达到的状态; 绩效上,自我评价,个人不是一个特别卷的人,或者说不会为了卷而卷。但是,如果我认定我应该把它做好得,我的 Owner 意识,以及负责态度,我觉得还是可以的。最终在腾讯四年的绩效也还算过的去。再谈一些其他软技能方面: 1、文档能力\n作为程序员,文档能力其实是一项很重要的能力。其实我也没觉得自己文档能力有多好,但是前后两任总监,都说我的文档不错,那看来,我可能在平均水准之上。\n2、明确方向\n最后,说一个更虚的,但是我觉得最有价值的收获: 我逐渐明确了,或者确定了以后的方向和路,那就是走数据开发。\n其实,找到并确定一个目标很难,身边有清晰目标和方向的人很少,大多数是迷茫的。\n前一段时间,跟人聊天,谈到职业规划,说是可以从两个角度思考:\n选一个业务方向,比如电商,广告,不断地积累业务领域知识和业务相关技能,随着经验的不断积累,最终你就是这个领域的专家。 深入一个技术方向,不断钻研底层技术知识,这样就有希望成为此技术专家。坦白来说,虽然我深入研究并实践过领域驱动设计,也用来建模和解决了一些复杂业务问题,但是发自内心的,我其实更喜欢钻研技术,同时,我又对大数据很感兴趣。因此,我决定了,以后的方向,就做数据相关的工作。 腾讯的四年,是我的第一份工作经历,认识了很多厉害的人,学到了很多。最后自己主动离开,也算走的体面(即使损失了大礼包),还是感谢腾讯。\n"},{"id":608,"href":"/zh/docs/technology/Interview/java/jvm/jvm-intro/","title":"大白话带你认识 JVM","section":"Jvm","content":" 来自 说出你的愿望吧丷投稿,原文地址: https://juejin.im/post/5e1505d0f265da5d5d744050。\n前言 # 如果在文中用词或者理解方面出现问题,欢迎指出。此文旨在提及而不深究,但会尽量效率地把知识点都抛出来\n一、JVM 的基本介绍 # JVM 是 Java Virtual Machine 的缩写,它是一个虚构出来的计算机,一种规范。通过在实际的计算机上仿真模拟各类计算机功能实现···\n好,其实抛开这么专业的句子不说,就知道 JVM 其实就类似于一台小电脑运行在 windows 或者 linux 这些操作系统环境下即可。它直接和操作系统进行交互,与硬件不直接交互,而操作系统可以帮我们完成和硬件进行交互的工作。\n1.1 Java 文件是如何被运行的 # 比如我们现在写了一个 HelloWorld.java 好了,那这个 HelloWorld.java 抛开所有东西不谈,那是不是就类似于一个文本文件,只是这个文本文件它写的都是英文,而且有一定的缩进而已。\n那我们的 JVM 是不认识文本文件的,所以它需要一个 编译 ,让其成为一个它会读二进制文件的 HelloWorld.class\n① 类加载器 # 如果 JVM 想要执行这个 .class 文件,我们需要将其装进一个 类加载器 中,它就像一个搬运工一样,会把所有的 .class 文件全部搬进 JVM 里面来。\n② 方法区 # 方法区 是用于存放类似于元数据信息方面的数据的,比如类信息,常量,静态变量,编译后代码···等\n类加载器将 .class 文件搬过来就是先丢到这一块上\n③ 堆 # 堆 主要放了一些存储的数据,比如对象实例,数组···等,它和方法区都同属于 线程共享区域 。也就是说它们都是 线程不安全 的\n④ 栈 # 栈 这是我们的代码运行空间。我们编写的每一个方法都会放到 栈 里面运行。\n我们会听说过 本地方法栈 或者 本地方法接口 这两个名词,不过我们基本不会涉及这两块的内容,它俩底层是使用 C 来进行工作的,和 Java 没有太大的关系。\n⑤ 程序计数器 # 主要就是完成一个加载工作,类似于一个指针一样的,指向下一行我们需要执行的代码。和栈一样,都是 线程独享 的,就是说每一个线程都会有自己对应的一块区域而不会存在并发和多线程的问题。\n小总结 # Java 文件经过编译后变成 .class 字节码文件 字节码文件通过类加载器被搬运到 JVM 虚拟机中 虚拟机主要的 5 大块:方法区,堆都为线程共享区域,有线程安全问题,栈和本地方法栈和计数器都是独享区域,不存在线程安全问题,而 JVM 的调优主要就是围绕堆,栈两大块进行 1.2 简单的代码例子 # 一个简单的学生类\n一个 main 方法\n执行 main 方法的步骤如下:\n编译好 App.java 后得到 App.class 后,执行 App.class,系统会启动一个 JVM 进程,从 classpath 路径中找到一个名为 App.class 的二进制文件,将 App 的类信息加载到运行时数据区的方法区内,这个过程叫做 App 类的加载 JVM 找到 App 的主程序入口,执行 main 方法 这个 main 中的第一条语句为 Student student = new Student(\u0026ldquo;tellUrDream\u0026rdquo;) ,就是让 JVM 创建一个 Student 对象,但是这个时候方法区中是没有 Student 类的信息的,所以 JVM 马上加载 Student 类,把 Student 类的信息放到方法区中 加载完 Student 类后,JVM 在堆中为一个新的 Student 实例分配内存,然后调用构造函数初始化 Student 实例,这个 Student 实例持有 指向方法区中的 Student 类的类型信息 的引用 执行 student.sayName();时,JVM 根据 student 的引用找到 student 对象,然后根据 student 对象持有的引用定位到方法区中 student 类的类型信息的方法表,获得 sayName() 的字节码地址。 执行 sayName() 其实也不用管太多,只需要知道对象实例初始化时会去方法区中找类信息,完成后再到栈那里去运行方法。找方法就在方法表中找。\n二、类加载器的介绍 # 之前也提到了它是负责加载.class 文件的,它们在文件开头会有特定的文件标示,将 class 文件字节码内容加载到内存中,并将这些内容转换成方法区中的运行时数据结构,并且 ClassLoader 只负责 class 文件的加载,而是否能够运行则由 Execution Engine 来决定\n2.1 类加载器的流程 # 从类被加载到虚拟机内存中开始,到释放内存总共有 7 个步骤:加载,验证,准备,解析,初始化,使用,卸载。其中验证,准备,解析三个部分统称为连接\n2.1.1 加载 # 将 class 文件加载到内存 将静态数据结构转化成方法区中运行时的数据结构 在堆中生成一个代表这个类的 java.lang.Class 对象作为数据访问的入口 2.1.2 链接 # 验证:确保加载的类符合 JVM 规范和安全,保证被校验类的方法在运行时不会做出危害虚拟机的事件,其实就是一个安全检查 准备:为 static 变量在方法区中分配内存空间,设置变量的初始值,例如 static int a = 3 (注意:准备阶段只设置类中的静态变量(方法区中),不包括实例变量(堆内存中),实例变量是对象初始化时赋值的) 解析:虚拟机将常量池内的符号引用替换为直接引用的过程(符号引用比如我现在 import java.util.ArrayList 这就算符号引用,直接引用就是指针或者对象地址,注意引用对象一定是在内存进行) 2.1.3 初始化 # 初始化其实就是执行类构造器方法的\u0026lt;clinit\u0026gt;()的过程,而且要保证执行前父类的\u0026lt;clinit\u0026gt;()方法执行完毕。这个方法由编译器收集,顺序执行所有类变量(static 修饰的成员变量)显式初始化和静态代码块中语句。此时准备阶段时的那个 static int a 由默认初始化的 0 变成了显式初始化的 3。 由于执行顺序缘故,初始化阶段类变量如果在静态代码块中又进行了更改,会覆盖类变量的显式初始化,最终值会为静态代码块中的赋值。\n注意:字节码文件中初始化方法有两种,非静态资源初始化的\u0026lt;init\u0026gt;和静态资源初始化的\u0026lt;clinit\u0026gt;,类构造器方法\u0026lt;clinit\u0026gt;()不同于类的构造器,这些方法都是字节码文件中只能给 JVM 识别的特殊方法。\n2.1.4 卸载 # GC 将无用对象从内存中卸载\n2.2 类加载器的加载顺序 # 加载一个 Class 类的顺序也是有优先级的,类加载器从最底层开始往上的顺序是这样的\nBootStrap ClassLoader:rt.jar Extension ClassLoader: 加载扩展的 jar 包 App ClassLoader:指定的 classpath 下面的 jar 包 Custom ClassLoader:自定义的类加载器 2.3 双亲委派机制 # 当一个类收到了加载请求时,它是不会先自己去尝试加载的,而是委派给父类去完成,比如我现在要 new 一个 Person,这个 Person 是我们自定义的类,如果我们要加载它,就会先委派 App ClassLoader ,只有当父类加载器都反馈自己无法完成这个请求(也就是父类加载器都没有找到加载所需的 Class)时,子类加载器才会自行尝试加载。\n这样做的好处是,加载位于 rt.jar 包中的类时不管是哪个加载器加载,最终都会委托到 BootStrap ClassLoader 进行加载,这样保证了使用不同的类加载器得到的都是同一个结果。\n其实这个也是一个隔离的作用,避免了我们的代码影响了 JDK 的代码,比如我现在自己定义一个 java.lang.String:\npackage java.lang; public class String { public static void main(String[] args) { System.out.println(); } } 尝试运行当前类的 main 函数的时候,我们的代码肯定会报错。这是因为在加载的时候其实是找到了 rt.jar 中的java.lang.String,然而发现这个里面并没有 main 方法。\n三、运行时数据区 # 3.1 本地方法栈和程序计数器 # 比如说我们现在点开 Thread 类的源码,会看到它的 start0 方法带有一个 native 关键字修饰,而且不存在方法体,这种用 native 修饰的方法就是本地方法,这是使用 C 来实现的,然后一般这些方法都会放到一个叫做本地方法栈的区域。\n程序计数器其实就是一个指针,它指向了我们程序中下一句需要执行的指令,它也是内存区域中唯一一个不会出现 OutOfMemoryError 的区域,而且占用内存空间小到基本可以忽略不计。这个内存仅代表当前线程所执行的字节码的行号指示器,字节码解析器通过改变这个计数器的值选取下一条需要执行的字节码指令。\n如果执行的是 native 方法,那这个指针就不工作了。\n3.2 方法区 # 方法区主要的作用是存放类的元数据信息,常量和静态变量···等。当它存储的信息过大时,会在无法满足内存分配时报错。\n3.3 虚拟机栈和虚拟机堆 # 一句话便是:栈管运行,堆管存储。则虚拟机栈负责运行代码,而虚拟机堆负责存储数据。\n3.3.1 虚拟机栈的概念 # 它是 Java 方法执行的内存模型。里面会对局部变量,动态链表,方法出口,栈的操作(入栈和出栈)进行存储,且线程独享。同时如果我们听到局部变量表,那也是在说虚拟机栈\npublic class Person{ int a = 1; public void doSomething(){ int b = 2; } } 3.3.2 虚拟机栈存在的异常 # 如果线程请求的栈的深度大于虚拟机栈的最大深度,就会报 StackOverflowError (这种错误经常出现在递归中)。Java 虚拟机也可以动态扩展,但随着扩展会不断地申请内存,当无法申请足够内存时就会报错 OutOfMemoryError。\n3.3.3 虚拟机栈的生命周期 # 对于栈来说,不存在垃圾回收。只要程序运行结束,栈的空间自然就会释放了。栈的生命周期和所处的线程是一致的。\n这里补充一句:8 种基本类型的变量+对象的引用变量+实例方法都是在栈里面分配内存。\n3.3.4 虚拟机栈的执行 # 我们经常说的栈帧数据,说白了在 JVM 中叫栈帧,放到 Java 中其实就是方法,它也是存放在栈中的。\n栈中的数据都是以栈帧的格式存在,它是一个关于方法和运行期数据的数据集。比如我们执行一个方法 a,就会对应产生一个栈帧 A1,然后 A1 会被压入栈中。同理方法 b 会有一个 B1,方法 c 会有一个 C1,等到这个线程执行完毕后,栈会先弹出 C1,后 B1,A1。它是一个先进后出,后进先出原则。\n3.3.5 局部变量的复用 # 局部变量表用于存放方法参数和方法内部所定义的局部变量。它的容量是以 Slot 为最小单位,一个 slot 可以存放 32 位以内的数据类型。\n虚拟机通过索引定位的方式使用局部变量表,范围为 [0,局部变量表的 slot 的数量]。方法中的参数就会按一定顺序排列在这个局部变量表中,至于怎么排的我们可以先不关心。而为了节省栈帧空间,这些 slot 是可以复用的,当方法执行位置超过了某个变量,那么这个变量的 slot 可以被其它变量复用。当然如果需要复用,那我们的垃圾回收自然就不会去动这些内存。\n3.3.6 虚拟机堆的概念 # JVM 内存会划分为堆内存和非堆内存,堆内存中也会划分为年轻代和老年代,而非堆内存则为永久代。年轻代又会分为Eden和Survivor区。Survivor 也会分为FromPlace和ToPlace,toPlace 的 survivor 区域是空的。Eden,FromPlace 和 ToPlace 的默认占比为 8:1:1。当然这个东西其实也可以通过一个 -XX:+UsePSAdaptiveSurvivorSizePolicy 参数来根据生成对象的速率动态调整\n堆内存中存放的是对象,垃圾收集就是收集这些对象然后交给 GC 算法进行回收。非堆内存其实我们已经说过了,就是方法区。在 1.8 中已经移除永久代,替代品是一个元空间(MetaSpace),最大区别是 metaSpace 是不存在于 JVM 中的,它使用的是本地内存。并有两个参数\nMetaspaceSize:初始化元空间大小,控制发生GC MaxMetaspaceSize:限制元空间大小上限,防止占用过多物理内存。 移除的原因可以大致了解一下:融合 HotSpot JVM 和 JRockit VM 而做出的改变,因为 JRockit 是没有永久代的,不过这也间接性地解决了永久代的 OOM 问题。\n3.3.7 Eden 年轻代的介绍 # 当我们 new 一个对象后,会先放到 Eden 划分出来的一块作为存储空间的内存,但是我们知道对堆内存是线程共享的,所以有可能会出现两个对象共用一个内存的情况。这里 JVM 的处理是为每个线程都预先申请好一块连续的内存空间并规定了对象存放的位置,而如果空间不足会再申请多块内存空间。这个操作我们会称作 TLAB,有兴趣可以了解一下。\n当 Eden 空间满了之后,会触发一个叫做 Minor GC(就是一个发生在年轻代的 GC)的操作,存活下来的对象移动到 Survivor0 区。Survivor0 区满后触发 Minor GC,就会将存活对象移动到 Survivor1 区,此时还会把 from 和 to 两个指针交换,这样保证了一段时间内总有一个 survivor 区为空且 to 所指向的 survivor 区为空。经过多次的 Minor GC 后仍然存活的对象(这里的存活判断是 15 次,对应到虚拟机参数为 -XX:MaxTenuringThreshold 。为什么是 15,因为 HotSpot 会在对象头中的标记字段里记录年龄,分配到的空间仅有 4 位,所以最多只能记录到 15)会移动到老年代。\n🐛 修正:当 Eden 区内存空间满了的时候,就会触发 Minor GC,Survivor0 区满不会触发 Minor GC 。\n那 Survivor0 区 的对象什么时候垃圾回收呢?\n假设 Survivor0 区现在是满的,此时又触发了 Minor GC ,发现 Survivor0 区依旧是满的,存不下,此时会将 S0 区与 Eden 区的对象一起进行可达性分析,找出活跃的对象,将它复制到 S1 区并且将 S0 区域和 Eden 区的对象给清空,这样那些不可达的对象进行清除,并且将 S0 区 和 S1 区交换。\n老年代是存储长期存活的对象的,占满时就会触发我们最常听说的 Full GC,期间会停止所有线程等待 GC 的完成。所以对于响应要求高的应用应该尽量去减少发生 Full GC 从而避免响应超时的问题。\n而且当老年区执行了 full gc 之后仍然无法进行对象保存的操作,就会产生 OOM,这时候就是虚拟机中的堆内存不足,原因可能会是堆内存设置的大小过小,这个可以通过参数-Xms、-Xmx 来调整。也可能是代码中创建的对象大且多,而且它们一直在被引用从而长时间垃圾收集无法收集它们。\n补充说明:关于-XX:TargetSurvivorRatio 参数的问题。其实也不一定是要满足-XX:MaxTenuringThreshold 才移动到老年代。可以举个例子:如对象年龄 5 的占 30%,年龄 6 的占 36%,年龄 7 的占 34%,加入某个年龄段(如例子中的年龄 6)后,总占用超过 Survivor 空间*TargetSurvivorRatio 的时候,从该年龄段开始及大于的年龄对象就要进入老年代(即例子中的年龄 6 对象,就是年龄 6 和年龄 7 晋升到老年代),这时候无需等到 MaxTenuringThreshold 中要求的 15\n3.3.8 如何判断一个对象需要被干掉 # 图中程序计数器、虚拟机栈、本地方法栈,3 个区域随着线程的生存而生存的。内存分配和回收都是确定的。随着线程的结束内存自然就被回收了,因此不需要考虑垃圾回收的问题。而 Java 堆和方法区则不一样,各线程共享,内存的分配和回收都是动态的。因此垃圾收集器所关注的都是堆和方法这部分内存。\n在进行回收前就要判断哪些对象还存活,哪些已经死去。下面介绍两个基础的计算方法\n1.引用计数器计算:给对象添加一个引用计数器,每次引用这个对象时计数器加一,引用失效时减一,计数器等于 0 时就是不会再次使用的。不过这个方法有一种情况就是出现对象的循环引用时 GC 没法回收。\n2.可达性分析计算:这是一种类似于二叉树的实现,将一系列的 GC ROOTS 作为起始的存活对象集,从这个节点往下搜索,搜索所走过的路径成为引用链,把能被该集合引用到的对象加入到集合中。搜索当一个对象到 GC Roots 没有使用任何引用链时,则说明该对象是不可用的。主流的商用程序语言,例如 Java,C#等都是靠这招去判定对象是否存活的。\n(了解一下即可)在 Java 语言汇总能作为 GC Roots 的对象分为以下几种:\n虚拟机栈(栈帧中的本地方法表)中引用的对象(局部变量) 方法区中静态变量所引用的对象(静态变量) 方法区中常量引用的对象 本地方法栈(即 native 修饰的方法)中 JNI 引用的对象(JNI 是 Java 虚拟机调用对应的 C 函数的方式,通过 JNI 函数也可以创建新的 Java 对象。且 JNI 对于对象的局部引用或者全局引用都会把它们指向的对象都标记为不可回收) 已启动的且未终止的 Java 线程 这种方法的优点是能够解决循环引用的问题,可它的实现需要耗费大量资源和时间,也需要 GC(它的分析过程引用关系不能发生变化,所以需要停止所有进程)\n3.3.9 如何宣告一个对象的真正死亡 # 首先必须要提到的是一个名叫 finalize() 的方法\nfinalize()是 Object 类的一个方法、一个对象的 finalize()方法只会被系统自动调用一次,经过 finalize()方法逃脱死亡的对象,第二次不会再调用。\n补充一句:并不提倡在程序中调用 finalize()来进行自救。建议忘掉 Java 程序中该方法的存在。因为它执行的时间不确定,甚至是否被执行也不确定(Java 程序的不正常退出),而且运行代价高昂,无法保证各个对象的调用顺序(甚至有不同线程中调用)。在 Java9 中已经被标记为 deprecated ,且 java.lang.ref.Cleaner(也就是强、软、弱、幻象引用的那一套)中已经逐步替换掉它,会比 finalize 来的更加的轻量及可靠。\n判断一个对象的死亡至少需要两次标记\n如果对象进行可达性分析之后没发现与 GC Roots 相连的引用链,那它将会第一次标记并且进行一次筛选。判断的条件是决定这个对象是否有必要执行 finalize()方法。如果对象有必要执行 finalize()方法,则被放入 F-Queue 队列中。 GC 对 F-Queue 队列中的对象进行二次标记。如果对象在 finalize()方法中重新与引用链上的任何一个对象建立了关联,那么二次标记时则会将它移出“即将回收”集合。如果此时对象还没成功逃脱,那么只能被回收了。 如果确定对象已经死亡,我们又该如何回收这些垃圾呢\n3.4 垃圾回收算法 # 关于常见垃圾回收算法的详细介绍,建议阅读这篇: JVM 垃圾回收详解(重点)。\n3.5 (了解)各种各样的垃圾回收器 # HotSpot VM 中的垃圾回收器,以及适用场景\n到 jdk8 为止,默认的垃圾收集器是 Parallel Scavenge 和 Parallel Old\n从 jdk9 开始,G1 收集器成为默认的垃圾收集器 目前来看,G1 回收器停顿时间最短而且没有明显缺点,非常适合 Web 应用。在 jdk8 中测试 Web 应用,堆内存 6G,新生代 4.5G 的情况下,Parallel Scavenge 回收新生代停顿长达 1.5 秒。G1 回收器回收同样大小的新生代只停顿 0.2 秒。\n3.6 (了解)JVM 的常用参数 # JVM 的参数非常之多,这里只列举比较重要的几个,通过各种各样的搜索引擎也可以得知这些信息。\n参数名称 含义 默认值 说明 -Xms 初始堆大小 物理内存的 1/64(\u0026lt;1GB) 默认(MinHeapFreeRatio 参数可以调整)空余堆内存小于 40%时,JVM 就会增大堆直到-Xmx 的最大限制. -Xmx 最大堆大小 物理内存的 1/4(\u0026lt;1GB) 默认(MaxHeapFreeRatio 参数可以调整)空余堆内存大于 70%时,JVM 会减少堆直到 -Xms 的最小限制 -Xmn 年轻代大小(1.4or later) 注意:此处的大小是(eden+ 2 survivor space).与 jmap -heap 中显示的 New gen 是不同的。整个堆大小=年轻代大小 + 老年代大小 + 持久代(永久代)大小.增大年轻代后,将会减小年老代大小.此值对系统性能影响较大,Sun 官方推荐配置为整个堆的 3/8 -XX:NewSize 设置年轻代大小(for 1.3/1.4) -XX:MaxNewSize 年轻代最大值(for 1.3/1.4) -XX:PermSize 设置持久代(perm gen)初始值 物理内存的 1/64 -XX:MaxPermSize 设置持久代最大值 物理内存的 1/4 -Xss 每个线程的堆栈大小 JDK5.0 以后每个线程堆栈大小为 1M,以前每个线程堆栈大小为 256K.根据应用的线程所需内存大小进行 调整.在相同物理内存下,减小这个值能生成更多的线程.但是操作系统对一个进程内的线程数还是有限制的,不能无限生成,经验值在 3000~5000 左右一般小的应用, 如果栈不是很深, 应该是 128k 够用的 大的应用建议使用 256k。这个选项对性能影响比较大,需要严格的测试。(校长)和 threadstacksize 选项解释很类似,官方文档似乎没有解释,在论坛中有这样一句话:-Xss is translated in a VM flag named ThreadStackSize”一般设置这个值就可以了 -XX:NewRatio 年轻代(包括 Eden 和两个 Survivor 区)与年老代的比值(除去持久代) -XX:NewRatio=4 表示年轻代与年老代所占比值为 1:4,年轻代占整个堆栈的 1/5Xms=Xmx 并且设置了 Xmn 的情况下,该参数不需要进行设置。 -XX:SurvivorRatio Eden 区与 Survivor 区的大小比值 设置为 8,则两个 Survivor 区与一个 Eden 区的比值为 2:8,一个 Survivor 区占整个年轻代的 1/10 -XX:+DisableExplicitGC 关闭 System.gc() 这个参数需要严格的测试 -XX:PretenureSizeThreshold 对象超过多大是直接在旧生代分配 0 单位字节 新生代采用 Parallel ScavengeGC 时无效另一种直接在旧生代分配的情况是大的数组对象,且数组中无外部引用对象. -XX:ParallelGCThreads 并行收集器的线程数 此值最好配置与处理器数目相等 同样适用于 CMS -XX:MaxGCPauseMillis 每次年轻代垃圾回收的最长时间(最大暂停时间) 如果无法满足此时间,JVM 会自动调整年轻代大小,以满足此值. 其实还有一些打印及 CMS 方面的参数,这里就不以一一列举了\n四、关于 JVM 调优的一些方面 # 根据刚刚涉及的 jvm 的知识点,我们可以尝试对 JVM 进行调优,主要就是堆内存那块\n所有线程共享数据区大小=新生代大小 + 年老代大小 + 持久代大小。持久代一般固定大小为 64m。所以 java 堆中增大年轻代后,将会减小年老代大小(因为老年代的清理是使用 fullgc,所以老年代过小的话反而是会增多 fullgc 的)。此值对系统性能影响较大,Sun 官方推荐配置为 java 堆的 3/8。\n4.1 调整最大堆内存和最小堆内存 # -Xmx –Xms:指定 java 堆最大值(默认值是物理内存的 1/4(\u0026lt;1GB))和初始 java 堆最小值(默认值是物理内存的 1/64(\u0026lt;1GB))\n默认(MinHeapFreeRatio 参数可以调整)空余堆内存小于 40%时,JVM 就会增大堆直到-Xmx 的最大限制.,默认(MaxHeapFreeRatio 参数可以调整)空余堆内存大于 70%时,JVM 会减少堆直到 -Xms 的最小限制。简单点来说,你不停地往堆内存里面丢数据,等它剩余大小小于 40%了,JVM 就会动态申请内存空间不过会小于-Xmx,如果剩余大小大于 70%,又会动态缩小不过不会小于–Xms。就这么简单\n开发过程中,通常会将 -Xms 与 -Xmx 两个参数配置成相同的值,其目的是为了能够在 java 垃圾回收机制清理完堆区后不需要重新分隔计算堆区的大小而浪费资源。\n我们执行下面的代码\nSystem.out.println(\u0026#34;Xmx=\u0026#34; + Runtime.getRuntime().maxMemory() / 1024.0 / 1024 + \u0026#34;M\u0026#34;); //系统的最大空间 System.out.println(\u0026#34;free mem=\u0026#34; + Runtime.getRuntime().freeMemory() / 1024.0 / 1024 + \u0026#34;M\u0026#34;); //系统的空闲空间 System.out.println(\u0026#34;total mem=\u0026#34; + Runtime.getRuntime().totalMemory() / 1024.0 / 1024 + \u0026#34;M\u0026#34;); //当前可用的总空间 注意:此处设置的是 Java 堆大小,也就是新生代大小 + 老年代大小\n设置一个 VM options 的参数\n-Xmx20m -Xms5m -XX:+PrintGCDetails 再次启动 main 方法\n这里 GC 弹出了一个 Allocation Failure 分配失败,这个事情发生在 PSYoungGen,也就是年轻代中\n这时候申请到的内存为 18M,空闲内存为 4.214195251464844M\n我们此时创建一个字节数组看看,执行下面的代码\nbyte[] b = new byte[1 * 1024 * 1024]; System.out.println(\u0026#34;分配了1M空间给数组\u0026#34;); System.out.println(\u0026#34;Xmx=\u0026#34; + Runtime.getRuntime().maxMemory() / 1024.0 / 1024 + \u0026#34;M\u0026#34;); //系统的最大空间 System.out.println(\u0026#34;free mem=\u0026#34; + Runtime.getRuntime().freeMemory() / 1024.0 / 1024 + \u0026#34;M\u0026#34;); //系统的空闲空间 System.out.println(\u0026#34;total mem=\u0026#34; + Runtime.getRuntime().totalMemory() / 1024.0 / 1024 + \u0026#34;M\u0026#34;); 此时 free memory 就又缩水了,不过 total memory 是没有变化的。Java 会尽可能将 total mem 的值维持在最小堆内存大小\nbyte[] b = new byte[10 * 1024 * 1024]; System.out.println(\u0026#34;分配了10M空间给数组\u0026#34;); System.out.println(\u0026#34;Xmx=\u0026#34; + Runtime.getRuntime().maxMemory() / 1024.0 / 1024 + \u0026#34;M\u0026#34;); //系统的最大空间 System.out.println(\u0026#34;free mem=\u0026#34; + Runtime.getRuntime().freeMemory() / 1024.0 / 1024 + \u0026#34;M\u0026#34;); //系统的空闲空间 System.out.println(\u0026#34;total mem=\u0026#34; + Runtime.getRuntime().totalMemory() / 1024.0 / 1024 + \u0026#34;M\u0026#34;); //当前可用的总空间 这时候我们创建了一个 10M 的字节数据,这时候最小堆内存是顶不住的。我们会发现现在的 total memory 已经变成了 15M,这就是已经申请了一次内存的结果。\n此时我们再跑一下这个代码\nSystem.gc(); System.out.println(\u0026#34;Xmx=\u0026#34; + Runtime.getRuntime().maxMemory() / 1024.0 / 1024 + \u0026#34;M\u0026#34;); //系统的最大空间 System.out.println(\u0026#34;free mem=\u0026#34; + Runtime.getRuntime().freeMemory() / 1024.0 / 1024 + \u0026#34;M\u0026#34;); //系统的空闲空间 System.out.println(\u0026#34;total mem=\u0026#34; + Runtime.getRuntime().totalMemory() / 1024.0 / 1024 + \u0026#34;M\u0026#34;); //当前可用的总空间 此时我们手动执行了一次 fullgc,此时 total memory 的内存空间又变回 5.5M 了,此时又是把申请的内存释放掉的结果。\n4.2 调整新生代和老年代的比值 # -XX:NewRatio --- 新生代(eden+2\\*Survivor)和老年代(不包含永久区)的比值 例如:-XX:NewRatio=4,表示新生代:老年代=1:4,即新生代占整个堆的 1/5。在 Xms=Xmx 并且设置了 Xmn 的情况下,该参数不需要进行设置。 4.3 调整 Survivor 区和 Eden 区的比值 # -XX:SurvivorRatio(幸存代)--- 设置两个 Survivor 区和 eden 的比值 例如:8,表示两个 Survivor:eden=2:8,即一个 Survivor 占年轻代的 1/10 4.4 设置年轻代和老年代的大小 # -XX:NewSize --- 设置年轻代大小 -XX:MaxNewSize --- 设置年轻代最大值 可以通过设置不同参数来测试不同的情况,反正最优解当然就是官方的 Eden 和 Survivor 的占比为 8:1:1,然后在刚刚介绍这些参数的时候都已经附带了一些说明,感兴趣的也可以看看。反正最大堆内存和最小堆内存如果数值不同会导致多次的 gc,需要注意。\n4.5 小总结 # 根据实际事情调整新生代和幸存代的大小,官方推荐新生代占 java 堆的 3/8,幸存代占新生代的 1/10\n在 OOM 时,记得 Dump 出堆,确保可以排查现场问题,通过下面命令你可以输出一个.dump 文件,这个文件可以使用 VisualVM 或者 Java 自带的 Java VisualVM 工具。\n-Xmx20m -Xms5m -XX:+HeapDumpOnOutOfMemoryError -XX:HeapDumpPath=你要输出的日志路径 一般我们也可以通过编写脚本的方式来让 OOM 出现时给我们报个信,可以通过发送邮件或者重启程序等来解决。\n4.6 永久区的设置 # -XX:PermSize -XX:MaxPermSize 初始空间(默认为物理内存的 1/64)和最大空间(默认为物理内存的 1/4)。也就是说,jvm 启动时,永久区一开始就占用了 PermSize 大小的空间,如果空间还不够,可以继续扩展,但是不能超过 MaxPermSize,否则会 OOM。\ntips:如果堆空间没有用完也抛出了 OOM,有可能是永久区导致的。堆空间实际占用非常少,但是永久区溢出 一样抛出 OOM。\n4.7 JVM 的栈参数调优 # 4.7.1 调整每个线程栈空间的大小 # 可以通过-Xss:调整每个线程栈空间的大小\nJDK5.0 以后每个线程堆栈大小为 1M,以前每个线程堆栈大小为 256K。在相同物理内存下,减小这个值能生成更多的线程。但是操作系统对一个进程内的线程数还是有限制的,不能无限生成,经验值在 3000~5000 左右\n4.7.2 设置线程栈的大小 # -XXThreadStackSize: 设置线程栈的大小(0 means use default stack size) 这些参数都是可以通过自己编写程序去简单测试的,这里碍于篇幅问题就不再提供 demo 了\n4.8 (可以直接跳过了)JVM 其他参数介绍 # 形形色色的参数很多,就不会说把所有都扯个遍了,因为大家其实也不会说一定要去深究到底。\n4.8.1 设置内存页的大小 # -XXThreadStackSize: 设置内存页的大小,不可设置过大,会影响Perm的大小 4.8.2 设置原始类型的快速优化 # -XX:+UseFastAccessorMethods: 设置原始类型的快速优化 4.8.3 设置关闭手动 GC # -XX:+DisableExplicitGC: 设置关闭System.gc()(这个参数需要严格的测试) 4.8.4 设置垃圾最大年龄 # -XX:MaxTenuringThreshold 设置垃圾最大年龄。如果设置为0的话,则年轻代对象不经过Survivor区,直接进入年老代.对于年老代比较多的应用,可以提高效率。如果将此值设置为一个较大值,则年轻代对象会在Survivor区进行多次复制,这样可以增加对象再年轻代的存活时间,加在年轻代即被回收的概率。该参数只有在串行GC时才有效. 4.8.5 加快编译速度 # -XX:+AggressiveOpts 加快编译速度 4.8.6 改善锁机制性能 # -XX:+UseBiasedLocking 4.8.7 禁用垃圾回收 # -Xnoclassgc 4.8.8 设置堆空间存活时间 # -XX:SoftRefLRUPolicyMSPerMB 设置每兆堆空闲空间中SoftReference的存活时间,默认值是1s。 4.8.9 设置对象直接分配在老年代 # -XX:PretenureSizeThreshold 设置对象超过多大时直接在老年代分配,默认值是0。 4.8.10 设置 TLAB 占 eden 区的比例 # -XX:TLABWasteTargetPercent 设置TLAB占eden区的百分比,默认值是1% 。 4.8.11 设置是否优先 YGC # -XX:+CollectGen0First 设置FullGC时是否先YGC,默认值是false。 finally # 真的扯了很久这东西,参考了多方的资料,有极客时间的《深入拆解虚拟机》和《Java 核心技术面试精讲》,也有百度,也有自己在学习的一些线上课程的总结。希望对你有所帮助,谢谢。\n"},{"id":609,"href":"/zh/docs/technology/Interview/system-design/basis/naming/","title":"代码命名指南","section":"Basis","content":"我还记得我刚工作那一段时间, 项目 Code Review 的时候,我经常因为变量命名不规范而被 “diss”!\n究其原因还是自己那会经验不足,而且,大学那会写项目的时候不太注意这些问题,想着只要把功能实现出来就行了。\n但是,工作中就不一样,为了代码的可读性、可维护性,项目组对于代码质量的要求还是很高的!\n前段时间,项目组新来的一个实习生也经常在 Code Review 因为变量命名不规范而被 “diss”,这让我想到自己刚到公司写代码那会的日子。\n于是,我就简单写了这篇关于变量命名规范的文章,希望能对同样有此困扰的小伙伴提供一些帮助。\n确实,编程过程中,有太多太多让我们头疼的事情了,比如命名、维护其他人的代码、写测试、与其他人沟通交流等等。\n据说之前在 Quora 网站,由接近 5000 名程序员票选出来的最难的事情就是“命名”。\n大名鼎鼎的《重构》的作者老马(Martin Fowler)曾经在 TwoHardThings这篇文章中提到过 CS 领域有两大最难的事情:一是 缓存失效 ,一是 程序命名 。\n这个句话实际上也是老马引用别人的,类似的表达还有很多。比如分布式系统领域有两大最难的事情:一是 保证消息顺序 ,一是 严格一次传递 。\n今天咱们就单独拎出 “命名” 来聊聊!\n这篇文章配合我之前发的 《编码 5 分钟,命名 2 小时?史上最全的 Java 命名规范参考!》 这篇文章阅读效果更佳哦!\n为什么需要重视命名? # 咱们需要先搞懂为什么要重视编程中的命名这一行为,它对于我们的编码工作有着什么意义。\n为什么命名很重要呢? 这是因为 好的命名即是注释,别人一看到你的命名就知道你的变量、方法或者类是做什么的!\n简单来说就是 别人根据你的命名就能知道你的代码要表达的意思 (不过,前提这个人也要有基本的英语知识,对于一些编程中常见的单词比较熟悉)。\n简单举个例子说明一下命名的重要性。\n《Clean Code》这本书明确指出:\n好的代码本身就是注释,我们要尽量规范和美化自己的代码来减少不必要的注释。\n若编程语言足够有表达力,就不需要注释,尽量通过代码来阐述。\n举个例子:\n去掉下面复杂的注释,只需要创建一个与注释所言同一事物的函数即可\n// check to see if the employee is eligible for full benefits if ((employee.flags \u0026amp; HOURLY_FLAG) \u0026amp;\u0026amp; (employee.age \u0026gt; 65)) 应替换为\nif (employee.isEligibleForFullBenefits()) 常见命名规则以及适用场景 # 这里只介绍 3 种最常见的命名规范。\n驼峰命名法(CamelCase) # 驼峰命名法应该我们最常见的一个,这种命名方式使用大小写混合的格式来区别各个单词,并且单词之间不使用空格隔开或者连接字符连接的命名方式\n大驼峰命名法(UpperCamelCase) # 类名需要使用大驼峰命名法(UpperCamelCase)\n正例:\nServiceDiscovery、ServiceInstance、LruCacheFactory 反例:\nserviceDiscovery、Serviceinstance、LRUCacheFactory 小驼峰命名法(lowerCamelCase) # 方法名、参数名、成员变量、局部变量需要使用小驼峰命名法(lowerCamelCase)。\n正例:\ngetUserInfo() createCustomThreadPool() setNameFormat(String nameFormat) Uservice userService; 反例:\nGetUserInfo()、CreateCustomThreadPool()、setNameFormat(String NameFormat) Uservice user_service 蛇形命名法(snake_case) # 测试方法名、常量、枚举名称需要使用蛇形命名法(snake_case)\n在蛇形命名法中,各个单词之间通过下划线“_”连接,比如should_get_200_status_code_when_request_is_valid、CLIENT_CONNECT_SERVER_FAILURE。\n蛇形命名法的优势是命名所需要的单词比较多的时候,比如我把上面的命名通过小驼峰命名法给大家看一下:“shouldGet200StatusCodeWhenRequestIsValid”。\n感觉如何? 相比于使用蛇形命名法(snake_case)来说是不是不那么易读?\n正例:\n@Test void should_get_200_status_code_when_request_is_valid() { ...... } 反例:\n@Test void shouldGet200StatusCodeWhenRequestIsValid() { ...... } 串式命名法(kebab-case) # 在串式命名法中,各个单词之间通过连接符“-”连接,比如dubbo-registry。\n建议项目文件夹名称使用串式命名法(kebab-case),比如 dubbo 项目的各个模块的命名是下面这样的。\n常见命名规范 # Java 语言基本命名规范 # 1、类名需要使用大驼峰命名法(UpperCamelCase)风格。方法名、参数名、成员变量、局部变量需要使用小驼峰命名法(lowerCamelCase)。\n2、测试方法名、常量、枚举名称需要使用蛇形命名法(snake_case),比如should_get_200_status_code_when_request_is_valid、CLIENT_CONNECT_SERVER_FAILURE。并且,测试方法名称要求全部小写,常量以及枚举名称需要全部大写。\n3、项目文件夹名称使用串式命名法(kebab-case),比如dubbo-registry。\n4、包名统一使用小写,尽量使用单个名词作为包名,各个单词通过 \u0026ldquo;.\u0026rdquo; 分隔符连接,并且各个单词必须为单数。\n正例:org.apache.dubbo.common.threadlocal\n反例:org.apache_dubbo.Common.threadLocals\n5、抽象类命名使用 Abstract 开头。\n//为远程传输部分抽象出来的一个抽象类(出处:Dubbo源码) public abstract class AbstractClient extends AbstractEndpoint implements Client { } 6、异常类命名使用 Exception 结尾。\n//自定义的 NoSuchMethodException(出处:Dubbo源码) public class NoSuchMethodException extends RuntimeException { private static final long serialVersionUID = -2725364246023268766L; public NoSuchMethodException() { super(); } public NoSuchMethodException(String msg) { super(msg); } } 7、测试类命名以它要测试的类的名称开始,以 Test 结尾。\n//为 AnnotationUtils 类写的测试类(出处:Dubbo源码) public class AnnotationUtilsTest { ...... } POJO 类中布尔类型的变量,都不要加 is 前缀,否则部分框架解析会引起序列化错误。\n如果模块、接口、类、方法使用了设计模式,在命名时需体现出具体模式。\n命名易读性规范 # 1、为了能让命名更加易懂和易读,尽量不要缩写/简写单词,除非这些单词已经被公认可以被这样缩写/简写。比如 CustomThreadFactory 不可以被写成 ~~CustomTF 。\n2、命名不像函数一样要尽量追求短,可读性强的名字优先于简短的名字,虽然可读性强的名字会比较长一点。 这个对应我们上面说的第 1 点。\n3、避免无意义的命名,你起的每一个名字都要能表明意思。\n正例:UserService userService; int userCount;\n反例: UserService service int count\n4、避免命名过长(50 个字符以内最好),过长的命名难以阅读并且丑陋。\n5、不要使用拼音,更不要使用中文。 不过像 alibaba、wuhan、taobao 这种国际通用名词可以当做英文来看待。\n正例:discount\n反例:dazhe\nCodelf:变量命名神器? # 这是一个由国人开发的网站,网上有很多人称其为变量命名神器, 我在实际使用了几天之后感觉没那么好用。小伙伴们可以自行体验一下,然后再给出自己的判断。\nCodelf 提供了在线网站版本,网址: https://unbug.github.io/codelf/,具体使用情况如下:\n我选择了 Java 编程语言,然后搜索了“序列化”这个关键词,然后它就返回了很多关于序列化的命名。\n并且,Codelf 还提供了 VS code 插件,看这个评价,看来大家还是很喜欢这款命名工具的。\n相关阅读推荐 # 《阿里巴巴 Java 开发手册》 《Clean Code》 Google Java 代码指南: https://google.github.io/styleguide/javaguide.html 告别编码 5 分钟,命名 2 小时!史上最全的 Java 命名规范参考: https://www.cnblogs.com/liqiangchn/p/12000361.html 总结 # 作为一个合格的程序员,小伙伴们应该都知道代码表义的重要性。想要写出高质量代码,好的命名就是第一步!\n好的命名对于其他人(包括你自己)理解你的代码有着很大的帮助!你的代码越容易被理解,可维护性就越强,侧面也就说明你的代码设计的也就越好!\n在日常编码过程中,我们需要谨记常见命名规范比如类名需要使用大驼峰命名法、不要使用拼音,更不要使用中文……。\n另外,国人开发的一个叫做 Codelf 的网站被很多人称为“变量命名神器”,当你为命名而头疼的时候,你可以去参考一下上面提供的一些命名示例。\n最后,祝愿大家都不用再为命名而困扰!\n"},{"id":610,"href":"/zh/docs/technology/Interview/system-design/basis/refactoring/","title":"代码重构指南","section":"Basis","content":"前段时间重读了 《重构:改善代码既有设计》,收货颇多。于是,简单写了一篇文章来聊聊我对重构的看法。\n何谓重构? # 学习重构必看的一本神书《重构:改善代码既有设计》从两个角度给出了重构的定义:\n重构(名词):对软件内部结构的一种调整,目的是在不改变软件可观察行为的前提下,提高其可理解性,降低其修改成本。 重构(动词):使用一系列重构手法,在不改变软件可观察行为的前提下,调整其结构。 用更贴近工程师的语言来说:重构就是利用设计模式(如组合模式、策略模式、责任链模式)、软件设计原则(如 SOLID 原则、YAGNI 原则、KISS 原则)和重构手段(如封装、继承、构建测试体系)来让代码更容易理解,更易于修改。\n软件设计原则指导着我们组织和规范代码,同时,重构也是为了能够尽量设计出尽量满足软件设计原则的软件。\n正确重构的核心在于 步子一定要小,每一步的重构都不会影响软件的正常运行,可以随时停止重构。\n常见的设计模式如下:\n更全面的设计模式总结,可以看 java-design-patterns 这个开源项目。\n常见的软件设计原则如下:\n更全面的设计原则总结,可以看 java-design-patterns 和 hacker-laws-zh 这两个开源项目。\n为什么要重构? # 在上面介绍重构定义的时候,我从比较抽象的角度介绍了重构的好处:重构的主要目的主要是提升代码\u0026amp;架构的灵活性/可扩展性以及复用性。\n如果对应到一个真实的项目,重构具体能为我们带来什么好处呢?\n让代码更容易理解:通过添加注释、命名规范、逻辑优化等手段可以让我们的代码更容易被理解; 避免代码腐化:通过重构干掉坏味道代码; 加深对代码的理解:重构代码的过程会加深你对某部分代码的理解; 发现潜在 bug:是这样的,很多潜在的 bug ,都是我们在重构的过程中发现的; …… 看了上面介绍的关于重构带来的好处之后,你会发现重构的最终目标是 提高软件开发速度和质量 。\n重构并不会减慢软件开发速度,相反,如果代码质量和软件设计较差,当我们想要添加新功能的话,开发速度会越来越慢。到了最后,甚至都有想要重写整个系统的冲动。\n《重构:改善代码既有设计》这本书中这样说:\n重构的唯一目的就是让我们开发更快,用更少的工作量创造更大的价值。\n性能优化就是重构吗? # 重构的目的是提高代码的可读性、可维护性和灵活性,它关注的是代码的内部结构——如何让开发者更容易理解代码,如何让后续的功能开发和维护更加高效。而性能优化则是为了让代码运行得更快、占用更少的资源,它关注的是程序的外部表现——如何减少响应时间、降低资源消耗、提升系统吞吐量。这两者看似对立,但实际上它们的目标是统一的,都是为了提高软件的整体质量。\n在实际开发中,理想的做法是首先确保代码的可读性和可维护性,然后根据实际需求选择合适的性能优化手段。优秀的软件设计不是一味追求性能最大化,而是要在可维护性和性能之间找到平衡。通过这种方式,我们可以打造既易于管理又具有良好性能的软件系统。\n何时进行重构? # 重构在是开发过程中随时可以进行的,见机行事即可,并不需要单独分配一两天的时间专门用来重构。\n提交代码之前 # 《重构:改善代码既有设计》这本书介绍了一个 营地法则 的概念:\n编程时,需要遵循营地法则:保证你离开时的代码库一定比来时更健康。\n这个概念表达的核心思想其实很简单:在你提交代码的之前,花一会时间想一想,我这次的提交是让项目代码变得更健康了,还是更腐化了,或者说没什么变化?\n项目团队的每一个人只有保证自己的提交没有让项目代码变得更腐化,项目代码才会朝着健康的方向发展。\n当我们离开营地(项目代码)的时候,请不要留下垃圾(代码坏味道)!尽量确保营地变得更干净了!\n开发一个新功能之后\u0026amp;之前 # 在开发一个新功能之后,我们应该回过头看看是不是有可以改进的地方。在添加一个新功能之前,我们可以思考一下自己是否可以重构代码以让新功能的开发更容易。\n一个新功能的开发不应该仅仅只有功能验证通过那么简单,我们还应该尽量保证代码质量。\n有一个两顶帽子的比喻:在我开发新功能之前,我发现重构可以让新功能的开发更容易,于是我戴上了重构的帽子。重构之后,我换回原来的帽子,继续开发新能功能。新功能开发完成之后,我又发现自己的代码难以理解,于是我又戴上了重构帽子。比较好的开发状态就是就是这样在重构和开发新功能之间来回切换。\nCode Review 之后 # Code Review 可以非常有效提高代码的整体质量,它会帮助我们发现代码中的坏味道以及可能存在问题的地方。并且, Code Review 可以帮助项目团队其他程序员理解你负责的业务模块,有效避免人员方面的单点风险。\n经历一次 Code Review ,你的代码可能会收到很多改进建议。\n捡垃圾式重构 # 当我们发现坏味道代码(垃圾)的时候,如果我们不想停下手头自己正在做的工作,但又不想放着垃圾不管,我们可以这样做:\n如果这个垃圾很容易重构的话,我们可以立即重构它。 如果这个垃圾不太容易重构的话,我们可以先记录下来,当完成当下的任务再回来重构它。 阅读理解代码的时候 # 搞开发的小伙伴应该非常有体会:我们经常需要阅读项目团队中其他人写的代码,也经常需要阅读自己过去写的代码。阅读代码的时候,通常要比我们写代码的时间还要多很多。\n我们在阅读理解代码的时候,如果发现一些坏味道的话,我们就可以对其进行重构。\n就比如说你在阅读张三写的某段代码的时候,你发现这段代码逻辑过于复杂难以理解,你有更好的写法,那你就可以对张三的这段代码逻辑进行重构。\n重构有哪些注意事项? # 单元测试是重构的保护网 # 单元测试可以为重构提供信心,降低重构的成本。我们要像重视生产代码那样,重视单元测试。\n另外,多提一句:持续集成也要依赖单元测试,当持续集成服务自动构建新代码之后,会自动运行单元测试来发现代码错误。\n怎样才能算单元测试呢? 网上的定义很多,很抽象,很容易把人给看迷糊了。我觉得对于单元测试的定义主要取决于你的项目,一个函数甚至是一个类都可以看作是一个单元。就比如说我们写了一个计算个人股票收益率的方法,我们为了验证它的正确性专门为它写了一个单元测试。再比如说我们代码有一个类专门负责数据脱敏,我们为了验证脱敏是否符合预期专门为这个类写了一个单元测试。\n单元测试也是需要重构或者修改的。 《代码整洁之道:敏捷软件开发手册》这本书这样写到:\n测试代码需要随着生产代码的演进而修改,如果测试不能保持整洁,只会越来越难修改。\n不要为了重构而重构 # 重构一定是要为项目带来价值的! 某些情况下我们不应该进行重构:\n学习了某个设计模式/工程实践之后,不顾项目实际情况,刻意使用在项目上(避免货物崇拜编程); 项目进展比较急的时候,重构项目调用的某个 API 的底层代码(重构之后对项目调用这个 API 并没有带来什么价值); 重写比重构更容易更省事; …… 遵循方法 # 《重构:改善代码既有设计》这本书中列举除了代码常见的一些坏味道(比如重复代码、过长函数)和重构手段(如提炼函数、提炼变量、提炼类)。我们应该花时间去学习这些重构相关的理论知识,并在代码中去实践这些重构理论。\n如何练习重构? # 除了可以在重构项目代码的过程中练习精进重构之外,你还可以有下面这些手段:\n当我重构时,我在想些什么:转转技术的这篇文章总结了常见的重构场景和重构方式。 重构实战练习:通过几个小案例一步一步带你学习重构! 设计模式+重构学习网站:免费在线学习代码重构、 设计模式、 SOLID 原则 (单一职责、 开闭原则、 里氏替换、 接口隔离以及依赖反转) 。 IDEA 官方文档的代码重构教程:教你如何使用 IDEA 进行重构。 参考 # 再读《重构》- ThoughtWorks 洞见 - 2020:详细介绍了重构的要点比如小步重构、捡垃圾式的重构,主要是重构概念相关的介绍。 常见代码重构技巧 - VectorJin - 2021:从软件设计原则、设计模式、代码分层、命名规范等角度介绍了如何进行重构,比较偏实战。 "},{"id":611,"href":"/zh/docs/technology/Interview/system-design/basis/unit-test/","title":"单元测试到底是什么?应该怎么做?","section":"Basis","content":" 本文重构完善自 谈谈为什么写单元测试 - 键盘男 - 2016这篇文章。\n何谓单元测试? # 维基百科是这样介绍单元测试的:\n在计算机编程中,单元测试(Unit Testing)是针对程序模块(软件设计的最小单位)进行的正确性检验测试工作。\n程序单元是应用的 最小可测试部件 。在过程化编程中,一个单元就是单个程序、函数、过程等;对于面向对象编程,最小单元就是方法,包括基类(超类)、抽象类、或者派生类(子类)中的方法。\n由于每个单元有独立的逻辑,在做单元测试时,为了隔离外部依赖,确保这些依赖不影响验证逻辑,我们经常会用到 Fake、Stub 与 Mock 。\n关于 Fake、Mock 与 Stub 这几个概念的解读,可以看看这篇文章: 测试中 Fakes、Mocks 以及 Stubs 概念明晰 - 王下邀月熊 - 2018 。\n为什么需要单元测试? # 为重构保驾护航 # 我在 重构这篇文章中这样写到:\n单元测试可以为重构提供信心,降低重构的成本。我们要像重视生产代码那样,重视单元测试。\n每个开发者都会经历重构,重构后把代码改坏了的情况并不少见,很可能你只是修改了一个很简单的方法就导致系统出现了一个比较严重的错误。\n如果有了单元测试的话,就不会存在这个隐患了。写完一个类,把单元测试写了,确保这个类逻辑正确;写第二个类,单元测试……写 100 个类,道理一样,每个类做到第一点“保证逻辑正确性”,100 个类拼在一起肯定不出问题。你大可以放心一边重构,一边运行 APP;而不是整体重构完,提心吊胆地 run。\n提高代码质量 # 由于每个单元有独立的逻辑,做单元测试时需要隔离外部依赖,确保这些依赖不影响验证逻辑。因为要把各种依赖分离,单元测试会促进工程进行组件拆分,整理工程依赖关系,更大程度减少代码耦合。这样写出来的代码,更好维护,更好扩展,从而提高代码质量。\n减少 bug # 一个机器,由各种细小的零件组成,如果其中某件零件坏了,机器运行故障。必须保证每个零件都按设计图要求的规格,机器才能正常运行。\n一个可单元测试的工程,会把业务、功能分割成规模更小、有独立的逻辑部件,称为单元。单元测试的目标,就是保证各个单元的逻辑正确性。单元测试保障工程各个“零件”按“规格”(需求)执行,从而保证整个“机器”(项目)运行正确,最大限度减少 bug。\n快速定位 bug # 如果程序有 bug,我们运行一次全部单元测试,找到不通过的测试,可以很快地定位对应的执行代码。修复代码后,运行对应的单元测试;如还不通过,继续修改,运行测试……直到测试通过。\n持续集成依赖单元测试 # 持续集成需要依赖单元测试,当持续集成服务自动构建新代码之后,会自动运行单元测试来发现代码错误。\n谁逼你写单元测试? # 领导要求 # 有些经验丰富的领导,或多或少都会要求团队写单元测试。对于有一定工作经验的队友,这要求挺合理;对于经验尚浅的、毕业生,恐怕要死要活了,连代码都写不好,还要写单元测试,are you kidding me?\n培训新人单元测试用法,是一项艰巨的任务。新人代码风格未形成,也不知道单元测试多重要,强制单元测试会让他们感到困惑,没办法按自己思路写代码。\n大牛都写单元测试 # 国外很多家喻户晓的开源项目,都有大量单元测试。例如, retrofit、 okhttp、 butterknife…… 国外大牛都写单元测试,我们也写吧!\n很多读者都有这种想法,一开始满腔热血。当真要对自己项目单元测试时,便困难重重,很大原因是项目对单元测试不友好。最后只能对一些不痛不痒的工具类做单元测试,久而久之,当初美好愿望也不了了之。\n保住面子 # 都是有些许年经验的老鸟,还天天被测试同学追 bug,好意思么?花多一点时间写单元测试,确保没低级 bug,还能彰显大牛风范,何乐而不为?\n心虚 # 笔者也是个不太相信自己代码的人,总觉得哪里会突然冒出莫名其妙的 bug,也怕别人不小心改了自己的代码(被害妄想症),新版本上线提心吊胆……花点时间写单元测试,有事没事跑一下测试,确保原逻辑没问题,至少能睡安稳一点。\nTDD 测试驱动开发 # 何谓 TDD? # TDD 即 Test-Driven Development( 测试驱动开发),这是敏捷开发的一项核心实践和技术,也是一种设计方法论。\nTDD 原理是开发功能代码之前,先编写测试用例代码,然后针对测试用例编写功能代码,使其能够通过。\nTDD 的节奏:“红 - 绿 - 重构”。\n由于 TDD 对开发人员要求非常高,跟传统开发思维不一样,因此实施起来相当困难。\nTDD 在很多人眼中是不实用的,一来他们并不理解测试“驱动”开发的含义,但更重要的是,他们很少会做任务分解。而任务分解是做好 TDD 的关键点。只有把任务分解到可以测试的地步,才能够有针对性地写测试。\nTDD 优缺点分析 # 测试驱动开发有好处也有坏处。因为每个测试用例都是根据需求来的,或者说把一个大需求分解成若干小需求编写测试用例,所以测试用例写出来后,开发者写的执行代码,必须满足测试用例。如果测试不通过,则修改执行代码,直到测试用例通过。\n优点:\n帮你整理需求,梳理思路; 帮你设计出更合理的接口(空想的话很容易设计出屎); 减小代码出现 bug 的概率; 提高开发效率(前提是正确且熟练使用 TDD)。 缺点:\n能用好 TDD 的人非常少,看似简单,实则门槛很高; 投入开发资源(时间和精力)通常会更多; 由于测试用例在未进行代码设计前写;很有可能限制开发者对代码整体设计; 可能引起开发人员不满情绪,我觉得这点很严重,毕竟不是人人都喜欢单元测试,尽管单元测试会带给我们相当多的好处。 相关阅读: 如何用正确的姿势打开 TDD? - 陈天 - 2017 。\n单测框架如何选择? # 对于单测来说,目前常用的单测框架有:JUnit、Mockito、Spock、PowerMock、JMockit、TestableMock 等等。\nJUnit 几乎是默认选择,但是其不支持 Mock,因此我们还需要选择一个 Mock 工具。Mockito 和 Spock 是最主流的两款 Mock 工具,一般都是在这两者中选择。\n究竟是选择 Mockito 还是 Spock 呢?我这里做了一些简单的对比分析:\nSpock 没办法 Mock 静态方法和私有方法 ,Mockito 3.4.0 以后,支持静态方法的 Mock,具体可以看这个 issue: https://github.com/mockito/mockito/issues/1013,具体教程可以看这篇文章: https://www.baeldung.com/mockito-mock-static-methods。 Spock 基于 Groovy,写出来的测试代码更清晰易读,比较规范(自带 given-when-then 的常用测试结构规范)。Mockito 没有具体的结构规范,需要项目组自己约定一个或者遵守比较好的测试代码实践。通常来说,同样的测试用例,Spock 的代码要更简洁。 Mockito 使用的人群更广泛,稳定可靠。并且,Mockito 是 SpringBoot Test 默认集成的 Mock 工具。 Mockito 和 Spock 都是非常不错的 Mock 工具,相对来说,Mockito 的适用性更强一些。\n总结 # 单元测试确实会带给你相当多的好处,但不是立刻体验出来。正如买重疾保险,交了很多保费,没病没痛,十几年甚至几十年都用不上,最好就是一辈子用不上理赔,身体健康最重要。单元测试也一样,写了可以买个放心,对代码的一种保障,有 bug 尽快测出来,没 bug 就最好,总不能说“写那么多单元测试,结果测不出 bug,浪费时间”吧?\n以下是个人对单元测试一些建议:\n越重要的代码,越要写单元测试; 代码做不到单元测试,多思考如何改进,而不是放弃; 边写业务代码,边写单元测试,而不是完成整个新功能后再写; 多思考如何改进、简化测试代码。 测试代码需要随着生产代码的演进而重构或者修改,如果测试不能保持整洁,只会越来越难修改。 作为一名经验丰富的程序员,写单元测试更多的是对自己的代码负责。有测试用例的代码,别人更容易看懂,以后别人接手你的代码时,也可能放心做改动。\n多敲代码实践,多跟有单元测试经验的工程师交流,你会发现写单元测试获得的收益会更多。\n"},{"id":612,"href":"/zh/docs/technology/Interview/high-quality-technical-articles/personal-experience/two-years-of-back-end-develop--experience-in-didi-and-toutiao/","title":"滴滴和头条两年后端工作经验分享","section":"Personal Experience","content":" 推荐语:很实用的工作经验分享,看完之后十分受用!\n内容概览:\n要学会深入思考,总结沉淀,这是我觉得最重要也是最有意义的一件事。 积极学习,保持技术热情。如果我们积极学习,保持技术能力、知识储备与工作年限成正比,这到了 35 岁哪还有什么焦虑呢,这样的大牛我觉得应该也是各大公司抢着要吧? 在能为公司办成事,创造价值这一点上,我觉得最重要的两个字就是主动,主动承担任务,主动沟通交流,主动推动项目进展,主动协调资源,主动向上反馈,主动创造影响力等等。 脸皮要厚一点,多找人聊,快速融入,最忌讳有问题也不说,自己把自己孤立起来。 想舔就舔,不想舔也没必要酸别人,Respect Greatness。 时刻准备着,技术在手就没什么可怕的,哪天干得不爽了直接跳槽。 平时积极总结沉淀,多跟别人交流,形成方法论。 …… 原文地址: https://www.nowcoder.com/discuss/351805\n先简单交代一下背景吧,某不知名 985 的本硕,17 年毕业加入滴滴,当时找工作时候也是在牛客这里跟大家一起奋战的。今年下半年跳槽到了头条,一直从事后端研发相关的工作。之前没有实习经历,算是两年半的工作经验吧。这两年半之间完成了一次晋升,换了一家公司,有过开心满足的时光,也有过迷茫挣扎的日子,不过还算顺利地从一只职场小菜鸟转变为了一名资深划水员。在这个过程中,总结出了一些还算实用的划水经验,有些是自己领悟到的,有些是跟别人交流学到的,在这里跟大家分享一下。\n学会深入思考,总结沉淀 # 我想说的第一条就是要学会深入思考,总结沉淀,这是我觉得最重要也是最有意义的一件事。\n先来说深入思考。 在程序员这个圈子里,常能听到一些言论:“我这个工作一点技术含量都没有,每天就 CRUD,再写写 if-else,这 TM 能让我学到什么东西?”\n抛开一部分调侃和戏谑的论调不谈,这可能确实是一部分同学的真实想法,至少曾经的我,就这么认为过。后来随着工作经验的积累,加上和一些高 level 的同学交流探讨之后,我发现这个想法其实是非常错误的。之所以出现没什么可学的这样的看法,基本上是思维懒惰的结果。任何一件看起来很不起眼的小事,只要进行深入思考,稍微纵向挖深或者横向拓宽一下,都是足以让人沉溺的知识海洋。\n举一个例子。某次有个同学跟我说,这周有个服务 OOM 了,查了一周发现有个地方 defer 写的有问题,改了几行代码上线修复了,周报都没法写。可能大家也遇到过这样的场景,还算是有一定的代表性。其实就查 bug 这件事来说,是一个发现问题,排查问题,解决问题的过程,包含了触发、定位、复现、根因、修复、复盘等诸多步骤,花了一周来做这件事,一定有不断尝试与纠错的过程,这里面其实就有很多思考的空间。比如说定位,如何缩小范围的?走了哪些弯路?用了哪些分析工具?比如说根因,可以研究的点起码有 linux 的 OOM,k8s 的 OOM,go 的内存管理,defer 机制,函数闭包的原理等等。如果这些真的都不涉及,仍然花了一周时间做这件事,那复盘应该会有很多思考,提出来几十个 WHY 没问题吧\u0026hellip;\n再来说下总结沉淀。 这个我觉得也是大多数程序员比较欠缺的地方,只顾埋头干活,可以把一件事做的很好。但是几乎从来不做抽象总结,以至于工作好几年了,所掌握的知识还是零星的几点,不成体系,不仅容易遗忘,而且造成自己视野比较窄,看问题比较局限。适时地做一些总结沉淀是很重要的,这是一个从术到道的过程,会让自己看问题的角度更广,层次更高。遇到同类型的问题,可以按照总结好的方法论,系统化、层次化地推进和解决。\n还是举一个例子。做后台服务,今天优化了 1G 内存,明天优化了 50%的读写耗时,是不是可以做一下性能优化的总结?比如说在应用层,可以管理服务对接的应用方,梳理他们访问的合理性;在架构层,可以做缓存、预处理、读写分离、异步、并行等等;在代码层,可以做的事情更多了,资源池化、对象复用、无锁化设计、大 key 拆分、延迟处理、编码压缩、gc 调优还有各种语言相关的高性能实践\u0026hellip;等下次再遇到需要性能优化的场景,一整套思路立马就能套用过来了,剩下的就是工具和实操的事儿了。\n还有的同学说了,我就每天跟 PM 撕撕逼,做做需求,也不做性能优化啊。先不讨论是否可以搞性能优化,单就做业务需求来讲,也有可以总结的地方。比如说,如何做系统建设?系统核心能力,系统边界,系统瓶颈,服务分层拆分,服务治理这些问题有思考过吗?每天跟 PM 讨论需求,那作为技术同学该如何培养产品思维,引导产品走向,如何做到架构先行于业务,这些问题也是可以思考和总结的吧。就想一下,连接手维护别人烂代码这种蛋疼的事情,都能让 Martin Fowler 整出来一套重构理论,还显得那么高大上,我们确实也没啥必要对自己的工作妄自菲薄\u0026hellip;\n所以说:学习和成长是一个自驱的过程,如果觉得没什么可学的,大概率并不是真的没什么可学的,而是因为自己太懒了,不仅是行动上太懒了,思维上也太懒了。可以多写技术文章,多分享,强迫自己去思考和总结,毕竟如果文章深度不够,大家也不好意思公开分享。\n积极学习,保持技术热情 # 最近两年在互联网圈里广泛传播的一种焦虑论叫做 35 岁程序员现象,大意是说程序员这个行业干到 35 岁就基本等着被裁员了。不可否认,互联网行业在这一点上确实不如公务员等体制内职业。但是,这个问题里 35 岁程序员并不是绝对生理意义上的 35 岁,应该是指那些工作十几年和工作两三年没什么太大区别的程序员。后面的工作基本是在吃老本,没有主动学习与充电,35 岁和 25 岁差不多,而且没有了 25 岁时对学习成长的渴望,反而添了家庭生活的诸多琐事,薪资要求往往也较高,在企业看来这确实是没什么竞争力。\n如果我们积极学习,保持技术能力、知识储备与工作年限成正比,这到了 35 岁哪还有什么焦虑呢,这样的大牛我觉得应该也是各大公司抢着要吧? 但是,学习这件事,其实是一个反人类的过程,这就需要我们强迫自己跳出自己的安逸区,主动学习,保持技术热情。 在滴滴时有一句话大概是,主动跳出自己的舒适区,感到挣扎与压力的时候,往往是黎明前的黑暗,那才是成长最快的时候。相反如果感觉自己每天都过得很安逸,工作只是在混时长,那可能真的是温水煮青蛙了。\n刚毕业的这段时间,往往空闲时间还比较多,正是努力学习技术的好时候。借助这段时间夯实基础,培养出良好的学习习惯,保持积极的学习态度,应该是受益终身的。至于如何高效率学习,网上有很多大牛写这样的帖子,到了公司后内网也能找到很多这样的分享,我就不多谈了。\n可以加入学习小组和技术社区,公司内和公司外的都可以,关注前沿技术。\n主动承担,及时交流反馈 # 前两条还是从个人的角度出发来说的,希望大家可以提升个人能力,保持核心竞争力,但从公司角度来讲,公司招聘员工入职,最重要的是让员工创造出业务价值,为公司服务。虽然对于校招生一般都会有一定的培养体系,但实际上公司确实没有帮助我们成长的义务。\n在能为公司办成事,创造价值这一点上,我觉得最重要的两个字就是主动,主动承担任务,主动沟通交流,主动推动项目进展,主动协调资源,主动向上反馈,主动创造影响力等等。\n我当初刚入职的时候,基本就是 leader 给分配什么任务就把本职工作做好,然后就干自己的事了,几乎从来不主动去跟别人交流或者主动去思考些能帮助项目发展的点子。自以为把本职工作保质保量完成就行了,后来发现这么做其实是非常不够的,这只是最基本的要求。而有些同学的做法则是 leader 只需要同步一下最近要做什么方向,下面的一系列事情基本不需要 leader 操心了 ,这样的同学我是 leader 我也喜欢啊。入职后经常会听到的一个词叫 owner 意识,大概就是这个意思吧。\n在这个过程中,另外很重要的一点就是及时向上沟通反馈。项目进展不顺利,遇到什么问题,及时跟 leader 同步,技术方案拿捏不准可以跟 leader 探讨,一些资源协调不了可以找 leader 帮忙,不要有太多顾忌,认为这些会太麻烦,leader 其实就是干这个事的。。如果项目进展比较顺利,确实也不需要 leader 介入,那也需要及时把项目的进度,取得的收益及时反馈,自己有什么想法也提出来探讨,问问 leader 对当前进展的建议,还有哪些地方需要改进,消除信息误差。做这些事一方面是合理利用 leader 的各种资源,另一方面也可以让 leader 了解到自己的工作量,对项目整体有所把控,毕竟 leader 也有 leader,也是要汇报的。可能算是大家比较反感的向上管理吧,有内味了,这个其实我也做得不好。但是最基本的一点,不要接了一个任务闷着头干活甚至与世隔绝了,一个月了也没跟 leader 同步过,想着憋个大招之类的,那基本凉凉。\n一定要主动,可以先从强迫自己在各种公开场合发言开始,有问题或想法及时 one-one。\n除了以上几点,还有一些小点我觉得也是比较重要的,列在下面:\n第一件事建立信任 # 无论是校招还是社招,刚入职的第一件事是非常重要的,直接决定了 leader 和同事对自己的第一印象。入职后要做的第一件事一定要做好,最起码的要顺利完成而且不能出线上事故。这件事的目的就是为了建立信任,让团队觉得自己起码是靠谱的。如果这件事做得比较好,后面一路都会比较顺利。如果这件事就搞杂了,可能有的 leader 还会给第二次机会,再搞不好,后面就很难了,这一条对于社招来说更为重要。\n而刚入职,公司技术栈不熟练,业务繁杂很难理清什么头绪,压力确实比较大。这时候一方面需要自己投入更多的精力,另一方面要多跟组内的同学交流,不懂就问。最有效率的学习方式,我觉得不是什么看书啊学习视频啊,而是直接去找对应的人聊,让别人讲一遍自己基本就全懂了,这效率比看文档看代码快多了,不仅省去了过滤无用信息的过程,还了解到了业务的演变历史。当然,这需要一定的沟通技巧,毕竟同事们也都很忙。\n脸皮要厚一点,多找人聊,快速融入,最忌讳有问题也不说,自己把自己孤立起来。\n超出预期 # 超出预期这个词的外延范围很广,比如 leader 让去做个值周,解答用户群里大家的问题,结果不仅解答了大家的问题,还收集了这些问题进行分类,进而做了一个智能问答机器人解放了值周的人力,这可以算超出预期。比如 leader 让给运营做一个小工具,结果建设了一系列的工具甚至发展成了一个平台,成为了一个完整的项目,这也算超出预期。超出预期要求我们有把事情做大的能力,也就是想到了 leader 没想到的地方,并且创造了实际价值,拿到了业务收益。这个能力其实也比较重要,在工作中发现,有的人能把一个小盘子越做越大,而有的人恰好反之,那么那些有创新能力,经常超出预期的同学发展空间显然就更大一点。\n这块其实比较看个人能力,暂时没想到什么太好的捷径,多想一步吧。\n体系化思考,系统化建设 # 这句话是晋升时候总结出来的,大意就是做系统建设要有全局视野,不要局限于某一个小点,应该有良好的规划能力和清晰的演进蓝图。比如,今天加了一个监控,明天加一个报警,这些事不应该成为一个个孤岛,而是属于稳定性建设一期其中的一小步。这一期稳定性建设要做的工作是报警配置和监控梳理,包括机器监控、系统监控、业务监控、数据监控等,预期能拿到 XXX 的收益。这个工作还有后续的 roadmap,稳定性建设二期要做容量规划,接入压测,三期要做降级演练,多活容灾,四期要做\u0026hellip;给人的感觉就是这个人思考非常全面,办事有体系有规划。\n平时积极总结沉淀,多跟别人交流,形成方法论。\n提升自己的软素质能力 # 这里的软素质能力其实想说的就是 PPT、沟通、表达、时间管理、设计、文档等方面的能力。说实话,我觉得我当时能晋升就是因为 PPT 做的好了一点\u0026hellip;可能大家平时对这些能力都不怎么关注,以前我也不重视,觉得比较简单,用时候直接上就行了,但事实可能并不像想象得那样简单。比如晋升时候 PPT+演讲+答辩这个工作,其实有很多细节的思考在里面,内容如何选取,排版怎么设计,怎样引导听众的情绪,如何回答评委的问题等等。晋升时候我见过很多同学 PPT 内容编排杂乱无章,演讲过程也不流畅自然,虽然确实做了很多实际工作,但在表达上欠缺了很多,属于会做不会说,如果再遇到不了解实际情况的外部门评委,吃亏是可以预见的。\n公司内网一般都会有一些软素质培训课程,可以找一些场合刻意训练。\n以上都是这些分享还都算比较伟光正,但是社会吧也不全是那么美好的。。下面这些内容有负能量倾向,三观特别正的同学以及观感不适者建议跳过。\n拍马屁是真的香 # 拍马屁这东西入职前我是很反感的,我最初想加入互联网公司的原因就是觉得互联网公司的人情世故没那么多,事实证明,我错了\u0026hellip;入职前几天,部门群里大 leader 发了一条消息,后面几十条带着大拇指的消息立马跟上,学习了,点赞,真不错,优秀,那场面,说是红旗招展锣鼓喧天鞭炮齐鸣一点也不过分。除了惊叹大家超强的信息接收能力和处理速度外,更进一步我还发现,连拍马屁都是有队形的,一级部门 leader 发消息,几个二级部门 leader 跟上,后面各组长跟上,最后是大家的狂欢,让我一度怀疑拍马屁的速度就决定了职业生涯的发展前景(没错,现在我已经不怀疑了)。\n坦诚地说,我到现在也没习惯在群里拍马屁,但也不反感了,可以说把这个事当成一乐了。倒不是说我没有那个口才和能力(事实上也不需要什么口才,大家都简单直接),在某些场合,为活跃气氛的需要,我也能小嘴儿抹了蜜,甚至能把古诗文彩虹屁给 leader 安排上。而是我发现我的直属 leader 也不怎么在群里拍马屁,所以我表面上不公开拍马屁其实属于暗地里事实上迎合了 leader 的喜好\u0026hellip;\n但是拍马屁这个事只要掌握好度,整体来说还是香的,最多是没用,至少不会有什么坏处嘛。大家能力都差不多,每一次在群里拍马屁的机会就是一次露脸的机会,按某个同事的说法,这就叫打造个人技术影响力\u0026hellip;\n想舔就舔,不想舔也没必要酸别人,Respect Greatness。\n永不缺席的撕逼甩锅实战 # 有人的地方,就有江湖。虽然搞技术的大多城府也不深,但撕逼甩锅邀功抢活这些闹心的事儿基本也不会缺席,甚至我还见到过公开群发邮件撕逼的\u0026hellip;这部分话题涉及到一些敏感信息就不多说了,而且我们低职级的遇到这些事儿的机会也不会太多。只是给大家提个醒,在工作的时候迟早都会吃到这方面的瓜,到时候留个心眼。\n稍微注意一下,咱不会去欺负别人,但也不能轻易让别人给欺负了。\n不要被画饼蒙蔽了双眼 # 说实话,我个人是比较反感灌鸡汤、打鸡血、谈梦想、讲奋斗这一类行为的,9102 年都快过完了,这一套***治还在大行其道,真不知道是该可笑还是可悲。当然,这些词本身并没有什么问题,但是这些东西应该是自驱的,而不应该成为外界的一种强 push。『我必须努力奋斗』这个句式我觉得是正常的,但是『你必须努力奋斗』这种话多少感觉有点诡异,努力奋斗所以让公司的股东们发家致富?尤其在钱没给够的情况下,这些行为无异于耍流氓。我们需要对 leader 的这些画饼操作保持清醒的认知,理性分析,作出决策。比如感觉钱没给够(或者职级太低,同理)的时候,可能有以下几种情况:\nleader 并没有注意到你薪资较低这一事实 leader 知道这个事实,但是不知道你有多强烈的涨薪需求 leader 知道你有涨薪的需求,但他觉得你能力还不够 leader 知道你有涨薪的需求,能力也够,但是他不想给你涨 leader 想给你涨,也向上反馈和争取了,但是没有资源 这时候我们需要做的是向上反馈,跟 leader 沟通确认。如果是 1 和 2,那么通过沟通可以消除信息误差。如果是 3,需要分情况讨论。如果是 4 和 5,已经可以考虑撤退了。对于这些事儿,也没必要抱怨,抱怨解决不了任何问题。我们要做的就是努力提升好个人能力,保持个人竞争力,等一个合适的时机,跳槽就完事了。\n时刻准备着,技术在手就没什么可怕的,哪天干得不爽了直接跳槽。\n学会包装 # 这一条说白了就是,要会吹。忘了从哪儿看到的了,能说、会写、善做是对职场人的三大要求。能说是很重要的,能说才能要来项目,拉来资源,招来人。同样一件事,不同的人能说出来完全不一样的效果。比如我做了个小工具上线了,我就只能说出来基本事实,而让 leader 描述一下,这就成了,打造了 XXX 的工具抓手,改进了 XXX 的完整生态,形成了 XXX 的业务闭环。老哥,我服了,硬币全给你还不行嘛。据我的观察,每个互联网公司都有这么几个词,抓手、生态、闭环、拉齐、梳理、迭代、owner 意识等等等等,我们需要做的就是熟读并背诵全文,啊不,是牢记并熟练使用。\n这是对事情的包装,对人的包装也是一样的,尤其是在晋升和面试这样的应试型场合,特点是流程短一锤子买卖,包装显得尤为重要。晋升和面试这里就不展开说了,这里面的道和术太多了。。下面的场景提炼自面试过程中和某公司面试官的谈话,大家可以感受一下:\n我们背后是一个四五百亿美金的市场\u0026hellip; 我负责过每天千亿级别访问量的系统\u0026hellip; 工作两年能达到这个程度挺不错的\u0026hellip; 贵司技术氛围挺好的,业务发展前景也很广阔\u0026hellip; 啊,彼此彼此\u0026hellip; 嗯,久仰久仰\u0026hellip; 人生如戏,全靠演技\n可以多看 leader 的 PPT,多听老板的向上汇报和宣讲会。\n选择和努力哪个更重要? # 这还用问么,当然是选择。在完美的选择面前,努力显得一文不值,我有个多年没联系的高中同学今年已经在时代广场敲钟了\u0026hellip;但是这样的案例太少了,做出完美选择的随机成本太高,不确定性太大。对于大多数刚毕业的同学,对行业的判断力还不够成熟,对自身能力和创业难度把握得也不够精准,此时拉几个人去创业,显得风险太高。我觉得更为稳妥的一条路是,先加入规模稍大一点的公司,找一个好 leader,抱好大腿,提升自己的个人能力。好平台加上大腿,再加上个人努力,这个起飞速度已经可以了。等后面积累了一定人脉和资金,深刻理解了市场和需求,对自己有信心了,可以再去考虑创业的事。\n后记 # 本来还想分享一些生活方面的故事,发现已经这么长了,那就先这样叭。上面写的一些总结和建议我自己做的也不是很好,还需要继续加油,和大家共勉。另外,其中某些观点,由于个人视角的局限性也不保证是普适和正确的,可能再工作几年这些观点也会发生改变,欢迎大家跟我交流~(甩锅成功)\n最后祝大家都能找到心仪的工作,快乐工作,幸福生活,广阔天地,大有作为。\n"},{"id":613,"href":"/zh/docs/technology/Interview/high-performance/read-and-write-separation-and-library-subtable/","title":"读写分离和分库分表详解","section":"High Performance","content":" 读写分离 # 什么是读写分离? # 见名思意,根据读写分离的名字,我们就可以知道:读写分离主要是为了将对数据库的读写操作分散到不同的数据库节点上。 这样的话,就能够小幅提升写性能,大幅提升读性能。\n我简单画了一张图来帮助不太清楚读写分离的小伙伴理解。\n一般情况下,我们都会选择一主多从,也就是一台主数据库负责写,其他的从数据库负责读。主库和从库之间会进行数据同步,以保证从库中数据的准确性。这样的架构实现起来比较简单,并且也符合系统的写少读多的特点。\n如何实现读写分离? # 不论是使用哪一种读写分离具体的实现方案,想要实现读写分离一般包含如下几步:\n部署多台数据库,选择其中的一台作为主数据库,其他的一台或者多台作为从数据库。 保证主数据库和从数据库之间的数据是实时同步的,这个过程也就是我们常说的主从复制。 系统将写请求交给主数据库处理,读请求交给从数据库处理。 落实到项目本身的话,常用的方式有两种:\n1. 代理方式\n我们可以在应用和数据中间加了一个代理层。应用程序所有的数据请求都交给代理层处理,代理层负责分离读写请求,将它们路由到对应的数据库中。\n提供类似功能的中间件有 MySQL Router(官方, MySQL Proxy 的替代方案)、Atlas(基于 MySQL Proxy)、MaxScale、MyCat。\n关于 MySQL Router 多提一点:在 MySQL 8.2 的版本中,MySQL Router 能自动分辨对数据库读写/操作并把这些操作路由到正确的实例上。这是一项有价值的功能,可以优化数据库性能和可扩展性,而无需在应用程序中进行任何更改。具体介绍可以参考官方博客: MySQL 8.2 – transparent read/write splitting。\n2. 组件方式\n在这种方式中,我们可以通过引入第三方组件来帮助我们读写请求。\n这也是我比较推荐的一种方式。这种方式目前在各种互联网公司中用的最多的,相关的实际的案例也非常多。如果你要采用这种方式的话,推荐使用 sharding-jdbc ,直接引入 jar 包即可使用,非常方便。同时,也节省了很多运维的成本。\n你可以在 shardingsphere 官方找到 sharding-jdbc 关于读写分离的操作。\n主从复制原理是什么? # MySQL binlog(binary log 即二进制日志文件) 主要记录了 MySQL 数据库中数据的所有变化(数据库执行的所有 DDL 和 DML 语句)。因此,我们根据主库的 MySQL binlog 日志就能够将主库的数据同步到从库中。\n更具体和详细的过程是这个样子的(图片来自于: 《MySQL Master-Slave Replication on the Same Machine》):\n主库将数据库中数据的变化写入到 binlog 从库连接主库 从库会创建一个 I/O 线程向主库请求更新的 binlog 主库会创建一个 binlog dump 线程来发送 binlog ,从库中的 I/O 线程负责接收 从库的 I/O 线程将接收的 binlog 写入到 relay log 中。 从库的 SQL 线程读取 relay log 同步数据到本地(也就是再执行一遍 SQL )。 怎么样?看了我对主从复制这个过程的讲解,你应该搞明白了吧!\n你一般看到 binlog 就要想到主从复制。当然,除了主从复制之外,binlog 还能帮助我们实现数据恢复。\n🌈 拓展一下:\n不知道大家有没有使用过阿里开源的一个叫做 canal 的工具。这个工具可以帮助我们实现 MySQL 和其他数据源比如 Elasticsearch 或者另外一台 MySQL 数据库之间的数据同步。很显然,这个工具的底层原理肯定也是依赖 binlog。canal 的原理就是模拟 MySQL 主从复制的过程,解析 binlog 将数据同步到其他的数据源。\n另外,像咱们常用的分布式缓存组件 Redis 也是通过主从复制实现的读写分离。\n🌕 简单总结一下:\nMySQL 主从复制是依赖于 binlog 。另外,常见的一些同步 MySQL 数据到其他数据源的工具(比如 canal)的底层一般也是依赖 binlog 。\n如何避免主从延迟? # 读写分离对于提升数据库的并发非常有效,但是,同时也会引来一个问题:主库和从库的数据存在延迟,比如你写完主库之后,主库的数据同步到从库是需要时间的,这个时间差就导致了主库和从库的数据不一致性问题。这也就是我们经常说的 主从同步延迟 。\n如果我们的业务场景无法容忍主从同步延迟的话,应该如何避免呢(注意:我这里说的是避免而不是减少延迟)?\n这里提供两种我知道的方案(能力有限,欢迎补充),你可以根据自己的业务场景参考一下。\n强制将读请求路由到主库处理 # 既然你从库的数据过期了,那我就直接从主库读取嘛!这种方案虽然会增加主库的压力,但是,实现起来比较简单,也是我了解到的使用最多的一种方式。\n比如 Sharding-JDBC 就是采用的这种方案。通过使用 Sharding-JDBC 的 HintManager 分片键值管理器,我们可以强制使用主库。\nHintManager hintManager = HintManager.getInstance(); hintManager.setMasterRouteOnly(); // 继续JDBC操作 对于这种方案,你可以将那些必须获取最新数据的读请求都交给主库处理。\n延迟读取 # 还有一些朋友肯定会想既然主从同步存在延迟,那我就在延迟之后读取啊,比如主从同步延迟 0.5s,那我就 1s 之后再读取数据。这样多方便啊!方便是方便,但是也很扯淡。\n不过,如果你是这样设计业务流程就会好很多:对于一些对数据比较敏感的场景,你可以在完成写请求之后,避免立即进行请求操作。比如你支付成功之后,跳转到一个支付成功的页面,当你点击返回之后才返回自己的账户。\n总结 # 关于如何避免主从延迟,我们这里介绍了两种方案。实际上,延迟读取这种方案没办法完全避免主从延迟,只能说可以减少出现延迟的概率而已,实际项目中一般不会使用。\n总的来说,要想不出现延迟问题,一般还是要强制将那些必须获取最新数据的读请求都交给主库处理。如果你的项目的大部分业务场景对数据准确性要求不是那么高的话,这种方案还是可以选择的。\n什么情况下会出现主从延迟?如何尽量减少延迟? # 我们在上面的内容中也提到了主从延迟以及避免主从延迟的方法,这里我们再来详细分析一下主从延迟出现的原因以及应该如何尽量减少主从延迟。\n要搞懂什么情况下会出现主从延迟,我们需要先搞懂什么是主从延迟。\nMySQL 主从同步延时是指从库的数据落后于主库的数据,这种情况可能由以下两个原因造成:\n从库 I/O 线程接收 binlog 的速度跟不上主库写入 binlog 的速度,导致从库 relay log 的数据滞后于主库 binlog 的数据; 从库 SQL 线程执行 relay log 的速度跟不上从库 I/O 线程接收 binlog 的速度,导致从库的数据滞后于从库 relay log 的数据。 与主从同步有关的时间点主要有 3 个:\n主库执行完一个事务,写入 binlog,将这个时刻记为 T1; 从库 I/O 线程接收到 binlog 并写入 relay log 的时刻记为 T2; 从库 SQL 线程读取 relay log 同步数据本地的时刻记为 T3。 结合我们上面讲到的主从复制原理,可以得出:\nT2 和 T1 的差值反映了从库 I/O 线程的性能和网络传输的效率,这个差值越小说明从库 I/O 线程的性能和网络传输效率越高。 T3 和 T2 的差值反映了从库 SQL 线程执行的速度,这个差值越小,说明从库 SQL 线程执行速度越快。 那什么情况下会出现出从延迟呢?这里列举几种常见的情况:\n从库机器性能比主库差:从库接收 binlog 并写入 relay log 以及执行 SQL 语句的速度会比较慢(也就是 T2-T1 和 T3-T2 的值会较大),进而导致延迟。解决方法是选择与主库一样规格或更高规格的机器作为从库,或者对从库进行性能优化,比如调整参数、增加缓存、使用 SSD 等。 从库处理的读请求过多:从库需要执行主库的所有写操作,同时还要响应读请求,如果读请求过多,会占用从库的 CPU、内存、网络等资源,影响从库的复制效率(也就是 T2-T1 和 T3-T2 的值会较大,和前一种情况类似)。解决方法是引入缓存(推荐)、使用一主多从的架构,将读请求分散到不同的从库,或者使用其他系统来提供查询的能力,比如将 binlog 接入到 Hadoop、Elasticsearch 等系统中。 大事务:运行时间比较长,长时间未提交的事务就可以称为大事务。由于大事务执行时间长,并且从库上的大事务会比主库上的大事务花费更多的时间和资源,因此非常容易造成主从延迟。解决办法是避免大批量修改数据,尽量分批进行。类似的情况还有执行时间较长的慢 SQL ,实际项目遇到慢 SQL 应该进行优化。 从库太多:主库需要将 binlog 同步到所有的从库,如果从库数量太多,会增加同步的时间和开销(也就是 T2-T1 的值会比较大,但这里是因为主库同步压力大导致的)。解决方案是减少从库的数量,或者将从库分为不同的层级,让上层的从库再同步给下层的从库,减少主库的压力。 网络延迟:如果主从之间的网络传输速度慢,或者出现丢包、抖动等问题,那么就会影响 binlog 的传输效率,导致从库延迟。解决方法是优化网络环境,比如提升带宽、降低延迟、增加稳定性等。 单线程复制:MySQL5.5 及之前,只支持单线程复制。为了优化复制性能,MySQL 5.6 引入了 多线程复制,MySQL 5.7 还进一步完善了多线程复制。 复制模式:MySQL 默认的复制是异步的,必然会存在延迟问题。全同步复制不存在延迟问题,但性能太差了。半同步复制是一种折中方案,相对于异步复制,半同步复制提高了数据的安全性,减少了主从延迟(还是有一定程度的延迟)。MySQL 5.5 开始,MySQL 以插件的形式支持 semi-sync 半同步复制。并且,MySQL 5.7 引入了 增强半同步复制 。 …… 《MySQL 实战 45 讲》这个专栏中的 读写分离有哪些坑?这篇文章也有对主从延迟解决方案这一话题进行探讨,感兴趣的可以阅读学习一下。\n分库分表 # 读写分离主要应对的是数据库读并发,没有解决数据库存储问题。试想一下:如果 MySQL 一张表的数据量过大怎么办?\n换言之,我们该如何解决 MySQL 的存储压力呢?\n答案之一就是 分库分表。\n什么是分库? # 分库 就是将数据库中的数据分散到不同的数据库上,可以垂直分库,也可以水平分库。\n垂直分库 就是把单一数据库按照业务进行划分,不同的业务使用不同的数据库,进而将一个数据库的压力分担到多个数据库。\n举个例子:说你将数据库中的用户表、订单表和商品表分别单独拆分为用户数据库、订单数据库和商品数据库。\n水平分库 是把同一个表按一定规则拆分到不同的数据库中,每个库可以位于不同的服务器上,这样就实现了水平扩展,解决了单表的存储和性能瓶颈的问题。\n举个例子:订单表数据量太大,你对订单表进行了水平切分(水平分表),然后将切分后的 2 张订单表分别放在两个不同的数据库。\n什么是分表? # 分表 就是对单表的数据进行拆分,可以是垂直拆分,也可以是水平拆分。\n垂直分表 是对数据表列的拆分,把一张列比较多的表拆分为多张表。\n举个例子:我们可以将用户信息表中的一些列单独抽出来作为一个表。\n水平分表 是对数据表行的拆分,把一张行比较多的表拆分为多张表,可以解决单一表数据量过大的问题。\n举个例子:我们可以将用户信息表拆分成多个用户信息表,这样就可以避免单一表数据量过大对性能造成影响。\n水平拆分只能解决单表数据量大的问题,为了提升性能,我们通常会选择将拆分后的多张表放在不同的数据库中。也就是说,水平分表通常和水平分库同时出现。\n什么情况下需要分库分表? # 遇到下面几种场景可以考虑分库分表:\n单表的数据达到千万级别以上,数据库读写速度比较缓慢。 数据库中的数据占用的空间越来越大,备份时间越来越长。 应用的并发量太大(应该优先考虑其他性能优化方法,而非分库分表)。 不过,分库分表的成本太高,如非必要尽量不要采用。而且,并不一定是单表千万级数据量就要分表,毕竟每张表包含的字段不同,它们在不错的性能下能够存放的数据量也不同,还是要具体情况具体分析。\n之前看过一篇文章分析 “ InnoDB 中高度为 3 的 B+ 树最多可以存多少数据”,写的挺不错,感兴趣的可以看看。\n常见的分片算法有哪些? # 分片算法主要解决了数据被水平分片之后,数据究竟该存放在哪个表的问题。\n常见的分片算法有:\n哈希分片:求指定分片键的哈希,然后根据哈希值确定数据应被放置在哪个表中。哈希分片比较适合随机读写的场景,不太适合经常需要范围查询的场景。哈希分片可以使每个表的数据分布相对均匀,但对动态伸缩(例如新增一个表或者库)不友好。 范围分片:按照特定的范围区间(比如时间区间、ID 区间)来分配数据,比如 将 id 为 1~299999 的记录分到第一个表, 300000~599999 的分到第二个表。范围分片适合需要经常进行范围查找且数据分布均匀的场景,不太适合随机读写的场景(数据未被分散,容易出现热点数据的问题)。 映射表分片:使用一个单独的表(称为映射表)来存储分片键和分片位置的对应关系。映射表分片策略可以支持任何类型的分片算法,如哈希分片、范围分片等。映射表分片策略是可以灵活地调整分片规则,不需要修改应用程序代码或重新分布数据。不过,这种方式需要维护额外的表,还增加了查询的开销和复杂度。 一致性哈希分片:将哈希空间组织成一个环形结构,将分片键和节点(数据库或表)都映射到这个环上,然后根据顺时针的规则确定数据或请求应该分配到哪个节点上,解决了传统哈希对动态伸缩不友好的问题。 地理位置分片:很多 NewSQL 数据库都支持地理位置分片算法,也就是根据地理位置(如城市、地域)来分配数据。 融合算法分片:灵活组合多种分片算法,比如将哈希分片和范围分片组合。 …… 分片键如何选择? # 分片键(Sharding Key)是数据分片的关键字段。分片键的选择非常重要,它关系着数据的分布和查询效率。一般来说,分片键应该具备以下特点:\n具有共性,即能够覆盖绝大多数的查询场景,尽量减少单次查询所涉及的分片数量,降低数据库压力; 具有离散性,即能够将数据均匀地分散到各个分片上,避免数据倾斜和热点问题; 具有稳定性,即分片键的值不会发生变化,避免数据迁移和一致性问题; 具有扩展性,即能够支持分片的动态增加和减少,避免数据重新分片的开销。 实际项目中,分片键很难满足上面提到的所有特点,需要权衡一下。并且,分片键可以是表中多个字段的组合,例如取用户 ID 后四位作为订单 ID 后缀。\n分库分表会带来什么问题呢? # 记住,你在公司做的任何技术决策,不光是要考虑这个技术能不能满足我们的要求,是否适合当前业务场景,还要重点考虑其带来的成本。\n引入分库分表之后,会给系统带来什么挑战呢?\njoin 操作:同一个数据库中的表分布在了不同的数据库中,导致无法使用 join 操作。这样就导致我们需要手动进行数据的封装,比如你在一个数据库中查询到一个数据之后,再根据这个数据去另外一个数据库中找对应的数据。不过,很多大厂的资深 DBA 都是建议尽量不要使用 join 操作。因为 join 的效率低,并且会对分库分表造成影响。对于需要用到 join 操作的地方,可以采用多次查询业务层进行数据组装的方法。不过,这种方法需要考虑业务上多次查询的事务性的容忍度。 事务问题:同一个数据库中的表分布在了不同的数据库中,如果单个操作涉及到多个数据库,那么数据库自带的事务就无法满足我们的要求了。这个时候,我们就需要引入分布式事务了。关于分布式事务常见解决方案总结,网站上也有对应的总结: https://javaguide.cn/distributed-system/distributed-transaction.html 。 分布式 ID:分库之后, 数据遍布在不同服务器上的数据库,数据库的自增主键已经没办法满足生成的主键唯一了。我们如何为不同的数据节点生成全局唯一主键呢?这个时候,我们就需要为我们的系统引入分布式 ID 了。关于分布式 ID 的详细介绍\u0026amp;实现方案总结,可以看我写的这篇文章: 分布式 ID 介绍\u0026amp;实现方案总结。 跨库聚合查询问题:分库分表会导致常规聚合查询操作,如 group by,order by 等变得异常复杂。这是因为这些操作需要在多个分片上进行数据汇总和排序,而不是在单个数据库上进行。为了实现这些操作,需要编写复杂的业务代码,或者使用中间件来协调分片间的通信和数据传输。这样会增加开发和维护的成本,以及影响查询的性能和可扩展性。 …… 另外,引入分库分表之后,一般需要 DBA 的参与,同时还需要更多的数据库服务器,这些都属于成本。\n分库分表有没有什么比较推荐的方案? # Apache ShardingSphere 是一款分布式的数据库生态系统, 可以将任意数据库转换为分布式数据库,并通过数据分片、弹性伸缩、加密等能力对原有数据库进行增强。\nShardingSphere 项目(包括 Sharding-JDBC、Sharding-Proxy 和 Sharding-Sidecar)是当当捐入 Apache 的,目前主要由京东数科的一些巨佬维护。\nShardingSphere 绝对可以说是当前分库分表的首选!ShardingSphere 的功能完善,除了支持读写分离和分库分表,还提供分布式事务、数据库治理、影子库、数据加密和脱敏等功能。\nShardingSphere 提供的功能如下:\nShardingSphere 的优势如下(摘自 ShardingSphere 官方文档: https://shardingsphere.apache.org/document/current/cn/overview/):\n极致性能:驱动程序端历经长年打磨,效率接近原生 JDBC,性能极致。 生态兼容:代理端支持任何通过 MySQL/PostgreSQL 协议的应用访问,驱动程序端可对接任意实现 JDBC 规范的数据库。 业务零侵入:面对数据库替换场景,ShardingSphere 可满足业务无需改造,实现平滑业务迁移。 运维低成本:在保留原技术栈不变前提下,对 DBA 学习、管理成本低,交互友好。 安全稳定:基于成熟数据库底座之上提供增量能力,兼顾安全性及稳定性。 弹性扩展:具备计算、存储平滑在线扩展能力,可满足业务多变的需求。 开放生态:通过多层次(内核、功能、生态)插件化能力,为用户提供可定制满足自身特殊需求的独有系统。 另外,ShardingSphere 的生态体系完善,社区活跃,文档完善,更新和发布比较频繁。\n不过,还是要多提一句:现在很多公司都是用的类似于 TiDB 这种分布式关系型数据库,不需要我们手动进行分库分表(数据库层面已经帮我们做了),也不需要解决手动分库分表引入的各种问题,直接一步到位,内置很多实用的功能(如无感扩容和缩容、冷热存储分离)!如果公司条件允许的话,个人也是比较推荐这种方式!\n分库分表后,数据怎么迁移呢? # 分库分表之后,我们如何将老库(单库单表)的数据迁移到新库(分库分表后的数据库系统)呢?\n比较简单同时也是非常常用的方案就是停机迁移,写个脚本老库的数据写到新库中。比如你在凌晨 2 点,系统使用的人数非常少的时候,挂一个公告说系统要维护升级预计 1 小时。然后,你写一个脚本将老库的数据都同步到新库中。\n如果你不想停机迁移数据的话,也可以考虑双写方案。双写方案是针对那种不能停机迁移的场景,实现起来要稍微麻烦一些。具体原理是这样的:\n我们对老库的更新操作(增删改),同时也要写入新库(双写)。如果操作的数据不存在于新库的话,需要插入到新库中。 这样就能保证,咱们新库里的数据是最新的。 在迁移过程,双写只会让被更新操作过的老库中的数据同步到新库,我们还需要自己写脚本将老库中的数据和新库的数据做比对。如果新库中没有,那咱们就把数据插入到新库。如果新库有,旧库没有,就把新库对应的数据删除(冗余数据清理)。 重复上一步的操作,直到老库和新库的数据一致为止。 想要在项目中实施双写还是比较麻烦的,很容易会出现问题。我们可以借助上面提到的数据库同步工具 Canal 做增量数据迁移(还是依赖 binlog,开发和维护成本较低)。\n总结 # 读写分离主要是为了将对数据库的读写操作分散到不同的数据库节点上。 这样的话,就能够小幅提升写性能,大幅提升读性能。 读写分离基于主从复制,MySQL 主从复制是依赖于 binlog 。 分库 就是将数据库中的数据分散到不同的数据库上。分表 就是对单表的数据进行拆分,可以是垂直拆分,也可以是水平拆分。 引入分库分表之后,需要系统解决事务、分布式 id、无法 join 操作问题。 现在很多公司都是用的类似于 TiDB 这种分布式关系型数据库,不需要我们手动进行分库分表(数据库层面已经帮我们做了),也不需要解决手动分库分表引入的各种问题,直接一步到位,内置很多实用的功能(如无感扩容和缩容、冷热存储分离)!如果公司条件允许的话,个人也是比较推荐这种方式! 如果必须要手动分库分表的话,ShardingSphere 是首选!ShardingSphere 的功能完善,除了支持读写分离和分库分表,还提供分布式事务、数据库治理等功能。另外,ShardingSphere 的生态体系完善,社区活跃,文档完善,更新和发布比较频繁。 "},{"id":614,"href":"/zh/docs/technology/Interview/java/basis/generics-and-wildcards/","title":"泛型\u0026通配符详解","section":"Basis","content":"泛型\u0026amp;通配符 相关的面试题为我的 知识星球(点击链接即可查看详细介绍以及加入方法)专属内容,已经整理到了 《Java 面试指北》(点击链接即可查看详细介绍以及获取方法)中。\n《Java 面试指北》 的部分内容展示如下,你可以将其看作是 JavaGuide 的补充完善,两者可以配合使用。\n《Java 面试指北》只是星球内部众多资料中的一个,星球还有很多其他优质资料比如 专属专栏、Java 编程视频、PDF 资料。\n"},{"id":615,"href":"/zh/docs/technology/Interview/cs-basics/network/the-whole-process-of-accessing-web-pages/","title":"访问网页的全过程(知识串联)","section":"Network","content":"开发岗中总是会考很多计算机网络的知识点,但如果让面试官只考一道题,便涵盖最多的计网知识点,那可能就是 网页浏览的全过程 了。本篇文章将带大家从头到尾过一遍这道被考烂的面试题,必会!!!\n总的来说,网络通信模型可以用下图来表示,也就是大家只要熟记网络结构五层模型,按照这个体系,很多知识点都能顺出来了。访问网页的过程也是如此。\n开始之前,我们先简单过一遍完整流程:\n在浏览器中输入指定网页的 URL。 浏览器通过 DNS 协议,获取域名对应的 IP 地址。 浏览器根据 IP 地址和端口号,向目标服务器发起一个 TCP 连接请求。 浏览器在 TCP 连接上,向服务器发送一个 HTTP 请求报文,请求获取网页的内容。 服务器收到 HTTP 请求报文后,处理请求,并返回 HTTP 响应报文给浏览器。 浏览器收到 HTTP 响应报文后,解析响应体中的 HTML 代码,渲染网页的结构和样式,同时根据 HTML 中的其他资源的 URL(如图片、CSS、JS 等),再次发起 HTTP 请求,获取这些资源的内容,直到网页完全加载显示。 浏览器在不需要和服务器通信时,可以主动关闭 TCP 连接,或者等待服务器的关闭请求。 应用层 # 一切的开始——打开浏览器,在地址栏输入 URL,回车确认。那么,什么是 URL?访问 URL 有什么用?\nURL # URL(Uniform Resource Locators),即统一资源定位器。网络上的所有资源都靠 URL 来定位,每一个文件就对应着一个 URL,就像是路径地址。理论上,文件资源和 URL 一一对应。实际上也有例外,比如某些 URL 指向的文件已经被重定位到另一个位置,这样就有多个 URL 指向同一个文件。\nURL 的组成结构 # 协议。URL 的前缀通常表示了该网址采用了何种应用层协议,通常有两种——HTTP 和 HTTPS。当然也有一些不太常见的前缀头,比如文件传输时用到的ftp:。 域名。域名便是访问网址的通用名,这里也有可能是网址的 IP 地址,域名可以理解为 IP 地址的可读版本,毕竟绝大部分人都不会选择记住一个网址的 IP 地址。 端口。如果指明了访问网址的端口的话,端口会紧跟在域名后面,并用一个冒号隔开。 资源路径。域名(端口)后紧跟的就是资源路径,从第一个/开始,表示从服务器上根目录开始进行索引到的文件路径,上图中要访问的文件就是服务器根目录下/path/to/myfile.html。早先的设计是该文件通常物理存储于服务器主机上,但现在随着网络技术的进步,该文件不一定会物理存储在服务器主机上,有可能存放在云上,而文件路径也有可能是虚拟的(遵循某种规则)。 参数。参数是浏览器在向服务器提交请求时,在 URL 中附带的参数。服务器解析请求时,会提取这些参数。参数采用键值对的形式key=value,每一个键值对使用\u0026amp;隔开。参数的具体含义和请求操作的具体方法有关。 锚点。锚点顾名思义,是在要访问的页面上的一个锚。要访问的页面大部分都多于一页,如果指定了锚点,那么在客户端显示该网页是就会定位到锚点处,相当于一个小书签。值得一提的是,在 URL 中,锚点以#开头,并且不会作为请求的一部分发送给服务端。 DNS # 键入了 URL 之后,第一个重头戏登场——DNS 服务器解析。DNS(Domain Name System)域名系统,要解决的是 域名和 IP 地址的映射问题 。毕竟,域名只是一个网址便于记住的名字,而网址真正存在的地址其实是 IP 地址。\n传送门: DNS 域名系统详解(应用层)\nHTTP/HTTPS # 利用 DNS 拿到了目标主机的 IP 地址之后,浏览器便可以向目标 IP 地址发送 HTTP 报文,请求需要的资源了。在这里,根据目标网站的不同,请求报文可能是 HTTP 协议或安全性增强的 HTTPS 协议。\n传送门:\nHTTP vs HTTPS(应用层) HTTP 1.0 vs HTTP 1.1(应用层) HTTP 常见状态码总结(应用层) 传输层 # 由于 HTTP 协议是基于 TCP 协议的,在应用层的数据封装好以后,要交给传输层,经 TCP 协议继续封装。\nTCP 协议保证了数据传输的可靠性,是数据包传输的主力协议。\n传送门:\nTCP 三次握手和四次挥手(传输层) TCP 传输可靠性保障(传输层) 网络层 # 终于,来到网络层,此时我们的主机不再是和另一台主机进行交互了,而是在和中间系统进行交互。也就是说,应用层和传输层都是端到端的协议,而网络层及以下都是中间件的协议了。\n网络层的的核心功能——转发与路由,必会!!!如果面试官问到了网络层,而你恰好又什么都不会的话,最最起码要说出这五个字——转发与路由。\n转发:将分组从路由器的输入端口转移到合适的输出端口。 路由:确定分组从源到目的经过的路径。 所以到目前为止,我们的数据包经过了应用层、传输层的封装,来到了网络层,终于开始准备在物理层面传输了,第一个要解决的问题就是——往哪里传输?或者说,要把数据包发到哪个路由器上? 这便是 BGP 协议要解决的问题。\n"},{"id":616,"href":"/zh/docs/technology/Interview/distributed-system/distributed-id/","title":"分布式ID介绍\u0026实现方案总结","section":"Distributed System","content":" 分布式 ID 介绍 # 什么是 ID? # 日常开发中,我们需要对系统中的各种数据使用 ID 唯一表示,比如用户 ID 对应且仅对应一个人,商品 ID 对应且仅对应一件商品,订单 ID 对应且仅对应一个订单。\n我们现实生活中也有各种 ID,比如身份证 ID 对应且仅对应一个人、地址 ID 对应且仅对应一个地址。\n简单来说,ID 就是数据的唯一标识。\n什么是分布式 ID? # 分布式 ID 是分布式系统下的 ID。分布式 ID 不存在与现实生活中,属于计算机系统中的一个概念。\n我简单举一个分库分表的例子。\n我司的一个项目,使用的是单机 MySQL 。但是,没想到的是,项目上线一个月之后,随着使用人数越来越多,整个系统的数据量将越来越大。单机 MySQL 已经没办法支撑了,需要进行分库分表(推荐 Sharding-JDBC)。\n在分库之后, 数据遍布在不同服务器上的数据库,数据库的自增主键已经没办法满足生成的主键唯一了。我们如何为不同的数据节点生成全局唯一主键呢?\n这个时候就需要生成分布式 ID了。\n分布式 ID 需要满足哪些要求? # 分布式 ID 作为分布式系统中必不可少的一环,很多地方都要用到分布式 ID。\n一个最基本的分布式 ID 需要满足下面这些要求:\n全局唯一:ID 的全局唯一性肯定是首先要满足的! 高性能:分布式 ID 的生成速度要快,对本地资源消耗要小。 高可用:生成分布式 ID 的服务要保证可用性无限接近于 100%。 方便易用:拿来即用,使用方便,快速接入! 除了这些之外,一个比较好的分布式 ID 还应保证:\n安全:ID 中不包含敏感信息。 有序递增:如果要把 ID 存放在数据库的话,ID 的有序性可以提升数据库写入速度。并且,很多时候 ,我们还很有可能会直接通过 ID 来进行排序。 有具体的业务含义:生成的 ID 如果能有具体的业务含义,可以让定位问题以及开发更透明化(通过 ID 就能确定是哪个业务)。 独立部署:也就是分布式系统单独有一个发号器服务,专门用来生成分布式 ID。这样就生成 ID 的服务可以和业务相关的服务解耦。不过,这样同样带来了网络调用消耗增加的问题。总的来说,如果需要用到分布式 ID 的场景比较多的话,独立部署的发号器服务还是很有必要的。 分布式 ID 常见解决方案 # 数据库 # 数据库主键自增 # 这种方式就比较简单直白了,就是通过关系型数据库的自增主键产生来唯一的 ID。\n以 MySQL 举例,我们通过下面的方式即可。\n1.创建一个数据库表。\nCREATE TABLE `sequence_id` ( `id` bigint(20) unsigned NOT NULL AUTO_INCREMENT, `stub` char(10) NOT NULL DEFAULT \u0026#39;\u0026#39;, PRIMARY KEY (`id`), UNIQUE KEY `stub` (`stub`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; stub 字段无意义,只是为了占位,便于我们插入或者修改数据。并且,给 stub 字段创建了唯一索引,保证其唯一性。\n2.通过 replace into 来插入数据。\nBEGIN; REPLACE INTO sequence_id (stub) VALUES (\u0026#39;stub\u0026#39;); SELECT LAST_INSERT_ID(); COMMIT; 插入数据这里,我们没有使用 insert into 而是使用 replace into 来插入数据,具体步骤是这样的:\n第一步:尝试把数据插入到表中。\n第二步:如果主键或唯一索引字段出现重复数据错误而插入失败时,先从表中删除含有重复关键字值的冲突行,然后再次尝试把数据插入到表中。\n这种方式的优缺点也比较明显:\n优点:实现起来比较简单、ID 有序递增、存储消耗空间小 缺点:支持的并发量不大、存在数据库单点问题(可以使用数据库集群解决,不过增加了复杂度)、ID 没有具体业务含义、安全问题(比如根据订单 ID 的递增规律就能推算出每天的订单量,商业机密啊! )、每次获取 ID 都要访问一次数据库(增加了对数据库的压力,获取速度也慢) 数据库号段模式 # 数据库主键自增这种模式,每次获取 ID 都要访问一次数据库,ID 需求比较大的时候,肯定是不行的。\n如果我们可以批量获取,然后存在在内存里面,需要用到的时候,直接从内存里面拿就舒服了!这也就是我们说的 基于数据库的号段模式来生成分布式 ID。\n数据库的号段模式也是目前比较主流的一种分布式 ID 生成方式。像滴滴开源的 Tinyid 就是基于这种方式来做的。不过,TinyId 使用了双号段缓存、增加多 db 支持等方式来进一步优化。\n以 MySQL 举例,我们通过下面的方式即可。\n1. 创建一个数据库表。\nCREATE TABLE `sequence_id_generator` ( `id` int(10) NOT NULL, `current_max_id` bigint(20) NOT NULL COMMENT \u0026#39;当前最大id\u0026#39;, `step` int(10) NOT NULL COMMENT \u0026#39;号段的长度\u0026#39;, `version` int(20) NOT NULL COMMENT \u0026#39;版本号\u0026#39;, `biz_type` int(20) NOT NULL COMMENT \u0026#39;业务类型\u0026#39;, PRIMARY KEY (`id`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; current_max_id 字段和step字段主要用于获取批量 ID,获取的批量 id 为:current_max_id ~ current_max_id+step。\nversion 字段主要用于解决并发问题(乐观锁),biz_type 主要用于表示业务类型。\n2. 先插入一行数据。\nINSERT INTO `sequence_id_generator` (`id`, `current_max_id`, `step`, `version`, `biz_type`) VALUES (1, 0, 100, 0, 101); 3. 通过 SELECT 获取指定业务下的批量唯一 ID\nSELECT `current_max_id`, `step`,`version` FROM `sequence_id_generator` where `biz_type` = 101 结果:\nid current_max_id step version biz_type 1 0 100 0 101 4. 不够用的话,更新之后重新 SELECT 即可。\nUPDATE sequence_id_generator SET current_max_id = 0+100, version=version+1 WHERE version = 0 AND `biz_type` = 101 SELECT `current_max_id`, `step`,`version` FROM `sequence_id_generator` where `biz_type` = 101 结果:\nid current_max_id step version biz_type 1 100 100 1 101 相比于数据库主键自增的方式,数据库的号段模式对于数据库的访问次数更少,数据库压力更小。\n另外,为了避免单点问题,你可以从使用主从模式来提高可用性。\n数据库号段模式的优缺点:\n优点:ID 有序递增、存储消耗空间小 缺点:存在数据库单点问题(可以使用数据库集群解决,不过增加了复杂度)、ID 没有具体业务含义、安全问题(比如根据订单 ID 的递增规律就能推算出每天的订单量,商业机密啊! ) NoSQL # 一般情况下,NoSQL 方案使用 Redis 多一些。我们通过 Redis 的 incr 命令即可实现对 id 原子顺序递增。\n127.0.0.1:6379\u0026gt; set sequence_id_biz_type 1 OK 127.0.0.1:6379\u0026gt; incr sequence_id_biz_type (integer) 2 127.0.0.1:6379\u0026gt; get sequence_id_biz_type \u0026#34;2\u0026#34; 为了提高可用性和并发,我们可以使用 Redis Cluster。Redis Cluster 是 Redis 官方提供的 Redis 集群解决方案(3.0+版本)。\n除了 Redis Cluster 之外,你也可以使用开源的 Redis 集群方案 Codis (大规模集群比如上百个节点的时候比较推荐)。\n除了高可用和并发之外,我们知道 Redis 基于内存,我们需要持久化数据,避免重启机器或者机器故障后数据丢失。Redis 支持两种不同的持久化方式:快照(snapshotting,RDB)、只追加文件(append-only file, AOF)。 并且,Redis 4.0 开始支持 RDB 和 AOF 的混合持久化(默认关闭,可以通过配置项 aof-use-rdb-preamble 开启)。\n关于 Redis 持久化,我这里就不过多介绍。不了解这部分内容的小伙伴,可以看看 Redis 持久化机制详解这篇文章。\nRedis 方案的优缺点:\n优点:性能不错并且生成的 ID 是有序递增的 缺点:和数据库主键自增方案的缺点类似 除了 Redis 之外,MongoDB ObjectId 经常也会被拿来当做分布式 ID 的解决方案。\nMongoDB ObjectId 一共需要 12 个字节存储:\n0~3:时间戳 3~6:代表机器 ID 7~8:机器进程 ID 9~11:自增值 MongoDB 方案的优缺点:\n优点:性能不错并且生成的 ID 是有序递增的 缺点:需要解决重复 ID 问题(当机器时间不对的情况下,可能导致会产生重复 ID)、有安全性问题(ID 生成有规律性) 算法 # UUID # UUID 是 Universally Unique Identifier(通用唯一标识符) 的缩写。UUID 包含 32 个 16 进制数字(8-4-4-4-12)。\nJDK 就提供了现成的生成 UUID 的方法,一行代码就行了。\n//输出示例:cb4a9ede-fa5e-4585-b9bb-d60bce986eaa UUID.randomUUID() RFC 4122 中关于 UUID 的示例是这样的:\n我们这里重点关注一下这个 Version(版本),不同的版本对应的 UUID 的生成规则是不同的。\n5 种不同的 Version(版本)值分别对应的含义(参考 维基百科对于 UUID 的介绍):\n版本 1 : UUID 是根据时间和节点 ID(通常是 MAC 地址)生成; 版本 2 : UUID 是根据标识符(通常是组或用户 ID)、时间和节点 ID 生成; 版本 3、版本 5 : 版本 5 - 确定性 UUID 通过散列(hashing)名字空间(namespace)标识符和名称生成; 版本 4 : UUID 使用 随机性或 伪随机性生成。 下面是 Version 1 版本下生成的 UUID 的示例:\nJDK 中通过 UUID 的 randomUUID() 方法生成的 UUID 的版本默认为 4。\nUUID uuid = UUID.randomUUID(); int version = uuid.version();// 4 另外,Variant(变体)也有 4 种不同的值,这种值分别对应不同的含义。这里就不介绍了,貌似平时也不怎么需要关注。\n需要用到的时候,去看看维基百科对于 UUID 的 Variant(变体) 相关的介绍即可。\n从上面的介绍中可以看出,UUID 可以保证唯一性,因为其生成规则包括 MAC 地址、时间戳、名字空间(Namespace)、随机或伪随机数、时序等元素,计算机基于这些规则生成的 UUID 是肯定不会重复的。\n虽然,UUID 可以做到全局唯一性,但是,我们一般很少会使用它。\n比如使用 UUID 作为 MySQL 数据库主键的时候就非常不合适:\n数据库主键要尽量越短越好,而 UUID 的消耗的存储空间比较大(32 个字符串,128 位)。 UUID 是无顺序的,InnoDB 引擎下,数据库主键的无序性会严重影响数据库性能。 最后,我们再简单分析一下 UUID 的优缺点 (面试的时候可能会被问到的哦!) :\n优点:生成速度比较快、简单易用 缺点:存储消耗空间大(32 个字符串,128 位)、 不安全(基于 MAC 地址生成 UUID 的算法会造成 MAC 地址泄露)、无序(非自增)、没有具体业务含义、需要解决重复 ID 问题(当机器时间不对的情况下,可能导致会产生重复 ID) Snowflake(雪花算法) # Snowflake 是 Twitter 开源的分布式 ID 生成算法。Snowflake 由 64 bit 的二进制数字组成,这 64bit 的二进制被分成了几部分,每一部分存储的数据都有特定的含义:\nsign(1bit):符号位(标识正负),始终为 0,代表生成的 ID 为正数。 timestamp (41 bits):一共 41 位,用来表示时间戳,单位是毫秒,可以支撑 2 ^41 毫秒(约 69 年) datacenter id + worker id (10 bits):一般来说,前 5 位表示机房 ID,后 5 位表示机器 ID(实际项目中可以根据实际情况调整)。这样就可以区分不同集群/机房的节点。 sequence (12 bits):一共 12 位,用来表示序列号。 序列号为自增值,代表单台机器每毫秒能够产生的最大 ID 数(2^12 = 4096),也就是说单台机器每毫秒最多可以生成 4096 个 唯一 ID。 在实际项目中,我们一般也会对 Snowflake 算法进行改造,最常见的就是在 Snowflake 算法生成的 ID 中加入业务类型信息。\n我们再来看看 Snowflake 算法的优缺点:\n优点:生成速度比较快、生成的 ID 有序递增、比较灵活(可以对 Snowflake 算法进行简单的改造比如加入业务 ID) 缺点:需要解决重复 ID 问题(ID 生成依赖时间,在获取时间的时候,可能会出现时间回拨的问题,也就是服务器上的时间突然倒退到之前的时间,进而导致会产生重复 ID)、依赖机器 ID 对分布式环境不友好(当需要自动启停或增减机器时,固定的机器 ID 可能不够灵活)。 如果你想要使用 Snowflake 算法的话,一般不需要你自己再造轮子。有很多基于 Snowflake 算法的开源实现比如美团 的 Leaf、百度的 UidGenerator(后面会提到),并且这些开源实现对原有的 Snowflake 算法进行了优化,性能更优秀,还解决了 Snowflake 算法的时间回拨问题和依赖机器 ID 的问题。\n并且,Seata 还提出了“改良版雪花算法”,针对原版雪花算法进行了一定的优化改良,解决了时间回拨问题,大幅提高的 QPS。具体介绍和改进原理,可以参考下面这两篇文章:\nSeata 基于改良版雪花算法的分布式 UUID 生成器分析 在开源项目中看到一个改良版的雪花算法,现在它是你的了。 开源框架 # UidGenerator(百度) # UidGenerator 是百度开源的一款基于 Snowflake(雪花算法)的唯一 ID 生成器。\n不过,UidGenerator 对 Snowflake(雪花算法)进行了改进,生成的唯一 ID 组成如下:\nsign(1bit):符号位(标识正负),始终为 0,代表生成的 ID 为正数。 delta seconds (28 bits):当前时间,相对于时间基点\u0026quot;2016-05-20\u0026quot;的增量值,单位:秒,最多可支持约 8.7 年 worker id (22 bits):机器 id,最多可支持约 420w 次机器启动。内置实现为在启动时由数据库分配,默认分配策略为用后即弃,后续可提供复用策略。 sequence (13 bits):每秒下的并发序列,13 bits 可支持每秒 8192 个并发。 可以看出,和原始 Snowflake(雪花算法)生成的唯一 ID 的组成不太一样。并且,上面这些参数我们都可以自定义。\nUidGenerator 官方文档中的介绍如下:\n自 18 年后,UidGenerator 就基本没有再维护了,我这里也不过多介绍。想要进一步了解的朋友,可以看看 UidGenerator 的官方介绍。\nLeaf(美团) # Leaf 是美团开源的一个分布式 ID 解决方案 。这个项目的名字 Leaf(树叶) 起源于德国哲学家、数学家莱布尼茨的一句话:“There are no two identical leaves in the world”(世界上没有两片相同的树叶) 。这名字起得真心挺不错的,有点文艺青年那味了!\nLeaf 提供了 号段模式 和 Snowflake(雪花算法) 这两种模式来生成分布式 ID。并且,它支持双号段,还解决了雪花 ID 系统时钟回拨问题。不过,时钟问题的解决需要弱依赖于 Zookeeper(使用 Zookeeper 作为注册中心,通过在特定路径下读取和创建子节点来管理 workId) 。\nLeaf 的诞生主要是为了解决美团各个业务线生成分布式 ID 的方法多种多样以及不可靠的问题。\nLeaf 对原有的号段模式进行改进,比如它这里增加了双号段避免获取 DB 在获取号段的时候阻塞请求获取 ID 的线程。简单来说,就是我一个号段还没用完之前,我自己就主动提前去获取下一个号段(图片来自于美团官方文章: 《Leaf——美团点评分布式 ID 生成系统》)。\n根据项目 README 介绍,在 4C8G VM 基础上,通过公司 RPC 方式调用,QPS 压测结果近 5w/s,TP999 1ms。\nTinyid(滴滴) # Tinyid 是滴滴开源的一款基于数据库号段模式的唯一 ID 生成器。\n数据库号段模式的原理我们在上面已经介绍过了。Tinyid 有哪些亮点呢?\n为了搞清楚这个问题,我们先来看看基于数据库号段模式的简单架构方案。(图片来自于 Tinyid 的官方 wiki: 《Tinyid 原理介绍》)\n在这种架构模式下,我们通过 HTTP 请求向发号器服务申请唯一 ID。负载均衡 router 会把我们的请求送往其中的一台 tinyid-server。\n这种方案有什么问题呢?在我看来(Tinyid 官方 wiki 也有介绍到),主要由下面这 2 个问题:\n获取新号段的情况下,程序获取唯一 ID 的速度比较慢。 需要保证 DB 高可用,这个是比较麻烦且耗费资源的。 除此之外,HTTP 调用也存在网络开销。\nTinyid 的原理比较简单,其架构如下图所示:\n相比于基于数据库号段模式的简单架构方案,Tinyid 方案主要做了下面这些优化:\n双号段缓存:为了避免在获取新号段的情况下,程序获取唯一 ID 的速度比较慢。 Tinyid 中的号段在用到一定程度的时候,就会去异步加载下一个号段,保证内存中始终有可用号段。 增加多 db 支持:支持多个 DB,并且,每个 DB 都能生成唯一 ID,提高了可用性。 增加 tinyid-client:纯本地操作,无 HTTP 请求消耗,性能和可用性都有很大提升。 Tinyid 的优缺点这里就不分析了,结合数据库号段模式的优缺点和 Tinyid 的原理就能知道。\nIdGenerator(个人) # 和 UidGenerator、Leaf 一样, IdGenerator 也是一款基于 Snowflake(雪花算法)的唯一 ID 生成器。\nIdGenerator 有如下特点:\n生成的唯一 ID 更短; 兼容所有雪花算法(号段模式或经典模式,大厂或小厂); 原生支持 C#/Java/Go/C/Rust/Python/Node.js/PHP(C 扩展)/SQL/ 等语言,并提供多线程安全调用动态库(FFI); 解决了时间回拨问题,支持手工插入新 ID(当业务需要在历史时间生成新 ID 时,用本算法的预留位能生成 5000 个每秒); 不依赖外部存储系统; 默认配置下,ID 可用 71000 年不重复。 IdGenerator 生成的唯一 ID 组成如下:\ntimestamp (位数不固定):时间差,是生成 ID 时的系统时间减去 BaseTime(基础时间,也称基点时间、原点时间、纪元时间,默认值为 2020 年) 的总时间差(毫秒单位)。初始为 5bits,随着运行时间而增加。如果觉得默认值太老,你可以重新设置,不过要注意,这个值以后最好不变。 worker id (默认 6 bits):机器 id,机器码,最重要参数,是区分不同机器或不同应用的唯一 ID,最大值由 WorkerIdBitLength(默认 6)限定。如果一台服务器部署多个独立服务,需要为每个服务指定不同的 WorkerId。 sequence (默认 6 bits):序列数,是每毫秒下的序列数,由参数中的 SeqBitLength(默认 6)限定。增加 SeqBitLength 会让性能更高,但生成的 ID 也会更长。 Java 语言使用示例: https://github.com/yitter/idgenerator/tree/master/Java。\n总结 # 通过这篇文章,我基本上已经把最常见的分布式 ID 生成方案都总结了一波。\n除了上面介绍的方式之外,像 ZooKeeper 这类中间件也可以帮助我们生成唯一 ID。没有银弹,一定要结合实际项目来选择最适合自己的方案。\n不过,本文主要介绍的是分布式 ID 的理论知识。在实际的面试中,面试官可能会结合具体的业务场景来考察你对分布式 ID 的设计,你可以参考这篇文章: 分布式 ID 设计指南(对于实际工作中分布式 ID 的设计也非常有帮助)。\n"},{"id":617,"href":"/zh/docs/technology/Interview/distributed-system/distributed-id-design/","title":"分布式ID设计指南","section":"Distributed System","content":"::: tip\n看到百度 Geek 说的一篇结合具体场景聊分布式 ID 设计的文章,感觉挺不错的。于是,我将这篇文章的部分内容整理到了这里。原文传送门: 分布式 ID 生成服务的技术原理和项目实战 。\n:::\n网上绝大多数的分布式 ID 生成服务,一般着重于技术原理剖析,很少见到根据具体的业务场景去选型 ID 生成服务的文章。\n本文结合一些使用场景,进一步探讨业务场景中对 ID 有哪些具体的要求。\n场景一:订单系统 # 我们在商场买东西一码付二维码,下单生成的订单号,使用到的优惠券码,联合商品兑换券码,这些是在网上购物经常使用到的单号,那么为什么有些单号那么长,有些只有几位数?有些单号一看就知道年月日的信息,有些却看不出任何意义?下面展开分析下订单系统中不同场景的 id 服务的具体实现。\n1、一码付 # 我们常见的一码付,指的是一个二维码可以使用支付宝或者微信进行扫码支付。\n二维码的本质是一个字符串。聚合码的本质就是一个链接地址。用户使用支付宝微信直接扫一个码付钱,不用担心拿支付宝扫了微信的收款码或者用微信扫了支付宝的收款码,这极大减少了用户扫码支付的时间。\n实现原理是当客户用 APP 扫码后,网站后台就会判断客户的扫码环境。(微信、支付宝、QQ 钱包、京东支付、云闪付等)。\n判断扫码环境的原理就是根据打开链接浏览器的 HTTP header。任何浏览器打开 http 链接时,请求的 header 都会有 User-Agent(UA、用户代理)信息。\nUA 是一个特殊字符串头,服务器依次可以识别出客户使用的操作系统及版本、CPU 类型、浏览器及版本、浏览器渲染引擎、浏览器语言、浏览器插件等很多信息。\n各渠道对应支付产品的名称不一样,一定要仔细看各支付产品的 API 介绍。\n微信支付:JSAPI 支付支付 支付宝:手机网站支付 QQ 钱包:公众号支付 其本质均为在 APP 内置浏览器中实现 HTML5 支付。\n文库的研发同学在这个思路上,做了优化迭代。动态生成一码付的二维码预先绑定用户所选的商品信息和价格,根据用户所选的商品动态更新。这样不仅支持一码多平台调起支付,而且不用用户选择商品输入金额,即可完成订单支付的功能,很丝滑。用户在真正扫码后,服务端才通过前端获取用户 UID,结合二维码绑定的商品信息,真正的生成订单,发送支付信息到第三方(qq、微信、支付宝),第三方生成支付订单推给用户设备,从而调起支付。\n区别于固定的一码付,在文库的应用中,使用到了动态二维码,二维码本质是一个短网址,ID 服务提供短网址的唯一标志参数。唯一的短网址映射的 ID 绑定了商品的订单信息,技术和业务的深度结合,缩短了支付流程,提升用户的支付体验。\n2、订单号 # 订单号在实际的业务过程中作为一个订单的唯一标识码存在,一般实现以下业务场景:\n用户订单遇到问题,需要找客服进行协助; 对订单进行操作,如线下收款,订单核销; 下单,改单,成单,退单,售后等系统内部的订单流程处理和跟进。 很多时候搜索订单相关信息的时候都是以订单 ID 作为唯一标识符,这是由于订单号的生成规则的唯一性决定的。从技术角度看,除了 ID 服务必要的特性之外,在订单号的设计上需要体现几个特性:\n(1)信息安全\n编号不能透露公司的运营情况,比如日销、公司流水号等信息,以及商业信息和用户手机号,身份证等隐私信息。并且不能有明显的整体规律(可以有局部规律),任意修改一个字符就能查询到另一个订单信息,这也是不允许的。\n类比于我们高考时候的考生编号的生成规则,一定不能是连号的,否则只需要根据顺序往下查询就能搜索到别的考生的成绩,这是绝对不可允许。\n(2)部分可读\n位数要便于操作,因此要求订单号的位数适中,且局部有规律。这样可以方便在订单异常,或者退货时客服查询。\n过长的订单号或易读性差的订单号会导致客服输入困难且易错率较高,影响用户体验的售后体验。因此在实际的业务场景中,订单号的设计通常都会适当携带一些允许公开的对使用场景有帮助的信息,如时间,星期,类型等等,这个主要根据所涉及的编号对应的使用场景来。\n而且像时间、星期这些自增长的属于作为订单号的设计的一部分元素,有助于解决业务累积而导致的订单号重复的问题。\n(3)查询效率\n常见的电商平台订单号大多是纯数字组成,兼具可读性的同时,int 类型相对 varchar 类型的查询效率更高,对在线业务更加友好。\n3、优惠券和兑换券 # 优惠券、兑换券是运营推广最常用的促销工具之一,合理使用它们,可以让买家得到实惠,商家提升商品销量。常见场景有:\n在文库购买【文库 VIP+QQ 音乐年卡】联合商品,支付成功后会得到 QQ 音乐年卡的兑换码,可以去 QQ 音乐 App 兑换音乐会员年卡; 疫情期间,部分地方政府发放的消费券; 瓶装饮料经常会出现输入优惠编码兑换奖品。 从技术角度看,有些场景适合 ID 即时生成,比如电商平台购物领取的优惠券,只需要在用户领取时分配优惠券信息即可。有些线上线下结合的场景,比如疫情优惠券,瓶盖开奖,京东卡,超市卡这种,则需要预先生成,预先生成的券码具备以下特性:\n1.预先生成,在活动正式开始前提供出来进行活动预热;\n2.优惠券体量大,以万为单位,通常在 10 万级别以上;\n3.不可破解、仿制券码;\n4.支持用后核销;\n5.优惠券、兑换券属于广撒网的策略,所以利用率低,也就不适合使用数据库进行存储 (占空间,有效的数据又少)。\n设计思路上,需要设计一种有效的兑换码生成策略,支持预先生成,支持校验,内容简洁,生成的兑换码都具有唯一性,那么这种策略就是一种特殊的编解码策略,按照约定的编解码规则支撑上述需求。\n既然是一种编解码规则,那么需要约定编码空间(也就是用户看到的组成兑换码的字符),编码空间由字符 a-z,A-Z,数字 0-9 组成,为了增强兑换码的可识别度,剔除大写字母 O 以及 I,可用字符如下所示,共 60 个字符:\nabcdefghijklmnopqrstuvwxyzABCDEFGHJKLMNPQRSTUVWXZY0123456789\n之前说过,兑换码要求尽可能简洁,那么设计时就需要考虑兑换码的字符数,假设上限为 12 位,而字符空间有 60 位,那么可以表示的空间范围为 60^12=130606940160000000000000(也就是可以 12 位的兑换码可以生成天量,应该够运营同学挥霍了),转换成 2 进制:\n1001000100000000101110011001101101110011000000000000000000000(61 位)\n兑换码组成成分分析\n兑换码可以预先生成,并且不需要额外的存储空间保存这些信息,每一个优惠方案都有独立的一组兑换码(指运营同学组织的每一场运营活动都有不同的兑换码,不能混合使用, 例如双 11 兑换码不能使用在双 12 活动上),每个兑换码有自己的编号,防止重复,为了保证兑换码的有效性,对兑换码的数据需要进行校验,当前兑换码的数据组成如下所示:\n优惠方案 ID + 兑换码序列号 i + 校验码\n编码方案\n兑换码序列号 i,代表当前兑换码是当前活动中第 i 个兑换码,兑换码序列号的空间范围决定了优惠活动可以发行的兑换码数目,当前采用 30 位 bit 位表示,可表示范围:1073741824(10 亿个券码)。 优惠方案 ID, 代表当前优惠方案的 ID 号,优惠方案的空间范围决定了可以组织的优惠活动次数,当前采用 15 位表示,可以表示范围:32768(考虑到运营活动的频率,以及 ID 的初始值 10000,15 位足够,365 天每天有运营活动,可以使用 54 年)。 校验码,校验兑换码是否有效,主要为了快捷的校验兑换码信息的是否正确,其次可以起到填充数据的目的,增强数据的散列性,使用 13 位表示校验位,其中分为两部分,前 6 位和后 7 位。 深耕业务还会有区分通用券和单独券的情况,分别具备以下特点,技术实现需要因地制宜地思考。\n通用券:多个玩家都可以输入兑换,然后有总量限制,期限限制。 单独券:运营同学可以在后台设置兑换码的奖励物品、期限、个数,然后由后台生成兑换码的列表,兑换之后核销。 场景二:Tracing # 1、日志跟踪 # 在分布式服务架构下,一个 Web 请求从网关流入,有可能会调用多个服务对请求进行处理,拿到最终结果。这个过程中每个服务之间的通信又是单独的网络请求,无论请求经过的哪个服务出了故障或者处理过慢都会对前端造成影响。\n处理一个 Web 请求要调用的多个服务,为了能更方便的查询哪个环节的服务出现了问题,现在常用的解决方案是为整个系统引入分布式链路跟踪。\n在分布式链路跟踪中有两个重要的概念:跟踪(trace)和 跨度( span)。trace 是请求在分布式系统中的整个链路视图,span 则代表整个链路中不同服务内部的视图,span 组合在一起就是整个 trace 的视图。\n在整个请求的调用链中,请求会一直携带 traceid 往下游服务传递,每个服务内部也会生成自己的 spanid 用于生成自己的内部调用视图,并和 traceid 一起传递给下游服务。\n2、TraceId 生成规则 # 这种场景下,生成的 ID 除了要求唯一之外,还要求生成的效率高、吞吐量大。traceid 需要具备接入层的服务器实例自主生成的能力,如果每个 trace 中的 ID 都需要请求公共的 ID 服务生成,纯纯的浪费网络带宽资源。且会阻塞用户请求向下游传递,响应耗时上升,增加了没必要的风险。所以需要服务器实例最好可以自行计算 tracid,spanid,避免依赖外部服务。\n产生规则:服务器 IP + ID 产生的时间 + 自增序列 + 当前进程号 ,比如:\n0ad1348f1403169275002100356696\n前 8 位 0ad1348f 即产生 TraceId 的机器的 IP,这是一个十六进制的数字,每两位代表 IP 中的一段,我们把这个数字,按每两位转成 10 进制即可得到常见的 IP 地址表示方式 10.209.52.143,您也可以根据这个规律来查找到请求经过的第一个服务器。\n后面的 13 位 1403169275002 是产生 TraceId 的时间。之后的 4 位 1003 是一个自增的序列,从 1000 涨到 9000,到达 9000 后回到 1000 再开始往上涨。最后的 5 位 56696 是当前的进程 ID,为了防止单机多进程出现 TraceId 冲突的情况,所以在 TraceId 末尾添加了当前的进程 ID。\n3、SpanId 生成规则 # span 是层的意思,比如在第一个实例算是第一层, 请求代理或者分流到下一个实例处理,就是第二层,以此类推。通过层,SpanId 代表本次调用在整个调用链路树中的位置。\n假设一个 服务器实例 A 接收了一次用户请求,代表是整个调用的根节点,那么 A 层处理这次请求产生的非服务调用日志记录 spanid 的值都是 0,A 层需要通过 RPC 依次调用 B、C、D 三个服务器实例,那么在 A 的日志中,SpanId 分别是 0.1,0.2 和 0.3,在 B、C、D 中,SpanId 也分别是 0.1,0.2 和 0.3;如果 C 系统在处理请求的时候又调用了 E,F 两个服务器实例,那么 C 系统中对应的 spanid 是 0.2.1 和 0.2.2,E、F 两个系统对应的日志也是 0.2.1 和 0.2.2。\n根据上面的描述可以知道,如果把一次调用中所有的 SpanId 收集起来,可以组成一棵完整的链路树。\nspanid 的生成本质:在跨层传递透传的同时,控制大小版本号的自增来实现的。\n场景三:短网址 # 短网址主要功能包括网址缩短与还原两大功能。相对于长网址,短网址可以更方便地在电子邮件,社交网络,微博和手机上传播,例如原来很长的网址通过短网址服务即可生成相应的短网址,避免折行或超出字符限制。\n常用的 ID 生成服务比如:MySQL ID 自增、 Redis 键自增、号段模式,生成的 ID 都是一串数字。短网址服务把客户的长网址转换成短网址,\n实际是在 dwz.cn 域名后面拼接新产生的数字类型 ID,直接用数字 ID,网址长度也有些长,服务可以通过数字 ID 转更高进制的方式压缩长度。这种算法在短网址的技术实现上越来越多了起来,它可以进一步压缩网址长度。转进制的压缩算法在生活中有广泛的应用场景,举例:\n客户的长网址: https://wenku.baidu.com/ndbusiness/browse/wenkuvipcashier?cashier_code=PCoperatebanner ID 映射的短网址: https://dwz.cn/2047601319t66 (演示使用,可能无法正确打开) 转进制后的短网址: https://dwz.cn/2ezwDJ0 (演示使用,可能无法正确打开) "},{"id":618,"href":"/zh/docs/technology/Interview/distributed-system/distributed-configuration-center/","title":"分布式配置中心常见问题总结(付费)","section":"Distributed System","content":"分布式配置中心 相关的面试题为我的 知识星球(点击链接即可查看详细介绍以及加入方法)专属内容,已经整理到了《Java 面试指北》中。\n"},{"id":619,"href":"/zh/docs/technology/Interview/distributed-system/distributed-transaction/","title":"分布式事务常见解决方案总结(付费)","section":"Distributed System","content":"分布式事务 相关的面试题为我的 知识星球(点击链接即可查看详细介绍以及加入方法)专属内容,已经整理到了《Java 面试指北》中。\n"},{"id":620,"href":"/zh/docs/technology/Interview/distributed-system/distributed-lock-implementations/","title":"分布式锁常见实现方案总结","section":"Distributed System","content":"通常情况下,我们一般会选择基于 Redis 或者 ZooKeeper 实现分布式锁,Redis 用的要更多一点,我这里也先以 Redis 为例介绍分布式锁的实现。\n基于 Redis 实现分布式锁 # 如何基于 Redis 实现一个最简易的分布式锁? # 不论是本地锁还是分布式锁,核心都在于“互斥”。\n在 Redis 中, SETNX 命令是可以帮助我们实现互斥。SETNX 即 SET if Not eXists (对应 Java 中的 setIfAbsent 方法),如果 key 不存在的话,才会设置 key 的值。如果 key 已经存在, SETNX 啥也不做。\n\u0026gt; SETNX lockKey uniqueValue (integer) 1 \u0026gt; SETNX lockKey uniqueValue (integer) 0 释放锁的话,直接通过 DEL 命令删除对应的 key 即可。\n\u0026gt; DEL lockKey (integer) 1 为了防止误删到其他的锁,这里我们建议使用 Lua 脚本通过 key 对应的 value(唯一值)来判断。\n选用 Lua 脚本是为了保证解锁操作的原子性。因为 Redis 在执行 Lua 脚本时,可以以原子性的方式执行,从而保证了锁释放操作的原子性。\n// 释放锁时,先比较锁对应的 value 值是否相等,避免锁的误释放 if redis.call(\u0026#34;get\u0026#34;,KEYS[1]) == ARGV[1] then return redis.call(\u0026#34;del\u0026#34;,KEYS[1]) else return 0 end 这是一种最简易的 Redis 分布式锁实现,实现方式比较简单,性能也很高效。不过,这种方式实现分布式锁存在一些问题。就比如应用程序遇到一些问题比如释放锁的逻辑突然挂掉,可能会导致锁无法被释放,进而造成共享资源无法再被其他线程/进程访问。\n为什么要给锁设置一个过期时间? # 为了避免锁无法被释放,我们可以想到的一个解决办法就是:给这个 key(也就是锁) 设置一个过期时间 。\n127.0.0.1:6379\u0026gt; SET lockKey uniqueValue EX 3 NX OK lockKey:加锁的锁名; uniqueValue:能够唯一标识锁的随机字符串; NX:只有当 lockKey 对应的 key 值不存在的时候才能 SET 成功; EX:过期时间设置(秒为单位)EX 3 标示这个锁有一个 3 秒的自动过期时间。与 EX 对应的是 PX(毫秒为单位),这两个都是过期时间设置。 一定要保证设置指定 key 的值和过期时间是一个原子操作!!! 不然的话,依然可能会出现锁无法被释放的问题。\n这样确实可以解决问题,不过,这种解决办法同样存在漏洞:如果操作共享资源的时间大于过期时间,就会出现锁提前过期的问题,进而导致分布式锁直接失效。如果锁的超时时间设置过长,又会影响到性能。\n你或许在想:如果操作共享资源的操作还未完成,锁过期时间能够自己续期就好了!\n如何实现锁的优雅续期? # 对于 Java 开发的小伙伴来说,已经有了现成的解决方案: Redisson 。其他语言的解决方案,可以在 Redis 官方文档中找到,地址: https://redis.io/topics/distlock 。\nRedisson 是一个开源的 Java 语言 Redis 客户端,提供了很多开箱即用的功能,不仅仅包括多种分布式锁的实现。并且,Redisson 还支持 Redis 单机、Redis Sentinel、Redis Cluster 等多种部署架构。\nRedisson 中的分布式锁自带自动续期机制,使用起来非常简单,原理也比较简单,其提供了一个专门用来监控和续期锁的 Watch Dog( 看门狗),如果操作共享资源的线程还未执行完成的话,Watch Dog 会不断地延长锁的过期时间,进而保证锁不会因为超时而被释放。\n看门狗名字的由来于 getLockWatchdogTimeout() 方法,这个方法返回的是看门狗给锁续期的过期时间,默认为 30 秒( redisson-3.17.6)。\n//默认 30秒,支持修改 private long lockWatchdogTimeout = 30 * 1000; public Config setLockWatchdogTimeout(long lockWatchdogTimeout) { this.lockWatchdogTimeout = lockWatchdogTimeout; return this; } public long getLockWatchdogTimeout() { return lockWatchdogTimeout; } renewExpiration() 方法包含了看门狗的主要逻辑:\nprivate void renewExpiration() { //...... Timeout task = commandExecutor.getConnectionManager().newTimeout(new TimerTask() { @Override public void run(Timeout timeout) throws Exception { //...... // 异步续期,基于 Lua 脚本 CompletionStage\u0026lt;Boolean\u0026gt; future = renewExpirationAsync(threadId); future.whenComplete((res, e) -\u0026gt; { if (e != null) { // 无法续期 log.error(\u0026#34;Can\u0026#39;t update lock \u0026#34; + getRawName() + \u0026#34; expiration\u0026#34;, e); EXPIRATION_RENEWAL_MAP.remove(getEntryName()); return; } if (res) { // 递归调用实现续期 renewExpiration(); } else { // 取消续期 cancelExpirationRenewal(null); } }); } // 延迟 internalLockLeaseTime/3(默认 10s,也就是 30/3) 再调用 }, internalLockLeaseTime / 3, TimeUnit.MILLISECONDS); ee.setTimeout(task); } 默认情况下,每过 10 秒,看门狗就会执行续期操作,将锁的超时时间设置为 30 秒。看门狗续期前也会先判断是否需要执行续期操作,需要才会执行续期,否则取消续期操作。\nWatch Dog 通过调用 renewExpirationAsync() 方法实现锁的异步续期:\nprotected CompletionStage\u0026lt;Boolean\u0026gt; renewExpirationAsync(long threadId) { return evalWriteAsync(getRawName(), LongCodec.INSTANCE, RedisCommands.EVAL_BOOLEAN, // 判断是否为持锁线程,如果是就执行续期操作,就锁的过期时间设置为 30s(默认) \u0026#34;if (redis.call(\u0026#39;hexists\u0026#39;, KEYS[1], ARGV[2]) == 1) then \u0026#34; + \u0026#34;redis.call(\u0026#39;pexpire\u0026#39;, KEYS[1], ARGV[1]); \u0026#34; + \u0026#34;return 1; \u0026#34; + \u0026#34;end; \u0026#34; + \u0026#34;return 0;\u0026#34;, Collections.singletonList(getRawName()), internalLockLeaseTime, getLockName(threadId)); } 可以看出, renewExpirationAsync 方法其实是调用 Lua 脚本实现的续期,这样做主要是为了保证续期操作的原子性。\n我这里以 Redisson 的分布式可重入锁 RLock 为例来说明如何使用 Redisson 实现分布式锁:\n// 1.获取指定的分布式锁对象 RLock lock = redisson.getLock(\u0026#34;lock\u0026#34;); // 2.拿锁且不设置锁超时时间,具备 Watch Dog 自动续期机制 lock.lock(); // 3.执行业务 ... // 4.释放锁 lock.unlock(); 只有未指定锁超时时间,才会使用到 Watch Dog 自动续期机制。\n// 手动给锁设置过期时间,不具备 Watch Dog 自动续期机制 lock.lock(10, TimeUnit.SECONDS); 如果使用 Redis 来实现分布式锁的话,还是比较推荐直接基于 Redisson 来做的。\n如何实现可重入锁? # 所谓可重入锁指的是在一个线程中可以多次获取同一把锁,比如一个线程在执行一个带锁的方法,该方法中又调用了另一个需要相同锁的方法,则该线程可以直接执行调用的方法即可重入 ,而无需重新获得锁。像 Java 中的 synchronized 和 ReentrantLock 都属于可重入锁。\n不可重入的分布式锁基本可以满足绝大部分业务场景了,一些特殊的场景可能会需要使用可重入的分布式锁。\n可重入分布式锁的实现核心思路是线程在获取锁的时候判断是否为自己的锁,如果是的话,就不用再重新获取了。为此,我们可以为每个锁关联一个可重入计数器和一个占有它的线程。当可重入计数器大于 0 时,则锁被占有,需要判断占有该锁的线程和请求获取锁的线程是否为同一个。\n实际项目中,我们不需要自己手动实现,推荐使用我们上面提到的 Redisson ,其内置了多种类型的锁比如可重入锁(Reentrant Lock)、自旋锁(Spin Lock)、公平锁(Fair Lock)、多重锁(MultiLock)、 红锁(RedLock)、 读写锁(ReadWriteLock)。\nRedis 如何解决集群情况下分布式锁的可靠性? # 为了避免单点故障,生产环境下的 Redis 服务通常是集群化部署的。\nRedis 集群下,上面介绍到的分布式锁的实现会存在一些问题。由于 Redis 集群数据同步到各个节点时是异步的,如果在 Redis 主节点获取到锁后,在没有同步到其他节点时,Redis 主节点宕机了,此时新的 Redis 主节点依然可以获取锁,所以多个应用服务就可以同时获取到锁。\n针对这个问题,Redis 之父 antirez 设计了 Redlock 算法 来解决。\nRedlock 算法的思想是让客户端向 Redis 集群中的多个独立的 Redis 实例依次请求申请加锁,如果客户端能够和半数以上的实例成功地完成加锁操作,那么我们就认为,客户端成功地获得分布式锁,否则加锁失败。\n即使部分 Redis 节点出现问题,只要保证 Redis 集群中有半数以上的 Redis 节点可用,分布式锁服务就是正常的。\nRedlock 是直接操作 Redis 节点的,并不是通过 Redis 集群操作的,这样才可以避免 Redis 集群主从切换导致的锁丢失问题。\nRedlock 实现比较复杂,性能比较差,发生时钟变迁的情况下还存在安全性隐患。《数据密集型应用系统设计》一书的作者 Martin Kleppmann 曾经专门发文( How to do distributed locking - Martin Kleppmann - 2016)怼过 Redlock,他认为这是一个很差的分布式锁实现。感兴趣的朋友可以看看 Redis 锁从面试连环炮聊到神仙打架这篇文章,有详细介绍到 antirez 和 Martin Kleppmann 关于 Redlock 的激烈辩论。\n实际项目中不建议使用 Redlock 算法,成本和收益不成正比,可以考虑基于 Redis 主从复制+哨兵模式实现分布式锁。\n基于 ZooKeeper 实现分布式锁 # ZooKeeper 相比于 Redis 实现分布式锁,除了提供相对更高的可靠性之外,在功能层面还有一个非常有用的特性:Watch 机制。这个机制可以用来实现公平的分布式锁。不过,使用 ZooKeeper 实现的分布式锁在性能方面相对较差,因此如果对性能要求比较高的话,ZooKeeper 可能就不太适合了。\n如何基于 ZooKeeper 实现分布式锁? # ZooKeeper 分布式锁是基于 临时顺序节点 和 Watcher(事件监听器) 实现的。\n获取锁:\n首先我们要有一个持久节点/locks,客户端获取锁就是在locks下创建临时顺序节点。 假设客户端 1 创建了/locks/lock1节点,创建成功之后,会判断 lock1是否是 /locks 下最小的子节点。 如果 lock1是最小的子节点,则获取锁成功。否则,获取锁失败。 如果获取锁失败,则说明有其他的客户端已经成功获取锁。客户端 1 并不会不停地循环去尝试加锁,而是在前一个节点比如/locks/lock0上注册一个事件监听器。这个监听器的作用是当前一个节点释放锁之后通知客户端 1(避免无效自旋),这样客户端 1 就加锁成功了。 释放锁:\n成功获取锁的客户端在执行完业务流程之后,会将对应的子节点删除。 成功获取锁的客户端在出现故障之后,对应的子节点由于是临时顺序节点,也会被自动删除,避免了锁无法被释放。 我们前面说的事件监听器其实监听的就是这个子节点删除事件,子节点删除就意味着锁被释放。 实际项目中,推荐使用 Curator 来实现 ZooKeeper 分布式锁。Curator 是 Netflix 公司开源的一套 ZooKeeper Java 客户端框架,相比于 ZooKeeper 自带的客户端 zookeeper 来说,Curator 的封装更加完善,各种 API 都可以比较方便地使用。\nCurator主要实现了下面四种锁:\nInterProcessMutex:分布式可重入排它锁 InterProcessSemaphoreMutex:分布式不可重入排它锁 InterProcessReadWriteLock:分布式读写锁 InterProcessMultiLock:将多个锁作为单个实体管理的容器,获取锁的时候获取所有锁,释放锁也会释放所有锁资源(忽略释放失败的锁)。 CuratorFramework client = ZKUtils.getClient(); client.start(); // 分布式可重入排它锁 InterProcessLock lock1 = new InterProcessMutex(client, lockPath1); // 分布式不可重入排它锁 InterProcessLock lock2 = new InterProcessSemaphoreMutex(client, lockPath2); // 将多个锁作为一个整体 InterProcessMultiLock lock = new InterProcessMultiLock(Arrays.asList(lock1, lock2)); if (!lock.acquire(10, TimeUnit.SECONDS)) { throw new IllegalStateException(\u0026#34;不能获取多锁\u0026#34;); } System.out.println(\u0026#34;已获取多锁\u0026#34;); System.out.println(\u0026#34;是否有第一个锁: \u0026#34; + lock1.isAcquiredInThisProcess()); System.out.println(\u0026#34;是否有第二个锁: \u0026#34; + lock2.isAcquiredInThisProcess()); try { // 资源操作 resource.use(); } finally { System.out.println(\u0026#34;释放多个锁\u0026#34;); lock.release(); } System.out.println(\u0026#34;是否有第一个锁: \u0026#34; + lock1.isAcquiredInThisProcess()); System.out.println(\u0026#34;是否有第二个锁: \u0026#34; + lock2.isAcquiredInThisProcess()); client.close(); 为什么要用临时顺序节点? # 每个数据节点在 ZooKeeper 中被称为 znode,它是 ZooKeeper 中数据的最小单元。\n我们通常是将 znode 分为 4 大类:\n持久(PERSISTENT)节点:一旦创建就一直存在即使 ZooKeeper 集群宕机,直到将其删除。 临时(EPHEMERAL)节点:临时节点的生命周期是与 客户端会话(session) 绑定的,会话消失则节点消失 。并且,临时节点只能做叶子节点 ,不能创建子节点。 持久顺序(PERSISTENT_SEQUENTIAL)节点:除了具有持久(PERSISTENT)节点的特性之外, 子节点的名称还具有顺序性。比如 /node1/app0000000001、/node1/app0000000002 。 临时顺序(EPHEMERAL_SEQUENTIAL)节点:除了具备临时(EPHEMERAL)节点的特性之外,子节点的名称还具有顺序性。 可以看出,临时节点相比持久节点,最主要的是对会话失效的情况处理不一样,临时节点会话消失则对应的节点消失。这样的话,如果客户端发生异常导致没来得及释放锁也没关系,会话失效节点自动被删除,不会发生死锁的问题。\n使用 Redis 实现分布式锁的时候,我们是通过过期时间来避免锁无法被释放导致死锁问题的,而 ZooKeeper 直接利用临时节点的特性即可。\n假设不使用顺序节点的话,所有尝试获取锁的客户端都会对持有锁的子节点加监听器。当该锁被释放之后,势必会造成所有尝试获取锁的客户端来争夺锁,这样对性能不友好。使用顺序节点之后,只需要监听前一个节点就好了,对性能更友好。\n为什么要设置对前一个节点的监听? # Watcher(事件监听器),是 ZooKeeper 中的一个很重要的特性。ZooKeeper 允许用户在指定节点上注册一些 Watcher,并且在一些特定事件触发的时候,ZooKeeper 服务端会将事件通知到感兴趣的客户端上去,该机制是 ZooKeeper 实现分布式协调服务的重要特性。\n同一时间段内,可能会有很多客户端同时获取锁,但只有一个可以获取成功。如果获取锁失败,则说明有其他的客户端已经成功获取锁。获取锁失败的客户端并不会不停地循环去尝试加锁,而是在前一个节点注册一个事件监听器。\n这个事件监听器的作用是:当前一个节点对应的客户端释放锁之后(也就是前一个节点被删除之后,监听的是删除事件),通知获取锁失败的客户端(唤醒等待的线程,Java 中的 wait/notifyAll ),让它尝试去获取锁,然后就成功获取锁了。\n如何实现可重入锁? # 这里以 Curator 的 InterProcessMutex 对可重入锁的实现来介绍(源码地址: InterProcessMutex.java)。\n当我们调用 InterProcessMutex#acquire方法获取锁的时候,会调用InterProcessMutex#internalLock方法。\n// 获取可重入互斥锁,直到获取成功为止 @Override public void acquire() throws Exception { if (!internalLock(-1, null)) { throw new IOException(\u0026#34;Lost connection while trying to acquire lock: \u0026#34; + basePath); } } internalLock 方法会先获取当前请求锁的线程,然后从 threadData( ConcurrentMap\u0026lt;Thread, LockData\u0026gt; 类型)中获取当前线程对应的 lockData 。 lockData 包含锁的信息和加锁的次数,是实现可重入锁的关键。\n第一次获取锁的时候,lockData为 null。获取锁成功之后,会将当前线程和对应的 lockData 放到 threadData 中\nprivate boolean internalLock(long time, TimeUnit unit) throws Exception { // 获取当前请求锁的线程 Thread currentThread = Thread.currentThread(); // 拿对应的 lockData LockData lockData = threadData.get(currentThread); // 第一次获取锁的话,lockData 为 null if (lockData != null) { // 当前线程获取过一次锁之后 // 因为当前线程的锁存在, lockCount 自增后返回,实现锁重入. lockData.lockCount.incrementAndGet(); return true; } // 尝试获取锁 String lockPath = internals.attemptLock(time, unit, getLockNodeBytes()); if (lockPath != null) { LockData newLockData = new LockData(currentThread, lockPath); // 获取锁成功之后,将当前线程和对应的 lockData 放到 threadData 中 threadData.put(currentThread, newLockData); return true; } return false; } LockData是 InterProcessMutex中的一个静态内部类。\nprivate final ConcurrentMap\u0026lt;Thread, LockData\u0026gt; threadData = Maps.newConcurrentMap(); private static class LockData { // 当前持有锁的线程 final Thread owningThread; // 锁对应的子节点 final String lockPath; // 加锁的次数 final AtomicInteger lockCount = new AtomicInteger(1); private LockData(Thread owningThread, String lockPath) { this.owningThread = owningThread; this.lockPath = lockPath; } } 如果已经获取过一次锁,后面再来获取锁的话,直接就会在 if (lockData != null) 这里被拦下了,然后就会执行lockData.lockCount.incrementAndGet(); 将加锁次数加 1。\n整个可重入锁的实现逻辑非常简单,直接在客户端判断当前线程有没有获取锁,有的话直接将加锁次数加 1 就可以了。\n总结 # 在这篇文章中,我介绍了实现分布式锁的两种常见方式:Redis 和 ZooKeeper。至于具体选择 Redis 还是 ZooKeeper 来实现分布式锁,还是要根据业务的具体需求来决定。\n如果对性能要求比较高的话,建议使用 Redis 实现分布式锁。推荐优先选择 Redisson 提供的现成分布式锁,而不是自己实现。实际项目中不建议使用 Redlock 算法,成本和收益不成正比,可以考虑基于 Redis 主从复制+哨兵模式实现分布式锁。 如果对可靠性要求比较高,建议使用 ZooKeeper 实现分布式锁,推荐基于 Curator 框架来实现。不过,现在很多项目都不会用到 ZooKeeper,如果单纯是因为分布式锁而引入 ZooKeeper 的话,那是不太可取的,不建议这样做,为了一个小小的功能增加了系统的复杂度。 需要注意的是,无论选择哪种方式实现分布式锁,包括 Redis、ZooKeeper 或 Etcd(本文没介绍,但也经常用来实现分布式锁),都无法保证 100% 的安全性,特别是在遇到进程垃圾回收(GC)、网络延迟等异常情况下。\n为了进一步提高系统的可靠性,建议引入一个兜底机制。例如,可以通过 版本号(Fencing Token)机制 来避免并发冲突。\n最后,再分享几篇我觉得写的还不错的文章:\n分布式锁实现原理与最佳实践 - 阿里云开发者 聊聊分布式锁 - 字节跳动技术团队 Redis、ZooKeeper、Etcd,谁有最好用的分布式锁? - 腾讯云开发者 "},{"id":621,"href":"/zh/docs/technology/Interview/distributed-system/distributed-lock/","title":"分布式锁介绍","section":"Distributed System","content":"网上有很多分布式锁相关的文章,写了一个相对简洁易懂的版本,针对面试和工作应该够用了。\n这篇文章我们先介绍一下分布式锁的基本概念。\n为什么需要分布式锁? # 在多线程环境中,如果多个线程同时访问共享资源(例如商品库存、外卖订单),会发生数据竞争,可能会导致出现脏数据或者系统问题,威胁到程序的正常运行。\n举个例子,假设现在有 100 个用户参与某个限时秒杀活动,每位用户限购 1 件商品,且商品的数量只有 3 个。如果不对共享资源进行互斥访问,就可能出现以下情况:\n线程 1、2、3 等多个线程同时进入抢购方法,每一个线程对应一个用户。 线程 1 查询用户已经抢购的数量,发现当前用户尚未抢购且商品库存还有 1 个,因此认为可以继续执行抢购流程。 线程 2 也执行查询用户已经抢购的数量,发现当前用户尚未抢购且商品库存还有 1 个,因此认为可以继续执行抢购流程。 线程 1 继续执行,将库存数量减少 1 个,然后返回成功。 线程 2 继续执行,将库存数量减少 1 个,然后返回成功。 此时就发生了超卖问题,导致商品被多卖了一份。 为了保证共享资源被安全地访问,我们需要使用互斥操作对共享资源进行保护,即同一时刻只允许一个线程访问共享资源,其他线程需要等待当前线程释放后才能访问。这样可以避免数据竞争和脏数据问题,保证程序的正确性和稳定性。\n如何才能实现共享资源的互斥访问呢? 锁是一个比较通用的解决方案,更准确点来说是悲观锁。\n悲观锁总是假设最坏的情况,认为共享资源每次被访问的时候就会出现问题(比如共享数据被修改),所以每次在获取资源操作的时候都会上锁,这样其他线程想拿到这个资源就会阻塞直到锁被上一个持有者释放。也就是说,共享资源每次只给一个线程使用,其它线程阻塞,用完后再把资源转让给其它线程。\n对于单机多线程来说,在 Java 中,我们通常使用 ReentrantLock 类、synchronized 关键字这类 JDK 自带的 本地锁 来控制一个 JVM 进程内的多个线程对本地共享资源的访问。\n下面是我对本地锁画的一张示意图。\n从图中可以看出,这些线程访问共享资源是互斥的,同一时刻只有一个线程可以获取到本地锁访问共享资源。\n分布式系统下,不同的服务/客户端通常运行在独立的 JVM 进程上。如果多个 JVM 进程共享同一份资源的话,使用本地锁就没办法实现资源的互斥访问了。于是,分布式锁 就诞生了。\n举个例子:系统的订单服务一共部署了 3 份,都对外提供服务。用户下订单之前需要检查库存,为了防止超卖,这里需要加锁以实现对检查库存操作的同步访问。由于订单服务位于不同的 JVM 进程中,本地锁在这种情况下就没办法正常工作了。我们需要用到分布式锁,这样的话,即使多个线程不在同一个 JVM 进程中也能获取到同一把锁,进而实现共享资源的互斥访问。\n下面是我对分布式锁画的一张示意图。\n从图中可以看出,这些独立的进程中的线程访问共享资源是互斥的,同一时刻只有一个线程可以获取到分布式锁访问共享资源。\n分布式锁应该具备哪些条件? # 一个最基本的分布式锁需要满足:\n互斥:任意一个时刻,锁只能被一个线程持有。 高可用:锁服务是高可用的,当一个锁服务出现问题,能够自动切换到另外一个锁服务。并且,即使客户端的释放锁的代码逻辑出现问题,锁最终一定还是会被释放,不会影响其他线程对共享资源的访问。这一般是通过超时机制实现的。 可重入:一个节点获取了锁之后,还可以再次获取锁。 除了上面这三个基本条件之外,一个好的分布式锁还需要满足下面这些条件:\n高性能:获取和释放锁的操作应该快速完成,并且不应该对整个系统的性能造成过大影响。 非阻塞:如果获取不到锁,不能无限期等待,避免对系统正常运行造成影响。 分布式锁的常见实现方式有哪些? # 常见分布式锁实现方案如下:\n基于关系型数据库比如 MySQL 实现分布式锁。 基于分布式协调服务 ZooKeeper 实现分布式锁。 基于分布式键值存储系统比如 Redis 、Etcd 实现分布式锁。 关系型数据库的方式一般是通过唯一索引或者排他锁实现。不过,一般不会使用这种方式,问题太多比如性能太差、不具备锁失效机制。\n基于 ZooKeeper 或者 Redis 实现分布式锁这两种实现方式要用的更多一些,我专门写了一篇文章来详细介绍这两种方案: 分布式锁常见实现方案总结。\n总结 # 这篇文章我们主要介绍了:\n分布式锁的用途:分布式系统下,不同的服务/客户端通常运行在独立的 JVM 进程上。如果多个 JVM 进程共享同一份资源的话,使用本地锁就没办法实现资源的互斥访问了。 分布式锁的应该具备的条件:互斥、高可用、可重入、高性能、非阻塞。 分布式锁的常见实现方式:关系型数据库比如 MySQL、分布式协调服务 ZooKeeper、分布式键值存储系统比如 Redis 、Etcd 。 "},{"id":622,"href":"/zh/docs/technology/Interview/high-availability/limit-request/","title":"服务限流详解","section":"High Availability","content":"针对软件系统来说,限流就是对请求的速率进行限制,避免瞬时的大量请求击垮软件系统。毕竟,软件系统的处理能力是有限的。如果说超过了其处理能力的范围,软件系统可能直接就挂掉了。\n限流可能会导致用户的请求无法被正确处理或者无法立即被处理,不过,这往往也是权衡了软件系统的稳定性之后得到的最优解。\n现实生活中,处处都有限流的实际应用,就比如排队买票是为了避免大量用户涌入购票而导致售票员无法处理。\n常见限流算法有哪些? # 简单介绍 4 种非常好理解并且容易实现的限流算法!\n图片来源于 InfoQ 的一篇文章 《分布式服务限流实战,已经为你排好坑了》。\n固定窗口计数器算法 # 固定窗口其实就是时间窗口,其原理是将时间划分为固定大小的窗口,在每个窗口内限制请求的数量或速率,即固定窗口计数器算法规定了系统单位时间处理的请求数量。\n假如我们规定系统中某个接口 1 分钟只能被访问 33 次的话,使用固定窗口计数器算法的实现思路如下:\n将时间划分固定大小窗口,这里是 1 分钟一个窗口。 给定一个变量 counter 来记录当前接口处理的请求数量,初始值为 0(代表接口当前 1 分钟内还未处理请求)。 1 分钟之内每处理一个请求之后就将 counter+1 ,当 counter=33 之后(也就是说在这 1 分钟内接口已经被访问 33 次的话),后续的请求就会被全部拒绝。 等到 1 分钟结束后,将 counter 重置 0,重新开始计数。 优点:实现简单,易于理解。\n缺点:\n限流不够平滑。例如,我们限制某个接口每分钟只能访问 30 次,假设前 30 秒就有 30 个请求到达的话,那后续 30 秒将无法处理请求,这是不可取的,用户体验极差! 无法保证限流速率,因而无法应对突然激增的流量。例如,我们限制某个接口 1 分钟只能访问 1000 次,该接口的 QPS 为 500,前 55s 这个接口 1 个请求没有接收,后 1s 突然接收了 1000 个请求。然后,在当前场景下,这 1000 个请求在 1s 内是没办法被处理的,系统直接就被瞬时的大量请求给击垮了。 滑动窗口计数器算法 # 滑动窗口计数器算法 算的上是固定窗口计数器算法的升级版,限流的颗粒度更小。\n滑动窗口计数器算法相比于固定窗口计数器算法的优化在于:它把时间以一定比例分片 。\n例如我们的接口限流每分钟处理 60 个请求,我们可以把 1 分钟分为 60 个窗口。每隔 1 秒移动一次,每个窗口一秒只能处理不大于 60(请求数)/60(窗口数) 的请求, 如果当前窗口的请求计数总和超过了限制的数量的话就不再处理其他请求。\n很显然, 当滑动窗口的格子划分的越多,滑动窗口的滚动就越平滑,限流的统计就会越精确。\n优点:\n相比于固定窗口算法,滑动窗口计数器算法可以应对突然激增的流量。 相比于固定窗口算法,滑动窗口计数器算法的颗粒度更小,可以提供更精确的限流控制。 缺点:\n与固定窗口计数器算法类似,滑动窗口计数器算法依然存在限流不够平滑的问题。 相比较于固定窗口计数器算法,滑动窗口计数器算法实现和理解起来更复杂一些。 漏桶算法 # 我们可以把发请求的动作比作成注水到桶中,我们处理请求的过程可以比喻为漏桶漏水。我们往桶中以任意速率流入水,以一定速率流出水。当水超过桶流量则丢弃,因为桶容量是不变的,保证了整体的速率。\n如果想要实现这个算法的话也很简单,准备一个队列用来保存请求,然后我们定期从队列中拿请求来执行就好了(和消息队列削峰/限流的思想是一样的)。\n优点:\n实现简单,易于理解。 可以控制限流速率,避免网络拥塞和系统过载。 缺点:\n无法应对突然激增的流量,因为只能以固定的速率处理请求,对系统资源利用不够友好。 桶流入水(发请求)的速率如果一直大于桶流出水(处理请求)的速率的话,那么桶会一直是满的,一部分新的请求会被丢弃,导致服务质量下降。 实际业务场景中,基本不会使用漏桶算法。\n令牌桶算法 # 令牌桶算法也比较简单。和漏桶算法算法一样,我们的主角还是桶(这限流算法和桶过不去啊)。不过现在桶里装的是令牌了,请求在被处理之前需要拿到一个令牌,请求处理完毕之后将这个令牌丢弃(删除)。我们根据限流大小,按照一定的速率往桶里添加令牌。如果桶装满了,就不能继续往里面继续添加令牌了。\n优点:\n可以限制平均速率和应对突然激增的流量。 可以动态调整生成令牌的速率。 缺点:\n如果令牌产生速率和桶的容量设置不合理,可能会出现问题比如大量的请求被丢弃、系统过载。 相比于其他限流算法,实现和理解起来更复杂一些。 针对什么来进行限流? # 实际项目中,还需要确定限流对象,也就是针对什么来进行限流。常见的限流对象如下:\nIP :针对 IP 进行限流,适用面较广,简单粗暴。 业务 ID:挑选唯一的业务 ID 以实现更针对性地限流。例如,基于用户 ID 进行限流。 个性化:根据用户的属性或行为,进行不同的限流策略。例如, VIP 用户不限流,而普通用户限流。根据系统的运行指标(如 QPS、并发调用数、系统负载等),动态调整限流策略。例如,当系统负载较高的时候,控制每秒通过的请求减少。 针对 IP 进行限流是目前比较常用的一个方案。不过,实际应用中需要注意用户真实 IP 地址的正确获取。常用的真实 IP 获取方法有 X-Forwarded-For 和 TCP Options 字段承载真实源 IP 信息。虽然 X-Forwarded-For 字段可能会被伪造,但因为其实现简单方便,很多项目还是直接用的这种方法。\n除了我上面介绍到的限流对象之外,还有一些其他较为复杂的限流对象策略,比如阿里的 Sentinel 还支持 基于调用关系的限流(包括基于调用方限流、基于调用链入口限流、关联流量限流等)以及更细维度的 热点参数限流(实时的统计热点参数并针对热点参数的资源调用进行流量控制)。\n另外,一个项目可以根据具体的业务需求选择多种不同的限流对象搭配使用。\n单机限流怎么做? # 单机限流针对的是单体架构应用。\n单机限流可以直接使用 Google Guava 自带的限流工具类 RateLimiter 。 RateLimiter 基于令牌桶算法,可以应对突发流量。\nGuava 地址: https://github.com/google/guava\n除了最基本的令牌桶算法(平滑突发限流)实现之外,Guava 的RateLimiter还提供了 平滑预热限流 的算法实现。\n平滑突发限流就是按照指定的速率放令牌到桶里,而平滑预热限流会有一段预热时间,预热时间之内,速率会逐渐提升到配置的速率。\n我们下面通过两个简单的小例子来详细了解吧!\n我们直接在项目中引入 Guava 相关的依赖即可使用。\n\u0026lt;dependency\u0026gt; \u0026lt;groupId\u0026gt;com.google.guava\u0026lt;/groupId\u0026gt; \u0026lt;artifactId\u0026gt;guava\u0026lt;/artifactId\u0026gt; \u0026lt;version\u0026gt;31.0.1-jre\u0026lt;/version\u0026gt; \u0026lt;/dependency\u0026gt; 下面是一个简单的 Guava 平滑突发限流的 Demo。\nimport com.google.common.util.concurrent.RateLimiter; /** * 微信搜 JavaGuide 回复\u0026#34;面试突击\u0026#34;即可免费领取个人原创的 Java 面试手册 * * @author Guide哥 * @date 2021/10/08 19:12 **/ public class RateLimiterDemo { public static void main(String[] args) { // 1s 放 5 个令牌到桶里也就是 0.2s 放 1个令牌到桶里 RateLimiter rateLimiter = RateLimiter.create(5); for (int i = 0; i \u0026lt; 10; i++) { double sleepingTime = rateLimiter.acquire(1); System.out.printf(\u0026#34;get 1 tokens: %ss%n\u0026#34;, sleepingTime); } } } 输出:\nget 1 tokens: 0.0s get 1 tokens: 0.188413s get 1 tokens: 0.197811s get 1 tokens: 0.198316s get 1 tokens: 0.19864s get 1 tokens: 0.199363s get 1 tokens: 0.193997s get 1 tokens: 0.199623s get 1 tokens: 0.199357s get 1 tokens: 0.195676s 下面是一个简单的 Guava 平滑预热限流的 Demo。\nimport com.google.common.util.concurrent.RateLimiter; import java.util.concurrent.TimeUnit; /** * 微信搜 JavaGuide 回复\u0026#34;面试突击\u0026#34;即可免费领取个人原创的 Java 面试手册 * * @author Guide哥 * @date 2021/10/08 19:12 **/ public class RateLimiterDemo { public static void main(String[] args) { // 1s 放 5 个令牌到桶里也就是 0.2s 放 1个令牌到桶里 // 预热时间为3s,也就说刚开始的 3s 内发牌速率会逐渐提升到 0.2s 放 1 个令牌到桶里 RateLimiter rateLimiter = RateLimiter.create(5, 3, TimeUnit.SECONDS); for (int i = 0; i \u0026lt; 20; i++) { double sleepingTime = rateLimiter.acquire(1); System.out.printf(\u0026#34;get 1 tokens: %sds%n\u0026#34;, sleepingTime); } } } 输出:\nget 1 tokens: 0.0s get 1 tokens: 0.561919s get 1 tokens: 0.516931s get 1 tokens: 0.463798s get 1 tokens: 0.41286s get 1 tokens: 0.356172s get 1 tokens: 0.300489s get 1 tokens: 0.252545s get 1 tokens: 0.203996s get 1 tokens: 0.198359s 另外,Bucket4j 是一个非常不错的基于令牌/漏桶算法的限流库。\nBucket4j 地址: https://github.com/vladimir-bukhtoyarov/bucket4j\n相对于,Guava 的限流工具类来说,Bucket4j 提供的限流功能更加全面。不仅支持单机限流和分布式限流,还可以集成监控,搭配 Prometheus 和 Grafana 使用。\n不过,毕竟 Guava 也只是一个功能全面的工具类库,其提供的开箱即用的限流功能在很多单机场景下还是比较实用的。\nSpring Cloud Gateway 中自带的单机限流的早期版本就是基于 Bucket4j 实现的。后来,替换成了 Resilience4j。\nResilience4j 是一个轻量级的容错组件,其灵感来自于 Hystrix。自 Netflix 宣布不再积极开发 Hystrix 之后,Spring 官方和 Netflix 都更推荐使用 Resilience4j 来做限流熔断。\nResilience4j 地址: https://github.com/resilience4j/resilience4j\n一般情况下,为了保证系统的高可用,项目的限流和熔断都是要一起做的。\nResilience4j 不仅提供限流,还提供了熔断、负载保护、自动重试等保障系统高可用开箱即用的功能。并且,Resilience4j 的生态也更好,很多网关都使用 Resilience4j 来做限流熔断的。\n因此,在绝大部分场景下 Resilience4j 或许会是更好的选择。如果是一些比较简单的限流场景的话,Guava 或者 Bucket4j 也是不错的选择。\n分布式限流怎么做? # 分布式限流针对的分布式/微服务应用架构应用,在这种架构下,单机限流就不适用了,因为会存在多种服务,并且一种服务也可能会被部署多份。\n分布式限流常见的方案:\n借助中间件限流:可以借助 Sentinel 或者使用 Redis 来自己实现对应的限流逻辑。 网关层限流:比较常用的一种方案,直接在网关层把限流给安排上了。不过,通常网关层限流通常也需要借助到中间件/框架。就比如 Spring Cloud Gateway 的分布式限流实现RedisRateLimiter就是基于 Redis+Lua 来实现的,再比如 Spring Cloud Gateway 还可以整合 Sentinel 来做限流。 如果你要基于 Redis 来手动实现限流逻辑的话,建议配合 Lua 脚本来做。\n为什么建议 Redis+Lua 的方式? 主要有两点原因:\n减少了网络开销:我们可以利用 Lua 脚本来批量执行多条 Redis 命令,这些 Redis 命令会被提交到 Redis 服务器一次性执行完成,大幅减小了网络开销。 原子性:一段 Lua 脚本可以视作一条命令执行,一段 Lua 脚本执行过程中不会有其他脚本或 Redis 命令同时执行,保证了操作不会被其他指令插入或打扰。 我这里就不放具体的限流脚本代码了,网上也有很多现成的优秀的限流脚本供你参考,就比如 Apache 网关项目 ShenYu 的 RateLimiter 限流插件就基于 Redis + Lua 实现了令牌桶算法/并发令牌桶算法、漏桶算法、滑动窗口算法。\nShenYu 地址: https://github.com/apache/incubator-shenyu\n另外,如果不想自己写 Lua 脚本的话,也可以直接利用 Redisson 中的 RRateLimiter 来实现分布式限流,其底层实现就是基于 Lua 代码+令牌桶算法。\nRedisson 是一个开源的 Java 语言 Redis 客户端,提供了很多开箱即用的功能,比如 Java 中常用的数据结构实现、分布式锁、延迟队列等等。并且,Redisson 还支持 Redis 单机、Redis Sentinel、Redis Cluster 等多种部署架构。\nRRateLimiter 的使用方式非常简单。我们首先需要获取一个RRateLimiter对象,直接通过 Redisson 客户端获取即可。然后,设置限流规则就好。\n// 创建一个 Redisson 客户端实例 RedissonClient redissonClient = Redisson.create(); // 获取一个名为 \u0026#34;javaguide.limiter\u0026#34; 的限流器对象 RRateLimiter rateLimiter = redissonClient.getRateLimiter(\u0026#34;javaguide.limiter\u0026#34;); // 尝试设置限流器的速率为每小时 100 次 // RateType 有两种,OVERALL是全局限流,ER_CLIENT是单Client限流(可以认为就是单机限流) rateLimiter.trySetRate(RateType.OVERALL, 100, 1, RateIntervalUnit.HOURS); 接下来我们调用acquire()方法或tryAcquire()方法即可获取许可。\n// 获取一个许可,如果超过限流器的速率则会等待 // acquire()是同步方法,对应的异步方法:acquireAsync() rateLimiter.acquire(1); // 尝试在 5 秒内获取一个许可,如果成功则返回 true,否则返回 false // tryAcquire()是同步方法,对应的异步方法:tryAcquireAsync() boolean res = rateLimiter.tryAcquire(1, 5, TimeUnit.SECONDS); 总结 # 这篇文章主要介绍了常见的限流算法、限流对象的选择以及单机限流和分布式限流分别应该怎么做。\n参考 # 服务治理之轻量级熔断框架 Resilience4j: https://xie.infoq.cn/article/14786e571c1a4143ad1ef8f19 超详细的 Guava RateLimiter 限流原理解析: https://cloud.tencent.com/developer/article/1408819 实战 Spring Cloud Gateway 之限流篇 👍: https://www.aneasystone.com/archives/2020/08/spring-cloud-gateway-current-limiting.html 详解 Redisson 分布式限流的实现原理: https://juejin.cn/post/7199882882138898489 一文详解 Java 限流接口实现 - 阿里云开发者: https://mp.weixin.qq.com/s/A5VYjstIDeVvizNK2HkrTQ 分布式限流方案的探索与实践 - 腾讯云开发者: https://mp.weixin.qq.com/s/MJbEQROGlThrHSwCjYB_4Q "},{"id":623,"href":"/zh/docs/technology/Interview/high-performance/load-balancing/","title":"负载均衡原理及算法详解","section":"High Performance","content":" 什么是负载均衡? # 负载均衡 指的是将用户请求分摊到不同的服务器上处理,以提高系统整体的并发处理能力以及可靠性。负载均衡服务可以有由专门的软件或者硬件来完成,一般情况下,硬件的性能更好,软件的价格更便宜(后文会详细介绍到)。\n下图是 《Java 面试指北》 「高并发篇」中的一篇文章的配图,从图中可以看出,系统的商品服务部署了多份在不同的服务器上,为了实现访问商品服务请求的分流,我们用到了负载均衡。\n负载均衡是一种比较常用且实施起来较为简单的提高系统并发能力和可靠性的手段,不论是单体架构的系统还是微服务架构的系统几乎都会用到。\n负载均衡分为哪几种? # 负载均衡可以简单分为 服务端负载均衡 和 客户端负载均衡 这两种。\n服务端负载均衡涉及到的知识点更多,工作中遇到的也比较多,因此,我会花更多时间来介绍。\n服务端负载均衡 # 服务端负载均衡 主要应用在 系统外部请求 和 网关层 之间,可以使用 软件 或者 硬件 实现。\n下图是我画的一个简单的基于 Nginx 的服务端负载均衡示意图:\n硬件负载均衡 通过专门的硬件设备(比如 F5、A10、Array )实现负载均衡功能。\n硬件负载均衡的优势是性能很强且稳定,缺点就是实在是太贵了。像基础款的 F5 最低也要 20 多万,绝大部分公司是根本负担不起的,业务量不大的话,真没必要非要去弄个硬件来做负载均衡,用软件负载均衡就足够了!\n在我们日常开发中,一般很难接触到硬件负载均衡,接触的比较多的还是 软件负载均衡 。软件负载均衡通过软件(比如 LVS、Nginx、HAproxy )实现负载均衡功能,性能虽然差一些,但价格便宜啊!像基础款的 Linux 服务器也就几千,性能好一点的 2~3 万的就很不错了。\n根据 OSI 模型,服务端负载均衡还可以分为:\n二层负载均衡 三层负载均衡 四层负载均衡 七层负载均衡 最常见的是四层和七层负载均衡,因此,本文也是重点介绍这两种负载均衡。\nNginx 官网对四层负载和七层负载均衡均衡做了详细介绍,感兴趣的可以看看。\nWhat Is Layer 4 Load Balancing? What Is Layer 7 Load Balancing? 四层负载均衡 工作在 OSI 模型第四层,也就是传输层,这一层的主要协议是 TCP/UDP,负载均衡器在这一层能够看到数据包里的源端口地址以及目的端口地址,会基于这些信息通过一定的负载均衡算法将数据包转发到后端真实服务器。也就是说,四层负载均衡的核心就是 IP+端口层面的负载均衡,不涉及具体的报文内容。 七层负载均衡 工作在 OSI 模型第七层,也就是应用层,这一层的主要协议是 HTTP 。这一层的负载均衡比四层负载均衡路由网络请求的方式更加复杂,它会读取报文的数据部分(比如说我们的 HTTP 部分的报文),然后根据读取到的数据内容(如 URL、Cookie)做出负载均衡决策。也就是说,七层负载均衡器的核心是报文内容(如 URL、Cookie)层面的负载均衡,执行第七层负载均衡的设备通常被称为 反向代理服务器 。 七层负载均衡比四层负载均衡会消耗更多的性能,不过,也相对更加灵活,能够更加智能地路由网络请求,比如说你可以根据请求的内容进行优化如缓存、压缩、加密。\n简单来说,四层负载均衡性能很强,七层负载均衡功能更强! 不过,对于绝大部分业务场景来说,四层负载均衡和七层负载均衡的性能差异基本可以忽略不计的。\n下面这段话摘自 Nginx 官网的 What Is Layer 4 Load Balancing? 这篇文章。\nLayer 4 load balancing was a popular architectural approach to traffic handling when commodity hardware was not as powerful as it is now, and the interaction between clients and application servers was much less complex. It requires less computation than more sophisticated load balancing methods (such as Layer 7), but CPU and memory are now sufficiently fast and cheap that the performance advantage for Layer 4 load balancing has become negligible or irrelevant in most situations.\n第 4 层负载平衡是一种流行的流量处理体系结构方法,当时商用硬件没有现在这么强大,客户端和应用程序服务器之间的交互也不那么复杂。它比更复杂的负载平衡方法(如第 7 层)需要更少的计算量,但是 CPU 和内存现在足够快和便宜,在大多数情况下,第 4 层负载平衡的性能优势已经变得微不足道或无关紧要。\n在工作中,我们通常会使用 Nginx 来做七层负载均衡,LVS(Linux Virtual Server 虚拟服务器, Linux 内核的 4 层负载均衡)来做四层负载均衡。\n关于 Nginx 的常见知识点总结, 《Java 面试指北》 中「技术面试题篇」中已经有对应的内容了,感兴趣的小伙伴可以去看看。\n不过,LVS 这个绝大部分公司真用不上,像阿里、百度、腾讯、eBay 等大厂才会使用到,用的最多的还是 Nginx。\n客户端负载均衡 # 客户端负载均衡 主要应用于系统内部的不同的服务之间,可以使用现成的负载均衡组件来实现。\n在客户端负载均衡中,客户端会自己维护一份服务器的地址列表,发送请求之前,客户端会根据对应的负载均衡算法来选择具体某一台服务器处理请求。\n客户端负载均衡器和服务运行在同一个进程或者说 Java 程序里,不存在额外的网络开销。不过,客户端负载均衡的实现会受到编程语言的限制,比如说 Spring Cloud Load Balancer 就只能用于 Java 语言。\nJava 领域主流的微服务框架 Dubbo、Spring Cloud 等都内置了开箱即用的客户端负载均衡实现。Dubbo 属于是默认自带了负载均衡功能,Spring Cloud 是通过组件的形式实现的负载均衡,属于可选项,比较常用的是 Spring Cloud Load Balancer(官方,推荐) 和 Ribbon(Netflix,已被弃用)。\n下图是我画的一个简单的基于 Spring Cloud Load Balancer(Ribbon 也类似) 的客户端负载均衡示意图:\n负载均衡常见的算法有哪些? # 随机法 # 随机法 是最简单粗暴的负载均衡算法。\n如果没有配置权重的话,所有的服务器被访问到的概率都是相同的。如果配置权重的话,权重越高的服务器被访问的概率就越大。\n未加权重的随机算法适合于服务器性能相近的集群,其中每个服务器承载相同的负载。加权随机算法适合于服务器性能不等的集群,权重的存在可以使请求分配更加合理化。\n不过,随机算法有一个比较明显的缺陷:部分机器在一段时间之内无法被随机到,毕竟是概率算法,就算是大家权重一样, 也可能会出现这种情况。\n于是,轮询法 来了!\n轮询法 # 轮询法是挨个轮询服务器处理,也可以设置权重。\n如果没有配置权重的话,每个请求按时间顺序逐一分配到不同的服务器处理。如果配置权重的话,权重越高的服务器被访问的次数就越多。\n未加权重的轮询算法适合于服务器性能相近的集群,其中每个服务器承载相同的负载。加权轮询算法适合于服务器性能不等的集群,权重的存在可以使请求分配更加合理化。\n在加权轮询的基础上,还有进一步改进得到的负载均衡算法,比如平滑的加权轮训算法。\n平滑的加权轮训算法最早是在 Nginx 中被实现,可以参考这个 commit: https://github.com/phusion/nginx/commit/27e94984486058d73157038f7950a0a36ecc6e35。如果你认真学习过 Dubbo 负载均衡策略的话,就会发现 Dubbo 的加权轮询就借鉴了该算法实现并进一步做了优化。\n两次随机法 # 两次随机法在随机法的基础上多增加了一次随机,多选出一个服务器。随后再根据两台服务器的负载等情况,从其中选择出一个最合适的服务器。\n两次随机法的好处是可以动态地调节后端节点的负载,使其更加均衡。如果只使用一次随机法,可能会导致某些服务器过载,而某些服务器空闲。\n哈希法 # 将请求的参数信息通过哈希函数转换成一个哈希值,然后根据哈希值来决定请求被哪一台服务器处理。\n在服务器数量不变的情况下,相同参数的请求总是发到同一台服务器处理,比如同个 IP 的请求、同一个用户的请求。\n一致性 Hash 法 # 和哈希法类似,一致性 Hash 法也可以让相同参数的请求总是发到同一台服务器处理。不过,它解决了哈希法存在的一些问题。\n常规哈希法在服务器数量变化时,哈希值会重新落在不同的服务器上,这明显违背了使用哈希法的本意。而一致性哈希法的核心思想是将数据和节点都映射到一个哈希环上,然后根据哈希值的顺序来确定数据属于哪个节点。当服务器增加或删除时,只影响该服务器的哈希,而不会导致整个服务集群的哈希键值重新分布。\n最小连接法 # 当有新的请求出现时,遍历服务器节点列表并选取其中连接数最小的一台服务器来响应当前请求。相同连接的情况下,可以进行加权随机。\n最少连接数基于一个服务器连接数越多,负载就越高这一理想假设。然而, 实际情况是连接数并不能代表服务器的实际负载,有些连接耗费系统资源更多,有些连接不怎么耗费系统资源。\n最少活跃法 # 最少活跃法和最小连接法类似,但要更科学一些。最少活跃法以活动连接数为标准,活动连接数可以理解为当前正在处理的请求数。活跃数越低,说明处理能力越强,这样就可以使处理能力强的服务器处理更多请求。相同活跃数的情况下,可以进行加权随机。\n最快响应时间法 # 不同于最小连接法和最少活跃法,最快响应时间法以响应时间为标准来选择具体是哪一台服务器处理。客户端会维持每个服务器的响应时间,每次请求挑选响应时间最短的。相同响应时间的情况下,可以进行加权随机。\n这种算法可以使得请求被更快处理,但可能会造成流量过于集中于高性能服务器的问题。\n七层负载均衡可以怎么做? # 简单介绍两种项目中常用的七层负载均衡解决方案:DNS 解析和反向代理。\n除了我介绍的这两种解决方案之外,HTTP 重定向等手段也可以用来实现负载均衡,不过,相对来说,还是 DNS 解析和反向代理用的更多一些,也更推荐一些。\nDNS 解析 # DNS 解析是比较早期的七层负载均衡实现方式,非常简单。\nDNS 解析实现负载均衡的原理是这样的:在 DNS 服务器中为同一个主机记录配置多个 IP 地址,这些 IP 地址对应不同的服务器。当用户请求域名的时候,DNS 服务器采用轮询算法返回 IP 地址,这样就实现了轮询版负载均衡。\n现在的 DNS 解析几乎都支持 IP 地址的权重配置,这样的话,在服务器性能不等的集群中请求分配会更加合理化。像我自己目前正在用的阿里云 DNS 就支持权重配置。\n反向代理 # 客户端将请求发送到反向代理服务器,由反向代理服务器去选择目标服务器,获取数据后再返回给客户端。对外暴露的是反向代理服务器地址,隐藏了真实服务器 IP 地址。反向代理“代理”的是目标服务器,这一个过程对于客户端而言是透明的。\nNginx 就是最常用的反向代理服务器,它可以将接收到的客户端请求以一定的规则(负载均衡策略)均匀地分配到这个服务器集群中所有的服务器上。\n反向代理负载均衡同样属于七层负载均衡。\n客户端负载均衡通常是怎么做的? # 我们上面也说了,客户端负载均衡可以使用现成的负载均衡组件来实现。\nNetflix Ribbon 和 Spring Cloud Load Balancer 就是目前 Java 生态最流行的两个负载均衡组件。\nRibbon 是老牌负载均衡组件,由 Netflix 开发,功能比较全面,支持的负载均衡策略也比较多。 Spring Cloud Load Balancer 是 Spring 官方为了取代 Ribbon 而推出的,功能相对更简单一些,支持的负载均衡也少一些。\nRibbon 支持的 7 种负载均衡策略:\nRandomRule:随机策略。 RoundRobinRule(默认):轮询策略 WeightedResponseTimeRule:权重(根据响应时间决定权重)策略 BestAvailableRule:最小连接数策略 RetryRule:重试策略(按照轮询策略来获取服务,如果获取的服务实例为 null 或已经失效,则在指定的时间之内不断地进行重试来获取服务,如果超过指定时间依然没获取到服务实例则返回 null) AvailabilityFilteringRule:可用敏感性策略(先过滤掉非健康的服务实例,然后再选择连接数较小的服务实例) ZoneAvoidanceRule:区域敏感性策略(根据服务所在区域的性能和服务的可用性来选择服务实例) Spring Cloud Load Balancer 支持的 2 种负载均衡策略:\nRandomLoadBalancer:随机策略 RoundRobinLoadBalancer(默认):轮询策略 public class CustomLoadBalancerConfiguration { @Bean ReactorLoadBalancer\u0026lt;ServiceInstance\u0026gt; randomLoadBalancer(Environment environment, LoadBalancerClientFactory loadBalancerClientFactory) { String name = environment.getProperty(LoadBalancerClientFactory.PROPERTY_NAME); return new RandomLoadBalancer(loadBalancerClientFactory .getLazyProvider(name, ServiceInstanceListSupplier.class), name); } } 不过,Spring Cloud Load Balancer 支持的负载均衡策略其实不止这两种,ServiceInstanceListSupplier 的实现类同样可以让其支持类似于 Ribbon 的负载均衡策略。这个应该是后续慢慢完善引入的,不看官方文档还真发现不了,所以说阅读官方文档真的很重要!\n这里举两个官方的例子:\nZonePreferenceServiceInstanceListSupplier:实现基于区域的负载平衡 HintBasedServiceInstanceListSupplier:实现基于 hint 提示的负载均衡 public class CustomLoadBalancerConfiguration { // 使用基于区域的负载平衡方法 @Bean public ServiceInstanceListSupplier discoveryClientServiceInstanceListSupplier( ConfigurableApplicationContext context) { return ServiceInstanceListSupplier.builder() .withDiscoveryClient() .withZonePreference() .withCaching() .build(context); } } 关于 Spring Cloud Load Balancer 更详细更新的介绍,推荐大家看看官方文档: https://docs.spring.io/spring-cloud-commons/docs/current/reference/html/#spring-cloud-loadbalancer ,一切以官方文档为主。\n轮询策略基本可以满足绝大部分项目的需求,我们的实际项目中如果没有特殊需求的话,通常使用的就是默认的轮询策略。并且,Ribbon 和 Spring Cloud Load Balancer 都支持自定义负载均衡策略。\n个人建议如非必需 Ribbon 某个特有的功能或者负载均衡策略的话,就优先选择 Spring 官方提供的 Spring Cloud Load Balancer。\n最后再说说为什么我不太推荐使用 Ribbon 。\nSpring Cloud 2020.0.0 版本移除了 Netflix 除 Eureka 外的所有组件。Spring Cloud Hoxton.M2 是第一个支持 Spring Cloud Load Balancer 来替代 Netfix Ribbon 的版本。\n我们早期学习微服务,肯定接触过 Netflix 公司开源的 Feign、Ribbon、Zuul、Hystrix、Eureka 等知名的微服务系统构建所必须的组件,直到现在依然有非常非常多的公司在使用这些组件。不夸张地说,Netflix 公司引领了 Java 技术栈下的微服务发展。\n那为什么 Spring Cloud 这么急着移除 Netflix 的组件呢? 主要是因为在 2018 年的时候,Netflix 宣布其开源的核心组件 Hystrix、Ribbon、Zuul、Eureka 等进入维护状态,不再进行新特性开发,只修 BUG。于是,Spring 官方不得不考虑移除 Netflix 的组件。\nSpring Cloud Alibaba 是一个不错的选择,尤其是对于国内的公司和个人开发者来说。\n参考 # 干货 | eBay 的 4 层软件负载均衡实现: https://mp.weixin.qq.com/s/bZMxLTECOK3mjdgiLbHj-g HTTP Load Balancing(Nginx 官方文档): https://docs.nginx.com/nginx/admin-guide/load-balancer/http-load-balancer/ 深入浅出负载均衡 - vivo 互联网技术: https://www.cnblogs.com/vivotech/p/14859041.html "},{"id":624,"href":"/zh/docs/technology/Interview/high-availability/high-availability-system-design/","title":"高可用系统设计指南","section":"High Availability","content":" 什么是高可用?可用性的判断标准是啥? # 高可用描述的是一个系统在大部分时间都是可用的,可以为我们提供服务的。高可用代表系统即使在发生硬件故障或者系统升级的时候,服务仍然是可用的。\n一般情况下,我们使用多少个 9 来评判一个系统的可用性,比如 99.9999% 就是代表该系统在所有的运行时间中只有 0.0001% 的时间是不可用的,这样的系统就是非常非常高可用的了!当然,也会有系统如果可用性不太好的话,可能连 9 都上不了。\n除此之外,系统的可用性还可以用某功能的失败次数与总的请求次数之比来衡量,比如对网站请求 1000 次,其中有 10 次请求失败,那么可用性就是 99%。\n哪些情况会导致系统不可用? # 黑客攻击; 硬件故障,比如服务器坏掉。 并发量/用户请求量激增导致整个服务宕掉或者部分服务不可用。 代码中的坏味道导致内存泄漏或者其他问题导致程序挂掉。 网站架构某个重要的角色比如 Nginx 或者数据库突然不可用。 自然灾害或者人为破坏。 …… 有哪些提高系统可用性的方法? # 注重代码质量,测试严格把关 # 我觉得这个是最最最重要的,代码质量有问题比如比较常见的内存泄漏、循环依赖都是对系统可用性极大的损害。大家都喜欢谈限流、降级、熔断,但是我觉得从代码质量这个源头把关是首先要做好的一件很重要的事情。如何提高代码质量?比较实际可用的就是 CodeReview,不要在乎每天多花的那 1 个小时左右的时间,作用可大着呢!\n另外,安利几个对提高代码质量有实际效果的神器:\nSonarqube; Alibaba 开源的 Java 诊断工具 Arthas; 阿里巴巴 Java 代码规范(Alibaba Java Code Guidelines); IDEA 自带的代码分析等工具。 使用集群,减少单点故障 # 先拿常用的 Redis 举个例子!我们如何保证我们的 Redis 缓存高可用呢?答案就是使用集群,避免单点故障。当我们使用一个 Redis 实例作为缓存的时候,这个 Redis 实例挂了之后,整个缓存服务可能就挂了。使用了集群之后,即使一台 Redis 实例挂了,不到一秒就会有另外一台 Redis 实例顶上。\n限流 # 流量控制(flow control),其原理是监控应用流量的 QPS 或并发线程数等指标,当达到指定的阈值时对流量进行控制,以避免被瞬时的流量高峰冲垮,从而保障应用的高可用性。——来自 alibaba-Sentinel 的 wiki。\n超时和重试机制设置 # 一旦用户请求超过某个时间的得不到响应,就抛出异常。这个是非常重要的,很多线上系统故障都是因为没有进行超时设置或者超时设置的方式不对导致的。我们在读取第三方服务的时候,尤其适合设置超时和重试机制。一般我们使用一些 RPC 框架的时候,这些框架都自带的超时重试的配置。如果不进行超时设置可能会导致请求响应速度慢,甚至导致请求堆积进而让系统无法再处理请求。重试的次数一般设为 3 次,再多次的重试没有好处,反而会加重服务器压力(部分场景使用失败重试机制会不太适合)。\n熔断机制 # 超时和重试机制设置之外,熔断机制也是很重要的。 熔断机制说的是系统自动收集所依赖服务的资源使用情况和性能指标,当所依赖的服务恶化或者调用失败次数达到某个阈值的时候就迅速失败,让当前系统立即切换依赖其他备用服务。 比较常用的流量控制和熔断降级框架是 Netflix 的 Hystrix 和 alibaba 的 Sentinel。\n异步调用 # 异步调用的话我们不需要关心最后的结果,这样我们就可以用户请求完成之后就立即返回结果,具体处理我们可以后续再做,秒杀场景用这个还是蛮多的。但是,使用异步之后我们可能需要 适当修改业务流程进行配合,比如用户在提交订单之后,不能立即返回用户订单提交成功,需要在消息队列的订单消费者进程真正处理完该订单之后,甚至出库后,再通过电子邮件或短信通知用户订单成功。除了可以在程序中实现异步之外,我们常常还使用消息队列,消息队列可以通过异步处理提高系统性能(削峰、减少响应所需时间)并且可以降低系统耦合性。\n使用缓存 # 如果我们的系统属于并发量比较高的话,如果我们单纯使用数据库的话,当大量请求直接落到数据库可能数据库就会直接挂掉。使用缓存缓存热点数据,因为缓存存储在内存中,所以速度相当地快!\n其他 # 核心应用和服务优先使用更好的硬件 监控系统资源使用情况增加报警设置。 注意备份,必要时候回滚。 灰度发布: 将服务器集群分成若干部分,每天只发布一部分机器,观察运行稳定没有故障,第二天继续发布一部分机器,持续几天才把整个集群全部发布完毕,期间如果发现问题,只需要回滚已发布的一部分服务器即可 定期检查/更换硬件: 如果不是购买的云服务的话,定期还是需要对硬件进行一波检查的,对于一些需要更换或者升级的硬件,要及时更换或者升级。 …… "},{"id":625,"href":"/zh/docs/technology/Interview/high-quality-technical-articles/advanced-programmer/seven-tips-for-becoming-an-advanced-programmer/","title":"给想成长为高级别开发同学的七条建议","section":"Advanced Programmer","content":" 推荐语:普通程序员要想成长为高级程序员甚至是专家等更高级别,应该注意在哪些方面注意加强?开发内功修炼号主飞哥在这篇文章中就给出了七条实用的建议。\n内容概览:\n刻意加强需求评审能力 主动思考效率 加强内功能力 思考性能 重视线上 关注全局 归纳总结能力 原文地址: https://mp.weixin.qq.com/s/8lMGzBzXine-NAsqEaIE4g\n建议 1:刻意加强需求评审能力 # 先从需求评审开始说。在互联网公司,需求评审是开发工作的主要入口。\n对于普通程序员来说,一般就是根据产品经理提的需求细节,开始设想这个功能要怎么实现,开发成本大概需要多长时间。把自己当成了需求到代码之间的翻译官。很少去思考需求的合理性,对于自己做的事情有多大价值,不管也不问。\n而对于高级别的程序员来说,并不会一开始就陷入细节,而是会更多地会从产品本身出发,询问产品经理为啥要做这个细节,目的是啥。换个说法,就是会先考虑这个需求是不是合理。\n如果需求高级不合理就进行 PK ,要么对需求进行调整,要么就砍掉。不过要注意的是 PK 和调整需求不仅仅砍需求,还有另外一个方向,那就是对需求进行加强。\n产品同学由于缺乏技术背景,很可能想的并不够充分,这个时候如果你有更好的想法,也完全可以提出来,加到需求里,让这个需求变得更有价值。\n总之,高级程序员并不会一五一十地按产品经理的需求文档来进行后面的开发,而是一切从有利于业务的角度出发思考,对产品经理的需求进行删、改、增。\n这样的工作表面看似和开发无关,但是只有这样才能保证后续所有开发同学都是有价值的,而不是做一堆无用功。无用功做的多了会极大的挫伤开发的成就感。\n所以,普通程序员要想成长为更高级别的开发,一定要加强需求评审能力的培养。\n建议 2:主动思考效率 # 普通的程序员,按部就班的去写代码,有活儿来我就干,没活儿的时候我就呆着。很少去深度思考现有的这些代码为什么要这么写,这么写的好处是啥,有哪些地方存在瓶颈,我是否可以把它优化一些。\n而高级一点程序员,并不会局限于把手头的活儿开发就算完事。他们会主动去琢磨,现在这种开发模式是不是不够的好。那么我是否能做一个什么东西能把这个效率给提升起来。\n举一个小例子,我 6 年前接手一个项目的时候,我发现运营一个月会找我四次,就是找我给她发送一个推送。她说以前的开发都是这么帮他弄的。虽然这个需求处理起来很简单,改两行发布一下就完事。但是烦啊,你想象一下你正专心写代码呢,她又双叒来找你了,思路全被她中断了。而且频繁地操作线上本来就会引入不确定的风险,万一那天手一抽抽搞错了,线上就完蛋了。\n我的做法就是,我专门抽了一周的时间,给她做了一套运营后台。这样以后所有的运营推送她就直接在后台上操作就完事了。我倒出精力去做其它更有价值的事情去了。\n所以,第二个建议就是要主动思考一下现有工作中哪些地方效率有改进的空间,想到了就主动去改进它!\n建议 3:加强内功能力 # 哪些算是内功呢,我想内功修炼的读者们肯定也都很熟悉的了,指的就是大家学校里都学过的操作系统、网络等这些基础。\n普通的程序员会觉得,这些基础知识我都会好么,我大学可是足足学了四年的。工作了以后并不会刻意来回头再来加强自己在这些基础上的深层次的提升。\n高级的程序员,非常清楚自己当年学的那点知识太皮毛了。工作之余也会深入地去研究 Linux、研究网络等方向的底层实现。\n事实上,互联网业界的技术大牛们很大程度是因为对这些基础的理解相当是深厚,具备了深厚的内功以后才促使他们成长为了技术大牛。\n我很难相信一个不理解底层,只会 CURD,只会用别人框架的开发将来能在技术方向成长为大牛。\n所以,还建议多多锻炼底层技术内功能力。如果你不知道怎么练,那就坚持看「开发内功修炼」公众号。\n建议 4:思考性能 # 普通程序员往往就是把需求开发完了就不管了,只要需求实现了,测试通过了就可以交付了。将来流量会有多大,没想过。自己的服务 QPS 能支撑多少,不清楚。\n而高级的程序员往往会关注自己写出来的代码的性能。\n在需求评审的时候,他们一般就会估算大概的请求流量有多大。进而设计阶段就会根据这个量设计符合性能要求的方案。\n在上线之前也会进行性能压测,检验一下在性能上是否符合预期。如果性能存在问题,瓶颈在哪儿,怎么样能进行优化一下。\n所以,第四个建议就是一定要多多主动你所负责业务的性能,并多多进行优化和改进。我想这个建议的重要程度非常之高。但这是需要你具备深厚的内功才可以办的到的,否则如果你连网络是怎么工作的都不清楚,谈何优化!\n建议 5:重视线上 # 普通程序员往往对线上的事情很少去关注,手里记录的服务器就是自己的开发机和发布机,线上机器有几台,流量多大,最近有没有波动这些可能都不清楚。\n而高级的程序员深深的明白,有条件的话,会尽量多多观察自己的线上服务,观察一下代码跑的咋样,有没有啥 error log。请求峰值的时候 CPU、内存的消耗咋样。网络端口消耗的情况咋样,是否需要调节一些参数配置。\n当性能不尽如人意的时候,可能会回头再来思考出性能的改进方案,重新开发和上线。\n你会发现在线上出问题的时候,能紧急扑上前线救火的都是高级一点的程序员。\n所以,飞哥给的第五个建议就是要多多观察线上运行情况。只有多多关注线上,当线上出故障的时候,你才能承担的起快速排出线上问题的重任。\n建议 6:关注全局 # 普通程序员是你分配给我哪个模块,我就干哪个模块,给自己的工作设定了非常小的一个边界,自己所有的眼光都聚集在这个小框框内。\n高级程序员是团队内所有项目模块,哪怕不是他负责的,他也会去熟悉,去了解。具备这种思维的同学无论在技术上,无论是在业务上,成长的也都是最快的。在职级上得到晋升,或者是职位上得到提拔的往往都是这类同学。\n甚至有更高级别的同学,还不止于把目光放在团队内,甚至还会关注公司内其它团队,甚至是业界的业务和技术栈。写到这里我想起了张一鸣说过的,不给自己的工作设边界。\n所以,建议要有大局观,不仅仅是你负责的模块,整个项目其实你都应该去关注。而不是连自己组内同学做的是啥都不知道。\n建议 7:归纳总结能力 # 普通程序员往往是工作的事情做完就拉到,很少回头去对自己的技术,对业务进行归纳和总结。\n而高级的程序员往往都会在一件比较大的事情做完之后总结一下,做个 ppt,写个博客啥的记录下来。这样既对自己的工作是一个归纳,也可以分享给其它同学,促进团队的共同成长。\n"},{"id":626,"href":"/zh/docs/technology/Interview/high-quality-technical-articles/advanced-programmer/thinking-about-technology-and-business-after-five-years-of-work/","title":"工作五年之后,对技术和业务的思考","section":"Advanced Programmer","content":" 推荐语:这是我在两年前看到的一篇对我触动比较深的文章。确实要学会适应变化,并积累能力。积累解决问题的能力,优化思考方式,拓宽自己的认知。\n原文地址: https://mp.weixin.qq.com/s/CTbEdi0F4-qFoJT05kNlXA\n苦海无边,回头无岸。\n01 前言 # 晃晃悠悠的,在互联网行业工作了五年,默然回首,你看哪里像灯火阑珊处?\n初入职场,大部分程序员会觉得苦学技术,以后会顺风顺水升职加薪,这样的想法没有错,但是不算全面,五年后你会不会继续做技术写代码这是核心问题。\n初入职场,会觉得努力加班可以不断提升能力,可以学到技术的公司就算薪水低点也可以接受,但是五年之后会认为加班都是在不断挤压自己的上升空间,薪水低是人生的天花板。\n这里想说的关键问题就是:初入职场的认知和想法大部分不会再适用于五年后的认知。\n工作五年之后面临的最大压力就是选择:职场天花板,技术能力天花板,薪水天花板,三十岁天花板。\n如何面对这些问题,是大部分程序员都在思考和纠结的。做选择的唯一参考点就是:利益最大化,这里可以理解为职场更好的升职加薪,顺风顺水。\n五年,变化最大不是工作经验,能力积累,而是心态,清楚的知道现实和理想之间是存在巨大的差距。\n02 学会适应变化,并积累能力 # 回首自己的职场五年,最认可的一句话就是:学会适应变化,并积累能力。\n变化的就是,五年的时间技术框架更新迭代,开发工具的变迁,公司环境队友的更换,甚至是不同城市的流浪,想着能把肉体和灵魂安放在一处,有句很经典的话就是:唯一不变的就是变化本身。\n要积累的是:解决问题的能力,思考方式,拓宽认知。\n这种很难直白的描述,属于个人认知的范畴,不同的人有不一样的看法,所以只能站在大众化的角度去思考。\n首先聊聊技术,大部分小白级别的,都希望自己的技术能力不断提高,争取做到架构师级别,但是站在当前的互联网环境中,这种想法实现难度还是偏高,这里既不是打击也不是为了抬杠。\n可以观察一下现状,技术团队大的 20-30 人,小的 10-15 人,能有一个架构师去专门管理底层框架都是少有现象。\n这个问题的原因很多,首先架构师的成本过高,环境架构也不是需要经常升级,说的难听点可能框架比项目生命周期更高。\n所以大部分公司的大部分业务,基于现有大部分成熟的开源框架都可以解决,这也就导致架构师这个角色通常由项目主管代替或者级别较高的开发直接负责,这就是现实情况。\n这就导致技术框架的选择思路就是:只选对的。即这方面的人才多,开源解决方案多,以此降低技术方面对公司业务发展的影响。\n那为什么还要不断学习和积累技术能力?如果没有这个能力,程序员岗位可能根本走不了五年之久,需要用技术深度积累不断解决工作中的各种问题,用技术的广度提升自己实现业务需求的认知边界,这是安放肉体的根本保障。\n这就是导致很多五年以后的程序员压力陡然升高的原因,走向管理岗的另一个壁垒就是业务思维和认知。\n03 提高业务能力的积累 # 程序员该不该用心研究业务,这个问题真的没有纠结的必要,只要不是纯技术型的公司,都需要面对业务。\n不管技术、运营、产品、管理层,都是在面向业务工作。\n从自己职场轨迹来看,五年变化最大就是解决业务问题的能力,职场之初面对很多业务场景都不知道如何下手,到几年之后设计业务的解决方案。\n这是大部分程序员在职场前五年跳槽就能涨薪的根本原因,面对业务场景,基于积累的经验和现有的开源工具,能快速给出合理的解决思路和实现过程。\n工作五年可能对技术底层的清晰程度都没有初入职场的小白清楚,但是写的程序却可以避开很多坑坑洼洼,对于业务的审视也是很细节全面。\n解决业务能力的积累,对于技术视野的宽度需求更甚,比如职场初期对于海量数据的处理束手无策,但是在工作几年之后见识数据行业的技术栈,真的就是技术选型的视野问题。\n什么是衡量技术能力的标准?站在一个共识的角度上看:系统的架构与代码设计能适应业务的不断变化和各种需求。\n相对比与技术,业务的变化更加快速频繁,高级工程师或者架构师之所以薪资高,这些角色一方面能适应业务的迭代,并且在工作中具有一定前瞻性,会考虑业务变化的情况下代码复用逻辑,这样的能力是需要一定的技术视野和业务思维的沉淀。\n所以职场中:业务能说的井井有条,代码能写的明明白白,得到机会的可能性更大。\n04 不同的阶段技术和业务的平衡和选择 # 从理性的角度看技术和业务两个方面,能让大部分人职场走的平稳顺利,但是不同的阶段对两者的平衡和选择是不一样的。\n在思考如何选择的时候,可以参考二八原则的逻辑,即在任何一组东西中,最重要的只占其中一小部分,约 20%,其余 80%尽管是多数,却是次要的,因此又称二八定律。\n个人真的非常喜欢这个原则,大部分人都不是天才,所以很难三心二意同时做好几件事情,在同一时间段内应该集中精力做好一件事件。\n但是单纯的二八原则模式可能不适应大部分职场初期的人,因为初期要学习很多内容,如何在职场生存:专业能力,职场关系,为人处世,产品设计等等。\n当然这些东西不是都要用心刻意学习,但是合理安排二二六原则或其他组合是更明智的,首先是专业能力要重点练习,其次可以根据自己的兴趣合理选择一到两个方面去慢慢了解,例如产品,运营,运维,数据等,毕竟三五年以后会不会继续写代码很难说,多给自己留个机会总是有备无患。\n在职场初期,基本都是从技术角度去思考问题,如何快速提升自己的编码能力,在公司能稳定是首要目标,因此大部分时间都是在做基础编码和学习规范,这时可能 90%的心思都是放在基础编码上,另外 10%会学习环境架构。\n最多一到两年,就会开始独立负责模块需求开发,需要自己设计整个代码思路,这里业务就会进入视野,要懂得业务上下游关联关系,学会思考如何设计代码结构,才能在需求变动的情况下代码改动较少,这个时候可能就会放 20%的心思在业务方面,30%学习架构方式。\n三到五年这个时间段,是解决问题能力提升最快的时候,因为这个阶段的程序员基本都是在开发核心业务链路,例如交易、支付、结算、智能商业等模块,需要对业务整体有较清晰的把握能力,不然就是给自己挖坑,这个阶段要对业务流付出大量心血思考。\n越是核心的业务线,越是容易爆发各种问题,如果在日常工作中不花心思处理各种细节问题,半夜异常自动的消息和邮件总是容易让人憔悴。\n所以努力学习技术是提升自己,培养自己的业务认知也同样重要,个人认为这二者的分量平分秋色,只是需要在合适的阶段做出合理的权重划分。\n05 学会在职场做选择和生存 # 基于技术能力和业务思维,学会在职场做选择和生存,这些是职场前五年一路走来的最大体会。\n不管是技术还是业务,这两个概念依旧是个很大的命题,不容易把握,所以学会理清这两个方面能力中的公共模块是关键。\n不管技术还是业务,都不可能从一家公司完全复制到另一家公司,但是可以把一家公司的技术框架,业务解决方案学会,并且带到另一家公司,例如技术领域内的架构、设计、流程、数据管理,业务领域内的思考方式、产品逻辑、分析等,这些是核心能力并且是大部分公司人才招聘的要求,所以这些才是工作中需要重点积累的。\n人的精力是有限的,而且面对三十这个天花板,各种事件也会接连而至,在职场中学会合理安排时间并不断提升核心能力,这样才能保证自己的竞争力。\n职场就像苦海无边,回首望去可能也没有岸边停泊,但是要具有换船的能力或者有个小木筏也就大差不差了。\n"},{"id":627,"href":"/zh/docs/technology/Interview/cs-basics/data-structure/red-black-tree/","title":"红黑树","section":"Data Structure","content":" 红黑树介绍 # 红黑树(Red Black Tree)是一种自平衡二叉查找树。它是在 1972 年由 Rudolf Bayer 发明的,当时被称为平衡二叉 B 树(symmetric binary B-trees)。后来,在 1978 年被 Leo J. Guibas 和 Robert Sedgewick 修改为如今的“红黑树”。\n由于其自平衡的特性,保证了最坏情形下在 O(logn) 时间复杂度内完成查找、增加、删除等操作,性能表现稳定。\n在 JDK 中,TreeMap、TreeSet 以及 JDK1.8 的 HashMap 底层都用到了红黑树。\n为什么需要红黑树? # 红黑树的诞生就是为了解决二叉查找树的缺陷。\n二叉查找树是一种基于比较的数据结构,它的每个节点都有一个键值,而且左子节点的键值小于父节点的键值,右子节点的键值大于父节点的键值。这样的结构可以方便地进行查找、插入和删除操作,因为只需要比较节点的键值就可以确定目标节点的位置。但是,二叉查找树有一个很大的问题,就是它的形状取决于节点插入的顺序。如果节点是按照升序或降序的方式插入的,那么二叉查找树就会退化成一个线性结构,也就是一个链表。这样的情况下,二叉查找树的性能就会大大降低,时间复杂度就会从 O(logn) 变为 O(n)。\n红黑树的诞生就是为了解决二叉查找树的缺陷,因为二叉查找树在某些情况下会退化成一个线性结构。\n红黑树特点 # 每个节点非红即黑。黑色决定平衡,红色不决定平衡。这对应了 2-3 树中一个节点内可以存放 1~2 个节点。 根节点总是黑色的。 每个叶子节点都是黑色的空节点(NIL 节点)。这里指的是红黑树都会有一个空的叶子节点,是红黑树自己的规则。 如果节点是红色的,则它的子节点必须是黑色的(反之不一定)。通常这条规则也叫不会有连续的红色节点。一个节点最多临时会有 3 个子节点,中间是黑色节点,左右是红色节点。 从任意节点到它的叶子节点或空子节点的每条路径,必须包含相同数目的黑色节点(即相同的黑色高度)。每一层都只是有一个节点贡献了树高决定平衡性,也就是对应红黑树中的黑色节点。 正是这些特点才保证了红黑树的平衡,让红黑树的高度不会超过 2log(n+1)。\n红黑树数据结构 # 建立在 BST 二叉搜索树的基础上,AVL、2-3 树、红黑树都是自平衡二叉树(统称 B-树)。但相比于 AVL 树,高度平衡所带来的时间复杂度,红黑树对平衡的控制要宽松一些,红黑树只需要保证黑色节点平衡即可。\n红黑树结构实现 # public class Node { public Class\u0026lt;?\u0026gt; clazz; public Integer value; public Node parent; public Node left; public Node right; // AVL 树所需属性 public int height; // 红黑树所需属性 public Color color = Color.RED; } 1.左倾染色 # 染色时根据当前节点的爷爷节点,找到当前节点的叔叔节点。 再把父节点染黑、叔叔节点染黑,爷爷节点染红。但爷爷节点染红是临时的,当平衡树高操作后会把根节点染黑。 2.右倾染色 # 3.左旋调衡 # 3.1 一次左旋 # 3.2 右旋+左旋 # 4.右旋调衡 # 4.1 一次右旋 # 4.2 左旋+右旋 # 文章推荐 # 《红黑树深入剖析及 Java 实现》 - 美团点评技术团队 漫画:什么是红黑树? - 程序员小灰(也介绍到了二叉查找树,非常推荐) "},{"id":628,"href":"/zh/docs/technology/Interview/high-quality-technical-articles/personal-experience/huawei-od-275-days/","title":"华为 OD 275 天后,我进了腾讯!","section":"Personal Experience","content":" 推荐语:一位朋友的华为 OD 工作经历以及腾讯面试经历分享,内容很不错。\n原文地址: https://www.cnblogs.com/shoufeng/p/14322931.html\n时间线 # 18 年 7 月,毕业于某不知名 985 计科专业; 毕业前,在某马的 JavaEE(后台开发)培训了 6 个月; 第一份工作(18-07 ~ 19-12)接触了大数据,感觉大数据更有前景; 19 年 12 月,入职中国平安产险(去到才发现是做后台开发 😢); 20 年 3 月,从平安辞职,跳去华为 OD 做大数据基础平台; 2021 年 1 月,入职鹅厂 华为 OD 工作经历总结 # 为什么会去华为 OD # 在平安产险(正式员工)只待了 3 个月,就跳去华为 OD,朋友们都是很不理解的 —— 好好的正编不做,去什么外包啊 😂\n但那个时候,我铁了心要去做大数据,不想和没完没了的 CRUD 打交道。刚好面试通过的岗位是华为 Cloud BU 的大数据部门,做的是国内政企中使用率绝对领先的大数据平台…… 平台和工作内容都不错,这么好的机会,说啥也要去啊 💪\n其实有想过在平安内部转岗到大数据的,但是不满足“入职一年以上”这个要求; 「等待就是浪费生命」,在转正流程还没批下来的时候,赶紧溜了 😂\n华为 OD 的工作内容 # 带着无限的期待,火急火燎地去华为报到了。\n和招聘的 HR 说的一样,和华为自有员工一起办公,工作内容和他们完全一样:\n主管根据你的能力水平分配工作,逐渐增加难度,能者多劳; 试用期 6 个月,有导师带你,一般都是高你 2 个 Level 的华为自有员工,基本都是部门大牛。\n所以,不存在外包做的都是基础的、流程性的、没有技术含量的工作 —— 顾虑这个的完全不用担心,你只需要打听清楚要去的部门/小组具体做什么,能接受就再考虑其他的。\n感触很深的一点是:华为是有着近 20 万员工的巨头,内部有很多流程和制度。好处是:能接触到大公司的产品从开发、测试,到发布、运维等一系列的流程,比如提交代码的时候,会由经验资深、经过内部认证的大牛给你 Review,在拉会检视的时候,可以学习他们考虑问题的角度,还有对整个产品全局的把控。\n但同时,个人觉得这也有不好的地方:流程繁琐会导致工作效率变低,比如改动几行代码,就需要跑完整个 CI(有些耗时比较久),还要提供自验和 VT 的报告。\nOD 与华为自有员工的对比 # 什么是 OD?Outstanding Dispatcher,人员派遣,官方强调说,OD 和常说的“外包”是不一样的。\n说说我了解的 OD:\n参考华为的薪酬框架,OD 人员的薪酬体系有一定的市场竞争力 —— 的确是这样,貌似会稍微倒挂同级别的自有员工; 可以参与华为主力产品的研发 —— 是的,这也是和某软等“供应商”的兄弟们不一样的地方; 外网权限也可以申请打开(对,就是梯子),部门内部的大多数文档都是可以看的; 工号是单独的 300 号段,其他供应商员工的工号是 8 开头,或着 WX 开头; 工卡带是红色的,和自有员工一样,但是工卡内容不同,OD 的明确标注:办公区通行证,并有德科公司的备注: 还听到一些内部的说法:\n没股票,没 TUP,年终奖少,只有工资可能比我司高一点点而已; 不能借针对 HW 的消费贷,也不能买公司提供的优惠保险…… 那,到底要不要去华为 OD? # 我想,搜到我这篇文字的你,心里其实是有偏向的,只是缺最后一片雪花 ❄️,让自己下决心。\n作为过来人之一,我再提供一些参考吧 😃\n1)除了华为 OD,还有没有更好的选择? 综合考虑加班(996、有些是 9106 甚至更多)、薪资、工作内容,以及这份工作经历对你整个职业的加成等等因素;\n2)有看到一些内部的说法,比如:“奇怪 OD 这么棒,为啥大家不自愿转去 OD 啊?”;再比如:“OD 等同华为?这话都说的出口,既然都等同,为啥还要 OD?就是降成本嘛……”\n3)内心够强大吗?虽然没有人会说你是 OD,但总有一些事情会提醒你:你不是华为员工。比如:\na) 内部发文啥的,还有心声平台的大部分内容,都是无权限看的:\nb) 你的考勤是在租赁人员管理系统里考核,绩效管理也是;\nc) 自有员工的工卡具有消费功能(包括刷夜宵),OD 的工卡不能消费,需要办个消费卡,而且夜宵只能通过手机软件领取(自有员工是用工卡领的);\nd) 你的加班一定要提加班申请电子流换 Double 薪资,不然只能换调休,离职时没时间调休也换不来 Double —— 而华为员工即使自己主动离职,也是有 N+1,以及加班时间换成 Double 薪资的;\n网传的 OD 转华为正编,真的假的? # 这个放到单独的一节,是因为它很重要,有很多纠结的同学在关注这个问题。\n答案是:真的。\n据各类非官方渠道(比如知乎上的一些分享),转华为自有是有条件的( https://www.zhihu.com/question/356592219/answer/1562692667):\n1)入职时间:一年以上 2)绩效要求:连续两次绩效 A 3)认证要求:通过可信专业级认证 4)其他条件:根据业务部门的人员需求及指标要求确定\n说说这些条件吧 😃\n条件 2 连续两次绩效 A\n上面链接里的说法:\n绩效 A 大约占整个部门的前 10%,连续两次 A 的意思就是一年里两次考评都排在部门前 10%,能做到这样的在华为属于火车头,这种难得的绩效会舍得分给一个租赁人员吗?\nOD 同学能拿到 A 吗?不知道,我入职晚,都没有经历一个完整的绩效考评。\n(20210605 更新下)一年多了,还留着的 OD 同学告知我:OD 是单独评绩效的,能拿到 A 的比例,大概是 1/5,对应的年终奖就是 4 个月;绩效是 B,年终奖就是 2 个月。\n在我看来,在试用期答辩时,能拿 A,接下来半年的绩效大概率也是拿 A 的。\n但总的来说,这种事既看实力,又看劳动态度(能不能拼命三郎疯狂加班),还要看运气(主管对你是不是认可)……\n条件 3 通过可信专业级认证\n可信专业级认证考试是啥?华为在推动技术人员的可信认证,算是一项安全合规的工作。 专业级有哪些考试呢?共有四门:\n科目一:上级编程,对比力扣 2 道中等、1 道困难; 科目二:编程知识与应用,考察基础的编程语言知识等; 科目三:安全编程、质量、隐私,还有开发者测试等; 科目四:重构知识,包括设计模式、代码重构等。 上面这些,每一门单季度只能考一次(好像有些一年只能考 3 次),每个都要准备,少则 3 天,多则 1 星期,不准备,基本都过不了。 我在 4 个月左右、还没转正的时候,就考过了专业级的科目二、三、四,只剩科目一大半年都没过(算法确实太菜了 😂 但也有同事没准备,连着好几次都没通过。\n条件 4 部门人员需求指标?\n这个听起来都感觉很玄学。还是那句话,实力和运气到了,应该可以的!成功转正员工图镇楼:\n真的感谢 OD,也感谢华为 # 运气很好,在我换工作还不到 3 个月的时候,华为还收我。\n我遇到了很好的主管,起码在工作时间,感觉跟兄长一样指导、帮助我;\n分配给我的导师,是我工作以来认识到技术实力最厉害的人,定位问题思路清晰,编码实力强悍,全局思考问题、制定方案……\n小组、部门的同学都很 nice,9 个多月里,我基本每天都跟打了鸡血一样,现在想想,也不知道当时为什么会那么积极有干劲 😂\n从个人能力上来讲,我是进不去华为的(心里还是有点数的 😂)。正是有了 OD 这个渠道,才有机会切身感受华为的工作氛围,也学到了很多软技能:\n积极主动,勇于承担尝试,好工作要抢过来自己做; 及时同步工作进展,包括已完成、待完成,存在的风险困难等内容,要让领导知道你的工作情况; 勤于总结提炼输出,形成个人 DNA,利人利己; 有不懂的可以随时找人问,脸皮要厚,虚心求教; 不管多忙,所有的会议,不论大小,都要有会议纪要,邮件发给相关人…… 再次感谢,大家都加油,向很牛掰很牛掰前进 💪\n投简历,找面试官求虐 # 20 年 11 月初的一天,在同事们讨论“某某被其他公司高薪挖去了,钱景无限”的消息。\n我忽然惊觉,自己来到华为半年多,除了熟悉内部的系统和流程,好像没有什么成长和进步?\n不禁反思:只有厉害的人才会被挖,现在这个状态的我,在市场上值几个钱?\n刚好想起了之前的一个同事在离职聚会上分享的经验:\n技术人不能闭门造车,要多交流,多看看外面的动态。\n如果感觉自己太安逸了,那就把简历挂出去,去了解其他公司用的是什么技术,他们更关注哪些痛点?面几次你就有方向了。\n这时候起了个念头:找面试官求虐,以此来鞭策自己,进而更好地制定学习方向。\n于是我重新下载了某聘软件,在首页推荐里投了几家公司。\n开始面试 # 11 月 10 号投的简历,当天就有 2 家预约了 11 号下午的线上面试,其中就有鹅厂 🐧\n好巧不巧,10 号晚上要双十一业务保障,一直到第二天凌晨 2 点半才下班。\n熬夜太伤身,还好能申请调休一天,也省去了找借口请假 🙊\n这段时间集中面了 3 家:\n第 1 个是广州的公司,11 号当晚就完成了 2 轮线上面试,开得有点低,就婉拒了; 第 2 个就是本文的重点——鹅厂; 第 3 个是做跨境电商的公司,一面就跪(恭喜它荣升为“在我有限的工作经历中,面试体验最差的 2 家公司之一”🙂️)\n鹅厂,去还是不去? # 一直有一个大厂梦,奈何菜鸟一枚,之前试过好几次,都跪在技术面了。\n所以想了个曲线救国的方法:先在其他单位积累着,有机会了再争取大厂的机会 💪\n很幸运,也很猝不及防,这次竟然通过了鹅厂的所有面试。\n虽然已到年底,但是要是错过这么难得的机会,下次就不知道什么时候才能再通关了。\n所以,年后拿到年终再跳槽 vs 已到手的鹅厂 Offer,我选择了后者 😄\n我的鹅厂面试 # 如本文标题所说,16 天通关五轮面试,第 17 天,我终于收到了期盼已久的鹅厂 Offer。\n做技术的同学,可能会对鹅厂的面试很好奇,他们都会问哪些问题呢?\n我应聘的是大数据开发(Java)岗位,接下来对我的面试做个梳理,也给想来鹅厂的同学们一个参考 😊\n几乎所有问题都能在网络上找到很详细的答案。 篇幅有限,这里只写题目和一些引申的问题。\n技术一面 # Java 语言相关 # 1、对 Java 的类加载器有没有了解?如何自定义类加载器?\n引申:一个类能被加载多次吗?java/javax 包下的类会被加载多次吗?\n2、Java 中要怎么创建一个对象 🐘?\n3、对多线程有了解吗?在什么场景下需要使用多线程?\n引申:对 线程安全 的认识;对线程池的了解,以及各个线程池的适用场景。\n4、对垃圾回收的了解?\n5、对 JVM 分代的了解?\n6、NIO 的了解?用过 RandomAccessFile 吗?\n引申:对 同步、异步,阻塞、非阻塞 的理解?\n多路复用 IO 的优势?\n7、ArrayList 和 LinkedList 的区别?各自的适用场景?\n8、实现一个 Hash 集合,需要考虑哪些因素?\n引申:JDK 对 HashMap 的设计关键点,比如初识容量,扩所容,链表转红黑树,以及 JDK 7 和 JDK 8 的区别等等。\n通用学科相关 # 1、TCP 的三次握手;\n2、Linux 的常用命令,比如:\nps aux / ps -ef、top C df -h、du -sh *、free -g vmstat、mpstat、iostat、netstat 项目框架相关 # 1、Kafka 和其他 MQ 的区别?它的吞吐量为什么高?\n消费者主动 pull 数据,目的是:控制消费节奏,还可以重复消费;\n吞吐量高:各 partition 顺序写 IO,批量刷新到磁盘(OS 的 pageCache 负责刷盘,Kafka 不用管),比随机 IO 快;读取数据基于 sendfile 的 Zero Copy;批量数据压缩……\n2、Hive 和 SparkSQL 的区别?\n3、Ranger 的权限模型、权限对象,鉴权过程,策略如何刷新……\n问题定位方法 # 1、ssh 连接失败,如何定位?\n是否能 ping 通(DNS 是否正确)、对端端口是否开了防火墙、对端服务是否正常……\n2、运行 Java 程序的服务器,CPU 使用率达到 100%,如何定位?\nps aux | grep xxx 或 jps 命令找到 Java 的进程号 pid,\n然后用 top -Hp pid 命令查看其阻塞的线程序号,将其转换为 16 进制;\n再通过 jstack pid 命令跟踪此 Java 进程的堆栈,搜索上述转换来的 16 进制线程号,即可找到对应的线程名及其堆栈信息……\n3、Java 程序发生了内存溢出,如何定位?\njmap 工具查看堆栈信息,看 Eden、Old 区的变化……\n技术二面 # 二面主要是过往项目相关的问题:\n1、Solr 和 Elasticsearch 的区别 / 优劣?\n2、对 Elasticsearch 的优化,它的索引过程,选主过程等问题……\n3、项目中遇到的难题,如何解决的?\nblabla 有少量的基础问题和一面有重复,还有几个和大数据相关的问题,记不太清了 😅\n技术三面 # 这一面是总监面,更多是个人关于职业发展的一些想法,以及在之前公司的成长和收获、对下一份工作的期望等问题。\n但也问了几个技术问题。印象比较深的是这个:\n1 个 1TB 的大文件,每行都只是 1 个数字,无重复,8GB 内存,要怎么对这个文件进行排序?\n首先想到的是 MapReduce 的思路,拆分小文件,分批排序,最后合并。\n此时连环追问来了:\nQ:如何尽可能多的利用内存呢?\nA:用位图法的思路,对数字按顺序映射。(对映射方法要有基本的了解)\nQ:如果在排好序之后,还需要快速查找呢?\nA:可以做索引,类似 Redis 的跳表,通过多级索引提高查找速度。\nQ:索引查找的还是文件。要如何才能更多地利用内存呢?\nA:那就要添加缓存了,把读取过的数字缓存到内存中。\nQ:缓存应该满足什么特点呢?\nA:应该使用 LRU 型的缓存。\n呼。。。总算是追问完了这道题 😂\n还有 GM 面和 HR 面,问题都和个人经历相关,这里就略去不表。\n文末的絮叨 # 入职鹅厂已经 1 月有余。不同的岗位,不同的工作内容,也是不同的挑战。\n感受比较深的是,作为程序员,还是要自我驱动,努力提升个人技术能力,横向纵向都要扩充,这样才能走得长远。\n"},{"id":629,"href":"/zh/docs/technology/Interview/database/redis/cache-basics/","title":"缓存基础常见面试题总结(付费)","section":"Redis","content":"缓存基础 相关的面试题为我的 知识星球(点击链接即可查看详细介绍以及加入方法)专属内容,已经整理到了 《Java 面试指北》中。\n"},{"id":630,"href":"/zh/docs/technology/Interview/cs-basics/algorithms/linkedlist-algorithm-problems/","title":"几道常见的链表算法题","section":"Algorithms","content":" 1. 两数相加 # 题目描述 # Leetcode:给定两个非空链表来表示两个非负整数。位数按照逆序方式存储,它们的每个节点只存储单个数字。将两数相加返回一个新的链表。\n你可以假设除了数字 0 之外,这两个数字都不会以零开头。\n示例:\n输入:(2 -\u0026gt; 4 -\u0026gt; 3) + (5 -\u0026gt; 6 -\u0026gt; 4) 输出:7 -\u0026gt; 0 -\u0026gt; 8 原因:342 + 465 = 807 问题分析 # Leetcode 官方详细解答地址:\nhttps://leetcode-cn.com/problems/add-two-numbers/solution/\n要对头结点进行操作时,考虑创建哑节点 dummy,使用 dummy-\u0026gt;next 表示真正的头节点。这样可以避免处理头节点为空的边界问题。\n我们使用变量来跟踪进位,并从包含最低有效位的表头开始模拟逐 位相加的过程。\nSolution # 我们首先从最低有效位也就是列表 l1 和 l2 的表头开始相加。注意需要考虑到进位的情况!\n/** * Definition for singly-linked list. * public class ListNode { * int val; * ListNode next; * ListNode(int x) { val = x; } * } */ //https://leetcode-cn.com/problems/add-two-numbers/description/ class Solution { public ListNode addTwoNumbers(ListNode l1, ListNode l2) { ListNode dummyHead = new ListNode(0); ListNode p = l1, q = l2, curr = dummyHead; //carry 表示进位数 int carry = 0; while (p != null || q != null) { int x = (p != null) ? p.val : 0; int y = (q != null) ? q.val : 0; int sum = carry + x + y; //进位数 carry = sum / 10; //新节点的数值为sum % 10 curr.next = new ListNode(sum % 10); curr = curr.next; if (p != null) p = p.next; if (q != null) q = q.next; } if (carry \u0026gt; 0) { curr.next = new ListNode(carry); } return dummyHead.next; } } 2. 翻转链表 # 题目描述 # 剑指 offer:输入一个链表,反转链表后,输出链表的所有元素。\n问题分析 # 这道算法题,说直白点就是:如何让后一个节点指向前一个节点!在下面的代码中定义了一个 next 节点,该节点主要是保存要反转到头的那个节点,防止链表 “断裂”。\nSolution # public class ListNode { int val; ListNode next = null; ListNode(int val) { this.val = val; } } /** * * @author Snailclimb * @date 2018年9月19日 * @Description: TODO */ public class Solution { public ListNode ReverseList(ListNode head) { ListNode next = null; ListNode pre = null; while (head != null) { // 保存要反转到头的那个节点 next = head.next; // 要反转的那个节点指向已经反转的上一个节点(备注:第一次反转的时候会指向null) head.next = pre; // 上一个已经反转到头部的节点 pre = head; // 一直向链表尾走 head = next; } return pre; } } 测试方法:\npublic static void main(String[] args) { ListNode a = new ListNode(1); ListNode b = new ListNode(2); ListNode c = new ListNode(3); ListNode d = new ListNode(4); ListNode e = new ListNode(5); a.next = b; b.next = c; c.next = d; d.next = e; new Solution().ReverseList(a); while (e != null) { System.out.println(e.val); e = e.next; } } 输出:\n5 4 3 2 1 3. 链表中倒数第 k 个节点 # 题目描述 # 剑指 offer: 输入一个链表,输出该链表中倒数第 k 个结点。\n问题分析 # 链表中倒数第 k 个节点也就是正数第(L-K+1)个节点,知道了只一点,这一题基本就没问题!\n首先两个节点/指针,一个节点 node1 先开始跑,指针 node1 跑到 k-1 个节点后,另一个节点 node2 开始跑,当 node1 跑到最后时,node2 所指的节点就是倒数第 k 个节点也就是正数第(L-K+1)个节点。\nSolution # /* public class ListNode { int val; ListNode next = null; ListNode(int val) { this.val = val; } }*/ // 时间复杂度O(n),一次遍历即可 // https://www.nowcoder.com/practice/529d3ae5a407492994ad2a246518148a?tpId=13\u0026amp;tqId=11167\u0026amp;tPage=1\u0026amp;rp=1\u0026amp;ru=/ta/coding-interviews\u0026amp;qru=/ta/coding-interviews/question-ranking public class Solution { public ListNode FindKthToTail(ListNode head, int k) { // 如果链表为空或者k小于等于0 if (head == null || k \u0026lt;= 0) { return null; } // 声明两个指向头结点的节点 ListNode node1 = head, node2 = head; // 记录节点的个数 int count = 0; // 记录k值,后面要使用 int index = k; // p指针先跑,并且记录节点数,当node1节点跑了k-1个节点后,node2节点开始跑, // 当node1节点跑到最后时,node2节点所指的节点就是倒数第k个节点 while (node1 != null) { node1 = node1.next; count++; if (k \u0026lt; 1) { node2 = node2.next; } k--; } // 如果节点个数小于所求的倒数第k个节点,则返回空 if (count \u0026lt; index) return null; return node2; } } 4. 删除链表的倒数第 N 个节点 # Leetcode:给定一个链表,删除链表的倒数第 n 个节点,并且返回链表的头结点。\n示例:\n给定一个链表: 1-\u0026gt;2-\u0026gt;3-\u0026gt;4-\u0026gt;5, 和 n = 2. 当删除了倒数第二个节点后,链表变为 1-\u0026gt;2-\u0026gt;3-\u0026gt;5. 说明:\n给定的 n 保证是有效的。\n进阶:\n你能尝试使用一趟扫描实现吗?\n该题在 leetcode 上有详细解答,具体可参考 Leetcode.\n问题分析 # 我们注意到这个问题可以容易地简化成另一个问题:删除从列表开头数起的第 (L - n + 1)个结点,其中 L 是列表的长度。只要我们找到列表的长度 L,这个问题就很容易解决。\nSolution # 两次遍历法\n首先我们将添加一个 哑结点 作为辅助,该结点位于列表头部。哑结点用来简化某些极端情况,例如列表中只含有一个结点,或需要删除列表的头部。在第一次遍历中,我们找出列表的长度 L。然后设置一个指向哑结点的指针,并移动它遍历列表,直至它到达第 (L - n) 个结点那里。我们把第 (L - n)个结点的 next 指针重新链接至第 (L - n + 2)个结点,完成这个算法。\n/** * Definition for singly-linked list. * public class ListNode { * int val; * ListNode next; * ListNode(int x) { val = x; } * } */ // https://leetcode-cn.com/problems/remove-nth-node-from-end-of-list/description/ public class Solution { public ListNode removeNthFromEnd(ListNode head, int n) { // 哑结点,哑结点用来简化某些极端情况,例如列表中只含有一个结点,或需要删除列表的头部 ListNode dummy = new ListNode(0); // 哑结点指向头结点 dummy.next = head; // 保存链表长度 int length = 0; ListNode len = head; while (len != null) { length++; len = len.next; } length = length - n; ListNode target = dummy; // 找到 L-n 位置的节点 while (length \u0026gt; 0) { target = target.next; length--; } // 把第 (L - n)个结点的 next 指针重新链接至第 (L - n + 2)个结点 target.next = target.next.next; return dummy.next; } } 进阶——一次遍历法:\n链表中倒数第 N 个节点也就是正数第(L - n + 1)个节点。\n其实这种方法就和我们上面第四题找“链表中倒数第 k 个节点”所用的思想是一样的。基本思路就是: 定义两个节点 node1、node2;node1 节点先跑,node1 节点 跑到第 n+1 个节点的时候,node2 节点开始跑.当 node1 节点跑到最后一个节点时,node2 节点所在的位置就是第 (L - n ) 个节点(L 代表总链表长度,也就是倒数第 n + 1 个节点)\n/** * Definition for singly-linked list. * public class ListNode { * int val; * ListNode next; * ListNode(int x) { val = x; } * } */ public class Solution { public ListNode removeNthFromEnd(ListNode head, int n) { ListNode dummy = new ListNode(0); dummy.next = head; // 声明两个指向头结点的节点 ListNode node1 = dummy, node2 = dummy; // node1 节点先跑,node1节点 跑到第 n 个节点的时候,node2 节点开始跑 // 当node1 节点跑到最后一个节点时,node2 节点所在的位置就是第 (L-n ) 个节点,也就是倒数第 n+1(L代表总链表长度) while (node1 != null) { node1 = node1.next; if (n \u0026lt; 1 \u0026amp;\u0026amp; node1 != null) { node2 = node2.next; } n--; } node2.next = node2.next.next; return dummy.next; } } 5. 合并两个排序的链表 # 题目描述 # 剑指 offer:输入两个单调递增的链表,输出两个链表合成后的链表,当然我们需要合成后的链表满足单调不减规则。\n问题分析 # 我们可以这样分析:\n假设我们有两个链表 A,B; A 的头节点 A1 的值与 B 的头结点 B1 的值比较,假设 A1 小,则 A1 为头节点; A2 再和 B1 比较,假设 B1 小,则,A1 指向 B1; A2 再和 B2 比较 就这样循环往复就行了,应该还算好理解。 考虑通过递归的方式实现!\nSolution # 递归版本:\n/* public class ListNode { int val; ListNode next = null; ListNode(int val) { this.val = val; } }*/ //https://www.nowcoder.com/practice/d8b6b4358f774294a89de2a6ac4d9337?tpId=13\u0026amp;tqId=11169\u0026amp;tPage=1\u0026amp;rp=1\u0026amp;ru=/ta/coding-interviews\u0026amp;qru=/ta/coding-interviews/question-ranking public class Solution { public ListNode Merge(ListNode list1, ListNode list2) { if (list1 == null) { return list2; } if (list2 == null) { return list1; } if (list1.val \u0026lt;= list2.val) { list1.next = Merge(list1.next, list2); return list1; } else { list2.next = Merge(list1, list2.next); return list2; } } } "},{"id":631,"href":"/zh/docs/technology/Interview/cs-basics/algorithms/string-algorithm-problems/","title":"几道常见的字符串算法题","section":"Algorithms","content":" 作者:wwwxmu\n原文地址: https://www.weiweiblog.cn/13string/\n1. KMP 算法 # 谈到字符串问题,不得不提的就是 KMP 算法,它是用来解决字符串查找的问题,可以在一个字符串(S)中查找一个子串(W)出现的位置。KMP 算法把字符匹配的时间复杂度缩小到 O(m+n) ,而空间复杂度也只有 O(m)。因为“暴力搜索”的方法会反复回溯主串,导致效率低下,而 KMP 算法可以利用已经部分匹配这个有效信息,保持主串上的指针不回溯,通过修改子串的指针,让模式串尽量地移动到有效的位置。\n具体算法细节请参考:\n从头到尾彻底理解 KMP: 如何更好的理解和掌握 KMP 算法? KMP 算法详细解析 图解 KMP 算法 汪都能听懂的 KMP 字符串匹配算法【双语字幕】 KMP 字符串匹配算法 1 除此之外,再来了解一下 BM 算法!\nBM 算法也是一种精确字符串匹配算法,它采用从右向左比较的方法,同时应用到了两种启发式规则,即坏字符规则 和好后缀规则 ,来决定向右跳跃的距离。基本思路就是从右往左进行字符匹配,遇到不匹配的字符后从坏字符表和好后缀表找一个最大的右移值,将模式串右移继续匹配。 《字符串匹配的 KMP 算法》: http://www.ruanyifeng.com/blog/2013/05/Knuth%E2%80%93Morris%E2%80%93Pratt_algorithm.html\n2. 替换空格 # 剑指 offer:请实现一个函数,将一个字符串中的每个空格替换成“%20”。例如,当字符串为 We Are Happy.则经过替换之后的字符串为 We%20Are%20Happy。\n这里我提供了两种方法:① 常规方法;② 利用 API 解决。\n//https://www.weiweiblog.cn/replacespace/ public class Solution { /** * 第一种方法:常规方法。利用String.charAt(i)以及String.valueOf(char).equals(\u0026#34; \u0026#34; * )遍历字符串并判断元素是否为空格。是则替换为\u0026#34;%20\u0026#34;,否则不替换 */ public static String replaceSpace(StringBuffer str) { int length = str.length(); // System.out.println(\u0026#34;length=\u0026#34; + length); StringBuffer result = new StringBuffer(); for (int i = 0; i \u0026lt; length; i++) { char b = str.charAt(i); if (String.valueOf(b).equals(\u0026#34; \u0026#34;)) { result.append(\u0026#34;%20\u0026#34;); } else { result.append(b); } } return result.toString(); } /** * 第二种方法:利用API替换掉所用空格,一行代码解决问题 */ public static String replaceSpace2(StringBuffer str) { return str.toString().replaceAll(\u0026#34;\\\\s\u0026#34;, \u0026#34;%20\u0026#34;); } } 对于替换固定字符(比如空格)的情况,第二种方法其实可以使用 replace 方法替换,性能更好!\nstr.toString().replace(\u0026#34; \u0026#34;,\u0026#34;%20\u0026#34;); 3. 最长公共前缀 # Leetcode: 编写一个函数来查找字符串数组中的最长公共前缀。如果不存在公共前缀,返回空字符串 \u0026ldquo;\u0026quot;。\n示例 1:\n输入: [\u0026#34;flower\u0026#34;,\u0026#34;flow\u0026#34;,\u0026#34;flight\u0026#34;] 输出: \u0026#34;fl\u0026#34; 示例 2:\n输入: [\u0026#34;dog\u0026#34;,\u0026#34;racecar\u0026#34;,\u0026#34;car\u0026#34;] 输出: \u0026#34;\u0026#34; 解释: 输入不存在公共前缀。 思路很简单!先利用 Arrays.sort(strs)为数组排序,再将数组第一个元素和最后一个元素的字符从前往后对比即可!\npublic class Main { public static String replaceSpace(String[] strs) { // 如果检查值不合法及就返回空串 if (!checkStrs(strs)) { return \u0026#34;\u0026#34;; } // 数组长度 int len = strs.length; // 用于保存结果 StringBuilder res = new StringBuilder(); // 给字符串数组的元素按照升序排序(包含数字的话,数字会排在前面) Arrays.sort(strs); int m = strs[0].length(); int n = strs[len - 1].length(); int num = Math.min(m, n); for (int i = 0; i \u0026lt; num; i++) { if (strs[0].charAt(i) == strs[len - 1].charAt(i)) { res.append(strs[0].charAt(i)); } else break; } return res.toString(); } private static boolean checkStrs(String[] strs) { boolean flag = false; if (strs != null) { // 遍历strs检查元素值 for (int i = 0; i \u0026lt; strs.length; i++) { if (strs[i] != null \u0026amp;\u0026amp; strs[i].length() != 0) { flag = true; } else { flag = false; break; } } } return flag; } // 测试 public static void main(String[] args) { String[] strs = { \u0026#34;customer\u0026#34;, \u0026#34;car\u0026#34;, \u0026#34;cat\u0026#34; }; // String[] strs = { \u0026#34;customer\u0026#34;, \u0026#34;car\u0026#34;, null };//空串 // String[] strs = {};//空串 // String[] strs = null;//空串 System.out.println(Main.replaceSpace(strs));// c } } 4. 回文串 # 4.1. 最长回文串 # LeetCode: 给定一个包含大写字母和小写字母的字符串,找到通过这些字母构造成的最长的回文串。在构造过程中,请注意区分大小写。比如\u0026quot;Aa\u0026quot;不能当做一个回文字符串。注 意:假设字符串的长度不会超过 1010。\n回文串:“回文串”是一个正读和反读都一样的字符串,比如“level”或者“noon”等等就是回文串。——百度百科 地址: https://baike.baidu.com/item/%E5%9B%9E%E6%96%87%E4%B8%B2/1274921?fr=aladdin\n示例 1:\n输入: \u0026#34;abccccdd\u0026#34; 输出: 7 解释: 我们可以构造的最长的回文串是\u0026#34;dccaccd\u0026#34;, 它的长度是 7。 我们上面已经知道了什么是回文串?现在我们考虑一下可以构成回文串的两种情况:\n字符出现次数为双数的组合 字符出现次数为偶数的组合+单个字符中出现次数最多且为奇数次的字符 (参见 issue665 ) 统计字符出现的次数即可,双数才能构成回文。因为允许中间一个数单独出现,比如“abcba”,所以如果最后有字母落单,总长度可以加 1。首先将字符串转变为字符数组。然后遍历该数组,判断对应字符是否在 hashset 中,如果不在就加进去,如果在就让 count++,然后移除该字符!这样就能找到出现次数为双数的字符个数。\n//https://leetcode-cn.com/problems/longest-palindrome/description/ class Solution { public int longestPalindrome(String s) { if (s.length() == 0) return 0; // 用于存放字符 HashSet\u0026lt;Character\u0026gt; hashset = new HashSet\u0026lt;Character\u0026gt;(); char[] chars = s.toCharArray(); int count = 0; for (int i = 0; i \u0026lt; chars.length; i++) { if (!hashset.contains(chars[i])) {// 如果hashset没有该字符就保存进去 hashset.add(chars[i]); } else {// 如果有,就让count++(说明找到了一个成对的字符),然后把该字符移除 hashset.remove(chars[i]); count++; } } return hashset.isEmpty() ? count * 2 : count * 2 + 1; } } 4.2. 验证回文串 # LeetCode: 给定一个字符串,验证它是否是回文串,只考虑字母和数字字符,可以忽略字母的大小写。 说明:本题中,我们将空字符串定义为有效的回文串。\n示例 1:\n输入: \u0026#34;A man, a plan, a canal: Panama\u0026#34; 输出: true 示例 2:\n输入: \u0026#34;race a car\u0026#34; 输出: false //https://leetcode-cn.com/problems/valid-palindrome/description/ class Solution { public boolean isPalindrome(String s) { if (s.length() == 0) return true; int l = 0, r = s.length() - 1; while (l \u0026lt; r) { // 从头和尾开始向中间遍历 if (!Character.isLetterOrDigit(s.charAt(l))) {// 字符不是字母和数字的情况 l++; } else if (!Character.isLetterOrDigit(s.charAt(r))) {// 字符不是字母和数字的情况 r--; } else { // 判断二者是否相等 if (Character.toLowerCase(s.charAt(l)) != Character.toLowerCase(s.charAt(r))) return false; l++; r--; } } return true; } } 4.3. 最长回文子串 # Leetcode: LeetCode: 最长回文子串 给定一个字符串 s,找到 s 中最长的回文子串。你可以假设 s 的最大长度为 1000。\n示例 1:\n输入: \u0026#34;babad\u0026#34; 输出: \u0026#34;bab\u0026#34; 注意: \u0026#34;aba\u0026#34;也是一个有效答案。 示例 2:\n输入: \u0026#34;cbbd\u0026#34; 输出: \u0026#34;bb\u0026#34; 以某个元素为中心,分别计算偶数长度的回文最大长度和奇数长度的回文最大长度。\n//https://leetcode-cn.com/problems/longest-palindromic-substring/description/ class Solution { private int index, len; public String longestPalindrome(String s) { if (s.length() \u0026lt; 2) return s; for (int i = 0; i \u0026lt; s.length() - 1; i++) { PalindromeHelper(s, i, i); PalindromeHelper(s, i, i + 1); } return s.substring(index, index + len); } public void PalindromeHelper(String s, int l, int r) { while (l \u0026gt;= 0 \u0026amp;\u0026amp; r \u0026lt; s.length() \u0026amp;\u0026amp; s.charAt(l) == s.charAt(r)) { l--; r++; } if (len \u0026lt; r - l - 1) { index = l + 1; len = r - l - 1; } } } 4.4. 最长回文子序列 # LeetCode: 最长回文子序列 给定一个字符串 s,找到其中最长的回文子序列。可以假设 s 的最大长度为 1000。 最长回文子序列和上一题最长回文子串的区别是,子串是字符串中连续的一个序列,而子序列是字符串中保持相对位置的字符序列,例如,\u0026ldquo;bbbb\u0026quot;可以是字符串\u0026quot;bbbab\u0026quot;的子序列但不是子串。\n给定一个字符串 s,找到其中最长的回文子序列。可以假设 s 的最大长度为 1000。\n示例 1:\n输入: \u0026#34;bbbab\u0026#34; 输出: 4 一个可能的最长回文子序列为 \u0026ldquo;bbbb\u0026rdquo;。\n示例 2:\n输入: \u0026#34;cbbd\u0026#34; 输出: 2 一个可能的最长回文子序列为 \u0026ldquo;bb\u0026rdquo;。\n动态规划: dp[i][j] = dp[i+1][j-1] + 2 if s.charAt(i) == s.charAt(j) otherwise, dp[i][j] = Math.max(dp[i+1][j], dp[i][j-1])\nclass Solution { public int longestPalindromeSubseq(String s) { int len = s.length(); int [][] dp = new int[len][len]; for(int i = len - 1; i\u0026gt;=0; i--){ dp[i][i] = 1; for(int j = i+1; j \u0026lt; len; j++){ if(s.charAt(i) == s.charAt(j)) dp[i][j] = dp[i+1][j-1] + 2; else dp[i][j] = Math.max(dp[i+1][j], dp[i][j-1]); } } return dp[0][len-1]; } } 5. 括号匹配深度 # 爱奇艺 2018 秋招 Java: 一个合法的括号匹配序列有以下定义:\n空串\u0026quot;\u0026ldquo;是一个合法的括号匹配序列 如果\u0026quot;X\u0026quot;和\u0026quot;Y\u0026quot;都是合法的括号匹配序列,\u0026ldquo;XY\u0026quot;也是一个合法的括号匹配序列 如果\u0026quot;X\u0026quot;是一个合法的括号匹配序列,那么\u0026rdquo;(X)\u0026ldquo;也是一个合法的括号匹配序列 每个合法的括号序列都可以由以上规则生成。 例如: \u0026ldquo;\u0026rdquo;,\u0026rdquo;()\u0026rdquo;,\u0026rdquo;()()\u0026rdquo;,\u0026quot;((()))\u0026ldquo;都是合法的括号序列 对于一个合法的括号序列我们又有以下定义它的深度:\n空串\u0026quot;\u0026ldquo;的深度是 0 如果字符串\u0026quot;X\u0026quot;的深度是 x,字符串\u0026quot;Y\u0026quot;的深度是 y,那么字符串\u0026quot;XY\u0026quot;的深度为 max(x,y) 如果\u0026quot;X\u0026quot;的深度是 x,那么字符串\u0026rdquo;(X)\u0026ldquo;的深度是 x+1 例如: \u0026ldquo;()()()\u0026ldquo;的深度是 1,\u0026rdquo;((()))\u0026ldquo;的深度是 3。牛牛现在给你一个合法的括号序列,需要你计算出其深度。\n输入描述: 输入包括一个合法的括号序列s,s长度length(2 ≤ length ≤ 50),序列中只包含\u0026#39;(\u0026#39;和\u0026#39;)\u0026#39;。 输出描述: 输出一个正整数,即这个序列的深度。 示例:\n输入: (()) 输出: 2 代码如下:\nimport java.util.Scanner; /** * https://www.nowcoder.com/test/8246651/summary * * @author Snailclimb * @date 2018年9月6日 * @Description: TODO 求给定合法括号序列的深度 */ public class Main { public static void main(String[] args) { Scanner sc = new Scanner(System.in); String s = sc.nextLine(); int cnt = 0, max = 0, i; for (i = 0; i \u0026lt; s.length(); ++i) { if (s.charAt(i) == \u0026#39;(\u0026#39;) cnt++; else cnt--; max = Math.max(max, cnt); } sc.close(); System.out.println(max); } } 6. 把字符串转换成整数 # 剑指 offer: 将一个字符串转换成一个整数(实现 Integer.valueOf(string)的功能,但是 string 不符合数字要求时返回 0),要求不能使用字符串转换整数的库函数。 数值为 0 或者字符串不是一个合法的数值则返回 0。\n//https://www.weiweiblog.cn/strtoint/ public class Main { public static int StrToInt(String str) { if (str.length() == 0) return 0; char[] chars = str.toCharArray(); // 判断是否存在符号位 int flag = 0; if (chars[0] == \u0026#39;+\u0026#39;) flag = 1; else if (chars[0] == \u0026#39;-\u0026#39;) flag = 2; int start = flag \u0026gt; 0 ? 1 : 0; int res = 0;// 保存结果 for (int i = start; i \u0026lt; chars.length; i++) { if (Character.isDigit(chars[i])) {// 调用Character.isDigit(char)方法判断是否是数字,是返回True,否则False int temp = chars[i] - \u0026#39;0\u0026#39;; res = res * 10 + temp; } else { return 0; } } return flag != 2 ? res : -res; } public static void main(String[] args) { // TODO Auto-generated method stub String s = \u0026#34;-12312312\u0026#34;; System.out.println(\u0026#34;使用库函数转换:\u0026#34; + Integer.valueOf(s)); int res = Main.StrToInt(s); System.out.println(\u0026#34;使用自己写的方法转换:\u0026#34; + res); } } "},{"id":632,"href":"/zh/docs/technology/Interview/cs-basics/network/other-network-questions/","title":"计算机网络常见面试题总结(上)","section":"Network","content":"上篇主要是计算机网络基础和应用层相关的内容。\n计算机网络基础 # 网络分层模型 # OSI 七层模型是什么?每一层的作用是什么? # OSI 七层模型 是国际标准化组织提出的一个网络分层模型,其大体结构以及每一层提供的功能如下图所示:\n每一层都专注做一件事情,并且每一层都需要使用下一层提供的功能比如传输层需要使用网络层提供的路由和寻址功能,这样传输层才知道把数据传输到哪里去。\nOSI 的七层体系结构概念清楚,理论也很完整,但是它比较复杂而且不实用,而且有些功能在多个层中重复出现。\n上面这种图可能比较抽象,再来一个比较生动的图片。下面这个图片是我在国外的一个网站上看到的,非常赞!\nTCP/IP 四层模型是什么?每一层的作用是什么? # TCP/IP 四层模型 是目前被广泛采用的一种模型,我们可以将 TCP / IP 模型看作是 OSI 七层模型的精简版本,由以下 4 层组成:\n应用层 传输层 网络层 网络接口层 需要注意的是,我们并不能将 TCP/IP 四层模型 和 OSI 七层模型完全精确地匹配起来,不过可以简单将两者对应起来,如下图所示:\n关于每一层作用的详细介绍,请看 OSI 和 TCP/IP 网络分层模型详解(基础) 这篇文章。\n为什么网络要分层? # 说到分层,我们先从我们平时使用框架开发一个后台程序来说,我们往往会按照每一层做不同的事情的原则将系统分为三层(复杂的系统分层会更多):\nRepository(数据库操作) Service(业务操作) Controller(前后端数据交互) 复杂的系统需要分层,因为每一层都需要专注于一类事情。网络分层的原因也是一样,每一层只专注于做一类事情。\n好了,再来说回:“为什么网络要分层?”。我觉得主要有 3 方面的原因:\n各层之间相互独立:各层之间相互独立,各层之间不需要关心其他层是如何实现的,只需要知道自己如何调用下层提供好的功能就可以了(可以简单理解为接口调用)。这个和我们对开发时系统进行分层是一个道理。 提高了灵活性和可替换性:每一层都可以使用最适合的技术来实现,你只需要保证你提供的功能以及暴露的接口的规则没有改变就行了。并且,每一层都可以根据需要进行修改或替换,而不会影响到整个网络的结构。这个和我们平时开发系统的时候要求的高内聚、低耦合的原则也是可以对应上的。 大问题化小:分层可以将复杂的网络问题分解为许多比较小的、界线比较清晰简单的小问题来处理和解决。这样使得复杂的计算机网络系统变得易于设计,实现和标准化。 这个和我们平时开发的时候,一般会将系统功能分解,然后将复杂的问题分解为容易理解的更小的问题是相对应的,这些较小的问题具有更好的边界(目标和接口)定义。 我想到了计算机世界非常非常有名的一句话,这里分享一下:\n计算机科学领域的任何问题都可以通过增加一个间接的中间层来解决,计算机整个体系从上到下都是按照严格的层次结构设计的。\n常见网络协议 # 应用层有哪些常见的协议? # HTTP(Hypertext Transfer Protocol,超文本传输协议):基于 TCP 协议,是一种用于传输超文本和多媒体内容的协议,主要是为 Web 浏览器与 Web 服务器之间的通信而设计的。当我们使用浏览器浏览网页的时候,我们网页就是通过 HTTP 请求进行加载的。 SMTP(Simple Mail Transfer Protocol,简单邮件发送协议):基于 TCP 协议,是一种用于发送电子邮件的协议。注意 ⚠️:SMTP 协议只负责邮件的发送,而不是接收。要从邮件服务器接收邮件,需要使用 POP3 或 IMAP 协议。 POP3/IMAP(邮件接收协议):基于 TCP 协议,两者都是负责邮件接收的协议。IMAP 协议是比 POP3 更新的协议,它在功能和性能上都更加强大。IMAP 支持邮件搜索、标记、分类、归档等高级功能,而且可以在多个设备之间同步邮件状态。几乎所有现代电子邮件客户端和服务器都支持 IMAP。 FTP(File Transfer Protocol,文件传输协议) : 基于 TCP 协议,是一种用于在计算机之间传输文件的协议,可以屏蔽操作系统和文件存储方式。注意 ⚠️:FTP 是一种不安全的协议,因为它在传输过程中不会对数据进行加密。建议在传输敏感数据时使用更安全的协议,如 SFTP。 Telnet(远程登陆协议):基于 TCP 协议,用于通过一个终端登陆到其他服务器。Telnet 协议的最大缺点之一是所有数据(包括用户名和密码)均以明文形式发送,这有潜在的安全风险。这就是为什么如今很少使用 Telnet,而是使用一种称为 SSH 的非常安全的网络传输协议的主要原因。 SSH(Secure Shell Protocol,安全的网络传输协议):基于 TCP 协议,通过加密和认证机制实现安全的访问和文件传输等业务 RTP(Real-time Transport Protocol,实时传输协议):通常基于 UDP 协议,但也支持 TCP 协议。它提供了端到端的实时传输数据的功能,但不包含资源预留存、不保证实时传输质量,这些功能由 WebRTC 实现。 DNS(Domain Name System,域名管理系统): 基于 UDP 协议,用于解决域名和 IP 地址的映射问题。 关于这些协议的详细介绍请看 应用层常见协议总结(应用层) 这篇文章。\n传输层有哪些常见的协议? # TCP(Transmission Control Protocol,传输控制协议 ):提供 面向连接 的,可靠 的数据传输服务。 UDP(User Datagram Protocol,用户数据协议):提供 无连接 的,尽最大努力 的数据传输服务(不保证数据传输的可靠性),简单高效。 网络层有哪些常见的协议? # IP(Internet Protocol,网际协议):TCP/IP 协议中最重要的协议之一,属于网络层的协议,主要作用是定义数据包的格式、对数据包进行路由和寻址,以便它们可以跨网络传播并到达正确的目的地。目前 IP 协议主要分为两种,一种是过去的 IPv4,另一种是较新的 IPv6,目前这两种协议都在使用,但后者已经被提议来取代前者。 ARP(Address Resolution Protocol,地址解析协议):ARP 协议解决的是网络层地址和链路层地址之间的转换问题。因为一个 IP 数据报在物理上传输的过程中,总是需要知道下一跳(物理上的下一个目的地)该去往何处,但 IP 地址属于逻辑地址,而 MAC 地址才是物理地址,ARP 协议解决了 IP 地址转 MAC 地址的一些问题。 ICMP(Internet Control Message Protocol,互联网控制报文协议):一种用于传输网络状态和错误消息的协议,常用于网络诊断和故障排除。例如,Ping 工具就使用了 ICMP 协议来测试网络连通性。 NAT(Network Address Translation,网络地址转换协议):NAT 协议的应用场景如同它的名称——网络地址转换,应用于内部网到外部网的地址转换过程中。具体地说,在一个小的子网(局域网,LAN)内,各主机使用的是同一个 LAN 下的 IP 地址,但在该 LAN 以外,在广域网(WAN)中,需要一个统一的 IP 地址来标识该 LAN 在整个 Internet 上的位置。 OSPF(Open Shortest Path First,开放式最短路径优先):一种内部网关协议(Interior Gateway Protocol,IGP),也是广泛使用的一种动态路由协议,基于链路状态算法,考虑了链路的带宽、延迟等因素来选择最佳路径。 RIP(Routing Information Protocol,路由信息协议):一种内部网关协议(Interior Gateway Protocol,IGP),也是一种动态路由协议,基于距离向量算法,使用固定的跳数作为度量标准,选择跳数最少的路径作为最佳路径。 BGP(Border Gateway Protocol,边界网关协议):一种用来在路由选择域之间交换网络层可达性信息(Network Layer Reachability Information,NLRI)的路由选择协议,具有高度的灵活性和可扩展性。 HTTP # 从输入 URL 到页面展示到底发生了什么?(非常重要) # 类似的问题:打开一个网页,整个过程会使用哪些协议?\n先来看一张图(来源于《图解 HTTP》):\n上图有一个错误需要注意:是 OSPF 不是 OPSF。 OSPF(Open Shortest Path First,ospf)开放最短路径优先协议, 是由 Internet 工程任务组开发的路由选择协议\n总体来说分为以下几个步骤:\n在浏览器中输入指定网页的 URL。 浏览器通过 DNS 协议,获取域名对应的 IP 地址。 浏览器根据 IP 地址和端口号,向目标服务器发起一个 TCP 连接请求。 浏览器在 TCP 连接上,向服务器发送一个 HTTP 请求报文,请求获取网页的内容。 服务器收到 HTTP 请求报文后,处理请求,并返回 HTTP 响应报文给浏览器。 浏览器收到 HTTP 响应报文后,解析响应体中的 HTML 代码,渲染网页的结构和样式,同时根据 HTML 中的其他资源的 URL(如图片、CSS、JS 等),再次发起 HTTP 请求,获取这些资源的内容,直到网页完全加载显示。 浏览器在不需要和服务器通信时,可以主动关闭 TCP 连接,或者等待服务器的关闭请求。 详细介绍可以查看这篇文章: 访问网页的全过程(知识串联)(强烈推荐)。\nHTTP 状态码有哪些? # HTTP 状态码用于描述 HTTP 请求的结果,比如 2xx 就代表请求被成功处理。\n关于 HTTP 状态码更详细的总结,可以看我写的这篇文章: HTTP 常见状态码总结(应用层)。\nHTTP Header 中常见的字段有哪些? # 请求头字段名 说明 示例 Accept 能够接受的回应内容类型(Content-Types)。 Accept: text/plain Accept-Charset 能够接受的字符集 Accept-Charset: utf-8 Accept-Datetime 能够接受的按照时间来表示的版本 Accept-Datetime: Thu, 31 May 2007 20:35:00 GMT Accept-Encoding 能够接受的编码方式列表。参考 HTTP 压缩。 Accept-Encoding: gzip, deflate Accept-Language 能够接受的回应内容的自然语言列表。 Accept-Language: en-US Authorization 用于超文本传输协议的认证的认证信息 Authorization: Basic QWxhZGRpbjpvcGVuIHNlc2FtZQ== Cache-Control 用来指定在这次的请求/响应链中的所有缓存机制 都必须 遵守的指令 Cache-Control: no-cache Connection 该浏览器想要优先使用的连接类型 Connection: keep-alive Content-Length 以八位字节数组(8 位的字节)表示的请求体的长度 Content-Length: 348 Content-MD5 请求体的内容的二进制 MD5 散列值,以 Base64 编码的结果 Content-MD5: Q2hlY2sgSW50ZWdyaXR5IQ== Content-Type 请求体的多媒体类型(用于 POST 和 PUT 请求中) Content-Type: application/x-www-form-urlencoded Cookie 之前由服务器通过 Set-Cookie(下文详述)发送的一个超文本传输协议 Cookie Cookie: $Version=1; Skin=new; Date 发送该消息的日期和时间(按照 RFC 7231 中定义的\u0026quot;超文本传输协议日期\u0026quot;格式来发送) Date: Tue, 15 Nov 1994 08:12:31 GMT Expect 表明客户端要求服务器做出特定的行为 Expect: 100-continue From 发起此请求的用户的邮件地址 From: user@example.com Host 服务器的域名(用于虚拟主机),以及服务器所监听的传输控制协议端口号。如果所请求的端口是对应的服务的标准端口,则端口号可被省略。 Host: en.wikipedia.org If-Match 仅当客户端提供的实体与服务器上对应的实体相匹配时,才进行对应的操作。主要作用是用于像 PUT 这样的方法中,仅当从用户上次更新某个资源以来,该资源未被修改的情况下,才更新该资源。 If-Match: \u0026ldquo;737060cd8c284d8af7ad3082f209582d\u0026rdquo; If-Modified-Since 允许服务器在请求的资源自指定的日期以来未被修改的情况下返回 304 Not Modified 状态码 If-Modified-Since: Sat, 29 Oct 1994 19:43:31 GMT If-None-Match 允许服务器在请求的资源的 ETag 未发生变化的情况下返回 304 Not Modified 状态码 If-None-Match: \u0026ldquo;737060cd8c284d8af7ad3082f209582d\u0026rdquo; If-Range 如果该实体未被修改过,则向我发送我所缺少的那一个或多个部分;否则,发送整个新的实体 If-Range: \u0026ldquo;737060cd8c284d8af7ad3082f209582d\u0026rdquo; If-Unmodified-Since 仅当该实体自某个特定时间以来未被修改的情况下,才发送回应。 If-Unmodified-Since: Sat, 29 Oct 1994 19:43:31 GMT Max-Forwards 限制该消息可被代理及网关转发的次数。 Max-Forwards: 10 Origin 发起一个针对跨来源资源共享的请求。 Origin: http://www.example-social-network.com Pragma 与具体的实现相关,这些字段可能在请求/回应链中的任何时候产生多种效果。 Pragma: no-cache Proxy-Authorization 用来向代理进行认证的认证信息。 Proxy-Authorization: Basic QWxhZGRpbjpvcGVuIHNlc2FtZQ== Range 仅请求某个实体的一部分。字节偏移以 0 开始。参见字节服务。 Range: bytes=500-999 Referer 表示浏览器所访问的前一个页面,正是那个页面上的某个链接将浏览器带到了当前所请求的这个页面。 Referer: http://en.wikipedia.org/wiki/Main_Page TE 浏览器预期接受的传输编码方式:可使用回应协议头 Transfer-Encoding 字段中的值; TE: trailers, deflate Upgrade 要求服务器升级到另一个协议。 Upgrade: HTTP/2.0, SHTTP/1.3, IRC/6.9, RTA/x11 User-Agent 浏览器的浏览器身份标识字符串 User-Agent: Mozilla/5.0 (X11; Linux x86_64; rv:12.0) Gecko/20100101 Firefox/21.0 Via 向服务器告知,这个请求是由哪些代理发出的。 Via: 1.0 fred, 1.1 example.com (Apache/1.1) Warning 一个一般性的警告,告知,在实体内容体中可能存在错误。 Warning: 199 Miscellaneous warning HTTP 和 HTTPS 有什么区别?(重要) # 端口号:HTTP 默认是 80,HTTPS 默认是 443。 URL 前缀:HTTP 的 URL 前缀是 http://,HTTPS 的 URL 前缀是 https://。 安全性和资源消耗:HTTP 协议运行在 TCP 之上,所有传输的内容都是明文,客户端和服务器端都无法验证对方的身份。HTTPS 是运行在 SSL/TLS 之上的 HTTP 协议,SSL/TLS 运行在 TCP 之上。所有传输的内容都经过加密,加密采用对称加密,但对称加密的密钥用服务器方的证书进行了非对称加密。所以说,HTTP 安全性没有 HTTPS 高,但是 HTTPS 比 HTTP 耗费更多服务器资源。 SEO(搜索引擎优化):搜索引擎通常会更青睐使用 HTTPS 协议的网站,因为 HTTPS 能够提供更高的安全性和用户隐私保护。使用 HTTPS 协议的网站在搜索结果中可能会被优先显示,从而对 SEO 产生影响。 关于 HTTP 和 HTTPS 更详细的对比总结,可以看我写的这篇文章: HTTP vs HTTPS(应用层) 。\nHTTP/1.0 和 HTTP/1.1 有什么区别? # 连接方式 : HTTP/1.0 为短连接,HTTP/1.1 支持长连接。HTTP 协议的长连接和短连接,实质上是 TCP 协议的长连接和短连接。 状态响应码 : HTTP/1.1 中新加入了大量的状态码,光是错误响应状态码就新增了 24 种。比如说,100 (Continue)——在请求大资源前的预热请求,206 (Partial Content)——范围请求的标识码,409 (Conflict)——请求与当前资源的规定冲突,410 (Gone)——资源已被永久转移,而且没有任何已知的转发地址。 缓存机制 : 在 HTTP/1.0 中主要使用 Header 里的 If-Modified-Since,Expires 来做为缓存判断的标准,HTTP/1.1 则引入了更多的缓存控制策略例如 Entity tag,If-Unmodified-Since, If-Match, If-None-Match 等更多可供选择的缓存头来控制缓存策略。 带宽:HTTP/1.0 中,存在一些浪费带宽的现象,例如客户端只是需要某个对象的一部分,而服务器却将整个对象送过来了,并且不支持断点续传功能,HTTP/1.1 则在请求头引入了 range 头域,它允许只请求资源的某个部分,即返回码是 206(Partial Content),这样就方便了开发者自由的选择以便于充分利用带宽和连接。 Host 头(Host Header)处理 :HTTP/1.1 引入了 Host 头字段,允许在同一 IP 地址上托管多个域名,从而支持虚拟主机的功能。而 HTTP/1.0 没有 Host 头字段,无法实现虚拟主机。 关于 HTTP/1.0 和 HTTP/1.1 更详细的对比总结,可以看我写的这篇文章: HTTP/1.0 vs HTTP/1.1(应用层) 。\nHTTP/1.1 和 HTTP/2.0 有什么区别? # 多路复用(Multiplexing):HTTP/2.0 在同一连接上可以同时传输多个请求和响应(可以看作是 HTTP/1.1 中长链接的升级版本),互不干扰。HTTP/1.1 则使用串行方式,每个请求和响应都需要独立的连接,而浏览器为了控制资源会有 6-8 个 TCP 连接的限制。。这使得 HTTP/2.0 在处理多个请求时更加高效,减少了网络延迟和提高了性能。 二进制帧(Binary Frames):HTTP/2.0 使用二进制帧进行数据传输,而 HTTP/1.1 则使用文本格式的报文。二进制帧更加紧凑和高效,减少了传输的数据量和带宽消耗。 头部压缩(Header Compression):HTTP/1.1 支持Body压缩,Header不支持压缩。HTTP/2.0 支持对Header压缩,使用了专门为Header压缩而设计的 HPACK 算法,减少了网络开销。 服务器推送(Server Push):HTTP/2.0 支持服务器推送,可以在客户端请求一个资源时,将其他相关资源一并推送给客户端,从而减少了客户端的请求次数和延迟。而 HTTP/1.1 需要客户端自己发送请求来获取相关资源。 HTTP/2.0 多路复用效果图(图源: HTTP/2 For Web Developers):\n可以看到,HTTP/2.0 的多路复用使得不同的请求可以共用一个 TCP 连接,避免建立多个连接带来不必要的额外开销,而 HTTP/1.1 中的每个请求都会建立一个单独的连接\nHTTP/2.0 和 HTTP/3.0 有什么区别? # 传输协议:HTTP/2.0 是基于 TCP 协议实现的,HTTP/3.0 新增了 QUIC(Quick UDP Internet Connections) 协议来实现可靠的传输,提供与 TLS/SSL 相当的安全性,具有较低的连接和传输延迟。你可以将 QUIC 看作是 UDP 的升级版本,在其基础上新增了很多功能比如加密、重传等等。HTTP/3.0 之前名为 HTTP-over-QUIC,从这个名字中我们也可以发现,HTTP/3 最大的改造就是使用了 QUIC。 连接建立:HTTP/2.0 需要经过经典的 TCP 三次握手过程(由于安全的 HTTPS 连接建立还需要 TLS 握手,共需要大约 3 个 RTT)。由于 QUIC 协议的特性(TLS 1.3,TLS 1.3 除了支持 1 个 RTT 的握手,还支持 0 个 RTT 的握手)连接建立仅需 0-RTT 或者 1-RTT。这意味着 QUIC 在最佳情况下不需要任何的额外往返时间就可以建立新连接。 头部压缩:HTTP/2.0 使用 HPACK 算法进行头部压缩,而 HTTP/3.0 使用更高效的 QPACK 头压缩算法。 队头阻塞:HTTP/2.0 多请求复用一个 TCP 连接,一旦发生丢包,就会阻塞住所有的 HTTP 请求。由于 QUIC 协议的特性,HTTP/3.0 在一定程度上解决了队头阻塞(Head-of-Line blocking, 简写:HOL blocking)问题,一个连接建立多个不同的数据流,这些数据流之间独立互不影响,某个数据流发生丢包了,其数据流不受影响(本质上是多路复用+轮询)。 连接迁移:HTTP/3.0 支持连接迁移,因为 QUIC 使用 64 位 ID 标识连接,只要 ID 不变就不会中断,网络环境改变时(如从 Wi-Fi 切换到移动数据)也能保持连接。而 TCP 连接是由(源 IP,源端口,目的 IP,目的端口)组成,这个四元组中一旦有一项值发生改变,这个连接也就不能用了。 错误恢复:HTTP/3.0 具有更好的错误恢复机制,当出现丢包、延迟等网络问题时,可以更快地进行恢复和重传。而 HTTP/2.0 则需要依赖于 TCP 的错误恢复机制。 安全性:在 HTTP/2.0 中,TLS 用于加密和认证整个 HTTP 会话,包括所有的 HTTP 头部和数据负载。TLS 的工作是在 TCP 层之上,它加密的是在 TCP 连接中传输的应用层的数据,并不会对 TCP 头部以及 TLS 记录层头部进行加密,所以在传输的过程中 TCP 头部可能会被攻击者篡改来干扰通信。而 HTTP/3.0 的 QUIC 对整个数据包(包括报文头和报文体)进行了加密与认证处理,保障安全性。 HTTP/1.0、HTTP/2.0 和 HTTP/3.0 的协议栈比较:\n下图是一个更详细的 HTTP/2.0 和 HTTP/3.0 对比图:\n从上图可以看出:\nHTTP/2.0:使用 TCP 作为传输协议、使用 HPACK 进行头部压缩、依赖 TLS 进行加密。 HTTP/3.0:使用基于 UDP 的 QUIC 协议、使用更高效的 QPACK 进行头部压缩、在 QUIC 中直接集成了 TLS。QUIC 协议具备连接迁移、拥塞控制与避免、流量控制等特性。 关于 HTTP/1.0 -\u0026gt; HTTP/3.0 更详细的演进介绍,推荐阅读 HTTP1 到 HTTP3 的工程优化。\nHTTP 是不保存状态的协议, 如何保存用户状态? # HTTP 是一种不保存状态,即无状态(stateless)协议。也就是说 HTTP 协议自身不对请求和响应之间的通信状态进行保存。那么我们如何保存用户状态呢?Session 机制的存在就是为了解决这个问题,Session 的主要作用就是通过服务端记录用户的状态。典型的场景是购物车,当你要添加商品到购物车的时候,系统不知道是哪个用户操作的,因为 HTTP 协议是无状态的。服务端给特定的用户创建特定的 Session 之后就可以标识这个用户并且跟踪这个用户了(一般情况下,服务器会在一定时间内保存这个 Session,过了时间限制,就会销毁这个 Session)。\n在服务端保存 Session 的方法很多,最常用的就是内存和数据库(比如是使用内存数据库 redis 保存)。既然 Session 存放在服务器端,那么我们如何实现 Session 跟踪呢?大部分情况下,我们都是通过在 Cookie 中附加一个 Session ID 来方式来跟踪。\nCookie 被禁用怎么办?\n最常用的就是利用 URL 重写把 Session ID 直接附加在 URL 路径的后面。\nURI 和 URL 的区别是什么? # URI(Uniform Resource Identifier) 是统一资源标志符,可以唯一标识一个资源。 URL(Uniform Resource Locator) 是统一资源定位符,可以提供该资源的路径。它是一种具体的 URI,即 URL 可以用来标识一个资源,而且还指明了如何 locate 这个资源。 URI 的作用像身份证号一样,URL 的作用更像家庭住址一样。URL 是一种具体的 URI,它不仅唯一标识资源,而且还提供了定位该资源的信息。\nCookie 和 Session 有什么区别? # 准确点来说,这个问题属于认证授权的范畴,你可以在 认证授权基础概念详解 这篇文章中找到详细的答案。\nGET 和 POST 的区别 # 这个问题在知乎上被讨论的挺火热的,地址: https://www.zhihu.com/question/28586791 。\nGET 和 POST 是 HTTP 协议中两种常用的请求方法,它们在不同的场景和目的下有不同的特点和用法。一般来说,可以从以下几个方面来区分二者(重点搞清两者在语义上的区别即可):\n语义(主要区别):GET 通常用于获取或查询资源,而 POST 通常用于创建或修改资源。 幂等:GET 请求是幂等的,即多次重复执行不会改变资源的状态,而 POST 请求是不幂等的,即每次执行可能会产生不同的结果或影响资源的状态。 格式:GET 请求的参数通常放在 URL 中,形成查询字符串(querystring),而 POST 请求的参数通常放在请求体(body)中,可以有多种编码格式,如 application/x-www-form-urlencoded、multipart/form-data、application/json 等。GET 请求的 URL 长度受到浏览器和服务器的限制,而 POST 请求的 body 大小则没有明确的限制。不过,实际上 GET 请求也可以用 body 传输数据,只是并不推荐这样做,因为这样可能会导致一些兼容性或者语义上的问题。 缓存:由于 GET 请求是幂等的,它可以被浏览器或其他中间节点(如代理、网关)缓存起来,以提高性能和效率。而 POST 请求则不适合被缓存,因为它可能有副作用,每次执行可能需要实时的响应。 安全性:GET 请求和 POST 请求如果使用 HTTP 协议的话,那都不安全,因为 HTTP 协议本身是明文传输的,必须使用 HTTPS 协议来加密传输数据。另外,GET 请求相比 POST 请求更容易泄露敏感数据,因为 GET 请求的参数通常放在 URL 中。 再次提示,重点搞清两者在语义上的区别即可,实际使用过程中,也是通过语义来区分使用 GET 还是 POST。不过,也有一些项目所有的请求都用 POST,这个并不是固定的,项目组达成共识即可。\nWebSocket # 什么是 WebSocket? # WebSocket 是一种基于 TCP 连接的全双工通信协议,即客户端和服务器可以同时发送和接收数据。\nWebSocket 协议在 2008 年诞生,2011 年成为国际标准,几乎所有主流较新版本的浏览器都支持该协议。不过,WebSocket 不只能在基于浏览器的应用程序中使用,很多编程语言、框架和服务器都提供了 WebSocket 支持。\nWebSocket 协议本质上是应用层的协议,用于弥补 HTTP 协议在持久通信能力上的不足。客户端和服务器仅需一次握手,两者之间就直接可以创建持久性的连接,并进行双向数据传输。\n下面是 WebSocket 的常见应用场景:\n视频弹幕 实时消息推送,详见 Web 实时消息推送详解这篇文章 实时游戏对战 多用户协同编辑 社交聊天 …… WebSocket 和 HTTP 有什么区别? # WebSocket 和 HTTP 两者都是基于 TCP 的应用层协议,都可以在网络中传输数据。\n下面是二者的主要区别:\nWebSocket 是一种双向实时通信协议,而 HTTP 是一种单向通信协议。并且,HTTP 协议下的通信只能由客户端发起,服务器无法主动通知客户端。 WebSocket 使用 ws:// 或 wss://(使用 SSL/TLS 加密后的协议,类似于 HTTP 和 HTTPS 的关系) 作为协议前缀,HTTP 使用 http:// 或 https:// 作为协议前缀。 WebSocket 可以支持扩展,用户可以扩展协议,实现部分自定义的子协议,如支持压缩、加密等。 WebSocket 通信数据格式比较轻量,用于协议控制的数据包头部相对较小,网络开销小,而 HTTP 通信每次都要携带完整的头部,网络开销较大(HTTP/2.0 使用二进制帧进行数据传输,还支持头部压缩,减少了网络开销)。 WebSocket 的工作过程是什么样的? # WebSocket 的工作过程可以分为以下几个步骤:\n客户端向服务器发送一个 HTTP 请求,请求头中包含 Upgrade: websocket 和 Sec-WebSocket-Key 等字段,表示要求升级协议为 WebSocket; 服务器收到这个请求后,会进行升级协议的操作,如果支持 WebSocket,它将回复一个 HTTP 101 状态码,响应头中包含 ,Connection: Upgrade和 Sec-WebSocket-Accept: xxx 等字段、表示成功升级到 WebSocket 协议。 客户端和服务器之间建立了一个 WebSocket 连接,可以进行双向的数据传输。数据以帧(frames)的形式进行传送,WebSocket 的每条消息可能会被切分成多个数据帧(最小单位)。发送端会将消息切割成多个帧发送给接收端,接收端接收消息帧,并将关联的帧重新组装成完整的消息。 客户端或服务器可以主动发送一个关闭帧,表示要断开连接。另一方收到后,也会回复一个关闭帧,然后双方关闭 TCP 连接。 另外,建立 WebSocket 连接之后,通过心跳机制来保持 WebSocket 连接的稳定性和活跃性。\nSSE 与 WebSocket 有什么区别? # 摘自 Web 实时消息推送详解。\nSSE 与 WebSocket 作用相似,都可以建立服务端与浏览器之间的通信,实现服务端向客户端推送消息,但还是有些许不同:\nSSE 是基于 HTTP 协议的,它们不需要特殊的协议或服务器实现即可工作;WebSocket 需单独服务器来处理协议。 SSE 单向通信,只能由服务端向客户端单向通信;WebSocket 全双工通信,即通信的双方可以同时发送和接受信息。 SSE 实现简单开发成本低,无需引入其他组件;WebSocket 传输数据需做二次解析,开发门槛高一些。 SSE 默认支持断线重连;WebSocket 则需要自己实现。 SSE 只能传送文本消息,二进制数据需要经过编码后传送;WebSocket 默认支持传送二进制数据。 SSE 与 WebSocket 该如何选择?\nSSE 好像一直不被大家所熟知,一部分原因是出现了 WebSocket,这个提供了更丰富的协议来执行双向、全双工通信。对于游戏、即时通信以及需要双向近乎实时更新的场景,拥有双向通道更具吸引力。\n但是,在某些情况下,不需要从客户端发送数据。而你只需要一些服务器操作的更新。比如:站内信、未读消息数、状态更新、股票行情、监控数量等场景,SSE 不管是从实现的难易和成本上都更加有优势。此外,SSE 具有 WebSocket 在设计上缺乏的多种功能,例如:自动重新连接、事件 ID 和发送任意事件的能力。\nPING # PING 命令的作用是什么? # PING 命令是一种常用的网络诊断工具,经常用来测试网络中主机之间的连通性和网络延迟。\n这里简单举一个例子,我们来 PING 一下百度。\n# 发送4个PING请求数据包到 www.baidu.com ❯ ping -c 4 www.baidu.com PING www.a.shifen.com (14.119.104.189): 56 data bytes 64 bytes from 14.119.104.189: icmp_seq=0 ttl=54 time=27.867 ms 64 bytes from 14.119.104.189: icmp_seq=1 ttl=54 time=28.732 ms 64 bytes from 14.119.104.189: icmp_seq=2 ttl=54 time=27.571 ms 64 bytes from 14.119.104.189: icmp_seq=3 ttl=54 time=27.581 ms --- www.a.shifen.com ping statistics --- 4 packets transmitted, 4 packets received, 0.0% packet loss round-trip min/avg/max/stddev = 27.571/27.938/28.732/0.474 ms PING 命令的输出结果通常包括以下几部分信息:\nICMP Echo Request(请求报文)信息:序列号、TTL(Time to Live)值。 目标主机的域名或 IP 地址:输出结果的第一行。 往返时间(RTT,Round-Trip Time):从发送 ICMP Echo Request(请求报文)到接收到 ICMP Echo Reply(响应报文)的总时间,用来衡量网络连接的延迟。 统计结果(Statistics):包括发送的 ICMP 请求数据包数量、接收到的 ICMP 响应数据包数量、丢包率、往返时间(RTT)的最小、平均、最大和标准偏差值。 如果 PING 对应的目标主机无法得到正确的响应,则表明这两个主机之间的连通性存在问题(有些主机或网络管理员可能禁用了对 ICMP 请求的回复,这样也会导致无法得到正确的响应)。如果往返时间(RTT)过高,则表明网络延迟过高。\nPING 命令的工作原理是什么? # PING 基于网络层的 ICMP(Internet Control Message Protocol,互联网控制报文协议),其主要原理就是通过在网络上发送和接收 ICMP 报文实现的。\nICMP 报文中包含了类型字段,用于标识 ICMP 报文类型。ICMP 报文的类型有很多种,但大致可以分为两类:\n查询报文类型:向目标主机发送请求并期望得到响应。 差错报文类型:向源主机发送错误信息,用于报告网络中的错误情况。 PING 用到的 ICMP Echo Request(类型为 8 ) 和 ICMP Echo Reply(类型为 0) 属于查询报文类型 。\nPING 命令会向目标主机发送 ICMP Echo Request。 如果两个主机的连通性正常,目标主机会返回一个对应的 ICMP Echo Reply。 DNS # DNS 的作用是什么? # DNS(Domain Name System)域名管理系统,是当用户使用浏览器访问网址之后,使用的第一个重要协议。DNS 要解决的是域名和 IP 地址的映射问题。\n在一台电脑上,可能存在浏览器 DNS 缓存,操作系统 DNS 缓存,路由器 DNS 缓存。如果以上缓存都查询不到,那么 DNS 就闪亮登场了。\n目前 DNS 的设计采用的是分布式、层次数据库结构,DNS 是应用层协议,它可以在 UDP 或 TCP 协议之上运行,端口为 53 。\nDNS 服务器有哪些?根服务器有多少个? # DNS 服务器自底向上可以依次分为以下几个层级(所有 DNS 服务器都属于以下四个类别之一):\n根 DNS 服务器。根 DNS 服务器提供 TLD 服务器的 IP 地址。目前世界上只有 13 组根服务器,我国境内目前仍没有根服务器。 顶级域 DNS 服务器(TLD 服务器)。顶级域是指域名的后缀,如com、org、net和edu等。国家也有自己的顶级域,如uk、fr和ca。TLD 服务器提供了权威 DNS 服务器的 IP 地址。 权威 DNS 服务器。在因特网上具有公共可访问主机的每个组织机构必须提供公共可访问的 DNS 记录,这些记录将这些主机的名字映射为 IP 地址。 本地 DNS 服务器。每个 ISP(互联网服务提供商)都有一个自己的本地 DNS 服务器。当主机发出 DNS 请求时,该请求被发往本地 DNS 服务器,它起着代理的作用,并将该请求转发到 DNS 层次结构中。严格说来,不属于 DNS 层级结构 世界上并不是只有 13 台根服务器,这是很多人普遍的误解,网上很多文章也是这么写的。实际上,现在根服务器数量远远超过这个数量。最初确实是为 DNS 根服务器分配了 13 个 IP 地址,每个 IP 地址对应一个不同的根 DNS 服务器。然而,由于互联网的快速发展和增长,这个原始的架构变得不太适应当前的需求。为了提高 DNS 的可靠性、安全性和性能,目前这 13 个 IP 地址中的每一个都有多个服务器,截止到 2023 年底,所有根服务器之和达到了 1700 多台,未来还会继续增加。\nDNS 解析的过程是什么样的? # 整个过程的步骤比较多,我单独写了一篇文章详细介绍: DNS 域名系统详解(应用层) 。\nDNS 劫持了解吗?如何应对? # DNS 劫持是一种网络攻击,它通过修改 DNS 服务器的解析结果,使用户访问的域名指向错误的 IP 地址,从而导致用户无法访问正常的网站,或者被引导到恶意的网站。DNS 劫持有时也被称为 DNS 重定向、DNS 欺骗或 DNS 污染。\n参考 # 《图解 HTTP》 《计算机网络自顶向下方法》(第七版) 详解 HTTP/2.0 及 HTTPS 协议: https://juejin.cn/post/7034668672262242318 HTTP 请求头字段大全| HTTP Request Headers: https://www.flysnow.org/tools/table/http-request-headers/ HTTP1、HTTP2、HTTP3: https://juejin.cn/post/6855470356657307662 如何看待 HTTP/3 ? - 车小胖的回答 - 知乎: https://www.zhihu.com/question/302412059/answer/533223530 "},{"id":633,"href":"/zh/docs/technology/Interview/cs-basics/network/other-network-questions2/","title":"计算机网络常见面试题总结(下)","section":"Network","content":"下篇主要是传输层和网络层相关的内容。\nTCP 与 UDP # TCP 与 UDP 的区别(重要) # 是否面向连接:UDP 在传送数据之前不需要先建立连接。而 TCP 提供面向连接的服务,在传送数据之前必须先建立连接,数据传送结束后要释放连接。 是否是可靠传输:远地主机在收到 UDP 报文后,不需要给出任何确认,并且不保证数据不丢失,不保证是否顺序到达。TCP 提供可靠的传输服务,TCP 在传递数据之前,会有三次握手来建立连接,而且在数据传递时,有确认、窗口、重传、拥塞控制机制。通过 TCP 连接传输的数据,无差错、不丢失、不重复、并且按序到达。 是否有状态:这个和上面的“是否可靠传输”相对应。TCP 传输是有状态的,这个有状态说的是 TCP 会去记录自己发送消息的状态比如消息是否发送了、是否被接收了等等。为此 ,TCP 需要维持复杂的连接状态表。而 UDP 是无状态服务,简单来说就是不管发出去之后的事情了(这很渣男!)。 传输效率:由于使用 TCP 进行传输的时候多了连接、确认、重传等机制,所以 TCP 的传输效率要比 UDP 低很多。 传输形式:TCP 是面向字节流的,UDP 是面向报文的。 首部开销:TCP 首部开销(20 ~ 60 字节)比 UDP 首部开销(8 字节)要大。 是否提供广播或多播服务:TCP 只支持点对点通信,UDP 支持一对一、一对多、多对一、多对多; …… 我把上面总结的内容通过表格形式展示出来了!确定不点个赞嘛?\nTCP UDP 是否面向连接 是 否 是否可靠 是 否 是否有状态 是 否 传输效率 较慢 较快 传输形式 字节流 数据报文段 首部开销 20 ~ 60 bytes 8 bytes 是否提供广播或多播服务 否 是 什么时候选择 TCP,什么时候选 UDP? # UDP 一般用于即时通信,比如:语音、 视频、直播等等。这些场景对传输数据的准确性要求不是特别高,比如你看视频即使少个一两帧,实际给人的感觉区别也不大。 TCP 用于对传输准确性要求特别高的场景,比如文件传输、发送和接收邮件、远程登录等等。 HTTP 基于 TCP 还是 UDP? # HTTP 协议是基于 TCP 协议的,所以发送 HTTP 请求之前首先要建立 TCP 连接也就是要经历 3 次握手。\n🐛 修正(参见 issue#1915):\nHTTP/3.0 之前是基于 TCP 协议的,而 HTTP/3.0 将弃用 TCP,改用 基于 UDP 的 QUIC 协议 。\n此变化解决了 HTTP/2 中存在的队头阻塞问题。队头阻塞是指在 HTTP/2.0 中,多个 HTTP 请求和响应共享一个 TCP 连接,如果其中一个请求或响应因为网络拥塞或丢包而被阻塞,那么后续的请求或响应也无法发送,导致整个连接的效率降低。这是由于 HTTP/2.0 在单个 TCP 连接上使用了多路复用,受到 TCP 拥塞控制的影响,少量的丢包就可能导致整个 TCP 连接上的所有流被阻塞。HTTP/3.0 在一定程度上解决了队头阻塞问题,一个连接建立多个不同的数据流,这些数据流之间独立互不影响,某个数据流发生丢包了,其数据流不受影响(本质上是多路复用+轮询)。\n除了解决队头阻塞问题,HTTP/3.0 还可以减少握手过程的延迟。在 HTTP/2.0 中,如果要建立一个安全的 HTTPS 连接,需要经过 TCP 三次握手和 TLS 握手:\nTCP 三次握手:客户端和服务器交换 SYN 和 ACK 包,建立一个 TCP 连接。这个过程需要 1.5 个 RTT(round-trip time),即一个数据包从发送到接收的时间。 TLS 握手:客户端和服务器交换密钥和证书,建立一个 TLS 加密层。这个过程需要至少 1 个 RTT(TLS 1.3)或者 2 个 RTT(TLS 1.2)。 所以,HTTP/2.0 的连接建立就至少需要 2.5 个 RTT(TLS 1.3)或者 3.5 个 RTT(TLS 1.2)。而在 HTTP/3.0 中,使用的 QUIC 协议(TLS 1.3,TLS 1.3 除了支持 1 个 RTT 的握手,还支持 0 个 RTT 的握手)连接建立仅需 0-RTT 或者 1-RTT。这意味着 QUIC 在最佳情况下不需要任何的额外往返时间就可以建立新连接。\n相关证明可以参考下面这两个链接:\nhttps://zh.wikipedia.org/zh/HTTP/3 https://datatracker.ietf.org/doc/rfc9114/ 使用 TCP 的协议有哪些?使用 UDP 的协议有哪些? # 运行于 TCP 协议之上的协议:\nHTTP 协议(HTTP/3.0 之前):超文本传输协议(HTTP,HyperText Transfer Protocol)是一种用于传输超文本和多媒体内容的协议,主要是为 Web 浏览器与 Web 服务器之间的通信而设计的。当我们使用浏览器浏览网页的时候,我们网页就是通过 HTTP 请求进行加载的。 HTTPS 协议:更安全的超文本传输协议(HTTPS,Hypertext Transfer Protocol Secure),身披 SSL 外衣的 HTTP 协议 FTP 协议:文件传输协议 FTP(File Transfer Protocol)是一种用于在计算机之间传输文件的协议,可以屏蔽操作系统和文件存储方式。注意 ⚠️:FTP 是一种不安全的协议,因为它在传输过程中不会对数据进行加密。建议在传输敏感数据时使用更安全的协议,如 SFTP。 SMTP 协议:简单邮件传输协议(SMTP,Simple Mail Transfer Protocol)的缩写,是一种用于发送电子邮件的协议。注意 ⚠️:SMTP 协议只负责邮件的发送,而不是接收。要从邮件服务器接收邮件,需要使用 POP3 或 IMAP 协议。 POP3/IMAP 协议:两者都是负责邮件接收的协议。IMAP 协议是比 POP3 更新的协议,它在功能和性能上都更加强大。IMAP 支持邮件搜索、标记、分类、归档等高级功能,而且可以在多个设备之间同步邮件状态。几乎所有现代电子邮件客户端和服务器都支持 IMAP。 Telnet 协议:用于通过一个终端登陆到其他服务器。Telnet 协议的最大缺点之一是所有数据(包括用户名和密码)均以明文形式发送,这有潜在的安全风险。这就是为什么如今很少使用 Telnet,而是使用一种称为 SSH 的非常安全的网络传输协议的主要原因。 SSH 协议 : SSH( Secure Shell)是目前较可靠,专为远程登录会话和其他网络服务提供安全性的协议。利用 SSH 协议可以有效防止远程管理过程中的信息泄露问题。SSH 建立在可靠的传输协议 TCP 之上。 …… 运行于 UDP 协议之上的协议:\nHTTP 协议(HTTP/3.0 ): HTTP/3.0 弃用 TCP,改用基于 UDP 的 QUIC 协议 。 DHCP 协议:动态主机配置协议,动态配置 IP 地址 DNS:域名系统(DNS,Domain Name System)将人类可读的域名 (例如,www.baidu.com) 转换为机器可读的 IP 地址 (例如,220.181.38.148)。 我们可以将其理解为专为互联网设计的电话薄。实际上,DNS 同时支持 UDP 和 TCP 协议。 …… TCP 三次握手和四次挥手(非常重要) # 相关面试题:\n为什么要三次握手? 第 2 次握手传回了 ACK,为什么还要传回 SYN? 为什么要四次挥手? 为什么不能把服务器发送的 ACK 和 FIN 合并起来,变成三次挥手? 如果第二次挥手时服务器的 ACK 没有送达客户端,会怎样? 为什么第四次挥手客户端需要等待 2*MSL(报文段最长寿命)时间后才进入 CLOSED 状态? 参考答案: TCP 三次握手和四次挥手(传输层) 。\nTCP 如何保证传输的可靠性?(重要) # TCP 传输可靠性保障(传输层)\nIP # IP 协议的作用是什么? # IP(Internet Protocol,网际协议) 是 TCP/IP 协议中最重要的协议之一,属于网络层的协议,主要作用是定义数据包的格式、对数据包进行路由和寻址,以便它们可以跨网络传播并到达正确的目的地。\n目前 IP 协议主要分为两种,一种是过去的 IPv4,另一种是较新的 IPv6,目前这两种协议都在使用,但后者已经被提议来取代前者。\n什么是 IP 地址?IP 寻址如何工作? # 每个连入互联网的设备或域(如计算机、服务器、路由器等)都被分配一个 IP 地址(Internet Protocol address),作为唯一标识符。每个 IP 地址都是一个字符序列,如 192.168.1.1(IPv4)、2001:0db8:85a3:0000:0000:8a2e:0370:7334(IPv6) 。\n当网络设备发送 IP 数据包时,数据包中包含了 源 IP 地址 和 目的 IP 地址 。源 IP 地址用于标识数据包的发送方设备或域,而目的 IP 地址则用于标识数据包的接收方设备或域。这类似于一封邮件中同时包含了目的地地址和回邮地址。\n网络设备根据目的 IP 地址来判断数据包的目的地,并将数据包转发到正确的目的地网络或子网络,从而实现了设备间的通信。\n这种基于 IP 地址的寻址方式是互联网通信的基础,它允许数据包在不同的网络之间传递,从而实现了全球范围内的网络互联互通。IP 地址的唯一性和全局性保证了网络中的每个设备都可以通过其独特的 IP 地址进行标识和寻址。\n什么是 IP 地址过滤? # IP 地址过滤(IP Address Filtering) 简单来说就是限制或阻止特定 IP 地址或 IP 地址范围的访问。例如,你有一个图片服务突然被某一个 IP 地址攻击,那我们就可以禁止这个 IP 地址访问图片服务。\nIP 地址过滤是一种简单的网络安全措施,实际应用中一般会结合其他网络安全措施,如认证、授权、加密等一起使用。单独使用 IP 地址过滤并不能完全保证网络的安全。\nIPv4 和 IPv6 有什么区别? # IPv4(Internet Protocol version 4) 是目前广泛使用的 IP 地址版本,其格式是四组由点分隔的数字,例如:123.89.46.72。IPv4 使用 32 位地址作为其 Internet 地址,这意味着共有约 42 亿( 2^32)个可用 IP 地址。\n这么少当然不够用啦!为了解决 IP 地址耗尽的问题,最根本的办法是采用具有更大地址空间的新版本 IP 协议 - IPv6(Internet Protocol version 6)。IPv6 地址使用更复杂的格式,该格式使用由单或双冒号分隔的一组数字和字母,例如:2001:0db8:85a3:0000:0000:8a2e:0370:7334 。IPv6 使用 128 位互联网地址,这意味着越有 2^128(3 开头的 39 位数字,恐怖如斯) 个可用 IP 地址。\n除了更大的地址空间之外,IPv6 的优势还包括:\n无状态地址自动配置(Stateless Address Autoconfiguration,简称 SLAAC):主机可以直接通过根据接口标识和网络前缀生成全局唯一的 IPv6 地址,而无需依赖 DHCP(Dynamic Host Configuration Protocol)服务器,简化了网络配置和管理。 NAT(Network Address Translation,网络地址转换) 成为可选项:IPv6 地址资源充足,可以给全球每个设备一个独立的地址。 对标头结构进行了改进:IPv6 标头结构相较于 IPv4 更加简化和高效,减少了处理开销,提高了网络性能。 可选的扩展头:允许在 IPv6 标头中添加不同的扩展头(Extension Headers),用于实现不同类型的功能和选项。 ICMPv6(Internet Control Message Protocol for IPv6):IPv6 中的 ICMPv6 相较于 IPv4 中的 ICMP 有了一些改进,如邻居发现、路径 MTU 发现等功能的改进,从而提升了网络的可靠性和性能。 …… 如何获取客户端真实 IP? # 获取客户端真实 IP 的方法有多种,主要分为应用层方法、传输层方法和网络层方法。\n应用层方法 :\n通过 X-Forwarded-For 请求头获取,简单方便。不过,这种方法无法保证获取到的是真实 IP,这是因为 X-Forwarded-For 字段可能会被伪造。如果经过多个代理服务器,X-Forwarded-For 字段可能会有多个值(附带了整个请求链中的所有代理服务器 IP 地址)。并且,这种方法只适用于 HTTP 和 SMTP 协议。\n传输层方法:\n利用 TCP Options 字段承载真实源 IP 信息。这种方法适用于任何基于 TCP 的协议,不受应用层的限制。不过,这并非是 TCP 标准所支持的,所以需要通信双方都进行改造。也就是:对于发送方来说,需要有能力把真实源 IP 插入到 TCP Options 里面。对于接收方来说,需要有能力把 TCP Options 里面的 IP 地址读取出来。\n也可以通过 Proxy Protocol 协议来传递客户端 IP 和 Port 信息。这种方法可以利用 Nginx 或者其他支持该协议的反向代理服务器来获取真实 IP 或者在业务服务器解析真实 IP。\n网络层方法:\n隧道 +DSR 模式。这种方法可以适用于任何协议,就是实施起来会比较麻烦,也存在一定限制,实际应用中一般不会使用这种方法。\nNAT 的作用是什么? # NAT(Network Address Translation,网络地址转换) 主要用于在不同网络之间转换 IP 地址。它允许将私有 IP 地址(如在局域网中使用的 IP 地址)映射为公有 IP 地址(在互联网中使用的 IP 地址)或者反向映射,从而实现局域网内的多个设备通过单一公有 IP 地址访问互联网。\nNAT 不光可以缓解 IPv4 地址资源短缺的问题,还可以隐藏内部网络的实际拓扑结构,使得外部网络无法直接访问内部网络中的设备,从而提高了内部网络的安全性。\n相关阅读: NAT 协议详解(网络层)。\nARP # 什么是 Mac 地址? # MAC 地址的全称是 媒体访问控制地址(Media Access Control Address)。如果说,互联网中每一个资源都由 IP 地址唯一标识(IP 协议内容),那么一切网络设备都由 MAC 地址唯一标识。\n可以理解为,MAC 地址是一个网络设备真正的身份证号,IP 地址只是一种不重复的定位方式(比如说住在某省某市某街道的张三,这种逻辑定位是 IP 地址,他的身份证号才是他的 MAC 地址),也可以理解为 MAC 地址是身份证号,IP 地址是邮政地址。MAC 地址也有一些别称,如 LAN 地址、物理地址、以太网地址等。\n还有一点要知道的是,不仅仅是网络资源才有 IP 地址,网络设备也有 IP 地址,比如路由器。但从结构上说,路由器等网络设备的作用是组成一个网络,而且通常是内网,所以它们使用的 IP 地址通常是内网 IP,内网的设备在与内网以外的设备进行通信时,需要用到 NAT 协议。\nMAC 地址的长度为 6 字节(48 比特),地址空间大小有 280 万亿之多( $2^{48}$ ),MAC 地址由 IEEE 统一管理与分配,理论上,一个网络设备中的网卡上的 MAC 地址是永久的。不同的网卡生产商从 IEEE 那里购买自己的 MAC 地址空间(MAC 的前 24 比特),也就是前 24 比特由 IEEE 统一管理,保证不会重复。而后 24 比特,由各家生产商自己管理,同样保证生产的两块网卡的 MAC 地址不会重复。\nMAC 地址具有可携带性、永久性,身份证号永久地标识一个人的身份,不论他到哪里都不会改变。而 IP 地址不具有这些性质,当一台设备更换了网络,它的 IP 地址也就可能发生改变,也就是它在互联网中的定位发生了变化。\n最后,记住,MAC 地址有一个特殊地址:FF-FF-FF-FF-FF-FF(全 1 地址),该地址表示广播地址。\nARP 协议解决了什么问题? # ARP 协议,全称 地址解析协议(Address Resolution Protocol),它解决的是网络层地址和链路层地址之间的转换问题。因为一个 IP 数据报在物理上传输的过程中,总是需要知道下一跳(物理上的下一个目的地)该去往何处,但 IP 地址属于逻辑地址,而 MAC 地址才是物理地址,ARP 协议解决了 IP 地址转 MAC 地址的一些问题。\nARP 协议的工作原理? # ARP 协议详解(网络层)\n复习建议 # 非常推荐大家看一下 《图解 HTTP》 这本书,这本书页数不多,但是内容很是充实,不管是用来系统的掌握网络方面的一些知识还是说纯粹为了应付面试都有很大帮助。下面的一些文章只是参考。大二学习这门课程的时候,我们使用的教材是 《计算机网络第七版》(谢希仁编著),不推荐大家看这本教材,书非常厚而且知识偏理论,不确定大家能不能心平气和的读完。\n参考 # 《图解 HTTP》 《计算机网络自顶向下方法》(第七版) 什么是 Internet 协议(IP)?: https://www.cloudflare.com/zh-cn/learning/network-layer/internet-protocol/ 透传真实源 IP 的各种方法 - 极客时间: https://time.geekbang.org/column/article/497864 What Is NAT and What Are the Benefits of NAT Firewalls?: https://community.fs.com/blog/what-is-nat-and-what-are-the-benefits-of-nat-firewalls.html "},{"id":634,"href":"/zh/docs/technology/Interview/cs-basics/algorithms/the-sword-refers-to-offer/","title":"剑指offer部分编程题","section":"Algorithms","content":" 斐波那契数列 # 题目描述:\n大家都知道斐波那契数列,现在要求输入一个整数 n,请你输出斐波那契数列的第 n 项。 n\u0026lt;=39\n问题分析:\n可以肯定的是这一题通过递归的方式是肯定能做出来,但是这样会有一个很大的问题,那就是递归大量的重复计算会导致内存溢出。另外可以使用迭代法,用 fn1 和 fn2 保存计算过程中的结果,并复用起来。下面我会把两个方法示例代码都给出来并给出两个方法的运行时间对比。\n示例代码:\n采用迭代法:\nint Fibonacci(int number) { if (number \u0026lt;= 0) { return 0; } if (number == 1 || number == 2) { return 1; } int first = 1, second = 1, third = 0; for (int i = 3; i \u0026lt;= number; i++) { third = first + second; first = second; second = third; } return third; } 采用递归:\npublic int Fibonacci(int n) { if (n \u0026lt;= 0) { return 0; } if (n == 1||n==2) { return 1; } return Fibonacci(n - 2) + Fibonacci(n - 1); } 跳台阶问题 # 题目描述:\n一只青蛙一次可以跳上 1 级台阶,也可以跳上 2 级。求该青蛙跳上一个 n 级的台阶总共有多少种跳法。\n问题分析:\n正常分析法:\na.如果两种跳法,1 阶或者 2 阶,那么假定第一次跳的是一阶,那么剩下的是 n-1 个台阶,跳法是 f(n-1); b.假定第一次跳的是 2 阶,那么剩下的是 n-2 个台阶,跳法是 f(n-2) c.由 a,b 假设可以得出总跳法为: f(n) = f(n-1) + f(n-2) d.然后通过实际的情况可以得出:只有一阶的时候 f(1) = 1 ,只有两阶的时候可以有 f(2) = 2\n找规律分析法:\nf(1) = 1, f(2) = 2, f(3) = 3, f(4) = 5, 可以总结出 f(n) = f(n-1) + f(n-2)的规律。但是为什么会出现这样的规律呢?假设现在 6 个台阶,我们可以从第 5 跳一步到 6,这样的话有多少种方案跳到 5 就有多少种方案跳到 6,另外我们也可以从 4 跳两步跳到 6,跳到 4 有多少种方案的话,就有多少种方案跳到 6,其他的不能从 3 跳到 6 什么的啦,所以最后就是 f(6) = f(5) + f(4);这样子也很好理解变态跳台阶的问题了。\n所以这道题其实就是斐波那契数列的问题。\n代码只需要在上一题的代码稍做修改即可。和上一题唯一不同的就是这一题的初始元素变为 1 2 3 5 8……而上一题为 1 1 2 3 5 ……。另外这一题也可以用递归做,但是递归效率太低,所以我这里只给出了迭代方式的代码。\n示例代码:\nint jumpFloor(int number) { if (number \u0026lt;= 0) { return 0; } if (number == 1) { return 1; } if (number == 2) { return 2; } int first = 1, second = 2, third = 0; for (int i = 3; i \u0026lt;= number; i++) { third = first + second; first = second; second = third; } return third; } 变态跳台阶问题 # 题目描述:\n一只青蛙一次可以跳上 1 级台阶,也可以跳上 2 级……它也可以跳上 n 级。求该青蛙跳上一个 n 级的台阶总共有多少种跳法。\n问题分析:\n假设 n\u0026gt;=2,第一步有 n 种跳法:跳 1 级、跳 2 级、到跳 n 级 跳 1 级,剩下 n-1 级,则剩下跳法是 f(n-1) 跳 2 级,剩下 n-2 级,则剩下跳法是 f(n-2) …… 跳 n-1 级,剩下 1 级,则剩下跳法是 f(1) 跳 n 级,剩下 0 级,则剩下跳法是 f(0) 所以在 n\u0026gt;=2 的情况下: f(n)=f(n-1)+f(n-2)+\u0026hellip;+f(1) 因为 f(n-1)=f(n-2)+f(n-3)+\u0026hellip;+f(1) 所以 f(n)=2*f(n-1) 又 f(1)=1,所以可得f(n)=2^(number-1)\n示例代码:\nint JumpFloorII(int number) { return 1 \u0026lt;\u0026lt; --number;//2^(number-1)用位移操作进行,更快 } 补充:\njava 中有三种移位运算符:\n“\u0026laquo;” : 左移运算符,等同于乘 2 的 n 次方 “\u0026raquo;”: 右移运算符,等同于除 2 的 n 次方 “\u0026raquo;\u0026gt;” : 无符号右移运算符,不管移动前最高位是 0 还是 1,右移后左侧产生的空位部分都以 0 来填充。与\u0026raquo;类似。 int a = 16; int b = a \u0026lt;\u0026lt; 2;//左移2,等同于16 * 2的2次方,也就是16 * 4 int c = a \u0026gt;\u0026gt; 2;//右移2,等同于16 / 2的2次方,也就是16 / 4 二维数组查找 # 题目描述:\n在一个二维数组中,每一行都按照从左到右递增的顺序排序,每一列都按照从上到下递增的顺序排序。请完成一个函数,输入这样的一个二维数组和一个整数,判断数组中是否含有该整数。\n问题解析:\n这一道题还是比较简单的,我们需要考虑的是如何做,效率最快。这里有一种很好理解的思路:\n矩阵是有序的,从左下角来看,向上数字递减,向右数字递增, 因此从左下角开始查找,当要查找数字比左下角数字大时。右移 要查找数字比左下角数字小时,上移。这样找的速度最快。\n示例代码:\npublic boolean Find(int target, int [][] array) { //基本思路从左下角开始找,这样速度最快 int row = array.length-1;//行 int column = 0;//列 //当行数大于0,当前列数小于总列数时循环条件成立 while((row \u0026gt;= 0)\u0026amp;\u0026amp; (column\u0026lt; array[0].length)){ if(array[row][column] \u0026gt; target){ row--; }else if(array[row][column] \u0026lt; target){ column++; }else{ return true; } } return false; } 替换空格 # 题目描述:\n请实现一个函数,将一个字符串中的空格替换成“%20”。例如,当字符串为 We Are Happy.则经过替换之后的字符串为 We%20Are%20Happy。\n问题分析:\n这道题不难,我们可以通过循环判断字符串的字符是否为空格,是的话就利用 append()方法添加追加“%20”,否则还是追加原字符。\n或者最简单的方法就是利用:replaceAll(String regex,String replacement)方法了,一行代码就可以解决。\n示例代码:\n常规做法:\npublic String replaceSpace(StringBuffer str) { StringBuffer out = new StringBuffer(); for (int i = 0; i \u0026lt; str.toString().length(); i++) { char b = str.charAt(i); if(String.valueOf(b).equals(\u0026#34; \u0026#34;)){ out.append(\u0026#34;%20\u0026#34;); }else{ out.append(b); } } return out.toString(); } 一行代码解决:\npublic String replaceSpace(StringBuffer str) { //return str.toString().replaceAll(\u0026#34; \u0026#34;, \u0026#34;%20\u0026#34;); //public String replaceAll(String regex,String replacement) //用给定的替换替换与给定的regular expression匹配的此字符串的每个子字符串。 //\\ 转义字符. 如果你要使用 \u0026#34;\\\u0026#34; 本身, 则应该使用 \u0026#34;\\\\\u0026#34;. String类型中的空格用“\\s”表示,所以我这里猜测\u0026#34;\\\\s\u0026#34;就是代表空格的意思 return str.toString().replaceAll(\u0026#34;\\\\s\u0026#34;, \u0026#34;%20\u0026#34;); } 数值的整数次方 # 题目描述:\n给定一个 double 类型的浮点数 base 和 int 类型的整数 exponent。求 base 的 exponent 次方。\n问题解析:\n这道题算是比较麻烦和难一点的一个了。我这里采用的是二分幂思想,当然也可以采用快速幂。 更具剑指 offer 书中细节,该题的解题思路如下:1.当底数为 0 且指数\u0026lt;0 时,会出现对 0 求倒数的情况,需进行错误处理,设置一个全局变量; 2.判断底数是否等于 0,由于 base 为 double 型,所以不能直接用==判断 3.优化求幂函数(二分幂)。 当 n 为偶数,an =(an/2)(an/2); 当 n 为奇数,an = a^[(n-1)/2] a^[(n-1)/2] * a。时间复杂度 O(logn)\n时间复杂度:O(logn)\n示例代码:\npublic class Solution { boolean invalidInput=false; public double Power(double base, int exponent) { //如果底数等于0并且指数小于0 //由于base为double型,不能直接用==判断 if(equal(base,0.0)\u0026amp;\u0026amp;exponent\u0026lt;0){ invalidInput=true; return 0.0; } int absexponent=exponent; //如果指数小于0,将指数转正 if(exponent\u0026lt;0) absexponent=-exponent; //getPower方法求出base的exponent次方。 double res=getPower(base,absexponent); //如果指数小于0,所得结果为上面求的结果的倒数 if(exponent\u0026lt;0) res=1.0/res; return res; } //比较两个double型变量是否相等的方法 boolean equal(double num1,double num2){ if(num1-num2\u0026gt;-0.000001\u0026amp;\u0026amp;num1-num2\u0026lt;0.000001) return true; else return false; } //求出b的e次方的方法 double getPower(double b,int e){ //如果指数为0,返回1 if(e==0) return 1.0; //如果指数为1,返回b if(e==1) return b; //e\u0026gt;\u0026gt;1相等于e/2,这里就是求a^n =(a^n/2)*(a^n/2) double result=getPower(b,e\u0026gt;\u0026gt;1); result*=result; //如果指数n为奇数,则要再乘一次底数base if((e\u0026amp;1)==1) result*=b; return result; } } 当然这一题也可以采用笨方法:累乘。不过这种方法的时间复杂度为 O(n),这样没有前一种方法效率高。\n// 使用累乘 public double powerAnother(double base, int exponent) { double result = 1.0; for (int i = 0; i \u0026lt; Math.abs(exponent); i++) { result *= base; } if (exponent \u0026gt;= 0) return result; else return 1 / result; } 调整数组顺序使奇数位于偶数前面 # 题目描述:\n输入一个整数数组,实现一个函数来调整该数组中数字的顺序,使得所有的奇数位于数组的前半部分,所有的偶数位于位于数组的后半部分,并保证奇数和奇数,偶数和偶数之间的相对位置不变。\n问题解析:\n这道题有挺多种解法的,给大家介绍一种我觉得挺好理解的方法: 我们首先统计奇数的个数假设为 n,然后新建一个等长数组,然后通过循环判断原数组中的元素为偶数还是奇数。如果是则从数组下标 0 的元素开始,把该奇数添加到新数组;如果是偶数则从数组下标为 n 的元素开始把该偶数添加到新数组中。\n示例代码:\n时间复杂度为 O(n),空间复杂度为 O(n)的算法\npublic class Solution { public void reOrderArray(int [] array) { //如果数组长度等于0或者等于1,什么都不做直接返回 if(array.length==0||array.length==1) return; //oddCount:保存奇数个数 //oddBegin:奇数从数组头部开始添加 int oddCount=0,oddBegin=0; //新建一个数组 int[] newArray=new int[array.length]; //计算出(数组中的奇数个数)开始添加元素 for(int i=0;i\u0026lt;array.length;i++){ if((array[i]\u0026amp;1)==1) oddCount++; } for(int i=0;i\u0026lt;array.length;i++){ //如果数为基数新数组从头开始添加元素 //如果为偶数就从oddCount(数组中的奇数个数)开始添加元素 if((array[i]\u0026amp;1)==1) newArray[oddBegin++]=array[i]; else newArray[oddCount++]=array[i]; } for(int i=0;i\u0026lt;array.length;i++){ array[i]=newArray[i]; } } } 链表中倒数第 k 个节点 # 题目描述:\n输入一个链表,输出该链表中倒数第 k 个结点\n问题分析:\n一句话概括: 两个指针一个指针 p1 先开始跑,指针 p1 跑到 k-1 个节点后,另一个节点 p2 开始跑,当 p1 跑到最后时,p2 所指的指针就是倒数第 k 个节点。\n思想的简单理解: 前提假设:链表的结点个数(长度)为 n。 规律一:要找到倒数第 k 个结点,需要向前走多少步呢?比如倒数第一个结点,需要走 n 步,那倒数第二个结点呢?很明显是向前走了 n-1 步,所以可以找到规律是找到倒数第 k 个结点,需要向前走 n-k+1 步。\n算法开始:\n设两个都指向 head 的指针 p1 和 p2,当 p1 走了 k-1 步的时候,停下来。p2 之前一直不动。 p1 的下一步是走第 k 步,这个时候,p2 开始一起动了。至于为什么 p2 这个时候动呢?看下面的分析。 当 p1 走到链表的尾部时,即 p1 走了 n 步。由于我们知道 p2 是在 p1 走了 k-1 步才开始动的,也就是说 p1 和 p2 永远差 k-1 步。所以当 p1 走了 n 步时,p2 走的应该是在 n-(k-1)步。即 p2 走了 n-k+1 步,此时巧妙的是 p2 正好指向的是规律一的倒数第 k 个结点处。 这样是不是很好理解了呢? 考察内容:\n链表+代码的鲁棒性\n示例代码:\n/* //链表类 public class ListNode { int val; ListNode next = null; ListNode(int val) { this.val = val; } }*/ //时间复杂度O(n),一次遍历即可 public class Solution { public ListNode FindKthToTail(ListNode head,int k) { ListNode pre=null,p=null; //两个指针都指向头结点 p=head; pre=head; //记录k值 int a=k; //记录节点的个数 int count=0; //p指针先跑,并且记录节点数,当p指针跑了k-1个节点后,pre指针开始跑, //当p指针跑到最后时,pre所指指针就是倒数第k个节点 while(p!=null){ p=p.next; count++; if(k\u0026lt;1){ pre=pre.next; } k--; } //如果节点个数小于所求的倒数第k个节点,则返回空 if(count\u0026lt;a) return null; return pre; } } 反转链表 # 题目描述:\n输入一个链表,反转链表后,输出链表的所有元素。\n问题分析:\n链表的很常规的一道题,这一道题思路不算难,但自己实现起来真的可能会感觉无从下手,我是参考了别人的代码。 思路就是我们根据链表的特点,前一个节点指向下一个节点的特点,把后面的节点移到前面来。 就比如下图:我们把 1 节点和 2 节点互换位置,然后再将 3 节点指向 2 节点,4 节点指向 3 节点,这样以来下面的链表就被反转了。\n考察内容:\n链表+代码的鲁棒性\n示例代码:\n/* public class ListNode { int val; ListNode next = null; ListNode(int val) { this.val = val; } }*/ public class Solution { public ListNode ReverseList(ListNode head) { ListNode next = null; ListNode pre = null; while (head != null) { //保存要反转到头来的那个节点 next = head.next; //要反转的那个节点指向已经反转的上一个节点 head.next = pre; //上一个已经反转到头部的节点 pre = head; //一直向链表尾走 head = next; } return pre; } } 合并两个排序的链表 # 题目描述:\n输入两个单调递增的链表,输出两个链表合成后的链表,当然我们需要合成后的链表满足单调不减规则。\n问题分析:\n我们可以这样分析:\n假设我们有两个链表 A,B; A 的头节点 A1 的值与 B 的头结点 B1 的值比较,假设 A1 小,则 A1 为头节点; A2 再和 B1 比较,假设 B1 小,则,A1 指向 B1; A2 再和 B2 比较。。。。。。。 就这样循环往复就行了,应该还算好理解。 考察内容:\n链表+代码的鲁棒性\n示例代码:\n非递归版本:\n/* public class ListNode { int val; ListNode next = null; ListNode(int val) { this.val = val; } }*/ public class Solution { public ListNode Merge(ListNode list1,ListNode list2) { //list1为空,直接返回list2 if(list1 == null){ return list2; } //list2为空,直接返回list1 if(list2 == null){ return list1; } ListNode mergeHead = null; ListNode current = null; //当list1和list2不为空时 while(list1!=null \u0026amp;\u0026amp; list2!=null){ //取较小值作头结点 if(list1.val \u0026lt;= list2.val){ if(mergeHead == null){ mergeHead = current = list1; }else{ current.next = list1; //current节点保存list1节点的值因为下一次还要用 current = list1; } //list1指向下一个节点 list1 = list1.next; }else{ if(mergeHead == null){ mergeHead = current = list2; }else{ current.next = list2; //current节点保存list2节点的值因为下一次还要用 current = list2; } //list2指向下一个节点 list2 = list2.next; } } if(list1 == null){ current.next = list2; }else{ current.next = list1; } return mergeHead; } } 递归版本:\npublic ListNode Merge(ListNode list1,ListNode list2) { if(list1 == null){ return list2; } if(list2 == null){ return list1; } if(list1.val \u0026lt;= list2.val){ list1.next = Merge(list1.next, list2); return list1; }else{ list2.next = Merge(list1, list2.next); return list2; } } 用两个栈实现队列 # 题目描述:\n用两个栈来实现一个队列,完成队列的 Push 和 Pop 操作。 队列中的元素为 int 类型。\n问题分析:\n先来回顾一下栈和队列的基本特点: ==栈:==后进先出(LIFO) 队列: 先进先出 很明显我们需要根据 JDK 给我们提供的栈的一些基本方法来实现。先来看一下 Stack 类的一些基本方法:\n既然题目给了我们两个栈,我们可以这样考虑当 push 的时候将元素 push 进 stack1,pop 的时候我们先把 stack1 的元素 pop 到 stack2,然后再对 stack2 执行 pop 操作,这样就可以保证是先进先出的。(负[pop]负[pop]得正[先进先出])\n考察内容:\n队列+栈\n示例代码:\n//左程云的《程序员代码面试指南》的答案 import java.util.Stack; public class Solution { Stack\u0026lt;Integer\u0026gt; stack1 = new Stack\u0026lt;Integer\u0026gt;(); Stack\u0026lt;Integer\u0026gt; stack2 = new Stack\u0026lt;Integer\u0026gt;(); //当执行push操作时,将元素添加到stack1 public void push(int node) { stack1.push(node); } public int pop() { //如果两个队列都为空则抛出异常,说明用户没有push进任何元素 if(stack1.empty()\u0026amp;\u0026amp;stack2.empty()){ throw new RuntimeException(\u0026#34;Queue is empty!\u0026#34;); } //如果stack2不为空直接对stack2执行pop操作, if(stack2.empty()){ while(!stack1.empty()){ //将stack1的元素按后进先出push进stack2里面 stack2.push(stack1.pop()); } } return stack2.pop(); } } 栈的压入,弹出序列 # 题目描述:\n输入两个整数序列,第一个序列表示栈的压入顺序,请判断第二个序列是否为该栈的弹出顺序。假设压入栈的所有数字均不相等。例如序列 1,2,3,4,5 是某栈的压入顺序,序列 4,5,3,2,1 是该压栈序列对应的一个弹出序列,但 4,3,5,1,2 就不可能是该压栈序列的弹出序列。(注意:这两个序列的长度是相等的)\n题目分析:\n这道题想了半天没有思路,参考了 Alias 的答案,他的思路写的也很详细应该很容易看懂。\n【思路】借用一个辅助的栈,遍历压栈顺序,先讲第一个放入栈中,这里是 1,然后判断栈顶元素是不是出栈顺序的第一个元素,这里是 4,很显然 1≠4,所以我们继续压栈,直到相等以后开始出栈,出栈一个元素,则将出栈顺序向后移动一位,直到不相等,这样循环等压栈顺序遍历完成,如果辅助栈还不为空,说明弹出序列不是该栈的弹出顺序。\n举例:\n入栈 1,2,3,4,5\n出栈 4,5,3,2,1\n首先 1 入辅助栈,此时栈顶 1≠4,继续入栈 2\n此时栈顶 2≠4,继续入栈 3\n此时栈顶 3≠4,继续入栈 4\n此时栈顶 4 = 4,出栈 4,弹出序列向后一位,此时为 5,,辅助栈里面是 1,2,3\n此时栈顶 3≠5,继续入栈 5\n此时栈顶 5=5,出栈 5,弹出序列向后一位,此时为 3,,辅助栈里面是 1,2,3\n……. 依次执行,最后辅助栈为空。如果不为空说明弹出序列不是该栈的弹出顺序。\n考察内容:\n栈\n示例代码:\nimport java.util.ArrayList; import java.util.Stack; //这道题没想出来,参考了Alias同学的答案:https://www.nowcoder.com/questionTerminal/d77d11405cc7470d82554cb392585106 public class Solution { public boolean IsPopOrder(int [] pushA,int [] popA) { if(pushA.length == 0 || popA.length == 0) return false; Stack\u0026lt;Integer\u0026gt; s = new Stack\u0026lt;Integer\u0026gt;(); //用于标识弹出序列的位置 int popIndex = 0; for(int i = 0; i\u0026lt; pushA.length;i++){ s.push(pushA[i]); //如果栈不为空,且栈顶元素等于弹出序列 while(!s.empty() \u0026amp;\u0026amp;s.peek() == popA[popIndex]){ //出栈 s.pop(); //弹出序列向后一位 popIndex++; } } return s.empty(); } } "},{"id":635,"href":"/zh/docs/technology/Interview/high-availability/fallback-and-circuit-breaker/","title":"降级\u0026熔断详解(付费)","section":"High Availability","content":"降级\u0026amp;熔断 相关的面试题为我的 知识星球(点击链接即可查看详细介绍以及加入方法)专属内容,已经整理到了 《Java 面试指北》中。\n"},{"id":636,"href":"/zh/docs/technology/Interview/cs-basics/algorithms/classical-algorithm-problems-recommendations/","title":"经典算法思想总结(含LeetCode题目推荐)","section":"Algorithms","content":" 贪心算法 # 算法思想 # 贪心的本质是选择每一阶段的局部最优,从而达到全局最优。\n一般解题步骤 # 将问题分解为若干个子问题 找出适合的贪心策略 求解每一个子问题的最优解 将局部最优解堆叠成全局最优解 LeetCode # 455.分发饼干: https://leetcode.cn/problems/assign-cookies/\n121.买卖股票的最佳时机: https://leetcode.cn/problems/best-time-to-buy-and-sell-stock/\n122.买卖股票的最佳时机 II: https://leetcode.cn/problems/best-time-to-buy-and-sell-stock-ii/\n55.跳跃游戏: https://leetcode.cn/problems/jump-game/\n45.跳跃游戏 II: https://leetcode.cn/problems/jump-game-ii/\n动态规划 # 算法思想 # 动态规划中每一个状态一定是由上一个状态推导出来的,这一点就区分于贪心,贪心没有状态推导,而是从局部直接选最优的。\n经典题目:01 背包、完全背包\n一般解题步骤 # 确定 dp 数组(dp table)以及下标的含义 确定递推公式 dp 数组如何初始化 确定遍历顺序 举例推导 dp 数组 LeetCode # 509.斐波那契数: https://leetcode.cn/problems/fibonacci-number/\n746.使用最小花费爬楼梯: https://leetcode.cn/problems/min-cost-climbing-stairs/\n416.分割等和子集: https://leetcode.cn/problems/partition-equal-subset-sum/\n518.零钱兑换: https://leetcode.cn/problems/coin-change-ii/\n647.回文子串: https://leetcode.cn/problems/palindromic-substrings/\n516.最长回文子序列: https://leetcode.cn/problems/longest-palindromic-subsequence/\n回溯算法 # 算法思想 # 回溯算法实际上一个类似枚举的搜索尝试过程,主要是在搜索尝试过程中寻找问题的解,当发现已不满足求解条\n件时,就“回溯”返回,尝试别的路径。其本质就是穷举。\n经典题目:8 皇后\n一般解题步骤 # 针对所给问题,定义问题的解空间,它至少包含问题的一个(最优)解。 确定易于搜索的解空间结构,使得能用回溯法方便地搜索整个解空间 。 以深度优先的方式搜索解空间,并且在搜索过程中用剪枝函数避免无效搜索。 leetcode # 77.组合: https://leetcode.cn/problems/combinations/\n39.组合总和: https://leetcode.cn/problems/combination-sum/\n40.组合总和 II: https://leetcode.cn/problems/combination-sum-ii/\n78.子集: https://leetcode.cn/problems/subsets/\n90.子集 II: https://leetcode.cn/problems/subsets-ii/\n51.N 皇后: https://leetcode.cn/problems/n-queens/\n分治算法 # 算法思想 # 将一个规模为 N 的问题分解为 K 个规模较小的子问题,这些子问题相互独立且与原问题性质相同。求出子问题的解,就可得到原问题的解。\n经典题目:二分查找、汉诺塔问题\n一般解题步骤 # 将原问题分解为若干个规模较小,相互独立,与原问题形式相同的子问题; 若子问题规模较小而容易被解决则直接解,否则递归地解各个子问题 将各个子问题的解合并为原问题的解。 LeetCode # 108.将有序数组转换成二叉搜索数: https://leetcode.cn/problems/convert-sorted-array-to-binary-search-tree/\n148.排序列表: https://leetcode.cn/problems/sort-list/\n23.合并 k 个升序链表: https://leetcode.cn/problems/merge-k-sorted-lists/\n"},{"id":637,"href":"/zh/docs/technology/Interview/java/concurrent/optimistic-lock-and-pessimistic-lock/","title":"乐观锁和悲观锁详解","section":"Concurrent","content":"如果将悲观锁(Pessimistic Lock)和乐观锁(Optimistic Lock)对应到现实生活中来。悲观锁有点像是一位比较悲观(也可以说是未雨绸缪)的人,总是会假设最坏的情况,避免出现问题。乐观锁有点像是一位比较乐观的人,总是会假设最好的情况,在要出现问题之前快速解决问题。\n什么是悲观锁? # 悲观锁总是假设最坏的情况,认为共享资源每次被访问的时候就会出现问题(比如共享数据被修改),所以每次在获取资源操作的时候都会上锁,这样其他线程想拿到这个资源就会阻塞直到锁被上一个持有者释放。也就是说,共享资源每次只给一个线程使用,其它线程阻塞,用完后再把资源转让给其它线程。\n像 Java 中synchronized和ReentrantLock等独占锁就是悲观锁思想的实现。\npublic void performSynchronisedTask() { synchronized (this) { // 需要同步的操作 } } private Lock lock = new ReentrantLock(); lock.lock(); try { // 需要同步的操作 } finally { lock.unlock(); } 高并发的场景下,激烈的锁竞争会造成线程阻塞,大量阻塞线程会导致系统的上下文切换,增加系统的性能开销。并且,悲观锁还可能会存在死锁问题(线程获得锁的顺序不当时),影响代码的正常运行。\n什么是乐观锁? # 乐观锁总是假设最好的情况,认为共享资源每次被访问的时候不会出现问题,线程可以不停地执行,无需加锁也无需等待,只是在提交修改的时候去验证对应的资源(也就是数据)是否被其它线程修改了(具体方法可以使用版本号机制或 CAS 算法)。\n在 Java 中java.util.concurrent.atomic包下面的原子变量类(比如AtomicInteger、LongAdder)就是使用了乐观锁的一种实现方式 CAS 实现的。 // LongAdder 在高并发场景下会比 AtomicInteger 和 AtomicLong 的性能更好 // 代价就是会消耗更多的内存空间(空间换时间) LongAdder sum = new LongAdder(); sum.increment(); 高并发的场景下,乐观锁相比悲观锁来说,不存在锁竞争造成线程阻塞,也不会有死锁问题,在性能上往往会更胜一筹。但是,如果冲突频繁发生(写占比非常多的情况),会频繁失败并重试,这样同样会非常影响性能,导致 CPU 飙升。\n不过,大量失败重试的问题也是可以解决的,像我们前面提到的 LongAdder以空间换时间的方式就解决了这个问题。\n理论上来说:\n悲观锁通常多用于写比较多的情况(多写场景,竞争激烈),这样可以避免频繁失败和重试影响性能,悲观锁的开销是固定的。不过,如果乐观锁解决了频繁失败和重试这个问题的话(比如LongAdder),也是可以考虑使用乐观锁的,要视实际情况而定。 乐观锁通常多用于写比较少的情况(多读场景,竞争较少),这样可以避免频繁加锁影响性能。不过,乐观锁主要针对的对象是单个共享变量(参考java.util.concurrent.atomic包下面的原子变量类)。 如何实现乐观锁? # 乐观锁一般会使用版本号机制或 CAS 算法实现,CAS 算法相对来说更多一些,这里需要格外注意。\n版本号机制 # 一般是在数据表中加上一个数据版本号 version 字段,表示数据被修改的次数。当数据被修改时,version 值会加一。当线程 A 要更新数据值时,在读取数据的同时也会读取 version 值,在提交更新时,若刚才读取到的 version 值为当前数据库中的 version 值相等时才更新,否则重试更新操作,直到更新成功。\n举一个简单的例子:假设数据库中帐户信息表中有一个 version 字段,当前值为 1 ;而当前帐户余额字段( balance )为 $100 。\n操作员 A 此时将其读出( version=1 ),并从其帐户余额中扣除 $50( $100-$50 )。 在操作员 A 操作的过程中,操作员 B 也读入此用户信息( version=1 ),并从其帐户余额中扣除 $20 ( $100-$20 )。 操作员 A 完成了修改工作,将数据版本号( version=1 ),连同帐户扣除后余额( balance=$50 ),提交至数据库更新,此时由于提交数据版本等于数据库记录当前版本,数据被更新,数据库记录 version 更新为 2 。 操作员 B 完成了操作,也将版本号( version=1 )试图向数据库提交数据( balance=$80 ),但此时比对数据库记录版本时发现,操作员 B 提交的数据版本号为 1 ,数据库记录当前版本也为 2 ,不满足 “ 提交版本必须等于当前版本才能执行更新 “ 的乐观锁策略,因此,操作员 B 的提交被驳回。 这样就避免了操作员 B 用基于 version=1 的旧数据修改的结果覆盖操作员 A 的操作结果的可能。\nCAS 算法 # CAS 的全称是 Compare And Swap(比较与交换) ,用于实现乐观锁,被广泛应用于各大框架中。CAS 的思想很简单,就是用一个预期值和要更新的变量值进行比较,两值相等才会进行更新。\nCAS 是一个原子操作,底层依赖于一条 CPU 的原子指令。\n原子操作 即最小不可拆分的操作,也就是说操作一旦开始,就不能被打断,直到操作完成。\nCAS 涉及到三个操作数:\nV:要更新的变量值(Var) E:预期值(Expected) N:拟写入的新值(New) 当且仅当 V 的值等于 E 时,CAS 通过原子方式用新值 N 来更新 V 的值。如果不等,说明已经有其它线程更新了 V,则当前线程放弃更新。\n举一个简单的例子:线程 A 要修改变量 i 的值为 6,i 原值为 1(V = 1,E=1,N=6,假设不存在 ABA 问题)。\ni 与 1 进行比较,如果相等, 则说明没被其他线程修改,可以被设置为 6 。 i 与 1 进行比较,如果不相等,则说明被其他线程修改,当前线程放弃更新,CAS 操作失败。 当多个线程同时使用 CAS 操作一个变量时,只有一个会胜出,并成功更新,其余均会失败,但失败的线程并不会被挂起,仅是被告知失败,并且允许再次尝试,当然也允许失败的线程放弃操作。\n关于 CAS 的进一步介绍,可以阅读读者写的这篇文章: CAS 详解,其中详细提到了 Java 中 CAS 的实现以及 CAS 存在的一些问题。\n总结 # 本文详细介绍了乐观锁和悲观锁的概念以及乐观锁常见实现方式:\n悲观锁基于悲观的假设,认为共享资源在每次访问时都会发生冲突,因此在每次操作时都会加锁。这种锁机制会导致其他线程阻塞,直到锁被释放。Java 中的 synchronized 和 ReentrantLock 是悲观锁的典型实现方式。虽然悲观锁能有效避免数据竞争,但在高并发场景下会导致线程阻塞、上下文切换频繁,从而影响系统性能,并且还可能引发死锁问题。 乐观锁基于乐观的假设,认为共享资源在每次访问时不会发生冲突,因此无须加锁,只需在提交修改时验证数据是否被其他线程修改。Java 中的 AtomicInteger 和 LongAdder 等类通过 CAS(Compare-And-Swap)算法实现了乐观锁。乐观锁避免了线程阻塞和死锁问题,在读多写少的场景中性能优越。但在写操作频繁的情况下,可能会导致大量重试和失败,从而影响性能。 乐观锁主要通过版本号机制或 CAS 算法实现。版本号机制通过比较版本号确保数据一致性,而 CAS 通过硬件指令实现原子操作,直接比较和交换变量值。 悲观锁和乐观锁各有优缺点,适用于不同的应用场景。在实际开发中,选择合适的锁机制能够有效提升系统的并发性能和稳定性。\n参考 # 《Java 并发编程核心 78 讲》 通俗易懂 悲观锁、乐观锁、可重入锁、自旋锁、偏向锁、轻量/重量级锁、读写锁、各种锁及其 Java 实现!: https://zhuanlan.zhihu.com/p/71156910 "},{"id":638,"href":"/zh/docs/technology/Interview/java/jvm/class-loading-process/","title":"类加载过程详解","section":"Jvm","content":" 类的生命周期 # 类从被加载到虚拟机内存中开始到卸载出内存为止,它的整个生命周期可以简单概括为 7 个阶段:加载(Loading)、验证(Verification)、准备(Preparation)、解析(Resolution)、初始化(Initialization)、使用(Using)和卸载(Unloading)。其中,验证、准备和解析这三个阶段可以统称为连接(Linking)。\n这 7 个阶段的顺序如下图所示:\n类加载过程 # Class 文件需要加载到虚拟机中之后才能运行和使用,那么虚拟机是如何加载这些 Class 文件呢?\n系统加载 Class 类型的文件主要三步:加载-\u0026gt;连接-\u0026gt;初始化。连接过程又可分为三步:验证-\u0026gt;准备-\u0026gt;解析。\n详见 Java Virtual Machine Specification - 5.3. Creation and Loading。\n加载 # 类加载过程的第一步,主要完成下面 3 件事情:\n通过全类名获取定义此类的二进制字节流。 将字节流所代表的静态存储结构转换为方法区的运行时数据结构。 在内存中生成一个代表该类的 Class 对象,作为方法区这些数据的访问入口。 虚拟机规范上面这 3 点并不具体,因此是非常灵活的。比如:\u0026ldquo;通过全类名获取定义此类的二进制字节流\u0026rdquo; 并没有指明具体从哪里获取( ZIP、 JAR、EAR、WAR、网络、动态代理技术运行时动态生成、其他文件生成比如 JSP\u0026hellip;)、怎样获取。\n加载这一步主要是通过我们后面要讲到的 类加载器 完成的。类加载器有很多种,当我们想要加载一个类的时候,具体是哪个类加载器加载由 双亲委派模型 决定(不过,我们也能打破由双亲委派模型)。\n类加载器、双亲委派模型也是非常重要的知识点,这部分内容在 类加载器详解这篇文章中有详细介绍到。阅读本篇文章的时候,大家知道有这么个东西就可以了。\n每个 Java 类都有一个引用指向加载它的 ClassLoader。不过,数组类不是通过 ClassLoader 创建的,而是 JVM 在需要的时候自动创建的,数组类通过getClassLoader()方法获取 ClassLoader 的时候和该数组的元素类型的 ClassLoader 是一致的。\n一个非数组类的加载阶段(加载阶段获取类的二进制字节流的动作)是可控性最强的阶段,这一步我们可以去完成还可以自定义类加载器去控制字节流的获取方式(重写一个类加载器的 loadClass() 方法)。\n加载阶段与连接阶段的部分动作(如一部分字节码文件格式验证动作)是交叉进行的,加载阶段尚未结束,连接阶段可能就已经开始了。\n验证 # 验证是连接阶段的第一步,这一阶段的目的是确保 Class 文件的字节流中包含的信息符合《Java 虚拟机规范》的全部约束要求,保证这些信息被当作代码运行后不会危害虚拟机自身的安全。\n验证阶段这一步在整个类加载过程中耗费的资源还是相对较多的,但很有必要,可以有效防止恶意代码的执行。任何时候,程序安全都是第一位。\n不过,验证阶段也不是必须要执行的阶段。如果程序运行的全部代码(包括自己编写的、第三方包中的、从外部加载的、动态生成的等所有代码)都已经被反复使用和验证过,在生产环境的实施阶段就可以考虑使用 -Xverify:none 参数来关闭大部分的类验证措施,以缩短虚拟机类加载的时间。但是需要注意的是 -Xverify:none 和 -noverify 在 JDK 13 中被标记为 deprecated ,在未来版本的 JDK 中可能会被移除。\n验证阶段主要由四个检验阶段组成:\n文件格式验证(Class 文件格式检查) 元数据验证(字节码语义检查) 字节码验证(程序语义检查) 符号引用验证(类的正确性检查) 文件格式验证这一阶段是基于该类的二进制字节流进行的,主要目的是保证输入的字节流能正确地解析并存储于方法区之内,格式上符合描述一个 Java 类型信息的要求。除了这一阶段之外,其余三个验证阶段都是基于方法区的存储结构上进行的,不会再直接读取、操作字节流了。\n方法区属于是 JVM 运行时数据区域的一块逻辑区域,是各个线程共享的内存区域。当虚拟机要使用一个类时,它需要读取并解析 Class 文件获取相关信息,再将信息存入到方法区。方法区会存储已被虚拟机加载的 类信息、字段信息、方法信息、常量、静态变量、即时编译器编译后的代码缓存等数据。\n关于方法区的详细介绍,推荐阅读 Java 内存区域详解 这篇文章。\n符号引用验证发生在类加载过程中的解析阶段,具体点说是 JVM 将符号引用转化为直接引用的时候(解析阶段会介绍符号引用和直接引用)。\n符号引用验证的主要目的是确保解析阶段能正常执行,如果无法通过符号引用验证,JVM 会抛出异常,比如:\njava.lang.IllegalAccessError:当类试图访问或修改它没有权限访问的字段,或调用它没有权限访问的方法时,抛出该异常。 java.lang.NoSuchFieldError:当类试图访问或修改一个指定的对象字段,而该对象不再包含该字段时,抛出该异常。 java.lang.NoSuchMethodError:当类试图访问一个指定的方法,而该方法不存在时,抛出该异常。 …… 准备 # 准备阶段是正式为类变量分配内存并设置类变量初始值的阶段,这些内存都将在方法区中分配。对于该阶段有以下几点需要注意:\n这时候进行内存分配的仅包括类变量( Class Variables ,即静态变量,被 static 关键字修饰的变量,只与类相关,因此被称为类变量),而不包括实例变量。实例变量会在对象实例化时随着对象一块分配在 Java 堆中。 从概念上讲,类变量所使用的内存都应当在 方法区 中进行分配。不过有一点需要注意的是:JDK 7 之前,HotSpot 使用永久代来实现方法区的时候,实现是完全符合这种逻辑概念的。 而在 JDK 7 及之后,HotSpot 已经把原本放在永久代的字符串常量池、静态变量等移动到堆中,这个时候类变量则会随着 Class 对象一起存放在 Java 堆中。相关阅读: 《深入理解 Java 虚拟机(第 3 版)》勘误#75 这里所设置的初始值\u0026quot;通常情况\u0026quot;下是数据类型默认的零值(如 0、0L、null、false 等),比如我们定义了public static int value=111 ,那么 value 变量在准备阶段的初始值就是 0 而不是 111(初始化阶段才会赋值)。特殊情况:比如给 value 变量加上了 final 关键字public static final int value=111 ,那么准备阶段 value 的值就被赋值为 111。 基本数据类型的零值:(图片来自《深入理解 Java 虚拟机》第 3 版 7.3.3 )\n解析 # 解析阶段是虚拟机将常量池内的符号引用替换为直接引用的过程。 解析动作主要针对类或接口、字段、类方法、接口方法、方法类型、方法句柄和调用限定符 7 类符号引用进行。\n《深入理解 Java 虚拟机》7.3.4 节第三版对符号引用和直接引用的解释如下:\n举个例子:在程序执行方法时,系统需要明确知道这个方法所在的位置。Java 虚拟机为每个类都准备了一张方法表来存放类中所有的方法。当需要调用一个类的方法的时候,只要知道这个方法在方法表中的偏移量就可以直接调用该方法了。通过解析操作符号引用就可以直接转变为目标方法在类中方法表的位置,从而使得方法可以被调用。\n综上,解析阶段是虚拟机将常量池内的符号引用替换为直接引用的过程,也就是得到类或者字段、方法在内存中的指针或者偏移量。\n初始化 # 初始化阶段是执行初始化方法 \u0026lt;clinit\u0026gt; ()方法的过程,是类加载的最后一步,这一步 JVM 才开始真正执行类中定义的 Java 程序代码(字节码)。\n说明:\u0026lt;clinit\u0026gt; ()方法是编译之后自动生成的。\n对于\u0026lt;clinit\u0026gt; () 方法的调用,虚拟机会自己确保其在多线程环境中的安全性。因为 \u0026lt;clinit\u0026gt; () 方法是带锁线程安全,所以在多线程环境下进行类初始化的话可能会引起多个线程阻塞,并且这种阻塞很难被发现。\n对于初始化阶段,虚拟机严格规范了有且只有 6 种情况下,必须对类进行初始化(只有主动去使用类才会初始化类):\n当遇到 new、 getstatic、putstatic 或 invokestatic 这 4 条字节码指令时,比如 new 一个类,读取一个静态字段(未被 final 修饰)、或调用一个类的静态方法时。 当 jvm 执行 new 指令时会初始化类。即当程序创建一个类的实例对象。 当 jvm 执行 getstatic 指令时会初始化类。即程序访问类的静态变量(不是静态常量,常量会被加载到运行时常量池)。 当 jvm 执行 putstatic 指令时会初始化类。即程序给类的静态变量赋值。 当 jvm 执行 invokestatic 指令时会初始化类。即程序调用类的静态方法。 使用 java.lang.reflect 包的方法对类进行反射调用时如 Class.forName(\u0026quot;...\u0026quot;), newInstance() 等等。如果类没初始化,需要触发其初始化。 初始化一个类,如果其父类还未初始化,则先触发该父类的初始化。 当虚拟机启动时,用户需要定义一个要执行的主类 (包含 main 方法的那个类),虚拟机会先初始化这个类。 MethodHandle 和 VarHandle 可以看作是轻量级的反射调用机制,而要想使用这 2 个调用, 就必须先使用 findStaticVarHandle 来初始化要调用的类。 「补充,来自 issue745」 当一个接口中定义了 JDK8 新加入的默认方法(被 default 关键字修饰的接口方法)时,如果有这个接口的实现类发生了初始化,那该接口要在其之前被初始化。 类卸载 # 卸载这部分内容来自 issue#662由 guang19 补充完善。\n卸载类即该类的 Class 对象被 GC。\n卸载类需要满足 3 个要求:\n该类的所有的实例对象都已被 GC,也就是说堆不存在该类的实例对象。 该类没有在其他任何地方被引用 该类的类加载器的实例已被 GC 所以,在 JVM 生命周期内,由 jvm 自带的类加载器加载的类是不会被卸载的。但是由我们自定义的类加载器加载的类是可能被卸载的。\n只要想通一点就好了,JDK 自带的 BootstrapClassLoader, ExtClassLoader, AppClassLoader 负责加载 JDK 提供的类,所以它们(类加载器的实例)肯定不会被回收。而我们自定义的类加载器的实例是可以被回收的,所以使用我们自定义加载器加载的类是可以被卸载掉的。\n参考\n《深入理解 Java 虚拟机》 《实战 Java 虚拟机》 Chapter 5. Loading, Linking, and Initializing - Java Virtual Machine Specification: https://docs.oracle.com/javase/specs/jvms/se8/html/jvms-5.html#jvms-5.4 "},{"id":639,"href":"/zh/docs/technology/Interview/java/jvm/classloader/","title":"类加载器详解(重点)","section":"Jvm","content":" 回顾一下类加载过程 # 开始介绍类加载器和双亲委派模型之前,简单回顾一下类加载过程。\n类加载过程:加载-\u0026gt;连接-\u0026gt;初始化。 连接过程又可分为三步:验证-\u0026gt;准备-\u0026gt;解析。 加载是类加载过程的第一步,主要完成下面 3 件事情:\n通过全类名获取定义此类的二进制字节流 将字节流所代表的静态存储结构转换为方法区的运行时数据结构 在内存中生成一个代表该类的 Class 对象,作为方法区这些数据的访问入口 类加载器 # 类加载器介绍 # 类加载器从 JDK 1.0 就出现了,最初只是为了满足 Java Applet(已经被淘汰) 的需要。后来,慢慢成为 Java 程序中的一个重要组成部分,赋予了 Java 类可以被动态加载到 JVM 中并执行的能力。\n根据官方 API 文档的介绍:\nA class loader is an object that is responsible for loading classes. The class ClassLoader is an abstract class. Given the binary name of a class, a class loader should attempt to locate or generate data that constitutes a definition for the class. A typical strategy is to transform the name into a file name and then read a \u0026ldquo;class file\u0026rdquo; of that name from a file system.\nEvery Class object contains a reference to the ClassLoader that defined it.\nClass objects for array classes are not created by class loaders, but are created automatically as required by the Java runtime. The class loader for an array class, as returned by Class.getClassLoader() is the same as the class loader for its element type; if the element type is a primitive type, then the array class has no class loader.\n翻译过来大概的意思是:\n类加载器是一个负责加载类的对象。ClassLoader 是一个抽象类。给定类的二进制名称,类加载器应尝试定位或生成构成类定义的数据。典型的策略是将名称转换为文件名,然后从文件系统中读取该名称的“类文件”。\n每个 Java 类都有一个引用指向加载它的 ClassLoader。不过,数组类不是通过 ClassLoader 创建的,而是 JVM 在需要的时候自动创建的,数组类通过getClassLoader()方法获取 ClassLoader 的时候和该数组的元素类型的 ClassLoader 是一致的。\n从上面的介绍可以看出:\n类加载器是一个负责加载类的对象,用于实现类加载过程中的加载这一步。 每个 Java 类都有一个引用指向加载它的 ClassLoader。 数组类不是通过 ClassLoader 创建的(数组类没有对应的二进制字节流),是由 JVM 直接生成的。 class Class\u0026lt;T\u0026gt; { ... private final ClassLoader classLoader; @CallerSensitive public ClassLoader getClassLoader() { //... } ... } 简单来说,类加载器的主要作用就是加载 Java 类的字节码( .class 文件)到 JVM 中(在内存中生成一个代表该类的 Class 对象)。 字节码可以是 Java 源程序(.java文件)经过 javac 编译得来,也可以是通过工具动态生成或者通过网络下载得来。\n其实除了加载类之外,类加载器还可以加载 Java 应用所需的资源如文本、图像、配置文件、视频等等文件资源。本文只讨论其核心功能:加载类。\n类加载器加载规则 # JVM 启动的时候,并不会一次性加载所有的类,而是根据需要去动态加载。也就是说,大部分类在具体用到的时候才会去加载,这样对内存更加友好。\n对于已经加载的类会被放在 ClassLoader 中。在类加载的时候,系统会首先判断当前类是否被加载过。已经被加载的类会直接返回,否则才会尝试加载。也就是说,对于一个类加载器来说,相同二进制名称的类只会被加载一次。\npublic abstract class ClassLoader { ... private final ClassLoader parent; // 由这个类加载器加载的类。 private final Vector\u0026lt;Class\u0026lt;?\u0026gt;\u0026gt; classes = new Vector\u0026lt;\u0026gt;(); // 由VM调用,用此类加载器记录每个已加载类。 void addClass(Class\u0026lt;?\u0026gt; c) { classes.addElement(c); } ... } 类加载器总结 # JVM 中内置了三个重要的 ClassLoader:\nBootstrapClassLoader(启动类加载器):最顶层的加载类,由 C++实现,通常表示为 null,并且没有父级,主要用来加载 JDK 内部的核心类库( %JAVA_HOME%/lib目录下的 rt.jar、resources.jar、charsets.jar等 jar 包和类)以及被 -Xbootclasspath参数指定的路径下的所有类。 ExtensionClassLoader(扩展类加载器):主要负责加载 %JRE_HOME%/lib/ext 目录下的 jar 包和类以及被 java.ext.dirs 系统变量所指定的路径下的所有类。 AppClassLoader(应用程序类加载器):面向我们用户的加载器,负责加载当前应用 classpath 下的所有 jar 包和类。 🌈 拓展一下:\nrt.jar:rt 代表“RunTime”,rt.jar是 Java 基础类库,包含 Java doc 里面看到的所有的类的类文件。也就是说,我们常用内置库 java.xxx.*都在里面,比如java.util.*、java.io.*、java.nio.*、java.lang.*、java.sql.*、java.math.*。 Java 9 引入了模块系统,并且略微更改了上述的类加载器。扩展类加载器被改名为平台类加载器(platform class loader)。Java SE 中除了少数几个关键模块,比如说 java.base 是由启动类加载器加载之外,其他的模块均由平台类加载器所加载。 除了这三种类加载器之外,用户还可以加入自定义的类加载器来进行拓展,以满足自己的特殊需求。就比如说,我们可以对 Java 类的字节码( .class 文件)进行加密,加载时再利用自定义的类加载器对其解密。\n除了 BootstrapClassLoader 是 JVM 自身的一部分之外,其他所有的类加载器都是在 JVM 外部实现的,并且全都继承自 ClassLoader抽象类。这样做的好处是用户可以自定义类加载器,以便让应用程序自己决定如何去获取所需的类。\n每个 ClassLoader 可以通过getParent()获取其父 ClassLoader,如果获取到 ClassLoader 为null的话,那么该类是通过 BootstrapClassLoader 加载的。\npublic abstract class ClassLoader { ... // 父加载器 private final ClassLoader parent; @CallerSensitive public final ClassLoader getParent() { //... } ... } 为什么 获取到 ClassLoader 为null就是 BootstrapClassLoader 加载的呢? 这是因为BootstrapClassLoader 由 C++ 实现,由于这个 C++ 实现的类加载器在 Java 中是没有与之对应的类的,所以拿到的结果是 null。\n下面我们来看一个获取 ClassLoader 的小案例:\npublic class PrintClassLoaderTree { public static void main(String[] args) { ClassLoader classLoader = PrintClassLoaderTree.class.getClassLoader(); StringBuilder split = new StringBuilder(\u0026#34;|--\u0026#34;); boolean needContinue = true; while (needContinue){ System.out.println(split.toString() + classLoader); if(classLoader == null){ needContinue = false; }else{ classLoader = classLoader.getParent(); split.insert(0, \u0026#34;\\t\u0026#34;); } } } } 输出结果(JDK 8 ):\n|--sun.misc.Launcher$AppClassLoader@18b4aac2 |--sun.misc.Launcher$ExtClassLoader@53bd815b |--null 从输出结果可以看出:\n我们编写的 Java 类 PrintClassLoaderTree 的 ClassLoader 是AppClassLoader; AppClassLoader的父 ClassLoader 是ExtClassLoader; ExtClassLoader的父ClassLoader是Bootstrap ClassLoader,因此输出结果为 null。 自定义类加载器 # 我们前面也说说了,除了 BootstrapClassLoader 其他类加载器均由 Java 实现且全部继承自java.lang.ClassLoader。如果我们要自定义自己的类加载器,很明显需要继承 ClassLoader抽象类。\nClassLoader 类有两个关键的方法:\nprotected Class loadClass(String name, boolean resolve):加载指定二进制名称的类,实现了双亲委派机制 。name 为类的二进制名称,resolve 如果为 true,在加载时调用 resolveClass(Class\u0026lt;?\u0026gt; c) 方法解析该类。 protected Class findClass(String name):根据类的二进制名称来查找类,默认实现是空方法。 官方 API 文档中写到:\nSubclasses of ClassLoader are encouraged to override findClass(String name), rather than this method.\n建议 ClassLoader的子类重写 findClass(String name)方法而不是loadClass(String name, boolean resolve) 方法。\n如果我们不想打破双亲委派模型,就重写 ClassLoader 类中的 findClass() 方法即可,无法被父类加载器加载的类最终会通过这个方法被加载。但是,如果想打破双亲委派模型则需要重写 loadClass() 方法。\n双亲委派模型 # 双亲委派模型介绍 # 类加载器有很多种,当我们想要加载一个类的时候,具体是哪个类加载器加载呢?这就需要提到双亲委派模型了。\n根据官网介绍:\nThe ClassLoader class uses a delegation model to search for classes and resources. Each instance of ClassLoader has an associated parent class loader. When requested to find a class or resource, a ClassLoader instance will delegate the search for the class or resource to its parent class loader before attempting to find the class or resource itself. The virtual machine\u0026rsquo;s built-in class loader, called the \u0026ldquo;bootstrap class loader\u0026rdquo;, does not itself have a parent but may serve as the parent of a ClassLoader instance.\n翻译过来大概的意思是:\nClassLoader 类使用委托模型来搜索类和资源。每个 ClassLoader 实例都有一个相关的父类加载器。需要查找类或资源时,ClassLoader 实例会在试图亲自查找类或资源之前,将搜索类或资源的任务委托给其父类加载器。 虚拟机中被称为 \u0026ldquo;bootstrap class loader\u0026quot;的内置类加载器本身没有父类加载器,但是可以作为 ClassLoader 实例的父类加载器。\n从上面的介绍可以看出:\nClassLoader 类使用委托模型来搜索类和资源。 双亲委派模型要求除了顶层的启动类加载器外,其余的类加载器都应有自己的父类加载器。 ClassLoader 实例会在试图亲自查找类或资源之前,将搜索类或资源的任务委托给其父类加载器。 下图展示的各种类加载器之间的层次关系被称为类加载器的“双亲委派模型(Parents Delegation Model)”。\n注意 ⚠️:双亲委派模型并不是一种强制性的约束,只是 JDK 官方推荐的一种方式。如果我们因为某些特殊需求想要打破双亲委派模型,也是可以的,后文会介绍具体的方法。\n其实这个双亲翻译的容易让别人误解,我们一般理解的双亲都是父母,这里的双亲更多地表达的是“父母这一辈”的人而已,并不是说真的有一个 MotherClassLoader 和一个FatherClassLoader 。个人觉得翻译成单亲委派模型更好一些,不过,国内既然翻译成了双亲委派模型并流传了,按照这个来也没问题,不要被误解了就好。\n另外,类加载器之间的父子关系一般不是以继承的关系来实现的,而是通常使用组合关系来复用父加载器的代码。\npublic abstract class ClassLoader { ... // 组合 private final ClassLoader parent; protected ClassLoader(ClassLoader parent) { this(checkCreateClassLoader(), parent); } ... } 在面向对象编程中,有一条非常经典的设计原则:组合优于继承,多用组合少用继承。\n双亲委派模型的执行流程 # 双亲委派模型的实现代码非常简单,逻辑非常清晰,都集中在 java.lang.ClassLoader 的 loadClass() 中,相关代码如下所示。\nprotected Class\u0026lt;?\u0026gt; loadClass(String name, boolean resolve) throws ClassNotFoundException { synchronized (getClassLoadingLock(name)) { //首先,检查该类是否已经加载过 Class c = findLoadedClass(name); if (c == null) { //如果 c 为 null,则说明该类没有被加载过 long t0 = System.nanoTime(); try { if (parent != null) { //当父类的加载器不为空,则通过父类的loadClass来加载该类 c = parent.loadClass(name, false); } else { //当父类的加载器为空,则调用启动类加载器来加载该类 c = findBootstrapClassOrNull(name); } } catch (ClassNotFoundException e) { //非空父类的类加载器无法找到相应的类,则抛出异常 } if (c == null) { //当父类加载器无法加载时,则调用findClass方法来加载该类 //用户可通过覆写该方法,来自定义类加载器 long t1 = System.nanoTime(); c = findClass(name); //用于统计类加载器相关的信息 sun.misc.PerfCounter.getParentDelegationTime().addTime(t1 - t0); sun.misc.PerfCounter.getFindClassTime().addElapsedTimeFrom(t1); sun.misc.PerfCounter.getFindClasses().increment(); } } if (resolve) { //对类进行link操作 resolveClass(c); } return c; } } 每当一个类加载器接收到加载请求时,它会先将请求转发给父类加载器。在父类加载器没有找到所请求的类的情况下,该类加载器才会尝试去加载。\n结合上面的源码,简单总结一下双亲委派模型的执行流程:\n在类加载的时候,系统会首先判断当前类是否被加载过。已经被加载的类会直接返回,否则才会尝试加载(每个父类加载器都会走一遍这个流程)。 类加载器在进行类加载的时候,它首先不会自己去尝试加载这个类,而是把这个请求委派给父类加载器去完成(调用父加载器 loadClass()方法来加载类)。这样的话,所有的请求最终都会传送到顶层的启动类加载器 BootstrapClassLoader 中。 只有当父加载器反馈自己无法完成这个加载请求(它的搜索范围中没有找到所需的类)时,子加载器才会尝试自己去加载(调用自己的 findClass() 方法来加载类)。 如果子类加载器也无法加载这个类,那么它会抛出一个 ClassNotFoundException 异常。 🌈 拓展一下:\nJVM 判定两个 Java 类是否相同的具体规则:JVM 不仅要看类的全名是否相同,还要看加载此类的类加载器是否一样。只有两者都相同的情况,才认为两个类是相同的。即使两个类来源于同一个 Class 文件,被同一个虚拟机加载,只要加载它们的类加载器不同,那这两个类就必定不相同。\n双亲委派模型的好处 # 双亲委派模型保证了 Java 程序的稳定运行,可以避免类的重复加载(JVM 区分不同类的方式不仅仅根据类名,相同的类文件被不同的类加载器加载产生的是两个不同的类),也保证了 Java 的核心 API 不被篡改。\n如果没有使用双亲委派模型,而是每个类加载器加载自己的话就会出现一些问题,比如我们编写一个称为 java.lang.Object 类的话,那么程序运行的时候,系统就会出现两个不同的 Object 类。双亲委派模型可以保证加载的是 JRE 里的那个 Object 类,而不是你写的 Object 类。这是因为 AppClassLoader 在加载你的 Object 类时,会委托给 ExtClassLoader 去加载,而 ExtClassLoader 又会委托给 BootstrapClassLoader,BootstrapClassLoader 发现自己已经加载过了 Object 类,会直接返回,不会去加载你写的 Object 类。\n打破双亲委派模型方法 # 为了避免双亲委托机制,我们可以自己定义一个类加载器,然后重写 loadClass() 即可。\n🐛 修正(参见: issue871 ):自定义加载器的话,需要继承 ClassLoader 。如果我们不想打破双亲委派模型,就重写 ClassLoader 类中的 findClass() 方法即可,无法被父类加载器加载的类最终会通过这个方法被加载。但是,如果想打破双亲委派模型则需要重写 loadClass() 方法。\n为什么是重写 loadClass() 方法打破双亲委派模型呢?双亲委派模型的执行流程已经解释了:\n类加载器在进行类加载的时候,它首先不会自己去尝试加载这个类,而是把这个请求委派给父类加载器去完成(调用父加载器 loadClass()方法来加载类)。\n重写 loadClass()方法之后,我们就可以改变传统双亲委派模型的执行流程。例如,子类加载器可以在委派给父类加载器之前,先自己尝试加载这个类,或者在父类加载器返回之后,再尝试从其他地方加载这个类。具体的规则由我们自己实现,根据项目需求定制化。\n我们比较熟悉的 Tomcat 服务器为了能够优先加载 Web 应用目录下的类,然后再加载其他目录下的类,就自定义了类加载器 WebAppClassLoader 来打破双亲委托机制。这也是 Tomcat 下 Web 应用之间的类实现隔离的具体原理。\nTomcat 的类加载器的层次结构如下:\nTomcat 这四个自定义的类加载器对应的目录如下:\nCommonClassLoader对应\u0026lt;Tomcat\u0026gt;/common/* CatalinaClassLoader对应\u0026lt;Tomcat \u0026gt;/server/* SharedClassLoader对应 \u0026lt;Tomcat \u0026gt;/shared/* WebAppClassloader对应 \u0026lt;Tomcat \u0026gt;/webapps/\u0026lt;app\u0026gt;/WEB-INF/* 从图中的委派关系中可以看出:\nCommonClassLoader作为 CatalinaClassLoader 和 SharedClassLoader 的父加载器。CommonClassLoader 能加载的类都可以被 CatalinaClassLoader 和 SharedClassLoader 使用。因此,CommonClassLoader 是为了实现公共类库(可以被所有 Web 应用和 Tomcat 内部组件使用的类库)的共享和隔离。 CatalinaClassLoader 和 SharedClassLoader 能加载的类则与对方相互隔离。CatalinaClassLoader 用于加载 Tomcat 自身的类,为了隔离 Tomcat 本身的类和 Web 应用的类。SharedClassLoader 作为 WebAppClassLoader 的父加载器,专门来加载 Web 应用之间共享的类比如 Spring、Mybatis。 每个 Web 应用都会创建一个单独的 WebAppClassLoader,并在启动 Web 应用的线程里设置线程线程上下文类加载器为 WebAppClassLoader。各个 WebAppClassLoader 实例之间相互隔离,进而实现 Web 应用之间的类隔。 单纯依靠自定义类加载器没办法满足某些场景的要求,例如,有些情况下,高层的类加载器需要加载低层的加载器才能加载的类。\n比如,SPI 中,SPI 的接口(如 java.sql.Driver)是由 Java 核心库提供的,由BootstrapClassLoader 加载。而 SPI 的实现(如com.mysql.cj.jdbc.Driver)是由第三方供应商提供的,它们是由应用程序类加载器或者自定义类加载器来加载的。默认情况下,一个类及其依赖类由同一个类加载器加载。所以,加载 SPI 的接口的类加载器(BootstrapClassLoader)也会用来加载 SPI 的实现。按照双亲委派模型,BootstrapClassLoader 是无法找到 SPI 的实现类的,因为它无法委托给子类加载器去尝试加载。\n再比如,假设我们的项目中有 Spring 的 jar 包,由于其是 Web 应用之间共享的,因此会由 SharedClassLoader 加载(Web 服务器是 Tomcat)。我们项目中有一些用到了 Spring 的业务类,比如实现了 Spring 提供的接口、用到了 Spring 提供的注解。所以,加载 Spring 的类加载器(也就是 SharedClassLoader)也会用来加载这些业务类。但是业务类在 Web 应用目录下,不在 SharedClassLoader 的加载路径下,所以 SharedClassLoader 无法找到业务类,也就无法加载它们。\n如何解决这个问题呢? 这个时候就需要用到 线程上下文类加载器(ThreadContextClassLoader) 了。\n拿 Spring 这个例子来说,当 Spring 需要加载业务类的时候,它不是用自己的类加载器,而是用当前线程的上下文类加载器。还记得我上面说的吗?每个 Web 应用都会创建一个单独的 WebAppClassLoader,并在启动 Web 应用的线程里设置线程线程上下文类加载器为 WebAppClassLoader。这样就可以让高层的类加载器(SharedClassLoader)借助子类加载器( WebAppClassLoader)来加载业务类,破坏了 Java 的类加载委托机制,让应用逆向使用类加载器。\n线程上下文类加载器的原理是将一个类加载器保存在线程私有数据里,跟线程绑定,然后在需要的时候取出来使用。这个类加载器通常是由应用程序或者容器(如 Tomcat)设置的。\nJava.lang.Thread 中的getContextClassLoader()和 setContextClassLoader(ClassLoader cl)分别用来获取和设置线程的上下文类加载器。如果没有通过setContextClassLoader(ClassLoader cl)进行设置的话,线程将继承其父线程的上下文类加载器。\nSpring 获取线程线程上下文类加载器的代码如下:\ncl = Thread.currentThread().getContextClassLoader(); 感兴趣的小伙伴可以自行深入研究一下 Tomcat 打破双亲委派模型的原理,推荐资料: 《深入拆解 Tomcat \u0026amp; Jetty》。\n推荐阅读 # 《深入拆解 Java 虚拟机》 深入分析 Java ClassLoader 原理: https://blog.csdn.net/xyang81/article/details/7292380 Java 类加载器(ClassLoader): http://gityuan.com/2016/01/24/java-classloader/ Class Loaders in Java: https://www.baeldung.com/java-classloaders Class ClassLoader - Oracle 官方文档: https://docs.oracle.com/javase/8/docs/api/java/lang/ClassLoader.html 老大难的 Java ClassLoader 再不理解就老了: https://zhuanlan.zhihu.com/p/51374915 "},{"id":640,"href":"/zh/docs/technology/Interview/java/jvm/class-file-structure/","title":"类文件结构详解","section":"Jvm","content":" 回顾一下字节码 # 在 Java 中,JVM 可以理解的代码就叫做字节码(即扩展名为 .class 的文件),它不面向任何特定的处理器,只面向虚拟机。Java 语言通过字节码的方式,在一定程度上解决了传统解释型语言执行效率低的问题,同时又保留了解释型语言可移植的特点。所以 Java 程序运行时比较高效,而且,由于字节码并不针对一种特定的机器,因此,Java 程序无须重新编译便可在多种不同操作系统的计算机上运行。\nClojure(Lisp 语言的一种方言)、Groovy、Scala、JRuby、Kotlin 等语言都是运行在 Java 虚拟机之上。下图展示了不同的语言被不同的编译器编译成.class文件最终运行在 Java 虚拟机之上。.class文件的二进制格式可以使用 WinHex 查看。\n可以说.class文件是不同的语言在 Java 虚拟机之间的重要桥梁,同时也是支持 Java 跨平台很重要的一个原因。\nClass 文件结构总结 # 根据 Java 虚拟机规范,Class 文件通过 ClassFile 定义,有点类似 C 语言的结构体。\nClassFile 的结构如下:\nClassFile { u4 magic; //Class 文件的标志 u2 minor_version;//Class 的小版本号 u2 major_version;//Class 的大版本号 u2 constant_pool_count;//常量池的数量 cp_info constant_pool[constant_pool_count-1];//常量池 u2 access_flags;//Class 的访问标记 u2 this_class;//当前类 u2 super_class;//父类 u2 interfaces_count;//接口数量 u2 interfaces[interfaces_count];//一个类可以实现多个接口 u2 fields_count;//字段数量 field_info fields[fields_count];//一个类可以有多个字段 u2 methods_count;//方法数量 method_info methods[methods_count];//一个类可以有个多个方法 u2 attributes_count;//此类的属性表中的属性数 attribute_info attributes[attributes_count];//属性表集合 } 通过分析 ClassFile 的内容,我们便可以知道 class 文件的组成。\n下面这张图是通过 IDEA 插件 jclasslib 查看的,你可以更直观看到 Class 文件结构。\n使用 jclasslib 不光可以直观地查看某个类对应的字节码文件,还可以查看类的基本信息、常量池、接口、属性、函数等信息。\n下面详细介绍一下 Class 文件结构涉及到的一些组件。\n魔数(Magic Number) # u4 magic; //Class 文件的标志 每个 Class 文件的头 4 个字节称为魔数(Magic Number),它的唯一作用是确定这个文件是否为一个能被虚拟机接收的 Class 文件。Java 规范规定魔数为固定值:0xCAFEBABE。如果读取的文件不是以这个魔数开头,Java 虚拟机将拒绝加载它。\nClass 文件版本号(Minor\u0026amp;Major Version) # u2 minor_version;//Class 的小版本号 u2 major_version;//Class 的大版本号 紧接着魔数的四个字节存储的是 Class 文件的版本号:第 5 和第 6 个字节是次版本号,第 7 和第 8 个字节是主版本号。\n每当 Java 发布大版本(比如 Java 8,Java9)的时候,主版本号都会加 1。你可以使用 javap -v 命令来快速查看 Class 文件的版本号信息。\n高版本的 Java 虚拟机可以执行低版本编译器生成的 Class 文件,但是低版本的 Java 虚拟机不能执行高版本编译器生成的 Class 文件。所以,我们在实际开发的时候要确保开发的的 JDK 版本和生产环境的 JDK 版本保持一致。\n常量池(Constant Pool) # u2 constant_pool_count;//常量池的数量 cp_info constant_pool[constant_pool_count-1];//常量池 紧接着主次版本号之后的是常量池,常量池的数量是 constant_pool_count-1(常量池计数器是从 1 开始计数的,将第 0 项常量空出来是有特殊考虑的,索引值为 0 代表“不引用任何一个常量池项”)。\n常量池主要存放两大常量:字面量和符号引用。字面量比较接近于 Java 语言层面的的常量概念,如文本字符串、声明为 final 的常量值等。而符号引用则属于编译原理方面的概念。包括下面三类常量:\n类和接口的全限定名 字段的名称和描述符 方法的名称和描述符 常量池中每一项常量都是一个表,这 14 种表有一个共同的特点:开始的第一位是一个 u1 类型的标志位 -tag 来标识常量的类型,代表当前这个常量属于哪种常量类型.\n类型 标志(tag) 描述 CONSTANT_utf8_info 1 UTF-8 编码的字符串 CONSTANT_Integer_info 3 整形字面量 CONSTANT_Float_info 4 浮点型字面量 CONSTANT_Long_info 5 长整型字面量 CONSTANT_Double_info 6 双精度浮点型字面量 CONSTANT_Class_info 7 类或接口的符号引用 CONSTANT_String_info 8 字符串类型字面量 CONSTANT_FieldRef_info 9 字段的符号引用 CONSTANT_MethodRef_info 10 类中方法的符号引用 CONSTANT_InterfaceMethodRef_info 11 接口中方法的符号引用 CONSTANT_NameAndType_info 12 字段或方法的符号引用 CONSTANT_MethodType_info 16 标志方法类型 CONSTANT_MethodHandle_info 15 表示方法句柄 CONSTANT_InvokeDynamic_info 18 表示一个动态方法调用点 .class 文件可以通过javap -v class类名 指令来看一下其常量池中的信息(javap -v class类名-\u0026gt; temp.txt:将结果输出到 temp.txt 文件)。\n访问标志(Access Flags) # u2 access_flags;//Class 的访问标记 在常量池结束之后,紧接着的两个字节代表访问标志,这个标志用于识别一些类或者接口层次的访问信息,包括:这个 Class 是类还是接口,是否为 public 或者 abstract 类型,如果是类的话是否声明为 final 等等。\n类访问和属性修饰符:\n我们定义了一个 Employee 类\npackage top.snailclimb.bean; public class Employee { ... } 通过javap -v class类名 指令来看一下类的访问标志。\n当前类(This Class)、父类(Super Class)、接口(Interfaces)索引集合 # u2 this_class;//当前类 u2 super_class;//父类 u2 interfaces_count;//接口数量 u2 interfaces[interfaces_count];//一个类可以实现多个接口 Java 类的继承关系由类索引、父类索引和接口索引集合三项确定。类索引、父类索引和接口索引集合按照顺序排在访问标志之后,\n类索引用于确定这个类的全限定名,父类索引用于确定这个类的父类的全限定名,由于 Java 语言的单继承,所以父类索引只有一个,除了 java.lang.Object 之外,所有的 Java 类都有父类,因此除了 java.lang.Object 外,所有 Java 类的父类索引都不为 0。\n接口索引集合用来描述这个类实现了哪些接口,这些被实现的接口将按 implements (如果这个类本身是接口的话则是extends) 后的接口顺序从左到右排列在接口索引集合中。\n字段表集合(Fields) # u2 fields_count;//字段数量 field_info fields[fields_count];//一个类会可以有个字段 字段表(field info)用于描述接口或类中声明的变量。字段包括类级变量以及实例变量,但不包括在方法内部声明的局部变量。\nfield info(字段表) 的结构:\naccess_flags: 字段的作用域(public ,private,protected修饰符),是实例变量还是类变量(static修饰符),可否被序列化(transient 修饰符),可变性(final),可见性(volatile 修饰符,是否强制从主内存读写)。 name_index: 对常量池的引用,表示的字段的名称; descriptor_index: 对常量池的引用,表示字段和方法的描述符; attributes_count: 一个字段还会拥有一些额外的属性,attributes_count 存放属性的个数; attributes[attributes_count]: 存放具体属性具体内容。 上述这些信息中,各个修饰符都是布尔值,要么有某个修饰符,要么没有,很适合使用标志位来表示。而字段叫什么名字、字段被定义为什么数据类型这些都是无法固定的,只能引用常量池中常量来描述。\n字段的 access_flag 的取值:\n方法表集合(Methods) # u2 methods_count;//方法数量 method_info methods[methods_count];//一个类可以有个多个方法 methods_count 表示方法的数量,而 method_info 表示方法表。\nClass 文件存储格式中对方法的描述与对字段的描述几乎采用了完全一致的方式。方法表的结构如同字段表一样,依次包括了访问标志、名称索引、描述符索引、属性表集合几项。\nmethod_info(方法表的) 结构:\n方法表的 access_flag 取值:\n注意:因为volatile修饰符和transient修饰符不可以修饰方法,所以方法表的访问标志中没有这两个对应的标志,但是增加了synchronized、native、abstract等关键字修饰方法,所以也就多了这些关键字对应的标志。\n属性表集合(Attributes) # u2 attributes_count;//此类的属性表中的属性数 attribute_info attributes[attributes_count];//属性表集合 在 Class 文件,字段表,方法表中都可以携带自己的属性表集合,以用于描述某些场景专有的信息。与 Class 文件中其它的数据项目要求的顺序、长度和内容不同,属性表集合的限制稍微宽松一些,不再要求各个属性表具有严格的顺序,并且只要不与已有的属性名重复,任何人实现的编译器都可以向属性表中写 入自己定义的属性信息,Java 虚拟机运行时会忽略掉它不认识的属性。\n参考 # 《实战 Java 虚拟机》 Chapter 4. The class File Format - Java Virtual Machine Specification: https://docs.oracle.com/javase/specs/jvms/se8/html/jvms-4.html 实例分析 JAVA CLASS 的文件结构: https://coolshell.cn/articles/9229.html 《Java 虚拟机原理图解》 1.2.2、Class 文件中的常量池详解(上): https://blog.csdn.net/luanlouis/article/details/39960815 "},{"id":641,"href":"/zh/docs/technology/Interview/high-quality-technical-articles/work/employee-performance/","title":"聊聊大厂的绩效考核","section":"Work","content":" 内容概览:\n在大部分公司,绩效跟你的年终奖、职级晋升、薪水涨幅等等福利是直接相关的。 你的上级、上上级对你的绩效拥有绝对的话语权,这是潜规则,放到任何公司都是。成年人的世界,没有绝对的公平,绩效考核尤为明显。 提升绩效的打法: 短期打法:找出 1-2 件事,体现出你的独特价值(抓关键事件)。 长期打法:通过一步步信任的建立,成为团队的核心人员或者是老板的心腹,具备不可替代性。 原文地址: https://mp.weixin.qq.com/s/D1s8p7z8Sp60c-ndGyh2yQ\n在新公司度过了一个完整的 Q3 季度,被打了绩效,也给下属打了绩效,感慨颇深。\n今天就好好聊聊大厂打工人最最关心的「绩效考核」,谈谈它背后的逻辑以及潜规则,摸清楚了它,你在大厂这片丛林里才能更好的生存下去。\n大厂的绩效到底有多重要? # 先从公司角度,谈谈为什么需要绩效考核?\n有一个著名的管理者言论,即:企业战略的上三路和下三路。\n上三路是使命、愿景、价值观,下三路是组织、人才、KPI。下三路需要确保上三路能执行下去,否则便是空谈。那怎么才能达成呢?\n马老板在湖畔大学的课堂上,对底下众多 CEO 学员说,“只能靠 KPI。没有 KPI,一切都是空话,组织和公司是不会进步的”。\n所以,KPI 一般是用来承接企业战略的。身处大厂的打工者们,也能深深感受到:每个季度的 KPI 是如何从大 Boss、到 Boss、再到基层,一层层拆解下来的,最终让所有人朝着一个方向行动,这便是 KPI 对于公司的意义。\n然鹅,并非每个员工都会站在 CEO 的高度去理解 KPI 的价值,大家更关注的是 KPI 对于我个人来说到底有什么意义?\n在互联网大厂,每家公司都会设定一套绩效考核体系,字节用的是 OKR,阿里用的是 KPI,通常都是「271」 制度,即:\n20% 的比例是 A+ 和 A,对应明星员工。\n70% 的比例是 B,对应普通员工。\n10% 的比例是 C 和 C-,对应需要绩效改进或者淘汰的员工。\n有了三六九等,然后才有了利益分配。\n在大厂,绩效结果跟奖金、晋升、薪水涨幅、股票授予是直接相关的。在内卷的今天,甚至可以直接划上等号。\n绩效好的员工,奖金必然多,一年可能调薪两次,晋升答辩时能 PK 掉绩效一般的人,职级低的人甚至可以晋升免试。\n而绩效差的人,有可能一年白干,甚至走人(大厂的末尾淘汰是不成文的规定)。\n总之,你能想到的直接利益都和「绩效」息息相关。所以,在大厂这片高手众多的丛林里,多琢磨下绩效背后的逻辑,既是生存之道,更是一技之长。\n你是怎么看待绩效的? # 凡是用来考核人的规则,大部分人在潜意识里都想去突破它,而不是被束缚。\n至少在我刚工作的前几年,看着身边有些同事因为背个 C 黯然离开的时候,觉得绩效考核就是一个冷血的管理工具。\n尤其遇到自己看不上的领导时,对于他给我打的绩效,其实也是很不屑的。\n到今天,实在见过太多的反面案例了,自己也踩过一些坑,逐渐认识到:当初的想法除了让自己心里爽一点,好像起不到任何作用,甚至会让我的工作方式变形。\n当思维方式变了,也就改变了我对绩效的态度,至少有两点我认为是打工人需要看清的。\n第一,你的上级、上上级对你的绩效拥有绝对的话语权,这是潜规则,放到任何公司都是。\n大家可以去看看身边发展特别好的人,除了有很强的个人能力以外,几乎都是善于利用规则,而不是去挑战规则的人。\n当然,我并不是说你要一味地去跪舔你的领导,而是表达:工作中不要站在领导的对立面去做对抗,如果领导做法很过分,要么直接沟通去影响他,要么选择离开。\n第二,成年人的世界,没有绝对的公平,绩效考核尤为明显。\n我所待过的团队,绩效考核还是相对公平的,虽然也存在受照顾的情况,但都是个例。\n另外就是,技术岗的绩效考核不同于销售或者运营岗,很容易指标化。\n需求吞吐量、BUG 数、线上事故\u0026hellip; 的确有一大堆研发效能指标,但这些指标在绩效考核时是否会被参考?具体又该如何分配比重?本身就是一个扯不清楚的难题。\n最终决定你绩效结果的还是你领导的主观判断。你所见到的 360 环评,以及弄一些指标排序,这些都只是将绩效结果合理化的一种方式,并非关键所在。\n因此,多琢磨如何去影响你的领导?站在他的视角去审视他在绩效考核时到底关注哪些核心点?这才是至关重要的。\n上面讲了一堆潜规则,是不是意味着绩效考核是可以投机取巧,完全不看工作业绩呢,当然不是。\n“你的努力不一定会被看见”、“你的努力应该有的放矢”,大家先记住这两条。\n下面我再展开聊聊,大家最最关心的 A 和 C,它们背后的逻辑。\n绩效被打 A 和 C 的逻辑是什么? # “铆足了劲拿不到 A,一不留神居然拿了个 C”,这是绝大多数打工人最真实的职场现状。\nA 和 C 属于绩效的两个极端,背后的逻辑类似,反着理解即可,下面我详细分析下 C。\n先从我身边人的情况说起,我所看到的案例绝大多数都属于:绩效被打了 C,完全没有任何预感,主管跟他沟通结果时,还是一脸懵逼,“为什么会给我打 C?一定是黑我呀!”。\n前阵子听公司一位大佬分享,用他的话说,这种人就是没有「角色认知」,他不知道他所处的角色和职级该做好哪些事?做成什么样才算「做好了」?被打 C 后自然觉得是在背锅。\n所以,务必确保你对于当前角色是认知到位的,这样才称得上进入了「工作状态」,否则你的一次松懈,一段不太好的表现,很可能导致 C 落在你的头上,岗位越高,摔得越重。\n有了角色认知,再说下对绩效的认知。\n第一,团队很优秀,是不是不用背 C?不是!大厂的 C 都是强制分配的,再优秀的团队也会有 C。所以团队越厉害,竞争越惨烈。\n第二,完成了 KPI,没有工作失误,是不是就万事大吉,不用背 C?不是,绩效是相对的,你必须清楚你在团队所处的位置,你在老板眼中的排序,慢慢练出这种嗅觉。\n懂了上面这些道理,很自然就能知道打 C 的逻辑,C 会集中在两类人上:\n1、工作表现称不上角色要求的人。\n2、在老板眼里排序靠后,就算离开,对团队影响也很小的人。\n要规避 C,有两种打法。\n第 1 种是短期打法:抓关键事件,能不能找出 1-2 件事,体现出你的独特价值(比如本身影响力很大的项目,或者是领导最重视的事),相当于让你的排序有了最基本的保障。\n这种打法,你不能等到评价时再去改变,一定是在前期就抓住机会,承担起最有挑战的任务,然后全力以赴,做好了拿 A,不弄砸也不至于背 C,就怕静水潜流,躺平了去工作。\n第 2 种是长期打法:通过一步步信任的建立,成为团队的核心人员或者是老板的心腹,具备不可替代性。\n上面两种打法都是大的思路,还有很多锦上添花的技巧,比如:加强主动汇报(抹平领导的信息差)、让关键干系人给你点赞(能影响到你领导做出绩效决策的人)。\n写在最后 # 有人的地方就有江湖,有江湖就一定有规则,大厂平面看似平静,其实在绩效考核、晋升等利益点面前,都是一场厮杀。\n当大家攻山头的能力都很强时,==到底做成什么样才算做好了?==当你弄清楚了这个玄机,职场也就看透了。\n如果这篇文章让你有一点启发,来个点赞和在看呀!我是武哥,我们下期见!\n"},{"id":642,"href":"/zh/docs/technology/Interview/high-quality-technical-articles/advanced-programmer/meituan-three-year-summary-lesson-10/","title":"美团三年,总结的10条血泪教训","section":"Advanced Programmer","content":" 推荐语:作者用了很多生动的例子和故事展示了自己在美团的成长和感悟,看了之后受益颇多!\n内容概览:\n本文的作者提出了以下十条建议,希望能对其他职场人有所启发和帮助:\n结构化思考与表达,提高个人影响力 忘掉职级,该怼就怼,推动事情往前走 用好平台资源,结识优秀的人,学习通识课 一切都是争取来的,不要等待机会,要主动寻求 关注商业,升维到老板思维,看清趋势,及时止损 培养数据思维,利用数据了解世界,指导决策 做一个好\u0026quot;销售\u0026quot;,无论是自己还是产品,都要学会展示和说服 少加班多运动,保持身心健康,提高工作效率 有随时可以离开的底气,不要被职场所困,借假修真,提升自己 只是一份工作,不要过分纠结,相信自己,走出去看看 原文地址: https://mp.weixin.qq.com/s/XidSVIwd4oKkDKEICaY1mQ\n在美团的三年多时光,如同一部悠长的交响曲,高高低低,而今离开已有一段时间。闲暇之余,梳理了三年多的收获与感慨,总结成 10 条,既是对过去一段时光的的一个深情回眸,也是对未来之路的一份期许。\n倘若一些感悟能为刚步入职场的年轻人,或是刚在职业生涯中崭露头角的后起之秀,带来一点点启示与帮助,也是莫大的荣幸。\n01 结构化思考与表达 # 美团是一家特别讲究方法论的公司,人人都要熟读四大名著《高效能人士的七个习惯》、《金字塔原理》、《用图表说话》和《学会提问》。\n与结构化思考和表达相关的,是《金字塔原理》,作者是麦肯锡公司第一位女性咨询顾问。这本书告诉我们,思考和表达的过程,就像构建金字塔(或者构建一棵树),先有整体结论,再寻找证据,证据之间要讲究相互独立、而且能穷尽(MECE 原则),论证的过程也要按特定的顺序进行,比如时间顺序、空间顺序、重要性顺序……\n作为大厂社畜,日常很大一部分工作就是写文档、看别人文档。大家做的事,但最后呈现的结果却有很大差异。一篇逻辑清晰、详略得当的文档,给人一种如沐春风的感受,能提炼出重要信息,是好的参考指南。\n结构化思考与表达算是职场最通用的能力,也是打造个人影响力最重要的途径之一。\n02 忘掉职级,该怼就怼 # 在阿里工作时,能看到每个人的 Title,看到江湖地位高(职级高+入职时间早)的同学,即便跟自己没有汇报关系,不自然的会多一层敬畏。推进工作时,会多一层压力,对方未读或已读未回时,不知如何应对。\n美团只能看到每个人的坑位信息,还有 Ta 的上级。工作相关的问题,可以向任何人提问,如果协同方没有及时响应,隔段时间@一次,甚至\u0026quot;怼一怼\u0026quot;,都没啥问题,事情一直往前推进才最重要。除了大象消息直接提问外,还有个大杀器\u0026ndash;TT(公司级问题流转系统),在上面提问时,加上对方主管,如果对方未及时回应,问题会自动升级,每天定时 Push,直到解决为止。\n我见到一些很年轻的同事,他们在推动 OKR、要资源的事上,很有一套,只要能达到自己的目标,不会考虑别人的感受,最终,他们还真能把事办成。\n当然了,段位越高的人,越能用自己的人格魅力、影响力、资源等,去影响和推动事情的进程,而不是靠对他人的 Push。只是在拿结果的事上,不要把自己太当回事,把别人太当回事,大家在一起,也只是为了完成各自的任务,忘掉职级,该怼时还得怼。\n03 用好平台资源 # 没有人能在一家公司待一辈子,公司再牛,跟自己关系不大,重要的是,在有限的时间内,最大化用好平台资源。\n在美团除了认识自己节点的同事外,有幸认识一群特别棒的协作方,还有其他 BU 的同学。\n这些优秀的人身上,有很多共同的特质:谦虚、利他、乐于分享、双赢思维。\n有两位做运营的同学。\n一位是无意中关注他公众号结识上的。他公众号记录了很多职场成长、家庭建造上的思考和收获,还有定期个人复盘。他和太太都是大厂中层管理者,从文章中看到的不是他多厉害,而是非常接地气的故事。我们约饭了两次,有很多共同话题,现在还时不时有一些互动。\n一位职级更高的同学,他在内网发起了一个\u0026quot;请我喝一杯咖啡,和我一起聊聊个人困惑\u0026quot;的活动,我报名参与了一期。和他聊天的过程,特别像是一场教练对话(最近学习教练课程时才感受到的),帮我排除干扰、聚焦目标的同时,也从他分享个人成长蜕变的过程,收获很多动力。(刚好自己最近也学习了教练技术,后面也准备采用类似的方式,去帮助曾经像我一样迷茫的人)\n还有一些协作方同学。他们工作做得超级到位,能感受到,他们在乎他人时间;稍微有点出彩的事儿,不忘记拉上更多人。利他和双赢思维,在他们身上是最好的阐释。\n除了结识优秀的人,向他们学习外,还可以关注各个通道/工种的课程资源。\n在大厂,多数人的角色都是螺丝钉,但千万不要局限于做一颗螺丝钉。多去学习一些通识课,了解商业交付的各个环节,看清商业世界,明白自己的定位,超越自己的定位。\n04 一切都是争取来的 # 工作很多年了,很晚才明白这个道理。\n之前一直认为,只要做好自己该做的,一定会被看见,被赏识,也会得到更多机会。但很多时候,这只是个人的一厢情愿。除了自己,不会有人关心你的权益。\n社会主义初级阶段,我国国内的主要矛盾是人民日益增长的物质文化需要同落后的社会生产之间的矛盾。无论在哪里,资源都是稀缺的,自己在乎的,就得去争取。\n想成长某个技能、想参与哪个模块、想做哪个项目,升职加薪……自己不提,不去争取,不会有人主动给你。\n争不争取是一回事,能不能得到是一回事,只有争取,才有可能得到。争取了,即便没有得到,最终也没失去什么。\n05 关注商业 # 大公司,极度关注效率,大部分岗位,拆解的粒度越细,效率会越高,这些对组织是有利的。但对个人来说,则很容易螺丝钉化。\n做技术的同学,更是这样。\n做前端的同学,不会关注数据是如何落库的;做后端的同学,不会思考页面是否存在兼容性问题;做业务开发的,不用考虑微服务诸多中间件是如何搭建起来的……\n大部分人都想着怎么把自己这摊子事搞好,不会去思考上下游同学在做些什么,更少有人真正关注商业,关心公司的盈利模式,关心每一次产品迭代到底带来哪些业务价值。\n把手头的事做好是应该的,但绝不能停留在此。所有的产品,只有在商业社会产生交付,让客户真正获益,才是有价值的。\n关注商业,能帮我们升维到老板思维,明白投入产出比,抓大放小;也帮助我们,在碰到不好的业务时,及时止损;更重要的是,它帮助我们真正看清趋势,提前做好准备。\n《五分钟商学院》系列,是很好的商业入门级书籍。尽管作者刘润最近存在争议,但不可否认,他比我们大多数人段位还是高很多,他的书值得一读。\n06 培养数据思维 # 当今数字化时代,数据思维显得尤为重要。数据不仅可以帮助我们更好地了解世界,还可以指导我们的决策和行动。\n非常幸运的是,在阿里和美团的两份经历,都是做商业化广告业务,在离钱💰最近的地方,也培养了数据的敏感性。见过商业数据指标的定义、加工、生产和应用全流程,也在不断熏陶下,能看懂大部分指标背后的价值。\n除了直接面向业务的数据,还有研发协作全流程产生的数据。数据被记录和汇总统计后,能直观地看到每个环节的效率和质量。螺丝钉们的工作,也彻彻底底被数字量化,除了积极面对虚拟化、线上化、数字化外,我们别无他法。\n受工作数据化的影响,生活中,我也渐渐变成了一个数据记录狂,日常运动(骑行、跑步、健走等)必须通过智能手表记录下来,没带 Apple Watch,感觉这次白运动了。每天也在很努力地完成三个圆环。\n数据时代,我们沦为了透明人。也得益于数据被记录和分析,我们做任何事,都能快速得到反馈,这也是自我提升的一个重要环节。\n07 做一个好\u0026quot;销售\u0026quot; # 就某种程度来说,所有的工作,本质都是销售。\n这是很多大咖的观点,我也是很晚才明白这个道理。\n我们去一家公司应聘,本质上是在讲一个「我很牛」的故事,销售的是自己;日常工作汇报、季度/年度述职、晋升答辩,是在销售自己;在任何一个场合曝光,也是在销售自己。\n如果我们所服务的组织,对外提供的是一件产品或一项服务,所有上下游协作的同学,唯一在做的事就是,齐心协力把产品/服务卖出去, 我们本质做的还是销售。\n所以, 千万不要看不起任何销售,也不要认为认为销售是一件很丢面子的事。\n真正的大佬,随时随地都在销售。\n08 少加班多运动 # 在职场,大家都认同一个观点,工作是做不完的。\n我们要做的是,用好时间管理四象限法,识别重要程度和优先级,有限时间,聚焦在固定几件事上。\n这要求我们不断提高自己的问题识别能力、拆解能力,还有专注力。\n我们会因为部分项目的需要而加班,但不会长期加班。\n加班时间短一点,就能腾出更多时间运动。\n最近一次线下培训课,认识一位老师 Hubert,Hubert 是一位超级有魅力的中年大叔(可以通过「有意思教练」的课程链接到他),从外企高管的位置离开后,和太太一起创办了一家培训机构。作为公司高层,日常工作非常忙,头发也有些花白了,但一身腱子肉胜过很多健身教练,给人的状态也是很年轻。聊天得知,Hubert 经常 5 点多起来泡健身房~\n我身边还有一些同事,跟我年龄差不多,因为长期加班,发福严重,比实际年龄看起来苍老 10+岁;\n还有同事曾经加班进 ICU,幸好后面身体慢慢恢复过来。\n某某厂员工长期加班猝死的例子,更是屡见不鲜。\n减少加班,增加运动,绝对是一件性价比极高的事。\n09 有随时可以离开的底气 # 当今职场,跟父辈时候完全不一样,职业的多样性和变化性越来越快,很少有人能够在同一份工作或同一个公司待一辈子。除了某些特定的岗位,如公务员、事业单位等,大多数人都会在职业生涯中经历多次的职业变化和调整。\n在商业组织里,个体是弱势群体,但不要做弱者。每一段职场,每一项工作,都是上天给我们的修炼。\n我很喜欢\u0026quot;借假修真\u0026quot;这个词。我们参与的大大小小的项目, 重要吗?对公司来说可能重要,对个人来说,则未必。我们去做,一方面是迫于生计;\n另外一方面,参与每个项目的感悟、心得、体会,是真实存在的,很多的能力,都是在这个过程得到提升。\n明白这一点,就不会被职场所困,会刻意在各样事上提升自己,积累的越多,对事务的本质理解的越深、越广,也越发相信很多底层知识是通用的,内心越平静,也会建立起随时都可以离开的底气。\n10 只是一份工作 # 工作中,我们时常会遇到各种挑战和困难,如发展瓶颈、难以处理的人和事,甚至职场 PUA 等。这些经历可能会让我们感到疲惫、沮丧,甚至怀疑自己的能力和价值。然而,重要的是要明白,困难只是成长道路上的暂时阻碍,而不是我们的定义。\n写总结和复盘是很好的方式,可以帮我们理清思路,找到问题的根源,并学习如何应对类似的情况。但也要注意不要陷入自我怀疑和内耗的陷阱。遇到困难时,应该学会相信自己,积极寻找解决问题的方法,而不是过分纠结于自己的不足和错误。\n内网常有同学匿名分享工作压力过大,常常失眠甚至中度抑郁,每次看到这些话题,非常难过。大环境不好,是不争的事实,但并不代表个体就没有出路。\n我们容易预设困难,容易加很多\u0026quot;可是\u0026quot;,当窗户布满灰尘时,不要试图努力把窗户擦干净,走出去吧,你将看到一片蔚蓝的天空。\n最后 # 写到最后,特别感恩美团三年多的经历。感谢我的 Leader 们,感谢曾经并肩作战过的小伙伴,感谢遇到的每一位和我一样在平凡的岗位,努力想带给身边一片微光的同学。所有的相遇,都是缘分。\n"},{"id":643,"href":"/zh/docs/technology/Interview/system-design/security/sentive-words-filter/","title":"敏感词过滤方案总结","section":"Security","content":"系统需要对用户输入的文本进行敏感词过滤如色情、政治、暴力相关的词汇。\n敏感词过滤用的使用比较多的 Trie 树算法 和 DFA 算法。\n算法实现 # Trie 树 # Trie 树 也称为字典树、单词查找树,哈希树的一种变种,通常被用于字符串匹配,用来解决在一组字符串集合中快速查找某个字符串的问题。像浏览器搜索的关键词提示就可以基于 Trie 树来做的。\n假如我们的敏感词库中有以下敏感词:\n高清视频 高清 CV 东京冷 东京热 我们构造出来的敏感词 Trie 树就是下面这样的:\n当我们要查找对应的字符串“东京热”的话,我们会把这个字符串切割成单个的字符“东”、“京”、“热”,然后我们从 Trie 树的根节点开始匹配。\n可以看出, Trie 树的核心原理其实很简单,就是通过公共前缀来提高字符串匹配效率。\nApache Commons Collections 这个库中就有 Trie 树实现:\nTrie\u0026lt;String, String\u0026gt; trie = new PatriciaTrie\u0026lt;\u0026gt;(); trie.put(\u0026#34;Abigail\u0026#34;, \u0026#34;student\u0026#34;); trie.put(\u0026#34;Abi\u0026#34;, \u0026#34;doctor\u0026#34;); trie.put(\u0026#34;Annabel\u0026#34;, \u0026#34;teacher\u0026#34;); trie.put(\u0026#34;Christina\u0026#34;, \u0026#34;student\u0026#34;); trie.put(\u0026#34;Chris\u0026#34;, \u0026#34;doctor\u0026#34;); Assertions.assertTrue(trie.containsKey(\u0026#34;Abigail\u0026#34;)); assertEquals(\u0026#34;{Abi=doctor, Abigail=student}\u0026#34;, trie.prefixMap(\u0026#34;Abi\u0026#34;).toString()); assertEquals(\u0026#34;{Chris=doctor, Christina=student}\u0026#34;, trie.prefixMap(\u0026#34;Chr\u0026#34;).toString()); Trie 树是一种利用空间换时间的数据结构,占用的内存会比较大。也正是因为这个原因,实际工程项目中都是使用的改进版 Trie 树例如双数组 Trie 树(Double-Array Trie,DAT)。\nDAT 的设计者是日本的 Aoe Jun-ichi,Mori Akira 和 Sato Takuya,他们在 1989 年发表了一篇论文 《An Efficient Implementation of Trie Structures》,详细介绍了 DAT 的构造和应用,原作者写的示例代码地址: https://github.com/komiya-atsushi/darts-java/blob/e2986a55e648296cc0a6244ae4a2e457cd89fb82/src/main/java/darts/DoubleArrayTrie.java。相比较于 Trie 树,DAT 的内存占用极低,可以达到 Trie 树内存的 1%左右。DAT 在中文分词、自然语言处理、信息检索等领域有广泛的应用,是一种非常优秀的数据结构。\nAC 自动机 # Aho-Corasick(AC)自动机是一种建立在 Trie 树上的一种改进算法,是一种多模式匹配算法,由贝尔实验室的研究人员 Alfred V. Aho 和 Margaret J.Corasick 发明。\nAC 自动机算法使用 Trie 树来存放模式串的前缀,通过失败匹配指针(失配指针)来处理匹配失败的跳转。关于 AC 自动机的详细介绍,可以查看这篇文章: 地铁十分钟 | AC 自动机。\n如果使用上面提到的 DAT 来表示 AC 自动机 ,就可以兼顾两者的优点,得到一种高效的多模式匹配算法。Github 上已经有了开源 Java 实现版本: https://github.com/hankcs/AhoCorasickDoubleArrayTrie 。\nDFA # DFA(Deterministic Finite Automata)即确定有穷自动机,与之对应的是 NFA(Non-Deterministic Finite Automata,不确定有穷自动机)。\n关于 DFA 的详细介绍可以看这篇文章: 有穷自动机 DFA\u0026amp;NFA (学习笔记) - 小蜗牛的文章 - 知乎 。\nHutool 提供了 DFA 算法的实现:\nWordTree wordTree = new WordTree(); wordTree.addWord(\u0026#34;大\u0026#34;); wordTree.addWord(\u0026#34;大憨憨\u0026#34;); wordTree.addWord(\u0026#34;憨憨\u0026#34;); String text = \u0026#34;那人真是个大憨憨!\u0026#34;; // 获得第一个匹配的关键字 String matchStr = wordTree.match(text); System.out.println(matchStr); // 标准匹配,匹配到最短关键词,并跳过已经匹配的关键词 List\u0026lt;String\u0026gt; matchStrList = wordTree.matchAll(text, -1, false, false); System.out.println(matchStrList); //匹配到最长关键词,跳过已经匹配的关键词 List\u0026lt;String\u0026gt; matchStrList2 = wordTree.matchAll(text, -1, false, true); System.out.println(matchStrList2); 输出:\n大 [大, 憨憨] [大, 大憨憨] 开源项目 # ToolGood.Words:一款高性能敏感词(非法词/脏字)检测过滤组件,附带繁体简体互换,支持全角半角互换,汉字转拼音,模糊搜索等功能。 sensitive-words-filter:敏感词过滤项目,提供 TTMP、DFA、DAT、hash bucket、Tire 算法支持过滤。可以支持文本的高亮、过滤、判词、替换的接口支持。 论文 # 一种敏感词自动过滤管理系统 一种网络游戏中敏感词过滤方法及系统 "},{"id":644,"href":"/zh/docs/technology/Interview/high-quality-technical-articles/interview/summary-of-spring-recruitment/","title":"普通人的春招总结(阿里、腾讯offer)","section":"Interview","content":" 推荐语:牛客网热帖,写的很全面!暑期实习,投了阿里、腾讯、字节,拿到了阿里和腾讯的 offer。\n原文地址: https://www.nowcoder.com/discuss/640519\n下篇: 十年饮冰,难凉热血——秋招总结\n背景 # 写这篇文章的时候,腾讯 offer 已经下来了,春招也算结束了,这次找暑期实习没有像去年找日常实习一样海投,只投了 BAT 三家,阿里和腾讯收获了 offer,字节没有给面试机会,可能是笔试太拉垮了。\n楼主大三,双非本科,我的春招的起始时间应该是 2 月 20 日到 3 月 23 日收到阿里意向书为止,但是从 3 月 7 日蚂蚁技术终面面完之后就没有面过技术面了,只面过两个 HR 面,剩下的时间都在等 offer。最开始是找朋友内推了字节财经的日常实习,但是到现在还在简历评估,后面又投了财经的暑期实习,笔试之后就一直卡在流程里了。腾讯是一开始被天美捞了,一面挂了之后被 PCG 捞了,最后走完了流程。阿里提前批投了好多部门,蚂蚁最先走完了终面,就录入了系统,最后拿了 offer。这一路走过来真的是酸甜苦辣都经历过,因为学历自卑过,以至于想去考研。总而言之,一定要找一个搭档和你一起复习,比如说 @你怕是个憨批哦,这是我实验室的同学,也是我们实验室的队长,这个人是真的强,阿里核心部门都拿遍了,他在我复习的过程中给了我很多帮助。\n写这个帖子的目的 # 写给自己:总结反思一下大学前三年以及找工作的一些经历与感悟。 写给还在找实习的朋友:希望自己的经历以及面经]能给你们一些启发和帮助。 写给和我一样有着大厂梦的学弟学妹们:你们还有很长的准备时间,无论你之前在干什么,没有目标也好,碌碌无为也好,没找对方向也好,只要从现在开始,找对学习的方向,并且坚持不懈的学上一年两年,一定可以实现你的梦想的。 我的大学经历 # 先简单聊聊一下自己大学的经历。\n本人无论文、无比赛、无 ACM,要啥奖没啥奖,绩点还行,不是很拉垮,也不亮眼。保研肯定保不了,考研估计也考不上。\n大一时候加入了工作室,上学期自学了 C 语言和数据结构,从寒假开始学 Java,当时还不知道 Java 那么卷,我得到的消息是 Java 好找工作,这里就不由得感叹信息差的重要性了,我当时只知道前端、后端和安卓开发,而我确实对后端开发感兴趣,但是因为信息差,我只知道 Java 可以做后端开发,并不知道后端开发其实是一个很局限的概念,后面才慢慢了解到后台开发、服务端开发这些名词,也不知道 C++、Golang 等语言也可以做后台开发,所以就学了 Java。但其实 Java 更适合做业务,C++ 更适合做底层开发、服务端开发,我虽然对业务不反感,但是对 OS、Network 这些更感兴趣一些,当然这些会作为我的一些兴趣,业余时间会自己去研究下。\n学习路线 # 大概学习的路线就是:Java SE 基础 -\u0026gt; MySQL -\u0026gt; Java Web(主要包括 JDBC、Servlet、JSP 等)-\u0026gt; SSM(其实当时 Spring Boot 已经兴起,但是我觉得没有 SSM 基础很难学会 Spring Boot,就先学了 SSM)-\u0026gt; Spring Boot -\u0026gt; Spring Cloud(当时虽然学了 Spring Cloud,但是缺少项目的锤炼,完全不会用,只是了解了分布式的一些概念)-\u0026gt; Redis -\u0026gt; Nginx -\u0026gt; 计算机网络(本来是计算机专业的必修课,可是我们专业要到大三下才学,所以就提前自学了)-\u0026gt; Dubbo -\u0026gt; Zookeeper -\u0026gt; JVM -\u0026gt; JUC -\u0026gt; Netty -\u0026gt; Rabbit MQ -\u0026gt; 操作系统(同计算机网络)-\u0026gt; 计算机组成原理(直接不开这门课)。\n这就是我的一个具体的学习路线,大概是在大二的下学期学完的这些东西,都是通过看视频学的,只会用,并不了解底层原理,达不到面试八股文的水准,把这些东西学完之后,搭建起了知识体系,就开始准备面试了,大概的开始时间是去年的六月份,开始在牛客网上看一些面经,然后会自己总结。准备面试的阶段我觉得最重要的是啃书 + 刷题,八股文只是辅助,我们只是自嘲说面试就背背八股文,但其实像阿里这样的公司,背八股文是完全不能蒙混过关的,除非你有非常亮眼的项目或者实习经历。\n书籍推荐 # 《Thinking in Java》:不多说了,好书,但太厚了,买了没看。 《深入理解 Java 虚拟机》:JVM 的圣经,看了两遍,每一遍都有不同的收获。 《Java 并发编程的艺术》:阿里人写的,基本涵盖了面试会问的并发编程的问题。 《MySQL 技术内幕》:写的很深入,但是对初学者可能不太友好,第一感觉写的比较深而杂,后面单独去看每一章节,觉得收获很大。 《Redis 设计与实现》:书如其名,结合源码深入讲解了 Redis 的实现原理,必看。 《深入理解计算机系统》:大名鼎鼎的 CSAPP,对你面 Java 可能帮助不是很大,但是不得不说这是一本经典,涵盖了计算机系统、体系结构、组成原理、操作系统等知识,我蚂蚁二面的时候就被问了遇到的最大的困难,我就和面试官交流了读这本书中遇到的一些问题,淘系二面的时候也和面试官交流了这本书,我们都觉得这本书还需要二刷。 《TCP/IP 详解卷 1》:我只看了 TCP 相关的章节,但是是有必要通读一遍的,面天美时候和面试官交流了这本书。 《操作系统导论》:颇具盛名的 OSTEP,南大操作系统的课本,看的时候可以结合在 B 站蒋炎岩老师的视频,我会在下面放链接。 这几本书理解透彻了,我相信面试的时候可以面试官面试官聊的很深入了,面试官也会对你印象非常好。但是对于普通人来说,看一遍是肯定记不住的,遗忘是非常正常的现象,我很多也只看了一遍,很多细节也记不清了,最近准备二刷。\n更多书籍推荐建议大家看 JavaGuide 这个网站上的书籍推荐,比较全面。\n教程推荐 # 我上面谈到的学习路线,我建议是跟着视频学,尚硅谷和黑马的教程都可以,一定要手敲一遍。\n2021 南京大学 “操作系统:设计与实现” (蒋炎岩):我不多说了,看评论就知道了。 SpringSecurity-Social-OAuth2 社交登录接口授权鉴权系列课程:字母哥讲的 Spring Security 也很好,Spring Security 或者 Shiro 是做项目必备的,会一个就好,根据实际场景以及个人喜好(笑)来选型。 清华大学邓俊辉数据结构与算法:清华不解释了。 MySQL 实战 45 讲:前 27 讲多看几遍基本可以秒杀面试中遇到的 MySQL 问题了。 Redis 核心技术与实战:讲解了大量的 Redis 在生产上的使用场景,和《Redis 设计与实现》配合着看,也可以秒杀面试中遇到的 Redis 问题了。 JavaGuide:「Java 学习+面试指南」一份涵盖大部分 Java 程序员所需要掌握的核心知识。 《Java 面试指北》:这是一份教你如何更高效地准备面试的小册,涵盖常见八股文(系统设计、常见框架、分布式、高并发 ……)、优质面经等内容。 找工作 # 大概是去年 11 月的时候,牛客上日常实习的面经开始多了起来,我也有了找实习的意识,然后就开始一边复习一边海投,投了很多公司,给面试机会的就那几家,腾讯二面挂了两次,当时心态完全崩了,甚至有了看空春招的想法。很幸运最后收获了一个实习机会,在实习的时候,除了完成日常的工作以外,其余时间也没有松懈,晚上下班后、周末的时间都用来复习,心里也暗暗下定决心,春招一定要卷土重来!\n从二月下旬开始海投阿里提前批,基本都有了面试,开系统那天收到了 16 封内推邮件,具体的面经可以看我以前发的文章。\n从 3.1 到 3.7 那一个周平均每天三场面试,真的非常崩溃,一度想考研,也焦虑过、哭过、笑过,还好结果是好的,最后也去了一直想去的支付宝。\n我主要是想通过自己对面试过程的总结给大家提一些建议,大佬不喜勿喷。\n面试准备 # 要去面试首先要准备一份简历,我个人认为一份好的简历应该有一下三个部分:\n完整的个人信息,这个不多说了吧,个人信息不完整面试官或 HR 都联系不上你,就算学校不好也要写上去,因为听说有些公司没有学校无法进行简历评估,非科班或者说学校不太出名可以将教育信息写在最下面。 项目/实习经历,项目真的很重要,面试大部分时间会围绕着项目来,你项目准备好了可以把控面试的节奏,引导面试官问你擅长的方向,我就是在这方面吃了亏。如果没有项目怎么办,可以去 GitHub 上找一些开源的项目,自己跟着做一遍,加入一些自己的思考和理解。还有做项目不能简单实现功能,还要考虑性能和优化,面试官并不关注你这个功能是怎么实现的,他想知道的是你是如何一步步思考的,一开始的方案是什么,后面选了什么方案,对性能有哪些提升,还能再改进吗? 具备的专业技能,这个可以简单的写一下你学过的专业知识,这样可以让面试官有针对的问一些基础知识,切忌长篇罗列,最擅长的一定要写在上面,依次往下。 简历写好了之后就进入了投递环节,最好找一个靠谱的内推人,因为内推人可以帮你跟进面试的进度,必要时候和 HR 沟通,哪怕挂了也可以告诉你原因,哪些方面表现的不好。现在内推已经不再是门槛,而是最低的入场券,没有认识的人内推也可以在牛客上找一些师兄内推,他们往往也很热情。\n在面试过程中一定不要紧张,因为一面面试官可能比我们大不了几岁,也工作没几年,所以 duck 不必紧张的不会说话,不会就说不会,然后笑一下,会就流利的表达出来,面试并不是一问一答,面试是沟通,是交流,你可以大胆的说出自己的思考,表达沟通能力也是面试的一个衡量指标。\n我个人认为面试和追妹子是差不多的,都是尽快的让对方了解自己,发现你身上的闪光点,只不过面试是让面试官了解你在技术上的造诣。所以,自我介绍环节就变得非常重要,你可以简单介绍完自己的个人信息之后,介绍一下你做过的项目,自我介绍最好长一些,因为在面试前,面试官可能没看过你的简历(逃),你最好留给面试官充足的时间去看你的简历。自我介绍包括项目的介绍可以写成一遍文档,多读几遍,在面试的时候能够背下来,实在不行也可以照着读。\n项目 # 我还是要重点讲一下项目,我以前认为项目是一个不确定性非常大的地方,后来经过面试才知道项目是最容易带面试官节奏的地方。问项目的意义是通过项目来问基础知识,所以就要求你对自己的项目非常熟悉,考虑各种极端情况以及优化方案,熟悉用到的中间件原理,以及这些中间件是如何处理这些情况的,比如说,MQ 的宕机恢复,Redis 集群、哨兵,缓存雪崩、缓存击穿、缓存穿透等。\n优化主要可以从缓存、MQ 解耦、加索引、多线程、异步任务、用 ElasticSearch 做检索等方面考虑,我认为项目优化主要的着手点就是减少数据库的访问量,减少同步调用的次数,比如说加缓存、用 ElasticSearch 做检索就是通过减少数据库的访问来实现的优化,MQ 解耦、异步任务等就是通过减少同步调用的次数来实现的优化。\n项目中还可以学到很多东西,比如下面的这些就是通过项目来学习的:\n权限控制(ABAC、RBAC) JWT 单点登录 分库分表 分片上传/导出 分布式锁 负载均衡 当然还有很多东西,每个人的项目不一样,能学到的东西也天差地别,但是你要相信的是,你接触到的东西,面试官应该是都会的,所以一定要好好准备,不然容易被怼。\n本质上来讲,项目也可以拆解成八股文,可以用准备基础知识的方式来准备项目。\n算法 # 项目的八股文化,会进一步导致无法准确的甄选候选人,所以就到了面试的第三个衡量标准,那就是算法,我曾经在反问阶段问过面试官刷算法对哪些方面有帮助,面试官直截了当的对我说,刷题对你以后找工作有帮助。我的观点是算法其实也是可以通过记忆来提高的,LeetCode 前 200 道题能刷上 3 遍,我不信面试时候还能手撕不了,所以在复习的过程中一定要保持算法的训练。\n面试建议 # 自我介绍尽量丰富一下,项目提前准备好如何介绍。 在面试的时候,遇到不会的问题最好不要直接说不会,然后愣着,等面试官问下一个问题,你可以说自己对这方面不太了解,但是对 XX 有一些了解,然后讲一下,如果面试官感兴趣,你就可以继续说,不感兴趣他就会问下一个问题,面试官一般是不会打断的,这也是让面试官快速了解你的一个小技巧。 尽量向面试官展示你的技术热情,比如说你可以和面试官聊 Java 每个版本的新特性,最近技术圈的一些新闻等等,因为就我所知,技术热情也是阿里面试考察的一方面。 面试是一个双向选择的过程,不要表现的太过去谄媚。 好好把握好反问阶段,问一些有价值的内容,比如说新人培养机制、转正机制等。 经验 # 如果你现在大一,OK,我希望你能多了解一下互联网就业的方向,看看自己的兴趣在哪,先把基础打好,比如说数据结构、操作性、计算机网络、计算机组成原理,因为这四门课既是大部分学校考研的专业课,也是面试中常常会被问到的问题。 如果已经大二了,那就要明确自己的方向,要有自驱力,知道你学习的这个方向都要学哪些知识,学到什么程度能够就业,合理安排好时间,知道自己在什么阶段要达到什么样的水准。 如果你学历比较吃亏,亦或是非科班出身,那么我建议你一定要付出超过常人的努力,因为在我混迹牛客这么多年,我看到的面经一般是学校好一些的问的简单一些,相对差一些的问的难一些,其实也可以理解,毕竟普遍上来说名校出身的综合实力要强一些。 尽量早点实习,如果你现在大二,已经有了能够实习的水平,我建议你早点投简历,尽量找暑期实习,你相信我,如果你这个暑假去实习了,明年一定是乱杀。 接上条,如果找不到实习,尽量要做几个有挑战的项目,并且找到这个项目的抓手。 多刷刷牛客,我在牛客上就认识了很多志同道合的人,他们在我找工作过程中给了我很多帮助。 建议 # 一定要抱团取暖,一起找工作的同学可以拉一个群,无论是自己学校的还是网上认识的,平常多交流复习心得,n 个 1 相加的和一定是大于 n 的。 知识的深度和广度都很重要,平常一定要多了解新技术,而且每学一门技术一定要争取了解它的原理,不然你学的不算是计算机,而是英语系,工作职位也不是研发工程师,而是 API 调用工程师。 运营好自己的 CSDN、掘金等博客平台,我有个学弟大二是 CSDN 博客专家,已经有猎头联系他了,平常写的代码尽量都提交到 GitHub 上,无论是项目也好,实验也好,如果有能力的话最好能录制一些视频发到哔哩哔哩上,因为这是面试官在面试你之前了解你表达能力的一个重要途径。 心态一定要好,面试不顺利,不一定是你的能力问题,也可能是因为他们招人很少,或者说某一些客观条件与他们不匹配,一定要多尝试不同的选择。 多和人沟通交流,不要自己埋头苦干,因为你以后进公司里也需要和别人合作,所以表达和沟通能力是一项基本的技能,要提前培养。 闲聊 # 谈谈信息差 # 我觉得学校的差距并不只是体现在教学水平上,诚然名校的老师讲课水平、实验水平都是高于弱校的,但是信息差才是主要的差距。在 985 学校里面读书,不仅能接触到更多优质企业的校招宣讲、讲座,还能接触到更好的就业氛围,因为名校里面去大厂、去外企的人、甚至出国的人更多,学长学姐的内推只是一方面,另一方面是你可以从他们身上学到技术以外的东西,而双非学校去大厂的人少,他们能影响的只是很少一部分人,这就是信息差。信息差的劣势主要体现在哪些方面呢?比如人家大二已经开始找日常实习了,而你认为找工作是大四的事情,人家大三已经找到暑期实习了,你暑假还需要去参加学校组织的培训,一步步的就这样拉下了。\n好在,互联网的出现让信息更加透明,你可以在网上检索各种各样你想要的信息,比如我就在牛客]上认识了一些志同道合的朋友,他们在找工作的过程中给了我很多帮助。平常可以多刷刷牛客,能够有效的减小信息差。\n谈谈 Java 的内卷 # Java 卷吗?毫无疑问,很卷,我个人认为开发属于没有什么门槛的工作,本科生来干正合适,但是因为算法岗更是神仙打架,导致很多的研究生也转了开发,而且基本都转了 Java 开发。Java 的内卷只是这个原因造成的吗?当然不是,我认为还有一个原因就是培训机构的兴起,让这个行业的门槛进一步降低,你要学什么东西,怎么学,都有人给你安排好了,这是造成内卷的第二个原因。第三个原因就是非科班转码,其它行业的凋落和互联网行业的繁荣形成了鲜明对比,导致很多其它专业的人也自学计算机,找互联网的工作,导致这个行业的人越来越多,蛋糕就那么大,分蛋糕的人却越来越多。\n其实内卷也不一定是个坏现象,这说明阶级上升的通道还没有完全关闭,还是有不少人愿意通过努力来改变现状,这也一定程度上会加快行业的发展,社会的发展。选择权在你自己手上,你可以选择回老家躺平或者进互联网公司内卷,如果选择后者的话,我的建议还是尽早占下坑位,因为唯一不变的是变化,你永远不知道三年后是什么样子。\n祝福 # 惟愿诸君,前程似锦!\n"},{"id":645,"href":"/zh/docs/technology/Interview/system-design/security/design-of-authority-system/","title":"权限系统设计详解","section":"Security","content":" 作者:转转技术团队\n原文: https://mp.weixin.qq.com/s/ONMuELjdHYa0yQceTj01Iw\n老权限系统的问题与现状 # 转转公司在过去并没有一个统一的权限管理系统,权限管理由各业务自行研发或是使用其他业务的权限系统,权限管理的不统一带来了不少问题:\n各业务重复造轮子,维护成本高 各系统只解决部分场景问题,方案不够通用,新项目选型时没有可靠的权限管理方案 缺乏统一的日志管理与审批流程,在授权信息追溯上十分困难 基于上述问题,去年底公司启动建设转转统一权限系统,目标是开发一套灵活、易用、安全的权限管理系统,供各业务使用。\n业界权限系统的设计方式 # 目前业界主流的权限模型有两种,下面分别介绍下:\n基于角色的访问控制(RBAC) 基于属性的访问控制(ABAC) RBAC 模型 # 基于角色的访问控制(Role-Based Access Control,简称 RBAC) 指的是通过用户的角色(Role)授权其相关权限,实现了灵活的访问控制,相比直接授予用户权限,要更加简单、高效、可扩展。\n一个用户可以拥有若干角色,每一个角色又可以被分配若干权限这样,就构造成“用户-角色-权限” 的授权模型。在这种模型中,用户与角色、角色与权限之间构成了多对多的关系。\n用一个图来描述如下:\n当使用 RBAC模型 时,通过分析用户的实际情况,基于共同的职责和需求,授予他们不同角色。这种 用户 -\u0026gt; 角色 -\u0026gt; 权限 间的关系,让我们可以不用再单独管理单个用户权限,用户从授予的角色里面获取所需的权限。\n以一个简单的场景(Gitlab 的权限系统)为例,用户系统中有 Admin、Maintainer、Operator 三种角色,这三种角色分别具备不同的权限,比如只有 Admin 具备创建代码仓库、删除代码仓库的权限,其他的角色都不具备。我们授予某个用户 Admin 这个角色,他就具备了 创建代码仓库 和 删除代码仓库 这两个权限。\n通过 RBAC模型 ,当存在多个用户拥有相同权限时,我们只需要创建好拥有该权限的角色,然后给不同的用户分配不同的角色,后续只需要修改角色的权限,就能自动修改角色内所有用户的权限。\nABAC 模型 # 基于属性的访问控制(Attribute-Based Access Control,简称 ABAC) 是一种比 RBAC模型 更加灵活的授权模型,它的原理是通过各种属性来动态判断一个操作是否可以被允许。这个模型在云系统中使用的比较多,比如 AWS,阿里云等。\n考虑下面这些场景的权限控制:\n授权某个人具体某本书的编辑权限 当一个文档的所属部门跟用户的部门相同时,用户可以访问这个文档 当用户是一个文档的拥有者并且文档的状态是草稿,用户可以编辑这个文档 早上九点前禁止 A 部门的人访问 B 系统 在除了上海以外的地方禁止以管理员身份访问 A 系统 用户对 2022-06-07 之前创建的订单有操作权限 可以发现上述的场景通过 RBAC模型 很难去实现,因为 RBAC模型 仅仅描述了用户可以做什么操作,但是操作的条件,以及操作的数据,RBAC模型 本身是没有这些限制的。但这恰恰是 ABAC模型 的长处,ABAC模型 的思想是基于用户、访问的数据的属性、以及各种环境因素去动态计算用户是否有权限进行操作。\nABAC 模型的原理 # 在 ABAC模型 中,一个操作是否被允许是基于对象、资源、操作和环境信息共同动态计算决定的。\n对象:对象是当前请求访问资源的用户。用户的属性包括 ID,个人资源,角色,部门和组织成员身份等 资源:资源是当前用户要访问的资产或对象,例如文件,数据,服务器,甚至 API 操作:操作是用户试图对资源进行的操作。常见的操作包括“读取”,“写入”,“编辑”,“复制”和“删除” 环境:环境是每个访问请求的上下文。环境属性包含访问的时间和位置,对象的设备,通信协议和加密强度等 在 ABAC模型 的决策语句的执行过程中,决策引擎会根据定义好的决策语句,结合对象、资源、操作、环境等因素动态计算出决策结果。每当发生访问请求时,ABAC模型 决策系统都会分析属性值是否与已建立的策略匹配。如果有匹配的策略,访问请求就会被通过。\n新权限系统的设计思想 # 结合转转的业务现状,RBAC模型 满足了转转绝大部分业务场景,并且开发成本远低于 ABAC模型 的权限系统,所以新权限系统选择了基于 RBAC模型 来实现。对于实在无法满足的业务系统,我们选择了暂时性不支持,这样可以保障新权限系统的快速落地,更快的让业务使用起来。\n标准的 RBAC模型 是完全遵守 用户 -\u0026gt; 角色 -\u0026gt; 权限 这个链路的,也就是用户的权限完全由他所拥有的角色来控制,但是这样会有一个缺点,就是给用户加权限必须新增一个角色,导致实际操作起来效率比较低。所以我们在 RBAC模型 的基础上,新增了给用户直接增加权限的能力,也就是说既可以给用户添加角色,也可以给用户直接添加权限。最终用户的权限是由拥有的角色和权限点组合而成。\n新权限系统的权限模型:用户最终权限 = 用户拥有的角色带来的权限 + 用户独立配置的权限,两者取并集。\n新权限系统方案如下图:\n首先,将集团所有的用户(包括外部用户),通过 统一登录与注册 功能实现了统一管理,同时与公司的组织架构信息模块打通,实现了同一个人员在所有系统中信息的一致,这也为后续基于组织架构进行权限管理提供了可行性。 其次,因为新权限系统需要服务集团所有业务,所以需要支持多系统权限管理。用户进行权限管理前,需要先选择相应的系统,然后配置该系统的 菜单权限 和 数据权限 信息,建立好系统的各个权限点。PS:菜单权限和数据权限的具体说明,下文会详细介绍。 最后,创建该系统下的不同角色,给不同角色配置好权限点。比如店长角色,拥有店员操作权限、本店数据查看权限等,配置好这个角色后,后续只需要给店长增加这个角色,就可以让他拥有对应的权限。 完成上述配置后,就可以进行用户的权限管理了。有两种方式可以给用户加权限:\n先选用户,然后添加权限。该方式可以给用户添加任意角色或是菜单/数据权限点。 先选择角色,然后关联用户。该方式只可给用户添加角色,不能单独添加菜单/数据权限点。 这两种方式的具体设计方案,后文会详细说明。\n权限系统自身的权限管理 # 对于权限系统来说,首先需要设计好系统自身的权限管理,也就是需要管理好 ”谁可以进入权限系统,谁可以管理其他系统的权限“,对于权限系统自身的用户,会分为三类:\n超级管理员:拥有权限系统的全部操作权限,可以进行系统自身的任何操作,也可以管理接入权限的应用系统的管理操作。 权限操作用户:拥有至少一个已接入的应用系统的超级管理员角色的用户。该用户能进行的操作限定在所拥有的应用系统权限范围内。权限操作用户是一种身份,无需分配,而是根据规则自动获得的。 普通用户:普通用户也可以认为是一种身份,除去上述 2 类人,其余的都为普通用户。他们只能申请接入系统以及访问权限申请页面。 权限类型的定义 # 新权限系统中,我们把权限分为两大类,分别是:\n菜单功能权限:包括系统的目录导航、菜单的访问权限,以及按钮和 API 操作的权限 数据权限:包括定义数据的查询范围权限,在不同系统中,通常叫做 “组织”、”站点“等,在新权限系统中,统一称作 ”组织“ 来管理数据权限 默认角色的分类 # 每个系统中设计了三个默认角色,用来满足基本的权限管理需求,分别如下:\n超级管理员:该角色拥有该系统的全部权限,可以修改系统的角色权限等配置,可以给其他用户授权。 系统管理员:该角色拥有给其他用户授权以及修改系统的角色权限等配置能力,但角色本身不具有任何权限。 授权管理员:该角色拥有给其他用户授权的能力。但是授权的范围不超出自己所拥有的权限。 举个栗子:授权管理员 A 可以给 B 用户添加权限,但添加的范围 小于等于 A 用户已拥有的权限。\n经过这么区分,把 拥有权限 和 拥有授权能力 ,这两部分给分隔开来,可以满足所有的权限控制的场景。\n新权限系统的核心模块设计 # 上面介绍了新权限系统的整体设计思想,接下来分别介绍下核心模块的设计\n系统/菜单/数据权限管理 # 把一个新系统接入权限系统有下列步骤:\n创建系统 配置菜单功能权限 配置数据权限(可选) 创建系统的角色 其中,1、2、3 的步骤,都是在系统管理模块完成,具体流程如下图:\n用户可以对系统的基本信息进行增删改查的操作,不同系统之间通过 系统编码 作为唯一区分。同时 系统编码 也会用作于菜单和数据权限编码的前缀,通过这样的设计保证权限编码全局唯一性。\n例如系统的编码为 test_online,那么该系统的菜单编码格式便为 test_online:m_xxx。\n系统管理界面设计如下:\n菜单管理 # 新权限系统首先对菜单进行了分类,分别是 目录、菜单 和 操作,示意如下图\n它们分别代表的含义是:\n目录:指的是应用系统中最顶部的一级目录,通常在系统 Logo 的右边 菜单:指的是应用系统左侧的多层级菜单,通常在系统 Logo 的下面,也是最常用的菜单结构 操作:指页面中的按钮、接口等一系列可以定义为操作或页面元素的部分。 菜单管理界面设计如下:\n菜单权限数据的使用,也提供两种方式:\n动态菜单模式:这种模式下,菜单的增删完全由权限系统接管。也就是说在权限系统增加菜单,应用系统会同步增加。这种模式好处是修改菜单无需项目上线。 静态菜单模式:菜单的增删由应用系统的前端控制,权限系统只控制访问权限。这种模式下,权限系统只能标识出用户是否拥有当前菜单的权限,而具体的显示控制是由前端根据权限数据来决定。 角色与用户管理 # 角色与用户管理都是可以直接改变用户权限的核心模块,整个设计思路如下图:\n这个模块设计重点是需要考虑到批量操作。无论是通过角色关联用户,还是给用户批量增加/删除/重置权限,批量操作的场景都是系统需要设计好的。\n权限申请 # 除了给其他用户添加权限外,新权限系统同时支持了用户自主申请权限。这个模块除了常规的审批流(申请、审批、查看)等,有一个比较特别的功能,就是如何让用户能选对自己要的权限。所以在该模块的设计上,除了直接选择角色外,还支持通过菜单/数据权限点,反向选择角色,如下图:\n操作日志 # 系统操作日志会分为两大类:\n操作流水日志:用户可看、可查的关键操作日志 服务 Log 日志:系统服务运行过程中产生的 Log 日志,其中,服务 Log 日志信息量大于操作流水日志,但是不方便搜索查看。所以权限系统需要提供操作流水日志功能。 在新权限系统中,用户所有的操作可以分为三类,分别为新增、更新、删除。所有的模块也可枚举,例如用户管理、角色管理、菜单管理等。明确这些信息后,那么一条日志就可以抽象为:什么人(Who)在什么时间(When)对哪些人(Target)的哪些模块做了哪些操作。 这样把所有的记录都入库,就可以方便的进行日志的查看和筛选了。\n总结与展望 # 至此,新权限系统的核心设计思路与模块都已介绍完成,新系统在转转内部有大量的业务接入使用,权限管理相比以前方便了许多。权限系统作为每家公司的一个基础系统,灵活且完备的设计可以助力日后业务的发展更加高效。\n后续两篇:\n转转统一权限系统的设计与实现(后端实现篇) 转转统一权限系统的设计与实现(前端实现篇) 参考 # 选择合适的权限模型: https://docs.authing.cn/v2/guides/access-control/choose-the-right-access-control-model.html "},{"id":646,"href":"/zh/docs/technology/Interview/system-design/security/basis-of-authority-certification/","title":"认证授权基础概念详解","section":"Security","content":" 认证 (Authentication) 和授权 (Authorization)的区别是什么? # 这是一个绝大多数人都会混淆的问题。首先先从读音上来认识这两个名词,很多人都会把它俩的读音搞混,所以我建议你先先去查一查这两个单词到底该怎么读,他们的具体含义是什么。\n说简单点就是:\n认证 (Authentication): 你是谁。 授权 (Authorization): 你有权限干什么。 稍微正式点(啰嗦点)的说法就是:\nAuthentication(认证) 是验证您的身份的凭据(例如用户名/用户 ID 和密码),通过这个凭据,系统得以知道你就是你,也就是说系统存在你这个用户。所以,Authentication 被称为身份/用户验证。 Authorization(授权) 发生在 Authentication(认证) 之后。授权嘛,光看意思大家应该就明白,它主要掌管我们访问系统的权限。比如有些特定资源只能具有特定权限的人才能访问比如 admin,有些对系统资源操作比如删除、添加、更新只能特定人才具有。 认证:\n授权:\n这两个一般在我们的系统中被结合在一起使用,目的就是为了保护我们系统的安全性。\nRBAC 模型了解吗? # 系统权限控制最常采用的访问控制模型就是 RBAC 模型 。\n什么是 RBAC 呢? RBAC 即基于角色的权限访问控制(Role-Based Access Control)。这是一种通过角色关联权限,角色同时又关联用户的授权的方式。\n简单地说:一个用户可以拥有若干角色,每一个角色又可以被分配若干权限,这样就构造成“用户-角色-权限” 的授权模型。在这种模型中,用户与角色、角色与权限之间构成了多对多的关系。\n在 RBAC 权限模型中,权限与角色相关联,用户通过成为包含特定角色的成员而得到这些角色的权限,这就极大地简化了权限的管理。\n为了实现 RBAC 权限模型,数据库表的常见设计如下(一共 5 张表,2 张用户建立表之间的联系):\n通过这个权限模型,我们可以创建不同的角色并为不同的角色分配不同的权限范围(菜单)。\n通常来说,如果系统对于权限控制要求比较严格的话,一般都会选择使用 RBAC 模型来做权限控制。\n什么是 Cookie ? Cookie 的作用是什么? # Cookie 和 Session 都是用来跟踪浏览器用户身份的会话方式,但是两者的应用场景不太一样。\n维基百科是这样定义 Cookie 的:\nCookies 是某些网站为了辨别用户身份而储存在用户本地终端上的数据(通常经过加密)。\n简单来说:Cookie 存放在客户端,一般用来保存用户信息。\n下面是 Cookie 的一些应用案例:\n我们在 Cookie 中保存已经登录过的用户信息,下次访问网站的时候页面可以自动帮你登录的一些基本信息给填了。除此之外,Cookie 还能保存用户首选项,主题和其他设置信息。 使用 Cookie 保存 SessionId 或者 Token ,向后端发送请求的时候带上 Cookie,这样后端就能取到 Session 或者 Token 了。这样就能记录用户当前的状态了,因为 HTTP 协议是无状态的。 Cookie 还可以用来记录和分析用户行为。举个简单的例子你在网上购物的时候,因为 HTTP 协议是没有状态的,如果服务器想要获取你在某个页面的停留状态或者看了哪些商品,一种常用的实现方式就是将这些信息存放在 Cookie …… 如何在项目中使用 Cookie 呢? # 我这里以 Spring Boot 项目为例。\n1)设置 Cookie 返回给客户端\n@GetMapping(\u0026#34;/change-username\u0026#34;) public String setCookie(HttpServletResponse response) { // 创建一个 cookie Cookie cookie = new Cookie(\u0026#34;username\u0026#34;, \u0026#34;Jovan\u0026#34;); //设置 cookie过期时间 cookie.setMaxAge(7 * 24 * 60 * 60); // expires in 7 days //添加到 response 中 response.addCookie(cookie); return \u0026#34;Username is changed!\u0026#34;; } 2) 使用 Spring 框架提供的 @CookieValue 注解获取特定的 cookie 的值\n@GetMapping(\u0026#34;/\u0026#34;) public String readCookie(@CookieValue(value = \u0026#34;username\u0026#34;, defaultValue = \u0026#34;Atta\u0026#34;) String username) { return \u0026#34;Hey! My username is \u0026#34; + username; } 3) 读取所有的 Cookie 值\n@GetMapping(\u0026#34;/all-cookies\u0026#34;) public String readAllCookies(HttpServletRequest request) { Cookie[] cookies = request.getCookies(); if (cookies != null) { return Arrays.stream(cookies) .map(c -\u0026gt; c.getName() + \u0026#34;=\u0026#34; + c.getValue()).collect(Collectors.joining(\u0026#34;, \u0026#34;)); } return \u0026#34;No cookies\u0026#34;; } 更多关于如何在 Spring Boot 中使用 Cookie 的内容可以查看这篇文章: How to use cookies in Spring Boot 。\nCookie 和 Session 有什么区别? # Session 的主要作用就是通过服务端记录用户的状态。 典型的场景是购物车,当你要添加商品到购物车的时候,系统不知道是哪个用户操作的,因为 HTTP 协议是无状态的。服务端给特定的用户创建特定的 Session 之后就可以标识这个用户并且跟踪这个用户了。\nCookie 数据保存在客户端(浏览器端),Session 数据保存在服务器端。相对来说 Session 安全性更高。如果使用 Cookie 的一些敏感信息不要写入 Cookie 中,最好能将 Cookie 信息加密然后使用到的时候再去服务器端解密。\n那么,如何使用 Session 进行身份验证?\n如何使用 Session-Cookie 方案进行身份验证? # 很多时候我们都是通过 SessionID 来实现特定的用户,SessionID 一般会选择存放在 Redis 中。举个例子:\n用户成功登陆系统,然后返回给客户端具有 SessionID 的 Cookie 。 当用户向后端发起请求的时候会把 SessionID 带上,这样后端就知道你的身份状态了。 关于这种认证方式更详细的过程如下:\n用户向服务器发送用户名、密码、验证码用于登陆系统。 服务器验证通过后,服务器为用户创建一个 Session,并将 Session 信息存储起来。 服务器向用户返回一个 SessionID,写入用户的 Cookie。 当用户保持登录状态时,Cookie 将与每个后续请求一起被发送出去。 服务器可以将存储在 Cookie 上的 SessionID 与存储在内存中或者数据库中的 Session 信息进行比较,以验证用户的身份,返回给用户客户端响应信息的时候会附带用户当前的状态。 使用 Session 的时候需要注意下面几个点:\n依赖 Session 的关键业务一定要确保客户端开启了 Cookie。 注意 Session 的过期时间。 另外,Spring Session 提供了一种跨多个应用程序或实例管理用户会话信息的机制。如果想详细了解可以查看下面几篇很不错的文章:\nGetting Started with Spring Session Guide to Spring Session Sticky Sessions with Spring Session \u0026amp; Redis 多服务器节点下 Session-Cookie 方案如何做? # Session-Cookie 方案在单体环境是一个非常好的身份认证方案。但是,当服务器水平拓展成多节点时,Session-Cookie 方案就要面临挑战了。\n举个例子:假如我们部署了两份相同的服务 A,B,用户第一次登陆的时候 ,Nginx 通过负载均衡机制将用户请求转发到 A 服务器,此时用户的 Session 信息保存在 A 服务器。结果,用户第二次访问的时候 Nginx 将请求路由到 B 服务器,由于 B 服务器没有保存 用户的 Session 信息,导致用户需要重新进行登陆。\n我们应该如何避免上面这种情况的出现呢?\n有几个方案可供大家参考:\n某个用户的所有请求都通过特性的哈希策略分配给同一个服务器处理。这样的话,每个服务器都保存了一部分用户的 Session 信息。服务器宕机,其保存的所有 Session 信息就完全丢失了。 每一个服务器保存的 Session 信息都是互相同步的,也就是说每一个服务器都保存了全量的 Session 信息。每当一个服务器的 Session 信息发生变化,我们就将其同步到其他服务器。这种方案成本太大,并且,节点越多时,同步成本也越高。 单独使用一个所有服务器都能访问到的数据节点(比如缓存)来存放 Session 信息。为了保证高可用,数据节点尽量要避免是单点。 Spring Session 是一个用于在多个服务器之间管理会话的项目。它可以与多种后端存储(如 Redis、MongoDB 等)集成,从而实现分布式会话管理。通过 Spring Session,可以将会话数据存储在共享的外部存储中,以实现跨服务器的会话同步和共享。 如果没有 Cookie 的话 Session 还能用吗? # 这是一道经典的面试题!\n一般是通过 Cookie 来保存 SessionID ,假如你使用了 Cookie 保存 SessionID 的方案的话, 如果客户端禁用了 Cookie,那么 Session 就无法正常工作。\n但是,并不是没有 Cookie 之后就不能用 Session 了,比如你可以将 SessionID 放在请求的 url 里面https://javaguide.cn/?Session_id=xxx 。这种方案的话可行,但是安全性和用户体验感降低。当然,为了安全你也可以对 SessionID 进行一次加密之后再传入后端。\n为什么 Cookie 无法防止 CSRF 攻击,而 Token 可以? # CSRF(Cross Site Request Forgery) 一般被翻译为 跨站请求伪造 。那么什么是 跨站请求伪造 呢?说简单点,就是用你的身份去发送一些对你不友好的请求。举个简单的例子:\n小壮登录了某网上银行,他来到了网上银行的帖子区,看到一个帖子下面有一个链接写着“科学理财,年盈利率过万”,小壮好奇的点开了这个链接,结果发现自己的账户少了 10000 元。这是这么回事呢?原来黑客在链接中藏了一个请求,这个请求直接利用小壮的身份给银行发送了一个转账请求,也就是通过你的 Cookie 向银行发出请求。\n\u0026lt;a src=http://www.mybank.com/Transfer?bankId=11\u0026amp;money=10000\u0026gt;科学理财,年盈利率过万\u0026lt;/\u0026gt; 上面也提到过,进行 Session 认证的时候,我们一般使用 Cookie 来存储 SessionId,当我们登陆后后端生成一个 SessionId 放在 Cookie 中返回给客户端,服务端通过 Redis 或者其他存储工具记录保存着这个 SessionId,客户端登录以后每次请求都会带上这个 SessionId,服务端通过这个 SessionId 来标示你这个人。如果别人通过 Cookie 拿到了 SessionId 后就可以代替你的身份访问系统了。\nSession 认证中 Cookie 中的 SessionId 是由浏览器发送到服务端的,借助这个特性,攻击者就可以通过让用户误点攻击链接,达到攻击效果。\n但是,我们使用 Token 的话就不会存在这个问题,在我们登录成功获得 Token 之后,一般会选择存放在 localStorage (浏览器本地存储)中。然后我们在前端通过某些方式会给每个发到后端的请求加上这个 Token,这样就不会出现 CSRF 漏洞的问题。因为,即使你点击了非法链接发送了请求到服务端,这个非法请求是不会携带 Token 的,所以这个请求将是非法的。\n需要注意的是:不论是 Cookie 还是 Token 都无法避免 跨站脚本攻击(Cross Site Scripting)XSS 。\n跨站脚本攻击(Cross Site Scripting)缩写为 CSS 但这会与层叠样式表(Cascading Style Sheets,CSS)的缩写混淆。因此,有人将跨站脚本攻击缩写为 XSS。\nXSS 中攻击者会用各种方式将恶意代码注入到其他用户的页面中。就可以通过脚本盗用信息比如 Cookie 。\n推荐阅读: 如何防止 CSRF 攻击?—美团技术团队\n什么是 JWT?JWT 由哪些部分组成? # JWT 基础概念详解\n如何基于 JWT 进行身份验证? 如何防止 JWT 被篡改? # JWT 基础概念详解\n什么是 SSO? # SSO(Single Sign On)即单点登录说的是用户登陆多个子系统的其中一个就有权访问与其相关的其他系统。举个例子我们在登陆了京东金融之后,我们同时也成功登陆京东的京东超市、京东国际、京东生鲜等子系统。\nSSO 有什么好处? # 用户角度 :用户能够做到一次登录多次使用,无需记录多套用户名和密码,省心。 系统管理员角度 : 管理员只需维护好一个统一的账号中心就可以了,方便。 新系统开发角度: 新系统开发时只需直接对接统一的账号中心即可,简化开发流程,省时。 如何设计实现一个 SSO 系统? # SSO 单点登录详解\n什么是 OAuth 2.0? # OAuth 是一个行业的标准授权协议,主要用来授权第三方应用获取有限的权限。而 OAuth 2.0 是对 OAuth 1.0 的完全重新设计,OAuth 2.0 更快,更容易实现,OAuth 1.0 已经被废弃。详情请见: rfc6749。\n实际上它就是一种授权机制,它的最终目的是为第三方应用颁发一个有时效性的令牌 Token,使得第三方应用能够通过该令牌获取相关的资源。\nOAuth 2.0 比较常用的场景就是第三方登录,当你的网站接入了第三方登录的时候一般就是使用的 OAuth 2.0 协议。\n另外,现在 OAuth 2.0 也常见于支付场景(微信支付、支付宝支付)和开发平台(微信开放平台、阿里开放平台等等)。\n下图是 Slack OAuth 2.0 第三方登录的示意图:\n推荐阅读:\nOAuth 2.0 的一个简单解释 10 分钟理解什么是 OAuth 2.0 协议 OAuth 2.0 的四种方式 GitHub OAuth 第三方登录示例教程 参考 # 不要用 JWT 替代 session 管理(上):全面了解 Token,JWT,OAuth,SAML,SSO: https://zhuanlan.zhihu.com/p/38942172 Introduction to JSON Web Tokens: https://jwt.io/introduction JSON Web Token Claims: https://auth0.com/docs/secure/tokens/json-web-tokens/json-web-token-claims "},{"id":647,"href":"/zh/docs/technology/Interview/high-availability/redundancy/","title":"冗余设计详解","section":"High Availability","content":"冗余设计是保证系统和数据高可用的最常的手段。\n对于服务来说,冗余的思想就是相同的服务部署多份,如果正在使用的服务突然挂掉的话,系统可以很快切换到备份服务上,大大减少系统的不可用时间,提高系统的可用性。\n对于数据来说,冗余的思想就是相同的数据备份多份,这样就可以很简单地提高数据的安全性。\n实际上,日常生活中就有非常多的冗余思想的应用。\n拿我自己来说,我对于重要文件的保存方法就是冗余思想的应用。我日常所使用的重要文件都会同步一份在 GitHub 以及个人云盘上,这样就可以保证即使电脑硬盘损坏,我也可以通过 GitHub 或者个人云盘找回自己的重要文件。\n高可用集群(High Availability Cluster,简称 HA Cluster)、同城灾备、异地灾备、同城多活和异地多活是冗余思想在高可用系统设计中最典型的应用。\n高可用集群 : 同一份服务部署两份或者多份,当正在使用的服务突然挂掉的话,可以切换到另外一台服务,从而保证服务的高可用。 同城灾备:一整个集群可以部署在同一个机房,而同城灾备中相同服务部署在同一个城市的不同机房中。并且,备用服务不处理请求。这样可以避免机房出现意外情况比如停电、火灾。 异地灾备:类似于同城灾备,不同的是,相同服务部署在异地(通常距离较远,甚至是在不同的城市或者国家)的不同机房中 同城多活:类似于同城灾备,但备用服务可以处理请求,这样可以充分利用系统资源,提高系统的并发。 异地多活 : 将服务部署在异地的不同机房中,并且,它们可以同时对外提供服务。 高可用集群单纯是服务的冗余,并没有强调地域。同城灾备、异地灾备、同城多活和异地多活实现了地域上的冗余。\n同城和异地的主要区别在于机房之间的距离。异地通常距离较远,甚至是在不同的城市或者国家。\n和传统的灾备设计相比,同城多活和异地多活最明显的改变在于“多活”,即所有站点都是同时在对外提供服务的。异地多活是为了应对突发状况比如火灾、地震等自然或者人为灾害。\n光做好冗余还不够,必须要配合上 故障转移 才可以! 所谓故障转移,简单来说就是实现不可用服务快速且自动地切换到可用服务,整个过程不需要人为干涉。\n举个例子:哨兵模式的 Redis 集群中,如果 Sentinel(哨兵) 检测到 master 节点出现故障的话, 它就会帮助我们实现故障转移,自动将某一台 slave 升级为 master,确保整个 Redis 系统的可用性。整个过程完全自动,不需要人工介入。我在 《Java 面试指北》的「技术面试题篇」中的数据库部分详细介绍了 Redis 集群相关的知识点\u0026amp;面试题,感兴趣的小伙伴可以看看。\n再举个例子:Nginx 可以结合 Keepalived 来实现高可用。如果 Nginx 主服务器宕机的话,Keepalived 可以自动进行故障转移,备用 Nginx 主服务器升级为主服务。并且,这个切换对外是透明的,因为使用的虚拟 IP,虚拟 IP 不会改变。我在 《Java 面试指北》的「技术面试题篇」中的「服务器」部分详细介绍了 Nginx 相关的知识点\u0026amp;面试题,感兴趣的小伙伴可以看看。\n异地多活架构实施起来非常难,需要考虑的因素非常多。本人不才,实际项目中并没有实践过异地多活架构,我对其了解还停留在书本知识。\n如果你想要深入学习异地多活相关的知识,我这里推荐几篇我觉得还不错的文章:\n搞懂异地多活,看这篇就够了- 水滴与银弹 - 2021 四步构建异地多活 《从零开始学架构》— 28 | 业务高可用的保障:异地多活架构 不过,这些文章大多也都是在介绍概念知识。目前,网上还缺少真正介绍具体要如何去实践落地异地多活架构的资料。\n"},{"id":648,"href":"/zh/docs/technology/Interview/database/redis/redis-delayed-task/","title":"如何基于Redis实现延时任务","section":"Redis","content":"基于 Redis 实现延时任务的功能无非就下面两种方案:\nRedis 过期事件监听 Redisson 内置的延时队列 面试的时候,你可以先说自己考虑了这两种方案,但最后发现 Redis 过期事件监听这种方案存在很多问题,因此你最终选择了 Redisson 内置的 DelayedQueue 这种方案。\n这个时候面试官可能会追问你一些相关的问题,我们后面会提到,提前准备就好了。\n另外,除了下面介绍到的这些问题之外,Redis 相关的常见问题建议你都复习一遍,不排除面试官会顺带问你一些 Redis 的其他问题。\nRedis 过期事件监听实现延时任务功能的原理? # Redis 2.0 引入了发布订阅 (pub/sub) 功能。在 pub/sub 中,引入了一个叫做 channel(频道) 的概念,有点类似于消息队列中的 topic(主题)。\npub/sub 涉及发布者(publisher)和订阅者(subscriber,也叫消费者)两个角色:\n发布者通过 PUBLISH 投递消息给指定 channel。 订阅者通过SUBSCRIBE订阅它关心的 channel。并且,订阅者可以订阅一个或者多个 channel。 在 pub/sub 模式下,生产者需要指定消息发送到哪个 channel 中,而消费者则订阅对应的 channel 以获取消息。\nRedis 中有很多默认的 channel,这些 channel 是由 Redis 本身向它们发送消息的,而不是我们自己编写的代码。其中,__keyevent@0__:expired 就是一个默认的 channel,负责监听 key 的过期事件。也就是说,当一个 key 过期之后,Redis 会发布一个 key 过期的事件到__keyevent@\u0026lt;db\u0026gt;__:expired这个 channel 中。\n我们只需要监听这个 channel,就可以拿到过期的 key 的消息,进而实现了延时任务功能。\n这个功能被 Redis 官方称为 keyspace notifications ,作用是实时监控 Redis 键和值的变化。\nRedis 过期事件监听实现延时任务功能有什么缺陷? # 1、时效性差\n官方文档的一段介绍解释了时效性差的原因,地址: https://redis.io/docs/manual/keyspace-notifications/#timing-of-expired-events 。\n这段话的核心是:过期事件消息是在 Redis 服务器删除 key 时发布的,而不是一个 key 过期之后就会就会直接发布。\n我们知道常用的过期数据的删除策略就两个:\n惰性删除:只会在取出 key 的时候才对数据进行过期检查。这样对 CPU 最友好,但是可能会造成太多过期 key 没有被删除。 定期删除:每隔一段时间抽取一批 key 执行删除过期 key 操作。并且,Redis 底层会通过限制删除操作执行的时长和频率来减少删除操作对 CPU 时间的影响。 定期删除对内存更加友好,惰性删除对 CPU 更加友好。两者各有千秋,所以 Redis 采用的是 定期删除+惰性/懒汉式删除 。\n因此,就会存在我设置了 key 的过期时间,但到了指定时间 key 还未被删除,进而没有发布过期事件的情况。\n2、丢消息\nRedis 的 pub/sub 模式中的消息并不支持持久化,这与消息队列不同。在 Redis 的 pub/sub 模式中,发布者将消息发送给指定的频道,订阅者监听相应的频道以接收消息。当没有订阅者时,消息会被直接丢弃,在 Redis 中不会存储该消息。\n3、多服务实例下消息重复消费\nRedis 的 pub/sub 模式目前只有广播模式,这意味着当生产者向特定频道发布一条消息时,所有订阅相关频道的消费者都能够收到该消息。\n这个时候,我们需要注意多个服务实例重复处理消息的问题,这会增加代码开发量和维护难度。\nRedisson 延迟队列原理是什么?有什么优势? # Redisson 是一个开源的 Java 语言 Redis 客户端,提供了很多开箱即用的功能,比如多种分布式锁的实现、延时队列。\n我们可以借助 Redisson 内置的延时队列 RDelayedQueue 来实现延时任务功能。\nRedisson 的延迟队列 RDelayedQueue 是基于 Redis 的 SortedSet 来实现的。SortedSet 是一个有序集合,其中的每个元素都可以设置一个分数,代表该元素的权重。Redisson 利用这一特性,将需要延迟执行的任务插入到 SortedSet 中,并给它们设置相应的过期时间作为分数。\nRedisson 使用 zrangebyscore 命令扫描 SortedSet 中过期的元素,然后将这些过期元素从 SortedSet 中移除,并将它们加入到就绪消息列表中。就绪消息列表是一个阻塞队列,有消息进入就会被监听到。这样做可以避免对整个 SortedSet 进行轮询,提高了执行效率。\n相比于 Redis 过期事件监听实现延时任务功能,这种方式具备下面这些优势:\n减少了丢消息的可能:DelayedQueue 中的消息会被持久化,即使 Redis 宕机了,根据持久化机制,也只可能丢失一点消息,影响不大。当然了,你也可以使用扫描数据库的方法作为补偿机制。 消息不存在重复消费问题:每个客户端都是从同一个目标队列中获取任务的,不存在重复消费的问题。 跟 Redisson 内置的延时队列相比,消息队列可以通过保障消息消费的可靠性、控制消息生产者和消费者的数量等手段来实现更高的吞吐量和更强的可靠性,实际项目中首选使用消息队列的延时消息这种方案。\n"},{"id":649,"href":"/zh/docs/technology/Interview/high-quality-technical-articles/interview/how-to-examine-the-technical-ability-of-programmers-in-the-first-test-of-technology/","title":"如何在技术初试中考察程序员的技术能力","section":"Interview","content":" 推荐语:从面试官和面试者两个角度探讨了技术面试!非常不错!\n内容概览:\n实战与理论结合。比如,候选人叙述 JVM 内存模型布局之后,可以接着问:有哪些原因可能会导致 OOM , 有哪些预防措施? 你是否遇到过内存泄露的问题? 如何排查和解决这类问题? 项目经历考察不宜超过两个。因为要深入考察一个项目的详情,所占用的时间还是比较大的。一般来说,会让候选人挑选一个他或她觉得最有收获的/最有挑战的/印象最深刻的/自己觉得特有意思的项目。然后围绕这个项目进行发问。通常是从项目背景出发,考察项目的技术栈、项目模块及交互的整体理解、项目中遇到的有挑战性的技术问题及解决方案、排查和解决问题、代码可维护性问题、工程质量保障等。 多问少说,让候选者多表现。根据候选者的回答适当地引导或递进或横向移动。 原文地址: https://www.cnblogs.com/lovesqcc/p/15169365.html\n灵魂三连问 # 你觉得人怎么样? 【表达能力、沟通能力、学习能力、总结能力、自省改进能力、抗压能力、情绪管理能力、影响力、团队管理能力】 如果让他独立完成项目的设计和实现,你觉得他能胜任吗? 【系统设计能力、项目管理能力】 他的分析和解决问题的能力,你的评价是啥?【原理理解能力、实战应用能力】 考察目标和思路 # 首先明确,技术初试的考察目标:\n候选人的技术基础; 候选人解决问题的思路和能力。 技术基础是基石(冰山之下的东西),占七分, 解决问题的思路和能力是落地(冰山之上露出的部分),占三分。 业务和技术基础考察,三七开。\n核心考察目标:分析和解决问题的能力。\n技术层面:深度 + 应用能力 + 广度。 对于校招或社招 P6 级别以下,要多注重 深度 + 应用能力,广度是加分项; 在 P6 之上,可增加 广度。\n校招:基础扎实,思维敏捷。 主要考察内容:基础数据结构与算法、进程与并发、内存管理、系统调用与 IO 机制、网络协议、数据库范式与设计、设计模式、设计原则、编程习惯; 社招:经验丰富,里外兼修。 主要考察内容:有一定深度的基础技术机制,比如 Java 内存模型及内存泄露、 JVM 机制、类加载机制、数据库索引及查询优化、缓存、消息中间件、项目、架构设计、工程规范等。 技术基础是什么? # 作为技术初试官,怎么去考察技术基础?究竟什么是技术基础?是知道什么,还是知道如何思考?知识作为现有的成熟原理体系,构成了基础的重要组成部分,而知道如何思考亦尤为重要。俗话说,知其然而知其所以然。知其然,是指熟悉现有知识体系,知其所以然,则是自底向上推导,真正理解知识的来龙去脉,理解为何是这样而不是那样。毕竟,对于本质是逻辑的程序世界而言,并无定法。知道如何思考,并能缜密地设计和开发,深入到细节,这就是技术基础吧。\n为什么要考察技术基础? # 程序员最重要的两种技术思维能力,是逻辑思维能力和抽象设计能力。逻辑思维能力是基础,抽象设计能力是高阶。 考察技术基础,正好可以同时考察这两种思维能力。能不能理解基础技术概念及关联,是考察逻辑思维能力;能不能把业务问题抽象成技术问题并合理的组织映射,是考察抽象设计能力。\n绝大部分业务问题,都可以抽象成技术问题。在某种意义上,业务问题只是技术问题的领域化表述。\n因此,通过技术基础考察候选者,才能考察到候选者的真实技术实力:技术深度和广度。\n为什么不能单考察业务维度? # 因为业务方面通常比较熟悉,可能就直接按照现有方案说出来了,很难考察到候选人的深入理解、横向拓展和归纳总结能力。\n这一点,建议有针对性地考察下候选人的归纳总结能力:比如, 微服务搭建或开发或维护/保证系统稳定性或性能方面的过程中,你收获了哪些可以分享的经验?\n为什么要考察业务维度? # 技术基础考察,容易错过的地方是,候选人的非技术能力特质,比如沟通组织能力、带项目能力、抗压能力、解决实际问题的能力、团队影响力、其它性格特质等。\n考察方法 # 技术基础考察 # 技术基础怎么考察?通过有效的多角度的发问模式来考察。\n是什么-为什么\n是什么考察对概念的基本理解,为什么考察对概念的实现原理。\n比如索引是什么? 索引是如何实现的?\n引导-横向发问-深入发问\n引导性,比如 “你对 java 同步工具熟悉吗?” 作个试探,得到肯定答复后,可以进一步问:“你熟悉哪些同步工具类?” 了解候选者的广度;\n获取候选者的回答后,可以进一步问:“ 谈谈 ConcurrentHashMap 或 AQS 的实现原理?”\n一个人在多大程度上把技术原理能讲得清晰,包括思路和细节,说明他对技术的掌握能力有多强。\n深度有梯度和层次的发问\n设置三个深度层次的发问。每个深度层次可以对应到某个技术深度。\n第一个发问是基本概念层次,考察候选人对概念的理解能力和深度; 第二个发问是原理机制层次,考察候选人对概念的内涵和外延的理解深度; 第三个发问是应用层次,考察候选人的应用能力和思维敏捷程度。 跳跃式/交叉式发问\n比如,讲到哈希高效查找,可以谈谈哈希一致性算法 。 两者既有关联又有很多不同点。也是一种技术广度的考察方法。\n总结性发问\n比如,你在做 XXX 中,获得了哪些可以分享的经验? 考察候选人的归纳总结能力。\n实战与理论结合\n比如,候选人叙述 JVM 内存模型布局之后,可以接着问:有哪些原因可能会导致 OOM , 有哪些预防措施? 你是否遇到过内存泄露的问题? 如何排查和解决这类问题? 比如,候选人有谈到 SQL 优化和索引优化,那就正好谈谈索引的实现原理,如何建立最佳索引? 比如,候选人有谈到事务,那就正好谈谈事务实现原理,隔离级别,快照实现等; 熟悉与不熟悉结合\n针对候选人简历上写的熟悉的部分,和没有写出的都问下。比如候选人简历上写着:熟悉 JVM 内存模型, 那我就考察下内存管理相关(熟悉部分),再考察下 Java 并发工具类(不确定是否熟悉部分)。\n死知识与活知识结合\n比如,查找算法有哪些?顺序查找、二分查找、哈希查找。这些大家通常能说出来,也是“死知识”。\n这些查找算法各适用于什么场景?在你工作中,有哪些场景用到了哪些查找算法?为什么? 这些是“活知识”。\n学习或工作中遇到的\n有时,在学习和工作中遇到的问题,也可以作为面试题。\n比如,最近在学习《操作系统导论》并发部分,有一章节是如何使数据结构成为线程安全的。这里就有一些可以提问的地方:如何实现一个锁?如何实现一个线程安全的计数器?如何实现一个线程安全的链表?如何实现一个线程安全的 Map ?如何提升并发的性能?\n工作中遇到的问题,也可以抽象提炼出来,作为技术基础面试题。\n技术栈适配度发问\n如果候选人(简历上所写的)使用的某些技术与本公司的技术栈比较契合,则可以针对这些技术点进行深入提问,考察候选人在这些技术点的掌握程度。如果掌握程度比较好,则技术适配度相对更高一些。\n当然,这一点并不能作为筛掉那些没有使用该技术栈的候选人的依据。比如本公司使用 MongoDB 和 MySQL, 而一个候选人没有用过 Mongodb, 但使用过 MySQL, Redis, ES, HBase 等多种存储系统,那么适配度并不比仅使用过 MySQL 和 Mongodb 的候选人逊色,因为他所涉及的技术广度更大,可以推断出他有足够能力掌握 Mongodb。\n应对背题式面试\n首先,背题式面试,说明候选人至少是有做准备的。当然,对于招聘的一方来说,更希望找到有能力而不是仅记忆了知识的候选人。\n应对背题式面试,可以通过 “引导-横向发问-深入发问” 的方式,先对候选人关于某个知识点的深度和广度做一个了解,然后出一道实际应用题来考察他是否能灵活使用知识。\n比如 Java 线程同步机制,可以出一道题:线程 A 执行了一段代码,然后创建了一个异步任务在线程 B 中执行,线程 A 需要等待线程 B 执行完成后才能继续执行,请问怎么实现?\n”理论 + 应用题“的模式。敌知我之变,而不知我变之形。变之形,不计其数。\n实用不生僻\n考察工作中频繁用到的知识、技能和能力,不考察冷僻的知识。\n比如我偏向考察数据结构与算法、并发、设计 这三类。因为这三类非常基础非常核心。\n综合串联式发问\n知识之间总是相互联系着的,不要单独考察一个知识点。\n设计一个初始问题,比如说查找算法,然后从这个初始问题出发,串联起各个知识点。比如:\n在每一个技术点上,都可以应用以上发问技巧,导向不同的问题分支。同时考察面试者的深度、广度和应用能力。\n创造有个性的面试题库\n每个技术面试官都会有一个面试题库。持续积累面试题库,日常中突然想到的问题,就随手记录下来。\n解决问题能力考察 # 仅仅只是技术基础还不够,通常最好结合实际业务,针对他项目里的业务,抽象出技术问题进行考察。\n解决思路重在层层递进。这一点对于面试官的要求也比较高,兼具良好的倾听能力、技术深度和业务经验。首先要仔细倾听候选人的阐述,找到适当的技术切入点,然后进行发问。如果进不去,那就容易考察失败。 常见问题:\n性能方面,qps, tps 多少?采用了什么优化措施,达成了什么效果? 如果有大数据量,如何处理?如何保证稳定性? 你觉得这个功能/模块/系统的关键点在哪里?有什么解决方案? 为什么使用 XXX 而不是 YYY ? 长字段如何做索引? 还有哪些方案或思路?各自的利弊? 第三方对接,如何应对外部接口的不稳定性? 第三方对接,对接大量外部系统,代码可维护性? 资损场景?严重故障场景? 线上出现了 CPU 飙高,如何处理? OOM 如何处理? IO 读写尖刺,如何排查? 线上运行过程中,出现过哪些问题?如何解决的? 多个子系统之间的数据一致性问题? 如果需要新增一个 XXX 需求,如何扩展? 重来一遍,你觉得可以在哪些方面改进? 系统可问的关联问题:\n绝大多数系统都有性能相关问题。如果没有性能问题,则说明是小系统,小系统就不值得考察了; 中大型系统通常有技术选型问题; 绝大多数系统都有改进空间; 大多数业务系统都涉及可扩展性问题和可维护性问题; 大多数重要业务系统都经历过比较惨重的线上教训; 大数据量系统必定有稳定性问题; 消费系统必定有时延和堆积问题; 第三方系统对接必定涉及可靠性问题; 分布式系统必定涉及可用性问题; 多个子系统协同必定涉及数据一致性问题; 交易系统有资损和故障场景; 设计问题\n比如多个机器间共享大量业务对象,这些业务对象之间有些联合字段是重复的,如何去重? 如果字段比较长,怎么处理? 如果瞬时有大量请求涌入,如何保证服务器的稳定性? 组件级别:设计一个本地缓存? 设计一个分布式缓存? 模块级别:设计一个任务调度模块?需要考虑什么因素? 系统级别:设计一个内部系统,从各个部门获取销售数据然后统计出报表。复杂性体现在哪里?关键质量属性是哪些?模块划分,模块之间的关联关系?技术选型? 项目经历\n项目经历考察不宜超过两个。因为要深入考察一个项目的详情,所占用的时间还是比较大的。\n一般来说,会让候选人挑选一个他或她觉得最有收获的/最有挑战的/印象最深刻的/自己觉得特有意思/感受到挫折的项目。然后围绕这个项目进行发问。通常是从项目背景出发,考察项目的技术栈、项目模块及交互的整体理解、项目中遇到的有挑战性的技术问题及解决方案、排查和解决问题、代码可维护性问题、工程质量保障、重来一遍可以改进哪些等。\n面试过程 # 预先准备 # 面试官也需要做一些准备。比如熟悉候选者的技能优势、工作经历等,做一个面试设计。\n在面试将要开始时,做好面试准备。此外,面试官也需要对公司的一些基本情况有所了解,尤其是公司所使用技术栈、业务全景及方向、工作内容、晋升制度等,这一点技术型候选人问得比较多。\n面试启动 # 一般以候选人自我介绍启动,不过候选人往往会谈得比较散,因此,我会直接提问:谈谈你有哪些优势以及自己觉得可以改进的地方?\n然后以一个相对简单的基础题作为技术提问的开始:你熟悉哪些查找算法?大多数人是能答上顺序查找、二分查找、哈希查找的。\n问题设计 # 提前阅读候选人简历,从简历中筛选出关键词,根据这些关键词进行有针对性地问题设计。\n比如候选人简历里提到 MVVM ,可以问 MVVM 与 MVC 的区别; 提到了观察者模式,可以谈谈观察者模式,顺便问问他还熟悉哪些设计模式。\n可遵循“优势-标准-随机”原则:\n首先,问他对哪方面技术感兴趣、投入较多(优势部分),根据其优势部分,阐述原理及实战应用; 其次,问若干标准化的问题,看看他的原理理解、实战应用如何; 最后,随机选一个问题,看看他的原理理解、实战应用如何; 对于项目同样可以如此:\n首先,问他最有成就感的项目,技术栈、模块及关联、技术选型、设计关键问题、解决方案、实现细节、改进空间; 其次,问他有挫折感的项目,问题在哪里、做过什么努力、如何改进; 宽松氛围 # 即使问的问题比较多比较难,也要注意保持宽松氛围。\n在面试前,根据候选人基本信息适当调侃一下,比如一位候选人叫汪奎,那我就说:之前我们团队有位叫袁奎,我们都喊他奎爷。\n在面试过程中,适当提示,或者给出少量自己的看法,也能缓解候选人的紧张情绪。\n学会倾听 # 多问少说,让候选者多表现。根据候选者的回答适当地引导或递进或横向移动。\n引导候选人表现他最优势的一面,让他或她感觉好一些:毕竟一场面试双方都付出了时间和精力,不应该是面试官 Diss 候选人的场合,而应该让彼此有更好的交流。很大可能,你也能从候选人那里学到不少东西。\n面试这件事,只不过双方的角色和立场有所不同,但并不代表面试官的水平就一定高于候选人。\n记录重点 # 认真客观地记录候选人的回答,尽可能避免任何主观评价,亦不作任何加工(比如自己给总结一下,总结能力也是候选人的一个特质)。\n多练习 # 模拟面试。\n作出判断 # 面试过程是一种铺垫,关键的是作出判断。\n作出判断最容易陷入误区的是:贪深求全。总希望候选人技术又深入又全面。实际上,这是一种奢望。如果候选人的技术能力又深入又全面,很可能也会面临两种情况:1. 候选人有更好的选择; 2. 候选人在其它方面可能存在不足,比如团队协作方面。\n一个比较合适的尺度是:1. 他或她的技术水平能否胜任当前工作; 2. 他或她的技术水平与同组团队成员水平如何; 3. 他或她的技术水平是否与年限相对匹配,是否有潜力胜任更复杂的任务。\n不同年龄看重的东西不一样 # 对于三年以下的工程师,应当更看重其技术基础,因为这代表着他的未来潜能;同时也考察下他在实际开发中的体现,比如团队协作、业务经验、抗压能力、主动学习的热情和能力等。\n对于三年以上的工程师,应当更看重其业务经验、解决问题能力,看看他或她是如何分析具体问题,在业务范畴内考察其技术基础的深度和广度。\n如何判断一个候选人的真实技术水平及是否适合所需,这方面,我也在学习中。\n面试初上路 # 提前准备好摄像头和音频,可以用耳机测试下。 提前阅读候选人简历,从中筛选关键字,准备几个基本问题。 多问技术基础题,培养下面试感觉。 适当深入问下原理和实现。 如果候选人简历有突出的地方,就先问那个部分;如果没有,就让候选人介绍项目背景,根据项目背景及经验来提问。 小量练习“连问”技巧,直到能够熟悉使用。 着重考察分析和解决问题的能力,必要的话,可以出个编程题。 留出时间给对方问:你有什么想问的?并告知对方三个工作日内回复面试结果。 高效考察 # 当作为技术面试官有一定熟悉度时,就需要提升面试效率。即:在更少的时间内有效考察候选人的技术深度和技术广度。可以准备一些常见的问题,作为标准化测试。\n比如我喜欢考察内存管理及算法、数据库索引、缓存、并发、系统设计、问题分析和思考能力等子主题。\n熟悉哪些用于查找的数据结构和算法? 请任选一种阐述其思想以及你认为有意思的地方。 如果运行到一个 Java 方法,里面创建了一个对象列表,内存是如何分配的?什么时候可能导致栈溢出?什么时候可能导致 OOM ? 导致 OOM 的原因有哪些?如何避免? 线上是否有遇到过 OOM ,怎么解决的? Java 分代垃圾回收算法是怎样的? 项目里选用的垃圾回收器是怎样的?为什么选择这个回收器而不是那个? Java 并发工具有哪些?不同工具适合于什么场景? Atomic 原子类的实现原理 ? ConcurrentHashMap 的实现原理? 如何实现一个可重入锁? 举个项目中的例子,哪些字段使用了索引?为什么是这些字段?你觉得还有什么优化空间?如何建一个好的索引? 缓存的可设置的参数有哪些?分别的影响是什么? Redis 过期策略有哪些? 如何选择 redis 过期策略? 如何实现病毒文件检测任务去重? 熟悉哪些设计模式和设计原则? 从 0 到 1 搭建一个模块/完整系统?你如何着手? 如果候选人答不上,可以问:如果你来设计这样一个 XXX, 你会怎么做?\n时间占比大概为:技术基础(25-30 分钟) + 项目(20-25 分钟) + 候选人提问(5-10 分钟)\n给候选人的话 # 为什么候选人需要关注技术基础\n一个常见的疑惑是:开发业务系统的大多数时候,基本不涉及数据结构与算法的设计与实现,为什么要考察 HashMap 的实现原理?为什么要学好数据结构与算法、操作系统、网络通信这些基础课程?\n现在我可以给出一个答案了:\n正如上面所述,绝大多数的业务问题,实际上最终都会映射到基础技术问题上:数据结构与算法的实现、内存管理、并发控制、网络通信等;这些是理解现代互联网大规模程序以及解决程序疑难问题的基石,—— 除非能祝福自己永远都不会遇到疑难问题,永远都只满足于编写 CRUD; 这些技术基础正是程序世界里最有趣最激动人心的地方。如果对这些不感兴趣,就很难在这个领域里深入进去,不如及早转行从事其它职业,非技术的世界一直都很精彩广阔(有时我也想多出去走走,不想局限于技术世界); 技术基础是程序员的内功,而具体技术则是招式。徒有招式而内功不深,遇到高手(优秀同行从业者的竞争及疑难杂症)容易不堪一击; 具备扎实的专业技术基础,能达到的上限更高,未来更有可能胜任复杂的技术问题求解,或者在同样的问题上能够做到更好的方案; 人们喜欢跟与自己相似的人合作,牛人倾向于与牛人合作能得到更好的效果;如果一个团队大部分人技术基础比较好,那么进来一个技术基础比较薄弱的人,协作成本会变高;如果你想和牛人一起合作拿到更好的结果,那就要让自己至少在技术基础上能够与牛人搭配的上; 在 CRUD 的基础上拓展其它才能也不失为一种好的选择,但这不会是一个真正的程序员的姿态,顶多是有技术基础的产品经理、项目经理、HR、运营、客满等其它岗位人才。这是职业选择的问题,已经超出了考察程序员的范畴。 不要在意某个问题回答不上来\n如果面试官问你很多问题,而有些没有回答上来,不要在意。面试官很可能只是在测试你的技术深度和广度,然后判断你是否达到某个水位线。\n重点是:有些问题你答得很有深度,也体现了你的深度思考能力。\n这一点是我当了技术面试官才领会到的。当然,并不是每位技术面试官都是这么想的,但我觉得这应该是个更合适的方式。\n参考资料 # 技术面试官的 9 大误区 如何当一个好的面试官? "},{"id":650,"href":"/zh/docs/technology/Interview/high-quality-technical-articles/interview/screen-candidates-for-packaging/","title":"如何甄别应聘者的包装程度","section":"Interview","content":" 推荐语:经常听到培训班待过的朋友给我说他们的老师是怎么教他们“包装”自己的,不光是培训班,我认识的很多朋友也都会在面试之前“包装”一下自己,所以这个现象是普遍存在的。但是面试官也不都是傻子,通过下面这篇文章来看看面试官是如何甄别应聘者的包装程度。\n原文地址: https://my.oschina.net/hooker/blog/3014656\n前言 # 上到职场干将下到职场萌新,都会接触到包装简历这个词语。当你简历投到心仪的公司,公司内负责求职的工作人员是如何甄别简历的包装程度的?我根据自己的经验写下了这篇文章,谁都不是天才,包装无可厚非,切勿对号入座!\n正文 # 在互联网极速膨胀的社会背景下,各行各业涌入互联网的 IT 民工日益增大。\n早在 2016 年,我司发布了 Java、Ios 工程师的招聘信息,就 Java 工程师单个岗位而言,日收简历近 200 份,Ios 日收简历近一千份。\n没错,这就是当年培训机构对 Ios 工程师这个岗位发起的市场讨伐。而随着近几年的发展,市场供大于求现象日益严重。人员摸底成为用人单位对人才考核的重大难题。\n笔者初次与求职者以面试的形式进行沟通是 2015 年 6 月。由于当时笔者从业时间短,经验不够丰富,错过了一些优秀的求职者。\n三年后的,今天,笔者再次因公司规模扩大而深入与求职者进行沟通。\n1.初选如何鉴别劣质简历 # 培训机构除了提供技术培训,往往还提供简历编写指导、面试指导。很多潜移默化的东西,我们很难甄别。但培训机构包装的简历,存在千遍一律的特征。\n年龄较小却具备高级文凭\n年龄较小却具备高级文凭,这个或许不能作为一项标准,但是大部分的应聘者,均符合传统文凭的市场情况。个别技术爱好者可能通过自考获得文凭,这种情况需提供独有的技术亮点。\n年龄较大却几乎不具备技术经验\n年龄较大却几乎不具备技术经验,相对前一点,这个问题就比较严重了。大家都知道,一个正常的人,对新事物的接受能力会随着年龄的增长而降低,互联网技术也包括其内。如果一个人年龄较大不具备技术经验,那么只有两种情况:\n中途转行(通过培训、自学等方式强行入行)。 由于能力问题,已有的经验不敢写入简历中(能力与经验/薪资不符)。 项目经验多为管理系统\n项目经验,这一项用来评估应聘者的水平太合适不过了。随着互联网的发展迭代,每一年都会出来很多创新型的互联网公司和新兴行业。笔者最近发布的招聘需求里面。CRM 系统、商城、XX 管理系统、问卷系统、课堂系统占了 90%的份额。试问现在 2019 年,内部管理系统这么火爆么。言归正传,我们对于简历的评估,应当多考虑“确有其事”的项目。比如说该人员当时就职于 XX 公司,该公司当时的背景下确实研发了该项目(外包除外)。\n项目的背景不符合互联网发展背景\n项目背景,每年的市场走向不同,从早些年的电商、彩票风波,到后来的 O2O、夺宝、直播、新零售。每个系列的产品的出现,都符合市场的定义。如果简历中出现 18 年、19 年才刚立项做彩票(15 年政府禁止互联网彩票)、O2O、商城、夺宝(17 年初禁止夺宝类产品)、直播等产品。显然是非常不符合市场需求的。这种情况下需考虑具体情况是否存在理解空间。\n缺乏新意\n不同工作经验下多个项目技术架构或项目结构一致,缺乏新意。一般情况而言,不同的公司技术栈不同,甚至产品的走向和模式完全不同。故此,当一个应聘者多家公司的多个项目中写到的技术千遍一律,业务流程异曲同工。看似整洁,实则更加缺乏说服力。\n技术过于新颖,对旧技术却只字不提\n技术过于新颖,根据互联网技术发展的走向来看,我们在不断向新型技术靠拢。但是任何企业作为资历深厚的 CTO、架构师来说。往往会选择更稳定、更成熟、学习成本更低的已有技术。对新技术的追求不会过于明显。而培训机构则是“哪项技术火我们就教哪项”。故此,出现了很多走入互联网行业的新人对旧技术一窍不通。甚至很多技术都没听过。\n工作经验较丰富,但从事的工作较低级。\n工作经验比较丰富,单从事的工作比较低级,这里存在很大的问题,要么就是原公司没法提供合理的舞台给该人员更好的发展空间,要么就是该人员能力不够,没法完成更高级的工作。当然,还有一种情况就是该人员包装过多的经验导致简历中不和谐。这种情况需要评估公司规模和背景。\n公司背景跨省跨市\n可能很多用人单位和鄙人一样,最近接受到的简历,90%为跨市跳槽的人员。其中武汉占了 60%以上。均为武汉 XX 网络科技有限公司。公司规模均小于 50 人。也有厦门、宁波、南京等等。这个问题笔者就不提了,大家都懂的。跨地区跳槽不好查证。\n缺少业余热情于技术的证明\n有些眼高手低的技术员,做了几个管理系统。用到的技术确是各种分布式、集群、高并发、大数据、消息队列、搜索引擎、镜像容器、多数据库、数据中心等等。期望的薪资也高于行业标准。一个对技术很热情的人,业余时间肯定在技术方面花费过不少时间。那么可以从该人员的博客、git 地址入手。甚至可以通过手机号、邮箱、昵称、马甲。去搜索引擎进行搜集,核实该人员是否在论坛、贴吧、开源组织有过技术背景。\n2. 进入面试阶段,如何甄别对方的水分 # 在甄别对方水分这一块,并没有明确的标准,但是笔者可以提几个点。这也是笔者在实际面试中惯用的做法。\n通过公司规模、团队规模、人员分配是否合理、人员合作方式来判断对方是否具备工作经验\n当招聘初级、初中级 IT 人员的时候,可以询问一些问题,比如公司有多少人、产品团队多少人、产品、技术、后端、前端、客户端、UI、测试各多少人。工作中如何合作的、产品做了多少时间、何时上线的、上线后多长时间迭代一个版本、多长时间迭代一个活动、发展至今多少用户(后端)、多大并发等等(后端)。根据笔者的经验,如果一个人没有任何从业周期,面对这些问题的时候,或多或少答非所问或者给出的答案非常不合理。\n背景公司入职时间、项目立项实现、完工时间、产品技术栈、迭代流程的核实\n很多应聘者对于简历过于包装,只为了追求更高的薪资。当我们问起:你是 xx 年 xx 月入职的该公司?你们项目是 xx 年 xx 月上线的?你们项目使用到 xx 技术?你们每次上线前夕是如何评审的。面对这些问题,应聘者给出的答案经常与简历不符合。这样问题就来了。关于项目使用到的技术,很多项目我们可以通过搜索该项目的地址、APP。通过 HTTP 协议、技术特征、抛出异常特征来大致判别对方使用到的技术。如果应聘者给出的答案明显与之不匹配,嘿嘿。\n通过技术深度,甄别对方的技术水平\n确定对方的技术栈,如:你做过最满意的项目是哪个,为什么?你最喜欢使用的技术是哪些,为什么?\n确定对方项目的发展程度,如:你们产品做了多久,迭代了多久,发布了多少版本,发展到了多少用户,带来多大并发,多少流水?\n确定对方的技术属性,如:平时你会通过什么渠道跟其他技术人形成技术沟通与交流,主要交流过哪些技术?\n笔者最近接待的面试者,很多面试者的简历上,写着层出不穷的各种技术,为了不跨越求职者的技术栈,笔者专门挑应聘者简历写到或用到的技术来进行询问。笔者举几个例子。\n1)某求职者简历上写着熟练使用 Redis。\n介绍一下你使用过 Redis 的哪些数据结构,并描述一下使用的业务场景; 介绍一下你操作 Redis 用到的是什么插件; 介绍一下你们使用的序列化方式; 介绍一下你们使用 Redis 遇到过给你印象较深的问题; 2)某求职者声称熟练 HTTP 协议并编写过爬虫。\n介绍一下你所了解的几个 HTTP head 头并描述其用途; 如果前端提交成功,后端无法接受数据,这时候你将如何排查问题; 描述一下 HTTP 基本报文结构; 如果服务器返回 Cookie,存储在响应内容里面 head 头的字段叫做什么; 当服务端返回 Transfer-Encoding:chunked 代表什么含义 是否了解分段加载并描述下其技术流程。 当然,面向不同的技术,对应的技术深度自然也不一样。\n大体上的套路便是如此:你说你杀过猪。那么你杀过几头猪,分别是啥时候,杀过多大的猪,有啥毛色。事实上对方可能给你的回答是:杀过、十几头、杀过五十斤的、杀过绿色、黄色、红色、蓝色的猪。那么问题就来了。\n然而笔者碰到的问题是:使用 Git 两年却不知道 GitHub、使用 Redis 一年却不知道数据结构也不知道序列化、专业做爬虫却不懂 content-type 含义、使用搜索引擎技术却说不出两个分词插件、使用数据库读写分离却不知道同步延时等等。\n写在最后,笔者认为在招聘途中,并不是不允许求职者包装,但是尽可能满足能筹平衡。虽然这篇文章没有完美的结尾,但是笔者提供了面试失败的各种经验。笔者最终招到了如意的小伙伴。也希望所有技术面试官早日找到符合自己产品发展的 IT 伙伴。\n"},{"id":651,"href":"/zh/docs/technology/Interview/system-design/basis/software-engineering/","title":"软件工程简明教程","section":"Basis","content":"大部分软件开发从业者,都会忽略软件开发中的一些最基础、最底层的一些概念。但是,这些软件开发的概念对于软件开发来说非常重要,就像是软件开发的基石一样。这也是我写这篇文章的原因。\n何为软件工程? # 1968 年 NATO(北大西洋公约组织)提出了软件危机(Software crisis)一词。同年,为了解决软件危机问题,“软件工程”的概念诞生了。一门叫做软件工程的学科也就应运而生。\n随着时间的推移,软件工程这门学科也经历了一轮又一轮的完善,其中的一些核心内容比如软件开发模型越来越丰富实用!\n什么是软件危机呢?\n简单来说,软件危机描述了当时软件开发的一个痛点:我们很难高效地开发出质量高的软件。\nDijkstra(Dijkstra 算法的作者) 在 1972 年图灵奖获奖感言中也提高过软件危机,他是这样说的:“导致软件危机的主要原因是机器变得功能强大了几个数量级!坦率地说:只要没有机器,编程就完全没有问题。当我们有一些弱小的计算机时,编程成为一个温和的问题,而现在我们有了庞大的计算机,编程也同样成为一个巨大的问题”。\n说了这么多,到底什么是软件工程呢?\n工程是为了解决实际的问题将理论应用于实践。软件工程指的就是将工程思想应用于软件开发。\n上面是我对软件工程的定义,我们再来看看比较权威的定义。IEEE 软件工程汇刊给出的定义是这样的: (1)将系统化的、规范的、可量化的方法应用到软件的开发、运行及维护中,即将工程化方法应用于软件。 (2)在(1)中所述方法的研究。\n总之,软件工程的终极目标就是:在更少资源消耗的情况下,创造出更好、更容易维护的软件。\n软件开发过程 # 维基百科是这样定义软件开发过程的:\n软件开发过程(英语:software development process),或软件过程(英语:software process),是软件开发的开发生命周期(software development life cycle),其各个阶段实现了软件的需求定义与分析、设计、实现、测试、交付和维护。软件过程是在开发与构建系统时应遵循的步骤,是软件开发的路线图。\n需求分析:分析用户的需求,建立逻辑模型。 软件设计:根据需求分析的结果对软件架构进行设计。 编码:编写程序运行的源代码。 测试 : 确定测试用例,编写测试报告。 交付:将做好的软件交付给客户。 维护:对软件进行维护比如解决 bug,完善功能。 软件开发过程只是比较笼统的层面上,一定义了一个软件开发可能涉及到的一些流程。\n软件开发模型更具体地定义了软件开发过程,对开发过程提供了强有力的理论支持。\n软件开发模型 # 软件开发模型有很多种,比如瀑布模型(Waterfall Model)、快速原型模型(Rapid Prototype Model)、V 模型(V-model)、W 模型(W-model)、敏捷开发模型。其中最具有代表性的还是 瀑布模型 和 敏捷开发 。\n瀑布模型 定义了一套完成的软件开发周期,完整地展示了一个软件的的生命周期。\n敏捷开发模型 是目前使用的最多的一种软件开发模型。 MBA 智库百科对敏捷开发的描述是这样的:\n敏捷开发 是一种以人为核心、迭代、循序渐进的开发方法。在敏捷开发中,软件项目的构建被切分成多个子项目,各个子项目的成果都经过测试,具备集成和可运行的特征。换言之,就是把一个大项目分为多个相互联系,但也可独立运行的小项目,并分别完成,在此过程中软件一直处于可使用状态。\n像现在比较常见的一些概念比如 持续集成、重构、小版本发布、低文档、站会、结对编程、测试驱动开发 都是敏捷开发的核心。\n软件开发的基本策略 # 软件复用 # 我们在构建一个新的软件的时候,不需要从零开始,通过复用已有的一些轮子(框架、第三方库等)、设计模式、设计原则等等现成的物料,我们可以更快地构建出一个满足要求的软件。\n像我们平时接触的开源项目就是最好的例子。我想,如果不是开源,我们构建出一个满足要求的软件,耗费的精力和时间要比现在多的多!\n分而治之 # 构建软件的过程中,我们会遇到很多问题。我们可以将一些比较复杂的问题拆解为一些小问题,然后,一一攻克。\n我结合现在比较火的软件设计方法—领域驱动设计(Domain Driven Design,简称 DDD)来说说。\n在领域驱动设计中,很重要的一个概念就是领域(Domain),它就是我们要解决的问题。在领域驱动设计中,我们要做的就是把比较大的领域(问题)拆解为若干的小领域(子域)。\n除此之外,分而治之也是一个比较常用的算法思想,对应的就是分治算法。如果你想了解分治算法的话,推荐你看一下北大的 《算法设计与分析 Design and Analysis of Algorithms》。\n逐步演进 # 软件开发是一个逐步演进的过程,我们需要不断进行迭代式增量开发,最终交付符合客户价值的产品。\n这里补充一个在软件开发领域,非常重要的概念:MVP(Minimum Viable Product,最小可行产品)。\n这个最小可行产品,可以理解为刚好能够满足客户需求的产品。下面这张图片把这个思想展示的非常精髓。\n利用最小可行产品,我们可以也可以提早进行市场分析,这对于我们在探索产品不确定性的道路上非常有帮助。可以非常有效地指导我们下一步该往哪里走。\n优化折中 # 软件开发是一个不断优化改进的过程。任何软件都有很多可以优化的点,不可能完美。我们需要不断改进和提升软件的质量。\n但是,也不要陷入这个怪圈。要学会折中,在有限的投入内,以最有效的方式提高现有软件的质量。\n参考 # 软件工程的基本概念-清华大学软件学院 刘强: https://www.xuetangx.com/course/THU08091000367 软件开发过程-维基百科: https://zh.wikipedia.org/wiki/软件开发过程 "},{"id":652,"href":"/zh/docs/technology/Interview/system-design/design-pattern/","title":"设计模式常见面试题总结","section":"System Design","content":"设计模式 相关的面试题已经整理到了 PDF 手册中,你可以在我的公众号“JavaGuide”后台回复“PDF” 获取。\n《设计模式》PDF 电子书内容概览:\n"},{"id":653,"href":"/zh/docs/technology/Interview/high-performance/deep-pagination-optimization/","title":"深度分页介绍及优化建议","section":"High Performance","content":" 深度分页介绍 # 查询偏移量过大的场景我们称为深度分页,这会导致查询性能较低,例如:\n# MySQL 在无法利用索引的情况下跳过1000000条记录后,再获取10条记录 SELECT * FROM t_order ORDER BY id LIMIT 1000000, 10 深度分页问题的原因 # 当查询偏移量过大时,MySQL 的查询优化器可能会选择全表扫描而不是利用索引来优化查询。这是因为扫描索引和跳过大量记录可能比直接全表扫描更耗费资源。\n不同机器上这个查询偏移量过大的临界点可能不同,取决于多个因素,包括硬件配置(如 CPU 性能、磁盘速度)、表的大小、索引的类型和统计信息等。\nMySQL 的查询优化器采用基于成本的策略来选择最优的查询执行计划。它会根据 CPU 和 I/O 的成本来决定是否使用索引扫描或全表扫描。如果优化器认为全表扫描的成本更低,它就会放弃使用索引。不过,即使偏移量很大,如果查询中使用了覆盖索引(covering index),MySQL 仍然可能会使用索引,避免回表操作。\n深度分页优化建议 # 这里以 MySQL 数据库为例介绍一下如何优化深度分页。\n范围查询 # 当可以保证 ID 的连续性时,根据 ID 范围进行分页是比较好的解决方案:\n# 查询指定 ID 范围的数据 SELECT * FROM t_order WHERE id \u0026gt; 100000 AND id \u0026lt;= 100010 ORDER BY id # 也可以通过记录上次查询结果的最后一条记录的ID进行下一页的查询: SELECT * FROM t_order WHERE id \u0026gt; 100000 LIMIT 10 这种基于 ID 范围的深度分页优化方式存在很大限制:\nID 连续性要求高: 实际项目中,数据库自增 ID 往往因为各种原因(例如删除数据、事务回滚等)导致 ID 不连续,难以保证连续性。 排序问题: 如果查询需要按照其他字段(例如创建时间、更新时间等)排序,而不是按照 ID 排序,那么这种方法就不再适用。 并发场景: 在高并发场景下,单纯依赖记录上次查询的最后一条记录的 ID 进行分页,容易出现数据重复或遗漏的问题。 子查询 # 我们先查询出 limit 第一个参数对应的主键值,再根据这个主键值再去过滤并 limit,这样效率会更快一些。\n阿里巴巴《Java 开发手册》中也有对应的描述:\n利用延迟关联或者子查询优化超多分页场景。\n# 通过子查询来获取 id 的起始值,把 limit 1000000 的条件转移到子查询 SELECT * FROM t_order WHERE id \u0026gt;= (SELECT id FROM t_order where id \u0026gt; 1000000 limit 1) LIMIT 10; 工作原理:\n子查询 (SELECT id FROM t_order where id \u0026gt; 1000000 limit 1) 会利用主键索引快速定位到第 1000001 条记录,并返回其 ID 值。 主查询 SELECT * FROM t_order WHERE id \u0026gt;= ... LIMIT 10 将子查询返回的起始 ID 作为过滤条件,使用 id \u0026gt;= 获取从该 ID 开始的后续 10 条记录。 不过,子查询的结果会产生一张新表,会影响性能,应该尽量避免大量使用子查询。并且,这种方法只适用于 ID 是正序的。在复杂分页场景,往往需要通过过滤条件,筛选到符合条件的 ID,此时的 ID 是离散且不连续的。\n当然,我们也可以利用子查询先去获取目标分页的 ID 集合,然后再根据 ID 集合获取内容,但这种写法非常繁琐,不如使用 INNER JOIN 延迟关联。\n延迟关联 # 延迟关联与子查询的优化思路类似,都是通过将 LIMIT 操作转移到主键索引树上,减少回表次数。相比直接使用子查询,延迟关联通过 INNER JOIN 将子查询结果集成到主查询中,避免了子查询可能产生的临时表。在执行 INNER JOIN 时,MySQL 优化器能够利用索引进行高效的连接操作(如索引扫描或其他优化策略),因此在深度分页场景下,性能通常优于直接使用子查询。\n-- 使用 INNER JOIN 进行延迟关联 SELECT t1.* FROM t_order t1 INNER JOIN (SELECT id FROM t_order where id \u0026gt; 1000000 LIMIT 10) t2 ON t1.id = t2.id; 工作原理:\n子查询 (SELECT id FROM t_order where id \u0026gt; 1000000 LIMIT 10) 利用主键索引快速定位目标分页的 10 条记录的 ID。 通过 INNER JOIN 将子查询结果与主表 t_order 关联,获取完整的记录数据。 除了使用 INNER JOIN 之外,还可以使用逗号连接子查询。\n-- 使用逗号进行延迟关联 SELECT t1.* FROM t_order t1, (SELECT id FROM t_order where id \u0026gt; 1000000 LIMIT 10) t2 WHERE t1.id = t2.id; 注意: 虽然逗号连接子查询也能实现类似的效果,但为了代码可读性和可维护性,建议使用更规范的 INNER JOIN 语法。\n覆盖索引 # 索引中已经包含了所有需要获取的字段的查询方式称为覆盖索引。\n覆盖索引的好处:\n避免 InnoDB 表进行索引的二次查询,也就是回表操作: InnoDB 是以聚集索引的顺序来存储的,对于 InnoDB 来说,二级索引在叶子节点中所保存的是行的主键信息,如果是用二级索引查询数据的话,在查找到相应的键值后,还要通过主键进行二次查询才能获取我们真实所需要的数据。而在覆盖索引中,二级索引的键值中可以获取所有的数据,避免了对主键的二次查询(回表),减少了 IO 操作,提升了查询效率。 可以把随机 IO 变成顺序 IO 加快查询效率: 由于覆盖索引是按键值的顺序存储的,对于 IO 密集型的范围查找来说,对比随机从磁盘读取每一行的数据 IO 要少的多,因此利用覆盖索引在访问时也可以把磁盘的随机读取的 IO 转变成索引查找的顺序 IO。 # 如果只需要查询 id, code, type 这三列,可建立 code 和 type 的覆盖索引 SELECT id, code, type FROM t_order ORDER BY code LIMIT 1000000, 10; ⚠️注意:\n当查询的结果集占表的总行数的很大一部分时,MySQL 查询优化器可能选择放弃使用索引,自动转换为全表扫描。 虽然可以使用 FORCE INDEX 强制查询优化器走索引,但这种方式可能会导致查询优化器无法选择更优的执行计划,效果并不总是理想。 总结 # 本文总结了几种常见的深度分页优化方案:\n范围查询: 基于 ID 连续性进行分页,通过记录上一页最后一条记录的 ID 来获取下一页数据。适合 ID 连续且按 ID 查询的场景,但在 ID 不连续或需要按其他字段排序时存在局限。 子查询: 先通过子查询获取分页的起始主键值,再根据主键进行筛选分页。利用主键索引提高效率,但子查询会生成临时表,复杂场景下性能不佳。 延迟关联 (INNER JOIN): 使用 INNER JOIN 将分页操作转移到主键索引上,减少回表次数。相比子查询,延迟关联的性能更优,适合大数据量的分页查询。 覆盖索引: 通过索引直接获取所需字段,避免回表操作,减少 IO 开销,适合查询特定字段的场景。但当结果集较大时,MySQL 可能会选择全表扫描。 参考 # 聊聊如何解决 MySQL 深分页问题 - 捡田螺的小男孩: https://juejin.cn/post/7012016858379321358 数据库深分页介绍及优化方案 - 京东零售技术: https://mp.weixin.qq.com/s/ZEwGKvRCyvAgGlmeseAS7g MySQL 深分页优化 - 得物技术: https://juejin.cn/post/6985478936683610149 "},{"id":654,"href":"/zh/docs/technology/Interview/cs-basics/algorithms/10-classical-sorting-algorithms/","title":"十大经典排序算法总结","section":"Algorithms","content":" 本文转自: http://www.guoyaohua.com/sorting.html,JavaGuide 对其做了补充完善。\n引言 # 所谓排序,就是使一串记录,按照其中的某个或某些关键字的大小,递增或递减的排列起来的操作。排序算法,就是如何使得记录按照要求排列的方法。排序算法在很多领域得到相当地重视,尤其是在大量数据的处理方面。一个优秀的算法可以节省大量的资源。在各个领域中考虑到数据的各种限制和规范,要得到一个符合实际的优秀算法,得经过大量的推理和分析。\n简介 # 排序算法总结 # 常见的内部排序算法有:插入排序、希尔排序、选择排序、冒泡排序、归并排序、快速排序、堆排序、基数排序等,本文只讲解内部排序算法。用一张表格概括:\n排序算法 时间复杂度(平均) 时间复杂度(最差) 时间复杂度(最好) 空间复杂度 排序方式 稳定性 冒泡排序 O(n^2) O(n^2) O(n) O(1) 内部排序 稳定 选择排序 O(n^2) O(n^2) O(n^2) O(1) 内部排序 不稳定 插入排序 O(n^2) O(n^2) O(n) O(1) 内部排序 稳定 希尔排序 O(nlogn) O(n^2) O(nlogn) O(1) 内部排序 不稳定 归并排序 O(nlogn) O(nlogn) O(nlogn) O(n) 外部排序 稳定 快速排序 O(nlogn) O(n^2) O(nlogn) O(logn) 内部排序 不稳定 堆排序 O(nlogn) O(nlogn) O(nlogn) O(1) 内部排序 不稳定 计数排序 O(n+k) O(n+k) O(n+k) O(k) 外部排序 稳定 桶排序 O(n+k) O(n^2) O(n+k) O(n+k) 外部排序 稳定 基数排序 O(n×k) O(n×k) O(n×k) O(n+k) 外部排序 稳定 术语解释:\nn:数据规模,表示待排序的数据量大小。 k:“桶” 的个数,在某些特定的排序算法中(如基数排序、桶排序等),表示分割成的独立的排序区间或类别的数量。 内部排序:所有排序操作都在内存中完成,不需要额外的磁盘或其他存储设备的辅助。这适用于数据量小到足以完全加载到内存中的情况。 外部排序:当数据量过大,不可能全部加载到内存中时使用。外部排序通常涉及到数据的分区处理,部分数据被暂时存储在外部磁盘等存储设备上。 稳定:如果 A 原本在 B 前面,而 $A=B$,排序之后 A 仍然在 B 的前面。 不稳定:如果 A 原本在 B 的前面,而 $A=B$,排序之后 A 可能会出现在 B 的后面。 时间复杂度:定性描述一个算法执行所耗费的时间。 空间复杂度:定性描述一个算法执行所需内存的大小。 排序算法分类 # 十种常见排序算法可以分类两大类别:比较类排序和非比较类排序。\n常见的快速排序、归并排序、堆排序以及冒泡排序等都属于比较类排序算法。比较类排序是通过比较来决定元素间的相对次序,由于其时间复杂度不能突破 O(nlogn),因此也称为非线性时间比较类排序。在冒泡排序之类的排序中,问题规模为 n,又因为需要比较 n 次,所以平均时间复杂度为 O(n²)。在归并排序、快速排序之类的排序中,问题规模通过分治法消减为 logn 次,所以时间复杂度平均 O(nlogn)。\n比较类排序的优势是,适用于各种规模的数据,也不在乎数据的分布,都能进行排序。可以说,比较排序适用于一切需要排序的情况。\n而计数排序、基数排序、桶排序则属于非比较类排序算法。非比较排序不通过比较来决定元素间的相对次序,而是通过确定每个元素之前,应该有多少个元素来排序。由于它可以突破基于比较排序的时间下界,以线性时间运行,因此称为线性时间非比较类排序。 非比较排序只要确定每个元素之前的已有的元素个数即可,所有一次遍历即可解决。算法时间复杂度 $O(n)$。\n非比较排序时间复杂度底,但由于非比较排序需要占用空间来确定唯一位置。所以对数据规模和数据分布有一定的要求。\n冒泡排序 (Bubble Sort) # 冒泡排序是一种简单的排序算法。它重复地遍历要排序的序列,依次比较两个元素,如果它们的顺序错误就把它们交换过来。遍历序列的工作是重复地进行直到没有再需要交换为止,此时说明该序列已经排序完成。这个算法的名字由来是因为越小的元素会经由交换慢慢 “浮” 到数列的顶端。\n算法步骤 # 比较相邻的元素。如果第一个比第二个大,就交换它们两个; 对每一对相邻元素作同样的工作,从开始第一对到结尾的最后一对,这样在最后的元素应该会是最大的数; 针对所有的元素重复以上的步骤,除了最后一个; 重复步骤 1~3,直到排序完成。 图解算法 # 代码实现 # /** * 冒泡排序 * @param arr * @return arr */ public static int[] bubbleSort(int[] arr) { for (int i = 1; i \u0026lt; arr.length; i++) { // Set a flag, if true, that means the loop has not been swapped, // that is, the sequence has been ordered, the sorting has been completed. boolean flag = true; for (int j = 0; j \u0026lt; arr.length - i; j++) { if (arr[j] \u0026gt; arr[j + 1]) { int tmp = arr[j]; arr[j] = arr[j + 1]; arr[j + 1] = tmp; // Change flag flag = false; } } if (flag) { break; } } return arr; } 此处对代码做了一个小优化,加入了 is_sorted Flag,目的是将算法的最佳时间复杂度优化为 O(n),即当原输入序列就是排序好的情况下,该算法的时间复杂度就是 O(n)。\n算法分析 # 稳定性:稳定 时间复杂度:最佳:$O(n)$ ,最差:$O(n2)$, 平均:$O(n2)$ 空间复杂度:$O(1)$ 排序方式:In-place 选择排序 (Selection Sort) # 选择排序是一种简单直观的排序算法,无论什么数据进去都是 $O(n^2)$ 的时间复杂度。所以用到它的时候,数据规模越小越好。唯一的好处可能就是不占用额外的内存空间了吧。它的工作原理:首先在未排序序列中找到最小(大)元素,存放到排序序列的起始位置,然后,再从剩余未排序元素中继续寻找最小(大)元素,然后放到已排序序列的末尾。以此类推,直到所有元素均排序完毕。\n算法步骤 # 首先在未排序序列中找到最小(大)元素,存放到排序序列的起始位置 再从剩余未排序元素中继续寻找最小(大)元素,然后放到已排序序列的末尾。 重复第 2 步,直到所有元素均排序完毕。 图解算法 # 代码实现 # /** * 选择排序 * @param arr * @return arr */ public static int[] selectionSort(int[] arr) { for (int i = 0; i \u0026lt; arr.length - 1; i++) { int minIndex = i; for (int j = i + 1; j \u0026lt; arr.length; j++) { if (arr[j] \u0026lt; arr[minIndex]) { minIndex = j; } } if (minIndex != i) { int tmp = arr[i]; arr[i] = arr[minIndex]; arr[minIndex] = tmp; } } return arr; } 算法分析 # 稳定性:不稳定 时间复杂度:最佳:$O(n2)$ ,最差:$O(n2)$, 平均:$O(n^2)$ 空间复杂度:$O(1)$ 排序方式:In-place 插入排序 (Insertion Sort) # 插入排序是一种简单直观的排序算法。它的工作原理是通过构建有序序列,对于未排序数据,在已排序序列中从后向前扫描,找到相应位置并插入。插入排序在实现上,通常采用 in-place 排序(即只需用到 $O(1)$ 的额外空间的排序),因而在从后向前扫描过程中,需要反复把已排序元素逐步向后挪位,为最新元素提供插入空间。\n插入排序的代码实现虽然没有冒泡排序和选择排序那么简单粗暴,但它的原理应该是最容易理解的了,因为只要打过扑克牌的人都应该能够秒懂。插入排序是一种最简单直观的排序算法,它的工作原理是通过构建有序序列,对于未排序数据,在已排序序列中从后向前扫描,找到相应位置并插入。\n插入排序和冒泡排序一样,也有一种优化算法,叫做拆半插入。\n算法步骤 # 从第一个元素开始,该元素可以认为已经被排序; 取出下一个元素,在已经排序的元素序列中从后向前扫描; 如果该元素(已排序)大于新元素,将该元素移到下一位置; 重复步骤 3,直到找到已排序的元素小于或者等于新元素的位置; 将新元素插入到该位置后; 重复步骤 2~5。 图解算法 # 代码实现 # /** * 插入排序 * @param arr * @return arr */ public static int[] insertionSort(int[] arr) { for (int i = 1; i \u0026lt; arr.length; i++) { int preIndex = i - 1; int current = arr[i]; while (preIndex \u0026gt;= 0 \u0026amp;\u0026amp; current \u0026lt; arr[preIndex]) { arr[preIndex + 1] = arr[preIndex]; preIndex -= 1; } arr[preIndex + 1] = current; } return arr; } 算法分析 # 稳定性:稳定 时间复杂度:最佳:$O(n)$ ,最差:$O(n^2)$, 平均:$O(n2)$ 空间复杂度:O(1)$ 排序方式:In-place 希尔排序 (Shell Sort) # 希尔排序是希尔 (Donald Shell) 于 1959 年提出的一种排序算法。希尔排序也是一种插入排序,它是简单插入排序经过改进之后的一个更高效的版本,也称为递减增量排序算法,同时该算法是冲破 $O(n^2)$ 的第一批算法之一。\n希尔排序的基本思想是:先将整个待排序的记录序列分割成为若干子序列分别进行直接插入排序,待整个序列中的记录 “基本有序” 时,再对全体记录进行依次直接插入排序。\n算法步骤 # 我们来看下希尔排序的基本步骤,在此我们选择增量 $gap=length/2$,缩小增量继续以 $gap = gap/2$ 的方式,这种增量选择我们可以用一个序列来表示,$\\lbrace \\frac{n}{2}, \\frac{(n/2)}{2}, \\dots, 1 \\rbrace$,称为增量序列。希尔排序的增量序列的选择与证明是个数学难题,我们选择的这个增量序列是比较常用的,也是希尔建议的增量,称为希尔增量,但其实这个增量序列不是最优的。此处我们做示例使用希尔增量。\n先将整个待排序的记录序列分割成为若干子序列分别进行直接插入排序,具体算法描述:\n选择一个增量序列 $\\lbrace t_1, t_2, \\dots, t_k \\rbrace$,其中 $t_i \\gt t_j, i \\lt j, t_k = 1$; 按增量序列个数 k,对序列进行 k 趟排序; 每趟排序,根据对应的增量 $t$,将待排序列分割成若干长度为 $m$ 的子序列,分别对各子表进行直接插入排序。仅增量因子为 1 时,整个序列作为一个表来处理,表长度即为整个序列的长度。 图解算法 # 代码实现 # /** * 希尔排序 * * @param arr * @return arr */ public static int[] shellSort(int[] arr) { int n = arr.length; int gap = n / 2; while (gap \u0026gt; 0) { for (int i = gap; i \u0026lt; n; i++) { int current = arr[i]; int preIndex = i - gap; // Insertion sort while (preIndex \u0026gt;= 0 \u0026amp;\u0026amp; arr[preIndex] \u0026gt; current) { arr[preIndex + gap] = arr[preIndex]; preIndex -= gap; } arr[preIndex + gap] = current; } gap /= 2; } return arr; } 算法分析 # 稳定性:不稳定 时间复杂度:最佳:$O(nlogn)$, 最差:$O(n^2)$ 平均:$O(nlogn)$ 空间复杂度:$O(1)$ 归并排序 (Merge Sort) # 归并排序是建立在归并操作上的一种有效的排序算法。该算法是采用分治法 (Divide and Conquer) 的一个非常典型的应用。归并排序是一种稳定的排序方法。将已有序的子序列合并,得到完全有序的序列;即先使每个子序列有序,再使子序列段间有序。若将两个有序表合并成一个有序表,称为 2 - 路归并。\n和选择排序一样,归并排序的性能不受输入数据的影响,但表现比选择排序好的多,因为始终都是 $O(nlogn)$ 的时间复杂度。代价是需要额外的内存空间。\n算法步骤 # 归并排序算法是一个递归过程,边界条件为当输入序列仅有一个元素时,直接返回,具体过程如下:\n如果输入内只有一个元素,则直接返回,否则将长度为 $n$ 的输入序列分成两个长度为 $n/2$ 的子序列; 分别对这两个子序列进行归并排序,使子序列变为有序状态; 设定两个指针,分别指向两个已经排序子序列的起始位置; 比较两个指针所指向的元素,选择相对小的元素放入到合并空间(用于存放排序结果),并移动指针到下一位置; 重复步骤 3 ~ 4 直到某一指针达到序列尾; 将另一序列剩下的所有元素直接复制到合并序列尾。 图解算法 # 代码实现 # /** * 归并排序 * * @param arr * @return arr */ public static int[] mergeSort(int[] arr) { if (arr.length \u0026lt;= 1) { return arr; } int middle = arr.length / 2; int[] arr_1 = Arrays.copyOfRange(arr, 0, middle); int[] arr_2 = Arrays.copyOfRange(arr, middle, arr.length); return merge(mergeSort(arr_1), mergeSort(arr_2)); } /** * Merge two sorted arrays * * @param arr_1 * @param arr_2 * @return sorted_arr */ public static int[] merge(int[] arr_1, int[] arr_2) { int[] sorted_arr = new int[arr_1.length + arr_2.length]; int idx = 0, idx_1 = 0, idx_2 = 0; while (idx_1 \u0026lt; arr_1.length \u0026amp;\u0026amp; idx_2 \u0026lt; arr_2.length) { if (arr_1[idx_1] \u0026lt; arr_2[idx_2]) { sorted_arr[idx] = arr_1[idx_1]; idx_1 += 1; } else { sorted_arr[idx] = arr_2[idx_2]; idx_2 += 1; } idx += 1; } if (idx_1 \u0026lt; arr_1.length) { while (idx_1 \u0026lt; arr_1.length) { sorted_arr[idx] = arr_1[idx_1]; idx_1 += 1; idx += 1; } } else { while (idx_2 \u0026lt; arr_2.length) { sorted_arr[idx] = arr_2[idx_2]; idx_2 += 1; idx += 1; } } return sorted_arr; } 算法分析 # 稳定性:稳定 时间复杂度:最佳:$O(nlogn)$, 最差:$O(nlogn)$, 平均:$O(nlogn)$ 空间复杂度:$O(n)$ 快速排序 (Quick Sort) # 快速排序用到了分治思想,同样的还有归并排序。乍看起来快速排序和归并排序非常相似,都是将问题变小,先排序子串,最后合并。不同的是快速排序在划分子问题的时候经过多一步处理,将划分的两组数据划分为一大一小,这样在最后合并的时候就不必像归并排序那样再进行比较。但也正因为如此,划分的不定性使得快速排序的时间复杂度并不稳定。\n快速排序的基本思想:通过一趟排序将待排序列分隔成独立的两部分,其中一部分记录的元素均比另一部分的元素小,则可分别对这两部分子序列继续进行排序,以达到整个序列有序。\n算法步骤 # 快速排序使用 分治法(Divide and conquer)策略来把一个序列分为较小和较大的 2 个子序列,然后递归地排序两个子序列。具体算法描述如下:\n从序列中随机挑出一个元素,做为 “基准”(pivot); 重新排列序列,将所有比基准值小的元素摆放在基准前面,所有比基准值大的摆在基准的后面(相同的数可以到任一边)。在这个操作结束之后,该基准就处于数列的中间位置。这个称为分区(partition)操作; 递归地把小于基准值元素的子序列和大于基准值元素的子序列进行快速排序。 图解算法 # 代码实现 # 来源: 使用 Java 实现快速排序(详解)\npublic static int partition(int[] array, int low, int high) { int pivot = array[high]; int pointer = low; for (int i = low; i \u0026lt; high; i++) { if (array[i] \u0026lt;= pivot) { int temp = array[i]; array[i] = array[pointer]; array[pointer] = temp; pointer++; } System.out.println(Arrays.toString(array)); } int temp = array[pointer]; array[pointer] = array[high]; array[high] = temp; return pointer; } public static void quickSort(int[] array, int low, int high) { if (low \u0026lt; high) { int position = partition(array, low, high); quickSort(array, low, position - 1); quickSort(array, position + 1, high); } } 算法分析 # 稳定性:不稳定 时间复杂度:最佳:$O(nlogn)$, 最差:$O(n^2)$,平均:$O(nlogn)$ 空间复杂度:$O(logn)$ 堆排序 (Heap Sort) # 堆排序是指利用堆这种数据结构所设计的一种排序算法。堆是一个近似完全二叉树的结构,并同时满足堆的性质:即子结点的值总是小于(或者大于)它的父节点。\n算法步骤 # 将初始待排序列 $(R_1, R_2, \\dots, R_n)$ 构建成大顶堆,此堆为初始的无序区; 将堆顶元素 $R_1$ 与最后一个元素 $R_n$ 交换,此时得到新的无序区 $(R_1, R_2, \\dots, R_{n-1})$ 和新的有序区 $R_n$, 且满足 $R_i \\leqslant R_n (i \\in 1, 2,\\dots, n-1)$; 由于交换后新的堆顶 $R_1$ 可能违反堆的性质,因此需要对当前无序区 $(R_1, R_2, \\dots, R_{n-1})$ 调整为新堆,然后再次将 $R_1$ 与无序区最后一个元素交换,得到新的无序区 $(R_1, R_2, \\dots, R_{n-2})$ 和新的有序区 $(R_{n-1}, R_n)$。不断重复此过程直到有序区的元素个数为 $n-1$,则整个排序过程完成。 图解算法 # 代码实现 # // Global variable that records the length of an array; static int heapLen; /** * Swap the two elements of an array * @param arr * @param i * @param j */ private static void swap(int[] arr, int i, int j) { int tmp = arr[i]; arr[i] = arr[j]; arr[j] = tmp; } /** * Build Max Heap * @param arr */ private static void buildMaxHeap(int[] arr) { for (int i = arr.length / 2 - 1; i \u0026gt;= 0; i--) { heapify(arr, i); } } /** * Adjust it to the maximum heap * @param arr * @param i */ private static void heapify(int[] arr, int i) { int left = 2 * i + 1; int right = 2 * i + 2; int largest = i; if (right \u0026lt; heapLen \u0026amp;\u0026amp; arr[right] \u0026gt; arr[largest]) { largest = right; } if (left \u0026lt; heapLen \u0026amp;\u0026amp; arr[left] \u0026gt; arr[largest]) { largest = left; } if (largest != i) { swap(arr, largest, i); heapify(arr, largest); } } /** * Heap Sort * @param arr * @return */ public static int[] heapSort(int[] arr) { // index at the end of the heap heapLen = arr.length; // build MaxHeap buildMaxHeap(arr); for (int i = arr.length - 1; i \u0026gt; 0; i--) { // Move the top of the heap to the tail of the heap in turn swap(arr, 0, i); heapLen -= 1; heapify(arr, 0); } return arr; } 算法分析 # 稳定性:不稳定 时间复杂度:最佳:$O(nlogn)$, 最差:$O(nlogn)$, 平均:$O(nlogn)$ 空间复杂度:$O(1)$ 计数排序 (Counting Sort) # 计数排序的核心在于将输入的数据值转化为键存储在额外开辟的数组空间中。 作为一种线性时间复杂度的排序,计数排序要求输入的数据必须是有确定范围的整数。\n计数排序 (Counting sort) 是一种稳定的排序算法。计数排序使用一个额外的数组 C,其中第 i 个元素是待排序数组 A 中值等于 i 的元素的个数。然后根据数组 C 来将 A 中的元素排到正确的位置。它只能对整数进行排序。\n算法步骤 # 找出数组中的最大值 max、最小值 min; 创建一个新数组 C,其长度是 max-min+1,其元素默认值都为 0; 遍历原数组 A 中的元素 A[i],以 A[i] - min 作为 C 数组的索引,以 A[i] 的值在 A 中元素出现次数作为 C[A[i] - min] 的值; 对 C 数组变形,新元素的值是该元素与前一个元素值的和,即当 i\u0026gt;1 时 C[i] = C[i] + C[i-1]; 创建结果数组 R,长度和原始数组一样。 从后向前遍历原始数组 A 中的元素 A[i],使用 A[i] 减去最小值 min 作为索引,在计数数组 C 中找到对应的值 C[A[i] - min],C[A[i] - min] - 1 就是 A[i] 在结果数组 R 中的位置,做完上述这些操作,将 count[A[i] - min] 减小 1。 图解算法 # 代码实现 # /** * Gets the maximum and minimum values in the array * * @param arr * @return */ private static int[] getMinAndMax(int[] arr) { int maxValue = arr[0]; int minValue = arr[0]; for (int i = 0; i \u0026lt; arr.length; i++) { if (arr[i] \u0026gt; maxValue) { maxValue = arr[i]; } else if (arr[i] \u0026lt; minValue) { minValue = arr[i]; } } return new int[] { minValue, maxValue }; } /** * Counting Sort * * @param arr * @return */ public static int[] countingSort(int[] arr) { if (arr.length \u0026lt; 2) { return arr; } int[] extremum = getMinAndMax(arr); int minValue = extremum[0]; int maxValue = extremum[1]; int[] countArr = new int[maxValue - minValue + 1]; int[] result = new int[arr.length]; for (int i = 0; i \u0026lt; arr.length; i++) { countArr[arr[i] - minValue] += 1; } for (int i = 1; i \u0026lt; countArr.length; i++) { countArr[i] += countArr[i - 1]; } for (int i = arr.length - 1; i \u0026gt;= 0; i--) { int idx = countArr[arr[i] - minValue] - 1; result[idx] = arr[i]; countArr[arr[i] - minValue] -= 1; } return result; } 算法分析 # 当输入的元素是 n 个 0 到 k 之间的整数时,它的运行时间是 $O(n+k)$。计数排序不是比较排序,排序的速度快于任何比较排序算法。由于用来计数的数组 C 的长度取决于待排序数组中数据的范围(等于待排序数组的最大值与最小值的差加上 1),这使得计数排序对于数据范围很大的数组,需要大量额外内存空间。\n稳定性:稳定 时间复杂度:最佳:$O(n+k)$ 最差:$O(n+k)$ 平均:$O(n+k)$ 空间复杂度:$O(k)$ 桶排序 (Bucket Sort) # 桶排序是计数排序的升级版。它利用了函数的映射关系,高效与否的关键就在于这个映射函数的确定。为了使桶排序更加高效,我们需要做到这两点:\n在额外空间充足的情况下,尽量增大桶的数量 使用的映射函数能够将输入的 N 个数据均匀的分配到 K 个桶中 桶排序的工作的原理:假设输入数据服从均匀分布,将数据分到有限数量的桶里,每个桶再分别排序(有可能再使用别的排序算法或是以递归方式继续使用桶排序进行。\n算法步骤 # 设置一个 BucketSize,作为每个桶所能放置多少个不同数值; 遍历输入数据,并且把数据依次映射到对应的桶里去; 对每个非空的桶进行排序,可以使用其它排序方法,也可以递归使用桶排序; 从非空桶里把排好序的数据拼接起来。 图解算法 # 代码实现 # /** * Gets the maximum and minimum values in the array * @param arr * @return */ private static int[] getMinAndMax(List\u0026lt;Integer\u0026gt; arr) { int maxValue = arr.get(0); int minValue = arr.get(0); for (int i : arr) { if (i \u0026gt; maxValue) { maxValue = i; } else if (i \u0026lt; minValue) { minValue = i; } } return new int[] { minValue, maxValue }; } /** * Bucket Sort * @param arr * @return */ public static List\u0026lt;Integer\u0026gt; bucketSort(List\u0026lt;Integer\u0026gt; arr, int bucket_size) { if (arr.size() \u0026lt; 2 || bucket_size == 0) { return arr; } int[] extremum = getMinAndMax(arr); int minValue = extremum[0]; int maxValue = extremum[1]; int bucket_cnt = (maxValue - minValue) / bucket_size + 1; List\u0026lt;List\u0026lt;Integer\u0026gt;\u0026gt; buckets = new ArrayList\u0026lt;\u0026gt;(); for (int i = 0; i \u0026lt; bucket_cnt; i++) { buckets.add(new ArrayList\u0026lt;Integer\u0026gt;()); } for (int element : arr) { int idx = (element - minValue) / bucket_size; buckets.get(idx).add(element); } for (int i = 0; i \u0026lt; buckets.size(); i++) { if (buckets.get(i).size() \u0026gt; 1) { buckets.set(i, sort(buckets.get(i), bucket_size / 2)); } } ArrayList\u0026lt;Integer\u0026gt; result = new ArrayList\u0026lt;\u0026gt;(); for (List\u0026lt;Integer\u0026gt; bucket : buckets) { for (int element : bucket) { result.add(element); } } return result; } 算法分析 # 稳定性:稳定 时间复杂度:最佳:$O(n+k)$ 最差:$O(n^2)$ 平均:$O(n+k)$ 空间复杂度:$O(n+k)$ 基数排序 (Radix Sort) # 基数排序也是非比较的排序算法,对元素中的每一位数字进行排序,从最低位开始排序,复杂度为 $O(n×k)$,$n$ 为数组长度,$k$ 为数组中元素的最大的位数;\n基数排序是按照低位先排序,然后收集;再按照高位排序,然后再收集;依次类推,直到最高位。有时候有些属性是有优先级顺序的,先按低优先级排序,再按高优先级排序。最后的次序就是高优先级高的在前,高优先级相同的低优先级高的在前。基数排序基于分别排序,分别收集,所以是稳定的。\n算法步骤 # 取得数组中的最大数,并取得位数,即为迭代次数 $N$(例如:数组中最大数值为 1000,则 $N=4$); A 为原始数组,从最低位开始取每个位组成 radix 数组; 对 radix 进行计数排序(利用计数排序适用于小范围数的特点); 将 radix 依次赋值给原数组; 重复 2~4 步骤 $N$ 次 图解算法 # 代码实现 # /** * Radix Sort * * @param arr * @return */ public static int[] radixSort(int[] arr) { if (arr.length \u0026lt; 2) { return arr; } int N = 1; int maxValue = arr[0]; for (int element : arr) { if (element \u0026gt; maxValue) { maxValue = element; } } while (maxValue / 10 != 0) { maxValue = maxValue / 10; N += 1; } for (int i = 0; i \u0026lt; N; i++) { List\u0026lt;List\u0026lt;Integer\u0026gt;\u0026gt; radix = new ArrayList\u0026lt;\u0026gt;(); for (int k = 0; k \u0026lt; 10; k++) { radix.add(new ArrayList\u0026lt;Integer\u0026gt;()); } for (int element : arr) { int idx = (element / (int) Math.pow(10, i)) % 10; radix.get(idx).add(element); } int idx = 0; for (List\u0026lt;Integer\u0026gt; l : radix) { for (int n : l) { arr[idx++] = n; } } } return arr; } 算法分析 # 稳定性:稳定 时间复杂度:最佳:$O(n×k)$ 最差:$O(n×k)$ 平均:$O(n×k)$ 空间复杂度:$O(n+k)$ 基数排序 vs 计数排序 vs 桶排序\n这三种排序算法都利用了桶的概念,但对桶的使用方法上有明显差异:\n基数排序:根据键值的每位数字来分配桶 计数排序:每个桶只存储单一键值 桶排序:每个桶存储一定范围的数值 参考文章 # https://www.cnblogs.com/guoyaohua/p/8600214.html https://en.wikipedia.org/wiki/Sorting_algorithm https://sort.hust.cc/ "},{"id":655,"href":"/zh/docs/technology/Interview/high-quality-technical-articles/advanced-programmer/ten-years-of-dachang-growth-road/","title":"十年大厂成长之路","section":"Advanced Programmer","content":" 推荐语:这篇文章的作者有着丰富的工作经验,曾在大厂工作了 12 年。结合自己走过的弯路和接触过的优秀技术人,他总结出了一些对于个人成长具有普遍指导意义的经验和特质。\n原文地址: https://mp.weixin.qq.com/s/vIIRxznpRr5yd6IVyNUW2w\n最近这段时间,有好几个年轻的同学和我聊到自己的迷茫。其中有关于技术成长的、有关于晋升的、有关于择业的。我很高兴他们愿意听我这个“过来人”分享自己的经验。\n我自己毕业后进入大厂,在大厂工作 12 年,我说的内容都来自于我自己或者身边人的真实情况。尤其,我会把 【我自己走过的弯路】 和 【我看到过的优秀技术人的特质】 相结合来给出建议。\n这些内容我觉得具有普遍的指导意义,所以决定做个整理分享出来。我相信,无论你在大厂还是小厂,如果你相信这些建议,或早或晚他们会帮助到你。\n我自己工作 12 年,走了些弯路,所以我就来讲讲,“在一个技术人 10 年的发展过程中,应该注意些什么”。我们把内容分为两块:\n十年技术路怎么走 一些重要选择 01 十年技术路怎么走 # 【1-2 年】=\u0026gt; 从“菜鸟”到“职业” # 应届生刚进入到工作时,会有各种不适应。比如写好的代码会被反复打回、和团队老司机讨论技术问题会有一堆问号、不敢提问和质疑、碰到问题一个人使劲死磕等等。\n简单来说就是,即使日以继夜地埋头苦干,最后也无法顺利的开展工作。\n这个阶段最重要的几个点:\n【多看多模仿】:比如写代码的时候,不要就像在学校完成书本作业那样只关心功能是否正确,还要关心模块的设计、异常的处理、代码的可读性等等。在你还没有了解这些内容的精髓之前,也要照猫画虎地模仿起来,慢慢地你就会越来越明白真实世界的代码是怎么写的,以及为什么要这么写。\n做技术方案的时候也是同理,技术文档的要求你也许并不理解,但你可以先参考已有文档写起来。\n【脸皮厚一点】:不懂就问,你是新人大家都是理解的。你做的各种方案也可以多找老司机们 review,不要怕被看笑话。\n【关注工作方式】:比如发现需求在计划时间完不成就要尽快报风险、及时做好工作内容的汇报(例如周报)、开会后确定会议结论和 todo 项、承诺时间就要尽力完成、严格遵循公司的要求(例如发布规范、权限规范等)\n一般来说,工作 2 年后,你就应该成为一个职业人。老板可以相信任何工作交到你的手里,不会出现“意外”(例如一个重要需求明天要上线了,突然被告知上不了)。\n【3-4 年】=\u0026gt; 从“职业”到“尖兵” # 工作两年后,对业务以及现有系统的了解已经到达了一定的程度,技术同学会开始承担更有难度的技术挑战。\n例如需要将性能提升到某一个水位、例如需要对某一个重要模块进行重构、例如有个重要的项目需要协同 N 个团队一起完成。\n可见,上述的这些技术问题,难度都已经远远超过一个普通的需求。解决这些问题需要有一定的技术能力,同时也需要具备更高的协同能力。\n这个阶段最重要的几个点:\n【技术能力提升】:无论是公司内还是公司外的技术内容,都要多做主动的学习。基本上这个阶段的技术难题都集中在【性能】【稳定性】和【扩展性】上,而这些内容在业界都是有成型的方法论的。\n【主人翁精神】:技术难题除了技术方案设计及落地外,背后还有一系列的其他工作。例如上线后对效果的观测、重点项目对于上下游改造和风险的了解程度、对于整个技改后续的计划(二期、三期的优化思路)等。\n在工作四年后,基本上你成为了团队的一、二号技术位。很多技术难题即使不是你来落地,也是由你来决定方案。你会做调研、会做方案对比、会考虑整个技改的生命周期。\n【5-7 年】=\u0026gt; 从“尖兵”到“专家” # 技术尖兵重点在于解决某一个具体的技术难题或者重点项目。而下一步的发展方向,就是能够承担起来一整个“业务板块”,也就是“领域技术专家”。\n想要承担一整个“业务板块”需要 【对业务领域有深刻的理解,同时基于这些理解来规划技术的发展方向】 。\n拿支付做个例子。简单的支付功能其实很容易完成,只要处理好和双联(网联和银联)的接口调用(成功、失败、异常)即可。但在很多背景下,支付没有那么简单。\n例如,支付是一个用户敏感型操作,非常强调用户体验,如何能兼顾体验和接口的不稳定?支付接口还需要承担费用,同步和异步的接口费用不同,如何能够降本?支付接口往往还有限额等。这一系列问题的背后涉及到很多技术的设计,包括异步化、补偿设计、资金流设计、最终一致性设计等等。\n这个阶段最重要的几个点:\n【深入理解行业及趋势】:密切关注行业的各种变化(新鲜的玩法、政策的变动、竞对的策略、科技等外在因素的影响等等),和业务同学加强沟通。\n【深入了解行业解决方案】:充分对标已有的国内外技术方案,做深入学习和尝试,评估建设及运维成本,结合业务趋势制定计划。\n【8-10 年】=\u0026gt; 从“专家”到“TL” # 其实很多时候,如果能做到专家,基本也是一个 TL 的角色了,但这并不代表正在执行 TL 的职责。\n专家虽然已经可以做到“为业务发展而规划好技术发展”,但问题是要怎么落地呢?显然,靠一个人的力量是不可能完成建设的。所以,这里的 TL 更多强调的不是“领导”这个职位,而是 【通过聚合一个团队的力量来实施技术规划】 。\n所以,这里的 TL 需要具备【团队技术培养】【合理分配资源】【确认工作优先级】【激励与奖惩】等各种能力。\n这个阶段最重要的几个点:\n【学习管理学】:这里的管理学当然不是指 PUA,而是指如何在每个同学都有各自诉求的现实背景下,让个人目标和团队目标相结合,产生向前发展的动力。\n【始终扎根技术】:很多时候,工作重心偏向管理以后,就会荒废技术。但事实是,一个优秀的领导永远是一个优秀的技术人。参与一起讨论技术方案并给予指导、不断扩展自己的技术宽度、保持对技术的好奇心,这些是让一个技术领导持续拥有向心力的关键。\n02 一些重要选择 # 下面来聊聊在十年间我们可能会碰到的一些重要选择。这些都是真实的血与泪的教训。\n我该不该转岗? # 大厂都有转岗的机制。转岗可以帮助员工寻找自己感兴趣的方向,也可以帮助新型团队招募有即战力的同学。\n转岗看似只是在公司内部变动,但你需要谨慎决定。\n本人转岗过多次。虽然还在同一家公司,但转岗等同于换工作。无论是领域沉淀、工作内容、信任关系、协作关系都是从零开始。\n针对转岗我的建议是:==如果你是想要拓宽自己的技术广度,也就是抱着提升技术能力的想法,我觉得可以转岗。但如果你想要晋升,不建议你转岗。==晋升需要在一个领域的持续积淀和在一个团队信任感的持续建立。\n当然,转岗可能还有其他原因,例如家庭原因、身体原因等,这个不展开讨论了。\n我该不该跳槽? # 跳槽和转岗一样,往往有很多因素造成,不能一概而论,我仅以几个场景来说:\n【晋升失败】:扪心自问,如果你觉得自己确实还不够格,那你就踏踏实实继续努力。如果你觉得评委有失偏颇,你可以尝试去外面面试一下,让市场来给你答案。\n【成长局限】:觉得自己做的事情没有挑战,无法成长。你可以和老板聊一下,有可能是因为你没有看到其中的挑战,也有可能老板没有意识到你的“野心”。\n【氛围不适】:一般来自于新入职或者领导更换,这种情况下不适是正常的。我的建议是,如果一个环境是“对事不对人”的,那就可以留下来,努力去适应,这种不适应只是做事方式不同导致的。但如果这个环境是“对人不对事”的话,走吧。\n跳槽该找怎样的工作? # 我们跳槽的时候往往会同时面试好几家公司。行情好的时候,往往可以收到多家 offer,那么我们要如何选择呢?\n考虑一个 offer 往往有这几点:【公司品牌】【薪资待遇】【职级职称】【技术背景】。每个同学其实都有自己的诉求,所以无论做什么选择都没有对错之分。\n我的一个建议是:你要关注新岗位的空间,这个空间是有希望满足你的期待的。\n比如,你想成为架构师,那新岗位是否有足够的技术挑战来帮助你提升技术能力,而不仅仅是疲于奔命地应付需求?\n比如,你想往技术管理发展,那新岗位是否有带人的机会?是否有足够的问题需要搭建团队来解决?\n比如,你想扎根在某个领域持续发展(例如电商、游戏),那新岗位是不是延续这个领域,并且可以碰到更多这个领域的问题?\n当然,如果薪资实在高到无法拒绝,以上参考可以忽略!\n结语 # 以上就是我对互联网从业技术人员十年成长之路的心得,希望在你困惑和关键选择的时候可以帮助到你。如果我的只言片语能够在未来的某个时间帮助到你哪怕一点,那将是我莫大的荣幸。\n"},{"id":656,"href":"/zh/docs/technology/Interview/cs-basics/data-structure/tree/","title":"树","section":"Data Structure","content":"树就是一种类似现实生活中的树的数据结构(倒置的树)。任何一颗非空树只有一个根节点。\n一棵树具有以下特点:\n一棵树中的任意两个结点有且仅有唯一的一条路径连通。 一棵树如果有 n 个结点,那么它一定恰好有 n-1 条边。 一棵树不包含回路。 下图就是一颗树,并且是一颗二叉树。\n如上图所示,通过上面这张图说明一下树中的常用概念:\n节点:树中的每个元素都可以统称为节点。 根节点:顶层节点或者说没有父节点的节点。上图中 A 节点就是根节点。 父节点:若一个节点含有子节点,则这个节点称为其子节点的父节点。上图中的 B 节点是 D 节点、E 节点的父节点。 子节点:一个节点含有的子树的根节点称为该节点的子节点。上图中 D 节点、E 节点是 B 节点的子节点。 兄弟节点:具有相同父节点的节点互称为兄弟节点。上图中 D 节点、E 节点的共同父节点是 B 节点,故 D 和 E 为兄弟节点。 叶子节点:没有子节点的节点。上图中的 D、F、H、I 都是叶子节点。 节点的高度:该节点到叶子节点的最长路径所包含的边数。 节点的深度:根节点到该节点的路径所包含的边数 节点的层数:节点的深度+1。 树的高度:根节点的高度。 关于树的深度和高度的定义可以看 stackoverflow 上的这个问题: What is the difference between tree depth and height? 。\n二叉树的分类 # 二叉树(Binary tree)是每个节点最多只有两个分支(即不存在分支度大于 2 的节点)的树结构。\n二叉树 的分支通常被称作“左子树”或“右子树”。并且,二叉树 的分支具有左右次序,不能随意颠倒。\n二叉树 的第 i 层至多拥有 2^(i-1) 个节点,深度为 k 的二叉树至多总共有 2^(k+1)-1 个节点(满二叉树的情况),至少有 2^(k) 个节点(关于节点的深度的定义国内争议比较多,我个人比较认可维基百科对 节点深度的定义)。\n满二叉树 # 一个二叉树,如果每一个层的结点数都达到最大值,则这个二叉树就是 满二叉树。也就是说,如果一个二叉树的层数为 K,且结点总数是(2^k) -1 ,则它就是 满二叉树。如下图所示:\n完全二叉树 # 除最后一层外,若其余层都是满的,并且最后一层是满的或者是在右边缺少连续若干节点,则这个二叉树就是 完全二叉树 。\n大家可以想象为一棵树从根结点开始扩展,扩展完左子节点才能开始扩展右子节点,每扩展完一层,才能继续扩展下一层。如下图所示:\n完全二叉树有一个很好的性质:父结点和子节点的序号有着对应关系。\n细心的小伙伴可能发现了,当根节点的值为 1 的情况下,若父结点的序号是 i,那么左子节点的序号就是 2i,右子节点的序号是 2i+1。这个性质使得完全二叉树利用数组存储时可以极大地节省空间,以及利用序号找到某个节点的父结点和子节点,后续二叉树的存储会详细介绍。\n平衡二叉树 # 平衡二叉树 是一棵二叉排序树,且具有以下性质:\n可以是一棵空树 如果不是空树,它的左右两个子树的高度差的绝对值不超过 1,并且左右两个子树都是一棵平衡二叉树。 平衡二叉树的常用实现方法有 红黑树、AVL 树、替罪羊树、加权平衡树、伸展树 等。\n在给大家展示平衡二叉树之前,先给大家看一棵树:\n你管这玩意儿叫树???\n没错,这玩意儿还真叫树,只不过这棵树已经退化为一个链表了,我们管它叫 斜树。\n如果这样,那我为啥不直接用链表呢?\n谁说不是呢?\n二叉树相比于链表,由于父子节点以及兄弟节点之间往往具有某种特殊的关系,这种关系使得我们在树中对数据进行搜索和修改时,相对于链表更加快捷便利。\n但是,如果二叉树退化为一个链表了,那么那么树所具有的优秀性质就难以表现出来,效率也会大打折,为了避免这样的情况,我们希望每个做 “家长”(父结点) 的,都 一碗水端平,分给左儿子和分给右儿子的尽可能一样多,相差最多不超过一层,如下图所示:\n二叉树的存储 # 二叉树的存储主要分为 链式存储 和 顺序存储 两种:\n链式存储 # 和链表类似,二叉树的链式存储依靠指针将各个节点串联起来,不需要连续的存储空间。\n每个节点包括三个属性:\n数据 data。data 不一定是单一的数据,根据不同情况,可以是多个具有不同类型的数据。 左节点指针 left 右节点指针 right。 可是 JAVA 没有指针啊!\n那就直接引用对象呗(别问我对象哪里找)\n顺序存储 # 顺序存储就是利用数组进行存储,数组中的每一个位置仅存储节点的 data,不存储左右子节点的指针,子节点的索引通过数组下标完成。根结点的序号为 1,对于每个节点 Node,假设它存储在数组中下标为 i 的位置,那么它的左子节点就存储在 2i 的位置,它的右子节点存储在下标为 2i+1 的位置。\n一棵完全二叉树的数组顺序存储如下图所示:\n大家可以试着填写一下存储如下二叉树的数组,比较一下和完全二叉树的顺序存储有何区别:\n可以看到,如果我们要存储的二叉树不是完全二叉树,在数组中就会出现空隙,导致内存利用率降低\n二叉树的遍历 # 先序遍历 # 二叉树的先序遍历,就是先输出根结点,再遍历左子树,最后遍历右子树,遍历左子树和右子树的时候,同样遵循先序遍历的规则,也就是说,我们可以递归实现先序遍历。\n代码如下:\npublic void preOrder(TreeNode root){ if(root == null){ return; } system.out.println(root.data); preOrder(root.left); preOrder(root.right); } 中序遍历 # 二叉树的中序遍历,就是先递归中序遍历左子树,再输出根结点的值,再递归中序遍历右子树,大家可以想象成一巴掌把树压扁,父结点被拍到了左子节点和右子节点的中间,如下图所示:\n代码如下:\npublic void inOrder(TreeNode root){ if(root == null){ return; } inOrder(root.left); system.out.println(root.data); inOrder(root.right); } 后序遍历 # 二叉树的后序遍历,就是先递归后序遍历左子树,再递归后序遍历右子树,最后输出根结点的值\n代码如下:\npublic void postOrder(TreeNode root){ if(root == null){ return; } postOrder(root.left); postOrder(root.right); system.out.println(root.data); } "},{"id":657,"href":"/zh/docs/technology/Interview/database/basis/","title":"数据库基础知识总结","section":"Database","content":"数据库知识基础,这部分内容一定要理解记忆。虽然这部分内容只是理论知识,但是非常重要,这是后面学习 MySQL 数据库的基础。PS: 这部分内容由于涉及太多概念性内容,所以参考了维基百科和百度百科相应的介绍。\n什么是数据库, 数据库管理系统, 数据库系统, 数据库管理员? # 数据库 : 数据库(DataBase 简称 DB)就是信息的集合或者说数据库是由数据库管理系统管理的数据的集合。 数据库管理系统 : 数据库管理系统(Database Management System 简称 DBMS)是一种操纵和管理数据库的大型软件,通常用于建立、使用和维护数据库。 数据库系统 : 数据库系统(Data Base System,简称 DBS)通常由软件、数据库和数据管理员(DBA)组成。 数据库管理员 : 数据库管理员(Database Administrator, 简称 DBA)负责全面管理和控制数据库系统。 什么是元组, 码, 候选码, 主码, 外码, 主属性, 非主属性? # 元组:元组(tuple)是关系数据库中的基本概念,关系是一张表,表中的每行(即数据库中的每条记录)就是一个元组,每列就是一个属性。 在二维表里,元组也称为行。 码:码就是能唯一标识实体的属性,对应表中的列。 候选码:若关系中的某一属性或属性组的值能唯一的标识一个元组,而其任何、子集都不能再标识,则称该属性组为候选码。例如:在学生实体中,“学号”是能唯一的区分学生实体的,同时又假设“姓名”、“班级”的属性组合足以区分学生实体,那么{学号}和{姓名,班级}都是候选码。 主码 : 主码也叫主键。主码是从候选码中选出来的。 一个实体集中只能有一个主码,但可以有多个候选码。 外码 : 外码也叫外键。如果一个关系中的一个属性是另外一个关系中的主码则这个属性为外码。 主属性:候选码中出现过的属性称为主属性。比如关系 工人(工号,身份证号,姓名,性别,部门). 显然工号和身份证号都能够唯一标示这个关系,所以都是候选码。工号、身份证号这两个属性就是主属性。如果主码是一个属性组,那么属性组中的属性都是主属性。 非主属性: 不包含在任何一个候选码中的属性称为非主属性。比如在关系——学生(学号,姓名,年龄,性别,班级)中,主码是“学号”,那么其他的“姓名”、“年龄”、“性别”、“班级”就都可以称为非主属性。 什么是 ER 图? # 我们做一个项目的时候一定要试着画 ER 图来捋清数据库设计,这个也是面试官问你项目的时候经常会被问到的。\nER 图 全称是 Entity Relationship Diagram(实体联系图),提供了表示实体类型、属性和联系的方法。\nER 图由下面 3 个要素组成:\n实体:通常是现实世界的业务对象,当然使用一些逻辑对象也可以。比如对于一个校园管理系统,会涉及学生、教师、课程、班级等等实体。在 ER 图中,实体使用矩形框表示。 属性:即某个实体拥有的属性,属性用来描述组成实体的要素,对于产品设计来说可以理解为字段。在 ER 图中,属性使用椭圆形表示。 联系:即实体与实体之间的关系,在 ER 图中用菱形表示,这个关系不仅有业务关联关系,还能通过数字表示实体之间的数量对照关系。例如,一个班级会有多个学生就是一种实体间的联系。 下图是一个学生选课的 ER 图,每个学生可以选若干门课程,同一门课程也可以被若干人选择,所以它们之间的关系是多对多(M: N)。另外,还有其他两种实体之间的关系是:1 对 1(1:1)、1 对多(1: N)。\n数据库范式了解吗? # 数据库范式有 3 种:\n1NF(第一范式):属性不可再分。 2NF(第二范式):1NF 的基础之上,消除了非主属性对于码的部分函数依赖。 3NF(第三范式):3NF 在 2NF 的基础之上,消除了非主属性对于码的传递函数依赖 。 1NF(第一范式) # 属性(对应于表中的字段)不能再被分割,也就是这个字段只能是一个值,不能再分为多个其他的字段了。1NF 是所有关系型数据库的最基本要求 ,也就是说关系型数据库中创建的表一定满足第一范式。\n2NF(第二范式) # 2NF 在 1NF 的基础之上,消除了非主属性对于码的部分函数依赖。如下图所示,展示了第一范式到第二范式的过渡。第二范式在第一范式的基础上增加了一个列,这个列称为主键,非主属性都依赖于主键。\n一些重要的概念:\n函数依赖(functional dependency):若在一张表中,在属性(或属性组)X 的值确定的情况下,必定能确定属性 Y 的值,那么就可以说 Y 函数依赖于 X,写作 X → Y。 部分函数依赖(partial functional dependency):如果 X→Y,并且存在 X 的一个真子集 X0,使得 X0→Y,则称 Y 对 X 部分函数依赖。比如学生基本信息表 R 中(学号,身份证号,姓名)当然学号属性取值是唯一的,在 R 关系中,(学号,身份证号)-\u0026gt;(姓名),(学号)-\u0026gt;(姓名),(身份证号)-\u0026gt;(姓名);所以姓名部分函数依赖于(学号,身份证号); 完全函数依赖(Full functional dependency):在一个关系中,若某个非主属性数据项依赖于全部关键字称之为完全函数依赖。比如学生基本信息表 R(学号,班级,姓名)假设不同的班级学号有相同的,班级内学号不能相同,在 R 关系中,(学号,班级)-\u0026gt;(姓名),但是(学号)-\u0026gt;(姓名)不成立,(班级)-\u0026gt;(姓名)不成立,所以姓名完全函数依赖与(学号,班级); 传递函数依赖:在关系模式 R(U)中,设 X,Y,Z 是 U 的不同的属性子集,如果 X 确定 Y、Y 确定 Z,且有 X 不包含 Y,Y 不确定 X,(X∪Y)∩Z=空集合,则称 Z 传递函数依赖(transitive functional dependency) 于 X。传递函数依赖会导致数据冗余和异常。传递函数依赖的 Y 和 Z 子集往往同属于某一个事物,因此可将其合并放到一个表中。比如在关系 R(学号 , 姓名, 系名,系主任)中,学号 → 系名,系名 → 系主任,所以存在非主属性系主任对于学号的传递函数依赖。 3NF(第三范式) # 3NF 在 2NF 的基础之上,消除了非主属性对于码的传递函数依赖 。符合 3NF 要求的数据库设计,基本上解决了数据冗余过大,插入异常,修改异常,删除异常的问题。比如在关系 R(学号 , 姓名, 系名,系主任)中,学号 → 系名,系名 → 系主任,所以存在非主属性系主任对于学号的传递函数依赖,所以该表的设计,不符合 3NF 的要求。\n主键和外键有什么区别? # 主键(主码):主键用于唯一标识一个元组,不能有重复,不允许为空。一个表只能有一个主键。 外键(外码):外键用来和其他表建立联系用,外键是另一表的主键,外键是可以有重复的,可以是空值。一个表可以有多个外键。 为什么不推荐使用外键与级联? # 对于外键和级联,阿里巴巴开发手册这样说到:\n【强制】不得使用外键与级联,一切外键概念必须在应用层解决。\n说明: 以学生和成绩的关系为例,学生表中的 student_id 是主键,那么成绩表中的 student_id 则为外键。如果更新学生表中的 student_id,同时触发成绩表中的 student_id 更新,即为级联更新。外键与级联更新适用于单机低并发,不适合分布式、高并发集群;级联更新是强阻塞,存在数据库更新风暴的风险;外键影响数据库的插入速度\n为什么不要用外键呢?大部分人可能会这样回答:\n增加了复杂性: a. 每次做 DELETE 或者 UPDATE 都必须考虑外键约束,会导致开发的时候很痛苦, 测试数据极为不方便; b. 外键的主从关系是定的,假如哪天需求有变化,数据库中的这个字段根本不需要和其他表有关联的话就会增加很多麻烦。 增加了额外工作:数据库需要增加维护外键的工作,比如当我们做一些涉及外键字段的增,删,更新操作之后,需要触发相关操作去检查,保证数据的的一致性和正确性,这样会不得不消耗数据库资源。如果在应用层面去维护的话,可以减小数据库压力; 对分库分表不友好:因为分库分表下外键是无法生效的。 …… 我个人觉得上面这种回答不是特别的全面,只是说了外键存在的一个常见的问题。实际上,我们知道外键也是有很多好处的,比如:\n保证了数据库数据的一致性和完整性; 级联操作方便,减轻了程序代码量; …… 所以说,不要一股脑的就抛弃了外键这个概念,既然它存在就有它存在的道理,如果系统不涉及分库分表,并发量不是很高的情况还是可以考虑使用外键的。\n什么是存储过程? # 我们可以把存储过程看成是一些 SQL 语句的集合,中间加了点逻辑控制语句。存储过程在业务比较复杂的时候是非常实用的,比如很多时候我们完成一个操作可能需要写一大串 SQL 语句,这时候我们就可以写有一个存储过程,这样也方便了我们下一次的调用。存储过程一旦调试完成通过后就能稳定运行,另外,使用存储过程比单纯 SQL 语句执行要快,因为存储过程是预编译过的。\n存储过程在互联网公司应用不多,因为存储过程难以调试和扩展,而且没有移植性,还会消耗数据库资源。\n阿里巴巴 Java 开发手册里要求禁止使用存储过程。\ndrop、delete 与 truncate 区别? # 用法不同 # drop(丢弃数据): drop table 表名 ,直接将表都删除掉,在删除表的时候使用。 truncate (清空数据) : truncate table 表名 ,只删除表中的数据,再插入数据的时候自增长 id 又从 1 开始,在清空表中数据的时候使用。 delete(删除数据) : delete from 表名 where 列名=值,删除某一行的数据,如果不加 where 子句和truncate table 表名作用类似。 truncate 和不带 where子句的 delete、以及 drop 都会删除表内的数据,但是 truncate 和 delete 只删除数据不删除表的结构(定义),执行 drop 语句,此表的结构也会删除,也就是执行drop 之后对应的表不复存在。\n属于不同的数据库语言 # truncate 和 drop 属于 DDL(数据定义语言)语句,操作立即生效,原数据不放到 rollback segment 中,不能回滚,操作不触发 trigger。而 delete 语句是 DML (数据库操作语言)语句,这个操作会放到 rollback segment 中,事务提交之后才生效。\nDML 语句和 DDL 语句区别:\nDML 是数据库操作语言(Data Manipulation Language)的缩写,是指对数据库中表记录的操作,主要包括表记录的插入、更新、删除和查询,是开发人员日常使用最频繁的操作。 DDL (Data Definition Language)是数据定义语言的缩写,简单来说,就是对数据库内部的对象进行创建、删除、修改的操作语言。它和 DML 语言的最大区别是 DML 只是对表内部数据的操作,而不涉及到表的定义、结构的修改,更不会涉及到其他对象。DDL 语句更多的被数据库管理员(DBA)所使用,一般的开发人员很少使用。 另外,由于select不会对表进行破坏,所以有的地方也会把select单独区分开叫做数据库查询语言 DQL(Data Query Language)。\n执行速度不同 # 一般来说:drop \u0026gt; truncate \u0026gt; delete(这个我没有实际测试过)。\ndelete命令执行的时候会产生数据库的binlog日志,而日志记录是需要消耗时间的,但是也有个好处方便数据回滚恢复。 truncate命令执行的时候不会产生数据库日志,因此比delete要快。除此之外,还会把表的自增值重置和索引恢复到初始大小等。 drop命令会把表占用的空间全部释放掉。 Tips:你应该更多地关注在使用场景上,而不是执行效率。\n数据库设计通常分为哪几步? # 需求分析 : 分析用户的需求,包括数据、功能和性能需求。 概念结构设计 : 主要采用 E-R 模型进行设计,包括画 E-R 图。 逻辑结构设计 : 通过将 E-R 图转换成表,实现从 E-R 模型到关系模型的转换。 物理结构设计 : 主要是为所设计的数据库选择合适的存储结构和存取路径。 数据库实施 : 包括编程、测试和试运行 数据库的运行和维护 : 系统的运行与数据库的日常维护。 参考 # https://blog.csdn.net/rl529014/article/details/48391465 https://www.zhihu.com/question/24696366/answer/29189700 https://blog.csdn.net/bieleyang/article/details/77149954 "},{"id":658,"href":"/zh/docs/technology/Interview/high-performance/data-cold-hot-separation/","title":"数据冷热分离详解","section":"High Performance","content":" 什么是数据冷热分离? # 数据冷热分离是指根据数据的访问频率和业务重要性,将数据分为冷数据和热数据,冷数据一般存储在存储在低成本、低性能的介质中,热数据高性能存储介质中。\n冷数据和热数据 # 热数据是指经常被访问和修改且需要快速访问的数据,冷数据是指不经常访问,对当前项目价值较低,但需要长期保存的数据。\n冷热数据到底如何区分呢?有两个常见的区分方法:\n时间维度区分:按照数据的创建时间、更新时间、过期时间等,将一定时间段内的数据视为热数据,超过该时间段的数据视为冷数据。例如,订单系统可以将 1 年前的订单数据作为冷数据,1 年内的订单数据作为热数据。这种方法适用于数据的访问频率和时间有较强的相关性的场景。 访问频率区分:将高频访问的数据视为热数据,低频访问的数据视为冷数据。例如,内容系统可以将浏览量非常低的文章作为冷数据,浏览量较高的文章作为热数据。这种方法需要记录数据的访问频率,成本较高,适合访问频率和数据本身有较强的相关性的场景。 几年前的数据并不一定都是冷数据,例如一些优质文章发表几年后依然有很多人访问,大部分普通用户新发表的文章却基本没什么人访问。\n这两种区分冷热数据的方法各有优劣,实际项目中,可以将两者结合使用。\n冷热分离的思想 # 冷热分离的思想非常简单,就是对数据进行分类,然后分开存储。冷热分离的思想可以应用到很多领域和场景中,而不仅仅是数据存储,例如:\n邮件系统中,可以将近期的比较重要的邮件放在收件箱,将比较久远的不太重要的邮件存入归档。 日常生活中,可以将常用的物品放在显眼的位置,不常用的物品放入储藏室或者阁楼。 图书馆中,可以将最受欢迎和最常借阅的图书单独放在一个显眼的区域,将较少借阅的书籍放在不起眼的位置。 …… 数据冷热分离的优缺点 # 优点:热数据的查询性能得到优化(用户的绝大部分操作体验会更好)、节约成本(可以冷热数据的不同存储需求,选择对应的数据库类型和硬件配置,比如将热数据放在 SSD 上,将冷数据放在 HDD 上) 缺点:系统复杂性和风险增加(需要分离冷热数据,数据错误的风险增加)、统计效率低(统计的时候可能需要用到冷库的数据)。 冷数据如何迁移? # 冷数据迁移方案:\n业务层代码实现:当有对数据进行写操作时,触发冷热分离的逻辑,判断数据是冷数据还是热数据,冷数据就入冷库,热数据就入热库。这种方案会影响性能且冷热数据的判断逻辑不太好确定,还需要修改业务层代码,因此一般不会使用。 任务调度:可以利用 xxl-job 或者其他分布式任务调度平台定时去扫描数据库,找出满足冷数据条件的数据,然后批量地将其复制到冷库中,并从热库中删除。这种方法修改的代码非常少,非常适合按照时间区分冷热数据的场景。 监听数据库的变更日志 binlog :将满足冷数据条件的数据从 binlog 中提取出来,然后复制到冷库中,并从热库中删除。这种方法可以不用修改代码,但不适合按照时间维度区分冷热数据的场景。 如果你的公司有 DBA 的话,也可以让 DBA 进行冷数据的人工迁移,一次迁移完成冷数据到冷库。然后,再搭配上面介绍的方案实现后续冷数据的迁移工作。\n冷数据如何存储? # 冷数据的存储要求主要是容量大,成本低,可靠性高,访问速度可以适当牺牲。\n冷数据存储方案:\n中小厂:直接使用 MySQL/PostgreSQL 即可(不改变数据库选型和项目当前使用的数据库保持一致),比如新增一张表来存储某个业务的冷数据或者使用单独的冷库来存放冷数据(涉及跨库查询,增加了系统复杂性和维护难度) 大厂:Hbase(常用)、RocksDB、Doris、Cassandra 如果公司成本预算足的话,也可以直接上 TiDB 这种分布式关系型数据库,直接一步到位。TiDB 6.0 正式支持数据冷热存储分离,可以降低 SSD 使用成本。使用 TiDB 6.0 的数据放置功能,可以在同一个集群实现海量数据的冷热存储,将新的热数据存入 SSD,历史冷数据存入 HDD。\n案例分享 # 如何快速优化几千万数据量的订单表 - 程序员济癫 - 2023 海量数据冷热分离方案与实践 - 字节跳动技术团队 - 2022 "},{"id":659,"href":"/zh/docs/technology/Interview/system-design/security/data-desensitization/","title":"数据脱敏方案总结","section":"Security","content":" 本文转载完善自 Hutool:一行代码搞定数据脱敏 - 京东云开发者。\n什么是数据脱敏 # 数据脱敏的定义 # 数据脱敏百度百科中是这样定义的:\n数据脱敏,指对某些敏感信息通过脱敏规则进行数据的变形,实现敏感隐私数据的可靠保护。这样就可以在开发、测试和其它非生产环境以及外包环境中安全地使用脱敏后的真实数据集。在涉及客户安全数据或者一些商业性敏感数据的情况下,在不违反系统规则条件下,对真实数据进行改造并提供测试使用,如身份证号、手机号、卡号、客户号等个人信息都需要进行数据脱敏。是数据库安全技术之一。\n总的来说,数据脱敏是指对某些敏感信息通过脱敏规则进行数据的变形,实现敏感隐私数据的可靠保护。\n在数据脱敏过程中,通常会采用不同的算法和技术,以根据不同的需求和场景对数据进行处理。例如,对于身份证号码,可以使用掩码算法(masking)将前几位数字保留,其他位用 “X” 或 \u0026ldquo;*\u0026rdquo; 代替;对于姓名,可以使用伪造(pseudonymization)算法,将真实姓名替换成随机生成的假名。\n常用脱敏规则 # 常用脱敏规则是为了保护敏感数据的安全性,在处理和存储敏感数据时对其进行变换或修改。\n下面是几种常见的脱敏规则:\n替换(常用):将敏感数据中的特定字符或字符序列替换为其他字符。例如,将信用卡号中的中间几位数字替换为星号(*)或其他字符。 删除:将敏感数据中的部分内容随机删除。比如,将电话号码的随机 3 位数字进行删除。 重排:将原始数据中的某些字符或字段的顺序打乱。例如,将身份证号码的随机位交错互换。 加噪:在数据中注入一些误差或者噪音,达到对数据脱敏的效果。例如,在敏感数据中添加一些随机生成的字符。 加密(常用):使用加密算法将敏感数据转换为密文。例如,将银行卡号用 MD5 或 SHA-256 等哈希函数进行散列。常见加密算法总结可以参考这篇文章: https://javaguide.cn/system-design/security/encryption-algorithms.html 。 …… 常用脱敏工具 # Hutool # Hutool 一个 Java 基础工具类,对文件、流、加密解密、转码、正则、线程、XML 等 JDK 方法进行封装,组成各种 Util 工具类,同时提供以下组件:\n模块 介绍 hutool-aop JDK 动态代理封装,提供非 IOC 下的切面支持 hutool-bloomFilter 布隆过滤,提供一些 Hash 算法的布隆过滤 hutool-cache 简单缓存实现 hutool-core 核心,包括 Bean 操作、日期、各种 Util 等 hutool-cron 定时任务模块,提供类 Crontab 表达式的定时任务 hutool-crypto 加密解密模块,提供对称、非对称和摘要算法封装 hutool-db JDBC 封装后的数据操作,基于 ActiveRecord 思想 hutool-dfa 基于 DFA 模型的多关键字查找 hutool-extra 扩展模块,对第三方封装(模板引擎、邮件、Servlet、二维码、Emoji、FTP、分词等) hutool-http 基于 HttpUrlConnection 的 Http 客户端封装 hutool-log 自动识别日志实现的日志门面 hutool-script 脚本执行封装,例如 Javascript hutool-setting 功能更强大的 Setting 配置文件和 Properties 封装 hutool-system 系统参数调用封装(JVM 信息等) hutool-json JSON 实现 hutool-captcha 图片验证码实现 hutool-poi 针对 POI 中 Excel 和 Word 的封装 hutool-socket 基于 Java 的 NIO 和 AIO 的 Socket 封装 hutool-jwt JSON Web Token (JWT) 封装实现 可以根据需求对每个模块单独引入,也可以通过引入hutool-all方式引入所有模块,本文所使用的数据脱敏工具就是在 hutool.core 模块。\n现阶段最新版本的 Hutool 支持的脱敏数据类型如下,基本覆盖了常见的敏感信息。\n用户 id 中文姓名 身份证号 座机号 手机号 地址 电子邮件 密码 中国大陆车牌,包含普通车辆、新能源车辆 银行卡 一行代码实现脱敏 # Hutool 提供的脱敏方法如下图所示:\n注意:Hutool 脱敏是通过 * 来代替敏感信息的,具体实现是在 StrUtil.hide 方法中,如果我们想要自定义隐藏符号,则可以把 Hutool 的源码拷出来,重新实现即可。\n这里以手机号、银行卡号、身份证号、密码信息的脱敏为例,下面是对应的测试代码。\nimport cn.hutool.core.util.DesensitizedUtil; import org.junit.Test; import org.springframework.boot.test.context.Spring BootTest; /** * * @description: Hutool实现数据脱敏 */ @Spring BootTest public class HuToolDesensitizationTest { @Test public void testPhoneDesensitization(){ String phone=\u0026#34;13723231234\u0026#34;; System.out.println(DesensitizedUtil.mobilePhone(phone)); //输出:137====1234 } @Test public void testBankCardDesensitization(){ String bankCard=\u0026#34;6217000130008255666\u0026#34;; System.out.println(DesensitizedUtil.bankCard(bankCard)); //输出:6217 ==== ==== *** 5666 } @Test public void testIdCardNumDesensitization(){ String idCardNum=\u0026#34;411021199901102321\u0026#34;; //只显示前4位和后2位 System.out.println(DesensitizedUtil.idCardNum(idCardNum,4,2)); //输出:4110============21 } @Test public void testPasswordDesensitization(){ String password=\u0026#34;www.jd.com_35711\u0026#34;; System.out.println(DesensitizedUtil.password(password)); //输出:================ } } 以上就是使用 Hutool 封装好的工具类实现数据脱敏。\n配合 JackSon 通过注解方式实现脱敏 # 现在有了数据脱敏工具类,如果前端需要显示数据数据的地方比较多,我们不可能在每个地方都调用一个工具类,这样就显得代码太冗余了,那我们如何通过注解的方式优雅的完成数据脱敏呢?\n如果项目是基于 Spring Boot 的 web 项目,则可以利用 Spring Boot 自带的 jackson 自定义序列化实现。它的实现原理其实就是在 json 进行序列化渲染给前端时,进行脱敏。\n第一步:脱敏策略的枚举。\n/** * @author * @description:脱敏策略枚举 */ public enum DesensitizationTypeEnum { //自定义 MY_RULE, //用户id USER_ID, //中文名 CHINESE_NAME, //身份证号 ID_CARD, //座机号 FIXED_PHONE, //手机号 MOBILE_PHONE, //地址 ADDRESS, //电子邮件 EMAIL, //密码 PASSWORD, //中国大陆车牌,包含普通车辆、新能源车辆 CAR_LICENSE, //银行卡 BANK_CARD } 上面表示支持的脱敏类型。\n第二步:定义一个用于脱敏的 Desensitization 注解。\n@Retention (RetentionPolicy.RUNTIME):运行时生效。 @Target (ElementType.FIELD):可用在字段上。 @JacksonAnnotationsInside:此注解可以点进去看一下是一个元注解,主要是用户打包其他注解一起使用。 @JsonSerialize:上面说到过,该注解的作用就是可自定义序列化,可以用在注解上,方法上,字段上,类上,运行时生效等等,根据提供的序列化类里面的重写方法实现自定义序列化。 /** * @author */ @Target(ElementType.FIELD) @Retention(RetentionPolicy.RUNTIME) @JacksonAnnotationsInside @JsonSerialize(using = DesensitizationSerialize.class) public @interface Desensitization { /** * 脱敏数据类型,在MY_RULE的时候,startInclude和endExclude生效 */ DesensitizationTypeEnum type() default DesensitizationTypeEnum.MY_RULE; /** * 脱敏开始位置(包含) */ int startInclude() default 0; /** * 脱敏结束位置(不包含) */ int endExclude() default 0; } 注:只有使用了自定义的脱敏枚举 MY_RULE 的时候,开始位置和结束位置才生效。\n第三步:创建自定的序列化类\n这一步是我们实现数据脱敏的关键。自定义序列化类继承 JsonSerializer,实现 ContextualSerializer 接口,并重写两个方法。\n/** * @author * @description: 自定义序列化类 */ @AllArgsConstructor @NoArgsConstructor public class DesensitizationSerialize extends JsonSerializer\u0026lt;String\u0026gt; implements ContextualSerializer { private DesensitizationTypeEnum type; private Integer startInclude; private Integer endExclude; @Override public void serialize(String str, JsonGenerator jsonGenerator, SerializerProvider serializerProvider) throws IOException { switch (type) { // 自定义类型脱敏 case MY_RULE: jsonGenerator.writeString(CharSequenceUtil.hide(str, startInclude, endExclude)); break; // userId脱敏 case USER_ID: jsonGenerator.writeString(String.valueOf(DesensitizedUtil.userId())); break; // 中文姓名脱敏 case CHINESE_NAME: jsonGenerator.writeString(DesensitizedUtil.chineseName(String.valueOf(str))); break; // 身份证脱敏 case ID_CARD: jsonGenerator.writeString(DesensitizedUtil.idCardNum(String.valueOf(str), 1, 2)); break; // 固定电话脱敏 case FIXED_PHONE: jsonGenerator.writeString(DesensitizedUtil.fixedPhone(String.valueOf(str))); break; // 手机号脱敏 case MOBILE_PHONE: jsonGenerator.writeString(DesensitizedUtil.mobilePhone(String.valueOf(str))); break; // 地址脱敏 case ADDRESS: jsonGenerator.writeString(DesensitizedUtil.address(String.valueOf(str), 8)); break; // 邮箱脱敏 case EMAIL: jsonGenerator.writeString(DesensitizedUtil.email(String.valueOf(str))); break; // 密码脱敏 case PASSWORD: jsonGenerator.writeString(DesensitizedUtil.password(String.valueOf(str))); break; // 中国车牌脱敏 case CAR_LICENSE: jsonGenerator.writeString(DesensitizedUtil.carLicense(String.valueOf(str))); break; // 银行卡脱敏 case BANK_CARD: jsonGenerator.writeString(DesensitizedUtil.bankCard(String.valueOf(str))); break; default: } } @Override public JsonSerializer\u0026lt;?\u0026gt; createContextual(SerializerProvider serializerProvider, BeanProperty beanProperty) throws JsonMappingException { if (beanProperty != null) { // 判断数据类型是否为String类型 if (Objects.equals(beanProperty.getType().getRawClass(), String.class)) { // 获取定义的注解 Desensitization desensitization = beanProperty.getAnnotation(Desensitization.class); // 为null if (desensitization == null) { desensitization = beanProperty.getContextAnnotation(Desensitization.class); } // 不为null if (desensitization != null) { // 创建定义的序列化类的实例并且返回,入参为注解定义的type,开始位置,结束位置。 return new DesensitizationSerialize(desensitization.type(), desensitization.startInclude(), desensitization.endExclude()); } } return serializerProvider.findValueSerializer(beanProperty.getType(), beanProperty); } return serializerProvider.findNullValueSerializer(null); } } 经过上述三步,已经完成了通过注解实现数据脱敏了,下面我们来测试一下。\n首先定义一个要测试的 pojo,对应的字段加入要脱敏的策略。\n/** * * @description: */ @Data @NoArgsConstructor @AllArgsConstructor public class TestPojo { private String userName; @Desensitization(type = DesensitizationTypeEnum.MOBILE_PHONE) private String phone; @Desensitization(type = DesensitizationTypeEnum.PASSWORD) private String password; @Desensitization(type = DesensitizationTypeEnum.MY_RULE, startInclude = 0, endExclude = 2) private String address; } 接下来写一个测试的 controller\n@RestController public class TestController { @RequestMapping(\u0026#34;/test\u0026#34;) public TestPojo testDesensitization(){ TestPojo testPojo = new TestPojo(); testPojo.setUserName(\u0026#34;我是用户名\u0026#34;); testPojo.setAddress(\u0026#34;地球中国-北京市通州区京东总部2号楼\u0026#34;); testPojo.setPhone(\u0026#34;13782946666\u0026#34;); testPojo.setPassword(\u0026#34;sunyangwei123123123.\u0026#34;); System.out.println(testPojo); return testPojo; } } 可以看到我们成功实现了数据脱敏。\nApache ShardingSphere # ShardingSphere 是一套开源的分布式数据库中间件解决方案组成的生态圈,它由 Sharding-JDBC、Sharding-Proxy 和 Sharding-Sidecar(计划中)这 3 款相互独立的产品组成。 他们均提供标准化的数据分片、分布式事务和数据库治理功能 。\nApache ShardingSphere 下面存在一个数据脱敏模块,此模块集成的常用的数据脱敏的功能。其基本原理是对用户输入的 SQL 进行解析拦截,并依靠用户的脱敏配置进行 SQL 的改写,从而实现对原文字段的加密及加密字段的解密。最终实现对用户无感的加解密存储、查询。\n通过 Apache ShardingSphere 可以自动化\u0026amp;透明化数据脱敏过程,用户无需关注脱敏中间实现细节。并且,提供了多种内置、第三方(AKS)的脱敏策略,用户仅需简单配置即可使用。\n官方文档地址: https://shardingsphere.apache.org/document/4.1.1/cn/features/orchestration/encrypt/ 。\nFastJSON # 平时开发 Web 项目的时候,除了默认的 Spring 自带的序列化工具,FastJson 也是一个很常用的 Spring Web Restful 接口序列化的工具。\nFastJSON 实现数据脱敏的方式主要有两种:\n基于注解 @JSONField 实现:需要自定义一个用于脱敏的序列化的类,然后在需要脱敏的字段上通过 @JSONField 中的 serializeUsing 指定为我们自定义的序列化类型即可。 基于序列化过滤器:需要实现 ValueFilter 接口,重写 process 方法完成自定义脱敏,然后在 JSON 转换时使用自定义的转换策略。具体实现可参考这篇文章: https://juejin.cn/post/7067916686141161479。 Mybatis-Mate # 先介绍一下 MyBatis、MyBatis-Plus 和 Mybatis-Mate 这三者的关系:\nMyBatis 是一款优秀的持久层框架,它支持定制化 SQL、存储过程以及高级映射。 MyBatis-Plus 是一个 MyBatis 的增强工具,能够极大地简化持久层的开发工作。 Mybatis-Mate 是为 MyBatis-Plus 提供的企业级模块,旨在更敏捷优雅处理数据。不过,使用之前需要配置授权码(付费)。 Mybatis-Mate 支持敏感词脱敏,内置手机号、邮箱、银行卡号等 9 种常用脱敏规则。\n@FieldSensitive(\u0026#34;testStrategy\u0026#34;) private String username; @Configuration public class SensitiveStrategyConfig { /** * 注入脱敏策略 */ @Bean public ISensitiveStrategy sensitiveStrategy() { // 自定义 testStrategy 类型脱敏处理 return new SensitiveStrategy().addStrategy(\u0026#34;testStrategy\u0026#34;, t -\u0026gt; t + \u0026#34;==*test==*\u0026#34;); } } // 跳过脱密处理,用于编辑场景 RequestDataTransfer.skipSensitive(); MyBatis-Flex # 类似于 MybatisPlus,MyBatis-Flex 也是一个 MyBatis 增强框架。MyBatis-Flex 同样提供了数据脱敏功能,并且是可以免费使用的。\nMyBatis-Flex 提供了 @ColumnMask() 注解,以及内置的 9 种脱敏规则,开箱即用:\n/** * 内置的数据脱敏方式 */ public class Masks { /** * 手机号脱敏 */ public static final String MOBILE = \u0026#34;mobile\u0026#34;; /** * 固定电话脱敏 */ public static final String FIXED_PHONE = \u0026#34;fixed_phone\u0026#34;; /** * 身份证号脱敏 */ public static final String ID_CARD_NUMBER = \u0026#34;id_card_number\u0026#34;; /** * 中文名脱敏 */ public static final String CHINESE_NAME = \u0026#34;chinese_name\u0026#34;; /** * 地址脱敏 */ public static final String ADDRESS = \u0026#34;address\u0026#34;; /** * 邮件脱敏 */ public static final String EMAIL = \u0026#34;email\u0026#34;; /** * 密码脱敏 */ public static final String PASSWORD = \u0026#34;password\u0026#34;; /** * 车牌号脱敏 */ public static final String CAR_LICENSE = \u0026#34;car_license\u0026#34;; /** * 银行卡号脱敏 */ public static final String BANK_CARD_NUMBER = \u0026#34;bank_card_number\u0026#34;; //... } 使用示例:\n@Table(\u0026#34;tb_account\u0026#34;) public class Account { @Id(keyType = KeyType.Auto) private Long id; @ColumnMask(Masks.CHINESE_NAME) private String userName; @ColumnMask(Masks.EMAIL) private String email; } 如果这些内置的脱敏规则不满足你的要求的话,你还可以自定义脱敏规则。\n1、通过 MaskManager 注册新的脱敏规则:\nMaskManager.registerMaskProcessor(\u0026#34;自定义规则名称\u0026#34; , data -\u0026gt; { return data; }) 2、使用自定义的脱敏规则\n@Table(\u0026#34;tb_account\u0026#34;) public class Account { @Id(keyType = KeyType.Auto) private Long id; @ColumnMask(\u0026#34;自定义规则名称\u0026#34;) private String userName; } 并且,对于需要跳过脱密处理的场景,例如进入编辑页面编辑用户数据,MyBatis-Flex 也提供了对应的支持:\nMaskManager#execWithoutMask(推荐):该方法使用了模版方法设计模式,保障跳过脱敏处理并执行相关逻辑后自动恢复脱敏处理。 MaskManager#skipMask:跳过脱敏处理。 MaskManager#restoreMask:恢复脱敏处理,确保后续的操作继续使用脱敏逻辑。 MaskManager#execWithoutMask方法实现如下:\npublic static \u0026lt;T\u0026gt; T execWithoutMask(Supplier\u0026lt;T\u0026gt; supplier) { try { skipMask(); return supplier.get(); } finally { restoreMask(); } } MaskManager 的skipMask和restoreMask方法一般配套使用,推荐try{...}finally{...}模式。\n总结 # 这篇文章主要介绍了:\n数据脱敏的定义:数据脱敏是指对某些敏感信息通过脱敏规则进行数据的变形,实现敏感隐私数据的可靠保护。 常用的脱敏规则:替换、删除、重排、加噪和加密。 常用的脱敏工具:Hutool、Apache ShardingSphere、FastJSON、Mybatis-Mate 和 MyBatis-Flex。 参考 # Hutool 工具官网: https://hutool.cn/docs/#/ 聊聊如何自定义数据脱敏: https://juejin.cn/post/7046567603971719204 FastJSON 实现数据脱敏: https://juejin.cn/post/7067916686141161479 "},{"id":660,"href":"/zh/docs/technology/Interview/cs-basics/data-structure/graph/","title":"图","section":"Data Structure","content":"图是一种较为复杂的非线性结构。 为啥说其较为复杂呢?\n根据前面的内容,我们知道:\n线性数据结构的元素满足唯一的线性关系,每个元素(除第一个和最后一个外)只有一个直接前趋和一个直接后继。 树形数据结构的元素之间有着明显的层次关系。 但是,图形结构的元素之间的关系是任意的。\n何为图呢? 简单来说,图就是由顶点的有穷非空集合和顶点之间的边组成的集合。通常表示为:G(V,E),其中,G 表示一个图,V 表示顶点的集合,E 表示边的集合。\n下图所展示的就是图这种数据结构,并且还是一张有向图。\n图在我们日常生活中的例子很多!比如我们在社交软件上好友关系就可以用图来表示。\n图的基本概念 # 顶点 # 图中的数据元素,我们称之为顶点,图至少有一个顶点(非空有穷集合)\n对应到好友关系图,每一个用户就代表一个顶点。\n边 # 顶点之间的关系用边表示。\n对应到好友关系图,两个用户是好友的话,那两者之间就存在一条边。\n度 # 度表示一个顶点包含多少条边,在有向图中,还分为出度和入度,出度表示从该顶点出去的边的条数,入度表示进入该顶点的边的条数。\n对应到好友关系图,度就代表了某个人的好友数量。\n无向图和有向图 # 边表示的是顶点之间的关系,有的关系是双向的,比如同学关系,A 是 B 的同学,那么 B 也肯定是 A 的同学,那么在表示 A 和 B 的关系时,就不用关注方向,用不带箭头的边表示,这样的图就是无向图。\n有的关系是有方向的,比如父子关系,师生关系,微博的关注关系,A 是 B 的爸爸,但 B 肯定不是 A 的爸爸,A 关注 B,B 不一定关注 A。在这种情况下,我们就用带箭头的边表示二者的关系,这样的图就是有向图。\n无权图和带权图 # 对于一个关系,如果我们只关心关系的有无,而不关心关系有多强,那么就可以用无权图表示二者的关系。\n对于一个关系,如果我们既关心关系的有无,也关心关系的强度,比如描述地图上两个城市的关系,需要用到距离,那么就用带权图来表示,带权图中的每一条边一个数值表示权值,代表关系的强度。\n下图就是一个带权有向图。\n图的存储 # 邻接矩阵存储 # 邻接矩阵将图用二维矩阵存储,是一种较为直观的表示方式。\n如果第 i 个顶点和第 j 个顶点之间有关系,且关系权值为 n,则 A[i][j]=n 。\n在无向图中,我们只关心关系的有无,所以当顶点 i 和顶点 j 有关系时,A[i][j]=1,当顶点 i 和顶点 j 没有关系时,A[i][j]=0。如下图所示:\n值得注意的是:无向图的邻接矩阵是一个对称矩阵,因为在无向图中,顶点 i 和顶点 j 有关系,则顶点 j 和顶点 i 必有关系。\n邻接矩阵存储的方式优点是简单直接(直接使用一个二维数组即可),并且,在获取两个定点之间的关系的时候也非常高效(直接获取指定位置的数组元素的值即可)。但是,这种存储方式的缺点也比较明显,那就是比较浪费空间,\n邻接表存储 # 针对上面邻接矩阵比较浪费内存空间的问题,诞生了图的另外一种存储方法—邻接表 。\n邻接链表使用一个链表来存储某个顶点的所有后继相邻顶点。对于图中每个顶点 Vi,把所有邻接于 Vi 的顶点 Vj 链成一个单链表,这个单链表称为顶点 Vi 的 邻接表。如下图所示:\n大家可以数一数邻接表中所存储的元素的个数以及图中边的条数,你会发现:\n在无向图中,邻接表元素个数等于边的条数的两倍,如左图所示的无向图中,边的条数为 7,邻接表存储的元素个数为 14。 在有向图中,邻接表元素个数等于边的条数,如右图所示的有向图中,边的条数为 8,邻接表存储的元素个数为 8。 图的搜索 # 广度优先搜索 # 广度优先搜索就像水面上的波纹一样一层一层向外扩展,如下图所示:\n广度优先搜索的具体实现方式用到了之前所学过的线性数据结构——队列 。具体过程如下图所示:\n第 1 步:\n第 2 步:\n第 3 步:\n第 4 步:\n第 5 步:\n第 6 步:\n深度优先搜索 # 深度优先搜索就是“一条路走到黑”,从源顶点开始,一直走到没有后继节点,才回溯到上一顶点,然后继续“一条路走到黑”,如下图所示:\n和广度优先搜索类似,深度优先搜索的具体实现用到了另一种线性数据结构——栈 。具体过程如下图所示:\n第 1 步:\n第 2 步:\n第 3 步:\n第 4 步:\n第 5 步:\n第 6 步:\n"},{"id":661,"href":"/zh/docs/technology/Interview/cs-basics/network/network-attack-means/","title":"网络攻击常见手段总结","section":"Network","content":" 本文整理完善自 TCP/IP 常见攻击手段 - 暖蓝笔记 - 2021这篇文章。\n这篇文章的内容主要是介绍 TCP/IP 常见攻击手段,尤其是 DDoS 攻击,也会补充一些其他的常见网络攻击手段。\nIP 欺骗 # IP 是什么? # 在网络中,所有的设备都会分配一个地址。这个地址就仿佛小蓝的家地址「多少号多少室」,这个号就是分配给整个子网的,「室」对应的号码即分配给子网中计算机的,这就是网络中的地址。「号」对应的号码为网络号,「室」对应的号码为主机号,这个地址的整体就是 IP 地址。\n通过 IP 地址我们能知道什么? # 通过 IP 地址,我们就可以知道判断访问对象服务器的位置,从而将消息发送到服务器。一般发送者发出的消息首先经过子网的集线器,转发到最近的路由器,然后根据路由位置访问下一个路由器的位置,直到终点\nIP 头部格式 :\nIP 欺骗技术是什么? # 骗呗,拐骗,诱骗!\nIP 欺骗技术就是伪造某台主机的 IP 地址的技术。通过 IP 地址的伪装使得某台主机能够伪装另外的一台主机,而这台主机往往具有某种特权或者被另外的主机所信任。\n假设现在有一个合法用户 (1.1.1.1) 已经同服务器建立正常的连接,攻击者构造攻击的 TCP 数据,伪装自己的 IP 为 1.1.1.1,并向服务器发送一个带有 RST 位的 TCP 数据段。服务器接收到这样的数据后,认为从 1.1.1.1 发送的连接有错误,就会清空缓冲区中建立好的连接。\n这时,如果合法用户 1.1.1.1 再发送合法数据,服务器就已经没有这样的连接了,该用户就必须从新开始建立连接。攻击时,伪造大量的 IP 地址,向目标发送 RST 数据,使服务器不对合法用户服务。虽然 IP 地址欺骗攻击有着相当难度,但我们应该清醒地意识到,这种攻击非常广泛,入侵往往从这种攻击开始。\n如何缓解 IP 欺骗? # 虽然无法预防 IP 欺骗,但可以采取措施来阻止伪造数据包渗透网络。入口过滤 是防范欺骗的一种极为常见的防御措施,如 BCP38(通用最佳实践文档)所示。入口过滤是一种数据包过滤形式,通常在 网络边缘设备上实施,用于检查传入的 IP 数据包并确定其源标头。如果这些数据包的源标头与其来源不匹配或者看上去很可疑,则拒绝这些数据包。一些网络还实施出口过滤,检查退出网络的 IP 数据包,确保这些数据包具有合法源标头,以防止网络内部用户使用 IP 欺骗技术发起出站恶意攻击。\nSYN Flood(洪水) # SYN Flood 是什么? # SYN Flood 是互联网上最原始、最经典的 DDoS(Distributed Denial of Service,分布式拒绝服务)攻击之一,旨在耗尽可用服务器资源,致使服务器无法传输合法流量\nSYN Flood 利用了 TCP 协议的三次握手机制,攻击者通常利用工具或者控制僵尸主机向服务器发送海量的变源 IP 地址或变源端口的 TCP SYN 报文,服务器响应了这些报文后就会生成大量的半连接,当系统资源被耗尽后,服务器将无法提供正常的服务。 增加服务器性能,提供更多的连接能力对于 SYN Flood 的海量报文来说杯水车薪,防御 SYN Flood 的关键在于判断哪些连接请求来自于真实源,屏蔽非真实源的请求以保障正常的业务请求能得到服务。\nTCP SYN Flood 攻击原理是什么? # TCP SYN Flood 攻击利用的是 TCP 的三次握手(SYN -\u0026gt; SYN/ACK -\u0026gt; ACK),假设连接发起方是 A,连接接受方是 B,即 B 在某个端口(Port)上监听 A 发出的连接请求,过程如下图所示,左边是 A,右边是 B。\nA 首先发送 SYN(Synchronization)消息给 B,要求 B 做好接收数据的准备;B 收到后反馈 SYN-ACK(Synchronization-Acknowledgement) 消息给 A,这个消息的目的有两个:\n向 A 确认已做好接收数据的准备, 同时要求 A 也做好接收数据的准备,此时 B 已向 A 确认好接收状态,并等待 A 的确认,连接处于半开状态(Half-Open),顾名思义只开了一半;A 收到后再次发送 ACK (Acknowledgement) 消息给 B,向 B 确认也做好了接收数据的准备,至此三次握手完成,「连接」就建立了, 大家注意到没有,最关键的一点在于双方是否都按对方的要求进入了可以接收消息的状态。而这个状态的确认主要是双方将要使用的消息序号(SequenceNum),TCP 为保证消息按发送顺序抵达接收方的上层应用,需要用消息序号来标记消息的发送先后顺序的。\nTCP是「双工」(Duplex)连接,同时支持双向通信,也就是双方同时可向对方发送消息,其中 SYN 和 SYN-ACK 消息开启了 A→B 的单向通信通道(B 获知了 A 的消息序号);SYN-ACK 和 ACK 消息开启了 B→A 单向通信通道(A 获知了 B 的消息序号)。\n上面讨论的是双方在诚实守信,正常情况下的通信。\n但实际情况是,网络可能不稳定会丢包,使握手消息不能抵达对方,也可能是对方故意不按规矩来,故意延迟或不发送握手确认消息。\n假设 B 通过某 TCP 端口提供服务,B 在收到 A 的 SYN 消息时,积极的反馈了 SYN-ACK 消息,使连接进入半开状态,因为 B 不确定自己发给 A 的 SYN-ACK 消息或 A 反馈的 ACK 消息是否会丢在半路,所以会给每个待完成的半开连接都设一个Timer,如果超过时间还没有收到 A 的 ACK 消息,则重新发送一次 SYN-ACK 消息给 A,直到重试超过一定次数时才会放弃。\nB 为帮助 A 能顺利连接,需要分配内核资源维护半开连接,那么当 B 面临海量的连接 A 时,如上图所示,SYN Flood 攻击就形成了。攻击方 A 可以控制肉鸡向 B 发送大量 SYN 消息但不响应 ACK 消息,或者干脆伪造 SYN 消息中的 Source IP,使 B 反馈的 SYN-ACK 消息石沉大海,导致 B 被大量注定不能完成的半开连接占据,直到资源耗尽,停止响应正常的连接请求。\nSYN Flood 的常见形式有哪些? # 恶意用户可通过三种不同方式发起 SYN Flood 攻击:\n直接攻击: 不伪造 IP 地址的 SYN 洪水攻击称为直接攻击。在此类攻击中,攻击者完全不屏蔽其 IP 地址。由于攻击者使用具有真实 IP 地址的单一源设备发起攻击,因此很容易发现并清理攻击者。为使目标机器呈现半开状态,黑客将阻止个人机器对服务器的 SYN-ACK 数据包做出响应。为此,通常采用以下两种方式实现:部署防火墙规则,阻止除 SYN 数据包以外的各类传出数据包;或者,对传入的所有 SYN-ACK 数据包进行过滤,防止其到达恶意用户机器。实际上,这种方法很少使用(即便使用过也不多见),因为此类攻击相当容易缓解 – 只需阻止每个恶意系统的 IP 地址。哪怕攻击者使用僵尸网络(如 Mirai 僵尸网络),通常也不会刻意屏蔽受感染设备的 IP。 欺骗攻击: 恶意用户还可以伪造其发送的各个 SYN 数据包的 IP 地址,以便阻止缓解措施并加大身份暴露难度。虽然数据包可能经过伪装,但还是可以通过这些数据包追根溯源。此类检测工作很难开展,但并非不可实现;特别是,如果 Internet 服务提供商 (ISP) 愿意提供帮助,则更容易实现。 分布式攻击(DDoS): 如果使用僵尸网络发起攻击,则追溯攻击源头的可能性很低。随着混淆级别的攀升,攻击者可能还会命令每台分布式设备伪造其发送数据包的 IP 地址。哪怕攻击者使用僵尸网络(如 Mirai 僵尸网络),通常也不会刻意屏蔽受感染设备的 IP。 如何缓解 SYN Flood? # 扩展积压工作队列 # 目标设备安装的每个操作系统都允许具有一定数量的半开连接。若要响应大量 SYN 数据包,一种方法是增加操作系统允许的最大半开连接数目。为成功扩展最大积压工作,系统必须额外预留内存资源以处理各类新请求。如果系统没有足够的内存,无法应对增加的积压工作队列规模,将对系统性能产生负面影响,但仍然好过拒绝服务。\n回收最先创建的 TCP 半开连接 # 另一种缓解策略是在填充积压工作后覆盖最先创建的半开连接。这项策略要求完全建立合法连接的时间低于恶意 SYN 数据包填充积压工作的时间。当攻击量增加或积压工作规模小于实际需求时,这项特定的防御措施将不奏效。\nSYN Cookie # 此策略要求服务器创建 Cookie。为避免在填充积压工作时断开连接,服务器使用 SYN-ACK 数据包响应每一项连接请求,而后从积压工作中删除 SYN 请求,同时从内存中删除请求,保证端口保持打开状态并做好重新建立连接的准备。如果连接是合法请求并且已将最后一个 ACK 数据包从客户端机器发回服务器,服务器将重建(存在一些限制)SYN 积压工作队列条目。虽然这项缓解措施势必会丢失一些 TCP 连接信息,但好过因此导致对合法用户发起拒绝服务攻击。\nUDP Flood(洪水) # UDP Flood 是什么? # UDP Flood 也是一种拒绝服务攻击,将大量的用户数据报协议(UDP)数据包发送到目标服务器,目的是压倒该设备的处理和响应能力。防火墙保护目标服务器也可能因 UDP 泛滥而耗尽,从而导致对合法流量的拒绝服务。\nUDP Flood 攻击原理是什么? # UDP Flood 主要通过利用服务器响应发送到其中一个端口的 UDP 数据包所采取的步骤。在正常情况下,当服务器在特定端口接收到 UDP 数据包时,会经过两个步骤:\n服务器首先检查是否正在运行正在侦听指定端口的请求的程序。 如果没有程序在该端口接收数据包,则服务器使用 ICMP(ping)数据包进行响应,以通知发送方目的地不可达。 举个例子。假设今天要联系酒店的小蓝,酒店客服接到电话后先查看房间的列表来确保小蓝在客房内,随后转接给小蓝。\n首先,接待员接收到呼叫者要求连接到特定房间的电话。接待员然后需要查看所有房间的清单,以确保客人在房间中可用,并愿意接听电话。碰巧的是,此时如果突然间所有的电话线同时亮起来,那么他们就会很快就变得不堪重负了。\n当服务器接收到每个新的 UDP 数据包时,它将通过步骤来处理请求,并利用该过程中的服务器资源。发送 UDP 报文时,每个报文将包含源设备的 IP 地址。在这种类型的 DDoS 攻击期间,攻击者通常不会使用自己的真实 IP 地址,而是会欺骗 UDP 数据包的源 IP 地址,从而阻止攻击者的真实位置被暴露并潜在地饱和来自目标的响应数据包服务器。\n由于目标服务器利用资源检查并响应每个接收到的 UDP 数据包的结果,当接收到大量 UDP 数据包时,目标的资源可能会迅速耗尽,导致对正常流量的拒绝服务。\n如何缓解 UDP Flooding? # 大多数操作系统部分限制了 ICMP 报文的响应速率,以中断需要 ICMP 响应的 DDoS 攻击。这种缓解的一个缺点是在攻击过程中,合法的数据包也可能被过滤。如果 UDP Flood 的容量足够高以使目标服务器的防火墙的状态表饱和,则在服务器级别发生的任何缓解都将不足以应对目标设备上游的瓶颈。\nHTTP Flood(洪水) # HTTP Flood 是什么? # HTTP Flood 是一种大规模的 DDoS(Distributed Denial of Service,分布式拒绝服务)攻击,旨在利用 HTTP 请求使目标服务器不堪重负。目标因请求而达到饱和,且无法响应正常流量后,将出现拒绝服务,拒绝来自实际用户的其他请求。\nHTTP Flood 的攻击原理是什么? # HTTP 洪水攻击是“第 7 层”DDoS 攻击的一种。第 7 层是 OSI 模型的应用程序层,指的是 HTTP 等互联网协议。HTTP 是基于浏览器的互联网请求的基础,通常用于加载网页或通过互联网发送表单内容。缓解应用程序层攻击特别复杂,因为恶意流量和正常流量很难区分。\n为了获得最大效率,恶意行为者通常会利用或创建僵尸网络,以最大程度地扩大攻击的影响。通过利用感染了恶意软件的多台设备,攻击者可以发起大量攻击流量来进行攻击。\nHTTP 洪水攻击有两种:\nHTTP GET 攻击:在这种攻击形式下,多台计算机或其他设备相互协调,向目标服务器发送对图像、文件或其他资产的多个请求。当目标被传入的请求和响应所淹没时,来自正常流量源的其他请求将被拒绝服务。 HTTP POST 攻击:一般而言,在网站上提交表单时,服务器必须处理传入的请求并将数据推送到持久层(通常是数据库)。与发送 POST 请求所需的处理能力和带宽相比,处理表单数据和运行必要数据库命令的过程相对密集。这种攻击利用相对资源消耗的差异,直接向目标服务器发送许多 POST 请求,直到目标服务器的容量饱和并拒绝服务为止。 如何防护 HTTP Flood? # 如前所述,缓解第 7 层攻击非常复杂,而且通常要从多方面进行。一种方法是对发出请求的设备实施质询,以测试它是否是机器人,这与在线创建帐户时常用的 CAPTCHA 测试非常相似。通过提出 JavaScript 计算挑战之类的要求,可以缓解许多攻击。\n其他阻止 HTTP 洪水攻击的途径包括使用 Web 应用程序防火墙 (WAF)、管理 IP 信誉数据库以跟踪和有选择地阻止恶意流量,以及由工程师进行动态分析。Cloudflare 具有超过 2000 万个互联网设备的规模优势,能够分析来自各种来源的流量并通过快速更新的 WAF 规则和其他防护策略来缓解潜在的攻击,从而消除应用程序层 DDoS 流量。\nDNS Flood(洪水) # DNS Flood 是什么? # 域名系统(DNS)服务器是互联网的“电话簿“;互联网设备通过这些服务器来查找特定 Web 服务器以便访问互联网内容。DNS Flood 攻击是一种分布式拒绝服务(DDoS)攻击,攻击者用大量流量淹没某个域的 DNS 服务器,以尝试中断该域的 DNS 解析。如果用户无法找到电话簿,就无法查找到用于调用特定资源的地址。通过中断 DNS 解析,DNS Flood 攻击将破坏网站、API 或 Web 应用程序响应合法流量的能力。很难将 DNS Flood 攻击与正常的大流量区分开来,因为这些大规模流量往往来自多个唯一地址,查询该域的真实记录,模仿合法流量。\nDNS Flood 的攻击原理是什么? # 域名系统的功能是将易于记忆的名称(例如 example.com)转换成难以记住的网站服务器地址(例如 192.168.0.1),因此成功攻击 DNS 基础设施将导致大多数人无法使用互联网。DNS Flood 攻击是一种相对较新的基于 DNS 的攻击,这种攻击是在高带宽 物联网(IoT) 僵尸网络(如 Mirai)兴起后激增的。DNS Flood 攻击使用 IP 摄像头、DVR 盒和其他 IoT 设备的高带宽连接直接淹没主要提供商的 DNS 服务器。来自 IoT 设备的大量请求淹没 DNS 提供商的服务,阻止合法用户访问提供商的 DNS 服务器。\nDNS Flood 攻击不同于 DNS 放大攻击。与 DNS Flood 攻击不同,DNS 放大攻击反射并放大不安全 DNS 服务器的流量,以便隐藏攻击的源头并提高攻击的有效性。DNS 放大攻击使用连接带宽较小的设备向不安全的 DNS 服务器发送无数请求。这些设备对非常大的 DNS 记录发出小型请求,但在发出请求时,攻击者伪造返回地址为目标受害者。这种放大效果让攻击者能借助有限的攻击资源来破坏较大的目标。\n如何防护 DNS Flood? # DNS Flood 对传统上基于放大的攻击方法做出了改变。借助轻易获得的高带宽僵尸网络,攻击者现能针对大型组织发动攻击。除非被破坏的 IoT 设备得以更新或替换,否则抵御这些攻击的唯一方法是使用一个超大型、高度分布式的 DNS 系统,以便实时监测、吸收和阻止攻击流量。\nTCP 重置攻击 # 在 TCP 重置攻击中,攻击者通过向通信的一方或双方发送伪造的消息,告诉它们立即断开连接,从而使通信双方连接中断。正常情况下,如果客户端收发现到达的报文段对于相关连接而言是不正确的,TCP 就会发送一个重置报文段,从而导致 TCP 连接的快速拆卸。\nTCP 重置攻击利用这一机制,通过向通信方发送伪造的重置报文段,欺骗通信双方提前关闭 TCP 连接。如果伪造的重置报文段完全逼真,接收者就会认为它有效,并关闭 TCP 连接,防止连接被用来进一步交换信息。服务端可以创建一个新的 TCP 连接来恢复通信,但仍然可能会被攻击者重置连接。万幸的是,攻击者需要一定的时间来组装和发送伪造的报文,所以一般情况下这种攻击只对长连接有杀伤力,对于短连接而言,你还没攻击呢,人家已经完成了信息交换。\n从某种意义上来说,伪造 TCP 报文段是很容易的,因为 TCP/IP 都没有任何内置的方法来验证服务端的身份。有些特殊的 IP 扩展协议(例如 IPSec)确实可以验证身份,但并没有被广泛使用。客户端只能接收报文段,并在可能的情况下使用更高级别的协议(如 TLS)来验证服务端的身份。但这个方法对 TCP 重置包并不适用,因为 TCP 重置包是 TCP 协议本身的一部分,无法使用更高级别的协议进行验证。\n模拟攻击 # 以下实验是在 OSX 系统中完成的,其他系统请自行测试。\n现在来总结一下伪造一个 TCP 重置报文要做哪些事情:\n嗅探通信双方的交换信息。 截获一个 ACK 标志位置位 1 的报文段,并读取其 ACK 号。 伪造一个 TCP 重置报文段(RST 标志位置为 1),其序列号等于上面截获的报文的 ACK 号。这只是理想情况下的方案,假设信息交换的速度不是很快。大多数情况下为了增加成功率,可以连续发送序列号不同的重置报文。 将伪造的重置报文发送给通信的一方或双方,时其中断连接。 为了实验简单,我们可以使用本地计算机通过 localhost 与自己通信,然后对自己进行 TCP 重置攻击。需要以下几个步骤:\n在两个终端之间建立一个 TCP 连接。 编写一个能嗅探通信双方数据的攻击程序。 修改攻击程序,伪造并发送重置报文。 下面正式开始实验。\n建立 TCP 连接\n可以使用 netcat 工具来建立 TCP 连接,这个工具很多操作系统都预装了。打开第一个终端窗口,运行以下命令:\nnc -nvl 8000 这个命令会启动一个 TCP 服务,监听端口为 8000。接着再打开第二个终端窗口,运行以下命令:\nnc 127.0.0.1 8000 该命令会尝试与上面的服务建立连接,在其中一个窗口输入一些字符,就会通过 TCP 连接发送给另一个窗口并打印出来。\n嗅探流量\n编写一个攻击程序,使用 Python 网络库 scapy 来读取两个终端窗口之间交换的数据,并将其打印到终端上。代码比较长,下面为一部份,完整代码后台回复 TCP 攻击,代码的核心是调用 scapy 的嗅探方法:\n这段代码告诉 scapy 在 lo0 网络接口上嗅探数据包,并记录所有 TCP 连接的详细信息。\niface : 告诉 scapy 在 lo0(localhost)网络接口上进行监听。 lfilter : 这是个过滤器,告诉 scapy 忽略所有不属于指定的 TCP 连接(通信双方皆为 localhost,且端口号为 8000)的数据包。 prn : scapy 通过这个函数来操作所有符合 lfilter 规则的数据包。上面的例子只是将数据包打印到终端,下文将会修改函数来伪造重置报文。 count : scapy 函数返回之前需要嗅探的数据包数量。 发送伪造的重置报文\n下面开始修改程序,发送伪造的 TCP 重置报文来进行 TCP 重置攻击。根据上面的解读,只需要修改 prn 函数就行了,让其检查数据包,提取必要参数,并利用这些参数来伪造 TCP 重置报文并发送。\n例如,假设该程序截获了一个从(src_ip, src_port)发往 (dst_ip, dst_port)的报文段,该报文段的 ACK 标志位已置为 1,ACK 号为 100,000。攻击程序接下来要做的是:\n由于伪造的数据包是对截获的数据包的响应,所以伪造数据包的源 IP/Port 应该是截获数据包的目的 IP/Port,反之亦然。 将伪造数据包的 RST 标志位置为 1,以表示这是一个重置报文。 将伪造数据包的序列号设置为截获数据包的 ACK 号,因为这是发送方期望收到的下一个序列号。 调用 scapy 的 send 方法,将伪造的数据包发送给截获数据包的发送方。 对于我的程序而言,只需将这一行取消注释,并注释这一行的上面一行,就可以全面攻击了。按照步骤 1 的方法设置 TCP 连接,打开第三个窗口运行攻击程序,然后在 TCP 连接的其中一个终端输入一些字符串,你会发现 TCP 连接被中断了!\n进一步实验\n可以继续使用攻击程序进行实验,将伪造数据包的序列号加减 1 看看会发生什么,是不是确实需要和截获数据包的 ACK 号完全相同。 打开 Wireshark,监听 lo0 网络接口,并使用过滤器 ip.src == 127.0.0.1 \u0026amp;\u0026amp; ip.dst == 127.0.0.1 \u0026amp;\u0026amp; tcp.port == 8000 来过滤无关数据。你可以看到 TCP 连接的所有细节。 在连接上更快速地发送数据流,使攻击更难执行。 中间人攻击 # 猪八戒要向小蓝表白,于是写了一封信给小蓝,结果第三者小黑拦截到了这封信,把这封信进行了篡改,于是乎在他们之间进行搞破坏行动。这个马文才就是中间人,实施的就是中间人攻击。好我们继续聊聊什么是中间人攻击。\n什么是中间人? # 攻击中间人攻击英文名叫 Man-in-the-MiddleAttack,简称「MITM 攻击」。指攻击者与通讯的两端分别创建独立的联系,并交换其所收到的数据,使通讯的两端认为他们正在通过一个私密的连接与对方 直接对话,但事实上整个会话都被攻击者完全控制。我们画一张图:\n从这张图可以看到,中间人其实就是攻击者。通过这种原理,有很多实现的用途,比如说,你在手机上浏览不健康网站的时候,手机就会提示你,此网站可能含有病毒,是否继续访问还是做其他的操作等等。\n中间人攻击的原理是什么? # 举个例子,我和公司签了一个一份劳动合同,一人一份合同。不晓得哪个可能改了合同内容,不知道真假了,怎么搞?只好找专业的机构来鉴定,自然就要花钱。\n在安全领域有句话:我们没有办法杜绝网络犯罪,只好想办法提高网络犯罪的成本。既然没法杜绝这种情况,那我们就想办法提高作案的成本,今天我们就简单了解下基本的网络安全知识,也是面试中的高频面试题了。\n为了避免双方说活不算数的情况,双方引入第三家机构,将合同原文给可信任的第三方机构,只要这个机构不监守自盗,合同就相对安全。\n如果第三方机构内部不严格或容易出现纰漏?\n虽然我们将合同原文给第三方机构了,为了防止内部人员的更改,需要采取什么措施呢\n一种可行的办法是引入 摘要算法 。即合同和摘要一起,为了简单的理解摘要。大家可以想象这个摘要为一个函数,这个函数对原文进行了加密,会产生一个唯一的散列值,一旦原文发生一点点变化,那么这个散列值将会变化。\n有哪些常用的摘要算法呢? # 目前比较常用的加密算法有消息摘要算法和安全散列算法(SHA)。MD5 是将任意长度的文章转化为一个 128 位的散列值,可是在 2004 年,MD5 被证实了容易发生碰撞,即两篇原文产生相同的摘要。这样的话相当于直接给黑客一个后门,轻松伪造摘要。\n所以在大部分的情况下都会选择 SHA 算法 。\n出现内鬼了怎么办?\n看似很安全的场面了,理论上来说杜绝了篡改合同的做法。主要某个员工同时具有修改合同和摘要的权利,那搞事儿就是时间的问题了,毕竟没哪个系统可以完全的杜绝员工接触敏感信息,除非敏感信息都不存在。所以能不能考虑将合同和摘要分开存储呢\n那如何确保员工不会修改合同呢?\n这确实蛮难的,不过办法总比困难多。我们将合同放在双方手中,摘要放在第三方机构,篡改难度进一步加大\n那么员工万一和某个用户串通好了呢?\n看来放在第三方的机构还是不好使,同样存在不小风险。所以还需要寻找新的方案,这就出现了 数字签名和证书。\n数字证书和签名有什么用? # 同样的,举个例子。Sum 和 Mike 两个人签合同。Sum 首先用 SHA 算法计算合同的摘要,然后用自己私钥将摘要加密,得到数字签名。Sum 将合同原文、签名,以及公钥三者都交给 Mike\n如果 Sum 想要证明合同是 Mike 的,那么就要使用 Mike 的公钥,将这个签名解密得到摘要 x,然后 Mike 计算原文的 sha 摘要 Y,随后对比 x 和 y,如果两者相等,就认为数据没有被篡改\n在这样的过程中,Mike 是不能更改 Sum 的合同,因为要修改合同不仅仅要修改原文还要修改摘要,修改摘要需要提供 Mike 的私钥,私钥即 Sum 独有的密码,公钥即 Sum 公布给他人使用的密码\n总之,公钥加密的数据只能私钥可以解密。私钥加密的数据只有公钥可以解密,这就是 非对称加密 。\n隐私保护?不是吓唬大家,信息是透明的兄 die,不过尽量去维护个人的隐私吧,今天学习对称加密和非对称加密。\n大家先读读这个字\u0026quot;钥\u0026quot;,是读\u0026quot;yao\u0026quot;,我以前也是,其实读\u0026quot;yue\u0026quot;\n什么是对称加密? # 对称加密,顾名思义,加密方与解密方使用同一钥匙(秘钥)。具体一些就是,发送方通过使用相应的加密算法和秘钥,对将要发送的信息进行加密;对于接收方而言,使用解密算法和相同的秘钥解锁信息,从而有能力阅读信息。\n常见的对称加密算法有哪些? # DES\nDES 使用的密钥表面上是 64 位的,然而只有其中的 56 位被实际用于算法,其余 8 位可以被用于奇偶校验,并在算法中被丢弃。因此,DES 的有效密钥长度为 56 位,通常称 DES 的密钥长度为 56 位。假设秘钥为 56 位,采用暴力破 Jie 的方式,其秘钥个数为 2 的 56 次方,那么每纳秒执行一次解密所需要的时间差不多 1 年的样子。当然,没人这么干。DES 现在已经不是一种安全的加密方法,主要因为它使用的 56 位密钥过短。\nIDEA\n国际数据加密算法(International Data Encryption Algorithm)。秘钥长度 128 位,优点没有专利的限制。\nAES\n当 DES 被破解以后,没过多久推出了 AES 算法,提供了三种长度供选择,128 位、192 位和 256,为了保证性能不受太大的影响,选择 128 即可。\nSM1 和 SM4\n之前几种都是国外的,我们国内自行研究了国密 SM1和 SM4。其中 S 都属于国家标准,算法公开。优点就是国家的大力支持和认可\n总结:\n常见的非对称加密算法有哪些? # 在对称加密中,发送方与接收方使用相同的秘钥。那么在非对称加密中则是发送方与接收方使用的不同的秘钥。其主要解决的问题是防止在秘钥协商的过程中发生泄漏。比如在对称加密中,小蓝将需要发送的消息加密,然后告诉你密码是 123balala,ok,对于其他人而言,很容易就能劫持到密码是 123balala。那么在非对称的情况下,小蓝告诉所有人密码是 123balala,对于中间人而言,拿到也没用,因为没有私钥。所以,非对称密钥其实主要解决了密钥分发的难题。如下图\n其实我们经常都在使用非对称加密,比如使用多台服务器搭建大数据平台 hadoop,为了方便多台机器设置免密登录,是不是就会涉及到秘钥分发。再比如搭建 docker 集群也会使用相关非对称加密算法。\n常见的非对称加密算法:\nRSA(RSA 加密算法,RSA Algorithm):优势是性能比较快,如果想要较高的加密难度,需要很长的秘钥。\nECC:基于椭圆曲线提出。是目前加密强度最高的非对称加密算法\nSM2:同样基于椭圆曲线问题设计。最大优势就是国家认可和大力支持。\n总结:\n常见的散列算法有哪些? # 这个大家应该更加熟悉了,比如我们平常使用的 MD5 校验,在很多时候,我并不是拿来进行加密,而是用来获得唯一性 ID。在做系统的过程中,存储用户的各种密码信息,通常都会通过散列算法,最终存储其散列值。\nMD5(不推荐)\nMD5 可以用来生成一个 128 位的消息摘要,它是目前应用比较普遍的散列算法,具体的应用场景你可以自行  参阅。虽然,因为算法的缺陷,它的唯一性已经被破解了,但是大部分场景下,这并不会构成安全问题。但是,如果不是长度受限(32 个字符),我还是不推荐你继续使用 MD5 的。\nSHA\n安全散列算法。SHA 分为 SHA1 和 SH2 两个版本。该算法的思想是接收一段明文,然后以一种不可逆的方式将它转换成一段(通常更小)密文,也可以简单的理解为取一串输入码(称为预映射或信息),并把它们转化为长度较短、位数固定的输出序列即散列值(也称为信息摘要或信息认证代码)的过程。\nSM3\n国密算法SM3。加密强度和 SHA-256 算法 相差不多。主要是受到了国家的支持。\n总结:\n大部分情况下使用对称加密,具有比较不错的安全性。如果需要分布式进行秘钥分发,考虑非对称。如果不需要可逆计算则散列算法。 因为这段时间有这方面需求,就看了一些这方面的资料,入坑信息安全,就怕以后洗发水都不用买。谢谢大家查看!\n第三方机构和证书机制有什么用? # 问题还有,此时如果 Sum 否认给过 Mike 的公钥和合同,不久 gg 了\n所以需要 Sum 过的话做过的事儿需要足够的信誉,这就引入了 第三方机构和证书机制 。\n证书之所以会有信用,是因为证书的签发方拥有信用。所以如果 Sum 想让 Mike 承认自己的公钥,Sum 不会直接将公钥给 Mike ,而是提供由第三方机构,含有公钥的证书。如果 Mike 也信任这个机构,法律都认可,那 ik,信任关系成立\n如上图所示,Sum 将自己的申请提交给机构,产生证书的原文。机构用自己的私钥签名 Sum 的申请原文(先根据原文内容计算摘要,再用私钥加密),得到带有签名信息的证书。Mike 拿到带签名信息的证书,通过第三方机构的公钥进行解密,获得 Sum 证书的摘要、证书的原文。有了 Sum 证书的摘要和原文,Mike 就可以进行验签。验签通过,Mike 就可以确认 Sum 的证书的确是第三方机构签发的。\n用上面这样一个机制,合同的双方都无法否认合同。这个解决方案的核心在于需要第三方信用服务机构提供信用背书。这里产生了一个最基础的信任链,如果第三方机构的信任崩溃,比如被黑客攻破,那整条信任链条也就断裂了\n为了让这个信任条更加稳固,就需要环环相扣,打造更长的信任链,避免单点信任风险\n上图中,由信誉最好的根证书机构提供根证书,然后根证书机构去签发二级机构的证书;二级机构去签发三级机构的证书;最后有由三级机构去签发 Sum 证书。\n如果要验证 Sum 证书的合法性,就需要用三级机构证书中的公钥去解密 Sum 证书的数字签名。\n如果要验证三级机构证书的合法性,就需要用二级机构的证书去解密三级机构证书的数字签名。\n如果要验证二级结构证书的合法性,就需要用根证书去解密。\n以上,就构成了一个相对长一些的信任链。如果其中一方想要作弊是非常困难的,除非链条中的所有机构同时联合起来,进行欺诈。\n中间人攻击如何避免? # 既然知道了中间人攻击的原理也知道了他的危险,现在我们看看如何避免。相信我们都遇到过下面这种状况:\n出现这个界面的很多情况下,都是遇到了中间人攻击的现象,需要对安全证书进行及时地监测。而且大名鼎鼎的 github 网站,也曾遭遇过中间人攻击:\n想要避免中间人攻击的方法目前主要有两个:\n客户端不要轻易相信证书:因为这些证书极有可能是中间人。 App 可以提前预埋证书在本地:意思是我们本地提前有一些证书,这样其他证书就不能再起作用了。 DDOS # 通过上面的描述,总之即好多种攻击都是 DDOS 攻击,所以简单总结下这个攻击相关内容。\n其实,像全球互联网各大公司,均遭受过大量的 DDoS。\n2018 年,GitHub 在一瞬间遭到高达 1.35Tbps 的带宽攻击。这次 DDoS 攻击几乎可以堪称是互联网有史以来规模最大、威力最大的 DDoS 攻击了。在 GitHub 遭到攻击后,仅仅一周后,DDoS 攻击又开始对 Google、亚马逊甚至 Pornhub 等网站进行了 DDoS 攻击。后续的 DDoS 攻击带宽最高也达到了 1Tbps。\nDDoS 攻击究竟是什么? # DDos 全名 Distributed Denial of Service,翻译成中文就是分布式拒绝服务。指的是处于不同位置的多个攻击者同时向一个或数个目标发动攻击,是一种分布的、协同的大规模攻击方式。单一的 DoS 攻击一般是采用一对一方式的,它利用网络协议和操作系统的一些缺陷,采用欺骗和伪装的策略来进行网络攻击,使网站服务器充斥大量要求回复的信息,消耗网络带宽或系统资源,导致网络或系统不胜负荷以至于瘫痪而停止提供正常的网络服务。\n举个例子\n我开了一家有五十个座位的重庆火锅店,由于用料上等,童叟无欺。平时门庭若市,生意特别红火,而对面二狗家的火锅店却无人问津。二狗为了对付我,想了一个办法,叫了五十个人来我的火锅店坐着却不点菜,让别的客人无法吃饭。\n上面这个例子讲的就是典型的 DDoS 攻击,一般来说是指攻击者利用“肉鸡”对目标网站在较短的时间内发起大量请求,大规模消耗目标网站的主机资源,让它无法正常服务。在线游戏、互联网金融等领域是 DDoS 攻击的高发行业。\n攻击方式很多,比如 ICMP Flood、UDP Flood、NTP Flood、SYN Flood、CC 攻击、DNS Query Flood等等。\n如何应对 DDoS 攻击? # 高防服务器 # 还是拿开的重庆火锅店举例,高防服务器就是我给重庆火锅店增加了两名保安,这两名保安可以让保护店铺不受流氓骚扰,并且还会定期在店铺周围巡逻防止流氓骚扰。\n高防服务器主要是指能独立硬防御 50Gbps 以上的服务器,能够帮助网站拒绝服务攻击,定期扫描网络主节点等,这东西是不错,就是贵~\n黑名单 # 面对火锅店里面的流氓,我一怒之下将他们拍照入档,并禁止他们踏入店铺,但是有的时候遇到长得像的人也会禁止他进入店铺。这个就是设置黑名单,此方法秉承的就是“错杀一千,也不放一百”的原则,会封锁正常流量,影响到正常业务。\nDDoS 清洗 # DDos 清洗,就是我发现客人进店几分钟以后,但是一直不点餐,我就把他踢出店里。\nDDoS 清洗会对用户请求数据进行实时监控,及时发现 DOS 攻击等异常流量,在不影响正常业务开展的情况下清洗掉这些异常流量。\nCDN 加速 # CDN 加速,我们可以这么理解:为了减少流氓骚扰,我干脆将火锅店开到了线上,承接外卖服务,这样流氓找不到店在哪里,也耍不来流氓了。\n在现实中,CDN 服务将网站访问流量分配到了各个节点中,这样一方面隐藏网站的真实 IP,另一方面即使遭遇 DDoS 攻击,也可以将流量分散到各个节点中,防止源站崩溃。\n参考 # HTTP 洪水攻击 - CloudFlare: https://www.cloudflare.com/zh-cn/learning/ddos/http-flood-ddos-attack/ SYN 洪水攻击: https://www.cloudflare.com/zh-cn/learning/ddos/syn-flood-ddos-attack/ 什么是 IP 欺骗?: https://www.cloudflare.com/zh-cn/learning/ddos/glossary/ip-spoofing/ 什么是 DNS 洪水?| DNS 洪水 DDoS 攻击: https://www.cloudflare.com/zh-cn/learning/ddos/dns-flood-ddos-attack/ "},{"id":662,"href":"/zh/docs/technology/Interview/system-design/system-design-questions/","title":"系统设计常见面试题总结(付费)","section":"System Design","content":"系统设计 相关的面试题为我的 知识星球(点击链接即可查看详细介绍以及加入方法)专属内容,已经整理到了 《Java 面试指北》中。\n"},{"id":663,"href":"/zh/docs/technology/Interview/cs-basics/data-structure/linear-data-structure/","title":"线性数据结构","section":"Data Structure","content":" 1. 数组 # 数组(Array) 是一种很常见的数据结构。它由相同类型的元素(element)组成,并且是使用一块连续的内存来存储。\n我们直接可以利用元素的索引(index)可以计算出该元素对应的存储地址。\n数组的特点是:提供随机访问 并且容量有限。\n假如数组的长度为 n。 访问:O(1)//访问特定位置的元素 插入:O(n )//最坏的情况发生在插入发生在数组的首部并需要移动所有元素时 删除:O(n)//最坏的情况发生在删除数组的开头发生并需要移动第一元素后面所有的元素时 2. 链表 # 2.1. 链表简介 # 链表(LinkedList) 虽然是一种线性表,但是并不会按线性的顺序存储数据,使用的不是连续的内存空间来存储数据。\n链表的插入和删除操作的复杂度为 O(1) ,只需要知道目标位置元素的上一个元素即可。但是,在查找一个节点或者访问特定位置的节点的时候复杂度为 O(n) 。\n使用链表结构可以克服数组需要预先知道数据大小的缺点,链表结构可以充分利用计算机内存空间,实现灵活的内存动态管理。但链表不会节省空间,相比于数组会占用更多的空间,因为链表中每个节点存放的还有指向其他节点的指针。除此之外,链表不具有数组随机读取的优点。\n2.2. 链表分类 # 常见链表分类:\n单链表 双向链表 循环链表 双向循环链表 假如链表中有n个元素。 访问:O(n)//访问特定位置的元素 插入删除:O(1)//必须要要知道插入元素的位置 2.2.1. 单链表 # 单链表 单向链表只有一个方向,结点只有一个后继指针 next 指向后面的节点。因此,链表这种数据结构通常在物理内存上是不连续的。我们习惯性地把第一个结点叫作头结点,链表通常有一个不保存任何值的 head 节点(头结点),通过头结点我们可以遍历整个链表。尾结点通常指向 null。\n2.2.2. 循环链表 # 循环链表 其实是一种特殊的单链表,和单链表不同的是循环链表的尾结点不是指向 null,而是指向链表的头结点。\n2.2.3. 双向链表 # 双向链表 包含两个指针,一个 prev 指向前一个节点,一个 next 指向后一个节点。\n2.2.4. 双向循环链表 # 双向循环链表 最后一个节点的 next 指向 head,而 head 的 prev 指向最后一个节点,构成一个环。\n2.3. 应用场景 # 如果需要支持随机访问的话,链表没办法做到。 如果需要存储的数据元素的个数不确定,并且需要经常添加和删除数据的话,使用链表比较合适。 如果需要存储的数据元素的个数确定,并且不需要经常添加和删除数据的话,使用数组比较合适。 2.4. 数组 vs 链表 # 数组支持随机访问,而链表不支持。 数组使用的是连续内存空间对 CPU 的缓存机制友好,链表则相反。 数组的大小固定,而链表则天然支持动态扩容。如果声明的数组过小,需要另外申请一个更大的内存空间存放数组元素,然后将原数组拷贝进去,这个操作是比较耗时的! 3. 栈 # 3.1. 栈简介 # 栈 (Stack) 只允许在有序的线性数据集合的一端(称为栈顶 top)进行加入数据(push)和移除数据(pop)。因而按照 后进先出(LIFO, Last In First Out) 的原理运作。在栈中,push 和 pop 的操作都发生在栈顶。\n栈常用一维数组或链表来实现,用数组实现的栈叫作 顺序栈 ,用链表实现的栈叫作 链式栈 。\n假设堆栈中有n个元素。 访问:O(n)//最坏情况 插入删除:O(1)//顶端插入和删除元素 3.2. 栈的常见应用场景 # 当我们我们要处理的数据只涉及在一端插入和删除数据,并且满足 后进先出(LIFO, Last In First Out) 的特性时,我们就可以使用栈这个数据结构。\n3.2.1. 实现浏览器的回退和前进功能 # 我们只需要使用两个栈(Stack1 和 Stack2)和就能实现这个功能。比如你按顺序查看了 1,2,3,4 这四个页面,我们依次把 1,2,3,4 这四个页面压入 Stack1 中。当你想回头看 2 这个页面的时候,你点击回退按钮,我们依次把 4,3 这两个页面从 Stack1 弹出,然后压入 Stack2 中。假如你又想回到页面 3,你点击前进按钮,我们将 3 页面从 Stack2 弹出,然后压入到 Stack1 中。示例图如下:\n3.2.2. 检查符号是否成对出现 # 给定一个只包括 '(',')','{','}','[',']' 的字符串,判断该字符串是否有效。\n有效字符串需满足:\n左括号必须用相同类型的右括号闭合。 左括号必须以正确的顺序闭合。 比如 \u0026ldquo;()\u0026quot;、\u0026rdquo;()[]{}\u0026quot;、\u0026quot;{[]}\u0026quot; 都是有效字符串,而 \u0026ldquo;(]\u0026quot;、\u0026rdquo;([)]\u0026quot; 则不是。\n这个问题实际是 Leetcode 的一道题目,我们可以利用栈 Stack 来解决这个问题。\n首先我们将括号间的对应规则存放在 Map 中,这一点应该毋容置疑; 创建一个栈。遍历字符串,如果字符是左括号就直接加入stack中,否则将stack 的栈顶元素与这个括号做比较,如果不相等就直接返回 false。遍历结束,如果stack为空,返回 true。 public boolean isValid(String s){ // 括号之间的对应规则 HashMap\u0026lt;Character, Character\u0026gt; mappings = new HashMap\u0026lt;Character, Character\u0026gt;(); mappings.put(\u0026#39;)\u0026#39;, \u0026#39;(\u0026#39;); mappings.put(\u0026#39;}\u0026#39;, \u0026#39;{\u0026#39;); mappings.put(\u0026#39;]\u0026#39;, \u0026#39;[\u0026#39;); Stack\u0026lt;Character\u0026gt; stack = new Stack\u0026lt;Character\u0026gt;(); char[] chars = s.toCharArray(); for (int i = 0; i \u0026lt; chars.length; i++) { if (mappings.containsKey(chars[i])) { char topElement = stack.empty() ? \u0026#39;#\u0026#39; : stack.pop(); if (topElement != mappings.get(chars[i])) { return false; } } else { stack.push(chars[i]); } } return stack.isEmpty(); } 3.2.3. 反转字符串 # 将字符串中的每个字符先入栈再出栈就可以了。\n3.2.4. 维护函数调用 # 最后一个被调用的函数必须先完成执行,符合栈的 后进先出(LIFO, Last In First Out) 特性。\n例如递归函数调用可以通过栈来实现,每次递归调用都会将参数和返回地址压栈。\n3.2.5 深度优先遍历(DFS) # 在深度优先搜索过程中,栈被用来保存搜索路径,以便回溯到上一层。\n3.3. 栈的实现 # 栈既可以通过数组实现,也可以通过链表来实现。不管基于数组还是链表,入栈、出栈的时间复杂度都为 O(1)。\n下面我们使用数组来实现一个栈,并且这个栈具有push()、pop()(返回栈顶元素并出栈)、peek() (返回栈顶元素不出栈)、isEmpty()、size()这些基本的方法。\n提示:每次入栈之前先判断栈的容量是否够用,如果不够用就用Arrays.copyOf()进行扩容;\npublic class MyStack { private int[] storage;//存放栈中元素的数组 private int capacity;//栈的容量 private int count;//栈中元素数量 private static final int GROW_FACTOR = 2; //不带初始容量的构造方法。默认容量为8 public MyStack() { this.capacity = 8; this.storage=new int[8]; this.count = 0; } //带初始容量的构造方法 public MyStack(int initialCapacity) { if (initialCapacity \u0026lt; 1) throw new IllegalArgumentException(\u0026#34;Capacity too small.\u0026#34;); this.capacity = initialCapacity; this.storage = new int[initialCapacity]; this.count = 0; } //入栈 public void push(int value) { if (count == capacity) { ensureCapacity(); } storage[count++] = value; } //确保容量大小 private void ensureCapacity() { int newCapacity = capacity * GROW_FACTOR; storage = Arrays.copyOf(storage, newCapacity); capacity = newCapacity; } //返回栈顶元素并出栈 private int pop() { if (count == 0) throw new IllegalArgumentException(\u0026#34;Stack is empty.\u0026#34;); count--; return storage[count]; } //返回栈顶元素不出栈 private int peek() { if (count == 0){ throw new IllegalArgumentException(\u0026#34;Stack is empty.\u0026#34;); }else { return storage[count-1]; } } //判断栈是否为空 private boolean isEmpty() { return count == 0; } //返回栈中元素的个数 private int size() { return count; } } 验证\nMyStack myStack = new MyStack(3); myStack.push(1); myStack.push(2); myStack.push(3); myStack.push(4); myStack.push(5); myStack.push(6); myStack.push(7); myStack.push(8); System.out.println(myStack.peek());//8 System.out.println(myStack.size());//8 for (int i = 0; i \u0026lt; 8; i++) { System.out.println(myStack.pop()); } System.out.println(myStack.isEmpty());//true myStack.pop();//报错:java.lang.IllegalArgumentException: Stack is empty. 4. 队列 # 4.1. 队列简介 # 队列(Queue) 是 先进先出 (FIFO,First In, First Out) 的线性表。在具体应用中通常用链表或者数组来实现,用数组实现的队列叫作 顺序队列 ,用链表实现的队列叫作 链式队列 。队列只允许在后端(rear)进行插入操作也就是入队 enqueue,在前端(front)进行删除操作也就是出队 dequeue\n队列的操作方式和堆栈类似,唯一的区别在于队列只允许新数据在后端进行添加。\n假设队列中有n个元素。 访问:O(n)//最坏情况 插入删除:O(1)//后端插入前端删除元素 4.2. 队列分类 # 4.2.1. 单队列 # 单队列就是常见的队列, 每次添加元素时,都是添加到队尾。单队列又分为 顺序队列(数组实现) 和 链式队列(链表实现)。\n顺序队列存在“假溢出”的问题也就是明明有位置却不能添加的情况。\n假设下图是一个顺序队列,我们将前两个元素 1,2 出队,并入队两个元素 7,8。当进行入队、出队操作的时候,front 和 rear 都会持续往后移动,当 rear 移动到最后的时候,我们无法再往队列中添加数据,即使数组中还有空余空间,这种现象就是 ”假溢出“ 。除了假溢出问题之外,如下图所示,当添加元素 8 的时候,rear 指针移动到数组之外(越界)。\n为了避免当只有一个元素的时候,队头和队尾重合使处理变得麻烦,所以引入两个指针,front 指针指向对头元素,rear 指针指向队列最后一个元素的下一个位置,这样当 front 等于 rear 时,此队列不是还剩一个元素,而是空队列。——From 《大话数据结构》\n4.2.2. 循环队列 # 循环队列可以解决顺序队列的假溢出和越界问题。解决办法就是:从头开始,这样也就会形成头尾相接的循环,这也就是循环队列名字的由来。\n还是用上面的图,我们将 rear 指针指向数组下标为 0 的位置就不会有越界问题了。当我们再向队列中添加元素的时候, rear 向后移动。\n顺序队列中,我们说 front==rear 的时候队列为空,循环队列中则不一样,也可能为满,如上图所示。解决办法有两种:\n可以设置一个标志变量 flag,当 front==rear 并且 flag=0 的时候队列为空,当front==rear 并且 flag=1 的时候队列为满。 队列为空的时候就是 front==rear ,队列满的时候,我们保证数组还有一个空闲的位置,rear 就指向这个空闲位置,如下图所示,那么现在判断队列是否为满的条件就是:(rear+1) % QueueSize==front 。 4.2.3 双端队列 # 双端队列 (Deque) 是一种在队列的两端都可以进行插入和删除操作的队列,相比单队列来说更加灵活。\n一般来说,我们可以对双端队列进行 addFirst、addLast、removeFirst 和 removeLast 操作。\n4.2.4 优先队列 # 优先队列 (Priority Queue) 从底层结构上来讲并非线性的数据结构,它一般是由堆来实现的。\n在每个元素入队时,优先队列会将新元素其插入堆中并调整堆。 在队头出队时,优先队列会返回堆顶元素并调整堆。 关于堆的具体实现可以看 堆这一节。\n总而言之,不论我们进行什么操作,优先队列都能按照某种排序方式进行一系列堆的相关操作,从而保证整个集合的有序性。\n虽然优先队列的底层并非严格的线性结构,但是在我们使用的过程中,我们是感知不到堆的,从使用者的眼中优先队列可以被认为是一种线性的数据结构:一种会自动排序的线性队列。\n4.3. 队列的常见应用场景 # 当我们需要按照一定顺序来处理数据的时候可以考虑使用队列这个数据结构。\n阻塞队列: 阻塞队列可以看成在队列基础上加了阻塞操作的队列。当队列为空的时候,出队操作阻塞,当队列满的时候,入队操作阻塞。使用阻塞队列我们可以很容易实现“生产者 - 消费者“模型。 线程池中的请求/任务队列: 线程池中没有空闲线程时,新的任务请求线程资源时,线程池该如何处理呢?答案是将这些请求放在队列中,当有空闲线程的时候,会循环中反复从队列中获取任务来执行。队列分为无界队列(基于链表)和有界队列(基于数组)。无界队列的特点就是可以一直入列,除非系统资源耗尽,比如:FixedThreadPool 使用无界队列 LinkedBlockingQueue。但是有界队列就不一样了,当队列满的话后面再有任务/请求就会拒绝,在 Java 中的体现就是会抛出java.util.concurrent.RejectedExecutionException 异常。 栈:双端队列天生便可以实现栈的全部功能(push、pop 和 peek),并且在 Deque 接口中已经实现了相关方法。Stack 类已经和 Vector 一样被遗弃,现在在 Java 中普遍使用双端队列(Deque)来实现栈。 广度优先搜索(BFS),在图的广度优先搜索过程中,队列被用于存储待访问的节点,保证按照层次顺序遍历图的节点。 Linux 内核进程队列(按优先级排队) 现实生活中的派对,播放器上的播放列表; 消息队列 等等…… "},{"id":664,"href":"/zh/docs/technology/Interview/high-performance/message-queue/message-queue/","title":"消息队列基础知识总结","section":"High Performance","content":"::: tip\n这篇文章中的消息队列主要指的是分布式消息队列。\n:::\n“RabbitMQ?”“Kafka?”“RocketMQ?”\u0026hellip;在日常学习与开发过程中,我们常常听到消息队列这个关键词。我也在我的多篇文章中提到了这个概念。可能你是熟练使用消息队列的老手,又或者你是不懂消息队列的新手,不论你了不了解消息队列,本文都将带你搞懂消息队列的一些基本理论。\n如果你是老手,你可能从本文学到你之前不曾注意的一些关于消息队列的重要概念,如果你是新手,相信本文将是你打开消息队列大门的一板砖。\n什么是消息队列? # 我们可以把消息队列看作是一个存放消息的容器,当我们需要使用消息的时候,直接从容器中取出消息供自己使用即可。由于队列 Queue 是一种先进先出的数据结构,所以消费消息时也是按照顺序来消费的。\n参与消息传递的双方称为 生产者 和 消费者 ,生产者负责发送消息,消费者负责处理消息。\n操作系统中的进程通信的一种很重要的方式就是消息队列。我们这里提到的消息队列稍微有点区别,更多指的是各个服务以及系统内部各个组件/模块之前的通信,属于一种 中间件 。\n维基百科是这样介绍中间件的:\n中间件(英语:Middleware),又译中间件、中介层,是一类提供系统软件和应用软件之间连接、便于软件各部件之间的沟通的软件,应用软件可以借助中间件在不同的技术架构之间共享信息与资源。中间件位于客户机服务器的操作系统之上,管理着计算资源和网络通信。\n简单来说:中间件就是一类为应用软件服务的软件,应用软件是为用户服务的,用户不会接触或者使用到中间件。\n除了消息队列之外,常见的中间件还有 RPC 框架、分布式组件、HTTP 服务器、任务调度框架、配置中心、数据库层的分库分表工具和数据迁移工具等等。\n关于中间件比较详细的介绍可以参考阿里巴巴淘系技术的一篇回答: https://www.zhihu.com/question/19730582/answer/1663627873 。\n随着分布式和微服务系统的发展,消息队列在系统设计中有了更大的发挥空间,使用消息队列可以降低系统耦合性、实现任务异步、有效地进行流量削峰,是分布式和微服务系统中重要的组件之一。\n消息队列有什么用? # 通常来说,使用消息队列主要能为我们的系统带来下面三点好处:\n异步处理 削峰/限流 降低系统耦合性 除了这三点之外,消息队列还有其他的一些应用场景,例如实现分布式事务、顺序保证和数据流处理。\n如果在面试的时候你被面试官问到这个问题的话,一般情况是你在你的简历上涉及到消息队列这方面的内容,这个时候推荐你结合你自己的项目来回答。\n异步处理 # 将用户请求中包含的耗时操作,通过消息队列实现异步处理,将对应的消息发送到消息队列之后就立即返回结果,减少响应时间,提高用户体验。随后,系统再对消息进行消费。\n因为用户请求数据写入消息队列之后就立即返回给用户了,但是请求数据在后续的业务校验、写数据库等操作中可能失败。因此,使用消息队列进行异步处理之后,需要适当修改业务流程进行配合,比如用户在提交订单之后,订单数据写入消息队列,不能立即返回用户订单提交成功,需要在消息队列的订单消费者进程真正处理完该订单之后,甚至出库后,再通过电子邮件或短信通知用户订单成功,以免交易纠纷。这就类似我们平时手机订火车票和电影票。\n削峰/限流 # 先将短时间高并发产生的事务消息存储在消息队列中,然后后端服务再慢慢根据自己的能力去消费这些消息,这样就避免直接把后端服务打垮掉。\n举例:在电子商务一些秒杀、促销活动中,合理使用消息队列可以有效抵御促销活动刚开始大量订单涌入对系统的冲击。如下图所示:\n降低系统耦合性 # 使用消息队列还可以降低系统耦合性。如果模块之间不存在直接调用,那么新增模块或者修改模块就对其他模块影响较小,这样系统的可扩展性无疑更好一些。\n生产者(客户端)发送消息到消息队列中去,消费者(服务端)处理消息,需要消费的系统直接去消息队列取消息进行消费即可而不需要和其他系统有耦合,这显然也提高了系统的扩展性。\n消息队列使用发布-订阅模式工作,消息发送者(生产者)发布消息,一个或多个消息接受者(消费者)订阅消息。 从上图可以看到消息发送者(生产者)和消息接受者(消费者)之间没有直接耦合,消息发送者将消息发送至分布式消息队列即结束对消息的处理,消息接受者从分布式消息队列获取该消息后进行后续处理,并不需要知道该消息从何而来。对新增业务,只要对该类消息感兴趣,即可订阅该消息,对原有系统和业务没有任何影响,从而实现网站业务的可扩展性设计。\n例如,我们商城系统分为用户、订单、财务、仓储、消息通知、物流、风控等多个服务。用户在完成下单后,需要调用财务(扣款)、仓储(库存管理)、物流(发货)、消息通知(通知用户发货)、风控(风险评估)等服务。使用消息队列后,下单操作和后续的扣款、发货、通知等操作就解耦了,下单完成发送一个消息到消息队列,需要用到的地方去订阅这个消息进行消息即可。\n另外,为了避免消息队列服务器宕机造成消息丢失,会将成功发送到消息队列的消息存储在消息生产者服务器上,等消息真正被消费者服务器处理后才删除消息。在消息队列服务器宕机后,生产者服务器会选择分布式消息队列服务器集群中的其他服务器发布消息。\n备注: 不要认为消息队列只能利用发布-订阅模式工作,只不过在解耦这个特定业务环境下是使用发布-订阅模式的。除了发布-订阅模式,还有点对点订阅模式(一个消息只有一个消费者),我们比较常用的是发布-订阅模式。另外,这两种消息模型是 JMS 提供的,AMQP 协议还提供了另外 5 种消息模型。\n实现分布式事务 # 分布式事务的解决方案之一就是 MQ 事务。\nRocketMQ、 Kafka、Pulsar、QMQ 都提供了事务相关的功能。事务允许事件流应用将消费,处理,生产消息整个过程定义为一个原子操作。\n详细介绍可以查看 分布式事务详解(付费) 这篇文章。\n顺序保证 # 在很多应用场景中,处理数据的顺序至关重要。消息队列保证数据按照特定的顺序被处理,适用于那些对数据顺序有严格要求的场景。大部分消息队列,例如 RocketMQ、RabbitMQ、Pulsar、Kafka,都支持顺序消息。\n延时/定时处理 # 消息发送后不会立即被消费,而是指定一个时间,到时间后再消费。大部分消息队列,例如 RocketMQ、RabbitMQ、Pulsar、Kafka,都支持定时/延时消息。\n即时通讯 # MQTT(消息队列遥测传输协议)是一种轻量级的通讯协议,采用发布/订阅模式,非常适合于物联网(IoT)等需要在低带宽、高延迟或不可靠网络环境下工作的应用。它支持即时消息传递,即使在网络条件较差的情况下也能保持通信的稳定性。\nRabbitMQ 内置了 MQTT 插件用于实现 MQTT 功能(默认不启用,需要手动开启)。\n数据流处理 # 针对分布式系统产生的海量数据流,如业务日志、监控数据、用户行为等,消息队列可以实时或批量收集这些数据,并将其导入到大数据处理引擎中,实现高效的数据流管理和处理。\n使用消息队列会带来哪些问题? # 系统可用性降低: 系统可用性在某种程度上降低,为什么这样说呢?在加入 MQ 之前,你不用考虑消息丢失或者说 MQ 挂掉等等的情况,但是,引入 MQ 之后你就需要去考虑了! 系统复杂性提高: 加入 MQ 之后,你需要保证消息没有被重复消费、处理消息丢失的情况、保证消息传递的顺序性等等问题! 一致性问题: 我上面讲了消息队列可以实现异步,消息队列带来的异步确实可以提高系统响应速度。但是,万一消息的真正消费者并没有正确消费消息怎么办?这样就会导致数据不一致的情况了! JMS 和 AMQP # JMS 是什么? # JMS(JAVA Message Service,java 消息服务)是 Java 的消息服务,JMS 的客户端之间可以通过 JMS 服务进行异步的消息传输。JMS(JAVA Message Service,Java 消息服务)API 是一个消息服务的标准或者说是规范,允许应用程序组件基于 JavaEE 平台创建、发送、接收和读取消息。它使分布式通信耦合度更低,消息服务更加可靠以及异步性。\nJMS 定义了五种不同的消息正文格式以及调用的消息类型,允许你发送并接收以一些不同形式的数据:\nStreamMessage:Java 原始值的数据流 MapMessage:一套名称-值对 TextMessage:一个字符串对象 ObjectMessage:一个序列化的 Java 对象 BytesMessage:一个字节的数据流 ActiveMQ(已被淘汰) 就是基于 JMS 规范实现的。\nJMS 两种消息模型 # 点到点(P2P)模型 # 使用队列(Queue)作为消息通信载体;满足生产者与消费者模式,一条消息只能被一个消费者使用,未被消费的消息在队列中保留直到被消费或超时。比如:我们生产者发送 100 条消息的话,两个消费者来消费一般情况下两个消费者会按照消息发送的顺序各自消费一半(也就是你一个我一个的消费。)\n发布/订阅(Pub/Sub)模型 # 发布订阅模型(Pub/Sub) 使用主题(Topic)作为消息通信载体,类似于广播模式;发布者发布一条消息,该消息通过主题传递给所有的订阅者。\nAMQP 是什么? # AMQP,即 Advanced Message Queuing Protocol,一个提供统一消息服务的应用层标准 高级消息队列协议(二进制应用层协议),是应用层协议的一个开放标准,为面向消息的中间件设计,兼容 JMS。基于此协议的客户端与消息中间件可传递消息,并不受客户端/中间件同产品,不同的开发语言等条件的限制。\nRabbitMQ 就是基于 AMQP 协议实现的。\nJMS vs AMQP # 对比方向 JMS AMQP 定义 Java API 协议 跨语言 否 是 跨平台 否 是 支持消息类型 提供两种消息模型:①Peer-2-Peer;②Pub/sub 提供了五种消息模型:①direct exchange;②fanout exchange;③topic change;④headers exchange;⑤system exchange。本质来讲,后四种和 JMS 的 pub/sub 模型没有太大差别,仅是在路由机制上做了更详细的划分; 支持消息类型 支持多种消息类型 ,我们在上面提到过 byte[](二进制) 总结:\nAMQP 为消息定义了线路层(wire-level protocol)的协议,而 JMS 所定义的是 API 规范。在 Java 体系中,多个 client 均可以通过 JMS 进行交互,不需要应用修改代码,但是其对跨平台的支持较差。而 AMQP 天然具有跨平台、跨语言特性。 JMS 支持 TextMessage、MapMessage 等复杂的消息类型;而 AMQP 仅支持 byte[] 消息类型(复杂的类型可序列化后发送)。 由于 Exchange 提供的路由算法,AMQP 可以提供多样化的路由方式来传递消息到消息队列,而 JMS 仅支持 队列 和 主题/订阅 方式两种。 RPC 和消息队列的区别 # RPC 和消息队列都是分布式微服务系统中重要的组件之一,下面我们来简单对比一下两者:\n从用途来看:RPC 主要用来解决两个服务的远程通信问题,不需要了解底层网络的通信机制。通过 RPC 可以帮助我们调用远程计算机上某个服务的方法,这个过程就像调用本地方法一样简单。消息队列主要用来降低系统耦合性、实现任务异步、有效地进行流量削峰。 从通信方式来看:RPC 是双向直接网络通讯,消息队列是单向引入中间载体的网络通讯。 从架构上来看:消息队列需要把消息存储起来,RPC 则没有这个要求,因为前面也说了 RPC 是双向直接网络通讯。 从请求处理的时效性来看:通过 RPC 发出的调用一般会立即被处理,存放在消息队列中的消息并不一定会立即被处理。 RPC 和消息队列本质上是网络通讯的两种不同的实现机制,两者的用途不同,万不可将两者混为一谈。\n分布式消息队列技术选型 # 常见的消息队列有哪些? # Kafka # Kafka 是 LinkedIn 开源的一个分布式流式处理平台,已经成为 Apache 顶级项目,早期被用来用于处理海量的日志,后面才慢慢发展成了一款功能全面的高性能消息队列。\n流式处理平台具有三个关键功能:\n消息队列:发布和订阅消息流,这个功能类似于消息队列,这也是 Kafka 也被归类为消息队列的原因。 容错的持久方式存储记录消息流:Kafka 会把消息持久化到磁盘,有效避免了消息丢失的风险。 流式处理平台: 在消息发布的时候进行处理,Kafka 提供了一个完整的流式处理类库。 Kafka 是一个分布式系统,由通过高性能 TCP 网络协议进行通信的服务器和客户端组成,可以部署在在本地和云环境中的裸机硬件、虚拟机和容器上。\n在 Kafka 2.8 之前,Kafka 最被大家诟病的就是其重度依赖于 Zookeeper 做元数据管理和集群的高可用。在 Kafka 2.8 之后,引入了基于 Raft 协议的 KRaft 模式,不再依赖 Zookeeper,大大简化了 Kafka 的架构,让你可以以一种轻量级的方式来使用 Kafka。\n不过,要提示一下:如果要使用 KRaft 模式的话,建议选择较高版本的 Kafka,因为这个功能还在持续完善优化中。Kafka 3.3.1 版本是第一个将 KRaft(Kafka Raft)共识协议标记为生产就绪的版本。\nKafka 官网: http://kafka.apache.org/\nKafka 更新记录(可以直观看到项目是否还在维护): https://kafka.apache.org/downloads\nRocketMQ # RocketMQ 是阿里开源的一款云原生“消息、事件、流”实时数据处理平台,借鉴了 Kafka,已经成为 Apache 顶级项目。\nRocketMQ 的核心特性(摘自 RocketMQ 官网):\n云原生:生与云,长与云,无限弹性扩缩,K8s 友好 高吞吐:万亿级吞吐保证,同时满足微服务与大数据场景。 流处理:提供轻量、高扩展、高性能和丰富功能的流计算引擎。 金融级:金融级的稳定性,广泛用于交易核心链路。 架构极简:零外部依赖,Shared-nothing 架构。 生态友好:无缝对接微服务、实时计算、数据湖等周边生态。 根据官网介绍:\nApache RocketMQ 自诞生以来,因其架构简单、业务功能丰富、具备极强可扩展性等特点被众多企业开发者以及云厂商广泛采用。历经十余年的大规模场景打磨,RocketMQ 已经成为业内共识的金融级可靠业务消息首选方案,被广泛应用于互联网、大数据、移动互联网、物联网等领域的业务场景。\nRocketMQ 官网: https://rocketmq.apache.org/ (文档很详细,推荐阅读)\nRocketMQ 更新记录(可以直观看到项目是否还在维护): https://github.com/apache/rocketmq/releases\nRabbitMQ # RabbitMQ 是采用 Erlang 语言实现 AMQP(Advanced Message Queuing Protocol,高级消息队列协议)的消息中间件,它最初起源于金融系统,用于在分布式系统中存储转发消息。\nRabbitMQ 发展到今天,被越来越多的人认可,这和它在易用性、扩展性、可靠性和高可用性等方面的卓著表现是分不开的。RabbitMQ 的具体特点可以概括为以下几点:\n可靠性: RabbitMQ 使用一些机制来保证消息的可靠性,如持久化、传输确认及发布确认等。 灵活的路由: 在消息进入队列之前,通过交换器来路由消息。对于典型的路由功能,RabbitMQ 己经提供了一些内置的交换器来实现。针对更复杂的路由功能,可以将多个交换器绑定在一起,也可以通过插件机制来实现自己的交换器。这个后面会在我们讲 RabbitMQ 核心概念的时候详细介绍到。 扩展性: 多个 RabbitMQ 节点可以组成一个集群,也可以根据实际业务情况动态地扩展集群中节点。 高可用性: 队列可以在集群中的机器上设置镜像,使得在部分节点出现问题的情况下队列仍然可用。 支持多种协议: RabbitMQ 除了原生支持 AMQP 协议,还支持 STOMP、MQTT 等多种消息中间件协议。 多语言客户端: RabbitMQ 几乎支持所有常用语言,比如 Java、Python、Ruby、PHP、C#、JavaScript 等。 易用的管理界面: RabbitMQ 提供了一个易用的用户界面,使得用户可以监控和管理消息、集群中的节点等。在安装 RabbitMQ 的时候会介绍到,安装好 RabbitMQ 就自带管理界面。 插件机制: RabbitMQ 提供了许多插件,以实现从多方面进行扩展,当然也可以编写自己的插件。感觉这个有点类似 Dubbo 的 SPI 机制 RabbitMQ 官网: https://www.rabbitmq.com/ 。\nRabbitMQ 更新记录(可以直观看到项目是否还在维护): https://www.rabbitmq.com/news.html\nPulsar # Pulsar 是下一代云原生分布式消息流平台,最初由 Yahoo 开发 ,已经成为 Apache 顶级项目。\nPulsar 集消息、存储、轻量化函数式计算为一体,采用计算与存储分离架构设计,支持多租户、持久化存储、多机房跨区域数据复制,具有强一致性、高吞吐、低延时及高可扩展性等流数据存储特性,被看作是云原生时代实时消息流传输、存储和计算最佳解决方案。\nPulsar 的关键特性如下(摘自官网):\n是下一代云原生分布式消息流平台。 Pulsar 的单个实例原生支持多个集群,可跨机房在集群间无缝地完成消息复制。 极低的发布延迟和端到端延迟。 可无缝扩展到超过一百万个 topic。 简单的客户端 API,支持 Java、Go、Python 和 C++。 主题的多种订阅模式(独占、共享和故障转移)。 通过 Apache BookKeeper 提供的持久化消息存储机制保证消息传递 。 由轻量级的 serverless 计算框架 Pulsar Functions 实现流原生的数据处理。 基于 Pulsar Functions 的 serverless connector 框架 Pulsar IO 使得数据更易移入、移出 Apache Pulsar。 分层式存储可在数据陈旧时,将数据从热存储卸载到冷/长期存储(如 S3、GCS)中。 Pulsar 官网: https://pulsar.apache.org/\nPulsar 更新记录(可以直观看到项目是否还在维护): https://github.com/apache/pulsar/releases\nActiveMQ # 目前已经被淘汰,不推荐使用,不建议学习。\n如何选择? # 参考《Java 工程师面试突击第 1 季-中华石杉老师》\n对比方向 概要 吞吐量 万级的 ActiveMQ 和 RabbitMQ 的吞吐量(ActiveMQ 的性能最差)要比十万级甚至是百万级的 RocketMQ 和 Kafka 低一个数量级。 可用性 都可以实现高可用。ActiveMQ 和 RabbitMQ 都是基于主从架构实现高可用性。RocketMQ 基于分布式架构。 Kafka 也是分布式的,一个数据多个副本,少数机器宕机,不会丢失数据,不会导致不可用 时效性 RabbitMQ 基于 Erlang 开发,所以并发能力很强,性能极其好,延时很低,达到微秒级,其他几个都是 ms 级。 功能支持 Pulsar 的功能更全面,支持多租户、多种消费模式和持久性模式等功能,是下一代云原生分布式消息流平台。 消息丢失 ActiveMQ 和 RabbitMQ 丢失的可能性非常低, Kafka、RocketMQ 和 Pulsar 理论上可以做到 0 丢失。 总结:\nActiveMQ 的社区算是比较成熟,但是较目前来说,ActiveMQ 的性能比较差,而且版本迭代很慢,不推荐使用,已经被淘汰了。 RabbitMQ 在吞吐量方面虽然稍逊于 Kafka、RocketMQ 和 Pulsar,但是由于它基于 Erlang 开发,所以并发能力很强,性能极其好,延时很低,达到微秒级。但是也因为 RabbitMQ 基于 Erlang 开发,所以国内很少有公司有实力做 Erlang 源码级别的研究和定制。如果业务场景对并发量要求不是太高(十万级、百万级),那这几种消息队列中,RabbitMQ 或许是你的首选。 RocketMQ 和 Pulsar 支持强一致性,对消息一致性要求比较高的场景可以使用。 RocketMQ 阿里出品,Java 系开源项目,源代码我们可以直接阅读,然后可以定制自己公司的 MQ,并且 RocketMQ 有阿里巴巴的实际业务场景的实战考验。 Kafka 的特点其实很明显,就是仅仅提供较少的核心功能,但是提供超高的吞吐量,ms 级的延迟,极高的可用性以及可靠性,而且分布式可以任意扩展。同时 Kafka 最好是支撑较少的 topic 数量即可,保证其超高吞吐量。Kafka 唯一的一点劣势是有可能消息重复消费,那么对数据准确性会造成极其轻微的影响,在大数据领域中以及日志采集中,这点轻微影响可以忽略这个特性天然适合大数据实时计算以及日志收集。如果是大数据领域的实时计算、日志采集等场景,用 Kafka 是业内标准的,绝对没问题,社区活跃度很高,绝对不会黄,何况几乎是全世界这个领域的事实性规范。 参考 # 《大型网站技术架构 》 KRaft: Apache Kafka Without ZooKeeper: https://developer.confluent.io/learn/kraft/ 消息队列的使用场景是什么样的?: https://mp.weixin.qq.com/s/4V1jI6RylJr7Jr9JsQe73A "},{"id":665,"href":"/zh/docs/technology/Interview/high-quality-technical-articles/interview/my-personal-experience-in-2021/","title":"校招进入飞书的个人经验","section":"Interview","content":" 推荐语:这篇文章的作者校招最终去了飞书做开发。在这篇文章中,他分享了自己的校招经历以及个人经验。\n原文地址: https://www.ihewro.com/archives/1217/\n基本情况 # 我是 C++主要是后台开发的方向。\n2021 春招入职字节飞书客户端,入职字节之前拿到了百度 offer(音视频直播部分) 以及腾讯 PCG (微视、后台开发)的 HR 面试通过(还没有收到录用意向书)。\n不顺利的春招过程 # 春招实习对我来说不太顺利 # 实验室在 1 月份元旦的那天正式可以放假回家,但回家仍然继续“远程工作”,工作并没有减少,每天日复一日的测试,调试我们开发的“流媒体会议系统”。\n在 1 月的倒数第三天,我们开了“年终总结”线上会议。至此,作为研二基本上与实验室的工作开始告别。也正式开始了春招复习的阶段。\n2 月前已经间歇性的开始准备,无非就是在 LeetCode 上面刷刷题目,一天刷不了几道,后面甚至象征性的刷一下每日一题。对我的算法刷题帮助很少。\n2 月份开始,2 月初的时候,LeetCode 才刷了大概 40 多道题目,挤出了几周时间更新了 handsome 主题的 8.x 版本,这又是一个繁忙的几周。直到春节的当天正式发布,春节过后又开始陆陆续续用一些时间修复 bug,发布修复版本。2 月份这样悄悄溜走。\n找实习的过程 # 2021-3 月初\n3 月 初的时候,投了阿里提前批,没想到阿里 3 月 4 号提前批就结束了,那一天约的一面的电话面也被取消了。紧接了开学实验室开会同步进度的时候,发现大家都一面/二面/三面的进度,而我还没有投递的进度。\n2021-3-8\n投递了字节飞书\n2021-4 月初\n字节第一次一面,腾讯第一次一面\n2021-4 中旬\n美团一、二面,腾讯第二次一面和二面,百度三轮面试,通过了。\n2021-4 底\n腾讯第三次一面和字节第二次一面\n2021-5 月初\n腾讯第三次二面和字节第二次二面,后面这两个都通过了\n阿里 # 第一次投了钉钉,没想到因为行测做的不好,在简历筛选给拒绝了。\n第二次阿里妈妈的后端面试,一面电话面试,我感觉面的还可以,最后题目也做出来了。最后反问阶段问对我的面试有什么建议,面试官说投阿里最好还是 Java 的…… 然后电话结束后就给我拒了……\n当时真的心态有点崩,问了这个晚上 7 点半的面试,一直看书晚上都没吃……\n所以春招和阿里就无缘了。\n美团 # 美团一面的面试官真的人很好。也很轻松,因为他们是 Java 岗位,也没问 c++知识,聊了一些基础知识,后面半个小时就是聊非技术问题,比如最喜欢网络上的某位程序员是谁,如何写出优雅的代码,推荐的技术类的书籍之类的。当时回答王垠是比较喜欢的程序员,面试官笑了说他也很喜欢。面试的氛围感觉很好。\n二面的时候全程就问简历上的一个项目,问了大概 90 分钟,感觉他从一开始就有点不太想要我的感觉,很大原因我觉的是我是 c++,转 Java 可能成本还是有一些的。最后问 HR 说结果待定,几天后通知被拒了。\n百度 # 百度一共三轮面试,在一个下午一起进行,真的很刺激。一面就是很基础的一些 c++问题,写了一个题目说一下思路没让运行(真的要运行还不一定能运行起来:))\n二面也是基础,第一个题目合并两个有序数组,第二个题目写归并排序,写的结果不对,又给我换了一个题目,树的 BFS。二面面试官最后问我对今天面试觉得怎么样,我说虽然中间有一个道题目结果不对,但是思路是对的,可能某个小地方写的有问题,但总体的应该还是可以的。二面就给我通过了。\n三面问的技术问题比较少,30 多分钟,也没写题目,问了一些基本情况和基础知识。最后问部门做的什么内容。面试官说后面 hr 会联系我告诉我内容。\n字节飞书 # 第一次一面就凉了,原因应该是笔试题目结果不对……\n第二次一面在 4 月底了,很顺利。二面在五一劳动节后,面试官还让学姐告诉我让我多看看智能指针,面试的时候让我手写 shared_ptr,我之前看了一些实现,但是没有自己写过,导致代码考虑的不够完善,leader 就一直提醒我要怎么改怎么改。\n本来我以为凉了,在 5 月中旬的时候都准备去百度入职了,给我通知说过了,就这样决定去了字节。\n感悟 # 这么多次面试中,让我感悟最深的是面试中的考察题目真的很重要,因为我在基础知识上面也不突出,再加上如果算法题(一般 1 道或者 2 道)如果没做出来,基本就凉了。而面试之前的笔试考试反而没那么重要,也没那么难。基本 4 题写出来 1~2 道题目就有发起面试的机会了。难度也基本就是 LeetCode top 100 上面的那些算法。\n面试中做题,我很容易紧张,头脑就容易一片空白,稍不注意,写错个符号,或者链表赋值错了,很难看出来问题,导出最终结果不对。\n入职字节实习 # 入职字节之前我本来觉得这个岗位可能是我面试的最适合我的了,因为我主 c++,而且飞书用 c++应该挺深的。来之后就觉得我可能不太喜欢做客户端相关,感觉好复杂……也许服务端好一些,现在我仍然不能确定。\n字节的实习福利在这些公司中应该算是比较好的,小问题是工位比较窄,还是工作强度比其他的互联网公司大一些。字节食堂免费而且挺不错的。字节办公大厦很多,我所在的办公地点比较小。\n目前,需要放轻松,仓库代码慢慢看呗,mentor 也让我不急,准备有问题就多问问,不能憋着,浪费时间。拿到转正 offer 后,秋招还是想多试试外企或者国企。强度太大的工作目前很难适应。\n希望过段时间可以分享一下我的感受,以及能够更加适应目前的工作内容。\n求职经验分享 # 一些概念 # 日常实习与正式(暑期)实习有什么区别 # 日常实习如果一个组比较缺人,就很可能一年四季都招实习生,就会有日常实习的机会,只要是在校学生都可以去面试。而正式实习开始时间有一个范围比较固定,比如每年的 3-6 月,也就是暑期实习。 日常实习相对要好进一些,但是有的日常实习没有转正名额,这个要先确认一下。 字节的日常实习和正式实习在转正没什么区别,都是一起申请转正的。 正式实习拿到 offer 之后什么时候可以去实习 # 暑期实习拿到 offer 后就可以立即实习(一般需要走个流程 1 周左右的样子),也可以选择晚一点去实习,时间可以自己去把握,有的公司可以在系统上选择去实习的时间,有的是直接和 hr 沟通一下就可以。\n提前批和正式批的区别 # 以找实习为例:\n先提前批,再正式批,提前批一般是小组直接招人不进系统,没有笔试,流程相对走的快,一般一面过了,很快就是二面。 正式批面试都会有面评,如果上一次失败的面试评价会影响下一次面试,所以还是谨慎一点好 实习 offer 和正式 offer 区别 # 简单来说,实习 offer 只是给你一个实习的机会,如果在实习期间干的不错就可以转正,获得正式 offer。\n签署正式 offer 之后并不是意味着马上去上班,因为我们是校招生,拿到正式 offer 之后,可以继续实习(工资会是正式工资的百分比),也可以请假一段时间等真正毕业的时候再去正式工作。\n时间节点 # 尽早把简历弄出来,最好就是最近一段时间,因为大家对实验室项目现在还很熟悉,现在写起来不是很难,再过几个月写简历就比较痛苦了。\n以去年为例:\n2 月份中旬的时候阿里提前批开始(基本上只有阿里这个时候开了提前批),3 月 8 号阿里提前批结束。腾讯提前批是 3 月多开始的,4 月 15 号结束 3-5 月拿到实习 offer,最好在 4 月份可以拿到比较想去的实习 offer。 4-8 月份实习,7 月初秋招提前批,7 月底或者 8 月初就是秋招正式批,9 月底秋招就少了挺多,但是只是相对来说,还是有机会, 10 月底秋招基本结束,后面还会有秋招补录 怎么找实习机会,个人觉得可以找认识的人内推比较好,内推好处除了可以帮看进度,一般可以直推到组,这样可以排除一些坑的组。提前知道这个组干嘛的。 实习挺重要,最好是实习的时候就找到一个想去的公司,秋招会轻松很多,因为实习转正基本没什么问题,其次实习转正的 offer 一般要比秋招的好(当然如果秋招表现好也是可以拿到很好的 offer)身边不少人正式 offer 都是实习转正的。 控制好实习的时间,因为边实习边准备秋招挺累的,一般实习的时候工作压力也挺大,没什么时间刷题。 面试准备 # 项目经历 # 我觉得我们实验室项目是没问题的,重要是要讲好。\n项目介绍 首先可能让你介绍一下这个项目是什么东西,以及为什么要去做这个项目。\n项目的结果 然后可能会问这个项目的一些数据上最终结果,比如会议系统能够同时多少人使用,或者量化的体验,比如流畅度,或者是一些其他的一些优势。\n项目中的困难 最后都会问过程中有没有遇到什么困难、挑战的,以及怎么解决的。这个过程中主要考察这个项目的技术点是什么。\n困难是指什么,个人觉得主要是花了好几天才解决的问题就是困难。\n举两个例子:\n第一个例子是排查 bug 方面,比如有一个内存泄露的问题花了一周才排查出来,那就算一个困难,那么解决这个困难的过程就是如何去定位这个问题过程,比如我们先根据错误搜索相关资料,肯定没那么容易就直接找到原因,而是我们会在这些资料中找到一些关键词,比如一些工具,那么我们对这个工具的使用就是解决问题的一个过程。\n第二个例子是需求方案的设计,比如某个需求完成,我们实现这个需求可能有多个可行的设计方案。解决这个困难的过程就是我们对最终选择这个方法的原因,以及其他的设计方案的优缺点的思考。\n面试中被问到:你在工作中碰到的最困难的问题是什么?发现问题,解决问题.-CSDN 博客面试中问到工作中遇到困难是怎么解决的\n有人说我解决方法就是通过百度搜索,但实际上细节也是先搜索某个错误或者问题,但是肯定不可能一下子就搜到了代码答案,而是找到一个答案中有某个关键词,接着我们继续找关键词获取其他的信息。\n笔试 # 找实习的笔试我觉得不会太难,一般如果是 4 道题目,做出来 1-2 道题目差不多就有面试的机会了。\n刷题老生常谈的问题,LeetCode Top100。一开始刷题很痛苦,等刷了 40 道题目的时候就有点感觉的,建议从链表、二叉树开始刷,数组类型题目有很多不能通用的技巧。\n::一定要用白版进行训练::,一定要用白板,不仅仅是为了面试记住 API,更重要的是用白板熟练后,写代码会更熟练而且思路更独立和没有依赖。 算法题重中之重,终点不是困难题目,而是简单,中等,常见,高频的题目要熟能生巧,滚瓜烂熟。 面试的笔试过程中,如果出现了问题,一定要第一时间申请使用本地 IDE 进行调试,否则可能很长时间找不到问题,浪费了机会。 面试 # 面试一般 1 场 1 个小时候分为两个部分,前半部分会问一些基础知识或者项目经历,后半部分做题。\n基础知识复习一开始没必要系统的去复习,首先是确保高频问题必会,比如计算机网络、操作系统那几个必问的问题,可以多看看面经就能找到常问题的问题,对于比较偏问题就算没答上来也不是决定性的影响。\n多看面经!!!!!! 不要一直埋头自己学,要看别人问过了哪些常问的问题。 对于实习工作,看的知识点常见的问题一定要全!!!!!,不是那么精问题不大,一定要全,一定要全!!!! 对于自己不会的,尽量多的说!!!! 实在不行,就往别的地方说!!!总之是引导面试官往自己会的地方上说。 面试中的笔试和前面的笔试风格不同,面试笔试题目不太难,但是考察是冷静思考,代码优雅,没有 bug,先思考清楚!!!在写!!! 在描述项目的难点的时候,不要去聊文档调研是难点,回答这部分问题更应该是技术上的难点,最后通过了什么技术解决了这个问题,这部分技术可以让面试官来更多提问以便知道自己的技术能力。 "},{"id":666,"href":"/zh/docs/technology/Interview/high-quality-technical-articles/work/get-into-work-mode-quickly-when-you-join-a-company/","title":"新入职一家公司如何快速进入工作状态","section":"Work","content":" 推荐语:强烈建议每一位即将入职/在职的小伙伴看看这篇文章,看完之后可以帮助你少踩很多坑。整篇文章逻辑清晰,内容全面!\n原文地址: https://www.cnblogs.com/hunternet/p/14675348.html\n一年一度的金三银四跳槽大戏即将落幕,相信很多跳槽的小伙伴们已经找到了心仪的工作,即将或已经有了新的开始。\n相信有过跳槽经验的小伙伴们都知道,每到一个新的公司面临的可能都是新的业务、新的技术、新的团队……这些可能会打破你原来工作思维、编码习惯、合作方式……\n而于公司而言,又不能给你几个月的时间去慢慢的熟悉。这个时候,如何快速进入工作状态,尽快发挥自己的价值是非常重要的。\n有些人可能会很幸运,入职的公司会有完善的流程与机制,通过一带一、各种培训等方式可以在短时间内快速的让新人进入工作状态。有些人可能就没有那么幸运了,就比如我在几年前跳槽进入某厂的时候,当时还没有像我们现在这么完善的带新人融入的机制,又赶上团队最忙的一段时间,刚一入职的当天下午就让给了我几个线上问题去排查,也没有任何的文档和培训。遇到情况,很多人可能会因为难以快速适应,最终承受不起压力而萌生退意。\n那么,我们应该如何去快速的让自己进入工作状态,适应新的工作节奏呢?\n新的工作面对着一堆的代码仓库,很多人常常感觉无从下手。但回顾一下自己过往的工作与项目的经验,我们可以发现它们有着异曲同工之处。当开始一个新的项目,一般会经历几个步骤:需求-\u0026gt;设计-\u0026gt;开发-\u0026gt;测试-\u0026gt;发布,就这么循环往复,我们完成了一个又一个的项目。\n而在这个过程中主要有四个方面的知识那就是业务、技术、项目与团队贯穿始终。新入职一家公司,我们第一阶段的目标就是要具备能够跟着团队做项目的能力,因此我们所应尽快掌握的知识点也要从这四个方面入手。\n业务 # 很多人可能会认为作为一个技术人,最应该了解的不应该是技术吗?于是他们在进入一家公司后,就迫不及待的研究起来了一些技术文档,系统架构,甚至抱起来源代码就开始“啃”,如果你也是这么做的,那就大错特错了!在几乎所有的公司里,技术都是作为一个工具存在的,虽然它很重要,但是它也是为了承载业务所存在的,技术解决了如何做的问题,而业务却告诉我们,做什么,为什么做。一旦脱离了业务,那么技术的存在将毫无意义。\n想要了解业务,有两个非常重要的方式\n一是靠问\n如果你加入的团队,有着完善的业务培训机制,详尽的需求文档,也许你不需要过多的询问就可以了解业务,但这只是理想中的情况,大多数公司是没有这个条件的。因此我们只能靠问。\n这里不得不提的是,作为一个新人一定要有一定的脸皮厚度,不懂就要问。我见过很多新人会因为内向、腼腆,遇到疑问总是不好意思去问,这导致他们很长一段时间都难以融入团队、承担更重要的责任。不怕要怕挨训、怕被怼,而且我相信绝对多数的程序员还是很好沟通的!\n二是靠测试\n我认为测试绝对是一个人快速了解团队业务的方式。通过测试我们可以走一走自己团队所负责项目的整体流程,如果遇到自己走不下去或想不通的地方及时去问,在这个过程中我们自然而然的就可以快速的了解到核心的业务流程。\n在了解业务的过程中,我们应该注意的是不要让自己过多的去追求细节,我们的目的是先能够整体了解业务流程,我们面向哪些用户,提供了哪些服务……\n技术 # 在我们初步了解完业务之后,就该到技术了,也许你已经按捺不住翻开源代码的准备了,但还是要先提醒你一句先不要着急。\n这个时候我们应该先按照自己了解到的业务,结合自己过往的工作经验去思考一下如果是自己去实现这个系统,应该如何去做?这一步很重要,它可以在后面我们具体去了解系统的技术实现的时候去对比一下与自己的实现思路有哪些差异,为什么会有这些差异,哪些更好,哪些不好,对于不好我们可以提出自己的意见,对于更好的我们可以吸收学习为己用!\n接下来,我们就是要了解技术了,但也不是一上来就去翻源代码。 应该按照从宏观到细节,由外而内逐步地对系统进行分析。\n首先,我们应该简单的了解一下 自己团队/项目的所用到的技术栈 ,Java 还是.NET、亦或是多种语言并存,项目是前后端分离还是服务端全包,使用的数据库是 MySQL 还是 PostgreSQL……,这样我们可能会对所用到的技术和框架,以及自己所负责的内容有一定的预期,这一点有的人可能在面试的时候就会简单了解过。\n下一步,我们应该了解的是 系统的宏观业务架构 。自己的团队主要负责哪些系统,每个系统又主要包含哪些模块,又与哪些外部系统进行交互……对于这些,最好可以通过流程图或者思维导图等方式整理出来。\n然后,我们要做的是看一下 自己的团队提供了哪些对外的接口或者服务 。每个接口和服务所提供功能是什么。这一点我们可以继续去测试自己的系统,这个时候我们要看一看主要流程中主要包含了哪些页面,每个页面又调用了后端的哪些接口,每个后端接口又对应着哪个代码仓库。(如果是单纯做后端服务的,可以看一下我们提供了哪些服务,又有哪些上游服务,每个上游服务调用自己团队的哪些服务……),同样我们应该用画图的形式整理出来。\n接着,我们要了解一下 自己的系统或服务又依赖了哪些外部服务 ,也就是说需要哪些外部系统的支持,这些服务也许是团队之外、公司之外,也可能是其他公司提供的。这个时候我们可以简单的进入代码看一下与外部系统的交互是怎么做的,包括通讯框架(REST、RPC)、通讯协议……\n到了代码层面,我们首先应该了解每个模块代码的层次结构,一个模块分了多少层,每个层次的职责是什么,了解了这个就对系统的整个设计有了初步的概念,紧接着就是代码的目录结构、配置文件的位置。\n最后,我们可以寻找一个示例,可以是一个接口,一个页面,让我们的思路跟随者代码的运行的路线,从入参到出参,完整的走一遍来验证一下我们之前的了解。\n到了这里我们对于技术层面的了解就可以先告一段落了,我们的目的知识对系统有一个初步的认知,更细节的东西,后面我们会有大把的时间去了解\n项目与团队 # 上面我们提到,新入职一家公司,第一阶段的目标是有跟着团队做项目的能力,接下来我们要了解的就是项目是如何运作的。\n我们应该把握从需求设计到代码编写入库最终到发布上线的整个过程中的一些关键点。例如项目采用敏捷还是瀑布的模式,一个迭代周期是多长,需求的来源以及展现形式,有没有需求评审,代码的编写规范是什么,编写完成后如何构建,如何入库,有没有提交规范,如何交付测试,发布前的准备是什么,发布工具如何使用……\n关于项目我们只需要观察同事,或者自己亲身经历一个迭代的开发,就能够大概了解清楚。\n在了解项目运作的同时,我们还应该去了解团队,同样我们应该先从外部开始,我们对接了哪些外部团队,比如需求从哪里来,是否对接公司外部的团队,提供服务的上游团队有哪些,依赖的下游团队有哪些,团队之间如何沟通,常用的沟通方式是什么……\n接下来则是团队内部,团队中有哪些角色,每个人的职责是什么,这样遇到问题我们也可以清楚的找到对应的同事寻求帮助。是否有一些定期的活动与会议,例如每日站会、周例会,是否有一些约定俗成的规矩,是否有一些内部评审,分享机制……\n总结 # 新入职一家公司,面临新的工作挑战,能够尽快进入工作状态,实现自己的价值,将会给你带来一个好的开始。\n作为一个程序员,能够尽快进入工作状态,意味着我们首先应该具备跟着团队做项目的能力,这里我站在了一个后端开发的角度上从业务、技术、项目与团队四个方面总结了一些方法和经验。\n关于如何快速进入工作状态,如果你有好的方法与建议,欢迎在评论区留言。\n最后我们用一张思维导图来回顾一下这篇文章的内容。如果你觉得这篇文章对你有所帮助,可以关注文末公众号,我会经常分享一些自己成长过程中的经验与心得,与大家一起学习与进步。\n"},{"id":667,"href":"/zh/docs/technology/Interview/high-availability/performance-test/","title":"性能测试入门","section":"High Availability","content":"性能测试一般情况下都是由测试这个职位去做的,那还需要我们开发学这个干嘛呢?了解性能测试的指标、分类以及工具等知识有助于我们更好地去写出性能更好的程序,另外作为开发这个角色,如果你会性能测试的话,相信也会为你的履历加分不少。\n这篇文章是我会结合自己的实际经历以及在测试这里取的经所得,除此之外,我还借鉴了一些优秀书籍,希望对你有帮助。\n不同角色看网站性能 # 用户 # 当用户打开一个网站的时候,最关注的是什么?当然是网站响应速度的快慢。比如我们点击了淘宝的主页,淘宝需要多久将首页的内容呈现在我的面前,我点击了提交订单按钮需要多久返回结果等等。\n所以,用户在体验我们系统的时候往往根据你的响应速度的快慢来评判你的网站的性能。\n开发人员 # 用户与开发人员都关注速度,这个速度实际上就是我们的系统处理用户请求的速度。\n开发人员一般情况下很难直观的去评判自己网站的性能,我们往往会根据网站当前的架构以及基础设施情况给一个大概的值,比如:\n项目架构是分布式的吗? 用到了缓存和消息队列没有? 高并发的业务有没有特殊处理? 数据库设计是否合理? 系统用到的算法是否还需要优化? 系统是否存在内存泄露的问题? 项目使用的 Redis 缓存多大?服务器性能如何?用的是机械硬盘还是固态硬盘? …… 测试人员 # 测试人员一般会根据性能测试工具来测试,然后一般会做出一个表格。这个表格可能会涵盖下面这些重要的内容:\n响应时间; 请求成功率; 吞吐量; …… 运维人员 # 运维人员会倾向于根据基础设施和资源的利用率来判断网站的性能,比如我们的服务器资源使用是否合理、数据库资源是否存在滥用的情况、当然,这是传统的运维人员,现在 Devops 火起来后,单纯干运维的很少了。我们这里暂且还保留有这个角色。\n性能测试需要注意的点 # 几乎没有文章在讲性能测试的时候提到这个问题,大家都会讲如何去性能测试,有哪些性能测试指标这些东西。\n了解系统的业务场景 # 性能测试之前更需要你了解当前的系统的业务场景。 对系统业务了解的不够深刻,我们很容易犯测试方向偏执的错误,从而导致我们忽略了对系统某些更需要性能测试的地方进行测试。比如我们的系统可以为用户提供发送邮件的功能,用户配置成功邮箱后只需输入相应的邮箱之后就能发送,系统每天大概能处理上万次发邮件的请求。很多人看到这个可能就直接开始使用相关工具测试邮箱发送接口,但是,发送邮件这个场景可能不是当前系统的性能瓶颈,这么多人用我们的系统发邮件, 还可能有很多人一起发邮件,单单这个场景就这么人用,那用户管理可能才是性能瓶颈吧!\n历史数据非常有用 # 当前系统所留下的历史数据非常重要,一般情况下,我们可以通过相应的些历史数据初步判定这个系统哪些接口调用的比较多、哪些服务承受的压力最大,这样的话,我们就可以针对这些地方进行更细致的性能测试与分析。\n另外,这些地方也就像这个系统的一个短板一样,优化好了这些地方会为我们的系统带来质的提升。\n常见性能指标 # 响应时间 # 响应时间 RT(Response-time)就是用户发出请求到用户收到系统处理结果所需要的时间。\nRT 是一个非常重要且直观的指标,RT 数值大小直接反应了系统处理用户请求速度的快慢。\n并发数 # 并发数可以简单理解为系统能够同时供多少人访问使用也就是说系统同时能处理的请求数量。\n并发数反应了系统的负载能力。\nQPS 和 TPS # QPS(Query Per Second) :服务器每秒可以执行的查询次数; TPS(Transaction Per Second) :服务器每秒处理的事务数(这里的一个事务可以理解为客户发出请求到收到服务器的过程); 书中是这样描述 QPS 和 TPS 的区别的。\nQPS vs TPS:QPS 基本类似于 TPS,但是不同的是,对于一个页面的一次访问,形成一个 TPS;但一次页面请求,可能产生多次对服务器的请求,服务器对这些请求,就可计入“QPS”之中。如,访问一个页面会请求服务器 2 次,一次访问,产生一个“T”,产生 2 个“Q”。\n吞吐量 # 吞吐量指的是系统单位时间内系统处理的请求数量。\n一个系统的吞吐量与请求对系统的资源消耗等紧密关联。请求对系统资源消耗越多,系统吞吐能力越低,反之则越高。\nTPS、QPS 都是吞吐量的常用量化指标。\nQPS(TPS) = 并发数/平均响应时间(RT) 并发数 = QPS * 平均响应时间(RT) 系统活跃度指标 # PV(Page View) # 访问量, 即页面浏览量或点击量,衡量网站用户访问的网页数量;在一定统计周期内用户每打开或刷新一个页面就记录 1 次,多次打开或刷新同一页面则浏览量累计。UV 从网页打开的数量/刷新的次数的角度来统计的。\nUV(Unique Visitor) # 独立访客,统计 1 天内访问某站点的用户数。1 天内相同访客多次访问网站,只计算为 1 个独立访客。UV 是从用户个体的角度来统计的。\nDAU(Daily Active User) # 日活跃用户数量。\nMAU(monthly active users) # 月活跃用户人数。\n举例:某网站 DAU 为 1200w, 用户日均使用时长 1 小时,RT 为 0.5s,求并发量和 QPS。\n平均并发量 = DAU(1200w)* 日均使用时长(1 小时,3600 秒) /一天的秒数(86400)=1200w/24 = 50w\n真实并发量(考虑到某些时间段使用人数比较少) = DAU(1200w)* 日均使用时长(1 小时,3600 秒) /一天的秒数-访问量比较小的时间段假设为 8 小时(57600)=1200w/16 = 75w\n峰值并发量 = 平均并发量 * 6 = 300w\nQPS = 真实并发量/RT = 75W/0.5=150w/s\n性能测试分类 # 性能测试 # 性能测试方法是通过测试工具模拟用户请求系统,目的主要是为了测试系统的性能是否满足要求。通俗地说,这种方法就是要在特定的运行条件下验证系统的能力状态。\n性能测试是你在对系统性能已经有了解的前提之后进行的,并且有明确的性能指标。\n负载测试 # 对被测试的系统继续加大请求压力,直到服务器的某个资源已经达到饱和了,比如系统的缓存已经不够用了或者系统的响应时间已经不满足要求了。\n负载测试说白点就是测试系统的上限。\n压力测试 # 不去管系统资源的使用情况,对系统继续加大请求压力,直到服务器崩溃无法再继续提供服务。\n稳定性测试 # 模拟真实场景,给系统一定压力,看看业务是否能稳定运行。\n常用性能测试工具 # 后端常用 # 既然系统设计涉及到系统性能方面的问题,那在面试的时候,面试官就很可能会问:你是如何进行性能测试的?\n推荐 4 个比较常用的性能测试工具:\nJmeter :Apache JMeter 是 JAVA 开发的性能测试工具。 LoadRunner:一款商业的性能测试工具。 Galtling :一款基于 Scala 开发的高性能服务器性能测试工具。 ab :全称为 Apache Bench 。Apache 旗下的一款测试工具,非常实用。 没记错的话,除了 LoadRunner 其他几款性能测试工具都是开源免费的。\n前端常用 # Fiddler:抓包工具,它可以修改请求的数据,甚至可以修改服务器返回的数据,功能非常强大,是 Web 调试的利器。 HttpWatch: 可用于录制 HTTP 请求信息的工具。 常见的性能优化策略 # 性能优化之前我们需要对请求经历的各个环节进行分析,排查出可能出现性能瓶颈的地方,定位问题。\n下面是一些性能优化时,我经常拿来自问的一些问题:\n系统是否需要缓存? 系统架构本身是不是就有问题? 系统是否存在死锁的地方? 系统是否存在内存泄漏?(Java 的自动回收内存虽然很方便,但是,有时候代码写的不好真的会造成内存泄漏) 数据库索引使用是否合理? …… "},{"id":668,"href":"/zh/docs/technology/Interview/java/concurrent/virtual-thread/","title":"虚拟线程常见问题总结","section":"Concurrent","content":" 本文部分内容来自 Lorin 的 PR。\n虚拟线程在 Java 21 正式发布,这是一项重量级的更新。\n什么是虚拟线程? # 虚拟线程(Virtual Thread)是 JDK 而不是 OS 实现的轻量级线程(Lightweight Process,LWP),由 JVM 调度。许多虚拟线程共享同一个操作系统线程,虚拟线程的数量可以远大于操作系统线程的数量。\n虚拟线程和平台线程有什么关系? # 在引入虚拟线程之前,java.lang.Thread 包已经支持所谓的平台线程(Platform Thread),也就是没有虚拟线程之前,我们一直使用的线程。JVM 调度程序通过平台线程(载体线程)来管理虚拟线程,一个平台线程可以在不同的时间执行不同的虚拟线程(多个虚拟线程挂载在一个平台线程上),当虚拟线程被阻塞或等待时,平台线程可以切换到执行另一个虚拟线程。\n虚拟线程、平台线程和系统内核线程的关系图如下所示(图源: How to Use Java 19 Virtual Threads):\n关于平台线程和系统内核线程的对应关系多提一点:在 Windows 和 Linux 等主流操作系统中,Java 线程采用的是一对一的线程模型,也就是一个平台线程对应一个系统内核线程。Solaris 系统是一个特例,HotSpot VM 在 Solaris 上支持多对多和一对一。具体可以参考 R 大的回答: JVM 中的线程模型是用户级的么?。\n虚拟线程有什么优点和缺点? # 优点 # 非常轻量级:可以在单个线程中创建成百上千个虚拟线程而不会导致过多的线程创建和上下文切换。 简化异步编程: 虚拟线程可以简化异步编程,使代码更易于理解和维护。它可以将异步代码编写得更像同步代码,避免了回调地狱(Callback Hell)。 减少资源开销: 由于虚拟线程是由 JVM 实现的,它能够更高效地利用底层资源,例如 CPU 和内存。虚拟线程的上下文切换比平台线程更轻量,因此能够更好地支持高并发场景。 缺点 # 不适用于计算密集型任务: 虚拟线程适用于 I/O 密集型任务,但不适用于计算密集型任务,因为密集型计算始终需要 CPU 资源作为支持。 与某些第三方库不兼容: 虽然虚拟线程设计时考虑了与现有代码的兼容性,但某些依赖平台线程特性的第三方库可能不完全兼容虚拟线程。 如何创建虚拟线程? # 官方提供了以下四种方式创建虚拟线程:\n使用 Thread.startVirtualThread() 创建 使用 Thread.ofVirtual() 创建 使用 ThreadFactory 创建 使用 Executors.newVirtualThreadPerTaskExecutor()创建 1、使用 Thread.startVirtualThread() 创建\npublic class VirtualThreadTest { public static void main(String[] args) { CustomThread customThread = new CustomThread(); Thread.startVirtualThread(customThread); } } static class CustomThread implements Runnable { @Override public void run() { System.out.println(\u0026#34;CustomThread run\u0026#34;); } } 2、使用 Thread.ofVirtual() 创建\npublic class VirtualThreadTest { public static void main(String[] args) { CustomThread customThread = new CustomThread(); // 创建不启动 Thread unStarted = Thread.ofVirtual().unstarted(customThread); unStarted.start(); // 创建直接启动 Thread.ofVirtual().start(customThread); } } static class CustomThread implements Runnable { @Override public void run() { System.out.println(\u0026#34;CustomThread run\u0026#34;); } } 3、使用 ThreadFactory 创建\npublic class VirtualThreadTest { public static void main(String[] args) { CustomThread customThread = new CustomThread(); ThreadFactory factory = Thread.ofVirtual().factory(); Thread thread = factory.newThread(customThread); thread.start(); } } static class CustomThread implements Runnable { @Override public void run() { System.out.println(\u0026#34;CustomThread run\u0026#34;); } } 4、使用Executors.newVirtualThreadPerTaskExecutor()创建\npublic class VirtualThreadTest { public static void main(String[] args) { CustomThread customThread = new CustomThread(); ExecutorService executor = Executors.newVirtualThreadPerTaskExecutor(); executor.submit(customThread); } } static class CustomThread implements Runnable { @Override public void run() { System.out.println(\u0026#34;CustomThread run\u0026#34;); } } 虚拟线程和平台线程性能对比 # 通过多线程和虚拟线程的方式处理相同的任务,对比创建的系统线程数和处理耗时。\n说明:统计创建的系统线程中部分为后台线程(比如 GC 线程),两种场景下都一样,所以并不影响对比。\n测试代码:\npublic class VirtualThreadTest { static List\u0026lt;Integer\u0026gt; list = new ArrayList\u0026lt;\u0026gt;(); public static void main(String[] args) { // 开启线程 统计平台线程数 ScheduledExecutorService scheduledExecutorService = Executors.newScheduledThreadPool(1); scheduledExecutorService.scheduleAtFixedRate(() -\u0026gt; { ThreadMXBean threadBean = ManagementFactory.getThreadMXBean(); ThreadInfo[] threadInfo = threadBean.dumpAllThreads(false, false); updateMaxThreadNum(threadInfo.length); }, 10, 10, TimeUnit.MILLISECONDS); long start = System.currentTimeMillis(); // 虚拟线程 ExecutorService executor = Executors.newVirtualThreadPerTaskExecutor(); // 使用平台线程 // ExecutorService executor = Executors.newFixedThreadPool(200); for (int i = 0; i \u0026lt; 10000; i++) { executor.submit(() -\u0026gt; { try { // 线程睡眠 0.5 s,模拟业务处理 TimeUnit.MILLISECONDS.sleep(500); } catch (InterruptedException ignored) { } }); } executor.close(); System.out.println(\u0026#34;max:\u0026#34; + list.get(0) + \u0026#34; platform thread/os thread\u0026#34;); System.out.printf(\u0026#34;totalMillis:%dms\\n\u0026#34;, System.currentTimeMillis() - start); } // 更新创建的平台最大线程数 private static void updateMaxThreadNum(int num) { if (list.isEmpty()) { list.add(num); } else { Integer integer = list.get(0); if (num \u0026gt; integer) { list.add(0, num); } } } } 请求数 10000 单请求耗时 1s:\n// Virtual Thread max:22 platform thread/os thread totalMillis:1806ms // Platform Thread 线程数200 max:209 platform thread/os thread totalMillis:50578ms // Platform Thread 线程数500 max:509 platform thread/os thread totalMillis:20254ms // Platform Thread 线程数1000 max:1009 platform thread/os thread totalMillis:10214ms // Platform Thread 线程数2000 max:2009 platform thread/os thread totalMillis:5358ms 请求数 10000 单请求耗时 0.5s:\n// Virtual Thread max:22 platform thread/os thread totalMillis:1316ms // Platform Thread 线程数200 max:209 platform thread/os thread totalMillis:25619ms // Platform Thread 线程数500 max:509 platform thread/os thread totalMillis:10277ms // Platform Thread 线程数1000 max:1009 platform thread/os thread totalMillis:5197ms // Platform Thread 线程数2000 max:2009 platform thread/os thread totalMillis:2865ms 可以看到在密集 IO 的场景下,需要创建大量的平台线程异步处理才能达到虚拟线程的处理速度。 因此,在密集 IO 的场景,虚拟线程可以大幅提高线程的执行效率,减少线程资源的创建以及上下文切换。 注意:有段时间 JDK 一直致力于 Reactor 响应式编程来提高 Java 性能,但响应式编程难以理解、调试、使用,最终又回到了同步编程,最终虚拟线程诞生。\n虚拟线程的底层原理是什么? # 如果你想要详细了解虚拟线程实现原理,推荐一篇文章: 虚拟线程 - VirtualThread 源码透视。\n面试一般是不会问到这个问题的,仅供学有余力的同学进一步研究学习。\n"},{"id":669,"href":"/zh/docs/technology/Interview/high-quality-technical-articles/personal-experience/8-years-programmer-work-summary/","title":"一个中科大差生的 8 年程序员工作总结","section":"Personal Experience","content":" 推荐语:这篇文章讲述了一位中科大的朋友 8 年的经历:从 2013 年毕业之后加入上海航天 x 院某卫星研究所,再到入职华为,从华为离职。除了丰富的经历之外,作者在文章还给出了很多自己对于工作/生活的思考。我觉得非常受用!我在这里,向这位作者表达一下衷心的感谢。\n原文地址: https://www.cnblogs.com/scada/p/14259332.html\n前言 # 今年终于从大菊花厂离职了,离职前收入大概 60w 不到吧!在某乎属于比较差的,今天终于有空写一下自己的职场故事,也算是给自己近 8 年的程序员工作做个总结复盘。\n近 8 年有些事情做对了,也有更多事情做错了,在这里记录一下,希望能够给后人一些帮助吧,也欢迎私信交流。文笔不好,见谅,有些细节记不清了,如果有出入,就当是我编的这个故事吧。\nPS:有几个问题先在这里解释一下,评论就不一一回复了\n关于差生,我本人在科大时确实成绩偏下,差生主要讲这一点,没其他意思。 因为买房是我人生中的大事,我认为需要记录和总结一下,本文中会有买房,房价之类的信息出现,您如果对房价,炒房等反感的话,请您停止阅读,并且我再这里为浪费您的时间先道个歉。 2013 年 # 加入上海航天 x 院某卫星研究所 # 本人 86 年生人,13 年从中科大软件相关专业毕业,由于父母均是老师,从小接受的教育就是努力学习,找个稳定的“好工作”,报效国家。\n于是乎,毕业时候头脑一热加入了上海航天 x 院某卫星研究所,没有经过自己认真思考,仅仅听从父母意见,就草率的决定了自己的第一份工作,这也为我 5 年后离职埋下了隐患。这里总结第一条经验:\n如果你的亲人是普通阶层,那对于人生中一些大事来说,他们给的建议往往就是普通阶层的思维,他们的阶层就是他们一生思维决策的结果,如果你的目标是跳出本阶层,那最好只把他们的建议当成参考。\n13 年 4 月份,我坐上火车来到上海,在一路换乘地铁来到了大闵行,出了地铁走路到单位,一路上建筑都比较老旧,我心里想这跟老家也没什么区别嘛,还大上海呢。\n到达单位报道,负责报道的老师很亲切,填写完资料,分配了一间宿舍,还给了大概 3k 左右安家费,当时我心里那个激动啊(乡下孩子没有见过钱啊,见谅),拿了安家费,在附近小超市买好生活用品,这样我就开始了自己航天生涯。\n经过 1 个月集中培训后,我分配到部门,主要负责卫星上嵌入式软件开发。不过说是高大上的卫星软件开发,其实刚开始就是打杂,给实验室、厂房推箱子搬设备,呵呵,说航天是个体力活相信很多航天人都有同感吧。不过当时年轻,心思很单纯,每天搬完设备,晚上主动加班,看文档材料,画软件流程图,编程练习,日子过得很充实。\n记得第一个月到手大概 5k 左右(好少呀),当时很多一起入职的同事抱怨,我没有,我甚至不太愿意和他们比较工资,这里总结第二条经验:\n不要和你的同事比工资,没有意义,比工资总会有人受伤,更多的是负面影响,并且很多时候受伤的会是你。\n工作中暂露头角 # 工作大概一个月的时候,我遇到了一件事情,让我从新员工里面开始暂露头角。事情是这样的当时国家要对军工单位进行 GJB5000A 软件开发等级认证(搞过这个认证的同学应该都知道,过这个认证那是要多酸爽有多酸爽),但是当时一个负责配置管理的同事却提出离职,原因是他考上了公务员,当时我们用的那个软件平台后台的版本控制是 SVN 实现的,恰好我在学校写程序时用过,呵呵,话说回来现在学生自己写软件很少有人会在本地搭版本控制器吧!我记得当时还被同学嘲笑过,这让我想起了乔布斯学习美术字的故事,这里总结一下:\n不要说一项技能没有用,任何你掌握的技能都有价值,但是你要学会找到发挥它的场景。如果有一天你落水了,你可能会很庆幸,自己以前学会了游泳。\n工作中如果要上升,你要勇于承担麻烦的、有挑战的任务,当你推掉麻烦的时候,你也推掉了机遇。\n好了,扯远了,回到前面,当时我主动跟单位认证负责人提出,我可以帮忙负责这方面的工作,我有一定经验。这里要提一下这个负责人,是位女士,她是我非常敬佩的一个前辈,认真,负责,无私,整个人为国家的航天事业奉献了几十年,其实航天领域有非常多这样的老前辈,他们默默奋斗,拿着不高的薪水,为祖国的国防建设做出了巨大的贡献。当时这位负责人,看我平时工作认真积极,思维反应也比较灵活(因为过认证需要和认证专家现场答辩的)就同意了我的请求,接受到这个任务之后,我迅速投入,学习认证流程、体系文件、迅速掌握认证工作要点,一点一点把相关的工作做好,同时周期性对业务进行复盘,总结复盘可能是我自己的一个优点:\n很多人喜欢不停的做事,但不会停下来思考,缺乏总结复盘的能力,其实阶段性总结复盘,不仅能够固化前面的经验,也能梳理后面的方向;把事情做对很重要,但是更重要的是做对的事;另外不要贪快,方向正确慢就是快(后半句是我后来才想明白的,如果早想明白,就不会混成目前这样了)\n1 个月后,当时有惊无险通过了当年的认证,当时负责人主动向单位申请了 2k 特别奖,当时我真的非常高兴,主要是自己的工作产生了价值,得到了认可。后来几个月的日子平淡无奇,有印象的好像只有两件事情。\n一件事情是当年端午,当时我们在单位的宿舍休息,突然楼道上一阵骚动,我打开宿舍门一看,原来是书记来慰问,还给每个人送了一箱消暑饮料,这件事印象比较深刻,是我觉得国企虽然有各种各样的问题,但是论人文关怀,还是国企要好得多。\n错失一次暴富的机会 # 另一件事是当年室友刚买房,然后天天研究生财\u0026amp;之道,一会劝我买房,一会劝我买比\u0026amp;特\u0026amp;币,我当时没有鸟他,为什么呢,因为当时的室友生活习惯不太好,会躺在床上抽烟,还在宿舍内做饭(我们宿舍是那种很老的单位房,通风不好),我有鼻炎,所以不是很喜欢他(嗯,这里要向室友道歉,当年真是太幼稚了)。现在 B\u0026amp;T\u0026amp;C4 万美元了,我当时要是听了室友也能小发一笔了(其实我后来 18 年买了,但是没有拿住这是后话),这里要总结一下:\n不要因为某人的外在,如外貌、习惯、学历等对人贴上标签,去盲目否定别人,对于别人的建议,应该从客观出发,综合分析,从善如流是一项非常难得的品质。\n人很难挣到他认知之外的财富,就算偶然拿到了,也可能很快失去。所以不要羡慕别人投机获得的财富,努力提升你的思维,财商才是正道。\n航天生涯的第一个正式项目 # 转眼到了 9 月份(我 4 月份入职的),我迎来了我航天生涯第一个正式的型号项目(型号,是军工的术语,就相当于某个产品系列比如华为的 mate),当时分配给我的型号正式启动,我终于可以开始写卫星上的代码了。\n当时真的是心无旁骛,一心投身军工码农事业,每天实验室,测试厂房,评审会,日子虽然忙碌,但是也算充实。并且由于我的努力工作,加上还算可以的技术水平,我很快就能独立胜任一些型号基础性的工作了,并且我的努力也受到了型号(产品)线的领导的认可,他们开始计划让我担任型号主管设计师,这是一般工作 1-2 年的员工的岗位,当时还是有的激动的。\n2014 年 # 升任主管设计师后的一次波折 # 转眼间到 2014 年了,大概上半年吧,我正式升任主管设计师,研发工作上也开时独挡一面了,但是没多久产品研发就给了我当头一棒。\n事情是这样的,当时有一个版本软件编写完毕,加载到整星上进行测试,有一天大领导来检查,当时非常巧,领导来时测试主岗按某个岗位的人员要求,发送了一串平时整星没有使用的命令(我在实验室是验证过的),结果我的软件立刻崩溃,无法运行。由于正好领导视察,这个问题立马被上报到质量处,于是我开始了苦逼的技术归零攻关(搞航天的都懂,我就不解释了)。\n期间每天都有 3 个以上领导,询问进度,当时作为新人的我压力可想而知,可是我无论如何都查不出来问题,在实验室我的软件完全正常!后来,某天中午我突然想到,整星上可能有不同的环境,具体过程就不说了。后人查出来是一个负责加载我软件的第三方软件没有受控,非法篡改了我程序的 4 个字节,而这 4 字节正好是那天发送命令才会执行的代码,结果导致我的软件崩溃。最后我花了进一个月完成了所有质量归零报告,技术分析报告,当然负责技术的领导没有责怪,相反的还更加看重我了,后来我每遇到一个质量问题,无论多忙最后定要写一份总结分析报告,这成了我一个技术习惯,也为后来我升任软件开发组长奠定了技术影响基础。\n强烈建议技术团队定期开展质量回溯,需要文档化,还要当面讲解,深入的技术回溯有助于增加团队技术交流活跃度,同时提升团队技术积淀,是提升产品质量,打造优秀团队的有效方法。\n个人的话建议养成写技术总结文章的习惯,这不仅能提升个人技术,同时分享也可以增加你的影响力\n职场软技能的重新认识 # 上半年就在忙碌中度过了,到了年底,发生了一件对我们组影响很大的事情,年底单位开展优秀小组评比, 其实这个很多公司都有,那为什么说对我们组影响很大内,这里我先卖关子。这里先不得不提一个人,是个女孩子,南京大学的,比我晚来一年,她做事积极,反应灵敏,还做得一手不错的 PPT,非常优秀,就是黑了点(希望她看到了不要来找我,呵呵)。\n当时单位开展优秀小组评比,我们当时是新员工,什么都很新鲜,就想参加一下,当时领导说我们每年都参加的,我们问,我们每年做不少东西,怎么没有看到过评比的奖状,领导有点不好意思,说我们没有进过决赛。我们又问,多少名可以进入决赛圈,答曰前 27 名即可(总共好像 50+个组)我们当时心里真是一万个羊驼跑过。。。。\n其实当时我们组每年是做不少事情的,我们觉得我们不应该排名如此之低,于是我们几个年轻人开始策划,先是对我们的办公室彻底改造(因为要现场先打分,然后进决赛),然后好好梳理了我们当年取得的成绩,现场评比时我自告奋勇进行答辩(我沟通表达能力还不错,这也算我一个优势吧),后面在加上前文提到的女孩子做的漂亮 PPT,最后我们组拿到了铜牌班组的好成绩,我也因为这次答辩的优秀表现在领导那里又得到了认可,写了大半段了,再总结一下:\n职场软技能如自我展示很重要,特别是程序员,往往在这方面是个弱项,如果可以的话,可以通过练习,培训强化一下这些软技能,对职场的中后期非常有帮助。\n2015 年 # 时间总是过得很快,一下就到 2015 年了,这一年发生了一件对我影响很大的事情。\n升任小组副组长 # 当时我们小组有 18 个人了,有一天部门开会,主任要求大家匿名投票选副组长(当时部门领导还是很民主的),因为日常事务逐渐增多,老组长精力有限,想把一些事物分担出来,当天选举结果就出来了,我由于前面的技术积累和沟通表达能力的展现,居然升任副组长,当时即有些意外,因为总是有传言说国企没背景一辈子就是最最底层,后来我仔细思考过,下面是我不成熟的想法:\n不要总觉得国企事业单位的人都是拼背景,拼关系,我承认存在关系户,但是不要把关系户和低能力挂钩,背景只是一个放大器,当关系户做出了成绩时它会正面放大影响,当关系户做了不光彩的事情是,它也会让影响更坏。没有背景,你可以作出更大的贡献来达到自己的目标,你奋斗的过程是更大的财富。另外,我遇到的关系户能力都很强,也可能是巧合,也可能是他们的父辈给给他们在经验层次上比我们更优秀的教育。\n学习团队管理技巧 # 升任副组长后,我的工作更加忙碌了,不仅要做自己项目的事情,还要横向管理和协调组内其他项目的事情,有人说要多体谅军工单位的基层班组长,这话真是没错啊。这个时候我开始学习一些管理技巧,如何凝聚团队,如何统一协调资源等等,这段时间我还是在不断成长。不过记得当年还是犯了一个很大的方向性错误,虽然更多的原因可能归结为体制吧,但是当时其实可以在力所能及的范围内做一些事情的。\n具体是这样的,当时管理上有项目线,有行政线,就是很常见的矩阵式管理体系,不过在这个特殊的体制下面出现了一些问题。当时部门一把手被上级强制要求不得挂名某个型号,因为他要负责部门资源调配,而下面的我们每个人都归属 1-2 个型号(项目),在更高层的管理上又有横向的行政线(不归属型号),又有纵向的型号管理线。\n而型号的任务往往是第一线的,因为产品还是第一位的,但是个人的绩效、升迁又归属行政线管理,这种形式在能够高效沟通的民企或者外企一般来说不是问题,但是在沟通效率缓慢,还有其他掣肘因素的国企最终导致组内每个人忙于自身的型号任务,各自单打独斗,无法聚焦,一年忙到头最终却得不到部门认可,我也因为要两面管理疲于应付,后来曾经反思过,其实可以聚焦精力打造通用平台(虽然这在我们行业很难)部分解决这个问题:\n无论个人还是团队,做事情要聚焦,因为个人和团队资源永远都是有限的,如果集中一个事情都做不好,那分散就更难以成功,但是在聚焦之前要深入思考,往什么方向聚焦才是正确的,只有持续做正确的事情才是最重要的。\n2016 年 # 这一年是我人生的关键一年,发生了很多事情。\n升任小组副组长 # 第一件事情是我正式升任组长,由于副组长的工作经验,在组长的岗位上也做得比较顺利,在保证研发工作的同时,继续带领团队连续获得铜牌以上班组奖励,另外各种认证检查都稳稳当通过,但是就在这个时候,因为年轻,我犯下了一个至今非常后悔的错误。\n大概是这样的,我们部门当时有两个大组,一个是我们的软件研发组,一个是负责系统设计的系统分析组。\n当时两个组的工作界面是系统组下发软件任务书给软件组,软件组依照任务书开发,当时由于历史原因,软件组有不少 10 年以上的老员工,而系统组由于新成立由很多员工工作时间不到 2 年,不知道从什么时候起,也不知道是从哪位人员开始,软件组的不少同事认为自己是给系统组打工的。并且,由于系统组同事工作年限较短,实际设计经验不足,任务书中难免出现遗漏,从而导致实际产品出错,两组同事矛盾不断加深。\n最后,出现了一个爆发:当时系统组主推一项新的平台,虽然这个平台得到了行政线的支持,但是由于军工产品迭代严谨,这个新平台当时没有型号愿意使用,同时平台的部分负责人,居然没有完整的型号经验!由于这个新平台的软件需要软件组实现,但是因为已经形成的偏见,软件同事认为这项工作中自己是为利益既得者打工。\n我当时也因为即负责实际软件开发,又负责部分行政事务,并且年轻思想不成熟,也持有类似的思想。过程中的摩擦、冲突就不说了,最后的结果是系统组、软件组多人辞职,系统组组长离职,部门主任离职创业(当然他们辞职不全是这个原因,包括我离职也不全是这个原因,但是我相信这件事情有一定的影响),这件事情我非常后悔,后来反思过其实当时自己应该站出来,协调两组矛盾,全力支持部门技术升级,可能最终就不会有那么多优秀的同事离开了。\n公司战略的转型,技术的升级迭代,一定会伴随着阵痛,作为基层组织者,应该摒弃个人偏见,带领团队配合部门、公司主战略,主战略的成功才是团队成功的前提。\n买房 # 16 年我第二件大事情就是买房,关注过近几年房价的人都可能还记得,16 年一线城市猛涨的情景。其实当时 15 年底,上海市中心和学区房已经开始上涨,我 15 年底听同事开始讨论上涨的房价,我心里开始有了买房的打算,大约 16 春节(2 月份吧,具体记不得了),我回老家探望父母,同时跟他们提出了买房的打算。\n我的父亲是一个“央视新闻爱好者”,爱好看狼咸平,XX 刀,XX 檀的节目,大家懂了吧,父亲说上海房价太高了,都是泡沫,不要买。这个时候我已经不是菜鸟了,我想起我总结的第一条经验(见上文),我开始收集往年的房价数据,中央历年的房价政策,在复盘 15 年的经济政策时我发现,当年有 5 次降息降准,提升公积金贷款额度,放松贷款要求于是我判定房价一定会继续涨,涨到一个幅度各地才会出台各种限购政策,并且房价在城市中是按内环往外涨的于是我开始第一次在人生大事上反对父母,我坚决表态要买房。父亲还是不太同意,他说年底吧,先看看情况(实际是年底母亲的退休公积金可以拿出来大概十几万吧,另外未来丈母娘的公积金也能拿出来了大概比这多些)。我还是不同意,父亲最终拗不过我,终于松口,于是我们拿着双方家庭凑的 50w 现金开始买房,后来上海的房价大家都看到了。这件事也是我做的不多的正确的事情之一。\n但是最可笑的是,我研究房价的同时居然犯下了一个匪夷所思的错误,我居然没有研究买房子最重要的因素是什么,我们当时一心想买一手房(现在想想真是脑子进水),最后买了一套松江区交通不便的房子,这第一套房子的地理位置也为我后来第二次离职埋下了隐患,这个后面会说。\n一线或者准一线城市能买尽量买,不要听信房产崩溃论,如果买不起,那可以在有潜力的城市群里用父母的名义先买一套,毕竟大多数人的财富其实是涨不过通货膨胀的。另外买房最重要的三个要素是,地段,地段,地段。\n买房的那天上午和女朋友领的证,话说当时居然把身份证写错了三次 。。。\n这下我终于算是有个家了,交完首付那个时候身上真的是身无分文了。航天的基层员工的收入真的是不高,我记得我当时作为组长,每月到手大概也就 7k-8k 的样子,另外有少量的奖金,但是总数仍然不高,好在公积金比较多,我日常也没什么消费欲望,房贷到是压力不大。\n买完房子之后,我心里想,这下真的是把双方家庭都掏空了(我们双方家庭都比较普通,我的收入也在知乎垫底,没办法)万一有个意外怎么办,我思来想去,于是在我下一个月发工资之后,做了一个我至今也不知道是对是错的举动,我利用当月的工资,给全家人家人买了保险保险,各种重疾,意外都配好了。但是为什么我至今也不知道对错呢,因为后来老丈人,我母亲都遭遇病魔,但是两次保险公司都拒赔,找出的理由我真是哑口无言,谁叫我近视呢。另外真的是要感谢国家,亲人重病之后,最终还是走了医保,赔偿了部分,不然真的是一笔不小的负担。\n2017 年 # 对我人生重大影响的 2016 年,在历史的长河中终究连浪花都激不起来。历史长河静静流淌到了 2017 年,这一年我参加了中国深空探测项目,当然后面我没有等到天问一号发射就离开了航天,但是有时候仰望星空的时候,想想我的代码正在遥远的星空发挥作用,心里也挺感慨的,我也算是重大历史的参与者了,呵呵。好了不说工作了,平淡无奇的 2017 年,对我来说也发生了两件大事。\n买了第二套房子 # 第一件事是我买了第二套房子,说来可笑,当年第一套房子都是掏空家里,这第二年就买了第二套房子,生活真的是难以捉摸。到 2017 年时,前文说道,我母亲和丈母娘先后退休,公积金提取出来了,然后在双方家里各自办了酒席,酒席之后,双方父母都把所有礼金给了我们,父母对自己的孩子真的是无私之至。当时我们除了月光之外,其实没有什么外债,就是生活简单点。拿到这笔钱后,我们就在想如何使用,一天我在菜市场买菜,有人给我一张 xuanchuan 页,本来对于这样的 xuanchuan 页我一般是直接扔掉的,但是当天鬼死神差我看了一眼,只见上面写着“嘉善高铁房,紧邻上海 1.5w”我当时就石化了,我记得去年我研究上海房价的时候,曾经在网站上看到过嘉善的房价,我清楚的记得是 5-6k,我突然意识到我是不是错过了什么机会,反思一下:\n工作生活中尽量保持好奇心,不要对什么的持怀疑态度,很多机会就隐藏在不起眼的细节中,比如二十年前有人告诉你未来可以在网上购物,有人告诉你未来可以用手机支付,你先别把他直接归为骗子,静下来想一想,凡事要有好奇心,但是要有自己的判断。\n于是我立马飞奔回家,开始分析,大城市周边的房价。我分析了昆山,燕郊,东莞,我发现燕郊极其特殊,几乎没有产业,纯粹是承接大城市人口溢出,因此房价成高度波动。而昆山和东莞,由于自身有产业支撑,又紧邻大城市,因此房价稳定上涨。我和妻子一商量,开始了外地看房之旅,后来我们去了嘉善,觉得没有产业支撑,昆山限购,我们又到嘉兴看房,我发现嘉兴房价也涨了很多,但是这里购房的大多数新房,都是上海购房者,入住率比较低,很多都是打算买给父母住的,但是实际情况是父母几乎不在里面住,我觉得这里买房不妥,存在一个变现的问题。于是我开始继续寻找,一天我看着杭州湾的地图,突然想到,杭州湾北侧不行,那南侧呢?南侧绍兴,宁波经济不是更达吗。于是我们目光投向绍兴,看了一个月后,最后在绍兴紧贴杭州的一个区,购买了一套小房子,后来 17 年房价果然如我预料的那样完成中心城市的上涨之后开始带动三四线城市上涨。后来国家出台了大湾区政策,我对我的小房子更有信心了。这里稍微总结一下我个人不成熟的看法:\n在稳定通胀的时代,负债其实是一种财富。长三角城市群会未来强于珠港澳,因为香港和澳门和深圳存在竞争关系,而长三角城市间更多的是互补,未来我们看澳门可能就跟看一个中等省会城市一样了。\n准备要孩子 # 2017 年的第二件事是,我们终于准备要孩子了,但是妻子怎么也备孕不成功,我们开始频繁的去医院,从 10 元挂号费的普通门诊,看到 200 元,300 元挂号费的专家门诊,看到 600 元的特需门诊,从综合医院看到妇幼医院,从西医看到中医,每个周末不是在医院排队,就是在去医院的路上。最后的诊疗结果是有一定的希望,但是有困难,得到消息时我真的感觉眼前一片黑暗,这种从来在新闻上才能看到了事情居然落到了我们头上,我们甚至开始接触地下 XX 市场。同时越来越高的医疗开销(专家门诊以上就不能报销了)也开始成为了我的负担,前文说了,我收入一直不高,又还贷款,又支付医疗开支渐渐的开始捉襟见肘,我甚至动了卖小房子的打算。\n2018 年 # 前面说到,2017 年开始频繁出入医院,同时项目也越来越忙,我渐渐的开始喘不过气起来,最后医生也给了结论,需要做手术,手术有不小的失败的几率。我和妻子商量后一咬牙做吧,如果失败就走地下的路子,但是可能需要准备一笔钱(手术如果成功倒是花销不会太大),哎,古人说一分钱难倒英雄汉,真是诚不欺我啊,这个时候我已经开始萌生离职的想法了。怎么办呢,生活还是要继续,我想起了经常来单位办理贷款的银行人员,贷款吧,这种事情保险公司肯定不赔的嘛,于是我办理了一笔贷款,准备应急。\n项目结束,离职 # 时间慢慢的时间走到了 8 月份,我的项目已经告一定段落,一颗卫星圆满发射成功,深空项目也通过了初样阶段我的第一份工作也算有始有终了。我开始在网上投递简历,我技术还算可以,沟通交流也不错,面试很顺利,一个月就拿到了 6 个 offer,其中就有大菊花厂的 offer,定级 16A,25k 月薪后来政策改革加了绩效工资 6k(其实我定级和总薪水还是有些偏低了和我是国企,本来总薪水就低有很大关系,话说菊花厂级别后面真的是注水严重,博士入职轻松 17 级)菊花厂的 offer 审批流程是我见过最长,我当时的接口人天天催于流程都走了近 2 个月。我向领导提出了离职,离职的过程很痛苦,有过经历的人估计都知道,这里就不说了。话说我为什么会选择华为呢,一是当时急需钱,二是总觉得搞嵌入式的不到华为看看真的是人生遗憾。现在想想没有认真去理解公司的企业文化就进入一家公司还是太草率了:\n如果你不认同一个公司的企业文化,你大概率干不长,干不到中高层,IT 人你不及时突破到中高层很快你就会面临非常多问题;公司招人主要有两种人,一种是合格的人,一种是合适的人,合格的人是指技能合格,合适的人是指认同文化。企业招人就是先把合格的人找进来,然后通过日日宣讲,潜移默化把不合适的人淘汰掉。\n入职华为 # 经过一阵折腾终于离职成功,开始入职华为。离职我做了一件比较疯狂的事情,当时因为手上有一笔现金了,一直在支付利息,心里就像拿它干点啥。那时由于看病,接触了地下 XX 市场,听说了 B\u0026amp;TC,走之前我心一横买 B\u0026amp;T\u0026amp;C,后来不断波动,最终我还是卖了,挣了一些钱,但是最终没有拿到现在,果然是考验人性啊。\n2019 年 # 成功转正 # 华为的试用期真长,整整 6 个月,每个月还有流程跟踪,交流访谈,终于我转正了,转正答辩我不出意料拿到了 Excellent 评价,涨了点薪水,呵呵还不错。华为的事情我不太想说太多,总之我觉得自己没有资格评判这个公司,从公司看公司的角度华为真正是个伟大的公司,任老爷子也是一个值得敬佩的企业家。\n在华为干了半年后,我发现我终究还是入职的时候太草率了,我当时没有具体的了解这个岗位,这个部门。入职之后我发现,我所在的是硬件部门,我在一个硬件部门的软件组,我真是脑子秀逗了。\n在一个部门,你需要尽力进入到部门主航道里,尽力不要在边缘的航道工作,特别是那些节奏快,考核严格的部门。\n更严峻的是我所在的大组,居然是一个分布在全国 4 地的组,大组长(华为叫 LM)在上海,4 地各有一个本地业务负责人。我立刻意识到,到年终考评时,所有的成果一定会是 4 地分配,并且 4 地的负责人会占去一大部分,这是组织结构形成的优势。我所在的小组到时候会难以突破,资源分配会非常激烈。\n备孕成功 # 先不说这些,在 18 年时妻子做完了手术,手术居然很成功。休息完之后我们 19 年初开始备孕了,这次真的是上天保佑,运气不错,很快就怀上了。这段时间,我虽然每天做地铁 1.5 小时到公司上班,经受高强度的工作,我心里每天还是乐滋滋的。但是,突然有一天,PL(华为小组长)根我说,LM 需要派人去杭研所支持工作,我是最合适人选,让我有个心里准备。当时我是不想去的,这个时候妻子是最需要关怀的时候,我想 LM 表达了我的意愿,并且我也知道如果去了杭州年底绩效考评肯定不高。过程不多说了,反正结果是我去了杭州。\n于是我开始了两头奔波的日子,每个月回上海一趟。这过程中还有个插曲,家里老家城中村改造,分了一点钱,父母执意卖掉了老家学校周边的房子,丈母娘也处理老家的一些房子,然后把钱都给了我们,然后我用这笔家里最后的资产,同时利用华为的现金流在绍、甬不限购地区购买一些房子,我没有炒房的想法,只是防止被通货膨胀侵蚀而已,不过后来结果证明我貌似又蒙对了啊,我自己的看法是:\n杭绍甬在经济层面会连成紧密的一片,在行政区上杭州兼并绍 部分区域的概率其实不大,行政区的扩展应该是先兼并自身的下级代管城市。\n宝宝出生 # 不说房子了,继续工作吧。10 月份干了快一年时候,我华为的师傅(华为有师徒培养体系)偷偷告诉我被定为备选 PL 了,虽然不知道真假,但是我心里还是有点小高兴。不过我心里也慢慢意识到这个公司可能不是我真正想要的公司,这么多年了,愚钝如我慢慢也开始知道自己想干什么了。因为我的宝宝出生了,看着这只四脚吞金兽,我意识到自己已经是一个父亲了。\n2019 年随着美国不断升级的制裁消息,我在华为的日子也走到年底,马上将迎来神奇的 2020 年。\n2020 # 2020 就少写一些了,有些东西真的可能忘却更好。\n在家办公 # 年初就给大家来了一个重击,新冠疫情改变了太多的东西。这个时候真的是看出华为的执行力,居家办公也效率不减多少,并且迅速实现了复工。到了 3-4 月份,华为开始正式评议去年绩效等级,我心里开始有预感,以前的分析大概率会兑现,并且绩效和收入挂钩,华为是个风险意识极强的公司,去年的制裁会导致公司开始风险预备,虽然我日常工作还是受到多数人好评,但是我知道这其实在评议人员那里,没有任何意义。果然绩效评议结果出来了,呵呵,我很不满意。绩效沟通时 LM 破例跟我沟通了很长时间,我直接表达了我的想法。LM 承诺钱不会少,呵呵,我不评价吧。后来一天开始组织调整,成立一个新的小组,LM 给我电话让我当组长,我拒绝了,这件事情我不知道对错,我当时是这样考虑的\n升任新的职位,未必是好事,更高的职位意味着更高的要求,因此对备选人员要么在原岗位已经能力有余,要么时间精力有余;我认为当时我这两个都不满足,呵呵离家有点远,LM 很可以只是因为绩效事情做些补偿。 华为不会垮,这点大家有信心,但未来一定会出现战略收缩,最后这艘大船上还剩下哪些人不清楚,底层士兵有可能是牺牲品。 我 34 岁了 提出离职 # 另外我一直思考未来想做什么,已经有了一丝眉目,就这样,我拿了年终奖约 7 月就提出了离职,后来部门还让我做了最后一次贡献,把我硬留到 10 月份,这样就可以参加上半年考核了,让帮忙背了一个 C 呵呵,这是工作多年,最差绩效吧。\n这里还有一个小插曲,最后这三个月我负责什么工作呢,因为 20 年 3 月开始我就接手了部分部门招聘工作(在华为干过的都知道为什么非 HR 也要帮忙招聘,呵呵大坑啊,就不多解释了),结果最后三个月我这个待离职员工居然继续负责招聘,真的是很搞笑,不过由于我在上一份工作中其实一直也有招聘的工作,所以也算做的轻车熟路,每天看 50 份左右简历(我看得都非常仔细,我害怕自己的疏忽会导致一个优秀的人才错失机会,所以比较慢)其实也蛮有收货,最后好歹对程序员如何写简历有了一些心得。\n总结 # 好了 7 年多,近 8 年的职场讲完了,不管过去如何,未来还是要继续努力,希望看到这篇文章觉得有帮助的朋友,可以帮忙点个推荐,这样可能更多的人看到,也许可以避免更多的人犯我犯的错误。另外欢迎私信或者其他方式交流(某 Xin 号,jingyewandeng),可以讨论职场经验,方向,我也可以帮忙改简历(免费啊),不用怕打扰,能帮助别人是一项很有成绩感的事,并且过程中也会有收获,程序员也不要太腼腆呵呵\n"},{"id":670,"href":"/zh/docs/technology/Interview/database/mysql/a-thousand-lines-of-mysql-study-notes/","title":"一千行 MySQL 学习笔记","section":"Mysql","content":" 原文地址: https://shockerli.net/post/1000-line-mysql-note/ ,JavaGuide 对本文进行了简答排版,新增了目录。\n非常不错的总结,强烈建议保存下来,需要的时候看一看。\n基本操作 # /* Windows服务 */ -- 启动 MySQL net start mysql -- 创建Windows服务 sc create mysql binPath= mysqld_bin_path(注意:等号与值之间有空格) /* 连接与断开服务器 */ -- 连接 MySQL mysql -h 地址 -P 端口 -u 用户名 -p 密码 -- 显示哪些线程正在运行 SHOW PROCESSLIST -- 显示系统变量信息 SHOW VARIABLES 数据库操作 # /* 数据库操作 */ -- 查看当前数据库 SELECT DATABASE(); -- 显示当前时间、用户名、数据库版本 SELECT now(), user(), version(); -- 创建库 CREATE DATABASE[ IF NOT EXISTS] 数据库名 数据库选项 数据库选项: CHARACTER SET charset_name COLLATE collation_name -- 查看已有库 SHOW DATABASES[ LIKE \u0026#39;PATTERN\u0026#39;] -- 查看当前库信息 SHOW CREATE DATABASE 数据库名 -- 修改库的选项信息 ALTER DATABASE 库名 选项信息 -- 删除库 DROP DATABASE[ IF EXISTS] 数据库名 同时删除该数据库相关的目录及其目录内容 表的操作 # /* 表的操作 */ -- 创建表 CREATE [TEMPORARY] TABLE[ IF NOT EXISTS] [库名.]表名 ( 表的结构定义 )[ 表选项] 每个字段必须有数据类型 最后一个字段后不能有逗号 TEMPORARY 临时表,会话结束时表自动消失 对于字段的定义: 字段名 数据类型 [NOT NULL | NULL] [DEFAULT default_value] [AUTO_INCREMENT] [UNIQUE [KEY] | [PRIMARY] KEY] [COMMENT \u0026#39;string\u0026#39;] -- 表选项 -- 字符集 CHARSET = charset_name 如果表没有设定,则使用数据库字符集 -- 存储引擎 ENGINE = engine_name 表在管理数据时采用的不同的数据结构,结构不同会导致处理方式、提供的特性操作等不同 常见的引擎:InnoDB MyISAM Memory/Heap BDB Merge Example CSV MaxDB Archive 不同的引擎在保存表的结构和数据时采用不同的方式 MyISAM表文件含义:.frm表定义,.MYD表数据,.MYI表索引 InnoDB表文件含义:.frm表定义,表空间数据和日志文件 SHOW ENGINES -- 显示存储引擎的状态信息 SHOW ENGINE 引擎名 {LOGS|STATUS} -- 显示存储引擎的日志或状态信息 -- 自增起始数 AUTO_INCREMENT = 行数 -- 数据文件目录 DATA DIRECTORY = \u0026#39;目录\u0026#39; -- 索引文件目录 INDEX DIRECTORY = \u0026#39;目录\u0026#39; -- 表注释 COMMENT = \u0026#39;string\u0026#39; -- 分区选项 PARTITION BY ... (详细见手册) -- 查看所有表 SHOW TABLES[ LIKE \u0026#39;pattern\u0026#39;] SHOW TABLES FROM 库名 -- 查看表结构 SHOW CREATE TABLE 表名 (信息更详细) DESC 表名 / DESCRIBE 表名 / EXPLAIN 表名 / SHOW COLUMNS FROM 表名 [LIKE \u0026#39;PATTERN\u0026#39;] SHOW TABLE STATUS [FROM db_name] [LIKE \u0026#39;pattern\u0026#39;] -- 修改表 -- 修改表本身的选项 ALTER TABLE 表名 表的选项 eg: ALTER TABLE 表名 ENGINE=MYISAM; -- 对表进行重命名 RENAME TABLE 原表名 TO 新表名 RENAME TABLE 原表名 TO 库名.表名 (可将表移动到另一个数据库) -- RENAME可以交换两个表名 -- 修改表的字段机构(13.1.2. ALTER TABLE语法) ALTER TABLE 表名 操作名 -- 操作名 ADD[ COLUMN] 字段定义 -- 增加字段 AFTER 字段名 -- 表示增加在该字段名后面 FIRST -- 表示增加在第一个 ADD PRIMARY KEY(字段名) -- 创建主键 ADD UNIQUE [索引名] (字段名)-- 创建唯一索引 ADD INDEX [索引名] (字段名) -- 创建普通索引 DROP[ COLUMN] 字段名 -- 删除字段 MODIFY[ COLUMN] 字段名 字段属性 -- 支持对字段属性进行修改,不能修改字段名(所有原有属性也需写上) CHANGE[ COLUMN] 原字段名 新字段名 字段属性 -- 支持对字段名修改 DROP PRIMARY KEY -- 删除主键(删除主键前需删除其AUTO_INCREMENT属性) DROP INDEX 索引名 -- 删除索引 DROP FOREIGN KEY 外键 -- 删除外键 -- 删除表 DROP TABLE[ IF EXISTS] 表名 ... -- 清空表数据 TRUNCATE [TABLE] 表名 -- 复制表结构 CREATE TABLE 表名 LIKE 要复制的表名 -- 复制表结构和数据 CREATE TABLE 表名 [AS] SELECT * FROM 要复制的表名 -- 检查表是否有错误 CHECK TABLE tbl_name [, tbl_name] ... [option] ... -- 优化表 OPTIMIZE [LOCAL | NO_WRITE_TO_BINLOG] TABLE tbl_name [, tbl_name] ... -- 修复表 REPAIR [LOCAL | NO_WRITE_TO_BINLOG] TABLE tbl_name [, tbl_name] ... [QUICK] [EXTENDED] [USE_FRM] -- 分析表 ANALYZE [LOCAL | NO_WRITE_TO_BINLOG] TABLE tbl_name [, tbl_name] ... 数据操作 # /* 数据操作 */ ------------------ -- 增 INSERT [INTO] 表名 [(字段列表)] VALUES (值列表)[, (值列表), ...] -- 如果要插入的值列表包含所有字段并且顺序一致,则可以省略字段列表。 -- 可同时插入多条数据记录! REPLACE与INSERT类似,唯一的区别是对于匹配的行,现有行(与主键/唯一键比较)的数据会被替换,如果没有现有行,则插入新行。 INSERT [INTO] 表名 SET 字段名=值[, 字段名=值, ...] -- 查 SELECT 字段列表 FROM 表名[ 其他子句] -- 可来自多个表的多个字段 -- 其他子句可以不使用 -- 字段列表可以用*代替,表示所有字段 -- 删 DELETE FROM 表名[ 删除条件子句] 没有条件子句,则会删除全部 -- 改 UPDATE 表名 SET 字段名=新值[, 字段名=新值] [更新条件] 字符集编码 # /* 字符集编码 */ ------------------ -- MySQL、数据库、表、字段均可设置编码 -- 数据编码与客户端编码不需一致 SHOW VARIABLES LIKE \u0026#39;character_set_%\u0026#39; -- 查看所有字符集编码项 character_set_client 客户端向服务器发送数据时使用的编码 character_set_results 服务器端将结果返回给客户端所使用的编码 character_set_connection 连接层编码 SET 变量名 = 变量值 SET character_set_client = gbk; SET character_set_results = gbk; SET character_set_connection = gbk; SET NAMES GBK; -- 相当于完成以上三个设置 -- 校对集 校对集用以排序 SHOW CHARACTER SET [LIKE \u0026#39;pattern\u0026#39;]/SHOW CHARSET [LIKE \u0026#39;pattern\u0026#39;] 查看所有字符集 SHOW COLLATION [LIKE \u0026#39;pattern\u0026#39;] 查看所有校对集 CHARSET 字符集编码 设置字符集编码 COLLATE 校对集编码 设置校对集编码 数据类型(列类型) # /* 数据类型(列类型) */ ------------------ 1. 数值类型 -- a. 整型 ---------- 类型 字节 范围(有符号位) tinyint 1字节 -128 ~ 127 无符号位:0 ~ 255 smallint 2字节 -32768 ~ 32767 mediumint 3字节 -8388608 ~ 8388607 int 4字节 bigint 8字节 int(M) M表示总位数 - 默认存在符号位,unsigned 属性修改 - 显示宽度,如果某个数不够定义字段时设置的位数,则前面以0补填,zerofill 属性修改 例:int(5) 插入一个数\u0026#39;123\u0026#39;,补填后为\u0026#39;00123\u0026#39; - 在满足要求的情况下,越小越好。 - 1表示bool值真,0表示bool值假。MySQL没有布尔类型,通过整型0和1表示。常用tinyint(1)表示布尔型。 -- b. 浮点型 ---------- 类型 字节 范围 float(单精度) 4字节 double(双精度) 8字节 浮点型既支持符号位 unsigned 属性,也支持显示宽度 zerofill 属性。 不同于整型,前后均会补填0. 定义浮点型时,需指定总位数和小数位数。 float(M, D) double(M, D) M表示总位数,D表示小数位数。 M和D的大小会决定浮点数的范围。不同于整型的固定范围。 M既表示总位数(不包括小数点和正负号),也表示显示宽度(所有显示符号均包括)。 支持科学计数法表示。 浮点数表示近似值。 -- c. 定点数 ---------- decimal -- 可变长度 decimal(M, D) M也表示总位数,D表示小数位数。 保存一个精确的数值,不会发生数据的改变,不同于浮点数的四舍五入。 将浮点数转换为字符串来保存,每9位数字保存为4个字节。 2. 字符串类型 -- a. char, varchar ---------- char 定长字符串,速度快,但浪费空间 varchar 变长字符串,速度慢,但节省空间 M表示能存储的最大长度,此长度是字符数,非字节数。 不同的编码,所占用的空间不同。 char,最多255个字符,与编码无关。 varchar,最多65535字符,与编码有关。 一条有效记录最大不能超过65535个字节。 utf8 最大为21844个字符,gbk 最大为32766个字符,latin1 最大为65532个字符 varchar 是变长的,需要利用存储空间保存 varchar 的长度,如果数据小于255个字节,则采用一个字节来保存长度,反之需要两个字节来保存。 varchar 的最大有效长度由最大行大小和使用的字符集确定。 最大有效长度是65532字节,因为在varchar存字符串时,第一个字节是空的,不存在任何数据,然后还需两个字节来存放字符串的长度,所以有效长度是65535-1-2=65532字节。 例:若一个表定义为 CREATE TABLE tb(c1 int, c2 char(30), c3 varchar(N)) charset=utf8; 问N的最大值是多少? 答:(65535-1-2-4-30*3)/3 -- b. blob, text ---------- blob 二进制字符串(字节字符串) tinyblob, blob, mediumblob, longblob text 非二进制字符串(字符字符串) tinytext, text, mediumtext, longtext text 在定义时,不需要定义长度,也不会计算总长度。 text 类型在定义时,不可给default值 -- c. binary, varbinary ---------- 类似于char和varchar,用于保存二进制字符串,也就是保存字节字符串而非字符字符串。 char, varchar, text 对应 binary, varbinary, blob. 3. 日期时间类型 一般用整型保存时间戳,因为PHP可以很方便的将时间戳进行格式化。 datetime 8字节 日期及时间 1000-01-01 00:00:00 到 9999-12-31 23:59:59 date 3字节 日期 1000-01-01 到 9999-12-31 timestamp 4字节 时间戳 19700101000000 到 2038-01-19 03:14:07 time 3字节 时间 -838:59:59 到 838:59:59 year 1字节 年份 1901 - 2155 datetime YYYY-MM-DD hh:mm:ss timestamp YY-MM-DD hh:mm:ss YYYYMMDDhhmmss YYMMDDhhmmss YYYYMMDDhhmmss YYMMDDhhmmss date YYYY-MM-DD YY-MM-DD YYYYMMDD YYMMDD YYYYMMDD YYMMDD time hh:mm:ss hhmmss hhmmss year YYYY YY YYYY YY 4. 枚举和集合 -- 枚举(enum) ---------- enum(val1, val2, val3...) 在已知的值中进行单选。最大数量为65535. 枚举值在保存时,以2个字节的整型(smallint)保存。每个枚举值,按保存的位置顺序,从1开始逐一递增。 表现为字符串类型,存储却是整型。 NULL值的索引是NULL。 空字符串错误值的索引值是0。 -- 集合(set) ---------- set(val1, val2, val3...) create table tab ( gender set(\u0026#39;男\u0026#39;, \u0026#39;女\u0026#39;, \u0026#39;无\u0026#39;) ); insert into tab values (\u0026#39;男, 女\u0026#39;); 最多可以有64个不同的成员。以bigint存储,共8个字节。采取位运算的形式。 当创建表时,SET成员值的尾部空格将自动被删除。 列属性(列约束) # /* 列属性(列约束) */ ------------------ 1. PRIMARY 主键 - 能唯一标识记录的字段,可以作为主键。 - 一个表只能有一个主键。 - 主键具有唯一性。 - 声明字段时,用 primary key 标识。 也可以在字段列表之后声明 例:create table tab ( id int, stu varchar(10), primary key (id)); - 主键字段的值不能为null。 - 主键可以由多个字段共同组成。此时需要在字段列表后声明的方法。 例:create table tab ( id int, stu varchar(10), age int, primary key (stu, age)); 2. UNIQUE 唯一索引(唯一约束) 使得某字段的值也不能重复。 3. NULL 约束 null不是数据类型,是列的一个属性。 表示当前列是否可以为null,表示什么都没有。 null, 允许为空。默认。 not null, 不允许为空。 insert into tab values (null, \u0026#39;val\u0026#39;); -- 此时表示将第一个字段的值设为null, 取决于该字段是否允许为null 4. DEFAULT 默认值属性 当前字段的默认值。 insert into tab values (default, \u0026#39;val\u0026#39;); -- 此时表示强制使用默认值。 create table tab ( add_time timestamp default current_timestamp ); -- 表示将当前时间的时间戳设为默认值。 current_date, current_time 5. AUTO_INCREMENT 自动增长约束 自动增长必须为索引(主键或unique) 只能存在一个字段为自动增长。 默认为1开始自动增长。可以通过表属性 auto_increment = x进行设置,或 alter table tbl auto_increment = x; 6. COMMENT 注释 例:create table tab ( id int ) comment \u0026#39;注释内容\u0026#39;; 7. FOREIGN KEY 外键约束 用于限制主表与从表数据完整性。 alter table t1 add constraint `t1_t2_fk` foreign key (t1_id) references t2(id); -- 将表t1的t1_id外键关联到表t2的id字段。 -- 每个外键都有一个名字,可以通过 constraint 指定 存在外键的表,称之为从表(子表),外键指向的表,称之为主表(父表)。 作用:保持数据一致性,完整性,主要目的是控制存储在外键表(从表)中的数据。 MySQL中,可以对InnoDB引擎使用外键约束: 语法: foreign key (外键字段) references 主表名 (关联字段) [主表记录删除时的动作] [主表记录更新时的动作] 此时需要检测一个从表的外键需要约束为主表的已存在的值。外键在没有关联的情况下,可以设置为null.前提是该外键列,没有not null。 可以不指定主表记录更改或更新时的动作,那么此时主表的操作被拒绝。 如果指定了 on update 或 on delete:在删除或更新时,有如下几个操作可以选择: 1. cascade,级联操作。主表数据被更新(主键值更新),从表也被更新(外键值更新)。主表记录被删除,从表相关记录也被删除。 2. set null,设置为null。主表数据被更新(主键值更新),从表的外键被设置为null。主表记录被删除,从表相关记录外键被设置成null。但注意,要求该外键列,没有not null属性约束。 3. restrict,拒绝父表删除和更新。 注意,外键只被InnoDB存储引擎所支持。其他引擎是不支持的。 建表规范 # /* 建表规范 */ ------------------ -- Normal Format, NF - 每个表保存一个实体信息 - 每个具有一个ID字段作为主键 - ID主键 + 原子表 -- 1NF, 第一范式 字段不能再分,就满足第一范式。 -- 2NF, 第二范式 满足第一范式的前提下,不能出现部分依赖。 消除复合主键就可以避免部分依赖。增加单列关键字。 -- 3NF, 第三范式 满足第二范式的前提下,不能出现传递依赖。 某个字段依赖于主键,而有其他字段依赖于该字段。这就是传递依赖。 将一个实体信息的数据放在一个表内实现。 SELECT # /* SELECT */ ------------------ SELECT [ALL|DISTINCT] select_expr FROM -\u0026gt; WHERE -\u0026gt; GROUP BY [合计函数] -\u0026gt; HAVING -\u0026gt; ORDER BY -\u0026gt; LIMIT a. select_expr -- 可以用 * 表示所有字段。 select * from tb; -- 可以使用表达式(计算公式、函数调用、字段也是个表达式) select stu, 29+25, now() from tb; -- 可以为每个列使用别名。适用于简化列标识,避免多个列标识符重复。 - 使用 as 关键字,也可省略 as. select stu+10 as add10 from tb; b. FROM 子句 用于标识查询来源。 -- 可以为表起别名。使用as关键字。 SELECT * FROM tb1 AS tt, tb2 AS bb; -- from子句后,可以同时出现多个表。 -- 多个表会横向叠加到一起,而数据会形成一个笛卡尔积。 SELECT * FROM tb1, tb2; -- 向优化符提示如何选择索引 USE INDEX、IGNORE INDEX、FORCE INDEX SELECT * FROM table1 USE INDEX (key1,key2) WHERE key1=1 AND key2=2 AND key3=3; SELECT * FROM table1 IGNORE INDEX (key3) WHERE key1=1 AND key2=2 AND key3=3; c. WHERE 子句 -- 从from获得的数据源中进行筛选。 -- 整型1表示真,0表示假。 -- 表达式由运算符和运算数组成。 -- 运算数:变量(字段)、值、函数返回值 -- 运算符: =, \u0026lt;=\u0026gt;, \u0026lt;\u0026gt;, !=, \u0026lt;=, \u0026lt;, \u0026gt;=, \u0026gt;, !, \u0026amp;\u0026amp;, ||, in (not) null, (not) like, (not) in, (not) between and, is (not), and, or, not, xor is/is not 加上true/false/unknown,检验某个值的真假 \u0026lt;=\u0026gt;与\u0026lt;\u0026gt;功能相同,\u0026lt;=\u0026gt;可用于null比较 d. GROUP BY 子句, 分组子句 GROUP BY 字段/别名 [排序方式] 分组后会进行排序。升序:ASC,降序:DESC 以下[合计函数]需配合 GROUP BY 使用: count 返回不同的非NULL值数目 count(*)、count(字段) sum 求和 max 求最大值 min 求最小值 avg 求平均值 group_concat 返回带有来自一个组的连接的非NULL值的字符串结果。组内字符串连接。 e. HAVING 子句,条件子句 与 where 功能、用法相同,执行时机不同。 where 在开始时执行检测数据,对原数据进行过滤。 having 对筛选出的结果再次进行过滤。 having 字段必须是查询出来的,where 字段必须是数据表存在的。 where 不可以使用字段的别名,having 可以。因为执行WHERE代码时,可能尚未确定列值。 where 不可以使用合计函数。一般需用合计函数才会用 having SQL标准要求HAVING必须引用GROUP BY子句中的列或用于合计函数中的列。 f. ORDER BY 子句,排序子句 order by 排序字段/别名 排序方式 [,排序字段/别名 排序方式]... 升序:ASC,降序:DESC 支持多个字段的排序。 g. LIMIT 子句,限制结果数量子句 仅对处理好的结果进行数量限制。将处理好的结果的看作是一个集合,按照记录出现的顺序,索引从0开始。 limit 起始位置, 获取条数 省略第一个参数,表示从索引0开始。limit 获取条数 h. DISTINCT, ALL 选项 distinct 去除重复记录 默认为 all, 全部记录 UNION # /* UNION */ ------------------ 将多个select查询的结果组合成一个结果集合。 SELECT ... UNION [ALL|DISTINCT] SELECT ... 默认 DISTINCT 方式,即所有返回的行都是唯一的 建议,对每个SELECT查询加上小括号包裹。 ORDER BY 排序时,需加上 LIMIT 进行结合。 需要各select查询的字段数量一样。 每个select查询的字段列表(数量、类型)应一致,因为结果中的字段名以第一条select语句为准。 子查询 # /* 子查询 */ ------------------ - 子查询需用括号包裹。 -- from型 from后要求是一个表,必须给子查询结果取个别名。 - 简化每个查询内的条件。 - from型需将结果生成一个临时表格,可用以原表的锁定的释放。 - 子查询返回一个表,表型子查询。 select * from (select * from tb where id\u0026gt;0) as subfrom where id\u0026gt;1; -- where型 - 子查询返回一个值,标量子查询。 - 不需要给子查询取别名。 - where子查询内的表,不能直接用以更新。 select * from tb where money = (select max(money) from tb); -- 列子查询 如果子查询结果返回的是一列。 使用 in 或 not in 完成查询 exists 和 not exists 条件 如果子查询返回数据,则返回1或0。常用于判断条件。 select column1 from t1 where exists (select * from t2); -- 行子查询 查询条件是一个行。 select * from t1 where (id, gender) in (select id, gender from t2); 行构造符:(col1, col2, ...) 或 ROW(col1, col2, ...) 行构造符通常用于与对能返回两个或两个以上列的子查询进行比较。 -- 特殊运算符 != all() 相当于 not in = some() 相当于 in。any 是 some 的别名 != some() 不等同于 not in,不等于其中某一个。 all, some 可以配合其他运算符一起使用。 连接查询(join) # /* 连接查询(join) */ ------------------ 将多个表的字段进行连接,可以指定连接条件。 -- 内连接(inner join) - 默认就是内连接,可省略inner。 - 只有数据存在时才能发送连接。即连接结果不能出现空行。 on 表示连接条件。其条件表达式与where类似。也可以省略条件(表示条件永远为真) 也可用where表示连接条件。 还有 using, 但需字段名相同。 using(字段名) -- 交叉连接 cross join 即,没有条件的内连接。 select * from tb1 cross join tb2; -- 外连接(outer join) - 如果数据不存在,也会出现在连接结果中。 -- 左外连接 left join 如果数据不存在,左表记录会出现,而右表为null填充 -- 右外连接 right join 如果数据不存在,右表记录会出现,而左表为null填充 -- 自然连接(natural join) 自动判断连接条件完成连接。 相当于省略了using,会自动查找相同字段名。 natural join natural left join natural right join select info.id, info.name, info.stu_num, extra_info.hobby, extra_info.sex from info, extra_info where info.stu_num = extra_info.stu_id; TRUNCATE # /* TRUNCATE */ ------------------ TRUNCATE [TABLE] tbl_name 清空数据 删除重建表 区别: 1,truncate 是删除表再创建,delete 是逐条删除 2,truncate 重置auto_increment的值。而delete不会 3,truncate 不知道删除了几条,而delete知道。 4,当被用于带分区的表时,truncate 会保留分区 备份与还原 # /* 备份与还原 */ ------------------ 备份,将数据的结构与表内数据保存起来。 利用 mysqldump 指令完成。 -- 导出 mysqldump [options] db_name [tables] mysqldump [options] ---database DB1 [DB2 DB3...] mysqldump [options] --all--database 1. 导出一张表 mysqldump -u用户名 -p密码 库名 表名 \u0026gt; 文件名(D:/a.sql) 2. 导出多张表 mysqldump -u用户名 -p密码 库名 表1 表2 表3 \u0026gt; 文件名(D:/a.sql) 3. 导出所有表 mysqldump -u用户名 -p密码 库名 \u0026gt; 文件名(D:/a.sql) 4. 导出一个库 mysqldump -u用户名 -p密码 --lock-all-tables --database 库名 \u0026gt; 文件名(D:/a.sql) 可以-w携带WHERE条件 -- 导入 1. 在登录mysql的情况下: source 备份文件 2. 在不登录的情况下 mysql -u用户名 -p密码 库名 \u0026lt; 备份文件 视图 # 什么是视图: 视图是一个虚拟表,其内容由查询定义。同真实的表一样,视图包含一系列带有名称的列和行数据。但是,视图并不在数据库中以存储的数据值集形式存在。行和列数据来自由定义视图的查询所引用的表,并且在引用视图时动态生成。 视图具有表结构文件,但不存在数据文件。 对其中所引用的基础表来说,视图的作用类似于筛选。定义视图的筛选可以来自当前或其它数据库的一个或多个表,或者其它视图。通过视图进行查询没有任何限制,通过它们进行数据修改时的限制也很少。 视图是存储在数据库中的查询的sql语句,它主要出于两种原因:安全原因,视图可以隐藏一些数据,如:社会保险基金表,可以用视图只显示姓名,地址,而不显示社会保险号和工资数等,另一原因是可使复杂的查询易于理解和使用。 -- 创建视图 CREATE [OR REPLACE] [ALGORITHM = {UNDEFINED | MERGE | TEMPTABLE}] VIEW view_name [(column_list)] AS select_statement - 视图名必须唯一,同时不能与表重名。 - 视图可以使用select语句查询到的列名,也可以自己指定相应的列名。 - 可以指定视图执行的算法,通过ALGORITHM指定。 - column_list如果存在,则数目必须等于SELECT语句检索的列数 -- 查看结构 SHOW CREATE VIEW view_name -- 删除视图 - 删除视图后,数据依然存在。 - 可同时删除多个视图。 DROP VIEW [IF EXISTS] view_name ... -- 修改视图结构 - 一般不修改视图,因为不是所有的更新视图都会映射到表上。 ALTER VIEW view_name [(column_list)] AS select_statement -- 视图作用 1. 简化业务逻辑 2. 对客户端隐藏真实的表结构 -- 视图算法(ALGORITHM) MERGE 合并 将视图的查询语句,与外部查询需要先合并再执行! TEMPTABLE 临时表 将视图执行完毕后,形成临时表,再做外层查询! UNDEFINED 未定义(默认),指的是MySQL自主去选择相应的算法。 事务(transaction) # 事务是指逻辑上的一组操作,组成这组操作的各个单元,要不全成功要不全失败。 - 支持连续SQL的集体成功或集体撤销。 - 事务是数据库在数据完整性方面的一个功能。 - 需要利用 InnoDB 或 BDB 存储引擎,对自动提交的特性支持完成。 - InnoDB被称为事务安全型引擎。 -- 事务开启 START TRANSACTION; 或者 BEGIN; 开启事务后,所有被执行的SQL语句均被认作当前事务内的SQL语句。 -- 事务提交 COMMIT; -- 事务回滚 ROLLBACK; 如果部分操作发生问题,映射到事务开启前。 -- 事务的特性 1. 原子性(Atomicity) 事务是一个不可分割的工作单位,事务中的操作要么都发生,要么都不发生。 2. 一致性(Consistency) 事务前后数据的完整性必须保持一致。 - 事务开始和结束时,外部数据一致 - 在整个事务过程中,操作是连续的 3. 隔离性(Isolation) 多个用户并发访问数据库时,一个用户的事务不能被其它用户的事务所干扰,多个并发事务之间的数据要相互隔离。 4. 持久性(Durability) 一个事务一旦被提交,它对数据库中的数据改变就是永久性的。 -- 事务的实现 1. 要求是事务支持的表类型 2. 执行一组相关的操作前开启事务 3. 整组操作完成后,都成功,则提交;如果存在失败,选择回滚,则会回到事务开始的备份点。 -- 事务的原理 利用InnoDB的自动提交(autocommit)特性完成。 普通的MySQL执行语句后,当前的数据提交操作均可被其他客户端可见。 而事务是暂时关闭“自动提交”机制,需要commit提交持久化数据操作。 -- 注意 1. 数据定义语言(DDL)语句不能被回滚,比如创建或取消数据库的语句,和创建、取消或更改表或存储的子程序的语句。 2. 事务不能被嵌套 -- 保存点 SAVEPOINT 保存点名称 -- 设置一个事务保存点 ROLLBACK TO SAVEPOINT 保存点名称 -- 回滚到保存点 RELEASE SAVEPOINT 保存点名称 -- 删除保存点 -- InnoDB自动提交特性设置 SET autocommit = 0|1; 0表示关闭自动提交,1表示开启自动提交。 - 如果关闭了,那普通操作的结果对其他客户端也不可见,需要commit提交后才能持久化数据操作。 - 也可以关闭自动提交来开启事务。但与START TRANSACTION不同的是, SET autocommit是永久改变服务器的设置,直到下次再次修改该设置。(针对当前连接) 而START TRANSACTION记录开启前的状态,而一旦事务提交或回滚后就需要再次开启事务。(针对当前事务) 锁表 # /* 锁表 */ 表锁定只用于防止其它客户端进行不正当地读取和写入 MyISAM 支持表锁,InnoDB 支持行锁 -- 锁定 LOCK TABLES tbl_name [AS alias] -- 解锁 UNLOCK TABLES 触发器 # /* 触发器 */ ------------------ 触发程序是与表有关的命名数据库对象,当该表出现特定事件时,将激活该对象 监听:记录的增加、修改、删除。 -- 创建触发器 CREATE TRIGGER trigger_name trigger_time trigger_event ON tbl_name FOR EACH ROW trigger_stmt 参数: trigger_time是触发程序的动作时间。它可以是 before 或 after,以指明触发程序是在激活它的语句之前或之后触发。 trigger_event指明了激活触发程序的语句的类型 INSERT:将新行插入表时激活触发程序 UPDATE:更改某一行时激活触发程序 DELETE:从表中删除某一行时激活触发程序 tbl_name:监听的表,必须是永久性的表,不能将触发程序与TEMPORARY表或视图关联起来。 trigger_stmt:当触发程序激活时执行的语句。执行多个语句,可使用BEGIN...END复合语句结构 -- 删除 DROP TRIGGER [schema_name.]trigger_name 可以使用old和new代替旧的和新的数据 更新操作,更新前是old,更新后是new. 删除操作,只有old. 增加操作,只有new. -- 注意 1. 对于具有相同触发程序动作时间和事件的给定表,不能有两个触发程序。 -- 字符连接函数 concat(str1,str2,...]) concat_ws(separator,str1,str2,...) -- 分支语句 if 条件 then 执行语句 elseif 条件 then 执行语句 else 执行语句 end if; -- 修改最外层语句结束符 delimiter 自定义结束符号 SQL语句 自定义结束符号 delimiter ; -- 修改回原来的分号 -- 语句块包裹 begin 语句块 end -- 特殊的执行 1. 只要添加记录,就会触发程序。 2. Insert into on duplicate key update 语法会触发: 如果没有重复记录,会触发 before insert, after insert; 如果有重复记录并更新,会触发 before insert, before update, after update; 如果有重复记录但是没有发生更新,则触发 before insert, before update 3. Replace 语法 如果有记录,则执行 before insert, before delete, after delete, after insert SQL 编程 # /* SQL编程 */ ------------------ --// 局部变量 ---------- -- 变量声明 declare var_name[,...] type [default value] 这个语句被用来声明局部变量。要给变量提供一个默认值,请包含一个default子句。值可以被指定为一个表达式,不需要为一个常数。如果没有default子句,初始值为null。 -- 赋值 使用 set 和 select into 语句为变量赋值。 - 注意:在函数内是可以使用全局变量(用户自定义的变量) --// 全局变量 ---------- -- 定义、赋值 set 语句可以定义并为变量赋值。 set @var = value; 也可以使用select into语句为变量初始化并赋值。这样要求select语句只能返回一行,但是可以是多个字段,就意味着同时为多个变量进行赋值,变量的数量需要与查询的列数一致。 还可以把赋值语句看作一个表达式,通过select执行完成。此时为了避免=被当作关系运算符看待,使用:=代替。(set语句可以使用= 和 :=)。 select @var:=20; select @v1:=id, @v2=name from t1 limit 1; select * from tbl_name where @var:=30; select into 可以将表中查询获得的数据赋给变量。 -| select max(height) into @max_height from tb; -- 自定义变量名 为了避免select语句中,用户自定义的变量与系统标识符(通常是字段名)冲突,用户自定义变量在变量名前使用@作为开始符号。 @var=10; - 变量被定义后,在整个会话周期都有效(登录到退出) --// 控制结构 ---------- -- if语句 if search_condition then statement_list [elseif search_condition then statement_list] ... [else statement_list] end if; -- case语句 CASE value WHEN [compare-value] THEN result [WHEN [compare-value] THEN result ...] [ELSE result] END -- while循环 [begin_label:] while search_condition do statement_list end while [end_label]; - 如果需要在循环内提前终止 while循环,则需要使用标签;标签需要成对出现。 -- 退出循环 退出整个循环 leave 退出当前循环 iterate 通过退出的标签决定退出哪个循环 --// 内置函数 ---------- -- 数值函数 abs(x) -- 绝对值 abs(-10.9) = 10 format(x, d) -- 格式化千分位数值 format(1234567.456, 2) = 1,234,567.46 ceil(x) -- 向上取整 ceil(10.1) = 11 floor(x) -- 向下取整 floor (10.1) = 10 round(x) -- 四舍五入去整 mod(m, n) -- m%n m mod n 求余 10%3=1 pi() -- 获得圆周率 pow(m, n) -- m^n sqrt(x) -- 算术平方根 rand() -- 随机数 truncate(x, d) -- 截取d位小数 -- 时间日期函数 now(), current_timestamp(); -- 当前日期时间 current_date(); -- 当前日期 current_time(); -- 当前时间 date(\u0026#39;yyyy-mm-dd hh:ii:ss\u0026#39;); -- 获取日期部分 time(\u0026#39;yyyy-mm-dd hh:ii:ss\u0026#39;); -- 获取时间部分 date_format(\u0026#39;yyyy-mm-dd hh:ii:ss\u0026#39;, \u0026#39;%d %y %a %d %m %b %j\u0026#39;); -- 格式化时间 unix_timestamp(); -- 获得unix时间戳 from_unixtime(); -- 从时间戳获得时间 -- 字符串函数 length(string) -- string长度,字节 char_length(string) -- string的字符个数 substring(str, position [,length]) -- 从str的position开始,取length个字符 replace(str ,search_str ,replace_str) -- 在str中用replace_str替换search_str instr(string ,substring) -- 返回substring首次在string中出现的位置 concat(string [,...]) -- 连接字串 charset(str) -- 返回字串字符集 lcase(string) -- 转换成小写 left(string, length) -- 从string2中的左边起取length个字符 load_file(file_name) -- 从文件读取内容 locate(substring, string [,start_position]) -- 同instr,但可指定开始位置 lpad(string, length, pad) -- 重复用pad加在string开头,直到字串长度为length ltrim(string) -- 去除前端空格 repeat(string, count) -- 重复count次 rpad(string, length, pad) --在str后用pad补充,直到长度为length rtrim(string) -- 去除后端空格 strcmp(string1 ,string2) -- 逐字符比较两字串大小 -- 流程函数 case when [condition] then result [when [condition] then result ...] [else result] end 多分支 if(expr1,expr2,expr3) 双分支。 -- 聚合函数 count() sum(); max(); min(); avg(); group_concat() -- 其他常用函数 md5(); default(); --// 存储函数,自定义函数 ---------- -- 新建 CREATE FUNCTION function_name (参数列表) RETURNS 返回值类型 函数体 - 函数名,应该合法的标识符,并且不应该与已有的关键字冲突。 - 一个函数应该属于某个数据库,可以使用db_name.function_name的形式执行当前函数所属数据库,否则为当前数据库。 - 参数部分,由\u0026#34;参数名\u0026#34;和\u0026#34;参数类型\u0026#34;组成。多个参数用逗号隔开。 - 函数体由多条可用的mysql语句,流程控制,变量声明等语句构成。 - 多条语句应该使用 begin...end 语句块包含。 - 一定要有 return 返回值语句。 -- 删除 DROP FUNCTION [IF EXISTS] function_name; -- 查看 SHOW FUNCTION STATUS LIKE \u0026#39;partten\u0026#39; SHOW CREATE FUNCTION function_name; -- 修改 ALTER FUNCTION function_name 函数选项 --// 存储过程,自定义功能 ---------- -- 定义 存储存储过程 是一段代码(过程),存储在数据库中的sql组成。 一个存储过程通常用于完成一段业务逻辑,例如报名,交班费,订单入库等。 而一个函数通常专注与某个功能,视为其他程序服务的,需要在其他语句中调用函数才可以,而存储过程不能被其他调用,是自己执行 通过call执行。 -- 创建 CREATE PROCEDURE sp_name (参数列表) 过程体 参数列表:不同于函数的参数列表,需要指明参数类型 IN,表示输入型 OUT,表示输出型 INOUT,表示混合型 注意,没有返回值。 存储过程 # /* 存储过程 */ ------------------ 存储过程是一段可执行性代码的集合。相比函数,更偏向于业务逻辑。 调用:CALL 过程名 -- 注意 - 没有返回值。 - 只能单独调用,不可夹杂在其他语句中 -- 参数 IN|OUT|INOUT 参数名 数据类型 IN 输入:在调用过程中,将数据输入到过程体内部的参数 OUT 输出:在调用过程中,将过程体处理完的结果返回到客户端 INOUT 输入输出:既可输入,也可输出 -- 语法 CREATE PROCEDURE 过程名 (参数列表) BEGIN 过程体 END 用户和权限管理 # /* 用户和权限管理 */ ------------------ -- root密码重置 1. 停止MySQL服务 2. [Linux] /usr/local/mysql/bin/safe_mysqld --skip-grant-tables \u0026amp; [Windows] mysqld --skip-grant-tables 3. use mysql; 4. UPDATE `user` SET PASSWORD=PASSWORD(\u0026#34;密码\u0026#34;) WHERE `user` = \u0026#34;root\u0026#34;; 5. FLUSH PRIVILEGES; 用户信息表:mysql.user -- 刷新权限 FLUSH PRIVILEGES; -- 增加用户 CREATE USER 用户名 IDENTIFIED BY [PASSWORD] 密码(字符串) - 必须拥有mysql数据库的全局CREATE USER权限,或拥有INSERT权限。 - 只能创建用户,不能赋予权限。 - 用户名,注意引号:如 \u0026#39;user_name\u0026#39;@\u0026#39;192.168.1.1\u0026#39; - 密码也需引号,纯数字密码也要加引号 - 要在纯文本中指定密码,需忽略PASSWORD关键词。要把密码指定为由PASSWORD()函数返回的混编值,需包含关键字PASSWORD -- 重命名用户 RENAME USER old_user TO new_user -- 设置密码 SET PASSWORD = PASSWORD(\u0026#39;密码\u0026#39;) -- 为当前用户设置密码 SET PASSWORD FOR 用户名 = PASSWORD(\u0026#39;密码\u0026#39;) -- 为指定用户设置密码 -- 删除用户 DROP USER 用户名 -- 分配权限/添加用户 GRANT 权限列表 ON 表名 TO 用户名 [IDENTIFIED BY [PASSWORD] \u0026#39;password\u0026#39;] - all privileges 表示所有权限 - *.* 表示所有库的所有表 - 库名.表名 表示某库下面的某表 GRANT ALL PRIVILEGES ON `pms`.* TO \u0026#39;pms\u0026#39;@\u0026#39;%\u0026#39; IDENTIFIED BY \u0026#39;pms0817\u0026#39;; -- 查看权限 SHOW GRANTS FOR 用户名 -- 查看当前用户权限 SHOW GRANTS; 或 SHOW GRANTS FOR CURRENT_USER; 或 SHOW GRANTS FOR CURRENT_USER(); -- 撤消权限 REVOKE 权限列表 ON 表名 FROM 用户名 REVOKE ALL PRIVILEGES, GRANT OPTION FROM 用户名 -- 撤销所有权限 -- 权限层级 -- 要使用GRANT或REVOKE,您必须拥有GRANT OPTION权限,并且您必须用于您正在授予或撤销的权限。 全局层级:全局权限适用于一个给定服务器中的所有数据库,mysql.user GRANT ALL ON *.*和 REVOKE ALL ON *.*只授予和撤销全局权限。 数据库层级:数据库权限适用于一个给定数据库中的所有目标,mysql.db, mysql.host GRANT ALL ON db_name.*和REVOKE ALL ON db_name.*只授予和撤销数据库权限。 表层级:表权限适用于一个给定表中的所有列,mysql.talbes_priv GRANT ALL ON db_name.tbl_name和REVOKE ALL ON db_name.tbl_name只授予和撤销表权限。 列层级:列权限适用于一个给定表中的单一列,mysql.columns_priv 当使用REVOKE时,您必须指定与被授权列相同的列。 -- 权限列表 ALL [PRIVILEGES] -- 设置除GRANT OPTION之外的所有简单权限 ALTER -- 允许使用ALTER TABLE ALTER ROUTINE -- 更改或取消已存储的子程序 CREATE -- 允许使用CREATE TABLE CREATE ROUTINE -- 创建已存储的子程序 CREATE TEMPORARY TABLES -- 允许使用CREATE TEMPORARY TABLE CREATE USER -- 允许使用CREATE USER, DROP USER, RENAME USER和REVOKE ALL PRIVILEGES。 CREATE VIEW -- 允许使用CREATE VIEW DELETE -- 允许使用DELETE DROP -- 允许使用DROP TABLE EXECUTE -- 允许用户运行已存储的子程序 FILE -- 允许使用SELECT...INTO OUTFILE和LOAD DATA INFILE INDEX -- 允许使用CREATE INDEX和DROP INDEX INSERT -- 允许使用INSERT LOCK TABLES -- 允许对您拥有SELECT权限的表使用LOCK TABLES PROCESS -- 允许使用SHOW FULL PROCESSLIST REFERENCES -- 未被实施 RELOAD -- 允许使用FLUSH REPLICATION CLIENT -- 允许用户询问从属服务器或主服务器的地址 REPLICATION SLAVE -- 用于复制型从属服务器(从主服务器中读取二进制日志事件) SELECT -- 允许使用SELECT SHOW DATABASES -- 显示所有数据库 SHOW VIEW -- 允许使用SHOW CREATE VIEW SHUTDOWN -- 允许使用mysqladmin shutdown SUPER -- 允许使用CHANGE MASTER, KILL, PURGE MASTER LOGS和SET GLOBAL语句,mysqladmin debug命令;允许您连接(一次),即使已达到max_connections。 UPDATE -- 允许使用UPDATE USAGE -- “无权限”的同义词 GRANT OPTION -- 允许授予权限 表维护 # /* 表维护 */ -- 分析和存储表的关键字分布 ANALYZE [LOCAL | NO_WRITE_TO_BINLOG] TABLE 表名 ... -- 检查一个或多个表是否有错误 CHECK TABLE tbl_name [, tbl_name] ... [option] ... option = {QUICK | FAST | MEDIUM | EXTENDED | CHANGED} -- 整理数据文件的碎片 OPTIMIZE [LOCAL | NO_WRITE_TO_BINLOG] TABLE tbl_name [, tbl_name] ... 杂项 # /* 杂项 */ ------------------ 1. 可用反引号(`)为标识符(库名、表名、字段名、索引、别名)包裹,以避免与关键字重名!中文也可以作为标识符! 2. 每个库目录存在一个保存当前数据库的选项文件db.opt。 3. 注释: 单行注释 # 注释内容 多行注释 /* 注释内容 */ 单行注释 -- 注释内容 (标准SQL注释风格,要求双破折号后加一空格符(空格、TAB、换行等)) 4. 模式通配符: _ 任意单个字符 % 任意多个字符,甚至包括零字符 单引号需要进行转义 \\\u0026#39; 5. CMD命令行内的语句结束符可以为 \u0026#34;;\u0026#34;, \u0026#34;\\G\u0026#34;, \u0026#34;\\g\u0026#34;,仅影响显示结果。其他地方还是用分号结束。delimiter 可修改当前对话的语句结束符。 6. SQL对大小写不敏感 7. 清除已有语句:\\c "},{"id":671,"href":"/zh/docs/technology/Interview/high-quality-technical-articles/interview/the-experience-and-thinking-of-an-interview-experienced-by-an-older-programmer/","title":"一位大龄程序员所经历的面试的历炼和思考","section":"Interview","content":" 推荐语:本文的作者,今年 36 岁,已有 8 年 JAVA 开发经验。在阿里云三年半,有赞四年半,已是标准的大龄程序员了。在这篇文章中,作者给出了一些关于面试和个人能力提升的一些小建议,非常实用!\n内容概览:\n个人介绍,是对自己的一个更为清晰、深入和全面的认识契机。 简历是充分展示自己的浓缩精华,也是重新审视自己和过往经历的契机。不仅仅是简要介绍技能和经验,更要最大程度凸显自己的优势领域(差异化)。 我个人是不赞成海投的,而倾向于定向投。找准方向投,虽然目标更少,但更有效率。 技术探索,一定要先理解原理。原理不懂,就会浮于表层,不能真正掌握它。技术原理探究要掌握到什么程度?数据结构与算法设计、考量因素、技术机制、优化思路。要在脑中回放,直到一切细节而清晰可见。如果能够清晰有条理地表述出来,就更好了。技术原理探究,一定要看源码。看了源码与没看源码是有区别的。没看源码,虽然说得出来,但终是隔了一层纸;看了源码,才捅破了那层纸,有了自己的理解,也就能说得更加有底气了。当然,也可能是我缺乏演戏的本领。 要善于从失败中学习。正是在杭州四个月空档期的持续学习、思考、积累和提炼,以及面试失败的反思、不断调整对策、完善准备、改善原有的短板,采取更为合理的方式,才在回武汉的短短两个周内拿到比较满意的 offer 。 面试是通过沟通来理解双方的过程。面试中的问题,千变万化,但有一些问题是需要提前准备好的。 原文地址: https://www.cnblogs.com/lovesqcc/p/14354921.html\n从每一段经历中学习,在每一件事情中修行。善于从挫折中学习。\n引子 # 我今年 36 岁,已有 8 年 JAVA 开发经验。在阿里云三年半,有赞四年半,已是标准的大龄程序员了。\n在多年的读书、学习和思考中,我的价值观、人生观和世界观也逐步塑造成型。我意识到自己的志趣在于做教育文化方面,因此在半冲动之下,8 月份下旬,裸辞去找工作了。有限理性难以阻挡冲动的个性。不建议裸辞,做事应该有规划、科学合理。\n尽管我最初认为自己“有理想有目标有意愿有能力”,找一份教育开发的工作应该不难,但事实上我还是过于乐观了。现实很快给我泼了一瓢瓢冷水。我屡战屡败,又屡败屡战。惊讶地发现自己还有这个韧性。面试是一项历炼,如果没有被失败击倒,那么从中会生长出一份韧性,这种韧性能让人走得更远。谁没有经历过失败的历练呢?失败是最伟大的导师了,如果你愿意跟他学一学的话。\n在面试的过程中,我很快发现自己的劣势:\n投入精力做业务,技术深度不够,对原理的理解局限于较浅的层次; 视野不够开阔,局限于自己所做的订单业务线,对其它关联业务线(比如商品、营销、支付等)了解不够; 思维不够开阔,大部分时间投入在开发和测试上,对运维、产品、业务、商业层面思考都思考不多; 缺乏管理经验,年龄偏大;这两项劣势我一度低估,但逐渐凸显出来,甚至让我一度不自信,但最终我还是走出来了。 但我也有自己的优势。职业竞争的基本法则是稀缺性和差异化。能够解决大型项目的架构设计和攻克技术难题,精通某个高端技术领域是稀缺性体现;而能够做事能做到缜密周全精细化,有高并发大流量系统开发经验,则是差异性体现。稀缺性是上策,差异化是中策,而降格以求就是下策了。\n我缺乏稀缺性优势,但还有一点差异化优势:\n对每一份工作都很踏实,时间均在 3 年 - 5 年之间,有一点大厂光环,能获得更多面试机会(虽然不一定能面上); 坚持写博客,孜孜不倦地追求软件开发的“道”,时常思考记录开发中遇到的问题及解决方案; 做事认真严谨,能够从整体分析和思考问题,也很注重基础提升; 对工程质量、性能优化、稳定性建设、业务配置化设计有实践经验; 大流量微服务系统的长期开发维护经验。 我投出简历的公司并不多。在不多的面试中,我逐渐意识到网上的“斩获几十家大厂 offer”的说法并不可信。理由如下:\n如果能真斩获大量大厂 offer ,面试的级别很大概率是初级工程师。要知道面试 4 年以上的工程师,面试的深度和广度令人发指,从基础的算法、到各种中间件的原理机制到实际运维架构,无所不包,真个是沉浸在“技术的海洋”,除非一个人的背景和实力非常强大,平时也做了非常深且广的沉淀; 一个背景和实力非常强大的人,是不会有兴趣去投入这么多精力去面各种公司,仅仅是为了吹嘘自己有多能耐;实力越强的人,他会有自己的选择逻辑,投的简历会更定向精准。话说,他为什么不花更多精力投入在那些能够让他有最大化收益的优秀企业呢? 培训机构做的广告。因为他们最清楚新手需要的是信心,哪怕是伪装出来的信心。 好了,闲话不多说了。我讲讲自己在面试中所经受的历练和思考吧。\n准备工作 # 人生或许很长,但面试的时间很短,最长不过一小时或一个半小时。别人如何在短短一小时内能够更清晰地认识长达三十多年的你呢?这就需要你做大量细致的准备工作了。在某种程度上,面试与舞蹈有异曲同工之妙:台上五分钟,台下十年功。\n准备工作主要包括简历准备、个人介绍、公司了解、技术探索、表述能力、常见问题、中高端职位、好的心态。准备工作是对自身和对外部世界的一次全面深入的重新认知。\n初期,我以为自己准备很充分,简历改改就完事了。随着一次次受挫,才发现自己的准备很不充分。在现在的我看来,准备七分,应变三分。准备,就是要知己知彼,知道对方会问哪些问题(通常是系统/项目/技术的深度和广度)、自己应当如何作答;应变,就是当自己遇到不会、不懂、不知道的问题时,如何合理地展示自己的解决思路,以及根据面试中答不上来的问题查漏补缺,夯实基础。\n这个过程,实际上也是学习的过程。持续的反思和提炼、学习新的内容、重新认识自己和过往经历等。\n简历准备 # 最开始,我做得比较简单。把以前的简历拿出来,添加上新的工作经历,略作修改,但整体上模板基本不变。\n在基本面上,我做的是较为细致的,诚实地写上了自己擅长和熟悉的技能和经验经历,排版也尽力做得整洁美观(学过一些 UI 设计)。不浮夸也不故作谦虚。\n在扩展面上,我做的还是不够的。有一天,一位猎头打电话给我,问:“你最大的优势是什么?”。我顿时说不上来。当时也未多加思考。在后续面试屡遭失败之后,一度有些不自信之后,我开始仔细思考自己的优势来。然后将“对工程质量、性能优化、稳定性建设、业务配置化设计有深入思考和实践经验”写在了“技能素养”栏的第一行,因为这确实是我所做过的、最实在且脚踏实地的且具备概括性的。\n有时,简历内容的编排顺序也很重要。之前,我把掌握的语言及技术写在前面,而“项目管理能力和团队影响力”之类的写在后面。但投年糕妈妈之后,未有面试直接被拉到不合适里面,受到了刺激,我意识到或许是对方觉得我管理经验不足。因此,刻意将“项目管理能力和团队影响力”提到了前面,表示自己是重视管理方面的,不过,投过新的简历之后,没有回应。我意识到,这样的编排顺序可能会让人误解我是管理能力偏重的(事实上有一位 HR 问我是不是还在写代码),但实际上管理方面我是欠缺的,最后,我还是调回了原来的顺序,凸出自己“工程师的本色”。后面,我又做了一些语句的编排上的修改。\n随着面试的进展,有时,也会发现自己的简历上写得不够或者以前做得不够的地方。比如,在订单导出这段经历里,我只是写了大幅提升性能和稳定性,显得定性描述化,因此,我添加了一些量化的东西(2w 阻塞 =\u0026gt; 300w+,1w/1min)作为证实;比如,8 月份离职,到 12 月份面试的时候,有一段空档期,有些企业会问到这个。因此,我索性加了一句话,说明这段时间我在干些啥;比如,代表性系统和项目,每一个系统和项目的价值和意义(不一定写在上面,但是心里要有数)。功夫要下足。\n再比如,我很详细地写了有赞的工作经历及经验,但阿里云的那段基本没动。而有些企业对这段经历更感兴趣,我却觉得没太多可说的,留在脑海里的只有少量印象深刻的东西,以及一些博客文章的记录,相比这段工作经历来说显得太单薄。这里实质上不是简历的问题,而是过往经历复盘的问题。建议,在每个项目结束后,都要写个自我复盘。避免时间将这些可贵的经历冲淡。\n每个人其实都有很多可说的东西,但记录下来的又有多少呢?值得谈道的有多少呢?过往不努力,面试徒伤悲。\n简历更新的心得:\n简历是充分展示自己的浓缩精华,也是重新审视自己和过往经历的契机; 不仅仅是简要介绍技能和经验,更要最大程度凸显自己的优势领域(差异化); 增强工作经历的表述,凸显贡献,赢得别人的认可; 复盘并记录每一个项目中的收获,为跳槽和面试打下好的铺垫。 个人介绍 # 面试前通常会要求做个简要的个人介绍。个人介绍通常作为进入面试的前奏曲和缓冲阶段,缓和下紧张气氛。\n我最开始的个人介绍,个性啊业余生活啊工作经历啊志趣啊等等,似乎不知道该说些什么。实际上,个人介绍是一个充分展示自己的主页。主页应当让自己最最核心的优势一目了然(需要挖掘自己的经历并仔细提炼)。我现在的个人介绍一般会包括:个性(比如偏安静)、做事风格(工作认真严谨、注重质量、善于整体思考)、最大优势(owner 意识、执行力、工程把控能力)、工作经历简述(在每个公司的工作负责什么、贡献了什么、收获了什么)。个人介绍简明扼要,无需赘言。\n个人介绍,是对自己的一个更为清晰、深入和全面的认识契机。\n公司了解 # 很多人可能跟我一样,对公司业务了解甚少,就直接投出去了。这样其实是不合理的。首先,我个人是不赞成海投的,而倾向于定向投。找准方向投,虽然目标更少,但更有效率。这跟租房一样,我一般在豆瓣上租房,虽然目标源少,但逮着一个就是好运。\n投一家公司,是因为这家公司符合意向,值得争取,而不是因为这是一家公司。就像找对象,不是为了找一个女人。要确定这家公司是否符合意向,就应当多去了解这家公司:主营业务、未来发展及规划、所在行业及地位、财务状况、业界及网络评价等。\n在面试的过程中适当谈到公司的业务及思考,是可加分项。亦可用于“你有什么想问的?”的提问。\n技术探索 # 技术能力是一个技术人的基本素养。因此,我觉得,无论未来做什么工作,技术能力过硬,总归是最不可或缺的不可忽视的。\n原理和设计思想是软件技术中最为精髓的东西。一般软件技术可以分为两个方面:\n原理:事物如何工作的基本规律和流程; 架构:如何组织大规模逻辑的艺术。 技术探索,一定要先理解原理。原理不懂,就会浮于表层,不能真正掌握它。技术原理探究要掌握到什么程度?数据结构与算法设计、考量因素、技术机制、优化思路。要在脑中回放,直到一切细节而清晰可见。如果能够清晰有条理地表述出来,就更好了。\n技术原理探究,一定要看源码。看了源码与没看源码是有区别的。没看源码,虽然说得出来,但终是隔了一层纸;看了源码,才捅破了那层纸,有了自己的理解,也就能说得更加有底气了。当然,也可能是我缺乏演戏的本领。\n我个人不太赞成刷题式面试。虽然刷题确实是进厂的捷径,但也有缺点:\n它依然是别人的知识体系,而不是自己总结的知识体系; 技术探究是为了未来的工作准备,而不是为了应对一时之需,否则即使进去了还是会处于麻痹状态。 经过系统的整理,我逐步形成了适合自己的技术体系结构: “互联网应用服务端的常用技术思想与机制纲要” 。在这个基础上,再博采众长,看看面试题进行自测和查漏补缺,是更恰当的方式。我会在这个体系上深耕细作。\n表述能力 # 目前,绝大多数企业的主要面试形式是通过口头沟通进行的,少部分企业可能有笔试或机试。口头沟通的形式是有其局限性的。对表述能力的要求比较高,而对专业能力的凸显并不明显。一个人掌握的专业和经验的深度和广度,很难通过几分钟的表述呈现出来。往往深度和广度越大,反而越难表述。而技术人员往往疏于表达。\n我平时写得多说得少,说起来不利索。有时没讲清楚背景,就直接展开,兼之啰嗦、跳跃和回旋往复(这种方式可能更适合写小说),让面试官有时摸不着头脑。表述的条理性和清晰性也是很重要的。不妨自己测试一下:Dubbo 的架构设计是怎样的? Redis 的持久化机制是怎样的?然后自己回答试试看。\n表述能力的基本法则:\n先总后分,先整体后局部; 先说基本思路,然后说优化; 体现互动。先综述,然后向面试官询问要听哪方面,再分述。避免自己一脑瓜子倾倒出来,让面试官猝不及防;系统设计的场景题,多问一些要求,比如时间要求、空间要求、要支持多大数据量或并发量、是否要考虑某些情况等。 常见问题 # 面试是通过沟通来理解双方的过程。面试中的问题,千变万化,但有一些问题是需要提前准备好的。\n比如“灵魂 N 问”:\n你为什么从 XXX 离职? 你的期望薪资是多少? 你有一段空档期,能解释下怎么回事么? 你的职业规划是怎样的? 高频技术问题:\n基础:数据结构与算法、网络; 微服务:技术体系、组件、基础设施等; Dubbo:Dubbo 整体架构、扩展机制、服务暴露、引用、调用、优雅停机等; MySQL:索引与事务的实现原理、SQL 优化、分库分表; Redis : 数据结构、缓存、分布式锁、持久化机制、复制机制; 分布式:分布式事务、一致性问题; 消息中间件:原理、对比; 架构:架构设计方法、架构经验、设计模式; 性能优化:JVM、GC、应用层面的性能优化; 并发基础:ConcurrentHashMap, AQS, CAS,线程池等; 高并发:IO 多路复用;缓存问题及方案; 稳定性:稳定性的思想及经验; 生产问题:工具及排查方法。 中高端职位 # 说起来,我这人可能有点不太自信。我是怀着“踏实做一个工程师”的思想投简历的。\n对于大龄程序员,企业的期望更高。我的每一份“高级工程师”投递,自动被转换为“技术专家”或“架构师”。无力反驳,倍感压力。面试中高端职位,需要更多准备:\n你有带团队经历吗? 在你 X 年的工作经历中,有多少时间用于架构设计? 架构过程是怎样的?你有哪些架构设计思想或方法论? 如果不作准备,就被一下子问懵,乱了阵脚。实际上,我或许还是存着侥幸心理把“技术专家”和“架构师”岗位当做“高工”来面试的,也就无一不遭遇失败了。显然,我把次序弄反了:应当以“技术专家”和“架构师”的规格来面试高级工程师。\n好吧,那就迎难而上吧!我不是惧怕挑战的人。\n此外,“技术专家”和“架构师”职位应当至少留一天的时间来准备。已经有丰富经验的技术专家和架构师可以忽略。\n好的心态 # 保持好的心态也尤为重要。我经历了“乐观-不自信-重拾信心”的心态变化过程。\n很长一段时间,由于“求成心切”,生怕某个技术问题回答不上来搞砸,因此小心谨慎,略显紧张,结果已经梳理好的往往说不清楚或者说得不够有条理。冲着“拿 offer ”的心态去面试,真的很难受,会觉得每场面试都很被动那么难过,甚至有点想要“降格以求”。\n有时,我在想:咋就混成这个样子了呢?按理来说,这个时候我应该有能力去追求自己喜爱的事业了啊!还是平时有点松懈了,视野狭窄,积累不够,导致今天的不利处境。\n我是一个守时的人,也希望对方尽可能守时。杭州的面试官中,基本是守时的,即使迟到也在心理接受范围内,回武汉面试后,节奏就有点被少量企业带偏了。有一两次,我甚至不确定面试官什么时候进入会议。我想,难道这是人才应该受到的“礼待”吗?我有点被轻微冒犯的感觉了。不过我还是“很有涵养地”表示没事。但我始终觉得:面试官迟到,是对人才的不尊重。进入不尊重人才的公司,我是怀有疑虑的。良禽择木而栖,良臣择主而事。难道我能因为此刻的不利处境,而放弃一些基本的原则底线,而屈从于一份不尊重人才的 offer 吗?\n我意识到:一个人应当用其实力去赢得对方的尊重和赏识,以后的合作才会更顺畅。不若,哪怕惜其无缘,亦不可强留。无论别人怎么存疑,心无旁骛地打磨实力,挖掘自己的才干和优势,终会发出自己的光芒。因此,我的心态顿时转变了:应当专注去沟通,与对方充分认识了解,赢得对方心服的认可,而不是拿到一张入门券,成为干活的工具。\n有一个“石头和玉”的小故事,把自己当做人才,并努力去提升自己,才能获得“人才的礼遇”;把自己当石头贱卖,放松努力,也就只能得到“石头的礼遇”。尽管一个人不一定马上就具备人才的能力,但在自己的内心里,就应当从人才的视角去观察待入职的企业,而不仅仅是为了找一份“赚更多钱”的工作。\n此外,焦虑也是不必要的。焦虑的实质是现实与目标的差距。一个人总可以评估目标的合理性及如何达成目标。如果目标过高,则适当调整目标级别;目标可行,则作出合理的决策,并通过持续的努力和恰当的出击来实现目标。决策、努力和出击能力都是可以持续修炼的。\n面试历炼 # 技术人的面试还是更偏重于技术,因此,技术的深度和广度还是要好好准备的。面试官和候选人的处境是不一样的,一个面试官问的只是少量点,但是多个面试官合起来就是一个面。明白这一点,作为面试官的你就不要忘乎所以,以为自己就比候选人厉害。\n我面的企业不多,因为我已经打算从事教育事业,用“志趣和驱动力”这项就直接过滤了很多企业的面试邀请。在杭州面试的基本是教育企业,连阿里华为等抛来的橄榄枝都婉拒了(尽管我也不一定能面上)。虽然做法有点“直男”,但投入最多精力于自己期望从事的行业和事业,才是值得的。\n我所认为的教育事业,并不局限于现在常谈起的在线教育或 K12 教育,而是一个教育体系,任何可以更好滴起到教育效果的事业,包括而不限于教学、阅读、音乐、设计等。\n接力棒科技-高工 # 面的第一家。畅谈一番后,没音讯了。但我也没有太在意。面试官问的比较偏交易业务性的东西,较深的就是如何保证应用的数据一致性了。\n此时的我,就像在路上扔了一颗探路的小石子,尚未意识到自己的处境。\n网易云音乐-高工 # 接着是网易云音乐。大厂就是大厂。一面问的尽是缓存、分布式锁、Dubbo、ZK, MQ 中间件相关的机制。很遗憾,由于我平时关于技术原理的沉淀还是很少,基本是“一问两不知”,挂得很出彩。\n此时,我初步意识到自己的技术底子还很薄弱,也就开始了广阔的技术学习和夯实,自底向上地梳理原理和逻辑,系统地进行整理总结,最终初步形成了自己的互联网服务端技术知识体系结构。\n铭师堂-技术专家 # 架构师面试的。问的相对多了一些,DB, Redis 等。反馈是技术还行,但缺乏管理经验。这是我第一次意识到大龄程序员缺乏管理经验的不利。中小企业的技术专家线招聘中,往往附加了管理经验的需求。应聘时要注意。\n缺乏管理经验,该怎么办呢?思考过一段时间后,我的想法是:\n改变能改变的,不能改变的,学习它。比如技术原理的学习是我能够改变的,但管理经验属于难以一时改变的,那就多了解点管理的基本理论吧。 从经历中挖掘相关经验。虽然我没有正式带团队的实际经验,但是有带项目和带工程师,管控某个业务线的基本管理经验。多多挖掘自己的经历。 字节教育-高工 # 字节教育面试,我给自己挖了不少坑往里跳。\n比如面试官问,讲一个你比较成就感的项目经历。我选择的是近 4 年前的周期购项目。虽然这是我入职有赞的第一个有代表性的项目,但时间太久,又没有详细记录,很多技术细节遗忘不清晰了。我讲到当时印象比较深的“一体化”设计思想,却忘记了当时为什么会有这种思想(未做仔细记录)。\n再比如,一个上课的场景题,我问是用 CS 架构还是 BS 架构?面试官说用 CS 架构吧。这不是给自己挖坑吗?明明自己不熟悉 CS 架构,何必问这个选择呢,不如直接按照 BS 架构来讲解。哎!\n字节教育给我的反馈是:业务 Sense 不错,系统设计能力有待提高。我觉得还是比较中肯的。因此,也开始注重系统设计实战方面的文章阅读和思考训练。\n经验是:\n做项目时,要详细记录每个项目的技术栈、技术决策及原因、技术细节,为面试做好铺垫; 提前准备好印象最深刻的最代表性的系统和项目,避免选择距离当前时间较久的缺乏详细记录的项目; 选择熟悉的项目和架构,至少有好的第一印象,不然给面试官的印象就是你啥都不会。 咪咕数媒-架构师 # 好家伙,一下子 3 位面试官群面。可能我以前经历的太少了吧。似乎国企面试较高端职位,喜欢采取这种形式。兼听则明偏听则暗嘛。问的问题也很广泛,从 ES 的基本原理,到机房的数据迁移。有些技术机制虽然学习过,但不牢固,不清晰,答的也不好。比如 ES 的搜索原理优化,讲过倒排索引后,我对 Term Index 和 Trie 树 讲不清楚。这说明,知道并不代表真正理解了。只有能够清晰有条理地把思路和细节都讲清楚,才算是真正理解了。\n印象深刻的是,有一个问题:你有哪些架构思想?这是第一次被问到架构设计方面的东西,我顿时有点慌乱。虽然平时多有思考,也有写过文章,却没有形成系统精炼的方法论,结果就是答的比较凌乱。\n涂鸦智能-高工 # 应聘涂鸦智能,是因为我觉得这家企业不错。优秀的企业至少应该多沟通一下,说不准以后有合作机会呢!看问题的思维要开阔一些,不能死守在自己想到的那一个事情上。\n涂鸦智能给我的整体观感还是不错的。面试官也很有礼貌有耐心,整体架构、技术和项目都问了很多,问到了我熟悉的地方,答得也还可以。也许我的经验正好是切中他们的需求吧。\n若不是当时想做教育的执念特别强,我很大概率会入职涂鸦智能。物联网在我看来应该是很有趣的领域。\n跟谁学-技术专家 # “跟谁学”基本能答上来。不过反馈是:对于提问抓重点的能力有所欠缺,对于技术的归纳整理也不够。我当时还有点不服气,认为自己写了那么多文章,也算是有不少思考,怎能算是总结不够呢?顶多是有技术盲点。技术犹如海洋,谁能没有盲点?\n不过现在反观,确实距离自己应该有的程度不够。对技术原理机制和生产问题排查的总结不够,不够清晰细致;对设计实践的经验总结也不够,不够系统扎实。这个事情还要持续深入地去做。\n此外,面得越多,越发现自己的表述能力确实有所欠缺。啰嗦、容易就一点展开说个没完、脱离背景直接说方案、跳跃、回旋往复,然后面试官很可能没耐心了。应该遵循“先总后分”、“基本思路-实现-优化”的一些基本逻辑来作答会更好一些。表述能力真的很重要,不可只顾着敲代码。还有每次面教育企业就不免紧张,生怕错过这个机会。\n这是第二家直接告诉我年龄与经验不匹配的企业,加深了我对年龄偏大的忧虑,以致于开始有点不自信了。\n那么我又是怎么重拾信心的呢?有一句老话:“留得青山在,不怕没柴烧”。就算我年龄比较大,如果我的技术能力打磨得足够硬朗,就不信找不到一家能够认可我的企业。大不了我去做开源项目好了。具备好的技术能力,并不一定就局限在企业的范围内去发挥作用,也没必要局限于那些被年龄偏见所蒙蔽的人的认知里。外界的认可固然重要,内在的可贵性却远胜于外在。\n亿童文教-架构师 # 也是采用的 3 人同时面试。主要问的是项目经历,技术方面问得倒不是深入。个人觉得答得还行。面试官也问了架构设计相关的问题,我答得一般。此时,我仍然没有意识到自己在以面“高级工程师”的规格来面试“架构师”岗位。\n面试官比较温和,HR 也在积极联系和沟通,感觉还不错。只是,我没有主动去问反馈意见,也就没有下文了。\n新东方-高工 # 面试新东方,主要是因为切中我做教育的期望,虽然职位需求是做信息管理系统,距离我理想中的业务还有一定距离。经过沟通了解,他们更需要的是对运维方面更熟悉的工程师,不过我正好对运维方面不太熟悉,平时关注不多,因此不太符合他们的真实招聘要求。面试官也是很温和的人,老家在宜昌,是我本科上大学的地方,面试体验不错。\n以后要花些时间学习一些运维相关的东西。作为一名优秀的工程师和合格的架构师,是要广泛学习和熟悉系统所采用的各种组件、中间件、运维部署等的。要有综观能力,不过我醒悟的可能有点迟。Better later than never.\nZOOM-高工 # ZOOM 的一位面试官或许是我见过的所有面试官中最差劲的。共有两位面试官,一位显得很有耐心,另一位则挺着胖胖的肚子,还打着哈欠,一副不怎么关心面试和候选人的样子。我心想,你要不想面,为啥还要来面呢?你以为候选人就低你一等么?换个位置我可以暴打你。不过我还是很有礼貌的,当做什么事也没发生。公司在挑人,候选人也在挑选公司。\n想想,ZOOM 还是疫情期间我们公司用过的远程通信会议软件。印象还不错,有这样的工程师和面试官藏于其中,我也是服了。难倒他是传说中的大大神?据我所知,国外对国内的互联网软件技术设施基本呈碾压态势,中国大部分企业所用的框架、中间件、基础设施等基本是拿国外的来用或者做定制化,真正有自研的很少,有什么好自满的呢?\n阿优文化-高工 # 阿优文化有四轮技术面。其中第一个技术面给我印象比较深刻。看上去,面试官对操作系统的原理机制特别擅长和熟悉。很多问题我都没答上来。本以为挂了,不过又给了扳回一局的机会。第二位面试问的项目经历和技术问题是我很熟悉的。第三位面试官问的比较广泛,有答的上来的,有答不上来的。不过面试官很耐心。第四位是技术总监,也问得很广泛细致。\n整体来说,面试氛围还是很宽松的。不过,阿优当时的招聘需求并不强烈,估计是希望后续有机会时再联系我。可惜我那时准备回武汉了。主要是考虑父母年事已高,希望能多陪陪父母。\n想想,我想问题做决策还是过于简单的,不会做很复杂的计算和权衡。\n小米-专家/架构 # 应聘小米,主要是因为职位与之前在有赞做的很相似,都是做交易中台相关。浏览小米官网之后,觉得他们做的事情很棒,可是与我想做教育文化事业的初衷不太贴合。\n加入小米的意愿不太强烈,面试也就失去了大半动力。我这个性子还是要改一改。\n视觉中国-高工 # 围绕技术、项目和经历来问。总体来说,技术深度并不是太难,项目方面也涉及到了。人力面前辈很温和,我以为会针对自己的经历进行一番“轰炸”,结果是为前辈讲了讲有赞的产品服务和生意模式,然后略略带了下自己的一些经历。\n科大讯飞-架构师 # 一二面,感觉面试官对安排的面试不太感兴趣。架构师,至少是一个对技术和设计能力非常高要求的职位。一面的技术和架构都问了些,二面总围绕我的背景和非技术相关的东西问,似乎对我的外在更关注,而对我自身的技术和设计能力不感兴趣。交流偏浅。\n能力固然有高下之分,但尊重人才的基本礼节却是不变的。尊重人才,是指聚焦人才的能力和才学,而不是一些与才学不甚相关的东西。\n青藤云-高工 # 青藤云的技术面试风格是温和的。感受到坦率交流的味道,被认可的感觉。感受到 HR 求才若渴的心情。和我之前认为的“应当用其实力去赢得对方的尊重和赏识”不谋而合。\n腾讯会议-高工 # 和腾讯面试官是用腾讯会议软件面试腾讯会议的职位。哈哈。由于网络不太稳定,面试过程充满了磕磕碰碰,一句话没说完整就听不清楚了。可想情况如何。但是我们都很有很有很有耐心,最终一起完成了一面。面试是双方智慧与力量的较量,更是双方一起去完成一件事情、发现彼此的合作。这样想来,传统的“单方考验筛选式”的面试观念需要革新。\n由于我已经拿到 offer , 且腾讯会议的事情并不太贴合自己的初衷,因此,我与腾讯方面沟通,停止了二面。\n最终选择 # 当拿到多个 offer 时,如何选择呢?我个人主要看重:\n志趣与驱动力; 薪资待遇; 公司发展前景和个人发展空间; 工作氛围; 小而有战斗力的企业。 在视觉中国与青藤云之间如何选择?作个对比:\n薪资待遇:两者的薪资待遇不相上下,也都是认可我的;视觉中国给出的是 Leader 的职位,而青藤云给出的是核心业务的承诺; 工作氛围:青藤云应该更偏工程师文化氛围,而视觉中国更偏业务化; 挑战性:青藤云的技术挑战更强,而视觉中国的业务挑战性更强; 志趣与驱动力:视觉中国更符合我想做文化的事情,而青藤云安全并不贴合我想做教育文化事业的初衷,而且比较偏技术和底层(我更希望做一些人文性的事情)。但青藤云做的是关于安全的事情,安全是一件很有价值很有意义的事情。而且,以后安全也可以服务于教育行业。有点曲线救国的味道。尤其是创始人张福的理想主义信念“让安全之光照亮互联网的每个角落”及自己的身体力行,让人更有一些触动。最终,我觉得做安全比做图片版权保护稍胜出一小筹。 此外,我觉得做教育,更适合自己的是编程教育,或者是工程师教育。我还想成为一名系统设计师。还需要积累更多生产实践经验。可以多与初中级工程师打交道,在企业内部做培训指导。或者工作之余录制视频,上传到 B 站,服务广大吃瓜群众。将来,我或许还会写一本关于编程设计的书,汇聚毕生所学。\n因此,经过一天慎重的考虑,我决定,加入青藤云安全。当然,做这个选择的同时,也意味着我选择了一个更大的挑战:在安全方面我基本一穷二白,需要学习很多很多的知识和经验,对于我这个大龄程序员来说,是一项不小的挑战。\n小结 # 很多事情都有解决的方法,即使“头疼的”大龄程序员找工作也不例外。确立明确清晰的目标、制定科学合理的决策、持续的努力、掌握基本面、恰当的出击,终能斩获胜利的果实。但要强调一下:功夫在平时。平时要是不累积好,面试的时候就要花更多时间去学习,会受挫、磕磕碰碰、过得也不太舒坦。还是平摊到平时比较好。此外,平时视野也要保持开阔,切忌在面试的时候才“幡然醒悟”。\n一个重要经验是,要善于从失败中学习。正是在杭州四个月空档期的持续学习、思考、积累和提炼,以及面试失败的反思、不断调整对策、完善准备、改善原有的短板,采取更为合理的方式,才在回武汉的短短两个周内拿到比较满意的 offer 。\n此外,值得提及的是,对于技术人员,写博客是一件很有价值的事情。面试通过沟通去了解对方,有其局限性所在。面试未能筛选出符合的人才其实是有比较大概率的:\n面试的时间很短,即使是很有经验的面试官,也会看走眼(根本局限性); 面试官问到的正好是自己不会的(运气问题); 面试官情绪不好,没兴趣(运气问题); 面试官自身的水平。 因此,具备真才实学而被 PASS 掉,并不值得伤心。写博客的意义在于,有更多展示自己思考和平时工作的维度。\n尊重人才的企业,一定是希望从多方面去认识候选人(在优点和缺点之间选择确认是否符合期望),包括博客;不尊重人才的企业,则会倾向于用偷懒的方法,对候选人真实的本领不在意,用一些外在的标准去快速过滤,固然高效,最终对人才的识别能力并不会有多大进步。\n经过这一段面试的历炼,我觉得现在相比离职时的自己,又有了不少进步的。不说脱胎换骨,至少也是蜕了一层皮吧。差距,差距还是有的。起码面试那些知名大厂企业的技术专家和架构师还有差距。这与我平时工作的挑战性、认知视野的局限性及总结不足有关。下一次,我希望积蓄足够实力做到更好,和内心热爱的有价值有意义的事情再近一些些。\n面试,其实也是一段工作经历。\n"},{"id":672,"href":"/zh/docs/technology/Interview/cs-basics/network/application-layer-protocol/","title":"应用层常见协议总结(应用层)","section":"Network","content":" HTTP:超文本传输协议 # 超文本传输协议(HTTP,HyperText Transfer Protocol) 是一种用于传输超文本和多媒体内容的协议,主要是为 Web 浏览器与 Web 服务器之间的通信而设计的。当我们使用浏览器浏览网页的时候,我们网页就是通过 HTTP 请求进行加载的。\nHTTP 使用客户端-服务器模型,客户端向服务器发送 HTTP Request(请求),服务器响应请求并返回 HTTP Response(响应),整个过程如下图所示。\nHTTP 协议基于 TCP 协议,发送 HTTP 请求之前首先要建立 TCP 连接也就是要经历 3 次握手。目前使用的 HTTP 协议大部分都是 1.1。在 1.1 的协议里面,默认是开启了 Keep-Alive 的,这样的话建立的连接就可以在多次请求中被复用了。\n另外, HTTP 协议是”无状态”的协议,它无法记录客户端用户的状态,一般我们都是通过 Session 来记录客户端用户的状态。\nWebsocket:全双工通信协议 # WebSocket 是一种基于 TCP 连接的全双工通信协议,即客户端和服务器可以同时发送和接收数据。\nWebSocket 协议在 2008 年诞生,2011 年成为国际标准,几乎所有主流较新版本的浏览器都支持该协议。不过,WebSocket 不只能在基于浏览器的应用程序中使用,很多编程语言、框架和服务器都提供了 WebSocket 支持。\nWebSocket 协议本质上是应用层的协议,用于弥补 HTTP 协议在持久通信能力上的不足。客户端和服务器仅需一次握手,两者之间就直接可以创建持久性的连接,并进行双向数据传输。\n下面是 WebSocket 的常见应用场景:\n视频弹幕 实时消息推送,详见 Web 实时消息推送详解这篇文章 实时游戏对战 多用户协同编辑 社交聊天 …… WebSocket 的工作过程可以分为以下几个步骤:\n客户端向服务器发送一个 HTTP 请求,请求头中包含 Upgrade: websocket 和 Sec-WebSocket-Key 等字段,表示要求升级协议为 WebSocket; 服务器收到这个请求后,会进行升级协议的操作,如果支持 WebSocket,它将回复一个 HTTP 101 状态码,响应头中包含 ,Connection: Upgrade和 Sec-WebSocket-Accept: xxx 等字段、表示成功升级到 WebSocket 协议。 客户端和服务器之间建立了一个 WebSocket 连接,可以进行双向的数据传输。数据以帧(frames)的形式进行传送,WebSocket 的每条消息可能会被切分成多个数据帧(最小单位)。发送端会将消息切割成多个帧发送给接收端,接收端接收消息帧,并将关联的帧重新组装成完整的消息。 客户端或服务器可以主动发送一个关闭帧,表示要断开连接。另一方收到后,也会回复一个关闭帧,然后双方关闭 TCP 连接。 另外,建立 WebSocket 连接之后,通过心跳机制来保持 WebSocket 连接的稳定性和活跃性。\nSMTP:简单邮件传输(发送)协议 # 简单邮件传输(发送)协议(SMTP,Simple Mail Transfer Protocol) 基于 TCP 协议,是一种用于发送电子邮件的协议\n注意 ⚠️:接受邮件的协议不是 SMTP 而是 POP3 协议。\nSMTP 协议这块涉及的内容比较多,下面这两个问题比较重要:\n电子邮件的发送过程 如何判断邮箱是真正存在的? 电子邮件的发送过程?\n比如我的邮箱是“ dabai@cszhinan.com”,我要向“ xiaoma@qq.com”发送邮件,整个过程可以简单分为下面几步:\n通过 SMTP 协议,我将我写好的邮件交给 163 邮箱服务器(邮局)。 163 邮箱服务器发现我发送的邮箱是 qq 邮箱,然后它使用 SMTP 协议将我的邮件转发到 qq 邮箱服务器。 qq 邮箱服务器接收邮件之后就通知邮箱为“ xiaoma@qq.com”的用户来收邮件,然后用户就通过 POP3/IMAP 协议将邮件取出。 如何判断邮箱是真正存在的?\n很多场景(比如邮件营销)下面我们需要判断我们要发送的邮箱地址是否真的存在,这个时候我们可以利用 SMTP 协议来检测:\n查找邮箱域名对应的 SMTP 服务器地址 尝试与服务器建立连接 连接成功后尝试向需要验证的邮箱发送邮件 根据返回结果判定邮箱地址的真实性 推荐几个在线邮箱是否有效检测工具:\nhttps://verify-email.org/ http://tool.chacuo.net/mailverify https://www.emailcamel.com/ POP3/IMAP:邮件接收的协议 # 这两个协议没必要多做阐述,只需要了解 POP3 和 IMAP 两者都是负责邮件接收的协议 即可(二者也是基于 TCP 协议)。另外,需要注意不要将这两者和 SMTP 协议搞混淆了。SMTP 协议只负责邮件的发送,真正负责接收的协议是 POP3/IMAP。\nIMAP 协议是比 POP3 更新的协议,它在功能和性能上都更加强大。IMAP 支持邮件搜索、标记、分类、归档等高级功能,而且可以在多个设备之间同步邮件状态。几乎所有现代电子邮件客户端和服务器都支持 IMAP。\nFTP:文件传输协议 # FTP 协议 基于 TCP 协议,是一种用于在计算机之间传输文件的协议,可以屏蔽操作系统和文件存储方式。\nFTP 是基于客户—服务器(C/S)模型而设计的,在客户端与 FTP 服务器之间建立两个连接。如果我们要基于 FTP 协议开发一个文件传输的软件的话,首先需要搞清楚 FTP 的原理。关于 FTP 的原理,很多书籍上已经描述的非常详细了:\nFTP 的独特的优势同时也是与其它客户服务器程序最大的不同点就在于它在两台通信的主机之间使用了两条 TCP 连接(其它客户服务器应用程序一般只有一条 TCP 连接):\n控制连接:用于传送控制信息(命令和响应) 数据连接:用于数据传送; 这种将命令和数据分开传送的思想大大提高了 FTP 的效率。\n注意 ⚠️:FTP 是一种不安全的协议,因为它在传输过程中不会对数据进行加密。因此,FTP 传输的文件可能会被窃听或篡改。建议在传输敏感数据时使用更安全的协议,如 SFTP(SSH File Transfer Protocol,一种基于 SSH 协议的安全文件传输协议,用于在网络上安全地传输文件)。\nTelnet:远程登陆协议 # Telnet 协议 基于 TCP 协议,用于通过一个终端登陆到其他服务器。Telnet 协议的最大缺点之一是所有数据(包括用户名和密码)均以明文形式发送,这有潜在的安全风险。这就是为什么如今很少使用 Telnet,而是使用一种称为 SSH 的非常安全的网络传输协议的主要原因。\nSSH:安全的网络传输协议 # SSH(Secure Shell) 基于 TCP 协议,通过加密和认证机制实现安全的访问和文件传输等业务。\nSSH 的经典用途是登录到远程电脑中执行命令。除此之外,SSH 也支持隧道协议、端口映射和 X11 连接(允许用户在本地运行远程服务器上的图形应用程序)。借助 SFTP(SSH File Transfer Protocol) 或 SCP(Secure Copy Protocol) 协议,SSH 还可以安全传输文件。\nSSH 使用客户端-服务器模型,默认端口是 22。SSH 是一个守护进程,负责实时监听客户端请求,并进行处理。大多数现代操作系统都提供了 SSH。\n如下图所示,SSH Client(SSH 客户端)和 SSH Server(SSH 服务器)通过公钥交换生成共享的对称加密密钥,用于后续的加密通信。\nRTP:实时传输协议 # RTP(Real-time Transport Protocol,实时传输协议)通常基于 UDP 协议,但也支持 TCP 协议。它提供了端到端的实时传输数据的功能,但不包含资源预留存、不保证实时传输质量,这些功能由 WebRTC 实现。\nRTP 协议分为两种子协议:\nRTP(Real-time Transport Protocol,实时传输协议):传输具有实时特性的数据。 RTCP(RTP Control Protocol,RTP 控制协议):提供实时传输过程中的统计信息(如网络延迟、丢包率等),WebRTC 正是根据这些信息处理丢包 DNS:域名系统 # DNS(Domain Name System,域名管理系统)基于 UDP 协议,用于解决域名和 IP 地址的映射问题。\n参考 # 《计算机网络自顶向下方法》(第七版) RTP 协议介绍: https://mthli.xyz/rtp-introduction/ "},{"id":673,"href":"/zh/docs/technology/Interview/distributed-system/rpc/httprpc/","title":"有了 HTTP 协议,为什么还要有 RPC ?","section":"Rpc","content":" 本文来自 小白 debug投稿,原文: https://juejin.cn/post/7121882245605883934 。\n我想起了我刚工作的时候,第一次接触 RPC 协议,当时就很懵,我 HTTP 协议用的好好的,为什么还要用 RPC 协议?\n于是就到网上去搜。\n不少解释显得非常官方,我相信大家在各种平台上也都看到过,解释了又好像没解释,都在用一个我们不认识的概念去解释另外一个我们不认识的概念,懂的人不需要看,不懂的人看了还是不懂。\n这种看了,又好像没看的感觉,云里雾里的很难受,我懂。\n为了避免大家有强烈的审丑疲劳,今天我们来尝试重新换个方式讲一讲。\n从 TCP 聊起 # 作为一个程序员,假设我们需要在 A 电脑的进程发一段数据到 B 电脑的进程,我们一般会在代码里使用 socket 进行编程。\n这时候,我们可选项一般也就TCP 和 UDP 二选一。TCP 可靠,UDP 不可靠。 除非是马总这种神级程序员(早期 QQ 大量使用 UDP),否则,只要稍微对可靠性有些要求,普通人一般无脑选 TCP 就对了。\n类似下面这样。\nfd = socket(AF_INET,SOCK_STREAM,0); 其中SOCK_STREAM,是指使用字节流传输数据,说白了就是TCP 协议。\n在定义了 socket 之后,我们就可以愉快的对这个 socket 进行操作,比如用bind()绑定 IP 端口,用connect()发起建连。\n在连接建立之后,我们就可以使用send()发送数据,recv()接收数据。\n光这样一个纯裸的 TCP 连接,就可以做到收发数据了,那是不是就够了?\n不行,这么用会有问题。\n使用纯裸 TCP 会有什么问题 # 八股文常背,TCP 是有三个特点,面向连接、可靠、基于字节流。\n这三个特点真的概括的 非常精辟 ,这个八股文我们没白背。\n每个特点展开都能聊一篇文章,而今天我们需要关注的是 基于字节流 这一点。\n字节流可以理解为一个双向的通道里流淌的二进制数据,也就是 01 串 。纯裸 TCP 收发的这些 01 串之间是 没有任何边界 的,你根本不知道到哪个地方才算一条完整消息。\n正因为这个没有任何边界的特点,所以当我们选择使用 TCP 发送 \u0026ldquo;夏洛\u0026quot;和\u0026quot;特烦恼\u0026rdquo; 的时候,接收端收到的就是 \u0026ldquo;夏洛特烦恼\u0026rdquo; ,这时候接收端没发区分你是想要表达 \u0026ldquo;夏洛\u0026rdquo;+\u0026ldquo;特烦恼\u0026rdquo; 还是 \u0026ldquo;夏洛特\u0026rdquo;+\u0026ldquo;烦恼\u0026rdquo; 。\n这就是所谓的 粘包问题,之前也写过一篇专门的 文章聊过这个问题。\n说这个的目的是为了告诉大家,纯裸 TCP 是不能直接拿来用的,你需要在这个基础上加入一些 自定义的规则 ,用于区分 消息边界 。\n于是我们会把每条要发送的数据都包装一下,比如加入 消息头 ,消息头里写清楚一个完整的包长度是多少,根据这个长度可以继续接收数据,截取出来后它们就是我们真正要传输的 消息体 。\n而这里头提到的 消息头 ,还可以放各种东西,比如消息体是否被压缩过和消息体格式之类的,只要上下游都约定好了,互相都认就可以了,这就是所谓的 协议。\n每个使用 TCP 的项目都可能会定义一套类似这样的协议解析标准,他们可能 有区别,但原理都类似。\n于是基于 TCP,就衍生了非常多的协议,比如 HTTP 和 RPC。\nHTTP 和 RPC # RPC 其实是一种调用方式 # 我们回过头来看网络的分层图。\nTCP 是传输层的协议 ,而基于 TCP 造出来的 HTTP 和各类 RPC 协议,它们都只是定义了不同消息格式的 应用层协议 而已。\nHTTP(Hyper Text Transfer Protocol)协议又叫做 超文本传输协议 。我们用的比较多,平时上网在浏览器上敲个网址就能访问网页,这里用到的就是 HTTP 协议。\n而 RPC(Remote Procedure Call)又叫做 远程过程调用,它本身并不是一个具体的协议,而是一种 调用方式 。\n举个例子,我们平时调用一个 本地方法 就像下面这样。\nres = localFunc(req) 如果现在这不是个本地方法,而是个远端服务器暴露出来的一个方法remoteFunc,如果我们还能像调用本地方法那样去调用它,这样就可以屏蔽掉一些网络细节,用起来更方便,岂不美哉?\nres = remoteFunc(req) 基于这个思路,大佬们造出了非常多款式的 RPC 协议,比如比较有名的gRPC,thrift。\n值得注意的是,虽然大部分 RPC 协议底层使用 TCP,但实际上 它们不一定非得使用 TCP,改用 UDP 或者 HTTP,其实也可以做到类似的功能。\n到这里,我们回到文章标题的问题。\n那既然有 RPC 了,为什么还要有 HTTP 呢? # 其实,TCP 是 70 年 代出来的协议,而 HTTP 是 90 年代 才开始流行的。而直接使用裸 TCP 会有问题,可想而知,这中间这么多年有多少自定义的协议,而这里面就有 80 年代 出来的RPC。\n所以我们该问的不是 既然有 HTTP 协议为什么要有 RPC ,而是 为什么有 RPC 还要有 HTTP 协议?\n现在电脑上装的各种联网软件,比如 xx 管家,xx 卫士,它们都作为客户端(Client) 需要跟服务端(Server) 建立连接收发消息,此时都会用到应用层协议,在这种 Client/Server (C/S) 架构下,它们可以使用自家造的 RPC 协议,因为它只管连自己公司的服务器就 ok 了。\n但有个软件不同,浏览器(Browser) ,不管是 Chrome 还是 IE,它们不仅要能访问自家公司的服务器(Server) ,还需要访问其他公司的网站服务器,因此它们需要有个统一的标准,不然大家没法交流。于是,HTTP 就是那个时代用于统一 Browser/Server (B/S) 的协议。\n也就是说在多年以前,HTTP 主要用于 B/S 架构,而 RPC 更多用于 C/S 架构。但现在其实已经没分那么清了,B/S 和 C/S 在慢慢融合。 很多软件同时支持多端,比如某度云盘,既要支持网页版,还要支持手机端和 PC 端,如果通信协议都用 HTTP 的话,那服务器只用同一套就够了。而 RPC 就开始退居幕后,一般用于公司内部集群里,各个微服务之间的通讯。\n那这么说的话,都用 HTTP 得了,还用什么 RPC?\n仿佛又回到了文章开头的样子,那这就要从它们之间的区别开始说起。\nHTTP 和 RPC 有什么区别 # 我们来看看 RPC 和 HTTP 区别比较明显的几个点。\n服务发现 # 首先要向某个服务器发起请求,你得先建立连接,而建立连接的前提是,你得知道 IP 地址和端口 。这个找到服务对应的 IP 端口的过程,其实就是 服务发现。\n在 HTTP 中,你知道服务的域名,就可以通过 DNS 服务 去解析得到它背后的 IP 地址,默认 80 端口。\n而 RPC 的话,就有些区别,一般会有专门的中间服务去保存服务名和 IP 信息,比如 Consul、Etcd、Nacos、ZooKeeper,甚至是 Redis。想要访问某个服务,就去这些中间服务去获得 IP 和端口信息。由于 DNS 也是服务发现的一种,所以也有基于 DNS 去做服务发现的组件,比如 CoreDNS。\n可以看出服务发现这一块,两者是有些区别,但不太能分高低。\n底层连接形式 # 以主流的 HTTP1.1 协议为例,其默认在建立底层 TCP 连接之后会一直保持这个连接(keep alive),之后的请求和响应都会复用这条连接。\n而 RPC 协议,也跟 HTTP 类似,也是通过建立 TCP 长链接进行数据交互,但不同的地方在于,RPC 协议一般还会再建个 连接池,在请求量大的时候,建立多条连接放在池内,要发数据的时候就从池里取一条连接出来,用完放回去,下次再复用,可以说非常环保。\n由于连接池有利于提升网络请求性能,所以不少编程语言的网络库里都会给 HTTP 加个连接池,比如 Go 就是这么干的。\n可以看出这一块两者也没太大区别,所以也不是关键。\n传输的内容 # 基于 TCP 传输的消息,说到底,无非都是 消息头 Header 和消息体 Body。\nHeader 是用于标记一些特殊信息,其中最重要的是 消息体长度。\nBody 则是放我们真正需要传输的内容,而这些内容只能是二进制 01 串,毕竟计算机只认识这玩意。所以 TCP 传字符串和数字都问题不大,因为字符串可以转成编码再变成 01 串,而数字本身也能直接转为二进制。但结构体呢,我们得想个办法将它也转为二进制 01 串,这样的方案现在也有很多现成的,比如 JSON,Protocol Buffers (Protobuf) 。\n这个将结构体转为二进制数组的过程就叫 序列化 ,反过来将二进制数组复原成结构体的过程叫 反序列化。\n对于主流的 HTTP1.1,虽然它现在叫超文本协议,支持音频视频,但 HTTP 设计 初是用于做网页文本展示的,所以它传的内容以字符串为主。Header 和 Body 都是如此。在 Body 这块,它使用 JSON 来 序列化 结构体数据。\n我们可以随便截个图直观看下。\n可以看到这里面的内容非常多的冗余,显得非常啰嗦。最明显的,像 Header 里的那些信息,其实如果我们约定好头部的第几位是 Content-Type,就不需要每次都真的把 Content-Type 这个字段都传过来,类似的情况其实在 Body 的 JSON 结构里也特别明显。\n而 RPC,因为它定制化程度更高,可以采用体积更小的 Protobuf 或其他序列化协议去保存结构体数据,同时也不需要像 HTTP 那样考虑各种浏览器行为,比如 302 重定向跳转啥的。因此性能也会更好一些,这也是在公司内部微服务中抛弃 HTTP,选择使用 RPC 的最主要原因。\n当然上面说的 HTTP,其实 特指的是现在主流使用的 HTTP1.1,HTTP2在前者的基础上做了很多改进,所以 性能可能比很多 RPC 协议还要好,甚至连gRPC底层都直接用的HTTP2。\n那么问题又来了。\n为什么既然有了 HTTP2,还要有 RPC 协议? # 这个是由于 HTTP2 是 2015 年出来的。那时候很多公司内部的 RPC 协议都已经跑了好些年了,基于历史原因,一般也没必要去换了。\n总结 # 纯裸 TCP 是能收发数据,但它是个无边界的数据流,上层需要定义消息格式用于定义 消息边界 。于是就有了各种协议,HTTP 和各类 RPC 协议就是在 TCP 之上定义的应用层协议。 RPC 本质上不算是协议,而是一种调用方式,而像 gRPC 和 Thrift 这样的具体实现,才是协议,它们是实现了 RPC 调用的协议。目的是希望程序员能像调用本地方法那样去调用远端的服务方法。同时 RPC 有很多种实现方式,不一定非得基于 TCP 协议。 从发展历史来说,HTTP 主要用于 B/S 架构,而 RPC 更多用于 C/S 架构。但现在其实已经没分那么清了,B/S 和 C/S 在慢慢融合。 很多软件同时支持多端,所以对外一般用 HTTP 协议,而内部集群的微服务之间则采用 RPC 协议进行通讯。 RPC 其实比 HTTP 出现的要早,且比目前主流的 HTTP1.1 性能要更好,所以大部分公司内部都还在使用 RPC。 HTTP2.0 在 HTTP1.1 的基础上做了优化,性能可能比很多 RPC 协议都要好,但由于是这几年才出来的,所以也不太可能取代掉 RPC。 "},{"id":674,"href":"/zh/docs/technology/Interview/high-quality-technical-articles/advanced-programmer/20-bad-habits-of-bad-programmers/","title":"糟糕程序员的 20 个坏习惯","section":"Advanced Programmer","content":" 推荐语:Kaito 大佬的一篇文章,很实用的建议!\n原文地址: https://mp.weixin.qq.com/s/6hUU6SZsxGPWAIIByq93Rw\n我想你肯定遇到过这样一类程序员:他们无论是写代码,还是写文档,又或是和别人沟通,都显得特别专业。每次遇到这类人,我都在想,他们到底是怎么做到的?\n随着工作时间的增长,渐渐地我也总结出一些经验,他们身上都保持着一些看似很微小的优秀习惯,但正是因为这些习惯,体现出了一个优秀程序员的基本素养。\n但今天我们来换个角度,来看看一个糟糕程序员有哪些坏习惯?只要我们都能避开这些问题,就可以逐渐向一个优秀程序员靠近。\n1、技术名词拼写不规范 # 无论是个人简历,还是技术文档,我经常看到拼写不规范的技术名词,例如 JAVA、javascript、python、MySql、Hbase、restful。\n正确的拼写应该是 Java、JavaScript、Python、MySQL、HBase、RESTful,不要小看这个问题,很多面试官很有可能因为这一点刷掉你的简历。\n2、写文档,中英文混排不规范 # 中文描述使用英文标点符号,英文和数字使用了全角字符,中文与英文、数字之间没有空格等等。\n其中很多人会忽视中文和英文、数字之间加一个「空格」,这样排版阅读起来会更舒服。之前我的文章排版,都是遵循了这些细节。\n3、重要逻辑不写注释,或写得很拖沓 # 复杂且重要的逻辑代码,很多程序员不写注释,除了自己能看懂代码逻辑,其他人根本看不懂。或者是注释虽然写了,但写得很拖沓,没有逻辑可言。\n重要的逻辑不止要写注释,还要写得简洁、清晰。如果是一眼就能读懂的简单代码,可以不加注释。\n4、写复杂冗长的函数 # 一个函数几百行,一个文件上千行代码,复杂函数不做拆分,导致代码变得越来越难维护,最后谁也不敢动。\n基本的设计模式还是要遵守的,例如单一职责,一个函数只做一件事,开闭原则,对扩展开放,对修改关闭。\n如果函数逻辑确实复杂,也至少要保证主干逻辑足够清晰。\n5、不看官方文档,只看垃圾博客 # 很多人遇到问题不先去看官方文档,而是热衷于去看垃圾博客,这些博客的内容都是互相抄袭,错误百出。\n其实很多软件官方文档写得已经非常好了,常见问题都能找到答案,认真读一读官方文档,比看垃圾博客强一百倍,要养成看官方文档的好习惯。\n6、宣扬内功无用论 # 有些人天天追求日新月异的开源项目和框架,却不肯花时间去啃一啃底层原理,常见问题虽然可以解决,但遇到稍微深一点的问题就束手无策。\n很多高大上的架构设计,思路其实都源于底层。想一想,像计算机体系结构、操作系统、网络协议这些东西,经过多少年演进才变为现在的样子,演进过程中遇到的复杂问题比比皆是,理解了解决这些问题的思路,再看上层技术会变得很简单。\n7、乐于炫技 # 有些人天天把「高大上」的技术名词挂在嘴边,生怕别人不知道自己学了什么高深技术,嘴上乐于炫技,但别人一问他细节就会哑口无言。\n8、不接受质疑 # 自己设计的方案,别人提出疑问时只会回怼,而不是理性分析利弊,抱着学习的心态交流。\n这些人学了点东西就觉得自己很有本事,殊不知只是自己见识太少。\n9、接口协议不规范 # 和别人定 API 协议全靠口头沟通,不给规范的文档说明,甚至到了测试联调时会发现,竟然和协商的还不一样,或者改协议了却不通知对接方,合作体验极差。\n10、遇到问题自己死磕 # 很初级程序员容易犯的问题,遇到问题只会自己死磕,拖到 deadline 也没有产出,领导来问才知道有问题解决不了。\n有问题及时反馈才是对自己负责,对团队负责。\n11、一说就会,一写就废 # 平时技术方案吹得天花乱坠,一让他写代码就废,典型的眼高手低选手。\n12、表达没有逻辑,不站在对方角度看问题 # 讨论问题不交代背景,上来就说自己的方案,别人听得云里雾里,让你从头描述你又讲不明白。\n学会沟通和表达,是合作的基础。\n13、不主动思考,伸手党 # 遇到问题不去 google,不做思考就向别人提问,喜欢做伸手党。\n每个人的时间都很宝贵,大家都更喜欢你带着自己的思考来提问,一来可以规避很多低级问题,二来可以提高交流质量。\n14、经常犯重复的错误 # 出问题后说下次会注意,但下次问题依旧,对自己不负责任,说到底是态度问题。\n15、加功能不考虑扩展性 # 加新功能只关注某一小块业务,不考虑系统整体的扩展性,堆代码行为严重。\n要学会分析需求和未来可能发生的变化,设计更通用的解决方案,降低后期开发成本。\n16、接口不自测,出问题不打日志 # 自己开发的接口不自测就和别人联调,出了问题又说没打日志,协作效率极低。\n17、提交代码不规范 # 很多人提交代码不写描述,或者写的是无意义的描述,尤其是修改很少代码时,这种情况会导致回溯问题成本变高。\n制定代码提交规范,能让你在每一次提交代码时,不会做太随意的代码修改。\n18、手动修改生产环境数据库 # 直连生产环境数据库修改数据,更有 UPDATE / DELETE SQL 忘写 WHERE 条件的情况,产生数据事故。\n修改生产环境数据库一定要谨慎再谨慎,建议操作前先找同事 review 代码再操作。\n19、没理清需求就直接写代码 # 很多程序员接到需求后,不怎么思考就开始写代码,需求和自己理解的有偏差,造成无意义返工。\n多花些时间梳理需求,能规避很多不合理的问题。\n20、重要设计不写文档 # 重要的设计没有文档输出,和别人交接系统时只做口头描述,丢失关键信息。\n有时候理解一个设计方案,一个好的文档要比看几百行代码更高效。\n总结 # 以上这些不良习惯,你命中几个呢?或者你身边有没有碰到这样的人?\n我认为提早规避这些问题,是成为一个优秀程序员必须要做的。这些习惯总结起来大致分为这 4 个方面:\n良好的编程修养 谦虚的学习心态 良好的沟通和表达 注重团队协作 优秀程序员的专业技能,我们可能很难在短时间内学会,但这些基本的职业素养,是可以在短期内做到的。\n希望你我可以有则改之,无则加勉。\n"},{"id":675,"href":"/zh/docs/technology/Interview/high-quality-technical-articles/interview/the-experience-of-get-offer-from-over-20-big-companies/","title":"斩获 20+ 大厂 offer 的面试经验分享","section":"Interview","content":" 推荐语:很实用的面试经验分享!\n原文地址: https://mp.weixin.qq.com/s/HXKg6-H0kGUU2OA1DS43Bw\n突然回想起当年,我也在秋招时也斩获了 20+的互联网各大厂 offer。现在想起来也是有点唏嘘,毕竟拿得再多也只能选择一家。不过许多朋友想让我分享下互联网面试方法,今天就来给大家仔细讲讲打法!\n如今金九银十已经过去,满是硝烟的求职战场上也只留下一处处炮灰。在现在这段日子,又是重新锻炼,时刻准备着明年金三银四的时候。\n对于还没毕业的学生来说,明年三四月是春招补招或者实习招聘的机会;对于职场老油条来说,明年三四月也是拿完年终奖准备提桶跑路的时候。\n所以这段日子,就需要好好准备积累面试方法以及面试经验,明年的冲锋陷阵打下基础。这篇文章将为大家讲讲,程序员应该如何准备好技术面试。\n一般而言,互联网公司技术岗的招聘都会根据需要设置为 3 ~ 4 轮面试,一些 HC 较少的岗位可能还会经历 5 ~ 8 轮面试不等。除此之外,视公司情况,面试之前还可能也会设定相应的笔试环节。\n多轮的面试中包括技术面和 HR 面。相对来说,在整体的招聘流程中,技术面的决定性比较重要,HR 面更多的是确认候选人的基本情况和职业素养。\n不过在某些大厂,HR 也具有一票否决权,所以每一轮面试都该好好准备和应对。技术面试一般可分为五个部分:\n双方自我介绍 项目经历 专业知识考查 编码能力考察 候选人 Q\u0026amp;A 双方自我介绍 # 面试往往是以自我介绍作为开场,很多时候一段条理清晰逻辑明确的开场会决定整场面试的氛围和节奏。\n作为候选人,我们可以在自我介绍中适当的为本次面试提供指向性的信息,以辅助面试官去发掘自己身上的亮点和长处。\n其实自我介绍并不是简单的个人基本情况的条条过目,而是对自己简历的有效性概括。\n什么是有效性概括呢,就是意味着需要对简历中的信息进行核心关键词的提取整合。一段话下来,就能够让面试官对你整体的情况有了了解,从而能够引导面试官的联系提问。\n项目经历 # 项目经历是面试过程中非常重要的一环,特别是在社招的面试中。一般社招的职级越高,往往越看重项目经历。\n而对于一般的校招生而言,几份岗位度匹配度以及项目完整性高的项目经历可以成为面试的亮点,也是决定于拿SP or SSP的关键。\n但是准备好项目经历,并不是一件容易的事情。很多人并不清楚应该怎样去描述自己的项目,更不知道应该在经历中如何去体现自己的优势和亮点。\n这里针对项目经历给大家提几点建议:\n1、高效有条理的描述\n项目经历的一般是简历里篇幅最大的部分,所以在面试时这部分同样重要。在表述时,语言的逻辑和条理一定要清晰,以保证面试官能够在最快的时间抓到你的项目的整体思路。\n相信很多人都听说过写简历的各种原则,比如STAR、SMART等。但实际上这些原则都可以用来规范自己的表达逻辑。\nSTAR原则相对简单,用来在面试过程中规范自己的条理非常有效。所谓STAR,即Situation、Target、Action、Result。这跟写论文写文档的逻辑划分大体一致。\nSituation: 即项目背景,需要将项目提出的原因、现状以及出发点表述清楚。简单来说,就是要将项目提出的来龙去脉描述清晰。比如某某平台建设的原因,是切入用户怎样的痛点之类的。 Target: 即项目目标,这点描述的是项目预期达到或完成的程度。==最好是有可量化的指标和预期结果。==比如性能优化的指标、架构优化所带来的业务收益等等。 Action: 即方法方案,意味着完成项目具体实施的行为。这点在技术面试中最为重要,也是表现候选人能力的基础。==项目的方法或方案可以从技术栈出发,根据采用的不同技术点来具体写明解决了哪些问题。==比如用了什么框架/技术实现了什么架构/优化/设计,解决了项目中什么样的问题。 Result: 即项目获得结果,这点可以在面试中讲讲自己经历过项目后的思考和反思。这样会让面试官感受到你的成长和沉淀,会比直接的结果并动人。 2、充分准备项目亮点\n说实话,大部分人其实都没有十分亮眼的项目,但是并不意味着没有项目经历的亮点。特别是在面试中。\n在面试中,你可以通过充分的准备以及深入的思考来突出你的项目亮点。比如可以从以下几个方向入手:\n充分了解项目的业务逻辑和技术架构 熟悉项目的整体架构和关键设计 明确的知道业务架构或技术方案选型以及决策逻辑 深入掌握项目中涉及的组件以及框架 熟悉项目中的疑难杂症或长期遗留 bug 的解决方案 …… 专业知识考查 # 有经验的面试官往往会在对项目经历刨根问底的同时,从中考察你的专业知识。\n所谓专业知识,对于程序员而言就是意向岗位的计算机知识图谱。对于校招生来说,大部分都是计算机基础;而对于社招而言,很大部分可能是对应岗位的技能树。\n计算机基础主要就是计算机网络、操作系统、编程语言之类的,也就是所谓的八股文。虽然这些东西在实际的工作中可能用处并不多,但是却是面试官评估候选人潜力的标准。\n而对应岗位的技能树就需要根据具体的岗位来划分,比如说客户端岗位可能会问移动操作系统理解、端性能优化、客户端架构以及跨端框架之类的。跟直播视频相关的岗位,还会问音视频处理、通信等相关的知识。\n而后端岗位可能就更偏向于高可用架构、事务理论、分布式中间件以及一些服务化、异步、高可用可扩展的架构设计思想。\n总而言之,工作经验越丰富,岗位技术能的问题也就越深入。\n怎么在面试前去准备这些技术点,在这里我就不过多说了, 因为很多学习路线以及说的很清楚了。\n这里我就讲讲在应对面试的时候,该怎样去更好的表达描述清楚。\n这里针对专业知识考察给大家提几点建议:\n1、提前建立一份技术知识图谱\n在面试之前,可以先将自己比较熟悉的知识点做一个简单的归纳总结,根据不同方向和领域画个简单的草图。这是为了辅助自己在面试时能够进行合理的扩展和延伸。\n面试官一问一答形式的面试总是会给人不太好的面试体验,所以在回答技术要点的过程中,要善于利用自己已有的知识图谱来进行技术广度的扩展和技术深度的钻研。这样一来能够引导面试官往你擅长的方向去提问,二来能够尽可能多的展现自己的亮点。\n2、结合具体经验来总结理解\n技术点本身都是非常死板和冰冷的,但是如果能够将生硬的技术点与具体的案例结合起来描述,会让人眼前一亮。同时也能够表明自己是的的确确理解了该知识点。\n现在网上各种面试素材应有尽有,可能你背背题就能够应付面试官的提问。但是面试官也同样知道这点,所以他能够很清楚的判别出你是否在背题。\n因此,结合具体的经验来解释表达问题是能够防止被误认为背题的有效方法。可能有人会问了,那具体的经验哪里去找呢。\n这就得靠平时的积累了,平时需要多积累沉淀,多看大厂的各类技术输出。经验不一定是自己的,也可以是从别的地方总结而来的。\n此外,也可以结合自己在做项目的过程中的一些技术选型经验以及技术方案更新迭代的过程进行融会贯通,相互结合的来进行表述。\n编码能力考察 # 编码能力考察就是咱们俗称的手撕代码,也是许多同学最害怕的一关。很多人会觉得面试结果就是看手撕代码的表现,但其实并不一定。\n==首先得明确的一点是,编码能力不完全等于算法能力。==很多同学面试时候算法题明明写出来了,但是最终的面试评价却是编码能力一般。还有很多同学面试时算法题死活没通过,但是面试官却觉得他的编码能力还可以。\n所以一定要注意区分这点,编码能力不完全等于算法能力。从公司出发,如果纯粹为了出难度高的算法题来筛选候选人,是没有意义的。因为大家都知道,进了公司可能工作几年都写不了几个算法。\n要记住,做算法题只是一个用来验证编码能力和逻辑思维的手段和方式。\n当然说到底,在准备这一块的面试时,算法题肯定得刷,但是不该盲目追求难度,甚至是死记硬背。\n几点面试时的建议:\n1、数据结构和算法思想是基础\n算法本身实际上是逻辑思考的产物,所以掌握算法思想比会做某一道题要更有意义。数据结构是帮助实现算法的工具,这也很编程的基本能力。所以这二者的熟悉程度是手撕代码的基础。\n2、不要忽视编码规范\n这点就是提醒大家要记住,就算是一段很简单的算法题也能够从中看出你的编码能力。这往往就体现在一些基本的编码规范上。你说你编程经验有 3 年,但是发现连基本的函数封装类型保护都不会,让人怎么相信呢。\n3、沟通很重要\n手撕代码绝对不是一个闭卷考试的过程,而是一个相互沟通的过程。上面也说过,考察算法也是为了考察逻辑思维能力。所以让面试官知道你思考问题的思路以及逻辑比你直接写出答案更重要。\n不仅如此,提前沟通清楚思路,遇到题意不明确的地方及时询问,也是节省大家时间,给面试官留下好印象的机会。\n此外,自己写的代码一定要经得住推敲和质疑,自己能够讲的明白。这也是能够区分「背题」和「真正会做」的地方。\n最后,如果代码实在写不出来,但是也可以适当的表达自己的思路并与面试官交流探讨。毕竟面试也是一个学习的过程。\n候选人 Q\u0026amp;A # 一般正常的话,都会有候选人反问环节。倘若没有,可能是想让你回家等消息。\n反问环节其实也可以是面试中重要的环节,因为这个时候你能够从面试官口中获得关于公司关于岗位更具体真实的信息。\n这些信息可以帮助我们做出更全面更理性的决策,毕竟求职也是一个双向选择的过程。\n加分项 # 最后,给能够坚持看到最后的同学一个福利。我们来谈谈面试中的加分项。\n很多同学会觉得明明面试时候的问题都答上来了,但是最终却没有通过面试,或者面试评价并不高。这很有可能就是面试过程中缺少了亮点,可能你并不差,但是没有打动面试官的地方。\n一般面试官会从下面几个方面去考察候选人的亮点:\n1、沟通\n面试毕竟是问答与表达的艺术,所以你流利的表达,清晰有条理的思路自然能够增加面试官对你的高感度。同时如果还具有举一反三的思维,那也能够从侧面证明你的潜力。\n2、匹配度\n这一点毋庸置疑,但是却很容易被忽视。因为往往大家都会认为,匹配度不高的都在简历筛选阶段被刷掉了。但其实在面试过程中,面试官同样也会评估面试人与岗位的匹配度。\n这个匹配度与工作经历强相关,与之前做过的业务和技术联系很大。特别是某些垂直领域的技术岗位,比如财经、资金、音视频等。\n所以在面试中,如若有跟目标岗位匹配度很高的经历和项目,可以着重详细介绍。\n3、高业绩,有超出岗位的思考\n这点就是可遇不可及,毕竟不是所有人都能够拿着好业绩然后跳槽。但是上一份工作所带来的好业绩,以及在重要项目中的骨干身份会为自己的经历加分。\n同时,如果能在面试中表现出超出岗位本身的能力,更能引起面试官注意。比如具备一定的技术视野,具备良好的规划能力,或者对业务方向有比较深入的见解。这些都能够成为亮点。\n4、技术深度或广度\n相信很多人都听过,职场中最受欢迎的是T型人才。也就是在拥有一定技术广度的基础上,在自己擅长的领域十分拔尖。这样的人才的确很难得,既要求能够胜任自己的在职工作,又能够不设边界的学习和输出其它领域的知识。\n除此之外,比 T 型人才更为难得是所谓 π 型人才,相比于 T 型人才,有了不止一项拔尖的领域。这类人才更是公司会抢占的资源。\n总结 # 面试虽说是考察和筛选优秀人才的过程,但说到底还是人与人沟通并展现自我的方式。所以掌握有效面试的技巧也是帮助自己收获更多的工具。\n这篇文章其实算讲的是方法论,很多我们一看就明白的「道理」实施起来可能会很难。可能会遇到一个不按常理出牌的面试官,也可能也会遇到一个沟通困难的面试官,当然也可能会撞上一个不怎么匹配的岗位。\n总而言之,为了自己想要争取的东西,做好足够的准备总是没有坏处的。祝愿大家能成为π型人才,获得想要的offer!\n"},{"id":676,"href":"/zh/docs/technology/Interview/database/character-set/","title":"字符集详解","section":"Database","content":"MySQL 字符编码集中有两套 UTF-8 编码实现:utf8 和 utf8mb4。\n如果使用 utf8 的话,存储 emoji 符号和一些比较复杂的汉字、繁体字就会出错。\n为什么会这样呢?这篇文章可以从源头给你解答。\n字符集是什么? # 字符是各种文字和符号的统称,包括各个国家文字、标点符号、表情、数字等等。 字符集 就是一系列字符的集合。字符集的种类较多,每个字符集可以表示的字符范围通常不同,就比如说有些字符集是无法表示汉字的。\n计算机只能存储二进制的数据,那英文、汉字、表情等字符应该如何存储呢?\n我们要将这些字符和二进制的数据一一对应起来,比如说字符“a”对应“01100001”,反之,“01100001”对应 “a”。我们将字符对应二进制数据的过程称为\u0026quot;字符编码\u0026quot;,反之,二进制数据解析成字符的过程称为“字符解码”。\n字符编码是什么? # 字符编码是一种将字符集中的字符与计算机中的二进制数据相互转换的方法,可以看作是一种映射规则。也就是说,字符编码的目的是为了让计算机能够存储和传输各种文字信息。\n每种字符集都有自己的字符编码规则,常用的字符集编码规则有 ASCII 编码、 GB2312 编码、GBK 编码、GB18030 编码、Big5 编码、UTF-8 编码、UTF-16 编码等。\n有哪些常见的字符集? # 常见的字符集有:ASCII、GB2312、GB18030、GBK、Unicode……。\n不同的字符集的主要区别在于:\n可以表示的字符范围 编码方式 ASCII # ASCII (American Standard Code for Information Interchange,美国信息交换标准代码) 是一套主要用于现代美国英语的字符集(这也是 ASCII 字符集的局限性所在)。\n为什么 ASCII 字符集没有考虑到中文等其他字符呢? 因为计算机是美国人发明的,当时,计算机的发展还处于比较雏形的时代,还未在其他国家大规模使用。因此,美国发布 ASCII 字符集的时候没有考虑兼容其他国家的语言。\nASCII 字符集至今为止共定义了 128 个字符,其中有 33 个控制字符(比如回车、删除)无法显示。\n一个 ASCII 码长度是一个字节也就是 8 个 bit,比如“a”对应的 ASCII 码是“01100001”。不过,最高位是 0 仅仅作为校验位,其余 7 位使用 0 和 1 进行组合,所以,ASCII 字符集可以定义 128(2^7)个字符。\n由于,ASCII 码可以表示的字符实在是太少了。后来,人们对其进行了扩展得到了 ASCII 扩展字符集 。ASCII 扩展字符集使用 8 位(bits)表示一个字符,所以,ASCII 扩展字符集可以定义 256(2^8)个字符。\nGB2312 # 我们上面说了,ASCII 字符集是一种现代美国英语适用的字符集。因此,很多国家都捣鼓了一个适合自己国家语言的字符集。\nGB2312 字符集是一种对汉字比较友好的字符集,共收录 6700 多个汉字,基本涵盖了绝大部分常用汉字。不过,GB2312 字符集不支持绝大部分的生僻字和繁体字。\n对于英语字符,GB2312 编码和 ASCII 码是相同的,1 字节编码即可。对于非英字符,需要 2 字节编码。\nGBK # GBK 字符集可以看作是 GB2312 字符集的扩展,兼容 GB2312 字符集,共收录了 20000 多个汉字。\nGBK 中 K 是汉语拼音 Kuo Zhan(扩展)中的“Kuo”的首字母。\nGB18030 # GB18030 完全兼容 GB2312 和 GBK 字符集,纳入中国国内少数民族的文字,且收录了日韩汉字,是目前为止最全面的汉字字符集,共收录汉字 70000 多个。\nBIG5 # BIG5 主要针对的是繁体中文,收录了 13000 多个汉字。\nUnicode \u0026amp; UTF-8 # 为了更加适合本国语言,诞生了很多种字符集。\n我们上面也说了不同的字符集可以表示的字符范围以及编码规则存在差异。这就导致了一个非常严重的问题:使用错误的编码方式查看一个包含字符的文件就会产生乱码现象。\n就比如说你使用 UTF-8 编码方式打开 GB2312 编码格式的文件就会出现乱码。示例:“牛”这个汉字 GB2312 编码后的十六进制数值为 “C5A3”,而 “C5A3” 用 UTF-8 解码之后得到的却是 “ţ”。\n你可以通过这个网站在线进行编码和解码: https://www.haomeili.net/HanZi/ZiFuBianMaZhuanHuan\n这样我们就搞懂了乱码的本质:编码和解码时用了不同或者不兼容的字符集 。\n为了解决这个问题,人们就想:“如果我们能够有一种字符集将世界上所有的字符都纳入其中就好了!”。\n然后,Unicode 带着这个使命诞生了。\nUnicode 字符集中包含了世界上几乎所有已知的字符。不过,Unicode 字符集并没有规定如何存储这些字符(也就是如何使用二进制数据表示这些字符)。\n然后,就有了 UTF-8(8-bit Unicode Transformation Format)。类似的还有 UTF-16、 UTF-32。\nUTF-8 使用 1 到 4 个字节为每个字符编码, UTF-16 使用 2 或 4 个字节为每个字符编码,UTF-32 固定位 4 个字节为每个字符编码。\nUTF-8 可以根据不同的符号自动选择编码的长短,像英文字符只需要 1 个字节就够了,这一点 ASCII 字符集一样 。因此,对于英语字符,UTF-8 编码和 ASCII 码是相同的。\nUTF-32 的规则最简单,不过缺陷也比较明显,对于英文字母这类字符消耗的空间是 UTF-8 的 4 倍之多。\nUTF-8 是目前使用最广的一种字符编码。\nMySQL 字符集 # MySQL 支持很多种字符集的方式,比如 GB2312、GBK、BIG5、多种 Unicode 字符集(UTF-8 编码、UTF-16 编码、UCS-2 编码、UTF-32 编码等等)。\n查看支持的字符集 # 你可以通过 SHOW CHARSET 命令来查看,支持 like 和 where 子句。\n默认字符集 # 在 MySQL5.7 中,默认字符集是 latin1 ;在 MySQL8.0 中,默认字符集是 utf8mb4\n字符集的层次级别 # MySQL 中的字符集有以下的层次级别:\nserver(MySQL 实例级别) database(库级别) table(表级别) column(字段级别) 它们的优先级可以简单的认为是从上往下依次增大,也即 column 的优先级会大于 table 等其余层次的。如指定 MySQL 实例级别字符集是utf8mb4,指定某个表字符集是latin1,那么这个表的所有字段如果不指定的话,编码就是latin1。\nserver # 不同版本的 MySQL 其 server 级别的字符集默认值不同,在 MySQL5.7 中,其默认值是 latin1 ;在 MySQL8.0 中,其默认值是 utf8mb4 。\n当然也可以通过在启动 mysqld 时指定 --character-set-server 来设置 server 级别的字符集。\nmysqld mysqld --character-set-server=utf8mb4 mysqld --character-set-server=utf8mb4 \\ --collation-server=utf8mb4_0900_ai_ci 或者如果你是通过源码构建的方式启动的 MySQL,你可以在 cmake 命令中指定选项:\ncmake . -DDEFAULT_CHARSET=latin1 或者 cmake . -DDEFAULT_CHARSET=latin1 \\ -DDEFAULT_COLLATION=latin1_german1_ci 此外,你也可以在运行时改变 character_set_server 的值,从而达到修改 server 级别的字符集的目的。\nserver 级别的字符集是 MySQL 服务器的全局设置,它不仅会作为创建或修改数据库时的默认字符集(如果没有指定其他字符集),还会影响到客户端和服务器之间的连接字符集,具体可以查看 MySQL Connector/J 8.0 - 6.7 Using Character Sets and Unicode。\ndatabase # database 级别的字符集是我们在创建数据库和修改数据库时指定的:\nCREATE DATABASE db_name [[DEFAULT] CHARACTER SET charset_name] [[DEFAULT] COLLATE collation_name] ALTER DATABASE db_name [[DEFAULT] CHARACTER SET charset_name] [[DEFAULT] COLLATE collation_name] 如前面所说,如果在执行上述语句时未指定字符集,那么 MySQL 将会使用 server 级别的字符集。\n可以通过下面的方式查看某个数据库的字符集:\nUSE db_name; SELECT @@character_set_database, @@collation_database; SELECT DEFAULT_CHARACTER_SET_NAME, DEFAULT_COLLATION_NAME FROM INFORMATION_SCHEMA.SCHEMATA WHERE SCHEMA_NAME = \u0026#39;db_name\u0026#39;; table # table 级别的字符集是在创建表和修改表时指定的:\nCREATE TABLE tbl_name (column_list) [[DEFAULT] CHARACTER SET charset_name] [COLLATE collation_name]] ALTER TABLE tbl_name [[DEFAULT] CHARACTER SET charset_name] [COLLATE collation_name] 如果在创建表和修改表时未指定字符集,那么将会使用 database 级别的字符集。\ncolumn # column 级别的字符集同样是在创建表和修改表时指定的,只不过它是定义在列中。下面是个例子:\nCREATE TABLE t1 ( col1 VARCHAR(5) CHARACTER SET latin1 COLLATE latin1_german1_ci ); 如果未指定列级别的字符集,那么将会使用表级别的字符集。\n连接字符集 # 前面说到了字符集的层次级别,它们是和存储相关的。而连接字符集涉及的是和 MySQL 服务器的通信。\n连接字符集与下面这几个变量息息相关:\ncharacter_set_client :描述了客户端发送给服务器的 SQL 语句使用的是什么字符集。 character_set_connection :描述了服务器接收到 SQL 语句时使用什么字符集进行翻译。 character_set_results :描述了服务器返回给客户端的结果使用的是什么字符集。 它们的值可以通过下面的 SQL 语句查询:\nSELECT * FROM performance_schema.session_variables WHERE VARIABLE_NAME IN ( \u0026#39;character_set_client\u0026#39;, \u0026#39;character_set_connection\u0026#39;, \u0026#39;character_set_results\u0026#39;, \u0026#39;collation_connection\u0026#39; ) ORDER BY VARIABLE_NAME; SHOW SESSION VARIABLES LIKE \u0026#39;character\\_set\\_%\u0026#39;; 如果要想修改前面提到的几个变量的值,有以下方式:\n1、修改配置文件\n[mysql] # 只针对MySQL客户端程序 default-character-set=utf8mb4 2、使用 SQL 语句\nset names utf8mb4 # 或者一个个进行修改 # SET character_set_client = utf8mb4; # SET character_set_results = utf8mb4; # SET collation_connection = utf8mb4; JDBC 对连接字符集的影响 # 不知道你们有没有碰到过存储 emoji 表情正常,但是使用类似 Navicat 之类的软件的进行查询的时候,发现 emoji 表情变成了问号的情况。这个问题很有可能就是 JDBC 驱动引起的。\n根据前面的内容,我们知道连接字符集也是会影响我们存储的数据的,而 JDBC 驱动会影响连接字符集。\nmysql-connector-java (JDBC 驱动)主要通过这几个属性影响连接字符集:\ncharacterEncoding characterSetResults 以 DataGrip 2023.1.2 来说,在它配置数据源的高级对话框中,可以看到 characterSetResults 的默认值是 utf8 ,在使用 mysql-connector-java 8.0.25 时,连接字符集最后会被设置成 utf8mb3 。那么这种情况下 emoji 表情就会被显示为问号,并且当前版本驱动还不支持把 characterSetResults 设置为 utf8mb4 ,不过换成 mysql-connector-java driver 8.0.29 却是允许的。\n具体可以看一下 StackOverflow 的 DataGrip MySQL stores emojis correctly but displays them as?这个回答。\nUTF-8 使用 # 通常情况下,我们建议使用 UTF-8 作为默认的字符编码方式。\n不过,这里有一个小坑。\nMySQL 字符编码集中有两套 UTF-8 编码实现:\nutf8:utf8编码只支持1-3个字节 。 在 utf8 编码中,中文是占 3 个字节,其他数字、英文、符号占一个字节。但 emoji 符号占 4 个字节,一些较复杂的文字、繁体字也是 4 个字节。 utf8mb4:UTF-8 的完整实现,正版!最多支持使用 4 个字节表示字符,因此,可以用来存储 emoji 符号。 为什么有两套 UTF-8 编码实现呢? 原因如下:\n因此,如果你需要存储emoji类型的数据或者一些比较复杂的文字、繁体字到 MySQL 数据库的话,数据库的编码一定要指定为utf8mb4 而不是utf8 ,要不然存储的时候就会报错了。\n演示一下吧!(环境:MySQL 5.7+)\n建表语句如下,我们指定数据库 CHARSET 为 utf8 。\nCREATE TABLE `user` ( `id` varchar(66) CHARACTER SET utf8mb3 NOT NULL, `name` varchar(33) CHARACTER SET utf8mb3 NOT NULL, `phone` varchar(33) CHARACTER SET utf8mb3 DEFAULT NULL, `password` varchar(100) CHARACTER SET utf8mb3 DEFAULT NULL ) ENGINE=InnoDB DEFAULT CHARSET=utf8; 当我们执行下面的 insert 语句插入数据到数据库时,果然报错!\nINSERT INTO `user` (`id`, `name`, `phone`, `password`) VALUES (\u0026#39;A00003\u0026#39;, \u0026#39;guide哥😘😘😘\u0026#39;, \u0026#39;181631312312\u0026#39;, \u0026#39;123456\u0026#39;); 报错信息如下:\nIncorrect string value: \u0026#39;\\xF0\\x9F\\x98\\x98\\xF0\\x9F...\u0026#39; for column \u0026#39;name\u0026#39; at row 1 参考 # 字符集和字符编码(Charset \u0026amp; Encoding): https://www.cnblogs.com/skynet/archive/2011/05/03/2035105.html 十分钟搞清字符集和字符编码: http://cenalulu.github.io/linux/character-encoding/ Unicode-维基百科: https://zh.wikipedia.org/wiki/Unicode GB2312-维基百科: https://zh.wikipedia.org/wiki/GB_2312 UTF-8-维基百科: https://zh.wikipedia.org/wiki/UTF-8 GB18030-维基百科: https://zh.wikipedia.org/wiki/GB_18030 MySQL8 文档: https://dev.mysql.com/doc/refman/8.0/en/charset.html MySQL5.7 文档: https://dev.mysql.com/doc/refman/5.7/en/charset.html MySQL Connector/J 文档: https://dev.mysql.com/doc/connector-j/8.0/en/connector-j-reference-charsets.html "},{"id":677,"href":"/zh/docs/technology/Interview/java/jvm/jvm-parameters-intro/","title":"最重要的JVM参数总结","section":"Jvm","content":" 本文由 JavaGuide 翻译自 https://www.baeldung.com/jvm-parameters,并对文章进行了大量的完善补充。\nJDK 版本:1.8\n1.概述 # 在本篇文章中,你将掌握最常用的 JVM 参数配置。\n2.堆内存相关 # Java 虚拟机所管理的内存中最大的一块,Java 堆是所有线程共享的一块内存区域,在虚拟机启动时创建。此内存区域的唯一目的就是存放对象实例,几乎所有的对象实例以及数组都在这里分配内存。\n2.1.显式指定堆内存–Xms和-Xmx # 与性能有关的最常见实践之一是根据应用程序要求初始化堆内存。如果我们需要指定最小和最大堆大小(推荐显示指定大小),以下参数可以帮助你实现:\n-Xms\u0026lt;heap size\u0026gt;[unit] -Xmx\u0026lt;heap size\u0026gt;[unit] heap size 表示要初始化内存的具体大小。 unit 表示要初始化内存的单位。单位为 “ g” (GB)、“ m”(MB)、“ k”(KB)。 举个栗子 🌰,如果我们要为 JVM 分配最小 2 GB 和最大 5 GB 的堆内存大小,我们的参数应该这样来写:\n-Xms2G -Xmx5G 2.2.显式新生代内存(Young Generation) # 根据 Oracle 官方文档,在堆总可用内存配置完成之后,第二大影响因素是为 Young Generation 在堆内存所占的比例。默认情况下,YG 的最小大小为 1310 MB,最大大小为 无限制。\n一共有两种指定 新生代内存(Young Generation)大小的方法:\n1.通过-XX:NewSize和-XX:MaxNewSize指定\n-XX:NewSize=\u0026lt;young size\u0026gt;[unit] -XX:MaxNewSize=\u0026lt;young size\u0026gt;[unit] 举个栗子 🌰,如果我们要为 新生代分配 最小 256m 的内存,最大 1024m 的内存我们的参数应该这样来写:\n-XX:NewSize=256m -XX:MaxNewSize=1024m 2.通过-Xmn\u0026lt;young size\u0026gt;[unit]指定\n举个栗子 🌰,如果我们要为 新生代分配 256m 的内存(NewSize 与 MaxNewSize 设为一致),我们的参数应该这样来写:\n-Xmn256m GC 调优策略中很重要的一条经验总结是这样说的:\n将新对象预留在新生代,由于 Full GC 的成本远高于 Minor GC,因此尽可能将对象分配在新生代是明智的做法,实际项目中根据 GC 日志分析新生代空间大小分配是否合理,适当通过“-Xmn”命令调节新生代大小,最大限度降低新对象直接进入老年代的情况。\n另外,你还可以通过 -XX:NewRatio=\u0026lt;int\u0026gt; 来设置老年代与新生代内存的比值。\n比如下面的参数就是设置老年代与新生代内存的比值为 1。也就是说老年代和新生代所占比值为 1:1,新生代占整个堆栈的 1/2。\n-XX:NewRatio=1 2.3.显式指定永久代/元空间的大小 # 从 Java 8 开始,如果我们没有指定 Metaspace 的大小,随着更多类的创建,虚拟机会耗尽所有可用的系统内存(永久代并不会出现这种情况)。\nJDK 1.8 之前永久代还没被彻底移除的时候通常通过下面这些参数来调节方法区大小\n-XX:PermSize=N #方法区 (永久代) 初始大小 -XX:MaxPermSize=N #方法区 (永久代) 最大大小,超过这个值将会抛出 OutOfMemoryError 异常:java.lang.OutOfMemoryError: PermGen 相对而言,垃圾收集行为在这个区域是比较少出现的,但并非数据进入方法区后就“永久存在”了。\nJDK 1.8 的时候,方法区(HotSpot 的永久代)被彻底移除了(JDK1.7 就已经开始了),取而代之是元空间,元空间使用的是本地内存。\n下面是一些常用参数:\n-XX:MetaspaceSize=N #设置 Metaspace 的初始大小(是一个常见的误区,后面会解释) -XX:MaxMetaspaceSize=N #设置 Metaspace 的最大大小 🐛 修正(参见: issue#1947):\n1、Metaspace 的初始容量并不是 -XX:MetaspaceSize 设置,无论 -XX:MetaspaceSize 配置什么值,对于 64 位 JVM 来说,Metaspace 的初始容量都是 21807104(约 20.8m)。\n可以参考 Oracle 官方文档 Other Considerations 中提到的:\nSpecify a higher value for the option MetaspaceSize to avoid early garbage collections induced for class metadata. The amount of class metadata allocated for an application is application-dependent and general guidelines do not exist for the selection of MetaspaceSize. The default size of MetaspaceSize is platform-dependent and ranges from 12 MB to about 20 MB.\nMetaspaceSize 的默认大小取决于平台,范围从 12 MB 到大约 20 MB。\n另外,还可以看一下这个试验: JVM 参数 MetaspaceSize 的误解。\n2、Metaspace 由于使用不断扩容到-XX:MetaspaceSize参数指定的量,就会发生 FGC,且之后每次 Metaspace 扩容都会发生 Full GC。\n也就是说,MetaspaceSize 表示 Metaspace 使用过程中触发 Full GC 的阈值,只对触发起作用。\n垃圾搜集器内部是根据变量 _capacity_until_GC来判断 Metaspace 区域是否达到阈值的,初始化代码如下所示:\nvoid MetaspaceGC::initialize() { // Set the high-water mark to MaxMetapaceSize during VM initialization since // we can\u0026#39;t do a GC during initialization. _capacity_until_GC = MaxMetaspaceSize; } 相关阅读: issue 更正:MaxMetaspaceSize 如果不指定大小的话,不会耗尽内存 #1204 。\n3.垃圾收集相关 # 3.1.垃圾回收器 # 为了提高应用程序的稳定性,选择正确的 垃圾收集算法至关重要。\nJVM 具有四种类型的 GC 实现:\n串行垃圾收集器 并行垃圾收集器 CMS 垃圾收集器 G1 垃圾收集器 可以使用以下参数声明这些实现:\n-XX:+UseSerialGC -XX:+UseParallelGC -XX:+UseConcMarkSweepGC -XX:+UseG1GC 有关 垃圾回收 实施的更多详细信息,请参见 此处。\n3.2.GC 日志记录 # 生产环境上,或者其他要测试 GC 问题的环境上,一定会配置上打印 GC 日志的参数,便于分析 GC 相关的问题。\n# 必选 # 打印基本 GC 信息 -XX:+PrintGCDetails -XX:+PrintGCDateStamps # 打印对象分布 -XX:+PrintTenuringDistribution # 打印堆数据 -XX:+PrintHeapAtGC # 打印Reference处理信息 # 强引用/弱引用/软引用/虚引用/finalize 相关的方法 -XX:+PrintReferenceGC # 打印STW时间 -XX:+PrintGCApplicationStoppedTime # 可选 # 打印safepoint信息,进入 STW 阶段之前,需要要找到一个合适的 safepoint -XX:+PrintSafepointStatistics -XX:PrintSafepointStatisticsCount=1 # GC日志输出的文件路径 -Xloggc:/path/to/gc-%t.log # 开启日志文件分割 -XX:+UseGCLogFileRotation # 最多分割几个文件,超过之后从头文件开始写 -XX:NumberOfGCLogFiles=14 # 每个文件上限大小,超过就触发分割 -XX:GCLogFileSize=50M 4.处理 OOM # 对于大型应用程序来说,面对内存不足错误是非常常见的,这反过来会导致应用程序崩溃。这是一个非常关键的场景,很难通过复制来解决这个问题。\n这就是为什么 JVM 提供了一些参数,这些参数将堆内存转储到一个物理文件中,以后可以用来查找泄漏:\n-XX:+HeapDumpOnOutOfMemoryError -XX:HeapDumpPath=./java_pid\u0026lt;pid\u0026gt;.hprof -XX:OnOutOfMemoryError=\u0026#34;\u0026lt; cmd args \u0026gt;;\u0026lt; cmd args \u0026gt;\u0026#34; -XX:+UseGCOverheadLimit 这里有几点需要注意:\nHeapDumpOnOutOfMemoryError 指示 JVM 在遇到 OutOfMemoryError 错误时将 heap 转储到物理文件中。 HeapDumpPath 表示要写入文件的路径; 可以给出任何文件名; 但是,如果 JVM 在名称中找到一个 \u0026lt;pid\u0026gt; 标记,则当前进程的进程 id 将附加到文件名中,并使用.hprof格式 OnOutOfMemoryError 用于发出紧急命令,以便在内存不足的情况下执行; 应该在 cmd args 空间中使用适当的命令。例如,如果我们想在内存不足时重启服务器,我们可以设置参数: -XX:OnOutOfMemoryError=\u0026quot;shutdown -r\u0026quot; 。 UseGCOverheadLimit 是一种策略,它限制在抛出 OutOfMemory 错误之前在 GC 中花费的 VM 时间的比例 5.其他 # -server : 启用“ Server Hotspot VM”; 此参数默认用于 64 位 JVM -XX:+UseStringDeduplication : Java 8u20 引入了这个 JVM 参数,通过创建太多相同 String 的实例来减少不必要的内存使用; 这通过将重复 String 值减少为单个全局 char [] 数组来优化堆内存。 -XX:+UseLWPSynchronization: 设置基于 LWP (轻量级进程)的同步策略,而不是基于线程的同步。 -XX:LargePageSizeInBytes: 设置用于 Java 堆的较大页面大小; 它采用 GB/MB/KB 的参数; 页面大小越大,我们可以更好地利用虚拟内存硬件资源; 然而,这可能会导致 PermGen 的空间大小更大,这反过来又会迫使 Java 堆空间的大小减小。 -XX:MaxHeapFreeRatio : 设置 GC 后, 堆空闲的最大百分比,以避免收缩。 -XX:SurvivorRatio : eden/survivor 空间的比例, 例如-XX:SurvivorRatio=6 设置每个 survivor 和 eden 之间的比例为 1:6。 -XX:+UseLargePages : 如果系统支持,则使用大页面内存; 请注意,如果使用这个 JVM 参数,OpenJDK 7 可能会崩溃。 -XX:+UseStringCache : 启用 String 池中可用的常用分配字符串的缓存。 -XX:+UseCompressedStrings : 对 String 对象使用 byte [] 类型,该类型可以用纯 ASCII 格式表示。 -XX:+OptimizeStringConcat : 它尽可能优化字符串串联操作。 文章推荐 # 这里推荐了非常多优质的 JVM 实践相关的文章,推荐阅读,尤其是 JVM 性能优化和问题排查相关的文章。\nJVM 参数配置说明 - 阿里云官方文档 - 2022 JVM 内存配置最佳实践 - 阿里云官方文档 - 2022 求你了,GC 日志打印别再瞎配置了 - 思否 - 2022 一次大量 JVM Native 内存泄露的排查分析(64M 问题) - 掘金 - 2022 一次线上 JVM 调优实践,FullGC40 次/天到 10 天一次的优化过程 - HeapDump - 2021 听说 JVM 性能优化很难?今天我小试了一把! - 陈树义 - 2021 你们要的线上 GC 问题案例来啦 - 编了个程 - 2021 Java 中 9 种常见的 CMS GC 问题分析与解决 - 美团技术团队 - 2020 从实际案例聊聊 Java 应用的 GC 优化-美团技术团队 - 美团技术团队 - 2017 "}] \ No newline at end of file +[{"id":0,"href":"/zh/docs/technology/","title":"技术","section":"Docs","content":"这里面都是放一些平常技术知识的学习,知识来源主要来自经典书籍,或是其他通俗易懂的系列视频。\n"},{"id":1,"href":"/zh/docs/problem/","title":"问题解决","section":"Docs","content":"主要是一些平常遇到的一些问题,或是经过一顿折腾后解决的,或是临时遇到的小问题。\n"},{"id":2,"href":"/zh/docs/life/","title":"生活","section":"Docs","content":"生活相关的一些随感而发\n"},{"id":3,"href":"/zh/docs/technology/Review/java_guide/","title":"JavaGuide","section":"面试","content":"基本全部转载自https://github.com/Snailclimb/JavaGuide(添加小部分笔记)感谢作者! 不过不是整篇拷贝,也是一句句理解后,一个字一个字打下来的。\n"},{"id":4,"href":"/zh/docs/technology/Review/","title":"面试","section":"技术","content":"面试用的,面经。但是从另一个角度也可以说是梳理知识框架,爱恨交加。\n"},{"id":5,"href":"/zh/docs/technology/Interview/","title":"Interview","section":"技术","content":" 必看 # 项目介绍 使用建议 贡献指南 常见问题\n面试准备 # 手把手教你如何准备Java面试(重要) 程序员简历编写指南(重要) Java面试重点总结(重要) 项目经验指南 优质面经汇总(付费) 常见面试题自测(付费)\nJava # Java基础常见面试题总结(上) Java基础常见面试题总结(中) Java基础常见面试题总结(下)\n重要知识点 # Java 值传递详解 Java 序列化详解 泛型\u0026amp;通配符详解\nJava 反射机制详解\nJava 代理模式详解\nBigDecimal 详解\nJava 魔法类 Unsafe 详解\nJava SPI 机制详解\nJava 语法糖详解\n集合 # Java集合常见面试题总结(上)\nJava集合常见面试题总结(下)\nJava集合使用注意事项总结\n源码分析 # ArrayList 源码分析\nLinkedList 源码分析\nHashMap 源码分析\nConcurrentHashMap 源码分析\nLinkedHashMap 源码分析\nCopyOnWriteArrayList 源码分析\nArrayBlockingQueue 源码分析\nPriorityQueue 源码分析(付费)\nDelayQueue 源码分析\n并发编程 # Java并发常见面试题总结(上)\nJava并发常见面试题总结(中)\nJava并发常见面试题总结(下)\n重要知识点 # 乐观锁和悲观锁详解CAS\n详解JMM(Java 内存模型)\n详解Java 线程池详解Java\n线程池最佳实践Java\n常见并发容器总结AQS\n详解Atomic 原子类总结\nThreadLocal\n详解CompletableFuture\n详解虚拟线程常见问题总结\nIO # Java IO 基础知识总结\nJava IO 设计模式总结\nJava IO 模型详解\nJava NIO 核心知识总结\nJVM # Java内存区域详解(重点)\nJVM垃圾回收详解(重点)\n类文件结构详解\n类加载过程详解\n类加载器详解(重点)\n最重要的JVM参数总结\nJDK监控和故障处理工具总结\nJVM线上问题排查和性能调优案例\n新特性 # Java8 新特性实战\n《Java8 指南》中文翻译\nJava 9 新特性概览\nJava 10 新特性概览\nJava 11 新特性概览\nJava 12 \u0026amp; 13 新特性概览\nJava 14 \u0026amp; 15 新特性概览\nJava 16 新特性概览\nJava 17 新特性概览(重要)\nJava 18 新特性概览\nJava 19 新特性概览\nJava 20 新特性概览\nJava 21 新特性概览(重要)\nJava 22 \u0026amp; 23 新特性概览\n计算机基础 # 网络 # 计算机网络常见面试题总结(上)计算机网络常见面试题总结(下)\n重要知识点 # OSI 和 TCP/IP 网络分层模型详解(基础)\n访问网页的全过程(知识串联)\n应用层常见协议总结(应用层)\nHTTP vs HTTPS(应用层)\nHTTP 1.0 vs HTTP 1.1(应用层)\nHTTP 常见状态码总结(应用层)\nDNS 域名系统详解(应用层)\nTCP 三次握手和四次挥手(传输层)\nTCP 传输可靠性保障(传输层)\nARP 协议详解(网络层)\nNAT 协议详解(网络层)\n网络攻击常见手段总结\n操作系统 # 操作系统常见面试题总结(上)\n操作系统常见面试题总结(下)\nLinux # Linux 基础知识总结Shell\n编程基础知识总结\n数据结构 # 线性数据结构\n图\n堆\n树\n红黑树\n布隆过滤器\n算法 # 经典算法思想总结(含LeetCode题目推荐)\n常见数据结构经典LeetCode题目推荐\n几道常见的字符串算法题\n几道常见的链表算法题\n剑指offer部分编程题\n十大经典排序算法总结\n数据库 # 基础 # 数据库基础知识总结\nNoSQL基础知识总结\n字符集详解\nSQL # SQL语法基础知识总结\nSQL常见面试题总结(1)\nSQL常见面试题总结(2)\nSQL常见面试题总结(3)\nSQL常见面试题总结(4)\nSQL常见面试题总结(5)\nMySQL # MySQL常见面试题总结\nMySQL高性能优化规范建议总结\n重要知识点 # MySQL索引详解\nMySQL三大日志详解\nMySQL事务隔离级别详解\nInnoDB存储引擎对MVCC的实现\nSQL语句在MySQL中的执行过程\nMySQL查询缓存详解\nMySQL执行计划分析\nMySQL自增主键一定是连续的吗\nMySQL日期类型选择建议\nMySQL隐式转换造成索引失效\nRedis # 缓存基础常见面试题总结(付费)\nRedis常见面试题总结(上)\nRedis常见面试题总结(下)\n重要知识点 # 如何基于Redis实现延时任务\n3种常用的缓存读写策略详解\nRedis 5 种基本数据类型详解\nRedis 3 种特殊数据类型详解\nRedis为什么用跳表实现有序集合\nRedis持久化机制详解\nRedis内存碎片详解\nRedis常见阻塞原因总结\nRedis集群详解(付费)\nElasticsearch # Elasticsearch常见面试题总结(付费)\nMongoDB # MongoDB常见面试题总结(上)\nMongoDB常见面试题总结(下)\n开发工具 # Maven # Maven核心概念总结\nMaven最佳实践\nGradle # Gradle核心概念总结\nGit # Git核心概念总结\nGithub实用小技巧总结\nDocker # Docker核心概念总结\nDocker实战\nIDEA # 常用框架 # Spring\u0026amp;SpringBoot # Spring常见面试题总结\nSpringBoot常见面试题总结(付费)\nSpring\u0026amp;SpringBoot常用注解总结\nSpring Boot核心源码解读(付费)\n重要知识点 # IoC \u0026amp; AOP详解(快速搞懂)\nSpring 事务详解\nSpring 中的设计模式详解\nSpringBoot 自动装配原理详解\nMyBatis常见面试题总结 # Netty常见面试题总结(付费) # 系统设计 # 基础知识 # RestFul API 简明教程\n软件工程简明教程\n代码命名指南\n代码重构指南\n单元测试指南\n认证授权 # 认证授权基础概念详解\nJWT 基础概念详解\nJWT 身份认证优缺点分析\nSSO 单点登录详解\n权限系统设计详解\n数据安全 # 常见加密算法总结\n敏感词过滤方案总结\n数据脱敏方案总结\n系统设计常见面试题总结(付费) # 设计模式常见面试题总结 # Java 定时任务详解 # Web 实时消息推送详解 # 分布式 # 理论\u0026amp;算法\u0026amp;协议 # CAP \u0026amp; BASE理论详解\nPaxos 算法详解\nRaft 算法详解\nGossip 协议详解\nAPI网关 # API网关基础知识总结\nSpring Cloud Gateway常见问题总结\n分布式ID # 分布式ID介绍\u0026amp;实现方案总结\n分布式ID设计指南\n分布式锁 # 分布式锁介绍\n分布式锁常见实现方案总结\n分布式事务 # 分布式事务常见解决方案总结(付费)\n分布式配置中心 # 分布式配置中心常见问题总结(付费)\nRPC\nZooKeeper\n高性能\n高可用\nRPC # RPC基础知识总结\nDubbo常见问题总结\nZooKeeper # ZooKeeper相关概念总结(入门)\nZooKeeper相关概念总结(进阶)\n高性能 # CDN # CDN工作原理详解\n负载均衡 # 负载均衡原理及算法详解\n数据库优化 # 读写分离和分库分表详解\n数据冷热分离详解\n常见SQL优化手段总结(付费)\n深度分页介绍及优化建议\n消息队列 # 消息队列基础知识总结\nDisruptor常见问题总结\nKafka常见问题总结\nRocketMQ常见问题总结\nRabbitMQ常见问题总结\n高可用 # 高可用系统设计指南\n冗余设计详解\n服务限流详解\n降级\u0026amp;熔断详解(付费)\n超时\u0026amp;重试详解\n性能测试入门\n"},{"id":6,"href":"/zh/docs/test/","title":"测试","section":"Docs","content":"没啥重要的,随便测试的一些东西,大部分跟博客架构相关的东西,之前用的hexo,现在改用hugo了,还有一些东西在摸索中。\n"},{"id":7,"href":"/zh/docs/technology/MySQL/MySQL%E6%98%AF%E6%80%8E%E6%A0%B7%E8%BF%90%E8%A1%8C%E7%9A%84/%E7%AC%AC26%E7%AB%A0_%E5%86%99%E4%BD%9C%E6%9C%AC%E4%B9%A6%E6%97%B6%E7%94%A8%E5%88%B0%E7%9A%84%E4%B8%80%E4%BA%9B%E9%87%8D%E8%A6%81%E7%9A%84%E5%8F%82%E8%80%83%E8%B5%84%E6%96%99/","title":"第26章_写作本书时用到的一些重要的参考资料","section":"My Sql是怎样运行的","content":"第26章 写作本书时用到的一些重要的参考资料\n感谢 # 我不生产知识,只是知识的搬运工。写作本小册的时间主要用在了两个方面:\n搞清楚事情的本质是什么。\n这个过程就是研究源码、书籍和资料。\n如何把我已经知道的知识表达出来。\n这个过程就是我不停的在地上走过来走过去,梳理知识结构,斟酌用词用句,不停的将已经写好的文章推倒重来,只是想给大家一个不错的用户体验。\n这两个方面用的时间基本上是一半一半吧,在搞清楚事情的本质是什么阶段,除了直接阅读MySQL的源码之外,查看参考资料也是一种比较偷懒的学习方式。本书只是MySQL进阶的一个入门,想了解更多关于MySQL的知识,大家可以从下面这些资料里找点灵感。\n一些链接 # MySQL官方文档: https://dev.mysql.com/doc/refman/5.7/en/\nMySQL官方文档是写作本书时参考最多的一个资料。说实话,文档写的非常通俗易懂,唯一的缺点就是太长了,导致大家看的时候无从下手。\nMySQL Internals Manual: https://dev.mysql.com/doc/internals/en/\n介绍MySQL如何实现各种功能的文档,写的比较好,但是太少了,有很多章节直接跳过了。\n何登成的github: https://github.com/hedengcheng/tech\n登博的博客非常好,对事务、优化这讨论的细节也非常多,不过由于大多是PPT结构,字太少,对上下文不清楚的同学可能会一脸懵逼。\norczhou的博客: http://www.orczhou.com/\nJeremy Cole的博客: https://blog.jcole.us/innodb/\nJeremy Cole大神不仅写作了innodb_ruby这个非常棒的解析InnoDB存储结构的工具,还对这些存储结构写了一系列的博客,在我几乎要放弃深入研究表空间结构的时候,是他老人家的博客把我又从深渊里拉了回来。\n那海蓝蓝(李海翔)的博客: https://blog.csdn.net/fly2nn\ntaobao月报: http://mysql.taobao.org/monthly/\n因为MySQL的源码非常多,经常让大家无从下手,而taobao月报就是一个非常好的源码阅读指南。\n吐槽一下,这个taobao月报也只能当作源码阅读指南看,如果真的不看源码光看月报,那只能当作天书看,十有八九被绕进去出不来了。\nMySQL Server Blog: http://mysqlserverteam.com/\nMySQL team的博客,一手资料,在我不知道看什么的时候给了很多启示。\nmysql_lover的博客: https://blog.csdn.net/mysql_lover/\nJorgen\u0026rsquo;s point of view: https://jorgenloland.blogspot.com/ mariadb的关于查询优化的文档: https://mariadb.com/kb/en/library/query-optimizations/\n不得不说mariadb的文档相比MySQL的来说就非常有艺术性了(里边儿有很多漂亮的插图),我很怀疑MySQL文档是程序员直接写的,mariadb的文档是产品经理写的。当我们想研究某个功能的原理,在MySQL文档干巴巴的说明中找不到头脑时,可以参考一下mariadb娓娓道来的风格。\nReconstructing Data Manipulation Queries from Redo Logs: https://www.sba-research.org/wp-content/uploads/publications/WSDF2012_InnoDB.pdf\n关于InnoDB事务的一个PPT: https://mariadb.org/wp-content/uploads/2018/02/Deep-Dive_-InnoDB-Transactions-and-Write-Paths.pdf 非官方优化文档: http://www.unofficialmysqlguide.com/optimizer-trace.html\n这个文档非常好,非常非常好~\nMySQL8.0的源码文档: https://dev.mysql.com/doc/dev/mysql-server\n一些书籍 # 《数据库查询优化器的艺术》李海翔著\n大家可以把这本书当作源码观看指南来看,不过讲的是5.6的源码,5.7里重构了一些,不过大体的思路还是可以参考的。\n《MySQL运维内参》周彦伟、王竹峰、强昌金著\n内参里有许多代码细节,是一个阅读源码的比较好的指南。\n《Effectiv MySQL:Optimizing SQL Statements》Ronald Bradford著\n小册子,可以一口气看完,对了解MySQL查询优化的大概内容还是有些好处滴。\n《高性能MySQL》瓦茨 (Baron Schwartz) / 扎伊采夫 (Peter Zaitsev) / 特卡琴科 (Vadim Tkachenko) 著\n经典,对于第三版的内容来说,如果把第2章和第3章的内容放到最后就更好了。不过作者更愿意把MySQL当作一个黑盒去讲述,主要是说明了如何更好的使用MySQL这个软件,这一点从第二版向第三版的转变上就可以看出来,第二版中涉及的许多的底层细节都在第三版中移除了。总而言之它是MySQL进阶的一个非常好的入门读物。\n《数据库事务处理的艺术》李海翔著\n同《数据库查询优化器的艺术》。\n《MySQL技术内幕 : InnoDB存储引擎 第2版》姜承尧著\n学习MySQL内核进阶阅读的第一本书。\n《MySQL技术内幕 第5版》 Paul DuBois 著\n这本书是对于MySQL使用层面的一个非常详细的介绍,也就是说它并不涉及MySQL的任何内核原理,甚至连索引结构都懒得讲。像是一个老妈子在给你不停的介绍吃饭怎么吃,喝水怎么喝,怎么上厕所的各种絮叨。整体风格比较像MySQL的官方文档,如果有想从使用层面从头了解MySQL的同学可以尝试的看看。\n《数据库系统概念》(美)Abraham Silberschatz / (美)Henry F.Korth / (美)S.Sudarshan 著\n这本书对于入门数据库原理来说非常好,不过看起来学术气味比较大一些,毕竟是一本正经的教科书,里边有不少的公式什么的。\n《事务处理 概念与技术》Jim Gray / Andreas Reuter 著\n这本书只是象征性的看了1~5章,说实话看不太懂,总是get不到作者要表达的点。不过听说业界非常推崇这本书,而恰巧我也看过一点,就写上了,有兴趣的同学可以去看看。\n说点不好的 # 上面尽说这些参考资料如何如何好了,主要是因为在我写作过程中的确参考到了,没有这些资料可能三五年都无法把小册写完。但是除了MySQL的文档以及《高性能MySQL》、《Effectiv MySQL:Optimizing SQL Statements》这两本书之外,其余的资料在大部分时间都是看的我头晕眼花,四肢乏力,不看个十遍八遍基本无法理清楚作者想要表达的点,这也是我写本小册的初衷\u0026mdash;让天下没有难学的知识。\n结语 # 希望这是各位2019年最爽的一次知识付费,如果各位因为阅读本小册而顺利通过面试,或者解决了工作中的很多技术问题,觉得29.9实在是太物超所值,希望各位能来给点打赏(本人很穷,靠救济生活~ 添加好友可以问关于小册的问题,不过希望不要扯犊子聊八卦了,我其实挺忙的~ 微信号:xiaohaizi4919)。\n小贴士:请允许我鄙视一下那些打着知识付费骗钱的人,除了不生产一点社会价值外,反而生产了数不清的焦虑,让人们连幸福感都丧失掉了。也请各位警惕那些说只要你交几百块钱,就能得到诸如境界上的提升、开阔了眼界、追赶上行业发展趋势之类的课程/知识付费,这类抽象而无法验证的主题都是骗人的。 "},{"id":8,"href":"/zh/docs/technology/MySQL/MySQL%E6%98%AF%E6%80%8E%E6%A0%B7%E8%BF%90%E8%A1%8C%E7%9A%84/%E7%AC%AC25%E7%AB%A0_%E5%B7%A5%E4%BD%9C%E9%9D%A2%E8%AF%95%E8%80%81%E5%A4%A7%E9%9A%BE-%E9%94%81/","title":"第25章_工作面试老大难-锁","section":"My Sql是怎样运行的","content":"第25章 工作面试老大难-锁\n解决并发事务带来问题的两种基本方式 # 上一章介绍了事务并发执行时可能带来的各种问题,并发事务访问相同记录的情况大致可以划分为3种:\n读-读情况:即并发事务相继读取相同的记录。\n读取操作本身不会对记录有一毛钱影响,并不会引起什么问题,所以允许这种情况的发生。\n写-写情况:即并发事务相继对相同的记录做出改动。\n我们前面说过,在这种情况下会发生脏写的问题,任何一种隔离级别都不允许这种问题的发生。所以在多个未提交事务相继对一条记录做改动时,需要让它们排队执行,这个排队的过程其实是通过锁来实现的。这个所谓的锁其实是一个内存中的结构,在事务执行前本来是没有锁的,也就是说一开始是没有锁结构和记录进行关联的,如图所示:\n当一个事务想对这条记录做改动时,首先会看看内存中有没有与这条记录关联的锁结构,当没有的时候就会在内存中生成一个锁结构与之关联。比方说事务T1要对这条记录做改动,就需要生成一个锁结构与之关联:\n其实在锁结构里有很多信息,不过为了简化理解,我们现在只把两个比较重要的属性拿了出来: - trx信息:代表这个锁结构是哪个事务生成的。 - is_waiting:代表当前事务是否在等待。\n如图所示,当事务T1改动了这条记录后,就生成了一个锁结构与该记录关联,因为之前没有别的事务为这条记录加锁,所以is_waiting属性就是false,我们把这个场景就称之为获取锁成功,或者加锁成功,然后就可以继续执行操作了。\n在事务T1提交之前,另一个事务T2也想对该记录做改动,那么先去看看有没有锁结构与这条记录关联,发现有一个锁结构与之关联后,然后也生成了一个锁结构与这条记录关联,不过锁结构的is_waiting属性值为true,表示当前事务需要等待,我们把这个场景就称之为获取锁失败,或者加锁失败,或者没有成功的获取到锁,画个图表示就是这样:\n在事务T1提交之后,就会把该事务生成的锁结构释放掉,然后看看还有没有别的事务在等待获取锁,发现了事务T2还在等待获取锁,所以把事务T2对应的锁结构的is_waiting属性设置为false,然后把该事务对应的线程唤醒,让它继续执行,此时事务T2就算获取到锁了。效果图就是这样:\n我们总结一下后续内容中可能用到的几种说法,以免大家混淆:\n+ 不加锁\n意思就是不需要在内存中生成对应的锁结构,可以直接执行操作。\n+ 获取锁成功,或者加锁成功\n意思就是在内存中生成了对应的锁结构,而且锁结构的is_waiting属性为false,也就是事务可以继续执行操作。\n+ 获取锁失败,或者加锁失败,或者没有获取到锁\n意思就是在内存中生成了对应的锁结构,不过锁结构的is_waiting属性为true,也就是事务需要等待,不可以继续执行操作。\n小贴士:这里只是对锁结构做了一个非常简单的描述,我们后边会详细介绍介绍锁结构的,稍安勿躁。\n读-写或写-读情况:也就是一个事务进行读取操作,另一个进行改动操作。\n我们前面说过,这种情况下可能发生脏读、不可重复读、幻读的问题。\n小贴士:幻读问题的产生是因为某个事务读了一个范围的记录,之后别的事务在该范围内插入了新记录,该事务再次读取该范围的记录时,可以读到新插入的记录,所以幻读问题准确的说并不是因为读取和写入一条相同记录而产生的,这一点要注意一下。\nSQL标准规定不同隔离级别下可能发生的问题不一样: - 在READ UNCOMMITTED隔离级别下,脏读、不可重复读、幻读都可能发生。 - 在READ COMMITTED隔离级别下,不可重复读、幻读可能发生,脏读不可以发生。 - 在REPEATABLE READ隔离级别下,幻读可能发生,脏读和不可重复读不可以发生。 - 在SERIALIZABLE隔离级别下,上述问题都不可以发生。\n不过各个数据库厂商对SQL标准的支持都可能不一样,与SQL标准不同的一点就是,MySQL在REPEATABLE READ隔离级别实际上就已经解决了幻读问题。\n怎么解决脏读、不可重复读、幻读这些问题呢?其实有两种可选的解决方案:\n+ 方案一:读操作利用多版本并发控制(MVCC),写操作进行加锁。\n所谓的MVCC我们在前一章有过详细的描述,就是通过生成一个ReadView,然后通过ReadView找到符合条件的记录版本(历史版本是由undo日志构建的),其实就像是在生成ReadView的那个时刻做了一次时间静止(就像用相机拍了一个快照),查询语句只能读到在生成ReadView之前已提交事务所做的更改,在生成ReadView之前未提交的事务或者之后才开启的事务所做的更改是看不到的。而写操作肯定针对的是最新版本的记录,读记录的历史版本和改动记录的最新版本本身并不冲突,也就是采用MVCC时,读-写操作并不冲突。\n小贴士:我们说过普通的SELECT语句在READ COMMITTED和REPEATABLE READ隔离级别下会使用到MVCC读取记录。在READ COMMITTED隔离级别下,一个事务在执行过程中每次执行SELECT操作时都会生成一个ReadView,ReadView的存在本身就保证了事务不可以读取到未提交的事务所做的更改,也就是避免了脏读现象;REPEATABLE READ隔离级别下,一个事务在执行过程中只有第一次执行SELECT操作才会生成一个ReadView,之后的SELECT操作都复用这个ReadView,这样也就避免了不可重复读和幻读的问题。\n+ 方案二:读、写操作都采用加锁的方式。\n如果我们的一些业务场景不允许读取记录的旧版本,而是每次都必须去读取记录的最新版本,比方在银行存款的事务中,你需要先把账户的余额读出来,然后将其加上本次存款的数额,最后再写到数据库中。在将账户余额读取出来后,就不想让别的事务再访问该余额,直到本次存款事务执行完成,其他事务才可以访问账户的余额。这样在读取记录的时候也就需要对其进行加锁操作,这样也就意味着读操作和写操作也像写-写操作那样排队执行。\n小贴士: 我们说脏读的产生是因为当前事务读取了另一个未提交事务写的一条记录,如果另一个事务在写记录的时候就给这条记录加锁,那么当前事务就无法继续读取该记录了,所以也就不会有脏读问题的产生了。不可重复读的产生是因为当前事务先读取一条记录,另外一个事务对该记录做了改动之后并提交之后,当前事务再次读取时会获得不同的值,如果在当前事务读取记录时就给该记录加锁,那么另一个事务就无法修改该记录,自然也不会发生不可重复读了。我们说幻读问题的产生是因为当前事务读取了一个范围的记录,然后另外的事务向该范围内插入了新记录,当前事务再次读取该范围的记录时发现了新插入的新记录,我们把新插入的那些记录称之为幻影记录。采用加锁的方式解决幻读问题就有那么一丢丢麻烦了,因为当前事务在第一次读取记录时那些幻影记录并不存在,所以读取的时候加锁就有点尴尬 —— 因为你并不知道给谁加锁,没关系,这难不倒设计InnoDB的大佬的,我们稍后揭晓答案,稍安勿躁。\n很明显,采用MVCC方式的话,读-写操作彼此并不冲突,性能更高,采用加锁方式的话,读-写操作彼此需要排队执行,影响性能。一般情况下我们当然愿意采用MVCC来解决读-写操作并发执行的问题,但是业务在某些特殊情况下,要求必须采用加锁的方式执行,那也是没有办法的事。\n一致性读(Consistent Reads) # 事务利用MVCC进行的读取操作称之为一致性读,或者一致性无锁读,有的地方也称之为快照读。所有普通的SELECT语句(plain SELECT)在READ COMMITTED、REPEATABLE READ隔离级别下都算是一致性读,比方说: SELECT * FROM t; SELECT * FROM t1 INNER JOIN t2 ON t1.col1 = t2.col2 一致性读并不会对表中的任何记录做加锁操作,其他事务可以自由的对表中的记录做改动。\n锁定读(Locking Reads) # 共享锁和独占锁 # 我们前面说过,并发事务的读-读情况并不会引起什么问题,不过对于写-写、读-写或写-读这些情况可能会引起一些问题,需要使用MVCC或者加锁的方式来解决它们。在使用加锁的方式解决问题时,由于既要允许读-读情况不受影响,又要使写-写、读-写或写-读情况中的操作相互阻塞,所以设计MySQL的大佬给锁分了个类: - 共享锁,英文名:Shared Locks,简称S锁。在事务要读取一条记录时,需要先获取该记录的S锁。 - 独占锁,也常称排他锁,英文名:Exclusive Locks,简称X锁。在事务要改动一条记录时,需要先获取该记录的X锁。\n假如事务T1首先获取了一条记录的S锁之后,事务T2接着也要访问这条记录: - 如果事务T2想要再获取一个记录的S锁,那么事务T2也会获得该锁,也就意味着事务T1和T2在该记录上同时持有S锁。 - 如果事务T2想要再获取一个记录的X锁,那么此操作会被阻塞,直到事务T1提交之后将S锁释放掉。\n如果事务T1首先获取了一条记录的X锁之后,那么不管事务T2接着想获取该记录的S锁还是X锁都会被阻塞,直到事务T1提交。\n所以我们说S锁和S锁是兼容的,S锁和X锁是不兼容的,X锁和X锁也是不兼容的,画个表表示一下就是这样: 兼容性 X S X 不兼容 不兼容 S 不兼容 兼容\n锁定读的语句 # 我们前面说在采用加锁方式解决脏读、不可重复读、幻读这些问题时,读取一条记录时需要获取一下该记录的S锁,其实这是不严谨的,有时候想在读取记录时就获取记录的X锁,来禁止别的事务读写该记录,为此设计MySQL的大佬提出了两种比较特殊的SELECT语句格式:\n对读取的记录加S锁:\nSELECT ... LOCK IN SHARE MODE;\n也就是在普通的SELECT语句后边加LOCK IN SHARE MODE,如果当前事务执行了该语句,那么它会为读取到的记录加S锁,这样允许别的事务继续获取这些记录的S锁(比方说别的事务也使用SELECT ... LOCK IN SHARE MODE语句来读取这些记录),但是不能获取这些记录的X锁(比方说使用SELECT ... FOR UPDATE语句来读取这些记录,或者直接修改这些记录)。如果别的事务想要获取这些记录的X锁,那么它们会阻塞,直到当前事务提交之后将这些记录上的S锁释放掉。\n对读取的记录加X锁:\nSELECT ... FOR UPDATE;\n也就是在普通的SELECT语句后边加FOR UPDATE,如果当前事务执行了该语句,那么它会为读取到的记录加X锁,这样既不允许别的事务获取这些记录的S锁(比方说别的事务使用SELECT ... LOCK IN SHARE MODE语句来读取这些记录),也不允许获取这些记录的X锁(比方也说使用SELECT ... FOR UPDATE语句来读取这些记录,或者直接修改这些记录)。如果别的事务想要获取这些记录的S锁或者X锁,那么它们会阻塞,直到当前事务提交之后将这些记录上的X锁释放掉。\n关于更多锁定读的加锁细节我们稍后会详细介绍,稍安勿躁。\n写操作 # 平常所用到的写操作无非是DELETE、UPDATE、INSERT这三种:\nDELETE:\n对一条记录做DELETE操作的过程其实是先在B+树中定位到这条记录的位置,然后获取一下这条记录的X锁,然后再执行delete mark操作。我们也可以把这个定位待删除记录在B+树中位置的过程看成是一个获取X锁的锁定读。\nUPDATE:\n在对一条记录做UPDATE操作时分为三种情况: - 如果未修改该记录的键值并且被更新的列占用的存储空间在修改前后未发生变化,则先在B+树中定位到这条记录的位置,然后再获取一下记录的X锁,最后在原记录的位置进行修改操作。其实我们也可以把这个定位待修改记录在B+树中位置的过程看成是一个获取X锁的锁定读。 - 如果未修改该记录的键值并且至少有一个被更新的列占用的存储空间在修改前后发生变化,则先在B+树中定位到这条记录的位置,然后获取一下记录的X锁,将该记录彻底删除掉(就是把记录彻底移入垃圾链表),最后再插入一条新记录。这个定位待修改记录在B+树中位置的过程看成是一个获取X锁的锁定读,新插入的记录由INSERT操作提供的隐式锁进行保护。 - 如果修改了该记录的键值,则相当于在原记录上做DELETE操作之后再来一次INSERT操作,加锁操作就需要按照DELETE和INSERT的规则进行了。\nINSERT:\n一般情况下,新插入一条记录的操作并不加锁,设计InnoDB的大佬通过一种称之为隐式锁的东东来保护这条新插入的记录在本事务提交前不被别的事务访问,更多细节我们后边看~\n小贴士:当然,在一些特殊情况下INSERT操作也是会获取锁的,具体情况我们后边介绍。\n多粒度锁 # 我们前面提到的锁都是针对记录的,也可以被称之为行级锁或者行锁,对一条记录加锁影响的也只是这条记录而已,我们就说这个锁的粒度比较细;其实一个事务也可以在表级别进行加锁,自然就被称之为表级锁或者表锁,对一个表加锁影响整个表中的记录,我们就说这个锁的粒度比较粗。给表加的锁也可以分为共享锁(S锁)和独占锁(X锁):\n给表加S锁:\n如果一个事务给表加了S锁,那么: - 别的事务可以继续获得该表的S锁 - 别的事务可以继续获得该表中的某些记录的S锁 - 别的事务不可以继续获得该表的X锁 - 别的事务不可以继续获得该表中的某些记录的X锁\n给表加X锁:\n如果一个事务给表加了X锁(意味着该事务要独占这个表),那么: - 别的事务不可以继续获得该表的S锁 - 别的事务不可以继续获得该表中的某些记录的S锁 - 别的事务不可以继续获得该表的X锁 - 别的事务不可以继续获得该表中的某些记录的X锁\n上面看着有点啰嗦,为了更好的理解这个表级别的S锁和X锁,我们举一个现实生活中的例子。不知道各位同学都上过大学没,我们以大学教学楼中的教室为例来分析一下加锁的情况: - 教室一般都是公用的,我们可以随便选教室进去上自习。当然,教室不是自家的,一间教室可以容纳很多同学同时上自习,每当一个人进去上自习,就相当于在教室门口挂了一把S锁,如果很多同学都进去上自习,相当于教室门口挂了很多把S锁(类似行级别的S锁)。 - 有的时候教室会进行检修,比方说换地板,换天花板,换灯管什么的,这些维修项目并不能同时开展。如果教室针对某个项目进行检修,就不允许别的同学来上自习,也不允许其他维修项目进行,此时相当于教室门口会挂一把X锁(类似行级别的X锁)。\n上面提到的这两种锁都是针对教室而言的,不过有时候我们会有一些特殊的需求: - 有领导要来参观教学楼的环境。\n校领导考虑并不想影响同学们上自习,但是此时不能有教室处于维修状态,所以可以在教学楼门口放置一把`S锁`(类似表级别的`S锁`)。此时: - 来上自习的学生们看到教学楼门口有`S锁`,可以继续进入教学楼上自习。 - 修理工看到教学楼门口有`S锁`,则先在教学楼门口等着,什么时候领导走了,把教学楼的`S锁`撤掉再进入教学楼维修。 学校要占用教学楼进行考试。\n此时不允许教学楼中有正在上自习的教室,也不允许对教室进行维修。所以可以在教学楼门口放置一把X锁(类似表级别的X锁)。此时: - 来上自习的学生们看到教学楼门口有X锁,则需要在教学楼门口等着,什么时候考试结束,把教学楼的X锁撤掉再进入教学楼上自习。 - 修理工看到教学楼门口有X锁,则先在教学楼门口等着,什么时候考试结束,把教学楼的X锁撤掉再进入教学楼维修。\n但是这里头有两个问题: - 如果我们想对教学楼整体上S锁,首先需要确保教学楼中的没有正在维修的教室,如果有正在维修的教室,需要等到维修结束才可以对教学楼整体上S锁。 - 如果我们想对教学楼整体上X锁,首先需要确保教学楼中的没有上自习的教室以及正在维修的教室,如果有上自习的教室或者正在维修的教室,需要等到全部上自习的同学都上完自习离开,以及维修工维修完教室离开后才可以对教学楼整体上X锁。\n我们在对教学楼整体上锁(表锁)时,怎么知道教学楼中有没有教室已经被上锁(行锁)了呢?依次检查每一间教室门口有没有上锁?那这效率也太慢了吧!遍历是不可能遍历的,这辈子也不可能遍历的,于是乎设计InnoDB的大佬们提出了一种称之为意向锁(英文名:Intention Locks)的东东: - 意向共享锁,英文名:Intention Shared Lock,简称IS锁。当事务准备在某条记录上加S锁时,需要先在表级别加一个IS锁。 - 意向独占锁,英文名:Intention Exclusive Lock,简称IX锁。当事务准备在某条记录上加X锁时,需要先在表级别加一个IX锁。\n视角回到教学楼和教室上来: - 如果有学生到教室中上自习,那么他先在整栋教学楼门口放一把IS锁(表级锁),然后再到教室门口放一把S锁(行锁)。 - 如果有维修工到教室中维修,那么它先在整栋教学楼门口放一把IX锁(表级锁),然后再到教室门口放一把X锁(行锁)。\n之后: - 如果有领导要参观教学楼,也就是想在教学楼门口前放S锁(表锁)时,首先要看一下教学楼门口有没有IX锁,如果有,意味着有教室在维修,需要等到维修结束把IX锁撤掉后才可以在整栋教学楼上加S锁。 - 如果有考试要占用教学楼,也就是想在教学楼门口前放X锁(表锁)时,首先要看一下教学楼门口有没有IS锁或IX锁,如果有,意味着有教室在上自习或者维修,需要等到学生们上完自习以及维修结束把IS锁和IX锁撤掉后才可以在整栋教学楼上加X锁。\n小贴士:学生在教学楼门口加IS锁时,是不关心教学楼门口是否有IX锁的,维修工在教学楼门口加IX锁时,是不关心教学楼门口是否有IS锁或者其他IX锁的。IS和IX锁只是为了判断当前时间教学楼里有没有被占用的教室用的,也就是在对教学楼加S锁或者X锁时才会用到。\n总结一下:IS、IX锁是表级锁,它们的提出仅仅为了在之后加表级别的S锁和X锁时可以快速判断表中的记录是否被上锁,以避免用遍历的方式来查看表中有没有上锁的记录,也就是说其实IS锁和IX锁是兼容的,IX锁和IX锁是兼容的。我们画个表来看一下表级别的各种锁的兼容性: 兼容性 X IX S IS X 不兼容 不兼容 不兼容 不兼容 IX 不兼容 兼容 不兼容 兼容 S 不兼容 不兼容 兼容 兼容 IS 不兼容 兼容 兼容 兼容\nMySQL中的行锁和表锁 # 上面说的都算是些理论知识,其实MySQL支持多种存储引擎,不同存储引擎对锁的支持也是不一样的。当然,我们重点还是讨论InnoDB存储引擎中的锁,其他的存储引擎只是稍微提一下~\n其他存储引擎中的锁 # 对于MyISAM、MEMORY、MERGE这些存储引擎来说,它们只支持表级锁,而且这些引擎并不支持事务,所以使用这些存储引擎的锁一般都是针对当前会话来说的。比方说在Session 1中对一个表执行SELECT操作,就相当于为这个表加了一个表级别的S锁,如果在SELECT操作未完成时,Session 2中对这个表执行UPDATE操作,相当于要获取表的X锁,此操作会被阻塞,直到Session 1中的SELECT操作完成,释放掉表级别的S锁后,Session 2中对这个表执行UPDATE操作才能继续获取X锁,然后执行具体的更新语句。\n小贴士:因为使用MyISAM、MEMORY、MERGE这些存储引擎的表在同一时刻只允许一个会话对表进行写操作,所以这些存储引擎实际上最好用在只读,或者大部分都是读操作,或者单用户的情景下。另外,在MyISAM存储引擎中有一个称之为Concurrent Inserts的特性,支持在对MyISAM表读取时同时插入记录,这样可以提升一些插入速度。关于更多Concurrent Inserts的细节,我们就不介绍了,详情可以参考文档。\nInnoDB存储引擎中的锁 # InnoDB存储引擎既支持表锁,也支持行锁。表锁实现简单,占用资源较少,不过粒度很粗,有时候你仅仅需要锁住几条记录,但使用表锁的话相当于为表中的所有记录都加锁,所以性能比较差。行锁粒度更细,可以实现更精准的并发控制。下面我们详细看一下。\nInnoDB中的表级锁 # 表级别的S锁、X锁\n在对某个表执行SELECT、INSERT、DELETE、UPDATE语句时,InnoDB存储引擎是不会为这个表添加表级别的S锁或者X锁的。\n另外,在对某个表执行一些诸如ALTER TABLE、DROP TABLE这类的DDL语句时,其他事务对这个表并发执行诸如SELECT、INSERT、DELETE、UPDATE的语句会发生阻塞,同理,某个事务中对某个表执行SELECT、INSERT、DELETE、UPDATE语句时,在其他会话中对这个表执行DDL语句也会发生阻塞。这个过程其实是通过在server层使用一种称之为元数据锁(英文名:Metadata Locks,简称MDL)东东来实现的,一般情况下也不会使用InnoDB存储引擎自己提供的表级别的S锁和X锁。\n小贴士:在事务简介的章节中我们说过,DDL语句执行时会隐式的提交当前会话中的事务,这主要是DDL语句的执行一般都会在若干个特殊事务中完成,在开启这些特殊事务前,需要将当前会话中的事务提交掉。另外,关于MDL锁并不是我们本章所要讨论的范围,大家可以参阅文档了解~\n其实这个InnoDB存储引擎提供的表级S锁或者X锁是相当鸡肋,只会在一些特殊情况下,比方说崩溃恢复过程中用到。不过我们还是可以手动获取一下的,比方说在系统变量autocommit=0,innodb_table_locks = 1时,手动获取InnoDB存储引擎提供的表t的S锁或者X锁可以这么写: - LOCK TABLES t READ:InnoDB存储引擎会对表t加表级别的S锁。 - LOCK TABLES t WRITE:InnoDB存储引擎会对表t加表级别的X锁。\n不过请尽量避免在使用InnoDB存储引擎的表上使用LOCK TABLES这样的手动锁表语句,它们并不会提供什么额外的保护,只是会降低并发能力而已。InnoDB的厉害之处还是实现了更细粒度的行锁,关于表级别的S锁和X锁大家了解一下就罢了。\n表级别的IS锁、IX锁\n当我们在对使用InnoDB存储引擎的表的某些记录加S锁之前,那就需要先在表级别加一个IS锁,当我们在对使用InnoDB存储引擎的表的某些记录加X锁之前,那就需要先在表级别加一个IX锁。IS锁和IX锁的使命只是为了后续在加表级别的S锁和X锁时判断表中是否有已经被加锁的记录,以避免用遍历的方式来查看表中有没有上锁的记录。更多关于IS锁和IX锁的解释我们上面都介绍过了,就不赘述了。\n表级别的AUTO-INC锁\n在使用MySQL过程中,我们可以为表的某个列添加AUTO_INCREMENT属性,之后在插入记录时,可以不指定该列的值,系统会自动为它赋上递增的值,比方说我们有一个表: CREATE TABLE t ( id INT NOT NULL AUTO_INCREMENT, c VARCHAR(100), PRIMARY KEY (id) ) Engine=InnoDB CHARSET=utf8; 由于这个表的id字段声明了AUTO_INCREMENT,也就意味着在书写插入语句时不需要为其赋值,比方说这样: INSERT INTO t(c) VALUES('aa'), ('bb'); 上面的插入语句并没有为id列显式赋值,所以系统会自动为它赋上递增的值,效果就是这样:\nmysql\u0026gt; SELECT * FROM t; +----+------+ | id | c | +----+------+ | 1 | aa | | 2 | bb | +----+------+ 2 rows in set (0.00 sec) 系统实现这种自动给AUTO_INCREMENT修饰的列递增赋值的原理主要是两个:\n+ 采用AUTO-INC锁,也就是在执行插入语句时就在表级别加一个AUTO-INC锁,然后为每条待插入记录的AUTO_INCREMENT修饰的列分配递增的值,在该语句执行结束后,再把AUTO-INC锁释放掉。这样一个事务在持有AUTO-INC锁的过程中,其他事务的插入语句都要被阻塞,可以保证一个语句中分配的递增值是连续的。\n如果我们的插入语句在执行前不可以确定具体要插入多少条记录(无法预计即将插入记录的数量),比方说使用INSERT ... SELECT、REPLACE ... SELECT或者LOAD DATA这种插入语句,一般是使用AUTO-INC锁为AUTO_INCREMENT修饰的列生成对应的值。\n小贴士:需要注意一下的是,这个AUTO-INC锁的作用范围只是单个插入语句,插入语句执行完成后,这个锁就被释放了,跟我们之前介绍的锁在事务结束时释放是不一样的。\n+ 采用一个轻量级的锁,在为插入语句生成AUTO_INCREMENT修饰的列的值时获取一下这个轻量级锁,然后生成本次插入语句需要用到的AUTO_INCREMENT列的值之后,就把该轻量级锁释放掉,并不需要等到整个插入语句执行完才释放锁。\n如果我们的插入语句在执行前就可以确定具体要插入多少条记录,比方说我们上面举的关于表t的例子中,在语句执行前就可以确定要插入2条记录,那么一般采用轻量级锁的方式对AUTO_INCREMENT修饰的列进行赋值。这种方式可以避免锁定表,可以提升插入性能。\n小贴士:设计InnoDB的大佬提供了一个称之为innodb_autoinc_lock_mode的系统变量来控制到底使用上述两种方式中的哪种来为AUTO_INCREMENT修饰的列进行赋值,当innodb_autoinc_lock_mode值为0时,一律采用AUTO-INC锁;当innodb_autoinc_lock_mode值为2时,一律采用轻量级锁;当innodb_autoinc_lock_mode值为1时,两种方式混着来(也就是在插入记录数量确定时采用轻量级锁,不确定时使用AUTO-INC锁)。不过当innodb_autoinc_lock_mode值为2时,可能会造成不同事务中的插入语句为AUTO_INCREMENT修饰的列生成的值是交叉的,在有主从复制的场景中是不安全的。\nInnoDB中的行级锁 # 很遗憾的通知大家一个不好的消息,上面讲的都是铺垫,本章真正的重点才刚刚开始[手动偷笑]。\n行锁,也称为记录锁,顾名思义就是在记录上加的锁。不过设计InnoDB的大佬很有才,一个行锁玩出了各种花样,也就是把行锁分成了各种类型。换句话说即使对同一条记录加行锁,如果类型不同,起到的功效也是不同的。为了故事的顺利发展,我们还是先将之前介绍MVCC时用到的表抄一遍: CREATE TABLE hero ( number INT, name VARCHAR(100), country varchar(100), PRIMARY KEY (number), KEY idx_name (name) ) Engine=InnoDB CHARSET=utf8; 我们主要是想用这个表存储三国时的英雄,然后向这个表里插入几条记录: INSERT INTO hero VALUES (1, 'l刘备', '蜀'), (3, 'z诸葛亮', '蜀'), (8, 'c曹操', '魏'), (15, 'x荀彧', '魏'), (20, 's孙权', '吴'); 现在表里的数据就是这样的: mysql\u0026gt; SELECT * FROM hero; +--------+------------+---------+ | number | name | country | +--------+------------+---------+ | 1 | l刘备 | 蜀 | | 3 | z诸葛亮 | 蜀 | | 8 | c曹操 | 魏 | | 15 | x荀彧 | 魏 | | 20 | s孙权 | 吴 | +--------+------------+---------+ 5 rows in set (0.01 sec) 小贴士:不是说好的存储三国时的英雄么,你在搞什么,为什么要在'刘备'、'曹操'、'孙权'前面加上'l'、'c'、's'这几个字母呀?这个主要是因为我们采用utf8字符集,该字符集并没有对应的按照汉语拼音进行排序的比较规则,也就是说'刘备'、'曹操'、'孙权'这几个字符串的排序并不是按照它们汉语拼音进行排序的,我怕大家懵逼,所以在汉字前面加上了汉字对应的拼音的第一个字母,这样在排序时就是按照汉语拼音进行排序,大家也不懵逼了。另外,我们故意把各条记录number列的值搞得很分散,后边会用到,稍安勿躁~ 我们把hero表中的聚簇索引的示意图画一下:\n当然,我们把B+树的索引结构做了一个超级简化,只把索引中的记录给拿了出来,我们这里只是想强调聚簇索引中的记录是按照主键大小排序的,并且省略掉了聚簇索引中的隐藏列,大家心里明白就好(不理解索引结构的同学可以去前面的文章中查看)。\n现在准备工作做完了,下面我们来看看都有哪些常用的行锁类型。\nRecord Locks:\n我们前面提到的记录锁就是这种类型,也就是仅仅把一条记录锁上,我决定给这种类型的锁起一个比较不正经的名字:正经记录锁(请允许我皮一下,我实在不知道该叫什么名好)。官方的类型名称为:LOCK_REC_NOT_GAP。比方说我们把number值为8的那条记录加一个正经记录锁的示意图如下:\n正经记录锁是有S锁和X锁之分的,让我们分别称之为S型正经记录锁和X型正经记录锁吧(听起来有点怪怪的),当一个事务获取了一条记录的S型正经记录锁后,其他事务也可以继续获取该记录的S型正经记录锁,但不可以继续获取X型正经记录锁;当一个事务获取了一条记录的X型正经记录锁后,其他事务既不可以继续获取该记录的S型正经记录锁,也不可以继续获取X型正经记录锁;\nGap Locks:\n我们说MySQL在REPEATABLE READ隔离级别下是可以解决幻读问题的,解决方案有两种,可以使用MVCC方案解决,也可以采用加锁方案解决。但是在使用加锁方案解决时有个大问题,就是事务在第一次执行读取操作时,那些幻影记录尚不存在,我们无法给这些幻影记录加上正经记录锁。不过这难不倒设计InnoDB的大佬,他们提出了一种称之为Gap Locks的锁,官方的类型名称为:LOCK_GAP,我们也可以简称为gap锁。比方说我们把number值为8的那条记录加一个gap锁的示意图如下:\n如图中为number值为8的记录加了gap锁,意味着不允许别的事务在number值为8的记录前面的间隙插入新记录,其实就是number列的值(3, 8)这个区间的新记录是不允许立即插入的。比方说有另外一个事务再想插入一条number值为4的新记录,它定位到该条新记录的下一条记录的number值为8,而这条记录上又有一个gap锁,所以就会阻塞插入操作,直到拥有这个gap锁的事务提交了之后,number列的值在区间(3, 8)中的新记录才可以被插入。\n这个gap锁的提出仅仅是为了防止插入幻影记录而提出的,虽然有共享gap锁和独占gap锁这样的说法,但是它们起到的作用都是相同的。而且如果你对一条记录加了gap锁(不论是共享gap锁还是独占gap锁),并不会限制其他事务对这条记录加正经记录锁或者继续加gap锁,再强调一遍,gap锁的作用仅仅是为了防止插入幻影记录的而已。\n不知道大家发现了一个问题没,给一条记录加了gap锁只是不允许其他事务往这条记录前面的间隙插入新记录,那对于最后一条记录之后的间隙,也就是hero表中number值为20的记录之后的间隙该咋办呢?也就是说给哪条记录加gap锁才能阻止其他事务插入number值在(20, +∞)这个区间的新记录呢?这时候应该想起我们在前面介绍数据页时介绍的两条伪记录了: - Infimum记录,表示该页面中最小的记录。 - Supremum记录,表示该页面中最大的记录。\n为了实现阻止其他事务插入number值在(20, +∞)这个区间的新记录,我们可以给索引中的最后一条记录,也就是number值为20的那条记录所在页面的Supremum记录加上一个gap锁,画个图就是这样:\n这样就可以阻止其他事务插入number值在(20, +∞)这个区间的新记录。为了大家理解方便,之后的索引示意图中都会把这个Supremum记录画出来。\nNext-Key Locks:\n有时候我们既想锁住某条记录,又想阻止其他事务在该记录前面的间隙插入新记录,所以设计InnoDB的大佬们就提出了一种称之为Next-Key Locks的锁,官方的类型名称为:LOCK_ORDINARY,我们也可以简称为next-key锁。比方说我们把number值为8的那条记录加一个next-key锁的示意图如下:\nnext-key锁的本质就是一个正经记录锁和一个gap锁的合体,它既能保护该条记录,又能阻止别的事务将新记录插入被保护记录前面的间隙。\nInsert Intention Locks:\n我们说一个事务在插入一条记录时需要判断一下插入位置是不是被别的事务加了所谓的gap锁(next-key锁也包含gap锁,后边就不强调了),如果有的话,插入操作需要等待,直到拥有gap锁的那个事务提交。但是设计InnoDB的大佬规定事务在等待的时候也需要在内存中生成一个锁结构,表明有事务想在某个间隙中插入新记录,但是现在在等待。设计InnoDB的大佬就把这种类型的锁命名为Insert Intention Locks,官方的类型名称为:LOCK_INSERT_INTENTION,我们也可以称为插入意向锁。\n比方说我们把number值为8的那条记录加一个插入意向锁的示意图如下:\n为了让大家彻底理解这个插入意向锁的功能,我们还是举个例子然后画个图表示一下。比方说现在T1为number值为8的记录加了一个gap锁,然后T2和T3分别想向hero表中插入number值分别为4、5的两条记录,所以现在为number值为8的记录加的锁的示意图就如下所示:\n小贴士:我们在锁结构中又新添了一个type属性,表明该锁的类型。稍后会全面介绍InnoDB存储引擎中的一个锁结构到底长什么样。 从图中可以看到,由于T1持有gap锁,所以T2和T3需要生成一个插入意向锁的锁结构并且处于等待状态。当T1提交后会把它获取到的锁都释放掉,这样T2和T3就能获取到对应的插入意向锁了(本质上就是把插入意向锁对应锁结构的is_waiting属性改为false),T2和T3之间也并不会相互阻塞,它们可以同时获取到number值为8的插入意向锁,然后执行插入操作。事实上插入意向锁并不会阻止别的事务继续获取该记录上任何类型的锁(插入意向锁就是这么鸡肋)。\n隐式锁\n我们前面说一个事务在执行INSERT操作时,如果即将插入的间隙已经被其他事务加了gap锁,那么本次INSERT操作会阻塞,并且当前事务会在该间隙上加一个插入意向锁,否则一般情况下INSERT操作是不加锁的。那如果一个事务首先插入了一条记录(此时并没有与该记录关联的锁结构),然后另一个事务: - 立即使用SELECT ... LOCK IN SHARE MODE语句读取这条事务,也就是在要获取这条记录的S锁,或者使用SELECT ... FOR UPDATE语句读取这条事务或者直接修改这条记录,也就是要获取这条记录的X锁,该咋办?\n如果允许这种情况的发生,那么可能产生`脏读`问题。 + 立即修改这条记录,也就是要获取这条记录的X锁,该咋办?\n如果允许这种情况的发生,那么可能产生脏写问题。\n这时候我们前面介绍了很多遍的事务id又要起作用了。我们把聚簇索引和二级索引中的记录分开看一下: - 情景一:对于聚簇索引记录来说,有一个trx_id隐藏列,该隐藏列记录着最后改动该记录的事务id。那么如果在当前事务中新插入一条聚簇索引记录后,该记录的trx_id隐藏列代表的的就是当前事务的事务id,如果其他事务此时想对该记录添加S锁或者X锁时,首先会看一下该记录的trx_id隐藏列代表的事务是否是当前的活跃事务,如果是的话,那么就帮助当前事务创建一个X锁(也就是为当前事务创建一个锁结构,is_waiting属性是false),然后自己进入等待状态(也就是为自己也创建一个锁结构,is_waiting属性是true)。 - 情景二:对于二级索引记录来说,本身并没有trx_id隐藏列,但是在二级索引页面的Page Header部分有一个PAGE_MAX_TRX_ID属性,该属性代表对该页面做改动的最大的事务id,如果PAGE_MAX_TRX_ID属性值小于当前最小的活跃事务id,那么说明对该页面做修改的事务都已经提交了,否则就需要在页面中定位到对应的二级索引记录,然后回表找到它对应的聚簇索引记录,然后再重复情景一的做法。\n通过上面的叙述我们知道,一个事务对新插入的记录可以不显式的加锁(生成一个锁结构),但是由于事务id这个牛逼的东东的存在,相当于加了一个隐式锁。别的事务在对这条记录加S锁或者X锁时,由于隐式锁的存在,会先帮助当前事务生成一个锁结构,然后自己再生成一个锁结构后进入等待状态。\n小贴士:除了插入意向锁,在一些特殊情况下INSERT还会获取一些锁,我们稍后介绍。\nInnoDB锁的内存结构 # 我们前面说对一条记录加锁的本质就是在内存中创建一个锁结构与之关联,那么是不是一个事务对多条记录加锁,就要创建多个锁结构呢?比方说事务T1要执行下面这个语句: ```\n事务T1\nSELECT * FROM hero LOCK IN SHARE MODE; ``` 很显然这条语句需要为hero表中的所有记录进行加锁,那是不是需要为每条记录都生成一个锁结构呢?其实理论上创建多个锁结构没问题,反而更容易理解,但是谁知道你在一个事务里想对多少记录加锁呢,如果一个事务要获取10000条记录的锁,要生成10000个这样的结构也太亏了吧!所以设计InnoDB的大佬本着勤俭节约的传统美德,决定在对不同记录加锁时,如果符合下面这些条件: - 在同一个事务中进行加锁操作 - 被加锁的记录在同一个页面中 - 加锁的类型是一样的 - 等待状态是一样的\n那么这些记录的锁就可以被放到一个锁结构中。当然,这么空口白牙的说有点儿抽象,我们还是画个图来看看InnoDB存储引擎中的锁结构具体长什么样吧:\n我们看看这个结构里边的各种信息都是干嘛的:\n锁所在的事务信息:\n不论是表锁还是行锁,都是在事务执行过程中生成的,哪个事务生成了这个锁结构,这里就记载着这个事务的信息。\n小贴士:实际上这个所谓的锁所在的事务信息在内存结构中只是一个指针而已,所以不会占用多大内存空间,通过指针可以找到内存中关于该事务的更多信息,比方说事务id是什么。下面介绍的所谓的索引信息其实也是一个指针。\n索引信息:\n对于行锁来说,需要记录一下加锁的记录是属于哪个索引的。\n表锁/行锁信息:\n表锁结构和行锁结构在这个位置的内容是不同的:\n+ 表锁:\n记载着这是对哪个表加的锁,还有其他的一些信息。\n+ 行锁:\n记载了三个重要的信息: - Space ID:记录所在表空间。 - Page Number:记录所在页号。 - n_bits:对于行锁来说,一条记录就对应着一个比特位,一个页面中包含很多记录,用不同的比特位来区分到底是哪一条记录加了锁。为此在行锁结构的末尾放置了一堆比特位,这个n_bits属性代表使用了多少比特位。\n小贴士:并不是该页面中有多少记录,n_bits属性的值就是多少。为了让之后在页面中插入了新记录后也不至于重新分配锁结构,所以n_bits的值一般都比页面中记录条数多一些。 - type_mode:\n这是一个32位的数,被分成了lock_mode、lock_type和rec_lock_type三个部分,如图所示:\n+ 锁的模式(lock_mode),占用低4位,可选的值如下:\n+ `LOCK_IS`(十进制的`0`):表示共享意向锁,也就是`IS锁`。 + `LOCK_IX`(十进制的`1`):表示独占意向锁,也就是`IX锁`。 + `LOCK_S`(十进制的`2`):表示共享锁,也就是`S锁`。 + `LOCK_X`(十进制的`3`):表示独占锁,也就是`X锁`。 + `LOCK_AUTO_INC`(十进制的`4`):表示`AUTO-INC锁`。 小贴士:在InnoDB存储引擎中,LOCK_IS,LOCK_IX,LOCK_AUTO_INC都算是表级锁的模式,LOCK_S和LOCK_X既可以算是表级锁的模式,也可以是行级锁的模式。\n+ 锁的类型(lock_type),占用第5~8位,不过现阶段只有第5位和第6位被使用:\n+ `LOCK_TABLE`(十进制的`16`),也就是当第5个比特位置为1时,表示表级锁。 + `LOCK_REC`(十进制的`32`),也就是当第6个比特位置为1时,表示行级锁。 + 行锁的具体类型(rec_lock_type),使用其余的位来表示。只有在lock_type的值为LOCK_REC时,也就是只有在该锁为行级锁时,才会被细分为更多的类型:\n+ `LOCK_ORDINARY`(十进制的`0`):表示`next-key锁`。 + `LOCK_GAP`(十进制的`512`):也就是当第10个比特位置为1时,表示`gap锁`。 + `LOCK_REC_NOT_GAP`(十进制的`1024`):也就是当第11个比特位置为1时,表示`正经记录锁`。 + `LOCK_INSERT_INTENTION`(十进制的`2048`):也就是当第12个比特位置为1时,表示插入意向锁。 + 其他的类型:还有一些不常用的类型我们就不多说了。 怎么还没看见is_waiting属性呢?这主要还是设计InnoDB的大佬太抠门了,一个比特位也不想浪费,所以他们把is_waiting属性也放到了type_mode这个32位的数字中: - LOCK_WAIT(十进制的256) :也就是当第9个比特位置为1时,表示is_waiting为true,也就是当前事务尚未获取到锁,处在等待状态;当这个比特位为0时,表示is_waiting为false,也就是当前事务获取锁成功。\n其他信息:\n为了更好的管理系统运行过程中生成的各种锁结构而设计了各种哈希表和链表,为了简化讨论,我们忽略这部分信息~\n一堆比特位:\n如果是行锁结构的话,在该结构末尾还放置了一堆比特位,比特位的数量是由上面提到的n_bits属性表示的。我们前面介绍InnoDB记录结构的时候说过,页面中的每条记录在记录头信息中都包含一个heap_no属性,伪记录Infimum的heap_no值为0,Supremum的heap_no值为1,之后每插入一条记录,heap_no值就增1。锁结构最后的一堆比特位就对应着一个页面中的记录,一个比特位映射一个heap_no,不过为了编码方便,映射方式有点怪:\n小贴士:这么怪的映射方式纯粹是为了敲代码方便,大家不要大惊小怪,只需要知道一个比特位映射到页内的一条记录就好了。\n可能上面的描述大家觉得还是有些抽象,我们还是举个例子说明一下。比方说现在有两个事务T1和T2想对hero表中的记录进行加锁,hero表中记录比较少,假设这些记录都存储在所在的表空间号为67,页号为3的页面上,那么如果:\nT1想对number值为15的这条记录加S型正常记录锁,在对记录加行锁之前,需要先加表级别的IS锁,也就是会生成一个表级锁的内存结构,不过我们这里不关心表级锁,所以就忽略掉了~ 接下来分析一下生成行锁结构的过程:\n+ 事务`T1`要进行加锁,所以锁结构的`锁所在事务信息`指的就是`T1`。 + 直接对聚簇索引进行加锁,所以索引信息指的其实就是`PRIMARY`索引。 + 由于是行锁,所以接下来需要记录的是三个重要信息:\n+ `Space ID`:表空间号为`67`。 + `Page Number`:页号为`3`。 + n_bits:我们的hero表中现在只插入了5条用户记录,但是在初始分配比特位时会多分配一些,这主要是为了在之后新增记录时不用频繁分配比特位。其实计算n_bits有一个公式:\nn_bits = (1 + ((n_recs + LOCK_PAGE_BITMAP_MARGIN) / 8)) * 8 其中n_recs指的是当前页面中一共有多少条记录(算上伪记录和在垃圾链表中的记录),比方说现在hero表一共有7条记录(5条用户记录和2条伪记录),所以n_recs的值就是7,LOCK_PAGE_BITMAP_MARGIN是一个固定的值64,所以本次加锁的n_bits值就是: n_bits = (1 + ((7 + 64) / 8)) * 8 = 72\n+ type_mode是由三部分组成的:\n+ `lock_mode`,这是对记录加`S锁`,它的值为`LOCK_S`。 + `lock_type`,这是对记录进行加锁,也就是行锁,所以它的值为`LOCK_REC`。 + `rec_lock_type`,这是对记录加`正经记录锁`,也就是类型为`LOCK_REC_NOT_GAP`的锁。另外,由于当前没有其他事务对该记录加锁,所以应当获取到锁,也就是`LOCK_WAIT`代表的二进制位应该是0。 综上所属,此次加锁的type_mode的值应该是:\ntype_mode = LOCK_S | LOCK_REC | LOCK_REC_NOT_GAP 也就是: type_mode = 2 | 32 | 1024 = 1058\n+ 其他信息\n略~\n+ 一堆比特位\n因为number值为15的记录heap_no值为5,根据上面列举的比特位和heap_no的映射图来看,应该是第一个字节从低位往高位数第6个比特位被置为1,就像这样:\n综上所述,事务T1为number值为5的记录加锁生成的锁结构就如下图所示:\nT2想对number值为3、8、15的这三条记录加X型的next-key锁,在对记录加行锁之前,需要先加表级别的IX锁,也就是会生成一个表级锁的内存结构,不过我们这里不关心表级锁,所以就忽略掉了~\n现在T2要为3条记录加锁,number为3、8的两条记录由于没有其他事务加锁,所以可以成功获取这条记录的X型next-key锁,也就是生成的锁结构的is_waiting属性为false;但是number为15的记录已经被T1加了S型正经记录锁,T2是不能获取到该记录的X型next-key锁的,也就是生成的锁结构的is_waiting属性为true。因为等待状态不相同,所以这时候会生成两个锁结构。这两个锁结构中相同的属性如下: - 事务T2要进行加锁,所以锁结构的锁所在事务信息指的就是T2。 - 直接对聚簇索引进行加锁,所以索引信息指的其实就是PRIMARY索引。 - 由于是行锁,所以接下来需要记录是三个重要信息: - Space ID:表空间号为67。 - Page Number:页号为3。 - n_bits:此属性生成策略同T1中一样,该属性的值为72。 - type_mode是由三部分组成的: - lock_mode,这是对记录加X锁,它的值为LOCK_X。 - lock_type,这是对记录进行加锁,也就是行锁,所以它的值为LOCK_REC。 - rec_lock_type,这是对记录加next-key锁,也就是类型为LOCK_ORDINARY的锁。\n+ 其他信息\n略~\n不同的属性如下:\n+ 为number为3、8的记录生成的锁结构:\n+ type_mode值。\n由于可以获取到锁,所以is_waiting属性为false,也就是LOCK_WAIT代表的二进制位被置0。所以: type_mode = LOCK_X | LOCK_REC |LOCK_ORDINARY 也就是 type_mode = 3 | 32 | 0 = 35\n+ 一堆比特位\n因为number值为3、8的记录heap_no值分别为3、4,根据上面列举的比特位和heap_no的映射图来看,应该是第一个字节从低位往高位数第4、5个比特位被置为1,就像这样:\n综上所述,事务T2为number值为3、8两条记录加锁生成的锁结构就如下图所示:\n+ 为number为15的记录生成的锁结构:\n+ type_mode值。\n由于可以获取到锁,所以is_waiting属性为true,也就是LOCK_WAIT代表的二进制位被置1。所以: type_mode = LOCK_X | LOCK_REC |LOCK_ORDINARY | LOCK_WAIT 也就是 type_mode = 3 | 32 | 0 | 256 = 291\n+ 一堆比特位\n因为number值为15的记录heap_no值为5,根据上面列举的比特位和heap_no的映射图来看,应该是第一个字节从低位往高位数第6个比特位被置为1,就像这样:\n综上所述,事务T2为number值为15的记录加锁生成的锁结构就如下图所示:\n综上所述,事务T1先获取number值为15的S型正经记录锁,然后事务T2获取number值为3、8、15的X型正经记录锁共需要生成3个锁结构。噗~ 关于锁结构我本来就想写一点点的,没想到一些起来就停不下了,大家乐呵乐呵看~\n小贴士:上面事务T2在对number值分别为3、8、15这三条记录加锁的情景中,是按照先对number值为3的记录加锁、再对number值为8的记录加锁,最后对number值为15的记录加锁的顺序进行的,如果我们一开始就对number值为15的记录加锁,那么该事务在为number值为15的记录生成一个锁结构后,直接就进入等待状态,就不为number值为3、8的两条记录生成锁结构了。在事务T1提交后会把在number值为15的记录上获取的锁释放掉,然后事务T2就可以获取该记录上的锁,这时再对number值为3、8的两条记录加锁时,就可以复用之前为number值为15的记录加锁时生成的锁结构了。\n更多内容 # 欢迎各位关注我的微信公众号「我们都是小青蛙」,那里有更多技术干货与特色扯犊子文章(后续会在公众号中发布各种不同的语句具体的加锁情况分析,敬请期待)。\n"},{"id":9,"href":"/zh/docs/technology/MySQL/MySQL%E6%98%AF%E6%80%8E%E6%A0%B7%E8%BF%90%E8%A1%8C%E7%9A%84/%E7%AC%AC24%E7%AB%A0_%E4%B8%80%E6%9D%A1%E8%AE%B0%E5%BD%95%E7%9A%84%E5%A4%9A%E5%B9%85%E9%9D%A2%E5%AD%94-%E4%BA%8B%E5%8A%A1%E7%9A%84%E9%9A%94%E7%A6%BB%E7%BA%A7%E5%88%AB%E4%B8%8EMVCC/","title":"第24章_一条记录的多幅面孔-事务的隔离级别与MVCC","section":"My Sql是怎样运行的","content":"第24章 一条记录的多幅面孔-事务的隔离级别与MVCC\n事前准备 # 为了故事的顺利发展,我们需要创建一个表: CREATE TABLE hero ( number INT, name VARCHAR(100), country varchar(100), PRIMARY KEY (number) ) Engine=InnoDB CHARSET=utf8; 小贴士:注意我们把这个hero表的主键命名为number,而不是id,主要是想和后边要用到的事务id做区别,大家不用大惊小怪~ 然后向这个表里插入一条数据: INSERT INTO hero VALUES(1, '刘备', '蜀'); 现在表里的数据就是这样的: mysql\u0026gt; SELECT * FROM hero; +--------+--------+---------+ | number | name | country | +--------+--------+---------+ | 1 | 刘备 | 蜀 | +--------+--------+---------+ 1 row in set (0.00 sec)\n事务隔离级别 # 我们知道MySQL是一个客户端/服务器架构的软件,对于同一个服务器来说,可以有若干个客户端与之连接,每个客户端与服务器连接上之后,就可以称之为一个会话(Session)。每个客户端都可以在自己的会话中向服务器发出请求语句,一个请求语句可能是某个事务的一部分,也就是对于服务器来说可能同时处理多个事务。在事务简介的章节中我们说过事务有一个称之为隔离性的特性,理论上在某个事务对某个数据进行访问时,其他事务应该进行排队,当该事务提交之后,其他事务才可以继续访问这个数据。但是这样子的话对性能影响太大,我们既想保持事务的隔离性,又想让服务器在处理访问同一数据的多个事务时性能尽量高些,鱼和熊掌不可得兼,舍一部分隔离性而取性能者也。\n事务并发执行遇到的问题 # 怎么个舍弃法呢?我们先得看一下访问相同数据的事务在不保证串行执行(也就是执行完一个再执行另一个)的情况下可能会出现哪些问题:\n脏写(Dirty Write)\n如果一个事务修改了另一个未提交事务修改过的数据,那就意味着发生了脏写,示意图如下:\n如上图,Session A和Session B各开启了一个事务,Session B中的事务先将number列为1的记录的name列更新为'关羽',然后Session A中的事务接着又把这条number列为1的记录的name列更新为张飞。如果之后Session B中的事务进行了回滚,那么Session A中的更新也将不复存在,这种现象就称之为脏写。这时Session A中的事务就很懵逼,我明明把数据更新了,最后也提交事务了,怎么到最后说自己什么也没干呢?\n脏读(Dirty Read)\n如果一个事务读到了另一个未提交事务修改过的数据,那就意味着发生了脏读,示意图如下:\n如上图,Session A和Session B各开启了一个事务,Session B中的事务先将number列为1的记录的name列更新为'关羽',然后Session A中的事务再去查询这条number为1的记录,如果du到列name的值为'关羽',而Session B中的事务稍后进行了回滚,那么Session A中的事务相当于读到了一个不存在的数据,这种现象就称之为脏读。\n不可重复读(Non-Repeatable Read)\n如果一个事务只能读到另一个已经提交的事务修改过的数据,并且其他事务每对该数据进行一次修改并提交后,该事务都能查询得到最新值,那就意味着发生了不可重复读,示意图如下:\n如上图,我们在Session B中提交了几个隐式事务(注意是隐式事务,意味着语句结束事务就提交了),这些事务都修改了number列为1的记录的列name的值,每次事务提交之后,如果Session A中的事务都可以查看到最新的值,这种现象也被称之为不可重复读。\n幻读(Phantom)\n如果一个事务先根据某些条件查询出一些记录,之后另一个事务又向表中插入了符合这些条件的记录,原先的事务再次按照该条件查询时,能把另一个事务插入的记录也读出来,那就意味着发生了幻读,示意图如下:\n如上图,Session A中的事务先根据条件number \u0026gt; 0这个条件查询表hero,得到了name列值为'刘备'的记录;之后Session B中提交了一个隐式事务,该事务向表hero中插入了一条新记录;之后Session A中的事务再根据相同的条件number \u0026gt; 0查询表hero,得到的结果集中包含Session B中的事务新插入的那条记录,这种现象也被称之为幻读。\n有的同学会有疑问,那如果Session B中是删除了一些符合number \u0026gt; 0的记录而不是插入新记录,那Session A中之后再根据number \u0026gt; 0的条件读取的记录变少了,这种现象算不算幻读呢?明确说一下,这种现象不属于幻读,幻读强调的是一个事务按照某个相同条件多次读取记录时,后读取时读到了之前没有读到的记录。\n小贴士:那对于先前已经读到的记录,之后又读取不到这种情况,算什么呢?其实这相当于对每一条记录都发生了不可重复读的现象。幻读只是重点强调了读取到了之前读取没有获取到的记录。\nSQL标准中的四种隔离级别 # 我们上面介绍了几种并发事务执行过程中可能遇到的一些问题,这些问题也有轻重缓急之分,我们给这些问题按照严重性来排一下序: 脏写 \u0026gt; 脏读 \u0026gt; 不可重复读 \u0026gt; 幻读 我们上面所说的舍弃一部分隔离性来换取一部分性能在这里就体现在:设立一些隔离级别,隔离级别越低,越严重的问题就越可能发生。有一帮人(并不是设计MySQL的大佬们)制定了一个所谓的SQL标准,在标准中设立了4个隔离级别: - READ UNCOMMITTED:未提交读。 - READ COMMITTED:已提交读。 - REPEATABLE READ:可重复读。 - SERIALIZABLE:可串行化。\nSQL标准中规定,针对不同的隔离级别,并发事务可以发生不同严重程度的问题,具体情况如下: 隔离级别 脏读 不可重复读 幻读 READ UNCOMMITTED Possible Possible Possible READ COMMITTED Not Possible Possible Possible REPEATABLE READ Not Possible Not Possible Possible SERIALIZABLE Not Possible Not Possible Not Possible 也就是说: - READ UNCOMMITTED隔离级别下,可能发生脏读、不可重复读和幻读问题。 - READ COMMITTED隔离级别下,可能发生不可重复读和幻读问题,但是不可以发生脏读问题。 - REPEATABLE READ隔离级别下,可能发生幻读问题,但是不可以发生脏读和不可重复读的问题。 - SERIALIZABLE隔离级别下,各种问题都不可以发生。\n脏写是怎么回事儿?怎么里边都没写呢?这是因为脏写这个问题太严重了,不论是哪种隔离级别,都不允许脏写的情况发生。\nMySQL中支持的四种隔离级别 # 不同的数据库厂商对SQL标准中规定的四种隔离级别支持不一样,比方说Oracle就只支持READ COMMITTED和SERIALIZABLE隔离级别。本书中所讨论的MySQL虽然支持4种隔离级别,但与SQL标准中所规定的各级隔离级别允许发生的问题却有些出入,MySQL在REPEATABLE READ隔离级别下,是可以禁止幻读问题的发生的(关于如何禁止我们之后会详细说明的)。\nMySQL的默认隔离级别为REPEATABLE READ,我们可以手动修改一下事务的隔离级别。\n如何设置事务的隔离级别 # 我们可以通过下面的语句修改事务的隔离级别: SET [GLOBAL|SESSION] TRANSACTION ISOLATION LEVEL level; 其中的level可选值有4个: level: { REPEATABLE READ | READ COMMITTED | READ UNCOMMITTED | SERIALIZABLE } 设置事务的隔离级别的语句中,在SET关键字后可以放置GLOBAL关键字、SESSION关键字或者什么都不放,这样会对不同范围的事务产生不同的影响,具体如下:\n使用GLOBAL关键字(在全局范围影响):\n比方说这样: SET GLOBAL TRANSACTION ISOLATION LEVEL SERIALIZABLE; 则: - 只对执行完该语句之后产生的会话起作用。 - 当前已经存在的会话无效。\n使用SESSION关键字(在会话范围影响):\n比方说这样: SET SESSION TRANSACTION ISOLATION LEVEL SERIALIZABLE; 则: - 对当前会话的所有后续的事务有效 - 该语句可以在已经开启的事务中间执行,但不会影响当前正在执行的事务。 - 如果在事务之间执行,则对后续的事务有效。\n上述两个关键字都不用(只对执行语句后的下一个事务产生影响):\n比方说这样: SET TRANSACTION ISOLATION LEVEL SERIALIZABLE; 则: - 只对当前会话中下一个即将开启的事务有效。 - 下一个事务执行完后,后续事务将恢复到之前的隔离级别。 - 该语句不能在已经开启的事务中间执行,会报错的。\n如果我们在服务器启动时想改变事务的默认隔离级别,可以修改启动参数transaction-isolation的值,比方说我们在启动服务器时指定了--transaction-isolation=SERIALIZABLE,那么事务的默认隔离级别就从原来的REPEATABLE READ变成了SERIALIZABLE。\n想要查看当前会话默认的隔离级别可以通过查看系统变量transaction_isolation的值来确定: mysql\u0026gt; SHOW VARIABLES LIKE 'transaction_isolation'; +-----------------------+-----------------+ | Variable_name | Value | +-----------------------+-----------------+ | transaction_isolation | REPEATABLE-READ | +-----------------------+-----------------+ 1 row in set (0.02 sec) 或者使用更简便的写法: mysql\u0026gt; SELECT @@transaction_isolation; +-------------------------+ | @@transaction_isolation | +-------------------------+ | REPEATABLE-READ | +-------------------------+ 1 row in set (0.00 sec)\n小贴士:我们也可以使用设置系统变量transaction_isolation的方式来设置事务的隔离级别,不过我们前面介绍过,一般系统变量只有GLOBAL和SESSION两个作用范围,而这个transaction_isolation却有3个(与上面 SET TRANSACTION ISOLATION LEVEL的语法相对应),设置语法上有些特殊,更多详情可以参见文档:https://dev.mysql.com/doc/refman/5.7/en/server-system-variables.html#sysvar_transaction_isolation。另外,transaction_isolation是在MySQL 5.7.20的版本中引入来替换tx_isolation的,如果你使用的是之前版本的MySQL,请将上述用到系统变量transaction_isolation的地方替换为tx_isolation。\nMVCC原理 # 版本链 # 我们前面说过,对于使用InnoDB存储引擎的表来说,它的聚簇索引记录中都包含两个必要的隐藏列(row_id并不是必要的,我们创建的表中有主键或者非NULL的UNIQUE键时都不会包含row_id列): - trx_id:每次一个事务对某条聚簇索引记录进行改动时,都会把该事务的事务id赋值给trx_id隐藏列。 - roll_pointer:每次对某条聚簇索引记录进行改动时,都会把旧的版本写入到undo日志中,然后这个隐藏列就相当于一个指针,可以通过它来找到该记录修改前的信息。\n比方说我们的表hero现在只包含一条记录: mysql\u0026gt; SELECT * FROM hero; +--------+--------+---------+ | number | name | country | +--------+--------+---------+ | 1 | 刘备 | 蜀 | +--------+--------+---------+ 1 row in set (0.07 sec) 假设插入该记录的事务id为80,那么此刻该条记录的示意图如下所示:\n小贴士:实际上insert undo只在事务回滚时起作用,当事务提交后,该类型的undo日志就没用了,它占用的Undo Log Segment也会被系统回收(也就是该undo日志占用的Undo页面链表要么被重用,要么被释放)。虽然真正的insert undo日志占用的存储空间被释放了,但是roll_pointer的值并不会被清除,roll_pointer属性占用7个字节,第一个比特位就标记着它指向的undo日志的类型,如果该比特位的值为1时,就代表着它zhi向的undo日志类型为insert undo。所以我们之后在画图时都会把insert undo给去掉,大家留意一下就好了。\n假设之后两个事务id分别为100、200的事务对这条记录进行UPDATE操作,操作流程如下:\n小贴士:能不能在两个事务中交叉更新同一条记录呢?这不就是一个事务修改了另一个未提交事务修改过的数据,沦为了脏写了么?InnoDB使用锁来保证不会有脏写情况的发生,也就是在第一个事务更新了某条记录后,就会给这条记录加锁,另一个事务再次更新时就需要等待第一个事务提交了,把锁释放之后才可以继续更新。关于锁的更多细节我们后续的文章中再介绍~ 每次对记录进行改动,都会记录一条undo日志,每条undo日志也都有一个roll_pointer属性(INSERT操作对应的undo日志没有该属性,因为该记录并没有更早的版本),可以将这些undo日志都连起来,串成一个链表,所以现在的情况就像下图一样:\n对该记录每次更新后,都会将旧值放到一条undo日志中,就算是该记录的一个旧版本,随着更新次数的增多,所有的版本都会被roll_pointer属性连接成一个链表,我们把这个链表称之为版本链,版本链的头节点就是当前记录最新的值。另外,每个版本中还包含生成该版本时对应的事务id,这个信息很重要,我们稍后就会用到。\nReadView # 对于使用READ UNCOMMITTED隔离级别的事务来说,由于可以读到未提交事务修改过的记录,所以直接读取记录的最新版本就好了;对于使用SERIALIZABLE隔离级别的事务来说,设计InnoDB的大佬规定使用加锁的方式来访问记录(加锁是什么我们后续文章中说);对于使用READ COMMITTED和REPEATABLE READ隔离级别的事务来说,都必须保证读到已经提交了的事务修改过的记录,也就是说假如另一个事务已经修改了记录但是尚未提交,是不能直接读取最新版本的记录的,核心问题就是:需要判断一下版本链中的哪个版本是当前事务可见的。为此,设计InnoDB的大佬提出了一个ReadView的概念,这个ReadView中主要包含4个比较重要的内容: - m_ids:表示在生成ReadView时当前系统中活跃的读写事务的事务id列表。 - min_trx_id:表示在生成ReadView时当前系统中活跃的读写事务中最小的事务id,也就是m_ids中的最小值。 - max_trx_id:表示生成ReadView时系统中应该分配给下一个事务的id值。\n``` 小贴士:注意max_trx_id并不是m_ids中的最大值,事务id是递增分配的。比方说现在有id为1,2,3这三个事务,之后id为3的事务提交了。那么一个新的读事务在生成ReadView时,m_ids就包括1和2,min_trx_id的值就是1,max_trx_id的值就是4。 ``` creator_trx_id:表示生成该ReadView的事务的事务id。\n小贴士:我们前面说过,只有在对表中的记录做改动时(执行INSERT、DELETE、UPDATE这些语句时)才会为事务分配事务id,否则在一个只读事务中的事务id值都默认为0。\n有了这个ReadView,这样在访问某条记录时,只需要按照下面的步骤判断记录的某个版本是否可见: - 如果被访问版本的trx_id属性值与ReadView中的creator_trx_id值相同,意味着当前事务在访问它自己修改过的记录,所以该版本可以被当前事务访问。 - 如果被访问版本的trx_id属性值小于ReadView中的min_trx_id值,表明生成该版本的事务在当前事务生成ReadView前已经提交,所以该版本可以被当前事务访问。 - 如果被访问版本的trx_id属性值大于ReadView中的max_trx_id值,表明生成该版本的事务在当前事务生成ReadView后才开启,所以该版本不可以被当前事务访问。 - 如果被访问版本的trx_id属性值在ReadView的min_trx_id和max_trx_id之间,那就需要判断一下trx_id属性值是不是在m_ids列表中,如果在,说明创建ReadView时生成该版本的事务还是活跃的,该版本不可以被访问;如果不在,说明创建ReadView时生成该版本的事务已经被提交,该版本可以被访问。\n如果某个版本的数据对当前事务不可见的话,那就顺着版本链找到下一个版本的数据,继续按照上面的步骤判断可见性,依此类推,直到版本链中的最后一个版本。如果最后一个版本也不可见的话,那么就意味着该条记录对该事务完全不可见,查询结果就不包含该记录。\n在MySQL中,READ COMMITTED和REPEATABLE READ隔离级别的的一个非常大的区别就是它们生成ReadView的时机不同。我们还是以表hero为例来,假设现在表hero中只有一条由事务id为80的事务插入的一条记录: mysql\u0026gt; SELECT * FROM hero; +--------+--------+---------+ | number | name | country | +--------+--------+---------+ | 1 | 刘备 | 蜀 | +--------+--------+---------+ 1 row in set (0.07 sec) 接下来看一下READ COMMITTED和REPEATABLE READ所谓的生成ReadView的时机不同到底不同在哪里。\nREAD COMMITTED —— 每次读取数据前都生成一个ReadView # 比方说现在系统里有两个事务id分别为100、200的事务在执行: ```\nTransaction 100\nBEGIN;\nUPDATE hero SET name = \u0026lsquo;关羽\u0026rsquo; WHERE number = 1;\nUPDATE hero SET name = \u0026lsquo;张飞\u0026rsquo; WHERE number = 1; ``\nTransaction 200\nBEGIN;\n更新了一些别的表的记录\n\u0026hellip; `` 小贴士:再次强调一遍,事务执行过程中,只有在第一次真正修改记录时(比如使用INSERT、DELETE、UPDATE语句),才会被分配一个单独的事务id,这个事务id是递增的。所以我们才在Transaction 200中更新一些别的表的记录,目的是让它分配事务id。 ``` 此刻,表hero中number为1的记录得到的版本链表如下所示:\n假设现在有一个使用READ COMMITTED隔离级别的事务开始执行: ```\n使用READ COMMITTED隔离级别的事务\nBEGIN;\nSELECT1:Transaction 100、200未提交\nSELECT * FROM hero WHERE number = 1; # 得到的列name的值为\u0026rsquo;刘备\u0026rsquo; ``` 这个SELECT1的执行过程如下: - 在执行SELECT语句时会先生成一个ReadView,ReadView的m_ids列表的内容就是[100, 200],min_trx_id为100,max_trx_id为201,creator_trx_id为0。 - 然后从版本链中挑选可见的记录,从图中可以看出,最新版本的列name的内容是'张飞',该版本的trx_id值为100,在m_ids列表内,所以不符合可见性要求,根据roll_pointer跳到下一个版本。 - 下一个版本的列name的内容是'关羽',该版本的trx_id值也为100,也在m_ids列表内,所以也不符合要求,继续跳到下一个版本。 - 下一个版本的列name的内容是'刘备',该版本的trx_id值为80,小于ReadView中的min_trx_id值100,所以这个版本是符合要求的,最后返回给用户的版本就是这条列name为'刘备'的记录。\n之后,我们把事务id为100的事务提交一下,就像这样:\nTransaction 100 BEGIN; UPDATE hero SET name = \u0026#39;关羽\u0026#39; WHERE number = 1; UPDATE hero SET name = \u0026#39;张飞\u0026#39; WHERE number = 1; COMMIT; `然后再到`事务id`为`200`的事务中更新一下表`hero`中`number`为`1`的记录:` Transaction 200 BEGIN; 更新了一些别的表的记录 ... UPDATE hero SET name = \u0026#39;赵云\u0026#39; WHERE number = 1; UPDATE hero SET name = \u0026#39;诸葛亮\u0026#39; WHERE number = 1; ``` 此刻,表`hero`中`number`为`1`的记录的版本链就长这样: ![](img/24-09.png) 然后再到刚才使用`READ COMMITTED`隔离级别的事务中继续查找这个`number`为`1`的记录,如下: ``` 使用READ COMMITTED隔离级别的事务 BEGIN; SELECT1:Transaction 100、200均未提交 SELECT * FROM hero WHERE number = 1; \\# 得到的列name的值为\u0026#39;刘备\u0026#39; SELECT2:Transaction 100提交,Transaction 200未提交 SELECT * FROM hero WHERE number = 1; \\# 得到的列name的值为\u0026#39;张飞\u0026#39; ``` 这个`SELECT2`的执行过程如下: - 在执行`SELECT`语句时会**又会单独生成**一个`ReadView`,该`ReadView`的`m_ids`列表的内容就是`[200]`(`事务id`为`100`的那个事务已经提交了,所以再次生成快照时就没有它了),`min_trx_id`为`200`,`max_trx_id`为`201`,`creator_trx_id`为`0`。 - 然后从版本链中挑选可见的记录,从图中可以看出,最新版本的列`name`的内容是`\u0026#39;诸葛亮\u0026#39;`,该版本的`trx_id`值为`200`,在`m_ids`列表内,所以不符合可见性要求,根据`roll_pointer`跳到下一个版本。 - 下一个版本的列`name`的内容是`\u0026#39;赵云\u0026#39;`,该版本的`trx_id`值为`200`,也在`m_ids`列表内,所以也不符合要求,继续跳到下一个版本。 - 下一个版本的列`name`的内容是`\u0026#39;张飞\u0026#39;`,该版本的`trx_id`值为`100`,小于`ReadView`中的`min_trx_id`值`200`,所以这个版本是符合要求的,最后返回给用户的版本就是这条列`name`为`\u0026#39;张飞\u0026#39;`的记录。 以此类推,如果之后`事务id`为`200`的记录也提交了,再此在使用`READ COMMITTED`隔离级别的事务中查询表`hero`中`number`值为`1`的记录时,得到的结果就是`\u0026#39;诸葛亮\u0026#39;`了,具体流程我们就不分析了。总结一下就是:**使用READ COMMITTED隔离级别的事务在每次查询开始时都会生成一个独立的ReadView**。 ### REPEATABLE READ —— 在第一次读取数据时生成一个ReadView 对于使用`REPEATABLE READ`隔离级别的事务来说,只会在第一次执行查询语句时生成一个`ReadView`,之后的查询就不会重复生成了。我们还是用例子看一下是什么效果。 比方说现在系统里有两个`事务id`分别为`100`、`200`的事务在执行: ``` Transaction 100 BEGIN; UPDATE hero SET name = \u0026#39;关羽\u0026#39; WHERE number = 1; UPDATE hero SET name = \u0026#39;张飞\u0026#39; WHERE number = 1; ``` Transaction 200\nBEGIN;\n更新了一些别的表的记录\n\u0026hellip; ```\n此刻,表hero中number为1的记录得到的版本链表如下所示:\n假设现在有一个使用REPEATABLE READ隔离级别的事务开始执行: ```\n使用REPEATABLE READ隔离级别的事务\nBEGIN;\nSELECT1:Transaction 100、200未提交\nSELECT * FROM hero WHERE number = 1; # 得到的列name的值为\u0026rsquo;刘备\u0026rsquo; ``` 这个SELECT1的执行过程如下:\n在执行SELECT语句时会先生成一个ReadView,ReadView的m_ids列表的内容就是[100, 200],min_trx_id为100,max_trx_id为201,creator_trx_id为0。 然后从版本链中挑选可见的记录,从图中可以看出,最新版本的列name的内容是'张飞',该版本的trx_id值为100,在m_ids列表内,所以不符合可见性要求,根据roll_pointer跳到下一个版本。 下一个版本的列name的内容是'关羽',该版本的trx_id值也为100,也在m_ids列表内,所以也不符合要求,继续跳到下一个版本。 下一个版本的列name的内容是'刘备',该版本的trx_id值为80,小于ReadView中的min_trx_id值100,所以这个版本是符合要求的,最后返回给用户的版本就是这条列name为'刘备'的记录。 之后,我们把事务id为100的事务提交一下,就像这样: ```\nTransaction 100\nBEGIN;\nUPDATE hero SET name = \u0026lsquo;关羽\u0026rsquo; WHERE number = 1;\nUPDATE hero SET name = \u0026lsquo;张飞\u0026rsquo; WHERE number = 1;\nCOMMIT; 然后再到事务id为200的事务中更新一下表hero中number为1的记录:\nTransaction 200\nBEGIN;\n更新了一些别的表的记录\n\u0026hellip;\nUPDATE hero SET name = \u0026lsquo;赵云\u0026rsquo; WHERE number = 1;\nUPDATE hero SET name = \u0026lsquo;诸葛亮\u0026rsquo; WHERE number = 1; ``` 此刻,表hero中number为1的记录的版本链就长这样:\n然后再到刚才使用REPEATABLE READ隔离级别的事务中继续查找这个number为1的记录,如下: ```\n使用REPEATABLE READ隔离级别的事务\nBEGIN;\nSELECT1:Transaction 100、200均未提交\nSELECT * FROM hero WHERE number = 1; # 得到的列name的值为\u0026rsquo;刘备\u0026rsquo;\nSELECT2:Transaction 100提交,Transaction 200未提交\nSELECT * FROM hero WHERE number = 1; # 得到的列name的值仍为\u0026rsquo;刘备\u0026rsquo; ``` 这个SELECT2的执行过程如下: - 因为当前事务的隔离级别为REPEATABLE READ,而之前在执行SELECT1时已经生成过ReadView了,所以此时直接复用之前的ReadView,之前的ReadView的m_ids列表的内容就是[100, 200],min_trx_id为100,max_trx_id为201,creator_trx_id为0。 - 然后从版本链中挑选可见的记录,从图中可以看出,最新版本的列name的内容是'诸葛亮',该版本的trx_id值为200,在m_ids列表内,所以不符合可见性要求,根据roll_pointer跳到下一个版本。 - 下一个版本的列name的内容是'赵云',该版本的trx_id值为200,也在m_ids列表内,所以也不符合要求,继续跳到下一个版本。 - 下一个版本的列name的内容是'张飞',该版本的trx_id值为100,而m_ids列表中是包含值为100的事务id的,所以该版本也不符合要求,同理下一个列name的内容是'关羽'的版本也不符合要求。继续跳到下一个版本。 - 下一个版本的列name的内容是'刘备',该版本的trx_id值为80,小于ReadView中的min_trx_id值100,所以这个版本是符合要求的,最后返回给用户的版本就是这条列c为'刘备'的记录。\n也就是说两次SELECT查询得到的结果是重复的,记录的列c值都是'刘备',这就是可重复读的含义。如果我们之后再把事务id为200的记录提交了,然后再到刚才使用REPEATABLE READ隔离级别的事务中继续查找这个number为1的记录,得到的结果还是'刘备',具体执行过程大家可以自己分析一下。\nMVCC小结 # 从上面的描述中我们可以看出来,所谓的MVCC(Multi-Version Concurrency Control ,多版本并发控制)指的就是在使用READ COMMITTD、REPEATABLE READ这两种隔离级别的事务在执行普通的SEELCT操作时访问记录的版本链的过程,这样子可以使不同事务的读-写、写-读操作并发执行,从而提升系统性能。READ COMMITTD、REPEATABLE READ这两个隔离级别的一个很大不同就是:生成ReadView的时机不同,READ COMMITTD在每一次进行普通SELECT操作前都会生成一个ReadView,而REPEATABLE READ只在第一次进行普通SELECT操作前生成一个ReadView,之后的查询操作都重复使用这个ReadView就好了。\n小贴士:我们之前说执行DELETE语句或者更新主键的UPDATE语句并不会立即把对应的记录完全从页面中删除,而是执行一个所谓的delete mark操作,相当于只是对记录打上了一个删除标志位,这主要就是为MVCC服务的,大家可以对比上面举的例子自己试想一下怎么使用。另外,所谓的MVCC只是在我们进行普通的SEELCT查询时才生效,截止到目前我们所见的所有SELECT语句都算是普通的查询,至于什么是个不普通的查询,我们稍后再说~\n关于purge # 大家有没有发现两件事儿: - 我们说insert undo在事务提交之后就可以被释放掉了,而update undo由于还需要支持MVCC,不能立即删除掉。 - 为了支持MVCC,对于delete mark操作来说,仅仅是在记录上打一个删除标记,并没有真正将它删除掉。\n随着系统的运行,在确定系统中包含最早产生的那个ReadView的事务不会再访问某些update undo日志以及被打了删除标记的记录后,有一个后台运行的purge线程会把它们真正的删除掉。关于更多的purge细节,我们将放到纸质书中进行详细介绍,不见不散~\n"},{"id":10,"href":"/zh/docs/technology/MySQL/MySQL%E6%98%AF%E6%80%8E%E6%A0%B7%E8%BF%90%E8%A1%8C%E7%9A%84/%E7%AC%AC23%E7%AB%A0_%E5%90%8E%E6%82%94%E4%BA%86%E6%80%8E%E4%B9%88%E5%8A%9E-undo%E6%97%A5%E5%BF%97%E4%B8%8B/","title":"第23章_后悔了怎么办-undo日志(下)","section":"My Sql是怎样运行的","content":"第23章 后悔了怎么办-undo日志(下)\n上一章我们主要介绍了为什么需要undo日志,以及INSERT、DELETE、UPDATE这些会对数据做改动的语句都会产生什么类型的undo日志,还有不同类型的undo日志的具体格式是什么。本章会继续介绍这些undo日志会被具体写到什么地方,以及在写入过程中需要注意的一些问题。\n通用链表结构 # 在写入undo日志的过程中会使用到多个链表,很多链表都有同样的节点结构,如图所示:\n在某个表空间内,我们可以通过一个页的页号和在页内的偏移量来唯一定位一个节点的位置,这两个信息也就相当于指向这个节点的一个指针。所以: - Pre Node Page Number和Pre Node Offset的组合就是指向前一个节点的指针 - Next Node Page Number和Next Node Offset的组合就是指向后一个节点的指针。\n整个List Node占用12个字节的存储空间。\n为了更好的管理链表,设计InnoDB的大佬还提出了一个基节点的结构,里边存储了这个链表的头节点、尾节点以及链表长度信息,基节点的结构示意图如下:\n其中: - List Length表明该链表一共有多少节点。 - First Node Page Number和First Node Offset的组合就是指向链表头节点的指针。 - Last Node Page Number和Last Node Offset的组合就是指向链表尾节点的指针。\n整个List Base Node占用16个字节的存储空间。\n所以使用List Base Node和List Node这两个结构组成的链表的示意图就是这样:\n小贴士:上述链表结构我们在前面的文章中频频提到,尤其是在表空间那一章重点描述过,不过我不敢奢求大家都记住了,所以在这里又强调一遍,希望大家不要嫌我烦,我只是怕大家忘了学习后续内容吃力而已~\nFIL_PAGE_UNDO_LOG页面 # 我们前面介绍表空间的时候说过,表空间其实是由许许多多的页面构成的,页面默认大小为16KB。这些页面有不同的类型,比如类型为FIL_PAGE_INDEX的页面用于存储聚簇索引以及二级索引,类型为FIL_PAGE_TYPE_FSP_HDR的页面用于存储表空间头部信息的,还有其他各种类型的页面,其中有一种称之为FIL_PAGE_UNDO_LOG类型的页面是专门用来存储undo日志的,这种类型的页面的通用结构如下图所示(以默认的16KB大小为例):\n“类型为FIL_PAGE_UNDO_LOG的页”这种说法太绕口,以后我们就简称为Undo页面了。上图中的File Header和File Trailer是各种页面都有的通用结构,我们前面介绍过很多遍了,这里就不赘述了(忘记了的可以到讲述数据页结构或者表空间的章节中查看)。Undo Page Header是Undo页面所特有的,我们来看一下它的结构:\n其中各个属性的意思如下:\nTRX_UNDO_PAGE_TYPE:本页面准备存储什么种类的undo日志。\n我们前面介绍了好几种类型的undo日志,它们可以被分为两个大类:\n+ `TRX_UNDO_INSERT`(使用十进制`1`表示):类型为`TRX_UNDO_INSERT_REC`的`undo日志`属于此大类,一般由`INSERT`语句产生,或者在`UPDATE`语句中有更新主键的情况也会产生此类型的`undo日志`。 + `TRX_UNDO_UPDATE`(使用十进制`2`表示),除了类型为`TRX_UNDO_INSERT_REC`的`undo日志`,其他类型的`undo日志`都属于这个大类,比如我们前面说的`TRX_UNDO_DEL_MARK_REC`、`TRX_UNDO_UPD_EXIST_REC`什么的,一般由`DELETE`、`UPDATE`语句产生的`undo日志`属于这个大类。 这个TRX_UNDO_PAGE_TYPE属性可选的值就是上面的两个,用来标记本页面用于存储哪个大类的undo日志,不同大类的undo日志不能混着存储,比如一个Undo页面的TRX_UNDO_PAGE_TYPE属性值为TRX_UNDO_INSERT,那么这个页面就只能存储类型为TRX_UNDO_INSERT_REC的undo日志,其他类型的undo日志就不能放到这个页面中了。\n小贴士:之所以把undo日志分成两个大类,是因为类型为TRX_UNDO_INSERT_REC的undo日志在事务提交后可以直接删除掉,而其他类型的undo日志还需要为所谓的MVCC服务,不能直接删除掉,对它们的处理需要区别对待。当然,如果你看这段话迷迷糊糊的话,那就不需要再看一遍了,现在只需要知道undo日志分为2个大类就好了,更详细的东西我们后边会仔细介绍的。\nTRX_UNDO_PAGE_START:表示在当前页面中是从什么位置开始存储undo日志的,或者说表示第一条undo日志在本页面中的起始偏移量。\nTRX_UNDO_PAGE_FREE:与上面的TRX_UNDO_PAGE_START对应,表示当前页面中存储的最后一条undo日志结束时的偏移量,或者说从这个位置开始,可以继续写入新的undo日志。\n假设现在向页面中写入了3条undo日志,那么TRX_UNDO_PAGE_START和TRX_UNDO_PAGE_FREE的示意图就是这样:\n当然,在最初一条undo日志也没写入的情况下,TRX_UNDO_PAGE_START和TRX_UNDO_PAGE_FREE的值是相同的。\nTRX_UNDO_PAGE_NODE:代表一个List Node结构(链表的普通节点,我们上面刚说的)。\n下面马上用到这个属性,稍安勿躁。\nUndo页面链表 # 单个事务中的Undo页面链表 # 因为一个事务可能包含多个语句,而且一个语句可能对若干条记录进行改动,而对每条记录进行改动前,都需要记录1条或2条的undo日志,所以在一个事务执行过程中可能产生很多undo日志,这些日志可能一个页面放不下,需要放到多个页面中,这些页面就通过我们上面介绍的TRX_UNDO_PAGE_NODE属性连成了链表:\n大家往上再瞅一瞅上面的图,我们特意把链表中的第一个Undo页面给标了出来,称它为first undo page,其余的Undo页面称之为normal undo page,这是因为在first undo page中除了记录Undo Page Header之外,还会记录其他的一些管理信息,这个我们稍后再说。\n在一个事务执行过程中,可能混着执行INSERT、DELETE、UPDATE语句,也就意味着会产生不同类型的undo日志。但是我们前面又强调过,同一个Undo页面要么只存储TRX_UNDO_INSERT大类的undo日志,要么只存储TRX_UNDO_UPDATE大类的undo日志,反正不能混着存,所以在一个事务执行过程中就可能需要2个Undo页面的链表,一个称之为insert undo链表,另一个称之为update undo链表,画个示意图就是这样:\n另外,设计InnoDB的大佬规定对普通表和临时表的记录改动时产生的undo日志要分别记录(我们稍后阐释为什么这么做),所以在一个事务中最多有4个以Undo页面为节点组成的链表:\n当然,并不是在事务一开始就会为这个事务分配这4个链表,具体分配策略如下: - 刚刚开启事务时,一个Undo页面链表也不分配。 - 当事务执行过程中向普通表中插入记录或者执行更新记录主键的操作之后,就会为其分配一个普通表的insert undo链表。 - 当事务执行过程中删除或者更新了普通表中的记录之后,就会为其分配一个普通表的update undo链表。 - 当事务执行过程中向临时表中插入记录或者执行更新记录主键的操作之后,就会为其分配一个临时表的insert undo链表。 - 当事务执行过程中删除或者更新了临时表中的记录之后,就会为其分配一个临时表的update undo链表。\n总结一句就是:按需分配,什么时候需要什么时候再分配,不需要就不分配。\n多个事务中的Undo页面链表 # 为了尽可能提高undo日志的写入效率,不同事务执行过程中产生的undo日志需要被写入到不同的Undo页面链表中。比方说现在有事务id分别为1、2的两个事务,我们分别称之为trx 1和trx 2,假设在这两个事务执行过程中:\ntrx 1对普通表做了DELETE操作,对临时表做了INSERT和UPDATE操作。\nInnoDB会为trx 1分配3个链表,分别是: - 针对普通表的update undo链表 - 针对临时表的insert undo链表 - 针对临时表的update undo链表。\ntrx 2对普通表做了INSERT、UPDATE和DELETE操作,没有对临时表做改动。\nInnoDB会为trx 2分配2个链表,分别是: - 针对普通表的insert undo链表 - 针对普通表的update undo链表。\n综上所述,在trx 1和trx 2执行过程中,InnoDB共需为这两个事务分配5个Undo页面链表,画个图就是这样:\n如果有更多的事务,那就意味着可能会产生更多的Undo页面链表。\nundo日志具体写入过程 # 段(Segment)的概念 # 如果你有认真看过表空间那一章的话,对这个段的概念应该印象深刻,我们当时花了非常大的篇幅来介绍这个概念。简单讲,这个段是一个逻辑上的概念,本质上是由若干个零散页面和若干个完整的区组成的。比如一个B+树索引被划分成两个段,一个叶子节点段,一个非叶子节点段,这样叶子节点就可以被尽可能的存到一起,非叶子节点被尽可能的存到一起。每一个段对应一个INODE Entry结构,这个INODE Entry结构描述了这个段的各种信息,比如段的ID,段内的各种链表基节点,零散页面的页号有哪些等信息(具体该结构中每个属性的意思大家可以到表空间那一章里再次重温一下)。我们前面也说过,为了定位一个INODE Entry,设计InnoDB的大佬设计了一个Segment Header的结构:\n整个Segment Header占用10个字节大小,各个属性的意思如下: - Space ID of the INODE Entry:INODE Entry结构所在的表空间ID。 - Page Number of the INODE Entry:INODE Entry结构所在的页面页号。 - Byte Offset of the INODE Ent:INODE Entry结构在该页面中的偏移量\n知道了表空间ID、页号、页内偏移量,不就可以唯一定位一个INODE Entry的地址了么~\n小贴士:这部分关于段的各种概念我们在表空间那一章中都有详细解释,在这里重提一下只是为了唤醒大家沉睡的记忆,如果有任何不清楚的地方可以再次跳回表空间的那一章仔细读一下。\nUndo Log Segment Header # 设计InnoDB的大佬规定,每一个Undo页面链表都对应着一个段,称之为Undo Log Segment。也就是说链表中的页面都是从这个段里边申请的,所以他们在Undo页面链表的第一个页面,也就是上面提到的first undo page中设计了一个称之为Undo Log Segment Header的部分,这个部分中包含了该链表对应的段的segment header信息以及其他的一些关于这个段的信息,所以Undo页面链表的第一个页面其实长这样:\n可以看到这个Undo链表的第一个页面比普通页面多了个Undo Log Segment Header,我们来看一下它的结构:\n其中各个属性的意思如下:\nTRX_UNDO_STATE:本Undo页面链表处在什么状态。\n一个Undo Log Segment可能处在的状态包括: - TRX_UNDO_ACTIVE:活跃状态,也就是一个活跃的事务正在往这个段里边写入undo日志。 - TRX_UNDO_CACHED:被缓存的状态。处在该状态的Undo页面链表等待着之后被其他事务重用。 - TRX_UNDO_TO_FREE:对于insert undo链表来说,如果在它对应的事务提交之后,该链表不能被重用,那么就会处于这种状态。 - TRX_UNDO_TO_PURGE:对于update undo链表来说,如果在它对应的事务提交之后,该链表不能被重用,那么就会处于这种状态。 - TRX_UNDO_PREPARED:包含处于PREPARE阶段的事务产生的undo日志。\n小贴士:Undo页面链表什么时候会被重用,怎么重用我们之后会详细说的。事务的PREPARE阶段是在所谓的分布式事务中才出现的,本书中不会介绍更多关于分布式事务的事情,所以大家目前忽略这个状态就好了。\nTRX_UNDO_LAST_LOG:本Undo页面链表中最后一个Undo Log Header的位置。\n小贴士:关于什么是Undo Log Header,我们稍后马上介绍。\nTRX_UNDO_FSEG_HEADER:本Undo页面链表对应的段的Segment Header信息(就是我们上一节介绍的那个10字节结构,通过这个信息可以找到该段对应的INODE Entry)。\nTRX_UNDO_PAGE_LIST:Undo页面链表的基节点。\n我们上面说Undo页面的Undo Page Header部分有一个12字节大小的TRX_UNDO_PAGE_NODE属性,这个属性代表一个List Node结构。每一个Undo页面都包含Undo Page Header结构,这些页面就可以通过这个属性连成一个链表。这个TRX_UNDO_PAGE_LIST属性代表着这个链表的基节点,当然这个基节点只存在于Undo页面链表的第一个页面,也就是first undo page中。\nUndo Log Header # 一个事务在向Undo页面中写入undo日志时的方式是十分简单暴力的,就是直接往里怼,写完一条紧接着写另一条,各条undo日志之间是亲密无间的。写完一个Undo页面后,再从段里申请一个新页面,然后把这个页面插入到Undo页面链表中,继续往这个新申请的页面中写。设计InnoDB的大佬认为同一个事务向一个Undo页面链表中写入的undo日志算是一个组,比方说我们上面介绍的trx 1由于会分配3个Undo页面链表,也就会写入3个组的undo日志;trx 2由于会分配2个Undo页面链表,也就会写入2个组的undo日志。在每写入一组undo日志时,都会在这组undo日志前先记录一下关于这个组的一些属性,设计InnoDB的大佬把存储这些属性的地方称之为Undo Log Header。所以Undo页面链表的第一个页面在真正写入undo日志前,其实都会被填充Undo Page Header、Undo Log Segment Header、Undo Log Header这3个部分,如图所示:\n这个Undo Log Header具体的结构如下:\n哇唔,映入眼帘的又是一大坨属性,我们先大致看一下它们都是什么意思: - TRX_UNDO_TRX_ID:生成本组undo日志的事务id。 - TRX_UNDO_TRX_NO:事务提交后生成的一个需要序号,使用此序号来标记事务的提交顺序(先提交的此序号小,后提交的此序号大)。 - TRX_UNDO_DEL_MARKS:标记本组undo日志中是否包含由于Delete mark操作产生的undo日志。 - TRX_UNDO_LOG_START:表示本组undo日志中第一条undo日志的在页面中的偏移量。 - TRX_UNDO_XID_EXISTS:本组undo日志是否包含XID信息。\n``` 小贴士:本书不会讲述更多关于XID是个什么东东,有兴趣的同学可以到搜索引擎或者文档中搜一搜。 ``` TRX_UNDO_DICT_TRANS:标记本组undo日志是不是由DDL语句产生的。 TRX_UNDO_TABLE_ID:如果TRX_UNDO_DICT_TRANS为真,那么本属性表示DDL语句操作的表的table id。 TRX_UNDO_NEXT_LOG:下一组的undo日志在页面中开始的偏移量。 TRX_UNDO_PREV_LOG:上一组的undo日志在页面中开始的偏移量。\n小贴士:一般来说一个Undo页面链表只存储一个事务执行过程中产生的一组undo日志,但是在某些情况下,可能会在一个事务提交之后,之后开启的事务重复利用这个Undo页面链表,这样就会导致一个Undo页面中可能存放多组Undo日志,TRX_UNDO_NEXT_LOG和TRX_UNDO_PREV_LOG就是用来标记下一组和上一组undo日志在页面中的偏移量的。关于什么时候重用Undo页面链表,怎么重用这个链表我们稍后会详细说明的,现在先理解TRX_UNDO_NEXT_LOG和TRX_UNDO_PREV_LOG这两个属性的意思就好了。\nTRX_UNDO_HISTORY_NODE:一个12字节的List Node结构,代表一个称之为History链表的节点。\n小贴士:关于History链表我们后边会格外详细的介绍,现在先不用管。\n小结 # 对于没有被重用的Undo页面链表来说,链表的第一个页面,也就是first undo page在真正写入undo日志前,会填充Undo Page Header、Undo Log Segment Header、Undo Log Header这3个部分,之后才开始正式写入undo日志。对于其他的页面来说,也就是normal undo page在真正写入undo日志前,只会填充Undo Page Header。链表的List Base Node存放到first undo page的Undo Log Segment Header部分,List Node信息存放到每一个Undo页面的undo Page Header部分,所以画一个Undo页面链表的示意图就是这样:\n重用Undo页面 # 我们前面说为了能提高并发执行的多个事务写入undo日志的性能,设计InnoDB的大佬决定为每个事务单独分配相应的Undo页面链表(最多可能单独分配4个链表)。但是这样也造成了一些问题,比如其实大部分事务执行过程中可能只修改了一条或几条记录,针对某个Undo页面链表只产生了非常少的undo日志,这些undo日志可能只占用一丢丢存储空间,每开启一个事务就新创建一个Undo页面链表(虽然这个链表中只有一个页面)来存储这么一丢丢undo日志岂不是太浪费了么?的确是挺浪费,于是设计InnoDB的大佬本着勤俭节约的优良传统,决定在事务提交后在某些情况下重用该事务的Undo页面链表。一个Undo页面链表是否可以被重用的条件很简单:\n该链表中只包含一个Undo页面。\n如果一个事务执行过程中产生了非常多的undo日志,那么它可能申请非常多的页面加入到Undo页面链表中。在该事物提交后,如果将整个链表中的页面都重用,那就意味着即使新的事务并没有向该Undo页面链表中写入很多undo日志,那该链表中也得维护非常多的页面,那些用不到的页面也不能被别的事务所使用,这样就造成了另一种浪费。所以设计InnoDB的大佬们规定,只有在Undo页面链表中只包含一个Undo页面时,该链表才可以被下一个事务所重用。\n该Undo页面已经使用的空间小于整个页面空间的3/4。\n我们前面说过,Undo页面链表按照存储的undo日志所属的大类可以被分为insert undo链表和update undo链表两种,这两种链表在被重用时的策略也是不同的,我们分别看一下:\ninsert undo链表\ninsert undo链表中只存储类型为TRX_UNDO_INSERT_REC的undo日志,这种类型的undo日志在事务提交之后就没用了,就可以被清除掉。所以在某个事务提交后,重用这个事务的insert undo链表(这个链表中只有一个页面)时,可以直接把之前事务写入的一组undo日志覆盖掉,从头开始写入新事务的一组undo日志,如下图所示:\n如图所示,假设有一个事务使用的insert undo链表,到事务提交时,只向insert undo链表中插入了3条undo日志,这个insert undo链表只申请了一个Undo页面。假设此刻该页面已使用的空间小于整个页面大小的3/4,那么下一个事务就可以重用这个insert undo链表(链表中只有一个页面)。假设此时有一个新事务重用了该insert undo链表,那么可以直接把旧的一组undo日志覆盖掉,写入一组新的undo日志。\n小贴士:当然,在重用Undo页面链表写入新的一组undo日志时,不仅会写入新的Undo Log Header,还会适当调整Undo Page Header、Undo Log Segment Header、Undo Log Header中的一些属性,比如TRX_UNDO_PAGE_START、TRX_UNDO_PAGE_FREE等等等等,这些我们就不具体介绍了。\nupdate undo链表\n在一个事务提交后,它的update undo链表中的undo日志也不能立即删除掉(这些日志用于MVCC,我们后边会说的)。所以如果之后的事务想重用update undo链表时,就不能覆盖之前事务写入的undo日志。这样就相当于在同一个Undo页面中写入了多组的undo日志,效果看起来就是这样:\n回滚段 # 回滚段的概念 # 我们现在知道一个事务在执行过程中最多可以分配4个Undo页面链表,在同一时刻不同事务拥有的Undo页面链表是不一样的,所以在同一时刻系统里其实可以有许许多多个Undo页面链表存在。为了更好的管理这些链表,设计InnoDB的大佬又设计了一个称之为Rollback Segment Header的页面,在这个页面中存放了各个Undo页面链表的frist undo page的页号,他们把这些页号称之为undo slot。我们可以这样理解,每个Undo页面链表都相当于是一个班,这个链表的first undo page就相当于这个班的班长,找到了这个班的班长,就可以找到班里的其他同学(其他同学相当于normal undo page)。有时候学校需要向这些班级传达一下精神,就需要把班长都召集在会议室,这个Rollback Segment Header就相当于是一个会议室。\n我们看一下这个称之为Rollback Segment Header的页面长什么样(以默认的16KB为例):\n设计InnoDB的大佬规定,每一个Rollback Segment Header页面都对应着一个段,这个段就称为Rollback Segment,翻译过来就是回滚段。与我们之前介绍的各种段不同的是,这个Rollback Segment里其实只有一个页面(这可能是设计InnoDB的大佬们的一种洁癖,他们可能觉得为了某个目的去分配页面的话都得先申请一个段,或者他们觉得虽然目前版本的MySQL里Rollback Segment里其实只有一个页面,但可能之后的版本里会增加页面也说不定)。\n了解了Rollback Segment的含义之后,我们再来看看这个称之为Rollback Segment Header的页面的各个部分的含义都是什么意思:\nTRX_RSEG_MAX_SIZE:本Rollback Segment中管理的所有Undo页面链表中的Undo页面数量之和的最大值。换句话说,本Rollback Segment中所有Undo页面链表中的Undo页面数量之和不能超过TRX_RSEG_MAX_SIZE代表的值。\n该属性的值默认为无限大,也就是我们想写多少Undo页面都可以。 小贴士:无限大其实也只是个夸张的说法,4个字节能表示最大的数也就是0xFFFFFFFF,但是我们之后会看到,0xFFFFFFFF这个数有特殊用途,所以实际上TRX_RSEG_MAX_SIZE的值为0xFFFFFFFE。\nTRX_RSEG_HISTORY_SIZE:History链表占用的页面数量。\nTRX_RSEG_HISTORY:History链表的基节点。\n小贴士:History链表后边讲,稍安勿躁。\nTRX_RSEG_FSEG_HEADER:本Rollback Segment对应的10字节大小的Segment Header结构,通过它可以找到本段对应的INODE Entry。\nTRX_RSEG_UNDO_SLOTS:各个Undo页面链表的first undo page的页号集合,也就是undo slot集合。\n一个页号占用4个字节,对于16KB大小的页面来说,这个TRX_RSEG_UNDO_SLOTS部分共存储了1024个undo slot,所以共需1024 × 4 = 4096个字节。\n从回滚段中申请Undo页面链表 # 初始情况下,由于未向任何事务分配任何Undo页面链表,所以对于一个Rollback Segment Header页面来说,它的各个undo slot都被设置成了一个特殊的值:FIL_NULL(对应的十六进制就是0xFFFFFFFF),表示该undo slot不指向任何页面。\n随着时间的流逝,开始有事务需要分配Undo页面链表了,就从回滚段的第一个undo slot开始,看看该undo slot的值是不是FIL_NULL:\n如果是FIL_NULL,那么在表空间中新创建一个段(也就是Undo Log Segment),然后从段里申请一个页面作为Undo页面链表的first undo page,然后把该undo slot的值设置为刚刚申请的这个页面的地址,这样也就意味着这个undo slot被分配给了这个事务。 如果不是FIL_NULL,说明该undo slot已经指向了一个undo链表,也就是说这个undo slot已经被别的事务占用了,那就跳到下一个undo slot,判断该undo slot的值是不是FIL_NULL,重复上面的步骤。 一个Rollback Segment Header页面中包含1024个undo slot,如果这1024个undo slot的值都不为FIL_NULL,这就意味着这1024个undo slot都已经名花有主(被分配给了某个事务),此时由于新事务无法再获得新的Undo页面链表,就会回滚这个事务并且给用户报错: Too many active concurrent transactions 用户看到这个错误,可以选择重新执行这个事务(可能重新执行时有别的事务提交了,该事务就可以被分配Undo页面链表了)。\n当一个事务提交时,它所占用的undo slot有两种命运:\n如果该undo slot指向的Undo页面链表符合被重用的条件(就是我们上面说的Undo页面链表只占用一个页面并且已使用空间小于整个页面的3/4)。\n该undo slot就处于被缓存的状态,设计InnoDB的大佬规定这时该Undo页面链表的TRX_UNDO_STATE属性(该属性在first undo page的Undo Log Segment Header部分)会被设置为TRX_UNDO_CACHED。\n被缓存的undo slot都会被加入到一个链表,根据对应的Undo页面链表的类型不同,也会被加入到不同的链表:\n+ 如果对应的`Undo页面`链表是`insert undo链表`,则该`undo slot`会被加入`insert undo cached链表`。 + 如果对应的`Undo页面`链表是`update undo链表`,则该`undo slot`会被加入`update undo cached链表`。 一个回滚段就对应着上述两个cached链表,如果有新事务要分配undo slot时,先从对应的cached链表中找。如果没有被缓存的undo slot,才会到回滚段的Rollback Segment Header页面中再去找。\n如果该undo slot指向的Undo页面链表不符合被重用的条件,那么针对该undo slot对应的Undo页面链表类型不同,也会有不同的处理:\n+ 如果对应的`Undo页面`链表是`insert undo链表`,则该`Undo页面`链表的`TRX_UNDO_STATE`属性会被设置为`TRX_UNDO_TO_FREE`,之后该`Undo页面`链表对应的段会被释放掉(也就意味着段中的页面可以被挪作他用),然后把该`undo slot`的值设置为`FIL_NULL`。 + 如果对应的`Undo页面`链表是`update undo链表`,则该`Undo页面`链表的`TRX_UNDO_STATE`属性会被设置为`TRX_UNDO_TO_PRUGE`,则会将该`undo slot`的值设置为`FIL_NULL`,然后将本次事务写入的一组`undo`日志放到所谓的`History链表`中(需要注意的是,这里并不会将`Undo页面`链表对应的段给释放掉,因为这些`undo`日志还有用呢~)。 小贴士:更多关于History链表的事我们稍后再说,稍安勿躁。\n多个回滚段 # 我们说一个事务执行过程中最多分配4个Undo页面链表,而一个回滚段里只有1024个undo slot,很显然undo slot的数量有点少啊。我们即使假设一个读写事务执行过程中只分配1个Undo页面链表,那1024个undo slot也只能支持1024个读写事务同时执行,再多了就崩溃了。这就相当于会议室只能容下1024个班长同时开会,如果有几千人同时到会议室开会的话,那后来的那些班长就没地方坐了,只能等待前面的人开完会自己再进去开。\n话说在InnoDB的早期发展阶段的确只有一个回滚段,但是设计InnoDB的大佬后来意识到了这个问题,咋解决这问题呢?会议室不够,多盖几个会议室不就得了。所以设计InnoDB的大佬一口气定义了128个回滚段,也就相当于有了128 × 1024 = 131072个undo slot。假设一个读写事务执行过程中只分配1个Undo页面链表,那么就可以同时支持131072个读写事务并发执行(这么多事务在一台机器上并发执行,还真没见过呢~)。 小贴士:只读事务并不需要分配Undo页面链表,MySQL 5.7中所有刚开启的事务默认都是只读事务,只有在事务执行过程中对记录做了某些改动时才会被升级为读写事务。 每个回滚段都对应着一个Rollback Segment Header页面,有128个回滚段,自然就要有128个Rollback Segment Header页面,这些页面的地址总得找个地方存一下吧!于是设计InnoDB的大佬在系统表空间的第5号页面的某个区域包含了128个8字节大小的格子:\n每个8字节的格子的构造就像这样:\n如果所示,每个8字节的格子其实由两部分组成:\n4字节大小的Space ID,代表一个表空间的ID。 4字节大小的Page number,代表一个页号。 也就是说每个8字节大小的格子相当于一个指针,指向某个表空间中的某个页面,这些页面就是Rollback Segment Header。这里需要注意的一点事,要定位一个Rollback Segment Header还需要知道对应的表空间ID,这也就意味着不同的回滚段可能分布在不同的表空间中。\n所以通过上面的叙述我们可以大致清楚,在系统表空间的第5号页面中存储了128个Rollback Segment Header页面地址,每个Rollback Segment Header就相当于一个回滚段。在Rollback Segment Header页面中,又包含1024个undo slot,每个undo slot都对应一个Undo页面链表。我们画个示意图:\n把图一画出来就清爽多了。\n回滚段的分类 # 我们把这128个回滚段给编一下号,最开始的回滚段称之为第0号回滚段,之后依次递增,最后一个回滚段就称之为第127号回滚段。这128个回滚段可以被分成两大类:\n第0号、第33~127号回滚段属于一类。其中第0号回滚段必须在系统表空间中(就是说第0号回滚段对应的Rollback Segment Header页面必须在系统表空间中),第33~127号回滚段既可以在系统表空间中,也可以在自己配置的undo表空间中,关于怎么配置我们稍后再说。\n如果一个事务在执行过程中由于对普通表的记录做了改动需要分配Undo页面链表时,必须从这一类的段中分配相应的undo slot。\n第1~32号回滚段属于一类。这些回滚段必须在临时表空间(对应着数据目录中的ibtmp1文件)中。\n如果一个事务在执行过程中由于对临时表的记录做了改动需要分配Undo页面链表时,必须从这一类的段中分配相应的undo slot。\n也就是说如果一个事务在执行过程中既对普通表的记录做了改动,又对临时表的记录做了改动,那么需要为这个记录分配2个回滚段,再分别到这两个回滚段中分配对应的undo slot。\n不知道大家有没有疑惑,为什么要把针对普通表和临时表来划分不同种类的回滚段呢?这个还得从Undo页面本身说起,我们说Undo页面其实是类型为FIL_PAGE_UNDO_LOG的页面的简称,说到底它也是一个普通的页面。我们前面说过,在修改页面之前一定要先把对应的redo日志写上,这样在系统奔溃重启时才能恢复到奔溃前的状态。我们向Undo页面写入undo日志本身也是一个写页面的过程,设计InnoDB的大佬为此还设计了许多种redo日志的类型,比方说MLOG_UNDO_HDR_CREATE、MLOG_UNDO_INSERT、MLOG_UNDO_INIT等等等等,也就是说我们对Undo页面做的任何改动都会记录相应类型的redo日志。但是对于临时表来说,因为修改临时表而产生的undo日志只需要在系统运行过程中有效,如果系统奔溃了,那么在重启时也不需要恢复这些undo日志所在的页面,所以在写针对临时表的Undo页面时,并不需要记录相应的redo日志。总结一下针对普通表和临时表划分不同种类的回滚段的原因:在修改针对普通表的回滚段中的Undo页面时,需要记录对应的redo日志,而修改针对临时表的回滚段中的Undo页面时,不需要记录对应的redo日志。\n小贴士:实际上在MySQL 5.7.21这个版本中,如果我们仅仅对普通表的记录做了改动,那么只会为该事务分配针对普通表的回滚段,不分配针对临时表的回滚段。但是如果我们仅仅对临时表的记录做了改动,那么既会为该事务分配针对普通表的回滚段,又会为其分配针对临时表的回滚段(不过分配了回滚段并不会立即分配undo slot,只有在真正需要Undo页面链表时才会去分配回滚段中的undo slot)。\n为事务分配Undo页面链表详细过程 # 上面说了一大堆的概念,大家应该有一点点的小晕,接下来我们以事务对普通表的记录做改动为例,给大家梳理一下事务执行过程中分配Undo页面链表时的完整过程,\n事务在执行过程中对普通表的记录首次做改动之前,首先会到系统表空间的第5号页面中分配一个回滚段(其实就是获取一个Rollback Segment Header页面的地址)。一旦某个回滚段被分配给了这个事务,那么之后该事务中再对普通表的记录做改动时,就不会重复分配了。\n使用传说中的round-robin(循环使用)方式来分配回滚段。比如当前事务分配了第0号回滚段,那么下一个事务就要分配第33号回滚段,下下个事务就要分配第34号回滚段,简单一点的说就是这些回滚段被轮着分配给不同的事务(就是这么简单粗暴,没什么好说的)。\n在分配到回滚段后,首先看一下这个回滚段的两个cached链表有没有已经缓存了的undo slot,比如如果事务做的是INSERT操作,就去回滚段对应的insert undo cached链表中看看有没有缓存的undo slot;如果事务做的是DELETE操作,就去回滚段对应的update undo cached链表中看看有没有缓存的undo slot。如果有缓存的undo slot,那么就把这个缓存的undo slot分配给该事务。\n如果没有缓存的undo slot可供分配,那么就要到Rollback Segment Header页面中找一个可用的undo slot分配给当前事务。\n从Rollback Segment Header页面中分配可用的undo slot的方式我们上面也说过了,就是从第0个undo slot开始,如果该undo slot的值为FIL_NULL,意味着这个undo slot是空闲的,就把这个undo slot分配给当前事务,否则查看第1个undo slot是否满足条件,依次类推,直到最后一个undo slot。如果这1024个undo slot都没有值为FIL_NULL的情况,就直接报错喽(一般不会出现这种情况)~\n找到可用的undo slot后,如果该undo slot是从cached链表中获取的,那么它对应的Undo Log Segment已经分配了,否则的话需要重新分配一个Undo Log Segment,然后从该Undo Log Segment中申请一个页面作为Undo页面链表的first undo page。\n然后事务就可以把undo日志写入到上面申请的Undo页面链表了! 对临时表的记录做改动的步骤和上述的一样,就不赘述了。不错需要再次强调一次,如果一个事务在执行过程中既对普通表的记录做了改动,又对临时表的记录做了改动,那么需要为这个记录分配2个回滚段。并发执行的不同事务其实也可以被分配相同的回滚段,只要分配不同的undo slot就可以了。\n回滚段相关配置 # 配置回滚段数量 # 我们前面说系统中一共有128个回滚段,其实这只是默认值,我们可以通过启动参数innodb_rollback_segments来配置回滚段的数量,可配置的范围是1~128。但是这个参数并不会影响针对临时表的回滚段数量,针对临时表的回滚段数量一直是32,也就是说: - 如果我们把innodb_rollback_segments的值设置为1,那么只会有1个针对普通表的可用回滚段,但是仍然有32个针对临时表的可用回滚段。 - 如果我们把innodb_rollback_segments的值设置为2~33之间的数,效果和将其设置为1是一样的。 - 如果我们把innodb_rollback_segments设置为大于33的数,那么针对普通表的可用回滚段数量就是该值减去32。\n配置undo表空间 # 默认情况下,针对普通表设立的回滚段(第0号以及第33~127号回滚段)都是被分配到系统表空间的。其中的第第0号回滚段是一直在系统表空间的,但是第33~127号回滚段可以通过配置放到自定义的undo表空间中。但是这种配置只能在系统初始化(创建数据目录时)的时候使用,一旦初始化完成,之后就不能再次更改了。我们看一下相关启动参数: - 通过innodb_undo_directory指定undo表空间所在的目录,如果没有指定该参数,则默认undo表空间所在的目录就是数据目录。 - 通过innodb_undo_tablespaces定义undo表空间的数量。该参数的默认值为0,表明不创建任何undo表空间。\n第`33~127`号回滚段可以平均分布到不同的`undo表空间`中。 小贴士:如果我们在系统初始化的时候指定了创建了undo表空间,那么系统表空间中的第0号回滚段将处于不可用状态。 比如我们在系统初始化时指定的innodb_rollback_segments为35,innodb_undo_tablespaces为2,这样就会将第33、34号回滚段分别分布到一个undo表空间中。\n设立undo表空间的一个好处就是在undo表空间中的文件大到一定程度时,可以自动的将该undo表空间截断(truncate)成一个小文件。而系统表空间的大小只能不断的增大,却不能截断。\n"},{"id":11,"href":"/zh/docs/technology/MySQL/MySQL%E6%98%AF%E6%80%8E%E6%A0%B7%E8%BF%90%E8%A1%8C%E7%9A%84/%E7%AC%AC22%E7%AB%A0_%E5%90%8E%E6%82%94%E4%BA%86%E6%80%8E%E4%B9%88%E5%8A%9E-undo%E6%97%A5%E5%BF%97%E4%B8%8A/","title":"第22章_后悔了怎么办-undo日志(上)","section":"My Sql是怎样运行的","content":"第22章 后悔了怎么办-undo日志(上)\n事务回滚的需求 # 我们说过事务需要保证原子性,也就是事务中的操作要么全部完成,要么什么也不做。但是偏偏有时候事务执行到一半会出现一些情况,比如: - 情况一:事务执行过程中可能遇到各种错误,比如服务器本身的错误,操作系统错误,甚至是突然断电导致的错误。 - 情况二:程序员可以在事务执行过程中手动输入ROLLBACK语句结束当前的事务的执行。\n这两种情况都会导致事务执行到一半就结束,但是事务执行过程中可能已经修改了很多东西,为了保证事务的原子性,我们需要把东西改回原先的样子,这个过程就称之为回滚(英文名:rollback),这样就可以造成一个假象:这个事务看起来什么都没做,所以符合原子性要求。\n小时候我非常痴迷于象棋,总是想找厉害的大人下棋,赢棋是不可能赢棋的,这辈子都不可能赢棋的,又不想认输,只能偷偷的悔棋才能勉强玩的下去。悔棋就是一种非常典型的回滚操作,比如棋子往前走两步,悔棋对应的操作就是向后走两步;比如棋子往左走一步,悔棋对应的操作就是向右走一步。数据库中的回滚跟悔棋差不多,你插入了一条记录,回滚操作对应的就是把这条记录删除掉;你更新了一条记录,回滚操作对应的就是把该记录更新为旧值;你删除了一条记录,回滚操作对应的自然就是把该记录再插进去。说的貌似很简单的样子[手动偷笑😏]。\n从上面的描述中我们已经能隐约感觉到,每当我们要对一条记录做改动时(这里的改动可以指INSERT、DELETE、UPDATE),都需要留一手 —— 把回滚时所需的东西都给记下来。比方说: - 你插入一条记录时,至少要把这条记录的主键值记下来,之后回滚的时候只需要把这个主键值对应的记录删掉就好了。 - 你删除了一条记录,至少要把这条记录中的内容都记下来,这样之后回滚时再把由这些内容组成的记录插入到表中就好了。 - 你修改了一条记录,至少要把修改这条记录前的旧值都记录下来,这样之后回滚时再把这条记录更新为旧值就好了。\n设计数据库的大佬把这些为了回滚而记录的这些东东称之为撤销日志,英文名为undo log,我们也可以土洋结合,称之为undo日志。这里需要注意的一点是,由于查询操作(SELECT)并不会修改任何用户记录,所以在查询操作执行时,并不需要记录相应的undo日志。在真实的InnoDB中,undo日志其实并不像我们上面所说的那么简单,不同类型的操作产生的undo日志的格式也是不同的,不过先暂时把这些容易让人脑子糊的具体细节放一放,我们先回过头来看看事务id是个神马玩意儿。\n事务id # 给事务分配id的时机 # 我们前面在介绍事务简介时说过,一个事务可以是一个只读事务,或者是一个读写事务:\n我们可以通过START TRANSACTION READ ONLY语句开启一个只读事务。\n在只读事务中不可以对普通的表(其他事务也能访问到的表)进行增、删、改操作,但可以对临时表做增、删、改操作。\n我们可以通过START TRANSACTION READ WRITE语句开启一个读写事务,或者使用BEGIN、START TRANSACTION语句开启的事务默认也算是读写事务。\n在读写事务中可以对表执行增删改查操作。\n如果某个事务执行过程中对某个表执行了增、删、改操作,那么InnoDB存储引擎就会给它分配一个独一无二的事务id,分配方式如下:\n对于只读事务来说,只有在它第一次对某个用户创建的临时表执行增、删、改操作时才会为这个事务分配一个事务id,否则的话是不分配事务id的。\n小贴士:我们前面说过对某个查询语句执行EXPLAIN分析它的查询计划时,有时候在Extra列会看到Using temporary的提示,这个表明在执行该查询语句时会用到内部临时表。这个所谓的内部临时表和我们手动用CREATE TEMPORARY TABLE创建的用户临时表并不一样,在事务回滚时并不需要把执行SELECT语句过程中用到的内部临时表也回滚,在执行SELECT语句用到内部临时表时并不会为它分配事务id。 - 对于读写事务来说,只有在它第一次对某个表(包括用户创建的临时表)执行增、删、改操作时才会为这个事务分配一个事务id,否则的话也是不分配事务id的。\n有的时候虽然我们开启了一个读写事务,但是在这个事务中全是查询语句,并没有执行增、删、改的语句,那也就意味着这个事务并不会被分配一个事务id。\n说了半天,事务id有什么子用?这个先保密,后边会一步步的详细介绍。现在只要知道只有在事务对表中的记录做改动时才会为这个事务分配一个唯一的事务id。 小贴士:上面描述的事务id分配策略是针对MySQL 5.7来说的,前面的版本的分配方式可能不同~\n事务id是怎么生成的 # 这个事务id本质上就是一个数字,它的分配策略和我们前面提到的对隐藏列row_id(当用户没有为表创建主键和UNIQUE键时InnoDB自动创建的列)的分配策略大抵相同,具体策略如下: - 服务器会在内存中维护一个全局变量,每当需要为某个事务分配一个事务id时,就会把该变量的值当作事务id分配给该事务,并且把该变量自增1。 - 每当这个变量的值为256的倍数时,就会将该变量的值刷新到系统表空间的页号为5的页面中一个称之为Max Trx ID的属性处,这个属性占用8个字节的存储空间。 - 当系统下一次重新启动时,会将上面提到的Max Trx ID属性加载到内存中,将该值加上256之后赋值给我们前面提到的全局变量(因为在上次关机时该全局变量的值可能大于Max Trx ID属性值)。\n这样就可以保证整个系统中分配的事务id值是一个递增的数字。先被分配id的事务得到的是较小的事务id,后被分配id的事务得到的是较大的事务id。\ntrx_id隐藏列 # 我们前面介绍InnoDB记录行格式的时候重点强调过:聚簇索引的记录除了会保存完整的用户数据以外,而且还会自动添加名为trx_id、roll_pointer的隐藏列,如果用户没有在表中定义主键以及UNIQUE键,还会自动添加一个名为row_id的隐藏列。所以一条记录在页面中的真实结构看起来就是这样的:\n其中的trx_id列其实还蛮好理解的,就是某个对这个聚簇索引记录做改动的语句所在的事务对应的事务id而已(此处的改动可以是INSERT、DELETE、UPDATE操作)。至于roll_pointer隐藏列我们后边分析~\nundo日志的格式 # 为了实现事务的原子性,InnoDB存储引擎在实际进行增、删、改一条记录时,都需要先把对应的undo日志记下来。一般每对一条记录做一次改动,就对应着一条undo日志,但在某些更新记录的操作中,也可能会对应着2条undo日志,这个我们后边会仔细介绍。一个事务在执行过程中可能新增、删除、更新若干条记录,也就是说需要记录很多条对应的undo日志,这些undo日志会被从0开始编号,也就是说根据生成的顺序分别被称为第0号undo日志、第1号undo日志、\u0026hellip;、第n号undo日志等,这个编号也被称之为undo no。\n这些undo日志是被记录到类型为FIL_PAGE_UNDO_LOG(对应的十六进制是0x0002,忘记了页面类型是什么的同学需要回过头再看看前面的章节)的页面中。这些页面可以从系统表空间中分配,也可以从一种专门存放undo日志的表空间,也就是所谓的undo tablespace中分配。不过关于如何分配存储undo日志的页面这个事情我们稍后再说,现在先来看看不同操作都会产生什么样子的undo日志吧~ 为了故事的顺利发展,我们先来创建一个名为undo_demo的表: CREATE TABLE undo_demo ( id INT NOT NULL, key1 VARCHAR(100), col VARCHAR(100), PRIMARY KEY (id), KEY idx_key1 (key1) )Engine=InnoDB CHARSET=utf8; 这个表中有3个列,其中id列是主键,我们为key1列建立了一个二级索引,col列是一个普通的列。我们前面介绍InnoDB的数据字典时说过,每个表都会被分配一个唯一的table id,我们可以通过系统数据库information_schema中的innodb_sys_tables表来查看某个表对应的table id是什么,现在我们查看一下undo_demo对应的table id是多少: mysql\u0026gt; SELECT * FROM information_schema.innodb_sys_tables WHERE name = 'xiaohaizi/undo_demo'; +----------+---------------------+------+--------+-------+-------------+------------+---------------+------------+ | TABLE_ID | NAME | FLAG | N_COLS | SPACE | FILE_FORMAT | ROW_FORMAT | ZIP_PAGE_SIZE | SPACE_TYPE | +----------+---------------------+------+--------+-------+-------------+------------+---------------+------------+ | 138 | xiaohaizi/undo_demo | 33 | 6 | 482 | Barracuda | Dynamic | 0 | Single | +----------+---------------------+------+--------+-------+-------------+------------+---------------+------------+ 1 row in set (0.01 sec) 从查询结果可以看出,undo_demo表对应的table id为138,先把这个值记住,我们后边有用。\nINSERT操作对应的undo日志 # 我们前面说过,当我们向表中插入一条记录时会有乐观插入和悲观插入的区分,但是不管怎么插入,最终导致的结果就是这条记录被放到了一个数据页中。如果希望回滚这个插入操作,那么把这条记录删除就好了,也就是说在写对应的undo日志时,主要是把这条记录的主键信息记上。所以设计InnoDB的大佬设计了一个类型为TRX_UNDO_INSERT_REC的undo日志,它的完整结构如下图所示:\n根据示意图我们强调几点: - undo no在一个事务中是从0开始递增的,也就是说只要事务没提交,每生成一条undo日志,那么该条日志的undo no就增1。 - 如果记录中的主键只包含一个列,那么在类型为TRX_UNDO_INSERT_REC的undo日志中只需要把该列占用的存储空间大小和真实值记录下来,如果记录中的主键包含多个列,那么每个列占用的存储空间大小和对应的真实值都需要记录下来(图中的len就代表列占用的存储空间大小,value就代表列的真实值)。\n小贴士:当我们向某个表中插入一条记录时,实际上需要向聚簇索引和所有的二级索引都插入一条记录。不过记录undo日志时,我们只需要考虑向聚簇索引插入记录时的情况就好了,因为其实聚簇索引记录和二级索引记录是一一对应的,我们在回滚插入操作时,只需要知道这条记录的主键信息,然后根据主键信息做对应的删除操作,做删除操作时就会顺带着把所有二级索引中相应的记录也删除掉。后边说到的DELETE操作和UPDATE操作对应的undo日志也都是针对聚簇索引记录而言的,我们之后就不强调了。 现在我们向undo_demo中插入两条记录: ``` BEGIN; # 显式开启一个事务,假设该事务的id为100\n插入两条记录\nINSERT INTO undo_demo(id, key1, col) VALUES (1, \u0026lsquo;AWM\u0026rsquo;, \u0026lsquo;狙击枪\u0026rsquo;), (2, \u0026lsquo;M416\u0026rsquo;, \u0026lsquo;步枪\u0026rsquo;); ``` 因为记录的主键只包含一个id列,所以我们在对应的undo日志中只需要将待插入记录的id列占用的存储空间长度(id列的类型为INT,INT类型占用的存储空间长度为4个字节)和真实值记录下来。本例中插入了两条记录,所以会产生两条类型为TRX_UNDO_INSERT_REC的undo日志:\n第一条undo日志的undo no为0,记录主键占用的存储空间长度为4,真实值为1。画一个示意图就是这样:\n第二条undo日志的undo no为1,记录主键占用的存储空间长度为4,真实值为2。画一个示意图就是这样(与第一条undo日志对比,undo no和主键各列信息有不同):\n小贴士:为了最大限度的节省undo日志占用的存储空间,和我们前面说过的redo日志类似,设计InnoDB的大佬会给undo日志中的某些属性进行压缩处理,具体的压缩细节我们就不介绍了。\nroll_pointer隐藏列的含义 # 是时候揭开roll_pointer的真实面纱了,这个占用7个字节的字段其实一点都不神秘,本质上就是一个指向记录对应的undo日志的一个指针。比方说我们上面向undo_demo表里插入了2条记录,每条记录都有与其对应的一条undo日志。记录被存储到了类型为FIL_PAGE_INDEX的页面中(就是我们前面一直所说的数据页),undo日志被存放到了类型为FIL_PAGE_UNDO_LOG的页面中。效果如图所示:\n从图中也可以更直观的看出来,roll_pointer本质就是一个指针,指向记录对应的undo日志。不过这7个字节的roll_pointer的每一个字节具体的含义我们后边介绍完如何分配存储undo日志的页面之后再具体说~\nDELETE操作对应的undo日志 # 我们知道插入到页面中的记录会根据记录头信息中的next_record属性组成一个单向链表,我们把这个链表称之为正常记录链表;我们在前面介绍数据页结构的时候说过,被删除的记录其实也会根据记录头信息中的next_record属性组成一个链表,只不过这个链表中的记录占用的存储空间可以被重新利用,所以也称这个链表为垃圾链表。Page Header部分有一个称之为PAGE_FREE的属性,它指向由被删除记录组成的垃圾链表中的头节点。为了故事的顺利发展,我们先画一个图,假设此刻某个页面中的记录分布情况是这样的(这个不是undo_demo表中的记录,只是我们随便举的一个例子):\n为了突出主题,在这个简化版的示意图中,我们只把记录的delete_mask标志位展示了出来。从图中可以看出,正常记录链表中包含了3条正常记录,垃圾链表里包含了2条已删除记录,在垃圾链表中的这些记录占用的存储空间可以被重新利用。页面的Page Header部分的PAGE_FREE属性的值代表指向垃圾链表头节点的指针。假设现在我们准备使用DELETE语句把正常记录链表中的最后一条记录给删除掉,其实这个删除的过程需要经历两个阶段:\n阶段一:仅仅将记录的delete_mask标识位设置为1,其他的不做修改(其实会修改记录的trx_id、roll_pointer这些隐藏列的值)。设计InnoDB的大佬把这个阶段称之为delete mark。\n把这个过程画下来就是这样:\n可以看到,正常记录链表中的最后一条记录的delete_mask值被设置为1,但是并没有被加入到垃圾链表。也就是此时记录处于一个中间状态,跟猪八戒照镜子——里外不是人似的。在删除语句所在的事务提交之前,被删除的记录一直都处于这种所谓的中间状态。\n小贴士:为什么会有这种奇怪的中间状态呢?其实主要是为了实现一个称之为MVCC的功能,稍后再介绍。\n阶段二:当该删除语句所在的事务提交之后,会有专门的线程后来真正的把记录删除掉。所谓真正的删除就是把该记录从正常记录链表中移除,并且加入到垃圾链表中,然后还要调整一些页面的其他信息,比如页面中的用户记录数量PAGE_N_RECS、上次插入记录的位置PAGE_LAST_INSERT、垃圾链表头节点的指针PAGE_FREE、页面中可重用的字节数量PAGE_GARBAGE、还有页目录的一些信息等等。设计InnoDB的大佬把这个阶段称之为purge。\n把阶段二执行完了,这条记录就算是真正的被删除掉了。这条已删除记录占用的存储空间也可以被重新利用了。画下来就是这样:\n对照着图我们还要注意一点,将被删除记录加入到垃圾链表时,实际上加入到链表的头节点处,会跟着修改PAGE_FREE属性的值。\n小贴士:页面的Page Header部分有一个PAGE_GARBAGE属性,该属性记录着当前页面中可重用存储空间占用的总字节数。每当有已删除记录被加入到垃圾链表后,都会把这个PAGE_GARBAGE属性的值加上该已删除记录占用的存储空间大小。PAGE_FREE指向垃圾链表的头节点,之后每当新插入记录时,首先判断PAGE_FREE指向的头节点代表的已删除记录占用的存储空间是否足够容纳这条新插入的记录,如果不可以容纳,就直接向页面中申请新的空间来存储这条记录(是的,你没看错,并不会尝试遍历整个垃圾链表,找到一个可以容纳新记录的节点)。如果可以容纳,那么直接重用这条已删除记录的存储空间,并且把PAGE_FREE指向垃圾链表中的下一条已删除记录。但是这里有一个问题,如果新插入的那条记录占用的存储空间大小小于垃圾链表的头节点占用的存储空间大小,那就意味头节点对应的记录占用的存储空间里有一部分空间用不到,这部分空间就被称之为碎片空间。那这些碎片空间岂不是永远都用不到了么?其实也不是,这些碎片空间占用的存储空间大小会被统计到PAGE_GARBAGE属性中,这些碎片空间在整个页面快使用完前并不会被重新利用,不过当页面快满时,如果再插入一条记录,此时页面中并不能分配一条完整记录的空间,这时候会首先看一看PAGE_GARBAGE的空间和剩余可利用的空间加起来是不是可以容纳下这条记录,如果可以的话,InnoDB会尝试重新组织页内的记录,重新组织的过程就是先开辟一个临时页面,把页面内的记录依次插入一遍,因为依次插入时并不会产生碎片,之后再把临时页面的内容复制到本页面,这样就可以把那些碎片空间都解放出来(很显然重新组织页面内的记录比较耗费性能)。\n从上面的描述中我们也可以看出来,在删除语句所在的事务提交之前,只会经历阶段一,也就是delete mark阶段(提交之后我们就不用回滚了,所以只需考虑对删除操作的阶段一做的影响进行回滚)。设计InnoDB的大佬为此设计了一种称之为TRX_UNDO_DEL_MARK_REC类型的undo日志,它的完整结构如下图所示:\n这个里边的属性也太多了点儿吧~(其实大部分属性的意思我们上面已经介绍过了) 是的,的确有点多,不过大家千万不要在意,如果记不住千万不要勉强自己,我这里把它们都列出来让大家混个脸熟而已。劳烦大家先克服一下密集恐急症,再抬头大致看一遍上面的这个类型为TRX_UNDO_DEL_MARK_REC的undo日志中的属性,特别注意一下这几点:\n在对一条记录进行delete mark操作前,需要把该记录的旧的trx_id和roll_pointer隐藏列的值都给记到对应的undo日志中来,就是我们图中显示的old trx_id和old roll_pointer属性。这样有一个好处,那就是可以通过undo日志的old roll_pointer找到记录在修改之前对应的undo日志。比方说在一个事务中,我们先插入了一条记录,然后又执行对该记录的删除操作,这个过程的示意图就是这样:\n从图中可以看出来,执行完delete mark操作后,它对应的undo日志和INSERT操作对应的undo日志就串成了一个链表。这个很有意思啊,这个链表就称之为版本链,现在貌似看不出这个版本链有什么用,等我们再往后看看,讲完UPDATE操作对应的undo日志后,这个所谓的版本链就慢慢的展现出它的牛逼之处了。\n与类型为TRX_UNDO_INSERT_REC的undo日志不同,类型为TRX_UNDO_DEL_MARK_REC的undo日志还多了一个索引列各列信息的内容,也就是说如果某个列被包含在某个索引中,那么它的相关信息就应该被记录到这个索引列各列信息部分,所谓的相关信息包括该列在记录中的位置(用pos表示),该列占用的存储空间大小(用len表示),该列实际值(用value表示)。所以索引列各列信息存储的内容实质上就是\u0026lt;pos, len, value\u0026gt;的一个列表。这部分信息主要是用在事务提交后,对该中间状态记录做真正删除的阶段二,也就是purge阶段中使用的,具体如何使用现在我们可以忽略~\n该介绍的我们介绍完了,现在继续在上面那个事务id为100的事务中删除一条记录,比如我们把id为1的那条记录删除掉: ``` BEGIN; # 显式开启一个事务,假设该事务的id为100\n插入两条记录\nINSERT INTO undo_demo(id, key1, col) VALUES (1, \u0026lsquo;AWM\u0026rsquo;, \u0026lsquo;狙击枪\u0026rsquo;), (2, \u0026lsquo;M416\u0026rsquo;, \u0026lsquo;步枪\u0026rsquo;);\n删除一条记录\nDELETE FROM undo_demo WHERE id = 1; ``` 这个delete mark操作对应的undo日志的结构就是这样:\n对照着这个图,我们得注意下面几点: - 因为这条undo日志是id为100的事务中产生的第3条undo日志,所以它对应的undo no就是2。 - 在对记录做delete mark操作时,记录的trx_id隐藏列的值是100(也就是说对该记录最近的一次修改就发生在本事务中),所以把100填入old trx_id属性中。然后把记录的roll_pointer隐藏列的值取出来,填入old roll_pointer属性中,这样就可以通过old roll_pointer属性值找到最近一次对该记录做改动时产生的undo日志。 - 由于undo_demo表中有2个索引:一个是聚簇索引,一个是二级索引idx_key1。只要是包含在索引中的列,那么这个列在记录中的位置(pos),占用存储空间大小(len)和实际值(value)就需要存储到undo日志中。\n- 对于主键来说,只包含一个`id`列,存储到`undo日志`中的相关信息分别是: - `pos`:`id`列是主键,也就是在记录的第一个列,它对应的`pos`值为`0`。`pos`占用1个字节来存储。 - `len`:`id`列的类型为`INT`,占用4个字节,所以`len`的值为`4`。`len`占用1个字节来存储。 - `value`:在被删除的记录中`id`列的值为`1`,也就是`value`的值为`1`。`value`占用4个字节来存储。 画一个图演示一下就是这样: ![][22-12] 所以对于`id`列来说,最终存储的结果就是`\u0026lt;0, 4, 1\u0026gt;`,存储这些信息占用的存储空间大小为`1 + 1 + 4 = 6`个字节。 - 对于`idx_key1`来说,只包含一个`key1`列,存储到`undo日志`中的相关信息分别是: - `pos`:`key1`列是排在`id`列、`trx_id`列、`roll_pointer`列之后的,它对应的`pos`值为`3`。`pos`占用1个字节来存储。 - `len`:`key1`列的类型为`VARCHAR(100)`,使用`utf8`字符集,被删除的记录实际存储的内容是`AWM`,所以一共占用3个字节,也就是所以`len`的值为`3`。`len`占用1个字节来存储。 - `value`:在被删除的记录中`key1`列的值为`AWM`,也就是`value`的值为`AWM`。`value`占用3个字节来存储。 画一个图演示一下就是这样: ![][22-13] 所以对于`key1`列来说,最终存储的结果就是`\u0026lt;3, 3, 'AWM'\u0026gt;`,存储这些信息占用的存储空间大小为`1 + 1 + 3 = 5`个字节。 从上面的叙述中可以看到,`\u0026lt;0, 4, 1\u0026gt;`和`\u0026lt;3, 3, 'AWM'\u0026gt;`共占用`11`个字节。然后`index_col_info len`本身占用`2`个字节,所以加起来一共占用`13`个字节,把数字`13`就填到了`index_col_info len`的属性中。 UPDATE操作对应的undo日志 # 在执行UPDATE语句时,InnoDB对更新主键和不更新主键这两种情况有截然不同的处理方案。\n不更新主键的情况 # 在不更新主键的情况下,又可以细分为被更新的列占用的存储空间不发生变化和发生变化的情况。\n就地更新(in-place update)\n更新记录时,对于被更新的每个列来说,如果更新后的列和更新前的列占用的存储空间都一样大,那么就可以进行就地更新,也就是直接在原记录的基础上修改对应列的值。再次强调一边,是每个列在更新前后占用的存储空间一样大,有任何一个被更新的列更新前比更新后占用的存储空间大,或者更新前比更新后占用的存储空间小都不能进行就地更新。比方说现在undo_demo表里还有一条id值为2的记录,它的各个列占用的大小如图所示(因为采用utf8字符集,所以'步枪'这两个字符占用6个字节):\n假如我们有这样的UPDATE语句: UPDATE undo_demo SET key1 = 'P92', col = '手枪' WHERE id = 2; 在这个UPDATE语句中,col列从步枪被更新为手枪,前后都占用6个字节,也就是占用的存储空间大小未改变;key1列从M416被更新为P92,也就是从4个字节被更新为3个字节,这就不满足就地更新需要的条件了,所以不能进行就地更新。但是如果UPDATE语句长这样:\nUPDATE undo_demo SET key1 = 'M249', col = '机枪' WHERE id = 2; 由于各个被更新的列在更新前后占用的存储空间是一样大的,所以这样的语句可以执行就地更新。\n先删除掉旧记录,再插入新记录\n在不更新主键的情况下,如果有任何一个被更新的列更新前和更新后占用的存储空间大小不一致,那么就需要先把这条旧的记录从聚簇索引页面中删除掉,然后再根据更新后列的值创建一条新的记录插入到页面中。\n请注意一下,我们这里所说的删除并不是delete mark操作,而是真正的删除掉,也就是把这条记录从正常记录链表中移除并加入到垃圾链表中,并且修改页面中相应的统计信息(比如PAGE_FREE、PAGE_GARBAGE等这些信息)。不过这里做真正删除操作的线程并不是在介绍DELETE语句中做purge操作时使用的另外专门的线程,而是由用户线程同步执行真正的删除操作,真正删除之后紧接着就要根据各个列更新后的值创建的新记录插入。\n这里如果新创建的记录占用的存储空间大小不超过旧记录占用的空间,那么可以直接重用被加入到垃圾链表中的旧记录所占用的存储空间,否则的话需要在页面中新申请一段空间以供新记录使用,如果本页面内已经没有可用的空间的话,那就需要进行页面分裂操作,然后再插入新记录。\n针对UPDATE不更新主键的情况(包括上面所说的就地更新和先删除旧记录再插入新记录),设计InnoDB的大佬们设计了一种类型为TRX_UNDO_UPD_EXIST_REC的undo日志,它的完整结构如下:\n其实大部分属性和我们介绍过的TRX_UNDO_DEL_MARK_REC类型的undo日志是类似的,不过还是要注意这么几点:\nn_updated属性表示本条UPDATE语句执行后将有几个列被更新,后边跟着的\u0026lt;pos, old_len, old_value\u0026gt;分别表示被更新列在记录中的位置、更新前该列占用的存储空间大小、更新前该列的真实值。\n如果在UPDATE语句中更新的列包含索引列,那么也会添加索引列各列信息这个部分,否则的话是不会添加这个部分的。\n现在继续在上面那个事务id为100的事务中更新一条记录,比如我们把id为2的那条记录更新一下: ``` BEGIN; # 显式开启一个事务,假设该事务的id为100\n插入两条记录\nINSERT INTO undo_demo(id, key1, col) VALUES (1, \u0026lsquo;AWM\u0026rsquo;, \u0026lsquo;狙击枪\u0026rsquo;), (2, \u0026lsquo;M416\u0026rsquo;, \u0026lsquo;步枪\u0026rsquo;);\n删除一条记录\nDELETE FROM undo_demo WHERE id = 1;\n更新一条记录\nUPDATE undo_demo SET key1 = \u0026lsquo;M249\u0026rsquo;, col = \u0026lsquo;机枪\u0026rsquo; WHERE id = 2; ``` 这个UPDATE语句更新的列大小都没有改动,所以可以采用就地更新的方式来执行,在真正改动页面记录时,会先记录一条类型为TRX_UNDO_UPD_EXIST_REC的undo日志,长这样:\n对照着这个图我们注意一下这几个地方: - 因为这条undo日志是id为100的事务中产生的第4条undo日志,所以它对应的undo no就是3。 - 这条日志的roll_pointer指向undo no为1的那条日志,也就是插入主键值为2的记录时产生的那条undo日志,也就是最近一次对该记录做改动时产生的undo日志。 - 由于本条UPDATE语句中更新了索引列key1的值,所以需要记录一下索引列各列信息部分,也就是把主键和key1列更新前的信息填入。\n更新主键的情况 # 在聚簇索引中,记录是按照主键值的大小连成了一个单向链表的,如果我们更新了某条记录的主键值,意味着这条记录在聚簇索引中的位置将会发生改变,比如你将记录的主键值从1更新为10000,如果还有非常多的记录的主键值分布在1 ~ 10000之间的话,那么这两条记录在聚簇索引中就有可能离得非常远,甚至中间隔了好多个页面。针对UPDATE语句中更新了记录主键值的这种情况,InnoDB在聚簇索引中分了两步处理:\n将旧记录进行delete mark操作\n高能注意:这里是delete mark操作!这里是delete mark操作!这里是delete mark操作!也就是说在UPDATE语句所在的事务提交前,对旧记录只做一个delete mark操作,在事务提交后才由专门的线程做purge操作,把它加入到垃圾链表中。这里一定要和我们上面所说的在不更新记录主键值时,先真正删除旧记录,再插入新记录的方式区分开!\n小贴士:之所以只对旧记录做delete mark操作,是因为别的事务同时也可能访问这条记录,如果把它真正的删除加入到垃圾链表后,别的事务就访问不到了。这个功能就是所谓的MVCC,我们后边的章节中会详细介绍什么是个MVCC。\n根据更新后各列的值创建一条新记录,并将其插入到聚簇索引中(需重新定位插入的位置)。\n由于更新后的记录主键值发生了改变,所以需要重新从聚簇索引中定位这条记录所在的位置,然后把它插进去。\n针对UPDATE语句更新记录主键值的这种情况,在对该记录进行delete mark操作前,会记录一条类型为TRX_UNDO_DEL_MARK_REC的undo日志;之后插入新记录时,会记录一条类型为TRX_UNDO_INSERT_REC的undo日志,也就是说每对一条记录的主键值做改动时,会记录2条undo日志。这些日志的格式我们上面都介绍过了,就不赘述了。\n小贴士:其实还有一种称为TRX_UNDO_UPD_DEL_REC的undo日志的类型我们没有介绍,主要是想避免引入过多的复杂度,如果大家对这种类型的undo日志的使用感兴趣的话,可以额外查一下别的资料。\n"},{"id":12,"href":"/zh/docs/technology/MySQL/MySQL%E6%98%AF%E6%80%8E%E6%A0%B7%E8%BF%90%E8%A1%8C%E7%9A%84/%E7%AC%AC21%E7%AB%A0_%E8%AF%B4%E8%BF%87%E7%9A%84%E8%AF%9D%E5%B0%B1%E4%B8%80%E5%AE%9A%E8%A6%81%E5%8A%9E%E5%88%B0-redo%E6%97%A5%E5%BF%97%E4%B8%8B/","title":"第21章_说过的话就一定要办到-redo日志(下)","section":"My Sql是怎样运行的","content":"第21章 说过的话就一定要办到-redo日志(下)\nredo日志文件 # redo日志刷盘时机 # 我们前面说mtr运行过程中产生的一组redo日志在mtr结束时会被复制到log buffer中,可是这些日志总在内存里呆着也不是个办法,在一些情况下它们会被刷新到磁盘里,比如:\nlog buffer空间不足时\nlog buffer的大小是有限的(通过系统变量innodb_log_buffer_size指定),如果不停的往这个有限大小的log buffer里塞入日志,很快它就会被填满。设计InnoDB的大佬认为如果当前写入log buffer的redo日志量已经占满了log buffer总容量的大约一半左右,就需要把这些日志刷新到磁盘上。\n事务提交时\n我们前面说过之所以使用redo日志主要是因为它占用的空间少,还是顺序写,在事务提交时可以不把修改过的Buffer Pool页面刷新到磁盘,但是为了保证持久性,必须要把修改这些页面对应的redo日志刷新到磁盘。\n后台线程不停的刷刷刷\n后台有一个线程,大约每秒都会刷新一次log buffer中的redo日志到磁盘。\n正常关闭服务器时\n做所谓的checkpoint时(我们现在没介绍过checkpoint的概念,稍后会仔细介绍,稍安勿躁) 其他的一些情况\u0026hellip; redo日志文件组 # MySQL的数据目录(使用SHOW VARIABLES LIKE 'datadir'查看)下默认有两个名为ib_logfile0和ib_logfile1的文件,log buffer中的日志默认情况下就是刷新到这两个磁盘文件中。如果我们对默认的redo日志文件不满意,可以通过下面几个启动参数来调节:\ninnodb_log_group_home_dir\n该参数指定了redo日志文件所在的目录,默认值就是当前的数据目录。\ninnodb_log_file_size\n该参数指定了每个redo日志文件的大小,在MySQL 5.7.21这个版本中的默认值为48MB,\ninnodb_log_files_in_group\n该参数指定redo日志文件的个数,默认值为2,最大值为100。\n从上面的描述中可以看到,磁盘上的redo日志文件不只一个,而是以一个日志文件组的形式出现的。这些文件以ib_logfile[数字](数字可以是0、1、2\u0026hellip;)的形式进行命名。在将redo日志写入日志文件组时,是从ib_logfile0开始写,如果ib_logfile0写满了,就接着ib_logfile1写,同理,ib_logfile1写满了就去写ib_logfile2,依此类推。如果写到最后一个文件该咋办?那就重新转到ib_logfile0继续写,所以整个过程如下图所示:\n总共的redo日志文件大小其实就是:innodb_log_file_size × innodb_log_files_in_group。 小贴士:如果采用循环使用的方式向redo日志文件组里写数据的话,那岂不是要追尾,也就是后写入的redo日志覆盖掉前面写的redo日志?当然可能了!所以设计InnoDB的大佬提出了checkpoint的概念,稍后我们重点介绍~\nredo日志文件格式 # 我们前面说过log buffer本质上是一片连续的内存空间,被划分成了若干个512字节大小的block。将log buffer中的redo日志刷新到磁盘的本质就是把block的镜像写入日志文件中,所以redo日志文件其实也是由若干个512字节大小的block组成。\nredo日志文件组中的每个文件大小都一样,格式也一样,都是由两部分组成:\n前2048个字节,也就是前4个block是用来存储一些管理信息的。 从第2048字节往后是用来存储log buffer中的block镜像的。 所以我们前面所说的循环使用redo日志文件,其实是从每个日志文件的第2048个字节开始算,画个示意图就是这样:\n普通block的格式我们在介绍log buffer的时候都说过了,就是log block header、log block body、log block trialer这三个部分,就不重复介绍了。这里需要介绍一下每个redo日志文件前2048个字节,也就是前4个特殊block的格式都是干嘛的,废话少说,先看图:\n从图中可以看出来,这4个block分别是:\nlog file header:描述该redo日志文件的一些整体属性,看一下它的结构:\n各个属性的具体释义如下: 属性名 长度(单位:字节) 描述 LOG_HEADER_FORMAT 4 redo日志的版本,在MySQL 5.7.21中该值永远为1 LOG_HEADER_PAD1 4 做字节填充用的,没什么实际意义,忽略~ LOG_HEADER_START_LSN 8 标记本redo日志文件开始的LSN值,也就是文件偏移量为2048字节初对应的LSN值(关于什么是LSN我们稍后再看,看不懂的先忽略)。 LOG_HEADER_CREATOR 32 一个字符串,标记本redo日志文件的创建者是谁。正常运行时该值为MySQL的版本号,比如:\u0026quot;MySQL 5.7.21\u0026quot;,使用mysqlbackup命令创建的redo日志文件的该值为\u0026quot;ibbackup\u0026quot;和创建时间。 LOG_BLOCK_CHECKSUM 4 本block的校验值,所有block都有,我们不关心 小贴士:设计InnoDB的大佬对redo日志的block格式做了很多次修改,如果你阅读的其他书籍中发现上述的属性和你阅读书籍中的属性有些出入,不要慌,正常现象,忘记以前的版本吧。另外,LSN值我们后边才会介绍,现在千万别纠结LSN是什么。\ncheckpoint1:记录关于checkpoint的一些属性,看一下它的结构:\n各个属性的具体释义如下: 属性名 长度(单位:字节) 描述 LOG_CHECKPOINT_NO 8 服务器做checkpoint的编号,每做一次checkpoint,该值就加1。 LOG_CHECKPOINT_LSN 8 服务器做checkpoint结束时对应的LSN值,系统奔溃恢复时将从该值开始。 LOG_CHECKPOINT_OFFSET 8 上个属性中的LSN值在redo日志文件组中的偏移量 LOG_CHECKPOINT_LOG_BUF_SIZE 8 服务器在做checkpoint操作时对应的log buffer的大小 LOG_BLOCK_CHECKSUM 4 本block的校验值,所有block都有,我们不关心 小贴士:现在看不懂上面这些关于checkpoint和LSN的属性的释义是很正常的,我就是想让大家对上面这些属性混个脸熟,后边我们后详细介绍的。\n第三个block未使用,忽略~\ncheckpoint2:结构和checkpoint1一样。\nLog Sequeue Number # 自系统开始运行,就不断的在修改页面,也就意味着会不断的生成redo日志。redo日志的量在不断的递增,就像人的年龄一样,自打出生起就不断递增,永远不可能缩减了。设计InnoDB的大佬为记录已经写入的redo日志量,设计了一个称之为Log Sequeue Number的全局变量,翻译过来就是:日志序列号,简称lsn。不过不像人一出生的年龄是0岁,设计InnoDB的大佬规定初始的lsn值为8704(也就是一条redo日志也没写入时,lsn的值为8704)。\n我们知道在向log buffer中写入redo日志时不是一条一条写入的,而是以一个mtr生成的一组redo日志为单位进行写入的。而且实际上是把日志内容写在了log block body处。但是在统计lsn的增长量时,是按照实际写入的日志量加上占用的log block header和log block trailer来计算的。我们来看一个例子:\n系统第一次启动后初始化log buffer时,buf_free(就是标记下一条redo日志应该写入到log buffer的位置的变量)就会指向第一个block的偏移量为12字节(log block header的大小)的地方,那么lsn值也会跟着增加12:\n如果某个mtr产生的一组redo日志占用的存储空间比较小,也就是待插入的block剩余空闲空间能容纳这个mtr提交的日志时,lsn增长的量就是该mtr生成的redo日志占用的字节数,就像这样:\n我们假设上图中mtr_1产生的redo日志量为200字节,那么lsn就要在8716的基础上增加200,变为8916。\n如果某个mtr产生的一组redo日志占用的存储空间比较大,也就是待插入的block剩余空闲空间不足以容纳这个mtr提交的日志时,lsn增长的量就是该mtr生成的redo日志占用的字节数加上额外占用的log block header和log block trailer的字节数,就像这样:\n我们假设上图中mtr_2产生的redo日志量为1000字节,为了将mtr_2产生的redo日志写入log buffer,我们不得不额外多分配两个block,所以lsn的值需要在8916的基础上增加1000 + 12×2 + 4 × 2 = 1032。\n小贴士:为什么初始的lsn值为8704呢?我也不太清楚,人家就这么规定的。其实你也可以规定你一生下来算1岁,只要保证随着时间的流逝,你的年龄不断增长就好了。 从上面的描述中可以看出来,每一组由mtr生成的redo日志都有一个唯一的LSN值与其对应,LSN值越小,说明redo日志产生的越早。\nflushed_to_disk_lsn # redo日志是首先写到log buffer中,之后才会被刷新到磁盘上的redo日志文件。所以设计InnoDB的大佬提出了一个称之为buf_next_to_write的全局变量,标记当前log buffer中已经有哪些日志被刷新到磁盘中了。画个图表示就是这样:\n我们前面说lsn是表示当前系统中写入的redo日志量,这包括了写到log buffer而没有刷新到磁盘的日志,相应的,设计InnoDB的大佬提出了一个表示刷新到磁盘中的redo日志量的全局变量,称之为flushed_to_disk_lsn。系统第一次启动时,该变量的值和初始的lsn值是相同的,都是8704。随着系统的运行,redo日志被不断写入log buffer,但是并不会立即刷新到磁盘,lsn的值就和flushed_to_disk_lsn的值拉开了差距。我们演示一下:\n系统第一次启动后,向log buffer中写入了mtr_1、mtr_2、mtr_3这三个mtr产生的redo日志,假设这三个mtr开始和结束时对应的lsn值分别是:\n+ `mtr_1`:8716 ~ 8916 + `mtr_2`:8916 ~ 9948 + `mtr_3`:9948 ~ 10000 此时的lsn已经增长到了10000,但是由于没有刷新操作,所以此时flushed_to_disk_lsn的值仍为8704,如图:\n随后进行将log buffer中的block刷新到redo日志文件的操作,假设将mtr_1和mtr_2的日志刷新到磁盘,那么flushed_to_disk_lsn就应该增长mtr_1和mtr_2写入的日志量,所以flushed_to_disk_lsn的值增长到了9948,如图:\n综上所述,当有新的redo日志写入到log buffer时,首先lsn的值会增长,但flushed_to_disk_lsn不变,随后随着不断有log buffer中的日志被刷新到磁盘上,flushed_to_disk_lsn的值也跟着增长。如果两者的值相同时,说明log buffer中的所有redo日志都已经刷新到磁盘中了。\n小贴士:应用程序向磁盘写入文件时其实是先写到操作系统的缓冲区中去,如果某个写入操作要等到操作系统确认已经写到磁盘时才返回,那需要调用一下操作系统提供的fsync函数。其实只有当系统执行了fsync函数后,flushed_to_disk_lsn的值才会跟着增长,当仅仅把log buffer中的日志写入到操作系统缓冲区却没有显式的刷新到磁盘时,另外的一个称之为write_lsn的值跟着增长。不过为了大家理解上的方便,我们在讲述时把flushed_to_disk_lsn和write_lsn的概念混淆了起来。\nlsn值和redo日志文件偏移量的对应关系 # 因为lsn的值是代表系统写入的redo日志量的一个总和,一个mtr中产生多少日志,lsn的值就增加多少(当然有时候要加上log block header和log block trailer的大小),这样mtr产生的日志写到磁盘中时,很容易计算某一个lsn值在redo日志文件组中的偏移量,如图:\n初始时的LSN值是8704,对应文件偏移量2048,之后每个mtr向磁盘中写入多少字节日志,lsn的值就增长多少。\nflush链表中的LSN # 我们知道一个mtr代表一次对底层页面的原子访问,在访问过程中可能会产生一组不可分割的redo日志,在mtr结束时,会把这一组redo日志写入到log buffer中。除此之外,在mtr结束时还有一件非常重要的事情要做,就是把在mtr执行过程中可能修改过的页面加入到Buffer Pool的flush链表。为了防止大家早已忘记flush链表是什么,我们再看一下图:\n当第一次修改某个缓存在Buffer Pool中的页面时,就会把这个页面对应的控制块插入到flush链表的头部,之后再修改该页面时由于它已经在flush链表中了,就不再次插入了。也就是说flush链表中的脏页是按照页面的第一次修改时间从大到小进行排序的。在这个过程中会在缓存页对应的控制块中记录两个关于页面何时修改的属性:\noldest_modification:如果某个页面被加载到Buffer Pool后进行第一次修改,那么就将修改该页面的mtr开始时对应的lsn值写入这个属性。\nnewest_modification:每修改一次页面,都会将修改该页面的mtr结束时对应的lsn值写入这个属性。也就是说该属性表示页面最近一次修改后对应的系统lsn值。\n我们接着上面介绍flushed_to_disk_lsn的例子看一下:\n假设mtr_1执行过程中修改了页a,那么在mtr_1执行结束时,就会将页a对应的控制块加入到flush链表的头部。并且将mtr_1开始时对应的lsn,也就是8716写入页a对应的控制块的oldest_modification属性中,把mtr_1结束时对应的lsn,也就是8916写入页a对应的控制块的newest_modification属性中。画个图表示一下(为了让图片美观一些,我们把oldest_modification缩写成了o_m,把newest_modification缩写成了n_m):\n接着假设mtr_2执行过程中又修改了页b和页c两个页面,那么在mtr_2执行结束时,就会将页b和页c对应的控制块都加入到flush链表的头部。并且将mtr_2开始时对应的lsn,也就是8916写入页b和页c对应的控制块的oldest_modification属性中,把mtr_2结束时对应的lsn,也就是9948写入页b和页c对应的控制块的newest_modification属性中。画个图表示一下:\n从图中可以看出来,每次新插入到flush链表中的节点都是被放在了头部,也就是说flush链表中前面的脏页修改的时间比较晚,后边的脏页修改时间比较早。\n接着假设mtr_3执行过程中修改了页b和页d,不过页b之前已经被修改过了,所以它对应的控制块已经被插入到了flush链表,所以在mtr_3执行结束时,只需要将页d对应的控制块都加入到flush链表的头部即可。所以需要将mtr_3开始时对应的lsn,也就是9948写入页d对应的控制块的oldest_modification属性中,把mtr_3结束时对应的lsn,也就是10000写入页d对应的控制块的newest_modification属性中。另外,由于页b在mtr_3执行过程中又发生了一次修改,所以需要更新页b对应的控制块中newest_modification的值为10000。画个图表示一下:\n总结一下上面说的,就是:flush链表中的脏页按照修改发生的时间顺序进行排序,也就是按照oldest_modification代表的LSN值进行排序,被多次更新的页面不会重复插入到flush链表中,但是会更新newest_modification属性的值。\ncheckpoint # 有一个很不幸的事实就是我们的redo日志文件组容量是有限的,我们不得不选择循环使用redo日志文件组中的文件,但是这会造成最后写的redo日志与最开始写的redo日志追尾,这时应该想到:redo日志只是为了系统奔溃后恢复脏页用的,如果对应的脏页已经刷新到了磁盘,也就是说即使现在系统奔溃,那么在重启后也用不着使用redo日志恢复该页面了,所以该redo日志也就没有存在的必要了,那么它占用的磁盘空间就可以被后续的redo日志所重用。也就是说:判断某些redo日志占用的磁盘空间是否可以覆盖的依据就是它对应的脏页是否已经刷新到磁盘里。我们看一下前面一直介绍的那个例子:\n如图,虽然mtr_1和mtr_2生成的redo日志都已经被写到了磁盘上,但是它们修改的脏页仍然留在Buffer Pool中,所以它们生成的redo日志在磁盘上的空间是不可以被覆盖的。之后随着系统的运行,如果页a被刷新到了磁盘,那么它对应的控制块就会从flush链表中移除,就像这样子:\n这样mtr_1生成的redo日志就没有用了,它们占用的磁盘空间就可以被覆盖掉了。设计InnoDB的大佬提出了一个全局变量checkpoint_lsn来代表当前系统中可以被覆盖的redo日志总量是多少,这个变量初始值也是8704。\n比方说现在页a被刷新到了磁盘,mtr_1生成的redo日志就可以被覆盖了,所以我们可以进行一个增加checkpoint_lsn的操作,我们把这个过程称之为做一次checkpoint。做一次checkpoint其实可以分为两个步骤:\n步骤一:计算一下当前系统中可以被覆盖的redo日志对应的lsn值最大是多少。\nredo日志可以被覆盖,意味着它对应的脏页被刷到了磁盘,只要我们计算出当前系统中被最早修改的脏页对应的oldest_modification值,那凡是在系统lsn值小于该节点的oldest_modification值时产生的redo日志都是可以被覆盖掉的,我们就把该脏页的oldest_modification赋值给checkpoint_lsn。\n比方说当前系统中页a已经被刷新到磁盘,那么flush链表的尾节点就是页c,该节点就是当前系统中最早修改的脏页了,它的oldest_modification值为8916,我们就把8916赋值给checkpoint_lsn(也就是说在redo日志对应的lsn值小于8916时就可以被覆盖掉)。\n步骤二:将checkpoint_lsn和对应的redo日志文件组偏移量以及此次checkpint的编号写到日志文件的管理信息(就是checkpoint1或者checkpoint2)中。\n设计InnoDB的大佬维护了一个目前系统做了多少次checkpoint的变量checkpoint_no,每做一次checkpoint,该变量的值就加1。我们前面说过计算一个lsn值对应的redo日志文件组偏移量是很容易的,所以可以计算得到该checkpoint_lsn在redo日志文件组中对应的偏移量checkpoint_offset,然后把这三个值都写到redo日志文件组的管理信息中。\n我们说过,每一个redo日志文件都有2048个字节的管理信息,但是上述关于checkpoint的信息只会被写到日志文件组的第一个日志文件的管理信息中。不过我们是存储到checkpoint1中还是checkpoint2中呢?设计InnoDB的大佬规定,当checkpoint_no的值是偶数时,就写到checkpoint1中,是奇数时,就写到checkpoint2中。\n记录完checkpoint的信息之后,redo日志文件组中各个lsn值的关系就像这样:\n批量从flush链表中刷出脏页 # 我们在介绍Buffer Pool的时候说过,一般情况下都是后台的线程在对LRU链表和flush链表进行刷脏操作,这主要因为刷脏操作比较慢,不想影响用户线程处理请求。但是如果当前系统修改页面的操作十分频繁,这样就导致写日志操作十分频繁,系统lsn值增长过快。如果后台的刷脏操作不能将脏页刷出,那么系统无法及时做checkpoint,可能就需要用户线程同步的从flush链表中把那些最早修改的脏页(oldest_modification最小的脏页)刷新到磁盘,这样这些脏页对应的redo日志就没用了,然后就可以去做checkpoint了。\n查看系统中的各种LSN值 # 我们可以使用SHOW ENGINE INNODB STATUS命令查看当前InnoDB存储引擎中的各种LSN值的情况,比如:\n(...省略前面的许多状态) LOG * * * Log sequence number 124476971 Log flushed up to 124099769 Pages flushed up to 124052503 Last checkpoint at 124052494 0 pending log flushes, 0 pending chkp writes 24 log i/o\u0026#39;s done, 2.00 log i/o\u0026#39;s/second * * * (...省略后边的许多状态) ``` 其中: -`Log sequence number`:代表系统中的`lsn`值,也就是当前系统已经写入的`redo`日志量,包括写入`log buffer`中的日志。 -`Log flushed up to`:代表`flushed_to_disk_lsn`的值,也就是当前系统已经写入磁盘的`redo`日志量。 -`Pages flushed up to`:代表`flush链表`中被最早修改的那个页面对应的`oldest_modification`属性值。 -`Last checkpoint at`:当前系统的`checkpoint_lsn`值。 # innodb_flush_log_at_trx_commit的用法 我们前面说为了保证事务的`持久性`,用户线程在事务提交时需要将该事务执行过程中产生的所有`redo`日志都刷新到磁盘上。这一条要求太狠了,会很明显的降低数据库性能。如果有的同学对事务的`持久性`要求不是那么强烈的话,可以选择修改一个称为`innodb_flush_log_at_trx_commit`的系统变量的值,该变量有3个可选的值: + `0`:当该系统变量值为0时,表示在事务提交时不立即向磁盘中同步`redo`日志,这个任务是交给后台线程做的。 这样很明显会加快请求处理速度,但是如果事务提交后服务器挂了,后台线程没有及时将`redo`日志刷新到磁盘,那么该事务对页面的修改会丢失。 + `1`:当该系统变量值为1时,表示在事务提交时需要将`redo`日志同步到磁盘,可以保证事务的`持久性`。`1`也是`innodb_flush_log_at_trx_commit`的默认值。 + `2`:当该系统变量值为2时,表示在事务提交时需要将`redo`日志写到操作系统的缓冲区中,但并不需要保证将日志真正的刷新到磁盘。 这种情况下如果数据库挂了,操作系统没挂的话,事务的`持久性`还是可以保证的,但是操作系统也挂了的话,那就不能保证`持久性`了。 # 崩溃恢复 在服务器不挂的情况下,`redo`日志简直就是个大累赘,不仅没用,反而让性能变得更差。但是万一,我说万一啊,万一数据库挂了,那`redo`日志可是个宝了,我们就可以在重启时根据`redo`日志中的记录就可以将页面恢复到系统奔溃前的状态。我们接下来大致看一下恢复过程是什么样。 ## 确定恢复的起点 我们前面说过,`checkpoint_lsn`之前的`redo`日志都可以被覆盖,也就是说这些`redo`日志对应的脏页都已经被刷新到磁盘中了,既然它们已经被刷盘,我们就没必要恢复它们了。对于`checkpoint_lsn`之后的`redo`日志,它们对应的脏页可能没被刷盘,也可能被刷盘了,我们不能确定,所以需要从`checkpoint_lsn`开始读取`redo`日志来恢复页面。 当然,`redo`日志文件组的第一个文件的管理信息中有两个block都存储了`checkpoint_lsn`的信息,我们当然是要选取最近发生的那次checkpoint的信息。衡量`checkpoint`发生时间早晚的信息就是所谓的`checkpoint_no`,我们只要把`checkpoint1`和`checkpoint2`这两个block中的`checkpoint_no`值读出来比一下大小,哪个的`checkpoint_no`值更大,说明哪个block存储的就是最近的一次`checkpoint`信息。这样我们就能拿到最近发生的`checkpoint`对应的`checkpoint_lsn`值以及它在`redo`日志文件组中的偏移量`checkpoint_offset`。 ## 确定恢复的终点 `redo`日志恢复的起点确定了,那终点是哪个呢?这个还得从block的结构说起。我们说在写`redo`日志的时候都是顺序写的,写满了一个block之后会再往下一个block中写: ![](img/21-20.png) 普通block的`log block header`部分有一个称之为`LOG_BLOCK_HDR_DATA_LEN`的属性,该属性值记录了当前block里使用了多少字节的空间。对于被填满的block来说,该值永远为`512`。如果该属性的值不为`512`,那么就是它了,它就是此次奔溃恢复中需要扫描的最后一个block。 ## 怎么恢复 确定了需要扫描哪些`redo`日志进行奔溃恢复之后,接下来就是怎么进行恢复了。假设现在的`redo`日志文件中有5条`redo`日志,如图: ![](img/21-21.png) 由于`redo 0`在`checkpoint_lsn`后边,恢复时可以不管它。我们现在可以按照`redo`日志的顺序依次扫描`checkpoint_lsn`之后的各条redo日志,按照日志中记载的内容将对应的页面恢复出来。这样没什么问题,不过设计`InnoDB`的大佬还是想了一些办法加快这个恢复的过程: + 使用哈希表 根据`redo`日志的`space ID`和`page number`属性计算出散列值,把`space ID`和`page number`相同的`redo`日志放到哈希表的同一个槽里,如果有多个`space ID`和`page number`都相同的`redo`日志,那么它们之间使用链表连接起来,按照生成的先后顺序链接起来的,如图所示: ![](img/21-22.png) 之后就可以遍历哈希表,因为对同一个页面进行修改的`redo`日志都放在了一个槽里,所以可以一次性将一个页面修复好(避免了很多读取页面的随机IO),这样可以加快恢复速度。另外需要注意一点的是,同一个页面的`redo`日志是按照生成时间顺序进行排序的,所以恢复的时候也是按照这个顺序进行恢复,如果不按照生成时间顺序进行排序的话,那么可能出现错误。比如原先的修改操作是先插入一条记录,再删除该条记录,如果恢复时不按照这个顺序来,就可能变成先删除一条记录,再插入一条记录,这显然是错误的。 + 跳过已经刷新到磁盘的页面 我们前面说过,`checkpoint_lsn`之前的`redo`日志对应的脏页确定都已经刷到磁盘了,但是`checkpoint_lsn`之后的`redo`日志我们不能确定是否已经刷到磁盘,主要是因为在最近做的一次`checkpoint`后,可能后台线程又不断的从`LRU链表`和`flush链表`中将一些脏页刷出`Buffer Pool`。这些在`checkpoint_lsn`之后的`redo`日志,如果它们对应的脏页在奔溃发生时已经刷新到磁盘,那在恢复时也就没有必要根据`redo`日志的内容修改该页面了。 那在恢复时怎么知道某个`redo`日志对应的脏页是否在奔溃发生时已经刷新到磁盘了呢?这还得从页面的结构说起,我们前面说过每个页面都有一个称之为`File Header`的部分,在`File Header`里有一个称之为`FIL_PAGE_LSN`的属性,该属性记载了最近一次修改页面时对应的`lsn`值(其实就是页面控制块中的`newest_modification`值)。如果在做了某次`checkpoint`之后有脏页被刷新到磁盘中,那么该页对应的`FIL_PAGE_LSN`代表的`lsn`值肯定大于`checkpoint_lsn`的值,凡是符合这种情况的页面就不需要重复执行lsn值小于`FIL_PAGE_LSN`的redo日志了,所以更进一步提升了奔溃恢复的速度。 # 遗漏的问题:LOG_BLOCK_HDR_NO是如何计算的 我们前面说过,对于实际存储`redo`日志的普通的`log block`来说,在`log block header`处有一个称之为`LOG_BLOCK_HDR_NO`的属性(忘记了的话回头再看看),我们说这个属性代表一个唯一的标号。这个属性是初次使用该block时分配的,跟当时的系统`lsn`值有关。使用下面的公式计算该block的`LOG_BLOCK_HDR_NO`值: `((lsn / 512) \u0026amp; 0x3FFFFFFFUL) + 1` 这个公式里的`0x3FFFFFFFUL`可能让大家有点困惑,其实它的二进制表示可能更亲切一点: ![](img/21-23.png) 从图中可以看出,`0x3FFFFFFFUL`对应的二进制数的前2位为0,后30位的值都为`1`。我们刚开始学计算机的时候就学过,一个二进制位与0做与运算(`\u0026amp;`)的结果肯定是0,一个二进制位与1做与运算(`\u0026amp;`)的结果就是原值。让一个数和`0x3FFFFFFFUL`做与运算的意思就是要将该值的前2个比特位的值置为0,这样该值就肯定小于或等于`0x3FFFFFFFUL`了。这也就说明了,不论lsn多大,`((lsn / 512) \u0026amp; 0x3FFFFFFFUL)`的值肯定在`0`\\\\~`0x3FFFFFFFUL`之间,再加1的话肯定在`1`\\\\~`0x40000000UL`之间。而`0x40000000UL`这个值大家应该很熟悉,这个值就代表着`1GB`。也就是说系统最多能产生不重复的`LOG_BLOCK_HDR_NO`值只有`1GB`个。设计InnoDB的大佬规定`redo`日志文件组中包含的所有文件大小总和不得超过512GB,一个block大小是512字节,也就是说redo日志文件组中包含的block块最多为1GB个,所以有1GB个不重复的编号值也就够用了。 另外,`LOG_BLOCK_HDR_NO`值的第一个比特位比较特殊,称之为`flush bit`,如果该值为1,代表着本block是在某次将`log buffer`中的block刷新到磁盘的操作中的第一个被刷入的block。 "},{"id":13,"href":"/zh/docs/technology/MySQL/MySQL%E6%98%AF%E6%80%8E%E6%A0%B7%E8%BF%90%E8%A1%8C%E7%9A%84/%E7%AC%AC20%E7%AB%A0_%E8%AF%B4%E8%BF%87%E7%9A%84%E8%AF%9D%E5%B0%B1%E4%B8%80%E5%AE%9A%E8%A6%81%E5%8A%9E%E5%88%B0-redo%E6%97%A5%E5%BF%97%E4%B8%8A/","title":"第20章_说过的话就一定要办到-redo日志(上)","section":"My Sql是怎样运行的","content":"第20章 说过的话就一定要办到-redo日志(上)\n事先说明 # 本文以及接下来的几篇文章将会频繁的使用到我们前面介绍的InnoDB记录行格式、页面格式、索引原理、表空间的组成等各种基础知识,如果大家对这些东西理解的不透彻,那么阅读下面的文字可能会有些吃力,为保证您的阅读体验,请确保自己已经掌握了我前面介绍的这些知识。\nredo日志是什么 # 我们知道InnoDB存储引擎是以页为单位来管理存储空间的,我们进行的增删改查操作其实本质上都是在访问页面(包括读页面、写页面、创建新页面等操作)。我们前面介绍Buffer Pool的时候说过,在真正访问页面之前,需要把在磁盘上的页缓存到内存中的Buffer Pool之后才可以访问。但是在介绍事务的时候又强调过一个称之为持久性的特性,就是说对于一个已经提交的事务,在事务提交后即使系统发生了崩溃,这个事务对数据库中所做的更改也不能丢失。但是如果我们只在内存的Buffer Pool中修改了页面,假设在事务提交后突然发生了某个故障,导致内存中的数据都失效了,那么这个已经提交了的事务对数据库中所做的更改也就跟着丢失了,这是我们所不能忍受的(想想ATM机已经提示狗哥转账成功,但之后由于服务器出现故障,重启之后猫爷发现自己没收到钱,猫爷就被砍死了)。那么如何保证这个持久性呢?一个很简单的做法就是在事务提交完成之前把该事务所修改的所有页面都刷新到磁盘,但是这个简单粗暴的做法有些问题:\n刷新一个完整的数据页太浪费了\n有时候我们仅仅修改了某个页面中的一个字节,但是我们知道在InnoDB中是以页为单位来进行磁盘IO的,也就是说我们在该事务提交时不得不将一个完整的页面从内存中刷新到磁盘,我们又知道一个页面默认是16KB大小,只修改一个字节就要刷新16KB的数据到磁盘上显然是太浪费了。\n随机IO刷起来比较慢\n一个事务可能包含很多语句,即使是一条语句也可能修改许多页面,倒霉催的是该事务修改的这些页面可能并不相邻,这就意味着在将某个事务修改的Buffer Pool中的页面刷新到磁盘时,需要进行很多的随机IO,随机IO比顺序IO要慢,尤其对于传统的机械硬盘来说。\n咋办呢?再次回到我们的初心:我们只是想让已经提交了的事务对数据库中数据所做的修改永久生效,即使后来系统崩溃,在重启后也能把这种修改恢复出来。所以我们其实没有必要在每次事务提交时就把该事务在内存中修改过的全部页面刷新到磁盘,只需要把修改了哪些东西记录一下就好,比方说某个事务将系统表空间中的第100号页面中偏移量为1000处的那个字节的值1改成2我们只需要记录一下:\n将第0号表空间的100号页面的偏移量为1000处的值更新为2。\n这样我们在事务提交时,把上述内容刷新到磁盘中,即使之后系统崩溃了,重启之后只要按照上述内容所记录的步骤重新更新一下数据页,那么该事务对数据库中所做的修改又可以被恢复出来,也就意味着满足持久性的要求。因为在系统奔溃重启时需要按照上述内容所记录的步骤重新更新数据页,所以上述内容也被称之为重做日志,英文名为redo log,我们也可以土洋结合,称之为redo日志。与在事务提交时将所有修改过的内存中的页面刷新到磁盘中相比,只将该事务执行过程中产生的redo日志刷新到磁盘的好处如下:\nredo日志占用的空间非常小\n存储表空间ID、页号、偏移量以及需要更新的值所需的存储空间是很小的,关于redo日志的格式我们稍后会详细介绍,现在只要知道一条redo日志占用的空间不是很大就好了。\nredo日志是顺序写入磁盘的\n在执行事务的过程中,每执行一条语句,就可能产生若干条redo日志,这些日志是按照产生的顺序写入磁盘的,也就是使用顺序IO。\nredo日志格式 # 通过上面的内容我们知道,redo日志本质上只是记录了一下事务对数据库做了哪些修改。 设计InnoDB的大佬们针对事务对数据库的不同修改场景定义了多种类型的redo日志,但是绝大部分类型的redo日志都有下面这种通用的结构:\n各个部分的详细释义如下:\ntype:该条redo日志的类型。\n在MySQL 5.7.21这个版本中,设计InnoDB的大佬一共为redo日志设计了53种不同的类型,稍后会详细介绍不同类型的redo日志。\nspace ID:表空间ID。\npage number:页号。 data:该条redo日志的具体内容。 简单的redo日志类型 # 我们前面介绍InnoDB的记录行格式的时候说过,如果我们没有为某个表显式的定义主键,并且表中也没有定义Unique键,那么InnoDB会自动的为表添加一个称之为row_id的隐藏列作为主键。为这个row_id隐藏列赋值的方式如下: - 服务器会在内存中维护一个全局变量,每当向某个包含隐藏的row_id列的表中插入一条记录时,就会把该变量的值当作新记录的row_id列的值,并且把该变量自增1。 - 每当这个变量的值为256的倍数时,就会将该变量的值刷新到系统表空间的页号为7的页面中一个称之为Max Row ID的属性处(我们前面介绍表空间结构时详细说过)。 - 当系统启动时,会将上面提到的Max Row ID属性加载到内存中,将该值加上256之后赋值给我们前面提到的全局变量(因为在上次关机时该全局变量的值可能大于Max Row ID属性值)。\n这个Max Row ID属性占用的存储空间是8个字节,当某个事务向某个包含row_id隐藏列的表插入一条记录,并且为该记录分配的row_id值为256的倍数时,就会向系统表空间页号为7的页面的相应偏移量处写入8个字节的值。但是我们要知道,这个写入实际上是在Buffer Pool中完成的,我们需要为这个页面的修改记录一条redo日志,以便在系统奔溃后能将已经提交的该事务对该页面所做的修改恢复出来。这种情况下对页面的修改是极其简单的,redo日志中只需要记录一下在某个页面的某个偏移量处修改了几个字节的值,具体被修改的内容是什么就好了,设计InnoDB的大佬把这种极其简单的redo日志称之为物理日志,并且根据在页面中写入数据的多少划分了几种不同的redo日志类型: - MLOG_1BYTE(type字段对应的十进制数字为1):表示在页面的某个偏移量处写入1个字节的redo日志类型。 - MLOG_2BYTE(type字段对应的十进制数字为2):表示在页面的某个偏移量处写入2个字节的redo日志类型。 - MLOG_4BYTE(type字段对应的十进制数字为4):表示在页面的某个偏移量处写入4个字节的redo日志类型。 - MLOG_8BYTE(type字段对应的十进制数字为8):表示在页面的某个偏移量处写入8个字节的redo日志类型。 - MLOG_WRITE_STRING(type字段对应的十进制数字为30):表示在页面的某个偏移量处写入一串数据。\n我们上面提到的Max Row ID属性实际占用8个字节的存储空间,所以在修改页面中的该属性时,会记录一条类型为MLOG_8BYTE的redo日志,MLOG_8BYTE的redo日志结构如下所示:\n其余MLOG_1BYTE、MLOG_2BYTE、MLOG_4BYTE类型的redo日志结构和MLOG_8BYTE的类似,只不过具体数据中包含对应个字节的数据罢了。MLOG_WRITE_STRING类型的redo日志表示写入一串数据,但是因为不能确定写入的具体数据占用多少字节,所以需要在日志结构中添加一个len字段:\n小贴士:只要将MLOG_WRITE_STRING类型的redo日志的len字段填充上1、2、4、8这些数字,就可以分别替代MLOG_1BYTE、MLOG_2BYTE、MLOG_4BYTE、MLOG_8BYTE这些类型的redo日志,为什么还要多此一举设计这么多类型呢?还不是因为省空间啊,能不写len字段就不写len字段,省一个字节算一个字节。\n复杂一些的redo日志类型 # 有时候执行一条语句会修改非常多的页面,包括系统数据页面和用户数据页面(用户数据指的就是聚簇索引和二级索引对应的B+树)。以一条INSERT语句为例,它除了要向B+树的页面中插入数据,也可能更新系统数据Max Row ID的值,不过对于我们用户来说,平时更关心的是语句对B+树所做更新:\n表中包含多少个索引,一条INSERT语句就可能更新多少棵B+树。 针对某一棵B+树来说,既可能更新叶子节点页面,也可能更新内节点页面,也可能创建新的页面(在该记录插入的叶子节点的剩余空间比较少,不足以存放该记录时,会进行页面的分裂,在内节点页面中添加目录项记录)。 在语句执行过程中,INSERT语句对所有页面的修改都得保存到redo日志中去。这句话说的比较轻巧,做起来可就比较麻烦了,比方说将记录插入到聚簇索引中时,如果定位到的叶子节点的剩余空间足够存储该记录时,那么只更新该叶子节点页面就好,那么只记录一条MLOG_WRITE_STRING类型的redo日志,表明在页面的某个偏移量处增加了哪些数据就好了么?那就too young too naive了~ 别忘了一个数据页中除了存储实际的记录之后,还有什么File Header、Page Header、Page Directory等等部分(在介绍数据页的章节有详细讲解),所以每往叶子节点代表的数据页里插入一条记录时,还有其他很多地方会跟着更新,比如说:\n可能更新Page Directory中的槽信息。 Page Header中的各种页面统计信息,比如PAGE_N_DIR_SLOTS表示的槽数量可能会更改,PAGE_HEAP_TOP代表的还未使用的空间最小地址可能会更改,PAGE_N_HEAP代表的本页面中的记录数量可能会更改,等等,各种信息都可能会被修改。 我们知道在数据页里的记录是按照索引列从小到大的顺序组成一个单向链表的,每插入一条记录,还需要更新上一条记录的记录头信息中的next_record属性来维护这个单向链表。 还有别的等等的更新的地方,就不一一介绍了\u0026hellip; 画一个简易的示意图就像是这样:\n说了这么多,就是想表达:把一条记录插入到一个页面时需要更改的地方非常多。这时我们如果使用上面介绍的简单的物理redo日志来记录这些修改时,可以有两种解决方案:\n方案一:在每个修改的地方都记录一条redo日志。\n也就是如上图所示,有多少个加粗的块,就写多少条物理redo日志。这样子记录redo日志的缺点是显而易见的,因为被修改的地方是在太多了,可能记录的redo日志占用的空间都比整个页面占用的空间都多了~\n方案二:将整个页面的第一个被修改的字节到最后一个修改的字节之间所有的数据当成是一条物理redo日志中的具体数据。\n从图中也可以看出来,第一个被修改的字节到最后一个修改的字节之间仍然有许多没有修改过的数据,我们把这些没有修改的数据也加入到redo日志中去岂不是太浪费了~\n正因为上述两种使用物理redo日志的方式来记录某个页面中做了哪些修改比较浪费,设计InnoDB的大佬本着勤俭节约的初心,提出了一些新的redo日志类型,比如:\nMLOG_REC_INSERT(对应的十进制数字为9):表示插入一条使用非紧凑行格式的记录时的redo日志类型。 MLOG_COMP_REC_INSERT(对应的十进制数字为38):表示插入一条使用紧凑行格式的记录时的redo日志类型。 小贴士:Redundant是一种比较原始的行格式,它就是非紧凑的。而Compact、Dynamic以及Compressed行格式是较新的行格式,它们是紧凑的(占用更小的存储空间)。\nMLOG_COMP_PAGE_CREATE(type字段对应的十进制数字为58):表示创建一个存储紧凑行格式记录的页面的redo日志类型。 MLOG_COMP_REC_DELETE(type字段对应的十进制数字为42):表示删除一条使用紧凑行格式记录的redo日志类型。 MLOG_COMP_LIST_START_DELETE(type字段对应的十进制数字为44):表示从某条给定记录开始删除页面中的一系列使用紧凑行格式记录的redo日志类型。 MLOG_COMP_LIST_END_DELETE(type字段对应的十进制数字为43):与MLOG_COMP_LIST_START_DELETE类型的redo日志呼应,表示删除一系列记录直到MLOG_COMP_LIST_END_DELETE类型的redo日志对应的记录为止。 小贴士:我们前面介绍InnoDB数据页格式的时候重点强调过,数据页中的记录是按照索引列大小的顺序组成单向链表的。有时候我们会有删除索引列的值在某个区间范围内的所有记录的需求,这时候如果我们每删除一条记录就写一条redo日志的话,效率可能有点低,所以提出MLOG_COMP_LIST_START_DELETE和MLOG_COMP_LIST_END_DELETE类型的redo日志,可以很大程度上减少redo日志的条数。\nMLOG_ZIP_PAGE_COMPRESS(type字段对应的十进制数字为51):表示压缩一个数据页的redo日志类型。 ······还有很多很多种类型,这就不列举了,等用到再说~ 这些类型的redo日志既包含物理层面的意思,也包含逻辑层面的意思,具体指: - 物理层面看,这些日志都指明了对哪个表空间的哪个页进行了修改。 - 逻辑层面看,在系统奔溃重启时,并不能直接根据这些日志里的记载,将页面内的某个偏移量处恢复成某个数据,而是需要调用一些事先准备好的函数,执行完这些函数后才可以将页面恢复成系统奔溃前的样子。\n大家看到这可能有些懵逼,我们还是以类型为MLOG_COMP_REC_INSERT这个代表插入一条使用紧凑行格式的记录时的redo日志为例来理解一下我们上面所说的物理层面和逻辑层面到底是什么意思。废话少说,直接看一下这个类型为MLOG_COMP_REC_INSERT的redo日志的结构(由于字段太多了,我们把它们竖着看效果好些):\n这个类型为MLOG_COMP_REC_INSERT的redo日志结构有几个地方需要大家注意: - 我们前面在介绍索引的时候说过,在一个数据页里,不论是叶子节点还是非叶子节点,记录都是按照索引列从小到大的顺序排序的。对于二级索引来说,当索引列的值相同时,记录还需要按照主键值进行排序。图中n_uniques的值的含义是在一条记录中,需要几个字段的值才能确保记录的唯一性,这样当插入一条记录时就可以按照记录的前n_uniques个字段进行排序。对于聚簇索引来说,n_uniques的值为主键的列数,对于其他二级索引来说,该值为索引列数+主键列数。这里需要注意的是,唯一二级索引的值可能为NULL,所以该值仍然为索引列数+主键列数。 - field1_len ~ fieldn_len代表着该记录若干个字段占用存储空间的大小,需要注意的是,这里不管该字段的类型是固定长度大小的(比如INT),还是可变长度大小(比如VARCHAR(M))的,该字段占用的大小始终要写入redo日志中。 - offset代表的是该记录的前一条记录在页面中的地址。为什么要记录前一条记录的地址呢?这是因为每向数据页插入一条记录,都需要修改该页面中维护的记录链表,每条记录的记录头信息中都包含一个称为next_record的属性,所以在插入新记录时,需要修改前一条记录的next_record属性。 - 我们知道一条记录其实由额外信息和真实数据这两部分组成,这两个部分的总大小就是一条记录占用存储空间的总大小。通过end_seg_len的值可以间接的计算出一条记录占用存储空间的总大小,为什么不直接存储一条记录占用存储空间的总大小呢?这是因为写redo日志是一个非常频繁的操作,设计InnoDB的大佬想方设法想减小redo日志本身占用的存储空间大小,所以想了一些弯弯绕的算法来实现这个目标,end_seg_len这个字段就是为了节省redo日志存储空间而提出来的。至于具体设计InnoDB的大佬到底是用了什么神奇魔法减小redo日志大小的,我们这就不多介绍了,因为的确有那么一丢丢小复杂,说清楚还是有一点点麻烦的,而且说明白了也没什么用。 - mismatch_index的值也是为了节省redo日志的大小而设立的,大家可以忽略。\n很显然这个类型为MLOG_COMP_REC_INSERT的redo日志并没有记录PAGE_N_DIR_SLOTS的值修改为了什么,PAGE_HEAP_TOP的值修改为了什么,PAGE_N_HEAP的值修改为了什么等等这些信息,而只是把在本页面中插入一条记录所有必备的要素记了下来,之后系统奔溃重启时,服务器会调用相关向某个页面插入一条记录的那个函数,而redo日志中的那些数据就可以被当成是调用这个函数所需的参数,在调用完该函数后,页面中的PAGE_N_DIR_SLOTS、PAGE_HEAP_TOP、PAGE_N_HEAP等等的值也就都被恢复到系统奔溃前的样子了。这就是所谓的逻辑日志的意思。\nredo日志格式小结 # 虽然上面说了一大堆关于redo日志格式的内容,但是如果你不是为了写一个解析redo日志的工具或者自己开发一套redo日志系统的话,那就没必要把InnoDB中的各种类型的redo日志格式都研究的透透的,没那个必要。上面我只是象征性的介绍了几种类型的redo日志格式,目的还是想让大家明白:redo日志会把事务在执行过程中对数据库所做的所有修改都记录下来,在之后系统奔溃重启后可以把事务所做的任何修改都恢复出来。\n小贴士:为了节省redo日志占用的存储空间大小,设计InnoDB的大佬对redo日志中的某些数据还可能进行压缩处理,比方说spacd ID和page number一般占用4个字节来存储,但是经过压缩后,可能使用更小的空间来存储。具体压缩算法就不介绍了。\nMini-Transaction # 以组的形式写入redo日志 # 语句在执行过程中可能修改若干个页面。比如我们前面说的一条INSERT语句可能修改系统表空间页号为7的页面的Max Row ID属性(当然也可能更新别的系统页面,只不过我们没有都列举出来而已),还会更新聚簇索引和二级索引对应B+树中的页面。由于对这些页面的更改都发生在Buffer Pool中,所以在修改完页面之后,需要记录一下相应的redo日志。在执行语句的过程中产生的redo日志被设计InnoDB的大佬人为的划分成了若干个不可分割的组,比如: - 更新Max Row ID属性时产生的redo日志是不可分割的。 - 向聚簇索引对应B+树的页面中插入一条记录时产生的redo日志是不可分割的。 - 向某个二级索引对应B+树的页面中插入一条记录时产生的redo日志是不可分割的。 - 还有其他的一些对页面的访问操作时产生的redo日志是不可分割的。。。\n怎么理解这个不可分割的意思呢?我们以向某个索引对应的B+树插入一条记录为例,在向B+树中插入这条记录之前,需要先定位到这条记录应该被插入到哪个叶子节点代表的数据页中,定位到具体的数据页之后,有两种可能的情况:\n情况一:该数据页的剩余的空闲空间充足,足够容纳这一条待插入记录,那么事情很简单,直接把记录插入到这个数据页中,记录一条类型为MLOG_COMP_REC_INSERT的redo日志就好了,我们把这种情况称之为乐观插入。假如某个索引对应的B+树长这样:\n现在我们要插入一条键值为10的记录,很显然需要被插入到页b中,由于页b现在有足够的空间容纳一条记录,所以直接将该记录插入到页b中就好了,就像这样:\n情况二:该数据页剩余的空闲空间不足,那么事情就悲剧了,我们前面说过,遇到这种情况要进行所谓的页分裂操作,也就是新建一个叶子节点,然后把原先数据页中的一部分记录复制到这个新的数据页中,然后再把记录插入进去,把这个叶子节点插入到叶子节点链表中,最后还要在内节点中添加一条目录项记录指向这个新创建的页面。很显然,这个过程要对多个页面进行修改,也就意味着会产生多条redo日志,我们把这种情况称之为悲观插入。假如某个索引对应的B+树长这样:\n现在我们要插入一条键值为10的记录,很显然需要被插入到页b中,但是从图中也可以看出来,此时页b已经塞满了记录,没有更多的空闲空间来容纳这条新记录了,所以我们需要进行页面的分裂操作,就像这样:\n如果作为内节点的页a的剩余空闲空间也不足以容纳增加一条目录项记录,那需要继续做内节点页a的分裂操作,也就意味着会修改更多的页面,从而产生更多的redo日志。另外,对于悲观插入来说,由于需要新申请数据页,还需要改动一些系统页面,比方说要修改各种段、区的统计信息信息,各种链表的统计信息(比如什么FREE链表、FSP_FREE_FRAG链表等等我们在介绍表空间那一章中介绍过的各种东东)等等等等,反正总共需要记录的redo日志有二、三十条。\n小贴士:其实不光是悲观插入一条记录会生成许多条redo日志,设计InnoDB的大佬为了其他的一些功能,在乐观插入时也可能产生多条redo日志(具体是为了什么功能我们就不多说了,要不篇幅就受不了了~)。 设计InnoDB的大佬们认为向某个索引对应的B+树中插入一条记录的这个过程必须是原子的,不能说插了一半之后就停止了。比方说在悲观插入过程中,新的页面已经分配好了,数据也复制过去了,新的记录也插入到页面中了,可是没有向内节点中插入一条目录项记录,这个插入过程就是不完整的,这样会形成一棵不正确的B+树。我们知道redo日志是为了在系统奔溃重启时恢复崩溃前的状态,如果在悲观插入的过程中只记录了一部分redo日志,那么在系统奔溃重启时会将索引对应的B+树恢复成一种不正确的状态,这是设计InnoDB的大佬们所不能忍受的。所以他们规定在执行这些需要保证原子性的操作时必须以组的形式来记录的redo日志,在进行系统奔溃重启恢复时,针对某个组中的redo日志,要么把全部的日志都恢复掉,要么一条也不恢复。怎么做到的呢?这得分情况讨论:\n有的需要保证原子性的操作会生成多条redo日志,比如向某个索引对应的B+树中进行一次悲观插入就需要生成许多条redo日志。\n如何把这些redo日志划分到一个组里边儿呢?设计InnoDB的大佬做了一个很简单的小把戏,就是在该组中的最后一条redo日志后边加上一条特殊类型的redo日志,该类型名称为MLOG_MULTI_REC_END,type字段对应的十进制数字为31,该类型的redo日志结构很简单,只有一个type字段:\n所以某个需要保证原子性的操作产生的一系列redo日志必须要以一个类型为MLOG_MULTI_REC_END结尾,就像这样:\n这样在系统奔溃重启进行恢复时,只有当解析到类型为MLOG_MULTI_REC_END的redo日志,才认为解析到了一组完整的redo日志,才会进行恢复。否则的话直接放弃前面解析到的redo日志。\n有的需要保证原子性的操作只生成一条redo日志,比如更新Max Row ID属性的操作就只会生成一条redo日志。\n其实在一条日志后边跟一个类型为MLOG_MULTI_REC_END的redo日志也是可以的,不过设计InnoDB的大佬比较勤俭节约,他们不想浪费一个比特位。别忘了虽然redo日志的类型比较多,但撑死了也就是几十种,是小于127这个数字的,也就是说我们用7个比特位就足以包括所有的redo日志类型,而type字段其实是占用1个字节的,也就是说我们可以省出来一个比特位用来表示该需要保证原子性的操作只产生单一的一条redo日志,示意图如下:\n如果type字段的第一个比特位为1,代表该需要保证原子性的操作只产生了单一的一条redo日志,否则表示该需要保证原子性的操作产生了一系列的redo日志。\nMini-Transaction的概念 # 设计MySQL的大佬把对底层页面中的一次原子访问的过程称之为一个Mini-Transaction,简称mtr,比如上面所说的修改一次Max Row ID的值算是一个Mini-Transaction,向某个索引对应的B+树中插入一条记录的过程也算是一个Mini-Transaction。通过上面的叙述我们也知道,一个所谓的mtr可以包含一组redo日志,在进行奔溃恢复时这一组redo日志作为一个不可分割的整体。\n一个事务可以包含若干条语句,每一条语句其实是由若干个mtr组成,每一个mtr又可以包含若干条redo日志,画个图表示它们的关系就是这样:\nredo日志的写入过程 # redo log block # 设计InnoDB的大佬为了更好的进行系统奔溃恢复,他们把通过mtr生成的redo日志都放在了大小为512字节的页中。为了和我们前面提到的表空间中的页做区别,我们这里把用来存储redo日志的页称为block(你心里清楚页和block的意思其实差不多就行了)。一个redo log block的示意图如下:\n真正的redo日志都是存储到占用496字节大小的log block body中,图中的log block header和log block trailer存储的是一些管理信息。我们来看看这些所谓的管理信息都是什么:\n其中log block header的几个属性的意思分别如下: - LOG_BLOCK_HDR_NO:每一个block都有一个大于0的唯一标号,本属性就表示该标号值。 - LOG_BLOCK_HDR_DATA_LEN:表示block中已经使用了多少字节,初始值为12(因为log block body从第12个字节处开始)。随着往block中写入的redo日志越来也多,本属性值也跟着增长。如果log block body已经被全部写满,那么本属性的值被设置为512。 - LOG_BLOCK_FIRST_REC_GROUP:一条redo日志也可以称之为一条redo日志记录(redo log record),一个mtr会生产多条redo日志记录,这些redo日志记录被称之为一个redo日志记录组(redo log record group)。LOG_BLOCK_FIRST_REC_GROUP就代表该block中第一个mtr生成的redo日志记录组的偏移量(其实也就是这个block里第一个mtr生成的第一条redo日志的偏移量)。 - LOG_BLOCK_CHECKPOINT_NO:表示所谓的checkpoint的序号,checkpoint是我们后续内容的重点,现在先不用清楚它的意思,稍安勿躁。\nlog block trailer中属性的意思如下:\nLOG_BLOCK_CHECKSUM:表示block的校验值,用于正确性校验,我们暂时不关心它。 redo日志缓冲区 # 我们前面说过,设计InnoDB的大佬为了解决磁盘速度过慢的问题而引入了Buffer Pool。同理,写入redo日志时也不能直接直接写到磁盘上,实际上在服务器启动时就向操作系统申请了一大片称之为redo log buffer的连续内存空间,翻译成中文就是redo日志缓冲区,我们也可以简称为log buffer。这片内存空间被划分成若干个连续的redo log block,就像这样:\n我们可以通过启动参数innodb_log_buffer_size来指定log buffer的大小,在MySQL 5.7.21这个版本中,该启动参数的默认值为16MB。\nredo日志写入log buffer # 向log buffer中写入redo日志的过程是顺序的,也就是先往前面的block中写,当该block的空闲空间用完之后再往下一个block中写。当我们想往log buffer中写入redo日志时,第一个遇到的问题就是应该写在哪个block的哪个偏移量处,所以设计InnoDB的大佬特意提供了一个称之为buf_free的全局变量,该变量指明后续写入的redo日志应该写入到log buffer中的哪个位置,如图所示:\n我们前面说过一个mtr执行过程中可能产生若干条redo日志,这些redo日志是一个不可分割的组,所以其实并不是每生成一条redo日志,就将其插入到log buffer中,而是每个mtr运行过程中产生的日志先暂时存到一个地方,当该mtr结束的时候,将过程中产生的一组redo日志再全部复制到log buffer中。我们现在假设有两个名为T1、T2的事务,每个事务都包含2个mtr,我们给这几个mtr命名一下: - 事务T1的两个mtr分别称为mtr_T1_1和mtr_T1_2。 - 事务T2的两个mtr分别称为mtr_T2_1和mtr_T2_2。\n每个mtr都会产生一组redo日志,用示意图来描述一下这些mtr产生的日志情况:\n不同的事务可能是并发执行的,所以T1、T2之间的mtr可能是交替执行的。每当一个mtr执行完成时,伴随该mtr生成的一组redo日志就需要被复制到log buffer中,也就是说不同事务的mtr可能是交替写入log buffer的,我们画个示意图(为了美观,我们把一个mtr中产生的所有的redo日志当作一个整体来画):\n从示意图中我们可以看出来,不同的mtr产生的一组redo日志占用的存储空间可能不一样,有的mtr产生的redo日志量很少,比如mtr_t1_1、mtr_t2_1就被放到同一个block中存储,有的mtr产生的redo日志量非常大,比如mtr_t1_2产生的redo日志甚至占用了3个block来存储。 小贴士:对照着上图,自己分析一下每个block的LOG_BLOCK_HDR_DATA_LEN、LOG_BLOCK_FIRST_REC_GROUP属性值都是什么~\n"},{"id":14,"href":"/zh/docs/technology/MySQL/MySQL%E6%98%AF%E6%80%8E%E6%A0%B7%E8%BF%90%E8%A1%8C%E7%9A%84/%E7%AC%AC19%E7%AB%A0_%E4%BB%8E%E7%8C%AB%E7%88%B7%E8%A2%AB%E6%9D%80%E8%AF%B4%E8%B5%B7-%E4%BA%8B%E5%8A%A1%E7%AE%80%E4%BB%8B/","title":"第19章_从猫爷被杀说起-事务简介","section":"My Sql是怎样运行的","content":"第19章 从猫爷被杀说起-事务简介\n事务的起源 # 对于大部分程序员来说,他们的任务就是把现实世界的业务场景映射到数据库世界。比如银行为了存储人们的账户信息会建立一个account表: CREATE TABLE account ( id INT NOT NULL AUTO_INCREMENT COMMENT '自增id', name VARCHAR(100) COMMENT '客户名称', balance INT COMMENT '余额', PRIMARY KEY (id) ) Engine=InnoDB CHARSET=utf8; 狗哥和猫爷是一对好基友,他们都到银行开一个账户,他们在现实世界中拥有的资产就会体现在数据库世界的account表中。比如现在狗哥有11元,猫爷只有2元,那么现实中的这个情况映射到数据库的account表就是这样: +----+--------+---------+ | id | name | balance | +----+--------+---------+ | 1 | 狗哥 | 11 | | 2 | 猫爷 | 2 | +----+--------+---------+ 在某个特定的时刻,狗哥猫爷这些家伙在银行所拥有的资产是一个特定的值,这些特定的值也可以被描述为账户在这个特定的时刻现实世界的一个状态。随着时间的流逝,狗哥和猫爷可能陆续进行向账户中存钱、取钱或者向别人转账等操作,这样他们账户中的余额就可能发生变动,每一个操作都相当于现实世界中账户的一次状态转换。数据库世界作为现实世界的一个映射,自然也要进行相应的变动。不变不知道,一变吓一跳,现实世界中一些看似很简单的状态转换,映射到数据库世界却不是那么容易的。比方说有一次猫爷在赌场赌博输了钱,急忙打电话给狗哥要借10块钱,不然那些看场子的就会把自己剁了。现实世界中的狗哥走向了ATM机,输入了猫爷的账号以及10元的转账金额,然后按下确认,狗哥就拔卡走人了。对于数据库世界来说,相当于执行了下面这两条语句:\nUPDATE account SET balance = balance - 10 WHERE id = 1; UPDATE account SET balance = balance + 10 WHERE id = 2;\n但是这里头有个问题,上述两条语句只执行了一条时忽然服务器断电了咋办?把狗哥的钱扣了,但是没给猫爷转过去,那猫爷还是逃脱不了被砍死的噩运~ 即使对于单独的一条语句,我们前面介绍Buffer Pool时也说过,在对某个页面进行读写访问时,都会先把这个页面加载到Buffer Pool中,之后如果修改了某个页面,也不会立即把修改同步到磁盘,而只是把这个修改了的页面加到Buffer Pool的flush链表中,在之后的某个时间点才会刷新到磁盘。如果在将修改过的页刷新到磁盘之前系统崩溃了那岂不是猫爷还是要被砍死?或者在刷新磁盘的过程中(只刷新部分数据到磁盘上)系统奔溃了猫爷也会被砍死?\n怎么才能保证让可怜的猫爷不被砍死呢?其实再仔细想想,我们只是想让某些数据库操作符合现实世界中状态转换的规则而已,设计数据库的大佬们仔细盘算了盘算,现实世界中状态转换的规则有好几条,待我们慢慢道来。\n原子性(Atomicity) # 现实世界中转账操作是一个不可分割的操作,也就是说要么压根儿就没转,要么转账成功,不能存在中间的状态,也就是转了一半的这种情况。设计数据库的大佬们把这种要么全做,要么全不做的规则称之为原子性。但是在现实世界中的一个不可分割的操作却可能对应着数据库世界若干条不同的操作,数据库中的一条操作也可能被分解成若干个步骤(比如先修改缓存页,之后再刷新到磁盘等),最要命的是在任何一个可能的时间都可能发生意想不到的错误(可能是数据库本身的错误,或者是操作系统错误,甚至是直接断电之类的)而使操作执行不下去,所以猫爷可能会被砍死。为了保证在数据库世界中某些操作的原子性,设计数据库的大佬需要费一些心机来保证如果在执行操作的过程中发生了错误,把已经做了的操作恢复成没执行之前的样子,这也是我们后边章节要仔细介绍的内容。\n隔离性(Isolation) # 现实世界中的两次状态转换应该是互不影响的,比如说狗哥向猫爷同时进行的两次金额为5元的转账(假设可以在两个ATM机上同时操作)。那么最后狗哥的账户里肯定会少10元,猫爷的账户里肯定多了10元。但是到对应的数据库世界中,事情又变的复杂了一些。为了简化问题,我们粗略的假设狗哥向猫爷转账5元的过程是由下面几个步骤组成的:\n步骤一:读取狗哥账户的余额到变量A中,这一步骤简写为read(A)。 步骤二:将狗哥账户的余额减去转账金额,这一步骤简写为A = A - 5。 步骤三:将狗哥账户修改过的余额写到磁盘里,这一步骤简写为write(A)。 步骤四:读取猫爷账户的余额到变量B,这一步骤简写为read(B)。 步骤五:将猫爷账户的余额加上转账金额,这一步骤简写为B = B + 5。 步骤六:将猫爷账户修改过的余额写到磁盘里,这一步骤简写为write(B)。 我们将狗哥向猫爷同时进行的两次转账操作分别称为T1和T2,在现实世界中T1和T2是应该没有关系的,可以先执行完T1,再执行T2,或者先执行完T2,再执行T1,对应的数据库操作就像这样:\n但是很不幸,真实的数据库中T1和T2的操作可能交替执行,比如这样:\n如果按照上图中的执行顺序来进行两次转账的话,最终狗哥的账户里还剩6元钱,相当于只扣了5元钱,但是猫爷的账户里却成了12元钱,相当于多了10元钱,这银行岂不是要亏死了?\n所以对于现实世界中状态转换对应的某些数据库操作来说,不仅要保证这些操作以原子性的方式执行完成,而且要保证其它的状态转换不会影响到本次状态转换,这个规则被称之为隔离性。这时设计数据库的大佬们就需要采取一些措施来让访问相同数据(上例中的A账户和B账户)的不同状态转换(上例中的T1和T2)对应的数据库操作的执行顺序有一定规律,这也是我们后边章节要仔细介绍的内容。\n一致性(Consistency) # 我们生活的这个世界存在着形形色色的约束,比如身份证号不能重复,性别只能是男或者女,高考的分数只能在0~750之间,人民币面值最大只能是100(现在是2019年),红绿灯只有3种颜色,房价不能为负的,学生要听老师话,等等有点儿扯远了~ 只有符合这些约束的数据才是有效的,比如有个小孩儿跟你说他高考考了1000分,你一听就知道他胡扯呢。数据库世界只是现实世界的一个映射,现实世界中存在的约束当然也要在数据库世界中有所体现。如果数据库中的数据全部符合现实世界中的约束(all defined rules),我们说这些数据就是一致的,或者说符合一致性的。\n如何保证数据库中数据的一致性(就是符合所有现实世界的约束)呢?这其实靠两方面的努力:\n数据库本身能为我们保证一部分一致性需求(就是数据库自身可以保证一部分现实世界的约束永远有效)。\n我们知道MySQL数据库可以为表建立主键、唯一索引、外键、声明某个列为NOT NULL来拒绝NULL值的插入。比如说当我们对某个列建立唯一索引时,如果插入某条记录时该列的值重复了,那么MySQL就会报错并且拒绝插入。除了这些我们已经非常熟悉的保证一致性的功能,MySQL还支持CHECK语法来自定义约束,比如这样:\nCREATE TABLE account ( id INT NOT NULL AUTO_INCREMENT COMMENT '自增id', name VARCHAR(100) COMMENT '客户名称', balance INT COMMENT '余额', PRIMARY KEY (id), CHECK (balance \u0026gt;= 0) );\n上述例子中的CHECK语句本意是想规定balance列不能存储小于0的数字,对应的现实世界的意思就是银行账户余额不能小于0。但是很遗憾,MySQL仅仅支持CHECK语法,但实际上并没有一点卵用,也就是说即使我们使用上述带有CHECK子句的建表语句来创建account表,那么在后续插入或更新记录时,MySQL并不会去检查CHECK子句中的约束是否成立。\n小贴士:其它的一些数据库,比如SQL Server或者Oracle支持的CHECK语法是有实实在在的作用的,每次进行插入或更新记录之前都会检查一下数据是否符合CHECK子句中指定的约束条件是否成立,如果不成立的话就会拒绝插入或更新。 虽然CHECK子句对一致性检查没什么卵用,但是我们还是可以通过定义触发器的方式来自定义一些约束条件以保证数据库中数据的一致性。 小贴士:触发器是MySQL基础内容中的知识,本书是一本MySQL进阶的书籍,如果你不了解触发器,那恐怕要找本基础内容的书籍来看看了。\n更多的一致性需求需要靠写业务代码的程序员自己保证。\n为建立现实世界和数据库世界的对应关系,理论上应该把现实世界中的所有约束都反应到数据库世界中,但是很不幸,在更改数据库数据时进行一致性检查是一个耗费性能的工作,比方说我们为account表建立了一个触发器,每当插入或者更新记录时都会校验一下balance列的值是不是大于0,这就会影响到插入或更新的速度。仅仅是校验一行记录符不符合一致性需求倒也不是什么大问题,有的一致性需求简直变态,比方说银行会建立一张代表账单的表,里边儿记录了每个账户的每笔交易,每一笔交易完成后,都需要保证整个系统的余额等于所有账户的收入减去所有账户的支出。如果在数据库层面实现这个一致性需求的话,每次发生交易时,都需要将所有的收入加起来减去所有的支出,再将所有的账户余额加起来,看看两个值相不相等。这不是搞笑呢么,如果账单表里有几亿条记录,光是这个校验的过程可能就要跑好几个小时,也就是说你在煎饼摊买个煎饼,使用银行卡付款之后要等好几个小时才能提示付款成功,这样的性能代价是完全承受不起的。\n现实生活中复杂的一致性需求比比皆是,而由于性能问题把一致性需求交给数据库去解决这是不现实的,所以这个锅就甩给了业务端程序员。比方说我们的account表,我们也可以不建立触发器,只要编写业务的程序员在自己的业务代码里判断一下,当某个操作会将balance列的值更新为小于0的值时,就不执行该操作就好了嘛!\n我们前面介绍的原子性和隔离性都会对一致性产生影响,比如我们现实世界中转账操作完成后,有一个一致性需求就是参与转账的账户的总的余额是不变的。如果数据库不遵循原子性要求,也就是转了一半就不转了,也就是说给狗哥扣了钱而没给猫爷转过去,那最后就是不符合一致性需求的;类似的,如果数据库不遵循隔离性要求,就像我们前面介绍隔离性时举的例子中所说的,最终狗哥账户中扣的钱和猫爷账户中涨的钱可能就不一样了,也就是说不符合一致性需求了。所以说,数据库某些操作的原子性和隔离性都是保证一致性的一种手段,在操作执行完成后保证符合所有既定的约束则是一种结果。那满足原子性和隔离性的操作一定就满足一致性么?那倒也不一定,比如说狗哥要转账20元给猫爷,虽然在满足原子性和隔离性,但转账完成了之后狗哥的账户的余额就成负的了,这显然是不满足一致性的。那不满足原子性和隔离性的操作就一定不满足一致性么?这也不一定,只要最后的结果符合所有现实世界中的约束,那么就是符合一致性的。\n持久性(Durability) # 当现实世界的一个状态转换完成后,这个转换的结果将永久的保留,这个规则被设计数据库的大佬们称为持久性。比方说狗哥向猫爷转账,当ATM机提示转账成功了,就意味着这次账户的状态转换完成了,狗哥就可以拔卡走人了。如果当狗哥走掉之后,银行又把这次转账操作给撤销掉,恢复到没转账之前的样子,那猫爷不就惨了,又得被砍死了,所以这个持久性是非常重要的。\n当把现实世界的状态转换映射到数据库世界时,持久性意味着该转换对应的数据库操作所修改的数据都应该在磁盘上保留下来,不论之后发生了什么事故,本次转换造成的影响都不应该被丢失掉(要不然猫爷还是会被砍死)。\n事务的概念 # 为了方便大家记住我们上面介绍的现实世界状态转换过程中需要遵守的4个特性,我们把原子性(Atomicity)、隔离性(Isolation)、一致性(Consistency)和持久性(Durability)这四个词对应的英文单词首字母提取出来就是A、I、C、D,稍微变换一下顺序可以组成一个完整的英文单词:ACID。想必大家都是学过初高中英语的,ACID是英文酸的意思,以后我们提到ACID这个词儿,大家就应该想到原子性、一致性、隔离性、持久性这几个规则。另外,设计数据库的大佬为了方便起见,把需要保证原子性、隔离性、一致性和持久性的一个或多个数据库操作称之为一个事务(英文名是:transaction)。\n我们现在知道事务是一个抽象的概念,它其实对应着一个或多个数据库操作,设计数据库的大佬根据这些操作所执行的不同阶段把事务大致上划分成了这么几个状态:\n活动的(active)\n事务对应的数据库操作正在执行过程中时,我们就说该事务处在活动的状态。\n部分提交的(partially committed)\n当事务中的最后一个操作执行完成,但由于操作都在内存中执行,所造成的影响并没有刷新到磁盘时,我们就说该事务处在部分提交的状态。\n失败的(failed)\n当事务处在活动的或者部分提交的状态时,可能遇到了某些错误(数据库自身的错误、操作系统错误或者直接断电等)而无法继续执行,或者人为的停止当前事务的执行,我们就说该事务处在失败的状态。\n中止的(aborted)\n如果事务执行了半截而变为失败的状态,比如我们前面介绍的狗哥向猫爷转账的事务,当狗哥账户的钱被扣除,但是猫爷账户的钱没有增加时遇到了错误,从而当前事务处在了失败的状态,那么就需要把已经修改的狗哥账户余额调整为未转账之前的金额,换句话说,就是要撤销失败事务对当前数据库造成的影响。书面一点的话,我们把这个撤销的过程称之为回滚。当回滚操作执行完毕时,也就是数据库恢复到了执行事务之前的状态,我们就说该事务处在了中止的状态。\n提交的(committed)\n当一个处在部分提交的状态的事务将修改过的数据都同步到磁盘上之后,我们就可以说该事务处在了提交的状态。\n随着事务对应的数据库操作执行到不同阶段,事务的状态也在不断变化,一个基本的状态转换图如下所示:\n从图中大家也可以看出了,只有当事务处于提交的或者中止的状态时,一个事务的生命周期才算是结束了。对于已经提交的事务来说,该事务对数据库所做的修改将永久生效,对于处于中止状态的事务,该事务对数据库所做的所有修改都会被回滚到没执行该事务之前的状态。\n小贴士:贴士处纯属扯犊子,与正文没什么关系,纯属吐槽。大家知道我们的计算机术语基本上全是从英文翻译成中文的,事务的英文是transaction,英文直译就是交易,买卖的意思,交易就是买的人付钱,卖的人交货,不能付了钱不交货,交了货不付钱把,所以交易本身就是一种不可分割的操作。不知道是哪位大神把transaction翻译成了事务(我想估计是他们也想不出什么更好的词儿,只能随便找一个了),事务这个词儿完全没有交易、买卖的意思,所以大家理解起来也会比较困难,外国人理解transaction可能更好理解一点吧~\nMySQL中事务的语法 # 我们说事务的本质其实只是一系列数据库操作,只不过这些数据库操作符合ACID特性而已,那么MySQL中如何将某些操作放到一个事务里去执行的呢?我们下面就来重点介绍介绍。\n开启事务 # 我们可以使用下面两种语句之一来开启一个事务:\nBEGIN [WORK];\nBEGIN语句代表开启一个事务,后边的单词WORK可有可无。开启事务后,就可以继续写若干条语句,这些语句都属于刚刚开启的这个事务。 ``` mysql\u0026gt; BEGIN; Query OK, 0 rows affected (0.00 sec)\nmysql\u0026gt; 加入事务的语句\u0026hellip; ```\nSTART TRANSACTION;\nSTART TRANSACTION语句和BEGIN语句有着相同的功效,都标志着开启一个事务,比如这样: ``` mysql\u0026gt; START TRANSACTION; Query OK, 0 rows affected (0.00 sec)\nmysql\u0026gt; 加入事务的语句\u0026hellip; ```\n不过比BEGIN语句牛逼一点儿的是,可以在START TRANSACTION语句后边跟随几个修饰符,就是它们几个:\n+ READ ONLY:标识当前事务是一个只读事务,也就是属于该事务的数据库操作只能读取数据,而不能修改数据。\n小贴士:其实只读事务中只是不允许修改那些其他事务也能访问到的表中的数据,对于临时表来说(我们使用CREATE TMEPORARY TABLE创建的表),由于它们只能在当前会话中可见,所以只读事务其实也是可以对临时表进行增、删、改操作的。\n+ READ WRITE:标识当前事务是一个读写事务,也就是属于该事务的数据库操作既可以读取数据,也可以修改数据。\n+ WITH CONSISTENT SNAPSHOT:启动一致性读(先不用关心什么是个一致性读,后边的章节才会介绍)。\n比如我们想开启一个只读事务的话,直接把READ ONLY这个修饰符加在START TRANSACTION语句后边就好,比如这样: START TRANSACTION READ ONLY; 如果我们想在START TRANSACTION后边跟随多个修饰符的话,可以使用逗号将修饰符分开,比如开启一个只读事务和一致性读,就可以这样写:\nSTART TRANSACTION READ ONLY, WITH CONSISTENT SNAPSHOT; 或者开启一个读写事务和一致性读,就可以这样写: START TRANSACTION READ WRITE, WITH CONSISTENT SNAPSHOT 不过这里需要大家注意的一点是,READ ONLY和READ WRITE是用来设置所谓的事务访问模式的,就是以只读还是读写的方式来访问数据库中的数据,一个事务的访问模式不能同时既设置为只读的也设置为读写的,所以我们不能同时把READ ONLY和READ WRITE放到START TRANSACTION语句后边。另外,如果我们不显式指定事务的访问模式,那么该事务的访问模式就是读写模式。\n提交事务 # 开启事务之后就可以继续写需要放到该事务中的语句了,当最后一条语句写完了之后,我们就可以提交该事务了,提交的语句也很简单: COMMIT [WORK] COMMIT语句就代表提交一个事务,后边的WORK可有可无。比如我们上面说狗哥给猫爷转10元钱其实对应MySQL中的两条语句,我们就可以把这两条语句放到一个事务中,完整的过程就是这样: ``` mysql\u0026gt; BEGIN; Query OK, 0 rows affected (0.00 sec)\nmysql\u0026gt; UPDATE account SET balance = balance - 10 WHERE id = 1; Query OK, 1 row affected (0.02 sec) Rows matched: 1 Changed: 1 Warnings: 0\nmysql\u0026gt; UPDATE account SET balance = balance + 10 WHERE id = 2; Query OK, 1 row affected (0.00 sec) Rows matched: 1 Changed: 1 Warnings: 0\nmysql\u0026gt; COMMIT; Query OK, 0 rows affected (0.00 sec) ```\n手动中止事务 # 如果我们写了几条语句之后发现上面的某条语句写错了,我们可以手动的使用下面这个语句来将数据库恢复到事务执行之前的样子: ROLLBACK [WORK] ROLLBACK语句就代表中止并回滚一个事务,后边的WORK可有可无类似的。比如我们在写狗哥给猫爷转账10元钱对应的MySQL语句时,先给狗哥扣了10元,然后一时大意只给猫爷账户上增加了1元,此时就可以使用ROLLBACK语句进行回滚,完整的过程就是这样: ``` mysql\u0026gt; BEGIN; Query OK, 0 rows affected (0.00 sec)\nmysql\u0026gt; UPDATE account SET balance = balance - 10 WHERE id = 1; Query OK, 1 row affected (0.00 sec) Rows matched: 1 Changed: 1 Warnings: 0\nmysql\u0026gt; UPDATE account SET balance = balance + 1 WHERE id = 2; Query OK, 1 row affected (0.00 sec) Rows matched: 1 Changed: 1 Warnings: 0\nmysql\u0026gt; ROLLBACK; Query OK, 0 rows affected (0.00 sec) 这里需要强调一下,ROLLBACK语句是我们程序员手动的去回滚事务时才去使用的,如果事务在执行过程中遇到了某些错误而无法继续执行的话,事务自身会自动的回滚。 小贴士:我们这里所说的开启、提交、中止事务的语法只是针对使用黑框框时通过mysql客户端程序与服务器进行交互时控制事务的语法,如果大家使用的是别的客户端程序,比如JDBC之类的,那需要参考相应的文档来看看如何控制事务。 ```\n支持事务的存储引擎 # MySQL中并不是所有存储引擎都支持事务的功能,目前只有InnoDB和NDB存储引擎支持(NDB存储引擎不是我们的重点),如果某个事务中包含了修改使用不支持事务的存储引擎的表,那么对该使用不支持事务的存储引擎的表所做的修改将无法进行回滚。比方说我们有两个表,tbl1使用支持事务的存储引擎InnoDB,tbl2使用不支持事务的存储引擎MyISAM,它们的建表语句如下所示: ``` CREATE TABLE tbl1 ( i int ) engine=InnoDB;\nCREATE TABLE tbl2 ( i int ) ENGINE=MyISAM; 我们看看先开启一个事务,写一条插入语句后再回滚该事务,tbl1和tbl2的表现有什么不同: mysql\u0026gt; SELECT * FROM tbl1; Empty set (0.00 sec)\nmysql\u0026gt; BEGIN; Query OK, 0 rows affected (0.00 sec)\nmysql\u0026gt; INSERT INTO tbl1 VALUES(1); Query OK, 1 row affected (0.00 sec)\nmysql\u0026gt; ROLLBACK; Query OK, 0 rows affected (0.00 sec)\nmysql\u0026gt; SELECT * FROM tbl1; Empty set (0.00 sec) 可以看到,对于使用支持事务的存储引擎的tbl1表来说,我们在插入一条记录再回滚后,tbl1就恢复到没有插入记录时的状态了。再看看tbl2表的表现: mysql\u0026gt; SELECT * FROM tbl2; Empty set (0.00 sec)\nmysql\u0026gt; BEGIN; Query OK, 0 rows affected (0.00 sec)\nmysql\u0026gt; INSERT INTO tbl2 VALUES(1); Query OK, 1 row affected (0.00 sec)\nmysql\u0026gt; ROLLBACK; Query OK, 0 rows affected, 1 warning (0.01 sec)\nmysql\u0026gt; SELECT * FROM tbl2; +\u0026mdash;\u0026mdash;+ | i | +\u0026mdash;\u0026mdash;+ | 1 | +\u0026mdash;\u0026mdash;+ 1 row in set (0.00 sec) ``` 可以看到,虽然我们使用了ROLLBACK语句来回滚事务,但是插入的那条记录还是留在了tbl2表中。\n自动提交 # MySQL中有一个系统变量autocommit: mysql\u0026gt; SHOW VARIABLES LIKE 'autocommit'; +---------------+-------+ | Variable_name | Value | +---------------+-------+ | autocommit | ON | +---------------+-------+ 1 row in set (0.01 sec) 可以看到它的默认值为ON,也就是说默认情况下,如果我们不显式的使用START TRANSACTION或者BEGIN语句开启一个事务,那么每一条语句都算是一个独立的事务,这种特性称之为事务的自动提交。假如我们在狗哥向猫爷转账10元时不以START TRANSACTION或者BEGIN语句显式的开启一个事务,那么下面这两条语句就相当于放到两个独立的事务中去执行: UPDATE account SET balance = balance - 10 WHERE id = 1; UPDATE account SET balance = balance + 10 WHERE id = 2; 当然,如果我们想关闭这种自动提交的功能,可以使用下面两种方法之一:\n显式的的使用START TRANSACTION或者BEGIN语句开启一个事务。\n这样在本次事务提交或者回滚前会暂时关闭掉自动提交的功能。\n把系统变量autocommit的值设置为OFF,就像这样:\nSET autocommit = OFF; 这样的话,我们写入的多条语句就算是属于同一个事务了,直到我们显式的写出COMMIT语句来把这个事务提交掉,或者显式的写出ROLLBACK语句来把这个事务回滚掉。\n隐式提交 # 当我们使用START TRANSACTION或者BEGIN语句开启了一个事务,或者把系统变量autocommit的值设置为OFF时,事务就不会进行自动提交,但是如果我们输入了某些语句之后就会悄悄的提交掉,就像我们输入了COMMIT语句了一样,这种因为某些特殊的语句而导致事务提交的情况称为隐式提交,这些会导致事务隐式提交的语句包括:\n定义或修改数据库对象的数据定义语言(Data definition language,缩写为:DDL)。\n所谓的数据库对象,指的就是数据库、表、视图、存储过程等等这些东西。当我们使用CREATE、ALTER、DROP等语句去修改这些所谓的数据库对象时,就会隐式的提交前面语句所属于的事务,就像这样:\nSELECT ... \\# 事务中的一条语句 UPDATE ... \\# 事务中的一条语句 ... \\# 事务中的其它语句 CREATE TABLE ... \\# 此语句会隐式的提交前面语句所属于的事务 ``` + 隐式使用或修改`mysql`数据库中的表 当我们使用`ALTER USER`、`CREATE USER`、`DROP USER`、`GRANT`、`RENAME USER`、`REVOKE`、`SET PASSWORD`等语句时也会隐式的提交前面语句所属于的事务。 + 事务控制或关于锁定的语句 当我们在一个事务还没提交或者回滚时就又使用`START TRANSACTION`或者`BEGIN`语句开启了另一个事务时,会隐式的提交上一个事务,比如这样: ``` BEGIN; SELECT ... \\# 事务中的一条语句 UPDATE ... \\# 事务中的一条语句 ... \\# 事务中的其它语句 BEGIN; \\# 此语句会隐式的提交前面语句所属于的事务 ``` 或者当前的`autocommit`系统变量的值为`OFF`,我们手动把它调为`ON`时,也会隐式的提交前面语句所属的事务。 或者使用`LOCK TABLES`、`UNLOCK TABLES`等关于锁定的语句也会隐式的提交前面语句所属的事务。 + 加载数据的语句 比如我们使用`LOAD DATA`语句来批量往数据库中导入数据时,也会隐式的提交前面语句所属的事务。 + 关于`MySQL`复制的一些语句 使用`START SLAVE`、`STOP SLAVE`、`RESET SLAVE`、`CHANGE MASTER TO`等语句时也会隐式的提交前面语句所属的事务。 + 其它的一些语句 使用`ANALYZE TABLE`、`CACHE INDEX`、`CHECK TABLE`、`FLUSH`、 `LOAD INDEX INTO CACHE`、`OPTIMIZE TABLE`、`REPAIR TABLE`、`RESET`等语句也会隐式的提交前面语句所属的事务。 `小贴士:上面提到的一些语句,如果你都认识并且知道是干嘛用的那再好不过了,不认识也不要气馁,这里写出来只是为了内容的完整性,把可能会导致事务隐式提交的情况都列举一下,具体每个语句都是干嘛用的等我们遇到了再说。` ## 保存点 如果你开启了一个事务,并且已经敲了很多语句,忽然发现上一条语句有点问题,你只好使用`ROLLBACK`语句来让数据库状态恢复到事务执行之前的样子,然后一切从头再来,总有一种一夜回到解放前的感觉。所以设计数据库的大佬们提出了一个`保存点`(英文:`savepoint`)的概念,就是在事务对应的数据库语句中打几个点,我们在调用`ROLLBACK`语句时可以指定会滚到哪个点,而不是回到最初的原点。定义保存点的语法如下: `SAVEPOINT 保存点名称;` 当我们想回滚到某个保存点时,可以使用下面这个语句(下面语句中的单词`WORK`和`SAVEPOINT`是可有可无的): `ROLLBACK [WORK] TO [SAVEPOINT] 保存点名称;` 不过如果`ROLLBACK`语句后边不跟随保存点名称的话,会直接回滚到事务执行之前的状态。 如果我们想删除某个保存点,可以使用这个语句: `RELEASE SAVEPOINT 保存点名称;` 下面还是以狗哥向猫爷转账10元的例子展示一下`保存点`的用法,在执行完扣除狗哥账户的钱`10`元的语句之后打一个`保存点`: ``` mysql\u0026gt; SELECT * FROM account; \\+----\\+--------\\+---------\\+ | id | name | balance | \\+----\\+--------\\+---------\\+ | 1 | 狗哥 | 11 | | 2 | 猫爷 | 2 | \\+----\\+--------\\+---------\\+ 2 rows in set (0.00 sec) mysql\u0026gt; BEGIN; Query OK, 0 rows affected (0.00 sec) mysql\u0026gt; UPDATE account SET balance = balance - 10 WHERE id = 1; Query OK, 1 row affected (0.01 sec) Rows matched: 1 Changed: 1 Warnings: 0 mysql\u0026gt; SAVEPOINT s1; \\# 一个保存点 Query OK, 0 rows affected (0.00 sec) mysql\u0026gt; SELECT * FROM account; \\+----\\+--------\\+---------\\+ | id | name | balance | \\+----\\+--------\\+---------\\+ | 1 | 狗哥 | 1 | | 2 | 猫爷 | 2 | \\+----\\+--------\\+---------\\+ 2 rows in set (0.00 sec) mysql\u0026gt; UPDATE account SET balance = balance \\+ 1 WHERE id = 2; \\# 更新错了 Query OK, 1 row affected (0.00 sec) Rows matched: 1 Changed: 1 Warnings: 0 mysql\u0026gt; ROLLBACK TO s1; \\# 回滚到保存点s1处 Query OK, 0 rows affected (0.00 sec) mysql\u0026gt; SELECT * FROM account; \\+----\\+--------\\+---------\\+ | id | name | balance | \\+----\\+--------\\+---------\\+ | 1 | 狗哥 | 1 | | 2 | 猫爷 | 2 | \\+----\\+--------\\+---------\\+ 2 rows in set (0.00 sec) ``` "},{"id":15,"href":"/zh/docs/technology/MySQL/MySQL%E6%98%AF%E6%80%8E%E6%A0%B7%E8%BF%90%E8%A1%8C%E7%9A%84/%E7%AC%AC18%E7%AB%A0_%E8%B0%83%E8%8A%82%E7%A3%81%E7%9B%98%E5%92%8CCPU%E7%9A%84%E7%9F%9B%E7%9B%BE-InnoDB%E7%9A%84BufferPool/","title":"第18章_调节磁盘和CPU的矛盾-InnoDB的BufferPool","section":"My Sql是怎样运行的","content":"第18章 调节磁盘和CPU的矛盾-InnoDB的Buffer Pool\n缓存的重要性 # 通过前面的介绍我们知道,对于使用InnoDB作为存储引擎的表来说,不管是用于存储用户数据的索引(包括聚簇索引和二级索引),还是各种系统数据,都是以页的形式存放在表空间中的,而所谓的表空间只不过是InnoDB对文件系统上一个或几个实际文件的抽象,也就是说我们的数据说到底还是存储在磁盘上的。但是各位也都知道,磁盘的速度慢的跟乌龟一样,怎么能配得上“快如风,疾如电”的CPU呢?所以InnoDB存储引擎在处理客户端的请求时,当需要访问某个页的数据时,就会把完整的页的数据全部加载到内存中,也就是说即使我们只需要访问一个页的一条记录,那也需要先把整个页的数据加载到内存中。将整个页加载到内存中后就可以进行读写访问了,在进行完读写访问之后并不着急把该页对应的内存空间释放掉,而是将其缓存起来,这样将来有请求再次访问该页面时,就可以省去磁盘IO的开销了。\nInnoDB的Buffer Pool # 什么是个Buffer Pool # 设计InnoDB的大佬为了缓存磁盘中的页,在MySQL服务器启动的时候就向操作系统申请了一片连续的内存,他们给这片内存起了个名,叫做Buffer Pool(中文名是缓冲池)。那它有多大呢?这个其实看我们机器的配置,如果你是土豪,你有512G内存,你分配个几百G作为Buffer Pool也可以啊,当然你要是没那么有钱,设置小点也行呀~ 默认情况下Buffer Pool只有128M大小。当然如果你嫌弃这个128M太大或者太小,可以在启动服务器的时候配置innodb_buffer_pool_size参数的值,它表示Buffer Pool的大小,就像这样: [server] innodb_buffer_pool_size = 268435456 其中,268435456的单位是字节,也就是我指定Buffer Pool的大小为256M。需要注意的是,Buffer Pool也不能太小,最小值为5M(当小于该值时会自动设置成5M)。\nBuffer Pool内部组成 # Buffer Pool中默认的缓存页大小和在磁盘上默认的页大小是一样的,都是16KB。为了更好的管理这些在Buffer Pool中的缓存页,设计InnoDB的大佬为每一个缓存页都创建了一些所谓的控制信息,这些控制信息包括该页所属的表空间编号、页号、缓存页在Buffer Pool中的地址、链表节点信息、一些锁信息以及LSN信息(锁和LSN我们之后会具体介绍,现在可以先忽略),当然还有一些别的控制信息,我们这就不全介绍一遍了,挑重要的说嘛~\n每个缓存页对应的控制信息占用的内存大小是相同的,我们就把每个页对应的控制信息占用的一块内存称为一个控制块吧,控制块和缓存页是一一对应的,它们都被存放到 Buffer Pool 中,其中控制块被存放到 Buffer Pool 的前面,缓存页被存放到 Buffer Pool 后边,所以整个Buffer Pool对应的内存空间看起来就是这样的:\n咦?控制块和缓存页之间的那个碎片是个什么玩意儿?你想想啊,每一个控制块都对应一个缓存页,那在分配足够多的控制块和缓存页后,可能剩余的那点儿空间不够一对控制块和缓存页的大小,自然就用不到喽,这个用不到的那点儿内存空间就被称为碎片了。当然,如果你把Buffer Pool的大小设置的刚刚好的话,也可能不会产生碎片~ 小贴士:每个控制块大约占用缓存页大小的5%,在MySQL5.7.21这个版本中,每个控制块占用的大小是808字节。而我们设置的innodb_buffer_pool_size并不包含这部分控制块占用的内存空间大小,也就是说InnoDB在为Buffer Pool向操作系统申请连续的内存空间时,这片连续的内存空间一般会比innodb_buffer_pool_size的值大5%左右。\nfree链表的管理 # 当我们最初启动MySQL服务器的时候,需要完成对Buffer Pool的初始化过程,就是先向操作系统申请Buffer Pool的内存空间,然后把它划分成若干对控制块和缓存页。但是此时并没有真实的磁盘页被缓存到Buffer Pool中(因为还没有用到),之后随着程序的运行,会不断的有磁盘上的页被缓存到Buffer Pool中。那么问题来了,从磁盘上读取一个页到Buffer Pool中的时候该放到哪个缓存页的位置呢?或者说怎么区分Buffer Pool中哪些缓存页是空闲的,哪些已经被使用了呢?我们最好在某个地方记录一下Buffer Pool中哪些缓存页是可用的,这个时候缓存页对应的控制块就派上大用场了,我们可以把所有空闲的缓存页对应的控制块作为一个节点放到一个链表中,这个链表也可以被称作free链表(或者说空闲链表)。刚刚完成初始化的Buffer Pool中所有的缓存页都是空闲的,所以每一个缓存页对应的控制块都会被加入到free链表中,假设该Buffer Pool中可容纳的缓存页数量为n,那增加了free链表的效果图就是这样的:\n从图中可以看出,我们为了管理好这个free链表,特意为这个链表定义了一个基节点,里边儿包含着链表的头节点地址,尾节点地址,以及当前链表中节点的数量等信息。这里需要注意的是,链表的基节点占用的内存空间并不包含在为Buffer Pool申请的一大片连续内存空间之内,而是单独申请的一块内存空间。 小贴士:链表基节点占用的内存空间并不大,在MySQL5.7.21这个版本里,每个基节点只占用40字节大小。后边我们即将介绍许多不同的链表,它们的基节点和free链表的基节点的内存分配方式是一样一样的,都是单独申请的一块40字节大小的内存空间,并不包含在为Buffer Pool申请的一大片连续内存空间之内。\n有了这个free链表之后事儿就好办了,每当需要从磁盘中加载一个页到Buffer Pool中时,就从free链表中取一个空闲的缓存页,并且把该缓存页对应的控制块的信息填上(就是该页所在的表空间、页号之类的信息),然后把该缓存页对应的free链表节点从链表中移除,表示该缓存页已经被使用了~\n缓存页的哈希处理 # 我们前面说过,当我们需要访问某个页中的数据时,就会把该页从磁盘加载到Buffer Pool中,如果该页已经在Buffer Pool中的话直接使用就可以了。那么问题也就来了,我们怎么知道该页在不在Buffer Pool中呢?难不成需要依次遍历Buffer Pool中各个缓存页么?一个Buffer Pool中的缓存页这么多都遍历完岂不是要累死?\n再回头想想,我们其实是根据表空间号 + 页号来定位一个页的,也就相当于表空间号 + 页号是一个key,缓存页就是对应的value,怎么通过一个key来快速找着一个value呢?那肯定是希表喽~ 小贴士:什么?你别告诉我你不知道哈希表是什么?我们这个文章不是讲哈希表的,如果你不会那就去找本数据结构的书看看吧~ 什么?外头的书看不懂?别急,等我~ 所以我们可以用表空间号 + 页号作为key,缓存页作为value创建一个哈希表,在需要访问某个页的数据时,先从哈希表中根据表空间号 + 页号看看有没有对应的缓存页,如果有,直接使用该缓存页就好,如果没有,那就从free链表中选一个空闲的缓存页,然后把磁盘中对应的页加载到该缓存页的位置。\nflush链表的管理 # 如果我们修改了Buffer Pool中某个缓存页的数据,那它就和磁盘上的页不一致了,这样的缓存页也被称为脏页(英文名:dirty page)。当然,最简单的做法就是每发生一次修改就立即同步到磁盘上对应的页上,但是频繁的往磁盘中写数据会严重的影响程序的性能(毕竟磁盘慢的像乌龟一样)。所以每次修改缓存页后,我们并不着急立即把修改同步到磁盘上,而是在未来的某个时间点进行同步,至于这个同步的时间点我们后边会作说明说明的,现在先不用管~\n但是如果不立即同步到磁盘的话,那之后再同步的时候我们怎么知道Buffer Pool中哪些页是脏页,哪些页从来没被修改过呢?总不能把所有的缓存页都同步到磁盘上吧,假如Buffer Pool被设置的很大,比方说300G,那一次性同步这么多数据岂不是要慢死!所以,我们不得不再创建一个存储脏页的链表,凡是修改过的缓存页对应的控制块都会作为一个节点加入到一个链表中,因为这个链表节点对应的缓存页都是需要被刷新到磁盘上的,所以也叫flush链表。链表的构造和free链表差不多,假设某个时间点Buffer Pool中的脏页数量为n,那么对应的flush链表就长这样:\nLRU链表的管理 # 缓存不够的窘境 # Buffer Pool对应的内存大小毕竟是有限的,如果需要缓存的页占用的内存大小超过了Buffer Pool大小,也就是free链表中已经没有多余的空闲缓存页的时候岂不是很尴尬,发生了这样的事儿该咋办?当然是把某些旧的缓存页从Buffer Pool中移除,然后再把新的页放进来喽~ 那么问题来了,移除哪些缓存页呢?\n为了回答这个问题,我们还需要回到我们设立Buffer Pool的初衷,我们就是想减少和磁盘的IO交互,最好每次在访问某个页的时候它都已经被缓存到Buffer Pool中了。假设我们一共访问了n次页,那么被访问的页已经在缓存中的次数除以n就是所谓的缓存命中率,我们的期望就是让缓存命中率越高越好~ 从这个角度出发,回想一下我们的微信聊天列表,排在前面的都是最近很频繁使用的,排在后边的自然就是最近很少使用的,假如列表能容纳下的联系人有限,你是会把最近很频繁使用的留下还是最近很少使用的留下呢?废话,当然是留下最近很频繁使用的了~\n简单的LRU链表 # 管理Buffer Pool的缓存页其实也是这个道理,当Buffer Pool中不再有空闲的缓存页时,就需要淘汰掉部分最近很少使用的缓存页。不过,我们怎么知道哪些缓存页最近频繁使用,哪些最近很少使用呢?呵呵,神奇的链表再一次派上了用场,我们可以再创建一个链表,由于这个链表是为了按照最近最少使用的原则去淘汰缓存页的,所以这个链表可以被称为LRU链表(LRU的英文全称:Least Recently Used)。当我们需要访问某个页时,可以这样处理LRU链表:\n如果该页不在Buffer Pool中,在把该页从磁盘加载到Buffer Pool中的缓存页时,就把该缓存页对应的控制块作为节点塞到链表的头部。\n如果该页已经缓存在Buffer Pool中,则直接把该页对应的控制块移动到LRU链表的头部。\n也就是说:只要我们使用到某个缓存页,就把该缓存页调整到LRU链表的头部,这样LRU链表尾部就是最近最少使用的缓存页喽~ 所以当Buffer Pool中的空闲缓存页使用完时,到LRU链表的尾部找些缓存页淘汰就OK啦,真简单,啧啧\u0026hellip;\n划分区域的LRU链表 # 高兴的太早了,上面的这个简单的LRU链表用了没多长时间就发现问题了,因为存在这两种比较尴尬的情况:\n情况一:InnoDB提供了一个看起来比较贴心的服务——预读(英文名:read ahead)。所谓预读,就是InnoDB认为执行当前的请求可能之后会读取某些页面,就预先把它们加载到Buffer Pool中。根据触发方式的不同,预读又可以细分为下面两种:\n+ 线性预读\n设计InnoDB的大佬提供了一个系统变量innodb_read_ahead_threshold,如果顺序访问了某个区(extent)的页面超过这个系统变量的值,就会触发一次异步读取下一个区中全部的页面到Buffer Pool的请求,注意异步读取意味着从磁盘中加载这些被预读的页面并不会影响到当前工作线程的正常执行。这个innodb_read_ahead_threshold系统变量的值默认是56,我们可以在服务器启动时通过启动参数或者服务器运行过程中直接调整该系统变量的值,不过它是一个全局变量,注意使用SET GLOBAL命令来修改哦。 小贴士:InnoDB是怎么实现异步读取的呢?在Windows或者Linux平台上,可能是直接调用操作系统内核提供的AIO接口,在其它类Unix操作系统中,使用了一种模拟AIO接口的方式来实现异步读取,其实就是让别的线程去读取需要预读的页面。如果你读不懂上面这段话,那也就没必要懂了,和我们主题其实没太多关系,你只需要知道异步读取并不会影响到当前工作线程的正常执行就好了。其实这个过程涉及到操作系统如何处理IO以及多线程的问题,找本操作系统的书看看吧,什么?操作系统的书写的都很难懂?没关系,等我~\n+ 随机预读\n如果Buffer Pool中已经缓存了某个区的13个连续的页面,不论这些页面是不是顺序读取的,都会触发一次异步读取本区中所有其的页面到Buffer Pool的请求。设计InnoDB的大佬同时提供了innodb_random_read_ahead系统变量,它的默认值为OFF,也就意味着InnoDB并不会默认开启随机预读的功能,如果我们想开启该功能,可以通过修改启动参数或者直接使用SET GLOBAL命令把该变量的值设置为ON。\n预读本来是个好事儿,如果预读到Buffer Pool中的页成功的被使用到,那就可以极大的提高语句执行的效率。可是如果用不到呢?这些预读的页都会放到LRU链表的头部,但是如果此时Buffer Pool的容量不太大而且很多预读的页面都没有用到的话,这就会导致处在LRU链表尾部的一些缓存页会很快的被淘汰掉,也就是所谓的劣币驱逐良币,会大大降低缓存命中率。\n情况二:有的小伙伴可能会写一些需要扫描全表的查询语句(比如没有建立合适的索引或者压根儿没有WHERE子句的查询)。\n扫描全表意味着什么?意味着将访问到该表所在的所有页!假设这个表中记录非常多的话,那该表会占用特别多的页,当需要访问这些页时,会把它们统统都加载到Buffer Pool中,这也就意味着吧唧一下,Buffer Pool中的所有页都被换了一次血,其他查询语句在执行时又得执行一次从磁盘加载到Buffer Pool的操作。而这种全表扫描的语句执行的频率也不高,每次执行都要把Buffer Pool中的缓存页换一次血,这严重的影响到其他查询对 Buffer Pool的使用,从而大大降低了缓存命中率。\n总结一下上面说的可能降低Buffer Pool的两种情况: - 加载到Buffer Pool中的页不一定被用到。 - 如果非常多的使用频率偏低的页被同时加载到Buffer Pool时,可能会把那些使用频率非常高的页从Buffer Pool中淘汰掉。\n因为有这两种情况的存在,所以设计InnoDB的大佬把这个LRU链表按照一定比例分成两截,分别是: - 一部分存储使用频率非常高的缓存页,所以这一部分链表也叫做热数据,或者称young区域。 - 另一部分存储使用频率不是很高的缓存页,所以这一部分链表也叫做冷数据,或者称old区域。\n为了方便大家理解,我们把示意图做了简化,各位领会精神就好:\n大家要特别注意一个事儿:我们是按照某个比例将LRU链表分成两半的,不是某些节点固定是young区域的,某些节点固定是old区域的,随着程序的运行,某个节点所属的区域也可能发生变化。那这个划分成两截的比例怎么确定呢?对于InnoDB存储引擎来说,我们可以通过查看系统变量innodb_old_blocks_pct的值来确定old区域在LRU链表中所占的比例,比方说这样: mysql\u0026gt; SHOW VARIABLES LIKE 'innodb_old_blocks_pct'; +-----------------------+-------+ | Variable_name | Value | +-----------------------+-------+ | innodb_old_blocks_pct | 37 | +-----------------------+-------+ 1 row in set (0.01 sec) 从结果可以看出来,默认情况下,old区域在LRU链表中所占的比例是37%,也就是说old区域大约占LRU链表的3/8。这个比例我们是可以设置的,我们可以在启动时修改innodb_old_blocks_pct参数来控制old区域在LRU链表中所占的比例,比方说这样修改配置文件: [server] innodb_old_blocks_pct = 40 这样我们在启动服务器后,old区域占LRU链表的比例就是40%。当然,如果在服务器运行期间,我们也可以修改这个系统变量的值,不过需要注意的是,这个系统变量属于全局变量,一经修改,会对所有客户端生效,所以我们只能这样修改: SET GLOBAL innodb_old_blocks_pct = 40;\n有了这个被划分成young和old区域的LRU链表之后,设计InnoDB的大佬就可以针对我们上面提到的两种可能降低缓存命中率的情况进行优化了:\n针对预读的页面可能不进行后续访情况的优化\n设计InnoDB的大佬规定,当磁盘上的某个页面在初次加载到Buffer Pool中的某个缓存页时,该缓存页对应的控制块会被放到old区域的头部。这样针对预读到Buffer Pool却不进行后续访问的页面就会被逐渐从old区域逐出,而不会影响young区域中被使用比较频繁的缓存页。\n针对全表扫描时,短时间内访问大量使用频率非常低的页面情况的优化\n在进行全表扫描时,虽然首次被加载到Buffer Pool的页被放到了old区域的头部,但是后续会被马上访问到,每次进行访问的时候又会把该页放到young区域的头部,这样仍然会把那些使用频率比较高的页面给顶下去。有同学会想:可不可以在第一次访问该页面时不将其从old区域移动到young区域的头部,后续访问时再将其移动到young区域的头部。回答是:行不通!因为设计InnoDB的大佬规定每次去页面中读取一条记录时,都算是访问一次页面,而一个页面中可能会包含很多条记录,也就是说读取完某个页面的记录就相当于访问了这个页面好多次。\n咋办?全表扫描有一个特点,那就是它的执行频率非常低,谁也不会没事儿老在那写全表扫描的语句玩,而且在执行全表扫描的过程中,即使某个页面中有很多条记录,也就是去多次访问这个页面所花费的时间也是非常少的。所以我们只需要规定,在对某个处在old区域的缓存页进行第一次访问时就在它对应的控制块中记录下来这个访问时间,如果后续的访问时间与第一次访问的时间在某个时间间隔内,那么该页面就不会被从old区域移动到young区域的头部,否则将它移动到young区域的头部。上述的这个间隔时间是由系统变量innodb_old_blocks_time控制的,你看:\nmysql\u0026gt; SHOW VARIABLES LIKE 'innodb_old_blocks_time'; +------------------------+-------+ | Variable_name | Value | +------------------------+-------+ | innodb_old_blocks_time | 1000 | +------------------------+-------+ 1 row in set (0.01 sec) 这个innodb_old_blocks_time的默认值是1000,它的单位是毫秒,也就意味着对于从磁盘上被加载到LRU链表的old区域的某个页来说,如果第一次和最后一次访问该页面的时间间隔小于1s(很明显在一次全表扫描的过程中,多次访问一个页面中的时间不会超过1s),那么该页是不会被加入到young区域的~ 当然,像innodb_old_blocks_pct一样,我们也可以在服务器启动或运行时设置innodb_old_blocks_time的值,这里就不赘述了,你自己试试吧~ 这里需要注意的是,如果我们把innodb_old_blocks_time的值设置为0,那么每次我们访问一个页面时就会把该页面放到young区域的头部。\n综上所述,正是因为将LRU链表划分为young和old区域这两个部分,又添加了innodb_old_blocks_time这个系统变量,才使得预读机制和全表扫描造成的缓存命中率降低的问题得到了遏制,因为用不到的预读页面以及全表扫描的页面都只会被放到old区域,而不影响young区域中的缓存页。\n更进一步优化LRU链表 # LRU链表这就说完了么?没有,早着呢~ 对于young区域的缓存页来说,我们每次访问一个缓存页就要把它移动到LRU链表的头部,这样开销是不是太大啦,毕竟在young区域的缓存页都是热点数据,也就是可能被经常访问的,这样频繁的对LRU链表进行节点移动操作是不是不太好啊?是的,为了解决这个问题其实我们还可以提出一些优化策略,比如只有被访问的缓存页位于young区域的1/4的后边,才会被移动到LRU链表头部,这样就可以降低调整LRU链表的频率,从而提升性能(也就是说如果某个缓存页对应的节点在young区域的1/4中,再次访问该缓存页时也不会将其移动到LRU链表头部)。\n小贴士:我们之前介绍随机预读的时候曾说,如果Buffer Pool中有某个区的13个连续页面就会触发随机预读,这其实是不严谨的(不幸的是MySQL文档就是这么说的[摊手]),其实还要求这13个页面是非常热的页面,所谓的非常热,指的是这些页面在整个young区域的头1/4处。\n还有没有什么别的针对LRU链表的优化措施呢?当然有啊,你要是好好学,写篇论文,写本书都不是问题,可是这毕竟是一个介绍MySQL基础知识的文章,再说多了篇幅就受不了了,也影响大家的阅读体验,所以适可而止,想了解更多的优化知识,自己去看源码或者更多关于LRU链表的知识喽~ 但是不论怎么优化,千万别忘了我们的初心:尽量高效的提高 Buffer Pool 的缓存命中率。\n其他的一些链表 # 为了更好的管理Buffer Pool中的缓存页,除了我们上面提到的一些措施,设计InnoDB的大佬们还引进了其他的一些链表,比如unzip LRU链表用于管理解压页,zip clean链表用于管理没有被解压的压缩页,zip free数组中每一个元素都代表一个链表,它们组成所谓的伙伴系统来为压缩页提供内存空间等等,反正是为了更好的管理这个Buffer Pool引入了各种链表或其他数据结构,具体的使用方式就不啰嗦了,大家有兴趣深究的再去找些更深的书或者直接看源代码吧,也可以直接来找我~ 小贴士:我们压根儿没有深入介绍过InnoDB中的压缩页,对上面的这些链表也只是为了完整性顺便提一下,如果你看不懂千万不要抑郁,因为我压根儿就没打算向大家介绍它们。\n刷新脏页到磁盘 # 后台有专门的线程每隔一段时间负责把脏页刷新到磁盘,这样可以不影响用户线程处理正常的请求。主要有两种刷新路径:\n从LRU链表的冷数据中刷新一部分页面到磁盘。\n后台线程会定时从LRU链表尾部开始扫描一些页面,扫描的页面数量可以通过系统变量innodb_lru_scan_depth来指定,如果从里边儿发现脏页,会把它们刷新到磁盘。这种刷新页面的方式被称之为BUF_FLUSH_LRU。\n从flush链表中刷新一部分页面到磁盘。\n后台线程也会定时从flush链表中刷新一部分页面到磁盘,刷新的速率取决于当时系统是不是很繁忙。这种刷新页面的方式被称之为BUF_FLUSH_LIST。\n有时候后台线程刷新脏页的进度比较慢,导致用户线程在准备加载一个磁盘页到Buffer Pool时没有可用的缓存页,这时就会尝试看看LRU链表尾部有没有可以直接释放掉的未修改页面,如果没有的话会不得不将LRU链表尾部的一个脏页同步刷新到磁盘(和磁盘交互是很慢的,这会降低处理用户请求的速度)。这种刷新单个页面到磁盘中的刷新方式被称之为BUF_FLUSH_SINGLE_PAGE。\n当然,有时候系统特别繁忙时,也可能出现用户线程批量的从flush链表中刷新脏页的情况,很显然在处理用户请求过程中去刷新脏页是一种严重降低处理速度的行为(毕竟磁盘的速度满的要死),这属于一种迫不得已的情况,不过这得放在后边介绍redo日志的checkpoint时说了。\n多个Buffer Pool实例 # 我们上面说过,Buffer Pool本质是InnoDB向操作系统申请的一块连续的内存空间,在多线程环境下,访问Buffer Pool中的各种链表都需要加锁处理什么的,在Buffer Pool特别大而且多线程并发访问特别高的情况下,单一的Buffer Pool可能会影响请求的处理速度。所以在Buffer Pool特别大的时候,我们可以把它们拆分成若干个小的Buffer Pool,每个Buffer Pool都称为一个实例,它们都是独立的,独立的去申请内存空间,独立的管理各种链表,独立的等等,所以在多线程并发访问时并不会相互影响,从而提高并发处理能力。我们可以在服务器启动的时候通过设置innodb_buffer_pool_instances的值来修改Buffer Pool实例的个数,比方说这样: [server] innodb_buffer_pool_instances = 2 这样就表明我们要创建2个Buffer Pool实例,示意图就是这样:\n小贴士:为了简便,我只把各个链表的基节点画出来了,大家应该心里清楚这些链表的节点其实就是每个缓存页对应的控制块!\n那每个Buffer Pool实例实际占多少内存空间呢?其实使用这个公式算出来的: innodb_buffer_pool_size/innodb_buffer_pool_instances 也就是总共的大小除以实例的个数,结果就是每个Buffer Pool实例占用的大小。\n不过也不是说Buffer Pool实例创建的越多越好,分别管理各个Buffer Pool也是需要性能开销的,设计InnoDB的大佬们规定:当innodb_buffer_pool_size的值小于1G的时候设置多个实例是无效的,InnoDB会默认把innodb_buffer_pool_instances 的值修改为1。而我们鼓励在Buffer Pool大小或等于1G的时候设置多个Buffer Pool实例。\ninnodb_buffer_pool_chunk_size # 在MySQL 5.7.5之前,Buffer Pool的大小只能在服务器启动时通过配置innodb_buffer_pool_size启动参数来调整大小,在服务器运行过程中是不允许调整该值的。不过设计MySQL的大佬在5.7.5以及之后的版本中支持了在服务器运行过程中调整Buffer Pool大小的功能,但是有一个问题,就是每次当我们要重新调整Buffer Pool大小时,都需要重新向操作系统申请一块连续的内存空间,然后将旧的Buffer Pool中的内容复制到这一块新空间,这是极其耗时的。所以设计MySQL的大佬们决定不再一次性为某个Buffer Pool实例向操作系统申请一大片连续的内存空间,而是以一个所谓的chunk为单位向操作系统申请空间。也就是说一个Buffer Pool实例其实是由若干个chunk组成的,一个chunk就代表一片连续的内存空间,里边儿包含了若干缓存页与其对应的控制块,画个图表示就是这样:\n上图代表的Buffer Pool就是由2个实例组成的,每个实例中又包含2个chunk。\n正是因为发明了这个chunk的概念,我们在服务器运行期间调整Buffer Pool的大小时就是以chunk为单位增加或者删除内存空间,而不需要重新向操作系统申请一片大的内存,然后进行缓存页的复制。这个所谓的chunk的大小是我们在启动操作MySQL服务器时通过innodb_buffer_pool_chunk_size启动参数指定的,它的默认值是134217728,也就是128M。不过需要注意的是,innodb_buffer_pool_chunk_size的值只能在服务器启动时指定,在服务器运行过程中是不可以修改的。 小贴士:为什么不允许在服务器运行过程中修改innodb_buffer_pool_chunk_size的值?还不是因为innodb_buffer_pool_chunk_size的值代表InnoDB向操作系统申请的一片连续的内存空间的大小,如果你在服务器运行过程中修改了该值,就意味着要重新向操作系统申请连续的内存空间并且将原先的缓存页和它们对应的控制块复制到这个新的内存空间中,这是十分耗时的操作!另外,这个innodb_buffer_pool_chunk_size的值并不包含缓存页对应的控制块的内存空间大小,所以实际上InnoDB向操作系统申请连续内存空间时,每个chunk的大小要比innodb_buffer_pool_chunk_size的值大一些,约5%。\n配置Buffer Pool时的注意事项 # innodb_buffer_pool_size必须是innodb_buffer_pool_chunk_size × innodb_buffer_pool_instances的倍数(这主要是想保证每一个Buffer Pool实例中包含的chunk数量相同)。\n假设我们指定的innodb_buffer_pool_chunk_size的值是128M,innodb_buffer_pool_instances的值是16,那么这两个值的乘积就是2G,也就是说innodb_buffer_pool_size的值必须是2G或者2G的整数倍。比方说我们在启动MySQL服务器是这样指定启动参数的:\nmysqld --innodb-buffer-pool-size=8G --innodb-buffer-pool-instances=16 默认的innodb_buffer_pool_chunk_size值是128M,指定的innodb_buffer_pool_instances的值是16,所以innodb_buffer_pool_size的值必须是2G或者2G的整数倍,上面例子中指定的innodb_buffer_pool_size的值是8G,符合规定,所以在服务器启动完成之后我们查看一下该变量的值就是我们指定的8G(8589934592字节):\nmysql\u0026gt; show variables like 'innodb_buffer_pool_size'; +-------------------------+------------+ | Variable_name | Value | +-------------------------+------------+ | innodb_buffer_pool_size | 8589934592 | +-------------------------+------------+ 1 row in set (0.00 sec)\n如果我们指定的innodb_buffer_pool_size大于2G并且不是2G的整数倍,那么服务器会自动的把innodb_buffer_pool_size的值调整为2G的整数倍,比方说我们在启动服务器时指定的innodb_buffer_pool_size的值是9G:\nmysqld --innodb-buffer-pool-size=9G --innodb-buffer-pool-instances=16 那么服务器会自动把innodb_buffer_pool_size的值调整为10G(10737418240字节),不信你看: mysql\u0026gt; show variables like 'innodb_buffer_pool_size'; +-------------------------+-------------+ | Variable_name | Value | +-------------------------+-------------+ | innodb_buffer_pool_size | 10737418240 | +-------------------------+-------------+ 1 row in set (0.01 sec)\n如果在服务器启动时,innodb_buffer_pool_chunk_size × innodb_buffer_pool_instances的值已经大于innodb_buffer_pool_size的值,那么innodb_buffer_pool_chunk_size的值会被服务器自动设置为innodb_buffer_pool_size/innodb_buffer_pool_instances的值。\n比方说我们在启动服务器时指定的innodb_buffer_pool_size的值为2G,innodb_buffer_pool_instances的值为16,innodb_buffer_pool_chunk_size的值为256M:\nmysqld --innodb-buffer-pool-size=2G --innodb-buffer-pool-instances=16 --innodb-buffer-pool-chunk-size=256M 由于256M × 16 = 4G,而4G \u0026gt; 2G,所以innodb_buffer_pool_chunk_size值会被服务器改写为innodb_buffer_pool_size/innodb_buffer_pool_instances的值,也就是:2G/16 = 128M(134217728字节),不信你看:\nmysql\u0026gt; show variables like \u0026#39;innodb_buffer_pool_chunk_size\u0026#39;; \\+-------------------------------\\+-----------\\+ | Variable_name | Value | \\+-------------------------------\\+-----------\\+ | innodb_buffer_pool_chunk_size | 134217728 | \\+-------------------------------\\+-----------\\+ 1 row in set (0.00 sec) ``` ## Buffer Pool中存储的其它信息 `Buffer Pool`的缓存页除了用来缓存磁盘上的页面以外,还可以存储锁信息、自适应哈希索引等信息,这些内容等我们之后遇到了再详细讨论~ ## 查看Buffer Pool的状态信息 设计`MySQL`的大佬贴心的给我们提供了`SHOW ENGINE INNODB STATUS`语句来查看关于`InnoDB`存储引擎运行过程中的一些状态信息,其中就包括`Buffer Pool`的一些信息,我们看一下(为了突出重点,我们只把输出中关于`Buffer Pool`的部分提取了出来): ``` mysql\u0026gt; SHOW ENGINE INNODB STATUS\\\\G # (...省略前面的许多状态) # BUFFER POOL AND MEMORY Total memory allocated 13218349056; Dictionary memory allocated 4014231 Buffer pool size 786432 Free buffers 8174 Database pages 710576 Old database pages 262143 Modified db pages 124941 Pending reads 0 Pending writes: LRU 0, flush list 0, single page 0 Pages made young 6195930012, not young 78247510485 108.18 youngs/s, 226.15 non-youngs/s Pages read 2748866728, created 29217873, written 4845680877 160.77 reads/s, 3.80 creates/s, 190.16 writes/s Buffer pool hit rate 956 / 1000, young-making rate 30 / 1000 not 605 / 1000 Pages read ahead 0.00/s, evicted without access 0.00/s, Random read ahead 0.00/s LRU len: 710576, unzip_LRU len: 118 I/O sum[134264]:cur[144], unzip sum[16]:cur[0] * * * (...省略后边的许多状态) mysql\u0026gt; ``` 我们来详细看一下这里边的每个值都代表什么意思: -`Total memory allocated`:代表`Buffer Pool`向操作系统申请的连续内存空间大小,包括全部控制块、缓存页、以及碎片的大小。 -`Dictionary memory allocated`:为数据字典信息分配的内存空间大小,注意这个内存空间和`Buffer Pool`没什么关系,不包括在`Total memory allocated`中。 -`Buffer pool size`:代表该`Buffer Pool`可以容纳多少缓存`页`,注意,单位是`页`! -`Free buffers`:代表当前`Buffer Pool`还有多少空闲缓存页,也就是`free链表`中还有多少个节点。 -`Database pages`:代表`LRU`链表中的页的数量,包含`young`和`old`两个区域的节点数量。 -`Old database pages`:代表`LRU`链表`old`区域的节点数量。 -`Modified db pages`:代表脏页数量,也就是`flush链表`中节点的数量。 -`Pending reads`:正在等待从磁盘上加载到`Buffer Pool`中的页面数量。 当准备从磁盘中加载某个页面时,会先为这个页面在`Buffer Pool`中分配一个缓存页以及它对应的控制块,然后把这个控制块添加到`LRU`的`old`区域的头部,但是这个时候真正的磁盘页并没有被加载进来,`Pending reads`的值会跟着加1。 + `Pending writes LRU`:即将从`LRU`链表中刷新到磁盘中的页面数量。 + `Pending writes flush list`:即将从`flush`链表中刷新到磁盘中的页面数量。 + `Pending writes single page`:即将以单个页面的形式刷新到磁盘中的页面数量。 + `Pages made young`:代表`LRU`链表中曾经从`old`区域移动到`young`区域头部的节点数量。 这里需要注意,一个节点每次只有从`old`区域移动到`young`区域头部时才会将`Pages made young`的值加1,也就是说如果该节点本来就在`young`区域,由于它符合在`young`区域1/4后边的要求,下一次访问这个页面时也会将它移动到`young`区域头部,但这个过程并不会导致`Pages made young`的值加1。 + `Page made not young`:在将`innodb_old_blocks_time`设置的值大于0时,首次访问或者后续访问某个处在`old`区域的节点时由于不符合时间间隔的限制而不能将其移动到`young`区域头部时,`Page made not young`的值会加1。 这里需要注意,对于处在`young`区域的节点,如果由于它在`young`区域的1/4处而导致它没有被移动到`young`区域头部,这样的访问并不会将`Page made not young`的值加1。 + `youngs/s`:代表每秒从`old`区域被移动到`young`区域头部的节点数量。 + `non-youngs/s`:代表每秒由于不满足时间限制而不能从`old`区域移动到`young`区域头部的节点数量。 + `Pages read`、`created`、`written`:代表读取,创建,写入了多少页。后边跟着读取、创建、写入的速率。 + `Buffer pool hit rate`:表示在过去某段时间,平均访问1000次页面,有多少次该页面已经被缓存到`Buffer Pool`了。 + `young-making rate`:表示在过去某段时间,平均访问1000次页面,有多少次访问使页面移动到`young`区域的头部了。 需要大家注意的一点是,这里统计的将页面移动到`young`区域的头部次数不仅仅包含从`old`区域移动到`young`区域头部的次数,还包括从`young`区域移动到`young`区域头部的次数(访问某个`young`区域的节点,只要该节点在`young`区域的1/4处往后,就会把它移动到`young`区域的头部)。 + `not (young-making rate)`:表示在过去某段时间,平均访问1000次页面,有多少次访问没有使页面移动到`young`区域的头部。 需要大家注意的一点是,这里统计的没有将页面移动到`young`区域的头部次数不仅仅包含因为设置了`innodb_old_blocks_time`系统变量而导致访问了`old`区域中的节点但没把它们移动到`young`区域的次数,还包含因为该节点在`young`区域的前1/4处而没有被移动到`young`区域头部的次数。 + `LRU len`:代表`LRU链表`中节点的数量。 + `unzip_LRU`:代表`unzip_LRU链表`中节点的数量(由于我们没有具体介绍过这个链表,现在可以忽略它的值)。 + `I/O sum`:最近50s读取磁盘页的总数。 + `I/O cur`:现在正在读取的磁盘页数量。 + `I/O unzip sum`:最近50s解压的页面数量。 + `I/O unzip cur`:正在解压的页面数量。 # 总结 1. 磁盘太慢,用内存作为缓存很有必要。 2. `Buffer Pool`本质上是`InnoDB`向操作系统申请的一段连续的内存空间,可以通过`innodb_buffer_pool_size`来调整它的大小。 3. `Buffer Pool`向操作系统申请的连续内存由控制块和缓存页组成,每个控制块和缓存页都是一一对应的,在填充足够多的控制块和缓存页的组合后,`Buffer Pool`剩余的空间可能产生不够填充一组控制块和缓存页,这部分空间不能被使用,也被称为`碎片`。 4. `InnoDB`使用了许多`链表`来管理`Buffer Pool`。 5. `free链表`中每一个节点都代表一个空闲的缓存页,在将磁盘中的页加载到`Buffer Pool`时,会从`free链表`中寻找空闲的缓存页。 6. 为了快速定位某个页是否被加载到`Buffer Pool`,使用`表空间号 + 页号`作为`key`,缓存页作为`value`,建立哈希表。 7. 在`Buffer Pool`中被修改的页称为`脏页`,脏页并不是立即刷新,而是被加入到`flush链表`中,待之后的某个时刻同步到磁盘上。 8. `LRU链表`分为`young`和`old`两个区域,可以通过`innodb_old_blocks_pct`来调节`old`区域所占的比例。首次从磁盘上加载到`Buffer Pool`的页会被放到`old`区域的头部,在`innodb_old_blocks_time`间隔时间内访问该页不会把它移动到`young`区域头部。在`Buffer Pool`没有可用的空闲缓存页时,会首先淘汰掉`old`区域的一些页。 9. 我们可以通过指定`innodb_buffer_pool_instances`来控制`Buffer Pool`实例的个数,每个`Buffer Pool`实例中都有各自独立的链表,互不干扰。 10. 自`MySQL 5.7.5`版本之后,可以在服务器运行过程中调整`Buffer Pool`大小。每个`Buffer Pool`实例由若干个`chunk`组成,每个`chunk`的大小可以在服务器启动时通过启动参数调整。 11. 可以用下面的命令查看`Buffer Pool`的状态信息: `SHOW ENGINE INNODB STATUS\\G` "},{"id":16,"href":"/zh/docs/technology/MySQL/MySQL%E6%98%AF%E6%80%8E%E6%A0%B7%E8%BF%90%E8%A1%8C%E7%9A%84/%E7%AC%AC17%E7%AB%A0_%E7%A5%9E%E5%85%B5%E5%88%A9%E5%99%A8-optimizer_trace%E8%A1%A8%E7%9A%84%E7%A5%9E%E5%99%A8%E5%8A%9F%E6%95%88/","title":"第17章_神兵利器-optimizer_trace表的神器功效","section":"My Sql是怎样运行的","content":"第17章 神兵利器-optimizer trace表的神器功效\n对于MySQL 5.6以及之前的版本来说,查询优化器就像是一个黑盒子一样,你只能通过EXPLAIN语句查看到最后优化器决定使用的执行计划,却无法知道它为什么做这个决策。这对于一部分喜欢刨根问底的小伙伴来说简直是灾难:“我就觉得使用其他的执行方案比EXPLAIN输出的这种方案强,凭什么优化器做的决定和我想的不一样呢?”\n在MySQL 5.6以及之后的版本中,设计MySQL的大佬贴心的为这部分小伙伴提出了一个optimizer trace的功能,这个功能可以让我们方便的查看优化器生成执行计划的整个过程,这个功能的开启与关闭由系统变量optimizer_trace决定,我们看一下: mysql\u0026gt; SHOW VARIABLES LIKE 'optimizer_trace'; +-----------------+--------------------------+ | Variable_name | Value | +-----------------+--------------------------+ | optimizer_trace | enabled=off,one_line=off | +-----------------+--------------------------+ 1 row in set (0.02 sec) 可以看到enabled值为off,表明这个功能默认是关闭的。 小贴士:one_line的值是控制输出格式的,如果为on那么所有输出都将在一行中展示,不适合人阅读,所以我们就保持其默认值为off吧。 如果想打开这个功能,必须首先把enabled的值改为on,就像这样: mysql\u0026gt; SET optimizer_trace=\u0026quot;enabled=on\u0026quot;; Query OK, 0 rows affected (0.00 sec) 然后我们就可以输入我们想要查看优化过程的查询语句,当该查询语句执行完成后,就可以到information_schema数据库下的OPTIMIZER_TRACE表中查看完整的优化过程。这个OPTIMIZER_TRACE表有4个列,分别是: - QUERY:表示我们的查询语句。 - TRACE:表示优化过程的JSON格式文本。 - MISSING_BYTES_BEYOND_MAX_MEM_SIZE:由于优化过程可能会输出很多,如果超过某个限制时,多余的文本将不会被显示,这个字段展示了被忽略的文本字节数。 - INSUFFICIENT_PRIVILEGES:表示是否没有权限查看优化过程,默认值是0,只有某些特殊情况下才会是1,我们暂时不关心这个字段的值。\n完整的使用optimizer trace功能的步骤总结如下: ```\n打开optimizer trace功能 (默认情况下它是关闭的): SET optimizer_trace=\u0026ldquo;enabled=on\u0026rdquo;;\n这里输入你自己的查询语句 SELECT \u0026hellip;;\n从OPTIMIZER_TRACE表中查看上一个查询的优化过程 SELECT * FROM information_schema.OPTIMIZER_TRACE;\n可能你还要观察其他语句执行的优化过程,重复上面的第2、3步 \u0026hellip;\n当你停止查看语句的优化过程时,把optimizer trace功能关闭 SET optimizer_trace=\u0026ldquo;enabled=off\u0026rdquo;; 现在我们有一个搜索条件比较多的查询语句,它的执行计划如下: mysql\u0026gt; EXPLAIN SELECT * FROM s1 WHERE -\u0026gt; key1 \u0026gt; \u0026lsquo;z\u0026rsquo; AND -\u0026gt; key2 \u0026lt; 1000000 AND -\u0026gt; key3 IN (\u0026lsquo;a\u0026rsquo;, \u0026lsquo;b\u0026rsquo;, \u0026lsquo;c\u0026rsquo;) AND -\u0026gt; common_field = \u0026lsquo;abc\u0026rsquo;; +\u0026mdash;-+\u0026mdash;\u0026mdash;\u0026mdash;\u0026mdash;-+\u0026mdash;\u0026mdash;-+\u0026mdash;\u0026mdash;\u0026mdash;\u0026mdash;+\u0026mdash;\u0026mdash;-+\u0026mdash;\u0026mdash;\u0026mdash;\u0026mdash;\u0026mdash;\u0026mdash;\u0026mdash;\u0026mdash;\u0026mdash;-+\u0026mdash;\u0026mdash;\u0026mdash;-+\u0026mdash;\u0026mdash;\u0026mdash;+\u0026mdash;\u0026mdash;+\u0026mdash;\u0026mdash;+\u0026mdash;\u0026mdash;\u0026mdash;-+\u0026mdash;\u0026mdash;\u0026mdash;\u0026mdash;\u0026mdash;\u0026mdash;\u0026mdash;\u0026mdash;\u0026mdash;\u0026mdash;\u0026mdash;\u0026mdash;+ | id | select_type | table | partitions | type | possible_keys | key | key_len | ref | rows | filtered | Extra | +\u0026mdash;-+\u0026mdash;\u0026mdash;\u0026mdash;\u0026mdash;-+\u0026mdash;\u0026mdash;-+\u0026mdash;\u0026mdash;\u0026mdash;\u0026mdash;+\u0026mdash;\u0026mdash;-+\u0026mdash;\u0026mdash;\u0026mdash;\u0026mdash;\u0026mdash;\u0026mdash;\u0026mdash;\u0026mdash;\u0026mdash;-+\u0026mdash;\u0026mdash;\u0026mdash;-+\u0026mdash;\u0026mdash;\u0026mdash;+\u0026mdash;\u0026mdash;+\u0026mdash;\u0026mdash;+\u0026mdash;\u0026mdash;\u0026mdash;-+\u0026mdash;\u0026mdash;\u0026mdash;\u0026mdash;\u0026mdash;\u0026mdash;\u0026mdash;\u0026mdash;\u0026mdash;\u0026mdash;\u0026mdash;\u0026mdash;+ | 1 | SIMPLE | s1 | NULL | range | idx_key2,idx_key1,idx_key3 | idx_key2 | 5 | NULL | 12 | 0.42 | Using index condition; Using where | +\u0026mdash;-+\u0026mdash;\u0026mdash;\u0026mdash;\u0026mdash;-+\u0026mdash;\u0026mdash;-+\u0026mdash;\u0026mdash;\u0026mdash;\u0026mdash;+\u0026mdash;\u0026mdash;-+\u0026mdash;\u0026mdash;\u0026mdash;\u0026mdash;\u0026mdash;\u0026mdash;\u0026mdash;\u0026mdash;\u0026mdash;-+\u0026mdash;\u0026mdash;\u0026mdash;-+\u0026mdash;\u0026mdash;\u0026mdash;+\u0026mdash;\u0026mdash;+\u0026mdash;\u0026mdash;+\u0026mdash;\u0026mdash;\u0026mdash;-+\u0026mdash;\u0026mdash;\u0026mdash;\u0026mdash;\u0026mdash;\u0026mdash;\u0026mdash;\u0026mdash;\u0026mdash;\u0026mdash;\u0026mdash;\u0026mdash;+ 1 row in set, 1 warning (0.00 sec) 可以看到该查询可能使用到的索引有3个,那么为什么优化器最终选择了idx_key2而不选择其他的索引或者直接全表扫描呢?这时候就可以通过otpimzer trace功能来查看优化器的具体工作过程: SET optimizer_trace=\u0026ldquo;enabled=on\u0026rdquo;;\nSELECT * FROM s1 WHERE key1 \u0026gt; \u0026lsquo;z\u0026rsquo; AND key2 \u0026lt; 1000000 AND key3 IN (\u0026lsquo;a\u0026rsquo;, \u0026lsquo;b\u0026rsquo;, \u0026lsquo;c\u0026rsquo;) AND common_field = \u0026lsquo;abc\u0026rsquo;;\nSELECT * FROM information_schema.OPTIMIZER_TRACE\\G\n``` 我们直接看一下通过查询OPTIMIZER_TRACE表得到的输出(我使用\\#后跟随注释的形式为大家解释了优化过程中的一些比较重要的点,大家重点关注一下):\n分析的查询语句是什么 QUERY: SELECT * FROM s1 WHERE key1 \u0026gt; \u0026#39;z\u0026#39; AND key2 \u0026lt; 1000000 AND key3 IN (\u0026#39;a\u0026#39;, \u0026#39;b\u0026#39;, \u0026#39;c\u0026#39;) AND common_field = \u0026#39;abc\u0026#39; 优化的具体过程 TRACE: \\{ \u0026#34;steps\u0026#34;: [ \\{ \u0026#34;join_preparation\u0026#34;: \\{ \\# prepare阶段 \u0026#34;select\\#\u0026#34;: 1, \u0026#34;steps\u0026#34;: \\[ \\{ \u0026#34;IN_uses_bisection\u0026#34;: true \\}, \\{ \u0026#34;expanded_query\u0026#34;: \u0026#34;/* select\\#1 */ select `s1`.`id` AS `id`,`s1`.`key1` AS `key1`,`s1`.`key2` AS `key2`,`s1`.`key3` AS `key3`,`s1`.`key_part1` AS `key_part1`,`s1`.`key_part2` AS `key_part2`,`s1`.`key_part3` AS `key_part3`,`s1`.`common_field` AS `common_field` from `s1` where (\\(`s1`.`key1` \u0026gt; \u0026#39;z\u0026#39;) and (`s1`.`key2` \u0026lt; 1000000) and (`s1`.`key3` in \\(\u0026#39;a\u0026#39;,\u0026#39;b\u0026#39;,\u0026#39;c\u0026#39;)\\) and (`s1`.`common_field` = \u0026#39;abc\u0026#39;)\\)\u0026#34; \\} ] /* steps */ \\} /* join_preparation */ \\}, \\{ \u0026#34;join_optimization\u0026#34;: \\{ \\# optimize阶段 \u0026#34;select\\#\u0026#34;: 1, \u0026#34;steps\u0026#34;: \\[ \\{ \u0026#34;condition_processing\u0026#34;: \\{ \\# 处理搜索条件 \u0026#34;condition\u0026#34;: \u0026#34;WHERE\u0026#34;, \\# 原始搜索条件 \u0026#34;original_condition\u0026#34;: \u0026#34;(\\(`s1`.`key1` \u0026gt; \u0026#39;z\u0026#39;) and (`s1`.`key2` \u0026lt; 1000000) and (`s1`.`key3` in \\(\u0026#39;a\u0026#39;,\u0026#39;b\u0026#39;,\u0026#39;c\u0026#39;)\\) and (`s1`.`common_field` = \u0026#39;abc\u0026#39;)\\)\u0026#34;, \u0026#34;steps\u0026#34;: \\[ \\{ \\# 等值传递转换 \u0026#34;transformation\u0026#34;: \u0026#34;equality_propagation\u0026#34;, \u0026#34;resulting_condition\u0026#34;: \u0026#34;(\\(`s1`.`key1` \u0026gt; \u0026#39;z\u0026#39;) and (`s1`.`key2` \u0026lt; 1000000) and (`s1`.`key3` in \\(\u0026#39;a\u0026#39;,\u0026#39;b\u0026#39;,\u0026#39;c\u0026#39;)\\) and (`s1`.`common_field` = \u0026#39;abc\u0026#39;)\\)\u0026#34; \\}, \\{ \\# 常量传递转换 \u0026#34;transformation\u0026#34;: \u0026#34;constant_propagation\u0026#34;, \u0026#34;resulting_condition\u0026#34;: \u0026#34;(\\(`s1`.`key1` \u0026gt; \u0026#39;z\u0026#39;) and (`s1`.`key2` \u0026lt; 1000000) and (`s1`.`key3` in \\(\u0026#39;a\u0026#39;,\u0026#39;b\u0026#39;,\u0026#39;c\u0026#39;)\\) and (`s1`.`common_field` = \u0026#39;abc\u0026#39;)\\)\u0026#34; \\}, \\{ \\# 去除没用的条件 \u0026#34;transformation\u0026#34;: \u0026#34;trivial_condition_removal\u0026#34;, \u0026#34;resulting_condition\u0026#34;: \u0026#34;(\\(`s1`.`key1` \u0026gt; \u0026#39;z\u0026#39;) and (`s1`.`key2` \u0026lt; 1000000) and (`s1`.`key3` in \\(\u0026#39;a\u0026#39;,\u0026#39;b\u0026#39;,\u0026#39;c\u0026#39;)\\) and (`s1`.`common_field` = \u0026#39;abc\u0026#39;)\\)\u0026#34; \\} \\] /* steps */ \\} /* condition_processing */ \\}, \\{ \\# 替换虚拟生成列 \u0026#34;substitute_generated_columns\u0026#34;: \\{ \\} /* substitute_generated_columns */ \\}, \\{ \\# 表的依赖信息 \u0026#34;table_dependencies\u0026#34;: [ \\{ \u0026#34;table\u0026#34;: \u0026#34;`s1`\u0026#34;, \u0026#34;row_may_be_null\u0026#34;: false, \u0026#34;map_bit\u0026#34;: 0, \u0026#34;depends_on_map_bits\u0026#34;: \\[ ] /* depends_on_map_bits */ \\} \\] /* table_dependencies */ \\}, \\{ \u0026#34;ref_optimizer_key_uses\u0026#34;: [ ] /* ref_optimizer_key_uses */ \\}, \\{ # 预估不同单表访问方法的访问成本 \u0026#34;rows_estimation\u0026#34;: [ { \u0026#34;table\u0026#34;: \u0026#34;`s1`\u0026#34;, \u0026#34;range_analysis\u0026#34;: { \u0026#34;table_scan\u0026#34;: { # 全表扫描的行数以及成本 \u0026#34;rows\u0026#34;: 9688, \u0026#34;cost\u0026#34;: 2036.7 } /* table_scan */, # 分析可能使用的索引 \u0026#34;potential_range_indexes\u0026#34;: [ { \u0026#34;index\u0026#34;: \u0026#34;PRIMARY\u0026#34;, # 主键不可用 \u0026#34;usable\u0026#34;: false, \u0026#34;cause\u0026#34;: \u0026#34;not_applicable\u0026#34; }, { \u0026#34;index\u0026#34;: \u0026#34;idx_key2\u0026#34;, # idx_key2可能被使用 \u0026#34;usable\u0026#34;: true, \u0026#34;key_parts\u0026#34;: [ \u0026#34;key2\u0026#34; ] /* key_parts */ }, { \u0026#34;index\u0026#34;: \u0026#34;idx_key1\u0026#34;, # idx_key1可能被使用 \u0026#34;usable\u0026#34;: true, \u0026#34;key_parts\u0026#34;: [ \u0026#34;key1\u0026#34;, \u0026#34;id\u0026#34; ] /* key_parts */ }, { \u0026#34;index\u0026#34;: \u0026#34;idx_key3\u0026#34;, # idx_key3可能被使用 \u0026#34;usable\u0026#34;: true, \u0026#34;key_parts\u0026#34;: [ \u0026#34;key3\u0026#34;, \u0026#34;id\u0026#34; ] /* key_parts */ }, { \u0026#34;index\u0026#34;: \u0026#34;idx_key_part\u0026#34;, # idx_keypart不可用 \u0026#34;usable\u0026#34;: false, \u0026#34;cause\u0026#34;: \u0026#34;not_applicable\u0026#34; } ] /* potential_range_indexes */, \u0026#34;setup_range_conditions\u0026#34;: [ ] /* setup_range_conditions */, \u0026#34;group_index_range\u0026#34;: { \u0026#34;chosen\u0026#34;: false, \u0026#34;cause\u0026#34;: \u0026#34;not_group_by_or_distinct\u0026#34; } /* group_index_range */, # 分析各种可能使用的索引的成本 \u0026#34;analyzing_range_alternatives\u0026#34;: { \u0026#34;range_scan_alternatives\u0026#34;: [ { # 使用idx_key2的成本分析 \u0026#34;index\u0026#34;: \u0026#34;idx_key2\u0026#34;, # 使用idx_key2的范围区间 \u0026#34;ranges\u0026#34;: [ \u0026#34;NULL \u0026lt; key2 \u0026lt; 1000000\u0026#34; ] /* ranges */, \u0026#34;index_dives_for_eq_ranges\u0026#34;: true, # 是否使用index dive \u0026#34;rowid_ordered\u0026#34;: false, # 使用该索引获取的记录是否按照主键排序 \u0026#34;using_mrr\u0026#34;: false, # 是否使用mrr \u0026#34;index_only\u0026#34;: false, # 是否是索引覆盖访问 \u0026#34;rows\u0026#34;: 12, # 使用该索引获取的记录条数 \u0026#34;cost\u0026#34;: 15.41, # 使用该索引的成本 \u0026#34;chosen\u0026#34;: true # 是否选择该索引 }, { # 使用idx_key1的成本分析 \u0026#34;index\u0026#34;: \u0026#34;idx_key1\u0026#34;, # 使用idx_key1的范围区间 \u0026#34;ranges\u0026#34;: [ \u0026#34;z \u0026lt; key1\u0026#34; ] /* ranges */, \u0026#34;index_dives_for_eq_ranges\u0026#34;: true, # 同上 \u0026#34;rowid_ordered\u0026#34;: false, # 同上 \u0026#34;using_mrr\u0026#34;: false, # 同上 \u0026#34;index_only\u0026#34;: false, # 同上 \u0026#34;rows\u0026#34;: 266, # 同上 \u0026#34;cost\u0026#34;: 320.21, # 同上 \u0026#34;chosen\u0026#34;: false, # 同上 \u0026#34;cause\u0026#34;: \u0026#34;cost\u0026#34; # 因为成本太大所以不选择该索引 }, { # 使用idx_key3的成本分析 \u0026#34;index\u0026#34;: \u0026#34;idx_key3\u0026#34;, # 使用idx_key3的范围区间 \u0026#34;ranges\u0026#34;: [ \u0026#34;a \u0026lt;= key3 \u0026lt;= a\u0026#34;, \u0026#34;b \u0026lt;= key3 \u0026lt;= b\u0026#34;, \u0026#34;c \u0026lt;= key3 \u0026lt;= c\u0026#34; ] /* ranges */, \u0026#34;index_dives_for_eq_ranges\u0026#34;: true, # 同上 \u0026#34;rowid_ordered\u0026#34;: false, # 同上 \u0026#34;using_mrr\u0026#34;: false, # 同上 \u0026#34;index_only\u0026#34;: false, # 同上 \u0026#34;rows\u0026#34;: 21, # 同上 \u0026#34;cost\u0026#34;: 28.21, # 同上 \u0026#34;chosen\u0026#34;: false, # 同上 \u0026#34;cause\u0026#34;: \u0026#34;cost\u0026#34; # 同上 } ] /* range_scan_alternatives */, # 分析使用索引合并的成本 \u0026#34;analyzing_roworder_intersect\u0026#34;: { \u0026#34;usable\u0026#34;: false, \u0026#34;cause\u0026#34;: \u0026#34;too_few_roworder_scans\u0026#34; } /* analyzing_roworder_intersect */ } /* analyzing_range_alternatives */, # 对于上述单表查询s1最优的访问方法 \u0026#34;chosen_range_access_summary\u0026#34;: { \u0026#34;range_access_plan\u0026#34;: { \u0026#34;type\u0026#34;: \u0026#34;range_scan\u0026#34;, \u0026#34;index\u0026#34;: \u0026#34;idx_key2\u0026#34;, \u0026#34;rows\u0026#34;: 12, \u0026#34;ranges\u0026#34;: [ \u0026#34;NULL \u0026lt; key2 \u0026lt; 1000000\u0026#34; ] /* ranges */ } /* range_access_plan */, \u0026#34;rows_for_plan\u0026#34;: 12, \u0026#34;cost_for_plan\u0026#34;: 15.41, \u0026#34;chosen\u0026#34;: true } /* chosen_range_access_summary */ } /* range_analysis */ } ] /* rows_estimation */ }, { # 分析各种可能的执行计划 #(对多表查询这可能有很多种不同的方案,单表查询的方案上面已经分析过了,直接选取idx_key2就好) \u0026#34;considered_execution_plans\u0026#34;: [ { \u0026#34;plan_prefix\u0026#34;: [ ] /* plan_prefix */, \u0026#34;table\u0026#34;: \u0026#34;`s1`\u0026#34;, \u0026#34;best_access_path\u0026#34;: { \u0026#34;considered_access_paths\u0026#34;: [ { \u0026#34;rows_to_scan\u0026#34;: 12, \u0026#34;access_type\u0026#34;: \u0026#34;range\u0026#34;, \u0026#34;range_details\u0026#34;: { \u0026#34;used_index\u0026#34;: \u0026#34;idx_key2\u0026#34; } /* range_details */, \u0026#34;resulting_rows\u0026#34;: 12, \u0026#34;cost\u0026#34;: 17.81, \u0026#34;chosen\u0026#34;: true } ] /* considered_access_paths */ } /* best_access_path */, \u0026#34;condition_filtering_pct\u0026#34;: 100, \u0026#34;rows_for_plan\u0026#34;: 12, \u0026#34;cost_for_plan\u0026#34;: 17.81, \u0026#34;chosen\u0026#34;: true } ] /* considered_execution_plans */ }, { # 尝试给查询添加一些其他的查询条件 \u0026#34;attaching_conditions_to_tables\u0026#34;: { \u0026#34;original_condition\u0026#34;: \u0026#34;((`s1`.`key1` \u0026gt; \u0026#39;z\u0026#39;) and (`s1`.`key2` \u0026lt; 1000000) and (`s1`.`key3` in (\u0026#39;a\u0026#39;,\u0026#39;b\u0026#39;,\u0026#39;c\u0026#39;)) and (`s1`.`common_field` = \u0026#39;abc\u0026#39;))\u0026#34;, \u0026#34;attached_conditions_computation\u0026#34;: [ ] /* attached_conditions_computation */, \u0026#34;attached_conditions_summary\u0026#34;: [ { \u0026#34;table\u0026#34;: \u0026#34;`s1`\u0026#34;, \u0026#34;attached\u0026#34;: \u0026#34;((`s1`.`key1` \u0026gt; \u0026#39;z\u0026#39;) and (`s1`.`key2` \u0026lt; 1000000) and (`s1`.`key3` in (\u0026#39;a\u0026#39;,\u0026#39;b\u0026#39;,\u0026#39;c\u0026#39;)) and (`s1`.`common_field` = \u0026#39;abc\u0026#39;))\u0026#34; } ] /* attached_conditions_summary */ } /* attaching_conditions_to_tables */ }, { # 再稍稍的改进一下执行计划 \u0026#34;refine_plan\u0026#34;: [ { \u0026#34;table\u0026#34;: \u0026#34;`s1`\u0026#34;, \u0026#34;pushed_index_condition\u0026#34;: \u0026#34;(`s1`.`key2` \u0026lt; 1000000)\u0026#34;, \u0026#34;table_condition_attached\u0026#34;: \u0026#34;((`s1`.`key1` \u0026gt; \u0026#39;z\u0026#39;) and (`s1`.`key3` in (\u0026#39;a\u0026#39;,\u0026#39;b\u0026#39;,\u0026#39;c\u0026#39;)) and (`s1`.`common_field` = \u0026#39;abc\u0026#39;))\u0026#34; } ] /* refine_plan */ } ] /* steps */ } /* join_optimization */ }, { \u0026#34;join_execution\u0026#34;: { # execute阶段 \u0026#34;select#\u0026#34;: 1, \u0026#34;steps\u0026#34;: [ ] /* steps */ } /* join_execution */ } \\] /* steps */ \\} 因优化过程文本太多而丢弃的文本字节大小,值为0时表示并没有丢弃 MISSING_BYTES_BEYOND_MAX_MEM_SIZE: 0 权限字段 INSUFFICIENT_PRIVILEGES: 0 1 row in set (0.00 sec) ``` 大家看到这个输出的第一感觉就是这文本也太多了点儿吧,其实这只是优化器执行过程中的一小部分,设计`MySQL`的大佬可能会在之后的版本中添加更多的优化过程信息。不过杂乱之中其实还是蛮有规律的,优化过程大致分为了三个阶段: + `prepare`阶段 + `optimize`阶段 + `execute`阶段 我们所说的基于成本的优化主要集中在`optimize`阶段,对于单表查询来说,我们主要关注`optimize`阶段的`\u0026#34;rows_estimation\u0026#34;`这个过程,这个过程深入分析了对单表查询的各种执行方案的成本;对于多表连接查询来说,我们更多需要关注`\u0026#34;considered_execution_plans\u0026#34;`这个过程,这个过程里会写明各种不同的连接方式所对应的成本。反正优化器最终会选择成本最低的那种方案来作为最终的执行计划,也就是我们使用`EXPLAIN`语句所展现出的那种方案。 如果有小伙伴对使用`EXPLAIN`语句展示出的对某个查询的执行计划很不理解,大家可以尝试使用`optimizer trace`功能来详细了解每一种执行方案对应的成本,相信这个功能能让大家更深入的了解`MySQL`查询优化器。 "},{"id":17,"href":"/zh/docs/technology/MySQL/MySQL%E6%98%AF%E6%80%8E%E6%A0%B7%E8%BF%90%E8%A1%8C%E7%9A%84/%E7%AC%AC16%E7%AB%A0_%E6%9F%A5%E8%AF%A2%E4%BC%98%E5%8C%96%E7%9A%84%E7%99%BE%E7%A7%91%E5%85%A8%E4%B9%A6-Explain%E8%AF%A6%E8%A7%A3%E4%B8%8B/","title":"第16章_查询优化的百科全书-Explain详解(下)","section":"My Sql是怎样运行的","content":"第16章 查询优化的百科全书-Explain详解(下)\n执行计划输出中各列详解 # 本章紧接着上一节的内容,继续介绍EXPLAIN语句输出的各个列的意思。\nExtra # 顾名思义,Extra列是用来说明一些额外信息的,我们可以通过这些额外信息来更准确的理解MySQL到底将如何执行给定的查询语句。MySQL提供的额外信息有好几十个,我们就不一个一个介绍了(都介绍了感觉我们的文章就跟文档差不多了~),所以我们只挑一些平时常见的或者比较重要的额外信息介绍给大家。\nNo tables used\n当查询语句的没有FROM子句时将会提示该额外信息,比如: mysql\u0026gt; EXPLAIN SELECT 1; +----+-------------+-------+------------+------+---------------+------+---------+------+------+----------+----------------+ | id | select_type | table | partitions | type | possible_keys | key | key_len | ref | rows | filtered | Extra | +----+-------------+-------+------------+------+---------------+------+---------+------+------+----------+----------------+ | 1 | SIMPLE | NULL | NULL | NULL | NULL | NULL | NULL | NULL | NULL | NULL | No tables used | +----+-------------+-------+------------+------+---------------+------+---------+------+------+----------+----------------+ 1 row in set, 1 warning (0.00 sec)\nImpossible WHERE\n查询语句的WHERE子句永远为FALSE时将会提示该额外信息,比方说: mysql\u0026gt; EXPLAIN SELECT * FROM s1 WHERE 1 != 1; +----+-------------+-------+------------+------+---------------+------+---------+------+------+----------+------------------+ | id | select_type | table | partitions | type | possible_keys | key | key_len | ref | rows | filtered | Extra | +----+-------------+-------+------------+------+---------------+------+---------+------+------+----------+------------------+ | 1 | SIMPLE | NULL | NULL | NULL | NULL | NULL | NULL | NULL | NULL | NULL | Impossible WHERE | +----+-------------+-------+------------+------+---------------+------+---------+------+------+----------+------------------+ 1 row in set, 1 warning (0.01 sec)\nNo matching min/max row\n当查询列表处有MIN或者MAX聚集函数,但是并没有符合WHERE子句中的搜索条件的记录时,将会提示该额外信息,比方说:\nmysql\u0026gt; EXPLAIN SELECT MIN(key1) FROM s1 WHERE key1 = 'abcdefg'; +----+-------------+-------+------------+------+---------------+------+---------+------+------+----------+-------------------------+ | id | select_type | table | partitions | type | possible_keys | key | key_len | ref | rows | filtered | Extra | +----+-------------+-------+------------+------+---------------+------+---------+------+------+----------+-------------------------+ | 1 | SIMPLE | NULL | NULL | NULL | NULL | NULL | NULL | NULL | NULL | NULL | No matching min/max row | +----+-------------+-------+------------+------+---------------+------+---------+------+------+----------+-------------------------+ 1 row in set, 1 warning (0.00 sec)\nUsing index\n当我们的查询列表以及搜索条件中只包含属于某个索引的列,也就是在可以使用索引覆盖的情况下,在Extra列将会提示该额外信息。比方说下面这个查询中只需要用到idx_key1而不需要回表操作: mysql\u0026gt; EXPLAIN SELECT key1 FROM s1 WHERE key1 = 'a'; +----+-------------+-------+------------+------+---------------+----------+---------+-------+------+----------+-------------+ | id | select_type | table | partitions | type | possible_keys | key | key_len | ref | rows | filtered | Extra | +----+-------------+-------+------------+------+---------------+----------+---------+-------+------+----------+-------------+ | 1 | SIMPLE | s1 | NULL | ref | idx_key1 | idx_key1 | 303 | const | 8 | 100.00 | Using index | +----+-------------+-------+------------+------+---------------+----------+---------+-------+------+----------+-------------+ 1 row in set, 1 warning (0.00 sec)\nUsing index condition\n有些搜索条件中虽然出现了索引列,但却不能使用到索引,比如下面这个查询:\nSELECT * FROM s1 WHERE key1 \u0026gt; 'z' AND key1 LIKE '%a'; 其中的key1 \u0026gt; 'z'可以使用到索引,但是key1 LIKE '%a'却无法使用到索引,在以前版本的MySQL中,是按照下面步骤来执行这个查询的: - 先根据key1 \u0026gt; 'z'这个条件,从二级索引idx_key1中获取到对应的二级索引记录。 - 根据上一步骤得到的二级索引记录中的主键值进行回表,找到完整的用户记录再检测该记录是否符合key1 LIKE '%a'这个条件,将符合条件的记录加入到最后的结果集。\n但是虽然key1 LIKE '%a'不能组成范围区间参与range访问方法的执行,但这个条件毕竟只涉及到了key1列,所以设计MySQL的大佬把上面的步骤改进了一下: - 先根据key1 \u0026gt; 'z'这个条件,定位到二级索引idx_key1中对应的二级索引记录。 - 对于指定的二级索引记录,先不着急回表,而是先检测一下该记录是否满足key1 LIKE '%a'这个条件,如果这个条件不满足,则该二级索引记录压根儿就没必要回表。 - 对于满足key1 LIKE '%a'这个条件的二级索引记录执行回表操作。\n我们说回表操作其实是一个随机IO,比较耗时,所以上述修改虽然只改进了一点点,但是可以省去好多回表操作的成本。设计MySQL的大佬们把他们的这个改进称之为索引条件下推(英文名:Index Condition Pushdown)。\n如果在查询语句的执行过程中将要使用索引条件下推这个特性,在Extra列中将会显示Using index condition,比如这样:\nmysql\u0026gt; EXPLAIN SELECT * FROM s1 WHERE key1 \u0026gt; 'z' AND key1 LIKE '%b'; +----+-------------+-------+------------+-------+---------------+----------+---------+------+------+----------+-----------------------+ | id | select_type | table | partitions | type | possible_keys | key | key_len | ref | rows | filtered | Extra | +----+-------------+-------+------------+-------+---------------+----------+---------+------+------+----------+-----------------------+ | 1 | SIMPLE | s1 | NULL | range | idx_key1 | idx_key1 | 303 | NULL | 266 | 100.00 | Using index condition | +----+-------------+-------+------------+-------+---------------+----------+---------+------+------+----------+-----------------------+ 1 row in set, 1 warning (0.01 sec)\nUsing where\n当我们使用全表扫描来执行对某个表的查询,并且该语句的WHERE子句中有针对该表的搜索条件时,在Extra列中会提示上述额外信息。比如下面这个查询:\nmysql\u0026gt; EXPLAIN SELECT * FROM s1 WHERE common_field = 'a'; +----+-------------+-------+------------+------+---------------+------+---------+------+------+----------+-------------+ | id | select_type | table | partitions | type | possible_keys | key | key_len | ref | rows | filtered | Extra | +----+-------------+-------+------------+------+---------------+------+---------+------+------+----------+-------------+ | 1 | SIMPLE | s1 | NULL | ALL | NULL | NULL | NULL | NULL | 9688 | 10.00 | Using where | +----+-------------+-------+------------+------+---------------+------+---------+------+------+----------+-------------+ 1 row in set, 1 warning (0.01 sec) 当使用索引访问来执行对某个表的查询,并且该语句的WHERE子句中有除了该索引包含的列之外的其他搜索条件时,在Extra列中也会提示上述额外信息。比如下面这个查询虽然使用idx_key1索引执行查询,但是搜索条件中除了包含key1的搜索条件key1 = 'a',还有包含common_field的搜索条件,所以Extra列会显示Using where的提示:\nmysql\u0026gt; EXPLAIN SELECT * FROM s1 WHERE key1 = 'a' AND common_field = 'a'; +----+-------------+-------+------------+------+---------------+----------+---------+-------+------+----------+-------------+ | id | select_type | table | partitions | type | possible_keys | key | key_len | ref | rows | filtered | Extra | +----+-------------+-------+------------+------+---------------+----------+---------+-------+------+----------+-------------+ | 1 | SIMPLE | s1 | NULL | ref | idx_key1 | idx_key1 | 303 | const | 8 | 10.00 | Using where | +----+-------------+-------+------------+------+---------------+----------+---------+-------+------+----------+-------------+ 1 row in set, 1 warning (0.00 sec)\nUsing join buffer (Block Nested Loop)\n在连接查询执行过程中,当被驱动表不能有效的利用索引加快访问速度,MySQL一般会为其分配一块名叫join buffer的内存块来加快查询速度,也就是我们所讲的基于块的嵌套循环算法,比如下面这个查询语句:\nmysql\u0026gt; EXPLAIN SELECT * FROM s1 INNER JOIN s2 ON s1.common_field = s2.common_field; +----+-------------+-------+------------+------+---------------+------+---------+------+------+----------+----------------------------------------------------+ | id | select_type | table | partitions | type | possible_keys | key | key_len | ref | rows | filtered | Extra | +----+-------------+-------+------------+------+---------------+------+---------+------+------+----------+----------------------------------------------------+ | 1 | SIMPLE | s1 | NULL | ALL | NULL | NULL | NULL | NULL | 9688 | 100.00 | NULL | | 1 | SIMPLE | s2 | NULL | ALL | NULL | NULL | NULL | NULL | 9954 | 10.00 | Using where; Using join buffer (Block Nested Loop) | +----+-------------+-------+------------+------+---------------+------+---------+------+------+----------+----------------------------------------------------+ 2 rows in set, 1 warning (0.03 sec)\n可以在对s2表的执行计划的Extra列显示了两个提示:\n+ Using join buffer (Block Nested Loop):这是因为对表s2的访问不能有效利用索引,只好退而求其次,使用join buffer来减少对s2表的访问次数,从而提高性能。\n+ Using where:可以看到查询语句中有一个s1.common_field = s2.common_field条件,因为s1是驱动表,s2是被驱动表,所以在访问s2表时,s1.common_field的值已经确定下来了,所以实际上查询s2表的条件就是s2.common_field = 一个常数,所以提示了Using where额外信息。\nNot exists\n当我们使用左(外)连接时,如果WHERE子句中包含要求被驱动表的某个列等于NULL值的搜索条件,而且那个列又是不允许存储NULL值的,那么在该表的执行计划的Extra列就会提示Not exists额外信息,比如这样:\nmysql\u0026gt; EXPLAIN SELECT * FROM s1 LEFT JOIN s2 ON s1.key1 = s2.key1 WHERE s2.id IS NULL; +----+-------------+-------+------------+------+---------------+----------+---------+-------------------+------+----------+-------------------------+ | id | select_type | table | partitions | type | possible_keys | key | key_len | ref | rows | filtered | Extra | +----+-------------+-------+------------+------+---------------+----------+---------+-------------------+------+----------+-------------------------+ | 1 | SIMPLE | s1 | NULL | ALL | NULL | NULL | NULL | NULL | 9688 | 100.00 | NULL | | 1 | SIMPLE | s2 | NULL | ref | idx_key1 | idx_key1 | 303 | xiaohaizi.s1.key1 | 1 | 10.00 | Using where; Not exists | +----+-------------+-------+------------+------+---------------+----------+---------+-------------------+------+----------+-------------------------+ 2 rows in set, 1 warning (0.00 sec) 上述查询中s1表是驱动表,s2表是被驱动表,s2.id列是不允许存储NULL值的,而WHERE子句中又包含s2.id IS NULL的搜索条件,这意味着必定是驱动表的记录在被驱动表中找不到匹配ON子句条件的记录才会把该驱动表的记录加入到最终的结果集,所以对于某条驱动表中的记录来说,如果能在被驱动表中找到1条符合ON子句条件的记录,那么该驱动表的记录就不会被加入到最终的结果集,也就是说我们没有必要到被驱动表中找到全部符合ON子句条件的记录,这样可以稍微节省一点性能。 小贴士:右(外)连接可以被转换为左(外)连接,所以就不提右(外)连接的情况了。\nUsing intersect(...)、Using union(...)和Using sort_union(...)\n如果执行计划的Extra列出现了Using intersect(...)提示,说明准备使用Intersect索引合并的方式执行查询,括号中的...表示需要进行索引合并的索引名称;如果出现了Using union(...)提示,说明准备使用Union索引合并的方式执行查询;出现了Using sort_union(...)提示,说明准备使用Sort-Union索引合并的方式执行查询。比如这个查询的执行计划:\nmysql\u0026gt; EXPLAIN SELECT * FROM s1 WHERE key1 = 'a' AND key3 = 'a'; +----+-------------+-------+------------+-------------+-------------------+-------------------+---------+------+------+----------+-------------------------------------------------+ | id | select_type | table | partitions | type | possible_keys | key | key_len | ref | rows | filtered | Extra | +----+-------------+-------+------------+-------------+-------------------+-------------------+---------+------+------+----------+-------------------------------------------------+ | 1 | SIMPLE | s1 | NULL | index_merge | idx_key1,idx_key3 | idx_key3,idx_key1 | 303,303 | NULL | 1 | 100.00 | Using intersect(idx_key3,idx_key1); Using where | +----+-------------+-------+------------+-------------+-------------------+-------------------+---------+------+------+----------+-------------------------------------------------+ 1 row in set, 1 warning (0.01 sec) 其中Extra列就显示了Using intersect(idx_key3,idx_key1),表明MySQL即将使用idx_key3和idx_key1这两个索引进行Intersect索引合并的方式执行查询。\n小贴士:剩下两种类型的索引合并的Extra列信息就不一一举例子了,自己写个查询看看呗~\nZero limit\n当我们的LIMIT子句的参数为0时,表示压根儿不打算从表中读出任何记录,将会提示该额外信息,比如这样:\nmysql\u0026gt; EXPLAIN SELECT * FROM s1 LIMIT 0; +----+-------------+-------+------------+------+---------------+------+---------+------+------+----------+------------+ | id | select_type | table | partitions | type | possible_keys | key | key_len | ref | rows | filtered | Extra | +----+-------------+-------+------------+------+---------------+------+---------+------+------+----------+------------+ | 1 | SIMPLE | NULL | NULL | NULL | NULL | NULL | NULL | NULL | NULL | NULL | Zero limit | +----+-------------+-------+------------+------+---------------+------+---------+------+------+----------+------------+ 1 row in set, 1 warning (0.00 sec)\nUsing filesort\n有一些情况下对结果集中的记录进行排序是可以使用到索引的,比如下面这个查询: mysql\u0026gt; EXPLAIN SELECT * FROM s1 ORDER BY key1 LIMIT 10; +----+-------------+-------+------------+-------+---------------+----------+---------+------+------+----------+-------+ | id | select_type | table | partitions | type | possible_keys | key | key_len | ref | rows | filtered | Extra | +----+-------------+-------+------------+-------+---------------+----------+---------+------+------+----------+-------+ | 1 | SIMPLE | s1 | NULL | index | NULL | idx_key1 | 303 | NULL | 10 | 100.00 | NULL | +----+-------------+-------+------------+-------+---------------+----------+---------+------+------+----------+-------+ 1 row in set, 1 warning (0.03 sec) 这个查询语句可以利用idx_key1索引直接取出key1列的10条记录,然后再进行回表操作就好了。但是很多情况下排序操作无法使用到索引,只能在内存中(记录较少的时候)或者磁盘中(记录较多的时候)进行排序,设计MySQL的大佬把这种在内存中或者磁盘上进行排序的方式统称为文件排序(英文名:filesort)。如果某个查询需要使用文件排序的方式执行查询,就会在执行计划的Extra列中显示Using filesort提示,比如这样:\nmysql\u0026gt; EXPLAIN SELECT * FROM s1 ORDER BY common_field LIMIT 10; +----+-------------+-------+------------+------+---------------+------+---------+------+------+----------+----------------+ | id | select_type | table | partitions | type | possible_keys | key | key_len | ref | rows | filtered | Extra | +----+-------------+-------+------------+------+---------------+------+---------+------+------+----------+----------------+ | 1 | SIMPLE | s1 | NULL | ALL | NULL | NULL | NULL | NULL | 9688 | 100.00 | Using filesort | +----+-------------+-------+------------+------+---------------+------+---------+------+------+----------+----------------+ 1 row in set, 1 warning (0.00 sec) 需要注意的是,如果查询中需要使用filesort的方式进行排序的记录非常多,那么这个过程是很耗费性能的,我们最好想办法将使用文件排序的执行方式改为使用索引进行排序。\nUsing temporary\n在许多查询的执行过程中,MySQL可能会借助临时表来完成一些功能,比如去重、排序之类的,比如我们在执行许多包含DISTINCT、GROUP BY、UNION等子句的查询过程中,如果不能有效利用索引来完成查询,MySQL很有可能寻求通过建立内部的临时表来执行查询。如果查询中使用到了内部的临时表,在执行计划的Extra列将会显示Using temporary提示,比方说这样:\nmysql\u0026gt; EXPLAIN SELECT DISTINCT common_field FROM s1; +----+-------------+-------+------------+------+---------------+------+---------+------+------+----------+-----------------+ | id | select_type | table | partitions | type | possible_keys | key | key_len | ref | rows | filtered | Extra | +----+-------------+-------+------------+------+---------------+------+---------+------+------+----------+-----------------+ | 1 | SIMPLE | s1 | NULL | ALL | NULL | NULL | NULL | NULL | 9688 | 100.00 | Using temporary | +----+-------------+-------+------------+------+---------------+------+---------+------+------+----------+-----------------+ 1 row in set, 1 warning (0.00 sec) 再比如: mysql\u0026gt; EXPLAIN SELECT common_field, COUNT(*) AS amount FROM s1 GROUP BY common_field; +----+-------------+-------+------------+------+---------------+------+---------+------+------+----------+---------------------------------+ | id | select_type | table | partitions | type | possible_keys | key | key_len | ref | rows | filtered | Extra | +----+-------------+-------+------------+------+---------------+------+---------+------+------+----------+---------------------------------+ | 1 | SIMPLE | s1 | NULL | ALL | NULL | NULL | NULL | NULL | 9688 | 100.00 | Using temporary; Using filesort | +----+-------------+-------+------------+------+---------------+------+---------+------+------+----------+---------------------------------+ 1 row in set, 1 warning (0.00 sec) 不知道大家注意到没有,上述执行计划的Extra列不仅仅包含Using temporary提示,还包含Using filesort提示,可是我们的查询语句中明明没有写ORDER BY子句呀?这是因为MySQL会在包含GROUP BY子句的查询中默认添加上ORDER BY子句,也就是说上述查询其实和下面这个查询等价:\nEXPLAIN SELECT common_field, COUNT(*) AS amount FROM s1 GROUP BY common_field ORDER BY common_field; 如果我们并不想为包含GROUP BY子句的查询进行排序,需要我们显式的写上ORDER BY NULL,就像这样: mysql\u0026gt; EXPLAIN SELECT common_field, COUNT(*) AS amount FROM s1 GROUP BY common_field ORDER BY NULL; +----+-------------+-------+------------+------+---------------+------+---------+------+------+----------+-----------------+ | id | select_type | table | partitions | type | possible_keys | key | key_len | ref | rows | filtered | Extra | +----+-------------+-------+------------+------+---------------+------+---------+------+------+----------+-----------------+ | 1 | SIMPLE | s1 | NULL | ALL | NULL | NULL | NULL | NULL | 9688 | 100.00 | Using temporary | +----+-------------+-------+------------+------+---------------+------+---------+------+------+----------+-----------------+ 1 row in set, 1 warning (0.00 sec) 这回执行计划中就没有Using filesort的提示了,也就意味着执行查询时可以省去对记录进行文件排序的成本了。\n另外,执行计划中出现Using temporary并不是一个好的征兆,因为建立与维护临时表要付出很大成本的,所以我们最好能使用索引来替代掉使用临时表,比方说下面这个包含GROUP BY子句的查询就不需要使用临时表: mysql\u0026gt; EXPLAIN SELECT key1, COUNT(*) AS amount FROM s1 GROUP BY key1; +----+-------------+-------+------------+-------+---------------+----------+---------+------+------+----------+-------------+ | id | select_type | table | partitions | type | possible_keys | key | key_len | ref | rows | filtered | Extra | +----+-------------+-------+------------+-------+---------------+----------+---------+------+------+----------+-------------+ | 1 | SIMPLE | s1 | NULL | index | idx_key1 | idx_key1 | 303 | NULL | 9688 | 100.00 | Using index | +----+-------------+-------+------------+-------+---------------+----------+---------+------+------+----------+-------------+ 1 row in set, 1 warning (0.00 sec) 从Extra的Using index的提示里我们可以看出,上述查询只需要扫描idx_key1索引就可以搞定了,不再需要临时表了。\nStart temporary, End temporary\n我们前面介绍子查询的时候说过,查询优化器会优先尝试将IN子查询转换成semi-join,而semi-join又有好多种执行策略,当执行策略为DuplicateWeedout时,也就是通过建立临时表来实现为外层查询中的记录进行去重操作时,驱动表查询执行计划的Extra列将显示Start temporary提示,被驱动表查询执行计划的Extra列将显示End temporary提示,就是这样:\nmysql\u0026gt; EXPLAIN SELECT * FROM s1 WHERE key1 IN (SELECT key3 FROM s2 WHERE common_field = 'a'); +----+-------------+-------+------------+------+---------------+----------+---------+-------------------+------+----------+------------------------------+ | id | select_type | table | partitions | type | possible_keys | key | key_len | ref | rows | filtered | Extra | +----+-------------+-------+------------+------+---------------+----------+---------+-------------------+------+----------+------------------------------+ | 1 | SIMPLE | s2 | NULL | ALL | idx_key3 | NULL | NULL | NULL | 9954 | 10.00 | Using where; Start temporary | | 1 | SIMPLE | s1 | NULL | ref | idx_key1 | idx_key1 | 303 | xiaohaizi.s2.key3 | 1 | 100.00 | End temporary | +----+-------------+-------+------------+------+---------------+----------+---------+-------------------+------+----------+------------------------------+ 2 rows in set, 1 warning (0.00 sec)\nLooseScan\n在将In子查询转为semi-join时,如果采用的是LooseScan执行策略,则在驱动表执行计划的Extra列就是显示LooseScan提示,比如这样:\nmysql\u0026gt; EXPLAIN SELECT * FROM s1 WHERE key3 IN (SELECT key1 FROM s2 WHERE key1 \u0026gt; 'z'); +----+-------------+-------+------------+-------+---------------+----------+---------+-------------------+------+----------+-------------------------------------+ | id | select_type | table | partitions | type | possible_keys | key | key_len | ref | rows | filtered | Extra | +----+-------------+-------+------------+-------+---------------+----------+---------+-------------------+------+----------+-------------------------------------+ | 1 | SIMPLE | s2 | NULL | range | idx_key1 | idx_key1 | 303 | NULL | 270 | 100.00 | Using where; Using index; LooseScan | | 1 | SIMPLE | s1 | NULL | ref | idx_key3 | idx_key3 | 303 | xiaohaizi.s2.key1 | 1 | 100.00 | NULL | +----+-------------+-------+------------+-------+---------------+----------+---------+-------------------+------+----------+-------------------------------------+ 2 rows in set, 1 warning (0.01 sec)\nFirstMatch(tbl_name)\n在将In子查询转为semi-join时,如果采用的是FirstMatch执行策略,则在被驱动表执行计划的Extra列就是显示FirstMatch(tbl_name)提示,比如这样: mysql\u0026gt; EXPLAIN SELECT * FROM s1 WHERE common_field IN (SELECT key1 FROM s2 where s1.key3 = s2.key3); +----+-------------+-------+------------+------+-------------------+----------+---------+-------------------+------+----------+-----------------------------+ | id | select_type | table | partitions | type | possible_keys | key | key_len | ref | rows | filtered | Extra | +----+-------------+-------+------------+------+-------------------+----------+---------+-------------------+------+----------+-----------------------------+ | 1 | SIMPLE | s1 | NULL | ALL | idx_key3 | NULL | NULL | NULL | 9688 | 100.00 | Using where | | 1 | SIMPLE | s2 | NULL | ref | idx_key1,idx_key3 | idx_key3 | 303 | xiaohaizi.s1.key3 | 1 | 4.87 | Using where; FirstMatch(s1) | +----+-------------+-------+------------+------+-------------------+----------+---------+-------------------+------+----------+-----------------------------+ 2 rows in set, 2 warnings (0.00 sec)\nJson格式的执行计划 # 我们上面介绍的EXPLAIN语句输出中缺少了一个衡量执行计划好坏的重要属性 —— 成本。不过设计MySQL的大佬贴心的为我们提供了一种查看某个执行计划花费的成本的方式:\n在EXPLAIN单词和真正的查询语句中间加上FORMAT=JSON。 这样我们就可以得到一个json格式的执行计划,里边儿包含该计划花费的成本,比如这样: ``` mysql\u0026gt; EXPLAIN FORMAT=JSON SELECT * FROM s1 INNER JOIN s2 ON s1.key1 = s2.key2 WHERE s1.common_field = \u0026lsquo;a\u0026rsquo;\\G *************************** 1. row ***************************\nEXPLAIN: { \u0026ldquo;query_block\u0026rdquo;: { \u0026ldquo;select_id\u0026rdquo;: 1, # 整个查询语句只有1个SELECT关键字,该关键字对应的id号为1 \u0026ldquo;cost_info\u0026rdquo;: { \u0026ldquo;query_cost\u0026rdquo;: \u0026ldquo;3197.16\u0026rdquo; # 整个查询的执行成本预计为3197.16 }, \u0026ldquo;nested_loop\u0026rdquo;: [ # 几个表之间采用嵌套循环连接算法执行\n# 以下是参与嵌套循环连接算法的各个表的信息 { \u0026quot;table\u0026quot;: { \u0026quot;table_name\u0026quot;: \u0026quot;s1\u0026quot;, # s1表是驱动表 \u0026quot;access_type\u0026quot;: \u0026quot;ALL\u0026quot;, # 访问方法为ALL,意味着使用全表扫描访问 \u0026quot;possible_keys\u0026quot;: [ # 可能使用的索引 \u0026quot;idx_key1\u0026quot; ], \u0026quot;rows_examined_per_scan\u0026quot;: 9688, # 查询一次s1表大致需要扫描9688条记录 \u0026quot;rows_produced_per_join\u0026quot;: 968, # 驱动表s1的扇出是968 \u0026quot;filtered\u0026quot;: \u0026quot;10.00\u0026quot;, # condition filtering代表的百分比 \u0026quot;cost_info\u0026quot;: { \u0026quot;read_cost\u0026quot;: \u0026quot;1840.84\u0026quot;, # 稍后解释 \u0026quot;eval_cost\u0026quot;: \u0026quot;193.76\u0026quot;, # 稍后解释 \u0026quot;prefix_cost\u0026quot;: \u0026quot;2034.60\u0026quot;, # 单次查询s1表总共的成本 \u0026quot;data_read_per_join\u0026quot;: \u0026quot;1M\u0026quot; # 读取的数据量 }, \u0026quot;used_columns\u0026quot;: [ # 执行查询中涉及到的列 \u0026quot;id\u0026quot;, \u0026quot;key1\u0026quot;, \u0026quot;key2\u0026quot;, \u0026quot;key3\u0026quot;, \u0026quot;key_part1\u0026quot;, \u0026quot;key_part2\u0026quot;, \u0026quot;key_part3\u0026quot;, \u0026quot;common_field\u0026quot; ], # 对s1表访问时针对单表查询的条件 \u0026quot;attached_condition\u0026quot;: \u0026quot;((`xiaohaizi`.`s1`.`common_field` = 'a') and (`xiaohaizi`.`s1`.`key1` is not null))\u0026quot; } }, { \u0026quot;table\u0026quot;: { \u0026quot;table_name\u0026quot;: \u0026quot;s2\u0026quot;, # s2表是被驱动表 \u0026quot;access_type\u0026quot;: \u0026quot;ref\u0026quot;, # 访问方法为ref,意味着使用索引等值匹配的方式访问 \u0026quot;possible_keys\u0026quot;: [ # 可能使用的索引 \u0026quot;idx_key2\u0026quot; ], \u0026quot;key\u0026quot;: \u0026quot;idx_key2\u0026quot;, # 实际使用的索引 \u0026quot;used_key_parts\u0026quot;: [ # 使用到的索引列 \u0026quot;key2\u0026quot; ], \u0026quot;key_length\u0026quot;: \u0026quot;5\u0026quot;, # key_len \u0026quot;ref\u0026quot;: [ # 与key2列进行等值匹配的对象 \u0026quot;xiaohaizi.s1.key1\u0026quot; ], \u0026quot;rows_examined_per_scan\u0026quot;: 1, # 查询一次s2表大致需要扫描1条记录 \u0026quot;rows_produced_per_join\u0026quot;: 968, # 被驱动表s2的扇出是968(由于后边没有多余的表进行连接,所以这个值也没什么用) \u0026quot;filtered\u0026quot;: \u0026quot;100.00\u0026quot;, # condition filtering代表的百分比 # s2表使用索引进行查询的搜索条件 \u0026quot;index_condition\u0026quot;: \u0026quot;(`xiaohaizi`.`s1`.`key1` = `xiaohaizi`.`s2`.`key2`)\u0026quot;, \u0026quot;cost_info\u0026quot;: { \u0026quot;read_cost\u0026quot;: \u0026quot;968.80\u0026quot;, # 稍后解释 \u0026quot;eval_cost\u0026quot;: \u0026quot;193.76\u0026quot;, # 稍后解释 \u0026quot;prefix_cost\u0026quot;: \u0026quot;3197.16\u0026quot;, # 单次查询s1、多次查询s2表总共的成本 \u0026quot;data_read_per_join\u0026quot;: \u0026quot;1M\u0026quot; # 读取的数据量 }, \u0026quot;used_columns\u0026quot;: [ # 执行查询中涉及到的列 \u0026quot;id\u0026quot;, \u0026quot;key1\u0026quot;, \u0026quot;key2\u0026quot;, \u0026quot;key3\u0026quot;, \u0026quot;key_part1\u0026quot;, \u0026quot;key_part2\u0026quot;, \u0026quot;key_part3\u0026quot;, \u0026quot;common_field\u0026quot; ] } } ] } } 1 row in set, 2 warnings (0.00 sec) 我们使用#后边跟随注释的形式为大家解释了EXPLAIN FORMAT=JSON语句的输出内容,但是大家可能有疑问\u0026ldquo;cost_info\u0026rdquo;里边的成本看着怪怪的,它们是怎么计算出来的?先看s1表的\u0026ldquo;cost_info\u0026rdquo;部分: \u0026ldquo;cost_info\u0026rdquo;: { \u0026ldquo;read_cost\u0026rdquo;: \u0026ldquo;1840.84\u0026rdquo;, \u0026ldquo;eval_cost\u0026rdquo;: \u0026ldquo;193.76\u0026rdquo;, \u0026ldquo;prefix_cost\u0026rdquo;: \u0026ldquo;2034.60\u0026rdquo;, \u0026ldquo;data_read_per_join\u0026rdquo;: \u0026ldquo;1M\u0026rdquo; } ```\nread_cost是由下面这两部分组成的:\n+ `IO`成本 + 检测`rows × (1 - filter)`条记录的`CPU`成本 小贴士:rows和filter都是我们前面介绍执行计划的输出列,在JSON格式的执行计划中,rows相当于rows_examined_per_scan,filtered名称不变。\neval_cost是这样计算的:\n检测 rows × filter条记录的成本。\nprefix_cost就是单独查询s1表的成本,也就是:\nread_cost + eval_cost\ndata_read_per_join表示在此次查询中需要读取的数据量,我们就不多介绍这个了。\n小贴士:大家其实没必要关注MySQL为什么使用这么古怪的方式计算出read_cost和eval_cost,关注prefix_cost是查询s1表的成本就好了。 对于s2表的\u0026quot;cost_info\u0026quot;部分是这样的: \u0026quot;cost_info\u0026quot;: { \u0026quot;read_cost\u0026quot;: \u0026quot;968.80\u0026quot;, \u0026quot;eval_cost\u0026quot;: \u0026quot;193.76\u0026quot;, \u0026quot;prefix_cost\u0026quot;: \u0026quot;3197.16\u0026quot;, \u0026quot;data_read_per_join\u0026quot;: \u0026quot;1M\u0026quot; } 由于s2表是被驱动表,所以可能被读取多次,这里的read_cost和eval_cost是访问多次s2表后累加起来的值,大家主要关注里边儿的prefix_cost的值代表的是整个连接查询预计的成本,也就是单次查询s1表和多次查询s2表后的成本的和,也就是: 968.80 + 193.76 + 2034.60 = 3197.16\nExtented EXPLAIN # 最后,设计MySQL的大佬还为我们留了个彩蛋,在我们使用EXPLAIN语句查看了某个查询的执行计划后,紧接着还可以使用SHOW WARNINGS语句查看与这个查询的执行计划有关的一些扩展信息,比如这样: ``` mysql\u0026gt; EXPLAIN SELECT s1.key1, s2.key1 FROM s1 LEFT JOIN s2 ON s1.key1 = s2.key1 WHERE s2.common_field IS NOT NULL; +\u0026mdash;-+\u0026mdash;\u0026mdash;\u0026mdash;\u0026mdash;-+\u0026mdash;\u0026mdash;-+\u0026mdash;\u0026mdash;\u0026mdash;\u0026mdash;+\u0026mdash;\u0026mdash;+\u0026mdash;\u0026mdash;\u0026mdash;\u0026mdash;\u0026mdash;+\u0026mdash;\u0026mdash;\u0026mdash;-+\u0026mdash;\u0026mdash;\u0026mdash;+\u0026mdash;\u0026mdash;\u0026mdash;\u0026mdash;\u0026mdash;\u0026mdash;-+\u0026mdash;\u0026mdash;+\u0026mdash;\u0026mdash;\u0026mdash;-+\u0026mdash;\u0026mdash;\u0026mdash;\u0026mdash;-+ | id | select_type | table | partitions | type | possible_keys | key | key_len | ref | rows | filtered | Extra | +\u0026mdash;-+\u0026mdash;\u0026mdash;\u0026mdash;\u0026mdash;-+\u0026mdash;\u0026mdash;-+\u0026mdash;\u0026mdash;\u0026mdash;\u0026mdash;+\u0026mdash;\u0026mdash;+\u0026mdash;\u0026mdash;\u0026mdash;\u0026mdash;\u0026mdash;+\u0026mdash;\u0026mdash;\u0026mdash;-+\u0026mdash;\u0026mdash;\u0026mdash;+\u0026mdash;\u0026mdash;\u0026mdash;\u0026mdash;\u0026mdash;\u0026mdash;-+\u0026mdash;\u0026mdash;+\u0026mdash;\u0026mdash;\u0026mdash;-+\u0026mdash;\u0026mdash;\u0026mdash;\u0026mdash;-+ | 1 | SIMPLE | s2 | NULL | ALL | idx_key1 | NULL | NULL | NULL | 9954 | 90.00 | Using where | | 1 | SIMPLE | s1 | NULL | ref | idx_key1 | idx_key1 | 303 | xiaohaizi.s2.key1 | 1 | 100.00 | Using index | +\u0026mdash;-+\u0026mdash;\u0026mdash;\u0026mdash;\u0026mdash;-+\u0026mdash;\u0026mdash;-+\u0026mdash;\u0026mdash;\u0026mdash;\u0026mdash;+\u0026mdash;\u0026mdash;+\u0026mdash;\u0026mdash;\u0026mdash;\u0026mdash;\u0026mdash;+\u0026mdash;\u0026mdash;\u0026mdash;-+\u0026mdash;\u0026mdash;\u0026mdash;+\u0026mdash;\u0026mdash;\u0026mdash;\u0026mdash;\u0026mdash;\u0026mdash;-+\u0026mdash;\u0026mdash;+\u0026mdash;\u0026mdash;\u0026mdash;-+\u0026mdash;\u0026mdash;\u0026mdash;\u0026mdash;-+ 2 rows in set, 1 warning (0.00 sec)\nmysql\u0026gt; SHOW WARNINGS\\G *************************** 1. row ************************** Level: Note Code: 1003 Message: / select#1 */ select xiaohaizi.s1.key1 AS key1,xiaohaizi.s2.key1 AS key1 from xiaohaizi.s1 join xiaohaizi.s2 where ( ‘xiaohaizi‘.‘s1‘.‘key1‘=‘xiaohaizi‘.‘s2‘.‘key1‘)and(‘xiaohaizi‘.‘s2‘.‘commonfield‘isnotnull)`xiaohaizi`.`s1`.`key1` = `xiaohaizi`.`s2`.`key1`) and (`xiaohaizi`.`s2`.`common_field` is not null) 1 row in set (0.00 sec) ``` 大家可以看到SHOW WARNINGS展示出来的信息有三个字段,分别是Level、Code、Message。我们最常见的就是Code为1003的信息,当Code值为1003时,Message字段展示的信息类似于查询优化器将我们的查询语句重写后的语句。比如我们上面的查询本来是一个左(外)连接查询,但是有一个s2.common_field IS NOT NULL的条件,着就会导致查询优化器把左(外)连接查询优化为内连接查询,从SHOW WARNINGS的Message字段也可以看出来,原本的LEFT JOIN已经变成了JOIN。\n但是大家一定要注意,我们说Message字段展示的信息类似于查询优化器将我们的查询语句重写后的语句,并不是等价于,也就是说Message字段展示的信息并不是标准的查询语句,在很多情况下并不能直接拿到黑框框中运行,它只能作为帮助我们理解查MySQL将如何执行查询语句的一个参考依据而已。\n"},{"id":18,"href":"/zh/docs/technology/MySQL/MySQL%E6%98%AF%E6%80%8E%E6%A0%B7%E8%BF%90%E8%A1%8C%E7%9A%84/%E7%AC%AC15%E7%AB%A0_%E6%9F%A5%E8%AF%A2%E4%BC%98%E5%8C%96%E7%9A%84%E7%99%BE%E7%A7%91%E5%85%A8%E4%B9%A6-Explain%E8%AF%A6%E8%A7%A3%E4%B8%8A/","title":"第15章_查询优化的百科全书-Explain详解(上)","section":"My Sql是怎样运行的","content":"第15章 查询优化的百科全书-Explain详解(上)\n一条查询语句在经过MySQL查询优化器的各种基于成本和规则的优化会后生成一个所谓的执行计划,这个执行计划展示了接下来具体执行查询的方式,比如多表连接的顺序是什么,对于每个表采用什么访问方法来具体执行查询等等。设计MySQL的大佬贴心的为我们提供了EXPLAIN语句来帮助我们查看某个查询语句的具体执行计划,本章的内容就是为了帮助大家看懂EXPLAIN语句的各个输出项都是干嘛使的,从而可以有针对性的提升我们查询语句的性能。\n如果我们想看看某个查询的执行计划的话,可以在具体的查询语句前面加一个EXPLAIN,就像这样:\nmysql\u0026gt; EXPLAIN SELECT 1; +----+-------------+-------+------------+------+---------------+------+---------+------+------+----------+----------------+ | id | select_type | table | partitions | type | possible_keys | key | key_len | ref | rows | filtered | Extra | +----+-------------+-------+------------+------+---------------+------+---------+------+------+----------+----------------+ | 1 | SIMPLE | NULL | NULL | NULL | NULL | NULL | NULL | NULL | NULL | NULL | No tables used | +----+-------------+-------+------------+------+---------------+------+---------+------+------+----------+----------------+ 1 row in set, 1 warning (0.01 sec) 然后这输出的一大坨东西就是所谓的执行计划,我的任务就是带领大家看懂这一大坨东西里边的每个列都是干什么用的,以及在这个执行计划的辅助下,我们应该怎样改进自己的查询语句以使查询执行起来更高效。其实除了以SELECT开头的查询语句,其余的DELETE、INSERT、REPLACE以及UPDATE语句前面都可以加上EXPLAIN这个词儿,用来查看这些语句的执行计划,不过我们这里对SELECT语句更感兴趣,所以后边只会以SELECT语句为例来描述EXPLAIN语句的用法。为了让大家先有一个感性的认识,我们把EXPLAIN语句输出的各个列的作用先大致罗列一下: 列名 描述 id 在一个大的查询语句中每个SELECT关键字都对应一个唯一的id select_type SELECT关键字对应的那个查询的类型 table 表名 partitions 匹配的分区信息 type 针对单表的访问方法 possible_keys 可能用到的索引 key 实际上使用的索引 key_len 实际使用到的索引长度 ref 当使用索引列等值查询时,与索引列进行等值匹配的对象信息 rows 预估的需要读取的记录条数 filtered 某个表经过搜索条件过滤后剩余记录条数的百分比 Extra 一些额外的信息 需要注意的是,大家如果看不懂上面输出列含义,那是正常的,千万不要纠结~。我在这里把它们都列出来只是为了描述一个轮廓,让大家有一个大致的印象,下面会细细道来,等会儿说完了不信你不会~ 为了故事的顺利发展,我们还是要请出我们前面已经用了n遍的single_table表,为了防止大家忘了,再把它的结构描述一遍: CREATE TABLE single_table ( id INT NOT NULL AUTO_INCREMENT, key1 VARCHAR(100), key2 INT, key3 VARCHAR(100), key_part1 VARCHAR(100), key_part2 VARCHAR(100), key_part3 VARCHAR(100), common_field VARCHAR(100), PRIMARY KEY (id), KEY idx_key1 (key1), UNIQUE KEY idx_key2 (key2), KEY idx_key3 (key3), KEY idx_key_part(key_part1, key_part2, key_part3) ) Engine=InnoDB CHARSET=utf8; 我们仍然假设有两个和single_table表构造一模一样的s1、s2表,而且这两个表里边儿有10000条记录,除id列外其余的列都插入随机值。为了让大家有比较好的阅读体验,我们下面并不准备严格按照EXPLAIN输出列的顺序来介绍这些列分别是干嘛的,大家注意一下就好了。\n执行计划输出中各列详解 # table # 不论我们的查询语句有多复杂,里边儿包含了多少个表,到最后也是需要对每个表进行单表访问的,所以设计MySQL的大佬规定EXPLAIN语句输出的每条记录都对应着某个单表的访问方法,该条记录的table列代表着该表的表名。所以我们看一条比较简单的查询语句: mysql\u0026gt; EXPLAIN SELECT * FROM s1; +----+-------------+-------+------------+------+---------------+------+---------+------+------+----------+-------+ | id | select_type | table | partitions | type | possible_keys | key | key_len | ref | rows | filtered | Extra | +----+-------------+-------+------------+------+---------------+------+---------+------+------+----------+-------+ | 1 | SIMPLE | s1 | NULL | ALL | NULL | NULL | NULL | NULL | 9688 | 100.00 | NULL | +----+-------------+-------+------------+------+---------------+------+---------+------+------+----------+-------+ 1 row in set, 1 warning (0.00 sec) 这个查询语句只涉及对s1表的单表查询,所以EXPLAIN输出中只有一条记录,其中的table列的值是s1,表明这条记录是用来说明对s1表的单表访问方法的。\n下面我们看一下一个连接查询的执行计划:\nmysql\u0026gt; EXPLAIN SELECT * FROM s1 INNER JOIN s2; +----+-------------+-------+------------+------+---------------+------+---------+------+------+----------+---------------------------------------+ | id | select_type | table | partitions | type | possible_keys | key | key_len | ref | rows | filtered | Extra | +----+-------------+-------+------------+------+---------------+------+---------+------+------+----------+---------------------------------------+ | 1 | SIMPLE | s1 | NULL | ALL | NULL | NULL | NULL | NULL | 9688 | 100.00 | NULL | | 1 | SIMPLE | s2 | NULL | ALL | NULL | NULL | NULL | NULL | 9954 | 100.00 | Using join buffer (Block Nested Loop) | +----+-------------+-------+------------+------+---------------+------+---------+------+------+----------+---------------------------------------+ 2 rows in set, 1 warning (0.01 sec) 可以看到这个连接查询的执行计划中有两条记录,这两条记录的table列分别是s1和s2,这两条记录用来分别说明对s1表和s2表的访问方法是什么。\nid # 我们知道我们写的查询语句一般都以SELECT关键字开头,比较简单的查询语句里只有一个SELECT关键字,比如下面这个查询语句: SELECT * FROM s1 WHERE key1 = 'a'; 稍微复杂一点的连接查询中也只有一个SELECT关键字,比如: SELECT * FROM s1 INNER JOIN s2 ON s1.key1 = s2.key1 WHERE s1.common_field = 'a'; 但是下面两种情况下在一条查询语句中会出现多个SELECT关键字:\n查询中包含子查询的情况\n比如下面这个查询语句中就包含2个SELECT关键字: SELECT * FROM s1 WHERE key1 IN (SELECT * FROM s2);\n查询中包含UNION语句的情况\n比如下面这个查询语句中也包含2个SELECT关键字: SELECT * FROM s1 UNION SELECT * FROM s2;\n查询语句中每出现一个SELECT关键字,设计MySQL的大佬就会为它分配一个唯一的id值。这个id值就是EXPLAIN语句的第一个列,比如下面这个查询中只有一个SELECT关键字,所以EXPLAIN的结果中也就只有一条id列为1的记录:\nmysql\u0026gt; EXPLAIN SELECT * FROM s1 WHERE key1 = 'a'; +----+-------------+-------+------------+------+---------------+----------+---------+-------+------+----------+-------+ | id | select_type | table | partitions | type | possible_keys | key | key_len | ref | rows | filtered | Extra | +----+-------------+-------+------------+------+---------------+----------+---------+-------+------+----------+-------+ | 1 | SIMPLE | s1 | NULL | ref | idx_key1 | idx_key1 | 303 | const | 8 | 100.00 | NULL | +----+-------------+-------+------------+------+---------------+----------+---------+-------+------+----------+-------+ 1 row in set, 1 warning (0.03 sec) 对于连接查询来说,一个SELECT关键字后边的FROM子句中可以跟随多个表,所以在连接查询的执行计划中,每个表都会对应一条记录,但是这些记录的id值都是相同的,比如: mysql\u0026gt; EXPLAIN SELECT * FROM s1 INNER JOIN s2; +----+-------------+-------+------------+------+---------------+------+---------+------+------+----------+---------------------------------------+ | id | select_type | table | partitions | type | possible_keys | key | key_len | ref | rows | filtered | Extra | +----+-------------+-------+------------+------+---------------+------+---------+------+------+----------+---------------------------------------+ | 1 | SIMPLE | s1 | NULL | ALL | NULL | NULL | NULL | NULL | 9688 | 100.00 | NULL | | 1 | SIMPLE | s2 | NULL | ALL | NULL | NULL | NULL | NULL | 9954 | 100.00 | Using join buffer (Block Nested Loop) | +----+-------------+-------+------------+------+---------------+------+---------+------+------+----------+---------------------------------------+ 2 rows in set, 1 warning (0.01 sec) 可以看到,上述连接查询中参与连接的s1和s2表分别对应一条记录,但是这两条记录对应的id值都是1。这里需要大家记住的是,在连接查询的执行计划中,每个表都会对应一条记录,这些记录的id列的值是相同的,出现在前面的表表示驱动表,出现在后边的表表示被驱动表。所以从上面的EXPLAIN输出中我们可以看出,查询优化器准备让s1表作为驱动表,让s2表作为被驱动表来执行查询。\n对于包含子查询的查询语句来说,就可能涉及多个SELECT关键字,所以在包含子查询的查询语句的执行计划中,每个SELECT关键字都会对应一个唯一的id值,比如这样: mysql\u0026gt; EXPLAIN SELECT * FROM s1 WHERE key1 IN (SELECT key1 FROM s2) OR key3 = 'a'; +----+-------------+-------+------------+-------+---------------+----------+---------+------+------+----------+-------------+ | id | select_type | table | partitions | type | possible_keys | key | key_len | ref | rows | filtered | Extra | +----+-------------+-------+------------+-------+---------------+----------+---------+------+------+----------+-------------+ | 1 | PRIMARY | s1 | NULL | ALL | idx_key3 | NULL | NULL | NULL | 9688 | 100.00 | Using where | | 2 | SUBQUERY | s2 | NULL | index | idx_key1 | idx_key1 | 303 | NULL | 9954 | 100.00 | Using index | +----+-------------+-------+------------+-------+---------------+----------+---------+------+------+----------+-------------+ 2 rows in set, 1 warning (0.02 sec) 从输出结果中我们可以看到,s1表在外层查询中,外层查询有一个独立的SELECT关键字,所以第一条记录的id值就是1,s2表在子查询中,子查询有一个独立的SELECT关键字,所以第二条记录的id值就是2。\n但是这里大家需要特别注意,查询优化器可能对涉及子查询的查询语句进行重写,从而转换为连接查询。所以如果我们想知道查询优化器对某个包含子查询的语句是否进行了重写,直接查看执行计划就好了,比如说: mysql\u0026gt; EXPLAIN SELECT * FROM s1 WHERE key1 IN (SELECT key3 FROM s2 WHERE common_field = 'a'); +----+-------------+-------+------------+------+---------------+----------+---------+-------------------+------+----------+------------------------------+ | id | select_type | table | partitions | type | possible_keys | key | key_len | ref | rows | filtered | Extra | +----+-------------+-------+------------+------+---------------+----------+---------+-------------------+------+----------+------------------------------+ | 1 | SIMPLE | s2 | NULL | ALL | idx_key3 | NULL | NULL | NULL | 9954 | 10.00 | Using where; Start temporary | | 1 | SIMPLE | s1 | NULL | ref | idx_key1 | idx_key1 | 303 | xiaohaizi.s2.key3 | 1 | 100.00 | End temporary | +----+-------------+-------+------------+------+---------------+----------+---------+-------------------+------+----------+------------------------------+ 2 rows in set, 1 warning (0.00 sec) 可以看到,虽然我们的查询语句是一个子查询,但是执行计划中s1和s2表对应的记录的id值全部是1,这就表明了查询优化器将子查询转换为了连接查询。\n对于包含UNION子句的查询语句来说,每个SELECT关键字对应一个id值也是没错的,不过还是有点儿特别的东西,比方说下面这个查询: mysql\u0026gt; EXPLAIN SELECT * FROM s1 UNION SELECT * FROM s2; +----+--------------+------------+------------+------+---------------+------+---------+------+------+----------+-----------------+ | id | select_type | table | partitions | type | possible_keys | key | key_len | ref | rows | filtered | Extra | +----+--------------+------------+------------+------+---------------+------+---------+------+------+----------+-----------------+ | 1 | PRIMARY | s1 | NULL | ALL | NULL | NULL | NULL | NULL | 9688 | 100.00 | NULL | | 2 | UNION | s2 | NULL | ALL | NULL | NULL | NULL | NULL | 9954 | 100.00 | NULL | | NULL | UNION RESULT | \u0026lt;union1,2\u0026gt; | NULL | ALL | NULL | NULL | NULL | NULL | NULL | NULL | Using temporary | +----+--------------+------------+------------+------+---------------+------+---------+------+------+----------+-----------------+ 3 rows in set, 1 warning (0.00 sec) 这个语句的执行计划的第三条记录是个什么鬼?为毛id值是NULL,而且table列长的也怪怪的?大家别忘了UNION子句是干嘛用的,它会把多个查询的结果集合并起来并对结果集中的记录进行去重,怎么去重呢?MySQL使用的是内部的临时表。正如上面的查询计划中所示,UNION子句是为了把id为1的查询和id为2的查询的结果集合并起来并去重,所以在内部创建了一个名为\u0026lt;union1, 2\u0026gt;的临时表(就是执行计划第三条记录的table列的名称),id为NULL表明这个临时表是为了合并两个查询的结果集而创建的。\n跟UNION对比起来,UNION ALL就不需要为最终的结果集进行去重,它只是单纯的把多个查询的结果集中的记录合并成一个并返回给用户,所以也就不需要使用临时表。所以在包含UNION ALL子句的查询的执行计划中,就没有那个id为NULL的记录,如下所示: mysql\u0026gt; EXPLAIN SELECT * FROM s1 UNION ALL SELECT * FROM s2; +----+-------------+-------+------------+------+---------------+------+---------+------+------+----------+-------+ | id | select_type | table | partitions | type | possible_keys | key | key_len | ref | rows | filtered | Extra | +----+-------------+-------+------------+------+---------------+------+---------+------+------+----------+-------+ | 1 | PRIMARY | s1 | NULL | ALL | NULL | NULL | NULL | NULL | 9688 | 100.00 | NULL | | 2 | UNION | s2 | NULL | ALL | NULL | NULL | NULL | NULL | 9954 | 100.00 | NULL | +----+-------------+-------+------------+------+---------------+------+---------+------+------+----------+-------+ 2 rows in set, 1 warning (0.01 sec)\nselect_type # 通过上面的内容我们知道,一条大的查询语句里边可以包含若干个SELECT关键字,每个SELECT关键字代表着一个小的查询语句,而每个SELECT关键字的FROM子句中都可以包含若干张表(这些表用来做连接查询),每一张表都对应着执行计划输出中的一条记录,对于在同一个SELECT关键字中的表来说,它们的id值是相同的。\n设计MySQL的大佬为每一个SELECT关键字代表的小查询都定义了一个称之为select_type的属性,意思是我们只要知道了某个小查询的select_type属性,就知道了这个小查询在整个大查询中扮演了一个什么角色,口说无凭,我们还是先来见识见识这个select_type都能取哪些值(为了精确起见,我们直接使用文档中的英文做简要描述,随后会进行详细解释的): 名称 描述 SIMPLE Simple SELECT (not using UNION or subqueries) PRIMARY Outermost SELECT UNION Second or later SELECT statement in a UNION UNION RESULT Result of a UNION SUBQUERY First SELECT in subquery DEPENDENT SUBQUERY First SELECT in subquery, dependent on outer query DEPENDENT UNION Second or later SELECT statement in a UNION, dependent on outer query DERIVED Derived table MATERIALIZED Materialized subquery UNCACHEABLE SUBQUERY A subquery for which the result cannot be cached and must be re-evaluated for each row of the outer query UNCACHEABLE UNION The second or later select in a UNION that belongs to an uncacheable subquery (see UNCACHEABLE SUBQUERY) 英文描述太简单,不知道说了什么?来详细看看里边儿的每个值都是干什么吃的:\nSIMPLE\n查询语句中不包含UNION或者子查询的查询都算作是SIMPLE类型,比方说下面这个单表查询的select_type的值就是SIMPLE:\nmysql\u0026gt; EXPLAIN SELECT * FROM s1; +----+-------------+-------+------------+------+---------------+------+---------+------+------+----------+-------+ | id | select_type | table | partitions | type | possible_keys | key | key_len | ref | rows | filtered | Extra | +----+-------------+-------+------------+------+---------------+------+---------+------+------+----------+-------+ | 1 | SIMPLE | s1 | NULL | ALL | NULL | NULL | NULL | NULL | 9688 | 100.00 | NULL | +----+-------------+-------+------------+------+---------------+------+---------+------+------+----------+-------+ 1 row in set, 1 warning (0.00 sec) 当然,连接查询也算是SIMPLE类型,比如: mysql\u0026gt; EXPLAIN SELECT * FROM s1 INNER JOIN s2; +----+-------------+-------+------------+------+---------------+------+---------+------+------+----------+---------------------------------------+ | id | select_type | table | partitions | type | possible_keys | key | key_len | ref | rows | filtered | Extra | +----+-------------+-------+------------+------+---------------+------+---------+------+------+----------+---------------------------------------+ | 1 | SIMPLE | s1 | NULL | ALL | NULL | NULL | NULL | NULL | 9688 | 100.00 | NULL | | 1 | SIMPLE | s2 | NULL | ALL | NULL | NULL | NULL | NULL | 9954 | 100.00 | Using join buffer (Block Nested Loop) | +----+-------------+-------+------------+------+---------------+------+---------+------+------+----------+---------------------------------------+ 2 rows in set, 1 warning (0.01 sec)\nPRIMARY\n对于包含UNION、UNION ALL或者子查询的大查询来说,它是由几个小查询组成的,其中最左边的那个查询的select_type值就是PRIMARY,比方说: mysql\u0026gt; EXPLAIN SELECT * FROM s1 UNION SELECT * FROM s2; +----+--------------+------------+------------+------+---------------+------+---------+------+------+----------+-----------------+ | id | select_type | table | partitions | type | possible_keys | key | key_len | ref | rows | filtered | Extra | +----+--------------+------------+------------+------+---------------+------+---------+------+------+----------+-----------------+ | 1 | PRIMARY | s1 | NULL | ALL | NULL | NULL | NULL | NULL | 9688 | 100.00 | NULL | | 2 | UNION | s2 | NULL | ALL | NULL | NULL | NULL | NULL | 9954 | 100.00 | NULL | | NULL | UNION RESULT | \u0026lt;union1,2\u0026gt; | NULL | ALL | NULL | NULL | NULL | NULL | NULL | NULL | Using temporary | +----+--------------+------------+------------+------+---------------+------+---------+------+------+----------+-----------------+ 3 rows in set, 1 warning (0.00 sec) 从结果中可以看到,最左边的小查询SELECT * FROM s1对应的是执行计划中的第一条记录,它的select_type值就是PRIMARY。\nUNION\n对于包含UNION或者UNION ALL的大查询来说,它是由几个小查询组成的,其中除了最左边的那个小查询以外,其余的小查询的select_type值就是UNION,可以对比上一个例子的效果,这就不多举例子了。\nUNION RESULT\nMySQL选择使用临时表来完成UNION查询的去重工作,针对该临时表的查询的select_type就是UNION RESULT,例子上面有,就不赘述了。\nSUBQUERY\n如果包含子查询的查询语句不能够转为对应的semi-join的形式,并且该子查询是不相关子查询,并且查询优化器决定采用将该子查询物化的方案来执行该子查询时,该子查询的第一个SELECT关键字代表的那个查询的select_type就是SUBQUERY,比如下面这个查询:\nmysql\u0026gt; EXPLAIN SELECT * FROM s1 WHERE key1 IN (SELECT key1 FROM s2) OR key3 = 'a'; +----+-------------+-------+------------+-------+---------------+----------+---------+------+------+----------+-------------+ | id | select_type | table | partitions | type | possible_keys | key | key_len | ref | rows | filtered | Extra | +----+-------------+-------+------------+-------+---------------+----------+---------+------+------+----------+-------------+ | 1 | PRIMARY | s1 | NULL | ALL | idx_key3 | NULL | NULL | NULL | 9688 | 100.00 | Using where | | 2 | SUBQUERY | s2 | NULL | index | idx_key1 | idx_key1 | 303 | NULL | 9954 | 100.00 | Using index | +----+-------------+-------+------------+-------+---------------+----------+---------+------+------+----------+-------------+ 2 rows in set, 1 warning (0.00 sec) 可以看到,外层查询的select_type就是PRIMARY,子查询的select_type就是SUBQUERY。需要大家注意的是,由于select_type为SUBQUERY的子查询由于会被物化,所以只需要执行一遍。\nDEPENDENT SUBQUERY\n如果包含子查询的查询语句不能够转为对应的semi-join的形式,并且该子查询是相关子查询,则该子查询的第一个SELECT关键字代表的那个查询的select_type就是DEPENDENT SUBQUERY,比如下面这个查询: mysql\u0026gt; EXPLAIN SELECT * FROM s1 WHERE key1 IN (SELECT key1 FROM s2 WHERE s1.key2 = s2.key2) OR key3 = 'a'; +----+--------------------+-------+------------+------+-------------------+----------+---------+-------------------+------+----------+-------------+ | id | select_type | table | partitions | type | possible_keys | key | key_len | ref | rows | filtered | Extra | +----+--------------------+-------+------------+------+-------------------+----------+---------+-------------------+------+----------+-------------+ | 1 | PRIMARY | s1 | NULL | ALL | idx_key3 | NULL | NULL | NULL | 9688 | 100.00 | Using where | | 2 | DEPENDENT SUBQUERY | s2 | NULL | ref | idx_key2,idx_key1 | idx_key2 | 5 | xiaohaizi.s1.key2 | 1 | 10.00 | Using where | +----+--------------------+-------+------------+------+-------------------+----------+---------+-------------------+------+----------+-------------+ 2 rows in set, 2 warnings (0.00 sec) 需要大家注意的是,select_type为DEPENDENT SUBQUERY的查询可能会被执行多次。\nDEPENDENT UNION\n在包含UNION或者UNION ALL的大查询中,如果各个小查询都依赖于外层查询的话,那除了最左边的那个小查询之外,其余的小查询的select_type的值就是DEPENDENT UNION。说的有些绕,比方说下面这个查询:\nmysql\u0026gt; EXPLAIN SELECT * FROM s1 WHERE key1 IN (SELECT key1 FROM s2 WHERE key1 = 'a' UNION SELECT key1 FROM s1 WHERE key1 = 'b'); +----+--------------------+------------+------------+------+---------------+----------+---------+-------+------+----------+--------------------------+ | id | select_type | table | partitions | type | possible_keys | key | key_len | ref | rows | filtered | Extra | +----+--------------------+------------+------------+------+---------------+----------+---------+-------+------+----------+--------------------------+ | 1 | PRIMARY | s1 | NULL | ALL | NULL | NULL | NULL | NULL | 9688 | 100.00 | Using where | | 2 | DEPENDENT SUBQUERY | s2 | NULL | ref | idx_key1 | idx_key1 | 303 | const | 12 | 100.00 | Using where; Using index | | 3 | DEPENDENT UNION | s1 | NULL | ref | idx_key1 | idx_key1 | 303 | const | 8 | 100.00 | Using where; Using index | | NULL | UNION RESULT | \u0026lt;union2,3\u0026gt; | NULL | ALL | NULL | NULL | NULL | NULL | NULL | NULL | Using temporary | +----+--------------------+------------+------------+------+---------------+----------+---------+-------+------+----------+--------------------------+ 4 rows in set, 1 warning (0.03 sec) 这个查询比较复杂啊,大查询里包含了一个子查询,子查询里又是由UNION连起来的两个小查询。从执行计划中可以看出来,SELECT key1 FROM s2 WHERE key1 = 'a'这个小查询由于是子查询中第一个查询,所以它的select_type是DEPENDENT SUBQUERY,而SELECT key1 FROM s1 WHERE key1 = 'b'这个查询的select_type就是DEPENDENT UNION。\nDERIVED\n对于采用物化的方式执行的包含派生表的查询,该派生表对应的子查询的select_type就是DERIVED,比方说下面这个查询: mysql\u0026gt; EXPLAIN SELECT * FROM (SELECT key1, count(*) as c FROM s1 GROUP BY key1) AS derived_s1 where c \u0026gt; 1; +----+-------------+------------+------------+-------+---------------+----------+---------+------+------+----------+-------------+ | id | select_type | table | partitions | type | possible_keys | key | key_len | ref | rows | filtered | Extra | +----+-------------+------------+------------+-------+---------------+----------+---------+------+------+----------+-------------+ | 1 | PRIMARY | \u0026lt;derived2\u0026gt; | NULL | ALL | NULL | NULL | NULL | NULL | 9688 | 33.33 | Using where | | 2 | DERIVED | s1 | NULL | index | idx_key1 | idx_key1 | 303 | NULL | 9688 | 100.00 | Using index | +----+-------------+------------+------------+-------+---------------+----------+---------+------+------+----------+-------------+ 2 rows in set, 1 warning (0.00 sec) 从执行计划中可以看出,id为2的记录就代表子查询的执行方式,它的select_type是DERIVED,说明该子查询是以物化的方式执行的。id为1的记录代表外层查询,大家注意看它的table列显示的是\u0026lt;derived2\u0026gt;,表示该查询是针对将派生表物化之后的表进行查询的。\n小贴士:如果派生表可以通过和外层查询合并的方式执行的话,执行计划又是另一番景象,大家可以试试~ - MATERIALIZED\n当查询优化器在执行包含子查询的语句时,选择将子查询物化之后与外层查询进行连接查询时,该子查询对应的select_type属性就是MATERIALIZED,比如下面这个查询:\nmysql\u0026gt; EXPLAIN SELECT * FROM s1 WHERE key1 IN (SELECT key1 FROM s2); +----+--------------+-------------+------------+--------+---------------+------------+---------+-------------------+------+----------+-------------+ | id | select_type | table | partitions | type | possible_keys | key | key_len | ref | rows | filtered | Extra | +----+--------------+-------------+------------+--------+---------------+------------+---------+-------------------+------+----------+-------------+ | 1 | SIMPLE | s1 | NULL | ALL | idx_key1 | NULL | NULL | NULL | 9688 | 100.00 | Using where | | 1 | SIMPLE | \u0026lt;subquery2\u0026gt; | NULL | eq_ref | \u0026lt;auto_key\u0026gt; | \u0026lt;auto_key\u0026gt; | 303 | xiaohaizi.s1.key1 | 1 | 100.00 | NULL | | 2 | MATERIALIZED | s2 | NULL | index | idx_key1 | idx_key1 | 303 | NULL | 9954 | 100.00 | Using index | +----+--------------+-------------+------------+--------+---------------+------------+---------+-------------------+------+----------+-------------+ 3 rows in set, 1 warning (0.01 sec) 执行计划的第三条记录的id值为2,说明该条记录对应的是一个单表查询,从它的select_type值为MATERIALIZED可以看出,查询优化器是要把子查询先转换成物化表。然后看执行计划的前两条记录的id值都为1,说明这两条记录对应的表进行连接查询,需要注意的是第二条记录的table列的值是\u0026lt;subquery2\u0026gt;,说明该表其实就是id为2对应的子查询执行之后产生的物化表,然后将s1和该物化表进行连接查询。\nUNCACHEABLE SUBQUERY\n不常用,就不多介绍了。 - UNCACHEABLE UNION\n不常用,就不多介绍了。\npartitions # 由于我们压根儿就没介绍过分区是什么,所以这个输出列我们也就不说了,一般情况下我们的查询语句的执行计划的partitions列的值都是NULL。\ntype # 我们前面说过执行计划的一条记录就代表着MySQL对某个表的执行查询时的访问方法,其中的type列就表明了这个访问方法是什么,比方说下面这个查询: mysql\u0026gt; EXPLAIN SELECT * FROM s1 WHERE key1 = 'a'; +----+-------------+-------+------------+------+---------------+----------+---------+-------+------+----------+-------+ | id | select_type | table | partitions | type | possible_keys | key | key_len | ref | rows | filtered | Extra | +----+-------------+-------+------------+------+---------------+----------+---------+-------+------+----------+-------+ | 1 | SIMPLE | s1 | NULL | ref | idx_key1 | idx_key1 | 303 | const | 8 | 100.00 | NULL | +----+-------------+-------+------------+------+---------------+----------+---------+-------+------+----------+-------+ 1 row in set, 1 warning (0.04 sec) 可以看到type列的值是ref,表明MySQL即将使用ref访问方法来执行对s1表的查询。但是我们之前只介绍过对使用InnoDB存储引擎的表进行单表访问的一些访问方法,完整的访问方法如下:system,const,eq_ref,ref,fulltext,ref_or_null,index_merge,unique_subquery,index_subquery,range,index,ALL。当然我们还要详细介绍一下:\nsystem\n当表中只有一条记录并且该表使用的存储引擎的统计数据是精确的,比如MyISAM、Memory,那么对该表的访问方法就是system。比方说我们新建一个MyISAM表,并为其插入一条记录:\nmysql\u0026gt; INSERT INTO t VALUES(1); Query OK, 1 row affected (0.01 sec) `然后我们看一下查询这个表的执行计划:` mysql\u0026gt; EXPLAIN SELECT * FROM t; \\+----\\+-------------\\+-------\\+------------\\+--------\\+---------------\\+------\\+---------\\+------\\+------\\+----------\\+-------\\+ | id | select_type | table | partitions | type | possible_keys | key | key_len | ref | rows | filtered | Extra | \\+----\\+-------------\\+-------\\+------------\\+--------\\+---------------\\+------\\+---------\\+------\\+------\\+----------\\+-------\\+ | 1 | SIMPLE | t | NULL | system | NULL | NULL | NULL | NULL | 1 | 100.00 | NULL | \\+----\\+-------------\\+-------\\+------------\\+--------\\+---------------\\+------\\+---------\\+------\\+------\\+----------\\+-------\\+ 1 row in set, 1 warning (0.00 sec) `可以看到`type`列的值就是`system`了。` 小贴士:你可以把表改成使用InnoDB存储引擎,试试看执行计划的type列是什么。 ``` + `const` 这个我们前面介绍过,就是当我们根据主键或者唯一二级索引列与常数进行等值匹配时,对单表的访问方法就是`const`,比如: `mysql\u0026gt; EXPLAIN SELECT * FROM s1 WHERE id = 5; +----+-------------+-------+------------+-------+---------------+---------+---------+-------+------+----------+-------+ | id | select_type | table | partitions | type | possible_keys | key | key_len | ref | rows | filtered | Extra | +----+-------------+-------+------------+-------+---------------+---------+---------+-------+------+----------+-------+ | 1 | SIMPLE | s1 | NULL | const | PRIMARY | PRIMARY | 4 | const | 1 | 100.00 | NULL | +----+-------------+-------+------------+-------+---------------+---------+---------+-------+------+----------+-------+ 1 row in set, 1 warning (0.01 sec)` + `eq_ref` 在连接查询时,如果被驱动表是通过主键或者唯一二级索引列等值匹配的方式进行访问的(如果该主键或者唯一二级索引是联合索引的话,所有的索引列都必须进行等值比较),则对该被驱动表的访问方法就是`eq_ref`,比方说: `mysql\u0026gt; EXPLAIN SELECT * FROM s1 INNER JOIN s2 ON s1.id = s2.id; +----+-------------+-------+------------+--------+---------------+---------+---------+-----------------+------+----------+-------+ | id | select_type | table | partitions | type | possible_keys | key | key_len | ref | rows | filtered | Extra | +----+-------------+-------+------------+--------+---------------+---------+---------+-----------------+------+----------+-------+ | 1 | SIMPLE | s1 | NULL | ALL | PRIMARY | NULL | NULL | NULL | 9688 | 100.00 | NULL | | 1 | SIMPLE | s2 | NULL | eq_ref | PRIMARY | PRIMARY | 4 | xiaohaizi.s1.id | 1 | 100.00 | NULL | +----+-------------+-------+------------+--------+---------------+---------+---------+-----------------+------+----------+-------+ 2 rows in set, 1 warning (0.01 sec)` 从执行计划的结果中可以看出,`MySQL`打算将`s1`作为驱动表,`s2`作为被驱动表,重点关注`s2`的访问方法是`eq_ref`,表明在访问`s2`表的时候可以通过主键的等值匹配来进行访问。 + `ref` 当通过普通的二级索引列与常量进行等值匹配时来查询某个表,那么对该表的访问方法就**可能**是`ref`,最开始举过例子了,就不重复举例了。 + `fulltext` 全文索引,我们没有细讲过,跳过~ + `ref_or_null` 当对普通二级索引进行等值匹配查询,该索引列的值也可以是`NULL`值时,那么对该表的访问方法就**可能**是`ref_or_null`,比如说: `mysql\u0026gt; EXPLAIN SELECT * FROM s1 WHERE key1 = \u0026#39;a\u0026#39; OR key1 IS NULL; +----+-------------+-------+------------+-------------+---------------+----------+---------+-------+------+----------+-----------------------+ | id | select_type | table | partitions | type | possible_keys | key | key_len | ref | rows | filtered | Extra | +----+-------------+-------+------------+-------------+---------------+----------+---------+-------+------+----------+-----------------------+ | 1 | SIMPLE | s1 | NULL | ref_or_null | idx_key1 | idx_key1 | 303 | const | 9 | 100.00 | Using index condition | +----+-------------+-------+------------+-------------+---------------+----------+---------+-------+------+----------+-----------------------+ 1 row in set, 1 warning (0.01 sec)` + `index_merge` 一般情况下对于某个表的查询只能使用到一个索引,但我们介绍单表访问方法时特意强调了在某些场景下可以使用`Intersection`、`Union`、`Sort-Union`这三种索引合并的方式来执行查询,忘掉的回去补一下,我们看一下执行计划中是怎么体现`MySQL`使用索引合并的方式来对某个表执行查询的: `mysql\u0026gt; EXPLAIN SELECT * FROM s1 WHERE key1 = \u0026#39;a\u0026#39; OR key3 = \u0026#39;a\u0026#39;; +----+-------------+-------+------------+-------------+-------------------+-------------------+---------+------+------+----------+---------------------------------------------+ | id | select_type | table | partitions | type | possible_keys | key | key_len | ref | rows | filtered | Extra | +----+-------------+-------+------------+-------------+-------------------+-------------------+---------+------+------+----------+---------------------------------------------+ | 1 | SIMPLE | s1 | NULL | index_merge | idx_key1,idx_key3 | idx_key1,idx_key3 | 303,303 | NULL | 14 | 100.00 | Using union(idx_key1,idx_key3); Using where | +----+-------------+-------+------------+-------------+-------------------+-------------------+---------+------+------+----------+---------------------------------------------+ 1 row in set, 1 warning (0.01 sec)` 从执行计划的`type`列的值是`index_merge`就可以看出,`MySQL`打算使用索引合并的方式来执行对`s1`表的查询。 + `unique_subquery` 类似于两表连接中被驱动表的`eq_ref`访问方法,`unique_subquery`是针对在一些包含`IN`子查询的查询语句中,如果查询优化器决定将`IN`子查询转换为`EXISTS`子查询,而且子查询可以使用到主键进行等值匹配的话,那么该子查询执行计划的`type`列的值就是`unique_subquery`,比如下面的这个查询语句: `mysql\u0026gt; EXPLAIN SELECT * FROM s1 WHERE key2 IN (SELECT id FROM s2 where s1.key1 = s2.key1) OR key3 = \u0026#39;a\u0026#39;; +----+--------------------+-------+------------+-----------------+------------------+---------+---------+------+------+----------+-------------+ | id | select_type | table | partitions | type | possible_keys | key | key_len | ref | rows | filtered | Extra | +----+--------------------+-------+------------+-----------------+------------------+---------+---------+------+------+----------+-------------+ | 1 | PRIMARY | s1 | NULL | ALL | idx_key3 | NULL | NULL | NULL | 9688 | 100.00 | Using where | | 2 | DEPENDENT SUBQUERY | s2 | NULL | unique_subquery | PRIMARY,idx_key1 | PRIMARY | 4 | func | 1 | 10.00 | Using where | +----+--------------------+-------+------------+-----------------+------------------+---------+---------+------+------+----------+-------------+ 2 rows in set, 2 warnings (0.00 sec)` 可以看到执行计划的第二条记录的`type`值就是`unique_subquery`,说明在执行子查询时会使用到`id`列的索引。 + `index_subquery` `index_subquery`与`unique_subquery`类似,只不过访问子查询中的表时使用的是普通的索引,比如这样: `mysql\u0026gt; EXPLAIN SELECT * FROM s1 WHERE common_field IN (SELECT key3 FROM s2 where s1.key1 = s2.key1) OR key3 = \u0026#39;a\u0026#39;; +----+--------------------+-------+------------+----------------+-------------------+----------+---------+------+------+----------+-------------+ | id | select_type | table | partitions | type | possible_keys | key | key_len | ref | rows | filtered | Extra | +----+--------------------+-------+------------+----------------+-------------------+----------+---------+------+------+----------+-------------+ | 1 | PRIMARY | s1 | NULL | ALL | idx_key3 | NULL | NULL | NULL | 9688 | 100.00 | Using where | | 2 | DEPENDENT SUBQUERY | s2 | NULL | index_subquery | idx_key1,idx_key3 | idx_key3 | 303 | func | 1 | 10.00 | Using where | +----+--------------------+-------+------------+----------------+-------------------+----------+---------+------+------+----------+-------------+ 2 rows in set, 2 warnings (0.01 sec)` + `range` 如果使用索引获取某些`范围区间`的记录,那么就**可能**使用到`range`访问方法,比如下面的这个查询: `mysql\u0026gt; EXPLAIN SELECT * FROM s1 WHERE key1 IN (\u0026#39;a\u0026#39;, \u0026#39;b\u0026#39;, \u0026#39;c\u0026#39;); +----+-------------+-------+------------+-------+---------------+----------+---------+------+------+----------+-----------------------+ | id | select_type | table | partitions | type | possible_keys | key | key_len | ref | rows | filtered | Extra | +----+-------------+-------+------------+-------+---------------+----------+---------+------+------+----------+-----------------------+ | 1 | SIMPLE | s1 | NULL | range | idx_key1 | idx_key1 | 303 | NULL | 27 | 100.00 | Using index condition | +----+-------------+-------+------------+-------+---------------+----------+---------+------+------+----------+-----------------------+ 1 row in set, 1 warning (0.01 sec)` 或者: ``` mysql\u0026gt; EXPLAIN SELECT * FROM s1 WHERE key1 \u0026gt; \u0026#39;a\u0026#39; AND key1 \u0026lt; \u0026#39;b\u0026#39;; \\+----\\+-------------\\+-------\\+------------\\+-------\\+---------------\\+----------\\+---------\\+------\\+------\\+----------\\+-----------------------\\+ | id | select_type | table | partitions | type | possible_keys | key | key_len | ref | rows | filtered | Extra | \\+----\\+-------------\\+-------\\+------------\\+-------\\+---------------\\+----------\\+---------\\+------\\+------\\+----------\\+-----------------------\\+ | 1 | SIMPLE | s1 | NULL | range | idx_key1 | idx_key1 | 303 | NULL | 294 | 100.00 | Using index condition | \\+----\\+-------------\\+-------\\+------------\\+-------\\+---------------\\+----------\\+---------\\+------\\+------\\+----------\\+-----------------------\\+ 1 row in set, 1 warning (0.00 sec) ``` + `index` 当我们可以使用索引覆盖,但需要扫描全部的索引记录时,该表的访问方法就是`index`,比如这样: `mysql\u0026gt; EXPLAIN SELECT key_part2 FROM s1 WHERE key_part3 = \u0026#39;a\u0026#39;; +----+-------------+-------+------------+-------+---------------+--------------+---------+------+------+----------+--------------------------+ | id | select_type | table | partitions | type | possible_keys | key | key_len | ref | rows | filtered | Extra | +----+-------------+-------+------------+-------+---------------+--------------+---------+------+------+----------+--------------------------+ | 1 | SIMPLE | s1 | NULL | index | NULL | idx_key_part | 909 | NULL | 9688 | 10.00 | Using where; Using index | +----+-------------+-------+------------+-------+---------------+--------------+---------+------+------+----------+--------------------------+ 1 row in set, 1 warning (0.00 sec)` 上述查询中的搜索列表中只有`key_part2`一个列,而且搜索条件中也只有`key_part3`一个列,这两个列又恰好包含在`idx_key_part`这个索引中,可是搜索条件`key_part3`不能直接使用该索引进行`ref`或者`range`方式的访问,只能扫描整个`idx_key_part`索引的记录,所以查询计划的`type`列的值就是`index`。 `小贴士:再一次强调,对于使用InnoDB存储引擎的表来说,二级索引的记录只包含索引列和主键列的值,而聚簇索引中包含用户定义的全部列以及一些隐藏列,所以扫描二级索引的代价比直接全表扫描,也就是扫描聚簇索引的代价更低一些。` + `ALL` 最熟悉的全表扫描,就不多介绍了,直接看例子: `mysql\u0026gt; EXPLAIN SELECT * FROM s1; +----+-------------+-------+------------+------+---------------+------+---------+------+------+----------+-------+ | id | select_type | table | partitions | type | possible_keys | key | key_len | ref | rows | filtered | Extra | +----+-------------+-------+------------+------+---------------+------+---------+------+------+----------+-------+ | 1 | SIMPLE | s1 | NULL | ALL | NULL | NULL | NULL | NULL | 9688 | 100.00 | NULL | +----+-------------+-------+------------+------+---------------+------+---------+------+------+----------+-------+ 1 row in set, 1 warning (0.00 sec)` 一般来说,这些访问方法按照我们介绍它们的顺序性能依次变差。其中除了`All`这个访问方法外,其余的访问方法都能用到索引,除了`index_merge`访问方法外,其余的访问方法都最多只能用到一个索引。 ## possible_keys和key 在`EXPLAIN`语句输出的执行计划中,`possible_keys`列表示在某个查询语句中,对某个表执行单表查询时可能用到的索引有哪些,`key`列表示实际用到的索引有哪些,比方说下面这个查询: `mysql\u0026gt; EXPLAIN SELECT * FROM s1 WHERE key1 \u0026gt; \u0026#39;z\u0026#39; AND key3 = \u0026#39;a\u0026#39;; +----+-------------+-------+------------+------+-------------------+----------+---------+-------+------+----------+-------------+ | id | select_type | table | partitions | type | possible_keys | key | key_len | ref | rows | filtered | Extra | +----+-------------+-------+------------+------+-------------------+----------+---------+-------+------+----------+-------------+ | 1 | SIMPLE | s1 | NULL | ref | idx_key1,idx_key3 | idx_key3 | 303 | const | 6 | 2.75 | Using where | +----+-------------+-------+------------+------+-------------------+----------+---------+-------+------+----------+-------------+ 1 row in set, 1 warning (0.01 sec)` 上述执行计划的`possible_keys`列的值是`idx_key1,idx_key3`,表示该查询可能使用到`idx_key1,idx_key3`两个索引,然后`key`列的值是`idx_key3`,表示经过查询优化器计算使用不同索引的成本后,最后决定使用`idx_key3`来执行查询比较划算。 不过有一点比较特别,就是在使用`index`访问方法来查询某个表时,`possible_keys`列是空的,而`key`列展示的是实际使用到的索引,比如这样: `mysql\u0026gt; EXPLAIN SELECT key_part2 FROM s1 WHERE key_part3 = \u0026#39;a\u0026#39;; +----+-------------+-------+------------+-------+---------------+--------------+---------+------+------+----------+--------------------------+ | id | select_type | table | partitions | type | possible_keys | key | key_len | ref | rows | filtered | Extra | +----+-------------+-------+------------+-------+---------------+--------------+---------+------+------+----------+--------------------------+ | 1 | SIMPLE | s1 | NULL | index | NULL | idx_key_part | 909 | NULL | 9688 | 10.00 | Using where; Using index | +----+-------------+-------+------------+-------+---------------+--------------+---------+------+------+----------+--------------------------+ 1 row in set, 1 warning (0.00 sec)` 另外需要注意的一点是,**possible_keys列中的值并不是越多越好,可能使用的索引越多,查询优化器计算查询成本时就得花费更长时间,所以如果可以的话,尽量删除那些用不到的索引**。 ## key_len `key_len`列表示当优化器决定使用某个索引执行查询时,该索引记录的最大长度,它是由这三个部分构成的: - 对于使用固定长度类型的索引列来说,它实际占用的存储空间的最大长度就是该固定值,对于指定字符集的变长类型的索引列来说,比如某个索引列的类型是`VARCHAR(100)`,使用的字符集是`utf8`,那么该列实际占用的最大存储空间就是`100 × 3 = 300`个字节。 - 如果该索引列可以存储`NULL`值,则`key_len`比不可以存储`NULL`值时多1个字节。 - 对于变长字段来说,都会有2个字节的空间来存储该变长列的实际长度。 比如下面这个查询: `mysql\u0026gt; EXPLAIN SELECT * FROM s1 WHERE id = 5; +----+-------------+-------+------------+-------+---------------+---------+---------+-------+------+----------+-------+ | id | select_type | table | partitions | type | possible_keys | key | key_len | ref | rows | filtered | Extra | +----+-------------+-------+------------+-------+---------------+---------+---------+-------+------+----------+-------+ | 1 | SIMPLE | s1 | NULL | const | PRIMARY | PRIMARY | 4 | const | 1 | 100.00 | NULL | +----+-------------+-------+------------+-------+---------------+---------+---------+-------+------+----------+-------+ 1 row in set, 1 warning (0.01 sec)` 由于`id`列的类型是`INT`,并且不可以存储`NULL`值,所以在使用该列的索引时`key_len`大小就是`4`。当索引列可以存储`NULL`值时,比如: `mysql\u0026gt; EXPLAIN SELECT * FROM s1 WHERE key2 = 5; +----+-------------+-------+------------+-------+---------------+----------+---------+-------+------+----------+-------+ | id | select_type | table | partitions | type | possible_keys | key | key_len | ref | rows | filtered | Extra | +----+-------------+-------+------------+-------+---------------+----------+---------+-------+------+----------+-------+ | 1 | SIMPLE | s1 | NULL | const | idx_key2 | idx_key2 | 5 | const | 1 | 100.00 | NULL | +----+-------------+-------+------------+-------+---------------+----------+---------+-------+------+----------+-------+ 1 row in set, 1 warning (0.00 sec)` 可以看到`key_len`列就变成了`5`,比使用`id`列的索引时多了`1`。 对于可变长度的索引列来说,比如下面这个查询: `mysql\u0026gt; EXPLAIN SELECT * FROM s1 WHERE key1 = \u0026#39;a\u0026#39;; +----+-------------+-------+------------+------+---------------+----------+---------+-------+------+----------+-------+ | id | select_type | table | partitions | type | possible_keys | key | key_len | ref | rows | filtered | Extra | +----+-------------+-------+------------+------+---------------+----------+---------+-------+------+----------+-------+ | 1 | SIMPLE | s1 | NULL | ref | idx_key1 | idx_key1 | 303 | const | 8 | 100.00 | NULL | +----+-------------+-------+------------+------+---------------+----------+---------+-------+------+----------+-------+ 1 row in set, 1 warning (0.00 sec)` 由于`key1`列的类型是`VARCHAR(100)`,所以该列实际最多占用的存储空间就是`300`字节,又因为该列允许存储`NULL`值,所以`key_len`需要加`1`,又因为该列是可变长度列,所以`key_len`需要加`2`,所以最后`ken_len`的值就是`303`。 有的同学可能有疑问:你在前面介绍`InnoDB`行格式的时候不是说,存储变长字段的实际长度不是可能占用1个字节或者2个字节么?为什么现在不管三七二十一都用了`2`个字节?这里需要强调的一点是,执行计划的生成是在`MySQL server`层中的功能,并不是针对具体某个存储引擎的功能,设计`MySQL`的大佬在执行计划中输出`key_len`列主要是为了让我们区分某个使用联合索引的查询具体用了几个索引列,而不是为了准确的说明针对某个具体存储引擎存储变长字段的实际长度占用的空间到底是占用1个字节还是2个字节。比方说下面这个使用到联合索引`idx_key_part`的查询: `mysql\u0026gt; EXPLAIN SELECT * FROM s1 WHERE key_part1 = \u0026#39;a\u0026#39;; +----+-------------+-------+------------+------+---------------+--------------+---------+-------+------+----------+-------+ | id | select_type | table | partitions | type | possible_keys | key | key_len | ref | rows | filtered | Extra | +----+-------------+-------+------------+------+---------------+--------------+---------+-------+------+----------+-------+ | 1 | SIMPLE | s1 | NULL | ref | idx_key_part | idx_key_part | 303 | const | 12 | 100.00 | NULL | +----+-------------+-------+------------+------+---------------+--------------+---------+-------+------+----------+-------+ 1 row in set, 1 warning (0.00 sec)` 我们可以从执行计划的`key_len`列中看到值是`303`,这意味着`MySQL`在执行上述查询中只能用到`idx_key_part`索引的一个索引列,而下面这个查询: `mysql\u0026gt; EXPLAIN SELECT * FROM s1 WHERE key_part1 = \u0026#39;a\u0026#39; AND key_part2 = \u0026#39;b\u0026#39;; +----+-------------+-------+------------+------+---------------+--------------+---------+-------------+------+----------+-------+ | id | select_type | table | partitions | type | possible_keys | key | key_len | ref | rows | filtered | Extra | +----+-------------+-------+------------+------+---------------+--------------+---------+-------------+------+----------+-------+ | 1 | SIMPLE | s1 | NULL | ref | idx_key_part | idx_key_part | 606 | const,const | 1 | 100.00 | NULL | +----+-------------+-------+------------+------+---------------+--------------+---------+-------------+------+----------+-------+ 1 row in set, 1 warning (0.01 sec)` 这个查询的执行计划的`ken_len`列的值是`606`,说明执行这个查询的时候可以用到联合索引`idx_key_part`的两个索引列。 ## ref 当使用索引列等值匹配的条件去执行查询时,也就是在访问方法是`const`、`eq_ref`、`ref`、`ref_or_null`、`unique_subquery`、`index_subquery`其中之一时,`ref`列展示的就是与索引列作等值匹配的东东是什么,比如只是一个常数或者是某个列。大家看下面这个查询: `mysql\u0026gt; EXPLAIN SELECT * FROM s1 WHERE key1 = \u0026#39;a\u0026#39;; +----+-------------+-------+------------+------+---------------+----------+---------+-------+------+----------+-------+ | id | select_type | table | partitions | type | possible_keys | key | key_len | ref | rows | filtered | Extra | +----+-------------+-------+------------+------+---------------+----------+---------+-------+------+----------+-------+ | 1 | SIMPLE | s1 | NULL | ref | idx_key1 | idx_key1 | 303 | const | 8 | 100.00 | NULL | +----+-------------+-------+------------+------+---------------+----------+---------+-------+------+----------+-------+ 1 row in set, 1 warning (0.01 sec)` 可以看到`ref`列的值是`const`,表明在使用`idx_key1`索引执行查询时,与`key1`列作等值匹配的对象是一个常数,当然有时候更复杂一点: `mysql\u0026gt; EXPLAIN SELECT * FROM s1 INNER JOIN s2 ON s1.id = s2.id; +----+-------------+-------+------------+--------+---------------+---------+---------+-----------------+------+----------+-------+ | id | select_type | table | partitions | type | possible_keys | key | key_len | ref | rows | filtered | Extra | +----+-------------+-------+------------+--------+---------------+---------+---------+-----------------+------+----------+-------+ | 1 | SIMPLE | s1 | NULL | ALL | PRIMARY | NULL | NULL | NULL | 9688 | 100.00 | NULL | | 1 | SIMPLE | s2 | NULL | eq_ref | PRIMARY | PRIMARY | 4 | xiaohaizi.s1.id | 1 | 100.00 | NULL | +----+-------------+-------+------------+--------+---------------+---------+---------+-----------------+------+----------+-------+ 2 rows in set, 1 warning (0.00 sec)` 可以看到对被驱动表`s2`的访问方法是`eq_ref`,而对应的`ref`列的值是`xiaohaizi.s1.id`,这说明在对被驱动表进行访问时会用到`PRIMARY`索引,也就是聚簇索引与一个列进行等值匹配的条件,于`s2`表的`id`作等值匹配的对象就是`xiaohaizi.s1.id`列(注意这里把数据库名也写出来了)。 有的时候与索引列进行等值匹配的对象是一个函数,比方说下面这个查询: `mysql\u0026gt; EXPLAIN SELECT * FROM s1 INNER JOIN s2 ON s2.key1 = UPPER(s1.key1); +----+-------------+-------+------------+------+---------------+----------+---------+------+------+----------+-----------------------+ | id | select_type | table | partitions | type | possible_keys | key | key_len | ref | rows | filtered | Extra | +----+-------------+-------+------------+------+---------------+----------+---------+------+------+----------+-----------------------+ | 1 | SIMPLE | s1 | NULL | ALL | NULL | NULL | NULL | NULL | 9688 | 100.00 | NULL | | 1 | SIMPLE | s2 | NULL | ref | idx_key1 | idx_key1 | 303 | func | 1 | 100.00 | Using index condition | +----+-------------+-------+------------+------+---------------+----------+---------+------+------+----------+-----------------------+ 2 rows in set, 1 warning (0.00 sec)` 我们看执行计划的第二条记录,可以看到对`s2`表采用`ref`访问方法执行查询,然后在查询计划的`ref`列里输出的是`func`,说明与`s2`表的`key1`列进行等值匹配的对象是一个函数。 ## rows 如果查询优化器决定使用全表扫描的方式对某个表执行查询时,执行计划的`rows`列就代表预计需要扫描的行数,如果使用索引来执行查询时,执行计划的`rows`列就代表预计扫描的索引记录行数。比如下面这个查询: `mysql\u0026gt; EXPLAIN SELECT * FROM s1 WHERE key1 \u0026gt; \u0026#39;z\u0026#39;; +----+-------------+-------+------------+-------+---------------+----------+---------+------+------+----------+-----------------------+ | id | select_type | table | partitions | type | possible_keys | key | key_len | ref | rows | filtered | Extra | +----+-------------+-------+------------+-------+---------------+----------+---------+------+------+----------+-----------------------+ | 1 | SIMPLE | s1 | NULL | range | idx_key1 | idx_key1 | 303 | NULL | 266 | 100.00 | Using index condition | +----+-------------+-------+------------+-------+---------------+----------+---------+------+------+----------+-----------------------+ 1 row in set, 1 warning (0.00 sec)` 我们看到执行计划的`rows`列的值是`266`,这意味着查询优化器在经过分析使用`idx_key1`进行查询的成本之后,觉得满足`key1 \u0026gt; \u0026#39;z\u0026#39;`这个条件的记录只有`266`条。 ## filtered 之前在分析连接查询的成本时提出过一个`condition filtering`的概念,就是`MySQL`在计算驱动表扇出时采用的一个策略: - 如果使用的是全表扫描的方式执行的单表查询,那么计算驱动表扇出时需要估计出满足搜索条件的记录到底有多少条。 - 如果使用的是索引执行的单表扫描,那么计算驱动表扇出的时候需要估计出满足除使用到对应索引的搜索条件外的其他搜索条件的记录有多少条。 比方说下面这个查询: `mysql\u0026gt; EXPLAIN SELECT * FROM s1 WHERE key1 \u0026gt; \u0026#39;z\u0026#39; AND common_field = \u0026#39;a\u0026#39;; +----+-------------+-------+------------+-------+---------------+----------+---------+------+------+----------+------------------------------------+ | id | select_type | table | partitions | type | possible_keys | key | key_len | ref | rows | filtered | Extra | +----+-------------+-------+------------+-------+---------------+----------+---------+------+------+----------+------------------------------------+ | 1 | SIMPLE | s1 | NULL | range | idx_key1 | idx_key1 | 303 | NULL | 266 | 10.00 | Using index condition; Using where | +----+-------------+-------+------------+-------+---------------+----------+---------+------+------+----------+------------------------------------+ 1 row in set, 1 warning (0.00 sec)` 从执行计划的`key`列中可以看出来,该查询使用`idx_key1`索引来执行查询,从`rows`列可以看出满足`key1 \u0026gt; \u0026#39;z\u0026#39;`的记录有`266`条。执行计划的`filtered`列就代表查询优化器预测在这`266`条记录中,有多少条记录满足其余的搜索条件,也就是`common_field = \u0026#39;a\u0026#39;`这个条件的百分比。此处`filtered`列的值是`10.00`,说明查询优化器预测在`266`条记录中有`10.00%`的记录满足`common_field = \u0026#39;a\u0026#39;`这个条件。 对于单表查询来说,这个`filtered`列的值没什么意义,我们更关注在连接查询中驱动表对应的执行计划记录的`filtered`值,比方说下面这个查询: `mysql\u0026gt; EXPLAIN SELECT * FROM s1 INNER JOIN s2 ON s1.key1 = s2.key1 WHERE s1.common_field = \u0026#39;a\u0026#39;; +----+-------------+-------+------------+------+---------------+----------+---------+-------------------+------+----------+-------------+ | id | select_type | table | partitions | type | possible_keys | key | key_len | ref | rows | filtered | Extra | +----+-------------+-------+------------+------+---------------+----------+---------+-------------------+------+----------+-------------+ | 1 | SIMPLE | s1 | NULL | ALL | idx_key1 | NULL | NULL | NULL | 9688 | 10.00 | Using where | | 1 | SIMPLE | s2 | NULL | ref | idx_key1 | idx_key1 | 303 | xiaohaizi.s1.key1 | 1 | 100.00 | NULL | +----+-------------+-------+------------+------+---------------+----------+---------+-------------------+------+----------+-------------+ 2 rows in set, 1 warning (0.00 sec)` 从执行计划中可以看出来,查询优化器打算把`s1`当作驱动表,`s2`当作被驱动表。我们可以看到驱动表`s1`表的执行计划的`rows`列为`9688`, `filtered`列为`10.00`,这意味着驱动表`s1`的扇出值就是`9688 × 10.00% = 968.8`,这说明还要对被驱动表执行大约`968`次查询。 "},{"id":19,"href":"/zh/docs/technology/MySQL/MySQL%E6%98%AF%E6%80%8E%E6%A0%B7%E8%BF%90%E8%A1%8C%E7%9A%84/%E7%AC%AC14%E7%AB%A0_%E4%B8%8D%E5%A5%BD%E7%9C%8B%E5%B0%B1%E8%A6%81%E5%A4%9A%E6%95%B4%E5%AE%B9-MySQL%E5%9F%BA%E4%BA%8E%E8%A7%84%E5%88%99%E7%9A%84%E4%BC%98%E5%8C%96%E5%86%85%E5%90%AB%E5%85%B3%E4%BA%8E%E5%AD%90%E6%9F%A5%E8%AF%A2%E4%BC%98%E5%8C%96%E4%BA%8C%E4%B8%89%E4%BA%8B%E5%84%BF/","title":"第14章_不好看就要多整容-MySQL基于规则的优化(内含关于子查询优化二三事儿)","section":"My Sql是怎样运行的","content":"第14章 不好看就要多整容-MySQL基于规则的优化(内含关于子查询优化二三事儿)\n大家别忘了MySQL本质上是一个软件,设计MySQL的大佬并不能要求使用这个软件的人个个都是数据库高高手,就像我写这本书的时候并不能要求各位在学之前就会了里边儿的知识。 吐槽一下:都会了的人谁还看呢,难道是为了精神上受感化? 也就是说我们无法避免某些同学写一些执行起来十分耗费性能的语句。即使是这样,设计MySQL的大佬还是依据一些规则,竭尽全力的把这个很糟糕的语句转换成某种可以比较高效执行的形式,这个过程也可以被称作查询重写(就是人家觉得你写的语句不好,自己再重写一遍)。本章详细介绍一下一些比较重要的重写规则。\n条件化简 # 我们编写的查询语句的搜索条件本质上是一个表达式,这些表达式可能比较繁杂,或者不能高效的执行,MySQL的查询优化器会为我们简化这些表达式。为了方便大家理解,我们后边举例子的时候都使用诸如a、b、c之类的简单字母代表某个表的列名。\n移除不必要的括号 # 有时候表达式里有许多无用的括号,比如这样: ((a = 5 AND b = c) OR ((a \u0026gt; c) AND (c \u0026lt; 5))) 看着就很烦,优化器会把那些用不到的括号给干掉,就是这样: (a = 5 and b = c) OR (a \u0026gt; c AND c \u0026lt; 5)\n常量传递(constant_propagation) # 有时候某个表达式是某个列和某个常量做等值匹配,比如这样: a = 5 当这个表达式和其他涉及列a的表达式使用AND连接起来时,可以将其他表达式中的a的值替换为5,比如这样: a = 5 AND b \u0026gt; a 就可以被转换为: a = 5 AND b \u0026gt; 5 小贴士:为什么用OR连接起来的表达式就不能进行常量传递呢?自己想想~\n等值传递(equality_propagation) # 有时候多个列之间存在等值匹配的关系,比如这样: a = b and b = c and c = 5 这个表达式可以被简化为: a = 5 and b = 5 and c = 5\n移除没用的条件(trivial_condition_removal) # 对于一些明显永远为TRUE或者FALSE的表达式,优化器会移除掉它们,比如这个表达式: (a \u0026lt; 1 and b = b) OR (a = 6 OR 5 != 5) 很明显,b = b这个表达式永远为TRUE,5 != 5这个表达式永远为FALSE,所以简化后的表达式就是这样的: (a \u0026lt; 1 and TRUE) OR (a = 6 OR FALSE) 可以继续被简化为: a \u0026lt; 1 OR a = 6\n表达式计算 # 在查询开始执行之前,如果表达式中只包含常量的话,它的值会被先计算出来,比如这个: a = 5 + 1 因为5 + 1这个表达式只包含常量,所以就会被化简成: a = 6 但是这里需要注意的是,如果某个列并不是以单独的形式作为表达式的操作数时,比如出现在函数中,出现在某个更复杂表达式中,就像这样: ABS(a) \u0026gt; 5 或者: -a \u0026lt; -8 优化器是不会尝试对这些表达式进行化简的。我们前面说过只有搜索条件中索引列和常数使用某些运算符连接起来才可能使用到索引,所以如果可以的话,最好让索引列以单独的形式出现在表达式中。\nHAVING子句和WHERE子句的合并 # 如果查询语句中没有出现诸如SUM、MAX等等的聚集函数以及GROUP BY子句,优化器就把HAVING子句和WHERE子句合并起来。\n常量表检测 # 设计MySQL的大佬觉得下面这两种查询运行的特别快:\n查询的表中一条记录没有,或者只有一条记录。\n小贴士:大家有没有觉得这一条有点儿不对劲,我还没开始查表呢咋就知道这表里边有几条记录呢?这个其实依靠的是统计数据。不过我们说过InnoDB的统计数据数据不准确,所以这一条不能用于使用InnoDB作为存储引擎的表,只能适用于使用Memory或者MyISAM存储引擎的表。\n使用主键等值匹配或者唯一二级索引列等值匹配作为搜索条件来查询某个表。\n设计MySQL的大佬觉得这两种查询花费的时间特别少,少到可以忽略,所以也把通过这两种方式查询的表称之为常量表(英文名:constant tables)。优化器在分析一个查询语句时,先首先执行常量表查询,然后把查询中涉及到该表的条件全部替换成常数,最后再分析其余表的查询成本,比方说这个查询语句: SELECT * FROM table1 INNER JOIN table2 ON table1.column1 = table2.column2 WHERE table1.primary_key = 1; 很明显,这个查询可以使用主键和常量值的等值匹配来查询table1表,也就是在这个查询中table1表相当于常量表,在分析对table2表的查询成本之前,就会执行对table1表的查询,并把查询中涉及table1表的条件都替换掉,也就是上面的语句会被转换成这样: SELECT table1表记录的各个字段的常量值, table2.* FROM table1 INNER JOIN table2 ON table1表column1列的常量值 = table2.column2;\n外连接消除 # 我们前面说过,内连接的驱动表和被驱动表的位置可以相互转换,而左(外)连接和右(外)连接的驱动表和被驱动表是固定的。这就导致内连接可能通过优化表的连接顺序来降低整体的查询成本,而外连接却无法优化表的连接顺序。为了故事的顺利发展,我们还是把之前介绍连接原理时用过的t1和t2表请出来,为了防止大家早就忘掉了,我们再看一下这两个表的结构: ``` CREATE TABLE t1 ( m1 int, n1 char 1)1) Engine=InnoDB, CHARSET=utf8;\nCREATE TABLE t2 ( m2 int, n2 char 1)1) Engine=InnoDB, CHARSET=utf8; 为了唤醒大家的记忆,我们再把这两个表中的数据给展示一下: mysql\u0026gt; SELECT * FROM t1; +\u0026mdash;\u0026mdash;+\u0026mdash;\u0026mdash;+ | m1 | n1 | +\u0026mdash;\u0026mdash;+\u0026mdash;\u0026mdash;+ | 1 | a | | 2 | b | | 3 | c | +\u0026mdash;\u0026mdash;+\u0026mdash;\u0026mdash;+ 3 rows in set (0.00 sec)\nmysql\u0026gt; SELECT * FROM t2; +\u0026mdash;\u0026mdash;+\u0026mdash;\u0026mdash;+ | m2 | n2 | +\u0026mdash;\u0026mdash;+\u0026mdash;\u0026mdash;+ | 2 | b | | 3 | c | | 4 | d | +\u0026mdash;\u0026mdash;+\u0026mdash;\u0026mdash;+ 3 rows in set (0.00 sec) 我们之前说过,**外连接和内连接的本质区别就是:对于外连接的驱动表的记录来说,如果无法在被驱动表中找到匹配ON子句中的过滤条件的记录,那么该记录仍然会被加入到结果集中,对应的被驱动表记录的各个字段使用NULL值填充;而内连接的驱动表的记录如果无法在被驱动表中找到匹配ON子句中的过滤条件的记录,那么该记录会被舍弃**。查询效果就是这样: mysql\u0026gt; SELECT * FROM t1 INNER JOIN t2 ON t1.m1 = t2.m2; +\u0026mdash;\u0026mdash;+\u0026mdash;\u0026mdash;+\u0026mdash;\u0026mdash;+\u0026mdash;\u0026mdash;+ | m1 | n1 | m2 | n2 | +\u0026mdash;\u0026mdash;+\u0026mdash;\u0026mdash;+\u0026mdash;\u0026mdash;+\u0026mdash;\u0026mdash;+ | 2 | b | 2 | b | | 3 | c | 3 | c | +\u0026mdash;\u0026mdash;+\u0026mdash;\u0026mdash;+\u0026mdash;\u0026mdash;+\u0026mdash;\u0026mdash;+ 2 rows in set (0.00 sec)\nmysql\u0026gt; SELECT * FROM t1 LEFT JOIN t2 ON t1.m1 = t2.m2; +\u0026mdash;\u0026mdash;+\u0026mdash;\u0026mdash;+\u0026mdash;\u0026mdash;+\u0026mdash;\u0026mdash;+ | m1 | n1 | m2 | n2 | +\u0026mdash;\u0026mdash;+\u0026mdash;\u0026mdash;+\u0026mdash;\u0026mdash;+\u0026mdash;\u0026mdash;+ | 2 | b | 2 | b | | 3 | c | 3 | c | | 1 | a | NULL | NULL | +\u0026mdash;\u0026mdash;+\u0026mdash;\u0026mdash;+\u0026mdash;\u0026mdash;+\u0026mdash;\u0026mdash;+ 3 rows in set (0.00 sec) ``` 对于上面例子中的(左)外连接来说,由于驱动表t1中m1=1, n1='a'的记录无法在被驱动表t2中找到符合ON子句条件t1.m1 = t2.m2的记录,所以就直接把这条记录加入到结果集,对应的t2表的m2和n2列的值都设置为NULL。\n小贴士:右(外)连接和左(外)连接其实只在驱动表的选取方式上是不同的,其余方面都是一样的,所以优化器会首先把右(外)连接查询转换成左(外)连接查询。我们后边就不再介绍右(外)连接了。 我们知道WHERE子句的杀伤力比较大,凡是不符合WHERE子句中条件的记录都不会参与连接。只要我们在搜索条件中指定关于被驱动表相关列的值不为NULL,那么外连接中在被驱动表中找不到符合ON子句条件的驱动表记录也就被排除出最后的结果集了,也就是说:在这种情况下:外连接和内连接也就没有什么区别了!比方说这个查询: mysql\u0026gt; SELECT * FROM t1 LEFT JOIN t2 ON t1.m1 = t2.m2 WHERE t2.n2 IS NOT NULL; +------+------+------+------+ | m1 | n1 | m2 | n2 | +------+------+------+------+ | 2 | b | 2 | b | | 3 | c | 3 | c | +------+------+------+------+ 2 rows in set (0.01 sec) 由于指定了被驱动表t2的n2列不允许为NULL,所以上面的t1和t2表的左(外)连接查询和内连接查询是一样一样的。当然,我们也可以不用显式的指定被驱动表的某个列IS NOT NULL,只要隐含的有这个意思就行了,比方说这样: mysql\u0026gt; SELECT * FROM t1 LEFT JOIN t2 ON t1.m1 = t2.m2 WHERE t2.m2 = 2; +------+------+------+------+ | m1 | n1 | m2 | n2 | +------+------+------+------+ | 2 | b | 2 | b | +------+------+------+------+ 1 row in set (0.00 sec) 在这个例子中,我们在WHERE子句中指定了被驱动表t2的m2列等于2,也就相当于间接的指定了m2列不为NULL值,所以上面的这个左(外)连接查询其实和下面这个内连接查询是等价的: mysql\u0026gt; SELECT * FROM t1 INNER JOIN t2 ON t1.m1 = t2.m2 WHERE t2.m2 = 2; +------+------+------+------+ | m1 | n1 | m2 | n2 | +------+------+------+------+ | 2 | b | 2 | b | +------+------+------+------+ 1 row in set (0.00 sec) 我们把这种在外连接查询中,指定的WHERE子句中包含被驱动表中的列不为NULL值的条件称之为空值拒绝(英文名:reject-NULL)。在被驱动表的WHERE子句符合空值拒绝的条件后,外连接和内连接可以相互转换。这种转换带来的好处就是查询优化器可以通过评估表的不同连接顺序的成本,选出成本最低的那种连接顺序来执行查询。\n子查询优化 # 我们的主题本来是介绍MySQL查询优化器是如何处理子查询的,但是我还是有一万个担心好多同学连子查询的语法都没掌握全,所以我们就先介绍介绍什么是个子查询(当然不会面面俱到啦,只是说个大概),然后再介绍关于子查询优化的事儿。\n子查询语法 # 想必大家都是妈妈生下来的吧,连孙猴子都有妈妈——石头人。怀孕妈妈肚子里的那个东东就是她的孩子,类似的,在一个查询语句里的某个位置也可以有另一个查询语句,这个出现在某个查询语句的某个位置中的查询就被称为子查询(我们也可以称它为宝宝查询),那个充当“妈妈”角色的查询也被称之为外层查询。不像人们怀孕时宝宝们都只在肚子里,子查询可以在一个外层查询的各种位置出现,比如:\nSELECT子句中\n也就是我们平时说的查询列表中,比如这样:\nmysql\u0026gt; SELECT (SELECT m1 FROM t1 LIMIT 1); +-----------------------------+ | (SELECT m1 FROM t1 LIMIT 1) | +-----------------------------+ | 1 | +-----------------------------+ 1 row in set (0.00 sec) 其中的(SELECT m1 FROM t1 LIMIT 1)就是我们介绍的所谓的子查询。\nFROM子句中\n比如: SELECT m, n FROM (SELECT m2 + 1 AS m, n2 AS n FROM t2 WHERE m2 \u0026gt; 2) AS t; +------+------+ | m | n | +------+------+ | 4 | c | | 5 | d | +------+------+ 2 rows in set (0.00 sec) 这个例子中的子查询是:(SELECT m2 + 1 AS m, n2 AS n FROM t2 WHERE m2 \u0026gt; 2),很特别的地方是它出现在了FROM子句中。FROM子句里边儿不是存放我们要查询的表的名称么,这里放进来一个子查询是个什么鬼?其实这里我们可以把子查询的查询结果当作是一个表,子查询后边的AS t表明这个子查询的结果就相当于一个名称为t的表,这个名叫t的表的列就是子查询结果中的列,比如例子中表t就有两个列:m列和n列。这个放在FROM子句中的子查询本质上相当于一个表,但又和我们平常使用的表有点儿不一样,设计MySQL的大佬把这种由子查询结果集组成的表称之为派生表。\nWHERE或ON子句中\n把子查询放在外层查询的WHERE子句或者ON子句中可能是我们最常用的一种使用子查询的方式了,比如这样:\nmysql\u0026gt; SELECT * FROM t1 WHERE m1 IN (SELECT m2 FROM t2); +------+------+ | m1 | n1 | +------+------+ | 2 | b | | 3 | c | +------+------+ 2 rows in set (0.00 sec) 这个查询表明我们想要将(SELECT m2 FROM t2)这个子查询的结果作为外层查询的IN语句参数,整个查询语句的意思就是我们想找t1表中的某些记录,这些记录的m1列的值能在t2表的m2列找到匹配的值。\nORDER BY子句中\n虽然语法支持,但没什么子意义,不介绍这种情况了。\nGROUP BY子句中\n同上~\n按返回的结果集区分子查询 # 因为子查询本身也算是一个查询,所以可以按照它们返回的不同结果集类型而把这些子查询分为不同的类型:\n标量子查询\n那些只返回一个单一值的子查询称之为标量子查询,比如这样:\nSELECT (SELECT m1 FROM t1 LIMIT 1); 或者这样: SELECT * FROM t1 WHERE m1 = (SELECT MIN(m2) FROM t2);\n这两个查询语句中的子查询都返回一个单一的值,也就是一个标量。这些标量子查询可以作为一个单一值或者表达式的一部分出现在查询语句的各个地方。\n行子查询\n顾名思义,就是返回一条记录的子查询,不过这条记录需要包含多个列(只包含一个列就成了标量子查询了)。比如这样:\nSELECT * FROM t1 WHERE (m1, n1) = (SELECT m2, n2 FROM t2 LIMIT 1); 其中的(SELECT m2, n2 FROM t2 LIMIT 1)就是一个行子查询,整条语句的含义就是要从t1表中找一些记录,这些记录的m1和n2列分别等于子查询结果中的m2和n2列。\n列子查询\n列子查询自然就是查询出一个列的数据喽,不过这个列的数据需要包含多条记录(只包含一条记录就成了标量子查询了)。比如这样:\nSELECT * FROM t1 WHERE m1 IN (SELECT m2 FROM t2); 其中的(SELECT m2 FROM t2)就是一个列子查询,表明查询出t2表的m2列的值作为外层查询IN语句的参数。\n表子查询\n顾名思义,就是子查询的结果既包含很多条记录,又包含很多个列,比如这样:\nSELECT * FROM t1 WHERE (m1, n1) IN (SELECT m2, n2 FROM t2); 其中的(SELECT m2, n2 FROM t2)就是一个表子查询,这里需要和行子查询对比一下,行子查询中我们用了LIMIT 1来保证子查询的结果只有一条记录,表子查询中不需要这个限制。\n按与外层查询关系来区分子查询 # 不相关子查询\n如果子查询可以单独运行出结果,而不依赖于外层查询的值,我们就可以把这个子查询称之为不相关子查询。我们前面介绍的那些子查询全部都可以看作不相关子查询,所以也就不举例子了。\n相关子查询\n如果子查询的执行需要依赖于外层查询的值,我们就可以把这个子查询称之为相关子查询。比如:\nSELECT * FROM t1 WHERE m1 IN (SELECT m2 FROM t2 WHERE n1 = n2); 例子中的子查询是(SELECT m2 FROM t2 WHERE n1 = n2),可是这个查询中有一个搜索条件是n1 = n2,别忘了n1是表t1的列,也就是外层查询的列,也就是说子查询的执行需要依赖于外层查询的值,所以这个子查询就是一个相关子查询。\n子查询在布尔表达式中的使用 # 你说写下面这样的子查询有什么意义: SELECT (SELECT m1 FROM t1 LIMIT 1); 貌似没什么意义~ 我们平时用子查询最多的地方就是把它作为布尔表达式的一部分来作为搜索条件用在WHERE子句或者ON子句里。所以我们这里来总结一下子查询在布尔表达式中的使用场景。\n使用=、\u0026gt;、\u0026lt;、\u0026gt;=、\u0026lt;=、\u0026lt;\u0026gt;、!=、\u0026lt;=\u0026gt;作为布尔表达式的操作符\n这些操作符具体是什么意思就不用我多介绍了吧,如果你不知道的话,那我真的很佩服你是靠着什么勇气一口气看到这里的~ 为了方便,我们就把这些操作符称为comparison_operator吧,所以子查询组成的布尔表达式就长这样:\n操作数 comparison_operator (子查询)\n这里的操作数可以是某个列名,或者是一个常量,或者是一个更复杂的表达式,甚至可以是另一个子查询。但是需要注意的是,这里的子查询只能是标量子查询或者行子查询,也就是子查询的结果只能返回一个单一的值或者只能是一条记录。比如这样(标量子查询):\nSELECT * FROM t1 WHERE m1 \u0026lt; (SELECT MIN(m2) FROM t2); 或者这样(行子查询): SELECT * FROM t1 WHERE (m1, n1) = (SELECT m2, n2 FROM t2 LIMIT 1);\n[NOT] IN/ANY/SOME/ALL子查询\n对于列子查询和表子查询来说,它们的结果集中包含很多条记录,这些记录相当于是一个集合,所以就不能单纯的和另外一个操作数使用comparison_operator来组成布尔表达式了,MySQL通过下面的语法来支持某个操作数和一个集合组成一个布尔表达式:\n+ IN或者NOT IN\n具体的语法形式如下:\n操作数 [NOT] IN (子查询)\n这个布尔表达式的意思是用来判断某个操作数在不在由子查询结果集组成的集合中,比如下面的查询的意思是找出t1表中的某些记录,这些记录存在于子查询的结果集中:\nSELECT * FROM t1 WHERE (m1, n2) IN (SELECT m2, n2 FROM t2);\n+ ANY/SOME(ANY和SOME是同义词)\n具体的语法形式如下:\n操作数 comparison_operator ANY/SOME(子查询)\n这个布尔表达式的意思是只要子查询结果集中存在某个值和给定的操作数做comparison_operator比较结果为TRUE,那么整个表达式的结果就为TRUE,否则整个表达式的结果就为FALSE。比方说下面这个查询:\nSELECT * FROM t1 WHERE m1 \u0026gt; ANY(SELECT m2 FROM t2); 这个查询的意思就是对于t1表的某条记录的m1列的值来说,如果子查询(SELECT m2 FROM t2)的结果集中存在一个小于m1列的值,那么整个布尔表达式的值就是TRUE,否则为FALSE,也就是说只要m1列的值大于子查询结果集中最小的值,整个表达式的结果就是TRUE,所以上面的查询本质上等价于这个查询:\nSELECT * FROM t1 WHERE m1 \u0026gt; (SELECT MIN(m2) FROM t2);\n另外,=ANY相当于判断子查询结果集中是否存在某个值和给定的操作数相等,它的含义和IN是相同的。\n+ ALL\n具体的语法形式如下: 操作数 comparison_operator ALL(子查询)\n这个布尔表达式的意思是子查询结果集中所有的值和给定的操作数做comparison_operator比较结果为TRUE,那么整个表达式的结果就为TRUE,否则整个表达式的结果就为FALSE。比方说下面这个查询:\nSELECT * FROM t1 WHERE m1 \u0026gt; ALL(SELECT m2 FROM t2); 这个查询的意思就是对于t1表的某条记录的m1列的值来说,如果子查询(SELECT m2 FROM t2)的结果集中的所有值都小于m1列的值,那么整个布尔表达式的值就是TRUE,否则为FALSE,也就是说只要m1列的值大于子查询结果集中最大的值,整个表达式的结果就是TRUE,所以上面的查询本质上等价于这个查询:\n``小贴士:觉得ANY和ALL有点晕的同学多看两遍。 ``` + EXISTS子查询 有的时候我们仅仅需要判断子查询的结果集中是否有记录,而不在乎它的记录具体是什么,可以使用把`EXISTS`或者`NOT EXISTS`放在子查询语句前面,就像这样: `[NOT] EXISTS (子查询)` 我们举一个例子啊: `SELECT * FROM t1 WHERE EXISTS (SELECT 1 FROM t2);` 对于子查询`(SELECT 1 FROM t2)`来说,我们并不关心这个子查询最后到底查询出的结果是什么,所以查询列表里填`*`、某个列名,或者其他什么东西都无所谓,我们真正关心的是子查询的结果集中是否存在记录。也就是说只要`(SELECT 1 FROM t2)`这个查询中有记录,那么整个`EXISTS`表达式的结果就为`TRUE`。 ### 子查询语法注意事项 + 子查询必须用小括号扩起来。 不扩起来的子查询是非法的,比如这样: ``` mysql\u0026gt; SELECT SELECT m1 FROM t1; ERROR 1064 (42000): You have an error in your SQL syntax; check the manual that corresponds to your MySQL server version for the right syntax to use near \u0026#39;SELECT m1 FROM t1\u0026#39; at line 1 ``` + 在`SELECT`子句中的子查询必须是标量子查询。 如果子查询结果集中有多个列或者多个行,都不允许放在`SELECT`子句中,也就是查询列表中,比如这样就是非法的: ``` mysql\u0026gt; SELECT (SELECT m1, n1 FROM t1); ERROR 1241 (21000): Operand should contain 1 column(s) ``` - 在想要得到标量子查询或者行子查询,但又不能保证子查询的结果集只有一条记录时,应该使用`LIMIT 1`语句来限制记录数量。 + 对于`[NOT] IN/ANY/SOME/ALL`子查询来说,子查询中不允许有`LIMIT`语句。 比如这样是非法的: ``` mysql\u0026gt; SELECT * FROM t1 WHERE m1 IN (SELECT * FROM t2 LIMIT 2); ERROR 1235 (42000): This version of MySQL doesn\u0026#39;t yet support \u0026#39;LIMIT \u0026amp; IN/ALL/ANY/SOME subquery\u0026#39; ``` 为什么不合法?人家就这么规定的,不解释~ 可能以后的版本会支持吧。正因为`[NOT] IN/ANY/SOME/ALL`子查询不支持`LIMIT`语句,所以子查询中的这些语句也就是多余的了: + `ORDER BY`子句 子查询的结果其实就相当于一个集合,集合里的值排不排序一点儿都不重要,比如下面这个语句中的`ORDER BY`子句简直就是画蛇添足: `SELECT * FROM t1 WHERE m1 IN (SELECT m2 FROM t2 ORDER BY m2);` + `DISTINCT`语句 集合里的值去不去重也没什么意义,比如这样: `SELECT * FROM t1 WHERE m1 IN (SELECT DISTINCT m2 FROM t2);` + 没有聚集函数以及`HAVING`子句的`GROUP BY`子句。 在没有聚集函数以及`HAVING`子句时,`GROUP BY`子句就是个摆设,比如这样: `SELECT * FROM t1 WHERE m1 IN (SELECT m2 FROM t2 GROUP BY m2);` 对于这些冗余的语句,**查询优化器在一开始就把它们给干掉了**。 + 不允许在一条语句中增删改某个表的记录时同时还对该表进行子查询。 比方说这样: ``` mysql\u0026gt; DELETE FROM t1 WHERE m1 \u0026lt; (SELECT MAX\\(m1) FROM t1\\); ERROR 1093 (HY000): You can\u0026#39;t specify target table \u0026#39;t1\u0026#39; for update in FROM clause ``` ## 子查询在MySQL中是怎么执行的 好了,关于子查询的基础语法我们用最快的速度温习了一遍,如果想了解更多语法细节,大家可以去查看一下`MySQL`的文档,现在我们就假设各位都懂了什么是个子查询了喔,接下来就要介绍具体某种类型的子查询在`MySQL`中是怎么执行的了,想想就有点儿小激动呢~ 当然,为了故事的顺利发展,我们的例子也需要跟随形势鸟枪换炮,还是要祭出我们用了n遍的`single_table`表: `CREATE TABLE single_table ( id INT NOT NULL AUTO_INCREMENT, key1 VARCHAR(100), key2 INT, key3 VARCHAR(100), key_part1 VARCHAR(100), key_part2 VARCHAR(100), key_part3 VARCHAR(100), common_field VARCHAR(100), PRIMARY KEY (id), KEY idx_key1 (key1), UNIQUE KEY idx_key2 (key2), KEY idx_key3 (key3), KEY idx_key_part(key_part1, key_part2, key_part3) ) Engine=InnoDB CHARSET=utf8;` 为了方便,我们假设有两个表`s1`、`s2`与这个`single_table`表的构造是相同的,而且这两个表里边儿有10000条记录,除id列外其余的列都插入随机值。下面正式开始我们的表演。 ### 小白们眼中子查询的执行方式 在我还是一个单纯无知的少年时,觉得子查询的执行方式是这样的: + 如果该子查询是不相关子查询,比如下面这个查询: `SELECT * FROM s1 WHERE key1 IN (SELECT common_field FROM s2);` 我年少时觉得这个查询是的执行方式是这样的: + 先单独执行`(SELECT common_field FROM s2)`这个子查询。 + 然后在将上一步子查询得到的结果当作外层查询的参数再执行外层查询`SELECT * FROM s1 WHERE key1 IN (...)`。 + 如果该子查询是相关子查询,比如下面这个查询: `SELECT * FROM s1 WHERE key1 IN (SELECT common_field FROM s2 WHERE s1.key2 = s2.key2);` 这个查询中的子查询中出现了`s1.key2 = s2.key2`这样的条件,意味着该子查询的执行依赖着外层查询的值,所以我年少时觉得这个查询的执行方式是这样的: + 先从外层查询中获取一条记录,本例中也就是先从`s1`表中获取一条记录。 + 然后从上一步骤中获取的那条记录中找出子查询中涉及到的值,本例中就是从`s1`表中获取的那条记录中找出`s1.key2`列的值,然后执行子查询。 + 最后根据子查询的查询结果来检测外层查询`WHERE`子句的条件是否成立,如果成立,就把外层查询的那条记录加入到结果集,否则就丢弃。 + 再次执行第一步,获取第二条外层查询中的记录,依次类推~ 告诉我不只是我一个人是这样认为的,这样认为的同学请举起你们的双手~~~ 哇唔,还真不少~ 其实设计`MySQL`的大佬想了一系列的办法来优化子查询的执行,大部分情况下这些优化措施其实挺有效的,但是保不齐有的时候马失前蹄,下面我们详细介绍各种不同类型的子查询具体是怎么执行的。 `小贴士:我们下面即将介绍的关于MySQL优化子查询的执行方式的事儿都是基于MySQL5.7这个版本的,以后版本可能有更新的优化策略!` ### 标量子查询、行子查询的执行方式 我们经常在下面两个场景中使用到标量子查询或者行子查询: + `SELECT`子句中,我们前面说过的在查询列表中的子查询必须是标量子查询。 + 子查询使用`=`、`\u0026gt;`、`\u0026lt;`、`\u0026gt;=`、`\u0026lt;=`、`\u0026lt;\u0026gt;`、`!=`、`\u0026lt;=\u0026gt;`等操作符和某个操作数组成一个布尔表达式,这样的子查询必须是标量子查询或者行子查询。 对于上述两种场景中的**不相关**标量子查询或者行子查询来说,它们的执行方式是简单的,比方说下面这个查询语句: `SELECT * FROM s1 WHERE key1 = (SELECT common_field FROM s2 WHERE key3 = \u0026#39;a\u0026#39; LIMIT 1);` 它的执行方式和年少的我想的一样: + 先单独执行`(SELECT common_field FROM s2 WHERE key3 = \u0026#39;a\u0026#39; LIMIT 1)`这个子查询。 + 然后在将上一步子查询得到的结果当作外层查询的参数再执行外层查询`SELECT * FROM s1 WHERE key1 = ...`。 也就是说,**对于包含不相关的标量子查询或者行子查询的查询语句来说,MySQL会分别独立的执行外层查询和子查询,就当作两个单表查询就好了**。 对于**相关**的标量子查询或者行子查询来说,比如下面这个查询: `SELECT * FROM s1 WHERE key1 = (SELECT common_field FROM s2 WHERE s1.key3 = s2.key3 LIMIT 1);` 事情也和年少的我想的一样,它的执行方式就是这样的: + 先从外层查询中获取一条记录,本例中也就是先从`s1`表中获取一条记录。 + 然后从上一步骤中获取的那条记录中找出子查询中涉及到的值,本例中就是从`s1`表中获取的那条记录中找出`s1.key3`列的值,然后执行子查询。 + 最后根据子查询的查询结果来检测外层查询`WHERE`子句的条件是否成立,如果成立,就把外层查询的那条记录加入到结果集,否则就丢弃。 + 再次执行第一步,获取第二条外层查询中的记录,依次类推~ 也就是说对于一开始介绍的两种使用标量子查询以及行子查询的场景中,`MySQL`优化器的执行方式并没有什么新鲜的。 ### IN子查询优化 #### 物化表的提出 对于不相关的`IN`子查询,比如这样: `SELECT * FROM s1 WHERE key1 IN (SELECT common_field FROM s2 WHERE key3 = \u0026#39;a\u0026#39;);` 我们最开始的感觉就是这种不相关的`IN`子查询和不相关的标量子查询或者行子查询是一样一样的,都是把外层查询和子查询当作两个独立的单表查询来对待,可是很遗憾的是设计`MySQL`的大佬为了优化`IN`子查询倾注了太多心血(毕竟`IN`子查询是我们日常生活中最常用的子查询类型),所以整个执行过程并不像我们想象的那么简单(\u0026gt;_\u0026lt;)。 其实说句老实话,对于不相关的`IN`子查询来说,如果子查询的结果集中的记录条数很少,那么把子查询和外层查询分别看成两个单独的单表查询效率还是蛮高的,但是如果单独执行子查询后的结果集太多的话,就会导致这些问题: + 结果集太多,可能内存中都放不下~ + 对于外层查询来说,如果子查询的结果集太多,那就意味着`IN`子句中的参数特别多,这就导致: + 无法有效的使用索引,只能对外层查询进行全表扫描。 + 在对外层查询执行全表扫描时,由于`IN`子句中的参数太多,这会导致检测一条记录是否符合和`IN`子句中的参数匹配花费的时间太长。 比如说`IN`子句中的参数只有两个: `SELECT * FROM tbl_name WHERE column IN (a, b);` 这样相当于需要对`tbl_name`表中的每条记录判断一下它的`column`列是否符合`column = a OR column = b`。在`IN`子句中的参数比较少时这并不是什么问题,如果`IN`子句中的参数比较多时,比如这样: `SELECT * FROM tbl_name WHERE column IN (a, b, c ..., ...);` 那么这样每条记录需要判断一下它的`column`列是否符合`column = a OR column = b OR column = c OR ...`,这样性能耗费可就多了。 于是乎设计`MySQL`的大佬想了一个招:**不直接将不相关子查询的结果集当作外层查询的参数,而是将该结果集写入一个临时表里**。写入临时表的过程是这样的: + 该临时表的列就是子查询结果集中的列。 + 写入临时表的记录会被去重。 我们说`IN`语句是判断某个操作数在不在某个集合中,集合中的值重不重复对整个`IN`语句的结果并没有什么子关系,所以我们在将结果集写入临时表时对记录进行去重可以让临时表变得更小,更省地方~ `小贴士:临时表如何对记录进行去重?这不是小意思嘛,临时表也是个表,只要为表中记录的所有列建立主键或者唯一索引就好了嘛~` + 一般情况下子查询结果集不会大的离谱,所以会为它建立基于内存的使用`Memory`存储引擎的临时表,而且会为该表建立希索引。 `小贴士:IN语句的本质就是判断某个操作数在不在某个集合里,如果集合中的数据建立了哈希索引,那么这个匹配的过程就是超级快的。 有同学不知道哈希索引是什么?我这里就不展开了,自己上网找找吧,不会了再来问我~` 如果子查询的结果集非常大,超过了系统变量`tmp_table_size`或者`max_heap_table_size`,临时表会转而使用基于磁盘的存储引擎来保存结果集中的记录,索引类型也对应转变为`B+`树索引。 设计`MySQL`的大佬把这个将子查询结果集中的记录保存到临时表的过程称之为`物化`(英文名:`Materialize`)。为了方便起见,我们就把那个存储子查询结果集的临时表称之为`物化表`。正因为物化表中的记录都建立了索引(基于内存的物化表有哈希索引,基于磁盘的有B\\+树索引),通过索引执行`IN`语句判断某个操作数在不在子查询结果集中变得非常快,从而提升了子查询语句的性能。 #### 物化表转连接 事情到这就完了?我们还得重新审视一下最开始的那个查询语句: `SELECT * FROM s1 WHERE key1 IN (SELECT common_field FROM s2 WHERE key3 = \u0026#39;a\u0026#39;);` 当我们把子查询进行物化之后,假设子查询物化表的名称为`materialized_table`,该物化表存储的子查询结果集的列为`m_val`,那么这个查询其实可以从下面两种角度来看待: + 从表`s1`的角度来看待,整个查询的意思其实是:对于`s1`表中的每条记录来说,如果该记录的`key1`列的值在子查询对应的物化表中,则该记录会被加入最终的结果集。画个图表示一下就是这样: ![](img/14-01.png) + 从子查询物化表的角度来看待,整个查询的意思其实是:对于子查询物化表的每个值来说,如果能在`s1`表中找到对应的`key1`列的值与该值相等的记录,那么就把这些记录加入到最终的结果集。画个图表示一下就是这样: ![](img/14-02.png) 也就是说其实上面的查询就相当于表`s1`和子查询物化表`materialized_table`进行内连接: `SELECT s1.* FROM s1 INNER JOIN materialized_table ON key1 = m_val;` 转化成内连接之后就有意思了,查询优化器可以评估不同连接顺序需要的成本是多少,选取成本最低的那种查询方式执行查询。我们分析一下上述查询中使用外层查询的表`s1`和物化表`materialized_table`进行内连接的成本都是由哪几部分组成的: + 如果使用`s1`表作为驱动表的话,总查询成本由下面几个部分组成: + 物化子查询时需要的成本 + 扫描`s1`表时的成本 + s1表中的记录数量 × 通过`m_val = xxx`对`materialized_table`表进行单表访问的成本(我们前面说过物化表中的记录是不重复的,并且为物化表中的列建立了索引,所以这个步骤显然是非常快的)。 + 如果使用`materialized_table`表作为驱动表的话,总查询成本由下面几个部分组成: + 物化子查询时需要的成本 + 扫描物化表时的成本 + 物化表中的记录数量 × 通过`key1 = xxx`对`s1`表进行单表访问的成本(非常庆幸`key1`列上建立了索引,所以这个步骤是非常快的)。 `MySQL`查询优化器会通过运算来选择上述成本更低的方案来执行查询。 #### 将子查询转换为semi-join 虽然将子查询进行物化之后再执行查询都会有建立临时表的成本,但是不管怎么说,我们见识到了将子查询转换为连接的强大作用,设计`MySQL`的大佬继续开脑洞:能不能不进行物化操作直接把子查询转换为连接呢?让我们重新审视一下上面的查询语句: `SELECT * FROM s1 WHERE key1 IN (SELECT common_field FROM s2 WHERE key3 = \u0026#39;a\u0026#39;);` 我们可以把这个查询理解成:对于`s1`表中的某条记录,如果我们能在`s2`表(准确的说是执行完`WHERE s2.key3 = \u0026#39;a\u0026#39;`之后的结果集)中找到一条或多条记录,这些记录的`common_field`的值等于`s1`表记录的`key1`列的值,那么该条`s1`表的记录就会被加入到最终的结果集。这个过程其实和把`s1`和`s2`两个表连接起来的效果很像: `SELECT s1.* FROM s1 INNER JOIN s2 ON s1.key1 = s2.common_field WHERE s2.key3 = \u0026#39;a\u0026#39;;` 只不过我们不能保证对于`s1`表的某条记录来说,在`s2`表(准确的说是执行完`WHERE s2.key3 = \u0026#39;a\u0026#39;`之后的结果集)中有多少条记录满足`s1.key1 = s2.common_field`这个条件,不过我们可以分三种情况讨论: + 情况一:对于`s1`表的某条记录来说,`s2`表中**没有**任何记录满足`s1.key1 = s2.common_field`这个条件,那么该记录自然也不会加入到最后的结果集。 + 情况二:对于`s1`表的某条记录来说,`s2`表中**有且只有**记录满足`s1.key1 = s2.common_field`这个条件,那么该记录会被加入最终的结果集。 + 情况三:对于`s1`表的某条记录来说,`s2`表中**至少有2条**记录满足`s1.key1 = s2.common_field`这个条件,那么该记录会被**多次**加入最终的结果集。 对于`s1`表的某条记录来说,由于我们只关心`s2`表中**是否存在**记录满足`s1.key1 = s2.common_field`这个条件,而**不关心具体有多少条记录与之匹配**,又因为有`情况三`的存在,我们上面所说的`IN`子查询和两表连接之间并不完全等价。但是将子查询转换为连接又真的可以充分发挥优化器的作用,所以设计`MySQL`的大佬在这里提出了一个新概念 --- `半连接`(英文名:`semi-join`)。将`s1`表和`s2`表进行半连接的意思就是:**对于`s1`表的某条记录来说,我们只关心在`s2`表中是否存在与之匹配的记录是否存在,而不关心具体有多少条记录与之匹配,最终的结果集中只保留`s1`表的记录**。为了让大家有更直观的感受,我们假设MySQL内部是这么改写上面的子查询的: `SELECT s1.* FROM s1 SEMI JOIN s2 ON s1.key1 = s2.common_field WHERE key3 = \u0026#39;a\u0026#39;;` `小贴士:semi-join只是在MySQL内部采用的一种执行子查询的方式,MySQL并没有提供面向用户的semi-join语法,所以我们不需要,也不能尝试把上面这个语句放到黑框框里运行,我只是想说明一下上面的子查询在MySQL内部会被转换为类似上面语句的半连接~` 概念是有了,怎么实现这种所谓的`半连接`呢?设计`MySQL`的大佬准备了好几种办法。 + Table pullout (子查询中的表上拉) 当**子查询的查询列表处只有主键或者唯一索引列**时,可以直接把子查询中的表`上拉`到外层查询的`FROM`子句中,并把子查询中的搜索条件合并到外层查询的搜索条件中,比如这个 `SELECT * FROM s1 WHERE key2 IN (SELECT key2 FROM s2 WHERE key3 = \u0026#39;a\u0026#39;);` 由于`key2`列是`s2`表的唯一二级索引列,所以我们可以直接把`s2`表上拉到外层查询的`FROM`子句中,并且把子查询中的搜索条件合并到外层查询的搜索条件中,上拉之后的查询就是这样的: `SELECT s1.* FROM s1 INNER JOIN s2 ON s1.key2 = s2.key2 WHERE s2.key3 = \u0026#39;a\u0026#39;;` 为什么当子查询的查询列表处只有主键或者唯一索引列时,就可以直接将子查询转换为连接查询呢?哎呀,主键或者唯一索引列中的数据本身就是不重复的嘛!所以对于同一条`s1`表中的记录,你不可能找到两条以上的符合`s1.key2 = s2.key2`的记录呀~ + DuplicateWeedout execution strategy (重复值消除) 对于这个查询来说: `SELECT * FROM s1 WHERE key1 IN (SELECT common_field FROM s2 WHERE key3 = \u0026#39;a\u0026#39;);` 转换为半连接查询后,`s1`表中的某条记录可能在`s2`表中有多条匹配的记录,所以该条记录可能多次被添加到最后的结果集中,为了消除重复,我们可以建立一个临时表,比方说这个临时表长这样: `CREATE TABLE tmp ( id PRIMARY KEY );` 这样在执行连接查询的过程中,每当某条`s1`表中的记录要加入结果集时,就首先把这条记录的`id`值加入到这个临时表里,如果添加成功,说明之前这条`s1`表中的记录并没有加入最终的结果集,现在把该记录添加到最终的结果集;如果添加失败,说明这条之前这条`s1`表中的记录已经加入过最终的结果集,这里直接把它丢弃就好了,这种使用临时表消除`semi-join`结果集中的重复值的方式称之为`DuplicateWeedout`。 + LooseScan execution strategy (松散索引扫描) 大家看这个查询: `SELECT * FROM s1 WHERE key3 IN (SELECT key1 FROM s2 WHERE key1 \u0026gt; \u0026#39;a\u0026#39; AND key1 \u0026lt; \u0026#39;b\u0026#39;);` 在子查询中,对于`s2`表的访问可以使用到`key1`列的索引,而恰好子查询的查询列表处就是`key1`列,这样在将该查询转换为半连接查询后,如果将`s2`作为驱动表执行查询的话,那么执行过程就是这样: ![](img/14-03.png) 如图所示,在`s2`表的`idx_key1`索引中,值为`\u0026#39;aa\u0026#39;`的二级索引记录一共有3条,那么只需要取第一条的值到`s1`表中查找`s1.key3 = \u0026#39;aa\u0026#39;`的记录,如果能在`s1`表中找到对应的记录,那么就把对应的记录加入到结果集。依此类推,其他值相同的二级索引记录,也只需要取第一条记录的值到`s1`表中找匹配的记录,这种虽然是扫描索引,但只取值相同的记录的第一条去做匹配操作的方式称之为`松散索引扫描`。 + Semi-join Materialization execution strategy 我们之前介绍的先把外层查询的`IN`子句中的不相关子查询进行物化,然后再进行外层查询的表和物化表的连接本质上也算是一种`semi-join`,只不过由于物化表中没有重复的记录,所以可以直接将子查询转为连接查询。 + FirstMatch execution strategy (首次匹配) `FirstMatch`是一种最原始的半连接执行方式,跟我们年少时认为的相关子查询的执行方式是一样一样的,就是说先取一条外层查询的中的记录,然后到子查询的表中寻找符合匹配条件的记录,如果能找到一条,则将该外层查询的记录放入最终的结果集并且停止查找更多匹配的记录,如果找不到则把该外层查询的记录丢弃掉;然后再开始取下一条外层查询中的记录,重复上面这个过程。 对于某些使用`IN`语句的**相关**子查询,比方这个查询: `SELECT * FROM s1 WHERE key1 IN (SELECT common_field FROM s2 WHERE s1.key3 = s2.key3);` 它也可以很方便的转为半连接,转换后的语句类似这样: `SELECT s1.* FROM s1 SEMI JOIN s2 ON s1.key1 = s2.common_field AND s1.key3 = s2.key3;` 然后就可以使用我们上面介绍过的`DuplicateWeedout`、`LooseScan`、`FirstMatch`等半连接执行策略来执行查询,当然,如果子查询的查询列表处只有主键或者唯一二级索引列,还可以直接使用`table pullout`的策略来执行查询,但是需要大家注意的是,**由于相关子查询并不是一个独立的查询,所以不能转换为物化表来执行查询**。 #### semi-join的适用条件 当然,并不是所有包含`IN`子查询的查询语句都可以转换为`semi-join`,只有形如这样的查询才可以被转换为`semi-join`: ``` SELECT ... FROM outer_tables WHERE expr IN (SELECT ... FROM inner_tables ...) AND ... `或者这样的形式也可以:` SELECT ... FROM outer_tables WHERE (oe1, oe2, ...) IN (SELECT ie1, ie2, ... FROM inner_tables ...) AND ... ``` 用文字总结一下,只有符合下面这些条件的子查询才可以被转换为`semi-join`: + 该子查询必须是和`IN`语句组成的布尔表达式,并且在外层查询的`WHERE`或者`ON`子句中出现。 + 外层查询也可以有其他的搜索条件,只不过和`IN`子查询的搜索条件必须使用`AND`连接起来。 + 该子查询必须是一个单一的查询,不能是由若干查询由`UNION`连接起来的形式。 + 该子查询不能包含`GROUP BY`或者`HAVING`语句或者聚集函数。 + ... 还有一些条件比较少见,就不介绍啦~ #### 不适用于semi-join的情况 对于一些不能将子查询转位`semi-join`的情况,典型的比如下面这几种: + 外层查询的WHERE条件中有其他搜索条件与IN子查询组成的布尔表达式使用`OR`连接起来 `SELECT * FROM s1 WHERE key1 IN (SELECT common_field FROM s2 WHERE key3 = \u0026#39;a\u0026#39;) OR key2 \u0026gt; 100;` + 使用`NOT IN`而不是`IN`的情况 `SELECT * FROM s1 WHERE key1 NOT IN (SELECT common_field FROM s2 WHERE key3 = \u0026#39;a\u0026#39;)` + 在`SELECT`子句中的IN子查询的情况 `SELECT key1 IN (SELECT common_field FROM s2 WHERE key3 = \u0026#39;a\u0026#39;) FROM s1 ;` + 子查询中包含`GROUP BY`、`HAVING`或者聚集函数的情况 `SELECT * FROM s1 WHERE key2 IN (SELECT COUNT(*) FROM s2 GROUP BY key1);` + 子查询中包含`UNION`的情况 `SELECT * FROM s1 WHERE key1 IN ( SELECT common_field FROM s2 WHERE key3 = \u0026#39;a\u0026#39; UNION SELECT common_field FROM s2 WHERE key3 = \u0026#39;b\u0026#39; );` `MySQL`仍然留了两手绝活来优化不能转为`semi-join`查询的子查询,那就是: + 对于不相关子查询来说,可以尝试把它们物化之后再参与查询 比如我们上面提到的这个查询: `SELECT * FROM s1 WHERE key1 NOT IN (SELECT common_field FROM s2 WHERE key3 = \u0026#39;a\u0026#39;)` 先将子查询物化,然后再判断`key1`是否在物化表的结果集中可以加快查询执行的速度。 `小贴士:请注意这里将子查询物化之后不能转为和外层查询的表的连接,只能是先扫描s1表,然后对s1表的某条记录来说,判断该记录的key1值在不在物化表中。` + 不管子查询是相关的还是不相关的,都可以把`IN`子查询尝试专为`EXISTS`子查询 其实对于任意一个IN子查询来说,都可以被转为`EXISTS`子查询,通用的例子如下: `outer_expr IN (SELECT inner_expr FROM ... WHERE subquery_where)` 可以被转换为: `EXISTS (SELECT inner_expr FROM ... WHERE subquery_where AND outer_expr=inner_expr)` 当然这个过程中有一些特殊情况,比如在`outer_expr`或者`inner_expr`值为`NULL`的情况下就比较特殊。因为有`NULL`值作为操作数的表达式结果往往是`NULL`,比方说: ``` mysql\u0026gt; SELECT NULL IN (1, 2, 3); \\+-------------------\\+ | NULL IN (1, 2, 3) | \\+-------------------\\+ | NULL | \\+-------------------\\+ 1 row in set (0.00 sec) mysql\u0026gt; SELECT 1 IN (1, 2, 3); \\+----------------\\+ | 1 IN (1, 2, 3) | \\+----------------\\+ | 1 | \\+----------------\\+ 1 row in set (0.00 sec) mysql\u0026gt; SELECT NULL IN (NULL); \\+----------------\\+ | NULL IN (NULL) | \\+----------------\\+ | NULL | \\+----------------\\+ 1 row in set (0.00 sec) `而`EXISTS`子查询的结果肯定是`TRUE`或者`FASLE`:` mysql\u0026gt; SELECT EXISTS (SELECT 1 FROM s1 WHERE NULL = 1); \\+------------------------------------------\\+ | EXISTS (SELECT 1 FROM s1 WHERE NULL = 1) | \\+------------------------------------------\\+ | 0 | \\+------------------------------------------\\+ 1 row in set (0.01 sec) mysql\u0026gt; SELECT EXISTS (SELECT 1 FROM s1 WHERE 1 = NULL); \\+------------------------------------------\\+ | EXISTS (SELECT 1 FROM s1 WHERE 1 = NULL) | \\+------------------------------------------\\+ | 0 | \\+------------------------------------------\\+ 1 row in set (0.00 sec) mysql\u0026gt; SELECT EXISTS (SELECT 1 FROM s1 WHERE NULL = NULL); \\+---------------------------------------------\\+ | EXISTS (SELECT 1 FROM s1 WHERE NULL = NULL) | \\+---------------------------------------------\\+ | 0 | \\+---------------------------------------------\\+ 1 row in set (0.00 sec) ``` 但是幸运的是,我们大部分使用`IN`子查询的场景是把它放在`WHERE`或者`ON`子句中,而`WHERE`或者`ON`子句是不区分`NULL`和`FALSE`的,比方说: ``` mysql\u0026gt; SELECT 1 FROM s1 WHERE NULL; Empty set (0.00 sec) mysql\u0026gt; SELECT 1 FROM s1 WHERE FALSE; Empty set (0.00 sec) `所以只要我们的`IN`子查询是放在`WHERE`或者`ON`子句中的,那么`IN -\u0026gt; EXISTS`的转换就是没问题的。说了这么多,为什么要转换呢?这是因为不转换的话可能用不到索引,比方说下面这个查询:` SELECT * FROM s1 WHERE key1 IN (SELECT key3 FROM s2 where s1.common_field = s2.common_field) OR key2 \u0026gt; 1000; ``` 这个查询中的子查询是一个相关子查询,而且子查询执行的时候不能使用到索引,但是将它转为`EXISTS`子查询后却可以使用到索引: `SELECT * FROM s1 WHERE EXISTS (SELECT 1 FROM s2 where s1.common_field = s2.common_field AND s2.key3 = s1.key1) OR key2 \u0026gt; 1000;` 转为`EXISTS`子查询时便可以使用到`s2`表的`idx_key3`索引了。 需要注意的是,如果`IN`子查询不满足转换为`semi-join`的条件,又不能转换为物化表或者转换为物化表的成本太大,那么它就会被转换为`EXISTS`查询。 `小贴士:在MySQL5.5以及之前的版本没有引进semi-join和物化的方式优化子查询时,优化器都会把IN子查询转换为EXISTS子查询,好多同学就惊呼我明明写的是一个不相关子查询,为什么要按照执行相关子查询的方式来执行呢?所以当时好多声音都是建议大家把子查询转为连接,不过随着MySQL的发展,最近的版本中引入了非常多的子查询优化策略,大家可以稍微放心的使用子查询了,内部的转换工作优化器会为大家自动实现。` #### 小结一下 + 如果`IN`子查询符合转换为`semi-join`的条件,查询优化器会优先把该子查询为`semi-join`,然后再考虑下面5种执行半连接的策略中哪个成本最低: + Table pullout + DuplicateWeedout + LooseScan + Materialization + FirstMatch 选择成本最低的那种执行策略来执行子查询。 + 如果`IN`子查询不符合转换为`semi-join`的条件,那么查询优化器会从下面两种策略中找出一种成本更低的方式执行子查询: + 先将子查询物化之后再执行查询 + 执行`IN to EXISTS`转换。 ### ANY/ALL子查询优化 如果ANY/ALL子查询是不相关子查询的话,它们在很多场合都能转换成我们熟悉的方式去执行,比方说: 原始表达式 转换为 \u0026lt; ANY (SELECT inner_expr ...) \u0026lt; (SELECT MAX\\(inner_expr) ...\\) \u0026gt; ANY (SELECT inner_expr ...) \u0026gt; (SELECT MIN\\(inner_expr) ...\\) \u0026lt; ALL (SELECT inner_expr ...) \u0026lt; (SELECT MIN\\(inner_expr) ...\\) \u0026gt; ALL (SELECT inner_expr ...) \u0026gt; (SELECT MAX\\(inner_expr) ...\\) ### [NOT] EXISTS子查询的执行 如果`[NOT] EXISTS`子查询是不相关子查询,可以先执行子查询,得出该`[NOT] EXISTS`子查询的结果是`TRUE`还是`FALSE`,并重写原先的查询语句,比如对这个查询来说: `SELECT * FROM s1 WHERE EXISTS (SELECT 1 FROM s2 WHERE key1 = \u0026#39;a\u0026#39;) OR key2 \u0026gt; 100;` 因为这个语句里的子查询是不相关子查询,所以优化器会首先执行该子查询,假设该EXISTS子查询的结果为`TRUE`,那么接着优化器会重写查询为: `SELECT * FROM s1 WHERE TRUE OR key2 \u0026gt; 100;` 进一步简化后就变成了: `SELECT * FROM s1 WHERE TRUE;` 对于相关的`[NOT] EXISTS`子查询来说,比如这个查询: `SELECT * FROM s1 WHERE EXISTS (SELECT 1 FROM s2 WHERE s1.common_field = s2.common_field);` 很不幸,这个查询只能按照我们年少时的那种执行相关子查询的方式来执行。不过如果`[NOT] EXISTS`子查询中如果可以使用索引的话,那查询速度也会加快不少,比如: `SELECT * FROM s1 WHERE EXISTS (SELECT 1 FROM s2 WHERE s1.common_field = s2.key1);` 上面这个`EXISTS`子查询中可以使用`idx_key1`来加快查询速度。 ### 对于派生表的优化 我们前面说过把子查询放在外层查询的`FROM`子句后,那么这个子查询的结果相当于一个`派生表`,比如下面这个查询: `SELECT * FROM ( SELECT id AS d_id, key3 AS d_key3 FROM s2 WHERE key1 = \u0026#39;a\u0026#39; ) AS derived_s1 WHERE d_key3 = \u0026#39;a\u0026#39;;` 子查询`( SELECT id AS d_id, key3 AS d_key3 FROM s2 WHERE key1 = \u0026#39;a\u0026#39;)`的结果就相当于一个派生表,这个表的名称是`derived_s1`,该表有两个列,分别是`d_id`和`d_key3`。 对于含有`派生表`的查询,`MySQL`提供了两种执行策略: + 最容易想到的就是把派生表物化。 我们可以将派生表的结果集写到一个内部的临时表中,然后就把这个物化表当作普通表一样参与查询。当然,在对派生表进行物化时,设计`MySQL`的大佬使用了一种称为`延迟物化`的策略,也就是在查询中真正使用到派生表时才回去尝试物化派生表,而不是还没开始执行查询呢就把派生表物化掉。比方说对于下面这个含有派生表的查询来说: `SELECT * FROM ( SELECT * FROM s1 WHERE key1 = \u0026#39;a\u0026#39; ) AS derived_s1 INNER JOIN s2 ON derived_s1.key1 = s2.key1 WHERE s2.key2 = 1;` 如果采用物化派生表的方式来执行这个查询的话,那么执行时首先会到`s1`表中找出满足`s1.key2 = 1`的记录,如果压根儿找不到,说明参与连接的`s1`表记录就是空的,所以整个查询的结果集就是空的,所以也就没有必要去物化查询中的派生表了。 + 将派生表和外层的表合并,也就是将查询重写为没有派生表的形式 我们来看这个贼简单的包含派生表的查询: `SELECT * FROM (SELECT * FROM s1 WHERE key1 = \u0026#39;a\u0026#39;) AS derived_s1;` 这个查询本质上就是想查看`s1`表中满足`key1 = \u0026#39;a\u0026#39;`条件的的全部记录,所以和下面这个语句是等价的: `SELECT * FROM s1 WHERE key1 = \u0026#39;a\u0026#39;;` 对于一些稍微复杂的包含派生表的语句,比如我们上面提到的那个: `SELECT * FROM ( SELECT * FROM s1 WHERE key1 = \u0026#39;a\u0026#39; ) AS derived_s1 INNER JOIN s2 ON derived_s1.key1 = s2.key1 WHERE s2.key2 = 1;` 我们可以将派生表与外层查询的表合并,然后将派生表中的搜索条件放到外层查询的搜索条件中,就像这样: `SELECT * FROM s1 INNER JOIN s2 ON s1.key1 = s2.key1 WHERE s1.key1 = \u0026#39;a\u0026#39; AND s2.key2 = 1;` 这样通过将外层查询和派生表合并的方式成功的消除了派生表,也就意味着我们没必要再付出创建和访问临时表的成本了。可是并不是所有带有派生表的查询都能被成功的和外层查询合并,当派生表中有这些语句就不可以和外层查询合并: + 聚集函数,比如MAX()、MIN()、SUM()什么的 + DISTINCT + GROUP BY + HAVING + LIMIT + UNION 或者 UNION ALL + 派生表对应的子查询的`SELECT`子句中含有另一个子查询 + ... 还有些不常用的情况就不多说了~ 所以`MySQL`在执行带有派生表的时候,优先尝试把派生表和外层查询合并掉,如果不行的话,再把派生表物化掉执行查询。 "},{"id":20,"href":"/zh/docs/technology/MySQL/MySQL%E6%98%AF%E6%80%8E%E6%A0%B7%E8%BF%90%E8%A1%8C%E7%9A%84/%E7%AC%AC13%E7%AB%A0_%E5%85%B5%E9%A9%AC%E6%9C%AA%E5%8A%A8%E7%B2%AE%E8%8D%89%E5%85%88%E8%A1%8C-InnoDB%E7%BB%9F%E8%AE%A1%E6%95%B0%E6%8D%AE%E6%98%AF%E5%A6%82%E4%BD%95%E6%94%B6%E9%9B%86%E7%9A%84/","title":"第13章_兵马未动粮草先行-InnoDB统计数据是如何收集的","section":"My Sql是怎样运行的","content":"第13章 兵马未动,粮草先行-InnoDB统计数据是如何收集的\n我们前面介绍查询成本的时候经常用到一些统计数据,比如通过SHOW TABLE STATUS可以看到关于表的统计数据,通过SHOW INDEX可以看到关于索引的统计数据,那么这些统计数据是怎么来的呢?它们是以什么方式收集的呢?本章将聚焦于InnoDB存储引擎的统计数据收集策略,看完本章大家就会明白为什么前面老说InnoDB的统计信息是不精确的估计值了(言下之意就是我们不打算介绍MyISAM存储引擎统计数据的收集和存储方式,有想了解的同学自己个儿看看文档)。\n两种不同的统计数据存储方式 # InnoDB提供了两种存储统计数据的方式:\n永久性的统计数据\n这种统计数据存储在磁盘上,也就是服务器重启之后这些统计数据还在。\n非永久性的统计数据\n这种统计数据存储在内存中,当服务器关闭时这些这些统计数据就都被清除掉了,等到服务器重启之后,在某些适当的场景下才会重新收集这些统计数据。\n设计MySQL的大佬们给我们提供了系统变量innodb_stats_persistent来控制到底采用哪种方式去存储统计数据。在MySQL 5.6.6之前,innodb_stats_persistent的值默认是OFF,也就是说InnoDB的统计数据默认是存储到内存的,之后的版本中innodb_stats_persistent的值默认是ON,也就是统计数据默认被存储到磁盘中。\n不过InnoDB默认是以表为单位来收集和存储统计数据的,也就是说我们可以把某些表的统计数据(以及该表的索引统计数据)存储在磁盘上,把另一些表的统计数据存储在内存中。怎么做到的呢?我们可以在创建和修改表的时候通过指定STATS_PERSISTENT属性来指明该表的统计数据存储方式: CREATE TABLE 表名 (...) Engine=InnoDB, STATS_PERSISTENT = (1|0); ALTER TABLE 表名 Engine=InnoDB, STATS_PERSISTENT = (1|0); 当STATS_PERSISTENT=1时,表明我们想把该表的统计数据永久的存储到磁盘上,当STATS_PERSISTENT=0时,表明我们想把该表的统计数据临时的存储到内存中。如果我们在创建表时未指定STATS_PERSISTENT属性,那默认采用系统变量innodb_stats_persistent的值作为该属性的值。\n基于磁盘的永久性统计数据 # 当我们选择把某个表以及该表索引的统计数据存放到磁盘上时,实际上是把这些统计数据存储到了两个表里:\nmysql\u0026gt; SHOW TABLES FROM mysql LIKE 'innodb%'; +---------------------------+ | Tables_in_mysql (innodb%) | +---------------------------+ | innodb_index_stats | | innodb_table_stats | +---------------------------+ 2 rows in set (0.01 sec) 可以看到,这两个表都位于mysql系统数据库下面,其中: - innodb_table_stats存储了关于表的统计数据,每一条记录对应着一个表的统计数据。 - innodb_index_stats存储了关于索引的统计数据,每一条记录对应着一个索引的一个统计项的统计数据。\n我们下面的任务就是看一下这两个表里边都有什么以及表里的数据是如何生成的。\ninnodb_table_stats # 直接看一下这个innodb_table_stats表中的各个列都是干嘛的: 字段名 描述 database_name 数据库名 table_name 表名 last_update 本条记录最后更新时间 n_rows 表中记录的条数 clustered_index_size 表的聚簇索引占用的页面数量 sum_of_other_index_sizes 表的其他索引占用的页面数量 注意这个表的主键是(database_name,table_name),也就是innodb_table_stats表的每条记录代表着一个表的统计信息。我们直接看一下这个表里的内容: mysql\u0026gt; SELECT * FROM mysql.innodb_table_stats; +---------------+---------------+---------------------+--------+----------------------+--------------------------+ | database_name | table_name | last_update | n_rows | clustered_index_size | sum_of_other_index_sizes | +---------------+---------------+---------------------+--------+----------------------+--------------------------+ | mysql | gtid_executed | 2018-07-10 23:51:36 | 0 | 1 | 0 | | sys | sys_config | 2018-07-10 23:51:38 | 5 | 1 | 0 | | xiaohaizi | single_table | 2018-12-10 17:03:13 | 9693 | 97 | 175 | +---------------+---------------+---------------------+--------+----------------------+--------------------------+ 3 rows in set (0.01 sec) 可以看到我们熟悉的single_table表的统计信息就对应着mysql.innodb_table_stats的第三条记录。几个重要统计信息项的值如下: - n_rows的值是9693,表明single_table表中大约有9693条记录,注意这个数据是估计值。 - clustered_index_size的值是97,表明single_table表的聚簇索引占用97个页面,这个值是也是一个估计值。 - sum_of_other_index_sizes的值是175,表明single_table表的其他索引一共占用175个页面,这个值是也是一个估计值。\nn_rows统计项的收集 # 为什么老强调n_rows这个统计项的值是估计值呢?现在就来揭晓答案。InnoDB统计一个表中有多少行记录的套路是这样的:\n按照一定算法(并不是纯粹随机的)选取几个叶子节点页面,计算每个页面中主键值记录数量,然后计算平均一个页面中主键值的记录数量乘以全部叶子节点的数量就算是该表的n_rows值。\n小贴士:真实的计算过程比这个稍微复杂一些,不过大致上就是这样的啦~\n可以看出来这个n_rows值精确与否取决于统计时采样的页面数量,设计MySQL的大佬很贴心的为我们准备了一个名为innodb_stats_persistent_sample_pages的系统变量来控制使用永久性的统计数据时,计算统计数据时采样的页面数量。该值设置的越大,统计出的n_rows值越精确,但是统计耗时也就最久;该值设置的越小,统计出的n_rows值越不精确,但是统计耗时特别少。所以在实际使用是需要我们去权衡利弊,该系统变量的默认值是20。\n我们前面说过,不过InnoDB默认是以表为单位来收集和存储统计数据的,我们也可以单独设置某个表的采样页面的数量,设置方式就是在创建或修改表的时候通过指定STATS_SAMPLE_PAGES属性来指明该表的统计数据存储方式:\nALTER TABLE 表名 Engine=InnoDB, STATS_SAMPLE_PAGES = 具体的采样页面数量; ``` 如果我们在创建表的语句中并没有指定`STATS_SAMPLE_PAGES`属性的话,将默认使用系统变量`innodb_stats_persistent_sample_pages`的值作为该属性的值。 ### clustered_index_size和sum_of_other_index_sizes统计项的收集 统计这两个数据需要大量用到我们之前介绍的`InnoDB`表空间的知识,**如果大家压根儿没有看那一章,那下面的计算过程大家还是不要看了(看也看不懂)**;如果看过了,那大家就会发现`InnoDB`表空间的知识真是有用啊啊啊!!! 这两个统计项的收集过程如下: + 从数据字典里找到表的各个索引对应的根页面位置。 系统表`SYS_INDEXES`里存储了各个索引对应的根页面信息。 + 从根页面的`Page Header`里找到叶子节点段和非叶子节点段对应的`Segment Header`。 在每个索引的根页面的`Page Header`部分都有两个字段: + `PAGE_BTR_SEG_LEAF`:表示B\\+树叶子段的`Segment Header`信息。 + `PAGE_BTR_SEG_TOP`:表示B\\+树非叶子段的`Segment Header`信息。 + 从叶子节点段和非叶子节点段的`Segment Header`中找到这两个段对应的`INODE Entry`结构。 这个是`Segment Header`结构: ![](img/13-01.png) + 从对应的`INODE Entry`结构中可以找到该段对应所有零散的页面地址以及`FREE`、`NOT_FULL`、`FULL`链表的基节点。 这个是`INODE Entry`结构: ![](img/13-02.png) + 直接统计零散的页面有多少个,然后从那三个链表的`List Length`字段中读出该段占用的区的大小,每个区占用`64`个页,所以就可以统计出整个段占用的页面。 这个是链表基节点的示意图: ![](img/13-03.png) + 分别计算聚簇索引的叶子结点段和非叶子节点段占用的页面数,它们的和就是`clustered_index_size`的值,按照同样的套路把其余索引占用的页面数都算出来,加起来之后就是`sum_of_other_index_sizes`的值。 这里需要大家注意一个问题,我们说一个段的数据在非常多时(超过32个页面),会以`区`为单位来申请空间,这里头的问题是**以区为单位申请空间中有一些页可能并没有使用**,但是在统计`clustered_index_size`和`sum_of_other_index_sizes`时都把它们算进去了,所以说聚簇索引和其他的索引占用的页面数可能比这两个值要小一些。 ## innodb_index_stats 直接看一下这个`innodb_index_stats`表中的各个列都是干嘛的: 字段名 描述 `database_name` 数据库名 `table_name` 表名 `index_name` 索引名 `last_update` 本条记录最后更新时间 `stat_name` 统计项的名称 `stat_value` 对应的统计项的值 `sample_size` 为生成统计数据而采样的页面数量 `stat_description` 对应的统计项的描述 注意这个表的主键是`(database_name,table_name,index_name,stat_name)`,其中的`stat_name`是指统计项的名称,也就是说**innodb_index_stats表的每条记录代表着一个索引的一个统计项**。可能这会大家有些懵逼这个统计项到底指什么,别着急,我们直接看一下关于`single_table`表的索引统计数据都有些什么: `mysql\u0026gt; SELECT * FROM mysql.innodb_index_stats WHERE table_name = \u0026#39;single_table\u0026#39;; +---------------+--------------+--------------+---------------------+--------------+------------+-------------+-----------------------------------+ | database_name | table_name | index_name | last_update | stat_name | stat_value | sample_size | stat_description | +---------------+--------------+--------------+---------------------+--------------+------------+-------------+-----------------------------------+ | xiaohaizi | single_table | PRIMARY | 2018-12-14 14:24:46 | n_diff_pfx01 | 9693 | 20 | id | | xiaohaizi | single_table | PRIMARY | 2018-12-14 14:24:46 | n_leaf_pages | 91 | NULL | Number of leaf pages in the index | | xiaohaizi | single_table | PRIMARY | 2018-12-14 14:24:46 | size | 97 | NULL | Number of pages in the index | | xiaohaizi | single_table | idx_key1 | 2018-12-14 14:24:46 | n_diff_pfx01 | 968 | 28 | key1 | | xiaohaizi | single_table | idx_key1 | 2018-12-14 14:24:46 | n_diff_pfx02 | 10000 | 28 | key1,id | | xiaohaizi | single_table | idx_key1 | 2018-12-14 14:24:46 | n_leaf_pages | 28 | NULL | Number of leaf pages in the index | | xiaohaizi | single_table | idx_key1 | 2018-12-14 14:24:46 | size | 29 | NULL | Number of pages in the index | | xiaohaizi | single_table | idx_key2 | 2018-12-14 14:24:46 | n_diff_pfx01 | 10000 | 16 | key2 | | xiaohaizi | single_table | idx_key2 | 2018-12-14 14:24:46 | n_leaf_pages | 16 | NULL | Number of leaf pages in the index | | xiaohaizi | single_table | idx_key2 | 2018-12-14 14:24:46 | size | 17 | NULL | Number of pages in the index | | xiaohaizi | single_table | idx_key3 | 2018-12-14 14:24:46 | n_diff_pfx01 | 799 | 31 | key3 | | xiaohaizi | single_table | idx_key3 | 2018-12-14 14:24:46 | n_diff_pfx02 | 10000 | 31 | key3,id | | xiaohaizi | single_table | idx_key3 | 2018-12-14 14:24:46 | n_leaf_pages | 31 | NULL | Number of leaf pages in the index | | xiaohaizi | single_table | idx_key3 | 2018-12-14 14:24:46 | size | 32 | NULL | Number of pages in the index | | xiaohaizi | single_table | idx_key_part | 2018-12-14 14:24:46 | n_diff_pfx01 | 9673 | 64 | key_part1 | | xiaohaizi | single_table | idx_key_part | 2018-12-14 14:24:46 | n_diff_pfx02 | 9999 | 64 | key_part1,key_part2 | | xiaohaizi | single_table | idx_key_part | 2018-12-14 14:24:46 | n_diff_pfx03 | 10000 | 64 | key_part1,key_part2,key_part3 | | xiaohaizi | single_table | idx_key_part | 2018-12-14 14:24:46 | n_diff_pfx04 | 10000 | 64 | key_part1,key_part2,key_part3,id | | xiaohaizi | single_table | idx_key_part | 2018-12-14 14:24:46 | n_leaf_pages | 64 | NULL | Number of leaf pages in the index | | xiaohaizi | single_table | idx_key_part | 2018-12-14 14:24:46 | size | 97 | NULL | Number of pages in the index | +---------------+--------------+--------------+---------------------+--------------+------------+-------------+-----------------------------------+ 20 rows in set (0.03 sec)` 这个结果有点儿多,正确查看这个结果的方式是这样的: + 先查看`index_name`列,这个列说明该记录是哪个索引的统计信息,从结果中我们可以看出来,`PRIMARY`索引(也就是主键)占了3条记录,`idx_key_part`索引占了6条记录。 + 针对`index_name`列相同的记录,`stat_name`表示针对该索引的统计项名称,`stat_value`展示的是该索引在该统计项上的值,`stat_description`指的是来描述该统计项的含义的。我们来具体看一下一个索引都有哪些统计项: + `n_leaf_pages`:表示该索引的叶子节点占用多少页面。 + `size`:表示该索引共占用多少页面。 + `n_diff_pfx**NN**`:表示对应的索引列不重复的值有多少。其中的`NN`长得有点儿怪呀,什么意思呢? 其实`NN`可以被替换为`01`、`02`、`03`... 这样的数字。比如对于`idx_key_part`来说: + `n_diff_pfx01`表示的是统计`key_part1`这单单一个列不重复的值有多少。 + `n_diff_pfx02`表示的是统计`key_part1、key_part2`这两个列组合起来不重复的值有多少。 + `n_diff_pfx03`表示的是统计`key_part1、key_part2、key_part3`这三个列组合起来不重复的值有多少。 + `n_diff_pfx04`表示的是统计`key_part1、key_part2、key_part3、id`这四个列组合起来不重复的值有多少。 `小贴士:这里需要注意的是,对于普通的二级索引,并不能保证它的索引列值是唯一的,比如对于idx_key1来说,key1列就可能有很多值重复的记录。此时只有在索引列上加上主键值才可以区分两条索引列值都一样的二级索引记录。对于主键和唯一二级索引则没有这个问题,它们本身就可以保证索引列值的不重复,所以也不需要再统计一遍在索引列后加上主键值的不重复值有多少。比如上面的idx_key1有n_diff_pfx01、n_diff_pfx02两个统计项,而idx_key2却只有n_diff_pfx01一个统计项。` + 在计算某些索引列中包含多少不重复值时,需要对一些叶子节点页面进行采样,`size`列就表明了采样的页面数量是多少。 `小贴士:对于有多个列的联合索引来说,采样的页面数量是:innodb_stats_persistent_sample_pages × 索引列的个数。当需要采样的页面数量大于该索引的叶子节点数量的话,就直接采用全表扫描来统计索引列的不重复值数量了。所以大家可以在查询结果中看到不同索引对应的size列的值可能是不同的。` ## 定期更新统计数据 随着我们不断的对表进行增删改操作,表中的数据也一直在变化,`innodb_table_stats`和`innodb_index_stats`表里的统计数据是不是也应该跟着变一变了?当然要变了,不变的话`MySQL`查询优化器计算的成本可就差老鼻子远了。设计`MySQL`的大佬提供了如下两种更新统计数据的方式: + 开启`innodb_stats_auto_recalc`。 系统变量`innodb_stats_auto_recalc`决定着服务器是否自动重新计算统计数据,它的默认值是`ON`,也就是该功能默认是开启的。每个表都维护了一个变量,该变量记录着对该表进行增删改的记录条数,如果发生变动的记录数量超过了表大小的`10%`,并且自动重新计算统计数据的功能是打开的,那么服务器会重新进行一次统计数据的计算,并且更新`innodb_table_stats`和`innodb_index_stats`表。不过**自动重新计算统计数据的过程是异步发生的**,也就是即使表中变动的记录数超过了`10%`,自动重新计算统计数据也不会立即发生,可能会延迟几秒才会进行计算。 再一次强调,`InnoDB`默认是**以表为单位来收集和存储统计数据的**,我们也可以单独为某个表设置是否自动重新计算统计数的属性,设置方式就是在创建或修改表的时候通过指定`STATS_AUTO_RECALC`属性来指明该表的统计数据存储方式: ``` CREATE TABLE 表名 (...) Engine=InnoDB, STATS_AUTO_RECALC = (1|0); ALTER TABLE 表名 Engine=InnoDB, STATS_AUTO_RECALC = (1|0); ``` 当`STATS_AUTO_RECALC=1`时,表明我们想让该表自动重新计算统计数据,当`STATS_PERSISTENT=0`时,表明不想让该表自动重新计算统计数据。如果我们在创建表时未指定`STATS_AUTO_RECALC`属性,那默认采用系统变量`innodb_stats_auto_recalc`的值作为该属性的值。 + 手动调用`ANALYZE TABLE`语句来更新统计信息 如果`innodb_stats_auto_recalc`系统变量的值为`OFF`的话,我们也可以手动调用`ANALYZE TABLE`语句来重新计算统计数据,比如我们可以这样更新关于`single_table`表的统计数据: `mysql\u0026gt; ANALYZE TABLE single_table; +------------------------+---------+----------+----------+ | Table | Op | Msg_type | Msg_text | +------------------------+---------+----------+----------+ | xiaohaizi.single_table | analyze | status | OK | +------------------------+---------+----------+----------+ 1 row in set (0.08 sec)` 需要注意的是,**ANALYZE TABLE语句会立即重新计算统计数据,也就是这个过程是同步的**,在表中索引多或者采样页面特别多时这个过程可能会特别慢,请不要没事儿就运行一下`ANALYZE TABLE`语句,最好在业务不是很繁忙的时候再运行。 ## 手动更新**`innodb_table_stats`**和**`innodb_index_stats`**表 其实`innodb_table_stats`和`innodb_index_stats`表就相当于一个普通的表一样,我们能对它们做增删改查操作。这也就意味着我们可以**手动更新某个表或者索引的统计数据**。比如说我们想把`single_table`表关于行数的统计数据更改一下可以这么做: + 步骤一:更新`innodb_table_stats`表。 `UPDATE innodb_table_stats SET n_rows = 1 WHERE table_name = \u0026#39;single_table\u0026#39;;` + 步骤二:让`MySQL`查询优化器重新加载我们更改过的数据。 更新完`innodb_table_stats`只是单纯的修改了一个表的数据,需要让`MySQL`查询优化器重新加载我们更改过的数据,运行下面的命令就可以了: `FLUSH TABLE single_table;` 之后我们使用`SHOW TABLE STATUS`语句查看表的统计数据时就看到`Rows`行变为了`1`。 # 基于内存的非永久性统计数据 当我们把系统变量`innodb_stats_persistent`的值设置为`OFF`时,之后创建的表的统计数据默认就都是非永久性的了,或者我们直接在创建表或修改表时设置`STATS_PERSISTENT`属性的值为`0`,那么该表的统计数据就是非永久性的了。 与永久性的统计数据不同,非永久性的统计数据采样的页面数量是由`innodb_stats_transient_sample_pages`控制的,这个系统变量的默认值是`8`。 另外,由于非永久性的统计数据经常更新,所以导致`MySQL`查询优化器计算查询成本的时候依赖的是经常变化的统计数据,也就会**生成经常变化的执行计划**,这个可能让大家有些懵逼。不过最近的`MySQL`版本都不咋用这种基于内存的非永久性统计数据了,所以我们也就不深入介绍它了。 # innodb_stats_method的使用 我们知道`索引列不重复的值的数量`这个统计数据对于`MySQL`查询优化器十分重要,因为通过它可以计算出在索引列中平均一个值重复多少行,它的应用场景主要有两个: + 单表查询中单点区间太多,比方说这样: `SELECT * FROM tbl_name WHERE key IN (\u0026#39;xx1\u0026#39;, \u0026#39;xx2\u0026#39;, ..., \u0026#39;xxn\u0026#39;);` 当`IN`里的参数数量过多时,采用`index dive`的方式直接访问`B+`树索引去统计每个单点区间对应的记录的数量就太耗费性能了,所以直接依赖统计数据中的平均一个值重复多少行来计算单点区间对应的记录数量。 + 连接查询时,如果有涉及两个表的等值匹配连接条件,该连接条件对应的被驱动表中的列又拥有索引时,则可以使用`ref`访问方法来对被驱动表进行查询,比方说这样: `SELECT * FROM t1 JOIN t2 ON t1.column = t2.key WHERE ...;` 在真正执行对`t2`表的查询前,`t1.comumn`的值是不确定的,所以我们也不能通过`index dive`的方式直接访问`B+`树索引去统计每个单点区间对应的记录的数量,所以也只能依赖统计数据中的平均一个值重复多少行来计算单点区间对应的记录数量。 在统计索引列不重复的值的数量时,有一个比较烦的问题就是索引列中出现`NULL`值怎么办,比方说某个索引列的内容是这样: `+------+ | col | +------+ | 1 | | 2 | | NULL | | NULL | +------+` 此时计算这个`col`列中不重复的值的数量就有下面的分歧: + 有的人认为`NULL`值代表一个未确定的值,所以设计`MySQL`的大佬才认为任何和`NULL`值做比较的表达式的值都为`NULL`,就是这样: ``` mysql\u0026gt; SELECT 1 = NULL; \\+----------\\+ | 1 = NULL | \\+----------\\+ | NULL | \\+----------\\+ 1 row in set (0.00 sec) mysql\u0026gt; SELECT 1 \\!= NULL; \\+-----------\\+ | 1 \\!= NULL | \\+-----------\\+ | NULL | \\+-----------\\+ 1 row in set (0.00 sec) mysql\u0026gt; SELECT NULL = NULL; \\+-------------\\+ | NULL = NULL | \\+-------------\\+ | NULL | \\+-------------\\+ 1 row in set (0.00 sec) mysql\u0026gt; SELECT NULL \\!= NULL; \\+--------------\\+ | NULL \\!= NULL | \\+--------------\\+ | NULL | \\+--------------\\+ 1 row in set (0.00 sec) ``` 所以每一个`NULL`值都是独一无二的,也就是说统计索引列不重复的值的数量时,应该把`NULL`值当作一个独立的值,所以`col`列的不重复的值的数量就是:`4`(分别是1、2、NULL、NULL这四个值)。 + 有的人认为其实`NULL`值在业务上就是代表没有,所有的`NULL`值代表的意义是一样的,所以`col`列不重复的值的数量就是:`3`(分别是1、2、NULL这三个值)。 + 有的人认为这`NULL`完全没有意义嘛,所以在统计索引列不重复的值的数量时压根儿不能把它们算进来,所以`col`列不重复的值的数量就是:`2`(分别是1、2这两个值)。 设计`MySQL`的大佬蛮贴心的,他们提供了一个名为`innodb_stats_method`的系统变量,相当于在计算某个索引列不重复值的数量时如何对待`NULL`值这个锅甩给了用户,这个系统变量有三个候选值: + `nulls_equal`:认为所有`NULL`值都是相等的。这个值也是`innodb_stats_method`的默认值。 如果某个索引列中`NULL`值特别多的话,这种统计方式会让优化器认为某个列中平均一个值重复次数特别多,所以倾向于不使用索引进行访问。 + `nulls_unequal`:认为所有`NULL`值都是不相等的。 如果某个索引列中`NULL`值特别多的话,这种统计方式会让优化器认为某个列中平均一个值重复次数特别少,所以倾向于使用索引进行访问。 + `nulls_ignored`:直接把`NULL`值忽略掉。 反正这个锅是甩给用户了,当你选定了`innodb_stats_method`值之后,优化器即使选择了不是最优的执行计划,那也跟设计`MySQL`的大佬们没关系了~ 当然对于用户的我们来说,**最好不在索引列中存放NULL值才是正解**。 # 总结 + `InnoDB`以表为单位来收集统计数据,这些统计数据可以是基于磁盘的永久性统计数据,也可以是基于内存的非永久性统计数据。 + `innodb_stats_persistent`控制着使用永久性统计数据还是非永久性统计数据;`innodb_stats_persistent_sample_pages`控制着永久性统计数据的采样页面数量;`innodb_stats_transient_sample_pages`控制着非永久性统计数据的采样页面数量;`innodb_stats_auto_recalc`控制着是否自动重新计算统计数据。 + 我们可以针对某个具体的表,在创建和修改表时通过指定`STATS_PERSISTENT`、`STATS_AUTO_RECALC`、`STATS_SAMPLE_PAGES`的值来控制相关统计数据属性。 + `innodb_stats_method`决定着在统计某个索引列不重复值的数量时如何对待`NULL`值。 "},{"id":21,"href":"/zh/docs/technology/MySQL/MySQL%E6%98%AF%E6%80%8E%E6%A0%B7%E8%BF%90%E8%A1%8C%E7%9A%84/%E7%AC%AC12%E7%AB%A0_%E8%B0%81%E6%9C%80%E4%BE%BF%E5%AE%9C%E5%B0%B1%E9%80%89%E8%B0%81-MySQL%E5%9F%BA%E4%BA%8E%E6%88%90%E6%9C%AC%E7%9A%84%E4%BC%98%E5%8C%96/","title":"第12章_谁最便宜就选谁-MySQL基于成本的优化","section":"My Sql是怎样运行的","content":"第12章 谁最便宜就选谁-MySQL基于成本的优化\n什么是成本 # 我们之前老说MySQL执行一个查询可以有不同的执行方案,它会选择其中成本最低,或者说代价最低的那种方案去真正的执行查询。不过我们之前对成本的描述是非常模糊的,其实在MySQL中一条查询语句的执行成本是由下面这两个方面组成的:\nI/O成本\n我们的表经常使用的MyISAM、InnoDB存储引擎都是将数据和索引都存储到磁盘上的,当我们想查询表中的记录时,需要先把数据或者索引加载到内存中然后再操作。这个从磁盘到内存这个加载的过程损耗的时间称之为I/O成本。\nCPU成本\n读取以及检测记录是否满足对应的搜索条件、对结果集进行排序等这些操作损耗的时间称之为CPU成本。\n对于InnoDB存储引擎来说,页是磁盘和内存之间交互的基本单位,设计MySQL的大佬规定读取一个页面花费的成本默认是1.0,读取以及检测一条记录是否符合搜索条件的成本默认是0.2。1.0、0.2这些数字称之为成本常数,这两个成本常数我们最常用到,其余的成本常数我们后边再说。 小贴士:需要注意的是,不管读取记录时需不需要检测是否满足搜索条件,其成本都算是0.2。\n单表查询的成本 # 准备工作 # 为了故事的顺利发展,我们还得把之前用到的single_table表搬来,怕大家忘了这个表长什么样,再给大家抄一遍: CREATE TABLE single_table ( id INT NOT NULL AUTO_INCREMENT, key1 VARCHAR(100), key2 INT, key3 VARCHAR(100), key_part1 VARCHAR(100), key_part2 VARCHAR(100), key_part3 VARCHAR(100), common_field VARCHAR(100), PRIMARY KEY (id), KEY idx_key1 (key1), UNIQUE KEY idx_key2 (key2), KEY idx_key3 (key3), KEY idx_key_part(key_part1, key_part2, key_part3) ) Engine=InnoDB CHARSET=utf8; 还是假设这个表里边儿有10000条记录,除id列外其余的列都插入随机值。下面正式开始我们的表演。\n基于成本的优化步骤 # 在一条单表查询语句真正执行之前,MySQL的查询优化器会找出执行该语句所有可能使用的方案,对比之后找出成本最低的方案,这个成本最低的方案就是所谓的执行计划,之后才会调用存储引擎提供的接口真正的执行查询,这个过程总结一下就是这样: 1. 根据搜索条件,找出所有可能使用的索引 2. 计算全表扫描的代价 3. 计算使用不同索引执行查询的代价 4. 对比各种执行方案的代价,找出成本最低的那一个\n下面我们就以一个实例来分析一下这些步骤,单表查询语句如下: SELECT * FROM single_table WHERE key1 IN ('a', 'b', 'c') AND key2 \u0026gt; 10 AND key2 \u0026lt; 1000 AND key3 \u0026gt; key2 AND key_part1 LIKE '%hello%' AND common_field = '123'; 乍看上去有点儿复杂,我们一步一步分析一下。\n根据搜索条件,找出所有可能使用的索引 # 我们前面说过,对于B+树索引来说,只要索引列和常数使用=、\u0026lt;=\u0026gt;、IN、NOT IN、IS NULL、IS NOT NULL、\u0026gt;、\u0026lt;、\u0026gt;=、\u0026lt;=、BETWEEN、!=(不等于也可以写成\u0026lt;\u0026gt;)或者LIKE操作符连接起来,就可以产生一个所谓的范围区间(LIKE匹配字符串前缀也行),也就是说这些搜索条件都可能使用到索引,设计MySQL的大佬把一个查询中可能使用到的索引称之为possible keys。\n我们分析一下上面查询中涉及到的几个搜索条件: - key1 IN ('a', 'b', 'c'),这个搜索条件可以使用二级索引idx_key1。 - key2 \u0026gt; 10 AND key2 \u0026lt; 1000,这个搜索条件可以使用二级索引idx_key2。 - key3 \u0026gt; key2,这个搜索条件的索引列由于没有和常数比较,所以并不能使用到索引。 - key_part1 LIKE '%hello%',key_part1通过LIKE操作符和以通配符开头的字符串做比较,不可以适用索引。 - common_field = '123',由于该列上压根儿没有索引,所以不会用到索引。\n综上所述,上面的查询语句可能用到的索引,也就是possible keys只有idx_key1和idx_key2。\n计算全表扫描的代价 # 对于InnoDB存储引擎来说,全表扫描的意思就是把聚簇索引中的记录都依次和给定的搜索条件做一下比较,把符合搜索条件的记录加入到结果集,所以需要将聚簇索引对应的页面加载到内存中,然后再检测记录是否符合搜索条件。由于查询成本=I/O成本+CPU成本,所以计算全表扫描的代价需要两个信息: - 聚簇索引占用的页面数 - 该表中的记录数\n这两个信息从哪来呢?设计MySQL的大佬为每个表维护了一系列的统计信息,关于这些统计信息是如何收集起来的我们放在本章后边详细介绍,现在看看怎么查看这些统计信息。设计MySQL的大佬给我们提供了SHOW TABLE STATUS语句来查看表的统计信息,如果要看指定的某个表的统计信息,在该语句后加对应的LIKE语句就好了,比方说我们要查看single_table这个表的统计信息可以这么写:\nmysql\u0026gt; SHOW TABLE STATUS LIKE \u0026#39;single_table\u0026#39;\\\\G *************************** 1. row *************************** Name: single_table Engine: InnoDB Version: 10 Row_format: Dynamic Rows: 9693 Avg_row_length: 163 Data_length: 1589248 Max_data_length: 0 Index_length: 2752512 Data_free: 4194304 Auto_increment: 10001 Create_time: 2018-12-10 13:37:23 Update_time: 2018-12-10 13:38:03 Check_time: NULL Collation: utf8_general_ci Checksum: NULL Create_options: Comment: 1 row in set (0.01 sec) ``` 虽然出现了很多统计选项,但我们目前只关心两个: + `Rows` 本选项表示表中的记录条数。对于使用`MyISAM`存储引擎的表来说,该值是准确的,对于使用`InnoDB`存储引擎的表来说,该值是一个估计值。从查询结果我们也可以看出来,由于我们的`single_table`表是使用`InnoDB`存储引擎的,所以虽然实际上表中有10000条记录,但是`SHOW TABLE STATUS`显示的`Rows`值只有9693条记录。 + `Data_length` 本选项表示表占用的存储空间字节数。使用`MyISAM`存储引擎的表来说,该值就是数据文件的大小,对于使用`InnoDB`存储引擎的表来说,该值就相当于聚簇索引占用的存储空间大小,也就是说可以这样计算该值的大小: `Data_length = 聚簇索引的页面数量 x 每个页面的大小` 我们的`single_table`使用默认`16KB`的页面大小,而上面查询结果显示`Data_length`的值是`1589248`,所以我们可以反向来推导出`聚簇索引的页面数量`: `聚簇索引的页面数量 = 1589248 ÷ 16 ÷ 1024 = 97` 我们现在已经得到了聚簇索引占用的页面数量以及该表记录数的估计值,所以就可以计算全表扫描成本了,但是设计`MySQL`的大佬在真实计算成本时会进行一些`微调`,这些微调的值是直接硬编码到代码里的,由于没有注释,我也不知道这些微调值是什么子意思,但是由于这些微调的值十分的小,并不影响我们分析,所以我们也没有必要在这些微调值上纠结了。现在可以看一下全表扫描成本的计算过程: + `I/O`成本 `97 x 1.0 + 1.1 = 98.1` `97`指的是聚簇索引占用的页面数,`1.0`指的是加载一个页面的成本常数,后边的`1.1`是一个微调值,我们不用在意。 + `CPU`成本: `9693 x 0.2 + 1.0 = 1939.6` `9693`指的是统计数据中表的记录数,对于`InnoDB`存储引擎来说是一个估计值,`0.2`指的是访问一条记录所需的成本常数,后边的`1.0`是一个微调值,我们不用在意。 + 总成本: `98.1 + 1939.6 = 2037.7` 综上所述,对于`single_table`的全表扫描所需的总成本就是`2037.7`。 `小贴士:我们前面说过表中的记录其实都存储在聚簇索引对应B+树的叶子节点中,所以只要我们通过根节点获得了最左边的叶子节点,就可以沿着叶子节点组成的双向链表把所有记录都查看一遍。也就是说全表扫描这个过程其实有的B+树内节点是不需要访问的,但是设计MySQL的大佬们在计算全表扫描成本时直接使用聚簇索引占用的页面数作为计算I/O成本的依据,是不区分内节点和叶子节点的,有点儿简单暴力,大家注意一下就好了。` ### 计算使用不同索引执行查询的代价 从第1步分析我们得到,上述查询可能使用到`idx_key1`和`idx_key2`这两个索引,我们需要分别分析单独使用这些索引执行查询的成本,最后还要分析是否可能使用到索引合并。这里需要提一点的是,`MySQL`查询优化器先分析使用唯一二级索引的成本,再分析使用普通索引的成本,所以我们也先分析`idx_key2`的成本,然后再看使用`idx_key1`的成本。 #### 使用idx_key2执行查询的成本分析 `idx_key2`对应的搜索条件是:`key2 \u0026gt; 10 AND key2 \u0026lt; 1000`,也就是说对应的范围区间就是:`(10, 1000)`,使用`idx_key2`搜索的示意图就是这样子: ![](img/12-01.png) 对于使用`二级索引 + 回表`方式的查询,设计`MySQL`的大佬计算这种查询的成本依赖两个方面的数据: + 范围区间数量 不论某个范围区间的二级索引到底占用了多少页面,查询优化器粗暴的认为读取索引的一个范围区间的`I/O`成本和读取一个页面是相同的。本例中使用`idx_key2`的范围区间只有一个:`(10, 1000)`,所以相当于访问这个范围区间的二级索引付出的`I/O`成本就是: `1 x 1.0 = 1.0` + 需要回表的记录数 优化器需要计算二级索引的某个范围区间到底包含多少条记录,对于本例来说就是要计算`idx_key2`在`(10, 1000)`这个范围区间中包含多少二级索引记录,计算过程是这样的: + 步骤1:先根据`key2 \u0026gt; 10`这个条件访问一下`idx_key2`对应的`B+`树索引,找到满足`key2 \u0026gt; 10`这个条件的第一条记录,我们把这条记录称之为`区间最左记录`。我们前头说过在`B+`数树中定位一条记录的过程是贼快的,是常数级别的,所以这个过程的性能消耗是可以忽略不计的。 + 步骤2:然后再根据`key2 \u0026lt; 1000`这个条件继续从`idx_key2`对应的`B+`树索引中找出第一条满足这个条件的记录,我们把这条记录称之为`区间最右记录`,这个过程的性能消耗也可以忽略不计的。 + 步骤3:如果`区间最左记录`和`区间最右记录`相隔不太远(在`MySQL 5.7.21`这个版本里,只要相隔不大于10个页面即可),那就可以精确统计出满足`key2 \u0026gt; 10 AND key2 \u0026lt; 1000`条件的二级索引记录条数。否则只沿着`区间最左记录`向右读10个页面,计算平均每个页面中包含多少记录,然后用这个平均值乘以`区间最左记录`和`区间最右记录`之间的页面数量就可以了。那么问题又来了,怎么估计`区间最左记录`和`区间最右记录`之间有多少个页面呢?解决这个问题还得回到`B+`树索引的结构中来: ![](img/12-02.png) 如图,我们假设`区间最左记录`在`页b`中,`区间最右记录`在`页c`中,那么我们想计算`区间最左记录`和`区间最右记录`之间的页面数量就相当于计算`页b`和`页c`之间有多少页面,而每一条`目录项记录`都对应一个数据页,所以计算`页b`和`页c`之间有多少页面就相当于**计算它们父节点(也就是页a)中对应的目录项记录之间隔着几条记录**。在一个页面中统计两条记录之间有几条记录的成本就贼小了。 不过还有问题,如果`页b`和`页c`之间的页面实在太多,以至于`页b`和`页c`对应的目录项记录都不在一个页面中该咋办?继续递归啊,也就是再统计`页b`和`页c`对应的目录项记录所在页之间有多少个页面。之前我们说过一个`B+`树有4层高已经很了不得了,所以这个统计过程也不是很耗费性能。 知道了如何统计二级索引某个范围区间的记录数之后,就需要回到现实问题中来,根据上述算法测得`idx_key2`在区间`(10, 1000)`之间大约有`95`条记录。读取这`95`条二级索引记录需要付出的`CPU`成本就是: `95 x 0.2 + 0.01 = 19.01` 其中`95`是需要读取的二级索引记录条数,`0.2`是读取一条记录成本常数,`0.01`是微调。 在通过二级索引获取到记录之后,还需要干两件事儿: + 根据这些记录里的主键值到聚簇索引中做回表操作 这里需要大家使劲儿睁大自己滴溜溜的大眼睛仔细瞧,设计`MySQL`的大佬评估回表操作的`I/O`成本依旧很豪放,他们认为每次回表操作都相当于访问一个页面,也就是说二级索引范围区间有多少记录,就需要进行多少次回表操作,也就是需要进行多少次页面`I/O`。我们上面统计了使用`idx_key2`二级索引执行查询时,预计有`95`条二级索引记录需要进行回表操作,所以回表操作带来的`I/O`成本就是: `95 x 1.0 = 95.0` 其中`95`是预计的二级索引记录数,`1.0`是一个页面的`I/O`成本常数。 + 回表操作后得到的完整用户记录,然后再检测其他搜索条件是否成立 回表操作的本质就是通过二级索引记录的主键值到聚簇索引中找到完整的用户记录,然后再检测除`key2 \u0026gt; 10 AND key2 \u0026lt; 1000`这个搜索条件以外的搜索条件是否成立。因为我们通过范围区间获取到二级索引记录共`95`条,也就对应着聚簇索引中`95`条完整的用户记录,读取并检测这些完整的用户记录是否符合其余的搜索条件的`CPU`成本如下: 设计`MySQL`的大佬只计算这个查找过程所需的`I/O`成本,也就是我们上一步骤中得到的`95.0`,在内存中的定位完整用户记录的过程的成本是忽略不计的。在定位到这些完整的用户记录后,需要检测除`key2 \u0026gt; 10 AND key2 \u0026lt; 1000`这个搜索条件以外的搜索条件是否成立,这个比较过程花费的`CPU`成本就是: `95 x 0.2 = 19.0` 其中`95`是待检测记录的条数,`0.2`是检测一条记录是否符合给定的搜索条件的成本常数。 所以本例中使用`idx_key2`执行查询的成本就如下所示: + `I/O`成本: `1.0 + 95 x 1.0 = 96.0 (范围区间的数量 + 预估的二级索引记录条数)` + `CPU`成本: `95 x 0.2 + 0.01 + 95 x 0.2 = 38.01 (读取二级索引记录的成本 + 读取并检测回表后聚簇索引记录的成本)` 综上所述,使用`idx_key2`执行查询的总成本就是: `96.0 + 38.01 = 134.01` #### 使用idx_key1执行查询的成本分析 `idx_key1`对应的搜索条件是:`key1 IN (\u0026#39;a\u0026#39;, \u0026#39;b\u0026#39;, \u0026#39;c\u0026#39;)`,也就是说相当于3个单点区间: + `[\u0026#39;a\u0026#39;, \u0026#39;a\u0026#39;]` + `[\u0026#39;b\u0026#39;, \u0026#39;b\u0026#39;]` + `[\u0026#39;c\u0026#39;, \u0026#39;c\u0026#39;]` 使用`idx_key1`搜索的示意图如下: ![](img/12-03.png) 与使用`idx_key2`的情况类似,我们也需要计算使用`idx_key1`时需要访问的范围区间数量以及需要回表的记录数: + 范围区间数量 使用`idx_key1`执行查询时很显然有3个单点区间,所以访问这3个范围区间的二级索引付出的I/O成本就是: `3 x 1.0 = 3.0` + 需要回表的记录数 由于使用`idx_key1`时有3个单点区间,所以每个单点区间都需要查找一遍对应的二级索引记录数: + 查找单点区间`[\u0026#39;a\u0026#39;, \u0026#39;a\u0026#39;]`对应的二级索引记录数 计算单点区间对应的二级索引记录数和计算连续范围区间对应的二级索引记录数是一样的,都是先计算`区间最左记录`和`区间最右记录`,然后再计算它们之间的记录数,具体算法上面都介绍过了,就不赘述了。最后计算得到单点区间`[\u0026#39;a\u0026#39;, \u0026#39;a\u0026#39;]`对应的二级索引记录数是:`35`。 + 查找单点区间`[\u0026#39;b\u0026#39;, \u0026#39;b\u0026#39;]`对应的二级索引记录数 与上同理,计算得到本单点区间对应的记录数是:`44`。 + 查找单点区间`[\u0026#39;c\u0026#39;, \u0026#39;c\u0026#39;]`对应的二级索引记录数 与上同理,计算得到本单点区间对应的记录数是:`39`。 所以,这三个单点区间总共需要回表的记录数就是: `35 + 44 + 39 = 118` 读取这些二级索引记录的`CPU`成本就是: `118 x 0.2 + 0.01 = 23.61` 得到总共需要回表的记录数之后,就要考虑: + 根据这些记录里的主键值到聚簇索引中做回表操作 所需的`I/O`成本就是: `118 x 1.0 = 118.0` + 回表操作后得到的完整用户记录,然后再比较其他搜索条件是否成立 此步骤对应的`CPU`成本就是: `118 x 0.2 = 23.6` 所以本例中使用`idx_key1`执行查询的成本就如下所示: + `I/O`成本: `3.0 + 118 x 1.0 = 121.0 (范围区间的数量 + 预估的二级索引记录条数)` + `CPU`成本: `118 x 0.2 + 0.01 + 118 x 0.2 = 47.21 (读取二级索引记录的成本 + 读取并检测回表后聚簇索引记录的成本)` 综上所述,使用`idx_key1`执行查询的总成本就是: `121.0 + 47.21 = 168.21` #### 是否有可能使用索引合并(Index Merge) 本例中有关`key1`和`key2`的搜索条件是使用`AND`连接起来的,而对于`idx_key1`和`idx_key2`都是范围查询,也就是说查找到的二级索引记录并不是按照主键值进行排序的,并不满足使用`Intersection`索引合并的条件,所以并不会使用索引合并。 `小贴士:MySQL查询优化器计算索引合并成本的算法也比较麻烦,所以我们这也就不展开介绍了。` ### 4. 对比各种执行方案的代价,找出成本最低的那一个 下面把执行本例中的查询的各种可执行方案以及它们对应的成本列出来: - 全表扫描的成本:`2037.7` - 使用`idx_key2`的成本:`134.01` - 使用`idx_key1`的成本:`168.21` 很显然,使用`idx_key2`的成本最低,所以当然选择`idx_key2`来执行查询喽。 `小贴士:考虑到大家的阅读体验,为了最大限度的减少大家在理解优化器工作原理的过程中遇到的懵逼情况,这里对优化器在单表查询中对比各种执行方案的代价的方式稍稍的做了简化,不过毕竟大部分同学不需要去看MySQL的源码,把大致的精神传递正确就好了。` ## 基于索引统计数据的成本计算 有时候使用索引执行查询时会有许多单点区间,比如使用`IN`语句就很容易产生非常多的单点区间,比如下面这个查询(下面查询语句中的`...`表示还有很多参数): `SELECT * FROM single_table WHERE key1 IN (\u0026#39;aa1\u0026#39;, \u0026#39;aa2\u0026#39;, \u0026#39;aa3\u0026#39;, ... , \u0026#39;zzz\u0026#39;);` 很显然,这个查询可能使用到的索引就是`idx_key1`,由于这个索引并不是唯一二级索引,所以并不能确定一个单点区间对应的二级索引记录的条数有多少,需要我们去计算。计算方式我们上面已经介绍过了,就是先获取索引对应的`B+`树的`区间最左记录`和`区间最右记录`,然后再计算这两条记录之间有多少记录(记录条数少的时候可以做到精确计算,多的时候只能估算)。设计`MySQL`的大佬把这种通过直接访问索引对应的`B+`树来计算某个范围区间对应的索引记录条数的方式称之为`index dive`。 `小贴士:dive直译为中文的意思是跳水、俯冲的意思,原谅我的英文水平捉急,我实在不知道怎么翻译 index dive,索引跳水?索引俯冲?好像都不太合适,所以压根儿就不翻译了。不过大家要意会index dive就是直接利用索引对应的B+树来计算某个范围区间对应的记录条数。` 有零星几个单点区间的话,使用`index dive`的方式去计算这些单点区间对应的记录数也不是什么问题,可是你架不住有的孩子憋足了劲往`IN`语句里塞东西呀,我就见过有的同学写的`IN`语句里有20000个参数的🤣🤣,这就意味着`MySQL`的查询优化器为了计算这些单点区间对应的索引记录条数,要进行20000次`index dive`操作,这性能损耗可就大了,搞不好计算这些单点区间对应的索引记录条数的成本比直接全表扫描的成本都大了。设计`MySQL`的大佬们多聪明啊,他们当然考虑到了这种情况,所以提供了一个系统变量`eq_range_index_dive_limit`,我们看一下在`MySQL 5.7.21`中这个系统变量的默认值: `mysql\u0026gt; SHOW VARIABLES LIKE \u0026#39;%dive%\u0026#39;; +---------------------------+-------+ | Variable_name | Value | +---------------------------+-------+ | eq_range_index_dive_limit | 200 | +---------------------------+-------+ 1 row in set (0.08 sec)` 也就是说如果我们的`IN`语句中的参数个数小于200个的话,将使用`index dive`的方式计算各个单点区间对应的记录条数,如果大于或等于200个的话,可就不能使用`index dive`了,要使用所谓的索引统计数据来进行估算。怎么个估算法?继续往下看。 像会为每个表维护一份统计数据一样,`MySQL`也会为表中的每一个索引维护一份统计数据,查看某个表中索引的统计数据可以使用`SHOW INDEX FROM 表名`的语法,比如我们查看一下`single_table`的各个索引的统计数据可以这么写: `mysql\u0026gt; SHOW INDEX FROM single_table; +--------------+------------+--------------+--------------+-------------+-----------+-------------+----------+--------+------+------------+---------+---------------+ | Table | Non_unique | Key_name | Seq_in_index | Column_name | Collation | Cardinality | Sub_part | Packed | Null | Index_type | Comment | Index_comment | +--------------+------------+--------------+--------------+-------------+-----------+-------------+----------+--------+------+------------+---------+---------------+ | single_table | 0 | PRIMARY | 1 | id | A | 9693 | NULL | NULL | | BTREE | | | | single_table | 0 | idx_key2 | 1 | key2 | A | 9693 | NULL | NULL | YES | BTREE | | | | single_table | 1 | idx_key1 | 1 | key1 | A | 968 | NULL | NULL | YES | BTREE | | | | single_table | 1 | idx_key3 | 1 | key3 | A | 799 | NULL | NULL | YES | BTREE | | | | single_table | 1 | idx_key_part | 1 | key_part1 | A | 9673 | NULL | NULL | YES | BTREE | | | | single_table | 1 | idx_key_part | 2 | key_part2 | A | 9999 | NULL | NULL | YES | BTREE | | | | single_table | 1 | idx_key_part | 3 | key_part3 | A | 10000 | NULL | NULL | YES | BTREE | | | +--------------+------------+--------------+--------------+-------------+-----------+-------------+----------+--------+------+------------+---------+---------------+ 7 rows in set (0.01 sec)` 哇唔,竟然有这么多属性,不过好在这些属性都不难理解,我们就都介绍一遍吧: 属性名 描述 `Table` 索引所属表的名称。 `Non_unique` 索引列的值是否是唯一的,聚簇索引和唯一二级索引的该列值为`0`,普通二级索引该列值为`1`。 `Key_name` 索引的名称。 `Seq_in_index` 索引列在索引中的位置,从1开始计数。比如对于联合索引`idx_key_part`,来说,`key_part1`、`key_part2`和`key_part3`对应的位置分别是1、2、3。 `Column_name` 索引列的名称。 `Collation` 索引列中的值是按照何种排序方式存放的,值为`A`时代表升序存放,为`NULL`时代表降序存放。 `Cardinality` 索引列中不重复值的数量。后边我们会重点看这个属性的。 `Sub_part` 对于存储字符串或者字节串的列来说,有时候我们只想对这些串的前`n`个字符或字节建立索引,这个属性表示的就是那个`n`值。如果对完整的列建立索引的话,该属性的值就是`NULL`。 `Packed` 索引列如何被压缩,`NULL`值表示未被压缩。这个属性我们暂时不了解,可以先忽略掉。 `Null` 该索引列是否允许存储`NULL`值。 `Index_type` 使用索引的类型,我们最常见的就是`BTREE`,其实也就是`B+`树索引。 `Comment` 索引列注释信息。 `Index_comment` 索引注释信息。 上述属性除了`Packed`大家可能看不懂以外,应该没有什么看不懂的了,如果有的话肯定是大家看前面文章的时候跳过了什么东西。其实我们现在最在意的是`Cardinality`属性,`Cardinality`直译过来就是`基数`的意思,表示索引列中不重复值的个数。比如对于一个一万行记录的表来说,某个索引列的`Cardinality`属性是`10000`,那意味着该列中没有重复的值,如果`Cardinality`属性是`1`的话,就意味着该列的值全部是重复的。不过需要注意的是,**对于InnoDB存储引擎来说,使用SHOW INDEX语句展示出来的某个索引列的Cardinality属性是一个估计值,并不是精确的**。关于这个`Cardinality`属性的值是如何被计算出来的我们后边再说,先看看它有什么用途。 前面说道,当`IN`语句中的参数个数大于或等于系统变量`eq_range_index_dive_limit`的值的话,就不会使用`index dive`的方式计算各个单点区间对应的索引记录条数,而是使用索引统计数据,这里所指的`索引统计数据`指的是这两个值: + 使用`SHOW TABLE STATUS`展示出的`Rows`值,也就是一个表中有多少条记录。 这个统计数据我们在前面介绍全表扫描成本的时候说过很多遍了,就不赘述了。 + 使用`SHOW INDEX`语句展示出的`Cardinality`属性。 结合上一个`Rows`统计数据,我们可以针对索引列,计算出平均一个值重复多少次。 `一个值的重复次数 ≈ Rows ÷ Cardinality` 以`single_table`表的`idx_key1`索引为例,它的`Rows`值是`9693`,它对应索引列`key1`的`Cardinality`值是`968`,所以我们可以计算`key1`列平均单个值的重复次数就是: `9693 ÷ 968 ≈ 10(条)` 此时再看上面那条查询语句: `SELECT * FROM single_table WHERE key1 IN (\u0026#39;aa1\u0026#39;, \u0026#39;aa2\u0026#39;, \u0026#39;aa3\u0026#39;, ... , \u0026#39;zzz\u0026#39;);` 假设`IN`语句中有20000个参数的话,就直接使用统计数据来估算这些参数需要单点区间对应的记录条数了,每个参数大约对应`10`条记录,所以总共需要回表的记录数就是: `20000 x 10 = 200000` 使用统计数据来计算单点区间对应的索引记录条数可比`index dive`的方式简单多了,但是它的致命弱点就是:**不精确!**。使用统计数据算出来的查询成本与实际所需的成本可能相差非常大。 `小贴士:大家需要注意一下,在MySQL 5.7.3以及之前的版本中,eq_range_index_dive_limit的默认值为10,之后的版本默认值为200。所以如果大家采用的是5.7.3以及之前的版本的话,很容易采用索引统计数据而不是index dive的方式来计算查询成本。当你的查询中使用到了IN查询,但是却实际没有用到索引,就应该考虑一下是不是由于 eq_range_index_dive_limit 值太小导致的。` # 连接查询的成本 ## 准备工作 连接查询至少是要有两个表的,只有一个`single_table`表是不够的,所以为了故事的顺利发展,我们直接构造一个和`single_table`表一模一样的`single_table2`表。为了简便起见,我们把`single_table`表称为`s1`表,把`single_table2`表称为`s2`表。 ## Condition filtering介绍 我们前面说过,`MySQL`中连接查询采用的是嵌套循环连接算法,驱动表会被访问一次,被驱动表可能会被访问多次,所以对于两表连接查询来说,它的查询成本由下面两个部分构成: - 单次查询驱动表的成本 - 多次查询被驱动表的成本(**具体查询多少次取决于对驱动表查询的结果集中有多少条记录**) 我们把对驱动表进行查询后得到的记录条数称之为驱动表的`扇出`(英文名:`fanout`)。很显然驱动表的扇出值越小,对被驱动表的查询次数也就越少,连接查询的总成本也就越低。当查询优化器想计算整个连接查询所使用的成本时,就需要计算出驱动表的扇出值,有的时候扇出值的计算是很容易的,比如下面这两个查询: + 查询一: `SELECT * FROM single_table AS s1 INNER JOIN single_table2 AS s2;` 假设使用`s1`表作为驱动表,很显然对驱动表的单表查询只能使用全表扫描的方式执行,驱动表的扇出值也很明确,那就是驱动表中有多少记录,扇出值就是多少。我们前面说过,统计数据中`s1`表的记录行数是`9693`,也就是说优化器就直接会把`9693`当作在`s1`表的扇出值。 + 查询二: `SELECT * FROM single_table AS s1 INNER JOIN single_table2 AS s2 WHERE s1.key2 \u0026gt;10 AND s1.key2 \u0026lt; 1000;` 仍然假设`s1`表是驱动表的话,很显然对驱动表的单表查询可以使用`idx_key2`索引执行查询。此时`idx_key2`的范围区间`(10, 1000)`中有多少条记录,那么扇出值就是多少。我们前面计算过,满足`idx_key2`的范围区间`(10, 1000)`的记录数是95条,也就是说本查询中优化器会把`95`当作驱动表`s1`的扇出值。 事情当然不会总是一帆风顺的,要不然剧情就太平淡了。有的时候扇出值的计算就变得很棘手,比方说下面几个查询: + 查询三: `SELECT * FROM single_table AS s1 INNER JOIN single_table2 AS s2 WHERE s1.common_field \u0026gt; \u0026#39;xyz\u0026#39;;` 本查询和`查询一`类似,只不过对于驱动表`s1`多了一个`common_field \u0026gt; \u0026#39;xyz\u0026#39;`的搜索条件。查询优化器又不会真正的去执行查询,所以它只能`猜`这`9693`记录里有多少条记录满足`common_field \u0026gt; \u0026#39;xyz\u0026#39;`条件。 + 查询四: `SELECT * FROM single_table AS s1 INNER JOIN single_table2 AS s2 WHERE s1.key2 \u0026gt; 10 AND s1.key2 \u0026lt; 1000 AND s1.common_field \u0026gt; \u0026#39;xyz\u0026#39;;` 本查询和`查询二`类似,只不过对于驱动表`s1`也多了一个`common_field \u0026gt; \u0026#39;xyz\u0026#39;`的搜索条件。不过因为本查询可以使用`idx_key2`索引,所以只需要从符合二级索引范围区间的记录中猜有多少条记录符合`common_field \u0026gt; \u0026#39;xyz\u0026#39;`条件,也就是只需要猜在`95`条记录中有多少符合`common_field \u0026gt; \u0026#39;xyz\u0026#39;`条件。 + 查询五: `SELECT * FROM single_table AS s1 INNER JOIN single_table2 AS s2 WHERE s1.key2 \u0026gt; 10 AND s1.key2 \u0026lt; 1000 AND s1.key1 IN (\u0026#39;a\u0026#39;, \u0026#39;b\u0026#39;, \u0026#39;c\u0026#39;) AND s1.common_field \u0026gt; \u0026#39;xyz\u0026#39;;` 本查询和`查询二`类似,不过在驱动表`s1`选取`idx_key2`索引执行查询后,优化器需要从符合二级索引范围区间的记录中猜有多少条记录符合下面两个条件: + `key1 IN (\u0026#39;a\u0026#39;, \u0026#39;b\u0026#39;, \u0026#39;c\u0026#39;)` + `common_field \u0026gt; \u0026#39;xyz\u0026#39;` 也就是优化器需要猜在`95`条记录中有多少符合上述两个条件的。 说了这么多,其实就是想表达在这两种情况下计算驱动表扇出值时需要靠`猜`: - 如果使用的是全表扫描的方式执行的单表查询,那么计算驱动表扇出时需要猜满足搜索条件的记录到底有多少条。 - 如果使用的是索引执行的单表扫描,那么计算驱动表扇出的时候需要猜满足除使用到对应索引的搜索条件外的其他搜索条件的记录有多少条。 设计`MySQL`的大佬把这个`猜`的过程称之为`condition filtering`。当然,这个过程可能会使用到索引,也可能使用到统计数据,也可能就是设计`MySQL`的大佬单纯的瞎猜,整个评估过程挺复杂的,再仔细的介绍一遍可能引起大家的生理不适,所以我们就跳过了。 `小贴士:在MySQL 5.7之前的版本中,查询优化器在计算驱动表扇出时,如果是使用全表扫描的话,就直接使用表中记录的数量作为扇出值,如果使用索引的话,就直接使用满足范围条件的索引记录条数作为扇出值。在MySQL 5.7中,设计MySQL的大佬引入了这个condition filtering的功能,就是还要猜一猜剩余的那些搜索条件能把驱动表中的记录再过滤多少条,其实本质上就是为了让成本估算更精确。我们所说的纯粹瞎猜其实是很不严谨的,设计MySQL的大佬们称之为启发式规则(heuristic),大家有兴趣的可以再深入了解一下。` ## 两表连接的成本分析 连接查询的成本计算公式是这样的: `连接查询总成本 = 单次访问驱动表的成本 + 驱动表扇出数 x 单次访问被驱动表的成本` 对于左(外)连接和右(外)连接查询来说,它们的驱动表是固定的,所以想要得到最优的查询方案只需要: + 分别为驱动表和被驱动表选择成本最低的访问方法。 可是对于内连接来说,驱动表和被驱动表的位置是可以互换的,所以需要考虑两个方面的问题: + 不同的表作为驱动表最终的查询成本可能是不同的,也就是需要考虑最优的表连接顺序。 + 然后分别为驱动表和被驱动表选择成本最低的访问方法。 很显然,计算内连接查询成本的方式更麻烦一些,下面我们就以内连接为例来看看如何计算出最优的连接查询方案。 `小贴士:左(外)连接和右(外)连接查询在某些特殊情况下可以被优化为内连接查询,我们在之后的章节中会仔细介绍的,稍安勿躁。` 比如对于下面这个查询来说: `SELECT * FROM single_table AS s1 INNER JOIN single_table2 AS s2 ON s1.key1 = s2.common_field WHERE s1.key2 \u0026gt; 10 AND s1.key2 \u0026lt; 1000 AND s2.key2 \u0026gt; 1000 AND s2.key2 \u0026lt; 2000;` 可以选择的连接顺序有两种: - `s1`连接`s2`,也就是`s1`作为驱动表,`s2`作为被驱动表。 - `s2`连接`s1`,也就是`s2`作为驱动表,`s1`作为被驱动表。 查询优化器需要**分别考虑这两种情况下的最优查询成本,然后选取那个成本更低的连接顺序以及该连接顺序下各个表的最优访问方法作为最终的查询计划**。我们分别来看一下(定性的分析一下,不像分析单表查询那样定量的分析了): + 使用`s1`作为驱动表的情况 + 分析对于驱动表的成本最低的执行方案 首先看一下涉及`s1`表单表的搜索条件有哪些: + `s1.key2 \u0026gt; 10 AND s1.key2 \u0026lt; 1000` 所以这个查询可能使用到`idx_key2`索引,从全表扫描和使用`idx_key2`这两个方案中选出成本最低的那个,这个过程我们上面都介绍过了,很显然使用`idx_key2`执行查询的成本更低些。 + 然后分析对于被驱动表的成本最低的执行方案 此时涉及被驱动表`idx_key2`的搜索条件就是: + `s2.common_field = 常数`(这是因为对驱动表`s1`结果集中的每一条记录,都需要进行一次被驱动表`s2`的访问,此时那些涉及两表的条件现在相当于只涉及被驱动表`s2`了。) + `s2.key2 \u0026gt; 1000 AND s2.key2 \u0026lt; 2000` 很显然,第一个条件由于`common_field`没有用到索引,所以并没有什么卵用,此时访问`single_table2`表时可用的方案也是全表扫描和使用`idx_key2`两种,很显然使用`idx_key2`的成本更小。 所以此时使用`single_table`作为驱动表时的总成本就是(暂时不考虑使用`join buffer`对成本的影响): `使用idx_key2访问s1的成本 + s1的扇出 × 使用idx_key2访问s2的成本` + 使用`s2`作为驱动表的情况 + 分析对于驱动表的成本最低的执行方案 首先看一下涉及`s2`表单表的搜索条件有哪些: + `s2.key2 \u0026gt; 10 AND s2.key2 \u0026lt; 1000` 所以这个查询可能使用到`idx_key2`索引,从全表扫描和使用`idx_key2`这两个方案中选出成本最低的那个,这个过程我们上面都介绍过了,很显然使用`idx_key2`执行查询的成本更低些。 + 然后分析对于被驱动表的成本最低的执行方案 此时涉及被驱动表`idx_key2`的搜索条件就是: + `s1.key1 = 常数` + `s1.key2 \u0026gt; 1000 AND s1.key2 \u0026lt; 2000` 这时就很有趣了,使用`idx_key1`可以进行`ref`方式的访问,使用`idx_key2`可以使用`range`方式的访问。这是优化器需要从全表扫描、使用`idx_key1`、使用`idx_key2`这几个方案里选出一个成本最低的方案。这里有个问题啊,因为`idx_key2`的范围区间是确定的:`(10, 1000)`,怎么计算使用`idx_key2`的成本我们上面已经说过了,可是在没有真正执行查询前,`s1.key1 = 常数`中的`常数`值我们是不知道的,怎么衡量使用`idx_key1`执行查询的成本呢?其实很简单,直接使用索引统计数据就好了(就是索引列平均一个值重复多少次)。一般情况下,`ref`的访问方式要比`range`成本最低,这里假设使用`idx_key1`进行对`s2`的访问。 所以此时使用`single_table`作为驱动表时的总成本就是: `使用idx_key2访问s2的成本 + s2的扇出 × 使用idx_key1访问s1的成本` 最后优化器会比较这两种方式的最优访问成本,选取那个成本更低的连接顺序去真正的执行查询。从上面的计算过程也可以看出来,连接查询成本占大头的其实是`驱动表扇出数 x 单次访问被驱动表的成本`,所以我们的优化重点其实是下面这两个部分: + 尽量减少驱动表的扇出 + 对被驱动表的访问成本尽量低 这一点对于我们实际书写连接查询语句时十分有用,我们需要**尽量在被驱动表的连接列上建立索引**,这样就可以使用`ref`访问方法来降低访问被驱动表的成本了。如果可以,被驱动表的连接列最好是该表的主键或者唯一二级索引列,这样就可以把访问被驱动表的成本降到更低了。 ## 多表连接的成本分析 首先要考虑一下多表连接时可能产生出多少种连接顺序: + 对于两表连接,比如表A和表B连接 只有 AB、BA这两种连接顺序。其实相当于`2 × 1 = 2`种连接顺序。 + 对于三表连接,比如表A、表B、表C进行连接 有ABC、ACB、BAC、BCA、CAB、CBA这么6种连接顺序。其实相当于`3 × 2 × 1 = 6`种连接顺序。 + 对于四表连接的话,则会有`4 × 3 × 2 × 1 = 24`种连接顺序。 + 对于`n`表连接的话,则有 `n × (n-1) × (n-2) × ··· × 1`种连接顺序,就是n的阶乘种连接顺序,也就是`n!`。 有`n`个表进行连接,`MySQL`查询优化器要每一种连接顺序的成本都计算一遍么?那可是`n!`种连接顺序呀。其实真的是要都算一遍,不过设计`MySQL`的大佬们想了很多办法减少计算非常多种连接顺序的成本的方法: + 提前结束某种顺序的成本评估 `MySQL`在计算各种链接顺序的成本之前,会维护一个全局的变量,这个变量表示当前最小的连接查询成本。如果在分析某个连接顺序的成本时,该成本已经超过当前最小的连接查询成本,那就压根儿不对该连接顺序继续往下分析了。比方说A、B、C三个表进行连接,已经得到连接顺序`ABC`是当前的最小连接成本,比方说`10.0`,在计算连接顺序`BCA`时,发现`B`和`C`的连接成本就已经大于`10.0`时,就不再继续往后分析`BCA`这个连接顺序的成本了。 + 系统变量`optimizer_search_depth` 为了防止无穷无尽的分析各种连接顺序的成本,设计`MySQL`的大佬们提出了`optimizer_search_depth`系统变量,如果连接表的个数小于该值,那么就继续穷举分析每一种连接顺序的成本,否则只对与`optimizer_search_depth`值相同数量的表进行穷举分析。很显然,该值越大,成本分析的越精确,越容易得到好的执行计划,但是消耗的时间也就越长,否则得到不是很好的执行计划,但可以省掉很多分析连接成本的时间。 + 根据某些规则压根儿就不考虑某些连接顺序 即使是有上面两条规则的限制,但是分析多个表不同连接顺序成本花费的时间还是会很长,所以设计`MySQL`的大佬干脆提出了一些所谓的`启发式规则`(就是根据以往经验指定的一些规则),凡是不满足这些规则的连接顺序压根儿就不分析,这样可以极大的减少需要分析的连接顺序的数量,但是也可能造成错失最优的执行计划。他们提供了一个系统变量`optimizer_prune_level`来控制到底是不是用这些启发式规则。 # 调节成本常数 我们前面之介绍了两个`成本常数`: + 读取一个页面花费的成本默认是`1.0` + 检测一条记录是否符合搜索条件的成本默认是`0.2` 其实除了这两个成本常数,`MySQL`还支持好多呢,它们被存储到了`mysql`数据库(这是一个系统数据库,我们之前介绍过)的两个表中: `mysql\u0026gt; SHOW TABLES FROM mysql LIKE \u0026#39;%cost%\u0026#39;; +--------------------------+ | Tables_in_mysql (%cost%) | +--------------------------+ | engine_cost | | server_cost | +--------------------------+ 2 rows in set (0.00 sec)` 我们在第一章中就说过,一条语句的执行其实是分为两层的: + `server`层 + 存储引擎层 在`server`层进行连接管理、查询缓存、语法解析、查询优化等操作,在存储引擎层执行具体的数据存取操作。也就是说一条语句在`server`层中执行的成本是和它操作的表使用的存储引擎是没关系的,所以关于这些操作对应的`成本常数`就存储在了`server_cost`表中,而依赖于存储引擎的一些操作对应的`成本常数`就存储在了`engine_cost`表中。 ## mysql.server_cost表 `server_cost`表中在`server`层进行的一些操作对应的`成本常数`,具体内容如下: `mysql\u0026gt; SELECT * FROM mysql.server_cost; +------------------------------+------------+---------------------+---------+ | cost_name | cost_value | last_update | comment | +------------------------------+------------+---------------------+---------+ | disk_temptable_create_cost | NULL | 2018-01-20 12:03:21 | NULL | | disk_temptable_row_cost | NULL | 2018-01-20 12:03:21 | NULL | | key_compare_cost | NULL | 2018-01-20 12:03:21 | NULL | | memory_temptable_create_cost | NULL | 2018-01-20 12:03:21 | NULL | | memory_temptable_row_cost | NULL | 2018-01-20 12:03:21 | NULL | | row_evaluate_cost | NULL | 2018-01-20 12:03:21 | NULL | +------------------------------+------------+---------------------+---------+ 6 rows in set (0.05 sec)` 我们先看一下`server_cost`各个列都分别是什么意思: - `cost_name`:表示成本常数的名称。 - `cost_value`:表示成本常数对应的值。如果该列的值为`NULL`的话,意味着对应的成本常数会采用默认值。 - `last_update`:表示最后更新记录的时间。 - `comment`:注释。 从`server_cost`中的内容可以看出来,目前在`server`层的一些操作对应的`成本常数`有以下几种: 成本常数名称 默认值 描述 `disk_temptable_create_cost` `40.0` 创建基于磁盘的临时表的成本,如果增大这个值的话会让优化器尽量少的创建基于磁盘的临时表。 `disk_temptable_row_cost` `1.0` 向基于磁盘的临时表写入或读取一条记录的成本,如果增大这个值的话会让优化器尽量少的创建基于磁盘的临时表。 `key_compare_cost` `0.1` 两条记录做比较操作的成本,多用在排序操作上,如果增大这个值的话会提升`filesort`的成本,让优化器可能更倾向于使用索引完成排序而不是`filesort`。 `memory_temptable_create_cost` `2.0` 创建基于内存的临时表的成本,如果增大这个值的话会让优化器尽量少的创建基于内存的临时表。 `memory_temptable_row_cost` `0.2` 向基于内存的临时表写入或读取一条记录的成本,如果增大这个值的话会让优化器尽量少的创建基于内存的临时表。 `row_evaluate_cost` `0.2` 这个就是我们之前一直使用的检测一条记录是否符合搜索条件的成本,增大这个值可能让优化器更倾向于使用索引而不是直接全表扫描。 `小贴士:MySQL在执行诸如DISTINCT查询、分组查询、Union查询以及某些特殊条件下的排序查询都可能在内部先创建一个临时表,使用这个临时表来辅助完成查询(比如对于DISTINCT查询可以建一个带有UNIQUE索引的临时表,直接把需要去重的记录插入到这个临时表中,插入完成之后的记录就是结果集了)。在数据量大的情况下可能创建基于磁盘的临时表,也就是为该临时表使用MyISAM、InnoDB等存储引擎,在数据量不大时可能创建基于内存的临时表,也就是使用Memory存储引擎。关于更多临时表的细节我们并不打算展开介绍,因为展开可能又需要好几万字了,大家知道创建临时表和对这个临时表进行写入和读取的操作代价还是很高的就行了。` 这些成本常数在`server_cost`中的初始值都是`NULL`,意味着优化器会使用它们的默认值来计算某个操作的成本,如果我们想修改某个成本常数的值的话,需要做两个步骤: + 对我们感兴趣的成本常数做更新操作 比方说我们想把检测一条记录是否符合搜索条件的成本增大到`0.4`,那么就可以这样写更新语句: `UPDATE mysql.server_cost SET cost_value = 0.4 WHERE cost_name = \u0026#39;row_evaluate_cost\u0026#39;;` + 让系统重新加载这个表的值。 使用下面语句即可: `FLUSH OPTIMIZER_COSTS;` 当然,在你修改完某个成本常数后想把它们再改回默认值的话,可以直接把`cost_value`的值设置为`NULL`,再使用`FLUSH OPTIMIZER_COSTS`语句让系统重新加载它就好了。 ## mysql.engine_cost表 `engine_cost表`表中在存储引擎层进行的一些操作对应的`成本常数`,具体内容如下: `mysql\u0026gt; SELECT * FROM mysql.engine_cost; +-------------+-------------+------------------------+------------+---------------------+---------+ | engine_name | device_type | cost_name | cost_value | last_update | comment | +-------------+-------------+------------------------+------------+---------------------+---------+ | default | 0 | io_block_read_cost | NULL | 2018-01-20 12:03:21 | NULL | | default | 0 | memory_block_read_cost | NULL | 2018-01-20 12:03:21 | NULL | +-------------+-------------+------------------------+------------+---------------------+---------+ 2 rows in set (0.05 sec)` 与`server_cost`相比,`engine_cost`多了两个列: + `engine_name`列:指成本常数适用的存储引擎名称。如果该值为`default`,意味着对应的成本常数适用于所有的存储引擎。 + `device_type`列:指存储引擎使用的设备类型,这主要是为了区分常规的机械硬盘和固态硬盘,不过在`MySQL 5.7.21`这个版本中并没有对机械硬盘的成本和固态硬盘的成本作区分,所以该值默认是`0`。 我们从`engine_cost`表中的内容可以看出来,目前支持的存储引擎成本常数只有两个: 成本常数名称 默认值 描述 `io_block_read_cost` `1.0` 从磁盘上读取一个块对应的成本。请注意我使用的是`块`,而不是`页`这个词儿。对于`InnoDB`存储引擎来说,一个`页`就是一个块,不过对于`MyISAM`存储引擎来说,默认是以`4096`字节作为一个块的。增大这个值会加重`I/O`成本,可能让优化器更倾向于选择使用索引执行查询而不是执行全表扫描。 `memory_block_read_cost` `1.0` 与上一个参数类似,只不过衡量的是从内存中读取一个块对应的成本。 大家看完这两个成本常数的默认值是不是有些疑惑,怎么从内存中和从磁盘上读取一个块的默认成本是一样的,脑子瓦特了?这主要是因为在`MySQL`目前的实现中,并不能准确预测某个查询需要访问的块中有哪些块已经加载到内存中,有哪些块还停留在磁盘上,所以设计`MySQL`的大佬们很粗暴的认为不管这个块有没有加载到内存中,使用的成本都是`1.0`,不过随着`MySQL`的发展,等到可以准确预测哪些块在磁盘上,那些块在内存中的那一天,这两个成本常数的默认值可能会改一改吧。 与更新`server_cost`表中的记录一样,我们也可以通过更新`engine_cost`表中的记录来更改关于存储引擎的成本常数,我们也可以通过为`engine_cost`表插入新记录的方式来添加只针对某种存储引擎的成本常数: + 插入针对某个存储引擎的成本常数 比如我们想增大`InnoDB`存储引擎页面`I/O`的成本,书写正常的插入语句即可: `INSERT INTO mysql.engine_cost VALUES (\u0026#39;InnoDB\u0026#39;, 0, \u0026#39;io_block_read_cost\u0026#39;, 2.0, CURRENT_TIMESTAMP, \u0026#39;increase Innodb I/O cost\u0026#39;);` - 让系统重新加载这个表的值。 使用下面语句即可: `FLUSH OPTIMIZER_COSTS;` "},{"id":22,"href":"/zh/docs/technology/MySQL/MySQL%E6%98%AF%E6%80%8E%E6%A0%B7%E8%BF%90%E8%A1%8C%E7%9A%84/%E7%AC%AC10%E7%AB%A0_%E6%9D%A1%E6%9D%A1%E5%A4%A7%E8%B7%AF%E9%80%9A%E7%BD%97%E9%A9%AC-%E5%8D%95%E8%A1%A8%E8%AE%BF%E9%97%AE%E6%96%B9%E6%B3%95/","title":"第10章_条条大路通罗马-单表访问方法","section":"My Sql是怎样运行的","content":"第10章 条条大路通罗马-单表访问方法\n对于我们这些MySQL的使用者来说,MySQL其实就是一个软件,平时用的最多的就是查询功能。DBA时不时丢过来一些慢查询语句让优化,我们如果连查询是怎么执行的都不清楚还优化个毛线,所以是时候掌握真正的技术了。我们在第一章的时候就曾说过,MySQL Server有一个称为查询优化器的模块,一条查询语句进行语法解析之后就会被交给查询优化器来进行优化,优化的结果就是生成一个所谓的执行计划,这个执行计划表明了应该使用哪些索引进行查询,表之间的连接顺序是什么样的,最后会按照执行计划中的步骤调用存储引擎提供的方法来真正的执行查询,并将查询结果返回给用户。不过查询优化这个主题有点儿大,在学会跑之前还得先学会走,所以本章先来看看MySQL怎么执行单表查询(就是FROM子句后边只有一个表,最简单的那种查询~)。不过需要强调的一点是,在学习本章前务必看过前面关于记录结构、数据页结构以及索引的部分,如果你不能保证这些东西已经完全掌握,那么本章不适合你。\n为了故事的顺利发展,我们先得有个表: CREATE TABLE single_table ( id INT NOT NULL AUTO_INCREMENT, key1 VARCHAR(100), key2 INT, key3 VARCHAR(100), key_part1 VARCHAR(100), key_part2 VARCHAR(100), key_part3 VARCHAR(100), common_field VARCHAR(100), PRIMARY KEY (id), KEY idx_key1 (key1), UNIQUE KEY idx_key2 (key2), KEY idx_key3 (key3), KEY idx_key_part(key_part1, key_part2, key_part3) ) Engine=InnoDB CHARSET=utf8; 我们为这个single_table表建立了1个聚簇索引和4个二级索引,分别是:\n为id列建立的聚簇索引。\n为key1列建立的idx_key1二级索引。\n为key2列建立的idx_key2二级索引,而且该索引是唯一二级索引。\n为key3列建立的idx_key3二级索引。\n为key_part1、key_part2、key_part3列建立的idx_key_part二级索引,这也是一个联合索引。\n然后我们需要为这个表插入10000行记录,除id列外其余的列都插入随机值就好了,具体的插入语句我就不写了,自己写个程序插入吧(id列是自增主键列,不需要我们手动插入)。\n访问方法(access method)的概念 # 想必各位都用过高德地图来查找到某个地方的路线吧(此处没有为高德地图打广告的意思,他们没给我钱,大家用百度地图也可以啊),如果我们搜西安钟楼到大雁塔之间的路线的话,地图软件会给出n种路线供我们选择,如果我们实在闲的没事儿干并且足够有钱的话,还可以用南辕北辙的方式绕地球一圈到达目的地。也就是说,不论采用哪一种方式,我们最终的目标就是到达大雁塔这个地方。回到MySQL中来,我们平时所写的那些查询语句本质上只是一种声明式的语法,只是告诉MySQL我们要获取的数据符合哪些规则,至于MySQL背地里是怎么把查询结果搞出来的那是MySQL自己的事儿。对于单个表的查询来说,设计MySQL的大佬把查询的执行方式大致分为下面两种:\n使用全表扫描进行查询\n这种执行方式很好理解,就是把表的每一行记录都扫一遍嘛,把符合搜索条件的记录加入到结果集就完了。不管是什么查询都可以使用这种方式执行,当然,这种也是最笨的执行方式。\n使用索引进行查询\n因为直接使用全表扫描的方式执行查询要遍历好多记录,所以代价可能太大了。如果查询语句中的搜索条件可以使用到某个索引,那直接使用索引来执行查询可能会加快查询执行的时间。使用索引来执行查询的方式五花八门,又可以细分为许多种类:\n+ 针对主键或唯一二级索引的等值查询\n+ 针对普通二级索引的等值查询\n+ 针对索引列的范围查询\n+ 直接扫描整个索引\n设计MySQL的大佬把MySQL执行查询语句的方式称之为访问方法或者访问类型。同一个查询语句可能可以使用多种不同的访问方法来执行,虽然最后的查询结果都是一样的,但是执行的时间可能差老鼻子远了,就像是从钟楼到大雁塔,你可以坐火箭去,也可以坐飞机去,当然也可以坐乌龟去。下面细细道来各种访问方法的具体内容。\nconst # 有的时候我们可以通过主键列来定位一条记录,比方说这个查询: SELECT * FROM single_table WHERE id = 1438; MySQL会直接利用主键值在聚簇索引中定位对应的用户记录,就像这样:\n原谅我把聚簇索引对应的复杂的B+树结构搞了一个极度精简版,为了突出重点,我们忽略掉了页的结构,直接把所有的叶子节点的记录都放在一起展示,而且记录中只展示我们关心的索引列,对于single_table表的聚簇索引来说,展示的就是id列。我们想突出的重点就是:B+树叶子节点中的记录是按照索引列排序的,对于的聚簇索引来说,它对应的B+树叶子节点中的记录就是按照id列排序的。B+树本来就是一个矮矮的大胖子,所以这样根据主键值定位一条记录的速度贼快。类似的,我们根据唯一二级索引列来定位一条记录的速度也是贼快的,比如下面这个查询: SELECT * FROM single_table WHERE key2 = 3841; 这个查询的执行过程的示意图就是这样:\n可以看到这个查询的执行分两步,第一步先从idx_key2对应的B+树索引中根据key2列与常数的等值比较条件定位到一条二级索引记录,然后再根据该记录的id值到聚簇索引中获取到完整的用户记录。\n设计MySQL的大佬认为通过主键或者唯一二级索引列与常数的等值比较来定位一条记录是像坐火箭一样快的,所以他们把这种通过主键或者唯一二级索引列来定位一条记录的访问方法定义为:const,意思是常数级别的,代价是可以忽略不计的。不过这种const访问方法只能在主键列或者唯一二级索引列和一个常数进行等值比较时才有效,如果主键或者唯一二级索引是由多个列构成的话,索引中的每一个列都需要与常数进行等值比较,这个const访问方法才有效(这是因为只有该索引中全部列都采用等值比较才可以定位唯一的一条记录)。\n对于唯一二级索引来说,查询该列为NULL值的情况比较特殊,比如这样: SELECT * FROM single_table WHERE key2 IS NULL; 因为唯一二级索引列并不限制 NULL 值的数量,所以上述语句可能访问到多条记录,也就是说 上面这个语句不可以使用const访问方法来执行(至于是什么访问方法我们下面马上说)。\nref # 有时候我们对某个普通的二级索引列与常数进行等值比较,比如这样: SELECT * FROM single_table WHERE key1 = 'abc'; 对于这个查询,我们当然可以选择全表扫描来逐一对比搜索条件是否满足要求,我们也可以先使用二级索引找到对应记录的id值,然后再回表到聚簇索引中查找完整的用户记录。由于普通二级索引并不限制索引列值的唯一性,所以可能找到多条对应的记录,也就是说使用二级索引来执行查询的代价取决于等值匹配到的二级索引记录条数。如果匹配的记录较少,则回表的代价还是比较低的,所以MySQL可能选择使用索引而不是全表扫描的方式来执行查询。设计MySQL的大佬就把这种搜索条件为二级索引列与常数等值比较,采用二级索引来执行查询的访问方法称为:ref。我们看一下采用ref访问方法执行查询的图示:\n从图示中可以看出,对于普通的二级索引来说,通过索引列进行等值比较后可能匹配到多条连续的记录,而不是像主键或者唯一二级索引那样最多只能匹配1条记录,所以这种ref访问方法比const差了那么一丢丢,但是在二级索引等值比较时匹配的记录数较少时的效率还是很高的(如果匹配的二级索引记录太多那么回表的成本就太大了),跟坐高铁差不多。不过需要注意下面两种情况:\n二级索引列值为NULL的情况\n不论是普通的二级索引,还是唯一二级索引,它们的索引列对包含NULL值的数量并不限制,所以我们采用key IS NULL这种形式的搜索条件最多只能使用ref的访问方法,而不是const的访问方法。\n对于某个包含多个索引列的二级索引来说,只要是最左边的连续索引列是与常数的等值比较就可能采用ref的访问方法,比方说下面这几个查询:\nSELECT * FROM single_table WHERE key_part1 = \u0026#39;god like\u0026#39; AND key_part2 = \u0026#39;legendary\u0026#39;; SELECT * FROM single_table WHERE key_part1 = \u0026#39;god like\u0026#39; AND key_part2 = \u0026#39;legendary\u0026#39; AND key_part3 = \u0026#39;penta kill\u0026#39;; `但是如果最左边的连续索引列并不全部是等值比较的话,它的访问方法就不能称为`ref`了,比方说这样:` SELECT * FROM single_table WHERE key_part1 = \u0026#39;god like\u0026#39; AND key_part2 \u0026gt; \u0026#39;legendary\u0026#39;; ``` # ref_or_null 有时候我们不仅想找出某个二级索引列的值等于某个常数的记录,还想把该列的值为`NULL`的记录也找出来,就像下面这个查询: `SELECT * FROM single_demo WHERE key1 = \u0026#39;abc\u0026#39; OR key1 IS NULL;` 当使用二级索引而不是全表扫描的方式执行该查询时,这种类型的查询使用的访问方法就称为`ref_or_null`,这个`ref_or_null`访问方法的执行过程如下: ![](img/10-04.png) 可以看到,上面的查询相当于先分别从`idx_key1`索引对应的`B+`树中找出`key1 IS NULL`和`key1 = \u0026#39;abc\u0026#39;`的两个连续的记录范围,然后根据这些二级索引记录中的`id`值再回表查找完整的用户记录。 # range 我们之前介绍的几种访问方法都是在对索引列与某一个常数进行等值比较的时候才可能使用到(`ref_or_null`比较奇特,还计算了值为`NULL`的情况),但是有时候我们面对的搜索条件更复杂,比如下面这个查询: `SELECT * FROM single_table WHERE key2 IN (1438, 6328) OR (key2 \u0026gt;= 38 AND key2 \u0026lt;= 79);` 我们当然还可以使用全表扫描的方式来执行这个查询,不过也可以使用`二级索引 + 回表`的方式执行,如果采用`二级索引 + 回表`的方式来执行的话,那么此时的搜索条件就不只是要求索引列与常数的等值匹配了,而是索引列需要匹配某个或某些范围的值,在本查询中`key2`列的值只要匹配下列3个范围中的任何一个就算是匹配成功了: + `key2`的值是`1438` + `key2`的值是`6328` + `key2`的值在`38`和`79`之间。 设计`MySQL`的大佬把这种利用索引进行范围匹配的访问方法称之为:`range`。 `小贴士:此处所说的使用索引进行范围匹配中的 `索引` 可以是聚簇索引,也可以是二级索引。` 如果把这几个所谓的`key2`列的值需要满足的`范围`在数轴上体现出来的话,那应该是这个样子: ![](img/10-05.png) 也就是从数学的角度看,每一个所谓的范围都是数轴上的一个`区间`,3个范围也就对应着3个区间: + 范围1:`key2 = 1438` + 范围2:`key2 = 6328` + 范围3:`key2 ∈ [38, 79]`,注意这里是闭区间。 我们可以把那种索引列等值匹配的情况称之为`单点区间`,上面所说的`范围1`和`范围2`都可以被称为单点区间,像`范围3`这种的我们可以称为连续范围区间。 # index 看下面这个查询: `SELECT key_part1, key_part2, key_part3 FROM single_table WHERE key_part2 = \u0026#39;abc\u0026#39;;` 由于`key_part2`并不是联合索引`idx_key_part`最左索引列,所以我们无法使用`ref`或者`range`访问方法来执行这个语句。但是这个查询符合下面这两个条件: + 它的查询列表只有3个列:`key_part1`, `key_part2`, `key_part3`,而索引`idx_key_part`又包含这三个列。 + 搜索条件中只有`key_part2`列。这个列也包含在索引`idx_key_part`中。 也就是说我们可以直接通过遍历`idx_key_part`索引的叶子节点的记录来比较`key_part2 = \u0026#39;abc\u0026#39;`这个条件是否成立,把匹配成功的二级索引记录的`key_part1`, `key_part2`, `key_part3`列的值直接加到结果集中就行了。由于二级索引记录比聚簇索记录小的多(聚簇索引记录要存储所有用户定义的列以及所谓的隐藏列,而二级索引记录只需要存放索引列和主键),而且这个过程也不用进行回表操作,所以直接遍历二级索引比直接遍历聚簇索引的成本要小很多,设计`MySQL`的大佬就把这种采用遍历二级索引记录的执行方式称之为:`index`。 # all 最直接的查询执行方式就是我们已经提了无数遍的全表扫描,对于`InnoDB`表来说也就是直接扫描聚簇索引,设计`MySQL`的大佬把这种使用全表扫描执行查询的方式称之为:`all`。 # 注意事项 ## 重温 二级索引 \\+ 回表 **一般情况下**只能利用单个二级索引执行查询,比方说下面的这个查询: `SELECT * FROM single_table WHERE key1 = \u0026#39;abc\u0026#39; AND key2 \u0026gt; 1000;` 查询优化器会识别到这个查询中的两个搜索条件: + `key1 = \u0026#39;abc\u0026#39;` + `key2 \u0026gt; 1000` 优化器一般会根据`single_table`表的统计数据来判断到底使用哪个条件到对应的二级索引中查询扫描的行数会更少,选择那个扫描行数较少的条件到对应的二级索引中查询(关于如何比较的细节我们后边的章节中会介绍)。然后将从该二级索引中查询到的结果经过回表得到完整的用户记录后再根据其余的`WHERE`条件过滤记录。一般来说,等值查找比范围查找需要扫描的行数更少(也就是`ref`的访问方法一般比`range`好,但这也不总是一定的,也可能采用`ref`访问方法的那个索引列的值为特定值的行数特别多),所以这里假设优化器决定使用`idx_key1`索引进行查询,那么整个查询过程可以分为两个步骤: + 步骤1:使用二级索引定位记录的阶段,也就是根据条件`key1 = \u0026#39;abc\u0026#39;`从`idx_key1`索引代表的`B+`树中找到对应的二级索引记录。 + 步骤2:回表阶段,也就是根据上一步骤中找到的记录的主键值进行`回表`操作,也就是到聚簇索引中找到对应的完整的用户记录,再根据条件`key2 \u0026gt; 1000`到完整的用户记录继续过滤。将最终符合过滤条件的记录返回给用户。 这里需要特别提醒大家的一点是,**因为二级索引的节点中的记录只包含索引列和主键,所以在步骤1中使用`idx_key1`索引进行查询时只会用到与`key1`列有关的搜索条件,其余条件,比如`key2 \u0026gt; 1000`这个条件在步骤1中是用不到的,只有在步骤2完成回表操作后才能继续针对完整的用户记录中继续过滤**。 `小贴士:需要注意的是,我们说一般情况下执行一个查询只会用到单个二级索引,不过还是有特殊情况的,我们后边会详细介绍的。` ## 明确range访问方法使用的范围区间 其实对于`B+`树索引来说,只要索引列和常数使用`=`、`\u0026lt;=\u0026gt;`、`IN`、`NOT IN`、`IS NULL`、`IS NOT NULL`、`\u0026gt;`、`\u0026lt;`、`\u0026gt;=`、`\u0026lt;=`、`BETWEEN`、`!=`(不等于也可以写成`\u0026lt;\u0026gt;`)或者`LIKE`操作符连接起来,就可以产生一个所谓的`区间`。 `小贴士:LIKE操作符比较特殊,只有在匹配完整字符串或者匹配字符串前缀时才可以利用索引,具体原因我们在前面的章节中介绍过了,这里就不赘述了。 IN操作符的效果和若干个等值匹配操作符`=`之间用`OR`连接起来是一样的,也就是说会产生多个单点区间,比如下面这两个语句的效果是一样的: SELECT * FROM single_table WHERE key2 IN (1438, 6328); SELECT * FROM single_table WHERE key2 = 1438 OR key2 = 6328;` 不过在日常的工作中,一个查询的`WHERE`子句可能有很多个小的搜索条件,这些搜索条件需要使用`AND`或者`OR`操作符连接起来,虽然大家都知道这两个操作符的作用,但我还是要再说一遍: + `cond1 AND cond2` :只有当`cond1`和`cond2`都为`TRUE`时整个表达式才为`TRUE`。 + `cond1 OR cond2`:只要`cond1`或者`cond2`中有一个为`TRUE`整个表达式就为`TRUE`。 当我们想使用`range`访问方法来执行一个查询语句时,重点就是找出该查询可用的索引以及这些索引对应的范围区间。下面分两种情况看一下怎么从由`AND`或`OR`组成的复杂搜索条件中提取出正确的范围区间。 ### 所有搜索条件都可以使用某个索引的情况 有时候每个搜索条件都可以使用到某个索引,比如下面这个查询语句: `SELECT * FROM single_table WHERE key2 \u0026gt; 100 AND key2 \u0026gt; 200;` 这个查询中的搜索条件都可以使用到`key2`,也就是说每个搜索条件都对应着一个`idx_key2`的范围区间。这两个小的搜索条件使用`AND`连接起来,也就是要取两个范围区间的交集,在我们使用`range`访问方法执行查询时,使用的`idx_key2`索引的范围区间的确定过程就如下图所示: ![](img/10-06.png) `key2 \u0026gt; 100`和`key2 \u0026gt; 200`交集当然就是`key2 \u0026gt; 200`了,也就是说上面这个查询使用`idx_key2`的范围区间就是`(200, +∞)`。这东西小学都学过吧,再不济初中肯定都学过。我们再看一下使用`OR`将多个搜索条件连接在一起的情况: `SELECT * FROM single_table WHERE key2 \u0026gt; 100 OR key2 \u0026gt; 200;` `OR`意味着需要取各个范围区间的并集,所以上面这个查询在我们使用`range`访问方法执行查询时,使用的`idx_key2`索引的范围区间的确定过程就如下图所示: ![](img/10-07.png) 也就是说上面这个查询使用`idx_key2`的范围区间就是`(100, +∞)`。 ### 有的搜索条件无法使用索引的情况 比如下面这个查询: `SELECT * FROM single_table WHERE key2 \u0026gt; 100 AND common_field = \u0026#39;abc\u0026#39;;` 请注意,这个查询语句中能利用的索引只有`idx_key2`一个,而`idx_key2`这个二级索引的记录中又不包含`common_field`这个字段,所以在使用二级索引`idx_key2`定位记录的阶段用不到`common_field = \u0026#39;abc\u0026#39;`这个条件,这个条件是在回表获取了完整的用户记录后才使用的,而`范围区间`是为了到索引中取记录中提出的概念,所以在确定`范围区间`的时候不需要考虑`common_field = \u0026#39;abc\u0026#39;`这个条件,我们在为某个索引确定范围区间的时候只需要把用不到相关索引的搜索条件替换为`TRUE`就好了。 `小贴士:之所以把用不到索引的搜索条件替换为TRUE,是因为我们不打算使用这些条件进行在该索引上进行过滤,所以不管索引的记录满不满足这些条件,我们都把它们选取出来,待到之后回表的时候再使用它们过滤。` 我们把上面的查询中用不到`idx_key2`的搜索条件替换后就是这样: `SELECT * FROM single_table WHERE key2 \u0026gt; 100 AND TRUE;` 化简之后就是这样: `SELECT * FROM single_table WHERE key2 \u0026gt; 100;` 也就是说最上面那个查询使用`idx_key2`的范围区间就是:`(100, +∞)`。 再来看一下使用`OR`的情况: `SELECT * FROM single_table WHERE key2 \u0026gt; 100 OR common_field = \u0026#39;abc\u0026#39;;` 同理,我们把使用不到`idx_key2`索引的搜索条件替换为`TRUE`: `SELECT * FROM single_table WHERE key2 \u0026gt; 100 OR TRUE;` 接着化简: `SELECT * FROM single_table WHERE TRUE;` 额,这也就说说明如果我们强制使用`idx_key2`执行查询的话,对应的范围区间就是`(-∞, +∞)`,也就是需要将全部二级索引的记录进行回表,这个代价肯定比直接全表扫描都大了。也就是说一个使用到索引的搜索条件和没有使用该索引的搜索条件使用`OR`连接起来后是无法使用该索引的。 ### 复杂搜索条件下找出范围匹配的区间 有的查询的搜索条件可能特别复杂,光是找出范围匹配的各个区间就挺烦的,比方说下面这个: `SELECT * FROM single_table WHERE (key1 \u0026gt; \u0026#39;xyz\u0026#39; AND key2 = 748 ) OR (key1 \u0026lt; \u0026#39;abc\u0026#39; AND key1 \u0026gt; \u0026#39;lmn\u0026#39;) OR (key1 LIKE \u0026#39;%suf\u0026#39; AND key1 \u0026gt; \u0026#39;zzz\u0026#39; AND (key2 \u0026lt; 8000 OR common_field = \u0026#39;abc\u0026#39;)) ;` 我滴个神,这个搜索条件真是绝了,不过大家不要被复杂的表象迷住了双眼,按着下面这个套路分析一下: + 首先查看`WHERE`子句中的搜索条件都涉及到了哪些列,哪些列可能使用到索引。 这个查询的搜索条件涉及到了`key1`、`key2`、`common_field`这3个列,然后`key1`列有普通的二级索引`idx_key1`,`key2`列有唯一二级索引`idx_key2`。 + 对于那些可能用到的索引,分析它们的范围区间。 + 假设我们使用`idx_key1`执行查询 + 我们需要把那些用不到该索引的搜索条件暂时移除掉,移除方法也简单,直接把它们替换为`TRUE`就好了。上面的查询中除了有关`key2`和`common_field`列不能使用到`idx_key1`索引外,`key1 LIKE \u0026#39;%suf\u0026#39;`也使用不到索引,所以把这些搜索条件替换为`TRUE`之后的样子就是这样: `(key1 \u0026gt; \u0026#39;xyz\u0026#39; AND TRUE ) OR (key1 \u0026lt; \u0026#39;abc\u0026#39; AND key1 \u0026gt; \u0026#39;lmn\u0026#39;) OR (TRUE AND key1 \u0026gt; \u0026#39;zzz\u0026#39; AND (TRUE OR TRUE))` 化简一下上面的搜索条件就是下面这样: `(key1 \u0026gt; \u0026#39;xyz\u0026#39;) OR (key1 \u0026lt; \u0026#39;abc\u0026#39; AND key1 \u0026gt; \u0026#39;lmn\u0026#39;) OR (key1 \u0026gt; \u0026#39;zzz\u0026#39;)` - 替换掉永远为`TRUE`或`FALSE`的条件 因为符合`key1 \u0026lt; \u0026#39;abc\u0026#39; AND key1 \u0026gt; \u0026#39;lmn\u0026#39;`永远为`FALSE`,所以上面的搜索条件可以被写成这样: `(key1 \u0026gt; \u0026#39;xyz\u0026#39;) OR (key1 \u0026gt; \u0026#39;zzz\u0026#39;)` + 继续化简区间 `key1 \u0026gt; \u0026#39;xyz\u0026#39;`和`key1 \u0026gt; \u0026#39;zzz\u0026#39;`之间使用`OR`操作符连接起来的,意味着要取并集,所以最终的结果化简的到的区间就是:`key1 \u0026gt; xyz`。也就是说:**上面那个有一坨搜索条件的查询语句如果使用 idx_key1 索引执行查询的话,需要把满足`key1 \u0026gt; xyz`的二级索引记录都取出来,然后拿着这些记录的id再进行回表,得到完整的用户记录之后再使用其他的搜索条件进行过滤**。 + 假设我们使用`idx_key2`执行查询 + 我们需要把那些用不到该索引的搜索条件暂时使用`TRUE`条件替换掉,其中有关`key1`和`common_field`的搜索条件都需要被替换掉,替换结果就是: `(TRUE AND key2 = 748 ) OR (TRUE AND TRUE) OR (TRUE AND TRUE AND (key2 \u0026lt; 8000 OR TRUE))` 哎呀呀,`key2 \u0026lt; 8000 OR TRUE`的结果肯定是`TRUE`呀,也就是说化简之后的搜索条件成这样了: `key2 = 748 OR TRUE` 这个化简之后的结果就更简单了: `TRUE` 这个结果也就意味着如果我们要使用`idx_key2`索引执行查询语句的话,需要扫描`idx_key2`二级索引的所有记录,然后再回表,这不是得不偿失么,所以这种情况下不会使用`idx_key2`索引的。 ## 索引合并 我们前面说过`MySQL`在一般情况下执行一个查询时最多只会用到单个二级索引,但不是还有特殊情况么,在这些特殊情况下也可能在一个查询中使用到多个二级索引,设计`MySQL`的大佬把这种使用到多个索引来完成一次查询的执行方法称之为:`index merge`,具体的索引合并算法有下面三种。 ### Intersection合并 `Intersection`翻译过来的意思是`交集`。这里是说某个查询可以使用多个二级索引,将从多个二级索引中查询到的结果取交集,比方说下面这个查询: `SELECT * FROM single_table WHERE key1 = \u0026#39;a\u0026#39; AND key3 = \u0026#39;b\u0026#39;;` 假设这个查询使用`Intersection`合并的方式执行的话,那这个过程就是这样的: + 从`idx_key1`二级索引对应的`B+`树中取出`key1 = \u0026#39;a\u0026#39;`的相关记录。 + 从`idx_key3`二级索引对应的`B+`树中取出`key3 = \u0026#39;b\u0026#39;`的相关记录。 + 二级索引的记录都是由`索引列 + 主键`构成的,所以我们可以计算出这两个结果集中`id`值的交集。 + 按照上一步生成的`id`值列表进行回表操作,也就是从聚簇索引中把指定`id`值的完整用户记录取出来,返回给用户。 这里有同学会思考:为什么不直接使用`idx_key1`或者`idx_key3`只根据某个搜索条件去读取一个二级索引,然后回表后再过滤另外一个搜索条件呢?这里要分析一下两种查询执行方式之间需要的成本代价。 只读取一个二级索引的成本: + 按照某个搜索条件读取一个二级索引 + 根据从该二级索引得到的主键值进行回表操作,然后再过滤其他的搜索条件 读取多个二级索引之后取交集成本: + 按照不同的搜索条件分别读取不同的二级索引 + 将从多个二级索引得到的主键值取交集,然后进行回表操作 虽然读取多个二级索引比读取一个二级索引消耗性能,但是读取二级索引的操作是`顺序I/O`,而回表操作是`随机I/O`,所以如果只读取一个二级索引时需要回表的记录数特别多,而读取多个二级索引之后取交集的记录数非常少,当节省的因为`回表`而造成的性能损耗比访问多个二级索引带来的性能损耗更高时,读取多个二级索引后取交集比只读取一个二级索引的成本更低。 `MySQL`在某些特定的情况下才可能会使用到`Intersection`索引合并: + 情况一:二级索引列是等值匹配的情况,对于联合索引来说,在联合索引中的每个列都必须等值匹配,不能出现只匹配部分列的情况。 比方说下面这个查询可能用到`idx_key1`和`idx_key_part`这两个二级索引进行`Intersection`索引合并的操作: `SELECT * FROM single_table WHERE key1 = \u0026#39;a\u0026#39; AND key_part1 = \u0026#39;a\u0026#39; AND key_part2 = \u0026#39;b\u0026#39; AND key_part3 = \u0026#39;c\u0026#39;;` 而下面这两个查询就不能进行`Intersection`索引合并: ``` SELECT * FROM single_table WHERE key1 \u0026gt; \u0026#39;a\u0026#39; AND key_part1 = \u0026#39;a\u0026#39; AND key_part2 = \u0026#39;b\u0026#39; AND key_part3 = \u0026#39;c\u0026#39;; SELECT * FROM single_table WHERE key1 = \u0026#39;a\u0026#39; AND key_part1 = \u0026#39;a\u0026#39;; ``` 第一个查询是因为对`key1`进行了范围匹配,第二个查询是因为联合索引`idx_key_part`中的`key_part2`列并没有出现在搜索条件中,所以这两个查询不能进行`Intersection`索引合并。 + 情况二:主键列可以是范围匹配 比方说下面这个查询可能用到主键和`idx_key1`进行`Intersection`索引合并的操作: `SELECT * FROM single_table WHERE id \u0026gt; 100 AND key1 = \u0026#39;a\u0026#39;;` 为什么呢?凭什么呀?突然冒出这么两个规定让大家一脸懵逼,下面我们慢慢品一品这里头的玄机。这话还得从`InnoDB`的索引结构说起,你要是记不清麻烦再回头看看。对于`InnoDB`的二级索引来说,记录先是按照索引列进行排序,如果该二级索引是一个联合索引,那么会按照联合索引中的各个列依次排序。而二级索引的用户记录是由`索引列 + 主键`构成的,二级索引列的值相同的记录可能会有好多条,这些索引列的值相同的记录又是按照`主键`的值进行排序的。所以重点来了,之所以在二级索引列都是等值匹配的情况下才可能使用`Intersection`索引合并,是因为**只有在这种情况下根据二级索引查询出的结果集是按照主键值排序的**。 so?还是没看懂根据二级索引查询出的结果集是按照主键值排序的对使用`Intersection`索引合并有什么好处?小伙子,别忘了`Intersection`索引合并会把从多个二级索引中查询出的主键值求交集,如果从各个二级索引中查询的到的结果集本身就是已经按照主键排好序的,那么求交集的过程就很easy啦。假设某个查询使用`Intersection`索引合并的方式从`idx_key1`和`idx_key2`这两个二级索引中获取到的主键值分别是: + 从`idx_key1`中获取到已经排好序的主键值:1、3、5 + 从`idx_key2`中获取到已经排好序的主键值:2、3、4 那么求交集的过程就是这样:逐个取出这两个结果集中最小的主键值,如果两个值相等,则加入最后的交集结果中,否则丢弃当前较小的主键值,再取该丢弃的主键值所在结果集的后一个主键值来比较,直到某个结果集中的主键值用完了,如果还是觉得不太明白那继续往下看: + 先取出这两个结果集中较小的主键值做比较,因为`1 \u0026lt; 2`,所以把`idx_key1`的结果集的主键值`1`丢弃,取出后边的`3`来比较。 + 因为`3 \u0026gt; 2`,所以把`idx_key2`的结果集的主键值`2`丢弃,取出后边的`3`来比较。 + 因为`3 = 3`,所以把`3`加入到最后的交集结果中,继续两个结果集后边的主键值来比较。 + 后边的主键值也不相等,所以最后的交集结果中只包含主键值`3`。 别看我们写的啰嗦,这个过程其实可快了,时间复杂度是`O(n)`,但是如果从各个二级索引中查询出的结果集并不是按照主键排序的话,那就要先把结果集中的主键值排序完再来做上面的那个过程,就比较耗时了。 `小贴士:按照有序的主键值去回表取记录有个专有名词儿,叫:Rowid Ordered Retrieval,简称ROR,以后大家在某些地方见到这个名词儿就眼熟了。` 另外,不仅是多个二级索引之间可以采用`Intersection`索引合并,索引合并也可以有聚簇索引参加,也就是我们上面写的`情况二`:在搜索条件中有主键的范围匹配的情况下也可以使用`Intersection`索引合并索引合并。为什么主键这就可以范围匹配了?还是得回到应用场景里,比如看下面这个查询: `SELECT * FROM single_table WHERE key1 = \u0026#39;a\u0026#39; AND id \u0026gt; 100;` 假设这个查询可以采用`Intersection`索引合并,我们理所当然的以为这个查询会分别按照`id \u0026gt; 100`这个条件从聚簇索引中获取一些记录,在通过`key1 = \u0026#39;a\u0026#39;`这个条件从`idx_key1`二级索引中获取一些记录,然后再求交集,其实这样就把问题复杂化了,没必要从聚簇索引中获取一次记录。别忘了二级索引的记录中都带有主键值的,所以可以在从`idx_key1`中获取到的主键值上直接运用条件`id \u0026gt; 100`过滤就行了,这样多简单。所以涉及主键的搜索条件只不过是为了从别的二级索引得到的结果集中过滤记录罢了,是不是等值匹配不重要。 当然,上面说的`情况一`和`情况二`只是发生`Intersection`索引合并的必要条件,不是充分条件。也就是说即使情况一、情况二成立,也不一定发生`Intersection`索引合并,这得看优化器的心情。优化器只有在单独根据搜索条件从某个二级索引中获取的记录数太多,导致回表开销太大,而通过`Intersection`索引合并后需要回表的记录数大大减少时才会使用`Intersection`索引合并。 ### Union合并 我们在写查询语句时经常想把既符合某个搜索条件的记录取出来,也把符合另外的某个搜索条件的记录取出来,我们说这些不同的搜索条件之间是`OR`关系。有时候`OR`关系的不同搜索条件会使用到不同的索引,比方说这样: `SELECT * FROM single_table WHERE key1 = \u0026#39;a\u0026#39; OR key3 = \u0026#39;b\u0026#39;` `Intersection`是交集的意思,这适用于使用不同索引的搜索条件之间使用`AND`连接起来的情况;`Union`是并集的意思,适用于使用不同索引的搜索条件之间使用`OR`连接起来的情况。与`Intersection`索引合并类似,`MySQL`在某些特定的情况下才可能会使用到`Union`索引合并: + 情况一:二级索引列是等值匹配的情况,对于联合索引来说,在联合索引中的每个列都必须等值匹配,不能出现只出现匹配部分列的情况。 比方说下面这个查询可能用到`idx_key1`和`idx_key_part`这两个二级索引进行`Union`索引合并的操作: `SELECT * FROM single_table WHERE key1 = \u0026#39;a\u0026#39; OR ( key_part1 = \u0026#39;a\u0026#39; AND key_part2 = \u0026#39;b\u0026#39; AND key_part3 = \u0026#39;c\u0026#39;);` 而下面这两个查询就不能进行`Union`索引合并: ``` SELECT * FROM single_table WHERE key1 \u0026gt; \u0026#39;a\u0026#39; OR (key_part1 = \u0026#39;a\u0026#39; AND key_part2 = \u0026#39;b\u0026#39; AND key_part3 = \u0026#39;c\u0026#39;); SELECT * FROM single_table WHERE key1 = \u0026#39;a\u0026#39; OR key_part1 = \u0026#39;a\u0026#39;; ``` 第一个查询是因为对`key1`进行了范围匹配,第二个查询是因为联合索引`idx_key_part`中的`key_part2`列并没有出现在搜索条件中,所以这两个查询不能进行`Union`索引合并。 + 情况二:主键列可以是范围匹配 + 情况三:使用`Intersection`索引合并的搜索条件 这种情况其实也挺好理解,就是搜索条件的某些部分使用`Intersection`索引合并的方式得到的主键集合和其他方式得到的主键集合取交集,比方说这个查询: `SELECT * FROM single_table WHERE key_part1 = \u0026#39;a\u0026#39; AND key_part2 = \u0026#39;b\u0026#39; AND key_part3 = \u0026#39;c\u0026#39; OR (key1 = \u0026#39;a\u0026#39; AND key3 = \u0026#39;b\u0026#39;);` 优化器可能采用这样的方式来执行这个查询: + 先按照搜索条件`key1 = \u0026#39;a\u0026#39; AND key3 = \u0026#39;b\u0026#39;`从索引`idx_key1`和`idx_key3`中使用`Intersection`索引合并的方式得到一个主键集合。 + 再按照搜索条件`key_part1 = \u0026#39;a\u0026#39; AND key_part2 = \u0026#39;b\u0026#39; AND key_part3 = \u0026#39;c\u0026#39;`从联合索引`idx_key_part`中得到另一个主键集合。 + 采用`Union`索引合并的方式把上述两个主键集合取并集,然后进行回表操作,将结果返回给用户。 当然,查询条件符合了这些情况也不一定就会采用`Union`索引合并,也得看优化器的心情。优化器只有在单独根据搜索条件从某个二级索引中获取的记录数比较少,通过`Union`索引合并后进行访问的代价比全表扫描更小时才会使用`Union`索引合并。 ### Sort-Union合并 `Union`索引合并的使用条件太苛刻,必须保证各个二级索引列在进行等值匹配的条件下才可能被用到,比方说下面这个查询就无法使用到`Union`索引合并: `SELECT * FROM single_table WHERE key1 \u0026lt; \u0026#39;a\u0026#39; OR key3 \u0026gt; \u0026#39;z\u0026#39;` 这是因为根据`key1 \u0026lt; \u0026#39;a\u0026#39;`从`idx_key1`索引中获取的二级索引记录的主键值不是排好序的,根据`key3 \u0026gt; \u0026#39;z\u0026#39;`从`idx_key3`索引中获取的二级索引记录的主键值也不是排好序的,但是`key1 \u0026lt; \u0026#39;a\u0026#39;`和`key3 \u0026gt; \u0026#39;z\u0026#39;`这两个条件又特别让我们动心,所以我们可以这样: + 先根据`key1 \u0026lt; \u0026#39;a\u0026#39;`条件从`idx_key1`二级索引总获取记录,并按照记录的主键值进行排序 + 再根据`key3 \u0026gt; \u0026#39;z\u0026#39;`条件从`idx_key3`二级索引总获取记录,并按照记录的主键值进行排序 + 因为上述的两个二级索引主键值都是排好序的,剩下的操作和`Union`索引合并方式就一样了。 我们把上述这种先按照二级索引记录的主键值进行排序,之后按照`Union`索引合并方式执行的方式称之为`Sort-Union`索引合并,很显然,这种`Sort-Union`索引合并比单纯的`Union`索引合并多了一步对二级索引记录的主键值排序的过程。 `小贴士:为什么有Sort-Union索引合并,就没有Sort-Intersection索引合并么?是的,的确没有Sort-Intersection索引合并这么一说,Sort-Union的适用场景是单独根据搜索条件从某个二级索引中获取的记录数比较少,这样即使对这些二级索引记录按照主键值进行排序的成本也不会太高,而Intersection索引合并的适用场景是单独根据搜索条件从某个二级索引中获取的记录数太多,导致回表开销太大,合并后可以明显降低回表开销,但是如果加入Sort-Intersection后,就需要为大量的二级索引记录按照主键值进行排序,这个成本可能比回表查询都高了,所以也就没有引入Sort-Intersection这个玩意儿。` ### 索引合并注意事项 ### 联合索引替代Intersection索引合并 `SELECT * FROM single_table WHERE key1 = \u0026#39;a\u0026#39; AND key3 = \u0026#39;b\u0026#39;;` 这个查询之所以可能使用`Intersection`索引合并的方式执行,还不是因为`idx_key1`和`idx_key3`是两个单独的`B+`树索引,你要是把这两个列搞一个联合索引,那直接使用这个联合索引就把事情搞定了,何必用什么索引合并呢,就像这样: `ALTER TABLE single_table drop index idx_key1, idx_key3, add index idx_key1_key3(key1, key3);` 这样我们把没用的`idx_key1`、`idx_key3`都干掉,再添加一个联合索引`idx_key1_key3`,使用这个联合索引进行查询简直是又快又好,既不用多读一棵`B+`树,也不用合并结果,何乐而不为? `小贴士:不过小心有单独对key3列进行查询的业务场景,这样子不得不再把key3列的单独索引给加上。` "},{"id":23,"href":"/zh/docs/technology/MySQL/MySQL%E6%98%AF%E6%80%8E%E6%A0%B7%E8%BF%90%E8%A1%8C%E7%9A%84/%E7%AC%AC9%E7%AB%A0_%E5%AD%98%E6%94%BE%E9%A1%B5%E7%9A%84%E5%A4%A7%E6%B1%A0%E5%AD%90-InnoDB%E7%9A%84%E8%A1%A8%E7%A9%BA%E9%97%B4/","title":"第9章_存放页的大池子-InnoDB的表空间","section":"My Sql是怎样运行的","content":"第9章 存放页的大池子-InnoDB的表空间\n通过前面儿的内容大家知道,表空间是一个抽象的概念,对于系统表空间来说,对应着文件系统中一个或多个实际文件;对于每个独立表空间来说,对应着文件系统中一个名为表名.ibd的实际文件。大家可以把表空间想象成被切分为许许多多个页的池子,当我们想为某个表插入一条记录的时候,就从池子中捞出一个对应的页来把数据写进去。本章内容会深入到表空间的各个细节中,带领大家在InnoDB存储结构的池子中畅游。由于本章中将会涉及比较多的概念,虽然这些概念都不难,但是却相互依赖,所以奉劝大家在看的时候:\n不要跳着看!\n不要跳着看!\n不要跳着看!\n回忆一些旧知识 # 页类型 # 再一次强调,InnoDB是以页为单位管理存储空间的,我们的聚簇索引(也就是完整的表数据)和其他的二级索引都是以B+树的形式保存到表空间的,而B+树的节点就是数据页。我们前面说过,这个数据页的类型名其实是:FIL_PAGE_INDEX,除了这种存放索引数据的页类型之外,InnoDB也为了不同的目的设计了若干种不同类型的页,为了唤醒大家的记忆,我们再一次把各种常用的页类型提出来: 类型名称 十六进制 描述 FIL_PAGE_TYPE_ALLOCATED 0x0000 最新分配,还没使用 FIL_PAGE_UNDO_LOG 0x0002 Undo日志页 FIL_PAGE_INODE 0x0003 段信息节点 FIL_PAGE_IBUF_FREE_LIST 0x0004 Insert Buffer空闲列表 FIL_PAGE_IBUF_BITMAP 0x0005 Insert Buffer位图 FIL_PAGE_TYPE_SYS 0x0006 系统页 FIL_PAGE_TYPE_TRX_SYS 0x0007 事务系统数据 FIL_PAGE_TYPE_FSP_HDR 0x0008 表空间头部信息 FIL_PAGE_TYPE_XDES 0x0009 扩展描述页 FIL_PAGE_TYPE_BLOB 0x000A BLOB页 FIL_PAGE_INDEX 0x45BF 索引页,也就是我们所说的数据页 因为页类型前面都有个FIL_PAGE或者FIL_PAGE_TYPE的前缀,为简便起见我们后边介绍页类型的时候就把这些前缀省略掉了,比方说FIL_PAGE_TYPE_ALLOCATED类型称为ALLOCATED类型,FIL_PAGE_INDEX类型称为INDEX类型。\n页通用部分 # 我们前面说过数据页,也就是INDEX类型的页由7个部分组成,其中的两个部分是所有类型的页都通用的。当然我不能寄希望于你把我说的话都记住,所以在这里重新强调一遍,任何类型的页都有下面这种通用的结构:\n从上图中可以看出,任何类型的页都会包含这两个部分:\nFile Header:记录页的一些通用信息\nFile Trailer:校验页是否完整,保证从内存到磁盘刷新时内容的一致性。\n对于File Trailer我们不再做过多强调,全部忘记了的话可以到将数据页的那一章回顾一下。我们这里再强调一遍File Header的各个组成部分: 名称 占用空间大小 描述 FIL_PAGE_SPACE_OR_CHKSUM 4字节 页的校验和(checksum值) FIL_PAGE_OFFSET 4字节 页号 FIL_PAGE_PREV 4字节 上一个页的页号 FIL_PAGE_NEXT 4字节 下一个页的页号 FIL_PAGE_LSN 8字节 页被最后修改时对应的日志序列位置(英文名是:Log Sequence Number) FIL_PAGE_TYPE 2字节 该页的类型 FIL_PAGE_FILE_FLUSH_LSN 8字节 仅在系统表空间的一个页中定义,代表文件至少被刷新到了对应的LSN值 FIL_PAGE_ARCH_LOG_NO_OR_SPACE_ID 4字节 页属于哪个表空间 现在除了名称里边儿带有LSN的两个字段大家可能看不懂以外,其他的字段肯定都是倍儿熟了,不过我们仍要强调这么几点:\n表空间中的每一个页都对应着一个页号,也就是FIL_PAGE_OFFSET,这个页号由4个字节组成,也就是32个比特位,所以一个表空间最多可以拥有2³²个页,如果按照页的默认大小16KB来算,一个表空间最多支持64TB的数据。表空间的第一个页的页号为0,之后的页号分别是1,2,3\u0026hellip;依此类推\n某些类型的页可以组成链表,链表中的页可以不按照物理顺序存储,而是根据FIL_PAGE_PREV和FIL_PAGE_NEXT来存储上一个页和下一个页的页号。需要注意的是,这两个字段主要是为了INDEX类型的页,也就是我们之前一直说的数据页建立B+树后,为每层节点建立双向链表用的,一般类型的页是不使用这两个字段的。\n每个页的类型由FIL_PAGE_TYPE表示,比如像数据页的该字段的值就是0x45BF,我们后边会介绍各种不同类型的页,不同类型的页在该字段上的值是不同的。\n独立表空间结构 # 我们知道InnoDB支持许多种类型的表空间,本章重点关注独立表空间和系统表空间的结构。它们的结构比较相似,但是由于系统表空间中额外包含了一些关于整个系统的信息,所以我们先挑简单一点的独立表空间来介绍,稍后再说系统表空间的结构。\n区(extent)的概念 # 表空间中的页实在是太多了,为了更好的管理这些页,设计InnoDB的大佬们提出了区(英文名:extent)的概念。对于16KB的页来说,连续的64个页就是一个区,也就是说一个区默认占用1MB空间大小。不论是系统表空间还是独立表空间,都可以看成是由若干个区组成的,每256个区被划分成一组。画个图表示就是这样:\n其中extent 0 ~ extent 255这256个区算是第一个组,extent 256 ~ extent 511这256个区算是第二个组,extent 512 ~ extent 767这256个区算是第三个组(上图中并未画全第三个组全部的区,请自行脑补),依此类推可以划分更多的组。这些组的头几个页的类型都是类似的,就像这样:\n从上图中我们能得到如下信息:\n第一个组最开始的3个页的类型是固定的,也就是说extent 0这个区最开始的3个页的类型是固定的,分别是:\n+ FSP_HDR类型:这个类型的页是用来登记整个表空间的一些整体属性以及本组所有的区,也就是extent 0 ~ extent 255这256个区的属性,稍后详细介绍。需要注意的一点是,整个表空间只有一个FSP_HDR类型的页。\n+ IBUF_BITMAP类型:这个类型的页是存储本组所有的区的所有页关于INSERT BUFFER的信息。当然,你现在不用知道什么是个INSERT BUFFER,后边会详细说到你吐。\n+ INODE类型:这个类型的页存储了许多称为INODE的数据结构,还是那句话,现在你不需要知道什么是个INODE,后边儿会说到你吐。\n其余各组最开始的2个页的类型是固定的,也就是说extent 256、extent 512这些区最开始的2个页的类型是固定的,分别是:\n+ XDES类型:全称是extent descriptor,用来登记本组256个区的属性,也就是说对于在extent 256区中的该类型页存储的就是extent 256 ~ extent 511这些区的属性,对于在extent 512区中的该类型页存储的就是extent 512 ~ extent 767这些区的属性。上面介绍的FSP_HDR类型的页其实和XDES类型的页的作用类似,只不过FSP_HDR类型的页还会额外存储一些表空间的属性。\n+ IBUF_BITMAP类型:上面介绍过了。\n好了,宏观的结构介绍完了,里边儿的名词大家也不用记清楚,只要大致记得:表空间被划分为许多连续的区,每个区默认由64个页组成,每256个区划分为一组,每个组的最开始的几个页类型是固定的就好了。\n段(segment)的概念 # 为什么好端端的提出一个区(extent)的概念呢?我们以前分析问题的套路都是这样的:表中的记录存储到页里边儿,然后页作为节点组成B+树,这个B+树就是索引,然后等等一堆聚簇索引和二级索引的区别。这套路也没什么不妥的呀~\n是的,如果我们表中数据量很少的话,比如说你的表中只有几十条、几百条数据的话,的确用不到区的概念,因为简单的几个页就能把对应的数据存储起来,但是你架不住表里的记录越来越多呀。\n什么??表里的记录多了又怎样?B+树的每一层中的页都会形成一个双向链表呀,File Header中的FIL_PAGE_PREV和FIL_PAGE_NEXT字段不就是为了形成双向链表设置的么?\n是的是的,您说的都对,从理论上说,不引入区的概念只使用页的概念对存储引擎的运行并没什么影响,但是我们来考虑一下下面这个场景:\n我们每向表中插入一条记录,本质上就是向该表的聚簇索引以及所有二级索引代表的B+树的节点中插入数据。而B+树的每一层中的页都会形成一个双向链表,如果是以页为单位来分配存储空间的话,双向链表相邻的两个页之间的物理位置可能离得非常远。我们介绍B+树索引的适用场景的时候特别提到范围查询只需要定位到最左边的记录和最右边的记录,然后沿着双向链表一直扫描就可以了,而如果链表中相邻的两个页物理位置离得非常远,就是所谓的随机I/O。再一次强调,磁盘的速度和内存的速度差了好几个数量级,随机I/O是非常慢的,所以我们应该尽量让链表中相邻的页的物理位置也相邻,这样进行范围查询的时候才可以使用所谓的顺序I/O。 所以,所以,所以才引入了区(extent)的概念,一个区就是在物理位置上连续的64个页。在表中数据量大的时候,为某个索引分配空间的时候就不再按照页为单位分配了,而是按照区为单位分配,甚至在表中的数据十分非常特别多的时候,可以一次性分配多个连续的区。虽然可能造成一点点空间的浪费(数据不足填充满整个区),但是从性能角度看,可以消除很多的随机I/O,功大于过嘛!\n事情到这里就结束了么?太天真了,我们提到的范围查询,其实是对B+树叶子节点中的记录进行顺序扫描,而如果不区分叶子节点和非叶子节点,统统把节点代表的页放到申请到的区中的话,进行范围扫描的效果就大打折扣了。所以设计InnoDB的大佬们对B+树的叶子节点和非叶子节点进行了区别对待,也就是说叶子节点有自己独有的区,非叶子节点也有自己独有的区。存放叶子节点的区的集合就算是一个段(segment),存放非叶子节点的区的集合也算是一个段。也就是说一个索引会生成2个段,一个叶子节点段,一个非叶子节点段。\n默认情况下一个使用InnoDB存储引擎的表只有一个聚簇索引,一个索引会生成2个段,而段是以区为单位申请存储空间的,一个区默认占用1M存储空间,所以默认情况下一个只存了几条记录的小表也需要2M的存储空间么?以后每次添加一个索引都要多申请2M的存储空间么?这对于存储记录比较少的表简直是天大的浪费。设计InnoDB的大佬们都挺节俭的,当然也考虑到了这种情况。这个问题的症结在于到现在为止我们介绍的区都是非常纯粹的,也就是一个区被整个分配给某一个段,或者说区中的所有页都是为了存储同一个段的数据而存在的,即使段的数据填不满区中所有的页,那余下的页也不能挪作他用。现在为了考虑以完整的区为单位分配给某个段对于数据量较小的表太浪费存储空间的这种情况,设计InnoDB的大佬们提出了一个碎片(fragment)区的概念,也就是在一个碎片区中,并不是所有的页都是为了存储同一个段的数据而存在的,而是碎片区中的页可以用于不同的目的,比如有些页用于段A,有些页用于段B,有些页甚至哪个段都不属于。碎片区直属于表空间,并不属于任何一个段。所以此后为某个段分配存储空间的策略是这样的:\n在刚开始向表中插入数据的时候,段是从某个碎片区以单个页为单位来分配存储空间的。\n当某个段已经占用了32个碎片区页之后,就会以完整的区为单位来分配存储空间。\n所以现在段不能仅定义为是某些区的集合,更精确的应该是某些零散的页以及一些完整的区的集合。除了索引的叶子节点段和非叶子节点段之外,InnoDB中还有为存储一些特殊的数据而定义的段,比如回滚段,当然我们现在并不关心别的类型的段,现在只需要知道段是一些零散的页以及一些完整的区的集合就好了。\n区的分类 # 通过上面一通介绍,大家知道了表空间的是由若干个区组成的,这些区大体上可以分为4种类型:\n空闲的区:现在还没有用到这个区中的任何页。\n有剩余空间的碎片区:表示碎片区中还有可用的页。\n没有剩余空间的碎片区:表示碎片区中的所有页都被使用,没有空闲页。\n附属于某个段的区。每一个索引都可以分为叶子节点段和非叶子节点段,除此之外InnoDB还会另外定义一些特殊作用的段,在这些段中的数据量很大时将使用区来作为基本的分配单位。\n这4种类型的区也可以被称为区的4种状态(State),设计InnoDB的大佬们为这4种状态的区定义了特定的名词儿: 状态名 含义 FREE 空闲的区 FREE_FRAG 有剩余空间的碎片区 FULL_FRAG 没有剩余空间的碎片区 FSEG 附属于某个段的区 需要再次强调一遍的是,处于FREE、FREE_FRAG以及FULL_FRAG这三种状态的区都是独立的,算是直属于表空间;而处于FSEG状态的区是附属于某个段的。 小贴士:如果把表空间比作是一个集团军,段就相当于师,区就相当于团。一般的团都是隶属于某个师的,就像是处于FSEG的区全都隶属于某个段,而处于FREE、FREE_FRAG以及FULL_FRAG这三种状态的区却直接隶属于表空间,就像独立团直接听命于军部一样。\n为了方便管理这些区,设计InnoDB的大佬设计了一个称为XDES Entry的结构(全称就是Extent Descriptor Entry),每一个区都对应着一个XDES Entry结构,这个结构记录了对应的区的一些属性。我们先看图来对这个结构有个大致的了解:\n从图中我们可以看出,XDES Entry是一个40个字节的结构,大致分为4个部分,各个部分的释义如下:\nSegment ID(8字节)\n每一个段都有一个唯一的编号,用ID表示,此处的Segment ID字段表示就是该区所在的段。当然前提是该区已经被分配给某个段了,不然的话该字段的值没什么意义。\nList Node(12字节)\n这个部分可以将若干个XDES Entry结构串联成一个链表,大家看一下这个List Node的结构:\n如果我们想定位表空间内的某一个位置的话,只需指定页号以及该位置在指定页号中的页内偏移量即可。所以:\n+ Pre Node Page Number和Pre Node Offset的组合就是指向前一个XDES Entry的指针\n+ Next Node Page Number和Next Node Offset的组合就是指向后一个XDES Entry的指针。\n把一些XDES Entry结构连成一个链表有什么用?稍安勿躁,我们稍后介绍XDES Entry结构组成的链表问题。\nState(4字节)\n这个字段表明区的状态。可选的值就是我们前面说过的那4个,分别是:FREE、FREE_FRAG、FULL_FRAG和FSEG。具体释义就不多介绍了,前面说的够仔细了。\nPage State Bitmap(16字节)\n这个部分共占用16个字节,也就是128个比特位。我们说一个区默认有64个页,这128个比特位被划分为64个部分,每个部分2个比特位,对应区中的一个页。比如Page State Bitmap部分的第1和第2个比特位对应着区中的第1个页,第3和第4个比特位对应着区中的第2个页,依此类推,Page State Bitmap部分的第127和128个比特位对应着区中的第64个页。这两个比特位的第一个位表示对应的页是否是空闲的,第二个比特位还没有用。\nXDES Entry链表 # 到现在为止,我们已经提出了五花八门的概念,什么区、段、碎片区、附属于段的区、XDES Entry结构等等的概念,走远了千万别忘了自己为什么出发,我们把事情搞这么麻烦的初心,仅仅是想提高向表插入数据的效率,又不至于数据量少的表浪费空间。现在我们知道向表中插入数据本质上就是向表中各个索引的叶子节点段、非叶子节点段插入数据,也知道了不同的区有不同的状态,再回到最初的起点,捋一捋向某个段中插入数据的过程:\n当段中数据较少的时候,首先会查看表空间中是否有状态为FREE_FRAG的区,也就是找还有空闲空间的碎片区,如果找到了,那么从该区中取一些零碎的页把数据插进去;否则到表空间下申请一个状态为FREE的区,也就是空闲的区,把该区的状态变为FREE_FRAG,然后从该新申请的区中取一些零碎的页把数据插进去。之后不同的段使用零碎页的时候都会从该区中取,直到该区中没有空闲空间,然后该区的状态就变成了FULL_FRAG。\n现在的问题是你怎么知道表空间里的哪些区是FREE的,哪些区的状态是FREE_FRAG的,哪些区是FULL_FRAG的?要知道表空间的大小是可以不断增大的,当增长到GB级别的时候,区的数量也就上千了,我们总不能每次都遍历这些区对应的XDES Entry结构吧?这时候就是XDES Entry中的List Node部分发挥奇效的时候了,我们可以通过List Node中的指针,做这么三件事:\n+ 把状态为FREE的区对应的XDES Entry结构通过List Node来连接成一个链表,这个链表我们就称之为FREE链表。\n+ 把状态为FREE_FRAG的区对应的XDES Entry结构通过List Node来连接成一个链表,这个链表我们就称之为FREE_FRAG链表。\n+ 把状态为FULL_FRAG的区对应的XDES Entry结构通过List Node来连接成一个链表,这个链表我们就称之为FULL_FRAG链表。\n这样每当我们想找一个FREE_FRAG状态的区时,就直接把FREE_FRAG链表的头节点拿出来,从这个节点中取一些零碎的页来插入数据,当这个节点对应的区用完时,就修改一下这个节点的State字段的值,然后从FREE_FRAG链表中移到FULL_FRAG链表中。同理,如果FREE_FRAG链表中一个节点都没有,那么就直接从FREE链表中取一个节点移动到FREE_FRAG链表的状态,并修改该节点的STATE字段值为FREE_FRAG,然后从这个节点对应的区中获取零碎的页就好了。\n当段中数据已经占满了32个零散的页后,就直接申请完整的区来插入数据了。\n还是那个问题,我们怎么知道哪些区属于哪个段的呢?再遍历各个XDES Entry结构?遍历是不可能遍历的,这辈子都不可能遍历的,有链表还遍历个毛线啊。所以我们把状态为FSEG的区对应的XDES Entry结构都加入到一个链表喽?傻呀,不同的段哪能共用一个区呢?你想把索引a的叶子节点段和索引b的叶子节点段都存储到一个区中么?显然我们想要每个段都有它独立的链表,所以可以根据段号(也就是Segment ID)来建立链表,有多少个段就建多少个链表?好像也有点问题,因为一个段中可以有好多个区,有的区是完全空闲的,有的区还有一些页可以用,有的区已经没有空闲页可以用了,所以我们有必要继续细分,设计InnoDB的大佬们为每个段中的区对应的XDES Entry结构建立了三个链表:\n+ FREE链表:同一个段中,所有页都是空闲的区对应的XDES Entry结构会被加入到这个链表。注意和直属于表空间的FREE链表区别开了,此处的FREE链表是附属于某个段的。\n+ NOT_FULL链表:同一个段中,仍有空闲空间的区对应的XDES Entry结构会被加入到这个链表。\n+ FULL链表:同一个段中,已经没有空闲空间的区对应的XDES Entry结构会被加入到这个链表。\n再次强调一遍,每一个索引都对应两个段,每个段都会维护上述的3个链表,比如下面这个表:\nCREATE TABLE t ( c1 INT NOT NULL AUTO_INCREMENT, c2 VARCHAR(100), c3 VARCHAR(100), PRIMARY KEY (c1), KEY idx_c2 (c2) )ENGINE=InnoDB;\n这个表t共有两个索引,一个聚簇索引,一个二级索引idx_c2,所以这个表共有4个段,每个段都会维护上述3个链表,总共是12个链表,加上我们上面说过的直属于表空间的3个链表,整个独立表空间共需要维护15个链表。所以段在数据量比较大时插入数据的话,会先获取NOT_FULL链表的头节点,直接把数据插入这个头节点对应的区中即可,如果该区的空间已经被用完,就把该节点移到FULL链表中。\n链表基节点 # 上面光是介绍了一堆链表,可我们怎么找到这些链表呢,或者说怎么找到某个链表的头节点或者尾节点在表空间中的位置呢?设计InnoDB的大佬当然考虑了这个问题,他们设计了一个叫List Base Node的结构,翻译成中文就是链表的基节点。这个结构中包含了链表的头节点和尾节点的指针以及这个链表中包含了多少节点的信息,我们画图看一下这个结构的示意图:\n我们上面介绍的每个链表都对应这么一个List Base Node结构,其中:\nList Length表明该链表一共有多少节点,\nFirst Node Page Number和First Node Offset表明该链表的头节点在表空间中的位置。\nLast Node Page Number和Last Node Offset表明该链表的尾节点在表空间中的位置。\n一般我们把某个链表对应的List Base Node结构放置在表空间中固定的位置,这样想找定位某个链表就变得so easy啦。\n链表小结 # 综上所述,表空间是由若干个区组成的,每个区都对应一个XDES Entry的结构,直属于表空间的区对应的XDES Entry结构可以分成FREE、FREE_FRAG和FULL_FRAG这3个链表;每个段可以附属若干个区,每个段中的区对应的XDES Entry结构可以分成FREE、NOT_FULL和FULL这3个链表。每个链表都对应一个List Base Node的结构,这个结构里记录了链表的头、尾节点的位置以及该链表中包含的节点数。正是因为这些链表的存在,管理这些区才变成了一件so easy的事情。\n段的结构 # 我们前面说过,段其实不对应表空间中某一个连续的物理区域,而是一个逻辑上的概念,由若干个零散的页以及一些完整的区组成。像每个区都有对应的XDES Entry来记录这个区中的属性一样,设计InnoDB的大佬为每个段都定义了一个INODE Entry结构来记录一下段中的属性。大家看一下示意图:\n它的各个部分释义如下:\nSegment ID\n就是指这个INODE Entry结构对应的段的编号(ID)。\nNOT_FULL_N_USED\n这个字段指的是在NOT_FULL链表中已经使用了多少个页。下次从NOT_FULL链表分配空闲页时可以直接根据这个字段的值定位到。而不用从链表中的第一个页开始遍历着寻找空闲页。\n3个List Base Node\n分别为段的FREE链表、NOT_FULL链表、FULL链表定义了List Base Node,这样我们想查找某个段的某个链表的头节点和尾节点的时候,就可以直接到这个部分找到对应链表的List Base Node。so easy!\nMagic Number:\n这个值是用来标记这个INODE Entry是否已经被初始化了(初始化的意思就是把各个字段的值都填进去了)。如果这个数字是值的97937874,表明该INODE Entry已经初始化,否则没有被初始化。(不用纠结这个值有什么特殊含义,人家规定的)。\nFragment Array Entry\n我们前面强调过无数次:段是一些零散页和一些完整的区的集合,每个Fragment Array Entry结构都对应着一个零散的页,这个结构一共4个字节,表示一个零散页的页号。\n结合着这个INODE Entry结构,大家可能对段是一些零散页和一些完整的区的集合的理解再次深刻一些。\n各类型页详细情况 # 到现在为止我们已经大概清楚了表空间、段、区、XDES Entry、INODE Entry、各种以XDES Enty为节点的链表的基本概念了,可是总有一种飞在天上不踏实的感觉,每个区对应的XDES Entry结构到底存储在表空间的什么地方?直属于表空间的FREE、FREE_FRAG、FULL_FRAG链表的基节点到底存储在表空间的什么地方?每个段对应的INODE Entry结构到底存在表空间的什么地方?我们前面介绍了每256个连续的区算是一个组,想解决刚才提出来的这些个疑问还得从每个组开头的一些类型相同的页说起,接下来我们一个页一个页的分析,真相马上就要浮出水面了。\n**FSP_HDR**类型 # 首先看第一个组的第一个页,当然也是表空间的第一个页,页号为0。这个页的类型是FSP_HDR,它存储了表空间的一些整体属性以及第一个组内256个区的对应的XDES Entry结构,直接看这个类型的页的示意图:\n从图中可以看出,一个完整的FSP_HDR类型的页大致由5个部分组成,各个部分的具体释义如下表: 名称 中文名 占用空间大小 简单描述 File Header 文件头部 38字节 页的一些通用信息 File Space Header 表空间头部 112字节 表空间的一些整体属性信息 XDES Entry 区描述信息 10240字节 存储本组256个区对应的属性信息 Empty Space 尚未使用空间 5986字节 用于页结构的填充,没什么实际意义 File Trailer 文件尾部 8字节 校验页是否完整 File Header和File Trailer就不再强调了,另外的几个部分中,Empty Space是尚未使用的空间,我们不用管它,重点来看看File Space Header和XDES Entry这两个部分。\nFile Space Header部分 # 从名字就可以看出来,这个部分是用来存储表空间的一些整体属性的,废话少说,看图:\n哇唔,字段有点儿多哦,不急一个一个慢慢看。下面是各个属性的简单描述: 名称 占用空间大小 描述 Space ID 4字节 表空间的ID Not Used 4字节 这4个字节未被使用,可以忽略 Size 4字节 当前表空间占有的页数 FREE Limit 4字节 尚未被初始化的最小页号,大于或等于这个页号的区对应的XDES Entry结构都没有被加入FREE链表 Space Flags 4字节 表空间的一些占用存储空间比较小的属性 FRAG_N_USED 4字节 FREE_FRAG链表中已使用的页数量 List Base Node for FREE List 16字节 FREE链表的基节点 List Base Node for FREE_FRAG List 16字节 FREE_FREG链表的基节点 List Base Node for FULL_FRAG List 16字节 FULL_FREG链表的基节点 Next Unused Segment ID 8字节 当前表空间中下一个未使用的 Segment ID List Base Node for SEG_INODES_FULL List 16字节 SEG_INODES_FULL链表的基节点 List Base Node for SEG_INODES_FREE List 16字节 SEG_INODES_FREE链表的基节点 这里头的Space ID、Not Used、Size这三个字段大家肯定一看就懂,其他的字段我们再详细看看,为了大家的阅读体验,我就不严格按照实际的字段顺序来解释各个字段了。\nList Base Node for FREE List、List Base Node for FREE_FRAG List、List Base Node for FULL_FRAG List。\n这三个大家看着太亲切了,分别是直属于表空间的FREE链表的基节点、FREE_FRAG链表的基节点、FULL_FRAG链表的基节点,这三个链表的基节点在表空间的位置是固定的,就是在表空间的第一个页(也就是FSP_HDR类型的页)的File Space Header部分。所以之后定位这几个链表就so easy啦。\nFRAG_N_USED\n这个字段表明在FREE_FRAG链表中已经使用的页数量,方便之后在链表中查找空闲的页。\nFREE Limit\n我们知道表空间都对应着具体的磁盘文件,一开始我们创建表空间的时候对应的磁盘文件中都没有数据,所以我们需要对表空间完成一个初始化操作,包括为表空间中的区建立XDES Entry结构,为各个段建立INODE Entry结构,建立各种链表等等的各种操作。我们可以一开始就为表空间申请一个特别大的空间,但是实际上有绝大部分的区是空闲的,我们可以选择把所有的这些空闲区对应的XDES Entry结构加入FREE链表,也可以选择只把一部分的空闲区加入FREE链表,等什么时候空闲链表中的XDES Entry结构对应的区不够使了,再把之前没有加入FREE链表的空闲区对应的XDES Entry结构加入FREE链表,中心思想就是什么时候用到什么时候初始化,设计InnoDB的大佬采用的就是后者,他们为表空间定义了FREE Limit这个字段,在该字段表示的页号之前的区都被初始化了,之后的区尚未被初始化。\nNext Unused Segment ID\n表中每个索引都对应2个段,每个段都有一个唯一的ID,那当我们为某个表新创建一个索引的时候,就意味着要创建两个新的段。那怎么为这个新创建的段找一个唯一的ID呢?去遍历现在表空间中所有的段么?我们说过,遍历是不可能遍历的,这辈子都不可能遍历,所以设计InnoDB的大佬们提出了这个名叫Next Unused Segment ID的字段,该字段表明当前表空间中最大的段ID的下一个ID,这样在创建新段的时候赋予新段一个唯一的ID值就so easy啦,直接使用这个字段的值就好了。\nSpace Flags\n表空间对于一些布尔类型的属性,或者只需要寥寥几个比特位搞定的属性都放在了这个Space Flags中存储,虽然它只有4个字节,32个比特位大小,却存储了好多表空间的属性,详细情况如下表: 标志名称 占用的空间(单位:bit) 描述 POST_ANTELOPE 1 表示文件格式是否大于ANTELOPE ZIP_SSIZE 4 表示压缩页的大小 ATOMIC_BLOBS 1 表示是否自动把值非常长的字段放到BLOB页里 PAGE_SSIZE 4 页大小 DATA_DIR 1 表示表空间是否是从默认的数据目录中获取的 SHARED 1 是否为共享表空间 TEMPORARY 1 是否为临时表空间 ENCRYPTION 1 表空间是否加密 UNUSED 18 没有使用到的比特位 小贴士:不同MySQL版本里 SPACE_FLAGS 代表的属性可能有些差异,我们这里列举的是5.7.21版本的。不过大家现在不必深究它们的意思,因为我们一旦把这些概念 展开,就需要非常大的篇幅,主要怕大家受不了。我们还是先挑重要的看,把主要的表空间结构了解完,这些 SPACE_FLAGS 里的属性的细节就暂时不深究了。\nList Base Node for SEG_INODES_FULL List和List Base Node for SEG_INODES_FREE List\n每个段对应的INODE Entry结构会集中存放到一个类型位INODE的页中,如果表空间中的段特别多,则会有多个INODE Entry结构,可能一个页放不下,这些INODE类型的页会组成两种列表:\n+ SEG_INODES_FULL链表,该链表中的INODE类型的页都已经被INODE Entry结构填充满了,没空闲空间存放额外的INODE Entry了。\n+ SEG_INODES_FREE链表,该链表中的INODE类型的页都已经仍有空闲空间来存放INODE Entry结构。\n由于我们现在还没有详细介绍INODE类型页,所以等会说过INODE类型的页之后再回过头来看着两个链表。\nXDES Entry部分 # 紧接着File Space Header部分的就是XDES Entry部分了,我们嘴上介绍过无数次,却从没见过真身的XDES Entry就是在表空间的第一个页中保存的。我们知道一个XDES Entry结构的大小是40字节,但是一个页的大小有限,只能存放有限个XDES Entry结构,所以我们才把256个区划分成一组,在每组的第一个页中存放256个XDES Entry结构。大家回看那个FSP_HDR类型页的示意图,XDES Entry 0就对应着extent 0,XDES Entry 1就对应着extent 1\u0026hellip; 依此类推,XDES Entry255就对应着extent 255。\n因为每个区对应的XDES Entry结构的地址是固定的,所以我们访问这些结构就so easy啦,至于该结构的详细使用情况我们已经介绍的够明白了,在这就不赘述了。\n**XDES**类型 # 我们说过,每一个XDES Entry结构对应表空间的一个区,虽然一个XDES Entry结构只占用40字节,但你抵不住表空间的区的数量也多啊。在区的数量非常多时,一个单独的页可能就不够存放足够多的XDES Entry结构,所以我们把表空间的区分为了若干个组,每组开头的一个页记录着本组内所有的区对应的XDES Entry结构。由于第一个组的第一个页有些特殊,因为它也是整个表空间的第一个页,所以除了记录本组中的所有区对应的XDES Entry结构以外,还记录着表空间的一些整体属性,这个页的类型就是我们刚刚说完的FSP_HDR类型,整个表空间里只有一个这个类型的页。除去第一个分组以外,之后的每个分组的第一个页只需要记录本组内所有的区对应的XDES Entry结构即可,不需要再记录表空间的属性了,为了和FSP_HDR类型做区别,我们把之后每个分组的第一个页的类型定义为XDES,它的结构和FSP_HDR类型是非常相似的:\n与FSP_HDR类型的页对比,除了少了File Space Header部分之外,也就是除了少了记录表空间整体属性的部分之外,其余的部分是一样一样的。由于我们上面介绍的已经够仔细了,对于XDES类型的页也就不重复介绍了。\n**IBUF_BITMAP**类型 # 对比前面介绍表空间的图,每个分组的第二个页的类型都是IBUF_BITMAP,这种类型的页里边记录了一些有关Change Buffer的东东,由于这个Change Buffer里又包含了贼多的概念,考虑到大家在一章中接受这么多新概念有点呼吸不适,怕大家心脏病犯了所以就把Change Buffer的相关知识放到后边的章节中,大家稍安勿躁。\n**INODE**类型 # 再次对比前面介绍表空间的图,第一个分组的第三个页的类型是INODE。我们前面说过设计InnoDB的大佬为每个索引定义了两个段,而且为某些特殊功能定义了些特殊的段。为了方便管理,他们又为每个段设计了一个INODE Entry结构,这个结构中记录了关于这个段的相关属性。而我们这会儿要介绍的这个INODE类型的页就是为了存储INODE Entry结构而存在的。好了,废话少说,直接看图:\n从图中可以看出,一个INODE类型的页是由这几部分构成的: 名称 中文名 占用空间大小 简单描述 File Header 文件头部 38字节 页的一些通用信息 List Node for INODE Page List 通用链表节点 12字节 存储上一个INODE页和下一个INODE页的指针 INODE Entry 段描述信息 16128字节 Empty Space 尚未使用空间 6字节 用于页结构的填充,没什么实际意义 File Trailer 文件尾部 8字节 校验页是否完整 除了File Header、Empty Space、File Trailer这几个老朋友外,我们重点关注List Node for INODE Page List和INODE Entry这两个部分。\n首先看INODE Entry部分,我们前面已经详细介绍过这个结构的组成了,主要包括对应的段内零散页的地址以及附属于该段的FREE、NOT_FULL和FULL链表的基节点。每个INODE Entry结构占用192字节,一个页里可以存储85个这样的结构。\n重点看一下List Node for INODE Page List这个玩意儿,因为一个表空间中可能存在超过85个段,所以可能一个INODE类型的页不足以存储所有的段对应的INODE Entry结构,所以就需要额外的INODE类型的页来存储这些结构。还是为了方便管理这些INODE类型的页,设计InnoDB的大佬们将这些INODE类型的页串联成两个不同的链表:\nSEG_INODES_FULL链表:该链表中的INODE类型的页中已经没有空闲空间来存储额外的INODE Entry结构了。\nSEG_INODES_FREE链表:该链表中的INODE类型的页中还有空闲空间来存储额外的INODE Entry结构了。\n想必大家已经认出这两个链表了,我们前面提到过这两个链表的基节点就存储在File Space Header里边,也就是说这两个链表的基节点的位置是固定的,所以我们可以很轻松的访问到这两个链表。以后每当我们新创建一个段(创建索引时就会创建段)时,都会创建一个INODE Entry结构与之对应,存储INODE Entry的大致过程就是这样的:\n先看看SEG_INODES_FREE链表是否为空,如果不为空,直接从该链表中获取一个节点,也就相当于获取到一个仍有空闲空间的INODE类型的页,然后把该INODE Entry结构防到该页中。当该页中无剩余空间时,就把该页放到SEG_INODES_FULL链表中。\n如果SEG_INODES_FREE链表为空,则需要从表空间的FREE_FRAG链表中申请一个页,修改该页的类型为INODE,把该页放到SEG_INODES_FREE链表中,与此同时把该INODE Entry结构放入该页。\nSegment Header 结构的运用 # 我们知道一个索引会产生两个段,分别是叶子节点段和非叶子节点段,而每个段都会对应一个INODE Entry结构,那我们怎么知道某个段对应哪个INODE Entry结构呢?所以得找个地方记下来这个对应关系。希望你还记得我们在介绍数据页,也就是INDEX类型的页时有一个Page Header部分,当然我不能指望你记住,所以把Page Header部分再抄一遍给你看:\nPage Header部分(为突出重点,省略了好多属性) 名称 占用空间大小 描述 \u0026hellip; \u0026hellip; \u0026hellip; PAGE_BTR_SEG_LEAF 10字节 B+树叶子段的头部信息,仅在B+树的根页定义 PAGE_BTR_SEG_TOP 10字节 B+树非叶子段的头部信息,仅在B+树的根页定义 其中的PAGE_BTR_SEG_LEAF和PAGE_BTR_SEG_TOP都占用10个字节,它们其实对应一个叫Segment Header的结构,该结构图示如下:\n各个部分的具体释义如下: 名称 占用字节数 描述 Space ID of the INODE Entry 4 INODE Entry结构所在的表空间ID Page Number of the INODE Entry 4 INODE Entry结构所在的页页号 Byte Offset of the INODE Ent 2 INODE Entry结构在该页中的偏移量 这样子就很清晰了,PAGE_BTR_SEG_LEAF记录着叶子节点段对应的INODE Entry结构的地址是哪个表空间的哪个页的哪个偏移量,PAGE_BTR_SEG_TOP记录着非叶子节点段对应的INODE Entry结构的地址是哪个表空间的哪个页的哪个偏移量。这样子索引和其对应的段的关系就建立起来了。不过需要注意的一点是,因为一个索引只对应两个段,所以只需要在索引的根页中记录这两个结构即可。\n真实表空间对应的文件大小 # 等会儿等会儿,上面的这些概念已经压的快喘不过气了。不过独立表空间有那么大么?我到数据目录里看了,一个新建的表对应的.ibd文件只占用了96K,才6个页大小,上面的内容该不是扯犊子吧?\n一开始表空间占用的空间自然是很小,因为表里边都没有数据嘛!不过别忘了这些.ibd文件是自扩展的,随着表中数据的增多,表空间对应的文件也逐渐增大。\n系统表空间 # 了解完了独立表空间的基本结构,系统表空间的结构也就好理解多了,系统表空间的结构和独立表空间基本类似,只不过由于整个MySQL进程只有一个系统表空间,在系统表空间中会额外记录一些有关整个系统信息的页,所以会比独立表空间多出一些记录这些信息的页。因为这个系统表空间最牛逼,相当于是表空间之首,所以它的表空间 ID(Space ID)是0。\n系统表空间的整体结构 # 系统表空间与独立表空间的一个非常明显的不同之处就是在表空间开头有许多记录整个系统属性的页,如图:\n可以看到,系统表空间和独立表空间的前三个页(页号分别为0、1、2,类型分别是FSP_HDR、IBUF_BITMAP、INODE)的类型是一致的,只是页号为3~7的页是系统表空间特有的,我们来看一下这些多出来的页都是干什么使的: 页号 页类型 英文描述 描述 3 SYS Insert Buffer Header 存储Insert Buffer的头部信息 4 INDEX Insert Buffer Root 存储Insert Buffer的根页 5 TRX_SYS Transction System 事务系统的相关信息 6 SYS First Rollback Segment 第一个回滚段的页 7 SYS Data Dictionary Header 数据字典头部信息 除了这几个记录系统属性的页之外,系统表空间的extent 1和extent 2这两个区,也就是页号从64~191这128个页被称为Doublewrite buffer,也就是双写缓冲区。不过上述的大部分知识都涉及到了事务和多版本控制的问题,这些问题我们会放在后边的章节集中介绍,现在讲述太影响用户体验,所以现在我们只介绍一下有关InnoDB数据字典的知识,其余的概念在后边再看。\nInnoDB数据字典 # 我们平时使用INSERT语句向表中插入的那些记录称之为用户数据,MySQL只是作为一个软件来为我们来保管这些数据,提供方便的增删改查接口而已。但是每当我们向一个表中插入一条记录的时候,MySQL先要校验一下插入语句对应的表存不存在,插入的列和表中的列是否符合,如果语法没有问题的话,还需要知道该表的聚簇索引和所有二级索引对应的根页是哪个表空间的哪个页,然后把记录插入对应索引的B+树中。所以说,MySQL除了保存着我们插入的用户数据之外,还需要保存许多额外的信息,比方说:\n某个表属于哪个表空间,表里边有多少列\n表对应的每一个列的类型是什么\n该表有多少索引,每个索引对应哪几个字段,该索引对应的根页在哪个表空间的哪个页\n该表有哪些外键,外键对应哪个表的哪些列\n某个表空间对应文件系统上文件路径是什么\nbalabala \u0026hellip; 还有好多,不一一列举了\n上述这些数据并不是我们使用INSERT语句插入的用户数据,实际上是为了更好的管理我们这些用户数据而不得已引入的一些额外数据,这些数据也称为元数据。InnoDB存储引擎特意定义了一些列的内部系统表(internal system table)来记录这些这些元数据: 表名 描述 SYS_TABLES 整个InnoDB存储引擎中所有的表的信息 SYS_COLUMNS 整个InnoDB存储引擎中所有的列的信息 SYS_INDEXES 整个InnoDB存储引擎中所有的索引的信息 SYS_FIELDS 整个InnoDB存储引擎中所有的索引对应的列的信息 SYS_FOREIGN 整个InnoDB存储引擎中所有的外键的信息 SYS_FOREIGN_COLS 整个InnoDB存储引擎中所有的外键对应列的信息 SYS_TABLESPACES 整个InnoDB存储引擎中所有的表空间信息 SYS_DATAFILES 整个InnoDB存储引擎中所有的表空间对应文件系统的文件路径信息 SYS_VIRTUAL 整个InnoDB存储引擎中所有的虚拟生成列的信息 这些系统表也被称为数据字典,它们都是以B+树的形式保存在系统表空间的某些页中,其中SYS_TABLES、SYS_COLUMNS、SYS_INDEXES、SYS_FIELDS这四个表尤其重要,称之为基本系统表(basic system tables),我们先看看这4个表的结构:\nSYS_TABLES表 # SYS_TABLES表的列 列名 描述 NAME 表的名称 ID InnoDB存储引擎中每个表都有一个唯一的ID N_COLS 该表拥有列的个数 TYPE 表的类型,记录了一些文件格式、行格式、压缩等信息 MIX_ID 已过时,忽略 MIX_LEN 表的一些额外的属性 CLUSTER_ID 未使用,忽略 SPACE 该表所属表空间的ID 这个SYS_TABLES表有两个索引:\n以NAME列为主键的聚簇索引\n以ID列建立的二级索引\nSYS_COLUMNS表 # SYS_COLUMNS表的列 列名 描述 TABLE_ID 该列所属表对应的ID POS 该列在表中是第几列 NAME 该列的名称 MTYPE main data type,主数据类型,就是那堆INT、CHAR、VARCHAR、FLOAT、DOUBLE之类的东东 PRTYPE precise type,精确数据类型,就是修饰主数据类型的那堆东东,比如是否允许NULL值,是否允许负数什么的 LEN 该列最多占用存储空间的字节数 PREC 该列的精度,不过这列貌似都没有使用,默认值都是0 这个SYS_COLUMNS表只有一个聚集索引:\n以(TABLE_ID, POS)列为主键的聚簇索引 SYS_INDEXES表 # SYS_INDEXES表的列 列名 描述 TABLE_ID 该索引所属表对应的ID ID InnoDB存储引擎中每个索引都有一个唯一的ID NAME 该索引的名称 N_FIELDS 该索引包含列的个数 TYPE 该索引的类型,比如聚簇索引、唯一索引、更改缓冲区的索引、全文索引、普通的二级索引等等各种类型 SPACE 该索引根页所在的表空间ID PAGE_NO 该索引根页所在的页号 MERGE_THRESHOLD 如果页中的记录被删除到某个比例,就把该页和相邻页合并,这个值就是这个比例 这个SYS_INEXES表只有一个聚集索引:\n以(TABLE_ID, ID)列为主键的聚簇索引 SYS_FIELDS表 # SYS_FIELDS表的列 列名 描述 INDEX_ID 该索引列所属的索引的ID POS 该索引列在某个索引中是第几列 COL_NAME 该索引列的名称 这个SYS_INEXES表只有一个聚集索引:\n以(INDEX_ID, POS)列为主键的聚簇索引 Data Dictionary Header页 # 只要有了上述4个基本系统表,也就意味着可以获取其他系统表以及用户定义的表的所有元数据。比方说我们想看看SYS_TABLESPACES这个系统表里存储了哪些表空间以及表空间对应的属性,那就可以:\n到SYS_TABLES表中根据表名定位到具体的记录,就可以获取到SYS_TABLESPACES表的TABLE_ID\n使用这个TABLE_ID到SYS_COLUMNS表中就可以获取到属于该表的所有列的信息。\n使用这个TABLE_ID还可以到SYS_INDEXES表中获取所有的索引的信息,索引的信息中包括对应的INDEX_ID,还记录着该索引对应的B+数根页是哪个表空间的哪个页。\n使用INDEX_ID就可以到SYS_FIELDS表中获取所有索引列的信息。\n也就是说这4个表是表中之表,那这4个表的元数据去哪里获取呢?没法搞了,只能把这4个表的元数据,就是它们有哪些列、哪些索引等信息硬编码到代码中,然后设计InnoDB的大佬又拿出一个固定的页来记录这4个表的聚簇索引和二级索引对应的B+树位置,这个页就是页号为7的页,类型为SYS,记录了Data Dictionary Header,也就是数据字典的头部信息。除了这4个表的5个索引的根页信息外,这个页号为7的页还记录了整个InnoDB存储引擎的一些全局属性,说话太啰嗦,直接看这个页的示意图:\n可以看到这个页由下面几个部分组成: 名称 中文名 占用空间大小 简单描述 File Header 文件头部 38字节 页的一些通用信息 Data Dictionary Header 数据字典头部信息 56字节 记录一些基本系统表的根页位置以及InnoDB存储引擎的一些全局信息 Segment Header 段头部信息 10字节 记录本页所在段对应的INODE Entry位置信息 Empty Space 尚未使用空间 16272字节 用于页结构的填充,没什么实际意义 File Trailer 文件尾部 8字节 校验页是否完整 可以看到这个页里竟然有Segment Header部分,意味着设计InnoDB的大佬把这些有关数据字典的信息当成一个段来分配存储空间,我们就姑且称之为数据字典段吧。由于目前我们需要记录的数据字典信息非常少(可以看到Data Dictionary Header部分仅占用了56字节),所以该段只有一个碎片页,也就是页号为7的这个页。\n接下来我们需要细细介绍一下Data Dictionary Header部分的各个字段:\nMax Row ID:我们说过如果我们不显式的为表定义主键,而且表中也没有UNIQUE索引,那么InnoDB存储引擎会默认为我们生成一个名为row_id的列作为主键。因为它是主键,所以每条记录的row_id列的值不能重复。原则上只要一个表中的row_id列不重复就可以了,也就是说表a和表b拥有一样的row_id列也没什么关系,不过设计InnoDB的大佬只提供了这个Max Row ID字段,不论哪个拥有row_id列的表插入一条记录时,该记录的row_id列的值就是Max Row ID对应的值,然后再把Max Row ID对应的值加1,也就是说这个Max Row ID是全局共享的。\nMax Table ID:InnoDB存储引擎中的所有的表都对应一个唯一的ID,每次新建一个表时,就会把本字段的值作为该表的ID,然后自增本字段的值。\nMax Index ID:InnoDB存储引擎中的所有的索引都对应一个唯一的ID,每次新建一个索引时,就会把本字段的值作为该索引的ID,然后自增本字段的值。\nMax Space ID:InnoDB存储引擎中的所有的表空间都对应一个唯一的ID,每次新建一个表空间时,就会把本字段的值作为该表空间的ID,然后自增本字段的值。\nMix ID Low(Unused):这个字段没什么用,跳过。\nRoot of SYS_TABLES clust index:本字段代表SYS_TABLES表聚簇索引的根页的页号。\nRoot of SYS_TABLE_IDS sec index:本字段代表SYS_TABLES表为ID列建立的二级索引的根页的页号。\nRoot of SYS_COLUMNS clust index:本字段代表SYS_COLUMNS表聚簇索引的根页的页号。\nRoot of SYS_INDEXES clust index本字段代表SYS_INDEXES表聚簇索引的根页的页号。\nRoot of SYS_FIELDS clust index:本字段代表SYS_FIELDS表聚簇索引的根页的页号。\nUnused:这4个字节没用,跳过。\n以上就是页号为7的页的全部内容,初次看可能会懵逼(因为有点儿绕),大家多瞅几次。\ninformation_schema系统数据库 # 需要注意一点的是,用户是不能直接访问InnoDB的这些内部系统表的,除非你直接去解析系统表空间对应文件系统上的文件。不过设计InnoDB的大佬考虑到查看这些表的内容可能有助于大家分析问题,所以在系统数据库information_schema中提供了一些以innodb_sys开头的表: ``` mysql\u0026gt; USE information_schema; Database changed\nmysql\u0026gt; SHOW TABLES LIKE \u0026lsquo;innodb_sys%\u0026rsquo;; +\u0026mdash;\u0026mdash;\u0026mdash;\u0026mdash;\u0026mdash;\u0026mdash;\u0026mdash;\u0026mdash;\u0026mdash;\u0026mdash;\u0026mdash;\u0026mdash;\u0026mdash;\u0026mdash;\u0026ndash;+ | Tables_in_information_schema (innodb_sys%) | +\u0026mdash;\u0026mdash;\u0026mdash;\u0026mdash;\u0026mdash;\u0026mdash;\u0026mdash;\u0026mdash;\u0026mdash;\u0026mdash;\u0026mdash;\u0026mdash;\u0026mdash;\u0026mdash;\u0026ndash;+ | INNODB_SYS_DATAFILES | | INNODB_SYS_VIRTUAL | | INNODB_SYS_INDEXES | | INNODB_SYS_TABLES | | INNODB_SYS_FIELDS | | INNODB_SYS_TABLESPACES | | INNODB_SYS_FOREIGN_COLS | | INNODB_SYS_COLUMNS | | INNODB_SYS_FOREIGN | | INNODB_SYS_TABLESTATS | +\u0026mdash;\u0026mdash;\u0026mdash;\u0026mdash;\u0026mdash;\u0026mdash;\u0026mdash;\u0026mdash;\u0026mdash;\u0026mdash;\u0026mdash;\u0026mdash;\u0026mdash;\u0026mdash;\u0026ndash;+ 10 rows in set (0.00 sec) ``` 在information_schema数据库中的这些以INNODB_SYS开头的表并不是真正的内部系统表(内部系统表就是我们上面介绍的以SYS开头的那些表),而是在存储引擎启动时读取这些以SYS开头的系统表,然后填充到这些以INNODB_SYS开头的表中。以INNODB_SYS开头的表和以SYS开头的表中的字段并不完全一样,但供大家参考已经足矣。这些表太多了,我就不介绍了,大家自个儿动手试着查一查这些表中的数据吧~\n总结图 # 小册微信交流群2群中一个昵称为think同学非常有心的为表空间画了一个全局图,希望能对各位有帮助(这种学习态度实在让我感动😹):\n"},{"id":24,"href":"/zh/docs/technology/MySQL/MySQL%E6%98%AF%E6%80%8E%E6%A0%B7%E8%BF%90%E8%A1%8C%E7%9A%84/%E7%AC%AC8%E7%AB%A0_%E6%95%B0%E6%8D%AE%E7%9A%84%E5%AE%B6-MySQL%E7%9A%84%E6%95%B0%E6%8D%AE%E7%9B%AE%E5%BD%95/","title":"第8章_数据的家-MySQL的数据目录","section":"My Sql是怎样运行的","content":"第8章 数据的家-MySQL的数据目录\n数据库和文件系统的关系 # 我们知道像InnoDB、MyISAM这样的存储引擎都是把表存储在磁盘上的,而操作系统用来管理磁盘的那个东东又被称为文件系统,所以用专业一点的话来表述就是:像 InnoDB 、 MyISAM 这样的存储引擎都是把表存储在文件系统上的。当我们想读取数据的时候,这些存储引擎会从文件系统中把数据读出来返回给我们,当我们想写入数据的时候,这些存储引擎会把这些数据又写回文件系统。本章就是要介绍一下InnoDB和MyISAM这两个存储引擎的数据如何在文件系统中存储的。\nMySQL数据目录 # MySQL服务器程序在启动时会到文件系统的某个目录下加载一些文件,之后在运行过程中产生的数据也都会存储到这个目录下的某些文件中,这个目录就称为数据目录,我们下面就要详细唠唠这个目录下具体都有哪些重要的东西。\n数据目录和安装目录的区别 # 我们之前只接触过MySQL的安装目录(在安装MySQL的时候我们可以自己指定),我们重点强调过这个安装目录下非常重要的bin目录,它里边存储了许多关于控制客户端程序和服务器程序的命令(许多可执行文件,比如mysql,mysqld,mysqld_safe等等等等好几十个)。而数据目录是用来存储MySQL在运行过程中产生的数据,一定要和本章要讨论的安装目录区别开!一定要区分开!一定要区分开!一定要区分开!\n如何确定MySQL中的数据目录 # 那说了半天,到底MySQL把数据都存到哪个路径下呢?其实数据目录对应着一个系统变量datadir,我们在使用客户端与服务器建立连接之后查看这个系统变量的值就可以了: mysql\u0026gt; SHOW VARIABLES LIKE 'datadir'; +---------------+-----------------------+ | Variable_name | Value | +---------------+-----------------------+ | datadir | /usr/local/var/mysql/ | +---------------+-----------------------+ 1 row in set (0.00 sec) 从结果中可以看出,在我的计算机上MySQL的数据目录就是/usr/local/var/mysql/,你用你的计算机试试呗~\n数据目录的结构 # MySQL在运行过程中都会产生哪些数据呢?当然会包含我们创建的数据库、表、视图和触发器等等的用户数据,除了这些用户数据,为了程序更好的运行,MySQL也会创建一些其他的额外数据,我们接下来细细的品味一下这个数据目录下的内容。\n数据库在文件系统中的表示 # 每当我们使用CREATE DATABASE 数据库名语句创建一个数据库的时候,在文件系统上实际发生了什么呢?其实很简单,每个数据库都对应数据目录下的一个子目录,或者说对应一个文件夹,我们每当我们新建一个数据库时,MySQL会帮我们做这两件事儿:\n在数据目录下创建一个和数据库名同名的子目录(或者说是文件夹)。\n在该与数据库名同名的子目录下创建一个名为db.opt的文件,这个文件中包含了该数据库的各种属性,比方说该数据库的字符集和比较规则是什么。\n比方说我们查看一下在我的计算机上当前有哪些数据库: mysql\u0026gt; SHOW DATABASES; +--------------------+ | Database | +--------------------+ | information_schema | | charset_demo_db | | dahaizi | | mysql | | performance_schema | | sys | | xiaohaizi | +--------------------+ 7 rows in set (0.00 sec) 可以看到在我的计算机上当前有7个数据库,其中charset_demo_db、dahaizi和xiaohaizi数据库是我们自定义的,其余4个数据库是属于MySQL自带的系统数据库。我们再看一下我的计算机上的数据目录下的内容: ``` . ├── auto.cnf ├── ca-key.pem ├── ca.pem ├── charset_demo_db ├── client-cert.pem ├── client-key.pem ├── dahaizi ├── ib_buffer_pool ├── ib_logfile0 ├── ib_logfile1 ├── ibdata1 ├── ibtmp1 ├── mysql ├── performance_schema ├── private_key.pem ├── public_key.pem ├── server-cert.pem ├── server-key.pem ├── sys ├── xiaohaizideMacBook-Pro.local.err ├── xiaohaizideMacBook-Pro.local.pid └── xiaohaizi\n6 directories, 16 files ``` 当然这个数据目录下的文件和子目录比较多,但是如果仔细看的话,除了information_schema这个系统数据库外,其他的数据库在数据目录下都有对应的子目录。这个information_schema比较特殊,设计MySQL的大佬们对它的实现进行了特殊对待,没有使用相应的数据库目录,我们忽略它的存在就好了。\n表在文件系统中的表示 # 我们的数据其实都是以记录的形式插入到表中的,每个表的信息其实可以分为两种:\n表结构的定义\n表中的数据\n表结构就是该表的名称是什么,表里边有多少列,每个列的数据类型是什么,有什么约束条件和索引,用的是什么字符集和比较规则等等的各种信息,这些信息都体现在我们的建表语句中了。为了保存这些信息,InnoDB和MyISAM这两种存储引擎都在数据目录下对应的数据库子目录下创建了一个专门用于描述表结构的文件,文件名是这样: 表名.frm 比方说我们在dahaizi数据库下创建一个名为test的表: ``` mysql\u0026gt; USE dahaizi; Database changed\nmysql\u0026gt; CREATE TABLE test ( -\u0026gt; c1 INT -\u0026gt; ); Query OK, 0 rows affected (0.03 sec) ``` 那在数据库dahaizi对应的子目录下就会创建一个名为test.frm的用于描述表结构的文件。值得注意的是,这个后缀名为.frm是以二进制格式存储的,我们直接打开会是乱码的~ 你还不赶紧在你的计算机上创建个表试试~\n描述表结构的文件我们知道怎么存储了,那表中的数据存到什么文件中了呢?在这个问题上,不同的存储引擎就产生了分歧了,下面我们分别看一下InnoDB和MyISAM是用什么文件来保存表中数据的。\nInnoDB是如何存储表数据的 # 我们前面重点介绍过InnoDB的一些实现原理,到现在为止我们应该熟悉下面这些东东:\nInnoDB其实是使用页为基本单位来管理存储空间的,默认的页大小为16KB。\n对于InnoDB存储引擎来说,每个索引都对应着一棵B+树,该B+树的每个节点都是一个数据页,数据页之间不必要是物理连续的,因为数据页之间有双向链表来维护着这些页的顺序。\nInnoDB的聚簇索引的叶子节点存储了完整的用户记录,也就是所谓的索引即数据,数据即索引。\n为了更好的管理这些页,设计InnoDB的大佬们提出了一个表空间或者文件空间(英文名:table space或者file space)的概念,这个表空间是一个抽象的概念,它可以对应文件系统上一个或多个真实文件(不同表空间对应的文件数量可能不同)。每一个表空间可以被划分为很多很多很多个页,我们的表数据就存放在某个表空间下的某些页里。设计InnoDB的大佬将表空间划分为几种不同的类型,我们一个一个看一下。\n系统表空间(system tablespace) # 这个所谓的系统表空间可以对应文件系统上一个或多个实际的文件,默认情况下,InnoDB会在数据目录下创建一个名为ibdata1(在你的数据目录下找找看有木有)、大小为12M的文件,这个文件就是对应的系统表空间在文件系统上的表示。怎么才12M?这么点儿还没插多少数据就用完了,那是因为这个文件是所谓的自扩展文件,也就是当不够用的时候它会自己增加文件大小~\n当然,如果你想让系统表空间对应文件系统上多个实际文件,或者仅仅觉得原来的ibdata1这个文件名难听,那可以在MySQL启动时配置对应的文件路径以及它们的大小,比如我们这样修改一下配置文件: [server] innodb_data_file_path=data1:512M;data2:512M:autoextend 这样在MySQL启动之后就会创建这两个512M大小的文件作为系统表空间,其中的autoextend表明这两个文件如果不够用会自动扩展data2文件的大小。\n我们也可以把系统表空间对应的文件路径不配置到数据目录下,甚至可以配置到单独的磁盘分区上,涉及到的启动参数就是innodb_data_file_path和innodb_data_home_dir,具体的配置逻辑挺绕的,我们这就不多介绍了,知道改哪个参数可以修改系统表空间对应的文件,有需要的时候到官方文档里一查就好了。\n需要注意的一点是,在一个MySQL服务器中,系统表空间只有一份。从MySQL5.5.7到MySQL5.6.6之间的各个版本中,我们表中的数据都会被默认存储到这个 系统表空间。\n独立表空间(file-per-table tablespace) # 在MySQL5.6.6以及之后的版本中,InnoDB并不会默认的把各个表的数据存储到系统表空间中,而是为每一个表建立一个独立表空间,也就是说我们创建了多少个表,就有多少个独立表空间。使用独立表空间来存储表数据的话,会在该表所属数据库对应的子目录下创建一个表示该独立表空间的文件,文件名和表名相同,只不过添加了一个.ibd的扩展名而已,所以完整的文件名称长这样: 表名.ibd 比方说假如我们使用了独立表空间去存储xiaohaizi数据库下的test表的话,那么在该表所在数据库对应的xiaohaizi目录下会为test表创建这两个文件: test.frm test.ibd 其中test.ibd文件就用来存储test表中的数据和索引。当然我们也可以自己指定使用系统表空间还是独立表空间来存储数据,这个功能由启动参数innodb_file_per_table控制,比如说我们想刻意将表数据都存储到系统表空间时,可以在启动MySQL服务器的时候这样配置: [server] innodb_file_per_table=0 当innodb_file_per_table的值为0时,代表使用系统表空间;当innodb_file_per_table的值为1时,代表使用独立表空间。不过innodb_file_per_table参数只对新建的表起作用,对于已经分配了表空间的表并不起作用。如果我们想把已经存在系统表空间中的表转移到独立表空间,可以使用下面的语法: ALTER TABLE 表名 TABLESPACE [=] innodb_file_per_table; 或者把已经存在独立表空间的表转移到系统表空间,可以使用下面的语法: ALTER TABLE 表名 TABLESPACE [=] innodb_system; 其中中括号扩起来的=可有可无,比方说我们想把test表从独立表空间移动到系统表空间,可以这么写: ALTER TABLE test TABLESPACE innodb_system;\n其他类型的表空间 # 随着MySQL的发展,除了上述两种老牌表空间之外,现在还新提出了一些不同类型的表空间,比如通用表空间(general tablespace)、undo表空间(undo tablespace)、临时表空间(temporary tablespace)等等的,具体情况我们就不细介绍了,等用到的时候再提。\nMyISAM是如何存储表数据的 # 好了,介绍完了InnoDB的系统表空间和独立表空间,现在轮到MyISAM了。我们知道不像InnoDB的索引和数据是一个东东,在MyISAM中的索引全部都是二级索引,该存储引擎的数据和索引是分开存放的。所以在文件系统中也是使用不同的文件来存储数据文件和索引文件。而且和InnoDB不同的是,MyISAM并没有什么所谓的表空间一说,表数据都存放到对应的数据库子目录下。假如test表使用MyISAM存储引擎的话,那么在它所在数据库对应的xiaohaizi目录下会为test表创建这三个文件: test.frm test.MYD test.MYI 其中test.MYD代表表的数据文件,也就是我们插入的用户记录;test.MYI代表表的索引文件,我们为该表创建的索引都会放到这个文件中。\n视图在文件系统中的表示 # 我们知道MySQL中的视图其实是虚拟的表,也就是某个查询语句的一个别名而已,所以在存储视图的时候是不需要存储真实的数据的,只需要把它的结构存储起来就行了。和表一样,描述视图结构的文件也会被存储到所属数据库对应的子目录下面,只会存储一个视图名.frm的文件。\n其他的文件 # 除了我们上面说的这些用户自己存储的数据以外,数据目录下还包括为了更好运行程序的一些额外文件,主要包括这几种类型的文件:\n服务器进程文件。\n我们知道每运行一个MySQL服务器程序,都意味着启动一个进程。MySQL服务器会把自己的进程ID写入到一个文件中。\n服务器日志文件。\n在服务器运行过程中,会产生各种各样的日志,比如常规的查询日志、错误日志、二进制日志、redo日志等等各种日志,这些日志各有各的用途,我们之后会重点介绍各种日志的用途,现在先了解一下就可以了。\n默认/自动生成的SSL和RSA证书和密钥文件。\n主要是为了客户端和服务器安全通信而创建的一些文件, 大家看不懂可以忽略~\n文件系统对数据库的影响 # 因为MySQL的数据都是存在文件系统中的,就不得不受到文件系统的一些制约,这在数据库和表的命名、表的大小和性能方面体现的比较明显,比如下面这些方面:\n数据库名称和表名称不得超过文件系统所允许的最大长度。\n每个数据库都对应数据目录的一个子目录,数据库名称就是这个子目录的名称;每个表都会在数据库子目录下产生一个和表名同名的.frm文件,如果是InnoDB的独立表空间或者使用MyISAM引擎还会有别的文件的名称与表名一致。这些目录或文件名的长度都受限于文件系统所允许的长度~\n特殊字符的问题\n为了避免因为数据库名和表名出现某些特殊字符而造成文件系统不支持的情况,MySQL会把数据库名和表名中所有除数字和拉丁字母以外的所有字符在文件名里都映射成 @+编码值的形式作为文件名。比方说我们创建的表的名称为'test?',由于?不属于数字或者拉丁字母,所以会被映射成编码值,所以这个表对应的.frm文件的名称就变成了test@003f.frm。\n文件长度受文件系统最大长度限制\n对于InnoDB的独立表空间来说,每个表的数据都会被存储到一个与表名同名的.ibd文件中;对于MyISAM存储引擎来说,数据和索引会分别存放到与表同名的.MYD和.MYI文件中。这些文件会随着表中记录的增加而增大,它们的大小受限于文件系统支持的最大文件大小。\nMySQL系统数据库简介 # 我们前面提到了MySQL的几个系统数据库,这几个数据库包含了MySQL服务器运行过程中所需的一些信息以及一些运行状态信息,我们现在稍微了解一下。\nmysql\n这个数据库贼核心,它存储了MySQL的用户账户和权限信息,一些存储过程、事件的定义信息,一些运行过程中产生的日志信息,一些帮助信息以及时区信息等。\ninformation_schema\n这个数据库保存着MySQL服务器维护的所有其他数据库的信息,比如有哪些表、哪些视图、哪些触发器、哪些列、哪些索引等等。这些信息并不是真实的用户数据,而是一些描述性信息,有时候也称之为元数据。\nperformance_schema\n这个数据库里主要保存MySQL服务器运行过程中的一些状态信息,算是对MySQL服务器的一个性能监控。包括统计最近执行了哪些语句,在执行过程的每个阶段都花费了多长时间,内存的使用情况等等信息。\nsys\n这个数据库主要是通过视图的形式把information_schema和performance_schema结合起来,让程序员可以更方便的了解MySQL服务器的一些性能信息。\n什么?这四个系统数据库这就介绍完了?是的,我们的标题写的就是简介嘛!如果真的要介绍一下这几个系统库的使用,那怕是又要写一本书了\u0026hellip; 这里只是因为介绍数据目录里遇到了,为了内容的完整性跟大家提一下,具体如何使用还是要参照文档~\n"},{"id":25,"href":"/zh/docs/technology/MySQL/MySQL%E6%98%AF%E6%80%8E%E6%A0%B7%E8%BF%90%E8%A1%8C%E7%9A%84/%E7%AC%AC7%E7%AB%A0_%E5%A5%BD%E4%B8%9C%E8%A5%BF%E4%B9%9F%E5%BE%97%E5%85%88%E5%AD%A6%E4%BC%9A%E6%80%8E%E4%B9%88%E7%94%A8-B+%E6%A0%91%E7%B4%A2%E5%BC%95%E7%9A%84%E4%BD%BF%E7%94%A8/","title":"第7章_好东西也得先学会怎么用-B+树索引的使用","section":"My Sql是怎样运行的","content":"第7章 好东西也得先学会怎么用-B+树索引的使用\n我们前面详细、详细又详细的介绍了InnoDB存储引擎的B+树索引,我们必须熟悉下面这些结论:\n每个索引都对应一棵B+树,B+树分为好多层,最下面一层是叶子节点,其余的是内节点。所有用户记录都存储在B+树的叶子节点,所有目录项记录都存储在内节点。\nInnoDB存储引擎会自动为主键(如果没有它会自动帮我们添加)建立聚簇索引,聚簇索引的叶子节点包含完整的用户记录。\n我们可以为自己感兴趣的列建立二级索引,二级索引的叶子节点包含的用户记录由索引列 + 主键组成,所以如果想通过二级索引来查找完整的用户记录的话,需要通过回表操作,也就是在通过二级索引找到主键值之后再到聚簇索引中查找完整的用户记录。\nB+树中每层节点都是按照索引列值从小到大的顺序排序而组成了双向链表,而且每个页内的记录(不论是用户记录还是目录项记录)都是按照索引列的值从小到大的顺序而形成了一个单链表。如果是联合索引的话,则页面和记录先按照联合索引前面的列排序,如果该列值相同,再按照联合索引后边的列排序。\n通过索引查找记录是从B+树的根节点开始,一层一层向下搜索。由于每个页面都按照索引列的值建立了Page Directory(页目录),所以在这些页面中的查找非常快。\n如果你读上面的几点结论有些任何一点点疑惑的话,那下面的内容不适合你,回过头先去看前面的内容去。\n索引的代价 # 在熟悉了B+树索引原理之后,本篇文章的主题是介绍如何更好的使用索引,虽然索引是个好东西,可不能乱建,在介绍如何更好的使用索引之前先要了解一下使用这玩意儿的代价,它在空间和时间上都会拖后腿:\n空间上的代价\n这个是显而易见的,每建立一个索引都要为它建立一棵B+树,每一棵B+树的每一个节点都是一个数据页,一个页默认会占用16KB的存储空间,一棵很大的B+树由许多数据页组成,那可是很大的一片存储空间呢。\n时间上的代价\n每次对表中的数据进行增、删、改操作时,都需要去修改各个B+树索引。而且我们讲过,B+树每层节点都是按照索引列的值从小到大的顺序排序而组成了双向链表。不论是叶子节点中的记录,还是内节点中的记录(也就是不论是用户记录还是目录项记录)都是按照索引列的值从小到大的顺序而形成了一个单向链表。而增、删、改操作可能会对节点和记录的排序造成破坏,所以存储引擎需要额外的时间进行一些记录移位,页面分裂、页面回收什么的操作来维护好节点和记录的排序。如果我们建了许多索引,每个索引对应的B+树都要进行相关的维护操作,这还能不给性能拖后腿么?\n所以说,一个表上索引建的越多,就会占用越多的存储空间,在增删改记录的时候性能就越差。为了能建立又好又少的索引,我们先得学学这些索引在哪些条件下起作用的。\nB+树索引适用的条件 # 下面我们将介绍许多种让B+树索引发挥最大效能的技巧和注意事项,不过大家要清楚,所有的技巧都是源自你对B+树索引本质的理解,所以如果你还不能保证对B+树索引充分的理解,那么再次建议回过头把前面的内容看完了再来,要不然读文章对你来说是一种折磨。首先,B+树索引并不是万能的,并不是所有的查询语句都能用到我们建立的索引。下面介绍几个我们可能使用B+树索引来进行查询的情况。为了故事的顺利发展,我们需要先创建一个表,这个表是用来存储人的一些基本信息的: CREATE TABLE person_info( id INT NOT NULL auto_increment, name VARCHAR(100) NOT NULL, birthday DATE NOT NULL, phone_number CHAR(11) NOT NULL, country varchar(100) NOT NULL, PRIMARY KEY (id), KEY idx_name_birthday_phone_number (name, birthday, phone_number) ); 对于这个person_info表我们需要注意两点:\n表中的主键是id列,它存储一个自动递增的整数。所以InnoDB存储引擎会自动为id列建立聚簇索引。\n我们额外定义了一个二级索引idx_name_birthday_phone_number,它是由3个列组成的联合索引。所以在这个索引对应的B+树的叶子节点处存储的用户记录只保留name、birthday、phone_number这三个列的值以及主键id的值,并不会保存country列的值。\n从这两点注意中我们可以再次看到,一个表中有多少索引就会建立多少棵B+树,person_info表会为聚簇索引和idx_name_birthday_phone_number索引建立2棵B+树。下面我们画一下索引idx_name_birthday_phone_number的示意图,不过既然我们已经掌握了InnoDB的B+树索引原理,那我们在画图的时候为了让图更加清晰,所以在省略一些不必要的部分,比如记录的额外信息,各页面的页号等等,其中内节点中目录项记录的页号信息我们用箭头来代替,在记录结构中只保留name、birthday、phone_number、id这四个列的真实数据值,所以示意图就长这样(留心的同学看出来了,这其实和《高性能MySQL》里举的例子的图差不多,我觉得这个例子特别好,所以就借鉴了一下):\n为了方便大家理解,我们特意标明了哪些是内节点,哪些是叶子节点。再次强调一下,内节点中存储的是目录项记录,叶子节点中存储的是用户记录(由于不是聚簇索引,所以用户记录是不完整的,缺少country列的值)。从图中可以看出,这个idx_name_birthday_phone_number索引对应的B+树中页面和记录的排序方式就是这样的:\n先按照name列的值进行排序。 如果name列的值相同,则按照birthday列的值进行排序。 如果birthday列的值也相同,则按照phone_number的值进行排序。 这个排序方式十分、特别、非常、巨、very very very重要,因为只要页面和记录是排好序的,我们就可以通过二分法来快速定位查找。下面的内容都仰仗这个图了,大家对照着图理解。\n全值匹配 # 如果我们的搜索条件中的列和索引列一致的话,这种情况就称为全值匹配,比方说下面这个查找语句: SELECT * FROM person_info WHERE name = 'Ashburn' AND birthday = '1990-09-27' AND phone_number = '15123983239'; 我们建立的idx_name_birthday_phone_number索引包含的3个列在这个查询语句中都展现出来了。大家可以想象一下这个查询过程:\n因为B+树的数据页和记录先是按照name列的值进行排序的,所以先可以很快定位name列的值是Ashburn的记录位置。\n在name列相同的记录里又是按照birthday列的值进行排序的,所以在name列的值是Ashburn的记录里又可以快速定位birthday列的值是'1990-09-27'的记录。\n如果很不幸,name和birthday列的值都是相同的,那记录是按照phone_number列的值排序的,所以联合索引中的三个列都可能被用到。\n有的同学也许有个疑问,WHERE子句中的几个搜索条件的顺序对查询结果有什么影响么?也就是说如果我们调换name、birthday、phone_number这几个搜索列的顺序对查询的执行过程有影响么?比方说写成下面这样: SELECT * FROM person_info WHERE birthday = '1990-09-27' AND phone_number = '15123983239' AND name = 'Ashburn'; 答案是:没影响。MySQL有一个叫查询优化器的东东,会分析这些搜索条件并且按照可以使用的索引中列的顺序来决定先使用哪个搜索条件,后使用哪个搜索条件。我们后边儿会有专门的章节来介绍查询优化器,敬请期待。\n匹配左边的列 # 其实在我们的搜索语句中也可以不用包含全部联合索引中的列,只包含左边的就行,比方说下面的查询语句: SELECT * FROM person_info WHERE name = 'Ashburn'; 或者包含多个左边的列也行: SELECT * FROM person_info WHERE name = 'Ashburn' AND birthday = '1990-09-27'; 那为什么搜索条件中必须出现左边的列才可以使用到这个B+树索引呢?比如下面的语句就用不到这个B+树索引么? SELECT * FROM person_info WHERE birthday = '1990-09-27'; 是的,的确用不到,因为B+树的数据页和记录先是按照name列的值排序的,在name列的值相同的情况下才使用birthday列进行排序,也就是说name列的值不同的记录中birthday的值可能是无序的。而现在你跳过name列直接根据birthday的值去查找,臣妾做不到呀~ 那如果我就想在只使用birthday的值去通过B+树索引进行查找咋办呢?这好办,你再对birthday列建一个B+树索引就行了,创建索引的语法不用我介绍了吧。\n但是需要特别注意的一点是,如果我们想使用联合索引中尽可能多的列,搜索条件中的各个列必须是联合索引中从最左边连续的列。比方说联合索引idx_name_birthday_phone_number中列的定义顺序是name、birthday、phone_number,如果我们的搜索条件中只有name和phone_number,而没有中间的birthday,比方说这样: SELECT * FROM person_info WHERE name = 'Ashburn' AND phone_number = '15123983239'; 这样只能用到name列的索引,birthday和phone_number的索引就用不上了,因为name值相同的记录先按照birthday的值进行排序,birthday值相同的记录才按照phone_number值进行排序。\n匹配列前缀 # 我们前面说过为某个列建立索引的意思其实就是在对应的B+树的记录中使用该列的值进行排序,比方说person_info表上建立的联合索引idx_name_birthday_phone_number会先用name列的值进行排序,所以这个联合索引对应的B+树中的记录的name列的排列就是这样的: Aaron Aaron ... Aaron Asa Ashburn ... Ashburn Baird Barlow ... Barlow 字符串排序的本质就是比较哪个字符串大一点儿,哪个字符串小一点,比较字符串大小就用到了该列的字符集和比较规则,这个我们前面儿介绍过,就不多介绍了。这里需要注意的是,一般的比较规则都是逐个比较字符的大小,也就是说我们比较两个字符串的大小的过程其实是这样的:\n先比较字符串的第一个字符,第一个字符小的那个字符串就比较小。\n如果两个字符串的第一个字符相同,那就再比较第二个字符,第二个字符比较小的那个字符串就比较小。\n如果两个字符串的第二个字符也相同,那就接着比较第三个字符,依此类推。\n所以一个排好序的字符串列其实有这样的特点:\n先按照字符串的第一个字符进行排序。\n如果第一个字符相同再按照第二个字符进行排序。\n如果第二个字符相同再按照第三个字符进行排序,依此类推。\n也就是说这些字符串的前n个字符,也就是前缀都是排好序的,所以对于字符串类型的索引列来说,我们只匹配它的前缀也是可以快速定位记录的,比方说我们想查询名字以'As'开头的记录,那就可以这么写查询语句: SELECT * FROM person_info WHERE name LIKE 'As%'; 但是需要注意的是,如果只给出后缀或者中间的某个字符串,比如这样: SELECT * FROM person_info WHERE name LIKE '%As%'; MySQL就无法快速定位记录位置了,因为字符串中间有'As'的字符串并没有排好序,所以只能全表扫描了。有时候我们有一些匹配某些字符串后缀的需求,比方说某个表有一个url列,该列中存储了许多url: +----------------+ | url | +----------------+ | www.baidu.com | | www.google.com | | www.gov.cn | | ... | | www.wto.org | +----------------+ 假设已经对该url列创建了索引,如果我们想查询以com为后缀的网址的话可以这样写查询条件:WHERE url LIKE '%com',但是这样的话无法使用该url列的索引。为了在查询时用到这个索引而不至于全表扫描,我们可以把后缀查询改写成前缀查询,不过我们就得把表中的数据全部逆序存储一下,也就是说我们可以这样保存url列中的数据: +----------------+ | url | +----------------+ | moc.udiab.www | | moc.elgoog.www | | nc.vog.www | | ... | | gro.otw.www | +----------------+ 这样再查找以com为后缀的网址时搜索条件便可以这么写:WHERE url LIKE 'moc%',这样就可以用到索引了。\n匹配范围值 # 回头看我们idx_name_birthday_phone_number索引的B+树示意图,所有记录都是按照索引列的值从小到大的顺序排好序的,所以这极大的方便我们查找索引列的值在某个范围内的记录。比方说下面这个查询语句: SELECT * FROM person_info WHERE name \u0026gt; 'Asa' AND name \u0026lt; 'Barlow'; 由于B+树中的数据页和记录是先按name列排序的,所以我们上面的查询过程其实是这样的:\n找到name值为Asa的记录。 找到name值为Barlow的记录。 哦啦,由于所有记录都是由链表连起来的(记录之间用单链表,数据页之间用双链表),所以他们之间的记录都可以很容易的取出来喽~ 找到这些记录的主键值,再到聚簇索引中回表查找完整的记录。 不过在使用联合进行范围查找的时候需要注意,如果对多个列同时进行范围查找的话,只有对索引最左边的那个列进行范围查找的时候才能用到B+树索引,比方说这样: SELECT * FROM person_info WHERE name \u0026gt; 'Asa' AND name \u0026lt; 'Barlow' AND birthday \u0026gt; '1980-01-01'; 上面这个查询可以分成两个部分:\n通过条件name \u0026gt; 'Asa' AND name \u0026lt; 'Barlow'来对name进行范围,查找的结果可能有多条name值不同的记录,\n对这些name值不同的记录继续通过birthday \u0026gt; '1980-01-01'条件继续过滤。\n这样子对于联合索引idx_name_birthday_phone_number来说,只能用到name列的部分,而用不到birthday列的部分,因为只有name值相同的情况下才能用birthday列的值进行排序,而这个查询中通过name进行范围查找的记录中可能并不是按照birthday列进行排序的,所以在搜索条件中继续以birthday列进行查找时是用不到这个B+树索引的。\n精确匹配某一列并范围匹配另外一列 # 对于同一个联合索引来说,虽然对多个列都进行范围查找时只能用到最左边那个索引列,但是如果左边的列是精确查找,则右边的列可以进行范围查找,比方说这样: SELECT * FROM person_info WHERE name = 'Ashburn' AND birthday \u0026gt; '1980-01-01' AND birthday \u0026lt; '2000-12-31' AND phone_number \u0026gt; '15100000000'; 这个查询的条件可以分为3个部分:\nname = 'Ashburn',对name列进行精确查找,当然可以使用B+树索引了。\nbirthday \u0026gt; '1980-01-01' AND birthday \u0026lt; '2000-12-31',由于name列是精确查找,所以通过name = 'Ashburn'条件查找后得到的结果的name值都是相同的,它们会再按照birthday的值进行排序。所以此时对birthday列进行范围查找是可以用到B+树索引的。\nphone_number \u0026gt; '15100000000',通过birthday的范围查找的记录的birthday的值可能不同,所以这个条件无法再利用B+树索引了,只能遍历上一步查询得到的记录。\n同理,下面的查询也是可能用到这个idx_name_birthday_phone_number联合索引的: SELECT * FROM person_info WHERE name = 'Ashburn' AND birthday = '1980-01-01' AND phone_number \u0026gt; '15100000000';\n用于排序 # 我们在写查询语句的时候经常需要对查询出来的记录通过ORDER BY子句按照某种规则进行排序。一般情况下,我们只能把记录都加载到内存中,再用一些排序算法,比如快速排序、归并排序、等等排序等等在内存中对这些记录进行排序,有的时候可能查询的结果集太大以至于不能在内存中进行排序的话,还可能暂时借助磁盘的空间来存放中间结果,排序操作完成后再把排好序的结果集返回到客户端。在MySQL中,把这种在内存中或者磁盘上进行排序的方式统称为文件排序(英文名:filesort),跟文件这个词儿一沾边儿,就显得这些排序操作非常慢了(磁盘和内存的速度比起来,就像是飞机和蜗牛的对比)。但是如果ORDER BY子句里使用到了我们的索引列,就有可能省去在内存或文件中排序的步骤,比如下面这个简单的查询语句: SELECT * FROM person_info ORDER BY name, birthday, phone_number LIMIT 10; 这个查询的结果集需要先按照name值排序,如果记录的name值相同,则需要按照birthday来排序,如果birthday的值相同,则需要按照phone_number排序。大家可以回过头去看我们建立的idx_name_birthday_phone_number索引的示意图,因为这个B+树索引本身就是按照上述规则排好序的,所以直接从索引中提取数据,然后进行回表操作取出该索引中不包含的列就好了。简单吧?是的,索引就是这么牛逼。\n使用联合索引进行排序注意事项 # 对于联合索引有个问题需要注意,ORDER BY的子句后边的列的顺序也必须按照索引列的顺序给出,如果给出ORDER BY phone_number, birthday, name的顺序,那也是用不了B+树索引,这种颠倒顺序就不能使用索引的原因我们上面详细说过了,这就不赘述了。\n同理,ORDER BY name、ORDER BY name, birthday这种匹配索引左边的列的形式可以使用部分的B+树索引。当联合索引左边列的值为常量,也可以使用后边的列进行排序,比如这样: SELECT * FROM person_info WHERE name = 'A' ORDER BY birthday, phone_number LIMIT 10; 这个查询能使用联合索引进行排序是因为name列的值相同的记录是按照birthday, phone_number排序的,说了好多遍了都。\n不可以使用索引进行排序的几种情况 # ASC、DESC混用 # 对于使用联合索引进行排序的场景,我们要求各个排序列的排序顺序是一致的,也就是要么各个列都是ASC规则排序,要么都是DESC规则排序。 小贴士: ORDER BY子句后的列如果不加ASC或者DESC默认是按照ASC排序规则排序的,也就是升序排序的。 为什么会有这种奇葩规定呢?这个还得回头想想这个idx_name_birthday_phone_number联合索引中记录的结构:\n先按照记录的name列的值进行升序排列。\n如果记录的name列的值相同,再按照birthday列的值进行升序排列。\n如果记录的birthday列的值相同,再按照phone_number列的值进行升序排列。\n如果查询中的各个排序列的排序顺序是一致的,比方说下面这两种情况:\nORDER BY name, birthday LIMIT 10\n这种情况直接从索引的最左边开始往右读10行记录就可以了。\nORDER BY name DESC, birthday DESC LIMIT 10\n这种情况直接从索引的最右边开始往左读10行记录就可以了。\n但是如果我们查询的需求是先按照name列进行升序排列,再按照birthday列进行降序排列的话,比如说这样的查询语句: SELECT * FROM person_info ORDER BY name, birthday DESC LIMIT 10; 这样如果使用索引排序的话过程就是这样的:\n先从索引的最左边确定name列最小的值,然后找到name列等于该值的所有记录,然后从name列等于该值的最右边的那条记录开始往左找10条记录。\n如果name列等于最小的值的记录不足10条,再继续往右找name值第二小的记录,重复上面那个过程,直到找到10条记录为止。\n累不累?累!重点是这样不能高效使用索引,而要采取更复杂的算法去从索引中取数据,设计MySQL的大佬觉得这样还不如直接文件排序来的快,所以就规定使用联合索引的各个排序列的排序顺序必须是一致的。\nWHERE子句中出现非排序使用到的索引列 # 如果WHERE子句中出现了非排序使用到的索引列,那么排序依然是使用不到索引的,比方说这样: SELECT * FROM person_info WHERE country = 'China' ORDER BY name LIMIT 10; 这个查询只能先把符合搜索条件country = 'China'的记录提取出来后再进行排序,是使用不到索引。注意和下面这个查询作区别: SELECT * FROM person_info WHERE name = 'A' ORDER BY birthday, phone_number LIMIT 10; 虽然这个查询也有搜索条件,但是name = 'A'可以使用到索引idx_name_birthday_phone_number,而且过滤剩下的记录还是按照birthday、phone_number列排序的,所以还是可以使用索引进行排序的。\n排序列包含非同一个索引的列 # 有时候用来排序的多个列不是一个索引里的,这种情况也不能使用索引进行排序,比方说: SELECT * FROM person_info ORDER BY name, country LIMIT 10; name和country并不属于一个联合索引中的列,所以无法使用索引进行排序,至于为什么我就不想再介绍了,自己用前面的理论自己捋一捋把~\n排序列使用了复杂的表达式 # 要想使用索引进行排序操作,必须保证索引列是以单独列的形式出现,而不是修饰过的形式,比方说这样: SELECT * FROM person_info ORDER BY UPPER(name) LIMIT 10; 使用了UPPER函数修饰过的列就不是单独的列啦,这样就无法使用索引进行排序啦。\n用于分组 # 有时候我们为了方便统计表中的一些信息,会把表中的记录按照某些列进行分组。比如下面这个分组查询: SELECT name, birthday, phone_number, COUNT(*) FROM person_info GROUP BY name, birthday, phone_number 这个查询语句相当于做了3次分组操作:\n先把记录按照name值进行分组,所有name值相同的记录划分为一组。\n将每个name值相同的分组里的记录再按照birthday的值进行分组,将birthday值相同的记录放到一个小分组里,所以看起来就像在一个大分组里又化分了好多小分组。\n再将上一步中产生的小分组按照phone_number的值分成更小的分组,所以整体上看起来就像是先把记录分成一个大分组,然后把大分组分成若干个小分组,然后把若干个小分组再细分成更多的小小分组。\n然后针对那些小小分组进行统计,比如在我们这个查询语句中就是统计每个小小分组包含的记录条数。如果没有索引的话,这个分组过程全部需要在内存里实现,而如果有了索引的话,恰巧这个分组顺序又和我们的B+树中的索引列的顺序是一致的,而我们的B+树索引又是按照索引列排好序的,这不正好么,所以可以直接使用B+树索引进行分组。\n和使用B+树索引进行排序是一个道理,分组列的顺序也需要和索引列的顺序一致,也可以只使用索引列中左边的列进行分组,等等的~\n回表的代价 # 上面的讨论对回表这个词儿多是一带而过,可能大家没什么深刻的体会,下面我们详细介绍下。还是用idx_name_birthday_phone_number索引为例,看下面这个查询: SELECT * FROM person_info WHERE name \u0026gt; 'Asa' AND name \u0026lt; 'Barlow'; 在使用idx_name_birthday_phone_number索引进行查询时大致可以分为这两个步骤:\n从索引idx_name_birthday_phone_number对应的B+树中取出name值在Asa~Barlow之间的用户记录。\n由于索引idx_name_birthday_phone_number对应的B+树用户记录中只包含name、birthday、phone_number、id这4个字段,而查询列表是*,意味着要查询表中所有字段,也就是还要包括country字段。这时需要把从上一步中获取到的每一条记录的id字段都到聚簇索引对应的B+树中找到完整的用户记录,也就是我们通常所说的回表,然后把完整的用户记录返回给查询用户。\n由于索引idx_name_birthday_phone_number对应的B+树中的记录首先会按照name列的值进行排序,所以值在Asa~Barlow之间的记录在磁盘中的存储是相连的,集中分布在一个或几个数据页中,我们可以很快的把这些连着的记录从磁盘中读出来,这种读取方式我们也可以称为顺序I/O。根据第1步中获取到的记录的id字段的值可能并不相连,而在聚簇索引中记录是根据id(也就是主键)的顺序排列的,所以根据这些并不连续的id值到聚簇索引中访问完整的用户记录可能分布在不同的数据页中,这样读取完整的用户记录可能要访问更多的数据页,这种读取方式我们也可以称为随机I/O。一般情况下,顺序I/O比随机I/O的性能高很多,所以步骤1的执行可能很快,而步骤2就慢一些。所以这个使用索引idx_name_birthday_phone_number的查询有这么两个特点:\n会使用到两个B+树索引,一个二级索引,一个聚簇索引。\n访问二级索引使用顺序I/O,访问聚簇索引使用随机I/O。\n需要回表的记录越多,使用二级索引的性能就越低,甚至让某些查询宁愿使用全表扫描也不使用二级索引。比方说name值在Asa~Barlow之间的用户记录数量占全部记录数量90%以上,那么如果使用idx_name_birthday_phone_number索引的话,有90%多的id值需要回表,这不是吃力不讨好么,还不如直接去扫描聚簇索引(也就是全表扫描)。\n那什么时候采用全表扫描的方式,什么时候使用采用二级索引 + 回表的方式去执行查询呢?这个就是传说中的查询优化器做的工作,查询优化器会事先对表中的记录计算一些统计数据,然后再利用这些统计数据根据查询的条件来计算一下需要回表的记录数,需要回表的记录数越多,就越倾向于使用全表扫描,反之倾向于使用二级索引 + 回表的方式。当然优化器做的分析工作不仅仅是这么简单,但是大致上是个这个过程。一般情况下,限制查询获取较少的记录数会让优化器更倾向于选择使用二级索引 + 回表的方式进行查询,因为回表的记录越少,性能提升就越高,比方说上面的查询可以改写成这样: SELECT * FROM person_info WHERE name \u0026gt; 'Asa' AND name \u0026lt; 'Barlow' LIMIT 10; 添加了LIMIT 10的查询更容易让优化器采用二级索引 + 回表的方式进行查询。\n对于有排序需求的查询,上面讨论的采用全表扫描还是二级索引 + 回表的方式进行查询的条件也是成立的,比方说下面这个查询: SELECT * FROM person_info ORDER BY name, birthday, phone_number; 由于查询列表是*,所以如果使用二级索引进行排序的话,需要把排序完的二级索引记录全部进行回表操作,这样操作的成本还不如直接遍历聚簇索引然后再进行文件排序(filesort)低,所以优化器会倾向于使用全表扫描的方式执行查询。如果我们加了LIMIT子句,比如这样: SELECT * FROM person_info ORDER BY name, birthday, phone_number LIMIT 10; 这样需要回表的记录特别少,优化器就会倾向于使用二级索引 + 回表的方式执行查询。\n覆盖索引 # 为了彻底告别回表操作带来的性能损耗,我们建议:最好在查询列表里只包含索引列,比如这样: SELECT name, birthday, phone_number FROM person_info WHERE name \u0026gt; 'Asa' AND name \u0026lt; 'Barlow'; 因为我们只查询name, birthday, phone_number这三个索引列的值,所以在通过idx_name_birthday_phone_number索引得到结果后就不必到聚簇索引中再查找记录的剩余列,也就是country列的值了,这样就省去了回表操作带来的性能损耗。我们把这种只需要用到索引的查询方式称为索引覆盖。排序操作也优先使用覆盖索引的方式进行查询,比方说这个查询: SELECT name, birthday, phone_number FROM person_info ORDER BY name, birthday, phone_number; 虽然这个查询中没有LIMIT子句,但是采用了覆盖索引,所以查询优化器就会直接使用idx_name_birthday_phone_number索引进行排序而不需要回表操作了。\n当然,如果业务需要查询出索引以外的列,那还是以保证业务需求为重。但是我们很不鼓励用*号作为查询列表,最好把我们需要查询的列依次标明。\n如何挑选索引 # 上面我们以idx_name_birthday_phone_number索引为例对索引的适用条件进行了详细的介绍,下面看一下我们在建立索引时或者编写查询语句时就应该注意的一些事项。\n只为用于搜索、排序或分组的列创建索引 # 也就是说,只为出现在WHERE子句中的列、连接子句中的连接列,或者出现在ORDER BY或GROUP BY子句中的列创建索引。而出现在查询列表中的列就没必要建立索引了: SELECT birthday, country FROM person_name WHERE name = 'Ashburn'; 像查询列表中的birthday、country这两个列就不需要建立索引,我们只需要为出现在WHERE子句中的name列创建索引就可以了。\n考虑列的基数 # 列的基数指的是某一列中不重复数据的个数,比方说某个列包含值2, 5, 8, 2, 5, 8, 2, 5, 8,虽然有9条记录,但该列的基数却是3。也就是说,在记录行数一定的情况下,列的基数越大,该列中的值越分散,列的基数越小,该列中的值越集中。这个列的基数指标非常重要,直接影响我们是否能有效的利用索引。假设某个列的基数为1,也就是所有记录在该列中的值都一样,那为该列建立索引是没有用的,因为所有值都一样就无法排序,无法进行快速查找了~ 而且如果某个建立了二级索引的列的重复值特别多,那么使用这个二级索引查出的记录还可能要做回表操作,这样性能损耗就更大了。所以结论就是:最好为那些列的基数大的列建立索引,为基数太小列的建立索引效果可能不好。\n索引列的类型尽量小 # 我们在定义表结构的时候要显式的指定列的类型,以整数类型为例,有TINYINT、MEDIUMINT、INT、BIGINT这么几种,它们占用的存储空间依次递增,我们这里所说的类型大小指的就是该类型表示的数据范围的大小。能表示的整数范围当然也是依次递增,如果我们想要对某个整数列建立索引的话,在表示的整数范围允许的情况下,尽量让索引列使用较小的类型,比如我们能使用INT就不要使用BIGINT,能使用MEDIUMINT就不要使用INT~ 这是因为:\n数据类型越小,在查询时进行的比较操作越快(这是CPU层次的东东)\n数据类型越小,索引占用的存储空间就越少,在一个数据页内就可以放下更多的记录,从而减少磁盘I/O带来的性能损耗,也就意味着可以把更多的数据页缓存在内存中,从而加快读写效率。\n这个建议对于表的主键来说更加适用,因为不仅是聚簇索引中会存储主键值,其他所有的二级索引的节点处都会存储一份记录的主键值,如果主键适用更小的数据类型,也就意味着节省更多的存储空间和更高效的I/O。\n索引字符串值的前缀 # 我们知道一个字符串其实是由若干个字符组成,如果我们在MySQL中使用utf8字符集去存储字符串的话,编码一个字符需要占用1~3个字节。假设我们的字符串很长,那存储一个字符串就需要占用很大的存储空间。在我们需要为这个字符串列建立索引时,那就意味着在对应的B+树中有这么两个问题:\nB+树索引中的记录需要把该列的完整字符串存储起来,而且字符串越长,在索引中占用的存储空间越大。\n如果B+树索引中索引列存储的字符串很长,那在做字符串比较时会占用更多的时间。\n我们前面儿说过索引列的字符串前缀其实也是排好序的,所以索引的设计者提出了个方案 \u0026mdash; 只对字符串的前几个字符进行索引也就是说在二级索引的记录中只保留字符串前几个字符。这样在查找记录时虽然不能精确的定位到记录的位置,但是能定位到相应前缀所在的位置,然后根据前缀相同的记录的主键值回表查询完整的字符串值,再对比就好了。这样只在B+树中存储字符串的前几个字符的编码,既节约空间,又减少了字符串的比较时间,还大概能解决排序的问题,何乐而不为,比方说我们在建表语句中只对name列的前10个字符进行索引可以这么写: CREATE TABLE person_info( name VARCHAR(100) NOT NULL, birthday DATE NOT NULL, phone_number CHAR(11) NOT NULL, country varchar(100) NOT NULL, KEY idx_name_birthday_phone_number (name(10), birthday, phone_number) ); name(10)就表示在建立的B+树索引中只保留记录的前10个字符的编码,这种只索引字符串值的前缀的策略是我们非常鼓励的,尤其是在字符串类型能存储的字符比较多的时候。\n索引列前缀对排序的影响 # 如果使用了索引列前缀,比方说前面只把name列的前10个字符放到了二级索引中,下面这个查询可能就有点儿尴尬了: SELECT * FROM person_info ORDER BY name LIMIT 10; 因为二级索引中不包含完整的name列信息,所以无法对前十个字符相同,后边的字符不同的记录进行排序,也就是使用索引列前缀的方式无法支持使用索引排序,只好乖乖的用文件排序喽。\n让索引列在比较表达式中单独出现 # 假设表中有一个整数列my_col,我们为这个列建立了索引。下面的两个WHERE子句虽然语义是一致的,但是在效率上却有差别:\nWHERE my_col * 2 \u0026lt; 4\nWHERE my_col \u0026lt; 4/2\n第1个WHERE子句中my_col列并不是以单独列的形式出现的,而是以my_col * 2这样的表达式的形式出现的,存储引擎会依次遍历所有的记录,计算这个表达式的值是不是小于4,所以这种情况下是使用不到为my_col列建立的B+树索引的。而第2个WHERE子句中my_col列并是以单独列的形式出现的,这样的情况可以直接使用B+树索引。\n所以结论就是:如果索引列在比较表达式中不是以单独列的形式出现,而是以某个表达式,或者函数调用形式出现的话,是用不到索引的。\n主键插入顺序 # 我们知道,对于一个使用InnoDB存储引擎的表来说,在我们没有显式的创建索引时,表中的数据实际上都是存储在聚簇索引的叶子节点的。而记录又是存储在数据页中的,数据页和记录又是按照记录主键值从小到大的顺序进行排序,所以如果我们插入的记录的主键值是依次增大的话,那我们每插满一个数据页就换到下一个数据页继续插,而如果我们插入的主键值忽大忽小的话,这就比较麻烦了,假设某个数据页存储的记录已经满了,它存储的主键值在1~100之间:\n如果此时再插入一条主键值为9的记录,那它插入的位置就如下图:\n可这个数据页已经满了啊,再插进来咋办呢?我们需要把当前页面分裂成两个页面,把本页中的一些记录移动到新创建的这个页中。页面分裂和记录移位意味着什么?意味着:性能损耗!所以如果我们想尽量避免这样无谓的性能损耗,最好让插入的记录的主键值依次递增,这样就不会发生这样的性能损耗了。所以我们建议:**让主键具有AUTO_INCREMENT,让存储引擎自己为表生成主键,而不是我们手动插入 **,比方说我们可以这样定义person_info表: CREATE TABLE person_info( id INT UNSIGNED NOT NULL AUTO_INCREMENT, name VARCHAR(100) NOT NULL, birthday DATE NOT NULL, phone_number CHAR(11) NOT NULL, country varchar(100) NOT NULL, PRIMARY KEY (id), KEY idx_name_birthday_phone_number (name(10), birthday, phone_number) ); 我们自定义的主键列id拥有AUTO_INCREMENT属性,在插入记录时存储引擎会自动为我们填入自增的主键值。\n冗余和重复索引 # 有时候有的同学有意或者无意的就对同一个列创建了多个索引,比方说这样写建表语句: CREATE TABLE person_info( id INT UNSIGNED NOT NULL AUTO_INCREMENT, name VARCHAR(100) NOT NULL, birthday DATE NOT NULL, phone_number CHAR(11) NOT NULL, country varchar(100) NOT NULL, PRIMARY KEY (id), KEY idx_name_birthday_phone_number (name(10), birthday, phone_number), KEY idx_name (name(10)) ); 我们知道,通过idx_name_birthday_phone_number索引就可以对name列进行快速搜索,再创建一个专门针对name列的索引就算是一个冗余索引,维护这个索引只会增加维护的成本,并不会对搜索有什么好处。\n另一种情况,我们可能会对某个列重复建立索引,比方说这样: CREATE TABLE repeat_index_demo ( c1 INT PRIMARY KEY, c2 INT, UNIQUE uidx_c1 (c1), INDEX idx_c1 (c1) ); 我们看到,c1既是主键、又给它定义为一个唯一索引,还给它定义了一个普通索引,可是主键本身就会生成聚簇索引,所以定义的唯一索引和普通索引是重复的,这种情况要避免。\n总结 # 上面只是我们在创建和使用B+树索引的过程中需要注意的一些点,后边我们还会陆续介绍更多的优化方法和注意事项,敬请期待。本集内容总结如下:\nB+树索引在空间和时间上都有代价,所以没事儿别瞎建索引。\nB+树索引适用于下面这些情况:\n+ 全值匹配 + 匹配左边的列 + 匹配范围值 + 精确匹配某一列并范围匹配另外一列 + 用于排序 + 用于分组 在使用索引时需要注意下面这些事项:\n+ 只为用于搜索、排序或分组的列创建索引 + 为列的基数大的列创建索引 + 索引列的类型尽量小 + 可以只对字符串值的前缀建立索引 + 只有索引列在比较表达式中单独出现才可以适用索引 + 为了尽可能少的让`聚簇索引`发生页面分裂和记录移位的情况,建议让主键拥有`AUTO_INCREMENT`属性。 + 定位并删除表中的重复和冗余索引 + 尽量使用`覆盖索引`进行查询,避免`回表`带来的性能损耗。 "},{"id":26,"href":"/zh/docs/technology/MySQL/MySQL%E6%98%AF%E6%80%8E%E6%A0%B7%E8%BF%90%E8%A1%8C%E7%9A%84/%E7%AC%AC6%E7%AB%A0_%E5%BF%AB%E9%80%9F%E6%9F%A5%E8%AF%A2%E7%9A%84%E7%A7%98%E7%B1%8D-B+%E6%A0%91%E7%B4%A2%E5%BC%95/","title":"第6章_快速查询的秘籍-B+树索引","section":"My Sql是怎样运行的","content":"第6章 快速查询的秘籍-B+树索引\n前面我们详细介绍了InnoDB数据页的7个组成部分,知道了各个数据页可以组成一个双向链表,而每个数据页中的记录会按照主键值从小到大的顺序组成一个单向链表,每个数据页都会为存储在它里边儿的记录生成一个页目录,在通过主键查找某条记录的时候可以在页目录中使用二分法快速定位到对应的槽,然后再遍历该槽对应分组中的记录即可快速找到指定的记录(如果你对这段话有一丁点儿疑惑,那么接下来的部分不适合你,返回去看一下数据页结构吧)。页和记录的关系示意图如下:\n其中页a、页b、页c \u0026hellip; 页n 这些页可以不在物理结构上相连,只要通过双向链表相关联即可。\n没有索引的查找 # 本集的主题是索引,在正式介绍索引之前,我们需要了解一下没有索引的时候是怎么查找记录的。为了方便大家理解,我们下面先只介绍搜索条件为对某个列精确匹配的情况,所谓精确匹配,就是搜索条件中用等于=连接起的表达式,比如这样: SELECT [列名列表] FROM 表名 WHERE 列名 = xxx;\n在一个页中的查找 # 假设目前表中的记录比较少,所有的记录都可以被存放到一个页中,在查找记录的时候可以根据搜索条件的不同分为两种情况:\n以主键为搜索条件\n这个查找过程我们已经很熟悉了,可以在页目录中使用二分法快速定位到对应的槽,然后再遍历该槽对应分组中的记录即可快速找到指定的记录。\n以其他列作为搜索条件\n对非主键列的查找的过程可就不这么幸运了,因为在数据页中并没有对非主键列建立所谓的页目录,所以我们无法通过二分法快速定位相应的槽。这种情况下只能从最小记录开始依次遍历单链表中的每条记录,然后对比每条记录是不是符合搜索条件。很显然,这种查找的效率是非常低的。\n在很多页中查找 # 大部分情况下我们表中存放的记录都是非常多的,需要好多的数据页来存储这些记录。在很多页中查找记录的话可以分为两个步骤:\n定位到记录所在的页。 从所在的页内中查找相应的记录。 在没有索引的情况下,不论是根据主键列或者其他列的值进行查找,由于我们并不能快速的定位到记录所在的页,所以只能从第一个页沿着双向链表一直往下找,在每一个页中根据我们刚刚介绍过的查找方式去查找指定的记录。因为要遍历所有的数据页,所以这种方式显然是超级耗时的,如果一个表有一亿条记录,使用这种方式去查找记录那要等到猴年马月才能等到查找结果。所以祖国和人民都在期盼一种能高效完成搜索的方法,索引同志就要亮相登台了。\n索引 # 为了故事的顺利发展,我们先建一个表: mysql\u0026gt; CREATE TABLE index_demo( -\u0026gt; c1 INT, -\u0026gt; c2 INT, -\u0026gt; c3 CHAR(1), -\u0026gt; PRIMARY KEY(c1) -\u0026gt; ) ROW_FORMAT = Compact; Query OK, 0 rows affected (0.03 sec) 这个新建的index_demo表中有2个INT类型的列,1个CHAR(1)类型的列,而且我们规定了c1列为主键,这个表使用Compact行格式来实际存储记录的。为了我们理解上的方便,我们简化了一下index_demo表的行格式示意图:\n我们只在示意图里展示记录的这几个部分:\nrecord_type:记录头信息的一项属性,表示记录的类型,0表示普通记录、2表示最小记录、3表示最大记录、1我们还没用过,等会再说~\nnext_record:记录头信息的一项属性,表示下一条地址相对于本条记录的地址偏移量,为了方便大家理解,我们都会用箭头来表明下一条记录是谁。\n各个列的值:这里只记录在index_demo表中的三个列,分别是c1、c2和c3。\n其他信息:除了上述3种信息以外的所有信息,包括其他隐藏列的值以及记录的额外信息。\n为了节省篇幅,我们之后的示意图中会把记录的其他信息这个部分省略掉,因为它占地方并且不会有什么观赏效果。另外,为了方便理解,我们觉得把记录竖着放看起来感觉更好,所以将记录格式示意图的其他信息去掉并把它竖起来的效果就是这样:\n把一些记录放到页里边的示意图就是:\n一个简单的索引方案 # 回到正题,我们在根据某个搜索条件查找一些记录时为什么要遍历所有的数据页呢?因为各个页中的记录并没有规律,我们并不知道我们的搜索条件匹配哪些页中的记录,所以 不得不 依次遍历所有的数据页。所以如果我们想快速的定位到需要查找的记录在哪些数据页中该咋办?还记得我们为根据主键值快速定位一条记录在页中的位置而设立的页目录么?我们也可以想办法为快速定位记录所在的数据页而建立一个别的目录,建这个目录必须完成下面这些事儿:\n下一个数据页中用户记录的主键值必须大于上一个页中用户记录的主键值。\n为了故事的顺利发展,我们这里需要做一个假设:假设我们的每个数据页最多能存放3条记录(实际上一个数据页非常大,可以存放下好多记录)。有了这个假设之后我们向index_demo表插入3条记录: mysql\u0026gt; INSERT INTO index_demo VALUES(1, 4, 'u'), (3, 9, 'd'), (5, 3, 'y'); Query OK, 3 rows affected (0.01 sec) Records: 3 Duplicates: 0 Warnings: 0 那么这些记录已经按照主键值的大小串联成一个单向链表了,如图所示:\n从图中可以看出来,index_demo表中的3条记录都被插入到了编号为10的数据页中了。此时我们再来插入一条记录: mysql\u0026gt; INSERT INTO index_demo VALUES(4, 4, 'a'); Query OK, 1 row affected (0.00 sec) 因为页10最多只能放3条记录,所以我们不得不再分配一个新页:\n咦?怎么分配的页号是28呀,不应该是11么?再次强调一遍,新分配的数据页编号可能并不是连续的,也就是说我们使用的这些页在存储空间里可能并不挨着。它们只是通过维护着上一个页和下一个页的编号而建立了链表关系。另外,页10中用户记录最大的主键值是5,而页28中有一条记录的主键值是4,因为5 \u0026gt; 4,所以这就不符合下一个数据页中用户记录的主键值必须大于上一个页中用户记录的主键值的要求,所以在插入主键值为4的记录的时候需要伴随着一次记录移动,也就是把主键值为5的记录移动到页28中,然后再把主键值为4的记录插入到页10中,这个过程的示意图如下:\n这个过程表明了在对页中的记录进行增删改操作的过程中,我们必须通过一些诸如记录移动的操作来始终保证这个状态一直成立:下一个数据页中用户记录的主键值必须大于上一个页中用户记录的主键值。这个过程我们也可以称为页分裂。\n给所有的页建立一个目录项。\n由于数据页的编号可能并不是连续的,所以在向index_demo表中插入许多条记录后,可能是这样的效果:\n因为这些16KB的页在物理存储上可能并不挨着,所以如果想从这么多页中根据主键值快速定位某些记录所在的页,我们需要给它们做个目录,每个页对应一个目录项,每个目录项包括下面两个部分:\n+ 页的用户记录中最小的主键值,我们用`key`来表示。 + 页号,我们用`page_no`表示。 所以我们为上面几个页做好的目录就像这样子:\n以页28为例,它对应目录项2,这个目录项中包含着该页的页号28以及该页中用户记录的最小主键值5。我们只需要把几个目录项在物理存储器上连续存储,比如把他们放到一个数组里,就可以实现根据主键值快速查找某条记录的功能了。比方说我们想找主键值为20的记录,具体查找过程分两步:\n1.先从目录项中根据二分法快速确定出主键值为20的记录在目录项3中(因为 12 \u0026lt; 20 \u0026lt; 209),它对应的页是页9。\n2.再根据前面说的在页中查找记录的方式去页9中定位具体的记录。\n至此,针对数据页做的简易目录就搞定了。不过忘了说了,这个目录有一个别名,称为索引。\nInnoDB中的索引方案 # 上面之所以称为一个简易的索引方案,是因为我们为了在根据主键值进行查找时使用二分法快速定位具体的目录项而假设所有目录项都可以在物理存储器上连续存储,但是这样做有几个问题:\nInnoDB是使用页来作为管理存储空间的基本单位,也就是最多能保证16KB的连续存储空间,而随着表中记录数量的增多,需要非常大的连续的存储空间才能把所有的目录项都放下,这对记录数量非常多的表是不现实的。\n我们时常会对记录进行增删,假设我们把页28中的记录都删除了,页28也就没有存在的必要了,那意味着目录项2也就没有存在的必要了,这就需要把目录项2后的目录项都向前移动一下,这种牵一发而动全身的设计不是什么好主意~\n所以,设计InnoDB的大佬们需要一种可以灵活管理所有目录项的方式。他们灵光乍现,忽然发现这些目录项其实长得跟我们的用户记录差不多,只不过目录项中的两个列是主键和页号而已,所以他们复用了之前存储用户记录的数据页来存储目录项,为了和用户记录做一下区分,我们把这些用来表示目录项的记录称为目录项记录。那InnoDB怎么区分一条记录是普通的用户记录还是目录项记录呢?别忘了记录头信息里的record_type属性,它的各个取值代表的意思如下:\n0:普通的用户记录 1:目录项记录 2:最小记录 3:最大记录 原来这个值为1的record_type是这个意思呀,我们把前面使用到的目录项放到数据页中的样子就是这样:\n从图中可以看出来,我们新分配了一个编号为30的页来专门存储目录项记录。这里再次强调一遍目录项记录和普通的用户记录的不同点:\n目录项记录的record_type值是1,而普通用户记录的record_type值是0。\n目录项记录只有主键值和页的编号两个列,而普通的用户记录的列是用户自己定义的,可能包含很多列,另外还有InnoDB自己添加的隐藏列。\n还记得我们之前在介绍记录头信息的时候说过一个叫min_rec_mask的属性么,只有在存储目录项记录的页中的主键值最小的目录项记录的min_rec_mask值为1,其他别的记录的min_rec_mask值都是0。\n除了上述几点外,这两者就没什么差别了,它们用的是一样的数据页(页面类型都是0x45BF,这个属性在File Header中,忘了的话可以翻到前面的文章看),页的组成结构也是一样一样的(就是我们前面介绍过的7个部分),都会为主键值生成Page Directory(页目录),从而在按照主键值进行查找时可以使用二分法来加快查询速度。现在以查找主键为20的记录为例,根据某个主键值去查找记录的步骤就可以大致拆分成下面两步:\n先到存储目录项记录的页,也就是页30中通过二分法快速定位到对应目录项,因为12 \u0026lt; 20 \u0026lt; 209,所以定位到对应的记录所在的页就是页9。\n再到存储用户记录的页9中根据二分法快速定位到主键值为20的用户记录。\n虽然说目录项记录中只存储主键值和对应的页号,比用户记录需要的存储空间小多了,但是不论怎么说一个页只有16KB大小,能存放的目录项记录也是有限的,那如果表中的数据太多,以至于一个数据页不足以存放所有的目录项记录,该咋办呢?\n当然是再多整一个存储目录项记录的页喽~ 为了大家更好的理解新分配一个目录项记录页的过程,我们假设一个存储目录项记录的页最多只能存放4条目录项记录(请注意是假设哦,真实情况下可以存放好多条的),所以如果此时我们再向上图中插入一条主键值为320的用户记录的话,那就需要分配一个新的存储目录项记录的页喽:\n从图中可以看出,我们插入了一条主键值为320的用户记录之后需要两个新的数据页:\n为存储该用户记录而新生成了页31。\n因为原先存储目录项记录的页30的容量已满(我们前面假设只能存储4条目录项记录),所以不得不需要一个新的页32来存放页31对应的目录项。\n现在因为存储目录项记录的页不止一个,所以如果我们想根据主键值查找一条用户记录大致需要3个步骤,以查找主键值为20的记录为例:\n确定目录项记录页\n我们现在的存储目录项记录的页有两个,即页30和页32,又因为页30表示的目录项的主键值的范围是[1, 320),页32表示的目录项的主键值不小于320,所以主键值为20的记录对应的目录项记录在页30中。\n通过目录项记录页确定用户记录真实所在的页。\n在一个存储目录项记录的页中通过主键值定位一条目录项记录的方式说过了,不赘述了~\n在真实存储用户记录的页中定位到具体的记录。\n在一个存储用户记录的页中通过主键值定位一条用户记录的方式已经说过200遍了,你再不会我就,我就,我就求你到上一篇介绍数据页结构的文章中多看几遍,求你了~\n那么问题来了,在这个查询步骤的第1步中我们需要定位存储目录项记录的页,但是这些页在存储空间中也可能不挨着,如果我们表中的数据非常多则会产生很多存储目录项记录的页,那我们怎么根据主键值快速定位一个存储目录项记录的页呢?其实也简单,为这些存储目录项记录的页再生成一个更高级的目录,就像是一个多级目录一样,大目录里嵌套小目录,小目录里才是实际的数据,所以现在各个页的示意图就是这样子:\n如图,我们生成了一个存储更高级目录项的页33,这个页中的两条记录分别代表页30和页32,如果用户记录的主键值在[1, 320)之间,则到页30中查找更详细的目录项记录,如果主键值不小于320的话,就到页32中查找更详细的目录项记录。不过这张图好漂亮喔,随着表中记录的增加,这个目录的层级会继续增加,如果简化一下,那么我们可以用下面这个图来描述它:\n这玩意儿像不像一个倒过来的树呀,上头是树根,下头是树叶!其实这是一种组织数据的形式,或者说是一种数据结构,它的名称是B+树。 小贴士:为什么叫B+呢,B树是什么?喔对不起,这不是我们讨论的范围,你可以去找一本数据结构或算法的书来看。什么?数据结构的书看不懂?等我~ 不论是存放用户记录的数据页,还是存放目录项记录的数据页,我们都把它们存放到B+树这个数据结构中了,所以我们也称这些数据页为节点。从图中可以看出来,我们的实际用户记录其实都存放在B+树的最底层的节点上,这些节点也被称为叶子节点或叶节点,其余用来存放目录项的节点称为非叶子节点或者内节点,其中B+树最上面的那个节点也称为根节点。\n从图中可以看出来,一个B+树的节点其实可以分成好多层,设计InnoDB的大佬们为了讨论方便,规定最下面的那层,也就是存放我们用户记录的那层为第0层,之后依次往上加。之前的讨论我们做了一个非常极端的假设:存放用户记录的页最多存放3条记录,存放目录项记录的页最多存放4条记录。其实真实环境中一个页存放的记录数量是非常大的,假设,假设,假设所有存放用户记录的叶子节点代表的数据页可以存放100条用户记录,所有存放目录项记录的内节点代表的数据页可以存放1000条目录项记录,那么:\n如果B+树只有1层,也就是只有1个用于存放用户记录的节点,最多能存放100条记录。\n如果B+树有2层,最多能存放1000×100=100000条记录。\n如果B+树有3层,最多能存放1000×1000×100=100000000条记录。\n如果B+树有4层,最多能存放1000×1000×1000×100=100000000000条记录。哇咔咔~这么多的记录!!!\n你的表里能存放100000000000条记录么?所以一般情况下,我们用到的B+树都不会超过4层,那我们通过主键值去查找某条记录最多只需要做4个页面内的查找(查找3个目录项页和一个用户记录页),又因为在每个页面内有所谓的Page Directory(页目录),所以在页面内也可以通过二分法实现快速定位记录,这不是很牛么!\n聚簇索引 # 我们上面介绍的B+树本身就是一个目录,或者说本身就是一个索引。它有两个特点:\n使用记录主键值的大小进行记录和页的排序,这包括三个方面的含义:\n+ 页内的记录是按照主键的大小顺序排成一个单向链表。\n+ 各个存放用户记录的页也是根据页中用户记录的主键大小顺序排成一个双向链表。\n+ 存放目录项记录的页分为不同的层次,在同一层次中的页也是根据页中目录项记录的主键大小顺序排成一个双向链表。\nB+树的叶子节点存储的是完整的用户记录。\n所谓完整的用户记录,就是指这个记录中存储了所有列的值(包括隐藏列)。\n我们把具有这两种特性的B+树称为聚簇索引,所有完整的用户记录都存放在这个聚簇索引的叶子节点处。这种聚簇索引并不需要我们在MySQL语句中显式的使用INDEX语句去创建(后边会介绍索引相关的语句),InnoDB存储引擎会自动的为我们创建聚簇索引。另外有趣的一点是,在InnoDB存储引擎中,聚簇索引就是数据的存储方式(所有的用户记录都存储在了叶子节点),也就是所谓的索引即数据,数据即索引。\n二级索引 # 大家有木有发现,上面介绍的聚簇索引只能在搜索条件是主键值时才能发挥作用,因为B+树中的数据都是按照主键进行排序的。那如果我们想以别的列作为搜索条件该咋办呢?难道只能从头到尾沿着链表依次遍历记录么?\n不,我们可以多建几棵B+树,不同的B+树中的数据采用不同的排序规则。比方说我们用c2列的大小作为数据页、页中记录的排序规则,再建一棵B+树,效果如下图所示:\n这个B+树与上面介绍的聚簇索引有几处不同:\n使用记录c2列的大小进行记录和页的排序,这包括三个方面的含义:\n+ 页内的记录是按照c2列的大小顺序排成一个单向链表。\n+ 各个存放用户记录的页也是根据页中记录的c2列大小顺序排成一个双向链表。\n+ 存放目录项记录的页分为不同的层次,在同一层次中的页也是根据页中目录项记录的c2列大小顺序排成一个双向链表。\nB+树的叶子节点存储的并不是完整的用户记录,而只是c2列+主键这两个列的值。\n目录项记录中不再是主键+页号的搭配,而变成了c2列+页号的搭配。\n所以如果我们现在想通过c2列的值查找某些记录的话就可以使用我们刚刚建好的这个B+树了。以查找c2列的值为4的记录为例,查找过程如下:\n确定目录项记录页\n根据根页面,也就是页44,可以快速定位到目录项记录所在的页为页42(因为2 \u0026lt; 4 \u0026lt; 9)。\n通过目录项记录页确定用户记录真实所在的页。\n在页42中可以快速定位到实际存储用户记录的页,但是由于c2列并没有唯一性约束,所以c2列值为4的记录可能分布在多个数据页中,又因为2 \u0026lt; 4 ≤ 4,所以确定实际存储用户记录的页在页34和页35中。\n在真实存储用户记录的页中定位到具体的记录。\n到页34和页35中定位到具体的记录。\n但是这个B+树的叶子节点中的记录只存储了c2和c1(也就是主键)两个列,所以我们必须再根据主键值去聚簇索引中再查找一遍完整的用户记录。\n各位各位,看到步骤4的操作了么?我们根据这个以c2列大小排序的B+树只能确定我们要查找记录的主键值,所以如果我们想根据c2列的值查找到完整的用户记录的话,仍然需要到聚簇索引中再查一遍,这个过程也被称为回表。也就是根据c2列的值查询一条完整的用户记录需要使用到2棵B+树!!!\n为什么我们还需要一次回表操作呢?直接把完整的用户记录放到叶子节点不就好了么?你说的对,如果把完整的用户记录放到叶子节点是可以不用回表,但是太占地方了呀~相当于每建立一棵B+树都需要把所有的用户记录再都拷贝一遍,这就有点太浪费存储空间了。因为这种按照非主键列建立的B+树需要一次回表操作才可以定位到完整的用户记录,所以这种B+树也被称为二级索引(英文名secondary index),或者辅助索引。由于我们使用的是c2列的大小作为B+树的排序规则,所以我们也称这个B+树为为c2列建立的索引。\n联合索引 # 我们也可以同时以多个列的大小作为排序规则,也就是同时为多个列建立索引,比方说我们想让B+树按照c2和c3列的大小进行排序,这个包含两层含义:\n先把各个记录和页按照c2列进行排序。 在记录的c2列相同的情况下,采用c3列进行排序 为c2和c3列建立的索引的示意图如下:\n如图所示,我们需要注意一下几点:\n每条目录项记录都由c2、c3、页号这三个部分组成,各条记录先按照c2列的值进行排序,如果记录的c2列相同,则按照c3列的值进行排序。\nB+树叶子节点处的用户记录由c2、c3和主键c1列组成。\n千万要注意一点,以c2和c3列的大小为排序规则建立的B+树称为联合索引,本质上也是一个二级索引。它的意思与分别为c2和c3列分别建立索引的表述是不同的,不同点如下:\n建立联合索引只会建立如上图一样的1棵B+树。\n为c2和c3列分别建立索引会分别以c2和c3列的大小为排序规则建立2棵B+树。\nInnoDB的B+树索引的注意事项 # 根页面万年不动窝 # 我们前面介绍B+树索引的时候,为了大家理解上的方便,先把存储用户记录的叶子节点都画出来,然后接着画存储目录项记录的内节点,实际上B+树的形成过程是这样的:\n每当为某个表创建一个B+树索引(聚簇索引不是人为创建的,默认就有)的时候,都会为这个索引创建一个根节点页面。最开始表中没有数据的时候,每个B+树索引对应的根节点中既没有用户记录,也没有目录项记录。\n随后向表中插入用户记录时,先把用户记录存储到这个根节点中。\n当根节点中的可用空间用完时继续插入记录,此时会将根节点中的所有记录复制到一个新分配的页,比如页a中,然后对这个新页进行页分裂的操作,得到另一个新页,比如页b。这时新插入的记录根据键值(也就是聚簇索引中的主键值,二级索引中对应的索引列的值)的大小就会被分配到页a或者页b中,而根节点便升级为存储目录项记录的页。\n这个过程需要大家特别注意的是:一个B+树索引的根节点自诞生之日起,便不会再移动。这样只要我们对某个表建立一个索引,那么它的根节点的页号便会被记录到某个地方,然后凡是InnoDB存储引擎需要用到这个索引的时候,都会从那个固定的地方取出根节点的页号,从而来访问这个索引。 小贴士:跟大家剧透一下,这个存储某个索引的根节点在哪个页面中的信息就是传说中的数据字典中的一项信息,关于更多数据字典的内容,后边会详细介绍,别着急。\n内节点中目录项记录的唯一性 # 我们知道B+树索引的内节点中目录项记录的内容是索引列 + 页号的搭配,但是这个搭配对于二级索引来说有点儿不严谨。还拿index_demo表为例,假设这个表中的数据是这样的: c1 c2 c3 1 1 \u0026lsquo;u\u0026rsquo; 3 1 \u0026rsquo;d\u0026rsquo; 5 1 \u0026lsquo;y\u0026rsquo; 7 1 \u0026lsquo;a\u0026rsquo; 如果二级索引中目录项记录的内容只是索引列 + 页号的搭配的话,那么为c2列建立索引后的B+树应该长这样:\n如果我们想新插入一行记录,其中c1、c2、c3的值分别是:9、1、'c',那么在修改这个为c2列建立的二级索引对应的B+树时便碰到了个大问题:由于页3中存储的目录项记录是由c2列 + 页号的值构成的,页3中的两条目录项记录对应的c2列的值都是1,而我们新插入的这条记录的c2列的值也是1,那我们这条新插入的记录到底应该放到页4中,还是应该放到页5中啊?答案是:对不起,懵逼了。\n为了让新插入记录能找到自己在那个页里,我们需要保证在B+树的同一层内节点的目录项记录除页号这个字段以外是唯一的。所以对于二级索引的内节点的目录项记录的内容实际上是由三个部分构成的:\n索引列的值 主键值 页号 也就是我们把主键值也添加到二级索引内节点中的目录项记录了,这样就能保证B+树每一层节点中各条目录项记录除页号这个字段外是唯一的,所以我们为c2列建立二级索引后的示意图实际上应该是这样子的:\n这样我们再插入记录(9, 1, 'c')时,由于页3中存储的目录项记录是由c2列 + 主键 + 页号的值构成的,可以先把新记录的c2列的值和页3中各目录项记录的c2列的值作比较,如果c2列的值相同的话,可以接着比较主键值,因为B+树同一层中不同目录项记录的c2列 + 主键的值肯定是不一样的,所以最后肯定能定位唯一的一条目录项记录,在本例中最后确定新记录应该被插入到页5中。\n一个页面最少存储2条记录 # 我们前面说过一个B+树只需要很少的层级就可以轻松存储数亿条记录,查询速度杠杠的!这是因为B+树本质上就是一个大的多层级目录,每经过一个目录时都会过滤掉许多无效的子目录,直到最后访问到存储真实数据的目录。那如果一个大的目录中只存放一个子目录是什么效果呢?那就是目录层级非常非常非常多,而且最后的那个存放真实数据的目录中只能存放一条记录。费了半天劲只能存放一条真实的用户记录?逗我呢?所以InnoDB的一个数据页至少可以存放两条记录,这也是我们之前介绍记录行格式的时候说过一个结论(我们当时依据这个结论推导了表中只有一个列时该列在不发生行溢出的情况下最多能存储多少字节,忘了的话回去看看吧)。\nMyISAM中的索引方案简单介绍 # 至此,我们介绍的都是InnoDB存储引擎中的索引方案,为了内容的完整性,以及各位可能在面试的时候遇到这类的问题,我们有必要再简单介绍一下MyISAM存储引擎中的索引方案。我们知道InnoDB中索引即数据,也就是聚簇索引的那棵B+树的叶子节点中已经把所有完整的用户记录都包含了,而MyISAM的索引方案虽然也使用树形结构,但是却将索引和数据分开存储:\n将表中的记录按照记录的插入顺序单独存储在一个文件中,称之为数据文件。这个文件并不划分为若干个数据页,有多少记录就往这个文件中塞多少记录就成了。我们可以通过行号而快速访问到一条记录。\nMyISAM记录也需要记录头信息来存储一些额外数据,我们以上面介绍过的index_demo表为例,看一下这个表中的记录使用MyISAM作为存储引擎在存储空间中的表示:\n由于在插入数据的时候并没有刻意按照主键大小排序,所以我们并不能在这些数据上使用二分法进行查找。\n使用MyISAM存储引擎的表会把索引信息另外存储到一个称为索引文件的另一个文件中。MyISAM会单独为表的主键创建一个索引,只不过在索引的叶子节点中存储的不是完整的用户记录,而是主键值 + 行号的组合。也就是先通过索引找到对应的行号,再通过行号去找对应的记录!\n这一点和InnoDB是完全不相同的,在InnoDB存储引擎中,我们只需要根据主键值对聚簇索引进行一次查找就能找到对应的记录,而在MyISAM中却需要进行一次回表操作,意味着**MyISAM中建立的索引相当于全部都是二级索引**!\n如果有需要的话,我们也可以对其它的列分别建立索引或者建立联合索引,原理和InnoDB中的索引差不多,不过在叶子节点处存储的是相应的列 + 行号。这些索引也全部都是二级索引。 小贴士:MyISAM的行格式有定长记录格式(Static)、变长记录格式(Dynamic)、压缩记录格式(Compressed)。上面用到的index_demo表采用定长记录格式,也就是一条记录占用存储空间的大小是固定的,这样就可以轻松算出某条记录在数据文件中的地址偏移量。但是变长记录格式就不行了,MyISAM会直接在索引叶子节点处存储该条记录在数据文件中的地址偏移量。通过这个可以看出,MyISAM的回表操作是十分快速的,因为是拿着地址偏移量直接到文件中取数据的,反观InnoDB是通过获取主键之后再去聚簇索引里边儿找记录,虽然说也不慢,但还是比不上直接用地址去访问。 此处我们只是非常简要的介绍了一下MyISAM的索引,具体细节全拿出来又可以写一篇文章了。这里只是希望大家理解InnoDB中的索引即数据,数据即索引,而MyISAM中却是索引是索引、数据是数据。\nMySQL中创建和删除索引的语句 # 光顾着介绍索引的原理了,那我们如何使用MySQL语句去建立这种索引呢?InnoDB和MyISAM会自动为主键或者声明为UNIQUE的列去自动建立B+树索引,但是如果我们想为其他的列建立索引就需要我们显式的去指明。为什么不自动为每个列都建立个索引呢?别忘了,每建立一个索引都会建立一棵B+树,每插入一条记录都要维护各个记录、数据页的排序关系,这是很费性能和存储空间的。\n我们可以在创建表的时候指定需要建立索引的单个列或者建立联合索引的多个列: CREATE TALBE 表名 ( 各种列的信息 ··· , [KEY|INDEX] 索引名 (需要被索引的单个列或多个列) ) 其中的KEY和INDEX是同义词,任意选用一个就可以。我们也可以在修改表结构的时候添加索引: ALTER TABLE 表名 ADD [INDEX|KEY] 索引名 (需要被索引的单个列或多个列); 也可以在修改表结构的时候删除索引: ALTER TABLE 表名 DROP [INDEX|KEY] 索引名; 比方说我们想在创建index_demo表的时候就为c2和c3列添加一个联合索引,可以这么写建表语句: CREATE TABLE index_demo( c1 INT, c2 INT, c3 CHAR(1), PRIMARY KEY(c1), INDEX idx_c2_c3 (c2, c3) ); 在这个建表语句中我们创建的索引名是idx_c2_c3,这个名称可以随便起,不过我们还是建议以idx_为前缀,后边跟着需要建立索引的列名,多个列名之间用下划线_分隔开。\n如果我们想删除这个索引,可以这么写: ALTER TABLE index_demo DROP INDEX idx_c2_c3;\n"},{"id":27,"href":"/zh/docs/technology/MySQL/MySQL%E6%98%AF%E6%80%8E%E6%A0%B7%E8%BF%90%E8%A1%8C%E7%9A%84/%E7%AC%AC5%E7%AB%A0-%E7%9B%9B%E6%94%BE%E8%AE%B0%E5%BD%95%E7%9A%84%E5%A4%A7%E7%9B%92%E5%AD%90-InnoDB%E6%95%B0%E6%8D%AE%E9%A1%B5%E7%BB%93%E6%9E%84/","title":"第5章 盛放记录的大盒子-InnoDB数据页结构","section":"My Sql是怎样运行的","content":"第5章 盛放记录的大盒子-InnoDB数据页结构\n不同类型的页简介 # 前面我们简单提了一下页的概念,它是InnoDB管理存储空间的基本单位,一个页的大小一般是16KB。InnoDB为了不同的目的而设计了许多种不同类型的页,比如存放表空间头部信息的页,存放Insert Buffer信息的页,存放INODE信息的页,存放undo日志信息的页等等等等。当然了,如果我说的这些名词你一个都没有听过,就当我放了个屁吧~ 不过这没有一毛钱关系,我们今儿个也不准备说这些类型的页,我们聚焦的是那些存放我们表中记录的那种类型的页,官方称这种存放记录的页为索引(INDEX)页,鉴于我们还没有了解过索引是个什么东西,而这些表中的记录就是我们日常口中所称的数据,所以目前还是叫这种存放记录的页为数据页吧。\n数据页结构的快速浏览 # 数据页代表的这块16KB大小的存储空间可以被划分为多个部分,不同部分有不同的功能,各个部分如图所示:\n从图中可以看出,一个InnoDB数据页的存储空间大致被划分成了7个部分,有的部分占用的字节数是确定的,有的部分占用的字节数是不确定的。下面我们用表格的方式来大致描述一下这7个部分都存储一些什么内容(快速的瞅一眼就行了,后边会详细介绍的): 名称 中文名 占用空间大小 简单描述 File Header 文件头部 38字节 页的一些通用信息 Page Header 页面头部 56字节 数据页专有的一些信息 Infimum + Supremum 最小记录和最大记录 26字节 两个虚拟的行记录 User Records 用户记录 不确定 实际存储的行记录内容 Free Space 空闲空间 不确定 页中尚未使用的空间 Page Directory 页面目录 不确定 页中的某些记录的相对位置 File Trailer 文件尾部 8字节 校验页是否完整 小贴士:我们接下来并不打算按照页中各个部分的出现顺序来依次介绍它们,因为各个部分中会出现很多大家目前不理解的概念,这会打击各位读文章的信心与兴趣,希望各位能接受这种拍摄手法~\n记录在页中的存储 # 在页的7个组成部分中,我们自己存储的记录会按照我们指定的行格式存储到User Records部分。但是在一开始生成页的时候,其实并没有User Records这个部分,每当我们插入一条记录,都会从Free Space部分,也就是尚未使用的存储空间中申请一个记录大小的空间划分到User Records部分,当Free Space部分的空间全部被User Records部分替代掉之后,也就意味着这个页使用完了,如果还有新的记录插入的话,就需要去申请新的页了,这个过程的图示如下:\n为了更好的管理在User Records中的这些记录,InnoDB可费了一番力气呢,在哪费力气了呢?不就是把记录按照指定的行格式一条一条摆在User Records部分么?其实这话还得从记录行格式的记录头信息中说起。\n记录头信息的秘密 # 为了故事的顺利发展,我们先创建一个表: mysql\u0026gt; CREATE TABLE page_demo( -\u0026gt; c1 INT, -\u0026gt; c2 INT, -\u0026gt; c3 VARCHAR(10000), -\u0026gt; PRIMARY KEY (c1) -\u0026gt; ) CHARSET=ascii ROW_FORMAT=Compact; Query OK, 0 rows affected (0.03 sec) 这个新创建的page_demo表有3个列,其中c1和c2列是用来存储整数的,c3列是用来存储字符串的。需要注意的是,我们把 c1 列指定为主键,所以在具体的行格式中InnoDB就没必要为我们去创建那个所谓的 row_id 隐藏列了。而且我们为这个表指定了ascii字符集以及Compact的行格式。所以这个表中记录的行格式示意图就是这样的:\n从图中可以看到,我们特意把记录头信息的5个字节的数据给标出来了,说明它很重要,我们再次先把这些记录头信息中各个属性的大体意思浏览一下(我们目前使用Compact行格式进行演示): 名称 大小(单位:bit) 描述 预留位1 1 没有使用 预留位2 1 没有使用 delete_mask 1 标记该记录是否被删除 min_rec_mask 1 B+树的每层非叶子节点中的最小记录都会添加该标记 n_owned 4 表示当前记录拥有的记录数 heap_no 13 表示当前记录在记录堆的位置信息 record_type 3 表示当前记录的类型,0表示普通记录,1表示B+树非叶节点记录,2表示最小记录,3表示最大记录 next_record 16 表示下一条记录的相对位置 由于我们现在主要在介绍记录头信息的作用,所以为了大家理解上的方便,我们只在page_demo表的行格式演示图中画出有关的头信息属性以及c1、c2、c3列的信息(其他信息没画不代表它们不存在啊,只是为了理解上的方便在图中省略了~),简化后的行格式示意图就是这样:\n下面我们试着向page_demo表中插入几条记录: mysql\u0026gt; INSERT INTO page_demo VALUES(1, 100, 'aaaa'), (2, 200, 'bbbb'), (3, 300, 'cccc'), (4, 400, 'dddd'); Query OK, 4 rows affected (0.00 sec) Records: 4 Duplicates: 0 Warnings: 0 为了方便大家分析这些记录在页的User Records部分中是怎么表示的,我把记录中头信息和实际的列数据都用十进制表示出来了(其实是一堆二进制位),所以这些记录的示意图就是:\n看这个图的时候需要注意一下,各条记录在User Records中存储的时候并没有空隙,这里只是为了大家观看方便才把每条记录单独画在一行中。我们对照着这个图来看看记录头信息中的各个属性是什么意思:\ndelete_mask\n这个属性标记着当前记录是否被删除,占用1个二进制位,值为0的时候代表记录并没有被删除,为1的时候代表记录被删除掉了。\n什么?被删除的记录还在页中么?是的,摆在台面上的和背地里做的可能大相径庭,你以为它删除了,可它还在真实的磁盘上[摊手](忽然想起冠希~)。这些被删除的记录之所以不立即从磁盘上移除,是因为移除它们之后把其他的记录在磁盘上重新排列需要性能消耗,所以只是打一个删除标记而已,所有被删除掉的记录都会组成一个所谓的垃圾链表,在这个链表中的记录占用的空间称之为所谓的可重用空间,之后如果有新记录插入到表中的话,可能把这些被删除的记录占用的存储空间覆盖掉。\n小贴士:将这个delete_mask位设置为1和将被删除的记录加入到垃圾链表中其实是两个阶段,我们后边在介绍事务的时候会详细介绍删除操作的详细过程,稍安勿躁。\nmin_rec_mask\nB+树的每层非叶子节点中的最小记录都会添加该标记,什么是个B+树?什么是个非叶子节点?好吧,等会再聊这个问题。反正我们自己插入的四条记录的min_rec_mask值都是0,意味着它们都不是B+树的非叶子节点中的最小记录。\nn_owned\n这个暂时保密,稍后它是主角~\nheap_no\n这个属性表示当前记录在本页中的位置,从图中可以看出来,我们插入的4条记录在本页中的位置分别是:2、3、4、5。是不是少了点什么?是的,怎么不见heap_no值为0和1的记录呢?\n这其实是设计InnoDB的大佬们玩的一个小把戏,他们自动给每个页里边儿加了两个记录,由于这两个记录并不是我们自己插入的,所以有时候也称为伪记录或者虚拟记录。这两个伪记录一个代表最小记录,一个代表最大记录,等一下~,记录可以比大小么?\n是的,记录也可以比大小,对于一条完整的记录来说,比较记录的大小就是比较主键的大小。比方说我们插入的4行记录的主键值分别是:1、2、3、4,这也就意味着这4条记录的大小从小到大依次递增。\n小贴士:请注意我强调了对于一条完整的记录来说,比较记录的大小就相当于比的是主键的大小。后边我们还会介绍只存储一条记录的部分列的情况,敬请期待~\n但是不管我们向页中插入了多少自己的记录,设计InnoDB的大佬们都规定他们定义的两条伪记录分别为最小记录与最大记录。这两条记录的构造十分简单,都是由5字节大小的记录头信息和8字节大小的一个固定的部分组成的,如图所示\n由于这两条记录不是我们自己定义的记录,所以它们并不存放在页的User Records部分,他们被单独放在一个称为Infimum + Supremum的部分,如图所示:\n从图中我们可以看出来,最小记录和最大记录的heap_no值分别是0和1,也就是说它们的位置最靠前。\nrecord_type\n这个属性表示当前记录的类型,一共有4种类型的记录,0表示普通记录,1表示B+树非叶节点记录,2表示最小记录,3表示最大记录。从图中我们也可以看出来,我们自己插入的记录就是普通记录,它们的record_type值都是0,而最小记录和最大记录的record_type值分别为2和3。\n至于record_type为1的情况,我们之后在说索引的时候会重点强调的。\nnext_record\n这玩意儿非常重要,它表示从当前记录的真实数据到下一条记录的真实数据的地址偏移量。比方说第一条记录的next_record值为32,意味着从第一条记录的真实数据的地址处向后找32个字节便是下一条记录的真实数据。如果你熟悉数据结构的话,就立即明白了,这其实是个链表,可以通过一条记录找到它的下一条记录。但是需要注意注意再注意的一点是,下一条记录指得并不是按照我们插入顺序的下一条记录,而是按照主键值由小到大的顺序的下一条记录。而且规定** Infimum记录(也就是最小记录) 的下一条记录就是本页中主键值最小的用户记录,而本页中主键值最大的用户记录的下一条记录就是 Supremum记录(也就是最大记录) **,为了更形象的表示一下这个next_record起到的作用,我们用箭头来替代一下next_record中的地址偏移量:\n从图中可以看出来,我们的记录按照主键从小到大的顺序形成了一个单链表。最大记录的next_record的值为0,这也就是说最大记录是没有下一条记录了,它是这个单链表中的最后一个节点。如果从中删除掉一条记录,这个链表也是会跟着变化的,比如我们把第2条记录删掉: mysql\u0026gt; DELETE FROM page_demo WHERE c1 = 2; Query OK, 1 row affected (0.02 sec) 删掉第2条记录后的示意图就是:\n从图中可以看出来,删除第2条记录前后主要发生了这些变化:\n+ 第2条记录并没有从存储空间中移除,而是把该条记录的`delete_mask`值设置为`1`。 + 第2条记录的`next_record`值变为了0,意味着该记录没有下一条记录了。 + 第1条记录的`next_record`指向了第3条记录。 + 还有一点你可能忽略了,就是`最大记录`的`n_owned`值从`5`变成了`4`,关于这一点的变化我们稍后会详细说明的。 所以,不论我们怎么对页中的记录做增删改操作,InnoDB始终会维护一条记录的单链表,链表中的各个节点是按照主键值由小到大的顺序连接起来的。\n小贴士:你会不会觉得next_record这个指针有点儿怪,为什么要指向记录头信息和真实数据之间的位置呢?为什么不干脆指向整条记录的开头位置,也就是记录的额外信息开头的位置呢?因为这个位置刚刚好,向左读取就是记录头信息,向右读取就是真实数据。我们前面还说过变长字段长度列表、NULL值列表中的信息都是逆序存放,这样可以使记录中位置靠前的字段和它们对应的字段长度信息在内存中的距离更近,可能会提高高速缓存的命中率。当然如果你看不懂这句话的话就不要勉强了,果断跳过~\n再来看一个有意思的事情,因为主键值为2的记录被我们删掉了,但是存储空间却没有回收,如果我们再次把这条记录插入到表中,会发生什么事呢? mysql\u0026gt; INSERT INTO page_demo VALUES(2, 200, 'bbbb'); Query OK, 1 row affected (0.00 sec) 我们看一下记录的存储情况:\n从图中可以看到,InnoDB并没有因为新记录的插入而为它申请新的存储空间,而是直接复用了原来被删除记录的存储空间。 小贴士:当数据页中存在多条被删除掉的记录时,这些记录的next_record属性将会把这些被删除掉的记录组成一个垃圾链表,以备之后重用这部分存储空间。\nPage Directory(页目录) # 现在我们了解了记录在页中按照主键值由小到大顺序串联成一个单链表,那如果我们想根据主键值查找页中的某条记录该咋办呢?比如说这样的查询语句: SELECT * FROM page_demo WHERE c1 = 3; 最笨的办法:从Infimum记录(最小记录)开始,沿着链表一直往后找,总有一天会找到(或者找不到[摊手]),在找的时候还能投机取巧,因为链表中各个记录的值是按照从小到大顺序排列的,所以当链表的某个节点代表的记录的主键值大于你想要查找的主键值时,你就可以停止查找了,因为该节点后边的节点的主键值依次递增。\n这个方法在页中存储的记录数量比较少的情况用起来也没什么问题,比方说现在我们的表里只有4条自己插入的记录,所以最多找4次就可以把所有记录都遍历一遍,但是如果一个页中存储了非常多的记录,这么查找对性能来说还是有损耗的,所以我们说这种遍历查找这是一个笨办法。但是设计InnoDB的大佬们是什么人,他们能用这么笨的办法么,当然是要设计一种更6的查找方式喽,他们从书的目录中找到了灵感。\n我们平常想从一本书中查找某个内容的时候,一般会先看目录,找到需要查找的内容对应的书的页码,然后到对应的页码查看内容。设计InnoDB的大佬们为我们的记录也制作了一个类似的目录,他们的制作过程是这样的:\n将所有正常的记录(包括最大和最小记录,不包括标记为已删除的记录)划分为几个组。\n每个组的最后一条记录(也就是组内最大的那条记录)的头信息中的n_owned属性表示该记录拥有多少条记录,也就是该组内共有几条记录。\n将每个组的最后一条记录的地址偏移量单独提取出来按顺序存储到靠近页的尾部的地方,这个地方就是所谓的Page Directory,也就是页目录(此时应该返回头看看页面各个部分的图)。页面目录中的这些地址偏移量被称为槽(英文名:Slot),所以这个页面目录就是由槽组成的。\n比方说现在的page_demo表中正常的记录共有6条,InnoDB会把它们分成两组,第一组中只有一个最小记录,第二组中是剩余的5条记录,看下面的示意图:\n从这个图中我们需要注意这么几点:\n现在页目录部分中有两个槽,也就意味着我们的记录被分成了两个组,槽1中的值是112,代表最大记录的地址偏移量(就是从页面的0字节开始数,数112个字节);槽0中的值是99,代表最小记录的地址偏移量。\n注意最小和最大记录的头信息中的n_owned属性\n+ 最小记录的`n_owned`值为`1`,这就代表着以最小记录结尾的这个分组中只有`1`条记录,也就是最小记录本身。 + 最大记录的`n_owned`值为`5`,这就代表着以最大记录结尾的这个分组中只有`5`条记录,包括最大记录本身还有我们自己插入的`4`条记录。 99和112这样的地址偏移量很不直观,我们用箭头指向的方式替代数字,这样更易于我们理解,所以修改后的示意图就是这样:\n哎呀,咋看上去怪怪的,这么乱的图对于我这个强迫症真是不能忍,那我们就暂时不管各条记录在存储设备上的排列方式了,单纯从逻辑上看一下这些记录和页目录的关系:\n这样看就顺眼多了嘛!为什么最小记录的n_owned值为1,而最大记录的n_owned值为5呢,这里头有什么猫腻么?\n是的,设计InnoDB的大佬们对每个分组中的记录条数是有规定的:对于最小记录所在的分组只能有 1 条记录,最大记录所在的分组拥有的记录条数只能在 1~8 条之间,剩下的分组中记录的条数范围只能在是 4~8 条之间。所以分组是按照下面的步骤进行的:\n初始情况下一个数据页里只有最小记录和最大记录两条记录,它们分属于两个分组。\n之后每插入一条记录,都会从页目录中找到主键值比本记录的主键值大并且差值最小的槽,然后把该槽对应的记录的n_owned值加1,表示本组内又添加了一条记录,直到该组中的记录数等于8个。\n在一个组中的记录数等于8个后再插入一条记录时,会将组中的记录拆分成两个组,一个组中4条记录,另一个5条记录。这个过程会在页目录中新增一个槽来记录这个新增分组中最大的那条记录的偏移量。\n由于现在page_demo表中的记录太少,无法演示添加了页目录之后加快查找速度的过程,所以再往page_demo表中添加一些记录: mysql\u0026gt; INSERT INTO page_demo VALUES(5, 500, 'eeee'), (6, 600, 'ffff'), (7, 700, 'gggg'), (8, 800, 'hhhh'), (9, 900, 'iiii'), (10, 1000, 'jjjj'), (11, 1100, 'kkkk'), (12, 1200, 'llll'), (13, 1300, 'mmmm'), (14, 1400, 'nnnn'), (15, 1500, 'oooo'), (16, 1600, 'pppp'); Query OK, 12 rows affected (0.00 sec) Records: 12 Duplicates: 0 Warnings: 0 我们一口气又往表中添加了12条记录,现在页里边就一共有18条记录了(包括最小和最大记录),这些记录被分成了5个组,如图所示:\n因为把16条记录的全部信息都画在一张图里太占地方,让人眼花缭乱的,所以只保留了用户记录头信息中的n_owned和next_record属性,也省略了各个记录之间的箭头,我没画不等于没有啊!现在看怎么从这个页目录中查找记录。因为各个槽代表的记录的主键值都是从小到大排序的,所以我们可以使用所谓的二分法来进行快速查找。4个槽的编号分别是:0、1、2、3、4,所以初始情况下最低的槽就是low=0,最高的槽就是high=4。比方说我们想找主键值为6的记录,过程是这样的:\n计算中间槽的位置:(0+4)/2=2,所以查看槽2对应记录的主键值为8,又因为8 \u0026gt; 6,所以设置high=2,low保持不变。\n重新计算中间槽的位置:(0+2)/2=1,所以查看槽1对应的主键值为4,又因为4 \u0026lt; 6,所以设置low=1,high保持不变。\n因为high - low的值为1,所以确定主键值为5的记录在槽2对应的组中。此刻我们需要找到槽2中主键值最小的那条记录,然后沿着单向链表遍历槽2中的记录。但是我们前面又说过,每个槽对应的记录都是该组中主键值最大的记录,这里槽2对应的记录是主键值为8的记录,怎么定位一个组中最小的记录呢?别忘了各个槽都是挨着的,我们可以很轻易的拿到槽1对应的记录(主键值为4),该条记录的下一条记录就是槽2中主键值最小的记录,该记录的主键值为5。所以我们可以从这条主键值为5的记录出发,遍历槽2中的各条记录,直到找到主键值为6的那条记录即可。由于一个组中包含的记录条数只能是1~8条,所以遍历一个组中的记录的代价是很小的。\n所以在一个数据页中查找指定主键值的记录的过程分为两步:\n通过二分法确定该记录所在的槽,并找到该槽中主键值最小的那条记录。\n通过记录的next_record属性遍历该槽所在的组中的各个记录。\n小贴士:如果你不知道二分法是个什么东西,找个基础算法书看看吧。什么?算法书写的看不懂?等我~\nPage Header(页面头部) # 设计InnoDB的大佬们为了能得到一个数据页中存储的记录的状态信息,比如本页中已经存储了多少条记录,第一条记录的地址是什么,页目录中存储了多少个槽等等,特意在页中定义了一个叫Page Header的部分,它是页结构的第二部分,这个部分占用固定的56个字节,专门存储各种状态信息,具体各个字节都是干嘛的看下表: 名称 占用空间大小 描述 PAGE_N_DIR_SLOTS 2字节 在页目录中的槽数量 PAGE_HEAP_TOP 2字节 还未使用的空间最小地址,也就是说从该地址之后就是Free Space PAGE_N_HEAP 2字节 本页中的记录的数量(包括最小和最大记录以及标记为删除的记录) PAGE_FREE 2字节 第一个已经标记为删除的记录地址(各个已删除的记录通过next_record也会组成一个单链表,这个单链表中的记录可以被重新利用) PAGE_GARBAGE 2字节 已删除记录占用的字节数 PAGE_LAST_INSERT 2字节 最后插入记录的位置 PAGE_DIRECTION 2字节 记录插入的方向 PAGE_N_DIRECTION 2字节 一个方向连续插入的记录数量 PAGE_N_RECS 2字节 该页中记录的数量(不包括最小和最大记录以及被标记为删除的记录) PAGE_MAX_TRX_ID 8字节 修改当前页的最大事务ID,该值仅在二级索引中定义 PAGE_LEVEL 2字节 当前页在B+树中所处的层级 PAGE_INDEX_ID 8字节 索引ID,表示当前页属于哪个索引 PAGE_BTR_SEG_LEAF 10字节 B+树叶子段的头部信息,仅在B+树的Root页定义 PAGE_BTR_SEG_TOP 10字节 B+树非叶子段的头部信息,仅在B+树的Root页定义 如果大家认真看过前面的文章,从PAGE_N_DIR_SLOTS到PAGE_LAST_INSERT以及PAGE_N_RECS的意思大家一定是清楚的,如果不清楚,对不起,你应该回头再看一遍前面的文章。剩下的状态信息看不明白不要着急,饭要一口一口吃,东西要一点一点学(一定要稍安勿躁哦,不要被这些名词吓到)。在这里我们先介绍一下PAGE_DIRECTION和PAGE_N_DIRECTION的意思:\nPAGE_DIRECTION\n假如新插入的一条记录的主键值比上一条记录的主键值大,我们说这条记录的插入方向是右边,反之则是左边。用来表示最后一条记录插入方向的状态就是PAGE_DIRECTION。\nPAGE_N_DIRECTION\n假设连续几次插入新记录的方向都是一致的,InnoDB会把沿着同一个方向插入记录的条数记下来,这个条数就用PAGE_N_DIRECTION这个状态表示。当然,如果最后一条记录的插入方向改变了的话,这个状态的值会被清零重新统计。\n至于我们没提到的那些属性,我没说是因为现在不需要大家知道。不要着急,当我们学完了后边的内容,你再回头看,一切都是那么清晰。\n小贴士:说到这个有些东西后边我们学过后回头看就很清晰的事儿不禁让我想到了乔布斯在斯坦福大学的演讲,摆一下原文: “You can't connect the dots looking forward; you can only connect them looking backwards. So you have to trust that the dots will somehow connect in your future.You have to trust in something - your gut, destiny, life, karma, whatever. This approach has never let me down, and it has made all the difference in my life.” 上面这段话纯属心血来潮写的,大意是坚持做自己喜欢的事儿,你在做的时候可能并不能搞清楚这些事儿对自己之后的人生有什么影响,但当你一路走来回头看时,一切都是那么清晰,就像是命中注定的一样。上述内容跟MySQL毫无干系,请忽略~\nFile Header(文件头部) # 上面介绍的Page Header是专门针对数据页记录的各种状态信息,比方说页里头有多少个记录了呀,有多少个槽了呀。我们现在描述的File Header针对各种类型的页都通用,也就是说不同类型的页都会以File Header作为第一个组成部分,它描述了一些针对各种页都通用的一些信息,比方说这个页的编号是多少,它的上一个页、下一个页是谁啦等等~ 这个部分占用固定的38个字节,是由下面这些内容组成的: 名称 占用空间大小 描述 FIL_PAGE_SPACE_OR_CHKSUM 4字节 页的校验和(checksum值) FIL_PAGE_OFFSET 4字节 页号 FIL_PAGE_PREV 4字节 上一个页的页号 FIL_PAGE_NEXT 4字节 下一个页的页号 FIL_PAGE_LSN 8字节 页面被最后修改时对应的日志序列位置(英文名是:Log Sequence Number) FIL_PAGE_TYPE 2字节 该页的类型 FIL_PAGE_FILE_FLUSH_LSN 8字节 仅在系统表空间的一个页中定义,代表文件至少被刷新到了对应的LSN值 FIL_PAGE_ARCH_LOG_NO_OR_SPACE_ID 4字节 页属于哪个表空间 对照着这个表格,我们看几个目前比较重要的部分:\nFIL_PAGE_SPACE_OR_CHKSUM\n这个代表当前页面的校验和(checksum)。什么是个校验和?就是对于一个很长很长的字节串来说,我们会通过某种算法来计算一个比较短的值来代表这个很长的字节串,这个比较短的值就称为校验和。这样在比较两个很长的字节串之前先比较这两个长字节串的校验和,如果校验和都不一样两个长字节串肯定是不同的,所以省去了直接比较两个比较长的字节串的时间损耗。\nFIL_PAGE_OFFSET\n每一个页都有一个单独的页号,就跟你的身份证号码一样,InnoDB通过页号来可以唯一定位一个页。\nFIL_PAGE_TYPE\n这个代表当前页的类型,我们前面说过,InnoDB为了不同的目的而把页分为不同的类型,我们上面介绍的其实都是存储记录的数据页,其实还有很多别的类型的页,具体如下表:\n类型名称 十六进制 描述 `FIL_PAGE_TYPE_ALLOCATED` 0x0000 最新分配,还没使用 `FIL_PAGE_UNDO_LOG` 0x0002 Undo日志页 `FIL_PAGE_INODE` 0x0003 段信息节点 `FIL_PAGE_IBUF_FREE_LIST` 0x0004 Insert Buffer空闲列表 `FIL_PAGE_IBUF_BITMAP` 0x0005 Insert Buffer位图 `FIL_PAGE_TYPE_SYS` 0x0006 系统页 `FIL_PAGE_TYPE_TRX_SYS` 0x0007 事务系统数据 `FIL_PAGE_TYPE_FSP_HDR` 0x0008 表空间头部信息 `FIL_PAGE_TYPE_XDES` 0x0009 扩展描述页 `FIL_PAGE_TYPE_BLOB` 0x000A BLOB页 `FIL_PAGE_INDEX` 0x45BF 索引页,也就是我们所说的`数据页` 我们存放记录的数据页的类型其实是`FIL_PAGE_INDEX`,也就是所谓的`索引页`。至于什么是个索引,且听下回分解~ FIL_PAGE_PREV和FIL_PAGE_NEXT\n我们前面强调过,InnoDB都是以页为单位存放数据的,有时候我们存放某种类型的数据占用的空间非常大(比方说一张表中可以有成千上万条记录),InnoDB可能不可以一次性为这么多数据分配一个非常大的存储空间,如果分散到多个不连续的页中存储的话需要把这些页关联起来,FIL_PAGE_PREV和FIL_PAGE_NEXT就分别代表本页的上一个和下一个页的页号。这样通过建立一个双向链表把许许多多的页就都串联起来了,而无需这些页在物理上真正连着。需要注意的是,并不是所有类型的页都有上一个和下一个页的属性,不过我们本集中介绍的数据页(也就是类型为FIL_PAGE_INDEX的页)是有这两个属性的,所以所有的数据页其实是一个双链表,就像这样:\n关于File Header的其他属性我们暂时用不到,等用到的时候再提~\nFile Trailer # 我们知道InnoDB存储引擎会把数据存储到磁盘上,但是磁盘速度太慢,需要以页为单位把数据加载到内存中处理,如果该页中的数据在内存中被修改了,那么在修改后的某个时间需要把数据同步到磁盘中。但是在同步了一半的时候中断电了咋办,这不是莫名尴尬么?为了检测一个页是否完整(也就是在同步的时候有没有发生只同步一半的尴尬情况),设计InnoDB的大佬们在每个页的尾部都加了一个File Trailer部分,这个部分由8个字节组成,可以分成2个小部分:\n前4个字节代表页的校验和\n这个部分是和File Header中的校验和相对应的。每当一个页面在内存中修改了,在同步之前就要把它的校验和算出来,因为File Header在页面的前面,所以校验和会被首先同步到磁盘,当完全写完时,校验和也会被写到页的尾部,如果完全同步成功,则页的首部和尾部的校验和应该是一致的。如果写了一半儿断电了,那么在File Header中的校验和就代表着已经修改过的页,而在File Trialer中的校验和代表着原先的页,二者不同则意味着同步中间出了错。\n后4个字节代表页面被最后修改时对应的日志序列位置(LSN)\n这个部分也是为了校验页的完整性的,只不过我们目前还没说LSN是个什么意思,所以大家可以先不用管这个属性。\n这个File Trailer与File Header类似,都是所有类型的页通用的。\n总结 # InnoDB为了不同的目的而设计了不同类型的页,我们把用于存放记录的页叫做数据页。\n一个数据页可以被大致划分为7个部分,分别是\n+ `File Header`,表示页的一些通用信息,占固定的38字节。 + `Page Header`,表示数据页专有的一些信息,占固定的56个字节。 + `Infimum + Supremum`,两个虚拟的伪记录,分别表示页中的最小和最大记录,占固定的`26`个字节。 + `User Records`:真实存储我们插入的记录的部分,大小不固定。 + `Free Space`:页中尚未使用的部分,大小不确定。 + `Page Directory`:页中的某些记录相对位置,也就是各个槽在页面中的地址偏移量,大小不固定,插入的记录越多,这个部分占用的空间越多。 + `File Trailer`:用于检验页是否完整的部分,占用固定的8个字节。 每个记录的头信息中都有一个next_record属性,从而使页中的所有记录串联成一个单链表。\nInnoDB会为把页中的记录划分为若干个组,每个组的最后一个记录的地址偏移量作为一个槽,存放在Page Directory中,所以在一个页中根据主键查找记录是非常快的,分为两步:\n+ 通过二分法确定该记录所在的槽。\n+ 通过记录的next_record属性遍历该槽所在的组中的各个记录。\n每个数据页的File Header部分都有上一个和下一个页的编号,所以所有的数据页会组成一个双链表。\n为保证从内存中同步到磁盘的页的完整性,在页的首部和尾部都会存储页中数据的校验和和页面最后修改时对应的LSN值,如果首部和尾部的校验和和LSN值校验不成功的话,就说明同步过程出现了问题。\n"},{"id":28,"href":"/zh/docs/technology/MySQL/MySQL%E6%98%AF%E6%80%8E%E6%A0%B7%E8%BF%90%E8%A1%8C%E7%9A%84/%E7%AC%AC4%E7%AB%A0_%E4%BB%8E%E4%B8%80%E6%9D%A1%E8%AE%B0%E5%BD%95%E8%AF%B4%E8%B5%B7-InnoDB%E8%AE%B0%E5%BD%95%E7%BB%93%E6%9E%84/","title":"第4章_从一条记录说起-InnoDB记录结构","section":"My Sql是怎样运行的","content":"第4章 从一条记录说起-InnoDB记录结构\n准备工作 # 到现在为止,MySQL对于我们来说还是一个黑盒,我们只负责使用客户端发送请求并等待服务器返回结果,表中的数据到底存到了哪里?以什么格式存放的?MySQL是以什么方式来访问的这些数据?这些问题我们统统不知道,对于未知领域的探索向来就是社会主义核心价值观中的一部分,作为新一代社会主义接班人,不把它们搞懂怎么支援祖国建设呢?\n我们前面介绍请求处理过程的时候提到过,MySQL服务器上负责对表中数据的读取和写入工作的部分是存储引擎,而服务器又支持不同类型的存储引擎,比如InnoDB、MyISAM、Memory什么的,不同的存储引擎一般是由不同的人为实现不同的特性而开发的,真实数据在不同存储引擎中存放的格式一般是不同的,甚至有的存储引擎比如Memory都不用磁盘来存储数据,也就是说关闭服务器后表中的数据就消失了。由于InnoDB是MySQL默认的存储引擎,也是我们最常用到的存储引擎,我们也没有那么多时间去把各个存储引擎的内部实现都看一遍,所以本集要介绍的是使用InnoDB作为存储引擎的数据存储结构,了解了一个存储引擎的数据存储结构之后,其他的存储引擎都是依葫芦画瓢,等我们用到了再说。\nInnoDB页简介 # InnoDB是一个将表中的数据存储到磁盘上的存储引擎,所以即使关机后重启我们的数据还是存在的。而真正处理数据的过程是发生在内存中的,所以需要把磁盘中的数据加载到内存中,如果是处理写入或修改请求的话,还需要把内存中的内容刷新到磁盘上。而我们知道读写磁盘的速度非常慢,和内存读写差了几个数量级,所以当我们想从表中获取某些记录时,InnoDB存储引擎需要一条一条的把记录从磁盘上读出来么?不,那样会慢死,InnoDB采取的方式是:将数据划分为若干个页,以页作为磁盘和内存之间交互的基本单位,InnoDB中页的大小一般为 16 KB。也就是在一般情况下,一次最少从磁盘中读取16KB的内容到内存中,一次最少把内存中的16KB内容刷新到磁盘中。\nInnoDB行格式 # 我们平时是以记录为单位来向表中插入数据的,这些记录在磁盘上的存放方式也被称为行格式或者记录格式。设计InnoDB存储引擎的大佬们到现在为止设计了4种不同类型的行格式,分别是Compact、Redundant、Dynamic和Compressed行格式,随着时间的推移,他们可能会设计出更多的行格式,但是不管怎么变,在原理上大体都是相同的。\n指定行格式的语法 # 我们可以在创建或修改表的语句中指定行格式: ``` CREATE TABLE 表名 (列的信息) ROW_FORMAT=行格式名称\nALTER TABLE 表名 ROW_FORMAT=行格式名称 比如我们在xiaohaizi数据库里创建一个演示用的表record_format_demo,可以这样指定它的行格式: mysql\u0026gt; USE xiaohaizi; Database changed\nmysql\u0026gt; CREATE TABLE record_format_demo ( -\u0026gt; c1 VARCHAR 10),−\u0026gt;c2VARCHAR(10)NOTNULL,−\u0026gt;c3CHAR(10),−\u0026gt;c4VARCHAR(10)−\u0026gt;10), -\u0026gt; c2 VARCHAR(10) NOT NULL, -\u0026gt; c3 CHAR(10), -\u0026gt; c4 VARCHAR(10) -\u0026gt; CHARSET=ascii ROW_FORMAT=COMPACT; Query OK, 0 rows affected (0.03 sec) 可以看到我们刚刚创建的这个表的行格式就是Compact,另外,我们还显式指定了这个表的字符集为ascii,因为ascii字符集只包括空格、标点符号、数字、大小写字母和一些不可见字符,所以我们的汉字是不能存到这个表里的。我们现在向这个表中插入两条记录: mysql\u0026gt; INSERT INTO record_format_demo(c1, c2, c3, c4) VALUES(\u0026lsquo;aaaa\u0026rsquo;, \u0026lsquo;bbb\u0026rsquo;, \u0026lsquo;cc\u0026rsquo;, \u0026rsquo;d\u0026rsquo;), (\u0026rsquo;eeee\u0026rsquo;, \u0026lsquo;fff\u0026rsquo;, NULL, NULL); Query OK, 2 rows affected (0.02 sec) Records: 2 Duplicates: 0 Warnings: 0 现在表中的记录就是这个样子的: mysql\u0026gt; SELECT * FROM record_format_demo; +\u0026mdash;\u0026mdash;+\u0026mdash;\u0026ndash;+\u0026mdash;\u0026mdash;+\u0026mdash;\u0026mdash;+ | c1 | c2 | c3 | c4 | +\u0026mdash;\u0026mdash;+\u0026mdash;\u0026ndash;+\u0026mdash;\u0026mdash;+\u0026mdash;\u0026mdash;+ | aaaa | bbb | cc | d | | eeee | fff | NULL | NULL | +\u0026mdash;\u0026mdash;+\u0026mdash;\u0026ndash;+\u0026mdash;\u0026mdash;+\u0026mdash;\u0026mdash;+ 2 rows in set (0.00 sec)\nmysql\u0026gt; ``` 演示表的内容也填充好了,现在我们就来看看各个行格式下的存储方式到底有什么不同吧~\nCOMPACT行格式 # 废话不多说,直接看图:\n大家从图中可以看出来,一条完整的记录其实可以被分为记录的额外信息和记录的真实数据两大部分,下面我们详细看一下这两部分的组成。\n记录的额外信息 # 这部分信息是服务器为了描述这条记录而不得不额外添加的一些信息,这些额外信息分为3类,分别是变长字段长度列表、NULL值列表和记录头信息,我们分别看一下。\n变长字段长度列表 # 我们知道MySQL支持一些变长的数据类型,比如VARCHAR(M)、VARBINARY(M)、各种TEXT类型,各种BLOB类型,我们也可以把拥有这些数据类型的列称为变长字段,变长字段中存储多少字节的数据是不固定的,所以我们在存储真实数据的时候需要顺便把这些数据占用的字节数也存起来,这样才不至于把MySQL服务器搞懵,所以这些变长字段占用的存储空间分为两部分:\n真正的数据内容 占用的字节数 在Compact行格式中,把所有变长字段的真实数据占用的字节长度都存放在记录的开头部位,从而形成一个变长字段长度列表,各变长字段数据占用的字节数按照列的顺序逆序存放,我们再次强调一遍,是逆序存放!\n我们拿record_format_demo表中的第一条记录来举个例子。因为record_format_demo表的c1、c2、c4列都是VARCHAR(10)类型的,也就是变长的数据类型,所以这三个列的值的长度都需要保存在记录开头处,因为record_format_demo表中的各个列都使用的是ascii字符集,所以每个字符只需要1个字节来进行编码,来看一下第一条记录各变长字段内容的长度: 列名 存储内容 内容长度(十进制表示) 内容长度(十六进制表示) c1 'aaaa' 4 0x04 c2 'bbb' 3 0x03 c4 'd' 1 0x01 又因为这些长度值需要按照列的逆序存放,所以最后变长字段长度列表的字节串用十六进制表示的效果就是(各个字节之间实际上没有空格,用空格隔开只是方便理解): 01 03 04 把这个字节串组成的变长字段长度列表填入上面的示意图中的效果就是:\n由于第一行记录中c1、c2、c4列中的字符串都比较短,也就是说内容占用的字节数比较小,用1个字节就可以表示,但是如果变长列的内容占用的字节数比较多,可能就需要用2个字节来表示。具体用1个还是2个字节来表示真实数据占用的字节数,InnoDB有它的一套规则,我们首先声明一下W、M和L的意思:\n假设某个字符集中表示一个字符最多需要使用的字节数为W,也就是使用SHOW CHARSET语句的结果中的Maxlen列,比方说utf8字符集中的W就是3,gbk字符集中的W就是2,ascii字符集中的W就是1。\n对于变长类型VARCHAR(M)来说,这种类型表示能存储最多M个字符(注意是字符不是字节),所以这个类型能表示的字符串最多占用的字节数就是M×W。\n假设它实际存储的字符串占用的字节数是L。\n所以确定使用1个字节还是2个字节表示真正字符串占用的字节数的规则就是这样:\n如果M×W \u0026lt;= 255,那么使用1个字节来表示真正字符串占用的字节数。 就是说InnoDB在读记录的变长字段长度列表时先查看表结构,如果某个变长字段允许存储的最大字节数不大于255时,可以认为只使用1个字节来表示真正字符串占用的字节数。\n如果M×W \u0026gt; 255,则分为两种情况: 如果L \u0026lt;= 127,则用1个字节来表示真正字符串占用的字节数。 如果L \u0026gt; 127,则用2个字节来表示真正字符串占用的字节数。 InnoDB在读记录的变长字段长度列表时先查看表结构,如果某个变长字段允许存储的最大字节数大于255时,该怎么区分它正在读的某个字节是一个单独的字段长度还是半个字段长度呢?设计InnoDB的大佬使用该字节的第一个二进制位作为标志位:如果该字节的第一个位为0,那该字节就是一个单独的字段长度(使用一个字节表示不大于127的二进制的第一个位都为0),如果该字节的第一个位为1,那该字节就是半个字段长度。 对于一些占用字节数非常多的字段,比方说某个字段长度大于了16KB,那么如果该记录在单个页面中无法存储时,InnoDB会把一部分数据存放到所谓的溢出页中(我们后边会介绍),在变长字段长度列表处只存储留在本页面中的长度,所以使用两个字节也可以存放下来。\n总结一下就是说:如果该可变字段允许存储的最大字节数(M×W)超过255字节并且真实存储的字节数(L)超过127字节,则使用2个字节,否则使用1个字节。\n另外需要注意的一点是,**变长字段长度列表中只存储值为 非NULL 的列内容占用的长度,值为 NULL 的列的长度是不储存的 **。也就是说对于第二条记录来说,因为c4列的值为NULL,所以第二条记录的变长字段长度列表只需要存储c1和c2列的长度即可。其中c1列存储的值为'eeee',占用的字节数为4,c2列存储的值为'fff',占用的字节数为3。数字4可以用1个字节表示,3也可以用1个字节表示,所以整个变长字段长度列表共需2个字节。填充完变长字段长度列表的两条记录的对比图如下:\n小贴士:并不是所有记录都有这个 变长字段长度列表 部分,比方说表中所有的列都不是变长的数据类型的话,这一部分就不需要有。\nNULL值列表 # 我们知道表中的某些列可能存储NULL值,如果把这些NULL值都放到记录的真实数据中存储会很占地方,所以Compact行格式把这些值为NULL的列统一管理起来,存储到NULL值列表中,它的处理过程是这样的:\n首先统计表中允许存储NULL的列有哪些。\n我们前面说过,主键列、被NOT NULL修饰的列都是不可以存储NULL值的,所以在统计的时候不会把这些列算进去。比方说表record_format_demo的3个列c1、c3、c4都是允许存储NULL值的,而c2列是被NOT NULL修饰,不允许存储NULL值。\n如果表中没有允许存储 NULL 的列,则 NULL值列表 也不存在了,否则将每个允许存储NULL的列对应一个二进制位,二进制位按照列的顺序逆序排列,二进制位表示的意义如下:\n+ 二进制位的值为`1`时,代表该列的值为`NULL`。 + 二进制位的值为`0`时,代表该列的值不为`NULL`。 因为表record_format_demo有3个值允许为NULL的列,所以这3个列和二进制位的对应关系就是这样:\n再一次强调,二进制位按照列的顺序逆序排列,所以第一个列c1和最后一个二进制位对应。\nMySQL规定NULL值列表必须用整数个字节的位表示,如果使用的二进制位个数不是整数个字节,则在字节的高位补0。\n表record_format_demo只有3个值允许为NULL的列,对应3个二进制位,不足一个字节,所以在字节的高位补0,效果就是这样:\n以此类推,如果一个表中有9个允许为NULL,那这个记录的NULL值列表部分就需要2个字节来表示了。\n知道了规则之后,我们再返回头看表record_format_demo中的两条记录中的NULL值列表应该怎么储存。因为只有c1、c3、c4这3个列允许存储NULL值,所以所有记录的NULL值列表只需要一个字节。\n对于第一条记录来说,c1、c3、c4这3个列的值都不为NULL,所以它们对应的二进制位都是0,画个图就是这样:\n所以第一条记录的NULL值列表用十六进制表示就是:0x00。\n对于第二条记录来说,c1、c3、c4这3个列中c3和c4的值都为NULL,所以这3个列对应的二进制位的情况就是:\n所以第二条记录的NULL值列表用十六进制表示就是:0x06。\n所以这两条记录在填充了NULL值列表后的示意图就是这样:\n记录头信息 # 除了变长字段长度列表、NULL值列表之外,还有一个用于描述记录的记录头信息,它是由固定的5个字节组成。5个字节也就是40个二进制位,不同的位代表不同的意思,如图:\n这些二进制位代表的详细信息如下表: 名称 大小(单位:bit) 描述 预留位1 1 没有使用 预留位2 1 没有使用 delete_mask 1 标记该记录是否被删除 min_rec_mask 1 B+树的每层非叶子节点中的最小记录都会添加该标记 n_owned 4 表示当前记录拥有的记录数 heap_no 13 表示当前记录在记录堆的位置信息 record_type 3 表示当前记录的类型,0表示普通记录,1表示B+树非叶子节点记录,2表示最小记录,3表示最大记录 next_record 16 表示下一条记录的相对位置 大家不要被这么多的属性和陌生的概念给吓着,我这里只是为了内容的完整性把这些位代表的意思都写了出来,现在没必要把它们的意思都记住,记住也没什么用,现在只需要看一遍混个脸熟,等之后用到这些属性的时候我们再回过头来看。\n因为我们并不清楚这些属性详细的用法,所以这里就不分析各个属性值是怎么产生的了,之后我们遇到会详细看的。所以我们现在直接看一下record_format_demo中的两条记录的头信息分别是什么:\n小贴士:再一次强调,大家如果看不懂记录头信息里各个位代表的概念千万别纠结,我们后边会说的~\n记录的真实数据 # 对于record_format_demo表来说,记录的真实数据除了c1、c2、c3、c4这几个我们自己定义的列的数据以外,MySQL会为每个记录默认的添加一些列(也称为隐藏列),具体的列如下: 列名 是否必须 占用空间 描述 row_id 否 6字节 行ID,唯一标识一条记录 transaction_id 是 6字节 事务ID roll_pointer 是 7字节 回滚指针 小贴士:实际上这几个列的真正名称其实是:DB_ROW_ID、DB_TRX_ID、DB_ROLL_PTR,我们为了美观才写成了row_id、transaction_id和roll_pointer。\n这里需要提一下InnoDB表对主键的生成策略:优先使用用户自定义主键作为主键,如果用户没有定义主键,则选取一个Unique键作为主键,如果表中连Unique键都没有定义的话,则InnoDB会为表默认添加一个名为row_id的隐藏列作为主键。所以我们从上表中可以看出:InnoDB存储引擎会为每条记录都添加 transaction_id 和 roll_pointer 这两个列,但是 row_id 是可选的(在没有自定义主键以及Unique键的情况下才会添加该列)。这些隐藏列的值不用我们操心,InnoDB存储引擎会自己帮我们生成的。\n因为表record_format_demo并没有定义主键,所以MySQL服务器会为每条记录增加上述的3个列。现在看一下加上记录的真实数据的两个记录长什么样吧:\n看这个图的时候我们需要注意几点:\n表record_format_demo使用的是ascii字符集,所以0x61616161就表示字符串'aaaa',0x626262就表示字符串'bbb',以此类推。\n注意第1条记录中c3列的值,它是CHAR(10)类型的,它实际存储的字符串是:'cc',而ascii字符集中的字节表示是'0x6363',虽然表示这个字符串只占用了2个字节,但整个c3列仍然占用了10个字节的空间,除真实数据以外的8个字节的统统都用空格字符填充,空格字符在ascii字符集的表示就是0x20。\n注意第2条记录中c3和c4列的值都为NULL,它们被存储在了前面的NULL值列表处,在记录的真实数据处就不再冗余存储,从而节省存储空间。\nCHAR(M)列的存储格式 # record_format_demo表的c1、c2、c4列的类型是VARCHAR(10),而c3列的类型是CHAR(10),我们说在Compact行格式下只会把变长类型的列的长度逆序存到变长字段长度列表中,就像这样:\n但是这只是因为我们的record_format_demo表采用的是ascii字符集,这个字符集是一个定长字符集,也就是说表示一个字符采用固定的一个字节,如果采用变长的字符集(也就是表示一个字符需要的字节数不确定,比如gbk表示一个字符要1\\~2个字节、utf8表示一个字符要1\\~3个字节等)的话,c3列的长度也会被存储到变长字段长度列表中,比如我们修改一下record_format_demo表的字符集: mysql\u0026gt; ALTER TABLE record_format_demo MODIFY COLUMN c3 CHAR(10) CHARACTER SET utf8; Query OK, 2 rows affected (0.02 sec) Records: 2 Duplicates: 0 Warnings: 0 修改该列字符集后记录的变长字段长度列表也发生了变化,如图:\n这就意味着:对于 CHAR(M) 类型的列来说,当列采用的是定长字符集时,该列占用的字节数不会被加到变长字段长度列表,而如果采用变长字符集时,该列占用的字节数也会被加到变长字段长度列表。\n另外有一点还需要注意,变长字符集的CHAR(M)类型的列要求至少占用M个字节,而VARCHAR(M)却没有这个要求。比方说对于使用utf8字符集的CHAR(10)的列来说,该列存储的数据字节长度的范围是10~30个字节。即使我们向该列中存储一个空字符串也会占用10个字节,这是怕将来更新该列的值的字节长度大于原有值的字节长度而小于10个字节时,可以在该记录处直接更新,而不是在存储空间中重新分配一个新的记录空间,导致原有的记录空间成为所谓的碎片。(这里你感受到设计Compact行格式的大佬既想节省存储空间,又不想更新CHAR(M)类型的列产生碎片时的纠结心情了吧。)\nRedundant行格式 # 其实知道了Compact行格式之后,其他的行格式就是依葫芦画瓢了。我们现在要介绍的Redundant行格式是MySQL5.0之前用的一种行格式,也就是说它已经非常老了,但是本着知识完整性的角度还是要提一下,大家乐呵乐呵的看就好。\n画个图展示一下Redundant行格式的全貌:\n现在我们把表record_format_demo的行格式修改为Redundant: mysql\u0026gt; ALTER TABLE record_format_demo ROW_FORMAT=Redundant; Query OK, 0 rows affected (0.05 sec) Records: 0 Duplicates: 0 Warnings: 0 为了方便大家理解和节省篇幅,我们直接把表record_format_demo在Redundant行格式下的两条记录的真实存储数据提供出来,之后我们着重分析两种行格式的不同即可。\n下面我们从各个方面看一下Redundant行格式有什么不同的地方:\n字段长度偏移列表\n注意Compact行格式的开头是变长字段长度列表,而Redundant行格式的开头是字段长度偏移列表,与变长字段长度列表有两处不同:\n+ 没有了变长两个字,意味着Redundant行格式会把该条记录中所有列(包括隐藏列)的长度信息都按照逆序存储到字段长度偏移列表。\n+ 多了个偏移两个字,这意味着计算列值长度的方式不像Compact行格式那么直观,它是采用两个相邻数值的差值来计算各个列值的长度。\n比如第一条记录的字段长度偏移列表就是: 25 24 1A 17 13 0C 06 因为它是逆序排放的,所以按照列的顺序排列就是: 06 0C 13 17 1A 24 25 按照两个相邻数值的差值来计算各个列值的长度的意思就是: 第一列(row_id)的长度就是 0x06个字节,也就是6个字节。 第二列(transaction_id)的长度就是 (0x0C - 0x06)个字节,也就是6个字节。 第三列(roll_pointer)的长度就是 (0x13 - 0x0C)个字节,也就是7个字节。 第四列(c1)的长度就是 (0x17 - 0x13)个字节,也就是4个字节。 第五列(c2)的长度就是 (0x1A - 0x17)个字节,也就是3个字节。 第六列(c3)的长度就是 (0x24 - 0x1A)个字节,也就是10个字节。 第七列(c4)的长度就是 (0x25 - 0x24)个字节,也就是1个字节。 - 记录头信息\nRedundant行格式的记录头信息占用6字节,48个二进制位,这些二进制位代表的意思如下:\n名称 大小(单位:bit) 描述 `预留位1` `1` 没有使用 `预留位2` `1` 没有使用 `delete_mask` `1` 标记该记录是否被删除 `min_rec_mask` `1` B\\+树的每层非叶子节点中的最小记录都会添加该标记 `n_owned` `4` 表示当前记录拥有的记录数 `heap_no` `13` 表示当前记录在页面堆的位置信息 `n_field` `10` 表示记录中列的数量 `1byte_offs_flag` `1` 标记字段长度偏移列表中每个列对应的偏移量是使用1字节还是2字节表示的 `next_record` `16` 表示下一条记录的相对位置 第一条记录中的头信息是: 00 00 10 0F 00 BC 根据这六个字节可以计算出各个属性的值,如下: 预留位1:0x00 预留位2:0x00 delete_mask: 0x00 min_rec_mask: 0x00 n_owned: 0x00 heap_no: 0x02 n_field: 0x07 1byte_offs_flag: 0x01 next_record:0xBC 与Compact行格式的记录头信息对比来看,有两处不同: 1. Redundant 行格式多了 n_field 和 1byte_offs_flag 这两个属性。 2. Redundant 行格式没有 record_type 这个属性。\n1byte_offs_flag的值是怎么选择的\n字段长度偏移列表实质上是存储每个列中的值占用的空间在记录的真实数据处结束的位置,还是拿record_format_demo第一条记录为例,0x06代表第一个列在记录的真实数据第6个字节处结束,0x0C代表第二个列在记录的真实数据第12个字节处结束,0x13代表第三个列在记录的真实数据第19个字节处结束,等等等等,最后一个列对应的偏移量值为0x25,也就意味着最后一个列在记录的真实数据第37个字节处结束,也就意味着整条记录的真实数据实际上占用37个字节。\n我们前面说过每个列对应的偏移量可以占用1个字节或者2个字节来存储,那到底什么时候用1个字节,什么时候用2个字节呢?其实是根据该条Redundant行格式记录的真实数据占用的总大小来判断的:\n+ 当记录的真实数据占用的字节数不大于127(十六进制`0x7F`,二进制`01111111`)时,每个列对应的偏移量占用1个字节。 小贴士:如果整个记录的真实数据占用的存储空间都不大于127个字节,那么每个列对应的偏移量值肯定也就不大于127,也就可以使用1个字节来表示喽。\n+ 当记录的真实数据占用的字节数大于127,但不大于32767(十六进制0x7FFF,二进制0111111111111111)时,每个列对应的偏移量占用2个字节。\n+ 有没有记录的真实数据大于32767的情况呢?有,不过此时的记录已经存放到了溢出页中,在本页中只保留前768个字节和20个字节的溢出页面地址(当然这20个字节中还记录了一些别的信息)。因为字段长度偏移列表处只需要记录每个列在本页面中的偏移就好了,所以每个列使用2个字节来存储偏移量就够了。\n大家可以看出来,设计Redundant行格式的大佬还是比较简单粗暴的,直接使用整个记录的真实数据长度来决定使用1个字节还是2个字节存储列对应的偏移量。只要整条记录的真实数据占用的存储空间大小大于127,即使第一个列的值占用存储空间小于127,那对不起,也需要使用2个字节来表示该列对应的偏移量。简单粗暴,就是这么简单粗暴(所以这种行格式有些过时了~)。\n小贴士:大家有没有疑惑,一个字节能表示的范围是0~255,为什么在记录的真实数据占用的存储空间大于127时就采用2个字节表示各个列的偏移量呢?稍安勿躁,后边马上揭晓。\n为了在解析记录时知道每个列的偏移量是使用1个字节还是2个字节表示的,设计Redundant行格式的大佬特意在记录头信息里放置了一个称之为1byte_offs_flag的属性:\n当它的值为1时,表明使用1个字节存储。 - 当它的值为0时,表明使用2个字节存储。 Redundant行格式中NULL值的处理\n因为Redundant行格式并没有NULL值列表,所以设计Redundant行格式的大佬在字段长度偏移列表中的各个列对应的偏移量处做了一些特殊处理 —— 将列对应的偏移量值的第一个比特位作为是否为NULL的依据,该比特位也可以被称之为NULL比特位。也就是说在解析一条记录的某个列时,首先看一下该列对应的偏移量的NULL比特位是不是为1,如果为1,那么该列的值就是NULL,否则不是NULL。\n这也就解释了上面介绍为什么只要记录的真实数据大于127(十六进制0x7F,二进制01111111)时,就采用2个字节来表示一个列对应的偏移量,主要是第一个比特位是所谓的NULL比特位,用来标记该列的值是否为NULL。\n但是还有一点要注意,对于值为NULL的列来说,该列的类型是否为定长类型决定了NULL值的实际存储方式,我们接下来分析一下record_format_demo表的第二条记录,它对应的字段长度偏移列表如下:\nA4 A4 1A 17 13 0C 06 按照列的顺序排放就是: 06 0C 13 17 1A A4 A4\n我们分情况看一下:\n+ 如果存储NULL值的字段是定长类型的,比方说CHAR(M)数据类型的,则NULL值也将占用记录的真实数据部分,并把该字段对应的数据使用0x00字节填充。\n如图第二条记录的c3列的值是NULL,而c3列的类型是CHAR(10),占用记录的真实数据部分10字节,所以我们看到在Redundant行格式中使用0x00000000000000000000来表示NULL值。\n另外,c3列对应的偏移量为0xA4,它对应的二进制实际是:10100100,可以看到最高位为1,意味着该列的值是NULL。将最高位去掉后的值变成了0100100,对应的十进制值为36,而c2列对应的偏移量为0x1A,也就是十进制的26。36 - 26 = 10,也就是说最终c3列占用的存储空间为10个字节。\n+ 如果该存储NULL值的字段是变长数据类型的,则不在记录的真实数据处占用任何存储空间。\n比如record_format_demo表的c4列是VARCHAR(10)类型的,VARCHAR(10)是一个变长数据类型,c4列对应的偏移量为0xA4,与c3列对应的偏移量相同,这也就意味着它的值也为NULL,将0xA4的最高位去掉后对应的十进制值也是36,36 - 36 = 0,也就意味着c4列本身不占用任何记录的实际数据处的空间。\n除了以上的几点之外,Redundant行格式和Compact行格式还是大致相同的。\nCHAR(M)列的存储格式 # 我们知道Compact行格式在CHAR(M)类型的列中存储数据的时候还挺麻烦,分变长字符集和定长字符集的情况,而在Redundant行格式中十分干脆,不管该列使用的字符集是什么,只要是使用CHAR(M)类型,占用的真实数据空间就是该字符集表示一个字符最多需要的字节数和M的乘积。比方说使用utf8字符集的CHAR(10)类型的列占用的真实数据空间始终为30个字节,使用gbk字符集的CHAR(10)类型的列占用的真实数据空间始终为20个字节。由此可以看出来,使用Redundant行格式的CHAR(M)类型的列是不会产生碎片的。\n行溢出数据 # VARCHAR(M)最多能存储的数据 # 我们知道对于VARCHAR(M)类型的列最多可以占用65535个字节。其中的M代表该类型最多存储的字符数量,如果我们使用ascii字符集的话,一个字符就代表一个字节,我们看看VARCHAR(65535)是否可用:\nmysql\u0026gt; CREATE TABLE varchar_size_demo( -\u0026gt; c VARCHAR(65535) -\u0026gt; ) CHARSET=ascii ROW_FORMAT=Compact; ERROR 1118 (42000): Row size too large. The maximum row size for the used table type, not counting BLOBs, is 65535. This includes storage overhead, check the manual. You have to change some columns to TEXT or BLOBs mysql\u0026gt; 从报错信息里可以看出,MySQL对一条记录占用的最大存储空间是有限制的,除了BLOB或者TEXT类型的列之外,其他所有的列(不包括隐藏列和记录头信息)占用的字节长度加起来不能超过65535个字节。所以MySQL服务器建议我们把存储类型改为TEXT或者BLOB的类型。这个65535个字节除了列本身的数据之外,还包括一些其他的数据(storage overhead),比如说我们为了存储一个VARCHAR(M)类型的列,其实需要占用3部分存储空间:\n真实数据 真实数据占用字节的长度 NULL值标识,如果该列有NOT NULL属性则可以没有这部分存储空间 如果该VARCHAR类型的列没有NOT NULL属性,那最多只能存储65532个字节的数据,因为真实数据的长度可能占用2个字节,NULL值标识需要占用1个字节: mysql\u0026gt; CREATE TABLE varchar_size_demo( -\u0026gt; c VARCHAR(65532) -\u0026gt; ) CHARSET=ascii ROW_FORMAT=Compact; Query OK, 0 rows affected (0.02 sec) 如果VARCHAR类型的列有NOT NULL属性,那最多只能存储65533个字节的数据,因为真实数据的长度可能占用2个字节,不需要NULL值标识: ``` mysql\u0026gt; DROP TABLE varchar_size_demo; Query OK, 0 rows affected (0.01 sec)\nmysql\u0026gt; CREATE TABLE varchar_size_demo( -\u0026gt; c VARCHAR 65533)NOTNULL−\u0026gt;65533) NOT NULL -\u0026gt; CHARSET=ascii ROW_FORMAT=Compact; Query OK, 0 rows affected (0.02 sec) 如果VARCHAR(M)类型的列使用的不是ascii字符集,那会怎么样呢?来看一下: mysql\u0026gt; DROP TABLE varchar_size_demo; Query OK, 0 rows affected (0.00 sec)\nmysql\u0026gt; CREATE TABLE varchar_size_demo( -\u0026gt; c VARCHAR 65532)−\u0026gt;65532) -\u0026gt; CHARSET=gbk ROW_FORMAT=Compact; ERROR 1074 (42000): Column length too big for column \u0026lsquo;c\u0026rsquo; (max = 32767); use BLOB or TEXT instead\nmysql\u0026gt; CREATE TABLE varchar_size_demo( -\u0026gt; c VARCHAR 65532)−\u0026gt;65532) -\u0026gt; CHARSET=utf8 ROW_FORMAT=Compact; ERROR 1074 (42000): Column length too big for column \u0026lsquo;c\u0026rsquo; (max = 21845); use BLOB or TEXT instead 从执行结果中可以看出,如果VARCHAR(M)类型的列使用的不是ascii字符集,那M的最大取值取决于该字符集表示一个字符最多需要的字节数。在列的值允许为NULL的情况下,gbk字符集表示一个字符最多需要2个字节,那在该字符集下,M的最大取值就是32766(也就是:65532/2),也就是说最多能存储32766个字符;utf8字符集表示一个字符最多需要3个字节,那在该字符集下,M的最大取值就是21844,就是说最多能存储21844(也就是:65532/3)个字符。 小贴士:上述所言在列的值允许为NULL的情况下,gbk字符集下M的最大取值就是32766,utf8字符集下M的最大取值就是21844,这都是在表中只有一个字段的情况下说的,一定要记住一个行中的所有列(不包括隐藏列和记录头信息)占用的字节长度加起来不能超过65535个字节! ```\n记录中的数据太多产生的溢出 # 我们以ascii字符集下的varchar_size_demo表为例,插入一条记录: ``` mysql\u0026gt; CREATE TABLE varchar_size_demo( -\u0026gt; c VARCHAR 65532)−\u0026gt;65532) -\u0026gt; CHARSET=ascii ROW_FORMAT=Compact; Query OK, 0 rows affected (0.01 sec)\nmysql\u0026gt; INSERT INTO varchar_size_demo(c) VALUES(REPEAT ′a′,65532)\u0026#x27;a\u0026#x27;, 65532) ; Query OK, 1 row affected (0.00 sec) ``` 其中的REPEAT('a', 65532)是一个函数调用,它表示生成一个把字符'a'重复65532次的字符串。前面说过,MySQL中磁盘和内存交互的基本单位是页,也就是说MySQL是以页为基本单位来管理存储空间的,我们的记录都会被分配到某个页中存储。而一个页的大小一般是16KB,也就是16384字节,而一个VARCHAR(M)类型的列就最多可以存储65532个字节,这样就可能造成一个页存放不了一条记录的尴尬情况。\n在Compact和Reduntant行格式中,对于占用存储空间非常大的列,在记录的真实数据处只会存储该列的一部分数据,把剩余的数据分散存储在几个其他的页中,然后记录的真实数据处用20个字节存储指向这些页的地址(当然这20个字节中还包括这些分散在其他页面中的数据的占用的字节数),从而可以找到剩余数据所在的页,如图所示:\n从图中可以看出来,对于Compact和Reduntant行格式来说,如果某一列中的数据非常多的话,在本记录的真实数据处只会存储该列的前768个字节的数据和一个指向其他页的地址,然后把剩下的数据存放到其他页中,这个过程也叫做行溢出,存储超出768字节的那些页面也被称为溢出页。画一个简图就是这样:\n最后需要注意的是,不只是 VARCHAR(M) 类型的列,其他的 TEXT、BLOB 类型的列在存储数据非常多的时候也会发生行溢出。\n行溢出的临界点 # 那发生行溢出的临界点是什么呢?也就是说在列存储多少字节的数据时就会发生行溢出?\nMySQL中规定一个页中至少存放两行记录,至于为什么这么规定我们之后再说,现在看一下这个规定造成的影响。以上面的varchar_size_demo表为例,它只有一个列c,我们往这个表中插入两条记录,每条记录最少插入多少字节的数据才会行溢出的现象呢?这得分析一下页中的空间都是如何利用的。\n每个页除了存放我们的记录以外,也需要存储一些额外的信息,乱七八糟的额外信息加起来需要136个字节的空间(现在只要知道这个数字就好了),其他的空间都可以被用来存储记录。\n每个记录需要的额外信息是27字节。\n这27个字节包括下面这些部分: - 2个字节用于存储真实数据的长度 - 1个字节用于存储列是否是NULL值 - 5个字节大小的头信息 - 6个字节的row_id列 - 6个字节的transaction_id列 - 7个字节的roll_pointer列\n假设一个列中存储的数据字节数为n,那么发生行溢出现象时需要满足这个式子: 136 + 2×(27 + n) \u0026gt; 16384 求解这个式子得出的解是:n \u0026gt; 8098。也就是说如果一个列中存储的数据不大于8098个字节,那就不会发生行溢出,否则就会发生行溢出。不过这个8098个字节的结论只是针对只有一个列的varchar_size_demo表来说的,如果表中有多个列,那上面的式子和结论都需要改一改了,所以重点就是:你不用关注这个临界点是什么,只要知道如果我们向一个行中存储了很大的数据时,可能发生行溢出的现象。\nDynamic和Compressed行格式 # 下面要介绍另外两个行格式,Dynamic和Compressed行格式,我现在使用的MySQL版本是5.7,它的默认行格式就是Dynamic,这俩行格式和Compact行格式挺像,只不过在处理行溢出数据时有点儿分歧,它们不会在记录的真实数据处存储字段真实数据的前768个字节,而是把所有的字节都存储到其他页面中,只在记录的真实数据处存储其他页面的地址,就像这样:\nCompressed行格式和Dynamic不同的一点是,Compressed行格式会采用压缩算法对页面进行压缩,以节省空间。\n总结 # 页是MySQL中磁盘和内存交互的基本单位,也是MySQL是管理存储空间的基本单位。\n指定和修改行格式的语法如下:\nALTER TABLE 表名 ROW_FORMAT=行格式名称 ``` 3.`InnoDB`目前定义了4种行格式 + COMPACT行格式 具体组成如图: ![](img/04-19.png) + Redundant行格式 具体组成如图: ![](img/04-20.png) + Dynamic和Compressed行格式 这两种行格式类似于`COMPACT行格式`,只不过在处理行溢出数据时有点儿分歧,它们不会在记录的真实数据处存储字符串的前768个字节,而是把所有的字节都存储到其他页面中,只在记录的真实数据处存储其他页面的地址。 另外,`Compressed`行格式会采用压缩算法对页面进行压缩。 3. 一个页一般是`16KB`,当记录中的数据太多,当前页放不下的时候,会把多余的数据存储到其他页中,这种现象称为`行溢出`。 "},{"id":29,"href":"/zh/docs/technology/MySQL/MySQL%E6%98%AF%E6%80%8E%E6%A0%B7%E8%BF%90%E8%A1%8C%E7%9A%84/%E7%AC%AC3%E7%AB%A0_%E4%B9%B1%E7%A0%81%E7%9A%84%E5%89%8D%E4%B8%96%E4%BB%8A%E7%94%9F-%E5%AD%97%E7%AC%A6%E9%9B%86%E5%92%8C%E6%AF%94%E8%BE%83%E8%A7%84%E5%88%99/","title":"第3章_乱码的前世今生-字符集和比较规则","section":"My Sql是怎样运行的","content":"第3章 乱码的前世今生-字符集和比较规则\n字符集和比较规则简介 # 字符集简介 # 我们知道在计算机中只能存储二进制数据,那该怎么存储字符串呢?当然是建立字符与二进制数据的映射关系了,建立这个关系最起码要搞清楚两件事儿:\n你要把哪些字符映射成二进制数据? 也就是界定清楚字符范围。 怎么映射? 将一个字符映射成一个二进制数据的过程也叫做编码,将一个二进制数据映射到一个字符的过程叫做解码。 人们抽象出一个字符集的概念来描述某个字符范围的编码规则。比方说我们来自定义一个名称为xiaohaizi的字符集,它包含的字符范围和编码规则如下:\n包含字符'a'、'b'、'A'、'B'。\n编码规则如下:\n采用1个字节编码一个字符的形式,字符和字节的映射关系如下: 'a' -\u0026gt; 00000001 (十六进制:0x01) 'b' -\u0026gt; 00000010 (十六进制:0x02) 'A' -\u0026gt; 00000011 (十六进制:0x03) 'B' -\u0026gt; 00000100 (十六进制:0x04)\n有了xiaohaizi字符集,我们就可以用二进制形式表示一些字符串了,下面是一些字符串用xiaohaizi字符集编码后的二进制表示: 'bA' -\u0026gt; 0000001000000011 (十六进制:0x0203) 'baB' -\u0026gt; 000000100000000100000100 (十六进制:0x020104) 'cd' -\u0026gt; 无法表示,字符集xiaohaizi不包含字符'c'和'd'\n比较规则简介 # 在我们确定了xiaohaizi字符集表示字符的范围以及编码规则后,怎么比较两个字符的大小呢?最容易想到的就是直接比较这两个字符对应的二进制编码的大小,比方说字符'a'的编码为0x01,字符'b'的编码为0x02,所以'a'小于'b',这种简单的比较规则也可以被称为二进制比较规则,英文名为binary collation。\n二进制比较规则是简单,但有时候并不符合现实需求,比如在很多场合对于英文字符我们都是不区分大小写的,也就是说'a'和'A'是相等的,在这种场合下就不能简单粗暴的使用二进制比较规则了,这时候我们可以这样指定比较规则:\n将两个大小写不同的字符全都转为大写或者小写。 再比较这两个字符对应的二进制数据。 这是一种稍微复杂一点点的比较规则,但是实际生活中的字符不止英文字符一种,比如我们的汉字有几万之多,对于某一种字符集来说,比较两个字符大小的规则可以制定出很多种,也就是说同一种字符集可以有多种比较规则,我们稍后就要介绍各种现实生活中用的字符集以及它们的一些比较规则。\n一些重要的字符集 # 不幸的是,这个世界太大了,不同的人制定出了好多种字符集,它们表示的字符范围和用到的编码规则可能都不一样。我们看一下一些常用字符集的情况:\nASCII字符集\n共收录128个字符,包括空格、标点符号、数字、大小写字母和一些不可见字符。由于总共才128个字符,所以可以使用1个字节来进行编码,我们看一些字符的编码方式: 'L' -\u0026gt; 01001100(十六进制:0x4C,十进制:76) 'M' -\u0026gt; 01001101(十六进制:0x4D,十进制:77)\nISO 8859-1字符集\n共收录256个字符,是在ASCII字符集的基础上又扩充了128个西欧常用字符(包括德法两国的字母),也可以使用1个字节来进行编码。这个字符集也有一个别名latin1。\nGB2312字符集\n收录了汉字以及拉丁字母、希腊字母、日文平假名及片假名字母、俄语西里尔字母。其中收录汉字6763个,其他文字符号682个。同时这种字符集又兼容ASCII字符集,所以在编码方式上显得有些奇怪:\n如果该字符在ASCII字符集中,则采用1字节编码。\n否则采用2字节编码。 这种表示一个字符需要的字节数可能不同的编码方式称为变长编码方式。比方说字符串'爱u',其中'爱'需要用2个字节进行编码,编码后的十六进制表示为0xB0AE,'u'需要用1个字节进行编码,编码后的十六进制表示为0x75,所以拼合起来就是0xB0AE75。\n小贴士:我们怎么区分某个字节代表一个单独的字符还是代表某个字符的一部分呢?别忘了ASCII字符集只收录128个字符,使用0~127就可以表示全部字符,所以如果某个字节是在0~127之内的,就意味着一个字节代表一个单独的字符,否则就是两个字节代表一个单独的字符。 - GBK字符集\nGBK字符集只是在收录字符范围上对GB2312字符集作了扩充,编码方式上兼容GB2312。\nutf8字符集 收录地球上能想到的所有字符,而且还在不断扩充。这种字符集兼容ASCII字符集,采用变长编码方式,编码一个字符需要使用1~4个字节,比方说这样: 'L' -\u0026gt; 01001100(十六进制:0x4C) '啊' -\u0026gt; 111001011001010110001010(十六进制:0xE5958A) 小贴士:其实准确的说,utf8只是Unicode字符集的一种编码方案,Unicode字符集可以采用utf8、utf16、utf32这几种编码方案,utf8使用1~4个字节编码一个字符,utf16使用2个或4个字节编码一个字符,utf32使用4个字节编码一个字符。更详细的Unicode和其编码方案的知识不是本书的重点,大家上网查查。MySQL中并不区分字符集和编码方案的概念,所以后边介绍的时候把utf8、utf16、utf32都当作一种字符集对待。\n对于同一个字符,不同字符集也可能有不同的编码方式。比如对于汉字'我'来说,ASCII字符集中根本没有收录这个字符,utf8和gb2312字符集对汉字我的编码方式如下: utf8编码:111001101000100010010001 (3个字节,十六进制表示是:0xE68891) gb2312编码:1100111011010010 (2个字节,十六进制表示是:0xCED2)\nMySQL中支持的字符集和排序规则 # MySQL中的utf8和utf8mb4 # 我们上面说utf8字符集表示一个字符需要使用1~4个字节,但是我们常用的一些字符使用1~3个字节就可以表示了。而在MySQL中字符集表示一个字符所用最大字节长度在某些方面会影响系统的存储和性能,所以设计MySQL的大佬偷偷的定义了两个概念:\nutf8mb3:阉割过的utf8字符集,只使用1~3个字节表示字符。\nutf8mb4:正宗的utf8字符集,使用1~4个字节表示字符。\n有一点需要大家十分的注意,在MySQL中utf8是utf8mb3的别名,所以之后在MySQL中提到utf8就意味着使用1~3个字节来表示一个字符,如果大家有使用4字节编码一个字符的情况,比如存储一些emoji表情什么的,那请使用utf8mb4。\n字符集的查看 # MySQL支持好多好多种字符集,查看当前MySQL中支持的字符集可以用下面这个语句: SHOW (CHARACTER SET|CHARSET) [LIKE 匹配的模式]; 其中CHARACTER SET和CHARSET是同义词,用任意一个都可以。我们查询一下(支持的字符集太多了,我们省略了一些): mysql\u0026gt; SHOW CHARSET; +----------+---------------------------------+---------------------+--------+ | Charset | Description | Default collation | Maxlen | +----------+---------------------------------+---------------------+--------+ | big5 | Big5 Traditional Chinese | big5_chinese_ci | 2 | ... | latin1 | cp1252 West European | latin1_swedish_ci | 1 | | latin2 | ISO 8859-2 Central European | latin2_general_ci | 1 | ... | ascii | US ASCII | ascii_general_ci | 1 | ... | gb2312 | GB2312 Simplified Chinese | gb2312_chinese_ci | 2 | ... | gbk | GBK Simplified Chinese | gbk_chinese_ci | 2 | | latin5 | ISO 8859-9 Turkish | latin5_turkish_ci | 1 | ... | utf8 | UTF-8 Unicode | utf8_general_ci | 3 | | ucs2 | UCS-2 Unicode | ucs2_general_ci | 2 | ... | latin7 | ISO 8859-13 Baltic | latin7_general_ci | 1 | | utf8mb4 | UTF-8 Unicode | utf8mb4_general_ci | 4 | | utf16 | UTF-16 Unicode | utf16_general_ci | 4 | | utf16le | UTF-16LE Unicode | utf16le_general_ci | 4 | ... | utf32 | UTF-32 Unicode | utf32_general_ci | 4 | | binary | Binary pseudo charset | binary | 1 | ... | gb18030 | China National Standard GB18030 | gb18030_chinese_ci | 4 | +----------+---------------------------------+---------------------+--------+ 41 rows in set (0.01 sec) 可以看到,我使用的这个MySQL版本一共支持41种字符集,其中的Default collation列表示这种字符集中一种默认的比较规则。大家注意返回结果中的最后一列Maxlen,它代表该种字符集表示一个字符最多需要几个字节。为了让大家的印象更深刻,我把几个常用到的字符集的Maxlen列摘抄下来,大家务必记住: 字符集名称 Maxlen ascii 1 latin1 1 gb2312 2 gbk 2 utf8 3 utf8mb4 4\n比较规则的查看 # 查看MySQL中支持的比较规则的命令如下: SHOW COLLATION [LIKE 匹配的模式]; 我们前面说过一种字符集可能对应着若干种比较规则,MySQL支持的字符集就已经非常多了,所以支持的比较规则更多,我们先只查看一下utf8字符集下的比较规则: mysql\u0026gt; SHOW COLLATION LIKE 'utf8_%'; +--------------------------+---------+-----+---------+----------+---------+ | Collation | Charset | Id | Default | Compiled | Sortlen | +--------------------------+---------+-----+---------+----------+---------+ | utf8_general_ci | utf8 | 33 | Yes | Yes | 1 | | utf8_bin | utf8 | 83 | | Yes | 1 | | utf8_unicode_ci | utf8 | 192 | | Yes | 8 | | utf8_icelandic_ci | utf8 | 193 | | Yes | 8 | | utf8_latvian_ci | utf8 | 194 | | Yes | 8 | | utf8_romanian_ci | utf8 | 195 | | Yes | 8 | | utf8_slovenian_ci | utf8 | 196 | | Yes | 8 | | utf8_polish_ci | utf8 | 197 | | Yes | 8 | | utf8_estonian_ci | utf8 | 198 | | Yes | 8 | | utf8_spanish_ci | utf8 | 199 | | Yes | 8 | | utf8_swedish_ci | utf8 | 200 | | Yes | 8 | | utf8_turkish_ci | utf8 | 201 | | Yes | 8 | | utf8_czech_ci | utf8 | 202 | | Yes | 8 | | utf8_danish_ci | utf8 | 203 | | Yes | 8 | | utf8_lithuanian_ci | utf8 | 204 | | Yes | 8 | | utf8_slovak_ci | utf8 | 205 | | Yes | 8 | | utf8_spanish2_ci | utf8 | 206 | | Yes | 8 | | utf8_roman_ci | utf8 | 207 | | Yes | 8 | | utf8_persian_ci | utf8 | 208 | | Yes | 8 | | utf8_esperanto_ci | utf8 | 209 | | Yes | 8 | | utf8_hungarian_ci | utf8 | 210 | | Yes | 8 | | utf8_sinhala_ci | utf8 | 211 | | Yes | 8 | | utf8_german2_ci | utf8 | 212 | | Yes | 8 | | utf8_croatian_ci | utf8 | 213 | | Yes | 8 | | utf8_unicode_520_ci | utf8 | 214 | | Yes | 8 | | utf8_vietnamese_ci | utf8 | 215 | | Yes | 8 | | utf8_general_mysql500_ci | utf8 | 223 | | Yes | 1 | +--------------------------+---------+-----+---------+----------+---------+ 27 rows in set (0.00 sec) 这些比较规则的命名还挺有规律的,具体规律如下:\n比较规则名称以与其关联的字符集的名称开头。如上图的查询结果的比较规则名称都是以utf8开头的。\n后边紧跟着该比较规则主要作用于哪种语言,比如utf8_polish_ci表示以波兰语的规则比较,utf8_spanish_ci是以西班牙语的规则比较,utf8_general_ci是一种通用的比较规则。\n名称后缀意味着该比较规则是否区分语言中的重音、大小写什么的,具体可以用的值如下:\n后缀 英文释义 描述 `_ai` `accent insensitive` 不区分重音 `_as` `accent sensitive` 区分重音 `_ci` `case insensitive` 不区分大小写 `_cs` `case sensitive` 区分大小写 `_bin` `binary` 以二进制方式比较 比如`utf8_general_ci`这个比较规则是以`ci`结尾的,说明不区分大小写。 每种字符集对应若干种比较规则,每种字符集都有一种默认的比较规则,SHOW COLLATION的返回结果中的Default列的值为YES的就是该字符集的默认比较规则,比方说utf8字符集默认的比较规则就是utf8_general_ci。\n字符集和比较规则的应用 # 各级别的字符集和比较规则 # MySQL有4个级别的字符集和比较规则,分别是:\n服务器级别 数据库级别 表级别 列级别 我们接下来仔细看一下怎么设置和查看这几个级别的字符集和比较规则。\n服务器级别 # MySQL提供了两个系统变量来表示服务器级别的字符集和比较规则: 系统变量 描述 character_set_server 服务器级别的字符集 collation_server 服务器级别的比较规则 我们看一下这两个系统变量的值: ``` mysql\u0026gt; SHOW VARIABLES LIKE \u0026lsquo;character_set_server\u0026rsquo;; +\u0026mdash;\u0026mdash;\u0026mdash;\u0026mdash;\u0026mdash;\u0026mdash;\u0026mdash;-+\u0026mdash;\u0026mdash;-+ | Variable_name | Value | +\u0026mdash;\u0026mdash;\u0026mdash;\u0026mdash;\u0026mdash;\u0026mdash;\u0026mdash;-+\u0026mdash;\u0026mdash;-+ | character_set_server | utf8 | +\u0026mdash;\u0026mdash;\u0026mdash;\u0026mdash;\u0026mdash;\u0026mdash;\u0026mdash;-+\u0026mdash;\u0026mdash;-+ 1 row in set (0.00 sec)\nmysql\u0026gt; SHOW VARIABLES LIKE \u0026lsquo;collation_server\u0026rsquo;; +\u0026mdash;\u0026mdash;\u0026mdash;\u0026mdash;\u0026mdash;\u0026mdash;+\u0026mdash;\u0026mdash;\u0026mdash;\u0026mdash;\u0026mdash;\u0026ndash;+ | Variable_name | Value | +\u0026mdash;\u0026mdash;\u0026mdash;\u0026mdash;\u0026mdash;\u0026mdash;+\u0026mdash;\u0026mdash;\u0026mdash;\u0026mdash;\u0026mdash;\u0026ndash;+ | collation_server | utf8_general_ci | +\u0026mdash;\u0026mdash;\u0026mdash;\u0026mdash;\u0026mdash;\u0026mdash;+\u0026mdash;\u0026mdash;\u0026mdash;\u0026mdash;\u0026mdash;\u0026ndash;+ 1 row in set (0.00 sec)\n``` 可以看到在我的计算机中服务器级别默认的字符集是utf8,默认的比较规则是utf8_general_ci。\n我们可以在启动服务器程序时通过启动选项或者在服务器程序运行过程中使用SET语句修改这两个变量的值。比如我们可以在配置文件中这样写: [server] character_set_server=gbk collation_server=gbk_chinese_ci 当服务器启动的时候读取这个配置文件后这两个系统变量的值便修改了。\n数据库级别 # \\[DEFAULT] CHARACTER SET 字符集名称\\]\\[DEFAULT] COLLATE 比较规则名称\\];\n\\[DEFAULT] CHARACTER SET 字符集名称\\]\\[DEFAULT] COLLATE 比较规则名称\\]; 其中的DEFAULT可以省略,并不影响语句的语义。比方说我们新创建一个名叫charset_demo_db的数据库,在创建的时候指定它使用的字符集为gb2312,比较规则为gb2312_chinese_ci: mysql\u0026gt; CREATE DATABASE charset_demo_db -\u0026gt; CHARACTER SET gb2312 -\u0026gt; COLLATE gb2312_chinese_ci; Query OK, 1 row affected (0.01 sec) 如果想查看当前数据库使用的字符集和比较规则,可以查看下面两个系统变量的值(前提是使用`USE`语句选择当前默认数据库,如果没有默认数据库,则变量与相应的服务器级系统变量具有相同的值): 系统变量 描述 `character_set_database` 当前数据库的字符集 `collation_database` 当前数据库的比较规则 我们来查看一下刚刚创建的`charset_demo_db`数据库的字符集和比较规则: mysql\u0026gt; USE charset_demo_db; Database changed\nmysql\u0026gt; SHOW VARIABLES LIKE \u0026lsquo;character_set_database\u0026rsquo;; +\u0026mdash;\u0026mdash;\u0026mdash;\u0026mdash;\u0026mdash;\u0026mdash;\u0026mdash;\u0026mdash;+\u0026mdash;\u0026mdash;\u0026ndash;+ | Variable_name | Value | +\u0026mdash;\u0026mdash;\u0026mdash;\u0026mdash;\u0026mdash;\u0026mdash;\u0026mdash;\u0026mdash;+\u0026mdash;\u0026mdash;\u0026ndash;+ | character_set_database | gb2312 | +\u0026mdash;\u0026mdash;\u0026mdash;\u0026mdash;\u0026mdash;\u0026mdash;\u0026mdash;\u0026mdash;+\u0026mdash;\u0026mdash;\u0026ndash;+ 1 row in set (0.00 sec)\nmysql\u0026gt; SHOW VARIABLES LIKE \u0026lsquo;collation_database\u0026rsquo;; +\u0026mdash;\u0026mdash;\u0026mdash;\u0026mdash;\u0026mdash;\u0026mdash;\u0026ndash;+\u0026mdash;\u0026mdash;\u0026mdash;\u0026mdash;\u0026mdash;\u0026mdash;-+ | Variable_name | Value | +\u0026mdash;\u0026mdash;\u0026mdash;\u0026mdash;\u0026mdash;\u0026mdash;\u0026ndash;+\u0026mdash;\u0026mdash;\u0026mdash;\u0026mdash;\u0026mdash;\u0026mdash;-+ | collation_database | gb2312_chinese_ci | +\u0026mdash;\u0026mdash;\u0026mdash;\u0026mdash;\u0026mdash;\u0026mdash;\u0026ndash;+\u0026mdash;\u0026mdash;\u0026mdash;\u0026mdash;\u0026mdash;\u0026mdash;-+ 1 row in set (0.00 sec)\nmysql\u0026gt; ``` 可以看到这个charset_demo_db数据库的字符集和比较规则就是我们在创建语句中指定的。需要注意的一点是:** character_set_database 和 collation_database 这两个系统变量是只读的,我们不能通过修改这两个变量的值而改变当前数据库的字符集和比较规则**。\n数据库的创建语句中也可以不指定字符集和比较规则,比如这样: CREATE DATABASE 数据库名; 这样的话,将使用服务器级别的字符集和比较规则作为数据库的字符集和比较规则。\n表级别 # \\[DEFAULT] CHARACTER SET 字符集名称\\] [COLLATE 比较规则名称]]\n\\[DEFAULT] CHARACTER SET 字符集名称\\] [COLLATE 比较规则名称] 比方说我们在刚刚创建的charset_demo_db数据库中创建一个名为t的表,并指定这个表的字符集和比较规则: mysql\u0026gt; CREATE TABLE t( -\u0026gt; col VARCHAR 10)−\u0026gt;10) -\u0026gt; CHARACTER SET utf8 COLLATE utf8_general_ci; Query OK, 0 rows affected (0.03 sec) 如果创建和修改表的语句中没有指明字符集和比较规则,**将使用该表所在数据库的字符集和比较规则作为该表的字符集和比较规则**。假设我们的创建表t的语句是这么写的: CREATE TABLE t( col VARCHAR 10)10) ; ``` 因为表t的建表语句中并没有明确指定字符集和比较规则,则表t的字符集和比较规则将继承所在数据库charset_demo_db的字符集和比较规则,也就是gb2312和gb2312_chinese_ci。\n列级别 # 需要注意的是,对于存储字符串的列,同一个表中的不同的列也可以有不同的字符集和比较规则。我们在创建和修改列定义的时候可以指定该列的字符集和比较规则,语法如下: ``` CREATE TABLE 表名( 列名 字符串类型 [CHARACTER SET 字符集名称] [COLLATE 比较规则名称], 其他列\u0026hellip; );\nALTER TABLE 表名 MODIFY 列名 字符串类型 [CHARACTER SET 字符集名称] [COLLATE 比较规则名称]; 比如我们修改一下表t中列col的字符集和比较规则可以这么写: mysql\u0026gt; ALTER TABLE t MODIFY col VARCHAR(10) CHARACTER SET gbk COLLATE gbk_chinese_ci; Query OK, 0 rows affected (0.04 sec) Records: 0 Duplicates: 0 Warnings: 0\nmysql\u0026gt; 对于某个列来说,如果在创建和修改的语句中没有指明字符集和比较规则,**将使用该列所在表的字符集和比较规则作为该列的字符集和比较规则**。比方说表t的字符集是utf8,比较规则是utf8_general_ci,修改列col的语句是这么写的: ALTER TABLE t MODIFY col VARCHAR(10); 那列col的字符集和编码将使用表t的字符集和比较规则,也就是utf8和utf8_general_ci。 小贴士:在转换列的字符集时需要注意,如果转换前列中存储的数据不能用转换后的字符集进行表示,就会发生错误。比方说原先列使用的字符集是utf8,列中存储了一些汉字,现在把列的字符集转换为ascii的话就会出错,因为ascii字符集并不能表示汉字字符。 ```\n仅修改字符集或仅修改比较规则 # 由于字符集和比较规则是互相有联系的,如果我们只修改了字符集,比较规则也会跟着变化,如果只修改了比较规则,字符集也会跟着变化,具体规则如下:\n只修改字符集,则比较规则将变为修改后的字符集默认的比较规则。 只修改比较规则,则字符集将变为修改后的比较规则对应的字符集。 不论哪个级别的字符集和比较规则,这两条规则都适用,我们以服务器级别的字符集和比较规则为例来看一下详细过程:\n只修改字符集,则比较规则将变为修改后的字符集默认的比较规则。 ``` mysql\u0026gt; SET character_set_server = gb2312; Query OK, 0 rows affected (0.00 sec)\nmysql\u0026gt; SHOW VARIABLES LIKE \u0026lsquo;character_set_server\u0026rsquo;; +\u0026mdash;\u0026mdash;\u0026mdash;\u0026mdash;\u0026mdash;\u0026mdash;\u0026mdash;-+\u0026mdash;\u0026mdash;\u0026ndash;+ | Variable_name | Value | +\u0026mdash;\u0026mdash;\u0026mdash;\u0026mdash;\u0026mdash;\u0026mdash;\u0026mdash;-+\u0026mdash;\u0026mdash;\u0026ndash;+ | character_set_server | gb2312 | +\u0026mdash;\u0026mdash;\u0026mdash;\u0026mdash;\u0026mdash;\u0026mdash;\u0026mdash;-+\u0026mdash;\u0026mdash;\u0026ndash;+ 1 row in set (0.00 sec)\nmysql\u0026gt; SHOW VARIABLES LIKE \u0026lsquo;collation_server\u0026rsquo;; +\u0026mdash;\u0026mdash;\u0026mdash;\u0026mdash;\u0026mdash;\u0026mdash;+\u0026mdash;\u0026mdash;\u0026mdash;\u0026mdash;\u0026mdash;\u0026mdash;-+ | Variable_name | Value | +\u0026mdash;\u0026mdash;\u0026mdash;\u0026mdash;\u0026mdash;\u0026mdash;+\u0026mdash;\u0026mdash;\u0026mdash;\u0026mdash;\u0026mdash;\u0026mdash;-+ | collation_server | gb2312_chinese_ci | +\u0026mdash;\u0026mdash;\u0026mdash;\u0026mdash;\u0026mdash;\u0026mdash;+\u0026mdash;\u0026mdash;\u0026mdash;\u0026mdash;\u0026mdash;\u0026mdash;-+ 1 row in set (0.00 sec) ``` 我们只修改了character_set_server的值为gb2312,collation_server的值自动变为了gb2312_chinese_ci。\n只修改比较规则,则字符集将变为修改后的比较规则对应的字符集。\nmysql\u0026gt; SHOW VARIABLES LIKE \u0026#39;character_set_server\u0026#39;; \\+----------------------\\+-------\\+ | Variable_name | Value | \\+----------------------\\+-------\\+ | character_set_server | utf8 | \\+----------------------\\+-------\\+ 1 row in set (0.00 sec) mysql\u0026gt; SHOW VARIABLES LIKE \u0026#39;collation_server\u0026#39;; \\+------------------\\+-----------------\\+ | Variable_name | Value | \\+------------------\\+-----------------\\+ | collation_server | utf8_general_ci | \\+------------------\\+-----------------\\+ 1 row in set (0.00 sec) mysql\u0026gt; ``` 我们只修改了`collation_server`的值为`utf8_general_ci`,`character_set_server`的值自动变为了`utf8`。 ### 各级别字符集和比较规则小结 我们介绍的这4个级别字符集和比较规则的联系如下: + 如果创建或修改列时,没有显式的指定字符集和比较规则,则该列默认用表的字符集和比较规则 + 如果创建或修改表时,没有显式的指定字符集和比较规则,则该表默认用数据库的字符集和比较规则 + 如果创建或修改数据库时,没有显式的指定字符集和比较规则,则该数据库默认用服务器的字符集和比较规则 知道了这些规则之后,对于给定的表,我们应该知道它的各个列的字符集和比较规则是什么,从而根据这个列的类型来确定存储数据时每个列的实际数据占用的存储空间大小了。比方说我们向表`t`中插入一条记录: ``` mysql\u0026gt; INSERT INTO t(col) VALUES(\u0026#39;我我\u0026#39;); Query OK, 1 row affected (0.00 sec) mysql\u0026gt; SELECT * FROM t; \\+--------\\+ | s | \\+--------\\+ | 我我 | \\+--------\\+ 1 row in set (0.00 sec) ``` 首先列`col`使用的字符集是`gbk`,一个字符`\u0026#39;我\u0026#39;`在`gbk`中的编码为`0xCED2`,占用两个字节,两个字符的实际数据就占用4个字节。如果把该列的字符集修改为`utf8`的话,这两个字符就实际占用6个字节。 ## 客户端和服务器通信中的字符集 ### 编码和解码使用的字符集不一致的后果 说到底,字符串在计算机上的体现就是一个字节串,如果你使用不同字符集去解码这个字节串,最后得到的结果可能让你挠头。 我们知道字符`\u0026#39;我\u0026#39;`在`utf8`字符集编码下的字节串长这样:`0xE68891`,如果一个程序把这个字节串发送到另一个程序里,另一个程序用不同的字符集去解码这个字节串,假设使用的是`gbk`字符集来解释这串字节,解码过程就是这样的: 1. 首先看第一个字节`0xE6`,它的值大于`0x7F`(十进制:127),说明是两字节编码,继续读一字节后是`0xE688`,然后从`gbk`编码表中查找字节为`0xE688`对应的字符,发现是字符`\u0026#39;鎴\u0026#39;` 2. 继续读一个字节`0x91`,它的值也大于`0x7F`,再往后读一个字节发现木有了,所以这是半个字符。 3. 所以`0xE68891`被`gbk`字符集解释成一个字符`\u0026#39;鎴\u0026#39;`和半个字符。 假设用`iso-8859-1`,也就是`latin1`字符集去解释这串字节,解码过程如下: 1. 先读第一个字节`0xE6`,它对应的`latin1`字符为`æ`。 2. 再读第二个字节`0x88`,它对应的`latin1`字符为`ˆ`。 3. 再读第二个字节`0x91`,它对应的`latin1`字符为`‘`。 4. 所以整串字节`0xE68891`被`latin1`字符集解释后的字符串就是`\u0026#39;我\u0026#39;` 可见,**如果对于同一个字符串编码和解码使用的字符集不一样,会产生意想不到的结果**,作为人类的我们看上去就像是产生了乱码一样。 ### 字符集转换的概念 如果接收`0xE68891`这个字节串的程序按照`utf8`字符集进行解码,然后又把它按照`gbk`字符集进行编码,最后编码后的字节串就是`0xCED2`,我们把这个过程称为`字符集的转换`,也就是字符串`\u0026#39;我\u0026#39;`从`utf8`字符集转换为`gbk`字符集。 ### MySQL中字符集的转换 我们知道从客户端发往服务器的请求本质上就是一个字符串,服务器向客户端返回的结果本质上也是一个字符串,而字符串其实是使用某种字符集编码的二进制数据。这个字符串可不是使用一种字符集的编码方式一条道走到黑的,从发送请求到返回结果这个过程中伴随着多次字符集的转换,在这个过程中会用到3个系统变量,我们先把它们写出来看一下: 系统变量 描述 `character_set_client` 服务器解码请求时使用的字符集 `character_set_connection` 服务器处理请求时会把请求字符串从`character_set_client`转为`character_set_connection` `character_set_results` 服务器向客户端返回数据时使用的字符集 这几个系统变量在我的计算机上的默认值如下(不同操作系统的默认值可能不同): ``` mysql\u0026gt; SHOW VARIABLES LIKE \u0026#39;character_set_client\u0026#39;; \\+----------------------\\+-------\\+ | Variable_name | Value | \\+----------------------\\+-------\\+ | character_set_client | utf8 | \\+----------------------\\+-------\\+ 1 row in set (0.00 sec) mysql\u0026gt; SHOW VARIABLES LIKE \u0026#39;character_set_connection\u0026#39;; \\+--------------------------\\+-------\\+ | Variable_name | Value | \\+--------------------------\\+-------\\+ | character_set_connection | utf8 | \\+--------------------------\\+-------\\+ 1 row in set (0.01 sec) mysql\u0026gt; SHOW VARIABLES LIKE \u0026#39;character_set_results\u0026#39;; \\+-----------------------\\+-------\\+ | Variable_name | Value | \\+-----------------------\\+-------\\+ | character_set_results | utf8 | \\+-----------------------\\+-------\\+ 1 row in set (0.00 sec) `大家可以看到这几个系统变量的值都是`utf8`,为了体现出字符集在请求处理过程中的变化,我们这里特意修改一个系统变量的值:` mysql\u0026gt; set character_set_connection = gbk; Query OK, 0 rows affected (0.00 sec) `所以现在系统变量`character_set_client`和`character_set_results`的值还是`utf8`,而`character_set_connection`的值为`gbk`。现在假设我们客户端发送的请求是下面这个字符串:` SELECT * FROM t WHERE s = \u0026#39;我\u0026#39;; ``` 为了方便大家理解这个过程,我们只分析字符`\u0026#39;我\u0026#39;`在这个过程中字符集的转换。 现在看一下在请求从发送到结果返回过程中字符集的变化: 1. 客户端发送请求所使用的字符集 一般情况下客户端所使用的字符集和当前操作系统一致,不同操作系统使用的字符集可能不一样,如下: + 类`Unix`系统使用的是`utf8` + `Windows`使用的是`gbk` 例如我在使用的`macOS`操作系统时,客户端使用的就是`utf8`字符集。所以字符`\u0026#39;我\u0026#39;`在发送给服务器的请求中的字节形式就是:`0xE68891` `小贴士:如果你使用的是可视化工具,比如navicat之类的,这些工具可能会使用自定义的字符集来编码发送到服务器的字符串,而不采用操作系统默认的字符集(所以在学习的时候还是尽量用黑框框)。` 1. 服务器接收到客户端发送来的请求其实是一串二进制的字节,它会认为这串字节采用的字符集是`character_set_client`,然后把这串字节转换为`character_set_connection`字符集编码的字符。 由于我的计算机上`character_set_client`的值是`utf8`,首先会按照`utf8`字符集对字节串`0xE68891`进行解码,得到的字符串就是`\u0026#39;我\u0026#39;`,然后按照`character_set_connection`代表的字符集,也就是`gbk`进行编码,得到的结果就是字节串`0xCED2`。 2. 因为表`t`的列`col`采用的是`gbk`字符集,与`character_set_connection`一致,所以直接到列中找字节值为`0xCED2`的记录,最后找到了一条记录。 `小贴士:如果某个列使用的字符集和character_set_connection代表的字符集不一致的话,还需要进行一次字符集转换。` 1. 上一步骤找到的记录中的`col`列其实是一个字节串`0xCED2`,`col`列是采用`gbk`进行编码的,所以首先会将这个字节串使用`gbk`进行解码,得到字符串`\u0026#39;我\u0026#39;`,然后再把这个字符串使用`character_set_results`代表的字符集,也就是`utf8`进行编码,得到了新的字节串:`0xE68891`,然后发送给客户端。 2. 由于客户端是用的字符集是`utf8`,所以可以顺利的将`0xE68891`解释成字符`我`,从而显示到我们的显示器上,所以我们人类也读懂了返回的结果。 如果你读上面的文字有点晕,可以参照这个图来仔细分析一下这几个步骤: ![](img/03-01.png) 从这个分析中我们可以得出这么几点需要注意的地方: + 服务器认为客户端发送过来的请求是用`character_set_client`编码的。 **假设你的客户端采用的字符集和 ***character_set_client*** 不一样的话,这就会出现意想不到的情况**。比如我的客户端使用的是`utf8`字符集,如果把系统变量`character_set_client`的值设置为`ascii`的话,服务器可能无法理解我们发送的请求,更别谈处理这个请求了。 + 服务器将把得到的结果集使用`character_set_results`编码后发送给客户端。 **假设你的客户端采用的字符集和 ***character_set_results*** 不一样的话,这就可能会出现客户端无法解码结果集的情况**,结果就是在你的屏幕上出现乱码。比如我的客户端使用的是`utf8`字符集,如果把系统变量`character_set_results`的值设置为`ascii`的话,可能会产生乱码。 + `character_set_connection`只是服务器在将请求的字节串从`character_set_client`转换为`character_set_connection`时使用,它是什么其实没多重要,但是一定要注意,**该字符集包含的字符范围一定涵盖请求中的字符**,要不然会导致有的字符无法使用`character_set_connection`代表的字符集进行编码。比如你把`character_set_client`设置为`utf8`,把`character_set_connection`设置成`ascii`,那么此时你如果从客户端发送一个汉字到服务器,那么服务器无法使用`ascii`字符集来编码这个汉字,就会向用户发出一个警告。 知道了在`MySQL`中从发送请求到返回结果过程里发生的各种字符集转换,但是为什么要转来转去的呢?不晕么? 答:是的,很头晕,所以**我们通常都把 ***character_set_client*** 、***character_set_connection***、***character_set_results*** 这三个系统变量设置成和客户端使用的字符集一致的情况,这样减少了很多无谓的字符集转换**。为了方便我们设置,`MySQL`提供了一条非常简便的语句: `SET NAMES 字符集名;` 这一条语句产生的效果和我们执行这3条的效果是一样的: `SET character_set_client = 字符集名; SET character_set_connection = 字符集名; SET character_set_results = 字符集名;` 比方说我的客户端使用的是`utf8`字符集,所以需要把这几个系统变量的值都设置为`utf8`: ``` mysql\u0026gt; SET NAMES utf8; Query OK, 0 rows affected (0.00 sec) mysql\u0026gt; SHOW VARIABLES LIKE \u0026#39;character_set_client\u0026#39;; \\+----------------------\\+-------\\+ | Variable_name | Value | \\+----------------------\\+-------\\+ | character_set_client | utf8 | \\+----------------------\\+-------\\+ 1 row in set (0.00 sec) mysql\u0026gt; SHOW VARIABLES LIKE \u0026#39;character_set_connection\u0026#39;; \\+--------------------------\\+-------\\+ | Variable_name | Value | \\+--------------------------\\+-------\\+ | character_set_connection | utf8 | \\+--------------------------\\+-------\\+ 1 row in set (0.00 sec) mysql\u0026gt; SHOW VARIABLES LIKE \u0026#39;character_set_results\u0026#39;; \\+-----------------------\\+-------\\+ | Variable_name | Value | \\+-----------------------\\+-------\\+ | character_set_results | utf8 | \\+-----------------------\\+-------\\+ 1 row in set (0.00 sec) mysql\u0026gt; `` 小贴士:如果你使用的是Windows系统,那应该设置成gbk。 `另外,如果你想在启动客户端的时候就把`character_set_client`、`character_set_connection`、`character_set_results`这三个系统变量的值设置成一样的,那我们可以在启动客户端的时候指定一个叫`default-character-set`的启动选项,比如在配置文件里可以这么写:` [client] default-character-set=utf8 ``` 它起到的效果和执行一遍`SET NAMES utf8`是一样一样的,都会将那三个系统变量的值设置成`utf8`。 ## 比较规则的应用 结束了字符集的漫游,我们把视角再次聚焦到`比较规则`,`比较规则`的作用通常体现比较字符串大小的表达式以及对某个字符串列进行排序中,所以有时候也称为`排序规则`。比方说表`t`的列`col`使用的字符集是`gbk`,使用的比较规则是`gbk_chinese_ci`,我们向里边插入几条记录: ``` mysql\u0026gt; INSERT INTO t(col) VALUES(\u0026#39;a\u0026#39;), (\u0026#39;b\u0026#39;), (\u0026#39;A\u0026#39;), (\u0026#39;B\u0026#39;); Query OK, 4 rows affected (0.00 sec) Records: 4 Duplicates: 0 Warnings: 0 mysql\u0026gt; `我们查询的时候按照`t`列排序一下:` mysql\u0026gt; SELECT * FROM t ORDER BY col; \\+------\\+ | col | \\+------\\+ | a | | A | | b | | B | | 我 | \\+------\\+ 5 rows in set (0.00 sec) `可以看到在默认的比较规则`gbk_chinese_ci`中是不区分大小写的,我们现在把列`col`的比较规则修改为`gbk_bin`:` mysql\u0026gt; ALTER TABLE t MODIFY col VARCHAR(10) COLLATE gbk_bin; Query OK, 5 rows affected (0.02 sec) Records: 5 Duplicates: 0 Warnings: 0 `由于`gbk_bin`是直接比较字符的编码,所以是区分大小写的,我们再看一下排序后的查询结果:` mysql\u0026gt; SELECT * FROM t ORDER BY s; \\+------\\+ | s | \\+------\\+ | A | | B | | a | | b | | 我 | \\+------\\+ 5 rows in set (0.00 sec) mysql\u0026gt; `所以,如果以后大家在对字符串做比较或者对某个字符串列做排序操作时,没有得到想象中的结果,需要思考一下是不是`比较规则`的问题。` 小贴士: 列col中各个字符在使用gbk字符集编码后对应的数字如下: \u0026#39;A\u0026#39; -\u0026gt; 65 (十进制) \u0026#39;B\u0026#39; -\u0026gt; 66 (十进制) \u0026#39;a\u0026#39; -\u0026gt; 97 (十进制) \u0026#39;b\u0026#39; -\u0026gt; 98 (十进制) \u0026#39;我\u0026#39; -\u0026gt; 25105 (十进制) ``` # 总结 1. `字符集`指的是某个字符范围的编码规则。 2. `比较规则`是针对某个字符集中的字符比较大小的一种规则。 3. 在`MySQL`中,一个字符集可以有若干种比较规则,其中有一个默认的比较规则,一个比较规则必须对应一个字符集。 4. 查看`MySQL`中查看支持的字符集和比较规则的语句如下: `SHOW (CHARACTER SET|CHARSET) [LIKE 匹配的模式]; SHOW COLLATION [LIKE 匹配的模式];` 5. MySQL有四个级别的字符集和比较规则 6. 服务器级别 `character_set_server`表示服务器级别的字符集,`collation_server`表示服务器级别的比较规则。 7. 数据库级别 创建和修改数据库时可以指定字符集和比较规则: ``` CREATE DATABASE 数据库名 [\\[DEFAULT] CHARACTER SET 字符集名称\\] [\\[DEFAULT] COLLATE 比较规则名称\\]; ALTER DATABASE 数据库名 [\\[DEFAULT] CHARACTER SET 字符集名称\\] [\\[DEFAULT] COLLATE 比较规则名称\\]; ````character_set_database`表示当前数据库的字符集,`collation_database`表示当前默认数据库的比较规则,这两个系统变量是只读的,不能修改。如果没有指定当前默认数据库,则变量与相应的服务器级系统变量具有相同的值。 8. 表级别 创建和修改表的时候指定表的字符集和比较规则: ``` CREATE TABLE 表名 (列的信息) [\\[DEFAULT] CHARACTER SET 字符集名称\\] [COLLATE 比较规则名称]\\]; ALTER TABLE 表名 [\\[DEFAULT] CHARACTER SET 字符集名称\\] [COLLATE 比较规则名称]; ``` 9. 列级别 创建和修改列定义的时候可以指定该列的字符集和比较规则: ``` CREATE TABLE 表名( 列名 字符串类型 [CHARACTER SET 字符集名称] [COLLATE 比较规则名称], 其他列... ); ALTER TABLE 表名 MODIFY 列名 字符串类型 [CHARACTER SET 字符集名称] [COLLATE 比较规则名称]; ``` 10. 从发送请求到接收结果过程中发生的字符集转换: + 客户端使用操作系统的字符集编码请求字符串,向服务器发送的是经过编码的一个字节串。 + 服务器将客户端发送来的字节串采用`character_set_client`代表的字符集进行解码,将解码后的字符串再按照`character_set_connection`代表的字符集进行编码。 + 如果`character_set_connection`代表的字符集和具体操作的列使用的字符集一致,则直接进行相应操作,否则的话需要将请求中的字符串从`character_set_connection`代表的字符集转换为具体操作的列使用的字符集之后再进行操作。 + 将从某个列获取到的字节串从该列使用的字符集转换为`character_set_results`代表的字符集后发送到客户端。 + 客户端使用操作系统的字符集解析收到的结果集字节串。 在这个过程中各个系统变量的含义如下: 系统变量 描述 `character_set_client` 服务器解码请求时使用的字符集 `character_set_connection` 服务器处理请求时会把请求字符串从`character_set_client`转为`character_set_connection` `character_set_results` 服务器向客户端返回数据时使用的字符集 一般情况下要使用保持这三个变量的值和客户端使用的字符集相同。 1. 比较规则的作用通常体现比较字符串大小的表达式以及对某个字符串列进行排序中。 "},{"id":30,"href":"/zh/docs/technology/MySQL/MySQL%E6%98%AF%E6%80%8E%E6%A0%B7%E8%BF%90%E8%A1%8C%E7%9A%84/%E7%AC%AC2%E7%AB%A0_MySQL%E7%9A%84%E8%B0%83%E6%8E%A7%E6%8C%89%E9%92%AE-%E5%90%AF%E5%8A%A8%E9%80%89%E9%A1%B9%E5%92%8C%E7%B3%BB%E7%BB%9F%E5%8F%98%E9%87%8F/","title":"第2章_MySQL的调控按钮-启动选项和系统变量","section":"My Sql是怎样运行的","content":"第2章 MySQL的调控按钮-启动选项和系统变量\n如果你用过手机,你的手机上一定有一个设置的功能,你可以选择设置手机的来电铃声、设置音量大小、设置解锁密码等等。假如没有这些设置功能,我们的生活将置于尴尬的境地,比如在图书馆里无法把手机设置为静音,无法把流量开关关掉以节省流量,在别人得知解锁密码后无法更改密码~ MySQL的服务器程序和客户端程序也有很多设置项,比如对于MySQL服务器程序,我们可以指定诸如允许同时连入的客户端数量、客户端和服务器通信方式、表的默认存储引擎、查询缓存的大小等设置项。对于MySQL客户端程序,我们之前已经见识过了,可以指定需要连接的服务器程序所在主机的主机名或IP地址、用户名及密码等信息。\n这些设置项一般都有各自的默认值,比方说服务器允许同时连入的客户端的默认数量是151,表的默认存储引擎是InnoDB,我们可以在程序启动的时候去修改这些默认值,对于这种在程序启动时指定的设置项也称之为启动选项(startup options),这些选项控制着程序启动后的行为。在MySQL安装目录下的bin目录中的各种可执行文件,不论是服务器相关的程序(比如mysqld、mysqld_safe)还是客户端相关的程序(比如mysql、mysqladmin),在启动的时候基本都可以指定启动参数。这些启动参数可以放在命令行中指定,也可以把它们放在配置文件中指定。下面我们以mysqld为例,来详细介绍指定启动选项的格式。需要注意的一点是,我们现在要介绍的是设置启动选项的方式,下面出现的启动选项不论大家认不认识,先不用去纠结每个选项具体的作用是什么,之后我们会对一些重要的启动选项详细介绍。\n在命令行上使用选项 # 如果我们在启动客户端程序时在-h参数后边紧跟服务器的IP地址,这就意味着客户端和服务器之间需要通过TCP/IP网络进行通信。因为我的客户端程序和服务器程序都装在一台计算机上,所以在使用客户端程序连接服务器程序时指定的主机名是127.0.0.1的情况下,客户端进程和服务器进程之间会使用TCP/IP网络进行通信。如果我们在启动服务器程序的时候就禁止各客户端使用TCP/IP网络进行通信,可以在启动服务器程序的命令行里添加skip-networking启动选项,就像这样: mysqld --skip-networking 可以看到,我们在命令行中指定启动选项时需要在选项名前加上--前缀。另外,如果选项名是由多个单词构成的,它们之间可以由短划线-连接起来,也可以使用下划线_连接起来,也就是说skip-networking和skip_networking表示的含义是相同的。所以上面的写法与下面的写法是等价的: mysqld --skip_networking 在按照上述命令启动服务器程序后,如果我们再使用mysql来启动客户端程序时,再把服务器主机名指定为127.0.0.1(IP地址的形式)的话会显示连接失败: ``` mysql -h127.0.0.1 -uroot -p Enter password:\nERROR 2003 (HY000): Can\u0026rsquo;t connect to MySQL server on \u0026lsquo;127.0.0.1\u0026rsquo; (61) ``` 这就意味着我们指定的启动选项skip-networking生效了!\n再举一个例子,我们前面说过如果在创建表的语句中没有显式指定表的存储引擎的话,那就会默认使用InnoDB作为表的存储引擎。如果我们想改变表的默认存储引擎的话,可以这样写启动服务器的命令行: mysqld --default-storage-engine=MyISAM 我们现在就已经把表的默认存储引擎改为MyISAM了,在客户端程序连接到服务器程序后试着创建一个表: mysql\u0026gt; CREATE TABLE sys_var_demo( -\u0026gt; i INT -\u0026gt; ); Query OK, 0 rows affected (0.02 sec) 这个定义语句中我们并没有明确指定表的存储引擎,创建成功后再看一下这个表的结构: mysql\u0026gt; SHOW CREATE TABLE sys_var_demo\\G *************************** 1. row *************************** Table: sys_var_demo Create Table: CREATE TABLE sys_var_demo(i int(11) DEFAULT NULL ) ENGINE=MyISAM DEFAULT CHARSET=utf8 1 row in set (0.01 sec) 可以看到该表的存储引擎已经是MyISAM了,说明启动选项default-storage-engine生效了。\n所以在启动服务器程序的命令行后边指定启动选项的通用格式就是这样的: --启动选项1[=值1] --启动选项2[=值2] ... --启动选项n[=值n] 也就是说我们可以将各个启动选项写到一行中,各个启动选项之间使用空白字符隔开,在每一个启动选项名称前面添加--。对于不需要值的启动选项,比方说skip-networking,它们就不需要指定对应的值。对于需要指定值的启动选项,比如default-storage-engine我们在指定这个设置项的时候需要显式的指定它的值:InnoDB、MyISAM等。在命令行上指定有值的启动选项时需要注意,选项名、=、选项值之间不可以有空白字符,比如写成下面这样就是不正确的: ``` mysqld \u0026ndash;default-storage-engine = MyISAM\n``` 每个MySQL程序都有许多不同的选项。大多数程序提供了一个--help选项,你可以查看该程序支持的全部启动选项以及它们的默认值。例如,使用mysql --help可以看到mysql程序支持的启动选项,mysqld_safe --help可以看到mysqld_safe程序支持的启动选项。查看mysqld支持的启动选项有些特别,需要使用mysqld --verbose --help。\n选项的长形式和短形式 # 我们前面提到的skip-networking、default-storage-engine称之为长形式的选项(因为它们很长),设计MySQL的大佬为了我们使用的方便,对于一些常用的选项提供了短形式,我们列举一些具有短形式的启动选项来看看(MySQL支持的短形式选项太多了,全列出来会刷屏的): 长形式 短形式 含义 --host -h 主机名 --user -u 用户名 --password -p 密码 --port -P 端口 --version -V 版本信息 短形式的选项名只有一个字母,与使用长形式选项时需要在选项名前加两个短划线--不同的是,使用短形式选项时在选项名前只加一个短划线-前缀。有一些短形式的选项我们之前已经接触过了,比方说我们在启动服务器程序时指定监听的端口号: mysqld -P3307 使用短形式指定启动选项时,选项名和选项值之间可以没有间隙,或者用空白字符隔开(-p选项有些特殊,-p和密码值之间不能有空白字符),也就是说上面的命令形式和下面的是等价的: mysqld -P 3307 另外,选项名是区分大小写的,比如-p和-P选项拥有完全不同的含义,大家需要注意一下。\n配置文件中使用选项 # 在命令行中设置启动选项只对当次启动生效,也就是说如果下一次重启程序的时候我们还想保留这些启动选项的话,还得重复把这些选项写到启动命令行中,这样真的很烦呀!于是设计MySQL的大佬们提出一种配置文件(也称为选项文件)的概念,我们把需要设置的启动选项都写在这个配置文件中,每次启动服务器的时候都从这个文件里加载相应的启动选项。由于这个配置文件可以长久的保存在计算机的硬盘里,所以只需我们配置一次,以后就都不用显式的把启动选项都写在启动命令行中了,所以我们推荐使用配置文件的方式来设置启动选项。\n配置文件的路径 # MySQL程序在启动时会寻找多个路径下的配置文件,这些路径有的是固定的,有的是可以在命令行指定的。根据操作系统的不同,配置文件的路径也有所不同,我们分开看一下。\nWindows操作系统的配置文件 # 在Windows操作系统中,MySQL会按照下列路径来寻找配置文件: 路径名 备注 %WINDIR%\\my.ini, %WINDIR%\\my.cnf C:\\my.ini, C:\\my.cnf BASEDIR\\my.ini, BASEDIR\\my.cnf defaults-extra-file 命令行指定的额外配置文件路径 %APPDATA%\\MySQL\\.mylogin.cnf 登录路径选项(仅限客户端) 在阅读这些Windows操作系统下配置文件路径的时候需要注意一些事情:\n在给定的前三个路径中,配置文件可以使用.ini的扩展名,也可以使用.cnf的扩展名。\n%WINDIR%指的是你机器上Windows目录的位置,通常是C:\\WINDOWS,如果你不确定,可以使用这个命令来查看:\necho %WINDIR%\nBASEDIR指的是MySQL安装目录的路径,在我的Windows机器上的BASEDIR的值是:\nC:\\Program Files\\MySQL\\MySQL Server 5.7\n第四个路径指的是我们在启动程序时可以通过指定defaults-extra-file参数的值来添加额外的配置文件路径,比方说我们在命令行上可以这么写:\nmysqld --defaults-extra-file=C:\\Users\\xiaohaizi\\my_extra_file.txt 这样MySQL服务器启动时就可以额外在C:\\Users\\xiaohaizi\\my_extra_file.txt这个路径下查找配置文件。\n%APPDATA%表示Windows应用程序数据目录的值,可以使用下列命令查看:\necho %APPDATA%\n列表中最后一个名为.mylogin.cnf配置文件有点儿特殊,它不是一个纯文本文件(其他的配置文件都是纯文本文件),而是使用mysql_config_editor实用程序创建的加密文件。文件中只能包含一些用于启动客户端软件时连接服务器的一些选项,包括 host、user、password、port和 socket。而且它只能被客户端程序所使用。\n小贴士:mysql_config_editor实用程序其实是MySQL安装目录下的bin目录下的一个可执行文件,这个实用程序有专用的语法来生成或修改 .mylogin.cnf 文件中的内容,如何使用这个程序不是我们讨论的主题,可以到MySQL的官方文档中查看。\n类Unix操作系统中的配置文件 # 在类UNIX操作系统中,MySQL会按照下列路径来寻找配置文件: 路径名 备注 /etc/my.cnf /etc/mysql/my.cnf SYSCONFDIR/my.cnf $MYSQL_HOME/my.cnf 特定于服务器的选项(仅限服务器) defaults-extra-file 命令行指定的额外配置文件路径 ~/.my.cnf 用户特定选项 ~/.mylogin.cnf 用户特定的登录路径选项(仅限客户端) 在阅读这些UNIX操作系统下配置文件路径的时候需要注意一些事情:\nSYSCONFDIR表示在使用CMake构建MySQL时使用SYSCONFDIR选项指定的目录。默认情况下,这是位于编译安装目录下的etc目录。 小贴士:如果你不懂什么是个CMAKE,什么是个编译,那就跳过吧,对我们后续的文章没什么影响。 - MYSQL_HOME是一个环境变量,该变量的值是我们自己设置的,我们想设置就设置,不想设置就不设置。该变量的值代表一个路径,我们可以在该路径下创建一个my.cnf配置文件,那么这个配置文件中只能放置关于启动服务器程序相关的选项(言外之意就是其他的配置文件既能存放服务器相关的选项也能存放客户端相关的选项,.mylogin.cnf除外,它只能存放客户端相关的一些选项)。\n小贴士:如果大家使用mysqld_safe启动服务器程序,而且我们也没有主动设置这个MySQL_HOME环境变量的值,那这个环境变量的值将自动被设置为MySQL的安装目录,也就是MySQL服务器将会在安装目录下查找名为my.cnf配置文件(别忘了mysql.server会调用mysqld_safe,所以使用mysql.server启动服务器时也会在安装目录下查找配置文件)。\n列表中的最后两个以~开头的路径是用户相关的,类UNIX系统中都有一个当前登陆用户的概念,每个用户都可以有一个用户目录,~就代表这个用户目录,大家可以查看HOME环境变量的值来确定一下当前用户的用户目录,比方说我的macOS机器上的用户目录就是/Users/xiaohaizi。之所以说列表中最后两个配置文件是用户相关的,是因为不同的类UNIX系统的用户都可以在自己的用户目录下创建.my.cnf或者.mylogin.cnf,换句话说,不同登录用户使用的.my.cnf或者.mylogin.cnf配置文件是不同的。\ndefaults-extra-file的含义与Windows中的一样。\n.mylogin.cnf的含义也同Windows中的一样,再次强调一遍,它不是纯文本文件,只能使用mysql_config_editor实用程序去创建或修改,用于存放客户端登陆服务器时的相关选项。\n这也就是说,在我的计算机中这几个路径中的任意一个都可以当作配置文件来使用,如果它们不存在,你可以手动创建一个,比方说我手动在~/.my.cnf这个路径下创建一个配置文件。\n另外,我们在介绍如何启动MySQL服务器程序的时候说过,使用mysqld_safe程序启动服务器时,会间接调用mysqld,所以对于传递给mysqld_safe的启动选项来说,如果mysqld_safe程序不处理,会接着传递给mysqld程序处理。比方说skip-networking选项是由mysqld处理的,mysqld_safe并不处理,但是如果我们我们在命令行上这样执行: mysqld_safe --skip-networking 则在mysqld_safe调用mysqld时,会把它处理不了的这个skip-networking选项交给mysqld处理。\n配置文件的内容 # 与在命令行中指定启动选项不同的是,配置文件中的启动选项被划分为若干个组,每个组有一个组名,用中括号[]扩起来,像这样: ``` [server] (具体的启动选项\u0026hellip;)\n[mysqld] (具体的启动选项\u0026hellip;)\n[mysqld_safe] (具体的启动选项\u0026hellip;)\n[client] (具体的启动选项\u0026hellip;)\n[mysql] (具体的启动选项\u0026hellip;)\n[mysqladmin] (具体的启动选项\u0026hellip;) ``` 像这个配置文件里就定义了许多个组,组名分别是server、mysqld、mysqld_safe、client、mysql、mysqladmin。每个组下面可以定义若干个启动选项,我们以[server]组为例来看一下填写启动选项的形式(其他组中启动选项的形式是一样的):\n[server] option1 #这是option1,该选项不需要选项值 option2 = value2 #这是option2,该选项需要选项值 ... 在配置文件中指定启动选项的语法类似于命令行语法,但是配置文件中只能使用长形式的选项。在配置文件中指定的启动选项不允许加--前缀,并且每行只指定一个选项,而且=周围可以有空白字符(命令行中选项名、=、选项值之间不允许有空白字符)。另外,在配置文件中,我们可以使用#来添加注释,从#出现直到行尾的内容都属于注释内容,读取配置文件时会忽略这些注释内容。为了大家更容易对比启动选项在命令行和配置文件中指定的区别,我们再把命令行中指定option1和option2两个选项的格式写一遍看看: --option1 --option2=value2 配置文件中不同的选项组是给不同的启动命令使用的,如果选项组名称与程序名称相同,则组中的选项将专门应用于该程序。例如,[mysqld]和[mysql]组分别应用于mysqld服务器程序和mysql客户端程序。不过有两个选项组比较特别:\n[server]组下面的启动选项将作用于所有的服务器程序。\n[client]组下面的启动选项将作用于所有的客户端程序。\n需要注意的一点是,mysqld_safe和mysql.server这两个程序在启动时都会读取[mysqld]选项组中的内容。为了直观感受一下,我们挑一些启动命令来看一下它们能读取的选项组都有哪些: 启动命令 类别 能读取的组 mysqld 启动服务器 [mysqld]、[server] mysqld_safe 启动服务器 [mysqld]、[server]、[mysqld_safe] mysql.server 启动服务器 [mysqld]、[server]、[mysql.server] mysql 启动客户端 [mysql]、[client] mysqladmin 启动客户端 [mysqladmin]、[client] mysqldump 启动客户端 [mysqldump]、[client] 现在我们以macOS操作系统为例,在/etc/mysql/my.cnf这个配置文件中添加一些内容(Windows系统参考上面提到的配置文件路径): [server] skip-networking default-storage-engine=MyISAM 然后直接用mysqld启动服务器程序: mysqld 虽然在命令行没有添加启动选项,但是在程序启动的时候,就会默认的到我们上面提到的配置文件路径下查找配置文件,其中就包括/etc/mysql/my.cnf。又由于mysqld命令可以读取[server]选项组的内容,所以skip-networking和default-storage-engine=MyISAM这两个选项是生效的。你可以把这些启动选项放在[client]组里再试试用mysqld启动服务器程序,看一下里边的启动选项生效不(剧透一下,不生效)。 小贴士:如果我们想指定mysql.server程序的启动参数,则必须将它们放在配置文件中,而不是放在命令行中。mysql.server仅支持start和stop作为命令行参数。\n特定MySQL版本的专用选项组 # 我们可以在选项组的名称后加上特定的MySQL版本号,比如对于[mysqld]选项组来说,我们可以定义一个[mysqld-5.7]的选项组,它的含义和[mysqld]一样,只不过只有版本号为5.7的mysqld程序才能使用这个选项组中的选项。\n配置文件的优先级 # 我们前面介绍过MySQL将在某些固定的路径下搜索配置文件,我们也可以通过在命令行上指定defaults-extra-file启动选项来指定额外的配置文件路径。MySQL将按照我们在上表中给定的顺序依次读取各个配置文件,如果该文件不存在则忽略。值得注意的是,如果我们在多个配置文件中设置了相同的启动选项,那以最后一个配置文件中的为准。比方说/etc/my.cnf文件的内容是这样的: [server] default-storage-engine=InnoDB 而~/.my.cnf文件中的内容是这样的: [server] default-storage-engine=MyISAM 又因为~/.my.cnf比/etc/my.cnf顺序靠后,所以如果两个配置文件中出现相同的启动选项,以~/.my.cnf中的为准,所以MySQL服务器程序启动之后,default-storage-engine的值就是MyISAM。\n同一个配置文件中多个组的优先级 # 我们说同一个命令可以访问配置文件中的多个组,比如mysqld可以访问[mysqld]、[server]组,如果在同一个配置文件中,比如~/.my.cnf,在这些组里出现了同样的配置项,比如这样: ``` [server] default-storage-engine=InnoDB\n[mysqld] default-storage-engine=MyISAM ``` 那么,将以最后一个出现的组中的启动选项为准,比方说例子中default-storage-engine既出现在[mysqld]组也出现在[server]组,因为[mysqld]组在[server]组后边,就以[mysqld]组中的配置项为准。\ndefaults-file的使用 # 如果我们不想让MySQL到默认的路径下搜索配置文件(就是上表中列出的那些),可以在命令行指定defaults-file选项,比如这样(以UNIX系统为例): mysqld --defaults-file=/tmp/myconfig.txt 这样,在程序启动的时候将只在/tmp/myconfig.txt路径下搜索配置文件。如果文件不存在或无法访问,则会发生错误。 小贴士:注意defaults-extra-file和defaults-file的区别,使用defaults-extra-file可以指定额外的配置文件搜索路径(也就是说那些固定的配置文件路径也会被搜索)。\n命令行和配置文件中启动选项的区别 # 在命令行上指定的绝大部分启动选项都可以放到配置文件中,但是有一些选项是专门为命令行设计的,比方说defaults-extra-file、defaults-file这样的选项本身就是为了指定配置文件路径的,再放在配置文件中使用就没什么意义了。剩下的一些只能用在命令行上而不能用到配置文件中的启动选项就不一一列举了,用到的时候再提(本书中基本用不到,有兴趣的到官方文档看)。\n另外有一点需要特别注意,如果同一个启动选项既出现在命令行中,又出现在配置文件中,那么以命令行中的启动选项为准!比如我们在配置文件中写了: [server] default-storage-engine=InnoDB 而我们的启动命令是: mysql.server start --default-storage-engine=MyISAM 那最后default-storage-engine的值就是MyISAM!\n系统变量 # 系统变量简介 # MySQL服务器程序运行过程中会用到许多影响程序行为的变量,它们被称为MySQL系统变量,比如允许同时连入的客户端数量用系统变量max_connections表示,表的默认存储引擎用系统变量default_storage_engine表示,查询缓存的大小用系统变量query_cache_size表示,MySQL服务器程序的系统变量有好几百条,我们就不一一列举了。每个系统变量都有一个默认值,我们可以使用命令行或者配置文件中的选项在启动服务器时改变一些系统变量的值。大多数的系统变量的值也可以在程序运行过程中修改,而无需停止并重新启动它。\n查看系统变量 # 我们可以使用下列命令查看MySQL服务器程序支持的系统变量以及它们的当前值: SHOW VARIABLES [LIKE 匹配的模式]; 由于系统变量实在太多了,如果我们直接使用SHOW VARIABLES查看的话就直接刷屏了,所以通常都会带一个LIKE过滤条件来查看我们需要的系统变量的值,比方说这么写: ``` mysql\u0026gt; SHOW VARIABLES LIKE \u0026lsquo;default_storage_engine\u0026rsquo;; +\u0026mdash;\u0026mdash;\u0026mdash;\u0026mdash;\u0026mdash;\u0026mdash;\u0026mdash;\u0026mdash;+\u0026mdash;\u0026mdash;\u0026ndash;+ | Variable_name | Value | +\u0026mdash;\u0026mdash;\u0026mdash;\u0026mdash;\u0026mdash;\u0026mdash;\u0026mdash;\u0026mdash;+\u0026mdash;\u0026mdash;\u0026ndash;+ | default_storage_engine | InnoDB | +\u0026mdash;\u0026mdash;\u0026mdash;\u0026mdash;\u0026mdash;\u0026mdash;\u0026mdash;\u0026mdash;+\u0026mdash;\u0026mdash;\u0026ndash;+ 1 row in set (0.01 sec)\nmysql\u0026gt; SHOW VARIABLES like \u0026lsquo;max_connections\u0026rsquo;; +\u0026mdash;\u0026mdash;\u0026mdash;\u0026mdash;\u0026mdash;\u0026ndash;+\u0026mdash;\u0026mdash;-+ | Variable_name | Value | +\u0026mdash;\u0026mdash;\u0026mdash;\u0026mdash;\u0026mdash;\u0026ndash;+\u0026mdash;\u0026mdash;-+ | max_connections | 151 | +\u0026mdash;\u0026mdash;\u0026mdash;\u0026mdash;\u0026mdash;\u0026ndash;+\u0026mdash;\u0026mdash;-+ 1 row in set (0.00 sec) 可以看到,现在服务器程序使用的默认存储引擎就是InnoDB,允许同时连接的客户端数量最多为151。别忘了LIKE表达式后边可以跟通配符来进行模糊查询,也就是说我们可以这么写: mysql\u0026gt; SHOW VARIABLES LIKE \u0026lsquo;default%\u0026rsquo;; +\u0026mdash;\u0026mdash;\u0026mdash;\u0026mdash;\u0026mdash;\u0026mdash;\u0026mdash;\u0026mdash;\u0026mdash;\u0026mdash;-+\u0026mdash;\u0026mdash;\u0026mdash;\u0026mdash;\u0026mdash;\u0026mdash;\u0026mdash;\u0026ndash;+ | Variable_name | Value | +\u0026mdash;\u0026mdash;\u0026mdash;\u0026mdash;\u0026mdash;\u0026mdash;\u0026mdash;\u0026mdash;\u0026mdash;\u0026mdash;-+\u0026mdash;\u0026mdash;\u0026mdash;\u0026mdash;\u0026mdash;\u0026mdash;\u0026mdash;\u0026ndash;+ | default_authentication_plugin | mysql_native_password | | default_password_lifetime | 0 | | default_storage_engine | InnoDB | | default_tmp_storage_engine | InnoDB | | default_week_format | 0 | +\u0026mdash;\u0026mdash;\u0026mdash;\u0026mdash;\u0026mdash;\u0026mdash;\u0026mdash;\u0026mdash;\u0026mdash;\u0026mdash;-+\u0026mdash;\u0026mdash;\u0026mdash;\u0026mdash;\u0026mdash;\u0026mdash;\u0026mdash;\u0026ndash;+ 5 rows in set (0.01 sec)\nmysql\u0026gt; ``` 这样就查出了所有以default开头的系统变量的值。\n设置系统变量 # 通过启动选项设置 # 大部分的系统变量都可以通过启动服务器时传送启动选项的方式来进行设置。如何填写启动选项我们上面已经花了大篇幅来介绍了,就是下面两种方式:\n通过命令行添加启动选项。\n比方说我们在启动服务器程序时用这个命令: mysqld --default-storage-engine=MyISAM --max-connections=10 - 通过配置文件添加启动选项。\n我们可以这样填写配置文件: [server] default-storage-engine=MyISAM max-connections=10\n当使用上面两种方式中的任意一种启动服务器程序后,我们再来查看一下系统变量的值: ``` mysql\u0026gt; SHOW VARIABLES LIKE \u0026lsquo;default_storage_engine\u0026rsquo;; +\u0026mdash;\u0026mdash;\u0026mdash;\u0026mdash;\u0026mdash;\u0026mdash;\u0026mdash;\u0026mdash;+\u0026mdash;\u0026mdash;\u0026ndash;+ | Variable_name | Value | +\u0026mdash;\u0026mdash;\u0026mdash;\u0026mdash;\u0026mdash;\u0026mdash;\u0026mdash;\u0026mdash;+\u0026mdash;\u0026mdash;\u0026ndash;+ | default_storage_engine | MyISAM | +\u0026mdash;\u0026mdash;\u0026mdash;\u0026mdash;\u0026mdash;\u0026mdash;\u0026mdash;\u0026mdash;+\u0026mdash;\u0026mdash;\u0026ndash;+ 1 row in set (0.00 sec)\nmysql\u0026gt; SHOW VARIABLES LIKE \u0026lsquo;max_connections\u0026rsquo;; +\u0026mdash;\u0026mdash;\u0026mdash;\u0026mdash;\u0026mdash;\u0026ndash;+\u0026mdash;\u0026mdash;-+ | Variable_name | Value | +\u0026mdash;\u0026mdash;\u0026mdash;\u0026mdash;\u0026mdash;\u0026ndash;+\u0026mdash;\u0026mdash;-+ | max_connections | 10 | +\u0026mdash;\u0026mdash;\u0026mdash;\u0026mdash;\u0026mdash;\u0026ndash;+\u0026mdash;\u0026mdash;-+ 1 row in set (0.00 sec)\nmysql\u0026gt; ``` 可以看到default_storage_engine和max_connections这两个系统变量的值已经被修改了。有一点需要注意的是,对于启动选项来说,如果启动选项名由多个单词组成,各个单词之间用短划线-或者下划线*连接起来都可以,但是对应的系统变量之间必须使用下划线*连接起来。\n服务器程序运行过程中设置 # 系统变量比较牛逼的一点就是,对于大部分系统变量来说,它们的值可以在服务器程序运行过程中,进行动态修改而无需停止并重启服务器。不过系统变量有作用范围之分,下面详细介绍下。\n设置不同作用范围的系统变量 # 我们前面说过,多个客户端程序可以同时连接到一个服务器程序。对于同一个系统变量,我们有时想让不同的客户端有不同的值。比方说狗哥使用客户端A,他想让当前客户端对应的默认存储引擎为InnoDB,所以他可以把系统变量default_storage_engine的值设置为InnoDB;猫爷使用客户端B,他想让当前客户端对应的默认存储引擎为MyISAM,所以他可以把系统变量default_storage_engine的值设置为MyISAM。这样可以使狗哥和猫爷的的客户端拥有不同的默认存储引擎,使用时互不影响,十分方便。但是这样各个客户端都私有一份系统变量会产生这么两个问题:\n有一些系统变量并不是针对单个客户端的,比如允许同时连接到服务器的客户端数量max_connections,查询缓存的大小query_cache_size,这些公有的系统变量让某个客户端私有显然不合适。\n一个新连接到服务器的客户端对应的系统变量的值该怎么设置?\n为了解决这两个问题,设计MySQL的大佬提出了系统变量的作用范围的概念,具体来说作用范围分为这两种:\nGLOBAL:全局变量,影响服务器的整体操作。\nSESSION:会话变量,影响某个客户端连接的操作。(注:SESSION有个别名叫LOCAL)\n在服务器启动时,会将每个全局变量初始化为其默认值(可以通过命令行或选项文件中指定的选项更改这些默认值)。然后服务器还为每个连接的客户端维护一组会话变量,客户端的会话变量在连接时使用相应全局变量的当前值初始化。\n这话有点儿绕,还是以default_storage_engine举例,在服务器启动时会初始化一个名为default_storage_engine,作用范围为GLOBAL的系统变量。之后每当有一个客户端连接到该服务器时,服务器都会单独为该客户端分配一个名为default_storage_engine,作用范围为SESSION的系统变量,该作用范围为SESSION的系统变量值按照当前作用范围为GLOBAL的同名系统变量值进行初始化。\n很显然,通过启动选项设置的系统变量的作用范围都是GLOBAL的,也就是对所有客户端都有效的,因为在系统启动的时候还没有客户端程序连接进来呢。了解了系统变量的GLOBAL和SESSION作用范围之后,我们再看一下在服务器程序运行期间通过客户端程序设置系统变量的语法: SET [GLOBAL|SESSION] 系统变量名 = 值; 或者写成这样也行: SET [@@(GLOBAL|SESSION).]var_name = XXX; 比如我们想在服务器运行过程中把作用范围为GLOBAL的系统变量default_storage_engine的值修改为MyISAM,也就是想让后面新连接到服务器的客户端都用MyISAM作为默认的存储引擎,那我们可以选择下面两条语句中的任意一条来进行设置: 语句一:SET GLOBAL default_storage_engine = MyISAM; 语句二:SET @@GLOBAL.default_storage_engine = MyISAM; 如果只想对本客户端生效,也可以选择下面三条语句中的任意一条来进行设置: 语句一:SET SESSION default_storage_engine = MyISAM; 语句二:SET @@SESSION.default_storage_engine = MyISAM; 语句三:SET default_storage_engine = MyISAM; 从上面的语句三也可以看出,如果在设置系统变量的语句中省略了作用范围,默认的作用范围就是SESSION。也就是说SET 系统变量名 = 值和SET SESSION 系统变量名 = 值是等价的。\n查看不同作用范围的系统变量 # 既然系统变量有作用范围之分,那我们的SHOW VARIABLES语句查看的是什么作用范围的系统变量呢?\n答:默认查看的是SESSION作用范围的系统变量。\n当然我们也可以在查看系统变量的语句上加上要查看哪个作用范围的系统变量,就像这样: SHOW [GLOBAL|SESSION] VARIABLES [LIKE 匹配的模式]; 下面我们演示一下完整的设置并查看系统变量的过程: ``` mysql\u0026gt; SHOW SESSION VARIABLES LIKE \u0026lsquo;default_storage_engine\u0026rsquo;; +\u0026mdash;\u0026mdash;\u0026mdash;\u0026mdash;\u0026mdash;\u0026mdash;\u0026mdash;\u0026mdash;+\u0026mdash;\u0026mdash;\u0026ndash;+ | Variable_name | Value | +\u0026mdash;\u0026mdash;\u0026mdash;\u0026mdash;\u0026mdash;\u0026mdash;\u0026mdash;\u0026mdash;+\u0026mdash;\u0026mdash;\u0026ndash;+ | default_storage_engine | InnoDB | +\u0026mdash;\u0026mdash;\u0026mdash;\u0026mdash;\u0026mdash;\u0026mdash;\u0026mdash;\u0026mdash;+\u0026mdash;\u0026mdash;\u0026ndash;+ 1 row in set (0.00 sec)\nmysql\u0026gt; SHOW GLOBAL VARIABLES LIKE \u0026lsquo;default_storage_engine\u0026rsquo;; +\u0026mdash;\u0026mdash;\u0026mdash;\u0026mdash;\u0026mdash;\u0026mdash;\u0026mdash;\u0026mdash;+\u0026mdash;\u0026mdash;\u0026ndash;+ | Variable_name | Value | +\u0026mdash;\u0026mdash;\u0026mdash;\u0026mdash;\u0026mdash;\u0026mdash;\u0026mdash;\u0026mdash;+\u0026mdash;\u0026mdash;\u0026ndash;+ | default_storage_engine | InnoDB | +\u0026mdash;\u0026mdash;\u0026mdash;\u0026mdash;\u0026mdash;\u0026mdash;\u0026mdash;\u0026mdash;+\u0026mdash;\u0026mdash;\u0026ndash;+ 1 row in set (0.00 sec)\nmysql\u0026gt; SET SESSION default_storage_engine = MyISAM; Query OK, 0 rows affected (0.00 sec)\nmysql\u0026gt; SHOW SESSION VARIABLES LIKE \u0026lsquo;default_storage_engine\u0026rsquo;; +\u0026mdash;\u0026mdash;\u0026mdash;\u0026mdash;\u0026mdash;\u0026mdash;\u0026mdash;\u0026mdash;+\u0026mdash;\u0026mdash;\u0026ndash;+ | Variable_name | Value | +\u0026mdash;\u0026mdash;\u0026mdash;\u0026mdash;\u0026mdash;\u0026mdash;\u0026mdash;\u0026mdash;+\u0026mdash;\u0026mdash;\u0026ndash;+ | default_storage_engine | MyISAM | +\u0026mdash;\u0026mdash;\u0026mdash;\u0026mdash;\u0026mdash;\u0026mdash;\u0026mdash;\u0026mdash;+\u0026mdash;\u0026mdash;\u0026ndash;+ 1 row in set (0.00 sec)\nmysql\u0026gt; SHOW GLOBAL VARIABLES LIKE \u0026lsquo;default_storage_engine\u0026rsquo;; +\u0026mdash;\u0026mdash;\u0026mdash;\u0026mdash;\u0026mdash;\u0026mdash;\u0026mdash;\u0026mdash;+\u0026mdash;\u0026mdash;\u0026ndash;+ | Variable_name | Value | +\u0026mdash;\u0026mdash;\u0026mdash;\u0026mdash;\u0026mdash;\u0026mdash;\u0026mdash;\u0026mdash;+\u0026mdash;\u0026mdash;\u0026ndash;+ | default_storage_engine | InnoDB | +\u0026mdash;\u0026mdash;\u0026mdash;\u0026mdash;\u0026mdash;\u0026mdash;\u0026mdash;\u0026mdash;+\u0026mdash;\u0026mdash;\u0026ndash;+ 1 row in set (0.00 sec)\nmysql\u0026gt; 可以看到,最初default_storage_engine的系统变量无论是在GLOBAL作用范围上还是在SESSION作用范围上的值都是InnoDB,我们在SESSION作用范围把它的值设置为MyISAM之后,可以看到GLOBAL作用范围的值并没有改变。 小贴士:如果某个客户端改变了某个系统变量在GLOBAL作用范围的值,并不会影响该系统变量在当前已经连接的客户端作用范围为SESSION的值,只会影响后续连入的客户端在作用范围为SESSION的值。 ```\n注意事项 # 并不是所有系统变量都具有GLOBAL和SESSION的作用范围。\n+ 有一些系统变量只具有GLOBAL作用范围,比方说max_connections,表示服务器程序支持同时最多有多少个客户端程序进行连接。\n+ 有一些系统变量只具有SESSION作用范围,比如insert_id,表示在对某个包含AUTO_INCREMENT列的表进行插入时,该列初始的值。\n+ 有一些系统变量的值既具有GLOBAL作用范围,也具有SESSION作用范围,比如我们前面用到的default_storage_engine,而且其实大部分的系统变量都是这样的,\n有些系统变量是只读的,并不能设置值。\n比方说version,表示当前MySQL的版本,我们客户端是不能设置它的值的,只能在SHOW VARIABLES语句里查看。\n启动选项和系统变量的区别 # 启动选项是在程序启动时我们程序员传递的一些参数,而系统变量是影响服务器程序运行行为的变量,它们之间的关系如下:\n大部分的系统变量都可以被当作启动选项传入。\n有些系统变量是在程序运行过程中自动生成的,是不可以当作启动选项来设置,比如auto_increment_offset、character_set_client等。\n有些启动选项也不是系统变量,比如defaults-file。\n状态变量 # 为了让我们更好的了解服务器程序的运行情况,MySQL服务器程序中维护了很多关于程序运行状态的变量,它们被称为状态变量。比方说Threads_connected表示当前有多少客户端与服务器建立了连接,Handler_update表示已经更新了多少行记录等,像这样显示服务器程序状态信息的状态变量还有好几百个,我们就不一一介绍了,等遇到了会详细说它们的作用的。\n由于状态变量是用来显示服务器程序运行状况的,所以它们的值只能由服务器程序自己来设置,我们程序员是不能设置的。与系统变量类似,状态变量也有GLOBAL和SESSION两个作用范围的,所以查看状态变量的语句可以这么写: SHOW [GLOBAL|SESSION] STATUS [LIKE 匹配的模式]; 类似的,如果我们不写明作用范围,默认的作用范围是SESSION,比方说这样: ``` mysql\u0026gt; SHOW STATUS LIKE \u0026rsquo;thread%\u0026rsquo;; +\u0026mdash;\u0026mdash;\u0026mdash;\u0026mdash;\u0026mdash;\u0026mdash;-+\u0026mdash;\u0026mdash;-+ | Variable_name | Value | +\u0026mdash;\u0026mdash;\u0026mdash;\u0026mdash;\u0026mdash;\u0026mdash;-+\u0026mdash;\u0026mdash;-+ | Threads_cached | 0 | | Threads_connected | 1 | | Threads_created | 1 | | Threads_running | 1 | +\u0026mdash;\u0026mdash;\u0026mdash;\u0026mdash;\u0026mdash;\u0026mdash;-+\u0026mdash;\u0026mdash;-+ 4 rows in set (0.00 sec)\nmysql\u0026gt; ``` 所有以Thread开头的SESSION作用范围的状态变量就都被展示出来了。\n"},{"id":31,"href":"/zh/docs/technology/MySQL/MySQL%E6%98%AF%E6%80%8E%E6%A0%B7%E8%BF%90%E8%A1%8C%E7%9A%84/%E7%AC%AC1%E7%AB%A0_%E8%A3%85%E4%BD%9C%E8%87%AA%E5%B7%B1%E6%98%AF%E4%B8%AA%E5%B0%8F%E7%99%BD-%E9%87%8D%E6%96%B0%E8%AE%A4%E8%AF%86MySQL/","title":"第1章_装作自己是个小白-重新认识MySQL","section":"My Sql是怎样运行的","content":"第1章 装作自己是个小白-重新认识MySQL\nMySQL的客户端/服务器架构 # 以我们平时使用的微信为例,它其实是由两部分组成的,一部分是客户端程序,一部分是服务器程序。客户端可能有很多种形式,比如手机APP,电脑软件或者是网页版微信,每个客户端都有一个唯一的用户名,就是你的微信号,另一方面,腾讯公司在他们的机房里运行着一个服务器软件,我们平时操作微信其实都是用客户端来和这个服务器来打交道。比如狗哥用微信给猫爷发了一条消息的过程其实是这样的:\n消息被客户端包装了一下,添加了发送者和接收者信息,然后从狗哥的微信客户端传送给微信服务器; 微信服务器从消息里获取到它的发送者和接收者,根据消息的接收者信息把这条消息送达到猫爷的微信客户端,猫爷的微信客户端里就显示出狗哥给他发了一条消息。 MySQL的使用过程跟这个是一样的,它的服务器程序直接和我们存储的数据打交道,然后可以有好多客户端程序连接到这个服务器程序,发送增删改查的请求,然后服务器就响应这些请求,从而操作它维护的数据。和微信一样,MySQL的每个客户端都需要提供用户名密码才能登录,登录之后才能给服务器发请求来操作某些数据。我们日常使用MySQL的情景一般是这样的:\n启动MySQL服务器程序。 启动MySQL客户端程序并连接到服务器程序。 在客户端程序中输入一些命令语句作为请求发送到服务器程序,服务器程序收到这些请求后,会根据请求的内容来操作具体的数据并向客户端返回操作结果。 我们知道计算机很牛逼,在一台计算机上可以同时运行多个程序,比如微信、QQ、音乐播放器、文本编辑器等,每一个运行着的程序也被称为一个进程。我们的MySQL服务器程序和客户端程序本质上都算是计算机上的一个进程,这个代表着MySQL服务器程序的进程也被称为MySQL数据库实例,简称数据库实例。\n每个进程都有一个唯一的编号,称为进程ID,英文名叫PID,这个编号是在我们启动程序的时候由操作系统随机分配的,操作系统会保证在某一时刻同一台机器上的进程号不重复。比如你打开了计算机中的QQ程序,那么操作系统会为它分配一个唯一的进程号,如果你把这个程序关掉了,那操作系统就会把这个进程号回收,之后可能会重新分配给别的进程。当我们下一次再启动 QQ程序的时候分配的就可能是另一个编号。每个进程都有一个名称,这个名称是编写程序的人自己定义的,比如我们启动的MySQL服务器进程的默认名称为mysqld, 而我们常用的MySQL客户端进程的默认名称为mysql。\nMySQL的安装 # 不论我们通过下载源代码自行编译安装的方式,还是直接使用官方提供的安装包进行安装之后,MySQL的服务器程序和客户端程序都会被安装到我们的机器上。不论使用上述两者的哪种安装方式,一定一定一定(重要的话说三遍)要记住你把MySQL安装到哪了,换句话说,一定要记住MySQL的安装目录。 小贴士:MySQL的大部分安装包都包含了服务器程序和客户端程序,不过在Linux下使用RPM包时会有单独的服务器RPM包和客户端RPM包,需要分别安装。\n另外,MySQL可以运行在各种各样的操作系统上,我们后边会讨论在类UNIX操作系统和Windows操作系统上使用的一些差别。为了方便大家理解,我在macOS 操作系统(苹果电脑使用的操作系统)和Windows操作系统上都安装了MySQL,它们的安装目录分别是:\nmacOS操作系统上的安装目录: /usr/local/mysql/\nWindows操作系统上的安装目录: C:\\Program Files\\MySQL\\MySQL Server 5.7\n下面我会以这两个安装目录为例来进一步扯出更多的概念,不过一定要注意,这两个安装目录是我的运行不同操作系统的机器上的安装目录,一定要记着把下面示例中用到安装目录的地方替换为你自己机器上的安装目录。 小贴士:类UNIX操作系统非常多,比如FreeBSD、Linux、macOS、Solaris等都属于UNIX操作系统的范畴,我们这里使用macOS操作系统代表类UNIX操作系统来运行MySQL。\nbin目录下的可执行文件 # 在MySQL的安装目录下有一个特别特别重要的bin目录,这个目录下存放着许多可执行文件,以macOS系统为例,这个bin目录的绝对路径就是(在我的机器上): /usr/local/mysql/bin 我们列出一些在macOS中这个bin目录下的一部分可执行文件来看一下(文件太多,全列出来会刷屏的): . ├── mysql ├── mysql.server -\u0026gt; ../support-files/mysql.server ├── mysqladmin ├── mysqlbinlog ├── mysqlcheck ├── mysqld ├── mysqld_multi ├── mysqld_safe ├── mysqldump ├── mysqlimport ├── mysqlpump ... (省略其他文件) 0 directories, 40 files Windows中的可执行文件与macOS中的类似,不过都是以.exe为扩展名的。这些可执行文件都是与服务器程序和客户端程序相关的,后边我们会详细介绍一些比较重要的可执行文件,现在先看看执行这些文件的方式。\n对于有可视化界面的操作系统来说,我们拿着鼠标点点点就可以执行某个可执行文件,不过现在我们更关注在命令行环境下如何执行这些可执行文件,命令行通俗的说就是那些黑框框,这里的指的是类UNIX系统中的Shell或者Windows系统中的cmd.exe,如果你现在还不知道怎么启动这些命令行工具,网上搜搜吧~ 下面我们以macOS系统为例来看看如何启动这些可执行文件(Windows中的操作是类似的,依葫芦画瓢就好了)\n使用可执行文件的相对/绝对路径\n假设我们现在所处的工作目录是MySQL的安装目录,也就是/usr/local/mysql,我们想启动bin目录下的mysqld这个可执行文件,可以使用相对路径来启动: ./bin/mysqld 或者直接输入mysqld的绝对路径也可以: /usr/local/mysql/bin/mysqld\n将该bin目录的路径加入到环境变量PATH中\n如果我们觉得每次执行一个文件都要输入一串长长的路径名贼麻烦的话,可以把该bin目录所在的路径添加到环境变量PATH中。环境变量PATH是一系列路径的集合,各个路径之间使用冒号:隔离开,比方说我的机器上的环境变量PATH的值就是: /usr/local/bin:/usr/bin:/bin:/usr/sbin:/sbin 我的系统中这个环境变量PATH的值表明:当我在输入一个命令时,系统便会在/usr/local/bin、/usr/bin:、/bin:、/usr/sbin、/sbin这些目录下依次寻找是否存在我们输入的那个命令,如果寻找成功,则执行该目录下对应的可执行文件。所以我们现在可以修改一下这个环境变量PATH,把MySQL安装目录下的bin目录的路径也加入到PATH中,在我的机器上修改后的环境变量PATH的值为: /usr/local/bin:/usr/bin:/bin:/usr/sbin:/sbin:/usr/local/mysql/bin 这样现在不论我们所处的工作目录是什么,我们都可以直接输入可执行文件的名字就可以启动它,比如这样: mysqld 方便多了~\n小贴士:关于什么是环境变量以及如何在当前系统中添加或修改系统变量不是我们介绍的范围,大家找本相关的书或者上网查一查~\n启动MySQL服务器程序 # UNIX里启动服务器程序 # 在类UNIX系统中用来启动MySQL服务器程序的可执行文件有很多,大多在MySQL安装目录的bin目录下,我们一起来看看。\nmysqld # mysqld这个可执行文件就代表着MySQL服务器程序,运行这个可执行文件就可以直接启动一个服务器进程。但这个命令不常用,我们继续往下看更牛逼的启动命令。\nmysqld_safe # mysqld_safe是一个启动脚本,它会间接的调用mysqld,而且还顺便启动了另外一个监控进程,这个监控进程在服务器进程挂了的时候,可以帮助重启它。另外,使用mysqld_safe启动服务器程序时,它会将服务器程序的出错信息和其他诊断信息重定向到某个文件中,产生出错日志,这样可以方便我们找出发生错误的原因。\nmysql.server # mysql.server也是一个启动脚本,它会间接的调用mysqld_safe,在调用mysql.server时在后边指定start参数就可以启动服务器程序了,就像这样: mysql.server start 需要注意的是,这个 mysql.server 文件其实是一个链接文件,它的实际文件是 ../support-files/mysql.server。我使用的macOS操作系统会在bin目录下自动创建一个指向实际文件的链接文件,如果你的操作系统没有帮你自动创建这个链接文件,那就自己创建一个呗~ 别告诉我你不会创建链接文件,上网搜搜呗~\n另外,我们还可以使用mysql.server命令来关闭正在运行的服务器程序,只要把start参数换成stop就好了: mysql.server stop\nmysqld_multi # 其实我们一台计算机上也可以运行多个服务器实例,也就是运行多个MySQL服务器进程。mysql_multi可执行文件可以对每一个服务器进程的启动或停止进行监控。这个命令的使用比较复杂,本书主要是为了讲清楚MySQL服务器和客户端运行的过程,不会对启动多个服务器程序进行过多介绍。\nWindows里启动服务器程序 # Windows里没有像类UNIX系统中那么多的启动脚本,但是也提供了手动启动和以服务的形式启动这两种方式,下面我们详细看。\nmysqld # 同样的,在MySQL安装目录下的bin目录下有一个mysqld可执行文件,在命令行里输入mysqld,或者直接双击运行它就算启动了MySQL服务器程序了。\n以服务的方式运行服务器程序 # 首先看看什么是个Windows 服务?如果无论是谁正在使用这台计算机,我们都需要长时间的运行某个程序,而且需要在计算机启动的时候便启动它,一般我们都会把它注册为一个Windows 服务,操作系统会帮我们管理它。把某个程序注册为Windows服务的方式挺简单,如下: \u0026quot;完整的可执行文件路径\u0026quot; --install [-manual] [服务名] 其中的-manual可以省略,加上它的话,表示在Windows系统启动的时候不自动启动该服务,否则会自动启动。服务名也可以省略,默认的服务名就是MySQL。比如我的Windows计算机上mysqld的完整路径是: C:\\Program Files\\MySQL\\MySQL Server 5.7\\bin\\mysqld 所以如果我们想把它注册为服务的话可以在命令行里这么写: \u0026quot;C:\\Program Files\\MySQL\\MySQL Server 5.7\\bin\\mysqld\u0026quot; --install 在把mysqld注册为Windows服务之后,我们就可以通过下面这个命令来启动MySQL服务器程序了: net start MySQL 当然,如果你喜欢图形界面的话,你可以通过Windows的服务管理器通过用鼠标点点点的方式来启动和停止服务(作为一个程序猿,还是用黑框框吧~)。\n关闭这个服务也非常简单,只要把上面的start换成stop就行了,就像这样: net stop MySQL\n启动MySQL客户端程序 # 在我们成功启动MySQL服务器程序后,就可以接着启动客户端程序来连接到这个服务器喽,bin目录下有许多客户端程序,比方说mysqladmin、mysqldump、mysqlcheck等等(好多呢,就不一一列举了)。这里我们重点要关注的是可执行文件mysql,通过这个可执行文件可以让我们和服务器程序进程交互,也就是发送请求,接收服务器的处理结果。启动这个可执行文件时一般需要一些参数,格式如下: mysql -h主机名 -u用户名 -p密码\n各个参数的意义如下: 参数名 含义 -h 表示服务器进程所在计算机的域名或者IP地址,如果服务器进程就运行在本机的话,可以省略这个参数,或者填localhost或者127.0.0.1。也可以写作 --host=主机名的形式。 -u 表示用户名。也可以写作 --user=用户名的形式。 -p 表示密码。也可以写作 --password=密码的形式。 小贴士:像 h、u、p 这样名称只有一个英文字母的参数称为短形式的参数,使用时前面需要加单短划线,像 host、user、password 这样大于一个英文字母的参数称为长形式的参数,使用时前面需要加双短划线。后边会详细讨论这些参数的使用方式的,稍安勿躁~ 比如我这样执行下面这个可执行文件(用户名密码按你的实际情况填写),就可以启动MySQL客户端,并且连接到服务器了。 mysql -hlocalhost -uroot -p123456 我们看一下连接成功后的界面: ``` Welcome to the MySQL monitor. Commands end with ; or \\g. Your MySQL connection id is 2 Server version: 5.7.21 Homebrew\nCopyright (c) 2000, 2018, Oracle and/or its affiliates. All rights reserved.\nOracle is a registered trademark of Oracle Corporation and/or its affiliates. Other names may be trademarks of their respective owners. Type \u0026lsquo;help;\u0026rsquo; or \u0026lsquo;\\h\u0026rsquo; for help. Type \u0026lsquo;\\c\u0026rsquo; to clear the current input statement.\nmysql\u0026gt; ``` 最后一行的mysql\u0026gt;是一个客户端的提示符,之后客户端发送给服务器的命令都需要写在这个提示符后边。\n如果我们想断开客户端与服务器的连接并且关闭客户端的话,可以在mysql\u0026gt;提示符后输入下面任意一个命令:\n1. `quit` 2. `exit` 3. `\\q` 比如我们输入quit试试: mysql\u0026gt; quit Bye 输出了Bye说明客户端程序已经关掉了。值得注意的是,这是关闭客户端程序的方式,不是关闭服务器程序的方式,怎么关闭服务器程序上一节里介绍过了。\n如果你愿意,你可以多打开几个黑框框,每个黑框框都使用mysql -hlocahhost -uroot -p123456来运行多个客户端程序,每个客户端程序都是互不影响的。如果你有多个电脑,也可以试试把它们用局域网连起来,在一个电脑上启动MySQL服务器程序,在另一个电脑上执行mysql命令时使用IP地址作为主机名来连接到服务器。\n连接注意事项 # 最好不要在一行命令中输入密码。\n我们直接在黑框框里输入密码很可能被别人看到,这和你当着别人的面输入银行卡密码没什么区别,所以我们在执行mysql连接服务器的时候可以不显式的写出密码,就像这样: mysql -hlocahhost -uroot -p 点击回车之后才会提示你输入密码: Enter password: 不过这回你输入的密码不会被显示出来,心怀不轨的人也就看不到了,输入完成点击回车就成功连接到了服务器。\n如果你非要在一行命令中显式的把密码输出来,那-p和密码值之间不能有空白字符(其他参数名之间可以有空白字符),就像这样:\nmysql -h localhost -u root -p123456 如果加上了空白字符就是错误的,比如这样: mysql -h localhost -u root -p 123456\nmysql的各个参数的摆放顺序没有硬性规定,也就是说你也可以这么写:\nmysql -p -u root -h localhost\n如果你的服务器和客户端安装在同一台机器上,-h参数可以省略,就像这样:\nmysql -u root -p\n如果你使用的是类UNIX系统,并且省略-u参数后,会把你登陆操作系统的用户名当作MySQL的用户名去处理。\n比方说我用登录操作系统的用户名是xiaohaizi,那么在我的机器上下面这两条命令是等价的: mysql -u xiaohaizi -p mysql -p 对于Windows系统来说,默认的用户名是ODBC,你可以通过设置环境变量USER来添加一个默认用户名。\n客户端与服务器连接的过程 # 我们现在已经知道如何启动MySQL的服务器程序,以及如何启动客户端程序来连接到这个服务器程序。运行着的服务器程序和客户端程序本质上都是计算机上的一个进程,所以客户端进程向服务器进程发送请求并得到回复的过程本质上是一个进程间通信的过程!MySQL支持下面三种客户端进程和服务器进程的通信方式。\nTCP/IP # 真实环境中,数据库服务器进程和客户端进程可能运行在不同的主机中,它们之间必须通过网络来进行通讯。MySQL采用TCP作为服务器和客户端之间的网络通信协议。在网络环境下,每台计算机都有一个唯一的IP地址,如果某个进程有需要采用TCP协议进行网络通信方面的需求,可以向操作系统申请一个端口号,这是一个整数值,它的取值范围是0~65535。这样在网络中的其他进程就可以通过IP地址 + 端口号的方式来与这个进程连接,这样进程之间就可以通过网络进行通信了。\nMySQL服务器启动的时候会默认申请3306端口号,之后就在这个端口号上等待客户端进程进行连接,用书面一点的话来说,MySQL服务器会默认监听3306端口。 小贴士:TCP/IP 网络体系结构是现在通用的一种网络体系结构,其中的 TCP 和 IP 是体系结构中两个非常重要的网络协议,如果你并不知道协议是什么,或者并不知道网络是什么,那恐怕兄弟你来错地方了,找本计算机网络的书去看看吧! 如果3306端口号已经被别的进程占用了或者我们单纯的想自定义该数据库实例监听的端口号,那可以在启动服务器程序的命令行里添加-P参数来明确指定一下端口号,比如这样: mysqld -P3307 这样MySQL服务器在启动时就会去监听我们指定的端口号3307。\n如果客户端进程想要使用TCP/IP网络来连接到服务器进程,比如我们在使用mysql来启动客户端程序时,在-h参数后必须跟随IP地址来作为需要连接的服务器进程所在主机的主机名,如果客户端进程和服务器进程在一台计算机中的话,我们可以使用127.0.0.1来代表本机的IP地址。另外,如果服务器进程监听的端口号不是默认的3306,我们也可以在使用mysql启动客户端程序时使用-P参数(大写的P,小写的p是用来指定密码的)来指定需要连接到的端口号。比如我们现在已经在本机启动了服务器进程,监听的端口号为3307,那我们启动客户端程序时可以这样写: mysql -h127.0.0.1 -uroot -P3307 -p 不知大家发现了没有,我们在启动服务器程序的命令mysqld和启动客户端程序的命令mysql后边都可以使用-P参数,关于如何在命令后边指定参数,指定哪些参数我们稍后会详细介绍的,稍微等等~\n命名管道和共享内存 # 如果你是一个Windows用户,那么客户端进程和服务器进程之间可以考虑使用命名管道或共享内存进行通信。不过启用这些通信方式的时候需要在启动服务器程序和客户端程序时添加一些参数:\n使用命名管道来进行进程间通信\n需要在启动服务器程序的命令中加上--enable-named-pipe参数,然后在启动客户端程序的命令中加入--pipe或者--protocol=pipe参数。\n使用共享内存来进行进程间通信\n需要在启动服务器程序的命令中加上--shared-memory参数,在成功启动服务器后,共享内存便成为本地客户端程序的默认连接方式,不过我们也可以在启动客户端程序的命令中加入--protocol=memory参数来显式的指定使用共享内存进行通信。\n不过需要注意的是,使用共享内存的方式进行通信的服务器进程和客户端进程必须在同一台Windows主机中。 小贴士:命名管道和共享内存是Windows操作系统中的两种进程间通信方式,如果你没听过的话也不用纠结,并不妨碍我们介绍MySQL的知识~\nUnix域套接字文件 # 如果我们的服务器进程和客户端进程都运行在同一台操作系统为类Unix的机器上的话,我们可以使用Unix域套接字文件来进行进程间通信。如果我们在启动客户端程序的时候指定的主机名为localhost,或者指定了--protocol=socket的启动参数,那服务器程序和客户端程序之间就可以通过Unix域套接字文件来进行通信了。MySQL服务器程序默认监听的Unix域套接字文件路径为/tmp/mysql.sock,客户端程序也默认连接到这个Unix域套接字文件。如果我们想改变这个默认路径,可以在启动服务器程序时指定socket参数,就像这样: mysqld --socket=/tmp/a.txt 这样服务器启动后便会监听/tmp/a.txt。在服务器改变了默认的UNIX域套接字文件后,如果客户端程序想通过UNIX域套接字文件进行通信的话,也需要显式的指定连接到的UNIX域套接字文件路径,就像这样: mysql -hlocalhost -uroot --socket=/tmp/a.txt -p 这样该客户端进程和服务器进程就可以通过路径为/tmp/a.txt的Unix域套接字文件进行通信了。\n服务器处理客户端请求 # 其实不论客户端进程和服务器进程是采用哪种方式进行通信,最后实现的效果都是:客户端进程向服务器进程发送一段文本(MySQL语句),服务器进程处理后再向客户端进程发送一段文本(处理结果)。那服务器进程对客户端进程发送的请求做了什么处理,才能产生最后的处理结果呢?客户端可以向服务器发送增删改查各类请求,我们这里以比较复杂的查询请求为例来画个图展示一下大致的过程:\n从图中我们可以看出,服务器程序处理来自客户端的查询请求大致需要经过三个部分,分别是连接管理、解析与优化、存储引擎。下面我们来详细看一下这三个部分都干了什么。\n连接管理 # 客户端进程可以采用我们上面介绍的TCP/IP、命名管道或共享内存、Unix域套接字这几种方式之一来与服务器进程建立连接,每当有一个客户端进程连接到服务器进程时,服务器进程都会创建一个线程来专门处理与这个客户端的交互,当该客户端退出时会与服务器断开连接,服务器并不会立即把与该客户端交互的线程销毁掉,而是把它缓存起来,在另一个新的客户端再进行连接时,把这个缓存的线程分配给该新客户端。这样就起到了不频繁创建和销毁线程的效果,从而节省开销。从这一点大家也能看出,MySQL服务器会为每一个连接进来的客户端分配一个线程,但是线程分配的太多了会严重影响系统性能,所以我们也需要限制一下可以同时连接到服务器的客户端数量,至于怎么限制我们后边再说~\n在客户端程序发起连接的时候,需要携带主机信息、用户名、密码,服务器程序会对客户端程序提供的这些信息进行认证,如果认证失败,服务器程序会拒绝连接。另外,如果客户端程序和服务器程序不运行在一台计算机上,我们还可以采用使用了SSL(安全套接字)的网络连接进行通信,来保证数据传输的安全性。\n当连接建立后,与该客户端关联的服务器线程会一直等待客户端发送过来的请求,MySQL服务器接收到的请求只是一个文本消息,该文本消息还要经过各种处理,预知后事如何,继续往下看~\n解析与优化 # 到现在为止,MySQL服务器已经获得了文本形式的请求,接着还要经过九九八十一难的处理,其中的几个比较重要的部分分别是查询缓存、语法解析和查询优化,下面我们详细来看。\n查询缓存 # 如果我问你9+8×16-3×2×17的值是多少,你可能会用计算器去算一下,或者牛逼一点用心算,最终得到了结果35,如果我再问你一遍9+8×16-3×2×17的值是多少,你还会再傻呵呵的算一遍么?我们刚刚已经算过了,直接说答案就好了。MySQL服务器程序处理查询请求的过程也是这样,会把刚刚处理过的查询请求和结果缓存起来,如果下一次有一模一样的请求过来,直接从缓存中查找结果就好了,就不用再傻呵呵的去底层的表中查找了。这个查询缓存可以在不同客户端之间共享,也就是说如果客户端A刚刚查询了一个语句,而客户端B之后发送了同样的查询请求,那么客户端B的这次查询就可以直接使用查询缓存中的数据。\n当然,MySQL服务器并没有人聪明,如果两个查询请求在任何字符上的不同(例如:空格、注释、大小写),都会导致缓存不会命中。另外,如果查询请求中包含某些系统函数、用户自定义变量和函数、一些系统表,如 mysql 、information_schema、 performance_schema 数据库中的表,那这个请求就不会被缓存。以某些系统函数举例,可能同样的函数的两次调用会产生不一样的结果,比如函数NOW,每次调用都会产生最新的当前时间,如果在一个查询请求中调用了这个函数,那即使查询请求的文本信息都一样,那不同时间的两次查询也应该得到不同的结果,如果在第一次查询时就缓存了,那第二次查询的时候直接使用第一次查询的结果就是错误的!\n不过既然是缓存,那就有它缓存失效的时候。MySQL的缓存系统会监测涉及到的每张表,只要该表的结构或者数据被修改,如对该表使用了INSERT、 UPDATE、DELETE、TRUNCATE TABLE、ALTER TABLE、DROP TABLE或 DROP DATABASE语句,那使用该表的所有高速缓存查询都将变为无效并从高速缓存中删除! 小贴士:虽然查询缓存有时可以提升系统性能,但也不得不因维护这块缓存而造成一些开销,比如每次都要去查询缓存中检索,查询请求处理完需要更新查询缓存,维护该查询缓存对应的内存区域。从MySQL 5.7.20开始,不推荐使用查询缓存,并在MySQL 8.0中删除。\n语法解析 # 如果查询缓存没有命中,接下来就需要进入正式的查询阶段了。因为客户端程序发送过来的请求只是一段文本而已,所以MySQL服务器程序首先要对这段文本做分析,判断请求的语法是否正确,然后从文本中将要查询的表、各种查询条件都提取出来放到MySQL服务器内部使用的一些数据结构上来。\n小贴士:这个从指定的文本中提取出我们需要的信息本质上算是一个编译过程,涉及词法解析、语法分析、语义分析等阶段,这些问题不属于我们讨论的范畴,大家只要了解在处理请求的过程中需要这个步骤就好了。\n查询优化 # 语法解析之后,服务器程序获得到了需要的信息,比如要查询的列是哪些,表是哪个,搜索条件是什么等等,但光有这些是不够的,因为我们写的MySQL语句执行起来效率可能并不是很高,MySQL的优化程序会对我们的语句做一些优化,如外连接转换为内连接、表达式简化、子查询转为连接等等的一堆东西。优化的结果就是生成一个执行计划,这个执行计划表明了应该使用哪些索引进行查询,表之间的连接顺序是什么样的。我们可以使用EXPLAIN语句来查看某个语句的执行计划,关于查询优化这部分的详细内容我们后边会仔细介绍,现在你只需要知道在MySQL服务器程序处理请求的过程中有这么一个步骤就好了。\n存储引擎 # 截止到服务器程序完成了查询优化为止,还没有真正的去访问真实的数据表,MySQL服务器把数据的存储和提取操作都封装到了一个叫存储引擎的模块里。我们知道表是由一行一行的记录组成的,但这只是一个逻辑上的概念,物理上如何表示记录,怎么从表中读取数据,怎么把数据写入具体的物理存储器上,这都是存储引擎负责的事情。为了实现不同的功能,MySQL提供了各式各样的存储引擎,不同存储引擎管理的表具体的存储结构可能不同,采用的存取算法也可能不同。 小贴士:为什么叫引擎呢?因为这个名字更拉风~ 其实这个存储引擎以前叫做表处理器,后来可能人们觉得太土,就改成了存储引擎的叫法,它的功能就是接收上层传下来的指令,然后对表中的数据进行提取或写入操作。 为了管理方便,人们把连接管理、查询缓存、语法解析、查询优化这些并不涉及真实数据存储的功能划分为MySQL server的功能,把真实存取数据的功能划分为存储引擎的功能。各种不同的存储引擎向上面的MySQL server层提供统一的调用接口(也就是存储引擎API),包含了几十个底层函数,像\u0026quot;读取索引第一条内容\u0026quot;、\u0026ldquo;读取索引下一条内容\u0026rdquo;、\u0026ldquo;插入记录\u0026quot;等等。\n所以在MySQL server完成了查询优化后,只需按照生成的执行计划调用底层存储引擎提供的API,获取到数据后返回给客户端就好了。\n常用存储引擎 # MySQL支持非常多种存储引擎,我这先列举一些: 存储引擎 描述 ARCHIVE 用于数据存档(行被插入后不能再修改) BLACKHOLE 丢弃写操作,读操作会返回空内容 CSV 在存储数据时,以逗号分隔各个数据项 FEDERATED 用来访问远程表 InnoDB 具备外键支持功能的事务存储引擎 MEMORY 置于内存的表 MERGE 用来管理多个MyISAM表构成的表集合 MyISAM 主要的非事务处理存储引擎 NDB MySQL集群专用存储引擎 这么多我们怎么挑啊,你多虑了,其实我们最常用的就是InnoDB和MyISAM,有时会提一下Memory。其中InnoDB是MySQL默认的存储引擎,我们之后会详细介绍这个存储引擎的各种功能,现在先看一下一些存储引擎对于某些功能的支持情况: Feature MyISAM Memory InnoDB Archive NDB B-tree indexes yes yes yes no no Backup/point-in-time recovery yes yes yes yes yes Cluster database support no no no no yes Clustered indexes no no yes no no Compressed data yes no yes yes no Data caches no N/A yes no yes Encrypted data yes yes yes yes yes Foreign key support no no yes no yes Full-text search indexes yes no yes no no Geospatial data type support yes no yes yes yes Geospatial indexing support yes no yes no no Hash indexes no yes no no yes Index caches yes N/A yes no yes Locking granularity Table Table Row Row Row MVCC no no yes no no Query cache support yes yes yes yes yes Replication support yes Limited yes yes yes Storage limits 256TB RAM 64TB None 384EB T-tree indexes no no no no yes Transactions no no yes no yes Update statistics for data dictionary yes yes yes yes yes 密密麻麻列了这么多,看的头皮都发麻了,达到的效果就是告诉你:这玩意儿很复杂。其实这些东西大家没必要立即就给记住,我列出来的目的就是想让大家明白不同的存储引擎支持不同的功能,有些重要的功能我们会在后边的介绍中慢慢让大家理解的~\n关于存储引擎的一些操作 # 查看当前服务器程序支持的存储引擎 # 我们可以用下面这个命令来查看当前服务器程序支持的存储引擎: SHOW ENGINES; 来看一下调用效果: ``` mysql\u0026gt; SHOW ENGINES; +\u0026mdash;\u0026mdash;\u0026mdash;\u0026mdash;\u0026mdash;\u0026mdash;\u0026ndash;+\u0026mdash;\u0026mdash;\u0026mdash;+\u0026mdash;\u0026mdash;\u0026mdash;\u0026mdash;\u0026mdash;\u0026mdash;\u0026mdash;\u0026mdash;\u0026mdash;\u0026mdash;\u0026mdash;\u0026mdash;\u0026mdash;\u0026mdash;\u0026mdash;\u0026mdash;\u0026mdash;\u0026mdash;\u0026mdash;\u0026mdash;\u0026mdash;-+\u0026mdash;\u0026mdash;\u0026mdash;\u0026mdash;\u0026ndash;+\u0026mdash;\u0026mdash;+\u0026mdash;\u0026mdash;\u0026mdash;\u0026mdash;+ | Engine | Support | Comment | Transactions | XA | Savepoints | +\u0026mdash;\u0026mdash;\u0026mdash;\u0026mdash;\u0026mdash;\u0026mdash;\u0026ndash;+\u0026mdash;\u0026mdash;\u0026mdash;+\u0026mdash;\u0026mdash;\u0026mdash;\u0026mdash;\u0026mdash;\u0026mdash;\u0026mdash;\u0026mdash;\u0026mdash;\u0026mdash;\u0026mdash;\u0026mdash;\u0026mdash;\u0026mdash;\u0026mdash;\u0026mdash;\u0026mdash;\u0026mdash;\u0026mdash;\u0026mdash;\u0026mdash;-+\u0026mdash;\u0026mdash;\u0026mdash;\u0026mdash;\u0026ndash;+\u0026mdash;\u0026mdash;+\u0026mdash;\u0026mdash;\u0026mdash;\u0026mdash;+ | InnoDB | DEFAULT | Supports transactions, row-level locking, and foreign keys | YES | YES | YES | | MRG_MYISAM | YES | Collection of identical MyISAM tables | NO | NO | NO | | MEMORY | YES | Hash based, stored in memory, useful for temporary tables | NO | NO | NO | | BLACKHOLE | YES | /dev/null storage engine (anything you write to it disappears) | NO | NO | NO | | MyISAM | YES | MyISAM storage engine | NO | NO | NO | | CSV | YES | CSV storage engine | NO | NO | NO | | ARCHIVE | YES | Archive storage engine | NO | NO | NO | | PERFORMANCE_SCHEMA | YES | Performance Schema | NO | NO | NO | | FEDERATED | NO | Federated MySQL storage engine | NULL | NULL | NULL | +\u0026mdash;\u0026mdash;\u0026mdash;\u0026mdash;\u0026mdash;\u0026mdash;\u0026ndash;+\u0026mdash;\u0026mdash;\u0026mdash;+\u0026mdash;\u0026mdash;\u0026mdash;\u0026mdash;\u0026mdash;\u0026mdash;\u0026mdash;\u0026mdash;\u0026mdash;\u0026mdash;\u0026mdash;\u0026mdash;\u0026mdash;\u0026mdash;\u0026mdash;\u0026mdash;\u0026mdash;\u0026mdash;\u0026mdash;\u0026mdash;\u0026mdash;-+\u0026mdash;\u0026mdash;\u0026mdash;\u0026mdash;\u0026ndash;+\u0026mdash;\u0026mdash;+\u0026mdash;\u0026mdash;\u0026mdash;\u0026mdash;+ 9 rows in set (0.00 sec)\nmysql\u0026gt; ```\n其中的Support列表示该存储引擎是否可用,DEFAULT值代表是当前服务器程序的默认存储引擎。Comment列是对存储引擎的一个描述,英文的,将就着看吧。Transactions列代表该存储引擎是否支持事务处理。XA列代表该存储引擎是否支持分布式事务。Savepoints代表该列是否支持部分事务回滚。 小贴士:好吧,也许你并不知道什么是个事务、更别提分布式事务了,这些内容我们在后边的章节会详细介绍,现在瞅一眼看个新鲜就行。\n设置表的存储引擎 # 我们前面说过,存储引擎是负责对表中的数据进行提取和写入的,我们可以为不同的表设置不同的存储引擎,也就是说不同的表可以有不同的物理存储结构,不同的提取和写入方式。\n创建表时指定存储引擎 # 我们之前创建表的语句都没有指定表的存储引擎,那就会使用默认的存储引擎InnoDB(当然这个默认的存储引擎也是可以修改的,我们在后边的章节中再说怎么改)。如果我们想显式的指定一下表的存储引擎,那可以这么写: CREATE TABLE 表名( 建表语句; ) ENGINE = 存储引擎名称; 比如我们想创建一个存储引擎为MyISAM的表可以这么写: ``` mysql\u0026gt; CREATE TABLE engine_demo_table( -\u0026gt; i int -\u0026gt; ) ENGINE = MyISAM; Query OK, 0 rows affected (0.02 sec)\nmysql\u0026gt; ```\n修改表的存储引擎 # 如果表已经建好了,我们也可以使用下面这个语句来修改表的存储引擎: ALTER TABLE 表名 ENGINE = 存储引擎名称; 比如我们修改一下engine_demo_table表的存储引擎: ``` mysql\u0026gt; ALTER TABLE engine_demo_table ENGINE = InnoDB; Query OK, 0 rows affected (0.05 sec) Records: 0 Duplicates: 0 Warnings: 0\nmysql\u0026gt; 这时我们再查看一下engine_demo_table的表结构: mysql\u0026gt; SHOW CREATE TABLE engine_demo_table\\G *************************** 1. row *************************** Table: engine_demo_table Create Table: CREATE TABLE engine_demo_table ( i int 11)DEFAULTNULL11) DEFAULT NULL ENGINE=InnoDB DEFAULT CHARSET=utf8 1 row in set (0.01 sec)\nmysql\u0026gt; ``` 可以看到该表的存储引擎已经改为InnoDB了。\n"},{"id":32,"href":"/zh/docs/technology/MySQL/MySQL%E6%98%AF%E6%80%8E%E6%A0%B7%E8%BF%90%E8%A1%8C%E7%9A%84/%E7%AC%AC0%E7%AB%A0_%E4%B8%87%E9%87%8C%E9%95%BF%E5%BE%81%E7%AC%AC%E4%B8%80%E6%AD%A5%E9%9D%9E%E5%B8%B8%E9%87%8D%E8%A6%81-%E5%A6%82%E4%BD%95%E6%84%89%E5%BF%AB%E7%9A%84%E9%98%85%E8%AF%BB%E6%9C%AC%E5%B0%8F%E5%86%8C/","title":"第0章_万里长征第一步(非常重要)-如何愉快的阅读本小册","section":"My Sql是怎样运行的","content":"第0章 万里长征第一步(非常重要)-如何愉快的阅读本小册\n购买前警告⚠️ # 此小册并非数据库入门书籍,需要各位知道增删改查是什么意思,并且能用 SQL 语言写出来,当然并不要求各位知道的太多,你甚至可以不知道连接的语法都可以。不过如果你连SELECT、INSERT这些单词都没听说过那本小册并不适合你。 此小册非正经科学专著,亦非十二五国家级规划教材,也没有大段代码和详细论证,有的全是图,喜欢正经论述的同学请避免购买本小册。 此小册作者乃一无业游民,非专业大佬,没有任何职称,只是单单喜欢把复杂问题讲清楚的那种快感,所以喜欢作者有 Google、Facebook 高级开发工程师,二百年工作经验等 Title 的同学请谨慎购买。 此小册是用于介绍 MySQL 的工作原理以及对我们程序猿的影响,并不是介绍概念设计、逻辑设计、物理设计、范式化之类的数据库设计方面的知识,希望了解上述这些知识的同学来错地方了。 文章标题中的“从根儿上理解MySQL”**其实是专门雇了 UC 震惊部小编起的,纯属为了吸引大家眼球。严格意义上说,本书只是介绍MySQL内核的一些核心概念的小白进阶书籍。大家读完本小册也不会一下子晋升业界大佬,当上 CTO,迎娶白富美,走上人生巅峰。希望本小册能够帮助大家解决一些工作、面试过程中的问题,逐渐成为一个更好的工程师,有兴趣的小伙伴可以再深入研究一下 MySQL,说不定你就是下一个数据库泰斗啦。 购买并阅读本小册的建议 # 本小册是一本待出版的纸质书籍,并非一些杂碎文章的集合,是非常有结构和套路的,所以大家阅读时千万不能当作厕所蹲坑、吃饭看手机时的所谓碎片化读物。碎片化阅读只适合听听矮大紧、罗胖子他们扯扯犊子,开阔一下视野用的。对于专业的技术知识来说,大家必须付出一个完整的时间段进行体系化学习,这样尊重知识,工资才能尊重你。 顺便说一句,我已经好久都不听罗胖子扯犊子了,刚开始办罗辑思维的时候觉得他扯的还可以,越往后越觉得都钻钱眼儿里了,天天在鼓吹焦虑,让大家去买他们的鸡汤课。不过听听矮大紧就挺好啊,不累~ 本小册是由 Markdown 写成,在电脑端阅读体验十分舒服,当然你非要用小手机看我也不拦着你,但是效果打了折扣是你的损失。 为了保证最好的阅读体验,不用一个没学过的概念去介绍另一个新概念,本小册的章节有严重的依赖性,比如你在没读InnoDB数据页结构前千万不要就去读B+树索引,所以大家最好从前看到尾,不要跳着看!不要跳着看!不要跳着看!,当然,不听劝告我也不能说什么,祝你好运。 大家可能买过别的小册,有的小册一篇文章可能用5分钟、10分钟读完,不过我的小册子每一篇文章都比较长,因为我把高耦合的部分都集中在一篇文章中了。文章中埋着各种伏笔,所以大家看的时候可能不会觉察出来很突兀的转变,所以在阅读一篇文章的时候千万不要跳着看!不要跳着看!不要跳着看! 大家在看本小册之前应该断断续续看过一些与本小册内容相关的知识,只是不成体系,细节学习的不够。对于这部分读者来说,希望大家像倚天屠龙记里的张无忌一样,在学张三丰的太极剑法时先忘记之前的武功,忘的越干净,学的越得真传。这样才能跟着我的套路走下去。 如果你真的是个小白的话,那这里头的数字都是假的: 一篇文章能用2个小时左右的时间掌握就很不错了。说句扫大家兴的话,虽然我已经很努力的想让大家的学习效率提升n倍,但是不幸的是想掌握一门核心技术仍然需要大家多看几遍(不然工资那么好涨啊~)。\n关于工具 # 本小册中会涉及很多 InnoDB 的存储结构的知识,比如记录结构、页结构、索引结构、表空间结构等等,这些知识是所有后续知识的基础,所以是重中之重,需要大家认真对待。Jeremy Cole 已经使用 Ruby 开发了一个简易的解析这些基础结构的工具,github地址是: innodb_ruby的github地址,大家可以按照说明安装上这个工具,可以更好的理解 InnoDB 中的一些存储结构(此工具虽然是针对MySQL 5.6的,但是幸好MySQL的基础存储结构基本没多大变化,所以大部分场景下这个innodb_ruby工具还是可以使用的)。\n关于盗版 # 在写这本小册之前,我天真的以为只需要找几本参考书,看看 MySQL 的官方文档,遇到不会的地方百度谷歌一下就可以在 3 个月内解决这本书,后来的现实证明我真的想的太美了。不仅花了大量的时间阅读各种书籍和源码,而且有的时候知识耦合太厉害,为了更加模块化的把知识表述清楚,我又花了大量的时间来思考如何写作才能符合用户认知习惯,还花了非常多的时间来画各种图表,总之就是心累啊~ 我希望的是:各位同学可以用很低的成本来更快速学会一些看起来生涩难懂的知识,但是毕竟我不是马云,不能一心一意做公益,希望各位通过正规渠道获得小册,尊重一下版权。 还有各位写博客的同学,引用的少了叫借鉴,引用的多了就,就有点那个了。希望各位不要大段大段的复制粘贴,用自己的话写出来的知识才是自己的东西。 我知道不论我们怎样强调版权意识,总是有一部分小伙伴喜欢不劳而获,总是喜欢想尽各种渠道来弄一份盗版的看,希望这部分同学看完别忘了关注公众号【我们都是小青蛙】,给我填个粉儿也算是赞助一下我(下面是二维码,觉得有帮助的话希望可以打赏一下,毕竟本人很穷。另外,公众号中有若干篇小册的补充文章,包括三篇极其重要的语句加锁分析):\n小贴士:我一直有个想法,就是如何降低教育成本。现在教育的盈利收费模式都太单一,就是直接跟学生收上课费,导致课程成为一种2C的商品,价格高低其实和内容质量并不是很相关,所以课程提供商会投入更大的精力做他们的渠道营销。所以现在的在线教育市场就是渠道为王,招生为王。我们其实可以换一种思路,在线教育的优势其实是传播费用更低,一个人上课和一千万人上课的费用区别其实就是服务器使用的多少罢了,所以我们可能并不需要那么多语文老师、数学老师,我们用专业的导演、专业的声优、专业的动画制作、专业的后期、专业的剪辑、专业的编剧组成的团队为某个科目制作一个专业的课程就好了嘛(顺便说一句,我就可以转行做课程编剧了)!把课程当作电影、电视剧来卖,只要在课程中植入广告,或者在播放平台上加广告就好了嘛,我们也可以在课程里培养偶像,来做一波粉丝经济。这样课程生产方也赚钱,学生们也省钱,最主要的是可以更大层度上促进教育公平,多好。\n关于错误 # 准确性问题 # 我不是神,并不是书中的所有内容我都一一对照源码来验证准确性(阅读的大部分源码是关于查询优化和事务处理的),如果各位发现了文中有准确性问题请直接联系我,我会加入 Bug 列表中修正的。\n阅读体验问题 # 大家知道大部分人在长大之后就忘记了自己小时候的样子,我写本书的初衷就是有很多资料我看不懂,看的我脑壳疼,之后才决定从小白的角度出发来写一本小白都能看懂的技术书籍。但是由于后来自己学的东西越来越多,可能有些地方我已经忘掉了小白的想法是怎么样的,所以大家在阅读过程中有任何阅读不畅快的地方都可以给我提,我也会加入bug列表中逐一优化。\n关于转发 # 如果你从本小册中获取到了自己想要的知识,并且这个过程是比较轻松愉快的,希望各位能帮助转发本小册,解放一下学不懂这些知识的童鞋们,多节省一下他们的学习时间以及让学习过程不再那么痛苦。大家的技术都长进了,咱国家的技术也就慢慢强起来了。\n关于疑惑 # 虽然我觉得文章写的已经很清晰了,但毕竟只是“我觉得”,不是大家觉得。传道授业解惑,解惑很重要。在学习一门知识时,我们最容易让一些问题绊住脚步,大家在阅读小册时如果发现了任何你觉得让你很困惑的问题,都可以直接加微信 xiaohaizi4919 问我,或者到群里提问题(最好到群里提,这样大家都能看到,也省的重复提问),我在力所能及的范围内尽力帮大家解答。 闲话 # 如果有的同学购买本小册后觉得并不是自己的菜,那很遗憾,我不能给你退款,钱是掘金这个平台收的。不过我还是觉得绝大部分同学读过后肯定有物超所值的感受,面试一般的数据库问题再也难不倒各位了,工作中一般的数据库问题也都是小菜一碟了,想继续研究 MySQL 源码的同学也找到方向了,如果你觉得 29.9 元不能表达你淘到宝的喜悦之情,那这好说,给我发红包就好了。\n"},{"id":33,"href":"/zh/docs/culture/%E6%B1%89%E5%AD%97%E5%B0%B1%E6%98%AF%E8%BF%99%E4%B9%88%E6%9D%A5%E7%9A%84/01%E8%B5%B0%E8%BF%9B%E6%B1%89%E5%AD%97%E4%B8%96%E7%95%8C/","title":"01走进汉字世界","section":"汉字就是这么来的","content":"01走进汉字世界\n封面 # 版权 # 版权信息\n书名:汉字就是这么来的·走进汉字世界\n作者:孟琢\n出版社:湖南少年儿童出版社\n出版时间:2020-08-01\nISBN:9787556251919\n本书由天津博集新媒科技有限公司授权亚马逊发行\n版权所有 侵权必究\n汉字的起源 # 汉字是中国文化的根源,蕴含着中华文明悠久古老的基因。当我们走进汉字世界的时候,先要思考一个重要的问题:汉字的源头是什么?\n对这个问题的回答,真是众说纷纭。在历史上,有三种比较重要的说法:八卦造字、结绳造字和仓颉造字。\n汉字与八卦 # 八卦,听起来蛮神奇的!中华民族的始祖伏羲氏[1]创造了八卦。他在大自然中看到了天空、大地、沼泽、大湖,还有清澈的水流、熊熊的烈火和浩浩荡荡的长风,听到了响彻苍穹的雷鸣。在伏羲看来,万事万物都分为阴阳,他用一个长横()表示阳爻,用两个短横()表示阴爻,再用阴阳进行组合,就拼出了八卦,分别代表天、地、山、泽、水、火、风、雷。\n在中国文化中,八卦相当重要。有一部听起来有些神秘的古老经典——《周易》,就是以八卦的道理为根本的。于是,有人认为汉字也起源于八卦。他们举了一个很有意思的例子:在八卦中,有一个坎卦,表示水的意思。你看,坎卦是这个样子的:。主张汉字起源于八卦的人们说,把坎卦旋转九十度竖起来,和古文字中的(水)非常像。\n你别说,对比一下还真像!那汉字真的起源于八卦吗?不是的,八卦中的另外七个卦和汉字一点儿关系也没有,样子也不相似。坎卦和水的相似,应该只是一种偶然。\n结绳造字 # 还有一种说法,认为汉字起源于结绳。\n什么叫作结绳呢?这是古人提醒自己别忘事的一种办法。他们随身携带一根绳子,有大事,就系一个大疙瘩,有小事,就系一个小疙瘩。这种风俗见于世界各地,有些比较原始的部落,现在还在使用结绳记事。\n结绳能够帮助我们记事,汉字有同样的功能,于是有人认为,汉字起源于结绳。我们仔细想一想,就会发现这种说法并不靠谱——绳子上的疙瘩能提醒我们有一件重要的事,但它无法告诉我们,到底是什么事。你是要去上学呢?还是要去吃火锅呢?还是去游乐场玩耍呢?但汉字就不一样了,汉字能够传达准确而具体的信息。\n仓颉:汉字的始祖 # 在造字的传说中,仓颉造字的说法流传最广。\n仓颉是谁?相传他生活在公元前2500多年,距今有4500多年。仓颉是黄帝时期的史官,掌管着历史、文化与文献,这个身份和汉字的关系最为密切,古人认为是他发明了汉字。这个时间和我们今天发现的早期文字符号的时期,也是相当接近的。\n根据一些古书中的记载,仓颉的样子很奇怪,“龙颜侈侈,四目灵光,实有睿德,生而能书”。这句话说的是,仓颉的额头很大,像龙一样,他有四个眼睛,绽放出闪闪的灵光,这个人非常聪明,生下来就能写字——哇,仓颉好厉害!\n仓颉真长这个样子吗?不见得。古人习惯于将一些厉害的人物神化,来凸显他们的与众不同。他们往往出生时伴有奇怪的天象,长相也异于常人。伏羲是人的脑袋蛇的身子;蚩尤和兄弟们都是铜头铁额,真是够酷。没这么夸张,稍微低调一点儿的也有。三国时期的刘备双耳垂肩,双手过膝,耳垂和手的长度远非常人能比;明朝开国皇帝朱元璋脚上有七颗痣,有个厉害的说法叫作“脚踏七星”。所以,仓颉的长相也是经过了艺术的夸张。\n仓颉是如何造字的呢?古代有一位大思想家叫韩非子,他记载了仓颉造字的一个思路,他说:“仓颉之作书也,自环者谓之私,背私者谓之公。”仓颉要给公开的“公”和自私的“私”造字。\n先造“私”字,这个字的意思有点儿抽象,用什么样的字形来表达呢?想来想去,他找到了一个造字的好办法:画一个封闭的圆圈,圈里的东西都是我的,也就是“厶”。这个字是今天“私”字的右半边。在古人心中,什么东西最能表示私有呢?莫过于粮食!于是,聪明的古人在“厶”旁边再添加一个“禾”,创造出“私”,强调这是我私有的财物。\n“私”造出来了,“公”又该如何造字呢?这个问题难不住聪明的仓颉!他说,“公”和“私”的意思是相反的,把私有的财物分给大家,不就是“公”嘛。因此,他在“厶”的上面加上了一个“八”字。在数字中,一分为二,二分为四,四分为八,都是一个不断分离的过程。因此“八”表示分的意思,把“私”用“八”不断分开,这就是“公”!\n仓颉造字,体现出高超的智慧!事实上,汉字不可能是某一个人造出来的,中华民族宝贵的文化遗产,是人民大众的智慧结晶。战国的大思想家荀子点明了仓颉造字的实质。《荀子》中说:“好书者众矣,而仓颉独传者,壹也。”喜欢写字、善于造字的人很多,但只有仓颉造字的名声千古流传,这是为什么呢?因为仓颉“专壹”!\n请注意,大写的“壹”字,既表示专一,仓颉一心一意地创造汉字;也表示规范、整理与统一的意思。仓颉是汉字的整理者、规范者与统一者,他是古人发明汉字的伟大的历史运动中的代表。\n正因如此,仓颉被后人尊称为“字圣”——发明汉字的圣人。\n随着汉字的发明,我们的历史文化大大地向前迈了一步。相传汉字发明之后,“天雨粟,鬼夜哭”——天上落下粮食雨,晚上有鬼在哭泣,听上去是不是有点儿恐怖呢?事实上,“天雨粟,鬼夜哭”是个吉兆呢!上天降下粮食雨,奖励人们发明了汉字;鬼也不是可怕的妖怪,所谓“鬼者,归也”,他们是“回归”故乡的祖先灵魂。那些去世的祖先觉得,有了汉字,他们的生平事迹就会被记录在文字里,再也不会被后人遗忘了,因此纷纷喜极而泣。\n汉字源自图画 # 传说听起来很热闹,但如果根据考古来看,汉字应当起源于早期的原始图画。大家跟我看大汶口文化的图画型陶符,以及半坡文化中的彩陶符号。在这些刻画在陶器上的符号中,有圆圆的太阳,有巍峨的高山,有抽象的小草……和汉字中的象形文字,真是如出一辙。\n在原始图画和汉字的对比中,这样的例子还有很多。你看,自由自在的鱼儿、展翅欲飞的小鸟、姿态优雅的小鹿,这些原始绘画摇身一变,就成了最早的汉字。\n图画是形象的,最早的汉字也是如此。古人造字的基本思路是“近取诸身,远取诸物”,从自己熟悉的身体和丰富多彩的大自然中选取形象,造出汉字。取象,是汉字造字的基本思路,也是我们给大家讲解汉字的基本视角。\n[1] 在神话传说中,伏羲和女娲是兄妹,是上古时代人类的始祖。在《伏羲女娲图》中能看到他们俩蛇身人首的样子,充满神话特有的浪漫和想象色彩。传说他发明了八卦,教会人们结网捕鱼,还发明了乐器——琴瑟。\n甲骨文-传奇的开始 # 甲骨文,是我们今天能见到的最早的汉字。\n甲骨文是汉字历史上的一个传奇,它是如何被发现的呢?它是什么时代的文字?甲骨文都写了些什么?又有怎样的特点呢?让我们带着疑问,一起走进甲骨文的世界吧。\n神奇的发现 # 甲骨文,为什么叫这个名字?\n我们一起来看两幅图。上面这幅是乌龟的腹甲,也就是肚子上的甲壳,下面这幅是牛的肩胛骨,两个骨片上都刻着密密麻麻的文字。那我们就清楚了,甲骨文的命名来源于它的载体,这是一种刻在龟甲和兽骨上的文字。\nVCG21gic3609468-©View Stock/VSI美好景象(Creative)/视觉中国\nVCG21gic2728673-©UGI/UGI优谷/视觉中国\n甲骨文的发现很传奇,发现甲骨文的人叫王懿(yì)荣,他是清朝的大臣,也是一位金石学[1]家,也就是研究古文字的人。大概是1899年的时候,王懿荣生病了,要吃中药。他一时兴起,想看看这次中药的成色如何,就拿了一包药打开翻检。没承想,在一味叫作“龙骨”的药材上,他看到了类似文字的刻痕。\n什么是龙骨?相传是龙的遗骨,吃下去能够补肾,听上去是不是有点儿高大上?其实,龙骨就是古代动物的骨骼化石。我们说过,王懿荣熟悉古文字,一般人看到龙骨上的刻痕,也许不会注意,但王懿荣一眼扫过去,却是大惊失色!\n“啊!这龙骨上面刻的东西,好像是文字!来人,快去把药店里的龙骨都买回来,我要好好研究一下。”\n这一研究不要紧,王懿荣就此发现了甲骨文。这对中国的文字学和历史学来说,都是一件开天辟地的大事。甲骨文让我们见到了最早的文字,也让我们看到了尘封已久的殷商王朝的历史。\n占卜的秘密 # 甲骨文所处的时代,是历史上的殷商时期。如果你看过《封神演义》的话,会知道有个残暴的商纣王[2],他的名字也曾出现在甲骨文中。甲骨文里都写了些什么呢?它主要记录了古代占卜的相关内容,因此,甲骨文也叫作卜辞文字。\n什么是占卜?这个事说来话长。殷商是一个非常迷信的时代,那个时候大大小小的事情,都要通过占卜来进行预测。用什么来占卜呢?选来选去,古人盯上了乌龟的壳。也许,他们觉得乌龟长寿,见多识广,因此能够预测未来吧。\n占卜的流程很复杂,古人先要把乌龟的腹甲,或者牛的肩胛骨锯下来,然后磨平。磨平之后,再用铜钻在上面钻出又深又圆的孔,再在圆孔旁边凿出枣核形的槽。又钻又凿,折腾了一大通,甲骨的表面就不再平整了。接下来,古人会用火去烧这个甲骨。不平整的东西受热不均匀,就容易爆裂。在爆裂的时候,发出“bu bu”的声音,这就是“卜”字的声音来源。爆裂之后,甲骨上会出现裂纹,这些裂纹的样子,就是“卜”字的形体来源。\n翻看前文牛的肩胛骨的图片,它上面的裂纹与甲骨文中的(卜)字,是不是很像呢?\n在我们看来,这些裂纹是随机的、偶然的,但在古人眼中,它们却有一种神奇的力量,能够揭示未来的吉凶祸福!古代有专门负责占卜的巫师家族,那些大巫头上插着炫目的羽毛,穿着与众不同的衣裳,神情肃穆地看着甲骨上的裂纹,为商王判断吉凶。有时候,尊贵的商王甚至会亲自进行占卜。\n这段历史也烙印在汉字里。在古文字中,“占”字上面是一个“卜”,下面是一个“口”。对“卜”(裂纹)的解释与预测,就是占卜。占卜之后,古人会把这次占卜的前因后果记录在甲骨上,记录使用的文字就是甲骨文!在殷商时期,无论是天时、祭祀、战争、农业、生活,各种各样的事情都要占卜,因此,甲骨文中也就记载了那个时代的方方面面。\n想学占卜吗?在考试之前预测吉凶,看看自己的分数如何?\n还是算了吧。第一,小乌龟蛮可怜的,不忍心!第二,占卜的方法早就失传了,学不到!\n最早的记叙文 # 古人占卜完后,在甲骨上用古老的文字记录占卜的相关信息。记录的体例和格式是什么样子的呢?\n在回答这个问题之前,我要先问你一个问题:你会写作文吗?\n这有什么难的!一篇记叙文,把时间、地点、人物、事件,清清楚楚地写下来就是了。嗯,这个回答很不错。但你知道吗,记叙文的规则与体裁,在甲骨文中就已经基本定型了。\n这么早啊!\n甲骨文有固定的记事模式,分为前辞、命辞、占辞、验辞四种。听起来,会不会觉得这些名字有点儿“玄”?其实,它们的内容和格式,与我们平时所写的记叙文非常像。\n“前辞”说了些什么?它告诉我们,古人在哪一天占卜,由谁来负责占卜,这其实就是时间和人物。一般来说,负责占卜的是专职的大巫师,他们被称为“贞人”——在古代汉语中,“贞”也有占卜的意思。占卜意味着与神灵沟通的能力,这是一种特殊而重要的文化权力,因此“贞人”往往是世袭的,形成了代代相传的“贞人集团”。\n“命辞”介绍了为什么事情而占卜,在甲骨文的“命辞”中,我们能看到殷商古人最关心的事情。比如有的甲骨文里说:“帝及四月令雨?帝弗其及今四月令雨?”你能看懂这句话吗?在这里,“帝”是天帝,“雨”是下雨,古人问的问题是:今年四月份,上天会不会下雨?\n“占辞”是巫师或者商王对占卜结果的判断。“验辞”是对占卜结果的验证,无论准确与否,都如实记录下来。有意思的是,甲骨文中有相当多的占卜“不灵”的记载,古人尽管有些迷信,但还是很“实在”的,把握住了实事求是的记录标准。\n你看,甲骨文是不是很像一篇记叙文,时间、人物、事件、结果,都非常清楚。\n千姿百态的甲骨文 # 介绍了甲骨文的发现、制作、内容与格式,最后,我们看一看这种文字的特点。\n甲骨文的第一个特点,是象形性强。什么叫象形性呢?就是文字和它所记录的事物特别像,惟妙惟肖。汉字来源于图画,早期汉字就像一幅幅生动的图案。不信你看上面的这张图片。\n这是甲骨文组成的一张面孔。圆圆的是脸的轮廓,中间是眼睛、鼻子、嘴巴,还有两旁的耳朵。其实,这些都是甲骨文。\n眼睛的象形,是“目”字,你把这个字掉转九十度看,和今天的“目”还颇为相似。鼻子的象形,是“自”字。古人为什么用鼻子来表示“自己”呢?很简单,自我介绍的时候,我们指着自己的鼻子说:“这是我!”你肯定不会指着自己的屁股,告诉别人说“这是我”,对不对?“自”下面的这个字,看起来很好笑。一张嘴,里面缺了好多颗牙,这是甲骨文中的“齿”字——古人没有牙刷牙膏,茹毛饮血对牙齿的磨损又很严重,在很年轻的时候,牙齿往往就损毁得十分严重了,这种健康状况,也大大地拉低了古人的平均寿命。在脸的两旁,是甲骨文中的“耳”字,你看,这个耳朵还不小呢!\n眼耳口鼻,这些字形取象于我们的面孔,非常形象。把它们拼到一起,就形成了一个殷商时期美男子的模样。\n天啊!这个样子能叫美男子吗?\n甲骨文的第二个特点,是文字瘦硬坚实、挺拔爽利,很有一种“骨感”之美。特别是把甲骨文和金文对比,前者骨感,后者肉感,堪称“环肥燕瘦”。在下一章关于“金文”的讲解中,你会看到非常鲜明的对比。\n这种骨感之美,形成了甲骨文独特的审美风貌。要知道,甲骨文多是用刀刻在龟甲、兽骨上的,刀和笔不同,很难做到圆转如意。因此,甲骨文中有很多直来直去的笔画,也就形成了硬朗、骨感的书写风格。\n甲骨文的第三个特点,是字形结构多变,同一个汉字往往有不同的写法。在汉字学中,这个现象叫作异体字。我们看甲骨文中的“龟”字:\n第一个字是侧面的小乌龟,头尾四肢都很完整;第二个字也是侧面的小乌龟,但似乎受了伤,缺胳膊断腿的,看上去很可怜;第三个字是从上往下俯视的乌龟。同一个龟字,有不同的写法,甲骨文中的异体字非常多,这也体现出它是一种不完全成形、不够规范的文字。\n[1] 研究古代钟鼎彝器碑碣石刻﹑考辨今古文字的一种专门学问。\n[2] 商朝的最后一任国君。我们在名著《封神演义》里也会见到他。\n金文-金灿灿的文字 # 走过殷商,迈入周朝,在经过了像画一样的甲骨文之后,我们步入了金文的鼎盛时代。需要注意的是,甲骨文、金文,以及我后面要讲的小篆(zhuàn)、隶书等,它们有大致的先后顺序,但没有特别清晰的时间界限。金文诞生于商朝中期,兴盛于西周,基本消亡于秦灭六国。\n什么是金文呢?\n和甲骨文一样,金文名字的由来也和它的载体有关。\n你见过博物馆里的青铜器吗?那些斑驳陆离的古老器物,是历史无言的见证者。由于氧化作用,我们见到的青铜器大多数是青黑色的,但它刚刚铸造出来的时候,则是耀眼夺目的金黄色。[1]要知道,周天子也喜欢“土豪金”的颜色呢。\n金文,就是铸造在金色的青铜器上的文字。在古代,有两种青铜器最为常用:一种是鼎,用作礼器;一种是钟,用作乐器。所以,金文也叫作“钟鼎文”。\n问鼎中原 # 古人为什么要把金文刻在鼎和钟这样的青铜器上呢?因为,以钟鼎为代表的青铜器在历史上的地位非常崇高。它们被称为“国之重器”,是国家政权的象征。在周代,不是谁都有资格铸造青铜器的。你说我家里特别有钱,铸个鼎玩玩,怎么样?那可是绝对不允许的。必须要由天子赏赐给诸侯大夫“金”,也就是青铜,然后诸侯大夫才有资格铸造钟鼎。\n钟和鼎如此重要,它们到底是干什么用的呢?\n让我们先从“鼎”说起,说出来你可能不信,地位这么尊崇的鼎,最初是一口接地气的大锅。中国第一部系统分析汉字字形和考究字源的字典《说文解字》[2]说:“鼎,三足两耳,和五味之宝器也。”“五味”是酸、咸、苦、甘、辛五种味道,泛指美味的食物,鼎最早是一种用于烹饪的器具。\n“鼎”长什么样呢?所谓“三足两耳”,我们看一看它的样子就知道了。鼎有三足,稳稳放在地上;上面又有两耳,可以把木棍穿进去,用来扛鼎。这个造型还真的蛮适合做锅的,下面的“三足”之间很适合放柴火,上面的“两耳”很适合把锅抬起来。\n作为一口接地气的大锅,鼎为什么备受推崇,地位倍增呢?\n因为一个我们熟悉的人——大禹。\n大禹治理了水灾,安定九州,广大人民都很爱戴他。舜便禅让了自己的位置,让大禹当王。大禹建立了夏朝,成为夏朝的开国天子。他掌管天下后,做了一件特别有权力象征意义的事情——收来了九州的金(青铜)铸造了九个大鼎,以此象征当时的九州[3]大地。正因如此,鼎在古代也象征着国家政权,是大国国力的体现。人们铸鼎,将它作为重要的礼器,用于祭祀等重大事宜。\n鼎作为礼器,是一个国家政权的象征,所以古代有“问鼎中原”这个成语。故事的主角是春秋时期的楚庄王,这是一个英明勇敢、野心勃勃的国君。有一次,他问周天子的使臣王孙满:“听说天子有九鼎,不知道这鼎有多沉啊?”你要知道,楚庄王并不是真想知道鼎有多沉,而是因为鼎象征国家政权,他想借此掂量一下周天子的分量!\n春秋时期周室衰微,诸侯蠢蠢欲动,天子已经没什么分量了,谁都敢欺负一下。但能言善辩的王孙满非同小可,他一句话就把楚庄王顶了回去——“在德不在鼎”!天子统治万国,在于德行,而不在于是否拥有九鼎。言下之意是,你楚庄王只关心鼎的轻重,不过是个有勇无德的莽夫罢了。\n楚庄王反驳道:“我们楚国长戟(jǐ)[4]上的钩尖儿加起来,就能够铸成九鼎。”这句话说的是铸鼎,其实暗含的意思是,我们楚国兵强马壮,靠军队就能推翻周王室,一统天下。\n王孙满丝毫不惧:“当初夏强盛的时候,即使是远方的诸侯也会赶来朝见大禹。九州的长官们贡献金,铸成九鼎,九鼎上刻着九州各地的出产以及奇特的东西。天地之间的事物都被容纳在九鼎之中,人们从鼎上能识别一切神圣与邪恶的东西。后来,夏桀[5]道德败坏,夏朝被殷商取代,九鼎也随之迁到了殷都[6]。殷商的国运有六百年,可惜纣王暴虐,于是殷商被周取代,九鼎便也归了周。可见,如果一个君王有德行,国家治理得美好清明,那么鼎即便很小,也重得难以移动。如果一个君王无德昏聩(kuì),那么鼎即便很大也会轻易失去。当初周天子把九鼎安放在都城时,占卜得知周会传国三十代,国运会持续七百年,这是天命。现在周虽然在衰败,但天命还没有改变。”\n楚庄王碰了软钉子,这才收敛了自己的傲气。\n在历史上,觊觎九鼎的霸主不止楚庄王一个。秦国的秦武王热衷举鼎,楚霸王项羽力能扛鼎。他们举起沉重的鼎,不仅是展现自己力气过人,也是要通过举鼎的行为,寄托征服天下的决心。\n价值连城的编钟 # 除了鼎,钟上也经常刻着金文。钟和鼎一样,都是价值连城的宝贝。\n钟是做什么用的呢?它是古代典礼上常备的乐器,一般都是一套,称为“编钟”。大大小小不同的钟,敲击起来音色不同,演奏出复杂的旋律。殷商的编钟多为3枚一组,春秋战国时期多为9枚一组。现存最华丽的编钟,是在湖北省博物馆里藏着的一套曾侯乙编钟。这套编钟共64枚,分三层悬挂,全部重量在2500公斤以上,美轮美奂,真可谓先秦青铜器中的珍品,你们有机会一定要去参观一下。\n在中国古代,人们把那些尊贵的家族称作“钟鸣鼎食之家”。什么是富贵的标志?不是黄金美酒、宝马香车,而是用钟奏乐、用鼎吃饭的人家——这幅古香古色的生活画面,不仅是古人心中财富的象征,也是社会地位的标志。\nVCG211262913655-©王萌/视觉中国\n国家大事与家族荣耀 # 青铜器地位崇高,铸刻在上面的文字也不是一般内容,或是国家的军国大事,或是一个家族代代相传的荣耀和辉煌。\n在金文中,我们能看到一些历史上的“重大新闻”。你看下面这个青铜器,古朴大方,它的名字叫利簋(guǐ)。簋和鼎一样,也是古代的一种食器,主要用于盛饭,也是一种礼器。北京有个饮食一条街,以麻辣小龙虾著称,就叫作“簋街”。“利”是这个簋的主人。大家跟我一起看右上角的利簋铭文:\n第一个字右边是戈,下面是止,所谓“止戈为武”,这是一个“武”字。旁边加上一个“王”,这是周武王的专用字。哇!鼎鼎大名的周武王,大人物出现了!武王下面的一个字不难认,这是征伐的“征”。第三个字,是商纣王的“商”。“珷(武)征商”——看到这几个字,有没有一点儿心潮澎湃的感觉。在古老的青铜器上,记录了中国历史上翻天覆地的一场大战。在利簋接下来的文字中,记录了武王伐纣的时间,“唯甲子朝”——在甲子那一天的清晨。这个时间和司马迁在《史记》中的记载是完全吻合的——“二月甲子昧爽,武王朝至于商郊牧野”。\n在利簋上,铭刻着“利”带领家族参与武王伐纣的光荣历史。这类青铜器是不折不扣的传家宝!因此,很多金文中有这样的句子:“子子孙孙永保用”,“用作宝尊彝(yí)”——周代的贵族们,希望这些尊贵典雅的青铜器,成为子孙代代相传的宝贝。当然,那些古老的权贵家族,在历史的长河中早已湮(yān)没无闻,时至今日,金文已不再是某个家族的荣耀,而是中华民族共同的瑰宝。\n古朴雄浑的金文 # 说完金文的载体和内容,再来看看金文字形本身的美。\n金文是一种古朴雄浑的文字。说它古朴,是因为金文保存了一些非常古老的字形。就拿我们熟悉的“王”字来说,“王”是国家的统治者,这个字最开始是什么样子的呢?在甲骨文中,“王”写成、、等样子,有人说,这是祭祀上天的架子,用来焚烧祭品,王者拥有祭祀上天的神圣权力!也有人不同意,哼!画个烤肉架,就能代表高高在上的王吗?\n争来争去,直到看到了金文中的一个字形。\n你看这个字像什么?没错,斧头!它是斧头的象形。那帝王和斧头之间,又有什么关联呢?\n在古代,有一种威猛夸张的大斧头,名字叫“钺”,它标志着征战、杀伐的权力,是王权的象征。前面说过,青铜刚刚铸造出来时是金黄色的,在古书中,这种青铜大斧有个专门的名字——黄钺!\n《尚书》中记载,武王伐纣的时候,“左杖黄钺,右秉白旄(máo)以麾(huī)”——左手拿着青铜大斧,右手挥舞雪白的旗帜,来指挥部队。除了史书,考古发现也印证了黄钺的存在。在殷商时期,商王武丁有一位英勇善战的妻子,她是中国历史上第一个女英雄,名字叫妇好。在河南安阳发现的妇好墓中,出土了非常霸气的青铜钺。而且,不止一把,而是两把!这说明妇好拥有领兵出征的权力。根据甲骨文记载,这位女英雄最多指挥过13000人,真是赫赫威风!不过,妇好出征,手拿两把大斧,听起来怎么有点儿像李逵……\n从妇好墓出土的黄钺,结合史料和金文,我们可以得出一个结论:什么是“王”?王者拥有钺所代表的征伐大权,这是一种至高无上的政治地位!\n至于雄浑,金文地位崇高,在当时都是由最好的书法家来写的。而且,金文不是用刀契刻,而是铸造而成,相比甲骨文而言,有着更多的书法展示空间。若你去博物馆游览或者看字帖,会发现金文的书法性非常高,有一种雄浑丰满、古朴天然的美感。吴昌硕、齐白石等大书法家都喜欢用这种字体进行创作。\n[1] 汉代以前所说的“金”往往指的是青铜,这是一种铜和锡、铅等的合金,刚制成时是耀眼的金色。博物馆里的青铜器,大都在地下经过了千年的水浸土埋,金属被腐蚀,颜色变为庄重古朴的青黑色,内敛了许多。\n[2] 《说文解字》是中国历史上第一部系统分析汉字字形、讲解汉字字理的字典,简称《说文》,作者是东汉的许慎。在体例上,它开创了部首编排法;在内容上,它用六书系统地解说汉字,并保存了部分早期古文字的写法,为我们研究甲骨文、金文等提供了依据。六书是六种汉字造字法,包括象形、会意、指事、形声、转注和假借。\n[3] 上古时代,天下被划分为九州,即九个地理区域。后来,人们用九州泛指天下。\n[4] 戟是一种兵器,主要由青铜制成。\n[5] 夏朝的最后一任国君。\n[6] 殷商的国都,在今天的河南安阳。\n小篆-一统天下 # 金文,是周代的主要文字,小篆,是秦国的文字。从周到秦,在这两个伟大的王朝之间,经历了春秋战国数百年的风云变幻。在这波澜壮阔的历史进程中,我们的汉字又经历了怎样的命运呢?\n战国文字:我的地盘我做主 # 典雅肃穆、古朴雄浑的金文是周代的官方文字。但随着周幽王[1]烽火戏诸侯的闹剧,还有周平王无奈而仓促的东迁[2],从西周到东周,天子的权威与礼乐一落千丈!在诸侯并起的春秋战国时代,金文这种整齐规矩的文字,也受到了极大的冲击——周天子都可以不放在眼里,何况区区文字呢?\n从周代到战国,汉字的面貌发生了巨大变化。我们还是先浏览一下战国的文字吧,你看,这页下面是秦国文字、三晋[3]文字、楚国文字和齐国文字。每个国家的文字各不相同,差异极大,战国文字最基本的特点就是复杂纷乱、各国不同。\n为什么会这样呢?\n这和当时的政治形势密不可分。当时天下分裂,战国七雄——秦、楚、燕、齐、赵、魏、韩,谁都想统一天下,成为天下共主,谁都要谋求自己的霸业和野心。在这样的心态下,各国的政治、文化、军事、社会不断走向差异——我的地盘我做主,就要和你不一样!\n天下分裂,各国制度不同,汉字也是如此。许慎在《说文解字叙》中说:“诸侯力政,不统于王,恶礼乐之害己,而皆去其典籍,分为七国,田畴异亩,车途异轨,律令异法,衣冠异制,言语异声,文字异形。”\n诸侯用暴力争夺天下,打来打去,要不怎么叫“战国”呢?在一个你咬我、我咬你的时代里,天子的礼乐就像耳边整天的唠叨一样,真烦人!赶紧扔掉。各国纷纷推出自己的货币政策、土地政策、交通法规、法令政策、衣冠风俗,连语言文字也不例外——嘴里说的是方言土语,写出的文字也各不相同。\nVCG21gic5474401-©View Stock/VSI美好景象(Creative)/视觉中国\nVCG21gic5474144-©View Stock/VSI美好景象(Creative)/视觉中国\nVCG21gic5474421-©View Stock/VSI美好景象(Creative)/视觉中国\nVCG2124a9e3381-©View Stock/VSI美好景象(Creative)/视觉中国\nVCG21gic5474248-©View Stock/VSI美好景象(Creative)/视觉中国\n战国时期的不同货币\n秦始皇与文字统一 # 天下大势,分久必合,合久必分,经过了近300年的春秋时期和200多年的战国纷争,中国历史终于又从分裂走向了统一。兼并六国,结束战国纷争的丰功伟业,是由秦始皇完成的。\n秦始皇雄才大略,他十三岁即位,二十二岁亲政,在三十九岁时就已经统一了中国,结束了春秋战国数百年的纷争。大诗人李白写道:“秦王扫六合,虎视何雄哉!”如果你读过《史记》中的《秦始皇本纪》,再去陕西的兵马俑实地感受一下,一定会为他的赫赫功业震撼不已。当然,在统一中国的过程中,秦国多次打败东方六国的大军,杀伤无数,也让秦始皇在历史上背负了残忍、暴虐的骂名。要知道,这样一个重要的历史人物,对他的评价始终都是相当复杂的。\n统一天下之后,秦始皇需要治理天下,这个时候,他发现自己遇到了一个大麻烦——各国的制度、风俗、语言、文字都不一样,想要推行秦朝的政令,下一道诏书,六国的人看不懂,怎么办?汉字不同,也影响大家之间的文化交流,想看其他六国的书,相当于要重新掌握好几门语言文字,简直太麻烦了。\n秦始皇正在头疼,他的丞相李斯来了。“陛下别急,六国的文字乱七八糟的,干脆用咱们大秦的文字,把它们统一起来!”秦始皇一听,“好办法!丞相所言,甚合朕心。这个任务就交给你了!”\n于是,李斯带领着那个指鹿为马的太监赵高(这个人品质很坏,但能耐不小,也懂得文字学呢),还有太史令胡毋敬,一起写出了《仓颉篇》《爰历篇》和《博学篇》。在这三篇字书中,包括了3300个字,他们根据传统的秦国文字,进一步加以简化、调整,同时废除了那些形状各异的六国文字,最后推出了小篆!\n这段历史,就是《说文解字叙》中所记载的:“秦始皇帝初兼天下,丞相李斯乃奏同之,罢其不与秦文合者。斯作《仓颉篇》,中车府令赵高作《爰历篇》,太史令胡毋敬作《博学篇》。皆取史籀(zhòu)[4]大篆,或颇省改,所谓小篆者也。”所谓“史籀大篆”,指的是多取自周代大篆的秦国文字,“省改”则是省简与改易的意思。\n你可能会问,“篆”这个字好陌生,是什么意思?\n“篆”虽然难认,但意思很清楚。“篆”是个形声字,下面的“彖”是用来表示“篆”的读音的,而上面的竹字头说明这个字和竹子有关。“篆”的本义就是用笔写在竹简上的意思。《说文解字》中说:“篆,引书也。”这是一种笔画拖长、线条流畅的文字,就好像用笔“牵引”着书写出来的一样。\n小篆作为秦始皇统一天下之后面向全国推行的标准字体,被大秦帝国不断地推广、普及。对秦始皇来说,统一文字是维护国家统一的重要政策。对中华民族来说,统一的汉字使得我们能够更加团结、凝聚,不要小看一个个小小的汉字,是它们铸成了我们几千年来未曾动摇的文化根基。\n李斯的马屁 # 小篆一统天下,听上去就很酷!\n小篆长什么样子呢?作为秦始皇推广至全国的规范文字,小篆字形整齐,结构规范,不像甲骨文那样,同一个字有各种各样的写法;小篆充分保留了造字的文化内涵,在它的字形中,蕴含着汉语和中华文化的奥秘。这一点,我们后面在具体讲解造字法时会讲到很多案例。此外,从字形来讲,小篆也是一种比甲骨文、战国文字更为漂亮的汉字,它线条细腻,工整大方,十分优美。\n遗憾的是,无论是《仓颉篇》《爰历篇》还是《博学篇》,李斯的标准小篆样板都已经失传了,我们今天只能在秦始皇的一些碑刻上,看到李斯小篆的残迹。\n秦始皇是一个精力旺盛的人,他不甘心“宅”在咸阳的宫殿里,而是要巡游天下,亲眼看一看自己统治的山河大地。哪里有造反闹事的,顺手再给平定了。他东到琅玡(láng yá),南到会稽(jī),登泰山,登峄(yì)山,顺道让大臣们歌颂自己的功德,然后刻在石碑上,秀给千秋万代。\n这就是历史上著名的秦刻石!\n这些碑刻,是大秦书法第一人——李斯所写,你看,这是《泰山刻石》上的残字,斑斑点点,充满了沧桑之感。古人读书写字,都是从右往左、自上向下的顺序,大家跟我一起看:\n第一个字是臣子的“臣”,第二个字是请求的“请”,第三个字是“具”,表示详尽,第四个字是“刻”,最后两个字是“诏书”。“臣请具刻诏书”,我请求把您的诏书详尽地刻下来。这六个字,应当是李斯的传世真迹,古朴苍劲、端庄大方,是中国书法史上难得的精品!\n李斯书法很好,但作为臣子,在内容上难免要拍一拍秦始皇的马屁。我们再一起看下面的《峄山碑》。\n这块碑是宋代人的仿刻,现在还保存在西安碑林中。它的内容很完整,我们看前四个字,“皇帝立国”,从秦始皇统一天下说起。在《峄山碑》里,有不少盛赞秦始皇的话,什么“威动四极,武义直方”啊,什么“孝道显明”“乃降专惠”啊,秦始皇太厉害了,他征讨逆贼,威震四方,都是正义的战争。他尊崇孝道,一直践行先祖统一天下的愿望,又是那么仁爱,把自己的恩泽惠及天下所有的人啊,听上去都有点儿小肉麻。\n最讽刺的是,《峄山碑》里面说秦始皇“灭六暴强”——六国是暴君,是强权,是我们伟大的皇帝把他们灭掉了。可在六国人眼中,发动战争的是“暴秦”啊,你是“暴强”,你全家都是“暴强”呢!\n看来,书写历史的权力,往往掌握在胜利者手中。\n[1] 西周末代国君,也是历史上有名的昏君。古代帝王诸侯贵族或高级官员死后,朝廷会根据其生平事迹,给他一个具有褒贬意义的称号,这种称号叫作谥号。周幽王谥号里的“幽”字,和商纣王的“纣”字一样,都不是好的谥号,能看出人们对这两位昏君的负面评价。\n[2] 周平王继位时,周王室已然衰微,国都丰镐(今陕西长安西北)被犬戎洗劫一空。为了避免再被犬戎侵袭,平王将国都东迁到洛邑(今河南洛阳),史称“平王东迁”。东周王朝由此开始。\n[3] 战国时代,赵国、魏国、韩国的合称。由于三个诸侯国都出自原晋国,所以统称“三晋”。\n[4] 相传史籀是西周的大书法家,他书写的字体叫做“大篆”。\n隶书-大刀阔斧的简化 # 小篆有种种优点,但为什么我们今天不用小篆来写字呢?这种字体,有没有致命的缺点呢?\n解答这个问题之前,建议你先写一写小篆,动动手你就明白了。\n写到手断的小篆 # 写什么呢?给你一个小篆样本好了——“一只忧郁的台湾乌龟”。\n有没有觉得字形好复杂?写呀写,有一种要崩溃的感觉!\n小篆字形规整、内涵丰富、端庄大方,很棒!但它有一个致命的问题,那就是不好写——写这种文字,需要耐心细致、凝神定气,手中的毛笔又慢又稳,很有修身养性的感觉,但就是效率特低!写上个千八百字,恨不得手都要累断了。\n历史上,秦始皇是一个暴君,但不可否认的是,他也是一个非常勤政的国君。相传他每天要看的奏折,有一百多斤重!秦始皇看奏折累,那些用小篆写奏折的人呢?累不累?秦国以法治国,政令繁多,很多事情都要写成公文去交流。如果用小篆来写的话,效率太低下了,大小官吏们实在辛苦。\n于是,在小篆作为规范文字向全国推广的同时,秦国的官吏们率先放弃了小篆,他们自觉地简化汉字,用一种简便的字体书写了大量的公文。这种字体,就是隶书。“隶”就是小吏的意思。\n隶书与简化 # 秦朝的官吏们都做了哪些简化呢?\n把隶书和小篆进行对比,你会发现,隶书的特点十分鲜明。比如“者”字,除去“日”,如果用小篆写,要六七笔,而且有很多弧形的笔画,写起来弯弯绕绕的。但在隶书中,复杂的笔画被连起来了,弯曲的笔画被拉直了,直来直去,唰唰唰唰,四笔就能写完!快得多!\n再看裤腰带的“带”。在小篆中,“带”上边的一横是腰带的象征。古代的“带”不仅是用来提裤子的腰带,还要挂上各种各样的饰物,“象系佩之形”,横下面的笔画就表示古人腰带上佩戴的一大串玉佩、香囊。小篆中的“带”弯弯曲曲,很好看,但也很复杂。到了隶书中,笔画被拉直简化,上半部分一横三竖,就写完了。\nVCG21gic20065841-©许嘉星/视觉中国\n从小篆到隶书,汉字的这个变化过程叫作“隶变”。在隶变中,汉字的笔画变得横平竖直,基本结构越发趋向现代汉字。经过大刀阔斧的简化过程,汉字书写的效率大大提高。但另一方面,汉字中的意义信息也丢失了不少。比如,“鸟”的小篆字体是,还能看出是一只小鸟朝上张嘴啼鸣,右下方是扇动的翅膀,左下方是小鸟的爪子,但到了隶书,已经是我们熟悉的繁体字“鳥”了,看不出小鸟的模样。\nVCG21gic20065840-©许嘉星/视觉中国\n在汉字的发展中,有一组重要的矛盾——字义丰富和书写效率。想要在一个字里体现更多的字义,就要用更多的笔画来表示。但如果笔画太多了,这个字又很难写,不利于传播,就要加以简化。\n举个有趣的例子,陕西有种面条叫“biang biang面”,这个“biang”写成这样:\n一个字,包含了好多好多内容。为了记住这个极度复杂的字,陕西有个歌谣:“一点撩上天,黄河两道湾,八字大张口,言字往里走。你一扭,我一扭;你一长,我一长;当中夹个马大王。心字底,月字旁,一个小钩挂麻糖,坐个车子回咸阳。”说的就是这个字。\n哇,好厉害的面!一碗面条,写出了这么多的讲究。但你不觉得这个字特别难写吗?让你写一百个面,我估计你这辈子都不想再碰它。\n因此,汉字势必要进行简化。为什么呢?因为我们懒啊,谁也不想写笔画多的字。从甲骨文、金文、小篆再到隶书,这是一个发展了几千年的简化过程。哪怕会丧失一些字义信息,但人们还是坚定不移地选择了简化。\n简化,是汉字发展的整体规律。\n两汉名碑 # 隶书在秦代萌芽后,在社会上越来越流行,在汉代大为兴盛。从西汉到东汉,出现了一批经典的隶书名碑,让我们得以窥见当时的隶书之美。\n在西安碑林,你能看到秀美端庄的《曹全碑》;在曲阜孔庙,你能看到大方古朴的《乙瑛碑》;在泰安岱庙,你能看到雄强大气的《张迁碑》;在陕西汉中,你能看到气势磅礴的《石门颂》……\n这些碑刻上的隶书精彩极了,在斑驳的石刻上,我们看到了汉字古朴浑厚的文化气质!尽管我们已经不知道那些写字者的具体姓名,但他们气象万千的隶书书法,早已成为中国书法史上光彩照人的篇章。\n《曹全碑》\n《乙瑛碑》\n从汉碑,我们能看出隶书的基本特点。第一,隶书字形在整体上是扁方形的。第二,隶书讲究“蚕头燕尾”。在起笔的时候顿笔,就像蚕宝宝的脑袋一样;在收笔的时候要翘起来,就像天上飞的小燕子的尾巴一样。\n这是隶书独特的审美风味。\n隶书是古今汉字的分水岭,之前的甲骨文、金文和小篆都是古文字,从隶书开始就是书写规范的今文字了。从古文字到今文字的演化过程中,汉字遭遇过一场史无前例的危机——秦始皇“焚书坑儒”。在漫天的大火里,中华古籍遭到了毁灭性的破坏,古文几乎废绝。在这样的年代里,人们离古文字渐行渐远,很多人不明白汉字发展的过程,便开始根据当时通行的隶书来随意讲解汉字。比如说,什么是“长”呢?当时的“长”写作繁体字“長”,“長”和“馬”(马)的上半部分很像,于是有人就说“马头人为长”——长像马,马脸长,人脸要是像马脸一样,自然就是“长”啦。\n听起来是不是有点儿荒唐?当时有一个叫许慎的人看不下去了!\n找一把解开古文字的钥匙 # 许慎是谁呢?许慎,字叔重,他熟读经典,是个大儒,被人们称赞为“五经无双”,意思是说许慎熟读五经,对古籍的理解天下无双。\n具有研究精神、严肃认真的许慎心想,怎样才能准确地讲解汉字呢?隶书已经经过简化和笔画的调整,不能准确地传达出汉字本身的造字含义,应该找一种更古老的字体,作为说文解字的基础。在许慎生活的年代,甲骨文尚未发现,金文数量极少且过于久远,于是,他将目光转向了古书中的小篆。比如说,如果看隶书中的“光”字,根本看不出它的造字思路,但如果看向“光”的小篆字体,便能看出上面是个“火”——“光”与“火”相关,简直再清晰明白不过了!\n就是它了!小篆承前启后,连接甲骨文、金文等古文字,保存了古文字中丰富的意义和文化,是打开古文字宝库的钥匙。凭着这把钥匙,许慎写出了汉字史上最重要的一部书——《说文解字》。\n许慎把小篆作为《说文解字》的基础,在古籍中遍寻汉字的小篆字形,广泛搜集。有的字找不到小篆字形,他便按小篆的书写规则加以复原。最终成书一共收录了9353个汉字(不包括异体字,也不包括后继学者为《说文解字》增加的新附字)。\n在字头下面,许慎会详细讲解汉字:这个字是怎么造出来的,它的形体有何特点,这个字的字义是什么,读音又是什么,这个字的背后有怎样的文化内涵。比如“示”字:\n示,天垂象,见吉凶,所以示人也。从二(二,古文上字)。三垂,日月星也。观乎天文,以察时变。示,神事也。凡示之属皆从示。\n这是《说文解字》对“示”的解释,我们知道,“示”有“展示”的意思,古人的理解则要更为复杂一些,具有鲜明的历史特点。在他们看来,什么东西能给人“展示”“揭示”出最根本的道理呢?那就是我们头上的苍天。“天垂象,见吉凶,所以示人也”是解释“示”的字义:上天有各种各样的“天象”,日月风云、星辰月相,都提示着人间的吉凶祸福。所以,人要善于观察天象,及时把握天意,得到启示。“从二。(二,古文上字。)三垂,日月星也”是拆分说解字形,你看小篆中的(示),上面是“二”,许慎先生告诉我们,这个“二”在古文字中是“上”,它在古汉语中有上天的意思,天象给人们带来启示。说起来,甲骨文的(上)就写作一长一短两横。下面一横长,表示大地;上面一横短,标注着大地上方,表示地平线以上的位置,也就是“上”的意思。这是一个标准的指事字,后面我们还会讲到。\n下面的“三垂”呢?也不是随便的三笔,而是象征着天上的日月星辰,也就是所谓的“三光”。无论是中国文化中的星宿,还是西方文化中的星座,在古人眼中,日月星辰都与人类世界密不可分,具有“示”的功能。“观乎天文,以察时变”,这是来自《周易·贲·彖辞》中的话,意思是古代圣人观察天文、获取启示,从而来把握现实中的种种变化。\n发现部首的奥秘 # 除了用小篆厘清汉字含义,《说文解字》的另一个重要意义和价值是:许慎发现了汉字部首的奥秘。\n在《说文解字》中,上万个汉字是由540个部首统摄起来的,掌握了部首,也就掌握了汉字的秘诀。比如,“示”就是一个重要的部首,这也就是现在楷书中的“礻”。所谓“凡示之属皆从示”,“礼、祭、祀、神、福、祸”,这些和神灵祭祀、祸福祈祷有关的字,都是从“示”的。\n许慎用部首把具有相同偏旁的字归纳起来,让汉字不再零散,呈现出汉字字义的基本规律,这是汉字历史上特别重要的一个发现。我们今天的《新华字典》里还有“部首检字法”,通过部首来查找汉字,这个重要的发明,是许慎先生的功劳!\n《说文解字》凝聚着许慎一生的心血,它是中国历史上第一部字典,也是中国历史上第一部汉字文化巨著,书中蕴藏着汉字的全部奥秘。因此,许慎也被尊称为仓颉之后的第二位“字圣”。\n我为大家讲解汉字,正是以《说文解字》为基础的。\n楷书-汉字的楷模 # 在隶书之后,还有草书、行书等不同字体,但影响最为深远的,还是楷书。\n楷书诞生的时间并不晚,1800多年前的汉魏之际就已经形成了,之后又过了200多年,到南北朝时期渐渐成为主流字体,进而一举成为汉字的标准字体。我们今天学写字,都是从楷书开始。\n从甲骨文到楷书,汉字经历了几千年的岁月,从历史走到今天,跌宕起伏,实在不易。\n什么叫楷书? # “楷”有楷模、标准的意思,也就是正体书法。这种字体端正大方,能够成为汉字的标准,因此有了“楷书”这样的好名字。楷书也叫作“真书”“正书”——一种“真真正正”的字体,带有标准的内涵。\n一般来说,和行书、草书一样,楷书也是由隶书发展而来的字体。和隶书相比,楷书发生了几个明显的变化。对比隶书和楷书,你能看出有哪些不同吗?\n很明显!楷书的字形结构由扁平变成了正方,所谓“方块汉字”,指的就是楷书。此外,楷书中的笔画没有明显的蚕头燕尾了,比隶书更为平直,写起来也就更加便捷。最后,楷书的字形比隶书更加简化了。\n楷书四大家 # 在汉字的历史上,曾出现过一大批善写楷书的大书法家。其中,最有名的是楷书四大家。\n他们是谁呢?\n他们是唐代的欧阳询、颜真卿、柳公权,还有元代的赵孟頫(fǔ),分别为欧体、颜体、柳体、赵体的创始者。在四大家中,有三个都是唐朝人,足见唐朝是楷书发展的高峰。四大家的书法各有特色,欧体端庄刚劲,颜体雄浑厚重,柳体瘦硬遒劲,赵体圆润俊逸,真是各有千秋、自得风流。\n在楷书四大家中,我最喜欢颜体,那种雄浑苍劲、大气非凡的感觉,看起来仿佛千军万马、长枪大戟一般。\n为什么颜体这么有力量呢?\n传说,颜真卿曾经告诉别人:“写千斤之字必先有千斤之力!”人家不解,“敢问,什么叫千斤之力呢?”\n颜真卿哈哈大笑:“你看着啊!”他弯下腰,握住椅子腿,将一把太师椅举过头顶,一、二、三……一口气举了几百下,再轻悄悄放回原处,大气不喘,面不改色。“看好了,这就是千斤之力!”\n好家伙,颜体是这样练出来的啊!\n这只是一段趣谈,其实颜体可谓字如其人,我给你讲讲颜真卿的故事吧。\n读《新唐书》中的颜真卿传记,会让我们对颜体字的理解又深了一层。颜真卿一门忠烈,他堂兄叫颜杲(gǎo)卿,是安禄山亲手提拔的常山太守。安史之乱时,颜杲卿举兵讨贼,把安禄山弄得狼狈不堪。后来城破被俘,安禄山恨透了他,把他捆在天津桥的柱子上,凌迟处死!\n哥哥壮烈殉国,这件事对颜真卿影响很大。到了颜真卿的晚年,李希烈在汝州造反,朝中奸臣陷害颜真卿,让他前往招抚。这是要把他送到狼嘴里去啊,不少人劝他别去,颜真卿却毫不犹豫地出发了。\n到了汝州,有上千名士兵拔刀冲上来,李希烈的将领们纷纷谩骂,“将食之”——“把这老东西吃了”!颜真卿面不改色,坦然宣读朝廷旨意。\n到后来,李希烈派人劝降,颜真卿正色说道:“你没听说过常山颜杲卿吗?那是我的兄长啊!我一个年近八十的老头子,眼看要死的人了,还会改变自己的操守吗?”\n李希烈没办法,便把颜真卿关了起来,挖了个坑要活埋他。颜真卿淡然一笑,说道:“生死有命,你以为挖个坑就能吓到我吗?”最后,颜真卿被李希烈派人绞死,临死前骂贼不止,和兄长一样壮烈殉国。\n颜真卿去世时是七十六岁,已是风烛残年,却爆发出刚强的气概。我们今天讲他的故事,仿佛还能感受到那股堂堂正气。北宋大文学家欧阳修评价颜体书法说:“斯人忠义出于天性,故其字画刚劲独立,不袭前迹,挺然奇伟,有似其为人。”意思是说,颜真卿天性忠义,他的字画就像他的为人一样,遒劲有力,不承袭前人的印记,挺拔奇伟。这也正是我最喜欢颜体的原因,读颜真卿的传记,再看他雄浑刚劲的书法,心中不禁会生出一种由衷的敬意。\n繁体字与简体字 # 楷书诞生后也不是一成不变的。\n从历史到今天,楷书经过了一个不断简化的过程。最大的变化,便是繁体转变为我们熟悉的简体。\n在楷书中,有繁体字和简体字的区别。你看右页,“凤、兴、爱、论”的繁体与简体,是不是差别蛮大?\n汉字从古至今,不断简化,新中国成立以来,国家更大力推行简化字。因此,我们如果读古书的话,会看到很多繁体字,但在今天的图书中,则是以简体字为主。\n也许你要问,繁体字和简体字,哪个更好?\n哎呀,回答这个问题真麻烦!我先问你一个问题吧,世界上有没有绝对的“好”,和绝对的“不好”呢?\n战国时代有一位哲学家,叫庄子。他说:“彼亦一是非,此亦一是非。”什么意思呢?是非对错,从来不是绝对的,要看在什么样的角度上理解。\n就书写方便来说,当然是简体字好!不承认的话,罚你抄一百遍“壹隻憂鬱的臺灣烏龜”(繁体字版“一只忧郁的台湾乌龟”)!\n为什么要追求书写效率呢?\n文字,是为社会应用服务的,一种好写、好认的汉字,当然更受欢迎。\n简化,是贯穿汉字发展的整体规律。从甲骨文开始,汉字就在不断简化了。在新中国简化汉字的过程中,汉字专家们在简化字时尽量做到有所依据。很多人都不知道,在今天的简体字里,只有0.26%是新中国成立之后完全新造的,其他简化字都有历史来源。比如说繁体字中的“爲”在简体字中写作“为”,但实际上,“为”的写法在王羲之的书法中就已经出现了。\n中国古人在写字的时候,会进行自发的简化。因此,我们千万不要以为,简体字是今人随随便便造出来的,它们其实是历代古人追求书写便利的智慧结晶。\n新中国推行简化字后,汉字好学好认,其一大好处便是扫盲。\n你听说过扫盲这个词吗?\n扫盲,就是扫除文盲。在古代,知识是奢侈的,只有少数的读书人才会写字,大多数老百姓根本没机会接受教育,更不认字,这样的人被叫作“文盲”。你能想象新中国刚成立的时候,中国大约80%的人都是文盲吗?你能想象文盲的生活吗?我有一次出国旅游,就体会了文盲的痛苦。吃饭点菜,菜单上都是不认识的洋文,随手叫了几个菜,那叫一个难吃……\n点菜还是小事,不认识字,老百姓就会被人欺负,被人骗,整个社会更毫无平等可言。中华人民共和国成立后,国家下了很大的力气扫盲,教那些不认字的老爷爷、老奶奶识字。扫盲,用的就是简体字,因为它好学啊!\n有的汉字简化得非常精妙,比如我们讲过的“龟”字。它在甲骨文中的写法就相当烦琐,繁体字写作“龜”,来自小乌龟的侧面象形,也很难写,一不小心就会写错。但简化成“龟”之后,笔画清晰,一目了然,而且和小乌龟的样子还是很像!\n介绍了简体字的好处,那繁体字呢?\n如果你想要了解汉字的历史、汉字的文化,就需要掌握繁体字的知识。在繁体字中,我们能够更清晰地看到汉字的历史脉络。讲《汉字就是这么来的》,也会给大家介绍繁体字的相关知识。要知道,简化字也是有副作用的,那就是会导致汉字字义丢失、汉字的历史源流断绝。在很长一段时间里,我们教学生掌握汉字的道理,都忽视了汉字源流的讲解,对汉字背后的文化内涵也不怎么清楚,导致我们只能机械地记忆汉字。今天,我们越来越重视汉字的历史,还有它背后的道理了,在学汉字的时候,也会注重汉字历史和汉字字理的讲解,这也是我写这套书,想和你聊聊汉字的原因。\n总之,简体字方便使用,繁体字保存历史,二者各有所长。对我们来说,踏踏实实地用简体字,再了解一下繁体字的知识,加深对汉字的理解,这就足够了。\n象形字-用图画来造字 # 汉字,从遥远的古代走到今天,从古老神秘的甲骨文,走到我们笔下整齐端正的楷书。汉字的岁月,是中国文化的生命历程;汉字的未来,也与中国文化相伴始终。\n懂得了汉字的历史,接下来,我们需要了解造字的方法,这是我们走进汉字世界的重要角度。汉字是怎么创造出来的?在造字中,凝聚了古人怎样的智慧与灵感?古人造字遵循了哪几种造字逻辑和规律呢?\n这些问题,都等待着我们探寻!\n什么是“六书”? # 中国古人在很早以前,就开始思考造字的规律了。他们把这些规律总结为“六书”——“书”是书写,写的是文字,六书就是六种造字、构字的规律。这是我们了解汉字的基本知识。\n在周代,古人就已经提到六书了,但历史上最重要的六书专家,把六书讲解得明明白白的,还是《说文解字》的作者许慎。在《说文解字》中,许慎先生给六书下了清楚的定义,用具体的例子说解六书,还用六书分析了全部的汉字。可以说,有了《说文解字》,六书才成为一门重要的学问。\n说来说去,“六书”是什么呢?\n它们包括象形、指事、会意、形声、转注、假借。一般来说,六书的重点在于前四书,也就是象形、指事、会意、形声。\n这些名称都是啥意思啊?听上去好难!别急,这正是我们接下来要重点讲解的内容。\n象形:汉字是画出来的 # 象形,是用画画的方式造字。\n象形字,和事物的“形”体一定要“像”。在《说文解字》中,许慎先生是这样讲解“象形”的——“象形者,画成其物,随体诘诎,日月是也”。\n什么意思呢?“画成其物”,象形是用绘画的手段来造字,“物”是汉字所要表达的事物。“随体诘诎”,象形字要惟妙惟肖地画出事物的形体,“体”是事物的形体,“诘诎”是弯弯曲曲的笔画,用来描绘事物的轮廓。最后,许慎先生举了两个例子:日和月。\n试想一下,如果你是一个小原始人的话,怎么给太阳和月亮造字呢?\n太阳圆圆的,非常明亮,仿佛有无尽的光明洒向大地。因此,古人造日的时候,画一个圆圈,来描绘太阳的形体;中间加上一点,表示太阳的光芒。《说文解字》说:“日,实也”,太阳的光芒是充实饱满的。\n月亮呢?十五的满月是圆的,但也有弯弯的月牙,像女子的眉毛一样。月有阴晴圆缺,和太阳相比,“缺”的状态是月亮独有的特点。因此,古人造月的时候,画一个弯弯的月牙,来描绘月亮的形体。\n你看,这就是古文字中的日与月,它们是最典型的象形字。\n画出自己,画出世界 # 参考日月的形象,造出、的过程,叫作“取象”。取象有两个最重要的来源,“近取诸身,远取诸物”——低下头,看看自己,从我们的身体中选取图像来造字;抬起头,眺望世界,从广阔无垠的大自然中选取图像来造字。简单来说,就是画出自己,画出世界。\n在象形字中,我们能看到从头到脚的人体形象。还记得在讲甲骨文的时候,我们拼出的古代人脸吗?那个让你难忘的美男子,就是由象形字组成的。再给你举几个例子吧,你看,这个字是什么?\n我们不难看出,这是一个脑袋的象形。无论是头发、面孔,还是眼睛,都清晰可辨。这个字是甲骨文中的“首”,也就是脑袋的意思。不过,这个脑袋看起来好丑……\n再看下一个字!,这是周代金文中的形体,猜猜看,这是什么字?\n猜对了!有手臂,有张开的五指,这个字是“手”的象形。\n有了手,也一定有脚。我们再来看第三个字:。\n上面是小腿,下面是脚,脚趾还向外张开着,这个字又是什么呢?\n它是甲骨文中的“足”。\n在象形字中,古人非常细致地观察了自己的身体,从头到脚,选取了各种各样的形体,造出大量和人体有关的汉字。对于这些字,我在《字里字外的人体世界》还会给大家详细讲解。\n在近取诸身以外,古人还远取诸物。\n他们仰观天文,俯察地理,把自己好奇的目光,投向了丰富多彩的大自然。在象形字中,有天文地理,有花草树木,有日用器皿,有饮食服饰,还有各种活泼可爱的小动物。\n你看,这里有几个汉字:\n第一个字,是甲骨文中的“山”,画出了高耸入云的山峰。\n第二个字,是甲骨文中的“水”,画出了蜿蜒曲折的流水,汉字中的小点,就像流水中飞溅起的水花一样。\n第三个字,是战国文字中的“艸”。“艸”是小草的意思,也是草字头的来源,春风吹拂大地,小草生出了嫩芽。\n第四个字,是甲骨文中的“木”,树根牢牢地扎在地里,树枝在微风中轻轻摇曳。\n这四个字放在一起,展现出大自然中的动人风光。\n我们再看下面的几个字:\n第一个字,画出了长长的腿,轻盈的四蹄,还有长长的角。这是什么动物呢?也许你一眼就能看出,这是甲骨文中的“鹿”字。\n第二个字要侧过来看,也是一种动物,脸长长的,后背上还长着鬃毛,似乎在随风飘荡。它是什么呢?这是甲骨文中的“马”。\n第三个字是动物的局部,凸显出下弯的双角,这是甲骨文中的“羊”。\n第四个字最好认了,太象形了,这是“牛”。\n这样的汉字还有好多,足够组成一个动物园了。\n认识了这些象形字,我要问你一个问题了。在你看来,象形字最主要的特点是什么?\n“像!太像了!”\n没错!象形字就是要把事物形体惟妙惟肖、淋漓尽致地描绘出来。可以说,象形字用图画的方法,记录了丰富而美丽的世界。需要注意的是,甲骨文在描绘物象的时候,非常注重展现它们的特点,从而进行有效地区别,比如说,牛角向上,羊角向下,就是凸显了牛羊的特点。\n图画与背景 # 不是所有的事物画成字时都容易识别。有些东西,即使你画出了它的样子,别人也不认识。\n什么?你说你不信?好吧,我画一个给你看。\n这个方块,你说它是什么?有人说,这是一块面包。有人说,这是一扇窗户。还有人说,这是一个手机。到底是什么,得不出一个明确的答案。\n因此,光描绘事物本身不行,有时还需要把它的背景画出来。比如这个方块,它的背景其实是一个房子——。\n墙上面方方正正的一个框,一定是窗户。这个字,其实是甲骨文中的“向”。《说文解字》说:“向,北出牖(yǒu)也。”“牖”是窗户的意思,“向”最初的意思是朝北开的窗户,由此引申出“方向、朝向”的意思。\n画完了一个方块,再画一个椭圆。猜猜看,这是什么字?\n有人说,这是一个鸡蛋。有人说,这是一块圆面包。还有人说,这是我们的圆滚滚的脑袋!\n都不是,这是一个瓜!\n凭什么说它一定是个瓜?我才不信呢!\n所以,在造字的时候,要把和瓜有关的背景凸显出来——。\n瓜长在藤上,甲骨文中画出了蔓延下垂的藤条,藤条下面圆滚滚的东西,自然是甜甜的西瓜啦。你看这个字,和今天的“瓜”字很像。\n刚学写字的时候,我们经常分不清“瓜”和“爪”。记住了,“瓜”字多出一点,这一点,正是瓜的象形。而“爪”的甲骨文是,看着很像爪子,或是人手垂下来的样子。\n再画一个:一个圆圈,里面是一个十字,十字里面有小点。有人说,这是一个鬼脸。有人说,这是一块田地。还有人说,这是一个熊掌。\n其实,这是一个果实的象形。果实里面长满了果肉,结满了籽。但如果有人觉得不像,怎么办呢?在造字的时候,古人在果实下面添加了一个“木”,变为——长在树上,肯定不是熊掌,不是田地,不是鬼脸,而是味美可口的水果。古文字中的“果”和今天的“果”还很像,记住,果上面不是“田”地,而是果实的象形。\n这些象形字有一个共同的特点,单独画出它们的形体,认不出来,必须把这个字的背景也画出来,才能确定是什么字。图画加背景,构成了另一种象形字。\n总结一下,“山、水、草、木、鹿、马、羊、牛”,这些只需要描绘事物的象形字,叫作“独体象形”;“向、瓜、果”,这些既需要描绘事物,也需要画出背景的象形字,叫作“合体象形”。\n特殊的象形字 # 无论刚才讲到的独体象形字的例子,还是合体象形字的例子,它们都是描绘具体的事物,都是名词。有没有一种表示抽象的字义、表示形容词的象形字呢?\n有,但比较少,这是一种特殊的象形字。\n你看这个字,像什么?下面是房屋的地基,上面是一间高高的房子,还有一个尖顶。它是房屋的象形,但却不是“房”字,而是甲骨文中的“高”。\n为什么高大、高矮的“高”,要用房子来表示呢?\n说来话长,高和低、矮相对,意思上比较抽象,不易造字。所以,古人就用生活中最常见的、高高的事物来造字。\n在远古时代,古人是穴居的,或者在山侧挖一个窑洞,或者在地上挖个坑,上面再搭一个棚子——“穴”是洞穴的意思,穴居就是住在洞穴里。在今天陕西半坡遗址中,还能看到先民穴居的遗迹。\n穴居生活相当辛苦,潮湿阴冷不说,还总受野兽和毒虫的侵扰。随着社会生产的发展,古人开始搭建一种二层的木制小楼——下面一层防潮,顺便还能养猪;上面一层干燥舒适,用来住人。这种建筑比以前的穴居屋舍,高出来很多,所以古人就用房屋的形状来表示抽象的“高”的概念。\n讲完了“高”,它还有一个近义词—“大”。\n怎么给“大”造字呢?画一头大象?画一座大山?画出无边无垠的天空?都不是好办法!大象、大山容易和“象”“山”混淆,无边的天空则很难描画。想来想去,聪明的古人想出一个好办法:在我们的人体中取象!\n人的身体在什么时候最“大”呢?当然是正面站立、四肢伸展的时候,很有一种顶天立地的大丈夫感。就这么办!古人画出了一个正面站立的人形,表示“大”,在今天的楷书中,我们还能看出,“大”是一个人伸展四肢的样子。\n不过,这种表示抽象概念的象形字,数量很少。\n讲完了象形字,我们一起思考一个问题。在你看来,这种造字方法有什么优点呢?形象,逼真,可爱,惟妙惟肖,一看就懂,趣味盎然……\n说得都不错!\n那么,象形字又有哪些不足呢?\n指事字-给象形字加上标签 # 象形字是一种经典的造字方法,古人细致地观察世界,选取各种精彩的画面,纳入汉字之中。象形字里充满了古人的智慧、灵感与用心。\n但是象形字也有它的不足。世界丰富多彩,并不是所有的事情都可以用图画来表达,一旦事情复杂了,象形字也难免笔画繁多,写起来太麻烦。而且象形字适合表达名词,特别是那些基础、常见的事物。但语言中的抽象内容,该怎么造字呢?\n这些不足,都给古人提出了新的挑战——要有更多的造字方法!\n什么是指事字? # 先问个小问题吧。你在家给爸爸妈妈帮厨吗?会切菜吗?我的问题是,如何造一个字,来表示菜刀的锋利呢?\n这个字真难造!我可以画出一把刀,但怎么能画出“锋利”呢?画一把快刀,旁边是斩断的树木,甚至连大山、大河都能砍断,这样行吗?意思是有了,但写起来太麻烦——别忘了,我们是很懒的哦。\n对此,聪明的古人想出来一个简单又好用的造字新方法。先画出一把刀,然后为了偷懒只保留了刀的一些线条。再在刀刃上添加一点,标示出刀刃的地方,创造出、等不同的写法——这就是甲骨文中的“刃”,表示刀的锋利之处,又好写,又好认!\n这种造字的新方法,人们叫作指事。“事”是想要表达的字义,“指”是表达字义的方法——在象形字上添加一个小标签,“指”明所要表达的“事”物。\n在《说文解字》中,许慎先生给出了指事字的辨别方法。“视而可识,察而见意,上下是也。”\n“视而可识”是针对象形字说的,“视”是看,“识”是认识,指事字我们往往觉得很熟悉,因为它是在象形字的基础上造出来的,一看就认识。“察而见意”说的是象形字上的小标签,用汉字学的科学术语来说,它叫作“指事符号”。“察”是仔细看,用心观察,“意”是字义——仔细看看指事字,认真研究象形字上面多出的小标签,你就能明了这个指事字的字义。\n总结一下,指事字是在象形的基础上,添加一个标示性的指事符号,来表达新的字义。\n许慎给指事举了两个例子,“上”与“下”。“上”和“下”太抽象了,怎么给它们造字呢?\n这两个就是“上”“下”的甲骨文,乍一看很像“二”。这两个甲骨文里,都有一个长横。在汉字中,长横经常用于表示大地,因为远眺地平线时,大地就是一道直线。在大地之上,加一个表示位置的小标签,就是甲骨文中的“上”;在大地之下,加上标签,就是甲骨文中的“下”。\n指事字不常见 # 指事字是对象形造字的重要补充,但在汉字中,指事字的数量是很少的。\n少到什么程度呢?常见的指事字,也许不超过二十个。正因如此,指事字在汉字中格外宝贵,物以稀为贵嘛!就这么点儿指事字,要不要好好了解一下呢?我现在就给你介绍几个有趣的指事字吧。\n先看这个字,它的基础是一个“又”字——。在古文字中,“又”是右手的象形,伸出你的右手,自然放松,大拇指和食指分开,中指、无名指、小指轻轻地合拢在一起,从侧面看,和甲骨文中的“又”像不像?在汉字中,“又”经常表示手的意思,我们在后面还会讲到。\n是在(又)的下面多了一点,作为指事符号。在手腕的部位,加上一个小标签,猜猜看,这是什么字呢?\n猜不出来吧,嘿嘿,我来告诉你,这是古文字中的“寸”。\n咦,“寸”是个长度单位啊,它和我们的手腕有什么关系?\n要知道,古代最早的丈量单位,很多都来自人体。人是万物的尺度,在尺子还未诞生的时代,我们用自己的身体丈量世界。我们的手腕上有一条横纹,在中医里叫作腕横纹。在你的腕横纹下面,用三个手指头一比,就是一寸,也就是中医所谓的“同身寸”——你的一寸,只有用自己的手指头才能量准。因此古人用一个小点标识出一寸的位置,造出了“寸”字。\n这个位置,有一个穴道,叫作内关穴。在腕横纹下面一寸,再找到两根筋,中间的位置就是内关穴。用力摁一摁,酸酸疼疼的,这是身体上非常重要的一个穴位。找找看,能不能按准自己的内关穴?\n你看,汉字包罗万象,它和古老的中医文化也是相通的。\n说完“寸”,再看这个字。\n里面是一只眼睛,也就是“目”,外面一个框,包围着目字。这是什么字呢?是眼眶的“眶”吗?眼睛的外围又是什么呢?\n其实,这个字是脸面、要面子的“面”,包围着“目”的框,是一种特殊的指事方式。在楷书中,“面”这个字里面也有个“目”,这是古文字的存留。明眸善睐的眼睛,给人留下的印象太深了,它不仅是心灵的窗口,也是颜面的象征。\n一个“木”变出三个字 # 再看这三个字,它们分别是什么呢?\n这三个字,有一个共同的基础,那就是中间有个(木)。是一个典型的象形字,惟妙惟肖地画出了一棵小树的样子。\n在木的下面,树根的地方,加一个小横作为标签,这就是“本”。“本”最开始是树根的意思,后来引申出“根本、基础”的意思。根扎牢了,树木才能茁壮成长,成为参天大树。做人之道也是如此,《论语》中说:“君子务本,本立而道生。”把握住人生的根本,才能不断地生发出君子之道。\n在木的上面,树梢的地方,加一个小横,这个字是什么呢?这个字和“本”相对,是“末”,表示树枝的末梢,因此引申出最后的意思。所谓“末日”,就是最后一天。\n在木的中间,树干的地方,加上一个小横,这个字又是什么呢?不好猜吧。这个字是“朱”。朱是红色的意思,它和树干有什么关系?我们看一看《说文解字》的解释,许慎先生说:“朱,赤心木也。”在大森林中,有一种特殊的树,它的中间是红色的。所以,在树干上加一个小标签,表示这是一种红心的木头,也就是“朱”。\n我为你介绍了一些最常见的指事字,在汉字中,还有哪些指事字呢?\n想想许慎先生说的“视而可识,察而见意”,快去找找看吧!\n会意字-拼积木造字法 # 在发明指事字的时候,古人明白了一个道理——光用象形造字是不够的,必须要有更多的造字方法。他们的脑子里很快又蹦出一个新的想法,如果把不同的象形字拼起来呢?下面我要介绍的这种造字法,就是一种像拼积木一样的造字法——会意。\n“文”与“字”可不一样 # 在讲解会意字之前,我们需要先分清楚“文”和“字”。\n什么?“文字”不是一个词吗?“文”和“字”不是一个意思吗?\n当然不是,听我慢慢讲来。\n象形字和指事字有一个共同的特点:拆不开。\n回想一下,象形字是一个独立的字,拆开了,就不成字了,指事字可以拆分成一个字和一个标签,但标签——指事符号也不是字,因此指事字里也只有一个字。这样的汉字,我们叫作“独体字”,它们由图画演变而来,是一个整体,切分不开,都是些独来独往,单独成字的“独行侠”。\n和独体字相对的,是“合体字”,它们喜欢热闹,喜欢抱团,是由两个或更多的独体字组成的新字,就像动画片里的合体变身一样酷炫。\n合体字有两种,一种是这一讲要介绍的会意字,还有一种,是下一章要讲的形声字。\n关于独体字与合体字,古人有专门的称谓——“文”与“字”。“文字”在今天是一个词,但在古人眼中,它们的意思是不一样的。\n什么是“文”呢?\n在古文字中,(文)是一个正面站立的人形,身上画着五彩斑斓的纹样,它的本义是文身。漂亮的甲骨文和指事字,都像是美丽的图画,仿佛精美的文身一样,因此,古人用“文”来指那些用图画的办法造出来的独体字。\n什么是“字”呢?\n你看金文中,(字)上面是一个“宀”,这是房屋的象形,下面是“子”,就像一个挥舞着两只手的小宝宝一样——在房屋中养育小孩子,所以,“字”有养育的意思,引申为不断生长。因此,“字”指那些在独体字的基础上,通过合体变身,不断滋生出来的合体字。\n“独体为文,合体为字”,这是汉字最基本的分类。\n由“文”到“字”,由独体到合体,汉字经历了一个不断生长的过程。在这个过程中,独体的“文”不断地合体、变化,造出各种各样的新字,像生命一样繁衍生息、昂扬生长,帮助古人更加准确地记录丰富多彩的世界。\n什么是会意字? # 讲完了合体字,现在我为你介绍合体字中的第一类——会意字。\n什么是会意字呢?\n我们知道,一个字是有它的字义的,这就是“意”。“会”呢,有集合的意思,开班会就是一群学生聚集在一块儿。因此,会意字是把不同的文字像拼积木一样拼合在一起,“会”聚不同汉字的字“意”,从而造出新字,表达新义。\n在《说文解字》中,许慎先生这样讲解会意字:“会意者,比类合谊,以见指㧑(huī)。”这句话看上去好难懂啊!简而言之,许慎的意思是,会意字是把两个或者多个形体的意义结合起来,来表达新的字义。\n许慎先生给会意字举了个例子——武。\n你喜欢看武侠电影吗?在荧屏上,侠客们飞来飞去、行侠仗义,是不是很潇洒?但你有没有想过,汉字“武”是怎么造出来的呢?它又具有怎样的文化内涵呢?\n你看,是甲骨文中的“武”。下面是一个停止的“止”,上半边是一个“戈”。在先秦时期,戈是最常用的一种武器,我们在博物馆里,还能见到各式各样的青铜戈。“止”加上“戈”,两个字拼在一起,创造出一个新的字义“止戈为武”——停止干戈、阻止战争,这就是“武”的意蕴所在。\n原来,我们一直理解错了“武”字,它不是武力,不是暴虐,不是厮杀和血腥,在它诞生之初,它其实寄托着一个美丽而远大的理想——缔造和平。\n古人对“武”的解释真是极具丰富哲理!而且,这个解释不是许慎的独创,它的创造者是那位“问鼎中原”的楚庄王。\n楚庄王是一位传奇的国君。根据《韩非子》记载,他继位的时候,年岁很小,朝廷中由权臣把握朝政。这位年轻的楚庄王怎么办呢?继位三年,他从来不发号施令,一天到晚游山玩水、饮酒作乐,潇洒极了。\n国君很颓废,一些忠心耿耿的大臣看不下去了。有位大臣想向他进谏,但楚庄王说过:“寡人烦着呢!谁也别给我提意见!”这可怎么办呢?\n这位大臣想来想去,突然灵机一动,有主意了!他找到楚庄王,一脸神秘地说:“启禀大王!咱们楚国出了一件怪事!”\n“什么怪事?”楚庄王正在喝酒,一下子来了精神。\n“楚国郊区来了一只奇怪的大鸟,三年了,它一声也不叫,也没有飞,不知道是怎么回事?”\n大鸟?三年?这明摆着是讽谏楚庄王无所作为。楚庄王又不傻,他放下酒杯,眯着眼睛看着大臣,缓缓地说:“爱卿,你是不懂这只大鸟的心啊!它不飞,是要让羽翼丰满;不鸣,是要观察人心。你看着吧,这只鸟三年不飞,一飞冲天,三年不鸣,一鸣惊人!”\n于是,楚庄王开始飞翔了。他励精图治,提拔贤才,惩处昏庸无能的大臣,让楚国迅速强大起来。\n楚国强大了,就要北上争霸中原。在春秋时期,北方强大的晋国和南方兴盛的楚国,在中原地带多次争霸。在楚庄王的带领下,楚国和晋国之间发生了一场大战!\n根据《左传》记载,当时晋国派出了一支敢死队,跑到楚国大营前耀武扬威,进行挑战。按照常规,敌人来挑战,派出一支小分队迎击就好了。但是这位英武勇敢的楚庄王竟然亲自出马,带领队伍追杀过去。\n楚庄王冲杀在队伍的最前方,王的军旗在风中猎猎飞扬。楚国大臣一看,“坏了!小心大王有危险!”怎么办?全军出阵!跟着大王往前冲杀!\n当时,楚国军队“车驰卒奔”——战车奔驰,士兵喊叫着拼命冲杀,一时间烟尘四起,杀气冲天。晋国大将哪儿见过这阵势,好家伙,楚庄王这是要拼命啊!慌乱之中,他犯下了一个致命的错误:“先济者有赏!”\n“济”是过河的意思,当时黄河在晋国军队的后方。“谁先跑过黄河,我就赏赐谁!”这个命令,等于鼓励临阵脱逃。于是,晋国军心大乱,纷纷溃逃,被楚国杀得落花流水。\n大胜之后,有人向楚庄王建议:“把敌人的尸体都堆积起来,展示我们的军威!”没想到,勇猛善战的楚庄王拒绝了这个建议。他说:“止戈为武!”真正的“武”,是要能够停止干戈,战争的终极目的是奠定和平。我们现在做不到,但至少不要炫耀杀了多少人吧。\n这个说法真精彩!\n楚庄王对“武”的解释,反映出中国古人向往和平、期待和平的愿望。\n一个“止”,一个“戈”,把两个字合起来造出新字,表达全新的意思,这就是会意字!\n人丁兴旺的会意字 # 在汉字中,会意字的数量比象形字、指事字加在一起还要多,真是人丁兴旺。\n先看几个和“手”有关的会意字吧。在讲指事字的时候,我们介绍过“又”字,这是古文字中手的象形。接下来的几个字,都和“又”有关。\n你看,是什么字?\n从甲骨文到小篆,这个字的形体一脉相承,上面一个“又”,下面一个“又”,两只手,表达怎样的含义呢?\n握手?拉手?掰手腕?\n其实,这个字是古文字中的“友”。\n你看,今天“友”上面的一横一撇,不就是(又)拉直之后的样子吗?\n《说文解字》说:“同志为友。”什么是真正的朋友呢?\n一方面,两个人要有共同的理想志向,有着共同的人生方向;另一方面,他们就像紧紧拉在一起的两只手一样,互相帮助、互相关怀、互相扶持。\n两只紧紧握在一起的手,把“友”的内涵淋漓尽致地展现出来了。\n再看这个字,下面是一只手,上面是一个人,手牢牢地把人抓住,这是什么字呢?\n这个字是古文字中的“及”。\n什么是“及”呢?\n《说文解字》说:“及,逮也,从又从人。”逮是逮捕的意思,也就是赶上,“及”的本义就是赶上、追上前面的人。在《宋史》中,有一家人在逃亡的过程中,“为金人所及”,就是被金兵追上,被抓住了。\n了解了“及”的字义,我们也就明白了“及格”的意思。在这个词里,“格”是标准的意思,“及格”就是刚刚赶上某种标准。考试的标准是60分,及格就是考试成绩达到了这个标准。\n还有《水浒传》中的好汉宋江,外号叫“及时雨”——赶在大旱时节,下了一场恰到好处的大雨,这就是“及时”的内涵。\n“友”和“及”是简单的会意字,我们再看一个形体复杂的会意字。这个字笔画好多!看上去蛮有趣的,它是什么字呢?\n这个字是小篆中的“爨”,读作cuàn,是烧火做饭的意思。在《说文解字》中,许慎先生详细地分析了它的形体:“臼象持甑(zèng),冂为灶口,廾推林内火。”上面是两只手,拿着一个蒸锅,放到灶口之上。做什么呢?当然是要烧饭了。光有锅不行,还要点火。古人没有燃气灶、微波炉,做饭时要烧柴火,这个字的下面是灶口,里面点燃了熊熊炉火,还有两只手,不断地把木柴添进去……\n“爨”是一个内涵丰富的会意字,把古人烧火做饭的画面,活灵活现地展现在我们面前。\n总之,会意字把不同的汉字拼在一起,造一个新字,这种造字方式适合表达复杂的字义和丰富的文化内涵。\n介绍完栩栩如生的象形字、数量稀少的指事字、姿态万千的会意字,这一章,我来介绍合体字中的第二类字——形声字。\n这是一种超厉害的造字法。\n好用的形声字 # 形声字厉害在哪里呢?\n好用!特别好用!\n在汉字中,形声字是最常用的造字方法。根据统计,汉字中有将近90%的形声字——十个字里面,有八九个是形声字,你说它重要不重要?\n什么是形声字?\n许慎先生说:“形声者,以事为名,取譬(pì)相成。”形声字可以一分为二,一半是形符,一半是声符,形符表达汉字的意义,声符提示汉字的读音。\n什么是形符呢?请看“溪、江、河、湖、海、洋”这几个字,它们表示大大小小不同的水系,都和水有关,因此都用“水”作为形符。“以事为名”,“事”是事物,也就是形符所表示的内容,“名”指汉字,“以事为名”指形声字以形符为基础来造字。\n知道了形符的含义,声符也就不难理解了。顾名思义,声符表示汉字的读音。我们看“取譬相成”这四个字:什么是“譬”呢?就是打比方。形声字的声符和这个字的读音很像,但不是完全相同,就像给读音打个比方一样。还以“溪、江、河、湖、海、洋”这几个字为例,“溪、湖、洋”的声符是什么?分别是“奚、胡、羊”,声符和形声字的读音一样;“江、河、海”的声符是什么?分别是“工、可、每”,声符的读音和形声字不太一样。\n为什么会有声符和形声字读音不同的现象呢?原因很复杂!有的是因为声符和形声字在古代读音一致,随着汉语的发展,今天的读音已经不同了,比如“工”和“江”;还有的是在造字之初,古人就选择了读音相近的字作为声符,比如“可”和“河”。无论如何,声符都能起到提示字音的作用。\n注意!声符提示字音,而不是完全等于字音。在我们识字的时候,有一个很容易犯的毛病,那就是根据声符的读音来念形声字。所谓“秀才识字念半边”,不假思索地把声符当作形声字来读,是要犯错误的。\n形声字从哪儿来? # 形声字是一种很好用的造字方法,它的来源是什么呢?\n一开始,古人发明形声字,是为了强化字义。什么是强化呢?我们看这个字,三个小方块,这是什么字呢?\n这个字不是品德的“品”,而是甲骨文中的“星”。古人仰观天象,看见天空中密布的小星星,于是画了三个小方块,代表众多星辰。可问题在于,有人非要说这是三块石头,怎么办呢?\n这难不住聪明的古人,他们想了一个好办法。在几颗小星星下边加上一个“生”,“生”和“星”的读音非常像,古人想通过一个相似的读音,来强化“星”字的所指——别胡思乱想了,这不是小石头,也不是小点心,而是星星的“星”!\n除了强化字义,形声字也能够分化一个汉字的不同意义。什么是分化呢?我再给大家举个例子:\n甲骨文是“止”,它是小脚丫的象形。下面是脚掌,上面是脚趾。你低头看看,像不像?\n也许你会说:“咦!我明明有五个脚指头,怎么到了甲骨文中,就剩下三个了?”要知道,“三”在古代表示众多的意思,我们前面讲过的“又”“星”都是如此——三根手指,表示众多手指;三颗星星,代表星罗棋布。三根脚指头,足够代表你所有的脚指头了。\n人的双脚站立在大地上,它们是人体的根基之处,因此,“止”引申出基址、地址的意思。可“止”又表示“脚趾”,又表示“地址”,还能表示“停止”的意思……这么多意思,会不会发生混淆呢?聪明的古人想了个好办法,给“止”加上不同的形符,表示不同的意义。\n如果要表示脚指头,那就和“足”有关,于是古人添加“足”字作为形符,创造出脚趾的“趾”;如果要表示地址,建筑物和土地有关,于是古人添加“土”字作为形符,创造出地址的“址”;至于“停止”“静止”的意思,就写作“止”。\n通过添加不同的形符,古人把“止”的字义清晰地区分开来,这就是形声字的分化功能。通过分化,汉字表达的字义更加清晰、准确。\n在强化和分化之外,还有很多形声字来自类化。什么是类化呢?有些相关的字,没有形符,古人为了汉字的规整,便给这一类字统一添加了形符。\n类化产生的形声字往往是批量的。比如说,和金属有关的字,一般都加上了金字旁,于是有了“铜、钢、锡、铠”等形声字。和人有关的字,一般都加上了“单人旁”,于是有了“傍、他、佛、伯”等形声字。和小草有关的字,一般都加上了“草字头”,于是有了“芽、花、芙、莉”等形声字。和树木有关的字,一般都加上了“木字旁”,于是有了“枝、橘、棉、棚”等形声字。\n这种一批一批出现的形声字,就是类化。\n无论是强化、分化,还是类化,古人在不断地使用自己的聪明智慧,让汉字表意更清晰、分类更明确、汉字系统更完整。在这种追求中,形声字成了最常用、最好用的造字方法!\n形声字≠会意字 # 在学习六书的时候,要特别注意,不要把形声字和会意字弄混了。特别是有些形声字的声符是古代的读音,今天读起来已经不像了,很容易被误解为会意字。据说,赫赫有名的王安石也犯过这样的错误。\n宋代的大政治家、文学家王安石,也是一位说文解字的爱好者。王安石研究汉字,写了一本书叫《字说》。有一次,大文豪苏东坡向他请教:“听说您研究汉字很有成果,给我讲讲‘波’这个字是什么意思吧?”\n王安石一听,可高兴了。想不到才高八斗的东坡先生,也有向我请教的时候,我来告诉你吧:“波者,水之皮也!”\n“波”应该怎么讲?它实际上是个形声字,从水皮声,“皮”的古音和“波”是一样的。但王安石不懂,他把“波”讲成会意字了,所谓波浪,就是水面上一层光滑的皮肤。听上去也蛮有道理的,对不对?\n但聪明的苏东坡不以为然,他脑筋一转,反问王安石:“照您这么说,那‘滑’字,难道还是水的骨头吗?”王安石一听,默然无语,无法回答。\n王安石的错误,是把形声字讲成了会意字。汉字是有系统的,我们可以像苏东坡一样举一反三——如果“波”是“水之皮”,那“滑”能是“水之骨”吗?“江”能是“水之工”吗?“河”能是“水之可”吗?完全说不通!\n在学习汉字时,我们一定要避免这样的问题,要有一种汉字系统的观念,触类旁通、举一反三地去学习汉字。\n在前面的内容中,我给大家介绍了汉字的历史和造字的方法。这些知识,是解读汉字的基础。\n可了解了这些知识,你可能还不太会运用。在这一章,我就给你介绍分析汉字,熟练运用六书解读汉字的实操方法——因形求义。顾名思义,因形求义就是利用汉字的字形来解读汉字的字义。自古以来,这都是一种重要的解释汉字的方法,有些因形求义的过程,就像福尔摩斯探案一样有趣呢。\n方法1:找到源头,看图识字 # 使用因形求义法,第一步要注意找寻汉字的源头。汉字是不断发展演变的,在时间的长河中,字形会发生变化,古人造字的用意也会不断丢失。因此,因形求义必须要找到比较古老的字形,追溯古人造字的真意。\n比如,我们都知道的一位古代圣贤——周公。他的名字叫姬旦,姬旦,听上去有点儿像“鸡蛋”!他怎么起了这么一个名字?“旦”又有什么含义呢?\n周公,是中国历史上的大政治家,他是周文王的儿子,周武王的弟弟。周武王打败商朝之后,很快就去世了,他的儿子周成王年纪还小,商朝的残余势力趁机作乱,周朝形势一下子岌岌可危。这个时候,周公挺身而出,摄持朝政,讨伐叛徒,建立起周礼的文化体系,奠定了周朝数百年的天下。在孔子看来,周公是古代制作礼乐的大圣人,他在学习礼乐的时候,还经常会梦到周公呢。\n周公这么了不起,他的名字应该不会搞笑。就让我们化身福尔摩斯,运用因形求义法,去找到周公名字的真相吧!\n简体的“旦”字看起来很抽象,让我们先追本溯源,看看“旦”在古文字中的写法。在甲骨文和金文中,“旦”上边的部分是“日”,下面像是土疙瘩,是大地的象形。在小篆中,“旦”的下面是一横,表示大地。之前我们讲“上”“下”时,它们都是用一横来表示大地的。由此可见,“旦”这个字,就是一轮朝阳从大地之上冉冉升起的样子,它有“朝阳”的意思。周文王给儿子起名字叫姬旦,寄托着一个非常好的寓意——这个孩子,将来是我周王朝的一轮朝阳,是希望的所在!\n通过“旦”的古文字字形,我们猜出并理解了“旦”的内涵。这个过程是不是很像“看图猜字”游戏呢?汉字是表意文字,在造字的时候,古人根据字义来设计字形,字形和字义高度统一。因此,我们可以通过分析汉字的字形,来理解汉语的词义,这就是“因形求义”的方法。\n再举个例子,比如老虎的“虎”字,在楷书、隶书中,都看不出这个字的造字逻辑,小篆也不行,那就要一直上溯,找到金文和甲骨文。\n在甲骨文中,“虎”是一个典型的象形字,惟妙惟肖地画出了老虎的獠牙、利爪,还有身上一道一道的斑纹。在甲骨文中因形求义,我们才能看到古人心目中,作为百兽之王的老虎,既凶猛,又威风!\n再举一个难点儿的例子——“执”。这个字有“抓住、拿着”的意思,手执红旗,迎风招展。在汉字中,“执”应该如何解释呢?它的造字逻辑是什么呢?\n如果根据简化字,左边一个“手”,右边一个“丸”,手里面拿着一个热腾腾的大丸子,这就是“执”吗?不像话!接着上溯,我们看繁体字“執”——“执”成了一个“幸”福的小“丸”子,也不像话呀!\n直到我们看到了甲骨文,才能弄清“执”的含义。在甲骨文中,“执”的左边是一只手铐的样子,右边是一个跪着的人,两只手伸出去,被刑具锁得死死的。《说文解字》说:“执,捕罪人也。”“执”最初的意思是抓住坏蛋,把他们牢牢地锁起来。\n中国古代有一部著名的史书——《左传》,里面记载说“执邾(zhū)悼公,以其伐我故”,这里的“我”指的是鲁国,也就是孔子的故乡。邾悼公是鲁国附近小国的君主,他去攻打鲁国,结果反倒被人家抓起来了,这就是“执邾悼公”。由此,我们梳理清楚了“执”的发展脉络,它的本义是牢牢地抓住,后来引申出“掌握、掌控”的意思,今天我们说“执行”“执掌”“执政”,都是从抓坏蛋的意思引申而来。\n你看,找到古老的形体,就开启了分析汉字秘密的大门。\n方法2:使用“六书”,举一反三 # 使用因形求义法,除了找源头,还要注意的是,千万别把六书的知识忘掉了。一个字是象形字、指事字、会意字,还是形声字,对于准确理解它的意义至关重要。比如“执”字,一个罪人,手穿到刑具中去,这是典型的会意字。如果从形声字的角度去理解,就完全说不通了。\n再比如说,“鹅”这个字怎么解释呢?\n在分析六书的时候,我们说过,一定不要把会意字和形声字弄混了。犯这种错误的人还真不少,有人说,鹅和人的关系很亲密,左边是一个“我”,右边是一个“鸟”,鹅就是“我的鸟”,指和我们关系最亲密的一种鸟。这么讲似乎有点儿道理,但问题在于,其他家禽呢?如果我们以此类推,举一反三,就会发现按照这个逻辑,会闹出一连串的笑话:“鸡”是“又一只鸟”吗?“鸭”是“甲的鸟”吗?“鸿”是“江上的鸟”吗?这样讲解汉字,你说好笑不好笑?\n会意字解释不通,我们可以试试形声字的思路。如果“鹅”是个形声字,那么“鸟”就是它的形符,而“我”可能是个读音不那么准确的声符。再举一反三一下,“鸭”的声符是“甲”,读音很像。至于“鸡”,它的繁体字是“鷄”,“奚”读作“xī”,和“鸡”的读音很像,作为声符也解释得通。这么看来,它们应该都是形声字。\n方法3:核对文献,找准“物证” # 找到了汉字的源头,准确地分析六书,在因形求义法中,最后一个重要的步骤,是要核对古代的典籍文献,确定汉字的本义。解谜汉字,就像侦探一样,要找到语言使用的蛛丝马迹,相互印证。这些古籍,就像解开汉字谜团的“物证”一样。\n举一个例子,你看“行”字。\n现在的字形中,我们已经看不出“行”字为什么这么造字了,它怎么看都和双人旁、和“亍”没什么关系。让我们先上溯到汉字的源头,看看在甲骨文中,“行”是什么样子,又是什么意思吧。猜猜看!\n甲骨文的“行”写作,从六书来分析,这是一个很典型的象形字,古人画出一个十字路口,指的是大路大道,又引申出行列的意思。\n现在,让我们去古文里找到物证,印证一下。\n大诗人杜甫有句很有名的诗:“两个黄鹂鸣翠柳,一行白鹭上青天”,用的就是“行列”的意思。在《诗经·小雅·鹿鸣》篇中,有一句“人之好我,示我周行”,这里的“行”是“道”的意思。诗人说的是,怎么叫作对我好呢?不是给我零花钱,也不是给我糖吃,而是要给我一个“周行”——周全的、完整无缺的大道。\n经过验证,“行”确实表示道路。它现在的样子,是汉字发展演化中,字形发生变化的结果。\n现在让我们反观“银行”一词,什么是银行呢?“银”是古人用的货币,“行”是大路,银行其实就是金银财富往来之所,是货币流通的地方,是财富来来往往的大道。这么解释,是不是非常清晰呢?\n最后总结一下,因形求义三部曲——找古字、辨六书、查古书,一个都不能少。\n通过前面的讲解,我们基本掌握了汉字的知识和应用。而掌握汉字的目的,是为了更好地理解祖先留给我们的精神财富——古代典籍。在阅读古代经典的时候,如果从古老的汉字出发,来解读那些历史悠久的文本,往往能有很多意想不到的收获。\n汉字是一把奇特的钥匙,能够为我们开启古书中的奥秘。让我们以最熟悉的一句经典为例,那就是《论语》中的第一句话:\n“学而时习之,不亦说(yuè)乎?”\n“说”是“悦”的假借字,这句话是说,学到知识后时常温习,不是一件令人心生喜悦的事吗?这句话我们太熟悉了!从上小学的时候,老师就会教这句话。问题是,虽然熟悉,但未必能解读透彻。理解这句话,需要思考一个非常真实的问题:学习是快乐的吗?\n回味一下我们的人生经历,从幼儿园到小学,从小学到中学,将来还要参加高考、进入大学,你是否能拍着胸脯说,我的学习是快乐的!还真不一定。那么,孔子为什么要说他的学习很快乐,他又“乐”在何处呢?想要理解这些问题,关键要读懂四个汉字——学、习、时、说。\n孔子的课程表 # 在讲解“学、时、习、说”之前,我们先要了解一下孔子的课程表。孔子当时要学习“六艺”,这是先秦时期重要的教学内容。什么是“六艺”呢?根据《周礼》(一部讲解古代礼乐、政治的经典)中的说法,“六艺”是礼、乐、射、御、书、数。它们体现为三个层次的教育内容:\n第一个层次是礼乐教育,主要用于涵养一个人的道德品质。“礼”是周代以来的文化传统,它是一整套待人接物、处世为人的法则。对礼的学习,就是在一个人的行为规范中,让他掌握做人的道理。古代礼乐相配,行礼的场合往往需要奏乐。如果说,“礼”主要是培养一个人的行为规范的话,“乐”则是对一个人的心灵、性情的陶冶。在古人看来,一个沉浸在雅乐中的人,自然会呈现出一种温和文雅的品质。\n第二个层次是“射”和“御”的教育。“射”是射箭,“御”是驾车,这是古人投身沙场、保家卫国的必备本领。先秦时期,战争以车战为主,大诗人屈原在《国殇》中写道:“霾(mái)两轮兮絷(zhí)四马。”[1]一车四马,战士在飞奔的马车上弯弓搭箭,射杀敌人。“射”和“御”之间需要整体配合,这是古代君子的能力素养,也是他们保家卫国的社会责任。\n第三个层次是“书”和“数”的教育。“书”是识字,“数”是计算,这是一个人要掌握的文化知识。\n“礼乐”教育是涵养品德,“射御”教育是掌握本领,“书数”教育是获取知识。我们看到,古代教育有三个层次——学品德、学本领、学知识,它们指向一套完整的人才培养体系。这种教育是“动手动脚”的,特别注重实践。要知道,我们的古人可不是端坐在课堂上,老老实实地听讲,“礼”和“乐”需要演练,“射”和“御”需要实操,这是一种全方位的、使人活动起来的学习。和今天的学习内容相比,“六艺”教育丰富多彩,一点儿也不枯燥。\n学:开启生命的觉悟 # 仅仅是学习内容的丰富,就会让人在学习中“不亦说乎”吗?也不尽然。了解了先秦的课程表之后,我们要进一步理解“学”的精神实质。什么是“学”?先秦古人眼中的“学”有着怎样的精神内涵?让我们从古老的汉字说起。\n早在甲骨文中,就已经有“学”了。到了金文和小篆,基本已具备了“学”的字形结构。\n你看,“学”的上面有两只手,手里拿的是“爻”。有人说,“爻”与算卦有关,指的是《周易》中卦爻的符号;也有人说,“爻”就是算筹,是一种算算数的教学工具。无论如何,它都是古人学习的内容。\n在的下面是一个“子”,谁来学习?当然是小朋友了!在“子”的上面,还有一个类似房屋建筑的形状,一般认为它代表了古代的教室。\n不过,《说文解字》对这个形体有另外一种有趣的解释。许慎先生认为,“子”上面是“冖”(mì)。\n什么是“冖”呢?\n在介绍金文的时候,我们说过,古代用鼎烹饪。鼎的个头不小,煮的饭经常一顿吃不完。如果食物没有吃完,古人就会用一个布做的盖子罩在鼎上,这个罩子就叫“冖”。\n我们可以想象一下,如果一个人被罩住了会怎样?《说文解字》中解释说:“冖,尚矇也。”一个人的心灵被遮蔽起来,就像被罩子罩住了一样,长期处在蒙昧之中,缺乏生命的自觉。而“学”的实质,就是要通过不断地教与学,来打破心灵的蒙昧状态,开启混沌的罩子,给心灵迎来真知的光明。\n因此,“学”的本质是一种精神觉悟,这种觉悟不仅是指掌握了多少知识,更是要找到自己的人生方向,找到生命的意义与价值。\n习:勤劳不懈的实践 # 和“学”相配的“习”字,它的含义又是什么呢?我们今天常说“学习”,在古人看来,“学”和“习”还不太一样。它们的区别是什么呢?还是要从汉字说起。\n你看,是甲骨文中的“习”。这个字上面是“羽”,下面是“日”。到了小篆,下面变成了“白”,这个字形是繁体字“習”的来源。《说文解字》根据小篆字形解释道:“习,数(shuò)飞也。”所谓“数飞”,就是多次飞行。无论是“白”还是“日”,“習”的字义都清晰明白,是小鸟在白日里学习飞翔。小鸟学飞,总是“扑棱扑棱”呼扇着翅膀,跟着大鸟从一个枝头飞到另一个枝头上,如此反复多次,才能单飞。既然是反复学飞,“习”一定有实践的含义!\n通过汉字的讲解,我们看到,“学”是精神觉悟,“习”是行为实践,它们是一个人成长学习过程中,不可或缺的两个方面。在“学而时习之”中,体现出心灵觉悟和行为实践的统一,这也是儒家传统中的“知行合一”。\n时:把握自然的节奏 # 觉悟和实践的统一,建立在“时”的节奏之中。对于“时”,可以有两种理解,第一种是“按时”,“学而时习之”指要按时学习。该上课了,该读书了,你不能还在睡懒觉。第二种是“适时”,要在合适的时间做合适的事。两种理解并不矛盾,按时学习很好懂,那什么是适时学习呢?\n在魏晋南北朝时期,有一位学问家叫皇侃(kǎn)[2],他在《论语义疏》中,对于“时”与“学”的关系,有着非常精彩的说解:\n夫学随时气则受业易入,故《王制》云“春夏学《诗》《乐》,秋冬学《书》《礼》”是也。\n所谓“时气”,指的是一年四季各有特点。古人认为,学习时要根据四季的特点,安排不同的学习内容。《王制》是周代天子的学习制度,里面说,“春夏学《诗》《乐》,秋冬学《书》《礼》”。\n什么意思呢?\n春天万物苏生,意气洋洋,每个人都活泼欢快。这个时候要学《诗》。要知道,“诗者,志之所之也”,《诗》是性情的抒发,因此要在春天学习,让我们的心灵与大自然一起生发、一起昂扬。\n到了夏天,万物繁盛,因此要学习气象宏大的雅乐,共同振奋。\n再到秋天,万物肃杀,这是一个收敛沉潜的季节,应该学《尚书》。在《尚书》中,有古代的历史,也有尧舜禹等上古先王的教导,在一个严肃的季节里,正好可以走进先王的历史传统。\n最后是冬天,大地冰封,安静肃穆。因此可以学习礼仪,培养做人的规矩。\n在一年四季中,大自然的节律与学习内容形成了完整的统一体。“学而时习之”,就是要根据大自然的节奏展开学习,把内心的精神觉悟落实到行为实践之中,落实到每个人具体的生命过程中。通过这样的学习过程,人不仅收获了知识,更能收获一种生命的充实与拓展。也正是这种成长的收获,给人带来发自内心的喜悦。\n说:心开意解的快乐 # 说到“说”,我们知道,“说”是“悦”的假借字,这究竟是怎样的一种快乐呢?\n也许你会说,“悦是喜悦,是属于内心的快乐”。没错,《孟子》中记载,孔子的弟子对孔子是“中心悦而诚服也”,也就是“心悦诚服”。\n但我接着问你,这份内心的快乐,又是怎样的快乐呢?\n在语言发展的浩瀚长河里,“悦”和“脱”“蜕”来自同一个源头。在古代,它们的读音是相同的,声符都是“兑”,在意义上也相近。“脱”有“解”的意思,“解脱”嘛。而“蜕”则是动物蜕皮,是把外面的一层硬皮“脱”下来。\n这几个字的共同特点,是在意义上都有一种“开解”的感觉。由此可知,“悦”不只是简单的快乐,而是一种“心开意解”的欢喜。遇到一道难题,百思而不得其解,突然灵光一现,思路来了,这就是“悦”;有件事死活想不通,心里打了个结,有人一句话点醒了你,这也是“悦”。\n我们读过陶渊明的《桃花源记》,里面有一段很著名的描写:“便舍船,从口入。初极狭,才通人,复行数十步,豁然开朗。”可以说,“悦”就是生命中的豁然开朗,这是一种彻底的开解与觉悟。\n解读了“悦”字,我们看到,在“学而时习之”和“不亦说乎”之间,存在着极为紧密的搭配关系——在“学”和“习”的互动中,在知行合一的收获中,我们才能收获由衷的“悦”,收获这种心开意解的精神至乐。可以说,“学而时习之”代表着孔子根本的人生方向,那就是一个平凡的生命,如何通过不断地觉悟与实践,不断达到无限的高度与可能。\n汉字的根本特点是“形义统一”,在汉字的形体中,蕴含着丰富的汉语词义的信息,也承载着历史悠久的中华文化。通过对“学、习、时、说”四个汉字的讲解,你是不是懂得了更多中华文化,对“学而时习之,不亦说乎”也有了更深刻的理解呢?\n经过这十三章的分享,我们对汉字有了新的认识——汉字,是通向中国文化的桥梁。走过这座桥,你会走进一个多姿多彩的世界。我们探寻汉字的奥秘,也是在探索中华文化的美丽画卷。希望这套小书可以给你一些有益的启示,让你看到一个不一样的汉字世界,从而掌握推开中华文化大门的能力。\n[1] 出自屈原《九歌·国殇》,整句为“霾两轮兮絷四马,援玉枹兮击鸣鼓”。\n[2] 皇侃(488—545),南朝梁经学家,吴郡(今江苏苏州)人,通《三礼》《论语》《孝经》。著有《论语义疏》《礼记讲疏》《礼记义疏》《孝经义疏》等,仅《论语义疏》存世。\n后记\n这套小书,由我在“博雅小学堂”为小朋友们讲授《说文解字》的讲稿整理而成。\n带领孩子们走进汉字世界,探寻汉字的前世今生,并非易事。如何把握汉字知识的“度”,哪些适合讲,哪些不适合讲?如何做到知识性与趣味性的结合?如何面对汉字的历史来源与释义发展之间的张力?如何通过汉字更为深入地理解我们的语言?如何从汉字出发走进中国文化的广阔天地?对这些问题,我们进行了积极的思考与尝试,是否得当,还请亲爱的读者们多多批评!\n成书之际,我要感谢赵凌女士、任小平女士,她们策划了这门课程;感谢楚怡兄在课程讲授过程中的高水平工作;感谢《青年文摘》的周玲女士,刊发了这套书的部分内容;感谢责任编辑李炜兄和张苗苗女士,对醉心学术而不断拖稿的笔者,给予了充分宽容;“小博集”做书的水平,亦远超我的预期。感谢尹梦、方伟杰、张燕语、王鹤凝、高铭婉、陈子昊、张祎昀等同学的协助整理,特别是张燕语君,帮我搜集了清晰的古文字字形。恍然之间,有的同学已离开了我们,自今视昔,伤逝何如!\n当然,最应该感谢的,是那些听我课程的、读我作品的小朋友!希望这套小书,能让你们喜欢上古老而神奇的汉字——它,是中国文化的根壤所在,也是每一个中国人心灵深处的文化基因。\n庚子仲夏于北京师范大学晓韵楼\n"},{"id":34,"href":"/zh/docs/culture/%E5%A4%A9%E7%BA%AA/%E4%BA%BA%E9%97%B4%E9%81%93/0%E5%BA%8F/","title":"序","section":"人间道","content":"人間道\n校 堪 序 # 本《人间道》乃由民间的中医爱好者整理与校对。中华民族文化博大精深、渊远流长,中医更是中华民族的瑰宝,几千年来一直守护着炎黄子孙的健康。继承和发扬中医,本是我们爱好中医人士与生俱来的使命。可是由于种种原因,使得中医的许多典籍在流传上产生问题;或已出版而校对欠佳,或印量稀少而极难购得,甚或已经绝版而失传。使得这份本应属于整个中华民族所共有共享的珍贵智慧,无法让更多需要的人学习它,大非往圣之本意!\n我们在校正的过程中,仅对明显的错别字给予了修改,对于无法确定者则保留原样。我们力图提供正确无误的电子书,但限于能力,自知错误在所难免。\n本书的原始材料来自于网络,为网络中善心人士传播之电子版本。本书的校正, 由民间中医爱好朋友们自发组织,属于无偿的自发行为,在此首先对他们表示感谢! 本电子版不收取任何费用,亦不得将本书内容用于商业行为。\n根据相关法律,本电子书仅供网络测试,不收取任何费用。请您下载后 24 小时内删除,如果您喜欢本书,请购买原版。任何人不得将本书用于商业行为,否则由此直接或间接引发的任何法律问题,我们不承担责任。\n民间中医爱好者 敬校\n2010-10-22\n自 序\n本書之著作,是為了發揚我國千年來,易經的神髓而作的,目的是希望後代子孫能以中國文化為榮,以身為炎黃子孫為傲,切不可數典忘袓,唯有真正的發揚固有文化,才可以讓中國人領導二十一世紀,獨領風騷。\n本書之書名,是由我父親倪志凌先生親手寫的,我父親飽讀詩書,現旅居美國, 一手好字,為同仁所公認,他一直以中國人為榮,時時教诲他的孫子,不可忘本, 從小我就受他影響很大,萬般皆下品,惟有讀書高的觀念,三日不讀書,面目可憎, 真是如此。現在社會形態一直轉變,我從事算命有十幾年,看盡人間百態有感而發, 希望藉由此著作,能寓教於人,不可再茫然度日,只知吃喝玩樂,不知長進。做長輩的要求子女多讀書,而不以身作則,人言:言教不如身教。我愛讀書,喜歡研究, 實身受我父親影響至大,故特別請我父親為我的書名立文,以茲感念。\n梵宇龍八十三年九月十日\n自 序\n易,變易也,隨時變易以從道也。\n不易也,事物之外象皆變,而其理不變也。簡易也,萬法皆不同,然其神只有一也。\n故易廣大皆備,順性命之理,通陰符,盡事物之情,而示吾人開物成務之道。得此易道之神,則天下萬事皆能化繁為簡矣。\n前賢失其義而只能傳其言,後學者誦言而忘其味。自秦以後,無傳矣。前有天官,姜太公、范蠡、鬼谷子、張良、諸葛亮、李淳風、程頤、邵康節、劉伯温之用易,其用易之神,後學者瞠乎其後,而實無來者再傳其神矣。\n易中聖人之道有四:以言者,尚其辭。以動者,尚其變。制器者,尚其象。卜筮者,尚其占。其間藴含吉凶消長之理,進退存亡之道。\n君子靜則觀象以悟其辭,動則觀變以玩其占。\n今世之人,大都得其辭,而未達其意,此著作以悟象之角度申其義,此其目的也。\n祈與同道共勉之!\n作者倪海廈甲戍年正月廿四日\n郭 序\n易經一書,相傳始於伏羲,成於文王。爲古人仰觀天象,俯察地理,累積日常生活資料所得之經驗。稱之為易,蓋取其〔簡易、變易、不易〕。因其簡易,當為常人所了解;因其變易,當可用於事事,復因不易,當能垂教萬世。唯以書成年代久遠,用語艱澀隱晦,不易暸解,師徒口語相傳,又失其微言大義,加以今人多不識此書,或竟視爲算命工具,或直斥爲迷信,殊為可惜。\n畏友 海廈兄,自幼隨明師習醫,於吾國固有之〔山、醫、命、相、卜〕諸術, 無不精通,尤擅於易經,除熟讀探究易經精義,更已將易經融入日常生活中,經由具體之實驗,驗證先哲思想之正確性。而余生也駑鈍,自倖入法界服務以來,對案件之偵査,雖期能究明實情,勿枉勿縱,但驗諸實際,每感實質正義追求之不易與人道之難爲,挫折感屢生,適因緣際會得識 海廈兄,每以易經象術相教,既解迷惑於一時,復啟對問題思考之另一模式,助益可謂大矣。\n今兄將研習易經多年之心得與課堂講授之經驗,集數月之力,爲十餘萬言,著成此書,以簡淺文字,發前人之所未言,闡明易經之微言大義;又博採史例,廣爲佐引,論證古今,詳實可信。於書成之際,邀余爲序,爰不揣淺陋做之序,以示慶賀之意,更盼巨著隨出,以享讀者。\n中 華 民 國 八十三年十一月九日\n郭學廉 序於台灣板橋地方法院檢察署\n徐 序\n人生於天地之間,秉天地之氣而有形,受天地之養以爲生。未有能離於天地之\n間而生者。是倪師海廈畢數十年之心力,上窮天道,下探地脈,中明人事,終底於成,而作「天紀」。\n余不敏,早歲亦嘗涉獵易理,惟不得法鬥。自從倪師游,方知易者「易」也, 何「難」之有?亦知吾國先人之智慧至倪師而能昭顯。「天紀」一書,以易經爲軸, 以天文、地理及人間道爲輔;發前人之所未發,言前人之所未言。復道盡天、人、地三才之關係,良以三才能分,能合;名異而實爲一體也。又豈天人合一之境所能比擬?\n吾國易經博大精深,漢、唐以前,重象、數而輕辭,自宋以降,則重辭而輕象、 數。倪師則並重之。使「天紀」一書不僅成爲集古代易經之大成,更有所發明。例如,,倪師之陽宅學,以九宮八卦圖爲内卦,居於其位之人爲外卦;卦既成,則觀該卦之象以斷其人之吉凶禍福。此實深合易之道也。\n君子静以觀象,退而演易,動則問卜,以果決行。「天紀」一書實爲倪師智慧之結晶,若以卜筮之流者視之,吾不與焉!\n徐光佑甲戍年孟冬 序於 台北景美\n前 言\n本書的內容,共分二部份。〔一〕從圖來談易經,即古人所謂「無字天書」,占卜、問卦,也可以應用於易經推命來批流年行運,也可以運用於陽宅來推易,這是學如何演易。〔二 〕從易辭部份來研究「人間道」。從第一卦「乾為天」開始,天地定位後,人的一生即進入此一輪迴,舉凡天地間所有的任何事物,全部包羅在內, 不但是醒世哲言,更可將世間所有的學問理論簡化之。今人讀易越讀越複雜,或是講易經講半天,而無人懂,這都是未得易之道,所謂易者,易也,不然何名「易經」?\n讀易的心理準備\n易經本身並不難,故讀者先要有一概念,在讀易之前,平心静氣,捨去一切雜念,千萬不可在讀易之前,先對易經有所認定,那尚未開始,您已經錯了 , 一步錯,步歩錯,一旦離開了易經的神,運氣好的要「十年乃字」〔十年乃數之終,有倦怠的象〕,運氣不好,又自以為是的話,可能終其一世,仍未窺得易之全貌。\n孟子學而篇:學而不思則罔,思而不學則殆。\n此二句銘言,示吾人正確的讀書方法為:學而後思則悟。\n現今教育的失敗即在此,莘莘學子們苦背書籍,為考試而讀書,加之考試內容問題的設計只著重死背,造成人們都學而不思則罔。罔者,迷惘也。故現今社會, 人心失落,不知孰是孰非,謡言滿天,大家所從何事,所為何事,只一昧的湊熱鬧, 一昧的為自己利益著想,別人都是不好,只有自己最好,大家一昧的修飾外在,而無人著重內實,繁不勝舉的例子,都是教育失敗造成。更有人成天胡思亂想,從不學習任何學問,亳無正確求知的方法,造成自以為是,殊不知危險就將來臨,結果自己害自己,絶大多數自以為是的人,往往事情發生前都認定是不可能的,事情發生以後都在後悔,更有人連後悔都不,還死不認錯,從不悔改,這是無知至極的, 相信夜闌人靜時一個人會害怕的無法入眠。\n吾人的邏輯〔求學的方法〕如下:假設→驗證→結果,凡天下任何事都無法離開這個科學精神,是非應辨,真理即現,舉例説:有人說:「我不相信命,命都是自己努力來的。」聽起來好像是對的,但諸君深入一想,真偽立判,首先此人犯了第一錯誤,他從未研究過命,也就是完全不懂命學,卻從一不知的角度,來認定古聖先賢智慧的結晶,就是「不知而説」,這是一無知的人。第二錯誤,吾人假設他的話是對的話,既然命完全靠努力可得,那農業博士必是第一志願,因為如拿到農業博士 , 一定可以做到總統的。試問這可能嗎?大家都知道,可是卻從不深思,這就犯了大錯。所以諸君在看易經之前,應如一張白紙,心中無任何擔心會看不懂,或看了無益,或是迷信等等任何想法,都不存於心中。切記「學而後思則悟」的真言,悟得後方屬於您自己的,這才是求知的方法。吾同道共勉之!\n易經中的「人間道」\n易經中共分六十四卦,每一卦體代表事,代表一狀況每一卦有六爻,每個爻意味事之時機也。每一卦的爻是由下而上,從第一爻到第六爻,共六階段,一般只知其為事之演進,吾人在何階段,做何動作,都可以預知其未來之果。如更進一步的推論,則是您在處於何階段,應如何做,如果做錯結果會如何,易經上早已明示, 更進一步提示讀者,這是一部終極的書,它可涵蓋一切,千萬不可被自已對易的觀念鎖定,要爭脱易的束縛,吸收為己用,此時它將領著您,使您有顆平常心,無欲則剛,智慧打開如海一樣能容納百川不增減,這才是讀易的至高階段。\n吾人先定位,六爻可區分為六個層面,如左:\n水雷屯: 定位\n※註:易經原文中,凡九代表陽,六代表陰,例如:九三,即代表第三爻為陽爻,六五即代表第五爻為陰爻。餘類推。\n吾人须先明屯之義,屯為物之初始,如胎在母體中,故萬物之始生謂 屯,此卦示吾人事之始生,如何戒之於初,即「慎始」之精神。\n例如,你是一位新任銀行經理,剛到任時,即物之始生因為經理如一方之將, 故你須看屯卦,第二爻位,為將位應有之態度,於始之時,〔爻為時也。〕。如你剛考上金融特考而分發到一陌生之單位,無人記得,那你就是居於第一爻的位置,易經告訴你如何處理才是正確的,又如你是大企業的第二代須接君位,你就必須看第五爻君位處屯時之方法,成敗就看你如何做了 。唐朝名將李靖,有一銘言:「古今勝敗,一誤而已,」示人一念之間,就已決定勝敗,故開始時是最重要的。此例諸君要依此類推,任何事情都不出易之法則。吾定書名為「天紀」就是説明易經,本來就是一部闡述天地間永不變的紀律,循此法則則不論上下,不論何行,皆不脱離此範圍。\n此後始論易經之人間道,將由六十四卦之順序演變,一 一為各位説明,許多天\n機,已被古之天官將易卦之順序調整,為防止天機外洩,故陰符經云:「天發殺機, 龍蛇起陸,人發殺機天地反覆。」此處恕無法多言,自古能通陰符經之人不多,連姜太公,亦只有一半通悟,故此留於諸賢來研究,俗云「師父領進门,修行在個人。」 現在讓我們來看一看「人間道」。\n目录\n| 乾爲天 | \u0026hellip;\u0026hellip;\u0026hellip;\u0026hellip;\u0026hellip;\u0026hellip;\u0026hellip;\u0026hellip;\u0026hellip;\u0026hellip;\u0026hellip;\u0026hellip;\u0026hellip;. 1 | 地火明夷 | \u0026hellip;\u0026hellip;\u0026hellip;\u0026hellip;\u0026hellip;\u0026hellip;\u0026hellip;\u0026hellip;\u0026hellip;\u0026hellip;\u0026hellip;\u0026hellip;\u0026hellip; 79 |\n|\u0026mdash;\u0026mdash;\u0026mdash;\u0026mdash;\u0026mdash;\u0026mdash;\u0026mdash;\u0026mdash;-|\u0026mdash;\u0026mdash;\u0026mdash;\u0026mdash;\u0026mdash;\u0026mdash;\u0026mdash;\u0026mdash;\u0026mdash;\u0026mdash;\u0026mdash;\u0026mdash;\u0026mdash;\u0026mdash;\u0026mdash;\u0026mdash;\u0026mdash;\u0026mdash;\u0026mdash;\u0026ndash;|\u0026mdash;\u0026mdash;\u0026mdash;\u0026mdash;\u0026mdash;\u0026mdash;\u0026mdash;\u0026mdash;-|\u0026mdash;\u0026mdash;\u0026mdash;\u0026mdash;\u0026mdash;\u0026mdash;\u0026mdash;\u0026mdash;\u0026mdash;\u0026mdash;\u0026mdash;\u0026mdash;\u0026mdash;\u0026mdash;\u0026mdash;\u0026mdash;\u0026mdash;\u0026mdash;\u0026mdash;\u0026ndash;|\n| 坤爲地 | \u0026hellip;\u0026hellip;\u0026hellip;\u0026hellip;\u0026hellip;\u0026hellip;\u0026hellip;\u0026hellip;\u0026hellip;\u0026hellip;\u0026hellip;\u0026hellip;\u0026hellip;.. 7 | 風火家人 | \u0026hellip;\u0026hellip;\u0026hellip;\u0026hellip;\u0026hellip;\u0026hellip;\u0026hellip;\u0026hellip;\u0026hellip;\u0026hellip;\u0026hellip;\u0026hellip;.. 81 |\n| 水雷屯 | \u0026hellip;\u0026hellip;\u0026hellip;\u0026hellip;\u0026hellip;\u0026hellip;\u0026hellip;\u0026hellip;\u0026hellip;\u0026hellip;\u0026hellip;\u0026hellip;.. 11 | 火澤睽 | \u0026hellip;\u0026hellip;\u0026hellip;\u0026hellip;\u0026hellip;\u0026hellip;\u0026hellip;\u0026hellip;\u0026hellip;\u0026hellip;\u0026hellip;\u0026hellip;\u0026hellip; 83 |\n| 山水蒙 | \u0026hellip;\u0026hellip;\u0026hellip;\u0026hellip;\u0026hellip;\u0026hellip;\u0026hellip;\u0026hellip;\u0026hellip;\u0026hellip;\u0026hellip;\u0026hellip;.. 13 | 水山蹇 | \u0026hellip;\u0026hellip;\u0026hellip;\u0026hellip;\u0026hellip;\u0026hellip;\u0026hellip;\u0026hellip;\u0026hellip;\u0026hellip;\u0026hellip;\u0026hellip;\u0026hellip; 85 |\n| 水天需 | \u0026hellip;\u0026hellip;\u0026hellip;\u0026hellip;\u0026hellip;\u0026hellip;\u0026hellip;\u0026hellip;\u0026hellip;\u0026hellip;\u0026hellip;\u0026hellip;.. 15 | 雷水解 | \u0026hellip;\u0026hellip;\u0026hellip;\u0026hellip;\u0026hellip;\u0026hellip;\u0026hellip;\u0026hellip;\u0026hellip;\u0026hellip;\u0026hellip;\u0026hellip;\u0026hellip;87 |\n| 天水讼 | \u0026hellip;\u0026hellip;\u0026hellip;\u0026hellip;\u0026hellip;\u0026hellip;\u0026hellip;\u0026hellip;\u0026hellip;\u0026hellip;\u0026hellip;\u0026hellip;.. 17 | 山澤損 | \u0026hellip;\u0026hellip;\u0026hellip;\u0026hellip;\u0026hellip;\u0026hellip;\u0026hellip;\u0026hellip;\u0026hellip;\u0026hellip;\u0026hellip;\u0026hellip;\u0026hellip; 89 |\n| 地水師 | \u0026hellip;\u0026hellip;\u0026hellip;\u0026hellip;\u0026hellip;\u0026hellip;\u0026hellip;\u0026hellip;\u0026hellip;\u0026hellip;\u0026hellip;\u0026hellip;.. 19 | 風雷益 | \u0026hellip;\u0026hellip;\u0026hellip;\u0026hellip;\u0026hellip;\u0026hellip;\u0026hellip;\u0026hellip;\u0026hellip;\u0026hellip;\u0026hellip;\u0026hellip;.. 91 |\n| 水地比 | \u0026hellip;\u0026hellip;\u0026hellip;\u0026hellip;\u0026hellip;\u0026hellip;\u0026hellip;\u0026hellip;\u0026hellip;\u0026hellip;\u0026hellip;\u0026hellip;.. 21 | 澤天夬 | \u0026hellip;\u0026hellip;\u0026hellip;\u0026hellip;\u0026hellip;\u0026hellip;\u0026hellip;\u0026hellip;\u0026hellip;\u0026hellip;\u0026hellip;\u0026hellip;\u0026hellip; 93 |\n| 風天小畜 | \u0026hellip;\u0026hellip;\u0026hellip;\u0026hellip;\u0026hellip;\u0026hellip;\u0026hellip;\u0026hellip;\u0026hellip;\u0026hellip;\u0026hellip;\u0026hellip;\u0026hellip; 25 | 天風姤 | \u0026hellip;\u0026hellip;\u0026hellip;\u0026hellip;\u0026hellip;\u0026hellip;\u0026hellip;\u0026hellip;\u0026hellip;\u0026hellip;\u0026hellip;\u0026hellip;\u0026hellip; 95 |\n| 天澤履 | \u0026hellip;\u0026hellip;\u0026hellip;\u0026hellip;\u0026hellip;\u0026hellip;\u0026hellip;\u0026hellip;\u0026hellip;\u0026hellip;\u0026hellip;\u0026hellip;\u0026hellip; 27 | 澤地萃 | \u0026hellip;\u0026hellip;\u0026hellip;\u0026hellip;\u0026hellip;\u0026hellip;\u0026hellip;\u0026hellip;\u0026hellip;\u0026hellip;\u0026hellip;\u0026hellip;\u0026hellip; 97 |\n| 地天泰 | \u0026hellip;\u0026hellip;\u0026hellip;\u0026hellip;\u0026hellip;\u0026hellip;\u0026hellip;\u0026hellip;\u0026hellip;\u0026hellip;\u0026hellip;\u0026hellip;\u0026hellip; 29 | 地風升 | \u0026hellip;\u0026hellip;\u0026hellip;\u0026hellip;\u0026hellip;\u0026hellip;\u0026hellip;\u0026hellip;\u0026hellip;\u0026hellip;\u0026hellip;\u0026hellip;\u0026hellip; 99 |\n| 天地否 | \u0026hellip;\u0026hellip;\u0026hellip;\u0026hellip;\u0026hellip;\u0026hellip;\u0026hellip;\u0026hellip;\u0026hellip;\u0026hellip;\u0026hellip;\u0026hellip;.. 31 | 澤水困 | \u0026hellip;\u0026hellip;\u0026hellip;\u0026hellip;\u0026hellip;\u0026hellip;\u0026hellip;\u0026hellip;\u0026hellip;\u0026hellip;\u0026hellip;\u0026hellip; 101 |\n| 天火同人 | \u0026hellip;\u0026hellip;\u0026hellip;\u0026hellip;\u0026hellip;\u0026hellip;\u0026hellip;\u0026hellip;\u0026hellip;\u0026hellip;\u0026hellip;\u0026hellip;\u0026hellip; 33 | 水風井 | \u0026hellip;\u0026hellip;\u0026hellip;\u0026hellip;\u0026hellip;\u0026hellip;\u0026hellip;\u0026hellip;\u0026hellip;\u0026hellip;\u0026hellip;\u0026hellip; 103 |\n| 火天大有 | \u0026hellip;\u0026hellip;\u0026hellip;\u0026hellip;\u0026hellip;\u0026hellip;\u0026hellip;\u0026hellip;\u0026hellip;\u0026hellip;\u0026hellip;\u0026hellip;\u0026hellip; 35 | 澤火革 | \u0026hellip;\u0026hellip;\u0026hellip;\u0026hellip;\u0026hellip;\u0026hellip;\u0026hellip;\u0026hellip;\u0026hellip;\u0026hellip;\u0026hellip;\u0026hellip;.105 |\n| 地山謙 | \u0026hellip;\u0026hellip;\u0026hellip;\u0026hellip;\u0026hellip;\u0026hellip;\u0026hellip;\u0026hellip;\u0026hellip;\u0026hellip;\u0026hellip;\u0026hellip;\u0026hellip; 37 | 火風鼎 | \u0026hellip;\u0026hellip;\u0026hellip;\u0026hellip;\u0026hellip;\u0026hellip;\u0026hellip;\u0026hellip;\u0026hellip;\u0026hellip;\u0026hellip;\u0026hellip; 107 |\n| 雷地豫 | \u0026hellip;\u0026hellip;\u0026hellip;\u0026hellip;\u0026hellip;\u0026hellip;\u0026hellip;\u0026hellip;\u0026hellip;\u0026hellip;\u0026hellip;\u0026hellip;\u0026hellip; 39 | 震爲雷 | \u0026hellip;\u0026hellip;\u0026hellip;\u0026hellip;\u0026hellip;\u0026hellip;\u0026hellip;\u0026hellip;\u0026hellip;\u0026hellip;\u0026hellip;\u0026hellip;.109 |\n| 澤雷隨 | \u0026hellip;\u0026hellip;\u0026hellip;\u0026hellip;\u0026hellip;\u0026hellip;\u0026hellip;\u0026hellip;\u0026hellip;\u0026hellip;\u0026hellip;\u0026hellip;.. 41 | 艮為山 | \u0026hellip;\u0026hellip;\u0026hellip;\u0026hellip;\u0026hellip;\u0026hellip;\u0026hellip;\u0026hellip;\u0026hellip;\u0026hellip;\u0026hellip;.. 111 |\n| 山風蠱 | \u0026hellip;\u0026hellip;\u0026hellip;\u0026hellip;\u0026hellip;\u0026hellip;\u0026hellip;\u0026hellip;\u0026hellip;\u0026hellip;\u0026hellip;\u0026hellip;\u0026hellip;43 | 風山漸 | \u0026hellip;\u0026hellip;\u0026hellip;\u0026hellip;\u0026hellip;\u0026hellip;\u0026hellip;\u0026hellip;\u0026hellip;\u0026hellip;\u0026hellip;\u0026hellip; 113 |\n| 地澤臨 | \u0026hellip;\u0026hellip;\u0026hellip;\u0026hellip;\u0026hellip;\u0026hellip;\u0026hellip;\u0026hellip;\u0026hellip;\u0026hellip;\u0026hellip;\u0026hellip;\u0026hellip; 45 | 雷澤歸妹 | \u0026hellip;\u0026hellip;\u0026hellip;\u0026hellip;\u0026hellip;\u0026hellip;\u0026hellip;\u0026hellip;\u0026hellip;\u0026hellip;\u0026hellip;\u0026hellip; 115 |\n| 風地觀 | \u0026hellip;\u0026hellip;\u0026hellip;\u0026hellip;\u0026hellip;\u0026hellip;\u0026hellip;\u0026hellip;\u0026hellip;\u0026hellip;\u0026hellip;\u0026hellip;\u0026hellip;47 | 雷火豐 | \u0026hellip;\u0026hellip;\u0026hellip;\u0026hellip;\u0026hellip;\u0026hellip;\u0026hellip;\u0026hellip;\u0026hellip;\u0026hellip;\u0026hellip;\u0026hellip; 117 |\n| 火雷噬嗑 | \u0026hellip;\u0026hellip;\u0026hellip;\u0026hellip;\u0026hellip;\u0026hellip;\u0026hellip;\u0026hellip;\u0026hellip;\u0026hellip;\u0026hellip;\u0026hellip;\u0026hellip; 49 | 火山旅 | \u0026hellip;\u0026hellip;\u0026hellip;\u0026hellip;\u0026hellip;\u0026hellip;\u0026hellip;\u0026hellip;\u0026hellip;\u0026hellip;\u0026hellip;\u0026hellip; 119 |\n| 山火賁 | \u0026hellip;\u0026hellip;\u0026hellip;\u0026hellip;\u0026hellip;\u0026hellip;\u0026hellip;\u0026hellip;\u0026hellip;\u0026hellip;\u0026hellip;\u0026hellip;.. 51 | 巽為風 | \u0026hellip;\u0026hellip;\u0026hellip;\u0026hellip;\u0026hellip;\u0026hellip;\u0026hellip;\u0026hellip;\u0026hellip;\u0026hellip;\u0026hellip;\u0026hellip; 121 |\n| 山地剝 | \u0026hellip;\u0026hellip;\u0026hellip;\u0026hellip;\u0026hellip;\u0026hellip;\u0026hellip;\u0026hellip;\u0026hellip;\u0026hellip;\u0026hellip;\u0026hellip;\u0026hellip; 53 | 兌爲澤 | \u0026hellip;\u0026hellip;\u0026hellip;\u0026hellip;\u0026hellip;\u0026hellip;\u0026hellip;\u0026hellip;\u0026hellip;\u0026hellip;\u0026hellip;\u0026hellip; 123 |\n| 地雷復 | \u0026hellip;\u0026hellip;\u0026hellip;\u0026hellip;\u0026hellip;\u0026hellip;\u0026hellip;\u0026hellip;\u0026hellip;\u0026hellip;\u0026hellip;\u0026hellip;\u0026hellip; 55 | 風水渙 | \u0026hellip;\u0026hellip;\u0026hellip;\u0026hellip;\u0026hellip;\u0026hellip;\u0026hellip;\u0026hellip;\u0026hellip;\u0026hellip;\u0026hellip;\u0026hellip;.125 |\n| 天雷无妄 | \u0026hellip;\u0026hellip;\u0026hellip;\u0026hellip;\u0026hellip;\u0026hellip;\u0026hellip;\u0026hellip;\u0026hellip;\u0026hellip;\u0026hellip;\u0026hellip;\u0026hellip; 57 | 水澤節 | \u0026hellip;\u0026hellip;\u0026hellip;\u0026hellip;\u0026hellip;\u0026hellip;\u0026hellip;\u0026hellip;\u0026hellip;\u0026hellip;\u0026hellip;\u0026hellip; 127 |\n| 山天大畜 | \u0026hellip;\u0026hellip;\u0026hellip;\u0026hellip;\u0026hellip;\u0026hellip;\u0026hellip;\u0026hellip;\u0026hellip;\u0026hellip;\u0026hellip;\u0026hellip;\u0026hellip; 59 | 風澤中孚 | \u0026hellip;\u0026hellip;\u0026hellip;\u0026hellip;\u0026hellip;\u0026hellip;\u0026hellip;\u0026hellip;\u0026hellip;\u0026hellip;\u0026hellip;\u0026hellip;.129 |\n| 山雷頤 | \u0026hellip;\u0026hellip;\u0026hellip;\u0026hellip;\u0026hellip;\u0026hellip;\u0026hellip;\u0026hellip;\u0026hellip;\u0026hellip;\u0026hellip;\u0026hellip;.. 61 | 雷山小過 | \u0026hellip;\u0026hellip;\u0026hellip;\u0026hellip;\u0026hellip;\u0026hellip;\u0026hellip;\u0026hellip;\u0026hellip;\u0026hellip;\u0026hellip;\u0026hellip; 131 |\n| 澤風大過 | \u0026hellip;\u0026hellip;\u0026hellip;\u0026hellip;\u0026hellip;\u0026hellip;\u0026hellip;\u0026hellip;\u0026hellip;\u0026hellip;\u0026hellip;\u0026hellip;\u0026hellip; 63 | 水火既濟 | \u0026hellip;\u0026hellip;\u0026hellip;\u0026hellip;\u0026hellip;\u0026hellip;\u0026hellip;\u0026hellip;\u0026hellip;\u0026hellip;\u0026hellip;\u0026hellip; 133 |\n| 坎爲水 | \u0026hellip;\u0026hellip;\u0026hellip;\u0026hellip;\u0026hellip;\u0026hellip;\u0026hellip;\u0026hellip;\u0026hellip;\u0026hellip;\u0026hellip;\u0026hellip;\u0026hellip; 65 | 火水未濟 | \u0026hellip;\u0026hellip;\u0026hellip;\u0026hellip;\u0026hellip;\u0026hellip;\u0026hellip;\u0026hellip;\u0026hellip;\u0026hellip;\u0026hellip;\u0026hellip; 135 |\n| 離為火 | \u0026hellip;\u0026hellip;\u0026hellip;\u0026hellip;\u0026hellip;\u0026hellip;\u0026hellip;\u0026hellip;\u0026hellip;\u0026hellip;\u0026hellip;\u0026hellip;\u0026hellip; 67 | | |\n| 澤山咸 | \u0026hellip;\u0026hellip;\u0026hellip;\u0026hellip;\u0026hellip;\u0026hellip;\u0026hellip;\u0026hellip;\u0026hellip;\u0026hellip;\u0026hellip;\u0026hellip;\u0026hellip; 69 | | |\n| 雷風恆 | \u0026hellip;\u0026hellip;\u0026hellip;\u0026hellip;\u0026hellip;\u0026hellip;\u0026hellip;\u0026hellip;\u0026hellip;\u0026hellip;\u0026hellip;\u0026hellip;.. 71 | | |\n| 天山遯 | \u0026hellip;\u0026hellip;\u0026hellip;\u0026hellip;\u0026hellip;\u0026hellip;\u0026hellip;\u0026hellip;\u0026hellip;\u0026hellip;\u0026hellip;\u0026hellip;\u0026hellip; 73 | | |\n| 雷天大壯 | \u0026hellip;\u0026hellip;\u0026hellip;\u0026hellip;\u0026hellip;\u0026hellip;\u0026hellip;\u0026hellip;\u0026hellip;\u0026hellip;\u0026hellip;\u0026hellip;\u0026hellip; 75 | | |\n| 火地晉 | \u0026hellip;\u0026hellip;\u0026hellip;\u0026hellip;\u0026hellip;\u0026hellip;\u0026hellip;\u0026hellip;\u0026hellip;\u0026hellip;\u0026hellip;\u0026hellip;\u0026hellip; 77 | | |\n"},{"id":35,"href":"/zh/docs/culture/%E5%A4%A9%E7%BA%AA/%E4%BA%BA%E9%97%B4%E9%81%93/%E4%B8%8B%E7%BB%8F/64%E7%81%AB%E6%B0%B4%E6%9C%AA%E6%BF%9F/","title":"64火水未濟","section":"下经","content":" 64火水未濟 # 此卦孔子穿九曲明珠未徹,卜得之,乃遇二女方始得穿也。\n圖中:\n一人提刀斧,一虎坐,一旗卓在山上, 一人取旗,梯子有到字。\n碣火求珠之課 憂中望喜之象\n物必不會終盡,故以未濟為易卦之終。既濟為物之終,易為變易而不盡,有生生之意,故未濟為其序。未濟為未窮盡也,此卦,上離下坎,火在水上,互不為用,為未濟之時義。\n卦圖象解\n一、一人提刀斧:劉姓,武官,手有生殺之權。二、 一虎坐:肖虎人,王姓,虎坐乃無威之象。三、一旗卓在山上:揭竿而起,正義之師。\n四、一人取旗:代替也,高姓,宋姓,其人取代之象。\n五、梯子有至字:無刀日至,桎梏受困象,六、(附註:峪:谷姓。旗揚:楊姓。)有谷、楊合乃招凶象。\n人間道\n未濟:亨。小狐汔濟,濡其尾,无攸利。\n未濟之時,亦有亨通之道。假如小狐不知慎,逞其壯勇,渡河不知慎,其終必不能濟,故無所利矣。\n彖曰:未濟亨,柔得中也。小狐汔濟,未出中也。 # 未濟之時,能亨通,乃因其柔居中得其時,慎處之於未濟時,可以亨通也。如小狐之果敢於渡河,如不憂其尾濕,必不可脱險也。\n濡其尾,无攸利,不續終也。雖不當位,剛柔應也。 # 其進雖勇,因尾濡而不能進,故不利往也。若能慎重處之,剛柔並濟則雖不當位,亦有可濟之理處。\n象曰:火在水上,未濟,君子以愼辨物居方。 # 火在水上,互不相交,此為未濟,君子觀象知,乃處不當之象,應慎處事物,辨其所當, 各居其位,止於其所。\n初六:濡其尾,吝。 # 陰柔居下位,居未濟之時,求力進猶獸之渡河,必揭其尾,方可渡,此言人不度量其才而進,終必不濟,終招吝鄙也。\n九二 :曳其輪,貞吉。 # 陽剛居將位,有欲動之象,今居未濟之時, 有以剛凌柔水來勝火之象,故須知止,如曳車之輪使其止進,如此可吉。戒之在剛過,如此則犯上,其終必凶。\n六三:未濟,征凶,利涉大川。 # 居未濟之時,柔居相位,在下卦之上,有領導之象,但才不足濟,居險而無才足以出險, 如此而行,其必招凶致。惟俟時至,俟上位之才相應,再涉險而出,乃可出險。\n九四:貞吉 ,悔亡,震用伐鬼方, 三年有賞于大國。 # 陽剛之賢才,居於君側,上為中虛明順之君,故能濟天下之艱困,能伐鬼方示其力之大, 三年後乃有大功受國封賞。\n六五:貞吉 ,无悔,君子之光, 有孚,吉。 # 中虛而明之才居君之尊位,能虛其心任剛賢之相為輔佐,且信任之而不悔,此處之至善, 即令己才不足,但亦由中心孚誠,終必大吉。\n象曰:君子之光,其暉吉也。 # 上九:有孚于飲酒,无咎,濡其首, 有孚失是。\n陽剛居極,在中虛卦之上,乃示其剛且明, 能如此,則必不生燥,決之以明,明能洞察事理,剛能行事,然居未濟之時,因無可濟之理, 故成樂天順命而已,可以無災。如過於禮法, 濡其首,亂如是,其必不知反省,則示不安於所居也。\n象曰:濡其尾,亦不知極也。 # 不度其才而力進,乃不知至極點也。\n象曰:九二貞吉,中以行正也。 # 九二能吉,乃因其能得中道,行之正,不過剛,犯上,故也。\n象曰:未濟征凶,位不當也。 # 陰柔無能之才居領導之相位,處未濟時, 動必有凶,仍因其位不適。\n象曰:貞吉悔亡,志行也。 # 賢相之才,能與時合,且貞固心志,必能吉而不慮悔亡也。\n君子之能如是,則其功之光必明且亮,其光盛而吉也。\n象曰:飲酒濡首,亦不知節也。 # 飲酒而至於弄濕其首,乃過樂也,必不能安於命,此不知命節制,必失其常理,人能安處劣勢,必能守其心志。此理之至然。\n"},{"id":36,"href":"/zh/docs/culture/%E5%A4%A9%E7%BA%AA/%E4%BA%BA%E9%97%B4%E9%81%93/%E4%B8%8B%E7%BB%8F/63%E6%B0%B4%E7%81%AB%E6%97%A2%E6%BF%9F/","title":"63水火既濟","section":"下经","content":" 63水火既濟 # 此卦季布在周家潛藏,卜得之,遂遇高皇\n帝\n圖中:\n人在岸上一船來,一堆錢,雲中雨下, 二小兒在雨中行,文書一策。\n舟楫濟川之課 陰陽配合之象\n小過乃過於物,能過於物,必能相濟,故受之以既濟。此卦,水在上,火在下,火水相交, 則能為用,能互為用,即為既濟,此卦言,天下萬事,己互濟之時也。\n卦圖象解\n一、人在岸上:泣也,等待也,無目的也。二、 一船來:接引至也。\n三、一堆錢:財祿無用也,憂心忡忡也,有竊象,抄家之象。四、雲中雨下:陰中有果也,夜間至凌晨時。\n五、二小兒:主喜也,二人也,二人雨中行也。六、文書一策:證件,命令也,資料也。\n人間道\n既濟:亨小,利貞,初吉,終亂。\n既濟之時大事必能亨通,然須知小事亦须亨通,方可為吉,必須所有事皆吉,否則濟至極時,終亂。\n彖曰:既濟亨,小者亨也。利貞,剛柔正而位當也。 # 陰陽能皆得位,故為既濟,但須無所不亨,乃可也,且貞固守之。\n初吉,柔得中也。終止則亂,其道窮也。 # 其以柔順文明之中道,故可成既濟之功,至既濟之極,若不知變通,必生亂,其亂之生乃因道至盡窮。\n象曰:水在火上,既濟,君子以思患而豫防之。 # 水火即交,各有其用,為互濟,時當既濟,君子觀象知於既濟之時,必先思慮患害之生, 使不至成禍患也。自古以來,天下由治而亂,乃皆因居治不思亂時之戒。\n初九:曳其輪,濡其尾,无咎。 # 倒曳輪,使不再進。獸之涉水,必舉高其尾,使尾不濕方可進,無災至。\n象曰:曳其輪,義无咎也。 # 於既濟大吉之時,能知止進,則必不至極時,故必無咎也。\n六二:婦喪其茀,勿逐,七日得。 # 陰居正位,得五君位應,其志必得遂行, 但中正之道,不可廢也,猶婦人出門用茀遮己今喪其茀,則不行,能自守不失,道必復也。待時之至。\n九三:高宗伐鬼方,三年克之,小人勿用。 # 以剛居剛位,居既濟時,其君主威武必令民心服。但若專肆威武,必令民心忿而不服, 殘害人民,貪人民之富,故有小人勿用之戒, 因惟小人其威武必為滿足其私欲,君子戒之。\n六四:繻有衣袽,終日戒。 # 四近君位,陰柔居之,乃適其任,當既濟之時,須如行舟,戒之滲漏,始漏則塞以衣物, 且終日戒懼不怠,則必可免患。\n九五:柬鄰殺牛,不如西鄰之禴祭,實受其福。 # 九五至尊君位,陽剛人居此,當既濟之時, 必以至誠信孚如祭祀之誠,則即令薄禮之祭, 也可勝於豐厚之祭,由其至誠之心使然也。易之不重形,而重神,此明而顯矣,故論易須知\n上六:濡其首,厲。 # 既濟至極時,必不安而危,今陰柔居之, 居坎險之上,即既濟之終,小人居之,其敗壞必立可見矣。\n象曰:七日得,以中道也。 # 因中正之道非不可用,乃時之未至,於此時自守其中,俟時至必能行矣。\n象曰:三年克之,憊也。 # 既濟之時,必濟天下,於高宗可也,乃因其心為民,道必正,如為己之私欲,則三年民必疲憊而終忿矣。\n象曰:終日戒,有所疑也。 # 當既濟之時,必疑其患之將至,其戒懼之心如此,謹慎如此。\n時,方可得易之神明矣。\n象曰:東鄰殺牛,不如西鄰之時 也,實受其福,吉大來也。 # 既濟之時,能得時者,即令薄祭,亦可有大吉之福至,此言時之可貴也。\n象曰:濡其首厲,何可久也。 # 藭盡之須以水淋其首,其必不可長久也。\n"},{"id":37,"href":"/zh/docs/culture/%E5%A4%A9%E7%BA%AA/%E4%BA%BA%E9%97%B4%E9%81%93/%E4%B8%8B%E7%BB%8F/62%E9%9B%B7%E5%B1%B1%E5%B0%8F%E9%81%8E/","title":"62雷山小過","section":"下经","content":" 62雷山小過 # 此卦漢君有難,卜得之,後果脱險也。\n圖中:\n明月當空,林下一人彈冠,人在網中。一人割網,猴子在山頭出。\n飛鳥遺音之課 上逆下順之象\n人之有信後必行,行則生過,故小過所以為中孚序也。此卦山上有雷,雷震於高處,其聲必過,故為小過。小過者,可說為小事過,亦可説為過之小也。\n卦圖象解\n一、明月當空:政清狀,入法庭,刑堂。二、林下一人:林姓人。\n三、人陷網中:不得脱身,受束縛。\n四、一人彈冠:求去之象,但求去為形,束縛為神,合則有表裡不一狀。五、一人割網:貴人解救,脱困狀。\n六、猴子在山頭:從新開始之課(候再出山)。\n人間道\n小過:亨,利,貞。\n過之意在過其正常也。如矯枉過正,此過為就正意。時不正,用過之道使正,此過之吉處, 吾人須知過之道,知用過之時機方如是。\n可小事,不可大事,飛鳥遺之音,不宜上,宜下,大吉。 # 用過之道,乃為求中正。其過之限,可用小事,於大事不可用過。故曰不宜上,宜下,順之則吉,乃過能順理,吉反更大也。\n彖曰:小過,小者過而亨也。過以利貞,與時行也。 # 小過之道,在過之小,有時當過,過而能致亨,過之能吉矣。但用過之道,須配合時機, 乃意當過則過,此非真過矣,此為以過養正也。\n柔得中,是以小事吉也,剛失位而不中,是以不可大事也,有飛鳥之象焉。 # 小過之道,處小事之過,可得吉。但不可做大事,因剛失位又不得中,故不可以用在大事上,故有飛鳥之象,鳥飛之事小,過而餘音,但無災也。\n飛鳥遺之音,不宜上宜下,大吉,上逆而下順也。 # 事之有過,當從其宜。如人之過恭,過哀,過儉,其太過,則不可。其過當如飛鳥之遺音,\n其聲出,而身己過,事之過當如是,則吉宜。此順道之過,故也。\n象曰:山上有雷,小過。君子以行過乎恭,喪過乎哀,用過乎儉。 # 雷震於山上,其聲至,而雷已過,故為小過,君子觀象,知天下事,有時當過,但不可過甚。故為小過,當過而過,乃其宜也。\n初六:飛鳥以凶。 # 陰柔居下位,為小人之象,小人本躁易動, 今逢小過時,乃得理不饒人,其行為之過。必速且遠,救之莫及也,故凶。\n六二:過其祖,遇其妣,不及其君, 遇其臣,无咎。 # 二與五爻位,其猶祖之象,今陰柔居其位, 越過三,四位,直與五相應,有越位之戒,今逢該過之時,如過越位,仍不失臣道,亦可無咎。於其他之卦,越過本位凶,但今於小過, 乃意當過之時,可過,無災,切忌失君臣之道,\n九三:弗過防之,從或戕之,凶。 # 陽居正位,於小過之時,意味手下無能, 又為陰之小人蒙蔽。此時須過防之。如不加強防範,必招害。君子防小人之道,以正己為先, 堅守正道也。\n九四:无咎,弗過遇之,往厲必戒, 勿用永貞。 # 剛居柔位,以剛而用柔,其剛必不過也。故無災咎。剛陽之道居小過時,須戒之隨宜, 不可固守不變,故君子隨時順處,不固守其常也。\n六五:密雲不雨,自我西郊,公弋取彼在穴。 # 此陰柔居君位,居當過之時,乃不夠陽剛, 故雖欲過,但不能成功。故猶如密雲而不成雨, 其不成功,即令越過其位向二爻相應,三、四爻位不應,亦如同密雲集中,而無法成雨\n上六:弗遇過之,飛鳥離之,凶, 是謂災眚。 # 六居小過之極,居過之極,不顧正理,必違常道,遠矣,故凶,必招人為災害也,此於天理人事皆同。\n象曰:飛鳥以凶,不可如何也。 # 過之疾,如鳥之速,必救之不及,無有作為也。\n及過之太過,反凶。\n象曰:不及其君,臣不可過也。 # 下臣遇须過之時,可過越其上位,但不可至君位,至君前,則太過,過一爻可無咎。\n象曰:從或戕之,凶如何也。 # 小人道盛,必害於君子。當防而不防,必傷矣。\n象曰:弗過遇之,位不當也,往厲必戒,終不可長也。 # 陽剛居柔位,不當位時,於當過之時,能不過剛反柔,乃得其宜,於自保可也,但終不可成長至盛,故往則有危,須戒之。\n象曰:密雲不雨,已上也。 # 陽下陰上,合則成雨,今陰已在上,即令雲再密,無陽而不成雨,終無功也。\n象曰:弗遇過之,已亢也。 # 居過之終,失正理之甚,乃過至亢極,故招凶致。\n"},{"id":38,"href":"/zh/docs/culture/%E5%A4%A9%E7%BA%AA/%E4%BA%BA%E9%97%B4%E9%81%93/%E4%B8%8B%E7%BB%8F/61%E9%A2%A8%E6%BE%A4%E4%B8%AD%E5%AD%9A/","title":"61風澤中孚","section":"下经","content":" 61風澤中孚 # 此卦辛君屯邊,卜得之,遂果得梅妃之信也。\n圖中:\n望子上文書,人擊柝,貴人用繩牽鹿雁啣書。\n鶴鳴子和之課 事有定期之象\n人有節,而後有信,故中孚次之。節本有節制,不可過越之意,人能信而後行之,上位知信守,下位知信從。此卦澤上有風,風行于澤上能感於水為中孚之象,此因一 、二爻位,五六爻位,皆實陽,而三、四爻位,為中虛之象,全卦有外實中虛之義,此中孚之象。\n卦圖象解\n一、望子上文書:科甲高中,刑法更吉。二 、人擊柝:預警也。\n三、貴人用繩牽鹿:守成,不可攻也。四、雁啣書:喜事將至,有南徙之象。\n五、人立庭中:蒞庭也,防官司或打官司保身也。\n人間道\n中孚:豚魚吉,利涉大川,利貞。\n中孚之道,其中心之誠信,能使豚魚都有感,則無所不至矣。故利涉大川,週行無限也\n彖曰:中孚,柔在内而剛得中。 # 中孚之中虛乃至誠之象。此示意剛之道能得中正,故吉。\n説而巽,孚乃化邦也。豚魚吉,信及豚魚也。 # 上位以至誠而順從於下,下位以至誠以求上悦,由其中孚之至誠,必教化邦國,此信能令豚魚皆感,此道之至善也。\n利涉大川,乘木舟虛也。中孚以利貞,乃應乎天也。 # 中孚道之吉,猶乘舟渡川,內無實物,不虞覆船,即處艱困,能以中孚則必可亨通,能堅守中孚之道,此天道之極至也。\n象曰:澤上有風,中孚,君子以議獄緩死。 # 風在澤上,澤有感于風,因水體本虛,故風能入。君子觀之,知人心虛,則物必感之,此中孚之象,君子于議獄,本為盡忠而已。於決死之際,但求緩之,寬之。\n初九:虞吉。有它,不燕。 # 陽剛居始進,於中孚之道並非了解,人初志必不定,故如能以愚誠信之,且專一之志, 若生異志,必不得安矣。\n九二:鳴鶴在陰,其子和之,我有好爵,吾與爾靡之。 # 內剛外柔,陽居陰位,中孚之感,因外柔必能感通,猶鶴鳴於幽谷,人不聞也,而其子必應和,乃心感通也。故能中心孚誠,千里能\n六三:得敵,或鼓或罷,或泣或歌。 # 柔居陽剛之位,居位高但才不能濟,故勢必唯所信而從,其外在之鼓張,罷廢悲泣,歡歌皆因導於內心之所信,因才不足,故只有信,\n六四:月幾望,馬匹亡,无咎。 # 近君之位,以柔順得信,其盛必如月之滿盈。即同類同屬亡失,亦可無咎。\n九五:有孚孿如,无咎。 # 君位之人,以中孚至誠之道,感通天下, 民心之信服,固結如抽掣也。\n上九:翰音登于天,貞凶。 # 中誠孚信致於極,有信終則衰,華美其外, 內無忠篤,猶翰音登天,不知止之,貞固如此, 不知變,乃自招凶。孔子曰:好信不好學,此敝也贼。意即固守而不通也。\n象曰:初九虞吉,志未變也。 # 始信之時,志未能從,但能愚誠專一至信, 則吉矣,故吾人初始,必求能為己所信之道, 方以愚誠,如此方不致迷。\n感之,能化邦國。\n其子和之,中心願也。 # 子能與合,乃中心誠意能通故也。\n而不知吉凶,此非長才之君。\n象曰:鼓鼓或罷,位不當也。 # 居不適位,無所心主,惟能從於所信而已。\n象曰:馬匹亡,絕類上也。 # 求上之孚信,即令同類亡,亦不顧而求, 此所以吉也。\n象曰:有孚孿如,位正當也。 # 五居君位以陽剛,用中正之道,天下民心固結信服,其稱位如此,君之道乃能致極矣。\n象曰:翰音登于天,何可長也? # 守誠信至窮極而不知變,必不能長久?此固守不知變之戒,招凶至矣。\n"},{"id":39,"href":"/zh/docs/culture/%E5%A4%A9%E7%BA%AA/%E4%BA%BA%E9%97%B4%E9%81%93/%E4%B8%8B%E7%BB%8F/60%E6%B0%B4%E6%BE%A4%E7%AF%80/","title":"60水澤節","section":"下经","content":" 60水澤節 # 此卦是孟姜女送寒衣,卜得之,知夫落亡不吉之兆。\n圖中:\n大雨下,火中魚躍出,雞在屋上,犬在井中,屋門開著。\n船行風黃之課 寒暑有節之象\n渙者散也,物不可以終散,必當節止,故渙之後以節卦為序。此卦「澤」上有「水」,澤為有限之水地,如再置水,當滿不容,故有節制之象。\n卦圖象解\n一、大雨下:陰暗不明,凶象。\n二、火中魚躍:飛騰而徒勞無功,又餐飲業。三、雞在屋上:酉年肖雞人,解救之時與人。\n四、犬在井中:招陷,動彈不得,不知節制而招如此。五、屋門開著:仍可開張,须雞人來助大吉。\n人間道\n節:亨,苦節,不可貞。\n節之道,在有制,萬事如知節,必可亨。節之貴在中庸,如限制太過則苦,如節至於苦, 必不可常久也,人事如此。\n彖曰:節亨,剛柔分,而剛得中。 # 節之道能致亨通,乃因知剛柔並濟,能剛又且能適中,此節之能亨。\n苦節不可貞,其道窮也。 # 如節之致於苦極,必無法堅固守志,則節道必招困窮也。\n説以行險,當位以節,中正以通。 # 此以卦才言,外險內悦,以能悦且又知止,此為節之大義,常人於悦時不知止,遇艱險方思止。當位居尊之人如明節之道,必能中正且亨通矣。今人之權力欲望無盡,居尊不思止,悦而無限,終必至凶,乃不知節之道,明矣。\n天地節而四時成,節以制度,不傷財,不害民。 # 天地之間有節道故四季分明,聖人觀之知立制度,以為節道,所以必不傷財害民,此法治觀念之始,聖人立此道,即因知人之欲望無限,故以節制之,免流於人治,必因私欲,而終致勞民傷財。\n象曰:澤上有水,節,君子以制數度,議德行。 # 澤之容水有限,故節。君子觀之,知節以制度來限制,下定義來區分君子小人之行為。\n初九:不出户庭,无咎。 # 陽剛之性居節之初,必不能節,如居門庭之內,則可無咎。\n九二:不出門庭,凶。 # 剛陽之性,居陰柔之位,爻義為,處陰且不正之人居當節之時,不知節必合於中道,過與不及皆非節,如此即令不出門庭,亦會招凶。\n六三:不節若,則嗟若,无咎。 # 三爻本剛,今陰柔居之,須剛斷而柔性之人居其位,因其柔順,且知自節乃可順於義行, 亦可以無過。\n六四:安節,亨。 # 陰居陰位,其正位,於節時,即居高位且有節之象,能安於此,則亨通。\n九五:節,吉,往有尚。 # 九五尊位,乃居節時之主位,能甘之如飴, 盡節之道,必吉,功大矣。\n上六:苦節,貞凶,悔亡。 # 上六乃居節之極,其必因節致苦,如堅守不改則必凶,終致亡而悔,易之節卦悔亡,與他卦之悔亡,辭同但意不同也。\n象曰:不出户庭,知通塞也。 # 不出户庭可以無咎,但須知外之言與行, 必以時來定進退之機。\n象曰:不出門庭凶,失時極也。 # 時之義在易中最為重要,不知節之義,又居節之時,失其時,因不合中道之節,過與不及皆不對,即令不出門庭,亦有凶矣。\n象曰:不節之嗟,又誰咎也? # 知節可以免過失,故不知自節而招禍,又能怪誰呢?\n象曰:安節之亨,承上道也。 # 能行節之中道,且安居於此,必亨通義。\n象曰:甘節之吉,居位中也。 # 君王能知節道,用節且甘如飴,必成大功, 因其居尊位故也。\n象曰:苦節貞凶,其道窮也。 # 因節致苦,不知變,失易之神,其凶乃因道之窮盡。\n"},{"id":40,"href":"/zh/docs/culture/%E5%A4%A9%E7%BA%AA/%E4%BA%BA%E9%97%B4%E9%81%93/%E4%B8%8B%E7%BB%8F/59%E9%A2%A8%E6%B0%B4%E6%B8%99/","title":"59風水渙","section":"下经","content":" 59風水渙 # 此卦漢武帝,卜得之,乃知李夫還魂也。\n圖中:\n山上有寺,一僧,一人隨後,一鬼在後,金甲人。\n順水行舟之課 大風吹物之象\n兌,悦也,人悦時,則必舒散,渙、散也,故渙為兌之序。人之性憂則結聚,悦則舒散。此卦上巽下水,乃風行水上,水遇風則渙散,故渙為散也。\n卦圖象解\n一、山上有寺:出家狀,對峙狀,寸土寸金象。二、僧:化外之人,光頭人,曾姓人。\n三、一人隨後:逃避,求助化外之人。\n四、一鬼在後:為禍追緝之象,內心有鬼,處事不明象。五、金甲人:正義之師,得民心也。\n六、寺,土頭人作對。等待時機也。\n人間道\n渙:亨。王假有廟,利涉大川,利貞。\n渙,散也。人會離散,本於中心一念,心離則散矣。故能治散,必從中入手,有能收拾人心,散可聚矣,故散之道論如何用散,故可以亨。君王能知立宗廟收人心,則必可前進無阻, 故須堅心到底。\n彖曰:渙,亨。剛來而不窮,柔得位乎外而上同。 # 渙之道所以可致亨,以卦來言,用陽剛之法,不可致極剛,以居下位,又得中道,柔位而得五君位之相應,故居渙時,能守其中,必不至於離散,所以能亨。\n王假有廟,王乃在中也。利涉大川,乘木有功也。 # 君王能利用宗廟收拾人心,乃知用中道之妙,能如此可往天下,無處不阻也,自古以來, 能得民者,必得其心,方可謂得民矣。\n象曰:風行水上,渙。先王以享于帝立廟。 # 風行水上,渙散之象。先王觀渙之象知,救天下之散,惟有祭祀宗廟,收合人心,合心之道,莫大於此。\n初六:用拯馬壯,吉。 # 初爻,為渙之始,陰柔居不正,又處卑下, 故於始時即察知而拯,只須託於陽剛之人,即可整渙,故吉。\n九二:渙奔其機,悔亡。 # 外飾順,內實險憂,心已散。九二居坎險中位,乃意於渙散時,又居險中,其險可知, 如能知機而奔往不猶豫,方可不慮亡也。\n六三:渙其躬,无悔。 # 六三相位,今才為陰質,不適居位,於渙散之時,必無法拯救他人,只能止於其身,可以無悔矣。\n六四:渙其群,元吉。渙有丘,匪夷所思。 # 六四乃大臣之位,今有九五君來同應,有君臣合力,以濟天下之渙散,能如此則有大功。方渙散之時,用剛則必不能使之懷附,用柔又不足使其依歸,故如能使大聚,此事必難,用必非常法,能成此大功,非聖賢何能如是乎?\n九五:渙汗其大號,渙王居,无咎。 # 九五君位,居渙之時,以陽剛正德又得巽之外順,此深得處渙之道,必能號令人民,民心信服而從矣,如汗之於體外,息息相關,民與君之關係能如此,則必能居王位而無咎。\n上九:渙其血,去逖出,无咎。 # 渙至極時,仍能守巽順之道,即令有傷, 亦仍可出險,遠離災害而無災矣。\n象曰:初六之吉,順也。 # 初六之能吉,乃因於渙始即察知,始而拯, 此得時也故吉。\n象曰:渙本汁其機,得願也。 # 渙之時居險,必以知機而親近之,乃可得願矣。\n象曰:渙其躬,志在外也。 # 渙時,以躬順求上同。可免己之災。\n象曰:渙其群,元吉,光大也。 # 元吉謂大功德也,君臣合力於渙時,能群聚民眾,其功可光大也。\n象曰:王居无咎,正位也。 # 君位尊,而其才德又適其位,稱王可無咎, 因合於正位。\n象曰:渙其血,遠害也。 # 渙散至極,即令血出傷害。以堅守巽順之道,必遠離禍害也。\n"},{"id":41,"href":"/zh/docs/culture/%E5%A4%A9%E7%BA%AA/%E4%BA%BA%E9%97%B4%E9%81%93/%E4%B8%8B%E7%BB%8F/58%E5%85%8C%E7%88%B2%E6%BE%A4/","title":"58兌爲澤","section":"下经","content":" 58兌爲澤 # 此卦唐三藏去西天取經,卜得之,乃知必歸唐國。\n圖中:\n人坐看一堆擔,月在天邊,秀才登梯, 一女在合邊立,文字上箭。\n江湖養物之課 天峰雨澤之象\n巽者,入也,物能相入,必有相悦方成,故兌為巽之序。\n卦圖象解\n一、人坐看一擔:任務完成狀,有人相助象,助人為樂之貴人。二、月在天邊:清明之治。\n三、秀才登梯:金榜登科象。\n四、一女在合邊立:女人介入,先成後破。五、文字上箭:得機而發,射:發象。\n人間道\n兌:亨,利、貞。\n兌之道,可以致亨。人能悦於物,物亦相悦,必足以亨。然兌之道在貞正,求悦不以正道, 必成邪吝,終致悔咎。\n彖曰:兌,説也,剛中而柔外,説以利貞,是以順乎天而應乎人,説以先民,民忘其勞,説以犯難,民忘其死,説之大,民勸矣哉。 # 兑,悦也。外柔內剛,中心誠實之象,悦之道可亨,乃因其能貞正。上順天理,下應人心, 能使民以悦,則民忘勞苦,且欣然為國犯難。忘其生死,此悦之道至極矣,民莫不信之。\n象曰:麗澤,兌,君子以朋友講習。 # 兩兌相重,即兩澤互麗,交相浸潤,互有滋益之象,君子觀之乃知朋友講習,互相增益, 為天下之大悦,有互相明益之象。\n初九:和兌,吉。 # 初雖陽剛,但因居卑下,乃知卑下和順, 此悦必無所偏私,此兌之正道,故吉。\n九二:孚兌,吉,悔亡。 # 二位有剛中之德,內實孚信,雖近小人, 但不失君子之道,悦而不失剛中之德,所以能吉而不慮亡矣。\n象曰:和兌之吉,行未疑也。 # 初位必隨時順處,心無所欲,惟求能和而已,是以吉也。其行必未有可疑。\n象曰:孚兌之吉,信志也。 # 君子之悦,自心中之至誠,故必不失道, 小人之悦,必忘形而自失不知。\n六三:來兌,凶。 # 陰棄居陽剛之位,不適也,比得兌之道不以中正,為悦而求悦,人之有求必因私欲,己離正道,故凶。\n九四:商兌未寧,介疾有喜。 # 陽剛處陰位,居不中正,故有未決,未能定也。居兑之時,不為悦所惑,能知命守剛正, 疾惡去仇,必能得君之信,而有喜也。\n九五:孚于剝,有厲。 # 九五君位得中正之道,居悦時,乃可盡善, 但聖人復設戒於有厲,即雖中正聖賢在上,天下仍有小人,為免惑於悦,小人之入而不自知,\n上六:引兌 # 悦之至極則愈悦,故言引兌。天下萬事過之皆不宜,有凶至。\n象曰:來兌之凶,位不當也。 # 柔居陽剛,自處必不中正,和欲而求悦, 必招凶。\n象曰:九四之喜,有慶也。 # 九四君側之人能有喜慶,必来自君之信孚,得遂行其陽剛之志也。\n故於此設戒。\n象曰:孚于剝,位正當也。 # 居悦知剝之戒,人於事始知戒,可以無災咎也。\n象曰:上六引兌,未光也。 # 悦之至極,已太過於事理,其果必不能更光也。\n"},{"id":42,"href":"/zh/docs/culture/%E5%A4%A9%E7%BA%AA/%E4%BA%BA%E9%97%B4%E9%81%93/%E4%B8%8B%E7%BB%8F/57%E5%B7%BD%E7%82%BA%E9%A2%A8/","title":"57巽為風","section":"下经","content":" 57巽為風 # 此卦范蠡辭官入湖,卜得之,乃知越國不久也。\n圖中:\n貴人賜衣,一人跪受,雲中雁傳書, 人在虎下坐。一人射虎中箭,虎走。\n風行草偃之課 上行下放之象\n巽者,入也。處旅時,親人不在,惟能巽順,方可平安,無所不入。故巽次旅也。此卦一陰居二陽之下,乃有陰順於陽象。\n一、貴人賜衣:先破後成象。\n卦圖象解\n二、 一人跪受:貴綬也。如占疾病,則主壽衣,凶象。三、雲中雁傳書:意外喜訊。\n四、人在虎下坐:身處險而不知。五、一人射虎中箭:貴人相救。 六、虎走:脱險,寅年也。\n又虎乃有威望之人,利武官,主人在虎邊進退不得之時。亦言虎將須知功成身退,否則暗箭難防,以明哲保身為吉。\n人間道\n巽:小亨。利有攸往,利見大人。\n巽順之道,得之可以小亨。故論柔之順性,能如此,利往進,可見貴人,必助。\n彖曰:重巽以申命。剛巽乎中正而志行,柔皆順乎剛,是以小亨。利有攸往,利見大人。\n巽之重卦,有上順下亦順象,苟能上順中道以出命,下順命而服從,則必吉。故君子申復命令,乃知巽也。能知順乎剛且中正,即令才不足亦可以小亨。能得巽順之道,必無往不入可出世見大人也。\n象曰:隨風,巽。君子以申命行事。 # 物之隨風而動,巽之道也,君子觀巽象,乃知重復申令之重要,能有政令,上隨下以順服, 上下皆順,即重巽之象,為始切實,故重申命令。\n初六:進退,利武人之貞。 # 居巽順之時,又處卑下,以陰柔之質,必畏而不安,無所適從,故此時惟利於武官,從武職之人,其巽赈必吉也。\n九二:巽在床下,用史巫紛若,吉, 无咎。 # 九二乃示剛居陰位,外又有巽順之象,意即人有過於卑順之時,不是恐懼就是諂媚,皆非正也。但其雖非正禮,亦可遠恥辱,去\n怨隙,亦可為吉,就如同用誠意來通於神明之史巫,其誠意能通,亦可無過。\n九三:頻巽,吝。 # 九三為下卦之上極,剛居之,有剛亢之質, 居巽順之時,非真能順,乃出於勉強為之,必有所失,失後又頻順,頻順又再失,亦可卑吝也。\n六四:悔亡,田獲三品。 # 六四僅乃陰柔居陰,此位居下之上,乃居上位而知順下,人能如此善處,必可不慮亡。猶田之收獲能遍及上下,能如此,何慮悔亡更且有功。\n九五:貞吉,悔亡无不利,无初有終,先庚三日,後庚三日,吉。 # 陽居陽位又處君位,此為巽之主,其命令, 必合於中正之道,天下黎民莫不順從。能始終如此,必無往不利,如命令之出,有須變更時, 改始之不善,成終之善,則可變更,其中道,\n上九:巽在床下,喪其資斧,貞凶。 # 床,人之所安處也。今在床下,有過於安之義,九為巽極,乃過於柔順,乃喪失剛斷, 必失居所,乃自失也,對正道而言為凶。\n象曰:進退志疑也,利武人之貞, 志治也。 # 進退不知所安,其志必疑且懼,故堅心服從,利於武職之人,不生貳心。\n象曰:紛若之吉,得中也。 # 人能得中道,亦以至誠,則人必信之,吉而無咎。\n象曰:頻巽之吝,志窮也。 # 陽剛之才,本非能順巽,今處重巽之時, 因勢不能遂志,又必須以順,故必有所失,其志必困窮。\n象曰:田獲三品,有功也。 # 巽能通於上下,如田之獲,上下受惠,此巽順之功。\n必始終如一方可。\n象曰:九五之吉,位正中也。 # 九五之尊,能處之吉,乃因能得中正之道, 過與不及皆不善。\n象曰:巽在床下,上窮也,喪其資斧,正乎凶也。 # 居上之極本為陽剛,今卻過於巽順,必失正道,為凶道。\n"},{"id":43,"href":"/zh/docs/culture/%E5%A4%A9%E7%BA%AA/%E4%BA%BA%E9%97%B4%E9%81%93/%E4%B8%8B%E7%BB%8F/56%E7%81%AB%E5%B1%B1%E6%97%85/","title":"56火山旅","section":"下经","content":" 56火山旅 # 此卦陳後主得張麗華,卜得之,乃知先喜後悲。\n圖中:\n三星者,貴人台上垂釣牽水畔人,一猴一羊,大溪者。\n始鳥焚巢之課 歡極哀生之象\n豐至極時,不知戒盛,乃失其居,故序卦為旅。此卦「離」上「艮」下乃外明內止之象, 山為止而不動,火行而不居,有不與同流之意。故為旅象,又人之旅,必外麗,亦旅象。\n卦圖象解\n一、三星者:三台貴人,相位之人。\n二、貴人台上垂釣:君主相求,老闆相求人才。\n三、牽水畔人:賢能之人,離野入朝封候。或慕權勢而變志。四、一猴一羊:肖猴,肖羊,候、楊姓人,未申年應之。\n五、大溪:脱險也,港口也,水側也。\n人間道\n旅:小亨、旅貞吉。\n旅之時,此因不同流而旅,故有小亨,且得之堅心,必吉。切不可假旅之道而有私欲其中。\n彖曰:旅,小亨,柔得中乎外而順乎剛,止而麗乎明,是以小亨,旅貞吉也。旅之時義大矣哉! # 旅之時至而旅,乃有亨,人能知所進退,內有中道外又知順於剛,止之在能外麗而內明, 是故有小亨,旅而堅貞其志,是故吉也。當旅不旅乃自求咎。故天下之事,當有時宜,因時而動,其義大也。\n象曰:山上有火,旅。君子以明慎用刑,而不留獄。 # 火在山高處,其明及遠,旅之道也。君子觀此明照之象,知明慎以用刑,絶不依持己之明, 必有戒慎恐懼之心而施於用刑。因火行不留,故有不留獄之志,獄乃不得已而設,故不求留獄, 此觀火之行而體悟之。\n初六:旅瑣瑣,斯其所取災。 # 陰柔無才之人,居卑下,居旅之時,因才質如此,故必畏畏瑣瑣,其必終自取其辱。\n象曰:旅瑣瑣,志窮災也。 # 意志因困窮時,而生變,乃自取其災也。\n六二:旅即次,懷其資,得童僕貞。 # 六二乃得適位之將才居旅時,因知中正處不失當,必能懷蓄財資,又能得僕人之忠心, 故吉。\n九三:旅焚其次,喪其童僕,貞厲。 # 旅之時,必以柔順謙下為吉,如今自處過剛,又居高,乃招致災困。必失僕人之忠,終有危厲來臨。\n九四:旅于處,得其資斧,我心不快。 # 陽剛又居柔位,有用柔之象。人居旅時, 能柔,得旅之道,必吉。此法居旅時可得財货之資助,但不能伸其大志,其心必不快。\n六五:射雉,一矢亡,終以譽命。 # 人於旅時,不可有錯,一但犯之,災禍立至。就如射雉,能一箭而中,不虛發即無過失, 能如此,則於旅時,必可立名揚譽。\n上九:鳥焚其巢,旅人先笑後號 # 咷,喪牛于易,凶。\n旅時以過陽剛自處以高,不知謙卑,就如鳥高飛而自焚其巢,故終居無所安處,自負過剛,居旅時,始則可快意盡情,故生笑。繼而失安無居所,故後號咷。牛為順性之物,但也\n象曰:得童僕貞,終无尤也。 # 旅行之人,所賴者為童僕,能得其忠心, 必可無災。\n象曰:旅焚其次,亦以傷矣,以旅與下,其義喪也。 # 居旅時以過剛自高,手下必喪失忠心,危之至矣。\n象曰:旅于處,未得位也,得其資斧,必未快也。 # 以剛居柔,合於旅之道,故旅時為善,但欲得行其志,卻不能也。心必不快。\n象曰:終以譽命,上逮也。 # 旅之時,能令上下皆應,則可吉。其因旅之時乃困而未得其安之時為旅。\n易於喪失,只一疏忽而己。故凶。\n象曰:以旅在上,其義焚也。喪牛于易,終莫之聞也。 # 旅時以居高自處,必不能保安,猶鳥之焚巢,喪失順德極易,待凶至,乃不知自聞知也。\n"},{"id":44,"href":"/zh/docs/culture/%E5%A4%A9%E7%BA%AA/%E4%BA%BA%E9%97%B4%E9%81%93/%E4%B8%8B%E7%BB%8F/55%E9%9B%B7%E7%81%AB%E8%B1%90/","title":"55雷火豐","section":"下经","content":" 55雷火豐 # 此卦莊周説劍,臨行卜得之,果得劍也。\n圖中:\n竹简灰起,龍蛇交錯者,官人著衣裳立,一合子,人吹笙竿,腳踏虎。\n日麗中天之課 藏暗向明之象\n萬物與人能適得所歸,必能成大,故歸妹之後,受之以豐,豐有盛大之義,此卦上震下離, 有內明外動之象,能以明而動,動而能明,此為致豐之道,是故明而能照,動而能亨,可以致豐大也。\n卦圖象解\n一、竹简灰起:簡姓,竹有順象,堅節不變之象,灰起,中空之象。二、龍蛇交錯:辰巳之年,正邪相爭,蛇虺乃毒謀暗算也。\n三、官人著衣裳立:官人、倌人、丈夫也,藏於內也。四、一合子:先成後破。\n五、人吹笙竿:閒情逸志,主喜事臨門。六、腳踏虎:臨危不亂也,險在下也。 七、旱沼之旁:心力交瘁也。\n八、池中無水:旱象,破財也。九、珠落盤中:先聚後散也。\n人間道\n豐:亨,王假之,勿憂,宣日中。\n豐之道,在能致亨通,能令天下百姓致豐,唯王者能。豐之道在令人民繁盛,事物豐發, 有如日中之明,無所不照,則可以無憂。\n彖曰:豐,大也,明以動,故豐。王假之,尚大也。 # 豐之道,在論如何盛大,能明而動,必能成豐盛且大也。王者所追求之,為能及天下之大, 致人民以豐,必可永保其治世之道。\n勿憂宜日中,宜照天下也。 # 致豐之道,能如日中之盛明,普照天下,無所不及,如此則可無憂,而能保其豐大及永久也。\n日中則昃,月盈則食,天地盈虛,與時消息,而況於人乎?況於鬼神乎? # 日正當中,也有暗時,月滿之後亦有缺虧,天地之盈虛,靠時為消息,更何况乎人?何沉\n乎鬼神?此君子戒之在盛,知盛時须戒,方可長久。\n象曰:雷電皆至,豐,君子以折獄致刑。 # 電雷並行,乃明動相濟,成豐之象,君子觀明動之象,知用刑制法,在求明與威並行,君子能明則無折獄,慎於用刑。能立威則民服無怨。\n初九:遇其配主,雖旬无咎,往有尚。 # 初進之陽剛,居豐之時,知往有豐,雖非有君之對應,但有其左右高位之人能與同志, 此往可吉也,所謂「同舟能共濟也」,此於豐之時,可得無咎也。\n六二:豐其蘚,日中見斗,往得疑疾,有孚發若,吉 。 # 六二乃柔將其位,又居明卦之中,為至明之才,但因所遇之君不明,而無法下求於己, 若居豐時,意往而求其君能明己才,必招致猜妒疑惑。惟有盡己之至誠,以求其感而能發,\n九三:豐其沛,日中見沫,折其右肱,无咎。 # 九三乃居相位有賢才之人,於豐時,卻不見明主之相應,其日之晦更甚於六二將位,故有如人之折肱無法於行,賢能之人有才,但無法發揮乃因君之不明,故無可歸咎也。\n九四:豐其薜,日中見斗,遇其夷主,吉。 # 九四為陽剛之人居君側大臣之位,遇君不明,其賢能陽剛受圍,如日中有缺晦,不得其用,亦無用也,故君子之才須得遇明主,方可有用能成濟世之功。\n六五:來章,有慶譽,吉。 # 陰柔居君之尊位,己之才不足,但知用下位章美之才,必有福慶,且有美譽故吉。\n上六:豐其屋,蔀其家,窺其户, 闋其無人,三歲不覿,凶。 # 上六居丰極之時,因處動之終,必燥動, 人於丰盛之時,必假謙虛方吉。如不知戒盛, 外丰其居,內不明,暗藏其家,又目中無人, 三年又不知變,一意孤行,终自招凶。\n象曰:雖旬无咎,過旬災也。 # 聖人知時之所至,順時而為,能知降己以相求,若懷己之私意,必招患至。\n則君之昏蒙可開,如此則吉矣。\n象曰:有孚發君,信以發志也。 # 用己之至誠孚實,以求發上之知信,如可成,則吉必至矣。\n象曰:豐其沛,不可大事也,折其右肱,終不可用也。 # 豐之時,不見上用,無法成大事,猶人折其肱,終無法受重用。\n象曰:豐其蔀,位不當也。日中見斗,幽不明也。遇其夷主,吉行也。 # 有才能卻受圍困,無法致豐,乃位不適當也。豐之時,又有幽暗之處必因君不明,臣位又不適當而造成。惟求遇明主,吉乃可行。\n象曰:六五之吉,有慶也。 # 君位能吉,於豐之時,必可有吉慶及於天下。此因君能用才也。\n象曰:豐其屋,天際翔也,窺其户, 闃其无人,自藏也。 # 人處豐之極,不知謙卑,目中無人,猶鳥之翔於天際,不知己才之不足,居高自傲,必招人棄絶,其致如此,乃不知自藏也。\n"},{"id":45,"href":"/zh/docs/culture/%E5%A4%A9%E7%BA%AA/%E4%BA%BA%E9%97%B4%E9%81%93/%E4%B8%8B%E7%BB%8F/54%E9%9B%B7%E6%BE%A4%E6%AD%B8%E5%A6%B9/","title":"54雷澤歸妹","section":"下经","content":" 54雷澤歸妹 # 此卦舜娶堯二女,卜得之,乃知卑幼不寧也。\n圖中:\n官人騎鹿指雲,小鹿子在後,望竿上有文字,人落刺中,一人拔出。\n浮雲蔽日之課 陰陽不交之象\n渐乃進也,人進必有所歸,所以歸妹次渐也,少女,人所悦也,雷為動,今如人之動以悦, 此因悦而動,必有所不正,此卦澤上有雷,雷震而澤動,相從之象,男動在外而女從之在內, 有女嫁歸男之象。人之動须以明,明而後動,方不失正。\n卦圖象解\n一、官人騎鹿指雲:浮雲蔽日,乘亂而欲進。二、小鹿子在後:生意人,隨從狀。\n三、望竿上有文字:旦夕而亡,揭竿而起,正名也。四、人落刺中:如鲠在喉,去之不得。\n五、一人拔出:一人相救示寵狀。六、此卦問病凶,問財吉,問政凶。\n人間道\n歸妹,征凶,无攸利。\n此動之以悦,必有不當,如動反招凶,所往必不利。\n彖曰:歸妹,天地之大義也。 # 此言陰陽之道,男女之配,乃天地間之常理也。有男居上女在下,陰從陽動。\n天地不交而萬物不興,歸妹,人之終始也。 # 天地如不交則萬物必不生,女之從男,為生生之道,人類從男女之交而生,其終必不窮\n説以動,所歸妹也,征凶,位不當也。无攸利,柔乘剛也。 # 此動之因喜悦對方為少女,人如只因悦而動,必失明之道,動必凶至。此不以正道,而以悦道,故位不當也。男女尊卑,夫唱婦隨,人之常理,如不以常道,惟私欲作與,柔勝於剛, 所以凶也,往必不利矣。\n象曰:澤上有雷,歸妹,君子以永終知敝。 # 君子觀雷震於澤上,澤隨雷而動,猶男女相配,生生不息之象,其有永終之戒,因物久後必生敝壞,君子於始初乃知戒敝壞之理,故凡事之長久而吉,必於初始即生戒故謂永終之戒。\n初九:歸妹以娣,跛能履,征吉。 # 娣,有賢良正德之義,女之嫁歸能如此, 又知處卑順,然因其位卑,即令有賢才亦只能助夫而已,獨善其身,猶跛之能行,必無法及遠,但往乃得吉。\n九二:眇能視,利幽人之貞。 # 九二乃陽剛之賢居正位,於歸妹之時,乃意女之賢能所配不良之人,猶如目眇之人,其視必不能及遠,此時惟隱藏其賢,且堅心以正禮,可利也。\n六三:歸妹以須,反歸以娣。 # 六三乃相位居陰柔之人,於歸妹時,以悦而求上應,不以正道,故必不得其歸,無所適從。必當反歸求處卑下,則可吉矣。\n九四:歸妹愆期,遲歸有時。 # 四柔陽剛居之,即意處柔乃婦人之常道, 但內有剛明之賢,處歸妹之時,因賢明又居高位,人所願娶,但卻有遲嫁之象,非不嫁也, 乃待時而動,有佳配而後行也,此遲歸有時。\n六五:帝乙歸妹,其君之袂,不如其娣之袂良,月幾望,吉 。 # 六五居尊位之人,柔性象女,示女之高貴者,今居歸妹之期,雖為帝女,於歸時,乃得屈而謙降,故女之歸以能謙降為美德,但求於禮,不求於外飾,故猶月陰之盛,而不至於盈滿,此乃能為吉。女子尊貴之道即此。\n上六:女承筐无實,士刲羊无血, 无攸利。 # 女歸之終至極時,乃承繼祖先祭祀之職, 但至極則無實可祭,猶士人之割取羊血以祭禮,如割羊無血無以祭,必不利繼往也。\n象曰:歸妹以娣,以恆也。跛能履吉,相承也。 # 女嫁歸男,能自處卑順,且悦於內,持之以恆,即令跛者行路,亦可以有吉,其因乃其能相承相助也。\n象曰:利幽人之貞,未變常也。 # 守其隱於內之才,堅心不變,此不失夫婦常久之道也。\n象曰:歸妹以須,未當也。 # 女歸嫁男,如求己之须,無法求外應合, 必不當也。\n象曰:愆期之志,有待而行也。 # 此之延期所由皆己,不由他人,因己之有賢,故能如是。\n象曰:帝乙歸妹,不如其娣之袂良也,其位在中,以貴行也。 # 此尚禮而不尚外飾,為帝乙歸妹之道,五為柔而居尊位,乃有尊貴而又知能行中道之人也。\n象曰:上六无實,承虛筐也。 # 女歸嫁之極處,以柔居之,猶空筐之無實, 必不可以承祭祀,女終不可承繼也,必主人人離絶也。\n"},{"id":46,"href":"/zh/docs/culture/%E5%A4%A9%E7%BA%AA/%E4%BA%BA%E9%97%B4%E9%81%93/%E4%B8%8B%E7%BB%8F/53%E9%A2%A8%E5%B1%B1%E6%BC%B8/","title":"53風山漸","section":"下经","content":" 53風山漸 # 此卦齊晏子應舉,卜得之,後果為丞相也。\n圖中:\n一望竽在爽高處,一藥爐在地,一官人登梯,一枝花在地上。\n高山植木之課 積小成大之象\n艮止之後,必有所生,其生乃因前之有止,故此生必渐進,故渐為艮之序。渐,有渐進緩進之意,其進能緩,必有序,而不踰越。此卦上「巽」下「艮」,巽為木,而生於山上,此木之高乃因在山之上,故渐之道在知高之因,在知進退消息之理據。\n卦圖象解\n一、一望竿在爽高處:家人望歸象。二、 一藥爐在地:平安,無災也。\n三、一官人登梯:升官之象,衣錦還鄉也,棺也。四、一枝花在地:落第象,不久之象。\n人間道\n漸:女歸吉,利貞。\n渐之道,其有序,且緩進,吾人可於女之出嫁而見之,女歸時,以渐進有序,必吉,且始終如一,更吉。猶臣之入朝侍奉天子亦同,必當有序,如失序則為欺陵主上,必生禍害。\n彖曰:漸之進也,女歸吉也。進得位,往有功也。 # 渐乃論進之道,知進必有序,猶如女之歸夫,必吉。進時能剛柔並濟,適得其位,此進必有功也。進以正,可以正邦也。其位,剛得中也。止而巽,動不窮也。以正道而進,可以興邦正德也。故凡進皆須稟持以正道則吉。正得其位之定義,必以剛中而得,方可謂得位。內止外順,其人之進如此,方可得吉,如進之以欲,乃生燥,必失渐道,阻力乃生,故能做到內無欲, 而外和順,此進方可無窮無盡矣。\n象曰:山上有木,漸.,君子以居賢德善俗。 # 木在山上,其高有自,故君子觀渐象,乃知居賢能正德,美化風俗,其能成功,皆歸之在渐,人之錯習,遽改生反,教化之於人,勢必以渐進方可入於人心,此渐之道也。\n初六:鴻漸于干,小子厲,有言,无咎。\n鴻鳥之以時而遷動,又群聚而生且有序, 此渐也。初陰居卑下,上又無援助,君子知時, 故處之不疑。但小人及無知之人只能見眼前之事,不知患於未來,洞察事理,唯以剛而求進, 失渐矣,進則生咎,不進無災。\n象曰:小子之厲,義无咎也。 # 初始時,雖有小人之危懼不安之心,但因於義理仍合,故有無咎。\n六二:鴻漸于磐,飲食衎衎,吉。 # 柔居柔位,得中位得當,居渐之時,進必不速,穩若磐石狀,其能安居如此,故可飲食和樂,其吉必然。\n九三:鴻漸于陸,夫征不復,婦孕不育,凶,利禦寇。 # 九三陽剛居下卦之上,即將進入上卦,意言,人之將上進之時,理應守正道以得時至, 萬不可以欲而進,以遂私志,此己失渐道,猶為求進而不顧於道之夫,婦人受孕不以正,亦不可育同義,此凶即致矣。惟可堅守正道,摒除邪念,可得吉。\n六四:鴻漸于木,或得其桷,无咎。 # 此陰柔在高位,下有陽剛欲進之人,必不得安處,猶鴻鳥近於木,立木高處,必不安也, 如能立在横枝上,自處安寧,則可無災。\n九五:鴻漸于陵,婦三婦不孕,終莫之勝,吉。 # 鴻鳥所止於至高之地,象君之尊位,此渐之時至,乃知惟下二爻位,可與同心同德,居中之三,四位相隔,猶即令婦人三年不孕,亦無用於事,相隔正道之不正,其終必消,正道必合,惟時較久而已。終吉。\n上九:鴻漸于陸,其羽可用爲儀, 吉。 # 鴻能飛於天上,毫無阻礙,人之賢能如此, 進至終極,又不失渐之道,其賢能通達之能可以為表率,猶鴻之羽,可以用禮儀一樣,必吉。\n象曰:飲食衎衎,不素飽也。\n中正之人,得正位,適居之狀,能飲食安樂,心志愉快,不光是飽食而已。\n象曰:夫征不復,離群醜也,婦孕不育,失其道也,利用禦寇,順相保也。 # 夫但知征而不知復,乃因欲而失正道,叛離群類,可醜也。婦人之孕乃不正,故不可育也。皆因失其正道,唯利在堅守正道,棄絶邪惡,可因順正道而保平安也。\n象曰:或得其桷,順以巽也。 # 此意求平安自寧之道,惟有順於義,行乎正,能如此者,何處會不安呢?\n象曰:終莫之勝吉,得所願也。 # 君臣以中正之道相交,其終必合,所願必遂也。\n象曰:其羽可用爲儀,吉,不可亂也。 # 君子之進,必有以渐,有序渐循之,乃可以為吉,此進因不失序,故吉,序亂招凶。故可以為禮法而遵循之。\n"},{"id":47,"href":"/zh/docs/culture/%E5%A4%A9%E7%BA%AA/%E4%BA%BA%E9%97%B4%E9%81%93/%E4%B8%8B%E7%BB%8F/52%E8%89%AE%E7%82%BA%E5%B1%B1/","title":"52艮為山","section":"下经","content":" 52艮為山 # 此卦是漢高祖困榮陽時,卜得知,只宜守舊。\n圖中:\n猴上東北字,猴執文書,官吏執鏡,三人繩相繫縛\n游魚避網之課 積小成高之象\n震乃動也,物之動其終必止,艮,止也,故為震之序卦。此卦上下皆艮,艮為山,故有安重堅實之象,外內皆止,皆靜,此止於其所也。人能知安於止,則得艮之道也。\n卦圖象解\n一、堠上東北字:候也,停滯等候也,陳姓。二、猴執文書:候其時,待公文命令至也。三、官吏執鏡:清明之法官,政治清明。\n四、三人繩相繫縛:三人相牽連招訟事也。\n人間道\n艮其背,不獲其身,行其庭,不見其人,无咎。\n艮為止之道,人之不知止,乃私欲作為,背為不見之處,如不能見,則無欲以亂其心志人能忘我,則止矣,今人見利則欲據為己有,失止之道。知止之道,則可無咎也。\n彖曰:艮,止也,時止則止,時行則行,動靜不失其時,其道光明。 # 艮之道,言止也,動靜之間不以時,失止之義也,君子貴乎時,時可行則行,時之義於君子,則重視之,人能不失時,知所進退,道乃光明。\n艮其止,止其所也。上下敵應,不相與也。 # 艮止之道,其功在能止於適止之所,此唯聖人能成之。此卦上下二體皆為山,有因同而相敵應也,互相背而不相與也。\n是以不獲其身,行其庭不見其人,无咎也。 # 相背而不見,行不見其人,則欲無處生則能止,能止則無咎也。\n象曰:兼山,艮,君子以思不出其位。 # 上下皆山,故為兼山,乃重艮之象,君子觀艮止之道,知思安於所止,不越其本位。\n初六:艮其趾,无咎,利永貞。 # 初六居最下,乃足趾之象,趾乃人動之最先,於始動即知止,必可無咎,且須患己之性陰柔,故戒之在堅心守道。\n象曰:艮其趾 ,未失正也。 # 能止於始初,必易,乃不失正也。\n六二:艮其腓,不拯其隨,其心不快。 # 陰居陰位,得止之正道,然居二位為猶人之腿肌位,股動則肌動,二雖正道,然受三爻之影響,即中正之道,無法救助於上位時,唯勉而隨之,因言不聽道不隨,故其心不快。\n九三:艮其限,列其夤,厲薰心。 # 九三陽剛居陽位,乃意其剛極而使上下分隔,不復進退,其堅強如此,則必造成獨限一隅,而世人不相與,故無法安定其心。故止之道,貴在時宜,行止之間必以時,則吉。\n六四:艮其身,无咎。 # 居君側之人,位高權重,然於艮止之時, 當止而不能止,乃因君位過陽剛,不能信孚於臣位之人,居此際,惟自止其身,可無咎,但如臨施政,則生咎矣。意即在上位之人只能獨\n六五:艮其輔,言有序,悔亡。 # 人之所當止者,唯言與行,今陰柔居君位, 於止之時,其才不足任此位,如此則須言行一致,不可脱序,古言:君無戲言,即此,故雖己之才不足任此位,但謹言慎行,亦可無慮於敗亡。\n上九:敦艮,吉。 # 以剛陽之性居艮之終極,可見其止之道, 性之堅實如此。常人於止之時,難於持久,晚節不保等事常見,人能敦厚知止,如此堅心, 此所以吉也。\n象曰:不拯其隨,未退聽也。 # 上位之人,未能從下意,不須救助之,唯隨之可也。\n象曰:艮其限,危薰心也。 # 堅固己之剛性,不能因時知進退,必有危懼生其心內也。\n善其身,必無可取也。\n象曰:艮其身,止諸躬也。 # 居大臣之位,卻只能獨善其身,不能行止之道,乃不適其位也。\n象曰:艮其輔,以中正也。 # 君位之人能止於中道,言行不脱序於中正之道,即令無足之才能,亦可免禍故易示人之言行之重要如是。\n象曰:敦艮之吉 ,以厚終也。 # 人常於始能行止之道,至終則無法堅持到底,始終如一是易之神,艮致终能吉,乃因其能終守不變。\n"},{"id":48,"href":"/zh/docs/culture/%E5%A4%A9%E7%BA%AA/%E4%BA%BA%E9%97%B4%E9%81%93/%E4%B8%8B%E7%BB%8F/51%E9%9C%87%E7%88%B2%E9%9B%B7/","title":"51震爲雷","section":"下经","content":" 51震爲雷 # 此卦是李靜天師遇龍母借宿,替龍行雨, 卜得之,官至僕射。\n圖中:\n人在岩上立,一樹開花,一文書,一人推車上有文字,一堆錢財者。\n震驚百里之課 有聲無形之象\n鼎,器也,能主器者,必賴長子,震為長男,取其主器之義,故次鼎為震也,此卦二震相重,有奮發震驚之象,二雷相繼,重雷也。長子繼位為君也。\n卦圖象解\n一、人立岩上:特立獨行,摇摇欲墜也。危險也。\n二、一樹開花:有果也,惜乎過於短暫,少部份之人。三、一文書:消息至,公文至。\n四、一人推車:轉變之象,執行命令象。五、上有文字:得官令也。\n六、一堆錢財者:錢財散於地狀,憂心也,喪事也,求財不利,占疾病凶。\n人間道\n震:亨。震來虩虩,笑言啞啞。震驚百里,不喪匕鬯。\n震之道,能奮發,動而進,知懼而進修,皆足以亨。當震動之來,恐懼不寧不安狀,知所戒懼,則可保安,故笑言和適貌。震聲遠及百里,人無不懼而喪失,唯執宗廟祭祀之器者,不喪失不懼也。人間之至誠,莫如於祭祀時之堅心守正。\n彖曰:震亨,震來虩虩,恐致福也。笑言啞啞,後有則也。 # 震來時能知恐懼,則無患而能亨矣。此因能有戒懼反為福也,必恐而知自修,不敢違於常理法規,因震而生法度,必能致福安,故可笑言無憂。\n震驚百里,驚遠而懼邇也。出可以守宗廟社稷,以爲祭主也。 # 雷之嚮震百里,遠受驚而近受懼,人能知戒懼不妄為,則出可以長守宗廟社稷,能如是, 則必可守承國家。\n象曰:洊雷,震。君子以恐懼修省。 # 洊為重意,上下同卦故為洊雷,形容威震之大也,君子觀之乃知恐懼自修,畏天威省思已過而改之,唯君子能如是。\n初九:震來虩虩,後笑言啞啞,吉。 # 初九:居震之始,知震之來,能於始即知戒懼恐慎,不敢掉以輕心,終必安吉。\n六二:震來厲,億喪貝,躋于九陵, 勿逐,七日得。 # 六ニ乃柔居正位,善處震時之道也,雷震乃剛動而上,無人能禦其威猛,度量知其必喪全數之資,故能遠避以自守其中正,以不追隨而得,雖不能立,即防禦,但時過後,必可得\n六三:震蘇蘇,震行无眚。 # 三位為陽位,陰居之,乃不正位也。不正位之人於平日,尚且不安,更何況處於震時居\n九四:震遂泥。 # 以剛居柔,不適位而失去剛健之道,無法震奮也,如泥之滯也。\n六五:震往來厲,億无喪有事。 # 君位之人居震動之時,必不失中道,即令危亦不為凶矣。中道勝於正道,有中道之人必不違於正道,正道卻不必一定為中道。此其差異所在也。\n象曰:震來虩虩,恐致福也,笑言啞啞,後有則也。 # 雷震之來知懼進修,此因知恐而終有福吉。不違於常法,則可保平安也。\n象曰:震來厲,乘剛也。 # 震之至剛而來,如欲駕乘,乃自招危厄。\n此如能知己力之不足而求去,亦可無咎。\n象曰:震蘇蘇,位不當也。 # 處不當位,即震來而不知戒,不知恐懼也。\n象曰:震遂泥,未光也。 # 震之動必以剛為本,今動如滯泥,已失剛道,震之義,必無法光大也。\n象曰:震往來厲,危行也,其事在中,大无喪也。 # 震之動不以中,往来皆有危,其危之生, 乃因失中之道,居此時,以無喪失為至善。\n上六:震索索,視矍矍,征凶。震不于其躬,于其鄰,无咎,婚媾有言。 # 陰柔之人居震之極,驚懼至甚,無志抖索狀,其視瞻亦不能安定而明,其於此時,若動, 必招凶。乃不明而動之戒。震之戒在不及身而及於鄰時己生戒心,則終無咎,如婚事之初有爭言,則己生戒心,莫俟及於身,已不及矣。\n"},{"id":49,"href":"/zh/docs/culture/%E5%A4%A9%E7%BA%AA/%E4%BA%BA%E9%97%B4%E9%81%93/%E4%B8%8B%E7%BB%8F/50%E7%81%AB%E9%A2%A8%E9%BC%8E/","title":"50火風鼎","section":"下经","content":" 50火風鼎 # 此卦秦君卜得之,乃知得九鼎以象九州也。\n圖中:\n雲中月現,鵲南飛,一子裹席帽,一人執刀,貴人端坐無畏,一鼠。\n調和鼎鼐之課 去故取新之象\n能使物分隔,為鼎,故鼎之用在能革物,能使水火不同之物相合,易生為熟,易堅為柔, 使相合而不相害,此革物之功也,故鼎為革之序卦。此卦上火下風,乃木入火中,烹飪之象, 鼎之象即此。為器之大用。\n卦圖象解\n一、雲中月現:顯象,撥雲見月之象,清明也。二、鵲南飛:冬至來臨,北人執政。\n三九一\n三、一子裹席帽,不明之人,無知之人。果喜也。四、一人執刀:武官也,護衛將軍。\n五、貴人端坐無畏:君王,老闆等人,至公無私。 六、一鼠:子年,暗謀之人,內有陰謀,肖鼠人也。\n人間道\n鼎:元吉、亨。\n鼎之道,能使不合之物相合,故必吉亨。\n彖曰:鼎,象也。 # 鼎之為器,法自象也。古人以方代表實且正之象。兩耳對峙在上,三足分峙於下,周圓內外,皆有法而正,成安重之象。\n以木巽火,烹飪也。聖人亨以享上帝,而大亨以養聖賢。 # 用木就火,烹飪之象,鼎器人賴以生,故聖人以鼎享上帝,用大亨以享聖人。\n巽而耳目聰明,柔進而上行,得中而應乎剛,是以元亨。 # 人能如卦才,外明內順,上之頭面能中虛為明,乃耳聰目明之象,柔乃在下之物,能進上位,以明居尊,得乎中道,而陽剛之道相應,此乃元亨之因也。\n象曰:木上有火,鼎,君子以正位凝命。 # 木之上有火,生火烹飪之象為鼎,君子觀象,知法象器,形體端正且安重,以正其位也\n初六:鼎顚趾,利出否。得妾以其子,无咎。 # 初六乃最下之位,與四爻相應,今居鼎時, 有如趾之向上,顛也。鼎覆則趾顛,此非順道, 反道而行之理,利於天下敗壞之時,乃可為也。居卑下而從陽如妾,妾之從夫,則可無咎。\n九二:鼎有實,我仇有疾,不我能即,吉。 # 二位為中位,以剛居中,乃鼎中有物之實象,但於陰位,有能才而密比於陰之意,居此當求己之守正,不求於人,使之能求於我,則不正亦终就之,此吉也。\n九三:鼎耳革,其行塞,雉膏不食, 方雨虧悔,終吉。 # 九三相位,陽剛居之,乃正位,然五位之柔君不能與合,不信用之,其行必阻塞,道必不行。有才而不能得君祿命任用之時,君子必內其德,守其正道,終必吉。\n九四:鼎折足,覆公餗,其形渥, 凶。 # 四為君側之位,與初下爻相應,乃示初陰柔之小人不可用;其不勝任而敗事,猶鼎足折也。居大臣之位,所用非人,至於覆敗,此不勝任,其凶可知。成因在於任不適之才,必來\n六五:鼎黃耳,金鉉,利貞。 # 鼎之主在耳,執耳之意,有陽剛之體,又得其位,才必充實,乃能為大用,至善矣。\n上九:鼎玉鉉,大吉,无不利。 # 鼎之終,功成也,玉為剛而温,居成功之項,唯小心善處,剛柔適用,動靜不可過,乃生大吉。無所不利也。\n象曰:鼎顚趾,未悖也。利出否, 以從貴也。 # 鼎顛覆趾在上時,背道而行,只利天下敗壞之際,棄惡而從貴也。\n象曰:鼎有實,慎所之也。我仇有疾,終无尤也。 # 鼎之有實,猶人之有才,必慎擇趨向,如往之不慎,必有對立之事,於此能自求守正, 則彼必不能影響我,亦可無過矣。\n象曰:鼎耳革,失其義也。 # 有才居相位,與君之念不合,乃失其義也, 但上能明時,下之才必有能用,故吉。\n自私心作用。\n象曰:覆公餗,信如何也。 # 當大臣之人,須成天下之治,如任人之不當,以招不信,乃誤也。\n象曰:鼎黃耳,中以爲實也。 # 能執鼎耳之才,乃由其得中道也。\n象曰:玉鉉在上,剛柔節也。 # 鼎以能終為功成,致成功之地,以剛柔之並用,有法度之,無不利也。\n"},{"id":50,"href":"/zh/docs/culture/%E5%A4%A9%E7%BA%AA/%E4%BA%BA%E9%97%B4%E9%81%93/%E4%B8%8B%E7%BB%8F/49%E6%BE%A4%E7%81%AB%E9%9D%A9/","title":"49澤火革","section":"下经","content":" 49澤火革 # 此卦彭越戰項王絶糧時,卜得之,遂能承恩改革也。\n圖中:\n一人把柿全,一人把柿不全,一兔虎, 官人推車,車上一印,在大路上。\n豹變爲虎之課 改舊從新之象\n井之為用,在甘潔寒洌,如中有穢物,任其存之必敗,清之則必潔,此乃革之義,故井後, 革為其序。此卦澤在上,火在下,乃水能滅火,火亦能涸水之象,人能外悦而內明,乃革之真義。\n卦圖象解\n一、一人把柿全,乃人肺腑之言。二、一人把柿不全,一人半真言也。三、一兔:躍進之象。\n四、一虎:威權之象,大人之象,肖虎也。\n五、官人推車有一印:如為武官,則訟至。今為文官帶印,主得掛印封帥,須力求變革, 果必成。丈夫推車,乃求婚也,帶印必成。\n六、虎兔向山行:賢良大人退,後繼有人躍進同行。七、山頭四點:上位之人,黑暗不明之象。\n八、大路:轉變為新且順之象。\n人間道\n革:巳日乃孚,元亨利貞,悔亡。\n革之道,在變其故舊,始初人必未能遽信,必俟至巳日乃人心信從。革之道在求變故後能亨通,但須誠正利於天下,乃能去故從新,不因有大變而生悔。\n彖曰:革,水火相息,二女同居,其志不相得,曰革。 # 水火互息互滅,次女少女同居一室,其志不同,其所歸各異,如革也,有生變異之象。\n巳日乃孚,革而信之。文明以説,大亨以正,革而當,其悔乃亡。 # 革之雖變,始初不令人信,但如以正道,日久乃信。能明且悦,則能盡事之理,無事不察, 人心和順,必致大亨,此革之至當,必不生悔。\n天地革而四時成,湯武革命,順乎天而應乎人,革之時大矣哉! # 天地有變革後,乃能生成四時寒暑,萬物因其時節而生。王者之與,能上順天命,下應人心,改舊去新,得易之神,此革之時義大也。\n象曰:澤中有火。革,君子以治歷明時。 # 水火相消滅,為革道,除舊佈新,君子觀革之象,乃知推演歷數,知日月星辰之變,以明四時之序,則終能合於天地。\n初九:鞏用黃牛之革。 # 變革之事為大事,必有時機,有人才,有其地位,謀慮而後動,則可無侮矣。猶如用黃牛之皮來侷束,但求自固而守,不求任意妄動之義。\n六二:巳日乃革之,征吉 ,无咎。 # 陰居陰位,乃得才適用之時,此際足以去天下之弊乃上輔於君,行其正道而革之,吉而無災也。\n九三:征凶,貞厲。革言三就,有孚。 # 九三相位,居下卦之上,上有君,於此以陽剛之勢而力革時,乃過於燥動,行之必有凶。但如慎戒敬懼,守貞正之道,知順從公論,行革不為人疑出於私利,則吉,因眾必孚服而順。\n九四:悔亡,有孚,改命,吉。 # 此即以剛處柔位,近君側之人,剛柔互濟, 於革之時,行以至誠,致令上信下順,其吉必矣。\n九五:大人虎變,未占有孚。 # 九五至尊之君,能以中正之道,居革之時, 乃能力革之,其必昭著天下,不須占決,知其行為之至當,天下必信。\n上六:君子豹變,小人革面,征凶, 居貞吉。 # 革之終時,君子必從革後而大變,於至善, 小人昏愚難變,即令心智未化,但其面已改, 從上之教導。天下之事於始革必艱難,至革成, 則患無法自守,故於此須戒之守貞堅志,則果必 吉 。\n象曰:鞏用黃牛,不可以有爲也。 # 初革之時,但求能自固守其位,不可妄求有功。\n象曰:己日革之,行有嘉也。 # 時至人信乃革之,動則有喜慶。如時至而不動,則必無濟世之心,必因失時而生悔矣。\n象曰:革言三就,又何之矣。 # 革之道經再三察合,知順乎天理,乃事之至當,何須再往呢?\n象曰:改命之吉 ,信志也。 # 能改命致吉,必上下能順從信服方成,因至誠感召也。\n象曰:大人虎變,其文炳也。 # 事理之明如虎紋之明盛,則天下無不信服。\n象曰:君子豹變,其文蔚也。小人革面,順以從君也。 # 君子變革後,其更精進,如文采之蔚盛。小人因於革,不敢為惡,唯外飾臣服,以從君道,此革道成功矣。\n"},{"id":51,"href":"/zh/docs/culture/%E5%A4%A9%E7%BA%AA/%E4%BA%BA%E9%97%B4%E9%81%93/%E4%B8%8B%E7%BB%8F/48%E6%B0%B4%E9%A2%A8%E4%BA%95/","title":"48水風井","section":"下经","content":" 48水風井 # 此卦楊貴妃,私與安祿山為事,卜得之,反受其害也。\n圖中:\n金甲神執符,女子抱合,錢寶有光起, 人落井中,官人用繩引出。\n珠藏深淵之課 守靜安常之象\n困之後,如至極,則纏身動彈不得,猶陷井中,故困之後為井。物之居下者,莫如井,此卦坎上巽下,坎為水,巽為木,木在水下,必上乎水,故有汲井之象。\n卦圖象解\n一、金甲神執符:貴人在空中,祥瑞之兆也。二、女子抱合:好也,但先成後破。\n三、錢寶有光起:財不失,不露之象,不久有喪也。四、人落井中:病重象,官司沈陷象,招陷害也。 五、官人用繩引出:貴人為官人,欲相救也。\n六、符:符乃策略有傍人作對。\n人間道\n井:改邑不改井,无喪无得,往來井井。\n井,為不常改之處,常見都巿已改,但井無法遷動,井之性,汲之不竭,存之不盈,故曰無喪無得,凡人至井側,皆受其用,此不變之常德也,此為井之道。\n汔至,亦未繘井,贏其瓶,凶。 # 井之道在乎供人用為善,如人至而未用井,仍飲泉,則有亦若無。君子之道,以有成為貴, 如道即令正,但不為所用,亦等於無也。徒自招凶。\n彖曰:巽乎水而上水,井,井養而不窮也,改邑不改井,乃以剛中也。 # 木入水中,而又上於水,此井也,井能養物,不盡不竭,此井德之常也。城巿可改,而井不能遷,猶君子剛中之德,即令再艱險,卻永不改變其志。\n汔至亦未繘井,未有功也,羸其瓶,是以凶也。 # 井以供人使用為功,今人至井側,而不用井,則有井亦同於無井,無法發揮井之功用。瓶須盛水,今破壞,是以無用而致凶也。\n象曰:木上有水,井,君子以勞民勸相。 # 君子觀井象,效法井之性,為助民而任勞,且教其互助之功,此效法井之佈施無私也。\n初六:井泥不食,舊井无禽。 # 井之道,始為井之時,仍不適用也,井內初無水,其底為泥,不可食,井之德能養人, 乃其有水也。今若為廢棄之舊井,其無水,故必無禽至,猶人之無才無法濟物,必為時代淘汰。今之師即須有舊井之戒,故孟子曰:人之\n九二:井谷射鮒,甕敝漏。 # 陽剛居將位,居井之時,上不對應,因而趨下,失井之道,降下而趨泥,而成微不足道之物,如破漏之甕也,終必無功。\n九三:井渫不食,爲我心惻,可用汲,王明並受其福。 # 九三之才居相位,陽剛又居高位,才必能濟世,如井之清水升而上,可助人之力大也, 今不見人食,乃謂君明其賢不用其才,賢才之心必憂,如君能用其才,使其才能濟民,乃上下之共福也。\n六四:井甃,无咎。 # 陰柔之人居君王身側,才不足而任其位, 若但求修治井口之磚砌,自守之,亦可無咎。\n九五:井洌,寒泉食。 # 九五君位之人,能如井中之泉,甘寒潔淨, 為民所喜用,此井道之至善也。\n上六:井收勿幕,有孚元吉。 # 井以水能上出為人所用為居功,使人汲取而不竭,其利必無窮無盡,其間之性為常此不變,始終如一,廣施其德於萬民,此乃井道之成也。\n患,莫過於為師,即有舊井之戒。\n象曰:井泥不食,下也,舊井无禽, 時舍也。 # 初為陰柔,而居井之底,乃泥象,無水之泥,人必不食,無水之井亦無禽聚,此必為時所捨之也。\n象曰:井谷射鮒,无與也。 # 井,能出水助人為功用,故以能出為成功, 今陽剛之才,本可濟用於世,因上不用故成無用。\n象曰:井渫不食,行惻也,求王明, 受福也。 # 井中水清澈而不食用,乃有才智之人,不見用,其無法遂行其志而憂懷,但求得遇明君而天下受其福澤。\n象曰:井甃無咎,脩井也。 # 修治井口 ,即令無功於天下,亦因其能守而不致招廢棄,亦可為功矣。\n象曰:寒泉之食,中正也。 # 寒洌甘潔之泉水受人喜用,乃中正之道得用亦同。\n象曰:元吉在上,大成也。 # 能以至善而居卦終,乃大成也。\n"},{"id":52,"href":"/zh/docs/culture/%E5%A4%A9%E7%BA%AA/%E4%BA%BA%E9%97%B4%E9%81%93/%E4%B8%8B%E7%BB%8F/47%E6%BE%A4%E6%B0%B4%E5%9B%B0/","title":"47澤水困","section":"下经","content":" 47澤水困 # 此卦李德裕罷相時,卜得之,乃知身命無氣也。\n圖中:\n一輪獨在地上,一人臥病,葯爐, 貴人倾水救旱池魚,池中青草。\n河中無水之課 守己待時之象\n升至極,不知進退,私欲使然,終必至困,故困次升也。此卦「兌」上「坎」下,水在澤上乃有水也,今水在澤下乃枯涸無水之象,此示人之困乏象,本卦論人紀中,君子受小人掩蔽, 居窮困之時也。\n卦圖象解\n一、一輪獨在地下:獨行也無依也,方向不明也。二、一人臥病,招危難也。\n三、藥爐:有人來救,待時緩進也。\n四、貴人傾水救旱池魚:旱池,無財也,有貴人資援。五、池中青草:仍有生氣狀。\n人間道\n困:亨,貞,大人士口,无咎,有言口不信。\n困之時,如何用困之道,乃易之精神,大人居困時,以樂天安命,隨時善處,自得其樂, 必吉。有愚頑之人,居困而力求言,人必不信也。\n彖曰:困,剛揜也。險以説,困而不失其所亨,其唯君子乎? # 困之生乃因剛為柔所掩蔽,專言君子之道為小人所掩蔽,而困窮之時君子知困之道,外悦內險,處困而能以悦之度量居之,不失正道,則其道必自然亨通,能如此之人,必為君子貞, 大人吉,以剛中也。有言不信,尚口乃窮也。\n處困時,君子能吉,乃因堅守剛中之道,不變節。於困之時,所言人必不信,如反求己之口才以脱困,必更致困,此即為尚口之戒。\n象曰:澤无水,困。君子以致命遂志。 # 澤中無水,困之象。居此時君子以力盡防患之法,如仍難免,則歸之於命,泰然處之,絶不因困窮而變其志節。小人遇困,志節必變,但求附於他人,以求脱困,其終必凶。\n初六:臀困于株木,入于幽谷,三歲不覿。 # 陰柔之人位居卑下,又無上位救助,猶如無葉之木,無法蔭庇下物,因無庇蔭,故不能安居於此,如進入幽暗之谷,無法自出,有三年的時間無法入亨。\n九二:困于酒食,朱紱方來,利用享祀:征凶,无咎。 # 常人無不為酒食所困,不知酒食之施,乃生於人有所欲,常言:宴無好宴。君子之飲與小人不同,能困君子的,必是其剛正之道不足以濟天下之困,此其困擾也。居此時君子必求至誠以守,等待時機,可利用祭祀以示至誠, 俟貴人至,求之方吉。若不安居困,自往求之,\n六三:困于石,據于蒺藜,入于其宮,不見其妻,凶。 # 處困可羞之時,卻用外剛來飾內險,益增自困,必堅重如石,而不安之情如手握蒺藜之物,因刺多而不能握,進退不得狀,必失所居, 凶。\n九四:來徐徐,困于金車,吝,有終。 # 居君側之人,不以中正之道處困時,而才能又不足濟困,其來動必遲遲,困於金車之內, 必不見容於世,可羞也,如知濟困得中正之道, 乃有歸也。\n九五:劓刖,困于赤紱,乃徐有説, 利用祭祀。 # 劓,乃上有傷,刖,乃下有傷,人君之困, 乃上下無應,天下之民不來也,必起用剛正之賢才,以至誠待之,並利用祭祀,示道之至誠於天,則民必徐來。\n上六:困于葛藟,于臲卼,曰動悔。有悔,征吉。 # 困至極,必生變,如物之纏束於身,動則有悔,無所不困,即動靜皆困,必求進,方有吉處。\n象曰:入于幽谷,幽不明也。 # 自陷於深谷,乃出自己之不明也。\n其招凶皆自取也。\n象曰:困于酒食,中有慶也。 # 居困時,雖未能施惠於人,如能守其剛中之德,必能亨。而有吉慶也。\n象曰:據于蒺藜,乘剛也。入于其宮,不見其妻,不祥也。 # 此言內之不安,欲求外剛來飾,乃用剛之不正,必終失所安,果必不祥也\n象曰:來徐徐,志在下也,雖不當位,有與也。 # 居不適位,志在求下,其雖徐徐而來,即令未善,但因與正相應,必有果矣。\n象曰:劓刖,志未得也,乃徐有説, 以中直也,利用祭祀,受福也。 # 上下不應,君王之困,其志不遂,於此必用祭祀以至誠,求得天下之賢,必能濟天下之困,終享其福慶也。\n象曰:困于葛藟,未當也,動悔有悔,吉行也。 # 為困纏身,無法求變,乃未知困之道,知動有悔,求悔而去,必可出困,其行必吉矣。\n"},{"id":53,"href":"/zh/docs/culture/%E5%A4%A9%E7%BA%AA/%E4%BA%BA%E9%97%B4%E9%81%93/%E4%B8%8B%E7%BB%8F/46%E5%9C%B0%E9%A2%A8%E5%8D%87/","title":"46地風升","section":"下经","content":" 46地風升 # 此卦房玄齡去蓬萊採葯未回,卜得知,主不在也。\n圖中:\n雲中雨點下,木匠下墨解木,一人磨鏡,一架子有鏡\n高山植木之課 積小成大之象\n萃者聚也,聚之後能上,即升也,萬物咸因能聚方可升高,故升次萃也。此卦坤上巽下, 乃木在地下生於地中,長而愈高此升之象也。\n卦圖象解\n一、雲中雨點下:沾衣恩澤之象,昏暗之時將過也。\n二、木匠下墨解木:有尺度法則,有依據之象。匠—將也。以武力去災也有改朝換代之象也。\n三、一人磨鏡:可成始得之象。\n四、一架子負大木:有不小之財也。\n五、有鏡:指明鏡高懸,於商事指有競爭也。六、藍:難也,百廢待與之象。\n人間道\n升:元亨,用見大人,勿恤,南征吉。\n升之道,在於亨通,用此道来見大人,不靠体恤而能進,必吉。\n彖曰:柔以時升,巽而順,剛中而應,是以大亨。 # 柔即順也,順時而升,以柔而進,此升之得時矣,二剛陽之位,得五柔順之君應則能大亨也。\n用見大人,勿恤,有慶也。南征吉,志行也。 # 用巽順剛陽中正之道來見大人,必遂心志得升,從不去憂升之不遂,此吉慶也,前進則必升,必能遂行其志也。\n象曰:地中生木,升;君子以順德,積小以高大。 # 地中生木,木長而高,此升也。君子觀升象,乃知修身之德,由積累微小,乃至高大,故積不善,則不足以成名,凡學問道德之高,皆由累積而成,升之義也。\n初六:允升,大吉。 # 初進時柔順於九二之剛,因得信而升,此升吉也。\n象曰:允升大吉,上合志也。 # 與上位之志合,得信而升,此因信於剛中之賢而能大吉也。\n九二:孚乃利用禴,无咎。 # 九二陽剛之臣,處升之時,因上位君柔, 絶不可矯求外飾,必求以中心之至誠來感通於上,如此則可無咎。\n九三:升虛邑。 # 九三陽剛之相,能正而順上,如此而升, 就如入無人之巿,無人抵禦。\n六四:王用享于岐山,吉 ,无咎。 # 居諸候之位,能上柔順於君王,下又順天下之賢,舉之升進,於已則柔順謙恭,不求離本位,有德如是,必吉,且無災矣。人之戒, 切記在不可無事而升,升又必量力而進,方合君子之道。今人已不復如此,無功而升,比比\n六五:貞,吉,升階。 # 君位之人性柔,必得貞正且固守之德,則人皆信而賢至,能知人善用,必因用賢而能再升,澤及天下也。\n上六:冥升,利于不息之貞。 # 升之極乃致昏昧於升,只知進不知止,此為不明,君子之人終日乾乾,無時不警惕自己, 知所進退。小人貪求私慾之心盛,乃生不知進退之行為,凶也。\n象曰:九二之孚,有喜也。 # 君臣之道,臣之事君能中心至誠,不但為臣無禍,又可遂行其大志,此可喜也。\n象曰:升虛邑,无所疑也。 # 升而如入無人之邑,則此進,必無可疑慮也。\n皆是,升之道,如出私欲,必令國致窮困矣。\n象曰:王用享于岐山,順事也。 # 居君側之人,因知順時順事,能順處之, 得坤之性,可以無咎也。\n象曰:貞吉升階,大得志也。 # 君能任用賢才,如此可致天下大治,故君王升之道在能用賢耳,必遂其志。\n象曰:冥升在上,消不富也。 # 昏昧之人已至極上,猶求升之不已,其果唯消亡而已,無復增益也。\n"},{"id":54,"href":"/zh/docs/culture/%E5%A4%A9%E7%BA%AA/%E4%BA%BA%E9%97%B4%E9%81%93/%E4%B8%8B%E7%BB%8F/45%E6%BE%A4%E5%9C%B0%E8%90%83/","title":"45澤地萃","section":"下经","content":" 45澤地萃 # 此卦韓信被呂后疑惑,卜得知,果被其戮也。\n圖中:\n貴人磨玉,一僧指小兒山路,一人救火,一魚在火上,一鳳啣書。\n魚龍會聚之課 如水就下之象\n物相遇而後,同氣相求,則生聚象,萃,聚也。物相聚成群,為萃,故次姤卦之後也此卦, 澤上地下,水之聚象,水之在地上,乃方聚之時也。\n卦圖象解\n一、貴人磨玉:進修也,專心一致也,免破損也。\n二、一僧指:指引也,局外人也,光頭人也,曾姓人也。 三、小兒山路:退引山林,以退為進象,示上坡也。倪姓。四、一人救火:救災禍也;猶行醫濟世救人之道也。\n五、一魚在火上:得救狀,或餐飲業。六、一鳳啣書:喜訊至也。\n人間道\n萃:亨,王假有廟。利見大人,亨,利貞。用大牲吉,利有攸往。 # 人之至誠專一莫過於宗廟祭祀之時,王者能令天下之人其心專一,莫過於利用宗廟,故聖人制祭祀之禮以養民之德。此聚之義大也。民聚之時,能得賢才,則必聚而正,如不正,治之不以正理,則亂之生亦由人之聚,故聚必以道,非正之道,即令有聚,亦不能安處也。於聚時祭禮用大牲厚禮,乃示慎重且富是天下必同之,今人聚而人不能亨其豐盛,則必散也。是故古今皆然,凡能與大功立大業之人,第一必得其時,第二必聚而後能用,此動必吉,天理莫及此也。\n彖曰:萃,聚也,順以説,剛中而應,故聚也。 # 萃卦,乃上悦下順之象,上位以中道用民,合於民心,下又能順從於上之政令且又剛陽相應,堅心赤誠如此,必能聚天下之人才。\n王假有廟,致孝享也。 # 王者要有聚民心之道,必立宗廟以示孝順之至誠,能順天下之人心,必以孝方成。\n利見大人亨,聚以正也。 # 聚之以正道,必能得人才治之,乃因其正也。\n用大牲吉,利有攸往,順天命也。 # 祭祀時用厚禮祭天,必可有為,此順天命也。\n視其所聚,而天地萬物之情可見矣。 # 天地萬物間皆有聚,有動,有散等,聖人觀象,可見萬物之情性也。\n象曰:澤上於地,萃,君子以除戎器,戒不虞。 # 澤上於地,為集聚之象,君子知始戒之慎,故眾民相聚之初,就也生戒,因眾聚必有爭, 私心所成,故先除兵械,乃能戒而無虞。\n初六:有孚不終,乃亂乃萃,若號, 一握爲笑,勿恤,往无咎。 # 聚之始,柔居之,陰柔之人,必無法堅守正節,為求與人同而捨其正道,但為求同,此不終之戒。人心必亂,同氣相求而生聚,或哀號作苦以求相應,其果必為眾人之笑柄,若能堅心往從陽剛正道,必無過咎,否則成小人矣。\n六二:引吉,无咎,孚乃利用禴。 # 二之位與五相對應,位雖有差距,此時乃當聚而未及合之時,如能相引聚則可無咎,此因其中正之德未變,如德變,則必不相吸引。故於群小人聚時能獨立其間,且與上位之德同,必能合,此其意也。即令不重外飾,專以至誠,則終必合矣。\n六三:萃如磋如,无攸利,往无咎, 小吝。 # 不正之人,求能與人聚合,但人皆不應, 為人棄絶,上下皆不應,唯退求事外之賢與之相應,如此則可無咎。人之動有求,即令得之, 亦可羞也。\n九四:大吉无咎。 # 九四之位,能以陽剛,與君位對應,得君臣之萃,下又得群陰之聚,此上下能因其陽剛而聚,此至善也。\n九五:萃有位,无咎。匪孚,元永貞,悔亡。 # 九五居天下之尊,有萃聚天下之力,則無災,此時必自修其德,正其位,處不以私,為得其位也。如有人不信而未能歸從,當自省是否不正,堅固中正不變之德性,則無不歸矣。\n上六:齎咨涕洟,无咎。 # 象曰:乃亂乃萃,其志亂也。\n若人之心志受人惑而亂,必不能固守節志,乃失其正道也。\n象曰:引吉无咎,中未變也。 # 萃之時,以能聚為吉,同志相求,平安, 其位有差距,因中正誠心同德必能無咎。\n象曰:往无咎,上巽也。 # 上六居柔之極,故能往而無災。\n象曰:大吉无咎,位不當也。 # 能有吉且無災之果,即令位不當,因上下與已合德,故也。\n象曰:萃有位,志未光也。 # 為王者,必以誠信以示天下,如此方有感於民,則人莫不歸矣。如仍有不從,乃已之志未能光顯也\n小人居高位,求人與其聚,而人皆不與,此可嘆也,人之棄絶,實出於已,怎可歸咎他人\n如再不知自省,反嗟嘆而致涕泣,此小人狀也。\n象曰:齎咨涕洟,未安上也。 # 小人之對人皆因貪而失所宜,以致终窮困,此起因於不能安居其位也\n"},{"id":55,"href":"/zh/docs/culture/%E5%A4%A9%E7%BA%AA/%E4%BA%BA%E9%97%B4%E9%81%93/%E4%B8%8B%E7%BB%8F/44%E5%A4%A9%E9%A2%A8%E5%A7%A4/","title":"44天風姤","section":"下经","content":" 44天風姤 # 此卦漢呂后擬立呂氏謀漢社稷,卜得之果不利也。\n圖中:\n官人射鹿,文書有喜字,二人執索, 綠衣人指路。\n風雲相濟之課 或聚或散之象\n夬,決也,潰決之後,必有遇,姤,遇也,故次夬卦。此卦,乾上巽下,即風行天下,風行時必觸及萬物,故為遇之象。\n卦圖象解\n一、官人射鹿:丈夫也,取祿狀,野外之財,射,發也。二、文書上有喜字:摇摇欲墜,根只一線,夫妻凶。\n三、二人執索:互相牽累。\n四、綠衣人指路:掌生死簿之人,信差(今日)。五、二重山:重阻也。出也。\n人間道\n姤:女壯,勿用取女。\n姤之道在戒陰壯之始,娶女本欲其柔且順從,但初進為陰,而渐進長盛,且有壯於陽者, 古来女壯必失男女之道,家道必敗。戒之在初始。\n彖曰:姤,遇也,柔遇剛也。勿用取女,不可與長也。 # 姤,乃柔之遇剛,小人之道始遇君子之陽剛,須戒之在始,勿取如是之女,不可使小人之道長。\n天地相遇,品物咸章也。剛遇中正,天下大行也。姤之時義大矣哉。 # 萬物之生始於天陽地陰之交遇,不遇則不生。但须剛遇中正之道,猶人君之遇賢臣,剛與中正合德,其道必及於天下,姤之道即在遇之始,遇之正則吉,遇之不正須始戒。\n象曰:天下有風,姤,后以施命誥四方。 # 君之道,能如風之及於萬物,方可施政令而及於四方,故遇之道能行,天下必因中正之遇, 上下配合,而政令能如風之遍及萬物。\n初六:繫于金柅,貞吉,有攸往, 見凶,羸豕孚蹢躅。 # 柅,止車之物,金,堅固意,因姤乃陰始於初而將長盛之卦,小人之道,於始初即應如止車之柅,且繫之,使不進也。此吉。如令其渐盛,不知始戒,有凶,猶羸弱之豕其雖不強, 然為陰物,故知其能跳躍之時,必消之,故小人之道於始即消除,則必不能成大,而無有作為矣。\n九二:包有魚,无咎,不利賓。 # 姤相遇之道在於專一,陽剛將位之人與五君位應,其志必專一,遇則無災,如又遇於旁人,變其志,則有悔,其不利在不能專一。陰\n九三:臀无膚,其行次且,厲,无大咎。 # 時義乃謂初進之人與上位相遇而恰合,此時居上位之人同志於初始進之人,如為求其助,而親密於下,此凶,乃行且困難,必令居不安也,如臀之無膚狀,居此時如懷危懼不妄\n九四:包无魚,起凶。 # 君側之人始遇初進同志之相應,但因其已先遇九二位之人,此遇已失,此意味臣位之人不中正,必失其民,故凶。故遇之道,必下不離散,今如下位之人散,上必失道也。\n九五:以杞包瓜,含章,有損自天。 # 九五乃至尊之位,上位下求賢才,瓜乃美實但居於下,今上位之人能屈而求下,此乃包含之美德,人君能如此,則必有遇所求之人才, 故能屈尊求下內心至誠,如有隕石自天而下, 此遇之善道也。\n上九:姤其角,吝,无咎。 # 至剛而居上,莫過於角,人之遇,必由降屈相求,巽順相應,此合之由來,如居高而剛極,持才自傲,人何將與之,此人之遠離,乃肇因於己之過亢,非他人之罪也。\n象曰:繫于金柅,柔道牽也。 # 陰柔小人之道,始進則如車之受金柅而止,不使其進,正道必存,吉也。\n柔之人志易變,必不能專。\n象曰:包有魚,義不及賓也。 # 二位乃初遇志同之時,因不事二主,當如苞苴之魚,只能及於主人,無法及於客賓一樣。動,可無咎也。\n象曰:其行次且,行未牽也。 # 相位之人求姤遇於初進之人,中有將位阻隔,故行次且困頓,知危立改,必可未至大殃也。\n象曰:无魚之凶,遠民也。 # 下民遠離,乃起因於已之不中正,或已中正,而下民不相應也。\n象曰:九五含章,中正也。有隕自天,志不合,命也。 # 九五至尊,能含有中正之德,求賢而屈下, 此存志乃合於天理,必如隕石般,天助而得良才,遇之大道。\n象曰:姤其角,上窮吝也。 # 遇之如角之亢極,以剛而遇,必致窮極末路,人必散之。\n"},{"id":56,"href":"/zh/docs/culture/%E5%A4%A9%E7%BA%AA/%E4%BA%BA%E9%97%B4%E9%81%93/%E4%B8%8B%E7%BB%8F/43%E6%BE%A4%E5%A4%A9%E5%A4%AC/","title":"43澤天夬","section":"下经","content":" 43澤天夬 # 此卦漢高祖欲拜韓信為將,卜得之,知有王佐才也。\n圖中:\n二人同行,前水後火,虎蛇當道,一人斬蛇,竿上有文字,竿下有錢。\n神劍斬蛟之課 先損後益之象\n益之至極,猶水之滿溢,必決出之,夬者,決也,必決而後止。以卦体而言,兌上乾下, 有水之聚高至頂,則潰決也。如以爻而言,五陽在下,陽之長而陰將退,有眾陽決去一陰之象, 故於人間道,則此為君子道長,小人道消之時也。\n卦圖象解\n一、二人同行:相輔相成,可成事也。\n二、前水後火:前險而後明,宜進取象,土生金象。\n三、虎蛇當道:虎,有權威之人。蛇—險奸之小人阻道。四、一人斬蛇:勇士也,得名將也。\n五、竿上有文字:揚竿而起,正名出師。六、竿下有錢:行動有利也。\n七、錢下有火:燒錢狀,主喪服。\n人間道\n夬:揚于王庭,孚號,有厲。\n夬之道,在小人式微,君子道長,必顯於公堂,使人明善惡,故揚于王庭。於此時敬戒之以至誠以命下民,使民戒之,居安思危,隨時生戒懼之心,必無後患。\n告自邑,不利即戎,利有攸往。 # 夬之道能於至善,必不尚武力,從修已開始,齊家後能治國,以道服眾,如此則可進而往。\n彖曰:夬,決也,剛決柔也。健而説,決而和。 # 五陽去一陰,下健而上悦,乃因健而能悦,能和,此決道之至善。\n揚于王庭,柔乘五剛也。孚號有厲,其危乃光也。 # 陰居陽上,此不正也,君子去之,當揚其罪於公堂,使民眾知善惡也。以衷心至誠帶領民眾,使知戒懼,防危之至,君子之道方可顯其力大。\n告自邑,不利即戎,所尚乃窮也。利有攸往,剛長乃終也。 # 夬之時,不可崇武以力取,必須從修治本身之地起,方可有利前進光大君子陽剛之道,待正道至剛時,決不可留一邪吝之道,此剛進至終可吉也。\n象曰:澤上於天,夬,君子以施祿及下,居德則忌。 # 水之聚而上於天,為夬象,其終必決於上,而灌溉於下,君子觀象法天,故知必施祿於天下,則民皆向之,於安處之時,知須防禁,則無潰散之虞。\n初九:壯于前趾,往不勝爲咎。 # 在下位剛健之人,於決之時,必強進執行, 如執行中受阻而失敗,必決之過,故凡事欲動之前,必戒之在燥,做最壞之打算,方可行進。\n九二:惕號,莫夜有戎,勿恤。 # 君子去小人之時,必思懷戒備,不鬆懈一時,如此即令夜有兵戎,亦不驚也。\n九三:壯于頄,有凶。君子夬夬, 獨行遇雨,若濡有慍,无咎。 # 九三居相位,上有君王,卻剛於果決,此決乃自任之決,必非合君意,果必招凶。故君子居夬之時,知己道之長,小人之道將消,必不獨與小人合,且面現愠色,必可無咎。今人皆自以為重要,見人之招凶,不論其因為何, 一律給予支持,此愚善之表現,徒令小人得逞, 不知反悔,反更盛其勢,此禍之端,乃肇因於人之愚善。\n九四:臀无膚,其行次且,牽羊悔亡,聞言不信。 # 陽居陰位,乃示剛決不足之人所犯之過, 陽剛正理已明極時,如己之果決不足,必招居不安,行進又難之狀,若能法羊群之群行特性, 可無悔,但剛決不足之人,令其以柔進,必不能也,故即令告之,本性如此,必不能信用。自古以來,知過能改,知善能用,克己之欲能\n九五:莧陸夬夬,中行无咎。 # 君為決定之主,居夬之時,必立決,以去小人之道,稟持中正之道,必無災。\n上六:无號,終有凶。 # 此陰將盡之時,君子道顯,小人道必消之際,惟比附於君子之道方吉,否則即令不號咷畏懼,亦終獲凶。\n象曰:不勝而往,咎也。 # 君子之行,必度量而進,知不可而力求, 必招咎也。小人無此之戒,不量力而進,其果自取其辱。\n象曰:有戎勿恤,得中道也。 # 衣有兵戎,不驚憂,乃因自處之善,能行中道,知所戒懼也,故學易之人必知時識勢, 不知如此,惶論精易。\n象曰:君子夬夬,終无咎也。 # 決必合於正理,君子於當決之時果決之, 終不至咎。常人须戒,在不明狀況之前,不做決定就是良策,切不可憑己之所好而蒙決,必害及他人。\n如是之人,必剛明者方可做到。\n象曰:其行次且,位不當也;聞言不信,聰不明也。 # 此行為受阻,受難,乃居位之人不適其職位。正理之言不信,因其聰聽不明也。\n象曰:中行无咎,中未光也。 # 人能中心至誠,必決之無過也,但人心皆有私慾,一旦涉及私欲,必不能光大中道。\n象曰:无號之凶,終不可長也。 # 如示人號咷畏懼,仍招凶,此道必不久也, 故君子思去小人之道,非斬盡小人也。\n"},{"id":57,"href":"/zh/docs/culture/%E5%A4%A9%E7%BA%AA/%E4%BA%BA%E9%97%B4%E9%81%93/%E4%B8%8B%E7%BB%8F/42%E9%A2%A8%E9%9B%B7%E7%9B%8A/","title":"42風雷益","section":"下经","content":" 42風雷益 # 此卦冉伯牛有疾,卜得之,乃知謾師之過也。\n圖中:\n官人抱合子,一人推車,一鹿一錢。\n鴻鵠遇風之課 河水溢出之象\n損而不己必生益,自古盛,衰,損,益,天理循環,損至極生益,所以繼損也。此卦巽上震下,風雷二物互相增益,雷受風則迅,風受雷激則烈,互相助威,所以成益象。損上益下, 故為益,前卦乃損下益上故損,以其義而推,吾人可知,民厚則國安。\n卦圖象解\n一、官人抱合子:倌人,丈夫也,事必先成後破。二、一人推車:因時而動之象,空坐相請。\n三、一鹿一錢:才祿俱備,有回祿之災,財有破損,憂心忡忡。\n四、不為財货,不畏時尚之變,明知先成後破,仍走馬上任,以損上益下之道,天下或事業亦能平治。\n人間道\n益:利有攸往,利涉大川。\n益,乃有益於天下之道,故可濟險難,利涉大川。\n彖曰:益,損上益下,民説无疆,自上下下,其道大光。 # 益之本義,其道在損於上而益於下,民必悦之且無盡無窮,從上而降,下之下亦受之,益道乃光顯而大也。\n利有攸往,中正有慶。利涉大川,木道乃行。 # 以中正之道助益天下,天下受福,故往吉。萬物中唯木助益人類最大,舉凡葯材,車船, 築室等皆不離木,木於平時尚未顯其要,但於艱危困頓時,則助益乃大,益道亦如是。\n益動而巽,日進无疆。天施地生,其益無方。凡益之道,與時偕行。 # 益道之動乃在乎順,動不順乎理,必不成益。天道滋始,地道成物,而化育萬物,各正性命,使益之道廣大無邊。聖人體天地之道,知能利乎天下之道,必須順乎時應乎理,因時制宜, 乃益之大道。\n象曰:風雷,益,君子以見善則遷,有過則改。 # 風與雷二物相互助益,君子觀其象,而知益於己之道,乃在見善則遷,則可盡天下之善, 有過能改,則無過矣。\n初九:利用爲大作,元吉,無咎。 # 初九為陽剛之賢,如遇六四上位之順時, 亦即居下位而有能力之賢才,能得上位之順從支持,必做大益於天下之事,此為大作,如不能持此原則,不但悔咎來臨,且又累上位,此是上位之過也。如益眾人,則必無過。今人多志得意滿,狐假虎威,欺上瞞下,唯圖利自己而已。\n六二:或益之,十朋之龜,弗克違, 永貞吉,王用享于帝,吉。 # 人能處於中正之道,又知中虛來求益,且能順從無私,天下何不能受?必不相違,日久更吉。如此則必能通上位,獲吉,人皆相從矣。\n六三:益之用凶事,无咎。有孚中行,告公用圭。 # 六三乃相位,居下民之上統管民事,如能居民上而剛決,其果為益民而決,即令是凶事, 亦可無咎。但仍須以誠意通於上,使上能信任, 如所為不合中道,即令上信亦不可為也。\n六四:中行,告公從,利用爲依遷國。 # 居君側,以柔順從君,對下又順應於初陽之剛賢,如此則能令民順而從行。\n九五:有孚惠心,勿問元吉,有孚惠我德。 # 陽剛君王,居尊心中又誠正,必能益於天下,不問可知,天下之人必至誠愛戴,因君之能施恩德也。\n上九:莫益之,或擊之,立心勿恒, 凶。 # 以剛處益之極,此專欲利己,不與眾同欲, 必招怨,或受攻撃,聖人戒之在人欲,堅心不可有私欲,否則招凶。有則須速改。\n象曰:元吉无咎,下不厚事也。 # 下位之人,本就不可擔重大之事,今得上位信賴而出任大事,必须能濟世助人,方可使上位有知人善任之譽,如只會壞事,民怨四起, 則上下皆有過失也。\n象曰:或益之,自外來也。 # 能知益天下之道,且堅守此道,必令眾人自外而來歸矣。\n象曰:益用凶事,固有之也。 # 居下凡事當稟明於上,乃得誠信。但如遇危難救災之事,可立以剛決,必能無咎,此其大義也。\n象曰:告公從,以益志也。 # 其動之志在益天下,上必信從,因其為公不為私,故古人不患上之不從,患己之不正。\n象曰:有孚惠心,勿問之矣,惠我德,大得志也。 # 人君有至誠增益天下之心,不须問,天下之民,必懷德承恩,益道大行,人君志必行。\n象曰:莫益之,偏辭也,或擊之, 自外來也。 # 若以私利增益自己,人必與爭之,必不肯之。或言辭偏,或攻擊之,皆自外來。起因皆因己心不合正理,私欲專利而成。\n"},{"id":58,"href":"/zh/docs/culture/%E5%A4%A9%E7%BA%AA/%E4%BA%BA%E9%97%B4%E9%81%93/%E4%B8%8B%E7%BB%8F/41%E5%B1%B1%E6%BE%A4%E6%90%8D/","title":"41山澤損","section":"下经","content":" 41山澤損 # 此卦薛仁貴將收燕,卜得之,大破燕軍。\n圖中:\n二人對酌,酒瓶倒案上,毬在地上, 文書二策,有再告二字。\n鑿石見玉之課 握土爲山之象\n解,散也,斥去也,其後必有所失,故損為序。此卦「艮」上「兑」下,山體高在上而澤體深在下乃有損下益上之義。如損上而益下,為益卦,取下而求益上,則為損,故居人上而施恩澤於下則益,如取下而求自足,乃損。\n卦圖象解\n一、二人對酌:二人夾木,來也。二、酒瓶倒案之:目前無指望也。三、毬在地上:所求不成狀。\n四、文書二策有再告二字:再次求,方成。\n居此之時唯求有益於人,乃得損之真道也,損其多餘,同志乃至。\n人間道\n損:有孚,元吉,无咎,可貞,利有攸往。\n損之道須持之正理,來自誠正,則損因順理而至大善,此可吉也。堅固常行必利有所往。常人之損,有過或不及或不常皆不合乎正理。\n曷之用? 二簋可用享。\n人間祭祀之禮,文繁節褥,常人多備祭品,聖人以儉為禮之本,示以至誠之心敬天,如求祭物過多,乃求飾其誠,實示人偽矣。故知凡人之慾望若有過者,其始初皆起於奉養,常久以後則成宮樓峻牆,酒池肉林,聖人有見於此,故先制其本以儉,故損之精義在損滅人欲以近乎天理而已。\n彖曰:損,損下益上,其道上行。 # 損之成,乃損下而益於上,故道乃上行。\n損而有孚,元吉,无咎,可貞,利有攸往。 # 損之道在至誠,堅心如此,乃可盡損之道也。\n曷之用?二簋可用享,二簋應有時,損剛益柔有時。 # 吾人應損去外在浮飾,使至誠見,萬事之始,必有長幼尊卑,然於事之末,往往已流於形式,損之時乃知何時須損剛益柔,此損之道必以時乃吉。\n損益盈虛,與時偕行。 # 須損須益,求盈求虛,只隨時之一念而已,易之於時義於此可見。\n象曰:山下有澤,損,君子以懲忿窒欲。 # 君子觀損之象,以損己為上,修己之道在忿與欲上,能知損己之忿與欲,乃得真損之時義。\n初九:已事遄往,无咎,酌損之。 # 損之道,在損剛益柔也。今居下之人為益上,當以功與之,不求己居,事畢則速去,不求居功,乃能無過。若求享其功之美,此離損下益上之道,不可也。\n九二:利貞,征凶,弗損益之。 # 剛中之人與君位柔性相應,居損時,用柔悦態度求應於上,則有失剛中之德,其動必凶, 乃因不知堅心於陽剛也。此適足以損也。\n六三:三人行,則損一人。一人行, 則得其友。 # 天下沒有獨一之人,必有二者,如男女之往,由其精一,故能生也。一陰一陽,無法加入故三人因志不專一,必生損一人,一人獨行, 由其志為專,故必有一友能同其志,故天地之間損義之明且大哉,莫過於此。\n六四:換其疾,使遄有喜,无咎。 # 陰柔之人居損時,須自損以從陽剛中正之道,即損不善而從善,必有喜而無災。人之損過失,唯患不速,如速則此過必不至深,為可喜也。\n六五:或益之,十朋之龜,弗克違, 元吉。 # 君王能柔順居尊位,處損之時,知虛中自損,順從在下陽剛之賢人,人君能如此,則天下人必從其德,咸以損己為美,故有眾民之公論助之,因合正理,即龜筮亦不相違,可謂大善之吉也。古云:「謀能從眾,則合天心。」\n上九:弗損益之,无咎,貞吉,利有攸往,得臣无家。 # 損道之終極,能居上而不損其下又益增在下之人,則天下莫不服從,人心歸順,無遠近內外之區限。\n象曰:已事遄往,尚合志也。 # 事畢後退居不居功,能與上合志也。\n象曰:九二利貞,中以爲志也。 # 九二陽剛之人能守中正之道,則何有不吉。志存乎中道,則必自正也。\n象曰:一人行,三則疑也。 # 一人行而能得友,三人行則易生疑,於此時須明損之道,損去一人,乃損多餘。\n象曰:損其疾,亦可喜也。 # 知損之道,得損之時,速去其不善,此可喜也。\n象曰:六五元吉,自上祐也。 # 人君能盡眾人之見,合於天地之理,天必降福祐也。\n象曰:弗損益之,大得志也。 # 居上而不損下,又能增益之,君子必能遂行其志,故簡言之,君子之志,唯在如何益於人而已。\n"},{"id":59,"href":"/zh/docs/culture/%E5%A4%A9%E7%BA%AA/%E4%BA%BA%E9%97%B4%E9%81%93/%E4%B8%8B%E7%BB%8F/40%E9%9B%B7%E6%B0%B4%E8%A7%A3/","title":"40雷水解","section":"下经","content":" 40雷水解 # 此卦項羽受困垓下,卜得之,後果士卒潰散也。\n圖中:\n旗上提字,一刀插地,一兔走,貴人雲中,一雞在邊鳴,道士手指門,道人獻書,小堠在側。\n雷行風止之課 患難解散之象\n解者散也,萬物無終難之理,難至極而散,故解為蹇之序。此卦震上坎下,外動內險,即動於險外,有出險之象,故為患難解散之時也。\n卦圖象解\n一、旗上提字:指名提凶,不利。有遠走他鄉之象。二、一刀插地:求快也,劉姓之人也。\n三、一兔走:卯年,劉姓。\n四、一雞在邊鳴:有競爭象,酉年適逢貴人救。五、貴人雲中:不及救也。\n六、道士手指門:入空門也,逃亡方向,出家也。七、道人獻書:裝飾外表,示誠象。\n人間道\n解:利西南,無所往,其來復吉,有攸往,夙吉。\n西南即坤方,坤之体本含弘光大,於天下之難方解時,人始離困苦,當以宽大簡易之法待之,切不可使煩於苛政,人心必安,再求重修冶道,立綱紀,建法治,且宜早動,如俟難復則不及矣。\n象曰:解,險以動,動而免乎險,解。 # 險則示難也,不險必不難,遇解時,如不動必不能出險,故此動是免於招險也。\n解利西南,往得眾也。 # 此即解難之道,须廣大且平易之法以待民,則民心必歸。\n其來復吉,乃得中也。 # 治世之道,必待解之時來臨方可往用,此為得宜。\n有攸往夙吉,往有功也。 # 欲動而往,則必利速,愈早前往,功愈大,遲則害已生矣。\n天地解而雷雨作,雷雨作而百果草木皆甲坼,解之時大矣哉! # 天地之氣開而和暢生雷雨,雷雨生而萬物甲坼,故解能成天地之功,故聖人知解之時,大\n也。讀易須体時之義,方成得易之道。易之道與天地合而同德。\n象曰:雷雨作,解;君子以赦過宥罪。 # 天地解散而生雷雨,君子觀雷雨之象,体會發育之功,而知施恩仁,行宽法也。\n初六:无咎。 # 初解之時,柔居陽剛,乃柔而能剛象,知剛柔之合宜,使患難解散。\n九二:田獲三狐,得黃矢,貞吉。 # 二與五陰位相應,此言陰柔之君在上,陽剛之臣在之時也,古來如君柔則小人易蔽,威必受損,又不果斷且易受小人之惑,小人一近則心動矣,處此時又逢災難初解,聖人必以能去小人來正君之心,行其剛中之道,方可吉,\n六三:負且乘,致寇至,貞吝。 # 小人本非在上之物,今居下之上位,猶負重而乘車,乃招寇至,終必悔矣。\n九四:解而姆,朋至斯孚。 # 陽剛之人居陰柔君之側時,如居上位親近小人,必使賢士遠矣,須斥去小人則君子必進, 能得人之信孚。\n六五:君子維有解,吉,有孚于小人。 # 君主之人必去小人,則君子必進,正道乃行,如不去小人,則天下不治,此可反看,即天下不治,乃小人不去也。\n上六:公用射隼于高墉之上,獲之无不利。 # 解之至極如仍未解,必有堅強之害也,故於此時可強用弓矢如射鷹隼於高處,時至而發,如無良器又動不以時,射未成,必反生害, 故此言器之重要與動之時機,獲之必吉,不成\n象曰:剛柔之際,義无咎也。 # 剛柔相接又得其時宜,必無災也。\n狐為邪媚之獸,黄乃直中也。\n象曰:九二貞吉,得中道也。 # 能得中正之道,除去邪惡乃正且吉也。\n象曰:負且乘,亦可醜也。自我致戎,又誰咎也。 # 負重乘車,招寇致其之醜乃咎由自取,又可怪誰呢。小人不量力勉居君子之位處上,必終自招盜而奪之,自取其辱也。\n象曰:解而姆,未當位也。 # 如與君子至誠以交,則必無擋,故小人如能介乎中,則示人其交必不誠至也。\n象曰:君子有解,小人退也。 # 君子能解退小人,則君子之道方行,故能行君子之道,乃因能去小人也。\n反招凶。\n象曰:公用射隼以解悖也 # 至解之終仍未解,必害之大且堅,故須強硬如射隼以解悖亂,天下可治也。\n"},{"id":60,"href":"/zh/docs/culture/%E5%A4%A9%E7%BA%AA/%E4%BA%BA%E9%97%B4%E9%81%93/%E4%B8%8B%E7%BB%8F/39%E6%B0%B4%E5%B1%B1%E8%B9%87/","title":"39水山蹇","section":"下经","content":" 39水山蹇 # 此卦鍾離末將收楚,卜得之,乃知身不王矣。\n圖中:\n日當天,旗一面上有使字,鼓五面中有一鹿、堠上千里字。\n飛雁啣草之課 背明向暗之象\n睽,乖違也,乖離必生難,蹇即難也,因時之乖違,必有蹇難,故蹇次睽為序卦。此卦坎上艮下,坎,險也,艮,止也,前有險而止不進,為蹇之意也。\n卦圖象解\n一、日當天:君明之象,吳姓。\n二、旗一面只有使字:出使,和平之象,使—二人一口,示單人也。\n三、鼓五面者:時為五更天,佔五面地,王為君王,一方之主,有多國或多島之象。四、中有一鹿:攜財福至,旅客帶財來狀,商人至也。\n五、堆子千里字:行西南,千里封候,候姓也。六、千里為重:有第二次方吉。\n人間道\n蹇:利西南,不利東北,利見大人,貞吉。\n西南為坤,坤体順,東北為艮,艮体險止,此言於蹇難之時,利處平易之地,不利止於危險,今人遇險而止,不知因險而止,乃更險也。\n彖曰:蹇,難也,險在前也。見險而能止,知矣哉! # 蹇有險阻之難也,因險在前,止而不進狀。處蹇之道,乃能見險而止,此知蹇難也,此止非真止也,乃不犯險而進之意。\n蹇:利西南,往得中也:不利東北,其道窮也。 # 蹇之時,唯利處平易之地,乃得中正之位,東北為山,險阻在前,犯險而進,道必窮。\n利見大人,往有功也,當位貞吉,以正邦也。蹇之時用大矣哉! # 蹇難之時,唯聖賢能濟天下之蹇,故用賢人能往求而成功,在位之人堅心執意如此,必能正家邦,此即知蹇之時任用賢人,適時而動,量險而行,使至正之大道能濟天下之蹇。今之為政者,不知蹇之時難,乃因不知古聖先哲之智慧也。\n象曰:山上有水,蹇;君子以反身修德。 # 山已為險阻,上又有水,故有上下險阻狀,為蹇,君子觀蹇象,知此時乃反求於己修身進德,故必省自身,以待時之至也。\n初六:往蹇,來譽。 # 此蹇之初,以陰柔無助而求進,蹇之甚也, 是知止而不進,乃因知進退而成譽也。\n六二:王臣蹇蹇,匪躬之故。 # 二爻為陰位,陰居之為正位,乃意為中正之臣,又與上五君同德而受信任,此即王臣, 今處蹇難時,即令中正之人,以陰柔之質,不能勝任,故有蹇中之蹇狀,因為中正之人,其所為必忠於君,非為私己,故即令不勝,亦因\n九三:往蹇,來反。 # 以剛居下卦之上,處蹇時,下位之人皆柔, 依附於上狀。欲再上進,又逄陰柔,無法相濟, 故有來反,即還歸也,反回其所。\n六四:往蹇,來連。 # 蹇之時以陰柔而往入坎險之深處為往蹇, 如能與同志之人,使合眾而附之,此即来連, 乃真得處蹇之道也。\n九五:大蹇,朋來。 # 君處蹇難之時,必天下處難也,故名大蹇, 此時如能得朋來助,且須為陽剛中正之才來相輔方有效。\n上六:往蹇,來碩,吉,利見大人。 # 六以陰柔居蹇之極處,蹇之極有將離蹇之態,如以陰柔必不得出,須有陽剛之助,以寛裕之大量,見有德之人,方可為吉。\n象曰:往蹇來譽,宜待也。 # 於蹇之初,進必愈蹇,宜見幾而止,待時而進,不可燥進。\n其能忠而可嘉勉。\n象曰:王臣蹇蹇,終无尤也。 # 中正之臣所為必為其君,不為私,故於蹇難時,雖未成功,但終無過也。\n象曰:往蹇來反,内喜之也。 # 下之陰柔無法獨立,必附於九三之陽剛。故吾人知,於處蹇之時,必得下之心可以求安, 此求內而喜之也。\n象曰:往蹇來連,當位實也。 # 處蹇,居於上位,能不與下往而眾來,乃得眾志也,能得眾志之人,必始之於誠實待下方可得也。\n象曰:大蹇朋來,以中節也。 # 大蹇之時,須陽剛中正之才,如臣之才不足濟蹇,只守於節義,不能濟世也,今之政客, 多屬此類,不知己之才不濟,而力進,徒顯其無能也。\n象曰:往蹇來碩,志在内也,利見大人,以從貴也。 # 六位陰柔又處蹇之極,能近陽剛中正之君,來求自濟,此以從貴之義,吉也。\n"},{"id":61,"href":"/zh/docs/culture/%E5%A4%A9%E7%BA%AA/%E4%BA%BA%E9%97%B4%E9%81%93/%E4%B8%8B%E7%BB%8F/38%E7%81%AB%E6%BE%A4%E7%9D%BD/","title":"38火澤睽","section":"下经","content":" 38火澤睽 # 此卦武則天聘尚賈至精魅成,卜此除之。\n圖中:\n人執斧在手,文書半破,牛鼠,桃開鬥掩,雁飛鳴。\n猛虎陷井之課 二女同舟之象\n家之道到窮途,則生乖違離散,故睽卦為之序。此卦上火下澤,二體相違,此睽之義,二女三女同居一室,但所歸各異,乃因志之不同也。\n卦圖象解\n一、人執斧在手:求快也,行刑之人,執法之人,有權柄也。二、文書半破:無望象,毁約也。\n三、牛鼠:秋冬吉,春夏凶。四、桃開:逃開也。\n五、門掩:牢獄之災,閉也,隔也。\n六、雁飛鳴:悲也,孤單也,報凶訊也。\n人間道\n睽:小事吉。\n睽違之道於小之事,可言吉。如與不良之人乖離,與不正之事違背也。\n象曰:睽,火動而上,澤動而下,二女同居,其志不同行。 # 睽,以卦才言火在上而動,澤在下位,此二物性反,其義猶二女三女其本志同而共處,及長所歸各異,其志不同也。\n説而麗乎明,柔進而上行,得中而應乎剛,是以小事吉。 # 內悦順而又應乎外明,麗明居上,以柔求進,又能得中正之道相應,處睽之時雖無法去天下之睽,但於小處則吉矣。\n天地睽而其事同也,男女睽而其志通也,萬物睽而其事類也,睽之時用大矣哉! # 天上地下雖睽然天陽下降地陰上升而成萬物,其事本同也。男女之質不同,但互相求之志卻同也。天下萬物不同是暌,然皆稟天地之氣而成,則相同也。是故物雖異而其本同,聖人用睽之道,能令天下不同之群眾,合而為一,故其事大矣哉。\n象曰:上火下澤,睽,君子以同而異。 # 火在上,澤在下,二物之性相違,為睽象,君子觀睽而知於世俗所同者,有時須獨異於世人,中庸曰和而不流,即義此也。\n初九:悔亡,喪馬勿逐,自復;見惡人,无咎。 # 卦之初,即睽之始時,於睽時又剛動於下, 其悔可知,於此時因不能行而喪其馬如勿逐則馬自復回。因睽時小人必眾,若棄之,則必天下而仇己乎?就會喪失坤德之含弘功能,乃致凶地。不但無法使之化善,更遑論能合之,故有必見惡人之議,所以自古以來能化姦邪為善良,去仇敵而為臣民者,皆因不拒絶見之。\n九二:遇主于巷,無咎。 # 睽違之時乃陰陽不合,且剛戾相對,即令為陽剛之中臣,當以委曲而婉轉之善道將就於主,期使之合,絶非求附君主而屈道相向也。\n六三:見輿曳,其牛掣,其人天且劓,无初有終。 # 三爻位為下卦之上,本離下卦而求進於上,今逢四之剛為阻,而下二爻之陽剛於後, 處此之時又逢乖違不和,猶牛車之行具,牛受掣阻於前,後有車輿在後,如以此狀況,又求力進,則必重傷於上,宜剛守中正之道與其對\n九四:睽孤,遇元夫,交孚,厲无咎。\n四爻近君位,君不與應,乃睽乖之時,故為孤,唯求與同德之人相合,且至誠相與交往, 能如此,雖處危,但仍可無咎也。\n六五:悔亡,厥宗噬膚,往何咎。 # 陰柔又居尊位,於睽時,其危可知,然有九二陽剛之賢相輔,可以悔亡,且陽剛之賢又能成黨,且深入之,則往而有慶也,如劉襌之庸,有孔明之輔,乃成中興之態勢。\n上九:暌孤,見豕負塗,載鬼一車, 先張之弧,後説之弧,匪寇婚媾,往遇雨則吉。 # 睽極之時,必反復正道,猶人見污濁之豕, 且全身爛泥,又車載一群惡鬼,心厭惡之乃欲張弓射之,後思之如動必反,而弗射,化仇寇為婚媾,陰陽交合而成雨,故始因疑而睽,睽至極因不疑而合,猶天地陰陽上為雨,往而遇之,則吉也。\n象曰:見惡人,以辟咎也? # 於睽時人情乖違,為求和合,若因其惡人而避之,則生眾仇於君子,故見惡人乃為避免怨咎之悔也。\n象曰:遇主于巷,未失道也。 # 此意即當以至誠以動君心,使明義理而能求相合,不可屈道迎逢,乃真未失道。今之小人明知不正,然於乖違,之時,只知迎合奉承, 為求己利,枉顧正道,故因時局而戀志之人乃真小人也。\n應,待睽終極之時必合,方為善處之道。\n象曰:見輿曳,位不當也,无初有終,遇剛也。 # 見車牛之負掣後拖曳而行,知位所不當, 聖人知理順行,知時而守,待遇剛而能合。\n象曰:交孚无咎,志行也。 # 睽乖之時,上下至誠相交,不止無咎,且其志必可行,故救睽之道,唯君子以陽剛之才, 至誠相輔,何所不能濟也。\n象曰:厥宗噬膚,往有慶也。 # 人君之才不足,如知信任賢輔,使其道能深入於己,亦可為也。今人不知己之才不足, 以為博士就是好的,不但不能成事,且造成乖違之勢愈烈,果必凶。\n象曰:遇雨之吉,群疑亡也。 # 雨之成,乃因陰陽之和,故所以合和能成, 乃因疑慮盡消亡也。\n"},{"id":62,"href":"/zh/docs/culture/%E5%A4%A9%E7%BA%AA/%E4%BA%BA%E9%97%B4%E9%81%93/%E4%B8%8B%E7%BB%8F/37%E9%A2%A8%E7%81%AB%E5%AE%B6%E4%BA%BA/","title":"37風火家人","section":"下经","content":" 37風火家人 # 此卦董永喪父賣身,卜之。後感得仙女為妻。\n圖中:\n一人張弓,一帶在水邊,雲中文書, 貴人拜綬,婦人攜手。三點。\n入海求珠之課 開花結子之象\n人之傷於外而必後返於內,故家人次明夷為序卦,此卦專論家內之道,父子、夫婦、長幼尊卑之倫理與恩義的家人之道。外風內火,乃風自火出,火愈熾則風生,有自內而出之象,故處家之道,有內明外順象。聖人之謂人能立於家,則必能施於國,以至天下大治。故治天下猶治家也。\n卦圖象解\n一、一人張弓:暗算,破壞也。\n二、一帶在水邊:事滯也,棄官也。三、雲中文書:突而來之喜訊也。四、貴人拜受:綬命也。\n五、婦人攜手:家和之象,此卦卜婚姻大吉。\n人間道\n家人:利女貞。\n家人之道,利在女正且堅,女能正,則家道正矣。\n彖曰:家人,女正位乎内,男正位乎外,男女正,天地之大義也。 # 家人之道,正位且堅心,男主外,女主內,其有尊卑有道,乃合乎天地陰陽之道。\n家人有嚴君焉,父母之謂也。 # 父母為一家之君長,無尊嚴則無人孝,無大小,則倫理廢,故君嚴則家正。\n父父子子,兄兄弟弟,夫夫婦婦,而家道正,正家而天下定矣。 # 父母兄弟夫妻各適其位,則家道正矣。能治家道,其道亦可推而治天下。\n象曰:風自火出,家人。君子以言有物,而行有恆。 # 君子觀風自火出之象,知正身之道,皆由內出,故言必有物,行必有恆,身正而能治家。\n初九:閑有家,悔亡。 # 家人道之始為初九,须以陽剛立家道,如無法則度量,則終至人情流放,必悔。\n象曰:閑有家,志未變也。 # 閑即預防之法則也,閑必於始初而立。在正志未流散之前,變動而法規之,因始立規之時,必不傷恩,不失義,如於志變後再思治, 則多有傷,必有悔。\n六二:无攸遂,在中饋,貞吉。 # 常人處家,唯骨肉親情,故常以情勝禮, 以恩去義,偏私之人多矣。獨有能剛之人不以私愛而不顧理。今以陰柔之人治家,必無可為, 唯婦人之道可以柔順,如能處正,居中協調, 則必能吉。\n九三:家人嗃嗃,悔厲吉,婦子嘻嘻,終吝。 # 嗃嗃乃嚴厲約束意,家人之道如約束過嚴,必有悔,然能令人心敬畏,也比讓婦人之放肆,喜樂無節,終至家敗而來得好。同理, 人能剛立,必知忠義,喜愛玩樂必不能正倫理, 知恩義也。\n六四:富家,大吉。 # 能有其富,於家道大吉,保有富之人,而能保其家,此吉之大也。\n九五:王假有家,勿恤,吉。 # 能剛又處君位,位尊而中正,又能順應內, 此治家之至正至善。是故修身來齊家,家家能正,則天下大治,此不須憂勞而天下自治矣。\n上九:有孚威如,終吉。 # 此言治家之道,非至誠之心不能也。己能守其规如常則人自化,如己不能守,況於他人乎?家之患必在禮法不足,下犯上,上凌下。如能守正禮法,又有威嚴,則必能吉。\n象曰:六二之吉,順以巽也。 # 今以陰柔居中,能順從而動其心,此為婦人之正道。\n象曰:家人嗃嗃,未失也;婦子嘻嘻,失家節也。 # 雖過嚴厲傷恩,但猶未失家人之道。但令婦人喜節無度,失家之道,必亂矣。\n象曰:富家大吉,順在位也。 # 以巽順之道而能保其家富,此家之大吉也。\n象曰:王假有家,交相愛也。 # 尊位又能有家之道,不但能使其順從而已,必使其能從內心而化合。丈夫能愛內助, 婦能愛其威治於家,此交相愛,為家之至道。\n象曰:威如之吉,反身之謂也。 # 此即言治家之道,以正身為本,當先嚴其身,後有威望於人,則人能服。此反身之意。\n"},{"id":63,"href":"/zh/docs/culture/%E5%A4%A9%E7%BA%AA/%E4%BA%BA%E9%97%B4%E9%81%93/%E4%B8%8B%E7%BB%8F/36%E5%9C%B0%E7%81%AB%E6%98%8E%E5%A4%B7/","title":"36地火明夷","section":"下经","content":" 36地火明夷 # 此卦是文王囚姜里,見子不至,卜得知,果子歿也。\n圖中:\n婦人在井中,虎在井上,錢破,人逐鹿, 堠上有工尺,珠聚。\n鳳凰重翼之課 出明入暗之象\n物進不已,必有所傷,故明夷為序卦,夷者,傷也,此卦坤上離下,乃明入地中之象,為晉之反卦,故義亦反,晉為明且盛,上明君下群賢並進之時。明夷乃暗君在上,明者見傷之時, 日入於地中象。\n卦圖象解\n一、婦人在井中:女人招災,或頸部災,家內有災。二、虎在外:外人虎視眈眈。\n三、錢缺:財傷也。\n四、人逐鹿:財失也,爭相為祿,逐鹿中原。五、堠在人鹿中:猴作梗也。\n六、「:尺也,工匠獨有,匠,將也,武官人。七、堠:不動也,尊位也,姓侯,肖猴。\n八、鹿回頭:回祿也,火災。九、人手上木枝:休象。\n見此課,必傷且凶矣。\n人間道\n明夷:利艱貞。\n君子處明夷之時,須知時之艱難,必求不失正道,方為君子。小人則阿諛奉承,唯利是圖, 順附勢而為,不顧正道。\n彖曰:明入地中,明夷,内文明而外柔順,以蒙大難,文王以之。\n君子處明夷之時,知明者必見傷,故求內明外順之態勢,應對大難之時,如此可內不失明, 外又可遠禍患。易之妙即在此,卦之德本亦卦之解也。\n利艱貞,晦其明也,内難而能正其志.箕子以之。 # 處艱難時,能知晦藏其明,不知藏晦,必生禍患,不能守正,必非賢人。\n象曰:明入地中,明夷,君子以莅眾,用晦而明。 # 君子觀明入地中,明夷之道,乃知不極其明察而用晦之道,於莅眾之時,如此必能容物和眾,眾親而安,猶水至清則無魚,人至明則無徒亦然。\n初九:明夷于飛,垂其翼。君子于行,三日不食,有攸往,主人有言。 # 明夷之初,乃見傷之始,暗君在上,陽剛之人不得上進,猶鳥飛而傷翼,小人害君子皆害其所以行。君子之人能見事於微末,始見傷, 則避之,即令困窮亦退避之,取適之位而往, 絶不以世俗之見而遲疑所行也。常人無法見傷於始,不知退避,至傷於己時,已不能退也。\n象曰:君子于行,義不食也。 # 義之所至,即令困窮,亦不所為不正,故君子能安之於苦悶。\n六二:明夷,夷于左股,用拯馬壯, 吉。 # 六二乃陰居陰位,得中正之德,順時知處, 乃至善也。但處明夷之時,亦不免有所損傷, 但須如馬之壯,可獲免傷而吉,如自處之道不壯,則傷之深矣。\n九三:明夷於南狩,得其大首,不可疾貞。 # 因上卦暗極,而又正居明之極,明即將與暗衝突之時也。居此,君子必前進除害,以獲之魁首,為要務,至於舊疾敗俗,必不能立刻改變,須知渐進之道,教其渐改,如求劇革必令不安,非上策也。\n象曰:六二之吉,順以則也。 # 順處且有原則,合於中正之道,故必能處明傷之時,而能保吉也。\n象曰:南狩之志,乃大得也。 # 用下位之明,來除上位之暗,唯求目的在除害而已,如不然則為背亂之事也。\n六四:入于左腹,獲明夷之心,于出門庭。 # 此爻意陰邪之小人,處近君之位,乃以柔順附於君主時,為保其固交,乃以柔邪之法, 隱敝之交而結於上。而君本陰邪之人,必受蠱惑其心,而行之於外。故君必求明,守正道, 而不為小人所煽動,所利用也,即此。\n六五:箕子之明夷,利貞。 # 此爻言暗傷至極,乃謂明見傷之主,若於此時顯示其明,必見傷害。要如箕子之知晦藏, 則可免於難,若不知晦藏其明,則害大矣。\n上六:不明晦,初登于天,後入于地。 # 明之至高而又傷於此時,本可遠照,今傷於明,必不明反昏悔,反失其道也。\n象曰:入于左腹,獲心意也。 # 此以邪惡之陰道惑於君心,而逞其私慾, 君為小人,必始終不悟也。\n象曰:箕子之貞,明不可息也。 # 箕子知晦藏其明於暗傷之時,且能不失意志,同與陰暗之人合污,其明必能不滅,小人若受禍患所逼,必失其節守也。\n象曰:初登於天,照四國也,後入于地,失則也。 # 始登於天,能明照四方,今傷於昏暗,反失其道。\n"},{"id":64,"href":"/zh/docs/culture/%E5%A4%A9%E7%BA%AA/%E4%BA%BA%E9%97%B4%E9%81%93/%E4%B8%8B%E7%BB%8F/35%E7%81%AB%E5%9C%B0%E6%99%89/","title":"35火地晉","section":"下经","content":" 35火地晉 # 此卦昔司馬進策,卜得之,後果為丞相。\n圖中:\n文字破,官人掩面,毬在泥上,雞啣秤, 枯木生花,鹿啣書,一堆金寶。\n龍劍入匣之課 以臣遇君之象\n繼壯盛之後,則必進,晉,進也,明出於地,升而明,故晉,此論進之道,離,中虛為明, 乃明出於地上也。\n卦圖象解\n一、文字破:官事不成,退位象。二、官人掩面:無顏見人,悲象。三、毬在泥上:所求必陷險也。\n四、雞啣秤:雞,酉也,肖雞人也,落日之象。秤:示公平,稱心也。五、枯木生花:晚發也。\n六、鹿啣書:祿與命到也。\n七、一堆金寶:財祿豐收之象,占疾厄,則有焚金紙之象。主喪服,二人去一也。八、人立水邊:恐泣之象,主喪服。\n九、三點在石上:心象,示人心在石上,不動念,堅心也。\n人間道\n晉:康侯用錫馬蕃庶,晝日三接。\n有能治安之諸侯,名康侯,其因能明,下體順附,承王之象,則見眾多之馬駕,有重賜, 乃寵遇之至也,此上明下順,乃進盛之時。\n彖曰:晉,進也。明出地上,順而麗乎大明,柔進而上行,是以康侯用錫馬蕃庶,晝日三接也。 # 晉乃明進而盛,此外明內順,故能包明至大明,如順德之良臣,附麗於明主也。柔居君位, 能明而順,上附於君,下順於民,故為康侯,此為諸侯之道也。\n象曰:明出地上,晉,君子以自昭明德。 # 君子觀明出地上,近明為晉,故知惟昭明德於已,求己之明且有德,為晉之道。\n初六:晉如摧如,貞吉,罔孚,裕无咎。 # 初爻乃晉之始時,始進而求快升,必不遂其意,因上位未能遽信,必當自守安位,寬裕不求,以待上位之信方可。小人始進而求上信, 則必傷於義,皆招咎悔。此君子方知進退之道。\n六二:晉如愁如,貞吉。受茲介福, 于其王母。 # 進退之間守正道,當吉,二爻之位雖無援, 因能守正,日久彰顯,自得上六五君位之人相應,上位自當求之,因德同也。\n六三:眾允,悔亡。 # 柔居剛位,又不中正,此乃居不適位時, 必有悔咎,惟居此時,能明謀從眾,順應天心, 吉。\n九四:晉如鼫鼠,貞厲。 # 陽剛居陰位,非適其位而居之,乃貪據其位,貪而畏人者,範鼠也,近君之位又不適, 下位又求上進,居此時生畏忌之心,必生危矣。\n六五:悔亡,失得勿恤,往吉,无不利。 # 柔居尊位,乃不正位,本凶。但用大明之德而上下皆附順,故反吉。因下之順附,必能盡誠委任眾人之才,以濟天下。明君不患其不能明照,患其用明之過於察察,有失委任之道, 自任其明而以私意偏任,天下必無法為公。\n象曰:晉如摧如,獨行正也。裕无咎,未受命也。 # 進退之間,惟君子能獨行正道,唯義之所趨,方可出仕,未受命時,皆寬裕無求。若能信於上,忠職守,卻反失去職務,則君子一日不居,必退而遠之。\n象曰:受茲介福,以中正也。 # 雖居下位之人,能永持中正之道,久必有亨。更加有明君在上,雖位遠,但必因同德受福。\n象曰:眾允之志,上行也。 # 能順從眾志,居下位之上,又上從明君, 合於眾志。\n象曰:鼫鼠貞厲,位不當也。 # 不正又居高位,時懷貪而懼失之心,堅守此不放,招危險也。\n象曰:失得勿恤,往有慶也。 # 有明明之君,下又上附順,能推誠委任, 必能成天下之大功,往必有福也。\n上九:晉其角,維用伐邑,厲吉, 无咎,貞咎。 # 陽剛又居最上,如角之象,此言進之極, 過剛則易失於躁急,本剛又急於求進,必有失也。此惟有用於治內(伐邑〕可以無患。但如用其治外,反凶。觀人之自治,能剛之人守道必固,求進燥急之人必遷善益速,唯須合中正之道,可無咎。\n象曰:維用伐邑,道未光也。 # 盡善之道,能安內攘外,今維用伐邑,能治內不能治外,其道未光大也。\n"},{"id":65,"href":"/zh/docs/culture/%E5%A4%A9%E7%BA%AA/%E4%BA%BA%E9%97%B4%E9%81%93/%E4%B8%8B%E7%BB%8F/34%E9%9B%B7%E5%A4%A9%E5%A4%A7%E5%A3%AF/","title":"34雷天大壯","section":"下经","content":" 34雷天大壯 # 此卦唐玄宗避安祿山亂,卜得之,乃知不久亨通也。\n圖中:\n北斗星,天神執劍,一官人燒香拜, 一猴,一兔,一犬。\n羝羊觸藩之課 先曲後順之象\n君子之退而伸道,多數君子之退且聚合,而成大壯之勢,壯有進且盛義,陽剛而震動此以剛而動,乃大壯之象,猶雷威震在天上也。\n卦圖象解\n一、北斗星:帝君,老闆,主事之人。二、天神執劍:民意歸向,人心也。\n三、一官人燒香拜:拜求於天,而不求人,凶。\n四、一猴、一兔、一犬:侯,劉,狄姓,在野之人。五、猴回頭:回吼。\n六、兔回頭:回吐。七、羊回頭:回陽。\n此卦示意過剛必招凶,而猴、羊、兔為三貴人,和中近易為處之道。\n人間道\n大壯:利,貞。\n大壯之道利在正,理正而勢正,如不得正而壯,只強猛無理而已,非君子之壯。\n彖曰:大壯,大者壯也,剛以動,故壯。\n大且壯為大壯,乾之至剛而動,外動內剛狀,名壯。\n大壯利貞,大者正也。正大而天地之情可見矣。 # 天地之道可運行而長久,乃至大至正始然也,此道之至大能如是。\n象曰:雷在天上,大壯,君子以非禮弗履。 # 君子觀雷震威在天,此之壯大,而行大壯之道,知唯克己復禮最大。能自勝之人為天地之最壯者,能赴湯蹈火,此唯武夫之勇也。君子能和而不流,大立而不倚。\n初九:壯於趾,征凶,有孚。 # 初爻在下,而陽剛居之,乃初進而用壯, 必凶,吾人居上用壯,猶須正方可行,初進而剛壯,必招凶辱。\n象曰:壯於趾,其孚窮也。 # 在下位而用壯,即令行可信,亦必招窮困阻滯也。\n九二:貞吉。 # 二位屬陰位,陽剛居之,乃示人剛柔得中也,人能識得時義之輕重,則必吉。\n九三:小人用壯,君子用罔。貞厲, 羝羊觸藩,羸其角。 # 小人尚力,只用其壯勇,而君子用罔,因其志剛,無視於非正之事,無所忌憚也。萬物莫不用其壯,猶羊之壯於首角,一遇藩籬,亦受困也,故用壯,不可強力進,須持之正道, 無忌憚於邪吝,方為真罔。\n九四:貞吉,悔亡,藩決不羸,壯于大輿之複。 # 時方道之長盛時,如有小失,則必害進之勢,猶大車其輪軸必壯,乃利於行,如輪軸不強,以致大車之不行也,有如藩籬之決開而不復其困也。\n六五:喪羊于易,无悔。 # 羊性喜群行又互觸其角,猶諸陽並行,如以力制,則必難勝有悔,唯其平和得之,則無所用其剛,使其壯失於和易之道,可以無悔。\n上六:羝羊觸藩,不能退,不能遂, 无攸利,艱則吉。 # 陰柔又處壯極,猶羊角之觸藩,進退不得, 必無所往也,陰柔處壯時,一遇艱困,必不能守,往即凶也。吾人須居艱困時而處柔,必吉。此壯之極,須變義也。\n象曰:不能退,不能遂,不詳也。 # 象曰:九二貞吉,以中也。\n能得中正,而堅持守之,也吉乎,此意中而不失正。今人中立而為己,已失正道,猶牆頭之草,可嘆也。\n象曰:小人用壯,君子罔也。 # 小人用其強壯之武力,終困,君子志氣剛強,無視邪惡,無所忌憚。\n象曰:藩決不羸,尚往也。 # 四位未至極,以當盛之陽,又用壯以進, 無能阻之,即令藩籬隔而不困其力也。\n象曰:喪羊于易,位不當也。 # 柔居尊位,位不當也,猶人君之勢不足, 下又壯進,才有所謂『治壯之道』,治壯之道不可用剛,須以平易近人待之,可化壯也。\n艱則吉,咎不長也。 # 此因自處不詳不慎,以致進退不能也。以柔處艱時,即有咎,亦不長久。\n"},{"id":66,"href":"/zh/docs/culture/%E5%A4%A9%E7%BA%AA/%E4%BA%BA%E9%97%B4%E9%81%93/%E4%B8%8B%E7%BB%8F/33%E5%A4%A9%E5%B1%B1%E9%81%AF/","title":"33天山遯","section":"下经","content":" 33天山遯 # 此孟嘗君進白狐裘,夜渡函谷關,卜得知, 果脱身也。\n圖中:\n一山,一水,酒旗上文字,官人踏龜,月半雲中,幞頭上樹,樹下人獨酌。\n豹隱南山之課 守道去惡之象\n恆者,久也,久不動則有去意,遯乃退也,故遯所以繼恆也,此卦天下有山,天陽向上, 物雖上陵但止而不進,以陰陽之道論,二陰居下有上進之勢,陽將消退,故此時乃論小人渐盛, 君子退避之時,此遯之義也。\n卦圖象解 # 一、一山:阻也。二、一水:險也。\n三、酒旗上文字:酒為忘憂之物,文在旗上,受人提攜也。四、官人踏龜:歸,鬼也;玄武自來,水上求財。\n五、幞頭上樹:求去辭官也。\n六、月在雲中:主半清半濁象,事未明晰。七、人在水邊:等待平安狀,蒞臨也。\n八、樹下人獨酌:休也,無事也,萌生退意狀。\n人間道\n遞:亨,小利貞。\n遯,乃君子潛藏之時,君子退而伸其道,人退道不屈,則可亨,雖無大力,但仍有小吉, 因知時避退,此善之道也。\n彖曰:遯亨,遯而亨也。剛當位而應,與時行也。 # 君子處遯之道,知退生吉,退所以為伸道。故雖退之時,君子處之,未必真遯,當隨時消息,有可以致力,當不避之,以扶持其道,並非於遯時,藏而不為也。\n小利貞,浸而長也,遯之時義大矣哉! # 小人之道長時,不可求大功,但利於小功,君子之人能察於其微,始即深以戒之,退而伸道,不使道消,此處遯之時意義之大處也。\n象曰:天下有山,遯;君子以遠小人,不惡而嚴。 # 山在天下,由下起而上止,天上進分之相違,此遯之象,因違和而須遯。君子遠小人之道, 若以惡聲厲色,只增其怨忿而已,唯用莊嚴威色,使其敬畏,小人自然遠矣。\n初六:遯尾,厲,勿用有攸往。 # 因遯本即退去,故初爻為往退之初,反為其尾,此時以柔處遯,不可往,往則有災。\n六二:執之用黃牛之革,莫之勝説。 # 二與五位相應,居正而相親合,其交之堅, 若牛革之繋,不可言語。\n九三:係遯,有疾厲,畜臣妾,吉。 # 遯退之道,其貴在速及遠,如有事累,則不速,於遯時則有危也。此爻即言,人之親, 若以女子小人而言,其皆懷恩而不知義,親愛之則忠,犯錯而嚴厲時,則生暗害,此小人之道。然君子之道非如是,待之以正,即令有拖累亦不有災,如君王有危而不棄黎民,雖危亦\n九四:好遯,君子吉,小人否。 # 君子之人亦有所好,處退之時,必去而不疑,以道來制欲,故為吉,而小人反之,其不能以義處之,唯私慾之所好,即令身陷亦不能自制,故於小人則否。故君子必易出難進,小人則易進難出。\n九五:嘉遯,貞吉。 # 君王處得中道,動靜間必以時,故嘉美, 終吉。今人卻動靜皆以私慾,表裡不一,不以中正之道處,知進不知退,此凶也。\n上九:肥遯,无不利。 # 此退之極致,退求遠且無累於他人,此退之有餘,無往不利矣。今人即令退,亦追求有所牽累他人,其動不正,唯自苦而已,小人自招之累也。\n象曰:遯尾之厲,不往何災也。 # 退處之時,居於尾者,此危也,唯晦藏於下或末端,可得吉,因往有晦不若不往,故晦藏可免災也。\n象曰:執用黃牛,固志也。 # 二與五位用中正之道相結,且堅心其志, 猶牛革之堅也。\n可無咎,古之前例多矣。\n象曰:係遯之厲,有疾憊也,畜臣妾吉,不可大事也。 # 係遯之道,於養臣妾親暱人時可吉,但不可以擔當大事,待人取中正之道,不以私心而生偏,取諸於公,則可行大事矣。\n象曰:君子好遯,小人否也。 # 君子知時而退不疑,小人則無法克制私慾,以至於凶也。\n象曰:嘉遯貞吉,以正志也。 # 內心正則動必正,此退之美也,今人不以退為美,只動之以欲,因志不正也。\n象曰:肥遯无不利,无所疑也。 # 退之求其遠,且無所拖累,此退之道至極, 必無不利。且剛決而不疑,君子之遯道,雖退, 因得道,卻能無往不利。\n"},{"id":67,"href":"/zh/docs/culture/%E5%A4%A9%E7%BA%AA/%E4%BA%BA%E9%97%B4%E9%81%93/%E4%B8%8B%E7%BB%8F/32%E9%9B%B7%E9%A2%A8%E6%81%86/","title":"32雷風恆","section":"下经","content":" 32雷風恆 # 此卦宋王奪韓朋妻,卜,得之也。\n圖中:\n日在雲,鳳啣書,官人行路,道士手指門,鼠下兩口,牆擋路。\n日月常明之課 四時不沒之象\n恆,乃論夫妻之道,終身不變者,前『咸』為少女少男之感,今『恆』乃長男長女之往, 男尊在上,女卑在下,此夫婦居室之道,男動於外,女順於內,此人倫之常,此恆久之道。\n卦圖象解\n一、日在雲中:昏暗之象,君主不明象。二 、鳳啣書:喜訊至,金榜题名也。\n三、官人行路:將至也,貴人至。官人,倌人,丈夫也,人傍為倌也。四、道士手指鬥:入空門也,閃也。\n五、鼠下兩口:陰謀在後之象;兩口,回也,呂姓也。六、時機:在春末,日為春之末尾也。\n此即持之以恆,則有撥雲見日之喜也。 # 人間道\n恆:亨,无咎,利貞,利有攸往。\n恆為常久之道,人能恆之於善,此道可恆,君子之道。小人之道,乃恆於惡,已失可恆之道,故恆之道能常久,乃因所恆為正,恆不正,招凶也。\n彖曰:恆,久也。剛上而柔下,雷風相與,巽而動,剛柔皆應,恆。 # 恆久之道,在於上剛下柔,君剛臣柔,夫剛婦柔,雷震風興,風中有雷,相互交應,助長其勢,天地之間能恆久不已,乃因能知順動而已。\n恆,亨,无咎,利貞,久於其道也。\n恆之道,可以令亨通,且無災,但須注意所恆為正,吉也,所恆不正,則反招辱。\n天地之道,恆久而不已也。 # 人如能恆持可恆之道,則合於天地之理。必可長久。\n利有攸往,終則有始也。 # 萬物之動,必有始終,今動而能恆,其間變化必多,能知隨時變易其外,神在內不變,此得易之神也。\n日月得天而能久照,四時變化而能久成,聖人久於其道而天下化成, 觀其所恆,而天地萬物之情可見矣。 # 日月得天之道,能久照不已,四時往来生成萬物,也因得天,故常久不已,聖人用恆之道, 行之不已,能化成天下,成其美俗。觀天地之恆常,得天下常久之理,此聖人之道所以能常久也。常人孰能知。\n象曰:雷風,恆;君子以立不易方。 # 人才君子觀恆之象,以恆久之德性,持立於中道,永不易其立足之道。\n初六:浚恆,貞凶,无攸利。 # 初六以柔暗之人初進,只知守常卻不度勢而為,但求上之眷顧,又堅守此向,致凶之道也。因其心志在求上眷顧,必不能安居其位, 故凶也。\n九二:悔亡。 # 恆能居其正位,常道也。今陽居陰位,處非其位,雖中正之才,不知輕重之勢,造成有\n九三:不恆其德,或承之羞,貞吝。 # 得位乃可久,但於須持久時不知持之以恆,朝令夕改,人無所適從,反招羞辱矣。\n九四:田无禽。 # 田獵又無獲禽,徙勞無功也,九四陰位而陽居之,處非其所,恆又何益?人之動必有道, 持之以恆則有功,不得道,即久亦無功也。\n六五:恆其德,貞,婦人吉 ,夫子凶。 # 柔居剛位,又得九二之陽剛助應,此即居中正又有應乎中正,恆此以久,吉之道。婦人之道以順從為恆,此順從於婦人則吉,丈夫為剛陽,如以順從之道持恆,反致凶也。更何沉人君之道,君道切不以柔順為恆也。\n上六:振恆,凶。 # 此恆之極位,乃振恆,即守之不常也,居上而動無節宜,再以此為恆,凶至也。故吾人動之必以正理,不逾矩,動如失正,又不及復, 反持恆下去,果致凶矣。\n象曰:浚恆之凶,始求深也。 # 但求上之眷顧,不知度勢而行,安居其位, 失恆之道於始也。\n悔。人能知所輕重,易之義即此。\n象曰:九二悔亡,能久中也。 # 人能知恆久於中道,知所輕重,必可常久。\n象曰:不恆其德,无所容也。 # 不知恆之道的人,終至無處容身也。\n象曰:久非其位,安得禽也。 # 處不適位,雖久何益,猶徙獵而無功。\n象曰:婦人貞吉,從一而終也,夫子制義,從婦凶也。 # 柔而順從於婦人則吉,乃從一而終也,君王之義,從婦人之順從,招凶。\n象曰:振恆在上,大无功也。 # 居上之道,躁動不安,必無所成,此言持恆,知恆,用恆之重要性,人不知此即,令再大之勞亦無功也。\n"},{"id":68,"href":"/zh/docs/culture/%E5%A4%A9%E7%BA%AA/%E4%BA%BA%E9%97%B4%E9%81%93/%E4%B8%8B%E7%BB%8F/31%E6%BE%A4%E5%B1%B1%E5%92%B8/","title":"31澤山咸","section":"下经","content":" 31澤山咸 # 此卦漢王昭君,卜得之,知和番不回也。\n圖中:\n空中有一拳,錢寶一堆,貴人在山頂, 女人上山,一合子。\n山澤通氣之課 至誠感神之象\n易經上經乃論天地萬物之本,有天地後有萬物,聖人觀其間象,以巨視之角度來體察出天道。咸卦之始,為易經下經,其論夫婦人倫之始,較重於人道。咸之為卦,兌上艮下,乃少女少男之意,表示世上男女相感之深,莫過於少男少女,童稚之間無隔閡,人人皆以平常心處之, 男女相悦又無所求,互相以誠及悦相處對應,此時之感,即咸之深意。\n卦圖象解\n一、空中有一拳:貴人提拔,憑空而得。二、錢寶一堆:求財得財。\n三、貴人在山頂:孤立狀,即將下坡,退休狀。四、女人上山:夫妻吉,陰人卜之知走馬上任。五、合子:先成後破,因誠感方吉,物感必凶。\n人間道\n咸:亨,利,貞,取女吉。\n咸,感也,舉凡君臣上下,夫婦、父子等皆有感之道,人人能以少男少女之童悦相感以誠悦,即以平常心相待,則必有亨通之理。今人皆以媚説,上下之位處以邪僻,皆感之不正也。艮為止,澤為悦,乃外悦內止之意,以此之意近女,乃得正而吉也。\n彖曰:咸,感也,柔上而剛下,二氣感應以相與,止而説,男下女,是以亨利貞,取女吉也。 # 陰陽相交,乃男女相感之義同,男女之交,男以篤實至誠之心,女以喜悦之心相應,此男女之感於正道,故能亨通得正。\n天地感而萬物化生,聖人感人心而天下和平,觀其所感,而天地萬物之情可見矣。 # 感之道到極限時,聖人能以至誠之心感億兆人之心,使天下和平,其情之偉大可見,但須知感通之理,須以默而觀之,不可道破,此感方可久矣。\n象曰:山上有澤,咸;君子以虛受人。 # 澤在山上有渐潤通澈狀,此感之道。君子觀山澤通氣知須虛其中方能受人,凡人之心能中虛必可受,實則不能入矣。\n初六:咸其拇。 # 初與四相感,初之時感必未深,不能動人, 如拇指之動,不可進也,須識勢而為。\n六二:咸其腓,凶,居吉。 # 腓,足肚也,人之動,足必先行,足肚自動也。二位無法守道而妄求於上,故凶。進退之道乃安居其位,以待上求,此吉也。\n九三:咸其股,執其隨,往吝。 # 股在身之下,足之上,不能自主,隨身而動。九三陽剛之才,感於所動而隨其動,如此已失其正道,其動必羞也。\n九四:貞吉,悔亡,憧憧往來,朋從爾思。 # 人之所動皆因感也,九四在中,乃當心臓之位,為心感之主,感之正則吉,不正則凶。今人以私心感人,所感狹小也。天地萬物各有不同,事亦變化萬千,能殊途同歸,此感之極至,必以公正無私方可行。人之向往,必有屈求,人之來至,必因有信,此屈信相感,利之\n九五:咸其脢,无悔。 # 居尊位之人,必以至誠感於天下,脢,背肉也,其與心相背,目所不見狀,此意能背其私心,感於非所見而能悦者,以人君可以無悔。今人不知,皆感於所見而悦,所不見而仇視之, 此感不正,招自凶亡也。\n上六:咸其輔,頰,舌。 # 上為感之極位,陰柔居之,此即不能以至誠感物,唯求於口舌之間感人,乃小人女子之常態,必不能動於人,故云頰。\n象曰:咸其拇,志在外也。 # 感應之志雖動,但未大,不足以進。初志之感與四相應,故曰在外。\n象曰:雖凶,居吉,順不害也。 # 陰居二位,其得位中正,在咸之時,因質本柔,必戒以先動,求之必凶,守居不動吉。\n象曰:咸其股,亦不處也,志在隨人,所執下也。 # 人有陽剛之質,卻又不能自主,反隨於人而動,此所執者必卑賤甚也。\n所生處。\n象曰:貞吉悔亡,未感害也。憧憧往來,未光大也。 # 人能正則必吉,事皆以私,則生害也,互相往來以私心相感,此感之道狹,必未光大也。\n象曰:咸其脢,志未也。 # 人感於所不見而生戒,其必淺也,此私心之用也。\n象曰:咸其輔頰舌,滕口説也。 # 世間唯至誠能感人,如靠口舌説話之才, 感人必不能長久。\n"},{"id":69,"href":"/zh/docs/culture/%E5%A4%A9%E7%BA%AA/%E4%BA%BA%E9%97%B4%E9%81%93/%E4%B8%8A%E7%BB%8F/30%E9%9B%A2%E7%82%BA%E7%81%AB/","title":"30離為火","section":"上经","content":" 30離為火 # 此卦朱買臣被妻棄時,卜得之,知身必貴也。\n圖中:\n人在虎背上立,官人執箭在岸上立,水中有船,二人分立兩岸。\n飛禽在網之課 大明當天之象\n陷於險難之中,必有所附麗,理之自然,離者麗也。離為火,火體中虛而能明照,故能麗於物而明也。人之明,必經險難,大風大浪過後,其經驗之累積,而後能明,此猶可人。\n今人雖歷災險,但仍不能明,吾人教授易經,其目的即希望世人,不須付出血的代價,亦能明,此中虛之廣義。\n卦圖象解\n一、人在虎背上立:行危不懼;難下也。\n二、 一船在江心:口舌相爭,無所適從也。三、官人執箭岸上立:在位之人,欲殺之象。此卦夫妻破散成仇,兄弟分家各自為王之課。\n人間道\n離:利貞,亨,畜牝牛,吉。\n天下萬物莫不有所麗,其形即麗。人之所麗,必得貞正,得正可以亨通,人能附麗於正, 必能順於正道,從善如流為人之性,如牝牛之吉,牛之順道,由養而成,人之順德亦同,既附麗於正道,當有養以成其德也。\n彖曰:離,麗也。日月麗乎天,百穀草木麗乎土 。 # 附麗狀,如日月麗於天,百穀草木麗於土 ,天下萬物必有所麗,無麗之物,人必視何麗方正,則能亨通也。\n重明以麗乎正,乃化成天下。柔麗乎中正,故亨。是以畜牝牛吉也。 # 上下皆離故曰重明,乃意君臣皆有明之能,又處事中正,故可以教化天下成文明之俗也。人又能柔順於中正之明德如牝牛之順吉,乃更能將明德發乎光大,襯托明德之麗。\n象曰:明兩作離,大人以繼明照于四方。 # 此卦若云兩離,則只見明,而不知繼明之德。君子觀離明相繼之象,知本身之明德能濟天下,為使以明德中正永傳後世,則須有進一步繼明之能,能明於選擇何人能承繼,乃繼明之義。如堯之擇舜,漢之蕭規曹隨等即是繼明之用。\n初九:履錯然,敬之,无咎。 # 初爻陽居之,乃言初進有陽剛之才而進又躁動,卻因動之非宜,已失居下位之分而得咎, 但因為人才,只須知守義而戒慎之,則不至於有咎。\n六二:黃離,元吉。 # 以柔居中乃得正位,黃為中色,人能文明而中正,則美之至極也。又有六五文明之君以順其德,其能明如此,乃大善之吉也。\n九三:日昃之離,不鼓缶而歌,則大耋之嗟,凶。 # 易有八純卦,唯離為火於人事之義陳述最細。今九三吾人居下卦之終乃意前明將盡,後明當繼之時,人之有始,必有其終,此常道也。智慮通達之人,以順理為樂且常持之,如不能如此,則有將盡之悲,而日夜恐於死亡,故君子樂天,小人終日恐懼,此處易論生死之道也。\n象曰:履錯之敬,以辟咎也。 # 能知履動之躁進為錯而敬慎之,因居下位,但求避之可也,不致有災。吾人有陽剛之才,仍須有明之能,能剛不能明,則因妄動生災也。\n象曰:黃離元吉,得中道也。 # 能成文明乃由中之虛明,故元吉之因,是由其得中之道也。\n象曰:日昃之離,何可久也。 # 日已倾,不可久也,能明之人,能求人以繼其事,知退而修其身,則可長久。\n九四:突如、其來如、焚如、死如、棄如。 # 九四位,乃言繼明之初,承繼之意,但陽爻居之,有剛躁但不失中正之意,此非善繼者, 能知善繼之人,始繼必有謙讓之誠,知順承前賢之道。今突如其來,已失善繼,又剛勢凌主, 氣焰高張故焚如,必招禍害致死,故死如,以眾所棄之,故果為棄如。\n六五:出涕沱若,戚嗟若,吉。 # 出涕戚嗟,皆形容人憂懼至深。君位以陰柔之才居之,又受於陽剛之在左右,此居尊位之明,能知憂且畏,故能吉。今若自持己才之不足,全然不知懼,必不能得其吉也。\n上九:王用出征,有嘉。折首,獲匪其醜,无咎。 # 陽剛之才居離之終,有剛明至極之狀。此方式乃適用之於君王出征時,明則足以察邪惡,剛則能斷事,施以威行,如此則能去天下之邪惡,此有功也。世上事至刑不足以阻時, 必用征伐方法解決,此之時也。去天下之邪惡, 如明斷之極,則必無所宽宥,此時必取其魁首, 始作俑者方是,趕盡誅滅,必有暴殘之後悔也。\n象曰:突如其來如,无所容也。 # 諸君試想,人之凌其君,而不順所承繼之前賢,必眾棄,天下不容也。\n象曰:六五之士,離王公也。 # 居上位之吉,乃在明察事理,且以畏懼憂慮之心以持之,可以為吉也。\n象曰:王用出征,以正邦也。 # 君王用此德,則能治其邦國,此剛明至上之道也。\n"},{"id":70,"href":"/zh/docs/culture/%E5%A4%A9%E7%BA%AA/%E4%BA%BA%E9%97%B4%E9%81%93/%E4%B8%8A%E7%BB%8F/29%E5%9D%8E%E7%88%B2%E6%B0%B4/","title":"29坎爲水","section":"上经","content":" 29坎爲水 # 此卦唐玄宗避祿山,卜得之,果身出九重也。\n圖中:\n人在井中,人用繩引出,一牛一鼠,人身虎頭。\n船涉重灘之課 外虛中實之象\n大過之後,過至極必陷,坎者,陷也。重坎即重險,險中有險,此卦專論處險之道,如何用險等。\n卦圖象解 # 一、人在井中:官司,牢獄,陷入險地象。\n二、人用繩引出:責人出手相救狀。\n三、一牛:勞苦也,春夏凶,秋冬吉,亦肖牛人。\n四、一鼠:肖鼠人,陰謀暗害者,暗藏也。\n五、人身虎頭:有權威之人,貴人也。\n人間道 # 習坎:有孚,維心亨,行有尚。\n陽實在中,乃中有孚信,惟心誠正一,則能亨通,俗謂:至誠可以通金石,入水火,有何險難不可亨?故出險之道在於誠一而行,否則常居險中也。\n彖曰:習坎,重險也,水流而不盈,行險而不失其信。\n習有重覆之意,上下皆坎,即險中有險,水流而不盈,即動於險中仍未出險之時,吾人行險须不失其信,乃可吉。\n維心亨,乃以剛中也。行有尚,往有功也。\n以剛中之道,抱持至誠之內實,天下何所不通,若止而不行,則常居險中也,故坎險以能行為功。\n天險不可升也,地險山川丘陵也,王公設險以守其國。險之時用大矣哉!\n天之險乃因高不可升,地之險為山川丘陵,諸王公觀坎之象,知險不可陵越,故設高牆深地為險,以守其國,保其人民,此用險之時也。人間亦然,凡知人之學,分尊卑,分貴賤,區分小人與君子,皆為用於杜絶災殃,限隔上下,此都是體悟了險之用也。\n象曰:水洊至,習坎,君子以常德行,習敎事。\n水勢就下,由小而大,歷久不變,君子觀坎水之象,取其有常,不變其德性為真。如人之德行常變,則偽也。政治上發令行教,必使民熟於聞,人人皆從之,故有謂三令五申,即此意。\n初六:習坎,入于坎窞,凶。\n初以陰柔居坎險之下,因無援,處不當, 不能出險,唯益陷深而已,故凶。\n象曰:習坎入坎,失道凶也。\n由險中而加陷入險,乃失正道,加凶。\n九二:坎有險,求小得。\n二爻位,乃陽陷二陰之中,即至險而有險狀,但其本為剛中之才,故雖未出險,亦可自救,不至於愈險,故君子處艱險而能自得,皆因剛中而已。能剛則有才,能執中則動不失所宜。\n象曰:求小得,未出中也。\n此即雖有小得,雖不致於陷險,然亦未能出險狀。\n六三:來之坎坎,險且枕,入于坎富,勿用。\n六三位在坎險之時,以陰柔居而處不正, 走下則入險,向上則重險,故有進與退皆遇險, 居又有險,枕乃處不安狀,此時唯戒勿用方是。\n象曰:來之坎坎,終无功也。\n以陰柔又處不正,動則唯益險,人出險须有道,如失道,但為能求去,只益增困窮而己。\n六四:樽酒,簋貳,用缶,納約自牖,終无咎。\n此言大臣當險難之時,唯有至誠於君,不可停交往,而又能打開使君能明之心,則可保无災。人欲求上之篤信,只有求質實而已,如多禮儀而尚服飾,反凶。一樽之酒,二簋之飯, 以瓦缶為器,乃求質實之意。再處明之地以明君之心,如此方能入君心,即令艱險,亦可無咎。自古以來,只求剛直攻訐直言不諱之人, 多忤逆君心,而屬温厚明辨者,反而能入君心, 改變君主。\n象曰:樽酒簋貳,剛柔際也。\n但求實質,又剛中有柔,必平安。君臣之交能長久,不外乎誠實而已。\n九五:坎不盈,衹。既平,无咎。\n此即九五之尊,有剛中之才,可以濟險, 但下無助,獨依君之力無法濟天下之險,則有咎,必待盈平,方可無咎。\n象曰:坎不盈,中未大也。\n剛中之才得居君位,時天下險難,但未至全險,其未能平險,乃因君臣無法協力相濟之故,故此君道未能救濟天下,乃因無臣,故曰不稱其位。\n上六:係用徽纆,寘于叢棘,三歲不得,凶。\n此意陰柔而深陷險中,聖人以牢獄比喻, 縛以徽纏,囚置於叢棘之中,其不能出,至少三年不可免,其凶如此。\n象曰:上六失道,凶三歲也。\n上六以陰柔而處險地,乃因其失道而成。其災延三年不得免,意久也。\n"},{"id":71,"href":"/zh/docs/culture/%E5%A4%A9%E7%BA%AA/%E4%BA%BA%E9%97%B4%E9%81%93/%E4%B8%8A%E7%BB%8F/28%E6%BE%A4%E9%A2%A8%E5%A4%A7%E9%81%8E/","title":"28澤風大過","section":"上经","content":" 28澤風大過 # 此卦姜太公釣渭水,卜之,至八十方遇文王。\n圖中:\n官人乘車上,插兩旗,旗上有喜字, 入朱門,門外貴人立,文書在地,一合子。\n寒木生花之課 本末俱弱之象\n頤者,養也,萬物養而後能成,成而後動,動則有過,故大過次頤也。大過乃論過失之大與過人之大事也。舉凡世間事之大過於常者,皆是也,古聖人盡人道非過於理之正,如今之行過乎恭,喪過乎哀,用過乎儉,皆過之也。過人之大事,本不常見,故非常人能為之,如堯舜之襌讓,湯武之放伐,即立不世之功,乃過人之大事。\n卦圖象解\n一、官人乘車上插兩旗:車至官來,必有官司訴訟,軍人象。二、旗上有喜字且分開,乃喜有破損象,捨去婚姻象。\n三、入朱鬥:乃豪門世家相請,或朱姓人。四,、門外貴人立:被棄於外象。\n五、文書在地:合約,契約,不成之象。六、一合子:先成後破之象。\n人間道\n大過:棟橈,利有攸往,亨。\n小過與大過之分,乃小過為陰過於上下,大過乃陽過於中,上下皆弱,故有棟橈之象。此卦四陽居中,乃棟象,能任重也。即君子盛而小人衰,故利有攸往可亨。\n彖曰:大過,大者過也。 # 犯過之大與成大過於人之事。\n棟橈,本末弱也。 # 本末為陰而中為重陽,故為楝橈。\n剛過而中,巽而説行,利有攸往,乃亨。 # 雖因陽剛有過,但處不失中道,巽順和悦,無以私心,必利攸往,所以亨。\n大過之時大矣哉! # 人能立非常之大事,立不世之大功,乃真大過,處之時得宜乃大哉。\n象曰:澤滅木,大過。君子以獨立不懼,遯世无悶。 # 澤本潤養於木,今至滅沒於木,此過之甚矣,為天地之象。君子(人才)觀大過之象,以立其大過於人之行,其所以能大過於人者,乃其能獨立不懼,天下誹之亦無動其心,舉世不見\n知而不悔,隱世而不憂悶,如此方能自守,此為其能大過於人也。\n初六:藉用白茅,无咎。 # 此以陰柔而處卑下,易藉用茅為至薄之物,但用之亦很慎重,此為敬慎之至也,故可以無災。\n九二:枯陽生稊,老夫得其女妻, 无不利。 # 二為柔位,陽剛居柔,是過剛之人而能以中自處,與柔相濟,其功如老夫之得女妻,則\n九三:棟橈,凶。 # 九三以太過之陽剛自居,又不得中道,此剛過之甚,其動必違和於中,拂逆眾心,故即以棟樑之才,而無人自輔,安能當大過之任乎?故凶。君子居大過之時,立大過之功,非剛柔適中,得人之輔,則不能也。\n九四:棟隆,吉 ,有它吝。 # 此近君之位,又當大過之任,以剛處柔則能相濟,是吉也。但如有異志,則剛必失中, 此加累於陽剛,雖不致有大害,但亦可吝也。\n九五:枯楊生華,老婦得其士夫, 无咎无譽。 # 君位處大過之時,下無助援,就如枯楊不生根而生外表,其表秀雖可見,但卻無益於枯也。猶如老婦之與士夫,此雖无咎,但非美也, 其可醜乎。\n上六:過涉滅頂,凶,无咎。 # 此陰柔又處過之極者,乃小人過之極也, 小人之大過,並非能做大過於人之事也,而是過越常理,不體恤危亡,犯險闖禍而已,如涉水而至滅頂,必凶。此自取其辱,无所怨尤。\n象曰:藉用白茅,柔在下也。 # 此示意陰柔而居卑下,過於敬慎之意,此至慎之道。\n亦能成生育之功。\n象曰:老夫女妻,過以相與也。 # 老夫少妻之配,雖過於常份,但老夫能悦少女,而少女能順老夫,亦無災。\n象曰:棟橈之凶,不可以有輔也。 # 此即剛強之過,必不能取於人,人亦無法輔之,此其凶也。\n象曰:棟隆之吉,不橈乎下也。 # 棟能隆起則吉,不可曲橈就下。\n象曰:枯楊生華,何可久也?老婦士夫,亦可醜也。 # 枯陽不生根而生華秀之外表,必不能長久。老婦得士夫,必不能成生育之功也。\n象曰:過涉之凶,不可咎也。 # 過涉水以至於溺水,皆咎由自取,無可抱怨也。\n"},{"id":72,"href":"/zh/docs/culture/%E5%A4%A9%E7%BA%AA/%E4%BA%BA%E9%97%B4%E9%81%93/%E4%B8%8A%E7%BB%8F/27%E5%B1%B1%E9%9B%B7%E9%A0%A4/","title":"27山雷頤","section":"上经","content":" 27山雷頤 # 此卦張騫尋黄河源,卜得之,乃知登天位也。\n圖中:\n雨下,三小兒,日當天,香案,金紫官人引一人。\n龍隱清潭之課 遷善遠惡之象\n萬物畜聚之後,必有所養,無養不成,故頤者,養也,次大畜之序卦。上艮下震,乃上止而下動,外實中虛象,如人頷頤物於口中象。人之口所以飲食,乃能養人之身,此為頤。此論頤養之道,大至於天地養萬物,聖人養賢,人之養生,養形,養德,養人,養才,皆屬頤養之道。\n一、雨下:受恩澤也。\n卦圖象解\n二、三小兒:乃合心之象,有德之國也。三、日當天:君明之象。父全。\n四、香案:求取之象。\n五、金紫官人:天子身側之人。六、引一人:提攜,推薦也。\n人間道\n頤:貞吉,觀頤,自求口實。\n頤之養人,君子觀之,自求口實之道,乃養君子。口不實又不知養,小人。\n彖曰:頤,貞吉,養正則吉也,觀頤,觀其所養也,自求口實,觀其自養也。天地養萬物,聖人養賢以及萬民,頤之時大矣哉! # 吾人所養之人及養生之道,皆不外自求口實,皆以正則吉。聖人養賢,使之為天位,使之食天祿,為求賢才施惠於天下。此養賢即養萬民也。\n象曰:山下有雷,頤。君子以慎言語,節飲食。 # 以象言,雷震於山下,萬物根動而萌芽,此天地養之象。君子知口所以養身,故慎言語以養其德,節制飲食以養身。\n初九:舍爾靈龜,觀我朵頤,凶。 # 龜之性,能咽息不食,可以不求養於外, 故為靈,即明智,而人口腹之慾既動,則必失所養,故凶。\n象曰:觀我朵頤,亦不足貴也。 # 人之動為慾,即使有剛健之才,再明智, 終亦必失,故不足貴也。\n六二:顚頤,拂經,于丘頤,征凶。 # 養之道正,在以上養下,天子養天下,養臣子,臣食君祿,皆以上養下。今女不能自處必從男,陰不能獨立必從於陽,此二爻不能自養反求下之陽剛,故為顛倒,拂違常理,故不可行。丘即上九,為在外而高之物,故往而必凶,此即意示人之才不足以自養,見在上位勢足養人,即非同類之君子,妄往求之,取辱必矣。\n六三:拂頤,貞凶,十年勿用,无攸利。 # 邪柔又動之不正,違反頤之正道,故凶。\n六四:顚頤,吉,虎視眈眈,其欲逐逐,无咎。 # 陰柔居大臣之位,陰柔不足以自養,如何養天下?今如與初陽剛相應,而柔順於初陽之賢正,吉。故如己之才不足以養天下,但知求在下之賢而順從之,亦可吉也。但須養其威嚴, 眈眈如虎視,以免因己才之陰柔不足養,而受人輕視,造成犯上。但亦須注意所須用者,必逐逐相繼而不缺,若唯取於人又不能繼,困窮\n六五:拂經,居貞吉,不可涉大川。 # 君位乃養人者,但其質如陰柔,即才不足以養天下,而能順從於陽剛(師)之賢,賴其以濟天下,必須有居安篤信之心,全般信賴, 則可輔其身,德澤天下,故吉。但此種狀況可依賴賢能之人於平時,不可於有困艱生變故之時,否則招凶。\n上九:由頤,厲吉,利涉大川。 # 此以陽剛之德,任師之職,君位從而篤信, 天下得養也,但須夕惕若厲,戒慎恐懼,方能濟天下之危,故利涉大川。\n象曰:六二征凶,行失類也。 # 征而從上,又非其類,故人求非同類,必得凶矣。\n十為數之終,故終不可用,無所往而利。\n象曰:十年勿用,道大悖也。 # 此戒之終不可用,凡人邪而柔,又欲動, 反正道大也,此戒之。\n至矣。\n象曰:顚頤之吉,上施光也。 # 吾人能顛倒求養而又吉者,皆得自陽剛以濟應其事,故能施光明于天下。\n象曰:居貞之吉,順以從上也。 # 居上位之吉處,乃因能篤信委任剛賢之人,來養天下。\n象曰:由頤厲吉,大有慶也。 # 若上九之大任能謹言慎行,安居已位,不妄圖權利,則必福慶至矣。\n"},{"id":73,"href":"/zh/docs/culture/%E5%A4%A9%E7%BA%AA/%E4%BA%BA%E9%97%B4%E9%81%93/%E4%B8%8A%E7%BB%8F/26%E5%B1%B1%E5%A4%A9%E5%A4%A7%E7%95%9C/","title":"26山天大畜","section":"上经","content":" 26山天大畜 # 此卦昔神堯,卜得之,果登天位也。\n圖中:\n一鹿一馬,月下有文書,官人憑欄, 盆內蒼發茂盛。\n龍潛大壑之課 積小成大之象\n無妄則至善,可以積畜,故大畜為序卦。畜為止,又為聚,止而後聚,此卦天至大,而在山中,有所畜至大之象。外止內健為畜象,吾人視萬物外則無欲,即止也。內則運行不斷,必有大積畜於其中。\n卦圖象解\n一、一鹿一馬:祿馬交馳,大富之象。\n二、月下有文書:明而有往,中秋時節,文書在空中尚未到手。\n三、官人憑欄:無事狀,遥望所失之國度。又欄為木,人靠木,休也。棺也。四、花在盆中:有限也。\n此卦求官不利,求財大利。 # 人間道\n大畜:利貞,不家食,吉,利涉大川。\n大畜於人亦同,人之畜在學術道德充積於內,乃至大之畜,若為邪端異説畜又何用?今人重大畜於錢財,不重視學術修養,所畜何用?不為大畜。\n彖曰:大畜剛健,篤實輝光,日新其德。 # 人须能剛健篤實,其畜乃大,又因充實而光辉,德必日新。\n剛上而尚賢,能止健,大正也。 # 居尊位能剛明用賢,又能持之永恆,此大正之道。今之君王剛而不明,用人乃因私慾,而不以賢能為基準,故非大正之道。\n不家食吉,養賢也;利涉大川,應乎天也。 # 大畜學術道德之人,施其所畜,利於天下,因而不在家食,乃吉。能涉大川而無險,乃應乎天地也。\n象曰:天在山中,大畜,君子以多識前言往行,以畜其德。 # 君子觀天之至大而畜山中,故知人之能畜大,乃由學而大,學之道在多聞古聖賢之言行, 取長為己用,察言以求其內心,乃大畜之真義。\n初九:有厲,利己。 # 初爻為陽剛,必上進不已,則有危厲,此時,利在己之畜而不進,待與上合志乃動。\n九二:輿説輹。 # 二為六五位所對應,雖陽剛但亦不可力進,進則如車輪脱軸,不得行也。\n九三:良馬逐,利艱貞,曰閑輿衛, 利有攸往。 # 三為相位,以剛健之才,能與上位合志而同進,就如良馬之馳,速也。唯雖速,但須切\n六四:童牛之牿,元吉。 # 此言近君位之人,须上畜止人君之邪心, 下畜止天下之惡。凡人之惡能止於初始,則易如童牛加桎梏。俟惡盛而後禁,必相隔而難勝。故聖人止惡於初。\n六五:豶豕之牙,吉。 # 豬之去勢,則雖有牙,而剛躁必自止。君子體之,知天下之惡,不可力制之,須査其根本,不靠刑法嚴峻而能止惡。聖人教修政教, 使人人足用,知廉恥之心,方為至善。\n上九:何天之衢,亨。 # 大畜之道,能如行空中,橫行無阻,必散之天下,吉也。\n象曰:有厲利己,不犯災也。 # 此即不度局勢而進,不利己,乃災至。戒不可犯危而行。\n象曰:輿説輹,中无尤也。 # 至善為剛中,柔不可過柔,因位居下,仍不足以進。易之道,乃知所進退也。\n忌不可持才之健,而忘戒備與謹慎也。\n象曰:利有攸往,上合志也。 # 因能與上合志,故往而利也。\n象曰:六四元吉,有喜也。 # 大畜之道在止惡於初,此大善而吉,必能不勞而易治。\n象曰:六五之吉,有慶也。 # 在上者止惡有道,乃天下福慶也。反用強力嚴刑與民為敵,傷又無功矣。\n象曰:何天之衢,道大行也。 # 道路空曠,而能大行,乃大畜之極也。\n"},{"id":74,"href":"/zh/docs/culture/%E5%A4%A9%E7%BA%AA/%E4%BA%BA%E9%97%B4%E9%81%93/%E4%B8%8A%E7%BB%8F/25%E5%A4%A9%E9%9B%B7%E6%97%A0%E5%A6%84/","title":"25天雷无妄","section":"上经","content":" 25天雷无妄 # 此卦李廣,卜得之,後凡為事皆不利也。\n圖中:\n官人射鹿,鹿啣文書,錢一堆在水中,一鼠一豬。\n石中蘊玉之課 守舊安常之象\n因復於道,合於正理,則无妄,故復之後受以无妄。卦乾上震下,此言動以天則為无妄, 如動以人慾則生妄矣。故无妄之義大也。\n卦圖象解\n一、官人射鹿:自亂陣腳。二、鹿啣文書:財訊至也。\n三、錢一堆在水中:得財遇險之象。險中求財,憂心忡忡也。四、一鼠一豬:遇子、亥年吉,肖鼠人陰謀,肖豬人黑心。\n又可以子、亥為孩,亦即孩無子為亥,缺子象。又六字不全,亥月凶,無尾凶, 不足六歲,為五歲凶死。\n天機象:犬、豬、牛、羊:叱之即去。雞、魚、鵝、鴨:欲用則不生。\n人間道\n无妄:元,亨,利,貞,其匪正有眚,不利有攸往。\n无妄即至誠也。至誠為天道。天能化育萬物,生生不息,且各正其性,此无妄也。人如能合於无妄之道,則能與天地合德,故大吉,君子行無妄之道,能致大吉,須戒在堅心,如失貞, 則致災。今人本無邪心,但如不合正理,則妄亦邪心也。\n彖曰:无妄,剛自外來而爲主於内。動而健,剛中而應,大亨以正,天之命也。\n動以天則无妄,今人動以人慾。剛自外而入主於內,乃以正去妄之象。上健而下動,內動而外健,其動乃剛,理正氣壯,道乃大亨,此天道也。今人動之以慾,而無法示誠於外,不夠剛明,乃因內不實,此致妄矣。\n其匪正有眚,不利有攸往,无妄之往,何之矣。天命不祐,行矣哉? # 无妄,即正也,小失於正,乃妄。入於妄,不知復,乃更往,則必悖天理,為天道所不容, 不可行也。\n象曰:天下雷行,物與无妄,先王以茂對時育萬物。 # 雷行天下,陰陽和而成聲,生發萬物,各正性命,其發而无妄,先王觀此象,體天之道, 養育人民,使各正性命,乃對時育物之道也。\n初九:无妄,往吉。 # 初進陽剛在內,以无妄之天理行之於外, 故往吉。\n六二:不耕穫,不菑畬,則利有攸往。 # 人之欲即妄,理之自然者,非妄,今以耕穫喻之,農耕後之穫,乃理之自然也。田一歲為『菑』,三歲曰『畬』,有耕而有穫,有菑而有畬,聖人順時而作,乃為聖也,讀易之人, 能知時順勢,得易之道矣。\n六三:无妄之災,或繫之牛,行人之得,邑人之災。 # 人之妄動,由己之欲也,妄動有得,亦必有失,陰柔居相位,動乃因妄,失之大在欲, 故妄得之福,災亦隨之,妄得之得,失亦來之, 皆非真得也。\n九四:可貞,无咎。 # 四位剛而居之,剛則必無私,永不生妄。\n九五:无亡之疾,勿藥有喜。 # 九五為尊位,以陽剛應之,下又以陽剛中正順應,則致善也。即令有小疵,不須去治而自癒,如持之以恆,則平安無災,戒之在動, 一動生妄,故人君之无妄道即在此。\n上九:无妄,行有眚,无攸利。 # 此位為卦之極,无妄已至善,人至無妄, 又復行,理必過也。過於理,則生妄。\n象曰:无妄之往,得志也。 # 无妄乃誠也,人之至誠,物無不動,君子以之修身則身正,以之治事則得理,以之待人則人化,无往不利也。\n象曰:不耕穫,未富也。 # 心念於始耕菑之時,乃為求畬,此所以為富。心有欲而為,則妄。故易言人之行但求合於自然,不為人欲所動,故能勝己之欲為大勇也。\n象曰:行人得牛,邑人災也。 # 有得有失,不足言得。聖人之得,乃無欲而得,不言有失。\n象曰:可貞无咎,固有之也。 # 堅心守无妄之道,剛而無欲,則无災。\n象曰:无妄之藥,不可試也。 # 人有妄必改,則致无妄,今反以藥治之, 反成妄矣。故不可試。\n象曰:无妄之行,窮之災也。 # 无妄已為極點,今又復加進,乃生妄矣, 故窮極而生災也。\n"},{"id":75,"href":"/zh/docs/culture/%E5%A4%A9%E7%BA%AA/%E4%BA%BA%E9%97%B4%E9%81%93/%E4%B8%8A%E7%BB%8F/24%E5%9C%B0%E9%9B%B7%E5%BE%A9/","title":"24地雷復","section":"上经","content":" 24地雷復 # 此卦唐太宗歸天,卜得之,七日復還魂也。\n圖中:\n官人乘車,上兩隻旗,堠上東字,一將持刀立,一兔一虎。\n淘沙見金之課 反復往來之象\n萬物無剝盡之理,故剝極必復來。復為之序卦。此卦一陽生於五陰之下,有陰極生陽象, 如同冬至極寒,則必生陽,亦同。雷在地下,有復生之象,外順內動,此為復之始。\n卦圖象解\n一、官人乘車:使節也;車姓、連姓也;軍隊也;官大也;調職也。二、上兩隻旗:鬥也;將、帥也;一方之主也。\n三、堠上東字:猴發木形人。東:有心約束,十八日至。十日來人封侯象。四、一將持刀立:武威之人,司法執行人。\n五、一兔:肖兔之人,劉姓之人。六、一虎:肖虎之人,王姓之人。\n此卦云:君子之道,既消而復,始復必不能力勝小人,必待其朋類渐盛,協力致勝可也。\n人間道\n復:亨,出入无疾,朋來无咎。\n萬物既復則必亨,如君子之道將復,雖微,但同類將渐進,故亨且盛矣。故復之時至,並無法立勝小人,必待其同朋相繼,則協力能勝也。\n反復其道,七日來復,利有攸往。 # 此為消長之道,易之論數,以立準則,小有七日乃復,大有七年方復,此復之道也。\n彖曰:復,亨。剛反,動而以順行,是以出入无疾,朋來无咎。 # 此以卦才來論,復之道,陽剛因消弱至極而反來,其法動而取順行法,是故出入皆無災, 朋之來,亦因順而動來也。\n反復其道,七日來復,天行也。利有攸往,剛長也;復其見天地之心乎! # 復之返來,順天之運行,故言七日。因君子道長,故利前往,陽復生於下,乃是天地生物之心,以為動之始也,故外見動,而始生於內也。\n象曰:雷在地中,復。先王以至日閉關,商旅不行,后不省方。 # 以卦象而言,如雷乃陰陽相搏而成聲,時陽之微,未能發也。如聖人順天之道,於日陽之\n始生,安靜養之以閉關,使商旅不得行,人君不視四方,此順天也,於人亦以此安養其陽為復原之道。\n初九:不遠復,无祇悔,元吉。 # 有失而後有復,此復之始時,因失之不遠, 故復必不至於悔,故吉。\n六二:休復,吉。 # 二為切近初陽之位,君子道始生於下,若二位志向於陽,今陽在下從之,故能下之道為仁。吉。\n六三:頻復,厲,无咎。 # 從復之道,又頻復履失,致危之道也。故須厲,即戒此可无咎,頻失致危,其過乃在失而不復也。今人迷途不知返,比之皆是,又知返而不戒又入迷,凶也。\n六四:中行獨復。 # 四於眾陰之間,能自處於正,但因力弱, 不足克濟,此言獨復,不一定無災。\n六五:敦復,无悔。 # 君王於復之道,能敦篤而行之,但臣下均陰柔之輩,因無助,但能無悔而不能無災,君子戒之。\n上六:迷復,凶,有災眚,用行師, 終有大敗,以其國君凶,至于十年不克征。 # 陰柔居復之終,乃意終迷不復者,大凶。招凶,皆因己之迷而不返,動則必有過失,以之出師必大敗,以之為國君,大凶。迷於道,\n象曰:不遠之復,以脩身也。 # 此即知不善而速改,為君子修身之道。學問之道即在此,知不對立改,為君子也。\n象曰:休復之吉,以下仁也。 # 仁者天下之公,善之本源,能體會而近於下之仁,乃休復之美。\n象曰:頻復之厲,義无咎也。 # 戒之在屢復,復而又失,失之過,故堅心於復不失,無災殃也。\n象曰:中行獨復,以從道也。 # 以獨復於小人間,如從陽剛,君子之道也。\n象曰:敦復无悔,中以自考也。 # 五以陰居尊位,能敦篤善道,以中道自成, 此可無悔也。\n而終不可行也。\n象曰:迷復之凶,反君道也。 # 人君居上而治天下,當從天下之善,如迷於復,反復無常,皆招凶也。\n"},{"id":76,"href":"/zh/docs/culture/%E5%A4%A9%E7%BA%AA/%E4%BA%BA%E9%97%B4%E9%81%93/%E4%B8%8A%E7%BB%8F/23%E5%B1%B1%E5%9C%B0%E5%89%9D/","title":"23山地剝","section":"上经","content":" 23山地剝 # 此卦尉遲將軍與金牙鬥爭,卜得之,不利男子。\n圖中:\n婦人床上坐,風中燭,葫蘆在地,山下官人坐,冠巾掛木上,一結亂絲。\n去舊生新之課 群陰剝盡之象\n盡賁飾之道,而後可亨。但飾之終,不戒,則必剝,故為之序。卦體為五陰一陽,乃意陰始自下生,渐長至極盛,群陰皆受剝於陽也。天地之間,山乃高大,但仍附於地上,此為頹剝之象也。外止內順,招剝之象也。而順且止,為處剝之道。\n卦圖象解\n一、婦人床上坐:必有暗災,陰人得勢,陽道衰弱,小人為主官。二、燭在風中:險也。\n三、葫蘆:醫藥無救也;有暗計也。四、繅絲:事務繁冗。\n五、山下官人坐:有靠山,但陰人為禍。 六、冠巾掛木上:求去之象;又有樂之象。\n人間道\n剝:不利有攸往。\n剝乃群陰長盛,剝削陽時,同眾小人剝喪君子,故君子不利往。\n彖曰:剝,剝也,柔變剛也。不利有攸往,小人長也。 # 剝,落也。柔盛而剛剝,陰盛陽必退,此陰陽之道,小人道長盛,君子消退也。\n順而止之,觀象也;君子尚消息盈虛,天行也。 # 君子處剝之時,知不可往,順時而止,乃因能觀剝也。故此卦有順止之象,為處剝之道。君子之人見事於始,凡事之始皆有理,君子視其盈虧消息,而知處之道。順時則吉,逆時則凶, 故君子事天。小人反之,不見事始之作為如何,不論事理之盈虧如何,一昧迎隨奉承,故小人事人。\n象曰:山附於地,剝,上以厚下安宅。 # 山再高大仍依附於地,聖人體此,知人君與上位者,視剝而知厚固其下位之人,以安居也, 下者為上之本,從未有基本固而能剝者也。故上如有剝,必自下。故安養人民以厚國本。書曰\n『民惟邦本,本固邦寧』。\n初六:剝床以足,蔑貞,凶。 # 因剝之始皆自下,故曰剝足,陰自下進, 渐消亡正道,凶之至也。\n六二:剝床以辨,蔑貞凶。 # 此陰漸進,為將者已受剝也,凶益甚也。\n六三:剝之,无咎。 # 眾陰在下剝陽之時,三之相位獨剛應之, 則可無災,但因勢孤弱,雖可無災,但未必能大亨。\n六四:剝床以膚,凶。 # 剝之及身時也,其凶可知。乃因不知始剝於下,以致進盛至此。\n六五:貫魚,以宮人寵,无不利。 # 剝本及於君,凶可知,但聖人於此,知人性之從善如流,以示寵方式,使群陰如魚之貫序,則可無所不利。此寵如對妻妾之同態也。\n上九:碩果不食,君子得輿,小人剝廬。 # 果之大者,如不食,則有復生之理。陰道盛極,天下大亂,但亂極則必思治,故眾人之心,必願載君子,故如得輿。此陰極生陽也。若為小人於此,則必無安身之地。\n象曰:剝床以足,以滅下也。 # 正道之侵滅,必由下而上渐進。用剝之道即在此。\n象曰:剝床以辨,未有與也。 # 小人之侵剝君子,若君子有對應,則小人不能為害,如無對應,則有被滅之凶。\n象曰:剝之,无咎,失上下也。 # 為相之人居剝之時,无災之因乃同類而相失,不與同道,故也。\n象曰:剝床以膚,切近災也。 # 象為剝及皮膚,因四為君側之人,如君之膚,示災禍之近身也。\n象曰:以宮人寵,終无尤也。 # 於剝之及身時,能以寵妻妾之方式對待, 則終無災。\n象曰:君子得輿,民所載也;小人剝盧,終不可用也。 # 因正道消剝至極,則人必思治,上九為陽剛之君子,為民所載也,小人處剝之極,終不可用也。\n"},{"id":77,"href":"/zh/docs/culture/%E5%A4%A9%E7%BA%AA/%E4%BA%BA%E9%97%B4%E9%81%93/%E4%B8%8A%E7%BB%8F/22%E5%B1%B1%E7%81%AB%E8%B3%81/","title":"22山火賁","section":"上经","content":" 22山火賁 # 此卦管鮑卜得之,果獲全,彼此相遜,終顯名義也。\n圖中:\n雨下,車行路,舟張帆江中,官人著公服登梯,仙女雲中執珪。\n猛虎靠岩之課 光明通泰之象\n物之合後則必立文,文乃飾之意。賁者,飾也,故為之序。人因合聚則有威儀上下,物之合聚則必有次序行列。此卦山下有火,草木百物聚於山處,下有火,則能照見草木百物,且增益其光彩,有賁飾之象。\n卦圖象解\n一、雨下:滿也,潤澤也,成也。 # 二、車行路:前往任官象,行走四方也。三、舟張帆在江中:此行順風大利也。\n四、官人著公服登梯:升遷在外地象。任公職主官外調象。丈夫從公職升官也。五、仙女雲中執珪:受陰人提攜象。\n人間道\n賁:亨,小利有攸往。\n物有外飾而後有立,但须有內實而加外飾,則可以亨。因文飾之道可以增加光彩,故必利於小進。\n彖曰:賁亨,柔來而文剛,故亨。分剛上而文柔,故小利有攸往,天文也;文明以止,人文也。\n賁之道能亨,實由飾而來,飾之道,須來往以柔,求文飾以剛,名須正,故利小吉。賁飾之道,並非能增其內實,只外加文彩而已。今人但求外飾,不求內實,富侈表面,內無實才, 比比皆是,上位者所用皆富有之人,此敗亡之初始象也。\n觀乎天文以察時變,觀乎人文以化成天下。 # 天文,為日月星辰之錯列,四時寒暑之變化。人文為人理之倫序,觀人文以教化天下,此聖人用賁之道也。\n象曰:山下有火,賁。君子以明庶政,无敢折獄。 # 山下有火,草木為其明照而有光彩,此賁飾也。聖人觀此明照之象,以修明庶政,成文明之治,無敢於用文飾,掩飾實情,枉法不顧,故君子以用明為戒。\n初九:賁其趾,舍車而徒。 # 初九乃君子有剛明之德,居於下也。因其無位,無法施濟天下,唯求賁飾自己而已。君子修飾之道,在正其所行,守節處義,凡不正則寧不與同行。\n六二:賁其須。 # 外飾於物,無法改變其本質,須因其本質如何加飾,乃賁之道。\n九三:賁如濡如,永貞,吉。 # 賁之盛,須戒永正,則吉,因永正之心及行,很難維持。\n六四:賁如皤如,白馬翰如,匪寇婚媾。 # 賁外飾為白色,白馬亦為白色,但志不同, 由外飾同,而終有婚遂相應也。今人之求門當户對也。\n六五:賁于丘園,束帛戔戔,吝, 終吉。 # 此雖君位,但本質陰柔,不足自守,但求外賁於設險守國,田園近城,山丘在外,據險而守,即令陰柔之質,受人裁制其外,雖君子吝之,但終平安。\n上九:白賁,无咎。 # 賁飾之極,則失於外偽,唯能質白於賁, 則無過飾之災。故尚質素,不失其本真,千萬不可外飾過於華美而失其本質,此戒賁也。\n象曰:舍車而徒,義弗乘也。 # 於義不可同行,寧舍車而步行。\n象曰:賁其須,與上興也。 # 須賁之道,須與上隨,隨上而動,動靜歸依於所主。\n象曰:永貞之吉,終莫之陵也。 # 如飾之變化多且非正,人所陵侮也,故戒之永正則吉。\n象曰:六四當位,疑也;匪寇婚媾, 終无尤也。 # 四與一為應爻,六四當位時,中離間隔二三位,故疑也,但因一與四為正應,理直義勝, 終必無災也。\n象曰:六五之吉,有喜也。 # 君能從人以成賁之功,此吉有喜也。\n象曰:白賁无咎,上得志也。 # 處賁之極,則有過華偽失實之慮,故戒之, 以質素則可無災,飾不可過也。\n"},{"id":78,"href":"/zh/docs/culture/%E5%A4%A9%E7%BA%AA/%E4%BA%BA%E9%97%B4%E9%81%93/%E4%B8%8A%E7%BB%8F/21%E7%81%AB%E9%9B%B7%E5%99%AC%E5%97%91/","title":"21火雷噬嗑","section":"上经","content":" 21火雷噬嗑 # 此卦蘇秦説六國,卜得之,後為六國丞相。\n圖中:\n北斗星,婦人燒香拜,憂字不全,喜字全,一雁食稻,一錢財,一鹿。\n日中爲喜之課 順中有物之象\n觀之後而有來合者,所合者,噬嗑也。故為觀之序。卦才中有一剛居,意為口中有物隔其上下,不得嗑狀,必咬之則得嗑,故稱噬嗑,以此理推而天下事,如天下有強梗或讒邪阻隔其間,使天下事不得合,當用刑法。小則懲戒,大則誅戮以除去,使天下得治。古今萬物皆合而後遂成,如未合必有間阻,若君臣、父子、親人、朋友有怨隙者,因讒言在其間也,除之則合, 是故間隔為天下之大害。聖人體噬嗑之道,知此為天下之大用,能去天下之阻間,唯刑與罰。此卦二體明照而威鎮,乃用刑之象。\n卦圖象解\n一、北斗星:君王也,夫君也,公職也。\n二、婦人燒香拜:妻於人事無助,乃求助於天。三、憂字不全喜字全:乃無又喜象。\n四、一雞食稻:果被食,無果也。\n五、一錢財、一鹿者:於財祿事憂心忡忡也。六、禽:字解為『離多會少』之象。\n人間道\n噬嗑:亨,利用獄。\n此即天下之事不能亨通,乃因有間阻,非刑獄何以除之。\n彖曰:頤中有物,曰噬嗑,噬嗑而亨。 # 如口中有物阻,必用力嗑之,乃能亨通象。用獄之道,所以察情偽,得其情,則知為間之道,而後可以設防與致刑。知噬嗑之道,乃知去間之道,而天下亨通。\n剛柔分,動而明,雷電合而章。 # 此卦才有剛有柔,分而不雜,為明辨之象。察獄之本即明辨,以明而動,上震下離,其動因明也。照與威並行,為用獄之道。人能照,則無隱情,有威則無人不畏,故照與明並用也。\n柔得中而上行,雖不當位,利用獄也。 # 此言治獄之道,如全剛則傷於嚴暴,過柔則失於宽縱。以仁而居剛得中正,則得用獄之道。\n象曰:雷電,噬嗑,先王以明罰敕法。 # 雷威而電明,聖人觀雷電之象,效法其明與威,而立刑罰法令。法之精神乃在教明事理而設防也。\n初九:屨校滅趾,无咎。 # 最下位,下民之象,為受刑之人,用刑之始,罪小刑輕,以木械傷其趾,則不敢進惡矣。此即因小懲而有大戒。小人之福,即在此。\n六二:噬膚滅鼻,无咎也。 # 二居中而得正,是用刑得中正象。如此罪惡易服,然遇剛強之人,刑之必须深痛,故致滅鼻而无災。\n六三:噬臘肉遇毒,小吝,无咎。 # 本身不正又用刑於人,則人不服而怨對反駁,如食臘肉遇毒,反傷於口。此雖有小議, 但無大災也。\n九四:噬乾胏,得金矢,利艱貞, 吉。 # 九四乃居近君之位,乃意愈大,則用刑必重。至此得剛直之道,可克艱其事,又因貞固本身操守,且執意行之,則无咎。\n六五:噬乾肉,得黃金,貞厲,无咎。 # 因五乃居君位,至尊故勢大,以此刑下, 較易如吃乾肉,故為君之道,時天下之間阻物大,須得中而剛,心懷危懼,持續下去,方吉。\n上九:何校滅耳,凶。 # 受刑之極,乃起因於惡之積也,其凶可知。小惡不懲,必成大惡。\n象曰:履校滅趾,不行也。 # 因小罪而小用木械傷其趾,則因懲而不再進為惡也。\n象曰:噬膚滅鼻,乘剛也。 # 此用刑於剛強之人,不得不深痛,乃有戒也。\n象曰:遇毒,位不當也。 # 受刑之人難服,乃因其用刑之人位不當也。今之他國人在本國犯罪,即見之。\n象曰:利艱貞吉,未光也。 # 此時,乃有不足,故雖堅心,但仍未光大也。此貞亦艱也。\n象曰:貞厲无咎,得當也。 # 當正位,以剛而能守正又慮危,故無災。\n象曰:何校滅耳,聰不明也。 # 人之為惡不悟,積其惡以致極大,至此何教之?今之人聰而不明,不知止惡於初,教正以刑,乃至大惡。\n"},{"id":79,"href":"/zh/docs/culture/%E5%A4%A9%E7%BA%AA/%E4%BA%BA%E9%97%B4%E9%81%93/%E4%B8%8A%E7%BB%8F/20%E9%A2%A8%E5%9C%B0%E8%A7%80/","title":"20風地觀","section":"上经","content":" 20風地觀 # 此卦唐明皇與葉靜遊月宫,卜得之,雖有好事,必違也。\n圖中:\n日月當天,官人香案邊立,鹿在山上, 金甲人執印秤。\n雲捲晴空之課 春花競發之象\n臨者,大也,萬物之成大,乃有可觀,故觀所以次臨也。凡觀視於物,即為觀也。人君上觀天道,下觀民俗,為觀之道。風行地上,遍觸萬物,乃周觀之象。以陽剛在上,為群下所觀, 故觀亦有仰之義。\n卦圖象解\n一、日月當天:政治清明,普照大地,父母雙全。\n二、官人香案邊立:倌人,丈夫也。蔡姓人士,神人共明之象。三、鹿在山上:得高祿之象。\n四、金甲人:君側之人也,護衛也。五、執印秤:提印信而來授權也。\n人間道\n觀:盥而不薦,有孚顒若。\n人於祭祀之始,求神之時,心念最專精之時也。居上位者,正其儀表,以為下民之觀,當\n始與終,皆能令民之觀如初始之專精,則盡觀之道也。今人至君位後,禮數繁多,致令人心散, 精一不如初始也。\n彖曰:大觀在上,順而巽,中正以觀天下。 # 五居尊位之人,能以陽剛中正之德,始終如一,為下所觀見,則必能精一也。\n觀,盥而不薦,有孚顒若,下觀而化也。 # 處觀之道須嚴守如始之精一,則下民必至誠瞻仰而從教化,不薦為不使誠意少散。\n觀天之神道,而四時不忒,聖人以神道設敎,而天下服矣。 # 天之道至神,故曰神道。聖人觀天之運行四時,從無差錯,其神妙如此。聖人體神之道而以設教,故天下服也。\n象曰:風行地上,觀。先王以省方、觀民、設敎。 # 風行地上,能觸萬物,聖人體之,知巡省四方,觀視民俗,設為政教,例如見奢則約之以儉,見奸則矯之以正,則民將觀之。\n初六:童觀,小人无咎,君子吝。 # 處最下之位,又為陰柔之本質,離陽剛過遠,是故其觀淺如童稚,故曰童觀。小人觀陽剛之道,不識君子之道,於觀之初,反而無災, 如見君子亦昏淺如是,則可羞也。\n六二:闚觀,利女貞。 # 象曰:初六童觀,小人道也。\n此所觀不明如童稚,此實小人也,故言小人之道。\n觀,乃少見而無甚明也。二居將位,而不能明見陽剛中正之道,則如女子之道,即見之不甚明,又能順從者。將位之人,能如女子之順從,則不失其正,故利。\n象曰:闚觀女貞,亦可醜也。 # 此即君子之人不能觀見陽剛中正之大德,雖能順從,乃同女子也。亦可羞也。\n六三:觀我生,進退。 # 柔質居相位,處觀之時,其進退之道,乃為己者,雖非正,.亦不為過也。\n六四:觀國之光,利用賓于王。 # 四雖陰柔居君側之位,能觀見國之光辉盛大,可見人君之道德也。此因九五君位為聖明之人,致令才德之士 ,皆願進朝廷輔佐,以濟天下。反之聖君因能用敬才德之人,則人才聚之。\n九五:觀我生,君子无咎。 # 九五君位之人,體觀之道,其視天下之俗而知己,若天下之俗,不合乎君子之道,則是己之所為,政治必不佳,不可不怪自己。\n上九:觀其生,君子无咎。 # 上九為不當位之人,以陽剛處之,如同賢人君子不在其位,而道德為天下所觀仰,能如此,皆君子也。\n象曰:觀我生進退,未失道也。 # 觀己知生存之道,而以之進退,此未失道也。\n象曰:觀國之光,尚賓也。 # 君子懷才自守,乃因無明主也。既觀見國之盛德光華,則志願登進王朝,以行其道。\n象曰:觀我生,觀民也。 # 人君欲觀己之施政良否,當觀於民,民俗善,則政化必善。此意即王弼云『觀民以察己之道』。\n象曰:觀其生,志未平也。 # 吾人觀其生存之道,常不失於君子,雖不在位,則亦不失人所望之教化也。\n"},{"id":80,"href":"/zh/docs/culture/%E5%A4%A9%E7%BA%AA/%E4%BA%BA%E9%97%B4%E9%81%93/%E4%B8%8A%E7%BB%8F/19%E5%9C%B0%E6%BE%A4%E8%87%A8/","title":"19地澤臨","section":"上经","content":" 19地澤臨 # 此卦蔡琰去和番,卜得知,必還故國也。\n圖中:\n婦人乘風,一車上有使旗,人在山頂, 虎在山下坐,一合子,人射弓。\n鳳入雞群之課 以上臨下之象\n有事之後乃有可大之心,臨者,大也,故序卦為臨。為卦澤上有地,乃岸也,因與水相近故曰臨,天下之事最近臨者,唯水與地,故地上有水,為此卦,澤上有地,為臨;臨,即臨民臨事,凡所面臨的,皆有臨之道。\n卦圖象解\n一、婦人乘風:外援為陰女,美人也。\n二、一車上有使旗:出使之象,以謀略可解災,謀之對象為女人也。連姓、車姓人。三、人在山頂頭:盛極將衰,招困也,即將下坡也。\n四、虎在山下坐:人受虎困之象。五、一合:先成後破象。\n六、人射弓:張姓人士,夷狄之人,以弓射獵,故外族入侵象。\n故自古天下安治,未有久而不亂者,皆因不能戒盛而已。 # 人間道\n臨:元,亨,利,貞。\n言臨之道,可致大亨也。\n至于八月有凶。 # 二陽才剛生於下,聖人戒之於方盛,方盛而慮衰,則可以防自滿,圖其永久,衰而後戒, 無及也,此乃戒盛之道。今人富而多驕,為安樂而壞綱紀,忘禍亂而任小人奸邪,皆因不知戒盛。\n彖曰:臨,剛浸而長,説而順,剛中而應,大亨以正,天之道也。 # 剛中得正道,而有應助,故能大亨。天道,能化育生萬物,皆因其剛正而和順而已。以此道來臨事,臨人,臨天下,莫不大亨通也。\n至於八月有凶,消不久也。 # 此乃聖人知戒於盛時,不以自滿,則凶不至也。\n象曰:澤上有地,臨。君子以敎思无窮,容保民无疆。 # 地與水之臨近為至近,君子以此知臨之道乃親民如此,才能教導人民。有寬容保民之心, 才能廣大無窮的包容。\n初九:咸臨,貞吉。 # 初為卑下之位,而以正道與四位感應,為所信任而得行其志,故吉。\n九二:咸臨,吉无不利。 # 九二居將位,能以陽剛之心臨人,臨事, 不愧於人,吉無不利。\n六三:甘臨,无攸利,既憂之,无咎。 # 三居相位,柔陰居之,此居上位又不以中正臨下,此失德也,不利。如能憂於此而立改之,則無災也。\n六四:至臨,无咎。 # 此臨之至也,臨道乃由近而生,此位居上之下,又切臨於下,乃處近君之臣位,守正任賢,如親臨於下,則无咎。\n六五:知臨,大君之宜,吉。 # 以一人之身,君臨天下之廣,若皆以自任, 必不能周於萬事,如自認為皆知者,正明示其不知也。唯有能得天下之善,用天下之智者, 則無所不周,這就是不以自認皆知,故其知乃可大。\n上六:敦臨,吉,无咎。 # 此位乃臨之極位,以坤順而居此位,乃意尊而應卑,高而從下,尊賢取善,此為敦厚之極也。皆因其能敦厚於順剛,是以吉而無災。\n象曰:咸臨貞吉,志行正也。 # 陽剛之志在於行正,志正也。\n象曰:咸臨,吉无不利,未順命也。 # 九二乃應於五之尊位,因以剛德之長,又得中道,此至誠相感,而得吉,並非因順上之命而得吉也。\n象曰:甘臨,位不當也,既憂之, 咎不長也。 # 陰柔之人,處臨人事不以中正,又居下之上,乃不適其位,如能知懼而憂之,則必自改其災不長也。\n象曰:至臨无咎,位當也。 # 居近君之臣,陰柔對之,為得正道,與下之剛賢相應,故能無咎。\n象曰:大君之宜,行中之謂也。 # 君臣合道,皆因同氣相求,君能倚任剛中之賢,則能成臨之功,此知臨之意。\n象曰:敦臨之吉,志在内也。 # 內心敦厚篤實,志順於陽剛,其吉可知。\n"},{"id":81,"href":"/zh/docs/culture/%E5%A4%A9%E7%BA%AA/%E4%BA%BA%E9%97%B4%E9%81%93/%E4%B8%8A%E7%BB%8F/18%E5%B1%B1%E9%A2%A8%E8%A0%B1/","title":"18山風蠱","section":"上经","content":" 18山風蠱 # 此卦伯樂療馬,卜得知馬難治,見三蠱同器皿也。\n圖中:\n孩兒在雲中,雁啣書,一鹿、一錢,男女相拜。\n三蠱食血之課 以惡害義之象\n因喜而隨人者,必有事,無事何喜何隨?故蠱以次隨也。山下有風,風在山下,遇山而回, 使物亂,是為蠱象,蠱者,壞亂也。長女下於少男,長幼無序也,其亂可知。此卦象言成蠱之因,卦才專言,治蠱之道。\n卦圖象解\n一、孩兒在雲中:有天官促成也。不求名利之人。二、雁啣書:有消息至。\n三、一鹿:有大利也。\n四、錢在空中:此心中圖利之象也。\n五、男女相拜:男女相合,此為利而結合,非正婚也。\n人間道\n蠱:元亨,利涉大川。\n蠱之大者,乃時之艱難險阻,能有治蠱之才,則必亨通。\n先甲三日,後甲三日。 # 甲為十干之首,意言事之始也。先甲三日,乃言治蠱之道,應先思其事之原,再思其事之後,乃知救弊之道。亦強調慮之深,推之遠也。能善於救者,則前之弊可革,能善親備者,則後利長久。此聖人之治蠱道。今人不明聖人先甲後甲之誡,慮淺而事近,故勞於救亂,亂不革, 功未及成,弊已生矣。\n彖曰:蠱,剛上而柔下,巽而止,蠱。 # 以卦而言,男雖少但居上,女雖長而在下,尊卑得正,上下顺理,治蠱之道也,故以巽順之道治蠱,是以元亨。\n蠱,元亨,而天下治也。利涉大川,往有事也。 # 自古治亂者,能使尊卑上下之義正,在下為巽順,在上有才能安定萬事,如此則天下大治。如正遇天下壞亂之時,持治蠱之道而往濟之,利也。\n先甲三日。後甲三日,終則有始,天行也。 # 有始必有終,終之後必有始,此天道也。聖人體始終之道,故能於事之始而探究其所以然, 能於事之終而備其將然,此先甲後甲之慮,故能治蠱而天下亨。\n象曰:山下有風,蠱,君子以振民育德。 # 山下有風,風遇山而回,致物皆散亂,故為有事之象。君子觀有事之象,在己則養德,在天下則濟民,君子所事此二者為最大。\n初六:幹父之盎,有子,考无咎, 厲終吉。 # 庶民之道,即子幹父蠱之道。子能居其位而知正,則無災,不然,則為父之累,故必惕厲為戒,終吉。初六為最下之道,故言子與父之關係。\n九二:幹母之蠱,不可貞。 # 婦人本陰柔,若子只顧強伸己陽剛之道, 反逆之,則傷恩,所害大也,唯有屈下己意柔順將成,使之身正而事乃治,故曰不可貞。如同陽剛之道輔柔弱之君,須盡誠竭忠致使其於中道即可,不會有大做為的。\n象曰:幹父之蠱,意承考也。 # 子幹父蠱之道,意在能承擔父事,常懷惕厲,只敬其事,盡誠於父,則終吉。\n象曰:幹母之蠱,得中道也。 # 居臣位,不過剛,乃得幹母蠱之道完整者。\n九三:幹父之蠱,小有悔,无大咎。 # 九三乃以陽剛之才居相位,克盡所事,雖以剛過而小有悔,但終無大災,但如以事親之道,則並非善事親之人也。\n六四:裕父之蠱,往見吝。 # 此柔順之才而以正道處事,只能自守其道而已,若遇非正之事,因柔順之本質,而無法矯正,故不能而招罷斥。\n六五:幹父之蠱,用譽。 # 此君位之人但質柔,但下應九二陽剛之位,意其能用陽剛之才為臣,但因本實陰柔, 故可為承其舊業,而不能為開創事業之才。自古創業之事,非剛明之才不足以成事。不能用剛賢之人,只可以守舊袓業而已。\n上九:不事王侯,高尚其事。 # 上九乃居蠱之終,乃無事之地,處事之外。此應賢人君子不遇於時,應高潔自守,不可曲道以遁時,此進退合於道者也。\n象曰:幹父之蠱,終无咎也。 # 此因剛而明斷之才,雖剛因不失正,故終無災。\n象曰:裕父之蠱,往未得也。 # 柔順之才,守於寛裕之時可以,如要前行, 則不可得也,再加其任務,必不能勝任之。聖人知柔順之才可以守成,但不可用於發展,委之重任,必不成也。\n象曰:幹父用譽,承以德也。 # 陰柔之君主,能承下陽剛之才,以在下位之賢而能用之,乃因此而聲譽顯。\n象曰:不事王侯,志可則也。 # 不臣事於王侯,因知無法施其志,此乃賢能之人,此可以為法則也。小人皆可曲道而順行,背己逆天,只圖權力名位,此不可法也。\n"},{"id":82,"href":"/zh/docs/culture/%E5%A4%A9%E7%BA%AA/%E4%BA%BA%E9%97%B4%E9%81%93/%E4%B8%8A%E7%BB%8F/17%E6%BE%A4%E9%9B%B7%E9%9A%A8/","title":"17澤雷隨","section":"上经","content":" 17澤雷隨 # 此卦孫臏破秦,卜得之,便知必勝也。\n圖中:\n雲中雁傳書,一堆錢,朱門內有人坐, 一人在門外立,地上一堆珠。\n良士琢玉之課 如水推車之象\n豫悦之道,人必皆隨之,故豫之後有隨。震為雷,兌為澤,雷震於澤中,澤隨雷而動,此隨之義。以陽而下陰,陰乃隨陽,此陰陽不易之理。『外悦內動象』『下動上悦象』。\n卦圖象解\n一、雲中雁傳書:飛來喜訊象。二、一堆錢:財聚也。\n三、朱門內有人坐:君王之象,必見君面也。\n四、一人在鬥外立地:乃士人求仕,受尊重之象。五、串錢在地:有用利不行之象。\n六、亂珠聚盆:先聚後散之象。\n全卦:禮儀得體,有見速喜之象。 # 人間道\n隨:元,亨,利,貞,无咎。\n隨之道,可以致大亨通。君子之道,眾人所隨,又遇事擇所隨,隨能得道,必致大亨。今\n人君之從民意,臣下之從君意,人人隨從義,利在堅守隨義,隨因能正,其道必亨。小人群聚, 此隨失正,豈不招凶乎。\n彖曰:隨,剛來而下柔,動而説,隨。大亨,貞,无咎,而天下隨時。 # 雷為剛今居柔下,是以尊貴而對下隨,為隨之義。臣君關係,以上悦下動,以動可悦,為隨之道,如是則亨通而無災。\n隨時之義大矣哉。 # 君子之道,隨時而變,因時制宜。今世昏蒙不明,凡事皆表明裡暗,是故民之所隨,未能適中,常受謡言、外飾所惑,隨而必枉,受奸人利用。\n象曰:澤中有雷,隨,君子以嚮晦入宴息。 # 澤隨雷震而動,君子晝則自強不息,夜則入居內安其身,起居作息隨天時,乃宜也。禮云: 君子晝不居內,夜不居外,此隨時之道。\n初九:官有渝,貞吉,出門交有功。 # 官為守也,因所從隨為正,故吉。出門交往多所親愛者,因人心所從隨也。常人之情, 愛之所見皆對,惡之所見皆非,小人皆以私情而隨從之,不合正理,君子出鬥交不以私,故所隨皆有功也。\n六二:係小子,失丈夫。 # 六二為陰柔位,陰乃順從,柔順之意,但如順隨的對象為小人,則必失卻君子之人,此因不能固守,聖人戒之。\n六三:係丈夫,失小子,隨有求得, 利居貞。 # 六三居相位,所固守本身之正,求上之隨, 有求必得,乃上上隨也。如背是求非,舍君子從小人,為下下之隨也。故君子之隨,首先固守本身之正。\n九四:隨有獲,貞凶,有孚在道以明,何咎? # 因四位乃臣之極位,如隨而有獲,即正亦易招凶,皆因天下之心隨於己。為臣之道,應使恩威出於上,眾心能隨於君,如讓人心從己, 致凶之道。故君子居此,唯誠正於心,所動皆合於道,則可無悔。小人位極,用君王權,遂行其志,得民心又不歸功於上,位極而逼上, 勢強而專權,隨之過大矣。君子雖功大震主, 但由於其正而心誠,故主隨從而信之,何有災?\n九五:孚於嘉,吉。 # 此君位之人,得中正而固守,動隨於善, 大吉也。故此,從人君到平民,隨道之吉,唯隨善耳。\n上六:拘係之,乃從維之,王用亨于西山。 # 象曰:官有渝,從正吉也。出門交有功,不失也。\n所隨從之道,須從正則吉。隨從不正,招悔咎也。因交非因本身之私,其交必正,故不失。\n象曰:係小子,弗兼與也。 # 人因所順隨為正道,邪惡必遠也,但須戒, 人若從於正,須專一,不可兼與。\n象曰:係丈夫,志舍下也。 # 隨之至善即在此,舍小人而從君子,人人如此,小人無所遁也,常自省而人之性乃從善如流,故將無小人矣。\n象曰:隨有獲,其義凶也,有孚在道,明功也。 # 近君之位,因追隨而有得,雖有凶戒,但有中正之誠,則無災,明哲保身也。\n象曰:孚于嘉,吉,位正中也。 # 處中正之位,又中誠所隨,必吉矣。\n此柔順而居隨之極也,柔順之隨至極雖有過之失,但如人心皆隨,因有得民之隨,仍有王道王業之成也,故能得民心者,得天下也。\n象曰:拘係之,上窮也。 # 隨之道至此,乃窮盡也。\n"},{"id":83,"href":"/zh/docs/culture/%E5%A4%A9%E7%BA%AA/%E4%BA%BA%E9%97%B4%E9%81%93/%E4%B8%8A%E7%BB%8F/16%E9%9B%B7%E5%9C%B0%E8%B1%AB/","title":"16雷地豫","section":"上经","content":" 16雷地豫 # 此卦諸葛孔明討南蠻,卜得之,便知必勝。\n圖中:\n兩重山,一官人,一祿一馬,金銀數錠,錢一堆者。\n鳳凰生雛之課 萬物發生之象\n人有大量而又能謙,必豫,豫者悦也,豫卦承大有及謙之序也。上動而下又順,以象言, 陽之始,潛伏地下,及出於地,乃因動,即為雷,故雷出地上,有通暢和順象。\n卦圖象解\n一、兩重山:出象,千里阻隔也。\n二、官人在中:外出不任官象;丈夫外出從商。三、一祿一馬:從商之人,財祿豐盛。\n四、金銀數錠:紙錢錠,主喪服。五、錢一堆者:內有憂心忡忡象。\n全卦:此卦內有憂,外有阻,乃順勢而動,祿馬來會,可去官從商矣。 # 人間道\n豫:利建侯,行師。\n聖人體豫悦之道,知兵師之興,諸侯之封,必眾心和悦方成,故能君臨萬邦,能群聚大眾, 唯和悦可也,此豫之時也。\n彖曰:豫,剛應而志行,順以動,豫。 # 動之順理,眾順而回應,其志乃行,故豫乃動而眾順。\n豫順以動,故天地如之,而況建侯行師乎。 # 順理而動,天地之道亦如是,更何況立建諸侯,興兵之師。\n天地以順動,故日月不爲過,而四時不忒;聖人以順動,則刑罰清, 而民服。 # 天地之順而動,吾人可視四季之行,日月運轉永不為過,聖人稟此,而知因正而民相爭於行善,刑清罰簡,萬民皆服。\n豫之時義大矣哉。 # 聖人知豫而體用豫之道,故於時機之掌握大矣,此卦之下有十一卦,豫、遯、姤、旅,皆言時之義。坎、睽、蹇,皆言時之用。頤、大過、解、革,皆言時之大也。\n象曰:雷出地奮,豫。先王以作樂崇德,殷薦之上帝,以配祖考。 # 雷動出於地而奮震,悦之象也。先王作聲樂以褒揚功德,歸之上帝、祖先,此言,豫之道, 能知,能體,則盛而至大也。\n初六:鳴豫,凶。 # 初六乃下位,陰柔處之,是意不中正之小人,處豫時,為上所寵,志得意滿,乃至於發聲,小人之輕淺如是,必至凶也。\n六二:介于石,不終日,貞吉。 # 豫悦之道,易流放縱,過則失正道,乃不合於時。此六二本陰位,今陰爻居位,乃處中正,為自守之象,君子處豫之時,獨其能以中正自守,示出其節介如石之堅也,故吉。能見事於幾微者,謂之神妙。君子之人上交不諂, 下交不瀆,因能知微,吉凶之始,可先見於此也。守堅如石,則能不惑而明。\n六三:盱豫,悔。遲有悔。 # 六三乃以陰而居陽位,為不中不正之人居相位也。如此動則有悔,盱為上視也,其動竟上視君側之人,故不中不正,悔之始也。是故君子明處身不正,進退皆有悔矣。處已之道, 在正本身而已,以禮制心,即處豫卻不失中正, 則無悔矣。\n九四:由豫,大有得,勿疑,朋盍簪。 # 九四乃君側之位,動則眾陰悦順,此豫之義。豫悦之由,以陽剛而任此位,大行其志, 而天下皆悦。人能盡誠則無疑,上下因至誠而信,合而聚之,簪,乃聚髪之物。\n六五:貞疾,恆不死。 # 六五為君位,當豫悦之時,陰柔有沉溺之象,故乃柔弱不能自立且耽於酒色之豫道,受制於專權之臣,因受制於下,故有疾苦,古今人君致危之道很多,但以縱情於樂居多,豈有不死乎?\n上六:冥豫,成有渝,无咎。 # 豫之極,而以陰柔居之,乃聖人示意,凡人之失,苟能自變,則亦可以无咎,此乃為君子。如昏迷不知反省,必招凶。\n象曰:初六鳴豫,志窮凶也。 # 初六處下而驕鳴,雖外飾喜悦,實乃窮極而凶。\n象曰:不終日貞吉,以中正也。 # 人能中正,且守堅,故能辨之於早,此君子處豫之道。\n象曰:盱豫有悔,位不當也。 # 因自處不當,失卻中正,造成進退有悔, 處不當位也。\n象曰:由豫,大有得,志大行也。 # 志得大行於天下,乃皆由聚所悦也。\n象曰:六五貞疾,乘剛也;恆不死, 中未亡也。 # 君位有疾,乃因側位專權之人壓制也,如能不死,乃因側位之剛為中正,方不致於亡。\n象曰:冥豫在上,何可長也。 # 昏冥於豫悦,乃至於終極,災難至矣,不可長久,君子當速變。\n"},{"id":84,"href":"/zh/docs/culture/%E5%A4%A9%E7%BA%AA/%E4%BA%BA%E9%97%B4%E9%81%93/%E4%B8%8A%E7%BB%8F/15%E5%9C%B0%E5%B1%B1%E8%AC%99/","title":"15地山謙","section":"上经","content":" 15地山謙 # 此卦唐玄宗因祿山之亂,卜得之,乃知干戈必息也。\n圖中:\n月當天,一人騎鹿,三人腳下亂絲, 貴人捧鏡,文字上有公字。\n地中有山之課 仰高就下之象\n大有至終必不可盈滿,故聖人受之序卦為謙。天之道盈滿,則必損地道,此卦為亦謙之義, 以自然界喻之,外卦為地,內卦為山,即山居地下,山乃至高大之物,而今居地下,此處卑乃謙之象,亦示人有崇高之至德,而處卑下,乃盡謙之義。\n卦圖象解\n一、月當天:清明之政,中秋時也。二、一人骑鹿:才祿俱備。\n三、三人腳下亂絲,隱於山後:小人不敢正面示人,惟隱山謙之後(即外飾謙為幌子)實則亂如麻,計無所出。\n四、貴人捧鏡:明鏡高懸,執法公正光大。五、文字上有公字:處事以公,得理也。\n全卦:不知謙下自晦,果必招訟也。 # 人間道\n謙:亨,君子有終。\n有德而不居,為謙,人能以謙遜自處,無往不利。君子之志在達理,樂天命而無競之心, 但求內充,退讓而不衿持。能安於謙,終身不易。小人則有慾必競,此種人見有德之人必攻之, 即令有人告之謙遜,但終不能宽行固守。故小人,必不能有終也。\n彖曰:謙,亨,天道下濟而光明,地道卑而上行。 # 君子明謙之道而能亨。天之道因其氣能下濟,故能生育萬物,故道乃光明,故君子知謙而能使其道濟天下萬民,地之道亦同天,因自處卑地,故能上交於天,是故天地二者,皆因能卑降而終亨通也。\n天道虧盈而益謙。 # 天之道有日月五星,其隨時盈晦,故能降氣於地,生養萬物。\n地道變盈而流謙。 # 地勢如盈滿,則必倾變而下陷。\n鬼神害盈而福謙。 # 天地之道乃形而上之學,吾人可推理求之。鬼神人道乃形而下之學,吾人可親而見之,世間萬物必有其用,凡盈滿者,必有禍害,能謙損者,必有福祐之,猶今之西醫,其但知能如何除瘤用毒,不知損害之大,故後遗症之多,而自不見之。\n人道惡盈而好謙。 # 人情必惡人之盈滿,好人之謙損,故聖人戒盈而勸人為謙也。\n謙尊而光,卑而不可喻,君子之終也。 # 君子之道因謙卑而光大,至誠之念如是而終。\n象曰:地中有山,謙。君子以裒多益寡,稱物平施。 # 高大之山在地下,故示人外卑下,內實高大乃謙象。君子有過則損之,不足則益之,以之用於事,使萬物皆平衡也。\n初六:謙謙君子,用涉大川,吉。 # 最卑下之位以柔態處之,又謙讓也。能如是,君子也。即涉險難,必無凶也。\n六三:嗚謙,貞吉。 # 二位以柔順居中,謙之德至此,則須明倡於外,見諸聲色,堅心如此,吉也。\n九三:勞謙,君子有終,吉。 # 三為相位,以陽剛居之,上為君所任,下為民所從,能知勞而不居功,謙下待人,君子能行之至終,故吉。今上位掌權之人,盡皆示民其功勳為何,不知勞謙之美,此為小人之道長之時也。反之,能盡勞謙之道,必為君子。\n六四:无不利,撝謙。 # 四體居近君主之位,此時因六五之君,以謙柔自處,九三之相位,又因大功德為上所用, 此當恭畏侍君,卑謙以讓勞謙之臣,所有布施, 均為謙也,切不可不利於謙。\n六五:不富以其鄰,利用侵伐,无不利。 # 富貴,眾人之所嚮往,以財來聚人。今五以居尊之君王,不以富而能得人親,皆因知謙順,乃能得天下。然須戒君道,切不可專尚謙順,必須威柔並濟,才能服天下,故易曰:「利用侵伐」。威德並重,方為君道。\n象曰:謙謙君子,卑以自牧也。 # 君子以謙卑之道,於初入世間而自處之。\n象曰:鳴謙貞吉,中心得也。 # 因中心能自得,山至大又能知謙,故吾人須有高厚實力,乃真謙非勉力為之也。\n象曰:勞謙君子,萬民服也。 # 此勞謙君子,能服萬民也。\n象曰:无不利,撝謙,不達則也。 # 六四因近君之位,又居勞謙臣之上,絶不可不利謙道,此得宜之法也。\n象曰:利用侵伐,征不服也。 # 君王之道,須用征伐,其用謙德所不能服者,否則不能治天下,則為謙之過也。\n上六:鳴謙,利用行師,征邑國。 # 上六處謙之極也,因極謙反居高位,為太過於謙,為倡明其謙,只有用剛武之道,於已之私有地,征之。此因謙之太過而致如此。\n象曰:鳴謙,志未得也。可用行師, 征邑國也。 # 因極謙又居上,其不適謙,故志不得伸, 則鳴,唯宜以剛武自治其國內。\n"},{"id":85,"href":"/zh/docs/culture/%E5%A4%A9%E7%BA%AA/%E4%BA%BA%E9%97%B4%E9%81%93/%E4%B8%8A%E7%BB%8F/14%E7%81%AB%E5%A4%A9%E5%A4%A7%E6%9C%89/","title":"14火天大有","section":"上经","content":" 14火天大有 # 此卦藺相如送趙壁往秦,卜得之,果還壁歸趙也。\n圖中:\n婦人腹中一道氣,氣中二小兒,一藥王,藥有光,女人受藥,一犬。\n金玉滿堂之課 日麗中天之象\n同人之序卦,與人同者,物必歸,此天紀也。故大有為同人之序,物歸之後,乃大有也此卦,火在天上,火之處高,其明及遠,能照萬物,此為大有之象,一柔居尊位,眾陽應和,此居尊執柔,物乃所歸,上下相應,為天子之道。\n卦圖象解\n一、婦人腹中一道氣:妊娠之事;事萌於內也。二、氣中二子:主雙喜臨鬥。伯仲之間象。\n三、一藥王:平安之象。得病有救,遇良醫也。四、藥有光:藥有名也,災中有救也。\n五、女人受藥:女人為陰,示表面不信,但暗中仍聽之。\n六、一犬:乃狗年,戌月,戌日,此論時機也;或狄姓人士。七、犬:哭笑皆有象。\n人間道\n大有:元亨。\n此乃以卦才而言,不言元亨利貞四德,乃恐與乾坤二卦相混淆,只言元亨乃盡元之義,元乃物之始,萬物生成之初皆為大善,因必有功用。此為大善之初,易經唯四卦,大有、蠱、升、鼎有元亨。其不與乾坤同,在乾為首出萬物,元始之象,在此四卦唯至善為大而已。\n彖曰:大有,柔得尊位,大中而上下應之,曰大有。其德剛健而文明, 應乎天而時行,是以元亨。 # 此卦之所以為大有,乃因五以陰柔居尊位,又處以中庸大中之道,為諸陽所依歸,上下相應,此居尊執柔,有虛中之象,卦德內剛健而外明理,處事順乎天應乎人,此所以元亨。事於成後,方可見成敗,敗非先現於事前也。故有得後乃有所失,非得則何言有失乎?\n象曰:火在天上,大有,君子以遏惡揚善,順天休命。 # 火因在天上,故能照見萬物,君子觀大有之象,故知治眾之道,唯遏惡揚善,此能順天命而安眾民心,天子之道即如是。\n初九:无交害,匪咎,艱則无咎。 # 以陽剛之性處卑下之位,因未至於盛,故不有驕盈之慮,因無交往,故無害。人之富有於財或才,鮮不有害,富有本無罪,但人卻皆因富有而招害,乃因處富有而不知思艱戒盛, 則生驕侈之心,此所以生害也。\n九二:大車以載,有攸往,无咎。 # 此位以剛健之才,居於陰柔之位,而為六五君位所信任,故無災。就如大車之材,強壯能载重物,可以任重行遠,往而無災。\n九三:公用亨于天子,小人弗克。 # 九三居下卦之上位,為諸侯之位,諸侯雖享有土地之富,人民之眾,但仍屬天子所有, 此人臣之義。但小人居此位則專其富有以為私,不知奉上以公之道。\n九四:匪其彭,无咎。 # 九四陽剛居大有之時,因近君位,如處之太盛,則致凶,彭為盛大之義,須謙損,方為吉道。\n六五:厥孚交如,威如,吉。 # 人君之位,以陰柔守之,以誠信待於下, 下亦誠信待上,上下相交,此人心易安,但若專以柔順,則必生悔慢之心,故以威信,故君子柔中有威,使下屬有畏,其吉可知也。\n上九:自天祐之,吉,无不利。 # 此位乃大有卦之終極,仍明之極意。人唯至明,故不居於已有,有極大之位,而不為已有,則無盈滿之災,君能至此,則合於天道, 得天之祐,則無往不吉。大同之世來矣。\n象曰:大有初九,无交害也。 # 此即在大有之初始,即生戒心,須時思念艱難之時,此居安思危之來源,故能如此,則交亦無害也。故與人交往,持富有而驕,必生害也。\n象曰:大車以載,積中不敗也。 # 大車能載重物,重物集中而不損敗,言九二才力之強,能勝大有之重任。\n象曰:公用亨于天子,小人害也。 # 諸侯能守臣節,忠順奉上,慎養其眾,為王之徵用。若小人處此位,則不知奉上之道, 以所有為私有,故小人居此位,則有大害也。\n象曰:匪其彭,无咎,明辯皙也。 # 明智之人,處此大有盛大之時,戒在咎之將至,以謙損態度應之,不敢至於極滿招凶之道也。故無災。\n象曰:厥孚交如,信以發志也;威如之吉,易而无備也。 # 上下相交以誠信,互相呼應,上位如無威嚴,則下屬易生毁謾,故君須戒無威。\n象曰:大有上吉,自天祐也。 # 大有至極處,本有變化,但由所為皆順天合道,此君子滿而不溢,故天祐之。尊尚賢人, 崇尚信義,故為上吉。\n"},{"id":86,"href":"/zh/docs/culture/%E5%A4%A9%E7%BA%AA/%E4%BA%BA%E9%97%B4%E9%81%93/%E4%B8%8A%E7%BB%8F/13%E5%A4%A9%E7%81%AB%E5%90%8C%E4%BA%BA/","title":"13天火同人","section":"上经","content":" 13天火同人 # 此卦劉文龍在外求官,卜得之,果衣錦還鄉。\n圖中:\n人捧文書上有心字,人張弓,射山上, 一鹿飲水,一溪。\n游魚從水之課 二人分金之象\n天地不交則否,上下相同則同。遇否之世,必與人同力方能渡過。故次否也。以象言,天之性在上,火之性炎上與天同,故為同人。以卦體言,五為君位乾主,二為離位,陰柔居二爻。上下相同之義,天性剛健,火性明耀,即外健內明之象,吾人外剛健,內明道,則成天火同人, 此為大同之道。\n卦圖象解\n一、人捧文書上有心字:得民心之象,同心協力之象。寧姓。二、人張弓射山上:高中金榜。張姓人氏。\n三、一鹿飲水:财祿滾滾而來。亦在野之賢人。四、一溪:平和安靜狀。\n五、鹿之性,動則敏,靜則順,此剛健人之性,世間但聞虎食人,未聞鹿食人。\n人間道\n同人:于野,亨。利涉大川,利君子貞。\n野即遠與外之意,以天下大同之道,聖賢大公之心,使之無遠不同。常人之同,以私意所合,此暱親之情。故必于野,方可謂大同。天下皆同時,何險阻能阻礙?何艱困能致危?故利涉大川,君子須堅心於此。小人唯用私意,所親比者亦為小人,朋黨之始,其心不正也。\n象曰:同人,柔得位,得中而應乎乾,曰同人。 # 二爻以陰居陰位,此為正位,得中正之德,應乎乾上,五為君位,以剛健中正,二以柔順對應,上下同心同德,故曰:同人。\n同人于野,亨,利涉大川,乾行也。 # 乾之行,必至誠無私,故可以涉險難,而吉。無私,天德也。\n文明以健,中正而應,君子正也。唯君子爲能通天下之志。 # 下體為有文明之德,上卦為剛健之性,此君子之正道。天下之志萬殊且異,但理則一也。君子因明理,故能通天下之志。聖人視億萬人之心為一心,因只一理而已。文明能使人更明理, 故能明大同之真義,剛健必能克已,則必能致大同之道。\n象曰:天與火,同人,君子以類族辨物。 # 天在上,火之性炎上,此為同人,君子觀同人之象,以分類群族各以其性同來區分,如君子小人,善惡是非,物之外形固異,但事理皆同,君子能辨明。\n初九:同人于門,无咎。 # 初進為剛健,此因無所偏私,出門在外因無私,而無咎也。\n象曰:出門同人,又誰咎也。 # 出門在外,因同人之道而無私,同此道之人又廣,無厚此薄彼之異,則無人歸咎也。\n六二:同人于宗,吝! # 宗為宗黨也,同於所派系,此有所偏,故可吝。故只用宗黨之人則有偏私,此為鄙吝之人。\n九三:伏戎于莽,升其高陵,三歲不興。 # 二以中正之道與君五位相同志,三以剛暴之人居二五之間,欲奪,然理不直,義不勝, 不敢發動,故只能藏兵戎於莽林間,時升高陵顧望,至三年之久,仍無機可乘,此小人之情狀。\n九四:乘其墉,弗克攻,吉。 # 九四乃陰位,陽剛居之,故以剛居柔位, 其近君位,知義不直而能復返正道,亦吉也。\n三以剛居剛,故終其強而不可能反。此示人畏義能改則終吉。\n九五:同人,先號咷而後笑,大師克相遇。 # 人君當與天下大同,如獨私一人,則非君道也。其同志為二爻,間隔有三四之剛,故未遇之前憤怒而號咷,即遇則大笑,故二人同心, 其利斷金,中誠所同,無所不同,天下莫能離間,則無所不入,故聖人赞之曰:『同心之言, 其臭如蘭』。\n上九:同人于郊,无悔。 # 求大同之道,必相親相與,即在外又遠之地不同,但亦永無悔矣。\n象曰:同人于宗,吝道也。 # 同人之道用於宗親,乃有所私,因私比, 非人君之道,故此為吝道。今人皆如是,莫若太宗之用人。\n象曰:伏戎于莽,敵剛也;三歲不興,安行也。 # 因三爻乃剛暴之人,其五君位為剛且正, 故畏忌而不敢與,即三年亦不與。\n象曰:乘其墉,義弗克也;其吉, 則困而反則也。 # 因同人乃一陰,而眾陽所同欲也。今獨三四爻有爭奪之意,乃邪不勝義,其困窮乃因反於法則,故吉。\n象曰:同人之先,以中直也;大師相遇,言相克也。 # 同人之先以中誠理直,故同心之力大,雖敵剛強,如大師相遇,由其義直理勝,終能克之也。\n象曰:同人于郊,志未得也。 # 此申明同人之道,在外及遠之地求同之志不成,即無悔,但非最善之道。\n"},{"id":87,"href":"/zh/docs/culture/%E5%A4%A9%E7%BA%AA/%E4%BA%BA%E9%97%B4%E9%81%93/%E4%B8%8A%E7%BB%8F/12%E5%A4%A9%E5%9C%B0%E5%90%A6/","title":"12天地否","section":"上经","content":" 12天地否 # 此卦蘇秦將遊説六國,卜得之,果為相矣。\n圖中:\n男人臥病,鏡破,人路上坐,張弓箭頭落地,人拍掌笑,口舌。\n天地不交之課 人口不實之象\n上泰之終,六爻以陰柔(小人)處之,則為否之初。天地相交,陰陽相通,則為泰,今天在上,地在下,是天地隔絶,不相往來,故成否卦。\n卦圖象解\n一、男子臥病:男子為陽,陽同佯,裝病之象也。二、镜破:不可赴也。破鏡難圓之象。\n三、人路上坐:小人阻路,不讓進也。四、張弓箭頭落地:鳥盡弓藏也。\n五、人拍掌笑:幸災樂禍之小人,做假攻訐兩人感情得逞狀,夫妻凶。六、舌:主口舌,官司,謡言生害。\n七、口上有四點:四點為黑,暗中誣陷之謡言,古人謂三口成虎,即意此。\n人間道\n否之匪人。不利君子貞,大往小來。\n舉凡天地之間皆為人道,今天地不交,不生萬物,是無人道,故曰匪人。處此之時,君子不可堅心,因正道不行,小人道長之時。\n彖曰:否之匪人,不利君子貞,大往小來,則是天地不交,而萬物不通也。上下不交,而天下无邦也。内陰而外陽,内柔而外剛,内小人而外君子,小人道長,君子道消也。 # 天地不交,萬物不生。上下不和,則天下無治國之道。治世乃上位施政來治民,人民擁戴君王而願從命,上下相交,此治國之道。今君子居於外野,小人處於朝內,故為小人道長,君子道消之時也。\n象曰:天地不交,否。君子以儉德辟難,不可榮以祿。 # 君子觀否塞之象為外健內顺(柔)。處否之時,損儉自己,避開禍亂。千萬不可榮居祿位, 戀棧不去。因小人得志之時,君子仍居顯位,則禍必及己身。反之,仍居顯位不放,乃真小人也。即內小人,外君子之意。\n初六:拔茅如,以其彙,貞吉,亨。 # 否之在最下者,為君子也。因否而不能進者,君子也。處否而能進者,小人也。因在下位,又貞固其節志,同類而聚,雖不進升,但亦吉矣。\n六二:包承,小人吉,大人否,亨。 # 六二本陰爻位,今陰柔居之,以小人而言, 其心所包容的,皆承顺上位之意,以求本身之利,此小人之吉。大人處否,則以道之陽剛自勉,絶不枉屈正道,奉承上位,雖身處否,但道仍亨也。\n六三:包羞。 # 不中不正之人居否之時,位居相位,不能守道安命,極盡小人之能事,每日謀慮邪事, 無所不包,此羞恥之大也。\n九四:有命,无咎,疇離祉。 # 居君側之人,最忌有居功招忌之事,因否時君道不正,即令有濟世之大才,亦不堪用。如能使事事出於君令,威柄皆歸於上,則\n無災而可實現大志。當君子道行,同類必同進同出為天下黎民福祉著想。小人亦同進退也。\n九五:休否,大人吉,其亡,其亡, # 繋于苞桑。\n處否之時,惟有陽剛中正之人居君位,能去天下之否,即大人吉。如循環至泰,亦須戒盛警惕否之復来,此其亡,其亡之意。此戒须如同苞桑,桑為根深蒂固之物,苞乃叢生之物, 其固更強,此為安固之道。此為聖人之深戒也。 繫辭:『危者安其位者也,亡者保其存者也, 亂者有其治者也。是故君子安而不忘危,存而不忘亡,治而不忘亂,是以身安而國家可保也。』\n上九:傾否,先否後喜。 # 物極必反,泰極則否,否極則泰,故否之極,即否道將覆,則泰矣,故曰後喜。\n象曰:拔茅貞吉,志在君也。 # 君子處否時而居下位,冀得同類而進,如遇小人,則堅守其節,但心仍在天下。\n象曰:大人否亨,不亂群也。 # 處否而守其正節,乃為大人,不與小人同亂為群體,形雖否,但其道吉,此道必大。\n象曰:包羞,位不當也。 # 居否時,所為邪吝,羞於公正,居此相位而不適,此不可以為正道。\n象曰:有命,无咎,志行也。 # 凡事皆由君命而出,則無災,且大志得行。\n象曰:大人之吉,位正當也。 # 因有陽剛中正之德才,又居君子之位,能去天下之否,故吉也。\n象曰:否終則傾,何可長也。 # 否之終必傾危,絶無永否之理,但反危為安,易亂而治,必有陽剛之才,乃能做也,故否卦之上九能傾否,如屯之上六則不能去屯同意也。\n"},{"id":88,"href":"/zh/docs/culture/%E5%A4%A9%E7%BA%AA/%E4%BA%BA%E9%97%B4%E9%81%93/%E4%B8%8A%E7%BB%8F/11%E5%9C%B0%E5%A4%A9%E6%B3%B0/","title":"11地天泰","section":"上经","content":" 11地天泰 # 此卦堯帝將襌位,卜得之,乃得舜而遜位。\n圖中:\n月中桂開,官人登梯,鹿銜書,小兒在雲中,羊回頭,地下亂點。\n天地交泰之課 小往大來之象\n履之後(有禮而始終如一)則有泰,即履得其所,則舒泰而安也。故泰所以次履也,坤柔在上,乾陽居下,乃天地陰陽之氣相交合,萬物生成,故表通泰狀。卦象外柔內健,此為致泰之道。\n卦圖象解\n一、月中桂開:清明政治之時。\n二、官人登梯:升遷順遂之意,官人亦倌人,即丈夫也。三、鹿銜書:受天子恩賜祿位。\n四、小兒在雲中:少年得志象。得天官貴人助也。 五、羊回頭:回陽間也。亦楊姓人,發肖羊人成格。\n人間道\n泰,小往大來,吉亨。\n陽氣下降,陰氣上升,陰陽和暢,萬物生焉,此天地之泰。人間之泰,大則居上,小則臣下,君王推誠任下,臣盡誠以事君,上下同志,朝廷之泰。君子,小人亦如是,君子處君位, 小人居下位,天下之泰也。\n彖曰:泰,小往大來,吉亨。則是天地交而萬物通也,上下交而其志同也。 # 陰往而陽來,天地氣相交,萬物通泰。人則上下之志相同,互相交通,人之泰也。「內陽而外陰,內健而外順,內君子而外小人,君子道長,小人道消也。」陽進陰退之時,故內健外順,君子之道也,君子在內,小人在外,此所以為泰。\n象曰:天地交,泰。后以財成天地之道,輔相天地之宜,以左右民。 # 此示人君須體會交泰之道而為法制,使民用天時之宜,輔助教育人民之功,使民有財,輔助於民,民則必賴君上之法制,因法治之宜也,得其生養。\n初九:拔茅茹,以其彙,征吉。 # 剛明之才,居於下位,遇泰之時,志而上\n進,遇同志而行同道,因同類而進,吉。凡君子小人都須賴同類以助,未有人能獨立而不須朋類之助。\n象曰:拔茅征吉,志在外也。 # 同類相聚,如拔茅之根相牽連,同欲上進。\n九二:包荒,用馮河,不遐遺,朋亡,得尚于中行。 # 九二為將位,以剛明之才,五為柔順而得君位,上下之專信建立,此位乃治泰者,故治泰之道,主將位而能包荒,如人情放肆所為, 則政令緩,法度廢弛,治此之道,必有包含荒穢之量,詳密施政,去其弊端,則人安之。處泰之道,人之常情習於久安,惰於因循,不敢變更,用馮河,乃奮發改革之意,雖至小至微之事亦不可遺漏,自古立法治事,牽於人情, 卒不能行者多矣。如禁奢侈則害近戚,限田宅, 則防礙貴族之家,此治泰之難。遇治泰,須稟持公正之態度,即中行意。\n九三:无平不陂,无往不復,艱貞无咎,勿恤其孚,于食有福。 # 三居諸陽之上,乃泰之盛時。聖人為之戒, 在下者必升,居上者必降,泰久而必否,故戒之。故當此時,不敢安逸,居安思危,則無災。故處泰之道須能堅貞,可常保泰。自古以來隆盛皆因內失道而喪敗下來。\n六四:翩翩不富,以其鄰,不戒以孚。 # 翩翩,疾飛之貌,人富而從其類,為利也。不富而從其者;志同也。上三爻皆為陰,陰在上而失其中實之道,皆欲下從,故為不富而從, 此誠意相合也。如至六四位方戒已晚,居三為適中,知戒可保,四位則已過,必生變化。\n六五:帝乙歸妹,以祉元吉。 # 為居君位,古之帝女皆向下嫁,帝乙制禮法,须降其尊貴,以順從其夫也。今六五以陰柔居君位,對應之九二為陽剛,乃能信之,而任用其賢且順從之,猶帝女之下嫁亦然,此成治泰之功也。\n上六:城復于隍,勿用師,自邑告命,貞吝。 # 此致泰之終也,小人處之,行必否矣。掘隍土累積成城,如治道累積以成泰,今城土頹圮,又復返於隍也。勿用師,夫用師之道,必得民心,今民心不從,用師必亂,此時即自有天命任之,亦逄羞而凶矣。\n象曰:包荒,得尚於中行,以光大也。 # 有包含荒穢之量,又配合中行之德,其道則明顯光大。\n象曰:无不復,天地際也。 # 陽降於下,必復上,陰升於上,必復下, 此示人明天地交泰之道不常存之理也,聖人戒之。\n象曰:翩翩不富,皆失實也;不戒以孚,中心願也。 # 因三陰在上,失其實才,欲行往下,不待其人富而鄰從,皆因失實故也。此已失乃方知戒之意,此時不待告誡而誠意相待,乃出於中心所願也。\n象曰:以祉元吉,中以行願也。 # 其能任用剛中之賢,乃因已有中道與之合,志同相交也。\n象曰:城復於隍,其命亂也。 # 此意城傾圮,又回歸隍土 ,即令命為正, 但亂已不可止矣。\n"},{"id":89,"href":"/zh/docs/culture/%E5%A4%A9%E7%BA%AA/%E4%BA%BA%E9%97%B4%E9%81%93/%E4%B8%8A%E7%BB%8F/10%E5%A4%A9%E6%B3%BD%E5%B1%A5/","title":"10天澤履","section":"上经","content":" 10天澤履 # 此卦子路出行,卜得之,遇虎拔其尾也。\n圖中:\n笠子下一女,文書破,手中有傘,軍旗官人邊坐,堠上有千里字,地上兩點足印。\n如履虎尾之課 安中防危之象\n天地間萬物由畜道而止之後,有禮生焉,履者,禮也,故畜之後為履。所有物之聚大小不同,高下不等,有美惡之分,是故物聚後必有禮。人到何處,必有禮也。卦體天在上,澤在下, 上下區分,尊卑立見,此理所當然也。此以柔藉剛,謙卑而順,説禮之道也。澤有卑順之意, 卦象『外健內卑』,禮之本也。\n一、女人在笠下:妾也。\n卦圖象解\n二、千里堠:遠離外出封侯象。\n三、文書破在地:憑證承諾無效象。四、傘下:有庇蔭也。\n五、軍旗下官人坐:主有官司、訴訟、牢獄之災象。\n六、二點在地:踐踏之足印也,示人處危之時,有禮之道,乃能行危而無殃也。\n人間道\n履虎尾,不咥人,亨。\n有禮之道即入險中,如踐虎尾,不見其反食人,乃因有禮也,所以亨也。故常言伸手不打笑臉人,同此。\n彖曰:履,柔履剛也。説而應乎乾,是以履虎尾,不咥人,亨。 # 兌為澤,為悦,兌以柔順之藉乾剛,與乾剛相乎應,又有禮於下,故踐虎尾而不相害也。\n剛中正,履帝位而不疚,光明也。 # 九五以陽剛又居君位,又得履道之善,此不病之因,乃得光明也。\n象曰:上天下澤,履。君子以辯上下,定民志。 # 天在上,澤在下,尊卑有分,此天下之正理也。人之禮常如是,有禮乃行也。君子觀履象, 以辨上下之區分,來立民志,民因上下區分明顯而志定,自此乃可言治。立法複雜,民無所從, 治世將不再。是故人各安其位,此得其分內也,如佔位又不進德,除之,由君子進任,士人進修學識,到一定程度而君子求之。士農工商各行業之人,因所享有限,而能有定志,則天下一\n心也。今人自下至公卿,日所志為尊榮,農工商人,日思於富侈,億兆之心交相為利,天下皆如此,心如何一致?要它不亂也難矣!皆因上下無定其志也。君子觀履,分區上下,使各當其位,用此以定民心之向也。\n初九:素履往,无咎。 # 初處於下,陽剛之才可以進,但外則表現其卑下之位的素養,無咎。\n九二:履道坦坦,幽人貞吉。 # 二為陰位,陽居,即意其待人之禮坦蕩蕩, 平易之道也。因為陽進,故須有防人嗤之禮數之誹,是以安幽清靜之心情處此時,則吉。\n六三:眇能視,跛能履,履虎尾, 咥人凶,武人爲于大君。 # 三為陽位,柔居之,乃志欲剛而體本柔, 不能堅心為履道。就如盲人之視而不見,跛人行路而不遠,意乃才能不足,又處時之不順遂, 則禮道非正,乃履於危地,因不善履道,入危地召凶,禍患立至,故咥人凶,就如武暴之人卻居人上,又任意為之,不知禮,乃凶之道。\n象曰:素履之往,獨行願也。 # 安於有禮之往,因非為利也,乃各有其志也。如人有行道之心,又有名利之心,交戰於中,豈能安履。\n象曰:幽人貞吉,中不自亂也。 # 履之道(禮之道)在於安靜,其因正,則所履安也,心中躁動,則不能安於禮道,此即有禮於人,必以心中安靜,如以利欲交爭於心中, 必自亂。\n象曰:眇能視,不足以明也,跛能履,不足以與行也。 # 陰柔乃才不足之人,視不明,行又不遠, 今又居剛之上,災難來矣。\n咥人之凶,位不當也,武人爲于大君,志剛也。 # 此凶之致,乃因才不足,以武人為喻,其居陽剛之位,但才不足,又強出之,則所履不由本道,屬於志剛又妄動。\n九四:履虎尾,愬愬終吉。 # 在近君之側,知伴君如伴虎,愬愬,畏懼之貌,意如能畏懼,則終必吉。上位之陽剛, 雖近處,能敬慎畏懼,即入危地終亦必吉也。\n九五:夬履貞厲。 # 夬乃剛決之意,九五雖示君位之人,居此位,任意剛決而行之,即得正,仍危厲也。古之聖人,居天下之尊,仍納眾言,明足以照, 剛足以決,必以明而動,動則志剛,此之所以為聖也,若自以為剛明,決行不顧,即使行正, 亦屬危道也。\n上九:視履考祥,其旋元吉。 # 人之禮,須視其終,若始終完備,善之至也。今人淺視人之表面禮遇,而不考證其始終, 禮之至善,其『始終如一』方是。\n象曰:愬愬,終吉,志行也。\n畏懼之貌,入危而終吉,因本心在於能行己之志願,故須居此位,故為行其志,而示畏懼之態,終得吉,其志遂行。\n象曰:夬履,貞厲,位正當也。 # 居正當之尊位,須戒剛決自任,否則招凶。\n象曰:元吉在上,大有慶也。 # 人之所以履善終能吉,貴乎有終,始終如一,禮之至極也。\n"},{"id":90,"href":"/zh/docs/culture/%E5%A4%A9%E7%BA%AA/%E4%BA%BA%E9%97%B4%E9%81%93/%E4%B8%8A%E7%BB%8F/09%E9%A2%A8%E5%A4%A9%E5%B0%8F%E7%95%9C/","title":"09風天小畜","section":"上经","content":" 09風天小畜 # 此卦韓信擊取散關不破,卜得之後,再撃之果破也。\n圖中:\n兩重山,一人在山頂,舟横岸上,望竿在草中,羊馬過河。\n匣藏寶劍之課 密雲不雨之象\n比乃親和,親和之後必有所畜,因物相比親則附於一處,為畜,此畜者,聚之來源也。同相親和之人,其志必同畜。畜亦止也,止然後有聚。此體本乾為天,乃在上之體,今居風之下, 示意吾人如要聚止剛健之人,唯有巽順為大,故世間再剛健之事與人,必為巽順所止也。本卦一陰居第四位,為五陽所包,此得位乃因柔巽之道也。卦象『外柔內剛』,乃能以小畜大。\n卦圖象解\n一、兩重山:知有險阻於前,不妄動之象。亦為出字象。二、一人山頂:孤立獨行象。已至顛峰,將走下坡之意。三、舟横岸上:準備出發,水未至而不行也。\n四、望竿在草裡:等待訊息象。草頭姓人為貴人。望,亡也。\n五、上有羊馬:馬引羊過河,馬為貴人,肖馬人,姓馬人也,馬亦為午時。\n全卦:一人在頂顛,但有疑,前有二山所阻,只有等待消息,午年或午月、午日、午時或馬人引羊至,不由水路而來也。 # 人間道\n小畜,亨。密雲不雨,自我西郊。\n此言雲之畜聚雖密,卻不能成雨,猶人雖聚,卻不能和之義。\n彖曰:小畜,柔得位而上下應之,曰小畜。 # 小畜之卦,因第四爻陰,居近君主之位,以巽之柔順本性,使上下之陽剛相互溝通,但只能維繫,卻不能強固,故曰小畜。\n健而巽,剛中而志行,乃亨。 # 以卦象而言,內剛健而外能柔順,雖為小畜,但能亨也。\n密雲不雨,尚往也。自我西郊,施未行也。 # 此言密雲不能成雨,猶小畜無法成大也,其因下陽往上,上陽往下,二氣不和。\n象曰:風行天上,小畜,君子以懿文德。 # 以乾之剛健,風仍能在上而行,故剛健之性,唯柔順能畜止之,君子以小則文章才藝,大則道德經綸之聖才,以此二道為所畜之才義。\n初九:復自道,何其咎,吉。 # 初入之地為初爻,以其剛健之性,又得上位之同性,進必上,何來災也。故初之陽剛, 乃因上位之陽剛性同,故無災也。\n九二:牽復,吉。 # 九二居將位,因與第五爻陽爻相對應,同為剛健,雖中有四爻,但因同為陽剛,自古『同患相憂』〔同慾相憎〕,吉之道,此時為將時。\n九三:輿説輻,夫妻反目。(説即脱) # 九三為下卦之上爻,最親密於四爻陰位, 故乃陰陽之情相處也。猶如夫妻。陰本受制於陽,今居四位居陽之上,即反制陽,如夫妻之反目,故如車輿脱去輪軸,不能行也。妻為夫所惑,反制於夫,乃因夫不正道。未有夫不失道,而妻能制之也。\n六四:有孚,血去惕出,无咎。 # 六四乃處近君之位,其能畜君,使五位君之威嚴能因之而止其欲,皆因六四有孚信(孚, 乃內有誠信也〕而受其感也。\n九五:有孚攣如,富以其鄰。 # 小畜之時,乃眾陽皆為其中一陰所畜之時。猶如一國之君,與臣下皆剛而不容(密雲不雨〕,但近君側之人,適得柔順之人,而此人為上下溝通之管道,此時乃即小畜之時也。九五為君位,如以中正居至尊之位,又內有誠信,則所有皆附應之。就好像富人出其財力與鄰共享之也。從此爻則知當君子為小人所困, 正人為艰邪所逼,此時如無上下正陽之互援, 則獨力難助於此時,須有互助方可去小人也。\n上九:既雨既處,尚德載,婦貞厲。月幾望,君子征凶。 # 上九為此卦之終極,乃處畜之終。和而能止乃畜之道,陰柔之能畜剛,由累積而成,非朝夕可得,此戒之在平時,如專任以柔制剛, 不知止,如婦之堅守此道則乃凶矣,天下無婦制其夫,臣制其君,尚能安穩者乎?月滿之時為月望,與日相敵狀。若君子待婦將成敵時,\n動之必凶,不須戒也,故须戒之於月未滿時。\n象曰:復自道,其義吉也。 # 初爻與第四爻本為對應之位,故在畜時, 陰在四,陽在初,陰陽相應,故吉。\n象曰:牽復在中,亦不自失也。 # 初爻為陽剛,復二爻亦陽剛,其勢乃強, 但因此時乃居中位,將位,故即強亦不會過剛, 過剛乃失,此天道。故吾人須知居中而陽剛之美,因理正,故能強剛,君子持之以理,雖剛亦不為過矣。\n象曰:夫妻反目,不能正室也。 # 因陽剛位之夫,過剛而失自處之道,不能顧家室,以致夫妻反目。故為夫之道,須正室其家,方為正道。\n象曰:有孚惕出,上合志也。 # 因四位有誠信且外柔,使五之君位信任之,乃合同志。五因四位之人能與之合,眾陽從之,故惕出則血去,不見兵禍矣。\n象曰:有孚攣如,不獨富也。 # 是故君子處之艱危,唯其至誠,能得眾力之助,則平安矣。(攣如即牽連使相從之。)\n象曰:既雨既處,德積載也;君子征凶,有所疑也。 # 此小畜之道積滿而成,其象為陰將盛樣, 君子動則有凶也。小人抗君子,必有害於君子, 制陰極盛之道,則為君子有疑而知警惕,不妄動,求其所以制君子之因,則不至凶也。\n"},{"id":91,"href":"/zh/docs/culture/%E5%A4%A9%E7%BA%AA/%E4%BA%BA%E9%97%B4%E9%81%93/%E4%B8%8A%E7%BB%8F/08%E6%B0%B4%E5%9C%B0%E6%AF%94/","title":"08水地比","section":"上经","content":" 08水地比 # 此卦陸賈將説蠻,卜得之,果勝蠻王歸也。\n圖中:\n月圓當空,秀才望月飲酒,自酌自斟, 藥爐在高處,枯樹開花。\n眾星拱比之課 水行地上之象\n師之後,師者眾也,眾聚有相比親也。比,相親也,有眾則必有比,故次師也。物之相近莫如水與地,永遠相比附,故卦體為水在地上,地上有水象。水為險,地為順,故外險內順為比卦。險猶戰戰兢兢,恐得罪人象,內心又持順之地道,有坤厚之德性,此比之道也。\n卦圖象解\n一、月圓當空:政治清明象。\n二、三星拱照:得賢能剛直之人輔助。\n三、秀才望月飲酒:示才智之人無憂也。(亦示:作秀之人,於政治清明時,無法出頭〕四、自酌自斟:無慾則剛象,孤獨之象。\n五、藥爐在高處:無病無災,故不需用藥也。\n六、枯樹開花:晚發也。示心有誠,制度立,事必成。七、酒:忘憂之物。\n全卦:上位清明,三台輔佐,人將無憂,即使枯樹亦能開花。 # 人間道\n比:吉,原筮,元永貞,无咎。不寧方來,後夫凶。\n比為吉之道。人相親比,必有其道,須視可比而決定比之,不可比而比,凶。如果等到不能自保安寧之時,方開始求親比,幸運的得所親比,可得平安。但如仍獨立自持,求親比之想法不前反後,即使是丈夫之親人,亦招凶矣。\n是故君王亦懷柔天下,下位之人亦和親其上,未能獨立也,平日即須有此志,天地之間, 沒有不相親比而能獨立生存的。\n相比之道,须兩志相求,如互不相求則為睽卦,故戰國策〈中山策〕有同慾相憎,同憂相親之理。所以好的制度,能使人有所依從,故能互相親和也。\n彖曰:比,吉也。比,輔也,下順從也。 # 因相親比為吉之道,有相輔相成之意。但求比之道,須如臣下順從君上一樣,即使地位相同,或上下有別,決不可因持於己之高位,而忘順從之道,此乃真比之道。\n原筮,元永貞,无咎,以剛中也。 # 元、永、貞為相比之道。元為君長之道,永為可以長久,貞謂得正道且堅其心志,人有此三德性,再以陽剛當君主之位,此為君之德也。如此之君主可以無災矣。\n不寧方來,上下應也。 # 上位之念,應知君不能獨立,须保民以此為安。下民知己不能自保,須擁君以求寧,此上下相應之理。如以王之私而行為之,不求下民之附和,凶危立至矣。\n後夫凶,其道窮矣。 # 若眾人之志相和親,則無有不遂,此天地之道,若人之和親不行,則雖夫亦凶矣。\n象曰:地上有水,比。先王以建萬國,親諸侯。 # 天地之間,物相親比,莫如水在地上,聖人觀比之象,以此建萬國,近諸侯,親近人民, 此比之極道。\n初六:有孚,比之无咎。有孚盈缶, 終來有它,吉。 # 此為比之始。孚為中信,故相比之道,以誠信為本,表裡不一致,人誰近之。誠信充實於內,如於缶中之滿,且外不加修飾(它,外也〕,至誠以待之,則無不信。\n六二:比之自内,貞吉。 # 自內言主之在己也。得正道,而與君道相合,此吉也。即以中正之道,合於上位之所求, 乃曰自內。今人汲汲以求比者,非君子自重之道,乃自失之道也。\n六三:比之匪人。 # 相親比之人,如為不正當之人,即為匪人, 招凶也。\n六四:外比之,貞吉。 # 六四之位,為近君之位。居此位之人,以柔性坤德向君主親比,且堅心順從,吉也。\n象曰:比之初六,有它吉也。 # 此即比之道在乎始也。始即能中實誠信, 終吉,始無誠信,終凶。\n象曰:比之自内,不自失也。 # 堅守己中正之道,以待上求,乃不自失也。此易之戒。故士人修己,方求上進之道。降志辱身此絶非自重之道。吾人救天下之心,並非不急切,乃因須待禮遇之至方出也。\n象曰:比之匪人,不亦傷乎。 # 相親於匪人,必將得悔咎,傷之大矣,君子須深戒所親比之人。\n象曰:外比於賢,以從上也。 # 六四之位從於五君之位,為外比。因五位剛明之賢為中正,故附從之,此從上之道,絶不可盲從。\n九五:顯比,王用三驅,失前禽,邑人不誡,吉。 # 人君親比天下之道,须誠意以待物,恕已以及人,發政施仁,民之所好好之,民之所惡惡之,為民圖利,使天下蒙其澤,此盡善也。則天下亦盡親於上也。如表彰己之小功,展示自己的權力,違反道德人心,強力施為,致人心不信,社會浮華,人偷敗亡,如此而求天下親比是不可能的。就好比君王出獵,只圍三面開一面,禽獸前去者,免矣,此曰失前禽,只取來者, 此王道也。邑者,居邑,誡,期約也。此意臣之效於君,竭盡忠誠,用盡才能,以彰顯親君之道,此為正道。至於用之與否,決定在君,萬不可阿諛奉承,妄求君之親比也。朋友亦然,須修身誠意待人,至於是否願意與己相親,在人而己,千萬不可巧言令色,曲從苟合,以求人之相比。『此顯比之道』。\n象曰:顯比之吉,位正中也。舍逆取順,失前禽也。邑人不誡,上使中也。 # 故顯比之所以吉,以其所在之位,得適中之法也。且比之道,须舍逆取順,孟子收徒之道: 往者不追,來者不拒也。三面圍網,所失唯前禽,即言此來者撫之,去者不追之理。\n上六:比之无首,凶。 # 上六乃比之終極處。凡比之首,其始善, 終亦必善矣。多數人皆有其始,而無其終,但從未有人沒有開始,而有終善也。故此言,比之無首(首,始也〕,至終必凶矣。今天下之人,始親和比之時不以正道,至終必有隔閡, 此類人居多數。\n象曰:比之无首,无所終也。 # 此即始不以道,則至終必也凶矣。是故交友之道,你來我往,你不來,我何往。如我往, 必有求,須以正,否則終將隔隙,互相仇視也。\n"},{"id":92,"href":"/zh/docs/culture/%E5%A4%A9%E7%BA%AA/%E4%BA%BA%E9%97%B4%E9%81%93/%E4%B8%8A%E7%BB%8F/07%E5%9C%B0%E6%B0%B4%E5%B8%AB/","title":"07地水師","section":"上经","content":" 07地水師 # 此卦周亞夫將欲排陣,卜得之,果獲勝。\n圖中:\n虎馬羊待命,將軍臺上立,一文一武綬印,人跪台上受印,棋盤在下。\n天馬出群之課 以寡服眾之象\n師之興,因有爭議也。此卦本體上順下險之象,亦即地下有水,內外分即內險外順,有行險之道如何順行之義。水在地下必聚,如部隊之集結,如九五為一陽,餘為諸陰,則為天子之象,今九二為將位,統帥諸陰,則為將帥之象。此卦申明處師之時,內須戒慎警惕,不可掉以輕心,外須統一專權,命令如一,方能行順之義。\n卦圖象解\n一、肖虎、馬、羊之人三合:可戰。二、文武二官綬印:得萬人服之帥。\n三、人立棋盤上:圍棋愈下愈多,但須有謀略方可成事。 四、將軍臺上立:示有兵權也。生殺之專權也。武職大利。\n五、羊回首:回陽也,病危之人卜之,有自陰間回陽間之象。六、虎在馬後:為馬之靠山,馬因虎威而動。故馬回首視虎。\n人間道\n師:貞,丈人吉,无咎。\n此言,師之道必有正名,因師之與,天下萬民必傷,如不以正,則民心不從,致凶之道。丈人,即尊貴有眾望之人,故出師必立帥將,此人必民所聽從順同,不能服眾人,安得民心。如此則無災。故興師必有二道:正名與主將。\n彖曰:師,眾也;貞,正也,能以眾正,可以王矣。 # 師為帶眾之道,必以正,如能使眾人皆正,即可王天下。此用師之正道。\n剛中而應,行險而順。 # 出師之將帥,須得君王之專信也。此王者之帥,民心從之,雖有險必順矣。\n以此毒天下,而民從之,吉又何咎矣。 # 如從前面所論之師道下手,則雖因出師傷下之百姓,民仍從之,故吉而無災。\n象曰:地中有水,師,君子以容民畜眾。 # 水在地下相聚,為眾聚之象,君子之人須以包容民眾,為使民順從之法。\n初六:師出以律,否臧凶。 # 師之出,必以誅亂制暴而動,行師之道, 须以律法,合於義理,如不按此法,即致勝亦凶。\n九二:在師中,吉,无咎,王三錫命。 # 此言九二之道,為為將之道,君之事為人臣絶無敢專權,但出師作戰之時,則將在外君命有所不從,君王須順從其命,以專信任之。\n六三: 師或輿尸,凶。 # 輿尸,眾人為主也。此言師旅之事,任當專一,須以剛中之才居上為信,乃得成功。如不這樣,而以眾之意見為主,凶之道也。\n六四:師左次,无咎。 # 左次,乃知不可進而退。此言,師之常法, 見可而進,知難而退,進退有據,平安無災。六四為陰爻,主陰柔,而師必以強勇,中有陰柔,即兵家之風、林、火、山同義。\n六五:田有禽,利執言,无咎。長子帥師,弟子輿尸,貞凶。 # 五為君之位,此為興師之主,此君主興師任將之道。師之興,必以生民受災,蠻夷賊寇, 此正名以誅之。如禽獸之入於田中,害五穀, 於義宜獵取,則獵取之,如此之動,則吉。如無禽獸入田,則出不因禽,凶。此有禽之義。\n上六:大君有命,開國成家,小人勿用。 # 師之終極,言功之成也。此時君主以爵位財祿賞賜有功之人,並任用之。但於軍旅征戰中,查覺出之小人,不能因其有功而任用之, 致凶之道。\n象曰:師出以律,失律凶也。 # 師出必有律法,失律法,致凶之道。\n象曰:在師中吉,承天寵也。王三錫命,懷萬邦也。 # 人臣如無君之專寵,則不得專制之權,更何論成功。\n象曰:師或輿尸,大无功也。 # 如倚二三人以上,必誤之時也,不但無功, 凶禍至矣。\n象曰:左次,无咎,未失常也。 # 言師之道,退亦未必為失道,退須得宜, 無災。\n象曰:長子帥師,以中行也,弟子輿尸,使不當也 # 任將授師之道,必以長子帥師。此長子義, 非定為真長子,而示意為任用可信任之如長子者,可以為帥,以其專任後之權必大,故必有信:方可。若以眾人主之,無主將帥之師,雖名正出師於義,亦凶道也。\n象曰:大君有命,以正功也。小人勿用,必亂邦也。 # 君王有持恩賞之權柄,来表揚軍旅之功, 小人雖亦有賞,用之必亂邦,史上小人持功而亂邦,有太多案例了 。\n"},{"id":93,"href":"/zh/docs/culture/%E5%A4%A9%E7%BA%AA/%E4%BA%BA%E9%97%B4%E9%81%93/%E4%B8%8A%E7%BB%8F/06%E5%A4%A9%E6%B0%B4%E8%AE%BC/","title":"06天水讼","section":"上经","content":" 06天水讼 # 此卦漢高祖斬丁公,疑惑,卜得之後,果遭戮也。\n圖中:\n口舌二字,山下有睡虎,文書在雲中, 人立虎下,望口舌,柳樹在旁。\n蒼鷹逐兔之課 天水相違之象\n人之所需飲食也,因有所須,爭訟之所由起。天陽上行,水性就下,其行相違,所以成訟也。卦義:外健內險之象,必有訟。\n卦圖象解\n一、口舌二字:官司,糾紛,災也。二、山下有睡虎:入危地而不知象。三、虎:王姓,肖虎之人。\n四、文書在雲中:心想事不成,幻想也。 五、人立虎下:近險也,近險為脱險之道。\n六、柳樹:隨風而動,雖大風而不斷,此柳之性也。〔韓信之辱,即柳性〕\n人间道\n訟:有孚,窒惕,中吉,終凶。\n訟之道,必中實,如中無有實,乃誣妄,凶之道也。訟,乃與人爭辩,而待決於他人,雖有孚,仍會窒塞。故有得中實則吉,但終極其事則凶也。\n利見大人,不利涉大川。 # 因訟者求辩曲直,故利見大人,如大人能以剛明中正決其訟,吉。因訟非吉事,須擇安地而居,不可陷於險難,故不利涉大川也。\n彖曰:訟,上剛下險,險而健,訟。 # 此內險外健,訟之所起。若健而不險,不生訟也。險而不健,不能訟也。猶如一人,只重外飾,內無真實材料,此為訟之源也。\n訟,有孚,窒惕,中吉,剛來而得中也。 # 訟之,有中實剛健,但處訟之時,雖有中實,仍有阻礙而須惕懼,則吉。\n終凶,訟不可成也。 # 因訟本非善事,乃不得已也,終極其事,則凶矣。\n利見大人,尚中正也。不利涉大川,入于淵也。 # 如見中正之大人,吉。與人訟必先居於平安之地,任意行險,則身陷。\n象曰:天與水違行,訟,君子以作事謀始。 # 此因天上水下,二卦體相違,訟之由也。君子觀象,知人有爭訟之道,故行事必「慎始」, 絶訟端於事之始,則訟不生矣。\n初六:不永所事,小有言,終吉。 # 此陰柔居下位,必不可終極其訟也。若不終極其訟,雖小有言傷,而終得吉。\n九二:不克訟,歸而逋,其邑人三百户,无眚。 # 二爻與五爻為相應之地,九二乃將位,以剛處險,與五之君位為敵,知不可敵,歸而避之,儉樸自處,則無過矣。三百户,乃邑之至小者,如處強大,此競也,則必過也。\n六三:食舊德,貞厲終吉。或從王事,无成。 # 陰爻居三陽剛位,乃陰柔居二剛之間,须知雖處危地,能知危懼,終必獲吉。守原之本分而無所求,則不生訟矣。或從上而成,因從王事,故不在己也。訟乃剛健之事,故始則不永,三則從之,皆可使善也。\n九四:不克訟,復即命,渝安貞, 吉。 # 此陽剛居乾下,因不得中正,本必訟。故四爻剛位陽居之,雖剛健欲訟,而無與對敵, 則訟無由而興。此即若能克制剛忿欲訟之心, 就於命,革其心,平其氣,而變為安貞,吉矣。孟子云:「方命虐民,夫剛健而不中正,則躁動,故不安,處非中正,故不貞,不安貞,所以好訟也。」方,不順也。\n九五:訟,元吉。 # 九五居尊位,治訟得中正,則大吉而盡善。\n上九:或錫之鞶帶,終朝三褫之。 # 剛健之極,處訟之終極,人因其剛強,窮極於訟,取禍喪身,即使善訟能勝,即賞,亦來自與人仇爭所得,其能保乎。終一日而見三次褫奪也。\n象曰:不永所事,訟不可長也。雖小有言,其辯明也。 # 此即於訟之初,即戒訟,因柔弱而居下, 不可長久。因既訟,必有小災,應辩理之明, 終得其吉。\n象曰:不克訟,歸逋竄也。自下訟上,患至掇也。 # 因義不敵,故不能訟,須避去其所。自下訟上,義不正,且氣勢不足,此招禍患之至易也。\n象曰:食舊德,從上吉也。 # 守其本分,順從上之所為,非由己意,終也吉。\n象曰:復即命,渝安貞,不失也。 # 能如上,則無失,吉也。\n象曰:訟元吉,以中正也。 # 此云中正之道,施必吉也。\n象曰:以訟受服,亦不足敬也。 # 窮極訟事,即有受命之寵,亦不足以敬, 而可賤惡,禍患隨至也。\n"},{"id":94,"href":"/zh/docs/culture/%E5%A4%A9%E7%BA%AA/%E4%BA%BA%E9%97%B4%E9%81%93/%E4%B8%8A%E7%BB%8F/05%E6%B0%B4%E5%A4%A9%E9%9C%80/","title":"05水天需","section":"上经","content":" 05水天需 # 此卦蔡順遇赤眉贼,卜得知,乃知必脱大難也。\n圖中:\n月當天,一門,一人攀龍尾,一僧接引,一墓。\n雲靄中天之課 密雲不雨之象\n蒙之後須養,此需之時也,飲食之道也。雲之上於天有蒸潤之象,就如同飲食之於人一樣, 此養之時,乃待也。乾之性健,必採進法,仍處坎險之下,故須待而後進,即外險內健,此卦之象,乃真養之義。\n卦圖象解\n一、月當天:陰人居上位象,無擔當也;清明無災。二、 一門:豪鬥也,官府也。\n三、一人攀龍尾:附於貴人,想進據其位也。\n四、一僧接引:光頭之人,柔而無慾之象。入空門可解。五、墓:藏棺之地,有官與財象;置之死地而後生之象。\n全意,即使如龍之力大,但動不以時,則有入墓之險,此動為求官與財。佛理亦同:『生死一線,無死焉大生』。 # 人间道\n需:有孚,光亨贞吉,利涉大川。\n以二卦體而言,乾以剛健,如上進而遇險,此未可進也。需待之。以卦之爻言,五爻陽居陽位,乃陽剛中正之德人,居君位,而誠信充實於內(內卦為乾),則光明而可進,必亨,故利涉大川。\n彖曰:需,須也,險在前也,剛健而不陷,其義不困窮矣。 # 因險在前,未可遽進,须待而進,以乾之剛健,能待而不輕動,則不陷險中,則必不至困窮。時下剛健之人頗多,其動必躁,如能待時而動,則為至善之道。\n需:有孚,光亨貞吉,位乎天位以正中也。 # 孚,中實之義。五位因剛實居中,此孚之象,此居天位而能正中,光明而亨通,且須貞正\n(堅心),吉也。\n利涉大川,往有功也。 # 因中實(內之學問、操守)而貞正(堅心向道),即涉險阻,亦可有功也。故需之道在以乾剛之性而知待之道,何所不利。\n象曰:雲上於天,需,君子以飲食宴樂。 # 此自然之象,水上於天未成雨,為雲。猶君子積蓄其才德,而未施於用也,懷其大才,安以待時,飲食以養身體,宴樂和其心志也。\n初九:需于郊,利用恆,无咎。 # 初爻因最遠於險,故於郊(曠遠之地), 故君子處於曠遠之地,仍安守其常,則無咎災也。如躁進犯難,則必災至矣。\n九二:需于沙,小有言,終吉。 # 坎為水,水近則有沙,此二爻之位近險, 故需于沙。君子知渐近於險難,雖未至於害, 已小有言矣。此示言語之傷,至小者也。二爻以陽爻居之,示人陽剛之才居陰柔位,守中正之德,雖小有言語之傷,而无大害,終也吉。\n九三:需于泥,致寇至。 # 泥,逼近於水也。因逼近於險,而致寇難之至,此居健體之上,有進動之象,苟非謹慎, 終致喪敗也。\n六四:需于血,出自穴。 # 第四爻位以陰柔之質居於險,下又有三陽之進,必傷於險難。因傷於險難,必不可安居, 而失其居所,故出自穴。此順時以從,不競於險難,則不至凶也。又無中正之德,只以剛競於險,此適足以致凶之道也。\n九五:需於酒食,貞吉。 # 此五君位,陽剛之人居中,只需宴安酒食以待之,所需必得也。堅心,必吉。\n上六:入于穴,有不速之客三人來, 敬之終吉。 # 陰柔於六位,乃需之極限,須安其處,此入於穴之義。安而止居,則下之三陽必來。不速,不促之而自來也。此時須以至誠盡敬之心以待之,雖再剛暴,必無欺凌之理也,此因六位陰位,非三陽乾體之人,志在必奪,故敬之則吉。\n象曰:需于郊,不犯難行也,利用恆,无咎,未失常也。 # 君子處下野,不冒犯險難而行,復宜安處, 不失其常,保持恆靜心亦不動,如雖不進而志動,則不能安其常也。\n象曰:需于沙,衍在中也;雖小有言,以吉終也。 # 此寓,二位雖近險,而如以寬裕居中,即小有言語之傷,及終得吉。\n象曰:需于泥,災在外也。自我致寇,敬愼不敗也。 # 三位居上險之最近,故云災在外。此寇致之因,實乃己之逼也。須敬慎小心,量宜而進, 則无喪敗。義在相時機而動,戒之盛也。即盛時須戒之象。\n象曰:需于血,順以聽也。 # 意為陰柔之性居近險之位,不能處,則退, 以順從而聽於時,不至凶也。\n象曰:酒食貞吉,以中正也。 # 陽剛中正居五之君位,只需酒食,即可盡其道也。\n象曰:不速之客來,敬之終吉;雖不當位,未大失也。 # 此不當位,意為以险而在上也。聖人明示陰宜居下而今居上,此不當位也。但如能謹慎自處,則陽再剛亦不能欺,終得其吉。\n"},{"id":95,"href":"/zh/docs/culture/%E5%A4%A9%E7%BA%AA/%E4%BA%BA%E9%97%B4%E9%81%93/%E4%B8%8A%E7%BB%8F/04%E5%B1%B1%E6%B0%B4%E8%92%99/","title":"04山水蒙","section":"上经","content":" 04山水蒙 # 此卦王莽篡漢社稷卜得知,乃知漢家必有中與之主。\n圖中:\n一鹿一堆錢,一合子,李樹一枝子折, 二人江中撑船,珍寳填塞。\n人藏祿寶之課 萬物发生之象\n艮為山,為止,坎為水,為險。山下有險,遇險而止,莫知所之,蒙之象也。\n卦圖象解\n蒙卦有示吾人去蒙之道。反觀之,如人以蒙蔽之法示人,勢必有所圖也。圖中:\n一、滿船珍寳:暗示人圖財之象。二、船在水上:乃離國他去象。\n三、鹿在地上,示仍有小祿於內,鹿—祿也。\n四、兩串錢:乃憂心忡忡象,即意雖有祿,但仍令人憂心。\n五、圖中一碗:示此蒙蔽手段,必先成後破,因其乃不正之財,必不久遠也。六、李樹子折:示人李姓人氏,且有子夭折之象,則成格。\n人间道\n蒙:亨,匪我求童蒙,童蒙求我。初筮告,再三瀆,瀆則不告,利貞。 # 兒童之蒙,其未發蒙,而志則專一,此為童蒙,因有童蒙,則告之。因其童蒙,故必至誠一意以求必中。而發蒙之道,必以貞正方吉。\n象曰:蒙,山下有險,險而止,蒙。蒙亨,以亨行,時中也。匪我求童蒙,童蒙求我,志應也。 # 此因剛賢之才居九二爻位,處於下位。六五之童蒙,處於君位。九二之賢臣,必以時中也。時之義,必待其至誠一心之童蒙求問,方以告之,乃中與。如賢能之人處下,而自求為進,主動告以君,則必無被信用之理。故如方法正確,非二求於王君位,實為五之志應於下二也。此為「時中」也。\n初筮告,以剛中也,再三瀆,漬則不告,瀆蒙也。 # 此言,如誠一而來求決其蒙,當以剛中之道開發之,如煩數不能誠一,則乃瀆蒙,此時, 不當告。因告之必不能信受,徒以煩瀆,無益矣。\n蒙以養正,聖功也。 # 此申利貞之義,養蒙之法,必以正道,此時乃純一未發之童蒙時,故養正於蒙,乃學之至善也。現今人類皆「教而後禁」,故難以教勝,故時風日下。\n象曰:山下出泉,蒙,君子以果行育德。 # 此蒙之象也,如人蒙蔽,未知所適從狀。君子此時,必以果決其行,使通行無阻。如始生而方法不對,則以養育其明德為教法。\n初六:發蒙,利用刑人,用説桎梏, 以往吝。 # 初六之爻屬最下位,此言,發下民之蒙, 须明刑禁以示之,使之知懼,從而教之。是故為政之始,立法居先,治蒙之初,威以刑者, 是以使民去其昏蒙之桎梏。不設法去其蒙之桎梏,即善教亦無法改變其蒙,故聖人使下民畏威以從,不敢任意其昏蒙之欲,然後才能漸知善道,此為移風易俗之唯一法門。但只有初爻之始可用之,如專用刑以為治,則蒙雖畏,終不能发。\n九二:包蒙吉,納婦吉,子克家。 # 九二有剛明之才,與六五之君相對應,其志又一同,當其時,必廣其包容,老人婦孺之見,皆包容,則能啓天下之蒙,功大矣。今人專持其明,漫用自任,致凶之道。是故古之堯舜,其聖功天下莫及,尚能請教下民,取合理之言,天下之民歸之,就如兒子能治其家一樣\n六三:勿用,取女見金夫,不有躬, 无攸利。 # 三爻之位陰居之,此時機正應上位不能遠從近見,九二為群蒙所蔽,居此之時,無所往則利矣。猶女之嫁夫,當由正禮,如見人多金, 悦而相從,不可取也。\n六四:困蒙,吝。 # 因六四之陰爻,離剛賢最遠,無由來發其蒙,終困於昏蒙也,其永不足矣。\n六五:童蒙,吉。 # 此示柔順之人居君位,下應於二,乃示柔中之德,任剛明之才,如此能治天下之蒙,吉也。為人君者,如至誠用賢,以成功惠於百姓,此功亦猶如己出一樣,何須顧忌手下,功高震主。\n象曰:利用刑人,以正法也。 # 此即用立法制刑,以教人之意,萬不可不教而誅。後世之論刑者,不復知教化孕其中, 只一昧的論刑。\n象曰:子克家,剛柔接也。 # 兒子之能治家,其因父之信任專一也。是故九二能成啓蒙之功,乃由五之信任專一也。此剛柔相接之義也。\n象曰:勿用取女,行不順也。 # 此女不可取,因行不順故也。\n象曰:困蒙之吝,獨遠實也。 # 此義昏蒙之人,不能親賢以致困,終不得明矣。實,陽剛也。\n象曰:童蒙之吉,順以巽也。 # 從人之言,只要合理,都能接受,順也。降尊位下求,巽也。君能如此,天下治矣。\n上九:擊蒙,不利爲寇,利禦寇。 # 陽剛居蒙之極,爻之終,示吾人知,人之愚蒙至極,而為宼為亂者,當擊伐之,故戒不利為寇。\n象曰:利用禦寇,上下順也。 # 禦寇之限度,上不過暴,下得撃去其蒙, 則上下皆得順矣。\n"},{"id":96,"href":"/zh/docs/culture/%E5%A4%A9%E7%BA%AA/%E4%BA%BA%E9%97%B4%E9%81%93/%E4%B8%8A%E7%BB%8F/03%E6%B0%B4%E9%9B%B7%E5%B1%AF/","title":"03水雷屯","section":"上经","content":" 03水雷屯 # 此卦季布遇難,卜得之漢推其忠,乃赦其罪。\n圖中:\n人在望竿頭立,車在泥中,犬頭上回字, 人射文書,刀在牛上,一合子。\n龍困淺水之課 萬物如生之象\n雲雷之興,陰陽始交,而未成澤,故為屯,如成澤則為解,此卦動於險中,乃屯之義,如陰陽不交則否,此時機乃天下屯難,未亨泰之時也。\n卦圖象解\n一、人在望竿頭上立:此不明局勢之屯難時也,不可妄動也。二、犬上回字:乃肖狗之人,狗年,狄姓之人,果為哭之象。三、車在泥中:進退兩難之象。刀在牛上:解也,牛為貴人象。四、人射文書:張姓,小人阻礙也。射:同音,色也,緋聞。五、牛不順道:背道而馳,反其道而為之,吉。\n六、人望:人亡也。七、人立:在位也。八、盒:先成後破。\n九、牛回頭視犬:計無所出,待也。\n卜得此卦:動於險中,先成後破也。 # 人間道\n屯:元、亨、利、貞,勿用有攸往,利建侯。\n此義處屯之時必貞固(堅心),須明非獨力能成,必廣資助,此利見侯之義。不可往,唯利建侯。\n象曰:屯,剛柔始交而難生,動乎險中。 # 剛柔始交而未通暢,則難屯,難生,於此之時,如動即往,乃取險之道。\n大亨貞,雷雨之動滿盈。 # 此言屯有大亨之道,得陰陽交而和洽,則成雷雨,充滿天地之間,此因貞固才能出屯。\n天造草昧,宜建侯而不寧。 # 天時造出地上之草,亂而無序,此暗昧不明時,須建立輔助,可以有助矣,此聖人之戒。\n象曰:雲雷,屯。君子以經綸。 # 水未能成雨為雲象,君子觀屯象,知須立法規範來經營此時。\n初九:盤桓,利居貞,利建侯。 # 初而陽爻居下,意乃剛明之才,當屯難之時,而居下位。此時未便往濟,故盤桓。如遽進,則犯險遇難。此即示人處屯難時,須守正方是,現今之人,鮮少人於屯難之時守正。聖人戒之於屯時。\n以貴下賤,大得民也。 # 象曰:雖盤桓,志行正也。\n聖人處屯時,雖有濟屯之志,仍盤桓不動, 因時未至也。此居下位之念。\n初陽之剛健,居陰之下,此易以貴下賤之象。即處屯之時,陰柔不能存,唯一陽剛之才, 則眾所歸從也。更加此人能自處卑下,所以大得民心。\n六二 :屯如邅如,乘馬班如,匪寇,婚媾,女子貞不字,十年乃字。 # 此陰爻之柔居屯世。受逼於初剛健之人, 故柔處屯時,無法自濟,又受下之陽剛所逼, 為難為也。非理而至者為寇,柔守中正不苟合於初剛之意,須十年久久乃通。\n象曰:六二之難,乘剛也。十年乃字,反常也。 # 此六二患難,因柔,又居屯時,又有下之陽剛所逼,此患難乃因柔而生,須十年,難久必過。如反其柔之常性,而與陽剛合,則省十年,十為數之終也。\n六四:乘馬班如,求婚媾,往吉, 无不利。 # 此以柔順居近於君位,此得之於上者,而才不足以濟屯時,須求賢自輔,則往而利矣。此即居公卿之位,己之才不足以濟屯難之時, 若能求在下之賢人,親而用之,則必平安有利。\n九五:屯其膏,小貞吉,大貞凶。 # 五居尊位得正,當屯時,若有剛明之才為位,則利。如無,則屯其膏,此因無良臣,而施為有所不下於民,民未得其德澤,乃因威權不在己之故也。此屯因威權已去而妄想瞬間正回,此求凶之道。须以渐正之,方吉,如不為會因當屯時不改,以至於亡矣。\n上六:乘馬班如,泣血漣如。 # 六此以陰柔居屯之終,此險之極而無援, 居之不安,動無所向,此窮厄之\n極,若陽剛而有助,即屯至極,亦可平安互濟矣。\n六三:即鹿无虞,惟入于林中,君子幾,不如舍,往吝。 # 此陰爻居三之陽位,此意柔居剛而不中正,則必妄動,貪於所求,必不足自濟,此不安之源。如入山林射鹿,又無嚮導,此不安也。君子見事於微,捨而勿逐,如往取,徒取其咎也。\n象曰:即鹿无虞,以從禽也,君子舍之,往吝窮也。 # 事因欲而妄動,此貪禽也。處屯之時,不可動而動,凶也。君子捨之不從,則無咎,如往必困窮也。\n象曰:求而往、明也。 # 知己才之不足,往而求賢,以濟己之不足、此乃真明也。\n象曰:屯其膏,施未光也。 # 膏澤不下及,此人君之屯也,乃因威權不在之象。\n象曰:泣血漣如,何可長也。 # 如於屯難窮極,而莫知所為,則至泣血顛沛,則必不長久。\n"},{"id":97,"href":"/zh/docs/culture/%E5%A4%A9%E7%BA%AA/%E4%BA%BA%E9%97%B4%E9%81%93/%E4%B8%8A%E7%BB%8F/02%E5%9D%A4%E4%B8%BA%E5%9C%B0/","title":"02坤爲地","section":"上经","content":" 02坤爲地 # 此卦漢高袓與項羽交爭卜得之,乃知身霸天下。\n圖中:\n十一箇口,一官人,坐看二串錢。一官人立受命,一馬,金甲神人在台上乘雲下, 綬文書與官,土堆上有四點。\n生載萬物之課 君倡臣和之象\n一、十一口:吉也,陳姓人也。\n卦圖象解\n二、一官人:倌人,丈夫也。公務員也。 三、坐看二串錢:欲拿不得,憂心忡忡狀。四、一馬者:肖馬人;指官貴;指調動也。五、金甲神:示民心也。\n六、授文書與官:授官封侯。\n七、土堆上有四點:黑也,背有陰謀也。\n人間道\n坤:元、亨、利牝馬之貞。\n坤乃地之厚德,示人效地之性,如牝馬之柔順而健行也,貞示堅心,但與乾不同,乃堅心於柔順之念也。\n君子有攸往。 # 君子因行柔順,且表裡一致,合乎坤地之德性。\n先迷後得,主利。 # 陰從陽也,即不了解但知須從陽剛之中正,如不知而欲進,則迷錯,損失在己,須居於後, 則利,如君臣之道,柔順乃臣之職也。\n西南得朋,東北喪朋,安貞吉。 # 西南為陰位,東北為陽位,陰必從於陽,陽之正而陰亦正,由陽剛之中正,去蕪存菁,故能成化育之功也。\n彖曰:至哉坤元!萬物資生,乃順承天,坤厚載物,德合无疆。 # 坤之道大也,萬物因乾而始,因坤而生,猶父母之道,坤之德厚,故能持載萬物。\n含弘光大,品物咸亨,牝馬地類,行地無彊,柔順利貞,君子攸行。 # 含弘光大此四者,用来形容坤道,含,包容也,弘,寬裕也,光,昭明也,大,厚且博廣。聖人有此四德,故能成天之功,牝馬因柔順而能行,故健。\n先迷失道,後順得常。西南得朋,乃與類行;東北喪朋,乃終有慶, 安貞之吉,應地无疆。 # 因先迷而從陽剛之德,後必得理,西南為陰,同類而行,故得朋,東北陽方,離其類而喪朋,因離其類而從陽,故能成物之功也。終必吉,又因能安心堅守此道,則無所不可往矣。\n象曰:地勢坤,君子以厚德載物。 # 坤地之道至大,聖人體之,君子以地之柔順且厚能載物之德處事。\n初六:履霜,堅冰至。 # 聖人於陰之始生,知其將長,此時戒之, 猶人之履及霜,則知後必有堅冰,故小人始雖甚微,但絶不可使其長,長盛則凶也。\n六二:直方大,不習,无不利。 # 聖人以地之直、方、大三德以盡地之道。直者,直來直往,不迂迴作假。方者,原則不因局勢而改變,且正。大者,容載萬物之胸襟乃大也。不習,謂順其自然,則無往不利。亦可云,因又直且方,氣勢乃大也。\n六三:含章可貞,或從王事,无成有終。 # 六三居相位,為臣之道,如有含章之美, 即不居其功,將善歸於君王,乃可因王無忌惡之心,終必吉。\n或從王事,知光大也。 # 象曰:履霜堅冰,陰始凝也,馴致其道,至堅冰也。\n陰始凝為霜,渐盛則至堅冰,故聖人戒小人於初,如任小人因循不阻止,則終至凶。故吾人須戒之於初,小人表面常有可憐,博人同情,示弱以求憫恤,實則內心有所圖,今人不知濟弱扶倾與姑息養奸乃一線之差,一昧以濟弱扶倾為自傲,殊不知其姑息養奸,而至今小人才盛。\n象曰:六二之動,直以方也,不習, 无不利,地道光也。 # 地道之光顯,由其直且方大,人人順其自然,則奸人何所遁形,無往不利。\n象曰:含章可貞,以時發也。 # 處臣下之道,不當有功,免招君忌,但義之當為時,須立行之,此因不有其功,不失其宜,皆因時也。今人含而不為,皆不忠之人。\n聖人知含章之光大,故能養晦,小人有善唯恐人不知,此君子小人之分野。\n六四:括囊,无咎,无譽。 # 六四為居近君之側位,藏口而不露,則平安無災,此沈默之功也。\n六五:黄裳,元吉。 # 坤本論為臣之道,今臣居尊位,或婦人居尊位,须有黄裳之戒,應守中而居下,不可揚溢無節制,否則必有非常之變。\n象曰:括囊无咎,慎不害也。 # 能隨時謹言,不妄言,此無害之因慎也。\n象曰:黃裳元吉 ,文在中也。 # 因黃裳之美,乃內積至美,執中不過,位高但謙居下,故吉。\n上六:龍戰於野,其血玄黃。 # 六爻為極盛之位,陰至極則陽至,故有抗爭,因六又再進不已,故必戰,戰必有傷,故血色現,天之血色為玄,地之血色為黃。\n用六:利永貞。 # 此為用陰之道,如乾有(用九)之道同。陰道柔而難常,有朝令夕改之憂。故利在常而堅固。\n象曰:龍戰于野,其道窮也。 # 道因陰至極而無道,故有龍戰於野之象現。\n象曰:用六永貞,以大終也。 # 聖人自此悟之,始終如一,堅心不變,必利,其終極必大。今人朝夕所思不同,自視甚重,角色不能認清,小人有太過之行為,常人從俗只見愚善,致小人極盛,積非成是,故積重難改。\n文言曰:坤至柔而動也剛,至靜而德方。後得主而有常,含萬物而化光, 坤道其順乎?承天而時行。 # 坤之道雖柔,但如動亦須剛,坤之體至靜,但其德也须方正。動而不違方正。再得同道之呼應,成萬物光大之功,故坤道之順大,皆因王時之至也。\n積善之家,必有餘慶,積不善之家,必有餘殃。臣弑其君,子弑其父,非一朝一夕之故;其所由來者漸矣,由辨之不早辨也。易曰:履霜堅冰至,蓋言順也。 # 學者讀此段,必可自悟矣。故聖人戒小人於初,其道大也。\n直其正也,方其義也;君子敬以直内,義以方外,敬義立而德不孤, 直方大,不習无不利,則不疑其所行也。 # 君子外敬內直,堅守義以方其外,故內敬直,外義方,其德之盛,順其自然,不须裝飾, 無人會疑其所行也。\n陰雖有美含之,以從王事,弗敢成也。地道也,妻道也,臣道也, 地道无成,而代有終也。 # 臣下之道,不表其功,含晦以為王行事,至終而不敢有其功,如地道代天終萬物,而成功又歸之於天,妻之道同此。今之妻如何,吾人試自問之?\n天地變化,草木蕃。天地閉,賢人隱。易曰:括囊,無咎無譽,蓋言謹也。 # 天地相交,則能興發草木,天下因君臣相往而道亨。今天地隔絶,萬物不成,猶君臣無道, 賢者隱退,此時易道為閉口含藏,雖無聲譽,但無災,皆因言論謹慎也。\n君子黃中通理,正位居體,美在其中,而暢於四支,發於事業,美之至也。 # 君子知謙守下之道而通於事理,居君王位故美積其中,暢於四體,現於事業,此美之極也。此段乃示柔中有剛,乃至大至美,今人有柔善而無剛,或過剛而無仁,皆不足之美也。\n陰疑於陽必戰,爲其嫌於无陽也,故稱龍焉; 猶未離其類也,故稱血焉。夫玄黃者,天地之雜也,天玄而地黃。 # 中醫之至道在此,陽大陰小,陰乃從陽,故經方派乃用陽藥,如人間亦然。如令陰到極盛, 陽受陰盛而外走,此不相從必戰,傷而見血,天地之色變,亦言傷之大也。\n第 9页\n"},{"id":98,"href":"/zh/docs/culture/%E5%A4%A9%E7%BA%AA/%E4%BA%BA%E9%97%B4%E9%81%93/%E4%B8%8A%E7%BB%8F/01%E4%B9%BE%E4%B8%BA%E5%A4%A9/","title":"01乾爲天","section":"上经","content":" 01乾爲天 # 此卦高祖與呂后走在芒碭山卜得之,餘人難壓也。\n圖中:\n鹿在雲中,石上玉,有光明,人琢玉。月當空,官人登雲梯,望月。\n六龍御天之課 廣大包容之象\n萬物皆成於天地之間,故天之高,地之厚德,皆為吾人所效法。聖人觀天而知天之運行恆古不變,且永不止息,此天之道。乾為萬物之始,以象言其為天,為陽,為父,為君。\n卦圖象解\n一、官人在梯上:平步青雲象。\n二、鹿在雲中:作為行事心中不可求祿,則祿至矣。三、石上玉有光明:示人才如玉之含光,吉也。\n四、人磨玉:示人堅心不移,夕惕若厲,隨時戒之。五、工匠:有重立門户之象。\n六、梯上官人:棺也。卜疾厄凶也。\n七、官人望磨玉人:示人即平步青雲須戒盛,且望下看,念中無妄,則升遷平安,如爭位則旦夕而亡也。\n人間道\n乾:元、亨、利、貞。\n元為萬物之始生,亨為萬物之成長,利為萬物之遂成,貞為萬物之堅心,此為乾之性,恒古不變,示人须有堅定不移之心志,萬事乃成於此。\n初九:潛龍勿用。 # 初爻為陽位,且陽居此,因爻理本無形, 聖人假借龍之物象來示人乾之初象如聖人始萌,如物之始端,若龍之潛隱,由於始生,未為可用之時機,當韜光養晦,以俟時之至。\n九二:見龍在田,利見大人。 # 以個人而言,此時如舜之田漁,其德已著, 乃時之至,故利見大德之君,以行其道。以君王言,此時亦利見大德之人臣,以共成其功。以天下百姓言,亦利見大德之人才,以受其恩澤。\n九三:君子終日乾乾,夕惕若厲,无咎。 # 三爻位是在下卦之最上,雖為臣位,但未離下位,此時君子知朝夕不懈,隨時警惕,即處危地,但因戒惕而無災至。時今在下位之人,因德已顯彰,民心將至,天下將歸於他,試想其危懼之程度,故聖人設戒於此時,此易之論時也。\n九四:或躍在淵,无咎。 # 或乃進與不進之時,聖人示戒進之時機,如時機不對,亦可不進,或之意也,無災。\n九五:飛龍在天,利見大人。 # 此乃進乎天位君位之時,天下利見有大德之人,來共成天下之大事,聖人居此位,乃知時之至矣。廣納天下大德之人共成之。\n用九:見群龍無首,吉。 # 上九:亢龍有悔。\n上九為位之極點,過此時太過也。聖人知此為時之極限,乃知進退存亡而終至無過,就不至於後悔也。\n用九之道,即用鲍陽剛之道,須剛柔相濟方為中道,如過乎剛,以至剛為天下之優先,此凶之道也。\n彖曰:大哉乾元!萬物資始,乃統天。雲行雨施,品物流形。大明终始。六位時成,時乘六龍以御天。乾道變化,各正性命,保合大和, 乃利貞。首出庶務,萬國咸寧。 # 彖為一卦之義,吾人觀讀彖,有助於對卦之了解。開始即讃乾道之浩大,萬物由此而生, 乾道因統天,故即為天道,萬物生後,雲行雨施,品物流形,此物之長也。六爻之卦位,各因時而成,此為天之運行,因天道之剛健而自強不息的運作,生育出萬物,且各有分類,各有所性,各有所命。吾人言『天所賦為命,物所受為性。』自此常存此道,因天道生成之\n剛正,利於吾人堅心向道,果必吉。天之道首出萬物而皆亨吉,人君之道遵循天道,以致四海顺从。\n象曰:天行健,君子以自彊不息。 # 象為解一卦之神,每卦皆有象,每爻皆有象,所有的卦皆以象為法則。此乾之卦象言,始終如一,永恆不變,人之行為能如此則至健,可以見天道也。吾人之自強不息,乃法天之則也。此為天紀。\n潛龍勿用,陽在下也。 # 陽居最下始生之位,猶植物之生於土中, 未出頭,始萌之狀。故君子如處此時,乃應進德修業,未為可用之時機也。\n終日乾乾,反復道也。 # 此時居下卦之最上,要再進前,但又未脱下位之關係,進退動靜,必依有道,且旦夕進修勵德,此時之法則也。\n飛龍在天,大人造也。 # 君子能進君位,而行其正道,天下大治矣。\n用九,天德不可爲首也。 # 用九,意為用剛之道也,天道陽剛,又用剛而為先,此之過矣。\n見龍在田,德施普也。 # 此時君子之德已普及各地,人民已感受其教化之力量也。\n或躍在淵,進无咎也。 # 或乃量可而進,待之以時,時至而動,動必以明,無災。\n亢龍有悔,盈不可久也。 # 滿則溢,此天之道也,故易示人滿必不可持久,太過必生悔矣。\n文言曰:元者,善之長也;亨者,嘉之會也;利者,義之和也;貞者, 事之幹也。 # 易中唯乾坤二卦有文言,此乃補強乾坤天地之道。元,萬物之始皆為善,天之生必有其用, 此為造化之跡也。亨者,乃其成長之嘉且美也。利者,任何行為必合於義也。貞者,萬物之用, 必以堅心不移方可成矣。\n君子體仁,足以長人。 # 分均,仁也,此仁之道大矣,君子體之,以仁普及教化,以所以長人也。\n嘉會,足以合禮。 # 不合理則為非禮,合於理之生長,為美之至嘉也。故萬般事物皆有一定之法則,吾人須體理之治,則發展合於天道自然。\n利物,足以合義。 # 合於義,則能利於萬物之遂行。後出,義也。能斷後,則為義也。而今人類製造之物,無法完全回收,造成自然界之失衡,此不合於義之道也,不合於利物之理。\n貞固,足以幹事。 # 因堅定不移之意志,則定以成事。\n君子行此四德者,故曰:乾,元,亨,利,貞。\n能行此四德,則合乎乾天之道。\n初九曰:潛龍勿用,何謂也?子曰: # 龍德而隱者也,不易乎世,不成乎名,遁世无悶,不見是而无悶,樂則行之,憂則違之,確乎其不可拔, 潛龍也。\n乾卦之用,即用九之道。初九為陽剛之微\n現,如龍之潛隱,聖人居之陋側,堅守其道不因世之變而改變,時不至,隱其行,不求為人知也,自信自樂,見可而動,知災而避,守正道之心堅而不移,此真潛龍也。\n九三曰:君子終日乾乾,夕惕若厲, 无咎,何謂也?子曰:君子進德脩業,忠信所以盡德也;脩辭立其誠, 所以居業也。知至至之,可與幾也; 知終終之,可與存義也。是故居上位而不驕,在下位而不憂,故乾乾因其時而惕,雖危无咎矣。 # 位居下卦之上者,已近君位,又因有君之\n德顯,為免招凶,故君子居此時,必求進德修業,終日謹慎言行,知進退之機,此義之道。不以居上位或處下位而驕傲或憂慮,不予人有野心之戒,故雖處危地但無災至也。\n九二曰:見龍在田,利見大人,何 # 謂也?子曰:龍德而正中者也。庸言之信,庸行之谨,閑邪存其誠, 善世而不伐,德博而化。易曰:見龍在田,利見大人,君德也。\n因非為君位,但有中正之德,謹言慎行,\n但求處於無過之地,遇邪道,但求存誠,德施普及,但不伐其惡,以誠純一,雖不為君,但為君之德也。\n九四曰:或躍在淵,无咎,何謂也? 子曰:上下无常,非爲邪也;進退无恆,非離群也;君子進德脩業。欲及時也,故无咎。 # 或即進退須待時之意,時行時止,不可恆久不變。君子之順時而動,猶影之隨形,並非欲為邪吝之人,亦非欲離同類也。此位乃近君惻之人也。\n九五曰:飛龍在天,利見大人,何謂也?子曰:同聲相應,同氣相求, 水流濕,火就燥,雲從龍,風從虎,聖人作而萬物睹。本乎天者親上, 本乎地者親下,則各從其類也。 # 此有中正之德的人,居於君王之位,天下人皆歸仰其德,上應於下,下從於上,故聖人做而萬人皆明,因同德相感,故賢人出,各有其類,本為君位之人則親和於君王,本為位低之賢, 則近於下位之人,此乃自然之水流濕,火就燥,雲從龍,風從虎之義。故利見大人。\n上九曰:亢龍有悔,何謂也?子曰:貴而无位,高而无民,賢人在下位而无輔,是以動而有悔也。 # 有陽剛之正德而居不適位,則動而必悔,因无民无輔,獨力難成也。\n潛龍勿用,下也。 # 居下位之時,即有中正之德,亦宜潛隱, 不可就用也。\n終日乾乾,行事也。 # 君子行事之法則,不居君位,則終日警惕, 進德修業,不求有大功。\n飛龍在天,上治也。 # 有君王之位人,又有中正之德者,如龍在天,乃上上之治,其意如此。\n乾元用九,天下治也。 # 用九之道,因天與聖人同知且同用,天下因治。\n見龍在田,天下文明。 # 龍之陽剛德性,見於地上,如此天下則見其文明也。\n或躍在淵,乾道乃革。 # 因知進退之得宜,故能離下卦,而躍居上位,故曰上下之革。\n亢龍有悔,與時偕極。 # 時之過,再居此時,則人與時皆過也。\n乾元用九,乃見天則。 # 用九,乃天之則也,天之法則即為天道, 聖人稟道而行,得失吉凶立知於事之初,此為天則。\n見龍在田,時舍也。 # 因時而止,此利普施其德於一地,範圍有限。\n或躍在淵,自試也。 # 處君側,可自試時機為何,動靜隨消息而進或止,不可躁進,求凶之道。\n亢龍有悔,窮之災也。 # 因窮困之極,乃有太過之舉動,此災之至也。\n潛龍勿用,陽氣潛藏。 # 方陽衰而潛藏之時,君子效此,亦晦隱, 不可用也。\n終日乾乾,與時偕行。 # 待時而動,不可分秒懈怠。\n飛龍在天,乃位乎天德。 # 正於君位,天德乃現,如龍升天。\n乾元者,始而亨者也。 # 用陽剛之天則,初始即可亨通也。\n利貞者,性情也。 # 陽剛之性既亨通,須始終如一,則能生生不息。\n乾始能以美利利天下,不言所利,大矣哉。 # 乾始之道,能使天下皆感其美,而天下因之而利,又不歌功頌德,將功歸己,此則大矣。\n大哉乾乎!剛健中正,純粹精也;六爻發揮,旁通情也;時乘六龍, 以御天也;雲行雨施,天下平也。 # 此言乾道之偉大,因純之剛健中正,按步就班,順時順位而行之,則可以統御天下,陰陽和暢,天下則進入和平之道也。\n君子以成德爲行,日可見之行也。潛之爲言也,隱而未見,行而未成,是以君子弗用也。 # 君子以行為而示人其德性,隨時可見。初因方潛未為見用,故君子弗用也。\n君子學以聚之,問以辯之,寬以居之,仁以行之,易曰:見龍在田, 利見大人,君德也。 # 聖人居下位,德已顯,卻未得位時,必求進德修身,不怪時之不與,但求身體力行此雖不為君,卻是為君之德才。\n九三:重剛而不中,上不在天,下不在田,故乾乾因其時而惕,雖危无咎矣。 # 因九三位為過中而又居下卦之上,屬相位,上又未至君位,又為陽剛之性,故屬危懼之地,聖人知戒,競競惕惕以防危,故雖處此時而无咎也。\n九四:重剛而不中,上不在天,下不在田,中不在人,故或之;或之者,疑之也,故无咎。 # 九四為近君位,亦屬危地,或進或退,但求平安耳,故可无咎。君子能曲能伸。\n夫大人者,與天地合其德,與日月合其明,與四時合其序,與鬼神合其吉凶,先天而天弗爲,後天而奉天時,天且弗違,而況於人乎?况於鬼神乎? # 聖人先知於天故天與其同,後於天又順於天,此合乎天道,故人與鬼神皆不能違,天為至大,存天地之間,道也。鬼神,造化之跡也。\n亢之爲言也,知進而不知退,知存而不知亡,知得而不知喪,其唯聖人乎!知進退存亡,而不失其正者,其唯聖人乎! # 亢之義乃不知進退存亡之機也,聖人知亢而處此,凡事皆不失正道,故不為亢,唯聖人可為也。\n"},{"id":99,"href":"/zh/docs/test/test2/","title":"test2","section":"测试","content":" index\n,,,啊四道口附近看喀什地方就开始角度看开始卡斯克使得开发商开具收款附件啊可是结果卡就十分就凯撒记得付款就看四道口附近凯撒记得付款就开始大家可接受的看就是接口设计开具收款的 就框架 看 看技术的开发就开始酒店开房间奥数开始讲课 伺机待发开具收款即可就开始的JFK两节课\n撒旦发射点 撒旦发射点 s地方\n这里我做一个测试,,写字的测试哦σ假装,○这里宀~不很好用。句号不太好画好来,\n"},{"id":100,"href":"/zh/docs/culture/%E8%AE%BA%E8%AF%AD/%E8%AE%BA%E8%AF%AD%E8%AF%91%E6%B3%A8_%E6%9D%A8%E4%BC%AF%E5%B3%BB/19%E5%AD%90%E5%BC%A0%E7%AF%87%E7%AC%AC%E5%8D%81%E4%B9%9D/","title":"19子张篇第十九","section":"论语译注 杨伯峻","content":" 子张篇第十九 # (共二十五章)\n19.1子张曰:“士见危致命,见得思义,祭思敬,丧思哀,其可已矣。”\n【译文】子张说:“读书人看见危险便肯豁出生命,看见有所得便考虑是否该得,祭祀时候考虑严肃恭敬,居丧时候考虑悲痛哀伤,那也就可以了。”\n19.2子张曰:“执德不弘⑴,信道不笃,焉能为有?焉能为亡⑵?”\n【译文】子张说:“对于道德,行为不坚强,信仰不忠实,[这种人,]有他不为多,没他不为少。”\n【注释】⑴弘——此“弘”字就是今之“强”字,说见章炳麟《广论语骈枝》。⑵焉能为有,焉能为亡——这两句疑是当日成语。何晏《论语集解》云:“言无所轻重”,所以译文也用今日俗语来表达此意。\n19.3子夏之门人问交于子张。子张曰:“子夏云何?”\n对曰:“子夏曰:‘可者与之,其不可者拒之。’”\n子张曰:“异乎吾所闻:君子尊贤而容众,嘉善而矜不能。我之大贤与,于人何所不容?我之不贤与,人将拒我,如之何其拒人也?”\n【译文】子夏的学生向子张问怎样去交朋友。子张道:“子夏说了些什么?”\n答道:“子夏说,可以交的去交他,不可以交的拒绝他。”\n子张道:“我所听到的与此不同:君子尊敬贤人,也接纳普通人;鼓励好人,可怜无能的人。我是非常好的人吗,对什么人不能容纳呢?我是坏人吗,别人会拒绝我,我怎能去拒绝别人呢?”\n19.4子夏曰:“虽小道,必有可观者焉;致远恐泥,是以君子不为也。”\n【译文】子夏说道:“就是小技艺,一定有可取的地方;恐怕它妨碍远大事业,所以君子不从事于它。”\n19.5子夏曰:“日知其所亡,月无忘其所能,可谓好学也已矣。”\n【译文】子夏说:“每天知道所未知的,每月复习所已能的,可以说是好学了。”\n19.6子夏曰:“博学而笃志⑴,切问而近思,仁在其中矣。”\n【译文】子夏说:“广泛地学习,坚守自己志趣;恳切地发问,多考虑当前的问题,仁德就在这中间了。”\n【注释】⑴志——孔注以为“志”与“识”同,那么,“博学笃志”便是“博闻强记”之意,说虽可通,但不及译文所解恰切。\n19.7子夏曰:“百工居肆以成其事,君子学以致其道。”\n【译文】子夏说:“各种工人居住于其制造场所完成他们的工作,君子则用学习获得那个道。”\n19.8子夏曰:“小人之过也必文。”\n【译文】子夏说:“小人对于错误一定加以掩饰。”\n19.9子夏曰:“君子有三变:望之俨然,卽之也温,听其言也厉。”\n【译文】子夏说:“君子有三变:远远望着,庄严可畏;向他靠拢,温和可亲;听他的话,严厉不苟。”\n19.10子夏曰:“君子信而后劳其民;未信,则以为厉己也。信而后谏;未信,则以为谤己也。”\n【译文】子夏说:“君子必须得到信仰以后才去动员百姓;否则百姓会以为你在折磨他们。必须得到信任以后才去进谏,否则君上会以为你在毁谤他。”\n19.11子夏曰:“大德不踰闲,小德出入可也。”\n【译文】子夏说:“人的重大节操不能踰越界限,作风上的小节稍稍放松一点是可以的。”\n19.12子游曰:“子夏之门人小子,当洒扫应对进退,则可矣,抑末也。本之则无,如之何?”\n子夏闻之,曰:“噫!言游过矣!君子之道,孰先传焉?孰后倦焉?譬诸草木,区以别矣。君子之道,焉可诬也?有始有卒者,其惟圣人乎!”\n【译文】子游道:“子夏的学生,叫他们做做打扫、接待客人、应对进退的工作,那是可以的;不过这只是末节罢了。探讨他们的学术基础却没有,怎样可以呢?”\n子夏听了这话,便道:“咳!言游说错了!君子的学术,哪一项先传授呢?哪一项最后讲述呢?学术犹如草木,是要区别为各种各类的。君子的学术,如何可以歪曲?[依照一定的次序去传授而]有始有终的,大概只有圣人罢!”\n19.13子夏曰:“仕而优则学,学而优则仕。”\n【译文】子夏说:“做官了,有余力便去学习;学习了,有余力便去做官。”\n19.14子游曰:“丧致乎哀而止。”\n【译文】子游说:“居丧,充分表现了他的悲哀也就够了。”\n19.15子游曰:“吾友张也为难能也,然而未仁。”\n【译文】子游说:“我的朋友子张是难能可贵的了,然而还不能做到仁。”\n19.16曾子曰:“堂堂⑴乎张也,难与并为仁矣。”\n【译文】曾子说:“子张的为人高得不可攀了,难以携带别人一同进入仁德。”\n【注释】⑴堂堂——这是迭两字而成的形容词,其具体意义如何,古今解释纷纭。《荀子·非十二子篇》云:“弟佗其冠,神禫其辞,禹行而舜趋,是子张氏之贱儒也。”这是对子张学派的具体描写,因此我把“堂堂”译为“高不可攀”。根据《论语》和后代儒家诸书,可以证明曾子的学问重在“正心诚意”,而子张则重在言语形貌,所以子游也批评子张“然而未仁”。\n19.17曾子曰:“吾闻诸夫子:人未有自致者也,必也亲丧乎!”\n【译文】曾子说:“我听老师说过,平常时候,人不可能来自动地充分发挥感情,[如果有,]一定在父母死亡的时候罢!”\n19.18曾子曰:“吾闻诸夫子:孟庄子⑴之孝也,其它可能也;其不改父之臣与父之政,是难能也。”\n【译文】曾子说:“我听老师说过:孟庄子的孝,别的都容易做到;而留用他父亲的僚属,保持他父亲的政治设施,是难以做到的。”\n【注释】⑴孟庄子——鲁大夫孟献子仲孙蔑之子,名速。其父死于鲁襄公十九年,本人死于二十三年,相距仅四年。这一章可以和“三年无改于父之道可谓孝矣”(1.11)结合来看。\n19.19孟氏使阳肤⑴为士师,问于曾子。曾子曰:“上失其道,民散⑵久矣。如得其情,则哀矜而勿喜!”\n【译文】孟氏任命阳肤做法官,阳肤向曾子求教。曾子道:“现今在上位的人不依规矩行事,百姓早就离心离德了。你假若能够审出罪犯的真情,便应该同情他,可怜他,切不要自鸣得意!”\n【注释】⑴阳肤——旧注说他是曾子弟子。⑵散——黄家岱《嬹艺轩杂着·论语多齐鲁方言述》云:“散训犯法,与上下文义方接。扬氏《方言》:‘虔散,杀也。东齐曰散,青徐淮楚之间曰虔。’虔散为贼杀义。曰民散久矣,用齐语也。”译文未取此说,録之以备参考。\n19.20子贡曰:“纣⑴之不善,不如是之甚也。是以君子恶居下流,天下之恶皆归焉。”\n【译文】子贡说:“商纣的坏,不像现在传说的这么厉害。所以君子憎恨居于下流,一居下流,天下的什么坏名声都会集中在他身上了。”\n【注释】⑴纣——殷商最末之君,为周武王所伐,自焚而死。\n19.21子贡曰:“君子之过也,如日月之食焉:过也,人皆见之;更也,人皆仰之。”\n【译文】子贡说:“君子的过失好比日蚀月蚀:错误的时候,每个人都看得见;更改的时候,每个人都仰望着。”\n19.22卫公孙朝⑴问于子贡曰:“仲尼焉学?”子贡曰:“文武之道,未坠于地,在人。贤者识其大者,不贤者识其小者。莫不有文武之道焉。夫子焉不学?而亦何常师之有?”\n【译文】卫国的公孙朝向子贡问道:“孔仲尼的学问是从哪里学来的?”子贡道:“周文王武王之道,并没有失传,散在人间。贤能的人便抓住大处,不贤能的人只抓些末节。没有地方没有文王武王之道。我的老师何处不学,又为什么要有一定的老师,专门的传授呢?”\n【注释】⑴卫公孙朝——翟灏《四书考异》云:“春秋时鲁有成大夫公孙朝,见昭二十六年传;楚有武城尹公孙朝,见哀十七年传;郑子产有弟曰公孙朝,见列子。记者故系‘卫’以别之。”\n19.23叔孙武叔⑴语大夫于朝曰:“子贡贤于仲尼。”\n子服景伯以告子贡。\n子贡曰:“譬之宫墙⑵,赐之墙也及肩,窥见室家之好。夫子之墙数仞⑶,不得其门而入,不见宗庙之美,百官⑷之富。得其门者或寡矣。夫子之云,不亦宜乎!”\n【译文】叔孙武叔在朝廷中对官员们说:“子贡比他老师仲尼要强些。”\n子服景伯便把这话告诉子贡。\n子贡道:“拿房屋的围墙作比喻罢:我家的围墙只有肩膀那么高,谁都可以探望到房屋的美好。我老师的围墙却有几丈高,找不到大门走进去,就看不到他那宗庙的雄伟,房舍的多种多样。能够找着大门的人或许不多罢,那么,武叔他老人家的这话,不也是自然的吗?”\n【注释】⑴叔孙武叔——鲁大夫,名州仇。⑵宫墙——“宫”有围障的意义,如《礼记·丧大记》:“君为庐宫之”。“宫墙”当系一词,犹如今天的“围墙”。⑶仞——七尺曰仞(此从程瑶田《通艺録·释仞》之说)。⑷官——“官”字的本义是房舍,其后才引申为官职之义,说见俞樾《羣经平议》卷三及遇夫先生《积微居小学金石论丛》卷一。这里也是指房舍而言。\n19.24叔孙武叔毁仲尼。子贡曰:“无以⑴为也!仲尼不可毁也。他人之贤者,丘陵也,犹可踰也;仲尼,日月也,无得而踰焉。人虽欲自绝,其何伤于日月乎?多⑵见其不知量也⑶。”\n【译文】叔孙武叔毁谤仲尼。子贡道:“不要这样做,仲尼是毁谤不了的。别人的贤能,好比山邱,还可以超越过去;仲尼,简直是太阳和月亮,不可能超越它。人家纵是要自绝于太阳月亮,那对太阳月亮有什么损害呢?祗是表示他不自量罢了。”\n【注释】⑴以——此也,这里作副词用。⑵多——副词,祗也,适也。⑶不知量也——皇侃《义疏》解此句为“不知圣人之度量”,译文从朱熹《集注》。“也”,用法同“耳”。\n19.25陈子禽谓子贡曰:“子为恭也,仲尼岂贤于子乎?”\n子贡曰:“君子一言以为知,一言以为不知,言不可不慎也。夫子之不可及也,犹天之不可阶而升也。夫子之得邦家者,所谓立之斯立,道之斯行,绥之斯来,动之斯和。其生也荣,其死也哀,如之何其可及也?”\n【译文】陈子禽对子贡道:“您对仲尼是客气罢,是谦让罢,难道他真比您还强吗?”\n子贡道:“高贵人物由一句话表现他的有知,也由一句话表现他的无知,所以说话不可不谨慎。他老人家的不可以赶得上,犹如青天的不可以用阶梯爬上去。他老人家如果得国而为诸侯,或者得到采邑而为卿大夫,那正如我们所说的一叫百姓人人能立足于社会,百姓自会人人能立足于社会;一引导百姓,百姓自会前进;一安抚百姓,百姓自会从远方来投靠;一动员百姓,百姓自会同心协力。他老人家,生得光荣,死得可惜,怎么样能够赶得上呢?”\n"},{"id":101,"href":"/zh/docs/culture/%E8%AE%BA%E8%AF%AD/%E8%AE%BA%E8%AF%AD%E8%AF%91%E6%B3%A8_%E6%9D%A8%E4%BC%AF%E5%B3%BB/13%E5%AD%90%E8%B7%AF%E7%AF%87%E7%AC%AC%E5%8D%81%E4%B8%89/","title":"13子路篇第十三","section":"论语译注 杨伯峻","content":" 子路篇第十三 # (共三十章)\n13.1子路问政。子曰:“先之⑴劳之。”请益。曰:“无倦⑵。”\n【译文】子路问政治。孔子道:“自己给百姓带头,然后让他们勤劳地工作。”子路请求多讲一点。孔子又道:“永远不要懈怠。”\n【注释】⑴先之——就是下一章“先有司”之意。⑵无倦——也就是“居之无倦”(12.14)之意。\n13.2仲弓为季氏宰,问政。子曰:“先有司,赦小过,举贤才。”\n曰:“焉知贤才而举之?”子曰:“举尔所知;尔所不知,人其舍诸?”\n【译文】仲弓做了季氏的总管,向孔子问政治。孔子道:“给工作人员带头,不计较人家的小错误,提拔优秀人才。”\n仲弓道:“怎样去识别优秀人才把他们提拔出来呢?”孔子道:“提拔你所知道的;那些你所不知道的,别人难道会埋没他吗?”\n13.3子路曰:“卫君⑴待子而为政,子将奚先?”\n子曰:“必也正名⑵乎!”\n子路曰:“有是哉,子之迂也!奚其正?”\n子曰:“野哉,由也!君子于其所不知,盖阙如也。名不正,则言不顺;言不顺,则事不成;事不成,则礼乐不兴;礼乐不兴,则刑罚不中;刑罚不中,则民无所错⑶手足。故君子名之必可言也,言之必可行也。君子于其言,无所苟而已矣。”\n【译文】子路对孔子说:“卫君等着您去治理国政,您准备首先干什么?”\n孔子道:“那一定是纠正名分上的用词不当罢!”\n子路道:“您的迂腐竟到如此地步吗!这又何必纠正?”\n孔子道:“你怎么这样卤莽!君子对于他所不懂的,大概采取保留态度,[你怎么能乱说呢?]用词不当,言语就不能顺理成章;言语不顺理成章,工作就不可能搞好;工作搞不好,国家的礼乐制度也就举办不起来;礼乐制度举办不起来,刑罚也就不会得当;刑罚不得当,百姓就会[惶惶不安,]连手脚都不晓得摆在哪里才好。所以君子用一个词,一定[有它一定的理由,]可以说得出来;而顺理成章的话也一定行得通。君子对于措词说话要没有一点马虎的地方才罢了。”\n【注释】⑴卫君——历来的注释家都说是卫出公辄。⑵正名——关于这两个字的解释,从汉以来便异说纷纭。皇侃《义疏》引郑玄的注云:“正名谓正书字也,古者曰名,今世曰字。”这说恐不合孔子原意。《左传》成公二年曾经载有孔子的话,说:“唯器(礼器)与名(名义、名分)不可以假人。”《论语》这一“名”字应该和《左传》的这一“名”字相同。《论语》中有孔子“觚不觚”之叹。“觚”而不像“觚”,有其名,无其实,就是名不正。孔子对齐景公之问,说,“君君,臣臣,父父,子子”,也就是正名。《韩诗外传》卷五记载着孔子的一段故事,说,“孔子侍坐于季孙,季孙之宰通曰:‘君使人假马,其与之乎?’孔子曰:‘吾闻:君取于臣曰取,不曰假。’季孙悟,告宰通曰:‘今以往,君有取谓之取,无曰假。’孔子曰:‘正假马之言而君臣之义定矣。’”更可以说明孔子正名的实际意义。我这里用“名分上的用词不当”来解释“名不正”,似乎较为接近孔子原意。但孔子所要纠正的,只是有关古代礼制、名分上的用词不当的现象,而不是一般的用词不当的现象。一般的用词不当的现象,是语法修辞范畴中的问题;礼制上、名分上用词不当的现象,依孔子的意见,是有关伦理和政治的问题,这两点必须区别开来。⑶错——同“措”,安置也。\n13.4樊迟请学稼。子曰:“吾不如老农。”请学为圃。曰:“吾不如老圃。”\n樊迟出。子曰:“小人哉,樊须也!上好礼,则民莫敢不敬;上好义,则民莫敢不服;上好信,则民莫敢不用情。夫如是,则四方之民襁负其子而至矣,焉用稼?”\n【译文】樊迟请求学种庄稼。孔子道:“我不如老农民。”又请求学种菜蔬。孔子道:“我不如老菜农。”\n樊迟退了出来。孔子道:“樊迟真是小人,统治者讲究礼节,百姓就没有人敢不尊敬;统治者行为正当,百姓就没有人敢不服从;统治者诚恳信实,百姓就没有人敢不说真话。做到这样,四方的百姓都会背负着小儿女来投奔,为什么要自己种庄稼呢?”\n13.5子曰:“诵《诗》三百,授之以政,不达;使于四方,不能专对⑴;虽多,亦奚以为⑵?”\n【译文】孔子说:“熟读《诗经》三百篇,交给他以政治任务,却办不通;叫他出使外国,又不能独立地去谈判酬酢;纵是读得多,有什么用处呢?”\n【注释】⑴不能专对——古代的使节,只接受使命,至于如何去交涉应对,只能随机应变,独立行事,更不能事事请示或者早就在国内一切安排好,这便叫做“受命不受辞”,也就是这里的“专对”。同时春秋时代的外交酬酢和谈判,多半背诵诗篇来代替语言(《左传》里充满了这种记载),所以诗是外交人才的必读书。⑵亦奚以为——“以”,动词,用也。“为”,表疑问的语气词,但只跟“奚”、“何”诸字连用,如“何以文为”、“何以伐为”。\n13.6子曰:“其身正,不令而行;其身不正,虽令不从。”\n【译文】孔子说:“统治者本身行为正当,不发命令,事情也行得通。他本身行为不正当,纵三令五申,百姓也不会信从。”\n13.7子曰:“鲁、卫之政,兄弟也。”\n【译文】孔子说:“鲁国的政治和卫国的政治,像兄弟一般[地相差不远]。”\n13.8子谓卫公子荆⑴,“善居室⑵,始有,曰:‘苟合⑶矣。’少有,曰:‘苟完矣。’富有,曰:‘茍美矣。’”\n【译文】孔子谈到卫国的公子荆,说:“他善于居家过日子,刚有一点,便说道:‘差不多够了。’增加了一点,又说道:‘差不多完备了。’多有一点,便说道:‘差不多富丽堂皇了。”\n【注释】⑴卫公子荆——卫国的公子,吴季札曾把他列为卫国的君子,见《左传》襄公二十九年。有人说:“此取荆之善居室以风有位者也。”因为当时的卿大夫,不但贪污,而且奢侈成风,所以孔子“以廉风贪,以俭风侈。”似可备一说。⑵居室——这一词组意义甚多:(甲)居住房舍,《礼记·曲礼》“君子将营宫室,宗庙为先,廐库为次,居室为后。”(乙)夫妇同居,《孟子·万章》:“男女居室,人之大伦也。”(丙)汉代又以为狱名,《史记·卫青传》:“青尝从入甘泉居室。”(丁)此则为积蓄家业居家度日之义。“居”读为“奇货可居”之“居”。⑶合——给也,足也。此依俞樾《羣经平议》说。\n13.9子适卫,冉有仆⑴。子曰:“庶矣哉!”\n冉有曰:“既庶矣,又何加焉?”曰:“富之。”\n曰:“既富矣,又何加焉?”曰:“教之⑵。”\n【译文】孔子到卫国,冉有替他驾车子。孔子道:“好稠密的人口!”\n冉有道:“人口已经众多了,又该怎么办呢?”孔子道:“使他们富裕起来。”\n冉有道:“已经富裕了,又该怎么办呢?”孔子道:“教育他们”\n【注释】⑴仆——动词,驾御车马。其人则谓之仆夫,《诗·小雅·出车》“仆夫况瘁”可证。仆亦作名词,驾车者,《诗·小雅·正月》“屡顾尔仆”是也。⑵既富……教之——孔子主张“先富后教”,孟子、荀子也都继续发挥了这一主张。所以孟子说“乐岁终身苦,凶年不免于死亡。此惟救死而恐不赡,奚暇治礼义哉?”(《梁惠王上》)也和《管子·治国篇》的“凡治国之道,必先富民”主张相同。\n13.10子曰:“苟有用我者,期月⑴而已可也,三年有成。”\n【译文】孔子说:“假若有用我主持国家政事的,一年便差不多了,三年便会很有成绩。”\n【注释】⑴期月——期同“朞”,有些本子卽作“朞”,音姬,jī。期月,一年。\n13.11子曰:“‘善人为邦百年,亦可以胜⑴残去⑵杀矣⑶。’诚哉是言也!”\n【译文】孔子说:“‘善人治理国政连续到一百年,也可以克服残暴免除虐杀了。’这句话真说得对呀!”\n【注释】⑴胜——旧读平声。⑵去——旧读上声。⑶善人……去杀矣——依文意是孔子引别人的话。\n13.12子曰:“如有王者,必世而后仁。”\n【译文】孔子说:“假若有王者兴起,一定需要三十年才能使仁政大行。”\n13.13子曰:“苟正其身矣,于从政乎何有?不能正其身,如正人何?”\n【译文】孔子说:“假若端正了自己,治理国政有什么困难呢?连本身都不能端正,怎么端正别人呢?”\n13.14冉子退朝。子曰:“何晏也?”对曰:“有政。”子曰:“其事也。如有政,虽不吾以,吾其与闻之⑴。”\n【译文】冉有从办公的地方回来。孔子道:“为什么今天回得这样晚呢?”答道:“有政务。”孔子道:“那只是事务罢了。若是有政务,虽然不用我了,我也会知道的。”\n【注释】⑴与闻之——与,去声,参预之意。《左传》哀公十一年曾有记载,季氏以用田赋的事征求孔子意见,并且说,“子为国老,待子而行。”可见孔子“如有政,吾其与闻之”这话是有根据的。只是冉有不明白“政”和“事”的分别,一时用词不当罢了。依我看,这章并无其它意义,前人有故求深解的,未必对。\n13.15定公问:“一言而可以兴邦,有诸?”\n孔子对曰:“言不可以若是其几也。人之言曰:‘为君难,为臣不易。’如知为君之难也,不几乎一言而兴邦乎?”\n曰:“一言而丧邦,有诸?”\n孔子对曰:“言不可以若是其几也。人之言曰:‘予无乐乎为君,唯其言而莫予违也。’如其善而莫之违也,不亦善乎?如不善而莫之违也,不几乎一言而丧邦乎?”\n【译文】鲁定公问:“一句话兴盛国家,有这事么?”\n孔子答道:“说话不可以像这样地简单机械。不过,人家都说:‘做君上很难,做臣子不容易。’假若知道做君上的艰难,[自然会谨慎认真地干去,]不近于一句话便兴盛国家么?”\n定公又道:“一句话丧失国家,有这事么?”\n孔子答道:“说话不可以像这样地简单机械。不过,大家都说:‘我做国君没有别的快乐,只是我说什么话都没有人违抗我。’假若说的话正确而没有人违抗,不也好么?假若说的话不正确而也没有人违抗,不近于一句话便丧失国家么?”\n13.16叶公问政。子曰:“近者悦,远者来。”\n【译文】叶公问政治。孔子道:“境内的人使他高兴,境外的人使他来投奔。”\n13.17子夏为莒父⑴宰,问政。子曰:“无欲速,无见小利。欲速,则不达;见小利,则大事不成。”\n【译文】子夏做了莒父的县长,问政治。孔子道:“不要图快,不要顾小利。图快,反而不能达到目的;顾小利,就办不成大事。”\n【注释】⑴莒父——鲁国之一邑,现在已经不能确知其所在。山东通志认为在今山东高密县东南。\n13.18叶公语孔子曰:“吾党有直躬者,其父攘羊,而子证⑴之。”孔子曰:“吾党之直者异于是:父为子隐,子为父隐。——直在其中⑵矣。”\n【译文】叶公告诉孔子道:“我那里有个坦白直率的人,他父亲偷了羊,他便告发。”孔子道:“我们那里坦白直率的人和你们的不同:父亲替儿子隐瞒,儿子替父亲隐瞒——直率就在这里面。”\n【注释】⑴证——《说文》云:“证,告也。”正是此义。相当今日的“检举”“揭发”,《韩非子·五蠹篇》述此事作“谒之吏”,《吕氏春秋·当务篇》述此事作“谒之上”,都可以说明正是其子去告发他父亲。“证明”的“证”,古书一般用“征”字为之。⑵直在其中——孔子伦理哲学的基础就在于“孝”和“慈”因之说父子相隐,直在其中。\n13.19樊迟问仁。子曰:“居处恭,执事敬,与人忠。虽之⑴夷狄,不可弃也。”\n【译文】樊迟问仁。孔子道:“平日容貌态度端正庄严,工作严肃认真,为别人忠心诚意。这几种品德,纵到外国去,也是不能废弃的。”\n【注释】⑴之——动词,到也。\n13.20子贡问曰:“何如斯可谓之士矣?”子曰:“行己有耻,使于四方,不辱君命,可谓士矣。”\n曰:“敢问其次。”曰:“宗族称孝焉,乡党称弟焉。”\n曰:“敢问其次。”曰:“言必信,行必果,硁硁然小人哉!——抑亦可以为次矣。”\n曰:“今之从政者何如?”子曰:“噫!斗筲之人⑴,何足算也?”\n【译文】子贡问道:“怎样才可以叫做‘士’?”孔子道:“自己行为保持羞耻之心,出使外国,很好地完成君主的使命,可以叫做‘士’了。”\n子贡道:“请问次一等的。”孔子道:“宗族称赞他孝顺父母,乡里称赞他恭敬尊长。”\n子贡又道:“请问再次一等的。”孔子道:“言语一定信实,行为一定坚决,这是不问是非黑白而只管自己贯彻言行的小人呀,但也可以说是再次一等的‘士’了。”\n子贡道:“现在的执政诸公怎么样?”孔子道:“咳!这班器识狭小的人算得什么?”\n【注释】⑴斗筲之人——斗是古代的量名,筲音梢,shāo,古代的饭筐(《说文》作𥳓),能容五升。斗筲譬如度量和见识的狭小。有人说,“斗筲之人”也可以译为“车载斗量之人”,言其不足为奇。\n13.21子曰:“不得中行而与之,必也狂狷⑴乎!狂者进取,狷者有所不为也。”\n【译文】孔子说:“得不到言行合乎中庸的人和他相交,那一定要交到激进的人和狷介的人罢,激进者一意向前,狷介者也不肯做坏事。”\n【注释】⑴狂狷——《孟子·尽心篇下》有一段话可以为本文的解释,録之于下:“孟子曰:‘孔子不得中道而与之,必也狂獧(同“狷”)乎!狂者进取,獧者有所不为也。孔子岂不欲中道哉?不可必得,故思其次也。’‘敢问何如斯可谓狂矣?’(此万章问词,下同。)曰:‘如琴张、曾晳、牧皮者,孔子之所谓狂矣。’何以谓之狂也?’曰:‘其志嘐嘐然,曰:古之人!古之人!夷考其行而不掩焉者也。狂者又不可得,欲得不屑不洁之士而与之,是獧也,是又其次也。’”孟轲这话未必尽合孔子本意,但可备参考。\n13.22子曰:“南人有言曰:‘人而无恒,不可以作巫医⑴。’善夫!”\n“不恒其德⑵,或承之羞。”子曰:“不占而已矣。”\n【译文】孔子说:“南方人有句话说,‘人假若没有恒心,连巫医都做不了。’这句话很好呀!”\n《易经·恒卦》的爻辞说:“三心二意,翻云覆雨,总有人招致羞耻。”孔子又说:“这话的意思是叫无恒心的人不必去占卦罢了。”\n【注释】⑴巫医——巫医是一词,不应分为卜筮的巫和治病的医两种。古代常以禳祷之术替人治疗,这种人便叫巫医。⑵不恒其德——这有两种意义:(甲)不能持久,时作时辍;(乙)没有一定的操守。译文用“三心二意”表示“不能持久”,用“翻云覆雨”表示“没有操守”。\n13.23子曰:“君子和而不同,小人同而不和⑴。”\n【译文】孔子说:“君子用自己的正确意见来纠正别人的错误意见,使一切都做到恰到好处,却不肯盲从附和。小人只是盲从附和,却不肯表示自己的不同意见。”\n【注释】⑴和,同——“和”与“同”是春秋时代的两个常用术语,《左传》昭公二十年所载晏子对齐景公批评梁丘据的话,和《国语·郑语》所载史伯的话都解说得非常详细。“和”如五味的调和,八音的和谐,一定要有水、火、酱、醋各种不同的材料才能调和滋味,一定要有高下、长短、疾徐各种不同的声调才能使乐曲和谐。晏子说:“君臣亦然。君所谓可,而有否焉,臣献其否以成其可;君所谓否,而有可焉,臣献其可以去其否。”因此史伯也说,“以他平他谓之和”。“同”就不如此,用晏子的话说:“君所谓可,据亦曰可;君所谓否,据亦曰否;若以水济水,谁能食之?若琴瑟之专一,谁能听之?‘同’之不可也如是。”我又认为这个“和”字与“礼之用和为贵”的“和”有相通之处。因此译文也出现了“恰到好处”的字眼。\n13.24子贡问曰:“乡人皆好之,何如?”子曰:“未可也⑴。”\n“乡人皆恶之,何如?”子曰:“未可也;不如乡人之善者好之,其不善者恶之。”\n【译文】子贡问道:“满乡村的人都喜欢他,这个人怎么样?”孔子道:“还不行。”\n子贡便又道:“满乡村的人都厌恶他,这个人怎么样?”孔子道:“还不行。最好是满乡村的好人都喜欢他,满乡村的坏人都厌恶他。”\n【注释】⑴未可也——如果一乡之人皆好之,便近乎所谓好好先生,孔、孟叫他为“乡愿。”因之孔子便说:“众好之,必察焉;众恶之,必察焉。”(15.28)又说,“唯仁者能好人,能恶人。”(4.3)这可以为“善者好之,不善者恶之”的解释。\n13.25子曰:“君子易事⑴而难说也。说之不以道,不说也;及其使人也,器之。小人难事而易说也。说之虽不以道,说也;及其使人也,求备焉。”\n【译文】孔子说:“在君子底下工作很容易,讨他的欢喜却难。不用正当的方式去讨他的欢喜,他不会欢喜的;等到他使用人的时候,却衡量各人的才德去分配任务。在小人底下工作很难,讨他的欢喜却容易。用不正当的方式去讨他的欢喜,他会欢喜的;等到他使用人的时候,便会百般挑剔,求全责备。”\n【注释】⑴易事——《说苑·雅言篇》说:“曾子曰,‘夫子见人之一善而忘其百非,是夫子之易事也’。”这话可以作“君子易事”的一个说明。\n13.26子曰:“君子泰而不骄⑴,小人骄而不泰。”\n【译文】孔子说:“君子安详舒泰,却不骄傲凌人;小人骄傲凌人,却不安详舒泰。”\n【注释】⑴泰,骄——皇侃《义疏》云:“君子坦荡荡,心貌怡平,是泰而不为骄慢也;小人性好轻凌,而心恒戚戚,是骄而不泰也。”李塨《论语传注》云:“君子无众寡,无小大,无敢慢(按:见20.2),何其舒泰!小人矜己傲物,惟恐失尊,何其骄侈,而安得泰?”译文正取此义。\n13.27子曰:“刚、毅、木、讷近仁。”\n【译文】孔子说:“刚强、果决、朴质,而言语不轻易出口,有这四种品德的人近于仁德。”\n13.28子路问曰:“何如斯可谓之士矣?”子曰:“切切偲偲⑴,怡怡⑵如也,可谓士矣。朋友切切偲偲,兄弟怡怡。”\n【译文】子路问道:“怎么样才可以叫做‘士’了呢?”孔子道:“互相批评,和睦共处,可以叫做‘士’了。朋友之间,互相批评;兄弟之间,和睦共处。”\n【注释】⑴切切偲偲——偲音思,sī。切切偲偲,互相责善的样子。⑵怡怡——和顺的样子。\n13.29子曰:“善人教民七年,亦可以卽戎⑴矣。”\n【译文】孔子说:“善人教导人民达七年之久,也能够叫他们作战了。”\n【注释】⑴卽戎——“卽”是“卽位”的“卽”,就也,往那里去的意思。“戎”是“兵戎”的意思。\n13.30子曰:“以不教民⑴战,是谓弃之。”\n【译文】孔子道:“用未经受过训练的人民去作战,这等于糟踏生命。”\n【注释】⑴不教民——“不教民”三字构成一个名词语,意思就是“不教之民”,正如《诗经·邶风·柏舟》“心之忧矣,如匪澣衣”的“匪澣衣”一样,意思就是“匪澣之衣”(不曾洗涤过的衣服)。\n"},{"id":102,"href":"/zh/docs/culture/%E8%AE%BA%E8%AF%AD/%E8%AE%BA%E8%AF%AD%E8%AF%91%E6%B3%A8_%E6%9D%A8%E4%BC%AF%E5%B3%BB/09%E5%AD%90%E7%BD%95%E7%AF%87%E7%AC%AC%E4%B9%9D/","title":"09子罕篇第九","section":"论语译注 杨伯峻","content":" 子罕篇第九 # (共三十一章(朱熹《集注》把第六、第七两章合并为一章,所以作三十章。))\n9.1子罕⑴言利与命与仁。\n【译文】孔子很少[主动]谈到功利、命运和仁德。\n【注释】⑴罕——副词,少也,只表示动作频率。而《论语》一书,讲“利”的六次,讲“命”的八、九次,若以孔子全部语言比较起来,可能还算少的。因之子贡也说过,“夫子之言性与天道,不可得而闻也。”(公冶长篇第五)至于“仁”,在《论语》中讲得最多,为什么还说“孔子罕言”呢?于是对这一句话便生出别的解释了。金人王若虚(《误谬杂辨》)、清人史绳祖(《学斋占毕》)都以为造句应如此读:“子罕言利,与命,与仁。”“与”,许也。意思是“孔子很少谈到利,却赞成命,赞成仁”。黄式三(《论语后案》)则认为“罕”读为“轩”,显也。意思是“孔子很明显地谈到利、命和仁”。遇夫先生(《论语疏证》)又以为“所谓罕言仁者,乃不轻许人以仁之意,与罕言利命之义似不同。试以圣人评论仲弓、子路、冉有、公西华、令尹子文、陈文子之为人及克伐怨欲不行之德,皆云不知其仁,更参之以儒行之说,可以证明矣”。我则以为《论语》中讲“仁”虽多,但是一方面多半是和别人问答之词,另一方面,“仁”又是孔门的最高道德标准,正因为少谈,孔子偶一谈到,便有记载。不能以记载的多便推论孔子谈得也多。孔子平生所言,自然千万倍于《论语》所记载的,《论语》出现孔子论“仁”之处若用来和所有孔子平生之言相比,可能还是少的。诸家之说未免对于《论语》一书过于拘泥,恐怕不与当时事实相符,所以不取。于省吾读“仁”为“𡰥”,卽“夷狄”之“夷”,未必确。\n9.2达巷党⑴人曰:“大哉孔子!博学而无所成名。”子闻之,谓门弟子曰:“吾何执?执御乎?执射乎?吾执御矣。”\n【译文】达街的一个人说:“孔子真伟大!学问广博,可惜没有足以树立名声的专长。”孔子听了这话,就对学生们说:“我干什么呢?赶马车呢?做射击手呢?我赶马车好了。”\n【注释】⑴达巷党——《礼记·杂记》有“余从老聃助葬于巷党”的话,可见“巷党”两字为一词,“里巷”的意思。\n9.3子曰:“麻冕⑴,礼也;今也纯⑵,俭⑶,吾从众。拜下⑷,礼也;今拜乎上,泰也。虽违众,吾从下。”\n【译文】孔子说:“礼帽用麻料来织,这是合于传统的礼的;今天大家都用丝料,这样省俭些,我同意大家的做法。臣见君,先在堂下磕头,然后升堂又磕头,这是合于传统的礼的。今天大家都免除了堂下的磕头,只升堂后磕头,这是倨傲的表现。虽然违反大家,我仍然主张要先在堂下磕头。”\n【注释】⑴麻冕——一种礼帽,有人说就是缁布冠(古人一到二十岁,便举行加帽子的仪式,叫“冠礼”。第一次加的便是缁布冠),未必可信。⑵纯——黑色的丝。⑶俭——绩麻做礼帽,依照规定,要用二千四百缕经线。麻质较粗,必须织得非常细密,这很费工。若用丝,丝质细,容易织成,因而省俭些。⑷拜下——指臣子对君主的行礼,先在堂下磕头,然后升堂再磕头。《左传》僖公九年和《国语·齐语》都记述齐桓公不听从周襄王的辞让,终于下拜的事。到孔子时,下拜的礼似乎废弃了。\n9.4子絶四——毋意,毋必,毋固,毋我。\n【译文】孔子一点也没有四种毛病——不悬空揣测,不绝对肯定,不拘泥固执,不唯我独是。\n9.5子畏于匡⑴,曰:“文王既没,文不在兹乎?天之将丧斯文也,后死者⑵不得与⑶于斯文也;天之未丧斯文也,匡人其如予何?”\n【译文】孔子被匡地的羣众所拘禁,便道:“周文王死了以后,一切文化遗产不都在我这里吗?天若是要消灭这种文化,那我也不会掌握这些文化了;天若是不要消灭这一文化,那匡人将把我怎么样呢?”\n【注释】⑴子畏于匡——《史记·孔子世家》说,孔子离开卫国,准备到陈国去,经过匡。匡人曾经遭受过鲁国阳货的掠夺和残杀,而孔子的相貌很像阳货,便以为孔子就是过去曾经残害过匡地的人,于是囚禁了孔子。“畏”是拘囚的意思,《荀子·赋篇》云:“比干见刳,孔子拘匡。”《史记·孔子世家》作“拘焉五日”,可见这一“畏”字和《礼记·檀弓》“死而不吊者三,畏、厌、溺”的“畏”相同,说见俞樾《羣经平议》。今河南省长垣县西南十五里有匡城,可能就是当日孔子被囚之地。⑵后死者——孔子自谓。⑶与——音预。\n9.6太宰⑴问于子贡曰:“夫子圣者与?何其多能也?”子贡曰:“固天纵之将圣,又多能也。”\n子闻之,曰:“太宰知我乎!吾少也贱,故多能鄙事。君子多乎哉?不多也。”\n【译文】太宰向子贡问道:“孔老先生是位圣人吗?为什么这样多才多艺呢?”子贡道:“这本是上天让他成为圣人,又使他多才多艺。”\n孔子听到,便道:“太宰知道我呀!我小时候穷苦,所以学会了不少鄙贱的技艺。真正的君子会有这样多的技巧吗?是不会的。”\n【注释】⑴太宰——官名。这位太宰已经不知是哪一国人以及姓甚名谁了。\n9.7牢⑴曰:“子云,‘吾不试⑵,故艺。’”\n【译文】牢说:“孔子说过,我不曾被国家所用,所以学得一些技艺。”\n【注释】⑴牢——郑玄说是孔子学生,但《史记·仲尼弟子列传》无此人。王肃伪撰之《孔子家语》说“琴张,一名牢,字子开,亦字子张,卫人也”,尤其不可信。说本王引之,详王念孙《读书杂志》卷四之三。⑵试——《论衡·正说篇》云:“尧曰:‘我其试哉!’说《尚书》曰:‘试者用也。’”这“试”字也应当“用”字解。\n9.8子曰:“吾有知乎哉?无知也。有鄙夫问于我,空空如也。我叩其两端而竭焉。”\n【译文】孔子说:“我有知识吗?没有哩。有一个庄稼汉问我,我本是一点也不知道的;我从他那个问题的首尾两头去盘问,[才得到很多意思,]然后尽量地告诉他。”\n9.9子曰:“凤鸟不至,河不出图⑴,吾已矣夫!”\n【译文】孔子说:“凤凰不飞来了,黄河也没有图画出来了,我这一生恐怕是完了吧!”\n【注释】⑴凤鸟河图——古代传说,凤凰是一种神鸟,祥瑞的象征,出现就是表示天下太平。又说,圣人受命,黄河就出现图画。孔子说这几句话,不过藉此比喻当时天下无清明之望罢了。\n9.10子见齐衰⑴者、冕衣裳者⑵与瞽者,见之,虽少,必作⑶;过之,必趋⑶。\n【译文】孔子看见穿丧服的人、穿戴着礼帽礼服的人以及瞎了眼睛的人,相见的时候,他们虽然年轻,孔子一定站起来;走过的时候,一定快走几步。\n【注释】⑴齐衰——齐音咨,zī;衰音崔,cuī。齐衰,古代丧服,用熟麻布做的,其下边缝齐(斩衰则用粗而生的麻布,左右及下边也都不缝)。齐衰又有齐衰三年、齐衰期(一年)、齐衰五月、齐衰三月几等;看死了什么人,便服多长日子的孝。这里讲齐衰,自然也包括斩衰而言。斩衰是最重的孝服,儿子对父亲,臣下对君上才斩衰三年。⑵冕衣裳者——卽衣冠整齐的贵族。冕是高等贵族所戴的礼帽,后来只有皇帝所戴才称冕。衣是上衣,裳是下衣,相当现代的帬。古代男子上穿衣,下着帬。⑶作,趋——作,起;趋,疾行。这都是一种敬意的表示。\n9.11颜渊喟然叹曰:“仰之弥高,钻之弥坚。瞻之在前,忽焉在后。夫子循循然善诱人,博我以文,约我以礼,欲罢不能。既竭吾才,如有所立卓尔。虽欲从之,末由也已。”\n【译文】颜渊感叹着说:“老师之道,越抬头看,越觉得高;越用力钻研,越觉得深。看看,似乎在前面,忽然又到后面去了。[虽然这样高深和不容易捉摸,可是]老师善于有步骤地诱导我们,用各种文献来丰富我的知识,又用一定的礼节来约束我的行为,使我想停止学习都不可能。我已经用尽我的才力,似乎能够独立地工作。要想再向前迈进一步,又不知怎样着手了。”\n9.12子疾病,子路使门人为臣⑴。病间,曰:“久矣哉,由之行诈也!无臣而为有臣。吾谁欺?欺天乎!且予与其死于臣之手也,无宁⑵死于二三子之手乎!且予纵不得大葬,予死于道路乎?”\n【译文】孔子病得厉害,子路便命孔子的学生组织治丧处。很久以后,孔子的病渐渐好了,就道:“仲由干这种欺假的勾当竟太长久了呀!我本不该有治丧的组织,却一定要使人组织治丧处。我欺哄谁呢?欺哄上天吗?我与其死在治丧的人的手里,宁肯死在你们学生们的手里,不还好些吗?卽使不能热热闹闹地办理丧葬,我会死在路上吗?”\n【注释】⑴为臣——和今天的组织治丧处有相似之处,所以译文用来比傅。但也有不同之处。相似之处是死者有一定的社会地位才给他组织治丧处。古代,诸侯之死才能有“臣”;孔子当时,可能有许多卿大夫也“僭”行此礼。不同之处是治丧处人死以后才组织,才开始工作。“臣”却不然,死前便工作,死者的衣衾手足的安排以及翦须诸事都由“臣”去处理。所以孔子这里也说“死于臣之手”的话。⑵无宁——“无”为发语词,无义。《左传》隐公十一年云:“无宁兹许公复奉其社稷。”杜预的注说:“无宁,宁也。”\n9.13子贡曰:“有美玉于斯,韫椟而藏诸?求善贾⑴而沽诸?”子曰:“沽之哉!沽之哉!我待贾者也。”\n【译文】子贡道:“这里有一块美玉,把它放在柜子里藏起来呢?还是找一个识货的商人卖掉呢?”孔子道:“卖掉,卖掉,我是在等待识货者哩。”\n【注释】⑴贾——音古,gǔ,商人。又同“价”,价钱。如果取后一义,“善贾”便是“好价钱”,“待贾”便是“等好价钱”。不过与其说孔子是等价钱的人,不如说他是等识货者的人。\n9.14子欲居九夷⑴。或曰:“陋,如之何?”子曰:“君子居之,何陋之有⑵?”\n【译文】孔子想搬到九夷去住。有人说:“那地方非常简陋,怎么好住?,”孔子道:“有君子去住,就不简陋了。”\n【注释】⑴九夷——九夷就是淮夷。《韩非子·说林上篇》云:“周公旦攻九夷而商盖伏。”商盖就是商奄,则九夷本居鲁国之地,周公曾用武力降服他们。春秋以后,盖臣属楚、吴、越三国,战国时又专属楚。以《说苑·君道篇》、《淮南子·齐俗训》、《战国策·秦策》与《魏策》、李斯〈上秦始皇书〉诸说九夷者考之,九夷实散居于淮、泗之间,北与齐、鲁接壤(说本孙诒让《墨子闲诂·非攻篇》)。⑵何陋之有——直译是“有什么简陋呢”,此用意译。\n9.15子曰:“吾自卫反鲁⑴,然后乐正,《雅》、《颂》各得其所⑵。”\n【译文】孔子说:“我从卫国回到鲁国,才把音乐[的篇章]整理出来,使《雅》归《雅》,《颂》归《颂》,各有适当的安置。”\n【注释】⑴自卫反鲁——根据《左传》,事在鲁哀公十一年冬。⑵雅颂各得其所——“雅”和“颂”一方面是《诗经》内容分类的类名,一方面也是乐曲分类的类名。篇章内容的分类,可以由今日的《诗经》考见;乐曲的分类,因为古乐早已失传,便无可考证了。孔子的正雅颂,究竟是正其篇章呢?还是正其乐曲呢?或者两者都正呢?《史记·孔子世家》和《汉书·礼乐志》则以为主要的是正其篇章,因为我们已经得不到别的材料,只得依从此说。孔子只“正乐”,调整《诗经》篇章的次序,太史公在孔子世家中因而说孔子曾把三千余篇的古诗删为三百余篇,是不可信的。\n9.16子曰:“出则事公卿,入则事父兄⑴,丧事不敢不勉,不为酒困,何有于我哉⑵?”\n【译文】孔子说:“出外便服事公卿,入门便服事父兄,有丧事不敢不尽礼,不被酒所困扰,这些事我做到了哪些呢?”\n【注释】⑴父兄——孔子父亲早死,说这话时候,或者他哥孟皮还在,“父兄”二字,只“兄”字有义,古人常有这用法。“父兄”或者在此引伸为长者之义。⑵何有于我哉——如果把“何有”看为“不难之词”,那这一句便当译为“这些事对我有什么困难呢”。全文由自谦之词变为自述之词了。\n9.17子在川上,曰:“逝者如斯夫!不舍⑴昼夜。”\n【译文】孔子在河边,叹道:“消逝的时光像河水一样呀!日夜不停地流去。”\n【注释】⑴舍——上、去两声都可以读。上声,同舍;去声,也作动词,居住,停留。孔子这话不过感叹光阴之奔驶而不复返吧了,未必有其它深刻的意义。《孟子·离娄下》、《荀子·宥坐篇》、《春秋繁露·山川颂》对此都各有阐发,很难说是孔子本意。\n9.18子曰:“吾未见好德如好色者也。”\n【译文】孔子说:“我没有看见过这样的人,喜爱道德赛过喜爱美貌。”\n9.19子曰:“譬如为山,未成一篑,止,吾止也。譬如平地,虽覆一篑,进,吾往也⑴。”\n【译文】孔子说:“好比堆土成山,只要再加一筐土便成山了,如果懒得做下去,这是我自己停止的。又好比在平地上堆土成山,纵是刚刚倒下一筐土,如果决心努力前进,还是要自己坚持呵!”\n【注释】⑴子曰……往也——这一章也可以这样讲解:“好比堆土成山,只差一筐土了,如果[应该]停止,我便停止。好比平地堆土成山,纵是刚刚倒下一筐土,如果[应该]前进,我便前进。”依照前一讲解,便是“为仁由己”的意思;依照后一讲解,便是“唯义与比”的意思。\n9.20子曰:“语之而不惰者,其回也与!”\n【译文】孔子说:“听我说话始终不懈怠的,大概只有颜回一个人吧!”\n9.21子谓颜渊,曰:“惜乎!吾见其进也,未见其止也。”\n【译文】孔子谈到颜渊,说道:“可惜呀[他死了]!我只看见他不断地进步,从没看见他停留。”\n9.22子曰:“苗而不秀⑴者有矣夫!秀而不实者有矣夫!”\n【译文】孔子说:“庄稼生长了,却不吐穗开花的,有过的罢!吐穗开花了,却不凝浆结实的,有过的罢!”\n【注释】⑴秀——“秀”字从禾,则只是指禾黍的吐花。《诗经·大雅·生民》云:“实发实秀,实坚实好。”“发”和“秀”是指庄稼的生长和吐穗开花;“坚”和“好”是指谷粒的坚实和壮大。这都是“秀”的本义。现在还把庄稼的吐穗开花叫做“秀穗”。因此译文点明是指庄稼而言。汉人唐人多以为孔子这话是为颜回短命而发。但颜回只是“秀而不实”(祢衡〈颜子碑〉如此说),则“苗而不秀”又指谁呢?孔子此言必有为而发,但究竟何所指,则不必妄测。\n9.23子曰:“后生可畏,焉知来者之不如今也?四十、五十而无闻焉,斯亦不足畏也已。”\n【译文】孔子说:“年少的人是可怕的,怎能断定他的将来赶不上现在的人呢,一个人到了四、五十岁还没有什么名望,也就值不得惧怕了。”\n9.24子曰:“法语之言,能无从乎?改之为贵。巽与之言,能无说乎?绎之为贵。说而不绎,从而不改,吾末如之何也已矣。”\n【译文】孔子说:“严肃而合乎原则的话,能够不接受吗?改正错误才可贵。顺从己意的话,能够不高兴吗?分析一下才可贵。盲目高兴,不加分析;表面接受,实际不改,这种人我是没有办法对付他的了。”\n9.25子曰:“主忠信,毋友不如己者,过则勿惮改⑴。”\n【注释】⑴见卷一学而篇。\n9.26子曰:“三军⑴可夺帅也,匹夫不可夺志也。”\n【译文】孔子说:“一国军队,可以使它丧失主帅;一个男子汉,却不能强迫他放弃主张。”\n【注释】⑴三军——周朝的制度,诸侯中的大国可以拥有军队三军。因此便用“三军”作军队的通称。\n9.27子曰:“衣⑴敝缊⑵袍,与衣⑴狐貉者立,而不耻者,其由也与?‘不忮不求,何用不臧⑶?’”子路终身诵之。子曰:“是道也,何足以臧?”\n【译文】孔子说道:“穿着破烂的旧丝绵袍子和穿着狐貉裘的人一道站着,不觉得惭愧的,恐怕只有仲由罢!《诗经》上说:‘不嫉妒,不贪求,为什么不会好?’”子路听了,便老念着这两句诗。孔子又道:“仅仅这个样子,怎样能够好得起来?”\n【注释】⑴衣——去声,动词,当“穿”字解。⑵缊——音运,yùn,旧絮。古代没有草棉,所有“絮”字都是指丝绵。一曰,乱麻也。⑶不忮不求,何用不臧——两句见于《诗经·邶风·雄雉篇》。\n9.28子曰:“岁寒,然后知松柏之后雕⑴也。”\n【译文】孔子说:“天冷了,才晓得松柏树是最后落叶的。”\n【注释】⑴雕——同凋、凋零,零落。\n9.29子曰:“知者不惑,仁者不忧,勇者不惧。”\n【译文】孔子说:“聪明人不致疑惑,仁德的人经常乐观,勇敢的人无所畏惧。”\n9.30子曰:“可与共学,未可与适道;可与适道,未可与立⑴;可与立,未可与权。”\n【译文】孔子说:“可以同他一道学习的人,未必可以同他一道取得某种成就;可以同他一道取得某种成就的人,未必可以同他一道事事依体而行;可以同他一道事事依体而行的人,未必可以同他一道通权达变。”\n【注释】⑴立——《论语》的“立”经常包含着“立于礼”的意思,所以这里译为“事事依礼而行”。\n9.31“唐棣⑴之华,偏其反而。岂不尔思?室是远而。”子曰:“未之思也,夫何远之有?”\n【译文】古代有几句这样的诗:“唐棣树的花,翩翩地摇摆。难道我不想念你?因为家住得太遥远。”孔子道:“他是不去想念哩,真的想念,有什么遥远呢?”\n【注释】⑴唐棣……何远之有——唐棣,一种植物,陆玑《毛诗草木鸟兽虫鱼疏》以为就是郁李(蔷薇科,落叶乔木),李时珍《本草纲目》却以为是扶栘(蔷薇科,落叶乔木)。“唐棣之华,偏其反而”似是捉摸不定的意思,或者和颜回讲孔子之道“瞻之在前,忽焉在后”(9.11)意思差不多。“夫何远之有”可能是“仁远乎哉?我欲仁,斯仁至矣”(7.30)的意思。或者当时有人引此诗(这是“逸诗”,不在今《诗经》中),意在证明道之远而不可捉摸,孔子则说,你不曾努力罢了,其实是一呼卽至的。\n"},{"id":103,"href":"/zh/docs/culture/%E8%AE%BA%E8%AF%AD/%E8%AE%BA%E8%AF%AD%E8%AF%91%E6%B3%A8_%E6%9D%A8%E4%BC%AF%E5%B3%BB/06%E9%9B%8D%E4%B9%9F%E7%AF%87%E7%AC%AC%E5%85%AD/","title":"06雍也篇第六","section":"论语译注 杨伯峻","content":" 雍也篇第六 # (共三十章(朱熹《集注》把第一、第二和第四、第五各并为一章,故作二十八章。))\n6.1子曰:“雍也可使南面⑴。”\n【译文】孔子说:“冉雍这个人,可以让他做一部门或一地方的长官。”\n【注释】⑴南面——古代早就知道坐北朝南的方向是最好的,因此也以这个方向的位置最为尊贵,无论天子、诸侯、卿大夫,当他作为长官出现的时候,总是南面而坐的。说见王引之《经义述闻》和凌廷堪《礼经释义》。\n6.2仲弓问子桑伯子⑴。子曰:“可也简⑵。”仲弓曰:“居敬而行简,以临其民,不亦可乎?居简而行简,无乃⑶大⑷简乎?”子曰:“雍之言然。”\n【译文】仲弓问到子桑伯子这个人。孔子道:“他简单得好。”仲弓道:“若存心严肃认真,而以简单行之,[抓大体,不烦琐,]来治理百姓,不也可以吗?若存心简单,又以简单行之,不是太简单了吗?”孔子道:“你这番话正确。”\n【注释】⑴子桑伯子——此人已经无可考。有人以为就是《庄子》的子桑户,又有人以为就是秦穆公时的子桑(公孙枝),都未必可靠。既然称“伯子”,很大可能是卿大夫。仲弓说“以临其民”。也要是卿大夫才能临民。⑵简——《说苑》有子桑伯子的一段故事,说他“不衣冠而处”,孔子却认为他“质美而无文”,因之有人认为这一“简”字是指其“无文”而言。但此处明明说他“可也简”,而《说苑》孔子却说,“吾将说而文之”,似乎不能如此解释。朱熹以为“简”之所以“可”,在于“事不烦而民不扰”,颇有道理,故译文加了两句。⑶无乃——相当于“不是”,但只用于反问句。⑷大——同“太”。\n6.3哀公问:“弟子孰为好学?”孔子对曰:“有颜回者好学,不迁怒,不贰过。不幸短命⑴死矣,今也则亡,未闻好学者也。”\n【译文】鲁哀公问:“你的学生中,哪个好学?”孔子答道:“有一个叫颜回的人好学,不拿别人出气;也不再犯同样的过失。不幸短命死了,现在再没有这样的人了,再也没听过好学的人了。”\n【注释】短命——《公羊传》把颜渊的死列在鲁哀公十四年(公元前481年),其时孔子年七十一,依《史记·仲尼弟子列传》,颜渊少于孔子三十岁,则死时年四十一。但据《孔子家语》等书,颜回卒时年仅三十一,因此毛奇龄(《论语稽求篇》)谓《史记》“少孔子三十岁,原是四十之误”。\n6.4子华⑴使⑵于齐,冉子⑶为其母请粟⑷。子曰:“与之釜⑸。”\n请益。曰:“与之庾⑹。”\n冉子与之粟五秉⑺。\n子曰:“赤之适齐也,乘肥马⑻,衣⑼轻裘。吾闻之也:君子周⑽急不继富。”\n【译文】公西华被派到齐国去作使者,冉有替他母亲向孔子请求小米。孔子道:“给他六斗四升。”\n冉有请求增加。孔子道:“再给他二斗四升。”\n冉有却给了他八十石。\n孔子道:“公西赤到齐国去,坐着由肥马驾的车辆,穿着又轻又暖的皮袍。我听说过:君子只是雪里送炭,不去锦上添花。”\n【注释】⑴子华——孔子学生,姓公西,名赤,字子华,比孔子小四十二岁。⑵使——旧读去声,出使。⑶冉子——《论语》中,孔子弟子称“子”的不过曾参、有若、闵子骞和冉有几个人,因之这冉子当然就是冉有。⑷粟——小米(详新建设杂志1954年12月号胡静〈我国古代农艺史上的几个问题〉)。一般的说法,粟是指未去壳的谷粒,去了壳就叫做米。但在古书中也有把米唤做粟的。见沈彤《周官禄田考》。⑸釜——fǔ,古代量名,容当时的量器六斗四升,约合今天的容量一斗二升八合。⑹庾——yǔ,古代量名,容当日的二斗四升,约合今日的四升八合。⑺秉——音丙,bǐng,古代量名,十六斛。五秉则是八十斛。古代以十斗为斛,所以译为八十石。南宋的贾似道才改为五斗一斛,一石两斛,沿用到民国初年,现今已经废除这一量名了。周秦的八十斛合今天的十六石。⑻乘肥马——不能解释为“骑肥马”,因为孔子时穿着大袖子宽腰身的衣裳,是不便于骑马的。直到战国时的赵武灵王才改穿少数民族服装,学习少数民族的骑着马射箭,以便利于作战。在所有“经书”中找不到骑马的文字,只有《曲礼》有“前有车骑”一语,但《曲礼》的成书在战国以后。⑼衣——去声,动词,当“穿”字解。⑽周——后人写作“赒”,救济。\n6.5原思⑴为之⑵宰,与之粟九百⑶,辞。子曰:“毋!以与尔邻里乡党⑷乎!”\n【译文】原思任孔子家的总管,孔子给他小米九百,他不肯受。孔子道:“别辞,有多的,给你地方上[的穷人]吧!”\n【注释】⑴原思——孔子弟子原宪,字子思。⑵之——用法同“其”,他的,指孔子而言。⑵九百——下无量名,不知是斛是斗,还是别的。习惯上常把最通用的度、量、衡的单位省畧不说,古今大致相同。不过这一省畧,可把我们迷胡了。⑷邻里乡党——都是古代地方单位的名称,五家为邻,二十五家为里,万二千五百家为乡,五百家为党。\n6.6子谓仲弓,曰:“犂牛⑴之子骍⑵且角⑶;虽欲勿用⑷,山川其⑸舍诸⑹?”\n【译文】孔子谈到冉雍,说:“耕牛的儿子长着赤色的毛,整齐的角,虽然不想用它作牺牲来祭祀,山川之神难道会舍弃它吗?”\n【注释】⑴犂牛——耕牛。古人的名和字,意义一定互相照应。从孔子学生冉耕字伯牛、司马耕字子牛的现象看来,足以知道生牛犂田的方法当时已经普遍实行。从前人说,耕牛制度开始于汉武帝时的赵过,那是由于误解《汉书·食货志》的缘故。⑵骍——赤色。周朝以赤色为贵,所以祭祀的时候也用赤色的牲畜。⑶角——意思是两角长得周正。这是古人用词的简略处。⑷用——义同《左传》“用牲于社”之“用”,杀之以祭也。据《史记·仲尼弟子列传》说,仲弓的父亲是贱人,仲弓却是“可使南面”的人才,因此孔子说了这番话。古代供祭祀的牺牲不用耕牛,而且认为耕牛之子也不配作牺牲。孔子的意思是,耕牛所产之子如果够得上作牺牲的条件,山川之神一定会接受这种祭享。那么,仲弓这样的人才,为什么因为他父亲“下贱”而舍弃不用呢?⑸其——意义同“岂”。⑹诸——“之乎”两字的合音字。\n6.7子曰:“回也,其心三月⑴不违仁,其余则日月⑵至焉而已矣。”\n【译文】孔子说:“颜回呀,他的心长久地不离开仁德,别的学生么,只是短时期偶然想起一下罢了。”\n【注释】⑴三月,日月——这种词语必须活看,不要被字面所拘束,因此译文用“长久地”译“三月”,用“短时期”“偶然”来译“日月”。\n6.8季康子问:“仲由可使从政也与?”子曰:“由也果,于从政乎何有?”\n曰:“赐也可使从政也与?”曰:“赐也达,于从政乎何有?”\n曰:“求也可使从政也与?”曰:“求也艺,于从政乎何有?”\n【译文】季康子问孔子:“仲由这人,可以使用他治理政事么?”孔子道:“仲由果敢决断,让他治理政事有什么困难呢?”\n又问:“端木赐可以使用他治理政事么?”孔子道:“端木赐通情达理,让他治理政事有什么困难呢?”\n又问:“冉求可以使用他治理政事么?”孔子道:“冉求多才多艺,让他治理政事有什么困难呢?”\n6.9季氏使闵子骞⑴为费⑵宰。闵子骞曰:“善为我辞焉!如有复我者,则吾必在汶上⑶矣。”\n【译文】季氏叫闵子骞作他采邑费地的县长。闵子骞对来人说道:“好好地替我辞掉吧!若是再来找我的话,那我一定会逃到汶水之北去了。”\n【注释】⑴闵子骞——孔子学生闵损,字子骞,比孔子小十五岁。(公元前515——?)⑵费——旧音秘,故城在今山东费县西北二十里。⑶汶上——汶音问,wèn,水名,就是山东的大汶河。桂馥《札朴》云:“水以阳为北,凡言某水上者,皆谓水北。”“汶上”暗指齐国之地。\n6.10伯牛⑴有疾,子问之,自牖执其手,曰:“亡之⑵,命矣夫!斯人也而有斯疾也!斯人也而有斯疾也!”\n【译文】伯牛生了病,孔子去探问他,从窗户里握着他的手,道:“难得活了,这是命呀,这样的人竟有这样的病!这样的人竟有这样的病!”\n【注释】⑴伯牛——孔子学生冉耕字伯牛。⑵亡之——这“之”字不是代词,不是“亡”(死亡之意)的宾语,因为“亡”字在这里不应该有宾语,只是凑成一个音节罢了。古代常有这种形似宾语而实非宾语的“之”字,详拙著《文言语法》。\n6.11子曰:“贤哉,回也!一箪⑴食,一瓢饮,在陋巷,人不堪其忧,回也不改其乐。贤哉,回也!”\n【译文】孔子说:“颜回多么有修养呀,一竹筐饭,一瓜瓢水,住在小巷子里,别人都受不了那穷苦的忧愁,颜回却不改变他自有的快乐。颜回多么有修养呀!”\n【注释】⑴箪——音单,dān,古代盛饭的竹器,圆形。\n6.12冉求曰:“非不说子之道,力不足也。”子曰:“力不足者⑴,中道而废。今女画⑵。”\n【译文】冉求道:“不是我不喜欢您的学说,是我力量不够。”孔子道:“如果真是力量不够,走到半道会再走不动了。现在你却没有开步走。”\n【注释】⑴力不足者——“者”这一表示停顿的语气词,有时兼表假设语气,详《文言语法》。⑵画——停止。\n6.13子谓子夏曰:“女为君子儒!无为小人儒!”\n【译文】孔子对子夏道:“你要去做个君子式的儒者,不要去做那小人式的儒者!”\n6.14子游为武城⑴宰。子曰:“女得人焉耳⑵乎?”曰:“有澹台灭明者⑶,行不由径,非公事,未尝至于偃之室也。”\n【译文】子游做武城县县长。孔子道:“你在这儿得到什么人才没有?”他道:“有一个叫澹台灭明的人,走路不插小道,不是公事,从不到我屋里来。”\n【注释】⑴武城——鲁国的城邑,在今山东费县西南。⑵耳——通行本作“尔”,兹依《唐石经》、《宋石经》、皇侃《义疏》本作“耳”。⑶有澹台灭明者——澹台灭明字子羽,《史记·仲尼弟子列传》也把他列入弟子。但从这里子游的答话语气来看,说这话时还没有向孔子受业。因为“有……者”的提法,是表示这人是听者以前所不知道的。若果如《史记》所记,澹台灭明在此以前便已经是孔子学生,那子游这时的语气应该与此不同。\n6.15子曰:“孟之反⑴不伐,奔而殿,将入门,策其马,曰:‘非敢后也,马不进也。’”\n【译文】孔子说:“孟之反不夸耀自己,[在抵御齐国的战役中,右翼的军队溃退了,]他走在最后,掩护全军,将进城门,便鞭打着马匹,一面说道:‘不是我敢于殿后,是马匹不肯快走的缘故。’”\n【注释】⑴孟之反——《左传》哀公十一年作“孟之侧”,译文参照《左传》所叙述的事实有所增加。\n6.16子曰:“不有⑴祝鮀⑵之佞,而⑶有宋朝⑷之美,难乎免于今之世矣。”\n【译文】孔子说:“假使没有祝鮀的口才,而仅有宋朝的美丽,在今天的社会里怕不易避免祸害了。”\n【注释】⑴不有——这里用以表示假设语气,“假若没有”的意思。⑵祝鮀——卫国的大夫,字子鱼,《左传》定公四年曾记载着他的外交词令。⑶而——王引之《经义述闻》云:“而犹与也,言有祝鮀之佞与有宋朝之美也。”很多人同意这种讲法,但我终嫌“不有祝鮀之佞,与有宋朝之美”为语句不顺,王氏此说恐非原意。⑷宋朝——宋国的公子朝,《左传》昭公二十年和定公十四年都曾记载着他因为美丽而惹起乱子的事情。\n6.17子曰:“谁能出不由户?何莫由斯道也?”\n【译文】孔子说:“谁能够走出屋外不从房门经过?为什么没有人从我这条路行走呢?”\n6.18子曰:“质胜文则野,文胜质则史。文质彬彬⑴,然后君子。”\n【译文】孔子说:“朴实多于文采,就未免粗野;文采多于朴实,又未免虚浮。文采和朴实,配合适当,这才是个君子。”\n【注释】⑴文质彬彬——此处形容人既文雅又朴实,后来多用来指人文雅有礼貌。\n6.19子曰:“人之生也⑴直,罔⑵之生也幸而免。”\n【译文】孔子说:“人的生存由于正直,不正直的人也可以生存,那是他侥幸地免于祸害。”\n【注释】⑴也——语气词,表“人之生”是一词组作主语,这里无妨作一停顿,下文“直”是谓语。⑵罔——诬罔的人,不直的人。\n6.20子曰:“知之者不如好之者,好之者不如乐之者。”\n【译文】孔子说:“[对于任何学问和事业,]懂得它的人不如喜爱它的人,喜爱它的人又不如以它为乐的人。”\n6.21子曰:“中人以上,可以语上也;中人以下,不可以语上也。”\n【译文】孔子说:“中等水平以上的人,可以告诉他高深学问;中等水平以下的人,不可以告诉他高深学问。”\n6.22樊迟问知。子曰:“务民之义,敬鬼神而远之⑴,可谓知矣。”\n问仁。曰:“仁者先难⑵而后获,可谓仁矣。”\n【译文】樊迟问怎么样才算聪明。孔子道:“把心力专一地放在使人民走向‘义’上,严肃地对待鬼神,但并不打算接近他,可以说是聪明了。”\n又问怎么样才叫做有仁德。孔子道:“仁德的人付出一定的力量,然后收获果实,可以说是仁德了。”\n【注释】⑴远之——远作及物动词,去声,yuàn。疏远,不去接近的意思。譬如祈祷、淫祀,在孔子看来都不是“远之”。⑵先难——颜渊篇第十二又有一段答樊迟的话,其中有两句道:“先事后得,非崇德与?”和这里“先难后获可谓仁矣”是一个意思,所以我把“难”字译为“付出一定的力量”。孔子对樊迟两次说这样的话,是不是樊迟有坐享其成的想法,那就不得而知了。\n6.23子曰:“知者乐水,仁者乐山。知者动,仁者静。知者乐,仁者寿。”\n【译文】孔子说:“聪明人乐于水,仁人乐于山。聪明人活动,仁人沉静。聪明人快乐,仁人长寿。”\n6.24子曰:“齐一变,至于鲁;鲁一变,至于道。”\n【译文】孔子说:“齐国[的政治和教育]一有改革,便达到鲁国的样子;鲁国[的政治和教育]一有改革,便进而合于大道了。”\n6.25子曰:“觚⑴不觚,觚哉!觚哉!”\n【译文】孔子说:“觚不像个觚,这是觚吗!这是觚吗!”\n【注释】⑴觚——音孤,gū,古代盛酒的器皿,腹部作四条棱角,足部也作四条棱角。每器容当时容量二升(或曰三升)。孔子为什么说这话,后人有两种较为近于情理的猜想:(甲)觚有棱角,才能叫做觚。可是做出棱角比做圆的难,孔子所见的觚可能只是一个圆形的酒器,而不是上圆下方(有四条棱角)的了。但也名为棱,因之孔子慨叹当日事物名实不符,如“君不君,臣不臣,父不父,子不子”之类。(乙)觚和孤同音,寡少的意思。只能容酒两升(或者三升)的叫觚,是叫人少饮不要沉湎之意。可能当时的觚实际容量已经大大不止此数,由此孔子发出感慨。(古代酿酒,不懂得蒸酒的技术,因之酒精成份很低,而升又小,两三升酒是微不足道的。《史记·滑稽列传》载淳于髡的话,最多能够饮一石,可以想见了。)\n6.26宰我问曰:“仁者,虽告之曰,‘井有仁⑴焉。’其从之也?”子曰:“何为其然也?君子可逝⑵也,不可陷也;可欺⑶也,不可罔⑷也。”\n【译文】宰我问道:“有仁德的人,就是告诉他,‘井里掉下一位仁人啦。’他是不是会跟着下去呢?”孔子道:“为什么你要这样做呢?君子可以叫他远远走开不再回来,却不可以陷害他;可以欺骗他,却不可以愚弄他。”\n【注释】⑴仁——卽“仁人”的意思,和学而篇第一“泛爱众而亲仁”的“仁”用法相同。⑵逝——古代“逝”字的意义和“往”字有所不同,“往”而不复返才用“逝”字。译文卽用此义。俞樾《羣经平议》读“逝”为“折”说:“逝与折古通用。君子杀身成仁则有之矣,故可得而摧折,然不可以非理陷害之,故可折而不可陷。”亦通。⑶欺、罔——《孟子·万章上》有这样一段话,和这一段结合,正好说明“欺”和“罔”的区别。那段的原文是:“昔者有馈生鱼于郑子产,子产使校人畜之池。校人烹之,反命曰:‘始舍之,圉圉焉;少则洋洋焉;攸然而逝。’子产曰:‘得其所哉!得其所哉!’校人出,曰:‘孰谓子产知?予既烹而食之,曰,得其所哉,得其所哉。’故君子可欺以其方,难罔以非其道。”那么,校人的欺骗子产,是“欺以其方”,而宰我的假设便是“罔以非其道”了。\n6.27子曰:“君子博学于文,约之以礼⑴,亦可以弗畔⑵矣夫!”\n【译文】孔子说:“君子广泛地学习文献,再用礼节来加以约束,也就可以不致于离经叛道了。”\n【注释】⑴博学于文,约之以礼——子罕篇第九云:“颜渊喟然叹曰:‘夫子循循然善诱人,博我以文,约我以礼。’”这里的“博学于文,约之以礼”和子罕篇的“博我以文,约我以礼”是不是完全相同呢?如果完全相同,则“约之以礼”的“之”是指代“君子”而言。这是一般人的说法。但毛奇龄的《论语稽求篇》却说:“博约是两事,文礼是两物,然与‘博我以文,约我以礼’不同。何也?彼之博约是以文礼博约回;此之博约是以礼约文,以约约博也。博在文,约文又在礼也。”毛氏认为“约之以礼”的“之”是指代“文”,正是我们平常所说的“由博返约”的意思。⑵畔——同“叛”。\n6.28子见南子⑴,子路不说。夫子矢之曰:“予所⑵否者,天厌之!天厌之!”\n【译文】孔子去和南子相见,子路不高兴。孔子发誓道:“我假若不对的话,天厌弃我罢!天厌弃我罢!”\n【注释】⑴南子——卫灵公夫人,把持着当日卫国的政治,而且有不正当的行为,名声不好。《史记·孔子世家》对“子见南子”的情况有生动的描述。⑵所——如果,假若。假设连词,但只用于誓词中。详阎若璩《四书释地》。\n6.29子曰:“中庸⑴之为德也,其至矣乎!民⑵鲜久矣。”\n【译文】孔子说:“中庸这种道德,该是最高的了,大家已经是长久地缺乏它了。”\n【注释】⑴中庸——这是孔子的最高道德标准。“中”,折中,无过,也无不及,调和;“庸”,平常。孔子拈出这两个字,就表示他的最高道德标准,其实就是折中的和平常的东西。后代的儒家又根据这两个字作了一篇题为“中庸”的文章,西汉人戴圣收入《礼记》,南宋人朱熹又取入《四书》。司马迁说是子思所作,未必可靠。从其文字和内容看,可能是战国至秦的作品,难免不和孔子的“中庸”有相当距离。⑵民——这“民”字不完全指老百姓,因以“大家”译之。\n6.30子贡曰:“如有博施⑴于民而能济众,何如?可谓仁乎?”子曰:“何事于仁!必也圣乎!尧舜⑵其犹病诸!夫⑶仁者,己欲立而立人,己欲达而达人。能近取譬,可谓仁之方也已。”\n【译文】子贡道:“假若有这么一个人,广泛地给人民以好处,又能帮助大家生活得很好,怎么样?可以说是仁道了吗?”孔子道:“哪里仅是仁道!那一定是圣德了!尧舜或者都难以做到哩!仁是甚么呢?自己要站得住,同时也使别人站得住;自己要事事行得通,同时也使别人事事行得通。能够就眼下的事实选择例子一步步去做,可以说是实践仁道的方法了。”\n【注释】⑴施——旧读去声。⑵尧舜——传说中的上古两位帝王,也是孔子心目中的榜样。⑶夫——音扶,fú,文言中的提挈词。\n"},{"id":104,"href":"/zh/docs/culture/%E8%AE%BA%E8%AF%AD/%E8%AE%BA%E8%AF%AD%E8%AF%91%E6%B3%A8_%E6%9D%A8%E4%BC%AF%E5%B3%BB/20%E5%B0%A7%E6%9B%B0%E7%AF%87%E7%AC%AC%E4%BA%8C%E5%8D%81/","title":"20尧曰篇第二十","section":"论语译注 杨伯峻","content":" 尧曰篇第二十 # (共三章)\n20.1尧曰:“咨!尔舜!天之历数在尔躬,允执其中。四海困穷,天禄永终。”\n舜亦以命禹⑴。\n【译文】尧[让位给舜的时候,]说道:“啧啧!你这位舜!上天的大命已经落到你的身上了,诚实地保持着那正确罢!假若天下的百姓都陷于困苦贫穷,上天给你的禄位也会永远地终止了。”\n舜[让位给禹的时候,]也说了这一番话。\n【注释】⑴这一章的文字前后不相连贯,从宋朝苏轼以来便有许多人疑心它有脱落。我只得把它分为若干段落,逐段译注,以便观览。\n曰:“予小子履⑴敢用玄牡,敢昭告于皇皇后帝:有罪不敢赦。帝臣不蔽⑵,简在帝心。朕躬有罪,无以万方;万方有罪,罪在朕躬。”\n【译文】[汤]说:“我履谨用黑色牡牛作牺牲,明明白白地告于光明而伟大的天帝:有罪的人[我]不敢擅自去赦免他。您的臣仆[的善恶]我也不隐瞒掩盖,您心里也是早就晓得的。我本人若有罪,就不要牵连天下万方;天下万方若有罪,都归我一个人来承担。”\n【注释】⑴予小子履——“予小子”和“予一人”都是上古帝王自称之词。从《史记·殷本记》中知道汤名天乙,甲骨卜辞作“大乙”,相传汤又名履。⑵帝臣不蔽——《墨子·兼爱下篇》此句作“有善不敢蔽”,但郑玄注此句云:“言天简阅其善恶也。”译文从郑。《墨子·兼爱下篇》和《吕氏春秋·顺民篇》都说这是成汤战胜夏桀以后,遭逢大旱,向上天祈祷求雨之词。《国语·周语上》引汤誓“余一人有罪,无以万夫”,和这“朕躬有罪,无以万方”义近。\n周有大赉,善人是富。“虽有周亲,不如仁人。百姓有过,在予一人⑴。”\n【译文】周朝大封诸侯,使善人都富贵起来。“我虽然有至亲,却不如有仁德之人。百姓如果有罪过,应该由我来担承。”\n【注释】⑴虽有周亲……一人——刘宝楠《论语正义》引宋翔凤说,“虽有周亲”四句是周武王封诸侯之辞,尤其像封姜太公于齐之辞。\n谨权量,审法度⑴,修废官⑵,四方之政行焉。兴灭国,继绝世,举逸民,天下之民归心焉。\n【译文】检验并审定度量衡,修复已废弃的机关工作,全国的政令就都会通行了。恢复被灭亡的国家,承续已断绝的后代,提拔被遗落的人才,天下的百姓就都会心悦诚服了。\n【注释】⑴谨权量,审法度——权就是量轻重的衡量,量就是容量,度就是长度。“法度”不是法律制度之意。《史记·秦始皇本纪》和秦权、秦量的刻辞中都有“法度”一词,都是指长度的分、寸、尺、丈、引而言。所以“谨权量,审法度”两句只是“齐一度量衡”一个意思。这一说法,清初阎若璩的《四书释地》又续已发其端。⑵废官——赵佑《四书温故録》云:“或有职而无其官,或有官而不举其职,皆曰废。”这以下都是孔子的话。从文章的风格来看,也和尧告舜、成汤求雨、武王封诸侯的文诰体不同。历代注释家多以为是孔子的话,大致可信。但是刘宝楠《正义》引《汉书·律历志》“孔子陈后王之法曰,谨权量,审法度,修废官,举逸民,四方之政行矣”说:“据《志》此文,是‘谨权量’以下皆孔子语,故何休《公羊》昭三十二年注引此节文冠以孔子曰”云云,则不足为证。因为汉人引《论语》,不论是否孔子之言,多称“孔子曰”。《困学纪闻》曾举出《汉书·艺文志》引“小道可观”(19.4),《后汉书·蔡邕传》引“致远恐泥”(同上)皆以子夏之言为孔子,其实不止于此,如后汉章帝长水校尉樊鯈奏言引“博学而笃志”三句(19.6),也以子夏之言为孔子之言,《史记·田叔传》赞曰“孔子称居是国必闻其政”,又以子禽之问(1.10)为孔子之言;刘向《说苑》引“孔子曰,君子务本”,又引“孔子曰,恭近于礼”,则以有子之言为孔子之言。甚至郑玄注《曲礼》、《玉藻》,以及王充着《论衡》,引乡党篇之文,都冠以“孔子曰”。则可见《论语》之书当时似别称“孔子”,如“孟子书”之称孟子者然。翟灏《四书考异》据《尸子·广泽篇》、“墨子贵兼,孔子贵公,皇子贵衷”云云,以为先儒以孔子杂诸子中;又据《论衡·率性篇》云“孔子道德之祖,诸子中最卓者也”谓当时等孔子于诸子,其言不为无据(说本《诂经精舍三集》吴承志〈汉人引孔门诸子言皆称孔子说〉)。若此,则刘氏所举不足为证矣。\n所重:民、食、丧、祭。\n【译文】所重视的:人民、粮食、丧礼、祭祀。\n宽则得众,信则民任焉此五字衍文⑴,敏则有功,公则说。\n【译文】宽厚就会得到羣众的拥护,勤敏就会有功绩,公平就会使百姓高兴。\n【注释】⑴信则民任焉——《汉石经》无此五字,《天文本校勘记》云:“皇本、唐本、津藩本、正平本均无此句。”足见这一句是因阳货篇“信则人任焉”而误增的。阳货篇作“人”,“人”是领导。此处误作“民”。“民”指百姓。有信实,就会被百姓任命,这种思想绝非孔子所能有,尤其可见此句不是原文。\n20.2子张问于孔子曰:“何如斯可以从政矣?”\n子曰:“尊五美,屏⑴四恶,斯可以从政矣。”\n子张曰:“何谓五美?”\n子曰:“君子惠而不费,劳而不怨,欲而不贪⑵,泰而不骄,威而不猛。”\n子张曰:“何谓惠而不费?”\n子曰:“因民之所利而利之,斯不亦惠而不费乎?择可劳而劳之,又谁怨?欲仁而得仁,又焉贪?君子无众寡,无小大,无敢慢,斯不亦泰而不骄乎?君子正其衣冠,尊其瞻视,俨然人望而畏之,斯不亦威而不猛乎?”\n子张曰:“何谓四恶?”\n子曰:“不教而杀谓之虐;不戒视成谓之暴;慢令致期谓之贼;犹之⑶与人也,出纳⑷之吝谓之有司⑸。”\n【译文】子张向孔子问道:“怎样就可以治理政事呢?”\n孔子道:“尊贵五种美德,排除四种恶政,这就可以治理政事了。”\n子张道:“五种美德是些什么?”\n孔子道:“君子给人民以好处,而自己却无所耗费;劳动百姓,百姓却不怨恨;自己欲仁欲义,却不能叫做贪;安泰矜持却不骄傲;威严却不凶猛。”\n子张道:“给人民以好处,自己却无所耗费,这应该怎么办呢?”\n孔子道:“就着人民能得利益之处因而使他们有利,这也不是给人民以好处而自己却无所耗费吗?选择可以劳动的[时间、情况和人民]再去劳动他们,又有谁来怨恨呢?自己需要仁德便得到了仁德,又贪求什么呢?无论人多人少,无论势力大小,君子都不敢怠慢他们,这不也是安泰矜持却不骄傲吗?君子衣冠整齐,目不邪视,庄严地使人望而有所畏惧,这也不是威严却不凶猛吗?”\n子张道:“四种恶政又是些什么呢?”\n孔子道:“不加教育便加杀戮叫做虐;不加申诫便要成绩叫做暴;起先懈怠,突然限期叫做贼;同是给人以财物,出手悭吝,叫做小家子气。”\n【注释】⑴屏——音丙,又去声音并,bíng,屏除。⑵欲而不贪——下文云:“欲仁而得仁,又焉贪?”可见此“欲”字是指欲仁欲义而言,因之皇侃《义疏》云:“欲仁义者为廉,欲财色者为贪。”译文本此。⑶犹之——王引之《释词》云:“犹之与人,均之与人也。”⑷出纳——出和纳(入)是两个意义相反的词,这里虽然在一起连用,却只有“出”的意义,没有“纳”的意义。说本俞樾《羣经平议》。⑸有司——古代管事者之称,职务卑微,这里意译为“小家子气”。\n20.3孔子曰:“不知命,无以为君子也;不知礼,无以立也;不知言⑴,无以知人也。”\n【译文】孔子说:“不懂得命运,没有可能作为君子;不懂得礼,没有可能立足于社会;不懂得分辨人家的言语,没有可能认识人。”\n【注释】⑴知言——这里“知言”的意义和《孟子·公孙丑上》的“我知言”的“知言”相同,善于分析别人的言语,辨其是非善恶的意思。\n"},{"id":105,"href":"/zh/docs/culture/%E8%AE%BA%E8%AF%AD/%E8%AE%BA%E8%AF%AD%E8%AF%91%E6%B3%A8_%E6%9D%A8%E4%BC%AF%E5%B3%BB/17%E9%98%B3%E8%B4%A7%E7%AF%87%E7%AC%AC%E5%8D%81%E4%B8%83/","title":"17阳货篇第十七","section":"论语译注 杨伯峻","content":" 阳货篇第十七 # (共二十六章(《汉石经》同。何晏《集解》把第二、第三两章以及第九、第十两章各并为一章,所以只二十四章。))\n17.1阳货⑴欲见孔子,孔子不见,归孔子豚⑵。\n孔子时其亡也,而往拜之。\n遇诸涂。\n谓孔子曰:“来!予与尔言。”曰⑶:“怀其宝而迷其邦,可谓仁乎?”曰:“不可。——好从事而亟⑷失时,可谓知乎?”曰:“不可。——日月逝矣,岁不我与。”\n孔子曰:“诺;吾将仕矣⑸。”\n【译文】阳货想要孔子来拜会他,孔子不去,他便送孔子一个[蒸熟了的]小猪,[使孔子到他家来道谢。]\n孔子探听他不在家的时候,去拜谢。\n两人在路上碰着了。\n他叫着孔子道:“来,我同你说话。”[孔子走了过去。]他又道:“自己有一身的本领,却听任着国家的事情糊里胡涂,可以叫做仁爱吗?”[孔子没吭声。]他便自己接口道:“不可以;——一个人喜欢做官,却屡屡错过机会,可以叫做聪明吗?”[孔子仍然没吭声。]他又自己接口道:“不可以;——时光一去,就不再回来了呀。”\n孔子这才说道:“好吧;我打算做官了。”\n【注释】⑴阳贷——又叫阳虎,季氏的家臣。季氏几代以来把持鲁国的政治,阳货这时正又把持季氏的权柄。最后因企图削除三桓而未成,逃往晋国。⑵归孔子豚——“归”同“馈”,赠送也。《孟子·滕文公下》对这事有一段说明,他说,当时,“大夫有赐于士,不得受于其家,则往拜其门。”阳货便利用这一礼俗,趁孔子不在家,送一个蒸熟了的小猪去。孔子也就趁阳货不在家才去登门拜谢。⑵曰——自此以下的几个“曰”字,都是阳贷的自为问答。说本毛奇龄《论语稽求篇》引明人郝敬之说。俞樾《古书疑义举例》卷二有“一人之辞而加曰字例”,对这种修辞方式更有详细引证。⑷亟——去声,音气,qì,屡也。⑸吾将仕矣——孔子于阳虎当权之时,并未仕于阳虎。可参《左传》定公八、九年传。\n17.2子曰:“性相近也,习相远也。”\n【译文】孔子说:“人性情本相近,因为习染不同,便相距悬远。”\n17.3子曰:“唯上知与下愚⑴不移。”\n【译文】孔子说:“只有上等的智者和下等的愚人是改变不了的。”\n【注释】⑴上知下愚——关于“上知”下愚”的解释,古今颇有异说。《汉书·古今人表》说:“可与为善,不可与为恶,是谓上智。可与为恶,不可与为善,是谓下愚。”则是以其品质言。孙星衍《问字堂集》说:“上知谓生而知之,下愚谓困而不学。”则是兼以其知识与质量而言。译文仅就字面译出。但孔子说过“生而知之者上也”(16.9),这里的“上知”可能就是“生而知之”的人。当然这种人是不会有的。可是当时的人却以为一定有,甚至孔子都曾否认地说过“我非生而知之者”(7.20)。\n17.4子之武城,闻弦歌之声。夫子莞尔而笑,曰:“割鸡焉用牛刀?”\n子游对曰:“昔者偃也闻诸夫子曰:‘君子学道则爱人,小人学道则易使也。’”\n子曰:“二三子!偃之言是也。前言戏之耳。”\n【译文】孔子到了[子游作县长]的武城,听到了弹琴瑟唱诗歌的声音。孔子微微笑着,说道:“宰鸡,何必用宰牛的刀?[治理这个小地方,用得着教育吗?]”\n子游答道:“以前我听老师说过,做官的学习了,就会有仁爱之心;老百姓学习了,就容易听指挥,听使唤。[教育总是有用的。]”\n孔子便向学生们道:“二三子!言偃的这话是正确的。我刚才那句话不过同他开顽笑吧了。”\n17.5公山弗扰⑴以费畔⑵,召,子欲往。\n子路不说,曰:“末之也,已⑶,何必公山氏之之⑷也?”\n子曰:“夫召我者,而岂徒哉⑸?如有用我者,吾其为东周乎?”\n【译文】公山弗扰盘踞在费邑图谋造反,叫孔子去,孔子准备去。子路很不高兴,说道:“没有地方去便算了,为什么一定要去公山氏那里呢?”\n孔子道:“那个叫我去的人,难道是白白召我吗?假若有人用我,我将使周文王武王之道在东方复兴。”\n【注释】⑴公山弗扰——疑卽《左传》定公五年、八年、十二年及哀公八年之公山不狃(唯陈天祥的《四书辨疑》认为是两人)。不过《论语》所叙之事不见于《左传》,而《左传》定公十二年所叙的公山不狃反叛鲁国的事,不但没有叫孔子去,而且孔子当时正为司寇,命人打败了他。因此赵翼的《陔余丛考》、崔述的《洙泗考信録》都疑心这段文字不可信。但是其后又有一些人,如刘宝楠《论语正义》,则说赵、崔不该信《左传》而疑《论语》。我们于此等处只能存疑。⑵畔——毛奇龄说,“畔是谋逆”,译文取这一义。⑶末之也已——旧作一句读,此依武亿《经读考异》作两句读。“末”,没有地方的意思;“之”,动词,往也;“已”,止也。⑷何必公山氏之之也——“何必之公山氏也”的倒装。“之之”的第一个“之”字只是帮助倒装用的结构助词,第二个“之”字是动词。⑸而岂徒哉——“徒”下省略动宾结构,说完全是“而岂徒召我哉”。\n17.6子张问仁于孔子。孔子曰:“能行五者于天下为仁矣。”\n“请问之。”曰:“恭,宽,信,敏,惠。恭则不侮,宽则得众,信则人任焉,敏则有功,惠则足以使人。”\n【译文】子张向孔子问仁。孔子道:“能够处处实行五种品德,便是仁人了。”\n子张道:“请问哪五种。”孔子道:“庄重,宽厚,诚实,勤敏,慈惠。庄重就不致遭受侮辱,宽厚就会得到大众的拥护,诚实就会得到别人的任用,勤敏就会工作效率高、贡献大,慈惠就能够使唤人。”\n17.7佛肸⑴召,子欲往。\n子路曰:“昔者由也闻诸夫子曰:‘亲于其身为不善者,君子不入也。’佛肸以中牟⑵畔,子之往也,如之何?”\n子曰:“然,有是言也。不曰坚乎,磨而不磷⑶;不曰白乎,湼⑷而不缁。吾岂匏瓜⑸也哉?焉能系而不食?”\n【译文】佛肸叫孔子,孔子打算去。\n子路道:“从前我听老师说过,‘亲自做坏事的人那里,君子不去的。’如今佛肸盘踞中牟谋反,您却要去,怎么说得过去呢?”\n孔子道:“对,我有过这话。但是,你不知道吗?最坚固的东西,磨也磨不薄;最白的东西,染也染不黑。我难道是匏瓜吗?哪里能够只是被悬挂着而不给人吃食呢?”\n【注释】⑴佛肸——晋国赵简子攻打范中行,佛肸是范中行的家臣,为中牟的县长,因此依据中牟来抗拒赵简子。⑵中牟——春秋时晋邑,故址当在今日河北省邢台和邯郸之间,跟河南的中牟了不相涉。⑶磷——音吝,lìn,薄也。⑷湼——niè,本是一种矿物,古人用作黑色染料,这里作动词,染黑之意。⑹匏瓜——卽匏子,古有甘、苦两种,苦的不能吃,但因它比水轻,可以系于腰,用以泅渡。《国语·鲁语》“苦瓠不材,于人共济而已。”《庄子·逍遥游》:“今子有五石之匏,何不虑以为大樽,而浮乎江湖。”皆可以为证。\n17.8子曰:“由也!女闻六言⑴六蔽矣乎?”对曰:“未也。”\n“居!吾语女。好仁不好学⑵,其蔽也愚⑶;好知不好学,其蔽也荡⑷;好信不好学,其蔽也贼⑸;好直不好学,共蔽也绞;好勇不好学,其蔽也乱;好刚不好学,其蔽也狂。”\n【译文】孔子说:“仲由,你听过有六种品德便会有六种弊病吗?”子路答道:“没有。”\n孔子道:“坐下!我告诉你。爱仁德,却不爱学问,那种弊病就是容易被人愚弄;爱耍聪明,却不爱学问,那种弊病就是放荡而无基础;爱诚实,却不爱学问,那种弊病就是[容易被人利用,反而]害了自己;爱直率,却不爱学问,那种弊病就是说话尖刻,刺痛人心;爱勇敢,却不爱学问,那种弊病就是捣乱闯祸;爱刚强,却不爱学问,那种弊病就是胆大妄为。”\n【注释】⑴言——这个“言”字和“有一言而可以终身行之”(15.24)的“言”相同,名曰“言”,实是指“德”。“一言”,孔子拈出“恕”字;“六言”,孔子拈出“仁”、“知”、“信”、“直”、“勇”、“刚”六宇。后代“五言诗”、“七言诗”以一字为“言”之义盖本于此。⑵不好学——不学则不能明其理。⑶愚——朱熹《集注》云:“愚若可陷可罔之类。”译文取之。⑷荡——孔安国云:“荡,无所适守也。”译文取之。⑹贼——管同《四书纪闻》云:“大人之所以不必信者,惟其为学而知义之所在也。苟好信不好学,则惟知重然诺而不明事理之是非,谨厚者则硁硁为小人;苟又挟以刚勇之气,必如周汉刺客游侠,轻身殉人,扞文网而犯公义,自圣贤观之,非贼而何?”这是根据春秋侠勇之士的事实,又根据儒家明哲保身的理论所发的议论,似乎近于孔子本意。\n17.9子曰:“小子何莫学夫诗?诗,可以兴,可以观,可以羣,可以怨。迩之事父,远之事君;多识于鸟兽草木之名。”\n【译文】孔子说:“学生们为什么没有人研究诗?读诗,可以培养联想力,可以提高观察力,可以锻炼合羣性,可以学得讽刺方法。近呢,可以运用其中道理来事奉父母;远呢,可以用来服事君上;而且多多认识鸟兽草木的名称。”\n17.10子谓伯鱼曰:“女为《周南》、《召南》⑴矣乎?人而不为《周南》、《召南》,其犹正墙面而立⑵也与?”\n【译文】孔子对伯鱼说道:“你研究过《周南》和《召南》了吗?人假若不研究《周南》和《召南》,那会像面正对着墙壁而站着罢!”\n【注释】⑴《周南》、《召南》——现存《诗经·国风》中。但沈括《梦溪笔谈》卷三说:“《周南》、《召南》,乐名也。……有乐有舞焉,学者之事。……所谓为《周南》、《召南》者,不独诵其诗而已。”⑵正墙面而立——朱熹云:“言卽其至近之地,而一物无所见,一步不可行。”\n17.11子曰:“礼云礼云,玉帛云乎哉?乐云乐云,钟鼓云乎哉?”\n【译文】孔子说:“礼呀礼呀,仅是指玉帛等等礼物而说的吗?乐呀乐呀,仅是指钟鼓等等乐器而说的吗?”\n17.12子曰:“色厉而内荏,譬诸小人,其犹穿窬之盗也与?”\n【译文】孔子说:“颜色严厉,内心怯弱,若用坏人作比喻,怕像个挖洞跳墙的小偷罢!”\n17.13子曰:“乡愿⑴,德之贼也。”\n【译文】孔子说:“没有真是非的好好先生是足以败坏道德的小人。”\n【注释】⑴乡愿——愿音愿,yuàn,孟子作“原”。《孟子·尽心下》对“乡愿”有一段最具体的解释:“何以是嘐嘐也?言不顾行,行不顾言,则曰:‘古之人,古之人,行何为踽踽凉凉?生斯世也,为斯世也,善斯可矣。’阉然媚于世也者,是乡原也。”又说;“非之无举也,刺之无刺也。同乎流俗,合乎污世。居之似忠信,行之似廉洁。众皆悦之,自以为是,而不可与入尧舜之道。故曰‘德之贼’也。”\n17.14子曰:“道听而涂说,德之弃也。”\n【译文】孔子说:“听到道路传言就四处传播,这是应该革除的作风。”\n17.15子曰:“鄙夫可与⑴事君也与哉?其未得之也,患得之当作患不得之⑵。既得之,患失之。苟患失之,无所不至矣。”\n【译文】孔子说:“鄙夫,难道能同他共事吗?当他没有得到职位的时候,生怕得不着;已经得着了,又怕失去。假若生怕失去,会无所不用其极了。”\n【注释】⑴可与——王引之《释词》谓卽“可以”,今不取。⑵患得之——王符《潜夫论·爱日篇》云:“孔子疾夫未之得也,患不得之,既得之,患失之者。”可见东汉人所据的本子有“不”字。《荀子·子道篇》说:“孔子曰,……小人者,其未得也,则忧不得;既已得之,又恐失之。”(《说苑·杂言篇》同)此虽是述意,“得”上也有“不”字。宋人沈作喆寓简云:“东坡解云,‘患得之’当作‘患不得之’”,可见宋人所见的本子已脱此“不”字。\n17.16子曰:“古者民有三疾,今也或是之亡也。古之狂也肆,今之狂也荡;古之矜也廉⑴,今之矜也忿戾;古之愚也直,今之愚也诈而已矣。”\n【译文】孔子说:“古代的人民还有三种[可贵的]毛病,现在呢,或许都没有了。古代的狂人肆意直言,现在的狂人便放荡无羁了;古代自己矜持的人还有些不能触犯的地方,现在自己矜持的人却只是一味老羞成怒,无理取闹罢了;古代的愚人还直率,现在的愚人却只是欺诈耍手段罢了。”\n【注释】⑴廉——“廉隅”的“廉”,本义是器物的棱角,人的行为方正有威也叫“廉”。\n17.17子曰:“巧言令色,鲜矣仁⑴。”\n【注释】⑴见学而篇(1.3)。\n17.18子曰:“恶紫之夺朱⑴也,恶郑声之乱雅乐也,恶利口之覆邦家者。”\n【译文】孔子说:“紫色夺去了大红色的光彩和地位,可憎恶;郑国的乐曲破坏了典雅的乐曲,可憎恶;强嘴利舌颠覆国家,可憎恶。”\n【注释】⑴紫之夺朱——春秋时候,鲁桓公和齐桓公都喜欢穿紫色衣服。从《左传》哀公十七年卫浑良夫“紫衣狐裘”而被罪的事情看来,那时的紫色可能已代替了朱色而变为诸侯衣服的正色了。\n17.19子曰:“予欲无言。”子贡曰:“子如不言,则小子何述焉?”子曰:“天何言哉?四时行焉,百物生焉,天何言哉?”\n【译文】孔子说:“我想不说话了。”子贡道:“您假若不说话,那我们传述什么呢?”孔子道:“天说了什么呢?四季照样运行,百物照样生长,天说了什么呢?”\n17.20孺悲⑴欲见孔子,孔子辞以疾⑵。将命者出户,取瑟而歌,使之闻之。\n【译文】孺悲来,要会晤孔子,孔子托言有病,拒绝接待。传命的人刚出房门,孔子便把瑟拿下来弹,并且唱着歌,故意使孺悲听到。\n【注释】⑴孺悲——鲁国人。《礼记·杂记》云:“恤由之丧,哀公使孺悲之孔子学士丧礼,《士丧礼》于是乎书。”⑵辞以疾——《孟子·告子下》说:“教亦多术矣。予不屑之教诲也者,是亦教诲之而已矣。”孔子故意不接见孺悲,并且使他知道,是不是也是如此的呢?\n17.21宰我问:“三年之丧,期已久矣。君子三年不为礼,礼必坏;三年不为乐,乐必崩。旧谷既没,新谷既升,钻燧改火⑴,期⑵可已矣。”\n子曰:“食夫稻⑶,衣夫锦,于女安乎?”\n曰:“安。”\n“女安,则为之!。夫君子之居丧,食旨不甘,闻乐不乐,居处不安⑷,故不为也。今女安,则为之!”\n宰我出,子曰:“予之不仁也!子生三年,然后免于父母之怀。夫三年之丧,天下之通丧也,予也有三年之爱于其父母乎!”\n【译文】宰我间道:“父母死了,守孝三年,为期也太久了。君子有三年不去习礼仪,礼仪一定会废弃掉;三年不去奏音乐,音乐一定会失传。陈谷既已吃完了,新谷又已登场;打火用的燧木又经过了一个轮回,一年也就可以了。”\n孔子道:“[父母死了,不到三年,]你便吃那个白米饭,穿那个花缎衣,你心里安不安呢?”\n宰我道:“安。”\n孔子便抢着道:“你安,你就去干吧,君子的守孝,吃美味不晓得甜,听音乐不觉得快乐,住在家里不以为舒适,才不这样干。如今你既然觉得心安,便去干好了。”\n宰我退了出来。孔子道:“宰予真不仁呀,儿女生下地来,三年以后才能完全脱离父母的怀抱。替父母守孝三年,天下都是如此的。宰予难道就没有从他父母那里得着三年怀抱的爱护吗?”\n【注释】钻燧改火——古代用的是钻木取火的方法,被钻的木,四季不同,所谓“春取榆柳之火,夏取枣杏之火,季夏取桑柘之火,秋取柞楢之火,冬取槐檀之火”(马融引《周书·月令篇》文),一年一轮回。⑵期——同朞,音基。jī,一年。⑶稻——古代北方以稷(小米)为主要粮食,水稻和粱(精细的小米)是珍品,而稻的耕种面积更小,所以这里特别提出它来和“锦”为对文。⑷居处不安——古代孝子要“居倚庐,寝苫枕块”,就是住临时用草料木料搭成的凶庐,睡在用草编成的藁垫上,用土块做枕头。这里的“居处”是指平日的居住生活而言。\n17.22子曰:“饱食终日,无所用心,难矣哉!不有博⑴弈者乎?为之,犹贤乎已⑵。”\n【译文】孔子说:“整天吃饱了饭,什么事也不做,不行的呀!不是有掷采下弈的游戏吗?干干也比闲着好。”\n【注释】⑴博——古代的一种棊局。焦循的《孟子正义》说:“盖弈但行棊,博以掷采(骰子)而后行棊。”又说:“后人不行棊而专掷采,遂称掷采为博(赌博),博与弈益远矣。”⑵犹贤乎已——句法与意义和《墨子·法仪篇》的“犹逾(同愈)已”,《孟子·尽心上》的“犹愈于已”全同。“已”是不动作的意思。\n17.23子路曰:“君子尚⑴勇乎?”子曰:“君子义以为上⑴,君子有勇而无义为乱,小人有勇而无义为盗。”\n【译文】子路问道:“君子尊贵勇敢不?”孔子道:“君子认为义是最可尊贵的,君子只有勇,没有义,就会捣乱造反;小人只有勇,没有义,就会做土匪强盗。”\n【注释】⑴尚,上——“尚勇”的“尚”和“上”相同。不过用作动词。\n17.24子贡曰:“君子亦有恶乎?”子曰:“有恶:恶称人之恶者,恶居下流流字衍文⑴而讪上者,恶勇而无礼者,恶果敢而窒者。”\n曰:“赐也亦有恶乎?”“恶徼以为知者,恶不孙以为勇者,恶讦以为直者。”\n【译文】子贡道:“君子也有憎恨的事吗?”孔子道:“有憎恨的事:憎恨一味传播别人的坏处的人,憎恨在下位而毁谤上级的人,憎恨勇敢却不懂礼节的人,憎恨勇于贯彻自己的主张,却顽固不通、执抝到底的人。”\n孔子又道:“赐,你也有憎恶的事吗?”子贡随卽答道:“我憎恨偷袭别人的成绩却作为自己的聪明的人,憎恨毫不谦虚却自以为勇敢的人,憎恨揭发别人阴私却自以为直率的人。”\n【注释】⑴下流——根据惠栋的《九经古义》和冯登府的《论语异文考证》,证明了晚唐以前的本子没有这个“流”字。案文义,这个“流”字也是不应该有的。但苏轼〈上韩太尉书〉引此文时已有“流”字,可见北宋时已经误衍。\n17.25子曰:“唯女子与小人为难养也,近之则不孙,远之则怨。”\n【译文】孔子道:“只有女子和小人是难得同他们共处的,亲近了,他会无礼;疏远了,他会怨恨。”\n17.26子曰:“年四十而见恶焉,其终也已⑴。”\n【译文】孔子说:“到了四十岁还被厌恶,他这一生也就完了。”\n【注释】⑴其终也已——“已”是动词,和“末之也已”(17.4)“斯害也已”(2.16)的“已”字相同,句法更和“斯害也已”一致。“其终也”“斯害也”为主语;“已”为动词,谓语。如在“其终也”下作一停顿,文意便显豁了。\n"},{"id":106,"href":"/zh/docs/culture/%E8%AE%BA%E8%AF%AD/%E8%AE%BA%E8%AF%AD%E8%AF%91%E6%B3%A8_%E6%9D%A8%E4%BC%AF%E5%B3%BB/12%E9%A2%9C%E6%B8%8A%E7%AF%87%E7%AC%AC%E5%8D%81%E4%BA%8C/","title":"12颜渊篇第十二","section":"论语译注 杨伯峻","content":" 颜渊篇第十二 # (共二十四章)\n12.1颜渊问仁。子曰:“克己复礼为仁⑴。一日克己复礼,天下归仁⑵焉。为仁由己,而由人乎哉?”\n颜渊曰:“请问其目。”子曰:“非礼勿视,非礼勿听,非礼勿言,非礼勿动。”\n颜渊曰:“回虽不敏,请事斯语矣。”\n【译文】颜渊问仁德。孔子道:“抑制自己,使言语行动都合于礼,就是仁。一旦这样做到了,天下的人都会称许你是仁人。实践仁德,全凭自己,还凭别人吗?”\n颜渊道:“请问行动的纲领。”孔子道:“不合礼的事不看,不合礼的话不听,不合礼的话不说,不合礼的事不做。”\n颜渊道:“我虽然迟钝,也要实行您这话。”\n【注释】⑴克己复礼——《左传》昭公十二年说:“仲尼曰:‘古也有志:克己复礼,仁也。’”那么,“克己复礼为仁”是孔子用前人的话赋予新的含义。⑵归仁——“称仁”的意思,说见毛奇龄《论语稽求篇》。朱熹《集注》谓“归,犹与也”,也是此意。\n12.2仲弓问仁。子曰:“出门如见大宾,使民如承大祭。己所不欲,勿施于人。在邦无怨,在家⑴无怨。”\n仲弓曰:“雍虽不敏,请事斯语矣。”\n【译文】仲弓问仁德。孔子道:“出门[工作]好像去接待贵宾,役使百姓好像去承当大祀典,[都得严肃认真,小心谨慎。]自己所不喜欢的事物,就不强加于别人。在工作岗位上不对工作有怨恨,就是不在工作岗位上也没有怨恨。”\n仲弓道:“我虽然迟钝,也要实行您这话。”\n【注释】⑴在家——刘宝楠《论语正义》说:“在邦谓仕于诸侯之邦,在家谓仕于卿大夫之家也。”把“家”字拘泥于“大夫曰家”的一个意义,不妥当。\n12.3司马牛⑴问仁。子曰:“仁者,其言也讱。”\n曰:“其言也讱,斯谓之仁已乎?”子曰:“为之难,言之得无讱乎?”\n【译文】司马牛问仁德。孔子道:“仁人,他的言语迟钝。”\n司马牛道:“言语迟钝,造就叫做仁了吗?”孔子道:“做起来不容易,说话能够不迟钝吗?”\n【注释】⑴司马牛——《史记·仲尼弟子列传》云:“司马耕,字子牛。牛多言而躁,问仁于孔子。孔子曰:‘仁者其言也讱。’”根据司马迁的这一说法,孔子的答语是针对问者“多言而躁”的缺点而说的。\n12.4司马牛问君子。子曰:“君子不忧不惧。”\n曰:“不忧不惧,斯谓之君子已乎?”子曰:“内省不疚,夫何忧何惧?”\n【译文】司马牛问怎样去做一个君子。孔子道:“君子不忧愁,不恐惧。”司马牛道:“不忧愁,不恐惧,这样就可以叫做君子了吗?”孔子道:“自己问心无愧,那有什么可以忧愁和恐惧的呢?”\n12.5司马牛忧曰:“人皆有兄弟,我独亡⑴。”子夏曰:“商闻之矣:死生有命,富贵在天。君子敬而无失,与人恭而有礼。四海之内,皆兄弟也——君子何患乎无兄弟也?”\n【译文】司马牛忧愁地说道:“别人都有好兄弟,单单我没有。”子夏道:“我听说过:死生听之命运,富贵由天安排。君子只是对待工作严肃认真,不出差错,对待别人词色恭谨,合乎礼节,天下之大,到处都是好兄弟——君子又何必着急没有好兄弟呢?”\n【注释】⑴人皆有兄弟,我独亡——自来的注释家都说这个司马牛就是宋国桓魋的兄弟。桓魋为人很坏,结果是谋反失败,他的几个兄弟也都跟着失败了。其中只有司马牛不赞同他这些兄弟的行为。但结果也是逃亡在外,死于道路(事见《左传》哀公十四年)。译文姑且根据这种说法。但我却认为,孔子的学生司马牛和宋国桓魋的弟弟司马牛可能是两个不同的人,难于混为一谈。第一,《史记·仲尼弟子列传》既不说这一个司马牛是宋人,更没有把《左传》上司马牛的事情记载上去,太史公如果看到了这类史料而不采取,可见他是把两个司马牛作不同的人看待的。第二,说《论语》的司马牛就是《左传》的司马牛者始于孔安国。孔安国又说司马牛名犂,又和《史记·仲尼弟子列传》说司马牛名耕的不同。如果孔安国之言有所本,那么,原本就有两个司马牛,一个名耕,孔子弟子;一个名犂,桓魋之弟。但自孔安国以后的若干人却误把名犂的也当作孔子学生了。姑识于此,以供参考。\n12.6子张问明。子曰:“浸润之谮,肤受之愬,不行焉,可谓明也已矣。浸润之谮,肤受之愬,不行焉,可谓远也已矣。”\n【译文】子张问怎样才叫做见事明白。孔子道:“点滴而来,日积月累的谗言和肌肤所受、急迫切身的诬告都在你这里行不通,那你可以说是看得明白的了。点滴而来,日积月累的谗言和肌肤所受、急迫切身的诬告也都在你这里行不通,那你可以说是看得远的了。”\n12.7子贡问政。子曰:“足食,足兵⑴,民信之矣。”\n子贡曰:“必不得已而去,于斯三者何先?”曰:“去兵。”\n子贡曰:“必不得已而去,于斯二者何先?”曰:“去食。自古皆有死,民无信不立。”\n【译文】子贡问怎样去治理政事。孔子道:“充足粮食,充足军备,百姓对政府就有信心了。”\n子贡道:“如果迫于不得已,在粮食、军备和人民的信心三者之中一定要去掉一项,先去掉哪一项?”孔子道:“去掉军备。”\n子贡道:“如果迫于不得已,在粮食和人民的信心两者之中一定要去掉一项,先去掉哪一项?”孔子道:“去掉粮食。[没有粮食,不过死亡,但]自古以来谁都免不了死亡。如果人民对政府缺乏信心,国家是站不起来的。”\n【注释】⑴兵——在五经和《论语》、《孟子》中,“兵”字多指兵器而言,但也偶有解作兵士的。如《左传》隐公四年“诸侯之师败郑徒兵”,襄公元年“败其徒兵于洧上”。顾炎武、阎若璩都以为五经中的“兵”字无作士兵解者,恐未谛(刘宝楠说)。但此“兵”字仍以解为军器为宜,故以军备译之。\n12.8棘子成⑴曰:“君子质而已矣,何以文为?”子贡曰:“惜乎,夫子之说君子也⑵!驷不及舌。文犹质也,质犹文也。虎豹之鞟犹犬羊之鞟。”\n【译文】棘子成道:“君子只要有好的本质便够了,要那些文彩[那些仪节、那些形式]干什么?”子贡道:“先生这样地谈论君子,可惜说错了。一言既出,驷马难追。本质和文彩,是同等重要的。假若把虎豹和犬羊两类兽皮拔去有文彩的毛,那这两类皮革就很少区别了。”\n【注释】⑴棘子成——卫国大夫。古代大夫都可以被尊称为“夫子”,所以子贡这样称呼他。⑵惜乎夫子之说君子也——朱熹《集注》把它作两句读:“惜乎!夫子之说,君子也。”便应该这样翻译:“先生的话,是出自君子之口,可惜说错了。”我则以为“夫子之说君子也”为主语,“惜乎”为谓语,此为倒装句。\n12.9哀公问于有若曰:“年饥,用不足,如之何?”\n有若对曰:“盍彻乎?”\n曰:“二,吾犹不足,如之何其彻也?”\n对曰:“百姓足,君孰与不足?百姓不足,君孰与足?”\n【译文】鲁哀公向有若问道:“年成不好,国家用度不够,应该怎么办?”\n有若答道:“为什么不实行十分抽一的税率呢?”\n哀公道:“十分抽二,我还不够,怎么能十分抽一呢?”\n答道:“如果百姓的用度够,您怎么会不够?如果百姓的用度不够,您又怎么会够?”\n12.10子张问崇德辨惑。子曰:“主忠信,徙义,崇德也。爱之欲其生,恶之欲其死。既欲其生,又欲其死,是惑也。‘诚不以富,亦祗以异⑴。’”\n【译文】子张问如何去提高品德,辨别迷惑。孔子道:“以忠诚信实为主,唯义是从,这就可以提高品德。爱一个人,希望他长寿;厌恶起来,恨不得他马上死去。既要他长寿,又要他短命,这便是迷惑。这样,的确对自己毫无好处,只是使人奇怪罢了。”\n【注释】⑴诚不以富,亦祗以异——《诗经·小雅·我行其野篇》诗句,引在这里,很难解释。程颐说是“错简”(别章的文句,因为书页次序错了,误在此处),但无证据。我这里姑且依朱熹《集注》的解释而意译之。\n12.11齐景公问政于孔子。孔子对曰:“君君,臣臣,父父,子子。”公曰:“善哉!信如君不君,臣不臣,父不父,子不子,虽有粟,吾得而食诸?”\n【译文】齐景公向孔子问政治。孔子答道:“君要像个君,臣要像个臣,父亲要像父亲,儿子要像儿子。”景公道:“对呀!若是君不像君,臣不像臣,父不像父,子不像子,卽使粮食很多,我能吃得着吗?”\n12.12子曰:“片言可以折狱⑴者,其由也与?”\n子路无宿诺⑵。\n【译文】孔子说:“根据一方面的语言就可以判决案件的,大概只有仲由吧!”\n子路从不拖延诺言。\n【注释】⑴片言折狱——“片言”古人也叫做“单辞”。打官司一定有原告和被告两方面的人,叫做两造。自古迄今从没有只根据一造的言辞来判决案件的(除掉被告缺席裁判)。孔子说子路“片言可以折狱”,不过表示他的为人诚实直率,别人不愿欺他罢了。⑵子路无宿诺——这句话与上文有什么逻辑关系,从来没有人说得明白(焦循《论语补疏》的解释也不可信)。唐陆德明《经典释文》云:“或分此为别章。”\n12.13子曰:“听讼⑴,吾犹人也。必也使无讼乎!”\n【译文】孔子说:“审理诉讼,我同别人差不多。一定要使诉讼的事件完全消灭才好。”\n【注释】⑴听讼——据《史记·孔子世家》,孔子在鲁定公时,曾为大司寇,司寇为治理刑事的官,孔子这话或许是刚作司寇时所说。\n12.14子张问政。子曰:“居之无倦,行之以忠。”\n【译文】子张问政治。孔子道:“在位不要疲倦懈怠,执行政令要忠心。”\n12.15子曰:“博学于文,约之以礼,亦可以弗畔矣夫!”\n【注释】⑴见雍也篇第六。\n12.16子曰:“君子成人之美,不成人之恶。小人反是。”\n【译文】孔子说:“君子成全别人的好事,不促成别人的坏事。小人却和这相反。”\n12.17季康子问政于孔子。孔子对曰:”政者,正也。子帅以正,孰敢不正?”\n【译文】季康子向孔子问政治。孔子答道:“政字的意思就是端正。您自己带头端正,谁敢不端正呢?”\n12.18季康子患盗,问于孔子。孔子对曰:“苟子之不欲,虽赏之不窃。”\n【译文】季康子苦于盗贼太多,向孔子求教。孔子答道:“假若您不贪求太多的财货,就是奖励偷抢,他们也不会干。”\n12.19季康子⑴问政于孔子曰:“如杀无道,以就有道何如?”孔子对曰:“子为政,焉用杀?子欲善而民善矣。君子之德风,小人之德草。草上之风,必偃。”\n【译文】季康子向孔子请教政治,说道:“假若杀掉坏人来亲近好人,怎么样?”孔子答道:“您治理政治,为什么要杀戮?您想把国家搞好,百姓就会好起来。领导人的作风好比风,老百姓的作风好比草,风向哪边吹,草向哪边倒。”\n【注释】⑴季康子——根据《春秋》以及《左传》,季孙斯(桓子)死于哀公三年秋七月,季孙肥(康子)随卽袭位。则以上三章季康子之问,当在鲁哀公三年七月以后。\n12.20子张问:“士何如斯可谓之达矣?”子曰:“何哉,尔所谓达者?”子张对曰:“在邦必闻,在家必闻。”子曰:“是闻也,非达也。夫达也者,质直而好义,察言而观色,虑以下人。在邦必达,在家必达。夫闻也者,色取仁而行违,居之不疑。在邦必闻,在家必闻。”\n【译文】子张问:“读书人要怎样做才可以叫达?”孔子道:“你所说的达是什么意思?”子张答道:“做国家的官时一定有名望,在大夫家工作时一定有名望。”孔子道:“这个叫闻,不叫达。怎样才是达呢?质量正直,遇事讲理,善于分析别人的言语,观察别人的颜色,从思想上愿意对别人退让。这种人,做国家的官时固然事事行得通,在大夫家一定事事行得通。至于闻,表面上似乎爱好仁德,实际行为却不如此,可是自己竟以仁人自居而不加疑惑。这种人,做官的时候一定会骗取名望,居家的时候也一定会骗取名望。”\n12.21樊迟从游于舞雩之下,曰:“敢问崇德,修慝,辨惑。”子曰:“善哉问!先事后得,非崇德与?攻其恶,无攻人之恶,非修慝与?一朝之忿,忘其身,以及其亲,非惑与?”\n【译文】樊迟陪侍孔子在舞雩台下游逛,说道:“请问怎样提高自己的品德,怎样消除别人对自己不露面的怨恨,怎样辨别出哪种是胡涂事。”孔子道:“问得好!首先付出劳动,然后收获,不是提高品德了吗?批判自己的坏处,不去批判别人的坏处,不就消除无形的怨恨了吗?因为偶然的忿怒,便忘记自己,甚至也忘记了爹娘,不是胡涂吗?”\n12.22樊迟问仁。子曰:“爱人。”问知。子曰:“知人。”\n樊迟未达。子曰:“举直错诸枉,能使枉者直。”\n樊迟退,见子夏曰:“乡⑴也吾见于夫子而问知,子曰,‘举直错诸枉,能使枉者直’,何谓也?”\n子夏曰:“富哉言乎!舜有天下,选于众,举皋陶⑵,不仁者远⑶矣。汤⑷有天下,选于众,举伊尹⑸,不仁者远矣⑹。”\n【译文】樊迟问仁。孔子道:“爱人。”又问智。孔子道:“善于鉴别人物。”\n樊迟还不透澈了解。孔子道:“把正直人提拔出来,位置在邪恶人之上,能够使邪恶人正直。”\n樊迟退了出来,找着子夏,说道:“刚纔我去见老师向他问智,他说,‘把正直人提拔出来,位置在邪恶人之上’,这是什么意思?”\n子夏道:“意义多么丰富的话呀!舜有了天下,在众人之中挑选,把皋陶提拔出来,坏人就难以存在了。汤有了天下,在众人之中挑选,把伊尹提拔出来,坏人也就难以存在了。”\n【注释】⑴乡——去声,同“向”。⑵皋陶——音高摇,gāoyáo,舜的臣子。⑶远——本是“离开”“逋逃”之意,但人是可以转变的,何必非逃离不可。译文用“难以存在”来表达,比之拘泥字面或者还符合子夏的本意些。⑷汤——卜辞作“唐”,罗振玉云:“唐殆太乙之谥。”(《增订殷虚书契考释》)商朝开国之君,名履(卜辞作“大乙”,而无“履”字),伐夏桀而得天下。⑸伊尹——汤的辅相。⑹“举直”而“使枉者直”,属于“仁”;知道谁是直人而举他,属于“智”,所以“举直错诸枉”是仁智之事,而孔子屡言之(参2.19)。\n12.23子贡问友。子曰:“忠告⑴而善道之,不可则止,毋自辱焉。”\n【译文】子贡问对待朋友的方法。孔子道:“忠心地劝告他,好好地引导他,他不听从,也就罢了,不要自找侮辱。”\n【注释】⑴告——旧读梏,gù。\n12.24曾子曰:“君子以文会友,以友辅仁。”\n【译文】曾子说:“君子用文章学问来聚会朋友,用朋友来帮助我培养仁德。”\n"},{"id":107,"href":"/zh/docs/culture/%E8%AE%BA%E8%AF%AD/%E8%AE%BA%E8%AF%AD%E8%AF%91%E6%B3%A8_%E6%9D%A8%E4%BC%AF%E5%B3%BB/01%E5%AD%A6%E8%80%8C%E7%AF%87%E7%AC%AC%E4%B8%80/","title":"01学而篇第一","section":"论语译注 杨伯峻","content":" 学而篇第一 # (共十六章)\n1.1子⑴曰:“学而时⑵习⑶之,不亦说⑷乎?有朋⑸自远方来,不亦乐乎?人不知⑹,而不愠⑺,不亦君子⑻乎?”\n【译文】孔子说:“学了,然后按一定的时间去实习它,不也高兴吗?有志同道合的人从远处来,不也快乐吗?人家不了解我,我却不怨恨,不也是君子吗?”\n【注释】⑴子——《论语》“子曰”的“子”都是指孔子而言。⑵时——“时”字在周秦时候若作副词用,等于《孟子·梁惠王上》“斧斤以时入山林”的“以时”,“在一定的时候”或者“在适当的时候”的意思。王肃的《论语注》正是这样解释的。朱熹的《论语集注》把它解为“时常”,是用后代的词义解释古书。⑶习——一般人把习解为“温习”,但在古书中,它还有“实习”、“演习”的意义,如《礼记·射义》的“习礼乐”、“习射”。《史记·孔子世家》:“孔子去曹适宋,与弟子习礼大树下。”这一“习”字,更是演习的意思。孔子所讲的功课,一般都和当时的社会生活和政治生活密切结合。像礼(包括各种仪节)、乐(音乐)、射(射箭)、御(驾车)这些,尤其非演习、实习不可。所以这“习”字以讲为实习为好。⑷说——音读和意义跟“悦”字相同,高兴、愉快的意思。⑸有朋——古本有作“友朋”的。旧注说:“同门曰朋。”宋翔凤《朴学斋札记》说,这里的“朋”字卽指“弟子”,就是《史记·孔子世家》的“故孔子不仕,退而修诗、书、礼乐,弟子弥众,至自远方”译文用“志同道合之人”卽本此义。⑹人不知——这一句,“知”下没有宾语,人家不知道什么呢?当时因为有说话的实际环境,不需要说出便可以了解,所以未给说出。这却给后人留下一个谜。有人说,这一句是接上一句说的,从远方来的朋友向我求教,我告诉他,他还不懂,我却不怨恨。这样,“人不知”是“人家不知道我所讲述的”了。这种说法我嫌牵强,所以仍照一般的解释。这一句和宪问篇的“君子病无能焉,不病人之不己知也”的精神相同。⑺愠——yùn,怨恨。⑻君子——《论语》的“君子”,有时指“有德者”,有时指“有位者”,这里是指“有德者”。\n1.2有子⑴曰:“其为人也孝弟⑵,而好犯⑶上者,鲜⑷矣;不好犯上,而好作乱者,未之有也⑸。君子务本,本立而道生。孝弟也者,其为仁之本⑹与⑺!”\n【译文】有子说:“他的为人,孝顺爹娘,敬爱兄长,却喜欢触犯上级,这种人是很少的;不喜欢触犯上级,却喜欢造反,这种人从来没有过。君子专心致力于基础工作,基础树立了,‘道’就会产生。孝顺爹娘,敬爱兄长,这就是‘仁’的基础吧!”\n【注释】⑴有子——孔子学生,姓有,名若,比孔子小十三岁,一说小三十三岁,以小三十三岁之说较可信。《论语》记载孔子的学生一般称字,独曾参和有若称“子”(另外,冉有和闵子骞偶一称子,又当别论),因此很多人疑心《论语》就是由他们两人的学生所纂述的。但是有若称子,可能是由于他在孔子死后曾一度为孔门弟子所尊重的缘故(这一史实可参阅《礼记·檀弓上》、《孟子·滕文公上》和《史记·仲尼弟子列传》)。至于《左传》哀公八年说有若是一个“国士”,还未必是足以使他被尊称为“子”的原因。⑵孝弟——孝,奴隶社会时期所认为的子女对待父母的正确态度;弟,音读和意义跟“悌”相同,音替,tì,弟弟对待兄长的正确态度。封建时代也把“孝弟”作为维持它那时候的社会制度、社会秩序的一种基本道德力量。⑶犯——抵触,违反,冒犯。⑷鲜——音显,xiǎn,少。《论语》的“鲜”都是如此用法。⑸未之有也——“未有之也”的倒装形式。古代句法有一条这样的规律:否定句,宾语若是指代词,这指代词的宾语一般放在动词前。⑹孝弟为仁之本——“仁”是孔子的一种最高道德的名称。也有人说(宋人陈善的《扪虱新语》开始如此说,后人赞同者很多),这“仁”字就是“人”字,古书“仁”“人”两字本有很多写混了的。这里是说“孝悌是做人的根本”。这一说虽然也讲得通,但不能和“本立而道生”一句相呼应,未必符合有子的原意。《管子·戒篇》说,“孝弟者,仁之祖也”,也是这意。⑺与——音读和意义跟“欤”字一样,《论语》的“欤”字都写作“与”。\n1.3子曰:“巧言令色⑴,鲜矣仁!”\n【译文】孔子说:“花言巧语,伪善的面貌,这种人,‘仁德’是不会多的。”\n【注释】⑴巧言令色——朱注云:“好其言,善其色,致饰于外,务以说人。”所以译文以“花言巧语”译巧言,“伪善的面貌”译令色。\n1.4曾子⑴曰:“吾日三省⑵吾身——为人谋而不忠乎?与朋友交而不信⑶乎?传⑷不习⑸乎?”\n【译文】曾子说:“我每天多次自己反省:替别人办事是否尽心竭力了呢?同朋友往来是否诚实呢?老师传授我的学业是否复习了呢?”\n【注释】⑴曾子——孔子学生,名参(音森,sēn),字子舆,南武城(故城在今天的山东枣庄市附近)人,比孔子小四十六岁(公元前505—435)。⑵三省——“三”字有读去声的,其实不破读也可以。“省”音醒,xǐng,自我检查,反省,内省。“三省”的“三”表示多次的意思。古代在有动作性的动词上加数字,这数字一般表示动作频率。而“三”“九”等字,又一般表示次数的多,不要着实地去看待。说详汪中《述学·释三九》。这里所反省的是三件事,和“三省”的“三”只是巧合。如果这“三”字是指以下三件事而言,依《论语》的句法便应该这样说:“吾日省者三。”和宪问篇的“君子道者三”一样。⑶信——诚也。⑷传——平声,chuán,动词作名词用,老师的传授。⑸习——这“习”字和“学而时习之”的“习”一样,包括温习、实习、演习而言,这里概括地译为“复习”。\n1.5子曰:“道⑴千乘之国⑵,敬事⑶而信,节用而爱人⑷,使民以时⑸。”\n【译文】孔子说:“治理具有一千辆兵车的国家,就要严肃认真地对待工作,信实无欺,节约费用,爱护官吏,役使老百姓要在农闲时间。”\n【注释】⑴道——动词,治理的意思。⑵千乘之国——乘音剩,shèng,古代用四匹马拉着的兵车。春秋时代,打仗用车子,所以国家的强弱都用车辆的数目来计算。春秋初期,大国都没有千辆兵车。像《左传》僖公二十八年所记载的城濮之战,晋文公还只七百乘。但是在那时代,战争频繁,无论侵略者和被侵略者都必须扩充军备。侵略者更因为兼并的结果,兵车的发展速度更快;譬如晋国到平丘之会,据叔向的话,已有四千乘了(见《左传》昭公十三年)。千乘之国,在孔子之时已经不是大国,因此子路也说“千乘之国摄乎大国之间”(11.26)的话了。⑶敬事——“敬”字一般用于表示工作态度,因之常和“事”字连用,如卫灵公篇的“事君敬其事而后其食”。⑷爱人——古代“人”字有广狭两义。广义的“人”指一切人羣,狭义的人只指士大夫以上各阶层的人。这里和“民”(使“民”以时)对言,用的是狭义。⑸使民以时——古代以农业为主,“使民以时”卽是《孟子·梁惠王上》的“不违农时”,因此用意译。\n1.6子曰:“弟子⑴入⑵则孝,出⑵则悌,谨⑶而信,泛爱众,而亲仁⑷。行有余力,则以学文。”\n【译文】孔子说:“后生小子,在父母跟前,就孝顺父母;离开自己房子,便敬爱兄长;寡言少语,说则诚实可信,博爱大众,亲近有仁德的人。这样躬行实践之后,有剩余力量,就再去学习文献。”\n【注释】⑴弟子——一般有两种意义:(甲)年纪幼小的人,(乙)学生。这里用的是第一种意义。⑵入、出——《礼记·内则》:“由命士以上,父子皆异宫”,则知这里的“弟子”是指“命士”以上的人物而言。“入”是“入父宫”,“出”是“出己宫”。⑶谨——寡言叫做谨。详见杨遇夫先生的《积微居小学金石论丛》卷一。⑷仁——“仁”卽“仁人”,和雍也篇第六的“井有仁焉”的“仁”一样。古代的词汇经常运用这样一种规律:用某一具体人和事物的性质、特征甚至原料来代表那一具体的人和事物。\n1.7子夏⑴曰:“贤贤易色⑵;事父母,能竭其力;事君,能致⑶其身;与朋友交,言而有信。虽曰未学,吾必谓之学矣。”\n【译文】子夏说:“对妻子,重品德,不重容貌;侍奉爹娘,能尽心竭力;服事君上,能豁出生命;同朋友交往,说话诚实守信。这种人,虽说没学习过,我一定说他已经学习过了。”\n【注释】⑴子夏——孔子学生,姓卜,名商,字子夏,比孔子小四十四岁。(公元前507—?)⑵贤贤易色——这句话,一般的解释是:“用尊贵优秀品德的心来交换(或者改变)爱好美色的心。”照这种解释,这句话的意义就比较空泛。陈祖范的《经咫》、宋翔凤的《朴学斋札记》等书却说,以下三句,事父母、事君、交朋友,各指一定的人事关系;那么,“贤贤易色”也应该是指某一种人事关系而言,不能是一般的泛指。奴隶社会和封建社会把夫妻间关系看得极重,认为是“人伦之始”和“王化之基”,这里开始便谈到它,是不足为奇的。我认为这话很有道理。“易”有交换、改变的意义,也有轻视(如言“轻易”)、简慢的意义。因之我便用《汉书》卷七十五《李寻传》颜师古注的说法,把“易色”解为“不重容貌”。⑵致——有“委弃”、“献纳”等意义,所以用“豁出生命”来译它。\n1.8子曰:“君子⑴不重,则不威;学则不固。主忠信⑵。无友不如己者⑶。过,则勿惮改。”\n【译文】孔子说:“君子,如果不庄重,就没有威严;卽使读书,所学的也不会巩固。要以忠和信两种道德为主。不要跟不如自己的人交朋友。有了过错,就不要怕改正。”\n【注释】⑴君子——这一词一直贯串到末尾,因此译文将这两字作一停顿。⑵主忠信——颜渊篇(12.10)也说,“主忠信,徙义,崇德也”,可见“忠信”是道德。⑶无友不如己者——古今人对这一句发生不少怀疑,因而有一些不同的解释。译文只就字面译出。\n1.9曾子曰:“慎终⑴,追远⑵,民德归厚矣。”\n【译文】曾子说:“谨慎地对待父母的死亡,追念远代祖先,自然会导致老百姓归于忠厚老实了。”\n【注释】⑴慎终——郑玄的注:“老死曰终。”可见这“终”字是指父母的死亡。慎终的内容,刘宝楠《论语正义》引《檀弓》曾子的话是指附身(装殓)、附棺(埋葬)的事必诚必信,不要有后悔。⑵追远——具体地说是指“祭祀尽其敬”。两者译文都只就字面译出。\n1.10子禽⑴问于子贡⑵曰:“夫子⑶至于是邦也,必闻其政,求之与?抑与之与?”子贡曰:“夫子温、良、恭、俭、让以得之。夫子之求之也,其诸⑷异乎人之求之与?”\n【译文】子禽向子贡问道:“他老人家一到哪个国家,必然听得到那个国家的政事,求来的呢?还是别人自动告诉他的呢?”子贡道:“他老人家是靠温和、善良、严肃、节俭、谦逊来取得的。他老人家获得的方法,和别人获得的方法,不相同吧?”\n【注释】⑴子禽——陈亢(kàng)字子禽。从子张篇所载的事看来,恐怕不是孔子的学生。《史记·仲尼弟子列传》也不载此人。但郑玄注《论语》和《檀弓》都说他是孔子学生,不晓得有什么根据。(臧庸的《拜经日记》说子禽就是仲尼弟子列传的原亢禽,简朝亮的《论语集注补疏》曾加以辩驳。)⑵子贡——孔子学生,姓端木,名赐,字子贡,卫人,比孔子小三十一岁。(公元前520—?)⑶夫子——这是古代的一种敬称,凡是做过大夫的人,都可以取得这一敬称。孔子曾为鲁国的司寇,所以他的学生称他为夫子,后来因此沿袭以称呼老师。在一定的场合下,也用以特指孔子。⑷其诸——洪颐煊《读书丛録》云:“公羊桓六年传,‘其诸以病桓与?’闵元年传,‘其诸吾仲孙与?’僖二十四年传,‘其诸此之谓与?’宣五年传,‘其诸为其双双而俱至者与?’十五年传,‘其诸则宜于此焉变矣。’‘其诸’是齐鲁间语。”案,总上诸例,皆用来表示不肯定的语气。黄家岱《嬹艺轩杂着》说“其诸”意为“或者”,大致得之。\n1.11子曰:“父在,观其⑴志;父没,观其⑴行⑵;三年⑶无改于父之道⑷,可谓孝矣。”\n【译文】孔子说:“当他父亲活着,[因为他无权独立行动,]要观察他的志向;他父亲死了,要考察他的行为;若是他对他父亲的合理部分,长期地不加改变,可以说做到孝了。”\n【注释】⑴其——指儿子,不是指父亲。⑵行——去声,xìng。⑶三年——古人这种数字,有时不要看得太机械。它经常只表示一种很长的期间。⑷道——有时候是一般意义的名词,无论好坏、善恶都可以叫做道。但更多时候是积极意义的名词,表示善的好的东西。这里应该这样看,所以译为“合理部分”。\n1.12有子曰:“礼之用,和⑴为贵。先王之道,斯为美;小大由之。有所不行⑵,知和而和,不以礼节之,亦不可行也。”\n【译文】有子说:“礼的作用,以遇事都做得恰当为可贵。过去圣明君王的治理国家,可宝贵的地方就在这里;他们小事大事都做得恰当。但是,如有行不通的地方,便为恰当而求恰当,不用一定的规矩制度来加以节制,也是不可行的。”\n【注释】⑴和——《礼记·中庸》:“喜怒哀乐之未发谓之中,发而皆中节谓之和。”杨遇夫先生《论语疏证》说:“事之中节者皆谓之和,不独喜怒哀乐之发一事也。说文云:‘龢,调也。’‘盉,调味也。’乐调谓之龢,味调谓之盉,事之调适者谓之和,其义一也。和今言适合,言恰当,言恰到好处。”⑵有所不行——皇侃《义疏》把这句属上,全文便如此读:“礼之用,和为贵。先王之道,斯为美。小大由之,有所不行。……”他把“和”解为音乐,说:“此以下明人君行化必礼乐相须。……变乐言和,见乐功也。……小大由之有所不行者,言每事小大皆用礼,而不以乐和之,则其政有所不行也。”这种句读法值得考虑,但把“和”解释为音乐,而且认为“小大由之”的“之”是指“礼”而言,都觉牵强。特为注出,以供大家考虑。\n1.13有子曰:“信近于义,言可复⑴也。恭近于礼,远⑵耻辱也。因⑶不失其亲,亦可宗⑷也。”\n【译文】有子说:“所守的约言符合义,说的话就能兑现。态度容貌的庄矜合于礼,就不致遭受侮辱。依靠关系深的人,也就可靠了。”\n【注释】⑴复——《左传》僖公九年荀息说:“吾与先君言矣,不可以贰,能欲复言而爱身乎?”又哀公十六年叶公说:“吾闻胜也好复言,……复言非信也。”这“复言”都是实践诺言之义。《论语》此义当同于此。朱熹《集注》云:“复,践言也。”但未举论证,因之后代训诂家多有疑之者。童第德先生为我举出《左传》为证,足补古今字书之所未及。⑵远——去声,音院,yuàn,动词,使动用法,使之远离的意思。此处亦可以译为避免。⑶因——依靠,凭借。有人读为“姻”字,那“因不失其亲”便当译为“所与婚姻的人都是可亲的”,恐未必如此。⑷宗——主,可靠。一般解释为“尊敬”,不妥。\n1.14子曰:“君子⑴食无求饱,居无求安,敏于事而慎于言,就有道而正⑵焉,可谓好学也已。”\n【译文】孔子说:“君子,吃食不要求饱足,居住不要求舒适,对工作勤劳敏捷,说话却谨慎,到有道的人那里去匡正自己,这样,可以说是好学了。”\n【注释】⑴君子——《论语》的“君子”有时指“有位之人”,有时指“有德之人”。但有的地方究竟是指有位者,还是指有德者,很难分别。此处大概是指有德者。⑵正——《论语》“正”字用了很多次。当动词的,都作“匡正”或“端正”讲,这里不必例外。一般把“正”字解为“正其是非”、“判其得失”,我所不取。\n1.15子贡曰:“贫而无谄,富而无骄,何如⑴?”子曰:“可也;未若贫而乐⑵,富而好礼者也。”子贡曰:“《诗》云:‘如切如磋,如琢如磨⑶’其斯之谓与?”子曰:“赐⑷也,始可与言诗已矣,告诸往而知来者⑸。”\n【译文】子贡说:“贫穷却不巴结奉承,有钱却不骄傲自大,怎么样?”孔子说:“可以了;但是还不如虽贫穷却乐于道,纵有钱却谦虚好礼哩。”子贡说:“《诗经》上说:‘要像对待骨、角、象牙、玉石一样,先开料,再糙锉,细刻,然后磨光。’那就是这样的意思吧?”孔子道:“赐呀,现在可以同你讨论《诗经》了,告诉你一件,你能有所发挥,举一反三了。”\n【注释】⑴何如——《论语》中的“何如”,都可以译为“怎么样”。⑵贫而乐——皇侃本“乐”下有“道”字。郑玄注云:“乐谓志于道,不以贫为忧苦。”所以译文增“于道”两字。⑶如切如磋,如琢如磨——两语见于《诗经·卫风·淇奥篇》。⑷赐——子贡名。孔子对学生都称名。⑸告诸往而知来者——“诸”,在这里用法同“之”一样。“往”,过去的事,这里譬为已知的事;“来者”,未来的事,这里譬为未知的事。译文用意译法。孔子赞美子贡能运用《诗经》作譬,表示学问道德都要提高一步看。\n1.16子曰:“不患人之不己知,患不知人也。”\n【译文】孔子说:“别人不了解我,我不急;我急的是自己不了解别人。”\n"},{"id":108,"href":"/zh/docs/culture/%E8%AE%BA%E8%AF%AD/%E8%AE%BA%E8%AF%AD%E8%AF%91%E6%B3%A8_%E6%9D%A8%E4%BC%AF%E5%B3%BB/10%E4%B9%A1%E5%85%9A%E7%AF%87%E7%AC%AC%E5%8D%81/","title":"10乡党篇第十","section":"论语译注 杨伯峻","content":" 乡党篇第十 # (本是一章,今分为二十七节。)\n10.1孔子于乡党,恂恂⑴如也,似不能言者。其在宗庙朝廷,便便⑵言,唯谨尔。\n【译文】孔子在本乡的地方上非常恭顺,好像不能说话的样子。他在宗庙里、朝廷上,有话便明白而流畅地说出,只是说得很少。\n【注释】⑴恂恂——恂音旬,xún,恭顺貌。⑵便便——便旧读骈,pián。\n10.2朝,与下大夫言,侃侃如也;与上大夫言,誾誾⑴如也。君在,踧踖如也,与与如也。\n【译文】上朝的时候,[君主还没有到来,]同下大夫说话,温和而快乐的样子;同上大夫说话,正直而恭敬的样子。君主已经来了,恭敬而心中不安的样子,行步安祥的样子。\n【注释】⑴誾——音银,yín。\n10.3君召使摈,色勃如也,足躩⑴如也。揖所与立,左右手,衣前后⑵,襜⑶如也。趋进⑷,翼如也。宾退,必复命曰:“宾不顾矣。”\n【译文】鲁君召他去接待外国的贵宾,面色矜持庄重,脚步也快起来。向两旁的人作揖,或者向左拱手,或者向右拱手,衣裳一俯一仰,却很整齐。快步向前,好像鸟儿舒展了翅膀。贵宾辞别后一定向君主回报说:“客人已经不回头了。”\n【注释】⑴躩——音矍,jué,皇侃《义疏》引江熙云:“不暇闲步,躩,速貌也。”⑵前后——俯仰的意思。⑵襜——音幨,chān,整齐之貌。⑷趋进——在行步时一种表示敬意的行动。\n10.4入公门,鞠躬如⑴也,如不容。\n立不中门,行不履阈。\n过位⑵,色勃如也,足躩如也,其言似不足者。\n摄齐⑶升堂,鞠躬如也,屏气⑷似不息者。\n出,降一等,逞颜色,怡怡如也。\n没阶,趋进⑸,翼如也。\n复其位,踧踖如也。\n【译文】孔子走进朝廷的门,害怕而谨慎的样子,好像没有容身之地。\n站,不站在门的中间;走,不踩门坎。\n经过国君的坐位,面色便矜庄,脚步也快,言语也好像中气不足。\n提起下襬向堂上走,恭敬谨慎的样子,憋住气好像不呼吸一般。\n走出来,降下台阶一级,面色便放松,怡然自得。\n走完了台阶,快快地向前走几步,好像鸟儿舒展翅膀。\n回到自己的位置,恭敬而内心不安的样子。\n【注释】⑴鞠躬如——这“鞠躬”两字不能当“曲身”讲。这是双声字,用以形容谨慎恭敬的样子。《论语》所有“□□如”的区别词(区别词是形容词、副词的合称),都不用动词结构。清人卢文弨《龙城札记》说:“……且曲身乃实事,而云曲身如,更无此文法。”⑵过位——过旧音戈,平声。位是人君的坐位,经过之时,人君并不在,坐位是空的。⑶摄齐——齐音咨,zī,衣裳缝了边的下襬;摄,提起。⑷屏——音丙,又音并,bìng,屏气卽屏息,压抑呼吸。⑸趋进——有些本子无“进”字,不对。自汉以来所有引《论语》此文的都有“进”字,《唐石经》也有“进”字,《太平御览》居处部、人事部引文,张子《正蒙》引文也都有“进”字。\n10.5执圭⑴,鞠躬如也,如不胜⑵。上如揖,下如授。勃如战色,足蹜蹜如有循⑶。\n享礼⑷,有容色⑸。\n私觌⑹,愉愉如也。\n【译文】[孔子出使到外国,举行典礼,]拿着圭,恭敬谨慎地,好像举不起来。向上举好像在作揖,向下拿好像在交给别人。面色矜庄好像在作战。脚步也紧凑狭窄,好像在沿着[一条线]走过。\n献礼物的时候,满脸和气。\n用私人身分和外国君臣会见,显得轻松愉快。\n【注释】⑴圭——一种玉器,上圆,或者作剑头形,下方,举行典礼的时候,君臣都拿着。⑵胜——音升,shēng,能担负得了。⑶足蹜蹜如有循——蹜音缩,“蹜蹜”,举脚密而狭的样子。“如有循”,所沿循的应当是很窄狭的东西,所以译文加了“一条线”诸字以示意。⑷享礼——古代出使外国,初到所聘问的国家,便行聘问礼。“执圭”一段所写的正是行聘问礼时孔子的情貌。聘问之后,便行享献之礼。“享礼”就是享献礼,使臣把所带来的各种礼物罗列满庭。⑸有容色——《仪礼·聘礼》:“及享,发气焉盈容。”“有容色”就是“发气焉盈容”。⑹觌——音狄,dí,相见。\n10.6君子不以绀緅饰⑴,红紫不以为亵服⑵。\n当暑,袗絺绤⑶,必表而出之。\n缁衣,羔裘;素衣,麑裘;黄衣,狐裘⑷。\n亵裘长⑸,短右袂⑹。\n必有寝衣⑺,长一身有半。\n狐貉之厚以居。\n去丧,无所不佩。\n非帷裳⑻,必杀之⑼。\n羔裘玄冠不以吊⑽。\n吉月⑾,必朝服而朝。\n【译文】君子不用[近乎黑色的]天青色和铁灰色作镶边,[近乎赤色的]浅红色和紫色不用来作平常居家的衣服。\n暑天,穿着粗的或者细的葛布单衣,但一定裹着衬衫,使它露在外面。\n黑色的衣配紫羔,白色的衣配麑裘,黄色的衣配狐裘。\n居家的皮袄身材较长,可是右边的袖子要做得短些。\n睡觉一定有小被,长度合本人身长的一又二分之一。\n用狐貉皮的厚毛作坐垫。\n丧服满了以后,什么东西都可以佩带。\n不是[上朝和祭祀穿的]用整幅布做的裙子,一定裁去一些布。\n紫羔和黑色礼帽都不穿戴着去吊丧。\n大年初一,一定穿着上朝的礼服去朝贺。\n【注释】⑴绀緅饰——绀音赣,gàn;緅音邹,zōu;都是表示颜色的名称。“绀”是深青中透红的颜色,相当今天的“天青”;“緅”是青多红少,比绀更暗的颜色,这里用“铁灰色”来表明它。“饰”是滚边,镶边,缘边。古代,黑色是正式礼服的颜色,而这两种颜色都近于黑色,所以不用来镶边,为别的颜色作装饰。⑵红紫不以为亵服——古代大红色叫“朱”,这是很贵重的颜色。“红”和“紫”都属此类,也连带地被重视,不用为平常家居衣服的颜色。⑶袗絺绤——袗音轸,zhěn,单也。此处用为动词。絺音痴,chī,细葛布;绤音隙,xì,粗葛布。⑷缁衣羔裘等三句——这三句表示衣服里外的颜色应该相称。古代穿皮衣,毛向外,因之外面一定要用罩衣,这罩衣就叫做裼(音锡)衣。这里“缁衣”、“素衣”、“黄衣”的“衣”指的正是裼衣。缁,黑色。古代所谓“羔裘”都是黑色的羊毛,就是今天的紫羔。麑音倪,ní,小鹿,它的毛是白色。⑸亵裘长——亵裘长为着保暖。古代男子上面穿衣,下面穿裳(裙),衣裳不相连。因之孔子在家的皮袄就做得比较长。⑹短右袂——袂,mèi,袖子。右袖较短,为着做事方便。有人认为衣袖一长一短,不大好看,孔子不会如此,于是对这一句别生解释,我认为那些解释都不可信。⑺寝衣——卽被。古代大被叫“衾”,小被叫“被”。⑻帷裳——礼服,上朝和祭祀时穿,用整幅布做,不加翦裁,多余的布作褶迭(褶迭古代叫做襞积),犹如今天的百褶裙。古代男子上衣下裙。⑼杀——去声,shài,减少,裁去。“杀之”就是缝制之先裁去多余的布,不用褶迭,省工省料。⑽羔裘玄冠不以吊——玄冠,一种礼帽。“羔裘玄冠”都是黑色的,古代都用作吉服。丧事是凶事,因之不能穿戴着去吊丧。⑾吉月——这两个字有各种解释:(甲)每月初一(旧注都如此);(乙)“吉”字误,应该作“告”。“告月”就是每月月底,司历者以下月初一告之于君(王引之《经义述闻》、俞樾《羣经平议》);两说都不可信。今从程树德《论语集释》之说。\n10.7齐,必有明衣,布⑴。\n齐必变食⑵,居必迁坐⑶。\n【译文】斋戒沐浴的时候,一定有浴衣,用布做的。\n斋戒的时候,一定改变平常的饮食;居住也一定搬移地方[不和妻妾同房]。\n【注释】⑴布——现在的布一般是用草棉(棉花)纺织的,但古代没有草棉,布的质料,王夫之《四书稗疏》说:“古之言布者,兼丝麻枲葛而言之。练丝为帛,未练为布,盖今之生丝绢也。清商曲有云:‘丝布涩难缝’,则晋宋间犹有丝布之名。唯孔丛子谓麻苎葛曰布,当亦一隅之论。”赵翼《陔余丛考》说:“古时未有棉布,凡布皆麻为之。记曰:‘治其丝麻,以为布帛’是也。”⑵变食——变食的内容,古人有三种说法:(甲)《庄子·人间世篇》说:“颜回曰:‘回之家贫,惟不饮酒不茹荤者数月矣。如此,则可以为齐乎?’曰:‘是祭祀之齐,非心齐也。’”有人据此,便把“不饮酒,不茹荤(荤是有浓厚气味的蔬菜,如蒜、韭、葱之属)”来解释“变食”。(乙)《周礼·天官·膳夫》:“王日一举……王齐,日三举。”这意思是王每天虽然吃饭三顿,却只在第一顿饭时杀牲,其余两顿,只把第一顿的剩菜回锅罢了。天子如此,其它的人更不会顿顿吃新鲜的。若在斋戒之时那就顿顿吃新鲜的,不吃回锅的剩菜,取其洁净,这便是“变食”。(丙)金鹗《求古録·礼说补遗》说,变食不但不饮酒、不食葱蒜等,也不食鱼肉。⑶迁坐——等于说改变卧室。古代的上层人物平常和妻室居于“燕寝”;斋戒之时则居于“外寝”(也叫“正寝”),和妻室不同房。唐朝的法律还规定着举行大祭,在斋戒之时官吏不宿于正寝的,每一晚打五十竹板。这或者犹是古代风俗的残余。\n10.8食不厌精,脍不厌细。\n食饐而餲⑴,鱼馁而肉败⑵,不食。色恶,不食。臭恶,不食。失饪,不食。不时⑶,不食。割不正⑷,不食。不得其酱,不食。\n肉虽多,不使胜食气⑸。\n唯酒无量,不及乱⑹。\n沽酒市脯不食。\n不撤姜食,不多食。\n【译文】粮食不嫌舂得精,鱼和肉不嫌切得细。粮食霉烂发臭,鱼和肉腐烂,都不吃。食物颜色难看,不吃。气味难闻,不吃。烹调不当,不吃。不到该当吃食时候,不吃。不是按一定方法砍割的肉,不吃。没有一定调味的酱醋,不吃。\n席上肉虽然多,吃它不超过主食。\n只有酒不限量,却不至醉。\n买来的酒和肉干不吃。\n吃完了,姜不撤除,但吃得不多。\n【注释】⑴饐而餲——饐音懿,yì;餲,ài;饮食经久而腐臭。⑵馁,败——馁音“内”的上声,něi,鱼腐烂叫“馁”,肉腐烂叫“败”。⑶不时——有两说:(甲)过早的食物,冬天在温室种菜蔬,在《汉书·循吏·召信臣传》和桓宽《盐铁论·散不足篇》里便称为“不时之物”。但在汉朝,也只有“太官园”和其它少数园圃才能供奉,也只有皇上和极为富贵之家才能享受,而在孔子时,不但不必有温室种菜的技术,卽有,孔子也未必能够享受。(乙)不是该当吃食的时候。《吕氏春秋·尽数篇》:“食能以时,身必无灾。”卽此意。⑷割不正——“割”和“切”不同。“割”指宰杀猪牛羊时肢体的分解。古人有一定的分解方法,不按那方法分解的,便叫“割不正”。说本王夫之《四书稗疏》。⑸食气——食音嗣,“气”,说文引作“既”。“既”、“气”、“饩”三字古书通用。“食气”,饭料。⑹乱——高亨《周易古经今注》云:“乱者神志昏乱也。《左传》宣公十五年传:‘疾病则乱’。《论语·乡党篇》:‘唯酒无量不及乱’。易象传曰:‘乃乱乃萃,其志乱也。’得其恉矣。”\n10.9祭于公,不宿肉⑴。祭肉⑵不出三日。出三日,不食之矣。\n【译文】参与国家祭祀典礼,不把祭肉留到第二天。别的祭肉留存不超过三天。若是存放过了三天,便不吃了。\n【注释】⑴不宿肉——古代的大夫、士都有助君祭祀之礼。天子诸侯的祭礼,当天清早宰杀牲畜,然后举行祭典。第二天又祭,叫做“绎祭”。绎祭之后才令各人拿自己带来助祭的肉回去,或者又依贵贱等级分别颁赐祭肉。这样,祭于公的肉,在未颁下来以前,至少是放了一两宵了,因之不能再存放一夜。⑵祭肉——这一祭肉或者指自己家中的,或者指朋友送来的,都可以。\n10.10食不语,寝不言。\n【译文】吃饭的时候不交谈,睡觉的时候不说话。\n10.11虽疏食菜羹,瓜祭⑴,必齐如也。\n【译文】虽然是糙米饭小菜汤,也一定得先祭一祭,而且祭的时候还一定恭恭敬敬,好像斋戒了的一样。\n【注释】⑴瓜祭——有些本子作“必祭”,“瓜”恐怕是错字。这是食前将席上各种食品拿出少许,放在食器之间,祭最初发明饮食的人,《左传》叫泛祭。\n10.12席⑴不正,不坐。\n【译文】坐席摆的方向不合礼制,不坐。\n【注释】⑴席——古代没有椅和櫈,都是在地面上铺席子,坐在席子上。席子一般是用蒲苇、蒯草、竹篾以至禾穰为质料。现在日本人还保留着席地而坐的习惯。《墨子·非儒篇》说:“哀公迎孔子,席不端,不坐。”以“端”解“正”,则“席不正”,是坐席不端正之意。然而《汉书·王尊传》说,“[匡]衡与中二千石大鸿胪赏等会坐殿门下,衡南乡,赏等西乡。衡更为赏布束乡席,起立延赏坐……而设不正之席,使下坐上”云云,那么,“席不正”是布席不合礼制之意。\n10.13乡人饮酒⑴,杖者出,斯出矣。\n【译文】行乡饮酒礼后,要等老年人都出去了,自己这纔出去。\n【注释】⑴乡人饮酒——卽行乡饮酒礼,据《礼记·乡饮酒义》“少长以齿”。《王制》也说:“习乡尚齿”。既论年龄大小,所以孔子必须让杖者先出。\n10.14乡人傩⑴,朝服而立于阼阶⑵。\n【译文】本地方人迎神驱鬼,穿着朝服站在东边的台阶上。\n【注释】⑴滩——音挪,nuó,古代的一种风俗,迎神以驱逐疫鬼。解放前的湖南,如果家中有病人,还有雇请巫师以驱逐疫鬼的迷信,叫做“冲傩”,可能是这种风俗的残余。⑵阼阶——阼音祚,zuò,东面的台阶,主人所立之地。\n10.15问⑴人于他邦,再拜⑵而送之。\n【译文】托人给在外国的朋友问好送礼,便向受托者拜两次送行。\n【注释】⑴问——问讯,问好。不过古代问好,也致送礼物以表示情意,如《诗经·郑风·女曰鸡鸣》“杂佩以问之”,《左传》成公十六年“楚子使工尹襄问之以弓”,哀公十一年“使问弦多以琴”,因此译文加了“送礼”两字。⑵拜——拱手并弯腰。\n10.16康子馈药,拜而受之。曰:“丘未达,不敢尝。”\n【译文】季康子给孔子送药,孔子拜而接受,却说道:“我对这药性不很了解,不敢试服。”\n10.17厩焚。子退朝,曰:“伤人乎?”不问马。\n【译文】孔子的马棚失了火。孔子从朝廷回来,道:“伤了人吗?”,不问到马。\n10.18君赐食,必正席先尝之。君赐腥,必熟而荐⑴之。君赐生,必畜之。\n侍食于君,君祭,先饭。\n【译文】国君赐以熟食,孔子一定摆正坐位先尝一尝。国君赐以生肉,一定煮熟了,先[给祖宗]进供。国君赐以活物,一定养着它。\n同国君一道吃饭,当他举行饭前祭礼的时候,自己先吃饭,[不吃菜。]\n【注释】⑴荐——进奉。这里进奉的对象是自己的祖先,但不能看为祭祀。\n10.19疾,君视之,东首⑴,加朝服,拖绅⑵。\n【译文】孔子病了,国君来探问,他便脑袋朝东,把上朝的礼服披在身上,拖着大带。\n【注释】⑴东首——指孔子病中仍旧卧床而言。古人卧榻一般设在南窗的西面。国君来,从东边台阶走上来(东阶就是阼阶,原是主人的位向,但国君自以为是全国的主人,就是到其臣下家中,仍从阼阶上下),所以孔子面朝东来迎接他。⑵加朝服,拖绅——孔子卧病在床,自不能穿朝服,只能盖在身上。绅是束在腰间的大带。束了以后,仍有一节垂下来。\n10.20君命召,不俟驾行矣。\n【译文】国君呼唤,孔子不等待车辆驾好马,立卽先步行。\n10.21入太庙,每事问⑴。\n【注释】⑴见八佾篇。\n10.22朋友死,无所归,曰:“于我殡⑴。”\n【译文】朋友死亡,没有负责收敛的人,孔子便道:“丧葬由我来料理。”\n【注释】⑴殡——停放灵柩叫殡,埋葬也可以叫殡,这里当指一切丧葬事务而言。\n10.23朋友之馈,虽车马,非祭肉,不拜。\n【译文】朋友的赠品,卽使是车马,只要不是祭肉,孔子在接受的时候,不行礼。\n10.24寝不尸,居不客⑴。\n【译文】孔子睡觉不像死尸一样[直躺着],平日坐着,也不像接见客人或者自己做客人一样,[跪着两膝在席上。]\n【注释】⑴居不客——“客”本作“容”,今从《释文》和《唐石经》校订作“客”。居,坐;客,宾客。古人的坐法有几种,恭敬的是屈着两膝,膝盖着地,而足跟承着臀部。作客和见客时必须如此。不过这样难以持久,居家不必如此。省力的坐法是脚板着地,两膝耸起,臀部向下而不贴地,和蹲一样。所以《说文》说:“居,蹲也。”(这几个字是依从段玉裁的校本。)最不恭敬的坐法是臀部贴地,两腿张开,平放而直伸,像箕一样,叫做“箕踞”。孔子平日的坐式可能像蹲。说见段玉裁《说文解字注》。\n10.25见齐衰者,虽狎,必变。见冕者与瞽者,虽亵,必以貌。\n凶服者式⑴之。式负版⑵者。\n有盛馔,必变色而作。\n迅雷风烈⑶必变。\n【译文】孔子看见穿齐衰孝服的人,就是极亲密的,也一定改变态度,[表示同情。]看见戴着礼帽和瞎了眼睛的人,卽使常相见,也一定有礼貌。\n在车中遇着拿了送死人衣物的人,便把身体微微地向前一俯,手伏着车前的横木,[表示同情。]遇见背负国家图籍的人,也手伏车前横木。\n一有丰富的菜肴,一定神色变动,站立起来。\n遇见疾雷、大风,一定改变态度。\n【注释】⑴式——同“轼”,古代车辆前的横木叫“轼”,这里作动词用,用手伏轼的意思。⑵版——国家图籍。⑶迅雷风烈——就是“迅雷烈风”的意思。\n10.26升车,必正立,执绥。\n车中,不内顾,不疾言,不亲指。\n【译文】孔子上车,一定先端正地站好,拉着扶手带[登车]。在车中,不向内回顾,不很快地说话,不用手指指画画。\n10.27色斯举矣,翔而后集。曰:“山梁雌雉,时哉时哉!”子路共⑴之,三嗅⑵而作⑶。\n【译文】[孔子在山谷中行走,看见几只野鸡。]孔子的脸色一动,野鸡便飞向天空,盘旋一阵,又都停在一处。孔子道:“这些山梁上雌雉,得其时呀,得其时呀,”子路向它们拱拱手,它们又振一振翅膀飞去了。\n【注释】⑴共——同“拱”。⑵嗅——当作狊,jù,张两翅之貌。⑶这段文字很费解,自古以来就没有满意的解释,很多人疑它有脱误,我只能取前人的解释之较为平易者翻译出来。\n"},{"id":109,"href":"/zh/docs/culture/%E8%AE%BA%E8%AF%AD/%E8%AE%BA%E8%AF%AD%E8%AF%91%E6%B3%A8_%E6%9D%A8%E4%BC%AF%E5%B3%BB/14%E5%AE%AA%E9%97%AE%E7%AF%87%E7%AC%AC%E5%8D%81%E5%9B%9B/","title":"14宪问篇第十四","section":"论语译注 杨伯峻","content":" 宪问篇第十四 # (共四十四章(朱熹《集注》把第一章自“克、伐、怨、欲”以下别为一章,把第二十章自“曾子曰”以下别为一章,又把第三十七章自“子曰作者”以下别为一章,所以题为四十七章。))\n14.1宪问耻。子曰:“邦有道,谷;邦无道,谷,耻也。”\n“克、伐、怨、欲不行焉,可以为仁矣?⑴”子曰:“可以为难矣,仁则吾不知也。”\n【译文】原宪问如何叫耻辱。孔子道:“国家政治清明,做官领薪俸;国家政治黑暗,做官领薪俸,这就是耻辱。”\n原宪又道:“好胜、自夸、怨恨和贪心四种毛病都不曾表现过,这可以说是仁人了吗?”孔子道:“可以说是难能可贵的了,若说是仁人,那我不能同意。”\n【注释】⑴可以为仁矣——这句话从形式上看应是肯定句,但从上下文看,实际应是疑问句,不过疑问只从说话者的语势来表示,不藉助于别的表达形式而已。这一段可以和“邦有道,贫且贱焉,耻也;邦无道,富且贵焉,耻也。”(8.13)互相发明。\n14.2子曰:“士而怀居⑴,不足以为士矣。”\n【译文】孔子说:“读书人而留恋安逸,便不配做读书人了。”\n【注释】⑴怀居——怀,怀思,留恋;居,安居。《左传》僖公二十三年记载着晋文公的流亡故事,说他在齐国安居下来,有妻妾,有家财,便不肯再移动了。他老婆姜氏便对他说:“行也!怀与安,实败名。”便和此意相近。\n14.3子曰:“邦有道,危⑴言危行;邦无道,危行言孙⑵。”\n【译文】孔子说:“政治清明,言语正直,行为正直;政治黑暗,行为正直,言语谦顺。”\n【注释】⑴危——《礼记·缁衣》注:“危,高峻也。”意谓高于俗,朱熹《集注》用之,固然可通。但《广雅》云:“危,正也。”王念孙《疏证》卽引《论语》此文来作证,更为恰当,译文卽用此解。⑵孙——同逊。\n14.4子曰:“有德者必有言,有言者不必有德。仁者必有勇,勇者不必有仁。”\n【译文】孔子说:“有道德的人一定有名言,但有名言的人不一定有道德。仁人一定勇敢,但勇敢的人不一定仁。”\n14.5南宫适⑴问于孔子曰:“羿⑵善射,奡⑶荡舟⑷,俱不得其死然。禹稷躬稼而有天下。”夫子不答。\n南宫适出,子曰:“君子哉若人!尚德哉若人⑸!”\n【译文】南宫适向孔子问道:“羿擅长射箭,奡擅长水战,都没有得到好死。禹和稷自己下地种田,却得到了天下。[怎样解释这些历史?]”孔子没有答复。\n南宫适退了出来。孔子道:“这个人,好一个君子!这个人,多么尊尚道德!”\n【注释】⑴南宫适——孔子学生南容。⑵羿——音诣,yì。在古代传说中有三个羿,都是射箭能手。一为帝喾的射师,见于说文;二为唐尧时人,传说当时十个太阳同时出现,羿射落了九个,见《淮南子·本经训》;三为夏代有穷国的君主,见《左传》襄公四年。这里所指的和《孟子·离娄篇》所载的“逢蒙学射于羿”的羿,据说都是夏代的羿。⑶奡——音傲,aò。也是古代传说中的人物,夏代寒浞的儿子。字又作“浇”。⑷荡舟——顾炎武《日知録》云:“古人以左右冲杀为荡。陈其锐卒,谓之跳荡;别帅谓之荡主。荡舟盖兼此义。”译成现代汉语,就是用舟师冲锋陷阵。⑸君子……尚德哉若人——南宫适托古代的事来问孔子,中心思想是当今尚力不尚德,但按之历史,尚力者不得善终,尚德者终有天下。因之孔子称赞他。\n14.6子曰:“君子⑴而不仁者有矣夫,未有小人⑵而仁者也。”\n【译文】孔子说:“君子之中不仁的人有的罢,小人之中却不会有仁人。”\n【注释】⑴君子,小人——这个“君子”“小人”的含义不大清楚。“君子”“小人”若指有德者无德者而言,则第二句可以不说;看来,这里似乎是指在位者和老百姓而言。\n14.7子曰:“爱之,能勿劳乎⑴?忠焉,能勿诲乎?”\n【译文】孔子说:“爱他,能不叫他劳苦吗?忠于他,能够不教诲他吗?”\n【注释】⑴能勿劳乎——《国语·鲁语下》说:“夫民劳则思,思则善心生;逸则淫,淫则忘善,忘善则恶心生。”可以为“能勿劳乎”的注脚。\n14.8子曰:“为命⑴,裨谌⑵草创之,世叔⑶讨论⑷之,行人子羽⑸修饰之,东里子产⑹润色之。”\n【译文】孔子说:“郑国外交辞令的创制,裨谌拟稿,世叔提意见,外交官子羽修改,子产作文词上的加工。”\n【注释】⑴为命——《左传》襄公三十一年云:“郑国将有诸侯之事,子产乃问四国之为于子羽,且使多为辞令,与裨谌乘以适野,使谋可否,而告冯简子使断之。事成,乃授子太叔使行之,以应对宾客,是以鲜有败事。”可与《论语》此文相参校。《左传》所讲的过程和《论语》此文虽然有些出入,但主题是相同的,因此我把“命”译为“外交辞令”,不作一般的政令讲。⑵裨谌——音庇臣,bìchén,郑国大夫,见《左传》。⑶世叔——卽《左传》的子太叔(古代,“太”和“世”两字通用),名游吉。⑷讨论——意义和今天的“讨论”不同,这是一个人去研究而后提意见的意思。⑸行人子羽——行人,官名,卽古代的外交官。子羽,公孙挥的字。⑹东里子产——东里,地名,今在郑州市,子产所居。\n14.9或问子产。子曰:“惠人也。”\n问子西⑴。曰:“彼哉!彼哉⑵!”\n问管仲。曰:“人也。夺伯氏⑶骈邑⑷三百,饭疏食,没齿无怨言。”\n【译文】有人向孔子问子产是怎样的人物。孔子道:“是宽厚慈惠的人。”\n又问到子西。孔子道:“他呀,他呀!”\n又问到管仲。孔子道:“他是人才。剥夺了伯氏骈邑三百户的采地,使伯氏只能吃粗粮,到死没有怨恨的话。”\n【注释】⑴子西——春秋时有三个子西,一是郑国的公孙夏,生当鲁襄公之世,为子产的同宗兄弟,子产便是继他而主持郑国政治的。二是楚国的鬬宜申,生当鲁僖公、文公之世。三是楚国的公子申,和孔子同时。鬬宜申去孔子太远,公子申又太近,这人所问的当是公孙夏。⑵彼哉彼哉——《公羊传》定公八年记载阳虎谋杀季孙的事,说阳虎谋杀未成,在郊外休息,忽然望见公敛处父领着追兵而来,便道:“彼哉彼哉!”毛奇龄《论语稽求篇》因云:“此必古成语,而夫子引以作答者。”案:这是当时表示轻视的习惯语。⑶伯氏——齐国的大夫,皇侃《义疏》云:“伯氏名偃。”不知何据。⑷骈邑——地名。阮元曾得伯爵彝,说是乾隆五十六年出土于山东临朐县柳山寨。他在《积古斋锺鼎彝器款识》里说,柳山寨有古城的城基,卽春秋的骈邑。用《水经·巨洋水注》证之,阮氏之言很可信。\n14.10子曰:“贫而无怨难,富而无骄易。”\n【译文】孔子说:“贫穷却没有怨恨,很难;富贵却不骄傲,倒容易做到”\n14.11子曰:“孟公绰⑴为赵魏老⑵则优⑶,不可以为滕、薛⑷大夫。”\n【译文】孔子说:“孟公绰,若是叫他做晋国诸卿赵氏、魏氏的家臣,那是力有余裕的;却没有才能来做滕、薛这样小国的大夫。”\n【注释】⑴孟公绰——鲁国大夫,《左传》襄公二十五年记载着他的一段事。《史记·仲尼弟子列传》说他是孔子所尊敬的人。⑵老——古代,大夫的家臣称老,也称室老。⑶优——本意是“优裕”,所以用“力有余裕”来译它。⑷滕、薛——当时的小国,都在鲁国附近。滕的故城在今山东滕县西南十五里,薛的故城在今滕县南四十四里官桥公社处。\n14.12子路问成人。子曰:“若臧武仲⑴之知,公绰之不欲,卞庄子⑵之勇,冉求之艺,文之以礼乐,亦可以为成人矣。”曰:“今之成人者何必然?见利思义,见危授命,久要⑶不忘平生之言,亦可以为成人矣。”\n【译文】子路问怎样才是全人。孔子道:“智慧像臧武仲,清心寡欲像孟公绰,勇敢像卞庄子,多才多艺像冉求,再用礼乐来成就他的文采,也可以说是全人了。”等了一会,又道:“现在的全人哪里一定要这样?看见利益便能想起该得不该得,遇到危险便肯付出生命,经过长久的穷困日子都不忘记平日的诺言,也可以说是全人了。”\n【注释】⑴臧武仲——鲁大夫臧孙纥。他很聪明,逃到齐国之后,能预见齐庄公的被杀而设法辞去庄公给他的田。事见《左传》襄公二十三年。⑵卞庄子——鲁国的勇士。《荀子·大略篇》和《韩诗外传》卷十都载有他的勇敢故事。⑵久要——“要”为“约”的借字,“约”,穷困之意。说见杨遇夫先生的《积微居小学述林》。\n14.13子问公叔文子⑴于公明贾⑵曰:“信乎,夫子不言,不笑,不取乎?”\n公明贾对曰:“以⑶告者过也。夫子时然后言,人不厌其言;乐然后笑,人不厌其笑;义然后取,人不厌其取。”\n子曰:“其然?岂其然乎?”\n【译文】孔子向公明贾问到公叔文子,说:“他老人家不言语,不笑,不取,是真的吗?”\n公明贾答道:“这是传话的人说错了。他老人家到应说话的时候才说话,别人不厌恶他的话;高兴了才笑,别人不厌恶他的笑;应该取才取,别人不厌恶他的取。”\n孔子道:“如此的吗?难道真是如此的吗?”\n【注释】⑴公叔文子——卫国大夫,《檀弓》载有他的故事。⑵公明贾——卫人,姓公明,名贾。贾音假,jiǎ。《左传》哀公十四年楚有蔿贾也音假。⑶以——代词,此也。例证可参考杨遇夫先生的《词诠》。\n14.14子曰:“臧武仲以防求为后于鲁⑴,虽曰不要⑵君,吾不信也。”\n【译文】孔子说:“臧武仲[逃到齐国之前,]凭借着他的采邑防城请求立其子弟嗣为鲁国卿大夫,纵然有人说他不是要挟,我是不相信的。”\n【注释】⑴臧武仲以防求为后于鲁——事见《左传》襄公二十三年。防,臧武仲的封邑,在今山东费县东北六十里之华城,离齐国边境很近。⑵要——平声,音腰,yāo。\n14.15子曰:“晋文公⑴谲⑵而不正,齐桓公⑴正而不谲。”\n【译文】孔子说:“晋文公诡诈好耍手段,作风不正派;齐桓公作风正派,不用诡诈,不耍手段。”\n【注释】⑴晋文公、齐桓公——晋文公名重耳,齐桓公名小白。齐桓、晋文是春秋时五霸中最有名声的两个霸主。⑵谲——音决,jué,欺诈,玩弄权术阴谋。\n14.16子路曰:“桓公杀公子纠,召忽死之,管仲不死⑴。”曰:“未仁乎?”子曰:“桓公九合⑵诸侯,不以兵车,管仲之力也。如其仁,如其仁⑶。”\n【译文】子路道:“齐桓公杀了他哥哥公子纠,[公子纠的师傅]召忽因此自杀,[但是他的另一师傅]管仲却活着。”接着又道:“管仲该不是有仁德的罢?”孔子道:“齐桓公多次地主持诸侯间的盟会,停止了战争,都是管仲的力量。这就是管仲的仁德,这就是管仲的仁德。”\n【注释】⑴管仲不死——齐桓公和公子纠都是齐襄公的弟弟。齐襄公无道,两人都怕牵累,桓公便由鲍叔牙侍奉逃往莒国,公子纠也由管仲和召忽侍奉逃往鲁国。襄公被杀以后,桓公先入齐国,立为君,便兴兵伐鲁,逼迫鲁国杀了公子纠,召忽自杀以殉,管仲却做了桓公的宰相。这段历史可看《左传》庄公八年和九年。⑵九合——齐桓公纠合诸侯共计十一次。这一“九”字实是虚数,不过表示其多罢了。⑶如其仁——王引之《经传释词》云:“如犹乃也。”扬雄《法言》三次仿用这种句法,义同。\n14.17子贡曰:“管仲非仁者与?桓公杀公子纠,不能死,又相之。”子曰:“管仲相桓公,霸诸侯,一匡天下,民到于今受其赐。微⑴管仲,吾其被⑵发左衽矣。岂若匹夫匹妇之为谅也,自经⑶于沟渎⑷而莫之知也?”\n【译文】子贡道:“管仲不是仁人罢?桓公杀掉了公子纠,他不但不以身殉难,还去辅相他。”孔子道:“管仲辅相桓公,称霸诸侯,使天下一切得到匡正,人民到今天还受到他的好处。假若没有管仲,我们都会披散着头发,衣襟向左边开,[沦为落后民族]了。他难道要像普通老百姓一样守着小节小信,在山沟中自杀,还没有人知道的吗?”\n【注释】⑴微——假若没有的意思,只用于和既成事实相反的假设句之首。⑵被——同“披”。⑶自经——自缢。⑷沟渎——犹《孟子·梁惠王》的“沟壑”。王夫之《四书稗疏》认为它是地名,就是《左传》的“句渎”,《史记》的“笙渎”,那么,孔子的匹夫匹妇就是指召忽而言,恐不可信。\n14.18公叔文子之臣大夫⑴僎与文子同升诸⑵公。子闻之,曰:“可以为‘文’⑶矣。”\n【译文】公叔文子的家臣大夫僎,[由于文子的推荐,]和文子一道做了国家的大臣。孔子知道这事,便道:“这便可以谥为‘文’了。”\n【注释】⑴毛奇龄《四书剩言》云:“臣大夫卽家大夫也。”把“臣大夫”三字不分,今不取。《后汉书·吴良传》李贤注说:“文子家臣名僎”云云,也可见唐初人不以“臣大夫”为一词。⑵诸——用法同“于”。⑶据《礼记·檀弓》,公叔文子实谥为贞惠文子。郑玄《礼记》注说:“不言‘贞惠’者?‘文’足以兼之。”\n14.19子言卫灵公之无道也,康子曰:“夫如是,奚而⑴不丧?”孔子曰:“仲叔圉⑵治宾客,祝鮀治宗庙,王孙贾治军旅。夫如是,奚其丧?”\n【译文】孔子讲到卫灵公的昏乱,康子道:“既然这样,为什么不败亡?”孔子道:“他有仲叔圉接待宾客,祝鮀管理祭祀,王孙贾统率军队,像这样,怎么会败亡?”\n【注释】⑴奚而——俞樾《羣经平议》云:“奚而犹奚为也。”⑵仲叔圉——就是孔文子。\n14.20子曰:“其言之不怍,则为之也难。”\n【译文】孔子说:“那个人大言不惭,他实行就不容易。”\n14.21陈成子⑴弑简公⑵。孔子沐浴而朝⑶,告于哀公曰:“陈恒弑其君,请讨之⑷。”公曰:“告夫三子!”\n孔子曰⑸:“以吾从大夫之后,不敢不告也。君曰‘告夫三子’者!”\n之三子告,不可。孔子曰:“以吾从大夫之后,不敢不告也。”\n【译文】陈恒杀了齐简公。孔子斋戒沐浴而后朝见鲁哀公,报告道:“陈恒杀了他的君主,请你出兵讨伐他。”哀公道:“你向季孙、仲孙、孟孙三人去报告罢!”\n孔子[退了出来],道:“因为我曾忝为大夫,不敢不来报告,但是君上却对我说,‘给那三人报告吧’!”\n孔子又去报告三位大臣,不肯出兵。孔子道:“因为我曾忝为大夫,不敢不报告。”\n【注释】⑴陈成子——就是陈恒。⑵简公——齐简公,名壬。⑶孔子沐浴而朝——这时孔子已经告老还家,特为这事来朝见鲁君。⑷请讨之——孔子请讨陈恒,主要地由于陈恒以臣杀君,依孔子的学说,非讨不可。同时孔子也估计了战争的胜负。《左传》记载着孔子的话道:“陈恒弒其君,民之不与者半。以鲁之众加齐之半,可克也。”但这事仍可讨论。⑸孔子曰——这是孔子退朝后的话,参校《左传》哀公十四年的记载便可以知道。\n14.22子路问事君。子曰:“勿欺也,而犯之。”\n【译文】子路问怎样服侍人君。孔子道:“不要[阳奉阴违地]欺骗他,却可以[当面]触犯他。”\n14.23子曰:“君子上达⑴,小人下达⑴。”\n【译文】孔子说:“君子通达于仁义,小人通达于财利。”\n【注释】⑴上达下达——古今学人各有解释,译文采取了皇侃《义疏》的说法。\n14.24子曰:“古之学者为己⑴,今之学者为人⑴。”\n【译文】孔子说:“古代学者的目的在修养自己的学问道德,现代学者的目的却在装饰自己,给别人看。”\n【注释】为己为人——如何叫做“为己”和“为人”,译文采用了《荀子·劝学篇》、《北堂书钞》所引《新序》和《后汉书·桓荣传论》(俱见杨遇夫先生《论语疏证》)的解释。\n14.25蘧伯玉⑴使人于孔子。孔子与之坐而问焉,曰:“夫子何为?”对曰:“夫子欲寡其过⑵而未能也。”\n使者出。子曰“使乎!使乎!”\n【译文】蘧伯玉派一位使者访问孔子。孔子给他坐位,而后问道:“他老人家干些什么?”使者答道:“他老人家想减少过错却还没能做到。”\n使者辞了出来。孔子道:“好一位使者!好一位使者!”\n【注释】⑴蘧伯玉——卫国的大夫,名瑗。孔子在卫国之时,曾经住过他家。⑵寡其过——《庄子·则阳篇》说:“蘧伯玉行年六十而六十化,未尝不始于是之,而卒诎之以非也;或未知今之所谓是之非五十九非也(六十之是或为五十九之非)。”《淮南子·原道篇》也说:“蘧伯玉年五十而知四十九年非。”大概这人是位求进甚急善于改过的人。使者之言既得其实,又不卑不亢,所以孔子连声称赞。\n14.26子曰:“不在其位,不谋其政⑴。”\n曾子曰:“君子思不出其位。”\n【译文】曾子说:“君子所思虑的不超出自己的工作岗位。”\n【注释】⑴见泰伯篇。(8.14)\n14.27子曰:“君子耻其言而⑴过其行。”\n【译文】孔子说:“说得多,做得少,君子以为耻。”\n【注释】⑴而——用法同“之”,说详《词诠》。皇侃所据本,日本足利本,这一“而”字都作“之”。\n14.28子曰:“君子道者三,我无能焉:仁者不忧,知者不惑,勇者不惧。”子贡曰:“夫子自道也。”\n【译文】孔子说:“君子所行的三件事,我一件也没能做到:仁德的人不忧虑,智慧的人不迷惑,勇敢的人不惧怕。”子贡道:“这正是他老人家对自己的叙述哩。”\n14.29子贡方人⑴。子曰:“赐也贤乎哉?夫我则不暇。”\n【译文】子贡讥评别人。孔子对他道:“你就够好了吗?我却没有这闲工夫。”\n【注释】⑴方人——《经典释文》说,郑玄注的《论语》作“谤人”,又引郑注云“谓言人之遇恶”。因此译文译为“讥评”。《世说新语·容止篇》:“或以方谢仁祖不乃重者。”这“方”字作品评解,其用法可能出于此。\n14.30子曰:“不患人之不己知,患其不能也。”\n【译文】孔子说:“不着急别人不知道我,只着急自己没有能力。”\n14.31子曰:“不逆诈,不亿不信,抑亦先觉者,是贤乎!”\n【译文】孔子说:“不预先怀疑别人的欺诈,也不无根据地猜测别人的不老实,却能及早发觉,这样的人是一位贤者罢!”\n14.32微生亩⑴谓孔子曰:“丘何为是⑵栖栖者与?无乃为佞乎?”孔子曰:“非敢为佞也,疾固也。”\n【译文】微生亩对孔子道:“你为什么这样忙忙碌碌的呢?不是要逞你的口才吗?”孔子道:“我不是敢逞口才,而是讨厌那种顽固不通的人。”\n【注释】⑴微生亩——“微生是姓,“亩”是名。⑵是——这里作副词用,当“如此”解。\n14.33子曰:“骥不称其力,称其德也。”\n【译文】孔子说:“称千里马叫做骥,并不是赞美它的气力,而是赞美他的质量。”\n14.34或曰:“以德报怨⑴,何如?”子曰:“何以报德?以直报怨,以德报德。”\n【译文】有人对孔子道:“拿恩惠来回答怨恨,怎么样?”孔子道:“拿什么来酬答恩惠呢?拿公平正直来回答怨恨,拿恩惠来酬答恩惠。”\n【注释】⑴以德报怨——《老子》也说:“大小多少,报怨以德。”可能当日流行此语。\n14.35子曰:“莫我知也夫!”子贡曰:“何为其莫知子也?”子曰:“不怨天,不尤人,下学而上达⑴。知我者其天乎!”\n【译文】孔子叹道:“没有人知道我呀!”子贡道:“为什么没有人知道您呢?”孔子道:“不怨恨天,不责备人,学习一些平常的知识,却透澈了解很高的道理。知道我的,只是天罢!”\n【注释】⑴下学而上达——这句话具体的意义是什么,古今颇有不同解释,译文所言只能备参考。皇侃《义疏》云:“下学,学人事;上达,达天命。我既学人事,人事有否有泰,故不尤人。上达天命,天命有穷有通,故我不怨天也。”全部意思都贯通了,虽不敢说合于孔子本意,无妨録供参考。\n14.36公伯寮⑴愬⑵子路于季孙。子服景伯⑶以告,曰:“夫子固有惑志于公伯寮,吾力犹能肆诸市朝⑷。”\n子曰:“道之将行也与,命也;道之将废也与,命也。公伯寮其如命何!”\n【译文】公伯寮向季孙毁谤子路。子服景伯告诉孔子,并且说:“他老人家已经被公伯寮所迷惑了,可是我的力量还能把他的尸首在街头示众。”\n孔子道:“我的主张将实现吗,听之于命运;我的主张将永不实现吗,也听之于命运。公伯寮能把我的命运怎样呢!”\n【注释】⑴公伯寮——《史记·仲尼弟子列传》作“公伯僚”云“字子周”。⑵愬——同“诉”。⑶子服景伯——鲁大夫,名何。⑷市朝——古人把罪人之尸示众,或者于朝廷,或者于市集。\n14.37子曰:“贤者辟⑴世,其次辟地,其次辟色,其次辟言。”\n子曰:“作者七人矣。”\n【译文】孔子说:“有些贤者逃避恶浊社会而隐居,次一等的择地而处,再次一等的避免不好的脸色,再次一等的迥避恶言。”\n孔子又说:“像这样的人已经有七位了。”\n【注释】⑴辟——同“避”。\n14.38子路宿于石门⑴。晨门曰:“奚自?”子路曰:“自孔氏。”曰:“是知其不可而为之者与?”\n【译文】子路在石门住了一宵,[第二天清早进城,]司门者道:“从哪儿来?”子路道:“从孔家来。”司门者道:“就是那位知道做不到却定要去做的人吗?”\n【注释】⑴石门——《后汉书·张皓王龚传论》注引郑玄《论语注》云:“石门,鲁城外门也。”\n14.39子击磬于卫,有荷蒉而过孔氏之门者,曰:“有心哉,击磬乎!”既而曰:“鄙哉,踁踁乎!莫己知也,斯己而已矣。深则厉,浅则揭⑴。”\n子曰:“果哉!末之难矣。”\n【译文】孔子在卫国,一天正敲着磬,有一个挑着草筐子的汉子恰在门前走过,便说道:“这个敲磬是有深意的呀!”等一会又说道:“磬声踁踁的,可鄙呀,[它好像在说,没有人知道我呀!]没有人知道自己,这就罢休好了。水深,索性连衣裳走过去;水浅,无妨撩起衣裳走过去。”\n孔子道:“好坚决!没有办法说服他了。”\n【注释】⑴深厉浅揭——两句见于《诗经·邶风·匏有苦叶》。这是比喻。水深比喻社会非常黑暗,只得听之任之;水浅比喻黑暗的程度不深,还可以使自己不受沾染,便无妨撩起衣裳,免得濡湿。\n14.40子张曰:“《书》云:‘高宗谅阴⑴,三年不言。’何谓也?”子曰:“何必高宗,古之人皆然。君薨,百官总己以听于冢宰三年。”\n【译文】子张道:“《尚书》说:‘殷高宗守孝,住在凶庐,三年不言语。’这是什么意思?”孔子道:“不仅仅高宗,古人都是这样:国君死了,继承的君王三年不问政治,各部门的官员听命于宰相。”\n【注释】⑴谅阴——居丧时所住的房子,又叫“凶庐”。两语见《无逸篇》。\n14.41子曰:“上好礼,则民易使也。”\n【译文】孔子说:“在上位的人若遇事依礼而行,就容易使百姓听从指挥。”\n14.42子路问君子。子曰:“修己以敬。”\n曰:“如斯而已乎?”曰:“修己以安人⑴。”\n曰:“如斯而已乎?”曰:“修己以安百姓。修己以安百姓⑵,尧舜其犹病诸?”\n【译文】子路问怎样才能算是一个君子。孔子道:“修养自己来严肃认真地对待工作。”\n子路道:“这样就够了吗?”孔子道:“修养自己来使上层人物安乐。”\n子路道:“这样就够了吗?”孔子道:“修养自己来使所有老百姓安乐。修养自己来使所有老百姓安乐,尧舜大概还没有完全做到哩!”\n【注释】⑴人——这个“人”字显然是狭义的“人”(参见1.5注四),没有把“百姓”包括在内。⑵修己以安百姓——雍也篇说:“博施于民……尧舜其犹病诸。”(6.30)这里说:“修己以安百姓,尧舜其犹病诸。”可见这里的“修己以安百姓”就是“博施于民”。\n14.43原壤⑴夷俟⑵。子曰:“幼而不孙弟⑶,长而无述焉,老而不死,是为贼。”以杖叩其胫。\n【译文】原壤两腿像八字一样张开坐在地上,等着孔子。孔子骂道:“你幼小时候不懂礼节,长大了毫无贡献,老了还白吃粮食,真是个害人精。”说完,用拐杖敲了敲他的小腿。\n【注释】⑴原壤——孔子的老朋友,《礼记·檀弓》记载他一段故事,说他母亲死了,孔子去帮助他治丧,他却站在棺材上唱起歌来了,孔子也只好装做没听见。大概这人是一位另有主张而立意反对孔子的人。⑵夷俟——夷,箕踞;俟,等待。⑵孙弟——同逊悌。\n14.44阙党⑴童子将命。或问之曰:“益者与?”子曰:“吾见其居于位⑵也。见其与先生并行⑶也。非求益者也,欲速成者也。”\n【译文】阙党的一个童子来向孔子传达信息。有人问孔子道:“这小孩是肯求上进的人吗?孔子道:“我看见他[大模大样地]坐在位上,又看见他同长辈并肩而行。这不是个肯求上进的人,只是一个急于求成的人。”\n【注释】⑴阙党——顾炎武的《日知録》说:“《史记·鲁世家》‘炀公筑茅阙门’,盖阙门之下,其里卽名阙里,夫子之宅在焉。亦谓之阙党。”案顾氏此说很对(阎若璩《四书释地》的驳论不对),《荀子·儒效篇》也有孔子“居于阙党”的记载,可见阙党为孔子所居之地名。⑵居于位——根据《礼记·玉藻》的记载,“童子无事则立主人之北,南面。”则“居于位”是不合当日礼节的。⑶与先生并行——《礼记·曲礼》上篇说:“五年以长,则肩随之”(“肩随”就是与之并行而稍后),而童子的年龄相差甚远,依当日礼节,不能和成人并行。\n"},{"id":110,"href":"/zh/docs/culture/%E8%AE%BA%E8%AF%AD/%E8%AE%BA%E8%AF%AD%E8%AF%91%E6%B3%A8_%E6%9D%A8%E4%BC%AF%E5%B3%BB/11%E5%85%88%E8%BF%9B%E7%AF%87%E7%AC%AC%E5%8D%81%E4%B8%80/","title":"11先进篇第十一","section":"论语译注 杨伯峻","content":" 先进篇第十一 # (共二十六章(朱熹《集注》把第二、第三两章合并为一章。刘宝楠正义则把第十八、第十九和第二十、第二十一各并为一章。))\n11.1子曰:“先进⑴于礼乐,野人也;后进⑴于礼乐,君子也。如用之,则吾从先进。”\n【译文】孔子说:“先学习礼乐而后做官的是未曾有过爵禄的一般人,先有了官位而后学习礼乐的是卿大夫的子弟。如果要我选用人才,我主张选用先学习礼乐的人。”\n【注释】⑴先进,后进——这两个术语的解释很多,都不恰当。译文本刘宝楠《论语正义》之说而略有取舍。孔子是主张“学而优则仕”的人,对于当时的卿大夫子弟,承袭父兄的庇荫,在做官中去学习的情况可能不满意。《孟子·告子下》引葵丘之会盟约说,“士无世官”,又说,“取士必得”,那么,孔子所谓“先进”一般指“士”。\n11.2子曰:“从我于陈、蔡⑴者,皆不及门⑵也。”\n【译文】孔子说:“跟着我在陈国、蔡国之间忍饥受饿的人,都不在我这里了。”\n【注释】⑴从我于陈、蔡——“从”读去声,zòng。《史记·孔子世家》云:“吴伐陈,楚救陈,军于城父。闻孔子在陈、蔡之间,楚使人聘孔子,孔子将往拜礼。陈、蔡大夫谋曰:‘孔子贤者,所刺讥皆中诸侯之疾,今者久留陈、蔡之间,诸大夫所设行皆非仲尼之意。今楚,大国也,来聘孔子。孔子用于楚,则陈、蔡用事大夫危矣。’乃相与发徒役围孔子于野。不得已,绝粮。从者病,莫能兴。……于是使子贡至楚。楚昭王兴师迎孔子,然后得免。”⑵不及门——汉唐旧解“不及门”为“不及仕进之门”或“不仕于卿大夫之门”,刘宝楠因而傅会孟子的“无上下之交”,解为“孔子弟子无仕陈蔡者”,我则终嫌与文意不甚密合,故不取,而用朱熹之说。郑珍《巢经巢文集》卷二〈驳朱竹垞孔子门人考〉有云:“古之教者家有塾,塾在门堂之左右,施教受业者居焉。所谓‘皆不及门’,及此门也。‘奚为于丘(原作某,由于避讳故,今改)之门’,于此门也。滕更之‘在门’,在此门也,故曰‘愿留而受业于门’(按上两句俱见《孟子》)。”亦见朱熹此说之有据。\n11.3德行:颜渊,闵子骞,冉伯牛,仲弓。言语:宰我,子贡。政事:冉有,季路。文学⑴:子游,子夏。\n【译文】[孔子的学生各有所长。]德行好的:颜渊,闵子骞,冉伯牛,仲弓。会说话的:宰我,子贡。能办理政事的:冉有,季路。熟悉古代文献的:子游,子夏。\n【注释】⑴文学——指古代文献,卽孔子所传的《诗》、《书》、《易》等。皇侃《义疏》引范宁说如此。《后汉书·徐防传》说:“防上疏云:‘经书礼乐,定自孔子;发明章句,始于子夏”。似亦可为证。又这一章和上一章“从我于陈蔡者”不相连。朱熹《四书集注》说这十人卽当在陈、蔡之时随行的人,是错误的。根据《左传》,冉有其时在鲁国为季氏之臣,未必随行。根据《史记·仲尼弟子列传》,当时随行的还有子张,何以这里不说及?根据各种史料,确知孔子在陈绝粮之时为鲁哀公四年,时孔子六十一岁。又据《史记·仲尼弟子列传》,子游小于孔子四十五岁,子夏小于孔子四十四岁,那么,孔子在陈、蔡受困时,子游不过十六岁,子夏不过十七岁,都不算成人。这么年幼的人卽使已经在孔子门下受业,也未必都跟去了。可见这几句话不过是孔子对这十个学生的一时的叙述,由弟子转述下来的记载而已。\n11.4子曰:“回也非助我者也,于吾言无所不说。”\n【译文】孔子说:“颜回不是对我有所帮助的人,他对我的话没有不喜欢的。”\n11.5子曰:“孝哉闵子骞!人不间于其父母昆弟之言。”\n【译文】孔子说:“闵子骞真是孝顺呀,别人对于他爹娘兄弟称赞他的言语并无异议。”\n11.6南容三复白圭⑴,孔子以其兄之子妻之。\n【译文】南容把“白圭之玷,尚可磨也;斯言之玷,不可为也”的几句诗读了又读,孔子便把自己的侄女嫁给他。\n【注释】⑴白圭——白圭的诗四句见于《诗经·大雅·抑篇》,意思是白圭的污点还可以磨掉;我们言语中的污点便没有办法去掉。大概南容是一个谨小慎微的人,所以能做到“邦有道,不废;邦无道,免于刑戮”。(5.2)\n11.7季康子问⑴:“弟子孰为好学?”孔子对曰:“有颜回者好学,不幸短命死矣,今也则亡。”\n【译文】季康子问道:“你学生中谁用功?”孔子答道:“有一个叫颜回的用功,不幸短命死了,现在就再没有这样的人了。”\n【注释】⑴季康子问——鲁哀公曾经也有此问(6.3),孔子的回答较为详细。有人说,从此可见孔子与鲁君的问答和与季氏的问答有繁简之不同。\n11.8颜渊死,颜路⑴请子之车以为之⑵椁⑶。子曰:“才不才,亦各言其子也。鲤⑷也死,有棺而无椁。吾不徒行以为之椁。以吾从大夫之后⑸,不可徒行也。”\n【译文】颜渊死了,他父亲颜路请求孔子卖掉车子来替颜渊办外椁。孔子道:“不管有才能或者没有才能,但总是自己的儿子。我的儿子鲤死了,也只有内棺,没有外椁。我不能[卖掉车子]步行来替他买椁。因为我也曾做过大夫,是不可以步行的。”\n【注释】⑴颜路——颜回的父亲,据《史记·仲尼弟子列传》,名无繇,字路,也是孔子学生。⑵之——用法同“其”。⑶椁——也作“椁”,音果,guǒ。古代大官棺木至少用两重,里面的一重叫棺,外面又一重大的叫椁,平常我们说“内棺外椁”就是这个意思。⑷鲤也死——鲤,字伯鱼,年五十死,那时孔子年七十。⑹从大夫之后——孔子在鲁国曾经做过司寇的官,是大夫之位。不过此时孔子已经去位多年。他不说“我曾为大夫”,而说“吾从大夫之后”(在大夫行列之后随行的意思)只是一种谦逊的口气罢了。\n11.9颜渊死。子曰:“噫!天丧予!天丧予⑴!”\n【译文】颜渊死了,孔子道:“咳!天老爷要我的命呀!天老爷要我的命呀!”\n【注释】⑴天丧予——译文只就字面译出。\n11.10颜渊死,子哭之恸⑴。从者曰:“子恸矣!”曰:“有恸乎?非夫人之为恸⑵而谁为?”\n【译文】颜渊死了,孔子哭得很伤心。跟着孔子的人道:“您太伤心了!”孔子道:“真的太伤心了吗?我不为这样的人伤心,还为什么人伤心呢!”\n【注释】⑴恸——郑注:“恸,变动容貌”。马融注:“恸,哀过也”。译文从马。⑵非夫人之为恸而谁为——“非夫人之为恸”是“非为夫人恸”的倒装形式。“夫人”的“夫”读阳平,音扶,指示形容词,“那”的意思。“之为”的“之”是专作帮助倒装用的,无实际意义。这一整句下文的“谁为”,依现代汉语的格式说也是倒装,不过在古代,如果介词或者动词的宾语是疑问代词,一般都放在介词或者动词之上。\n11.11颜渊死,门人欲厚葬⑴之。子曰:“不可。”门人厚葬之。子曰:“回也视予犹父也,予不得视犹子也。非我也,夫二三子也。”\n【译文】颜渊死了,孔子的学生们想要很丰厚地埋葬他。孔子道:“不可以。”学生们仍然很丰厚地埋葬了他。孔子道:“颜回呀,你看待我好像看待父亲,我却不能够像对待儿子一般地看待你。这不是我的主意呀,是你那班同学干的呀。”\n【注释】⑴厚葬——根据《檀弓》所记载孔子的话,丧葬应该“称家之有亡,有,毋过礼。苟亡矣,敛首足形,还葬,县棺而封。”颜子家中本穷,而用厚葬,从孔子看来,是不应该的。孔子的叹,实是责备那些主持厚葬的学生。\n11.12季路问事鬼神。子曰:“未能事人,焉能事鬼?”\n曰:“敢⑴问死。”曰:“未知生,焉知死?”\n【译文】子路问服事鬼神的方法。孔子道:“活人还不能服事,怎么能去服事死人?”\n子路又道:“我大胆地请问死是怎么回事。”孔子道:“生的道理还没有弄明白,怎么能够懂得死?”\n【注释】⑴敢——表敬副词,无实际意义。《仪礼·士虞礼》郑玄注云:“敢,冒昧之词。”贾公彦疏云:“凡言‘敢’者,皆是以卑触尊不自明之意。”\n11.13闵子侍侧,誾誾如也;子路,行行⑴如也;冉有、子贡,侃侃如也。子乐。“若由也,不得其死然⑵。”\n【译文】闵子骞站在孔子身旁,恭敬而正直的样子;子路很刚强的样子;冉有、子贡温和而快乐的样子。孔子高兴起来了。[不过,又道:]“像仲由吧,怕得不到好死。”\n【注释】⑴行行——旧读去声,hàng。⑵不得其死然——得死,当时俗语,谓得善终。《左传》僖公十九年“得死为幸”;哀公十六年“得死,乃非我”。然,语气词,用法同“焉”。\n11.14鲁人⑴为长府。闵子骞曰:“仍旧贯,如之何?何必改作?”子曰:“夫人不言,言必有中。”\n【译文】鲁国翻修叫长府的金库。闵子骞道:“照着老样子下去怎么样?为什么一定要翻造呢?”孔子道:“这个人平日不大开口,一开口一定中肯。”\n【注释】⑴鲁人——“鲁人”的“人”指其国的执政大臣而言。此“人”和“民”的区别。\n11.15子曰:“由之瑟⑴奚为于丘之门?”门人不敬子路。子曰:“由也升堂矣,未入于室⑵也。”\n【译文】孔子道:“仲由弹瑟,为什么在我这里来弹呢?”因此孔子的学生们瞧不起子路。孔子道:“由么,学问已经不错了,只是还不够精深罢了。”\n【注释】⑴瑟——音涩,sè,古代的乐器,和琴同类。这里孔子不是不高兴子路弹瑟,而是不高兴他所弹的音调。《说苑·修文篇》对这段文字曾有所发挥。⑵升堂入室——这是比喻话。“堂”是正厅,“室”是内室。先入门,次升堂,最后入室,表示做学问的几个阶段。“入室”犹如今天的俗语“到家”。我们说,“这个人的学问到家了”,正是表示他的学问极好。\n11.16子贡问:“师与商也孰贤?”子曰:“师也过,商也不及。”\n曰:“然则师愈与?”子曰:“过犹不及。”\n【译文】子贡问孔子:“颛孙师(子张)和卜商(子夏)两个人,谁强一些?”孔子道:“师呢,有些过分;商呢,有些赶不上。”子贡道:“那么,师强一些吗?”孔子道:“过分和赶不上同样不好。”\n11.17季氏富于周公⑴,而求也为之聚敛而附益之⑵。子曰:“非吾徒也。小子鸣鼓而攻之,可也。”\n【译文】季氏比周公还有钱,冉求却又替他搜括,增加更多的财富。孔子道:“冉求不是我们的人,你们学生很可以大张旗鼓地来攻击他。”\n【注释】⑴周公——有两说:(甲)周公旦;(乙)泛指在周天子左右作卿士的人,如周公黑肩、周公阅之类。⑵聚敛而附益之——事实可参阅《左传》哀公十一年和十二年文。季氏要用田赋制度,增加赋税,使冉求征求孔子的意见,孔子则主张“施取其厚,事举其中,敛从其薄”。结果冉求仍旧听从季氏,实行田赋制度。聚敛,《礼记·大学》说:“百乘之家,不畜聚敛之臣。与其有聚敛之臣,宁有盗臣。”可见儒家为了维护统治,反对对人民的过分剥削。其思想渊源或者本于此章。\n11.18柴⑴也愚,参也鲁,师也辟⑵,由也喭。\n【译文】高柴愚笨,曾参迟钝,颛孙师偏激,仲由卤莽。\n【注释】⑴柴——高柴,字子羔,孔子学生,比孔子小三十岁(公元前521—?)。⑵辟——音辟,pì。黄式三《论语后案》云:“辟读若《左传》‘阙西辟’之辟,偏也。以其志过高而流于一偏也”。\n11.19子曰:“回也其庶⑴乎,屡空⑵。赐不受命⑶,而货殖焉,亿则屡中。”\n【译文】孔子说:“颜回的学问道德差不多了罢,可是常常穷得没有办法。端木赐不安本分,去囤积投机,猜测行情,竟每每猜对了。”\n【注释】⑴庶——庶几,差不多。一般用在称赞的场合。⑵空——世俗把“空”字读去声,不但无根据,也无此必要。“贫”和“穷”两字在古代有时有些区别,财货的缺少叫贫,生活无着落,前途无出路叫穷。“空”字却兼有这两方面的意思,所以用“穷得没有办法”来译它。⑶赐不受命——此语古今颇有不同解释,关键在于“命”字的涵义。有把“命”解为“教命”的,则“不受命”为“不率教”,其为错误甚明显。王弼、江熙把“命”解为“爵命”“禄命”,则“不受命”为“不做官”,自然很讲得通,可是子贡并不是不曾做官。《史记·仲尼弟子列传》说他“常相鲁卫”,《货殖列传》又说他“既学于仲尼,退而仕于卫,废着鬻财于曹鲁之间”,则子贡的经商和做官是不相先后的。那么,这一说既不合事实,也就不合孔子原意了。又有人把“命”讲为“天命”(皇《疏》引或说,朱熹《集注》),俞樾《羣经平议》则以为古之经商皆受命于官,“若夫不受命于官而自以其财市贱鬻贵,逐什一之利,是谓不受命而货殖。”两说皆言之成理,而未知孰是,故译文仅以“不安本分”言之。\n11.20子张问善人之道。子曰:“不践迹,亦不入于室⑴。”\n【译文】子张问怎样才是善人。孔子道:“善人不踩着别人的脚印走,学问道德也难以到家。”\n【注释】⑴善人——孔子曾三次论到“善人”,这章可和(7.26)(13.11)两章合看。\n11.21子曰:“论笃是与⑴,君子者乎?色庄者乎?”\n【译文】孔子说:“总是推许言论笃实的人,这种笃实的人是真正的君子呢?还是神情上伪装庄重的人呢?”\n【注释】⑴论笃是与——这是“与论笃”的倒装形式,“是”是帮助倒装之用的词,和“唯你是问”的“是”用法相同。“与”,许也。“论笃”就是“论笃者”的意思。\n11.22子路问:“闻斯行诸?”子曰:“有父兄在,如之何其闻斯行之?”\n冉有问:“闻斯行诸?”子曰:“闻斯行之。”\n公西华曰:“由也问闻斯行诸,子曰,‘有父兄在’,求也问闻斯行诸,子曰,‘闻斯行之’。赤也惑,敢问。”子曰:“求也退,故进之;由也兼人⑴,故退之。”\n【译文】子路问:“听到就干起来吗?”孔子道:“有爸爸哥哥活着,怎么能听到就干起来?”\n冉有问:“听到就干起来吗?”孔子道:“听到就干起来。”\n公西华道:“仲由问听到就干起来吗,您说‘有爸爸哥哥活着,[不能这样做;]’冉求问听到就干起来吗,您说‘听到就干起来。’[两个人问题相同,而您的答复相反,]我有些胡涂,大胆地来问问。”\n孔子道:“冉求平日做事退缩,所以我给他壮胆;仲由的胆量却有两个人的大,勇于作为,所以我要压压他。”\n【注释】⑴兼人——孔安国和朱熹都把“兼人”解为“胜人”,但子路虽勇,未必“务在胜尚人”;反不如张敬夫把“兼人”解为“勇为”为适当。\n11.23子畏于匡,颜渊后。子曰:“吾以女为死矣。”曰:“子在,回何敢死?”\n【译文】孔子在匡被囚禁了之后,颜渊最后才来。孔子道:“我以为你是死了。”颜渊道:“您还活着,我怎么敢死呢?”\n11.24季子然⑴问:“仲由、冉求可谓大臣与?”子曰:“吾以子为异之问,曾由与求之问。所谓大臣者,以道事君,不可则止。今由与求也,可谓具臣矣⑵。”\n曰:“然则从之者与?”子曰:“弑父与君,亦不从也。”\n【译文】季子然问:“仲由和冉求可以说是大臣吗”孔子道:“我以为你是问别的人,竟问由和求呀。我们所说的大臣,他用最合于仁义的内容和方式来对待君主,如果这样行不通,宁肯辞职不干。如今由和求这两个人,可以说是具有相当才能的臣属了。”\n季子然又道:“那么,他们会一切顺从上级吗?”孔子道:“杀父亲、杀君主的事情,他们也不会顺从的。”\n【注释】⑴季子然——当为季氏的同族之人,《史记·仲尼弟子列传》作“季孙问曰:子路可谓大臣与”,与《论语》稍异。⑵这一章可以和孔子不以仁来许他们的一章(5.8)以及季氏旅泰山冉有不救章(3.6)、季氏伐颛臾冉有子路为他解脱章(16.1)合看。\n11.25子路使子羔为费宰。子曰:“贼夫人之子。”\n子路曰:“有民人焉,有社禝焉,何必读书,然后为学?”\n子曰:“是故恶夫佞者。”\n【译文】子路叫子羔去做费县县长。孔子道:“这是害了别人的儿子!”\n子路道:“那地方有老百姓,有土地和五谷,为什么定要读书才叫做学问呢?”\n孔子道:“所以我讨厌强嘴利舌的人。”\n11.26子路、曾晳⑴、冉有、公西华侍坐。\n子曰:“以吾一日长乎尔,毋吾以也。居⑵则曰:‘不吾知也!’如或知尔,则何以哉?”\n子路率尔而对曰“千乘之国,摄乎大国之间,加之以师旅,因之以饥馑;由也为之,比⑶及三年,可使有勇,且知方也。”\n夫子哂之。\n“求!尔何如?”\n对曰:“方六七十⑷,如⑸五六十,求也为之,比⑶及三年,可使足民。如其礼乐,以俟君子。”\n“赤!尔何如?”\n对曰:“非曰能之,愿学焉。宗庙之事,如会同,端章甫⑹,愿为小相⑺焉。”\n“点!尔何如?”\n鼓瑟希,铿尔,舍瑟而作⑻,对曰:“异乎三子者之撰。”\n子曰:“何伤乎?亦各言其志也。”\n曰:“莫⑼春者,春服既成⑽,冠者五六人,童子六七人,浴乎沂⑾,风乎舞雩⑿,咏而归。”\n夫子喟然叹曰:“吾与点也!”\n三子者出,曾晳后。曾晳曰:“夫三子者之言何如?”\n子曰:“亦各言其志也已矣。”\n曰:“夫子何哂由也?”\n曰:“为国以礼,其言不让,是故哂之。”\n“唯⒀求则非邦也与?”\n“安见方六七十如五六十而非邦也者?”\n“唯赤则非邦也与?”\n“宗庙会同,非诸侯而何?赤也为之⒁小,孰能为之⒁大?”\n【译文】子路、曾晳、冉有、公西华四个人陪着孔子坐着。孔子说道:“因为我比你们年纪都大,[老了,]没有人用我了。你们平日说:‘人家不了解我呀!’假若有人了解你们,[打算请你们出去,]那你们怎么办呢?”\n子路不加思索地答道:“一千辆兵车的国家,局促地处于几个大国的中间,外面有军队侵犯它,国内又加以灾荒。我去治理,等到三年光景,可以使人人有勇气,而且懂得大道理。”\n孔子微微一笑。\n又问:“冉求,你怎么样?”\n答道:“国土纵横各六七十里或者五六十里的小国家,我去治理,等到三年光景,可以使人人富足。至于修明礼乐,那只有等待贤人君子了。”\n又问:“公西赤!你怎么样?”\n答道:“不是说我已经很有本领了,我愿意这样学习:祭祀的工作或者同外国盟会,我愿意穿着礼服,戴着礼帽,做一个小司仪者。”\n又问:“曾点!你怎么样?”\n他弹瑟正近尾声,铿的一声把瑟放下,站了起来答道:“我的志向和他们三位所讲的不同。”\n孔子道:“那有什么妨碍呢?正是要各人说出自己的志向呵!”\n曾晳便道:“暮春三月,春天衣服都穿定了,我陪同五六位成年人,六七个小孩,在沂水旁边洗洗澡,在舞雩台上吹吹风,一路唱歌,一路走回来。”\n孔子长叹一声道:“我同意曾点的主张呀!”子路、冉有、公西华三人都出来了,曾晳后走。曾晳问道:“那三位同学的话怎样?”\n孔子道:“也不过各人说说自己的志向罢了。”\n曾晳又道:“您为什么对仲由微笑呢?”\n孔子道:“治理国家应该讲求礼让,可是他的话却一点不谦虚,所以笑笑他。”\n“难道冉求所讲的就不是国家吗?”\n孔子道:“怎样见得横纵各六七十里或者五六十里的土地就不够是一个国家呢?”\n“公西赤所讲的不是国家吗?”\n孔子道:“有宗庙,有国际间的盟会,不是国家是什么?[我笑仲由的不是说他不能治理国家,关键不在是不是国家,而是笑他说话的内容和态度不够谦虚。譬如公西赤,他是个十分懂得礼仪的人,但他只说愿意学着做一个小司仪者。]如果他只做一小司仪者,又有谁来做大司仪者呢?”\n【注释】⑴曾晳——名点,曾参的父亲,也是孔子的学生。⑵居——义与唐、宋人口语“平居”同,平日、平常的意思。⑶比——去声,bì,等到的意思。⑷方六七十——这是古代的土地面积计算方式,“方六七十”不等于“六七十方里”,而是每边长六七十里的意思。⑸如——或者的意思。⑹端章甫——端,古代礼服之名;章甫,古代礼帽之名。“端章甫”为修饰句,在古代可以不用动词。⑺相——去声,名词,赞礼之人。⑻舍瑟而作——作,站起来的意思。曾点答孔子之问站了起来,其它学生也同样站了起来可以推知,不过上文未曾明说罢了。⑼莫——同“暮”。⑽成——定也。《国语·吴语》:“吴晋争长未成”,就是争为盟主而未定的意思。⑾沂——水名,但和大沂河以及流入于大沂河的小沂河都不同。这沂水源出山东邹县东北,西流经曲阜与洙水合,入于泗水。也就是《左传》昭公二十五年“季平子请待于沂上”的“沂”。⑿舞雩——《水经注》:“沂水北对稷门,一名高门,一名雩门。南隔水有雩坛,坛高三丈。卽曾点所欲风处也。”当在今曲阜县南。⒀唯——语首词,无义。⒁之——用法同“其”。\n"},{"id":111,"href":"/zh/docs/culture/%E8%AE%BA%E8%AF%AD/%E8%AE%BA%E8%AF%AD%E8%AF%91%E6%B3%A8_%E6%9D%A8%E4%BC%AF%E5%B3%BB/18%E5%BE%AE%E5%AD%90%E7%AF%87%E7%AC%AC%E5%8D%81%E5%85%AB/","title":"18微子篇第十八","section":"论语译注 杨伯峻","content":" 微子篇第十八 # (共十一章)\n18.1微子⑴去之,箕子为之奴⑵,比干谏而死⑶。孔子曰:“殷有三仁焉。”\n【译文】[纣王昏乱残暴,]微子便离开了他,箕子做了他的奴隶,比干谏劝而被杀。孔子说:“殷商末年有三位仁人。”\n【注释】⑴微子——名启,纣王的同母兄,不过当他出生时,他的母亲尚为帝乙之妾,其后才立为妻,然后生了纣,所以帝乙死后,纣得嗣立,而微子不得立。事见《吕氏春秋·仲冬纪》。古书中唯《孟子·告子篇》认为微子是纣的叔父。⑵箕子为之奴——箕子,纣王的叔父。纣王无道,他曾进谏而不听,便披发佯狂,降为奴隶。⑶比干谏而死——比干也是纣的叔父,力谏纣王,纣王说,我听说圣人的心有七个孔,便剖开他的心而死。\n18.2柳下惠为士师,三黜。人曰:“子未可以去乎?”曰:“直道而事人,焉往而不三黜?枉道而事人,何必去父母之邦?”\n【译文】柳下惠做法官,多次地被撤职。有人对他说:“您不可以离开鲁国吗?”他道:“正直地工作,到哪里去不多次被撤职?不正直地工作,为什么一定要离开祖国呢?”\n18.3齐景公待孔子曰:“若季氏,则吾不能;以季孟之间待之。”曰:“吾老矣,不能用也。”孔子行。\n【译文】齐景公讲到对待孔子的打算时说:“用鲁君对待季氏的模样对待孔子,那我做不到;我要用次于季氏而高于孟氏的待遇来对待他。”不久,又说道:“我老了,没有什么作为了。”孔子离开了齐国。\n18.4齐人归女乐⑴,季桓子⑵受之,三日不朝,孔子行。\n【译文】齐国送了许多歌姬舞女给鲁国,季桓子接受了,三天不问政事,孔子就离职走了。\n【注释】⑴齐人归女乐——“归”同“馈”。此事可参阅《史记·孔子世家》和《韩非子·内储说》。⑵季桓子——季孙斯,鲁国定公以至哀公初年时的执政上卿,死于哀公三年。\n18.5楚狂接舆⑴歌而过孔子曰:“凤兮凤兮!何德之衰?往者不可谏,来者犹可追⑵。已而,已而!今之从政者殆而!”\n孔子下,欲与之言。趋而辟之,不得与之言。\n【译文】楚国的狂人接舆一面走过孔子的车子,一面唱着歌,道:“凤凰呀,凤凰呀!为什么这么倒霉?过去的不能再挽回,未来的还可不再着迷。算了吧,算了吧!现在的执政诸公危乎其危!”\n孔子下车,想同他谈谈,他却赶快避开,孔子没法同他谈。\n【注释】⑴接舆——曹之升《四书摭余说》云:“《论语》所记隐士皆以其事名之。门者谓之‘晨门’,杖者谓之‘丈人’,津者谓之‘沮’、‘溺’,接孔子之舆者谓之‘接舆’,非名亦非字也。”⑵犹可追——赶得上、来得及的意思,译文因图押韵,故用意译法。\n18.6长沮、桀溺耦而耕⑴,孔子过之,使子路问津焉。\n长沮曰:“夫执舆⑵者为谁?”\n子路曰:“为孔丘。”\n曰:“是鲁孔丘与?”\n曰:“是也。”\n曰:“是知津矣。”\n问于桀溺。\n桀溺曰:“子为谁?”\n曰:“为仲由。”\n曰:“是鲁孔丘之徒与?”\n对曰:“然。”\n曰:“滔滔者天下皆是也,而谁以⑶易之?且而⑷与其从辟⑸人之士也,岂若从辟世之士哉?”耰⑹而不辍。\n子路行以告。\n夫子怃⑺然曰:“鸟兽不可与同羣,吾非斯人之徒与而谁与?天下有道,丘不与易也。”\n【译文】长沮、桀溺两人一同耕田,孔子在那儿经过,叫子路去问渡口。\n长沮问子路道:“那位驾车子的是谁?”\n子路道:“是孔丘。”\n他又道:“是鲁国的那位孔丘吗?”\n子路道:“是的。”\n他便道:“他么,早晓得渡口在哪儿了。”\n去问桀溺。\n桀溺道:“您是谁?”\n子路道:“我是仲由。”\n桀溺道:“您是鲁国孔丘的门徒吗?”\n答道:“对的。”\n他便道:“像洪水一样的坏东西到处都是,你们同谁去改革它呢?你与其跟着[孔丘那种]逃避坏人的人,为什么不跟着[我们这些]逃避整个社会的人呢?”说完,仍旧不停地做田里工夫。\n子路回来报告给孔子。\n孔子很失望地道:“我们既然不可以同飞禽走兽合羣共处,若不同人羣打交道,又同什么去打交道呢?如果天下太平,我就不会同你们一道来从事改革了。”\n【注释】⑴长沮、桀溺耦而耕——“长溺”“桀溺”不是真姓名。其姓名当时已经不暇询问,后世更无由知道了。耦耕是古代耕田的一种方法。春秋时代已经用牛耕田,不但由冉耕字伯牛、司马耕字子牛的现象可以看出,《国语·晋语》云:“其子孙将耕于齐,宗庙之牺为畎亩之勤”,尤为确证。耦耕的方法说法不少,都难说很精确。下文又说“耰而不辍”,则这耦耕未必是执耒,像夏炘学《礼管释·释二耜为耦》所说的。估计这个耦耕不过说二人做庄稼活罢了。1959年科学出版社《农史研究集刊》万国钧〈耦耕考〉对此有解释。上海中华书局《中华文史论丛》第三辑何兹全〈谈耦耕〉对万说有补充,也只能作参考。⑵执舆——就是执辔(拉马的缰绳)。本是子路做的,因子路已下车,所以孔子代为驾御。⑶以——与也,和下文“不可与同羣”,“斯人之徒与而谁与”,“丘不与易也”诸“与”字同义。⑷而——同“尔”。⑸辟——同“避”。⑹耰——音忧,yōu,播种之后,再以土覆之,摩而平之,使种入土,鸟不能啄,这便叫耰。⑺怃——音舞。wǔ,怃然,怅惘失意之貌。\n18.7子路从而后,遇丈人,以杖荷筱⑴。\n子路问曰:“子见夫子乎?”\n丈人曰:“四体不勤,五谷不分⑵。孰为夫子?”植其杖而芸。\n子路拱而立。\n止子路宿,杀鸡为黍⑶而食之,见其二子焉。\n明日,子路行以告。\n子曰:“隐者也。”使子路反见之。至,则行矣。\n子路曰:“不仕无义。长幼之节,不可废也;君臣之义,如之何其废之?欲洁其身,而乱大伦。君子之仕也,行其义也。道之不行,已知之矣。”\n【译文】子路跟随着孔子,却远落在后面,碰到一个老头,用拐杖挑着除草用的工具。\n子路问道:“您看见我的老师吗?”\n老头道:“你这人,四肢不劳动,五谷不认识,谁晓得你的老师是什么人?”说完,便扶着拐杖去锄草。\n子路拱着手恭敬地站着。\n他便留子路到他家住宿,杀鸡、作饭给子路吃,又叫他两个儿子出来相见。\n第二天,子路赶上了孔子,报告了这件事。\n孔子道:“这是位隐士。”叫子路回去再看看他。子路到了那里,他却走开了。\n子路便道:“不做官是不对的。长幼间的关系,是不可能废弃的;君臣间的关系,怎么能不管呢?你原想不沾污自身,却不知道这样隐居便是忽视了君臣间的必要关系。君子出来做官,只是尽应尽之责。至于我们的政治主张行不通,早就知道了。”\n【注释】⑴筱——音掉,diào,古代除田中草所用的工具。说文作“莜”。⑵四体不勤,五谷不分——这二句,宋吕本中《紫微杂说》以至清朱彬《经传考证》、宋翔凤《论语发微》都说是丈人说自己。其余更多人主张说是丈人责子路。译文从后说。⑶为黍——黍就是现在的黍子,也叫黄米。它比当时的主要食粮稷(小米)的收获量小,因此在一般人中也算是比较珍贵的主食。杀鸡做菜,为黍做饭,这在当时是很好的招待了。\n18.8逸⑴民:伯夷、叔齐、虞仲、夷逸、朱张、柳下惠、少连⑵。子曰:“不降其志,不辱其身,伯夷、叔齐与!”谓“柳下惠、少连,降志辱身矣,言中伦,行中虑,其斯而已矣。”谓“虞仲、夷逸,隐居放言,身中清,废中权。我则异于是,无可无不可。”\n【译文】古今被遗落的人才有伯夷、叔齐、虞仲、夷逸、朱张、柳下惠、少连。孔子道:“不动摇自己意志,不辱没自己身份,是伯夷、叔齐罢!”又说,“柳下惠、少连降低自己意志,屈辱自己身份了,可是言语合乎法度,行为经过思虑,那也不过如此罢了。”又说:“虞仲、夷逸逃世隐居,放肆直言。行为廉洁,被废弃也是他的权术。我就和他们这些人不同,没有什么可以,也没有什么不可以。”\n【注释】⑴逸——同“佚”,《论语》两用“逸民”,义都如此。《孟子·公孙丑上》云:“柳下惠……遗佚而不怨,阨穷而不闵。”这一“逸”正是《孟子》“遗佚”之义。说本黄式三《论语后案》。⑵虞仲、夷逸、朱张、少连——四人言行多已不可考。虞仲前人认为就是吴太伯之弟仲雍,不可信。夷逸曾见《尸子》,有人劝他做官,他不肯。少连曾见《礼记·杂记》,孔子说他善于守孝。夏炘《景紫堂文集》卷三有〈逸民虞仲、夷逸、朱张皆无考说〉,于若干附会之说有所驳正。\n18.9大师挚⑴适齐,亚饭干适楚,三饭缭适蔡,四饭缺适秦⑵,鼓方叔入于河,播鼗武入于汉,少师阳、击磬襄入于海。\n【译文】太师挚逃到了齐国,二饭乐师干逃到了楚国,三饭乐师缭逃到了蔡国,四饭乐师缺逃到了秦国,打鼓的方叔入居黄河之滨,摇小鼓的武入居汉水之涯,少师阳和击磬的襄入居海边。\n【注释】⑴大师挚——泰伯篇第八有“师挚之始”,不知是不是此人。⑵亚饭——古代天子诸侯用饭都得奏乐,所以乐官有“亚饭”、“三饭”、“四饭”之名。这些人究竟是何时人,已经无法肯定。\n18.10周公谓鲁公⑴曰:“君子不施⑵其亲,不使大臣怨乎不以。故旧无大故,则不弃也。无求备于一人!”\n【译文】周公对鲁公说道:“君子不怠慢他的亲族,不让大臣抱怨没被信用。老臣故人没有发生严重过失,就不要抛弃他。不要对某一人求全责备!”\n【注释】⑴周公、鲁公——周公,周公旦,孔子心目中的圣人。鲁公是他的儿子伯禽。⑵施——同“弛”,有些本子卽作“弛”。\n18.11周有八士:伯达、伯适、仲突、仲忽、叔夜、叔夏、季随、季騧⑴。\n【译文】周朝有八个有教养的人:伯达、伯适、仲突、仲忽、叔夜、叔夏、季随、季騧。\n【注释】⑴伯达等八人——此八人已经无可考。前人看见此八人两人一列,依伯、仲、叔、季排列,而且各自押韵(达适一韵,突忽一韵,夜夏一韵,随騧一韵),便说这是四对双生子。\n"},{"id":112,"href":"/zh/docs/culture/%E8%AE%BA%E8%AF%AD/%E8%AE%BA%E8%AF%AD%E8%AF%91%E6%B3%A8_%E6%9D%A8%E4%BC%AF%E5%B3%BB/02%E4%B8%BA%E6%94%BF%E7%AF%87%E7%AC%AC%E4%BA%8C/","title":"02为政篇第二","section":"论语译注 杨伯峻","content":" 为政篇第二 # (共二十四章)\n2.1子曰:“为政以德,譬如北辰⑴居其所而众星共⑵之。”\n【译文】孔子说:“用道德来治理国政,自己便会像北极星一般,在一定的位置上,别的星辰都环绕着它。”\n【注释】⑴北辰——由于地球自转轴正对天球北极,在地球自转和公转所反映出来的恒星周日和周年视运动中,天球北极是不动的,其它恒星则绕之旋转。我国黄河中、下游流域,约为北纬36度,因之天球北极也高出北方地平线上36度。孔子所说的北辰,不是指天球北极,而是指北极星。天球北极虽然不动,其它星辰都环绕着它动,但北极星也是动的,而且转动非常快。祗是因为它距离地球太远,约782光年,人们不觉得它移动罢了。距今四千年前北极在右枢(天龙座α)附近,今年则在勾陈一(小熊座α)。⑵共——同拱,与《左传》僖公三十二年“尔墓之木拱矣”的“拱”意义相近,环抱、环绕之意。\n2.2子曰:“诗三百⑴,一言以蔽之,曰:‘思无邪⑵’。”\n【译文】孔子说:“《诗经》三百篇,用一句话来概括它,就是‘思想纯正’。”\n【注释】⑴诗三百——《诗经》实有三百五篇,“三百”只是举其整数。⑵思无邪——“思无邪”一语本是《诗经·鲁颂·駉篇》之文,孔子借它来评论所有诗篇。思字在《駉篇》本是无义的语首词,孔子引用它却当思想解,自是断章取义。俞樾《曲园杂纂》说项说这也是语辞,恐不合孔子原意。\n2.3子曰:“道⑴之以政,齐之以刑,民免⑵而无耻;道之以德,齐之以礼,有耻且格⑶。”\n【译文】孔子说:“用政法来诱导他们,使用刑罚来整顿他们,人民只是暂时地免于罪过,却没有廉耻之心。如果用道德来诱导他们,使用礼教来整顿他们,人民不但有廉耻之心,而且人心归服。”\n【注释】⑴道——有人把它看成“道千乘之国”的“道”一样,治理的意思。也有人把它看成“导”字,引导的意思,我取后一说。⑵免——先秦古书若单用一个“免”字,一般都是“免罪”、“免刑”、“免祸”的意思。⑶格——这个字的意义本来很多,在这里有把它解为“来”的,也有解为“至”的,还有解为“正”的,更有写作“恪”,解为“敬”的。这些不同的讲解都未必符合孔子原意。《礼记·缁衣篇》:“夫民,教之以德,齐之以礼,则民有格心;教之以政,齐之以刑,则民有遯心。”这话可以看作孔子此言的最早注释,较为可信。此处“格心”和“遯心”相对成文,“遯”卽“遁”字,逃避的意思。逃避的反面应该是亲近、归服、向往,所以用“人心归服”来译它。\n2.4子曰:“吾十有⑴五而志于学,三十而立⑵,四十而不惑⑶,五十而知天命⑷,六十而耳顺⑸,七十而从心所欲,不踰矩⑹。”\n【译文】孔子说:“我十五岁,有志于学问;三十岁,[懂礼仪,]说话做事都有把握;四十岁,[掌握了各种知识,]不致迷惑;五十岁,得知天命;六十岁,一听别人言语,便可以分别真假,判明是非;到了七十岁,便随心所欲,任何念头不越出规矩。”\n【注释】⑴有——同又。古人在整数和小一位的数字之间多用“有”字,不用“又”字。⑵立——泰伯篇说:“立于礼。”季氏篇又说:“不学礼,无以立。”因之译文添了“懂得礼仪”几个字。“立”是站立的意思,这里是“站得住”的意思,为求上下文的流畅,意译为遇事“都有把握”。⑶不惑——子罕篇和宪问篇都有“知者不惑”的话,所以译文用“掌握了知识”来说明“不惑”。⑷天命——孔子不是宿命论者,但也讲天命。孔子的天命,我已有文探讨。后来的人虽然谈得很多,未必符合孔子本意。因此,这两个字暂不译出。⑸耳顺——这两个字很难讲,企图把它讲通的也有很多人,但都觉牵强。译者姑且作如此讲解。⑷从心所欲不踰矩——“从”字有作“纵”字的,皇侃《义疏》也读为“纵”,解为放纵。柳宗元〈与杨晦之书〉说“孔子七十而纵心”,不但“从”字写作“纵”,而且以“心”字绝句,“所欲”属下读。“七十而纵心,所欲不踰矩”。但“纵”字古人多用于贬义,如《左传》昭公十年“我实纵欲”,柳读难从。\n2.5孟懿子⑴问孝。子曰:“无违⑵。”\n樊迟⑶御,子告之曰:“孟孙问孝于我,我对曰,无违。”樊迟曰:“何谓也?”子曰:“生,事之以礼⑷;死,葬之以礼,祭之以礼。”\n【译文】孟懿子向孔子问孝道。孔子说:“不要违背礼节。”不久,樊迟替孔子赶车子,孔子便告诉他说:“孟孙向我问孝道,我答复说,不要违背礼节。”樊迟道:“这是什么意思?”孔子道:“父母活着,依规定的礼节侍奉他们;死了,依规定的礼节埋葬他们,祭祀他们。”\n【注释】⑴孟懿子——鲁国的大夫,三家之一,姓仲孙,名何忌,“懿”是谥号。他父亲是孟僖子仲孙貜。《左传》昭公七年说,孟僖子将死,遗嘱要他向孔子学礼。⑵无违——黄式三《论语后案》说:“《左传》桓公二年云,‘昭德塞违’,‘灭德立违’,‘君违,不忘谏之以德’;六年传云:‘有嘉德而无违心’,襄公二十六年传云,‘正其违而治其烦’……古人凡背礼者谓之违。”因此,我把“违”译为“违礼”。王充《论衡·问孔篇》曾经质问孔子,为什么不讲“无违礼”,而故意省略讲为“无违”,难道不怕人误会为“毋违志”吗?由此可见“违”字的这一含义在后汉时已经不被人所了解了。⑶樊迟——孔子学生,名须,字子迟,比孔子小四十六岁。[《史记·仲尼弟子列传》作小三十六岁,《孔子家语》作小四十六岁。若从《左传》哀公十一年所记载的樊迟的事考之,可能《史记》的“三”系“亖”(古四字)之误。]⑷生,事之以礼——“生”和下句“死”都是表示时间的节缩语,所以自成一逗。古代的礼仪有一定的差等,天子、诸侯、大夫、士、庶人各不相同。鲁国的三家是大夫,不但有时用鲁公(诸侯)之礼,甚至有时用天子之礼。这种行为当时叫做“僭”,是孔子所最痛心的。孔子这几句答语,或者是针对这一现象发出的。\n2.6孟武伯⑴问孝。子曰:“父母唯其⑵疾之忧。”\n【译文】孟武伯向孔子请教孝道。孔子道:“做爹娘的只是为孝子的疾病发愁。”\n【注释】⑴孟武伯——仲孙彘,孟懿子的儿子,“武”是谥号。⑵其——第三人称表示领位的代名词,相当于“他的”、“他们的”。但这里所指代的是父母呢,还是儿女呢?便有两说。王充《论衡·问孔篇》说:“武伯善忧父母,故曰,唯其疾之忧。”《淮南子·说林训》说:“忧父之疾者子,治之者医。”高诱注云:“父母唯其疾之忧,故曰忧之者子。”可见王充、高诱都以为“其”字是指代父母而言。马融却说:“言孝子不妄为非,唯疾病然后使父母忧。”把“其”字代孝子。两说都可通,而译文采取马融之说。\n2.7子游⑴问孝。子曰:“今之孝者,是谓能养⑵。至于⑶犬马,皆能有养⑷;不敬,何以别乎?”\n【译文】子游问孝道。孔子说:“现在的所谓孝,就是说能够养活爹娘便行了。对于狗马都能够得到饲养;若不存心严肃地孝顺父母,那养活爹娘和饲养狗马怎样去分别呢?”\n【注释】⑴子游——孔子学生,姓言,名偃,字子游,吴人,小于孔子四十五岁。⑵养——“养父母”的“养”从前人都读去声,音漾,yàng。⑶至于——张相的《诗词曲语词汇释》把“至于”解作“卽使”、“就是”。在这一段中固然能够讲得文从字顺,可是“至于”的这一种用法,在先秦古书中仅此一见,还难于据以肯定。我认为这一“至于”和《孟子·告子上》的“惟耳亦然。至于声,天下期于师旷,是天下之耳相似也。惟目亦然。至于子都,天下莫不知其姣也。”的“至于”用法相似。都可用“谈到”、“讲到”来译它。不译也可。⑷至于犬马皆能有养——这一句很有些不同的讲法。一说是犬马也能养活人,人养活人,若不加以敬,便和犬马的养活人无所分别。这一说也通。还有一说是犬马也能养活它自己的爹娘(李光地《论语剳记》、翟灏《四书考异》),可是犬马在事实上是不能够养活自己爹娘的,所以这说不可信。还有人说,犬马是比喻小人之词(刘宝楠《论语正义》引刘宝树说),可是用这种比喻的修辞法,在《论语》中找不出第二个相似的例子,和《论语》的文章风格不相侔,更不足信。\n2.8子夏问孝。子曰:“色难⑴。有事,弟子⑵服其劳;有酒食⑶,先生馔⑷,曾⑸是以为孝乎?”\n【译文】子夏问孝道。孔子道:“儿子在父母前经常有愉悦的容色,是件难事。有事情,年轻人效劳;有酒有肴,年长的人吃喝,难道这竟可认为是孝么?”\n【注释】⑴色难——这句话有两说,一说是儿子侍奉父母时的容色。《礼记·祭义篇》说:“孝子之有深爱者必有和气,有和气者必有愉色,有愉色者必有婉容。”可以做这两个字的注脚。另一说是侍奉父母的容色,后汉的经学家包咸、马融都如此说。但是,若原意果如此的话,应该说为“侍色为难”,不该简单地说为“色难”,因之我不采取。⑵弟子、先生——刘台拱《论语骈枝》云:“《论语》言‘弟子’者七,其二皆年幼者,其五谓门人。言‘先生’者二、皆谓年长者。”马融说:“先生谓父兄也。”亦通。⑶食——旧读去声,音嗣,sì,食物。不过现在仍如字读shí,如“主食”、“副食”、“面食”。⑷馔——zhuàn,吃喝。《鲁论》作“馂”。馂,食余也。那么这句便当如此读:“有酒,食先生馂”,而如此翻译:“有酒,幼辈吃其剩余。”⑸曾——音层,céng,副词,竟也。\n2.9子曰:“吾与回⑴言终日,不违,如愚。退而省其私⑵,亦足以发,回也不愚。”\n【译文】孔子说:“我整天和颜回讲学,他从不提反对意见和疑问,像个蠢人。等他退回去自己研究,却也能发挥,可见颜回并不愚蠢。”\n【注释】⑴回——颜回,孔子最得意的学生,鲁国人,字子渊,小孔子三十岁(《史记·仲尼弟子列传》如此。但根据毛奇龄《论语稽求篇》和崔适《论语足征记》的考证,《史记》的“三十”应为“四十”之误,颜渊实比孔子小四十岁,公元前511—480)。⑵退而省其私——朱熹的《集注》以为孔子退而省颜回的私,“则见其日用动静语默之间皆足以发明夫子之道。”用颜回的实践来证明他能发挥孔子之道,说也可通。\n2.10子曰:“视其所以⑴,观其所由⑵,察其所安⑶。人焉廋哉⑷?人焉廋哉?”\n【译文】孔子说:“考查一个人所结交的朋友;观察他为达到一定目的所采用的方式方法;了解他的心情,安于什么,不安于什么。那么,这个人怎样隐藏得住呢?这个人怎样隐藏得住呢?”\n【注释】⑴所以——“以”字可以当“用”讲,也可以当“与”讲。如果解释为“用”,便和下句“所由”的意思重复,因此我把它解释为“与”,和微子篇第十八“而谁以易之”的“以”同义。有人说“以犹为也”。“视其所以”卽《大戴礼·文王官人篇》的“考其所为”,也通。⑵所由——“由”,“由此行”的意思。学而篇第一的“小大由之”,雍也篇第六的“行不由径”,泰伯篇第八的“民可使由之”的“由”都如此解。“所由”是指所从由的道路,因此我用方式方法来译述。⑶所安——“安”就是阳货篇第十七孔子对宰予说的“女安,则为之”的“安”。一个人未尝不错做一两件坏事,如果因此而心不安,仍不失为好人。因之译文多说了几句。⑷人焉廋哉——焉,何处;廋,音搜,sōu,隐藏,藏匿。这句话机械地翻译,便是:“这个人到哪里去隐藏呢。”《史记·魏世家》述说李克的观人方法是“居视其所亲,富视其所与,达视其所举,穷视其所不为,贫视其所不取”。虽较具体,却无此深刻。\n2.11子曰:“温故而知新⑴,可以为师矣。”\n【译文】孔子说:“在温习旧知识时,能有新体会、新发现,就可以做老师了。”\n【注释】⑴温故而知新——皇侃《义疏》说,“温故”就是“月无忘其所能”,“知新”就是“日知其所亡”(19.5),也通。\n2.12子曰:“君子不器⑴。”\n【译文】孔子说:“君子不像器皿一般,[只有一定的用途。]”\n【注释】⑴古代知识范围狭窄,孔子认为应该无所不通。后人还曾说,一事之不知,儒者之耻。虽然有人批评孔子“博学而无所成名”(9.2),但孔子仍说“君子不器”。\n2.13子贡问君子。子曰:“先行其言而后从之。”\n【译文】子贡问怎样才能做一个君子。孔子道:“对于你要说的话,先实行了,再说出来[这就够说是一个君子了]。”\n2.14子曰:“君子周而不比⑴,小人比而不周。”\n【译文】孔子说:“君子是团结,而不是勾结;小人是勾结,而不是团结。”\n【注释】⑴周、比——“周”是以当时所谓道义来团结人,“比”则是以暂时共同利害互相勾结。“比”旧读去声bì。\n2.15子曰:“学而不思则罔⑴,思而不学则殆⑵。”\n【译文】孔子说:“只是读书,却不思考,就会受骗;只是空想,却不读书,就会缺乏信心。”\n【注释】⑴罔——诬罔的意思。“学而不思”则受欺,似乎是《孟子·尽心下》“尽信书,不如无书”的意思。⑵殆——《论语》的“殆”(dài)有两个意义。下文第十八章“多见阙殆”的“殆”当“疑惑”解(说本王引之《经义述闻》),微子篇“今之从政者殆而”的“殆”当危险解。这里两个意义都讲得过去,译文取前一义。古人常以“罔”“殆”对文,如《诗经·小雅·节南山》云:“弗问弗仕,勿罔君子,式夷式己,无小人殆。”(“无小人殆”卽“无殆小人”,因韵脚而倒装。)旧注有以“罔然无所得”释“罔”,以“精神疲殆”释“殆”的,似乎难以圆通。\n2.16子曰:“攻⑴乎异端⑵,斯⑶害也已⑷。”\n【译文】孔子说:“批判那些不正确的议论,祸害就可以消灭了。”\n【注释】⑴攻——《论语》共享四次“攻”字,像先进篇的“小子鸣鼓而攻之”,颜渊篇的“攻其恶,无攻人之恶”的三个“攻”字都当“攻击”解,这里也不应例外。很多人却把它解为“治学”的“治”。⑵异端——孔子之时,自然还没有诸子百家,因之很难译为“不同的学说”,但和孔子相异的主张、言论未必没有,所以译为“不正确的议论”。⑶斯——连词,“这就”的意思。⑷已——应该看为动词,止也。因之我译为“消灭”。如果把“攻”字解为“治”,那么“斯”字得看作指代词,“这”的意思;“也已”得看作语气词。全文便如此译:“从事于不正确的学术研究,这是祸害哩。”一般的讲法是如此的,虽能文从字顺,但和《论语》词法和句法都不合。\n2.17子曰:“由⑴!诲女知之乎!知之为知之,不知为不知,是知也⑵。”\n【译文】孔子说:“由!教给你对待知或不知的正确态度吧!知道就是知道,不知道就是不知道,这就是聪明智慧。”\n【注释】⑴由——孔子学生,仲由,字子路,卞(故城在今山东泗水县东五十里)人,小于孔子九岁。(公元前542—480)⑵是知也——《荀子·子道篇》也载了这一段话,但比这详细。其中有两句道:“言要则知,行至则仁。”因之读“知”为“智”。如果“知”如字读,便该这样翻译:这就是对待知或不知的正确态度。\n2.18子张⑴学干禄⑵。子曰:“多闻阙疑,慎言其余,则寡尤;多见阙殆⑶,慎行其余,则寡悔。言寡尤,行⑷寡悔,禄在其中矣。”\n【译文】子张向孔子学求官职得俸禄的方法。孔子说:“多听,有怀疑的地方,加以保留;其余足以自信的部分,谨慎地说出,就能减少错误。多看,有怀疑的地方,加以保留;其余足以自信的部分,谨慎地实行,就能减少懊悔。言语的错误少,行动的懊悔少,官职俸禄就在这里面了。”\n【注释】⑴子张——孔子学生颛孙师,字子张,陈人,小于孔子四十八岁。(公元前503—?)⑵干禄——干,求也,禄,旧时官吏的俸给。⑶阙殆——和“阙疑”同意。上文作“阙疑”,这里作“阙殆”。“疑”和“殆”是同义词,所谓“互文”见义。⑷行——名词,去声,xìng。\n2.19哀公⑴问曰:“何为则民服?”孔子对曰⑵:“举直错诸枉⑶,则民服;举枉错诸直,则民不服。”\n【译文】鲁哀公问道:“要做些甚么事才能使百姓服从呢?”,孔子答道:“把正直的人提拔出来,放在邪曲的人之上,百姓就服从了;若是把邪曲的人提拔出来,放在正直的人之上,百姓就会不服从。”\n【注释】⑴哀公——鲁君,姓姬,名蒋,定公之子,继定公而卽位,在位二十七年。(公元前494—466)“哀”是谥号。⑵孔子对曰——《论语》的行文体例是,臣下对答君上的询问一定用“对曰”,这里孔子答复鲁君之问,所以用“孔子对曰”。⑶错诸枉——“错”有放置的意思,也有废置的意思。一般人把它解为废置,说是“废置那些邪恶的人”(把“诸”字解为“众”)。这种解法和古汉语语法规律不相合。因为“枉”、“直”是以虚代实的名词,古文中的“众”、“诸”这类数量形容词,一般只放在真正的实体词之上,不放在这种以虚代实的词之上。这一规律,南宋人孙季和(名应时)便已明白。王应麟《困学纪闻》曾引他的话说:“若诸家解,何用二‘诸’字?”这二“诸”字只能看做“之于”的合音,“错”当“放置”解。“置之于枉”等于说“置之于枉人之上”,古代汉语“于”字之后的方位词有时可以省略。朱亦栋《论语札记》解此句不误。\n2.20季康子⑴问:“使民敬、忠以⑵劝,如之何?”子曰:“临之以庄,则敬;孝慈,则忠;举善而教不能,则劝。”\n【译文】季康子问道:“要使人民严肃认真,尽心竭力和互相勉励,应该怎么办呢?”孔子说:“你对待人民的事情严肃认真,他们对待你的政令也会严肃认真了;你孝顺父母,慈爱幼小,他们也就会对你尽心竭力了;你提拔好人,教育能力弱的人,他们也就会劝勉了。”\n【注释】⑴季康子——季孙肥,鲁哀公时正卿,当时政治上最有权力的人。“康”是谥号。⑵以——连词,与“和”同。\n2.21或谓孔子曰:“子奚不为政?”子曰:“《书》⑴云:‘孝乎惟孝,友于兄弟,施⑵于有政⑶。’是亦为政,奚其为为政?”\n【译文】有人对孔子道:“你为什么不参与政治?”孔子道:“《尚书》上说,‘孝呀,只有孝顺父母,友爱兄弟,把这种风气影响到政治上去。’这也就是参与政治了呀,为什么定要做官才算参与政治呢?”\n【注释】⑴书云——以下三句是《尚书》的逸文,作《伪古文尚书》的便从这里采入《君陈篇》。⑵施——这里应该当“延及”讲,从前人解为“施行”,不妥。⑶施于有政——“有”字无义,加于名词之前,这是古代构词法的一种形态,详拙著《文言语法》。杨遇夫先生说:“政谓卿相大臣,以职言,不以事言。”(说详增订《积微居小学金石论丛·〈论语〉子奚不为政解》)那么。这句话便当译为“把这种风气影响到卿相大臣上去”。\n2.22子曰:“人而无信⑴,不知其可也。大车无輗,小车无軏⑵,其何以行之哉?”\n【译文】孔子说:“做为一个人,却不讲信誉,不知那怎么可以。譬如大车子没有安横木的輗,小车子没有安横木的軏,如何能走呢?”\n【注释】⑴人而无信——这“而”字不能当“如果”讲。不说“人无信”,而说“人而无信”者,表示“人”字要作一读。古书多有这种句法,译文似能表达其意。⑵輗、軏——輗音倪,ní;軏音月,yuè。古代用牛力的车叫大车,用马力的车叫小车。两者都要把牲口套在车辕上。车辕前面有一道横木,就是驾牲口的地方。那横木,大车上的叫做鬲,小车上的叫做衡。鬲、衡两头都有关键(活销),輗就是鬲的关键,軏就是衡的关键。车子没有它,自然无法套住牲口,那怎么能走呢?\n2.23子张问:“十世可知也⑴?”子曰:“殷因于夏礼,所损益,可知也;周因于殷礼,所损益,可知也。其或继周者,虽百世,可知也。”\n【译文】子张问:“今后十代[的礼仪制度]可以预先知道吗?”孔子说:“殷朝沿袭夏朝的礼仪制度,所废除的,所增加的,是可以知道的;周朝沿袭殷朝的礼仪制度,所废除的,所增加的,也是可以知道的。那么,假定有继承周朝而当政的人,就是以后一百代,也是可以预先知道的。”\n【注释】⑴十世可知也——从下文孔子的答语看来,便足以断定子张是问今后十代的礼仪制度,而不是泛问,所以译文加了几个字。这“也”字同“耶”,表疑问。\n2.24子曰:“非其鬼⑴而祭⑵之,谄⑶也。见义不为,无勇也。”\n【译文】孔子说:“不是自己应该祭祀的鬼神,却去祭祀他,这是献媚。眼见应该挺身而出的事情,却袖手旁观,这是怯懦。”\n【注释】⑴鬼——古代人死都叫“鬼”,一般指已死的祖先而言,但也偶有泛指的。⑵祭——祭是吉祭,和凶祭的奠不同(人初死,陈设饮食以安其灵魂,叫做奠)。祭鬼的目的一般是祈福。⑶谄——chǎn,谄媚,阿谀。\n"},{"id":113,"href":"/zh/docs/culture/%E8%AE%BA%E8%AF%AD/%E8%AE%BA%E8%AF%AD%E8%AF%91%E6%B3%A8_%E6%9D%A8%E4%BC%AF%E5%B3%BB/15%E5%8D%AB%E7%81%B5%E5%85%AC%E7%AF%87%E7%AC%AC%E5%8D%81%E4%BA%94/","title":"15卫灵公篇第十五","section":"论语译注 杨伯峻","content":" 卫灵公篇第十五 # (共四十二章(朱熹《集注》把第一、第二两章并为一章,所以说“凡四十一章”。))\n15.1卫灵公问陈⑴于孔子。孔子对曰:“俎豆⑵之事,则尝闻之矣;军旅之事,未之学也。”明日遂行。\n【译文】卫灵公向孔子问军队陈列之法。孔子答道:“礼仪的事情,我曾经听到过;军队的事情,从来没学习过。”第二天便离开卫国。\n【注释】⑴陈——就是今天的“阵”字。⑵俎豆之事——俎和豆都是古代盛肉食的器皿,行礼时用它,因之藉以表示礼仪之事。这种用法和泰伯篇第八的“笾豆之事”相同。\n15.2在陈绝粮,从者病,莫能兴。子路愠见曰:“君子亦有穷乎?”子曰:“君子固穷,小人穷斯滥矣。”\n【译文】孔子在陈国断绝了粮食,跟随的人都饿病了,爬不起床来。子路很不高兴地来见孔子,说道:“君子也有穷得毫无办法的时候吗?”孔子道:“君子虽然穷,还是坚持着;小人一穷便无所不为了。”\n15.3子曰:“赐也,女以予为多学而识之者与?”对曰:“然,非与?”曰:“非也,予一以贯之⑴。”\n【译文】孔子道:“赐!你以为我是多多地学习又能够记得住的吗?”子贡答道:“对呀,难道不是这样吗?”孔子道:“不是的,我有一个基本观念来贯串它。”\n【注释】⑴一以贯之——这和里仁篇的“夫子之道,忠恕而已矣”(4.15)的“一贯”相同。从这里可以看出,子贡他们所重视的,是孔子的博学多才,因之认为他是“多学而识之”;而孔子自己所重视的,则在于他的以忠恕之道贯穿于其整个学行之中。\n15.4子曰:“由!知德者鲜矣。”\n【译文】孔子对子路道:“由!懂得‘德’的人可少啦。”\n15.5子曰:“无为而治⑴者其舜也与?夫何为哉?恭己正南面而已矣。”\n【译文】孔子说:“自己从容安静而使天下太平的人大概只有舜罢?他干了什么呢?庄严端正地坐朝廷罢了。”\n【注释】⑴无为而治——舜何以能如此?一般儒者都以为他能“所任得其人,故优游而自逸也。”(《三国志·吴志·楼玄传》)如《大戴礼·主言篇》云:“昔者舜左禹而右皋陶,不下席而天下治。”《新序·杂事三》云:“故王者劳于求人,佚于得贤。舜举众贤在位,垂衣裳恭己无为而天下治。”赵岐《孟子注》也说:“言任官得其人,故无为而治”。\n15.6子张问行。子曰:“言忠信,行笃敬,虽蛮貊之邦,行矣。言不忠信,行不笃敬,虽州里,行乎哉?立则见其参于前也,在舆则见其倚于衡也,夫然后行。”子张书诸绅。\n【译文】子张问如何才能使自己到处行得通。孔子道:“言语忠诚老实,行为忠厚严肃,纵到了别的部族国家,也行得通。言语欺诈无信,行为刻薄轻浮,就是在本乡本土,能行得通吗?站立的时候,就[彷佛]看见“忠诚老实忠厚严肃”几个字在我们面前;在车箱里,也[彷佛]看见它刻在前面的横木上;[时时刻刻记着它,]这才能使自己到处行得通。”子张把这些话写在大带上。\n15.7子曰:“直哉史鱼⑴!邦有道,如矢;邦无道,如矢。君子哉蘧伯玉⑵!邦有道,则仕;邦无道,则可卷而怀之。”\n【译文】孔子说:“好一个刚直不屈的史鱼!政治清明也像箭一样直,政治黑暗也像箭一样直。好一个君子蘧伯玉!政治清明就出来做官,政治黑暗就可以把自己的本领收藏起来。”\n【注释】⑴史鱼——卫国的大夫史鳅,字子鱼。他临死时嘱咐他的儿子,不要“治丧正室”,以此劝告卫灵公进用蘧伯玉,斥退弥子瑕,古人叫为“尸谏”,事见《韩诗外传》卷七。⑵蘧伯玉——事可参见《左传》襄公十四年和二十六年。\n15.8子曰:“可与言而不与之言,失人;不可与言而与之言,失言。知者不失人,亦不失言。”\n【译文】孔子说:“可以同他谈,却不同他谈,这是错过人才;不可以同他谈,却同他谈,这是浪费言语。聪明人既不错过人才,也不浪费言语。”\n15.9子曰:“志士仁人,无求生以害仁,有杀身以成仁。”\n【译文】孔子说:“志士仁人,不贪生怕死因而损害仁德,只勇于牺牲来成全仁德。”\n15.10子贡问为仁。子曰:“工欲善其事,必先利其器。居是邦也,事其大夫之贤者,友其士⑴之仁者。”\n【译文】子贡问怎样去培养仁德。孔子道:“工人要搞好他的工作,一定先要搞好他的工具。我们住在一个国家,就要敬奉那些大官中的贤人,结交那些士人中的仁人。”\n【注释】⑴士——《论语》中的“士”,有时指有一定修养的人,如“士志于道”(4.9)的“士”。有时指有一定社会地位的人。如“使于四方,不辱君命,可调士矣”的“士”(13.20)。此处和“大夫”并言,可能是“士、大夫”之“士”,卽已做官而位置下于大夫的人。\n15.11颜渊问为邦。子曰:“行夏之时⑴,乘殷之辂⑵,服周之冕⑶,乐则韶、舞⑷。放郑声⑸,远佞人。郑声淫,佞人殆。”\n【译文】颜渊问怎样去治理国家。孔子道:“用夏朝的历法,坐殷朝的车子,戴周朝的礼帽,音乐就用韶和武。舍弃郑国的乐曲,斥退小人。郑国的乐曲靡曼淫秽,小人危险。”\n【注释】⑴行夏之时——据古史记载,夏朝用的自然历,以建寅之月(旧历正月)为每年的第一月,春、夏、秋、冬合乎自然现象。周朝则以建子之月(旧历十一月)为每年的第一月,而且以冬至日为元日。这个虽然在观测天象方面比较以前进步,但实用起来却不及夏历方便于农业生产。就是在周朝,也有很多国家是仍旧用夏朝历法。⑵乘殷之辂——辂音路,商代的车子,比周代的车子自然朴质些。所以《左传》桓公二年也说:“大辂、越席,昭其俭也。”⑵服周之冕——周代的礼帽自然又比以前的华美,孔子是不反对礼服的华美的,赞美禹“致美乎黻冕”可见。⑷韶、舞——韶是舜时的音乐,“舞”同“武”,周武王时的音乐。⑸放郑声——“郑声”和“郑诗”不同。郑诗指其文辞,郑声指其乐曲。说本明人杨慎《丹铅总録》。清人陈启源《毛诗稽古篇》。\n15.12子曰:“人无远虑,必有近忧。”\n【译文】孔子说:“一个人没有长远的考虑,一定会有眼前的忧患。”\n15.13子曰:“已矣乎!吾未见好德如好色⑴者也。”\n【译文】孔子说:“完了吧!我从没见过像喜欢美貌一般地喜欢美德的人哩。”\n【注释】⑴好色——据《史记·孔子世家》,孔子“居卫月余,灵公与夫人(南子)同车,宦者雍渠参乘出,使孔子为次乘,招摇市过之。”孔子因发这一感叹。\n15.14子曰:“藏文仲⑴其窃位者与!知柳下惠⑵之贤而不与立⑶也。”\n【译文】孔子说:“臧文仲大概是个做官不管事的人,他明知柳下惠贤良,却不给他官位。”\n【注释】⑴臧文仲——鲁国的大夫臧孙辰,历仕庄、闵、僖、文四朝。⑵柳下惠——鲁国贤者,本名展获,字禽,又叫展季。“柳下”可能是其所居,因以为号;据《列女传》,“惠”是由他的妻子的倡议给他的私谥(不由国家授予的谥号叫私谥)。⑶立——同“位”,说详俞樾《羣经平议》。\n15.15子曰:“躬自厚⑴而薄责于人,则远怨矣。”\n【译文】孔子说:“多责备自己,而少责备别人,怨恨自然不会来了。”\n【注释】⑴躬自厚——本当作“躬自厚责”,“责”字探下文“薄责”之“责”而省略。说详拙著《文言语法》。“躬自”是一双音节的副词,和《诗经·卫风·氓》的“静言思之,躬自悼矣”的“躬自”用法一样。\n15.16子曰:“不曰‘如之何⑴,如之何’者,吾末如之何也已矣。”\n【译文】孔子说:“[一个人]不想想‘怎么办,怎么办’的,对这种人,我也不知道怎么办了。”\n【注释】⑴如之何——“不曰如之何”意思就是不动脑筋。《荀子·大略篇》说:“天子卽位,上卿进曰,如之何,忧之长也。”则说如之何的,便是深忧远虑的人。\n15.17子曰:“羣居终日,言不及义,好行小慧,难矣哉!”\n【译文】孔子说:“同大家整天在一块,不说一句有道理的话,只喜欢卖弄小聪明,这种人真难教导!”\n15.18子曰:“君子义以为质,礼以行之,孙以出之⑴,信以成之。君子哉!”\n【译文】孔子说:“君子[对于事业],以合宜为原则,依礼节实行它,用谦逊的言语说出它,用诚实的态度完成它。真个是位君子呀!”\n【注释】⑴孙以出之——“出”谓出言。何晏《论语集解》引郑玄注云:“孙以出之谓言语。”\n15.19子曰:“君子病无能焉,不病人之不己知也。”\n【译文】孔子说:“君子只惭愧自己没有能力,不怨恨别人不知道自己。”\n15.20子曰:“君子疾没世而名不称焉。”\n【译文】孔子说:“到死而名声不被人家称述,君子引以为恨。”\n15.21子曰:“君子求诸己,小人求诸人。”\n【译文】孔子说:“君子要求自己,小人要求别人。”\n15.22子曰:“君子矜而不争,羣而不党⑴。”\n【译文】孔子说:“君子庄矜而不争执,合羣而不闹宗派。”\n【注释】⑴羣而不党——“羣而不党”可能包含着“周而不比”(2.14)以及“和而不同”(13.23)两个意思。\n15.23子曰:“君子不以言举人,不以人废言。”\n【译文】孔子说:“君子不因为人家一句话[说得好]便提拔他,不因为他是坏人而鄙弃他的好话。”\n15.24子贡问曰:“有一言而可以终身行之者乎?”子曰:“其恕⑴乎!己所不欲,勿施于人。”\n【译文】子贡问道:“有没有一句可以终身奉行的话呢?”孔子道:“大概是‘恕’罢!自己所不想要的任何事物,就不要加给别人。”\n【注释】⑴恕——“忠”(己欲立而立人,己欲达而达人)是有积极意义的道德,未必每个人都有条件来实行。“恕”只是“己所不欲,勿施于人”,则谁都可以这样做,因之孔子在这里言“恕”不言“忠”。《礼记·大学》篇的“絜矩之道”就是“恕”道。可是在阶级社会里,也只能是幻想。\n15.25子曰:“吾之于人也,谁毁谁誉?如有所誉者,其有所试矣。斯民也,三代之所以直道而行也。”\n【译文】孔子说:“我对于别人,诋毁了谁?称赞了谁?假若我有所称赞,必然是曾经考验过他的。夏、商、周三代的人都如此,所以三代能直道而行。”\n15.26子曰:“吾犹及史之阙文也。有马者借人乘之,今亡矣夫!”\n【译文】孔子说:“我还能够看到史书存疑的地方。有马的人[自己不会训练,]先给别人使用,这种精神,今天也没有了罢,”\n【注释】“史之阙文”和“有马借人乘之”,其间有什么关连,很难理解。包咸的《论语章句》和皇侃的《义疏》都把它们看成两件不相关的事。宋叶梦得《石林燕语》却根据《汉书·艺文志》的引文无“有马”等七个字,因疑这七个字是衍文。其它穿凿的解释很多,依我看来,还是把它看为两件事较妥当。又有人说这七字当作“有焉者晋人之乘”(见《诂经精舍六集》卷九〈方赞尧有马者借人乘之解〉),更是毫无凭据的臆测。\n15.27子曰:“巧言乱德。小不忍⑴,则乱大谋。”\n【译文】孔子说:“花言巧语足以败坏道德。小事情不忍耐,便会败坏大事情。”\n【注释】⑴小不忍——“小不忍”不仅是不忍小忿怒,也包括不忍小仁小恩,没有“蝮蛇螫手,壮士断腕”的勇气,也包括吝财不忍舍,以及见小利而贪。\n15.28子曰:“众恶之,必察焉⑴;众好之,必察焉。”\n【译文】孔子说:“大家厌恶他,一定要去考察;大家喜爱他,也一定要去考察。”\n【注释】⑴必察焉——子路篇有这样一段:“子贡问曰:‘乡人皆好之,何如?’子曰:‘未可也。’‘乡人皆恶之,何如?’子曰:‘未可也。不如乡人之善者好之,其不善者恶之。’”(13.24)可以和这段话互相发明。\n15.29子曰:“人能弘道,非道弘人⑴。”\n【译文】孔子说:“人能够把道廓大,不是用道来廓大人。”\n【注释】⑴这一章只能就字面来翻译,孔子的真意何在,又如何叫做“非道弘人”,很难体会。朱熹曾经强为解释,而郑皓的《论语集注述要》却说,“此章最不烦解而最可疑”,则我们也只好不加臆测。《汉书·董仲舒传》所载董仲舒的对策和《礼乐志》所载的平当对策都引此二句,都以为是治乱兴废在于人的意思,但细加思考,仍未必相合。\n15.30子曰:“过而不改,是谓过矣⑴。”\n【译文】孔子说:“有错误而不改正,那个错误便真叫做错误了。”\n【注释】⑴是谓过矣——《韩诗外传》卷三曾引孔子的话说:“过而改之,是不过也。”\n15.31子曰:“吾尝终日不食,终夜不寝,以思,无益,不如学也。”\n【译文】孔子说:“我曾经整天不吃,整晚不睡,去想,没有益处,不如去学习。”\n15.32子曰:“君子谋道不谋食。耕也,馁在其中矣;学也,禄在其中⑴矣。君子忧道不忧贫。”\n【译文】孔子说:“君子用心力于学术,不用心力于衣食。耕田,也常常饿着肚皮;学习,常常得到俸禄。君子只着急得不到道,不着急得不到财。”\n【注释】⑴禄在其中——这一章可以和“樊迟请学稼”章(13.4)结合着看。\n15.33子曰:“知及之⑴,仁不能守之;虽得之,必失之。知及之,仁能守之。不庄以莅之,则民不敬。知及之,仁能守之,庄以莅之,动之不以礼,未善也。”\n【译文】孔子说:“聪明才智足以得到它,仁德不能保持它;就是得到,一定会丧失。聪明才智足以得到它,仁德能保持它,不用严肃态度来治理百姓,百姓也不会认真[地生活和工作]。聪明才智足以得到它,仁德能保持它,能用严肃的态度来治理百姓,假若不合理合法地动员百姓,是不够好的。”\n【注释】⑴知及之——“知及之”诸“之”字究竟何指,原文未曾说出。以“不庄以莅之”“动之不以礼”诸句来看,似是小则指卿大夫士的禄位,大则指天下国家。不然,不会涉及临民和动员人民的。\n15.34子曰:“君子不可小知而可大受也,小人不可大受而可小知也。”\n【译文】孔子道:“君子不可以用小事情考验他,却可以接受重大任务;小人不可以接受重大任务,却可以用小事情考验他。”\n15.35子曰:“民之于仁也,甚于水火⑴。水火,吾见蹈而死者矣,未见蹈仁而死者也。”\n【译文】孔子说:“百姓需要仁德,更急于需要水火。往水火里去,我看见因而死了的,却从没有看见践履仁德因而死了的。”\n【注释】⑴甚于水火——《孟子·尽心上》说:“民非水火不生活”,译文摘取此意,故加“需要”两字。\n15.36子曰:“当仁,不让于师。”\n【译文】孔子说:“面临着仁德,就是老师,也不同他谦让。”\n15.37子曰:“君子贞⑴而不谅⑵。”\n【译文】孔子说:“君子讲大信,却不讲小信。”\n【注释】⑴贞——《贾子·道术篇》云:“言行抱一谓之贞。”所以译文以“大信”译之。⑵谅——朱骏声《说文通训定声说》这“谅”字假借为“勍”,犹固执也。则他把这“贞”字解为《伪古文尚书·太甲》“万邦以贞”的“贞”,正也。似不妥。\n15.38子曰:“事君,敬其事而后其食⑴。”\n【译文】孔子说:“对待君上,认真工作,把拿俸禄的事放在后面。”\n【注释】⑴而后其食——据宋晁公武《郡斋读书志》的记载,蜀石经作“而后食其禄”。\n15.39子曰:“有教无类⑴。”\n【译文】孔子说:“人人我都教育,没有[贫富、地域等等]区别。”\n【注释】⑴无类——“自行束修以上,吾未尝无诲焉”(7.7),便是“有教无类。”\n15.40子曰:“道不同,不相为谋。”\n【译文】孔子说:“主张不同,不互相商议。”\n15.41子曰:“辞达⑴而已矣。”\n【译文】孔子说:“言辞,足以达意便罢了。”\n【注释】⑴辞达——可以和“文胜质则史”(6.18)参看。过于浮华的词藻,是孔子所不同意的。\n15.42师冕⑴见,及阶,子曰:“阶也。”及席,子曰:“席也。”皆坐,子告之曰:“某在斯,某在斯。”\n师冕出。子张问曰:“与师言之道与?”子曰:“然;固相师之道也。”\n【译文】师冕来见孔子,走到阶沿,孔子道:“这是阶沿啦。”走到坐席旁,孔子道:“这是坐席啦。”都坐定了,孔子告诉他说:“某人在这里,某人在这里。”\n师冕辞了出来。子张问道:“这是同瞎子讲话的方式吗?”孔子道:“对的;这本来是帮助瞎子的方式。”\n【注释】⑴师冕——师,乐师,冕,这人之名。古代乐官一般用瞎子充当。\n"},{"id":114,"href":"/zh/docs/culture/%E8%AE%BA%E8%AF%AD/%E8%AE%BA%E8%AF%AD%E8%AF%91%E6%B3%A8_%E6%9D%A8%E4%BC%AF%E5%B3%BB/08%E6%B3%B0%E4%BC%AF%E7%AF%87%E7%AC%AC%E5%85%AB/","title":"08泰伯篇第八","section":"论语译注 杨伯峻","content":" 泰伯篇第八 # (共二十一章)\n8.1子曰:“泰伯⑴,其可谓至德也已矣。三以天下⑵让,民无得而称焉。”\n【译文】孔子说:“泰伯,那可以说是品德极崇高了。屡次地把天下让给季历,老百姓简直找不出恰当的词语来称赞他。”\n【注释】】⑴泰伯——亦作“太伯”,周朝祖先古公亶父的长子。古公有三子,太伯、仲雍、季历。季历的儿子就是姬昌(周文王)。据传说,古公预见到昌的圣德,因此想打破惯例,把君位不传长子太伯,而传给幼子季历,从而传给昌。太伯为着实现他父亲的意愿,便偕同仲雍出走至勾吴(为吴国的始祖),终于把君位传给季历和昌。昌后来扩张国势,竟有天下的三分之二,到他儿子姬发(周武王),便灭了殷商,统一天下。⑵天下——当古公、泰伯之时,周室仅是一个小的部落,谈不上“天下”。这“天下”两字可能卽指其当时的部落而言。也有人说,是预指以后的周部落统一了中原的天下而言。\n8.2子曰:“恭而无礼则劳⑴,慎而无礼则葸⑵,勇而无礼则乱,直而无礼则绞⑶。君子笃于亲,则民兴于仁;故旧不遗,则民不偷⑷。”\n【译文】孔子说:“注重容貌态度的端庄,却不知礼,就未免劳倦;只知谨慎,却不知礼,就流于畏葸懦弱;专凭敢作敢为的胆量,却不知礼,就会盲动闯祸;心直口快,却不知礼,就会尖刻刺人。在上位的人能用深厚感情对待亲族,老百姓就会走向仁德;在上位的人不遗弃他的老同事、老朋友,那老百姓就不致对人冷淡无情。\n【注释】⑴礼——这里指的是礼的本质。⑵葸——xǐ,胆怯,害怕。⑶绞——尖刻刺人。⑷偷——淡薄,这里指人与人的感情而言。\n8.3曾子有疾,召门弟子曰:“启⑴予足!启予手!《诗》云⑵,‘战战兢兢,如临深渊,如履⑶薄冰。’而今而后,吾知免夫!小子!”\n【译文】曾参病了,把他的学生召集拢来,说道:“看看我的脚!看看我的手!《诗经》上说:‘小心呀!谨慎呀!好像面临深深水坑之旁,好像行走薄薄冰层之上。’从今以后,我才晓得自己是可以免于祸害刑戮的了!学生们!”\n【注释】⑴启——说文有“”字,云:“视也。”王念孙《广雅疏证》(《释诂》)说,《论语》的这“启”字就是说文的“”字。⑵《诗》云——三句诗见《诗经·小雅·小旻篇》。⑶履——《易·履卦》爻辞:“眇能视,跛能履。”履,步行也。\n8.4曾子有疾,孟敬子⑴问之。曾子言曰:“鸟之将死,其鸣也哀;人之将死,其言也善。君子所贵乎道者三:动容貌,斯远暴慢⑵矣;正颜色,斯近信矣;出辞气,斯远鄙倍⑶矣。笾豆之事⑷,则有司⑸存。”\n【译文】曾参病了,孟敬子探问他。曾子说:“鸟要死了,鸣声是悲哀的;人要死了,说出的话是善意的。在上位的人待人接物有三方面应该注重:严肃自己的容貌,就可以避免别人的粗暴和懈怠;端正自己的脸色,就容易使人相信;说话的时候,多考虑言辞和声调,就可以避免鄙陋粗野和错误。至于礼仪的细节,自有主管人员。”\n【注释】⑴孟敬子——鲁国大夫仲孙捷。⑵暴慢——暴是粗暴无礼,慢是懈怠不敬。⑶鄙倍——鄙是粗野鄙陋;倍同“背”,不合理,错误。⑷笾豆之事——笾音边,古代的一种竹器,高脚,上面圆口,有些像碗,祭祀时用以盛果实等食品。豆也是古代一种像笾一般的器皿,木料做的,有盖,用以盛有汁的食物,祭祀时也用它。这里“笾豆之事”系代表礼仪中的一切具体细节。⑸有司——主管其事的小吏。\n8.5曾子曰:“以能问于不能,以多问于寡;有若无,实若虚,犯而不校——昔者吾友⑴尝从事于斯矣。”\n【译文】曾子说:“有能力却向无能力的人请教,知识丰富却向知识缺少的人请教;有学问像没学问一样,满腹知识像空无所有一样;纵被欺侮,也不计较——从前我的一位朋友便曾这样做了。”\n【注释】⑴吾友——历来的注释家都以为是指颜回。\n8.6曾子曰:“可以托六尺⑴之孤,可以寄百里之命,临大节而不可夺也——君子人与?君子人也。”\n【译文】曾子说:“可以把幼小的孤儿和国家的命脉都交付给他,面临安危存亡的紧要关头,却不动摇屈服——这种人,是君子人吗?是君子人哩。”\n【注释】⑴六尺——古代尺短,六尺约合今日一百三十八厘米,市尺四尺一寸四分。身长六尺的人还是小孩,一般指十五岁以下的人。\n8.7曾子曰:“士不可以不弘毅⑴,任重而道远。仁以为己任,不亦重乎?死而后已,不亦远乎?”\n【译文】曾子说:“读书人不可以不刚强而有毅力,因为他负担沉重,路程遥远。以实现仁德于天下为己任,不也沉重吗?到死方休,不也遥远吗?”\n【注释】⑴弘毅——就是“强毅”。章太炎(炳麟)先生《广论语骈枝》说:“说文:‘弘,弓声也。’后人借‘强’为之,用为‘强’义。此‘弘’字卽今之‘强’字也。说文:‘毅,有决也。’任重须强,不强则力绌;致远须决,不决则志渝。”\n8.8子曰:“兴于《诗》,立于礼,成于乐⑴。”\n【译文】孔子说:“诗篇使我振奋,礼使我能在社会上站得住,音乐使我的所学得以完成。”\n【注释】⑴成于乐——孔子所谓“乐”的内容和本质都离不开“礼”,因此常常“礼乐”连言。他本人也很懂音乐,因此把音乐作为他的教学工作的一个最后阶段。\n8.9子曰:“民可使由之,不可使知之⑴。”\n【译文】孔子说:“老百姓,可以使他们照着我们的道路走去,不可以使他们知道那是为什么。”\n【注释】⑴子曰……知之——这两句与“民可以乐成,不可与虑始”(《史记·滑稽列传》补所载西门豹之言,《商君列传》作“民不可与虑始,而可与乐成”)意思大致相同,不必深求。后来有些人觉得这种说法不很妥当,于是别生解释,意在为孔子这位“圣人”回护,虽煞费苦心,反失孔子本意。如刘宝楠《正义》以为“上章是夫子教弟子之法,此‘民’字亦指弟子”。不知上章“兴于诗”三句与此章旨意各别,自古以来亦曾未有以“民”代“弟子”者。宦懋庸《论语稽》则云:“对于民,其可者使其自由之,而所不可者亦使知之。或曰,舆论所可者则使共由之,其不可者亦使共知之。”则原文当读为“民可,使由之;不可,使知之”。恐怕古人无此语法。若是古人果是此意,必用“则”字,甚至“使”下再用“之”字以重指“民”,作“民可,则使(之)由之,不可,则使(之)知之”,方不致晦涩而误解。\n8.10子曰:“好勇疾贫,乱也。人而不仁,疾之已甚,乱也。”\n【译文】孔子说:“以勇敢自喜却厌恶贫困,是一种祸害。对于不仁的人,痛恨太甚,也是一种祸害。”\n8.11子曰:“如有周公之才之美,使骄且吝,其余不足观也已。”\n【译文】孔子说:“假如才能的美妙真比得上周公,只要骄傲而吝啬,别的方面也就不值得一看了。”\n8.12子曰:“三年学,不至⑴于谷⑵,不易得也。”\n【译文】孔子说:“读书三年并不存做官的念头,这是难得的。”\n【注释】⑴至——这“至”字和雍也篇第六“回也其心三月不违仁,其余则日月至焉而已矣”的“至”用法相同,指意念之所至。⑵谷——古代以谷米为俸禄(作用相当于今日的工资),所以“谷”有“禄”的意义。宪问篇第十四的“邦有道,谷;邦无道,谷”的“谷”正与此同。\n8.13子曰:“笃信⑴好学,守死善道。危邦不入,乱邦不居⑵。天下有道则见⑶,无道则隐。邦有道,贫且贱焉,耻也;邦无道,富且贵焉,耻也。”\n【译文】孔子说:“坚定地相信我们的道,努力学习它,誓死保全它。不进入危险的国家,不居住祸乱的国家。天下太平,就出来工作;不太平,就隐居。政治清明,自己贫贱,是耻辱;政治黑暗,自己富贵,也是耻辱。”\n【注释】⑴笃信——子张篇:“执德不弘,信道不笃,焉能为有?焉能为亡?”这一“笃信”应该和“信道不笃”的意思一样。⑵危邦乱邦——包咸云“臣弒君,子弑父,乱也;危者,将乱之兆也。”⑶见——同“现”。\n8.14子曰:“不在其位,不谋其政。”\n【译文】孔子说:“不居于那个职位,便不考虑它的政务。”\n8.15子曰:“师挚之始⑴,《关雎》之乱⑵,洋洋乎盈耳哉!”\n【译文】孔子说:“当太师挚开始演奏的时候,当结尾演奏《关雎》之曲的时候,满耳朵都是音乐呀!”\n【注释】⑴师挚之始——“始”是乐曲的开端,古代奏乐,开始叫做“升歌”,一般由太师演奏。师挚是鲁国的太师,名挚,由他演奏,所以说“师挚之始”。⑵《关雎》之乱——“始”是乐的开端,“乱”是乐的结束。由“始”到“乱”,叫做“一成”。“乱”是“合乐”,犹如今日的合唱。当合奏之时,奏《关雎》的乐章,所以说“《关雎》之乱”。\n8.16子曰:“狂而不直,侗而不愿,悾悾而不信,吾不知之矣。”\n【译文】孔子说:“狂妄而不直率,幼稚而不老实,无能而不讲信用,这种人我是不知道其所以然的。”\n8.17子曰:“学如不及,犹恐失之。”\n【译文】孔子说:“做学问好像[追逐什么似的,]生怕赶不上;[赶上了,]还生怕丢掉了。”\n8.18子曰:“巍巍乎,舜禹⑴之有天下也而不与⑵焉!”\n【译文】孔子说:“舜和禹真是崇高得很呀!贵为天子,富有四海,[却整年地为百姓勤劳,]一点也不为自己。”\n【注释】⑴禹——夏朝开国之君。据传说,受虞舜的禅让而卽帝位。又是中国主持水利工程最早的有着功勋的人物。⑵与——音预yù,参与,关连。这里含着“私有”、“享受”的意思。\n8.19子曰:“大哉尧之为君也!巍巍乎!唯天为大,唯尧则之。荡荡乎,民无能名焉。巍巍乎其有成功也,焕乎其有文章!”\n【译文】孔子说:“尧真是了不得呀!真高大得很呀!只有天最高最大,只有尧能够学习天。他的恩惠真是广博呀!老百姓简直不知道怎样称赞他。他的功绩实在太崇高了,他的礼仪制度也真够美好了!”\n8.20舜有臣五人而天下治。武王曰:“予有乱臣⑴十人。”孔子曰:“才难,不其然乎!唐虞之际,于斯为盛。有妇人焉,九人而已。三分天下有其二⑵,以服事殷。周之德,其可谓至德也已矣。”\n【译文】舜有五位贤臣,天下便太平。武王也说过,“我有十位能治理天下的臣子。”孔子因此说道:“[常言道:]‘人才不易得。’不是这样吗?唐尧和虞舜之间以及周武王说那话的时候,人才最兴盛。然而武王十位人才之中还有一位妇女,实际上只是九位罢了。周文王得了天下的三分之二,仍然向商纣称臣,周朝的道德,可以说是最高的了。”\n【注释】⑴乱臣——说文:“乱,治也。”《尔雅·释诂》同。《左传》昭公二十四年引《大誓》说:“余有乱臣十人,同心同德。”则“乱臣”就是“治国之臣”。近人周谷城(《古史零证》)认为“乱”有“亲近”的意义,则“乱臣”相当于《孟子·粱惠王下》“王无亲臣矣”的“亲臣”,虽然言之亦能成理,但和下文“才难”之意不吻合,恐非孔子原意。⑵三分天下有其二——《逸周书·程典篇》说:“文王合九州岛之侯,奉勤于商”。相传当时分九州岛,文王得六州,是有三分之二。\n8.21子曰:“禹,吾无间然矣。菲饮食而致孝乎鬼神,恶衣服而致美乎黻冕⑴,卑宫室而尽力乎沟洫⑵。禹,吾无间然矣。”\n【译文】孔子说:“禹,我对他没有批评了。他自己吃得很坏,却把祭品办得极丰盛;穿得很坏,却把祭服做得极华美;住得很坏,却把力量完全用于沟渠水利。禹,我对他没有批评了。”\n【注释】⑴黻冕——黻音弗,fú,祭祀时穿的礼服;冕音免,miǎn,古代大夫以上的人的帽子都叫冕,后来只有帝王的帽子才叫冕。这里指祭祀时的礼帽。⑵沟洫——就是沟渠,这里指农田水利而言。\n"},{"id":115,"href":"/zh/docs/culture/%E8%AE%BA%E8%AF%AD/%E8%AE%BA%E8%AF%AD%E8%AF%91%E6%B3%A8_%E6%9D%A8%E4%BC%AF%E5%B3%BB/07%E8%BF%B0%E8%80%8C%E7%AF%87%E7%AC%AC%E4%B8%83/","title":"07述而篇第七","section":"论语译注 杨伯峻","content":" 述而篇第七 # (共三十八章(朱熹《集注》把第九、第十两章并作一章,所以题为三十七章。))\n7.1子曰:“述而不作,信而好古⑴,窃比于我老彭⑵。”\n【译文】孔子说:“阐述而不创作,以相信的态度喜爱古代文化,我私自和我那老彭相比。”\n【注释】⑴作,好古——下文第二十八章说:“盖有不知而作之者,我无是也。”这个“作”,大概也是“不知而作”的涵义,很难说孔子的学说中没有创造性。又第二十章说:“好古敏以求之”,也可为这个“好古”的证明。⑵老彭——人名。有人说是老子和彭祖两人,有人说是殷商时代的彭祖一人,又有人说孔子说“我的老彭”,其人一定和孔子相当亲密,未必是古人。《大戴礼·虞戴德篇》有“商老彭”,不知卽此人不。\n7.2子曰:“默而识⑴之,学而不厌,诲人不倦,何有于我哉⑵?”\n【译文】孔子说:“[把所见所闻的]默默地记在心里,努力学习而不厌弃,教导别人而不疲倦,这些事情我做到了哪些呢?”\n【注释】⑴识——音志,zhì,记住。⑵何有于我哉——“何有”在古代是一常用语,在不同场合表示不同意义。像《诗·邶风·谷风》“何有何亡?黾勉求之”的“何有”便是“有什么”的意思,译文就是用的这一意义。也有人说,《论语》的“何有”都是“不难之辞”,那么,这句话便该译为“这些事情对我有什么困难呢”。这种译法便不是孔子谦虚之词,而和下文第二十八章的“多闻,择其善者而从之,多见而识之”以及“抑为之不厌,诲人不倦”的态度相同了。\n7.3子曰:“德之不修,学之不讲,闻义不能徙,不善不能改,是吾忧也。”\n【译文】孔子说:“品德不培养;学问不讲习;听到义在那里,却不能亲身赴之;有缺点不能改正,这些都是我的忧虑哩!”\n7.4子之燕居,申申⑴如也,夭夭⑵如也。\n【译文】孔子在家闲居,很整齐的,很和乐而舒展的。\n【注释】⑴申申——整敕之貌。⑵夭夭——和舒之貌。\n7.5子曰:“甚矣吾衰也!久矣吾不复梦见周公⑴!”\n【译文】孔子说:“我衰老得多么厉害呀!我好长时间没再梦见周公了!”\n【注释】⑴周公——姓姬,名旦,周文王的儿子,武王的弟弟,成王的叔父,鲁国的始祖,又是孔子心目中最敬服的古代圣人之一。\n7.6子曰:“志于道,据于德,依于仁,游于艺⑴。”\n【译文】孔子说:“目标在‘道’,根据在‘德’,依靠在‘仁’,而游憩于礼、乐、射、御、书、数六艺之中。”\n【注释】⑴游于艺——《礼记·学记》曾说:“不兴其艺,不能乐学。故君子之于学也,藏焉,修焉,息焉,游焉。夫然,故安其学而亲其师,乐其友而信其道,是以虽离师辅而不反也。”可以阐明这里的“游于艺”。\n7.7子曰:“自行束修⑴以上,吾未尝无诲焉。”\n【译文】孔子说:“只要是主动地给我一点见面薄礼,我从没有不教诲的。”\n【注释】⑴束修——修是干肉,又叫脯。每条脯叫一脡(挺),十脡为一束。束修就是十条干肉,古代用来作初次拜见的礼物。但这一礼物是菲薄的。\n7.8子曰:“不愤⑴不启,不悱⑵不发⑶。举一隅不以三隅反,则不复也。”\n【译文】孔子说:“教导学生,不到他想求明白而不得的时候,不去开导他;不到他想说出来却说不出的时候,不去启发他。教给他东方,他却不能由此推知西、南、北三方,便不再教他了。”\n【注释】⑴愤——心求通而未得之意。⑵悱音斐,fěi,口欲言而未能之貌。⑶不启,不发——这是孔子自述其教学方法,必须受教者先发生困难,有求知的动机,然后去启发他。这样,教学效果自然好些。\n7.9子食于有丧者之侧,未尝饱也。\n【译文】孔子在死了亲属的人旁边吃饭,不曾吃饱过。\n7.10子于是日哭,则不歌。\n【译文】孔子在这一天哭泣过,就不再唱歌。\n7.11子谓颜渊曰:“用之则行,舍之则藏,惟我与尔有是夫!”\n子路曰:“子行三军,则谁与⑴?”\n子曰:“暴虎冯河⑵,死而无悔者,吾不与也。必也临事而惧,好谋而成者也。”\n【译文】孔子对颜渊道:“用我呢,就干起来;不用呢,就藏起来。只有我和你才能这样吧!”\n子路道:“您若率领军队,找谁共事?”\n孔子道:“赤手空拳和老虎搏斗,不用船只去渡河,这样死了都不后悔的人,我是不和他共事的。[我所找他共事的,]一定是面临任务便恐惧谨慎,善于谋略而能完成的人哩!”\n【注释】⑴子行三军,则谁与——“行”字古人用得很活,行军犹言行师。《易经·谦卦·上六》云:“利用行师征邑国”,又《复卦·上六》:“用行师终有大败”,行师似有出兵之意。这种活用,一直到中古都如此。如“子夜歌”的“欢行白日心,朝东暮还西。”“与”,动词,偕同的意思。子路好勇,看见孔子夸奖颜渊,便发此问。⑵暴虎冯河——冯音凭,píng。徒手搏虎曰暴虎,徒足涉河曰冯河。“冯河”两字最初见于《易·泰卦·爻辞》,又见于《诗·小雅·小旻》。“暴虎”也见于《诗经·郑风·大叔于田》和《小雅·小旻》,可见都是很早就有的俗语。“河”不一定是专指黄河,古代也有用作通名,泛指江河的。\n7.12子曰:“富而⑴可求也,虽执鞭之士⑵,吾亦为之。如不可求,从吾所好。”\n【译文】孔子说:“财富如果可以求得的话,就是做市场的守门卒我也干。如果求它不到,还是我干我的罢。”\n【注释】⑴而——用法同“如”,假设连词。但是用在句中的多,卽有用在句首的,那句也多半和上一句有密切的关连,独立地用在句首的极少见。⑵执鞭之士——根据《周礼》,有两种人拿着皮鞭,一种是古代天子以及诸侯出入之时,有二至八人拿着皮鞭使行路之人让道。一种是市场的守门人,手执皮鞭来维持秩序。这里讲的是求财,市场是财富所聚集之处,因此译为“市场守门卒”。\n7.13子之所慎:齐⑴,战,疾⑵。\n【译文】孔子所小心慎重的事有三样:斋戒,战争,疾病。\n【注释】⑴齐——同“斋”。古代于祭祀之前,一定先要做一番身心的整洁工作,这一工作便叫做‘斋’或者“斋戒”。乡党篇第十说孔子“斋必变食,居必迁坐”。⑵战,疾——上文说到孔子作战必求“临事而惧好谋而成”的人,因为它关系国家的存亡安危;乡党篇又描写孔子病了,不敢随便吃药,因为它关系个人的生死。这都是孔子不能不谨慎的地方。\n7.14子在齐闻韶,三月不知肉味,曰:“不图为乐之至于斯也。”\n【译文】孔子在齐国听到韶的乐章,很长时间尝不出肉味,于是道:“想不到欣赏音乐竟到了这种境界。”\n7.15冉有曰:“夫子为⑴卫君⑵乎?”子贡曰:“诺;吾将问之。”\n入,曰:“伯夷、叔齐何人也?”曰:“古之贤人也。”曰:“怨乎?”曰:“求仁而得仁,又何怨?”\n出,曰:“夫子不为也。”\n【译文】冉有道:“老师赞成卫君吗?”子贡道:“好罢;我去问问他。”\n子贡进到孔子屋里,道:“伯夷、叔齐是什么样的人?”孔子道:“是古代的贤人。”子贡道:“[他们两人互相推让,都不肯做孤竹国的国君,结果都跑到国外,]是不是后来又怨悔呢?”孔子道:“他们求仁德,便得到了仁德,又怨悔什么呢?”\n子贡走出,答复冉有道:“老师不赞成卫君。”\n【注释】⑴为——动词,去声,本意是帮助,这里译为“赞成”,似乎更合原意。⑵卫君——指卫出公辄。辄是卫灵公之孙,太子蒯聩之子。太子蒯聩得罪了卫灵公的夫人南子,逃在晋国。灵公死,立辄为君。晋国的赵简子又把蒯聩送回,藉以侵略卫国。卫国抵御晋兵,自然也拒绝了蒯聩的回国。从蒯聩和辄是父子关系的一点看来,似乎是两父子争夺卫君的位置,和伯夷、叔齐两兄弟的互相推让,终于都抛弃了君位相比,恰恰成一对照。因之下文子贡引以发问,藉以试探孔子对出公辄的态度。孔子赞美伯夷、叔齐,自然就是不赞成出公辄了。\n7.16子曰:“饭疏食⑴饮水⑵,曲肱⑶而枕⑷之,乐亦在其中矣。不义而富且贵,于我如浮云。”\n【译文】孔子说:“吃粗粮,喝冷水,弯着胳膊做枕头,也有着乐趣。干不正当的事而得来的富贵,我看来好像浮云。”\n【注释】⑴疏食——有两个解释:(甲)粗粮。古代以稻梁为细粮,以稷为粗粮。见程瑶田《通艺録·九谷考》。(乙)糙米。⑵水——古代常以“汤”和“水”对言,“汤”的意义是热水,“水”就是冷水。⑶肱——音宫,gōng,胳膊。⑷枕——这里用作动词,旧读去声。\n7.17子曰:“加我数年,五十以学《易》⑴,可以无大过矣。”\n【译文】孔子说:“让我多活几年,到五十岁时候去学习《易经》,便可以没有大过错了。”\n【注释】⑴易——古代一部用以占筮的书,其中的卦辞和爻辞是孔子以前的作品。\n7.18子所雅言⑴,《诗》、《书》、执礼,皆雅言也。\n【译文】孔子有用普通话的时候,读《诗》,读《书》,行礼,都用普通话。\n【注释】⑴雅言——当时中国所通行的语言。春秋时代各国语言不能统一,不但可以想象得到,卽从古书中也可以找到证明。当时较为通行的语言便是“雅言”。\n7.19叶公⑴问孔子于子路,子路不对。子曰:“女奚不曰,其为人也,发愤忘食,乐以忘忧,不知老之将至云尔⑵。”\n【译文】叶公向子路问孔子为人怎么样,子路不回答。孔子对子路道:“你为什么不这样说:他的为人,用功便忘记吃饭,快乐便忘记忧愁,不晓得衰老会要到来,如此罢了。”\n【注释】⑴叶——旧音摄,shè,地名,当时属楚,今河南叶县南三十里有古叶城。叶公是叶地方的县长,楚君称王,那县长便称公。此人叫沈诸梁,字子高,《左传》定公、哀公之间有一些关于他的记载,在楚国当时还算是一位贤者。⑵云尔——云,如此;尔同“耳”,而已,罢了。\n7.20子曰:“我非生而知之者,好古,敏以求之者也。”\n【译文】孔子说:“我不是生来就有知识的人,而是爱好古代文化,勤奋敏捷去求得来的人。”\n7.21子不语怪,力,乱,神。\n【译文】孔子不谈怪异、勇力、叛乱和鬼神。\n7.22子曰:“三人行,必有我师焉:择其善者而从之,其不善者而改之⑴。”\n【译文】孔子说:“几个人一块走路,其中便一定有可以为我所取法的人:我选取那些优点而学习,看出那些缺点而改正。”\n【注释】⑴子曰……改之——子贡说孔子没有特定的老师(见19.22),意思就是随处都有老师,和这章可以以互相证明,老子说:“善人,不善人之师;不善人,善人之资。”未尝不是这个道理。\n7.23子曰:“天生德于予,桓魋⑴其如予何⑵?”\n【译文】孔子说:“天在我身上生了这样的品德,那桓魋将把我怎样?”\n【注释】⑴桓魋——“魋”音颓,tuí。桓魋,宋国的司马向魋,因为是宋桓公的后代,所以又叫桓魋。⑵桓魋其如予何——《史记·孔子世家》有一段这样的记载:“孔子去曹,适宋,与弟子习礼大树下。宋司马桓魋欲杀孔子,拔其树。孔子去,弟子曰‘可以速矣!’孔子曰:‘天生德于予,桓魋其如予何?’”\n7.24子曰:“二三子以我为隐乎?吾无隐乎尔。吾无行而不与二三子者,是丘也。”\n【译文】孔子说:“你们这些学生以为我有所隐瞒吗?我对你们是没有隐瞒的。我没有一点不向你们公开,这就是我孔丘的为人”\n7.25子以四教:文,行⑴,忠,信。\n【译文】孔子用四种内容教育学生:历代文献,社会生活的实践,对待别人的忠心,与人交际的信实。\n【注释】⑴行——作名词用,旧读去声。\n7.26子曰:“圣人,吾不得而见之矣;得见君子者,斯可矣。”\n子曰:“善人,吾不得而见之矣;得见有恒⑴者,斯可矣。亡而为有,虚而为盈,约而为泰⑵,难乎有恒矣。”\n【译文】孔子说:“圣人,我不能看见了;能看见君子,就可以了。”又说:“善人,我不能看见了,能看见有一定操守的人,就可以了。本来没有,却装做有;本来空虚,却装做充足;本来穷困,却要豪华,这样的人便难于保持一定操守了。”\n【注释】⑴有恒——这个“恒”字和《孟子·梁惠王上》的“无恒产而有恒心”的“恒”是一个意义。⑵泰——这“泰”字和《国语·晋语》的“恃其富宠,以泰于国”,《荀子·议兵篇》的“用财欲泰”的“泰”同义,用度豪华而不吝惜的意思。\n7.27子钓而不纲⑴,弋⑵不射宿⑶。\n【译文】孔子钓鱼,不用大绳横断流水来取鱼;用带生丝的箭射鸟,不射归巢的鸟。\n【注释】⑴纲——网上的大绳叫纲,用它来横断水流,再用生丝系钓,着于纲上来取鱼,这也叫纲。“不纲”的“纲”是动词。⑵弋——音亦,yì,用带生丝的矢来射。⑶宿——歇宿了的鸟。\n7.28子曰:“盖有不知而作之者,我无是也。多闻,择其善者而从之;多见而识之;知之次也⑴。”\n【译文】孔子说:“大概有一种自己不懂却凭空造作的人,我没有这种毛病。多多地听,选择其中好的加以接受;多多地看,全记在心里。这样的知,是仅次于‘生而知之’的。”\n【注释】⑴次——《论语》的“次”一共享了八次,都是当“差一等”、“次一等”讲。季氏篇云:“孔子曰:‘生而知之者,上也;学而知之者,次也。’”这里的“知之次也”正是“学而知之者,次也”的意思。孔子自己也说他是学而知之(好古敏以求之)的人,所以译文加了几个字。\n7.29互乡⑴难与言,童子见,门人惑。子曰:“与其进也,不与其退也,唯何甚?人洁己以进,与其洁也,不保⑵其往也。”\n【译文】互乡这地方的人难于交谈,一个童子得到孔子的接见,弟子们疑惑。孔子道:“我们赞成他的进步,不赞成他的退步,何必做得太过?别人把自己弄得干干净净而来,便应当赞成他的干净,不要死记住他那过去。”\n【注释】⑴互乡——地名,现在已不详其所在。⑵保——守也,所以译为“死记住”。\n7.30子曰:“仁远乎哉?我欲仁,斯仁至矣。”\n【译文】孔子道:“仁德难道离我们很远吗?我要它,它就来了。”\n7.31陈司败⑴问昭公⑵知礼乎,孔子曰:“知礼。”\n孔子退,揖巫马期⑶而进之,曰:“吾闻君子不党,君子亦党乎?君取于吴⑷,为同姓⑸,谓之吴孟子⑹。君而知礼,孰不知礼?”\n巫马期以告。子曰:“丘也幸,苟有过⑺,人必知之。”\n【译文】陈司败向孔子问鲁昭公懂不懂礼,孔子道:“懂礼。”\n孔子走了出来,陈司败便向巫马期作了个揖。请他走近自己,然后说道:“我听说君子无所偏袒,难道孔子竟偏袒吗?鲁君从吴国娶了位夫人,吴和鲁是同姓国家,[不便叫她做吴姬,]于是叫她做吴孟子。鲁君若是懂得礼,谁不懂得礼呢?”\n巫马期把这话转告给孔子。孔子道:“我真幸运,假若有错误,人家一定给指出来,”\n【注释】⑴陈司败——人名。有人说“司败”是官名,也有人说是人名,究竟是什么样的人,今天已经无法知道。⑵昭公——鲁昭公,名裯,襄公庶子,继襄公而为君。“昭”是谥号,陈司败之问若在昭公死后,则“昭公知礼乎”可能是原来语言。如果他这次发问尚在昭公生时,那“昭公”字眼当是后人的记述。我们已无从判断,所以这句不加引号。⑶巫马期——孔子学生,姓巫马,名施,字子期,小于孔子三十岁。⑷君取于吴——“取”这里用作“娶”字。吴,当时的国名,拥有今天淮水、泗水以南以及浙江的嘉兴、湖州等地。哀公时,为越王勾践所灭。⑸为同姓——鲁为周公之后,姬姓;吴为太伯之后,也是姬姓。⑹吴孟子——春秋时代,国君夫人的称号一般是所生长之国名加她的本姓。鲁娶于吴,这位夫人便应该称为吴姬。但“同姓不婚”是周朝的礼法,鲁君夫人的称号而把“姬”字标明出来,便是很显明地表示出鲁君的违背了“同姓不婚”的礼制,因之改称为“吴孟子”。“孟子”可能是这位夫人的字。《左传》哀公十二年亦书曰:“昭夫人孟子卒”。⑺苟有过——根据《荀子·子道篇》关于孔子的另一段故事,和《史记·仲尼弟子列传》对这一事“臣不可言君亲之恶,为讳者礼也”的解释,则孔子对鲁昭公所谓不合礼的行为不是不知,而是不说,最后只得归过于自己。\n7.32子与人歌而善,必使反之,而后和之。\n【译文】孔子同别人一道唱歌,如果唱得好,一定请他再唱一遍,然后自己又和他。\n7.33子曰:“文,莫⑴吾犹人也。躬行君子,则吾未之有得。”\n【译文】孔子说:“书本上的学问,大约我同别人差不多。在生活实践中做一个君子,那我还没有成功。”\n【注释】⑴文莫——以前人都把“文莫”两字连读,看成一个双音词,但又不能得出恰当的解释。吴检斋(承仕)先生在〈亡莫无虑同词说〉(载于前北京中国大学《国学丛编》第一期第一册)中以为“文”是一词,指孔子所谓的“文章”,“莫”是一词,“大约”的意思。关于“莫”字的说法在先秦古籍中虽然缺乏坚强的论证,但解释本文却比所有各家来得较为满意,因之为译者所采用。朱熹《集注》亦云,“莫,疑辞”,或为吴说所本。\n7.34子曰:“若圣⑴与仁,则吾岂敢?抑为之不厌,诲人不倦,则可谓云尔已矣。”公西华曰:“正唯弟子不能学也。”\n【译文】孔子说道:“讲到圣和仁,我怎么敢当?不过是学习和工作总不厌倦,教导别人总不疲劳,就是如此如此罢了。”公西华道:“这正是我们学不到的。”\n【注释】⑴圣——《孟子·公孙丑上》载子贡对这事的看法说:“学不厌,智也;教不倦,仁也。仁且智,夫子既圣矣。”可见当时的学生就已把孔子看成圣人。\n7.35子疾病⑴,子路请祷。子曰:“有诸?”子路对曰:“有之;〈诔〉⑵曰:‘祷尔于上下神祇⑶。’”子曰:“丘之祷久矣。”\n【译文】孔子病重,子路请求祈祷。孔子道:“有这回事吗?”子路答道:“有的;〈诔文〉说过:‘替你向天神地祇祈祷。’”孔子道:“我早就祈祷过了。”\n【注释】⑴疾病——“疾病”连言,是重病。⑵诔——音耒,lèi,本应作讄,祈祷文。和哀悼死者的“诔”不同。⑶祇——音祁,qí,地神。\n7.36子曰:“奢则不孙⑴,俭则固⑵。与其不孙也,宁固。”\n【译文】孔子说:“奢侈豪华就显得骄傲,省俭朴素就显得寒伧。与其骄傲,宁可寒伧。”\n【注释】⑴孙——同“逊”。⑵固——固陋,寒伧。\n7.37子曰:“君子坦荡荡,小人长戚戚。”\n【译文】孔子说:“君子心地平坦宽广,小人却经常局促忧愁。”\n7.38子温而厉,威而不猛,恭而安。\n【译文】孔子温和而严厉,有威仪而不凶猛,庄严而安详。\n"},{"id":116,"href":"/zh/docs/culture/%E8%AE%BA%E8%AF%AD/%E8%AE%BA%E8%AF%AD%E8%AF%91%E6%B3%A8_%E6%9D%A8%E4%BC%AF%E5%B3%BB/%E8%AF%95%E8%AE%BA-%E5%AF%BC%E8%A8%80-%E4%BE%8B%E8%A8%80/","title":"试论-导言-例言","section":"论语译注 杨伯峻","content":"论语译注\n杨伯峻译注\n中华书局\n1980年 北京\n试论孔子 # (一)孔子身世 # 孔子名丘,字仲尼,一说生于鲁襄公二十一年(《公羊传》和《谷梁传》,卽公元前五五一年),一说生于鲁襄公二十二年(《史记·孔子世家》),相差仅一年。前人为此打了许多笔墨官司,实在不必。死于鲁哀公十六年,卽公元前四七九年。终年实七十二岁。\n孔子自己说“而丘也,殷人也”(《礼记·檀弓上》),就是说他是殷商的苗裔。周武王灭了殷商,封殷商的微子启于宋。孔子的先祖孔父嘉是宋国宗室,因为距离宋国始祖已经超过五代,便改为孔氏。孔父嘉无辜被华父督杀害(见《左传》桓公元年和二年)。据《史记·孔子世家·索隐》,孔父嘉的后代防叔畏惧华氏的逼迫而出奔到鲁国,防叔生伯夏,伯夏生叔梁纥,叔梁纥就是孔子的父亲,因此孔子便成为鲁国人。\n殷商是奴隶社会,《礼记·表记》说:“殷人尚神”,这些都能从卜辞中得到证明。孔子也说:“殷礼,吾能言之。”(3.9)孔子所处的时代正是奴隶社会衰亡、新兴封建制逐渐兴起的交替时期。孔子本人,便看到这些迹象。譬如微子篇(18.6)耦耕的长沮、桀溺,不但知道孔子,讥讽孔子,而且知道子路是“鲁孔丘之徒”。这种农民,有文化,通风气,有自己的思想,绝对不是农业奴隶。在孔子生前,鲁宣公十五年,卽公元前五九四年,鲁国实行“初税亩”制。卽依各人所拥有的田地亩数抽收赋税,这表明了承认土地私有的合法性。《诗经·小雅·北山》说:“溥天之下,莫非王土。率土之滨,莫非王臣。”这是奴隶社会的情况。天下的土地全是天子的土地,天子再分封一些给他的宗族、亲戚、功臣和古代延续下来的旧国,或者成为国家,或者成为采邑。土地的收入,大部为被封者所享有,一部分还得向天子纳贡。土地的所有权,在天子权力强大时,还是为天子所有。他可以收回,可以另行给予别人。这种情况固然在封建社会完全确立以后还曾出现,如汉代初年,然而实质上却有不同。在汉代以后,基本上已经消灭了农业奴隶,而且土地可以自由买卖。而在奴隶社会,从事农业的基本上是奴隶,土地既是“王土”,当然不得自由买卖。鲁国的“初税亩”,至少打破了“莫非王土”的传统,承认土地为某一宗族所有,甚至为某一个人所有。一部《春秋左传》和其它春秋史料,虽然不曾明显地记载着土地自由买卖的情况,但出现有下列几种情况。已经有自耕农,长沮、桀溺便是。《左传》记载着鲁襄公二十七年(孔子出生后五年或六年),申鲜虞“仆赁于野”,这就是说产生了雇农。《左传》昭公二十五年说鲁国的季氏“隐民多取食焉”,隐民就是游民。游民来自各方,也很有可能来自农村。游民必然是自由身份,才能向各大氏族投靠。春秋时,商业很发达,商人有时参与政治。《左传》僖公三十三年记载着郑国商人弦高的事。他偶然碰着秦国来侵的军队,便假借郑国国君名义去犒劳秦军,示意郑国早有准备。昭公十六年,郑国当政者子产宁肯得罪晋国执政大臣韩起,不肯向无名商人施加小小压力逼他出卖玉环。到春秋晚期,孔子学生子贡一面做官,一面做买卖。越国的大功臣范蠡帮助越王勾践灭亡吴国后,便抛弃官位而去做商人,大发其财。这些现象应该能说明两点:一是社会购买力已有一定发展,而购买力的发展是伴随生产力,尤其农业生产力的发展而来的。没有土地所有制的改革,农业生产力是不容有较快较大发展的。于是乎又可以说明,田地可能自由买卖了,兼并现象也发生了,不仅雇农和游民大量出现,而且商人也可以经营皮毛玉贝等货物,经营田地和农产品。\n至于“率土之滨,莫非王臣”这一传统,更容易地被打破。周天子自平王东迁以后,王仅仅享有虚名,因之一般士大夫,不仅不是“王臣”,而且各有其主。春秋初期,齐国内乱,便有公子纠和公子小白争夺齐国君位之战。管仲和召忽本是公子纠之臣,鲍叔牙则是小白(齐桓公)之臣。小白得胜,召忽因之而死,管仲却转而辅佐齐桓公。晋献公死后,荀息是忠于献公遗嘱拥护奚齐的,但另外很多人,却分别为公子重耳(晋文公)、公子夷吾(晋惠公)之臣。有的甚至由本国出去做别国的官,《左传》襄公二十六年便述说若干楚国人才为晋国所用的情事。卽以孔子而言,从来不曾做过“王臣”。他从很卑微的小吏,如“委吏”(仓库管理员),如“乘田”(主持畜牧者——俱见《孟子·万章下》),进而受到鲁国权臣季氏的赏识,才进入“大夫”的行列。鲁国不用他,他又臣仕于自己讥评为“无道”的卫灵公。甚至晋国范氏、中行氏的党羽佛佾盘踞中牟(在今河北省邢台市和邯郸市之间),来叫孔子去,孔子也打算去。(17.7)这些事例,说明所谓“莫非王土”、“莫非王臣”的传统观念早已随着时间的流逝,形势的变迁,被人轻视,甚至完全抛弃了。\n孔子所处的社会,是动荡的社会;所处的时代,是变革的时代。公元前五四六年,卽孔子出生后五、六年,晋、楚两大国在宋国召开了弭兵大会。自此以后,诸侯间的兼并战争少了,而各国内部,尤其是大国内部,权臣间或者强大氏族间的你吞我杀,却多起来了。鲁国呢,三大氏族(季氏、孟氏、仲氏)互相兼并现象不严重,但和鲁国公室冲突日益扩大。甚至迫使鲁昭公寄居齐国和晋国,死在晋国边邑干侯,鲁哀公出亡在越国,死在越国。\n这种动荡和变革,我认为是由奴隶社会崩溃而逐渐转化为封建社会引起的。根据《左传》,在孔子出生前十年或十一年,卽鲁襄公十年,鲁国三大家族便曾“三分公室而各有其一”。这就是把鲁君的“三郊三遂”(《尚书·费誓》)的军赋所出的土地人口全部瓜分为三,三家各有其一,而且把私家军队也并入,各帅一军。但三家所采取的军赋办法不同。季氏采取封建社会的办法,所分得的人口全部解放为自由民。孟氏采取半封建半奴隶的办法,年轻力壮的仍旧是奴隶。叔孙氏则依旧全用奴隶制。过了二十五年,又把公室再瓜分一次,分为四份,季氏得一半,孟氏和叔孙氏各得四分之一,都废除奴隶制。这正是孔子所耳闻目见的国家的大变化。在这种变革动荡时代中,自然有许多人提出不同主张。当时还谈不上“百家争鸣”,但主张不同则是自然的。孔子作为救世者,也有他的主张。他因而把和自己意见不同的主张称为“异端”。还说:“攻乎异端,斯害也已。”(2.16)\n孔子的志向很大,要做到“老者安之,朋友信之,少者怀之”。(5.26)在鲁国行不通,到齐国也碰壁,到陈蔡等小国,更不必说了。在卫国,被卫灵公供养,住了较长时间,晚年终于回到鲁国。大半辈子精力用于教育和整理古代文献。他对后代的最大贡献也就在这里。\n(二)孔子思想体系的渊源 # 孔子的世界观,留在下面再谈。我们先讨论孔子思想体系卽他的世界观形成的渊源。我认为从有关孔子的历史资料中选择那些最为可信的,来论定孔子的阶级地位、经历、学术以及所受的影响等等,这就可以确定孔子的思想体系形成的渊源。\n第一,孔子纵然是殷商的苗裔,但早巳从贵族下降到一般平民。他自己说:“吾少也贱。”足以说明他的身世。他父亲,《史记》称做叔梁纥,这是字和名的合称,春秋以前有这种称法,字在前,名在后。“叔梁”是字,“纥”是名。《左传》称做郰人纥(襄公十年),这是官和名的合称。春秋时代一些国家,习惯把一些地方长官叫“人”,孔子父亲曾经做过郰地的宰(卽长官),所以叫他做郰人纥。郰人纥在孔子出生后不久死去,只留得孔子的寡母存在。相传寡母名征在。寡母抚养孔子,孔子也得赡养寡母,因之,他不能不干些杂活。他自己说:“吾少也贱,故多能鄙事。”(9.6)鄙事就是杂活。委吏、乘田或许还是高级的“鄙事”。由此可以说,孔子的祖先出身贵族,到他自己,相隔太久了,失去了贵族的地位。他做委吏也好,做乘田也好,干其它“鄙事”也好,自必有一些共事的同伴。那些人自然都贫贱。难道自少小和他共事的贫贱者,不给孔子一点点影响么?孔子也能够完全摆脱那些人的影响么?这是不可能的。\n第二,孔子是鲁国人。在孔子生前,鲁国政权已在季、孟、仲孙三家之手,而季氏权柄势力又最大。以季氏而论,似乎有些自相矛盾的做法。当奴隶制度衰落时,他分得“公室”三分之一,便采用封建的军赋制度;到昭公五年,再“四分公室”,其它二家都学习他的榜样,全都采用封建军赋制度。这是他的进步处。但鲁昭公自二十五年出外居于齐国,到三十二年死在干侯,鲁国几乎七年没有国君,国内照常安定自不必说,因为政权早巳不在鲁昭公手里。但季氏,卽叫季孙意如的,却一点也没有夺取君位的意图,还曾想把鲁昭公迎接回国;鲁昭公死了,又立昭公之弟定公为君。这不能说是倒退的,也不能说是奇怪的,自然有它的原由。第一,正是这个时候,齐国的陈氏(《史记》作田氏)有夺取姜齐政柄的趋向,鲁昭公三年晏婴曾经向晋国的叔向作了这种预言,叔向也向晏婴透露了他对晋国公室削弱卑微的看法。然而,当时还没有一个国家由权臣取代君位的,季氏还没有胆量开这一先例。何况鲁国是弱小国家,齐、秦、晋、楚这些强大之国,能不以此为借口而攻伐季氏么?第二,鲁国是为西周奴隶社会制作礼乐典章法度的周公旦后代的国家,当时还有人说:“周礼尽在鲁矣。”(《左传》昭公二年)还说:鲁“犹秉周礼”(闵公元年)。周礼的内容究竟怎样,现在流传的周礼不足为凭。但周公姬旦制作它,其本意在于巩固奴隶主阶级的统治,是可以肯定的。这种传统在鲁国还有不小力量,季氏也就难以取鲁君之位而代之了。孔子对于季氏对待昭公和哀公的态度,是目见耳闻的,却不曾有一言半语评论它,是孔子没有评论呢?还是没有传下来呢?弄不清楚。这里我只想说明一点,卽孔子作为一个鲁国人,他的思想也不能不受鲁国的特定环境卽鲁国当时的国情的影响。当时的鲁国,正处于新、旧交替之中,卽有改革,而改革又不彻底,这种情况,也反映在孔子的思想上。\n第三,孔子自己说“信而好古”。(7.1)他的学子子贡说他老师“夫子焉不学?而亦何常师之有?”(19.22)孔子自己又说:“三人行,必有我师焉:择其善者而从之,其不善者而改之。”(7.22)可见孔子的学习,不但读书,而且还在于观察别人,尤其在“每事问”。(3.15)卽以古代文献而论,孔子是非常认真看待的。他能讲夏代的礼,更能讲述殷代的礼,却因为缺乏文献,无法证实,以至于感叹言之。(3.9)那么,他爱护古代文献和书籍的心情可想而知。由《论语》一书来考察,他整理过《诗经》的《雅》和《颂》,(9.15)命令儿子学诗学礼。(16.3)自己又说:“五十以学《易》。”(7.17)《易》本来是用来占筮的书,而孔子不用来占筮,却当作人生哲理书读,因此才说:“五十以学《易》,可以无大过矣。”他引用《易》“不恒其德,或承之羞”二句,结论是“不占而已矣”。(13.22)他征引过《尚书》。他也从许多早已亡佚的古书中学习很多东西。举一个例子,他的思想核心是仁。他曾为仁作一定义“克己复礼”。(12.1)然而这不是孔子自己创造的,根据《左传》昭公十二年孔子自己的话,在古代一种“志”书中,早有“克己复礼,仁也”的话。那么,孔子答对颜回“克己复礼为仁”,不过是孔子的“古为今用”罢了。孔子对他儿子伯鱼说:“不学礼,无以立。”(16.13)这本是孟僖子的话,见于《左传》昭公七年。孟僖子说这话时,孔子还不过十七、八岁,自然又是孔子借用孟僖子的话。足见孔子读了当时存在的许多书,吸取了他认为可用的东西,加以利用。古代书籍和古人对孔子都有不小影响。\n第四,古人,尤其春秋时人,有各种政治家、思想家,自然有进步的,有改良主义的,也有保守和倒退的。孔子对他们都很熟知,有的作好评,有的作恶评,有的不加评论。由这些地方,可以看出孔子对他们的看法和取舍,反过来也可从中看出他们对孔子的影响。子产是一位唯物主义者,又是郑国最有名、最有政绩的政治家和外交家。孔子对他极为赞扬。郑国有个“乡校”,平日一般士大夫聚集在那里议论朝廷政治,于是有人主张毁掉它。子产不肯,并且说:“其所善者,吾则行之;其所恶者,吾则改之,是吾师也,若之何毁之?”这时孔子至多十一岁,而后来评论说:“以是观之,人谓子产不仁,吾不信也。”(《左传》襄公三十一年)孔子以“仁”来赞扬子产的极有限的民主作风,足见他对待当时政治的态度。他讥评鲁国早年的执政臧文仲“三不仁”、“三不知(智)”。其中有压抑贤良展禽(柳下惠)一事(《左传》文公二年),而又赞许公叔文子大力提拔大夫僎升居卿位。用人唯贤,不准许压抑贤良,这也是孔子品评人物标准之一。又譬如晋国有位叔向(羊舌佾),当时贤良之士都表扬他,喜爱他。他也和吴季札、齐晏婴、郑子产友好,孔子对他没有什么议论,可能因为他政治态度过于倾向保守罢。春秋时代二三百年,著名而有影响的人物不少,他们的言行,或多或少地影响孔子。这自是孔子思想体系渊源之一。\n以上几点说明,孔子的思想渊源是复杂的,所受的影响是多方面的。我们今天研究孔子,不应当只抓住某一方面,片面地加以夸大,肯定一切或否定一切。\n(三)孔子论天、命、鬼神和卜筮 # 孔子是殷商苗裔,又是鲁国人,这两个国家比其它各国更为迷信。以宋国而论,宇宙有陨星,这是自然现象,也是常见之事,宋襄公是个图霸之君,却还向周内史过问吉凶,使得内史过不敢不诡辞答复。宋景公逝世,有二个养子,宋昭公——养子之一,名“得”,《史记》作“特”——因为作了个好梦,就自信能继承君位。这表示宋国极迷信,认为天象或梦境预示着未来的吉凶。至于鲁国也一样,穆姜搬家,先要用《周易》占筮(《左传》襄公九年);叔孙穆子刚出生,也用《周易》卜筮(《左传》昭公五年);成季尚未出生,鲁桓公既用龟甲卜,又用蓍草筮(《左传》闵公二年),而且听信多年以前的童谣,用这童谣来断定鲁国政治前途。这类事情,在今天看来,都很荒谬。其它各国无不信天、信命、信鬼神。这是奴隶社会以及封建社会的必然现象,唯有真正的唯物主义者而又有勇气的,才不如此。以周太史过而论,他认为“陨星”是“阴阳”之事,而“吉凶由人”,因为不敢得罪宋襄公,才以自己观察所得假“陨星”以答。以子产而论,能说“天道远,人道迩,非所及也”(《左传》昭公十八年),却对伯有作为鬼魂出现这种谣传和惊乱,不敢作勇敢的否定,恐怕一则不愿得罪晋国执政大臣赵景子,二则也不敢过于作违俗之论罢!\n孔子是不迷信的。我认为只有庄子懂得孔子,庄子说:“六合之外,圣人存而不论。”(《庄子·齐物论篇》)庄子所说的“圣人”无疑是孔子,由下文“春秋经世先王之志,圣人议而不辩”可以肯定。“天”、“命”、“鬼神”都是“六合之外,圣人存而不论”的东西。所谓“存而不论”,用现代话说,就是保留它而不置可否,不论其有或无。实际上也就是不大相信有。\n孔子为什么没有迷信思想,这和他治学态度的严谨很有关系。他说过,“多闻阙疑”,“多见阙殆”。(2.18)还说:“学而不思则罔,思而不学则殆。”(2.15)足见他主张多闻、多见和学思结合。“思”什么呢?其中至少包括思考某事某物的道理。虽然当时绝大多数人相信卜筮,相信鬼神,孔子却想不出它们存在的道理。所以他不讲“怪、力、乱、神”。(7.21)“力”和“乱”,或者是孔子不愿谈,“怪”和“神”很大可能是孔子根本采取“阙疑”、“存而不论”的态度。臧文仲相信占卜,畜养一个大乌龟,并且给它极为华丽的地方住,孔子便批评他不聪明,或者说是愚蠢。(5.18)一个乌龟壳怎能预先知道一切事情呢?这是孔子所想不通的。由于孔子这种治学态度,所以能够超出当时一般人,包括宋、鲁二国人之上。“知之为知之,不知为不知”,(2.17)不但于“六合之外”存而不论,卽六合之内,也有存而不论的。\n我们现在来谈谈孔子有关天、命、卜筮和鬼神的一些具体说法和看法。我只用《论语》和《左传》的资料。其它古书的数据,很多是靠不住的,需要更多地审查和选择,不能轻易使用。\n先讨论“天”。\n在《论语》中,除复音词如“天下”、“天子”、“天道”之类外,单言“天”字的,一共十八次。在十八次中,除掉别人说的,孔子自己说了十二次半。在这十二次半中,“天”有三个意义:一是自然之“天”,一是主宰或命运之天,一是义理之天。自然之天仅出现三次,而且二句是重复句:\n天何言哉!四时行焉,百物生焉,天何言哉,(7.19)巍巍乎唯天为大。(8.19)\n义理之天,仅有一次:\n获罪于天,无所祷也。(3.13)\n命运之天或主宰之天就比较多,依出现先后次序録述它:\n予所否者,天厌之!天厌之!(6.28)\n天生德于予,桓魋其如予何?(7.23)\n天之将丧斯文也,后死者不得与于斯文也;天之未丧斯文也,匡人其如予何?(9.5)\n吾谁欺,欺天乎!(9.12)\n不怨天,不尤人。下学而上达,知我者,其天乎!(14.35)\n另外一次是子夏说的。他说:“商闻之矣:死生有命,富贵在天。”但这话子夏是听别人说的。听谁说的呢?很大可能是听孔子说的,所以算它半次。\n若从孔子讲“天”的具体语言环境来说,不过三、四种。一种是发誓,“天厌之”就是当时赌咒的语言。一种是孔子处于困境或险境中,如在匡被围或者桓魋想谋害他,他无以自慰,只好听天。因为孔子很自负,不但自认有“德”,而且自认有“文”,所以把自己的生死都归之于天。一种是发怒,对子路的弄虚作假,违犯礼节大为不满,便骂“欺天乎”。在不得意而又被学生引起牢骚时,只得说“知我者其天乎”。古人也说过,疾病则呼天,创痛则呼父母。孔子这样称天,并不一定认为天真是主宰,天真有意志,不过藉天以自慰、或发泄感情罢了。至于“获罪于天”的“天”,意思就是行为不合天理。\n再讨论“命”,《论语》中孔子讲“命”五次半,讲“天命”三次。也罗列于下:\n亡之!命矣夫!斯人也而有斯疾也!(6.10)\n道之将行也与,命也;道之将废也与,命也。公伯寮其如命何?(14.36)\n不知命,无以为君子也,(20.3)\n同“富贵在天”一样,子夏还听他说过“死生有命”。关于“天命”的有下列一些语句:\n五十而知天命。(2.4)\n君子有三畏:畏天命,……小人不知天命而不畏也。(16.8)\n从文句表面看,孤立地看,似乎孔子是宿命论者,或者如《墨子·天志篇》所主张的一样是天有意志能行令论者。其实不如此。古代人之所以成为宿命论者或者天志论者,是因为他们对于宇宙以至社会现象不能很好理解的缘故。孔子于“六合之外,存而不论”,他认为对宇宙现象不可能有所知,因此也不谈,所以他讲“命”,都是关于人事。依一般人看,在社会上,应该有个“理”。无论各家各派的“理”怎样,各家各派自然认为他们的“理”是正确的,善的,美的。而且他们还要认为依他的“理”而行,必然会得到“善报”;违背他们的“理”而行,必然会有“凶恶”的结果。然而世间事不完全或者大大地不如他们的意料,这就是常人所说善人得不到好报,恶人反而能够荣华富贵以及长寿。伯牛是好人,却害着治不好的病,当孔子时自然无所谓病理学和生理学,无以归之,只得归之于“命”。如果说,孔子是天志论者,认为天便是人间的主宰,自会“赏善而罚淫”,那伯牛有疾,孔子不会说“命矣夫”,而会怨天瞎了眼,怎么孔子自己又说“不怨天”呢?(14.35)如果孔子是天命论者,那一切早已由天安排妥当,什么都不必干,听其自然就可以了,孔子又何必栖栖遑遑“知其不可而为之”呢?人世间事,有必然,有偶然。越是文化落后的社会,偶然性越大越多,在不少人看来,不合“理”的事越多。古人自然不懂得偶然性和必然性以及这两者的关系,由一般有知识者看来,上天似乎有意志,又似乎没有意志,这是谜,又是个不可解的谜,孟子因之说:“莫之为而为者,天也;莫之致而至者,命也。”(《万章上》)这就是把一切偶然性,甚至某些必然性,都归之于“天”和“命”。这就是孔、孟的天命观。\n孔子是怀疑鬼神的存在的。他说:“祭如在,祭神如神在。”(3.12)祭祖先(鬼)好像祖先真在那里,祭神好像神真在那里。所谓“如在”“如神在”,实际上是说并不在。孔子病危,子路请求祈祷,并且征引古书作证,孔子就婉言拒绝。(7.35)楚昭王病重,拒绝祭神,孔子赞美他“知大道”(《左传》哀公六年)。假使孔子真认为天地有神灵,祈祷能去灾得福,为什么拒绝祈祷呢?为什么赞美楚昭王“知大道”呢?子路曾问孔子如何服事鬼神。孔子答说:“活人还不能服事,怎么能去服事死人?”子路又问死是怎么回事。孔子答说:“生的道理还没有弄明白,怎么能够懂得死?”(11.12)足见孔子只讲现实的事,不讲虚无渺茫的事。孔子说:“君子于其所不知,盖阙如也。”(13.3)孔子对死和鬼的问题,回避答复,也是这种表现。那么为什么孔子要讲究祭祀,讲孝道,讲三年之丧呢?我认为,这是孔子利用所谓古礼来为现实服务。殷人最重祭祀,最重鬼神。孔子虽然不大相信鬼神的实有,却不去公开否定它,而是利用它,用曾参的话说:“慎终追远,民德归厚矣。”(1.9)很显然,孔子的这些主张不过企图藉此维持剥削者的统治而已。\n至于卜筮,孔子曾经引《易经》“不恒其德,或承之羞”,结论是不必占卜了。这正如王充《论衡·卜筮篇》所说,“枯骨死草,何能知吉凶乎”(依刘盼遂《集解》本校正)。\n(四)孔子的政治观和人生观 # 在春秋时代,除郑国子产等几位世卿有心救世以外,本人原在下层地位,而有心救世的,像战国时许多人物一般,或许不见得没有,但却没有一人能和孔子相比,这从所有流传下来的历史数据可以肯定。在《论语》一书中反映孔子热心救世,碰到不少隐士泼以冰凉的水。除长沮、桀溺外,还有楚狂接舆、(18.5)荷筱丈人、(18.7)石门司门者(14.38)和微生亩(14.32)等等。孔子自己说:“天下有道,丘不与易也。”(18.6)石门司门者则评孔子为“知其不可而为之”。“知其不可而为之”,可以说是“不识时务”,但也可以说是坚韧不拔。孔子的热心救世,当时未见成效,有客观原因,也有主观原因,这里不谈。但这种“席不暇暖”(韩愈:〈争臣论〉,盖本于《文选·班固答宾戏》),“三月无君则吊”(《孟子·滕文公下》)的精神,不能不说是极难得的,也是可敬佩的。\n孔子的时代,周王室已经无法恢复权力和威信,这是当时人都知道的,难道孔子不清楚?就是齐桓公、晋文公这样的霸主,也已经成为陈迹。中原各国,不是政权落于卿大夫,就是“陪臣执国命”。如晋国先有六卿相争,后来只剩四卿——韩、赵、魏和知伯。《左传》最后载知伯被灭,孔子早“寿终正寝”了。齐国陈恒杀了齐简公,这也是孔子所亲见的。(14.21)在鲁国,情况更不好,“禄之去公室五世(宣、成、襄、昭、定五公)矣,政逮于大夫四世(季文子、武子、平子、桓子四代)矣,故夫三桓之子孙微矣”,(16.3)而处于“陪臣执国命”(16.2)时代。在这种情况下,中原诸国,如卫、陈、蔡等,国小力微,不能有所作为。秦国僻在西方,自秦穆公、康公以后已无力再过问中原的事。楚国又被吴国打得精疲力尽,孔子仅仅到了楚国边境,和叶公相见。(13.16,又7.19)纵然有极少数小官,如仪封人之辈赞许孔子,(3.24)但在二千多年以前,要对当时政治实行较大改变,没有适当力量的凭借是不可能做到的。孔子徒抱大志,感叹以死罢了。\n孔子的政治思想,从尧曰篇可以看出。我认为尧曰篇“谨权量,审法度”以下都是孔子的政治主张。然而度、量、衡的统一直到孔子死后二百五十八年,秦始皇二十六年统一中国后才实行。孔子又说,治理国家要重视三件事,粮食充足,军备无缺,人民信任,而人民信任是极为重要的。(12.7)甚至批评晋文公伐原取信(见《左传》僖公二十六年)为“谲而不正”。(14.15)孔子主张“正名”,(13.3)正名就是“君君,臣臣,父父,子子”;(12.11)而当时正是“君不君,臣不臣,父不父,子不子”。孔子的政绩表现于当时的,一是定公十年和齐景公在夹谷相会,在外交上取得重大胜利;一是子路毁坏季氏的费城,叔孙氏自己毁坏了他们的郈城,唯独孟氏不肯毁坏成城(《左传》定公十二年)。假使三家的老巢城池都被毁了,孔子继续在鲁国做官,他的“君君,臣臣”的主张有可能逐渐实现。但齐国的“女乐”送来,孔子只得离开鲁国了。(18.4)孔子其它政治主张,仅仅托之空言。\n孔子还说:“如有用我者,吾其为东周乎!”(17.5)孔子所谓“东周”究竟是些什么内容,虽然难以完全考定,但从上文所述以及联系孔子其它言行考察,可以肯定绝不是把周公旦所制定的礼乐制度恢复原状。孔子知道时代不同,礼要有“损益”。(2.23)他主张“行夏之时”,(15.11)便是对周礼的改变。夏的历法是以立春之月为一年的第一月,周的历法是以冬至之月为一年的第一月。夏历便于农业生产,周历不便于农业生产。从《左传》或者《诗经》看,尽管某些国家用周历,但民间还用夏历。晋国上下全用夏历。所谓周礼,在春秋以前,很被人重视。孔子不能抛弃这面旗帜,因为它有号召力,何况孔子本来景仰周公?周礼是上层建筑,在阶级社会,封建地主阶级无妨利用奴隶主阶级某些礼制加以改造,来巩固自己的统治。不能说孔子要“复礼”,要“为东周”,便是倒退。他在夹谷会上,不惜用武力对待齐景公的无礼,恐怕未必合乎周礼。由此看来,孔子的政治主张,尽管难免有些保守处,如“兴灭国,继绝世”,(20.1)但基本倾向是进步的,和时代的步伐合拍的。\n至于他的人生观,更是积极的。他“发愤忘食,乐以忘忧,不知老之将至”。(7.19)他能够过穷苦生活,而对于不义的富贵,视同浮云。(7.16)这些地方还不失他原为平民的本色。\n(五)关于忠恕和仁 # 春秋时代重视“礼”,“礼”包括礼仪、礼制、礼器等,却很少讲“仁”。我把《左传》“礼”字统计一下,一共讲了462次:另外还有“礼食”一次,“礼书”、“礼经”各一次,“礼秩”一次,“礼义”三次。但讲“仁”不过33次,少于讲“礼”的至429次之多。并且把礼提到最高地位。《左传》昭公二十六年晏婴对齐景公说:“礼之可以为国也久矣,与天地并。”还有一个现象,《左传》没有“仁义”并言的。《论语》讲“礼”75次,包括“礼乐”并言的;讲“仁”却109次。由此看来,孔子批判地继承春秋时代的思潮,不以礼为核心,而以仁为核心。而且认为没有仁,也就谈不上礼,所以说:“人而不仁,如礼何?”(3.3)\n一部《论语》,对“仁”有许多解释,或者说“克己复礼为仁”,(12.1)或者说“仁者先难而后获”,(6.22)或者说“能行五者(恭、宽、信、敏、惠)于天下为仁”,(17.6)或者说“爱人”就是“仁”,(12.22)还有很多歧异的说法。究竟“仁”的内涵是什么呢?我认为从孔子对曾参一段话可以推知“仁”的真谛。孔子对曾参说:“吾道一以贯之。”曾参告诉其它同学说:“夫子之道,忠恕而已矣。”(4.15)“吾道”就是孔子自己的整个思想体系,而贯穿这个思想体系的,必然是它的核心。分别讲是“忠恕”,概括讲是“仁”。\n孔子自己曾给“恕”下了定义:“己所不欲,勿施于人。”(15.24)这是“仁”的消极面。另一面是积极面:“己欲立而立人,己欲达而达人。”(6.30)而“仁”并不是孔子所认为的最高境界,“圣”才是最高境界。“圣”的目标是:“博施于民而能济众”,(6.30)“修己以安百姓”。(14.41)这个目标,孔子认为尧、舜都未必能达到。\n用具体人物来作证。\n孔子不轻许人以“仁”。有人说:“雍也仁而不佞。”孔子的答复是,“不知其仁(意卽雍不为仁),焉用佞”。(5.5)又答复孟武伯说,子路、冉有、公西华,都“不知其仁”。(5.8)孔子对所有学生,仅仅说“回也其心三月不违仁”,(6.7)这也未必是说颜渊是仁人。对于令尹子文和陈文子,说他们“忠”或“清”,却不同意他们是仁。(5.19)但有一件似乎不无矛盾的事,孔子说管仲不俭,不知礼,(3.22)却说:“桓公九合诸侯,不以兵车,管仲之力也!如其仁!如其仁!”(14.16)由这点看来,孔子认为管仲纵是“有反坫”“有三归”,却帮助齐桓公使天下有一个较长期的(齐桓公在位四十三年)、较安定的局面,这是大有益于大众的事,而这就是仁德,《孟子·告子下》曾载齐桓公葵丘之会的盟约,其中有几条,如“尊贤育才”“无曲防,无遏籴”。并且说:“凡我同盟之人,既盟之后,言归于好。”孟子还说当孟子时的诸侯,都触犯了葵丘的禁令。由此可见,依孔子意见,谁能够使天下安定,保护大多数人的生命,就可以许他为仁。\n孔子是爱惜人的生命的。殷商是奴隶社会,但那时以活奴隶殉葬的风气孔子未必知道。自从生产力有所发展,奴隶对奴隶主多少还有些用处、有些利益以后,奴隶主便舍不得把他们活埋,而用木偶人、土俑代替殉葬的活人了。在春秋,也有用活人殉葬的事。秦穆公便用活人殉葬,殉葬的不仅是奴隶,还有闻名的贤良的三兄弟,秦国人叫他们做“三良”。秦国人谴责这一举动,《诗经·秦风》里《黄鸟》一诗就是哀恸三良、讥刺秦穆公的。《左传》宣公十五年记载晋国魏犨有个爱妾,魏犨曾经告诉他儿子说,我死了,一定嫁了她。等到魏犨病危,却命令儿子,一定要她殉葬,在黄泉中陪侍自己。结果是他儿子魏颗把她嫁出去。足见春秋时代一般人不以用活人殉葬为然。孟子曾经引孔子的话说:“始作俑者,其无后乎!”(《孟子·梁惠王上》)在别处,孔子从来不曾这样狠毒地咒骂人。骂人“绝子灭孙”,“断绝后代”,在过去社会里是谁都忍受不了的。用孟子的话说,“不孝有三,无后为大。”(《孟子·离娄上》)孔子对最初发明用木俑土俑殉葬的人都这样狠毒地骂,对于用活人殉葬的态度又该怎样呢?由此足以明白,在孔子的仁德中,包括着重视人的生命。\n孔子说仁就是“爱人”。后代,尤其现代,有些人说“人”不包括“民”。“民”是奴隶,“人”是士以上的人物。“人”和“民”二字,有时有区别,有时没有区别。以《论语》而论,“节用而爱人,使民以时”,(1.5)“人”和“民”对言,就有区别。“逸民”(18.8)的“民”,便不是奴隶,因为孔子所举的伯夷、叔齐、柳下惠等都是上层人物,甚至是大奴隶主,“人”和“民”便没有区别。纵然在孔子心目中,“士”以下的庶民是不足道的,“民斯为下矣”,(16.9)但他对于“修己以安百姓”(14.42)“博施于民而能济众”(6.30)的人,简直捧得比尧和舜还高。从这里又可以看到,孔子的重视人的性命,也包括一切阶级、阶层的人在内。\n要做到“修己以安人”,至少做到“不以兵车”“一匡天下”,没有相当地位、力量和时间是不行的。但是做到“己所不欲,勿施于人”,孔子以为比较容易。子贡问“有一言而可以终身行之者乎”,孔子便拈出一个“恕”字。实际上在阶级社会中,要做到“己所不欲,勿施于人”,不但极难,甚至不可能,只能是一种幻想,孔子却认为可以“终身行之”,而且这是“仁”的一个方面。于是乎说能“为仁由己”(12.1)了。\n“四人帮”的论客们捉住“克己复礼为仁”(12.1)一句不放,武断地说孔子所要“复”的“礼”是周礼,是奴隶制的礼,而撇开孔子其它论“仁”的话不加讨论,甚至不予参考,这是有意歪曲,妄图借此达到他们政治上的罪恶目的。《论语》“礼”字出现七十四次,其中不见孔子对礼下任何较有概括性的定义。孔子只是说:“人而不仁,如礼何?人而不仁,如乐何?”(3.3)还说:“礼云礼云,玉帛云乎哉?乐云乐云,钟鼓云乎哉?”(17.11)可见孔子认为礼乐不在形式,不在器物,而在于其本质。其本质就是仁。没有仁,也就没有真的礼乐。春秋以及春秋以上的时代,没有仁的礼乐,不过徒然有其仪节和器物罢了。孔子也并不是完全固执不变的人。他主张臣对君要行“拜下”之礼,但对“麻冕”却赞同实行变通,(9.3)以求省俭。他不主张用周代历法,上文已经说过。由此看来,有什么凭据能肯定孔子在复周礼呢?孔子曾经说自己,“我则异于是,无可无不可”,(18.8)孟子说孔子为“圣之时”(万章下),我认为适才是真正的孔子!\n(六)孔子对后代的贡献 # 孔子以前有不少文献,孔子一方面学习它,一方面加以整理,同时向弟子传授。《论语》所涉及的有《易》,有《书》,有《诗》。虽然有“礼”,却不是简册(书籍)。据《礼记·杂记下》“恤由之丧,哀公使孺悲之孔子学士丧礼,士丧礼于是乎书”,那么,《仪礼》诸篇虽出在孔子以后,却由孔子传出。孺悲这人也见于《论语》,他曾求见孔子,孔子不但以有病为辞不接见,还故意弹瑟使他知道是托病拒绝,其实并没有病。(17.20)但孺悲若是受哀公之命而来学,孔子就难以拒绝。《论语》没有谈到《春秋》,然而自《左传》作者以来都说孔子修《春秋》,孟子甚至说孔子作《春秋》。《公羊春秋》和《谷梁春秋》记载孔子出生的年、月、日,《左氏春秋》也记载孔子逝世的年、月、日;而且《公羊春秋》、《谷梁春秋》止于哀公十四年“西狩获麟”,《左氏春秋》则止于哀公十六年“夏四月己丑孔丘卒”。三种《春秋》,二种记载孔子生,一种记载孔子卒,能说《春秋》和孔子没有关系么?我不认为孔子修过《春秋》,更不相信孔子作过《春秋》,而认为目前所传的《春秋》还是鲁史的原文。尽管王安石诋毁《春秋》为“断烂朝报”(初见于苏辙《春秋集解》自序,其后周麟之、陆佃以及《宋史·王安石传》都曾记载这话),但《春秋》二百四十二年的史事大纲却赖此以传。更为重要的事是假若没有《春秋》,就不会有人作《左传》。《春秋》二百多年的史料,我们就只能靠地下挖掘。总而言之,古代文献和孔子以及孔门弟子有关系的,至少有《诗》、《书》、《易》、《仪礼》、《春秋》五种。\n孔子弟子不过七十多人,《史记·孔子世家》说“弟子盖三千焉”,用一“盖”字,就表明太史公说这话时自己也不太相信。根据《左传》昭公二十年记载,琴张往吊宗鲁之死,孔子阻止他。琴张是孔子弟子,这时孔子三十岁。其后又不断地招收门徒,所以孔子弟子有若干批;年龄相差也很大。依《史记·仲尼弟子列传》所载,子路小于孔子九岁,可能是年纪最大的学生。(《史记·索隐》引《孔子家语》说颜无繇只小于孔子六岁,不知可靠否,因不计数。)可能以颛孙师卽子张为最小,小于孔子四十八岁,孔子四十八岁时他才出生。假定他十八岁从孔子受业,孔子已是六十六岁的老人。孔子前半生,有志于安定天下,弟子也跟随他奔走,所以孔子前一批学生从事政治的多,故《左传》多载子路、冉有、子贡的言行。后辈学生可能以子游、子夏、曾参为著名,他们不做官,多半从事教学。子夏曾居于西河,为魏文侯所礼遇,曾参曾责备他“退而老于西河之上,使西河之民疑女于夫子”(《礼记·檀弓上》),可见他在当时名声之大。孔门四科,文学有子游、子夏,(11.3)而子张也在后辈之列,自成一派,当然也设帐教书,所以《荀子·非十二子篇》有“子张氏之贱儒”、“子夏氏之贱儒”和“子游氏之贱儒”。姑不论他们是不是“贱儒”,但他们传授文献,使中国古代文化不致绝灭,而且有发展、有变化,这种贡献开自孔子,行于孔门。若依韩非子显学篇所说,儒家又分为八派。战国初期魏文侯礼待儒生,任用能人;礼待者,卽所谓“君皆师之”(《史记·魏世家》,亦见《韩诗外传》和《说苑》)的,有卜子夏、田子方(《吕氏春秋·当染篇》说他是子贡学生)、段干木(《吕氏春秋·尊贤篇》说他是子夏学生)三人。信用的能人有魏成子,卽推荐子夏等三人之人;有翟璜,卽推荐吴起、乐羊、西门豹、李克、屈侯鲋(《韩诗外传》作“赵苍”)的人。吴起本是儒家,其后成为法家和军事家。李克本是子夏学生,但为魏文侯“务尽地力”,卽努力于开垦并提高农业生产力,而且着有《法经》(《晋书·刑法志》),也变成法家。守孔子学说而不加变通的,新兴地主阶级的头目,只尊重他们,却不任用他们。接受孔门所传的文化教育,而适应形势,由儒变法的,新兴地主阶级的头目却任用他们,使他们竭尽心力,为自己国家争取富强。魏文侯礼贤之后,又有齐国的稷下。齐都(今山东临淄镇)西面城门叫稷门,在稷门外建筑不少学舍,优厚供养四方来的学者,让他们辩论和著书,当时称这班被供养者为稷下先生。稷下可能开始于田齐桓公,而盛于威王、宣王,经历愍王、襄王,垂及王建,历时一百多年。荀子重礼,他的礼近于法家的法,而且韩非、李斯都出自他门下,但纵在稷下“三为祭酒”(《史记·孟荀列传》),却仍然得不到任用,这是由于他仍然很大程度地固守孔子学说而变通不大。但他的讲学和著作,却极大地影响后代。韩非是荀卿学生,也大不以他老师为然。《显学篇》的“孙氏之儒”就是“荀氏之儒”。然而没有孔子和孔门弟子以及其后的儒学,尤其是荀卿,不但不可能有战国的百家争鸣,更不可能有商鞅帮助秦孝公变法(《晋书·刑法志》说:“李悝[卽李克]着法经六篇,商鞅受之以相秦。”),奠定秦始皇统一的基础;尤其不可能有李斯帮助秦始皇统一天下。溯源数典,孔子在学术上、文化上的贡献以及对后代的影响是不可磨灭的。\n孔子的学习态度和教学方法,也有可取之处。孔子虽说“生而知之者上也”,(16.9)自己却说:“我非生而知之者,好古,敏以求之者也。”(7.20)似乎孔子并不真正承认有“生而知之者”。孔子到了周公庙,事事都向人请教,有人讥笑他不知礼。孔子答复是,不懂得就问,正是礼。(3.15)孔子还说:“三人行,必有我师焉:择其善者而从之,其不善者而改之。”(7.22)就是说,在交往的人中,总有我的正面老师,也有我的反面教员。子贡说,孔子没有一定的老师,哪里都去学习。(19.22)我们现在说“活到老,学到老”。依孔子自述的话,“发愤忘食,乐以忘忧,不知老之将至”,(7.19)就是说学习不晓得老。不管时代怎么不同,如何发展,这种学习精神是值得敬佩而采取的。\n孔子自己说“诲人不倦”,(7.2,又34)而且毫无隐瞒。(7.24)元好问〈论诗〉诗说:“鸳鸯绣了从教看,莫把金针度与人。”过去不少工艺和拳术教师,对学生总留一手,不愿意把全部本领尤其最紧要处,最关键处,俗话说的“最后一手”“看家本领”传授下来。孔子则对学生无所隐瞒,因而才赢得学生对他的无限尊敬和景仰。孔子死了,学生如同死了父母一般,在孔子墓旁结庐而居,三年而后去,子贡还继续居住墓旁三年(《孟子·滕文公上》)。有这种“诲人不倦”的老师,才能有这种守庐三年、六年的学生。我们当然反对什么守庐,但能做到师生关系比父子还亲密,总有值得学习的地方。\n孔子对每个学生非常了解,对有些学生作了评论。在解答学生的疑问时,纵然同一问题,因问者不同,答复也不同。颜渊篇记载颜渊、仲弓、司马牛三人“问仁”,孔子有三种答案。甚至子路和冉有都问“闻斯行诸”,孔子的答复竟完全相反,引起公西华的疑问。(11.22)因材施教,在今天的教育中是不是还用得着?我以为还是可以用的,只看如何适应今天的情况而已。时代不同,具体要求和做法必然也不同。然而孔子对待学生的态度和某些教学方法如“不愤不启,不悱不发”,(7.8)就是在今天,也还有可取之处。\n孔子以前,学在官府。《左传》载郑国有乡校,那也只有大夫以上的人及他们的子弟才能入学。私人设立学校,开门招生,学费又非常低廉,只是十条肉干,(7.7)自古以至春秋,恐怕孔子是第一人。有人说同时有少正卯也招收学徒,这事未必可信。纵有这事,但少正卯之学和他的学生对后代毫无影响。\n孔子所招收的学生,除鲁的南宫敬叔以外,如果司马牛果然是桓魋兄弟,仅他们两人出身高门,其余多出身贫贱。据《史记·仲尼弟子列传》,子路“冠雄鸡,佩豭豚”,简直像个流氓。据《史记·游侠列传》,原宪“终身空室蓬户,褐衣疏食”,更为穷困。《论语》说公冶长无罪被囚,假设他家有地位,有罪还未必被囚,何况无罪?足见也是下贱门第。据《弟子列传·正义》引《韩诗外传》,曾参曾经做小吏,能谋斗升之粟来养亲,就很满足,可见曾点、曾参父子都很穷。据《吕氏春秋·尊师篇》,子张是“鲁之鄙家”。颜回居住在陋巷,箪食瓢饮,死后有棺无椁,都见于《论语》。由此推论,孔子学生,出身贫贱的多,出身富贵的可知者只有二人。那么,孔子向下层传播文化的功劳,何能抹杀?《淮南子·要略篇》说:“墨子学儒者之业,受孔子之术。”这不是说墨子出自儒,而是说,在当时,要学习文化和文献,离开孔门不行。韩非子说“今之显学,儒、墨也”,由儒家墨家而后有诸子百家,所以我说,中国文化的流传和发达与孔子的整理古代文献和设立私塾是分不开的。\n导言 # (一)“论语”命名的意义和来由 # 《论语》是这样一部书,它记载着孔子的言语行事,也记载着孔子的若干学生的言语行事。班固的《汉书·艺文志》说:\n“《论语》者,孔子应答弟子、时人及弟子相与言而接闻于夫子之语也。当时弟子各有所记,夫子既卒,门人相与辑而论纂,故谓之《论语》。”\n《文选·辩命论》注引《傅子》也说:\n“昔仲尼既殁,仲弓之徒追论夫子之言,谓之《论语》。”\n从这两段话裹,我们得到两点概念:(1)“论语”的“论”是“论纂”的意思,“论语”的“语”是“语言”的意思,“论语”就是把“接闻于夫子之语”“论纂”起来的意思。(2)“论语”的名字是当时就有的,不是后来别人给它的。\n关于“论语”命名的意义,后来还有些不同的说法,譬如刘熙在《释名·释典艺》中说:“《论语》,记孔子与弟子所语之言也。论,伦也,有伦理也。语,叙也,叙己所欲说也。”那么,“论语”的意义便是“有条理地叙述自己的话”。说到这里,谁都不免会问一句:难道除孔子和他的弟子以外,别人的说话都不是“有条理的叙述”吗?如果不是这样,那“论语”这样命名有什么意义呢?可见刘熙这一解释是很牵强的。(《释名》的训诂名物,以音训为主,其中不少牵强傅会的地方。)还有把“论”解释为“讨论”的,说“论语”是“讨论文义”的书,何异孙的《十一经问对》便如是主张,更是后出的主观的看法了。\n关于《论语》命名的来由,也有不同的说法。王充在《论衡·正说篇》便说:“初,孔子孙孔安国以教鲁人扶卿,官至荆州刺史,始曰《论语》。”似乎是《论语》之名要到汉武帝时才由孔安国、扶卿给它的。这一说法不但和刘歆、班固的说法不同,而且也未必与事实相合,《礼记·坊记》中有这样一段话:\n“子云:君子弛其亲之过而敬其美。《论语》曰:‘三年无改于父之道,可谓孝矣。’”\n坊记的著作年代我们目前虽然还不能确定,但不会在汉武帝以后,是可以断言的①。因之,《论衡》的这一说法也未必可靠。\n由此可以得出结论:“论语”这一书名是当日的编纂者给它命名的,意义是语言的论纂。\n①吴骞《经说》因《坊记》有“论语”之称,便认它是汉人所记,固属武断;而沈约却说《坊记》是子思所作,也欠缺有力论证。\n(二)“论语”的作者和编着年代 # 《论语》又是若干断片的篇章集合体。这些篇章的排列不一定有什么道理;就是前后两章间,也不一定有什么关连。而且这些断片的篇章绝不是一个人的手笔。《论语》一书,篇幅不多,却出现了不少次的重复的章节。其中有字句完全相同的,如“巧言令色鲜矣仁”一章,先见于学而篇第一,又重出于阳货篇第十七:“博学于文”一章先见于雍也篇第六,又重出于颜渊篇第十二。又有基本上是重复只是详略不同的,如“君子不重,”学而篇第一多出十一个字,子罕篇第九只载“主忠信”以下的十四个字;“父在观其志”章,学而篇第一多出十字,里仁篇第四只载“三年”以下的十二字。还有一个意思,却有各种记载的,如里仁篇第四说:“不患莫己知,求为可知也,”宪问篇第十四又说:“不患人之不己知,患其不能也。”卫灵公篇第十五又说:“君子病无能焉,不病人之不己知也。”如果加上学而篇第一的“人不知而不愠,不亦君子乎”,便是重复四次。这种现象只能作一个合理的推论:孔子的言论,当时弟子各有记载,后来才汇集成书。所以《论语》一书绝不能看成某一个人的著作。\n那么,《论语》的作者是一些什么人呢?其中当然有孔子的学生。今天可以窥测得到的有两章。一章在子罕篇第九:\n“牢曰:‘子云:吾不试,故艺。’”\n“牢”是人名,相传他姓琴,字子开,又字子张(这一说法最初见于王肃的伪《孔子家语》,因此王引之的《经义述闻》和刘宝楠的《论语正义》都对它怀疑,认为琴牢和琴张是不同的两个人)。不论这一传说是否可靠,但这里不称姓氏只称名,这种记述方式和《论语》的一般体例是不相吻合的。因此,便可以作这样的推论,这一章是琴牢的本人的记载,编辑《论语》的人,“直取其所记而载之耳”(日本学者安井息轩《论语集说》中语)。另一章就是宪问篇第十四的第一章:\n“宪问耻。子曰:‘邦有道,谷;邦无道,谷,耻也。”\n“宪”是原宪,字子思,也就是雍也篇第六的“原思为之宰”的原思。这里也去姓称名,不称字,显然和《论语》的一般体例不合,因此也可以推论,这是原宪自己的笔墨。\n《论语》的篇章不但出自孔子不同学生之手,而且还出自他不同的再传弟子之手。这里面不少是曾参的学生的记载。像泰伯篇第八的第一章:\n“曾子有疾,召门弟子曰:‘启予足!启予手!《诗》云,战战兢兢,如临深渊,如履薄冰。而今而后,吾知免夫!小子!’”\n不能不说是曾参的门弟子的记载。又如子张篇第十九:\n“子夏之门人问交于子张。子张曰:‘子夏云何?’对曰:‘子夏曰:可者与之,其不可者拒之。’子张曰:‘异乎吾所闻:君子尊贤而容众,嘉善而矜不能。我之大贤与,于人何所不容?我之不贤与,人将拒我,如之何其拒人也?”\n这一段又像子张或者子夏的学生的记载。又如先进篇第十一的第五章和第十三章:\n“子曰:‘孝哉闵子骞,人不间于其父母昆弟之言。”\n“闵子侍侧,誾誾如也;子路,行行如也;冉有、子贡,侃侃如也。子乐。”\n孔子称学生从来直呼其名,独独这里对闵损称字,不能不启人疑窦。有人说,这是“孔子述时人之言”,从上下文意来看,这一解释不可凭信,崔述在《论语余说》中加以驳斥是正确的。我认为这一章可能就是闵损的学生所追记的,因而有这一不经意的失实。至于闵子侍侧一章,不但闵子骞称“子”,而且列在子路、冉有、子贡三人之前,都是难以理解的。以年龄而论,子路最长;以仕宦而论,闵子更赶不上这三人。他凭什么能在这一段记载上居于首位而且得着“子”的尊称呢?合理的推论是,这也是闵子骞的学生把平日闻于老师之言追记下来而成的。\n《论语》一书有孔子弟子的笔墨,也有孔子再传弟子的笔墨,那么,著作年代便有先有后了。这一点,从词义的运用上也适当地反映了出来。譬如“夫子”一词,在较早的年代一般指第三者,相当于“他老人家”,直到战国,才普遍用为第二人称的表敬代词,相当于“你老人家”。《论语》的一般用法都是相当于“他老人家”的,孔子学生当面称孔子为“子”,背面才称“夫子”,别人对孔子也是背面才称“夫子”,孔子称别人也是背面才称“夫子”。只是在阳货篇第十七中有两处例外,言偃对孔子说,“昔者偃也闻诸夫子”;子路对孔子也说,“昔者由也闻诸夫子”,都是当面称“夫子”,“夫子”用如“你老人家”,开战国时运用“夫子”一词的词义之端。崔述在《洙泗考信録》据此来断定《论语》的少数篇章的“驳杂”,固然未免武断;但《论语》的着笔有先有后,其间相距或者不止于三、五十年,似乎可以由此窥测得到。\n《论语》一书,既然成于很多人之手,而且这些作者的年代相去或者不止于三、五十年,那么,这最后编定者是谁呢?自唐人柳宗元以来,很多学者都疑心是由曾参的学生所编定的,我看很有道理。第一,《论语》不但对曾参无一处不称“子”,而且记载他的言行和孔子其它弟子比较起来为最多。除开和孔子问答之词以外,单独记载曾参言行的,还有学而篇两章,泰伯篇五章,颜渊篇一章,宪问篇和孔子的话合并的一章,子张篇四章,总共十三章。第二,在孔子弟子中,不但曾参最年轻,而且有一章还记载着曾参将死之前对孟敬子的一段话。孟敬子是鲁大夫孟武伯的儿子仲孙捷的谥号①。假定曾参死在鲁元公元年(周考王五年,纪元前四三六年。这是依《阙里文献考》“曾子年七十而卒”一语而推定的),则孟敬子之死更在其后,那么,这一事的记述者一定是在孟敬子死后才着笔的。孟敬子的年岁我们已难考定,但《檀弓》记载着当鲁悼公死时,孟敬子对答季昭子的一番话,可见当曾子年近七十之时,孟敬子已是鲁国执政大臣之一了。则这一段记载之为曾子弟子所记,毫无可疑。《论语》所叙的人物和事迹,再没有比这更晚的,那么,《论语》的编定者或者就是这班曾参的学生。因此,我们说《论语》的着笔当开始于春秋末期,而编辑成书则在战国初期,大概是接近于历史事实的②。\n①谥法在什么时候才兴起的,古今说法不同。历代学者相信《逸周书·谥法解》的说法,说起于周初。自王国维发表了〈遹敦跋〉(《观堂集林》卷十八)以后,这一说法才告动摇。王氏的结论说:“周初诸王若文、武、成、康、昭、穆,皆号而非谥也。”又说:“则谥法之作其在宗周共、懿诸王以后乎?”这一说法较可信赖。郭沫若先生则说“当在春秋中叶以后”(《金文丛考·谥法之起源》,又《两周金文辞大系·初序》),这结论则尚待研究。至于疑心“谥法之兴当在战国时代”(〈谥法之起源〉),甚至说“起于战国中叶以后”(《文学遗产》一一七期〈读了关于《周颂·噫嘻篇》的解释)〉,那未免更使人怀疑了。郭先生的后一种结论,不但在其文中缺乏坚强的论证,而且太与古代的文献材料相矛盾。即从《论语》看(如“孔文子何以谓之文也”),从《左传》看(如文公元年、宣公十一年、襄公十三年死后议谥的记载),这些史料,都不能以“托古作伪”四字轻轻了之。因而我对旧说仍作适当保留。唐人陆淳说:“《史记》、《世本》,厉王以前,诸人有谥者少,其后乃皆有谥。”似亦可属余说之佐证。\n②日本学者山下寅次有《论语编纂年代考》(附于其所著《史记编述年代考》内)。谓《论语》编纂年代为纪元前479年(孔子卒年)至400年(子思卒年)之间。虽然其论证与我不同。但结论却基本一致。\n(三)“论语”的版本和真伪 # 《论语》传到汉朝,有三种不同的本子:(1)《鲁论语》二十篇;(2)《齐论语》二十二篇,其中二十篇的章句很多和《鲁论语》相同,但是多出问王和知道两篇;(3)《古文论语》二十一篇,也没有问王和知道两篇,但是把尧曰篇的“子张问”另分为一篇,于是有了两个子张篇。篇次也和《齐论》、《鲁论》不一样,文字不同的计四百多字。《鲁论》和《齐论》最初各有师传,到西汉末年,安昌侯张禹先学习了《鲁论》,后来又讲习《齐论》,于是把两个本子融合为一,但是篇目以《鲁论》为根据,“采获所安”,号为张侯论。张禹是汉成帝的师傅,其时极为尊贵,所以他的这一个本子便为当时一般儒生所尊奉,后汉灵帝时所刻的《熹平石经》就是用的张侯论。《古文论语》是在汉景帝时由鲁恭王刘余在孔子旧宅壁中发现的,当时并没有传授。何晏《论语集解》序说:“《古论》,唯博士孔安国为之训解,而世不传。”《论语集解》并经常引用了孔安国的注。但孔安国是否曾为《论语》作训解,集解中的孔安国说是否伪作,陈鳣的《论语古训》自序已有怀疑,沈涛的《论语孔注辨伪》认为就是何晏自己的伪造品,丁晏的《论语孔注证伪》则认为出于王肃之手。这一官司我们且不去管它。直到东汉末年,大学者郑玄以张侯论为依据,参照《齐论》、《古论》,作了《论语注》。在残存的郑玄《论语注》中我们还可以略略窥见鲁、齐、古三种《论语》本子的异同。然而,我们今天所用的《论语》本子,基本上就是张侯论。于是怀疑《论语》的人便在这里抓住它作话柄。张禹这个人实际上够不上说是一位“经师”,只是一个无耻的政客,附会王氏,保全富贵,当时便被斥为“佞臣”,所以崔述在《论语源流附考》中竟说:“公山、佛肸两章安知非其有意采之以入《鲁论》为己解嘲地(也)乎?”但是,崔述的话纵然不为无理,而《论语》的篇章仍然不能说有后人所杜撰的东西在内,顶多只是说掺杂着孔门弟子以及再传弟子之中的不同传说而已。如果我们要研究孔子,仍然只能以《论语》为最可信赖的材料。无论如何,《论语》的成书要在《左传》之前,我很同意刘宝楠在《论语正义》(公山章)的主张,我们应该相信《论语》来补充《左传》,不应该根据《左传》来怀疑《论语》。至于崔述用后代的封建道德作为标准,以此来范围孔子,来测量《论语》的真伪、纯驳,更是不公平和不客观的。\n(四)略谈古今“论语”注释书籍 # 《论语》自汉代以来,便有不少人注解它,《论语》和孝经是汉朝初学者必读书,一定要先读这两部书,才进而学习“五经”,“五经”就是今天的《诗经》、《尚书》(除去伪古文)、《易经》、《仪礼》和春秋。看来,《论语》是汉人启蒙书的一种。汉朝人所注释的《论语》,基本上全部亡佚,今日所残存的,以郑玄(127—200,《后汉书》有传)注为较多,因为敦煌和日本发现了一些唐写本残卷,估计十存六七;其它各家,在何晏(190—249)《论语集解》以后,就多半只存于《论语集解》中。现在十三经注疏《论语注疏》就用何晏《集解》,宋人邢昺(932—1010,《宋史》有传)的疏。至于何晏、邢昺前后还有不少专注《论语》的书,可以参看清人朱彝尊(1629—1709,清史稿有传)《经义考》,纪昀(1724—1805)等的《四库全书总目提要》以及唐陆德明(550左右—630左右。新、旧《唐书》对他的生卒年并没有明确记载,此由《册府元龟》卷九十七推而估计之)《经典释文·序録》和吴检斋(承仕)师的疏证。\n我曾经说过,关于《论语》的书,真是汗牛充栋,举不胜举。读者如果认为看了《论语译注》还有进一步研究的必要,可以再看下列几种书:\n(1)《论语注疏》——即何晏《集解》、邢昺疏,在十三经注疏中,除武英殿本外,其它各本多沿袭阮元南昌刻本,因它有校勘记,可以参考。其本文文字出现于校勘记的,便在那文字句右侧用小圈作标帜,便于查考。\n(2)《论语集注》——宋朱熹(1130—1200)从《礼记》中抽出《大学》和《中庸》,合《论语》、《孟子》为四书,自己用很大功力做集注。固然有很多封建道德迂腐之论,朱熹本人也是个客观唯心主义者。但一则自明朝以至清末,科举考试,题目都从《四书》中出;所做文章的义理,也不能违背朱熹的见解,这叫做“代圣人立言”,影响很大。二则朱熹对于《论语》,不但讲“义理”,也注意训诂。所以这书无妨参看。\n(3)刘宝楠(1791—1855)《论语正义》——清代儒生大多不满意于唐、宋人的注疏,所以陈奂(1786—1863)作毛诗传疏,焦循(1763一1820)作《孟子正义》。刘宝楠便依焦循作《孟子正义》之法,作《论语正义》,因病而停笔,由他的儿子刘恭冕(1821—1880)继续写定。所以这书实是刘宝楠父子二人所共着。征引广博,折中大体恰当。只因学问日益进展,当日的好书,今天便可以指出不少缺点,但参考价值仍然不小。\n(4)程树德《论语集释》。此书在例言中已有论述,不再重复。\n(5)杨树达(1885—1956)《论语疏证》。这书把三国以前所有征引《论语》或者和《论语》的有关资料都依《论语》原文疏列,有时出己意,加案语。值得参考。\n例言 # 一、在本书中,著者的企图是:帮助一般读者比较容易而正确地读懂《论语》,并给有志深入研究的人提供若干线索。同时,有许多读者想藉自学的方式提高阅读古书的能力,本书也能起一些阶梯作用。\n二、《论语》章节的分合,历代版本和各家注解本互相间稍有出入,著者在斟酌取舍之后,依照旧有体例,在各篇篇名之下,简略地记述各重要注解本的异同。\n三、《论语》的本文,古今学者作了极为详尽的校勘,但本书所择取的只是必须对通行本的文字加以订正的那一部分。而在这一部分中,其有刊本足为依据的,便直接用那一刊本的文字;不然,仍用通行本的文字印出,只是在应加订正的原文之下用较小字体注出来。\n四、译文在尽可能不走失原意并保持原来风格下力求流畅明白。但古人言辞简略,有时不得不加些词句。这些在原文涵义之外的词句,外用方括号[]作标志。\n五、在注释中,著者所注意的是字音词义、语法规律、修辞方式、历史知识、地理沿革、名物制度和风俗习惯的考证等等,依出现先后以阿拉伯数字为标记。\n六、本书虽然不纠缠于考证,但一切结论都是从细致深入的考证中提炼出来的。其中绝大多数为古今学者的研究成果,也间有著者个人千虑之一得。结论固很简单,得来却不容易。为便于读者查究,有时注明出处,有时略举参考书籍,有时也稍加论证。\n七、字音词义的注释只限于生僻字、破读和易生歧义的地方,而且一般只在第一次出现时加注。注音一般用汉语拼音,有时兼用直音法,而以北京语音为标准。直音法力求避免古今音和土语方言的歧异。但以各地方言的纷歧庞杂,恐难完全避免,所以希望读者依照汉语拼音所拼成的音去读。\n八、注释以及词典中所用的语法术语以及其所根据的理论,可参考我的另一本着作《文言语法》(北京出版社出版)。\n九、《论语》中某地在今日何处,有时发生不同说法,著者只选择其较为可信的,其它说法不再征引。今日的地名暂依中华人民共和国行政区划简册,这本书是依据1975年底由公安部编成的。\n十、朱熹的《论语集注》,虽然他自己也说,“至于训诂皆仔细者”(《朱子语类大全》卷十一),但是,他究竟是个唯心主义者,也有意地利用《论语》的注释来阐述自己的哲学思想,因之不少主观片面的说法;同时,他那时的考据之学、训诂之学的水平远不及后代,所以必须纠正的地方很多。而他这本书给后代的影响特别大,至今还有许多人“积非成是”,深信不疑。因之,在某些关节处,著者对其错误说法,不能不稍加驳正。\n十一、《论语》的词句,几乎每一章节都有两三种以至十多种不同的讲解。一方面,是由于古今人物引用《论语》者“断章取义”的结果。我们不必去反对“断章取义”的做法(这实在是难以避免的),但是不要认为其断章之义就是《论语》的本义。另一方面,更有许多是由于解释《论语》者“立意求高”的结果。金人王若虚在其所著《滹南遗老集》卷五中说:\n“‘子曰,十室之邑必有忠信如丘者焉,不如丘之好学也。’或训‘焉’为‘何’,而属之下句。‘厩焚,子退朝,曰,伤人乎,不问马。’或读‘不’为‘否’而属之上句(著者案:当云另成一读)。意圣人至谦,必不肯言人之莫己若;圣人至仁,必不贱畜而无所恤也。义理之是非姑置勿论,且道世之为文者有如此语法乎?故凡解经,其论虽高,而于文势语法不顺者亦未可遽从,况未高乎?”\n我非常同意这种意见。因之,著者的方针是不炫博,不矜奇,像这样的讲解,一概不加论列。但也不自是,不遗美。有些讲解虽然和“译文”有所不同,却仍然值得考虑,或者可以两存,便也在注释中加以征引。也有时对某些流行的似是而非的讲解加以论辨。\n十二、本书引用诸家,除仲父及师友称字并称“先生”外,余皆称名。\n十三、本书初稿曾经我叔父遇夫(树达)先生逐字审读,直接加以批改,改正了不少错误。其后又承王了一(力)教授审阅,第二次稿又承冯芝生(友兰)教授审阅,两位先生都给提了宝贵意见。最后又承古籍出版社童第德先生提了许多意见。著者因此作了适当的增改。对冯、王、童三位先生,著者在此表示感谢;但很伤心的是遇夫先生已经不及看到本书的出版了。\n十四、著者在撰述“译注”之先,曾经对《论语》的每一字、每一词作过研究,编着有“《论语》词典”一稿。其意在尽可能地弄清《论语》本文每字每词的涵义,译注才有把握。“得鱼忘筌”,译注完稿,“词典”便被弃置。最近吕叔湘先生向我建议,可以仿效苏联《普希金词典》的体例,标注每词每义的出现次数,另行出版。我接受了这一建议,把“词典”未定稿加以整理。但以为另行出版,不如附于“译注”之后,以收相辅相成的效用。详于“注释”者,“词典”仅略言之;“注释”未备者,“词典”便补充之,对读者或者有些好处。在这里,自不能不对吕先生的这一可宝贵的提议表示感谢。\n十五、古今中外关于《论语》的著作真是“汗牛充栋”。仅日本学者林泰辅博士在《论语年谱》中所著録的便达三千种之多,此外还有他所不曾着録和不及着録的,又还有散见于别的书籍的大量零星考证材料。程树德的《论语集释》,征引书籍六百八十种,可说是繁富的了,然而还未免有疏略和可以商量的地方。著者以前人已有的成果为基础,着手虽然比较容易,但仍限于学力和见解,一定还有不妥以至错误之处,诚恳地希望读者指正。\n著者\n一九五六年七月十六日写讫,一九五七年\n三月廿六日增改。一九七九年十二月修订\n"},{"id":117,"href":"/zh/docs/culture/%E8%AE%BA%E8%AF%AD/%E8%AE%BA%E8%AF%AD%E8%AF%91%E6%B3%A8_%E6%9D%A8%E4%BC%AF%E5%B3%BB/04%E9%87%8C%E4%BB%81%E7%AF%87%E7%AC%AC%E5%9B%9B/","title":"04里仁篇第四","section":"论语译注 杨伯峻","content":" 里仁篇第四 # (共二十六章)\n4.1子曰:“里⑴仁为美。择不处⑵仁,焉得知⑶?”\n【译文】孔子说:“住的地方,要有仁德这才好。选择住处,没有仁德,怎么能是聪明呢?”\n【注释】⑴里——这里可以看为动词。居住也。⑵处——上声,音杵,chǔ,居住也。⑶知——《论语》的“智”字都如此写。这一段话,究竟孔子是单纯地指“择居”而言呢,还是泛指,“择邻”、“择业”、“择友”等等都包括在内呢?我们已经不敢肯定。《孟子·公孙丑上》云:“孟子曰:‘矢人岂不仁于函人哉?矢人惟恐不伤人,函人惟恐伤人。巫、匠亦然。故术不可不慎也。孔子曰,里仁为美。择不处仁,焉得智?’”便是指择业。因此译文于“仁”字仅照字面翻译,不实指为仁人。\n4.2子曰:“不仁者不可以久处约,不可以长处乐。仁者安仁,知者利仁。”\n【译文】孔子说:“不仁的人不可以长久地居于穷困中,也不可以长久地居于安乐中。有仁德的人安于仁[实行仁德便心安,不实行仁德心便不安];聪明人利用仁[他认识到仁德对他长远而巨大的利益,他便实行仁德]。”\n4.3子曰:“唯仁者能好人,能恶人⑴。”\n【译文】孔子说:“只有仁人才能够喜爱某人,厌恶某人。”\n【注释】⑴唯仁者能好人,能恶人——《后汉书·孝明八王传》注引《东观汉记》说:和帝赐彭城王恭诏曰:“孔子曰,‘惟仁者能好人,能恶人’。——贵仁者所好恶得其中也。”我认为“贵仁者所好恶得其中”,正可说明这句。\n4.4子曰:“苟志于仁矣,无恶也。”\n【译文】孔子说:“假如立定志向实行仁德,总没有坏处。”\n4.5子曰:“富与贵,是人之所欲也;不以其道得之,不处也。贫与贱,是人之所恶也;不以其道得之⑴,不去也。君子去仁,恶乎⑵成名?君子无终食之间违⑶仁,造次必于是,颠沛必于是。”\n【译文】孔子说:“发大财,做大官,这是人人所盼望的;不用正当的方法去得到它,君子不接受。穷困和下贱,这是人人所厌恶的;不用正当的方法去抛掉它,君子不摆脱。君子抛弃了仁德,怎样去成就他的声名呢?君子没有吃完一餐饭的时间离开仁德,就是在仓卒匆忙的时候一定和仁德同在,就是在颠沛流离的时候一定和仁德同在。”\n【注释】⑴贫与贱……不以其道得之——“富与贵”可以说“得之”,“贫与贱”却不是人人想“得之”的。这里也讲“不以其道得之”,“得之”应该改为“去之”。译文只就这一整段的精神加以诠释,这里为什么也讲“得之”可能是古人的不经意处,我们不必再在这上面做文章了。⑵恶乎——恶音乌,wū,何处。“恶乎”卽“于何处”,译文意译为“怎样”。⑶违——离开,和公冶长篇第五的“弃而违之”的“违”同义。\n4.6子曰:“我未见好仁者,恶不仁者。好仁者,无以尚⑴之;恶不仁者,其为仁矣⑵,不使不仁者加乎其身。有能一日用其力于仁矣乎?我未见力不足者。盖⑶有之矣,我未之见也。”\n【译文】孔子说:“我不曾见到过爱好仁德的人和厌恶不仁德的人。爱好仁德的人,那是再好也没有的了;厌恶不仁德的人,他行仁德只是不使不仁德的东西加在自己身上。有谁能在某一天使用他的力量于仁德呢?我没见过力量不够的。大概这样人还是有的,我不曾见到罢了。”\n【注释】⑴尚——动词,超过之意。⑵矣——这个“矣”字用法同“也”,表示停顿。⑶盖——副词,大概之意。\n4.7子曰:“人之过也,各于其党。观过,斯知仁⑴矣。”\n【译文】孔子说:“[人是各种各样的,人的错误也是各种各样的。]什么样的错误就是由什么样的人犯的。仔细考察某人所犯的错误,就可以知道他是什么样式的人了。”\n【注释】⑴仁——同“人”。《后汉书·吴佑传》引此文正作“人”(武英殿本却又改作“仁”,不可为据)。\n4.8子曰:“朝闻道,夕死可矣。”\n【译文】孔子说:“早晨得知真理,要我当晚死去,都可以。”\n4.9子曰:“士志于道,而耻恶衣恶食者,未足与议也。”\n【译文】孔子说:“读书人有志于真理,但又以自己吃粗粮穿破衣为耻辱,这种人,不值得同他商议了。”\n4.10子曰:“君子之于天下也,无适⑴也,无莫⑴也,义之与比⑵。”\n【译文】孔子说:“君子对于天下的事情,没规定要怎样干,也没规定不要怎样干,只要怎样干合理恰当,便怎样干。”\n【注释】⑴适,莫——这两字讲法很多,有的解为“亲疏厚薄”,“无适无莫”便是“情无亲疏厚薄”。有的解为“敌对与羡慕”,“无适(读为敌)无莫(读为慕)”便是“无所为仇,无所欣羡”。我则用朱熹《集注》的说法。⑵比——去声,bì,挨着,靠拢,为邻。从孟子和以后的一些儒家看来,孔子“无必无固”(9.4),通权达变,“可以仕则仕,可以止则止,可以久则久,可以速则速”(《孟子·公孙丑上》),唯义是从,叫做“圣之时”,或者可以做这章的解释。\n4.11子曰:“君子怀德,小人怀土⑴;君子怀刑⑵,小人怀惠。”\n【译文】孔子说:“君子怀念道德,小人怀念乡土;君子关心法度,小人关心恩惠。”\n【注释】⑴土——如果解为田土,亦通。⑵刑——古代法律制度的“刑”作“刑”,刑罚的“刑”作“㓝”,从刀井,后来都写作“刑”了。这“刑”字应该解释为法度。\n4.12子曰:“放⑴于利而行,多怨。”\n【译文】孔子说:“依据个人利益而行动,会招致很多的怨恨。”\n【注释】⑴放——旧读上声,音仿,fǎng,依据。\n4.13子曰:“能以礼让为国乎?何有⑴?不能以礼让为国,如礼何⑵?”\n【译文】孔子说:“能够用礼让来治理国家吗?这有什么困难呢?如果不能用礼让来治理国家,又怎样来对待礼仪呢?”\n【注释】⑴何有——这是春秋时代的常用语,在这里是“有何困难”的意思。黄式三《论语后案》、刘宝楠《论语正义》都说:“何有,不难之词。”⑵如礼何——依孔子的意见,国家的礼仪必有其“以礼让为国”的本质,它是内容和形式的统一体。如果舍弃它的内容,徒拘守那些仪节上的形式,孔子说,是没有什么作用的。\n4.14子曰:“不患无位,患所以立⑴。不患莫己知,求为可知也。”\n【译文】孔子说:“不发愁没有职位,只发愁没有任职的本领;不怕没有人知道自己,去追求足以使别人知道自己的本领好了。”\n【注释】⑴患所以立——“立”和“位”古通用,这“立”字便是“不患无位”的“位”字。《春秋》桓公二年“公卽位”,石经作“公卽立”可以为证。\n4.15子曰:“参乎!吾道一以贯⑴之。”曾子曰:“唯。”子出,门人问曰:“何谓也?”曾子曰:“夫子之道,忠恕⑵而已矣。”\n【译文】孔子说:“参呀!我的学说贯穿着一个基本观念。”曾子说:“是。”孔子走出去以后,别的学生便问曾子道:“这是什么意思”曾子道:“他老人家的学说,只是忠和恕罢了。”\n【注释】⑴贯——贯穿、统贯。阮元《揅经室集》有〈一贯说〉,认为《论语》的“贯”字都是“行”、“事”的意义,未必可信。⑵忠、恕——“恕”,孔子自己下了定义:“己所不欲,勿施于人。”“忠”则是“恕”的积极一面,用孔子自己的话,便应该是:“己欲立而立人,己欲达而达人。”\n4.16子曰:“君子⑴喻于义,小人⑴喻于利。”\n【译文】孔子说:“君子懂得的是义,小人懂得的是利。”\n【注释】⑴君子、小人——这里的“君子”、“小人”是指在位者,还是指有德者,还是两者兼指,孔子原意不得而知。《汉书·杨恽传·报孙会宗书》曾引董仲舒的话说:“明明求仁义常恐不能化民者,卿大夫之意也;明明求财利常恐困乏者,庶人之事也。”只能看作这一语的汉代经师的注解,不必过信。\n4.17子曰:“见贤思齐焉,见不贤而内自省也。”\n【译文】孔子说:“看见贤人,便应该想向他看齐;看见不贤的人,便应该自己反省,[有没有同他类似的毛病。]”\n4.18子曰:“事父母几⑴谏,见志不从,又敬不违⑵,劳⑶而不怨。”\n【译文】孔子说:“侍奉父母,[如果他们有不对的地方,]得轻微婉转地劝止,看到自己的心意没有被听从,仍然恭敬地不触犯他们,虽然忧愁,但不怨恨。”\n【注释】⑴几——平声,音机,jī,轻微,婉转。⑵违——触忤,冒犯。⑶劳——忧愁。说见王引之《经义述闻》。\n4.19子曰:“父母在,不远游,游必有方。”\n【译文】孔子说:“父母在世,不出远门,如果要出远门,必须有一定的去处。”\n4.20子曰:“三年无改于父之道,可谓孝矣⑴。”\n【注释】⑴见学而篇。(1.11)\n4.21子曰:“父母之年,不可不知也。一则以喜,一则以惧。”\n【译文】孔子说:“父母的年纪不能不时时记在心里:一方面因[其高寿]而喜欢,另一方面又因[其寿高]而有所恐惧。”\n4.22子曰:“古者言之不出,耻⑴躬之不逮⑵也。”\n【译文】孔子说:“古时候言语不轻易出口,就是怕自己的行动赶不上。”\n【注释】⑴耻——动词的意动用法,以为可耻的意思。⑵逮——音代,dài,及,赶上。\n4.23子曰:“以约⑴失之者鲜矣。”\n【译文】孔子说:“因为对自己节制、约束而犯过失的,这种事情总不会多。”\n【注释】⑴约——《论语》的“约”字不外两个意义:(甲)穷困,(乙)约束。至于节俭的意义,虽然已见于《荀子》,却未必适用于这里。\n4.24子曰:“君子欲讷⑴于言而敏于行⑵。”\n【译文】孔子说:“君子言语要谨慎迟钝,工作要勤劳敏捷。”\n【注释】⑴讷——读nà,语言迟钝。⑵讷于言敏于行——造句和学而篇的“敏于事而慎于言”意思一样,所以译文加“谨慎”两字,同时也把“行”字译为“工作”。\n4.25子曰:“德不孤,必有邻⑴。”\n【译文】孔子说:“有道德的人不会孤单,一定会有[志同道合的人来和他做]伙伴。”\n【注释】⑴德不孤必有邻——《易·系辞上》说:“方以类聚,物以羣分。”又《干·文言》说:“子曰:同声相应,同气相求。”这都可以作为“德不孤”的解释。\n4.26子游曰:“事君数⑴,斯辱矣;朋友数⑴,斯疏矣。”\n【译文】子游说:“对待君主过于烦琐,就会招致侮辱;对待朋友过于烦琐,就会反被疏远。”\n【注释】⑴数——音朔,shuò,密,屡屡。这里依上下文意译为“烦琐”。颜渊篇第十二说:“子贡问友。子曰:‘忠告而善道之,不可则止,无自辱焉。’”也正是这个意思。\n"},{"id":118,"href":"/zh/docs/culture/%E8%AE%BA%E8%AF%AD/%E8%AE%BA%E8%AF%AD%E8%AF%91%E6%B3%A8_%E6%9D%A8%E4%BC%AF%E5%B3%BB/16%E5%AD%A3%E6%B0%8F%E7%AF%87%E7%AC%AC%E5%8D%81%E5%85%AD/","title":"16季氏篇第十六","section":"论语译注 杨伯峻","content":" 季氏篇第十六 # (共十四章)\n16.1季氏将伐颛臾⑴。冉有、季路见于孔子曰:“季氏将有事⑵于颛臾。”\n孔子曰:“求!无乃尔是过⑶与?夫颛臾,昔者先王以为东蒙⑷主,且在邦域之中矣,是社稷之臣也。何以伐为?”\n冉有曰:“夫子欲之,吾二臣者皆不欲也。”\n孔子曰:“求!周任⑸有言曰:‘陈力就列,不能者止。’危而不持,颠而不扶,则将焉用彼相矣?且尔言过矣,虎兕出于柙,龟玉毁于椟中,是谁之过与?”\n冉有曰:“今夫颛臾,固而近于费⑹。今不取,后世必为子孙忧。”\n孔子曰:“求!君子疾夫舍⑺曰欲之而必为之辞。丘也闻有国有家者,不患寡当作贫而患不均,不患贫当作寡而患不安⑻。盖均无贫,和无寡,安无倾。夫如是,故远人不服,则修文德以来之。既来之,则安之。今由与求也,相夫子,远人不服,而不能来也;邦分崩离析,而不能守也;而谋动干戈于邦内。吾恐季孙之忧,不在颛臾,而在萧墙之内⑼也。”\n【译文】季氏准备攻打颛臾。冉有、子路两人谒见孔子,说道:“季氏准备对颛臾使用兵力。”\n孔子道:“冉求,这难道不应该责备你吗?颛臾,上代的君王曾经授权他主持东蒙山的祭祀,而且它的国境早在我们最初被封时的疆土之中,这正是和鲁国共安危存亡的藩属,为什么要去攻打它呢?”\n冉有道:“季孙要这么干,我们两人本来都是不同意的。”\n孔子道:“冉求!周任有句话说:‘能够贡献自己的力量,这再任职;如果不行,就该辞职。’譬如瞎子遇到危险,不去扶持;将要摔倒了,不去搀扶,那又何必用助手呢?你的话是错了。老虎犀牛从槛里逃了出来,龟壳美玉在匣子里毁坏了,这是谁的责任呢?”\n冉有道:“颛臾,城墙既然坚牢,而且离季孙的采邑费地很近。现今不把它占领,日子久了,一定会给子孙留下祸害。”\n孔子道:“冉求!君子就讨厌[那种态度,]不说自己贪心无厌,却一定另找借口。我听说过:无论是诸侯或者大夫,不必着急财富不多,只须着急财富不均;不必着急人民太少,只须着急境内不安。若是财富平均,便无所谓贫穷;境内和平团结,便不会觉得人少;境内平安,便不会倾危。做到这样,远方的人还不归服,便再修仁义礼乐的政教来招致他们。他们来了,就得使他们安心。如今仲由和冉求两人辅相季孙,远方之人不归服,却不能招致;国家支离破碎,却不能保全;反而想在国境以内使用兵力。我恐怕季孙的忧愁不在颛臾,却在鲁君哩。”\n【注释】⑴颛臾——鲁国的附庸国家,现在山东省费县西北八十里有颛臾村,当是古颛臾之地。⑵有事——《左传》成公十三年,“国之大事,在祀与戎。”这“有事”卽指用兵。⑶尔是过——不能解作“尔之过”,因为古代人称代词表示领位极少再加别的虚词的(像《尚书·康诰》“朕其弟小子封”只是极个别的例子)。这里“过”字可看作动词,“是”字是表示倒装之用的词,顺装便是“过尔”,“责备你”、“归罪于你”的意思。⑷东蒙——卽蒙山,在今山东蒙阴县南,接费县界。⑸周任——古代的一位史官。⑹费——音秘,bèi,鲁国季氏采邑,今山东费县西南七十里有费城。⑺舍——同“舍”。⑻不患寡而患不均,不患贫而患不安——当作“不患贫而患不均,不患寡而患不安”,“贫”和“均”是从财富着眼,下文“均无贫”可以为证;“寡”和“安”是从人民着眼,下文“和无寡”可以为证。说详俞樾《羣经平议》。⑼萧墙之内——“萧墙”是鲁君所用的屏风。人臣至此屏风,便会肃然起敬,所以叫做萧墙(萧字从肃得声)。“萧墙之内”指鲁君。当时季孙把持鲁国政治,和鲁君矛盾很大,也知道鲁君想收拾他以收回主权,因此怕颛臾凭借有利的地势起而帮助鲁国,于是要先下手为强,攻打颛臾。孔子这句话,深深地刺中了季孙的内心。\n16.2孔子曰:“天下有道,则礼乐征伐自天子出;天下无道,则礼乐征伐自诸侯出。自诸侯出,盖十世希不失矣;自大夫出,五世希不失矣;陪臣执国命,三世希不失矣⑴。天下有道,则政不在大夫。天下有道,则庶人不议。”\n【译文】孔子说:“天下太平,制礼作乐以及出兵都决定于天子;天下昏乱,制礼作乐以及出兵便决定于诸侯。决定于诸侯,大概传到十代,很少还能继续的;决定于大夫,传到五代,很少还能继续的;若是大夫的家臣把持国家政权,传到三代很少还能继续的。天下太平,国家的最高政治权力就不会掌握在大夫之手。天下太平,老百姓就不会议论纷纷。”\n【注释】⑴孔子这一段话可能是从考察历史,尤其是当日时事所得出的结论。“自天子出”,孔子认为尧、舜、禹、汤以及西周都如此的;“天下无道”则自齐桓公以后,周天子已无发号施令的力量了。齐自桓公称霸,历孝公、昭公、懿公、惠公、顷公、灵公、庄公、景公、悼公、简公十公,至简公而为陈恒所杀,孔子亲身见之;晋自文公称霸,历襄公、灵公、成公、景公、厉公、平公、昭公、顷公九公,六卿专权,也是孔子所亲见的。所以说:“十世希不失”。鲁自季友专政,历文子、武子、平子、桓子而为阳虎所执,更是孔子所亲见的。所以说“五世希不失”。至于鲁季氏家臣南蒯、公山弗扰、阳虎之流都当身而败,不曾到过三世。当时各国家臣有专政的,孔子言“三世希不失”,盖宽言之。这也是历史演变的必然,愈近变动时代,权力再分配的鬬争,一定愈加激烈。这却是孔子所不明白的。\n16.3孔子曰:“禄之去公室五世⑴矣,政逮于大夫四世⑴矣,故夫三桓⑵之子孙微矣。”\n【译文】孔子说:“国家政权离开了鲁君,[从鲁君来说,]已经五代了;政权到了大夫之手,[从季氏来说,]已经四代了,所以桓公的三房子孙现在也衰微了。”\n【注释】⑴五世四世——自鲁君丧失政治权力到孔子说这段话的时候,经历了宣公、成公、襄公、昭公、定公五代;自季氏最初把持鲁国政治到孔子说这段话时,经历了文子、武子、平子、桓子四代。说本毛奇龄《论语稽求篇》。⑵三桓——鲁国的三卿,仲孙(卽孟孙)、叔孙、季孙都出于鲁桓公,故称“三桓”。\n16.4孔子曰:“益者三友,损者三友。友直,友谅⑴,友多闻,益矣。友便辟,友善柔,友便佞,损矣。”\n【译文】孔子说:“有益的朋友三种,有害的朋友三种。同正直的人交友,同信实的人交友,同见闻广博的人交友,便有益了。同谄媚奉承的人交友,同当面恭维背面毁谤的人交友,同夸夸其谈的人交友,便有害了。”\n【注释】⑴谅——《说文》:“谅,信也。”“谅”和“信”有时意义相同,这里便如此。有时意义有别。如宪问篇第十四“岂若匹夫匹妇之为谅也”的“谅”只是“小信”的意思。\n16.5孔子曰:“益者三乐,损者三乐。乐节礼乐,乐道人之善,乐多贤友,益矣。乐骄乐,乐佚游,乐晏乐,损矣。”\n【译文】孔子说:“有益的快乐三种,有害的快乐三种。以得到礼乐的调节为快乐,以宣扬别人的好处为快乐,以交了不少有益的朋友为快乐,便有益了。以骄傲为快乐,以游荡忘返为快乐,以饮食荒淫为快乐,便有害了。”\n16.6孔子曰:“侍于君子有三愆:言未及之而言谓之躁,言及之而不言谓之隐,未见颜色而言谓之瞽。”\n【译文】孔子说:“陪着君子说话容易犯三种过失:没轮到他说话,却先说,叫做急躁;该说话了,却不说,叫做隐瞒;不看看君子的脸色便贸然开口,叫做瞎眼睛。”\n16.7孔子曰:“君子有三戒:少之时,血气未定,戒之在色;及其壮也,血气方刚,戒之在鬬;及其老也,血气既衰,戒之在得⑴。”\n【译文】孔子说:“君子有三件事情应该警惕戒备:年轻的时候,血气未定,便要警戒,莫迷恋女色;等到壮大了,血气正旺盛,便要警戒,莫好胜喜鬬;等到年老了,血气已经衰弱,便要警戒,莫贪求无厌。”\n【注释】⑴孔安国注云:“得,贪得。”所贪者可能包括名誉、地位、财货在内。《淮南子·诠言训》:“凡人之性,少则猖狂,壮则强暴,老则好利。”意本于此章,而以“好利”释得,可能涵义太狭。\n16.8孔子曰:“君子有三畏:畏天命,畏大人⑴,畏圣人之言。小人不知天命而不畏也,狎大人,侮圣人之言。”\n【译文】孔子说:“君子害怕的有三件事:怕天命,怕王公大人,怕圣人的言语。小人不懂得天命,因而不怕它;轻视王公大人,轻侮圣人的言语。”\n【注释】⑴大人——古代对于在高位的人叫“大人”,如《易·干卦》“利见大人”,《礼记·礼运》“大人世及以为礼”,《孟子·尽心下》“说大人,则藐之”。对于有道德的人也可以叫“大人”,如《孟子·告子上》“从其大体为大人”。这里的“大人”是指在高位的人,而“圣人”则是指有道德的人。\n16.9孔子曰:“生而知之者上也,学而知之者次也;困而学之,又其次也;困而不学,民斯为下矣。”\n【译文】孔子说:“生来就知道的是上等,学习然后知道的是次一等;实践中遇见困难,再去学它,又是再次一等;遇见困难而不学,老百姓就是这种最下等的了。”\n16.10孔子曰:“君子有九思:视思明,听思聪,色思温,貌思恭,言思忠,事思敬,疑思问,忿思难,见得思义。”\n【译文】孔子说:“君子有九种考虑:看的时候,考虑看明白了没有;听的时候,考虑听清楚了没有;脸上的颜色,考虑温和么;容貌态度,考虑庄矜么;说的言语,考虑忠诚老实么;对待工作,考虑严肃认真么;遇到疑问,考虑怎样向人家请教;将发怒了,考虑有什么后患;看见可得的,考虑我是否应该得。”\n16.11孔子曰:“见善如不及,见不善如探汤。吾见其人矣,吾闻其语矣。隐居以求其志,行义以达其道。吾闻其语矣,未见其人也。”\n【译文】孔子说:“看见善良,努力追求,好像赶不上似的;遇见邪恶,使劲避开,好像将伸手到沸水里。我看见这样的人,也听过这样的话。避世隐居求保全他的意志,依义而行来贯彻他的主张。我听过这样的话,却没有见过这样的人。”\n16.12齐景公有马千驷⑴,死之日,民无德而称焉。伯夷、叔齐饿于首阳⑵之下,民到于今称之。其斯之谓与⑶?\n【译文】齐景公有马四千匹,死了以后,谁都不觉得他有什么好行为可以称述。伯夷、叔齐两人饿死在首阳山下,大家到现在还称颂他。那就是这个意思吧!\n【注释】⑴千驷——古代一般用四匹马驾一辆车,所以一驷就是四匹马。《左传》哀公八年:“鲍牧谓羣公子曰:‘使女有马千乘乎?’”这“千乘”就是景公所遗留的“千驷”。鲍牧用此来诱劝羣公子争夺君位,可见“千乘”是一笔相当富厚的私产。⑵首阳——山名,现在何地,古今传说纷歧,总之,已经难于确指。⑶其斯之谓与——这一章既然没有“子曰”字样,而且“其斯之谓与”的上面无所承受,程颐以为颜渊篇第十二的“诚不以富,亦祗以异”两句引文应该放在此处“其斯之谓与”之上,但无证据。朱熹〈答江德功书〉云:“此章文势或有断续,或有阙文,或非一章,皆不可考。”\n16.13陈亢⑴问于伯鱼曰:“子亦有异闻乎?”\n对曰:“未也。尝独立,鲤趋而过庭。曰:‘学诗乎?’对曰:‘未也。’‘不学诗,无以言。’鲤退而学诗。他日,又独立,鲤趋而过庭。曰:‘学礼乎?’对曰:‘未也。’‘不学礼,无以立。’鲤退而学礼。闻斯二者。”\n陈亢退而喜曰:“问一得三,闻诗,闻礼,又闻君子之远其子也。”\n【译文】陈亢向孔子的儿子伯鱼问道:“您在老师那儿,也得着与众不同的传授吗?”\n答道:“没有。他曾经一个人站在庭中,我恭敬地走过。他问我道:‘学诗没有?’我道:‘没有。’他便道:‘不学诗就不会说话。’我退回便学诗。过了几天,他又一个人站在庭中,我又恭敬地走过。他问道:‘学礼没有?’我答:‘没有。’他道:‘不学礼,便没有立足社会的依据。’我退回便学礼。只听到这两件。”\n陈亢回去非常高兴地道:“我问一件事,知道了三件事。知道诗,知道礼,又知道君子对他儿子的态度。”\n【注释】⑴陈亢——亢音刚,gāng,就是陈子禽。\n16.14邦君之妻,君称之曰夫人,夫人自称曰小童;邦人称之曰君夫人,称诸异邦曰寡小君;异邦人称之亦曰君夫人⑴。\n【译文】国君的妻子,国君称她为夫人,她自称为小童;国内的人称她为君夫人,但对外国人便称她为寡小君;外国人称她也为君夫人。\n【注释】⑴这章可能也是孔子所言,却遗落了“子曰”两字。有人疑心这是后人见竹简有空白处,任意附记的。殊不知书写《论语》的竹简不过八寸,短者每章一简,长者一章数简,断断没有多大空白能书写这四十多字。而且这一章既见于《古论》,又见于《鲁论》(《鲁论》作“固君之妻”),尤其可见各种古本都有之,决非后人所搀入。\n"},{"id":119,"href":"/zh/docs/culture/%E8%AE%BA%E8%AF%AD/%E8%AE%BA%E8%AF%AD%E8%AF%91%E6%B3%A8_%E6%9D%A8%E4%BC%AF%E5%B3%BB/05%E5%85%AC%E5%86%B6%E9%95%BF%E7%AF%87%E7%AC%AC%E4%BA%94/","title":"05公冶长篇第五","section":"论语译注 杨伯峻","content":" 公冶长篇第五 # (共二十八章(何晏《集解》把第十章“子曰,始吾于人也”以下又分一章,故题为二十九章;朱熹《集注》把第一、第二两章并为一章,故题为二十七章。))\n5.1子谓公冶长⑴,“可妻⑵也。虽在缧绁⑶之中,非其罪也。”以其子⑷妻之。\n【译文】孔子说公冶长,“可以把女儿嫁给他。他虽然曾被关在监狱之中,但不是他的罪过。”便把自己的女儿嫁给他。\n【注释】⑴公冶长——孔子学生,齐人。⑵妻——动词,去声,qì。⑶缧绁——缧同“累”,léi;绁音泄,xiè。缧绁,拴罪人的绳索,这里指代监狱。⑷子——儿女,此处指的是女儿。\n5.2子谓南容⑴,“邦有道,不废;邦无道,免于刑戮。”以其兄之子妻之⑵。\n【译文】孔子说南容,“国家政治清明,[总有官做,]不被废弃;国家政治黑暗,也不致被刑罚。”于是把自己的侄女嫁给他。\n【注释】⑴南容——孔子学生南宫适,字子容。⑵兄之子——孔子之兄叫孟皮,见《史记·孔子世家·索隐》引《家语》。这时孟皮可能已死,所以孔子替他女儿主婚。\n5.3子谓子贱⑴,“君子哉若人!鲁无君子者,斯焉取斯?”\n【译文】孔子评论宓子贱,说:“这人是君子呀!假若鲁国没有君子,这种人从哪里取来这种好品德呢?”\n【注释】⑴子贱——孔子学生宓不齐,字子贱,少于孔子四十九岁。(公元前521—?)\n5.4子贡问曰:“赐也何如?”子曰:“女,器也。”曰:“何器也?”曰:“瑚琏⑴也。”\n【译文】子贡问道:“我是一个怎样的人?”孔子道:“你好比是一个器皿。”子贡道:“什么器皿?”孔子道:“宗庙里盛黍稷的瑚琏。”\n【注释】⑴瑚琏——音胡连,又音胡hú辇niǎn,卽簠簋,古代祭祀时盛粮食的器皿,方形的叫簠,圆形的叫簋,是相当尊贵的。\n5.5或曰:“雍⑴也仁而不佞⑵。”子曰:“焉用佞?御人以口给⑶,屡憎于人。不知其仁⑷,焉用佞?”\n【译文】有人说:“冉雍这个人有仁德,却没有口才。”孔子道:“何必要口才呢?强嘴利舌地同人家辩驳,常常被人讨厌。冉雍未必仁,但为什么要有口才呢?”\n【注释】⑴雍——孔子学生冉雍,字仲弓。⑵佞——音泞,nìng,能言善说,有口才。⑶口给——给,足也。“口给”犹如后来所说“言词不穷”、“辩才无碍”。⑷不知其仁——孔子说不知,不是真的不知,只是否定的另一方式,实际上说冉雍还不能达到“仁”的水平。下文第八章“孟武伯问子路仁乎,子曰,不知也”,这“不知”也是如此。\n5.6子使漆雕开⑴仕。对曰:“吾斯之未能信⑵。”子说。\n【译文】孔子叫漆雕开去做官。他答道:“我对这个还没有信心。”孔子听了很欢喜。\n【注释】⑴漆雕开——“漆雕”是姓,“开”是名,孔子学生,字子开。⑵吾斯之未能信——这句是“吾未能信斯”的倒装形式,“之”是用来倒装的词。\n5.7子曰:“道不行,乘桴⑴浮于海。从⑵我者,其由与?”子路闻之喜。子曰:“由也好勇过我,无所取材⑶。”\n【译文】孔子道:“主张行不通了,我想坐个木簰到海外去,跟随我的恐怕只有仲由吧!”子路听到这话,高兴得很。孔子说:“仲由这个人太好勇敢了,好勇的精神大大超过了我,这就没有什么可取的呀!”\n【注释】桴——音孚,fú,古代把竹子或者木头编成簰,以当船用,大的叫筏,小的叫桴,也就是现在的木簰。⑵从——动词,旧读去声,跟随。⑶材——同“哉”,古字有时通用。有人解做木材,说是孔子以为子路真要到海外去,便说,“没地方去取得木材”。这种解释一定不符合孔子原意。也有人把“材”看做“翦裁”的“裁’,说是“子路太好勇了,不知道节制、检点”,这种解释不知把“取”字置于何地,因之也不采用。\n5.8孟武伯问子路仁乎?子曰:“不知也。”又问。子曰:“由也,千乘之国,可使治其赋⑴也,不知其仁也。”\n“求也何如?”子曰:“求也,千室之邑⑵,百乘之家⑶,可使为之⑷宰⑸也,不知其仁也。”\n“赤也何如?”子曰:“赤也,束带立于朝,可使与宾客⑹言也,不知其仁也。”\n【译文】孟武伯向孔子问子路有没有仁德。孔子道:“不晓得。”他又问。孔子道:“仲由啦,如果有一千辆兵车的国家,可以叫他负责兵役和军政的工作。至于他有没有仁德,我不晓得。”\n孟武伯继续问:“冉求又怎么样呢?”,孔子道:“求啦,千户人口的私邑,可以叫他当县长;百辆兵车的大夫封地,可以叫他当总管。至于他有没有仁德,我不晓得。”。\n“公西赤又怎么样呢?”。孔子道:“赤啦,穿着礼服,立于朝廷之中,可以叫他接待外宾,办理交涉。至于他有没有仁德,我不晓得。”\n【注释】⑴赋——兵赋,古代的兵役制度。这里自也包括军政工作而言。⑵邑——《左传》庄公二十八年云:“凡邑,有宗庙先王之主曰都,无曰邑。”又《公羊传》桓公元年云:“田多邑少称田,邑多田少称邑。”可见“邑”就是古代庶民聚居之所,不过有一些田地罢了。⑶家——古代的卿大夫由国家封以一定的地方,由他派人治理,并且收用当地的租税,这地方便叫采地或者采邑。“家”便是指这种采邑而言。⑷之——用法同“其”,他的。⑸宰——古代一县的县长叫做“宰”,大夫家的总管也叫做“宰”。所以“原思为之宰”(6.5)的宰为“总管”,而“季氏使闵子骞为费宰”(6.9)的“宰”是“县长”。⑹宾客——“宾”“客”两字散文则通,对文有异。一般是贵客叫宾,因之天子诸侯的客人叫宾,一般客人叫客,《易经·需卦·爻辞》“有不速之客三人来”的“客”正是此意。这里则把“宾客”合为一词了。\n5.9子谓子贡曰:“女与回也孰愈?”对曰:“赐也何敢望回?回也闻一以知十,赐也闻一以知二。”子曰:“弗如也;吾与⑴女弗如也。”\n【译文】孔子对子贡道:“你和颜回,哪一个强些?”子贡答道:“我么,怎敢和回相比?他啦,听到一件事,可以推演知道十件事;我咧,听到一件事,只能推知两件事。”孔子道:“赶不上他;我同意你的话,是赶不上他。”\n【注释】⑴与——动词,同意,赞同。这里不应该看作连词。\n5.10宰予昼寝。子曰:“朽木不可雕也;粪土之墙不可杇⑴也;于予与何诛⑵”子曰⑶:“始吾于人也,听其言而信其行;今吾于人也,听其言而观其行。于予与改是。”\n【译文】宰予在白天睡觉。孔子说:“腐烂了的木头雕刻不得,粪土似的墙壁粉刷不得;对于宰予么,不值得责备呀。”又说:“最初,我对人家,听到他的话,便相信他的行为;今天,我对人家,听到他的话,却要考察他的行为。从宰予的事件以后,我改变了态度。”\n【注释】⑴杇——音乌,wū,泥工抹墙的工具叫杇,把墙壁抹平也叫杇。这里依上文的意思译为“粉刷”。⑵何诛——机械地翻译是“责备什么呢”,这里是意译。⑶子曰——以下的话虽然也是针对“宰予昼寝”而发出,却是孔子另一个时候的言语,所以又加“子曰”两字以示区别。古人有这种修辞条例,俞樾《古书疑义举例》卷二“一人之辞而加曰字例”曾有所阐述(但未引证此条),可参阅。\n5.11子曰:“吾未见刚者。”或对曰:“申枨⑴。”子曰:“枨也欲,焉得刚?”\n【译文】孔子道:“我没见过刚毅不屈的人。”有人答道:“申枨是这样的人。”孔子道:“申枨啦,他欲望太多,哪里能够刚毅不屈?”\n【注释】⑴申枨——枨音橙,chéng。《史记·仲尼弟子列传》有申党,古音“党”和“枨”相近,那么“申枨”就是“申党”。\n5.12子贡曰:“我不欲人之加⑴诸我也,吾亦欲无加诸人。”子曰:“赐也,非尔所及也。”\n【译文】子贡道:“我不想别人欺侮我,我也不想欺侮别人。”孔子说:“赐,这不是你能做到的。”\n【注释】⑴加——驾凌,凌辱。\n5.13子贡曰:“夫子之文章⑴,可得而闻也;夫子之言性⑵与天道⑶,不可得而闻也。”\n【译文】子贡说:“老师关于文献方面的学问,我们听得到;老师辟于天性和天道的言论,我们听不到。”\n【注释】⑴文章——孔子是古代文化的整理者和传播者,这里的“文章”该是指有关古代文献的学问而言。在《论语》中可以考见的有诗、书、史、礼等等。⑵性——人的本性。古代不可能有阶级观点,因之不知道人的阶级性。而对人的自然的性,孟子、荀子都有所主张,孔子却只说过“性相近也,习相远也”(17.2)一句话。⑶天道——古代所讲的天道一般是指自然和人类社会吉凶祸福的关系。但《左传》昭公十八年郑国子产的话说:“天道远,人道迩,非所及也。”却是对自然和人类社会的吉凶有必然关系的否认。《左传》昭公二十六年又有晏婴的话:“天道不謟。”虽然是用人类的美德来衡量自然之神,反对禳灾,也是对当时迷信习惯的破除。这两人都与孔子同时而年龄较大,而且为孔子所称道。孔子不讲天道,对自然和人类社会的关系取存而不论的态度,不知道是否受这种思想的影响。\n5.14子路有闻,未之能行,唯恐有⑴闻。\n【译文】子路有所闻,还没有能够去做,只怕又有所闻。\n【注释】⑴有——同“又”。\n5.15子贡问曰:“孔文子⑴何以谓之‘文’也?”子曰:“敏而好学,不耻下问,是以谓之‘文’也。”\n【译文】子贡问道:“孔文子凭什么谥他为‘文’?”孔子道:“他聪敏灵活,爱好学问,又谦虚下问,不以为耻,所以用‘文’字做他的谥号。”\n【注释】⑴孔文子——卫国的大夫孔圉。考孔文子死于鲁哀公十五年,或者在此稍前,孔子卒于十六年夏四月,那么,这次问答一定在鲁哀公十五年到十六年初的一段时间内。\n5.16子谓子产⑴,“有君子之道四焉:其行己也恭,其事上也敬,其养民也惠,其使民也义。”\n【译文】孔子评论子产,说:“他有四种行为合于君子之道:他自己的容颜态度庄严恭敬,他对待君上负责认真,他教养人民有恩惠,他役使人民合于道理。\n【注释】⑴子产——公孙侨,字子产,郑穆公之孙,为春秋时郑国的贤相,在郑简公、郑定公之时执政二十二年。其时,于晋国当悼公、平公、昭公、顷公、定公五世,于楚国当共王、康王、郏敖、灵王、平王五世,正是两国争强、战争不息的时候。郑国地位冲要,而周旋于这两大强国之间,子产却能不低声下气,也不妄自尊大,使国家得到尊敬和安全,的确是古代中国的一位杰出的政治家和外交家。\n5.17子曰:“晏平仲⑴善与人交,久而敬之⑵。”\n【译文】孔子说:“晏平仲善于和别人交朋友,相交越久,别人越发恭敬他。”\n【注释】晏平仲——齐国的贤大夫,名婴。《史记》卷六十二有他的传记。现在所传的《晏子春秋》,当然不是晏婴自己的作品,但亦是西汉以前的书。⑵久而敬之——〈魏著作郎韩显宗墓志〉,“善与人交,人亦久而敬焉”,卽本《论语》,义与别本《论语》作“久而人敬之”者相合。故我以“之”字指晏平仲自己。若以为是指相交之人,译文便当这样:“相交越久,越发恭敬别人”。\n5.18子曰:“臧文仲⑴居蔡⑵,山节藻梲⑵,何如其知⑷也?”\n【译文】孔子说:“臧文仲替一种叫蔡的大乌龟盖了一间屋,有雕刻着像山一样的斗栱和画着藻草的梁上短柱,这个人的聪明怎么这样呢?”\n【注释】⑴臧文仲——鲁国的大夫臧孙辰。(?——公元前617年)⑵居蔡——古代人把大乌龟叫作“蔡”。《淮南子·说山训》说:“大蔡神龟,出于沟壑。”高诱注说:“大蔡,元龟之所出地名,因名其龟为大蔡,臧文仲所居蔡是也。”古代人迷信卜筮,卜卦用龟,筮用蓍草。用龟,认为越大越灵。蔡便是这种大龟。臧文仲宝藏着它,使它住在讲究的地方。居,作及物动词用,使动用法,使之居住的意思。⑶山节藻梲——节,柱上斗栱;“梲”音啄,zhuō,梁上短柱。⑷知——同“智”。\n5.19子张问曰:“令尹子文⑴三仕⑵为令尹,无喜色;三已⑵之,无愠色。旧令尹之政,必以告新令尹。何如?”子曰:“忠矣。”曰:“仁矣乎?”曰:“未知⑶;——焉得仁?”\n“崔子弒齐君⑷,陈文子⑸有马十乘,弃而违之。至于他邦,则曰,‘犹吾大夫崔子也。’违之。之一邦,则又曰:‘犹吾大夫崔子也。’违之。何如?”子曰:“清矣。”曰:“仁矣乎?”曰:“未知⑶;——焉得仁?”\n【译文】子张问道:“楚国的令尹子文三次做令尹的官,没有高兴的颜色;三次被罢免,没有怨恨的颜色。[每次交代,]一定把自己的一切政令全部告诉接位的人。这个人怎么样?”孔子道:“可算尽忠于国家了。”子张道:“算不算仁呢?”孔子道:“不晓得;——这怎么能算是仁呢?”\n子张又问:“崔杼无理地杀掉齐庄公,陈文子有四十匹马,舍弃不要,离开齐国。到了另一个国家,说道:‘这里的执政者同我们的崔子差不多。’又离开。又到了一国,又说道:‘这里的执政者同我们的崔子差不多。’于是又离开。这个人怎么样?”孔子道:“清白得很。”子张道:“算不算仁呢?”孔子道:“不晓得;——这怎么能算是仁呢?”\n【注释】⑴令尹子文——楚国的宰相叫做令尹。子文卽鬬谷(谷音构)于菟(音乌徒)。根据《左传》,子文于鲁庄公三十年开始做令尹,到僖公二十三年让位给子玉,其中相距二十八年。在这二十八年中可能有几次被罢免又被任命,《国语·楚语下》说:“昔子文三舍令尹,无一日之积”,也就可以证明。⑵三仕——“三仕”和“三已”的“三”不一定是实数,可能只是表示那事情的次数之多。⑶未知——和上文第五章“不知其仁”,第八章“不知也”的“不知”相同,不是真的“不知”,只是否定的另一方式,孔子停了一下,又说“焉得仁”,因此用破折号表示。⑷崔子弒齐君——崔子,齐国的大夫崔杼;齐君,齐庄公,名光。弑,古代在下的人杀掉在上的人叫做弑。“崔子弑齐君”的事见《左传》襄公二十五年。⑸陈文子——也是齐国的大夫,名须无。可是《左传》没有记载他离开的事,却记载了他以后在齐国的行为很多,可能是一度离开,终于回到本国了。\n5.20季文子⑴三思⑵而后行。子闻之,曰:“再⑶,斯可矣。”\n【译文】季文子每件事考虑多次才行动。孔子听到了,说:“想两次也就可以了。”\n【注释)⑴季文子——鲁国的大夫季孙行父,历仕鲁国文公、宣公、成公、襄公诸代。孔子生于襄公二十二年,文子死在襄公五年。(?——公元前568年)孔子说这话的时候,文子死了很久了。⑵三思——这一“三”字更其不是实实在在的“三”。⑶再——“再”在古文中一般只当副词用,其下承上文省去了动词“思”字。《唐石经》作“再思”,“思”字不省。凡事三思,一般总是利多弊少,为什么孔子却不同意季文子这样做呢?宦懋庸《论语稽说》,“文子生平盖祸福利害之计太明,故其美恶两不相掩,皆三思之病也。其思之至三者,特以世故太深,过为谨慎;然其流弊将至利害徇一己之私矣”云云。若以《左传》所载文子先后行事证明,此话不为无理。\n5.21子曰:“宁武子⑴,邦有道,则知;邦无道,则愚⑵。其知可及也,其愚不可及也。”\n【译文】孔子说:“宁武子在国家太平时节,便聪明;在国家昏暗时节,便装儍。他那聪明,别人赶得上;那装儍,别人就赶不上了。”\n【注释】⑴宁武子——卫国的大夫,姓宁,名俞。⑵愚——孔安国以为这“愚”是“佯愚似实”,故译为“装儍”。\n5.22子在陈⑴,曰:“归与!归与!吾党之小子狂简,斐然成章,不知所以裁之⑵。”\n【译文】孔子在陈国,说:“回去吧!回去吧!我们那里的学生们志向高大得很,文彩又都斐然可观,我不知道怎样去指导他们。”\n【注释】⑴陈——国名,姓妫。周武王灭殷以后,求得舜的后代叫妫满的封于陈。春秋时拥有现在河南开封以东,安徽亳县以北一带地方。都于宛丘,卽今天的河南淮阳县。春秋末为楚所灭。⑵不知所以裁之——《史记·孔子世家》作“吾不知所以裁之”。译文也认为这一句的主语不是承上文“吾党之小子”而省略,而是省略了自称代词。“裁”,翦裁。布要翦裁才能成衣,人要教育才能成才,所以译为“指导”。\n5.23子曰:“伯夷、叔齐⑴不念旧恶⑵,怨是用希。”\n【译文】孔子说:“伯夷、叔齐这两兄弟不记念过去的仇恨,别人对他们的怨恨也就很少。”\n【注释】⑴伯夷、叔齐——孤竹君的两个儿子,父亲死了,互相让位,而都逃到周文王那里。周武王起兵讨伐商纣,他们拦住车马劝阻。周朝统一天下,他们以吃食周朝的粮食为可耻,饿死于首阳山。《史记》卷六十一有传。⑵恶——嫌隙,仇恨。\n5.24子曰:“孰谓微生高⑴直?或乞酰⑵焉,乞诸其邻而与之。”\n【译文】孔子说:“谁说微生高这个人直爽?有人向他讨点醋,[他不说自己没有,]却到邻人那里转讨一点给人。”\n【注释】⑴微生高——《庄子》、《战国策》诸书载有尾生高守信的故事,说这人和一位女子相约,在桥梁之下见面。到时候,女子不来,他却老等,水涨了都不走,终于淹死。“微”、“尾”古音相近,字通,因此很多人认为微生高就是尾生高。⑵酰——xī,醋。\n5.25子曰:“巧言、令色、足⑴恭,左丘明⑵耻之,丘亦耻之。匿怨而友其人,左丘明耻之,丘亦耻之。”\n【译文】孔子说:“花言巧语,伪善的容貌,十足的恭顺,这种态度,左丘明认为可耻,我也认为可耻。内心藏着怨恨,表面上却同他要好,这种行为,左丘明认为可耻,我也认为可耻。”\n【注释】⑴足恭——“足”字旧读去声,zù。⑵左丘明——历来相传左丘明为《左传》的作者,又因为司马迁在报任安书中说遇:“左丘失明,厥有《国语》。”又说他是《国语》的作者。这一问题,经过很多人的研究,我则以为下面的两点结论是可以肯定的:(甲)《国语》和《左传》的作者不是一人,(乙)两书都不可能是和孔子同时甚或较早于孔子(因为孔子这段言语把左丘明放在自己之前,而且引以自重)的左丘明所作。\n5.26颜渊季路侍⑴。子曰:“盍⑵各言尔志?”子路曰:“愿车马衣轻轻字当删裘与朋友共敝之而无憾。⑶”\n颜渊曰:“愿无伐善,无施⑷劳。”\n子路曰:“愿闻子之志。”\n子曰:“老者安之,朋友信之,少者怀之⑸。”\n【译文】孔子坐着,颜渊、季路两人站在孔子身边。孔子道:“何不各人说说自己的志向?”\n子路道:“愿意把我的车马衣服同朋友共同使用坏了也没有什么不满”\n颜渊道:“愿意不夸耀自己的好处,不表白自己的功劳。”子路向孔子道:“希望听到您的志向。”\n孔子道:“[我的志向是,]老者使他安逸,朋友使他信任我,年青人使他怀念我。”\n【注释】⑴侍——《论语》有时用一“侍”字,有时用“侍侧”两字,有时用“侍坐”两字。若单用“侍”字,便是孔子坐着,弟子站着。若用“侍坐”,便是孔子和弟子都坐着。至于“侍侧”,则或坐或立,不加肯定。⑵盍——“何不”的合音字。⑶愿车马衣轻裘与朋友共敝之而无憾——这句的“轻”字是后人加上去的,有很多证据可以证明唐以前的本子并没有这一“轻”字。详见刘宝楠《论语正义》。这一句有两种读法。一种从“共”字断句,把“共”字作谓词。一种作一句读,“共”字看作副词,修饰“敝”字。这两种读法所表现的意义并无显明的区别。⑷施——《淮南子·诠言训》“功盖天下,不施其美。”这两个“施”字意义相同,《礼记·祭统》注云:“施犹着也。”卽表白的意思。⑸信之、怀之——译文把“信”和“怀”同“安”一样看做动词的使动用法。如果把它看做一般用法,那这两句便应该如此翻译:对“朋友有信任,年青人便关心他”。\n5.27子曰:“已矣乎,吾未见能见其过而内自讼者也。”\n【译文】孔子说:“算了吧,我没有看见过能够看到自己的错误便自我责备的哩。”\n5.28子曰:“十室之邑,必有忠信如丘者焉,不如丘之好学也。”\n【译文】孔子说:“就是十户人家的地方,一定有像我这样又忠心又信实的人,祗是赶不上我的喜欢学问罢了。”\n"},{"id":120,"href":"/zh/docs/culture/%E8%AE%BA%E8%AF%AD/%E8%AE%BA%E8%AF%AD%E8%AF%91%E6%B3%A8_%E6%9D%A8%E4%BC%AF%E5%B3%BB/03%E5%85%AB%E4%BD%BE%E7%AF%87%E7%AC%AC%E4%B8%89/","title":"03八佾篇第三","section":"论语译注 杨伯峻","content":" 八佾篇第三 # (共二十六章)\n3.1孔子谓季氏,⑴“八佾⑵舞于庭,是可忍⑶也,孰不可忍也?”\n【译文】孔子谈到季氏,说:“他用六十四人在庭院中奏乐舞蹈,这都可以狠心做出来,甚么事不可以狠心做出来呢?”\n【注释】⑴季氏——根据《左传》昭公二十五年的记载和《汉书·刘向传》,这季氏可能是指季平子,卽季孙意如。据《韩诗外传》,似以为季康子,马融注则以为季桓子,恐皆不足信。⑵八佾——佾音逸,yì。古代舞蹈奏乐,八个人为一行,这一行叫一佾。八佾是八行,八八六十四人,只有天子才能用。诸侯用六佾,卽六行,四十八人。大夫用四佾,三十二人。四佾才是季氏所应该用的。⑶忍——一般人把它解为“容忍”、“忍耐”,不好;因为孔子当时并没有讨伐季氏的条件和意志,而且季平子削弱鲁公室,鲁昭公不能忍,出走到齐,又到晋,终于死在晋国之干侯。这可能就是孔子所“孰不可忍”的事。《贾子·道术篇》:“恻隐怜人谓之慈,反慈为忍。”这“忍”字正是此意。\n3.2三家⑴者以《雍》⑵彻。子曰:“‘相⑶维辟公,天子穆穆’,奚取于三家之堂?”\n【译文】仲孙、叔孙、季孙三家,当他们祭祀祖先时候,[也用天子的礼,]唱着雍这篇诗来撤除祭品。孔子说:“[《雍》诗上有这样的话]‘助祭的是诸侯,天子严肃静穆地在那儿主祭。’这两句话,用在三家祭祖的大厅上在意义上取它哪一点呢?”\n【注释】⑴三家——鲁国当政的三卿。⑵《雍》——也写作“雝”,《诗经·周颂》的一篇。⑶相——去声,音向,xiàng助祭者。\n3.3子曰:“人而不仁,如礼何?人而不仁,如乐何?”\n【译文】孔子说:“做了人,却不仁,怎样来对待礼仪制度呢?做了人,却不仁,怎样来对待音乐呢?”\n3.4林放⑴问礼之本。子曰:“大哉问!礼,与其奢也,宁俭;丧,与其易⑵也,宁戚。”\n【译文】林放问礼的本质。孔子说:“你的问题意义重大呀,就一般礼仪说,与其铺张浪费,宁可朴素俭约;就丧礼说,与其仪文周到,宁可过度悲哀。”\n【注释】⑴林放——鲁人。⑵易——《礼记·檀弓上》云:“子路曰,‘吾闻诸夫子:丧礼,与其哀不足而礼有余也,不若礼不足而哀有余也。”可以看做“与其易也,宁戚”的最早的解释。“易”有把事情办妥的意思,如《孟子·尽心上》“易其田畴”,因此这里译为“仪文周到”。\n3.5子曰:“夷狄之有君,不如⑴诸夏之亡⑵也。”\n【译文】孔子说:“文化落后国家虽然有个君主,还不如中国没有君主哩。”\n【注释】⑴夷狄有君……亡也——杨遇夫先生《论语疏证》说,夷狄有君指楚庄王、吴王阖庐等。君是贤明之君。句意是夷狄还有贤明之君,不像中原诸国却没有。说亦可通。⑵亡——同“无”。在《论语》中,“亡”下不用宾语,“无”下必有宾语。\n3.6季氏旅⑴于泰山。子谓冉有⑵曰:“女弗能救与?”对曰:“不能。”子曰:“呜呼!曾谓泰山不如林放乎?”\n【译文】季氏要去祭祀泰山。孔子对冉有说道:“你不能阻止吗?”冉有答道:“不能。”孔子道:“哎呀!竟可以说泰山之神还不及林放[懂礼,居然接受这不合规矩的祭祀]吗?”\n【注释】⑴旅——动词,祭山。在当时,只有天子和诸侯才有祭祀“名山大川”的资格。季氏只是鲁国的大夫,竟去祭祀泰山,因之孔子认为是“僭礼”。⑵冉有——孔子学生冉求,字子有,小于孔子二十九岁。(公元前522—?)当时在季氏之下做事,所以孔子责备他。\n3.7子曰:“君子无所争。必也射乎!揖让而升,下而饮。其争也君子⑴。”\n【译文】孔子说:“君子没有什么可争的事情。如果有所争,一定是比箭吧,[但是当射箭的时候,]相互作揖然后登堂;[射箭完毕,]走下堂来,然后[作揖]喝酒。那一种竞赛是很有礼貌的。”\n【注释】⑴其争也君子——这是讲古代射礼,详见《仪礼·乡射礼》和《大射仪》。登堂而射,射后计算谁中靶多,中靶少的被罚饮酒。\n3.8子夏问曰:“‘巧笑倩⑴兮,美目盼⑵兮,素以为绚⑶兮。’何谓也?”子曰:“绘事后素。”\n曰:“礼后⑷乎?”子曰:“起⑸予者商也!始可与言诗已矣。”\n【译文】子夏问道:“‘有酒涡的脸笑得美呀,黑白分明的眼流转得媚呀,洁白的底子上画着花卉呀。’这几句诗是什么意思?”孔子道:“先有白色底子,然后画花。”\n子夏道:“那么,是不是礼乐的产生在[仁义]以后呢?”孔子道:“卜商呀,你真是能启发我的人。现在可以同你讨论《诗经》了。”\n【注释】⑴倩——音欠,qiàn,面颊长得好。⑵盼——黑白分明。⑶绚xuàn,有文采,译文为着协韵,故用“画着花卉”以代之。这三句诗,第一句第二句见于《诗经·卫风·硕人》。第三句可能是逸句,王先谦《三家诗义集疏》以为《鲁诗》有此一句。⑷礼后——“礼”在什么之后呢,原文没说出。根据儒家的若干文献,译文加了“仁义”两字。⑸起——友人孙子书(楷第)先生云:“凡人病困而愈谓之起,义有滞碍隐蔽,通达之,亦谓之起。”说见杨遇夫先生《汉书窥管》卷九引文。\n3.9子曰:“夏礼,吾能言之,杞⑴不足征也;殷礼,吾能言之,宋⑵不足征也。文献⑶不足故也。足,则吾能征之矣。”\n【译文】孔子说:“夏代的礼,我能说出来,它的后代杞国不足以作证;殷代的礼,我能说出来,它的后代宋国不足以作证。这是他们的历史文件和贤者不够的缘故。若有足够的文件和贤者,我就可以引来作证了。”\n【注释】⑴杞——国名,夏禹的后代。周武王时候的故城卽今日河南的杞县。其后因为国家弱小,依赖别国的力量来延长国命,屡经迁移。⑵宋——国名,商汤的后代,故城在今日河南商邱县南。国土最大的时候,有现在河南商邱以东,江苏徐州以西之地。战国时为齐、魏、楚三国所共灭。⑵文献——《论语》的“文献”和今天所用的“文献”一词的概念有不同之处。《论语》的“文献”包括历代的历史文件和当时的贤者两项(朱注云:“文,典籍也;献,贤也。”)。今日“文献”一词只指历史文件而言。\n3.10子曰:“禘⑴自既灌⑵而往者,吾不欲观之矣。”\n【译文】孔子说:“禘祭的礼,从第一次献酒以后,我就不想看了。”\n【注释】⑴禘——这一禘礼是指古代一种极为隆重的大祭之礼,只有天子才能举行。不过周成王曾因为周公旦对周朝有过莫大的功勋,特许他举行禘祭。以后鲁国之君都沿此惯例,“僭”用这一禘礼,因此孔子不想看。⑵灌——本作“裸”,祭祀中的一个节目。古代祭祀,用活人以代受祭者,这活人便叫“尸”。尸一般用幼小的男女。第一次献酒给尸,使他(她)闻到“郁鬯”(一种配合香料煮成的酒)的香气,叫做裸。\n3.11或问禘之说。子曰:“不知也⑴;知其说者之于天下也,其如示⑵诸斯乎!”指其掌。\n【译文】有人向孔子请教关于禘祭的理论。孔子说:“我不知道;知道的人对于治理天下,会好像把东西摆在这里一样容易罢!”一面说,一面指着手掌。\n【注释】⑴不知也——禘是天子之礼,鲁国举行,在孔子看来,是完全不应该的。但孔子又不想明白指出,只得说“不欲观”,“不知也”,甚至说“如果有懂得的人,他对于治理天下是好像把东西放在手掌上一样的容易。”⑵示——假借字,同“置”,摆、放的意义。或曰同“视”,犹言“了如指掌”。\n3.12祭如在,祭神如神在。子曰:“吾不与祭,如不祭⑴。”\n【译文】孔子祭祀祖先的时候,便好像祖先真在那里;祭神的时候,便好像神真在那里。孔子又说:“我若是不能亲自参加祭祀,是不请别人代理的。”\n【注释】⑴吾不与祭,如不祭——这是一般的句读法。“与”读去声,音预,yù,参预的意思。“如不祭”译文是意译。另外有人主张“与”字仍读上声,赞同的意思,而且在这里一读,便是“吾不与,祭如不祭”。译文便应改为:“若是我所不同意的祭礼,祭了同没祭一般。”我不同意此义,因为孔丘素来不赞成不合所谓礼的祭祀,如“非其鬼而祭之,谄也”,(2.24)孔丘自不会参加他所不赞同的祭祀。\n3.13王孙贾⑴问曰:“与其媚于奥,宁媚于灶⑵,何谓也?”子曰:“不然;获罪于天,无所祷也⑶。”\n【译文】王孙贾问道:“‘与其巴结房屋里西南角的神,宁可巴结灶君司命,’这两句话是什么意思?”孔子道:“不对;若是得罪了上天,祈祷也没用。”\n【注释】⑴王孙贾——卫灵公的大臣。⑵与其媚于奥,宁媚如灶——这两句疑是当时俗语。屋内西南角叫奥,弄饭的设备叫灶,古代都以为那里有神,因而祭它。⑶王孙贾和孔子的问答都用的比喻,他们的正意何在,我们只能揣想。有人说,奥是一室之主,比喻卫君,又在室内,也可以比喻卫灵公的宠姬南子;灶则是王孙贾自比。这是王孙贾暗示孔子,“你与其巴结卫公或者南子,不如巴结我。”因此孔子答复他:“我若做了坏事,巴结也没有用处,我若不做坏事,谁都不巴结。”又有人说,这不是王孙贾暗示孔子的话,而是请教孔子的话。奥指卫君,灶指南子、弥子瑕,位职虽低,却有权有势。意思是说,“有人告诉我,与其巴结国君,不如巴结有势力的左右像南子、弥子瑕。你以为怎样?”孔子却告诉他:“这话不对;得罪了上天,那无所用其祈祷,巴结谁都不行。”我以为后一说比较近情理。\n3.14子曰:“周监于二代⑴,郁郁乎文哉!吾从周。”\n【译文】孔子说:“周朝的礼仪制度是以夏商两代为根据,然后制定的,多么丰富多彩呀,我主张周朝的。”\n【注释】⑴二代——夏、商两朝。\n3.15子入太庙⑴,每事问。或曰:“孰谓鄹人之子⑵知礼乎?入太庙,每事问。”子闻之,曰:“是礼也。”\n【译文】孔子到了周公庙,每件事情都发问。有人便说:“谁说叔梁纥的这个儿子懂得礼呢?他到了太庙,每件事都要向别人请教。”孔子听到了这话,便道:“这正是礼呀。”\n【注释】⑴太庙——古代开国之君叫太祖,太祖之庙便叫做太庙,周公旦是鲁国最初受封之君,因之这太庙就是周公的庙。⑵鄹人之子——鄹音邹,zōu,又作郰,地名。《史记·孔子世家》:“孔子生鲁昌平乡郰邑。”有人说,这地就是今天的山东省曲阜县东南十里的西邹集。“鄹人”指孔子父亲叔梁纥。叔梁纥曾经作过鄹大夫,古代经常把某地的大夫称为某人,因之这里也把鄹大夫叔梁纥称为“鄹人”。\n3.16子曰:“射不主皮⑴,为⑵力不同科⑶,古之道也。”\n【译文】孔子说:“比箭,不一定要穿破箭靶子,因为各人的气力大小不一样,这是古时的规矩。”\n【注释】⑴射不主皮——“皮”代表箭靶子。古代箭靶子叫“侯”,有用布做的,也有用皮做的。当中画着各种猛兽或者别的东西,最中心的又叫做“正”或者“鹄”。孔子在这里所讲的射应该是演习礼乐的射,而不是军中的武射,因此以中不中为主,不以穿破皮侯与否为主。《仪礼·乡射礼》云,“礼射不主皮”,盖本此。⑵为——去声,wèi,因为。⑶同科——同等。\n3.17子贡欲去⑴告朔之饩羊⑵。子曰:“赐也!尔爱⑶其羊,我爱其礼。”\n【译文】子贡要把鲁国每月初一告祭祖庙的那只活羊去而不用。孔子道:“赐呀,你可惜那只羊,我可惜那种礼。”\n【注释】⑴去——从前读为上声,因为它在这里作为及物动词,而且和“来去”的“去”意义不同。⑵告朔饩羊——“告”,从前人读梏,gù,入声。“朔”,每月的第一天,初一。“饩”,xì。“告朔饩羊”,古代的一种制度。每年秋冬之交,周天子把第二年的历书颁给诸侯。这历书包括那年有无闰月,每月初一是哪一天,因之叫“颁告朔”。诸侯接受了这一历书,藏于祖庙。每逢初一,便杀一只活羊祭于庙,然后回到朝廷听政。这祭庙叫做“告朔”,听政叫做“视朔”,或者“听朔”。到子贡的时候,每月初一,鲁君不但不亲临祖庙,而且也不听政,只是杀一只活羊“虚应故事”罢了。所以子贡认为不必留此形式,不如干脆连羊也不杀。孔子却认为尽管这是残存的形式,也比什么也不留好。⑶爱——可惜的意思。\n3.18子曰:“事君尽礼,人以为谄也。”\n【译文】孔子说:“服事君主,一切依照做臣子的礼节做去,别人却以为他在谄媚哩。”\n3.19定公⑴问:“君使臣,臣事君,如之何?”孔子对曰:“君使臣以礼,臣事君以忠。”\n【译文】鲁定公问:“君主使用臣子,臣子服事君主,各应该怎么样?”孔子答道:“君主应该依礼来使用臣子,臣子应该忠心地服事君主。”\n【注释】⑴定公——鲁君,名宋,昭公之弟,继昭公而立,在位十五年。(公元前509—495)“定”是谥号。\n3.20子曰:“《关雎》⑴,乐而不淫⑵,哀而不伤。”\n【译文】孔子说:“《关雎》这诗,快乐而不放荡,悲哀而不痛苦。”\n【注释】⑴《关雎》——《诗经》的第一篇。但这篇诗并没有悲哀的情调,因此刘台拱的《论语骈枝》说:“诗有《关雎》,乐亦有《关雎》,此章据乐言之。古之乐章皆三篇为一。……乐而不淫者,《关雎》、《葛覃》也;哀而不伤者,卷耳也。”⑵淫——古人凡过分以至于到失当的地步叫淫,如言“淫祀”(不应该祭祀而去祭祀的祭礼)、“淫雨”(过久的雨水)。\n3.21哀公问社⑴于宰我⑵。宰我对曰:“夏后氏以松,殷人以柏,周人以栗,曰,使民战栗。”子闻之,曰:“成事不说,遂事不谏,既往不咎。”\n【译文】鲁哀公向宰我问,作社主用什么木。宰我答道:“夏代用松木,殷代用柏木,周代用栗木,意思是使人民战战栗栗。”孔子听到了这话,[责备宰我]说:“已经做了的事不便再解释了,已经完成的事不便再挽救了,已经过去的事不便再追究了。”\n【注释】⑴社——土神叫社,不过哀公所问的社,从宰我的答话中可以推知是指社主而言。古代祭祀土神,要替他立一个木制的牌位,这牌位叫主,而认为这一木主,便是神灵之所凭依。如果国家有对外战争,还必需载这一木主而行。详见俞正燮《癸巳类稾》。有人说“社”是指立社所栽的树,未必可信。⑵宰我——孔子学生,名予,字子我。\n3.22子曰:“管仲⑴之器小哉!”\n或曰:“管仲俭乎?”曰:“管氏有三归⑵,官事不摄⑶,焉得俭?”\n“然则管仲知礼乎?”曰:“邦君树塞门⑷,管氏亦树塞门。邦君为两君之好⑸,有反坫⑹,管氏亦有反坫。管氏而⑺知礼,孰不知礼?”\n【译文】孔子说:“管仲的器量狭小得很呀!”\n有人便问:“他是不是很节俭呢?”孔子道:“他收取了人民的大量的市租,他手下的人员,[一人一职,]从不兼差,如何能说是节俭呢?”\n那人又问:“那末,他懂得礼节么?”孔子又道:“国君宫殿门前,立了一个塞门;管氏也立了个塞门;国君设燕招待外国的君主,在堂上有放置酒杯的设备,管氏也有这样的设备。假若说他懂得礼节,那谁不懂得礼节呢?”\n【注释】⑴管仲——春秋时齐国人,名夷吾,做了齐桓公的宰相,使他称霸诸侯。⑵三归——“三归”的解释还有:(甲)国君一娶三女,管仲也娶了三国之女(《集解》引包咸说,皇侃《义疏》等);(乙)三处家庭(俞樾《羣经平议》);(丙)地名,管仲的采邑(梁玉绳《瞥记》);(丁)藏泉币的府库(武亿《羣经义证》)。我认为这些解释都不正确。郭嵩焘《养知书屋文集》卷一释三归云:“此盖《管子》九府轻重之法,当就《管子》书求之。〈山至数篇〉曰。‘则民之三有归于上矣。’三归之名,实本于此。是所谓三归者,市租之常例之归之公者也。桓公既霸,遂以赏管仲。《汉书·地理志》、《食货志》并云,桓公用管仲设轻重以富民,身在陪臣,而取三归。其言较然明显。《韩非子》云,‘使子有三归之家’,《说苑》作‘赏之市租’。三归之为市租,汉世儒者犹能明之,此一证也。《晏子春秋》辞三归之赏,而云厚受赏以伤国民之义,其取之民无疑也,此又一证也。”这一说法很有道理。我还再举两个间接证据。(甲)《战国策》一说:“齐桓公宫中七市,女闾七百,国人非之。管仲故为三归之家以掩桓公,非自伤于民也。”似亦以三归为市租。(乙)《三国志·魏志·武帝纪》建安十五年令曰:“若必廉士而后可用,则齐桓其何以霸?”亦以管仲不是清廉之士,当指三归。⑶摄——兼职。⑷树塞门——树,动词,立也。塞门,用以间隔内外视线的一种东西,形式和作用可以同今天的照壁相比。⑸好——古读去声,友好。⑹反坫——坫音店,diàn,用以放置器物的设备,用土筑成的,形似土堆,筑于两楹(厅堂前部东西各有一柱)之间。详全祖望《经史问答》。⑺而——假设连词,假如,假若。\n3.23子语⑴鲁大师⑵乐,曰:“乐其可知也:始作,翕⑶如也;从⑷之,纯如也,皦⑸如也,绎如也,以成。”\n【译文】孔子把演奏音乐的道理告给鲁国的太师,说道:“音乐,那是可以晓得的:开始演奏,翕翕地热烈;继续下去,纯纯地和谐,皦皦地清晰,绎绎地不绝,这样,然后完成。”\n【注释】⑴语——去声,yù,告诉。⑵大师——大音泰,tài,乐官之长。⑶翕——xī。⑷从——去声,zòng。⑸皦——音皎,jiǎo。\n3.24仪封人⑴请见⑵,曰:“君子之至于斯也,吾未尝不得见也。”从者⑶见之⑵。出曰:“二三子何患于丧⑷乎?天下之无道也久矣,天将以夫子为木铎⑸。”\n【译文】仪这个地方的边防官请求孔子接见他,说道:“所有到了这个地方的有道德学问的人,我从没有不和他见面的。”孔子的随行学生请求孔子接见了他。他辞出以后,对孔子的学生们说:“你们这些人为什么着急没有官位呢?天下黑暗日子也长久了,[圣人也该有得意的时候了,]上天会要把他老人家做人民的导师哩。”\n【注释】⑴仪封人——仪,地名。有人说当在今日的开封市内,未必可靠。封人,官名。《左传》有颖谷封人、祭封人、萧封人、吕封人,大概是典守边疆的官。说本方观旭《论语偶记》。⑵请见、见之——两个“见”字从前都读去声,音现,xiàn。“请见”是请求接见的意思,“见之”是使孔子接见了他的意思。何焯《义门读书记》云:“古者相见必由绍介,逆旅之中无可因缘,故称平日未尝见绝于贤者,见气类之同,致词以代绍介,故从者因而通之。夫子亦不拒其请,与不见孺悲异也。”⑶从者——“从”去声,zòng。⑷丧——去声,sàng,失掉官位。⑸木铎——铜质木舌的铃子。古代公家有什么事要宣布,便摇这铃,召集大家来听。\n3.25子谓韶⑴,“尽美⑵矣,又尽善⑵也。”谓武⑶,“尽美矣,未尽善也。”\n【译文】孔子论到韶,说:“美极了,而且好极了。”论到武,说:“美极了,却还不够好。”\n【注释】⑴韶——舜时的乐曲名。⑵美、善——“美”可能指声音言,“善”可能指内容言。舜的天子之位是由尧“禅让”而来,故孔子认为“尽善”。周武王的天子之位是由讨伐商纣而来,尽管是正义战,依孔子意,却认为“未尽善”。⑶武——周武王时乐曲名。\n3.26子曰:“居上不宽,为礼不敬,临丧不哀,吾何以观之哉?”\n【译文】孔子说:“居于统治地位不宽宏大量,行礼的时候不严肃认真,参加丧礼的时候不悲哀,这种样子我怎么看得下去呢?”\n"},{"id":121,"href":"/zh/docs/technology/MySQL/MySQL%E6%98%AF%E6%80%8E%E6%A0%B7%E8%BF%90%E8%A1%8C%E7%9A%84/%E7%AC%AC11%E7%AB%A0_%E4%B8%A4%E4%B8%AA%E8%A1%A8%E7%9A%84%E4%BA%B2%E5%AF%86%E6%8E%A5%E8%A7%A6-%E8%BF%9E%E6%8E%A5%E7%9A%84%E5%8E%9F%E7%90%86/","title":"第11章_两个表的亲密接触-连接的原理","section":"My Sql是怎样运行的","content":"第11章 两个表的亲密接触-连接的原理\n搞数据库一个避不开的概念就是Join,翻译成中文就是连接。相信很多小伙伴在初学连接的时候有些一脸懵逼,理解了连接的语义之后又可能不明白各个表中的记录到底是怎么连起来的,以至于在使用的时候常常陷入下面两种误区: - 误区一:业务至上,管他三七二十一,再复杂的查询也用在一个连接语句中搞定。 - 误区二:敬而远之,上次 DBA 那给报过来的慢查询就是因为使用了连接导致的,以后再也不敢用了。\n所以本章就来扒一扒连接的原理。考虑到一部分小伙伴可能忘了连接是什么或者压根儿就不知道,为了节省他们百度或者看其他书的宝贵时间以及为了我的书凑字数,我们先来介绍一下 MySQL 中支持的一些连接语法。\n连接简介 # 连接的本质 # 为了故事的顺利发展,我们先建立两个简单的表并给它们填充一点数据:\nmysql\u0026gt; CREATE TABLE t2 (m2 int, n2 char\\(1)\\); Query OK, 0 rows affected (0.02 sec) mysql\u0026gt; INSERT INTO t1 VALUES(1, \u0026#39;a\u0026#39;), (2, \u0026#39;b\u0026#39;), (3, \u0026#39;c\u0026#39;); Query OK, 3 rows affected (0.00 sec) Records: 3 Duplicates: 0 Warnings: 0 mysql\u0026gt; INSERT INTO t2 VALUES(2, \u0026#39;b\u0026#39;), (3, \u0026#39;c\u0026#39;), (4, \u0026#39;d\u0026#39;); Query OK, 3 rows affected (0.00 sec) Records: 3 Duplicates: 0 Warnings: 0 我们成功建立了t1、t2两个表,这两个表都有两个列,一个是INT类型的,一个是CHAR(1)`类型的,填充好数据的两个表长这样:\nmysql\u0026gt; SELECT * FROM t1; \\+------\\+------\\+ | m1 | n1 | \\+------\\+------\\+ | 1 | a | | 2 | b | | 3 | c | \\+------\\+------\\+ 3 rows in set (0.00 sec) mysql\u0026gt; SELECT * FROM t2; \\+------\\+------\\+ | m2 | n2 | \\+------\\+------\\+ | 2 | b | | 3 | c | | 4 | d | \\+------\\+------\\+ 3 rows in set (0.00 sec) 连接的本质就是把各个连接表中的记录都取出来依次匹配的组合加入结果集并返回给用户。所以我们把t1和t2两个表连接起来的过程如下图所示:\n这个过程看起来就是把t1表的记录和t2的记录连起来组成新的更大的记录,所以这个查询过程称之为连接查询。连接查询的结果集中包含一个表中的每一条记录与另一个表中的每一条记录相互匹配的组合,像这样的结果集就可以称之为笛卡尔积。因为表t1中有3条记录,表t2中也有3条记录,所以这两个表连接之后的笛卡尔积就有3×3=9行记录。在MySQL中,连接查询的语法也很随意,只要在FROM语句后边跟多个表名就好了,比如我们把t1表和t2表连接起来的查询语句可以写成这样: mysql\u0026gt; SELECT * FROM t1, t2; +------+------+------+------+ | m1 | n1 | m2 | n2 | +------+------+------+------+ | 1 | a | 2 | b | | 2 | b | 2 | b | | 3 | c | 2 | b | | 1 | a | 3 | c | | 2 | b | 3 | c | | 3 | c | 3 | c | | 1 | a | 4 | d | | 2 | b | 4 | d | | 3 | c | 4 | d | +------+------+------+------+ 9 rows in set (0.00 sec)\n连接过程简介 # 如果我们乐意,我们可以连接任意数量张表,但是如果没有任何限制条件的话,这些表连接起来产生的笛卡尔积可能是非常巨大的。比方说3个100行记录的表连接起来产生的笛卡尔积就有100×100×100=1000000行数据!所以在连接的时候过滤掉特定记录组合是有必要的,在连接查询中的过滤条件可以分成两种:\n涉及单表的条件\n这种只设计单表的过滤条件我们之前都提到过一万遍了,我们之前也一直称为搜索条件,比如t1.m1 \u0026gt; 1是只针对t1表的过滤条件,t2.n2 \u0026lt; 'd'是只针对t2表的过滤条件。\n涉及两表的条件\n这种过滤条件我们之前没见过,比如t1.m1 = t2.m2、t1.n1 \u0026gt; t2.n2等,这些条件中涉及到了两个表,我们稍后会仔细分析这种过滤条件是如何使用的。\n下面我们就要看一下携带过滤条件的连接查询的大致执行过程了,比方说下面这个查询语句: SELECT * FROM t1, t2 WHERE t1.m1 \u0026gt; 1 AND t1.m1 = t2.m2 AND t2.n2 \u0026lt; 'd'; 在这个查询中我们指明了这三个过滤条件:\nt1.m1 \u0026gt; 1\nt1.m1 = t2.m2\nt2.n2 \u0026lt; 'd'\n那么这个连接查询的大致执行过程如下:\n首先确定第一个需要查询的表,这个表称之为驱动表。怎样在单表中执行查询语句我们在前一章都介绍过了,只需要选取代价最小的那种访问方法去执行单表查询语句就好了(就是说从const、ref、ref_or_null、range、index、all这些执行方法中选取代价最小的去执行查询)。此处假设使用t1作为驱动表,那么就需要到t1表中找满足t1.m1 \u0026gt; 1的记录,因为表中的数据太少,我们也没在表上建立二级索引,所以此处查询t1表的访问方法就设定为all吧,也就是采用全表扫描的方式执行单表查询。关于如何提升连接查询的性能我们之后再说,现在先把基本概念捋清楚。所以查询过程就如下图所示:\n我们可以看到,t1表中符合t1.m1 \u0026gt; 1的记录有两条。\n针对上一步骤中从驱动表产生的结果集中的每一条记录,分别需要到t2表中查找匹配的记录,所谓匹配的记录,指的是符合过滤条件的记录。因为是根据t1表中的记录去找t2表中的记录,所以t2表也可以被称之为被驱动表。上一步骤从驱动表中得到了2条记录,所以需要查询2次t2表。此时涉及两个表的列的过滤条件t1.m1 = t2.m2就派上用场了:\n+ 当`t1.m1 = 2`时,过滤条件`t1.m1 = t2.m2`就相当于`t2.m2 = 2`,所以此时`t2`表相当于有了`t2.m2 = 2`、`t2.n2 \u0026lt; 'd'`这两个过滤条件,然后到`t2`表中执行单表查询。 + 当`t1.m1 = 3`时,过滤条件`t1.m1 = t2.m2`就相当于`t2.m2 = 3`,所以此时`t2`表相当于有了`t2.m2 = 3`、`t2.n2 \u0026lt; 'd'`这两个过滤条件,然后到`t2`表中执行单表查询。 所以整个连接查询的执行过程就如下图所示:\n也就是说整个连接查询最后的结果只有两条符合过滤条件的记录:\n+------+------+------+------+ | m1 | n1 | m2 | n2 | +------+------+------+------+ | 2 | b | 2 | b | | 3 | c | 3 | c | +------+------+------+------+\n从上面两个步骤可以看出来,我们上面介绍的这个两表连接查询共需要查询1次t1表,2次t2表。当然这是在特定的过滤条件下的结果,如果我们把t1.m1 \u0026gt; 1这个条件去掉,那么从t1表中查出的记录就有3条,就需要查询3次t2表了。也就是说在两表连接查询中,驱动表只需要访问一次,被驱动表可能被访问多次。\n内连接和外连接 # 为了大家更好理解后边内容,我们先创建两个有现实意义的表,\nCREATE TABLE score ( number INT COMMENT \u0026#39;学号\u0026#39;, subject VARCHAR\\(30) COMMENT \u0026#39;科目\u0026#39;, score TINYINT COMMENT \u0026#39;成绩\u0026#39;, PRIMARY KEY (number, score) \\) Engine=InnoDB CHARSET=utf8 COMMENT \u0026#39;学生成绩表\u0026#39;; 我们新建了一个学生信息表,一个学生成绩表,然后我们向上述两个表中插入一些数据,为节省篇幅,具体插入过程就不介绍了,插入后两表中的数据如下: ` mysql\u0026gt; SELECT * FROM student; +\u0026mdash;\u0026mdash;\u0026mdash;-+\u0026mdash;\u0026mdash;\u0026mdash;\u0026ndash;+\u0026mdash;\u0026mdash;\u0026mdash;\u0026mdash;\u0026mdash;\u0026mdash;\u0026mdash;\u0026mdash;\u0026ndash;+ | number | name | major | +\u0026mdash;\u0026mdash;\u0026mdash;-+\u0026mdash;\u0026mdash;\u0026mdash;\u0026ndash;+\u0026mdash;\u0026mdash;\u0026mdash;\u0026mdash;\u0026mdash;\u0026mdash;\u0026mdash;\u0026mdash;\u0026ndash;+ | 20180101 | 杜子腾 | 软件学院 | | 20180102 | 范统 | 计算机科学与工程 | | 20180103 | 史珍香 | 计算机科学与工程 | +\u0026mdash;\u0026mdash;\u0026mdash;-+\u0026mdash;\u0026mdash;\u0026mdash;\u0026ndash;+\u0026mdash;\u0026mdash;\u0026mdash;\u0026mdash;\u0026mdash;\u0026mdash;\u0026mdash;\u0026mdash;\u0026ndash;+ 3 rows in set (0.00 sec)\nmysql\u0026gt; SELECT * FROM score; +\u0026mdash;\u0026mdash;\u0026mdash;-+\u0026mdash;\u0026mdash;\u0026mdash;\u0026mdash;\u0026mdash;\u0026mdash;\u0026mdash;\u0026mdash;\u0026mdash;\u0026ndash;+\u0026mdash;\u0026mdash;-+ | number | subject | score | +\u0026mdash;\u0026mdash;\u0026mdash;-+\u0026mdash;\u0026mdash;\u0026mdash;\u0026mdash;\u0026mdash;\u0026mdash;\u0026mdash;\u0026mdash;\u0026mdash;\u0026ndash;+\u0026mdash;\u0026mdash;-+ | 20180101 | 母猪的产后护理 | 78 | | 20180101 | 论萨达姆的战争准备 | 88 | | 20180102 | 论萨达姆的战争准备 | 98 | | 20180102 | 母猪的产后护理 | 100 | +\u0026mdash;\u0026mdash;\u0026mdash;-+\u0026mdash;\u0026mdash;\u0026mdash;\u0026mdash;\u0026mdash;\u0026mdash;\u0026mdash;\u0026mdash;\u0026mdash;\u0026ndash;+\u0026mdash;\u0026mdash;-+ 4 rows in set (0.00 sec) 现在我们想把每个学生的考试成绩都查询出来就需要进行两表连接了(因为score中没有姓名信息,所以不能单纯只查询score表)。连接过程就是从student表中取出记录,在score表中查找number相同的成绩记录,所以过滤条件就是student.number = socre.number,整个查询语句就是这样: mysql\u0026gt; SELECT * FROM student, score WHERE student.number = score.number; +\u0026mdash;\u0026mdash;\u0026mdash;-+\u0026mdash;\u0026mdash;\u0026mdash;\u0026ndash;+\u0026mdash;\u0026mdash;\u0026mdash;\u0026mdash;\u0026mdash;\u0026mdash;\u0026mdash;\u0026mdash;\u0026ndash;+\u0026mdash;\u0026mdash;\u0026mdash;-+\u0026mdash;\u0026mdash;\u0026mdash;\u0026mdash;\u0026mdash;\u0026mdash;\u0026mdash;\u0026mdash;\u0026mdash;\u0026ndash;+\u0026mdash;\u0026mdash;-+ | number | name | major | number | subject | score | +\u0026mdash;\u0026mdash;\u0026mdash;-+\u0026mdash;\u0026mdash;\u0026mdash;\u0026ndash;+\u0026mdash;\u0026mdash;\u0026mdash;\u0026mdash;\u0026mdash;\u0026mdash;\u0026mdash;\u0026mdash;\u0026ndash;+\u0026mdash;\u0026mdash;\u0026mdash;-+\u0026mdash;\u0026mdash;\u0026mdash;\u0026mdash;\u0026mdash;\u0026mdash;\u0026mdash;\u0026mdash;\u0026mdash;\u0026ndash;+\u0026mdash;\u0026mdash;-+ | 20180101 | 杜子腾 | 软件学院 | 20180101 | 母猪的产后护理 | 78 | | 20180101 | 杜子腾 | 软件学院 | 20180101 | 论萨达姆的战争准备 | 88 | | 20180102 | 范统 | 计算机科学与工程 | 20180102 | 论萨达姆的战争准备 | 98 | | 20180102 | 范统 | 计算机科学与工程 | 20180102 | 母猪的产后护理 | 100 | +\u0026mdash;\u0026mdash;\u0026mdash;-+\u0026mdash;\u0026mdash;\u0026mdash;\u0026ndash;+\u0026mdash;\u0026mdash;\u0026mdash;\u0026mdash;\u0026mdash;\u0026mdash;\u0026mdash;\u0026mdash;\u0026ndash;+\u0026mdash;\u0026mdash;\u0026mdash;-+\u0026mdash;\u0026mdash;\u0026mdash;\u0026mdash;\u0026mdash;\u0026mdash;\u0026mdash;\u0026mdash;\u0026mdash;\u0026ndash;+\u0026mdash;\u0026mdash;-+ 4 rows in set (0.00 sec) ```\n字段有点多哦,我们少查询几个字段: mysql\u0026gt; SELECT s1.number, s1.name, s2.subject, s2.score FROM student AS s1, score AS s2 WHERE s1.number = s2.number; +----------+-----------+-----------------------------+-------+ | number | name | subject | score | +----------+-----------+-----------------------------+-------+ | 20180101 | 杜子腾 | 母猪的产后护理 | 78 | | 20180101 | 杜子腾 | 论萨达姆的战争准备 | 88 | | 20180102 | 范统 | 论萨达姆的战争准备 | 98 | | 20180102 | 范统 | 母猪的产后护理 | 100 | +----------+-----------+-----------------------------+-------+ 4 rows in set (0.00 sec) 从上述查询结果中我们可以看到,各个同学对应的各科成绩就都被查出来了,可是有个问题,史珍香同学,也就是学号为20180103的同学因为某些原因没有参加考试,所以在score表中没有对应的成绩记录。那如果老师想查看所有同学的考试成绩,即使是缺考的同学也应该展示出来,但是到目前为止我们介绍的连接查询是无法完成这样的需求的。我们稍微思考一下这个需求,其本质是想:驱动表中的记录即使在被驱动表中没有匹配的记录,也仍然需要加入到结果集。为了解决这个问题,就有了内连接和外连接的概念:\n对于内连接的两个表,驱动表中的记录在被驱动表中找不到匹配的记录,该记录不会加入到最后的结果集,我们上面提到的连接都是所谓的内连接。\n对于外连接的两个表,驱动表中的记录即使在被驱动表中没有匹配的记录,也仍然需要加入到结果集。\n在MySQL中,根据选取驱动表的不同,外连接仍然可以细分为2种:\n+ 左外连接\n选取左侧的表为驱动表。\n+ 右外连接\n选取右侧的表为驱动表。\n可是这样仍然存在问题,即使对于外连接来说,有时候我们也并不想把驱动表的全部记录都加入到最后的结果集。这就犯难了,有时候匹配失败要加入结果集,有时候又不要加入结果集,这咋办,有点儿愁啊。。。噫,把过滤条件分为两种不就解决了这个问题了么,所以放在不同地方的过滤条件是有不同语义的:\nWHERE子句中的过滤条件\nWHERE子句中的过滤条件就是我们平时见的那种,不论是内连接还是外连接,凡是不符合WHERE子句中的过滤条件的记录都不会被加入最后的结果集。\nON子句中的过滤条件\n对于外连接的驱动表的记录来说,如果无法在被驱动表中找到匹配ON子句中的过滤条件的记录,那么该记录仍然会被加入到结果集中,对应的被驱动表记录的各个字段使用NULL值填充。\n需要注意的是,这个ON子句是专门为外连接驱动表中的记录在被驱动表找不到匹配记录时应不应该把该记录加入结果集这个场景下提出的,所以如果把ON子句放到内连接中,MySQL会把它和WHERE子句一样对待,也就是说:内连接中的WHERE子句和ON子句是等价的。\n一般情况下,我们都把只涉及单表的过滤条件放到WHERE子句中,把涉及两表的过滤条件都放到ON子句中,我们也一般把放到ON子句中的过滤条件也称之为连接条件。\n小贴士:左外连接和右外连接简称左连接和右连接,所以下面提到的左外连接和右外连接中的外字都用括号扩起来,以表示这个字儿可有可无。\n左(外)连接的语法 # 左(外)连接的语法还是挺简单的,比如我们要把t1表和t2表进行左外连接查询可以这么写: SELECT * FROM t1 LEFT [OUTER] JOIN t2 ON 连接条件 [WHERE 普通过滤条件]; 其中,中括号里的OUTER单词是可以省略的。对于LEFT JOIN类型的连接来说,我们把放在左边的表称之为外表或者驱动表,右边的表称之为内表或者被驱动表。所以上述例子中t1就是外表或者驱动表,t2就是内表或者被驱动表。需要注意的是,对于左(外)连接和右(外)连接来说,必须使用ON子句来指出连接条件。了解了左(外)连接的基本语法之后,再次回到我们上面那个现实问题中来,看看怎样写查询语句才能把所有的学生的成绩信息都查询出来,即使是缺考的考生也应该被放到结果集中:\nmysql\u0026gt; SELECT s1.number, s1.name, s2.subject, s2.score FROM student AS s1 LEFT JOIN score AS s2 ON s1.number = s2.number; +----------+-----------+-----------------------------+-------+ | number | name | subject | score | +----------+-----------+-----------------------------+-------+ | 20180101 | 杜子腾 | 母猪的产后护理 | 78 | | 20180101 | 杜子腾 | 论萨达姆的战争准备 | 88 | | 20180102 | 范统 | 论萨达姆的战争准备 | 98 | | 20180102 | 范统 | 母猪的产后护理 | 100 | | 20180103 | 史珍香 | NULL | NULL | +----------+-----------+-----------------------------+-------+ 5 rows in set (0.04 sec) 从结果集中可以看出来,虽然史珍香并没有对应的成绩记录,但是由于采用的是连接类型为左(外)连接,所以仍然把她放到了结果集中,只不过在对应的成绩记录的各列使用NULL值填充而已。\n右(外)连接的语法 # 右(外)连接和左(外)连接的原理是一样一样的,语法也只是把LEFT换成RIGHT而已: SELECT * FROM t1 RIGHT [OUTER] JOIN t2 ON 连接条件 [WHERE 普通过滤条件]; 只不过驱动表是右边的表,被驱动表是左边的表,具体就不介绍了。\n内连接的语法 # 内连接和外连接的根本区别就是在驱动表中的记录不符合ON子句中的连接条件时不会把该记录加入到最后的结果集,我们最开始介绍的那些连接查询的类型都是内连接。不过之前仅仅提到了一种最简单的内连接语法,就是直接把需要连接的多个表都放到FROM子句后边。其实针对内连接,MySQL提供了好多不同的语法,我们以t1和t2表为例看看: SELECT * FROM t1 [INNER | CROSS] JOIN t2 [ON 连接条件] [WHERE 普通过滤条件]; 也就是说在MySQL中,下面这几种内连接的写法都是等价的: - SELECT * FROM t1 JOIN t2; - SELECT * FROM t1 INNER JOIN t2; - SELECT * FROM t1 CROSS JOIN t2;\n上面的这些写法和直接把需要连接的表名放到FROM语句之后,用逗号,分隔开的写法是等价的: SELECT * FROM t1, t2; 现在我们虽然介绍了很多种内连接的书写方式,不过熟悉一种就好了,这里我们推荐INNER JOIN的形式书写内连接(因为INNER JOIN语义很明确嘛,可以和LEFT JOIN和RIGHT JOIN很轻松的区分开)。这里需要注意的是,由于在内连接中ON子句和WHERE子句是等价的,所以内连接中不要求强制写明ON子句。\n我们前面说过,连接的本质就是把各个连接表中的记录都取出来依次匹配的组合加入结果集并返回给用户。不论哪个表作为驱动表,两表连接产生的笛卡尔积肯定是一样的。而对于内连接来说,由于凡是不符合ON子句或WHERE子句中的条件的记录都会被过滤掉,其实也就相当于从两表连接的笛卡尔积中把不符合过滤条件的记录给踢出去,所以对于内连接来说,驱动表和被驱动表是可以互换的,并不会影响最后的查询结果。但是对于外连接来说,由于驱动表中的记录即使在被驱动表中找不到符合ON子句连接条件的记录,所以此时驱动表和被驱动表的关系就很重要了,也就是说左外连接和右外连接的驱动表和被驱动表不能轻易互换。\n小结 # 上面说了很多,给大家的感觉不是很直观,我们直接把表t1和t2的三种连接方式写在一起,这样大家理解起来就很easy了: ``` mysql\u0026gt; SELECT * FROM t1 INNER JOIN t2 ON t1.m1 = t2.m2; +\u0026mdash;\u0026mdash;+\u0026mdash;\u0026mdash;+\u0026mdash;\u0026mdash;+\u0026mdash;\u0026mdash;+ | m1 | n1 | m2 | n2 | +\u0026mdash;\u0026mdash;+\u0026mdash;\u0026mdash;+\u0026mdash;\u0026mdash;+\u0026mdash;\u0026mdash;+ | 2 | b | 2 | b | | 3 | c | 3 | c | +\u0026mdash;\u0026mdash;+\u0026mdash;\u0026mdash;+\u0026mdash;\u0026mdash;+\u0026mdash;\u0026mdash;+ 2 rows in set (0.00 sec)\nmysql\u0026gt; SELECT * FROM t1 LEFT JOIN t2 ON t1.m1 = t2.m2; +\u0026mdash;\u0026mdash;+\u0026mdash;\u0026mdash;+\u0026mdash;\u0026mdash;+\u0026mdash;\u0026mdash;+ | m1 | n1 | m2 | n2 | +\u0026mdash;\u0026mdash;+\u0026mdash;\u0026mdash;+\u0026mdash;\u0026mdash;+\u0026mdash;\u0026mdash;+ | 2 | b | 2 | b | | 3 | c | 3 | c | | 1 | a | NULL | NULL | +\u0026mdash;\u0026mdash;+\u0026mdash;\u0026mdash;+\u0026mdash;\u0026mdash;+\u0026mdash;\u0026mdash;+ 3 rows in set (0.00 sec)\nmysql\u0026gt; SELECT * FROM t1 RIGHT JOIN t2 ON t1.m1 = t2.m2; +\u0026mdash;\u0026mdash;+\u0026mdash;\u0026mdash;+\u0026mdash;\u0026mdash;+\u0026mdash;\u0026mdash;+ | m1 | n1 | m2 | n2 | +\u0026mdash;\u0026mdash;+\u0026mdash;\u0026mdash;+\u0026mdash;\u0026mdash;+\u0026mdash;\u0026mdash;+ | 2 | b | 2 | b | | 3 | c | 3 | c | | NULL | NULL | 4 | d | +\u0026mdash;\u0026mdash;+\u0026mdash;\u0026mdash;+\u0026mdash;\u0026mdash;+\u0026mdash;\u0026mdash;+ 3 rows in set (0.00 sec) ```\n连接的原理 # 上面的介绍都只是为了唤醒大家对连接、内连接、外连接这些概念的记忆,这些基本概念是为了真正进入本章主题做的铺垫。真正的重点是MySQL采用了什么样的算法来进行表与表之间的连接,了解了这个之后,大家才能明白为什么有的连接查询运行的快如闪电,有的却慢如蜗牛。\n嵌套循环连接(Nested-Loop Join) # 我们前面说过,对于两表连接来说,驱动表只会被访问一遍,但被驱动表却要被访问到好多遍,具体访问几遍取决于对驱动表执行单表查询后的结果集中的记录条数。对于内连接来说,选取哪个表为驱动表都没关系,而外连接的驱动表是固定的,也就是说左(外)连接的驱动表就是左边的那个表,右(外)连接的驱动表就是右边的那个表。我们上面已经大致介绍过t1表和t2表执行内连接查询的大致过程,我们温习一下: - 步骤1:选取驱动表,使用与驱动表相关的过滤条件,选取代价最低的单表访问方法来执行对驱动表的单表查询。 - 步骤2:对上一步骤中查询驱动表得到的结果集中每一条记录,都分别到被驱动表中查找匹配的记录。\n通用的两表连接过程如下图所示:\n如果有3个表进行连接的话,那么步骤2中得到的结果集就像是新的驱动表,然后第三个表就成为了被驱动表,重复上面过程,也就是步骤2中得到的结果集中的每一条记录都需要到t3表中找一找有没有匹配的记录,用伪代码表示一下这个过程就是这样: ``` for each row in t1 { #此处表示遍历满足对t1单表查询结果集中的每一条记录\nfor each row in t2 { #此处表示对于某条t1表的记录来说,遍历满足对t2单表查询结果集中的每一条记录 for each row in t3 { #此处表示对于某条t1和t2表的记录组合来说,对t3表进行单表查询 if row satisfies join conditions, send to client } } } ``` 这个过程就像是一个嵌套的循环,所以这种驱动表只访问一次,但被驱动表却可能被多次访问,访问次数取决于对驱动表执行单表查询后的结果集中的记录条数的连接执行方式称之为嵌套循环连接(Nested-Loop Join),这是最简单,也是最笨拙的一种连接查询算法。\n使用索引加快连接速度 # 我们知道在嵌套循环连接的步骤2中可能需要访问多次被驱动表,如果访问被驱动表的方式都是全表扫描的话,妈呀,那得要扫描好多次呀~~~ 但是别忘了,查询t2表其实就相当于一次单表扫描,我们可以利用索引来加快查询速度哦。回顾一下最开始介绍的t1表和t2表进行内连接的例子: SELECT * FROM t1, t2 WHERE t1.m1 \u0026gt; 1 AND t1.m1 = t2.m2 AND t2.n2 \u0026lt; 'd'; 我们使用的其实是嵌套循环连接算法执行的连接查询,再把上面那个查询执行过程表拉下来给大家看一下:\n查询驱动表t1后的结果集中有两条记录,嵌套循环连接算法需要对被驱动表查询2次:\n当t1.m1 = 2时,去查询一遍t2表,对t2表的查询语句相当于:\nSELECT * FROM t2 WHERE t2.m2 = 2 AND t2.n2 \u0026lt; 'd';\n当t1.m1 = 3时,再去查询一遍t2表,此时对t2表的查询语句相当于:\nSELECT * FROM t2 WHERE t2.m2 = 3 AND t2.n2 \u0026lt; 'd';\n可以看到,原来的t1.m1 = t2.m2这个涉及两个表的过滤条件在针对t2表做查询时关于t1表的条件就已经确定了,所以我们只需要单单优化对t2表的查询了,上述两个对t2表的查询语句中利用到的列是m2和n2列,我们可以:\n在m2列上建立索引,因为对m2列的条件是等值查找,比如t2.m2 = 2、t2.m2 = 3等,所以可能使用到ref的访问方法,假设使用ref的访问方法去执行对t2表的查询的话,需要回表之后再判断t2.n2 \u0026lt; d这个条件是否成立。\n这里有一个比较特殊的情况,就是假设m2列是t2表的主键或者唯一二级索引列,那么使用t2.m2 = 常数值这样的条件从t2表中查找记录的过程的代价就是常数级别的。我们知道在单表中使用主键值或者唯一二级索引列的值进行等值查找的方式称之为const,而设计MySQL的大佬把在连接查询中对被驱动表使用主键值或者唯一二级索引列的值进行等值查找的查询执行方式称之为:eq_ref。\n在n2列上建立索引,涉及到的条件是t2.n2 \u0026lt; 'd',可能用到range的访问方法,假设使用range的访问方法对t2表的查询的话,需要回表之后再判断在m2列上的条件是否成立。\n假设m2和n2列上都存在索引的话,那么就需要从这两个里边儿挑一个代价更低的去执行对t2表的查询。当然,建立了索引不一定使用索引,只有在二级索引 + 回表的代价比全表扫描的代价更低时才会使用索引。\n另外,有时候连接查询的查询列表和过滤条件中可能只涉及被驱动表的部分列,而这些列都是某个索引的一部分,这种情况下即使不能使用eq_ref、ref、ref_or_null或者range这些访问方法执行对被驱动表的查询的话,也可以使用索引扫描,也就是index的访问方法来查询被驱动表。所以我们建议在真实工作中最好不要使用*作为查询列表,最好把真实用到的列作为查询列表。\n基于块的嵌套循环连接(Block Nested-Loop Join) # 扫描一个表的过程其实是先把这个表从磁盘上加载到内存中,然后从内存中比较匹配条件是否满足。现实生活中的表可不像t1、t2这种只有3条记录,成千上万条记录都是少的,几百万、几千万甚至几亿条记录的表到处都是。内存里可能并不能完全存放的下表中所有的记录,所以在扫描表前面记录的时候后边的记录可能还在磁盘上,等扫描到后边记录的时候可能内存不足,所以需要把前面的记录从内存中释放掉。我们前面又说过,采用嵌套循环连接算法的两表连接过程中,被驱动表可是要被访问好多次的,如果这个被驱动表中的数据特别多而且不能使用索引进行访问,那就相当于要从磁盘上读好几次这个表,这个I/O代价就非常大了,所以我们得想办法:尽量减少访问被驱动表的次数。\n当被驱动表中的数据非常多时,每次访问被驱动表,被驱动表的记录会被加载到内存中,在内存中的每一条记录只会和驱动表结果集的一条记录做匹配,之后就会被从内存中清除掉。然后再从驱动表结果集中拿出另一条记录,再一次把被驱动表的记录加载到内存中一遍,周而复始,驱动表结果集中有多少条记录,就得把被驱动表从磁盘上加载到内存中多少次。所以我们可不可以在把被驱动表的记录加载到内存的时候,一次性和多条驱动表中的记录做匹配,这样就可以大大减少重复从磁盘上加载被驱动表的代价了。所以设计MySQL的大佬提出了一个join buffer的概念,join buffer就是执行连接查询前申请的一块固定大小的内存,先把若干条驱动表结果集中的记录装在这个join buffer中,然后开始扫描被驱动表,每一条被驱动表的记录一次性和join buffer中的多条驱动表记录做匹配,因为匹配的过程都是在内存中完成的,所以这样可以显著减少被驱动表的I/O代价。使用join buffer的过程如下图所示:\n最好的情况是join buffer足够大,能容纳驱动表结果集中的所有记录,这样只需要访问一次被驱动表就可以完成连接操作了。设计MySQL的大佬把这种加入了join buffer的嵌套循环连接算法称之为基于块的嵌套连接(Block Nested-Loop Join)算法。\n这个join buffer的大小是可以通过启动参数或者系统变量join_buffer_size进行配置,默认大小为262144字节(也就是256KB),最小可以设置为128字节。当然,对于优化被驱动表的查询来说,最好是为被驱动表加上效率高的索引,如果实在不能使用索引,并且自己的机器的内存也比较大可以尝试调大join_buffer_size的值来对连接查询进行优化。\n另外需要注意的是,驱动表的记录并不是所有列都会被放到join buffer中,只有查询列表中的列和过滤条件中的列才会被放到join buffer中,所以再次提醒我们,最好不要把*作为查询列表,只需要把我们关心的列放到查询列表就好了,这样还可以在join buffer中放置更多的记录呢。\n"},{"id":122,"href":"/zh/docs/culture/%E6%81%B0%E5%90%8C%E5%AD%A6%E5%B0%91%E5%B9%B4/%E7%AE%80%E4%BB%8B-%E4%BD%9C%E8%80%85/","title":"简介-作者","section":"恰同学少年","content":"\n请支持正版 # 书名:恰同学少年\n作者:黄辉\n排版:墨茗\n邮箱:1052805965@QQ.COM\n本书仅供个人学习之用,请勿用于商业用途。如果觉得好,请购买正版书籍,书中若有错误,望反馈给我,谢谢。\n所用字体:方正博雅方刊宋_GBK、方正黑体_GBK\n简 介 # 小说根据同名电视剧改编,以毛泽东在湖南第一师范五年半的读书生活为主要表现背景,描绘了1913~1918年以毛泽东、蔡和森、向警予、杨开慧、陶斯咏等为代表的一批优秀青年积极进取的学习生活和他们之间纯真美丽的爱情故事,同时塑造了杨昌济、孔昭绶等一批优秀教师形象。深刻揭示了“学生应该怎样读书,教师应该怎样育人”这个与当今社会紧密相关的现实主题,很好展现了毛泽东为代表的一群风华正茂的青年以天下为己任的抱负与情怀。这对社会主义核心价值体系的构建、现行教育理念的完善、当代青年树立正确的理想追求有重大的现实意义。\n作 者 # 黄晖,第26届电视“飞天奖”优秀编剧获得者,现居长沙。2007年凭借《恰同学少年》荣获中国电视剧艺术成就最高奖——飞天奖优秀编剧,年末编剧创作的“传奇大戏”《血色湘西》在湖南卫视引起收视狂潮。\n2007年,作品《恰同学少年》红遍大江南北,不仅得到普通观众的追捧,同时也受到了国家领导高层的高度关注,黄晖凭此剧一举拿下今年电视“飞天奖”优秀编剧奖。年末,湖南卫视推出他编剧的“传奇大片”《血色湘西》,收视率节节升高。\n"},{"id":123,"href":"/zh/docs/culture/%E6%81%B0%E5%90%8C%E5%AD%A6%E5%B0%91%E5%B9%B4/%E7%BB%8F%E5%85%B8%E8%AF%AD%E5%BD%95/","title":"经典语录","section":"恰同学少年","content":" 经典语录 # 衡山西,岳麓东,城南讲学峙其中。人可铸,金可熔,丽泽绍高风。多材自昔夸熊封。男儿努力蔚为万夫雄。\n天欲使其灭亡,必先使其疯狂 。\n人,之所以为人,正是因为人有理想,有信念,懂得崇高与纯洁的意义。假如眼中只有利益与私欲,那人和只会满足于物欲的动物,又有何分别呢?林文忠公有言:壁立千仞,无欲则刚。我若相信崇高,崇高自与我同在!而区区人言冷暖,物欲得失,与之相比,又渺小得何值一题.\n耻辱啊!耻辱!\n我泱泱大国,巍巍中华,竟成了诸般列强眼中的蛮荒未开化之地。\n耻辱啊!我四万万同胞竟成了任其宰割的鱼肉。\n人,不可不知耻。耻,有个人之耻,国家之耻。德守不坚,学识愚昧,身体衰弱,遭人白眼,乃个人之耻。纲纪扫地,主权外移,疆土日蹙,奴颜卑膝,乃国家之耻。我四万万同胞,如果人人为人所耻,则国家必为人所耻,一个国家被人耻笑,那么个人也将成为被别人耻笑的把柄。支那之耻,无有个人与国家之分,此乃我中华全体之奇耻大辱!\n今日之日本,处心积虑,虎视眈眈,视我中华为其囊中之物,大有灭我而朝食之想,已远非一日。今次,二十一条的强加于我,是欲将我中华亡国灭种的野心赤裸裸地表现。而袁世凯政府呢,曲意承欢,卑躬屈膝,卖国求荣,他直欲将我大好河山,拱手让于日寇,此等卖国行径如我国人仍浑浑噩噩,仍然任其为之,中华灭亡,迫在眉睫!!!\n夷狄虎视,国之将亡,多少国人痛心疾首,多少国人惶惶不安呢!是啊,大难来临了,国家要亡了,这样的灾难什么时候才是尽头,老天爷为什么不开开眼,劈死这些贪婪的强盗。这些抱怨,这些呼号,我们听过无数回,也说过无数回,可抱怨有什么用呢?我们恨这些强盗,恨得牙痒痒的。可是恨,救不了中国!\n大家都知道,南满铁路,东蒙铁路,都归于日本人之手,山东权益也归于日本人之手。要旅顺,要大连,整个长江流域所有的矿产要归日本来开采,一国之政治军事财经各项都要请日本人担任顾问,所有的武器要跟日本去买,就连我中国的警察都要跟日本来合作,这还能算是一个主权国家吗?这究竟是为什么?为什么局势会这样?国家为什么会落到了如此的地步?\n有人说,是因为国势积弱,无力维护自己的利益;有人说,是因为袁世凯政府太腐败,在列强面前,只知一味退让;还有人说,是因为国人太冷漠,仁人志士的呼号像一道道警钟,却难以唤醒他们麻木的心灵。我们坐在这里,痛斥列强,痛斥一切让中国落后挨打受欺负的人和事的时候,你的心中有没有想过,我们每一个中国人应该为国家的落后承担些什么样的责任?应该为这个民族的强大和兴盛担负起什么样的义务?天下兴亡,匹夫有责。这个匹夫不是指除你之外的别人,而是首先应该包括你自己。我们都希望国家强大,但是我要在这里告戒大家一句:不能光有恨!我们要学会将仇恨埋在心底,把悲愤化为动力,我们要拿出十倍的精神,百倍的努力,卧薪尝胆,发奋图强,振兴中华,做得比任何人更好,更出色,这才是每一个中国人应尽的职责。国家之广设学校,所为何事?我们青年置身于学校,又所为何来?正因为一国之希望,在于青年;一国之未来,要由青年来担当。当此国难之际,我青年学子,责有悠归,更肩负着为国家储备实力的重任。\nTable of Contents\n封 面 版 权 简 介 作 者 第一章 我叫毛泽东 第二章 免费招生 第三章 论小学教育 第四章 经世致用 第五章 欲栽大木柱长天 第六章 嘤其鸣矣 第七章 修学储能 第八章 俭朴为修身之本 第九章 袁门立雨 第十章 世间大才少通才 第十一章 过年 第十二章 二十八画生征友启事 第十三章 可怜天下父母心 第十四章 纳于大麓 烈风骤雨弗迷 第十五章 五月七日 民国奇耻 第十六章 感国家之多难 誓九死以不移 第十七章 新任校长 第十八章 易永畦之死 第十九章 驱张事件 第二十章 君子有所不为亦必有所为 第二十一章 逆书大案 第二十二章 文明其精神 野蛮其体魄 第二十三章 到中流击水 第二十四章 书生练兵 第二十五章 学生人物互选 第二十六章 汗漫九垓 第二十七章 工人夜学 第二十八章 梦醒时分 第二十九章 男儿蔚为万夫雄 经典语录 "},{"id":124,"href":"/zh/docs/culture/%E6%81%B0%E5%90%8C%E5%AD%A6%E5%B0%91%E5%B9%B4/%E7%AC%AC1%E7%AB%A0-%E7%AC%AC5%E7%AB%A0/","title":"第1章-第5章","section":"恰同学少年","content":" 第一章 我叫毛泽东 # “人可铸,\n金可熔,\n丽泽绍高风\u0026hellip;\u0026hellip;\n多才自昔夸熊封,\n男儿努力蔚为万夫雄!”\n一 # 1913年3月,这一天清晨,长沙城里一阵微雨才过,空气中便荡满了新叶抽芽的清香和浓烈的花香,透亮的阳光掠进湖南省公立第一师范的院子里,照得几树梧桐新发的鹅黄色嫩叶上的雨滴晶莹剔透,院墙外一树桃花含满雨水次第绽放,红如胭脂,艳如流霞。\n方维夏匆匆穿过梧桐的绿阴,步子轻快有力,清新的空气令他精神不由一振。这位第一师范的学监主任已然年近四十,背微有些曲,一直性情内敛,举止平和。但经历了1911年那一场旷日持久的血雨腥风之后,他和大多数狂热的年轻人一样没有了分别,都为新生的中华民国所激励和鼓舞,就像这春天一样忽然从寒冬里迸发出了无限生机,充满了无穷活力。\n今天是长沙市商会陶会长到校捐资的日子 ,这位陶会长是长沙首富,向来乐善好施,尤其看重教育,被称作湖南教育界的财神,每到捐资的时候长沙各校都是争相逢迎,其恭敬不下于湖南的都督谭延?莅临。这一次一师数日前才新换了位校长,方维夏唯恐这位新校长不懂其中的干系,冷落了财神,因此急忙赶来提醒。\n他一脚跨进校长室,却见新校长孔昭绶在办公桌后正襟危坐,这位才从日本留学回来的法学学士约摸三十多岁年纪,剃得颇短的头发根根直立,脸上棱角分明,目光锐利,颇有行伍之气,他正端正地在一封聘书上写着字。方维夏见他戴了一顶黑呢礼帽,穿着苏绸的长衫马褂,脚下是老泰鑫的圆口新布鞋,胸前挂一块古铜怀表。在他印象里,这位新校长似乎只在上任的那天,才穿得这样正式,不觉暗自点头,看来孔昭绶对这位财神还是极重视的,他对孔昭绶说:“校长,商会的陶会长半个小时后到。”\n孔昭绶起身将聘书放进口袋,微笑道:“维夏,今天我有要事要出门,客人来了,你就代为接待吧。” 方维夏不觉一愣,忙说道:“商会陶翁每次来,历任校长都是亲自接待的……”但孔昭绶却摆了摆手说:“我今天的事,比钱重要。”说话间径直出了门,扔下方维夏在那里发呆:什么事比财神上门还重要?\n出了校门,孔昭绶租了一顶“三人抬”的小轿,只吩咐一句:“浏城桥,板仓杨宅。”便微眯上眼睛养神。沿街一线是高高低低的青砖鳞瓦小楼,深黑色的飞檐和素白色的粉壁在阳光里清亮而又明净。各色的招牌和旗幌迎风轻荡,石板街面上微雨渐干,一尘不染,空中天高云淡,往来行人安闲自在。\n孔昭绶打量着街头的悠闲,不觉想起一年多前长沙街头的那种惊惶。1911年10月(宣统三年八月)武昌起义爆发,随后焦达峰和陈作新在湖南起义,同时倾力增援武昌。但就在焦、陈抽空身边兵力增援武昌时,从邵阳赶到长沙的新军第50协(团)第二营管带梅馨乘机发动兵变,杀了焦、陈二人。因梅资历不足,派士兵一顶小轿将谭延?拥上了湖南都督的位置。其时的长沙可谓是一夜数惊,到处在杀人,到处在抢掠。同时袁世凯的军队已经攻占了汉口,大炮的火力隔江控制着革命军占领的汉阳与武昌,近在咫尺的长沙更是谣言不断,人心惶惶,连谭延?也有朝不保夕之感。随即忽然南北议和,1912年1月1日中华民国成立。\n民国建立后,谭延?开始真心实意地裁撤军队,发展经济。其时湖南建立了省议会,颁布了新刑法;兴办了大量的民营及省办的实业,修筑了第一条湖南的公路——长沙至湘潭公路;废除了清朝的田赋制度,减轻了农民的负担;还拿出经费大办教育,选派公费留学生,为湖南的建设培养人才。不到一年,湖南各业都迸发出勃勃生机。\n孔昭绶从日本政法大学留学一回来就得到了谭延?的聘任,就任第一师范校长。这些天来,他感到长沙这个千年古城一夜之间便从寒冬跨进了暖春,人们从新民国看到了民族复兴、国家强盛的希望,都以一种前所未有的热情进行建设。孔昭绶不由热血沸腾,他等这一天等得太久了,当真有一种时不我待之感。\n轿夫们穿着草鞋的脚拐进一条青石板的小巷。这时忽然传来一阵喧闹的鼓乐声,前方的小巷被挤得水泄不通。孔昭绶怔了一怔,看时,前方不远处一支仪仗队,开路的24人全套西洋军乐队奏着军乐,鼓乐嘹亮,后面紧跟着48名法式盛装、绶带肩章、刺刀闪亮的仪仗兵,军容耀眼,步伐整齐,吸引一路的行人纷纷围观,小孩子们更是跑前跑后。领队的那人孔昭绶再熟不过,正是省教育司的督学纪墨鸿。孔昭绶不觉发呆,这分明是湖南都督府专门迎奉贵客的仪仗队,怎么到了这里?又是什么人要教育司的督学亲自出马?\n小巷太窄,围观的人却越聚越多,孔昭绶的轿子只得跟着仪仗队后慢慢地走。一时大队人马迤逦行来,终于在一间大宅子前停下,看着墙上挂着的“板仓杨宅”的牌子,孔昭绶不由脸色一变,暗想:不会这么巧吧?\n这时纪墨鸿翻身下马,轻轻地扣了扣大门,只听大门“吱呀”一声开了,走出个中年男子来,穿长衫,中等身材,面容丰润,目光柔和,举止沉稳。背后却藏着一个十二三岁的少女,梳两个小辫子,脸如满月,睁大了一双漆黑的眼睛伸出头好奇地打量着。\n“立——正!”随着一声威严的军令骤然在门口响起,几十双锃亮的军靴轰然踩得地上尘土飞扬,一声令下,仪仗队的士兵同时枪下肩,向那中年男子行了一个标准的军礼,随即八面军鼓震耳欲聋地响起来。\n纪墨鸿把手一抬,军鼓便戛然而止,他向那中年男子深深鞠了一躬,朗声道:“卑职省教育司督学纪墨鸿,奉湖南都督谭延?大帅令,特来拜访板仓先生。”没等那人开口,纪墨鸿已经向后一招手:“呈上来!”\n一时鼓声和军乐又骤然大作。两名仪仗兵托着一只锦缎衬底的盘子正步上前,盘中是一封大红烫金、足有一尺见方的聘书。纪墨鸿双手捧起聘书,呈到那人面前:“谭大帅素仰先生风格高古,学贯中西,今林泉隐逸,是为我湘省厥才之失。兹特命卑职率都督府仪仗队,礼聘先生俯就湖南省教育司司长。这是都督大人的亲笔聘书,伏请先生屈尊。”四周人群中顿时发出惊叹之声,目光齐齐投在那张聘书上。\n孔昭绶见状,不由下意识地摸了摸自己怀里的聘书,他显然有些措手不及,只睁大了眼看着那中年男子。面对如此排场,那中年人却像是一个偶尔经过的过客。他并不去接聘书,只是淡淡说道:“杨某久居国外,于国内情形素无了解,更兼毫无行政才能,实在不是做官的料子。烦纪先生转告谭帅,就说他的好意我领了,请他见谅。”\n那人的态度让众人都吃了一惊,纪墨鸿尴尬地捧着那份聘书,看着他笑道:“大帅思贤若渴,一片赤诚,几次三番求到先生门下,先生总得给大帅一个面子吧!”\n“好了,该说的话,我也说过了。杨某区区闲云野鹤一书生,只想关起门来教几个学生读几句书,谭帅也是三湘名儒,想必能体会杨某这点书呆子想法。不送了。”说完这番话,这人转身牵着那少女进了院子,反手掩上了院门。\n纪墨鸿不觉呆在那里,仿佛泥塑木雕,半晌才沮丧上马而去,一路偃旗息鼓。孔昭绶不觉脸上露出一丝微笑。\n孔昭绶下了轿,走到大门前,正要伸手叩门,却见那门是虚掩的。他轻轻推开,里面是一个小院落,三面房间,一面院墙大门,正中一个小天井到处植满花木,阳光透进来,一片葱茏,花架子上十数盆兰花才经新雨,长长短短的绿叶舒展开来,几朵素白的春兰悄然绽放,清香满院。\n只见那中年男子手里拿着个洒水壶,悠闲地在那里浇水,少女也提起一个水壶,边学着父亲的样子洒水,边歪着脖子问:“爸爸,他们是来请你去当官的吧?为什么你不当官,当官不好吗?”\n这人看看女儿,又看看眼前的兰花,说:“当官嘛,倒也没什么不好,不过是有人合适当官,有人不合适。就好像花吧,一种跟另一种也不一样啊,你比方牡丹,是富贵花,像爸爸和开慧种的兰花呢……”\n少女抢过话头说:“我知道,我知道,是君子花。”“对喽。你想若兰花变得像牡丹一样一身富贵气,那兰花还是兰花吗?”那人笑了起来。不等少女答话,院门口忽然传来了一个声音,“恐怕不是。”\n那人诧异地回头,看到孔昭绶正站在门前,一时间,他简直有些不敢相信自己的眼睛,“昭绶兄?” 孔昭绶也是快步上前:“昌济兄!”\n“哈哈哈哈,真是没想到,没想到啊……”这人惊喜地说着,迎上去握住孔昭绶的手,二人相视大笑。这人名叫杨昌济,长沙人。又名怀中,字华生,是个虔诚的佛教徒。早年就读城南、岳麓书院,研究宋明理学。1903年春到1913年,先后在日本弘文学院、东京高等师范学校及英国爱伯汀大学留学,并赴德国考察。对西方教育、哲学和伦理学之历史与现状、理论与实践均有深入研究,乃是湖南有名的大学者。方才回国不久。那少女是他的小女儿,名叫杨开慧,今年刚刚12岁。\n二人一同到书房就坐,杨昌济兀自还在久别的激动中:“东京一别,一晃这都几年了,好几回做梦,我还梦见昭绶兄在法政大学演讲的情景呢——‘当今之中国,唯有驱除满清鞑虏,建立共和之民国,方为民族生存之唯一方法!’那是何等的慷慨激昂!言犹在耳,言犹在耳啊!”\n“我也一直记挂着昌济兄啊。从日本回来以后,我还托人打听过你的消息,听说你去了英国留学,后来又去了德国和瑞士……”尽管久别重逢,想说的话很多,但孔昭绶是个急性子,略略寒暄,便开门见山:“哎,闲话少叙,今天我可是无事不登三宝殿哦。”说着,从口袋里掏出那份聘书,递到杨昌济面前。\n杨昌济不禁有些疑惑,打开聘书,只见写着:“今敦请怀中杨老先生为本校修身及伦理教员,每周授课四时,月敬送修金大洋叁拾圆正。此约湖南省公立第一师范学校校长孔昭绶。”\n“怎么,奇怪啊?当此民国初创、百废待兴之际,什么是强国之本?什么是当务之急?教育是强国之本,教育是当务之急!”迎着杨昌济的目光,孔昭绶站起身,声音大了起来,“一个国家,一个民族,不把教育二字放在首位,何谈国家之发展,何谈民族之未来?开民智,兴教育,提高全体国民的素质,这,才是民族生存之根本,中华强盛之源泉啊!”\n杨昌济连连点头:“嗯,这一点,你我在日本的时候就有共识。”孔昭绶继续说道:“而教育要办好,首先就得办好师范,得有好的老师,才有好的教育啊。这回谭畏公招我任一师校长,我也想过了,头一步就得聘请一批德才兼备的优秀教员,扫除旧学校那股酸腐之气,为我湖湘之教育开出一个崭新局面。昌济兄,你的学问,三湘学界谁不景仰,我又怎能放过你这位板仓先生?”\n迎着孔昭绶殷切的目光,杨昌济却明显地露出了为难的神情。孔昭绶不禁笑了:“怎么,谭畏公的官你不做,我那儿的庙你也嫌小了?”\n“昭绶兄,你开了口,我本应该义不容辞,不过这一次,只怕你是来晚了。”杨昌济从书桌抽屉里取出一封聘书,递给孔昭绶:“这是周南女中昨天送来的聘书,聘我去教国文,我已经答应了。”\n这个变故显然大出孔昭绶的意料,看看聘书上的日期,还真是昨天的落款,失望之中,他只得起身告辞,却仍不甘心:“‘得天下英才而教之!’昌济兄,我记得这可是你毕生的理想啊。”\n杨昌济道:“只可惜英才难求啊。”\n“你怎么知道我那儿就没有英才?我第一师范自宋代城南书院发祥,千年以降,哪一代不是人才济济?且不说张南轩、曾国藩这些历史人物,就是眼下,缔造共和的民国第一人黄克强先生,那不也是我一师的毕业生吗?”\n“可是周南那边……”\n孔昭绶赶紧趁热打铁:“不就是一点国文吗?我只要你来兼课,耽误不了你多少时间的。昌济兄,以你的学问,只要肯来屈尊,未必不能在一师学子之中,造就一批栋梁之材!怎么样,还是答应我吧?”\n迎着孔昭绶期待的目光,杨昌济沉吟了片刻,只好说道:“这样吧,你给我几天时间,我想办法安排一下,要是安排得过来,我就来给你兼这份差。”\n得了他这句话,孔昭绶才算是放心出了杨宅。临上轿,还回头郑重叮嘱了一句:“昌济兄,可别敷衍我哦。”\n送走孔昭绶,父女二人回了书房,开慧一路还在问:“爸爸,孔叔叔他们学校的学生真的很好吗?”杨昌济道:“现在在校的学生嘛,倒没听说什么特别出类拔萃的,新学生呢,又还没招,好不好现在怎么知道?”\n“可是孔叔叔不是说他们学校出了好多人才吗?还有个缔造民国的黄克强先生,那是谁呀?”\n杨昌济告诉女儿:“黄克强,就是黄兴,也是爸爸在日本的时候的同学。”\n“黄兴大元帅?他也是孔叔叔他们学校的学生?”开慧听得几乎跳了起来,拉住父亲的手臂,“哇!爸爸,那你赶紧去呀,你也去教几个黄兴那样的大英雄出来,到时候,民国的大总统、大元帅都是你的学生,那多带劲!”\n“还几个?哈哈……”杨昌济不禁一笑,“真要遇上一个,就已经是佛祖显灵了。可惜爸爸善缘还修得不够,遇不上哦。”开慧嘟着小嘴问:“为什么?”\n杨昌济拍了拍女儿的头,笑着回答:“你还小,不明白这个道理。这个世上,最难求的,就是人才,且不说黄兴那样惊天动地的英雄人物,但凡能遇上一个可造之才,能教出一个于国于民还有些作用的学生,像爸爸这样的教书匠,一辈子,也就知足了。”\n开慧甩开父亲的手臂,偏着头,很认真地对父亲说:“我就不信!爸爸,你以后一定会教出一个比黄兴元帅还厉害、还有本事的学生!”杨昌济笑道:“你算得这么准?”开慧起劲地点点头:“不信我们打赌。”\n杨昌济笑了,望着书桌上的地球仪和那尊他朝夕敬奉的白玉观音像,脸上的笑容却渐渐凝结了起来,心里想:如此人才,却不知锥藏何处?\n二 # 陶会长那辆镶着银色花纹的豪华马车才停在一师门口,一个十六七岁的少女便跳下车来。这少女面目清秀,身材高挑,穿一身淡雅学生裙,虽然看上去像个内秀的古典美女,但她纤细而灵巧的双脚,流光溢彩的双眼却泄露了充满渴望的少女情怀。\n“斯咏!不要乱跑。” 陶会长在车上叫道。“爸,我去看看,这个学校好漂亮。”少女说话间直进了校门。陶会长尴尬地向前来迎接的方维夏一笑,说:“小女陶斯咏,小孩子不懂规矩,让先生见笑了。” 方维夏也一笑说:“不要紧。”然后迎着陶会长进了校长室。\n陶斯咏一个人在学校里缓缓而行。第一师范前身为南宋绍兴三十一年(公元1161年)张浚、张拭父子创建的城南书院。乾道三年,朱熹来访时,住此两月。书院遂因朱张会讲而名传天下,与岳麓书院齐名。书院建在妙高峰上。妙高峰为长沙城区的最高峰,号称长沙城南“第一名胜”。学院前临湘江,与岳麓书院隔水相望。清末书院被毁,一师便在原址上重建,建筑风格仿照日本青山师范学校,以黑白线条为主,等角三角形的深黑色瓦顶,映衬素白的拱形顶百叶窗,墨蓝色方形墙面,整个建筑群是典型欧式风格,典雅庄重。但连接建筑的回廊迂回曲折,开出一个独立的庭院,或有小亭,或有古井,独具东方韵味。\n此时阳光越发明净,院子里几株老槐抽出新条,一树垂柳如烟一般,满院草色苍然,学生们都在上课,回廊里静寂无声,暖风轻拂,一只蝴蝶翩然而飞。斯咏穿过回廊,在一间一间的教室窗外探过头去,看里面都是男生,不觉撇了撇薄薄的嘴唇。\n“衡山西,岳麓东,城南讲学峙其中……”一阵悠扬的歌声和钢琴声忽然传来,斯咏不自觉地寻声走过一个回廊,却见不远处繁花绿树之中,一个穿中式长衫、金发碧眼的老师在那里弹着钢琴,当他那双白种人修长的手滑过键盘时,就有音符如行云流水般从他灵巧的指端泻落,这声音,穿透了斯咏的身心。\n几十个一师的学生一色的白色校服,朝气蓬勃,手里捧着歌谱,嘴里唱着新学的校歌,眼睛却被回廊前斯咏那双灵动的大眼睛所牵引,不能收回到歌谱上,歌声也没有刚才响亮了。斯咏迎着满院男生们诧异的目光,调皮地一笑。\n“斯咏!”陶会长不知什么时候已经站在走廊一头的楼梯口,皱着眉头,尽量压低嗓门叫自己的宝贝女儿,“像什么样子?还不过来?”\n陶斯咏又回看了两眼,才跑了开去。陶会长责怪说:“这是男校!女孩家东跑西跑,成何体统?”看看身边的方维夏,又道:“小女失礼,让方先生见笑了。”\n方维夏倒不在意: “哪里。陶翁代表商会慷慨解囊,捐资助学,我们欢迎还来不及呢,小姐参观一下有什么关系?倒是孔校长有事外出,未能亲迎陶翁,失礼之处,还望见谅。”\n办完了捐款的事,陶会长辞别方维夏,出了校门,正要上车,却不见斯咏跟上来,回头一看,斯咏还站在教学楼的台阶下,一副恋恋不舍的样子。陶会长催道:“斯咏,你到底走不走?”\n“急什么嘛?爸,你看这儿好美啊,那么大的树,还有那么多花,教室也那么漂亮……”斯咏一面走一面回头说,“爸,要是我能到这儿来读书该多好?”\n陶会长被女儿的话逗笑了:“胡说八道!哪有女孩子读男校的道理?”\n“可女的为什么就不能读嘛?不公平!”\n“不是给你办好了上周南女中吗?”\n“可是这儿比周南漂亮嘛!”\n陶会长望着这个被他娇宠惯了的女儿,忍不住摇了摇头:“你个小脑瓜子一天到晚想些什么?一点正经都没有!还不走?”斯咏噘着嘴,恋恋不舍地又回头望了一眼,这才上了车。\n车行到南门口,斯咏素来爱逛开在这里的观止轩书店,便先下了车。\n她来到书店前,习惯性地看了看门口推介新书的广告牌,却见上面最醒目的一行写着:“板仓杨昌济先生新作《达化斋读书录》,每册大洋一元二角”,当即抬脚进了书店。\n书店柜台前的店伙计一只手撑着下巴,一只手百无聊赖地拨着面前的算盘珠子,眼睛却时不时地盯住书柜下露出的一双破布鞋——这个家伙从一大早就来了,蹲在那里看书,一动不动,已经白看了一上午了。店伙计心中早已有些不耐烦,斯咏正好走了进来:“请问有杨昌济先生的《达化斋读书录》吗?”\n“有,还剩最后一本。”伙计满脸堆笑, “小姐,您算来巧了。我这就给您拿去?”一时在书架上四处乱翻,却没有找到,正纳闷时,一眼瞟见破布鞋上遮着的正是那本《达化斋读书录》,叫道:“这位先生,对不起,打搅一下。”\n那人全没有听见他的话,只顾埋头看书,伙计拍拍他的肩膀,大声说“先生!这位先生!”“啊?”那人吓了一跳,问道:“干什么?”伙计指指外面,说:“对不起,您这本书有人要买。”\n“哦,你另外拿本给他吧。”这人又埋头继续看书。伙计忍无可忍,伸手盖住了书,说:“哎哎哎哎,别看了别看了。” “怎么了?”这人站了起来。\n店伙计瞪了他一眼, “这是最后一本,别人买了!”声音惊动了柜台前的斯咏,她向这边望过来,只见一个青年高大的背影,肩上打了一大块补丁,说一口略带湘潭腔的长沙话,“你等我看完嘛……”“我等,人家顾客不能等,你这不让我为难吗?”\n那青年忙说好话:“那……那我看完这一章,就两页了,看完这两页就给他……”“哎呀,拿来吧,你!”店伙计实在懒得跟他纠缠下去,一把将书夺了过来,白了他一眼,换上笑脸走向斯咏,“小姐,对不起对不起,劳您久等了。”\n那青年悻悻地走了出来,斯咏这才发现他身材极是高大,头发剃成短短的板寸,眉目清秀,目光却炯然有神,身上的短衫满是补丁,一双布鞋破开了个大口。他淡淡地扫了斯咏一眼,向门外走去。斯咏怔了一怔,没有接书说:“没关系,人家在看嘛。”店伙计指了指那青年:“您说那位呀?嗨,都蹲那儿半天了,从早上一直到现在,光知道白看!买不起就买不起吧,他还霸着不让别人买,真是!”\n这青年听见这话,猛然转到柜台前,一把将书从伙计手里抢了过来,重重拍在柜台上:“这本书我买了!”斯咏不禁一愣,却正碰上他示威似的目光。店伙计也愣住了,抱怨道:“人家都买了,你这不是抬杠吗?”\n那青年也不含糊,他看看书后的定价,回敬道:“先来后到嘛。我先来,凭什么不让我买?不就一块二吗?”他一手按着书,一手伸进口袋,颇有一副谁怕谁的傲气。然而那伸进口袋的手却慢慢僵住了,脸上的表情也跟着僵住了:他左掏右掏,掏来掏去,不过掏出了两三个铜板,一腔气势顿时化作尴尬。\n伙计脸上浮起了一丝嘲笑:“哟,您不是没带钱吧?”感受到身边斯咏的目光,青年的脸顿时涨红了。伙计却还在继续奚落他:“要是您手头不方便,那我只好卖给这位小姐了。”他说着话,使劲从青年的手掌下抽出书,放在了斯咏面前。\n青年愣了一愣,转身出了书店。斯咏付了钱,拿着书缓缓沿街而行,这时她突然忍不住笑了:那位青年人就走在前面不远处的街边上,似乎脚被什么东西磕了一下,他发泄地一脚踢去,却将鞋踢飞了,他赶紧单脚跳着去捡那只飞出老远的鞋。\n这个样子真是太滑稽了。斯咏看着他跳着移到一棵树旁,正扶住树穿鞋,那只鞋鞋帮被踢开了个更大的口子。斯咏的心隐隐地动了一下,她忽然加快了脚步,走到青年身后,将那本《达化斋读书录》递到了他面前,说:“这本书送给你。”\n青年顿时愣住了,看了看斯咏,一时接也不是,不接也不是。斯咏把书往他手里一塞:“你不是没看完吗?拿着吧。”青年这才反应过来,赶紧边手忙脚乱地掏口袋边对斯咏说:“那,我……我给你钱。”手一伸进口袋,才想起自己根本没有钱,不由得越发尴尬了。\n斯咏道:“我说了送给你。哎,这可是大街上啊,你不会拒绝一位女士的好意吧?”青年只得赶紧接过书,喃喃地回应:“那,算我借你的,我回头还给你。”\n斯咏一笑,转身就走。青年一手举着书,一手提着破布鞋,高声问:“哎,你叫什么?我怎么找你啊?”斯咏回头说:“不用了,书你留着吧。再见。”叫了一辆过路的黄包车,径直上了车。青年想追,但少了一只鞋,无法迈开步子,他单脚跳着,冲斯咏的背影叫道:“哎,哎——那你有空来找我吧,我就住前面湘乡会馆,我叫——”这时黄包车已经跑出老远,显然听不到他的喊声了。青年看看那本书,再看看破布鞋,突然冲着那只破布鞋裂开的大洞喊道:“我叫毛、泽、东!”\n三 # 拿着那本《达化斋读书录》,毛泽东用兜里剩的铜板买了个烧饼,边啃边向湘乡会馆方向走来。\n湘乡会馆所在的巷子口,照例摆了个小小的臭豆腐摊子,摆摊的老人虽然不过五十来岁年纪,看上去却苍老得像六十好几的老头,这老头叫刘三爹,毛泽东一向喜欢吃臭豆腐,早和他混得烂熟。\n毛泽东一路走来,远远便闻到了那股臭味,摸摸口袋,却只能叹了口气。刚要走进巷子,忽见那小摊的破木桌旁坐着两个年轻人,一个十七八岁,长衫笔挺,容貌雅俊,收拾得一丝不苟;一个十五六岁,对襟短衫,还是个愣头小子。这时刘三爹正把臭豆腐端到二人面前,那穿长衫的顿时皱起眉头,掩着鼻子:“端过去端过去,他的。” 刘三爹赶紧把臭豆腐移到那愣头小子面前,侧头问那长衫少年:“这位少爷,您不来碗?”\n长衫少年掩着鼻子使劲地摇头。毛泽东见了他的模样,当时便笑了,悄悄走了过来。这边那愣头小子把脸凑近热气腾腾的臭豆腐,深吸一口气,盯着长衫少年问:“哥,你真不吃?”\n长衫少年头一摇:“臭烘烘的,吃什么不好?吃这个。”“闻着臭,吃着香!你就不懂。”愣头小子说着从筷笼里抄起一双筷子就要动手,长衫少年赶紧拦住他,从衣兜里掏出了一块雪白的手帕。愣头小子看他擦着筷子,摇头说:“就你讲究多!”长衫少年瞪了他一眼,反反复复狠擦了几遍,看看手帕上并无污渍,这才把筷子塞给了弟弟。\n毛泽东走到二人身后,忽然一拍那长衫少年,那少年吃了一惊,却听对面的弟弟早抬起头来,惊喜地叫道:“润之哥。”这二人正是毛泽东的好友,长衫少年名叫萧子升,愣头小子名叫萧三,两人是两兄弟,都是毛泽东两年前在湘乡东山学堂时的同窗。看着二人,毛泽东还没开口,肚子就先发出一阵“咕噜噜”的声音。萧子升忙拉毛泽东坐下,要了一碗臭豆腐。毛泽东风卷残云吃得干干净净,放下空碗,用手背一擦嘴,这才长长地透了一口气。\n萧子升打趣道:“一箪食,一瓢饮,润之兄饱乎?不饱乎?”“饱也,饱也。还不饱我不成饭桶了?”毛泽东拍着肚皮,有些不好意思地解释,“不瞒子升兄,我呀,五天没吃过一餐饱饭了,天天一个烧饼打发,那烧饼做得又小,吃下去跟没吃一样。”\n“怎么,口袋又布贴布了?”萧子升说着,掏出钱袋,“哗啦”一声,把钱通通倒在桌上,里面是几块银元和一堆铜板,他把钱分成三堆,也不数,将其中一堆推到毛泽东面前:“拿着吧。”\n毛泽东也不客气,收了钱:“等我家寄了钱,我再连以前的一起还给你。”\n“等你家寄钱?等你家寄钱你还不饿死七八回了?我说润之,你这样下去也不是个办法,总不能老跟家里犟下去,还是要跟伯父说清楚才行……”萧子升还在说着,毛泽东打断了他的话,说:“哎呀,你不明白的。我们家老倌子,什么都好商量,就读书两个字提不得!”\n三个人离开臭豆腐摊,回了湘乡会馆,进了萧家兄弟租住的房间,萧子升说道:“我说润之,你这样下去不行,才到长沙一两年,学校读了无数个,没一个满意的,也怨不得伯父生气。你到底打算上哪所学校?”\n这个话题正触到了毛泽东的难处,他呆了一呆,摸摸后脑勺说:“那些学校是不行嘛,读不下去,我有什么办法?正好,最近有没有什么新消息呀?”萧三笑说:“润之哥,我们今天正想去跟你说这件事情的,干脆,跟我们一起考北大算了。”“北大?”毛泽东眼睛一亮,“北大今年对湖南招生了?”萧三手舞足蹈地说:“对呀,招生广告都出来了,全国都可以报名,我和我哥都打算去考呢。”\n“真的?哎呀那太好了!我去年就想考北大,兵荒马乱的没去成。哎,它什么时候招生?能不能在长沙考?”毛泽东大喜过望,一口气提了一连串的问题。萧子升笑道:“哪有在长沙考的道理?当然得去北京,就下个月。我和萧三正在想办法筹钱呢。润之,一起去吧。三个人一块,到北京还能省点住宿费呢。”\n一提起钱,毛泽东口气便虚了:“那,大概要好多钱啊?”\n“一个人总要150块大洋吧。”\n毛泽东听得眼睛都瞪圆了,叫了起来:“150?!”\n“你瞪着我干嘛?”萧子升看到毛泽东的这副样子,索性扳起手指给他算账,“你想呀,这么远的路,食宿、路费,两个月备考,再加上头一年的学费、杂费、生活费各项,150块已经是紧打紧算了。”\n毛泽东这下傻了眼:“我的个天,150!剁了我这身肉,不晓得卖得到15块钱不?”\n“我现在也是天天愁钱。两兄弟这一下就是三四百块,家父这一段身体又不好,家境也不如从前,可除了跟家里伸手呢,我又实在想不出别的办法。”萧子升话锋一转,对毛泽东说,“其实说起来,你比我们强多了。”\n“我比你们强?我都穷得饿饭了!”\n“好歹你家里并不穷嘛,真要想办法,这个钱未必拿不出来。”萧子升道。萧三也点头:“是啊,润之哥,你就跟你爸说说好话嘛,你要去北大,肯定能考取,这么好的机会,错过就可惜了。”\n“机会我当然不想放过,可我们家老倌子,哎呀……”毛泽东想想还是摇了摇头。\n“你没试过怎么知道他一定不答应你?以前你读书,他不是也供过你吗?你跟他说清楚,全中国就一个北大,最好的大学。父望子成龙嘛,他也盼着你前途无量。”\n“对对对,你把读北大的好处说他个天花乱坠,万一说动了伯父,不就解决了吗?”\n听着萧氏兄弟的劝说,毛泽东嘴里沉默不语,心底里也不觉有些动了。\n第二章 免费招生 # 湖南省公立第一师范的招生广告,\n末尾 “ 免收学费,\n免费膳宿,\n另发津贴 ”\n一行字极为醒目\n一 # 残阳缓缓从韶山的峰峦间隐去,一山的苍翠都被抹上了胭脂。沿山而下,掩映的绿树翠竹中是一栋十三间的泥砖青瓦房,房前一口池塘,塘边春草初生,塘内小荷露出尖角。远处的山野间油菜花开得正旺,一片金黄,夹杂着绿树和新放的桃花梨花,四处炊烟,袅袅而起。\n屋场上,一个中年妇女正拿着一个小竹簸在撒谷喂鸡,随着她“啰啰啰”的叫声,十几只鸡争先恐后地抢着谷粒。不远处,一个戴着瓜皮帽穿着短褂的老人坐在板凳上,闷头敲打着一张犁。这时忽然一个惊喜的声音传来,“娘!娘!大哥回来了,大哥回来了!” 一个十三四岁的少年打着赤脚,边喊边直跑过来。\n中年妇女诧异地抬起头来,正看见少年身后,毛泽东背着蓝布行李包,拿着雨伞,大步奔来,老远便喊道:“娘——娘——”这妇女正是毛泽东的母亲文七妹,两行泪珠立时从她的眼中夺眶而出,她喃喃说道:“石三伢子?我的石三伢子啊……”手一抖,小竹簸顿时掉在了地上。鸡群蜂拥了上来,争抢着谷粒。\n“去去去,去去去……”正在修犁的老人赶紧抢上前,手忙脚乱赶开鸡,捡起竹簸,放到了一旁的竹架子上。这时毛泽东放下手里的行李,向他叫道:“爹。”毛贻昌看了他一眼,说:“你也晓得回来了。”仍自顾去修犁。文七妹急忙擦去眼泪,说道:“快,泽民,帮你大哥把行李拿进去。”那少年答应着,毛泽东忙说:“不用了。”拿起行李便进了屋。\n一家人吃过晚饭,文七妹把两个小孩子毛泽覃、毛泽建打发去睡觉了,和毛泽东坐在灶房门口。一个缝补着毛泽东那只破了的布鞋,一个剥着豆,都不时地悄悄偷窥着毛贻昌的表情。\n房里“噼啪”燃烧着的火塘上,吊着一口老式铜吊壶。毛贻昌就挨近火塘坐在条凳上,把旱烟锅子凑近火苗,点着了烟丝,跳动的火苗照亮了他满是皱纹的脸,他长长地喷出一口烟,紧锁的眉头下,目光固执。半晌终于开口问:“你讲的那个什么什么大学?”\n毛泽东小心翼翼地补充说:“北京大学,就是以前的京师大学堂。”毛贻昌猛地把烟锅子往条凳上一磕,“我不管你什么金师大学堂、银师大学堂,一句话,什么学堂你都莫打主意! 150块大洋?亏你讲得出口!你当这个家里有座金山,容得你一顿败家子败哒!”\n毛泽东低头看着父亲,说:“我是读书,又不是浪费。”毛贻昌一听更是火冒三丈,用烟锅子指着儿子说:“你还好意思提读书!你读的什么鬼书?哼!”文七妹忙说:“哎呀,你好点讲嘛,一开口就发脾气,三伢子这才进门……”\n毛贻昌瞪了她一眼,“你少啰嗦!都是你把他惯坏了!”文七妹赶紧不做声了,埋头继续补手里的鞋。\n毛贻昌却越说越生气,“早听了我的他不会是这个样子,你自己看看你自己看看,二十岁的人了,文文不得武武不得,一天到晚东游西逛,只晓得花钱就不晓得赚钱!都是你这个做娘的从小惯的……”\n毛泽东抬起了头:“爹!你骂我就骂我,骂我娘干什么?”毛贻昌眼睛一瞪:“这个家还是老子当家,老子骂不得啊?还顶嘴!你自己算一下,这些年你读书读书都读出了什么名堂?东山学堂你呆不住要去省城,老子让你去了,你呢?读不得几天你退学,什么不好当你去当兵!”\n毛泽东嘟囔道:“那你以前不也当过兵……”毛贻昌却一句话把儿子堵了回去:“我当兵是没饭吃!你也没饭吃啊?你有吃有喝有老子供祖宗一样供起你,你去当兵!好铁不打钉,好男不当兵,这句话你没听过啊?”\n“我现在不是没当兵了吗?” 毛泽东缓了口气。毛贻昌不理他,又装了一锅烟丝,凑近火塘点燃,咂了一口,坐回条凳上去,这才说:“那倒是!兵你不当了,你讲要读书,结果呢?今天讲要进商业学校学做生意,我还蛮高兴,答应你,给你钱报名,你读两天讲听不懂什么英文,你要退学;明天讲你要进肥皂学校学做肥皂,我又答应你,又给你钱报名;后天你要进警察学校学当警察;大后天你要进什么法政学校学法律,当法官;再过两天一封信来你又到了省一中……你自己算算,半年不到,你换了好多学堂?有哪个学堂你呆满过一个月?你读书?你读什么鬼书?你把老子当鬼哄才是真的!”\n毛泽东似乎没发现父亲的忍耐已经快到极限了,插嘴说:“那些学校是不好嘛。”毛贻昌眯起眼睛反问道:“那些都不好,这个就好了?”毛泽东忙道:“这次这个不一样,这是北京大学,中国最好的大学……”\n毛贻昌劈头打断他:“你少跟我乱弹琴!哪一个学校你开始不讲好?哪一个学校你又读得下去?长沙读遍了,不好玩了,你又想起去北京,换个大地方玩是吧?你啊,老子是看透了,从今往后,再莫跟我提什么读书的事!”\n一直埋头补鞋的文七妹忍不住又抬起头说:“顺生,三伢子想读书,又不是什么坏事……”毛贻昌转头厉声说:“我求哒你闭起嘴巴好不!”文七妹只得又不做声了,打量着补好的鞋,收拾着顶针、针线。\n毛贻昌回头对毛泽东说:“我告诉你,你今天回来了就莫想再走了。银田市那边天和成米店,是我的老主顾,人家给了天大的面子,愿意收你去当学徒,明天你就跟我一起去,以后老老实实在那里拜师学徒,三年学成,接老子的脚!”\n毛泽东头一扭:“我不去!”\n“你敢!我告诉你,以前我都由着你的性子,才搞得你这么没出息,这一次的事,板上钉钉,你去也得去,不去也得去!”毛贻昌用烟杆敲打着条凳,“还有,罗家的媳妇你14岁上我就给你定好了,你一拖拖到现在,你拖得起,人家女方拖不起。等你到天和成拜完师,就给我回来办喜事圆房,以后老老实实成家立业种田做生意,也省得你一天到晚胡思乱想,一世人在外面吊儿郎当!”\n毛泽东闻言,腾地站起身来,毛贻昌瞪眼喝道:“你干什么?”\n“我不要钱了,我明天就回长沙!”\n“你再讲一遍!”\n“我明天就回长沙,以后再也不回来了!”\n“反了你了?”毛贻昌抡起旱烟杆就劈了过去,毛泽东一闪,旱烟杆打在板凳上,断成了两截。毛贻昌顺手又抄起火塘边的火钳,扑了上来,骂道:“还敢顶嘴?还顶嘴?我打死你个忤逆不孝的东西……”\n他抡着火钳便是一顿乱打,毛泽东虽然东躲西闪,身上还是挨了两下。文七妹和毛泽民吓得赶紧冲上来,死死拦住毛贻昌。文七妹叫道:“哎呀,你干什么你?你放下!这是铁做的,你晓不晓得……”\n混乱中,隔壁的泽覃、泽建也被惊醒了,揉着惺忪的睡眼站到了灶房门外。恰在这时,哗啦一声,把大家都吓了一跳,原来是文七妹装针线的小竹匾被毛贻昌一火钳打翻,将里面的顶针早砸扁了。才六岁的泽建吓得“哇”的一声哭了出来,叫道:“爹——”\n毛贻昌喘着粗气,直指着儿子,“你给老子听着,滚回房去蒙起脑壳好好想清白!你要敢跑,我打脱你的腿!” 毛泽东哼了一声,却被母亲连推带劝进了卧室。毛贻昌找了把锁来,只等文七妹出来,便“咔嚓”一声锁住了房门。\n那天夜里,毛泽东躺在床上,翻来覆去,却始终无法平静下来。\n也不知过了多久,一阵轻微的响声突然从窗台传了过来,毛泽东腾地弹起,扑到窗前,看见泽民正在窗外撬着窗户。兄弟俩心有灵犀,一里一外,小心翼翼地一起用力,窗子被撬开了。毛泽民向他做了个手势,低声说:“大哥,爹睡着了,你小心点。”\n毛泽东点点头,敏捷地爬上窗户,刚把头探出窗外,却看见母亲站在窗外等着,忙叫道:“娘?”\n母子三人轻手轻脚离了家,到了村口,文七妹这才把一个蓝布包裹递到了毛泽东手中,从怀里小心地摸出一方手帕包,拿出里面的几块银元,塞了过来:“你娘也没有几个钱,这是瞒着你爹攒的,就这么多。娘这一世也没什么用,你想读那个大学,娘也帮不上你。要读书,你就找个便宜点的学堂吧。”\n毛泽东呆了一呆,接过银元,喉咙里不觉一阵哽咽,也不知说什么好。文七妹抚着儿子的脸,柔声说:“一个人在外面,要自己多保重,饭要吃饱,冷了要记得加衣服,莫太苦自己,有什么难处,就写信回来,娘帮你想办法。你爹爹也是为你好,就是性子急,你不要怪他,等过一阵子他气消了,你再写封信回来跟他认个错,就没事了,啊。”毛泽东怔怔地听着,点头说:“哎,我记住了。”\n“好了,快走吧,晚了你爹爹醒来了,又走不成了。走吧走吧。”文七妹推着儿子,眼里却红了。\n毛泽东好不容易忍住了眼泪,长吸一口气,对身旁的泽民说:“二弟,我走了,你在家里多照顾娘。”\n他刚转身走出几步,身后又传来了文七妹的叮嘱声:“三伢子,记得走大路,莫走山上的小路,晚上山上有狼。”\n毛泽东再也忍不住了,转身扑向母亲,一下子跪倒在地,哽咽着说:“娘,儿子不孝,不能守在您身边,对不起您了……”眼泪从他的眼中狂涌而出。\n文七妹搂住儿子,拍拍他的后背,催促道:“好了好了,莫哭了莫哭了,娘晓得你孝顺。我石三伢子是有出息的人,要干大事的,娘不要你守着。不哭了啊,快点走吧,听话,走吧。”毛泽东用力给母亲磕了个头,狠狠擦了一把泪,站起身就走。\n刚走出几步,他突然愣住了,前方不远处的大树下,父亲毛贻昌居然正站在大路中央。\n毛泽民和母亲都呆住了,一时都不知道怎么才好。毛泽东和父亲对视着,沉默中,两个人似乎在比试谁比谁更倔强。终于,毛贻昌低下头、背着双手,缓缓走了过来。看到父亲脸色铁青地从自己身边走过,却看也不看自己,毛泽东的鼻子忽然有些酸楚,这时一个小包裹直落在了他脚边,随即地上一阵丁当乱响,月光下洒了一地的银元,闪闪发亮。\n母子三个人面面相觑。只听见毛贻昌冷冷地说:“你娘老子的话你都听到了,那种少爷公子读的什么大学,莫怪家里不供你,自己去找个便宜学堂,再要读不进,就老实给我滚回来!”说话间他头也不回,径直向家里走去。\n看着父亲消失的方向,毛泽东蓦然心里一热。他蹲下去,伸出被父亲用火钳打得满是淤青的手,一块一块地捡着地上的银元。摸索中,他突然停住了——父亲扔给他的包裹里除了银元,居然还有一瓶跌打油!他猛然站起来,大声叫道:\n“爹,我记住了,我会读出个名堂的!”\n二 # 从湘潭韶山到长沙,约摸一百五十里水路。毛泽东坐船回到长沙,已经是第二天傍晚时分,他下船便向萧氏兄弟的住地而来。方才坐下,萧子升正想开口,萧三却抢着问道:“润之哥,上次我们说一起考北大,你决定没有?”\n“我这次来就是要跟你们说这件事情。唉,我回家去一说上北大要150块大洋,我们家老倌子就火冒三丈。哦,差点忘记了,我这次是来还钱的。不好意思,有借有还,再借不难。”毛泽东边拿出钱来,边把自己挨打,连夜逃跑的事说了一遍,末了说:“老倌子给的钱不够啊,我现在也不知怎么办好。”\n萧三哈哈大笑,说:“你们家老倌子真有意思。”萧子升却静静地听两人说话,一言不发。毛泽东问道:“你们两个怎么样了,有办法了没有?” 萧子升闻言叹了口气,从怀里拿出两样东西,一封信、一张报纸。把信递给毛泽东,神色凝重,说:“子暲也看看吧。” 萧三呆了一呆,伏在毛泽东背上看时,却是一封家书,写道:“子升、子暲吾儿,汝父昨日为汝学费一事,外出筹借款项,突发晕眩旧疾,至跌伤右足。家中近年生计本已颇不如前,岂料又生此变故?来信所言报考北大之学杂各费,恐已难以为备…… ”萧三脸色顿时变了,说:“娘什么时候来的信,哥你怎么早不说。”\n子升不理他,说:“家父都病成这样了,我们做儿子的,不能为家里分忧也就罢了,还提什么考北大?按说家里出了这样的事,我们兄弟就应该回家尽孝,不该再想什么读书的事。可家父这些年辛辛苦苦,盼的就是我们有个像样的出息,现在说不读书的话,他老人家是断不会答应的。”\n萧三张大了嘴说:“那怎么办?”子升将那张报纸推到二人面前,上面赫然是一则湖南省公立第一师范的招生广告,末尾“免收学费,免费膳宿,另发津贴”一行字极为醒目。\n毛泽东顿时明白过来,大笑说:“你想去读不要钱的师范?”\n“除此还有什么两全之策?”子升苦笑了一下,“其实师范也不错啊,又不要钱,出来又不愁没事做。再说,一师这次除了五年制的本科,还开了两年制的讲习科,我正想早点毕业做事,读两年,就能出来帮着供子暲,也不错呀。”\n萧三沉默一时,说:“哥,你读书比我强,还是我去考讲习科,你读本科吧。”“我是大哥还是你是大哥?这件事不要提了。”子升回头发现毛泽东仍拿着那份报纸出神,便问:“润之,你的学费也没凑足,有没有想过下一步怎么办?”\n毛泽东仿佛没有听见他的话,突然没头没脑冒出一句:“‘毛老师’?哎,你们觉得,‘毛老师’三个字,喊起来顺不顺口啊?”看看萧氏兄弟一副摸不着头脑的样子,毛泽东接着说:“我是说我要是去教书,往讲台上这么一站,那些学生不得喊‘毛老师好’吗?”\n“你也想考?” 子升、萧三这才明白过来,三人顿时相视大笑起来。\n三 # 湘江自南向北迤逦而来,在大西门穿城而过,将长沙城分作东西两部。自光绪三十年(1904年)长沙开埠,客货云集,大西门渡口便成了长沙最繁华的渡口。\n这一天清晨,在渡口长长的石阶旁,衣着一丝不苟的纪墨鸿坐在椅子上,一面跷着脚让人给他擦皮鞋,一面看报纸,报纸背面是醒目的“湖南省公立第一师范学校招生启事”。给他擦皮鞋的是一个十七八岁的少年,身材高挑、体形单薄。他专注地看着报纸,却忘了手里的活计。纪墨鸿显然感觉到了,他突然移开了报纸,对着小伙子吼道:“喂,你还擦不擦?”这个少年名叫蔡和森,湖南湘乡人。\n蔡和森吃了一惊,手脚麻利地忙碌着:“擦,擦,马上就好……先生,擦好了。”纪墨鸿看看擦得锃亮的鞋,站起身,掏了两个铜板递出去。蔡和森红着脸,轻声说:“我不要您的钱。先生,能不能把这份报纸给我?”\n纪墨鸿愣了一下,看着眼前这个小伙子,穿的一身学生装,虽然打着补丁却很整洁,问道:“擦鞋的还看报?你认识字吗?”蔡和森点了点头。\n纪墨鸿严肃的脸顿时笑了起来,他把报纸递给蔡和森,“拿去吧。贫而好学,穷且益坚,我最喜欢这样的年轻人了。”蔡和森连忙谢过,蹲在地上,认真地看着报纸,全没听到码头上响起的高亢的汽笛声,轮船靠岸,旅客纷纷涌了出来。\n向警予在保姆仆人的簇拥下慢慢下了船,她老远便见一乘轿子候在路边,一个管事模样的人领着大堆仆人站在那里,不觉皱了皱眉头,向管事问道:“斯咏没来?”管事笑说:“小姐临时有事,她临走时托付小人,千万要照顾好向小姐。”这管事正是陶会长的管家,向警予的父亲则是溆浦商会的会长,陶向两家是世交,常有往来,向警予与陶斯咏自小就是好朋友,此次向警予前来长沙就读周南中学,向父便托陶会长代为照看。\n管事说道:“向小姐,这是我们老爷给您准备的轿子。”向警予手一挥:“谢了,我用不着。”\n管事忙道:“那哪行啊?您是千金小姐,哪有自己走路的道理?”\n“我就喜欢自己走路。” 向警予理也不理他,直上了台阶。管事还想劝,保姆拦住他说:“您就别客气了,我们小姐就这习惯,从来不坐轿,劝也没用。”\n管事愣住了,后面跟上来的仆人嘀咕了一句:“这什么小姐呀?”管事瞪了仆人一眼,仆人赶紧不做声了。管事只得向轿夫一挥手:“跟上跟上。”前面向警予走得飞快。\n警予忽然停在了蔡和森的身后,她被蔡和森手里的报纸吸引住了。蔡和森诧异地一回头,却看见一位美貌少女正探头看自己手里报纸上的广告,正不知所措,还没反应过来发生了什么事情,这少女已经大大方方地也蹲了下来,对他说:“哎,报纸能不能借我看一下。”\n蔡和森还没遇到过这样大胆的女子呢,他实在不清楚对方要做什么,只得赶紧把报纸递给她。管事的跟了上来,很是抱歉地说:“向小姐,您要看报啊?我这就给您买去。”\n“不用了不用了,我不正在看吗?” 警予头也没抬,小声读着广告:“‘……于见报次日,即开始报名。’哎,这是哪天的报纸?”一时乱翻,去看报头的日期,也不等蔡和森回答,又自言自语道:“今天的?太好了!”\n看到蔡和森正茫然地望着自己,她笑了笑,问:“哎,你想去考啊?到时候咱们一块儿考。”“一师不是女校,你怎么可以去考呀?”蔡和森真想不明白,怎么这个女生连长沙的一般学校不招女生都不知道?\n“广告上也没说只招男生啊?怎么这样啊?太没道理了!我还以为省城会比小地方强呢,也这么落后!”警予站起身,把报纸还给蔡和森,冲管事大声说,“走,去第一师范!”管事又是一愣,这位小姐让他已经有些昏头昏脑,“不回陶府吗?”\n“我现在不去陶老爷府上,我要去第一师范!”向警予一字一顿地说着,走出几步,又回头对蔡和森说,“哎,你等着看,我肯定跟你一起考。”\n向警予直奔第一师范而来,一脚踏进教务室,叫道:“老师,报名处是这里吗?我要报考。” 教务室此时只有国文教师袁吉六一个人,这位前清的举人花白大胡子,体态肥胖,留着剪过辫子后半长不长的披肩发。他半天才弄明白眼前这个风风火火的女子居然要考一师,几乎有些不敢相信,“你个女娃娃考一师?”\n向警予挺起腰杆,大声道:“我是女人,不是什么女娃娃!”袁吉六把眼镜往鼻梁上一推,看也不看警予一眼,“女人更不能考!男女之大防都不要了,成何体统!去去去!”看到向警予的脸都被气白了,旁边的管事赶紧插话:“这位先生,说话客气一点嘛。这可是向会长家的千金……”\n“我管你什么千金万金,赶紧领回家去,少在这里捣乱!” 袁吉六扬扬下巴说。这时一名校役进门,“袁先生,校长请您去开会呢。” “知道了。”袁吉六慢条斯理地起身,端起水烟,边走边对管事说:“赶紧走赶紧走,也不看看地方——这是学校,学校是女人家来的场合吗?搞得没名堂!”\n“你才没名堂呢,老封建!”向警予冲袁吉六的背影跺跺脚骂完了,又冲着管事说,“走,这种地方,请我我都不来!”她冲将出去,从袁吉六背后挤过,扬长而去。袁吉六被挤得一个踉跄,连水烟壶都差点掉了,气得他吹胡子瞪眼,半天才憋出一句:“简直……简直……世风日下!世风日下!哼!”\n向警予憋着气到了陶家,一进陶家就大声嚷嚷:“气死我了,真是气死我了!简直不把女人当人!真是气死我了!”这时门外一个声音传来,“什么事把我们的向大小姐气成这样?”陶斯咏站在了门前。\n“斯咏,你个死丫头,也不到码头接我。” 警予叫道,两人一把抱住了,笑闹成一团。这时一个佣人上前来说道:“小姐,刚才姨太太来电话了,她和表少爷一会就到。” 斯咏闻言怔了一怔,顿时不耐烦起来,说:“知道了。”却对警予说:“走,去看看你的房间。”\n二人上楼来,警予一面看,一面把报考一师被拒的事说了,斯咏却有些心不在焉,趴在床上说:“谁要你跑到男校去报名的?其实读周南还不是一回事?反正都是师范。我现在才是真的遇到麻烦了。”\n警予却没有听出她的言外之意,在房间里不停地走来走去,说道:“那也不能把我轰出来吧?还城南书院,千年学府?都是老封建!况且,我也没说非得进一师,我就是咽不下这口气!谁比谁差呀?”\n斯咏点点头,应和道:“那倒是,进了考场,说不定那些男生还考不过我们呢。”\n警予一听这话,突然停下脚步,偏着头想了想,然后一脸坏笑地看着斯咏,凑近她说: “如果有两个女生,悄悄去参加了一场只准男生参加的考试,而且考了第一名,然后她们再去告诉那些老封建考官,你们录取的头名状元,乃巾帼英雄陶斯咏、向警予是也,那时候你会是一种什么感觉?”\n斯咏推开她说:“去去去,异想天开!我可不跟你发神经!”“哎,我可不是跟你开玩笑,我们这回,就是要让他们看看,女人比男人强!来,拉钩!”警予一本正经地凑拢来,向斯咏伸出手来。斯咏犹豫着,警予用目光鼓励着她,斯咏显然经不起这番怂恿,终于按捺不住,两只手的小指勾在了一起。\n这时陶家的丫环进来,讲王家的老爷、太太和表少爷已经来了。斯咏闻言呆了一呆,不情愿地站了起来,苦着脸看着警予,警予笑说:“快去,别让人等急了,我累了,要睡觉,就不打扰你们约会了。” 说话间又暧昧地一笑,斯咏瞪了她一眼,半晌才缓缓下楼来。\n陶家的客厅里,西装革履、戴着近视眼镜的王子鹏坐在沙发上,一双纤弱的手下意识地绞在一起。他长得颇为清秀,从头到脚收拾得一丝不苟,只是脸色略有些苍白,身后站着他的丫环秀秀。\n斯咏进了客厅,看到父母和姨夫姨母都不在,有些意外,只好很不自然地招呼子鹏:“表哥,你来了?”\n子鹏站起身,同样的不自然,紧张地挤了个笑容。倒是秀秀乖巧地叫了一声表小姐。\n“哟,斯咏,”王老板、王夫人与陶会长这才从里面出来,王夫人先咋咋呼呼叫了起来,“子鹏今天专门来邀你出去玩的,都等你半天了。我们大人要商量点事情,你们小孩子出去玩吧。”\n斯咏看看三位长辈,再看看局促不安的王子鹏,一脸的不情愿,向外走去。子鹏赶紧跟了出去。秀秀几乎是条件反射地拿起子鹏的围巾,跟在子鹏身后。王夫人眼睛一瞪,呵斥道:“阿秀,少爷陪表小姐散步,你跟着算怎么回事?一边去。”\n秀秀收住脚步,回到夫人身边,目送着子鹏出门。只见子鹏跟在斯咏身后走出大门,悄悄窥视着斯咏的表情,正好斯咏回过头来,他又赶紧低下头。\n斯咏问他:“你到底想上哪儿去?”“我……随便。”斯咏说道:“王子鹏,你什么时候能有一回主见?哪怕就说一个具体的地点,这不是很难吧?”\n子鹏紧张地绞着双手,不敢看斯咏。斯咏移开目光,摇了摇头。这时远处忽然传来教堂悠扬的钟声,子鹏似乎想起了什么,兴奋地说道:“我们去教堂!”\n“听说……你要上周南去读书?”子鹏终于找着了一个话题。斯咏点头说:“周南女中师范科。还有一个朋友跟我一起。”“谁呀?”子鹏无话找话。\n“溆浦商会向会长的女儿,叫向警予。我们约好了一起读师范,以后毕业了,一起当老师。对了,你呢?” 斯咏说道。“我什么?”子鹏呆了一呆。\n“你的打算啊?打算上哪所学校?学什么?打算以后干什么?”\n“我,我还没想好。” 子鹏半晌才说道。\n“就是说,姨父姨母还没给你安排好,是吗?”\n子鹏不禁有些窘迫。这时教堂的钟声再次响起。子鹏突然想起了什么,忙不迭地掏起口袋来,他掏出一大把零钱数着,兀自不足,“斯咏,你——有没有零钱?”\n斯咏看得莫名其妙:“你要那么多零钱干什么?”子鹏吞吞吐吐地说:“我……我借一下。”\n“王少爷,哎,王少爷来了……!”这时一大群小乞丐看见子鹏,呼啦一下围了上来,一只只黑黑的小手伸了过来,子鹏忙不迭地把手中大把零钱分发给每一个孩子。在孩子们的一声声“谢谢”里,斯咏温柔地望着子鹏分发零钱时那灿烂的笑容。\n孩子们一阵风似的来,又一阵风似的散去。待最后一个孩子跑开,子鹏回过头,正碰上斯咏的目光,这目光他很陌生。在教堂外的椅子上坐下,斯咏问:“你好像跟他们很熟?”\n隔着一个人的位置,子鹏坐在斯咏左边:“也谈不上……我经常来这儿,他们习惯了。”斯咏望着子鹏,子鹏被她的目光弄得一阵紧张,低下头。\n斯咏沉吟说道:“表哥,有句话我想跟你说。其实,你是个很好、很善良的人,可你想没想过,一个人光心地善良是不够的。你可以发善心,给这些孩子施舍,可这能改变什么呢?你能改变他们的前途,能改变他们的命运吗?”\n子鹏愣住了,他显然没认真想过这些问题。斯咏又说:“中国到处都是这样的孩子,如果光是施舍,而不为他们去做点什么,那他们今天是这样,明天还会是这样,甚至他们的孩子,他们孩子的孩子仍然会是这样。这就是我为什么要去读师范,要去当老师的原因。”\n她抓起子鹏那只略有些苍白的右手:“你有没有想过,你的这双手,能为这些孩子,能为这个社会做些什么有用的事?能让你自己觉得,你是一个对别人有用的人?表哥,这些问题,我们都好好想想,好吗?我先走了。”\n斯咏走了,子鹏呆在那儿抬起自己的手,仿佛不认识一样端详着,直到钟声又一次响着,惊得一群鸽子扑啦啦从他面前飞起,才站起身来。\n中午子鹏闷闷不乐回到家,他在心里反复咀嚼着斯咏的话,呆坐在阳台上,随手翻看当天的报纸,当他看到一师的招生广告时,沉默了一时,忽然忍不住问正给他端茶来的秀秀:“秀秀,你说,我,王子鹏,是不是一个有用的人?”\n秀秀放下茶杯,站在少爷身后,说:“少爷读过那么多书,还会洋文,心又那么好……您是少爷,怎么会没用呢?”\n子鹏把手里的报纸放在桌子上,撑着下巴说:“我有用?我是能文,能武?还是能做工,能种田,能教书,能医病,我能干什么?我对别人有什么用?除了当少爷,我连一杯茶都不会泡,还得你泡好了给我端过来!”\n秀秀以为是自己说错了话惹少爷不高兴了,赶紧摆着手说:“少爷,好端端的您这是怎么了?您哪能跟我这种下人比呢?少爷……”\n“我应该跟你比,跟你比了我才会知道,我就是个废物,一个废物!”长长地叹了一口气,子鹏拿起那份报纸,读着上面的广告,说:“我不要做废物,我去考一师范,当教师,教孩子!”\n秀秀看了看报纸,忽然说道:“少爷,您这张报纸能给我吗?”\n四 # 湘乡会馆巷子口卖臭豆腐的刘三爹今天收了摊,儿子刘俊卿考上了法政学堂,眼看着就要报到了,可是家里哪能拿得出30块大洋的学费呀?实在没有办法,刘三爹只好领着儿子去了三堂会。\n堂里的大哥马疤子斜在榻上抽着大烟,手下的亲信老六带着好几名打手凶神恶煞地侍立在旁边。马疤子喷了口烟圈,懒洋洋地说:“嘿,有意思。借钱交学费?我说刘老三,你不是真老糊涂了吧?”\n刘三爹把腰快弯成一张弓了,低声恳求:“实在是想不出法子了,这才求到马爷这儿。就30块大洋,多少利息我都认,求求您了。”\n“你认?”马疤子坐了起来,长长地伸了个懒腰,盯着刘三爹问:“你拿什么认?啊?就凭你那清汤寡水的臭豆腐摊?”他说着下了烟榻,过来拍拍刘三爹的肩膀,又说:“老刘啊,听我马疤子一句劝,死了这条心吧。就为你这傻儿子读书,这些年你都过的什么日子?能典的典能当的当,三更半夜起早贪黑,连闺女都押给人家当了丫环,你值吗你?”\n“俊卿他会读书,他真的会读书,他以前在学堂年年考第一的。”刘三爹赶紧拉过刘俊卿,“俊卿,来,你把学堂的成绩单给马爷看,你拿出来呀。”\n这种卑躬屈膝的屈辱令清秀俊朗的刘俊卿很是难堪,他沉着脸,甩开了父亲的手。\n“好了好了,谁看那破玩意?”马疤子看到刘俊卿这副样子,“哼”了一声,“我就不明白,这书有什么好读的?还当法官?马爷我一天书没读过,连法官还得让我三分呢!告诉你,没钱就别做那个白日梦,麻雀变凤凰,还轮不到你那臭豆腐种!”\n“我求求您,马爷,只要俊卿进了学堂,我给您做牛做马……”刘三爹还不死心,刘俊卿却实在受不了了,他转身就走,刘三爹赶紧拉他,“俊卿,你回来,快求求马大爷……”\n刘俊卿甩掉父亲的手,说:“要求你求,我不求!”\n马疤子在身后叫道:“哟嘿,还蛮有骨气?我说小子,真有骨气,就别把你家老头往死里逼,自己给自己寻条活路是正经。马爷我为人义字当先,最是个爱帮人的,要不,上爷这儿来?爷手底下能写会算的还真不多,包管有你一碗饱饭吃。”\n父子俩回到家已经是黄昏了,棚屋里已经简陋得没有任何一样值钱的东西。一道布帘将本来就狭窄的房子一分为二,靠外面杂乱地堆满了石磨、竹匾等做臭豆腐的工具,只有一床窄小破旧的铺盖挤在墙角,这是父亲住的地方。布帘另一侧桌椅床铺虽然简单,却还干净整洁,那就是刘俊卿的书房了。刘俊卿气愤地在床头坐下,点亮油灯,看起书来。\n忽然门外轻响,秀秀走了进来,她见刘俊卿在那里读书,也不惊动他,只在布帘外悄悄拉了父亲一把,掏出一个布帕递给父亲,小声说:“爸,这是我的工钱。”一时又看布帘里的刘俊卿一眼,说:“那个法政学堂那么贵,一年学费好几十块,我们上哪弄得到这么多钱?”\n刘三爹无奈地说:“我想,实在不行,我明天再去求一求三堂会……”秀秀急了,打断父亲的话说:“爸,那种钱借不得,利滚利,要人命的!”\n“我怎么这么没用?我怎么这么没用?就这么一个儿,我都供不起他读书……”刘三爹抬手猛捶着自己的脑袋,哭着说。\n刘俊卿在屋里坐不下去了,他掀开布帘子走出来,紧紧地抱住已是老泪纵横的父亲,叫道:“爸,你别这样,大不了……大不了我不读了。”\n“怎么能不读呢?你这么会读书,你要读了才有出息,你要当法官的,不读怎么行呢……”刘三爹一把捂住了脸,“都怪我这个当爹的没用,害了我的儿啊……” 刘俊卿兄妹相互看了一眼,不说话,秀秀眼泪止不住地流了下来,半晌掏出了那张报纸,递给刘俊卿说:“哥,这是我找我们少爷要来的,可能,你有用。”\n五 # 湘江对岸的岳麓山。山下溁湾镇刘家台子的一个小巷子里,用竹篱笆围成一个小院落,院内一间阴暗的小房子里,桌上、地上堆满了火柴盒子和糨糊,斜阳照进来,一个妇人和一个十多岁的小女孩正低头在那里糊着火柴盒。\n这个妇人梳着一个大髻,乌黑的头发总挽在脑后,穿一件深蓝色衣衫,虽已极是破旧,但破口处都用花饰掩盖,整洁异常。她面容清瘦,眉角间满是风霜之色,然而举止从容娴静。\n“第……八十五页。” 妇人一边报数字,一边手不停地忙碌着。\n小女孩手边赫然是一本翻旧了的《西哲诗选》,她看了一眼标题,盖住书,拿起刷子,一面在火柴盒上刷糨糊,口里背诵:“‘假如生活欺骗了你,不要愤慨,也不要忧郁。’”她背了这句,停下来,看着那妇人。\n“不顺心时暂且克制自己,相信吧,快乐的日子就要来临。” 妇人立时续道,然后看着女孩。女孩也续着,“现实总是令人悲哀,我们的心却憧憬未来。”又停下来。妇人又接道,“一切都是暂时的,它将转瞬即逝。”\n两个人你一句我一句,从普希金到雪莱,从哥德到席勒,背个不停。这时一个少年走进了院子,正是蔡和森,他轻手轻脚掀开墙边的破草席,把一个擦鞋的工具箱藏进去盖好,换出自己的书包背在背上,然后擦了擦手上的黑渍,整理好衣服,这才推门进了屋,问:“妈,小妹,今天谁赢了?”\n“打平!”小女孩放下手里的刷子。她正是蔡和森的小妹蔡畅,那妇人是他的母亲葛健豪。蔡和森放下书包,坐在妹妹身边帮着糊火柴盒,低着头说:“不可能,你能跟妈打平?” 蔡畅得意地说:“今天我发挥得好,不信你问妈。”\n葛健豪看着儿子,问:“又这么晚才放学啊?” 蔡和森答应着,不动声色地避开了母亲的目光,对妹妹说:“来来,再比,我也来一个。小妹,你来翻书。”\n“书待会儿再背吧。” 葛健豪拍拍手,站起身,叫着儿子的小名,“彬彬,你来一下,我有话问你。”少年蔡和森犹豫了一下,立即微笑着站起来跟母亲出了房间。等儿子出来,葛健豪关严了房门,站到破草席旁问儿子:“这些天学校里还好吧?”\n蔡和森故作轻松地回答:“就那样。”“就那样是哪样啊?”葛健豪的语调平静。蔡和森说:“还不就是上课,也没什么可说的。”\n葛健豪的眼睛还看着儿子,一只手却掀开了草席,指着露出来的擦鞋箱:“就用这个上课吗?如果不是你们学校今天寄通知过来,妈到现在还被你瞒着呢。你自己看看,学校说你一直欠着学费没交,最近一段干脆连课也不去上了。彬彬,要学费为什么不跟妈说呢?”\n“咱家现在哪交得起这么多学费啊”!蔡和森低下了头,小声说,“小妹又要读中学了,我是想……”\n“不管怎么想,总不能不去读书!”葛健豪打断儿子的话,平静了一下,伸手按在儿子的肩上,很坚决地对儿子说: “彬彬,你是个好孩子,你心里想什么妈也知道,可不管怎么苦,不管怎么难,妈不能看着你们两兄妹失学。连妈都在读书,何况是你们?不怕穷了家业,只怕蠢了儿女啊,你懂不懂?”\n“可这个铁路学堂,我实在是读不下去了,一年学费这么多,我不能看着妈你白天晚上糊火柴盒子供我上学,再说也供不起啊!”蔡和森叹了口气。\n葛健豪眼眶不由红了,说:“妈明白,妈不是那种不切实际的人。学校太贵,咱们可以换,好学校也不是个个都贵的。关键是你得读下去。”\n蔡和森这时从口袋里掏出了那份叠好的报纸,打开递给母亲:“我想过了,妈,我想退学考一师。”\n第三章 论小学教育 # “ 民国教育,提倡的是平民化,\n一般平民看得懂的,倒正是这些大白话。\n如果我们还守着子曰诗云那些几千年的圣人经典,\n又何谈普及国民教育?再说,师范学校,\n本来招收的就主要是贫家子弟,以后他们要做的,\n也是最基础的小学教育\u0026hellip;\u0026hellip;”\n一 # “传、不、习、乎?” 一师的校长室里,碧眼黄发的美籍英语教师饶伯斯眯缝着眼睛,读着纸上的考题,操着一口颇流利的中文不解地问,“这是什么意思?”\n二十出头的历史教师兼庶务主任黎锦熙,一身笔挺的西装,留着当时少见的漂亮发型,用颇为流利的英语回答饶伯斯:“这是孔夫子的学生曾参的话,意思是说,作为教师应该经常反思,教授给学生的知识和道理,自己是不是经常体验、学习,是不是身体力行地掌握好了。”他说完这段话,看到饶伯斯呆呆地望着自己,一时不明白他是什么意思:没听明白?还是听明白了在思考?\n饶伯斯却把一直微微张着的嘴合拢,咂巴了两下,才问:“这么长的一句话,四个字就讲完了?”\n满屋子的中国教师都情不自禁地笑出了声。方维夏给他解释说:“中国的古文就是这样,字很少,意思却很深,一般人不容易理解。”\n一直坐在旁边没吭声的德籍音乐教师费尔廉,忽然问道:“既然不容易理解,为什么要出这样的考题?”\n大家一时都不知怎么回答他,全把目光投向了出这个题目的国文老师袁吉六。袁吉六吧嗒吧嗒地吸着水烟,慢条斯理地理了理烟楣子,这才说:“微言大义,自古考题都是如此,袁某这种老古董也变不来什么新花样,既然列位都觉得酸腐,合不上民国新教育的要求,那就照列位的意思来吧。”\n“既然仲老都这么说了。”孔昭绶其实等的就是这句话,正好接住话茬往下说,“大家有什么提议,就尽管说吧。”\n一阵沉默之后,黎锦熙看到孔昭绶正微笑着对自己点头,心领神会地轻轻咳嗽了一声,开口说:“我们不是培养小学教师的吗?以‘论小学教育’为题,既简单又明了,怎么样?”\n大家都还没表态,袁吉六先皱起了眉头:“论小学教育?这不成了大白话吗?”\n费尔廉直抒胸臆:“我觉得大白话好啊,意思很明白,容易懂,这个题目很好很好。”\n袁吉六白了这个老外一眼,“哼”了一声,说:“只怕上不了台面吧?”\n方维夏站起来说道: “我看倒也不见得,民国教育,提倡的是平民化,一般平民看得懂的,倒正是这些大白话。如果我们还守着子曰诗云那些几千年的圣人经典,又何谈普及国民教育?再说,师范学校,本来招收的就主要是贫家子弟,以后他们要做的,也是最基础的小学教育。论小学教育,这个题目应该不错。”\n看到其他几位老师也纷纷表示首肯,孔昭绶询问的目光投向袁吉六。袁吉六显然还是有些不以为然,他喷了一大口烟圈,说:“既然大家都觉得好,那——论就论吧。”\n孔昭绶听到袁吉六这样说,一颗悬着的心才彻底落了下来,总结说:“那这个题目就定下来了。依我看,还可以再放宽一步,只要以‘论小学教育’为中心议题,具体的作文题目可以由考生自行拟定,文体、篇幅一概不限。我们就是要让考生自由发挥!”\n二 # “……凡长沙本市及湖南中路各县考生,具高小毕业及同等学力者,均可报名……报名之次日,将入学考试作文送交本校教务室……录取结果将于五日后张榜公布……”\n当蔡和森从溁湾镇坐船过了湘江,赶到一师时,一师操场的公示栏前,已经密不透风地围了一大群年轻人,都伸长了脖子在看《招生报名须知》,有的还边看边断断续续地念着。蔡和森站在后面干着急,想挤挤不进去,踮起脚来也看不全公示栏上的内容,正没办法,看到前面站了个特别高的大个子,便拍了拍那人说:“这位老兄,老兄!”\n身穿半旧长衫的大个子回过头来问:“什么事?”\n“你能不能帮我看看考题是什么?”\n大个子看了看蔡和森,说:“‘论小学教育’,以此为内容,题目自拟,篇幅不限。哎,你也是来报名的?”\n蔡和森点点头,看着眼前密密麻麻的脑袋,叹息道:“没想到会有这么多人啊。”\n大个子朗声笑了:“就是。才招80个,来报名的倒有好几百!”\n蔡和森正想接着问,却见大个子伸手拍拍他前面的一个清瘦小伙子,说:“哎,萧菩萨,想不想对个对子?上联是——叫花子开粥厂。”那位“萧菩萨”才回过头,还没来得及答话,大个子却自行接了下去:“眼前就是绝妙的下联——穷师范招学生。”\n“萧菩萨”似乎和大个子很熟,习惯了他这样说话,很默契地问:“横批?”大个子一字一顿地说:“挤、破、脑、壳。”\n周围的人都大笑起来,蔡和森也被逗乐了,他不禁仔细地多看了这个乐天达观的大个子几眼。只有紧挨在前面的刘俊卿皱起了眉头:竞争者之多已经令他不安,偏偏还有人拿这个开玩笑……他移开了几步,躲开了这笑声。\n这时候,在不远处的操场大门前,一字排开的几张方案上,立着“报名处”的牌子,旁边还摆好了笔墨、报名表格等。黎锦熙站上台阶大声说:“请各位考生注意了,凡愿意报名者,到报名处来领取报名表,操场上摆了桌子供大家填写。填写后,交到这边来,换取考号。”\n蔡和森随着人流呼啦一下都围了过去,抢到一张表格,他左右张望着,想找个位子坐下来填写表格,却看到那位“萧菩萨”在和一个同学打招呼,“哎,易礼容?”易礼容看时,惊叫道:“子升兄?你这湘乡第一才子也来考?你看看你看看,你这一跑来,我们还有什么指望啊?干脆直接回家得了。”\n众人都回过头了,想看看这位名叫萧子升的湘乡第一才子长得是什么模样。蔡和森这时却瞅到了一个空位子,忙坐下提起毛笔填写。等他再去蘸墨的时候,发现身边坐的人也正好伸过笔来,顺着一双大手看上去,呵,这不正是刚才帮自己的那位大个子吗?大个子显然也认出了他,率先对他说:“你好!”\n蔡和森回应着,把面前的砚台给他推近了些。大个子说着“谢谢”,无意间,却正好看见蔡和森表格上填好的姓名,一下子惊叫起来:“蔡和森!你就是蔡和森?铁路学堂那个蔡和森?”\n蔡和森有些奇怪:“你怎么知道呀?”大个子依然大着嗓门说:“嗨,长沙的学生,哪个不晓得有个蔡和森,去年考铁路学堂,作文考了105分。满分不够,还另加5分,天下奇闻啊!原来就是你呀。哎,你不是在读铁路学堂吗?怎么又跑到这里来了?”\n蔡和森很坦率地回答:“那边!学费太贵,实在读不起,我已经退学了。”“哦!彼此彼此。穷师范招学生,还是咱们穷兄弟多。”大个子说道。\n二人一面填表,一面聊着。蔡和森问道:“对了,还没请教老兄贵姓啊?”“贵什么贵?”大个子把报名表递了过来,“我姓毛,毛泽东。”蔡和森的目光停留在表格的履历一栏上,那上面除了“工”一项外,农兵学商都打上了勾,他颇为惊奇:“嘿,毛兄干过那么多行当?农兵学商都全了!”\n毛泽东得意地说:“我呀,是家在农村种过地,老爹贩米帮过忙,出了私塾进学堂,辛亥革命又扛枪。五花八门,反正都试了一下。”\n“毛兄不过比我大一两岁,阅历却如此丰富,令人佩服。”蔡和森说道。“我们就不要你佩服我,我佩服你了。”毛泽东向蔡和森伸出手,爽快地说,“来,交个朋友。”\n两个人的手握在了一起。毛泽东说:“以后,你我可就是同学了。”蔡和森笑道:“还不知道考不考得上呢。”毛泽东手一挥:“怎么会考不上?肯定考得上!”\n“……李维汉,255号;周世钊,256号;邹彝鼎,257号;罗学瓒,258号……” 黎锦熙依次收着考生交来的报名表,一面读出考生姓名,一面往表上编定考号:“……萧子升,401号;刘俊卿,402号;这,这是怎么填的嘛?乱七八糟的,向——胜男,403号。”\n这个“向胜男”年龄也不小了,来考师范,想必应该是读过书的,但却连自己的名字都写得歪歪斜斜,像是才提笔写字的学童一样。不仅写字,走路的样子也很奇怪,像是跑堂的小二进了文庙,埋着头弯着腰,全身紧张。更可笑的是,他领了考号,竟像是做贼一样,飞快地跑了出去,看得所有人都目瞪口呆。排着长队的学生里有人起哄道:“哈哈,这样的人还想胜男?”\n这时又一张表格递了过来,收表格的同学抬起头一看,当即愣住了——面前是一个矮矮壮壮、留着粗粗的八字胡、戴着眼镜的中年人,那张脸上都已经有了皱纹,忙道:“这位老伯,对不起,学校规定要由考生本人报名,不能由家长代报。”\n中年人笑着说:“我就是考生啊。”这话把旁边的人都吓了一跳。中年人很温和地问:“年纪大是吗?可招生不是没限年龄吗?”\n“年龄是不限,可是……您真的来报名?”这个同学有些疑惑地念着表格,“何叔衡?哟,您还是位秀才啊?”\n黎锦熙听到何叔衡的名字,忙过来接过表格,看了看,猜疑地问道:“您不是宁乡的何琥璜先生吧?”“正是鄙人。”何叔衡笑说。\n“何先生,您好,我是一师的历史教师黎锦熙。您这是开什么玩笑?您可是长沙教育界的老前辈了,怎么能到我们这儿来报名呢?”\n何叔衡赶紧解释说:“我真的不是开玩笑。何某虽说已经37岁了,在宁乡办过几年学,教过几年书,可过去学的,都是些老掉牙的八股文章,穷乡僻壤,风气不开,如果不多学些新知识、新文化,再教下去,只怕就要误人子弟了。所以,我是真心实意来贵校报名,想从头学起,做个民国合格的老师。怎么,不会嫌我这个学生太老了吧?”\n“哪里的话?琥璜先生这么看得起一师,是我们一师的光荣。”黎锦熙对那个高年级的同学说,“陈章甫,来来来,大家都来,为何先生鼓鼓掌,欢迎何先生!”围观的报名考生都鼓起了掌,掌声顿时响成了一片。\n忙了一上午,黎锦熙才把报名表格汇总交到教务室,老师们顿时都围了上来,竞相关心着新生报名的情况。\n“连琥璜先生这等人物都来报名了?”袁吉六拿着何叔衡的那份报名表,笑逐颜开,“一师这回,真是人才济济啊!”\n黎锦熙清理着桌上厚厚的报名表格,说:“不光何先生,还有这个——蔡和森,去年考铁路学堂,作文考了105分,全长沙都出了名了!”他的手停在了下一份报名表上:“哎,这个也挺有意思,才19岁,务过农,经过商,做过学生,还当过兵,什么都干全了。”\n“哦,还有这种全才?我看看。”孔昭绶刚要接过那张毛泽东的报名表,同在清理表格的方维夏突然一拍桌子:“漂亮!太漂亮了!哎,你们来看你们来看。”\n几个人都围了上来,那是萧子升的报名表,表上的字简直是一幅书法作品。方维夏啧啧有声地夸着:“看看,看看,这是18岁的后生写出来的字!不是亲眼所见,谁敢信啊?”\n黎锦熙看得也呆了:“哇,这手字,咱们在座的只怕是没谁能写得出来哦。”袁吉六捏着胡子,左右端详:“嗯,飘逸灵秀,有几分大家神韵,了不起!”\n孔昭绶接过报名表,同样爱不释手,不住地颔首。他踱到窗前,望着碧空万里,校旗飘扬,他长长舒了一口气,似乎是在对几位同事说,又更像是在踌躇满志地自言自语:“咱们一师,有希望,大有希望啊。”\n突然他转过身问:“对了,杨昌济先生还没有消息过来吗?”\n众人都摇了摇头。\n三 # 那位在一师闹了笑话的“向胜男”,一路跑回了陶府,跑进了陶家小姐陶斯咏的房间。擦着冷汗把领取考号的过程给一直等在这里的两位小姐做了详细的汇报。斯咏递给他一块钱,并吩咐他不许泄漏一个字。“是,小姐。”他答应着欢喜地接了钱,关上门出去了。\n“向胜男先生,动手吧。” 等仆人一离开,斯咏就立刻兴奋地和警予一起开始合谋答卷了。向警予正要落笔,心里突然猛跳了一下,想:不知道那个擦皮鞋的家伙现在是不是也在答卷?\n蔡和森这个时候的确正在答卷。在他身边,葛健豪与蔡畅正静悄悄地糊着火柴盒。蔡和森写完最后一个字,从头到尾看了一遍,然后收起笔墨,对葛健豪说:“妈,您休息一下,我来吧。”\n而在萧家兄弟的租屋里,子升也正提笔凝神思考。萧三已经写了一小半,看到哥哥不慌不忙的样子,着急地催道:“哥,快写呀,我们还要去赶船呢!润之哥一会就要去刘三爹那里等我们了。”\n刘俊卿的文章却很快就写好了,想到父亲为了自己能上学受的苦,他心里酸酸的,放好了卷子就到父亲这里来帮忙。刘三爹看到儿子站在他面前,忙问:“俊卿,有事啊?”\n“没什么事。我,文章写完了。”刘俊卿说着,操起炸臭豆腐的长筷子。“哎呀,这哪是你做的事?”刘三爹吓得赶紧拦住儿子,“又是油又是火的,你快些站开,莫烫着了。”\n“那,我帮你擦擦桌子。”刘俊卿伸手去拿抹布。刘三爹赶紧又抢了过来:“不用不用,俊卿啊,你这双手是写字的,怎么能做这些粗活?莫做坏了手!你饿不饿啊?要不要吃碗臭豆腐?”\n“爸,我不饿。”“写了一下午文章,怎么会不饿呢?先吃一碗。”刘三爹装起一碗臭豆腐,放到了他面前,“吃啊,吃。”\n眼看什么也插不上手,刘俊卿只得坐在父亲摊子旁边,吃了起来。\n这时萧家兄弟提着行李来到摊前,萧三坐下看子升的文章,子升叫道:“老板,来碗臭豆腐。”读着文章的萧三忍不住挑起了大拇指:“哇,湘乡第一才子到底是湘乡第一才子!哥,我什么时候才能写出你这么好的文章?”\n听见这句话,刘俊卿抬起头来,往这边看了两眼。他认出了子升,下意识地侧了侧身子,背向二人。\n“行了,子暲,自家兄弟,还吹个什么劲?”子升全没有看见他。“哥,你是写得好嘛,就凭这篇文章,这回考一师,准是你的头名状元!”刘俊卿听到这话,脸色越来越难看,甚至微微扯着嘴角。\n“子暲,状不状元先别管,也不知爹怎么样了。我先去买船票,你看好行李,润之那边我已经约好了,让他帮我们代交文章。他一会儿就到这儿来碰头,你把文章给他,赶快到码头,六点的船,别耽误了。”子升站起来说道。\n“哥,知道了,都交代一百遍了,也不烦。”萧三答应着,看子升匆匆离去后,这时刘三爹端了臭豆腐过来,他随手将文章往摆在长凳上的包袱下一压,吃了起来。压在包袱下的文章的一角露在外面,随风轻轻抖动。刘俊卿一口一口,慢慢地嚼着臭豆腐,眼睛却始终盯着把被风吹动的文章。\n“子暲,子暲,萧三少爷!” 也不知道过了多久,正倚着桌子养神的萧三被一阵叫声惊醒了,他睁开惺忪的眼睛一看,正是毛泽东来了。\n“怎么,梦见周公了?”毛泽东问他,“让我代交的大作呢?”“写了半天文章,跟着就收拾行李,一口气都没喘,我刚眯了一下眼睛。”萧三解释着,转身去拿起凳上的包袱,顿时傻眼了——压在下面的文章已不翼而飞!\n“咦,我的文章呢?我明明放在这里的,怎么不见了?哎,这真是怪了,出鬼了?”两个人四下到处搜寻,哪里有文章的影子?\n毛泽东问他:“你不会记错吧?是不是放在别的地方了?”“我就放在这儿,肯定没错!”萧三着急地问刘三爹,“老板,你看到有谁动过我的东西吗?”\n刘三爹想了想,摇摇头,说:“哟,这我可没注意。怎么,丢东西了?”“两篇文章!我和我哥考第一师范的作文!没它就考不了!”\n“文章?那东西谁会拿呢?挺要紧的?可是,这儿也没来过别人啊。”刘三爹说着,目光下意识地向儿子刚才坐的地方看去:刘俊卿早已不见了,摊子上留了一只空碗。\n万般无奈,萧三只得听从了毛泽东的建议,把卷子的事情交给毛泽东来解决,然后赶紧去码头和哥哥会合。暮色初现的码头趸船上,看到萧三提着行李,气喘吁吁地跑来,子升已经急得不知道该怎么责备他了,只是催促着:“你怎么搞的?再晚来几分钟,船就开了。走走走,快点!\n上了船,子升站在踏板上,将箱子放上行李架,回头来接另一个包袱,萧三却抱着包袱走了神。\n“子暲!你怎么魂不守舍的?”子升从弟弟手里拿过包袱放好,在弟弟身边坐下来,问:“那两篇文章呢?我问你给润之没有?”\n“已经……已经给了……”萧三回答的时候,躲避着哥哥的目光。子升望着他的样子,皱起了眉头:“你今天这是怎么了?有什么事瞒着我?”\n“没有哇。”“你一说谎就不停地眨眼,我还看不出来?说,到底怎么回事?说呀!”\n萧三只得把丢卷子的经过一一告诉子升。伴着他的讲述,传来一声长鸣的汽笛,有人在喊“开船啰!”随即,船离开了岸边。\n“什么,文章丢了?”子升听了弟弟的话,腾地站了起来,爬上踏板就搬行李,但眼看着窗外已是江水一片,子升一屁股坐下,重重叹了口气:“你把我害死了!”\n四 # 转眼便到了张榜的日子,这一天一大早,一师的教务室里,气氛轻松。袁吉六是这次考试的总阅卷,录取依照科举考试的惯例,考生上榜的名次由后往前,分批公布。费尔廉饶有兴致地说:“这种方式也很好啊,能制造悬念,更加刺激。用中国的俗话来说,叫做——对了,叫‘卖、关、子’。”逗得大家都笑了。\n一师校门口的公告栏前,看榜的考生围得水泄不通,通红的考榜上是“招生录取名次”几个大字。校门对面的角落里,刘三爹心不在焉地用长筷拨拉着臭豆腐,眼睛却望着拥挤的考生。这时刘俊卿走了过来,他四下瞄了一眼,忙低声埋怨说:“你怎么把摊子摆到这里来了?”\n“我,我想来看看你考上没有。”刘三爹半晌才说道。刘俊卿撇撇嘴,说:“你又不认识字,来凑什么热闹?现在有什么好看的?都是些后面的名次。等出前三名的时候再说吧。”\n“俊卿,看到了赶紧来告诉我一声,记住啊。”刘三爹在他背后喊。“知道了。”刘俊卿头也没回,生怕被人看见。\n这时有人叫道:“出来了出来了。”只见两个老师手里拿张红纸直出了教务室,考生们呼啦一下都涌了上来。一张名单贴上了公示栏,这是第一批后四十名,考生们都寻找着自己的名字,罗学瓒高兴地拉着易礼容:“考上了,我们都考上了!太好了,走走走。”\n易礼容却站在原地,说:“走什么?再看看,看看谁能考第一嘛。”人群中,毛泽东一拍蔡和森:“蔡和森,你上榜了吗?”蔡和森摇摇头,反问道:“你呢?”\n“我也没有。嗨,急什么,后面还有嘛。”毛泽东很轻松地回答。他背后王子鹏也扶着眼镜,焦急地寻找自己的名字,名单上,却连一个姓王的也找不到。\n又一张红榜贴上了公示栏,这是第十一到第四十名的名单。子鹏摘下眼镜擦了擦,仔细搜寻着,他的名字还是没有出现。随后公示栏上,第十至第四名公布了:何叔衡榜上有名,刘俊卿排在第六,萧植蕃第五,第四名是“向胜男”。刘俊卿的脸一下子沉了下来——第六的名次显然大大出乎他的意料。他阴沉着脸,挤开人群就走。\n看看上面还没有名字,毛泽东问:“蔡和森,你不会着急了吧?”“就剩三个了,怎么会不急呢?” 蔡和森不觉有些担心了。\n“就凭你,左手都考进前三名去,你急什么?”毛泽东与蔡和森说着话,眼睛却看到萧氏兄弟挤进了人群,大叫道: “哎哟,萧大少,萧三少,你们回来了?”\n萧三擦着汗走过来,说:“今天看榜嘛。紧赶慢赶,行李都还没放呢。哎,我们上榜了吗?”毛泽东扬扬下巴:“你抬头看呀。”“第五名,萧植蕃!我上榜了,哎,我上榜了!”\n看到弟弟得意忘形,子升目光严厉地看了他一眼:“这值得你高兴吗?”萧三赶紧不做声,躲到一边去了。\n毛泽东却还在说着笑话:“萧菩萨,莫着急,我的名字也没看见。还有前三名,好戏在后头。”“我怕的就是你的好戏。”子升沉着脸回答。\n这边刘三爹看刘俊卿沉着脸一言不发走过来,忙赶紧叫他,问:“考上没有。”刘俊卿只当没有听见,直走过去,刘三爹愣在了那儿,自言自语:“没考上?不会吧?”\n而在一师大门对面的茶楼包厢里,向警予听了仆人的报告也腾地站了起来:“第四名?那前三名是谁?”她有些不敢相信地看看斯咏,斯咏显然也颇为意外,只听警予说道:“斯咏,走!去一师!我倒要看看,把我们俩比下去的,到底是何方神圣!”\n“哎呀,不行的!我表哥也在那儿看榜。”“你表哥就你表哥,有什么好怕的?”警予看看斯咏的神情,突然笑了,“哦,就是那个跟你订了娃娃亲的表哥是吧?好好好,陶大小姐脸皮薄,不去就不去。”\n斯咏拧了警予一把,对仆人说:“我们就在楼上等着,你去把前三名的名字记清楚,回来告诉我们。”\n一师布告栏的红榜上,前三名仍然空着。还没上榜的考生们都等得着急了,人人脸上已按捺不住紧张的表情。子鹏更是心虚,他当然不敢奢望自己能考进前三名。只有毛泽东全无紧张之色,“早晓得要等这么久,不记得带本书来看。”他不耐烦地来回走了几步,突然吸了吸鼻子,“哎,什么那么香啊?”\n子升也闻到了,却皱起了眉头,“是臭吧?”“香!臭豆腐香!”毛泽东吸着鼻子,踮起脚向人群外张望,远远看见了刘三爹的臭豆腐摊,顿时兴奋起来,“哎哎哎,那边在炸臭豆腐,你们饿不饿?”\n几个人望着他,简直不敢相信他这时候居然还有这种胃口。“老板。”他们来到臭豆腐摊,毛泽东一眼认出了刘三爹,“哎,是你老先生啊,今天摊子摆到这里来了?”\n“几位老主顾,一人来几块?”刘三爹点着头赔着笑。“一人来八块,炸老点,莫舍不得放辣椒啊!”毛泽东拉开一条板凳,蔡和森、萧子升、萧三分别坐下。\n“好吃!”毛泽东满头大汗,辣得直咂嘴巴,他夹起碗里最后一块臭豆腐塞进嘴里,“辣得过瘾啊!”\n萧三一边吃着一边看看毛泽东,又看看萧子升。子升面前的一碗臭豆腐却动也没动。\n“怎么,萧菩萨,还讲客气啊?”毛泽东有意没话找话说。子升闷声回答:“这东西有什么吃头?”\n“你这个人啊,天下第一美味的臭豆腐,你都不晓得品尝,你活着有什么意思啰?”他把那碗臭豆腐端到自己面前,大方地说:“你不吃我吃,免得浪费。”\n蔡和森看着毛泽东的样子,问:“毛兄倒真是豁达之人啊,你真一点都不担心?”“你说那边的考试啊?哎呀,是你的自然是你的,它又飞不掉。想还不是白想了。来来来,先吃。”毛泽东将钱递给刘三爹,“老板,付账。”\n刘三爹听着他们的对话,半晌才迟疑地问:“几位老板,你们也是来看榜的吗?”“对呀。”“能不能跟你们打听个事,有一个叫刘俊卿的,不晓得上了榜没有?”\n“刘俊卿?有啊,第六名。”萧三回答, “我第五,他第六,我记得清清楚楚,刘俊卿,肯定没错!”\n“考上了?考上了?哎呀,太好了,俊卿考上了!俊卿考上了!”刘三爹激动得把钱又塞回毛泽东手里,说,“今天的钱不收了,我请客,我请客!”\n“那怎么行,钱还是要收的。你请客,我出钱,好吧?”毛泽东觉得这个老爹很是投缘,他把钱又拍回桌上。\n“那……那就谢谢了。”刘三爹压抑不住兴奋,不住地自言自语:“太好了,俊卿考上了,这就放心了,放心了……”四个年轻人起身朝一师走去,毛泽东边走边说刘三爹: “考了个第六都高兴成那样,要是像你蔡和森考个第一,那不要飞上天了?”\n蔡和森反问说:“你怎么知道我考第一?”“除了你还有谁?总不会是我吧?”“怎么就不会是你呢?”“我那个文章自己还不晓得?糙得很!”\n众人伸长了颈,眼见着时间到了中午,前三名却仍不见出来。大多数学生都等得不耐烦了,陆续散去,却不知这时的教务室中,老师们正吵成了一团。\n黎锦熙皱着眉头读着两份试卷,弥封的卷子上头,标着第一名、第二名的字样。发现黎锦熙满脸的严肃,孔昭绶不由问道:“怎么,卷子有问题?”\n“袁先生署定的第一名这篇,文章的确很好,这我也不否认。但第二名这篇,论述气势磅礴,文笔纵横驰骋,观点新颖,颇有其独到之处。一个学生,能写出这样的文章,锦熙生平之所未见。我不明白,为什么它倒成了第二名?”\n袁吉六闻言,转身说:“黎先生读过多少文章,就能断言好坏?”“锦熙年轻,自然当不得袁老先生,但这两篇文章不仅我一个人看过,还有好几位先生与我的看法也一致,这又怎么说呢?” 黎锦熙却是寸步不让。\n袁吉六的目光从眼镜上方射出来,环视着众人,问:“未必都一致吧?”方维夏沉吟了一下:“仲老,恕我直言,以文章的气势而言,这篇文章我也觉得略胜第一名那篇。”\n“我看不惯的,正是它那个气势!上下五千年,纵横八万里,中国一直扯到外国,咿哩哇啦一顿扯过去,一副老子天下第一的口气,张扬过甚!它败就败在这一点上。哪像第一名这篇,娓娓道来,平稳含蓄,颇有古之大家之风。文重平实嘛,反正我喜欢这篇。” 袁吉六一顿手里的水烟壶,说道。\n看到气氛紧张,易培基忙打圆场说:“其实第一名也好,第二名也好,反正都是录取,就不必太计较吧?”袁吉六脸一板:“话不是这么说,好就是好,差就是差,既然排名次,当然要排得人家心服口服。”\n“没错,考卷以后是要公布的,我们评出来的结果,总要经得起大家的评价。”黎锦熙反火上浇油。\n袁吉六冷冷看了他一眼,反问:“你的意思是,袁某评的结果经不起悠悠之口?”\n黎锦熙毫不示弱:“不敢,晚辈只是平心而论而已。”两个人针锋相对,谁也不肯退一步,局面一时僵在了那儿。\n方维夏轻轻拉了一下孔昭绶,二人来到办公楼走廊上,站在窗户前,远远望着公示栏前仍然等待着的成群考生,方维夏显然着急了:“校长,这样拖着可不是个办法。得赶紧拿个主意才行,总不能让考生们一直这么等下去啊。”\n“我何尝不知道?可仲老和锦熙这回算是拗上了,仲老的脾气你不是不知道,锦熙呢,偏偏也是个爱较真的性子,不好办啊。”孔昭绶左右为难。\n“好歹您是校长,实在不行,就由您下个定论算了。”\n“那怎么行?文章好坏,又不是当校长就说了算,如果草率定论,总会有一方心里不服。”\n“要是现在能有个让他们都服气的人开句口就好了。”\n听到方维夏这样说,孔昭绶摇摇头:“仲老和锦熙何等人物,想开这两把硬锁,那得什么样的钥匙?”\n第四章 经世致用 # 何谓经世?\n致力于国家,\n致力于社会谓之经世;\n何谓致用?\n以我之所学,化我之所用谓之致用。\n一 # 杨宅的书桌上,那封聘书正静静地躺着。开慧把一杯茶轻轻放在了杨昌济手边,问:“爸爸,你真的不去孔叔叔那儿教书了?”正对着聘书提笔沉思的杨昌济放下笔,抚了抚开慧的头:“爸爸的事情实在太多了,除了周南那边的课,还想多留些时间,好好写两本书出来。”\n开慧想了想问:“可孔叔叔不是说,一个礼拜只要去上几节课吗?”杨昌济耐心地给女儿解释:“教书的事,你还不懂。台上一分钟,台下十年功,要上好一堂课,先得花十堂、二十堂课的精神备好课。爸爸总不能像那种照本宣科的懒先生,误人子弟不是?”\n开慧点点头,但又觉得不妥:“可是孔叔叔上次那样求你——”杨昌济看看乖巧的小女儿,笑了:“我为难的也就是这个。爸爸跟孔叔叔不是一般的交情,这个话确实是不大好说出口啊。算了,还是上一师去一趟,当面跟他赔个罪吧。”\n杨昌济还没有到一师大门口,便远远听到一片嘈杂声。看见有人在张贴红榜,这才明白是怎么回事,不由微微一笑,向办公楼走去。正见了一个校役,便烦他前去通报。校役匆匆来到了教务室外,将门推开一条缝,向里一瞄,只见里面坐满了老师,个个神色严肃,正在为什么事情忙着呢,赶紧把门掩上,回来看杨昌济坐在长廊的长椅上,不住地掏出怀表来看,回话说:“先生,实在对不起,孔校长现在真的忙着,还得麻烦您再等等。”\n杨昌济收起怀表,站起身来,掏出那份聘书,对校役说:“不好意思,麻烦你一件事好吗?今天我来,本来是为了退还孔校长这份聘书,既然他忙着,我就先不打搅了。麻烦你代我转交一下,告诉他恕我无法分身,不能从命,改日再登门向他谢罪。”说完,转身向外走去。\n校役答应着边走边打开了那份聘书:“杨……杨怀中先生?哎哟妈呀!”他照自己脸上就劈了一巴掌,忙不迭地快步跑到孔昭绶面前,把聘书递给他,结结巴巴地想说明白事情的来龙去脉。\n“你搞什么名堂?连杨先生的驾也敢挡?他人呢?”“刚刚走,这会儿只怕还没出大门呢……”孔昭绶当即一拍方维夏:“维夏,走,开锁的钥匙来了!”\n“昌济兄,昌济兄!”孔昭绶、方维夏从楼梯口匆匆追上正走出办公楼的杨昌济,声音大得几乎像是在喊了,“对不住对不住,不知道是你大驾光临,劳你久等了。走走走,先到教务室坐坐。”\n“坐就不必坐了。你不是正忙着吗?不用耽误时间陪我。要不,我就在这儿几句话讲完,还是为了上次的事……”孔昭绶打断了杨昌济的话,不由分说拉住他就往教务室走:“什么事我们回头再说,先跟我上楼去。我呀,正有一件事要请你帮个忙。走走走。”\n二人引着杨昌济走进教务室,孔昭绶一进门就说:“各位先生,跟大家介绍一下,这位就是板仓杨昌济先生。”\n“杨先生……板仓先生……”满座教师呼啦一下都站了起来,连向来倨傲的袁吉六都抢着迎上前来,问候道:“原来是大名鼎鼎的板仓先生,失敬了。”\n杨昌济拱着手回礼:“哪里哪里。”寒暄完毕,孔昭绶将两篇文章摆在了杨昌济面前: “孰优孰劣,请昌济兄法眼一辨。”\n杨昌济指着弥封上标的名次问:“可这不是已经定了名次吗?”不等孔昭绶解释,袁吉六先表了态:“原来那个不算数,初评而已,板仓先生不必放在心上,只管照您的看法来。”\n杨昌济有些疑惑了:“这到底是怎么回事啊?”孔昭绶笑说:“没怎么回事,就是请你看看文章,发表一下看法,真的没什么别的意思。”\n“那——我就先看看,要是有什么说得不对的,还请各位方家指正。”杨昌济拿起标着第一名的那篇看了起来:“《小学教育改良管窥》,标题倒也平实。”教务室里安静下来,所有的人都静静地等待着。\n刚刚看完开头,杨昌济已忍不住点头不止:“好!这个头开得好!”他接着往下看,越看越喜欢,不住地点着头:“嗯,精辟……好……不错不错,有见地……”\n袁吉六忍不住露出了微笑,颇有觅得了知音的得意。看完文章,杨昌济忍不住拍案叫绝:“写得太好了!昭绶兄,这是你的考生写的?”\n孔昭绶点点头。“哎呀,难得难得。文笔流畅,逻辑严密,于平实之中娓娓道来,虽然以全篇而言稍欠些起伏,但一个学生写得出这样的文章,已经是难能可贵了。昭绶兄,你这里有人才啊!”\n“先别着急夸奖,这儿还有一篇呢。” 孔昭绶又推过一篇文章来。 “哦,对对对。我都给忘了。”杨昌济拿起第二名的文章,《普胜法,毛奇谓当归功于小学教师,其故安在?》,不禁微微皱了皱眉:“这么长的标题?写小学教育还写到普鲁士打法兰西去了?倒也新鲜。”\n他接着看了下去,这一回却不像看上一篇,脸上原有的笑容渐渐凝结了起来,也没有了不住口的评价,反而越看越严肃,越看眉头皱得越紧。大家都专注地看着他,教务室里的气氛也不禁凝重起来。\n杨昌济很快看完了一遍,抬起头,仿佛要开口,大家正等着听他的评价,不料他沉吟了一下,却一言不发,又从头开始看起第二遍来,这回看得反而慢得多。\n凝重的气氛似乎都有些紧张了,教务室里安静得只剩了文章翻动发出的纸声。杨昌济终于缓缓地放下了文章。一片寂静中,孔昭绶试探着:“怎么样?”\n杨昌济说:“单以文笔而言,倒是粗糙了一些。”黎锦熙等不禁露出了失望之色,袁吉六则微笑起来。\n杨昌济接着说:“文章结构、论理之严密,尤其遣词用字这些细微之处,应该说是不及前一篇的。”\n孔昭绶点点头:“既然昌济兄也这么说,那……”\n杨昌济一抬手:“我还没有说完。单以这些作文的技巧来看,这篇文章确实略逊于前一篇,然则此文之中,越看越有一股压不住的勃勃生气,以小学教育之优劣,见战争之成败,国家之兴衰,纵横驰骋间豪气冲天,立意高远而胆识惊人。没错,胆识惊人,豪气冲天,就是这八个字!”\n激动中,他不禁站了起来,连声音都大了,“文采华章,固属难能,而气势与胆识,才是天纵奇才之征兆!此子笔下虽粗糙,胸中有丘壑,如璞中美玉,似待磨精钢,假以时日,当成非凡大器,非凡大器!”\n一片惊讶的肃静中,袁吉六缓缓站起身,走上前,提笔划去了两份卷子上已经标好的名次。他拆开前三名试卷的弥封,读出姓名:“第三名,萧子升;第二名,蔡和森;第一名,毛泽东。”\n这时孔昭绶也笑了起来,取出了那份聘书,“对了,昌济兄,今天找我什么事?不会真要把这封聘书退给我吧?” “恰好相反,”杨昌济转过身来,带着微笑说,“我是专程来告诉你,我接受你的聘请。”\n二 # 第三天便是开学的日子,灿烂的阳光里,一师大门口那幅“第一师范欢迎你”的崭新横幅分外耀眼。横幅下,入校的新生肩扛手提扁担挑,带着各色行李铺盖,布鞋、草鞋、长衫、短褂……汇集在一起,方维夏正带着陈章甫等一批老生在负责接待,偌大的前坪上,一片热闹。\n“蔡和森!”一只大手拍在蔡和森肩头,蔡和森一回头,毛泽东一手提着行李,正站在他身后,忙答应:“嘿,你好。”\n“哎,你分在哪个班?”毛泽东问。\n“本科第六班。你呢?”\n“我第八班。这么说我们不在一个班?搞什么名堂,我还想跟你同班呢。”毛泽东遗憾地说。\n“反正是一个年级,还不一样?”蔡和森嘴里这样说,心里却很感动,他没想到毛泽东会这么看重自己。\n正在这时,大门口传来了一个妇人不耐烦的叫声:“让开让开,怎么回事?还让不让人过路啊?”\n所有新生的目光都被这突兀的声音吸引了过去——大门前,王家的三乘轿子被人流挡住了去路,王夫人正掀开轿帘呵斥着挡路的新生们:“听见没有?都让开!你没看见他们挡着路啊?一群乡下土包子,连轿子都不知道让!”\n新生们人人侧目,但还是让开了一条路。可轿夫正要起步,方维夏走了过来,背着双手站在轿子前面,绷着脸说:“对不起,请下轿。”\n王夫人冲着他吼道:“下什么轿?我是来送我儿子读书的!”\n“本校规定,从这条线起,家长一律止步。”方维夏说着,指了指脚下齐着大门的一条白线,线后标着“家长止步”四个大字。\n王夫人摆足了阔太太架势,盛气凌人地冲着方维夏说:“我儿子来读书,我当妈的还不能进门了?你知道这是谁家的轿子吗?这可是王议员家……”\n“行了!”\n“妈,你嚷嚷什么?”\n王老板和王子鹏这时候已经从后面两顶轿子里下来,穿过拥挤的人群,来到了王夫人的轿子旁,异口同声地责怪着王太太。\n王夫人还想嚷嚷,看到丈夫的样子,又生生地把嘴边的话咽了回去。王老板黑着脸瞪了老婆一眼,又很快换了副笑容转向方维夏,说:“鄙人王万源,请教先生……”\n“本校学监主任,方维夏。”\n王老板拱手说道:“是方主任啊。犬子刚刚考上贵校,我们这是送儿子来报到的,还请行个方便。”\n“学生入校,一切自理,家长不得代劳,这是本校的规定。王先生,请将贵公子的所携用品交与他本人,学校自会安排他入住,你们父母就不必操心了。”\n王夫人看方维夏一点面子都不给,很是生气,嘟囔道:“那么多东西,他一个人怎么拿?”\n大家听她这样说,才注意到轿子后面堆积的东西简直都成了山。毛泽东一捅蔡和森:“我说,他们不是在搬家吧?”\n这话听着幽默,仔细一想却意味深长,学生们都大笑起来。王子鹏看了看周围的同学,不知道该说什么,红着脸回头看了一眼妈妈。他原本以为自己没有机会读一师:这次,一师只收80个学生,他偏偏考了个81名。可让人万万想不到的是,那个叫“向胜男”的第四名临时转学,他幸运地补缺被录取了。更让他想不到的是,当他去陶家报喜的时候,表妹斯咏居然眉开眼笑地恭喜他。这让他很开心,一直以来,他都不知道该怎样做才能让表妹满意。可到了准备来学校报名时,他的心情又烦躁起来了,因为妈妈对十来个人挤在一间破旧的宿舍里很不满意,接连几天都把家里的丫环、仆人使唤得团团转,说是收拾子鹏上学的行李,把箱笼、铺盖、各种日用品堆得到处都是,整得像是要大搬家似的。临了,还让秀秀一件一件地清查了好几遍,连一瓶雪花膏都不许漏掉。子鹏也觉得妈妈这样做很过分, 可他能怎么办呢?\n子鹏不知道怎么办,方维夏却知道,他果断地对王老板、王夫人说:“学生寝室,十人一间,你们带来的东西,两间房都装不下,就不必全带进去了,还是选些必要之物,其他的原样带回吧。”\n王老板和王夫人还在面面相觑,子鹏已经沉着脸,冲到行李堆前,乒乒乓乓地打开箱笼,王夫人和秀秀见了,赶紧上去帮忙。子鹏也不理睬她们,独自沉着脸,提着匆匆收拾起的箱子就往里走。王夫人捡起一瓶雪花膏,望着儿子的背影尖声叫道:“子鹏,子鹏,你的雪花膏!”看到儿子头也不回,她把雪花膏塞给抱了一满怀东西的秀秀,呵斥道:“还不跟着少爷!”\n雪花膏这样的东西,当时只有少数女人才用,很难得听说有男人用的。在同学们异样的眼光和笑声里,子鹏尴尬地埋着头冲进了学校。秀秀拿着雪花膏想跟去,却又被方维夏拦住了:“对不起,本校学生,毋需仆人侍候。”\n王夫人跟在后面问:“丫鬟都不能去?那谁给我儿子铺床啊?”\n不仅用雪花膏,还要丫环铺床!这次,连蔡和森都被逗笑了,更不用说毛泽东。校园里一时似乎变成了看杂耍的街头,哄笑声此起彼伏。\n子鹏终于忍不住了,停下来回头朝母亲吼了一句:“你够了没有?还不走!”说着,提着东西就想逃离这个让他很是尴尬的现场。可这人啊,越急越容易出事情,子鹏才一抬脚,“哗啦”一声,刚才仓促间没收拾好的箱子打开了,里面的东西撒了一地。秀秀赶紧上来帮他捡,子鹏恼火地一把扒开她的手:“你走开,我不要你动,我自己能行!你走啊!”\n王老板沉着脸扶住被儿子吓得直往后退的秀秀,对仆人们吼道:“都回去,听到没有,赶紧走!”\n众目睽睽下,子鹏涨红了脸,狠狠地收拾着满地的东西。众人嘲弄的目光压得他几乎抬不起头来,他觉得好孤单。但出乎他意料,一只手突然出现在他面前,捡起脸盆递给他。他一抬头,蔡和森正蹲在他身边,向他露出微笑。又一只手帮他捡起了东西,那是易永畦,紧接着是何叔衡、罗学瓒等,毛泽东却不屑地摇了摇头,对这种少爷他显然不愿意帮忙。他上去提起蔡和森的行李往背上一甩,扬长而去。\n三 # 刘三爹今天没有出去卖臭豆腐,因为他要送儿子去一师报名。刘家简陋的棚屋里,床头、地上,摆放着崭新的铺盖、脸盆等用品,刘俊卿的身上,更是一袭全新笔挺的长衫,与房里的寒酸破败形成了鲜明的对比。\n“到了学校,不比在家里。”刘三爹一面收拾箱子,一面唠叨叮嘱儿子,“你从小也没受过这个苦,这一去吧,我又照顾你不到,也不晓得你吃不吃得饱饭,穿不穿得暖衣,只能靠你自己凡事小心。”\n“知道了。”刘俊卿正对着镜子梳理着自己几乎是一丝不乱的头发。\n“你睡觉的时候,最听不得有人打呼噜,万一寝室里有那种打呼噜的人,你莫跟他讲客气,告诉校长,要他换寝室。还有,饭碗、脸盆这些东西,莫让其他人随便用,不干净。”\n他正想合上箱子,刘俊卿却皱起眉头从箱子里拎出一条旧短裤:“这么旧的还带?”\n刘三爹看看那条旧短裤比自己身上补丁摞补丁的衣服明显好得多了,想说反正是短裤,穿在里面别人又不知道。可看看刘俊卿的神情,这话却怎么都说不出口,赶紧将短裤拿了出来,还负疚似的不停地说:“不带,不带不带。”\n父子俩收拾停当,一前一后出了门:刘俊卿两手空空地走前面,刘三爹挑着满满一担行李,跟在后面。看看离学校已近,刘俊卿站住了,回头说:“爸,你送就到这儿吧。”\n“不是还没到吗?”\n“你把东西给我,我自己拿进去就行了。”\n“你哪会挑担子啊?”刘三爹挑着担子继续向前走去。\n“爸,爸!”刘俊卿追上去要拉父亲,看到旁边走过来两个拿着行李的新生,刘俊卿赶紧收住口,摆出一副泰然自若的样子,悄悄与他一身皱巴巴的父亲拉开了距离。\n刘三爹挑着行李,挤了到了校门口,迎头碰上了秀秀刚送走子鹏要回王家。父女俩正想说话,方维夏挡在了刘三爹前面,轻声说:“对不起,你不能进来。”\n“我是来送行李的。”刘三爹忙解释。\n“学校规定,行李一律由学生本人拿。”方维夏抬头,提高了声音,“这是谁的行李?”\n“这是……”\n“是我的。”本来与父亲拉开了距离的刘俊卿抢上前来,打断了父亲的话,伸手就来解行李。\n“哎呀,你哪里挑得担子?”刘三爹急了,抓着行李,对方维夏说,“这位先生,还是让我挑进去吧,他从来没挑过担子的。”\n“那就从今天开始挑!”方维夏的口气一下子变得严厉了。\n“可是……东西这么重,他会拿不动的。”\n“年轻人,这点东西怎么会拿不动?”方维夏看看刘俊卿,发觉他的穿着打扮与刘三爹实在不像一路人,又问,“这是你什么人啊?”\n“这是……”刘俊卿看看四周到处是人,憋了憋,居然说,“是……是……我雇的挑夫。”\n刘俊卿的这句话仿佛一记重锤,击得刘三爹全身一震,击得秀秀目瞪口呆!而刘俊卿似乎也被自己口中说出的话吓了一跳,慌乱中,他埋下头,伸手来解行李,却碰到了死死抓着绳子的父亲的手。儿子的手一伸过来,刘三爹就如触电般一抖,松开了手里的绳子。秀秀仿佛这才反应过来,她刚要开口,衣角也被父亲使劲地揪住了。\n刘三爹用力挤出一丝笑容,对方维夏说:“是,是挑夫,我是挑夫。”\n终于把俊卿的行李送进去了,秀秀和刘三爹一起出了一师,她可以陪父亲走到南门口,再分路回王家。秀秀一路上都不说话,只是不住地流泪。\n“你哭什么嘛?又没什么事。本来嘛,我这样子,多不像样,学校是个体面场合,你哥他也是没办法。” 刘三爹知道女儿在想什么,他劝慰着女儿,可劝慰来劝慰去,他越劝慰越觉得这种解释没有道理,叹了口气,在路边蹲了下来,自言自语似的说:“他不会是有心的,肯定不是有心的,只是一句话,不会是有心的。”\n秀秀站在父亲身后,看着父亲花白的头发,使劲擦了一把泪。\n四 # 对于任何一所学校来说,最热闹的时候,都莫过于新生入学那几天。面对浪潮般涌入的一张张满是渴望的、朝气勃勃的青春笑脸,有谁不会热血澎湃呢?看到他们,就等于是看到了一个无限广阔的美好未来呀!\n八班寝室里,新生们收拾着床铺及生活用品。王子鹏正试图把“第一师范”的领章钉上校服领子,却左弄右弄也钉不好。\n在子鹏对面的床上,刘俊卿正木然地扣着新校服的扣子。屋子里,就数毛泽东的动静最大,他收拾好床铺,捧起母亲那枚因为自己而被父亲砸瘪的顶针看了看,轻轻放到枕头下面,然后换上了新校服。他伸伸胳膊伸伸腿,好像总感觉校服小了一点。\n刘俊卿钉好扣子,穿上新校服,木然出了寝室,远远看见萧子升闷头坐在走廊栏杆上。他心里一紧:我不是把他的考卷给……为什么他还能考那么好的成绩?真是奇怪!正想着,只见萧三抱着两套新校服匆匆跑来,他装不认识,继续往前走了几步,躲到走廊的圆柱后面。身后传来萧家兄弟的对话:\n“哥,校服我领来了,你试试。”萧三说,萧子升却没有回答。“哥,来都来了,就别再东想西想了。那件事,都怪我和润之哥,不关你的事。”\n“怎么能说不关我的事呢?”“是我弄丢的文章,是润之哥要帮这个忙,你又不知道,哥,别坐在这儿了,回寝室吧。”随着一声轻轻的叹息,刘俊卿听到了萧家兄弟的脚步声,他从圆柱后面探出头来,望着萧氏兄弟离去的背影,他脸上的木然早已一扫而空,只剩了一脸阴沉沉的疑惑——“润之帮忙?”\n“送电了……送电了……”天黑了,随着校役摇动的铜铃声和喊声,一只手拉动电灯拉绳,室内电灯陡然亮起,照亮了全寝室的十个穿着崭新校服的青年。\n“各位各位,”周世钊拍了拍巴掌,示意安静,“从今天起,我们十个人就是同寝室的室友了,今天呢,也算是个室友见面会,借这个机会,大家互相认识一下,就从我这个寝室长开始,我姓周,周世钊,宁乡人。”\n同学们次第举手示意,介绍着自己:\n“罗学瓒,株洲人。”\n“易礼容,湘乡人。”\n“邹蕴真,湘乡人。”\n“易永畦,浏阳人。”\n“刘俊卿,长沙人。”\n“我叫王子鹏,也是长沙人。”\n“毛泽东,湘潭的。”\n周世钊笑说:“你就不用介绍了,状元嘛,谁不知道?”\n大家都笑了起来,只有刘俊卿冷着脸,望了毛泽东一眼。“那以后就这样排定了——润之兄就是我们寝室的老大,我老二。”周世钊一个个指点着,“老三王子鹏……”\n罗学瓒忙道:“不对不对,我比王子鹏大三天。”周世钊点头说:“哦,对,罗学瓒老三,王子鹏老四……”\n这时外面走廊上孔昭绶与方维夏并肩走来,听到笑声,孔昭绶不由得停住了脚步,走进门来,笑说:“嘿,好热闹啊。”学生们一时都站了起来问好。方维夏说道:“各位同学,给大家介绍一下,这位就是本校的孔昭绶校长,孔校长今天是专门来看望新同学的。”\n孔昭绶和蔼地摆摆手,示意大家都坐下,说:“大家不用客气,都坐吧。我和大家一起聊聊天,好不好?”\n“好的好的,我正有一个问题要问,那就是我们为什么要读师范?”毛泽东倒有些考这个校长的意思,要知道这个题目说大不大,说小不小,一般的老师大可以拿一番套话来敷衍。\n这时刘俊卿忙不迭地给孔昭绶与方维夏倒来了水。孔昭绶沉吟一时,说道:“诸位今日走入师范之门,习教育之法,今后也要致力于民国之国民教育。如果不解决读书的目的这个问题,则必学而不得其旨,思而不知其意。到头来,不明白自己五年的大好青春,一番工夫都下在了哪里。”\n孔昭绶喝了一口水,停了一停:“要回答这个问题,我想,要从我们第一师范的办学宗旨讲起。”他缓缓口气,“大家都知道,一师素称千年学府,自南宋理学大儒张栻张南轩先生在此地创办城南书院发祥,800余年间,虽天灾,虽战祸,虽朝代变迁,帝王更迭,而绵绵不息直垂于今日……”\n孔昭绶侃侃而谈,一双双脚步悄悄停在了门外,一个个经过的学生静静地站在了门口,“如孙鼎臣、何绍基,如曾国藩、李元度,如谭嗣同、黄兴,历代人才辈出而灿若星辰,成为湖湘学派生生不息之重要一支,为什么?我想,一句话可以概括:经世致用。\n“何谓经世?致力于国家,致力于社会谓之经世;何谓致用,以我之所学,化我之所用谓之致用。经世致用者,就是说我们不是为了读书而读书,我们读书的目的,我们求学的动力,是为了学得知识,以求改变我们的国家,改变我们的社会。那种关进书斋里,埋头故纸堆中做些于国于民无关痛痒的所谓之学问,不是我湖湘学派的特点,湖南人读书,向来只为了两个字:做事!做什么事呢?做于国于民有用之事!”\n毛泽东迫不及待地插嘴道:“那——什么事于国于民最有用呢?”孔昭绶看了他一眼,沉默一时说:“乱以尚武平天下,治以修文化人心。以今时今日论,我以为首要大事,当推教育。我中华百年积弱,正因为民智未开,只有大兴教育,才能以新知识、新文化扫除全民族的愚昧落后,教育人人,则人人得治,人人自治,则社会必良,社会改良,则人才必盛,真才既出,则国势必张……”\n孔昭绶又喝了口水说:“以此而推论,当今之中国,有什么事比教育还大?欲救国强种,有什么手段比教育还强?所以,读师范,学教育,他日学成,以我之所学,为民智之开启而效绵薄,为中华之振兴而尽一己之力,这,不正是诸位经世致用的最佳途径吗?”\n一片沉思的寂静中,孔昭绶的身后,突然响起了掌声。孔昭绶一回头,发现身后居然密不透风地挤满了学生。\n第五章 欲栽大木柱长天 # “ 自闭桃源称太古,欲栽大木柱长天。 ”\n无为官之念,无发财之想,\n悄然遁世,不问炎凉,愿与诸君之中,\n得一二良材,栽得参天之大木,\n为我百年积弱之中撑起一片自强自立的天空。\n一 # 在这样一个特殊的夜晚,一师的校园里却有一个人和这喧闹的气氛格格不入。他就是萧子升。在一师的草坪上,子升一人缓缓地踱着步子。微风轻袭,掠动着他整洁的长衫,却似乎吹不走他心头的烦闷。他仰起头,凝视着夜空中那纯净无瑕的月亮,深深地吸了一口气,像是终于下定了决心,毅然转身往八班寝室走去。\n此刻的八班寝室里,毛泽东、 周世钊、 罗学瓒、 易礼容、邹蕴真、 易永畦、刘俊卿、 王子鹏他们依然一个个情绪高昂,他们在带头为孔校长的演讲鼓掌之后,又七嘴八舌地恳请方维夏也给大家说点什么。\n方维夏看了看众人,一时盛情难却:“那我就说两句。孔校长刚才给大家讲了为什么读书的大道理,我不会讲什么道理,就跟诸位提个小小的要求吧:有书读时,莫闲了光阴。年轻人最怕没有定力,无书读时盼书读,有书读时,却总不免有一些耽于游玩而疏于用功的人,总觉得明日复明日,明日何其多。其实这世上最易逝的,便是光阴。岳武穆云:”莫等闲,白了少年头,空悲切。‘青春只有那么几年啊,过去了,是追不回来的。所以,我只希望各位在校期间,多读书,读好书,今后,回想起你在一师的生活时,你能毫无遗憾地对自己说,我这五年,真正用在了读书上,真正学了该学的东西,我没有虚度光阴。如果能做到这一点,那就是你这五年师范生活的成功,就是第一师范教育的成功!试问诸君可能做到?“\n同学们沉默着,似乎都在思考这个问题。刘俊卿却翻开新课本写一行字,放下毛笔率先打破沉默说:“校长和学监的教诲,俊卿与诸位同学一定牢记在心,决不辜负学校的期望。”\n孔昭绶拿过刘俊卿的课本,看见扉页上是他刚刚写下的“书山有路,学海无涯”八个字,字迹工整,颇见功力,含笑点头说:“嗯,字写得不错嘛。”\n刘俊卿一脸诚恳地望着校长回答:“这是校长和学监的教诲,俊卿自当视为座右铭。”孔昭绶赞许地看着他,正要开口,身后却传来一个声音:“请问,毛泽东在吗?”\n毛泽东一回头,原来是子升挤进了寝室,忙站起身说:“子升兄?哎,来来来,快来快来。”\n“润之,我有点事找你……”\n“你先进来再说。”毛泽东上前一把将他拉了过来,“跟你介绍,孔校长,方学监。”\n子升一直沉浸在自己的心事里,显然没有去想这外面围了那么多学生的原因。他被吓了一跳,立即恭敬地向二人问好。\n毛泽东说道:“这是我的老同学,萧子升,这回刚考进讲习科的。”孔昭绶上上下下地看着眼前这个文弱清秀的青年,赞叹道:“萧子升?哦——我记得你,第三名嘛。你还有个弟弟,一起考上的,叫萧植蕃,你第三,他第五,两兄弟一起名列前茅,不简单啊。”\n方维夏在旁边提醒道:“不光是考得好,校长,你还记不记得他那手字?”孔昭绶恍然大悟,脸上越发的惊喜了,笑道:“怎么会不记得?飘逸灵秀,有几分大家神韵嘛。这评语,还是在看你填的报名表的时候,袁仲谦老先生给你下的呢。当时黎锦熙先生也说,就凭你的字,我们全校的先生都找不出一个有那份功力的。”\n子升看看毛泽东,却心不在焉,“校长谬赞,子升愧不敢当。”毛泽东不管子升的脸色好看不好看,生怕人家不知道他的朋友有多厉害,继续做他的宣传工作:“校长,子升可不光字好,他还有个绝活,天下无双。”\n孔昭绶感兴趣地问:“哦,什么绝活?说说看。”子升拉了一把毛泽东,毛泽东大声说:“你扯我干什么?本来就是天下无双嘛。他呀,不光右手写得,左手也写得,两只手一起,他还写得。”\n孔昭绶有些不相信:“两只手一起?”毛泽东把子升推到桌子前面,边摆纸笔边说:“就是左手右手一边一支笔,同时写字,而且是写不一样的字,写出来就跟一只手写的一样。”\n这招功夫显然让大家都来了兴趣。孔昭绶说:“还有这种绝招?这倒是见所未见啊。”方维夏也说:“萧同学,就这个机会,给大家表演一个,让我们也开开眼界,好不好?”\n子升还想谦虚:“一点微末之技,岂敢贻笑大方。”孔昭绶鼓励他:“你那个字要还是微末之技,别人还用写字吗?来,表演一个,表演一个。”\n毛泽东拍拍他的肩膀,说:“你就莫端起个架子了,都等着你呢。”子升实在没办法,只得说:“那,我就献个丑。”\n“这就对了嘛。有本事就要拿出来。”毛泽东说着话,将两张桌子拼在了一起,铺开雪白的纸,并随手把刘俊卿那本刚题好字的书丢到旁边。\n刘俊卿看到毛泽东这个动作,脸沉了下来,子升显示出的吸引力已经令他感到了冷落,这时更是平添一股被忽视的难堪,他悄悄收起了那本书。\n子升提着笔,犹豫着:“写点什么呢?”孔昭绶想了想,说:“嗯——就以读书为题,写副对联吧。”\n子升点点头,略一思索,两支笔同时落在纸上,但见他左右开弓,笔走龙蛇,却是互不干扰,一副对联顷刻已一挥而就:“旧书常读出新意,俗见尽弃做雅人”,整副对联完美无缺,竟完全看不出左右手同时书写的迹象。\n“好,字好,意思更好!”孔昭绶向子升一竖大拇指:“萧子升,奇才啊!”毛泽东搂住了子升的肩膀,兴奋地打了他一拳。一片啧啧称奇之声中,刘俊卿阴沉着脸,狠狠合上了自己那本书,眼睛眯了起来。\n“电灯公司拉闸了……各室赶快关灯……油灯注意……小心火烛。”吊在铁钩架子上的油灯叮叮当当地撞击着,值夜的校役敲着竹梆,在校园里边走边喊。\n随即整个校园里的电灯一下子熄灭了,同学们纷纷回了各自寝室。孔昭绶卷着那副对联,意犹未尽地说:“萧同学,这幅字就当送我的见面礼了,回去我就把它挂到校长室去。”\n子升有些难为情,“信手涂鸦,岂敢登大雅之堂。”“我不光要挂起来,以后其他学校来了人,我还要逢人就说,这是我一师学生萧子升的手笔,也让别人好好羡慕羡慕我这个当校长的!”孔昭绶收起笑容,环顾着学生们,“但愿各位同学更加努力,人人都成为我一师的骄傲!”\n同学们齐声答应,一时就散了,子升看着毛泽东,叹息一声,朝六班萧三的寝室走去。只有刘俊卿在那里没动,他咬了咬嘴唇,忽然快步赶上两位先生,说:“校长,我,刚才看到萧子升同学的书法,实在是很佩服,很想多学习学习。可手边又没有他的字……”\n“嗯,见贤思齐,这是求上进嘛。是不是想要这幅字啊?”孔昭绶问。“这是校长喜欢的,学生怎么敢要?”“没关系,你想学,我可以先借给你。”\n刘俊卿猛地挺了挺腰杆,语调很快地说:“不不,这幅字就不必了,我是想萧子升不是也参加了入学考试吗?那是整篇文章,字数更多,既然出自他的手,想必也是书法精品,所以……”\n方维夏听了这话,眉心突然一跳,仿佛想起了什么,他轻轻一拉孔昭绶,打断了他的话:“刘同学,今天太晚了,你先回去休息,文章的事,以后再说吧。”\n“是,方老师。那,我先回去了。”刘俊卿如释重负地转身离去。孔昭绶疑惑地问道:“维夏,你这是怎么了?”方维夏没有答话,脸上的神情却很是严肃。\n回到教务室,方维夏点亮了油灯,一把拉开柜子,急匆匆地搬出厚厚的入学考试作文,放在桌上,把前两名的文章放在一起,拿出第三名萧子升的文章,对孔昭绶说:“校长,您把萧子升那幅字打开看看。”\n孔昭绶疑惑地摊开了那副对联。方维夏将子升的文章摆在对联旁,拨亮油灯。油灯下,文章的字迹与对联上的字分明完全两样。\n孔昭绶的眉头皱了起来:“这字不对呀!怎么会这样?”方维夏说:“其实上次在教务室看到这批考卷的时候,我就曾经有过一种不对劲的感觉,可又说不出是为什么,正好仲老和锦熙为了一二名的次序争起来,这一打搅,我也就未加深思。可是刚才,那个刘俊卿的话提醒了我,萧子升这篇入学考试作文,绝不可能出自他的笔下!”\n“不是他,那会是谁?”两个人的目光同时停在了旁边的一篇作文上——那是被方维夏放在旁边的头两名的作文,上面一篇正是毛泽东的。两篇字迹一模一样的作文被移到了一起,孔昭绶几乎不敢相信自己的眼睛,“毛泽东?”他脸色一变,转身就要下楼。\n方维夏提着油灯跟在他后面:“校长,今天,是不是太晚了?”“不管有多晚,这件事必须马上处理,不能过夜。”\n“可是,他们都是您赏识的人才……”“人才?有德才有才!若是有才无德,将来只会成为更大的祸害!连基本的诚实都没有,代考舞弊这种事也敢做,不处理还了得?走!”\n子升回到寝室,萧三已经上床了,他迷迷糊糊地被哥哥拉到草坪上,一听哥哥唧唧歪歪地说起卷子的事情,火了:“哥,代考的事,怎么能怪润之呢?”他的声音不小,把正从走廊那头急急忙忙走过来的孔昭绶和方维夏吓了一跳。拐角处,孔昭绶收住了脚步,抬手示意方维夏放轻脚步。\n“文章我们都写了,它不是突然丢了吗?润之哥是怕我们耽误了船,才帮我们代写的。人家是一番好心,要怪,就怪我不该丢了文章。”\n“你说的这些我都知道。可这毕竟是作弊,用这样的手段考进学校,岂是君子之所为?”“哥,道理我都知道,我也后悔,可事情已经这样了,还能怎么办呢?”\n孔昭绶与方维夏贴墙而立,方维夏悄悄调小了油灯的光芒。子升的声音清晰地传来:“其实这两天,我一直在犹豫,想把这件事跟学校坦白出来。刚才我甚至都到了润之的寝室,想告诉他这个想法,可是……当时的情形你也知道了,校长、学监都在,润之呢,情绪又那么高,我是实在说不出口啊!再怎么说,润之也是为了我们兄弟好,虽然事情做错了,可要因此害得他读不成书,我总觉得……”\n“哥,其实要我说呢,凭你的文章,又不是真的考不起。真要考,第一名还未见得是润之呢,你又何必这么想不开?”\n“这不是考不考得起的问题!我当然知道我们考得起。可做人不能暗藏欺心,不能光讲结果,不论手段,你明不明白?我已经想过了,这件事,只有一个办法解决。”\n“什么办法?”\n“退学。明天就退,我们一起退。学校,我们可以再考,但良心上的安宁丢了,你我这一辈子都不会安心的。子暲,君子坦荡荡,这是做人最基本的原则啊!”\n“那,润之哥那边……”“润之那边,明天我会去跟他解释,我想,他会明白的。”\n孔昭绶略一沉吟,转过身,示意方维夏跟他退后,悄声说:“去找毛泽东。”\n毛泽东和蔡和森这时候也还没有睡觉,两人在学生寝室外的走廊上头碰着头,借着烛光,正在读课本上一师的校歌歌词。\n“……人可铸,金可熔,丽泽绍高风……多材自昔夸熊封,男儿努力蔚为万夫雄!”毛泽东压着声音朗诵着,声音里却透着压不住的激动,“写得多好啊!我一读到这歌词,心里头就像烧起一团火一样!”\n“是啊,男儿努力蔚为万夫雄!”看来蔡和森也一样激动。\n“哎,不瞒你说,其实一开始,我根本没打算考一师。”毛泽东说,“我那个时候,一门心思就想考北大,哪想过什么第一师范啊?可我们家不给我钱,人穷志就短,这里又不要钱,没办法,只好考到这儿来了。”\n“那现在还后悔吗?”蔡和森问。毛泽东笑道:“后悔?后悔没早考进来!今天我才知道,我们的先生是什么样的先生,我们的学校是什么样的学校!一句话,来这里,来对了,来得太对了!对我的胃口!”\n毛泽东的声音越来越大,却不知道孔昭绶与方维夏正静静地站在他们身后不远处。“我毛泽东没别的本事,就一条,认准了的事,我一条路走到黑,就在这里,就在这所第一师范,我死活要读出个名堂来!”\n蔡和森压低嗓门劝他:“润之,你声音小点。”孔昭绶一言未发,向方维夏摆了摆手,两个人顺着来路悄悄退了回去。望着沐浴在月光下的一师主楼,孔昭绶长长舒了一口气,对方维夏说:“学校是干什么的?不就是教育人的吗?人孰能无过,无过岂不成了圣人?那还要我们教什么?他们都还是孩子嘛,不论犯过什么错,都是进校以前的事了,只要知错能改,诚心上进,我不信在我们一师,在你我手上,教不出堂堂正正的君子来。明天……给他们一个主动的机会,等到明天。”\n方维夏郑重地点了点头。\n二 # 第二天一大早校长的办公桌上,一方刻着“知耻”字样的镇纸压着两份退学申请。孔昭绶的目光,从萧子升移到萧三,由萧三再移到毛泽东的身上。三个人垂手站在办公桌前,紧张中,都带着不安。\n“毛泽东同学,”孔昭绶终于开口了,“你的两个朋友已经决定以退学来承担良心上的责任,并没有牵连到你,你为什么还要主动来承认代考的事?”\n毛泽东笔直地站着,说:“因为代考是我主动提出来的,文章也是我写的,我的错,我承担。校长,无论您怎么惩罚我,我都接受,可他们真的没有主动舞弊,请您给他们一个机会吧……”\n“不!”子升打断了他,“这不关你的事!校长,事情由我们而起,是我们没有经过考试进了学校,责任应该由我们负……”\n“好了,你们也不要争了。责任由谁来负,该由我这个校长来决定吧?”孔昭绶说着,把两张雪白的纸和笔墨文具摆在了萧氏兄弟面前。\n子升一时没明白:“校长……”\n“怎么,刚刚考过的试,就不记得考题了?行,我提醒你们一句:论小学教育,标题自拟,篇幅不限——隔壁办公室已经给你们准备好了,由方主任给你们监考,补考时限两小时,够不够?”\n子升、萧三与毛泽东面面相觑,愣了一阵,这才反应过来,子升:“够!用不着两个小时,20分钟就够。”\n孔昭绶皱皱眉头:“20分钟?”“我自己写过的文章,每一个字我都记得。”子升喜出望外。\n孔昭绶问萧三:“你呢?”“我也一样,没问题!”萧三欢喜得只差没跳起来了。孔昭绶点头说:“好,那就20分钟。”拿起纸笔文具,子升与萧三激动得同时向孔昭绶深深一鞠躬:“谢谢校长!”\n两个人匆匆出门,子升又站住了:“校长,那润之……您能原谅他吗?”\n“这不是你现在该关心的问题。”看看面无表情的孔昭绶,再看看正用目光催他快走的毛泽东,子升也明白自己多说无益,只得退出了房间。\n“校长,谢谢您原谅他们。”毛泽东也向孔昭绶鞠了一躬。“可我没说过要原谅你哦。”\n“我明白。”正视着孔昭绶的眼睛,毛泽东目光坦然,“这件事的错误本源在我,一切责任本该归我承担。”\n“那,说说你错在哪儿?”“我……我不该随便帮朋友。”\n孔昭绶摇了摇头:“错。友道以义字为先,你帮朋友,我并不怪你。但君子立身,以诚信为本,义气是小道,诚信为大节。你的行为,耽于小义而乱大节,是谓本末倒置,本末倒置,则既伤己身,又害朋友。这才是你的错误之所在。”\n毛泽东沉默半晌,默然点头说:“我明白了,校长。”“真的明白了?” 孔昭绶肃然看着他。\n“真的明白了。校长,请您现在就处罚我吧。” 毛泽东低着头说。孔昭绶点了点头,“你能主动走进这间办公室,坦白自己的错误,我相信你是有诚意的。当然,绝不等于我不处罚你。”他拉开抽屉——抽屉里,是一面折得整整齐齐的旗帜。旗帜展开了,那是一面深蓝为底,正中印着庄重的“师”字白徽的一师校旗。\n“这是我一师的校旗,也是我一师光荣的象征,它有蓝色的坚强沉稳,更有白色的纯洁无瑕。它的洁净,不容沾上一点尘埃,它的诚实、理想、信念、光荣,更不容任何玷污!”孔昭绶将校旗递到了毛泽东面前:“把它接过去。”\n待毛泽东接过了校旗,孔昭绶又说:“一会儿就要开新生入学典礼了,我希望由你在典礼上升起这面校旗,我也希望从今以后,每当你看到这面校旗,都能想起今天犯过的错,都能告诉自己:从今天开始,从现在开始,我毛泽东,将光明磊落,无愧于这面校旗!这就是我对你的处理,能接受吗?”\n捧着校旗,仿佛捧着巨大的重托和承诺,毛泽东用力地一点头。\n三 # “何谓修身?修养一己之道德情操,勉以躬行实践,谓之修身。古人云:修身齐家治国平天下。也就是说,修身是一个人,一个读书人,一个想成为堂堂君子之人成材的第一道门坎。己身之道德不修养,情操不陶冶,私欲不约束,你就做不了一个纯粹的人,一个高尚的人,一个精神完美的人,齐家治国平天下这些作为就更无从谈起。那么,什么是修身的第一要务呢?两个字:立志!”\n这一天上午,本科八班教室里,杨昌济正在给学生上第一节修身课。\n他在黑板上用力写下“立志”二字,转过身来继续讲:“孔子曰:三军可夺帅也,匹夫不可夺志也。人无志,则没有目标,没有目标,修身就成了无源之水。所以,凡修身,必先立志,志存高远而心自纯洁!”\n讲到这里,他沉吟一时,然后走下讲台,来到学生中间,说道:“下面,我想请在座的各位同学谈一谈你的志向是什么。”他看看身边课桌上贴着的学生姓名:“周世钊同学,就从你开始吧。”\n周世钊笔直地站起来,朗声答道:“我的理想,是当一个学校的校长。”杨昌济颇感兴趣地问:“哦,为什么?”\n“我小时候每天早上都看到学校的门口,所有的学生向校长敬礼。我想我长大了,也要像他一样,那么威严,那么受人尊敬。我考入师范,就是为了实现这个理想。”\n“很好。”杨昌济微微一笑,说:“下一位,罗学瓒同学。”“为国为民,舍生取义,做一个像戊戌君子中的谭嗣同那样的人。如国家有事,则奋不顾身,死而后已。”\n杨昌济点点头,说:“舍身成仁,高洁之至,很好。易永畦同学。”易永畦有些紧张地站起:“我……我不知道该怎么说……”\n杨昌济鼓励他说:“不要紧张。你从小到大,总有过这样那样的梦想吧?就算是天真得不切实际,或者平凡得不值一提,都不妨一说,姑且言之嘛。”\n“我……我想当三国里的关云长大将军。”易永畦话音才落,教室里就有不少同学小声笑了起来,易永畦那副单薄如纸的身材实在不能让人把他跟武圣人关云长联系起来。\n“嗯,纵横沙场,精忠为国。虽然是童真稚趣,却存英雄之气,好!下一个,刘俊卿同学。”\n刘俊卿显然早已准备好答案了,他站起来,很自负地回答:“学生的理想,就是要好好读书,将来做一个学识渊博、为世人所景仰、为政府所器重的社会精英,凭自己的学问和才能,傲立于天地之间。”\n“傲立于天地之间?因为学问而傲吗?”杨昌济问。“是,老师。只有学识出众之人,才能为人所敬重,学生就是要做这样的精英。”\n杨昌济似乎想说什么,想想又收住了口:“你坐下吧。”他看看桌上的姓名,认真打量了毛泽东一眼,问,“你的志向是什么?”\n毛泽东站了起来,犹豫了一下,茫然地回答:“我不知道。”“不知道?”在全班同学的窃窃私语中,杨昌济皱起眉头,问:“一个人对自己的未来怎么会没有一点想法呢?难道你从来就没有想过?”\n“我想过,经常想。可是,我找不到答案。”毛泽东望着老师,他的目光清澈如水,他的话显然出自真心。“嗯,路漫漫其修远兮,吾将上下而求索。毛君亦在求索之中么?”“求学即求索。”\n杨昌济若有所思地点点头,对毛泽东说:“你坐下吧。”“老师,”毛泽东刚坐下,却又突然像是想起了什么,站起来问:“能不能问您一个问题?您的志向是什么?”\n毛泽东的大胆实在有些出乎教室里所有人的意料。同学们不禁一愣,杨昌济也有些意外地回过身来。他望着毛泽东的眼睛,那双眼睛平静却隐隐地含着让人必须面对的刚毅。一片静默中,杨昌济走上讲台,拿起粉笔,刷刷地在黑板上写了两行苍劲有力的大字:\n自闭桃源称太古\n欲栽大木柱长天\n一片肃穆中,杨昌济用极为平和但却坚定的语调说:“昌济平生,无为官之念,无发财之想,悄然遁世,不问炎凉,愿于诸君之中,得一二良材,栽得参天之大木,为我百年积弱之中华撑起一片自强自立的天空,则吾愿足矣。”\n一片寂静之中,周世钊、刘俊卿带头鼓起掌来,掌声立即响成了一片。只有毛泽东仍站在那里,望着老师,没有鼓掌。杨昌济挥手止住掌声:“毛泽东同学,今天你没有回答我的问题,我也不要求你马上回答,但有一件事我希望你能答应我。五年后,当你迈出一师校门时,我想听到你回答我,你的志向是什么。能答应我吗?”\n毛泽东还在揣度着老师写在黑板上的“志向”,想着能说出眼前这十四个字的人会是一个什么样的人、什么样的老师,想着什么是他眼里的桃源、太古、大木、长天?时至今日,他辗转上过好几所学校,见过数十位老师,却没有谁说过如此让他深思的话。毛泽东看着老师正凝望着自己的眼睛,郑重地点了点头,说:“我答应您,老师。”\n四 # 下午,杨家小院内里,杨开慧正在送爸爸出门去周南。她一边翻看着《普胜法,毛奇谓当归功于小学教师,其故安在?》,一边问爸爸:“他真的就什么也没说?文章写得这么好,怎么会没有理想呢?这个学生真怪啊。”\n“是的,他什么也没说。”杨昌济指着小院里花台上洒水的“壶”,风趣地解释道,“当然他没说并不意味着他没有,而是不肯轻言——有时候,鸿鹄,也要岁月磨炼方成的。”\n“爸,你怎么知道他就有鸿鹄之志?说不定是燕雀之志呢?”开慧还没见过爸爸这样评价一个学生,和爸爸开起了玩笑。\n“不会的。”杨昌济肯定地回答女儿。\n“为什么,就因为文章写得好吗?”\n杨昌济已经出了院门,听到女儿这样问,回过身来意味深长地说:“不光是文章。还有那双眼睛,明亮、有神——坚定!那不是一般年轻人能有的目光。由目可视其心,那样的目光,必定心存高远。”\n开慧对爸爸的话似懂非懂,但对爸爸的心思却是完全明白的。她把拿着文章的手背到身后,站在爸爸面前,注视着他的脸,调皮地问:“爸,你什么时候变成看相先生了?”\n“爸爸可不会看相,”杨昌济微微一笑,表情反倒严肃了,“爸爸看的,是那股精气神。”\n杨昌济来到周南女中,一片绿树苍翠之中是一副“周礼盍在,南化流行”的对联。他进到教室,一节课上完,说道:“今天给大家下发两篇范文,是第一师范本次入学考试中头两名的文章,也是我很欣赏的两篇文章。当然,作文之人年识尚浅,文章自非十全十美,但第一名这篇的气势和胆识,与第二名这篇的平实稳重,确有值得效仿之处。且文章为各位同学的同龄人所作,更有其借鉴意义。今天发给大家,希望大家课后细细品味,找一找自己的作文与这两篇文章之间的差距。”\n油印的文章在学生们手中依次向后传递着,学生们认真看着,相互悄声交流着。斯咏与警予同时捧起了文章,入神地看着。过了一会,放下了那篇蔡和森的文章,警予把手一摊,吐着舌头,眼睛瞪着天花板,说:“这么好的文章,让人还怎么活嘛?”\n“哟,今天太阳从西边出来了,我们向女侠居然也有服人的一天?”斯咏看看左右,悄声打趣警予。\n“人家是比我们强,比我们强我们当然得服。”警予一副梁山好汉的样子。\n斯咏拿着文章翻来覆去地看着,问警予:“哎,你觉得这两篇里头,哪篇更好?”“当然是这篇,蔡和森的。”警予毫不犹豫地说。\n“怎么会是这篇呢?你看看,从头到尾,唧唧歪歪,除了板着个脸讲道理,还是板着个脸讲道理,文似看山不喜平嘛,一篇文章作得这么四平八稳软绵绵的,有什么意思?”\n“这叫平中见奇,什么软绵绵的?”\n“反正啊,我还是喜欢这篇,多有气势。”斯咏坚持着自己的观点。\n“毛泽东这篇啊?去,你自己看看,从头到尾,咿里哇啦,除了扯着个嗓子大喊大叫,还是扯着个嗓子大喊大叫,文章就是要平实稳重嘛,有必要搞得这么气势汹汹的吗?”\n“你平时不就气势汹汹的,怎么,倒看不上气势汹汹的文章了?”斯咏看看警予,突然觉得她今天变得有些怪怪的。\n“谁平时气势汹汹的了?我对你凶过吗?算了算了,不跟你争。”警予转头问旁边的一个秀秀气气的女生,“一贞,你说说,这两篇文章哪篇好?”\n“都好。”赵一贞一笑两个酒窝,甜甜的。“我是说哪篇更好?”警予才不给她和稀泥的机会。\n“反正……都好嘛!”\n“什么都是好好好,你啊,整个一个好好小姐!”警予不和她说,又转头朝着斯咏,见她正爱不释手地读着毛泽东的文章,便故意拿起蔡和森那篇在斯咏面前晃着说:“我要把这篇文章贴在寝室的床头,每天看三遍!”见斯咏不理睬自己,她又盯着蔡和森的文章,凶巴巴地悄声说:“姓蔡的,你等着,总有一天,我要超过你!”\n"},{"id":125,"href":"/zh/docs/culture/%E6%81%B0%E5%90%8C%E5%AD%A6%E5%B0%91%E5%B9%B4/%E7%AC%AC11%E7%AB%A0-%E7%AC%AC15%E7%AB%A0/","title":"第11章-第15章","section":"恰同学少年","content":" 第十一章 过年 # 到了新年,毛家院子里,毛贻昌一身半旧的长袍马褂,正在端正自己的瓜皮小帽;泽建一身新花衣,扎着红头绳,蹦过来跳过去;毛泽东站在凳子上,正在泽覃泽建的指挥下贴着自己刚刚写好的对联。\n一 # 放假了,过年了,刘俊卿的心情特别好。虽然他只考了第三名,但在放假的前一天,纪督学特地把他叫到了督学办公室,拉着他的手说:“俊卿,老师心里闷,闷得很!老师难啊,大好的一所学校,怎么就搞成了这个样子?这是怎么回事嘛?这所学校,老师是彻底死心了!老师现在就剩了一个念头——你,可不要上那些乌七八糟的什么新教育观念的当,一定要踏踏实实,好好读书,考出好分数,给老师争口气。只要你好好学出个样子来,到时候,你的前程,包在老师身上!”\n“你的前程,包在老师身上!”这话像天上的福音一样,让刘俊卿振奋,他从这句话里似乎已经看到了自己辉煌的前程。迫不及待地,他想让心爱的人来分享自己的好心情。\n在离茶叶店不远的小街拐角处,刘俊卿与赵一贞依偎在淡淡的月光下说着知心话: “其实一二三名不都差不多,你何必对自己要求那么高呢?”\n“可我答应过你,我要考第一的。”\n“不管你考第几,我都不在乎。”\n“可我在乎。”刘俊卿叹了口气,“你知道吗?师范生就一条出路,当小学老师,小学老师啊!除非我有出类拔萃的成绩,否则,我就改变不了这个命运。”\n“可小学老师也不错呀。”\n刘俊卿不禁苦笑,“一辈子站讲台,吃粉笔灰,拿一点紧巴巴的薪水,跟一帮拖鼻涕的娃娃打交道,这就算不错吗?就算我能受得了,可我总不能让你跟着我这样过一辈子啊!”\n一贞捧住刘俊卿的脸,摇摇头:“我不在乎,俊卿,我真的不在乎,不管有没有人成绩比你好,不管你是不是教一辈子书,在我心里,你永远是最优秀的,永远。”\n端详着一贞清纯的脸,刘俊卿禁不住轻轻吻在她的面颊上:“一贞……”一贞将头埋进了他怀中。\n“我不会辜负你的!”仰望着月光,刘俊卿喃喃自语,仿佛是在向一贞立誓,又仿佛是在说给自己听。突然,一贞惊得弹了起来:“爸?”刘俊卿猛一回头——赵老板面如严霜,正站在拐角处!\n自那天赵老板把一贞拉走后,刘俊卿便再没有见过一贞了。他虽然无时无刻不在想着一贞,但却没有胆子去赵家的茶叶铺。转眼就到年三十了,简陋的棚屋门口,刘俊卿一身崭新的长衫,正拿着一副春联,在往土坯墙上比着贴的位置——春联上是他工整的字体。\n“俊卿,你饿不饿?要不,我先给你做点吃的。”刘三爹心疼地招呼儿子。\n刘俊卿懂事地说:“不用了,还是等阿秀回来,一起团年吧。”\n“也好。过年嘛,他王家准又得赏几样好菜,留着肚子,等你妹妹回来再吃也好。”\n就在这时,随着一阵急促的脚步声,传来一贞的声音:“俊卿。”\n“一贞?”刘俊卿大吃一惊:出现在他面前的,真的是跑得气喘吁吁的赵一贞,“你怎么来了?”\n带着喜悦,更带着几分羞涩,一贞使劲平静着过于激烈的呼吸:“我……我爸他说……请你上我们家去吃团年饭!”\n“你说什么?”刘俊卿简直不敢相信自己的耳朵。停了两秒钟,他这才反应过来:“这是真的?”\n忍着激动与呼吸,一贞用力点了点头。巨大的惊喜令刘俊卿张大了嘴,愣了一阵,喜极的笑容才绽放在他的脸上:“哎,我去,我……我换双鞋就去!”\n年夜饭吃过,一贞正在收拾着残羹冷炙。世故的赵老板剔着牙,点着了一支烟,吐出一口烟雾,这才盯着局促地坐在他面前,带着几分希望,忐忑不安地盯着自己的皮鞋尖的刘俊卿,和蔼地说:“吃好了吧?”\n刘俊卿赶紧点头。赵老板看了捧着碗筷还站一边的一贞一眼,一贞只得端着碗筷进了里屋。赵老板这才微笑着对刘俊卿:“吃好了,那我也不留你了,你走吧。”\n这话说得刘俊卿有点摸着不头脑。赵老板的下一句话却仿佛给了他当头一棒:“走了以后,就不要再来了。”刘俊卿不禁目瞪口呆!\n“怎么,听不明白?我是说今天踏出这个门,以后你就不用再来了,更不要再找一贞。”赵老板的口气冷酷,不容置疑。布帘里,端着碗筷、偷听着外面谈话的一贞顿时呆住了。\n“赵叔叔,可这……这是为什么?”刘俊卿还想问个明白。“为什么就不用再说了。总之一句话,今天我请你这顿年夜饭,就算是给你和一贞之间做个了断,只要以后你不再跟一贞来往,以前的事,我当没发生过。”\n“赵叔叔,我……我对一贞是真心的……我真的是真心的……”\n“怎么,你非要我点那么明?你当我是才知道你们的事?行,那我们就摊开来谈:刘俊卿,你一个父亲,一个妹妹,父亲摆小摊卖臭豆腐,妹妹典给人家当丫环,你读个不收钱的一师范,家里还欠了一屁股债——还用我说下去吗?”\n刘俊卿的脸色顿时一片惨白。布帘后,一贞同样面如死灰——这个突然的打击显然完全出乎她的预想。\n“我为什么送一贞去周南读书?因为那是长沙最好的女校,全长沙有身份的少爷娶的都是那儿的女学生!我赵家是小户人家,可小户人家也有个小户人家的盼头,我就一个女儿,我不想让她再过我这种紧巴巴的穷日子!我省吃俭用,我供她读书,就是要让她嫁个好人家!而不是你这种人!”\n一贞冲了出来:“爸!”赵老板腾地站起,指着女儿骂道:“滚回去!还嫌给我丢脸丢得不够啊?”\n一贞呆住了。瞟了一眼刘俊卿,赵老板站起身来,扔掉烟头,一脚踩灭:“要娶一贞,你还不够格。你走吧。以后不要来了。”\n仿佛自己的身体有千斤重,刘俊卿颤抖着腿,终于站了起来,咬了咬嘴唇,向门外走去。一贞叫了声“俊卿!”抬腿要追,赵老板一个耳光打得她一歪:“你敢!”\n捂着脸,一贞的眼泪滚了下来……\n二 # 在与长沙隔江相望的溁湾镇,蔡家母子三人也在温馨地准备着他们自己的新年。\n葛健豪对着镜子,披上一件老式大红女装——那是一件宽袍大袖,刺绣精致、衣料华美的旗式女装。她打开一只颇为精致但已陈旧的首饰盒,取出里面几件银首饰,往头上戴着。她的身后,蔡和森正举着一张通红的老虎剪纸窗花,在油灯前比划着问妹妹蔡畅像不像,他旁边的旧木桌子上,散乱着红纸和碎纸屑,摆着几张剪好的“春”、“福”字。\n“咦——不像不像,等我这个剪出来,你才知道什么叫过虎年!”蔡畅一面剪着自己手里的窗花,一面说,“想起以前在乡下,那些窗花才叫好看呢。一到过年,家里前前后后,那么大的院子,那么多间房子,门啊、窗户啊,到处都贴满了,我都看不过来。”\n蔡和森笑话妹妹:“那时候,你只记得缠着要压岁钱,还记得看窗花?”\n“谁只记得要压岁钱了?”\n“还不承认。那一年——就是爸从上海给你带了个那么大的洋娃娃的那一年,过年那天晚上,你跟族里头一帮孩子躲猫猫,藏到后花园花匠的屋里头,结果你一个人在那儿睡着了,吃年夜饭都找不到你。”\n“那是你们把我忘了。”\n“谁把你忘了?到处找。我还记得管家跑到我那里直嚷嚷:”少爷少爷,四小姐不见了,怎么办啊!‘弄得一家子仆人、丫环找你找出好几里地去,等把你找出来,你倒好,光记得问:“压岁钱给完了没有,我还没拿呢。’”\n蔡畅颇为得意:“哼,那年我拿的压岁钱最多,一年都没用完!”\n蔡和森说:“那是长辈们怕你哭,故意给你加了倍。”\n“你也不差呀,你这件西装,不就是那年爸从上海带回来的?老家那么多少爷,还没一个穿过呢。”\n兄妹二人越说越高兴的对话中,葛健豪照着镜子,戴着首饰,梳理着头发——本来,她还被儿女的高兴所打动,但渐渐地,她的笑容消失了,梳理着头发的手也渐渐停了下来。她的目光扫过简陋的房间,扫过一件件破旧的家具用品,扫过窗台上摆着的一碗红薯,扫过蔡和森明显有点小了、已经打了补丁的破旧西装,扫过蔡畅的粗布棉袄、鞋面补过的旧布鞋……\n房门轻轻的响动惊醒了兴致高昂的蔡和森,他一回头,才发现母亲已经出了门。镜子前,是几件摘下的银首饰,那件精致的旗式女装已经折好,放在了一旁。\n蔡畅并未注意到这一切,还在情绪高昂:“哎,对了,哥,你还记不记得我们门口挂过大灯笼,我们剪一个好不好?”\n“行,你先剪。”蔡和森不露声色地放下剪刀,“哥先出去帮妈做点事。好好剪啊。”\n蔡畅:“放心,肯定剪得像。”\n坐在墙边,葛健豪呆呆地望着夜空。她的面颊上,挂着两行眼泪。无声地,一只手轻轻拉住了她的手。\n“彬彬?”蓦然发现儿子站在身边,葛健豪赶紧擦了一把泪水。\n“妈,怎么了?”\n“没什么,没什么。”葛健豪掩饰着,但眼泪却又涌了出来,她极力想忍住,擦去泪,笑了一下,却不料眼泪越涌越多,她连擦了好几下,眼泪不曾擦尽,却猛然鼻子一酸,忍不住一下捂住了脸——那是一个坚强女人压抑不住的,突然感到疲惫、无助、软弱而内疚的抽泣声。\n“妈。”蔡和森蹲了下来,抓紧了母亲的手,“妈,您这是干什么?怎么了?”\n半晌,葛健豪才抬起头,望着儿子的眼睛:“小彬,你后悔过吗?跟着妈出来,跟着妈离开那个家,过上现在这样的穷日子,你后悔过吗?”\n“妈,您怎么会突然这样想?”\n“不是妈要这样想,是妈不能不想啊。妈这一辈子,做什么事都利落,都干脆,从来不想什么后果,也从来没有为自己的选择后悔过。只有把你们两兄妹带出来这件事,妈的心里,一直就不安稳。”她叹了口气,接着说,“离开家也好,受苦受穷也好,那都是妈自愿的,可你们不一样,你们都还是孩子,只要还呆在那个家里,你们就能吃好的,穿好的,过得无忧无虑。其实妈心里总是想啊,是不是妈害了你们,是不是妈太亏欠你们,是不是妈夺走了你们应该享受的幸福和快乐……”\n“妈。”蔡和森打断了母亲,“谁说我们现在过得不快乐了?”\n“可是……可是跟着妈,你们连个像样的年都过不上……”\n蔡和森突然站了起来,说:“妈,你真的不知道我们快不快乐?”\n葛健豪点了点头。“那您自己来看,来看看吧。”迟疑着,葛健豪站起身,顺着蔡和森的目光,向窗内望去。\n房里,蔡畅不知何时已经放下了剪刀,正站在母亲刚才照过的镜子前,披着母亲刚才穿过的那件大红旗装,学着母亲的样子,往头上戴着那几件银首饰。对着镜子,她比划着,欣赏着,做着各种天真的表情——大人不在身边,她那小女孩的天性这时展露得是那样一览无余。\n灿烂的、春天般的笑容充盈在她那还带着童稚的脸上。蔡和森问:“妈,您觉得,现在的小畅,不如过去的小畅快乐吗?”葛健豪不禁笑了。\n“要是没有妈妈在身边,做儿子、做女儿的,还能有真正的快乐吗?妈,跟着您出来,是我们这一辈子最正确的选择,您从来没有亏欠我们什么,正好相反,是您,给我们保留了这份幸福和快乐。”\n握着儿子的手,葛健豪点了点头。她突然把儿子的手贴到了脸上,紧紧地,紧紧地……\n三 # 炊烟袅袅,从毛家屋顶上升起。灶前,文七妹蹲在地上,眯着眼睛躲着柴草的烟,往灶膛吹火……\n有双脚步停在了她的身后。文七妹似乎这才感觉到了什么,她突然一回头——\n站在她身后的,正是背着包袱、一身长衫的毛泽东!\n“娘。”\n“哎……哎!”这一刹那,文七妹突然竟有些手足无措,她擦着沾满烟尘的双手,愣了好几秒钟,突然扯开了嗓子,喊,“顺生……回来了……顺生……回来了嘞!”\n毛贻昌板着脸出现在里屋门口:“鬼喊鬼叫什么?我又没聋!”\n他的目光移到了儿子身上。\n毛泽东:“爹。”\n毛贻昌鼻子里“嗯”了一声。\n“大哥……大哥……”年幼的弟妹欢叫着从里面钻了出来。\n“泽覃,泽建!”毛泽东一手一个,一把将两个年幼的弟妹抡了起来,在空中悠了一个圈。\n“大哥?”房门外,担着一担水进门的泽民愣了一下,放下担子就冲了上来,“大哥!”\n毛泽东放下泽覃,一把搂住了泽民。四兄妹欢声笑语,闹成了一团。\n望着自己的儿女们,文七妹搓着双手,喃喃道:“回来了,嘿嘿,回来了……”连毛贻昌的脸上,都闪过了一丝笑意。\n第二天便到了新年,毛家院子里,毛贻昌一身半旧的长袍马褂,正在端正自己的瓜皮小帽;泽建一身新花衣,扎着红头绳,蹦过来跳过去;毛泽东站在凳子上,正在泽覃泽建的指挥下贴着自己刚刚写好的对联。\n端着菜从厨房里面走出,文七妹笑融融地望着家人,快步把菜端进了厢房。抓着泽建的小手,毛泽东用香点燃了挂了树上的一段鞭炮。鞭炮声中,一家人进了厢房,丰盛的农家年夜饭摆满了一桌,父子五人围坐桌前,只有文七妹还戴着围裙,忙碌地上着菜。\n毛泽东从身后拿出了一个布包:“爹,我从省城也带了几件礼物回来,没花多少钱,都是些简单东西。”拿出一包麻糖,毛泽东说:“泽覃、泽建,这个是九如斋的麻糖,省城最有名的,又香又甜,我带了半斤给你们尝尝。”\n毛泽东又取出一本字帖和一叠描红纸:“泽民,你在家里,整天忙农活,认得那几个字我都怕你忘了,这是给你的,有空多练练,以后考学校,用得上。”泽民说道:“哎,谢谢大哥。”\n毛贻昌沉着脸,补了一句:“做完事再练,莫只记得几个字,当不得饭吃。”泽民点头笑说:“我会的,爹。”\n毛泽东又拿出了一盒香烟,送到了毛贻昌面前:“爹,这是给您的。”接过香烟,毛贻昌皱眉打量着——他显然不大认得这是什么东西:“什么家伙?”\n“洋烟,洋纸烟,听说比旱烟好抽。” 毛泽东说道。\n“贵吧?”毛贻昌仰头问。“不算贵,也就两毛钱。”\n毛贻昌掂量了一下轻飘飘的香烟,往桌上一甩:“两毛钱?买得斤多旱烟了,图这个新鲜!”\n“哎呀,三伢子还不是给你图个新鲜?”文七妹正好端上了最后一道菜,她推了丈夫一下,冲毛泽东,“买得好,蛮好,蛮好。”解着围裙,她也坐上了桌。\n毛泽东最后拿出了一只崭新的铜顶针:“娘,这是给您的。”“我?”文七妹有些不相信,“我要什么东西?不用的不用的。”\n“娘——我专门给您买的,您那个顶针不是断了吗?我跑了好多家店铺,才挑了这个最好的。您试试吧,试试合不合适。”接过顶针,文七妹的手居然有些发抖,她颤抖着把顶针戴上了手指。\n毛泽东问道:“娘,大小合适不?”顶针在文七妹的手指上明显大了,文七妹掩住了顶针,赶紧褪下:“合适,正合适,蛮合适的……”她忍不住擦了一把眼角的泪水,赶紧端起酒壶,给毛贻昌倒上酒:“吃饭吧,吃团年饭,一家人团团圆圆……”\n“你急什么?”毛贻昌打断了她,目光又投到了毛泽东身上,“就拿点麻糖、洋烟来交差啊?学堂的成绩单嘞?”\n毛泽东将成绩单递了过来。毛贻昌仔细地翻着成绩单,单子上一长串的各科成绩,都是满分或者九十几。\n他的神色缓和了,一丝笑意也浮了起来。翻过一页,他继续看着,眉头却突然一皱,眼睛凑近了成绩单,那是排在后面的数学等几科较差的成绩。\n“砰”的一声,毛贻昌将成绩单重重地拍在饭桌上,把妻子、儿女都吓了一跳!“数学61?”毛贻昌瞪着儿子,“你搞什么名堂,啊?”毛泽东低下了头。\n“乱七八糟的功课你倒是考一堆分子,算账的功课就乱弹琴!你数学课干什么去了?尽睡觉啊?”毛贻昌越说越火,一拍桌子,却正拍在那盒香烟上,他拿起香烟,“还买什么洋烟来糊弄老子,老子看到就碍眼睛!”一甩手,他将香烟扔到了地上!\n“哎呀你干什么你?”文七妹赶紧起身把烟捡了回来,“门把功课没考好,以后赶上来就是。大过年的,高高兴兴,你发什么脾气嘛?”她将那盒烟又塞进了毛贻昌的口袋。\n看看一家人一个个低头无语的样子,毛贻昌也感到气氛不对,他重重地哼了一声,移开了瞪着毛泽东的目光。\n文七妹忙笑说:“来,吃饭,团年饭——菜都冷了,都吃啊。”她用胳膊碰了毛贻昌一下,毛贻昌这才拿起筷子,挟了一筷鱼:“来,年年有余啊。”几个孩子总算松了一口气,大家都伸出了筷子:“年年有余。”\n转眼寒假过了,一家人都起了个大早,忙忙碌碌地为他准备着。厢房里,文七妹在收拾着毛泽东路上带的干粮等,毛泽民与泽覃在一旁捆扎着毛泽东的行李。\n泽建小心翼翼地把一碗热腾腾的熟鸡蛋端进了厢房,文七妹边往包袱里装着鸡蛋,边吩咐泽建,“去看看你大哥,怎么还在屋里头,莫耽误了船。”泽建推开大哥的门,喊道:“哥,娘在催你了。”\n锉刀声声,毛泽东正坐在桌前专注地干着什么,头也没抬,“晓得了,再等一下,我就好。”\n蹲在门口的一辆独轮车边,毛贻昌正拿着一支香烟,放在鼻子底下闻着。他手里,那包香烟拆了封,却一支也没抽过。\n似乎光闻闻已经过瘾,他又打算把烟装回烟盒,就在这时,两个乡邻正好经过,“顺生老倌,你三伢子要回省城读书去了吧?”\n毛贻昌点头:“哎哎哎,马上走,正在屋里收拾东西。”他一面回应,一面忙不迭地掏出火柴,点着香烟。\n“哟,顺生老倌,这是什么新鲜玩意啊?” 乡邻伸过头来。\n毛贻昌脸上一副不以为然的样子,挟着纸烟的手指高高翘起,展示着:“这个?洋烟,三伢子从省城买回来孝敬我的。细伢子,不懂事,只晓得花钱图新鲜。”\n乡邻凑得更近了,“洋烟是这个样子的哦?哎,顺生老倌,讨一根来我们也开开洋荤喽?”\n毛贻昌平时虽省吃俭用,可对乡亲们却不吝啬。儿子从长沙带来了盒洋烟,不正好让乡亲们尝尝鲜。他得意地将烟递给这两个乡邻,然后又将烟收进口袋,用手按着,这才又补充:“试试喽,看比旱烟强些不。”\n两个乡邻接过烟,点燃后细细地品味起来。\n毛贻昌也怡然自得地抽着烟,远望着两个乡邻走远。待乡邻身影消失不见,毛贻昌赶紧把手里还剩半截的烟掐灭,小心翼翼地,又将半截烟塞回了烟盒。\n这边泽民与泽覃把捆扎好的行李搬上了他身边的独轮推车,捆绑着。看着两个儿子的动作,毛贻昌一脸的不满,“一点东西都不晓得捆!站开站开,我来。”他干净利落,几下捆紧了行李。\n毛泽东却还在专注地干着。停下手,他拿起那根量过母亲手指大小的线,比照着,又拿起锉刀锉了起来。文七妹推开了房门:“三伢子,还在忙什么呢?”\n“就好了。”毛泽东最后锉了几下,转过头来,“娘,您再试试,应该合适了。”他的手中是那枚刚刚打磨过的顶针。\n望着崭新的顶针,和儿子那绽着细细汗珠的笑脸,文七妹一时竟愣住了。拿起母亲的手,毛泽东把顶针戴了上去——果然,不大不小,刚好合适:“娘,您看,刚好。”\n“这伢子……”抚摸着顶针,不知怎么,文七妹突然感到鼻子有些酸了……\n第十二章 二十八画生征友启事 # 二十八画生者,长沙布衣学子也。\n但有能耐艰苦劳顿,不惜己身而为国家者,\n修远求索,上下而欲觅同道者,皆吾之所求也。\n故曰:愿嘤鸣以求友,敢步将伯之呼。\n敬岂事二十八画生。\n一 # 三月,和暖的阳光从长沙街头梧桐新发的绿叶的叶尖,从街面青石板缝隙的新苔上,从湘江新涨的绿水之中滑过,便如一泓清泉,将整个长沙高高低低的建筑洗涤得干净而明亮。空气中弥漫了春天特有的气息,翠枝抽条,绿草萌芽的清新,纷纷绽放的杂花的浓香和新翻泥土的清香,都渗进了长沙街头熙熙攘攘的人流之中。\n何中秀在青石板的街道上快步而行,这位周南女中的教务长全没有在意春光的明媚,连路上的熟人打招呼也心不在焉。她的步子急促有力,颧骨高突的瘦脸上,拧成一体的细眉和紧咬着的薄薄的嘴唇,将她心中的恼怒都勾了出来。她努力控制自己的情绪,尽量想将步子放慢,然而呼吸却更为急促,紧裹在教会学校女学监式高领制服里的身子不由自主地颤抖,一张油印的纸帖在她手里皱成一团。当时在学校的大门前见到这张帖子,她几乎一把撕得粉碎,不过她很快冷静下来,她要留下来做证据。\n这位从英国留学回来的女教务长在周南一向以严厉著称,她下意识地捏了捏手里的帖子,这是一张所谓的《二十八画生征友启事》。不觉越想越恼火。早在英国留学时,她就见识过西方男学生追女生的胆大,令她这些“非礼勿视,非礼勿听”的中国女学生们大开眼界,但今日这个帖子,她发现中国的男学生们实在有青出于而胜于蓝之势,居然如此明目张胆地贴在学校的大门口来招揽女生的眼球,说什么“修远求索,上下而欲觅同道者……”什么“愿嘤鸣以求友,敢步将伯之呼”。 启事末尾写道,“来函请寄省立第一师范,黎锦熙转二十八画生……”一股怒火不自禁地从她脚底直窜到头顶。她倒要看看,这个黎锦熙到底是何方神圣,这个胆大妄为的“二十八画生”又是什么东西,一时脚下更快了起来。\n她折过几条街巷,远远便看见了第一师范那栋高大的暗红色教学楼,柔和的阳光如同蝉翼覆盖,越发显得雍容典雅。\n何中秀略略平缓了心情,这才走进一师那张深黑的镂花大门,学校开课已经几天了,学生们正在上课,回廊上静寂无声。何中秀径直穿过回廊,高挑着的头不动,但冷厉而恼怒地一眼便看见了教务处。何中秀推了推眼镜,抬起了手。\n“乓乓乓……”重重的敲门声吓了几个老师一跳。\n“谁呀?”一个老师打开房门,何中秀冷冷地直视着他。这个老师呆了一呆,右手握住门的把手,疑惑地问:“请问?”\n何中秀不理他,一脚跨进门去,语气生冷:“谁叫黎锦熙?”\n办公桌下不知在找什么的黎锦熙抬起头来,应声答道:“我就是。”他还没有回过神来,何中秀已经直奔过去,把一张纸向他桌上一拍!“这是你寄的?”\n黎锦熙拿起那张纸来,是一张油印的启事,他一眼瞟见那个兰亭体的标题——《二十八画生征友启事》,不觉笑了起来。慌忙说道:“小姐,您听我解释……”\n何中秀立刻打断他,说:“敝姓何,周南女中的教务长。”她的声音随即提高:“太不像话了!居然把这种东西发到我们周南女中来。你把我们周南当成什么地方了?”\n黎锦熙静静地等何中秀发泄完,才赔笑说:“何小姐,恐怕您误会了!”何中秀找了把椅子坐下,眼皮也不抬一抬,纠正说:“何教务长!”\n黎锦熙笑道:“何教务长,您听我解释,这是敝校一名学生写的,他只是托我代收来信……”他的话没有说完,何中秀更是怒气冲天,这是什么老师?一时声音更高了,尖锐的女声便如划过玻璃的钢丝,从教务处一直传到走廊,引得经过的几个老师纷纷侧目。“学生?学生你就更不应该!身为教师,眼看着学生发这种乌七八糟的东西出来勾三搭四,不但不阻止教育,你还帮他收信?是不是想助长他来蒙骗我的女学生啊?”\n黎锦熙这时脑子里已经乱成一团,张大了口说:“蒙骗女学生?”\n何中秀手指在那张启事上乱敲,厉声说:“把这种东西发到女校来,不是想蒙骗女学生还是干什么?还‘愿嘤鸣以求友,敢步将伯之呼’?想求什么友啊,女朋友吗?”\n满屋子的老师们都愣住了。黎锦熙一时真是不知从何解释起,看着何中秀苦笑起来,他深吸了一口气,才说:“何教务长,我想您真的误会了,我可以向您保证,这个学生绝对没有什么不轨的心思……”\n何中秀冷笑一声,说:“你向我保证?”她顿了一顿,尖声说道:“谁向我保证也不行!”\n“那我保证行吗?”\n忽然门被杨昌济推了开来。\n何中秀微微一怔,有学问的人何中秀也见过不少,但像杨昌济这样学贯中西又品行高洁的大学问家却极是少见,这也是她最敬重的。 杨昌济在周南兼课,她一直执以弟子之礼,这时赶紧站起身,神色恭谨:“杨先生?”然而心中疑惑,这件事怎么会和杨先生扯上关系,这个“二十八画生”究竟是何方神圣?\n杨昌济点头微笑,自桌上拿起那张启事,说:“何教务长,请您跟我来,我为您解释。如何?”\n何中秀不觉有些局促,忙说道:“您叫我小何吧。”\n杨昌济含笑说道:“好吧,小何,这边请。”一时领着何中秀出门去。 黎锦熙此时已经是满头大汗,看着两个人出门,长吁了口气,向几个老师自嘲说:“当真是唯小人与女子难养也,这位和苏格拉底的那位有得一拼。”几个老师都笑起来。\n何中秀随杨昌济慢慢穿过回廊,一时来到学校的公示栏前, 杨昌济指着上面贴着的一篇文章说:“你帮我看看,这篇文章怎么样?”\n何中秀一头雾水,但又不好多问,看那篇标题为《心之力》,署名“毛泽东”的文章上,密密麻麻被加上了一片圈点,圈到后来,竟已无从下笔。文章上方用红笔打上了“100”的分数。后面又重重地添上了“+5”。文章之下是杨昌济长长的批语。\n何中秀疑惑地慢慢读这篇文章,越读到后面,脸色越惊异,不自禁地扶住眼镜,又跨前一步,身子几乎已经贴住了公示栏。半晌才抬起头来,说道:“这是你们学生写的文章。”\n杨昌济点头一笑。何中秀半晌才吐了口气说:“一个学生,居然有这样深刻的思想,这样严密的逻辑?我也教了这么多年哲学,真是见所未见啊。”\n杨昌济手拍着公示栏,肃然说道:“不仅仅是才华。此生的人品、志趣,昌济是最了解的,别的不论,心底无私、光明磊落这八个字,我敢为他拍个胸脯。”\n何中秀怔了一怔,忽然回过神来,说:“等等,您是说, 这个毛泽东就是二十八画生?”\n杨昌济点头肯定。说:“是这样的,几天前刚开学,这位学生对我说, 他越来越觉得,所学到的东西,直接从书本上得来的少,倒是向各位先生质疑问难,和同侪学友相互交流中,得到的更多。”\n何中秀沉吟说:“嗯,从有字之书中搬学问,不如从无字之书中得真理。”\n杨昌济笑起来,说:“得真理也只是第一步,他对我说,修学也好,储能也好,归根结底,是为改造我们的社会,而改造社会,绝不是一个人的事,再大的本事,一个人也解决不了任何问题。所以他觉得应该扩大自己的交流范围,结交更多的有志青年,他日,方可形成于中国未来有所作用的新的力量。”\n何中秀闻言呆了一呆,忽然一击掌,说:“对,这就应该结交同志,公开征友。是不是?”\n杨昌济欣然大笑,打开那张启事,说:“您看,‘但求能耐艰苦劳顿,不惜己身而为国家者’,他既以家国天下为己任,自能想人之不敢想,行人之不敢行。区区世俗之见,又岂在他的眼中?”\n何中秀低头一笑说:“看来倒是我有俗见了,杨先生,今天是我冒昧了,请您原谅。”\n杨昌济微笑说:“这么说,何教务长不打算追究了?”\n何中秀含笑说:“我要追究的是心存不良的浪荡子,可不是有这等才华个性的好学生。”\n杨昌济会意一笑说:“那这份启事就还给我,就当这件事没有发生过,好不好?”\n何中秀缓缓摇了摇头,斩钉截铁地说:“那可不行。”\n杨昌济愣了一愣说:“怎么?”\n何中秀笑道:“启事还给您,我周南的学生,上哪儿去结交这样特立独行的人才呢?”\n杨昌济也笑起来,递过那张启事。何中秀接过来说道:“今天冒昧打扰了,麻烦您代我向黎先生致歉。”\n杨昌济笑着答应:“一定,一定。”\n何中秀告辞出来,已经是中午时分,阳光越发显得清亮了,便如透明的琥珀一般。何中秀不觉又将那张启事拿在手里细看,“二十八画生者,长沙布衣学子也……但有能耐艰苦劳顿,不惜己身而为国家者,修远求索,上下而欲觅同道者,皆吾之所求也。故曰:愿嘤鸣以求友,敢步将伯之呼。”脸上渐渐露出笑意,一抬头,却见不远处阳光下数株老槐都抽出碧绿的新条,如同清泉淌过的玉石一般。\n二 # 毛泽东这几天来一直都在一种激动和亢奋之中,周身仿佛有一团火在燃烧。他的征友启事在长沙各大中学贴出不过两天,便接到了长郡联合中学一位自号“纵宇一郎”的来信,这人名叫罗章龙,虽然只有19岁,但胆识气魄都超人一等,两个人一见之下,顿时有相见恨晚之感,从周日下午二时一直谈到天黑,还意犹未尽。罗章龙对经济学的领悟颇深,这是毛泽东尚未涉猎的新范畴,因此听得相当仔细,不觉暗自庆幸,如果不是有这次征友,在学校的课本上,他是无法学到这些新知识的。而从罗章龙的谈吐,他也情不自禁地感到,天下之大,无处不是英才,如果这些精英都能同心一力,中国的复兴只在指掌之间。\n这日一大早,毛泽东胡乱吃了早饭,便匆忙往爱晚亭赶,他与另一位来信应征的已经约好了在爱晚亭见面。一时过了湘江,直上岳麓山。这天正是周末,但天时还早,山上游人不多,天边一轮红日,自绵延的山岚之间浮出,便在满山碧绿的松涛中抹出一痕胭脂。松风振动,鸟雀相鸣。\n出岳麓书院后门,沿石道而上,山路盘折,越往里走,山路越窄,两山夹峙,行至山穷水尽之时,眼前忽然开朗,一个亭子金柱丹漆,四翼如飞,立在山麓之中, 正是号称天下四大名亭的爱晚亭。亭下两个大池塘,春水新涨,绿柳如丝。\n毛泽东在亭子里的一张石桌旁坐了下来,他来得太早,应征的人还没有到。但他此时心中却更为急切,在那亭子里坐立不安。\n终于听到有脚步声远远传来,毛泽东站了起来,看时,却是一男一女两个中年人,看着也不像。他又坐了下来,正失望时,忽然石道上闪出一个少年,只有十五六岁的年纪,短发,眉目清秀,但嘴唇丰厚。他步履谨慎,无声无息地上了亭子,略有些局促地看着毛泽东,张了张口,腼腆一笑试探道:“二十八画生?”\n毛泽东大笑一声,扬起手中的信来,两封信同时摆在了石桌上。\n“长郡联合中学,李隆郅。”这位少年报出名字。\n“第一师范,毛泽东。你好。”毛泽东热情地伸出手,李隆郅看了看这只手,才伸出手来,握了一下。\n毛泽东坐了下来,说:“你想先谈点什么?”\n李隆郅沉默一时,说:“毛兄主动征友,自然先听毛兄谈。”\n毛泽东全不推辞,顿时滔滔不绝:“嗯!那好,我就谈谈我为什么要征友。首先呢,我们都是民国新时代的青年,天下者,青年之天下也。青年要实现自己的理想和抱负,就要寻找更多志同道合的同志。古有高山流水,管鲍之谊,我们今天更应该与一切有志于救国的青年团结起来……”\n山风掠过,亭子四翼的松枝一阵颤动,便如触电一般,满山的松涛都荡开来,便如海波扬起,直向天空奔涌而去。毛泽东说得兴起,站了起来,在亭子里来回走动着,挥动手臂,声音也越来越大:“……正如梁启超先生言:今日之责任,全在我少年。少年强则中国强,少年进步则中国进步,少年雄于地球,则中国雄于地球……”\n李隆郅沉吟不语,目光落在了石桌上并排摆放的那两封信上。山风越发大起来,吹动信纸。\n“……以我万丈之雄心,蒸蒸向上,大呼无畏,大呼猛进,洗涤中国之旧,开发中国之新,何事不成……”\n毛泽东越说越兴奋,大开大阖,仿佛眼前的群山都是他的听众,正在受到他的鼓动感染!\n李隆郅默然无语,只是眼看着亭外的山景,沿池塘植满了垂柳,阳光透过来, 柳叶如眉,绿草如丝。\n“……莽莽乾坤,纵横八荒,谁堪与我青年匹敌?纵一人之力有限,合我进步青年之力,则必滔滔而成洪流,冲决一切,势不可挡,为我中华迎来一崭新世界!”毛泽东用力一挥手,声音戛然而止。一番演说带来的激动使得他额角都带上了微微的汗珠,眼里闪着炽热的光,等待李隆郅的回应。\n这时亭外一群飞鸟骤然从枝头惊起,正在打量着山景的李隆郅似乎也被惊醒,他看看毛泽东望向自己的眼神,半晌才说道:“毛兄——说完了?”\n毛泽东:“说完了。”\n李隆郅沉默一时,似乎下了很大的决心似的,站起身来,一言不发,向亭外走去。\n毛泽东呆了一呆,“哎,你上哪去?”\n李隆郅头也不回说:“你不是说完了吗?”\n毛泽东:“我讲完了,你还什么都没讲呢。”\n李隆郅却不理他,飞也似的跑下山去了。\n毛泽东不由哭笑不得,招手想叫他回来,但想一想却作罢了,只摇一摇头:“这个人,什么毛病?”\n不过毛泽东怎么也没有想到的是,不到十年,他和这个人成为了战友。1922年,李隆郅从法国留学回来,先到中共湘区委员会报到,书记正是当初寻友时结识的“润之兄”。毛泽东对他说:你的名字太难叫,工人们也不认识“隆郅”这两个字。这位性格豪爽的革命者马上同意改名,决定按谐音改成“能至”。再后李能至又更名李立三,成为早期中国共产党领导人之一,中国工人运动领袖,无产阶级革命家。只是毛泽东一直也没明白他当时为什么一言不发,这也成了一段谜。\n三 # 何中秀回到周南女中,当天就把这个启事张贴在了学校门口。放学后,一大群好奇的女生们叽叽喳喳地围在门口,有人读着,有人议论,也有人皱着眉头。\n“什么那么好看?让一下让一下。”警予拉着斯咏挤了进来。\n“《二十八画生征友启事》?嘿,这倒新鲜啊!”警予读着启事,“‘二十八画生者,长沙布衣学子也’——这是谁呀,这么酸溜溜的?”\n斯咏比较喜欢古文些,并不觉得这样写有什么不好,她蛮有兴趣地看着启事,说:“你管他谁,看看再说嘛。”\n“我才懒得看呢。”警予一点兴趣也没有。\n斯咏自顾自地读着启事:“……但有能耐艰苦劳顿,不惜己身而为国家者,修远求索,上下而欲觅同道者,皆吾所求也……”\n“切,好大的口气!”警予一把拉住斯咏,“走走走,牛皮哄哄的,有什么好看的?走!”\n两人刚转身,听到身后传来其他女生读启事的声音:“……故曰:愿嘤鸣以求友,敢步将伯之呼……”\n斯咏猛地站住了,她一把甩开警予的手,回过头来。启事的末尾,霍然是那句“愿嘤鸣以求友”!\n回到寝室。斯咏拿出那本《伦理学原理》,翻开了扉页,露出了那句“嘤其鸣矣,求其友声”。她几乎是下意识地把这一页翻过去,又翻回来,反反复复。\n“你说我们周南这是怎么了?平时连门都不让男生进,今天倒好,外校男生的征友启事,居然也让贴在大门口,真是怪了。”警予在趴在床边,摔打着一个旧布娃娃。\n一贞也轻轻应和着:“就是,我也觉得怪。”\n“哎,你们猜猜,会不会有人去应征啊?”警予看看斯咏,又看看一贞,问。\n只有一贞回答:“不会吧?”\n“你肯定?”\n“男生征友,女生谁会好意思去呀?那还不让人笑话死?”\n两个人聊着,却发现斯咏坐在一边出了神,警予把那布娃娃扔了过去,砸在斯咏头上:“哎!大小姐,今天怎么回事?一句话都不说。”\n斯咏没抬头,仍然盯着那句诗。\n“这丫头怎么了?丢魂了?”警予上前把那本书一把抢了过来,“想什么呢?”\n斯咏抬起头,忽然仿佛下定了决心似的,说:“我想去应征。”\n四 # 毛泽东接到陶斯咏的信已经是第三天,自和李隆郅见面之后,他一直也没有弄明白,李隆郅为什么一言不发便走了。而黎锦熙这回交给他的信,落款居然是“周南女中 悠然女士”,分明是个女生,他就更是犹豫,直到了约定的周日上午,他还拿不定主意,便来找蔡和森。\n“老蔡。”毛泽东把信放在蔡和森面前,“陪我走一趟好不好?”\n蔡和森看一看信上的落款,顿时笑起来:“想不到,润之兄天不怕地不怕,倒怕和女学生见面。”\n毛泽东哼一声,说:“我怕?我怕他个鬼!我就是觉得头回见面,一男一女,总不太好嘛。”\n蔡和森沉吟说:“人家肯来应征,足见思想开明,不是那种扭扭捏捏的传统女性。”\n毛泽东点头说:“这个我晓得。不过……我还是觉得不太好——再说,这么思想开明的女性,你也应该见识见识嘛。哎呀,走走走,走嘛。”\n来信约在岳麓山的半山亭,二人直出了校门,过湘江上山。\n半山亭在岳麓山的半山腰,此处原建有半云庵,后废弃,亭子是六方形,亭周苍松半隐,杂花乱放。松外半边晴日,半壁山石嵚嵌。\n“看样子还没到。”两个人上了亭子,毛泽东环顾四周。\n“还不到时间吧。” 蔡和森全不在意,看那亭子上“半山亭”三个字,说道:“润之,这半山亭还有个来历,你还记得那首诗么?”\n毛泽东正要说话,忽然背后一个女声传来“请问——”\n毛泽东和蔡和森同时回过头来,斯咏、警予、毛泽东、蔡和森都愣住了。\n“怎么是你?” 四个人几乎是不约而同。\n毛泽东大笑起来,一扬手中的信说:“两位谁是悠然女士?”\n警予一指斯咏笑说:“本人周南女侠,这位悠然女士。谁是二十八画生?”\n“敝人毛泽东,正好二十八画,这位第一师范蔡和森。”他向斯咏一笑:“两位女士好。”\n斯咏怔了一怔,这两个名字实在再熟悉不过了,想不到毛泽东就是他,立时伸出手来笑道:“你好,陶斯咏,向警予。”\n她话未说完,警予几乎跳了起来,“你就是蔡和森,你是毛泽东,去年一师入学考试的一二名?”指着蔡和森,“你还笑,你怎么骗我。”\n蔡和森尴尬一笑,毛陶二人奇道:“原来你们认识?”\n警予哼了一声,说道:“鬼才认识他。” 蔡和森却一抱拳笑道:“女侠气量如海,得罪之处,还请恕罪。”\n警予一摆手,撇嘴说:“也罢,本女侠肚里能撑船,暂时饶了你,下回再犯,定斩不饶。”\n一时四个人坐定,慢慢说起缘故,从向陶二人冒名考试,到蔡陶街头擦鞋,从毛陶二人书店偶遇,再到街头躲雨,原来都是对面相逢不识君。说到好笑处,都哈哈大笑起来。\n“这就叫无巧不成书啊。”毛泽东一捅蔡和森:“你看,你还不打算来,不来怎么碰上你这位崇拜者啊?”\n警予冷哼一声说:“还说!想起来就叫人生气,说什么‘我跟蔡和森是同学’——为什么骗我?”\n蔡和森笑说:“我可没骗你。”\n“还不承认!” 警予得理不饶人。\n蔡和森笑一笑说:“当时你只问我认不认识一师的蔡和森,我说认识也没错呀——我能不认识自己吗?”\n警予瞪了一眼,说道:“狡辩!”\n“好了,偶像也碰上了,还有什么不满意的?” 斯咏笑说,“再说刚才你怎么说的,‘本女侠肚里能撑船’。”\n警予一扭头反驳她:“谁说他是我偶像了?”\n“不是偶像?不是偶像你那床头贴的是什么?” 斯咏含笑说道。\n警予脸上微微发热,顿时反唇相讥:“不准说了啊。是谁又送书,又抄诗,还说我?”\n斯咏立时羞红了脸。\n“好了好了,以前的事都不提了,今天,就当我们正式交个朋友。来,握个手吧。”\n在毛泽东的提议下,四个人的手大大方方地握在了一起。\n五 # 读书会的周日活动时间很快就到了。这一天也正是斯咏、警予头回参加活动的日子,毛泽东一早便告诉了萧子升有两个新成员要加入,春色和暖中,读书会的人在一师门前陆续聚齐,萧子升一直留意,却不见有新人来。一时问:“润之,你说的两个新成员呢?”\n毛泽东笑说:“莫着急嘛,马上就到。”\n这时身后传来了警予的声音:“毛泽东。”萧子升看时,斯咏穿一件淡黄的连衣裙,一头乌青的长发如缎子一般飘动,高挑身材,眉如细月,目似澄波,神色从容,举止冷静。警予穿白色校服,短发,修眉俊目,文采精华,这两个人, 斯咏艳如霞映澄塘, 警予却是素若秋蕙披霜,一艳一素,看得萧子升不由怔住了。毛泽东大笑说:“你看,说曹操曹操就到吧。来来来,介绍一下,萧子升,我们读书会的负责人。这两位是周南女中的向警予、陶斯咏。”\n警予落落大方地伸出手来:“你好。”\n子升这才反应过来,赶紧掩饰着自己的失态:“你好。”\n斯咏也伸出了手,与子升相握:“你好。”\n毛泽东一拍巴掌说:“哎哎哎——人都到齐了,兵发湘江,走喽!”\n一行人浩浩荡荡过了湘江,向岳麓书院而来,一路上玩笑不断,向、陶很快和众人混熟了。\n岳麓书院始建于宋代开宝九年,书院前抵湘江西岸,背延至麓山之顶,占地数百亩。众人远远便见苍松老柏之间,院堂相接,楼阁勾连,自有一番气势,都不觉肃然起来。\n众人一时缓缓行到了桃李坪,却见正面是单檐硬山式的三间大门,额书“千年学府”。萧子升微微一笑,说道:“有人说一大段的时间,才凝聚出一点历史,一大段的历史,才凝聚成一点文化,文化之重,自古使然,这里是中国千年文化之地,虽然只有这简单的四个字,但其中的分量,实在有泰山之重。”\n蔡和森沉吟说道:“自来游名山大川,就有两种人:一种是明白人,积蕴深厚,胸中有丘壑,因此于简单处见文化,于平白处得性情;一种是糊涂人,只知道搜奇猎胜,更有人附庸风雅,不知所谓,实在糟蹋了这些名山胜景。”\n警予笑说:“你说我们是明白人还是糊涂人?”\n蔡和森笑一笑,不置可否。毛泽东却笑说:“他一向的难得糊涂,是大智若愚。”\n几个人说笑,已经进了那三间头门,这里就是正门了,只见五间出三山屏风墙,也是单檐硬山顶,门额“岳麓书院”,门联大书“惟楚有才,于斯为盛”。\n外檐石柱一幅楹联:“地结衡湘,大泽深山龙虎气,学宗邹鲁,礼门义路圣贤心”。\n警予念着门联,回过头来,手点着身后众人说:“哎,你们说,是不是于斯为盛呀?”\n斯咏笑道:“人家千年书院,才敢这么说,我们算老几?”\n警予哼一声:“那千年也过掉了嘛!以后呢,说不定就是我们。蔡和森,你说是不是?”\n蔡和森笑一笑说:“我可不敢做此奢望。”\n萧子升却沉声说:“为什么不?江山代有才人出,各领风骚数百年。焉知今后就不是你我之辈?”他的目光转向了斯咏,说道:“陶小姐,向小姐,请吧!”\n众人纷纷向里走去,斯咏却回头在找什么,只见毛泽东还站在原地,仰望着对联出神,招呼道:“毛泽东,走啊!”\n“哎!”毛泽东答应一声,又认真看了对联一眼,深吸了一口气,这才向里走去。\n向里便是书院的主体讲堂所在。自初创至今,讲堂堂序共有五间,前出轩廊七间,东西深三间,一体的青瓦歇山顶。讲堂明间正中设讲台,屏风正面刊着张栻撰、周昭怡书的《岳麓书院记》,背刊岳麓全景摹刻壁画。左右壁嵌石刻“忠、孝、廉、节”四字。轩廊后壁左右,分置石刻,为乾隆二十二年山长欧阳正焕书“整、齐、严、肃”四字。内壁四处都是木刻、石刻,刊满学箴、学规、题诗。\n蔡和森长吸一口气说:“这就是湖湘千年学术之滥觞啊。”\n萧子升点一点头,“站在这儿,想想当年,朱熹、张栻、王阳明、王船山这些先贤巨儒,就曾在那个讲台上传道授业,我们站的地方,就曾坐过曾国藩、左宗棠、谭嗣同、魏源这些学生……”\n警予扬起脸补充:“还有杨老师。”\n萧子升愣了一愣,笑了起来,“对,包括杨老师——身处圣贤故地,举目而思先哲,油然而生敬意啊!”\n警予突然一撩裙子,席地端坐了下来,招呼说:“来来来,都坐下,体会一下。”\n众青年纷纷学着古人听讲的样子,席地端坐下来。\n警予点头说:“嗯!感觉不错。可惜呀!就缺上面坐个老师了。”\n斯咏仰头说:“那上面谁敢坐?那可是朱熹、王阳明讲课的地方。”\n萧子升笑说:“是啊!我们没赶上好时候,不然,也能一睹圣贤风采了。”\n“我看老师还在。”这时身后传来了毛泽东的声音,众人回头一看,才发现只有他还站在大家后面。\n他走上前来,手一指:“那就是老师,真正的老师。”手指的方向,正是轩廊外檐明间匾额上“实事求是”四个大字。\n斯咏疑惑道:“实事求是?”\n“对,实事求是!据说朱熹在读《中庸》时,《中庸》里面关于心和性,他总是不得其解,就跟张栻讨论,张栻是胡宏的学生,认为‘未发就是性,已发就是心’,主张‘先察实,然后再持养’,这就是湖湘学派经世致用的发端。其后湖湘学派把这种心性的修炼和经世致用结合起来,像张栻的时候,他研究《孙子兵法》,而且认为《孙子兵法》是每个儒生必须要研究的。王船山还在这里办了一个社团,叫‘行社’,行动的行。曾国藩也专门解释过实事求是,说实事求是就是‘格物致知’,研究学问要格物,那个实事就是物,我们要格物就是要研究从实事中间来求得天理。朱夫子也好,王阳明也好,不管多少饱学先贤,也不过匆匆过客。只有从东汉就留下的这四个字,才是岳麓书院的精华,才是湖湘经世致用的根本所在。”毛泽东回过身来,“讲实话,做实事,不务虚,求真理,这才是值得我们记一辈子的原则!”\n他说到这里,也坐了下来,说:“我建议,今天我们就在这儿,对着这块匾,讨论一下,怎么做,才是真正的实事求是。”\n第十三章 可怜天下父母心 # 刘俊卿顺着孔昭绶手指的方向,走出房门,\n忽然,他愣住了,父亲竟然站在门口,\n全身都在颤抖,老泪纵横。\n刘俊卿无法面对父亲,\n更无法面对身份即将揭穿的难堪,\n他低头着,加快脚步,从父亲身旁逃也似的跑开。\n一 # 刘俊卿自过年那日被赵一贞的父亲羞辱赶出赵家之后,不敢再去赵家,每日里躲在巷口张望,心想哪怕是见上一面也好,至于真见了面要说些什么,或是答应赵一贞些什么,他是一点考虑也没有。故而每每看到赵一贞的身影出现在巷口,他反而躲得比赵一贞还快,唯恐被她发现。\n那一日,赵一贞还没放学,刘俊卿正躲闪着东张西望,冷不防被人横过来当胸一掌,推得他一个趔趄。刘俊卿大怒,挽了袖子正要上前据理力争,但一看原来是三堂会的老六,带着两个青衣打手直往赵记茶叶店去。刘俊卿忙把到了嘴边的话全部吞回肚里,远远憋在后面偷看。\n那三个人进到赵记茶叶店之后,一个青衣打手把一张印得三分像人,七分像鬼的关公像甩在柜台上。赵老板连忙拿了一块光洋恭恭敬敬放在关公像旁边。\n另一位青衣打手眼睛也不抬一下,说道:“马爷有话,从这个月起,你这种店面的香火钱一律两块。”\n赵老板赔着笑说:“两位爷,我这小店不一直是一块吗?”\n“怎么,不想给?”\n“不是这话。实在是生意难做啊,就一块我都是牙缝里挤着省呢……”赵老板只差点跪下了。\n“你这店是不是不想开了?”青衣打手把桌子一拍,赵老板吓得一哆嗦,老六看看这火候也差不多了,这才慢条斯理地故意问道:“这是怎么回事?”\n“这家不肯敬关二爷。”\n“不敬关二爷?嘿,我说你他妈的……”\n老六嘴里的三字经才刚出口,身穿周南校服的赵一贞正好进了门,顿时把老六看得呆了——女人他老六也见过不少,手下就打理着三堂会的两家妓院,但似一贞这般文静秀雅,既有良家女子的贤良,又有女学生的新潮的姑娘,他却着实是头回开眼,一时间看直了眼,目光追着一贞,直到一贞进了茶叶店的后院,放下帘子,这才回过神来。再回头看到手下正在又拍桌子又要拆店,他二话不说,“啪”地挥出一巴掌,打得手下晕头转向:“吵什么吵?你吵什么吵?老子在这儿,轮得到你来耍威风?从今天起,这间店的香火钱免了!谁敢再提拆店的事,我先把他给拆了!还不滚!”又转头对赵老板说,“没事了,没事了啊。老板,做生意,做生意。都是一家人,以后常来往,常来往啊。”说话间直出门去。\n看看老六一步一回头的样子,再回头瞄瞄里间的门帘,赵老板似乎猜到了其中的原因。\n这时赵一贞已经走过来,低声说: “爸,我出去一趟。”\n赵老板心不在焉地点点头。\n斑驳的夕阳,清清冷冷地洒在小巷陈旧的青石板上,一步,又一步,赵一贞在青石板来来回回地走着。她早就看到了躲在墙后的刘俊卿,或者说,她每天都看到了刘俊卿,她在等,等着刘俊卿自己出来,像个男人一样主动站出来。\n刘俊卿却仍然躲在墙后。\n赵一贞停下来:“你每天等在这儿,就是为了躲着我吗?如果你以为我和我爸想的一样,那你何必还等在这儿?你明明知道,有些事是我根本不会计较的,从认识你的第一天开始,我就没想过你是少爷公子还是穷学生,可要是连你都躲着我,连你自己都看不起自己,那你让我怎么办?这份感情,我需要有人跟我一起坚持啊!”\n刘俊卿脸贴在墙上,双唇紧闭。赵一贞也一动不动,她在等刘俊卿的回答,夕阳一点一点从她月白的衫子上退到墙角,直到巷子里都暗了下来,远处麓山寺的钟声隐隐传来,但刘俊卿仍然一言不发。赵一贞叹息一声,转过身来,缓缓离去。\n刘俊卿这时才从墙角转出身来,他张了张嘴,但最后还是忍住了,只看着赵一贞的背影,闭上了眼,抱头蹲下身来……\n二 # 一连几天,上课也好,读书也好,刘俊卿全无心思。这一天才放学,忽然见纪墨鸿向他招手,他一时进了纪墨鸿的办公室,只听纪墨鸿说道:“关上门。”\n“老师找我有事?” 刘俊卿掩上了门。\n“有个机会,你想不想抓住?” 纪墨鸿含笑着问他。\n“什么机会?”\n一时纪墨鸿说出一段话来,刘俊卿只觉得全身上下每一个毛孔都轻飘飘的,只要踮一踮脚,就可以飞起来。他告辞出来,飞快跑下楼梯,把迎面而来的同学手里拿着的试卷课本撞得满天飞扬。同学惊讶地看着他,他却看也不看一眼,抬起头继续向前飞奔,他心里只有一个念头,就是找到赵一贞,告诉她,他们的爱情有希望了,他们的生命,重新开始了。\n他冲进赵记茶叶店,把正在看店的赵一贞吓了一跳,又想父亲此时正好在家,生怕刘俊卿难堪,忙从柜台后面出来,打算拦住刘俊卿,却不料赵老板听到动静,马上从门帘后出来,把赵一贞往内屋推:“你给我进去,进去!”\n刘俊卿气喘吁吁:“一贞……赵叔叔,您听我说……有件事,我一定要告诉一声……”\n赵老板见刘俊卿不像来捣乱的,“什么事?”\n“我要当科长了,省教育司一司科长。”刘俊卿兴奋地说。\n“当科长?可你不是二年级还没读完吗?”赵老板冷眼看着他。\n刘俊卿解释说:“是这样,我有一个老师,是教育司的督学大人,刚代理了教育司长,他一直很欣赏我,这次那个科长的位置空出来了,他说,只要我能参加我们学校讲习科的毕业考试,考个第一名,他就推荐我接这个位子。到时候,我就是算是民国政府正式的文官,光薪水就比当老师高出好几倍,只要我再努力好好干,以后,还能升署长,升司长……”刘俊卿还欲滔滔不绝继续往下说,却被将信将疑的赵老板打断,“你说的——是真的?”\n刘俊卿一再保证,“是真的。赵叔叔,我一定会认真考,一定会争取到这次机会的。您就让我见见一贞,让我把这个消息告诉她,好吗?”\n刘俊卿知道赵一贞就在旁边,也听到他刚才所说的话,但是,他不管,他要亲口再对赵一贞说一遍,这是他对他们感情的保证。\n刘俊卿这番话,在赵老板听来,不可全信,也不可不信。在他的算盘里,与其让女儿跟三堂会老六那个大字不识,只会耍狠的流氓,还不如遂了女儿的心愿,许了眼前这位刘俊卿。这小子,穷是穷点,但好歹也是读书人,难免不保日后会有飞黄腾达的一天,说出去也体面。怕就怕这小子说话不尽不实,夸夸其谈,到头来,竹篮打水空欢喜一场事小,得罪了三堂会老六可就是身家性命不保的大事。\n赵老板脸上头一次有了笑容,对刘俊卿说:“我也没说过你们就不能见面了嘛!一贞,一贞。”等到一贞迫不及待从里屋出来,赵老板半步也不离身,挡在两个人中间,说:“俊卿呢,是来告诉你一个消息的,告诉完了他就会走,至于以后还来不来,就看他那个消息是不是能有结果了。”\n刘俊卿一心沉浸在爱情重燃希望的喜悦里,哪里想到就这短短三两分钟的工夫,赵老板这个生意人的脑子里,已转过了这许多念头。“您放心,赵叔叔!”刘俊卿口中喊着赵老板,目光却是迎着赵一贞,“这个第一名,我一定会考到手!”\n这些天,老六一天三趟地往茶叶店跑,赵老板唯恐他被撞见,忙说道:“话已经带到了,人也已经见到了,机会我已经给你了,至于晚饭我就不留你了,你好自为之。”\n赵老板一席话,倒勾出了刘俊卿的隐忧:转入讲习科参加毕业考试,这方面的手续问题,纪墨鸿既然开了口,自然不用他操心,但讲习科那边还有个天才萧子升,从入学作文开始,就一直压在他头上,压得他几乎喘不过气来。\n在刘俊卿看来,这世间的读书人也是分三六九等的,最差一等是王子鹏那样,读来读去都是倒数第几,自己对自己都没什么信心,幸亏有个好爹娘罩着,否则被人卖了还帮人数钱。再次就是毛泽东那种人,有一点小聪明就到处张扬,弄得天下皆知,不过一到考试就现真章,平均分也好,总分也好,加加减减下来也不过如此。最可怕的就是萧子升、蔡和森这种人,平时也没见他们如何熬夜加班加点努力用功,一到考试,却永远名列前茅。\n临近考试的那些日子,他找讲习科的同学借来听课笔记,拼了命地下苦功,心中已经定了目标,这回,一定要把萧子升远远抛在身后,把这个第一名考到手。\n然而事与愿违,他心中背了这个包袱,茶饭不思、没日没夜地熬下来,不知怎么,记性却反倒不如从前。这天王子鹏来帮他复习,几个问题考下来,刘俊卿竟答得一塌糊涂。\n“《独立宣言》是谁起草的?”\n“华盛顿。”\n“不是。”\n“那……富兰克林?”\n王子鹏又摇头:“是杰斐逊。”\n刘俊卿慌了手脚,本科要到明年才会正式开世界历史,讲习课却开得早,他原以为这段时间自己下了工夫,应该没问题了,不料越急越记不清,脑袋里全乱成了一锅粥。\n王子鹏看着刘俊卿面前堆得厚厚的笔记本,很为他担忧:“时间来得及吗?俊卿,不用太勉强了。”\n“没事,数学、英语这些基础科目我们都上过了,剩下的都是些要背诵的,无非是多花点时间背就是了。”刘俊卿只能自我安慰。\n“我看你把所有时间都用来背这些笔记了,别的老师都知道你报考讲习科一事,睁一眼闭一眼,但明天有袁老师的课,你小心点。”\n刘俊卿还是有些惧怕袁吉六的,大概就是所谓师道尊严吧。但另一方面,刘俊卿又觉得,袁吉六作为老师,过来人,更应该理解他,即便是抓到了他在课堂上背诵讲习科的笔记,也会放他一马。\n但刘俊卿失望了,袁吉六很生气,教鞭狠狠地抽在课桌上,两只眼睛瞪着他,一副要他把生吞活剥的样子。\n刘俊卿手忙脚乱:“袁老师,您听我解释……”\n“有什么好解释的?上课不认真听讲你还有理了!反了你了!”袁吉六抓起笔记,刷地扔到教室后面:“站到教室后面去给我好好反省,这堂课,你就站在那里听。”\n刘俊卿长这么大,一直是好学生,第一次被老师这样当面不留情面地批评。他不敢再说话,乖乖站到教室后面,笔记本就在他脚边,当着袁吉六的面,他不敢弯腰去捡。好不容易等到下了课,袁吉六离开教室之后,刘俊卿这才捡回笔记本,垂头丧气回了宿舍。\n宿舍里,易礼容和张昆弟把两张书桌拼起来,各拿着一只简陋的光板球拍,你来我往打起了乒乓球,引来了几十个六班和八班的同学过来看热闹,把宿舍挤得水泄不通。\n易礼容一招失手,被张昆弟抓住机会赢了一球,喝彩声之后,张昆弟大叫,“哈哈,六比五,你输定了!”\n易礼容不服气:“就一球,运气球,有什么了不起的,再来!”\n张昆弟洋洋得意:“这可不是运气问题,是水平问题,你就认输吧你,今天我吃定你了!”\n张昆弟这话言者无心,刘俊卿却是听者有意。这些天来,他所忌讳的,只有萧子升一人,刚才的课堂上,他出了这么大一个丑,张昆弟这些人无一不跟萧子升交好,当着他的面就敢这样指桑骂槐,背后说不定多么幸灾乐祸。刘俊卿想到这里,越发怒不可遏,仿佛疯了一样,扑上前去,一把夺过张昆弟手里的乒乓球拍:“吵吵吵!吵什么吵!还让不让人看书?这不是你一个人的寝室!你知不知道?”\n“啪”的一声,乒乓球拍被刘俊卿重重摔在地上。\n宿舍顿时安静下来,所有人都惊呆了。好半天易礼容才回过神来,悻悻地说:“走!我们都走!不要在这里耽误了某些人的远大前程!”\n待众人悻悻地出了寝室,刘俊卿猛地一把关上门,把所有声音关在门外,把自己一个关在宿舍里面。他知道,他已没有了退路,赵一贞那里没有了退路,他打开书本,呆呆地看着,但他的心却无法集中在书本上,而是迷失在某个无尽的空虚处。\n他现在,只剩了一个念头:只要能在这场考试中稳操胜券,无论什么办法,什么手段,他都将毫不犹豫……\n三 # 考试终于如期而至。\n这日刘三爹推着臭豆腐架子车来到一师附近,儿子不喜欢他在这里摆摊子,但今天是儿子参加讲习科毕业考试的关键时刻,他不放心,怎么也得来。前面是个陡坡,推上去就可以摆摊了。刘三爹竭力忍住咳嗽,这个动作他已经习惯了,在家时是为了不影响儿子学习,摆摊的时候又担心客人们不喜欢影响生意。\n坡很陡,刘三爹推了几次都没能推上去,他深吸一口气,准备做最大的努力,不料这口气堵在胸口,反而堵得他喘不过气来。\n他突然一头栽倒在地上。\n正要进校门的孔昭绶和王子鹏正好看见这一幕,赶紧上前同时扶住了刘三爹。孔昭绶连忙吩咐王子鹏:“快,去校医室。”\n校医检查的时候,刘三爹已经缓过劲来了,校医把孔昭绶拉到一边,低声说:“老人家暂时没什么大问题,不过,学校医疗条件有限,校长,这位老人家的病,还是上医院好好检查为好。”\n孔昭绶点点头,转向刘三爹:“老人家,要不要我通知您家里,送您上医院?”\n刘三爹看着孔昭绶,有些迷茫,王子鹏赶紧介绍说,“这位是我们一师的孔校长。”\n刘三爹吓了一跳:“不用了,不用了,校长大人,您是校长大人,那么忙,不用管我了,我没什么事,回头我自己去,自己去。”\n孔昭绶还是不放心,“那……您家里还有什么人?我叫人去通知一声,让他们来接您。”\n刘三爹吞吞吐吐:“我儿子……出去了,不在家,不在家的……要过些日子才能回来。”\n“哦。那……家里总还有别的人吧?”\n“倒是有个女儿,在大王家巷王议员家做丫头。”\n王子鹏一听,赶紧问道:“王议员家,她叫什么?”\n“阿秀?”\n王子鹏闻言不由一呆。\n正在这时,方维夏站在门口敲门,一脸严峻,背后有一个人在那里躲躲闪闪,正是刘俊卿。孔昭绶有些惊讶,方维夏找他找到校医室来,必是出了很严重的事。\n孔昭绶刚走到走廊,还没来得及关门,方维夏就说出了事情原委:“讲习科的毕业考试,有人作弊,被当场抓获。”\n“是谁?”孔昭绶怒不可遏。\n“原本科第八班转到讲习科的刘俊卿!”\n方维夏话音刚落,“啪”的一声,校医室传来一声巨响,孔昭绶和方维夏忙过头去,只见刘三爹定定地站在那里,看着地上摔碎的茶杯发呆,脸上是一片近乎死亡的灰白。一旁的王子鹏一连声地喊着:“刘老伯,您怎么啦,您哪里不舒服,您说话啊……”\n孔昭绶也急了,走到刘三爹身边:“老人家,老人家……”\n刘三爹仿佛这才从恶梦中惊醒过来,一把抓住孔昭绶的手,“校长大人,校长大人……”他正要把话说完,原本藏在方维夏身后的刘俊卿忽然听到父亲的声音,大吃一惊,抬头看了一眼,吓得赶紧又低了回去。\n“张校医,王子鹏,你们在这里照顾一下老人家,有什么事随时告诉我。”孔昭绶当着刘三爹的面,不方便处理刘俊卿的事,“维夏,我们去校长室再说。”\n来到校长室,方维夏把考场上从刘俊卿手里当场缴获的笔记本交给孔昭绶。孔昭绶猛然想起前些时候老师们纷纷反映,刘俊卿在上与考试内容无关的课时,总是背笔记。袁吉六那次闹得最凶,老先生回到教师办公室仍然气得吹胡子瞪眼,黎锦熙出来开解:“一师的记分方式改了没错,不再唯分数论,教育司录取公务员还是考考考分数是法宝,我们这些做老师的,不去体谅学生,反而责怪他们视分为命,于心何忍?”\n想到这里,孔昭绶的脸色稍稍缓和下来,对刘俊卿说:“通知你的家长,下午来学校。”孔昭绶觉得,刘俊卿这一次的作弊行为,错误性质非常严重,但也并非事出无因,应该跟家庭教育方式有很大关系,有必要进行沟通,再下处分决定。\n刘俊卿低头站在角落里,神经质地咬着嘴唇,一言不发。\n方维夏忍不住推了推他:“校长的话你听到没有?”\n刘俊卿似乎这才回过神来,慌慌张张地说:“我……我爸爸不在家。”\n“那就明天来。”\n刘俊卿不再出声了。\n孔昭绶提高声音,“怎么了,难道明天也不在吗?”\n“我……我爸爸在外地做生意,平时都不在家。”刘俊卿千方百计找理由搪塞。\n方维夏也看不过去了:“刘俊卿,到底是不在家还是你不愿意叫家长来?”\n刘俊卿又开始一言不发。\n孔昭绶涵养再好也忍不住了,他猛地打开房门,向门外一指:“刘俊卿,你必须找你的家长来,因为,按照校规,你将被开除!”\n几乎是下意识的,刘俊卿顺着孔昭绶手指的方向,走出房门,忽然,他愣住了,父亲竟然站在门口,全身都在颤抖,老泪纵横。刘俊卿无法面对父亲,更无法面对身份即将揭穿的难堪,他低头着,加快脚步,从父亲身旁逃也似的跑开。\n“孔校长,对不起,我……我拦不住刘老伯。”王子鹏急着跟孔昭绶解释。\n“老人家,您有什么事慢慢说,不用急……”孔昭绶一句话还没说完,“扑通”一声,刘三爹直挺挺跪倒在地。\n“老人家,您这是干什么?”孔昭绶、方维夏、王子鹏都大吃一惊,赶紧扶住刘三爹。\n刘三爹怎么也不肯起来:“我求求您,校长大人,您不要开除他好不好?我求求您,求求您放过他一回……”刘三爹一边说一边拼命地磕头,额头在地上碰得砰砰直响!\n“老人家,您先起来说话,先起来啊。”\n“我不能起来,您不答应我,我不能起来啊……”刘三爹此时只有一个念头,要求得孔昭绶答应为止。\n几个人一齐用力,总算把刘三爹架了起来,孔昭绶问他:“老人家,您这到底是为谁求情啊?”\n“刘俊卿啊,就是您那个学生刘俊卿啊。他还小,他不懂事,他不是有心要犯错的,您大人大量,就饶他这一回吧,我求求您了!”刘三爹的额头已经磕出血来,触目惊心。\n孔昭绶问:“您为什么替刘俊卿求情?他是你什么人啊?”\n“他……”刘三爹差点冲口说出他跟刘俊卿的关系,但刚才他又是磕头又是求情的闹,四周已围上不少看热闹的人,想起儿子是最要面子的人,不禁语塞,“他,他……不是我什么人,我不认识,不认识的……”\n“不认识您为什么来替他求情?”\n“我……我……我就是觉得他是个读书的料子,就想求您给他个机会,给他个机会……校长大人,求您了……”\n那一刻,孔昭绶与方维夏的心头,不禁全是疑云。\n四 # 那天夜里,孔昭绶约了方维夏,按照刘俊卿学籍单上的家庭住址,一起去做家访,却在刘家门外的小巷里,正好遇上了也来探望刘三爹的王子鹏。\n师生三人一同寻到刘家门口时,刘三爹也正倚在床头,苦口婆心劝儿子:“俊卿,算我求你,去认个错吧。我看你们校长是个好人,不会不给你机会的。俊卿,去求求他,明天就去,好不好?”\n刘俊卿背冲着父亲,却是死不开口。\n“你怎么就不听话呢?”刘三爹咳得喘不过气来,秀秀赶紧拼命地抚着他的后背,尽量帮他顺气:“爸,您别说了,说这些有什么用?”\n“家里现在这个情形,不读一师,俊卿还能上哪儿去读啊?”刘三爹心一急,牵动了病情,又开始不停地咳嗽,“好歹也读了两年了,总不能白读了不是?”\n秀秀一边帮父亲捶背顺气一边心疼地说:“爸,歇歇好吧,为了哥,您都熬成什么样了?”\n“我不怕,我怎么都熬得住,我只要俊卿有出息。”\n“可我心疼!我也想哥有出息,可出息也要自己把得住,不能拿您的命来换啊!”\n“够了!”刘俊卿听着父亲和妹妹的对话,一字一句,都像刀扎在心口上一样,“你们说够了没有,啊?说够了没有?是,我没出息,我自找的,我混蛋!可我愿意这样吗?你以为我不想好好读书?我也想!我也想出人头地,我也想光宗耀祖!我也梦想有一天,自己有大好前程,到那个时候,爸不用再卖臭豆腐,你也不用再给人当丫头,咱们刘家都能过上好日子,都能挺直腰杆做人!可做梦有什么用?有什么用啊?”\n刘三爹和秀秀都被吓呆了,秀秀扶着父亲,看着刘俊卿踢翻凳子,狂乱地挥舞着手臂想要抓住些什么,想要与虚空中的命运拼命,但最终,还是两手空空。\n刘俊卿越说越癫狂:“这个世道就是这样,卖臭豆腐的儿子就是卖臭豆腐的儿子,我不是你那个王少爷,天生的好命,要什么有什么,我只是个穷卖臭豆腐的儿子,穷买臭豆腐的儿子!没有人看得起我,没有人会给我机会,哪怕是给了,老天也抢走它——老天爷也知道,我就是个穷卖臭豆腐的儿子,我没有别的选择啊……”\n说到这里,刘俊卿已经撑不住了,颓然坐在地上,全身犹如散了架一样,什么也没有了,房间里安静得只听得到呼吸声。\n正在这时,吱呀一声房门声响,刘俊卿吓了一跳,抬头看时,孔昭绶、方维夏,还有王子鹏正站在门前!\n三人打量着整个房间,除了破败还是破败,唯一与这破败格格不入的,是刘俊卿脚上那双蹭亮的皮鞋。\n孔昭绶不禁长长叹了口气……\n从刘家回来后,孔校长一直在想该如何处理刘俊卿作弊、如何帮助刘三爹度过目前的难关。刘三爹自从生病后,身体大不如前,已经不能风里雨里外出摆摊了,但他如果不做事情,家里的生活就无以为继。经黎锦熙提议,孔校长决定请刘三爹来学校做校役,这样从吃到穿的问题就都解决了。刘三爹对孔校长又给他送医药费,又给他安排事做感念不已。当然最让他感动的,还是孔校长能让刘俊卿继续回学校读书。\n“学校嘛,也只是不想随便放弃一个学生,希望能给每一个年轻人一个改正错误的机会而已。刘俊卿,经过这次的事,我希望你能真正认识到自己的错误,不辜负学校,特别是不辜负你这位含辛茹苦的老父亲。可怜天下父母心啊,你要明白,要不是为了他这番苦心,学校是绝不会给你这次机会的。”\n孔校长的这番话刘俊卿是完全听明白了的,他在接受了开除学籍、留校察看的处分后,被安排回到了本科八班。\n一个星期后,刘俊卿重返校园,只见校园内外装饰一新,“第一师范讲习进修班毕业典礼”的横幅,高高悬挂在礼堂正中。通往礼堂的路上,八班的同学们身穿整齐的校服,一边走一边兴高采烈地讨论些什么。刘俊卿忙迎上前去,在脸上堆出笑容打算跟他们打个招呼,才走了不过两三步,同学们看到他,原本热闹的气氛一下子消失了,纷纷加快脚步,远远绕开他。\n刘俊卿不得不停下脚步,远远地站在一边,在那群人中寻找着熟悉的身影,终于,他看到了王子鹏,很显然,王子鹏也看到了他。\n刘俊卿欣喜若狂,踮起脚,挥起右手,刚要喊王子鹏的名字。就在此时,王子鹏一侧身,避开他的目光,抢在他开口之前叫道:“周世钊。”挽住周世钊的肩,很快融入人群。\n刘俊卿木然地继续走着,今天的毕业典礼,所有老师也来了,纪墨鸿走在最前头,满脸是笑。刘俊卿精神一振:“老师……”他才吐出这两个字,纪墨鸿却扭过了头,仿佛眼中没看见这个人,又仿佛从不认识他刘俊卿,迈着方步从他身边走了过去。\n进到礼堂,刘俊卿悄然在最后一排找了个位置坐下。讲习科的毕业生们都坐在第一排正中,老师们反而坐在了两旁。偌大的礼堂里,座无虚席,掌声如雷,毕业生正按孔昭绶读出的名字,次第走上讲台,领取毕业证。\n“……讲习科第二名毕业生:何叔衡!”掌声中,何叔衡上台,向孔昭绶鞠躬,接过毕业证,转身向台下师生鞠躬,最后面向校旗九十度鞠躬。\n孔昭绶拿起最后一份毕业证:“讲习科第一名毕业生:萧子升!”\n刘俊卿猛然抬头,主席台上,萧子升正从孔昭绶手里接过毕业证书,台下,杨昌济,徐特立,袁吉六,还有毛泽东,蔡和森,都在鼓掌。刘俊卿暗暗咬了咬嘴唇,低头悄然离开了礼堂。\n刘俊卿一个人在学校里漫无目的地乱走,他知道他现在只有忍,但他无法抑制自己心中的失落和恨意,礼堂内的掌声还在一阵接一阵,仿佛像一把刀,在一点一点的刺他的心,一种尖锐的疼痛瞬间传遍了全身。他握紧了拳头,一拳击在一棵老槐树上。\n这时忽然有脚步声传来,刘俊卿回过头,远远见何叔衡、萧子升、蔡和森和毛泽东四人一面说笑,向这边走来。他立时向树后一闪,只听毛泽东笑说:“我们同学终于有人有收入了,子升进了楚怡小学,叔翁你呢?”\n“修业小学。”何叔衡答道。\n“好好,都离长沙不远,以后没饭吃,就去吃你们的大户。” 毛泽东大笑说。\n“还是那句话,有我萧子升一口,就有你毛泽东一口。” 萧子升肃然说。\n蔡和森在一边沉吟一时,说:“虽然叔翁和子升兄毕业了,可我们读书会的活动还得继续,叔翁和子升兄,仍然是我们读书会的一员,每次活动,没有特别理由不得缺席。”\n何叔衡忙说:“求之不得。”\n四个人一路说话,全没有在意到刘俊卿,直走了过去,远远只听萧子升问,“润之兄,马上就放暑假了,你有什么计划没有?”\n“我跟张昆弟约好了,这个暑假留在长沙读书,至于住宿问题嘛——”毛泽东嘿嘿一笑,“当然是去蔡和森家打秋风啰。”\n刘俊卿从树后走了出来,他冷冷地看了四人的背影一眼,握紧了双拳。\n第十四章 纳于大麓 烈风骤雨弗迷 # 风,浴我之体,\n雨,浴我之身,\n烈风骤雨,\n浴我之魂!\n山川在我脚下!\n大地在我怀中!\n我就是这原野山川之主,\n我就是这天地万物之精灵!\n一 # 暑假的第一个星期天,陶斯咏满20周岁。因为是整生日,中国又素来有男做单女做双的规矩,陶会长决定为女儿大肆操办一番。\n多方打听之后,得知德国洋行那里新来了一个做西餐的西洋厨师,会做很精巧的叫什么生日蛋糕的西式点心。陶会长亲自把这人请到家里,忙碌好几天,做了一个一米多高的九层大蛋糕,每一层除了雕花奶油之外,还装饰了各式时令水果。陶斯咏和向警予也是第一次看到这么大的生日蛋糕,看了好半天之后,斯咏才说道:“爸,其实也就是个生日,用得着那么讲究吗?”\n陶会长呵呵笑着说:“我的女儿满20,怎么能不讲究呢?再说,你姨父姨母和你表哥也要来给你过生日,总还要给他们面子嘛!”\n“我过生日,关他们什么事?”\n“你以后总归是他王家的人嘛……”陶会长看见斯咏拉下了脸,赶紧收口,“不说了不说了,反正啊,这个生日,得给你过热闹了。”\n斯咏却突然想起了什么:“爸,我能不能另外请几个朋友来参加?”\n陶会长笑着说:“那有什么不行?人多热闹嘛!”\n“这可是你说的。”\n“只要你愿意,有多少请多少。你就是把全校同学都请来,我也给你开流水席。”\n“哪有那么夸张!就……”陶斯咏看看站在一旁的向警予,“就两个。”\n“是哪家的小姐?我这就叫人送帖子去。”\n“不用了,这两个人,我跟警予亲自去请。”\n陶斯咏拉了向警予就走,陶会长追在后面喊:“记得早点回来,晚上等你开席呢。”\n出了陶家大门口,警予问斯咏:“哎,你到底要请谁呀?”\n斯咏冲她一挤眼睛,悄悄说:“蔡和森和毛泽东。”\n“你疯了,你陶大小姐过生日,请两个外校男生到府,就算你爸不说,你那未来的公公、婆婆会怎么想啊?”\n“我偏要请,管他们怎么想。”\n“好,你请你请,可想请也得找得到人啊,现在都放暑假了,这么大个长沙,你上哪儿去找一个毛泽东?”\n斯咏却是一笑:“这我早就打听清楚了,这个暑假,毛泽东住在刘家台子蔡和森家读书。”\n二 # 毛泽东的确是和张昆弟早已约好了暑假留在长沙读书,两个人都没有租房的钱,只能相约借宿蔡和森家。可当萧三清早帮张昆弟送行李到蔡家,才知道蔡家已经连饭都没得吃了,原来租的三间房,也退掉了两间,连蔡和森自己都没地方住。萧三和张昆弟拿着行李,只得回到子升任教的楚怡小学。\n子升听他们解释了半天之后,问:“润之呢?”\n张昆弟说:“他说他下午动身,现在估计快到蔡家了吧?”\n子升沉吟了一下:“昆弟,你就先在我这儿住下。咱们分头出发,多找几个朋友,尽量凑点钱,到蔡家去。”\n这边子升在忙着想办法,那边毛泽东却还蒙在鼓里。在学校吃过午饭,他兴致勃勃地过了湘江,来到溁湾镇,找到了镇子最南边的蔡家。\n进门看时,却见蔡家正在搬家,狭小的房间里,中间搁了一张床,四周被家具书本杂物堆得满满当当,几乎连转身的地方都没有。葛健豪和蔡畅正在里面收拾。毛泽东连忙放下行李卷,一边帮着做搬运之类的重活,一边问道,“伯母,蔡和森呢?”葛健豪犹豫的工夫,蔡畅已经代为回答了,“我哥搬到爱晚亭去了。”毛泽东当即明白了,也不说话,只搬着东西。\n毛泽东帮完忙时间已近黄昏,他扛着行李卷,直奔岳麓山而来,沿石径而上。天气极是闷热,空中云层越积越厚,直从远处绵延的山峦之间纷涌过来,山道上蜻蜓四处乱飞,毛泽东忖度着要下大雨,不由加快了脚步。\n爱晚亭内,一座旧草席铺在正中地面上,亭栏上一竹篮子的书,旁边是叠得整整齐齐的几件简单衣物。两根亭柱间拉着一条麻绳,蔡和森正在将刚刚洗过的一师校服晾上绳子。大概是熟悉了的缘故,几只胆大的小鸟叽叽喳喳,在他不远处自在地觅食。毛泽东童心忽起,身子猛然向前一冲,鸟儿们拍起翅膀,扑啦啦飞上半空,他这才大声说道:“远上寒山石径斜,白云深处有人家——蔡隐士,好个首阳遗韵,夷齐之风啊!”\n两个人不禁相视一笑。\n山风之中,蔡和森帮着毛泽东铺开了行李:“让润之兄陪着我露宿山野,对不住了。”\n“天当房,地当床,清风伴我好乘凉。好得很嘛!”毛泽东往铺盖上一躺,双手往脑袋后面一背,“不到这山野中露宿一番,哪里享受得到这夏夜清凉,体会得到这天人一体的境界?”\n“你还别说,昨天在这儿住了一晚,仰头苍茫无尽,低头群山巍巍,着实是大开心胸啊。就是有一点不好。”\n他话音未落,两个人的肚子里咕噜噜响起一阵饥肠之声。\n两个人哈哈大笑起来。\n就在此时,一道闪电骤然划破长空,紧接着轰然一声,惊雷骤起,大雨不期而至,天色也刹那间暗了下来。顿时莽莽岳麓笼罩在一片倾盆大雨之中,雨水如帘,从亭檐直垂下来,被风一吹,一扫酷热烦闷。\n蔡和森手忙脚乱收拾着衣物书籍,毛泽东将双手伸在雨中,感受那份雨水冲刷的凉爽和快意,还是觉得不过瘾,遂回头叫道,“唉,老蔡,想不想去爬山?”\n“爬山?”\n“对啊,趁着这满山夜色归你我所独享,烈风骤雨中,凌其绝顶,一览众山,岂不快哉!”\n望了望亭外密密麻麻的雨点,再看看毛泽东跃跃欲试的眼神,蔡和森腾地站了起来:“去就去!”\n毛泽东一把握住了他的手:“走!”\n两个人一步冲出亭去,惊雷闪电中,大雨一下子浇了他们满身。\n“雨中的岳麓,我们来了!”\n忽然,一道闪电,似乎把前面的天空划开了一道口子,片刻之后,惊雷在他们身后响起,毛泽东大笑,“老蔡,我们快些跑,看是这闪电快,还是我们快。”二人顿时狂奔起来,只听毛泽东的声音在大叫“老蔡,我们来喊吧,看是这雷声大,还是我们的喊声大!”\n“啊……啊……”山道上,湿透的毛泽东和蔡和森长啸狂奔在雨中,喊声划破雨夜,直震长空,仿佛两个狂野的斗士,完全融入了雨中的自然。\n“润之!”“润之哥!”“蔡和森!”风雨中,隐隐有无数声音传来。\n蔡和森停下来,拉住毛泽东,“有人在叫我们?”“好像有很多人?”二人顺着喊声直奔回去,只见萧子升、萧三、张昆弟、陶斯咏、向警予,甚至蔡畅也来了,站在爱晚亭里焦急地张望,蔡畅急得直跺脚。向警予倒也罢了,平日里斯文含蓄的陶斯咏鞋袜、裙摆全已湿透,斑斑点点溅满了黄泥。看到他们二人从树林里钻出来,陶斯咏这才放下那颗一直悬着的心。\n“你们怎么来了?”毛泽东问。“来找你们啊,今天是……”向警予刚要说话,不料却被萧子升打断,“还问我们,这么大的雨,你们这是上哪里去了?”“爬山啊!”“爬山?”“对啊,刚从山顶下来。”毛泽东似乎意犹未尽。\n“大风大雨的,爬山?你们搞什么名堂?”萧子升问道。原来,毛泽东那边前脚离开蔡家,萧氏兄弟和张昆弟后脚也凑钱赶到了蔡家,待安顿好了蔡家断炊的事,却正撞见陶斯咏、向警予来找毛蔡二人去庆祝生日,得知他们二人在爱晚亭,便一齐找上山来。\n“大风怎么了?大雨怎么了?古人云:纳于大麓,烈风骤雨弗迷!今天,我和蔡和森算是好好体会了一回!老蔡,你说是不是?”毛泽东回过头问蔡和森。\n“没错!风,浴我之体,雨,浴我之身,烈风骤雨,浴我之魂!” 蔡和森一扫平日的沉稳。\n“说得好!”向警予情不自禁,放开嗓子大喊一声,“说得好!说得太好了!”\n毛泽东大踏步重新回到雨中,“来呀,还站在那里做什么?来体会一下,体会这风,体会这雨,享受这大自然的畅快淋漓!”蔡和森也在喊着,“来呀,都来呀!”\n向警予一阵面红耳热,第一个扔掉雨伞,大雨一下子浇在她身上,一阵畅快的清凉袭遍全身,她仰起头,迎接着雨水,纵情高呼:“舒服,真的很舒服!你们快来啊,都来试试!”\n萧三、张昆弟和蔡畅也深受感染,一个接着一个,扔掉雨伞,抛开一切束缚,冲进雨中大喊大叫。\n陶斯咏看着雨中兴奋不已的朋友们,千金大小姐的矜持正慢慢从她身体里远去,她迈出子升为她撑着的雨伞,冲进了雨中。大雨冲刷着她的身体,她仰起头,伸出双手迎接着雨水,似乎要把这20年来一直束缚着她的东西全部冲走,感受到那股从灵魂深处彻底解放出来的自由。她轻轻舔了舔嘴角的雨水,雨水竟然是咸的。不知何时,束缚的泪水、放纵的雨水已经混为一体,已分不清哪是泪水,哪是雨水。\n“山川在我脚下!大地在我怀中!我就是这原野山川之主,我就是这天地万物之精灵!”毛泽东大喊着,一手抓住斯咏的手,另一手握住了蔡和森,“来呀,一起来呀,跟我一起喊,风——雨——雷——电——”\n苍茫的原野上,青年们充满了自由力量的长啸狂呼声,应和着原始、野性的自然之力,刺破夜空,在电光飞闪中,如疾电破空、惊雷掠地!\n三 # 陶府上上下下寻了整整半夜,差点把雨中的长沙城翻了个遍,才从码头附近撑渡船的船夫那里打听到,天擦黑的时候,有两位小姐坐他们的船过了江,说是要去刘家台子,听衣着打扮,应该就是斯咏她们。\n陶会长领着家人、仆役,心急如焚地过江寻来,狂风渐弱,雷电渐息,刚过了溁湾镇,却听到一阵吟啸声直撼而来:\n我本楚狂人,凤歌笑孔丘。\n手持绿玉杖,朝别黄鹤楼。\n五岳寻仙不辞远,一生好入名山游。\n庐山秀出南斗傍,屏风九叠云锦张。\n影落明湖青黛光,金阙前开二峰长。\n银河倒挂三石梁,香炉瀑布遥相望……\n来的正是毛泽东等人,他们从岳麓山上一路狂呼长啸,吟诵而来,刚刚下了山,迎面忽然是一片火光通明,写着大大的“陶”字灯笼一排列开,众多仆役恭恭敬敬地齐声叫道:“小姐。”把大家都吓了一跳。\n萧子升抬头看见陶会长板着脸,站在众多仆役的最前面。他再回头找到陶斯咏,看到她正悄悄缩回一直被毛泽东拉着的手。\n陶会长深吸一口气,竭力压住心头的怒火,放缓了语气问:“斯咏,这几位是?”\n“我的……几个朋友。”陶斯咏忐忑不安,但有些不甘心,特地跟毛泽东介绍,“这是我爸。”\n陶会长打量着这群人,个个身上滴着水,鞋袜衣裙,到处溅着泥点。\n“斯咏,今天你生日,你姨父姨母一直在家等着给你过生日呢,先回家吧。”陶会长说。\n“今天你生日?”毛泽东有些意外。斯咏点头。“你看你怎么不早说?都没给庆祝一下……”“斯咏。”陶会长打断毛泽东,脸上的微笑快保持不住了,“走吧。”又说道,“谢谢你们几位送斯咏,我们先走一步了。”\n陶斯咏跟着父亲走了几步,忽然回过头来对毛泽东说道:“谢谢你,也谢谢大家,让我过了一个有生以来最有意义的生日!”\n四 # 斯咏的背影随着马车渐渐远了,大家怅然若失,兴奋过后疲倦袭来,打算各自散去,萧子升问道:“警予,你去哪里?周南好像现在关了门。”\n向警予笑笑说:“我现在无家可归了,你们谁收留我。”众人都呆了一呆,大家一群光棍,如何收留一个女孩子。蔡畅想了想笑着说:“去我家吧,只是太挤。我、我妈还有你三个人一张床。警予姐你习不习惯?”\n向警予笑着回答:“我无所谓,只怕太打扰了。” 毛泽东笑笑:“就这样定了,老蔡负责把两位女士送回家,我还是到爱晚亭当亭长去。”\n蔡和森、蔡畅陪警予一路回了蔡家,蔡畅一阵风似的蹦进屋来:“妈,我们回来了。”\n葛健豪正在看书,一抬头,却见神采飞扬的儿子身边竟然有一个明眸皓齿的少女,落落大方地望着她。\n“妈,这位是向警予小姐。”蔡和森倒是没有丝毫扭捏。警予甜甜地叫了声“伯母”,目不转睛地望着葛健豪。只见她虽然穿一件粗布上衣,眼角爬满皱纹,但一双眼如一泓深潭,深邃宁静,而举止之间,自然显出一种优雅沉静,仿佛天然生成的一般,全无半点的矫揉造作。\n蔡畅换好了衣服,笑嘻嘻地拿了一套自己的衣服递给警予,葛健豪笑了笑,“你的衣服能穿啊?”丢了套衣服给儿子,“把门关上,出去换了。”蔡和森再进屋顿时眼前一亮,松烟灯下,警予穿着一件衣料华美、刺绣精致的老式大红旗式女装,映红了她白净的脸蛋,越发衬得眉目如画,娇艳无比。葛健豪打量着警予,多年不穿的嫁衣倒也找到了个好衣架子,欣赏地笑了:“真像我年轻的时候啊。”蔡畅拍手叫道:“好漂亮,好漂亮,警予姐穿上妈的衣服,就像个刚出嫁的少奶奶。” 警予眼角瞟到呆子般的蔡和森,终于也羞涩起来,她有些慌乱地拿起了葛健豪放在破木桌上的书——那竟是一本雪莱的诗集!\n“伯母,您在看这本书?”警予惊讶地问,葛健豪微微一笑,算是承认,“跑了半晚上,都饿了吧?晚上就吃山芋煮野菜,家里没什么别的东西,委屈向小姐了。”\n“挺好啊,我正好尝尝鲜嘛。”\n吃饭时警予悄悄扫了一下四周,狭小的房里,家具杂物并不多,都已破旧,触目所及到处是书。葛健豪一边看书一边吃饭,夹到了一块山芋,顺手放进了蔡畅碗里,又夹起野菜送进嘴里。警予看得呆了,想起刘禹锡那老夫子的话:何陋之有啊?!\n吃过了饭,夏日雨后的夜空,清亮透明,清风过处,警予的心如微波浮动。她第一次安安静静地坐在蔡和森身边,听他娓娓道来。\n“我妈妈原来不叫葛健豪,叫葛兰英。我外公是曾国藩的一员部将,做过道台,所以我妈也算大户小姐出身。年轻的时候,她和鉴湖女侠秋瑾、同盟会的第一位女会员唐群英曾经是非常好的朋友,三个人还结拜过姐妹呢。”\n警予睁大眼睛望着他,秋瑾、唐群英?蔡和森微微一笑,继续说道:“16岁的时候,我妈嫁给了我爸,成了湘乡大财主蔡家的少奶奶,你身上穿的这件衣服,就是她出嫁的嫁衣。后来呢,她就生了我们。我小名叫彬彬,老家的人都叫我彬少爷。”\n警予疑惑地望着他,欲言又止。\n蔡和森看出她的疑惑,笑了笑:“你是想问现在怎么会这个样子?是吗?简单说起来,因为我妈跟我爸不是一路人。我妈妈爱读书,个性也强,她相信男女应该平等,相信社会一定会进步,相信女人也能成为社会的栋梁,所以我妈跟我爸的关系一直不好。后来,我爸到上海,学会了抽鸦片,还讨了小老婆,我妈就跟他彻底闹翻了。两年前,我爸做主,收了一个财主家500块光洋的聘礼,把我妹妹许给那家同样抽鸦片烟的儿子,我妈妈坚决不同意,就跟我爸离婚了。”\n警予简直不敢自己的耳朵:“离婚?”\n“不敢相信是吧?在那样的封建家庭里,一个女人,居然主动提出离婚!简直就是大逆不道,伤风败俗。我爸当然不答应,就提出条件,除非我妈妈放弃一切家产,一分钱也不带走。他肯定觉得,像我妈这样做了半辈子少奶奶的家庭妇女,一旦离开夫家,绝不可能生存下去,所以就用这样的条件挟胁我妈。”\n“但伯母偏偏就答应了。”警予慨然叹道。\n蔡和森笑了:“做了半辈子夫妻,我爸还不如你了解我妈,她一点也没有犹豫就答应了,带着我们兄妹,就这么空着两手,离开了那个家。”\n“所以你就从彬少爷,变成了现在的蔡和森?”\n“能够跟妈妈在一起,还有什么是不能放弃的呢?你知道吗?就靠那双手,妈妈养活了我们兄妹,供我们读书,她自己还半工半读,进了女子教员养成所,成了全长沙年龄最大的学生。就是在进校那天,她改成了现在的名字——葛健豪。”\n两个人幽幽地吸了口气,灯光从窗口透出,葛健豪的影子投在窗纸上,她正在补衣服,警予觉得她补衣服的影子都透着难以言表的高贵!\n警予突然握住了蔡和森的手:“你知道吗?以前,你一直是我的偶像。今天我才知道,为什么你会成为我的偶像。”\n“为什么?”\n“因为你有这样一个好妈妈。”\n五 # 一夜大雨之后,清晨柔和的阳光照在爱晚亭外垂柳的叶尖上,雨珠晶莹剔透,耀出七彩的光。池塘胀满,燕子直掠而过,歇在亭子的檐上呢喃。\n杨昌济一脚踏入爱晚亭,毛泽东兀自睡梦正酣,手脚袒露,被子也被踢到一边。杨昌济在他身边轻轻地站住,俯身下身来看着他,一年多以来,他对这个小伙子越来越欣赏,隐隐觉得在他的身上担负着自己一生中未竟的理想,他不敢说从他身上看到了国家的希望,但可以肯定地说,他看到了这个时代的希望。他是一块尚未琢磨的宝玉,而自己,是琢玉者。\n毛泽东隐隐感觉有个影子挡住了阳光,睁眼一看又惊又喜:“杨老师?”\n“要不是子升告诉我,你是不是打算在这亭子里当一假期野人啊?” 原来暑假来临,杨昌济嫌城市喧嚣,打算回到老家长沙县板仓乡下,临走时萧子升前去道别,才知道毛泽东在爱晚亭睡在“天地之间”,又好气又好笑,决定把这孩子带到乡下,也让他安心读书。\n毛泽东翻身起来,搔头笑了笑,赶紧手忙脚乱收拾东西。\n板仓离长沙不远,一上午工夫便到了。一路稻浪如海,随风而起,毛泽东随杨昌济转过一座小桥,远远便见一座大宅子隐于绿树之中,青砖鳞瓦,阳光照过来,屋后丘陵绵延起伏,四处寂静一片。\n先走进门来的杨昌济一边回头招呼着还站在门外的毛泽东:“愣着干嘛?进来吧。”一边给妻子向仲熙和儿子杨开智介绍道:“我的学生,毛润之,你们都听我提起过的。润之,这是你师母。”毛泽东扛着行李走进门来,赶紧鞠躬问好。向仲熙看着这个高大而羞怯的年轻人微笑着点点头。\n杨昌济随即问道:“对了,开慧呢?”\n向仲熙说道:“谁知道又上哪儿疯去了?这丫头,一天到晚也没个消停。”\n杨昌济也不以为意,向毛泽东一挥手说:“润之,跟我来。”他径直把毛泽东带到书房,“这个暑假,你就住这儿了,我这儿也没什么别的,就一样有你看不完的书。”毛泽东顿时眼睛都直了——偌大的书房里,重重叠叠,一架一架,一层一层,全是书,毛泽东上前抚着一层层的书本,贪婪地伸过头去,双眼圆睁,恨不能一下子把它们看个仔细。\n杨昌济笑说:“生活上需要什么,只管跟你师母说,她会给你准备的。”\n“不不不,什么都不要,” 毛泽东兴奋得有点语无伦次,“有这些就够了,什么都够了,都够了都够了。”他把行李卷随手往地上一扔,抽出一本书,往行李上一坐,迫不及待地翻了起来。\n杨昌济微笑带上门出来,只听他吩咐向仲熙:“仲熙,从今天起,多做两个人的饭。”\n“不是就一个客人吗?” 向仲熙一怔。杨昌济只一笑,说:“照我说的做,没错的。”\n毛泽东全不理会,在那里看书。不知道过了多久,眼前的书上,突然扫过一条辫梢,毛泽东一抬头,一双清澈见底的眼睛正盯着他。他眯了眯眼,一个扎着小辫的小姑娘,十四五岁,托着俏生生的圆脸,带着好奇和挑衅。\n毛泽东奇怪地问:“你看什么?”“看你呀。”“看我什么?”“看你的眼睛。”“我的眼睛?”\n“看看跟一般人的有什么不一样,看看我爸爸为什么会说有个学生眼睛怎么怎么明亮啊,有神啊,坚定啊,藏了好多好多远大理想在里头啊。”开慧夸张的表情把毛泽东逗笑了,他捏了捏她的鼻子,“哦,我知道你是谁了,杨开慧,我的小师妹。”开慧头一偏,也伸手一捏他的鼻子:“我也知道你是谁。毛泽东,我爸爸最喜欢的学生。”\n两个人同时说道,大笑起来,好像久别重逢的好朋友。开慧拉着他,“快走吧,我是来叫你吃饭的,你看书的时候爸爸不让我过来呢。”\n饭桌上毛泽东的表现让杨家人开了眼界,捧着一只大得吓人的海碗,狼吞虎咽,吃得啧啧有声。开慧惊奇地盯着毛泽东的吃相,他第一碗很快见底,到饭甑边抄起大饭勺,一连几下,他居然又堆了满满一海碗饭,饭桶一下子空了大半。开慧目瞪口呆,向仲熙却看着杨昌济会心一笑。\n毛泽东回头这才发现发现大家都看着自己,当下里端着大碗,有点不好意思起来。向仲熙连忙夹上一大筷子菜,放进了毛泽东的碗里,笑道:“快坐下吃,润之,我呀,就喜欢看你们年轻人吃得多,吃得多,身体才好嘛,就跟在自己家里一样,别客气啊。”\n一连十几天,毛泽东都呆在书房,也不管好歹,书架上的书,摸了一本就读,读罢便放在左手边,一时那里的书越堆越多。这一天他正看得出神,忽然一只纸折的蛤蟆放到了他的头上,回头见开慧笑嘻嘻地站在他身后。他哈哈一笑,抓下头上的纸蛤蟆:“没有天鹅肉吃,我可不愿意当癞蛤蟆。”\n开慧伸手给他,“来,咬一口啊。”\n毛泽东笑说:“哦,我把小师妹吃了,老师还不得找我算账?”\n开慧哼一声说:“谅你也不敢!”她靠在毛泽东身边坐下:“看什么呢?”伸手把书拿了过来,“《诸葛亮文集》?早就看过了。”\n“你才多大,就看《诸葛亮文集》?”\n“谁说我小啊?下学期我都上中学了,看这个算什么?”\n“好好好,十四岁的大姑娘。那我抽一段考考你。”\n开慧急了:“我只说看过,又没说都记得。难道你看一遍就都记得啊?”\n“差不多。”\n开慧噜着嘴:“吹牛皮,我不信!”随手翻开一页,“《诫子书》,背呀!”\n毛泽东张口就来:“夫君子之行,静以修身,俭以养德,非淡泊无以明志,非宁静无以致远。夫学须静也,才须学也,非学无以广才,非志无以成学,淫慢则不能励精,险躁则不能治性。年与时驰,竟与日去,遂成枯落……”\n“好了好了,《出师表》!”\n“臣亮言:先帝……”\n“前面不要背,从中间开始。嗯,‘可计日而待也’,从这里开始。”\n“臣本布衣,躬耕南阳,苟全性命于乱世,不求闻达于诸侯……”毛泽东又是一口气背了下来。\n开慧不服气,要毛泽东翻出所有看过的书,一心要考倒这位师兄,不厌其烦地提着问题,考到《五灯会元》十八卷时,毛泽东终于错了一句,开慧哈哈大笑,叫道:“我赢了我赢了,你背错了要罚!”\n毛泽东也让着她:“好吧好吧,你说怎么罚,杨先生。”\n开慧眼珠一转:“这样,罚你明天陪我去抓鱼,不许反悔。”\n第二天一大早开慧便来了,扯了毛泽东便走,毛泽东无奈,只得随她出来。两个人背着钓竿,提着鱼篓出了门,沿溪而行,那溪水曲折,直行出数里,在一座山下汇成一个港汊。一湾绿水沿山势环绕,直向东折去,岸边绿草如茵,两个人在草地上坐了下来,放眼一望,小山如黛,稻浪翻滚,远处三两间茅舍点缀。清风徐来,吹着开慧的发梢,她一身乡下姑娘打扮,更衬出她清水芙蓉的脸蛋,煞是可爱。\n“怎么样,我们乡下漂亮吧?”开慧卷起裤管,把白嫩嫩的小腿伸进溪水里拨弄着。\n“这算什么?一般般。我乡下长大的,我们家那边,比这儿还漂亮!那个山,那个水——你是没看见过,比画上画的都好看!”\n“不可能。”\n“你还不信?史书上都有记载,当年舜帝南巡,经过我们那里,见山水灵秀,叹为观止,乃为之制韶乐。韶乐你知不知道?就是‘子在齐闻韶,三月不知肉味’那个韶乐!那么美的音乐,就是看了我们那里的山水才作出来的,所以,我们那里就叫韶山,你说美不美?”\n“是吗?”听他这么一说,开慧都有点悠然神往了。\n“小时候,每年这个时候,我就在我们家对面的山坡上放牛,一边呢,就捡柴、捡粪,捡完了,往山坡上这么一躺。”说着就往草地上一躺, “太阳一照,风这么一吹,舒服啊!”\n开慧学着他的样子,也在他身边躺了下来。\n“有空啊,我就和邻居家的小孩一起,挖笋子,捉泥鳅,爬到树上摘樟树果果,下到水塘里去捞鱼,夏天就游泳,春天就放风筝,反正名堂搞尽。”\n“这些我也玩过,不新鲜。”\n“新鲜的也有呀,比方我们那里,最有意思的,就是唱山歌,这边山上唱,那边山上的人就和,一问一答,看谁比得谁赢。那些山歌真的有意思,我到现在还记得。”\n开慧来了兴趣,支起了身子:“那你唱一个我听听。”\n“我唱得太难听了。”\n“难听就难听喽,又没有别人,唱一个嘛。”\n毛泽东坐起身来:“好,给你唱一个《扯白歌》,就是专门扯谎的歌,比哪个扯谎扯得狠些,怎么不可能就怎么唱。你听啊。”\n“生下来我从不唱捏白的歌,风吹石头就滚上哒坡喽。出门就碰哒牛生个蛋,回来又看哒马长个角喽。四两棉花它沉哒水,咯大个石磨子它飘过哒河喽……”\n毛泽东五音不全的嗓子唱起山歌来,不知在念还是在喊。\n黄昏的路上,开慧握着一把浅紫色的野菊花,脚步十分的轻快,一路想起毛泽东的山歌,忍俊不禁,很快到了家。两人一进门,毛泽东不禁愣住了:“老蔡,子升,你们怎么跑来了?”\n来的正是蔡和森和萧子升,杨昌济神情凝重地放下手里的一份报纸:“他们俩,是来送这份报纸的。”\n“谭都督被撤职了?!”一旁倒好了茶的开慧趴了过来,看看报纸的大幅标题,奇怪地问,“谭都督是谁呀?”\n子升回答说:“就是我们一师的老校长,湖南都督,谭延闿.”\n“那,谁把他撤了?”\n“除了袁大总统,谁还能撤一省之都督?”蔡和森回答着开慧的问题,但脸却对着毛泽东,“江苏撤了,浙江撤了,四川撤了,广东撤了,如今,又轮到我们湖南了,看来,不把中国各省的都督都换成只服从他的人,这位袁大总统是不会罢休啊。”\n开慧还是不明白地问:“可大总统不是比都督官大吗?都督本来就应该服从他嘛。”\n“开慧,这些事,你还不懂。”蔡和森说,“都督也好,大总统也好,服从的,都应该是中华民国的法律,可如今北方各省,都是袁世凯北洋系的人,如果南方的都督也换成了他的人,那中国今后,就没有法律,只剩下他袁大总统了。”\n子升接着说:“刺杀宋教仁,解散国民党,把持国会,修改约法,这两年,他袁世凯这个大总统的权力已经扩大都得没边了,他难道还不满足?他到底想怎么样呢?”\n“独裁!”一片沉寂中,杨昌济开口了,“他要的,就是独裁!”\n毛泽东与蔡和森都微微点了点头。\n子升不禁叹了口气:“总统独不独裁,我们也操不上心,我只担心,谭都督在,湖南还算过了几天安稳日子,可谭都督这一走,我们湖南,只怕从此要不得安宁了。”\n一旁的开慧没有见过这么严肃的场面,一直紧张地听着,听到子升的话,急了:“真的?那,那学校呢?学校不会出什么事吧?我下学期还要上周南去读初中呢。”\n子升安慰开慧,也算自我安慰:“学校当然不会有事。教育乃立国之本嘛,不管哪个当权,也不管他独不独裁,总不至于拿教育开玩笑。”\n蔡和森分析道:“那可难说。民权他可以不顾,约法他可以乱改,区区教育,在独裁者眼里,又算得了什么?”\n“要我看,也好!”一直沉默着的毛泽东语出惊人。\n“好?”子升没听明白。\n“对,好!”毛泽东扬声说道,“上苍欲使人灭亡,必先令其疯狂,他爱蹦跶,让他蹦跶去,等蹦跶够了,他的日子,应该也就到头了!”\n“可他这一蹦跶,中国就得大乱啊!”\n“大乱就大乱,治乱更迭,本来就是天理循环,无一乱,不可得一治!三国怎么说的,‘天下大势,分久必合,合久必分’。”\n“可是……”子升还要说,杨昌济却抬手止住:“世事纷扰,国运多舛,中国是否会乱,乱中能否得治,确实令人担忧。作为你们的老师,今天,我只想提醒你们一句话,不管时局如何发展,不管变乱是否来临,读书求真理,才是你们现在最重要的事。非如此,不可为未来之中国积蓄力量。子升、和森、润之,记住我的话,好好用功,为了将来,做好准备吧。”\n三人点了点头。“还有我呢?我也算一个吧?”开慧突然插了一句。\n师生们都笑了,毛泽东一拍她的脑袋:“要得,你也好好用功,做好准备,到时候,国家有难,就靠你这个花木兰了。”\n第十五章 五月七日 民国奇耻 # 天下兴亡,匹夫有责。何以报仇,在我学子!国家之广设学校,所为何事?我们青年置身学校,所为何来?正因为一国之希望,全在青年,一国之未来,要由青年来担当!当此国难之际,我青年学子,责有悠归,更肩负着为我国家储备实力的重任……\n一 # 1915年5月,长沙的天气渐闷热起来,空中积满厚云,阳光似乎努力想从云层里挣扎出来,渗出淡淡的光,投在洒扫得没有一丝尘土的火车站月台。\n月台上每隔不到一米,便肃立着一个荷枪实弹的士兵,沿铁轨迤逦向北一字排开。警戒线外挤满了湖南各界的缙绅士商,官员贤达,西装革履,长袍马褂,各色不一,一面大横幅扯开,上书“三湘各界恭迎汤大将军莅临督湘”,阳光折过来,将这一行金字和众人举着的彩旗映得人眼花缭乱。\n一声汽笛长鸣,一列火车自北缓缓驶进站来。半晌车门方才开了,从里步出一个人来,这个人年纪不过30岁,白净的脸上架着一付精致的细金丝眼镜,削长脸儿,眉目清秀,穿一身细绸布长衫,手里习惯地把玩着一串晶莹透亮的玉质念珠。姿态优雅,气质沉静。除了剃得极短、极整齐的日本式板寸头外,他全身上下,几乎找不到一点能和军人联系起来的痕迹。\n这个人就是汤芗铭,字铸新。湖北浠水人,新任的湖南布政使,督理湖南军务将军。汤芗铭17岁中举。曾留学法国、英国学习海军知识,精通多国语言和梵文、藏文,乃是学贯中西的佛学大家。\n汤芗铭才一下车,军乐声,欢呼声顿时响成一团。汤芗铭不觉微微皱眉,他一向崇尚佛道的清静无为,极为厌弃这种繁文缛节。这时军乐声一停,一个长袍马褂、白须垂胸的老头子捧着本锦缎册子,颤巍巍地迎了上来:“三湘父老、官民代表恭迎汤大将军莅临督湘。”旋即打开册子,摇头晃脑,“伏惟国之盛世兮明公莅矣,民之雀跃兮如遇甘霖……”\n汤芗铭看也没看老头一眼,边走边对身后的副官说:“收了。”言语轻柔,轻得只有那副官才听得见。\n副官伸手便把老头捧着的册子抢了过来,老头迟钝,一时还没反应过来,直叫道:“哎,哎!”\n欢迎的人群呆了一呆,顿时冷了许多,大家都不免紧张起来,伸长了颈看着汤芗铭。他却向人群旁若无人地直走过来,人群只得赶紧让开了一条路。\n汤芗铭走不过两步,突然站住了,轻声说道:“省教育司有人来吗?”\n后排人群里的纪墨鸿一愣,赶紧挤上前:“卑职省教育司代理司长纪墨鸿,恭迎汤大将军。”\n汤芗铭的神情一下子和蔼了起来,居然伸出手,说道:“纪先生好。”\n纪墨鸿受宠若惊,忙小心地握住汤芗铭的手:“大帅好。”\n汤芗铭淡淡一笑说:“有个地方,想劳烦纪先生陪我走一趟,可否赏个面子啊?”\n纪墨鸿慌忙答道:“大帅差遣,墨鸿自当效劳。”\n这时一个军官小心地凑过来,说道:“大帅,省府各界已在玉楼东备了薄宴,大家都盼着一睹大帅的虎威……”\n汤芗铭扭过头,看了他一眼,目光虽平和,却自然透着股说不出的不耐烦,硬生生地把那军官的半截话逼了回去。\n但一转头,笑容重又到了他脸上,说道:“纪先生,请吧!”\n纪墨鸿低声问:“不知大帅要光临何处?”\n汤芗铭淡淡说道:“敝人生平最服左文襄公,就去他当年读书的城南书院吧。噢,现在应该叫做第一师范。千年学院,仰慕久矣!”\n一行人浩浩荡荡直出了火车站向一师而来。其时虽然南北大战,但湖南得到谭延闿周旋,未经大的兵火,长沙城里倒也繁华。不过沿街各省逃难而来的难民也是极多,汤芗铭到来之前,城中军警已经是倾尽全力驱赶,却也驱之不尽。\n汤芗铭坐在马车上,手里摩弄念珠,长沙街景在他身后一一退去,但他心思全不在这里。\n1905年汤芗铭在巴黎结识孙中山,并经孙中山介绍加入兴中会,事后汤芗铭知道孙中山曾是三点会帮会首领,汤芗铭认为三点会是黑社会组织,因而反悔道:“革命我们自己革,岂有拥戴三点会、 哥老会首领之理。”于是汤芗铭到孙中山居住的巴黎东郊横圣纳旅馆取走入会盟书,向清廷驻巴黎公使孙宝崎自首,自此为革命党人所不齿。后来虽然有起义援汉的功劳,孙中山又宽宏大量,不计前嫌,但汤芗铭心中始终存有芥蒂。\n而袁世凯因他曾助孙中山,也对他心存疑忌,虽发布命令任命他为湖南将军兼民政长,执掌湖南军政大权;但并不放心,先是派亲信沈金鉴至湘掣肘其权;继之任命爱将曹锟为长江上游警备司令,命其率第三师进驻岳州严密监视汤芗铭举动。\n汤芗铭不是谭延闿,深知南北对峙,湖南地处要冲,北方军队南下首攻湖南,南方军队北上,也是一样。谭延闿所谓的湘人治湘,在南北之间中立无异于痴人说梦。他汤芗铭现在两边都不讨好,唯有乘着这第一次成为一方诸侯的机会,明里向袁世凯纳诚效忠,暗里在湖南扩充军队,到时候有大军在手,他就谁也不惧。\n但要讨好袁世凯也不是一件容易的事,火车上他反复权衡。\n1914年以来,“袁世凯要做皇帝”的传说越来越多。1915年初,日本向中国政府提出企图把中国的领土、政治、军事及财政等都置于其控制之下的“二十一条”。消息一经传开,反日舆论沸腾。1915年2月2日中日两国开始正式谈判,日本以支持袁世凯称帝引诱于前,以武力威胁于后,企图迫使袁世凯政府全盘接受“二十一条”,但迫于舆论,一直拖到了现在。最近传来消息,据说日本打算以最后通牒的形式来逼迫袁世凯接受条件。\n汤芗铭揣摩袁世凯的意思,欧美列强虽然反对“二十一条”,但现在身陷欧战泥潭,也只能说说而已。中国无力独自对抗日本,只能极力维护和日本的关系。只是国内舆论喧嚣,现在要做的,就是要压制舆论,舆论都掌握在读书人手里。因此汤芗铭下车伊始,便是直奔长沙两大千年学院之一的城南书院。\n孔昭绶等人早已得到消息,当下里带着众位老师出迎到学校的大门,却见汤芗铭已抢先抱拳招呼:“晚生汤芗铭冒昧叨扰,列位先生,有礼了。”\n“汤大将军大驾光临,有失远迎,恕罪恕罪。”孔昭绶赶紧还礼。\n纪墨鸿赶紧介绍说:“这位就是一师的孔昭绶校长。”\n汤芗铭含笑又一抱拳说:“久仰久仰。”\n孔昭绶笑说:“岂敢岂敢,大帅客气了。”\n汤芗铭闻言说道:“孔校长,芗铭能否提个小小的要求?”\n孔昭绶说道:“请大帅指教。”\n汤芗铭沉声说道:“城南旧院,千年学府,本为先贤授业之道场,湖湘文华之滥觞,芗铭心向往之,已非一日。今日有幸瞻仰,可谓诚惶诚恐,又岂敢在先贤旧地,妄自尊大?所谓大帅、将军之类俗名,还是能免则免了吧,免得折了区区薄福。”\n孔昭绶呆了一呆,“这个?”\n汤芗铭微笑说:“就叫芗铭即可。”\n孔昭绶倒不好再客气了,说道:“铸新先生如此自谦,昭绶感佩不已。”\n汤芗铭目光微向孔昭绶身后移动,问道:“这几位是?”\n孔昭绶一让杨昌济:“这位是板仓杨昌济先生。”\n汤芗铭顿时肃然起敬:“原来是板仓先生?久仰大名了。”\n杨昌济笑一笑说:“哪里。昌济不过山野一书生,怎比得铸新先生海内学者,天下闻名?”\n纪墨鸿提醒着,“孔校长,此地可不是讲话之所,是不是先请大帅进去坐啊?”\n孔昭绶点点头一笑说:“对对对,倒是昭绶失礼了。就请铸新先生先到校长室喝杯茶吧。”\n汤芗铭略一沉吟,说道“校长室就不必了,不如教务室吧,芗铭就喜欢那种传道授业、教书育人的氛围。”\n孔昭绶微微一怔,说道“那……也好。铸新先生,请……”\n汤芗铭含笑说道:“列位先生请……”\n一行人进了大门,说话间来到了教务室。纪墨鸿说道:“早听说大帅学钟繇、张芝,得二王之精粹,可否为这千年书院赐一墨宝,也为后人添一佳话。”\n汤芗铭笑说:“岂敢岂敢,列位都是方家,芗铭哪里敢班门弄斧。”\n孔昭绶说道:“铸新先生客气了,先生学贯中西,名闻天下,若能得先生大笔一挥,我一师蓬荜生辉。”一时便叫人拿纸笔,汤芗铭也不推迟,当即写下“桃李成荫”四个字。\n“好字,有悬针垂露之异,又有临危据槁之形。可谓得钟王三昧。”袁吉六带头鼓起了掌,围成一圈的老师们掌声一片。\n汤芗铭放下了笔,“僭越了。其实,芗铭此生,一直在做一个梦,梦想像列位先生一样,做一个教书人,教得桃李满天下,可惜提笔的手,却偏偏拿了枪,可谓有辱斯文。”\n纪墨鸿忙道:“大帅太自谦了,论儒学,您是癸卯科年纪最轻的举人;论西学,您是留学法兰西、英吉利的高材生;论军事,您是中华民国海军的创建者。古今中外,文武之道,一以贯之,谁不佩服您的博学?”\n汤芗铭微摇了摇头,却转向了杨昌济:“板仓先生才真是学问通达之士。”\n杨昌济说道:“昌济好读书而已,岂敢称通达?”\n汤芗铭却长叹了一声:“芗铭毕生之夙愿,便是能如先生一般,潜心学问,只可惜俗务缠身,到底是放不下,惭愧惭愧。”\n大家都笑了起来,汤芗铭谦恭有礼,又兼才气过人,一时众人都渐渐与他亲近起来。\n只听汤芗铭说道:“孔校长,贵院学生的文章,芗铭可否有幸拜读?”\n孔昭绶说道:“先生说哪里话,还请先生指教。”一时便请袁吉六将毛、蔡等人的作文拿来。汤芗铭接过,第一眼便是毛泽东的,却见上面写着毛润之,微微一诧,笑说:“这里也有一位润之么?”\n杨昌济笑说:“这位学生心慕当年的胡润芝胡文忠公,便改表字为毛润之,让先生见笑了。”\n汤芗铭微微一笑说:“夫子云:”十五而志于学,古今有成就者,莫不少年便有大志‘。“他说到这里,指一指杨昌济,又指一指自己说道:”你我当年,恐怕也立过这样的志向吧。“\n他细看文章,点头笑说: “嗯,好文章,文理通达,深得韩文之三昧,气势更是不凡,当得润之这两个字。”抬起头向袁吉六说道:“袁老先生,能教学生写出这样的文章,果然名师高徒啊。”\n袁吉六大松了一口气,忙道:“总算能入方家之眼。”\n汤芗铭放下了文章,问道:“这个毛润之应该是一师学生中的翘楚了吧!”\n袁吉六点头说:“以作文而论,倒是名列前茅。”\n汤芗铭微一沉吟,说道:“哎!孔校长,芗铭能否借贵校学生的作文成绩单一睹啊?”\n孔昭绶忙答道:“那有什么不行?”\n接过作文成绩单,汤芗铭看了一眼,却转手交给了纪墨鸿。他站起身:“列位先生,今日芗铭不告而来,已是冒昧打搅,先贤之地既已瞻仰,就不多耽误各位的教务了。”\n大家也都站了起来,准备送客。\n汤芗铭却微笑说道:“差点忘了孔校长,芗铭此来,还有一件公事,想请您过将军府一叙。”\n孔昭绶不觉一愕,“我?”\n汤芗铭点头说:“对,非您不可。趁着车马就便,不妨与芗铭同行如何?”\n孔昭绶还来不及回过神来,汤芗铭已携了他的手,向外走去。众人方才行到一师门前,汤芗铭正待告辞,这时远处忽然一声枪响,随即传来一片喧闹,把众人都惊了一跳。护卫的军警顿时都忙乱起来,汤芗铭眉头微微一皱,副官只看了一眼他的眼色,立即会意,匆匆跑去。\n但笑容马上又重新回到汤芗铭脸上,拱手道:“叨扰列位的清静,芗铭就此告辞了。”一时众人纷纷回礼,看着汤芗铭携孔昭绶向一辆豪华马车行去。\n只见汤芗铭抢上一步,掀起了马车的帘子,说道:“孔校长,请!”\n孔昭绶怔了一怔,汤芗铭如此客气,倒叫他不好推辞,正要登车,这时那名副官引着一名军官匆匆跑来:“大帅。”\n汤芗铭扭过头来,那军官啪地一个立正,敬礼:“驻湘车震旅长沙城防营营副参见大帅!”\n汤芗铭只瞟了他一眼,便把头扭了回去,淡淡地说:“闹什么呢?”\n军官答道:“报告大帅,有一群要饭的饥民哄抢米铺的米,标下奉命率城防营前来弹压,闹事的22人已全部抓获。如何处置,请大帅示下。”\n未加思索,汤芗铭把玩着手串的食指在空中轻轻一划——这个动作他做得是那么习惯成自然。副官却早会过意来,转头对军官说道:“全部就地处决。”\n正要登车的孔昭绶全身猛地一震,连旁边的纪墨鸿都不禁嘴角一抽。\n那军官显然也吓了一跳,脸色发白说道:“处……处决?都是些女人孩子,二十多个呢……”\n汤芗铭的头扭了过来,看了他一眼,目光中,是一种极不耐烦的神色,目光森冷,直逼得那军官不由自主地低下了头:“……是!”转身跑步离去。\n孔昭绶这时才从震惊中反应过来,一把抓住了汤芗铭的胳膊,“大帅,罪不至死吧?”\n微笑着,汤芗铭轻轻将手按在了孔昭绶的手上:“孔校长,您执掌一师,不免有校规校纪,芗铭治理湖南,自然也有芗铭的规矩嘛。”\n“可是……”孔昭绶还想说什么。\n汤芗铭轻松笑一笑,说:“换作是一师,要是有谁敢乱了规矩,不一样要杀一儆百吗?说话间轻轻拿开了孔昭绶的手,扶着马车帘子,客气地说:”孔校长,请啊。“\n映着阳光,他的笑容和蔼,透着浓浓的书卷气。望着这张笑脸,孔昭绶脸上的肌肉不由自主的抽搐起来。\n枪声骤起!\n孔昭绶紧紧闭上了眼睛……\n二 # 到了将军府,汤芗铭便向孔昭绶合盘托出了这次请他前来的目的。\n“中日亲善征文?”端着茶碗的孔昭绶不由呆住了。一旁的纪墨鸿默然不语,他是在去一师的路上便早已知道这件事了。\n“说得完整点,应该是‘论袁大总统英明之中日亲善政策’。”汤芗铭坐在办公桌后,手里摩弄念珠,微笑说道。\n孔昭绶沉吟一时,放下了茶碗,缓缓说道:“中日关系,事关国策,一师不过一中等师范学校,学生素日所习,也不过是怎样做个教书匠,妄论国是,只怕不大合适吧?”\n汤芗铭依然慢条斯理:“孔校长何必过谦?贵校以湖湘学派之滥觞,上承城南遗风,这坐论国是,本来就是湖湘学人经世致用的传统嘛。刚才拜访贵校时,芗铭拜读的那篇学生作文,不就纵论家国,写得勃勃而有生气吗?”\n纪墨鸿笑说:“孔校长,大帅如此青睐,将这次全省征文活动交由一师发起,这是大帅对一师的信任,大言之,也是袁大总统对一师的信任,您就不必推脱了。”\n孔昭绶忍不住脱口道:“可日本对中国,狼子野心,早已是昭然……”他猛然碰上了汤芗铭笑吟吟的目光,那目光中的森森寒意硬生生将他的话堵了回去。掩饰着阵阵恐惧,他伸手端茶碗,但手却不由自主地在微微颤抖。\n许久,汤芗铭才收回目光:“看来孔校长还是深明大义,愿意配合我大总统英明决策的。征文的事,就这么定了,具体的做法,纪先生,你向孔校长介绍一下吧。”\n“是。”站起身来,纪墨鸿对孔昭绶说,“湖南将军汤大帅令,一、本次征文,以‘论袁大总统英明之中日亲善政策’为题;二、征文以一师为发起策源,首先在一师校内开展,除号召全校学生踊跃参加外,凡作文成绩名列前30名者,必须参加;三、征文结果,须送将军府审阅;四、征文结束后,以一师为范例,将征文比赛推广至省内各校,照例实行;五、凡征文优胜者,省教育司将颁以重奖。征文第一名除奖励外,省府还将特别简拔,实授科长以上职务,以示我民主政府求才若渴之心。”\n茶水突然溅在了孔昭绶的长衫上,他这才发现手里的茶碗不知不觉间端斜了,赶紧放下茶碗,擦着长衫上的水。一方雪白的手帕递到了他的面前,原来竟是汤芗铭起身给他递来了手帕:“征文之事,就由纪先生协助孔校长,即日实施,好吗?”\n孔昭绶回到学校,已经是下午了。他呆呆地坐在办公桌前,一动不动。那张“中日亲善征文”告示就摊在桌子上。\n纪墨鸿推开了房门,孔昭绶仍旧一动不动,仿佛充耳未闻。他拿起那张告示一看,顿时急了:“孔校长,您怎么还没用印啊?我可都等半天了。您到底要拖到什么时候啊?”\n孔昭绶依然不动。\n纪墨鸿叫道:“孔校长,昭绶兄。”凑到了孔昭绶眼前,口气也缓和了:“您心里想什么,墨鸿不是不知道。可咱们这些书生,管不了那么多国家大事,要咱们干什么,咱们就只能干什么,读书人,千古都是如此,生的就是这个命——谁叫咱们的手只会拿笔呢?”说到这里,他长叹了一口气,站直身子:“汤大帅的雷厉风行,您也是亲眼目睹了的,墨鸿还要赶回去交差,昭绶兄,就不要为难小弟了吧?”\n仿佛自己的手有千斤重,孔昭绶艰难地、一点一点地拉开了抽屉。校长的印信就躺在抽屉里。\n纪墨鸿半晌看他没有动手的样子,索性自己动手,手伸进抽屉,抓住了那方印。\n鲜红的校长大印盖上了告示。孔昭绶还是一动不动,仿佛一具失去了灵魂的躯壳。纪墨鸿叹息一声,摇一摇头,出了校长室,轻轻掩上门。\n走廊里,刘俊卿看到纪墨鸿急匆匆走来,怯生生地招呼了一声:“纪督学。”然后侧过身子,正要给纪墨鸿让路,却听见了纪墨鸿的声音:“俊卿。”\n刘俊卿不禁受宠若惊:“老师。”纪墨鸿把那份告示递了过来:“帮我个忙,把这个贴到公示栏上去。”\n“征文第一名将由省府特别简拔,实授科长以上职务……”\n刘俊卿正把告示往公示栏上贴,盯着上面征文奖励的条款,眼睛都直了:“老师,这是真的?”\n“大帅亲口说的,还能有假?”纪墨鸿拍了拍刘俊卿的肩膀,“俊卿,上次的事,你实在是让我太失望,太痛心了。可你毕竟还叫过我一声老师,我也不希望你这么个人才真的这么荒废了。现在机会摆在你面前,希望你可不要再错过了。”\n“老师,您放心,我不会错过的,我这就去写,我一定抓住这个机会。”\n激动中,刘俊卿全身都在颤抖,他又把公告仔细读了几遍,这才向寝室走来,一路寻思,这样的机会,大家都在那里抢,自己恐怕要竭尽全力,当下里拿定主意,请几天假,一心一意写好文章。\n这时学生们都已陆续上前来看告示,围成一团,议论纷纷。杨昌济走了过来,抬头看去,“中日亲善?”他简直都不敢相信自己的眼睛,一时细读,越读脸色越沉了下来,当即直奔校长室,连门也不敲,猛地推开,一步闯进去。\n三 # 《日本国发出最后通牒大总统袁世凯承认二十一条》。毛泽东拿着刚到的《大公报》,头版显著的大标题不觉令他发呆,一时怔在了校门口。\n此时心中的愤怒反使他冷静下来,他觉得自己应该要做点什么了,但到底怎么做?他第一个想到了杨昌济。\n“润芝,哪里都找你不到,原来你在这里?”迎面蔡和森和张昆弟满脸焦急,叫道。\n“怎么回事?”\n“出大事了。” 蔡和森说道,直将毛泽东拉到那公告栏前。\n毛泽东一看之下,也不由目瞪口呆,问道:“老蔡,这是什么时候的事?”\n“下午才贴的,现在杨老师已经去找孔校长了,我不相信孔校长会干这样的事,到处找你,我们一齐去问个清楚。怎么样?” 蔡和森说道。\n毛泽东不说话,将手里的报纸递给他说:“你看吧。”\n蔡和森接过,张昆弟也凑了过来,一见标题顿时双目圆睁,脸上一阵抽搐,一拳击在报纸上,喝道:“欺人太甚。”把周围的同学都吓了一跳。\n蔡和森细细将报纸看完,才问道:“润之,杨老师知道这件事么?”\n毛泽东沉吟说:“应该不知道,这是最新的报纸,刚到的。”他说到这里,一扯蔡张二人说:“走,我们去校长室。”\n三人匆匆向校长室赶来,只见房门大开,方维夏、黎锦熙、袁吉六……一个个老师都站在门前,大家的神情同样凝重,大家的表情同样难以置信。\n“全校征文?居然要我们的学生,要我们亲手教出来的学生为日本的狼子野心唱赞歌!这样的启事,竟贴进了一师的校园,我一师的传统何在?我一师的光荣何在?这座千年学府之浩然正气何在?” 杨昌济的声音越来越大,在回廊之间直震荡开来。\n三个人从窗子里看进去,只见孔昭绶一动不动背向众人,仿佛一尊泥雕一般。杨昌济激动得在那里走来走去。\n“耻辱啊,这件事,你到底知道不知道?你怎么不说话?难道你事先知道?你为什么不回答我,为什么不敢面对大家?你不是这种人啊,你到底是怎么了?你在怕什么?” 杨昌济敲着桌子说。\n孔昭绶依然没有任何动静。杨昌济再也忍不住了,他一把将孔昭绶的身子扳了过来,大叫道:“昭绶!”\n猛然,他愣住了。所有的老师也都愣住了。\n——两行泪水,正静静地滑出孔昭绶的眼眶,顺着他的面颊淌下!\n“你知道吗?他的手指这么一勾,就杀了二十二个人,因为他们没饭吃,他们抢了点米,他就这么一勾,二十二个人,二十二条命,就这么一勾……”孔昭绶喃喃地说着,整个人都笼罩在那种刻骨铭心的恐怖之中。忽然他猛地一拳重重砸在自己头上,声嘶力竭地叫道:“我是个胆小鬼啊!”\n所有的人都惊呆了。杨昌济扳在他肩上的手不自觉地滑落下来。\n毛泽东沉默一时,握着报纸,直闯进门去。\n“润之?”杨昌济不觉一怔。孔昭绶闻言也抬起头来。\n“校长,这是刚收到的报纸。” 毛泽东递过报纸。\n“原来这样!” 孔昭绶接过报纸看时,汤芗铭的种种企图刹那间都明白了。孔昭绶沉默片刻,将报纸递给了杨昌济,忽然一跃而起,冲出了校长室,直奔公告栏,这时栏前仍围满了学生。孔昭绶排开人群一把将告示撕了下来。面对满是惊愕的师生们,孔昭绶目光如炬,向追上来的方维夏说道:“维夏,马上起草一份征文启事——标题是:《就五·七国耻征文告全校师生书》!”\n方维夏闻言大声应道:“是。”在场的师生都轰然欢呼起来!\n四 # 整整三天,一师的师生都在忙乱之中,所有的文学老师连夜阅评,学生们自发的组织起来协助装订,整理,大家没有一句多余的话,仿佛形成了一种默契,把所有的耻辱和愤怒放在心里,用更多的行动去洗雪。到第二天上午,方维夏便将一本蓝色封皮、装帧简洁的《明耻篇》拿到了孔昭绶的办公室:“校长,国耻征文印出来了,这是样书。”\n孔昭绶接过来仔细翻看,点头说:“不错。”他沉吟一时,问道:“润之在哪里。”\n“他们在礼堂为明天的全校师生五·七明耻大会准备会场。我去叫他来。” 方维夏说道。\n孔昭绶摆摆手说:“不用了,我去找他,顺便看看会场。你去忙你的吧。”说话间站了起来,方维夏点点头,却眼看着孔昭绶,半晌站着不动。孔昭绶怔了一怔,说道:“维夏,你还有事?”\n方维夏摇一摇头,迟疑一时才缓缓说道:“校长,你没事吧。” 孔昭绶又是一愣,但瞬间他明白了方维夏的意思,微微一笑说:“维夏,谢谢你,我没事。” 方维夏沉吟一时,还想说些什么,但最后一句话也没有说,出了房门。\n孔昭绶看着他的背影,由不得心头一热,从昨天到现在,他从每个老师和学生的眼里都看到了一种关心,虽然没有一个人说出来,只是埋头做事,然而他可以明白的感受到,大家都在替他担心。他拿起那本《明耻篇》来,心中忽然感到一丝欣慰,随即关上门向礼堂而来。\n礼堂外露天摆放的桌子前,蔡和森正在写着大字。地上摊着长长的横幅,毛泽东、张昆弟等人正将他写好的大字拼贴在横幅上。孔昭绶站在蔡和森身后,也不说话,只看他写字。\n“校长。”毛泽东几个人抬起了头。孔昭绶笑笑说:“写得不错啊。”一时向毛泽东说:“润之,你那里先放一放,来给这本《明耻篇》题个引言吧。”说话间把书递了过来。\n毛泽东愣了一下:“我来题?”\n“对,你来题。” 孔昭绶拿起架在砚台旁的毛笔,递到了毛泽东面前: “如果不是你的提醒,就不会有这次国耻征文,所以,应该由你题。”盯着孔昭绶为他翻开的书的空白扉页,毛泽东沉吟了一会儿,接过了毛笔。大家都围了上来。\n毛泽东奋笔疾书,一挥而就,《明耻篇》的扉页上留下刚劲有力的十六个字。孔昭绶读出了声:“‘五月七日,民国奇耻。何以报仇,在我学子’写得好,写得好!”\n就在这时,只见刘俊卿慢慢挨了过来,叫道:“校长。”\n孔昭绶回过头来:“是你,什么事啊?”刘俊卿小心捧着手里的文章,恭恭敬敬递了上来:“我的征文写好了。”\n“征文?不是早就截止了吗,你怎么才送来?” 孔昭绶呆了一呆。“截止了?哎,不是有一个星期吗?” 刘俊卿急忙叫道。\n孔昭绶沉默一时,忽然好像想起什么,问道:“你写的什么征文?”“中日亲善征文啊。” 刘俊卿不觉奇怪,这有什么好问的。\n一刹那间,大家好像发现一只怪物,把刘俊卿看得莫名其妙。孔昭绶一把接过了刘俊卿的文章,打开看了一眼——文章的标题是《袁大总统中日亲善政策英明赋》。\n孔昭绶读了出来:“‘东邻有师,巍巍其皇。一衣带水,亲善之邦。’”他突然忍不住笑了,“一衣带水,亲善之邦!”他蓦然住口,两眼如刀一般盯着刘俊卿,握紧拳头,一种尖锐的痛楚从心底里直透出来,他都不知道自己该说什么。\n刘俊卿呆呆地看着孔昭绶,全不明白大家为什么会是这样的眼神,他只觉有无数的针从四面八方刺来,这时他看见孔昭绶缓缓地将他那篇文章一撕两半,不觉大惊,叫道:“校长,你……”\n孔昭绶冷冷地一点一点,将那篇文章撕得粉碎。纸屑洒落在地上。他拍打着双手,仿佛是要拍去什么不干净的东西,看也没看刘俊卿一眼,转身离去。\n刘俊卿仍旧呆在那里一动不动,毛泽东等人都不理他,自顾布置会场。张昆第却不耐烦了,叫道:“让一让。”从背后一推,将他推了个趔趄,他这才回过神来看清了地上那幅已经拼贴完工的横幅上,却是“第一师范师生五·七明耻大会”几个大字。\n五 # 第二天清晨,一师大礼堂的主席台上高悬出“第一师范五·七师生明耻大会”的横幅,左右两侧,是飞扬的行草,“五月七日,民国奇耻”、“何以报仇,在我学子”。台下全校数百师生聚集一堂,一片肃穆,过道间黎锦熙等人正在发放《明耻篇》,一本本书无声地由前至后传递着。\n当孔昭绶出现礼堂门口,刘俊卿死死地咬着嘴唇,坐在最后一排,木然接过那本《明耻篇》。这时雷鸣般的掌声响了起来,他有些怨恨地看着孔昭绶一步步走上了讲台。\n掌声骤然一停,全场一时鸦雀无声。孔昭绶环顾着台下,眼光从杨昌济、徐特立、方维夏等一位位老师身上,又从毛泽东、蔡和森、萧三等全场白衣胜雪的学子们身上掠过,他甚至看到了刘俊卿,仿佛有千言万语一时不知从何说起。\n终于,他深深地吸了一口气:“有一个词,大家一定都听过支那。这是日本人称呼我们中国人时用的词,在日本人嘴里,中国就是支那,我们这些在座的中国人就是支那人。那么支那是什么意思呢?过去我也并不清楚,只知道那是隋朝起从天竺语‘摩诃至那’中派生的一个对中国的称呼,本意并无褒贬。直到五年前,五年前,我在日本留学的时候,日本学校给我准备的学籍表上,填的就是‘支那人’孔昭绶。每次碰到日本人,他们也都会说:”哦,支那人来了。‘说这句话的时候,他们脸上的那种表情,我这一辈子也忘不了,那是一种看到了怪物,看到了异类,看到了某种不洁净的东西,看到了一头猪,混进了人的场合时才会有的蔑视和鄙夷!\n“于是我去查了一回字典,我不相信日本人的字典,我查的是荷兰人出的——1901年版《荷兰大百科通用辞典》,查到了:支那,中国的贬义称呼,常用于日本语,亦特指愚蠢的、精神有问题的中国人。这就是支那的解释!”\n“今日之日本,朝野上下,万众一心,视我中华为其囊中之物,大有灭我而朝食之想,已远非一日。今次,‘二十一条’的强加于我,即是欲将我中华亡国灭种的野心赤裸裸的表现!而袁世凯政府呢?曲意承欢,卑躬屈膝,卖国求荣,直欲将我大好河山拱手让于倭寇!此等卖国行径,如我国人仍浑浑噩噩,任其为之,则中华之亡,迫在眉睫矣!”孔昭绶痛心疾首,振臂而呼。\n“夷狄虎视,国之将亡,多少国人痛心疾首,多少国人惶惶不安?是,大难要临头了,中国要亡了,该死的日本人是多么可恨啊,老天爷怎么不开开眼劈死这帮贪婪的强盗?这些抱怨,这些呼号,我们都听过无数回,我们也讲过无数回。”端起杯子,孔昭绶似乎准备喝口水润润嗓子,但突然情绪激动起来,又把茶杯重重一放。“可是怨天尤人是没有用的!我们恨日本怎么样?恨得牙痒又怎么样?恨,救不了中国!\n“以日本之蕞尔小邦,40年来,励精图治,发愤图强,长足进步,已凛然与欧美之列强比肩,为什么?隋唐以降,一千多年,他日本代代臣服于我中华,衣我之衣冠,书我之文字,师我中华而亦步亦趋,而今,却凌我大国之上,肆意而为,视我中华如任其宰割之鱼肉,又是为什么?\n“因为日本人有优点,有许许多多我中国所没有的,也许过去有过,但今天却被丢弃了的优点!我在日本的时候,留学生们人人对日本人的歧视如针芒在背,可是呢,抱怨完了,却总有一些人,但不多,但总有那么几个逃学、旷课,他们干什么去了?打麻将!逛妓院!还要美其名曰,逛妓院是在日本女人身上雪我国耻,打麻将是在桌上修我中华永远不倒的长城!大家想一想,这还是在敌人的国土上,这还是当着敌人的面!他日本人又怎么会不歧视我们?怎么会不来灭亡这样一个庸碌昏聩的民族?\n“所以,我们都恨日本,可我却要在这里告诫大家,不要光记得恨!把我们的恨,且埋在心里,要恨而敬之,敬而学之,学而赶之,赶而胜之!要拿出十倍的精神、百倍的努力,比他日本人做得更好,更出色!这,才是每一个中国人的责任!”\n慷慨激昂的演说深深地震撼着全场的师生,不知何时,刘俊卿的座位悄悄空了……\n"},{"id":126,"href":"/zh/docs/culture/%E6%81%B0%E5%90%8C%E5%AD%A6%E5%B0%91%E5%B9%B4/%E7%AC%AC16%E7%AB%A0-%E7%AC%AC20%E7%AB%A0/","title":"第16章-第20章","section":"恰同学少年","content":" 第十六章 感国家之多难 誓九死以不移 # 感国家之多难,誓九死以不移,\n虽刀锯鼎镬又有何辞?\n人固有一死,死得其所,不亦快哉!\n鼓大勇,戡大乱,雪大耻,\n令我中华生存于竞争剧烈之中,\n大崛起于世界民族之林\n一 # 刘俊卿悄悄离开礼堂,埋头疾步朝校外跑去,忽然听到有人喊他的名字,吓了一跳,东张西望之后看清是父亲,这才松了一口气:“爸。”\n刘三爹本是提了开水瓶去礼堂倒茶的,却见儿子独自一人跑出来,很是奇怪:“不是开大会吗?你这是上哪去?”\n“我……有点急事……”\n“你能有什么急事啊?”\n“说了有急事,你就别管了。”刘俊卿走出几步,突然又回过身来:“爸……”看着父亲那饱经沧桑满是皱纹的脸,心头一热,似乎想说什么,却又不知从何说起,终于,他只是笑了笑:“爸,等着我,等我回来,也许你就不用给人倒开水了。”\n“那我倒什么?”刘三爹显然没听明白。\n“什么也不倒,以后,我要让别人给你倒。”\n扔下一头雾水的父亲,刘俊卿匆匆出了校门,一口气跑到省教育司纪墨鸿的办公室,边喘气边把“中日友善”变“明耻大会”的经过说了一遍。“学生按照老师要求,熬了一个通宵写的征文,被孔校长当着老师同学们的面撕得粉碎。”刘俊卿委屈地说。\n接过刘俊卿递来的《明耻篇》,纪墨鸿翻开封面,“五月七日,民国奇耻。何以报仇,在我学子”的引言赫然在目。“这还了得!这不是公然煽动学生造反吗?”纪墨鸿腾地站了起来,“走,马上跟我去将军府。”\n两人匆匆来到将军府,纪墨鸿吩咐刘俊卿等在外面,自己请陈副官赶紧通报,匆匆进了汤芗铭的办公室。刘俊卿本只想到纪墨鸿那里告个状就走人,万万没想到竟会被带到将军府来,看纪墨鸿的紧张模样,自己这一状真是告到了点子上,这一刻便觉得全身轻飘飘的,犹如踩着两团棉花,两只眼睛直勾勾地看着将军府内那颗桂花树。这时还只是初夏时节,他却仿佛闻到了一阵阵的桂花香,心中想:古人所云“蟾宫折桂”,大抵就是这个情形吧。\n正在胡思乱想之际,只听得一阵阵杂乱而紧张的脚步声,众多士兵涌了出来,刺刀闪亮,排列成行,刘俊卿哪见过这等阵仗,心中正发虚,却不料被人从后面拎住了衣领。回头一看,正是那个陈副官,脸上全无表情:“走,跟我去认人!”\n“认人,认什么人?”刘俊卿愣住了。\n“抓的是你们学校的校长,你不认人,谁认人?”陈副官眼睛一瞪,刘俊卿这才明白这帮士兵竟是要去捉孔昭绶的,顿时傻了,求援的目光投向一旁跟来的纪墨鸿,“可是……可是我……老师……”\n纪墨鸿似乎也有些歉然,躲开了他的目光:“俊卿,做人就要善始善终嘛。”刘俊卿急了:“不是啊,老师,我就是来报个信,这种事我怎么好去呀?”纪墨鸿拍着他的肩膀:“我知道,当着熟人,大庭广众的,脸上抹不开也是有的。可你不去,这些当兵的谁认识他孔昭绶啊?再说,大帅可有话,只要你肯尽心效力,绝不会亏待你,教育司一科科长的位子,可还空着呢。”\n“老师,我……我真的不行……”刘俊卿还在苦苦哀求,早已等得不耐烦的陈副官一挥手,两名士兵上来,一人一边,挟了刘俊卿就跑。纪墨鸿站在将军府门口,看着挣扎着的刘俊卿被士兵们带走,却是一言未发。\n二 # 这一刻,一师礼堂里,“明耻大会”仍在进行,孔昭绶还在慷慨陈词:\n“天下兴亡,匹夫有责。何以报仇,在我学子!国家之广设学校,所为何事?我们青年置身学校,所为何来?正因为一国之希望,全在青年,一国之未来,要由青年来担当!当此国难之际,我青年学子,责有悠归,更肩负着为我国家储备实力的重任……”\n忽然,砰的一声,礼堂门被撞开了,刘三爹气喘吁吁地冲了进来,把师生们吓了一大跳。原来,刘俊卿走后,刘三爹进到礼堂帮着老师们一一泡上热茶,又站着听了一会儿演讲,大道理他说不出来,就觉得孔昭绶说得有理,说出了中国人的骨气。他听了一半,想着儿子还在外面,开水瓶也空了,就出去换开水,顺便再把儿子喊进来。出了礼堂,却左找右找不见儿子身影,正在校门口东张西望之际,只见大批军队直朝一师而来,连忙锁了校门,跑来报信。\n“不好了,不好了,当兵的……全是当兵的……好多当兵的……”刘三爹话音未落,砰的一声,门口传来一声枪响,随即是校门被砸开的声音,士兵们整齐的脚步声,听得所有人心中一紧,几乎同时站了起来!\n“第一师范的师生人等,给我听清楚了,湖南将军汤大帅有令:文匪孔昭绶,目无国法,包藏祸心,蛊惑学生,对抗政府,着令立即逮捕。凡包庇孔犯昭绶,窝藏卷带者,与孔同罪。煽动闹事,阻碍搜捕者,格杀勿论!”\n门外的士兵们喊话声传来,礼堂里的学生们顿时一片大乱。\n“都不要乱,同学们,不要乱,听我把话讲完。”一片惊悚中,讲台上的孔昭绶却笑了,这一切原本就在他的预料之中,只不过提前了一点点罢了,“没有什么了不起的,不就是抓人吗?昭绶今日走上这个讲台,外面的情况,早就已在我意料之中。死算什么?感国家之多难,誓九死以不移,虽刀锯鼎镬又有何辞?人固有一死,死得其所,不亦快哉!”\n他戴上礼帽,正正衣襟:“同学们,我亲爱的同学们,昭绶今日虽去,一师未来犹存,但望我去后,诸位同学能不忘我今日所言,鼓大勇,戡大乱,雪大耻,令我中华生存于竞争剧烈之中,崛起于世界民族之林,则昭绶此去,如沐春风矣。”\n说罢,迈步便下了讲台。\n“校长!”前排的萧三再也忍不住了,双膝蓦然重重跪倒在地!一排排同学,一双双膝盖随着孔昭绶的经过,顿时跪倒了一片!一双双眼里,饱含着泪水,一双双手,伸向了即将生离死别的校长……\n满场黑压压的学生中,只剩了毛泽东、蔡和森还站着没动,两个人互相看着,却也不知如何是好。孔昭绶的眼睛也湿润了,他微笑着,坚定地排开一双双伸向他的手,向大门走去。杨昌济一把抓住了他的手:“昭绶!”\n“昌济兄,你我之约,望君铭记。”孔昭绶挡开杨昌济的手,就要来拉大门。猛地,站在门边的刘三爹一把靠住大门,堵住了孔昭绶的去路,冲毛泽东等人大喊:“你们还愣着干嘛?还不保护校长走?快啊!”\n毛泽东这才反应过来,一挥手,几个人上来一把抱住孔昭绶。孔昭绶挣扎着,“放开我,快放开我……”然而学生们人多势众,不容分说,架起他便往另一边的门跑去。\n孔昭绶这边刚被架走,枪托砸门的声音砰然大起!学生们赶紧冲上前,与刘三爹一起堵着大门。门外的士兵们蜂拥而上,枪托砸、肩膀撞,到底当兵的凶悍,轰然一声,礼堂的一边大门被撞断了门轴,倒了下来。数十把闪亮的刺刀一拥而入,逼得学生们纷纷后退。\n“带他认人!”副官和被士兵押着的刘俊卿走了上来。副官一挥手,士兵放开刘俊卿,顺手向前一推,刘俊卿一个踉跄,重重摔在地上。这一跤摔得很重,但刘俊卿也顾不得了,趴在地上,双手捂住脸,只希望这里的人认不出他来。\n“刘俊卿?”不知是谁首先喊出了这个名字,无数道惊愕的目光一齐射了过来。几乎是刹那之间,大家都明白了,目光一下子转成了无比的鄙夷。角落里,刘三爹更是惊得目瞪口呆,简直不敢相信自己的眼睛!\n一名士兵过来,揪着刘俊卿的衣领,把他拎了起来:“快认人!”\n看着曾经朝夕相处的同学们,刘俊卿躲闪着他们的眼光,最后,他的眼神落在易永畦——这位平日里最温顺和善的同学身上,“永畦,我……”他满怀希望地喊出了这个名字,他希望永畦能够明白他,原谅他今天所做的一切。\n易永畦猛地抬起头,抡起巴掌,狠狠扇在刘俊卿的脸上!\n一个士兵走过来,抡起枪托照着易永畦当胸狠狠砸去,易永畦一头摔翻在地,一口鲜血猛喷了出来!“永畦!”周世钊等好几名同学涌了上来,扶住了昏迷的他。\n“还有谁不老实?谁!”陈副官拔出手枪,黑洞洞的枪口对着同学们挥舞了一圈之后,停在刘俊卿的脑门上,“认人,你认不认!”\n脸上火辣辣的刘俊卿被冷冰冰的枪口指着,脑子一片空白,他不敢回头,后面全是黑洞洞的杀人的枪口。他也不敢向前,前面是张昆弟、周世钊他们仇恨的目光。如果他们手里也有枪,他们枪口第一个对准的,肯定也是他刘俊卿。站在人群中间,他重重咬着嘴唇,鲜血从唇角流下来。\n猛然,他疯一样地冲进人群,“我认,我认,我现在就认!”他一把推开了面前的同学,“孔昭绶,你给我出来!出来,孔昭绶!”\n他嘶吼着,寻找着,疯子般寻遍了整个礼堂,却不见孔昭绶。\n“走,走,再找!再找!我带你们找!”他领着士兵们冲了出去,这一刻,他已经只剩下一个念头:他已经不属于这所学校,他只想毁了这眼前的一切!\n此情此景,连刘俊卿的亲生父亲——刘三爹也看不下去了,他扶着墙壁,一步一步慢慢挪着离开了礼堂。路其实很平,他却摔了一跤,随即两腿发软,怎么也站不起来,嘴唇哆嗦着,上下牙齿咬得咔咔作响,终于,从齿缝里挤一句“兔崽子!”,禁不住泪如雨下。\n毛泽东、蔡和森一左一右夹着孔昭绶,一时之间也不知哪里安全,只好先带着老师们到宿舍再说。\n“昭绶兄,你怎么就不听劝呢?”杨昌济急得满头大汗,“白白牺牲一条性命,有必要吗?”徐特立、方维夏等人也纷纷劝道:“是啊,校长,赶紧走吧,迟了就来不及了。”\n孔昭绶早已抱了必死决心,只是微笑着说:“你们不用劝了,我不会走的。昌济兄、特立兄,你们都走吧。毛泽东、蔡和森,你们赶快把外面的同学都带走,千万别让他们出事。”\n毛泽东斩钉截铁地说:“您不走,谁也不会走的!”\n就在这时,外面传来了一阵嘈杂声。屋里的人不由得都紧张起来,只有孔昭绶反而更加平静了。蔡和森向杨昌济等点了一下头,打开门走了出去,迎头却愣住了……眼前,张昆弟、罗学瓒、萧三……几十个同学抄着棍棒、板凳、砖头等东西,正涌向门口。一张张年轻的脸上,都是视死如归的无畏。\n蔡和森问:“你们这是要干什么啊?”张昆弟扬着手里的木棒说:“和森兄,我们决定了,大家把校长围在中间,一起往外冲,拼出这条命,也要把校长送出去!”“对,冲出去……”众人纷纷点头。张昆弟一挥手,“说干就干!不怕死的,跟我来!”\n“都给我站住!”身后,传来了毛泽东的一声大吼,大家不由得都愣住了,“你们这是要干什么?都疯了?凭这几根木棍,就想跟刺刀、跟子弹、跟一支军队去拼命吗?”“那你说怎么办?总不能眼睁睁看着他们把校长抓走吧?”张昆弟说。\n“不管怎么样,也不能用血肉之躯,用这么多人命去冒这种险!这是无谓的牺牲,是匹夫之勇!”毛泽东一把抢下了张昆弟手中的棍子:“都把东西放下!都给我放下!”好几个同学被他震住了,放下了手里的东西,更多的人迟疑着,一时不知如何是好。张昆弟说:“不行,我不能看着他们把校长抓走,要命有一条!我不怕!”说完,就要往外冲,“昆弟……”蔡和森连忙一把拉住。\n“同学们!” 听到动静的孔昭绶与其他老师出现在门口,孔昭绶命令同学们,“把东西都放下来,放下!都放下!”\n一片静默中, 乒乓一阵,同学们手中的棍棒、砖头、板凳……通通落在了地上。忽然,一只手缓缓地,却是坚定地捡起了地上的一根木棒。所有人都愣住了——居然是蔡和森!\n孔昭绶急了:“蔡和森,你这是干什么?”蔡和森看着孔昭绶,一脸平静,仿佛什么也没发生过:“昆弟他们刚才要干什么,我现在就去干什么。”\n方维夏急了,站出来想要阻止,杨昌济却轻轻拉了他一把,他太了解蔡和森了,知道这个学生绝不会做出不理智的事来,尤其在这种危急时刻。\n蔡和森环顾着同学们:“怎么了?大家刚才不都还勇气十足吗?怎么现在都不敢了?”他又捡起一根球杆,递向毛泽东,“润之,拿着!”连毛泽东也被他搞糊涂了,一时间接也不是,不接也不是。孔昭绶上前,一把将那根球杆抢了过来:“蔡和森,你就别添乱了!你这不是去白白牺牲吗?”蔡和森说:“连校长都可以白白牺牲,我这个学生为什么不可以?连校长都不要命了,我这个学生还要什么命?”毛泽东这才醒悟过来,立刻率先抄起了板凳,其他学生也纷纷捡起刚才扔在地上的东西。杨昌济严厉地说:“昭绶,你还要以你的固执,去换取他们的生命吗?”\n所有人都在等着、期待着,终于,孔昭绶长长地吐了一口气:“好!我走。”\n大家刚刚松了一口气,萧三等人气喘吁吁地跑过来,“孔校长,快走,刘俊卿带人搜到宿舍来了。”跟在后面的李维汉接着说:“学校的前后门都被堵了,四面全是兵,一条出去的路都没有了!”\n“一个口子都没有了?”徐特立连忙问。“到处都是兵,围得跟铁桶一样,谁都不准出去啊。”罗学瓒说,“最可恨是那个刘俊卿,每个人他都要过目,比那些当兵的搜得还卖力!”\n孔昭绶心如死灰:“一师教出了这样的败类,也是天亡我了。”\n“校长。”身后突然传来了刘三爹的声音。孔昭绶一扭头,不知何时,刘三爹已来到人群外,提着一只油迹斑斑的竹匾,捧着一个蓝布包袱。他解开蓝布包袱,里面是一套皱巴巴、油腻腻的旧衣服,散发出难闻的臭味,那是他炸臭豆腐时经常穿的:“靠大椿桥那边的小侧门,只有几个当兵的守着,校长,您换上这身衣服,就说是来给学校食堂送臭豆腐的。学校里除了老师就是学生,没有这种打扮的人,他们肯定会相信。”\n“这行吗?”孔昭绶将信将疑。“换吧,校长,一定行,我打包票,一定行的。”刘三爹把旧衣服捧到了孔昭绶眼前,微笑着说:“换吧,校长。”\n一旁的杨昌济想了想:“不管怎么说,这个办法值得一试。不过,还得烦请徐副议长大驾。”“这话怎么说?”徐特立忙问。\n杨昌济低声说了几句,大家连连点头,孔昭绶也终于解开长衫扣子,开始换衣服。徐特立与杨昌济也赶紧动手,拿的拿衣服,取的取帽子帮他。换下的长衫被刘三爹随手接过,搭在自己臂弯里,忙乱中,谁也没留意。\n不一会儿,杨昌济、徐特立迈着方步,直朝大椿桥的小侧门而来。刘三爹说的没错,相比学生宿舍的喧闹混乱,这里显得安静很多。\n“站住!”两名持枪的士兵喝住了迎面走来的杨昌济和徐特立。杨昌济脸色一变,“你们干什么?知道这位是谁吗?省议会的徐副议长!连议长的驾都敢挡,好大的胆子!”一名军官上前来,嘴里骂骂咧咧,“少他妈啰嗦,老子是汤大帅的兵,不认得什么一长二长,都给我站住!”徐特立头一扬,端着架子就往外走,那名军官拔出手枪,迎头顶住了他:“往前一步,格杀勿论!”\n这里正僵持不下,身穿破衣、头戴毡帽的孔昭绶低着头从旁边走来,就要从他们身边出门,那名军官却眼尖,枪一抬:“哎——哪去哪去?”“我回家。”“回家?你干什么的?”“我,卖臭豆腐的,刚到学校食堂送完货。”“站这儿等着!”“长官,家里锅上还炸着豆腐呢,您行个方便吧。”“少啰嗦,人犯没抓到以前,谁都不准出这个门!”\n正在这时,身后不远处又传来一阵杂乱的脚步声,杨昌济一扭头——看到刘俊卿一马当先,带着一帮兵正向这边走来。刹那间,三个人的心猛地悬了起来。杨昌济与徐特立赶紧拦在了孔昭绶前面,眼睁睁看着刘俊卿一步步逼了上来,却是一点办法也没有。\n突然,哗啦一阵,一堆泥土从坡上滚了下来,正散在刘俊卿的脚边。他猛一扭头,坡上,一个穿长衫的背影一闪而过。\n刘俊卿的眼睛顿时亮了:“就是他,他在那儿!”陈副官也看见了,手一挥:“给我追!”士兵们与刘俊卿一窝蜂追了上去。那名负责看门的军官拔出手枪,一巴掌抽在一个士兵头上:“还愣着干嘛,还不快追!”他带着看门的两个兵也追了上去。\n喜欢恰同学少年,就登陆连城书盟踊跃投票吧。\n片刻之间,叫嚣呼喊声渐渐远去……门前的兵全空了。杨昌济与徐特立长长松了一口气,杨昌济催着:“昭绶兄,快走啊!”孔昭绶却是焦急地向士兵们追去的方向张望着:“我说,不会是谁被他们认错了吧?”徐特立说:“他们要抓的是你,肯定是看花了眼。”孔昭绶还是不放心:“可万一抓错了人……”\n杨昌济安慰他:“带头的是刘俊卿,真弄错了,他也能认得,不会连累别人的。昭绶,快走啊!”两个人拉着孔昭绶,硬把他推出了门。孔昭绶似乎还有些担心,但当此时刻,确也无力去核实,只得匆匆离去。\n刘俊卿一马当先,带着士兵们蜂拥追逐,穿长衫的背影跃山坡,过树丛,奔台阶……身后,枪声和士兵的叫喊响成了一片。\n背影冲过一条窄巷,骤然发现自己已拐进了死路——面前是横挡着的高墙。身后,跑过的刘俊卿一眼看到了僵立的背影,他大喊着,“他在这儿,他在这儿。”众多士兵哗啦将巷子口封了个水泄不通。\n“跑?”盯着无路可逃的背影,陈副官冷森森地笑了,“你往哪儿跑?再跑一步试试?”正在这时,犹豫了一下,本来僵立不动的背影突然纵身向墙头爬去。“妈的,活腻味了!”背影充耳不闻,半个身子已经骑上了墙头。陈副官抬起手枪,正对着后背开了一枪,“给我下来,听到没有?”\n一声枪响,背影全身一震,一头从墙上跌落下来。鲜血从他的后背、前胸同时涌了出来。\n“你跑啊,你跑啊!”刘俊卿一个箭步冲上前来,一把揪住了俯卧在地上人,“你不是要开除我吗?你不是撕我的文章吗?你不是不给我活路吗?你也有今天?”一把将地上的人翻转过来,熟悉的面孔映入眼帘,蓦然,刘俊卿呆住了:“爸?!”\n副官等人都是一愣,也纷纷围了上来。“爸,怎么会是你?爸,你撑住,你撑住啊……”刘俊卿拼命要把刘三爹抱起来,然而,刘三爹却死死按住了他的手。一口唾沫,和着鲜血,狠狠啐在他的脸上:“畜牲!”\n刘俊卿愣住了。\n陈副官的眼睛凶狠狠地眯了起来,抬起了手枪:“妈的,敢骗我!”\n刘俊卿大惊失色,拼命来挡:“不,不不!不要,他是我爸,他是我爸……”\n副官一把将他推翻在地,枪直顶在刘三爹头上。砰,枪响了!鲜血猛地溅了刘俊卿一脸,溅得他呆如雕塑……\n三 # 刘三爹的头七,雨下了整整一天。王子鹏一大早就来到秀秀家,帮着布置灵堂,安置灵位。秀秀倚在床上,从送完葬回来那天起,她整个人都垮了。子鹏端来一杯水送到嘴边:“阿秀……”秀秀呆呆地摇了摇头。也不知应该怎么劝她,子鹏黯然放下了杯子。\n房门突然开了,风夹着雨点,一下子洒进门来,全身上下滴着水的刘俊卿出现在门前。他站在门口,似乎想走进房,但望着父亲的灵位,看看妹妹的样子,却又有些鼓不起勇气,抬起的脚又缩了回去,“阿秀,我……我有话跟你说……”\n秀秀的目光移到了另一边,她宁可看墙壁也不愿看这个哥哥一眼,更不想跟他说话。\n刘俊卿上前一步,恳切地说,“阿秀……你听我说,我会去找事做,以后有了薪水,你也不用上王家当丫环了……”“滚。”秀秀从牙缝里挤出一个字来回答刘俊卿。\n“阿秀,我知道你恨我,我也在恨自己!我……我不知道该怎么跟你说……阿秀,你不用叫我哥,也不用理我,你就是别再去当丫环了,好不好?我求求你……”“滚!”秀秀还是只有这一个字。\n“我……”刘俊卿一阵冲动,抬脚迈进门来,看了一眼秀秀,但秀秀还是背对着他。他又把那只迈进了门的脚重新缩到了门槛外,对着子鹏递来了求援的目光:“子鹏兄,我知道,你是好人,你帮我劝劝阿秀吧,我求你,劝劝她吧。”\n“你走吧,阿秀不想见到你,我也不想见到你。”子鹏的回答出乎意料。刘俊卿不敢相信,“子鹏兄……”\n猛然间,从来是那么柔弱,从来不对人说一句重话的子鹏腾地站了起来,指着门外,一声怒吼:“你滚!”刘俊卿吓得倒退一步。\n王子鹏长到二十几岁,第一次冲人发这么大的火,发过之后,他反而有些手足无措,不知说什么好,轻轻叹了口气,避开了刘俊卿的目光,重新坐回到秀秀身边。\n屋外,雨越下越大,秀秀仍然一动不动,刘俊卿一步,又是一步,退出房门,轻轻把门关上。他不知道,他走之后,秀秀猛然回头,看着紧闭的房门,死死抱住子鹏的手臂,撕心裂肺地痛哭起来。子鹏搂住她,抚摸着她的头,眼泪同样淌过了面颊。\n刘俊卿跌跌撞撞走在雨中,他心中只有一个念头:他要拜祭父亲。秀秀不肯原谅他,不让他给父亲上香,他要找到父亲的坟墓,要去父亲的坟前磕头上香。\n“义士刘三根之墓”——七个血红的大字映入眼帘,全身透湿的刘俊卿呆若木鸡,一双膝盖再也支撑不住,猛地跪倒在坟前泥水里,任由雨水冲刷着他的全身。雨水顺着他的头发,淋过他的脸——他的脸上,早已分不清雨水与泪水。\n坟头新垒的泥土被雨水冲刷得滑落了下来。几乎是下意识的,刘俊卿伸手拦挡着滑落的泥土,要将泥重新敷上坟堆,但雨实在太大,泥浆四面滑落,他挡得这里挡不得那里,越来越手忙脚乱,到后来,他已是近乎疯狂地在与泥浆搏斗,整个人都变成一个泥人!“爸,爸……”他猛地全身扑在了坟堆上!压抑中爆发出的哭喊,是如此撕心裂肺,那是儿子痛彻心底的忏悔!\n一把雨伞悄无声息地遮住了他头上的雨。刘俊卿回过头,一贞打着雨伞,正站在他的身后。“一贞?”愣了一阵,刘俊卿突然吼了出来:“你还来干什么?你走,你走开!”手足并用,他连滚带爬地退缩着:“我不是人,我不是人!我这种狗屎都不如的东西,你还来干什么?你走,你走啊……”仿佛是耗尽了全身的力气,狂乱的喊叫变成了无力的呻吟,他一把抱住了头:“你走啊……”\n一贞默默地走上前,将遍身泥水的刘俊卿搂进了怀里。“一贞。”刘俊卿猛地一把紧紧抱住了一贞,哭得仿佛一个婴儿,“一贞,一贞,我该怎么办,我该怎么办?汤芗茗要我干侦缉队长,要我干那咬人的活,他恨不得我见人就咬一口,要咬得又准又狠,咬中那人的痛处。他要我拿枪,要我用拿笔的手拿枪杀人啊!”\n“俊卿,要不,咱们去找找纪老师,让他帮着求求情。”“纪老师?纪墨鸿?哈!一贞,你知道纪墨鸿是什么人吗?他让我去一师抓孔校长,让我欺师卖友,让我背黑锅!”大风大雨中,刘俊卿的嘶吼声仿佛受伤的野兽。\n“没关系,俊卿,没关系的,你不想做那个侦缉队长,咱们就不做。我们不拿枪,不杀人,你不是喜欢读书吗?我们回去读书。”赵一贞流着泪说。\n“回去?”刘俊卿冷笑,“回去?回去哪里?第一师范?他们恨不得挖我的心,喝我的血,又怎么会让我回去。退一万步讲,即使一师还要我!一贞,你怎么办?我能眼睁睁看着你嫁给老六那个流氓!”\n赵一贞慢慢松开刘俊卿,脸白如纸,“你都知道了?你怎么知道的?”“我怎么可能不知道!这七天来,我看着老六一趟一趟地往你家跑,看着他把扎红带彩的三牲六礼一趟一趟往你家抬,看着你爹收下老六的婚书,看着他跟老六赔笑脸,我是个男人,我是个男人啊!”\n“别说了!俊卿,别说了!”赵一贞再也听不下去,用尽全身力气喊了出来。她捂着脸,泪水从指间不断涌出来,“俊卿,求求你,别说了。”\n刘俊卿把她的双手从她脸上拿开,十指交叉,两个人四只手交叉在一起,这时的他,已经完全平静下来,“一贞,你放心,我不会让老六得逞的。”\n四 # 汤芗茗来到湖南之后,任命张树勋为警察长,以严刑峻法治理湖南,大开杀戒,仅这两个月被杀的就不下千余人。三堂会的娼嫽、烟馆、赌场也被封的封,关的关,生计越发艰难起来,不得不重操旧业,做起码头走私鸦片的活计。\n马疤子这趟货走得提心吊胆,满满30箱鸦片,几乎是三堂会的半副身家,这天夜里,货刚到长沙码头,没等他和押货的老六松口气,只听得“闪开!都他妈闪开……”一阵气势汹汹的吼声,荷枪实弹的侦缉队特务们一拥而上,拦住了一大帮正在卸货的三堂会打手。\n守在一旁的马疤子腾地站了起来,老六赶紧上前:“怎么回事?你们要干什么?”\n“没什么。”特务们一让,刘俊卿出现在面前,他一把推开了拦路的老六,举起一张纸向马疤子一晃,“奉上峰令,检查鸦片走私而已。”向特务们一挥手,“给我搜。”\n老六等人还想拦挡,马疤子却抬手制止住手下。\n特务们乒乒乓乓动起手来。\n很快,一个个特务跑了回来:“队长,没有。”\n马疤子笑了:“怎么样啊,刘队长?我马疤子可一向奉公守法,就靠这老实本分的名声混饭吃,今天这事,不能搜过就算吧?”\n打量着满地打开的货箱,刘俊卿一言不发,走上前来。翻翻箱子里的货,不过是些稻草裹鸡蛋,果然并无可疑之处。他的目光落在了用来当扁担抬货箱的一根根竹杠子上——那些杠子根根又粗又大。刘俊卿突然笑了:“马爷做生意,可真是小心啊,一箱鸡蛋才多重?也要用那么粗的竹杠子挑,太浪费喽。我看,这一根竹杠,劈开了至少能做四根扁担,要不,我帮帮马爷?”\n他抬腿就要踩脚边的竹杠。\n“刘队长、刘队长,有话好商量。”马疤子的脚抢先撂在了竹杠子上,“刘队长,给个面子,有话慢慢说。”\n两人进了码头附近一家茶馆的包间里,把手下都留在了门外。\n“这读书人就是读书人,脑筋就是转得快。不瞒刘队长,我马疤子吃这碗饭有年头了,能看出我这套把戏的,你算头一个。”马疤子满脸堆着笑,凑到了刘俊卿面前,说,“愿意的话,到我三堂会,有饭一起吃?”\n刘俊卿“哼”了一声,心里想:“敲竹杠”这样的手段,早就不是什么新鲜玩意了,你还敢在老子面前玩?\n“这侦缉队能挣几个钱?只要你进我三堂会,这二把交椅马上就是你的,凭你这脑袋瓜子,包咱们兄弟有发不完的财。”马疤子还想劝,看看刘俊卿一脸不屑,也便收了声,“刘队长还是看我们这行不上啊。那好吧,我也不勉强,一句话,你什么时候想通了,我什么时候欢迎你。我要的,就是你这种聪明人!”说完把手一拍,老六掀开帘子进来了,将一口小箱子摆到了刘俊卿面前。马疤子揭开箱子盖,露出了满满一箱子光洋,光洋的上面摆着那份婚书。\n刘俊卿拿起那张婚书便起了身:“别的就不必了,我只要这个。”\n五 # 因反袁而导致的第一师范孔昭绶事件,震惊了民国之初的全国教育界。因遭到袁世凯的全国通缉,孔昭绶被迫逃往上海,第二次赴日本留学。\n孔昭绶潜出长沙的那天,毛泽东也正在问自己老师和同学:“教育真的能救国吗?校长曾经告诉我,教育能救国,我也曾经以为,只有我们这些受教育的青年,才是中国未来的希望。可今天我才知道,搞教育的,连自己都救不了,那教育又怎么救别人,怎么救这么大的国家呢?”\n“我回答不了你的问题,润之,因为我也苦恼。”蔡和森沉吟了好一阵,又说,“但我还是相信,人会进步,社会会进步,国家也会进步。而进步,是离不开教育的。”\n“我也相信过,社会一定会进步,我也相信过,人,一定会越变越好,可为什么我们的身边并不是这样?有的人,有的事,真的能靠教育,真的能靠空洞的理想就改变过来吗?”\n“靠读书,也许是不能救国,靠教育,也许也不能改变一切。”杨昌济道:“但只有读书,我们才能悟出道理。只有读书,你今天的问题,才有可能在明天找到答案。除此以外,你还有什么更好的办法,破解你心中的疑团呢?”\n江水浑浊,无语北去。一团疑云也在毛泽东的心头渐渐升起,越来越大。\n第3部分\n[手机电子书网 Http://www.bookdown.com.cn]\n第十七章 新任校长 # 大幅 “第一师范增补校规条例”\n一张接一张,贴满了整个一师公示栏。\n章下有则,则下有款,款下有条,条再分一二三,\n蝇头小楷,密密麻麻,洋洋乎大观,\n最后落着校长张干的签名和大印。\n一 # 周末,天空中阴沉沉一片,大雨倾盆,打得人几乎睁不开眼睛。一师校园里,毛泽东光着膀子,在双杠间上下翻飞,雨水从他的头发、身体四处淋下,他全然不顾,任由大雨冲刷身体。萧三、罗学瓒匆匆从外面赶回来,直接找到毛泽东。\n萧三迫不及待地说,“润之哥,教育司给咱们一师派了个叫张干的新校长,听说是纪墨鸿推荐的。”罗学瓒也说,“你想想,纪墨鸿推荐的角色,能有什么好人?”“好人坏人要来了才知道,现在担心?太早了点吧?”毛泽东不经意笑笑,继续他的双杠动作。\n“那今晚读书会的活动还搞不搞?”看到毛泽东的笑容,萧三稍稍放心了些。毛泽东停下来,“搞!怎么不搞?”\n“可是——”萧三正想说点什么,一转眼,看着黎锦熙伞也没打扬着手匆匆跑过来,溅得长衫上又是泥又是水。“黎老师,您这是干什么,也来学润之雨中修身?”黎锦熙为人向来不拘小节,萧三这些学生最喜欢跟他开玩笑。\n黎锦熙一把拉住毛泽东,“那个……那个,张校长很关心你,要你以后下雨天不要出门,以免淋出病来。”\n“张校长?”毛泽东好半天才回过神来,“黎老师是说新来的校长,他来了?看来他还管得蛮宽的,刚来就管到我头上来了。”“润之,张校长这也是关心你。”黎锦熙说。\n毛泽东见黎锦熙一副左右为难的样子,遂从双杠上跳下来,抬头看了看对面楼上校长室紧闭的窗户,笑着说,“这张校长刚来,怎么也得尊重尊重,黎老师就不必为难了,大不了以后找个张校长看不见的地方修身。”\n当晚的读书会上,杨开慧听说此事,笑得直不起腰,用拳头捶着毛泽东结实的脊背,“毛大哥会被雨淋病?这校长长没长眼睛啊?”斯咏拉住开慧的手说:“人家也是关心润之的身体,应该也是出于好意。”\n毛泽东:“大概吧?就是管得也太宽了一点。哎呀,不管他,我们搞我们的。”他站起身来,将手里一本《青年杂志》创刊号往桌上一放:“大家安静一下,今天,我们讨论一个新的内容。《青年杂志》发刊词——陈独秀先生的《敬告青年》!国人而欲脱蒙昧时代,羞为浅化之民也,则急起直追,当以科学与人权并重。陈独秀先生的这番话,真正讲到点子上,中国的问题在哪里?就是不重科学,就是不讲……”\n“这是在干什么?”突然推开的门打断了毛泽东的慷慨激昂。一位身穿紧巴巴的日式文员制服的中年人出现在门口,扣子扣得一丝不苟,脸色苍白而瘦削,戴着一副略略有些老式的金丝眼镜。\n毛泽东放下了手里的杂志问,“你是谁?”“本校校长——张干!”所有的人都吓了一跳,大家纷纷站了起来,只有毛泽东还坐着。蔡和森解释说:“张校长,是这样,我们正在搞读书会的讨论活动……”\n张干打断他,“男男女女,半夜三更,讨论?——谁发起的?”毛泽东这才站起身:“我发起的。”听他喉咙还蛮粗,张干瞟了他一眼:“你哪个班的,叫什么?”“本科第八班,毛泽东。”\n张干打量着毛泽东,显然对这个名字还有印象:“马上解散!”“为什么?”毛泽东不服气。\n“学校是读书的地方,不是给你搞什么讨论的地方!”张干看了一眼斯咏、警予这几个女生:“你们几位是哪里的?”警予头一扭,没理他,还是斯咏主动回答:“周南女中。”“第一师范是男校,外校女生深夜滞留,多有不便。”张干向门口一指,“几位,请自重吧。”警予脸都气白了,开慧也是一脸忿忿,斯咏赶紧拉了她俩一把,几位女生都气呼呼地向外走去。\n张干又冲其他人呵斥:“还站在这里干什么?都给我回寝室!”众人无奈,纷纷散去。毛泽东气得把杂志往桌上一拍,一屁股坐下了。张干一眼瞥见,“毛泽东,你怎么还不走?”\n“我住这个寝室,走什么走?”毛泽东收拾起桌上的杂志和笔记本,气呼呼地起身往外走。“你不是住这个寝室吗?怎么又出去?”“不让讨论,我去阅览室看书可以了吧?”“你不看看现在几点了?阅览室早就关门了,你还去什么去?”“我有钥匙。”“学校阅览室,你一个学生哪来的钥匙?”“我看书看得晚,以前孔校长照顾我,特批的。”张干手一伸:“把钥匙交出来。”\n毛泽东愣住了:“这是孔校长给我的。”\n“我是张校长,不是孔校长!熄灯就寝,这是学校的校规,你不知道吗?交出来!”\n毛泽东万分不情愿地把钥匙放在了张干手上。张干顺手又把他手里那本《青年杂志》拿在手里:“这种跟课业无关的杂书,以后不要再看了!没收!”\n张干离开之后,毛泽东愣了半晌,一拳砸在墙上,满肚子火不知从何发起。\n二 # 第二天一大早,大幅“第一师范增补校规条例”一张接一张,贴满了整个一师公示栏。章下有则,则下有款,款下有条,条再分一二三,蝇头小楷,密密麻麻,洋洋乎大观,最后落着校长张干的签名和大印。众多学生围在公示栏前,眼前如此纷繁庞杂的条例规章把大家都给看呆了。\n“学生不得经营一切非关学术之事业,不得入一切非关学术之党社及教育会。润之兄,这是不是在说我们的读书会啊?”周世钊扶着眼镜读着校规。\n“不得散布谣言,发布传单,或匿名揭帖,鼓动同学,希图扰乱……虽盛暑严寒,必着制服,不得用妖冶华丽之时装,不得裸体、赤足……润之兄,这分明是在针对你嘛,你那天打赤膊雨中修身,他不是还让黎老师管你来着。”萧三也说道。\n“管天管地,管人拉屎放屁!”毛泽东懒得再看后面的内容,扔下一句话,排开人群就走。\n张昆弟继续读着条例,“不得干预外事,扰乱社会之秩序,不得有意破坏校内一切规则。不得停课罢学,不得私自开会演说,什么嘛,这分明在说孔校长反日反二十一条反得不对,不行,我得找他理论去!”张昆弟越读越觉得恼火,蛮劲上来,撸起袖子就要跑去校长室辩个究竟,周世钊、萧三等人也跟在后面跃跃欲试。\n“昆弟,不要冲动!”蔡和森一见情形不对,一把拉住张昆弟,想出了个折中的法子,“这样吧,我们先去问问黎老师,看这是怎么一回事再说。”\n众人来到黎锦熙的办公室,发现方维夏、陈章甫等几位老师居然都在,脸上还一副苦相。\n周士钊眼尖,一眼看到方维夏的办公桌上摆着一本小册子,封面上写着:《第一师范教职工工作条例》。他随手拿起,正要翻开看。方维夏要阻止,黎锦熙却拦住他,“让学生们看看也好。”\n周士钊翻开手册第一页,上面赫然写着——\n本条例计总则14条,下分各项职务细则14类,计校长11条,学监58条,庶务21条,会计11条,教员13条,事务员6条,文牍5条,管图书员5条,管仪器员6条,校医7条,实习主任8条,校园主任7条,工场主任6条,膳食主任5条。具体条例如下:校长,一,主持全校事务,聘请各职教员;二,督率全校职教员忠实尽职;三,规定本校一切规程,并执行政府官厅所颁布之法令;四,酌定学生入学、退学、升级、留级、毕业、休业及赏罚各事项;五,视察全校管教状况,审查教本,并核定学生操行、学业、身体各成绩……\n张昆弟摸了摸脑袋,惊呼:“天啊,方老师,你们的规矩定得比我们还多。”\n方维夏对同学们说,“这个条例我们也是刚刚拿到,这样吧,你们回去上课,这些事,让我们老师出面跟张校长好好谈谈。”\n劝走学生之后,黎锦熙、方维夏来到校长办公室,外面雨下得正大,黎锦熙把还在滴水的雨伞随手放在了墙角,水流在地板上。张干看了一眼,一言不发地拿起那把伞,小心移到门外,黎锦熙不禁有些尴尬,打量着这间熟悉而陌生的校长办公室。\n孔昭绶性格豪爽,喜欢结交朋友,畅谈交心。老师也好学生也罢,甚至一师的勤杂工人,他都能打成一片。他在的时候,校长办公室常常是人来人往,笔墨、书籍、报纸都放在触手可及的地方,略显零乱倒也不失方便。相比之下,张干则内敛严肃得多,公事之外少有谈笑的时候。即便是公事,也常常是三言两语命令了事。他来了之后,这间办公室被打扫得一尘不染,笔墨纸砚,书籍报纸都分门别类,各就各位。办公桌上,孔昭绶钟爱的那方刻着“知耻”二字的镇纸已不知去向,取而代之的那一方,上面刻着个“诚”字。\n方维夏一见这个情形,临时改变主意,条例的事还是不要开门见山的好,他正在考虑怎样委婉措辞之时,只听见张干说,“黎老师,方老师,你们来得真好,我这里有个通知,麻烦你马上下发全校。”\n黎锦熙接过通知一看,顿时愣住了,“月考?还每门都考?”\n黎锦熙激动起来,正要说话,一旁的方维夏对他使了个眼色,两个人一起出了校长办公室之后,方维夏说,“张校长刚来,不了解一师的情况,以月考的形式摸摸学生的底,履行一校之长的职责,这没什么不对吧。”\n方维夏一席话点醒了黎锦熙,“月考算什么,考就考,别的不敢说,一师的这些学生,我对他们有信心。等考试成绩一出来,我们再把校规条例的事摆一摆,张校长也是搞教育的,哪有不同意的道理。”\n“什么,月考?”学生宿舍里,听到消息的毛泽东瞪大了眼睛。\n罗学瓒等人都是一脸的不满,说:“刚宣布的,这个月开始,每月一次,门门功课都要考!”\n周世钊说:“这个张校长,期中期末还不够,是不是想把我们考死?”\n易礼容说:“难怪听说他是纪墨鸿推荐的,现在我才明白了,还是因为孔校长得罪了那个汤屠夫,他故意派这个张干来整我们一师的。”\n易永畦说:“话也不能这么说,张校长可能是想抓好学习……”张昆弟打断他,“只有你老实!抓学习?他张干来了才几天,你看看出了多少花样?加校规加校纪,取消读书会,增加晚自习,连润之兄出去搞锻炼他都不准。现在又是什么月考,不是整人是什么?”\n毛泽东说:“人善被人欺,马善被人骑,我看啊,不能让他把我们当软柿子。”\n罗学瓒眼前一亮:“润之,你有什么主意,我们听你的,你说,该怎么办吧?”张昆弟、周世钊等人都安静下来,竖着耳朵听毛泽东的主意。\n谁知毛泽东轻描淡写地说,“怎么办我不知道,反正他考他的,我就不理他那一套,他能怎么样?”说完,他拿起饭碗,“走,吃饭去。”\n张昆弟、罗学瓒相互交换了个眼神,很显然,他们从毛泽东这句话中受了启发,两个人脸上挂着会心的微笑,勾着肩膀出了宿舍。\n月考成绩很快就出来了,按照张干的吩咐,按分数排出名次贴在了公示栏,前十名写在红榜上,后十名写在白榜上,中间的名次写在绿榜上。与往常不同的是,同学们这一次关注的重点不是红榜,反而是白榜,在那里指指点点,高声大笑。\n一个同学说,“我原以为我够厉害的了,原来还有比我更猛的,唉,居然让你上了白榜。”\n另一个同学一拍大腿,“早知道我就干脆交白卷。”\n人群里,也在看榜的黎锦熙和方维夏两个已经惊得说不出话来了,两个人悄悄出来,低着头闷声朝办公室走去。果然,还没到门口,就听到张干嘶哑的声音,“这是怎么回事?这是怎么回事?一次月考,全校三分之二的学生不及格!这……这些题目并不难呀,怎么会考成这样?难道这就是一师的教学质量?这样下去,一师还能出几个合格的毕业生?不行,全校补课!马上补!方主任,黎老师,你们通知下去,从今天开始,每天晚上增加两节晚自习,星期天全天补课,还有,取消课间操,把做操的时间,并进上午第二节课。”\n方维夏和黎锦熙都愣住了:方维夏说,“校长,这样怕不妥吧?”张干说,“有什么不妥?学生成绩都成这样了,还不补怎么得了?”黎锦熙急了,“可补课也没有这样补法的。学生也是人,连课间操都不做了,哪有这样压着学的?”\n张干说,“读书就是要有压力!不压哪来的成绩?”黎锦熙反问,“可张校长压来压去,压出成绩了吗?”张干指着黎锦熙说,“你是说——倒是我害学生成绩变差了?”方维夏拉了黎锦熙一把,黎锦熙却把他的手一甩:“反正孔校长手上,一师的学生,从来没有这么差的成绩!”\n“你……”张干腾地站了起来,颤着声音说,“黎锦熙,我是第一师范的校长,第一师范的教学怎么进行,我说了算!”黎锦熙也站了起来:“那我也可以告诉张校长,这样的教学方式,我绝不赞成!”\n黎锦熙回到自己的办公室,心中犹如有一团火在烧,拿起笔,辞职信一挥而就。但完成之后,他又坐了下来,看着辞职信发呆。方维夏从后面追过来,推开门,看到黎锦熙手里的辞职信,脸色都变了,一把抓起,揉成一团,“锦熙,你……”\n正在这时,蔡和森、毛泽东、张昆弟、罗学瓒等几个同学也闻讯起来了,他们站在办公室门口,一个个像做错事的孩子,低头看着地面,不敢进来。\n黎锦熙笑笑,对同学们说,“都站在门口做什么,快进来。”说着,又从抽屉里拿出几封信,递给方维夏。方维夏打开一看,都是来自北京大学文学院的邀请信,最早的一封日期是半年前。方维夏抬起头,“锦熙,这……这是好事,你不是一直提倡言文一致,国语统一吗,这可是实现理想的大好机会,你怎么不早说?”\n黎锦熙笑着说,“我现在不正在说吗?”他转过头,对毛泽东说,“润之啊,老实说,这一次的事件,是不是你的主意?”毛泽东莫名其妙,“我的什么主意?”\n蔡和森忍不住质问,“润之,敢做就要敢当,这次月考的事,我听说是你发动同学,让大家通通不要考好成绩,给校长一个下马威,是不是?”\n毛泽东有点摸不着头脑,不知这矛头怎么一下子都朝着他来了,“这是怎么一回事,你不要冤枉我了啊,月考我是反对,但我也没有发动同学顶着干啊。”\n罗学瓒一见势头不对,忙上前解释:“黎老师,方老师,这件事真的不怪润之,他只是对月考有意见,说了几句,我们觉得他说得在理,所以,就悄悄联络同学顶着干了。”\n黎锦熙连连叹气,“你们怎么能这样呢?对月考有意见,你可以提嘛。哦,串联同学,故意考差,这就是你们的办法?为了目的,为了结果,也不能不讲方法,不讲手段吧?用这样的手段,只会适得其反,你们知不知道?”他又转向毛泽东:“你也是,他们这么干,你不可能事先不知道,大家平时都听你的,你要是劝阻一句,还会发生这种事吗?”\n毛泽东不服气,挺着脖子说,“张校长定的那些校规,是不合理嘛,我凭什么要劝阻啊?再说,顶他一下天也不会塌下来!”\n黎锦熙深深透了一口气,这才心平气和地对毛泽东说,“润之,不管怎样,我也要批评你几句。这次的事,张校长抓学习的方式可能是急了一点,但他终究还是为了你们的成绩,纵容大家串联同学,跟校长对着干,这算怎么一回事。你们对新出来的校规不满,本来我跟方老师商量,等你们月考成绩出来,跟校长坐下来好好谈,现在被你们这么一闹,唉……这样吧,杨老师出去讲学,也快要回来了。对学校的一些做法,你们就算有什么意见,也得等他回来,请他出面来解决。在此以前,不管张校长有什么要求,大家还是要服从,要记住自己是第一师范的学生,都记住了吗?”\n同学们依依不舍,一直把黎锦熙送出校门很远,眼见快要上晚自习了,这才返回学校。此时天色已暗,深秋的晚风颇有些刺骨的意思,吹到身上带着寒意。他们经过公示栏时,猛然发现那里又换了新花样,刚挂上的“距期末考试35天”的鲜红大幅警示即使在夜色中也赫然在目。罗学瓒几个对着警示撇了撇嘴,一脸的不满。张昆弟四处看了看,一个人影也没有,就走上前去,打算搞点破坏。他的手指还没碰到公示栏,另一只手比他动作更快,挡在了前面。张昆弟定睛一看,原来是毛泽东。\n“润之哥?”张昆弟不解。“黎老师刚才说的话,你就忘记了?”毛泽东说。张昆弟一听这话,脸色立刻变得沉重起来,不再说什么,把手收回来,跟着大家进了教室。\n教室里,手里抱着厚厚一堆资料的易永畦看见他们几个进来,连忙说,“你们来了,资料我都帮你们领了。”\n“什么资料?永畦,你病才好一点,这些事让我们来做就行了。”毛泽东连忙接过资料,拿在手里翻开一看,是厚厚一大本油印的《补充习题集》,再看课桌上,已经堆起了好几门课不同的补充习题、辅导资料等……毛泽东越看越心烦,“叭”地一声合上,正要发火,一旁的蔡和森推了推他,原来张昆弟他们几个,比他还冲动,一个一个正在用力把习题集砸在桌上,只差把它们撕成粉碎了,他赶紧大喊一声,“昆弟,你们几个做什么?!”\n“我撕了这些破玩意。”张昆弟话一出口,看到毛泽东、蔡和森等人一脸的不赞同,遂有些不好意思,摸摸脑门,“好了,好了,我做就是了。”\n张昆弟乖乖坐下之后,同学们也一个一个坐回位置,开始忙着那一本本习题集。做题的时间似乎过得特别慢,毛泽东忍不住想打哈欠,他本来还要忍,但见好几个同学也都疲倦得在打哈欠,也就不客气地伸起懒腰,大大打了个哈欠。\n另一张课桌上,易永畦咳嗽着,眼睛里全是血丝,好不容易做完了手中的一科功课,又伸手拿起一本作业来。就在这时,一阵咳嗽突然涌上,咳得他几乎喘不过气来,他用手帕捂着嘴,身子几乎弯成了一张弓。同学们都吓了一跳,纷纷围了上来:“永畦,怎么了……永畦……”\n毛泽东扶住易永畦,拍打着他的后背:“永畦,没事吧?”易永畦拼命忍着咳嗽,挤着尽量轻松的笑容:“没事,我没什么。”子鹏端来了一碗水:“永畦,喝点水吧。”\n“谢谢。”易永畦喝了口水之后,轻松多了:“好了好了,我没事了,谢谢你们了。”毛泽东还是不放心,“你真的没事?”易永畦说:“真的没事,只是刚才呛了一下,润之哥,还有功课呢,你去忙吧。”\n等大家纷纷散去,各自捧起了书本,易永畦才悄悄展开一直攥在手里的手帕,偷偷一看——手帕上竟然沾有血丝!他赶紧攥紧手帕,胡乱塞进口袋,生怕被同学们发现……\n好容易熬到晚自习下课,同学们总算松了口气,纷纷收拾东西准备离开,正在这时,教室门“吱呀”一声推开了,张干走了进来,径自走上讲台,“从今天开始,晚自习之后增加一堂课,今天补解析几何。”张干边说边在黑板上写下数学公式。\n同学们好半天才回过神来,只得强打精神继续听课。讲到一半的时候,电灯突然熄灭,教室外面传来校役的梆子声,“电灯公司拉闸了,各室点灯,小心火烛。”众人心中又升起隐约的希望,眼睁睁看着讲台上的张干。\n只见张干取出油灯,点燃之后,又拿出一个袋子,“前面的同学上来领蜡烛……我们继续上课……”\n三 # “子鹏,好一段没看见你上你姨父家了吧?”礼拜天子鹏一回家,王夫人就问起了儿子。\n子鹏这才想起来:“哦,我……忘了。”\n“怎么能忘了呢?你这孩子,斯咏是你未婚妻,你都不去看人家,人家还不当你没心没肺啊?下午就去,趁着礼拜天!”\n“我……还有功课呢。”\n王老板放下了报纸:“功课晚上做嘛。你跟斯咏,本来就走得不热乎,还不多来往,越发生疏了。按你妈说的,去!”\n吃过午饭,子鹏只得出门去陶家。秀秀的脚跟在子鹏的皮鞋后。但今天她却做不到往常的亦步亦趋,因为子鹏自己都心事重重,一副不知道要去什么地方的样子。前面,陶府的大门已遥遥在望。子鹏的脚步却停住了,犹豫了一下,他突然转身折回来路。\n秀秀紧跟上来问:“少爷,咱们不是上表小姐府上吗?”\n子鹏摇了摇头,看着秀秀,说:“我不想去那个府上,阿秀,找个清静点的地方,陪我坐坐吧。”\n两人漫无目的地打发着时间,不知不觉的,竟一前一后地来到了教堂前。子鹏站在教堂台阶下,凝视着教堂顶上的十字架,听庄严的教堂钟声在天际飘然回荡,看晴空下,鸽子群扑啦啦飞起,掠过教堂哥特式的拱顶和高悬的十字架。这静谧的宗教世界仿佛是一片世外桃源,隔断了世俗一切。子鹏在台阶前坐下了,拉了拉身边的秀秀,说:“阿秀,陪我坐坐吧,坐到我身边来。”\n“少爷,这……”\n“不要叫我少爷。这儿是教堂,在神的眼里,只有一个阿秀,一个王子鹏,没有少爷和丫环。”子鹏伸手握住了阿秀的手,“就让阿秀和王子鹏平等地一块儿坐坐,好吗?”\n望着子鹏坦诚的目光,秀秀犹豫了一下,在他的身边坐了下来。\n主仆二人在这个他们心里的世外桃源里,说着平常不容易说出口的知心话。却忘记了这里还是公共场所,不知道就在教堂旁的小街上,背着擦皮鞋的工具箱子,蔡和森与警予正并肩走来。\n蔡和森正在问警予,每个周末都来帮他擦皮鞋,会不会耽误警予的功课。\n警予白了他一眼,尖刻地问:“怎么,嫌我烦啊?”\n“我哪敢呀我?再说,有你帮忙,我挣的钱可多多了。”\n“那你还啰嗦什么?赶紧谢谢本小姐吧。哎!”警予突然一拉蔡和森,“那不是斯咏的表哥吗?”视线中,果然是子鹏与阿秀坐在教堂台阶上,正在说着话。警予向蔡和森一勾手指,“走,听听他们说什么。”\n“人家说话,你干嘛偷听?”蔡和森不想去。\n“那可是斯咏的未婚夫,瞒着斯咏在这儿拉拉扯扯的,我当然得听听。”警予一把拖着蔡和森就走,蔡和森又不敢出声,只得跟着警予,绕向教堂的一侧。\n台阶上,子鹏喃喃地,仿佛是在对阿秀倾诉,又更像是在自言自语:“过去,斯咏不愿意见我,我还不明白为什么,总觉得是我们来往太少,缺乏了解。现在我才明白,不想见一个人,却非要去见,是一种什么样的感觉。”\n“可您和表小姐定了亲的呀。”\n“定了亲又怎么样?定了亲就等于有感情吗?斯咏是那么热烈,那么奔放,她需要的,不是我这样性格柔弱的人,而我,每次跟她在一起,也总感觉是那么别扭,那么不自然,我和她根本不是一路人,又何必勉强在一起,互相破坏对方心里那份自然和宁静呢?\n墙角,警予偷听着,不住地点头。她身边的蔡和森显然觉得偷听别人的私语很不妥,想拉她走,却反而被警予用力按住。他哪里拗得过警予,只得陪着一起偷听。\n“我喜欢生活得简单,我喜欢宁静的日子。”台阶上,子鹏扭过头看着秀秀,说,“阿秀,倒是跟你在一起的时候,我会觉得非常非常的平静,非常非常的自然,这种感觉,根本不是跟斯咏在一起时能找到的。”\n秀秀有些慌乱地赶紧侧过身:“我只是个丫环,哪能跟表小姐比?”\n“不,在我心里,你比斯咏强得多。为了供你哥上学,为了照顾你生病的父亲,你吃了多少苦,受了多少罪,可你都一个人默默地扛着。如果说过去我还以为自己有多么善良的话,那么是你,告诉了我什么是真正的善良,什么是真正的坚强。尽管你很少开口,可我觉得,你,才是我最好、最知心的朋友。”子鹏说着话,一把握住了秀秀的手。\n眼泪湿润了秀秀的眼眶,望着子鹏,她似乎想说点什么,但又不会表达,只得看看被子鹏握着的手,轻轻垂下了头。\n“以后,我再也不去陶家了。爸爸妈妈非要我去,咱们就到这儿来,像现在这样,像一对最好的朋友,安安静静的,坐在神的脚下,让我们的心,更纯净,更安宁,好吗?”\n“我给你唱首歌吧,唱一首我们老师教我们的歌唱圣母的歌。”看到秀秀点了头、答应了自己,子鹏轻轻唱起了古诺的《圣母颂》:“圣母玛利亚,你是大地慈爱的母亲,你为我们受苦难……”\n宁静的歌声中,墙角的警予缩回了头。蔡和森还没发现她的情绪变化,正想探头往台阶那边看,警予一把将他揪了回来。他这才发现,警予的眼圈都红了。默默地沿着教堂后僻静的小街走出了老远,警予还在边走边擦着眼眶里的泪水。蔡和森忍了忍,还是问道:“怎么了你?”\n“受感动嘛。你不感动啊?”\n“你刚才还说他们拉拉扯扯的。”\n警予用胳膊肘一顶蔡和森:“你们男的怎么都这么没心没肺?人家说得多诚恳,多打动人啊?我都被他感动。你呢,死木头一个!”\n看到路边的石凳子,警予直着身子气哼哼地走过去坐下了。蔡和森也不知道她在生什么气、为谁生气,在她身边坐了下来,还傻乎乎地反问着:“可你不是说他是斯咏的未婚夫吗?”\n“他都说了,他们俩不合适嘛。我看也是,他呀,还是跟那个小丫环合适。”\n“人家把阿秀是当朋友,没你想的那么复杂。”\n“为什么不能复杂,为什么就不能复杂呀?我看他们俩就应该在一起。反正啊,今天的事,我绝不告诉斯咏,就要让他们发展下去。”\n“一个少爷,一个丫环,真要发展也难。身份地位差别那么大,真要发展下去,只怕也是个悲剧。”\n“要我说,阔少爷就应该配丫环,穷小子呢,就应该追求小姐,这样的爱情才是自由的爱情,什么身份地位,什么传统观念,通通见鬼去!”警予扬起拳头,威胁蔡和森,“赶紧赞成我一句。”\n蔡和森赶紧捂住了头,忙不迭地赞叹着:“你说得对,说得太对了。”\n“这还差不多。”警予仰头望着蓝天白云,长长舒了一口气,“要是人人都能有王子鹏那样的勇气,人人都能自由自在地追求心中的幸福,那该多好啊。”\n望着警予映着晚霞的脸,蔡和森的心里,突然涌起一阵激荡。悄悄地,他把手一寸一寸地向警予的手挪去,眼看手就要碰到警予的手,“当”的一声,教堂的钟声却在这时突然响起。蔡和森的手条件反射似的往后一缩,然而,不等他真缩回去,警予却一把抓住了他的手、一只手指着天空,兴奋莫名地叫道:“哎,哎,鸽子,鸽子!你看啊,你看啊!要是我能变成一只鸽子,那么自由,想飞就飞,该多好啊。”\n一大群鸽子刚刚被钟声所惊起,扑啦啦从教堂的顶上掠过,展翅飞翔在空中,但蔡和森的心思却不在这些鸽子身上……\n虽然明知警予只是情不自禁地握着自己的手,蔡和森还是情不自禁地脸热心跳。\n那天夜里,蔡和森的心情不能平静。躺在床上,他手枕着头,眼睛睁得大大的。辗转中,他索性一翻身爬了起来,悄悄跑到八班寝室,把毛泽东叫了出来。并排躺在草坪上仰望着夜空,蔡和森问毛泽东:“你说,这个世上,你最爱的人是谁呀?”\n“我娘。”毛泽东正睡得迷迷糊糊,被蔡和森强行拽到这里,头脑还是昏沉沉的。\n“妈妈不算。我是说除了亲人。”\n“那我倒没想过。你怎么问起这个来了?”这么古怪的问题,再加上外面凉爽的空气,终于让毛泽东清醒了。\n“随便问问嘛!哎,你就没有觉得哪个人跟你特别投缘、特别亲近吗?”\n“嗯,有的,杨老师。”\n“长辈不算。”\n“那,开慧,我跟她蛮亲近。”\n“太小的也不算。”\n毛泽东坐了起来,冲着蔡和森吆喝道:“我说,你到底想讲什么呢?东拉西扯的。”\n“没什么,我就是……你就没觉得有哪个同龄人特别让你觉得没有距离吗?”\n“你呀!”\n蔡和森瞪着一脸茫然的毛泽东,真不知道该怎么跟他继续说下去了。\n“毛泽东,蔡和森!”\n张干威严的声音骤然响起,那两个半夜起来谈心的人吓得赶紧爬了起来。\n“半夜三更,为什么夜不归宿?还不给我回寝室?”\n两个人哪敢作声,赶紧掉头就走,身后传来张干凶巴巴的吼叫声:“明天写检查,交到校长室!还有,打扫三天走廊!”\n第十八章 易永畦之死 # 去去思君深,思君君不来。\n愁杀芳年友,悲叹有余哀。\n衡阳雁声彻,湘滨春溜回。\n感物念所欢,踯躅城南隈。\n城南草萋萋,涔泪浸双题……\n一 # 此后的一师日程表上,便填满了一次又一次接踵而至的大考小考。整个学校像是一个大大的蒸笼,而学生们就像是蒸笼里的白薯,除了考试这个紧张的白色烟雾,什么都看不到。转眼间,在一师公示栏里,“距期末考试35天”的大幅警示已是赫然在目。学生们的课桌上已经堆起了几门课不同类型的补充习题、辅导资料,全把头埋在了高高的书堆里。白天如此,晚间补课也如此,停电以后,还要点起蜡烛继续奋战,身体好的同学已经吃不消了,像易永畦这样身体差的,更是顶不住,已经要端着药碗来上课了。但永畦尽管咳出血了,却还是悄悄忍着,一来不想让同学们担心,二来他也没钱治病。\n这样的状况却正是张干期待的。前任校长让他得到的教训,就是要把学生死死地拴在教室里,用繁重的功课压住他们,这样,他们就没有时间、没有精力、没有心思做那些会给他们带来危险的事情,也唯有这样,他们才会安全。\n这天,张干进了校长室,一如往常、不紧不慢地放下公文包时,看桌上有一封落着省教育司款的公函。他拿起来启开封皮,顿时愣住了。\n“砰”的一声,张干重重地关上校长室的门,沉着脸,脚步匆匆地赶到了教育司,把那份开了封的公函砰地拍在纪墨鸿办公室上!\n“老同学,你这是干什么?”纪墨鸿吓了一跳。\n“你还问我?你倒说说,你这是要干什么?”张干一把抽出了信封里的公文,读道,“‘从本学期起,在校学生一律补交十元学杂费,充作办学之资,原核定之公立学校拨款照此扣减’!我一师是全额拨款的公立师范学校,部颁有明令,办学经费概由国家拨款,怎么变成学生交钱了?”\n纪墨鸿叹了口气,无奈地说:“老弟,叫你收钱,你就收嘛。”\n“这个钱我不能收!公立师范实行免费教育,这是民国的规定!读师范的是些什么学生,他们的家境如何,你还不清楚?十块钱?家境差的学生,一年家里还给不了十块钱呢!你居然跟他们伸手,还一开口就是十块一个,你是想把学生们都逼走吗?”\n纪墨鸿一言不发,拉开抽屉,将一张将军手令推到了张干面前: “你也看到了,省里的教育经费,汤大帅一下就扣了一大半,要公立学校的学生交钱,也是他的手令,我能有什么办法?”\n“可教育经费专款专用,这是有法律规定的!”\n“老弟啊!枪杆子面前,谁跟你讲法律?孙中山正在广东反袁,他汤芗铭要为袁大总统出力,就得买枪买炮准备打仗。你去跟他说,钱是用来办学校、教学生的,不是用来买子弹、发军饷的,他会听你的吗?”纪墨鸿摇了摇头,起身来到张干身边,“老同学,我也是搞教育的,我何尝不知道办学校、教学生要用钱?我又何尝想逼得学生读不成书?可胳膊扭不过大腿,人在屋檐下,你就得低这个头啊!”\n长长地,张干无力地叹了口气。\n二 # 正如张干所言,一师的学生中,有几个家庭条件好的?比如毛泽东,要是家庭好,他怎么会来读一师呢?\n此时,还不知道要交钱的毛泽东正在校园里边走边读着一封母亲的来信: “三伢子,告诉你一个不好的事,你爹爹最近贩米,出了个大事,满满一船米,晚上被人抢光了……贩米的本钱,有一些还是借的。为这个事,你爹爹急得头发都白了一半。现在家里正在想办法还债,这一向只怕是没有办法给你寄钱了,只好让你跟家里一起吃点苦……”\n转过弯,突然传来一阵吵闹声,毛泽东放下手里的信看过去,只见公示栏前人头攒动,一片愤愤之声。毛泽东挤进人群一看,公示栏上,赫然是大幅的征收学杂费的通知。\n晚自习时,整个学校完全没有了往日的宁静,各个教室里,学生们都议论纷纷。\n“这次交学杂费,就是那个张干跟省里出的主意。”\n“上午好多人亲眼看见他喊轿子去教育司,中午一回来就出了这个通知,不是他是谁?”\n“他本来就是那个汤屠夫的人,汤屠夫赶走了孔校长,就派他来接班,汤屠夫要钱,他就想这种馊点子!”\n“什么鬼校长,就知道要钱!”\n不知情的学生们把所有的怨恨都发泄到了张干身上,但迎着学生们怀疑的、不满的、鄙视的目光,张干的脸上,居然平静得毫无表情。他能做什么?除了继续上课、保持学校的正常秩序,他还能干什么?他心里最清楚,唯有这样,他才能保住这些学生。但表情可以硬撑着,钱袋子却迅速地瘪了下去。这么大一所学校,每天有多少开支呀?只出不进,能够维持多久呢?张干正想着这一点,方维夏推开校长室的门进来,说:“张校长,食堂都快断粮了,经费怎么还不发下来?学生们还要吃饭啊!”\n张干沉着脸,一言不发。方维夏以为校长没听清楚说什么,就又重复了一遍刚才的话。张干还是没作声,只是缓缓地拉开抽屉,取出一叠钱来,又搜了搜口袋,摸出几块零散光洋,统统放在方维夏面前。想了想,他又摘下了胸前的怀表,也放在了钱上面:“先拿这些顶一顶吧,菜就算了,都买成米,至少保证学生一天一顿干饭吧。”\n望着面前的钱和怀表,方维夏犹豫了一下,问:“校长,您要是有什么苦衷,您就说出来……”\n“我没有什么要说的。经费的事,我会想办法,就不用你们操心了。你去办事吧。”张干挥了挥手,他所谓的想办法,就是直接去找汤芗铭。\n在汤芗铭的办公室外面,张干紧张地坐着。副官已经进去替他禀报了,可很长时间没有出来。他很希望能见到汤芗铭,当面把一师的情况向他汇报一下,他怎么都不能相信,教育经费真的会被挪用去充当军费,以为这里面一定有什么误会。\n副官终于出来了,对赶忙站起来的张干说:“张校长,大帅有公务在身,现在没空见你,请回吧!”\n“可是,我真的有急事。”张干想的,是一师几百师生的吃饭问题。\n“大帅的事不比你多?”\n张干无话可说了,他只得重新坐了下来:“那,我在这儿等,我等。”\n“张校长爱等,那随你的便喽!”副官不管张干在想什么,说话的口气比铁板还硬。\n呆坐在椅子上,张干看见有文官进了汤芗铭的办公室、有军官敲门进了汤芗铭的办公室、副官引着两个面团团富商模样的人进了办公室……张干挪了挪身子,活动一下酸疼的腰,习惯性地伸手去摸怀表,摸了个空,这才想起怀表已经没有了,不禁无声地叹了口气。恰在这时,门却开了,汤芗铭与那两名富商模样的人谈笑风生走了出来。张干赶紧迎上前去:“汤大帅,大帅!”\n汤芗铭颇为意外地看了他一眼,显然是不认识。\n张干赶紧说:“我是第一师范的校长张干,为学校经费一事,特来求见大帅。”\n汤芗铭挺和蔼地说:“哦,是张校长啊!哎呀,真是不巧,芗铭公务繁多,现在正要出门,要不您下次……”\n“大帅,学校现在万分艰难,实在是拖不下去了。大帅有事,我也不多耽误您,我这里写了一个呈文,有关的情况都已经写进去了,请大帅务必抽时间看一看。”\n“也好。张校长,您放心,贵校的事,芗铭一定尽快处理。不好意思,先失陪了。”汤芗铭接过呈文,客客气气地向张干抱拳告辞,与两名客人下了楼。\n张干这才长长舒了一口气,收拾起椅子上自己的皮包,张干也跟着走下楼来。前方不远,汤芗铭陪着客人正走出大门,谈笑风生间,他看也不看,顺手轻轻巧巧地将那份呈文扔进了门边的垃圾桶里。\n仿佛猝遭雷击,张干呆住了。\n三 # 因为张干的干涉,这个周末的上午,读书会的成员们不得不把活动地点改在距离市区比较偏远的楚怡小学子升小小的房间里。\n没有了往常的笑声,今天的气氛一片沉闷,大家都在谈论一师交学杂费的事情,蔡和森的意思,是希望大家冷静一点,有什么事,等杨老师回来再说。但毛泽东却扬言,不管杨老师回不回来,反正这个学杂费,他是不会交的。他还鼓动大家都莫交。看来,他已经把黎老师走的时候嘱咐他的话全忘记了。斯咏想到钱不多,希望毛泽东不要为了十块钱得罪校长。开慧却认为话不是这么说,即使是校长的话,好的大家可以听,歪门邪道就不能听。子升站起来支持斯咏的观点,大家争辩起来,很不愉快。\n“你们呀,都不用说了,谁爱交谁交,反正我不交,我也没钱,要交也交不起,他张干不是有汤芗铭撑腰吗?让他把我抓去卖钱好了。”任大家怎么说,毛泽东似乎已经铁了心。\n中午活动结束后,斯咏主动请毛泽东送她回家。一路上,两人并肩走着,毛泽东的脸色不好看,斯咏也不好多说什么,只是背在身后的手反复捏着一方手帕包成的小包,仿佛在酝酿着什么事,却又不知如何开口。到了陶府大门前,毛泽东完成任务,要准备回学校了。斯咏叫住他,伸出了背后的手,将手帕包成的小包递向毛泽东。毛泽东不明所以,接过来打开一看,居然是十来块光洋,问道:“你这是干什么?”\n“你不是没钱交学杂费吗?”\n毛泽东抓过斯咏的手,把钱硬塞回了她手里,坚决地说:“我不要!”\n“润之,你这又何必呢?为了十块钱,跟校长对着干,到时候,吃亏的还是你。你把钱交了,不就没事了吗?”\n“可这不是我一个人的事,全校还有几百同学呢!这种头,我不能带!”\n“润之……”\n两人正推推搡搡,陶会长板着脸站到了他们面前……\n目送毛泽东走远,父女俩回到家里。陶会长阴沉着脸盯着缩在沙发里的斯咏: “你跟那个毛泽东到底什么关系?”\n斯咏脸色苍白,情绪十分低落,她换了个坐姿,避开了父亲的目光,没有吭声。\n“我问你呢,那个毛泽东,到底跟你什么关系?”\n斯咏没好气地回答:“没什么关系。”\n“没什么关系?没什么关系你老跟他来往,你还给他钱?这像没什么关系吗?”\n一提到给钱的事,斯咏反而被刺痛了,她腾地站了起来:“我给钱怎么了?人家都不肯要,你高兴了吧?你还要怎么样嘛?”眼泪突然从她的脸上滑落了下来,仿佛是受了莫大的委屈,她竟伤心地抽泣起来。一转身,她哭着跑上楼去。\n陶会长呆了一呆,才回过味来:女儿的火,显然根本不是冲他发的。\n四 # 一路回来,因为刚才斯咏非要借钱给他的事情,毛泽东的心情很不好。他闷着头,匆匆走进校门,正遇到方维夏迎面跑过来,却不是在和他说话,而是越过他,和他身后的人说:“教育司纪司长来了,已经等您好半天,说是一师的学杂费至今还没上交,他专门来催款的。看他的样子,不太高兴。”\n毛泽东这才知道张干在自己身后,也不回头,径直朝教学楼走去。\n一师教学楼前厅的墙上,挂着“距期末考试只剩一天”的警示。纪墨鸿正在前厅里来回走动着,紧紧慢慢的脚步暴露了此时的心情。易永畦边咳嗽边捧着书本拐过弯,一不留神,正撞在纪墨鸿身上,吓得他把公文包失手掉到了地上。纪墨鸿正没处发火,逮住易永畦就是一顿训斥:“怎么回事?走路不长眼啊?”\n易永畦也被吓得不轻,连声说:“对不起,纪先生!对不起,纪先生!”\n“给我捡起来!”\n易永畦赶紧捡起公文包,双手递给纪墨鸿。纪墨鸿拍打着公文包上的灰尘,还不依不饶地训斥着:“这么宽的走廊,还要往人身上撞,搞什么名堂?”\n好几个经过的学生都远远躲开了,易永畦更是吓得不敢作声。毛泽东正从前厅走廊那头过来,远远地看到了事情发生的经过,几步跨过来,不满地对纪墨鸿说:“人家又不是故意的,凶什么凶?”\n纪墨鸿转向毛泽东,涨红着脸,问:“毛泽东,你说什么?”\n“我说大家都是人,用不着那么凶!”\n“还敢顶嘴?你……简直目无师长!”\n“我又没有开口就骂人,哪里目无师长了?”\n易永畦看看情形不对,赶紧一边鞠躬一边急切地说:“对不起,纪先生,都是我的错,对不起了,纪先生,都是我的错。”\n“不关你的事!毛泽东,我命令你,马上向我道歉,听到没有?”纪墨鸿看也不看易永畦,对毛泽东说。\n“对不起了,纪先生!”毛泽东硬邦邦地丢下一句,一拉易永畦,“永畦,走。”\n两个人转过身,却停下了,因为张干正板着脸站在前厅门口,冷冷地说:“你们两个,上操场,立正,罚站!”\n毛泽东拧着脖子问:“凭什么?”\n“新校规第十二条,学生侮慢师长,罚站半天。不记得了吗?”张干瞪着毛泽东说。\n“我们什么地方侮慢师长了……”\n“第十三条,怙过强辩,罚站半天。合起来,罚站一天。”\n可是……要罚罚我一个,易永畦又没开口,不关他的事。“\n“我说一起罚就一起罚!还不马上给我去?”\n夏日的阳光下,毛泽东与易永畦并排站在操场上。树上,蝉鸣声此起彼伏,仿佛它们也正热得难受。毛泽东胸前的衣服被汗浸湿了一大片。汗珠从易永畦苍白的脸上滚落,他轻轻咳嗽着,略显憔悴。\n校长室,张干呆呆地闷坐在办公桌后,任凭纪墨鸿将那份征收学杂费的公函拍在自己面前,敲打着。终于,纪墨鸿不能再忍受张干的沉默,转身出了校长室。张干一个人对着那份公函发着呆,一只手漫无目的地抚摸着那方“诚”字镇纸。已经黄昏了,他起身来到窗前,望着渐渐袭来的夜色里,那两个仍然在罚站的学生的身影,长长叹了口气,心里暗暗打定了一个主意。\n校役提着油灯来到毛泽东与易永畦面前,说:“毛泽东,易永畦,校长让我通知你们,可以回寝室了。”\n“永畦,走吧。”毛泽东吐了口气,活动活动站僵了的脚,走出两步,却不见易永畦跟上来,一回头,正看见易永畦顺着篮球架子,歪歪地滑了下去。\n毛泽东把脸色苍白如纸的易永畦背回寝室,扶到了床上。罗学瓒看子鹏端着杯水,在易永畦的床头怎么也找不到药,说:“别找了,永畦早就没药了。还不是那个破校长,天天逼着人交学杂费,永畦的家境本来就不好,他上哪去弄钱?还不是能省一分就省一分!”\n一句话弄得大家都沉默了,子鹏一跺脚,要马上出去买药,周世钊拉住了他说,半夜三更的,上哪去买?要买也得等明天呀。看看大家都在为自己担心,易永畦强打精神说:“其实,我也没什么事,休息一下,明天就好了。真的,明天还要期末考试,大家不要为我耽误复习了。”\n毛泽东听了这话,重重地叹了口气,给易永畦垫好了枕头。\n五 # 张干打定的主意,就是去找人筹钱。找谁呢?自然是长沙商会陶会长。在去的路上,张干想过陶会长不会很爽快地答应自己,也想过无数条他难为自己的理由。但当他面对陶会长,尴尬地把一师的难处说起来,并提出了自己的请求时,陶会长的条件却让他非常意外。\n“现在一师不单教师的修金,便是学生的口粮都已无钱购置,眼看就要难以为继。陶翁乐善好施,过去也曾多次慷慨解囊,捐资助学,故而张干老着脸皮,求到陶翁门外,还望陶翁体谅。”\n“那——张校长估计大致需要多少钱呢?”\n“这个——三……两千大洋吧。万一不行,暂借一千大洋,也可解一师燃眉之急。”\n陶会长沉吟着,终于开口了,说:“钱嘛,陶某倒还能想些办法——这样吧,我出五千大洋。不过,我有一个条件。我想让张校长答应我,开除一个名叫毛泽东的学生。至于什么原因,张校长就不必问了,总之,只要您把这个毛泽东开除出校,五千大洋,我马上送到贵校,就当是我的捐助,不必还的。”\n张干吃惊之余,腾地站了起来:“陶翁的条件,恕张干无法接受。张干今天冒昧登门,打搅陶翁了。”\n看他转身就要走,陶会长提醒道:“张校长,您这是干什么?毛泽东不就一个学生吗,您现在要救的是全校几百学生,孰轻孰重,您得考虑清楚啊。”\n“不必考虑了,再怎么样,我也不会拿一个学生的前途去换金钱的。”\n“张校长,”陶会长硬把张干拦住了,叹了口气说,“张校长,且听我把话说完好吗?本来吧,家丑不可外扬,但今天不把话讲清楚,张校长也不会明白这里头的原委,我也就只好直说了。事情是这样,贵校有个毛泽东,他组织些男男女女在校内外搞些什么活动,搞乱了学校秩序和风气,也有伤风化。我有个独生女儿,已经定了亲,她却受毛泽东的影响,追随他。哎!”\n张干目瞪口呆:“有这种事?”\n“说起来吧,也怪我这个父亲管教不严,未能及时发现。可我女儿好歹是定了亲的人,如再给毛泽东他们活动的机会,这要任其下去,万一闹出什么事来?不光我陶家,于贵校的脸上也不好看嘛。只要开除了毛泽东,这事也就过去了不是?”\n张干想了想,答应道:“事情若果真如陶翁所言,这样的行为,敝校也是绝不会允许的。”\n“千真万确!张校长,我也是没办法,才请您帮这个忙。这样吧,只要张校长点这个头,我捐一万大洋,明天就送到。怎么样?”\n张干坚决地说:“不,这是两回事。毛泽东如果并无此事,不管多少钱,我都不会开除他,否则,陶翁就算一分钱不出,我也一样会严肃处理。”\n出了陶宅,张干一路想着陶会长的话,坐车回了学校。他迈着沉重的步子,心事重重地上了教学楼,经过教务室时,听到虚掩的门里正传来一阵说笑声:\n“哈哈,有意思,有意思……”\n“……还真是又有大海又有太阳啊!”王立庵拿着毛泽东那张图画考卷,哈哈大笑。\n“你别说,两笔一幅画,还套上了李白的名句,这种绝招,也只有润之想得出来。”\n“反正我呀,拿他毛泽东,是哭不出也笑不出。”\n张干听到是在说毛泽东,推门进去问:“在说什么呢?这么热闹。”\n费尔廉说:“我们在看一个学生画的画,画得太有意思了,很有我们德国现代抽象派的风格。”\n“哦?我看看。”张干拿过毛泽东那幅画,愣住了,“这……这什么玩意?”\n陈章甫笑道:“半壁见海日啊,您看,一笔是海面,一笔是太阳,又简单又明了……”\n“什么简单明了?这也叫画?黄老师,这怎么回事?”张干严厉的口气使刚才轻松的气氛一扫而光,老师们不禁面面相觑,赶紧汇报说,不仅仅是图画课,还有那么几门课,毛泽东不是很感兴趣,成绩不是很理想……\n张干打断他们的话:“那你们就由着他想学就学,想考就考?就由着他拿这种鬼画符把考试当儿戏?”\n黄澍涛说:“这是孔校长以前特许的,说毛泽东是个特殊人才,他不感兴趣的课,不必硬逼着他拿高分,就当是一种因材施教的教育试验。”\n“简直乱弹琴!”张干把那张“半壁见海日”一拍,越想越气,“一个学生,不好好学习,视功课如儿戏,还能得到特许?这、这不是纵容学生乱来吗?”\n大家谁都不敢接腔,一时间,教务室里气氛紧张。就在这时,却传来了轻轻的敲门声,斯咏从虚掩的门后探出身来:“请问一师收学杂费,是在这儿交吗? 我来给毛泽东代交学杂费。”\n陈章甫惊讶地问:“给毛泽东代交?你是?”\n不等斯咏答话,一旁,张干扫了一眼斯咏,冷冷地说:“小姐是姓陶吗?毛泽东的学杂费,不必旁人代交。你走吧。”\n“可是……”斯咏的话还没说完,张干就毫不客气地一把将门贴着她的鼻子关上了。\n转过身,张干脸色阴沉得吓人:“陈老师,通知毛泽东,马上到校长室报到!”\n“毛泽东同学,叫你来之前,说实话,我对你身上暴露的问题是很有看法,甚至是有很大意见的。不过冷静下来一想,其实你身上这些缺点、毛病,也不能全怪你,应该说学校过去的教育方法也出现了偏差。既然是你有缺点,学校也有偏差,那就让我们共同来努力,改正这些存在的问题,你说好不好?”看着对面的毛泽东,张干坐在校长室自己的椅子上,字斟句酌地说。\n“我又存在什么问题了?”\n“你的问题,你自己还看不到吗?”张干不禁有些不快,但还是尽量平和地拿起那份考卷,“你说说,这叫怎么回事?一横一圈,这就叫半壁见海日?一个学生,怎么能这样对待学习,怎么能这样对待校规校纪呢?昨天才罚过你,今天你又是这样!屡教不改啊你!学校不是你家,不是菜市场,由不得你想怎样就怎样!你知不知道?”\n仿佛是发觉自己过于激动了,违背了初衷,他尽量平静了一下,接着说:“当然了,孔昭绶校长在这个问题上也有很大的责任,身为一校之长,不但不维护校规校纪,居然还对你放任自流,如此教育方式,怎么会不误人子弟?”\n毛泽东腾地站了起来:“张校长,你讲我就讲我,讲孔校长干什么?”\n“我是在帮你分析原因!”\n“那我也可以告诉你,孔校长是我见过的最好的、最称职的校长!比不上人家,就莫在背后讲人家坏话!”\n张干也腾地站了起来:“毛泽东!”\n“我在这儿!”\n张干指着毛泽东,气得连手指都在发抖:“好,好,好啊!我还说对你教育方法有问题,错!我看你是天性顽劣,不可救药!每次犯纪律的都是你,动不动就顶撞老师,难怪有人说上次是你在背后怂恿同学故意考差,别人家长在背后说你的空话……”\n毛泽东的眼睛猛地瞪圆了:“张校长,你把话讲清楚,我干了什么?”\n“你干了什么你自己知道。”\n“你……你瞎讲!”\n“怎么,心虚了?商会陶会长家的女儿,你跟她什么关系?人家家里早就看你不惯了,你居然还好意思去纠缠人家。”\n毛泽东的脸一下子涨得通红,他“砰”地一拍桌子:“你……你胡说八道!”\n张干简直都不敢相信自己的眼睛,一个学生,居然敢对校长拍桌子!一时间,两个人互相瞪着,房间里,只听见毛泽东呼呼喘粗气的声音!缓缓地,张干强压着全身的颤抖,扶着桌子坐下了。一指门口,他声音不大,却是一字一句:“出去。”\n毛泽东还愣着。\n猛地,张干几乎是声嘶力竭:“出去!”\n毛泽东转身冲出了校长室。“砰”的一声,房门被他重重摔上,声音之大,连桌上那方镇纸都被震得几乎跳了起来!\n几乎是大步跑回了寝室,乒乓一阵,毛泽东扫开桌上的东西,摊开纸笔砚台就写下了四个字: 退学申请。\n“润之!”蔡和森一把抓住了他的笔,“什么事都有个商量,犯得着那么冲动,挨了一回训就要退学吗?就算张校长讲错了,你也可以解释嘛。”\n易永畦咳嗽着,也挤上来说:“润之兄,我们都知道你不是那样的人,张校长是不了解你,你就别太计较了。”\n“润之,这件事都怪我。”斯咏走上前,“本来我只是想帮你,才来给你交钱的,没想到会给你惹出这些误会。要不我去跟你们校长解释清楚,好不好?”\n“我不要你们管!”毛泽东猛地一甩,把笔抢了过来,但纸上已被画了大大的一道,飞溅的墨水倒把蔡和森手上、身上都弄脏了, “丑话没讲到你们头上,你们当然讲得轻松!人家现在是在怀疑我的人格,是在讲我……反正我受不了这种侮辱!”\n斯咏说:“我说了我去解释……”\n“你算了!你不跑过来还好得多!”\n一句话令斯咏呆在了那儿!一刹那,眼泪猛地涌出了她的眼眶,她转身冲出了寝室。\n“斯咏,斯咏,”蔡和森追了两步,回过头,说,“毛泽东!你太不像话了!你要搞得人人都看你不顺眼吗?”\n“我就这样!看不顺眼莫看!”\n“好,好,你爱怎么办怎么办吧。谁都别管他,走!”蔡和森冲出了寝室,几个同学跟在他身后,也出了寝室。\n毛泽东越想越窝火,他一把将那张画坏了的纸揉成一团,扔到地上,又抓起一张空白纸,重重地拍在桌上。\n六 # 冲出校门,斯咏抽泣着一路跑去。蔡和森等追到学校门口时,斯咏已哭着跑远了。\n停住脚步,蔡和森重重地叹了口气,却看到杨昌济提着行李从停在校门口的人力车上走下来,忙把刚才发生的事情一五一十地全给老师讲了。杨昌济意识到了事态的严重,找到在校的徐特立、方维夏等老师,先看了毛泽东的《退学申请》,告诉他在老师们没有结束和校长的谈话之前,不要轻举妄动。然后,几位老师一起去了校长办公室。\n油灯下,张干的办公桌上堆满了试卷、教学资料等等,几乎要把他埋在其中。他正在一笔一画,十分专注地写着一篇文章,标题是《第一师范教学改良计划》。门被轻轻敲响,张干有些疑惑地抬起头,先看了一眼墙上的钟,这才说了声请进。杨昌济等三人推门走了进来。“杨先生?”张干不由得站了起来,“您回来了?”\n油灯映照下,张干埋着头,房间里气氛沉闷。\n徐特立和方维夏都将目光投向了杨昌济。杨昌济斟酌着:“张校长,你我都是搞教育的人,尽管对教育的理解,每一个人不尽相同,但我们都相信,您和过去的孔校长,和全校的每一位老师一样,都是想把一师办好。我也听说,自您到校以来,从来没有在晚上12点以前离开过学校,可以说,为了一师,您是在兢兢业业工作。可您有些做法,学校的老师、学生也确有看法,究竟是为什么要这样做,这一段,学校又到底碰上了什么让您为难的问题,您就不能跟大家解释一下吗?”\n张干抬头看了看杨昌济,似乎想说什么,却又一言不发地把头低下了。\n方维夏说:“我们知道您重视教学,希望把学生的成绩抓上来,可像现在这样,没日没夜,除了补课就是考试,学生的一切社会活动全部禁止,这是不是也过头了一点?学生也是人,他们不是读书的机器啊。还有,学校的经费为什么会这么紧张?到底出了什么事?我们都在着急啊。”\n徐特立也很着急:“张校长,大家都是同事,为什么您就不能把心里想的,跟我们谈出来呢?”\n张干依然一言不发。\n三个人互相看看,都有些不知该怎么谈下去了。沉默中,他们突然听到从学生寝室那边传来了一声撕心裂肺的惊呼……发生了什么事情?老师们迅速出了校长室,朝学生寝室方向跑去。\n当他们来到八班寝室时,只看到易永畦的被子、蚊帐上到处溅满了喷射状的鲜血。得知毛泽东已经把易永畦送往学校医务室了,他们又急忙撵了上去。但一切都迟了,医务室外长长的走廊上,鸦雀无声,挤满了第一师范的学生,所有的人都沉默着,所有的人眼中都含着泪水。一种不祥的感觉顿时攫住了张干的心,仿佛是为了印证他的不祥预感,盖着白布的易永畦的遗体被缓缓推了过来。仿佛猝遭雷击,张干一把扶住了墙,紧跟而来的杨昌济等人也都惊呆了……\n礼堂里,黑纱环绕,易永畦遗像挂在台上正中,上面悬着“易永畦同学千古”的横幅。台下,数百同学穿着整齐的校服,静静地肃立,萧三、子鹏等人正在裁剪纸张、黑布,制作白花、黑纱。在一片哀痛与泪光中,只有白花、黑纱在无声地传递着。蔡和森将白花、黑纱递到了毛泽东面前。默默地戴上白花、黑纱,毛泽东走到了易永畦的灵前。\n桌上,是折得整整齐齐的校服,抬头,是易永畦微笑着的相片,毛泽东将永畦沾满鲜血的课本轻轻放在校服上。身后的子鹏再也忍不住了,一把捂住了泪流满面的脸:“都怪我,我怎么……怎么就忘了给他买药回来……都怪我呀……”几个同寝室的同学搂住了子鹏,安慰着他。\n一支毛笔递到了毛泽东面前,蔡和森说:“润之,永畦平时最喜欢跟你在一起,他最佩服的,也是你,为他写点什么吧。”\n雪白的纸在毛泽东面前铺了开来。握着笔,抬头凝视着易永畦微笑的脸,眼泪轻轻从毛泽东眼眶中滑了下来,眼泪和着墨迹,落在纸上写下了挽诗的题目:《挽易永畦君》:“去去思君深,思君君不来。愁杀芳年友,悲叹有余哀。衡阳雁声彻,湘滨春溜回。感物念所欢,踯躅城南隈。城南草萋萋,涔泪浸双题……”\n毛泽东写着,一幕幕往事如此鲜活地重现在他的眼前:进校第一天,易永畦帮着不会钉校服领章的子鹏钉着领章;球场旁,不擅运动的易永畦在帮着打球的毛泽东等人看守衣服;杨老师的课上,易永畦讲述着自己想当将军的理想;灯光下,易永畦将补好的鞋悄悄放在毛泽东的脚边;操场上,易永畦与毛泽东一起罚站;礼堂里,面对成排的刺刀,易永畦狠狠地打向刘俊卿的脸,士兵的枪托狠击在他的胸口……\n“……我怀郁如焚,放歌倚列嶂。列嶂青且倩,愿言试长剑。东海有岛夷,北山尽仇怨。荡涤谁氏子,安得辞浮贱。子期竟早亡,牙琴从此绝。琴绝最伤情,朱华春不荣……”\n笔走龙蛇,字迹由行而草,饱含悲愤。肃立的同学们同样含着悲愤的泪水。毛泽东边写边擦着眼泪,但眼泪越涌越多,已将他的双颊完全湿透……\n蔡和森将毛泽东写好的《挽易永畦君》诗被放在了灵前。\n张昆弟情绪激动地高声呼喊道:“各位同窗,我们为什么会失去一位好同学?一师为什么会出这样的悲剧?大家心里都清楚,就因为那个张干!”\n罗学瓒也呼应着:“没错!就是他,一天到晚考考考,逼着永畦带病熬夜,永畦的身体本来就有伤,他是给活生生熬垮的呀!”\n萧三更是火上加油:“还有,为了什么学杂费,逼得永畦连药都舍不得买,前天,他还罚永畦在大太阳底下站了一整天……”\n“就是他……”悲痛中,学生的情绪都上来了,现场一片群情激愤。\n第十九章 驱张事件 # 第一师范不是一台机器,学生也不是木偶,他们有主见,他们敢想敢做,他们不需要我这样一个逃避现实的校长。一个跟不上学生要求的校长,只能是一个失败的校长,他所推行的教育,也只能是失败的教育。而我,就是这个失败者。\n一 # 墙上的挂钟单调而沉闷地晃动着钟摆。张干呆呆地坐在办公桌后,整个人如同一尊雕塑。\n“张校长,张校长。”\n校长室外,方维夏敲着房门。校长室内,张干充耳不闻,呆若木鸡。\n方维夏又敲了几下,却仍然听不见反应,他看看身边的徐特立,两个人都叹了口气。\n张干的姿势一动没动,只有挂钟还在单调地走,一下一下,沉闷得让人心烦。\n方维夏又敲了几下,无奈地停手。杨昌济看了看紧闭的校长室的门,说:“维夏,你是学监主任,应该有备用钥匙吧?”\n方维夏:“有是有,可是,这是校长室……”\n杨昌济不容置疑地:“打开!”\n门开了,杨昌济出现在门口。\n“张校长。”\n张干的背影一动不动。\n杨昌济一步来到他的面前,声音发生了变化:“张干先生!”\n仿佛是被突然震醒,张干的身子微微一动。\n“现在什么时候了?全校学生都集中在礼堂,他们有情绪!现在不是你闭门思过的时候,你的沉默,只会让事情越来越糟,你知不知道?”激动中,杨昌济走动两步,又一步折了回来:“从你进校开始,老师、学生,每一个人都不明白,每一个人都在等待你这个校长的解释,可你,就没有向大家说明过哪怕一次!第一师范不是一台机器,这里的师生也不是木偶,他们需要理解校长的教育理念,他们不能糊里糊涂地任人支配,你明不明白?你说话呀!”\n缓缓地,张干转过身来——杨昌济不禁一愣:张干的脸上,居然流着两行泪水!\n“校长,”方维夏走上前来:“全校学生正在为易永畦准备追悼会,您作为校长,应该去参加,到那儿,也算是给学生们一个交代,一个安慰,让他们也明白,您是关心学生的,您说是不是?”\n杨昌济:“我们都在等你,张校长!”\n缓缓地,张干终于点了点头。\n就在一行老师赶去礼堂的途中,学生们激动的情绪已经达到了顶点。 一片激愤中,萧三、罗学瓒、李维汉等一帮人围着毛泽东,问他接下来怎么办?毛泽东说:“我只知道,永畦不能就这么白死了,不管大家怎么办,我都支持!”\n“有你这句话就行!”张昆弟转身就往台上冲,“大家听我说,同学们,我们第一师范原来怎么样,现在怎么样,大家都是看在眼里的。”张昆弟一把举起那本沾血的课本,“大家说,是谁让这本书上喷满了易永畦同学的血?是谁造成了眼前这一切?”\n台下的学生异口同声地回答:“是张干!”\n台上,张昆弟情绪激动地继续问:“像张干这样的校长,我们要不要?”\n台下雷鸣般地回应:“不要!”\n“那么大家说,怎么办?”\n“把他赶出去……赶走张干……”\n“好!不想要张干这种校长的,跟我来!”张昆弟一步跳下讲台,学生们纷纷涌上,跟着他就往外涌去。\n人流在礼堂门口戛然站住了,因为站在礼堂门口的,是脸色铁青的张干。\n一个校长,数百学生,静静地对峙着。一刹那,数百人的礼堂里居然鸦雀无声。猛地,张昆弟振臂一呼:“张干滚出一师!”数百个声音仿如雷鸣:“张干滚出一师!”\n嘴唇剧烈地颤抖着,张干猛地转身就走。校长室内,张干怒不可遏地写着《开除通告》,名列被开除学生榜首的,赫然是毛泽东的名字!\n而在灵堂内,毛泽东也正奋笔疾书,白纸上的《驱张干书》尤为醒目。教室里,张昆弟等众多同学或写标语,或抄着《驱张干书》。不多久,一师的教室门口、走廊上到处都贴着“张干滚出一师”之类的标语和《驱张干书》。学生们在做了这些之后,还集中到了操场,开始罢课了!无论老师们怎么劝说,罢课的学生都无动于衷,杨昌济看了看眼前的学生,发现毛泽东和蔡和森等人不在其中,便对其他老师说:“你们先把学生看好,这件事,交给我来处理吧。”\n空荡荡的礼堂里,只有毛泽东与蔡和森静静地坐在易永畦的遗像前,吱呀一声,身后传来了大门推开的声音。蔡和森微微一愣,沉浸在悲痛中的毛泽东也被惊醒,回头看见杨昌济,不由得站了起来。轻轻掩上门,一步一步,杨昌济走到了遗像前。拿起桌上的一朵白花,他认真地戴好,然后郑重地向遗像深深鞠了一躬。\n“润之,和森,你们现在的心情,我都明白。永畦是你们的好同学,也是我的好学生,他走了,我这个老师,跟你们一样悲痛,也跟你们一样,无法接受这个现实。”杨昌济抚摸着那本带着鲜血的课本,眼泪渗出了眼眶,“我们一师,不该发生这样的悲剧啊!可是,不该发生的悲剧,已经发生了。我们是该从悲剧中吸取教训,还是让悲痛和情绪左右我们的理智,让悲剧愈演愈烈呢?我知道,你们对张校长的一些做法不满,永畦的不幸,更影响了大家的情绪。可无论张校长在治理学校方面有多少值得商榷的地方,作为学生,也不能采取如此极端的手段,不该用整个学校的正常秩序作为代价,来与校长争个高低啊!润之、和森,外面现在在发生什么,我想你们都知道,一所学校,连课都不上了,这是在干什么?这是在毁掉一所学校最基本的秩序!外面的同学都听你们两个的,我希望你们出去,现在就出去,制止大家,让一师恢复正常的秩序。”\n毛泽东与蔡和森互相看了一眼,两个人显然还难以接受这个要求。\n毛泽东问:“可是,永畦就这么白死了吗?”\n杨昌济说:“可永畦的死,真的就应该归结到张校长身上吗?永畦身上的伤哪来的?那是被汤芗铭的兵打的!永畦的身体,本来一直就不好,加上这么重的伤,这,能怪张校长吗?当然了,张校长来校时间短,没能及时了解永畦的身体情况,他有疏忽,可并不等于是他造成了永畦的悲剧啊!永畦走了,大家都很悲痛,可要是永畦还在,他会愿意看到大家为了他,连课都不上,连书都不读,会愿意看到一师变成现在这个样子吗?如果任由同学们这样下去,永畦在九泉之下,也会去得无法安心啊!”\n一番话,说得毛泽东与蔡和森都不禁低下了头。\n“老师,对不起,是我们不对,我们现在就出去,跟大家说……”蔡和森话未说完,“砰”的一声,虚掩的大门猛地开了,方维夏急匆匆地闯进门来:“杨先生!出事了,您赶紧来看看吧!”\n二 # 杨昌济来到一师公示栏前, 看到一纸《开除通告》赫然张贴在公示栏上,上面以毛泽东为首,赫然开列着17个因带头驱张而被开除的学生名字,下面是张干的落款和鲜红的校长大印!\n“开除?”\n惊讶中,杨昌济转过头来,老师们都面面相觑,然后一起急忙往校长办公室走去。毛泽东则快步冲回寝室,拿出《退学申请》,叫道:“我毛泽东用不着他张干来赶,此处不留人,天地大得很!”说着,推开想要拉住他的蔡和森,冲了出去。\n校长室里,满屋子的老师都望着张干,张干避开了大家的目光。\n徐特立说:“张校长,学生们的做法,也许是过于冲动了一些,可再怎么说,也是事出有因。一师已经出了易永畦这样的悲剧,难道还要一下子开除17个学生,让这悲剧继续下去,甚至是愈演愈烈吗?”\n张干低着头,一言不发。\n方维夏说:“就算学生们违反了校规,可校规校纪是死的,人是活的呀。孔校长过去就常说,学校是干什么的,就是教育人的,学生有问题,我们应该教育他们,而不是往门外一赶了之啊。”\n“这么说,列位是不是都不同意?”张干问。\n“我不同意……我不同意。”王立庵、陈章甫、饶伯斯等好几名老师纷纷摇头。\n费尔廉甚至说:“这件事,我觉得责任不全在学生身上。张校长,你的做法,比学生更冲动。”\n长长叹了口气,张干闭上了眼睛:“好吧,也许我是太冲动了,我可以收回这份开除通告。但是,其他16个人我可以放过,毛泽东,必须开除!”\n校长室外的走廊上,毛泽东拿着那份《退学申请》,三步两步跨上楼梯,匆匆走向校长室。校长室里,正传出张干激动的声音:“怎么,难道我身为校长,连开除毛泽东这么一个学生的权力都没有了吗?”\n杨昌济的声音:“这不是权力大小的问题!”\n毛泽东不由得站住了,听到杨昌济继续说:“年轻人,一时冲动总归难免,犯了错误,批评教育甚至处分我都不反对,可要是动辄拿出开除这样简单粗暴的手段,那我们这些先生是干什么的呢?”\n方维夏的声音:“张校长,这么多老师,没有一个赞成开除毛泽东,难道您就不考虑一下大家的意见吗?”\n张干的声音:“我就不信,像毛泽东这样无法无天的学生,各位先生都会站在他这一边?在座的各位,难道就没有一个认为毛泽东的行为已经足够开除处理了吗?”\n费尔廉说:“毛泽东的行为,也许是够开除,但开除他是不对的。”\n“又够开除又不开除,这叫什么道理?”\n“道理我讲不过张校长,我只知道就是不能开除。”\n听着里面老师们的争执,望着手中的《退学申请》,毛泽东一时真不知心里是什么滋味。一个身影突然出现在他的身后,毛泽东一回头,却是板着脸、端着水烟壶走来的袁吉六。\n“袁老师。”\n看也没看他,袁吉六口气淡淡地:“外面那篇赶校长的檄文,是你写的?”\n毛泽东点了点头。\n“混账东西!”袁吉六横眉立目,劈头一声暴喝,吓得毛泽东一抖!他的咆哮声从走廊上传了开去,“一看就知道是你!身为学生,驱赶校长,你好大的胆子!”\n老师们都愣住了,校长室的门开了,张干、杨昌济等人都探出头来。\n走廊上,袁吉六气势汹汹,劈头盖脸,训斥着毛泽东:“天地君亲师,人之五伦,师道尊严都敢丢到脑后,你眼里还有没有人伦纲常?教会你那几笔臭文章,就是用来干这个的吗?”\n毛泽东被训得一句话也不敢说。\n杨昌济叫了一声:“袁先生!”\n袁吉六又瞪了毛泽东一眼,狠狠扔下一句:“反了你了!”这才大咧咧地向校长室内走去。\n门“砰”的一声关上了,毛泽东一个人站在了走廊上。\n袁吉六走到张干的桌前,坐下了。老师们互相看着,袁吉六方才的态度,显然有些影响了方才一边倒的气氛。一片宁静中,张干仿佛打定了什么主意:“袁先生,您来得正好,有件事,我正想听听您的意见。”\n袁吉六问:“开除学生的事吗?”\n“是这样,这次开除学生,张干确有考虑不周之处,经各位先生提醒,现已决定,收回对其中16人的开除决定。可是为首的毛泽东,目无师长,扰乱校纪到了如此程度,再加姑息,学校还成什么学校?袁先生,您是一师任教的先生中年纪最大的前辈,既然列位先生不赞同我的想法,我也无法接受列位先生的纵容,开除毛泽东的事如何决断,就由您来定吧。”\n所有的目光顿时都集中到了袁吉六的身上。\n“张校长真的要老夫决定?”\n“但凭先生一言定夺。”\n众目睽睽中,袁吉六慢条斯理地抽了两口烟,吐出烟雾,将水烟壶放下,这才:“定夺不敢,袁某的意见就一句话;张校长若是开除毛泽东,袁某,现在就辞职。”\n说完,他起身就走。\n张干不禁呆住了。\n校长室外的毛泽东同样意外得几乎不敢相信自己的耳朵。\n猛然看见袁吉六走出,他几乎是下意识地:“袁老师……”\n袁吉六仍然没有看他一眼,仍然是那样硬冷,“别挡路!”大咧咧地踱着方步,消失在走廊尽头。\n毛泽东用手一摸,才发现泪水已滑出了自己的眼眶。那份《退学申请》被缓缓地,撕成了两半……\n三 # 一张盖着省教育司大印的对张干的免职令张贴到了一师的公告栏里。学生们欢呼一片,仿佛迎来了一场大胜利。\n隐隐的欢呼声中,校长室里,校长的大印、一本校长工作日志和第一师范校志被小心地推到了杨昌济、方维夏与徐特立面前。\n“张校长……”\n“我已经不是校长了。”张干轻轻一抬手,默默地收拾着桌上其他的东西。\n杨昌济按住了他的手,问:“次仑兄,就算是临走前一个交代吧,你就不能跟我们说说,这一切到底是为什么吗?”\n带着一丝苦涩,张干微笑了一下,笑容却转为无声的叹息 :“其实我不是不知道,学生们不喜欢我,因为我专横,我压制。我不准这样不准那样,我把学生关起来,让他们两耳不闻窗外事,恨不得他们一个个变成读书的机器。可这是我愿意的吗?这是这个世道逼的啊!”\n张干一把推开了窗户:“杨先生、徐先生、方先生,你们睁眼看看,眼前是个什么世道?民权写在法律里,法律高悬于庙堂上,可那庙堂之上的一纸空文,有谁当过一回事?拿枪的说话才是硬道理,掌权的是像汤芗铭那样杀人不眨眼的屠夫啊!就拿孔校长来说吧,学生们怀念他,怀念他开明,有胆气,关心国事,视天下兴亡为我一师师生之己任。可是结果怎么样?他不单自己被通缉,还险些给一师惹来灭顶之灾!还有徐先生,您为什么辞了省议会副议长的职务,您不就是不想同流合污吗?可您一个人可以辞职,我要面对的,却是好几百学生的第一师范啊。区区一个一师,在汤屠夫眼里,还比不上一只随手能捏死的蚂蚱,我还能怎么样?当此乱世,我只能压着学生老老实实,压着他们别惹事,我是一校之长,我要顾全大局,我不能让他们再往枪口上撞啊!”\n那份收学杂费的公文被摆在了桌上。\n“方先生,你一再问我,学校的经费究竟哪去了。现在你该明白了,是汤芗铭断了一师的经费,逼着学校收学生的钱。可我能告诉大家真相吗?我不能!因为那等于挑起学生们对政府不满,万一学生们冲动惹出事来,吃亏的是他们啊!所以我只能让大家骂我,把所有的气,都出在我身上,骂完我,出完气,他们就不会出去闹事了!退一万步来说,学生以学为本,严格校纪,发愤读书,这也是我这个校长的本职工作,让大家认真读书,这总没有错吧?可现在我才明白,我还是错了,杨先生说得对,第一师范不是一台机器,学生也不是木偶,他们有主见,他们敢想敢做,他们不需要我这样一个逃避现实的校长。一个跟不上学生要求的校长,只能是一个失败的校长,他所推行的教育,也只能是失败的教育。而我,就是这个失败者。”\n喃喃的,张干仿佛是在向三位同事解释,更像是在自我反思。平静地、小心地、如往常一样仔细地,张干一样一样收拾好了自己的备课资料、笔墨、雨伞……张干默默地将桌上那方“诚”字镇纸放进了包里。那方孔昭绶的“知耻”镇纸,被重新放回了原位。收拾得整整齐齐的办公室里,一切都恢复成了张干到来前的模样,只有办公桌上,端正地摆着那份已经起草好却还未来得及实施的《第一师范教学改良计划》。\n张干穿过走廊,走下楼梯,穿过教学楼前坪,经过他所熟悉的一处又一处。他的脚步停在了校门口的公示栏前,那上面,还贴着对他的免职令。回头最后望了一眼一师的校牌,张干的眼中,也流露出了依依不舍的伤感。人力车启动,车轮转动着,一块块青石街面被抛在了后面。\n这个时候,寝室走廊,欢庆胜利的学生蹦跳着走来,驱张的骨干们兴高采烈地簇拥着毛泽东,欢声笑语,洒满一路。学生们的声音突然停住了——面前,杨昌济、方维夏、徐特立正静静地站在他们面前。\n毛泽东:“老师……”\n望着这些让自己又深爱又头痛的学生们,几位先生相互交换了一个眼神,一时似乎都不知是什么心情。\n“校长没能开除学生,倒是学生赶走了校长,这确实是一件奇闻,也确乎值得大家庆祝一番。可当大家欢庆胜利的时候,你们有没有认真地想过,你们赶走的,究竟是怎样一个人?你们对他,又了解多少呢?”众多同学围成了一圈,静静地听杨昌济讲述着:“张校长的教育理念、治校方式,也许我们大家并不非常赞同,但当大家抱怨功课压力太重的时候,有谁注意到了张校长办公室里每天亮到深夜的灯光?当同学们为催交学杂费而意见纷纷的时候,有谁想过,张校长在教育司、在将军府据理力争却毫无结果时的痛苦?当一项又一项新校规压得大家喘不过气来的时候,有谁明白张校长千方百计保护学生的一片苦心?当同学们抱怨食堂伙食太差、吃不饱肚子的时候,又有谁知道,为了让大家还能吃个半饱,张校长甚至卖掉了自己的怀表……”\n围上来的同学越来越多,走廊、走廊旁的草地,渐渐都站满了。\n杨昌济讲得平心静气,毛泽东等人却越听越不安,老师讲述的话,显然是大家过去完全没有想到过的……\n“古语云:将心比心。然而真要做到这一点,真要从别人的角度去考虑问题,却不是一件容易的事。通过这一次,我只希望大家今后遇上别的事情的时候,不要光凭个人的好恶,不要以一时的冲动,不要单从自己的眼光、自己的角度来看待一件事、一个人,因为那样做出的判断,常常是有失公允,常常是会伤害别人,最终也令自己后悔莫及的。这,不仅是我们这些老师的希望,我想,当张校长走出一师的校门时,这,也一定是他心中对大家保留的最后一份期望……”\n脚步纷纷,学生们涌出教学楼。校门口,追出的学生们张望着:人海茫茫的街道上,早已消失了人力车的影子。毛泽东、蔡和森、张昆弟、罗学瓒、萧三等一个个同学的脸上,是歉疚、失望,是追悔、惆怅。\n天高云淡,第一师范的校旗随风轻扬,仿佛也在惋惜这场不应发生的离别。\n离开第一师范后,张干长期固守清贫,任教于长沙各中学。中华人民共和国成立后,毛泽东专门将老病失业的张干接到北京,为当年的驱张行动向这位老校长正式道歉。此后,他长期负担张干的生活与医疗费用,直至1967年张干病逝。这位学生用自己的行动,与当年被他赶走的校长修复了这段曾被破坏的师生关系。\n第二十章 君子有所不为亦必有所为 # “ 历史之车轮滚滚向前,\n欲以人力变其轨而倒行,\n只怕是无人指望得上。 ”\n一 # 我去帮他交钱,还不是不想他跟学校起冲突吗?他怎么这样对待我啊?!从一师回来后,斯咏越想越想不通,抱着枕头哭了一晚上,任警予怎么劝都不听。她以为自己这辈子可能都不想再见那头犟牛了,可几天后,当她和警予走出周南中学的校门,正看到毛泽东迎面走过来的时候,她的心还是和以前一样狂跳着,甚至跳得更厉害了。警予看了看他们俩,借故要去和开慧打排球,转身回了学校。走出几步,她心里暗想:还好,蔡和森不像他那么倔。前几天她将一方手帕包着的十来块光洋递到了蔡和森面前的时候,蔡和森可没有像毛泽东那样不领情,他只是开玩笑说不一定还得起。警予乘机唬着脸要挟他,不还也行,毕业后给她做十年长工,就算两清了。蔡和森算着账,问:“那,这十年长工都包括干哪些活?做牛啊,做马啊,还是做点别的什么?得有个具体内容吧?”当时是怎么回答的?“到时候叫你干什么你就干什么,哪那么多废话?”警予想着,脸一下子绯红。回头看看并排渐渐走远的毛泽东和陶斯咏,警予又想:不过,毛泽东要是不倔,还是毛泽东吗?\n毛泽东当然很倔,不过当他意识到自己确实误会了别人的时候,态度转变起来,还是蛮快的。所以,站在江风轻拂、竹影摇曳的湘江边,毛泽东坦诚地为那天的事情向斯咏道了歉。\n“事情过都过去了,你还专程来道什么歉?”斯咏低头走着,嘴里虽然这样说,脸上却荡漾着开心的笑意。\n“话不是这么说,本来是我不对嘛。我这个人,一急起来,就不分好歹,狗咬吕洞宾。你不计较就好。”他把手往斯咏面前一伸,说,“我们还是朋友。”\n“只要……只要你把我当朋友,我是永远不会变的。”斯咏握着毛泽东的手,有些忘情了,遥望着大江、岳麓山,轻轻地说:“但愿山无棱,天地绝……”\n“哎,你怎么学的古诗?那是讲两口子,讲朋友叫高山流水,知音长存。”毛泽东手一挥,指着眼前的山河,慷慨地说:“就好像这大江、岳麓山,历千古之风雨,永恒不变,那才叫真朋友。是不是?”\n黄昏的夕阳下,江水粼粼,金光万点。斯咏犹豫着,想说什么,可突然感到有水点落在头上。\n“哟,下雨了,走走走。”毛泽东拉起斯咏就走。\n雨越下越大,黄昏的街道显得比往常这个时候黯淡得多。顶着外衣遮雨,毛泽东与斯咏一头冲进了街边的小吃棚里。\n小吃摊的锅里,正煮着元宵。毛泽东闻到了香味:“嗯,好香啊!哎,斯咏,你饿不饿?今天我请客,来。”他拉开凳子让斯咏坐,高声喊道:“老板,元宵两大碗。”\n“嘘!”摊主被这话吓得脸都变色了,手指竖在嘴边,说,“小点声,小点声!”\n毛泽东和斯咏都愣住了:“怎么了?你那锅里不是元宵吗……”\n摊主一把捂住了毛泽东的嘴:“讲不得,讲不得啊!”他掀过摊前的牌子,指着上面的“汤圆”二字,压着声音,“姓袁的都被消灭了,还怎么当皇上啊?有圣旨,从今往后,这元宵,都得叫汤圆,叫错了就是大逆。嚓!”说着,手一挥,做了个杀头的动作。\n“砰”的一声,毛泽东把筷子重重拍在桌上。可很快,本来一脸怒容的他不知怎么,却突然笑了:“哈,哈哈,哈哈……”\n他越笑越开心,几乎是乐不可支,倒把斯咏笑糊涂了:“你还觉得好笑啊?”\n“为什么不好笑?千古奇闻嘛。心虚到如此地步,还梦想翻天,哈哈……”\n棚外,天色昏黑,雨,愈发大了。只有毛泽东的大笑声绵绵不绝,仿佛要冲破这无边的阴雨夜幕。\n斯咏和毛泽东吃了元宵回来,心情才好了些,欢欢喜喜地进了大门,却发现家里的气氛和往常很不一样。仆人们走路都是轻手轻脚的,连大气都不敢出。家里出了什么事情?斯咏心里一紧,在门厅里拉住管家就问,管家战战兢兢地小声说,老爷吩咐了,他今天生病不见任何客人,可进了客厅,正看到父亲闷声不响地窝在沙发里,一张平日里和蔼可亲的白白胖胖的脸,现在眉毛胡子全皱到一块了。\n斯咏走过去,在父亲身边坐下,还没开口,就看见父亲面前的茶几上摆了一本大红锦缎、富丽堂皇的聘书。她迟疑着看了父亲一眼,拿过聘书,打开,看到里面写着:“今敦请 长沙商会会长陶老夫子大人出任 湖南省各界拥戴 中华帝国洪宪大皇帝 登基大会筹办主任 晚生汤芗铭敬启百拜”。\n斯咏看父亲闷头不做声,腾地站起来,就要将那本聘书往壁炉里扔。\n“斯咏,你干什么你?你放下!”陶会长吓得赶紧一把将聘书抢了过来。\n“爸!”斯咏急了,“你难道真要跟他们一起遗臭万年吗?”\n“你知道什么你?”他将那本聘书往沙发上一甩,手拍着额头,又是长长一声叹息,他这时的苦恼无奈,真是无法用言语表达。\n二 # 蔡和森、警予陪着情绪低落的斯咏一起往楚怡小学走,要去参加本周的读书会。一路上,斯咏已经给他们讲了自己家发生的事情,言辞之间对父亲很不谅解。\n“算了,斯咏,伯父也不是自愿的,谁不知道那个汤屠夫杀人不眨眼?”蔡和森安慰着她。\n警予却率直地反驳:“话虽然这么说,可这是做人的原则,要是我,死也不干!”\n迎面,毛泽东与罗章龙、张昆弟等人也正好来到了门口。众人打着招呼,一齐向校内走去。毛泽东见斯咏一副长吁短叹的样子,就问她刚才在聊什么呢?斯咏说:“除了那个袁大皇帝,还能有什么?”\n房里已经聚集的五六个读书会成员,看到他们进来,开慧蹦起来大叫道:“毛大哥,你来你来,看子升大哥写的绝妙好联。”她左手一松,先垂下了上联“袁世凯千古”。右手再一松,露出了下联“中华民国万岁”。\n罗章龙疑惑地:“‘袁世凯’对不上‘中华民国’啊。”\n其他人也同样没弄明白,都搞不懂萧大才子为什么会写出这样一副不合规矩的对联。\n毛泽东第一个反应了过来,猛地一击掌:“好!写得好,写得绝!你们看你们看,‘袁世凯’对不起‘中华民国’,以错对成绝对!萧菩萨,干得漂亮啊,你!”\n因为汤芗铭把反袁的报纸、杂志都禁光了,整个湖南别说中文报纸、连英文报纸都收不到了,一时间通行的都是筹安会办的《大中报》,翻来覆去,全是“圣德汪洋”、“万寿无疆”,为袁世凯歌功颂德的。还好,读书会能辗转收到黎老师从北京偷偷寄来的报刊,虽然是迟到的新闻,但总比没有要好得多。今天他们读的,是《申报》上梁启超的《辟复辟论》:“……复辟果见诸事实,吾敢悬眼国门,以睹相续不断之革命!”\n“写得太好了。梁先生的文章,真是扎到了那帮复辟派的痛处,一针见血啊!”毛泽东忍不住击节而叹,站起身来说,“大家想想,启超先生他们这些文章,把复辟的问题分析得这么透彻,我们读了都明白了,可光我们十几个人读了又有什么用?全长沙还有好几千学生,他们整天看到的,全是汤芗铭塞下来的筹安会放的狗屁。如果我们能把这些报刊上的资料编印成一本书,散发出去,大家不就都明白他袁世凯是个什么东西了吗?”\n“好主意,这才是我们应该干的。”何叔衡头一个赞成,大家也强烈支持。可萧子升说:“这些资料都是违禁的,我们悄悄看看,还得躲着藏着。编印散发,这要被人发现了怎么办?”\n毛泽东说:“你就是一世人胆子小!要我说啊,只要做得巧,不怕别人搞。书印出来,又不是搬到大街上去发,我们分头行动,一个人传一个人,不是可靠的同学我们不传。长沙的学生,一百个至少有九十九个跟我们站在一边吧?我认识你,你再认识他,传不得几天,保证就传开了。到时候,就算汤芗铭真发现了,这本书已经到处都是,他未必还查得到始作俑者?大家说是不是?”\n众人还在考虑,又是何叔衡头一个点头:“润之的主意不错。要我看,不单是不见得真有风险,就算有,君子有所为有所不为,这件事我们也应该当仁不让!”\n何叔衡的一句话给这个主意定了性,几乎所有的人都点了点头。只有萧子升还有些犹豫:“主意呢,也许可行,可关键是前提,我们上哪儿去找一家肯印这种东西的印刷厂?还有,印书的钱又从哪来呢?”\n“这个大家就不用担心了,我们家就开着印刷厂,印书的事,包在我身上。”\n斯咏说得前所未有的坚定,话虽不是冲着萧子升来的,萧子升的脸却一下子红了。他看看斯咏,腾地站了起来:“好,说干就干!编书的事,我萧子升负责!”\n很快,一本本书名是 《最新阴阳黄历》而内容却是《梁启超等先生论时局的主张》的新书,就在长沙各个学校流传开了。\n三 # 书,很快就落到了汤芗铭的手里。拿着书,这个面如书生却杀人不眨眼的将军,决定要亲自出马,再验证一次他的“人格魅力”。他曾经凭借手中的权势和武器,让很多貌似高贵的头颅低垂在他面前,任他践踏。但这一次,他却没有十足的把握,只是想做最后一次努力。\n夜幕中,一只白净的手文雅地敲响了芋园杨宅的大门。杨昌济一介寒儒,平常往来的,除了亲戚朋友,便是学生同事,杨昌济一如平常把门打开,却没料到这次站在门口的,竟是汤芗铭,一身雪白的对襟短衫,似一名晚间散步的书生。\n“不速之客遑夜叨扰,板仓先生,打搅了。”\n杨昌济不由得往汤芗铭身后望了望,汤芗铭倒像是没明白他望什么,停了一下才反应过来:“哦,芗铭是来拜访朋友,就一个人来的。怎么,鸿儒雅居,芗铭无缘一入么?”\n“汤帅请。”\n汤芗铭环顾打量着书房:满满一排哲学经典排列在书架上,汤芗铭却看见一本《大乘金刚般若波罗密经》,他抽出书:“板仓先生也好《金刚经》吗?”\n“略也读过。”\n“芗铭平素最喜欢鸠摩大师的《金刚经》。”汤芗铭笑着在杨昌济对面坐下了,“这金刚经千言万语,妙谛莲花,据芗铭之陋见,倒是两个字可一以概之。”\n“哪两个字?”\n“一曰忍,二曰施,忍己而成佛,施爱于众生。忍得万般苦,方能布施众生啊。由此而论,倒是这个忍字,更是根本。先生以为如何?”\n“忍己是为施众,以昌济将来,倒是施才是目的。”\n“不忍何来施嘛?所谓世间万事,不如意者八九,人生于世,原是要忍的。”汤芗铭拍拍那本《金刚经》,“就譬如鸠摩罗什大师自己,一代大德,为后凉王吕光所逼,不也只好忍着与龟(音丘)兹公主成婚,一过15年吗?若是不忍,一味要杀身成仁,又何来后来如此煌煌佛学经典?所以中国人说,民不与官争,忍是根本哪!”\n他凑近了杨昌济:“鸠摩大师如果当时不忍,脑袋就掉了不是?”\n杨昌济不禁笑了。汤芗铭也笑了。\n一时间,两个人仿佛比着赛一样,但杨昌济越笑越开心,终于,汤芗铭有些尴尬地收住了笑容,房间里,只剩了杨昌济的大笑阵阵不绝!\n汤芗铭的眼睛眯起来了:“很好笑么?”\n“杭州灵隐寺弥勒佛前,有一联,下联尤其好:开口常笑笑世间可笑之人。”\n“却不知可笑之人,是先生,亦或芗铭?”\n“‘民不畏死,奈何以死惧之’,则汤帅以为孰人可笑呢?”\n汤芗铭腾地站了起来,仿佛是意识到自己的失态,他又搬出了惯有的、矜持的微笑:“长沙学界以先生为尊,而以先生之尊,自是无人敢让先生去死的。当此乱世,芗铭只希望先生为湖湘千年文华之气运存续考虑,不致任由湖湘学界生出什么变乱吧?”\n“汤帅谬赞了。昌济一介寒儒,哪里谈得上领导湖湘学界?至于变乱二字,当今世上,最大的变乱,恐怕并非来自学界,而是来自某些窃国之贼吧?”\n“看来,芗铭是指望不上先生了?”\n“历史之车轮滚滚向前,欲以人力变其轨而倒行,只怕是无人指望得上。”\n盯着杨昌济,足足有七八秒钟,汤芗铭这才放下《金刚经》,轻轻吐出一句:“打搅了。”\n他转身就走。身后,杨昌济站在原地说:“不送。”\n汤芗铭出了杨宅,吩咐带着卫兵埋伏在门外巷子里的副官:“传令,严查逆书来源,破案者,升三级。还有,通令长沙各校,一律组织学生,参加拥戴洪宪皇帝登基大会,不得有一人缺席。通知商会,赶印《洪宪皇帝圣谕》,到会师生,人手一册,作为各校正式教材,列入考试范围。”\n四 # 汤芗铭能封锁外面的报刊入湘,但却封锁不了私人信件往来,近期《申报》的头条消息《唐继尧蔡锷通电讨袁护国军进军川南湘西》还是悄悄地在长沙传开了。稍微有些政治头脑的人都开始观望,猜度汤芗铭下一步会走什么棋。而汤芗铭在这个时候,依然要印制《洪宪皇帝圣谕》、组织大规模“拥护袁大总统当皇帝”游行活动、清剿《梁启超等先生论时局的主张》,彻底暴露了他要跟随袁世凯走到底的决心。所以,尽管反袁的呐喊声已经响彻大半个中国,在湖南这片“敢为天下先”的沸腾土地上,汤芗铭的那些走狗还在为了讨主子欢心而绞尽脑汁,这其中,就包括那位因为出卖老师同学当上了侦缉队队长的刘俊卿。\n刘俊卿这几天因为忙着张罗 “拥护袁大总统当皇帝”游行,和三会堂的马疤子走得很近,两个人称兄道弟,酒馆同进茶馆同出,这让一贞心里很不痛快。刘俊卿便允诺,要继续努力,争取尽早转去教育司或者其他体面的部门,到时候,只要是一贞不喜欢的人,他保证再也不理,一贞不喜欢的事,他保证再也不干了。当然,他并没有给一贞说,以前是马疤子差人来他们赵家茶叶铺子收保护费,而现在,不仅那笔钱免了,马疤子为了码头那些见不得光的生意反而要按月给他刘俊卿分红利。虽然如此,刘俊卿给一贞说的话还是真心的,他一直都把自己当读书人,而对于一个读书人来说,“侦缉队队长”这个职务毕竟不是那么体面。\n从这个意义上说,汤芗铭确实没有看走眼,“刘俊卿这种人,你越不满足他,他就越会拼命干,因为他有一肚子火要发出来,火越大,他就越恨不得见人就咬一口,而且咬得又准又狠,一定咬中那人的痛处。”当初汤芗铭安排刘俊卿去干侦缉队是这个原因、现在要把清剿《梁启超等先生论时局的主张》的任务交给他也是这个原因。刘俊卿当然不会以为他是在被人利用,相反他觉得自己是在被重视、觉得这就是他迫切需要的、一直在等待的机会。拿着一本样书,别人不知道如何着手去查,他却知道,因为每一个巴心巴肝的走狗都是凭借鼻子来完成主人交代的任务的,所以,现在刘俊卿就认定了每本书有每本书的味道,他要寻着这味道去把主人想要的东西搜出来。于是,他带着下属直奔一师。\n在一师的凉水亭里,读书会的会员们还不知道汤芗铭已经指派了刘俊卿在严查他们散发“逆书”的事情,大家聚在一起正讨论斯咏爸爸的印刷厂是不是该为汤芗铭印《洪宪皇帝圣谕》。按照斯咏自己的意思,就算倾家荡产,陶家也不能给袁世凯当走狗。大家同仇敌忾,都是这个意思。可一向坚决反袁的毛泽东却一拍石桌:“印!为什么不印?汤芗铭印这个圣谕是想在庆祝大会上发给全长沙的学生,正好,借他这套锣鼓,唱我们的戏……”\n这边十几个脑袋凑成了一团,在听毛泽东的妙计,却没料到凉水亭虽然僻静,但毕竟也是一师的公共场合,难免会有喜欢清静的学生光顾。王子鹏在收到同学悄悄给的《梁启超等先生论时局的主张》后,胆小的他想来想去,就觉得来这里看比较安全,一路走来,上了后院的石阶,左右看看没人,他便迫不及待地翻开书,边走边看。走着走着从君子亭那边传来的声音把他吓了一跳:“对。拿这本《梁启超等先生论时局的主张》,换掉他汤芗铭的《洪宪皇帝圣谕》。到时候,大会一开,他把书一发,嘿嘿,拥护袁世凯登基马上就变成庆祝袁世凯垮台,看他汤芗铭还怎么收场!”\n这不是毛泽东的声音吗?王子鹏抬起头,看到他们班的张昆弟,六班的萧三、蔡和森,去年就已经毕业的萧子升,还有几个外校的学生……斯咏也在,她似乎和这些人非常熟悉,正附和着毛泽东的提议,说:“好主意!书都在我爸的厂里印,我来安排,应该可以做到。”有人劝斯咏不要这样做,会连累陶家的,斯咏说这是大是大非,孰轻孰重她还分得清,即使真出点什么事,以她爸在长沙的身份,汤芗铭也未见得真敢拿他怎么样。\n大是大非?子鹏听得惊呆了,看看手里的书,意识到他们的谈话肯定和这本书有关,便想转身离开这个是非之地,却不想慌乱中正好踢到一块石头,石头顺着台阶乒乒乓乓滚了下去。\n“谁呀?”子鹏吓得站住了,一回头,只见亭子里的人都警惕地站了起来,正望着自己,其他人的目光里只有猜忌,唯有斯咏,她的目光里有惊讶、也有惶恐……毕竟是一起长大的表兄妹、毕竟有了婚约、毕竟是自己有负于子鹏暗恋上了别人,斯咏猛一看到子鹏,骨子里的传统立刻就把她的愧疚从眼神里表达了出来。子鹏也不知道该怎么解释自己这个时候站在这里的原因,他低着头,逃也似的赶紧沿着台阶向下跑去。\n可他急促地跑下台阶,还没从刚才无意间听到的大胆计划里清醒过来,竟远远地看到刘俊卿正带着手下迎面走来。\n看着刘俊卿敞着衣衫、斜挎手枪、满脸杀气疾步走来的样子,回想起刚才在君子亭听到的那番话,子鹏的心狂跳起来,仿佛看到 “血染一师” 的场面就在眼前。他一时紧张得整个人都僵住了,只是下意识地将书藏到了身后。刘俊卿也看到了子鹏,正要打招呼,但看看子鹏的目光里没有一丝同学情谊,也倔强地扬起了头,摆出一副不在乎的样子,想要从子鹏身边走过。\n从这里到凉水亭,不过短短的二三十米远的距离!紧张中,子鹏想起了斯咏的目光、想起了她和毛泽东的对话、想起了易永畦在枪托下捂着胸口摔倒、想起了刘俊卿带着士兵在校园里疯狂搜查……子鹏手一松,把那紧紧攥着的书掉在了地上。\n刘俊卿捡起书,用冷冷的目光似笑非笑地望着子鹏,然后示意两个手下将子鹏推到了墙角。\n“说,哪来的?”刘俊卿扬起了那本书,在子鹏面前晃着,“子鹏兄,朋友一场,我这可是给你机会,别不识好歹。在这里问话,就是给你留面子,不想让别人看见。只要你说了,我保证,不告诉任何人是你说的,够可以吧?”\n子鹏瞟了他一眼:“书是我的,你爱怎么办就怎么办吧。”\n“还跟我耍少爷脾气?你当这是咱们在学校,吵两句嘴回头又好?这是掉脑袋的事!我再给你最后一次机会,赶紧说!”\n“我……我什么都不知道。”\n刘俊卿的眼睛眯起来了:“给你个好地方你不说,是不是想进侦缉队作回客?真到了那儿,子鹏兄,别说你这身细皮嫩肉,就是神仙我也包你脱三层皮!”\n子鹏听得全身都禁不住发抖了,但却死咬着牙关,保持着沉默。\n刘俊卿再也忍不住了,扔掉书,一把揪住子鹏,将子鹏按到墙上,拔出枪顶住他的头:“你到底说不说?!”\n“少爷?”秀秀是来给子鹏送换洗衣服的,在寝室里没有看到人,才找到这里来。看到哥哥正用枪顶着子鹏,她惊叫着扔掉手里抱着的那几件衣服,猛扑了上来,一把推开刘俊卿,拦在了子鹏前边:“你干什么你!”\n“阿秀,哥在办案,你别来多事,赶紧让开。”\n“办案你抓少爷干什么?少爷又不是坏人!”\n“他收藏逆书,够杀头的罪,你知不知道?你让开,我要带他去侦缉队。”\n“我不让!”秀秀死死拦着子鹏,又气又急之间,眼泪已经流了一脸, “我就不让,谁都不准抓你,都不准!”\n刘俊卿拎着枪冲了上来,想推开秀秀。秀秀一把抓住了手枪的枪管,按在了自己胸前!\n“阿秀,你……你这是干什么你?快放手,枪里有子弹的!”\n“你开枪吧,开枪啊!反正你不打死我,我是不会让你抓少爷的。”\n“你想为他送命啊?”\n“我愿意。”秀秀转过头,看了一眼子鹏,平静地说,“我愿意为他死,死多少遍都愿意。”\n秀秀看着子鹏,子鹏看着秀秀,两个人在对方的注视下,彼此都感受到了从未有的巨大冲击和心灵震撼。这冲击和震撼也把刘俊卿惊醒了,他的目光在秀秀、子鹏的脸上睃了好一阵,长长地吐了一口气,拨开秀秀的手,收起手枪,转身走了。\n仿佛是一下子耗尽了全身的力量,秀秀看到哥哥走了,突然脚一软,眼看着就要倒下,子鹏赶紧搂住了她。埋头抱着子鹏的胳膊,秀秀一时泣不成声。在这个安静的角落里,他们依偎在一起,共同感受着劫后余生的惊恐。终于平静下来了,秀秀这才想起问子鹏刚才出了什么事情。子鹏捡起被刘俊卿扔在地上的书,悄悄地翻给秀秀看,给她讲起了袁世凯复辟。\n复辟?秀秀对这个词一无所知,更不知道这个“复辟”和哥哥有什么关系,更不知道为什么他会因为袁世凯“复辟”而打少爷。她怔怔地看着子鹏,相信他做的事情总是对的。子鹏在秀秀清澈而惊恐的目光中想起秀秀刚才挺身救自己,心里涌起一股说不出的战栗,忍不住捧起了秀秀的脸……\n五 # 斯咏突然关心起《圣谕》印刷的事情,让陶会长非常诧异,女儿的理由既无奈也充分:“我不同意有什么用?长沙又不是我们一家印刷厂,反正他们也会印出来的。再说,胳膊也扭不过大腿,真不印,还不是咱们家倒霉?”\n看到女儿能体谅爸的难处了,陶会长心里好受多了,至于斯咏提出的要带同学们来参观印刷厂的事情,也一口答应了:自己家的厂子,参观参观有什么关系?不过,陶会长只是给印刷厂的厂长打招呼说,斯咏要带同学参观厂子,搞现代工业生产调查,却并没有说具体的时间。根据他们读书会在凉水亭的商议结果,这样的活动当然只能在晚上进行。机灵的斯咏便钻了这个空子,对厂长说,他们要等晚上不上课的时候才能来参观,而那时候工厂没人上班,只需要把工厂的钥匙给他们就可以了。\n计划于是在一个月白风清的夜晚得以实施。\n印刷厂堆满货箱的仓库里,一个贴着“洪宪圣谕”标签的箱子被打开了,几双手飞快地取出里面一本本《圣谕》,将旁边一个箱子里的《梁启超等先生论时局的主张》换了进去。\n毛泽东与蔡和森合力将一箱书码上了货堆,回头打量着仓库里:一箱箱书都已收拾码好,只剩了萧三、李维汉还在更换最后一箱书。蔡和森叫道:“子暲,你们俩快点。好了,大家赶紧走吧。”\n看到众人纷纷向外走来,在仓库外把风的女生们这才松了口气,刚要向门外走,恰在这时,门“吱呀”一声开了,陶会长推门进来说:“哟,这么多人啊?”\n这声音把紧紧靠在仓库墙角、正要盖上箱子的萧三与李维汉吓得往门后一缩,一时紧张得大气也不敢出。\n看到眼前这么多男女学生,陶会长也愣住了。他只是想女儿晚上参观,那能看见什么?因此特地前来看看,却不想竟看到了毛泽东他们。\n“斯咏,不是说带你同学来参观吗?怎么……”\n斯咏一时无言以对,用求援的目光看着毛泽东,毛泽东一时却也不知该如何回答,众人的心一下子悬了起来。就在这时,后面的何叔衡满面笑容地迎了上来:“这位就是陶翁吧?”\n“您是?”\n“在下姓何,在周南女中和第一师范任社会实习老师,今天借着贵府小姐提供机会,专门带了一师和周南的这些学生,前来参观。”\n“是这样啊。”何叔衡的年纪和气度令陶会长一下子放了心,他握住了何叔衡伸过来的手,“辛苦何先生了。斯咏,你看你,请何先生和同学们参观,也不选白天来,这半夜三更的,工人都走了,能看到什么?”\n斯咏一时还不知如何作答,何叔衡道:“我们也就是看个大概,了解一下现代工厂是个什么样子,再说学生们白天有课,晚上参观,既不影响学习也不影响工厂生产嘛。”\n“那倒也是。哦,我就不打搅各位参观了。斯咏,你出来一下,我有几句话跟你说。”\n斯咏跟着陶会长出去后,大家都微微松了口气。萧三与李维汉,这才敢活动一下因紧张而僵直的身子。无声无息中,李维汉胸前的校徽被萧三的手臂擦落,滑落在那只尚未盖上的书箱里,两人却浑然不觉,赶紧把箱子盖好,会同大家一起离开了这个让他们提心吊胆的地方。\n跟在父亲身后,斯咏在解释着方才的情景:“是何老师叫他们来的,我事先又不知道。”\n“好了好了,今天的事就不说了,反正你以后注意一点,少跟那个毛泽东来往,还有那帮第一师范的。”\n“知道了。爸,找我什么事啊?”\n一句话,勾起了陶会长满肚子的心事,抬头望着夜空中被乌云遮去了大半的月亮,陶会长一时仿佛不知该如何启齿:“怎么说呢?斯咏啊,我知道你很难理解,可有些事……人在屋檐下……”\n他摇了摇头。\n斯咏:“爸,有什么你就说吧。”\n陶会长犹豫了一下,这才说:“明天的拥戴洪宪皇帝登基大会,汤芗铭已经指定了,要我来主持。登这样的台,别人会怎么看我,我心里不是不清楚。可不登这个台吧?汤屠夫又点了我的名。斯咏,爸昨天一晚没睡着,今天又想了一天,可就是想不出个推脱的办法。我知道你绝不会同意我干这种事,可现在这种情况,爸实在是……”\n“去就去嘛。”斯咏很干脆地说。\n陶会长简直不敢相信自己的耳朵:“你是说让我去?”\n“爸,我知道你不是有意站在他们那边,这就够了。再说,不就是个大会吗?有什么大不了的?”\n“可这是公开……这可不是什么光彩的事,你真的不介意?”\n斯咏居然带着微笑:“爸,您放心,我不会介意的。”\n陶会长长长地松了口气:“你能理解爸,爸心里就轻松多了,爸怎么出丑都不要紧,就是怕你心里不舒服。”\n斯咏:“这回的事,还不知道是谁出丑呢。”\n心事重重的陶会长显然并没听懂她的一语双关。\n"},{"id":127,"href":"/zh/docs/culture/%E6%81%B0%E5%90%8C%E5%AD%A6%E5%B0%91%E5%B9%B4/%E7%AC%AC6%E7%AB%A0-%E7%AC%AC10%E7%AB%A0/","title":"第6章-第10章","section":"恰同学少年","content":" 第六章 嘤其鸣矣 # 伐木丁丁,鸟鸣嘤嘤。\n出自幽谷,迁于乔木。\n嘤其鸣矣,求其友声。\n一 # 光阴似水,渐渐到了四月,正是长沙的多雨时节,这一次一连下了三四天的雨,略略放晴,但天还是阴阴的。一师的综合大教室里,袁吉六正给六班、八班、讲习班全体学生上大课,评讲他们最近的作文。\n“第六班,蔡和森,95分。”袁吉六扬着手里蔡和森的作文本子,仿佛展览样品一般环视了教室里众学生一眼,这才笑吟吟地把作文本递给蔡和森。 “讲习班,萧子升,90分。”“第八班,刘俊卿,85分。”……\n接过作文本,不甘屈居人后的刘俊卿,脸色阴得像下暴雨前的天色。他瞄了蔡和森一眼,这一瞄,不是普通的瞄,而是带了钩子的,想要剜出什么来的样子。\n“第八班,毛泽东,”袁吉六又拿起了一个本子,声音却一下子沉了下来,“70分。”\n蔡和森、萧子升、萧三等人都吃了一惊,毛泽东也不禁一愣。他望着台上,正碰到袁吉六斜了自己一眼,然后硬冷冷地说:“锋芒太甚,须重含蓄!”本子被“砰”的扔在毛泽东的桌上,70分的分数旁边,果然是鲜红的评语“锋芒太甚,须重含蓄”。望着这八字评语,毛泽东显然有些摸不着头脑。\n下课后,欧式教学楼又热闹起来。川流的学生中,方维夏叫住了刘俊卿,说:“上次你不是说,想借讲习科萧子升同学的入学作文,学习他的书法吗?”说着,将一叠文章递了过来:“这是他补考的作文,还有他最近的两篇国文课作业,我都帮你借过来了。看完了,你直接还给他就可以了。”\n望着方维夏离去的背影,刘俊卿捏住那本作文,阴沉着脸,走回寝室。他伸手刚要推门,门却正好从里面被拉开,一个足球迎面飞出,随即毛泽东光着膀子,与周世钊他们冲了出来。\n“刘俊卿,”毛泽东看看侧着身子生怕被球碰到的刘俊卿,一边颠着足球一边招呼他,“走,踢足球去?”\n“不了,你们去吧。”刘俊卿说着,换了一副笑脸。\n“你也要动一动嘛。哎呀,随便你了。”毛泽东也不再勉强他,与周世钊等同学边传着球边往操场跑去。\n刘俊卿保持着笑容,走进了寝室。几乎在门关上的同时,他脸上的笑容一扫而光。看看手里萧子升的那本作文,再看看毛泽东的床位,他发泄似的将作文本扔到了桌上。他在自己床沿坐下,满寝室漫无目的地张望着,想这次的作文。想着看着,看着想着,猛然间,他的目光被子鹏鲜亮的床铺吸引住了。不由自主地,刘俊卿走到了子鹏床前,他有些忐忑地撩开蚊帐,窥视着里面的一切:崭新的、高档的、齐全的……总之是他刘俊卿没有见过却梦寐以求的,他把手伸了出来,却又有些心虚地望了望紧闭的门口,但最终还是抵抗不了诱惑,他在子鹏的床上坐了下来,怯生生地抚过绣花床单,抚过缎面被子,抚过柔软的枕头……他打开了一瓶雪花膏,闻了闻,又赶紧盖上,仿佛意识到了这一切并不属于自己,他有些慌乱地站起,放下了蚊帐。但地上那双擦得雪亮的皮鞋却令他怎么也无法迈开脚步,他看了看门口,咽了口唾沫,把手伸了过去……\n门突然开了,走进来的竟是子鹏!正在系着皮鞋鞋带的刘俊卿顿时愣在了那儿,脸一下子涨得通红,边手忙脚乱地脱鞋,边喃喃地说:“子鹏兄,你回来了?”\n子鹏看到刘俊卿的样子,一时间弄不明白他到底想做什么,愣了一下,随口说道:“没关系,你穿吧,没关系的。”\n“不是……我就是试试……试试这双和我那双是不是一样大小。”刘俊卿涨红着脸,换上自己的布鞋,逃也似的走出两步,又回头解释:“我那双放家里了,没带过来。”\n子鹏也不计较,跟在刘俊卿后面,一起往食堂走去。\n热闹喧天的一师食堂里,墙上的小黑板挂着菜谱——南瓜、茄子、包菜……都是些简单的素菜。学生们拿着各式各样的大碗,排着长长的队伍。终于排到他们了,子鹏和刘俊卿端着盛满饭菜的碗,找了个位子坐了下来。刘俊卿看见子鹏对着面前的茄子米饭,没有动筷子的意思,以为他在想刚才的事情,有些难为情。子鹏不想让同学难堪,解释说他不太习惯吃学校的饭菜,已经另外叫了点心。\n刘俊卿这才把心放回肚子里,低头吃饭,假装不经意地问:“哎,子鹏,问你个事,你那双皮鞋是在哪间店买的?要多少钱啊?”\n“南门口的大昌。也就七八块钱吧,怎么了?”\n“哦,没什么,我看看跟我那双是不是一家店的,我那双放家里了。”刘俊卿这时候说起谎来,已经脸不红心不跳了。\n这时候,一名跑堂的把子鹏的点心送来了。子鹏给了钱,跑堂要把零头还给他,子鹏手一挥,懒懒地说:“不用了,你留着吧。”跑堂满脸堆笑,说着感激的话走了。子鹏推开饭碗,吃起点心来,那些点心的样子很精美,可以想像,味道也一定很好。看看子鹏吃的,再看自己碗里的饭菜,刘俊卿顿时感到口里的食物有些难以下咽了。\n子鹏留意到了他的神情,赶紧把点心挪了过来,请他一起吃。\n刘俊卿客气了几句,还是没能抵抗住美食的诱惑,但又好面子地说:“那,下次我请你。”\n从食堂出来,刘俊卿直接出了学校,正要转弯,却看到父亲的臭豆腐摊子摆在对面的街角。他走过去,左右飞快地瞟了一眼,压低了声音说:“爸,你怎么又把摊子摆到这儿来了?南门口那边摆得好好的,怎么我一进一师,你就非天天摆到校门口来?”\n“俊卿啊,哦,我这就走,这就搬到南门口去。”看着儿子,刘三爹满脸歉然,赶紧收拾摊子。\n“爸,不是……我那个……我有件事……”犹豫了一会儿,刘俊卿终于还是开了口,“爸,你……你有钱吗?”\n刘三爹最怕听见的就是这句话,但他还是把秀秀的工钱全部拿出来给了儿子。\n刘俊卿揣着钱,飞快地跑到南门口的大昌鞋店,他看到中央柜台里,展示着一行皮鞋,当中最亮的一双与子鹏那双正好完全一样。\n看到刘俊卿的目光落在了那双皮鞋上,擅长察言观色的伙计忙凑过来说:“识货!瞧瞧,这位少爷就是识货。这是上海新款,英国老板的鞋厂做的,全省城的少爷都抢着买呢。要不,您拿双试试?”\n刘俊卿努力端着矜持,微一点头:“那就试试吧。”\n“好嘞。”伙计边拿鞋边冲旁边的小学徒,“给少爷上茶。”\n试好了鞋,伙计接过刘俊卿递来的一叠银元,忙不迭地收拾起刘俊卿换下来的布鞋,装进皮鞋盒:“多谢少爷。换下来的鞋,我叫人给您送府上去?”\n“不必了,我自己拿就可以了。”刘俊卿赶紧回绝,他的家哪里称得上是府呢?但接过鞋盒,他却站着没动。伙计问:“少爷,还有事啊?”\n“那个……”刘俊卿憋了一下,这才说,“好像还要找钱吧?”\n“哎哟,您瞧我这记性!”伙计抬手给了自己一巴掌,“对不起,对不起,忘了忘了。”他赶紧找出几枚铜元和一枚铜板递了过去。刘俊卿接过钱,犹豫了一下,又把那一枚铜板放回到伙计手中。学着子鹏的样子,他尽量自然地一挥手,说:“这是赏你的。”\n迈着方步,刘俊卿穿着崭新的皮鞋跨出了鞋店。店内,打量着手里那枚轻飘飘的铜板,伙计职业化的笑容一扫而空,瘪着嘴随手把铜板扔给一旁的小学徒,不屑地说:“去,什么他妈破少爷,伺候了半天,就他妈一个铜子!给,归你了!”\n一道闪电,划过乌云翻滚的天空,轰然一声,惊雷骤起,大雨滂沱。刘俊卿穿着崭新的皮鞋踏过雨点四溅的街道,顶着雨飞跑到一间茶叶店的屋檐下。大雨倾盆,雨点打在地上,水滴不断溅到他崭新的皮鞋上,他有些心痛,想了想,蹲下,准备解开鞋带把新皮鞋换下来。恰在这时,赵一贞背着书包,顶着雨,顺着屋檐跑了过来。刘俊卿突然蹲下,挡住了她的路,两个人一下子险些撞上,都吓了一跳。\n“哟,对不起。”刘俊卿赶紧站起,就在这一刹那间,他的心怦然而动,眼前明亮如彩虹高挂,那是湿淋淋的赵一贞,清秀而水灵。一贞读出了刘俊卿眼里的炽热,娇羞地躲开了刘俊卿的目光。\n店里的赵老板看见了女儿,叫道:“一贞,还不快回来?哎呀呀,你看看你这一身水,快擦擦,快擦擦。”一贞进了屋接过毛巾后,他又把一张货单递给一贞,说:“我先进去吃饭了,你看着店。这上面的几样货,都是客人订好了的,下午就会来拿,你赶紧包一下。弄漂亮点啊,人家要送礼的。”\n赵老板走后,一贞对着货单,收拾着包装茶叶的东西。几个竹编礼品盒放在货架最上面,一贞搬来凳子,脱鞋站上去,尽量伸手够着。她的脚用力踮起,打湿的衣裙贴着努力伸展的身体,露出了雪白的小腿,把屋檐下的刘俊卿看得都痴了。似乎是感觉到了某种异样,一贞一侧头,正碰上了刘俊卿痴痴的目光,慌乱中,哗啦一声,货架顶上的礼品茶叶盒摔了一地!\n“怎么回事?”里屋的布帘一掀,赵老板端着饭碗冲了出来,一看,火气腾地上来了,把饭碗往柜台上“砰”地一搁,对着女儿骂道,“你搞什么名堂?一点小事都做不好!这盒子一个多少钱你知不知道?”\n“养你吃,养你穿,供你念书还不够,还一回家就摔东西!你以为这点小生意供你供得容易啊?”女儿已经在道歉了,赵老板还是不依不饶,端起饭碗,吼了一声,“还不赶紧收拾?”\n赵老板重新进了屋后,一贞忍着眼泪,默默地收拾着地上的礼品盒。刘俊卿捡起掉在店门口的盒子,递到她面前。迎着刘俊卿满是安慰与同情的目光,一贞接过盒子,慌乱地低下了头,怯怯地招呼他进来躲雨。刘俊卿喜出望外地退进店里,坐在一贞递过来的凳子上。一贞躲开了刘俊卿的目光,背着他包扎茶叶礼品盒。刘俊卿的目光,却始终没有离开过一贞灵巧的双手。\n赵老板出来换赵一贞进去吃饭。赵一贞的身影已经看不见了,刘俊卿的目光还停留在通往里间的晃悠悠的门帘上。直到赵老板挡住了他的视线,提醒他说雨停了,他才起身不好意思地告辞。\n二 # “毛泽东。”捧着大堆信件和报纸的校役叫住了正趿着一双破布鞋,端着饭碗边走边吃的毛泽东,“你的报纸,还有你的一封信。”\n毛泽东接过校役递来的报纸和信,看到信封上是毛泽民那稚嫩的字体,落款却标着“母字”,一看就知道是母亲口述、弟弟抄写的,忙把饭碗随手往旁边的窗台上一放,赶紧拆开信读起来:“三伢子,收到你的信,晓得你考了个好学堂,碰上了好先生,妈妈真是好高兴……你爹爹白天还硬起脸,不肯看你的信,其实晚上一个人偷偷起来躲着看,还生怕被我看见了……你在学堂里要好好念书,不要记挂家里,家里爹爹、妈妈、弟弟、妹妹都好……读书辛苦,要注意身体。有什么难处就写信回来,妈妈给你想办法。没有时间,就不要想着回来看我,妈妈不要你看,只要你把书读好,就是对妈妈最大的孝顺……”\n缓缓地收起家信,毛泽东将信放进了贴身的口袋,拿起报纸和饭碗,刚一转身,却发现杨昌济与黎锦熙正站在他面前。两位老师打量着他,目光都落在了他那双打眼的破布鞋上。\n黎锦熙笑道:“润之,报纸呢,是越订越多,这双鞋呢上个月就说换,怎么到现在都还没换呀?也该换换了吧?”\n毛泽东不好意思地摸了摸脑袋说: “上个月……后来忘记了。杨老师,黎老师,我先走了。”\n“等一下。”他刚走出两步,杨昌济叫住他,把一块大洋递到了他面前,说: “书要读,报要看,鞋也不能不穿吧?趁中午,赶紧去买一双。”看毛泽东站着不动,黎锦熙拉了他一下,说:“拿着吧,还讲客气?”\n接过钱,毛泽东一时也不知该说什么好。站在原地看两位老师走远了,他赶紧收拾好报纸和碗筷,跑出去买鞋。\n大昌鞋店,伙计一听毛泽东连四毛一双的布鞋都还嫌贵,满脸不乐意地抱怨:“我这儿可是大昌,不卖便宜货。再要少,路边摊上买去。”毛泽东悻悻地向店外走去,在熙熙攘攘的叫卖声中,拖着一双破布鞋走在青石板街面上。这时街边,一个妇人正叫卖着:“布鞋,上好的布鞋,一毛五一双。”毛泽东径直向鞋摊方向走去。但他的脚步却没停在鞋摊前,而抢前几步,停在了一块招牌前。那正是观止轩书店的广告牌,上面开列着一系列新书消息。“《西洋伦理史论》?”毛泽东的眼睛亮了,转身进了观止轩书店。\n书架的两边,各有一双手正从相反的方向对准了相邻的两本书:一只纤纤小手放在了《伦理学原理》上,一双粗壮的大手放在了《西洋伦理史论》上。两个人在抽出书的同时,都发现了对方,毛泽东先惊呼了一声:“哎,是你啊?”斯咏暂时却还没把毛泽东认出来,她只是有些疑惑地望着这个似曾相识的人。\n“不记得了?上次,就在这里,那本书——你后来还送给我了。”毛泽东提醒她说。“哦——对对。”斯咏打量着毛泽东,目光落在那双鞋上,“你这双鞋修修补补的还在穿啊?”\n“上次那本书我已经看完了,你看什么时候还给你?”毛泽东看了看自己的鞋,不好意思地笑笑,边翻着手里的书边问。“我不是送给你了吗,还还什么?”“还是要还喽,哪有白拿你的道理?”毛泽东不好意思地说。\n“那——下次有机会再说啰。”“也好。哎,你买什么书呢?”斯咏把手里的书一亮,毛泽东看了看封面,说,“《伦理学原理》?哦,德国泡尔生的。我们发过课本,课还没开,不过我已经看完了。”\n斯咏看看他,吃惊地问:“你在读书啊?”“第一师范。你呢?”“我在周南。”斯咏犹豫了一下,问道,“哎,你是第一师范的?你贵姓啊?”\n“姓毛,毛润之。”斯咏顿时心里一热,试探问道:“你们第一师范有几个姓毛的?”\n“好几百学生,我怎么知道?哎,你叫什么?”看看斯咏翻开的课本露出的姓,毛泽东叹道,“陶斯咏?好名字啊,喜斯陶,陶斯咏,取得喜庆。”\n“你也知道这个典故?”斯咏惊疑说。“出自《礼记·檀弓上》嘛,‘喜则斯陶,陶斯咏,咏斯犹,犹斯舞。’你这个人,一辈子都会开心得连唱带跳喽!”\n说着话,毛泽东拿着书,来到柜台前,用杨昌济给他的那块大洋付了书钱。正要出门,才发现二人说话的时候,外面下起了大雨,雨顺着瓦当落下来,仿佛给大门挂上了一道水帘。毛泽东一展胳膊,满不在乎地说:“哈哈,人不留客天留客啊!”\n斯咏没料到他会这样想得开,很意外地问:“你还蛮高兴啊?”\n“天要下雨,你又挡不住,还不由得它下?”毛泽东回头叫道,“老板,拿条凳子来坐好不?”伙计提来了一条凳子,毛泽东接过就要坐,看看斯咏,觉得还是不妥,把凳子递过来请斯咏坐下,然后又问老板要。老板回答只有那一条,毛泽东只得在斯咏身边蹲了下来。\n雨如珠帘,洒在屋檐前。斯咏忍不住伸出手,任雨打在手上,感受着那份清凉。毛泽东学着她的样子,也把手伸进雨中。两个人看看自己,再看看对方,突然都笑了起来。这一笑,彼此之间便没有了生疏的感觉,说起话来也轻松多了。\n“要说写下雨,苏东坡那首《定风波》绝对天下无双!你听啊:莫听穿林打叶声,何妨吟啸且徐行。竹杖芒鞋轻胜马,谁怕,一蓑烟雨任平生……”指点雨景,吟起苏词,毛泽东兴致盎然。\n斯咏揭短道:“人家那是下小雨。”\n“大雨小雨还不是一回事,反正是写下雨的。”\n“那怎么会一样?下大雨不可能这么悠闲。”\n“倒也是啊。真要下这么大的雨,苏东坡还会‘徐行’?他肯定跑得比兔子还快。”\n毛泽东这句话把斯咏逗乐了,她嗔怪道:“正说也是你,反说也是你。”\n“不服气你来一首,得跟下雨有关啊。”\n明明知道毛泽东在激将她,斯咏还是大方地说:“来就来,李清照的《如梦令》,昨夜雨疏风骤,浓睡不消残酒。试问卷帘人,却道海棠依旧。知否知否,应是绿肥红瘦。怎么样,比你的有意境吧?”\n“光有意境,内容软绵绵的,还是没劲。你听这首,杜甫的《春夜喜雨》,好雨知时节,当春乃发生。随风潜入夜,润物细无声——由雨而遍及世间万物,比你那个意境开阔得多吧?”\n“诗词嘛,讲的是内心的感受,未必非要遍及世间万物才好。”斯咏争辩道。\n雨声潺潺,两个人对吟相和的声音一来一往,仿佛融入这纯净的雨中,成了其中的一部分。\n“伐木丁丁,鸟鸣嘤嘤。出自幽谷,迁于乔木。嘤其鸣矣,求其友声。”毛泽东得意洋洋,“我又赢一盘!怎么样,三打三胜了啊。”\n斯咏说不过毛泽东,耍着小性子:“你厉害,行了吧?不跟你比了。什么嘤其鸣矣,没意思。”\n“怎么会没意思呢?《诗经》里头,我最喜欢的就是这一句了。你看啊,空谷幽幽,一只寂寞的嘤鸟在徘徊吟唱,啊,天地之大,谁,能成为我的知音?谁,能成为我的朋友?谁,能懂得我的心,能跟我相应相和?”吟到高兴处,他拖着破布鞋,手为之舞,足为之蹈,完全陷入了诗的意境中。\n望着毛泽东,斯咏突然扑哧笑了出来。\n毛泽东问:“哎,你笑什么?这首诗未必好笑啊?”\n“诗倒是不好笑。我就是在想,你那个空谷,是不是在非洲啊?”\n“中国的诗,怎么又扯到非洲去了?”\n“要不是在非洲,”斯咏上下打量着毛泽东,“哪来那么大的一只鸟,你以为中国也产鸵鸟啊?”\n毛泽东的诗兴一下子被打断了,无奈地说:“你看你这个人,一点都不配合别人的情绪。真是对牛弹琴。”看到斯咏不高兴了,毛泽东赶紧弥补道:“开句玩笑嘛,这也当真?这世上哪有你这种身材的牛嘛?”\n“没错,蠢牛都是那些又高又大的家伙!”斯咏扭开头,过了一会儿,没听见毛泽东的声音,又扭头看去,却见毛泽东正笑嘻嘻地看着她。佯嗔着的斯咏也忍不住笑了,对着毛泽东又说了一句,“蠢牛!”\n“雨小了,该走了。我下午还有课,等不得了。再说这点雨,无所谓了。”打量着雨,毛泽东卷起了裤管,又把那双破布鞋脱了下来,拎在手里,转身,把刚脱过鞋的手伸向斯咏,“很高兴认识你。”\n看到斯咏盯着自己的手不动,毛泽东这才反应过来,赶紧把沾有污水的手往衣服上擦了几把,再次伸来,说,“对不起呀,没注意。”\n两个人握了握手,毛泽东说:“下次有空,我们再聊,到时候我把书还给你。再见了。”说完便冲进了雨中。\n望着毛泽东远去,斯咏不禁自言自语,“下次?一没时间二没地点,哪来的下次啊?这个人!”\n三 # 刘俊卿不舍地往前走去。地上,到处是积水,他找了个靠墙的地方,脱下皮鞋,换上了布鞋,小心地选着水少的地方落脚,向一师走来。眼看快到校门口了,他犹豫了一下,又躲到墙边,取出了皮鞋,掏出手帕,仔细地擦了擦,才穿上。崭新的皮鞋踏在地上,和穿布鞋的感觉就是不一样。刘俊卿昂着头,迈着方步,向学校走来。\n随着一声“落轿”,袁吉六一抖长衫,气派十足地下了轿。一旁,黄澍涛等人的轿子刚好也到了。二人互相抱着拳,走进校门。放眼看去,接送老师的轿子成了堆,众先生个个衣冠楚楚,一看就都是有身份的人。\n毛泽东光着双脚,提着那双破布鞋,正好也在这个时候跑了进来。刘俊卿心情很好,主动招呼道:“润之兄。”毛泽东随口答应着,看都没看刘俊卿的新皮鞋,自顾自地跑上台阶,抖着衣服放裤管。刘俊卿不禁有些失望,还好子鹏与蔡和森也正走来,他又来了精神,改变方向走向子鹏,不料此时身后正好有个中年人边抖蓑衣边走来,与他撞在了一起,中年人沾满泥水的草鞋踩在了刘俊卿闪亮的皮鞋上。\n“哎哟,对不起,对不起,一下没注意,对不起了。”中年人不好意思地说。\n看到崭新的皮鞋被踩上了几道泥水印,刘俊卿的眉头顿时皱了起来,他打量了一眼中年人,一身陈旧的土布短褂,卷着裤管,穿着草鞋,提着蓑衣,身上到处是水,脸上赔着憨厚的笑,一看就是个老实巴交的农民,顿时很不高兴地吼道:“搞什么名堂你?长没长眼啊?我这可是新鞋,上海货,弄坏了你赔得起吗?”\n“真是对不起,你多原谅……”中年人憨厚地继续道歉。\n刘俊卿却还是得理不饶人:“我不管,你给我弄干净!”\n“刘俊卿,你至于吗?人家又不是故意的。”毛泽东看不下去了,回头来为中年人打抱不平。\n刘俊卿对他怒目相向:“不是你的鞋,你当然不心疼。”\n“也不过就是双鞋,又不是你的命!”\n“你以为这是你那双破鞋啊?穿不起就别在这儿摆大方!”刘俊卿挖苦毛泽东,又冲中年人吼道,“你到底擦不擦?”\n蔡和森也看不过了,劝说道:“刘俊卿,何必呢?回去自己擦一下算了嘛。”\n“关你什么事?要你多嘴!”\n子鹏也来打圆场:“算了算了,我借你手帕……”\n毛泽东一把拉过子鹏:,说“莫借给他,让他自己擦!还不得了啦!”\n“毛泽东,我可没想惹你啊!”刘俊卿觉得毛泽东真是多事。\n毛泽东偏偏就是个不怕事的主,把腰一挺,冲着刘俊卿嚷嚷道:“那又怎么样?”\n眼看几个学生要吵起架来了,中年人赶紧插话说:“算了算了,都是我惹出来的事,我擦干净,好不好?”他蹲下去,抓着衣袖来给刘俊卿擦鞋。\n“哎,我说,你何必……”毛泽东还想阻止,中年人却带着一脸息事宁人的笑,温和地说,“算了,不就是擦一下吗?擦干净就什么事都没有了。”\n他用衣袖擦着皮鞋上的污水,刘俊卿伸着脚,一动不动。毛泽东实在看不下去,向刘俊卿重重地哼了一声,转身就走。\n中年人直起身问刘俊卿:“你看看擦好了吗?”\n众目睽睽下,刘俊卿似乎也感到了自己有些过分,他放缓了口气:“算了吧,下次小心点。”\n走进大楼的毛泽东又回头瞪了外面的刘俊卿一眼,他刚往里走,迎面,却站着杨昌济。看看老师的目光停留在自己拎在手里的破鞋和另一只手上的书上,毛泽东不由得不好意思起来,低下了头。\n杨昌济问他:“又买了什么书?”\n“《西洋伦理学史论》。”\n“哦。”杨昌济接过书,翻了翻定价:“不便宜嘛!”\n“本来我是去买鞋的,路上经过书店,没注意就……”他解释不下去了,摸了摸脑袋。望着他,好一阵,杨昌济才把书递了回来,不动声色地说:“要上课了,别耽误了。”望着毛泽东光脚跑去的背影,杨昌济微微地点了点头。\n毛泽东进了综合大教室里,才坐好,就看见方维夏进来了。他上了讲台,扫视了一眼台下的全体新生,说:“各位同学,从今天起,大家将开始一门新的课程——教育学的学习。教育学是我们师范生的专业主课,也是学校非常重视的一门课程,为了开好这门课,学校专门聘请了长沙教育界著名的教育学权威——徐特立先生为大家授课。”\n台下的学生们精神一振,不少人小声议论了起来,徐特立的名字显然大家都听说过。方维夏继续说:“徐先生是长沙师范学校的校长,也是省议会副议长,能于教务与政务之百忙中接受聘请,为大家来授课,是我们第一师范的光荣,也是各位新同学的荣幸。下面,让我们用热烈的掌声,欢迎徐特立老师!”\n掌声如雷,人群中,刘俊卿更是从听到“副议长”的头衔起就激动得两眼放光。他鼓掌的手突然僵住了。从门外进来的,竟是方才在大门口为他擦鞋的那个中年“农民”,他的袖口上,还带着擦鞋留下的污印。\n“同学们,”徐特立走上讲台,声音洪亮,“你们都是师范生,以后呢,都将成为小学教师,教育学就是教大家怎么做一个合格的教师。今天,我不打算给大家讲课,课本上的知识,留待今后。现在我们一起去参观一次小学教育,以便大家对今后要从事的职业有一个直观的认识。参观之后,回校分组讨论,各写一份参观心得,这就是我们的第一课。好,全体起立,跟我出发。”\n他干净利落地说完,大步就往外走。学生们纷纷跟了上来,这样的教学方法显然让大家颇觉新鲜,毛泽东拍拍蔡和森:“哎,这老先生有点意思啊。”蔡和森微笑着点点头,落在最后的刘俊卿却脸色惨白。\n四 # 足球场上,一场球正踢得热火朝天。学生们的球技显然大都不怎么样,却吆喝喧天,一个个大汗淋漓,只有易永畦一个人坐在场边,看守着大家堆放在一起的衣服、鞋子。\n简易的木框球门前,毛泽东大张双手,正在守门。萧三一脚劲射,毛泽东腾空跃起,一脚将球踢开,他身手虽快,动作姿势却并不漂亮,摔了个仰面朝天。那只修补过的布鞋唰的又撕裂了,随着球一道飞出了场外。一片笑声中,易永畦赶着给毛泽东捡回了鞋,毛泽东却示意不必,他索性脱了另一只鞋,光脚投入了比赛。拿着毛泽东的鞋,易永畦仔细地端详起那个破口子。\n黄昏的余光透过八班寝室的窗户,照在一双单瘦苍白的手上,这双手正吃力地用针线缝补着毛泽东那只裂了口子的布鞋。透过厚厚的近视眼镜,易永畦的神情是那样专注。\n“砰砰砰”,平和的敲门声传来。“请进。”易永畦抬起头,突然一愣,赶紧站起身来。走进门来的,正是杨昌济,他打量了一眼空荡荡的寝室,问:“怎么,毛泽东不在吗?”\n“您找润之啊,他这会儿肯定在图书馆阅览室,他每天这个时候都去看书,不到关门不回来的。”\n“是吗?”杨昌济点了点头,目光落在了毛泽东床头那张已经泛了白的姓名条上,“这是他的床吧?”\n“对。”\n杨昌济审视着毛泽东的床和桌子,床上,是简单的蓝色土布被褥,靠墙架着的一块木板上重重叠叠堆着好几层书,把木板压成了深深的弓形,还有不少书凌乱地堆在床头床尾,整张床只剩了勉强可容身的一小半地方。桌子上,同样层层叠叠堆满了书和笔记本,到处是残留的蜡烛痕迹和斑斑墨迹。一张摆在桌面上的报纸吸引住了杨昌济的目光,这是一张《大公报》,报纸却显得特别小了一号,杨昌济拿起来一看,才发现报纸被齐着有字的部分裁过,天头地脚都不见了。\n“这是怎么回事?”杨昌济显然有些不解,“怎么把报纸裁成这样?”\n“哦,这是润之自己裁的。”\n杨昌济这才注意到床边的另一叠报纸,这些报纸同样裁去了天头地脚,每张报纸上却都钉着一叠写了字的小纸条,可以看出正是用报纸的天头地脚裁成的。\n易永畦解释着:“润之读报有个习惯,特别仔细,不管看到什么不懂的,哪怕是一个地名,一个词,只要以前不知道的,他都要马上查资料,记到这些裁下来的纸条上。所以呀,我们都叫他‘时事通’,反正不管什么时事问题,只要问他,没有不知道的。”\n翻着钉在报纸上的一张张小纸条,杨昌济问:“可是,裁报纸多麻烦!为什么不另外用纸记呢?”\n“那个,”易永畦犹豫了一下,“白纸六张就要一分钱……”\n“哦。”杨昌济明白了,他点了点头,目光落在了易永畦手中那只正在补的布鞋上,问,“这是他的鞋吧?”\n“对,润之他就这双鞋,早就不能穿了,他又不会补,我反正以前补过鞋……就是鞋太旧了,补好了只怕也穿不了几天。”\n拿过那只补了一半的鞋,杨昌济伸手大致量了一下长短,突然笑了:“嗬,这双脚可够大。”\n易永畦憨厚地笑着,他自己的脚上,那双布鞋同样打着补丁,旧得不成样了。\n杨昌济一路想着易永畦说毛泽东的话,来到一师阅览室时,天色已经暗下来了,他进去时却发现一枝蜡烛摆在桌上,并没有点燃,毛泽东正借着窗前残余的微弱光线在看书。他的面前,是摊开的辞典和笔墨文具,他不时停下来,翻阅资料,核对着书上的内容。\n杨昌济划燃了火柴,微笑着点燃了那支蜡烛,对毛泽东说:“光线这么差,不怕坏眼睛啊?”\n毛泽东一看是杨老师,想站起来,杨昌济拍着他的肩膀,示意他坐下继续看书。毛泽东看看老师刚刚给自己点燃的蜡烛,说:“我觉得还看得清,再说天真黑了,学校也会来电。”\n杨昌济拿起毛泽东面前那本书,看了一眼,正是《西洋伦理学史论》,问道: “你好像对伦理学很感兴趣?来,说说看。”\n毛泽东大胆地说:“世间万事,以伦理而始,家国天下,以伦理为系,我觉得要研究历史、政治及社会各门学科,首先就要掌握伦理学。”\n杨昌济翻着书,又问:“那,你对泡尔生说的这个二元论怎么看?”\n“泡尔生说,精神不灭,物质不灭。我觉得很有道理,精神和物质,本来就一回事,一而二,二而一,正如王阳明所言,心即理也。”\n“你再具体说说你的感想。”\n“对。世界之历史文明,本来就都存在于人的观念里头,没有人的观念,就没有这个世界。孟子的仁义内在,王阳明的心即理,和德国康德的心物一体,讲的都是这个道理。可谓古今中外,万理一源。”\n“你是在想问题,带着思索读书方能有收获。”杨昌济笑了,放下书,站起身来,说:“好了,你先看书吧,我不打搅你了。”走出两步,他又转头:“对了,明天下了课,记得到我办公室来一趟。”\n走出阅览室,杨昌济的脚步停在了门外。静静地凝视着里面那个专心致志的身影。秋风掠过,杨昌济拉紧了西服的前襟。他的目光落在了毛泽东的凳子下,那双光着的大脚上,只穿着一双草鞋,却似乎全未感觉到寒冷的存在。\n从阅览室回到寝室,毛泽东洗脚准备休息了,可他的大脚从洗脚的木盆里提了出来,擦着脚上的水,眼睛却始终没有离开面前的书。一双手无声地移开了木盆旁的草鞋,将那双补好了的布鞋摆在了原处。毛泽东的脚落在鞋上,才发现感觉不对,一抬头,眼前是易永畦憨厚的笑容。毛泽东拿起鞋一看,愣住了。易永畦微笑着,向他点了点头,轻轻退回了自己的铺位。烛光下,凝视着重新补好的鞋,毛泽东一时间也不知是什么心情。\n第二天,在办公室里,杨昌济把厚厚的一大本手稿放在毛泽东面前,对他说:“昨天我看见你读那本《西洋伦理学史论》,那本是德文原著,蔡元培先生由日文转译而来,一则提纲挈领,比较简单;二则屡经转译,原意总不免打了折扣。我这里正好也译了一本《西洋伦理学史》,是由德文直接译过来的,你如果有兴趣,可以借给你看看。”\n毛泽东喜出望外:“真的?那……那太谢谢老师了!”\n“这可是手稿,只此一份,上海那边还等着凭此出书,你可要小心保管,要是丢了,我的书可就出不成了。”\n“您放心,弄丢一页,您砍我的脑壳!”\n毛泽东抱着书稿站起身,正要出门,却又听到杨昌济在喊他:“等一下。”然后,把两双崭新的布鞋递到了毛泽东面前。\n“我可不知道你的脚到底多大,只是估摸着买的。你这个个子,这鞋还真不好买。”\n拿着鞋,毛泽东一时真不知说什么好。他突然深深给杨昌济鞠了一躬:“谢谢老师!”\n第七章 修学储能 # 一个年轻人走进学校的目的是什么?是学习知识,更是储备能力。孔子曰:‘ 质胜文则野,文胜质则史。’ 就是说,一个人如果光是能力素质强,而学问修养不够,则必无法约束自己,本身的能力反而成了一种野性破坏之力;反过来,光是注重书本学问,却缺乏实际能力的培养,那知识也就成了死知识,学问也就成了伪学问,其人必死板呆滞,毫无价值。修学与储能,必须平衡发展,这是求学之路上不可或缺的两个方面。\n一 # “空山新雨后,天气晚来秋。”站在一师大门口,一身日本式的文官装束的纪墨鸿,打量着一师院内还带着雨水的参天大树、翠绿草坪,感慨颇多,“城南旧院,果然千年文华凝聚之地,气度不凡啊。”\n孔昭绶和方维夏、黎锦熙等人陪在他的身边,听到他的这番感慨,礼貌地说:“纪督学客气了。督学大人代表省府,莅临视察,故我一师蓬荜生辉。”三人一路寒暄,向校内走来。\n综合大教室里,人声鼎沸,一片热闹,学生们各自扎成一堆,热烈地讨论着,许多凳子都被抽乱,组成了一个个小组,连徐特立也挤在毛泽东这组学生中,和学生争辩着。教室门口,纪墨鸿望着眼前乱糟糟的样子,眉头拧得紧紧的,一副看不下去的样子。孔昭绶感觉到了他的不满,走上讲台,提高了嗓子大声说:“各位同学,请安静。特立先生,介绍一下,这位是省教育司派来的督学纪墨鸿先生,今天前来视察一师。”\n学生们这才发现校长等人来了,赶紧安静下来,各自坐好。不等徐特立开口,纪墨鸿抢先拱手作揖:“哎哟,是徐议长啊,久仰久仰。”\n徐特立淡淡地说:“纪督学客气了,这里没有什么徐议长,只有教书匠老徐。”孔昭绶问:“纪督学,既然来了,是不是给学生们训个话?”\n纪墨鸿赶紧摇手:“有徐议长在,哪容得卑职开口?”徐特立说:“在这里,我是老师,你是督学,督学训话,职责所在嘛!”\n“纪督学,您就不用客气了。”孔校长宣布:“各位同学,今天,省教育司督学纪墨鸿先生光临本校视察,下面,我们欢迎纪督学为大家训话。”\n他带头鼓起掌,掌声中,纪墨鸿一脸的迫不得已,向徐特立赔了个谦恭笑脸,这才整整衣冠,上了讲台。\n“各位青年才俊,在下纪墨鸿,墨者,翰墨飘香之墨,鸿者,鸿飞九天之鸿。墨鸿今日能与诸位才俊共聚一堂,深感荣幸。所谓训话二字,愧不敢当,不过借此机会,与诸位做个读书人之间的交流而已。这个读书二字,是世间最最可贵的了,何以这么说啊?书,它不是人人读得的,蠢人就读不得,只有聪明人才读得书进。所以这世上的读书人,都是聪明人,列位就是聪明人嘛……”\n台下,萧三忍不住跟毛泽东嘀咕了一句:“他不如照直讲,他这个人最聪明。”毛泽东一笑,他显然对这番话也极不以为然。\n“古人云:书中自有颜如玉,书中自有黄金屋。读了书,人自然就有大好前程,不然还读什么书呢?”纪墨鸿说得兴致勃勃,“所以,孔子曰:学而优则仕。就是说书读好了,政府才会请你去做官,你也才能出人头地,做个人上人啊!当然了,我不是说只有当官才有前途,打个比方,打个比方而已,但道理就是这个道理。”\n一下午无精打采的刘俊卿这时听得聚精会神,眼睛都望直了。蔡和森、萧子升等人却都露出了听不下去的神情,毛泽东则索性抽出一本书,翻了起来。\n“总之一句话,学生就要以学为本,好好读书,认真读书,不要去关心那些不该你关心的事,不要去浪费时间空口扯白话,多抽些时间读点书是正经。以后,你就会晓得,那才是你的前途,那才是你的饭碗。纪某是过来人,这番话,句句是肺腑之言,不知各位听到心里去没有?”\n台下,鸦雀无声中,突然传来了很清晰的一声翻书声——毛泽东哗啦翻过一页书,看得旁若无人。纪墨鸿不禁一阵尴尬,面露愠色。孔昭绶也愣了一下,一时又不好提醒毛泽东,不知如何是好,场面一时尴尬起来。安静中,刘俊卿突然带头鼓起掌来,这一下总算带起了一些掌声。纪墨鸿的尴尬总算有了下台的机会,僵住的笑容渐渐绽开。“嘿嘿,多谢,多谢多谢。”他团团抱拳,留意地看了为他解围的刘俊卿一眼。\n送走纪墨鸿,黎锦熙来到校长室,仰头喝了一大口水,长吐了一口气:“唉呀,总算是走了。”“总算?”方维夏苦笑了一下,“人家可没说以后不来了。”\n办公桌后,孔昭绶神情疲惫,他揉着自己的眉心,强打精神说:“维夏、锦熙,你们两个安排一下,尽快把这间校长室腾出来,再买几件像样的家具。还有,做一块督学办公室的牌子,记住,比校长室的这块要大。”\n黎锦熙愣住了:“校长,您还真给他腾办公室?”“全校就我这间大一点嘛。我无所谓,随便换间小的就是。”\n方维夏不解地问:“校长,他纪墨鸿不过是个督学,帮办督察而已,又不算什么真正的上司,不至于吧?”\n“这不是官大官小的问题,有的人哪,只要还能管到你一点……”孔昭绶没有继续往下说,只摆了摆手,“就这么办吧。”方维夏、黎锦熙无奈地看了一眼。\n二 # 因为上次“鼓掌解危”时,纪墨鸿曾刻意用嘉许的目光多看了刘俊卿几眼, 所以,几天后,一听说纪墨鸿搬进了督学办公室,敏锐的刘俊卿立即将自己精心写的一篇心得呈交了上去。\n纪墨鸿看了文章,微笑着说:“嗯,文章写得不错嘛。你怎么会想起写这篇心得给我呀?”\n刘俊卿毕恭毕敬地回答:“上次听了督学大人的教诲,学生激动得一晚上都没睡着觉。只有好好读书,才有大好前程,这个道理,从来没有人像大人说得那么透彻,真是句句说到学生的心里去了,学生有感而发,故此写了这篇心得,聊表对大人的高山仰止之意。”\n纪墨鸿满意地点了点头,亲切地说:“好了好了,你也别张口大人闭口大人的,这里是学校,纪某也是读书人,没有那么多官架子,你以后,就叫我老师吧。以后有空,多到我这儿坐坐。我呀,就喜欢跟你这样聪明上进的学生打交道。”\n刘俊卿低声唱着歌激动地从督学室内出来,一下子觉得整个身心从没有这样轻松过,头顶的天空也从来没有这样辽阔过。他一扫往日的沉郁,中午放学后,与子鹏有说有笑地结伴去食堂。食堂里,人流来往,喧闹非常,墙上木牌上仍然是老几样:茄子、南瓜、白菜……最好的不过是骨头汤。他俩一进去,就看见徐特立一身布衫草鞋,端着个大碗,排在一列学生队伍的最后面。刘俊卿一捅子鹏,夸张地说:“哎,看看看,徐大叫花又来了。”\n子鹏拉了拉他,低声说:“你怎么这么叫老师?”“都这么叫,又不是我一个人。本来嘛,教员食堂一餐才一毛钱,他都舍不得去,天天到这里吃不要钱的,不是叫花是什么?” 俊卿哼一哼说。\n两人打了饭菜坐下来。刘俊卿用筷子拨着碗里的饭菜,一脸不满地抱怨:“搞什么?天天就这点萝卜白菜!”子鹏苦笑着说:“味道是差了点。”\n“差了点?简直就是猪食!”刘俊卿说着把筷子一撂,抬眼看其他同学:食堂里,年轻人的胃口个个好得惊人,一桌桌学生都大口大口吃得正带劲。与学生们一桌吃饭的徐特立刮尽了碗里的饭,起身到开水桶前,接了半碗开水,涮涮碗,一仰脖喝下去,抹抹嘴,一副心满意足的样子。刘俊卿咽了一口唾液,站起身来说,“我去打两杯水过来。”\n这时秀秀忽然提着食盒进来了。她站在门口满食堂四处张望,一时见到王子鹏了,快步走过来,打开食盒,边取出里面的菜边对少爷说:“太太怕您吃不惯学校的伙食,叫我做了几样您爱吃的菜送过来。”\n“哇!阿秀,谢谢你了。”子鹏一看几乎要流口水了。\n端着两杯开水的刘俊卿猛然看见妹妹,手一抖,滚烫的开水抖了出来,烫得他一弹。子鹏赶紧接过开水,捧着俊卿的手吹气。“没事没事……水不烫。”紧张中,刘俊卿目光闪烁,瞟了一眼秀秀,又赶紧躲开她的目光。一个“哥”字都到了嘴边的秀秀硬生生地收住了口,她从哥哥的表情上看出,他不希望自己在这样的场合招呼他。\n子鹏掏手帕擦净了俊卿手上的水,说:“阿秀,这是我同学,刘俊卿,跟你同姓呢。俊卿,这是阿秀,在我家做事的。”\n迎着秀秀的目光,刘俊卿挤了个笑容,低下头。子鹏却请刘俊卿和他一起分享家里带来的美食,刘俊卿答应着,仿佛为着躲开妹妹,他端起桌上那两碗学校供应的饭菜,逃也似的向潲水桶走去,哗啦一下,两碗饭菜被他倒进了潲水桶。\n几个同学看见,诧异地看着刘俊卿,蔡和森一皱眉,忍不住站起,但想想又坐下了。秀秀的身子不禁微微一颤,跟子鹏说了一声送晚饭的时候再来收碗,就转身出去了。食堂外,回头远远地望着哥哥正和少爷一起吃饭的背影,哥哥脚上闪亮得刺眼的新皮鞋,两行眼泪从秀秀的脸上滑了下来。\n吃过了饭,学生们纷纷回教室,杨昌济正在那里准备教案,这时毛泽东捧着那本手稿,送到了他面前。杨昌济看看面前的手稿,再看看毛泽东,没有伸手接,却微微皱起了眉头。他沉吟了一下,说道:“润之,有句话,看来我得提醒你才行,读书切忌粗枝大叶,囫囵吞枣,这么厚的书,这么几天时间,你就看完了?这书中的精义,你难道都掌握了?”\n“老师,您误会了,这本书我还没来得及认真看呢。”\n杨昌济有点不高兴了,失望地说:“还没认真看?那你就还给我?这本书不值得你看吗?”\n“不是,书太好了,我才看了几页,就觉得太短的时间根本读不透书里面的内容,老师这部手稿又等着出书要用,所以……所以我抄了一份,打算留着慢慢消化。”\n“你抄了一份?”杨昌济眼都直了,“十几万字,一个礼拜,你抄了一份?”\n毛泽东点了点头。原来,就在杨昌济借书给毛泽东的那天下午放学后,毛泽东便跑去文具店花了他仅有的四毛八分钱,买回一大堆白纸和一块没有包装的低档墨,利用晚上寝室熄灯后,借着烛光往白纸订成的本子上抄录杨昌济的手稿。\n杨昌济显然还有些难以相信:“把你抄的给我看看。”\n厚厚几大本手抄本摆上了毛泽东的课桌,杨昌济翻阅着抄本,整整七本用白纸简单装订的手抄本上,字迹虽有些潦草,却是密密麻麻,一字不漏。他看看毛泽东,眼前的学生带着黑眼圈,精神却看不出一点疲倦。杨昌济又翻开了摆在旁边的“讲堂录”,看到笔记本上,同样是密密麻麻的潦草的字迹,上面还加着圆圈、三角、横线等各种不同的符号,旁边见缝插针,批满了蝇头小楷的批语。他惊讶地问:“这是你的课堂笔记?所有的课都记得这么详细?”\n毛泽东回答说:“一般社会学科的课我都记。”\n“怎么还分大字小字,还有那么多符号?”\n“大字是上课记的,小字是下课以后重新读笔记的心得,那些符号有的是重点,有的是疑义,有的是表示要进一步查阅……反正各有各的意思。”\n杨昌济点了点头:“你很舍得动笔啊。”\n“徐老师说过,不动笔墨不看书嘛,我习惯了,看书不记笔记,我总觉得好像没看一样。”\n杨昌济放下了讲堂录,看着毛泽东,似乎想说什么,却又没说出来。他抱起手稿和自己的备课资料,走出一步,又回头:“对了,礼拜六下午你好像只有一节课吧?如果你愿意,以后礼拜六下了课,可以到我这儿来,只要是你感兴趣的内容,我给你做课外辅导。”\n毛泽东问:“礼拜六您不是没有一师的课吗?”杨昌济笑着说:“以后有了,你的课。”\n三 # 一样的周末,因为不一样的心境,这些同学少年各自品味着属于他们的青春滋味。\n下午上完最后一节课,蔡和森归心似箭,回到了湘江西畔的溁湾镇刘家台子:“妈,我回来了。”\n正在吃饭的蔡畅蹦了起来:“哥。”\n葛健豪几乎是下意识地想盖住破木桌上的东西,然而蔡和森已经来到桌前,葛健豪的手又缩了回来。桌子上,是两碗几乎看不见米的稀粥,和两块黑糊糊的饼子。看看母亲和哥哥的神情,蔡畅也反应过来,拿着半块黑饼子的手藏向身后,但蔡和森已抓住她的手,将饼子拿了过来。他掰开饼子,碎糠渣子洒落在桌上。把那半块糠饼捏在手里,蔡和森坐在门边的石阶上,他慢慢地掰着,一口口细细地嚼着,嚼着。蔡畅蹲在他的身边,有些不安地观察着他的表情:“哥,其实——糠饼子也挺好吃的,嚼久了,还有一股米饭比不上的清香呢。”\n蔡和森没吭声,又掰了一块糠饼,放进口中。\n“哥,你别这样了。火柴厂关门了,我和妈会找别的事做,我们不会总吃这个的。”懂事的蔡畅抱住了哥哥的膝盖,安慰哥哥说,似乎整天吃这饼的是蔡和森而不是她和妈妈。\n“我知道。我只是想尝尝,尝尝这股清香而已。”蔡和森微笑着,抚了抚妹妹的头,“进屋睡吧,哥想一个人坐一会儿。”\n蔡畅犹豫着站起身,看看哥哥,悄悄回房间去了。\n残月当空,从乌云中探出,洒下浅浅的月光。蔡和森仰望着月亮,长长地吸了一口气,站起身来,走到墙角,掀开破草席。那只擦鞋的工具箱还静静躺在里面,蔡和森抹去箱子上的灰尘,清理着一件件擦鞋的工具。他抖了抖那块抛光的绒布,仿佛是在试探自己的手艺是否还熟练。\n一只手无声地按在他的肩膀上,蔡和森猛回头,看到妈妈温暖而平静的目光正直视着自己。沉默中,葛健豪蹲下身子,接过绒布,抹去了剩下两件工具上的灰尘。 “周末,其他时间不行。”关上鞋箱,站起身,葛健豪看着儿子的笑脸,理了理儿子的头发,说,“没有什么坎是人迈不过去的,只要我们一家人在一起,再难,天塌不下来。”\n蔡和森用力点了点头。月光下,葛健豪抚着儿子的头,突然抱住儿子,在他额上亲了一下。\n第二天上午,蔡和森背着擦皮鞋的箱子出了门。\n而在周南中学的寝室里,斯咏正专心致志地在一本书扉页上题字。警予轻手轻脚地从后面摸上来,摸到斯咏身后,大喝一声:“写什么呢?”\n“吓死我了,干什么你?”斯咏吓了一跳,一把盖住书。\n“看你写得那么认真,过来参观一下啰。写什么好东西,还遮着盖着?”\n斯咏把书推了过来,警予一看,那是一本《伦理学原理》,书的扉页上写的是“嘤其鸣矣,求其友声”。\n“嘤其鸣矣,求其友声?哎,你平时不是最烦《诗经》吗,怎么还抄这个?不就是有只鸟在叽叽喳、叽叽喳,想找只笨鸟跟它一块叫吗?很平常啊。呵呵,不会是有谁想跟你一块叫吧?”\n斯咏不再理睬警予,把头埋在书里了。警予看看她,三下两下、干净利落地收拾起自己的书包,蹬蹬蹬一个人出了门。\n“擦鞋吗,先生?又快又好……”蔡和森坐在街边擦鞋摊前,招揽着生意。远远的,一个正好经过的靓丽身影听到这熟悉的声音,走过来,停在了他的身边。蔡和森一抬头,站在面前的,居然是笑嘻嘻的向警予。\n蔡和森愣了一下,才认出她来:“嗨,是你啊。”\n“老远就看到是你。又在摆摊呢?哎,对了,上次你去考了一师吗?”\n蔡和森笑了笑,说:“考了。”\n“没考上?”\n“考上了。”\n“考上了?那你怎么还……”\n“擦皮鞋是吧?没钱就来擦啰。”\n“哦!勤工俭学。佩服佩服。”\n“这有什么好佩服的?人要吃饭嘛。”\n“话不是这么说,现在哪个学生拉得下面子干这个?只要考进个学校,一个个都好像上了天,恨不得把自己当文曲星供起来。像你这样的,我还真是第一次看见呢。”她在蔡和森身边蹲了下来,撑着下巴,盯着蔡和森:“嗯,我呢,今天出来给家里寄信。现在信也寄了,回去呢,也没别的事。所以呢……”\n蔡和森见她吞吞吐吐的样子,问:“你到底想说什么?”\n警予不容他回绝地说:“你教我擦皮鞋!”\n“哎!擦鞋擦鞋,擦皮鞋啰……”\n警予敲打着鞋刷子,扯开嗓子吆喝着。路人们纷纷侧目——这么漂亮而穿着高档的小姐居然吆喝这个,着实令人吃惊。连蔡和森都觉得有点不自然了,他推了推警予让她小声点,提醒她说别人都在看她呢。警予却敲得更起劲了,声称做生意嘛,就是要招人看呀。继续用更大的声音吆喝着:“来来来,哪位擦皮鞋?”\n一个男人挤了上来问:“哎,你们俩谁擦皮鞋啊?”\n警予:“他是师傅,我是徒弟,你想要师傅擦还是要徒弟擦?”\n“徒弟,就徒弟。”\n“那请坐吧!”\n男人兴高采烈地坐了下来,警予抄起工具就要动手,又抬头看看客人,说:“我刚学的,擦得不好别怪我啊!”\n男人忙不迭地答道:“不怪不怪。”\n看到警予的功夫还不错,人群一阵议论纷纷,好几个男人也挤了上来:“我也擦……我也擦……”\n一拨客人过后,两人哗啦哗啦地数着铜钱,才发现自己真是“发财”了。趁着没有客人,两个人坐在街边,说起上次报考一师的事情,警予问:“你们第一师范跟你一批考进去的,有个叫蔡和森的,你认识吗?”\n蔡和森不禁一愣:“你打听他干嘛?”\n“我看过他的入学作文,我们老师当范文发给我们的。怎么写得那么好,真是气死我了。”\n“他写得好你也生气啊?”蔡和森简直哭笑不得,“有那么严重吗?我看他很一般呀!”\n“写得也太好了一点嘛!我一直觉得自己作文好,跟他一比,人生都一片黑暗了。”警予容不得人家说蔡和森一般,“去,不识货!就他的文章,全长沙的学生,没人比得上,包括我。我想不通的就是这一点,我怎么就比不上他呢?未必他三头六臂啊?”\n蔡和森暗自笑了,随口说:“三头六臂?肯定没有,他嘛,也跟我差不多,一副穷样。”\n“我现在呀,把他那篇文章贴在我床头,每天起来第一件事,就是冲着那篇文章大喊一声:”姓蔡的,你等着瞧,我向警予总有一天要强过你!到时候,我就拿我的文章去找你,让你挖个地缝自己钻进去!‘“想想,她又叹了口气,说:”唉,也就是说说而已,真想赶上他,不知猴年马月喽!“\n“我看没问题,凭你这股倔劲,那姓蔡的肯定兔子尾巴长不了。”\n“对,总会有那一天。”警予看看天,突然想起斯咏,转头对蔡和森说,“哎,我得走了,再见……喂,我说的话,你可别告诉那个蔡和森啊!,”\n“你放心,我是肯定不会告诉第二个人的。”蔡和森望着警予风风火火离去的背影,笑着自言自语了一句,“向警予。”\n四 # 茶叶店里,赵一贞正捧着一本英文小说在读。阳光斜照,映着她柔美而清纯的脸。她眉头轻蹙,读得很入神,也显然很吃力。柜台前,传来了刘俊卿轻微的咳嗽声,赵一贞一抬头,正碰上刘俊卿的目光,一阵紧张,她有些慌乱地低下了头。\n刘俊卿同样也很紧张,他用有些干涩的声音说:“我,买点茶叶。”\n赵一贞低着头问:“要什么茶?”\n“嗯,”刘俊卿的心思当然并不在茶叶上,他随手一指,说:“就这个吧。”\n“您要多少?”\n“半斤吧。”\n赵一贞放下书,取茶叶,过秤。刘俊卿的目光追随着她,见一贞回头,他又掩饰着侧开头,装着在看那本放在柜台上的书,那是一本英文版的《少年维特之烦恼》。\n“你在看这本书啊?”\n赵一贞笑了笑,小声说:“看不太懂。”\n“什么地方看不懂?”\n赵一贞:“我英文差,一开始就看不太懂。”\n刘俊卿打开扉页,指着《卷首诗》问:“是这儿吗?”\n赵一贞点了一下头。\n“这是卷首诗,标题是《绿蒂与维特》。这两句是说:哪个少年不多情,哪个少女不怀春。”\n“哎呀!”一贞的手一抖,茶叶哗啦撒了一柜台,吓了她一跳。刘俊卿赶紧帮忙挡着,却正好抓住了一贞的手。一贞的脸绯红了,她赶紧把手抽了回来,小声说,“对不起啊,我……我给你另外换半斤。”\n“不用了,收拾起来是一样的。这样吧,你来扫,我接着。”\n他双手合拢,靠住柜台。一贞涨红了脸,扫拢茶叶,茶叶落在了刘俊卿手上,这一刻,两个人凑得那么近,几乎都能感觉到对方的呼吸。一贞的眼睛,头一次没有躲避刘俊卿火热的目光。\n比较起蔡家,刘家的日子却要好多了。摆在刘俊卿面前的除了一碗盛好的饭,还有几样菜,分量虽少,却既有肉,也有鱼。按刘三爹的意思,儿子吃了一个礼拜学校食堂,回了家还不吃点好的?\n看儿子有滋有味地吃着自己亲手做的菜,刘三爹打开儿子带回来的布包袱,将里面乱皱皱塞成一团的脏衣服、脏袜子倒进了木盆,吃力地端起木盆,走出布帘,伴着剧烈的咳嗽声,给儿子洗衣服。\n吃过饭,在父亲的咳嗽声中,刘俊卿不耐烦地挑亮了油灯,开始写字。他的面前是摊开的一本英文版《少年维特之烦恼》,和一张精致的描红信笺,信笺上是那首即将写完的《绿蒂与维特》的译文,字迹工整清秀,一丝不苟。听听门外总算安静了,他又提笔开始往下写,然而,刚写了一个字,更猛烈的咳嗽声又响了起来。刘俊卿烦得把笔一摔,拉开了门。月光下,刘三爹拼命抑制着咳嗽,提着一条洗好的裤子站起身来,腰却一阵发僵,他艰难地扶着腰站起,往绳子上晾衣服。本来一脸脾气的刘俊卿不由得站住了,他正想退回房里,却又站住了,轻声说:“爸,你不舒服,就早点休息吧,别太累着了。”\n一刹那,刘三爹张大了嘴,儿子少有的关怀令他整个人都呆了,他激动得嘴角直抖。两行老泪从刘三爹的脸上滑了下来,巨大的激动和喜悦几乎令他难以自持,提着衣服的手都在抖个不停。他用力擦去眼泪,一抖衣服,晾上了绳子。\n第二天一早,赵家茶叶店里,一贞送走了一名买茶的顾客,拿起抹布擦着柜台,突然看到一双熟悉的皮鞋站到了柜台前。一瞬间,一贞一阵紧张,涨红着脸,不敢抬头。刘俊卿把一张折得方方正正的描红信笺从柜台上推了过去。一贞犹豫着,伸出手正要去接,赵老板端着一盘茶叶,一掀门帘,走了出来。赵一贞吓得手一缩,赶紧转身叫了声“爸爸”。\n柜台上,那张信笺刷的一下被刘俊卿收了回去。\n赵老板吩咐女儿把指定的货分一下,回头看到刘俊卿,问:“这位先生,买茶吗?”\n刘俊卿一时间不知道说什么,逃也似地跑开了。\n“这小伙子,慌什么张啊?”赵老板看着刘俊卿,他突然回头看了一眼女儿,赵一贞干着活,头也没抬。\n趁着父亲背过身清理着钱箱里的钱,一贞抬起头,看到远处的拐角,刘俊卿正躲躲闪闪地探着头,向她打着手势。一贞一时不明白他的意思,顺着他手指的方向找了一阵,才发现算盘下正压着那张信笺。\n深夜,如水的月光透过窗楹,洒在那张描红信笺上。\n赵一贞痴痴地端详着信笺,信笺上,是那首卷首诗,下面写着“省立第一师范 刘俊卿赠”。\n五 # 毛泽东在当天下午放学后,如约到了杨昌济家。\n杨宅门前,“板仓杨”的门牌静静地挂在大门一侧,杨宅院内,兰花青翠,藤蔓攀墙,点点阳光透过树阴,洒在落叶片片的地上。探头打量着这宁静雅致的小院,毛泽东长长呼吸了一口清新的口气。\n“进来吧。”杨昌济推开了书房的门。\n带着几分崇敬,毛泽东跟在他身后,向里走去。书桌上,铺着一张雪白的纸,写着苍劲有力的四个大字:修学储能。\n“修学储能,这就是今天的第一课,也是我这个老师对你这个弟子提出的学习目标。”杨昌济放下笔,面对毛泽东坐了下来,说,“润之,一个年轻人走进学校的目的是什么?是学习知识,更是储备能力。孔子曰:”质胜文则野,文胜质则史。‘就是说,一个人如果光是能力素质强,而学问修养不够,则必无法约束自己,本身的能力反而成了一种野性破坏之力;反过来,光是注重书本学问,却缺乏实际能力的培养,那知识也就成了死知识,学问也就成了伪学问,其人必死板呆滞,毫无价值。所以,我今天送给你这四个字,就是要让你牢牢记住,修学与储能,必须平衡发展,这是你求学之路上不可或缺的两个方面。“\n毛泽东问:“那,以今日之我而言,应当以修什么学问,储哪种能力为先呢?”\n“什么学问?哪种能力?润之,你这种想法首先就是错的。今时今日之毛润之是什么人?一个师范学校一年级学生而已。你喜欢哲学伦理,也关心时事社会,这是兴趣,也是天赋,但我同时也担心你走入另一个误区,那就是于学问能力的涉猎之面太窄!润之,你的求学之路才刚刚起步,你才掌握了多少知识?才拥有多少能力?过早地框死了自己修学储能的范围,而不广泛学习,多方涉猎,于你的今后是有百弊而无一利的。所以,你现在的修学储能后面,还应该加上四个字:先博后渊。”\n毛泽东思索着,认真地点了点头:“我明白了,博采众长才能相互印证,固步自封则必粗陋浅薄。”\n杨昌济笑了,他为毛泽东有这样的悟性而感到非常欣慰。在谈到儒家三纲之说时,杨昌济喝了口茶,说:“儒家三纲之说,确属陈腐之论,船山先生的‘忠孝非以奉君亲,而但自践其身心之则’之说,于此即为明论。”\n记着笔记的毛泽东停下笔,插话道:“我觉得这种说法,其实是在提倡个人独立精神。”\n“对,个人独立。你看过谭嗣同的《仁学》吗?《仁学》对此就作了进一步阐发,它认为个人独立奋斗,是一个人成功的关键,即父子兄弟,亦无可依赖。而我以为,个人奋斗的宗旨,就在于两条原则。”他接过毛泽东手中的笔,在两张纸上各写了一个字:坚、忍。“坚者如磐石,虽岁月交替而不变,忍者如柔练,虽困苦艰辛而不摧。坚忍者,刚柔并济,百折不回,持之以恒也……”\n“口当……口当……”墙上挂钟恰在这时响了,毛泽东看看窗外的夜色,赶紧站起身:“哎哟!都这么晚了?老师,真是对不起,打搅您到这个时候,要不,我先回去了。”\n杨昌济伸展了一下胳膊,看来也是有些疲倦了,却意犹未尽地对毛泽东说:“清谈不觉迟,恍然过三更啊。算了,这么晚了,学校也早锁门了,我看,你就住这儿吧,反正我的家眷都回了乡下,房子空着也是空着。明天早上再走吧。”\n第二天早上,晨曦一缕,悄然抹亮了天际。 “板仓杨”的门牌映着初起的晨光,散发着古拙质朴。清晨的宁静中,一阵水流声传进了杨宅客房。毛泽东迷迷糊糊地睁开了眼,披着外衣,揉着惺忪的睡眼推开了门。他突然愣住了:就在眼前,小院的井边,杨昌济裸着身体,只穿着短裤和一双日本式的木屐,正在用冷水进行晨浴。光洁强健的脊背上,清水纵横,水流顺着身体,直淌到地上。一只木勺从木桶里舀起满满一勺水,冰凉的井水兜头浇下……他的神情肃穆,动作庄严,一吐一纳,仿佛正在进行某项庄严的仪式。似乎是感觉到了身后有人,杨昌济回过头来,看到毛泽东疑惑的眼神,他拿起井栏边的浴巾,擦着身上的水,说:“我在晨浴。几十年的老习惯了,清晨即起,以井水浴我肉体,然后晨诵半小时,以圣贤之言浴我精神,是以精神肉体,清清爽爽,方得全新之我,迎接新的一天嘛!”\n毛泽东伸手探了探水桶中残余的水,深秋之晨冰凉的井水,刺得他手一缩,问道: “老师,您不冷吗?”\n“一个人的修学之路上,比冷水更难熬、更严酷者不知有多少,若是连一点寒冷都受不了,还谈什么坚忍不拔?再说,读书人静坐过多,缺乏运动,这也是强健体魄的最好方式嘛!”杨昌济将浴巾往肩上一搭,在院中树下一块石头上盘腿坐下,拿起了手边的一本书,“哦,对了,我没有吃早饭的习惯,就不管你的饭了,你自便。我要晨诵了。”\n仿佛是在净化自己的心灵,杨昌济闭目长长呼吸了一口气,这才朗声:“杨昌济,光阴易逝,汝当惜之。先贤至理,汝当常忆……”随后,他打开书,端坐凝神,大声诵读起来,“子曰:学而时习之,不亦说乎?有朋自远方来,不亦乐乎?人不知而不愠,不亦君子乎……”\n渐渐明朗的晨光中,杨昌济读得如此旁若无人,那琅琅书声,仿佛天籁般充满了这雅致的小院。望着井边的木桶,望着晨光中静若雕塑的老师,听着那清澈得犹如回旋在天地之间的读书声,毛泽东几乎都痴了。\n随即他回到客房,一张“自订作息表”上,从清晨直到半夜,一个个时段,一项项安排,密密麻麻,开列详细。从此,这张作息表贴在毛泽东寝室的床头,一直伴随他读完一师。\n第八章 俭朴为修身之本 # 人,之所以为人,正是因为人有理想,有信念,懂得崇高与纯洁的意义,假如眼中只有利益与私欲,那人与只会满足于物欲的动物又有何分别?林文忠公有言:壁立千仞,无欲则刚。我若相信崇高,崇高自与我同在。而区区人言冷暖,物欲得失,与之相比,又渺小得何值一提呢?\n一 # 微弱的晨曦,刚刚将夜的天际稍稍染淡。一师的欧式教学楼还笼罩在一片黎明之前的深邃寂静之中。黑暗宁静的寝室里,交织着同学们不同的鼾声。毛泽东一个人轻手轻脚地下了床,他来到一师水井边,将满满一桶井水提出了井沿,脱掉衣服,全身只剩了一条短裤。深秋的晨风袭来,吹得高大的樟树哗哗作响,赤裸的毛泽东忍不住打了个寒战。他探了探冰凉冰凉的水温,用力深深呼吸了几口,仿佛是为自己壮胆,他狠狠一拍胸膛,撩起桶里的水,浇在胸膛上。顿时,他冷得全身一缩,倒吸了一口凉气。但咬咬牙,他一下接一下撩起水,浇在身上。然后用毛巾起劲地在透湿的身体上狠狠擦着……由慢而快,由冷而热,他体会着,他渴求着,他的呼吸交织着水花,他的脸上渐渐展开了笑容……猛地,他举起木桶,将半桶水兜头浇下。\n“爽快啊!”微起的晨曦中,他压抑不住的兴奋的声音回荡在树梢林间、秋风深处。\n接下来,他学着老师,大声诵道:“……呜呼!我中国其果老大矣乎?立乎今日,以指畴昔,唐虞三代,若何之郅治;秦皇汉武,若何之雄杰……”晨曦之中,宁静的一师校园里,毛泽东捧着一本《饮冰室文集》,正聚精会神地读着梁启超的《少年中国说》:“……梁启超曰,造成今日之老大中国者,则中国老朽之冤业也;制出将来之少年中国者,则中国少年之责任也……”\n“当啷、当啷……”校役摇晃着铜铃,起床铃声清脆地响满了寝室走廊。一间间寝室里,一顶顶蚊帐中,一个个学生打着哈欠,爬起床来。远远地,毛泽东的晨诵声正清晰地传来:“……故今日之责任,不在他人,而全在我少年。少年智则国智,少年富则国富,少年强则国强……”子升、萧三、张昆弟、罗学瓒……一个个同学奇怪地打开了房门,他们看到毛泽东端坐草坪的身影映着初升的朝阳,他的晨诵声如此清朗,盖过了一切铃声与起床的喧闹。\n“……少年独立则国独立,少年自由则国自由,少年进步则国进步,少年胜于欧洲则国胜于欧洲,少年雄于地球则国雄于地球……”晨诵声中,两个年轻的身影停在了毛泽东的身后,两个声音与他的声音汇成了一体。毛泽东一回头,原来是蔡和森和子升来到了他的身边,正加入他的诵读。三个人目光相对,会心一笑。毛泽东提高了声音,“……红日初升,其道大光;河出伏流,一泻汪洋;潜龙腾渊,鳞爪飞扬;乳虎啸谷,百兽震惶……”\n一双双脚步悄悄汇集,张昆弟、罗学瓒、萧三、李维汉、周世钊……一个个同学犹如被巨大的磁铁所吸引,不断聚集到毛泽东的身后,晨诵之声,越汇越响。那充满朝气、青春昂扬的晨诵声汇成了巨大的声浪,回荡在整个一师的上空,仿佛正呼唤一个崭新的开始,仿佛正向整个世界宣布着同学少年们青春的誓言。\n二 # 上午的课在综合大教室上。黑板上板书着“教师之职责与地位”的标题,台下,学生们不像往常面向讲台,而是面对面坐成了两个阵营,中间空出一片,相对摆了两把空椅子,整个教室布置得好像一个辩论场。\n徐特立草鞋布衫,一如往常,“这次的课堂心得,有一位同学表现不俗,不但论述详尽,有理有据,而且由此而阐发,对教师的职责与地位怎样确立,提出了自己独到的看法,那就是本科八班的刘俊卿同学。有趣的是,另有一位同学,这次的心得同样出类拔萃,而且观点正好与刘同学的相反,那就是本科六班的蔡和森同学。那么,两位同学的观点,究竟谁更有道理,作为师范生,我们又应该怎样认识教师的职责与地位问题呢?今天,我们的课换换花样,就请两位同学上台来,各自阐述自己的观点,交由大家来评判。”一指那两张空椅子,“刘同学,蔡同学,请上坐。”\n两位辩手上前坐了下来。望了对面那张平静的脸一眼,仿佛是为自己暗暗鼓劲,刘俊卿深吸了一口气,站了起来开始阐述自己的观点:“……教师要为社会奉献那么多,要还像现在这样,生活清苦,地位低下,那怎么吸引优秀的人才从事教育?”\n“我不同意。”蔡和森接过了话,“教师者,传道授业,教书育人者也。要是教师都一门心思追求更好的待遇,更高的地位去了,那还有什么心思培养学生?用这样的心态去教书,又怎么教得出愿意为社会、为大众奉献自己的学生呢?”\n“说得好!”学生中,毛泽东带头喊了出来,一时间,教室里响起嗡嗡一片赞同的议论,学生们大都站在了蔡和森一边。\n刘俊卿急了,争辩道:“大道理谁不会说?可真要让你低人一等,吃一辈子粉笔灰,你蔡和森也未必愿意吧?”\n此言一出,教室顿时静了,学生们一个个面面相觑,他们真的没有想到刘俊卿竟会说出这种话来。\n“对不起,我从来不觉得吃粉笔灰有什么地方低人一等,相反,我倒坚信,教书育人,是这个世界上最崇高的职业之一。”\n听到蔡和森以这样的方式这样回答自己,刘俊卿的脸涨红了,他心虚地说:“我……我也没有说就不崇高嘛,只不过、只不过别人都把老师看成穷教书匠,光你自己以为崇高,有什么用嘛……”\n他的声音不由自主地低了下去,在满教室鄙夷的目光注视下,他已然明白在这里讲出心里话是多么的不合时宜。\n平静地,蔡和森站了起来,一字一句地说:“人,之所以为人,正是因为人有理想,有信念,懂得崇高与纯洁的意义,假如眼中只有利益与私欲,那人与只会满足于物欲的动物又有何分别?林文忠公有言:壁立千仞,无欲则刚。我若相信崇高,崇高自与我同在。而区区人言冷暖,物欲得失,与之相比,又渺小得何值一提呢?”\n教室里,一片宁静,蔡和森的话,仿佛让所有的人都陷入了思考。宁静中,一个掌声突然响起,那是徐特立。掌声顿时响成了一片!热烈的掌声中,刘俊卿埋着头,满脸只剩了尴尬。带着屈辱与恼怒,他的目光扫过了蔡和森仍然平静的脸……\n三 # 最后一堂课,是评讲作文。\n“第二名,刘俊卿,90分。”袁吉六在发作文本,他把本子递给刘俊卿,微笑着,“有进步啊。”\n他又拿起一个本子,声音提高了八度:“第一名,蔡和森,98分。”冲着蔡和森,眼睛笑得都眯成了一条线,“如此文章,当上公示栏公示全校,展览完了再发还给你。”\n“毛泽东,”砰的一声,作文本甩在毛泽东的面前,袁吉六看也不看他一眼,从嗓子里挤出一个变了调的声音,“65分!”\n本子上,“65”分的分数旁,是大大的三字批语“老毛病!”看着自己的作文,再看看袁吉六,毛泽东都有些懵了……\n下课铃声中,众多学生纷纷拿着碗筷,涌出了教室。\n“俊卿兄。”走廊上,易礼容追上了正拿着碗筷走向食堂的刘俊卿, “是这样,你的文章最近进步那么快,我呢,就老是原地踏步,所以特别佩服你。不知道能不能耽误你一点时间,跟你讨教讨教,怎么才能提高作文水平。”\n“这个嘛……”刘俊卿露着笑容,口气却是不冷不热,“我现在功课也忙,要不改日吧。”他撇下易礼容,径直走去。身后,易礼容愣住了,张昆弟一拉他:“你也是,问他干什么?人家蔡和森文章比他强得多,又肯帮人,你不会去问蔡和森啊?”\n“我知道他不如蔡和森,可蔡和森是一直就强,他是慢慢进步的,所以我想问问他……”“那也得人家肯帮忙,你什么时候看到他帮过别人?”听着身后传来的话,刘俊卿的手捏紧了筷子,直捏得指节都发了白。\n人声鼎沸的食堂一角,蔡畅咬着窝头,面前是稀饭、咸菜、咸鸭蛋,蔡和森正微笑着看妹妹吃饭。学校今天发津贴,蔡和森特地带信让蔡畅来取回去,给家里买点米。望着妹妹吃得那样香甜,蔡和森的目光中充满了怜爱,告诉她自己已经吃过了,要她多吃点。\n人群中,毛泽东打好了饭,夹着书本,匆匆走出食堂,来到八班教室里,把稀饭、窝头摆在了桌上,咬着个窝头,急匆匆地打开课桌抽屉,把一本梁启超的《饮冰室文集》与他的作文本并排放在桌上。“我写不好,梁启超总写得好吧?”毛泽东把窝头往碗里一搁,一把翻开了梁启超的文集,说,“摆本梁启超在面前还学不像,我还不信了!”\n在距离蔡家兄妹不远处的食堂另一侧;王太太又吩咐秀秀来给王子鹏送饭菜了。子鹏推开食盒,表示不再吃外面送来的饭菜了,自己要和同学们吃一样的饭菜。秀秀正劝着他,刘俊卿端着饭走过,发现妹妹,赶紧扭开头,准备躲开,身后传来秀秀委屈的恳求声:“您不吃,太太那儿,我怎么交代得过去?太太说了,您要是不吃,就、就不准我回去……”\n“哟,子鹏兄,又有好吃的了?送都送了,这又何必呢?我做主,吃!”刘俊卿打开食盒,端出里面的饭菜,并不抬头,对秀秀:“好了,没事了,你回去吧。”\n看了为自己解围的哥哥一眼,秀秀转身离去。将丰盛的饭菜摆开,刘俊卿抄起了筷子,子鹏却犹豫着,看了看四周,有不少同学的目光都在望着这边。刘俊卿也感觉到了,扫了周围一眼,却见众目睽睽中,蔡和森一双平静的目光正在注视自己。咬了咬嘴唇,他端起饭碗,示威似的把几个窝头往桌上一扣!窝头在桌子上摇晃了几下,以不同的姿势乱七八糟地躺在了残汤剩水中间。\n正在吃饭的蔡畅不禁皱起了眉头:“哥,这个人怎么这样啊?”\n蔡和森想了想,站起身来,走过去,站在王子鹏和刘俊卿面前,尽量放着和缓的口气说:“子鹏兄,俊卿兄,你们两个如果不吃学校的饭,能不能不要这么浪费?这也太可惜了。”\n“哎哟,对不起啊!”子鹏赶紧起身,不好意思地道歉,“我们……不是故意的。”\n刘俊卿却沉下脸,一把拉开子鹏,说:“你跟他说什么对不起,又不是他的饭!”\n“不管是谁的,总归是粮食嘛……”蔡和森还想说服他。\n“粮食也是我和子鹏兄的粮食!怎么,看我们吃得好,看不过眼啊?”\n“俊卿,你别说了。”子鹏拉住刘俊卿,对蔡和森说,“蔡兄,是我们不对,我以后不倒了,再也不倒了。”\n子鹏说着,伸手来收拾桌上的窝头,刘俊卿却拦住了他,冲着蔡和森吼道:“我今天就倒了,怎么样吧?”\n蔡和森看看他,说:“你这个人怎么不讲道理呢?”\n“跟你我还偏不讲!当自己有什么了不起,我怕你呀?哼!”\n蔡和森盯着他,摇了摇头,一言不发地走回了座位。\n所有人的目光都在盯着刘俊卿和王子鹏,刘俊卿赌气似地坐下,提起筷子吃了起来。子鹏在一旁整个慌了手脚,满脸都是尴尬。刘俊卿却一副得胜的样子,用筷子一敲碗,大声说:“子鹏兄,吃呀!”\n回到自己的桌前,蔡和森坐下了,微笑对妹妹说:“吃饭吧,别理他。”等妹妹吃完饭后,又将她送出食堂门口。目送妹妹离去,蔡和森回到食堂饭桌前,收拾起自己的碗。他刚一转身,却看见方才子鹏和刘俊卿坐过的桌子下,躺着一串钥匙。蔡和森走过来,捡起钥匙,目光却不自觉地盯住了饭桌上那几个窝头。\n饭给了妹妹,他自己到现在还饿着呢。犹豫了一下,蔡和森咽了口口水,看了看四周,食堂里其他同学坐得离他还算远,没人注意他,于是,他把桌子上的窝头装进了自己碗里,大口大口地吃着。\n这时子鹏和刘俊卿来找钥匙,刘俊卿斜睨着蔡和森,脸上全是压不住的幸灾乐祸:“我说蔡兄哪来的力气教训人,原来吃饱了饭,还没忘了加餐,难怪难怪哟。”\n蔡和森手一抖,手中那半块窝头掉在了桌上。食堂里还在吃饭的学生涌了过来。子鹏拉了刘俊卿一把,希望他不要再说。\n“哎!都来瞧都来看,有好戏看了啊!从来只有叫花子捡人的剩饭,今天让大家开开眼,蔡和森蔡大才子也捡我刘俊卿的剩饭吃了。”刘俊卿把子鹏的手一甩,冲着四周里三层外三层的学生,兴致勃勃地喊道,“你说你还装什么样子嘛?还不让我和子鹏兄倒饭,不倒你上哪儿捡啊?”\n子鹏恳求着:“俊卿,我求求你,别说了!”\n“我偏要说!平日里他多威风啊?教训张三教训李四,今天也轮到他了!”刘俊卿捡起那半块窝头,伸向蔡和森,“蔡大才子,来呀来呀,别客气,不吃多浪费呀?”\n众目睽睽下,蔡和森脸色刷白,这番羞辱已经让他真是无地自容了,但更让他难堪的是,他听到有人在身后叫了一声。\n“哥!”\n“小妹?”蔡和森一回头,不禁全身一震,他看到的是早已泪流满面的蔡畅。因为想起了妈妈说要给哥哥留点钱买纸和墨,蔡畅跑到半路又回来了。蔡和森呆了一呆,他一把拉住妹妹,就往外走。\n“哎!别走哇!不还没吃完吗?蔡大才子,接着吃啊,要不要我来喂你?来呀来呀,别客气,来呀。”刘俊卿一步拦在蔡和森前头,举着那半块窝头,伸到蔡和森的鼻子底下,仿佛举着一面胜利的旗帜。\n突然,一只手从旁边伸了过来,一把抓住了那半块窝头。刘俊卿回头一看,愣住了。只见徐特立面无表情地将那半块窝头拿了过来,不紧不慢地将窝头塞进了自己嘴里!所有的同学都呆住了,子鹏一时手足无措,看看刘俊卿,刘俊卿更是尴尬万分。徐特立一言不发,在桌前坐了下来,把自己的空碗往桌上一放,又拿起一块窝头,旁若无人地吃了起来。一时间,全场静得连徐特立的咀嚼声都清晰可闻。\n“嗯,很香嘛!”徐特立一面大口吃着,“蔡和森,你要是不吃,我可就全吃了。”眼泪骤然滑出了蔡和森的眼眶,他拉开凳子坐下,也抓起一块窝头。两个人好像比赛一样,大口地吃着。两只手同时伸向了碗里最后一个窝头,还是徐特立拿了起来,他一掰两半:“来,二一添作五。”接过半块窝头,迎着徐特立温暖的目光,蔡和森笑了。\n就在这时,人群一阵躁动,孔昭绶与方维夏排开人群,出现在大家面前。孔昭绶的脸上没有任何表情,他看看四周的学生,拿过蔡和森手里的半块窝头,咬了一口,仿佛是回味起了某种久违的甜美,孔昭绶笑了,说:“小时候,我家里很穷,吃不起什么好东西。记得有一年过年,我母亲借了半袋玉米,磨成面,蒸了一锅窝头。窝头刚出锅,我饿极了,拿了一块就吃,结果烫了嘴,窝头掉在地上,母亲捡起来,把弄脏的那一半掰下来,自己吃了,干净的那一半,给了我吃。香啊!今天吃这半块窝头,又让我想起了小时候那半块,真的很香!”\n泪水蓦然湿润了他的眼眶,他擦了一把,昂起头继续说:“同学们,各位第一师范的同学们!一粥一饭,来之不易啊!你们的父亲,你们的母亲,在家里是何等的节俭,何等的惜粮惜物,你们从小都是看在眼里的!还记不记得,你们那种田的父亲,冒着三伏天的大太阳,在田里一整天、一整天地割稻?还记不记得,你们的母亲,把自己碗里的饭扒到你的碗里,告诉你她光半碗饭已经吃得好饱好饱?”\n他终于又忍不住,声音哽咽起来。不少学生都已是热泪盈眶!孔昭绶略平静了情绪,说:“刘俊卿同学,有一位老师,我想应该重新跟你介绍一次,也跟我们全体同学介绍一次,那就是被你称为徐大叫花的徐特立老师。我听说,不光你一个人,还有不少同学背后也这样叫他徐大叫花。是啊,徐大叫花。你们这位徐老师还真是像个叫花,身上补丁衣服,脚下是草鞋,坐不起轿子,吃学生食堂,连一把油纸伞都买不起,下雨天穿件蓑衣!很寒碜啊,长沙城的教书先生里头都找不出第二个这么寒碜的了。可我也要告诉你们,就是这位徐大叫花,光一项省议会副议长的职务,就是两百大洋的月薪!更不用说他还同时担任长沙师范学校的校长,兼着三所学校的课,他的收入,在我们长沙城所有的教书先生中无人能比,比我这个校长高出不止三倍!那徐老师的钱到哪里去了呢?如果大家有空,去一趟徐老师的家乡,长沙县五美乡,就会看到有一所小学,一所免费招收贫困农家子弟的五美小学,那里的学生读书不要钱,一分钱都不要!因为那是徐老师创办的学校,所有的钱,都是他一个人掏!而我们的徐老师,徐大叫花,连自己的家人都全部留在乡下务农,因为长沙城里生活费太高,因为多省一块钱,就能让一个穷人家的孩子多读一个月书!”\n已经不光是学生,所有的教师都深深地震撼了。\n“什么是贫困,什么是富有?穿草鞋、打补丁、吃粗茶淡饭就是贫困,穿皮鞋、坐轿子、吃山珍海味就是富有吗?不,孩子们,贫困与富有,不在于这些表面的东西。今天的你们,都还年轻,将来走入社会,你们都要经历金钱与名利的诱惑,都要面临理想与现实的选择。等到了那个时候,你将会真切地感受到,当你不计个人得失,尽己所能,使尽可能多的人得到幸福时,你的精神将是那样的富有和快乐。反过来,如果一天到晚只记得自己那一点私利,只盘算自己那一点得失,就算你坐拥万贯家财,就算你白天锦衣玉食,荣华富贵,等到了晚上,等到你一个人安静下来,你就会发现,你并不快乐,你所拥有的,只是无尽的空虚,因为在精神上,你只是个一文不名的穷光蛋!这半块窝头,我留下了,这半块窝头,我也希望从此留在每一位同学的心里!使我们牢牢记住,俭朴为修身之本!”\n猎猎秋风中,他的声音振聋发聩,回荡在整个学校的上空!\n四 # 任何事情都不可能只有一种绝对的评价,窝头事件也一样。当刘俊卿在会后委屈地垂头坐在督学办公室纪墨鸿的对面时,虽然纪墨鸿并没有明显地袒护他,但他还是看到了一线希望。\n纪墨鸿说:“挨了批评就挨了批评,垂头丧气的干什么?校长和先生们批评你,也是为了你好。你做学生的,难道还要到我这儿讨回个什么公道不成?当然了,有些观念,我也并不赞同,这读书人总还有个读书人的颜面,都弄得像个乞丐一样……算了,这些话,不是该跟你说的。你只要记住,学生,就得服从学校的规矩,不管听不听得进去,老师的话,总要服从,才是好学生。你先去吧。以后有什么事,还是可以来找我的。”\n刘俊卿毕恭毕敬地离开督学办公室之后,纪墨鸿也随即出门,到了杨昌济的办公室,在杨昌济对面坐下,端着茶杯字斟句酌地说:“有些事情,我这个督学本来不便开口,可不开口吧,这心里又堵得慌。杨先生,您是长沙学界之翘楚,与孔校长又有同窗之谊,我想,您的话他想必听得进去一些。”\n“纪先生有话,就尽管说吧。”除了上次来送聘书,杨昌济一向和纪墨鸿没什么交往,所以,他实在猜不透这位督学大人今天来找自己,到底是为了什么事情。\n“那我就直说了。你我都是致力于教育之人,学生应该教成什么样的人,不应该教成什么样的人,这是学校教育的大本大源,是万不可出一点纰漏的。这一次,孔校长在学校搞这场所谓俭朴教育,您就不觉得过分了吗?教学生俭朴做人,这墨鸿也是不反对的,可凡事过犹不及,俭朴要俭到捡人的剩饭吃吗?那剩饭是什么人吃的?那是叫花子!难道我们培养学生,就是要培养一群叫花子出来吗?”\n纪墨鸿说的是他的心里话,但道不同不相为谋。他的话显然在杨昌济这里得不到共鸣,相反,还让杨昌济非常反感。杨昌济反问道:“纪先生的意思,学校是培养上等人的地方,对吗?”\n“本来就是嘛,难道还培养下等人?”纪墨鸿端起茶碗要喝,但越想越生气,又把茶杯放下了,“这俗话说得好,水往低处流,人往高处走。学生家长辛辛苦苦,把孩子送到学校里来,为的什么?不就是为了他们有个好出息,他日有出人头地的那一天吗?咱们做先生的,也当时时想着身上担着的那份责任,总须培养学生谋个好前程,让那农家的孩子不必再扛锄头,做工人家的孩子不必再卖苦力,走出去一个个有头有脸,斯斯文文,做个人上人,才对得起学子们一番求学之意,家长们这番含辛茹苦啊。这下倒好,吃剩饭!学生吃了不纠正,老师还要带头吃,一个老师糊涂不算,校长还要吃!这、这、这是要干什么嘛?这样培养出来的学生,岂不是连高低贵贱都分不清?斯文扫地,真是斯文扫地!”\n说到情绪激动处,砰的一声,纪墨鸿把茶碗又一放。\n杨昌济实在听不下去了,但他还是尽力克制着,问:“扛锄头、卖苦力的,都是下等人,是贱民,只有读书人才是上等人,‘劳心者治人,劳力者治于人’,纪先生就是这个意思,对吗?”\n“话当然不能这么讲,一讲就是封建等级,糟粕之论。可这世道它就是这么个世道,道理也就是这么个道理嘛。”纪墨鸿的口气明显地软了些。\n“是吗?”杨昌济站了起来,他的口气却明显地硬了:“纪先生,如果事先不知道,我会以为今天当我的面讲这番话的,是哪位前清的学政大人。可你不是封建王朝的学政,你是民国的公务员!中华民国临时约法中明文规定,国民一律平等,哪来的高低贵贱之分?不错,今天的中国,还没有做到真正的人人平等,还有诸多不合理的现象,可我们这些从事教育的人要做的,不正是要抹平这种不合理的等级,让学生去除旧观念,做一个民国的新人,为人人平等之大同世界而努力才对吗?先生倒好,满口高低贵贱,恨不得把学生都教成蝇营狗苟,但求一己之富贵前程,不思国家、民族、社会之未来的自私自利之徒。我倒要请问纪先生,你,这是要干什么?”\n“大道理谁不会说,可大道理当不得饭吃!”纪墨鸿满脸涨得通红,腾地站了起来,拉开门便往外冲,一只脚已跨出了门,又回过头,狠狠地说:“我倒要看看,你板仓先生用这番大道理,教得出什么样的好学生!”\n纪墨鸿砰的一声关上了门走出办公室,迎面却正碰上毛泽东、蔡和森、萧子升三人站在他面前。三名学生显然看到纪墨鸿摔门而出的情景,都有些不自然。还是子升先恢复了常态,喊了一声:“纪督学。”\n纪墨鸿迅速平静了表情,和蔼地:“有事啊?”\n子升说:“我们来找杨老师。”\n纪墨鸿像没发生过任何事情一样,微笑着说:“杨先生在里面,进去吧。”走了几步,纪墨鸿又在楼梯口停了下来,回头看着三名学生进了杨昌济的办公室,轻轻摇了摇头。\n三个学生今天来找杨老师,是想请老师担任他们的指导老师。因为蔡和森提出想成立一个哲学读书会,基本成员除了他们三个,还有周世钊、张昆弟、罗学瓒、萧植蕃、李维汉、陈章甫、易礼容、熊光楚他们,一共十多个人,都是对哲学、社会学比较感兴趣的同学。他们商量着,打算定期开展读书活动,互相交换学习笔记,比赛学习进度,以促进提高自己。当然,根据毛泽东的建议,还要一起锻炼身体。这样的好事情,杨昌济怎么会不答应呢?只不过,无论是作为发起者的蔡和森、萧子升和毛泽东,还是哲学读书会的导师杨昌济,恐怕都没有想到,就是这个松散的、以强烈的求知欲望为纽带组织起来的学生兴趣小团体,后来竟会一步步壮大起来,一步步走向政治上的成熟。\n第九章 袁门立雨 # 寒风和着秋雨,刹那间笼罩了整个院落。房檐下,雨水如根根丝带,在风的吹动下,摇摆着。不平的地面上,很快形成了许多的小水潭。全身透湿的毛泽东平静而倔强,他垂手而立,一动不动,仿佛雨中一尊雕像。他那被雨水浸透了的头发一绺绺沾在他的前额上,雨,正顺着发梢不断地滴落。他的衣裳已经湿透,一双布鞋全部被从身上滑落下的雨水浸湿……\n一 # 上课铃响了,袁吉六绷着脸进了综合大教室,边报着分数,边把本子发给学生。\n“毛泽东,40分!”作文本“砰”的被扔在毛泽东课桌上,鲜红的“屡教不改”四个大字和40分的得分把毛泽东看得目瞪口呆!教室里的学生们也都愣住了:毛泽东居然只得到这样的分数?!\n“王子鹏,75;刘俊卿,90分……”袁吉六继续慢条斯理地给学生发放着作文本。他的身后,传来了“砰”的一声,不回头,他也知道这是毛泽东把作文本拍在桌上发出的声音。“怎么回事?”袁吉六环视着教室里的学生,瞪着眼睛问,“课堂之上,谁在喧哗?”\n毛泽东“呼”地站了起来,气呼呼地回答:“我!”\n“毛泽东?你要干什么?”袁吉六厉声问。\n“我不明白。”\n“什么不明白?”\n“我的作文,为什么只得40分?”\n“你还问我?”\n“袁老师打的分,我不问袁老师问谁?”\n这一来一往的针锋相对让所有的同学都吃了一惊,谁也没想到毛泽东居然敢这样跟袁吉六讲话!坐在旁边的几个好朋友拼命向毛泽东使眼色,示意他坐下,毛泽东却越发挺直了身子。\n“好,既然你问我,那我就告诉你!你这个作文,就只值40分!”袁吉六气愤地指着毛泽东的鼻子说。\n“我的作文有哪点不好了?”毛泽东质问老师的时候,完全忘记了自己是个学生,是在教室里。\n“哪点不好?哪点都不好!提醒你多少回了,要平实稳重,要锋芒内敛,不要有三分主意就喊得十七八分响,你听进去一回没有?你变本加厉!你越来越没边了!”袁吉六抓起那本作文,摇晃着说,“你这也叫文章?你这整个就是梁启超的新闻报道,只晓得喊口号!”\n“梁启超的文章怎么了?我就是学的他的文章。”\n“你还好意思讲!好的不学,学那些乌七八糟的半桶水!什么是温柔敦厚,什么是微言大义,什么是韩章柳句欧骨苏风,他梁启超懂吗?他屁都不懂!还跟他学?”\n“梁启超倒是屁都不懂,袁老师估计是懂了。”\n毛泽东这句话,把袁吉六气得大胡子直抖,他指着教室门吼道:“你……你混账!你给我滚出去,滚!”\n毛泽东愣住了,随即转身就往外冲,砰的一声,他的凳子被脚带倒在地!\n“你……”袁吉六大概也没想到毛泽东真敢冲离教室,怒气冲冲地朝着毛泽东的背影说,“好,你走,走了就再不准踏进我袁仲谦的教室!”\n“你放心,我不稀罕!”毛泽东头也不回地答应着,身影消失在了教室门外。\n袁吉六把手上剩下的作文本狠狠一摔,涨红着脸骂道:“混账东西!反了他了!”\n毛泽东气壮山河般地冲出教室,回到寝室里,坐也不是、站也不是,干脆躺在床上看书,可书也看不进去。正当他在床上翻烙饼的时候,方维夏、黎锦熙一脸严肃地进来了。方维夏沉着脸对他说:“出来一下,有话跟你谈。”毛泽东昂着脑袋,跟两位老师进了教务室,把刚才在综合教室发生的事情一五一十地讲了一遍,却一点没有认识错误的样子。\n黎锦熙敲边鼓说:“这件事情很严重,袁老师、孔校长、纪督学现在正在校长室研究对你的处理方案。”\n毛泽东像头小水牛一样,拧着脖子说:“处理什么?我本来没错。”\n“你没错,难道是老师错了不成?”\n看着方维夏满脸的恨铁不成钢,毛泽东一言不发。\n“润之,不管怎么说,袁老师都是为了你好,课堂之上,你当着那么多同学顶撞他,难道你还做对了?”黎锦熙的劝导还是很温和。\n毛泽东小声嘀咕道:“又不是我先骂人。”\n“这么说是袁老师先骂人?”黎锦熙问。\n“本来就是嘛。”\n“他骂谁了?”\n“梁启超。”\n方维夏和黎锦熙都愣住了,一时真是哭笑不得,异口同声地说: “他骂梁启超你较什么劲啊?”\n“那是我作文的偶像,我……我就是不让他骂。”\n“你……”方维夏简直不知该怎么跟他说下去了,“你这个人怎么这么犟呢?”\n两位老师是受孔校长的委托来找毛泽东谈话的,此时只好实事求是地回去向孔校长汇报。孔昭绶一听毛泽东死不认错,脾气也上来了,决定非要严肃处理他不可。但黎锦熙却认为,照毛泽东现在的情绪,处分只怕是火上浇油。站在两人中间,方维夏提议说:“校长,依我看,能不能先缓一缓?处分的目的,也是为了教育学生。可现在处分,不但达不到教育的效果,还会适得其反。毛泽东这个人,个性的确是有问题,太张扬,太冲动,倔强有余而不善自制。可我觉得,学生倔强也不见得都是坏事,如果能让一个倔强的学生认识到他的错误,那他一辈子可能都不会再犯同样的错误了。”\n孔昭绶冷静下来,也觉得这个办法可行,但谁能说服毛泽东这个倔强学生让他认识到自己的错误呢?他们三个人你看看我,我看看你,不约而同地齐声叫出了一个人的名字:杨昌济!\n杨昌济听了孔校长的一番话,也着实吃了一惊,但他想也没想,就接受了孔校长安排的任务。他也明白,就现在这种状况,除了他没有第二个合适的人选。姑且不说袁老那里学校不好交代,单说毛泽东,他也不能撒手不管呀。于是,当天晚上,他把毛泽东约到了君子亭。\n晚风中,杨昌济背着双手,仰望着星空,突然背起了一篇脍炙人口的文章:“‘世有伯乐,然后有千里马。千里马常有,而伯乐不常有。’润之,这篇文章你读过吗?”\n毛泽东在老师身后忐忑不安地坐着,小声回答:“读过,是韩愈的《马说》。”\n“对,《马说》。这个世上,真人才易得,识才者难求啊。为什么呢?”杨昌济在毛泽东身边坐下来,看着毛泽东,说:“因为人都有个毛病,自以为是。凡事总觉得自己是对的,看不到别人的优点,总之别人说的一概不认账。你比方……”\n他看到毛泽东微微侧开了头,那表情显然已经在等着自己的批评,忙话锋一转:“比方袁仲谦袁老先生,这方面的毛病就不小。”\n这一招很是高明,让毛泽东愣住了。\n杨昌济问:“怎么,你不同意我的看法?”\n“不是,老师怎么突然批评起袁先生来了?”毛泽东不好意思地说。\n“他做得不对我当然要批评他。你看啊,像你这样的学生,作文写得那么好,他居然看不上眼,这像话吗?不就是文章锋芒过甚,不太注重含蓄吗?又不是什么大不了的毛病,值得这么抓住不放?就算是有毛病吧,你毛润之改不改,关他什么事嘛?他要这么一而再再而三跟你过不去,真是吃饱了饭没事做!你说对不对?”\n毛泽东太尴尬了,尴尬得不知道怎么回答。\n杨昌济接着说:“还有还有,动不动就搬出什么韩柳欧苏,要人学什么古之大家,那韩柳欧苏有什么了不起?不就是几百上千年人人都觉得写得好嘛?难道你毛润之就非得跟一千年来的读书人看法一样?说不定你比这一千年来所有的读书人都要高明得多呢?他袁仲谦怎么就想不到这一层?这不是自以为是是什么?”\n这番话让毛泽东越发不安了,但杨昌济还在说:“最可气的是,他居然看不上梁启超的文章。梁启超的文章有什么不好,就算是比不得韩柳欧苏那么有名气,就算是许多人觉得过于直白,只适合打笔仗,上不得大台面,那又怎么样?你做学生的偏要喜欢,偏要当他十全十美,他这个老师管得着吗?还要因此在课堂上,当着那么多同学教训你,跟你争个面红耳赤,哪里有一点虚心的样子,哪里有一点容人的气度嘛?”\n“老师,我……”毛泽东垂下了头,擦了一把头上的汗。\n杨昌济不再继续说了,只是盯着毛泽东,直盯得他深深埋下了头。许久,杨昌济才站起身,向亭外走去。走出几步,他又站住了,回头说:“润之,道理呢,我就不跟你多说了,你自己慢慢去体会。不过有件事我想告诉你,你入学的作文,大家都知道,是我敲定为第一名的。可你不知道的是,那次阅卷其实是袁仲谦先生负责,当时他把你定为第二名。仲老是长沙国学界公认的权威,能在他的眼中得到第二名的成绩,足可见他有多么赏识你的才华,之所以定为第二名,也是因为你的文章还有明显的缺陷。他一次次指出这些缺陷,一次次降低你的作文分数,乃至降到40分,为什么?他看中的第二名写出的文章在他眼中真的只值40分吗?一个老师,当他碰上自己非常欣赏的有才华的学生,却又总也看不到学生改正缺点的时候,他会是什么心情?我告诉你,五个字——恨铁不成钢!”\n他说完,转身就走,只把夜空中的星光闪闪留给了正在发愣的毛泽东。\n二 # 那天夜里,毛泽东一口气跑到了袁吉六的宅第,“砰砰砰……”用力拍打着门环。\n“谁呀,这么晚了?”一名老仆人提着油灯,揉着睡眼打开了一道门缝。\n毛泽东喘着粗气对他说:“我是第一师范的学生毛泽东,来求见袁仲谦老师的。”\n“学生?也不看看几点了,有事不能明天说吗?”\n“我真的有事,我想马上见到袁老师。”\n“可先生已经睡了……”\n两人正说着,袁吉六的妻子戴长贞从里屋出来,站在走廊上问:“长顺,谁来呀?”仆人转头回答:“是老爷的学生。”\n戴长贞赶紧说:“哦。大冷的天,先让人家孩子进来嘛!”“是,太太。”仆人拉开大门,对毛泽东,“你进来吧!”\n毛泽东进到院子里,垂手立在天井里,听到里屋戴长贞正对袁吉六说:“说是来跟你道歉的,人在院子里等着呢。”袁吉六气冲冲的嗓门从房间里传出:“他爱等等去!谁也没请他来!睡觉!”\n话音一落,窗内的灯光骤然黑了,整个院落归入了一片宁静与黑暗,只剩了毛泽东一个人静静地站在院子里。\n夜空沉沉,星月无光,上半夜的满天星斗早已不知踪影。寒风骤起,在树梢、枝叶间呜咽,也卷起满地秋叶,掠过毛泽东一动不动的双脚。风是雨的脚,风吹雨就落。紧跟着,雨点落在了静静地伫立着的毛泽东的脸上。寒风和着秋雨,刹那间笼罩了整个院落。房檐下,雨水如根根丝带,在风的吹动下,摇摆着。不平的地面上,很快形成了许多的小水潭。全身透湿的毛泽东平静而倔强,他垂手而立,一动不动,仿佛雨中一尊雕像。他那被雨水浸透了的头发一绺绺沾在他的前额上,雨,正顺着发梢不断地滴落。他的衣裳已经湿透,一双布鞋全部被从身上滑落下的雨水浸湿……\n晨曦初露时,雨终于停了。渐渐的,东方的天际,一片火红。晨光中,雨水冲刷过的大自然,是那么干净、耀眼。\n袁吉六伸展着胳膊一走出卧室门,就听到毛泽东的声音:“老师。”\n袁吉六扣着扣子,扫了仍然站在原地的毛泽东一眼,一言不发。\n毛泽东往前走了几步,抬头正视着袁吉六逼人的目光,一字一顿地再次说:“老师,我错了,请您原谅我。”然后,深深地向袁吉六鞠了一躬。\n在毛泽东身后,残留的雨水悄然灌进了两个深深的脚印里,袁吉六心里一动,威严的目光从那两个脚印移到了毛泽东身上,看到眼前的学生静静地伫立着,浑身上下都湿淋淋的,脸上却平静谦和,全无半分疲色。\n良久,袁吉六接过妻子递过来的水烟壶,口气硬冷地说了声“跟我来”,便转身沿着走廊走去。\n望着这一对师徒离去的背影,戴长贞笑着招呼着仆人:“去,把我昨天晚上准备好的干净衣服拿来,还有,叫厨房烧碗姜汤。”\n师生俩进了袁家古色古香、四壁皆书的书房。袁吉六将水烟壶往毛泽东手上一塞,说:“拿着。”然后他踮起脚,小心翼翼地从书架上端取下了厚厚的一整套线装古书——那是一套足足二十多本的《韩昌黎全集》。\n“古文之兴,盛于唐宋,唐宋八大家,又以昌黎先生开千古文风之滥觞,读通了韩文,就读通了古文,也就懂得了什么是真文章。你的文章,缺的就是古之大家的凝练、平稳、含蓄、从容,如满弦之弓,只张不弛,令人全无回味。这是作文的大忌!这套韩昌黎全集是先父留给我的,里面有我几十年读此书留下的笔记心得,今天我借给你,希望你认真读,用心读,读懂什么是真正的千古文章!”\n“是,老师。”\n“遇到问题,只管来找我,我袁吉六家的门,你随时可以进,这间书房里所有的书,你也随时可以看,但有一条,毛病不改正,文章不进步,小心我对你不客气!”\n袁吉六炯炯的目光注视下,毛泽东用力点着头:“放心吧,老师!”\n三 # 袁老师的课,毛泽东这段时间是突飞猛进,可其他课,毛泽东就没这么幸运了。\n饶伯斯的英语课毛泽东还勉强过得去,美术课上他看其他科目的书,黄澎涛老师也能容忍,但在费尔廉老师的音乐课上,他那五音不全的大嗓门可就让他出尽了风头:他一跑调,隔壁几个班的同学全能听到,引来一片又一片哄笑,常常打断隔壁班老师的讲课。当然,这些还不是问题,最重要的是,他的数学和理化成绩不理想。没有办法,每次完成数学和理化作业,他都必须请教蔡和森跟萧三他们。\n这天晚上,他又抱着课本到了六班寝室。蔡和森去教室自习了,只有萧三在。两人约定,萧三先给他讲,讲了之后,毛泽东先自己做题,实在做不出来,再问萧三。萧三也不离开,就在旁边看书陪着他。\n“X加2Y等于X的平方,Y减X又等于……”毛泽东眉头紧锁,一副绞尽脑汁的苦相,一边做题还一边念念有词。\n萧三把手里的书一放:“你做就做,一晚上老念什么念?”\n“好好好,不念不念。”毛泽东苦着脸,继续做着题目。过了好半天,他终于把笔一放,长出了一口气,说:“哎呀呀呀,总算搞完了。哎,你看看,这回应该搞对了吧?”\n萧三接过作业本,逐一检查着。这个严厉的小老师看着看着,眉头皱起来了,脑袋一摇,把本子往毛泽东面前一塞,说:“润之哥,怎么回事啊你?”\n“怎么,还有错的?是哪一道?”毛泽东嬉皮笑脸地问。\n“哪一道?七道题搞错五道!总共两个公式,一晚上都跟你讲三遍了,第一遍你错七道,第二遍你错六道,第三遍你还要错五道,你说你怎么得了哟!”\n“怎么得了怎么得了,我还烦得死咧!什么鸡兔同笼,和尚分饼,一元二次,二元一次,鬼搞得它清?”毛泽东把作业本一摔,长叹一声,他显然也烦得够呛。\n“那你老是搞不清,考试的时候怎么办呢?”萧三问。\n毛泽东摇了摇头,仰头倒在了萧三床上。\n萧三又翻开了数学课本,没奈何地说:“算了算了,我再跟你讲最后一遍。”\n毛泽东强打着精神,支撑起身体,却无意间看见了萧三床头的一本《读史方舆纪要》。他的眼睛突然亮了:“《读史方舆纪要》?哎呀,这可是好书啊!”\n萧三几乎是条件反射地一把把书抢了过来:“哎!不行不行,这书不能给你。”\n“我看看怕什么?”\n“我还不知道你啊?看着看着就看到你手上去了。不准动啊。”\n“我看一下,就借三两天,两天可以了吧?”毛泽东哀求着。\n“一天都不行。”萧三护着书。\n“子暲,你不是那么小器的人吧?”\n“不是我小器。这是我哥的书,我刚拿过来的,他专门叮嘱了,不能借给你。”\n毛泽东:“怎么就不能借给我呢?哦,我借他的书什么时候不还了?”\n“你倒是还,还回来还是书吗?”他随手抓起床上两本书,翻动着,书上天头地脚到处都是墨迹:“你看看你看看,这都是你还回来的书,结果呢?上面写的字比书上的字还多,搞得我们哥俩都不晓得该看书上的字还是你写的字了。”\n“读书嘛,还不总要做点笔记?”\n“那你不会找个本子写啊?非要往书上写?我不管,反正我哥说了,什么都可以借给你,就是书不行。”\n“你哥讲了是你哥讲了,你可以通融一下嘛,我们两个还不好讲话——这回我保证不往书上写了,悄悄借,悄悄还,不让那个菩萨晓得,这总可以了吧?”\n“你会不写?我才不信呢。”\n“我保证!我,向袁大总统保证!”\n毛泽东把手伸到了萧三面前,脸上全是讨好的笑容。望着他,萧三满是无奈:“你到底是来补数学的,还是补历史的?”\n四 # 毛泽东的作文终于让袁吉六满意了,最近的一篇作文,袁吉六居然给他打了满分,还批了大大的两个字:“传阅”。\n这篇带着鲜红的“传阅”与满分成绩的作文,豁然张贴在一师公示栏的正中央。吸引着众多学生挤在公示栏前,争相阅读。何叔衡也挤在人群中,扶着眼镜仔细地读着,边读还边忍不住直点头。\n何叔衡读了毛泽东的满分作文,满脑子装的都是毛泽东,心里对这个比自己小了近20岁的年轻人钦佩不已。却不想从公示栏回来,一踏进讲习科寝室 ,正听到有人在说毛泽东。\n“我说了,什么都可以借,就是不能借书给他!你怎么就记不住呢?你看看你看看,这又成什么样子了?他保证不写,他毛泽东的保证你也信?他那身毛病,一看得激动起来,管他谁的书,反正是一顿乱抒发感慨,你又不是不知道!”\n何叔衡笑说:“子升兄,是什么书啊?能不能借我看看?”子升把书往他手里一递,“送给你了!”\n何叔衡接过来一看,是本《读史方舆纪要》,随手翻开,上面天头地脚又到处是墨迹,不觉好笑。这时子升拉开抽屉,取出几张空白描红纸,气冲冲地提笔在纸上的示范格写起偏旁来,感觉有些莫名其妙:难道他还需要练字吗?便好奇地问:“子升兄,你写这个干什么?”\n子升没做声。萧三赶紧解释:“是这样,润之哥正在练字,我哥每天都给他示范几张,好让他照着练。”望着子升一面带着气,一面一笔一画,精雕细刻,何叔衡忍不住笑了。\n子升看了何叔衡和萧三一眼,自己也不禁笑了,无可奈何地说:“交错了朋友,算我倒霉,行了吧?”\n何叔衡在子升一边坐下,读那本《读史方舆纪要》。他眯缝着眼睛,仔细地分辨着天头地脚上毛泽东潦草的字迹,与书上的内容作着对照。翻过一页时,他又寻找着上一页毛泽东未写完的评语,再翻回来对照着,不住地点头。不一会他便向毛泽东的寝室走来。\n“烦死了!” 何叔衡远远便听见一个声音。看见寝室里的桌子上,摊着课本、作业本,一个人正用圆规、直尺照着书画几何图形。左量右量,怎么画都跟书上对不上,烦得把尺一扔,却又碰掉了铅笔,铅笔滚到了床下。他嘟哝了一句,俯下身来捡铅笔,但铅笔滚到了床底,他只得尽量趴下去,使劲探着手臂。\n何叔衡不觉疑惑,问道:“请问毛泽东同学在吗?”“我就是,等一下啊。”那人探着手使劲地够着,总算够到了那支铅笔,从床底下钻了出来,拍打着满头满手的灰尘。\n毛泽东看着眼前这个手里还拿着那本书的老大哥,只觉得面熟,一时却想不起名字了,喃喃地问:“你不是那个?”“何叔衡,讲习科的。”\n“哦,对对对,何兄找我有事?”“我刚才看了毛兄公示的范文,还有这本书上的笔记,毛兄的知识之广,见解之深,立言之大胆,思索之缜密,令我非常佩服,真的,佩服之至。我有一个冒昧的想法,希望今后能多多来向毛兄求教。不知毛兄能不能给我这个机会?”\n毛泽东有点不好意思了,拍拍后脑勺说:“你看你这是怎么说的?你是老大哥嘛,我那点本事算什么?”\n“学问、见识,不以年龄论短长,我虽虚长几岁,却是远不及毛兄。今天,我确实是诚心诚意,来向毛兄讨教的。”何叔衡的态度非常恳切。\n毛泽东不喜欢客套,很爽快地向何叔衡伸出手来,说:“都是同学,有什么讨教不讨教?这样吧,我们交个朋友,以后,多多交流。”一老一少,两个人的手握在了一起。\n五 # 这周,杨昌济在周南师范科教室里,也在讲作文。\n“本次作文测验,又是陶斯咏同学第一名,向警予同学第二名,她们两个的作文水平,的确值得全班同学认真学习。”周南国文课上,杨昌济说道。\n女生们羡慕的目光都投到了斯咏和警予的身上,斯咏有点腼腆,警予却表情泰然。\n“当然了,陶同学和向同学的文章并非十全十美,这里呢,我也带来另外两篇范文,还是第一师范与你们同年级的毛泽东和蔡和森两位学生的,尤其是毛泽东这篇满分作文,可以说进步神速,克服了他过去作文中某些明显的弱点。今天我也把这两篇范文发给大家,以便大家学习体会别人是怎么改进提高的。”说着,杨昌济拿出一大叠油印稿发给学生。斯咏与警予不由得对了个眼神,脸色古怪。\n放学后,斯咏和警予肩并着肩走出校门。警予怎么都弄不明白,她每天都要喊三遍“我要超过你”的,可怎么越赶差得还越远呢?于是决定从今以后每天要喊六遍了。斯咏却说她现在是没那个志气了,既然打马扬鞭也追不上,不如不追。\n两人说着话,转身进小巷。警予突然问斯咏:“哎!你说这两个家伙会是个什么样啊?”\n“什么样?我怎么知道什么样?八只眼睛六条腿喽。”斯咏还没想过这个问题。\n“不行,我非得去看一眼不可,倒看看他们跟一般人长得有什么不同。”\n斯咏看警予那蛮横横的样子,打趣她说:“这好办啊,明天你直接往第一师范门口一站,两手往腰上一插,‘毛泽东,蔡和森,给姑奶奶我站出来!’包你马上看到。”\n“去!以为我神经病啊?”\n“你也知道啊?人家男校学生,我们跑去看,被看的还不知道是谁呢。”\n她话音未落,突然,被警予拉了一把。斯咏顺着警予的手指看过去,惊得嘴巴张得老大,半天合不拢!\n就在前面不远的,小巷的拐角处,赵一贞与刘俊卿正依偎在一起,两个人的唇正在悄悄接近。这时,斯咏和警予身后忽然传来了蹬蹬蹬的脚步声。斯咏回头一看,愣住了:穿得好像教会学校女学监模样的何教务长目不斜视,正向这边走来。\n“教务长好!”警予首先反应了过来,扯开嗓子喊了一声。斯咏也跟着问好:“教务长好。”\n“嗯。”何教务长答应着,对警予皱起了眉头,很严肃地说,“向警予同学,说话切忌高声,一个淑女,就得像陶斯咏同学这样,时刻保持温文尔雅,记住了?”\n何教务长说完又向前走。警予急了,一把拦在前面:“哎,教务长!”\n何教务长脸一板,问:“怎么又这么大声?温文尔雅,淑女风范!什么事啊?”\n“那个,明天照常上课吧?”“明天又不是礼拜天,当然上课!”“啊?哦!对对对,我那个、那个太糊涂了。”\n“没头没脑。”何教务长,说着,向前走了。警予、斯咏转身一看,大树下,一贞与刘俊卿早已躲得没了人影。两个人这才长出了一口气。\n回到寝室,“砰”的一声,警予的巴掌拍在桌上,喝道:“招!给我从实招!”赵一贞坐在自己床边,埋着头,声音细如蚊鸣:“他叫刘俊卿,第一师范的。”\n“刘俊卿,第一师范,这就算完了?”警予低头看看一贞的脸,“哟哟哟哟,还知道脸红呢!”\n一贞羞得捂住了脸。\n斯咏拉了一把警予:“你呀,算了,问那么多。”警予哼了一声,“不行,要没我们俩,今天什么后果?赶紧赶紧,怎么报答我们,说吧!”\n“随……随便你们喽!”\n“随我们说是吧?嗯——这倒是要好好想想。”警予突然眉毛一挑,想起了什么,“哎,对了,你是说,他是第一师范的?这样吧……”一贞听着警予的话,不停地点着头。\n周末,一贞一出周南女中的大门,就看到对面大树下,有一双锃亮的皮鞋,知道是刘俊卿在那里等自己,左右看看没人,便埋着头,紧张地走了过去,红着脸站在刘俊卿面前,却盯着自己的鞋尖,不敢看刘俊卿一眼。\n刘俊卿将一直背在身后的手伸了出来,在他的手里,是一个漂亮的小本子。一贞小声问:“是什么?”\n“《少年维特之烦恼》第一章,我翻译的——译得不太好,要是你觉得还能看下去,我再给你译后面的。”\n一贞红着脸,接过了本子,转过身,走上了回家的路。刘俊卿迟疑了一下,赶紧跟了上去。\n僻静的小巷,夕阳斜照,树影斑驳。抱着那个精巧的小本子,一贞与刘俊卿并肩默默地走着。秋风轻拂,一贞的辫角扫过俊卿的面颊。看着一贞含羞的脸,刘俊卿几乎都痴了。\n夕阳下,两个人的影子投在青石板路面上,一只手的影子悄悄伸向了另一只手,那只手微微挣了一下,两只手的影子还是合在了一起。夕阳将两个人的影子拉得老长老长。\n临到分手,一贞低声问:“俊卿,你能不能把毛泽东和蔡和森约到一师对面的茶馆里去。”刘俊卿停住脚步问,“你见他们干什么?”\n“不是我,是警予和斯咏。她们俩都是我最好的朋友,就是特别佩服你那两个同学的文章,所以想见见本人。怎么,是不是不好约啊?”\n刘俊卿犹豫一时说:“那倒不是……要不,我试试吧。”一贞打量着他的神情,说:“要是不好约,你也别勉强。”“怎么会呢?”刘俊卿赶紧换上轻松的笑容,“你交代的事,我怎么都会办好的,你就放心吧,让你两个同学等着见人就是。”\n六 # 南门口,车轿往来,行人穿梭,商贩叫卖,喧哗热闹的南门口的街道,今天却多了一个突兀而格格不入的声音——“Ill be back in a few days‘ time……”\n黄包车拉着斯咏,停在了街对面。斯咏下车付钱,听到读英语的声音,便掉头看去,就在嘈杂的街道边,毛泽东坐在大树下,正捧着英语课本,大声朗读着。阳光洒在他的身上、他的英语书上,形成美丽的剪影。而他竟读得如此专注,旁若无人,仿佛全未感觉到周围的吵闹和目光。\n斯咏悄悄停在了毛泽东的身后。“嗨!”毛泽东一回头,身后站着的,居然是斯咏:“嗨,是你呀,这么巧?”\n斯咏说:“我有点事,约了朋友在这儿碰头。你怎么……在这儿读书啊?”\n“哦,我英语成绩不太好,所以抽时间多练一练喽!”毛泽东看斯咏一副茫然的样子,又说, “是这样,我呀,有个毛病,性子太浮,读书也好,做事也好,旁边稍微一吵我就容易分心。古人不是说‘闹中取静’吗?南门口这里,最吵最闹人最多,所以我专门选了这个地方,每天来读一阵书。”\n“哦,身在烈火,如遇清凉境界?”斯咏和他开玩笑。\n“那是佛祖,我有那个本事还得了?只不过选个闹地方,练点静功夫,也算磨一磨自己的性子吧。”毛泽东说完,又捧起了书。\n望着毛泽东泰然自若的样子,斯咏不由地笑了。她索性在毛泽东身边坐了下来,问道: “你在读课文啊?”\n“我最差的就是口语,老是发音不准,只好多练习了。哎,你的英语怎么样?”毛泽东看斯咏自得的表情就知道她的英语一定不错,于是赶紧书捧到了两人中间,说,“那正好啊,我把这一段读一读,你帮我挑挑毛病。 It will be covered with some soil by me……”\n“等一下。”斯咏指着书上的单词:“这个词读得不准,应该是covered.”\n“covered.”毛泽东的发音仍然有点不地道。\n斯咏:“你看我的口形——covered.”\n毛泽东:“covered.”\n斯咏点点头。\n毛泽东:“我多练两遍:covered,covered,It will be covered with some soil by me……”\n碧空如洗,阳光轻柔。一教一学,斯咏与毛泽东的声音交替着。闹市的尘嚣似乎都已被拒之二人之外,只有清澈的英语诵读声,仿佛要融入这冬日的阳光之中……\n“斯咏,斯咏……”街对面,警予站在黄包车旁,正向这边招手叫着。\n“哎。”斯咏答应着起身,“对不起,我约的朋友来了。”\n毛泽东笑说:“哦,没关系,我也约了人,一会儿还有事。”\n斯咏跑到跟前,警予问:“谁呀那是?”“一个熟人,以前认识的,正好碰上。” 斯咏说道。\n这一天中午,警予、斯咏和一贞都等在一师对面的茶馆里,可来的却只有刘俊卿一个人。一贞忙问:“俊卿,到底是怎么回事,你不是说你那两个同学都答应了吗?”\n刘俊卿低着头,显然他没有兑现他的承诺,只得回避着她们的目光,吞吞吐吐地回答:“他们说……哎呀,我怎么说呢?”“是什么就说什么。”警予催促道。\n“他们……他们两个就这样,平时在学校里就那副嘴脸,一天到晚趾高气扬,把谁放在眼里过?一说是你们两位外校女生来找他们请教,那眼珠子,都快翻到天上了。还说我是没事找事,跟你们一样,吃饱了撑的。”刘俊卿编瞎话的本领可真是一流,一点破绽都让人看不出来。\n“对不起,给你添麻烦了,谢谢你。”警予腾地站了起来,脸涨得通红,一拉斯咏:“斯咏,我们走!”\n两人蹬蹬蹬蹬冲下了楼。一贞想追又不好追,一时满脸尴尬。\n刘俊卿拉住一贞的手说:“对不起啊,一贞,都是我没用,弄得你的朋友不高兴。”\n一贞回头对他笑了笑,说:“这怎么能怪你呢?你已经尽力了,是你那两个同学太不通情理了。”\n“什么不通情理?他们就是看不起人,自高自大,哼!”刘俊卿总算是出了一口气,说这话的时候,心情说不出有多爽快。但他却不知道,他的谎话最终会伤害到谁。\n警予回到寝室,径直冲到自己床前,一把将床头贴的蔡和森的文章撕了下来,团成一团,砸进了字纸篓!\n斯咏跟在她身后:“警予,算了,何必生那么大气?”\n“谁说我生气了?”警予回过头来,她脸上居然露出了笑容,“跟这种目中无人的家伙,我犯得着吗我?”\n斯咏:“其实,那个蔡和森和毛泽东又不认识我们,可能……可能只是一时……”\n警予:“斯咏,不用说了,你放心,我现在呀,反倒还轻松了。”\n她仔细地撕着床头残留的文章碎片:“原来呢,我还一直以为我们比别人差多远,现在我知道了,原来也不过如此。不就是文章写得好吗?那又有什么?德才德才,德永远在才的前面,像这样有才无德、狂妄自大的人,幸亏我们没去认识,要不然,更恶心!”\n她“呼”地一口气,将撕下的几片碎纸片轻轻吹落,拍了拍手。\n第十章 世间大才少通才 # 什么是真正的因材施教?\n怎样的教育,才是科学的、先进的、\n更利于培养真人才的呢?\n是一场考试定结果,还是别的什么?\n这确实是一个值得深深思考的问题。\n一 # 在南门口闹市区的大树下读英语的毛泽东约了什么人呢?\n日近黄昏了,几个下工的苦力和学徒、小贩,拿着扁担、麻绳之类的东西来到正在读英语的毛泽东身边。\n“哟,都来了?”毛泽东把书一收,“好,大家围拢,马上开课!今天我们学的这个字,上面一个自,自己的自,下面一个大,大小的大……”\n架子车旁,刘三爹等七八个市井百姓或蹲或坐,围坐成一圈,他们中间,毛泽东拿着一根树枝,正往地上写着一个“臭”字。\n“这不是我喜欢吃的那个臭豆腐的臭字吗?” 一个小贩认出了这个字,看来他也和毛泽东有一样的嗜好。\n毛泽东点点头,问:“你再仔细看看,这个字比那个臭字还少了什么没有?”\n小贩仔细分辨着,旁边一个码头苦力伸出手指着那个字说:“好像少了一点吧?”\n毛泽东加上了那一点,说:“什么气味讨人嫌啊?臭气,什么样的人讨人嫌呢?那些自高自大,以为自己了不起的人,看了就让人讨嫌。所以大家以后记住,”毛泽东用树枝指点着臭字的各个部分,“自、大、一点,惹人讨嫌。怎么样,这个臭字,都记住了吧?好,那我再讲一个字。”\n他先往地上写了一个“日”字,这个字大家显然学过,好几个人读了出来。\n“对,日头的日。”毛泽东又往地上写了个“禾”字,“这个字我也教过大家,还记得吗?”\n又有几个人读道:“禾,禾苗的禾。”\n“对,禾苗的禾。有了好太阳,禾苗会怎么样呢?”\n“长成谷啰。”\n“对了,万物生长靠太阳,日头一照,禾苗就能长成谷,到时候煮成饭,你一闻,嗯,怎么样啊?”\n“香。”\n“对了,就是一个香字!”毛泽东先日后禾,把香字写了出来,“日头照得禾苗长,这就是香喷喷的香。大家都记住了吗?”\n“原来这就是香字啊……记住了……”\n毛泽东扔掉树枝,拍打着手:“好了,今天的课就上到这里。放学了。”\n“谢谢您了,毛先生。”\n“讲什么客气?明天再来,我再给你们教五个字。”\n人群散去,毛泽东一抬头,孔昭绶迎面向他微笑着说:“毛老师,课上得不错啊,有板有眼的。”孔昭绶常常在这条路往来于学校和家之间,他已经不是第一次在这里看到毛泽东教人识字了,以往还以为只是碰巧有人请教,这次才知道,原来毛泽东是有计划地在这么做。\n毛泽东和校长并肩往学校走着,边走边给他解释说:“我这是在遵循徐老师的日行一事呀。他说,一个人,不必老想着去做什么惊天动地的大事,而应该着眼于每天做好一件小事,日积月累,才能真正成就大事。我们读书会专门讨论了这个原则,都觉得徐老师说得好。所以我们约好了,每人每天找一件实事来做。”\n孔昭绶赞许道:“你们这个读书会倒还搞得有声有色嘛。”\n“大家都谈得来,还不就凑到一起了。”\n“你怎么会想起教人认字呢?”\n“读师范嘛,以后反正要教书的,就算实习嘛。校长也说过,民国教育,就是要注重平民化,如今谁最需要教育,还不是那些一个字都不识的老百姓?”\n孔昭绶站住了,笑容也渐渐化为了严肃:“润之,你说的没错。师范的责任,就是要普及教育。学校应该想应该做却还没有想到、做到的事,你先想到、先做到了,谢谢你。”\n毛泽东有点不好意思:“我可没想那么多,我只是觉得,凡事光嘴上讲个道理没有用,只有自己去做,才算是真道理。”\n望着毛泽东,孔昭绶认真地点了点头。\n二 # 第二学期就要结束了,一师公示栏里,已经贴出了大幅的“期末预备测验考程表”,上面是各年级各科考程安排。大考前的紧张气氛扑面而来,学生们正端着饭从走廊上经过,不少人边吃手里还边捧着书。\n八班寝室里,个个同学正在聚精会神地复习。为了不影响他们,易永畦在寝室外的走廊上给毛泽东讲理化:“……质量,是物体所含的物质多少;重量,是地球对物体产生的引力大小。”\n毛泽东听得满头雾水:“可两个数字都一样啊。”\n“数字上看起来是一样,其实是两个概念。”\n“数字一样,又是两个概念……哎呀,我还是分不清。”\n“没关系,我再跟你从头讲一遍。”\n“润之哥,”萧三跑过来,把两份报纸递给毛泽东,“你的报纸,我帮你领回来了。”\n“谢谢啊。”拿到新报纸,毛泽东精神来了,“永畦,这些物理啊,化学啊,把我脑袋都搞晕了,要不我们休息一下,我先看看报纸。”\n“行,那你先看吧。”易永畦起身回了寝室。\n打开报纸,毛泽东浏览着标题,一篇有关欧战中巴黎保卫战况的报道首先吸引了他。读着报道,他的眉头突然皱了起来:“林木金阿皮耶?这是什么意思?”毛泽东立刻就跑去图书馆查,可查来查去没查到眉目,干脆又拿了报纸去找杨昌济,“我就是纳闷,这到底是个什么东西,怎么法国人用了它,一晚上就把军队运了那么远?”\n望着报纸的这行字,杨昌济的眉头也皱了起来:“这个词,我还真没见过,估计是从法语音译过来的吧?”沉吟了一下,杨昌济站起身:“要不,去请教一下其他先生吧。”\n杨昌济带着毛泽东,询问着一个个老师。易培基、黎锦熙……一个个老师看着报纸,都回答不上来。方维夏说:“林木金阿皮耶?哎哟,这个我还真不知道,我也没去过法国,对法语……对了,我想起了,纪督学是法国留学回来的。”\n杨昌济带着毛泽东来到督学办公室。纪墨鸿看了报纸,很轻松地说:“哦,这是法语中的一个词,通常见于上流社会很高雅的用法,翻译成汉语的话,可以叫做——出租汽车。”\n“出租汽车?”毛泽东没听明白。纪墨鸿笑了,他认真地说:“汽车你知道吗?”毛泽东点头,“听说过,是德国人发明的一种交通机器,我在报上见过照片。”\n纪墨鸿和蔼地说:“对喽。林木金阿皮耶指的是在大街上出租,付钱就可以坐的那种汽车,你付了钱,开车的司机就送你去要去的地方,好像我们这儿的黄包车,所以叫出租汽车。”\n“哦,就是英语里的TAXI嘛。” 杨昌济也明白过来。纪墨鸿笑说:“就是它。这篇报道是说德国军队进攻巴黎,法国人临时征用了全巴黎的七百辆出租汽车,一晚上把后方的军队运上了前线,所以保住了巴黎城。怎么样,你明白了吗?”\n毛泽东一拍手说:“明白了。难怪报纸上说这是人类有史以来调动军队最快的一次,兵贵神速,就是这个道理。”他兴奋地向纪墨鸿鞠了一躬:“谢谢您了,纪先生。”\n“谢什么?解惑答疑,本是我们做先生的责任嘛。”纪墨鸿端起茶杯,不经意地说:“哎,杨先生,一师什么时候增加军事课程了,我在教育大纲上没见过啊。”\n杨昌济诧异道:“军事课程倒没有,这只是毛泽东的个人兴趣而已。”“个人兴趣?”\n纪墨鸿眉头一皱,都举到了嘴边的茶杯又停下了:“这么说,毛同学,这不是你的课业?”毛泽东摇摇头:“不是。我对时事和军事平常就感兴趣,看到不懂的,所以才来请教先生。”\n“砰”的一声,纪墨鸿把茶杯重重往桌上一放:“乱弹琴!”毛泽东与杨昌济都吓了一跳。\n仿佛是意识到了自己失态,纪墨鸿赶紧控制了一下自己的语气:“毛泽东同学,你身为学生,不把精力用在自己的课业上,搞这些不着边际的玩意干什么?欧洲打仗,跟你有关系吗?没有嘛!搞懂一个兵贵神速,你的哪科分数能提高?不行嘛!——对了对了,还有十几天就要期末考试了,明后天你们全部科目还要摸底测验,你还不抓紧时间好好复习,是不是科科都能打一百分啊?”\n毛泽东被他一顿训斥,都懵了。杨昌济摇头说道:“纪先生,话也不能这么说,碰上问题,及时求教,这也是润之的优点嘛!”\n“那也得跟课业有关!这是什么?不务正业嘛!”纪墨鸿摇着脑袋,“早知道是这种问题,我才不会回答你呢!”他瞪了一眼毛泽东:“还站在这儿干什么?还不回去复习功课?”\n“纪先生再见。”毛泽东窝了一肚子气,转身就走。盯着纪墨鸿,杨昌济似乎有话说,但停了一停,只是道:“打扰纪先生,告辞了。”等房门一关上,纪墨鸿抓起那份报纸,便往字纸篓里一扔,“什么板仓先生,学生不懂事,他还助长劣习,如此为人师表,太不负责任了!”\n三 # 纪墨鸿对杨昌济教育学生的方式有意见,杨昌济对纪墨鸿教育学生的方式又何尝不是意见大大的!他非常担心纪墨鸿这样对待毛泽东,会打击毛泽东的求知欲,便把自己的担心告诉了老朋友孔昭绶。\n“毛泽东那个倔脾气,哪那么容易受打击?”孔昭绶满不在乎地安慰杨昌济,“哎,说起他,我又想起那天看到他教人认字的事。你看好的苗子,确实不错啊。说得天花乱坠,做起来眼高手低,这是读书人的通病,我最怕的,就是我们的学生也变成这样。这个毛润之倒确实不同凡响,不但能想能说,最难得的是,他想到什么,就去做什么,他愿意化为行动,这才是务实啊。”\n杨昌济很高兴孔校长能这样赏识他钟爱的学生:“润之的优点也就在这里。其实,论天资,他也并不见得有什么特别惊人之处,但别人坐而论道,他总是亲力亲为,所以长进得就是比一般人快……”\n杨昌济的话还没说完,校长室的门“砰”的一声被猛地推开了,黄澍涛挟着一叠考卷,一脸怒气冲冲地冲了进来,把孔昭绶和杨昌济都吓了一跳。\n“澍涛,这是怎么了?谁把你惹成这样?” 孔昭绶问道。\n“还能有谁?毛泽东!”黄澍涛把手里的考卷往桌上一拍。原来图画测验,黄澍涛监考。考试内容是:日常实物素描,请大家各自画出一件日常生活常见的实物。结果白纸发下去不到一分钟,毛泽东就交了卷子。\n“就画了这么个圈圈!”黄澍涛敲着孔昭绶手里的一张考卷,“你猜猜他说什么?他说这是他画的鸡蛋!”\n那张纸上,孤零零地还真就是一笔画了个椭圆形的圈。孔昭绶疑惑地问道:“怎么会这样?”\n“怎么不会这样?就这个毛泽东,每次上我的课,从来就没有认真过!——你有本事,功课学得好,你就是一节课都不上我也不怪你,可这是学得好吗?这不是胡扯蛋吗?” 黄澍涛越说越气。\n这时方维夏正好推门进来,说道:“校长,这次期末预备测验的数学和国文成绩单已经出来了。”他缓了一缓,看了杨昌济一眼,说:“有一个学生成绩比较怪——国文第一名,顺数;数学也是第一,可惜是倒数。”孔昭绶怔了一怔,问:“是谁?”\n“本科八班的毛泽东。” 方维夏答道。孔昭绶与杨昌济不觉面面相觑。信步来到教务室,却见老师们都在,正议论毛泽东的奇怪成绩。\n孔昭绶放下了手里的成绩单,说:“从这次摸底测验的成绩单上来看,毛泽东的确存在一定的偏科现象。各位都是第八班的任课老师,到底是什么原因?我想听听各位的意见。”\n费尔廉第一个开了口说:“从我的音乐课来看,毛泽东这个学生在音乐方面缺乏天赋。别的学生一遍就能学会的音乐,他五遍、十遍还要跑调。”他指指脑袋,“我觉得,他这里有问题,他太迟钝了,真的,这个学生不是不用功,他是非常非常的迟钝。”\n袁吉六一听,脸板起来了,当即回敬道:“毛泽东迟钝?他都迟钝了,一师范还有聪明学生吗?袁某教过的学生也不算少了,我敢断言,长沙城里最聪明,也最肯用功的学生就是毛泽东!”\n“不会吧?”数学老师王立庵情绪上来了,“毛泽东还用功?我教六个班的数学,还没见过他这么不用功的学生呢,上课上课老走神,作业作业不完成,我看他脑子是没有一点问题,就是不肯用功!”\n“你们说的毛泽东是我认识的毛泽东吗?”饶伯斯显然被搞糊涂了,“毛泽东上我的英语课是很认真很认真的,比一般学生刻苦得多,他就是基础差,所以成绩只是一般。我觉得,他是一个天分一般,但很用功的学生啊。”\n黄澍涛冷哼道:“依我看啊,聪明勤奋,他是哪一条都不占!”\n孔昭绶点头说:“嗯,又聪明又勤奋,聪明但不勤奋,勤奋却不聪明,又不聪明又不勤奋,这个毛泽东怕是个孙行者,七十二变啊!”\n评价如此悬殊,大家一时都不知该说什么好了。\n“我说两句吧。”一片沉静中,杨昌济开口了,“毛泽东的成绩单,我刚才也看了,总的来说,凡社会学科的课,他是门门全优,非社会学科的课呢,成绩确实不理想。你可以说他是一个偏科的学生,也可以说他是一个独擅专长的怪才。但我以为,他的身上,首先体现了一种难能可贵的东西,那就是个性!\n“的确,学生应该学好功课,偏科也证明了这名学生发展不全面。但学生为什么会偏科呢?原因就都在学生身上吗?” 杨昌济看了看大家一眼,顿了顿说,“我觉得不尽然。我国之教育,向来就有贪大求全之弊!以我校为例,部颁教育大纲规定的这些课程,可谓面面俱到,一个师范生,从国文、历史,到法制、经济,乃至农业、手工,文理工农商,无所不包。假如是小学、中学,那是打基础,全面培养学生最基本的知识,确实是必要的。可我们是小学、中学吗?不是,我们是高等专科学校啊。如此驳杂而主次不分的功课设计,这科学吗?这种恨不得将每个学生都培养成全才、通才的教育模式,本来就为教育界诸多有识之士所诟病,我本人也向来是不赞同的。\n“更令人担忧的是,把考试分数视为评价学生的唯一标准。学生的素质如何,能力怎样,没有人关心,每日里功课如山,作业如海,但以应试为唯一目的,把学生通通变成了考试的奴隶——须知一人之精力有限,面面俱到则面面不到,门门全优则门门不优,许多才质甚佳之优秀学生的个性,常常就湮没在这功课之山,作业之海里,便有峥嵘头角,也被磨得棱角全无了!”\n他说得不禁激动起来,站起身来:“以毛泽东为例吧!这个学生我接触较多,还是比较了解的。各位如果看过他的读书笔记,听过他讨论时的发言,就会发现,他是一个非常肯思考、也非常善于思考的学生。他的着眼点,从来不仅仅局限于个人之修身成才,而是把自己的学业,自己的前途、未来,与社会之发展,国家之兴衰,民族之未来紧紧联系在一起。身无半文而胸怀天下,砥砺寒窗而志在鸿鹄,这样的学生,你怎么可能用僵化呆板的应试教育来框死他,怎么可能要求他面面俱到、门门全优?\n“我们的教育应该提倡学生全面发展,但是如果出现某些个案就如临大敌,实在大可不必。因此,我们这些教书育人的先生,又何必为苛求某几门功课的成绩,硬要扼杀一个个性如此鲜明的学生的天性呢?”\n“杨先生这话,太不负责任了吧?”\n这个时候,纪墨鸿走了进来,“论见识,纪某是少了点,及不上杨先生。”纪墨鸿剔着指甲,慢条斯理、有意扭曲事实地说:“所以是怎么想,也想不明白,这上课不专心,读书不用功,校规校纪视若儿戏,考试成绩一塌糊涂,怎么他就成了个大才?要是这样就是大才,哈,那就好办了,学生通通不用上课了,考试通通取消掉,满山跑马遍地放羊,到时候,第一师范人人都成了大才。孔校长,是不是明天开始咱们就这么办啊?”\n杨昌济解释道:“纪先生不要误会我的话。昌济也没有说什么规矩都不要了,我说的只是毛泽东这个特例,他也并非上课不专心,读书不用功。”\n“特例?校规校纪就不许特例,部颁大纲更不容特例!”纪墨鸿毫无余地地回答。\n杨昌济继续说:“毛泽东的成绩,并非一塌糊涂,至少三分之二以上的科目,他堪称出类拔萃,虽然三四门功课还要加强,何必非得强求尽善尽美?”\n纪墨鸿敲着桌子:“三条腿的桌子站不稳!学生进校,学的是安身立命的本事。杨先生如此放任,他日这个毛泽东走出校门,万一就因为这几门功课不行砸了饭碗,只怕不会感激杨先生吧?”\n杨昌济摇摇头说:“纪先生是不了解毛泽东,此生读书,绝不是为了有碗饭吃。”\n“饭碗都不要了,他还想要什么?想上天啊?好,就算他可以不要饭碗,他去做他的旷世大才,其他学生呢?开出这么个先河,立起这么个榜样,岂不是要让其他学生都学他那样随心所欲,到时候,还有学生肯用功吗?”\n黎锦熙冷冷地说:“我想这倒不至于吧?毛泽东的用功,那是全校闻名的。我是事务长,我知道,每天晚上全校睡得最晚,也起得最早的,总是毛泽东,每天熄灯以后,他还要跑到茶炉房,借值班校役的灯光看好几个钟头的书。许多学生现在开夜车学习,还是受他的影响呢。”\n“又是一条,听听,又是一条!”纪墨鸿桌子敲得更响了,“熄灯就寝,这也是学校的规矩!熄了灯不睡觉,还要带着其他学生跟着他违反校规,果然是害群之马!不严惩何以正校纪?”\n黎锦熙不禁张口结舌。杨昌济笑说:“这真是正说也是纪先生,反说也是纪先生。”\n纪墨鸿冷笑说:“我没有什么正说反说,我只有一条:学校不是菜市场,一句话,不能没了规矩!”\n杨昌济肃然说:“我也只有一条,不能为了规矩扼杀了人才!”\n教务室里,一片宁静,一时间,气氛仿佛能点得燃火一般。坐在角落里的王立庵咳嗽了一声,却发觉自己的一声咳嗽在这一片剑拔弩张的安静中显得格外惹耳,赶紧强压住了声音。一片压抑的寂静中,连正在给老师们添茶的校役也小心翼翼地放轻了动作。\n“各位先生,我认为毛泽东的偏科,既不是他的能力缺陷,也不是学习态度有问题;广而言之,我们的教育,究竟应该以学生的考试分数为唯一标准,还是应该舍弃应试观念,尊重学生的个性,因材施教,我看,坐在这里讨论,也出不了结果,还是要从学生本人身上,去找真正的原因。”孔昭绶站了起来,说,“我建议,讨论先到这里。几位对毛泽东偏科有看法的先生,今天晚上,我们一起去找毛泽东谈一谈,再作定论,好不好?”\n四 # 老师们在教务室争论不休的同时,子升与蔡和森也在君子亭里就偏科的事情围攻毛泽东。\n“润之,我们是朋友,是朋友才会跟你说真心话。你这个偏科的毛病,我们是有看法的。读书不能光凭兴趣嘛,你我都是学生,学校规定的功课,怎么能想学什么学什么,不想学的就不学呢?”萧子升苦口婆心地劝毛泽东。\n“你以为我愿意啊?我也想通通学好,可是有些功课,我真的学不进去嘛。”毛泽东为自己辩解着。\n“你就是喜欢找借口。国文你学得好,历史、修身、伦理、教育那么多功课你都学得好,为什么数理化、音乐、美术就学不好呢?明明就是没用心嘛。”\n“我用了。”\n“你用了?用了怎么会学不通呢?”\n蔡和森看子升的话毛泽东听不进去,也开了口:“润之,我相信你说的是真心话,可是,偏科终归不是什么好事,你也不能总这样下去吧?”\n“我也烦咧。我就不想门门全优啊?可是,有些功课,我一拿起书就想打瞌睡,逼起自己看都看不进——有时候想想,也是想不通,那些个烂东西学起有什么用嘛?”毛泽东边说边叹了口气。\n子升问:“怎么能说没用呢?数学没用啊还是美术没用啊?你以后毕了业,要你教数学你怎么办?”\n毛泽东扯歪理:“我未必非要教数学啊?我可以教别的嘛。照你这么讲,我什么都要教,什么都要学,那读书不成了填鸭子?给你什么就往肚子里塞什么,以后一个个掏出来,都成了虎牌万金油,什么病都治,什么病都治不好,你就高兴了?”\n子升瞪着毛泽东,说:“什么叫我高兴了?学校有规矩,部颁有条例,这规矩、条例定出来,就是给人守的嘛。”\n“有时候,规矩定出来,也是给人破的。”\n“好好好,你破你破,反正跟你讲道理,永远也讲不清。”\n“你们俩呀,也不要争了。”\n旁边听着二人的唇枪舌剑,蔡和森仿佛思考清楚了什么,若有所思地说:“子升,其实仔细想想,润之说的,也不是全没道理。学习的目的,总不能光为了考试分数,数学不好,他以后可以不教数学,他教别的科目就是。所谓尺有所短,寸有所长嘛。”\n两个人说好了是来劝毛泽东的,这个时候见蔡和森这样说,子升火了:“你呀,和稀泥!”\n“我还没说完呢。话又说回来,润之,民国的教育才刚起步,学校的功课设计,的确不尽合理,但改变现实需要一个过程,规矩、条例也是客观存在,如果光凭热情和兴趣就想超越这个过程,什么规矩都不顾,我行我素,那也不现实啊。我知道,你的个性不是那种能被规矩框死了的人,可我们退一万步来想,分数毕竟还是决定升学和毕业的标准,你的成绩单,也要带回家去,给伯父、伯母过目。润之,你难道就忍心拿着一份几科不及格的成绩单回家,告诉你母亲,不是你学不好,是学校的规矩不对,所以你就是及不了格。那时候,你的母亲会怎么想?就算她不怪你,可她的心里,对你的学业,对你的前途又会产生多大的担忧?你就忍心让她为你着急吗?”\n毛泽东顿时沉默了。三个人坐在亭子里,各自想着心思。\n一直到晚饭后,毛泽东还在想着蔡和森最后说的那番话,他从枕头底下拿出那半片断裂的顶针,放在手心里。盯着母亲的顶针,毛泽东的目光中,有一丝内疚,有一丝思索,有一份牵挂,更有一份责任。不知不觉中,他收紧了拳头,顶针被他紧紧握在了手心。看看周围因为考完了试正在放松的同学,他拿了几本书,悄悄走了出去。因为有心事,在教室走廊上和王子鹏迎面错过的时候,连子鹏和他打招呼都没听到。\n子鹏盯着毛泽东的背影,直到毛泽东进了教室,知道他是去学习了,心里暗暗有些佩服。回到寝室里,子鹏看到周世钊他们四五个同学都围在桌子旁下象棋,参战的旁观的,正玩得来劲,不由得又想起了毛泽东,就在床头坐下,拿出书来看。过了一会,孔校长带着王立庵、费尔廉、黄澍涛、饶伯斯几位老师进来了。下棋的同学立刻就散开,站直了,向老师们问好,子鹏也赶紧从床上跳了下来。看到孔校长的目光落到了棋盘上,周世钊不好意思地解释:“今天刚考完,大家想轻松一下。”\n孔昭绶点点头,微笑着说:“哎,毛泽东呢?他不在寝室吗?”\n其他同学你看我我看你,这时候才发现毛泽东今天没和大家同乐。王子鹏一向不多言语,但此时见没人吭声,只好告诉校长,他刚才看见毛泽东往教室那边去了。\n孔昭绶点点头,一边叫学生们继续“战斗”,一边带着老师们去了八班教室。透过窗子,却见烛光下,毛泽东坐在课桌前,正在用圆规、尺子画着什么。他显然遇上了困难,左比右算,绞尽脑汁,冥思苦想。\n在半开的教室门口,孔昭绶与老师们交换了一个目光——他做了个噤声的手势,轻轻地、无声地把门推开了一些。\n聚精会神的毛泽东全未察觉,仍然埋头运算着。他的面前,是摊开的数学课本,还有零乱的、写满了运算过程的、画满了几何图形的草稿纸。\n一只手轻轻拿起了桌边的一张草稿纸。毛泽东一抬头,不由得一愣,赶紧起身:“校长,各位老师,找我有事吗?”\n老师们谁也没作声,只是相互交换了一下目光。数学老师王立庵突然拉过一张凳子,在他旁边坐了下来,“我找你来补习数学呀。有哪些地方不懂?说吧。”\n毛泽东一时还没反应过来。孔昭绶一拍他的肩膀:“老师都坐你身边了,还傻站着干什么?先补习。”\n他说完,转身就向门外走,似乎是突然想起什么,孔昭绶又回头说:“对了,润之,明天下了课,记得到我办公室来一趟。”\n五 # 第二天下了课,毛泽东到了校长室,忐忑不安地看着校长递过来的那叠成绩单。“那么紧张干什么?”孔昭绶突然笑了,“我说过要怪你了吗?十根手指没有一般长短,人不会十全十美,这个道理我也知道。”孔昭绶收起了笑容:“但是话要讲回来,润之,一个学生,对待功课过于随心所欲,绝不是什么好事。同样,一个学校,因材施教固然重要,但也绝不等于放任自流。”\n毛泽东感激地看着校长,认真地听着。“你的长处与短处,我相信你自己已经有所认识。我可以不强求你门门全优,好比音乐、美术这些需要特定天赋的功课,要你马上突飞猛进,本身也不现实。但有些功课,特别是数学、理化这些基础主科,是一个学生必须要掌握好的。就算你在这些功课上缺乏兴趣,也不可以轻言放弃。你明白吗?”\n“我明白,校长。”“那,愿不愿意跟你的校长达成一个约定?还有两周就要正式期末考试了,我不知道你能考出多少分,我也不要求你一定要考到多少分。我只要求一条:尽力——对你所欠缺的功课,你的确尽了全力,这就够了。能答应我吗?”\n“我答应您,校长。”“那我也答应你,只要你尽了力,你将得到一个意外的奖励。”孔昭绶他站起身,伸出手:“我们一言为定。”犹豫了一下,毛泽东伸出了手。校长与学生的手紧握在了一起。\n第二天的全校师生大会上,孔昭绶严肃地发表了他的《第一师范考评修正条例》:“各位先生:经过多方征求各科任课老师的意见,及报请省教育司批准,校务会决定,第一师范将改变过去单纯以考试评定学生优劣的做法。即日起,学生各科成绩,将由以下三部分组成后综合评定:其一,日常课堂问答、课外作业及实习能力占40%;其二,各科课内外笔记心得占20%;其三,考试成绩占40%,合计100%.做出这一修正,就是要改变以往一考定优劣、一考定前程的僵化体制,摆脱只讲形式的应试教育,将学生的考核融入整个学习过程中,全面地、科学地认识和评定我们的学生!”\n当晚,孔昭绶在《第一师范校长日志》上写道:“什么是真正的因材施教?怎样的教育,才是科学的、先进的、更利于培养真人才的呢?是一场考试定结果,还是别的什么?这确实是一个值得深深思考的问题。民国的新式教育刚刚起步,僵化守旧,唯分是举之弊,积淀甚深,从毛泽东这样有个性的学生身上,我们又能否探索出一种全新的人才观,使第一师范真正成为未来人才之摇篮,科学教育之殿堂呢?”\n随后两周所有同学都在忙碌之中。终于到了期末考试结束。这一天当成绩单汇总到校长办公室时,孔昭绶的心情一点也不亚于坐在他面前的毛泽东。他拿起成绩单,看了毛泽东一眼,肃然说:“你的理化成绩是——”毛泽东瞪大了眼看着他,孔昭绶的脸上露出微笑,“67分,及格了。”\n毛泽东顿时松了一口气。这时孔昭绶的笑容却突然又没了,“不过数学,可不如理化。”\n毛泽东一下子又紧张起来。“才只得了,”孔昭绶盯着毛泽东,脸上突然浮起笑容,“61,也及格了!”\n猛地一挥拳头,毛泽东往椅背上一仰,他长长地舒了一口气。“好了,现在,该我兑现承诺了。”孔昭绶放下成绩单,“我听昌济先生说过,你对船山学派的理论很感兴趣,是吗?”\n“是,校长。”\n孔昭绶笑道:“有个消息告诉你:湖南学界已经决定在小吴门重开船山学社,专门研讨王船山先生的学术思想和湖湘学派的经世之论。后天,学社就会开讲,以后,它将成为湖湘学术交流的中心。我看一师现在的课程好像也满足不了你这方面的需要,想不想要我帮你办一张听讲的入场证?”\n毛泽东喜出望外,“要,当然要!校长,能不能多办几张?子升和蔡和森他们对这方面也很感兴趣的。”\n孔昭绶笑道:“我试试看,应该不会有问题——怎么样,这,算不算我给了你个意外惊喜啊?”\n“算!算!我现在就去告诉他们!”毛泽东高兴得起身就要走。孔昭绶却叫住他,“等一下。”他从抽屉里取出一片钥匙,放在桌上。\n“这是?”毛泽东疑惑地看着他。\n“校阅览室的房门钥匙。我已经通知了管理员熊光楚,以后,他每天下班的时候会把灯加满油。你呢,就不要再在寝室里点蜡烛或者是跑到茶炉房去借光了,那里光线不好,坏眼睛。” 孔昭绶说道。\n望着面前的钥匙和孔昭绶和蔼的笑容,毛泽东一时真是无以言表,只说:“谢谢您了,校长。”\n"},{"id":128,"href":"/zh/docs/culture/%E6%81%B0%E5%90%8C%E5%AD%A6%E5%B0%91%E5%B9%B4/%E7%AC%AC21%E7%AB%A0-%E7%AC%AC25%E7%AB%A0/","title":"第21章-第25章","section":"恰同学少年","content":" 第二十一章 逆书大案 # 《护国浪潮席卷全国 袁逆世凯穷途末路》、《北洋将领全线倒戈 窃国大盗众叛亲离》、《袁世凯宣布取消帝制 恢复共和》、《袁逆心腹汤芗铭仓皇逃离湖南》、《湘军元老谭延闿再度督湘》……\n一 # 汤芗铭正要去参加拥戴洪宪皇帝登基大会,副官推门进了办公室,啪地立正,递上一份刚收到的广西、贵州通电。\n“说什么?”正展开手让卫兵扣扣子的汤芗铭显然不方便接电文,他今天穿上了肩章绶带、白旄高耸的华丽将军制服,两名卫兵正侍候着他扣上扣子,戴上雪白的手套。\n“贵州将军刘显世、广西将军陆荣廷通电全国,宣布反对帝制,支持护国军。”\n汤芗铭的手微微一震,抬手挡住了正要给他戴上帽子的卫兵。他伸手似乎是要来接那份电文,手伸到一半,却僵了一僵,又收回去了。拿起军帽,汤芗铭端正地戴上了,冷静地说:“去会场。”\n露天会场上,整齐的军乐队卖力地演奏着进行曲。鼓乐喧天中,“洪宪登基,三湘同庆”的横幅下,是披红挂彩的主席台。台下,一排排刺刀闪闪发亮、荷枪实弹的城防营士兵前后左右,几乎是包围了整个会场。刘俊卿带着几十个游动的侦缉队便衣,正监视着来自长沙各学校的数千师生入场。\n台上的欢天喜地与台下的一片冷漠、四周的如临大敌,构成了整个会场古怪的气氛。整齐的城防营士兵队列前,城防营营长张自忠穿一双锃亮的军靴正缓缓地踱着步子,冷漠地打量着眼前的一切。\n“一贞,”人丛中,刘俊卿看见了正在入场的一贞,兴奋地打着招呼, “我在当班,开完会等着我,我送你回去。”\n“哎。”一贞向他点了点头,答应着,追上了本校的队伍。\n纪墨鸿拿着白铁皮的喇叭,出现在台前:“各校注意了,庆祝大会马上开始,请各校代表速来领取洪宪大皇帝圣谕……”\n一师的队伍中,张昆弟悄悄接过了毛泽东递来的两卷红绸,与罗学瓒等人站了起来。\n看看主席台,张自忠随口往地上吐了口唾沫。\n主席台的一侧,成堆贴着“洪宪圣谕”标签的书箱堆放着,侦缉队的便衣正在纪墨鸿的指挥下向各学校领书的代表发放“圣谕”。书堆旁边,摆着两大捆鞭炮,和两卷卷好的红绸。靠着罗学瓒等人的身体掩护,张昆弟悄悄挨了过去,背着身子,取出了自己怀里暗藏的两卷红绸,调换了原来的两卷红绸。\n“让开让开。”两名便衣排开领书的人挤了过来,扒开张昆弟,一个抱起鞭炮,一个提起了红绸卷轴。在纪墨鸿的指挥下,两捆鞭炮与红绸对联在主席台两侧升了起来。\n台下,正走回一师学生方阵的张昆弟向毛泽东使了个成功的眼色。\n一箱箱“圣谕”搬到了一个个学校的师生们面前。\n一个个负责发书的老师带着压不住的厌恶和无奈,打开了一箱箱书,里面都是装得整整齐齐的《洪宪大皇帝圣谕》。\n一师学生方阵前,负责发书的陈章甫也打开了一箱书。\n“第六班、第七班……”他带着厌恶的神情,机械地取出成捆的书发给各班领书的代表。\n“第八班。”陈章甫又提起一捆书,正要交给来领书的周世钊和毛泽东,这捆书却没捆牢,哗啦散了一地\n陈章甫愣住了,散在地上的书,除了最上面一本“圣谕”,下面的居然全变成了《梁启超等先生论时局的主张》。\n看看他发愣的样子,毛泽东催促道:“章甫兄,发呀!”\n“发,继续发!第九班的谁来领?”陈章甫突然回过神来,懒洋洋的声音变得精神十足,拿书的动作也干净利落起来。\n一捆捆书打开了、一个个发书的老师都露出了惊讶的神情、一本本《梁启超等先生论时局的主张》传到了不同的学校、不同的学生手里,一张张意外、惊诧的脸很快都转成了兴奋,一个个发书的老师、学生都突然来了精神,游动监视的侦缉队便衣们看见这前后巨大的变化,都有些糊涂了。\n主席台上一阵骚动,原来是文武官员、各界代表们簇拥着汤芗铭到会了。汤芗铭殷勤地给陶会长抽出了椅子:“陶翁,今天可就辛苦你了。”似乎是想回应一个笑容,陶会长脸上却实在是掩饰不住的苦涩。\n台下的会场,嘈杂声却越来越大,人群兴奋,一片嗡嗡之声。台上的官员都有些糊涂了。汤芗铭也不由得皱起了眉头。副官看了一眼他的表情,连忙跑下台去。汤芗铭随即换上了笑脸,一手如往常一样轻松地把玩着玉手串:“陶翁,我看,可以开始了吧?”\n陶会长答应着站起身来,动作却犹犹豫豫,仿佛就要上刑场一样。\n台下一片混乱中,学生们的声音越来越大,到处是兴奋莫名的表情,几乎所有的人都在迫不及待地打开手里的书。刘俊卿奇怪地皱紧了眉头。他突然走上前去,拦住了一个正在发书的老师,抢过一本书来——他不由得呆住了,猛地把箱子里剩下的书往地上一倒,他一阵乱翻:所有的书都是《梁启超等先生论时局的主张》!\n副官正好跑到他面前,问他会场的秩序为什么这么乱,在学生的嘈杂声中,刘俊卿把书递到他面前。副官翻了翻书,转身往台上跑去。\n台上,陶会长终于艰难地站到了台前,开始主持大会。“拥戴……”刚说了两个字,他就觉得自己的嗓子很是干涩,使劲咳嗽了两声,这才又重新说,“拥戴洪宪皇帝登基庆祝大会,现在开始。”\n台下,两串鞭炮噼噼啪啪响了起来,与此同时,军乐队的鼓乐骤然大作。悬在鞭炮旁的对联同时放了下来。轰然一声,台下突然一片惊讶的声音,紧接着,惊讶声变成了一片笑声!台上,所有的官员们都愣住了。陶会长也被弄糊涂了,他不由得转过头来,往两边一看,放下的对联居然不是预先准备好的,而是一幅他从没见过的新联: “袁世凯千古,中华民国万岁”。纪墨鸿和大家一起在看,学者习惯,他没想那么多,只从字面分析着:“这‘袁世凯’对不起‘中华民国’呀?!”话才说完,他猛然反应过来,吓得一把捂住了自己的嘴。\n汤芗铭腾地站了起来,正要说什么,副官跑到了面前,将一本《梁启超等先生论时局的主张》双手呈送给他:“大帅,发给学生的圣谕被人换了,全部变成了这本逆书。”一手接过书,一手紧攥着那串手串,汤芗铭眼睛微微一眯四下扫视着:台下哄笑声、呼应声响成了一片,有学生正扯开嗓子喊“袁世凯对不起中华民国喽!”台两旁,长长的鞭炮还在起劲地炸着,仿佛是在给起哄嘲笑的学生们加油鼓劲。鞭炮燃到了尽头,最末那枚最大的鞭炮猛然炸响,“砰!”汤芗铭一向平和的脸色一阵发青,手骤然一紧,那串玉手串突然断了,一颗颗晶莹的珠子散落一地!他紧绷着脸,转身就走,台上的官员们也赶紧纷纷起身。\n台上,除了还忙着满地捡那串散珠子的纪墨鸿和副官,只剩了陶会长还呆呆站着。望着人群中闹得最起劲的毛泽东,再看看周南学生方阵中欢呼雀跃的斯咏、警予,他仿佛这才明白了什么,心里一下子轻松了,暗想这事情是谁带头做的呢?还没想出个头绪,更大的不安却又朝他袭来,他不敢想像,汤芗铭会如何处理这件事情。\n刘俊卿的想法却简单得多,他只想讨好汤芗铭。所以,一看到汤芗铭拂袖而去,他就立刻气急败坏地带着侦缉队的便衣们一拥而上,去抢夺那些让汤芗铭极度恼火的逆书。特务们把抢回来的书扔回书箱,其中一本落在了张自忠锃亮的军靴旁。张自忠弯腰捡起了那本书,仿佛无意识地随手翻着,转过身,悄悄把书塞进了口袋。\n人群中,赵一贞一动不动地站着,她眼前的喧嚣突然化成了一片无声的世界,只剩下了一支支挥舞的手枪、一张张特务凶恶的脸、无数双争来抢去的手、无数学生仇恨的目光……而这一切的中心,就是人群当中疯狂叫嚣着的刘俊卿。一贞的目光中,充满了恐惧和犹豫。\n成堆的书箱被搬回了侦缉队。乱成一堆的院子里,特务在一本本检查。一只未开封的书箱被撕开了,一箱子书哗啦倒在地上,“丁”的一声,一枚小小的校徽随着书跌落在地上。不等开箱的特务弯下腰,刘俊卿已经把校徽捡了起来。\n“第——一——师——范!”眯着眼睛盯着校徽,刘俊卿突然笑了,“我的老同学们,你们还真没让我猜错啊。”\n他把校徽往手心里一握,转身就往外走。迎面,一贞正站在门口。迎着一贞的目光,刘俊卿下意识地将握着校徽的手藏到了身后。\n犹豫过后的一贞,决定要用自己的办法阻止刘俊卿继续做那些让她感到恐惧的事情。她板着脸冲进队长室、冲到书架前,搬着架上的书。她的意思很明显,就是要刘俊卿马上离开这个肮脏的地方。刘俊卿明白她的意思,却在旁边说:“一贞,你这是干什么?不想让我干,也不用急着这一下吧?你这冲进来就收拾东西,我……我总还要个准备不是?”\n“准备?准备什么?准备去告密?去领赏?如果不是我正好来找你,你现在都已经到汤屠夫面前了,对不对?”\n“怎么能说是告密呢?我是管这个的,查到线索,我当然应该去报告。”\n“你还觉得当然?”\n“一贞,你听我说嘛。这个逆书案大帅非常重视,谁能破案,谁就马上连升三级。升三级啊!我知道你不想让我干这个破侦缉队长,抓住了这次机会,我不就可以不干了吗?”\n一贞望着刘俊卿,仿佛不认识眼前这个人一般:“我一直还以为,你以前做的那些事,都是被逼的,都是为了我,为了我们那份感情。今天我才知道,其实你全是为了自己,为了自己升官,为了自己发财!”\n“不是这样的……”\n“不是这样是什么样?为了升官,你连母校、过去的同学都打算出卖,你还有什么好说的?”\n“一贞!你怎么就不明白呢?我是个读书人,是个读书人啊,不找机会谋个体面的差使,难道我还真的拿把枪混一辈子吗?再说,我想换差使,也是为了好向你家求亲嘛?这回的事办完了,我进了教育司,就可以马上到你家去提亲,到时候,咱们不也风风光光……”\n“我不要这样的风光!我不要你与马疤子那样的流氓混在一起!我不要你出卖自己的同学,我不要你再干这些伤天害理的事!”眼泪蓦然滑出了一贞的眼眶,她颤抖着手,擦了一把泪,“俊卿,你知道吗?以前你干侦缉队,我还并没有觉得什么,我只当成那是你的差事,一个饭碗而已。可今天,我亲眼看到了,我看到你像疯了一样,带着那些特务抢学生的书,周围是那么多学生,那么多反抗,那么多人跟你们作对,那么多仇恨你们的眼睛,我当时好害怕,我真的好害怕呀!”流着泪,她一把抓住了刘俊卿的手:“俊卿,一个人,不能那么遭人恨,不能跟那么多人作对,不能啊!那么多双眼睛,那样仇恨地看着一个人,这个人一定不会有好下场,一定会有报应的,俊卿!我不想你遭报应,我不想啊!”\n刘俊卿呆住了。\n“答应我,俊卿,不要再干了,我不求你升官发财,我只要你平平安安,不再遭人恨,不再有那么多恨不得杀了你的眼睛盯着你,我就放心了。俊卿,你答应我呀!”\n望着一贞迫切的目光,刘俊卿轻轻为她擦去了眼泪,终于点了点头:“我答应你。”\n“你不会去告发了?”\n刘俊卿摇了摇头。\n“这个队长你也愿意辞掉?”\n刘俊卿点了点头。\n一贞盯着刘俊卿的眼睛:“你向我保证,你不会骗我。”\n“我保证,我保证可以了吧?”刘俊卿将一贞送出门来,“一贞,我还在当班,就不送你了。”\n刘俊卿望着一贞的背影消失在街拐角,久久地站立着,掏出口袋里那枚校徽,他犹豫着,总算下了决心,将校徽扔进了墙角。他转身走向办公室,刚走出几步,却又站住了。墙角里,那枚校徽映着阳光,闪闪发亮,亮得是那么充满诱惑。\n二 # “果然是这个毛泽东!”会后的陶家,陶会长颓然跌坐在沙发上,正在确证他的猜测。\n斯咏怯怯地在旁边说:“我们也只是不想看着汤芗铭倒行逆施,才想了这个主意。爸,对不起了。”\n“算了,事情不出也已经出了,你们本来也没做错什么。可有一句话我得告诉你,斯咏,毛泽东这个人,你是千万千万不能跟他来往了,我们陶家惹不起他这种祸害,你知不知道?”陶会长长长地叹了口气,心里还是最疼女儿。\n“谁说他是祸害?我觉得他是英雄!”\n“英雄我更惹不起!还是个学生,就敢把靖武将军、一等侯不放在眼里,以后他还了得?照这样下去,迟早连天都要被他捅出个窟窿来!斯咏,咱们是本分人家,咱们招惹不起这种惹是生非的祖宗,你明不明白?你不用说了,反正这个毛泽东,你绝不能再跟他有任何来往!他要翻天他去翻,他要找死他去死,我就是不能看着你被他连累进去!”\n他话音尚未落下,管家慌里慌张地跑进门来:“老爷,老爷,不好了……”\n不等管家的话说完,副官锃亮的皮靴已一步跨进了院门,后面是好几名枪兵!\n“汤大帅有令,传陶先生到将军府问话!”\n陶会长不想也知道,汤芗铭这是冲着印刷厂承印的书来的。但他能怎么说?他的确事先什么也不知道呀,可汤芗铭相信吗?\n“陶翁厂里印的书,陶翁居然不知道?”果然,汤芗铭听到陶会长这样解释,走到陶会长面前,弯下身子,说,“书是在陶翁厂里印的,直接从陶翁厂里运来的,一打开箱子就变成了逆书……这,可是要杀头的罪啊!”\n陶会长头上的冷汗已经历历可见,汤芗铭说得心平气和,似乎说的不是“杀头的罪”,而是在和陶会长讨论去什么地方出游。但说话的人和听话的人额头上,却都直冒冷汗。 汤芗铭掏出一方雪白的手帕,擦了一把自己额头上的汗,然后递给了陶会长。\n“哈哈……”看到陶会长接手帕的手微微有些发抖,汤芗铭笑了,“何必那么紧张呢?事情不是不可以商量嘛!”\n陶会长一听这话,知道还有生机,赶紧回答:“只要大帅为陶某做主,有什么条件,陶某任凭差遣。”\n汤芗铭又看了陶会长一眼,这才微笑着返回了自己的座位:“差遣不敢,可要说麻烦呢,眼下芗铭确实也不少啊。云南蔡锷的叛军已经打到湘西,南边吧,逆贼谭延闿、程潜的兵马也在蠢蠢欲动,芗铭为皇上坐镇一方,自当平逆报国,可我这手上,是要枪没枪,要饷没饷。兵马未动,粮草先行,这军火粮饷不济,还怎么打仗?陶翁,你说我难不难?”\n“大帅的意思是?”\n“50万大洋,这事就算了了。”\n陶会长惊得嘴都张大了:“50万?杀我的头我也拿不出啊,大帅!”\n汤芗铭用小刀修剪着指甲,看也不看陶会长,轻声细语:“陶翁长沙首富,后面还有那么大个长沙商会,这点钱真有这么难?”\n“商会力量薄弱,这些年生意也不好做。大帅,我是真的拿不出啊。”\n“40万。”\n“大帅,确实是难啊……”\n“30万。”“砰”的一声,汤芗铭把刀撂下了,抬起头来,“你当这是在买小菜啊,还要讨价还价?”\n“陶某不敢讨价还价,实在是数字太大,无力承担,求大帅再减减,无论如何再减减。”\n“那你觉得多少合适啊?”\n陶会长:“嗯,五万大洋,陶某还可勉力承担。”\n汤芗铭一言不发,盯得陶会长一阵阵发寒:“要不……要不……十万?”\n两个人在那里讨价还价,副官推开了门,纪墨鸿带着刘俊卿出现在门前,说:“卑职不敢惊扰大帅,确实是有紧急公务,那个逆书案有线索了。”\n刘俊卿唯唯诺诺地进来,把那枚校徽递给了汤芗铭之后,先看了看汤芗铭的表情,然后才咽了口唾沫,说:“以卑职所知,第一师范能干出这件事,也敢干出这件事的,就一个人。”\n“谁?”\n“本科第八班学生毛泽东!”\n“毛泽东?”汤芗铭看了刘俊卿一眼,“你那么肯定?”\n“这个人一向胆大妄为,目无王法,第一师范那些不老实、爱闹事的学生从来就以他为首。卑职保证,除了他,绝不会有别人。”\n汤芗铭微微点了点头:“来人哪!传令,逮捕第一师范学生毛泽东。”\n“大帅,”纪墨鸿却突然插了进来,“据卑职所知,这个毛泽东虽然只是一名学生,但在长沙各大学校中名气不小,颇有学生领袖的号召力,贸然抓这样一个学生,万一激起学潮……”\n“一个学生,至于吗?”\n“墨鸿也是为大帅考虑。上次抓一师孔昭绶之事,国内教育界至今仍沸沸扬扬,何况此次逆书案,并无证据证明与毛泽东有关。长沙学界目前正是人心不安之时,当此多事之秋,还是稳妥些,先抓住证据再动手的好。”\n仿佛是想起了什么,汤芗铭微微点了点头:“纪先生的话,也有道理,万一不是这个毛泽东,而是别的什么人背后捣鬼,岂不是放跑了真凶?”他转头吩咐副官,“传令城防营,协同侦缉队,搜查第一师范,务必查出逆书源头。一经查证,所有涉案叛逆,一律逮捕严办。”\n等副官、刘俊卿、纪墨鸿出了办公室。汤芗铭转过头来,微笑着叫了声:“陶翁。”\n陶会长仿佛突然被惊醒:“啊?哦,大帅。”\n“20万大洋就把陶翁吓成这样,不至于吧?要不,咱们再商量商量?”\n陶会长的目光微微向门口瞄了一下,似乎突然下了什么决心:“既然大帅开了口,20万就20万,陶某认了。”\n“哦?”汤芗铭倒没想到他突然爽快了,一拍桌子,“爽快!那我们就一言为定了。”\n陶会长站起身来:“陶某就先告辞了。”\n“哎,着什么急嘛?陶翁为皇上的千秋大业慷慨解囊,忠义可嘉,芗铭总要感谢一下,我这就叫人准备,晚上我做东,怎么样?”\n“不不不,陶某还要马上赶回去,召集商会成员,共商筹款大计,就不多耽误了。大帅吩咐的事,当然要马上办,要马上办。”\n陶会长一面说着,一面赔着笑,向门口退去。出了将军府,他火急火燎,一边上马车,一边不停地催促马夫赶紧走!马车飞驰在街道上。陶会长的手杖敲打着车沿,口里不住地催促着车夫再快点!长鞭脆响,马车拼命地跑着,但马车的速度还是令陶会长极为不满。正巧车子经过一条窄巷口,他敲打着车沿,喊道:“停下停下停下,快停车!怎么不走那边的近路?”\n“那边巷子太窄,车进不去啊。”\n“哎呀!”陶会长把手杖一甩,跳下车,撒腿就往小巷里跑。\n小巷那头,斯咏、警予、开慧正并肩走过来,斯咏的脸上,满是忧色。\n开慧正对斯咏说:“斯咏姐,你就放心吧,陶伯伯也是在气头上,你怕他还真能把你关起来,不让你和毛大哥见面啊?瞒着他不行了?咱们现在去一师范,马上就能见到毛大哥,陶伯伯不一样的不知道?”\n警予拍了开慧的脑袋一下:“你懂什么呀?斯咏担心的,不是这个。”\n三个人刚拐过街角,斯咏猛然一愣,正看到陶会长气喘吁吁迎面飞奔而来。三个人都被陶会长惊慌狼狈的样子吓了一跳,赶紧迎了上去。\n“快……快……”陶会长捂着胸口,身体摇晃着说,“第一师范……”\n斯咏和警予赶来报警的时候,毛泽东、蔡和森、张昆弟、罗学瓒、李维汉等正在寝室里清理剩下的179本书,打算明天后天加把劲,通通都发出去。听到斯咏带来的消息,他们还没来得及想对策,一阵凄厉的哨子声已经划破了校园的平静,侦缉队和城防营的人来了。\n一师门前,散乱的侦缉队与整齐划一的城防营正在会合。城防营整齐的小跑步在营副的号令下变为原地踏步。刘俊卿挥着手枪,冲着营副,心急火燎:“快快快,派一队人往左,一队往右,后面还有个侧门,赶紧包围!”\n营副根本没理他,继续整着队,士兵们的脚步戛然而止。\n“哎,你们怎么回事?”刘俊卿急了,“赶紧上啊!”\n挺立如林的城防营士兵们一个个充耳不闻,标准地执行着长官的口令。刘俊卿还在叫嚷着,营副看也没看他一眼,转身向另一边:“报告营长,城防营全体弟兄集合完毕!”\n刘俊卿发现张自忠骑在战马上正冷冷地望着他,赶紧换上了笑脸。张自忠盯着这张笑脸,直看得刘俊卿尴尬地低下了头,这才收回了目光,慢条斯理地下了马,打量着眼前第一师范的校牌,把手轻轻一挥:“围起来。”\n“是!”如炸雷一般的声音响过之后,两列士兵随即队列整齐、脚步划一、左右包抄而去。张自忠的治军之严,令刘俊卿望而生畏。\n三 # 教务室办公桌上,茶杯里的茶水突然荡起一阵阵涟漪,隐隐而来的脚步声是那样的震撼,仿佛正要吞噬这书香世界的平静。杨昌济、方维夏、袁吉六、饶伯斯、黄树涛、陈章甫……一个个老师疑惑地站起身来,推开了窗户:校门外,无数把刺刀反射着阳光,刺得老师们眼前一花!他们身后,门嘭地被推开,刚刚和斯咏、警予兵分两路的开慧飞快地跑进来,气喘吁吁地问:“我爸呢?”\n八班寝室里,大家还在商量着怎么样才能不让士兵们发现那些书。他们想扛出去,可来不及了,学校已经被包围!他们想藏在学校里,可一听说刘俊卿也来了,便知道藏也是白藏,还会连累全校同学。\n“那……那怎么办呢?”\n一时间,大家面面相觑,都已不知如何是好。\n一咬牙,毛泽东重新抱起箱子,哗啦一下,将满满一箱书倒在了自己床上:“大家都把书往我床上堆,堆不了的塞床下,都记住,这件事是我毛泽东一个人干的,你们谁也不知道!”\n“不!”\n斯咏急得扑了上来,一把抓住了毛泽东的胳膊:“润之,不能这样啊!”\n情急之下,她的声音都急得变了调!\n“不就是命一条,什么大不了的?大家都赶紧走,这里的事,我来对付。”\n“你真的要一个人留下?”斯咏使劲擦了一把泪,猛地抱起了一迭书,说,“那好,我跟你一起留。要死,我陪你一起死!”\n望着斯咏坚定的眼睛,毛泽东不由得愣住了:头一次,他在斯咏的目光中,仿佛读出某种从未感受到的东西。正当所有人的目光都集中在他们两人的身上时,一阵急促而杂乱的脚步声由远而近,开慧带着老师们出现在了寝室门口……\n一师门口,张自忠看着夕阳下一师古朴凝重的校牌和典雅庄重的教学楼,如同在欣赏一幅名家笔下的油画。\n“张营长,你到底要走到什么时候?咱们得赶紧动手呀!我可告诉你,我就是这所学校出来的,里面的校园大得很,再不动手,他们把证据一藏,要搜就难了!”\n张自忠转过身,看了看身后与眼前这书香世界格格不入的刀枪,淡淡地说:“搜查母校这种事,还是刘队长自己来干吧。我城防营接到的命令,是协同侦缉队办差,既是协同,当然以刘队长为主。这校门以外的包围警戒,我城防营还是会协同好的。”\n“好,这可是你说的,张营长,别怪我没提醒你,这校门外要是漏了口子,可得你担着!”刘俊卿狠狠点了点头,转身他一马当先,带领侦缉队特务们就往学校里冲去。\n“干什么?怎么走路的?眼睛长到屁股上了?”在教学楼的转弯处,一个熟悉的声音劈头盖脸地在刘俊卿面前响了起来。刘俊卿一抬头,发现正恶狠狠地瞪着他的,是袁吉六那双鼓凸的金鱼眼睛。\n仿佛又回到了过去的学生时代、仿佛又变成了过去那个胆怯的一师学生,面对自己向来最恐惧的老师,刘俊卿不由自主地倒退了一步。\n“混账东西,一边去!”袁吉六挟着包,昂着头,带着身后的老师就要走出教学楼。\n“上哪去上哪去?都站住都站住……”一名便衣拔出了手枪,一把将走上前的费尔廉推了个踉跄,“你给我站住!”\n“你居然动手打我!”费尔廉迎着枪口逼了上来,“我要向贵国政府抗议,抗议你们无故殴打一名德国公民,你要为你的行为付出代价!”\n“对不起对不起,我……我没看出来……对不起对不起。”推过之后,便衣这才发现这个穿着长衫、布鞋,戴着瓜皮小帽的,居然是个金发碧眼的洋人,一时手足无措,吓得直往后缩。\n饶伯斯也嚷嚷着帮腔:“我是美国侨民,我不准你们妨碍我的自由,赶紧让开!”\n“刘俊卿,这是怎么回事?”方维夏问。\n“我、我奉大帅之命,前来搜查违禁逆书。”\n“搜查?搜谁?搜老夫吗?”袁吉六恶狠狠地逼了上来,“你是想搜我袁某人的包,还是搜我袁某人的身啊?”\n刘俊卿被他逼得直往后退,他似乎自己也不明白为什么还是害怕这个老师,但这害怕却习惯成自然,令他怎么也无法鼓起勇气。\n“刘俊卿,你是不是想把我们这些老师都当成窝藏逆书的犯人啊?”\n“连老师都不认了,你眼里还有没有人伦纲常?”\n“还不给我滚开!”\n在老师们的质问声中,刘俊卿禁不住倒退出几步,便衣们一时没了主心骨。袁吉六、费尔廉、饶伯斯一马当先,其他老师纷纷跟着,拥出校门。\n“外头不有城防营吗?出了门就是他们的事。都傻站着干嘛?跟我上学生寝室!”刘俊卿眼睛一瞪,拼命提高嗓门掩饰着自己的尴尬,便衣们跟着他匆匆向校园内走去。\n校门外,是林立的刺刀,老师们各自挟着包,从刺刀丛里走过。落在后面的黄澍涛紧张得满头冷汗,眼前的阵势令他连头也不敢稍抬一下,只有拼命保持着镇定,但挟着包的手臂却还是止不住在微微发抖。就在这时,一匹战马突然嘶鸣了一声,黄澍涛吓得一抖,臂弯间的包失手落在地上,“砰”的一声,包裂开了,一本《梁启超等先生论时局的主张》滑出了包,正落在张自忠锃亮的军靴旁。\n黄澍涛整个人都僵住了,身边的老师们也目瞪口呆。空气紧张得似乎要凝固了,张自忠却慢慢弯下腰,不紧不慢地捡起了地上的包和书,微笑着将包递给黄澍涛:“这位先生,您的东西。”\n黄澍涛赶紧接过,连声说着谢谢。\n张自忠又翻了翻手里的书:“哟,这是什么书啊?”\n“是……教材,是教材。”\n“哦,这人不识字还真麻烦啊,这么好的教材,我这大老粗偏偏连个书名都不认识。”张自忠将书递向黄澍涛,扫了眼还站在原处的老师们,又说,“教书呢,就得教给学生这样的好书,可千万别教什么逆书、反书啊。各位先生,都别站在这儿了妨碍我们执行公务了,请吧请吧。”\n老师们这才松了口气,大家纷纷离去。走出几步,黄澍涛回过头来,一字一句地、很书生气地说:“这位长官,谢谢您了。”\n“不客气。好走了您。”张自忠转过身,又慢条斯理地踱起了步子。这位张自忠,后任国民政府天津、北平市长,第三十三集团军总司令等职,抗战爆发后,率部浴血奋战,屡挫日寇,参加了台儿庄大捷等一系列重大战役。1940年5月,在枣宜战役中,因率部阻击数十倍于己的日寇,壮烈牺牲于抗日战场,被国民政府追授为陆军一级上将。\n四 # 汤芗铭办公室,副官和刘俊卿正排着队向汤芗铭汇报请示事情。香烟袅袅,跳动的烛光映得汤芗铭脸上阴晴不定,他正一颗一颗、聚精会神地穿那串散了的玉手串。\n“大帅,前线急报,护国军程潜部已攻占湘西,逼近常德府。”\n“大帅,广东将军龙济光,江西将军李纯,山东将军靳云鹏,浙江将军朱瑞,长江巡阅使张勋五将军通电全国,反对帝制。”\n“大帅,日本国公使宣布,日本国不再支持中国实行帝制。”\n“大帅,四川将军陈宦刚刚发来通电,敦促洪宪皇帝退位。”\n“大帅,衡阳急电,我军防线已被谭延闿部击溃,谭部人马正进逼耒阳。”\n汤芗铭似乎没有听到副官的报告,只是专心穿着珠子,脸上全无半分表情。\n房间里好安静,安静得可以听到丝线从珠子中间穿过去的声音。刘俊卿看了副官一眼,怯生生地说:“大帅,卑职在第一师范未能搜查到逆书,估计是被毛泽东他们藏起来了,卑职建议,马上把他们抓起来,严加审讯,一定能问出逆书的下落……”\n轻轻地,汤芗铭打断了他的报告:“滚。”\n刘俊卿一愣。\n猛然间,汤芗铭站起,转过头来,声嘶力竭地吼道:“滚!”\n旁边副官被吓得浑身一抖。刘俊卿更是吓得魂飞魄散,撒腿便往外跑。\n汤芗铭颓然跌坐在椅子上,两手死死撑着桌沿,仿佛一只斗败的公鸡:“通电全国,湖南布政使、督办湖南军务将军汤芗铭宣布支持护国,反对帝制,声讨逆贼袁世凯。要快!”他的拳头气急败坏地砸在桌上。那串尚未穿好的玉手串又一次四散而飞。\n1916年3月的黄历一天一天翻过,长沙街头卖报的小童每天手里的报纸上都有爆炸性的新闻:《护国浪潮席卷全国 袁逆世凯穷途末路》、《北洋将领全线倒戈 窃国大盗众叛亲离》、《袁世凯宣布取消帝制 恢复共和》、《袁逆心腹汤芗铭仓皇逃离湖南》、《湘军元老谭延闿再度督湘》……\n第二十二章 文明其精神 野蛮其体魄 # 我最佩服的,是古希腊的斯巴达人,\n人数那么少,却能称霸希腊。\n为什么?\n因为他们不仅重视精神之文明,\n更崇尚野蛮之体魄!\n一 # 自古秋后都是处决犯人的季节。赵一贞看过很多话本、看过很多故事书,却从来没有一个时候比现在更让她害怕其中的那句“秋后问斩”。夏天不过刚刚过去,她走在街上还穿着单衫的行人中间,却感到了说不出的凉意。头顶的树叶绿中已经泛了黄、路边的小草青中已经带了焦,一切的生命、一切的情感,似乎都已经走过了它最旺盛的季节,正在渐渐地枯萎。但赵一贞不甘心,她不甘心眼睁睁地看着比生命还珍贵的东西枯萎。走在去监狱的路上,她有满腔的不甘心,但却不知道自己能怎么做;她不知道自己能怎么做,但却愈发地不甘心。\n袁世凯被推翻了、汤芗铭被赶跑了,新一轮的清算又开始了。长沙城里每天都有汤党余逆被抓,曾经威风八面的侦缉队队长,怎么可能漏网呢?自刘俊卿被抓以后,一贞就疯狂地四处打听刘俊卿的下落:刘俊卿被关到了哪座监狱、刘俊卿会被怎么处罚、刘俊卿已经知道错了吗……终于打听到了刘俊卿的确切消息,她又拿出自己所有的私房钱买通了狱警,只想着无论如何要见刘俊卿一面。\n隔着粗大的铁栏杆,一贞看到一个头发蓬乱、胡子拉碴、目光呆滞、浑身上下满是伤痕的人蜷缩在破草席上,她走过去,轻轻叫了一声:“俊卿!”\n似乎已经被打傻了的刘俊卿没有想到还有人会来这里看望自己,更没有想到一贞会来这里看望自己,他睁开血肉模糊的眼睛,抬头望着、望着……突然扑了过来,死死地抓住栏杆,砰砰有声地用头撞着,声嘶力竭地大叫:“一贞,一贞,我当初为什么不听你的?为什么不听你的啊?我怎么那么蠢,那么蠢啊!”\n看着眼前这个人,一贞的心疼着,心疼得甚至让她忘记了恐惧:这就是那个头发一丝不乱、一袭月白长衫、皮鞋锃亮的刘俊卿吗?这就是那个给她翻译“哪个少女不怀春、哪个少年不多情”的刘俊卿吗?这就是那个发誓要找一个体面的工作让她过上快乐日子的刘俊卿吗?是的,是的,这就是那个刘俊卿,是她的刘俊卿。尽管他面目全非,尽管在世人的眼里他早已经不是当年那个以第六名的成绩考进一师的学生,但他在一贞的眼里和心里,却永远不会改变。一贞抓住刘俊卿的手,紧紧地抓着,急促地说:“俊卿,不要这样,你会没事的,你一定要挺住,要挺住啊。”\n“没用的,一贞,我完了,我没指望了。你知道吗?这儿天天都在杀人,天天在杀,杀汤党余逆,拖出去就是一枪,就是一枪……”\n仿佛是为了印证刘俊卿的恐惧,走廊的尽头猝然响起一阵惊恐万状的狂叫声:“不,不要,我不是汤党,我不是汤党,我支持民国,民国万岁,民国万岁,我支持民国啊……”绝望的呼号迅速远去,紧接着,随着枪声,那个声音戛然而止!枪声中,一贞感觉到刘俊卿的手松了、随着他如烂泥一样的身体滑落下去,落到了血迹斑斑的枯草上。\n“一贞,以前,我答应过你许多事,答应过到你家提亲,答应过给你一个幸福的将来,这一切,我都做不到了,是我对不起你。你走吧,就当从来没认识过我,就当这世上从来没有过刘俊卿,你走吧,不要再来了。”\n望着刘俊卿,听着他绝望的声音,一贞苍白的脸色变得铁青,她用从未有过的坚定语调对刘俊卿说:“不,你不会死的,我一定会想办法,救你出去。你等着。”\n刘俊卿看着一贞毅然决然地转身离去,不相信她真的能救自己:一个无钱也无势的纤弱女子,她能有什么办法来搭救一个几乎是判了死刑的人呢?\n是的,一贞是一个无钱也无势的纤弱女子,她现在唯一可以自己支配的,只有她的生命。\n从监狱回来后,一贞突然说她同意嫁给老六了,这让赵老板喜出望外,他相信女儿也是想过富足日子的、相信女儿已经对那个没有出息的刘俊卿死心了。望着满桌子的绸缎、光洋和那封大红的婚书,他殷勤地给坐在一旁的老六递着烟:“这孩子吧,就是糊涂,你说要真跟了那个刘俊卿,这会儿受罪的还不是自己?现在好了,她也算是明白过来了,还是跟着六哥好。”\n一贞呆呆地坐在一旁,看到老六一直嘿嘿傻笑着、眼睛一眨不眨盯着自己,她木然地说:“六哥,这门亲事我有个条件。”\n“你说你说,不管什么条件,我都答应你。我要是办不到,还有我马大哥,有他在,长沙城就没有办不成的事!”\n“那就好。”\n老六同意了她的条件之后,一贞回学校去默默地收拾着东西:课本、笔记、作业、心爱的小饰物、周南女中的校徽……所有的书都已收拾好了,一贞最后拿起了那本《少年维特之烦恼》。书的扉页中,夹的是刘俊卿译得工工整整的那首卷首诗。一贞看着,眼泪忍不住滑出了眼眶,她悄悄擦了擦,将这本书单独收了起来。离开学校,一贞直接乘人力车来到王子鹏家,把那本《少年维特之烦恼》转给了秀秀。\n几天后,刘俊卿突然被释放了。\n一步迈进久违的阳光中,刘俊卿被刺得直眯眼睛。好一阵,他终于适应了光线,却看到秀秀和子鹏就站在前面不远处。\n回到他们虽然简陋但却还能遮风挡雨的家里,刘俊卿换下那身肮脏的破衣裳,吃着妹妹给他煮的面条。子鹏把一叠银元放在了刘俊卿面前,说:“我也帮不上你什么,俊卿,这些钱你拿着,找个事做也好,做点小生意也行……”\n刘俊卿把钱推了回来:“我不要。”\n“那你还想干什么?你还想去折腾?你说你折腾来折腾去,结果又怎么样……”\n“阿秀!”子鹏示意秀秀别再往下说,回过头来说,“俊卿,我们只是不想看着你像原来那样过下去,经过这么多事,我想你也应该明白了,一个人,就得老老实实过日子,踏踏实实做人。只要你想清楚了,现在重新开始,也不算晚,你说是吗?”\n刘俊卿长长地叹了口气:“我还能重新开始?”\n秀秀把那本《少年维特之烦恼》递到了他面前,刘俊卿呆了一呆,猛地一把抢过书:“这是哪来的?阿秀,你快说,这是哪来的?”\n“是一位赵小姐让我转交给你的。”\n“她跟你还说了什么?她还说了什么?你快说呀!”\n“她只说了一句,希望你出来以后,把她忘了,重新开始。”\n死死地握着书,刘俊卿一时还不曾反应过来。\n“哥,我听说,那位赵小姐今天出嫁。”\n刘俊卿惊得目瞪口呆!听着远处传来的隐隐的鞭炮声,他放下碗筷,撒腿就往外跑。\n那条他和一贞曾经手拉手走过的街道上,正鞭炮齐鸣,彩纸纷飞,唢呐、喇叭滴滴答答,迎亲的队伍浩浩荡荡,一派喜庆热闹。老六披红挂彩,骑在打头的马上,笑得嘴都合不拢。八抬大花轿旁,陈四姑屁颠屁颠地跟着。喧天鼓乐中,纷飞的彩纸飘飘洒洒,落在花轿上。轿帘偶尔掀动,但没有人注意到红彤彤的轿内,新娘凤冠霞帔,一身大红嫁衣,头上盖着同样鲜红的盖头。轻轻拉掉了盖头,露出的却是一张苍白绝望的脸。喜庆的鼓乐声中,新娘的手悄悄从怀里抽出,手里,是一把锋利的剪刀。剪刀挥动之后,一滴滴眼泪无声地滑过了她的面颊,一滴滴鲜血无声地浸透了她的大红嫁衣,落在花轿经过的路面上……\n但没有人留意。\n吹鼓手鼓着腮帮子,卖力地吹着喇叭;老六露着缺了两颗门牙的嘴,一路抱拳,嘿嘿傻笑;纷纷扬扬的彩纸在空中没有目的地飞扬、飞扬、落下来,落在了殷红的鲜血上,让人分不清那红色到底是纸的颜色还是鲜血的颜色。\n花轿已经远去了,飞奔而来的刘俊卿突然失足,摔倒在地,手里的书也脱了手。\n“一贞!一贞!”\n仿佛感觉到了什么不祥,他捡起书,书上,竟沾满了鲜血。他这才发现,地上是一路鲜血和带血的杂乱脚印!\n“一贞!”\n风卷着花花绿绿的纸屑,和着刘俊卿声嘶力竭的狂呼久久地在小街上空回旋。\n拖着麻木的双脚回到家,刘俊卿坐在火盆前,机械地撕扯着手里的书。跳动的火光映照着刘俊卿呆若木鸡的脸,那张脸上,没有泪光,甚至没有任何表情。火光熊熊,吞噬着一本本他珍藏的课本、书籍,仿佛也正吞噬着他久久珍藏的理想与梦幻。最后,他拿起了那本《少年维特之烦恼》,轻轻地抚摸着、轻轻地吻着,他的手一松,书落进了熊熊烈焰之中。\n那首承载着他与一贞所有情感的诗,在火焰中扭曲着,熊熊火焰映照着刘俊卿死灰一样的脸。纸灰飘逝,他一颗一颗扣好长衫的扣子,将头发梳理得整整齐齐, 走进了三堂会。\n二 # 酷暑过去,长沙城里渐渐飘起了越来越浓的桂花香,走街串巷的手艺人卖着担子里似乎几百年都没有什么变化的小玩意,也附带炫耀着他们从父辈那里复制而来的嘹亮嗓音。此起彼伏的吆喝声如湘江亘古不变的水声韵味悠长,让长沙人在梦醒的一瞬间、在回头的一刹那、在捧着茶碗拿起烟袋打开窗户的那一刻,想起自己经过的事和经过的人。\n因为反袁而不得不二度留学日本的一师原校长孔昭绶,归来时依然是一乘三人轿、依然是一身马褂长衫布鞋。穿过这最能撩起人心底乡愁的声音,他回到了一师,在校门口,轻轻地抚着一师的校牌,他的手指竟禁不住有些微微颤抖,那是久违后难以抑制的激动。有学生远远地看见了他,开始还不敢相信自己的眼睛,仔细看了、确认了,随即兴奋地奔跑着呼叫着:“校长回来了!校长回来了!”\n喊声回荡在楼道、走廊,回荡在整个学校的上空。钟声响起,惊喜的一师师生涌向了礼堂,他们要在校长当年离开的地方,接校长回来。\n“同学们,风风雨雨,我们,又在一起了!”\n百感交集的孔昭绶又站在了讲台上,他才一开口,台下便掌声雷动。孔昭绶的眼睛湿润了,他摘下眼镜,擦了擦眼角,梳理着离开一师的这些日子他的所有感想,然后重新戴好眼镜:\n“这一年多来,我们经历了许许多多,也思考了许许多多,过往的一切,千言万语,都不必多说。如果要说,我们就说一件事,那就是,第一师范的未来,我们应该怎样开创!是啊,一个学生应该怎样学习,一个老师应该怎样教书,一所学校应该怎样办好,一个民族、一个国家应该怎样振兴,这些问题,我们都曾经一而再,再而三的思考过、讨论过。中国的读书人,从来就不缺少坐而论道的能力,哪怕是天大的难事,我们也个个可以讲出一火车的道理来。可这一年多的思考,却让我明白了一个道理,这个道理就是:光讲道理是没有用的。天下兴亡,匹夫有责,这个匹夫,不是除你以外的别的中国人,而首先就是你自己!中国的事,盼着别人来做是不行的,从我开始,从现在开始,实实在在做实事,这才是我们这一代读书人的责任,才是我们这一代读书人崭新的精神!我宣布,从今天开始,第一师范将实行一项新的治校原则,一项新的教育观念:学生自治!”\n按部就班地机械灌输,连传统的“师”都算不上。一个优秀的教育家和一个普通校长的区别,就在于他是不是能让一所学校充满欣欣向荣的生机。经过了“驱张”、“反袁”之后的一师,如同一潭蓄势的山水,急需一条冲出峡谷的水道。而孔昭绶的“学生自治”来的恰是时候,它如清风般吹来,驱走了一师先前的沉闷和困惑,所到之处,叶为之舒展、花为之绽放、水为之流畅、生命为之鲜活。\n既然要搞学生自治,就要成立学友会事务室,就要选举产生学友会的“领导”。于是张贴《第一师范学友会竞选公告》、 开展学友会竞选演讲、全校同学排队投票……一师学子们青春的旗帜在这个金色的秋天,如同一师的校旗一样,迎风招展。\n学友会正式成立了,在专门的学友会事务室里,兼职会长孔昭绶和新当选的学友会全体成员围坐一堂,畅谈学友会将如何具体开展活动。周世钊、李维汉、萧三、张昆弟、罗学瓒、易礼容、毛泽东……环顾一张张意气风发写满希望的笑脸,孔昭绶微笑着说:“在座各位都是全校同学投票选举出来的学友会成员,第一师范的学生自治,应该怎样开展,就请大家谈谈想法吧。”\n“我觉得,学友会的工作,首要的是提高同学们的学习兴趣。我建议,根据现有的各科教学,成立相应的学生兴趣小组。比方说,有很多同学对文学就很感兴趣,如果成立一个文学兴趣小组,肯定会有不少同学参加。”\n“不光文学,手工、音乐、图画都可以成立嘛,这些内容,大家都会感兴趣的。”\n“我觉得外语更重要,如果成立一个英语兴趣组,一个日语兴趣组,对同学们提高外语水平,肯定有很大的帮助。”\n“我还有一个建议:办一个学友会资料室。学校现有的阅览室,有关时事、社会的报纸、杂志太少,像《新青年》、《东方红》、《太平洋》、《科学》、《旅欧杂志》、《教育周报》这些思想和观点新潮、激进的杂志,如果我们利用学友会的活动经费订齐全,一定能方便大家阅读。”\n“不光是订外面的,本校同学在学术和学业方面取得的优秀成绩,也可以在这个资料室公开陈列、展览,作为我们的成果,永久保留嘛。”\n“还有还有,一师的许多毕业生对母校感情都深得很,学友会可以定期组织老校友联谊活动,发动毕业校友支持在校学生的课外活动嘛。”\n……\n孔昭绶发现,在同学们热烈的讨论发言中,新当选的总务毛泽东却静静地坐在一旁。这个平素总是唱主角的毛泽东,今天为什么还没开过口呢?孔昭绶等待着他的爆发。\n果然,在大家都谈了自己的想法之后,仿佛才从回忆里走出来,毛泽东用与会场的热烈不那么协调的声音说:“大家刚才的提议,都非常好,我也很赞同。可刚才坐在这儿,听着大家的讨论,不晓得怎么,我却突然想起了一个人,一个同学,一个已经离我们而去了的同学。那就是永畦。真的,我经常想起永畦,早上起床,看见他空着的床,走进教室,看见他空着的座位,还有经过食堂,经过操场……好多次睡觉,我都梦见他,那么……那么腼腆地对我笑着,好像就要跟我说什么话,可又听不见他的声音,就是听不见……”\n他的声音哽咽了。\n“永畦的为人,是那么善良,永畦的成绩,也那么优秀,可他就有一个毛病,身体太差,稍微有点风雨,第一个感冒的,肯定是他。我记得那时候,我们打球、跑步、游泳、爬山,我也经常叫他一块去,可他……现在想起来,当初要是逼着他多锻炼锻炼身体,也许就不会发生这样的悲剧了。不仅仅是一个永畦,自古以来,中国的教育,可谓从来就没把体育放在眼里,颜回、贾谊、王勃、卢照邻,这些古人的才华还不惊人吗?可他们短命啊!于是只给历史留下一页页遗憾。没有健康的身体,你学得再多,学问再大,命都保不住,又有什么用呢?”\n满屋的同学,包括孔昭绶,都被毛泽东的话深深打动了,静静地看着他、听他说。\n“我最佩服的,是古希腊的斯巴达人,人数那么少,却能称霸希腊。为什么?因为他们不仅重视精神之文明,更崇尚野蛮之体魄!反观我今日之中国,身体羸弱者比比皆是,学校里,学生啃书本,老师教书本,家长更是一双眼睛只盯着孩子的书本,一国之青年都病怏怏的,这样下去,别人凭什么不把我们当成东亚病夫?国家的强大、武力的振兴又靠什么来保证?中国的未来,需要我们青年,青年的未来,需要野蛮强健的身体。所以,我的考虑是,学友会第一步的工作,当以全校的体育锻炼为中心,要让我们的同学,文明其精神,野蛮其体魄!”\n一片静默中,孔昭绶突然带头鼓起掌来。掌声随即响成了一片。\n“文明其精神,野蛮其体魄”,一师学友会把学校里的各项活动搞得有声有色,“武术组”、“架梁组”、“庭球组”、“竞技组”……都是同学们参加的热门,不过最热闹的,还要数毛泽东当守门员的蹴球队,他们聘请了年轻的德籍音乐教师费尔廉来做教练。有对手才有提高,经过一段时间的厉兵秣马,经学友会出面联系,一师的蹴球队和长郡中学的蹴球队在一个周末来了一场友谊赛。\n比赛是在长郡中学的简易蹴球场里进行的。长郡中学由罗章龙领队,一师由萧三、张昆弟领头。虽说是长郡中学的主场,可一师来的人比长郡本校来看球的还多,费尔廉这位教练就不说了,他正忙着布兵排阵呢,其他的,不仅校长孔昭绶带着杨昌济等老师来了,蔡和森带着拉拉队来了,萧子升从楚怡小学赶来了,斯咏、警予、蔡畅和开慧她们也来了。\n这次比赛的前两天,毛泽东去过一趟斯咏家。因为之前父亲曾经以五千元的代价要求张干校长开除毛泽东,所以斯咏怎么也没想到父亲会在千钧一发的时候跑去一师报信救毛泽东,更没想到事后毛泽东会来她家表示感谢。这一次在陶家的见面,让她陡然觉得自己和毛泽东之间的距离是那么近、也觉得父亲其实并不像她想像的那样讨厌毛泽东。“我救人,凭的只是良心,我觉得他了不起,也不等于认可你跟他交往。就算你可以不考虑我的看法,你也不应该忘记,你是定了亲的人,一个订了亲的女孩,跟别的男人,是不可能有将来的,这一点,不用我再提醒你了吧?”不过,想起在毛泽东走后父亲说的这番话,斯咏的情绪又一落千丈了。\n“笛!”随着裁判一声哨响,足球被一脚开出,场上的运动员跑起来了,看台上,孔昭绶、杨昌济等老师紧张地观看着比赛,旁边两个学校的拉拉队开始敲锣打鼓、呐喊助威。开慧冲在拉拉队最前面,扯起嗓子喊着“一师,加油!一师,加油!”小脸兴奋得通红,指挥着一师的男生们喊着号子。\n斯咏和子升、警予、蔡畅坐在一师的拉拉队前看球,但她的目光总也离不开一师队的球门,那里担任守门员的毛泽东张着双手,正全神贯注守着门。经过几个回合的无功拼抢,实力胜过一师队的长郡队此时正攻势更猛,猛然间,罗章龙突破防线,一脚劲射,球直飞网角——呐喊声骤然静了下来,所有人的心都悬起来了。说时迟,那时快,毛泽东一个飞身鱼跃,漂亮地扑住了这个球!叫好声惊雷般响了起来。看台上的孔昭绶与杨昌济长出了一口气,孔昭绶不禁擦了一把冷汗。斯咏同样松了一口气,手一抹,才发现自己也给吓出了一头的冷汗。子升把一块雪白的手帕递过来,斯咏擦了汗,把手帕递还子升,目光却又投向了毛泽东。\n球场上,一师队趁机反击,攻入一球。失球的长郡中学队攻势如潮,连续射门,毛泽东左腾右扑,一个个险球被他奇迹般地接连扑住!开慧高兴得都快疯了,冲到场边带着拉拉队狂喊:“毛泽东,加油!毛泽东,加油!”\n一场激烈的比赛最终因为一师队有一个无敌的守门员而以弱胜强。得胜归来的一师球队捧着锦旗,兴高采烈地班师回朝。孔昭绶拍着毛泽东的肩膀,兴奋得合不拢嘴:“那么多次射门,一个也没让他们射进去,润之,你好样的!”\n“那还用说?我们润之大哥,长沙有名的铁大门!”开慧攀着毛泽东的肩膀,一脸的得意。\n斯咏看到毛泽东满头的汗,接过张昆弟手里的毛巾,赶上两步,可没等她把毛巾递到毛泽东面前,开慧已经顺手用衣袖给毛泽东擦起了汗。斯咏收回毛巾,突然发现警予正看着她,不由得悄悄扭开了头。\n和一师的师生分路之后,斯咏跟警予不约而同地说起去看一贞。一贞的所有作为都是为了爱,她们又何尝不是?残阳如血,映红了黄昏的天际,血色余晖洒在一贞的新坟上,使这座新坟看起来像燃烧着的火焰。\n爱情真的比生命更重要吗?\n两人正想着各自的心思,身后传来一阵细碎的脚步声,回头去看,却是子鹏陪着秀秀来上坟。\n斯咏看了看子鹏,子鹏也正巧看了看斯咏,面对着刚刚被一门不情愿的婚事夺去了生命的一贞,这两个同样身不由己的人虽然相顾无言,目光中却已经交换了千言万语。\n三 # 上次一师和长郡的比赛结束后,杨昌济在带着开慧回家之前,给毛泽东布置了一个任务:考虑到毛泽东这段时间在一师推广体育锻炼搞得很好,杨昌济鼓励他写篇论文,把对体育运动的看法、心得总结一下。毛泽东兴奋不已,当时就保证两天交卷。\n礼拜二下午放学后,开慧出了周南女中就坐着黄包车一路飞快地到了一师,在学友会事务室找到了毛泽东,要先睹他的新文章为快。进了屋,却发现毛泽东并没有写文章,而是一边数着 “一、二、三、四,五、六、七、八”,一边手脚并用、蹦蹦跳跳,便问毛泽东在做什么。毛泽东告诉她,这是他发明的“毛氏六段操” ,这套体操,综合了手、足、头、躯干、拳击、跳跃六种运动,而且融合了体操、武术、西洋拳击各种运动形式,绝对是目前中国最先进的。她立刻对这个新鲜玩法有兴趣了,放下书包缠着毛泽东一定要学。\n毛泽东很高兴自己才发明的“毛氏六段操”有人喜欢,便把一篇画着“六段操动作图解”的文章翻开摆在桌上,开始手把手地教开慧: “一二三四,五六七八,二二三四,五六七八!这是手部运动;一二三四,五六七八,二二三四,五六七八,这是腿部运动…………”\n等到开慧练出了汗,休息的时候,毛泽东给开慧讲体育锻炼的好处时,说起自己小时候身体很差……一听毛大哥要讲小时候的故事,开慧可来劲了,催着他赶紧讲。\n“我小时候,身体一塌糊涂,三天两头生病,我上面还有两个哥哥,小小年纪都夭折了,我娘生怕我也养不大,求神拜佛,香都不晓得烧了好多。我们乡里有块石头,天生就像个观音,乡里人把那块石头当观音菩萨拜。有个算命先生告诉我娘,我要拜那个石头观音做干娘,以后才不会生病,我娘老子迷信,真的要我拜了那块石头做干娘,好保佑我不生病。所以,我有个小名,就叫石三伢子。”\n“那拜了有用吗?”开慧双手撑着下巴,仰着脸问。\n“一块石头,能有什么用?还不是哄鬼的。12岁那年,我一场大病,差一点就完蛋了。好不容易病好了,我也明白了,自己的身体,靠天靠菩萨都是假的,一句话,搞锻炼,坚持运动,自然百病不侵。从那个时候到现在,我就不晓得病字怎么写的。所以说,身体、精神、意志,那都是磨炼出来的。我在那一篇《体育之研究》里头,还专门总结了三条理论,讲人的精神、意志和身体之间的关系……”\n“毛大哥,你的‘毛氏六段操’也是这篇文章里的吗?哎呀,都忘记我是来做什么的了。”开慧跳起来,拿过摆在桌上的文章,翻到第一页,“体育二字,听起来是小事,其实关系一个国家的兴衰。一个人不爱运动,哪来的蓬勃之气?同样,一个民族不爱运动,哪来的尚武精神?到时候,国家有难,打仗都没有人扛得起枪,这个国家还有什么希望?你再看我们现在的学校教育,说是说德、智、体三育并重,其实呢……这么多字,我还是赶快回家,和爸爸一起看吧。”\n开慧把文章拿回来,还没忘记把刚学来的六段操表演给爸爸妈妈看。向仲熙自女儿来周南后就从老家搬来长沙杨宅照顾父女俩的起居了,她看到女儿大汗淋漓的样子,心疼地赶紧为女儿擦汗。\n“毛大哥说了,做运动嘛,就是要出汗。”开慧擦着汗,看到父亲缓缓地合上了手里那篇《体育之研究》,忙急不可待地问,“爸,怎么样怎么样?”\n杨昌济:“这么说吧,到目前为止,这是我看过的对体育运动论述得最好,也最全面的文章。润之这篇文章,应该说,对全国的体育教育改良都很有意义,我看,应该拿出去发表。而且要发在最好的杂志上。我打算将这篇文章推荐给《新青年》的陈独秀先生,他一定感兴趣的。”\n听到爸爸说要把毛大哥的文章推荐给《新青年》,开慧兴奋得眼睛都瞪圆了。\n第二十三章 到中流击水 # 总有一天,我要写一首诗,写出我们中流击水的风华正茂,写出我们指点江山的壮志豪情!\n一 # 转眼又是新的一年、新的一学期,1917年3月的最后一个周末,读书会的会员们除了开慧都来齐了,他们正在君子亭商议一个重要举措,那就是以他们现在的哲学读书会为基础,成立一个正式的、有组织、有纪律的青年团体。\n这件事情,毛泽东和蔡和森之前已经交流过很多次,只是还没有和大家讨论。按照毛泽东的想法,他们这个读书会,原本是因为共同的学习兴趣集合在一起。但读书学习毕竟不是他们的最终目的,而是为了改造社会。而且,虽然他们现在有一帮子人,但是人再多,一盘散沙子,也搞不成事。所以他才提议成立一个正式的青年团体,这个团体,不搞虚的,专门做能改造国家、能推动社会发展的实事,他坚信,只要按着这个目标做,他们的团体就完全可以成为湖南进步青年的中坚,成为改造中国一支不可忽视的力量!\n“成立一个正式团体,这我同意,不过,改造整个中国,这个目标,定得也太高了吧?”看到毛泽东说得慷慨激昂,萧子升第一个出来泼凉水,觉得做人还是要脚踏实地,不能好高骛远。\n“这怎么叫好高骛远呢?理想就应该定得高嘛。自己先把自己框死了,还成个什么气候?”毛泽东想要说服萧子升。\n“那也不能一口吃成个胖子吧?就你那口气,好像中国缺了我们几个都不行了,至于吗?”\n“缺了谁地球照样转。只不过,都照你那样想,世上就没有英雄豪杰了。”\n“我本来就没想过当什么英雄豪杰。改良社会,必须是个积跬步而至千里的过程,我们的任务,就是集中精力做好眼前的跬步之始,一天到晚只想着万里宏图,那反而会变成空中楼阁。”\n“胸中若无万里宏图,眼前的事岂不是没了方向?”\n众人正看着他俩面红耳赤、争得不可开交,猛听到亭外开慧兴奋的叫声,忙回头去看。毛泽东看到开慧手里拿着一本杂志气喘吁吁地跑来,便高声喊:“开慧,莫跑这么急,摔一跤不得了!”\n开慧根本没慢,反而一步冲进亭子,喘着气,双手抓起杂志,给大家看封面:是一本崭新的1917年四月号的《新青年》杂志。然后才翻到中间,一把递到毛泽东面前。\n“《体育之研究》?”毛泽东猛地一把抢过了杂志,“我的文章?我的文章发表了?哎!我的文章发表了。”\n大家一下子都围了上来,争先恐后地看着杂志。\n“哎,《新青年》?毛泽东,你可以啊!”。\n“润之,恭喜你。”\n“咱们长沙城,还没哪个学生能在《新青年》上面发表文章呢。”\n“老师也没几个啊!润之哥,这么大的喜事,要请客啊!”\n“对对对,请客请客!”\n“要得要得,请客请客。”众人纷纷向毛泽东道喜,毛泽东也高兴得嘴都合不拢,可他摸摸自己干瘪的口袋,不好意思地说,“请客我倒是愿意,可就是没钱。”\n“那不行,这么大的喜事,总要庆祝一下吧?”众人不依。\n“我看这样吧,客呢,就不要润之请了,他除了请大家喝开水,别的反正也请不起。不如我们搞个活动,现在不是春天吗?春暖花开,趁着明天礼拜天,我们出去春游,也算是庆祝润之的文章发表,大家说好不好?”蔡和森想了个两全其美的好办法,看大家都赞成,他又说,“润之,本来是给你庆祝,就由你定个地方吧。”\n“橘子洲头,怎么样?春江水暖,岸芷汀兰,长沙春色,尽收眼底……干脆我们搞回痛快的,游泳过去!”\n看了看斯咏为难的样子,他又补充说,“女生坐船,男生游泳。何胡子年纪大,你例外,其他人一律下水!”\n二 # 珠沉渊而水媚,青翠的橘子洲便是湘江的一颗绿宝石,湘江因为这颗宝石的光芒而柔媚,这颗宝石又因为湘江如兰的春水而熠熠生辉。\n湘江东边的沙滩上,读书会的同学们今天就要到江中的橘子洲上去庆贺毛泽东的《体育之研究》发表在《新青年》杂志上。蔡畅、何叔衡、开慧、斯咏都上了船,警予却还混在一群正脱了衣服做热身运动的男孩子堆里,像个大姐姐一样帮蔡和森收拾脱下来的衣服,叽里咕噜地吩咐蔡和森注意这样注意那样。毛泽东一边打趣他们的肉麻举动,一边把所有人的衣服卷成团一下子扔上了船,让开慧照顾着。\n开慧看到萧子升背着画架、居然和往常一样地一丝不苟地穿着长衫布鞋,也跟在斯咏身后上了船,问他:“萧大哥,你怎么也坐船啊?毛大哥说男生都要游泳过河的。”\n不等子升答话,岸边先传来了毛泽东的声音:“萧菩萨怕冷咧!还游泳?他呀,恨不得一天到晚把自己当个活菩萨供起哟!”\n看到毛泽东先将一只足球用力甩入江中,随后一个纵身鱼跃,身体在空中划出漂亮的弧线,一个猛子扎进了冰冷的江水,子升打了一个寒战:“你以为我是你啊?早春二月下河游泳!人不可违天时,你那是逆天而行。”他说着话放下画架,挨在斯咏身边坐下了。\n警予抱着蔡和森的衣服上船后,船就开了。船橹摇荡,渡船在水面划出长长的波纹。船的前方,男生们正劈浪前行,打打闹闹地玩着那只足球。毛泽东钻出了水面,踩着水,向船上挥着手:“萧菩萨,下来啊下来啊,水里舒服得很呢!”\n子升没理他,假装看着远处的橘子洲,余光却全在斯咏身上。江风吹来,斯咏裹紧了身上的衣裳,伸手试了试江水,江水冰冷,她的手才一伸下去,就猛地缩了回来。子升正想掏出手帕递出去,却听到斯咏对着击水的人群高声问:“润之,你们真的不冷啊?”子升黯然把手放在口袋里停了一会,然后空着手伸出来,抓住了身边的画架。\n“到了水里还冷什么冷,一身都发热,哎,玩几个花样给你们看啊!”毛泽东一跃老高,玩起了花样,侧身、平躺,倒立、翻筋斗……涌动的江水中,他似乎比鱼还自由。\n何叔衡看得呆了,说:“这个润之,到了水里,简直是条龙。”\n水里的和船上的都正看着毛泽东表演,毛泽东一个猛子却不见了。大家都知道他水性好,开始还想着他会从什么地方突然冒出来,给大家一个惊喜。可等了好一阵,还不见他浮出水面,大家禁不住都焦急起来,本来看得很开心的斯咏和警予竟吓得在船上大呼小叫。慌乱中,在船的另一边突然间水花涌起,毛泽东从斯咏的背后一头钻出了水面,攀住船舷挥手弹了开慧一脸的水,大叫:“我在这儿!”\n“哎哟,你吓死人了!”斯咏惊魂未定地拍着胸口。\n“你怕我淹死啊?一条湘江,再过50年我都能随便游。”\n“再过50年?再过50年你70多了,活不活得到那时候还难说呢?还游湘江?”萧子升说。\n“自信人生二百年,会当击水三千里!萧菩萨,你还莫不信,五十年以后,我游给你看看!”\n开慧擦着脸上的水问:“毛大哥,水里真的不冷啊?”\n“这个水啊,是下来前冷,下来反而不冷了,越游越热乎。不信你下来试试啊。”\n开慧把脱下的鞋和外衣往斯咏手里一塞,捏住自己的鼻子,扑通一声,真跳进了水里,水花溅了斯咏他们一身。水中的开慧游了几下,兴奋得直冲船上喊:“好舒服啊,还有谁要下来啊?”她边游边与毛泽东等在水里玩起了足球,球在青年们当中飞来飞去,一时间江中水花四溅,开慧的欢笑声响成了一片。蔡畅和开慧年龄相当,看到开慧在水里玩得那样开心,也依傍着船舷,乐得手舞足蹈。而警予的眼光却始终没有离开过蔡和森。\n三 # 过了江、上了岸、进了橘子林,换上干衣裳,大家就开始分工:一拨人去找当地的农民买红薯、一拨人去拣干柴。不用谁吩咐,蔡和森很自然地就跟在了警予身后,俩人一个捡柴,一个抱柴,动作蛮协调的。走出很长一段路了,警予看看身边一声不吭只顾着抱柴的蔡和森,突然“扑哧”一声笑了。蔡和森前后左右张望着,实在没发现什么异常情况,就问警予笑什么。警予抬起自己脚上的皮鞋,借着手里的柴棍摆了个俏皮的姿势,说:“我一直以为咱们只有在擦皮鞋的时候能配合默契,却不想,捡柴的时候也挺默契的。”蔡和森抱着柴就往回走,边走边说:“ 我倒觉得我们默契的时候还很多呢。”警予愣了一下,脸微微地红了,赶紧撵了上去。\n他们回来的时候,其他人早已经把柴和红薯堆在一起了,何叔衡和毛泽东正熟练地把一堆红薯埋进了挖好的土坑里,然后在上面搭着柴架子。看样子这两个人在家都是做活的好手,几弄几弄,一股青烟冒过,火苗“噌”地就起来了。\n等待红薯烤熟的这段时间,毛泽东、张昆弟、罗学瓒、萧三他们又在沙滩上踢起了足球,开慧套着毛泽东的长衫,袖子长得连手都伸不出来了,却还在沙滩上蹦着跳着给得了球的人加油。沙滩旁,子升架起画架写生,他的背后斯咏和蔡畅津津有味地看着浩浩湘江、连绵岳麓从子升的笔下流淌出来。警予和蔡和森却哪里都没有去,坐在火堆旁边添柴、守着红薯不要被烤糊了。通红的火苗窜出老高,映照着两人的脸。他们谈了最近读的书、谈了学校的新活动、又谈了些朋友间的趣闻,警予看着眼前的火堆、橘子林和远处同学们的身影,深深吸了口气,换了个话题:“真美啊!”\n“是啊,要是能天天这样,静静的,就这么坐着,那真是人生最大的幸福。”蔡和森犹豫了一下,“我是说,要是……要是两个人的话。”\n警予没有想到蔡和森会有这样的表白,心里猛然间说不出有多紧张、甜蜜和羞涩,竟不由自主地低下了头。一片小树叶在微风中飘下来,晃晃悠悠地正好落在警予的头发上,警予正伸手想去摘下来,蔡和森也已经伸出手了,两只手在警予的耳朵旁碰在了一起……\n“你们搞什么鬼?说好闻到香味就来叫我们的嘛!香味都飘过湘江了,你们居然还在这里只顾说话。”毛泽东像龙卷风一样横扫过来,凑在火堆旁仔细地嗅着红薯的香味,急急地用树枝扒出了一个个烤得黑糊糊的还在冒烟的红薯。他的叫喊声把所有人的馋虫都钓了起来,踢球的、画画的、喝彩的全欢呼着拥了过来。警予和蔡和森对视了一眼,心领神会地笑了笑,都扎进了抢红薯的人堆里。\n毛泽东把第一块红薯掰成两半,一半递给左边的开慧,一半递给右边的斯咏。斯咏文雅地小口咬着,开慧被红薯烫得直啧嘴,却偏要狼吞虎咽,吃得连鼻子尖都沾了红薯,旁边的人看到都大笑起来。\n简单的午餐过后,蔡和森宣布稍微休息一会,就开始今天的主题读书活动。张昆弟、萧三他们一听这话,抱起足球就往沙滩上跑,毛泽东跑了几步,又回来,把所有燃到一半的柴全部退了出来,埋进土堆里。他在做这些的时候,其余的人也已经各自找到了好玩的去处,跑掉了,只有斯咏静静地站在不远处看着他,等他把事情做完,走到自己身边。\n斯咏和毛泽东并肩走出橘子林,走到了江边。远远地看到萧三他们踢得起劲,毛泽东也想过去,可看看斯咏慢吞吞一副欲言又止的样子,他又不好意思把人家一个女孩子丢下。还好,斯咏终于开口了:“润之,还记不记得上次,我们也是这样,走在江边。”\n“哪次?”\n“就是上次,当时还下雨了。”\n“哦,你说那次啊,那不是在江那边吗?”\n“只要是我们俩,江哪边还不都一样?”\n斯咏看着毛泽东,似乎要把下面的话用眼睛说出来,可正当毛泽东看她时,她却又用长长的睫毛把眼睛覆盖住了,把一头青丝留给了毛泽东。两个人于是又沉默了,依然并肩慢慢地走着,在沉默中揣测着对方的心思,直到蔡和森在前面高声喊他们快开会了。\n今天的主持人是萧子升,大家都围坐在了沙滩上后,活动就正式开始了。\n“今天的议题,是改造读书会。这个想法,是润之和蔡和森提出来的,上次我们曾经讨论过,不过没有定论。今天呢,我们就继续讨论这个问题。”子升转向蔡和森,“和森,你的建议,你先说说吧。”\n“改造读书会,形成一个正式的进步青年团体,应该说是大家的共识,关键在于,我们新成立的这个团体,应该有着怎样的宗旨,应该朝哪个方向努力,应该定一个怎样的目标,只有这些方面形成了共识,这个想法才有可能实现。”\n蔡和森才停下来,萧三就回答:“上次润之哥不是说了吗?改造中国,改造世界啊。”\n“改造二字,未免言之过大,我看,这个团体,应该以致力于个人及人类生活向上为目标,首先是严格个人的生活,然后是周围的人,推而广之至全人类,只要我们这个团体,对此能有所贡献,使社会能受其影响,有所改良和进步,也就算是相当成功了。”子升一向不喜欢毛泽东的好高骛远。\n“个人及全人类生活向上?嗯,说得好。”\n“积跬步而至千里,千里我们也许做不到,能脚踏实地积跬步,也是不错的。”\n周世钊和何叔衡表示支持子升的观点,但开慧、张昆弟和罗学瓒却觉得还是毛泽东的改造世界来得过瘾,斯咏似乎还没从刚才的状态里走出来,一副心不在焉的样子。蔡和森于是把目光转向了毛泽东。\n“跬步也好,千里也好,现在言之,不免过早。我倒是觉得,有一条我们应该先定下来:团体的范围。我们这个团体,就应该是个最先进、最团结、最强有力的团体,所以范围不宜搞得太宽,我们要寻求的,必须是那些胸怀大志,能砥砺自身,严于律己,愿意为理想而奉献生命的真同志,”毛泽东突然往斯咏脸上看了一下,却又马上把目光收了回来,缓缓地站起来说,“时光这么宝贵,中国的事还有这么多等着我们去做,我们这些要担负大责任的青年,就应该想大事,做大事,没时间去考虑那些个人的小事情。所以我觉得,我们这个新团体,应该定一个‘三不’原则。”\n“三不原则?哪三不?”大家异口同声地问。\n“第一,不谈那些鸡毛蒜皮、杂七杂八的琐事。”\n“同意。”\n“第二,不谈个人的私事。”\n“同意。”\n“第三,不谈男女之情。”\n没有人注意到,斯咏的目光蓦然间黯淡了。警予正在给蔡和森整理着弄皱了的衣服,她抬起头,发现众人的目光都在看着自己,眼睛一瞪:“谁谈男女之情了?”\n“毛大哥没讲哪个具体的人啊?他只是说,时光宝贵,我们有志青年没时间谈那些不着边的事嘛!大家说是不是啊?”开慧一面说,一面挤眉弄眼,大家都知道她在说谁,萧三、张昆弟等几个人首先起哄吆喝起来:“开慧说得对,我同意!”\n“都同意是吧?都同意是吧?同意的握手。”开慧第一个伸出手来,其他人纷纷起身,七八只手一下子叠在了一起。众目睽睽下,蔡和森也只得伸出了手,那只手却犹豫着,伸了一半,僵在了半空中。他目光望向了警予,似乎准备把手往回缩,却又不好意思。望着他已经伸出去的手,警予的脸沉下来了,她赌气似的一伸手,往众人手上一叠:“我同意!”这下蔡和森的手不好缩回了,他也只得加入其中。\n在场的所有人中,只剩下斯咏与子升没有伸手。\n开慧问:“斯咏姐,子升大哥,你们两个呢?”\n望着毛泽东坦然的眼神,斯咏慢慢地伸出手,与大家握在一起,子升犹豫了一下,也伸手盖在了斯咏的手上。\n此时,夕阳正把一天中最美好的瞬间定格在湘江上。毛泽东看到今天的活动已经达到完满的效果了,就提议说:“这个时候,洲头的风景最漂亮,一起去看看,好不好?”\n“好哇好哇,大家比赛,看谁第一个到。”\n一群年轻人甩开膀子就跑,直往橘子洲头冲。猎猎晚风中,他们涌上洲头临江的高处,放眼望去,湘江浩荡,滚滚向前,天边,夕阳残照,晚霞满天,映照得一江春水,波光粼粼,苍翠的岳麓,大自然的壮观之美,震人心魄!迎着猎猎江风,方才的紧张与沉闷仿佛随风而去,毛泽东纵身跳上了一块突起的岩石展开双臂,仰天一声长啸:“啊!江山如画,一时多少豪杰!”\n子升笑道:“怎么?毛大诗人,发思古之幽情啊!”\n“思什么古嘛?难道只有古代才有豪杰?当年万户侯,皆已成粪土,我同学少年,才风华正茂,何须古人开我心胸?哎,你们也来,都上来,上来看看,来呀!”\n毛泽东一把将斯咏拎上了岩石,其他人也纷纷跳了上来。凌空而立、俯瞰山川,他们每个人的心里都豁然升腾起一种居高临下、超乎于自然之上的壮美:“问苍茫大地,谁……主……沉……浮……”\n“总有一天,我要写一首诗,写出我们中流击水的风华正茂,写出我们指点江山的壮志豪情!”夕阳下,毛泽东的声音如龙吟虎啸,回荡在天地山川间。\n第二十四章 书生练兵 # 窃昭绶忝再任第一师范学校校长……佥以人格教育、军国民教育、实用教育为实现救国强种唯一之教旨……我国国民,身体孱弱……历年外交失败,由无战斗实力以为折冲后盾……世界唯有铁血可以购公理,唯有武装可以企和平……故学校提倡尚武精神,诚为今日之要义,此学生志愿军倡办之必要也。\n一 # 去年冬天,斯咏看到子鹏带着秀秀又在教堂外,一边喊着“圣诞快乐”一边给一群小叫花子撒零钱,曾和子鹏开玩笑说:“这些小孩子未见得就知道耶稣、读过《圣经》,怎么会在乎圣诞节呢?”她没想到,一向在她面前说话唯唯诺诺的子朋居然想也没想就回答说:“我们小时候也不知道屈原、没读过《离骚》,不是一样过端午节吗?”这话说过彼此就都忘记了,但端午节真的快到了,斯咏却毫无来由地突然想起了这件事情,觉得子鹏偶尔说句话,还是蛮有道理的。很多时候,斯咏都在想,如果不是因为那桩莫名其妙的娃娃亲,她和子鹏的关系一定不会像现在这么尴尬。\n要过端午节了,今年家里会做些什么样的粽子、爸爸今年会不会请龙舟去参加一年一度在湘江举行的龙舟大赛?放学后,斯咏这样想着、哼着小调回到家,进了客厅,却看到陶会长已经回来了,正在仔细地打量一匹白纱,纱的旁边还堆着各色绸缎、果品和一大摞重叠的精致礼品盒子。\n“斯咏你回来了?快来快来,”陶会长拿起手里雪白的纱往斯咏身上比划着,“端午节快到了,这些都是你姨妈姨父送来的节礼。你和子鹏明年不就毕业了吗?你姨妈他们的意思呢,到时候,给你们弄回新鲜,办个西洋婚礼,这个,是人家专门托人从法国买回来的,最好的婚纱面料,你看喜不喜欢?”\n他兴奋地唠叨着,却没注意到斯咏的脸已经沉下来了,一手把婚纱面料扒开。陶会长赶忙问:“怎么了,不好看啊?”\n“好不好看我都不要!”\n“你要不喜欢,那我们还办中式婚礼,我跟王家说一声就是。”\n“我什么式都不要!”\n斯咏转身就走,甩手碰倒了摞得高高的礼品盒子,里面大大小小的饰物滚落出来,一下子把整洁的客厅弄得乱七八糟。\n“斯咏!”陶会长叫住怒气冲冲的女儿, “斯咏,我知道,有些话你不爱听,可你如今也不是孩子了,不能什么事都依着性子来。你和子鹏,那是你爷爷、外公手上就定好了的婚事,哪能你说不干就不干?”\n“我不喜欢表哥,我凭什么嫁给他?爷爷、外公他们都过世多少年了,我的事,凭什么还要他们说了算?”斯咏背对着爸爸,头也不回。\n“婚姻大事,长辈做主,天经地义嘛。”\n“爸,那我能问你一个问题吗?”斯咏腾地转过身,“你和妈也是长辈包办的婚姻,你觉得幸福吗?”\n陶会长没想到女儿会如此提及父母,不由得愣住了,好半天才喃喃地说:“我……我和你妈也不错啊,我们那么多年,一直相互尊重,相敬如宾……”\n“是,你们是相敬如宾,可夫妻之间,光有尊敬就够了吗?我一直还记得,妈过世以前,你们两个每天都是那样客客气气的,见面,打招呼,一起吃饭,然后呢,你做你的生意,她看她的小说,你们一天连话都说不上几句。哪怕你们吵一次架也好啊,可你们架也不吵,就这样十几年,就这样半辈子。爸,你真的觉得和妈在一起是幸福的?你对那样的婚姻,真的从没后悔过?你能回答我吗?”\n斯咏如竹筒倒豆子一般地把心里话说完以后,转身上楼,跑进自己的卧室,把陶会长一个人晾在客厅里。这次,陶会长没有叫住女儿。女儿的话,深深触动了他心底的痛处,他扶着沙发的靠背,抬头看墙上挂的那张他与妻子当年的一张合影。模糊的黑白照片上,长袍马褂的他与旗式装束的妻子隔着茶几,正襟危坐,面无表情。\n为了女儿的幸福,他决定当晚就去一趟王家。\n王老板和王夫人见陶会长一个人来了,有些失望。但随即就热情地请姐夫入座、吩咐仆人沏茶,还特意说子鹏出去散步了,马上就回来了。与斯咏妈妈的个性相反,这个姨妹能说会道、泼辣能干,陶会长一向对她敬而远之。这些年来,即使妻子在世的时候,也多是王家去陶家走动,妻子过世之后,两家走得不那么勤了,但也仍然只是王家上陶家的门。说来,陶家是女方,矜持一些也是应该的,况且自己的姐姐姐夫,从个性到家业都知根知底,王家夫妇也就没往别处想,他们早就把斯咏当成了王家的儿媳妇,而斯咏是陶家唯一的女儿,陶家的一切迟早都是王家的,还计较什么呢?\n陶会长当然明白王家夫妇的心思,其实这些年他又何尝不是这样想的呢?但问题的关键是,他也这样想的前提,是斯咏要嫁进王家和子鹏白头偕老,是女儿的幸福有保障。但现在女儿不想嫁给子鹏,这一切打算就毫无意义了。他想着,端起茶,拂着茶叶,斟酌着该如何开口。王老板看出陶会长的神色有些异样,便问他是不是有什么心事。\n“哦,也说不上有事。这几天,我……一直在想子鹏和斯咏的事,他们俩吧,原来小,长辈给作了主,也就那么定了。可现在呢,孩子都大了,都二十出头了嘛,也是自己有主意的年纪了,时间过得快呀……”\n陶会长说到这里,王夫人自以为听明白了姐夫的意思,拍着巴掌说:“这话说到点子上了,姐夫,您着急,我们比您还急呢。你看看人家,十七八的,孩子都能叫爹妈了,哪像他们俩,二十几了,还拖,早该办了。”\n“是啊,姐夫,原来呢,你一直不做声,我们是着急也不好催。难得你今天说起这事,我看啊,是得给他们好好准备准备了。这喜事嘛,宜早不宜迟。要照我的意思,也别等什么毕业不毕业,挑个好日子,尽早办。”\n本来想委婉地提出退婚的陶会长,听到这夫妻俩的话,也不敢确定他们是不是真的没听出自己的真实意图,又说: “我倒不是这个意思,我是说,时代不同了,年轻人有年轻人自己的想法,咱们这些长辈作的主,他们也不见得就一定愿意……”\n“这种事还能由得他们?还不得我们当父母的来操心?”\n王老板瞪了夫人一眼:“姐夫这话说得也在理。斯咏到底还在读书,真要成了亲,总不好再出去抛头露面吧?还是照咱们原来商量的,等他们毕业,毕业就办。算起来也就一年工夫了,咱们两家早点做准备,到时候办得风风光光的,孩子们也高兴嘛。姐夫,你看怎么样?”\n“这个……”陶会长看着这个精明的连襟,只得含糊地应承,“也是,也是……”\n“姨夫?!”\n陶会长正不知道说什么,子鹏散步回来了。看到陶会长在座,他喊了一声,回头看了看跟在后面的秀秀,秀秀急忙低下头看着脚尖。刚才在路上,子鹏才和秀秀说起希望能永远不毕业、希望那些不该来的永远别来,那些值得珍惜的永远留在身边。他们心里都明白,值得珍惜的,就是他和秀秀之间的情谊,不该来的,就是他和斯咏的婚事。可话音还在耳朵边响着,就在家里看到陶会长,这让子鹏很有些尴尬。\n“什么姨父?以后别叫姨父了。你姨父今天,可是专门来商量你和斯咏的婚事的,日子咱们都定好了。所以,打今天起,你呀,就该直接叫岳父。”王夫人一推王老板,“万源,你说是不是?”\n“对对对,叫岳父!难为你岳父大人为你的事辛辛苦苦跑来,赶紧,现在就叫,让他老人家也高兴高兴,叫啊。”\n尽管自小就和斯咏定了亲,也知道迟早要叫陶会长“岳父”,但子鹏却从没有想过这一天来得这么突然。他看看父母、又看看秀秀,父母的脸上是期待,秀秀低着头,看不到她脸上的表情。陶会长也没料到王家夫妻会来这么一招,却又不知如何推辞,看着子鹏说不出话来。\n“迟早都要叫,还等什么?子鹏,你瞧这孩子,还不好意思了,你倒是叫啊。”\n“子鹏!”\n妈妈的话绵里藏针、爸爸的话简直就是在命令了,子鹏像是被逼到了角落里的猎物,无助到了极点。他的嘴唇哆嗦着,还是艰难地叫出了声:“岳……岳父。”\n王老板和王夫人开怀大笑,陶会长木然地站了起来,秀秀的头埋得更低了。\n二 # 湖南这块土地上,出过太多的敢为天下先的英雄,最著名的莫过于以武功盖世著称的文人曾国藩曾文正公。道光十八年曾国藩从湖南湘乡一个偏僻的小山村以一介书生入京赴考,中进士留京师后十年七迁,连升十级,37岁任礼部侍郎,官至二品。因母丧返回长沙,恰逢太平天国巨澜横扫湘湖大地,他因势在家乡拉起了一支特别的民团——湘军,历尽艰辛为清王朝平定了天下, 被封为一等勇毅侯,成为清代以文人而封武侯的第一人。曾国藩所处的时代,是清王朝由乾嘉盛世转为没落衰败、内忧外患接踵而来的动荡年代,由于曾国藩等人的力挽狂澜, 一度出现“同治中兴”的局面,曾国藩正是这一过渡时期的重心人物,在政治、军事、文化、经济等各个方面产生了令人瞩目的影响,一时间“尚武强兵,以壮国力,人人有责”成了湖南学人的传统。还在一师做学生的毛泽东于近代诸多豪杰中,就独服曾国藩,并坚信,关到书斋里读死书是行不通的,继曾国藩之后,左宗棠、黄兴、蔡锷,哪个不是战场上打出来的赫赫功业?所以,唯有文武兼修,方能成大器!而1917年的中国,对外已经宣布参加第一次世界大战,对内也是军阀混战狼烟四起,于是,还有一年就要毕业的毛泽东想在一师搞学生练兵。\n他的主意得到了孔昭绶、杨昌济的大力赞成,但这毕竟不是简单的组织学生做体操,为慎重起见,孔昭绶就此事奏请了当时的湖南督军谭延闿:“字呈湖南督军谭延闿大帅阁下:窃昭绶忝再任第一师范学校校长……佥以人格教育、军国民教育、实用教育为实现救国强种唯一之教旨……我国国民,身体孱弱……历年外交失败,由无战斗实力以为折冲后盾……世界唯有铁血可以购公理,唯有武装可以企和平……故学校提倡尚武精神,诚为今日之要义,此学生志愿军倡办之必要也……”\n王子鹏是在学校的公示栏里看到《课外学生志愿军报名启事》时才知道这件事情的。公示栏旁边一字排着两张课桌,被报名的学生围得水泄不通。子鹏仔细看了启事后,对里面说的什么懵懵懂懂,不过旁边的一幅简练的标语却让他心动。\n“铁血可以购公理,武装可以企和平。”\n长久地看着这幅标语,子鹏的心里也鼓荡起了一股男子汉的豪情,理解了为什么会有那么多同学去报名,也站到了队伍后面,准备报名。可望着一个个同学领了崭新的学生军军装,兴高采烈地挤出人群,子鹏虽然好羡慕,却又想到自己平时在同学们眼里是个吃不得苦的大少爷,即使报名也未必能被录取,才沸腾起来的心又凉了,踌躇不敢上前。不过,这也正是改变不好印象的好机会呀,试一试有什么关系呢?也许自己不会比别人做得差呢……翻来覆去犹豫了很久,子鹏终于给自己鼓足了劲,抬脚向前挤去。\n“让一下、让一下。”恰在这时,毛泽东和张昆弟抱着两大捆新军装过来,子鹏只顾着看前面,没让路,毛泽东颇不耐烦地蹭了他一下,口里叫着王少爷,说人山人海的你挤到这来干什么?莫挡路。子鹏一惊,赶紧让到了一边,毛泽东他们刚一过去,后面的同学一下子挤了上去,又把他给挤到了最外面。\n子鹏想想毛泽东的话,看看挤成一团的同学,叹息一声,刚刚鼓起的勇气又消散了。\n学生军每天下午课后训练,八班寝室里,除了子鹏都去参加了。子鹏像只离群的雁,呆在哪里都不自在,干脆跑到操场旁边去看他们训练。“一二一,一二一,一、二、三、四!”震天的吼声中,同学们穿着仿制军服、戴着“第一师范学生军” 袖标、肩头扛着木头假枪,正在烈日下操练队列。子鹏目不转睛地盯着带队喊操的毛泽东,他走在队伍最前面,动作看起来好英武。四年前刚开学的时候,子鹏就听毛泽东给易永畦讲过,全校同学里头,就只有毛泽东一个人真正当过兵、扛过枪,而且还是正规军,湖南革命新军第二十五协五十标左队列兵。虽说只当了半年,可他们那时候的训练总长是日本讲武堂的高材生程潜,对他们进行的是一整套日本陆军正规操练。今天看来,果然是真的呢,难怪毛泽东只要一说起那段经历,就自豪得不得了。\n看看毛泽东健壮的身板,再看看自己单薄的身材,子鹏真恨不得能马上跑进操场里去,跟在毛泽东的身后,随着他的喊声和其他同学一起训练。可想想毛泽东看自己的眼光,却不由自主地转身想回寝室去。不过刚一抬脚,竟看到蔡和森陪着孔校长和杨老师边说着话边过来参观,只好又转回身靠在树干上,做出一副正在看训练的样子。\n“昭绶,你这个第一师范学生军,搞得还有声有色的啊!”\n“也算难为润之他们了。我原来还答应过他,跟督军府要真枪实弹呢,可到头来,一支真枪也没能给他们弄来。”\n“秀才练兵嘛,谭督军还能真把这些孩子当回事?能发几支木头枪,已经是给面子了。”\n“怕就怕这假枪练不出真本事来啊!”\n“又不是真上前线打仗。学生们要练的,也就是军人的那股尚武精神,只要能练出那股精气神,真枪假枪,有什么关系?”\n说话的是两位先生,蔡和森一直没开口。他们走过之后,子鹏看着他们的背影、咀嚼着他们刚才的对话,长出一口气,暗暗地下定了决心。\n训练结束了,同学们提着抢叫着累出了操场,毛泽东还是精神百倍,嚷着:“这就喊练惨了?我跟你们讲,才开始!也就是队列、卧倒,接下来,越野、格斗、拼刺、障碍,你们才晓得什么叫军训!”他的话音才落,就有人说道:\n“你放心,毛长官,你以前军营里怎么练的,我们也一样,撑不住的,不算好汉。”\n看到大家有说有笑地就要从自己面前走过去了,子鹏怯生生的叫了毛泽东两声。毛泽东见是子鹏,有些意外,站住了问:“叫我啊?什么事?”\n“我……我那个……”子鹏紧张地绞着有些苍白的手指。\n“有事讲啊!”\n“我……我想报名参加学生军。”\n已经走到前面去了的同学都愣住了,停下来看看王子鹏、又看看毛泽东。毛泽东上下打量着单瘦苍白的子鹏,仿佛是不敢相信自己的耳朵:“你?”说完,长笑三声,紧跑几步撵上了前面的同学,扬长而去。\n子鹏知道毛泽东一向讨厌自己是少爷出身,可也没想到他居然会这样对待自己。在旁边的低年级同学的指指点点中,子鹏恹恹地走在操场边的小路上。蔡和森刚把两位先生送走,转回来,看到子鹏和周围学弟的样子,忙问子鹏发生了什么事情。了解了经过,蔡和森拉上子鹏就往八班寝室走。\n毛泽东刚换下仿制军服、穿上自己的土布衣裳,正扣着扣子,一听蔡和森是来给王子鹏讲情的,看看子鹏说:“他还学生军?他少爷军就差不多。”\n子鹏被毛泽东盯得退后一步,蔡和森拉住他,对毛泽东说:“子鹏也是一师范的学生,一师范学生军,他为什么就不能参加呢?”\n“你自己看他那个样子,糯米团子一样,搞这么个人来,我的学生军还搞得成器?”\n“润之,这话就是你的不对了。第一师范学生志愿军,什么时候成你的了?子鹏平常性格是比较柔弱一点,可他既然想报名,就证明他想改变,想让自己坚强起来嘛。你那个报名启事上也说了,凡我同学,均可报名,怎么到了子鹏这儿,就要分个三六九等呢?”\n“他是个少爷啊!”\n“少爷就不是人了?我知道你对他印象不好,可你连个机会都不给他,又怎么知道他一定不行呢?”\n“你不信我跟你打赌,他那个少爷,搞不成器!”毛泽东的口气软了,算是答应了蔡和森,给子鹏一个机会。\n三 # 第二天课后,子鹏领到了学生军军装,衣裳虽然大了些,穿在身上松垮垮的,但子鹏还是很兴奋地扛着木头枪排在了整个队伍的末尾,在毛泽东的指挥下,进行着齐步跑训练。子鹏平时的体育课成绩就只是勉强过得去,又拉了课,跟在队伍里很吃力,不是立定的时候差一点没收住脚步,就是在行进中慢别人半拍,最让他难受的是卧倒。\n毛泽东一声令下,自己头一个结结实实扑在地上。身后,学生军一齐扑倒在地,排在末尾的子鹏痛得直咬牙。随着接连几声“卧倒”、“起立”、“卧倒”、“起立”,子鹏痛得嘴角都抽变了形,但他还是满头大汗地拼命地支撑着……\n晚上子鹏回到家,才一进屋,王夫人就尖叫起来,以为儿子遭劫了。子鹏解释了半天,才让妈妈明白自己是在参加军训。“你说你这孩子,什么不好玩,跟那帮不要命的玩打仗,你瞧瞧你瞧瞧,都成什么样子了?”王夫人把子鹏拉到沙发上坐下,检查着儿子身上一道道的红肿,招呼秀秀赶紧去拿碘酒。秀秀用蘸着碘酒的药棉轻轻擦在子鹏磨破了皮的手肘上,痛得他一抽,疼在子鹏身上,也疼在秀秀心里,秀秀的动作更加轻柔了。王夫人听儿子说这样的训练还要持续两个月,先是想说服子鹏不要去了,后来看看说服不了,就安排秀秀每天下午子鹏训练的时候,熬些解暑的汤送去。\n端午以后的太阳光,就跟火焰没什么区别了,烤得地面滚烫,照在皮肤上,让人有火辣辣的感觉,学生军的训练因此也更考验人了。训练期间,只要一休息,同学们就“哄”地全跑到树阴下去了,敞开衣襟扇着风,争先恐后地大口喝水,但子鹏因为拉下的训练太多,独自还留在操场上练着,前胸后背,都被汗水浸透了,满头满脸的汗水还在顺着脸淌着。毛泽东给子鹏指点了要领,也劝他去休息一会,子鹏倔强地要坚持要挤时间争取赶上同学们的进度。毛泽东赞赏地看看子鹏,说:“那你先练着,我喝水去,给你也端一碗来。”\n休息了一会儿,继续训练拼刺。一组组同学排着队,一支支木枪不断刺出,整个操场,杀声阵阵,一个个同学都练得异常兴奋。子鹏排在一队同学的最后,因为从没这么晒过,他的精神状态很不好。\n“下一个,王子鹏,王子鹏!”\n“哦。”子鹏猛然一惊,这才发现已经轮到了自己,赶紧端正架子,提枪刺出,这一枪却动作拙劣,连木桩的边也没挨到,刺了个空,他用力过猛,险些摔倒。旁边的同学都笑了起来。子鹏定了定神,再刺,还是差了一点,枪偏到了木桩外。他一连好几次,次次都偏了。他的动作实在是太滑稽了,旁边的同学已经笑成一团。\n大家又休息了,烈日下,子鹏咬紧牙关,用木枪刺着木桩。木桩震动着他的手,摩擦着他的手心,枪身握手的地方,已经沾上了血迹,他却仍然闷着头狠狠刺着。\n“少爷。”秀秀按照夫人的吩咐提酸梅汤来了,在子鹏的身后打开沙煲,将汤放在子鹏的旁边,又掏出手帕,来给子鹏擦汗。子鹏瞄了一眼不远处的同学们,想躲开,却又不好拒绝她的好意,只得伸手去拦。秀秀发现了少爷的手上的血,吓得一把抓住,慌忙用手帕去裹。\n“不要紧的,秀秀,真的不要……”\n“怎么不要紧?你的手这么嫩,哪受得了这个?你看还在出血呢!”\n远远看见这一幕,毛泽东不高兴了,虎着脸走了过来,“王子鹏,搞什么名堂?说过你多少次了,军训场上没有少爷!还丫环仆人跟着侍候,你以为这是你家?受不了苦你赶紧走,想当少爷回家当去,在这儿,就得像个男人,听到没有?”\n子鹏的脸腾地涨红了。秀秀还在拉着他的手包扎着:“少爷,您别动啊,还没包好呢!”\n“不要包了!”子鹏突然冲她吼了起来,“我不要你给我包,不要你送这样送那样,我不要人把我当孩子照顾,你不要再来烦我了好不好?!”\n他猛地一甩手,还未扎紧的手帕飞落在地。乒的一声,那只盛着汤的沙煲被他的脚碰翻,汤洒了一地!秀秀呆住了,眼泪涌了出来,她也不擦,转身就往操场外跑去。子鹏自己也被这突如其来的脾气吓住了,他愣了一下,把枪一扔,就去追秀秀。\n秀秀一路哭着跑过一家茶馆,刘俊卿带着几名三堂会手下正好优哉游哉地从茶馆里出来,他现在比当年当侦缉队长的时候还风光。不过一看到秀秀,脸上的表情立刻就柔和了很多,忙跟上去问,可怎么问,秀秀就是不吭声,只站在街角哭。刘俊卿不耐烦了:“到底出了什么事,你就不能跟哥说句实话吗?是不是在王家受气了?”\n秀秀一听这话,狠狠擦了一把眼泪。刘俊卿明白自己猜对了,顿时火冒三丈:“我去了王家几次,你都不见我。叫你别低三下四当丫环了,你偏不听。现在知道受气了?哥找你那么多回,求着你别干了,求着你出来当小姐,哥养着你,你偏不,你说你……你不犯贱吗?”\n一句话刺痛了秀秀的心,她转身就要走,刘俊卿赶紧拉住了她,尽量放软口气:“阿秀,哥不该跟你发火,是哥不对。我知道,我知道你看不起哥这种人渣,哥也知道自己就是个人渣子。可哥是真为你好,哥不想看到你再过那种穷日子啊!”\n说到伤心处,他自己先长叹了一声,颓然蹲下了。\n“哥这一辈子,反正是完了,混到哪天是哪天吧。可你不一样,哥亏欠你太多,这个世道它亏欠你太多了,哥没别的,就想你能过得好一点,就想你能开开心心,就算哥求求你好不好?你怎么……怎么就不肯给哥一点机会呢……”\n刘俊卿捂住了自己的脸,泪水从他的手指缝里流了出来。秀秀看着,想起几次看到哥哥在王家外面等自己、徘徊很久才离开,心里又有些感动,轻轻把手搭在了刘俊卿的肩上,叫了声:“哥!”\n这久违的声音令刘俊卿身子一抖,他站起来,正想说什么,突然传来子鹏的声音:“阿秀!”小巷口,满头大汗的子鹏正喘息着,望着秀秀。秀秀把手从哥哥肩膀上缩回来,低下了头。\n一时间,几个人谁也没说话。\n“你们谈吧!”看看子鹏,再看看妹妹,刘俊卿仿佛突然明白了什么,转身向巷子外走去,走出巷子口,又闪身往墙角一靠,偷听着妹妹和子鹏的谈话。\n“阿秀,对不起,我……我真的不是对你发火,我是心里烦,你别生气了。”\n“我只是个丫环,少爷骂我两句,我怎么敢生气?”\n“阿秀!我真的不是有心的,我知道你是关心我,可是……你知道吗?,我为什么参加军训?因为我不希望自己总是那么软弱,因为我一直很羡慕我的那些同学,毛泽东、蔡和森,还有好多好多我身边的同学,他们都活得那么自由,那么开心,那么敢做敢当。我只是想像他们一样生活,像他们一样坚强,我只是希望自己能勇敢起来,能保护我真正想保护的人!可我……可我却怎么也做不好,我是真的好烦好烦啊!”\n“少爷要保护的,应该是陶小姐才对。”\n“我不想保护什么陶小姐,我也不想别人塞给我一门什么婚事!”\n“可少爷跟陶小姐的婚事,已经定好了,老爷太太的话,少爷怎么能不听呢?陶小姐那么漂亮,那么知书识礼,少爷跟她,才是天生的一对。秀秀是个丫环,只希望少爷以后能和陶小姐过得开开心心的,秀秀就高兴了。”\n过了好一会,巷子外的墙边刘俊卿还没有听到声音,他探头出去,看到妹妹已经走了,子鹏还呆呆地站在原地,眯起眼睛想了想,心里已经开始酝酿一个计划了。\n四 # 陶府门外这几天突然多停了几辆马车:院墙边,有两辆人力车等着客人,车夫一个吸着旱烟,一个用草帽盖着头,倚在车上打着盹。旁边不远,还有两三辆车,车夫和几个闲人正围在一起下着象棋。不过,因为大门前是闹市区,常常车来车往,陶家也没有什么人在意。\n接连几天都没发生什么意外的事情,门外的车夫好像也不在乎生意的好坏,依然懒洋洋的。这天晚上,淡淡的月光照着,陶会长和女儿闲聊时,突然又说起了陶王两家的婚事:“感情呢,是可以慢慢培养的。要说子鹏,虽然是软弱一点,可这也是他的优点,人老实嘛!跟着他,至少让人放心不是?你们又是表兄妹,也不是完全不了解。我知道,现在说什么你可能也听不进去,可这门亲终究是定好的事,爸也不能随便跟王家反悔,你好好想想吧!”\n斯咏一听这事情就心烦,也不理睬父亲,沉着脸就出了大门,连管家叫她也不搭理。大门一侧的墙角边,那几辆人力车还停着没动,看到斯咏挥手,那个打着盹的车夫微微掀起草帽,向另一个车夫一勾手指,那个车夫便拉车迎了上去。\n“第一师范。”斯咏边说边上了车。\n斯咏坐的那辆车走后,打盹的车夫突然掀开草帽坐了起来,刘俊卿一张还算清秀的脸便暴露在了月光里。他手一挥,后面的一个车夫跑上前,拉起他就走。另外几辆人力车也同时跟了上去。\n陶会长看到女儿出了客厅,以为她只是去院子里转转就会回来,好半天没听到动静,便问管家小姐去了哪里?管家回答说不知道,叫她也没应,只是听她叫车,好像是去什么师范。陶会长眉头一皱,起身说:“备马车,去第一师范。”\n而此时,斯咏全然不知自己已进入了危险境地。入夜的街巷里,稀稀拉拉的只有几个行人、小贩,却有几辆相互跟着的人力车在青石街面上不紧不慢地跑着。最前面一辆车里坐着心事重重的斯咏,一路的街景晃过,她仿佛视而不见,甚至没有注意到车夫挽起袖子的胳膊上,赫然竟露着三堂会特有的刺青。他们身后的车上,刘俊卿眼睛微眯着,似乎在看前面的车、又似乎在看左右的行人。车子转进了一个巷子,里面很阴暗,连一个行人都没有。寂静中,只有人力车的车轮声吱呀呀地响着,刘俊卿腾地坐直了身子,手一挥,几辆人力车便同时加快了速度。\n斯咏听见了身后越来越近的脚步和车轮声,回头一看,僻静无人的街道上,好几辆人力车左右包抄,正向她围来,她不由得慌了,叫道:“车夫,快,快一点!”拉车的车夫不但没加快,反而停下了,他转过身,嘿嘿一笑:“对不起,陶小姐,跑不动了,休息一下吧。”\n斯咏一看这人咧开的大嘴缺了门牙,居然就是想强娶一贞的老六。斯咏还没来得及惊讶,几辆人力车已经从四面围了上来。暗夜中,寒光闪动,绳索、麻袋之外,好几个人手上还亮出了刀。斯咏吓呆了,尖叫道:“救命啊!救命啊!”\n几个人原以为计划万无一失,正要下手,后面却传来了马蹄声,一辆马车正朝这边疾驶而来。马车正是陶会长的,听见呼救,他猛地掀起车帘叫了声:“斯咏!”探身一把抢过了车夫的鞭子狠劲地抽着马。马车发疯般向前冲去,围上来的三堂会打手们猝不及防,吓得赶紧避让,马车撞翻了后头的人力车,直冲向前。\n“斯咏,快上车,快上车啊!”陶会长挥鞭抽打着欲上前阻拦的打手们,斯咏趁机冲过去,陶会长一把将她拉上了马车。\n刘俊卿已经回过了神,对着几个手下叫喊着拦住他、拦住他!前头的老六推起一辆人力车斜刺里冲上——马车“砰”地撞翻人力车,继续向前冲去,但站在车横梁上的陶会长被车子这一震,却摔下了车。\n“爸!爸!”\n“快跑,别管我,快带小姐跑!”\n在父女二人的喊叫声中,马车夫狂催车驾,马车狂奔而去。\n这一阵喧闹惊动了街两旁的居民,看到远远的有人嚷嚷着跑了出来,刘俊卿喝令手下把陶会长塞进麻袋里,赶紧撤退。\n但他们已经跑不掉了。\n斯咏乘着马车狂飙到一师找到毛泽东,说明了刚才发生的情况。尖锐的哨声骤然响起,划破了校园的宁静,正在休息的学生军马上投入了战斗,持着木枪,在毛泽东的带领下蜂拥来到刚刚出事的街面上,却只看到被撞得东倒西歪的那几辆人力车。\n斯咏急哭了,对着巷子两头大喊:“爸,爸!”\n毛泽东安慰她说:“你别着急,千万别着急,这帮家伙跑不远。大家听着,一连跟我走,二连往那边,连分排,排分班,每条街每条巷,分头去追!”\n“抓强盗啊!抓强盗啊……”一时间,四面呼应的喊叫声打破了黑夜的宁静,大街小巷,众多学生军分头追赶寻找劫匪。\n不远处的江边,正和秀秀闹着别扭的子鹏正抱着木枪心不在焉地练刺杀。木枪乒地刺在树上,却刺得太偏,向旁边一滑。子鹏咬着牙,盯着树干中间用粉笔画出的白色圆圈,再刺,枪又刺在了圈外。他定了定神,瞄了瞄,又一次刺出,却还是刺偏了。木枪单调而执著地击刺着,作为目标的大树已经被刺掉了不少树皮,露出了斑斑白印,但却几乎没有一处落在粉笔画成的白圈里。眼前的大树仿佛成了某个可恶的仇人,子鹏越刺越快,越刺越猛,直刺得喘着粗气还在拼命地刺着。猛地,木枪刺了个空,子鹏一个踉跄,撞在树上,枪失手跌落,他颓然跌坐在树下,仰头靠在了树上。\n“抓强盗啊!”学生军的呼喊隐隐传来。子鹏听见了喊声,站起身来,探出头打算看看发生了什么事情,却又吓得猛一缩头:他看到就在前面不远处,有几个人正抬着麻袋,朝这个方向跑来。\n一个抬麻袋的人实在累得不行,突然失足摔倒,麻袋一沉,其他几人也东倒西歪。\n“怎么回事,还不快点?”这分明就是刘俊卿的声音!\n“二爷,不行……实在是抬不动了……”\n“抬不动也得抬!给我起来,都起来,快!”\n在刘俊卿的吆喝声里,那几个人爬起来,拖着麻袋勉强向前走,渐渐地走近子鹏藏身的大树了。子鹏吓得紧紧靠在树身上,攥着木枪,紧张得牙齿都在不住地打战,全身上下,仿佛都僵硬了。打手们拖着麻袋,正从树旁经过,麻袋挣扎、扭动着,一阵阵绝望的闷哼正从里面传出。这绝望的声音让子鹏忘记了危险,他深深地吸了一口气,端着木枪突然跳了出去,拦在那群人前面!\n那一群人大吃一惊,扔掉麻袋,举起了雪亮的刀。但随即,他们就看出来了,拦在面前的,只有一个人。\n“王子鹏?”刘俊卿眉头一皱,“你也敢管闲事了?给我滚开!”\n子鹏喘着气,紧张得握枪的手都在不停地发抖,话也说不出来,只是使劲一摇头。\n刘俊卿盯着那抖动不止的枪头,笑了:“还逞英雄?王少爷,你怕是裤子都快尿湿了吧?赶紧滚!不然我不客气了!”\n“跟他废什么话?宰了他!”老六挥刀冲了上来。\n猛地,子鹏一声大吼:“杀!”\n木枪一记标准的刺杀,干净有力,正中老六胸口,老六仰面朝天,摔出老远!\n“快来人啊,强盗在这边!”这一枪准确的刺杀给了子鹏勇气,他终于声嘶力竭地大喊出来,而且一面呼救,一面挥舞木枪,与打手们拼命搏斗。\n寂静的夜里,子鹏的声音传出老远,斯咏和所有的学生军都听到了,一起朝江边拥了过来。\n势单力孤的子鹏终于抵挡不住,老六抢过木枪砸在子鹏头上,子鹏一头晕倒在地。四面,喊杀声、脚步声已然临近,打手们都慌了:“二爷,怎么办?”\n“还能怎么办?分头跑!跑出一个是一个!”\n打手们四散狂奔,老六捡起一把刀,想杀子鹏以报刚才刺杀之仇。可当他对准子鹏,举起刀时,有一柄匕首却已经从他的后背直穿过前胸!他回头一看,发现暗算他的,竟是刘俊卿。刘俊卿贴在他耳边,面上带着笑,口气却是狠狠地:“还记得被你逼死的赵一贞吗?我到三堂会,等的就是今天。”他手一松转身飞快地跑了,身后,老六一头栽倒在地。\n四面涌来的学生军围追堵截,一个个还没来得及跑掉的打手被当场生擒。解开麻袋,毛泽东和张昆弟扶起陶会长。斯咏一头扑进了父亲的怀里,陶会长反而拍着女儿的背安慰她不要哭,仿佛刚才装在麻袋里的是斯咏而不是自己。斯咏擦了一把眼泪,对父亲说:“爸,是……是润之他们救了您。”\n“陶伯伯,我们也是后来才到的。”毛泽东往旁边一让,指着蔡和森、萧三扶着的头上带伤的子鹏,“真正拼命救了您的,是这位王子鹏同学。”\n“子鹏?”\n“表哥?”\n“岳……”子鹏犹豫了一下,颇为艰难地叫了声,“岳父。”\n所有的人都愣住了,所有的目光,一下子都投在了子鹏和斯咏的身上。斯咏的脸,一下子涨得通红,恨不得地上有条缝能马上钻进去。\n五 # 因为涉嫌绑架和杀人,三会堂被查封了,三会堂的喽啰大都被警察抓住,只有马疤子和刘俊卿逃脱了,但各处交通要道都贴了通缉他们的告示。万般无奈,他们躲进塞满了鸡笼的船舱,打算逃离长沙。船到江心,马疤子和刘俊卿才战战兢兢地掀开笼盖,擦着满头满脸的鸡毛、鸡屎,探出头来透气,他们俩都穿着一身脏兮兮的破衣服,全没了往日的威风。\n打量着自己的狼狈样子,马疤子一肚子闷气实在是无处发泄,狠狠踹了刘俊卿一脚:“我操你个外婆的!我怎么就信了你这混账东西?几十年的基业,就他妈毁在你手里!我、我恨不得掐死你!”\n“行了,老大别怨老二,我还不一样,陪着你逃命?”刘俊卿看起来倒不像马疤子那样沉不住气。\n长叹了口气,马疤子悄悄向舱外探头,看到江水滔滔那一边,长沙城正渐渐远去,他恶狠狠地说:“长沙城啊长沙城,你等着,马爷我总有一天会回来的!”\n第4部分\n[手机电子书网 Http://www.bookdown.com.cn]\n第二十五章 学生人物互选 # 这两个学生,我必须了解。\n因为中国的未来,\n既不能缺少蔡和森的睿智,\n也不能缺少毛泽东的天才。\n一 # 1917年的6月,火热的夏天。绑架事件之后,所有人对王子鹏的印象都发生了转变,毛泽东也一样。\n一师操场,随着子鹏一声口令,十来个同学一齐立正。子鹏小跑到毛泽东面前,立正,敬礼:“一连下士王子鹏报告上士,本班全体集合完毕,请上士下令!”毛泽东没有马上作声,伸手给子鹏扣紧了军装最上面的扣子。四目相对,两个人都会心地微笑了。毛泽东拍了拍子鹏的肩膀:“开始巡逻吧。”\n“是!向右转,出发!”带着从未有过的自信与昂扬,子鹏带队向校门外走去。\n透过校长室的窗户,看到子鹏和学生军消失在大门外,孔昭绶将目光移向了飘扬的校旗,一时感慨顿生:“男儿何不带吴钩,收取关山五十州。”\n毛泽东组建学生军的建议能得到孔昭绶的首肯,是因为孔昭绶自己也是一个极度崇尚曾文正公的学人,想投笔从戎是多年的夙愿,并不是突生壮志豪情。他一直有个从军梦,常常和同事朋友谈起,这辈子如果不教书了,一定要去当个将军。现在看到毛泽东他们的学生军练得那么好,这样的心情更是迫切了。他一直都相信杨昌济的眼光,相信毛泽东是个非凡的学生,但也只是个学生而已。他却没想到,原本只是想让学生练就一点尚武精神,而毛泽东硬是把二百学生给练成了二百军人,连一师附近这一片的治安巡逻都让他们担起来了,还真是大出他的所料。\n正因为这个原因,他才把杨昌济请来,和他谈起了自己的新想法: “这一段,我们的学生自治运动开展得不错,趁着这个学期结束,我想用一种全新的形式总结一下。”\n当杨昌济听说这全新的形式就是学生人物互选时,立刻便表示支持,并就具体细节和孔昭绶进行了探讨。\n互选通知一公布,立刻就成了读书会成员们最关心的话题。周末到了君子亭,不需要谁提醒,大家就你一言我一语地议论起来了。\n“通知都出来了,明天公开投票,德智体三大项,下面分人品、自治、文学、言语、才具、胆识、体育等等十个小项。”萧三兴致勃勃,“每人限投两票,学生选学生,老师不参与。这可不是老师给谁打分呀,是全校同学投票,谁在同学里头最有威信,最得人心,最能服众,人心一杆秤,当场见分晓的。”\n毕业后已经到楚怡小学教了两年书的萧子升留意到今天斯咏没有来,心里说不出有多失落,对大家正在讨论的问题也很不以为然:“选上又怎么样?”\n但他的声音太微弱了,根本没引起大家的注意。性急的警予扯着大嗓门问:“哎,你们说,这次人物互选,你们一师范谁能得票第一?”\n开慧想也不想就回答:“那还用说,当然润之大哥。”\n何叔衡也点着头:“嗯,我也觉得应该是润之。”\n“不见得吧?就他那个成绩,数理化音乐美术,几门都不行,还优秀学生?”\n子升可不看好这个严重偏科的毛泽东,但他这句话却很让毛泽东不服气,毛泽东自信地反问:“又不光是选成绩,除了成绩差一点,未必我毛润之没有优点了?”\n子升看看他猴急的样子,故意激将道:“你这个意思,这个第一非你莫属喽?”\n“我没这么讲啊,谁得第一,大家选嘛。”毛泽东赶紧缴械,不敢再就这个话题和子升纠缠下去。\n警予的目光投向身边一直没作声的蔡和森,蔡和森却淡淡地微笑着,全无开口的意思,她耐不住了:“要我说,一师的优秀学生,这里也不止一个吧?”\n子升听出了警予话里的弦外之音,笑笑,明白地说:“对,要是我来投票,我就投给蔡和森。”\n一时间,李维汉等几个同学赞成子升的提议支持蔡和森,张昆弟、罗学瓒连声附和萧三支持毛泽东,局面基本成了一半对一半。\n子升温和地煽着风:“润之,和森,你们两个自己说,谁会得第一。”\n毛泽东立即表态:“选别人我不讲,选蔡和森,我毛润之一百个服气。”\n所有人都等着蔡和森开口。蔡和森平静地微笑着,摊开了手里的一本书:“我们今天不是讨论黑格尔的哲学原理吗?还是谈正题吧。”\n学生互选,别说在一师是首创,在全国的学校里也是首创呢。这么大的一个举措,学生们在讨论,老师们也在讨论。\n教务室,孔昭绶率先提出了这个大家都感兴趣的话题:“列位先生,你们猜猜,这次学生互选,谁的票数会第一?”\n正在忙着的老师们都放下了手里的工作,连老学究袁吉六也端起了水烟袋,自信地说:“要说谁能得第一,袁某心里,倒认准了一个。”\n费尔廉和老先生开玩笑:“我也觉得有个学生一定会得第一,不知道和袁先生想的是不是同一个人。”\n两人正要说,孔昭绶打断了他们:“哎,两位先生且慢,我有个建议,咱们在座的先生学一回瑜亮,各自把答案写出来,一起公布,再跟学生投票的结果作个对照,大家觉得怎么样?”\n看看老师评价学生,跟学生自己评价学生,结论是不是一样。这倒是个体察教育心理的好办法。这个建议显然更有挑战性,老师们互相看看,都点了点头。\n于是先生们各自提起了笔……六张写着答案的纸凑到一起,同时翻转过来:孔昭绶、徐特立、袁吉六写的是“毛泽东”,黄澍涛、饶伯斯、费尔廉写的是“蔡和森”,结果恰好三对三。\n费尔廉叫道:“怎么只有六票,刚才我们不是七个人吗?”\n大家回头一看,却发现杨昌济一个人还坐在原处,面前的白纸仍然空着。\n大家都等着板仓先生一票定乾坤,杨昌济反倒把笔放下了。\n“要说一师的人才,出类拔萃者,不外乎蔡、毛二人。蔡和森嘛,锋芒内敛,外柔内刚,那是表面平静却蕴藏着无穷力量的水,毛泽东呢,纵横恣肆,张扬不羁,就像一团熊熊燃烧光芒四射的火焰。如果单以个人喜好而言,倒是蔡和森的中正平和更对我的胃口。不过,这次学生互选,他们两个具体的得票率,却一定出乎大家的意料……这样吧,”杨昌济抽出钢笔,在白纸上刷刷写上两行字,折起来,说:“这里是我预测的选举结果,至于准不准确,等选举结果出来以后,我们再打开当场核对,好不好?”\n孔昭绶笑起来:“想不到昌济兄居然童心未泯,行,那我们就静候你的铁口神算了。”\n大家都笑了,暗自猜测着这个问题,最终会是什么结果。\n二 # 一师礼堂,一师全体学生聚集一堂,“第一师范学生人物互选”横幅悬挂在主席台上,主席台的桌上,一列排开的十来个票箱上,分别贴着“敦品”、“才具”、“自治”、“言语”、“胆识”等标签。\n台下,大家正在互相议论着。“各位同学,”主席台上的方维夏开始主持互选了,“今天,是我们第一师范学生人物互选的日子,也是第一师范第一次由全体同学投票决定,谁,是大家心目中最优秀的学生。在座的每一位同学,都可以凭自己心中的标准,按台上的分类,投出两票。下面,我不多说,给大家十分钟考虑,十分钟后,开始投票。”\n一片热烈的交头接耳声中,只有蔡和森静静地坐着,考虑着,仿佛是拿定了什么主意,他突然站起身来,找到主席台一侧的方维夏,说: “方老师。我有个请求,不知道行不行……”\n方维夏看着眼前这个优秀的学生,一时间不知道说什么好,只是拍拍他的肩膀,点点头。然后,他走向主席台,宣布:“ 各位同学,在投票开始以前,先跟大家说明一个情况,本科第六班蔡和森同学主动要求担任本次学生互选活动的计票人,为保证选举的公正,他本人提出,不再参加这次选举,也不接受大家对他的投票。如果有人已经填好了投给蔡和森同学的票,可以到台前来领取新的空白选票,另填其他同学。好,下面,开始投票。”\n台下的安静一下被打乱了,这个情况显然大出同学们的意外。\n萧三看看手里一张填好了蔡和森名字的选票:“这个蔡和森,怎么突然不参加了?”\n“就是嘛,昨天又不说,害得我也白填了。”李维汉也是满脸的不高兴。\n他们俩与不少填好蔡和森的票的同学纷纷揉掉了那张选票。人群中,毛泽东却坐着没动,带着责备,他的目光投向了台侧的蔡和森。蔡和森避开了他的目光,走上了主席台。毛泽东打开了自己填好的选票,他的两张票,写的正是他自己和蔡和森。\n“敦品:陈绍休,周世钊,邹彝鼎,毛泽东……”\n方维夏的唱票声中,蔡和森在小黑板上一笔笔计着票数。\n“文学:李维汉,毛泽东,萧植蕃,罗学瓒……”\n小黑板上,当选的同学名字渐多,毛泽东的名字不仅出现在已经唱到的每一项目下,而且几乎都是得票最高的。\n“言语:毛泽东。”方维夏唱过此项唯一一张选票,又打开下一个票箱,“胆识:毛泽东。”\n方维夏又打开一个票箱:“综合,蔡和森……”他愣住了。\n蔡和森也不由得一愣,转过身来看。选票上是毛泽东特有的飞扬张狂的字迹。台下,毛泽东的目光平静而坚定,直视着蔡和森投来的目光。目光交会间,蔡和森已然明白了这一票的来历。他轻轻抽掉了这张票,对方维夏:“这张是误投,方老师,念下面的吧。”\n互选很快结束了,方维夏回教务室,正打算向校长和各位老师报告结果。 “等一下,方先生,等一下宣布。”费尔廉打断了他,跑到杨昌济桌前,伸出手,带杨昌济将那张白纸放到了他手上,他这才说,“方先生,请宣布吧。”\n“得票总数,第三名,邹彝鼎;第二名,周世钊;第一名,毛泽东。”\n孔昭绶惊愕地:“那蔡和森呢?”\n“蔡和森主动要求担任计票,所以退出了选举。”\n打开白纸,费尔廉惊讶得嘴都张大了。袁吉六凑上前,念道:“毛泽东得票第一,蔡和森一票不得!……哎呀,板仓先生,神了您啊!”\n费尔廉连连摇头:“太神奇了,不可思议,简直不可思议!”\n众先生正在纷纷叹服,方维夏却道:“不,我虽然宣布了不用投蔡和森的票,但还是有人投了他一票。”\n“哦?”几个老师都愣住了。\n杨昌济只思考了几秒钟,就拍拍脑袋:“我明白了,是润之,一定是润之投的。以毛泽东的个性,但凡遇到这种胜负之争,他一定当仁不让;而蔡和森呢,却绝不会跟润之争这个胜负,所以一定会想一个既不损害这次学生互选的公正性,又能回避与润之一争高下的办法来退出选举,这些,我都算到了。可我却疏忽了一点,润之这个人,才不会理会什么投票的规则呢。他觉得谁优秀,这一票他就一定投给谁,蔡和森退不退出,都不可能改变他的想法。”\n一片敬佩的静默中,孔昭绶拍了拍杨昌济的肩:“知人之明,莫过于昌济兄啊。”\n静了好几秒钟,杨昌济来到窗前,推开了窗子。望着窗外飘扬的校旗,他仿佛是在回答孔昭绶,又更像是自言自语:“这两个学生,我必须了解。因为中国的未来,既不能缺少蔡和森的睿智,也不能缺少毛泽东的天才。”\n三 # 选举结束后,蔡和森约警予到湘江边散步。话题还是从那天的读书活动开始的,其实,那天并不是只有萧子升一个人留意到斯咏没来,只不过大家要么没多想,认为有事情耽误很正常;要么隐约知道些什么,却不好多问。这个时候,没有其他的人在场,蔡和森这才问起警予。\n“你是关心毛泽东还是关心斯咏呀?”警予明知故问,她怎么会不知道蔡和森是为了毛泽东才问起这件事情的呢。\n警予告诉蔡和森,别看斯咏有时候疯疯癫癫,有时候诗情画意,好像比谁都浪漫,其实,她骨子里是个特别传统的女孩。自从她和王子鹏的关系被大家知道以后,她就一直不肯去一师范,一直不肯见毛泽东,为什么?因为她害怕。可惜啊,她跟毛泽东相处那么久了,但对他的了解,还不如小丫头开慧。开慧都知道在毛泽东眼里,什么规矩、准则、三纲五常,根本就不值一提,这世上还有什么传统是他不敢蔑视的,更何况父母之命之类老掉牙的玩意儿?斯咏和毛泽东之间的问题,并不在于斯咏有没有订娃娃亲,而在于他们的个性是不是适合。毛泽东吸引斯咏的,是豪迈,是奔放,是那种一往无前,无所顾忌的个性,是蔑视一切,开创一切的勇气和信心。可这一切的背后是什么?是斯咏梦想的浪漫和温情吗?是斯咏渴望的平安和幸福吗?不,他所有的,是抱负,是理想,而不是只属于两个人的温情和浪漫。也就是说,斯咏所希望的,和毛泽东所追求的,其实并不是一回事。警予最后说:“我担心的,是斯咏一直陷在某种幻觉里,在用梦编织一种不切实际的期待。”\n蔡和森听着警予的分析,认真听着,一直没有打断她,直到警予把所有的话都说完了,才问:“你把你的这些想法都告诉她了吗?不知道她以后会怎么和润之相处。”\n“人家会怎么想呢,我就不知道了,不过,”警予停下脚步,侧过身,与蔡和森对面站着,说,“我现在是越来越觉得,你应该改个名字,叫毛和森算了。”\n蔡和森一愣,问道:“怎么,我放弃选举,你不高兴了?”\n“得了,我有那么小心眼吗?其实,我早应该想到你不会去争这个胜负的。如果你像润之那么好胜心切,那也不是蔡和森了。”\n“你也以为我是在让着润之?”\n“难道不是吗?凭你在同学里头的威信,真要选,我就不信选不过他。”\n蔡和森站住了,望着远山苍翠,湘江浩荡,他深深吸了一口气,说:“警予,你猜错了,我根本没想过要让着谁,我只是觉得,那些选票不配由我来得。”\n“你不配?”\n“对,我不配,因为有润之。不错,润之并不是完美无缺,他的能力,他的才华,更不是生来就超人一等,他甚至有很多这样那样的缺点和毛病,可这一切都不重要,重要的是,他有无比的热情,无比的斗志,有我和其他人都不具备的那种蔑视一切挫折、挑战一切困难的勇气和决心。他的身上,永远散发出那么强烈、让人无法抗拒的魅力,那是一种个性,一种什么也压不服、什么也挡不住的火一般的个性,跟他相处越久,我就越深刻地感到,他是那样的震撼人心,那么让我由衷地佩服。我相信,如果我们这些人当中有人能成就一番大事业的话,这个人绝不会是别人,一定是润之。”\n蔡和森说话的时候,表情异常严肃地望着远处的高天流云。警予顺着他的目光看出去,江水滔滔、远山横亘,似乎天地山川都正在回应着蔡和森的那番话……\n"},{"id":129,"href":"/zh/docs/culture/%E6%81%B0%E5%90%8C%E5%AD%A6%E5%B0%91%E5%B9%B4/%E7%AC%AC26%E7%AB%A0-%E7%AC%AC29%E7%AB%A0/","title":"第26章-第29章","section":"恰同学少年","content":" 第二十六章 汗漫九垓 # 欲从天下万物而学之,正当汗漫九垓,\n历游四宇,读无字之大书,方得真谛!\n览山川之胜,养大道于胸,以游为学。\n一 # 1917年的暑假到了,萧三回了老家,子升一个人待在楚怡小学自己的房间里正看书,毛泽东却拿着一张报纸进了门。\n他把那张《民报》摆在子升面前,手指敲打着一则报道的标题:“《两学生徒步漫游中国》,看看人家,一分钱不带,一双光脚杆,走遍全国,一直走到了西藏边境的打箭炉,厉害吧?”\n子升读着报道,不禁露出了佩服之色:“还真是的啊!嗯,值得佩服。”\n“莫光只顾得佩服喽,见贤要思齐嘛!人家走得,我们为什么走不得?当年太史公不是周游名山大川,遍访野叟隐老,哪来的煌煌《史记》?所以,还是顾炎武讲得对,欲从天下万物而学之,正当汗漫九垓,历游四宇,读无字之大书,方得真谛!”\n子升不禁点了点头:“嗯,览山川之胜,养大道于胸,以游为学,是个长见识的好办法。”\n“所以啊,趁着放暑假,我们也出去游,好不好?”\n“一个暑假,走不了那么远吧?”\n“远的去不了,我们去近的,中国游不完,我们游湖南嘛。我跟你讲啊,我都想好了,要学,我们就学个作古正经,跟他们一样,不准带一分钱,凭自己的本事,走多远算多远。”\n“那不成了讨饭当叫花子?”\n“讨饭怎么了?一不偷二不抢,讨得到也是你的本事,锻炼生存能力嘛。话又讲回来,你我总还读过几本书,写得几个字,两个读书人,未必还真的饿死在外面?那还不如一头撞死算了。”\n子升犹豫着。\n毛泽东激将他:“怎么,不敢去啊?”\n“游就游!谁怕谁啊?我就不信我会比你先饿死。干脆,叫上蔡和森,三个一起去。”\n“老蔡就算了,人家就靠暑假做事赚点钱,莫害得人家下个学期过不下去。你要是拿定了主意,我们明天就出发,好不好?”\n“好,我就陪你去当这回叫花子,一起走遍湖南!”\n第二天,俩人收拾停当准备开拔了,临出门才发现:准备还是不充分,子升与往常一样,一身笔挺的长衫,脚下布鞋整洁,上过油的头发一丝不苟,手里是结实的大皮箱;毛泽东却一身旧得不能再旧、还打了补丁的白色短布褂,一个瘪瘪的布包袱挑在油纸伞柄上,脚上穿着一双草鞋。\n毛泽东看着子升,大笑:“哈,你这是去走亲戚啊,还是去拜岳父老子?”\n子升看看毛泽东,再看看自己,也笑了:的确,自己这哪是去“叫花讨饭”呀,赶紧重新换上一身旧短布褂和草鞋,找了个师傅把头发理成极短的平头,背着油纸伞和简单的蓝布包袱。等他打扮得和毛泽东一样时,两人这才开始他们的正式行程。\n到了江边,正有船要离岸,毛泽东一拉子升:“走。上船喽,不坐船怎么过江?你又不肯游泳。”\n子升看了看船,说:“这是私人的渡船,要钱的,还是多走几里路,到那边搭免费的官渡吧。”\n“搭免费的船算什么本事?我们出来干什么,锻炼生存能力嘛,当然要舍易求难,怎么难搞就怎么搞。他的船要钱,我偏要不花钱去坐坐,那才是叫花子的搞法嘛。”看看子升还在犹豫,毛泽东拉起子升就走,“走喽,你还怕他把你丢到江里去啊?”\n江水如蓝,船篙轻点,渡船平稳地行驶在江心。“口当啷啷”,乘客们依次将铜板投进了收钱的小工手中的那面破铜锣里。挤在二十来个乘客当中,子升被越来越近的收钱声逼得忐忑不安。身边的毛泽东却大大咧咧,昂头打量着浩浩江水。铜锣伸到了二人面前,帮工等了一下,没见二人有反应:“哎,交钱啦!”\n子升瞄了毛泽东一眼,毛泽东仰着脸看着帮工,说:“对不起,没带钱。”\n“没带钱?”帮工眼睛瞪了起来,“没钱你坐什么船?”\n毛泽东笑嘻嘻地说:“那我坐都坐了,怎么办呢?”\n撑船的船夫火了:“嗨,没钱坐船你还坐出道理来了?我跟你讲,一人两个铜板,赶紧交钱!”\n毛泽东继续笑嘻嘻:“老板,我们两个是叫花子,半个铜板都没有,你就行个好,送我们过去算了嘛。”\n“我凭什么白送你们?没钱啊,”船夫看了看他们身上,说“没钱用雨伞顶!”\n“你就想得好啦,一把雨伞四毛钱,你船钱才两分,用雨伞顶,你也想得出!”\n子升有些不好意思了,劝毛泽东:“算了润之,要不,就给他这把雨伞?”\n“开什么玩笑?下雨怎么办,你不打伞啊?你愿意给,我还不愿意亏这个本呢!”\n船夫一听毛泽东这样说,脾气一下子上来了:“哎呀,你这个家伙是存心坐我的霸王船啊?!小五子,把船撑回去,让他们两个下去!”\n他真的调转船篙,要把船往回撑。船上的其他乘客顿时急了,纷纷嚷了起来:“哎哎哎,怎么回事,怎么往回开?我们怎么办?不行不行,我还有急事。”\n毛泽东乘机说:“看到了吧看到了吧?这里还有一船人,你不顾我们也要顾大家嘛。再说了,这船都走了一半了,你往回撑,湘江上又不是只你一条船,那边的生意不都让其他的船抢走了?为了个几文钱,划不来喽!”\n子升也帮着腔:“是啊,老板,你就当做回好事吧!”\n毛泽东:“你要是还想不通,我来帮你撑船,就当顶我们两个的船钱,这总可以了吧?”\n看看满船的人,再看看身后远远的江岸,船夫没辙了:“碰上你们这种人,算我倒霉!”\n二 # 下了船,走在乡间的小路上,回味着刚才坐船的经过,毛泽东开心的笑声把林间的小鸟都吓得四处乱飞。\n子升白了他一眼:“坐人家的霸王船,你还觉得蛮光彩啊?”\n“我们是叫花子,有什么光彩不光彩?再说了,他的船反正是过江,多我们两个不多,少我们两个不少,总共四文钱,他还发得财到?”\n“我看啊,你不是舍不得出钱,你是天生喜欢跟人对着干。”\n“这句话你还真讲对了。他不是犟吗?我比他还犟,看谁犟得过谁?人嘛,什么事都顺着来,那还活个什么劲?哎,这方面,上个礼拜我还在日记里头专门总结了三句话,叫作‘与天奋斗,其乐无穷;与地奋斗,其乐无穷;与人奋斗,其乐无穷’。”\n山野宁静,树影斑驳,毛泽东的声音在山冲里响起一阵回声。\n子升当然不赞成毛泽东这样说,反驳道:“你这种话不对!人,应该是一个世界和谐的组成部分,人与自然,应该和谐,人与人,更应该以和谐互补为目标,君子周而不比嘛,怎么能以互斗为乐呢?”\n“达尔文怎么说的?优胜劣汰!你说的清静无为,躲到山里当道士可以,在这个世上,它就行不通!”\n“反正我相信这个世界只有和谐才能发展,那些不和谐的互斗与纷争,终归没有前途。”\n“事实胜于雄辩,事实证明我斗赢了嘛,你还有什么话说?”\n“好好好,我不跟你争。”\n这天傍晚,两人便露宿江边。江水潺潺,一轮圆月亮如银盘,镶嵌在暗蓝暗蓝的夜空。月光映照下,宁静的夜空是那样纯净无瑕,那样深邃无边,仿佛要将一切人、一切事、一切烦忧融化在其中……\n“前不见古人,后不见来者。念天地之悠悠,独怆然而涕下。”子升枕着双手,躺在毛泽东身边,遥对夜空,吟起了陈子昂的诗。\n毛泽东最不耐烦子升来这一手,抗议道:“莫动不动就涕下涕下喽,清风明月,水秀山青,哪那么多眼泪鼻涕?”\n“那你想起什么?”\n“我想起啊?‘明月几时有,把酒问青天。不知天上宫阙,今夕是何年?’”\n“怎么,想当神仙了?”\n“神仙是修不成器了,不过,对着这么好的月亮,还真是想飞上去看看。看不到嫦娥,也可以看看吴刚砍桂花树嘛!”\n“那我宁愿看嫦娥。”子升突然转过了身子,撑着脑袋,问毛泽东,“哎,你说,我们在这儿看月亮,有没有人也在看着月亮想起我们?”\n毛泽东会心一笑:“谁会吃饱了没事,想你想我?不过,也难说,杨老师肯定会想我们的,我们到了前面镇子,给他寄封信吧?”\n三 # 他们的信很快就到了正在板仓老家过暑假的杨昌济的手上。油灯下,向仲熙正坐在杨昌济身边,与他看着一封信。开慧趴在一旁,急不可待问道:“爸,毛大哥信上都说了些什么?”\n“也没什么,说了一下路上大概的经历,再就是问候大家。”\n“有没有提到我?”\n“有哇,最后一句:代问师母及和森、斯咏、警予、子暲、叔衡、蔡畅、开慧小妹好。”\n“就一个名字啊?”\n看到女儿嘟起了小嘴,向仲熙开导她说:“总共一页纸,你还想他写多少?”\n“那萧大哥呢?”开慧想,毛大哥不记得我,萧大哥该记得吧?\n“子升倒是来了封长信,不过信里一大半内容是问候斯咏的,我已经叫人转给斯咏了。”\n爸爸的回答,让小开慧更失望:“一个个都不记得我,没劲!”\n开慧没有收到问候失望,斯咏收到了问候也一样很失望。在精致的台灯下,斯咏轻轻放下了子升的长信,目光却移到桌上那本《伦理学原理》上。她打开的扉页,看看是那句“嘤其鸣矣,求其友声”,叹息一声,轻轻把书合上了,又抬头望着窗外的月光\n在这样的夜晚,照耀着毛泽东的,不仅仅有月光,还有如空气一样存在着却看不见的母爱。在韶山冲毛家的厢房里,一盏调得小小的、微弱的油灯光闪动着,门口,半就着油灯光,半就着月光,文七妹正在纳着一只布鞋。她身边的小竹椅上,摆着已经做好了的两双崭新的布鞋。\n毛贻昌来到门口,在门槛上磕去了旱烟锅里的烟灰。拿起崭新的布鞋打量了一眼,他把布鞋扔回到竹椅上,想要关心妻子,但说出口的语言却是生硬的:“半晚三更,觉不睡觉,你怕是没累得?莫做哒。”\n文七妹头没抬,手没停,嘴里却答应着:“好了,就完了。”\n毛贻昌在她的身边蹲了下来,没头没尾地说:“一个暑假,人影子都没看见,做做做,做给鬼穿?”说是这么说,他却从口袋里摸出了半包皱巴巴的香烟,放在鼻子下闻——毛泽东进一师后第一次回家过年给他买的烟,他居然还没抽完!\n看到老婆微微地笑着看着自己,毛贻昌觉得有点尴尬,把烟往口袋里一塞,装起了一锅旱烟。看到老婆又埋头去纳鞋,他想了想,含着烟嘴,把油灯调亮了些。\n四 # 炽烈的正午骄阳下,毛泽东与子升到了安化县境,来拜访安化县劝学所所长、学者夏默安。\n安化县劝学所坐落在一片青翠宁静的山坡旁。门人进去通报了,毛泽东和萧子升扎在门外,看里面藤萝蔓绕,绿杨依依。院子一旁,池塘青青,荷叶田里,夏季盛开的荷花中,蛙声句句,更衬托出这书香之地的恬静清雅。\n正在看书的夏默安一身雪白的绸衫,戴着眼镜,摇着一把折扇,他六十来岁,表情古板,是个性格执拗沉闷的老先生。听了门人的通传,他继续看着书,头也不抬地说:“不见。”\n大门“咣口当”关上了。毛泽东与子升面面相觑。\n子升叹了口气:“唉,早听说夏老先生的大名,还想着当面求教一番,没想到却是闭门不纳啊!”\n“人家饱学先生,那么大的名气,你讲两个毛头学生来拜见,也难怪他没兴趣。”\n“也是啊,只好打道回府了。”\n“打道回府?开什么玩笑?来都来了,他不见就不见啊?”毛泽东沉吟了一会,说,“他不见,是不晓得我们有没有真本事,值不值得见,我们写个帖子递进去,让他也看看,我们不是个草包。”\n很快,两个人写的一首诗送进了劝学所内,送信的年轻门人给夏默安读了出来:“翻山渡水之名郡,竹杖草履谒学尊……”\n夏默安的头突然抬起来了,手一伸:“拿来我看。”\n诗递到了他的手上。纸上,子升漂亮的字体,首先已让夏默安眉心微微一挑,他继续读:“途见白云如晶海,沾衣晨露浸饿身。”\n他不禁轻轻吸了一口气,说:“请他们进来。”\n进了门,毛泽东与子升正襟危坐,有些局促地看着对面的夏默安。夏默安还是那样面无表情,眼睛盯着手里的书:“萧子升,毛泽东?”\n“是。素仰夏老先生大名,所以特来拜见。老先生的《默安诗》深得唐宋大家之意,遣词凝练,立意深远,《中华六族同胞考说》更是洋洋洒洒,考证古今,学生在长沙,就早已心向往之……”\n夏默安根本没理子升的赞誉,随口打断:“省城呆得好好的,为何出来游学啊?”\n讲了半截话就被打断了,子升被弄得一噎。\n毛泽东不像子升那样文绉绉的,他大声回答:“游学即求学。”\n“哦?有书不读,穷乡僻壤,山泽草野,有何可求?”\n毛泽东依然大声回答:“天下事,事皆有理,尽信书,不如无书。有字之书固然当读,然书中不过死道理,世事洞明皆学问。故学生二人,欲从山泽草野,世间百态中,读无字之大书,求无字之真理。”\n夏默安的头终于抬了起来,脸上,也现出了笑容:“上茶。”\n两杯清茶摆在了毛泽东与子升面前。\n窗外,绿杨轻拂,鸟鸣声声。\n夏默安收回了望向窗外的目光,突然提起笔来:“老夫有一联,请二位指教。”\n他挥笔写下,将上联移向毛萧二人这边,上联是“绿杨枝上鸟声声,春到也,春去也。”\n子升不禁与毛泽东交换了一个商量的目光。\n窗外,蛙声阵阵,毛泽东的高个子使他恰好能将一池碧水,夏日荷花,一览无余。\n“晚生斗胆一试。”毛泽东拿起笔,在纸的另一半上写了下去。\n一副对联顷刻已成,呈现在夏默安面前。\n“清水池中蛙句句,为公乎,为私乎?”夏默安读出下联,黯然半晌。\n移目窗外,鸟鸣蛙声,相映成趣,这上下联与眼前景象,当嵌合得天衣无缝,而下联的立意之深,也显然远超上联。\n他突然转头向外,提高了嗓门:“准备晚膳,收拾客房!”\n转向毛萧二人,一揖手,脸上已满是敬意:“两位小学弟,如蒙不弃,今晚便留宿寒舍,与默安畅论古今,对谈学问,谈他个痛快,意下如何啊?”\n五 # 拜别了夏默安,第二天,毛泽东与萧子升进了安化县城,这县城虽不大,却是街道古朴,店铺毗接,一派质朴的祥和。虽是一路同行,子升却仍然保持着清洁整齐,远不似身边的毛泽东,衣服皱巴巴的,脚下沾着泥点。\n“嗯!”毛泽东使劲地吸了吸鼻子,“好香啊,红烧肉,肯定是红烧肉!”\n子升顺着他的目光望去,对面恰好是一家饭馆,挂着“醉香楼”的招牌。\n子升问:“嘴馋了?”\n“二十几天嘴巴就没沾过油,未必你不馋?”\n“馋有什么用?还不是白馋?”\n毛泽东咽了一口唾液:“也是啊,再大方,也不会有人给叫花子打发红烧肉啊!哎呀,越闻越流口水,走!离它远点!”\n两人正往前走,只听“乒乒乓乓”,一家新开的店铺前,一串鞭炮正在热烈地炸响,门上是崭新的招牌,两旁是崭新的对联,店老板打躬作揖,正在接待到贺的街坊。\n“来去茶馆?”路过的毛泽东也看着热闹,“这是新开张啊。”\n子升眉头皱了起来:“哎,你看那副对联,平仄不对啊。”\n毛泽东一看,对联写的是“有茶有酒,香飘满楼”,不禁头一摇:“何止平仄?根本不是那回事嘛。”\n这话却让店老板听见了,他一拱手:“两位,我这副对联对得不好吗?”\n毛泽东:“你这个,不是对得不好,只怕连对联都算不上。”\n店老板:“哎哟,你看,我也没读过什么书,这副联是请别人写的,见笑了,两位既然是行家,就请赐一副联怎么样?”\n毛泽东一拍巴掌:“你算找对人了,我这位朋友对对联的本事,长沙城里都是有名的。”\n店老板一听,越发客气起来:“原来是省城来的秀才啊?那更要请你们留个墨宝了。”他一个劲地向子升拱着手,“这位先生,帮个忙帮个忙。”\n子升一时盛情难却,也便拿出了笔墨,店老板也赶紧裁来了红纸,子升仰头看看“来去茶馆”的招牌,略一沉吟,落下笔去,一副对联一挥而就:\n为名忙,为利忙,忙里偷闲,喝杯茶去\n劳心苦,劳力苦,苦中作乐,拿壶酒来\n旁边的观众们一片啧啧称奇声,就算看不出意思好坏,子升的一手字也已令大家叹为观止。\n店老板双手捧上了一个红包:“这位先生,多谢多谢,谢谢先生了。”\n子升赶紧推让:“这怎么好意思?”\n店老板:“些许心意,权作润笔,不成敬意,不成敬意。”\n子升还想推辞,毛泽东伸手把红包接了过来:“老板的心意,我们也莫讲客气了。”\n红包里倒出的,居然是两块光洋!站在街拐角,毛泽东和萧子升两个人你看看我,我看看你,再看看满街毗接不断的各种店铺,眼睛都亮了。\n两人当下就用这两块大洋买来红纸,租用了一个 “代写书信”的字摊,抄来一些比较像样的店铺的名称,开始“做生意”了,对联由子升写,讨钱的事情由毛泽东去做。\n他们的“生意”果然还不错,子升挥笔如云烟,毛泽东则一家家店铺跑去,一个下午,眼看着满街渐渐都换上了子升写的新对联,对联摊子前,看热闹的路人也越挤越多,子升的构思和书法成了当街最精彩的表演。\n便在这时,只听得一阵吆喝:“让开让开,都让开!”一个剽悍的家仆扒开了围观的人群挤了进来。\n子升停住了笔,抬起头,看到人群外停着一乘轿子,一个六七十岁、一身长袍马褂,翘着稀疏的山羊胡子的干瘪老头,正昂着脑袋大模大样地走了进来。\n这人显然来头不小,围观的人们都赶紧退让,几个士绅忙不迭地点头哈腰:“丁老爷……丁老爷好……”\n老头眼睛斜也没斜那些讨好打招呼的人一下,径自来到摊前,斜睨着写好的两副对联。看着看着,他昂得高高的脑袋突然低下了,神情一下子专注起来,拿起了一副对联,架起挂在胸前的眼镜,仔仔细细,从上看到下,再从下看到上,仿佛是有些不敢相信一般,目光转向了子升。\n子升问:“这位老先生,这对联有什么不妥吗?”\n老头没答他的话,却冒出一句:“你多大了?”\n“晚辈今年22岁。”\n“22岁?”老头又打量了子升一眼,问,“从哪里来?”\n“长沙。”\n“萧菩萨,写完了没有?”毛泽东风风火火,一步冲进人群,“那些我都送完了,收获不小啊!”\n他“哗啦”一声,把一大堆光洋、铜元堆在了桌上,忙不迭地收拾着剩下的对联:“剩下这两副赶紧送掉,我们好好吃一顿去!哎,不好意思啊!”\n他顺手把老头拿在手里的那半副联扯了过来。\n那名悍仆登时就要发作,老头却用目光制止住了仆人,他皱着眉头,打量了毛泽东一眼:一身皱巴巴,草鞋、裤脚上还沾着泥点的毛泽东,与文雅洁净的子升实在不像一路人。\n“在这儿等我啊。”毛泽东又急匆匆地冲出了人群。\n子升不禁有些不好意思:“老先生,我这位同学性子急,失礼了。”\n老头:“这是你同学?”\n子升:“是,我们一道游学,路经贵县,行囊拮据,故出此下策,让老先生见笑了。”\n老头瞄了桌上那堆钱一眼,再看看桌上笔墨与子升白净秀气的手,摇了摇头:“可惜了。”\n他大咧咧地出了人群。\n子升全然摸不着头脑:“这位是谁呀?”\n一名士绅对他说:“他你都不知道?丁德庵,我们安化有名的丁老爷,两榜进士,做过翰林的。”\n子升愣住了。直到毛泽东回来,他还没从刚才的惊讶中缓过来:“想不到是位进士翰林,这回我们真是班门弄斧了。”\n毛泽东只顾数着钱:“你管他翰林不翰林?他又不请你去做客。再说了,你那手字,未必会比翰林差。走走走,红烧肉兑现。”\n两人刚刚起身,身后突然传来了一个声音:“这位先生,请留步。”\n两个人回头一看,刚才那名跟着老头的仆人正恭恭敬敬地向子升拱着手,后面还跟着一乘小轿:“我家老爷看了先生的字,对先生的书法十分佩服,专程叫我来请先生过府做客,谈书论道,请先生务必赏光。”\n子升不禁有些惊喜:“丁老爷客气了,晚辈怎么敢当?”\n“先生就不必客气了,我家老爷最喜欢的,就是有本事的读书人。请先生赏个脸吧。”\n子升动心了:“润之,不如咱们去一趟?”\n不等毛泽东开口,那个仆人先抢着:“对不起,我家老爷只吩咐了请先生,没提别的人。”\n“这样啊……”子升不禁有些为难。\n毛泽东倒是无所谓:“哎呀,人家请你你就去嘛,反正我又不想见什么翰林。我吃我的红烧肉,饭馆里等你啊。”\n他径直向醉香楼走去。\n六 # 仆人将子升引入一扇朱漆大门,门上铜钉闪亮,门外镇府石狮威风凛凛,家丁排列,气势逼人。\n古色古香的丁府书房里,两壁皆书,精致的文房四宝,排列在檀木书桌上。正南墙上,挂着一个清朝官员的画像,提着“故中丞丁公树卿老大人遗像”,两旁挂着“诗礼传家”的中堂、“仁义乡里,忠烈遗泽”的对联,和“林隐乡居图”等等字画条幅,芝兰盆景,点缀其间,处处透着显赫的家世和归隐农田的文人雅致。\n“老先生原来是为国尽忠的丁中丞大人后人?”子升不由肃然起敬,“晚生真是失敬了。”\n“哪里哪里。”提到家世,丁德庵显然颇为自得,“丁某不肖,愧对先祖遗泽,倒是这诗礼传家的祖训,未敢轻忘,但求守几亩薄田,温几卷旧书,处江湖之远而独善其身而已。”\n他呷了一口茶,慢条斯理地说:“虽说隐居林下,老夫倒是最喜欢跟肚子里有真才的读书人交朋友,今天有幸一睹萧老弟的书法,颇有汉晋古雅风范,令人耳目一新啊!”\n“雕虫小技,贻笑方家了。”\n丁德庵却话锋一转:“只不过……”\n子升赶紧站起身:“老先生指教!”\n丁德庵挥手让他坐下:“以如此书法,竟当街卖字,不免有辱斯文了吧?”\n子升道:“晚辈倒是记得,昔时板桥先生亦曾将字画明码标价:大幅六两,中幅四两,小幅二两,扇子斗方五钱。可谓书生亦须作稻粱之谋,子升愚钝,困于行旅,只好斗胆学样而已。”\n丁德庵吃了一惊,倒笑了起来:“如此倒是老夫拘泥了。哎,萧老弟书倒是读得很杂呀,连这些野趣杂典也记得,不容易。”\n“当着老翰林之面,晚辈岂敢谈读书?”\n“哎,要谈要谈,读书人不谈读书,难道还谈种田挑粪那些下贱之事么?对了,老夫近日,正在重读老庄二经,不知萧老弟对这两本经熟吗?”\n子升道:“也略读过。”\n“以你之见,此二经,历代注解,谁的最好?”\n“晚辈浅见,注道德经,无过于王弼,注南华经,无过于郭象。”\n丁德庵满意地点了点头,对子升他显然又高看一眼了。\n“方才看老弟的对联,构思奇妙,老夫平时也好对句,正好拟了几副上联,还请指教一二如何?”丁德庵说着,起身踱了两步,手指室内花草盆景:“我这上联曰:室有余香谢草郑兰宝桂树。”\n子升几乎是张口就来:“晚辈对:身无长物唐诗晋字汉文章。”\n丁德庵不由得点头,他略一思索:“这句难一点:劝君更饮一杯酒。”\n子升思索了一阵:“晚辈对:与尔同销万古愁。”\n“嗯,以李白诗对王维诗,上下嵌合,天衣无缝,好,好,好!”丁德庵也颇有了知音之感,情绪上来了,“老夫还有一联,是三十年前翰林院的同仁出给我的,当时满朝翰林无人能对,一时而称绝对,萧老弟大才,今日老夫献丑,请教方家了。”他来到书桌前,铺纸提笔写下了上联,“出题之人,原是游戏文字,故意要弄出副绝对来,老弟若是为难,也不必放在心上。”\n“‘近世进士尽是近视’,四个词读音全同,词性各异,还是个全仄联?”子升思索着,这副联显然让他一时无从下手,沉吟中,他无意间又看见墙上那幅中丞遗像,突然灵机一动:“晚辈倒是可以斗胆一试,不过这下联要从老先生的先祖大人那儿来。”\n丁德庵扶着眼镜,读出子升的下联:“‘忠诚中丞终成忠臣’?对得好,对得好,对得太好了!”他猛然向子升一揖手,“萧先生大才,德庵佩服!”\n七 # “润之,”辞别了丁府,子升兴冲冲进了醉香楼,看见毛泽东,他一脸的兴奋莫名,“太可惜了,你没去真是太可惜了!这位丁翰林真是位雅人,学识过人,渊博风雅,不见一面真是可惜了。”他拉过长凳坐下,将一封光洋往毛泽东面前一放:“你看看,这是人家奉送的仪程,一出手,就是二十块光洋,大方吧?”见毛泽东只是“哼”了一声,没有接腔,子升不禁愣住了,这才发现气氛不对,毛泽东的身边,还站着互相扶持着默默抽泣的父女二人。\n看着子升不解的眼光,毛泽东义愤地告诉子升:“那位丁德庵的田,不管你丰年灾年,那是一粒租子都不能少。这几年,年景不好,这位老爹欠了他十担谷的租还不上,利滚利,驴打滚,就算成了一百多担的阎王债。这位老爹进城来求他姓丁的宽限宽限,他却看上了老爹的女儿芝妹子,逼他拿芝妹子抵债,芝妹子还不满十四岁,居然要去给他七十岁的人做第十三房,他也下得了这个手啊!”\n“爹……”芝妹子扑进父亲怀里,父女二人抱头痛哭。\n子升一脸的难以置信:“怎么会是这样?”\n酒楼的老板叹了口气,证实道:“你们两位是外乡人,不晓得底细,这位丁老爷,那是我们安化最大的一霸,家里的田,数都数不清,光佃户都有好几千。这种事算得什么?他家里逼租逼债,哪年不要逼出几条人命哦?”\n一位食客道:“丁德庵丁德庵,安化人人都喊他‘丁刮干’,不把你刮得干干净净,他从来就不会松手的。”\n其他围观的人或是面露不忍,或是默默点头,丁德庵的恶劣,显然为大家所公认。\n子升简直不敢相信:“满口礼义诗书,道德文章,居然……居然为人如此卑劣!”\n“他不在脑袋上贴个仁义道德,还贴个我是坏蛋啊?我告诉你,越是这种道貌岸然的读书人,越不是个东西!”毛泽东转向那父女俩,“我说,这个租,你们也不用交了,田是你种的,凭什么给他交粮?”\n老农却直摇头:“不行啊,丁老爷养了家丁,家里又有人做官,欠他的债不还,一家人活活打死的都有啊!”\n毛泽东火了:“他打你?你不晓得打他?他再养家丁,未必比你们几千佃户还多?你们几千人,一人一根扁担,冲到他家去,吃他的大户,你看他还耍什么威风?”\n子升急了:“润之!你这不是鼓动人家聚众闹事吗?”\n“聚众闹事怎么了?跟这种土豪劣绅,就是不能客气,大家一条心,谁怕谁呢!”\n“可你这不是搞暴动吗?真要惊动了上面,吃亏的还不是这些农民?”\n“那你说怎么办?”\n子升略一沉吟,起身,向围观的众人抱了个拳:“各位先生,这对父女的遭遇,大家也都看在眼里。我这儿呢,倒是有个主意,希望能帮他们一把,只是要有劳各位一起帮个忙,不知大家肯不肯?”\n八 # 丁府书房,丁德庵正在欣赏子升写的那副对联,仆人一把推开了房门:“老爷,大喜了!”\n丁德庵边扣马褂最上头一颗扣子,边匆匆迈出大门。门前的情景让他愣住了:黑压压一片都是县城里的商号老板和街坊们,簇拥着正中的一块匾,五六个吹鼓手还在起劲地吹吹打打。\n子升上前一步,手一抬,鞭炮、鼓乐齐止。\n子升朗声:“安化各界商民代表,为感本县世家丁氏诗礼教化,表率乡里,特向丁老夫子德庵先生献匾。”\n丁德庵一时乐得合不拢嘴:“哎哟哟哟……这怎么敢当……怎么敢当?”\n子升依旧大着嗓门:“老先生不必过谦,丁氏一门,既承忠烈遗泽,又秉仁义家风,道德廉耻,无所不备,高风亮节,泽被闾阎。晚辈受安化乡民之托,特书此匾,唯求略表全县乡亲敬慕仰仗之情于万一也。”\n他伸手掀去匾上蒙的红绸,露出了“造福桑梓”四个大字,与此同时,锣鼓、唢呐各色乐器同时大作。\n喜出望外之下,丁德庵只顾一个劲地抱拳拱手:“哎哟哟,这个这个……德庵何德何能,何德何能啊……”\n就在他伸手要接匾之际,人群中的毛泽东悄悄向旁边一让,一推躲在身后的那父女二人,父女二人一头扑了出来,扑通跪在丁德庵脚下,拼命地磕头:“丁老爷,您行行好,我求求你了,行行好啊,丁老爷……”\n丁德庵措手不及,吓得倒退出两步,两边的家丁一看不对,当场就要冲上来,毛泽东却抢先扶住了那老农,扯着嗓子:“哟,这位老伯,您这是干什么?有话慢慢说,丁老爷可是大善人,万事都有他老人家做主。”\n子升也上前来:“对对对,有丁老爷在,不管什么难处,您放心大胆地说。”\n看看四周人群,丁德庵赶紧用眼睛瞪住了家丁们。\n那老农抬头欲诉,看见丁德庵和身后气势汹汹的家丁,吓得又把头低下了,他女儿急了,头一扬:“我、我们是丁老爷家的佃户,年景不好,欠了老爷的租还不起,老爷他、他……”\n毛泽东:“老爷他怎么了?”\n女孩:“老爷……我爹说丁老爷要我去做小。”\n丁德庵的脸登时挂不住了。\n毛泽东:“胡说八道!丁老爷怎么会是那种人呢?”\n子升:“就是嘛,丁老爷是什么人?读书人,大善人,怎么会乘人之危呢?丁老爷,您说是不是?”\n当着众人,丁德庵的脸不禁涨得通红:“嗯,对呀,老夫什么时候说过那种话了?简直……简直一派胡言!”\n毛泽东:“听到了吧?人家丁老爷根本没有那么想。你这个当爹的也是,欠债还不起,可以来求丁老爷宽限嘛,就算免了你的债,那也是丁老爷一句话的事,怎么能拿女儿来抵债,这不是败坏丁老爷的名声吗?”\n子升:“这话说得是啊。丁老爷的为人,安化全县上下,谁不知道?你看看你看看,‘造、福、桑、梓’,你有难处,丁老爷还能不帮吗?”\n人群顿时一片附和之声。\n子升笑吟吟盯着丁德庵:“丁老先生,您的意思呢?”\n丁德庵的目光,从子升笑吟吟的脸,转到毛泽东,转到父女二人,再转到眼前黑压压的人群和那块崭新的匾上,他这才醒悟过来,眼前这一幕原来是专门给他下的圈套。\n“那个……啊,不是欠了点租吗?我丁某人怎么能逼佃户的租呢?那个那个……来人啦,把他家的借据找出来,还给人家。”\n他身边的仆人似乎还不敢相信:“老爷?”\n“快去!”\n“丁老先生的慷慨仗义,真令晚辈五体投地啊!”接过了仆人拿来的借据,子升转手将那块匾捧到了丁德庵眼前,“那,以后呢?”\n“以后……”丁德庵一咬牙,“以后的租子,也减半,一律减半。”\n毛泽东赶紧扯开了嗓门:“老人家,丁老爷的话你听见了?当着这么多人的面,他可是亲口答应,把你家的债全免了,还减了一半的租,丁老爷可是要面子的人,他说话,一定算话,你该放心了吧?”\n颤抖着手接过那张借据,父女二人抬起头来,已是泪流满面,两人同时重重磕下头去,泣不成声:“谢谢萧先生,谢谢毛先生……”\n子升与毛泽东赶紧拦住了父女二人:“怎么成了谢我们呢?谢谢丁老爷!”\n父女二人这才想起来,赶紧给丁德庵磕下头去:“谢谢丁老爷!”\n“免了,免了免了。”丁德庵捧着那块匾,笑得比哭还难看。\n九 # 离了安化县,那一路,毛泽东与萧子升还在为白天发生的事争执着,农民的疾苦,让两个人的心情都无法平静。\n“一个芝妹子,我们救得了,可还有成千上万个芝妹子,她们怎么办?”毛泽东思考着。\n“人力有时而穷,我们也只能救一个是一个。”子升也只能这样回答。\n“不,这是不负责任!你那一套仁义道德,你那一套温柔敦厚,解决不了农民的问题,也消灭不了这个社会的黑暗!”\n“可社会进步需要时间,完全的公正、完全的平等只能是不现实的空想。”\n“为什么?为什么就不能有完全的公正、完全的平等?”\n“人终归是有私欲的嘛。”\n“那我们就打破这个黑暗的现实,那我们就消灭这些无耻的私欲,把一切的不合理、一切的不公正、一切丑恶的人丑恶的事统统埋葬掉,这个世界自然会迎来大同。”\n“你那是理想主义,只会破坏社会的和谐。”\n“不公平不合理的所谓和谐,我宁可它统统被砸碎!”\n夕阳映在他们一样年轻的脸上,让他们彼此都看到了对方心里深深的疑惑。\n这满心的疑惑一路困扰着两个年轻人,直到五天后,他们来到了宁乡沩山寺。这沩山寺的住持证一和尚乃是佛门有名的大德,两人便专程登了门,想听听佛门中人对这俗世中的不平有何见解。\n进了证一的禅房,却见一床一几,此外便是四处堆积的书,把间禅房衬托得倒更像一间书房。那证一和尚年近七十,一身青衣短褂,如果不是光头上烫着戒疤,看上去简直就像一个和善的老农。\n听二人讲明来意,证一只是微微一笑,道:“佛门讲的是出世之理,二位施主的困惑,却是人间之事,只怕和尚是帮不上啊。”\n子升便道:“出世之理,亦由世上来,所谓万理同源,无分佛门与世俗,还请大师不吝指教。”\n证一没有答话,停了一停,端起茶壶,说:“先品新茶吧。”\n他将壶中茶水向子升面前原已倒好茶的杯中倒去,杯中水满,很快溢了出来。\n子升赶紧道:“大师,水溢了!”\n证一倒茶的手停住了:“水为什么会溢?”\n“这……因为杯中已经有茶了。”\n“是啊,旧茶不倾,新茶又如何倒得进去呢?我佛门禅宗,于此即有一佛理。”证一放下茶壶,铺开纸,提起笔,在纸上写下四个字,将纸转了个边,面向萧子升和毛泽东,写下了:不破不立。\n证一解释道:“所谓魔障所在,正见难存,旧念不除,无以证大道,不除旧,则无以布新,是当以霹雳手段,弃旧而图新也。”\n毛泽东一拍巴掌:“此言正合我意!佛门普度众生,与我辈欲拯救国家、民族,道理本来就一样,只有驱除腐恶,尽扫黑暗,彻底打破这个旧世界,才能迎来真正的光明,才能建立普遍的幸福,正如凤凰自烈火中涅槃,重得新生!”\n子升却不能接受:“可是新难道一定要从旧的废墟上才能建立吗?旧世界的问题,我们为什么不能徐图改良,为什么一定要毁灭旧的一切,这样的新,代价不是太大了吗?”\n证一想了一想,徐徐道:“两位所言,一则疾风骤雨,一则和风细雨,老衲以为,若无疾风骤雨,当头棒喝,则魔障难除,然先贤亦曰:飘风不终朝,暴雨不终夕,疾风骤雨,终难长久,破旧以骤雨,立新以和风,相辅相成,原是缺一不可的。取彼之长,补己之短,则新可立,道可成。”\n说罢又提起了笔:“老衲赠二位施主各一个字吧。”\n他先写下一个“动”字,转过来移到子升面前: “萧施主和风细雨,君子气节,独善己身足矣,但欲图进取,变世道,化人心,还须振作精神,勇于任事,以动辅静。”\n证一又写下一个“静”字,转过来推到毛泽东面前:“毛施主骤雨疾风,汹涌澎湃,以此雄心,天下无不可为之事。但世事无一蹴而就之理,施主于翻天覆地中,亦当常记,一动须有一静,一刚须有一柔,有些时候,是要静下来方好的。”\n子升和毛泽东互相看了一眼,都似乎有所领悟,但又似乎并未领悟得透彻,看看证一已然收了茶具,有起身送客之意,只得道了一声:“多谢大师!”\n第二十七章 工人夜学 # 要解决中国的问题,唤醒民众,肯定是件非搞不可的事。我们这些青年组成的团体,也只有眼睛向下,盯着最广大、最底层的国民,才能真正成就一点事情。\n一 # 暑假刚过,1917年秋,护法战争爆发了,护法军进军湖南,北洋系军阀傅良佐所率驻湘守军节节败退,安静了没几天的长沙城里,又是一日乱过一日。\n这天孔昭绶正在教务室召开全体教师会议,讨论一师为普及平民教育而办工人夜学的事,他这个想法由来已久,却一直腾不出空来,好不容易这回打出了招生广告,不料十多天过去,才七个人报名,眼看夜学就要成了泡影,无奈之下,只得找了老师们共同来分析原因。\n美术老师黄澍涛翻着办公室里的报纸,头一个便直摇头:“时局如此啊!校长,如今就连我们学校都朝不保夕,更何谈什么工人夜学?”\n的确,看看那堆报纸,哪张上面不是一个个触目惊心、火药味十足的大幅标题:《段祺瑞拒绝恢复约法,孙中山通电护法讨段》、《黔滇桂粤宣告独立 护法军誓师出征》、《湖南战事急报:湘南护法军兵进衡阳》、《衡山告急,傅部守军昨日增援耒衡前线》、《湖南督军傅良佐令:长沙即日实施宵禁》……\n方维夏也不禁叹了口气:“办夜学,普及平民教育,这件事本该是我们师范的责任。原指望谭督军在湖南,湖南还安稳一点,我们这些搞教育的,也可以做点实事,可这才安稳了几天?唉!”\n其他老师同样是七嘴八舌:\n“要说时局,确实是乱,可夜学办不起来,也不能说都是时局所致吧?”\n“可我们的招生广告打出去那么久了,才七个人报名,这样的学校,怎么办得起来呢?”\n“依我看,这帮出苦力做工的人,他就没那个读书上进的心思!你们看看,读书不要钱,课本全免费,连笔墨纸张都是免费送,这样的条件,上哪找去?这就是请他们来学嘛。你请他他都不来,还有什么好说的?”\n听着老师们的各抒己见,孔昭绶对自己当初的想法也有些动摇了:“这么说来,倒真是我们估计错了,这个工人夜学,工人们真的不感兴趣?”\n“我看,结论不必下得这么早吧?”始终没有开口的杨昌济突然说道, “要说夜学办不起来,是因为工人天生的不求上进,那有一件事我就想不明白了。我记得校长最初产生办工人夜学的想法,来源于看见毛泽东在街边教人认字。毛泽东是什么人?一个师范学生而已,为什么他教人认字,有人跟着学,而且学得认认真真,而我们,以一所师范学校的力量,提供比他那一根树枝当教鞭、一块泥地作黑板好得多的办学条件,反倒还招不来学生了,这能说得通吗?”\n所有的老师都静默了,这个问题显然极有说服力。\n“所以,招不来学生,我相信,责任不在工人,一定是我们的方法有不周之处。我的意思,还是先找润之谈谈。”\n毛泽东被孔昭绶叫进了教务室,一听来龙去脉,当即就拍了胸脯:这个工人夜学,一定会大受工人们的欢迎!校长要是不信,可以把工人夜学交给我们学友会来办。\n“你们来办?”一旁的袁吉六一脸的不信,“你们自己都还是些学生娃娃,还办学校?开玩笑!”\n看看老师们似乎都没有信心,毛泽东摆开了理由,说五年级的师范生上讲台当老师也就是没多远的事,现在接手工人夜学,等于给大家一个教育实习的场所,也给大家一个接触社会、锻炼自己的机会嘛。\n他的这个观点得到了老师们的一致认可。孔昭绶也当场点了头,答应把工人夜学交给学友会来办,但也有个条件:“十天内必须招到至少40个学生。”\n接了军令状,毛泽东回到学友会事务室,马上把学友会的成员统统召集起来,开了一个紧急会议。\n“这四年多来,我一直在思考,究竟什么才能改变我们这个千疮百孔的社会,才能拯救我们这个内忧外患的国家。过去,是读书、求学,可光靠书上道理有用吗?你读一万本书,不还是挡不住汤芗铭的那一营兵?我也想过,一个人不行,我们结交朋友,我们组成团体,可中国这么大,靠几个读书人来使劲,靠个把小团体,就能变过来?照样不行!这次暑假游学,给我的触动更大,一个土财主,就能骑在那么多佃户头上,为所欲为,为什么?因为读书人只有那么几个,因为中国真正多的,是农民工人老百姓,他们愚昧,他们老实,他们动不起来,你书读得再多,你是一帮学生,翻不了天。所以,要解决中国的问题,唤醒民众,肯定是件非搞不可的事。我们这些青年组成的团体,也只有眼睛向下,盯着最广大、最底层的国民,才能真正成就一点事情。”\n他这一番开场白,顿时赢得了大家的一片认可之声。\n毛泽东趁热打铁,在小黑板上画着一幅简单的地形示意图,把以第一师范为中心,往南到猴子石,往北到西湖桥,十里范围内集中的黑铅厂、印刷厂、纱厂、铸铁厂等大大小小十几家工厂全标了出来,这才宣布道:“这些工厂加起来,少说也有两千工人。而我们的任务,是十天内,从这两千工人里头,招到40个学生。大家说,敢不敢揽这个活?”\n“当然敢……没问题。”\n学友会的骨干们七嘴八舌,崭新的挑战令每一个人的眼中都闪着跃跃欲试的光芒。\n“好!说干就干!”毛泽东当即给大家分配了任务,“招生广告我来拟;子暲、昆弟,你们负责联系警察所,请他们帮忙,把广告尽可能贴遍每条街;李维汉、罗学瓒,你们负责准备报名表,接待报名;其他的人跟周世钊一起,收拾教室,准备课本、资料。”\n他伸出手来:“大家一起攒把劲,也让孔校长和全校的老师看看,我们这些师范生,不光会吃干饭!”\n学友会所有成员的手,紧握在了一起。\n二 # 毛泽东的笔头向来快,第二天,便拟好了一份大白话的《工人夜学招生广告》,那广告原文是这样的:\n列位大家来听我说几句白话:列位最不便益的是什么?大家晓得吗?就是俗话说的,讲了写不得,写了认不得,有数算不得。都是个人,照这样看起来,岂不是同木石一样?所以大家要求点知识,写得几个字,认得几个字,算得几笔数,方才是便益的。虽然如此,列位做工的人,又要劳动,又无人教授,如何能做到这样真是不易得的事。现今有个最好的法子,就是我们第一师范办了一个夜学。这个夜学专为列位工人设的,从礼拜一起至礼拜六止,每夜上课两点钟,教的是写信、算账,都是列位自己时刻要用的。讲义归我们发给,并不要钱。夜间上课又于列位工作并无妨碍。若是要来求学的,就赶快于一礼拜内到师范的号房来报名。\n广告拟好后,学友会的一部分同学立刻就拿去油印。很快,这份招生广告就在警察的帮助下,贴满了一师周围的街边墙上。其他同学搬桌椅,打扫卫生,很快把工人夜学的教室也布置好了。\n可是没想到,一晃眼过去了一周,总共才只有三个人报名!而街边的墙上,路人经过,也似乎都懒得多看那招生广告一眼。\n“按说广告也贴得够多了,怎么就没人来报名呢?”无奈之下,毛泽东也只能决定再请警察帮一次忙,多贴一些广告出去。\n但这次求上门去,却就没有上次那么好说话了。\n“什么,还贴?”警察所的警目把他们带来的招生广告往桌上一扔,“你当我们警察所是你第一师范开的?”\n毛泽东说着好话:“贴公益广告不是你们警察所的责任吗?”\n“你跟我讲责任?”警目眼睛一横,“弟兄们贴了一回就够对得起你们了,还一而再再而三?你以为我这帮弟兄专门给你当差的?”\n一旁有个年轻警察有点看不下去了,插嘴道:“长官,要我说,人家办夜学,也是做善事,能帮咱们还是帮帮吧。”\n“你是吃饱了撑着了,还是他发了你薪水给了你饷?”警目瞪着自己的手下,把那叠广告往毛泽东手里一塞,“给我拿回去,我这儿不侍候!”\n“你这个人怎么这样啊?当警察,就要为社会服务嘛……”\n萧三还想说点什么,毛泽东把他一拉,“子暲,跟这种人有什么好说的?求人不如求己。走!”\n几个人只得自己上街去贴广告,直贴到日头偏西,还剩了一半的街道不曾贴完,正在发愁时,那个年轻警察却与好几个同事赶了来,手里都还拿着糨糊桶、刷子之类。\n看看毛泽东他们一脸的诧异,那年轻警察笑了笑:“我们刚下差,反正没什么事,就来帮个手——哎,还剩几条街?”\n望着他和善的笑容,一股暖流蓦然涌上大家的心头,毛泽东用力点了点头:“东边南边我们都贴过了,这两边还剩几条街。”\n那年轻警察便抱起了一叠广告:“行,这边你们贴,那边归我们,动手吧。”说罢,带着警察们就走。\n毛泽东追了两步,问:“哎,你叫什么?”\n“郭亮。你呢?”\n“毛泽东。”\n郭亮和毛泽东就这样认识了,虽然只是匆匆一面,只是相互一挥手,虽然两个青年都不曾想到,他们今后的命运,会那样紧密联系在一起。\n三 # 第二次广告贴出去之后,从早等到晚,整整两天,还是没有人来报名。\n到底是怎么回事呢?毛泽东着实有些想不明白:明明是件好事情,可为什么就做不成呢?到底是哪个环节出了错?\n傍晚,他照例来到了水井旁,光着膀子开始了冷水浴,这一刻,他只想借冰凉的井水,来刺激一下自己,让自己的思路开启起来,他几乎是机械地一下一下往身上淋着水。一桶水很快见了底。\n刚打上一桶水,兜头淋了个从头到脚,杨开慧却气喘吁吁地跑来了:“润之大哥,我知道为什么没有工人报名了!”\n杨开慧是刚刚发现的原因。\n下午她和蔡畅一起放学回家,两人边走还边在帮哥哥们分析,为什么夜校招不到工人,却听到路边传来了一个声音:“搞什么名堂,怎么又把货送错了?”\n两个人转头一看,原来是一辆送货的板车停在布店门口,车上堆着标有“万源纱厂”的货箱,布庄的老板正敲着工人手中的一张单子直嚷嚷:“你看看这写的什么,再看看我的招牌——康和唐都分不清,你认不认识字?”\n开慧突然站住了,饶有兴趣地看着。\n“赶紧把我的货送来,我这儿客人等着要呢!”老板转身气呼呼进了店,剩下两个工人在那里大眼瞪小眼。\n开慧凑了上去问:“两位师傅,什么字弄错了?我们能不能看看啊?”\n工人的手里,是张送货单,上面写的是“唐记”布庄,布庄的招牌上却是“康记”。\n蔡畅说:“这是唐记,不是康记啊。”\n“看上去也差不多,我们哪分得那么清?”一个工人说,“唉!这眼看就要天黑戒严了,来回七八里,再送怎么来得及呀?”\n开慧问:“两位师傅,你们不认识这两个字吗?”\n一个工人说:“做工的,还不都是半个睁眼瞎子。”\n另一个工人也说:“真要识字,还能吃这种亏吗?”\n听了这话,开慧赶紧跟工人们说起了工人夜学的事,却不料两个工人一脸茫然,全不曾听说这回事,开慧问明了原因,恍然大悟,这才匆匆赶来,找到毛泽东。\n“你知道为什么没有工人报名吗?因为他们不识字、不认识广告上的字!”\n毛泽东这才醒悟过来:“你是说,工人根本不认识广告上的字?”\n“是的。我也是听那两个工人说了才知道,他们不光是看不懂,就算认识几个字的,也根本没敢去看广告。”\n“那又为什么?”\n“我们的广告不是请警察去贴的吗?警察在工人眼里,就是衙门抓人的差役,工人以为贴什么抓人的告示,怕惹麻烦,都躲着走,根本没人敢去看。就算有人看了,也不相信真有这种免费读书的好事。我碰上的那两个工人,就怎么都不肯相信,以为我跟他们开玩笑呢。”\n“原来这样。”毛泽东点了点头,略一思考,突然一挥拳头,“我有主意了!”\n四 # 第二天工人下工时分,万源纱厂的门口,两面铜锣当当直响,引得熙熙攘攘的路上,正在下班的工人们都奇怪地望了过来。一帮学生带着锣鼓唢呐,各种各样的乐器,在路边拉开了场子。原来毛泽东等连夜排了一个节目,想以这个方式来说服工人参加夜学。\n领头敲锣的,正是毛泽东和向警予,两人一边敲锣一边唱和:\n“哎,都来瞧都来看。”\n“看稀奇看古怪。”\n“看刘海砍樵出新段。”\n“胡大姐路边谈恋爱喽。”\n一旁,张昆弟、罗学瓒、萧三等一帮子锣鼓唢呐洋铁碗,滴滴答答伴奏声大作。\n这《刘海砍樵》本是长沙一带最受人欢迎的花鼓剧目,如今被弄出了这番新鲜举动,着实令人好奇,当下呼啦一下,众多工人顿时把他们围了个水泄不通。\n警予锣槌一抬,身后乐声止住,她团团一抱拳:“列位工友,有道是故事年年有,今年特别多。今天我们要讲的,便是这《刘海砍樵》的故事。”\n“列位要说了:《刘海砍樵》谁没看过?有什么新鲜的?”毛泽东接着和她一唱一和。\n“我告诉列位:有!”\n“怎么个有法?”\n“且看我们的小刘海进厂当工人!哎,刘海呢,刘海,刘海!”\n“(花鼓腔白)来哒咧……”但听得笛子、唢呐……花鼓调子的伴奏大起,音乐声中,蔡和森一身短褂、草鞋,背着雨伞、包袱,突然从观众群中钻了出来。\n蔡和森:“(唱)小刘海啊我别了娘亲,不上山来我不进林。(白)都说那做工比砍樵要好,我也到工厂(唱)来报个名哪咦呀哎嗨哟。”\n观众们的一片笑声中,警予手一背,挺胸腆肚,装起了工厂老板:“叫什么?”\n蔡和森:“(白)刘海。”\n警予:“哪个刘,哪个海?”\n蔡和森:“(白)刘海的刘,刘海的海。”\n毛泽东:“老板是问你名字怎么写的?”\n蔡和森:“(白)冒读过书,搞砣不清。”\n观众又是一片笑声。\n警予:“你自己的名字都不会写?”\n蔡和森:“唉,(唱)自幼家贫丧了父亲,上山砍樵养娘亲。我也有心把学堂进,无衣无食哪有读书的命。”\n毛泽东“当”的一声锣,冲观众:“可怜我们小刘海,论人才,原本也做得个纱厂的工头。”\n警予:“怎奈大字不识一个,只好先做了个送货的小学徒。”\n毛泽东:“一进工厂整三月。”\n警予:“家中急坏了胡秀英。”\n音乐声中,斯咏一身花红柳绿,袅袅婷婷出了场:“(唱)海哥哥进城三月挂零,秀英我在家中想夫君。不知他做工可做得好,为什么一去就无音讯?”\n她秀美的扮相与清脆的嗓音,一出场便博来了一片叫好声。\n“大姐,”开慧扎两根冲天辫子,打扮成个小丫头,从人群中挤了出来,“刘海哥来信了。”\n斯咏:“(唱)一块石头我落了地,小妹你快把书信念与我听。”\n开慧打开手上的信:“胡……胡圈圈?”\n斯咏凑上来一看:“哎哟,胡大姐喽。”\n开慧:“这明明是两个圈圈,哪里是大姐嘛?——还画都画不圆。”\n斯咏:“(白)海哥哥不会写大姐,画两颗脔心代替我啦。”\n四周观众哄堂大笑。\n开慧:“哦,(念)胡大姐,我在城里丢了命……”\n“啊?”斯咏、警予、毛泽东等齐声,“什么?”\n开慧:“(念)我在城里丢了命,一天到晚被雨淋,别人有命我无命,圈圈——哦不对——大姐有命送命来,若是大姐也无命,刘海我就不要命。”\n斯咏作焦急状:“(白)这可如何是好?”\n毛泽东、警予、开慧齐声:“赶快进城看看啊!”\n音乐声中,斯咏、开慧退场。这番新奇有趣的路边活报剧,一时间赢来了观众无比热烈的掌声与叫好。\n叫好声中,王老板夫妇也正好走出厂门,叫着仆人王福,让他备轿,那叫王福的仆人却正踮着脚挤在人群外看戏,听见老板叫他,赶紧跑来,一脸的兴奋地说:“老爷,好看啊,表小姐在那儿演戏,演得可好了。”\n“表小姐?”王老板夫妇眼都瞪圆了,夫妇俩走到人群后面,踮起脚来,果然正看到人群之中,斯咏与蔡和森一身戏装,一副久别重逢状,演得正来劲。\n开慧一拉斯咏:“大姐,刘海哥挺好的,没出事啊?”\n蔡和森:“(白)哪个讲我出哒事?”\n开慧:“你自己信里写的嘛。(拿出信来念)我在城里丢了命——这不是你写的?”\n场子一旁的警予举起一块大牌子,上面是一个老大的“命”字。\n蔡和森接过信:“(白)哎哟,我写的是‘我在城里丢了伞’嘞。”\n斯咏、开慧:“伞?”\n场子另一旁的毛泽东举起了另一块大牌子,上面是老大一个“伞”字,与警予举的牌子呼应着。\n蔡和森:“(白)对呀。(念信)‘我在城里丢了伞,一天到晚被雨淋,别个有伞我无伞,大姐有伞送伞来,若是大姐也无伞,刘海我就不要伞。’”\n斯咏:“(白)哎哟,海哥哥嘞,你硬把我脔心都吓跌哒咧。”\n开慧:“你看你这个刘海哥,(念板)我大姐,在家里,一天到晚想着你。听说你城里丢了命,大姐她心里好着急。”\n警予与毛泽东齐声:“嘿!胡大姐她心里好着急!”\n开慧:“她卖了鸭,卖了鸡,倒空了米缸卖了米。凑钱到城里把你看,原来你只是丢了伞。”\n警予、毛泽东:“嘿!原来他只是丢了伞!”\n这一段唱下来,有情节、又生动,把四周的围观的人全逗得大笑不止。\n斯咏:“(唱)海哥既然平安无事,秀英也算放哒心。三月工钱先把我,回家买米养娘亲。”\n蔡和森:“唉,(唱)提起工钱我眼泪汪汪,三个月辛苦我白忙一场。”\n斯咏:“(白)这又为何?”\n蔡和森:“(念板)上前天送货我出哒厂,要货的布老板他本姓唐。刘海我自幼读书少,一个唐字我看成哒康。跑出城外十几里,把货错送到康记布庄。等到我再往城里跑,太阳落山见月光。天一黑城里戒哒严,唐老板的生意塌哒场。厂里头怪我送错哒货,两个月的工钱全扣光。”\n毛泽东与警予一人一块牌子,上面写着大大的“康记布庄”和“唐记布庄”,生动地配合着他的念白,四周的工人们就算不认识这两个字,也看得明明白白,不由得又是一片哄笑。\n开慧:“这才两个月工钱,还有一个月的呢?”\n蔡和森:“(念板)昨天我送货又去结账,有个老板他冒得名堂。一共他要哒三次货,每回欠厂里八块光洋。三八是好多我又算不清账,只怪我细时候冒进学堂。他一看我算账不里手,硬讲三八是一十九。一下就少收哒五块钱,厂里头又要扣我工钱。学徒一个月才四块五,赔光哒下个月我还要补。认错一个字,算错一回账,三个月的工钱全泡汤啊全啊全泡汤。”\n斯咏:“(白)海哥哥,你明晓得冒读过书,厂里何式还喊你去送货喽,未必冒得别个哒?”\n蔡和森:“(白)大姐,你有所不知——(念板)厂里的工人有三百整,刘海我水平已经算蛮狠。斗大的字,还认得几箩筐,我就算厂里的状元郎。换哒别个更不得了,认字算数都摸风不到。写个一字他当扁担,写个二字以为筷子一双。”\n斯咏:“(白)那要是三字咧?”\n蔡和森:“(念板)写个三字他更眼生,还以为两双筷子跌哒一根。”\n这一段又让四周的观众笑得连气都喘不过来了。\n蔡和森:“(念板)讲得出口,写不得,别个写哒又认不得。厂里的规矩随老板讲,扣你的工钱冒商量。一世人做哒个睁眼瞎,人不读书就把亏吃。做工的生来命最苦,千苦万苦只因冒读得书。列位工友都在此,你们讲我讲得是不是?”\n满场的笑声戛然停了,一番话深深地引起工人们的共鸣,许多人都在默默地点头。\n斯咏:“(唱)听罢海哥话一场,秀英我心里好凄凉。人不读书遭白眼,夫受欺凌妻亦无光。千事万事先放下,海哥你今天就上学堂。”\n蔡和森:“(白)我也想读嘞,只是学堂这样贵,做工的哪里读得起喽?再说我只晚上有空,白天还要做事,大家讲,我这个书何式读得成器喽?”\n“谁说你读不成书?”一旁的警予与毛泽东突然插了上来,“我们给你指条路怎么样?”\n蔡和森、斯咏、开慧:“(白)哦?愿听端详。”\n警予:“眼下就有一个机会:第一师范新办了工人夜学,专门方便列位工友读书学知识。”\n毛泽东:“每天晚上两节课,不耽误你白天要做工。”\n警予:“你若担心晚上戒严,夜学还发听讲牌。”\n毛泽东:“凭牌就能畅通无阻,军警一概都放行。”\n蔡和森、斯咏、开慧:“当真?”\n警予、毛泽东:“当真。”\n蔡和森、斯咏、开慧:“果然?”\n警予、毛泽东:“果然。”\n蔡和森:“(白)但不知学费好多?”\n警予:“免费夜学,一文不收。”\n毛泽东:“课本笔墨,按人发放。”\n警予:“如今夜学正招生。”\n毛泽东:“要想报名你赶紧去。”\n众人:“对,要想报名你赶紧去!”\n“当当啷当,当当啷当……”观众的一片叫好与掌声中,伴奏的张昆弟、萧三等人打起了快板,走上前来,加入演员中,众人齐声:“嗨,嗨,这正是——\n“刘海砍樵的新故事,工人也要学知识。”\n“学写字,学算术,学了加减学乘除。”\n“能读书,能算账,我们和别人要一样。”\n“莫说人穷没人管,我们工友人穷志不短!”\n毛泽东扯开了嗓子:“列位工友,我们第一师范的工人夜学正在招生,过几日就正式开课,有愿意读书学知识的,现在就可以向我们报名!”\n“我报名……我也报……”\n呼啦一下,上百工人们争先恐后涌了上去,顿时将负责报名的张昆弟他们围了个水泄不通。\n望着那一张张渴盼的脸,一双双争抢着报名表的手,毛泽东兴奋得与蔡和森、开慧用力一击掌。\n转身,他又一把握住斯咏的手,用力一紧。\n“谢谢你,斯咏!”\n紧紧握着毛泽东的手,斯咏一时似乎也不知该如何表达成功的喜悦。就在这时,她蓦然一呆:涌上前来的观众群后面,露出了王老板夫妇,两口子脸色铁青,正狠狠地瞪着人群中的斯咏!迎着那两双气得简直要喷血的目光,斯咏不由得一慌,她下意识地正要放开毛泽东的手,想想,把头一扬,反而更紧地握住毛泽东的手。\n“润之,”蔡和森拿着一叠报名表挤过来,“不行呀,人太多了,昆弟他们忙不过来,你这边再开一个报名点吧。”\n“要得,交给我了。开慧,斯咏,来,一起帮个忙。”\n“好!”斯咏一把抓过毛泽东手中的报名表,仿佛示威般迎着王老板夫妇的目光,拉开了嗓子,“后面的工友们不要挤,这边也可以报名,请大家一个一个来,人人都能报……”\n几十名工人一下将她与毛泽东等围住了。\n一向很注意保持风度的王老板,这个时候都快要被未来儿媳给气疯了,他拉住同样目瞪口呆的王夫人,颤抖着嘴唇从牙缝中挤出一个字:“走!”\n1917年11月9日,第一师范工人夜学正式开学。\n因为是第一天上课,也因为毛泽东的军令状,孔昭绶带着杨昌济、方维夏、徐特立、袁吉六等先生特地来看教学效果。远远的,他们就看到了一块“工人夜学”的牌子挂在教室外面,不用说,大家都知道那是毛泽东的手笔。\n站在窗外,他们看到教室里讲台一侧挂着课程安排的粉牌:“今晚授课:第一节,国文,毛泽东;第二节,算术,陶斯咏”。毛泽东穿着一件灰白的长衫,颇有教师的风度,正在教工人读:“我是一个工人。”\n挤得密不透风的教室里,130多名工人捧着简单的油印课本,神情专注地跟着读:“我是一个工人。”\n“我为我们的中国做工。”\n“我为我们的中国做工。”\n……\n孔昭绶点点头,目光投向了袁吉六:“仲老,如何啊?”\n望着眼前的盛况,袁吉六彻底服气了:“袁某是老了,对他们年轻人,不服不行啊!”\n几个老师也纷纷断定,这个毛泽东,以后准是个好老师啊!\n那一刻,只有杨昌济却摇了摇头,心中暗想:老师是个好老师,就不知道这门教书的本事,他用不用得长了……\n第二十八章 梦醒时分 # “ 斯咏心中所藏的,也许只是一个浪漫的、不切实际的梦幻,我想,这也许是真的,因为如果她真的心中有我,在她的心中,所藏的那个人,也并不是真正的毛泽东,而只是一个被加工过的梦想而已。”\n一 # 陶家印刷厂门口,工人大多都已经下班了,陶会长看到还有几个工人没有回家,扎在一堆不知道在看着什么,就走了过去。工人们看到老板来了,赶紧起身问好。陶会长凑过来一看,地上是歪歪斜斜用树枝写的字:“我是一个工人。”\n工人把手里的识字课本递了过来 告诉他,这是工人夜学教的,第一师范办的工人夜学,教工人免费读书,好多工厂的工友都去了。陶会长翻看着识字课本,忍不住点着头,称赞这是件大善事,又问办学的是一师哪几位先生?一个工人说是一师的毛先生,还有周南的陶先生。陶会长锁着眉头重复了一句:“毛先生,陶先生?”\n一路疑惑地回到家,陶会长进了大门就问管家小姐在房里没有,管家回答说小姐出去了,姨老爷两口子来半天了,脸色不太高兴。陶会长“哦”了一声,进到客厅。果然看到王老板夫妇来了,不过王老板夫妇的脸色,说不高兴是太轻描淡写,那两张脸铁青,简直就是气急败坏!\n一看到姐夫进门,王夫人率先发难:“大街上!姐夫,那可是大街上啊!居然就跟人夫啊妻啊扭啊唱啊,让几百做工的围着看!成何体统啊?”\n“伤风败俗!”王老板也一巴掌重重地拍在桌子上,吼道:“伤风败俗!”\n这样有伤自尊的话任何一个女孩的父亲都接受不了,陶会长的脸色沉了下来。\n王夫人没看见姐夫的脸色,也或许她看见了却不在意,只顾自己发泄,而且越说口气越难听:“上次过生日,跟一帮男人疯到大街上,就够丢脸的了。现在倒好,干脆上大街,卖唱当戏子!真没个廉耻了!要让人知道这是我们王家的儿媳妇,我们还怎么做人哪。”\n“她二姨,你的意思,你王家的儿媳妇,我斯咏高攀不上?”\n看到陶会长脸拉得死长,王夫人这才发现话讲过了,赶紧放软了口气:“我……我不是这个意思,姐夫,我和你妹夫还不是为你着想,怕她丢了你的面子吗?好歹她是陶家的大小姐嘛。”\n王老板却仍然不松口:“她也是我王家的儿媳妇。姐夫,陶家、王家,都是有头有脸的人家,姐夫该管的还是得管,别闹出笑话来,大家脸上不好看。”\n陶会长把头一扭:“女儿怎么管,我自己有分寸!”\n“那就好,我们做公公婆婆的,可就拜托亲家翁了。”王老板拉起老婆,“告辞了。”\n“不送!”窝在沙发里,陶会长的胸膛一起一伏的,这番羞辱可当真把他气得够呛!\n这天晚上,工人夜学有斯咏的算术课,放学后已是夜里九点多钟了,斯咏一进门,迎头就碰上了陶会长铁青的脸。\n还没等她开口,陶会长砰地一拍桌子,劈头就是一顿骂,说的话居然和刚才王老板夫妇说的差不多。\n斯咏目瞪口呆地看着爸爸,长这么大,她可一直都是爸爸的心肝宝贝呢!听得父亲全没有收口的意思,她的小姐脾气上来了,登登登地上了楼,冲进卧室,反手便将门一关。\n“砰”的一声,门又被陶会长推开了:“怎么怎么,啊?还说不得你了?上大街丢人现眼的时候,你的面子哪去了?”\n“我那是为了办夜学,丢什么人?”\n“办夜学你可以去教书,我并没有反对嘛。可你、可你上什么大街?还夫啊妻啊跟人扭啊唱的,人家看到了会怎么想?啊?”\n“他爱怎么想怎么想,我就是做给他们看的!”\n“你……你怎么这么不懂事呢?那是你公公婆婆!你这个样子,以后怎么当人家儿媳妇?”\n“他看不惯是吧?看不惯正好,退婚喽。”\n“胡说!”陶会长这下真火了,“砰”地又是一拍桌子,“退婚是随便说的?我陶家多少代清清白白,无再嫁之女,无重婚之男!三媒六订许下的婚事,退婚?你不要脸我还要脸呢!”\n他一屁股在椅子上坐下,喘着粗气,强压了压火气,又把椅子往床边挪了挪,放缓了口气:“斯咏,不是爸想跟你发脾气,被人当着面这么说,爸心里窝火啊!要怪呢,只怪爸过去太娇惯你了,总想着你还小,什么事都还早,由着你玩就玩吧,到时候收心就是。可你一天一天,你越来越不像话。那个什么毛泽东,爸说过多少次,不要跟他来往不要跟他来往,你呢,听进去了吗?你非要跟他混在一起。他就那么好?就有那么大的吸引力?”\n“他就是好!就是好就是好!”\n“好,他好,他好上天又怎么样?不还是个穷师范生?爸不是说要嫌贫爱富,可凡事它总还有个门当户对,爸也得为你的将来考虑吧?退一万步,我们退一万步讲,你终归是定了亲的人,是有主的!你别忘了,还有半年你就得出嫁,不管什么毛泽东毛泽西,你跟他都没有将来!一个女孩子,名声要紧,不能乱来啊,你明不明白?”\n陶会长这番话,戳中了斯咏心里的痛处,她紧紧咬着牙,不知道该说什么了。\n那天晚上,斯咏躺在床上,翻来覆去地想着自己该怎么办。\n周末放学后,斯咏死乞白赖地要警予陪她去趟蔡家。开始怎么都不对警予说去蔡家的理由,后来警予威胁她不说就不陪她,她才说,蔡妈妈是反封建的典范、新女性楷模,所以想去找蔡妈妈给自己出个主意。警予白了她一眼,告诉她不用去蔡家,主意现成就有一个,退婚。\n“可是,我爸他就是不肯退啊!”\n“你爸不退,你自己退嘛!”\n“自己退?”这一层斯咏显然从未想到。\n“对呀,你的婚姻,退不退是你的权力,跟别人有什么关系?现在是民国,不是封建王朝!长辈的一句话,还能管你一辈子?不是我说你,斯咏,为了这一纸婚约,这些年你添了多少烦恼,多少无奈?想说的不敢说,想爱的不敢爱,都是因为它!其实不就是一张纸吗?撕了它,一身轻松!”\n看到斯咏还在犹豫,这次,警予主动提出去找蔡妈妈,因为她相信,蔡妈妈一定会赞成自己的想法。\n可是,当他们过江来到溁湾镇刘家台子蔡和森家,坐在蔡妈妈对面的时候,蔡妈妈的一番话,却叫他们大失所望。\n“我抛弃过一段老式婚姻,抛弃过一个封建家庭。斯咏,按理说,现在,最应该鼓励你,支持你,给你打气的,就是蔡伯母。可是,可是蔡伯母不能那样做。”\n葛健豪给孩子们续上茶水,又说:“你们还年轻啊,孩子们。有很多事,你们还没有经历过。只有经历了,你们才会明白,生活,并不像你们年轻人想的那样,只要迈过那一道坎,前头就会是一片阳光。正好相反,一个人,做出任何选择,都是有代价的,常常是,当你做出了选择,你却发现,你所面临的,反而是更大的、更长久的、更难以克服的障碍与压力。如果换作一个女人,要她去挑战旧婚姻、旧家庭、旧观念,甚至整个旧的社会,那更要付出巨大的,也许是你根本无法承受的代价。”\n“怎么,伯母,您后悔了?”警予几乎不敢相信这些话会出自葛健豪的口。\n“不,我没有后悔,我从来不为自己的选择后悔。可我是我,斯咏是斯咏。斯咏,你蔡伯母的性格,跟你不一样,蔡伯母的年龄,也比你大得多,我的选择,经过了深思熟虑,当我打算踏出那个家门时,我也自信已经做好了一切准备,预计好了一切困难。可当我真的离开那个家,我才发现,还有许许多多的白眼,许许多多的压力,许许多多旁人无法想像的困难,超出了我原来的预计。这些压力与困难,蔡伯母挺下来了,可是不是等于你也能挺下来,我不知道。真的,斯咏,我很愿意支持你,支持你挣脱枷锁,但作为一个过来人,我更要提醒你,做出一个人生的选择也许艰难,但承受一个选择所带来的终生的压力与代价,才是你今后真正要面对的现实。所以,当你打算做出选择的时候,我希望你认真地问一句自己:我,真的做好付出一切代价的准备了吗?”\n望着葛健豪坦诚而关切的眼睛,斯咏努力想弄明白蔡妈妈这番话背后的意思。\n回到家里,斯咏取出信笺,提笔写下了“姨父姨母大人台鉴”后,却又不知道该怎么写下去了,在桌子面前坐了半天,只是不知道怎么落笔。\n好不容易挨到天亮,她一路小跑到了一师,敲开了八班寝室的门。\n周世钊还睡眼惺忪的,一听斯咏说要找毛泽东,揉着眼睛说: “润之?他回家了。你不知道吗?他母亲病了。”\n二 # 毛泽东是头天离开的长沙,那时他正在寝室里整理“工人夜学记事簿”,四年来从没有到一师来过的毛泽民突然风尘仆仆地找来了,水都没有来得及喝一口,就拉住哥哥哽咽着说了母亲最近严重的病情。\n“你说什么?娘病了?”毛泽东大吃了一惊,问明母亲的情况,赶紧请了假,跟弟弟一起赶回了韶山。\n文七妹果然病得不轻,这一两年来,吃不下饭,睡不安觉,整个人已经骨瘦如柴,最近两个月,居然还连续晕倒了好几次,乡间本谈不上什么医疗条件,郎中也看不出是什么病来,她也就这么一日日挨着。毛顺生和毛泽民眼看她越来越严重了,这才下了决心,叫回了毛泽东,要送她去省城医病。\n文七妹却不想去长沙,只说跑那么远干什么,哪里不是一样的诊?毛泽东只能反复劝她省城可不像韶山这个小地方,有洋人开的大医院,什么病都诊得好。他一再宽妈妈的心,说不管什么病,等到了省城,就诊好了。好说歹说,文七妹终究拗不过丈夫和两个健壮的儿子,这才答应了下来。\n独轮车的车轮吱呀吱呀,辗过崎岖不平的羊肠山路。独轮车上,架着简单的木板,文七妹满脸病容,无力地斜靠在上面,盖着床被子。她的身后,毛泽东推着独轮车,额角绽着汗珠,汗水早已浸湿了前襟后背。泽民背着行李走在旁边,不时地对哥哥说:“大哥,你歇一会,我来推吧。”\n“不用不用,我来推。娘,您还没到过省城呢,等到了,诊好了病,我带您看省城,哪里热闹我们就看哪里,好不好?”\n“哎。看,看。只要娘走得动,就看。”\n“走得动的,诊好病就走得动了。不光省城,以后,北京、天津、上海、广州,好多地方您还要去看呢。”\n“娘哪里跑得那么远喽?”\n“跑得的。我先去嘛,这些地方,我都去,去了,就接娘去。娘,您还要活到九十九呢,哪里去不得?都去得。”\n“好,你去,三伢子去了,就等于娘去了。”\n山道弯弯,小车吱呀,母子之间的对话声,渐渐融入了秋日夕阳之中。\n车船劳顿,跑了整整一天的路,毛泽东总算把母亲送进了湘雅医院。\n给文七妹看病的是一个西洋医生,洋医生一番周折,检查完了,考虑了一下,才用还算清晰的中文对毛泽东说: “你是病人的大儿子?病人现在需要住院观察几天,先让你弟弟给她办住院手续。你留下来。”\n诊室内只剩了毛泽东和医生,毛泽东神情紧张地看着医生,听他说: “你母亲患的是淋巴腺结核,病情已经比较严重了。目前的医学,还没有治疗结核病的好办法,主要是保养,延缓病情的发展。但现在的问题,是你母亲的身体太差了。你说她只有50岁,可是从她的身体状况来看,就像一个70岁的人,我认为,她太过于劳累了,她在透支,透支自己的生命。如果再让这种情况发展下去,病情就会很难控制,你明白吗?”\n毛泽东沉重地点了点头,出了诊室,扶着楼梯一步一步走下楼来。眼看拐弯就要到病房了,毛泽东停住了脚步,深深吸了一口气,努力撑起一张笑脸,装出轻松的表情。他走到门口,正听到病房里传出一个冷冷的声音:“……怎么回事?你看不看得懂啊?”进门一看,一名护士一脸淡漠,看也不看毛泽民,边对着小镜子补妆边用鄙夷的口气说:“这是临时留观。什么是住院手续知道吗?”\n“我……我就是在门诊那边办的嘛。”毛泽民手足无措地看着护士。\n文七妹也看着护士小心翼翼地说:“那我们就留那个观嘛。”\n护士瞥了母子俩一眼冷冷地说:“你想留就留啊?真是!”\n毛泽东没听明白,进去之后,先看了看母亲,然后尽量和气地问:“护士,我们办错什么了?我是要给我娘办住院手续,如果错了,那我去补办一下。”\n护士自顾自地照着镜子:“你知道这儿住一天院多少钱吗?带了钱没有?”\n“请你给我娘安排病房,我现在就去补办手续。”\n因为长途跋涉,母子三人的身上和行李上,都满是尘土,护士看看人、又看看地上卷成一卷的行李、被子,毫无表情地说:“对不起,现在补办晚了,病房满了。”\n毛泽东真有些耐不住了,正想争辩,恰在这时,文七妹突然咳嗽起来,毛泽东赶紧扶住母亲,拍打着她的背:“娘,娘,您顺顺气,别着急,别着急啊。泽民,你扶着娘,我去打碗水来。”\n他刚转身,突然一愣,看到斯咏正站在面前的走廊上。\n斯咏是听说毛泽东接了母亲来看病,才专程赶来的,她看了看毛泽东,沉着脸,转向那个护士说:“我是这家医院陶董事的女儿,叫你们院长来!”\n病房的问题因为斯咏的到来而解决了。斯咏站在病房里,看毛泽东和弟弟小心翼翼地把妈妈扶到了病床上。\n毛泽东给母亲盖好被子,又端来一盆水,要给妈妈洗脸。文七妹拦着他,气喘吁吁地要儿子先招呼陶小姐,请陶小姐坐。站在一旁的斯咏赶紧摆着手说:“伯母,您不用客气,我和润之熟得很。”\n“对,我们是好朋友,不讲究这些。斯咏,你坐啊。娘,来,擦擦脸。”\n斯咏在一旁坐了下来,看毛泽东小心翼翼地给母亲擦着脸,他的动作是那样轻柔,那样仔细。洗了脸,他又捧着碗,小心地喂着母亲喝水,还用手帕轻轻擦去了母亲嘴角沾上的水。\n望着毛泽东在母亲面前温柔、仔细的一举一动,斯咏几乎都看呆了。\n妈妈睡下之后,毛泽东送斯咏出医院,很真诚地感激她今天为母亲做的一切。 斯咏问起文七妹的病情,毛泽东低下头,说:“我娘的病,其实都是累出来的。这几十年,整天整天,整夜整夜,田里,家里,大人,小孩,都是她一双手,就算是机器,它也要停一停啊,可我娘,就从来没停过。看看我这一身,哪样不是她一针一线熬夜熬出来的,可这些年,我这个做儿子的,也不在她老人家身边,什么事也没有为她分担,就连一点回报,也没有给过她老人家,反而让她牵挂我,想念我。”\n斯咏轻轻握住了他的手:“可你心里记着你母亲,有这一点,我想伯母也就满足了。”\n“是啊,中国最苦的,就是我娘这样的妇女,一辈子,什么都没有享受过,就这样一句话也不说,做啊,做啊,一直做到筋疲力尽,做出一身病痛,做到做不动为止。乡下呢,得了病,又没有地方看,只能这么拖,这么熬,结果小病拖成大病,大病拖成……好多人一辈子,连医院的门朝哪边开,连医生是个什么样子都不晓得啊!”\n“谁叫中国还这么落后,还这么贫穷呢?”\n“不,这一切都不合理,这一切都一定要改变!总有一天,我要让中国所有的人,不管是男人、女人,不管是城里、乡下,不管他有钱、没钱,都吃得起药,看得起病,我要让中国,再也不出现像我娘这样的悲剧!”毛泽东转过头,目光炯炯,“斯咏,你相信会有这么一天吗?”\n迎着他的目光,斯咏犹豫了一下。如此梦幻般的空想显然距现实太过遥远,但她又不忍否定:“也许吧,润之,你那么爱你的母亲,就凭这份爱,我相信你会做到。”\n三 # 晚上,忙了一天的陶会长进了门,伸展了一下的腰身,便倒在了沙发上。一杯茶轻轻端到了他面前,陶会长接过茶,却看到端茶给他的,居然是斯咏。\n“爸,忙了一天,累了吧?”斯咏转到沙发后,给陶会长按摩着肩膀。\n陶会长简直有些受宠若惊了,他扭头看着女儿。\n“怎么了,爸?”\n“没什么,没有什么。你今天……这么有空哦。”\n斯咏没回答他,她按着父亲的肩膀,突然趴到了父亲背后:“爸,我平时是不是很不听话?是不是老让您好烦好烦?老是惹您不高兴?”\n“你怎么……怎么突然说起这些来了?”\n“我只是想知道,想知道有我这样一个女儿,您后不后悔?”\n“后悔?这孩子!说什么傻话呢?”看着斯咏的眼睛,陶会长放下茶杯,也专注起来,“斯咏,不管是什么样的孩子,在父母眼里,永远都是最好最好的,你就是我最好的女儿,有你,爸这一辈子,都高兴,都幸福,都骄傲,你明白吗?”\n搂住了父亲的脖子,斯咏轻声叫着爸爸,心里却回想着毛泽东服侍他妈妈的样子……\n回到自己房间,斯咏铺开那张写着“姨父姨母大人台鉴”的信纸,深深吸了一口气,终于还是提笔写了下去。\n四 # 文七妹出院的前一天,葛健豪买了橘子来看文七妹。葛健豪听说文七妹明天就回去,很是意外。文七妹解释说:“家里事情放不下呀,鸡啊,猪啊,牛啊,都要喂,我老倌子和伢子、妹子又没人做饭。我呀,闲不得,闲了这几天,一身都痛,生就的贱命,没办法。”\n“可病总得看好呀。”\n“我这个病,洋郎中也讲了,就是自己保养,在医院,在家里,都差不多。还是回去好,回去习惯。”\n两位母亲亲切地聊着家常话,聊着他们都引以为自豪的儿子。从窗户看出去,她们正好可以看到毛泽东和蔡和森靠在病房外的走廊栏杆上,隐隐约约听到他们在说着工人夜学。\n“听说,我三伢子也常跑到你屋里去,又吃又住的,给你添好多麻烦吧?”\n“那有什么。润之这孩子,我喜欢。”\n文七妹说:“我听我三伢子讲过,你呀,知书达礼的,读过的书数都数不清,你有本事啊,所以教得那么好的儿子出,年年在学堂里拿前几名,不像我,字都不认得一个,一世人的睁眼瞎子,想教崽伢子,也不会教啊。”\n“不,毛妈妈,您才是最好的母亲。”葛健豪握住了文七妹的手,“过去我一直在想,是什么让润之这么出色,这么优秀,见到您,我才明白,是因为有您这样一个母亲。”\n文七妹憨笑着:“我哪有那个本事?哪有那个本事?”\n第二天,文七妹出院了,从长沙回韶山了,毛泽东在码头送别妈妈和弟弟,心里惦记着自己给妈妈许的那么多诺言,渴望着能有机会一一实现。但这一江秋水,却将母子二人永远隔开了……两年后,文七妹因患淋巴腺结核,病逝于韶山,终年52岁。\n五 # 斯咏的那封“姨父姨母大人亲启”的信在王家果然掀起了轩然大波。王老板怒不可遏地将那封信狠狠地拍在桌上,吩咐被吓得魂不附体的阿秀去把少爷叫回来,今天就去陶家,过彩礼,定日子,尽快完婚!\n子鹏回来后,却不愿意去陶家求亲,他告诉爸爸既然斯咏提出来要退婚就应该尊重人家。王老板回敬道:“尊重?她尊重你了吗?她尊重我们王家了吗?女孩家,居然敢擅自做主退婚,这不是往我们王家脸上抽嘴巴吗?这要放到从前,那就是沉潭浸猪笼的罪过!”\n子鹏又说,现在不是从前了,婚姻是要讲感情的。王夫人马上指着儿子的鼻子教训:“你们表兄表妹,怎么没感情了?就算现在淡一点,等她嫁过来,不自然有了吗?也不知道你这个脑子里天天想了些什么!”\n子鹏直接告诉父母,现在是人家根本就不愿意。王夫人一听这话,差点没跳起来:“那是你表妹一时糊涂!可她糊涂,你不能糊涂啊。陶家什么人家?长沙第一大户!家里又只有你表妹这一个女儿,只要娶过来,什么不是你的?这么简单的道理,还要妈教你?”\n子鹏这才明白,父母逼着自己和斯咏结婚,根本就没有考虑自己的幸福,而是记挂着陶家的家产。他更不想结婚了,又找理由说,也许退婚不仅仅是斯咏的意思,也是陶会长的想法。\n“不可能!”王老板斩钉截铁,“你姨父什么身份?定好的亲事,他敢悔婚?他还要不要这张脸?这就是斯咏整天在外头瞎混,被那些不三不四的学生给带坏了,所以我才要你赶紧求亲,趁早让她退学嫁过来,就什么事都没有了。好了好了,你也不要啰里啰嗦了,赶紧换衣服,上陶家!”\n“我不去!”子鹏看了一眼秀秀,涨红了脸,“这门亲事,我也不愿意,我也要退婚!”\n“混账东西!还敢顶嘴?”“啪”的一声,王老板一个耳光打得子鹏一歪,秀秀吓得赶紧扶住了子鹏。\n“你到底去不去?”\n“我偏不!”捂着被打红了的脸,子鹏猛然昂起头来冲出客厅,向大门跑去。身后,秀秀与王老板夫妇追了出来。\n秀秀在江边追上了子鹏,她走到了子鹏面前,抚摸着子鹏红肿的脸,劝他还是不要与父母作对,赶紧回去。子鹏摇了摇头,很坚决地表示绝不回去。\n“可老爷太太是真发脾气了,再说,您跟表小姐……其实真的很合适,您就听老爷的话吧。”\n“阿秀,你真的希望我跟表小姐结婚?”子鹏抓住了秀秀的手,盯着她的眼睛问。\n秀秀的头不由得低下了,犹豫了一下,还是点了点头:“少爷和表小姐,本来……就是天生的一对嘛。”\n子鹏的目光一下子黯淡了,机械地跟在秀秀的身后,往家里走。一阵江风吹过,子鹏停住了脚步。秀秀见他停住,伸手来拉子鹏的手。猛地,子鹏用力一拉,秀秀,猝不及防,一头扑在子鹏身上,子鹏一把将她紧紧抱住: “阿秀,我不会娶斯咏的,因为我早就在心里发过誓,这辈子除了你,我谁也不娶,不管她是小姐,是公主,是什么大富大贵,都比不上我的阿秀的万分之一!我现在只恨自己过去太胆小、太软弱,我早就应该像斯咏一样,勇敢地追求自己的幸福!”\n“少爷……”\n“不要叫我少爷,叫我子鹏。”\n“子……子鹏。”\n“答应我吧,阿秀,答应我,跟我一起走,走到一个新的,没有人认识我们,没有人能干涉我们的天地,我们结婚,我们永远在一起,快快乐乐的,永远不分开!只要有你跟我在一起,我会比过去活得快乐一千倍、一万倍。答应我,阿秀,答应我,跟我走吧?”\n两个人紧紧拥吻在一起,喜极而泣的眼泪混合着,流满了两张紧贴在一起的脸。\n这一幕被随后赶来的王老板看到,无疑是晴天霹雳。他一声怒呵,身后是那五六个粗壮的男仆马上扑了上来,从子鹏怀里拉走了秀秀,一路拖回王家,扔进了杂屋,用粗大的铜锁锁上了柴房门。\n子鹏经过一番挣扎,头发弄乱了、衣服撕破了、眼镜摔坏了,却最终被两个男仆按倒在了客厅的沙发里。余怒未消的王老板翻出秀秀的卖身契,在子鹏面前使劲地晃着: “看清楚了?啊?自愿卖身!我这可是有凭有据。她刘秀秀是卖给我王家的丫头,愿打愿卖都得由着我。你放心,打,我也懒得再打了,明天我就将她给卖了!”\n“不!”子鹏手脚并用地踢着、抓着,冲着父亲嚎叫。\n“近了我还不卖,上海、香港、南洋,能卖多远我卖多远,包身工也好,给人作妾也好,进窑子当婊子也好,反正这辈子我让你永远看不到她的影子!”\n“不!”子鹏猛地甩开了那两个男仆,一头扑到了一旁的王夫人脚下,“妈,我求求你,我求求你,不能这样,不关阿秀的事啊!”\n王夫人别过脸:“怎么不关她的事?就是这小狐狸精使的坏!看着老实巴交,我还当她是老实孩子呢,暗地里居然勾引我儿子,想当少奶奶了!这种狐媚子,留她干什么?”\n“妈,真的不怪阿秀,是我喜欢她,我喜欢她!是我硬要和她在一起的!”\n“你看看你看看,那小狐狸精使了什么招,把你迷得这么神经的?她是个丫头,是个丫头!你明不明白?”\n子鹏声泪俱下:“我真的喜欢她,妈,爸,我求求你们了,放过她吧!”\n“放过她?放过她你就听话了?”看到儿子不停地点头,王老板回到沙发上,坐下,不紧不慢地理了理衣服,“子鹏,你也别怪我和你妈逼你,你年轻,不懂事,我们也是没办法,以后你会明白,我和你妈这么做,都是为你好。你起来吧,起来呀。”\n王夫人忙不迭地把子鹏扶了起来。\n“你喜欢阿秀,我也没说一定不行,可你不能为了一个丫头耽误了正事。眼前就两条路,一条,你不娶斯咏,结果怎样我已经说过了。另一条,你老老实实,去陶家求亲,至于阿秀嘛,我可以留在家里,好好待她,等你把斯咏娶过了门,要她继续服侍也好,想把她收房做个小也行,我都不拦你。两条路,你自己选吧。”\n子鹏无力地跌坐在椅子上,默认了父亲的安排。\n王家父子俩带着丰厚的礼物衣冠楚楚地到了陶家,一进门就把斯咏的退婚信先给了陶会长。王老板挂着笑容,注意着姐夫的表情,子鹏一身西装革履,木然地坐在他身边。\n“死丫头,简直……简直想把我气死!”陶会长只看了一眼,就哆嗦着,猛地一把捏紧了手里的信。\n“别动气,别动气,姐夫,孩子们还年轻,犯犯糊涂总免不了。这也怪现在那些学校,什么自由啊,个性啊,解放啊,乌七八糟,教得学生不成个体统。斯咏都是受了那些所谓新思想的害,一时糊涂。要说呢,婚姻大事,那是开得玩笑的?斯咏这回,还真是太毛糙了。姐夫,我听说她跟一师范有些男学生常来常往,有些话,外面传起来,不大好听啊。当然了,我是不会往心里去的,可要万一真弄出什么事来,那可是孩子一辈子的事啊!咱们当长辈的,再来后悔不就晚了吗?”\n这话正说到陶会长的隐忧,他不由得点了点头。\n“所以,当断则断,只要马上把斯咏和子鹏的亲事一办,不就什么事都解决了?子鹏也是这个意思。子鹏,跟你岳父表个态啊!”\n子鹏木然地点了一下头。\n陶会长这一次,是在和王老板商量之后,把一切都安排好了,才突然对下午放学回来的斯咏说,要她退学结婚。而且说了这话之后,就吩咐了管家,在王家来接亲之前,不许小姐踏出家门一步!\n斯咏没有想到父亲这次做得如此决绝。但陶家一向宠惯了这个小姐,哪里能看得住她?趁着家里上上下下的丫环仆人都在贴喜字、挂灯笼,斯咏悄悄地跑了。聪明的姑娘直奔码头,问清楚当天晚上11点半就有最近一趟去武汉的船,她果断地掏出钱就要买一张船票。可就在递钱出去的那一瞬间,她犹豫了,突然改变主意,买了两张船票。\n斯咏紧紧攥着两张船票,坐上了黄包车。黄包车的车轮飞转在去一师的路上。\n六 # 黄昏的阳光透进学友会事务室里,给桌前正在看报纸的毛泽东涂上了一身的金黄。开慧蹦跳着进了门,叫了一声“毛大哥”,毛泽东似乎早已习惯了开慧这时候来,头也没怎么抬,只嗯了一声。开慧打量着摆了一桌子的记事本、杂志、球拍、笔墨等杂物,皱起了眉头,她拿起一个本子拍了一下毛泽东的脑袋,笑着骂他是个邋遢鬼,就一间办公室,还一天到晚乱七八糟。\n边说边麻利地把房间收拾整齐了。然后她趴到毛泽东坐着的椅子背上,顺手给他梳理着有些乱的头发,问他又有什么新闻啊?\n“护法军打傅良佐,傅良佐又反攻护法军。老调调,没什么新鲜的。”毛泽东笑笑,放下手里的《民报》,又拿起下面的《大公报》,浏览着主要的标题。猛然间,他腾地坐直了身子,把开慧吓了一跳!\n“出什么大事了?”\n“嘘,”毛泽东止住了开慧的打搅,一口气看完了报道,猛地一拍桌子,“好,好啊,太好了!开慧,你看……”\n开慧接过报纸,读出了下角一篇并不醒目的报道的标题:“《俄罗斯国爆发十月革命,工人武装推翻临时政府》?”\n“太好了!”毛泽东一挥拳头,仿佛指挥起义的是他一样,“你看看人家俄罗斯,工人起来了,武装暴动了,连政权都被他们夺到手了!我一直就在想,不破不立,可就是想不明白什么才是真正的不破不立?人家现在做出来了,打破旧世界,建立新世界,这就是不破不立,这就是新世界的希望!”\n他来回走了一圈,实在无法抑制心中的激动,猛地拉开房门:“开慧,我去找子升,你回去告诉老师,说我们回头就去你家,回头就去!”\n毛泽东和萧子升赶到杨宅书房,发现老师已经在等他们了。\n“这则报道我也看到了。”杨昌济待学生坐下了,也拍着报纸说,“惊世骇俗,的确是惊世骇俗啊!”\n毛泽东一拉身边的萧子升:“所以啊,我马上把子升拉来了。萧菩萨,你看,人民奋起,破旧立新,建立自己的政权,这才是推动世界进步的根本方法!”\n“有你说的这么厉害吗?”\n“你还不相信!你看啊,一个团体,布尔什维克,这是先进组织;广大民众,俄罗斯的工人,这是革命基础。上有团体组织,下有民众基础,所以人家搞成了事嘛!”毛泽东指着报纸,兴奋地阐述着自己的看法,又回头问杨昌济:“老师,您讲讲,像这样的革命,是不是代表了社会前进的方向,是不是给我们指明了打破旧中国、建立新中国的办法?”\n杨昌济沉吟着:“以霹雳手段,摧毁旧世界,看来人家确实是办到了。不过,破旧不等于立新,革命能不能真正成功,不光看革命能破坏什么,更要看它能建立什么。”\n“能破自然能立。工人起来了,民众起来了,还怕建不成人人幸福的大同世界?子升,你说对不对?”\n毛泽东推了子升一下,子升的眼睛却呆呆地望着报纸,兀自陷在震惊中,整个人一动不动。毛泽东察觉出了不对,伸过头来,这才发现就在有关十月革命的报道下面,刊登着一篇几乎同样大小的结婚广告:“王府公子子鹏先生,陶府千金斯咏小姐,定于民国六年十月初四(公历1917年11月18日礼拜天)借圣公理会大教堂举行结婚典礼。执手偕老,琴瑟永合,兹具此函,公之于众。”\n“王子鹏先生,陶府千金斯咏小姐……结婚?!”毛泽东也惊得目瞪口呆!\n就在这时,外面却传来了敲门声,毛泽东走出书房一看,竟然是警予和蔡和森,他们手里,居然也拿着那张报纸。蔡和森见面就说:“我猜你在这儿,果然没错。”\n进了书房,蔡和森先与警予对视了一眼,然后对杨昌济说:“老师,我们,想单独和润之谈谈,可以吗?”\n看看警予与蔡和森严肃的神色,再看看那张报纸,杨昌济站起了身向开慧、子升一挥手,示意二人跟自己出去。\n屋内,蔡和森、向警予直接告诉毛泽东,斯咏失踪了。毛泽东才看到斯咏的结婚启事,听到两人这样说,有点莫名其妙。向警予跟蔡和森轮番轰炸着毛泽东:\n“陶伯伯刚到周南找过斯咏,所以我们也是刚知道的消息。你知道斯咏为什么会失踪吗?斯咏和王子鹏,根本就没有感情,这种强加于人的婚姻,她当然无法接受。可更重要的是,她心里,一直装着另一个梦。”\n“斯咏的梦,也许不切实际,也许只是浪漫的幻觉,但是,就连我们,也常常能从她的目光中,感觉到一点什么,润之,难道你就从来没有感觉到过吗?”\n“我知道,事情往往是当局者迷,往往最后一个知道的人,才是自己。可是今天,斯咏为了抗拒她不需要的婚姻,也为了自己的梦,已经迈出了这一步。润之,不管过去你是不是有过感觉,现在也是你必须明白,必须给出一个答案的时候了。否则,就算找到斯咏,也没有任何意义。”\n一片静默中,毛泽东沉默着,犹豫着。他突然抬起头来,目光清澈,正视着自己的朋友:“不,毛泽东并不是一块木头,我也并不是从来没有过任何感觉的人。这些年来,朝夕相处,志同道合,我,和大家,和你们每一位朋友,也包括斯咏,有过那样多纯真而美好的过去。我记得我们的书生意气,指点江山,我记得我们的激扬文字,坦诚知心,还有我们的同生共死,患难与共。这其中,斯咏给过我许多,许多的友谊,许多的情感。当她不顾自己的生死,那样决然地跟我一起面对危险的时候,当我们并肩遥看湘江岳麓,她就站在我身边时,我不是没有那一刹那的感觉,也许她的心里,不仅仅是友谊那样简单……”\n院子里,杨昌济、向仲熙、子升、开慧面向书房,静静地听着。谁也没有察觉,他们的身后的小院的门口,竟多了一个人。那是提着行李箱的斯咏,她从码头赶到一师、又从一师赶到这里,满怀期待的心在这书房里传出的平静声音中渐渐被击碎了,而且在继续被击碎……\n“可是,我没有,从来没有过超出友谊的想法。在湘江边,在橘子洲头,在我们共同讨论一个属于我们的、更属于未来中国的青年团体的时候,我就提出来过,不谈男女私情。我是真心诚意说这句话的。也许,在别人眼里,这很幼稚,也很奇怪,可我真的是觉得,我们还年轻,我们还只是学生,我们有许多书要读,许多事要做,许多道理要明白,许多路要走。大言之,我们的社会,我们的中国,还有那么多需要改变的事情,而每一件,都值得我们倾注出全部的精力和热情。我不是一个天才,更不是什么超人,也许这一生,我成就不了什么事业,但我愿意倾我所能,为了理想而奋斗,为了中国而奋斗,为了更多的人,得到更多的光明,而牺牲我个人的一切。是,未来还很遥远,理想也只是梦幻,但它毕竟来自每一天,每一步的积累。作为一个学生,我相信,真心求学,实意做事,这才是今天的我们应该做的事,而不是那些只属于个人的卿卿我我,缠缠绵绵。也许正因为我太过理想化,也太过粗心,斯咏心里想的什么,我从来不曾真正去认真揣测过,哪怕偶尔的那一刹那,我也把它当成了我的多心,因为我们是这样风华正茂的一群人,因为我们这帮同学少年,都有着同样崇高的信念,决心以天下为己任,决心为真理而努力终生,我以为,友谊和信念,才是我们之间唯一的、值得信赖的桥梁,我不曾想过其他。”\n听着他真诚的袒露,警予与蔡和森都想弄明白:“可是,感情和理想,和信念,和事业,和你所追求的一切,真的就是矛盾的吗?”\n“不,感情和这一切,也许不矛盾。我虽然没有经历过这种感情,可我相信,感情是双方的,是共通的,是心有灵犀的,斯咏的感情,我体会得是那样肤浅,我对斯咏,更只有纯粹的友谊。那么我们之间,真的存在超出友谊的情感吗?蔡和森,你开始说,斯咏心中所藏的,也许只是一个浪漫的、不切实际的梦幻,我想,这也许是真的,因为如果她真的心中有我,在她的心中,所藏的那个人,也并不是真正的毛泽东,而只是一个被加工过的梦想而已。”\n斯咏似乎已经听到了自己心脏的最后一声破碎声,她微微闭上了眼睛,悄悄地俯下身,把那本《伦理学原理》轻轻放在了门槛边,出了杨宅。\n“爸?”\n她看到面前站着的竟然就是她的爸爸,更远处的巷子口,灯笼一片,马车、仆人们正静静地等候着。她不知道爸爸是怎么找到这里来的,但她知道爸爸一定找得很辛苦。\n“斯咏,回家吧。”\n斯咏呆立着。\n望着女儿的眼睛,陶会长和言细语:“明天是你大喜的日子,还有好多事要准备呢,别在这儿耽搁了,啊。”\n斯咏终于点了点头:“爸,回家吧。”\n她大步、决然地向前走去。\n杨宅院子里的人这时候听到声音都跑了出来,子升、开慧跑在最前面,后面是杨昌济夫妇。\n书房里,毛泽东头一个拉开房门就冲了出去,只看到远远的巷子口,斯咏与陶会长一道,正走向马车。\n“斯咏!”\n远远的,毛泽东的声音传来,正要上马车的斯咏脚步不禁一顿。只犹豫了一下,她继续向马车走去。随后追出的蔡和森与警予却看见了躺在门槛边的那本《伦理学原理》,警予捡了起来,书尚未递到毛泽东手上,夜风掠过,书的封面被吹开,露出了扉页上那句“嘤其鸣矣,求其友声”。\n接过书,毛泽东抬起头来。远远的巷口,斯咏已坐上了马车。马车驶动了,斯咏微微扭过头,但驶动的马车,将她的目光带出了巷口。两张纸片随着马车的背影,随着夜风,轻轻飘去。子升捡起来一看,那正是两张去武汉的船票。\n七 # 白天的小院已经丝毫没有了昨天晚上的喧嚣,但那喧嚣却留在了人的心里。杨昌济看到女儿手拂着兰花叶子,坐在花架前出着神,便静静地看着兰花,没有去打扰女儿的思绪。\n“爸,什么是爱情?”终于,女儿从沉思中醒了过来。\n杨昌济不禁微微愣了一下,回头看看妻子,妻子正站在屋檐下,静静地看着自己和女儿。\n“爱情,就是成年人之间,相互的倾心和爱慕。”\n“那,爱情和理想是矛盾的吗?”\n开慧看到父亲没有马上回答,似乎还在想什么,就自问自答: “你看啊,一个人的想法,其实分不了那么清的,理想、信念、抱负,和感情,不是一刀切开变成几回事,而是混在一起的,什么样的理想,什么样的信念,才会需要什么样的感情。如果两个人对人生、对别的大事追求、想法都不同,其实就不可能有一样的感情。对不对,爸?”\n“你能想到这一层,很不容易。”杨昌济不禁点了点头,“就比如润之吧,作为学生,润之是我见过的最优秀的一个,他的才华、他的倔强、他的冲天豪情、绝世抱负,都是我生平之所未见,能够教出这样一个学生,是爸爸一生最大的幸运。可是,可是他并不见得是一个能给人带来幸福的伴侣啊。他的个性太强了,他太执著、太任性,太像一团烈火,熊熊燃烧,不顾一切!他也许能成就惊天动地的事业,也许能赢得世人莫大的敬仰,但这样飞扬不羁的一个天才,能给爱他的人,带来一份属于自己的温馨、祥和,带来一份平平安安、无忧无虑的日子吗?”\n“那也不一定,爸,蜡烛燃得再久,有的人,也会宁愿选择惊天动地的闪电。”\n听到女儿这样说,杨昌济不禁与静静站在一旁的向仲熙对视了一眼。他理了理开慧额前的刘海,目光中充满了父亲的慈爱:“我的开慧长大了。可最迟发现女儿长大了的人,为什么永远是父亲呢?”\n开慧一笑:“我长大了?”\n“什么是真正的幸福,本来也只在于每个人自己内心的感受。能懂得这个道理的,就不是小孩子。”\n“我也长大了,哈哈。”开慧得意地站了起来,这一刹那,她的脸上挂着的又全是孩子般天真的笑,“这么深奥的道理,可不能只有我一个人知道。我现在就去教给那些应该知道的人。”\n望着她孩子气地蹦蹦跳跳出门,杨昌济不禁摇了摇头,对妻子说:“刚还说她长大了,结果……哈哈。”\n向仲熙看着女儿的背影,停了好几秒钟,这才说:“快了,不都16了吗?”\n江风吹拂,卷动着沙滩上那本《伦理学原理》,那句“嘤其鸣矣,求其友声”不断随着被风卷动的书页闪过。毛泽东的目光迷离在无尽的湘江天际,他的心里,同样如书页翻卷不休:观止轩书店里正选着书的斯咏、大大方方地把书递到了刚刚踢破了布鞋的毛泽东面前的斯咏、湘江边来应征笔友的斯咏、岳麓山上与毛泽东手拉着手忘情奔跑呼啸于大雨中的斯咏、乡村的草坡上与他一道枕着手仰望蓝天的斯咏、寝室里抱起了一堆《梁启超等先生论时局的主张》毅然地站在了他身边的斯咏、橘子洲头与毛泽东同立在岩石上面对壮丽山川展开双臂的斯咏……还有,杨宅院外,马车驶出巷口时留下那最后的令人如此神伤的一瞥的斯咏。\n无数的斯咏在毛泽东脑海里重叠着……突然,一阵脆生生的笑声响起,这笑声是那样突如其来,毫无关联,全无道理,却偏偏来得那么自然,一下子打破了斯咏眼神中无尽的哀怨。\n毛泽东用力地晃了晃头,以为是自己的幻觉,但随即笑声就已经到了他的背后,毛泽东一回头,站在身后的,正是开慧。\n“就知道你在这儿。”开慧蹦到了毛泽东面前,俯身盯着毛泽东的眼睛,“想不想听杨老师跟你讲个道理啊?”\n“怎么,老师也来了?”毛泽东四下看了看。\n开慧一指自己的鼻子:“这个杨老师。”\n双手托着小脸,开慧眼睛都不眨一眨地盯着毛泽东,把刚才讲给爸爸的和从爸爸那里听来的话,一股脑儿全给了毛泽东。\n看着这双清澈见底的眼睛,毛泽东点了点头:“我明白了。一个人的情感,和一个人追求,从来是一回事,斯咏与我走不到一起,只是因为我们是两种人,她梦想她的浪漫,我执著我的责任,我们之间,没有谁亏欠谁。”\n“所以啊,就算斯咏姐真的实现了她的梦,对她,也不见得是幸福。”\n毛泽东长长地舒了一口气,仿佛这一刻,他才终于感到了心中的解脱:“谢谢你,开慧。谢谢你帮我解开了这个心里的结。”\n开慧调皮地要求:“谢谢杨老师!”\n“要得,谢谢杨老师。”\n“哎,这还差不多。”能让毛泽东服一回气,开慧不禁开心得大笑,直笑得躺倒在了草地上。那清脆的、无拘无束的笑声,刹那间充盈在整个江岸边,整条湘江上。这天籁般的、纯真的笑声中,发自内心的、彻底轻松的笑容洋溢在毛泽东迎着阳光的脸上,他问开慧:“你说,那我毛泽东以后,是不是真的能碰上一个知我,懂我,和我一样的理想,一样的信念,也有一样的感情的人啊?”\n“你很难懂吗,我怎么不觉得?讲得自己好像好了不起,也不羞!”\n第二十九章 男儿蔚为万夫雄 # 夜幕下的一师,孔昭绶已经接到了猴子石传来的捷报,他打开《第一师范校志》,奋笔如飞地记载下了这次事件。意犹未尽,他又郑重地落下了这样一句话:“ 全校师生皆曰:毛泽东通身是胆。”\n一 # 陶家门口,大红灯笼高高挂起,鲜红的喜字对贴门上,忙碌的仆役披红戴彩,合府上下,一派喜气洋洋。斯咏房间里,画眉如烟,点唇似绛,换上了婚纱的斯咏面无表情地化着妆。那张秀美的脸,被描画得如此精致,偏偏却毫无生机,仿佛一张没有生命的假面。丫环推开了门,报告说该上教堂了。镜子前的新娘站起身来,捧起桌上一束鲜花,却突然看见了花下周南的校徽。她的手指轻轻一拨,校徽落进抽屉,抽屉关上了。\n王家,两个丫环为子鹏穿上了崭新的燕尾服。雪白的衬衣,精致的领结,闪亮的皮鞋,一丝不苟的头发……但子鹏却如同一具木偶。\n一尊巨大无比的豪华结婚蛋糕推到了客厅正中央,王老板夫妇打量着蛋糕,乐得嘴都合不上了。子鹏面无表情地走到了蛋糕前,看到了蛋糕旁的托盘里,有一柄扎着红丝带的餐刀。\n“瞧瞧,瞧瞧,满长沙城,谁家办喜事弄出过这么大的西洋蛋糕啊!子鹏,这回该满意了吧?”\n王太太还在唠叨,王老板看看时间,吩咐子鹏该上教堂了。子鹏却突然说他要见阿秀,不让他见她,他绝不去教堂。\n他转身就走,王老板夫妇慌了,赶紧追去。托盘里,红丝带还在,那柄刀却不见了。\n“哐啷”一声,杂屋的门开了。子鹏冲上前去,和蜷缩在墙角的秀秀紧紧拥抱在了一起。杂屋外王老板向看守的王福一使眼色,王福会意,咔嚓锁上房门,守住了门口。\n捧起秀秀带着伤痕的脸,子鹏已是泪流满面。秀秀同样流着泪,却努力露出了一丝微笑:“子鹏,别这样,我没事的,真的没事。”她擦了擦子鹏脸上的泪:“一会儿你还得去教堂,把眼泪擦擦吧,别让人看见了。”\n“我不会去教堂的,我不会跟别人结婚。”\n“子鹏,不要这样,我不怪你,真的。我没读过书,不会讲道理,我只知道,好久好久以来,子鹏少爷就是我心里的一个梦,我从没想到你会真的喜欢我,你会真心真意地爱过我,在梦里,我已经什么都得到了,我已经好满足好满足了。人,不能要得太多,有了梦里的,就不应该再想着真的了。”抚着子鹏的脸,秀秀含着微笑,“记着这个梦吧,子鹏,记着这个梦,就什么都够了。”\n“不,阿秀,它不是梦,我也不能把它当成梦。就算真的是梦,我也绝不让人毁了它!”子鹏缓缓地从袖子里,突然拔出了那柄餐刀。\n秀秀大惊:“少爷!”\n子鹏赶紧捂住了她的嘴:“阿秀,生不能相守,就让我们死在一起吧。”\n秀秀吓得慌了手脚:“不,子鹏,不,你不能这样,你不值得为我……”\n“值!值得!只要这一刀下去,那就谁也挡不住我们在一起,谁也不能阻止我们永远相依相伴。让我们永远在一起吧,阿秀。”\n两只几乎同样纤秀、白净的手腕紧靠在了一起。餐刀架在了两只手腕上。紧紧依偎在一起,两个决心殉情的人目光都是那样平静,充满了幸福的满足。刀微微一提,就要往下切……“砰!砰!砰!”突如其来的乱枪声惊得两个人同时抬起头来。\n二 # 1917年11月18日,北洋系军阀、湖南督军傅良佐在护法战争中被护法军程潜部(湘军)击溃,所部溃兵三千余人败往长沙,已经到了城南距离一师不远的猴子石。整个长沙城,陷入一片恐慌与混乱中,大街小巷,到处是拥挤不堪的骡马车轿,男男女女扶老携幼,扛着行李,争先恐后,夺路而逃。王陶两家正在进行的婚礼也被打断了。\n趁着父母在收拾细软,子鹏与秀秀趁机逃出后门,融进了逃难的人群。\n陶家,斯咏也脱掉了雪白的婚纱,却逆着逃难的人流艰难地往一师跑。\n第一师范校园里,此时铃声大作,学生们正跑向操场集合。孔昭绶见方维夏匆匆跑来,焦急地问:“维夏,怎么集合得那么慢?”\n“今天是礼拜天,老师们都放假了,人手不够啊!”\n“人手不够,也不能漏掉一个学生!”孔昭绶一咬牙:“我这边,你那边,一间一间寝室挨个喊!”\n两个人刚要出发,身后,传来了熟悉的声音:“昭绶兄。”\n孔昭绶、方维夏一回头,是气喘吁吁的杨昌济,他的身后,是满头大汗的袁吉六与徐特立。更后面,校门口,饶伯斯、费尔廉、黄澍涛、易培基、王立庵、雷明亮……一个个老师正匆匆跑来。望着老师们一张张脸,孔昭绶眼眶蓦然潮湿了,他用力一点头:“快,分头集合学生!”\n一师操场上,全体师生已集合完毕,子升、开慧、警予、蔡畅等读书会的会员因为在蔡和森寝室讨论,也一起都跑了过来。各班正在清点人数:“报告,本科十班集合完毕,全部到齐。”“报告,本科十五班集合完毕,全部到齐。”“讲习班全部到齐。”“本科六班全部到齐。”……“报告,”周世钊最后一个跑上前,“本科八班集合完毕,缺席二人。王子鹏和毛泽东。”\n孔昭绶看看杨昌济,对方维夏说:“先顾大家,赶紧宣布吧。”\n方维夏点点头,站上了中央的一张椅子,高声说:“同学们,目前的情况,大家可能都听说了,北洋军几千溃兵已经到了南面的猴子石,离我们只有一步之遥,长沙城将面临一场严重的兵祸!为了全校师生的安全,学校决定,全体师生马上撤离,集体到城东阿弥岭暂避兵祸。请大家迅速做好准备,保持秩序,五分钟后,全校出发……”\n“不,不能走!”这个时候,毛泽东风风火火,正跑进操场,全然忘记了自己只是一名学生,打断了方维夏的讲话,“老师,我们不能走!”\n杨昌济和孔昭绶看到毛泽东过来,焦急而责备地问道:“润之?你上哪去了?”\n“猴子石。”毛泽东喘着气,对两位老师说,“刚去的,我已经摸过了溃兵的情况,我认为,现在不是我们逃走的时候!唯今之计,只有主动进攻,方可保住学校,保住长沙城。”\n毛泽东面对老师和同学们,急切而大胆地提出了自己的分析、对策:虽然溃兵有几千人,但人多人少不是关键。傅良佐这个湖南督军,本来就是临时当上的,现在打了这么大的败仗,一路败逃,连傅良佐自己都跑得没影了,扔下这帮手下,群龙无首,完全是溃不成军,不要讲军队应有的士气,根据他刚到猴子石去看到的状况,那些溃兵已经连基本的建制都被打散了,完全就是帮散兵游勇,无头苍蝇,这样的军队,人再多,也不可能有什么战斗力。他们之所以敢来长沙城,就因为手里还有几千条枪。但仔细想想,他们跑来长沙干什么呢?不外乎想趁机抢一把,捞一笔。可是一两个钟头前他们就到了长沙城外,为什么到现在还没进城,还呆在猴子石不敢动?因为他们不知道城里的虚实!傅良佐的这支兵是被护法军里的湘军程潜部击溃后,从湘潭一线,由南往北败往长沙的,护法军的广西桂军呢?则是从湘西经常德,由西往东向长沙进攻,而且前天已经打过了益阳。也就是说,这支溃兵不可能知道从西而来的桂军现在的进展,他们之所以缩在城外不敢动,正是因为按时间来算,桂军完全有可能比他们先到长沙,他们怕的,也正是比他们多出好几倍的桂军在城里等着他们!这种兵败如山倒的军队,真打仗是绝对不敢打的,对他们来说,保命才是第一。但是,如果时间拖下去,城里没有动作,那就等于告诉他们,桂军还没到,长沙是一座空城。到那个时候,他们的胆子就会大起来,就会明白长沙城是他们面前的一盘菜,可以任他们宰割。这帮打了败仗的兵现在已经不是军队,而是强盗了!真要让他们一窝蜂拥进城,几十万人的长沙城,就会马上变成人间地狱!所以,现在最关键的是时间!只有抢在他们摸清虚实之前,打他们个措手不及,长沙城才能得救!\n所有的老师都不由得点了点头。溃兵进城会造成何其严重的后果,大家当然都估计得到。可长沙原来就是傅良佐的地盘,他自己跑了,又没有别的人马守城,这一时半会儿,上哪去找一支军队呢?\n毛泽东望着眼前的同学们,自信地说:“我们一师就有,一师学生志愿军!虽然一师只有两百学生,连一支真枪都没有,可猴子石四面是山,我们完全可以凭借地形,虚张声势,那帮吓破了胆的溃兵不可能摸清我们真正的实力。至于枪,也不是完全没有办法,警察所就有嘛,他们也是长沙的警察,为了长沙城,应该会跟我们一起干。”\n几个老师互相看了看,的确,这个主意虽然有理,但里头包藏的巨大风险实在令大家难以决断。\n毛泽东看出了老师们的犹豫,也看到了操场上同学们的群情激奋,他站到同学们前面,豪迈地说:“校长,诸位先生,我也知道,这样做有风险,可我们一师操练学生军是为了什么?不就是为了培养为国为民流血牺牲的尚武精神吗?事有轻重大小,君子有所不为亦必有所为,比起长沙城三十万老百姓,我们两百人算什么?当此全城民众安危之际,我们不挺身而出,谁挺身而出?各位老师,你们终生教授学生,想培养的,不正是敢于舍生取义、敢于临危向前的堂堂万夫之雄吗?”\n一番话震撼着每一位老师的心灵,也震撼着每一个同学的心灵。\n毛泽东的建议被采纳了,学生军的成员们换上了军装、扛起了木枪,大战将临,整个校园充满了紧张而有条不紊的气氛,每一张年轻的脸,都是那样无所畏惧,带着年轻人兴奋、紧张而又刻意保持的平静。\n几个学生军骨干正与毛泽东在一起,分派学生军的任务。一旁的子升走上前来,问有什么需要他帮忙。毛泽东笑了,他一拍子升的肩膀,刚要开口,却看到满头大汗、长发飘乱的斯咏气喘吁吁地跑来了。看到她这个时候跑来,毛泽东不由得想起了当刘俊卿带着特务来学校搜查“逆书”的时候,斯咏坚决地抱着书坐在他的床上、要和他同进退共存亡的举动,心里一热,对斯咏说:“你来得太好了,正有好多事情要你和警予、开慧做呢。”\n三 # 当蔡和森、张昆弟按照毛泽东的安排来到警察局救助时,最先响应他们的是那个曾经帮他们贴工人夜校招生广告的郭亮。但当郭亮带上枪要和学生们一起出门的时候,警目却一步拦在了众青年警察的前面,命令道:“都给我站住!想去干什么?你知道外头有多少兵?好几千!凭咱们这几十号人,十来条枪,想跟几千人对着干?你活腻了,弟兄们还没活腻呢!都给我把枪放下,听到没有?这是命令!”\n警察们无奈地将十来条步枪统统扔进了枪柜。咔嚓一声,警目把枪柜门锁上了,将钥匙往腰上一挂,拉过椅子,横坐在大门前。青年警察们互相看着,大家显然都窝了一肚子火,却是谁也不敢做声。郭亮又是气愤,又是羞愧,却毫无办法。\n看到蔡和森跟张昆弟无功而返,毛泽东似乎不意外,他沉着地说: “没枪就没枪!没枪,老子变也要变出一堆来!” 他吩咐萧三带人把七八个铁皮洋油桶和十几捆大大小小的鞭炮堆到学校门口,又吩咐罗学瓒收集起同学们扎的火把,准备运往猴子石。\n一旁,子升望着那堆鞭炮、洋油桶,不无担心:“润之,这些东西能管用吗?”\n“管不管用,试试就知道了。”毛泽东揪下一小截鞭炮,点燃,往洋油桶里一扔……\n猴子石的一片晒谷场上,一堆一堆熊熊燃烧的大火旁,军帽、绑腿散落一地,到处是乱糟糟的披着抢来的花花绿绿的被子、棉袄的北洋溃兵,他们正把砸坏的门板、桌椅、箱柜杂物纷纷扔进了燃着的火堆,火上架着瓦罐、铁锅,毛都没拔的死鸡被穿在刺刀上,直接伸进了火中……\n一身小军官打扮的马疤子从烧开的瓦罐里倒出一碗水,优哉游哉,哼着花鼓调子,边喝水边踱着步子,登上了晒谷场旁一块石头,眺望着远处对身边的刘俊卿说:“老二,我说过吧,总有一天,我马疤子还会杀回这长沙城的。”\n“回来又怎么样?都这副德性了,回来还不是丢人现眼?”看来虽然当了兵,但做了小文书的刘俊卿还是以前那副文弱的样子。\n“你错了,老弟。就是这样回来最好!天下大乱,越乱越好,越乱油水越多。”马疤子眺望长沙城,自言自语道,“长沙城啊长沙城,你就等着你马爷来慢慢收拾你吧。”\n晒谷场边的一家民居前,摆了几张桌椅,几个军官正大眼瞪着小眼地商议下一步怎么行动。因为不敢肯定长沙城里到底有没有桂军,在是否立即攻打长沙这个问题上,他们分成了势均力敌的两派,各不相让。其中军衔最高的一个团长最后决定,派两个人先去城里探个虚实。\n当然,这两个人最好就是长沙本地人。于是,马疤子和刘俊卿被毫无争议地选中了。团长掏出怀表看了一眼时间:“给你们两个钟头,快去快回,我们在这儿等消息。”\n马疤子和刘俊卿做梦都没想到,他们会以这样的方式回到长沙!换上从农民家里抢来的布衣短褂和破草帽,他们来到一片混乱的长沙街头时,他们也成了逃难人群里的一分子。正眯缝着眼满街乱串,突然,在一个小巷的岔路口,他们听到几声“枪”响,愣了瞬间之后,马疤子带着刘俊卿就往传来枪声的地点跑去。\n就在这附近,子鹏与秀秀也听到了这几声“枪”响。秀秀迟疑了一下,对子鹏说:“好像是你们学校那边……”子鹏二话不说,拉上秀秀就朝一师方向跑去。\n一师对面的小巷口墙角里,马疤子望着一师门口的学生、鞭炮和洋油桶,阴森森地笑了。马疤子一拍身边的刘俊卿说:“一帮学生崽子,还真他妈敢玩花样。老二,要不是亲眼看见,咱们说不定还真让他们给蒙了。”\n盯着一师熟悉的欧式教学楼,刘俊卿没有作声。马疤子站起身来:“还愣着干什么?回去搬兵吧。”\n刘俊卿心不在焉地问:“你真的想回去报告?”\n“那当然了,不然我们来干嘛?”\n“可是,这几千人真要进了长沙,长沙城就完了。他们是北方兵,咱们可都是长沙人啊。”刘俊卿还在迟疑着。\n“哟,看不出你还长出良心来了?本乡本土的,下不了手了?”马疤子挖苦刘俊卿道,“你他妈有病啊?你当你还是长沙人,长沙有谁把你当过人呀?你可别忘了,就是这座长沙城,就是这些长沙人,逼得你刘俊卿和我马疤子走投无路,才滚出城吃粮当的兵!你跟他们讲客气,谁跟你讲客气?啊?不信是吧?不信你摘了帽子走出去试试,你看看你那些老同学有哪一个会不把你当成一条狗?一条狗!”\n往昔的屈辱、仇恨蓦然充满了刘俊卿的眼睛,盯着一师,盯着门前的旧同学,他腾地站了起来:“走,回猴子石。”\n马疤子一拍他的肩膀:“这他妈才对了!等长沙城血流成了河,那才是你我的天下!”\n两个人转身向巷子里拐去,迎面,却正看到子鹏和秀秀贴着墙站在角落里。望着刘俊卿与马疤子,子鹏与秀秀带着巨大的、仿佛不敢相信的恐惧退缩着。显然他们刚才听见了马疤子和刘俊卿的对话。刷的一声,马疤子拔出了腰间的匕首,目光中杀气顿起!只犹豫了一秒钟,子鹏猛地将秀秀往身后一推,边拔出了那柄餐刀边高声喊叫:“快来人啊!抓坏人啊!”\n子鹏的呼救声在小巷子里回荡,一直传到了巷子对面……\n马疤子一手抓住了子鹏持刀的手腕,用力往墙上一撞,挥刀就刺。子鹏的餐刀落在地上,他拼命托住马疤子的手,但力不从心,马疤子的刀一点点向他的胸口压了下来。秀秀疯了似的扑上来,抱住了马疤子的手,拼命往上扳,合二人之力,马疤子的刀刺不下去了。\n“老二,你他妈愣着干嘛,还不快动手!”马疤子回头对刘俊卿叫道。\n秀秀不顾一切地用身体护着子鹏,也对刘俊卿叫道:“哥,不要啊!”\n刘俊卿几乎是下意识地捡起了那柄餐刀。然而,迎着子鹏与秀秀的目光,刘俊卿举着刀的手却剧烈地颤抖着,怎么也刺不下去。\n“不许动……别动……”随着一片怒吼,毛泽东等几十名学生军抄着木枪,冲进了小巷!\n当啷两声,马疤子与刘俊卿手中的刀颓然跌落在地……\n回到学校,子鹏换上学生军军装,想和毛泽东他们一起去打仗。可毛泽东却一指被反绑了双手蹲在地上的马疤子和刘俊卿,给了他一个同样艰巨的任务:“把这两个俘虏押到学友会办公室,由你负责看管。”\n看到子鹏失望的样子,蔡和森告诉他:“俘虏也要人看嘛,这也是重要任务,要让他们跑了,我们那边的戏可就没法唱了。”\n子鹏这才高兴地接受了任务。\n四 # 黄昏初起的薄暮中,猴子石的晒谷场上,散乱的满地溃兵东一支西一支点燃了火把,在火把忽闪忽闪的映照下,团长皱着眉头、吃力地辨认着怀表上的时间,嘴里不干不净地骂道:“妈拉个巴子,这两个混蛋,到现在还没消息,搞什么名堂?”\n一个上年纪的军官斜着眼睛说:“不会是给桂军抓去了吧?”\n另一个年轻军官骂道:“你他妈哪只眼睛看见桂军了?这么久了,鬼影子都没晃出来一个,要我说,现在就进城,冲进去,什么都有了!”\n“人不能拿命开玩笑。”上年纪的军官看来要稳重一些。\n“屁!真他妈有几万桂军在城里,能到现在一声枪响都听不见?你以为他桂军拿的是烧火棍啊?”年轻军官伸手拔出手枪,冲附近的散兵们吆喝着,“弟兄们,够胆儿的都给我起来,进城发财去!”\n不等四周的兵站直,仿佛是为了嘲笑他的嚣张,“砰”的一声“枪”响骤然传来,几个军官吓得全身一弹,随即便听到晒谷场三面的山坡上枪声阵阵。“我们被包围了!我们被包围了!”晒谷场上顿时风声鹤唳、乱成一团。\n此时在晒谷场东面的山头上,张昆弟、陈绍休正指挥着一部分学生军,将一串点燃的鞭炮扔进洋油桶;在晒谷场西面的山头上,罗学瓒、李维汉带领的学生军放的鞭炮声同样热烈;而在晒谷场北面的小山坡后,萧三点着了捆成一团的十来颗大雷鸣炮,倒转洋油桶盖住,一屁股坐在桶上,只听轰的一声闷响,一旁的蔡和森配合着他制造的“炮”声,点燃了从鞭炮里拆出的一堆火药,一大团硝烟腾空而起—— “枪”声骤停。\n毛泽东对着土喇叭喊道:“傅良佐部的官兵听着,我们是桂军。”\n张昆弟、陈绍休指挥着学生军喊道:“你们被包围了。”\n罗学瓒、李维汉等指挥的学生军喊道:“缴枪活命,赶紧投降!”\n喊声中, 斯咏、警予、开慧、蔡畅点燃了一支支火把,火把不断传递到男生们手上。一时间,漫山遍野,四面八方,喊杀四起,互相呼应。群山回荡,喊杀声与回音层层重叠,回旋不绝,四面看去,暮色中,但见点点火光逐渐亮成了一片,一时间,数千溃兵仿佛陷入了千军万马的重重包围之中!\n“团长,怎么办,怎么办啊……”趴在破墙后,几个军官慌成了一团。\n“奶奶的,还能怎么办?冲出去!”那个年轻军官拔出手枪。\n团长制止他说:“你他妈没长耳朵啊?听听,到处都是他们的人,往哪冲?想让弟兄们送死啊?”\n“不行……不行就投降吧?这好汉他不吃眼前亏嘛。”上年纪的军官提议说。\n团长觉得这主意不错,探出头来,扯着嗓子朝对面山上吆喝:“对面的桂军弟兄,别开枪,有事好商量。”\n对面山坡后,影影绰绰的火光、人影中,传来了毛泽东的声音:“只要你们缴枪投降,什么都好商量。”\n团长狡猾地要求:“口说无凭,你们得派代表过来谈判,当面答应我们的要求,不然弟兄们不放心。”\n两支火把熊熊燃烧,照亮了山坡前的路,也照亮了三个年轻的身影:两袭长衫是飘逸的毛泽东与萧子升,一身学生装,是沉静的蔡和森。一到晒谷场,几十支枪口呼啦一下,就对准了他们。毛泽东的脸上,浮起了一丝嘲讽的微笑,仿佛在耻笑对方的小题大做。\n“干什么干什么?都他妈把枪放下!”团长眼睛一瞪,换上笑容,抱拳迎了上来打招呼,“几位,有劳有劳。”\n毛泽东大咧咧地瞟了他一眼,问:“你是谁?”\n“兄弟傅良佐督军麾下王汝南师第三团团长,哦,这儿也就兄弟军衔最高,几位是……”\n子升一一介绍着:“这位是桂军谭浩明司令麾下毛副官,这位是长沙市政府蔡秘书,在下姓萧,是长沙商会的代表。受谭司令和长沙各界委托,我们三人负责今天的谈判。”\n“欢迎,欢迎。”团长一脸夸张的笑容,热烈地三人握着手,眼睛却不住地上下打量着三个实在过于年轻的对手。\n其他几个军官的目光也都集中毛泽东的身上,他的年轻与一身便装首先已令他们露出了一丝怀疑之色。那个五大三粗的年轻军官看了同伴们一眼,上前一步,一把握住毛泽东的手:“谭司令的副官好年轻啊。”\n毛泽东淡淡地:“傅督军的手下也不老嘛。”\n年轻军官笑了,手上却突然一紧,狠狠捏了下去。毛泽东微笑着,同样加了把劲。两张笑脸下,两只手无声地、却是狠狠地较开了劲,那个年轻军官的笑容突然僵住,他似乎想极力撑住,但手上巨大的疼痛却令他忍不住嘴角直抽,整张笑脸一阵扭曲,变得滑稽可笑。几个军官盯着二人的较量,原本带着的几分轻视不由得一扫而光。占尽了上风的毛泽东慢慢松开了手,那名年轻军官如蒙大赦,捂着手倒退出了好几步。\n团长赶紧打着圆场:“几位,别站在这儿啊,那边请,那边请。”\n坐在晒谷场的民居前,双方谈判正式开始。\n“局势摆在眼前,”方木桌前,蔡和森正侃侃而谈,“贵部如今已被团团包围,真要打,结局如何,团座及列位心里想必都有数。但不论战局如何,我长沙各界只有一个心愿,不希望看到仗在长沙城边打起来,殃及我千年古城之无辜官民等。因此,只要贵部能深明大义,化干戈为玉帛,护法军谭司令保证,决不伤贵部兄弟一人。毛副官,是这个意思吧。”\n毛泽东端起了桌上的粗瓷茶碗,看也不看对面的军官们:“缴枪活命,就是这句话。”\n蔡和森继续说:“只要贵部放下武器,尽可自由回乡,一切资遣靡费,均由我长沙商界承担。这一点,商会的萧代表也可以保证。”\n子升点点头,慢口答应:“只要不在长沙打仗,钱的事,都好说,都好说。”\n团长与几个军官互相看了看,回话道:“这其他的嘛,也还好说,就是这缴枪,没有必要吧?要不这样,桂军先撤围,放弟兄们一条活路,我们保证掉头就走,绝不回头再踏进长沙一步,好不好?”\n“双方各撤一步?亏团座想得出来啊。”毛泽东哈哈大笑。\n团长小心翼翼地问:“那毛副官的意思是?”\n子升赶紧向毛泽东使眼色,但毛泽东全不理睬,反而更加趾高气扬:“我军两万弟兄已将你们重重包围,占尽天时地利,能来跟你谈判,就是给你们面子。留枪不留人,留人不留枪,你们自己看着办!”\n那个年轻军官先嚷了起来:“要是我们不交枪呢?”\n“乒”的一声,毛泽东把喝干了水的瓷碗一放:“你试试!”\n上年纪的军官赶紧打圆场:“谈判嘛,谈判嘛,何必动怒,何必动怒呢?都坐,都坐。”\n他提起桌上的农家粗瓷茶壶,殷勤地给毛泽东续着水。毛泽东昂着头,四平八稳坐下了。团长向几个军官一使眼色,几个人随他退往一边角落。\n子升再也忍不住了,他站起身,笑着:“毛副官,蔡秘书,借一步说话好吗?”\n他们三人也起身,退向另一边角落。看看四周,子升压低了声音:“润之,你这干什么?他们能退出长沙,问题不就解决了吗?何必非逼他们缴枪?”\n“你怎么这么糊涂?两万人围住三千人,倒平白无故放他们把枪带走,这可能吗?傻瓜也不会信啊。这种时候,你就得压他一头。真要让他们带走枪,走不出几里路他们肯定会明白,我们这边是个空架子,所以不敢拿他们怎么样。到时候一个回马枪,那才是真的收不了场!”\n民居另一边角落里,那个年轻军官一脸的不服气:“要我说,这枪不能交,缴了枪,咱们弟兄还能剩什么?那不成了人家板上的肉?”\n“咱们现在已经是板上的肉了。换了是你是我,三千条枪摆在眼前,能不要吗?他要真不要,那倒是不对头了。”上年纪的军官分析得头头是道。\n团长一锤定音:“没错,他要真放咱们带枪走,就证明来的桂军不多,可能只是先头部队,他们是在吓唬人。要真是一步也不退,非全交枪不可,那才证明人家一口就能吞了咱们。真要那样,咱们也只有交枪保命了。”\n重新回到谈判桌前,火把依然通明,映照着相对而坐的两方代表。团长试探着问:“弟兄们商量的意思,留下三百条枪,就当给桂军的见面礼,你看怎么样?”\n毛泽东看也不看他,强硬地回答:“不行!”\n“那,五百条?”\n“不行!”\n“我再退一步,带一半,留一半,这总可以了吧?”\n“谭司令的命令,一颗子弹也不能留。这事,不用再商量了!”\n“毛副官这样说,那就不好谈了。”团长说着,向几个军官使了个眼色。\n年轻军官会意,头一个横起了眼睛:“带一半,留一半,最多这样!”\n上年纪的军官:“对对对,弟兄们总还要防防身吧?”\n另一个军官口气更凶:“老子本来就不想交,妈的,了不起一拍两散!”\n毛泽东:“是吗?”\n“没错,怎么样?”\n蔡和森向子升使了个眼色:“要是枪的事一时不好谈,那就先谈谈遣散费的事吧?”\n子升马上接口:“我们商会的意思,只要贵部弟兄离开长沙,路费嘛,士兵每人七块大洋,班排长以上,每人二十块,连长五十,各位觉得如何?”\n上了年纪的军官脱口问道:“那我们营长呢?”话刚出口,他已经意识到自己失言,看看周围几个军官责怪的眼神,他尴尬地坐了下去。\n蔡和森与子升换了个心照不宣的眼神,子升居高临下地回答:“营长一百,团长二百。”\n几个军官互相看了一眼,事到如今,大家显然都无心再强撑下去了。\n团长长叹了口气:“毛副官,弟兄们要真交了枪,谭司令可要保证绝不为难咱们啊。”\n带着胜利的姿态,毛泽东昂起了头,大模大样地命令道:“开始吧。”\n“是,是。”团长转向士兵们,高声宣布,“都给我听着,把枪放到这边,放完枪,退后一百米外,等候命令!第一排,出列。”\n第一排士兵乱糟糟上前,把几十支步枪和子弹、刺刀等扔在了地上。熊熊火堆,映照着一排排交枪的士兵脚步……\n悄悄地,子升舒了一口长气。毛泽东泰然自若,端起茶碗喝着水。\n五 # 长沙城里,入夜后的整条整条大街上,全是惊慌的人群,有人被挤倒,亲人拼命地拦着,仍挡不住混乱的脚步践踏。大人叫,孩子哭,乱作一团。混乱声、哭喊声传入警察所,几个青年警察面露愧色,郭亮更是来回焦躁地走动着。终于忍不住了,他一步冲到警目面前,叫道:“长官,长官!你听听,外面现在乱成什么样了?全长沙城的老百姓都在逃命,街上都乱成了一锅粥!多少人都在等着我们这些警察保护?可我们呢,还干坐在这儿!我们是警察,是警察啊!长官!民国的警察条例是怎么写的,你自己平时是怎么要求我们的?警察就要为民当差,警察就要保护民众!现在是谁在保护民众?不是我们,是第一师范的那些手无寸铁的学生!”\n“不要再说了!”警目一拳砸在桌子上!仿佛是为了平静或者是掩饰一下心情,他掏出一支烟,然而,划火柴的手却不住地颤抖着,接连几下,也没能划燃,此刻,警目的心情,显然也在受着剧烈的煎熬。响声中,警目刚刚划燃火柴的手一顿,反烫着了自己。\n郭亮似乎豁出去了,他猛地站了起来,三两下解开警服上面的扣子,一把将警服脱下、将警帽被狠狠摔在桌上。\n“长官,这个警察,我不干了!”郭亮转身冲着警察们,“弟兄们,外面,是我们长沙城的父老乡亲,是跟我们的父母、我们的兄弟姐妹一样的长沙老百姓!为了长沙城,为了我们的父老乡亲,是条汉子的,跟我走!”\n几个青年警察一齐站了起来,纷纷脱了警服,跟着郭亮就要往外冲。\n“都给我站住!”警目猛地站了起来,微微停了一停,把一串钥匙扔在桌上说,“想空着手去送死吗?枪柜里有十条枪,有枪的,上猴子石,去帮学生军,没枪的,全体上街,维持秩序!”\n“弟兄们,走!”脚步匆匆,郭亮带着九名扛枪的警察奔向猴子石。\n而在他们前面,还有一个人也正连滚带爬地往猴子石跑,这个人就是才从一师逃跑出来的马疤子。\n一个多小时之前,在学友会事务室里,子鹏原本很严密地监视着被反绑着双手的马疤子和刘俊卿,他们两个人正席地坐在墙角。\n秀秀看到刘俊卿又饥又渴的样子,就从外面端着一碗水拿了两只麦饼进来,看看刘俊卿反绑的双手,她犹豫了一下,还是蹲了下来,将水碗送到了刘俊卿嘴边。兄妹二人的目光微一接触,秀秀转开了目光。子鹏看到了,心里酸酸的,想起以前和刘俊卿同学的日子,动情地说:“俊卿,喝点吧。”\n犹豫了一下,刘俊卿凑上去,一口一口喝起了水。马疤子看见了,也想喝,被子鹏打了一枪托之后,才安分了。\n放下水碗,秀秀又拿起了一块麦饼,递到刘俊卿嘴边。刘俊卿却摇了摇头,目光一直盯着妹妹。秀秀站起身,正要走,身后突然传来了刘俊卿的声音:“阿秀,你脸上、手上是怎么了?是不是王家打你了,啊?”\n秀秀的身子猛地一震,她这才明白刘俊卿刚才不吃麦饼,是因为看见了她头上、手上那些早已褪得很淡的伤痕。一刹那,眼泪蓦然一下渗出了她的眼眶。她突然转身,在刘俊卿身边蹲下了:“哥,我没事,我……我有件事要告诉你。”\n她突然带起了羞涩的样子让刘俊卿有些奇怪:“什么事啊?”\n“我和子鹏……我和子鹏要在一起。”\n刘俊卿一下没听明白:“你和子鹏?”\n因为害羞、也因为不知道怎么才能说清楚,秀秀犹豫着、羞怯地看了看子鹏。子鹏对秀秀笑笑,鼓起勇气对刘俊卿说:“我要娶阿秀,我跟阿秀说好了,我们要结婚。”\n“哥,你……你同意吗?”秀秀望着刘俊卿,眼神里充满了渴望。\n“我同意吗?”刘俊卿愣了一下,妹妹的话让真切地感觉到他们兄妹间似乎又回到了很久很久之前,那时候多美好啊,妹妹无论遇到了什么事情都要哥哥拿主意。他醒悟过来,一直拒绝承认他的秀秀此刻是在把他当成唯一的亲人,当成家长,请求他对婚事的支持!巨大的激动、巨大的喜悦骤然冲击着刘俊卿的心,一刹那,他激动得全身都禁不住在发抖,狠狠地、狠狠地点着头,连声音都哽咽了:“阿秀,我同意,我同意,我同意!”\n刘俊卿说着,一头埋进了秀秀怀里,泣不成声。望着这一幕,子鹏的泪也忍不住了。而一旁的马疤子却狠狠地往地上啐了一口,唾沫落地的时候,马疤子看到了那只放在地上的瓷碗。\n“哥是个混蛋,是个人渣!”刘俊卿剧烈地抽泣着,“哥不配,不配你叫一声哥……”\n秀秀为刘俊卿擦着满脸的泪水:“哥,别这样,我知道,我知道你其实一直在后悔,知道你一直想为我好。哥,以前的事,都过去了,不管做过什么,你可以重新再来,我要你重新再来。”\n“不,我不行,我没有机会的……”刘俊卿使劲地摇着头。\n“俊卿,你有机会,我可以去求我姨父,求他原谅上次的事,求他不再追究你,他会答应我的。只要你肯改,就没有什么是不能弥补,没有什么是不能回头的。”\n“哥,子鹏说得对,你那么聪明,那么会读书,只要你肯改,有什么做不到?到时候,我和子鹏来想办法,想办法供你上学,供你重新读书,好不好?你以前不是说过吗,你天生就是读书的人,你还那么年轻,又那么聪明,会有好多好多学校抢着要你,你会读出出息的。”\n“阿秀……”刘俊卿再次泣不成声。\n“哭哭哭,哭什么哭?烦死了!”马疤子突然一脚扫来,“砰”的一声,那只放在地上的瓷碗被他踢得猛撞在墙上,四分五裂!\n“你干什么?”子鹏捅了他一枪:“老实点!往后退!”\n“不干什么,听他们哭得烦!”往后挪着的马疤子,一条大腿下,悄悄压住了一片尖锐的瓷片。\n秀秀将瓷片一片片捡了起来,捧着碎瓷片刚要走,子鹏突然想到了什么,拿起瓷片,拼凑起来,发现拼起的瓷碗缺了一片。看看地上,并没有其他瓷片的影子,子鹏的目光落在了马疤子身上:“你,手上拿了什么?”\n“啊?没什么呀?”正在用瓷片割绳子的马疤子吓得一愣。\n子鹏抄起了木枪边往马疤子面前走边说:“你转过来。”\n“我真没拿什么……”\n“我叫你转过来!”\n眼看马疤子的小动作就要无处可藏,门外突然传来了一阵喊叫声。子鹏与秀秀同时大吃一惊,回头去看,冲进门来的,果然是气喘吁吁的王老板夫妇!\n“哎哟子鹏啊,你可让妈好找啊你……”王夫人身子一软,差点没一屁股坐在地上,“我和你爸,是城东找到城西,可就没想到你这时候还敢往城南边跑,你怎么这么不要命啊我的傻儿子?子鹏,走吧,妈求你了,别管那么多,赶紧跟妈逃命啊。”\n子鹏很坚决地说:“我真的不能走!我们在保卫长沙城,你们知不知道?”\n“长沙城是你保得住的吗?”王老板火了,冲上来把子鹏手里的木枪一把抢下,往桌边一搁,拉着他就要走,“凭你们几个学生,就想挡住人家几千兵?你疯了你?跟我走!”\n“爸!”子鹏一把甩开了父亲,“我不!”\n“老爷,太太,你们就别劝了,子鹏真的不能走呀。”\n秀秀想帮子鹏解释,可王夫人一把推开她,骂道:“你少啰嗦!都是你这狐狸精!你给我滚开!”\n“妈!”看到妈妈一把将秀秀推得倒退了好几步,子鹏心痛了,他拦在秀秀前面,对王夫人说,“我不准你碰阿秀!”\n“好哇好哇,为了个狐狸精,你连妈都不要了?子鹏,妈和爸连命都不顾了,跑来找你,你怎么就不明白呢?”\n角落边,趁着子鹏他们争吵,马疤子的手,拼命地用瓷片割着绳子,绳子已经被割断大半了。\n王老板逼上前来,面如严霜地问子鹏:“你到底走不走!”\n子鹏一摇头。\n“啪。”一个耳光重重打在子鹏脸上,王老板又问:“走不走?”\n子鹏一个踉跄,那支靠在桌边的木枪被他身子一撞,向地上倒去,就在这时,马疤子猛地挣断了绳子,伸手接住了木枪,砸了过来。\n“子鹏!”秀秀吓得一声惊呼,猛扑上来护住子鹏,木枪重重砸在她肩头,将她打翻在地!\n“阿秀!”子鹏刚要去抱秀秀,木枪横扫,将他也打倒在地,他腰间那柄餐刀飞出,正好滑到马疤子面前,马疤子捡起刀,向王老板夫妇扑去。\n“救命啊……”\n呼救与打斗声猝然响起在一师的上空。\n马疤子用刀划伤了护着老婆的王老板,王老板抡着板凳倒退着,马疤子一脚踢飞了板凳,挥刀刺来!\n“爸!”\n“老爷!”\n子鹏和秀秀拼命扑上来,同时抱住了马疤子 “爸,妈,快跑啊,快跑啊!”\n马疤子一把掀翻秀秀,一刀扎在子鹏抱紧秀秀的胳膊上。\n“儿子!”王老板大叫着拼命扑上前要救子鹏,却被马疤子当胸一脚,踢得闷倒在地。\n抡起刀,马疤子就要刺向子鹏,秀秀惊叫着又扑上来,双手紧紧抓住了刀刃,血,一下子顺着刀流了下来。\n她一口咬在马疤子手上。\n“臭丫头,我宰了你!”负痛之下,马疤子暴怒地踢倒秀秀,他高举起刀,向秀秀扎下来。\n“阿秀!”\n说时迟,那时快,一声大吼中,一个身子猛地扑在秀秀身上,刀深深扎进了他的胸膛!\n那是还被反绑着双手的刘俊卿!狂吼着,刘俊卿疯了般向马疤子顶去,还握着刀柄的马疤子拼命向前刺,却被他用胸膛顶着踉跄倒退,直退出房门,一跤摔倒。带着那柄直没至柄的刀,刘俊卿屹立在门口,仿佛一尊浴血的门神!\n一阵脚步声中,众多老师远远向这边跑来,打头的饶伯斯等人还拎着西洋剑、球棍等。马疤子一看情形不妙,爬起来撒腿就跑,老师们围上前来,他已纵身翻过了围墙。\n直到这一刻,站在门口刘俊卿才仿佛耗尽了最后的生命力,突然仰面朝天,倒了下去!\n寂静的校园里顿时响起秀秀撕心裂肺的声音:“哥!”\n六 # 晒谷场,毛泽东他们正在顺利地收缴武器,突然,马疤子狂叫着向这边奔来:“他们是假的!别上当,他们是假的!”\n这个变故实在来得太突然,太致命了,子升的身子一震,蔡和森也禁不住眉心狂跳,山坡后,所有人的心更是猛然间悬了起来!\n“糟糕!”萧三提枪就要追,却被周世钊一把拉住了,他这才想起手里拿的是不能见人的假枪,不由得狠狠一跺脚。\n瞟了一眼跑进火把光照范围的马疤子,毛蔡萧三人显然都认出了他。子升禁不住与蔡和森紧张地对视了一眼。毛泽东的手,也不禁微微一颤,茶碗里的茶溅出少许。趁着众人的目光集中在马疤子身上,他手臂轻轻一带,用衣袖擦去了桌上的茶。\n“团座,他们……他们是假的!他们不是桂军!”狠狠擦了一把汗,马疤子气喘吁吁地叫道, “狗屁桂军!城里……城里他妈一个广西兵都没有,我亲眼看见的,全城的人都在逃难!长沙城根本就没兵!”\n团长将信将疑地指着毛泽东问:“那他们是什么人?”\n“他们是湖南第一师范的学生!”马疤子盯着毛泽东三人,恶狠狠地说, “团座,我说的是真的,他们真的是学生,就他妈一两百个人,我在他们学校门口亲眼看见的,他们连一支枪都没有啊!全他妈一堆洋油桶子里放鞭炮,吓唬人的!”\n“他奶奶的,玩老子?”年轻军官噌地拔出了手枪。\n团长一把按住了他的手:“先等会儿!”\n他看看马疤子,再看看毛蔡萧三人:马疤子的模样狼狈不堪,毛蔡萧三人的神情却都看不出一点慌乱。\n他当然不知道,保持着镇定的子升的脖子后,火光映照下,冷汗其实已经打湿了衣领,他下垂的衣袖正在不自觉地微微抖动着。但稳稳地,蔡和森悄悄握住了他的手。毛泽东却笑了,好像看到了一场无比有趣的滑稽戏,正等待着对方往下演。\n团长一时明显举棋不定:“马排长,刘文书呢?你不是和他一块儿去的吗?怎么没看见他人?”\n“那小子反了水了,我把他宰了!”\n“那你怎么去了这么久?”\n“我不是……不小心给这帮学生逮住了,我想了不少办法才逃出来的。团座,我真的没骗你,这四周真没有桂军,都是学生。团座,咱们三千弟兄,不能让他们二百来个学生给吓住了啊!”\n“哈哈……”毛泽东猛然爆发出一阵仰天大笑!\n马疤子:“你……你笑什么笑?你他妈就是学生,第一师范的,我见过你!”\n毛泽东抚掌大笑:“有意思,有意思有意思。团座,你这位弟兄是不是跟你有仇啊?要是没仇,怎么这么想害死这三千兄弟,啊?”\n几个军官的目光从毛泽东望到马疤子,再从马疤子望到毛泽东:按理马疤子不会骗他们,可毛泽东的样子又实在自信得几乎不容怀疑,让他们一时都糊涂了。\n看看几个军官迟疑的表情,马疤子狠狠地一咬牙:“好,团长,营长,你们都不信,都不肯信自家兄弟是吧?我有办法让你们信!”他一把从旁边一个兵手里抢过火把,转身冲前几步,面向小山坡,一拍胸脯:“对面第一师范的学生崽子们,给我马爷听着,你们他妈不是桂军吗?不是他妈机枪大炮吗?来呀,有种往这儿打!只要你们有一杆真枪,有一颗子弹,就往爷这儿打……”\n小山坡上,所有的人都急得不知如何是好,眼看马疤子如此嚣张,众人却偏是一点办法都没有。萧三急得一把扔掉了手里不顶事的假枪!斯咏、警予、开慧、蔡畅,四个女生紧张得紧紧拥抱在了一起。\n马疤子还在那里叫嚣:“你们打呀,打呀!怎么了,马爷送给你们打,怎么不敢打?刚才不是还机枪大炮满天响吗?这会儿怎么不响了?是没枪?还是没子弹?还是又没枪又没子弹?露馅了吧,一帮学生崽子们!”\n“毛副官?哼哼,戏演得不错嘛。”望着毛泽东,团长的眼睛狠狠地眯了起来,慢慢掏出了手枪,枪口猛地对准了毛泽东的脑门。枪口的准星里,毛泽东连眼睛都没往枪这边瞄一瞄,却不紧不慢地提起茶壶,给自己喝空了的茶碗里续起水来。\n子升、蔡和森的手猛然一紧。\n这时候,在山坡后,另一个枪口,另一个准星,瞄准的居然是马疤子。稍一犹豫,枪口突然又从马疤子身上移开,向团长的方向转去……砰!枪声骤响,夜空都仿佛为之一颤!随着枪声,团长头上帽子骤然飞起,吓得他猛一缩脖子!几个军官同样吓得一抖!连蔡和森和子升都是猛的一震!茶碗里的水刚好加满,一滴也没溢出——毛泽东稳稳地放下茶壶,一伸手,正好接住了团长落下的军帽,军帽上留着一个枪眼!\n军帽递到了面无人色的团长面前,毛泽东微笑着说:“兄弟治军不严,手下弟兄不小心走了火,让团座受惊了,真是对不住。”\n接过帽子,团长凶狠狠的目光突然转向了马疤子。马疤子显然也被弄糊涂了,但眼前的危险他却马上醒悟过来:“团座,不,我没骗你,我真的没骗你,不,团座,不要――”\n“砰。”一颗子弹正中马疤子脑门,他一头栽倒在地。\n“奶奶的,差点被你这狗娘养的害死!”团长又是砰砰几枪,打在早已毙命的马疤子身上。转过身,他擦了一把冷汗,将手枪捧到了毛泽东面前:“毛副官,我交枪!”\n哗啦一阵,溃兵们手中的枪纷纷落地。\n欢呼声中,众多学生军四面八方涌上前来。木枪被扔了一地,一双双手,抄起了地上堆放的真枪。胜利的欢呼声响成了一片!远远蹲成一片的溃兵们都给弄糊涂了,一双双眼睛都落在了学生军胳膊上“第一师范学生志愿军”的袖标和满地的木头假枪、洋油桶子、鞭炮碎屑上,几个军官全傻眼了。\n团长望着毛泽东,问:“你……你到底是什么人?”\n“第一师范本科八班学生,毛泽东。”\n团长双腿一软,蹲在了地上,狠狠一捶脑袋!\n毛泽东转过身,正与提枪而来的郭亮相遇在一起,两个人的手紧紧地握在了一起。\n夜幕下的一师,孔昭绶已经接到了猴子石传来的捷报,他打开《第一师范校志》,奋笔如飞地记载下了这次事件。意犹未尽,他又郑重地落下了这样一句话:“全校师生皆曰:毛泽东通身是胆。”\n七 # 王子鹏和阿秀结婚了,王家和陶家的婚书被如愿退给了斯咏,陶会长在经历了绑架案和猴子石一役之后,对毛泽东有了新的看法,不再认为那个穷师范生配不上他的女儿,反觉得他非同凡响,他的将来也绝非常人所能预测,希望女儿能跟这样惊世骇俗的人物共度一生。但斯咏却怅然地对父亲说: “那个曾经的、虚幻的梦,早已经醒来,我已经想得很清楚了:今生今世,我只会把毛泽东当成最好的朋友。”\n1918年4月14日,毛泽东还有他们共同的朋友蔡和森、何叔衡、萧子升、萧植蕃、罗章龙、张昆弟等在长沙溁湾镇的刘家台子蔡和森家里,发起成立了湖南近代史上最重要的进步青年团体——以“革新学术,砥砺品行,改良人心风俗”为宗旨的新民学会。\n1918年6月,毛泽东自湖南第一师范本科第八班毕业。\n同月,杨昌济赴北京大学担任伦理学教授。1920年1月,杨昌济因病逝世于北京,女儿杨开慧与学生毛泽东在病榻前陪伴他走过了人生最后的旅程。临终前,他还在向广州军政府秘书长章士钊写信推荐自己心爱的两名学生:毛泽东与蔡和森。信中说:“二子海内人才……君不言救国则已,言救中国,必先重此二子。”\n亦是同月,孔昭绶辞去第一师范校长职务,后投身军界,出任国民政府少将参议等职,1929年病逝于长沙。\n第一师范学监主任方维夏于1924年加入中国共产党,曾参加北伐战争与南昌起义,历任中华苏维埃共和国总务厅长,江西、湘赣省教育部长、裁判部长等职。红军长征后,奉命留守苏区。1936年,在艰苦卓绝的游击战中,遭叛徒出卖,牺牲于湖南桂东县。\n教育实习主任徐特立于1927年加入中国共产党,曾参加南昌起义,是长征中年龄最大的红军战士,历任中华苏维埃共和国、中华人民共和国教育部代部长、中共中央宣传部副部长等职,1968年病逝于北京。\n国文教师袁吉六20年代初曾出任湖南省教育司司长,并长期任教于长沙各学校,1936年病逝于湖南隆回县。中华人民共和国成立后,毛泽东曾长期照顾他的遗孀戴长贞女士。\n国文教师易培基20年代初曾出任第一师范校长,后曾担任国民政府农矿部长、故宫博物院院长等职。\n饶伯斯、费尔廉、黄澍涛、雷明亮、王立庵等第一师范其他教师后均长期从事教育工作。\n毛泽东于1920年起,被第一师范返聘为教师,先后担任一师附属小学主事(校长)、本科第二十二班班主任兼国文教师,任教一年半后辞去教职,成为职业革命家。\n杨开慧与毛泽东于1920年底在长沙结婚,育有三子。1921年,杨开慧加入中国共产党,成为中共历史上第二位女性党员。1930年,杨开慧在长沙被湖南军阀何键逮捕,遍历酷刑,坚贞不屈,因拒绝以宣布与毛泽东脱离关系为条件换取自由,同年10月14日,牺牲于长沙识字岭刑场,英年28岁。\n陶斯咏后长期致力于中国妇女教育,任教于长沙、上海等地,成为著名的女性教育家,曾培养了作家丁玲等大批优秀女性学生。1932年,陶斯咏因病早逝于长沙,享年37岁。终生未婚。\n蔡和森与向警予于1920年在法国结婚,二人后均成为早期中国共产党重要领袖。\n向警予曾任中共二、三、四大代表,中共中央妇女部长等职,是中国共产党历史上第一位女性中央委员。1928年,向警予在武汉组织工人运动时遭叛徒出卖被捕,5月1日,牺牲于汉口刑场,英年33岁。\n蔡和森曾是中国共产主义运动先驱者之一,历任中共二、三、四、五、六大代表,中央政治局委员,中共中央宣传部长等职,1931年,蔡和森在组织广东特委地下工作时,遭叛徒出卖被捕,坚贞不屈,约于6月中旬牺牲于广州军政监狱酷刑之下,英年36岁。\n何叔衡后成为中国共产党创始人之一,中共一大代表,历任中央苏维埃监察部长、内务部长、最高法庭主席等职。红军长征后,奉命留守苏区。1935年2月,在福建长汀的游击战中遇敌埋伏,突围失败,因不愿被俘,跳下悬崖壮烈牺牲,享年59岁。\n萧子升20世纪30年代曾出任国民政府农矿部次长、故宫博物院监守等职,后离职长期以学者身份旅居国外,任教于各大学。1979年病逝于南美乌拉圭。\n张昆弟于1922年加入中国共产党,曾任红五军团政治部主任,与贺龙等一道创建湘鄂西革命根据地。1930年在洪湖地区牺牲于“左”倾肃反运动中。\n罗学瓒于1922年加入中国共产党,曾任中共浙江省委书记,1930年牺牲于杭州。\n陈章甫于1921年加入中国共产党,曾任中共醴陵县委书记,1930年牺牲于长沙。\n郭亮于1921年加入中国共产党,成为著名工人运动领袖,曾任中共湖南、湖北省委书记,1928年3月牺牲于长沙。\n蔡畅后成为职业女革命家,长期担任党和国家领导职务,曾任中共中央委员,中华人民共和国全国人大副委员长,全国妇联主席,1990年病逝于北京。\n李维汉后成为职业革命家,长期担任党和国家领导职务,曾任中共中央政治局常委,中央组织部长、统战部长、中华人民共和国全国人大副委员长等职,1984年病逝于北京。\n萧三后成为著名作家、翻译家。\n罗章龙于1921年加入中国共产党,曾任早期中国共产党中央委员,后长期任教于各大学,成为著名经济学家。\n周世钊后曾长期担任第一师范校长,中华人民共和国成立后,曾担任湖南省副省长等职。后记后记\n记得四年前,我应湖南电视台的要求,创作电视连续剧《恰同学少年》剧本的时候,有不少熟人、朋友曾问起我在写什么,一听说是“毛泽东在第一师范上学的故事”,每一个人都摇头:“这能有什么意思?”——真的,没有一个人看好它。\n不用说别人,包括我自己,同样不敢看好这部剧的市场反响,因为剧本出自我笔下,我知道它太过严肃,没有搞笑、戏说、三角恋、婚外情、宫廷阴谋、凶杀大案……这些电视市场通行的娱乐元素,通篇至尾,除了教书育人,就是读书成才,一堆“国家、民族、理想、志向”的大道理,哪怕一样能跟“娱乐性”挂上钩的内容也找不出,所以,我是真不敢期望它的市场反响。\n而四年后的今天,这部剧在中央电视台、湖南电视台播出后,却连续创造了极高的收视率,在观众中、尤其是青少年观众中产生了空前强烈的反响,网络上好评如潮,称之为“《恰同学少年》现象”,甚至引起中央高层领导的关注,这样的成功,真是完全出乎我的意料。\n回想起来,《恰同学少年》的成功,首先源自湖南电视台欧阳常林台长独到的创意策划,是欧阳台长以高远的眼光和强烈的社会责任感,提出了创作一部以“老师怎样教书育人,学生怎样读书成才”为主题的电视剧作品,也是他最早提出,将选材放在杨昌济先生与毛泽东、蔡和森这一组历史最有名、最成功的师生组合上。可以说,欧阳台长的选题与创意,是《恰同学少年》得以成功的关键。\n另外,投资方之一长沙电视台对电视剧《恰同学少年》的大力投入,也是这部电视剧得以成功的不可或缺的因素,著名制片人罗浩先生的独具慧眼,至今令我由衷佩服。\n在剧本创作过程中,我的老师、著名编剧盛和煜先生全程参与策划、审稿、统稿,给予了我悉心的指导,另外,导演龚若飞先生作为项目负责人,也全程参与了剧本创作的讨论与执行,我的剧本能够创作成功,同样离不开他们的帮助和指导。\n《恰同学少年》播出以后,也得到湖南省委的高度重视,省委宣传部蒋建国部长专门指出要将《恰同学少年》的后续宣传与推广工作做好,并布置了六条具体措施,其中之一,就是及时出版同名小说作品。\n现在,这本电视剧同名小说已经面世,小说是在电视剧本的基础上加工完成的,由于时间仓促、水平有限,小说中还存在许多不足,敬请读者谅解。\n本书的出版单位湖南人民出版社对这个项目给予了高度重视,成立了专门的工作小组,组织改写、审稿、报批等等工作,这部小说能够这样快与读者见面,跟出版社有关领导与编辑人员加班加点的辛勤工作是分不开的。此外,何晓、张开宏、张雯轩、覃柳平等人也为这本书的改写,做了大量具体的工作,在此也一并表示感谢。\n黄晖\n2007年6月30日\n(全文完)\n"},{"id":130,"href":"/zh/docs/culture/%E5%A6%82%E4%BD%95%E9%98%85%E8%AF%BB%E4%B8%80%E6%9C%AC%E4%B9%A6/%E9%99%84%E5%BD%95/","title":"附录","section":"如何阅读一本书","content":" 附录一 建议阅读书目 # 下面所列举的书单,都是值得你花时间一读的书。我们说“值得你花时间”是很认真的。虽然这些书并不全都是一般人所认为的那种“伟大”,但只要你肯花时间努力,你就能得到回馈。所有这些书都超越了大多数的水平——超出许多。因而这些书会强迫大部分读者作心智上的成长,以了解并欣赏这样的书。当然,如果你想要增进自己的阅读技巧,这样的书就是你该找的书,同时你也会发现在我们文化传统中有过,哪些伟大的思想与说法。\n就我们在上一章所谈的特殊意义而言有些书特别了不起。每次你重读,都会发现许多新的想法。这些书是可以一读再读,永不会厌倦的。换句话说,这些书——我们不会正确地指出有多少这样的书,也不会指出是哪些书,因为这是由个人判断的——超越过所有读者的水平,就算最有技巧的读者也不能超越这样的书。我们在上一章说过,这些作品就是每个人都该特别努力去研读的书。这些书是真正的伟大作品,任何一个人要去荒岛,都该带着这些书一起去。\n这个书单很长,看起来有点难以消受。我们鼓励你不要因为这个书单而觉得为难。一开始,你可能会先要辨识大部分的作者是谁。这里面没什么是一般人难以了解,因而就该冷僻的道理。最重要的是,我们要提醒你,不论基于什么理由,最聪明的做法都是从你最感兴趣的书开始读。我们已经说过许多次,主要的目标是要读得好,而不是要读得广。如果一年当中你读不了几本书,其实不必觉得失望。书单上的书并不是要你在特定时间里读完的。这也不是非要读完所有的书才算完成的挑战。相反,这是一个你可以从容接受的邀请,只要你觉得很自在,任何时候都可以开始。\n作者名单是按时间前后顺序排出来的,以他们确实或大约的出生时期为准。一位作者有很多本书时,也是尽可能按作品时间顺序排列的。学者们对每一本书的最早出版时间可能不见得有一致的看法,但这对你来说没什么影响。要记得的重点是:这个书单就像是一个时代的演进表,当然,你用不着依时间先后的顺序来读。你甚至可以从最近出版的一本书来读,再回溯到荷马及《旧约》。\n我们并没有把每一位作者所有的书都列出来。通常我们都只挑选比较重要的作品,以论说性作品而言,我们挑选的根据是尽可能表现一位作者在不同学习领域里作了哪些贡献。在另外一些例子中,我们会列举一位作者的几部作品,然后把其中特别重要又有用的书用括号标示出来。\n要拟这份书单,最困难的总是跟当代作品有关的部分。作者越接近我们的年代,越难作很公正的评断。时间能证明一切是句好话,但我们不想等那么久。因此对现代的作者或作品,我们预留了一些不同观点的空间,因此在我们书单比较后面部分的书,我们不敢说前面那些书公认的地位。\n对前面部分的书,可能也有人有些不同的观点,因为我们没有列入某些作品,可能会认为我们在挑选时有偏见。在某些例子中,我们承认自己是有些偏见。这是我们开的书单,自然会跟别人开的书单有点不同。不过如果任何人想要认真地研拟一份值得一生阅读的好书书单,以增进阅读能力的话,其间的差别应该不会太大才对。当然,最后你还是要自己拟出一份书单,然后全力以赴。无论如何,在你列出自己的书单之前,先看一份被一致公认为好书的书单,是很聪明的做法。这份书单是一个可以开始的地方。\n我们还要提出一个疏漏之处,这可能会让一些不幸的读者觉得很受打击。这份书单只列出了西方的作品,不包括中国、日本或印度的作品。我们这么做有几个理由。其中一个是我们对西方传统文化以外的文化并不十分在行,我们建议的书单也不会有什么分量。另一个原因是东方并不像西方这样是单一的传统,我们必须要明白所有的东方文化传统之后,才能将这份书单拟好。而很少有学者能对所有的东方文化都有深刻的了解。第三,在你想要了解其他世界的文化之前,应该要先了解自己的文化。现代有许多人试着要读《易经》或《薄伽梵歌》(Bhagavad-Gita),都觉得很困难,不只是因为这样的书本身就很难懂,也因为他们并没有先利用自己文化中比较容易理解的书——他们比较容易接近的书把阅读技巧练习好。\n还有另外一个疏忽之处要提提。虽然是一份书单,其中主要以抒情诗诗人为人熟知的作者却没几位。当然,书单中另外有些作者也写抒情诗,但他们较为人知的是一些较长的其他著作。这方面不该当作是我们对抒情诗有偏见。读诗,我们认为从一本好的合选集开始阅读,会比从某一位作者的个人选集开始要好得多。帕尔格雷夫(Palgrave)编辑的《英诗金库》(The Golden reasury)及《牛津英诗选》(The Oxford Book of English Verse)是最好的入门书。这些老的诗选应该要有现代人做增补的工作——像塞尔登·罗德曼(Selden Rodman)的《现代诗一百首》(Owe Hundred Modern Poems),这本书用很有趣的概念,广泛收集了当代随手可得的英诗。因为阅读抒情诗需要特殊的技巧,我们也介绍了其他相关的指导书籍——像马克·范多伦的《诗歌入门》(Introduction to Poetry),是一本合选集,同时也包含了一些短论,谈到如何阅读许多有名的抒情诗。\n我们依照作者及书名将书单列出来,却没有列出出版者及特殊的版本。书单上几乎所有的书都可以在书店中找到,有许多出了不同的版本,平装或精装都有。不过,如果哪位作者或哪本作品已经收录进我们自己所编辑的两套书,那就会特别标示出来。其中出现在《西方世界的经典名著》(Great Books of the Western World)中的,打一个星号;出现在《名著入门》(Gateway to the Great Books)中的,打两个星号。\n1.Homer(9th century b.c.?)\n*Iliad\n*Odyssey\n2.The Old Testament\n3.Aeschylus(c.525-456 b.c.)\n*Tragedies\n4.Sophocles(c.495-406 b.c.)\n*Tragedies\n5.Herodotus(c.484-425 b.c.)\n*History(of the Persian Wars)\n6.Euripides(c.485-406 b.c.)\n*Tragedies\n(esp.Medea,Hippolytus,The Bacchae)\n7.Thucydides(c.460-400 b.c.)\n*History of the Peloponnesian War\n8.Hippocrates(c.460-377 b.c.)\n*Medical writings\n9.Airstophanes(c.448-380 b.c.)\n*Comedies\n(esp.The Clouds,The Birds,The Frogs)\n10.Plato(c.427-347 b.c.)\n*Dialogues\n(esp.The Republic,Symposium,Phaedo,Meno,Apology,Phaedrus,Protagoras,Gorgias, Sophist,Theaetetus)\n11.Aristotle(384-322 b.c.)\n*Works\n(esp. Organon,Physics,Metaphysics,On the Soul,The Nichomachean Ethics,Politics,Rhetoric,Poetics)\n12.**Epicurus(c.341-270 b.c.)\nLetter to Herodotus\nLetter to Menoeceus\n13.Euclid(fl.c.300 b.c.)\n*Elements(of Geometry)\n14.Archimedes(c.287-212 b.c.)\n*Works\n(esp.On the Equilibrium of Planes,On Floating Bodies,The Sand-Reckoner)\n15.Apollonius of Perga(fl.c.240 b.c.)\n*On Conic Sections\n16.**Cicero(106-43 b.c.)\nWorks\n(esp.Orations,On Friendship,On Old Age)\n17.Lucretius(c.95-55 b.c.)\n*On the Nature of Things IS.Virgil(70-19 b.c.)\n*Works\n19.Horace(65-8 b.c.)\nWorks\n(esp.Odes and Epodes,The Art of Poetry)\n20.Livy(59 b.c.-A.D.17)\nHistory of Rome\n21.Ovid(43 b.c.-A.D.17)\nWorks\n(esp.Metamorphoses)\n22.**Plutarch(c.45-120)\n*Lives of the Noble Grecians and Romans Moralia\n23.** Tacitus(c.55-117)\n*Histories\n*Annals\nAgricola\nGermania\n24.Nicomachus of Gerasa(fl. c.100 a.d.)\n*Introduction to Arithmetic\n25.* *Epictetus(c.60-120)\n*Discourses\nEncheiridion(Handbook)\n26.Ptolemy(c.100-178;fl.127-151)\n*Almagest\n27.** Lucian(c.120-c.190)\nWorks\n(esp.The Way to Write History,The True History t The Sale of Creeds)\n28.Marcus Aurelius(121-180)\n*Meditations\n29.Galen(c.130-200)\n*On the Natural Faculties\n30.The New Testament\n31.Plotinus(205-270)\n*The Enneads\n32.St.Augustine(354-430)\nWorks\n(esp.On the Teacher,*Confessions,The City of God,*Christian Doctrine)\n33.The Song of Roland(12th century?)\n34.The Nibelungenlied(13th century)\n(The Vlsunga Saga is the Scandinavian version of the same leg end.)\n35.The Saga of Burnt Njal\n36.St.Thomas Aquinas(c.1225-1274)\n*Summa Theologica\n37.**Dante Alighieri(1265-1321)\nWorks\n(esp.The New Life,On Monarchy,*The Divine Comedy)\n38.Geoffrey Chaucer(c.1340-1400)\nWorks\n(esp.*Troilus and Criseyde,* Canterbury Tales)\n39.Leonardo da Vinci(1452-1519)\nNotebooks\n40.Niccoló Machiavelli(1469-1527)\n*The Prince\nDiscourses on the First Ten Books of Livy\n41.Desiderius Erasmus(c.1469-1536)\nThe Praise of Folly\n42.Nicolaus Copernicus(1473-1543)\n*On the Revolutions of the Heavenly Spheres\n43.Sir Thomas More(c.1478-1535)\nUtopia\n44.Martin Luther(1483-1546)\nThree Treatises\nTable -Talk\n45.Francois Rabelais(c.1495-1553)\n*Gargantua and Pantagruel\n46.John Calvin(1509-1564)\nInstitutes of the Christian Religion\n47.Michel de Montaigne(1533-1592)\n*Essays\n48.William Gilbert(15401603)\n*On the Loadstone and Magnetic Bodies\n49.Miguel de Cervantes(1547-1616)\n*Don Quixote\n50.Edmund Spenser(c.1552-1599)\nProthalamion\nThe Faerie Queene\n51.**Francis Bacon(1561-1626)\nEssays\n*Advancement of Learning\n*Novum Organum\n*New Atlantis\n52.William Shakespeare(1564-1616)\n*Works\n53.**Galileo Galilei(1564-1642)\nThe Starry Messenger\n*Dialogues Concerning Two New Sciences\n54.Johannes Kepler(1571-1630)\n*Epitome of Copernican Astronomy\n*Concerning the Harmonies of the World\n55.William Harvey(1578-1657)\n*On the Motion of the Heart and Blood in Animals\n*On the Circulation of the Blood\n*On the Generation of Animals\n56.Thomas Hobbes(1588-1679)\n*The Leviathan\n57.Rene Descartes(1596-1650)\n*Rules for the Direction of the Mind\n*Discourse on Method\n*Geometry\n*Meditations on First Philosophy\n58.John Milton(1608-1674)\nWorks\n(esp.*the minor poems,*Areopagitica,*Paradise Lost,*Samson Agonistes)\n59.** Moliere(1622-1673)\nComedies\n(esp.The Miser,The School for Wives,The Misanthrope,The Doctor in Spite of Himself,Tartuffe)\n60.Blaise Pascal(1623-1662)\n*The Provincial Letters\n*Pensées\n*Scientific treatises\n61.Christiaan Huygens(1629-1695)\n*Treatise on Light\n62.Benedict de Spinoza(1632-1677)\n*Ethics\n63.John Locke(1632-1704)\n*Letter Concerning Toleration\n*“Of Civil Government ”( second treatise in Two Treatises on Government)\n*Essay Concerning Human Understanding Thoughts Concerning Education\n64.Jean Baptiste Racine(1639-1699)\nTragedies\n(esp.Andromache,Phaedra)\n65.Isaac Newton(1642-1727)\n*Mathematical Principles of Natural philosophy\n*Optics\n66.Gottfried Wilhelm von Leibniz(1646-1716)\nDiscourse on Metaphysics\nNew Essays Concerning Human Understanding Monadology\n67.**Daniel Defoe(1660-1731)\nRobinson Crusoe\n68.**Jonathan Swift(1667-1745)\nA Tale of a Tub\nJournal to Stella\n*Gulliver\u0026rsquo;s Travels\nA Modest Proposal\n69.William Congreve(1670-1729)\nThe Way of the World\n70.George Berkeley(1685-1753)\n*Principles of Human Knowledge\n71.Alexander Pope(1688-1744)\nEssay on Criticism\nRape of the Lock\nEssay on Man\n72.Charles de Secondat,Baron de Montesquieu(1689-1755)\nPersian Letters\n*Spirit of Laws\n73.**Voltaire(1694-1788)\nLetters on the English\nCandide\nPhilosophical Dictionary\n74.Henry Fielding(1707-1754)\nJoseph Andrews\n*Tom Jones\n75.**Samuel Johnson(1709-1784)\nThe Vanity of Human Wishes\nDictionary\nRasselas\nThe Lives of the Poets\n(esp.the essays on Milton and Pope)\n76.**David Hume(1711-1776)\nTreatise of Human Nature Essays Moral and Political\n*An Inquiry Concerning Human Understanding\n77.**Jean Jacques Rousseau(1712-1778)\n*On the Origin of Inequality\n*On Political Economy Emile\n*The Social Contract\n78.Laurence Sterne(1713-1768)\n*Tristram Shandy\nA Sentimental Journey Through France and Italy\n79.Adam Smith(1723-1790)\nThe Theory of the Moral Sentiments\n*Inquiry into the Nature and Causes of the Wealth of Nations\n80.**Immanuel Kant(1724-1804)\n*Critique of Pure Reason\n*Fundamental Principles of the Metaphysics of Morals\n*Critique of Practical Reason\n*The Science of Right\n*Critique of Judgment\nPerpetual Peace\n81.Edward Gibbon(1737-1794)\n*The Decline and Fall of the Roman Empire Autobiography\n82.James Boswell(17401795)\nJournal\n(esp.London Journal)\n*Life of Samuel Johnson Ll.D.\n83.Antoine Laurent Lavoisier(1743-1794)\n*Elements of Chemistry\n84.John Jay(1745-1829),James Madison(1751-1836),and Alexander Hamilton(1757-1804)\n*Federalist Papers\n(together with the *Articles of Confederation,the*Constitution of the United States,and the ^ Declaration of Independence)\n85.Jeremy Bentham(1748-1832)\nIntroduction to the Principles of Morals and Legislation Theory of Fictions\n86.Johann Wolfgang von Goethe(1749-1832)\n*Faust\nPoetry and Truth\n87.Jean Baptiste Joseph Fourier(1768-1830)\n*Analytical Theory of Heat\n88.Georg Wilhelm Friedrich Hegel(1770-1831)\nPhenomenology of Spirit\nPhilosophy of Right\nLectures on the Philosophy of History\n89.William Wordsworth(1770-1850)\nPoems\n(esp.Lyrical Ballads 9 Lucy poems,sonnets;The Prelude)\n90.Samuel Taylor Coleridge(1772-1834)\nPoems\n(esp.“Kubla Khan,”Rime of the Ancient Mariner)\nBiographia Literaria\n91. Jane Austen(1775-1817)\nPride and Prejudice\nEmma\n92.**Karl von Clausewitz(1780-1831)\nOn War\n93.Stendhal(1783-1842)\nThe Red and the Black\nThe Charterhouse of Parma\nOn Love\n94.George Gordon,Lord Byron(1788-1824)\nDon Juan\n95.**Arthur Schopenhauer(1788-1860)\nStudies in Pessimism\n96.**Michael Faraday(1791-1867)\nChemical History of a Candle\n*Experimental Researches in Electricity\n97.** Charles Lyell(1797-1875)\nPrinciples of Geology\n98.Auguste Comte(1798-1857)\nThe Positive Philosophy\n99.**Honore de Balzac(1799-1850)\nPère Goriot\nEugénie Grandet\n100.** Ralph Waldo Emerson(1803-1882)\nRepresentative Men\nEssays\nJournal\n101.** Nathaniel Hawthorne(1804-1864)\nThe Scarlet Letter\n102.**Alexis de Tocqueville(1805-1859)\nDemocracy in America\n103.**John Stuart Mill(1806-1873)\nA System of Logic\n*On Liberty\n*Representative Government\n*Utilitarianism\nThe Subjection of Women\nAutobiography\n104.**Charles Darwin(1809-1882)\n*The Origin of Species\n*The Descent of Man\nAutobiography\n105.**Charles Dickens(1812-1870)\nWorks\n(esp.Pickwick Papers,David Copper field,Hard Times)\n106.**Claude Bernard(1813-1878)\nIntroduction to the Study of Experimental Medicine\n107.**Henry David Thoreau(1817-1862)\nCivil Disobedience\nWalden\n108.Karl Marx(1818-1883)\n*Capital\n(together with the *Communist Manifesto)\n109.George Eliot(1819-1880)\nAdam Bede Middlemarch\n110.**Herman Melville(1819-1891)\n*Moby Dick\nBilly Budd\n111.**Fyodor Dostoevsky(1821-1881)\nCrime and Punishment\nThe Idiot\nThe Brothers Karamazov\n112.**Gustave Flaubert(1821-1880)\nMadame Bovary\nThree Stories\n113.**Henrik Ibsen(1828-1906)\nPlays\n(esp.Hedda Gabler,A Doll\u0026rsquo;s House,The Wild Duck)\n114.**Leo Tolstoy(1828-1910)\nWar and Peace\nAnna Karenina\nWhat Is Art?\nTwenty-three Tales\n115.**Mark Twain(1835-1910)\nThe Adventures of Huckleberry Finn\nThe Mysterious Stranger\n116.**William James(1842-1910)\n*The Principles of Psychology\nThe Varieties of Religious Experience Pragmatism\nEssays in Radical Empiricism\n117.**Henry James(1843-1916)\nThe American\nThe Ambassadors\n118.Friedrich Wilhelm Nietzsche(1844-1990)\nThus Spoke Zarathustra\nBeyond Good and Evil The Genealogy of Morals The Will to Power\n119.Jules Henri Poincare(1854-1912)\nScience and Hypothesis\nScience and Method\n120.Sigmund Freud(1856-1939)\n*The Interpretation of Dreams\n*Introductory Lectures on Psychoanalysis\n*Civilization and Its Discontents\n*New Introductory Lectures on Psychoanalysis\n121.**George Bernard Shaw(1856-1950)\nPlays(and Prefaces)\n(esp.Man and Superman,Major Barbara,Caesar and Cleopatra,Pygmalion,Saint Joan)\n122.** Max Planck(1858-1947)\nOrigin and Development of the Quantum Theory\nWhere Is Science Going?\nScientific Autobiography\n123.**Henri Bergson(1858-1941)\nTime and Free Will\nMatter and Memory\nCreative Evolution\nThe Two Sources of Morality and Religion\n124.“ John Dewey(1859-1952)\nHow We Think\nDemocracy and Education\nExperience and Nature\nLogic,the Theory of Inquiry\n125.**Alfred North Whitehead(1861-1947)\nAn Introduction to Mathematics\nScience and the Modern World\nThe Aims of Education and Other Essays\nAdventures of Ideas\n126.**George Santayana(1863-1952)\nThe Life of Reason\nSkepticism and Animal Faith\nPersons and Places\n127.Nikolai Lenin(1870-1924)\nThe State and Revolution\n128.Marcel Proust(1871-1922)\nRemembrance of Things Past\n129.**Bertrand Russell(1872-1970)\nThe Problems of Philosophy\nThe Analysis of Mind\nAn Inquiry into Meaning and Truth\nHuman Knowledge;Its Scope and Limits\n130.**Thomas Mann(1875-1955)\nThe Magic Mountain\nJoseph and His Brothers\n131.**Albert Einstein(1879-1955)\nThe Meaning of Relativity\nOn the Method of Theoretical Physics\nThe Evolution of Physics(with L.Infeld)\n132.**James Joyce(1882-1941)\n“The Dead”in Dubliners\nPortrait of the Artist as a Young Man Ulysses\n133.Jacques Maritain(1882-)\nArt and Scholasticism\nThe Degrees of Knowledge\nThe Rights of Man and Natural Law\nTrue Humanism\n134.Franz Kafka(1883-1924)\nThe Trial\nThe Castle\n135.Arnold Toynbee(1889-)\nA Study of History\nCivilization on Trial\n136.Jean Paul Sartre(1905-)\nNausea\nNo Exit\nBeing and Nothingness\n137.Aleksandr I.Solzhenitsyn(1918-)\nThe First Circle\nCancer Ward\n附录一 建议阅读书目(中文版)\n荷马——《伊利亚特》、《奥德赛》\n未知——《旧约》\n埃斯库罗斯——悲剧\n索福克勒斯——悲剧\n希罗多德——历史\n欧里庇得斯——悲剧 特别:《美狄亚》、《希波吕托斯》、《酒神的伴侣》\n修昔底德——《伯罗奔尼撒战争史》\n希波克拉底——《医学著作》\n阿里斯托芬——喜剧 特别:《云》、《鸟》、《青蛙》\n柏拉图——对话录 特别:《理想国》、《会饮》、《斐多》、《枚农》、《申辩篇》、《斐德罗》、《普罗太戈拉》、《高尔吉亚》、《智者》、《泰阿泰德》\n亚里士多德——全部作品 特别:《工具论》、《物理学》、《形而上学》、《论灵魂》、《尼各马科伦理学》、《政治学》、《修辞术》、《诗学》\n伊壁鸠鲁——《致希罗多德信》、《致梅瑙凯信》\n欧几里得——《几何原本》\n阿基米德——所有著作 特别:《论平板的平衡》、《论浮体》、《沙粒计算》\n阿波罗尼奥斯——《圆锥曲线论》\n西塞罗——《友谊篇》、《演说集》、《论老年》\n卢克莱修——《物性论》\n维吉尔——著作 英文版特别:《牧歌》、《农业的·田园诗 》、《埃涅阿斯纪》\n贺拉斯 ——《长短句》、《颂歌集》、《诗艺》\n李维——《罗马史》\n奥维德——著作 特别:《变形记》\n普鲁塔克——《希腊罗马名人比较列传》\n塔西佗——《历史》、《编年史》、《阿古利可拉传》、《日耳曼尼亚志》\n尼可马修斯——《算术入门》\n爱比克泰德——《金言录》、《手册》\n托密勒——《天文学大成》\n琉善(路吉阿诺斯)——著作 特别:《论撰史》、《真实的历史》、《待售的哲学》\n马库思·奥勒留——《沉思录》\n盖伦——《论自然力》\n未知——《新约》\n柏罗丁——《六部九章集》\n圣·奥古斯丁——著作 特别:《论教师》、《忏悔录》、《天主之城 》、《论基督教教义》\n未知——《罗兰之歌》\n未知——《尼伯龙根之歌》\n未知——《尼雅尔萨迦(尼雅尔传)》\n阿奎那——《神学大全》\n但丁——著作 特别:《新生活》、《君主国》、《神曲》\n乔叟——著作 特别:《特罗勒斯与克丽西德》、《坎特伯雷故事集》\n达芬奇——笔记\n马基维里——《君主论》、《李维罗马史论》\n伊拉斯谟——《愚人礼赞》\n哥白尼——《天体运行论(De Revolutionibus)》\n托马斯·摩尔——《乌托邦》\n马丁·路德——《三檄文》、《桌上谈》\n拉伯雷——《巨人传》\n加尔文——《基督教要义》\n蒙田——《随笔》\n威廉·吉尔伯特——《磁石论》\n塞万提斯——《堂吉诃德》\n爱德蒙·斯宾塞——《预祝婚礼曲》、《仙后》\n培根——《随笔集》、《学术的推进》、《新工具》、《新大西岛》\n莎士比亚——著作\n伽利略——《关于两门新科学的对话》、《星夜的差使》\n开布勒——《哥白尼天文学概要》、《世界的和谐》\n威廉·哈维——《动物心运动的解剖学研究》、《血液循环》、《论动物的生殖》\n托马斯·霍布斯——《利维坦》\n笛卡儿——《方法中的对话》、《方法论》、《几何学》、《第一哲学沉思》\n弥尔顿——著作 特别:《英文小诗歌》、《失乐园》、《力士参孙》、《论出版自由》\n莫里哀——喜剧 特别:《吝啬鬼》、《太太学校》、《恨世者》、《讨厌自己的医生》、《塔图弗》\n帕斯卡——《思想录》、《致外省人信札》、《科学论文集》\n惠更斯——《光论》\n斯宾诺莎——《伦理学》\n洛克——《论宽容》、《政府论》、《人类理解论》\n拉辛——悲剧 特别:《昂朵马格》、《费德儿》\n牛顿——《自然哲学的数学原理》、《光学》\n莱布尼兹——《形而上学序论》、《人类理智新论》、《单子论》\n笛福——《罗宾汉》\n斯威夫特——《致史黛拉书》、《格理弗游记》、《一个木桶的故事》、《一个小小的建议》\n康格里夫——《浮士道》\n柏克莱——《人类知识原理》\n蒲伯——《论批评》、《人论》、《鬈发遇劫记》\n孟德斯鸠——《波斯人信札》、《论法的精神》\n伏尔泰——《英国书简》、《憨第德》、《哲学词典》\n亨利·菲尔丁——《约瑟夫·安德鲁传》、《汤姆·琼斯》\n塞缪尔·约翰逊——《人类欲望的虚幻》、《英文辞典》、《拉塞勒斯》、《诗人传》\n休谟——《人性论》、《道德与政治文集》、《人类理智研究》\n让·雅各·卢梭——《论人类不平等的起源和基础》、《论政治经济学》、《爱弥尔》、《社会契约论》\n劳伦斯·斯特恩——《项狄传》、《在法国和意大利的伤感旅行》\n亚当·斯密——《道德情操论》、《国富论》\n康德——《纯粹理性批判》、《实践理性批判》、《法的形而上学原理-权利科学》、《判断力批判》、《论永久和平》\n爱德华·吉本——《罗马帝国的衰亡》\n包斯威尔——《伦敦日记》、《约翰逊传》\n拉瓦锡——《化学概要》\n多位——《联邦党人文集》\n边沁——《道德与立法原理导论》、《边沁的虚构理论(奥格登编撰)》\n歌德——《浮士德》、《诗与真相》\n傅立叶——《热的分析理论》\n黑格尔——《精神现象学》、《权利哲学》、《历史哲学》\n华兹华斯——诗 特别:《抒情歌谣集》、《露茜组诗》、《长诗(序曲)》\n萨缪尔·柯勒律——诗 特别:《忽必列汗》、《老水手行》\n奥斯汀——《傲慢与偏见》、《爱玛》\n克劳塞维茨——《战争论》\n司汤达——《红与黑》、《帕尔马修道院》、《爱情论》\n拜伦——《瑭璜》\n叔本华——《悲观主义的研究》\n法拉第——《蜡烛的化学历史》、《电学实验研究》\n莱伊尔——《地质学原理》\n孔德——《实证哲学教程》\n巴尔扎克——《高老头》、《欧也妮·葛朗台》\n爱默生——《代表人物》、《爱默生集:论文和讲演集》、《爱默生随笔》\n霍桑——《红字》\n托克威——《美国的民主政治》\n密尔——《理论学》、《论自由》、《代议制政府》、《功利主义》、《女性之卑屈》、《自传》\n查尔斯·达尔文——《物种起源》、《人类的由来》、《自传》\n查尔斯狄更斯——著作 特别:《匹克威克外传》、《大卫·科波维尔》、《艰难时世》\n克劳德·伯纳德——《实验医学研究导论》\n梭罗 ——《论公民的不服从》、《瓦尔登湖》\n马克思——《资本论》\n乔治·艾略特——《亚当·贝德》、《米德尔马契》\n赫尔曼·麦尔维尔——《莫比迪克(白鲸)》、《比理巴德》\n陀思妥耶夫斯基——《罪与罚》、《白痴》、《卡拉马佐夫兄弟》\nTable of Contents # 作者简介 序言 第一篇 阅读的层次 第一章 阅读的活力与艺术 1.主动的阅读 2.阅读的目标:为获得资讯而读,以及为求得理解而读 3.阅读就是学习:指导型的学习,以及自我发现型的学习之间的差异 4.老师的出席与缺席 第二章 阅读的层次 第三章 阅读的第一个层次:基础阅读 1.学习阅读的阶段 2.阅读的阶段与层次 3.更高层次的阅读与高等教育 4.阅读与民主教育的理念 第四章 阅读的第二个层次:检视阅读 1.检视阅读一:有系统的略读或粗读 2.检视阅读二:粗浅的阅读 3.阅读的速度 4.逗留与倒退 5.理解的问题 6.检视阅读的摘要 第五章 如何做一个自我要求的读者 1.主动的阅读基础:一个阅读者要提出的四个基本问题 2.如何让一本书真正属于你自己 3.三种做笔记的方法 4.培养阅读的习惯 5.由许多规则中养成一个习惯 第二篇 阅读的第三个层次:分析阅读 第六章 一本书的分类 1.书籍分类的重要性 2.从一本书的书名中你能学到什么 3.实用性VS.理论性作品 4.理论性作品的分类 第七章 透视一本书 1.结构与规划:叙述整本书的大意 2.驾驭复杂的内容:为一本书拟大纲的技巧 3.阅读与写作的互惠技巧 4.发现作者的意图 5.分析阅读的第一个阶段 第八章 与作者找出共通的词义 1.单字vs.词义 2.找出关键字 3.专门用语及特殊字汇 4.找出字义 第九章 判断作者的主旨 1.句子与主旨 2.找出关键句 3.找出主旨 4.找出论述 5.找出解答 6.分析阅读的第二个阶段 第十章 公正地评断一本书 1.受教是一种美德 2.修辞的作用 3.暂缓评论的重要性 4.避免争强好辩的重要性 5.化解争议 第十一章 赞同或反对作者 1.偏见与公正 2.判断作者的论点是否正确 3.判断作者论述的完整性 4.分析阅读的三阶段 第十二章 辅助阅读 1.相关经验的角色 2.其他的书可以当作阅读时的外在助力 3.如何运用导读与摘要 4.如何运用工具书 5.如何使用字典 6.如何使用百科全书 第三篇 阅读不同读物的方法 第十三章 如何阅读实用型的书 1.两种实用性的书 2.说服的角色 3.赞同实用书之后 第十四章 如何阅读想像文学 1.读想像文学的“不要” 2.阅读想像文学的一般规则 第十五章 阅读故事、戏剧与诗的一些建议 1.如何阅读故事书 2.关于史诗的重点 3.如何阅读戏剧 4.关于悲剧的重点 5.如何阅读抒情诗(Lyric Poetry) 第十六章 如何阅读历史书 1.难以捉摸的史实 2.历史的理论 3.历史中的普遍性 4.阅读历史书要提出的问题 5.如何阅读传记与自传 6.读关于当前的事件 7.摘的注意事项 第十七章 如何阅读科学与数学 1.了解科学这一门行业 2.阅读科学经典名著的建议 3.面对数学的问题 4.掌握科学作品中的数学问题 5.关于科普书的重点 第十八章 如何阅读哲学书 1.哲学家提出的问题 2.现代哲学与传承 3.哲学的方法 4.哲学的风格 5.阅读哲学的提示 6.厘清你的思绪 7.关于神学的重点 8.如何阅读“经书” 第十九章如何阅读社会科学 1.什么是社会科学? 2.阅读社会科学的容易处 3.阅读社会科学的困难处 4.阅读社会科学作品 第四篇 阅读的最终目标 第二十章 阅读的第四个层次:主题阅读 1.在主题阅读中,检视阅读所扮演的角色 2.主题阅读的五个步骤 3.客观的必要性 4.主题阅读的练习实例:进步论 5.如何应用主题工具书 6.构成主题阅读的原则 7.主题阅读精华摘要 第二十一章 阅读与心智的成长 1.好书能给我们什么帮助 2.书的金字塔 3.生命与心智的成长 附录一 建议阅读书目 附录一 建议阅读书目(中文版) "},{"id":131,"href":"/zh/docs/culture/%E5%A6%82%E4%BD%95%E9%98%85%E8%AF%BB%E4%B8%80%E6%9C%AC%E4%B9%A6/%E7%AE%80%E4%BB%8B-%E5%BA%8F%E8%A8%80/","title":"简介-序言","section":"如何阅读一本书","content":"如何阅读一本书\n[美]莫提默·J·艾德勒 查尔斯·范多伦\n作者简介 # 莫提默·J.艾德勒(1902-2001)以学者、教育家、编辑等多重面貌享有盛名。除了写作《如何阅读一本书》外,以主编《西方世界德经典》,并担任1974年第十五版《大英百科全书》的编辑指导而闻名于世。\n查尔斯·范多伦(1926- )\n先曾任美国哥伦比亚大学教授。后因故离任,和艾德勒一起工作。一方面襄助艾德勒编辑《大英百科全书》,一方面将本书1940年初版内容大幅度增补改写。因此,本书1970年新版由两个人共同署名。\n序言 # 《如何阅读一本书》的第一版是在1940年初出版的。很惊讶,我承认也很高兴的是,这本书立刻成为畅销书,高踞全美畅销书排行榜首有一年多时间。从1940年开始,这本书继续广泛的印刷发行,有精装本也有平装本,而且还被翻译成其他语言—法文、瑞典文、德文、西班牙文与意大利文。所以,为什么还要为目前这一代的读者再重新改写、编排呢?\n要这么做的原因,是近三十年来,我们的社会,与阅读这件事本身,都起了很大的变化。今天,完成高中教育及四年大学教育的年轻男女多了许多。尽管(或者说甚至因为)收音机及电视普及,识字的人也更多了。阅读的兴趣,有一种由小说类转移到非小说类的趋势。美国的教育人士都承认,教导年轻人阅读,以最基本的阅读概念来阅读,成了最重要的教育问题。曾经指出1970年代是阅读年代的现任健康、教育及福利部部长,提供了大笔大笔联邦政府经费,支持各式各样改进基本阅读技巧的努力,其中许多努力在启发儿童阅读的这种层次上也的确)有了些成果。此外,许多成人则着迷于速读课程亮丽的保证—增进他们阅读理解与阅读速度的保证。\n然而,过去三十年来,有些事情还是没有改变。其中一项是:要达到阅读的所有目的,就必须在阅读不同书籍的时候,运用适当的不同速度。不是所有的书都可以用最快的速度来阅读。法国学者巴斯卡(Pascal)在三百年前就说过:“读得太快或太慢,都一无所获。”现在既然速读已经形成全国性的狂热,新版的《如何阅读一本书》就针对这个问题,提出不同速度的阅读法才是解决之道。我们的目标是要读得更好,永远更好,不过,有时候要读得慢一点,有时候要读得快一点。\n很不幸的,另外有一件事也没有改变,那就是指导阅读的层次,仍然逗留在基本水平。我们教育体系里的人才、金钱与努力,大多花在小学六年的阅读指导上。超出这个范围,可以带引学生进人更高层次,需要不同阅读技巧的正式训练,则几乎少之又少。1939年,哥伦比亚大学教育学院的詹姆斯·墨塞尔(JamesMursell)教授在《大西洋月刊》上发表了一篇文章:《学校教育的失败》。现在我引述他当时所写的两段话,仍然十分贴切:\n学校是否有效地教导过学生如何阅读母语?可以说是,也可以说不是。到五六年级之前,整体来说,阅读是被有效地教导过,也学习过了。在这之前,我们发现阅读的学习曲线是稳定而普遍进步的,但是过了这一点之后,曲线就跌入死寂的水平。这不是说一个人到了六年级就达到个人学习能力的自然极限,因为证据一再显示,只要经过特殊的教导,成人及大一点的孩童,都能有显著的进步。同时,这也不表示大多数六年级学生在阅读各种实用书籍的时候,都已经有足够的理解能力。许许多多学生进入中学之后成绩很差,就是因为读不懂书中的意义。他们可以改进,他们也需要改进,但他们就不这么做。\n中学毕业的时候,学生都读过不少书了。但如果他要继续念大学,那就得还要念更多的书,不过这个时候他却很可能像是一个可怜而根本不懂得阅读的人(请注意:这里说的是一般学生,而不是受过特别娇正训练的学生)。他可以读一点简单的小说,享受一下。但是如果要他阅读结构严谨的细致作品,或是精简扼要的论文,或是需要运用严密思考的章节,他就没有办法了。举例来说,有人证明过,要一般中学生掌握一段文字的中心思想是什么,或是论述文的重点及次要重点在哪里,简直就是难上加难。不论就哪一方面来说,就算进了大学,他的阅读能力也都只会停留在小学六年级的程度。\n如果三十年前社会对《如何阅读一本书》有所需求,就像第一版所受到的欢迎的意义,那么今天就更需要这样的一本书了。但是,回应这些迫切的需求,并不是重写这本书的惟一动机,甚至也不是主要的动机。对于学习“如何阅读”这个问题的新观点;对于复杂的阅读艺术更深的理解与更完整的分析理念;对于如何弹性运用基本规则做不同形态的阅读(事实上可引伸到所有种类的读物上);对于新发明的阅读规则;对于读书应如金字塔—基础厚实,顶端尖锐等等概念,都是三十年前我写这本书时没有适当说明,或根本没提到的概念。所有这些,都在催促我加以阐述并重新彻底改写,呈现现在所完成,也出版的这个面貌。\n《如何阅读一本书》出版一年后,出现了博君一粲的模仿书《如何阅读两本书》(How to Read Two Books),而I. A.理查兹教授(I. A. Richards)则写了一篇严肃的论文《如何阅读一页书》(How to Read aPage)。提这些后续的事,是要指出这两部作品中所提到的一些阅读的问题,无论是好笑还是严肃的问题,都在我重写的书中谈到了,尤其是针对如何阅读一系列相关的书籍,并清楚掌握其针对同一主题相互补充与冲突的问题。\n在重写《如何阅读一本书》的种种理由当中,我特别强调了阅读的艺术,也指出对这种艺术更高水准的要求。这是第一版中我们没有谈到或详细说明的部分。任何人想要知道增补了些什么,只要比较新版与原版的目录,很快就会明白。在本书的四篇之中,只有第二篇,详述“分析阅读”(Analytical Reading)规则的那一篇,与原版很相近,但事实上也经过大幅度的改写。第一篇,介绍四种不同层次的阅读—基础阅读(elementaryreading)、检视阅读(inspectional reading)、分析阅读、主题阅读(syntopical reading)是本书在编排与内容上最基本也最决定性的改变。第三篇是全书增加最多的部分,详加说明了以不同阅读方法接触不同读物之道—如何阅读实用性与理论性作品、想像的文学(抒情诗、史诗、小说、戏剧)、历史、科学与数学、社会科学与哲学,以及参考书、报章杂志,甚至广告。最后,第四篇,主题阅读的讨论,则是全新的章节。\n在重新增订这本书时,我得到查尔斯·范多伦(Charles Van Doren)的帮助。他是我在哲学研究院(Institute for Philosophical Research)多年的同事。我们一起合写过其他的书,最为人知的是1969年由大英百科全书出版公司出版的二十册《美国编年史)(Annals\nofAmerica)。至于我们为什么要合作,共同挂名来改写本书,也许有个更相关的理由是:过去八年来,我和范多伦共同密切合作主持过许多经典著作(great books)的讨论会,以及在芝加哥、旧金山、科罗拉多州的阿斯本举行的许多研讨会。由于这些经验,我们获得了许多新观点来重写这本书。\n我很感激范多伦先生在我们合作中的贡献。对于建设性的批评与指导,他和我都想表达最深的谢意。也要谢谢我们的朋友,亚瑟·鲁宾(Arthur L.H.Rubin)的帮助—他说服我们在新版中提出许多重大的改变,使这本书得以与前一版有不同的生命,也成为我们所希望更好、更有用的一本书。\n莫提默·J·艾德勒\n1972年3月26日写于波卡格兰德(Boca Grande)\n"},{"id":132,"href":"/zh/docs/culture/%E5%A6%82%E4%BD%95%E9%98%85%E8%AF%BB%E4%B8%80%E6%9C%AC%E4%B9%A6/%E7%AC%AC%E4%B8%80%E7%AF%87_%E9%98%85%E8%AF%BB%E7%9A%84%E5%B1%82%E6%AC%A1/","title":"第一篇 阅读的层次","section":"如何阅读一本书","content":"第一篇 阅读的层次\n第一章 阅读的活力与艺术 # 这是一本为阅读的人,或是想要成为阅读的人而写的书。尤其是想要阅读书的人。说得更具体一点,这本书是为那些想把读书的主要目的当作是增进理解能力的人而写。\n这里所谓“阅读的人”(readers),是指那些今天仍然习惯于从书写文字中汲取大量资讯,以增进对世界了解的人,就和过去历史上每一个深有教养、智慧的人别无二致。当然,并不是每个人都能做到这一点。即使在收音机、电视没有出现以前,许多资讯与知识也是从口传或观察而得。但是对智能很高又充满好奇心的人来说,这样是不够的。他们知道他们还得阅读,而他们也真的身体力行。\n现代的人有一种感觉,读书这件事好像已经不再像以往那样必要了。收音机,特别是电视,取代了以往由书本所提供的部分功能,就像照片取代了图画或艺术设计的部分功能一样。我们不得不承认,电视有部分的功能确实很惊人,譬如对新闻事件的影像处理,就有极大的影响力。收音机最大的特点在于当我们手边正在做某件事(譬如开车)的时候,仍然能提供我们资讯,为我们节省不少的时间。但在这中间还是有一个严肃的议题:到底这些新时代的传播媒体是否真能增进我们对自己世界的了解?\n或许我们对这个世界的了解比以前的人多了,在某种范围内,知识(knowledge)也成了理解(understanding)的先决条件。这些都是好事。但是,“知识”是否那么必然是“理解”的先决条件,可能和一般人的以为有相当差距。我们为了“理解”(understand)一件事,并不需要“知道”(know)和这件事相关的所有事情。太多的资讯就如同太少的资讯一样,都是一种对理解力的阻碍。换句话说,现代的媒体正以压倒性的泛滥资讯阻碍了我们的理解力。\n会发生这个现象的一个原因是:我们所提到的这些媒体,经过太精心的设计,使得思想形同没有需要了(虽然只是表象如此)。如何将知识分子的态度与观点包装起来,是当今最有才智的人在做的最活跃的事业之一。电视观众、收音机听众、杂志读者所面对的是一种复杂的组成—从独创的华丽辞藻到经过审慎挑选的资料与统计—目的都在让人不需要面对困难或努力,很容易就整理出“自己”的思绪。但是这些精美包装的资讯效率实在太高了,让观众、听众或读者根本用不着自己做结论。相反的,他们直接将包装过后的观点装进自己的脑海中,就像录影机愿意接受录影带一样自然。他只要按一个“倒带”的钮,就能找到他所需要的适当言论。他根本不用思考就能表现得宜。\n1.主动的阅读 # 我们在一开始就说过,我们是针对发展阅读书的技巧而写的。但是如果你真的跟随并锻炼这些阅读的技巧,你便可以将这些技巧应用在任何印刷品的阅读上—报纸、杂志、小册子、文章、短讯,甚至广告。\n既然任何一种阅读都是一种活动,那就必须要有一些主动的活力。完全被动,就阅读不了—我们不可能在双眼停滞、头脑昏睡的状况下阅读。既然阅读有主动、被动之对比,那么我们的目标就是:第一提醒读者,阅读可以是一件多少主动的事。第二要指出的是,阅读越主动,效果越好。这个读者比另一个读者更主动一些,他在阅读世界里面的探索能力就更强一些,收获更多一些,因而也更高明一些。读者对他自己,以及自己面前的书籍,要求的越多,获得的就越多。\n虽然严格说来,不可能有完全被动阅读这回事,但还是有许多人认为,比起充满主动的写跟说,读与听完全是被动的事。写作者及演说者起码必须要花一点力气,听众或读者却什么也不必做。听众或读者被当作是一种沟通接收器,“接受”对方很卖力地在“给予”、“发送”的讯息。这种假设的谬误,在认为这种“接收”类同于被打了一拳,或得到一项遗产,或法院的判决。其实完全相反,听众或读者的“接收”,应该像是棒球赛中的捕手才对。\n捕手在接球时所发挥的主动是跟投手或打击手一样的。投手或打击手是负责“发送”的工作,他的行动概念就在让球动起来这件事上。捕手或外野手的责任是“接收”,他的行动就是要让球停下来。两者都是一种活动,只是方式有点不同。如果说有什么是被动的,就是那只球了。球是毫无感觉的,可以被投手投出去,也可以被捕手接住,完全看打球的人如何玩法。作者与读者之间的关系也很类似。写作与阅读的东西就像那只球一样,是被主动、有活力的双方所共有的,是由一方开始,另一方终结的。\n我们可以把这个类比的概念往前推。捕手的艺术就在能接住任何球的技巧—快速球、曲线球、变化球、慢速球等等。同样地,阅读的艺术也在尽可能掌握住每一种讯息的技巧。\n值得注意的是,只有当捕手与投手密切合作时,才会成功。作者与读者的关系也是如此。作者不会故意投对方接不到的球,尽管有时候看来如此。在任何案例中,成功的沟通都发生于作者想要传达给读者的讯息,刚好被读者掌握住了。作者的技巧与读者的技巧融合起来,便达到共同的终点。\n事实上,作者就很像是一位投手。有些作者完全知道如何“控球”:他们完全知道自己要传达的是什么,也精准正确地传达出去了。因此很公平地,比起一个毫无“控球”能力的“暴投”作家,他们是比较容易被读者所“接住”的。\n这个比喻有一点不恰当的是:球是一个单纯的个体,不是被完全接住,就是没接住。而一本作品,却是一个复杂的物件,可能被接受得多一点,可能少一点;从只接受到作者一点点概念到接受了整体意念,都有可能。读者想“接住”多少意念完全看他在阅读时多么主动,以及他投人不同心思来阅读的技巧如何。\n主动的阅读包含哪些条件?在这本书中我们会反复谈到这个问题。此刻我们只能说:拿同样的书给不同的人阅读,一个人却读得比另一个人好这件事,首先在于这人的阅读更主动,其次,在于他在阅读中的每一种活动都参与了更多的技巧。这两件事是息息相关的。阅读是一个复杂的活动,就跟写作一样,包含了大量不同的活动。要达成良好的阅读,这些活动都是不可或缺的。一个人越能运作这些活动,阅读的效果就越好。\n2.阅读的目标:为获得资讯而读,以及为求得理解而读 # 你有一个头脑。现在让我再假设你有一本想要读的书。这本书是某个人用文字书写的,想要与你沟通一些想法。你要能成功地阅读这本书,完全看你能接获多少作者想要传达的讯息。\n当然,这样说太简单了。因为在你的头脑与书本之间可能会产生两种关系,而不是一种。阅读的时候有两种不同的经验可以象征这两种不同的关系。\n这是书,那是你的头脑。你在阅读一页页的时候,对作者想要说的话不是很了解,就是不了解。如果很了解,你就获得了资讯(但你的理解不一定增强)。如果这本书从头到尾都是你明白的,那么这个作者跟你就是两个头脑却在同一个模子里铸造出来。这本书中的讯息只是将你还没读这本书之前,你们便共同了解的东西传达出来而已。\n让我们来谈谈第二种情况。你并不完全了解这本书。让我们假设—不幸的是并非经常如此—你对这本书的了解程度,刚好让你明白其实你并不了解这本书。你知道这本书要说的东西超过你所了解的,因此认为这本书包含了某些能增进你理解的东西。\n那你该怎么办?你可以把书拿给某个人,你认为他读得比你好的人,请他替你解释看不懂的地方。(“他”可能代表一个人,或是另一本书—导读的书或教科书。)或是你会决定,不值得为任何超越你头脑理解范围之外的书伤脑筋,你理解得已经够多了。不管是上述哪一种状况,你都不是本书所说的真正地在阅读。\n只有一种方式是真正地在阅读。没有任何外力的帮助,你就是要读这本书。你什么都没有,只凭着内心的力量,玩味着眼前的字句,慢慢地提升自己,从只有模糊的概念到更清楚地理解为止。这样的一种提升,是在阅读时的一种脑力活动,也是更高的阅读技巧。这种阅读就是让一本书向你既有的理解力做挑战。\n这样我们就可以粗略地为所谓的阅读艺术下个定义:这是一个凭借着头脑运作,除了玩味读物中的一些字句之外,不假任何外助,以一己之力来提升自我的过程。你的头脑会从粗浅的了解推进到深人的理解。而会产生这种结果的运作技巧,就是由许多不同活动所组合成的阅读的艺术。\n凭着你自己的心智活动努力阅读,从只有粗浅的了解推进到深人的体会,就像是自我的破茧而出。感觉上确实就是如此。这是最主要的作用。当然,这比你以前的阅读方式要多了很多活动,而且不只是有更多的活动,还有要完成这些多元化活动所需要的技巧。除此之外,当然,通常需要比较高难度阅读要求的读物,都有其相对应的价值,以及相对应水平的读者。\n为获得资讯而阅读,与为增进理解而阅读,其间的差异不能以道里计。我们再多谈一些。我们必须要考虑到两种阅读的目的。因为一种是读得懂的东西,另一种是必须要读的东西,二者之间的界限通常是很模糊的。在我们可以让这两种阅读目的区分开来的范围内,我们可以将“阅读”这个词,区分成两种不同的意义。\n第一种意义是我们自己在阅读报纸、杂志,或其他的东西时,凭我们的阅读技巧与聪明才智,一下子便能融会贯通了。这样的读物能增加我们‘的资讯,却不能增进我们的理解力,因为在开始阅读之前,我们的理解力就已经与他们完全相当了。否则,我们一路读下来早就应该被困住或吓住了—这是说如果我们够诚实、够敏感的话。\n第二种意义是一个人试着读某样他一开始并不怎么了解的东西。这个东西的水平就是比阅读的人高上一截。这个作者想要表达的东西,能增进阅读者的理解力。这种双方水准不齐之下的沟通,肯定是会发生的,否则,无论是透过演讲或书本,谁都永远不可能从别人身上学习到东西了。这里的“学习”指的是理解更多的事情,而不是记住更多的资讯—和你已经知道的资讯在同一水平的资讯。\n对一个知识分子来说,要从阅读中获得一些和他原先熟知的事物相类似的新资讯,并不是很困难的事。一个人对美国历史已经知道一些资料,也有一些理解的角度时,他只要用第一种意义上的阅读,就可以获得更多的类似资料,并且继续用原来的角度去理解。但是,假设他阅读的历史书不只是提供给他更多资讯,而且还在他已经知道的资讯当中,给他全新的或更高层次的启发。也就是说,他从中获得的理解超越了他原有的理解。如果他能试着掌握这种更深一层的理解,他就是在做第二种意义的阅读了。他透过阅读的活动间接地提升了自己,当然,不是作者有可以教他的东西也达不到这一点。\n在什么样的状况下,我们会为了增进理解而阅读?有两种状况:第一是一开始时不相等的理解程度。在对一本书的理解力上,作者一定要比读者来得“高杆”,写书时一定要用可读的形式来传达他有而读者所无的洞见。其次,阅读的人一定要把不相等的理解力克服到一定程度之内,虽然不能说全盘了解,但总是要达到与作者相当的程度。一旦达到相同的理解程度,就完成了清楚的沟通。\n简单来说,我们只能从比我们“更高杆”的人身上学习。我们一定要知道他们是谁,如何跟他们学习。有这种想法的人,就是能认知阅读艺术的人,就是我们这本书主要关心的对象。而任何一个可以阅读的人,都有能力用这样的方式来阅读。只要我们努力运用这样的技巧在有益的读物上,每个人都能读得更好,学得更多,毫无例外。\n我们并不想给予读者这样的印象:事实上,运用阅读以增加资讯与洞察力,与运用阅读增长理解力是很容易区分出来的。我们必须承认,有时候光是听别人转述一些讯息,也能增进很多的理解。这里我们想要强调的是:本书是关于阅读的艺术,是为了增强理解力而写的。幸运的是,只要你学会了这一点,为获取资讯而阅读的另一点也就不是问题了。\n当然,除了获取资讯与理解外,阅读还有一些其他的目标,就是娱乐。无论如何,本书不会谈论太多有关娱乐消遣的阅读。那是最没有要求,也不需要太多努力就能做到的事。而且那样的阅读也没有任何规则。任何人只要能阅读,想阅读,就能找一份读物来消遣。\n事实上,任何一本书能增进理解或增加资讯时,也就同时有了消遣的效果。就像一本能够增进我们理解力的书,也可以纯粹只读其中所包含的资讯一样。(这个情况并不是倒过来也成立:并不是每一种拿来消遣的书,都能当作增进我们的理解力来读。)我们也绝不是在鼓励你绝不要阅读任何消遣的书。重点在于,如果你想要读一本有助于增进理解力的好书,那我们是可以帮得上忙的。因此,如果增进理解力是你的目标,我们的主题就是阅读好书的艺术。\n3.阅读就是学习:指导型的学习,以及自我发现型的学习之间的差异 # 吸收资讯是一种学习,同样地,对你以前不了解的事开始理解了,也是一种学习。但是在这两种学习当中,却有很重要的差异。\n所谓吸收资讯,就只是知道某件事发生了。想要被启发,就是要去理解,搞清楚这到底是怎么回事:为什么会发生,与其他的事实有什么关联,有什么类似的情况,同类的差异在哪里等等。\n如果用你记得住什么事情,和你解释得了什么事情之间的差异来说明,就会比较容易明白。如果你记得某个作者所说的话,就是你在阅读中学到了东西。如果他说的都是真的,你甚至学到了有关这个世界的某种知识。但是不管你学到的是有关这本书的知识或有关世界的知识,如果你运用的只是你的记忆力,其实你除了那些讯息之外一无所获。你并没有被启发。要能被启发,除了知道作者所说的话之外,还要明白他的意思,懂得他为什么会这么说。\n当然,你可以同时记得作者所说的话,也能理解他话中的含义。吸收资讯是要被启发的前一个动作。无论如何,重点在不要止于吸收资讯而已。\n蒙田说:“初学者的无知在于未学,而学者的无知在于学后。”第一种的无知是连字母都没学过,当然无法阅读。第二种的无知却是读错了许多书。英国诗人亚历山大·蒲伯(Alexander Pope)称这种人是书呆子,无知的阅读者。总有一些书呆子读得太广,却读不通。希腊人给这种集阅读与愚蠢于一身的人一种特别称呼,这也可运用在任何年纪、好读书却读不懂的人身上。他们就叫“半瓶醋\u0026quot;(Sophomores)。\n要避免这样的错误—以为读得多就是读得好的错误—我们必须要区分出各种不同的阅读形态。这种区分对阅读的本身,以及阅读与一般教育的关系都有很重大的影响。\n在教育史上,人们总是将经由指导的学习,与自我发现的学习区别出来。一个人用言语或文字教导另一个人时,就是一种被引导的学习。当然,没有人教导,我们也可以学习。否则,如果每一位老师都必须要人教导过,才能去教导别人,就不会有求知的开始了。因此,自我发现的学习是必要的—这是经由研究、调查或在无人指导的状况下,自己深思熟虑的一种学习过程。\n自我发现的学习方式就是没有老师指导的方式,而被引导的学习就是要旁人的帮助。不论是哪一种方式,只有真正学习到的人才是主动的学习者。因此,如果说自我发现的学习是主动的,指导性的学习是被动的,很可能会造成谬误。其实,任何学习都不该没有活力,就像任何阅读都不该死气沉沉。\n这是非常真确的道理。事实上,要区分得更清楚一些的话,我们可以称指导型的学习是“辅助型的自我发现学习”。用不着像心理学家作深人的研究,我们也知道教育是非常特殊的艺术,与其他两种学术—农业与医学—一样,都有极为重要的特质。医生努力为病人做许多事,但最终的结论是这个病人必须自己好起来—变得健康起来。农夫为他的植物或动物做了许多事,结果是这些动植物必须长大,变得更好。同样地,老师可能用尽了方法来教学生,学生却必须自己能学习才行。当他学习到了,知识就会在他脑中生根发芽。\n指导型的学习与自我发现型的学习之间的差异—或是我们宁可说是在辅助型,及非辅助型的自我发现学习之间的差异—一个最基本的不同点就在学习者所使用的教材上。当他被指导时—在老师的帮助下自我发现时—学习者的行动立足于传达给他的讯息。他依照教导行事,无论是书写或口头的教导。他学习的方式就是阅读或倾听。在这里要注意阅读与倾听之间的密切关系。如果抛开这两种接收讯息方式之间的微小差异性,我们可以说阅读与倾听是同一种艺术—被教导的艺术。然而,当学习者在没有任何老师指导帮助下开始学习时,学习者则是立足于自然或世界,而不是教导来行动。这种学习的规范就构成了非辅助型的自我发现的学习。如果我们将“阅读”的含义放宽松一点,我们可以说自我发现型的学习—严格来说,非辅助型的自我发现学习—是阅读自我或世界的学习。就像指导型的学习(被教导,或辅助型的学习)是阅读一本书,包括倾听,从讲解中学习的一种艺术。\n那么思考呢?如果“思考”是指运用我们的头脑去增加知识或理解力,如果说自我发现型的学习与指导型的学习是增加知识的惟二法门时,那么思考一定是在这两种学习当中都会出现的东西。在阅读与倾听时我们必须要思考,就像我们在研究时一定要思考。当然,这些思考的方式都不相同—就像两种学习方式之不同。\n为什么许多人认为,比起辅助型学习,思考与非辅助型(或研究型)的自我发现学习更有关联,是因为他们假定阅读与倾听是丝毫不需要花力气的事。比起一个正在作研究发明的人,一个人在阅读资讯或消遣时,确实可能思考得较少一些。而这些都是比较被动的阅读方式。但对比较主动的阅读—努力追求理解力的阅读—来说,这个说法就不太正确了。没有一个这样阅读的人会说,那是丝毫不需要思考就能完成的工作。\n思考只是主动阅读的一部分。一个人还必须运用他的感觉与想像力。一个人必须观察,记忆,在看不到的地方运用想像力。我们要再提一次,这就是在非辅助型的学习中经常想要强调的任务,而在被教导型的阅读,或倾听学习中被遗忘或忽略的过程。譬如许多人会假设一位诗人在写诗的时候一定要运用他的想像力,而他们在读诗时却用不着。简单地说,阅读的艺术包括了所有非辅助型自我发现学习的技巧:敏锐的观察、灵敏可靠的记忆、想像的空间,再者当然就是训练有素的分析、省思能力。这么说的理由在于:阅读也就是一种发现—虽然那是经过帮助,而不是未经帮助的一个过程。\n4.老师的出席与缺席 # 一路谈来,我们似乎把阅读与倾听都当作是向老师学习的方式。在某种程度上,这确实是真的。两种方式都是在被指导,同样都需要被教导的技巧。譬如听一堂课就像读一本书一样,而听人念一首诗就跟亲自读到那首诗是一样的。在本书中所列举的规则跟这些经验都有关。但特别强调阅读的重要性,而将倾听当作第二顺位的考量,有很充分的理由。因为倾听是从一位出现在你眼前的老师学习—一位活生生的老师—而阅读却是跟一位缺席的老师学习。\n如果你问一位活生生的老师一个问题,他可能会回答你。如果你还是不懂他说的话,你可以再问他问题,省下自己思考的时间。然而,如果你问一本书一个问题,你就必须自己回答这个问题。在这样的情况下,这本书就跟自然或世界一样。当你提出间题时,只有等你自己作了思考与分析之后,才会在书本上找到答案。\n当然,这并不是说,如果有一位活生生的老师能回答你的问题,你就用不着再多做功课。如果你问的只是一件简单的事实的陈述,也许如此。但如果你追寻的是一种解释,你就必须去理解它,否则没有人能向你解释清楚。更进一步来说,一位活生生的老师出现在你眼前时,你从了解他所说的话,来提升理解力。而如果一本书就是你的老师的话,你就得一切靠自己了。\n在学校的学生通常会跟着老师或指导者阅读比较困难的书籍。但对我们这些已经不在学校的人来说,当我们试着要读一本既非主修也非选修的书籍时,也就是我们的成人教育要完全依赖书籍本身的时候,我们就不能再有老师的帮助了。因此,如果我们打算继续学习与发现,我们就要懂得如何让书本来教导我们。事实上,这就是本书最主要的目的。\n第二章 阅读的层次 # 在前一章里,我们说明了一些差异性的问题,这对接下来要说的事很重要。一位读者要追求的目标—为了消遣,获得资讯或增进理解力—会决定他阅读的方式。至于阅读的效果则取决于他在阅读上花了多少努力与技巧。一般来说,阅读的规则是:努力越多,效果越好。至少在阅读某些超越我们能力的书时,花一点力气就能让我们从不太了解进升到多一些了解的状态。最后,指导型与自我发现型学习(或辅助型与非辅助型自我发现学习)之间的区别之所以重要,因为我们大多数人在阅读时,都经常是没有人在旁边帮助的。阅读,就像是非辅助型的自我发现学习,是跟着一位缺席的老师在学习。只有当我们知道如何去读时,我们才可能真正读懂。\n虽然这些差异性很重要,但是这一章我们着墨不多。本章所谈的重点在阅读的层次问题。想要增进阅读的技巧之前,一定要先了解阅读层次的不同。\n一共有四种层次的阅读。我们称之为层次,而不称为种类的原因是,严格来说,种类是样样都不相同的,而层次却是再高的层次也包含了较低层次的特性。也就是说,阅读的层次是渐进的。第一层次的阅读并没有在第二层次的阅读中消失,第二层又包含在第三层中,第三层又在第四层中。事实上,第四层是最高的阅读层次,包括了所有的阅读层次,也超过了所有的层次。\n第一层次的阅读,我们称之为基础阅读(elementary reading)。也可以用其他的名称,如初级阅读、基本阅读或初步阅读。不管是哪一种名称,都指出一个人只要熟练这个层次的阅读,就摆脱了文盲的状态,至少已经开始认字了。在熟练这个层次的过程中,一个人可以学习到阅读的基本艺术,接受基础的阅读训练,获得初步的阅读技巧。我们之所以喜欢“基础阅读”这个名称,是因为这个阅读层次的学习通常是在小学时完成的。\n小孩子首先接触的就是这个层次的阅读。他的问题(也是我们开始阅读时的问题)是要如何认出一页中的一个个字。孩子看到的是白纸上的一堆黑色符号(或是黑板上的白色符号—如果他是从黑板上认字的话),而这些黑色符号代表着:“猫坐在帽子上。”一年级的孩子并不真的关心猫是不是坐在帽子上,或是这句话对猫、帽子或整个世界有什么意义。他关心的只是写这句话的人所用的语言。\n在这个层次的阅读中,要问读者的问题是:“这个句子在说什么?”当然,这个问题也有复杂与困难的一面,不过,我们在这里所说的只是最简单的那一面。\n对几乎所有阅读本书的读者来说,这个层次的阅读技巧应该在多年前就早已经学会了。但是,不论我们身为读者有多精通这样的阅读技巧,我们在阅读的时候还是一直会碰上这个层次的阅读问题。譬如,我们打开一本书想读的时候,书中写的却是我们不太熟悉的外国文字,这样的问题就发生了。这时我们要做的第一步努力就是去弄清楚这些字。只有当我们完全明白每个字的意思之后,我们才能试着去了解,努力去体会这些字到底要说的是什么。\n其实就算一本书是用本国语言写的,许多读者仍然会碰上这个阅读层次的各种不同的困难。大部分的困难都是技术性的问题,有些可以追溯到早期阅读教育的问题。克服了这些困难,通常能让我们读得更快一些。因此,大部分的速读课程都着眼在这个层次的阅读上。在下一章我们会详细讨论基础阅读,而速读会在第四章谈到。\n第二个层次的阅读我们称之为检视阅读(inspectional reading)。特点在强调时间。在这个阅读层次,学生必须在规定的时间内完成一项阅读的功课。譬如他可能要用十五分钟读完一本书,或是同样时间内念完两倍厚的书。\n因此,用另一种方式来形容这个层次的阅读,就是在一定的时间之内,抓出一本书的重点—通常是很短,而且总是(就定义上说)过短,很难掌握一本书所有重点。\n这个层次的阅读仍然可以用其他的称呼,譬如略读或预读。我们并不是说略读就是随便或随意浏览一本书。检视阅读是系统化略读(skimming systematically)的一门艺术。\n在这个层次的阅读上,你的目标是从表面去观察这本书,学习到光是书的表象所教给你的一切。这笔交易通常是很划得来的。\n如果第一层次的阅读所问的问题是:“这个句子在说什么?”那么在这个层次要问的典型问题就是:“这本书在谈什么?”这是个表象的问题。还有些类似的问题是:“这本书的架构如何?”或是:“这本书包含哪些部分?”\n用检视阅读读完一本书之后,无论你用了多短的时间,你都该回答得出这样的问题:“这是哪一类的书—小说、历史,还是科学论文?”\n第四章我们还会详细讨论这个层次的阅读,现在就不作进一步的说明了。我们想要强调的是,大多数人,即使是许多优秀的阅读者,都忽略了检视阅读的价值。他们打开一本书,从第一页开始读起,孜孜不倦,甚至连目录都不看一眼。因此,他们在只需要粗浅翻阅一本书的时候,却拿出了仔细阅读、理解一本书的时间。这就加重了阅读的困难。\n第三种层次的阅读,我们称之为分析阅读(analytical reading)。比起前面所说的两种阅读,这要更复杂,更系统化。随内文难读的程度有所不同,读者在使用这种阅读法的时候,多少会相当吃力。\n分析阅读就是全盘的阅读、完整的阅读,或是说优质的阅读—你能做到的最好的阅读方式。如果说检视阅读是在有限的时间内,最好也最完整的阅读,那么分析阅读就是在无限的时间里,最好也最完整的阅读。\n一个分析型的阅读者一定会对自己所读的东西提出许多有系统的问题。我们并不想在这里强调这个问题,因为本书主要就是在谈这个层次的阅读:本书的第二篇就是告诉你如何这么做的一些规则。我们要在这里强调的是,分析阅读永远是一种专注的活动。在这个层次的阅读中,读者会紧抓住一本书—这个比喻蛮恰当的—一直要读到这本书成为他自己为止。弗兰西斯·培根曾经说过:“有些书可以浅尝即止,有些书是要生吞活剥,只有少数的书是要咀嚼与消化的。”分析阅读就是要咀嚼与消化一本书。\n我们还要强调的是,如果你的目标只是获得资讯或消遣,就完全没有必要用到分析阅读。分析阅读就是特别在追寻理解的。相对的,除非你有相当程度的分析阅读的技巧,否则你也很难从对一本书不甚了解,进步到多一点的理解。\n第四种,也是最高层次的阅读,我们称之为主题阅读(syntopicalreading)。这是所有阅读中最复杂也最系统化的阅读。对阅读者来说,要求也非常多,就算他所阅读的是一本很简单、很容易懂的书也一样。\n也可以用另外的名称来形容这样的阅读,如比较阅读(comparative reading)。在做主题阅读时,阅读者会读很多书,而不是一本书,并列举出这些书之间相关之处,提出一个所有的书都谈到的主题。但只是书本字里行间的比较还不够。主题阅读涉及的远不止此。借助他所阅读的书籍,主题阅读者要能够架构出一个可能在哪一本书里都没提过的主题分析。因此,很显然的,主题阅读是最主动、也最花力气的一种阅读。\n我们会在第四篇讨论主题阅读。此刻我们只粗浅地说,主题阅读不是个轻松的阅读艺术,规则也并不广为人知。虽然如此,主题阅读却可能是所有阅读活动中最有收获的。就是因为你会获益良多,所以绝对值得你努力学习如何做到这样的阅读。\n第三章 阅读的第一个层次:基础阅读 # 我们生活在对阅读有很高的兴趣与关心的年代。官方宣称1970年代是“读书的年代”。畅销书告诉我们为什么强尼会念书或不会念书。在初步阅读的教学领域中,也有越来越多的人在作研究与实验。\n我们的年代会产生这样的狂热,是因为三个历史性的趋势或演变刚好聚合起来了。第一是美国在继续推行全民教育,这就是说,当然,最少要做到全国没有文盲。多年来美国一直在作这样的努力,甚至从国家草创时期就开始,成为民主生活的基石,而且也成果显著。美国比任何其他国家都更早达到接近全民教育,因而也帮助美国成为今天高度开发的现代工业化社会。但是其中也产生了许多问题。总括而言,要教育少数具有高度学习动机的孩子阅读(通常他们的父母都是知识分子),和教育一些不管动机有多微弱,或家庭有多贫困的孩子阅读,是完全不同的两码事—一百年前如此,今天依然如此。\n第二个历史趋向是阅读教育的本身起了变化。迟至1870年,大家所受的阅读教育,跟早期希腊或罗马学校没什么两样。在美国,至少所谓的ABC教学法仍然掌控了整个19世纪。孩子要学着分别以每一个字母来发音—这也是这个教学法名称的由来—然后再组合成音节,先是第一、二个字母,再来是三跟四,而不管这样拼出来的字是否有意义。因此,那些想要精通语言的人,就会勤练像是ab,ac,ad,ib,ic这样的音节。当一个孩子能记住所有组合的音节时,他就可以说是懂得ABC了。\n这样的阅读教学法在19世纪中叶受到严厉的批评,于是产生了两种变革。一种是ABC教学法的改变,变成了发音法(phonic method)。这样,认字不是由字母来认,而是由发音来辨识了。为了呈现某个字母所代表的各种发音,尤其是母音,得动用许多复杂又独创的印刷技术。如果你已经五十岁以上,在学校里所学的很可能就是这一类的发音法。\n另外有一种完全不同,着重分析,而非人为的教学法。起源于德国,由霍拉斯·曼(Horace Mann)与其他的教育专家在1840年所提倡。这个教学法强调在注意到每一个字母或发音之前,先以视觉认知整个单字。后来,这种所谓的视觉法(sight method)先看整个句子与其中的含义,然后才学习认识单字,最后才是字母。这种方法在19201930年间非常盛行,那段时期也正是强调从口语阅读转变成默读的转变时期。研究发现,口语阅读的能力在默读时并非必要,因此如果是以默读为目标的话,口语阅读的教学法也不一定适用了。因此,从1920-1925年,默读理解的阅读法几乎成为一家独尊的潮流。不过,后来潮流又转向了,发音法又受到了重视—事实上,发音法从来没有遭到过淘汰。\n所有这些不同的基础阅读教学法,对某些学生来说很有用,对另外一些学生却可能不管用。在过去的二三十年中,失败的案例总是引起更多的注意。结果第三次历史性的变动又兴起了。在美国,批判学校是一种传统。许多世纪以来,父母、自命专家的人与教育者都在攻击与控诉教育系统。在对学校所有的批评中,阅读教育受到最严厉的批评。现在所使用的教科书已经有长长的世系背景,而每次革新,都会带来一堆怀疑论者,与一些很难说服的观察者。\n这些批评可能对,也可能不对。但是,不论如何,随着全民教育进入新的一页,高中和大专学生日益增多,问题也呈现了新的尖锐面貌。一个不懂得如何阅读的年轻男子或年轻女子,在他追求美国梦的途中就会受到阻碍。如果他不在学校里,那主要是他个人的大问题。但如果他还在高中或大专求学,那就会成为他的老师和同学都关心的问题。\n因此,目前教育研究者非常活跃,他们的工作成果表现在许多新的阅读教学法上。在一些比较重要的新教学法中,包括了折衷教学法(eclectic approach),个别阅读教学法( individualized reading ap-proach)、语言经验教学法(language-experience approach),许多根据语言学原则而来的教学法,以及其他一些和某种特定教育计划多少挂钩的教学法。除此之外,一些新的媒介,如初期教学字母(InitialTeaching Alphabet)也被引进,有时候其中又包含了新的教学法。另外还有一些教学法如“全神贯注教学法,,(total immersion method)、“外国语言学校教法\u0026quot;(foreign-language-school method),以及众所周知的“看说\u0026quot;(see-say)、“看与说\u0026quot;(look-say)或“看到就说”(look-and-say)等等。毫无疑问,这些教学法都被实验证明各有巧妙之处。要判断哪一种方法才是解决所有阅读问题的万能妙药,可能还言之过早。\n1.学习阅读的阶段 # 最近有一项非常有用的研究,就是分析学习阅读的阶段。现在大家都广泛接受了这样的观念:在儿童具备纯熟的阅读能力之前,至少会经历大约四个截然不同的阶段。第一个阶段被称为“阅读准备阶段”(reading readiness)。专家指出,这一阶段从出生开始,直到六七岁为止。\n阅读准备阶段包括了几种不同的学习阅读的准备工作。身体方面的准备,包括良好的视力与听力。智力方面的准备是要有起码的认知能力,以便孩子能吸收与记住一个字,与组成这个字的字母。语言上的准备包括口齿清晰,能说出一些正确的句子。个人的准备,则包括能与其他孩童一起学习的能力,保持注意力,服从等等。\n阅读准备的总体是否成熟,要由测验来评定,也可以由一些经验丰富、眼光敏锐、很懂得判断小学生是否可以开始学习阅读的老师来作评估。最重要的是要记得,三级跳的做法通常会造成失败。一个孩子如果还没准备好就要教他阅读,他可能会不喜欢这样的学习经验,以后的学校教育甚至成人阶段都会受到影响。尽管有些父母会担心他们的孩子“反应迟钝”或“跟不上”同龄的孩子,超过阅读准备阶段,延后接受阅读指导,其实并不是太严重的事。\n在第二个阶段,孩子会学习读一些简单的读物。至少在美国,阅读的开始是一些看图识字。第一年结束时,基本上会认识三百到四百个字。这个时期会介绍一些基本的技巧,像字句的使用,词句的含意,字句的发音等等。这个阶段要结束时,小学生应该就能自己阅读简单的书,而且很喜欢阅读了。\n在这个阶段中,还有些附带的事情值得观察。那是在这个阶段发生的一些非常神秘,有点像是魔术一样的事情。在一个孩子发展过程中的某个时刻,面对着书本上一连串的符号,他会觉得毫无意义。但过不了多久—可能只是两三周之后—他却明白这些符号的意义了。他知道这是在说:“猫坐在帽子上。”不论哲学家与心理学家花了超过二千五百年的时间来研究这个奇迹,还是没有人真的知道这是怎么发生的。这些字的意义是从何而来的?法国的小孩是如何读懂“Le chatAasseyait sur le chapean”(猫坐在帽子上)的?事实上,懂得发现一些符号的意义,是人类所表现出的最惊人的聪明技巧,而大多数人在七岁以前就已经表现出来这样的智能了。\n第三个阶段的特征是快速建立字汇的能力,所用的方法是从上下文所提供的线索,“揭发”不熟悉的字眼。除此之外,孩子在这个阶段会学会不同目标与不同领域的阅读祛,像科学、社会学、语言艺术等等。他们学习到除了在学校之外,阅读还是一项可以自己来做的事—他们可以因为好玩、满足好奇心,或只是要“扩大视野”而阅读。\n最后,第四个阶段的特征是精练与增进前面所学的技巧。最重要的是,学生开始能消化他的阅读经验—从一本书所提出来的一个观点转化到另一个观点,在同一个主题上,对不同的作者所提出来的观点作比较。这是阅读的成熟阶段,应该是一个青少年就该达到的境界,也是终其一生都该持续下去的。\n但是对许多父母与教育者来说,显然孩子们并没有达到这样的目标。失败的原因很多,范围也很广,从被剥夺的家庭环境—经济、社会,或是智能(包括双亲是文盲)—到个人的各种问题(包括对整个“体制”的反抗)都有。但是其中有一个失败的原因却不常被注意到。过分强调阅读的准备阶段,过分注重教导孩子初步阅读的方法,往往意味着其他更高层次的阅读可能遭到忽视。这是很可以理解的,想想在第一个层次所可能碰到的各种紧急状况与问题的程度就会明白了。然而,除非我们在所有的阅读层次都投下努力,否则我们社会里有关阅读的整体问题是不可能有效地解决的。\n2.阅读的阶段与层次 # 我们已经形容过阅读的四个层次,也以很基础的方式列举了学习阅读的四个阶段。这些层次与阶段之间,到底有什么样的关联呢?\n最重要的是,这里所列举的四个阶段,都属于我们在前一章所谈的、第一个层次的阅读。这些阶段,都是基础阅读,对区分小学教育中的课程很有帮助。基础阅读的第一个阶段—阅读准备阶段—相当于学前教育或幼稚园的学习经验。第二阶段—认字—相当于一年级学生典型的学习经验(尽管相当多正常的孩子在某方面来说并非都很“典型,\u0026rsquo;)。这个阶段的成果是,孩子学会了我们称之为第二阶段的阅读技巧,或是一年级的阅读能力,或最初级的读写能力。基础阅读的第三个阶段—字汇的增长及对课文的运用—通常是(但非全面性,就算正常孩子也一样)在四年级结束时就学会的方法,这个阶段的成果可以称作是“四年级读写能力\u0026quot;(fourth grade literacy)或是“功能性读写能力\u0026quot;(functional literacy)也就是有能力很轻易地阅读交通号志,或图片说明,填写政府的有关简单表格等等。基础阅读的第四个阶段,也就是最后一个阶段,到这个时期,学生要从小学或初中毕业了。这个阶段有时候称之为八年级、九年级或十年级的读写能力。在某方面来说,这个孩子已经是一个“成熟”的阅读者,他几乎可以阅读所有的读物了,但是却还不够老练。简单来说,他的成熟度是可以上高中的课程了。\n无论如何,他还不是我们这本书中所说的“成熟的”阅读者。但他已经精通第一层次的阅读,如此而已。他可以自己阅读,也准备好要学习更多的阅读技巧。但是他还是不清楚要如何超越基础阅读,做更进一步的阅读。\n我们提到这些,是因为这跟本书要传达的讯息有密切的关系。我们假设,我们也必须假设你—我们的读者—已经有九年级的读写能力,也熟练了基础阅读,换句话说,你已经成功地通过我们所形容的四个阅读阶段。如果你想到这一点,就会了解我们的假设并不离谱。除非一个人能阅读,否则没有人能从一本教他如何如何的书中学到东西。特别就一本教人如何阅读的书来说,它的读者必须有某种程度的阅读能力才行。\n辅助型与非辅助型自我发现阅读的区别,在这里就有了关联。一般来说,基础阅读的四个阶段都有一位老师在旁指导。当然,每个孩子的能力并不相同,有些人需要比别人多一点的帮助。不过,在基础教育的几年当中,通常都会有一位老师出现在课堂,回答问题,消除在这个阶段会出现的难题。只有当一个孩子精通了基础阅读的四个阶段,才是他准备好往更高层次的阅读迈进的时候。只有当他能自己阅读时,才能够自己开始学习。也只有这样,他才能变成一个真正优秀的阅读者。\n3.更高层次的阅读与高等教育 # 传统上,美国的高中教育只为学生提供一点点阅读的指导,至于大学,更是一无所有。最近几年来,情况已经有点改变了。大约两个世代以前,高中登记人学的人数在短期内大量增加,教育者也开始觉察到,不能再假设所有的学生都能做到有效的阅读。矫正阅读的指导教育因此出现,不时有高达75%以上的学生需要矫正。在最近的十年当中,大学又发生同样的状况。譬如在1971年秋季,大约四万名新人进人纽约市立大学,却有高达一半,也就是超过二万名年轻人需要接受某种阅读训练的矫正课程。\n无论如何,这并不表示这些年来,许多美国大学都提供了基础阅读以上的教育指导课程。事实上,几乎可以说是完全没有。在更高层次的阅读中,矫正阅读的指导并不算指导。矫正阅读指导,只是要把学生带到一个他在小学毕业的时候所该具备的阅读能力程度。直到今天,大多数高等教育的指导者不是仍然不知道要如何指导学生超越基础阅读的层次,就是缺乏设备与人才来做这样的事。\n尽管最近一些四年大学或技术学院设立了速读,或“有效阅读法”,或“竞读”之类的课程,我们还是可以如此主张的。大体来说(虽然也有些例外),这些都是矫正阅读的课程。但这些课程都是为了克服初级教育的失败而设计的。这些课程不是为了要学生超越第一层次的阅读而设计的。也并不是在指导他们进人本书所主要强调的阅读层次与领域。\n当然,正常情况应该不是这样的。一个人文素养优良的高中,就算什么也没做,也该培养出能达到分析阅读的读者。一个优秀的大学,就算什么也没贡献,也该培育出能进行主题阅读的读者。大学的文凭应该代表着一般大学毕业生的阅读水平,不但能够阅读任何一种普通的资料,还能针对任何一种主题做个人的研究(这就是在所有阅读中,主题阅读能让你做到的事)。然而,通常大学生要在毕业以后,再读三四年的时间才能达到这样的程度,并且还不见得一定达到。\n一个人不应该花四年的时间留在研究所中只是为了学习如何阅读。四年研究所时间,再加上十二年的中、小学教育,四年的大学教育,总共加起来是整整二十年的学校教育。其实不该花这么长的时间来学习如何阅读。如果真是如此,这中间必然出了大问题。\n事情错了,可以改正。许多高中与大学可以依照本书所提供的方法来安排课程。我们所提供的方法并不神秘,甚至也并非新创。大多数只是普通常识而已。\n4.阅读与民主教育的理念 # 我们并不只想做个吹毛求疵的批评家。我们知道,不论我们要传达的讯息多么有道理,只要碰到成千上万的新人在学校的楼梯上踩得砰砰作响时,就什么也听不见了。看到这批新生当中有相当大的比率,或是大多数的人都无法达到有效阅读的基础水平时,我们应该警觉,当务之急是必须从最低层次的、最小公约数的阅读教起。\n甚至,此刻我们也不想提是否需要另一种教育方式了。我们的历史一直强调,无限制的受教育机会是一个社会能提供给人民最有价值的服务—或说得正确一点,只有当一个人的自我期许,能力与需要受限制时,教育机会才会受到限制。我们还没有办法提供这种机会之前,不表示我们就有理由要放弃尝试。-\n但是我们—包括学生、老师与门外汉等—也要明白:就算我们完成了眼前的任务,仍然还没有完成整个工作。我们一定要比一个人人识字的国家更进一步。我们的国人应该变成一个个真正“有能力”的阅读者,能够真正认知“有能力”这个字眼中的涵义。达不到这样的境界,我们就无法应付未来世界的需求。\n第四章 阅读的第二个层次:检视阅读 # 检视阅读,才算是真正进人阅读的层次。这和前一个层次(基础阅读)相当不同,也跟自然而来的下一个层次(分析阅读)大有差异。但是,就像我们在第二章所强调的,阅读的层次是渐进累积的。因此,基础阅读是包含在检视阅读中的,而事实上,检视阅读又包含在分析阅读中,分析阅读则包含在主题阅读中。\n事实上,除非你能精通基础阅读,否则你没法进人检视阅读的层次。你在阅读一位作者的作品时要相当顺手,用不着停下来检查许多生字的意思,也不会被文法或文章结构阻碍住。虽然不见得要每句每字都读得透彻,但你已经能掌握主要句子与章节的意义了。\n那么,检视阅读中究竟包含了些什么?你要怎样才能培养检视阅读的能力呢?\n首先要理解的是,检视阅读一共有两种。本来这是一体两面的事,但是对一个刚起步的阅读者来说,最好是将两者区别为不同的步骤与活动。有经验的阅读者已经学会同时运用两种步骤,不过此刻,我们还是将二者完全区分开来。\n1.检视阅读一:有系统的略读或粗读 # 让我们回到前面曾经提过的一些基本状态。这是一本书,或任何读物,而那是你的头脑。你会做的第一件事是什么?\n让我们再假设在这情况中还有两个相当常见的因素。第一,你并不知道自己想不想读这本书。你也不知道这本书是否值得做分析阅读。但你觉得,或只要你能挖掘出来,书中的资讯及观点就起码会对你有用处。\n其次,让我们假设—常会有这样的状况—你想要发掘所有的东西,但时间却很有限。\n在这样的情况下,你一定要做的就是“略读”(skim)整本书,或是有人说成是粗读(pre-read)一样。略读或粗读是检视阅读的第一个子层次。你脑中的目标是要发现这本书值不值得多花时间仔细阅读。其次,就算你决定了不再多花时间仔细阅读这本书,略读也能告诉你许多跟这本书有关的事。\n用这种快速浏览的方式来阅读一本书,就像是一个打谷的过程,能帮助你从糙糠中过滤出真正营养的谷核。当你浏览过后,你可能会发现这本书仅只是对你目前有用而已。这本书的价值不过如此而已。但至少你知道作者重要的主张是什么了,或是他到底写的是怎样的一本书。因此,你花在略读这本书上的时间绝没有浪费。\n略读的习惯应该用不着花太多时间。下面是要如何去做的一些建议:\n(1)先看书名页,然后如果有序就先看序。要很快地看过去。特别注意副标题,或其他的相关说明或宗旨,或是作者写作本书的特殊角度。在完成这个步骤之前,你对这本书的主题已经有概念了。如果你愿意,你会暂停一下,在你脑海中将这本书归类为某个特定的类型。而在那个类型中,已经包含了哪些书。\n(2)研究目录页,对这本书的基本架构做概括性的理解。这就像是在出发旅行之前,要先看一下地图一样。很惊讶的是,除非是真的要用到那本书了,许多人连目录页是看都不看一眼的。事实上,许多作者花了很多时间来创作目录页,想到这些努力往往都浪费了,不免让人伤心。\n通常,一本书,特别是一些论说性的书都会有目录,但是有时小说或诗集也会写上一整页的纲要目录,分卷分章之后再加许多小节的副标,以说明题旨。譬如写作《失乐园》(Paradise Lost)的时候,弥尔顿(John Milton)为每一章都写了很长的标题,或他所称的“要旨”(arguments)。吉朋(Edward Gibbon)出版的《罗马帝国衰亡史)) (Declineand Fall of the Roman Empire),为每一章都写了很长的分析性纲要。目前,虽然偶尔你还会看到一些分析性的纲要目录,但已经不普遍了。这种现象衰退的原因是,一般人似乎不再像以前一样喜欢阅读目录纲要了。同时,比起一本目录完全开诚布公的书,出版商也觉得越少揭露内容纲要,对读者越有吸引力。至于阅读者,他们觉得,一本书的章节标题有几分神秘性会更有吸引力—他们会想要阅读这本书以发现那些章节到底写了些什么。虽然如此,目录纲要还是很有价值的,在你开始阅读整本书之前,你应该先仔细阅读目录才对。\n谈到这里,如果你还没看过本书的目录页,你可能会想翻回去看一下了,我们尽可能地将目录页写得完整又说明清楚。检视一下这个目录页,你就会明白我们想要做的是什么了。\n(3)如果书中附有索引,也要检阅一下—大多数论说类的书籍都会有索引。快速评估一下这本书涵盖了哪些议题的范围,以及所提到的书籍种类与作者等等。如果你发现列举出来的哪一条词汇很重要,至少要看一下引用到这个词目的某几页内文。(我们会在第二部谈到词汇的重要问题。暂时你必须先依靠自己的常识,根据前面所提的第一及第二步骤,判别出一本书里你认为重要的词汇。)你所阅读的段落很可能就是个要点—这本书的关键点—或是关系到作者意图与态度的新方法。\n就跟目录页一样,现在你可能要检查一下本书的索引。你会辨认出一些我们已经讨论过的重要词目。那你能不能再找出其他一些也很重要的词目呢?—譬如说,参考一下词目底下所列被引用页数的多寡?\n(4)如果那是本包着书衣的新书,不妨读一下出版者的介绍。许多人对广告文案的印象无非是些吹牛夸张的文字。但这往往失之偏颇,尤其是一些论说性的作品更是如此,大致来说,许多书的宣传文案都是作者在出版公司企宣部门的协助下亲自写就的。这些作者尽力将书中的主旨正确地摘要出来,已经不是稀奇的事了。这些努力不应该被忽视。当然,如果宣传文案什么重点也没写到,只是在瞎吹牛,你也可以很容易看穿。不过,这也有助于你对这本书多一点了解,或许这本书根本没什么重要的东西可谈—而这也正是他们宣传文案一无可取的原因。\n完成这四个步骤,你对一本书已经有足够的资讯,让你判断是想要更仔细地读这本书,还是根本不想读下去了。不管是哪一种情况,现在你都可能会先将这本书放在一边一阵子。如果不是的话,现在你就准备好要真正地略读一本书了。\n(5)从你对一本书的目录很概略,甚至有点模糊的印象当中,开始挑几个看来跟主题息息相关的篇章来看。如果这些篇章在开头或结尾有摘要说明(很多会有),就要仔细地阅读这些说明。\n(6)最后一步,把书打开来,东翻翻西翻翻,念个一两段.有时候连续读几页,但不要太多。就用这样的方法把全书翻过一遍,随时寻找主要论点的讯号,留意主题的基本脉动。最重要的是,不要忽略最后的两三页。就算最后有后记,一本书最后结尾的两三页也还是不可忽视的。很少有作者能拒绝这样的诱惑,而不在结尾几页将自己认为既新又重要的观点重新整理一遍的。虽然有时候作者自己的看法不一定正确,但你不应该错过这个部分。\n现在你已经很有系统地略读过一本书了。你已经完成了第一种型态的检视阅读。现在,在花了几分钟,最多不过一小时的时间里,你对这本书已经了解很多了。尤其,你应该了解这本书是否包含你还想继续挖掘下去的内容,是否值得你再继续投下时间与注意?你也应该比以前更清楚,在脑海中这本书该归类为哪一个种类,以便将来有需要时好作参考。\n附带一提的是,这是一种非常主动的阅读。一个人如果不够灵活,不能够集中精神来阅读,就没法进行检视阅读。有多少次你在看一本好书的时候,翻了好几页,脑海却陷入了白日梦的状态中,等清醒过来,竟完全不明白自己刚看的那几页在说些什么?如果你跟随着我们提议的步骤来做,就绝不会发生这样的事—因为你始终有一个可以依循作者思路的系统了。\n你可以把自己想成是一个侦探,在找寻一本书的主题或思想的线索。随时保持敏感,就很容易让一切状况清楚。留意我们所提出的建议,会帮助你保持这样的态度。你会很惊讶地发现自己节省了更多时间,高兴自己掌握了更多重点,然后轻松地发现原来阅读是比想像中还更要简单的一件事。\n2.检视阅读二:粗浅的阅读 # 这一节的标题是故意要挑衅的。“粗浅”这两个字通常有负面的联想。但我们可是很认真在用这两个字。\n我们每个人都有这样的经验:对一本难读的书抱着高度的期望,以为它能启发我们,结果却只是在徒劳无益地挣扎而已。很自然的,我们会下个结论:一开始想读这本书就是个错误。但这并不是错误,而只是打从开始就对阅读一本难读的书期望过高。只要找到对的方向,不论是多难读的书,只要原来就是想写给大众读者看的,那就不该有望之却步的理由。\n什么叫对的方向?答案是一个很重要又有帮助的阅读规则,但却经常被忽略。这个规则很简单:头一次面对一本难读的书的时候,从头到尾先读完一遍,碰到不懂的地方不要停下来查询或思索。\n只注意你能理解的部分,不要为一些没法立即了解的东西而停顿。继续读下去,略过那些不懂的部分,很快你会读到你看得懂的地方。集中精神在这个部分。继续这样读下去。将全书读完,不要被一个看不懂的章节、注解、评论或参考资料阻挠或泄气。如果你让自己被困住了,如果你容许自己被某个顽固的段落绑住了,你就是被打败了。在大多数情况里,你一旦和它纠缠,就很难脱困而出。在读第二遍的时候,你对那个地方的了解可能会多一些,但是在那之前,你必须至少将这本书先从头到尾读一遍才行。\n你从头到尾读了一遍之后的了解—就算只有50%或更少—能帮助你在后来重读第一次略过的部分时,增进理解。就算你不重读,对一本难度很高的书了解了一半,也比什么都不了解来得要好些—如果你让自己在一碰上困难的地方就停住,最后就可能对这本书真的一无所知了。\n我们大多数人所受的教育,都说是要去注意那些我们不懂的地方。我们被教导说,碰到生字,就去查字典。我们被教导说,读到一些不明白的隐喻或论说,就去查百科全书或其他相关资料。我们被教导说,要去查注脚、学者的注释或其他的二手资料以获得帮助。但是如果时候不到就做这些事,却只会妨碍我们的阅读,而非帮助。\n譬如,阅读莎士比亚的戏剧,会获得极大的快乐。但是一代代的高中生被逼着要一幕一幕地念、一个生字接一个生字地查、一个学者注脚接一个注脚地读《裘利斯·凯撒)(Julius Caesar)、《皆大欢喜))(As YouLike It)或《哈姆雷特》(Hamlet),这种快乐就被破坏了。结果是他们从来没有真正读过莎士比亚的剧本。等他们读到最后的时候,已经忘了开始是什么,也无法洞察全剧的意义了。与其强迫他们接受这种装模作样的做学问的读法,不如鼓励他们一次读完全剧,然后讨论他们在第一次快速阅读中所获得的东西。只有这样,他们才算是做好接下来仔细又专心研究这个剧本的准备。因为他们已经有了相当的了解,可以准备再学一点新的东西了。\n这个规则也适用于论说性的作品。事实上,第一次看这样一本书的时候要粗浅阅读的这个规则,在你违反的时候正可以不证自明。拿一本经济学的基础书来说吧,譬如亚当·斯密(Adam Smith)的经典作品《国富论》(The Wealth of Nations)(我们会选这一本做例子,因为这不光只是一本教科书,或是为经济学家写的书,这也是一本为一般读者所写的书),如果你坚持要了解每一页的意义,才肯再往下读,那你一定读不了多少。在你努力去了解那些细微的重点时,就会错过斯密说得那么清楚的一些大原则:关于成本中包含的薪水、租金、利润与利息种种因素,市场在定价中的角色,垄断专卖的害处,自由贸易的理由等等。这样你在任何层次的阅读都不可能很好。\n3.阅读的速度 # 在第二章,我们谈过检视阅读是一种在有限的时间当中,充分了解一本书的艺术。本章我们要更进一步谈这件事,没有理由去改变这个定义。检视阅读的两个方式都需要快速地阅读。一个熟练的检视阅读者想要读一本书时,不论碰到多难读或多长的书,都能够很快地运用这两种方式读完。\n关于这个方式的定义,不可避免地一定会引起一个问题:那么速读又算什么呢?现在不论是商业界或学术界都有速读的课程,那么在阅读的层次与众多速读课程之间有什么关联呢?\n我们已经谈过那些课程基本上是为了矫正用的—因为他们所提供的就算不是全部,也主要都是基础阅读层次的指导。不过这要再多谈一点。\n首先我们要了解的是,我们都同意,大多数人应该有能力比他们现在读的速度还更快一点。更何况有很多东西根本不值得我们花那么多时间来读。如果我们不能读快一点,简直就是在浪费时间。的确没错,许多人阅读的速度太慢,应该要读快一点。但是,也有很多人读得太快了,应该要把速度放慢才行。一个很好的速读课程应该要教你不同的阅读速度,而不是一味求快,而忽略了你目前能掌握的程度。应该是依照读物的性质与复杂程度,而让你用不同的速度来阅读。\n我们的重点真的很简单。许多书其实是连略读都不值得的,另外一些书只需要快速读过就行了。有少数的书需要用某种速度,通常是相当慢的速度,才能完全理解。一本只需要快速阅读的书却用很慢的速度来读,就是在浪费时间,这时速读的技巧就能帮你解决问题。但这只是阅读问题中的一种而已。要了解一本难读的书,其间的障碍,非一般所谓生理或心理障碍所能比拟甚或涵盖。会有这些障碍,主要是因为阅读者在面对一本困难—值得读—的书时,完全不知道如何是好。他不知道阅读的规则,也不懂得运用心智的力量来做这件事。不论他读得多快,也不会获得更多,因为事实上,他根本不知道自己在寻找什么,就算找到了,也不清楚是不是自己想要的东西。\n所谓阅读速度,理想上来说,不只是要能读得快,还要能用不同的速度来阅读—要知道什么时候用什么样的速度是恰当的。检视阅读是一种训练有素的快速阅读,但这不只是因为你读的速度快—虽然你真的读得很快—而是因为在检视阅读时,你只读书中的一小部分,而且是用不同的方式来读,不一样的目标来读。分析阅读通常比检视阅读来得慢一些,但就算你拿到一本书要做分析阅读,也不该用同样的速度读完全书。每一本书,不论是多么难读的书,在无关紧要的间隙部分就可以读快一点。而一本好书,总会包含一些比较困难,应该慢慢阅读的内容。\n4.逗留与倒退 # 半个多世纪以来,速读的课程让我们有了一个最重大的发现:许多人会从最初学会阅读之后,多年一直使用“半出声”(sub-vocalize)的方式来阅读。此外,拍摄下来的眼睛在活动时的影片,显示年轻或未受过训练的阅读者,在阅读一行字的时候会在五六个地方发生“逗留”(fix-ate)现象。(眼睛在移动时看不见,只有停下来时才能看见。)因此,他们在读这一行字的时候,只能间隔着看到一个个单字或最多两三个字的组合。更糟的是,这些不熟练的阅读者在每看过两三行之后,眼睛就自然地“倒退”(regress)到原点—也就是说,他们又会倒退到先前读过的句子与那一行去了。\n所有这些习惯不但浪费而且显然降低了阅读的速度。之所以说是浪费,因为我们的头脑跟眼睛不一样,并不需要一次只“读”一个字或一个句子。我们的头脑是个惊人的工具,可以在“一瞥”之间掌握住一个句子或段落—只要眼睛能提供足够的资讯。因此,主要的课题—所有的速读课程都需要认知这一点—就是要矫正许多人在阅读时会“逗留”,会“倒退”,因而使他们的速度慢下来的习惯。幸运的是,要矫正这样的习惯还蛮容易的。一旦矫正过了,学生就能跟着脑部运作的快速度来阅读,而不是跟着眼部的慢动作来阅读了。\n要矫正眼睛逗留于一点的工具有很多种,有些很复杂又很昂贵。无论如何,任何复杂的工具其实都比不上你的一双手来得有用,你可以利用双手训练自己的眼睛,跟着章节段落移动得越来越快。你可以自己做这样的训练:将大拇指与食指、中指合并在一起,用这个“指针”顺着一行一行的字移动下去,速度要比你眼睛感觉的还要快一点。强迫自己的眼睛跟着手部的动作移动。一旦你的眼睛能跟着手移动时,你就能读到那些字句了。继续练习下去,继续增快手的动作,等到你发觉以前,你的速度已经可以比以前快两三倍了。\n5.理解的问题 # 不过,在你明显地增进了阅读的速度之后,你到底获得了什么呢?没错,你是省下了一些时间,但是理解力(comprehension)呢?同样地增进了,还是在这样的进展中一无所获?\n.就我们所知,没有一种速读课程不是声明在阅读速度加快时,理解力也同时增进。整体来说,这样的声明确实是有点根据的。我们的手(或其他工具)就像是个计时器,不只负责增进你的阅读速度,也能帮助你专注于你所阅读的东西上。一旦你能跟随自己的手指时,就很难打磕睡或做白日梦,胡思乱想。到目前为止,一切都很不错。专心一致也就是主动阅读的另一种称呼。一个优秀的阅读者就是读得很主动,很专心。\n但是专心并不一定等于理解力—如果大家对“理解力”并没有误解的话。理解力,是比回答书本内容一些简单问题还要多一点的东西。那种有限的理解力,不过是小学生回答“这是在说什么?\u0026lsquo;\u0026lsquo;之类问题的程度而已。一个读者要能够正确地回答许多更进一步的问题,才表示有更高一层的理解力,而这是速读课程所不要求的东西,也几乎没有人指导要如何回答这类的问题。\n为了说得更清楚一些,用一篇文章来做例子。我们用《独立宣言》为例。你手边可能有这篇文章,不妨拿出来看看。这篇文章印出来还不到三页的篇幅。你能多快读完全文?\n《独立宣言》的第二段结尾写着:“为了证明这一点,提供给这个公正的世界一些事实吧。”接下来的两页是一些“事实”。看起来有些部分似乎还蛮可疑的,不妨快一点读完。我们没有必要去深入了解杰佛逊所引述的事实到底是些什么,当然,除非你是个学者,非常在意他所写的历史环境背景如何,自然又另当别论。就算是最后一段,结尾是著名的公正的声明,几位歌者“互诵我们的生命,财富与神圣的荣耀。”这也可以快快地读过。那是一些修辞学上的华丽词藻,就只值得在修辞学上的注意力。但是,读《独立宣言》的前两段,需要的却绝不只是快速地阅读一遍。\n我们怀疑有人能以超过一分钟二十个字的速度来阅读前两段文字。的确,在著名的第二段里的一些字句,如“不可剥夺的”、“权利”、“自由”、“幸福”、“同意”、“正义的力量”,值得再三玩味、推敲、沉思。要完全了解《独立宣言》的前两段,正确的读法是需要花上几天,几星期,甚至好几年的时间。\n这么说来,速读的问题就出在理解力上。事实上,这里所谓的理解力是超越基础阅读层次以上的理解力,也是造成问题的根源。大多数的速读课程都没有包括这方面的指导。因此,有一点值得在这里强调的是,本书之所以想要改进的,正是这一种阅读的理解力。没有经过分析阅读,你就没法理解一本书。正如我们前面所言,分析阅读,是想要理解(或了解)一本书的基本要件。\n6.检视阅读的摘要 # 以下简短的几句话是本章的摘要。阅读的速度并非只有单一的一种,重点在如何读出不同的速度感,知道在阅读某种读物时该用什么样的速度。超快的速读法是引人怀疑的一种成就,那只是表现你在阅读一种根本不值得读的读物。更好的秘方是:在阅读一本书的时候,慢不该慢到不值得,快不该快到有损于满足与理解。不论怎么说,阅读的速度,不论是快还是慢,只不过是阅读问题一个微小的部分而已。\n略读或粗读一本书总是个好主意。尤其当你并不清楚手边的一本书是否值得细心阅读时(经常发生这种情况),必须先略读一下。略读过后,你就会很清楚了。一般来说,就算你想要仔细阅读的书也要先略读一下,从基本架构上先找到一些想法。\n最后,在第一次阅读一本难读的书时,不要企图了解每一个字句。这是最最重要的一个规则。这也是检视阅读的基本概念。不要害怕,或是担忧自己似乎读得很肤浅。就算是最难读的书也快快地读一遍。当你再读第二次时,你就已经准备好要读这本书了。\n我们已经完整地讨论过第二层次的阅读—检视阅读。我们会在第四篇时再讨论同一个主题,我们会提到检视阅读在主题阅读中占有多么重要的角色。主题阅读是第四层次,也是最高层次的阅读。\n无论如何,你应该记住,当我们在本书第二篇讨论第三层次的阅读—分析阅读时,检视阅读在那个层次中仍然有很重要的功能。检视阅读的两个步骤都可以当作是要开始做分析阅读之前的预备动作。第一阶段的检视阅读—我们称作有系统的略读或粗读—帮助阅读者分析在这个阶段一定要回答的问题。换句话说,有系统略读,就是准备要了解本书的架构。第二阶段的检视阅读—我们称之为粗浅的阅读—帮助阅读者在分析阅读中进人第二个阶段。粗浅的阅读,是阅读者想要了解全书内容的第一个必要步骤。\n在开始讨论分析阅读之前,我们要暂停一下,再想一下阅读的本质是一种活动。想要读得好,一个主动、自我要求的读者,就得采取一些行动。下一章,我们会谈。\n第五章 如何做一个自我要求的读者 # 在阅读的时候,让自己昏昏入睡比保持清醒要容易得多。爬上床,找个舒适的位置,让灯光有点昏暗,刚好能让你的眼睛觉得有点疲劳,然后选一本非常困难或极端无聊的书—可以是任何一个主题,是一本可读可不读的书—这样几分钟之后,你就会昏昏人睡了。\n不幸的是,要保持清醒并不是采取相反的行动就会奏效。就算你坐在舒适的椅子里,甚至躺在床上,仍然有可能保持清醒。我们已经知道许多人因为深夜还就着微弱的灯光阅读,而伤害了眼睛的事。到底是什么力量,能让那些秉烛夜读的人仍然保持清醒?起码有一点是可以确定的—他们有没有真正在阅读手中的那本书,造成了其间的差异,而且是极大的差异。\n在阅读的时候想要保持清醒,或昏昏入睡,主要看你的阅读目标是什么。如果你的阅读目标是获得利益—不论是心灵或精神上的成长—你就得保持清醒。这也意味着在阅读时要尽可能地保持主动,同时还要做一番努力—而这番努力是会有回馈的。\n好的书,小说或非小说,都值得这样用心阅读。把一本好书当作是镇静剂,完全是极度浪费。不论睡着,还是花了好几小时的时间想要从书中获得利益—主要想要理解这本书—最后却一路胡思乱想,都绝对无法达成你原来的目标。\n不过悲哀的是,许多人尽管可以区分出阅读的获益与取乐之不同—其中一方是理解力的增进,另一方则是娱乐或只是满足一点点的好奇心—最后仍然无法完成他们的阅读目标。就算他们知道那本书该用什么样的方式来阅读,还是失败。原因就在他们不知道如何做个自我要求的阅读者,如何将精神集中在他们所做的事情上,而不会一无所获。\n1.主动的阅读基础:一个阅读者要提出的四个基本问题 # 本书已经数度讨论过主动的阅读。我们说过,主动阅读是比较好的阅读,我们也强调过检视阅读永远是充满主动的。那是需要努力,而非毫不费力的阅读。但是我们还没有将主动阅读的核心作个简要的说明,那就是:你在阅读时要提出问题来—在阅读的过程中,你自己必须尝试去回答的问题。\n有问题吗?没有。只要是超越基础阅读的阅读层次,阅读的艺术就是要以适当的顺序提出适当的问题。关于一本书,你一定要提出四个主要的问题。\n(1)整体来说,这本书到底在谈些什么?你一定要想办法找出这本书的主题,作者如何依次发展这个主题,如何逐步从核心主题分解出从属的关键议题来。\n(2)作者细部说了什么,怎么说的?你一定要想办法找出主要的想法、声明与论点。这些组合成作者想要传达的特殊讯息。\n(3)这本书说得有道理吗?是全部有道理,还是部分有道理?除非你能回答前两个问题,否则你没法回答这个问题。在你判断这本书是否有道理之前,你必须先了解整本书在说些什么才行。然而,等你了解了一本书,如果你又读得很认真的话,你会觉得有责任为这本书做个自己的判断。光是知道作者的想法是不够的。\n(4)这本书跟你有什么关系?如果这本书给了你一些资讯,你一定要问问这些资讯有什么意义。为什么这位作者会认为知道这件事很重要?你真的有必要去了解吗?如果这本书不只提供了资讯,还启发了你,就更有必要找出其他相关的、更深的含意或建议,以获得更多的启示。\n在本书的其他篇章我们还会再回到这四个问题,做更深人的讨论。换句话说,这四个问题是阅读的基本规则,也是本书第二篇要讨论的主要议题。这四个重点以问题的方式出现在这里有一个很好的理由。任何一种超越基础阅读的阅读层次,核心就在你要努力提出问题(然后尽你可能地找出答案)。这是绝不可或忘的原则。这也是有自我要求的阅读者,与没有自我要求的阅读者之间,有天壤之别的原因。后者提不出问题—当然也得不到答案。\n前面说的四个问题,概括了一个阅读者的责任。这个原则适用于任何一种读物—一本书、一篇文章,甚至一个广告。检视阅读似乎对前两个问题要比对后两个更能提出正确的答案,但对后两个问题一样;会有帮助。而除非你能回答后面两个问题,否则即使用了分析阅读也不算功德圆满—你必须能够以自己的判断来掌握这本书的整体或部分道理与意义,才算真正完成了阅读。尤其最后一个问题—这本书跟你有什么关系?—可能是主题阅读中最重要的一个问题。当然,在想要回答最后一个问题之前,你得先回答前三个问题才行。\n光是知道这四个问题还不够。在阅读过程中,你要记得去提出这些问题。要养成这样的习惯,才能成为一个有自我要求的阅读者。除此之外,你还要知道如何精准、正确地回答问题。如此训练而来的能力,就是阅读的艺术。\n人们在读一本好书的时候会打磕睡,并不是他们不想努力,而是因为他们不知道要如何努力。你挂念着想读的好书太多了。(如果不是挂念着,也算不上是你觉得的好书。)而除非你能真正起身接触到它们,把自己提升到同样的层次,否则你所挂念的这些好书只会使你厌倦而已。并不是起身的本身在让你疲倦,而是因为你欠缺有效运用自我提升的技巧,在挫败中产生了沮丧,因而才感到厌倦。要保持主动的阅读,你不只是要有意愿这么做而已,还要有技巧—能战胜最初觉得自己能力不足部分,进而自我提升的艺术。\n2.如何让一本书真正属于你自己 # 如果你有读书时提出问题的习惯,那就要比没有这种习惯更能成为一个好的阅读者。但是,就像我们所强调的,仅仅提出问题还不够。你还要试着去回答问题。理论上来说,这样的过程可以在你脑海中完成,但如果你手中有一枝笔会更容易做到。在你阅读时,这枝笔会变成提醒你的一个讯号。\n俗话说:“你必须读出言外之意,才会有更大的收获。”而所谓阅读的规则,就是用一种比较正式的说法来说明这件事而已。此外,我们也鼓励你“写出言外之意”。不这么做,就难以达到最有效的阅读的境界。\n你买了一本书,就像是买了一项资产,和你付钱买衣服或家具是一样的。但是就一本书来说,付钱购买的动作却不过是真正拥有这本书的前奏而已。要真正完全拥有一本书,必须把这本书变成你自己的一部分才行,而要让你成为书的一部分最好的方法—书成为你的一部分和你成为书的一部分是同一件事—就是要去写下来。\n为什么对阅读来说,在书上做笔记是不可或缺的事?第一,那会让你保持清醒—不只是不昏睡,还是非常清醒。其次,阅读,如果是主动的,就是一种思考,而思考倾向于用语言表达出来—不管是用讲的还是写的。一个人如果说他知道他在想些什么,却说不出来,通常是他其实并不知道自己在想些什么。第三,将你的感想写下来,能帮助你记住作者的思想。\n阅读一本书应该像是你与作者之间的对话。有关这个主题,他知道的应该比你还多,否则你根本用不着去跟这本书打交道了。但是了解是一种双向沟通的过程,学生必须向自己提问题,也要向老师提问题。一旦他了解老师的说法后,还要能够跟老师争辩。在书上做笔记,其实就是在表达你跟作者之间相异或相同的观点。这是你对作者所能付出的最高的敬意。\n做笔记有各式各样,多彩多姿的方法。以下是几个可以采用的方法:\n(1)画底线—在主要的重点,或重要又有力量的句子下画线。\n(2)在画底线处的栏外再加画一道线—把你已经画线的部分再强调一遍,或是某一段很重要,但要画底线太长了,便在这一整段外加上一个记号。\n(3)在空白处做星号或其他符号—要慎用,只用来强调书中十来个最重要的声明或段落即可。你可能想要将做过这样记号的地方每页折一个角,或是夹一张书签,这样你随时从书架上拿起这本书,打开你做记号的地方,就能唤醒你的记忆。\n(4)在空白处编号—作者的某个论点发展出一连串的重要陈述时,可以做顺序编号。\n(5)在空白处记下其他的页码—强调作者在书中其他部分也有过同样的论点,或相关的要点,或是与此处观点不同的地方。这样做能让散布全书的想法统一集中起来。许多读者会用Cf这样的记号,表示比较或参照的意思。\n(6)将关键字或句子圈出来—这跟画底线是同样的功能。\n(7)在书页的空白处做笔记—在阅读某一章节时,你可能会有些问题(或答案),在空白处记下来,这样可以帮你回想起你的问题或答案。你也可以将复杂的论点简化说明在书页的空白处。或是记下全书所有主要论点的发展顺序。书中最后一页可以用来作为个人的索引页,将作者的主要观点依序记下来。\n对已经习惯做笔记的人来说,书本前面的空白页通常是非常重要的。有些人会保留这几页以盖上藏书印章。但是那不过表示了你在财务上对这本书的所有权而已。书前的空白页最好是用来记载你的思想。你读完一本书,在最后的空白页写下个人的索引后,再翻回前面的空白页,试着将全书的大纲写出来,用不着一页一页或一个重点一个重点地写(你已经在书后的空白页做过这件事了),试着将全书的整体架构写出来,列出基本的大纲与前后篇章秩序。这个大纲是在测量你是否了解了全书,这跟藏书印章不同,却能表现出你在智力上对这本书的所有权。\n3.三种做笔记的方法 # 在读一本书时,你可能会有三种不同的观点,因此做笔记时也会有三种不同的方式。你会用哪一种方式做笔记,完全依你阅读的层次而定。\n你用检视阅读来读一本书时,可能没有太多时间来做笔记。检视阅读,就像我们前面所说过的,所花的时间永远有限。虽然如此,你在这个层次阅读时,还是会提出一些重要的问题,而且最好是在你记忆犹新时,将答案也记下来—只是有时候不见得能做得到。\n在检视阅读中,要回答的问题是:第一,这是什么样的一本书?第二,整本书在谈的是什么?第三,作者是借着怎样的整体架构,来发展他的观点或陈述他对这个主题的理解?你应该做一下笔记,把这些问题的答案写下来。尤其如果你知道终有一天,或许是几天或几个月之后,你会重新拿起这本书做分析阅读时,就更该将问题与答案先写下来。要做这些笔记最好的地方是目录页,或是书名页,这些是我们前面所提的笔记方式中没有用到的页数。\n在这里要注意的是,这些笔记主要的重点是全书的架构,而不是内容—至少不是细节。因此我们称这样的笔记为结构(structuralnote-making)。\n在检视阅读的过程中,特别是又长又难读的书,你有可能掌握作者对这个主题所要表达的一些想法。但是通常你做不到这一点。而除非你真的再仔细读一遍全书,否则就不该对这本书立论的精确与否、有道理与否隧下结论。之后,等你做分析阅读时,关于这本书准确性与意义的问题,你就要提出答案了。在这个层次的阅读里,你做的笔记就不再是跟结构有关,而是跟概念有关了。这些概念是作者的观点,而当你读得越深越广时,便也会出现你自己的观点了。\n结构笔记与概念笔记(conceptual note-making)是截然不同的。而当你同时在读好几本书,在做主题阅读—就同一个主题,阅读许多不同的书时,你要做的又是什么样的笔记呢?同样的,这样的笔记也应该是概念性的。你在书中空白处所记下的页码不只是本书的页码,也会有其他几本书的页码。\n对一个已经熟练同时读好几本相同主题书籍的专业阅读者来说,还有一个更高层次的记笔记的方法。那就是针对一场讨论情境的笔记一这场讨论是由许多作者所共同参与的,而且他们可能根本没有常察自己的参与。在第四篇我们会详细讨论这一点,我们喜欢称这样的笔记为辩证笔记(dialectical note making)。因为这是从好多本书中摘要出来的,而不只是一本,因而通常需要用单独的一张纸来记载。这时,我们会再用上概念的结构—就一个单一主题,把所有相关的陈述和疑问顺序而列。我们会在第二十章时再回来讨论这样的笔记。\n4.培养阅读的习惯 # 所谓艺术或技巧,只属于那个能养成习惯,而且能依照规则来运作的人。这也是艺术家或任何领域的工匠与众不同之处。要养成习惯,除了不断地运作练习之外,别无他法。这也就是我们通常所说的,从实际去做中学习到如何去做的道理。在你养成习惯的前后,最大的差异就在于阅读能力与速度的不同。经过练习后,同一件事,你会做得比刚开始时要好很多。这也就是俗话说的熟能生巧。一开始你做不好的事,慢慢就会得心应手,像是自然天生一样。你好像生来就会做这件事,就跟你走路或吃饭一样自然。这也是为什么说习惯是第二天性的道理。\n知道一项艺术的规则,跟养成习惯是不同的。我们谈到一个有技术的人时,并不是在说他知道该如何去做那件事,而是他已经养成去做那件事的习惯了。当然,对于规则是否了解得够清楚,是能不能拥有技巧的关键。如果你不知道规则是什么,就根本不可能照规则来行事了。而你不能照规则来做,就不可能养成一种艺术,或任何技能的习惯。艺术就跟其他有规则可循的事一样,是可以学习、运作的。就跟养成其他事情的习惯一样,只要照着规则练习,就可以培养出习惯来。\n顺便一提,并不是每个人都清楚做一个艺术家是要照规则不断练习的。人们会指着一个具有高度原创性的画作或雕塑说:“他不按规矩来。他的作品原创性非常高,这是前人从没有做过的东西,根本没有规矩可循。”其实这些人是没有看出这个艺术家所遵循的规则而已。严格\u0026rsquo;来说,对艺术家或雕塑家而言,世上并没有最终的、不可打破的规则。但是准备画布,混合颜料,运用颜料,压模黏土或焊接钢铁,绝对是有规则要遵守的。画家或雕塑家一定要依循这些规则,否则他就没办法完成他想要做的作品了。不论他最后的作品如何有原创性,不论他淘汰了多少传统所知的“规则”,他都必须有做出这样成品的技巧。这就是我们在这里所要谈论的艺术—或是说技巧或手艺。\n5.由许多规则中养成一个习惯 # 阅读就像滑雪一样,做得很好的时候,像一个专家在做的时候,滑雪跟阅读一样都是很优美又和谐的一种活动。但如果是一个新手上路,两者都会是笨手笨脚、又慢又容易受挫的事。\n学习滑雪是一个成人最难堪的学习经验(这也是为什么要趁年轻时就要学会)。毕竟,一个成人习惯于走路E经很长一段时间。他知道如何落脚,如何一步一步往某个方向走。但是他一把雪橇架在脚上,就像他得重新学走路一样。他摔倒又滑倒,跌倒了还很难站起来。等好不容易站起来,雪橇又打横了,又跌倒了。他看起来—或感觉—自己就像个傻瓜。\n就算一个专业教练,对一个刚上路的新手也一筹莫展。滑雪教练滑出的优美动作是他口中所说的简单动作,而对一个新学者来说不只是天方夜谭,更近乎侮辱了。你要怎样才能记住教练所说的每一个动作?屈膝,眼睛往下面的山丘看,重心向下,保持背部挺直,还得学着身体往前倾。要求似乎没完没了—你怎能记住这么多事,同时还要滑雪呢?\n当然,滑雪的重点在不该将所有的动作分开来想,而是要连贯在一起,平滑而稳定地转动。你只要顾着往山下看,不管你会碰撞到什么,也不要理会其他同伴,享受冰凉的风吹在脸颊上,往山下滑行时身体流动的快感。换句话说,你一定要学会忘掉那些分开的步骤,才能表现出整体的动作,而每一个单一的步骤都还要确实表现得很好。但是,为了要忘掉这些单一的动作,一开始你必须先分别学会每一个单一的动作。只有这样,你才能将所有的动作连结起来,变成一个优秀的滑雪高手。\n这就踉阅读一样,或许你已经阅读了很长一段时间,现在却要一切重新开始,实在有点难堪。但是阅读就跟滑雪一样,除非你对每一个步骤都很熟练之后,你才能将所有不同的步骤连结起来,变成一个复杂却和谐的动作。你无法压缩其中不同的部分,好让不同的步骤立刻紧密连结起来。你在做这件事时,每一个分开来的步骤都需要你全神贯注地去做。在你分别练习过这些分开来的步骤后,你不但能放下你的注意力,很有效地将每个步骤做好,还能将所有的动作结合起来,表现出一个整体的顺畅行动。\n这是学习一种复杂技巧的基本知识。我们会这么说,仅仅是因为我们希望你知道学习阅读,至少跟学习滑雪、打字或打网球一样复杂。如果你能回想一下过去所学习的经验,就比较能忍受一位提出一大堆阅读规则的指导者了。\n一个人只要学习过一种复杂的技巧,就会知道要学习一项新技巧,一开始的复杂过程是不足为惧的。也知道他用不着担心这些个别的行动,因为只有当他精通这些个别的行动时,才能完成一个整体的行动。\n规则的多样化,意味着要养成一个习惯的复杂度,而非表示要形成许多个不同的习惯。在到达一个程度时,每个分开的动作自然会压缩、连结起来,变成一个完整的动作。当所有相关动作都能相当自然地做出来时,你就已经养成做这件事的习惯了。然后你就能想一下如何掌握一个专家的动作,滑出一个你从没滑过的动作,或是读一本以前你觉得对自己来说很困难的书。一开始时,学习者只会注意到自己与那些分开来的动作。等所有分开的动作不再分离,渐渐融为一体时,学习者便能将注意力转移到目标上,而他也具备了要达成目标的能力了。\n我们希望在这几页中所说的话能给你一些鼓励。要学习做一个很好的阅读者并不容易。而且不单单只是阅读,还是分析式的阅读。那是非常复杂的阅读技巧—比滑雪复杂多了。那更是一种心智的活动。一个初学滑雪的人必须先考虑到身体的动作,之后他才能放下这些注意力,做出自然的动作。相对来说,考虑到身体的动作还是比较容易做到的。考虑到心智上的活动却困难许多,尤其是在刚开始做分析阅读时更是如此,因为他总是在想着自己的想法。大多数人都不习惯这样的阅读。虽然如此,但仍然是可以训练出来的。而一旦学会了,你的阅读技巧就会越来越好。\n"},{"id":133,"href":"/zh/docs/culture/%E5%A6%82%E4%BD%95%E9%98%85%E8%AF%BB%E4%B8%80%E6%9C%AC%E4%B9%A6/%E7%AC%AC%E5%9B%9B%E7%AF%87_%E9%98%85%E8%AF%BB%E7%9A%84%E6%9C%80%E7%BB%88%E7%9B%AE%E6%A0%87/","title":"第四篇 阅读的最终目标","section":"如何阅读一本书","content":"第四篇 阅读的最终目标\n第二十章 阅读的第四个层次:主题阅读 # 到目前为止,我们还没有仔细谈过关于就同一个主题阅读两三本书的问题。我们在前面提到过,在讨论某个特定的主题时,牵涉到的往往不只是一本书。我们也一再非正式地提醒过,甚至其他领域中相关的作者与书籍,都与这个特定的主题有关。在作主题阅读时,第一个要求就是知道:对一个特定的问题来说,所牵涉的绝对不是一本书而已。第二个要求则是:要知道就总的来说,应该读的是哪些书?第二个要求比第一个要求还难做到。\n我们在检验这个句子:“与同一个主题相关两本以上的书”时,困难就出现了。我们所说的“同一个主题”是什么意思?如果这个主题是单一的历史时期或事件,就很清楚了,但是在其他的领域中,就很难作这样清楚的区分。《飘》与《战争与和平》都是关于伟大战争的小说—但是,两者相似之处也止于此了。司汤达的《帕玛修道院》(The Charterhouse of Parma)谈的拿破仑战争,也是托尔斯泰作品中谈的战争。但是这两本书当然都不是在谈这场战争,也不是与一般战争有关的书。在这两个故事中,战争只是提供了一个环境或背景,故事的本身所谈的是人类的生存与挣扎,战争不过是作者想吸引读者注意的手法。我们可能会了解有关这场战役的一些事情—事实上,托尔斯泰就说过,从司汤达所描述的滑铁卢之役中,他学到很多有关这场战役的事—但是如果我们的主题是要研究战争,就用不着拿这些小说来读了。\n你可能料到小说有这种情况。因为作品的特性,小说沟通问题的方法跟论说性作品不同。但是,论说性作品也有同样的问题。\n譬如说你对“爱”这个概念很感兴趣,想要阅读相关的读物。因为关于爱的作品很广泛,你要整理出一个相关书目来阅读是有点困难的。假设你向专家求教,到一个完备的图书馆中寻找书目,还对照一位优秀学者所写的论文,终于把书目弄出来了。再假设你进一步舍弃诗人和小说家谈的这个主题,只想从论说性的作品中找答案(在后面我们会说明为什么这样的做法是明智的)。现在你开始依照书目来阅读这些书了。你发现什么?\n即使只是匆匆的浏览,你也会找到一大堆相关的资料。人类的行为,几乎没有任何一种行为没有被称作是爱的行为—只是称呼的方式不同而已。而且爱并不只限于人类。如果你进一步往下阅读,你会发现宇宙中的万事万物皆有爱。也就是说,任何存在的事物都可能爱与被爱—或二者兼而有之。\n石头是爱,因为它是地球的中心。火焰会上扬,是因为爱的功能。铁刀会吸引磁铁,被形容为爱的结果。有些书专门研究变形虫、草履虫、蜗牛、蚂蚁的爱情生活。更别提一些较高等的动物,它们会爱它们的主人,也会彼此相爱。谈到人类的爱,我们发现作者谈到也写到他们对男人们、女人们、一个男人、一个女人、孩子、他们自己、人类、金钱、艺术、家庭生活、原则、原因、职业或专业、冒险、安全、想法、乡村生活、爱的本身、牛排或美酒之爱。在某些教材中,天体的运转被认为是受到爱的启发。而天使与魔鬼的不同就在爱的品质不同。至于上帝,当然是要来爱人的。\n面对如此庞大的相关资料,我们要如何决定我们要研究的主题是什么呢?我们能确定这中间只有一个单一的主题吗?当一个人说:“我爱起司。”另一个人说“我爱橄榄球。”而第三个人说“我爱人类”时,他们三个人所用的同样一个爱字,代表着同样的意义吗?毕竟,起司是可以吃的,橄榄球或人类是不能吃的。一个人可以玩橄榄球,却不能玩起司或其他的人。而不论“我爱人类”是什么意思,这个爱都与起司或橄榄球之爱不同。但是这三个人用的都是同样一个爱字。在这其中是否有深刻的理由?一些无法立即浮现的理由?就像这个问题本身的困难,在我们找到答案之前,我们能说我们已经确认了“同一个主题”吗?\n面对如此的混乱,你可能会决定把范围缩小到人类的爱上—人与人之间的爱,同性爱或异性爱,同年之爱或忘年之爱等等。其中的规则又跟我们前面说的三种爱法不同了。但是就算你只读了一小部分与主题相关的书,你仍然会找到一堆的相关资料。譬如你会发现某些作者说:爱只是一种占有的欲望,通常是性的欲望,也就是说,爱只是一种所有动物在面对异性时会产生的吸引力。但是你也会发现另一个作者所谈的爱是不包含占有的欲望,而是一种慈善。如果说占有的欲望总是暗示着想要为自己追求好东西,而慈善却暗示着要为别人追求好东西。那么占有的欲望与慈善之间,是否有相通之处?\n至少在占有的欲望与慈善之间,分享着一种共同的倾向,那就是渴望某种非常抽象的东西。但是你对这个主题的研究很快又让你发现:某些作者主张的爱是心灵的,而非肉欲的。这些作者认为爱是知性的行为,而非感性的行为。换句话说,知道某个人是值得仰慕的,总会引发渴望之心,不论是前面所说的哪一种渴望都行。这类作者并不否认有这样的渴望,但他们不承认那就是爱。\n让我们假设—事实上,我们认为可以做得到—在这么多有关人类之爱的构想中,你能找出一些共通的意义。就算是这样,你的问题还是没有解决。再想想看,在人际之间,爱所表现出来的方式其实是截然不同的。男女之间的爱在恋爱期间、结婚之后、二十多岁时、七十多岁时都相同吗?一个女人对丈夫的爱与对孩子的爱相同吗?当孩子长大时,母亲对他们的爱就改变了吗?一个兄弟对姊妹的爱,跟他对父亲的爱是一样的吗?一个孩子长大之后,对父母的爱会改变吗?男人对女人的爱—无论是妻子或其他的女人—跟他对朋友的爱是相同的吗?他和不同朋友之间的关系—像是某人跟他一起打保龄球,某人是一起工作的伙伴,某人是知性的伙伴等—是否各有不同?“爱情”与“友情”之所以不同,是因为其中牵涉到的情绪(如果这是它们被命名的原因)不同,才有不同的名称吗?两个不同年纪的人也能做朋友吗?两个在财富与知识水平上有明显差距的人,也能做朋友吗?女人之间真的有友谊吗?兄弟姊妹,或哥哥弟弟、姊姊妹妹之间真的能成为朋友吗?如果你向人借钱,或是借钱给人,你们之间的友谊能保持下去吗?如果不能·,为什么?一个男孩子能爱上自己的老师吗?而这个老师是男是女,会不会造成什么样的差别?如果真的有像人一样的机器人,人类会爱他们吗?如果我们在火星或其他星球上发现了有智慧的生物,我们会爱他们吗?我们会不会爱上一个素昧平生的人,像是电影明星或总统?如果我们觉得恨某个人,那是否其实是一种爱的表现?\n你只不过读了一小部分有关爱的论说性作品,这些问题就会浮现在你脑海中,其实还有更多其他的问题会出现。无论如何,我们已经说到重点了。在做主题阅读时,会出现一种很矛盾的现象。虽然这个层次的阅读被定义为就同一个主题,阅读两种以上的书,意思也是指在阅读开始之前,这个主题就已经被确认了,但是换个角度来说,这个主题也是跟着阅读走的,而不是事前就能定出来的。以爱这个例子来说,在你决定自己要读些什么之前,你可能已经读了好几百本相关的著作了。等你都读完之后,你会发现有一半的书其实跟主题根本无关。\n1.在主题阅读中,检视阅读所扮演的角色 # 我们已经说过很多次,阅读的层次是渐进累积的。较高层次的阅读中也包括了前面的,或较低层次的阅读。在主题阅读中,我们就要说明这一点。\n你可能还记得,在解说检视阅读与分析阅读的关系时,我们指出在检视阅读中的两个步骤—第一个是浏览,第二个是粗浅地阅读—也就是分析阅读的前两个步骤。浏览能帮助你准备做分析阅读的第一个步骤:你能确定自己在读的是什么主题,能说明这是什么样的书,并拟出大纲架构。粗浅的阅读对分析阅读的第一步骤也有帮助。基本上这是进人第二步骤的准备动作。在第二个步骤中,你要能够与作者达成共识,说明他的主旨,跟随他的论述,才能够诠释整本书的内容。\n同样的,检视阅读与分析阅读也可以当作是进人主题阅读的前置作业或准备动作。事实上,在这个阶段,检视阅读已经是读者在阅读时主要的工具或手段了。\n举例来说,你有上百本的参考书目,看起来全是与爱有关的主题。如果你全部用分析阅读来阅读,你不只会很清楚你在研究的主题是什么—主题阅读中的“同一主题”—你还会知道你所阅读的书中,那些跟主题无关,是你不需要的书。但是要用分析阅读将一百本书读完,.会花上你十年的时间。就算你能全心投注在这个研究上,仍然要花上好几个月的时间。再加上我们前面谈过的主题阅读中会出现的矛盾问题,显然必要有一些捷径。\n这个捷径是要靠你的检视阅读技巧来建立的。你收集好书目之后,要做的第一件事是检视书单上所有的书。在做检视阅读之前,绝不要用分析阅读来阅读。检视阅读不会让你明白有关主题的所有错综复杂的内容,或是作者所有的洞察力,但却具有两种基本的功能。第一,它会让你对自己想要研究的主题有个清晰的概念,这样接下来你针对某几本书做分析阅读时,会大有助益。其次,它会简化你的书目到一个合理的程度。\n对学生,尤其是研究生来说,我们很难想到还有比这更管用的方式。只要他们肯照着做,一定会有帮助。根据我们的经验,在研究生程度的学生中,确实有些人能做到主动的阅读与分析阅读。这对他们来说还不够,他们或许不是完美的读者,但是至少他们知道要如何掌握一本书的重点,能明确地说出书中的要点,并把这些观点纳人他们研究主题的一部分。但是他们的努力有一大半是浪费掉了,因为他们不知道要如何才能比别人读得快一点。他们阅读每一本书或每一篇文章都花上同样的时间与努力,结果他们该花精神好好阅读的书却没有读好,倒把时间花在那些不太值得注意的书上了。\n能够熟练检视阅读的读者,不但能在心中将书籍分类,而且能对内容有一个粗浅的了解。他也会用非常短的时间就发现,这本书谈的内容对他研究的主题到底重不重要。这时他可能还不清楚哪些资料才是最重要的—这可能要等到读下本书的时候才能发现。但是有两件事至少他已经知道其中之一。那就是他不是发现这本书必须回头再读一次,以获得启发,便是知道不论这本书多有趣又多丰富,却毫无启发性,因此不值得重新再读。\n这个忠告通常会被忽略是有原因的。我们说过,在分析阅读中,技巧熟练的阅读者可以同时用上许多技巧,而初学者却必须把步骤分开来。同样的,主题阅读的准备工作—先检视书目上所有的书,在开始做分析阅读之前先检视一遍—可以在做分析阅读时一并进行。但我们不相信任何读者能做到这一点,就算技巧再熟练也不行。这也是许多年轻研究生所犯的毛病。他们自以为两个步骤可以融合为一个,结果阅读任何书都用同样的速度,对某些特殊的作品来说不是太快就是太慢,但无论如何,对他们阅读的大部分书来说,这样的方法都是不对的。\n一旦你检视过,确定某些书跟你研究的主题相关后,你就可以开始做主题阅读了。要注意的是,我们并没有像你以为的说:“开始做分析阅读”。当然,你需要研读每一本书,再组合起跟你主题相关的资料,你在做分析阅读时就已经学会了这些技巧。但是绝不要忘了,分析阅读的技巧只适用于单一的作品,主要的目标是要了解这本书。而我们会看到,主题阅读的目标却大不相同。\n2.主题阅读的五个步骤 # 现在我们准备好要说明如何做主题阅读了。我们的假设是:你已经检视了相当多的书,你至少对其中一些书在谈些什么有点概念了,而且你也有想要研究的主题了。接下来你该怎么办?\n在主题阅读中一共有五个步骤。这些步骤我们不该称之为规则—虽然也许我们会—因为只要漏掉其中一个步骤,主题阅读就会变得很困难,甚至读不下去了。我们会简略地介绍一下这些步骤的顺序,不过这些步骤彼此之间还是可以互相取代的。·\n主题阅读步骤一:找到相关的章节。当然,我们假设你已经学会分析阅读了,如果你愿意,你能把所有相关的书都看透彻了。但是你可能会把阅读单本的书放在第一顺位,而把自己的主题放在其次。事实上,这个顺序应该颠倒过来,在主题阅读中,你及你关心的主题才是基本的重点,而不是你阅读的书。\n在你已经确定哪些书是相关的之后,主题阅读的第一个步骤就是把这些书整体检视阅读一遍。你的目标是找出书中与你的主题极为相关的章节。你选择的书不太可能全本都与你的主题或问题相关。就算是如此,也一定是少数,你应该很快地把这本书读完。你不该忘了,你的阅读是别有用心的—也就是说,你是为了要解决自己的问题才阅读—而不是为了这本书本身的目的而阅读。\n看起来,这个步骤似乎与前面所说的,为了发现这本书是否与你主题相关的检视阅读当同一件事来进行。许多状况的确可以这么做。但是如果你认为永远都可以这么做的话,可能就不太聪明了。记住,第一步的检视阅读是要集中焦点在你要进一步做主题阅读的主题上。我们说过,除非你已经检阅过书单上大部分的书,否则你无法完全理解这个问题。因此,在确认哪些是相关的书籍的同时,还要确认哪些是相关的章节,其实是很危险的做法。除非你的技巧已经很熟练,而且对你要研究的主题已经很清楚了,否则你最好是将两部分分开来做。\n在主题阅读中,能够把你所阅读的第一批书,与你后来针对这个主题阅读的许多本书的差别区分出来,是很重要的事。对后来的这些书来说,你可能对自己的主题已经有了很清楚的概念,这时就可以把两种检视阅读合并在一起。但是在一开始时,却要明显地区分出来,否则你在找相关章节时会犯下严重的错误,到后来要更正这些错误时又要花上很多的时间与精力。\n总之,要记得你最主要的工作不是理解整本书的内容,而是找出这本书对你的主题有什么帮助,而这可能与作者本身的写作目的相去甚远。在这个阶段的过程中,这并不重要。作者可能是在无意之间帮你解决了问题。我们已经说过,在主题阅读中,是书在服务你,而不是你在服务书。因此,主题阅读是最主动的一种阅读法。当然,分析阅读也需要主动的阅读方式。但是你在分析阅读一本书时,你就像是把书当作主人,供他使唤。而你在做主题阅读时,却一定要做书的主人。\n因此,在与作者达成共识这一点上,这个阶段有不同的做法。\n主题阅读步骤二:带引作者与你达成共识。在诠释阅读中(分析阅读的第二步骤),第一个规则是要你与作者达成共识,也就是要能找出关键字,发现他是如何使用这些字的。但是现在你面对的是许多不同的作者,他们不可能每个人都使用同样的字眼,或相同的共识。在这时候就是要由你来建立起共识,带引你的作者们与你达成共识,而不是你跟着他们走。\n在主题阅读中,这可能是最困难的一个步骤。真正的困难在于要强迫作者使用你的语言,而不是使用他的语言。这跟我们一般的阅读习惯都不相同。我们也指出过很多次,我们假设:我们想要用分析阅读来阅读的作者,是比我们优秀的人。尤其如果这是一本伟大的著作时,就更可能如此。无论我们在了解他的过程中花了多少力气,我们都会倾向于接受他的词义与他安排的主题结构。但在主题阅读中,如果我们接受任何一位作者所提出来的词汇(terminology),我们很快就会迷失。我们可能会了解他的书,却无法了解别人的书。我们也很难找到与自己感兴趣的主题的资料。\n我们不只要能够坚决拒绝接受任何一位作者的词汇,还得愿意面对可能没有任何一位作者的词汇对我们来说是有用的事实。换句话说,我们必须要接受一个事实:我们的词汇刚好与任何一位书目上的作者相同时,只是一种巧合。事实上,这样的巧合还满麻烦的。因为如果我们使用了某一位作者的一个或一组词义,我们就可能继续引用他书中其他的词义,而这只会带给我们麻烦,没有其他的帮助。\n简单来说,主题阅读是一种大量的翻译工作。我们并不是将一种语言翻成另一种语言,像法语翻成英语,但是我们要将一种共通的词汇加诸在许多作者身上,无论他们所使用的是不是相同的语言,或是不是关心我们想解决的问题,是否创造了理想的词汇供我们使用。\n这就是说,在进行主题阅读时,我们要建立一组词汇,首先帮助我们了解所有的作者,而不是其中一两个作者;其次帮助我们解决我们的问题。这一点认识会带我们进人第三个步骤。\n主题阅读步骤三:厘清问题。诠释阅读的第二个规则是要我们找出作者的关键句子。然后从中逐步了解作者的主旨。主旨是由词义组成的,在主题阅读中,当然我们也要做同样的工作。但是因为这时是由我们自己来建立词汇,因此,我们也得建立起一组不偏不倚的主旨。最好的方法是先列出一些可以把我们的问题说得比较明白的问题,然后让那些作者来回答这些问题。\n这也是很困难的工作,这些问题必须要以某种形式,某种秩序来说明,以帮助我们解决我们提出的问题,同时这些问题也要是大多数作者都能回答的问题。难就难在我们认为是问题的地方,作者也许并不认为是问题。他们对我们认定的主题可能有相当不同的看法。\n事实上,有时候我们必须接受作者可能一个问题也回答不了。在这样的状况中,我们必须要将他视为是对这个问题保持沉默,或是尚未作出决定。但是就算他并没有很清楚地讨论这个问题,有时我们也可以在他书中找到间接的回答。我们会得出这么一个结论:如果他考虑到这个问题的话,那就会如何如何回答这个问题。在这里需要一点自我约束。我们不能把思想强加在作者脑海中,也不能把话语放进他们的口中。但是我们也不能完全依赖他们对这个问题的解说。如果我们真的能靠其中任何一位作者来解释这个问题,或许我们根本就没有问题要解决。\n我们说过要把问题照秩序排列出来,好帮助我们在研究时使用。当然,这个秩序是跟主题有关的,不过还是有一般的方向可循。第一个问题通常跟我们在研究的概念或现象的存在或特质有关。如果一位作者说这种现象的确存在,或这种概念有一种特质,那么对于他的书我们就要提出更进一步的问题了。这个问题可能跟这个现象是如何被发现,或这个概念是如何表现出来的有关。最后一部分的问题则是与回答前面问题所产生的影响有关。\n我们不该期望所有的作者都用同一种方法来回答我们的问题。如果他们这么做了,我们就又没有问题要解决了。那个问题会被一致的意见解决了。正因为每个作者都不相同,因此我们要再面对主题阅读的下一个步骤。\n主题阅读步骤四:界定议题。如果一个问题很清楚,如果我们也确定各个作者会用不同的方式来回答—不论赞成或反对—那么这个议题就被定义出来了。这是介于用这种方法回答问题的作者,和用另外一种(可能是相反的)方法来回答问题的作者之间的议题。\n如果检验过后,所有的作者提供的答案只有正反两面的意见,那么这个问题算是简单的问题。通常,对一个问题会有超过两种以上的答案。在这种情况下,我们就要找出不同意见彼此之间的关联,再根据作者的观点来作分类。\n当两个作者对同一个问题有相当的了解,所作的回答却完全相反或矛盾时,这才是一个真正有参与的议题。但是这样的现象并不像我们希望的那样经常发生。通常,答案之不同固然来自于各人对这个主题有不同的观点,但也有很多情况是来自于对问题本身的认知不同。所以在做主题阅读的读者,要尽可能地确保议题是大家所共同参与的。有时候这会迫使他在列出问题的时候,小心不采取任何一位作者明白采用的方法。\n我们要处理的问题,可能会出现很多种不同的议题,不过通常都可以分门别类。譬如像考虑到某种概念的特质的问题,就会出现一堆相关的议题。许多议题绕着一组相互关联密切的问题打转,就会形成这个主题的争议。这样的争议可能很复杂,这时主题阅读的读者就要将所有争议的前后关系整理清楚—尽管没有任何作者做这件事。厘清争议,同时将相关议题整理出来之后,我们便要进入主题阅读的最后一个步骤。\n主题阅读步骤五:分析讨论。到目前为止,我们已经检验过作品,找出相关的章节,设定了一个不偏不倚的共识,适用于所有被检视过的作者,再设定出一整套的问题,其中大部分都能在作者的说明中找到答案。然后就不同的答案界定并安排出议题。接下来该怎么做呢?\n前面四个步骤与分析阅读的前两组规则是互相辉映的。这些规则应用在任何一本书中,都会要我们回答一个问题:这本书在说些什么?是如何说明的?在主题阅读中,对于与我们的问题相关的讨论,我们也要回答类似的问题。在只阅读一本书的分析阅读中,剩下还有两个问题要回答:这是真实的吗?这与我何干?而在主题阅读中,我们对于讨论也要准备回答同样的问题。\n让我们假设起头的那个阅读问题并不单纯,是个几世纪以来与许多思考者纷争不已的长久问题,许多人家不同意,并且会继续不同意的问题。在这个假设中,我们要认知的是,身为主题阅读的读者,我们的责任不只是要自己回答这些问题—这些问题是我们仔细整理出来,以便易于说明主题的本身与讨论的内容。有关这类问题的真理并不容易发现。如果我们期望真理就存在某一组问题的答案之中,那可能太轻率了。就算能找到答案,也是在一些相互矛盾的答案的冲突中找到令人信服的证据,而且有支持自己的确切理由。\n因此,就可以发现的真理而言,就我们可以找到的问题答案而言,与其说是立足于任何一组主旨或主张上,不如说是立足于顺序清楚的讨论的本身。因此,为了要让我们的头脑接受这样的真相—也让别人接受—我们要多做一点工作,不只是问问题与回答问题而已。我们要依照特定的顺序来提问题,也要能够辨认为什么是这个顺序。我们必须说明这些问题的不同答案,并说明原因。我们也一定要能够从我们检视过的书中找出支持我们把答案如此分类的根据。只有当我们做到这一切时,我们才能号称针对我们问题的讨论作了分析,也才能号称真正了解了问题。\n事实上,我们所做的可能超过这些。对一个问题完整地分析过后,将来其他人对同一个问题要作研究时,我们的分析讨论就会提供他一个很好的研究基础。那会清除一些障碍,理出一条路,让一个原创性的思考者能突破困境。如果没有这个分析的工作,就没法做到这一点,因为这个问题的各个层面就无法显现出来。\n3.客观的必要性 # 要完整地分析一个问题或某个主题,得指出这个讨论中的主要议题,或是一些基本的知性反对立场。这并不是说在所有的讨论中,反对的意见总是占主导的。相反,同意或反对的意见总是互相并存的。也就是说,在大多数的议题中,正反两面的意见总是有几个,甚至许多作者在支持。在一个争议性的立场上,我们很少看到一个孤零零的支持者或反对者。\n人类对任何领域某种事物的特质达成一致的观点,都建立一种假设,意味着他们共同拥有的意见代表着真理。而不同的观点则会建立起另一个相反的假设—无论你是否参与,这些争论中的观点可能没有一个是完全真实的。当然,在这些冲突的观点中,也可能有一个是完全真实的,而其他的则是虚假的。不过也可能双方面都只是表达了整体真理的一小部分。除了一些单调或孤立的争论之外(就我们在这里所读的问题,不太可能有这种形式的讨论),很可能正反双方的意见都是错的,一如所有的人可能都同意了一种错误的观点。而另一些没有表达出来的观点才可能是真实的,或接近真实的。\n换句话说,主题阅读的目的,并不是给阅读过程中发展出来的问题提供最终答案,也不是给这个计划开始时候的问题提供最终解答。当我们要给这样的主题阅读写一份读者报告的时候,这个道理特别清楚。如果这份报告就任何所界定并分析过的重要议题,想要主张或证明某一种观点的真实或虚假,都会太过教条,失去对话的意义。如果这么做,主题阅读就不再是主题阅读,而只是讨论过程中的另一个声音,失去了疏离与客观性。\n我们要说的,并不是我们认为对人类关心的重要议题多一个声音无足轻重。我们要说的是我们在追求理解的过程中,可以而且应该多贡献一种不同的形式。而这样的形式必须是绝对客观又公正的。主题阅读所追求的这种特质,可以用这句话来作总结:“辩证的客观。”\n简单来说,主题阅读就是要能面面俱到,而自己并不预设立场。当然,这是个严格的理想,一般人是没法做到的。而绝对的客观也不是人类所能做到的事。他可能可以做到不预设立场,毫无偏见地呈现出任何观点,对不同的意见也保持中立。但是采取中立比面面俱到要容易多了。在这一方面,主题阅读的读者注定会失败的。一个议题有各种不同的观点,不可能巨细靡遗地全都列出来。虽然如此,读者还是要努力一试。\n虽然我们说保持中立要比面面俱到容易一些,但还是没那么容易。主题阅读的读者必须抗拒一些诱惑,厘清自己的思绪。对于某些冲突性的观点避免作出明白的真伪判断,并不能保证就能做到完全的公正客观。偏见可能会以各种微妙的方式进人你的脑海中—可能是总结论述的方式,可能是因为强调与忽略的比重,可能是某个问题的语气或评论的色彩,甚至可能因为对某些关键问题的不同答案的排列顺序。\n要避免这样的危险,谨慎的主题阅读的读者可以采取一个明显的手段,尽量多加利用。那就是他要不断回头参阅诸多作者的原文,重新再阅读相关的章节。并且,当他要让更多的人能应用他的研究结果时,他必须照原作者的原文来引用他的观点或论述。虽然看起来有点矛盾,但这并不影响我们前面所说的,在分析问题时必须先建立一套中立的词汇。这样的中立语言还是必要的,而且在总结一个作者的论述时,一定要用这套中立的语言,而不是作者的语言。但是伴随着总结,一定要有仔细引用的作者原文,以免对文意有所扭曲,这样阅读者才能自己判断你对作者所作的诠释是否正确。\n主题阅读的读者必须能够坚决地避免这个问题,才不会偏离公正客观的立场。要达到这样的理想,必须要能不偏不倚地在各种相对立的问题中保持平衡,放下一切偏见,反省自己是否有过与不及的倾向。在最后的分析中,一份主题阅读的书面报告是否达到对话形式的客观,虽然也可以由读者来判断,但只有写这份报告的人才真正明白自己是否达到这些要求。\n4.主题阅读的练习实例:进步论 # 举个例子可以说明主题阅读是如何运作的。让我们以进步这个概念做例子。我们并不是随便找的这个例子。对这个问题我们做了相当多的研究。否则这个例子对你来说不会很有用。\n我们花了很长的时间研究这个重要的历史与哲学问题。第一个步骤是列出与研究主题相关的章节—也就是列出书目(最后出现的书单超过450本)。要完成这项工作,我们运用了一连串的检视阅读。针对许多书籍、文章与相关著作,做了许多次的检视阅读。对于讨论“进步”这个概念来说,这是非常重要的一个过程。同样的,对其他的重大研究来说这也是很重要的过程。许多最后被判定为相关的资料多少都是无意间发现的,或至少也是经过合理的猜测才找到的。许多近代的书籍都以“进步”为书名,因此要开始寻找资料并不困难。但是其他的书并没有标明进步这两个字,尤其是一些古书,内容虽然相关,却并没有运用这个词句。\n我们也读了一些小说或诗,但最后决定以论说性的作品为主。我们早说过,在主题阅读中,要包括小说、戏剧与诗是很困难的,原因有很多个。第一,故事的精髓在情节,而非对某个议题所秉持的立场。其次,就算是最能言善道的角色也很少对某个议题清楚表达出立场—譬如托马斯·曼的《魔山》(Magic Mountain)中,斯坦布林尼就对进步发表过一些见解—我们无法确定那是不是作者本人的观点。是作者在利用他的角色对这个议题作出反讽?还是他想要你看到这个观点的愚蠢,而非睿智?一般来说,要将小说作者的观点列人议题的某一方时,需要作很多很广泛的努力。要花的努力很多,得到的结果却可能是半信半疑的,因此通常最好放弃在这方面的努力。\n可以检验进步这个概念的其他许多作品,一如常见的情况,显得一片混乱。面对这样的问题,我们前面说过,就是要建立起一套中立的语言。这是一个很复杂的工作,下面的例子可以帮助我们说明这是如何进行的。\n所谓“进步”一词,不同的作者有许多不同的用法。这些不同的用法,大部分显示的只是意义的轻重不同,因而可以用分析的方法来处理。但是有些作者也用这个词来指出历史上某种特定的变化,而这种变化不是改善的变化。既然大多数作者都用“进步”来指出历史上某种为了促进人类朝向更美好生活的变化,并且既然往更改善的状态的变化是这个概念的基础,那么同样的字眼就不能适用于两种相反的概念了。因此,本例我们取大多数人的用法,那些主张历史上“非关改善的进展”(non meliorative advance)的作者,就只好划为少数派了。我们这么说的目的是,在讨论这些少数作者的观点时,就算他们自己运用了“进步”这样的字眼,我们也不能将他们纳入“进步”的概念中。\n我们前面说过,主题阅读的第三步是厘清问题。在“进步”的例子中,我们对这个问题一开始的直觉,经过检验之后,证明是正确的。第一个要问的问题,也是各个作者被认为提供各种不同答案的问题,是“历史上真的有‘进步\u0026rsquo;这回事吗?”说历史的演变整体是朝向改善人类的生存条件,的确是事实吗?基本上,对这个问题有三种不同的回答:(1)是;(2)否;(3)不知道。然而,回答“是”可以用许多不同的方式来表达,回答“否”也有好几种说法,而说“不知道”也至少有三种方式。\n对这个基本问题所产生的各式各样相互牵连的答案,构成我们所谓关于进步的一般性争议。所谓一般性,是因为我们研究的每个作者,只要对这个主题有话要说,就会在这个主题所界定的各个议题上选边站。但是对于进步还有一种特殊的争论,参与这种议题的,都是一些主张进步论的作者—这些作者主张进步确实发生。身为进步论的作者,他们全都强调进步是一种历史的事实,而所有的议题都应该和进步的本质或特质相关。这里的议题其实只有三种,只是个别讨论起来都很复杂。这三个议题我们可以用问题的形式来说明:(1)进步是必要的?还是要取决于其他事件?(2)进步会一直无止境地持续下去?还是会走到终点或高原期而消失?(3)进步是人类的天性,还是养成的习惯—来自人类动物的本能,或只是外在环境的影响?\n最后,就进步发生的面向而言,还有一些次要议题,不过,这些议题仍然只限于在主张进步论的作者之间。有六个面向是某些作者认为会发生,另外有些作者虽然多少会反对其中一两个的发生,但不会全部反 对(因为他们在定义上就是肯定进步发生的作者)。这六个面向是:(1)知识的进步;(2)技术的进步;(3)经济的进步;(4)政治的进步;(5)道德的进步;(6)艺术的进步。关于最后一项有些特殊的争议。因为在我们的观点里,没有一位作者坚信在这个面向中真的有进步,甚至有些作者否认这个面向有进步。\n我们列举出“进步”的分析架构,只是要让你明白,在这个主题中包含了多少的议题,与对这些讨论的分析—换句话说,这也是主题阅读的第四及第五个步骤。主题阅读的读者必须做类似的工作才行,当然,他用不着非得就自己的研究写一本厚厚的书不可。\n5.如何应用主题工具书 # 如果你仔细阅读过本章,你会注意到,虽然我们花了不少时间谈这件事,但我们并没有解决主题阅读中的矛盾问题。这个矛盾可以说明如下:除非你知道要读些什么书,你没法使用主题阅读。但是除非你能做主题阅读,否则你不知道该读些什么书。换句话说,这可以算是主题阅读中的根本问题。也就是说,如果你不知道从何开始,你就没法做主题阅读。就算你对如何开始有粗浅的概念,你花在寻找相关书籍与篇章的时间,远超过其他步骤所需时间的总和。\n当然,至少理论上有一种方法可以解决这个矛盾的问题。理论上来说,你可以对我们传统中的主要经典作品有一番完整的认识,对每本书所讨论的各种观念都有相当的认知。如果你是这样的人,就根本用不着任何人帮忙,我们在主题阅读上也没法再多教给你什么了。\n从另一个角度来看,就算你本身没有这样的知识,你还是可以找有这种知识的人帮忙。但你要认清一点,就算你能找到这样的人,他的建议最后对你来说,在帮助的同时,几乎也都会变成障碍。如果那个主题正好是他做过特殊研究的,对他来说就很难只告诉你哪些章节是重要相关的,而不告诉你该如何读这些书—而这一点很可能就造成你的阻碍。但是如果他并没有针对这个主题做过特殊的研究,他知道的也许还没有你多—尽管你们双方都觉得应该比你多。\n因此,你需要的是一本工具书,能告诉你在广泛的资料当中,到哪里去找与你感兴趣的主题相关的章节,而用不着花时间教你如何读这些章节—也就是对这些章节的意义与影响不抱持偏见。譬如,主题工具书(Syntopicon)就是这样的一种工具。出版于1940年,名为《西方世界的经典名著))(Great Books of the Western World)的这套书,包含了三千种话题或主题,就每一个讨论到的主题,你可以按照页码找到相关的参考资料。某些参考资料长达多页,某些则只是几段关键文字。你用不着花太多时间,只需取出其中的某本书,动手翻阅便行了。\n当然,主题工具书有一个主要的缺点。这仍然是一套书目的索引(尽管是很大的一套),至于这套书没有包含的其他作品里什么地方可以找到你要的东西,则只有一些粗略的指引。不过,不管你要做哪一类主题阅读,这套书至少总能帮助你知道从何处着手。同时,在这整套名著中的书,不论是关于哪个主题,也都是你真的想要阅读的书。因此,主题工具书能帮助成熟的学者,或刚开始研究特定问题的初学者节省许多基本的研究工具,能让他很快进人重点,开始做独立的思考。因为他已经知道前人的思想是什么了。\n主题工具书对这种研究型的读者很有帮助,而且对初学者更有助益。主题工具书能从三方面帮助刚开始做研究的人:启动阅读,建议阅读.指导阅读。\n在启动阅读方面,主题工具书能帮助我们在面对传统经典作品时,克服最初的困难。这些作品都有点吸引力,我们都很想读这些书,但往往做不到。我们听到很多建议,要我们从不同的角度来阅读这样的书,而且有不同的阅读进度,从简单的作品开始读,再进展到困难的作品。但是所有这类阅读计划都是要读完整本书,或是至少要读完其中的大部分内容。就一般的经验来说,这样的解决方案很少能达到预期的效果。\n对于这类经典巨著,使用主题阅读再加上主题工具书的帮助,就会产生完全不同的解决方案。主题工具书可以帮读者就他们感兴趣的主题,启动他对一些经典著作的阅读—在这些主题上,先阅读来自大量不同作者的一些比较短的章节。这可以帮助我们在读完这些经典著作之前,先读进去。\n使用主题阅读来阅读经典名著,再加上主题工具书的帮助,还能提供我们许多建议。读者一开始阅读是对某个主题特别感兴趣,但是会逐渐激发出对其他主题的兴趣。而一旦你开始研究某位作者,就很难不去探索他的上下文。就在你明白过来之前,这本书你已经读了一大半了。\n最后,主题阅读加上主题工具书,还能从三种不同的方向指导关系。事实上,这是这个层次的阅读最有利的地方。\n第一,读者阅读的章节所涉及的主题,能够给他一个诠释这些章节的方向。但这并不是告诉他这些章节是什么意思,因为一个章节可能从好几个或许多个方向与主题相关。而读者的责任就是要找出这个章节与主题真正相关的地方在哪里。要学习这一点,需要拥有很重要的阅读技巧。\n第二,针对同一个主题,从许多不同的作者与书籍中收集出来的章节,能帮助读者强化对各个章节的诠释能力。有时候我们从同一本书中依照顺序来阅读的章节,以及挑出来比对阅读的章节,相互对照之下可以让我们更了解其中的含意。有时候从不同书中摘出来的章节是互相冲突的,但是当你读到彼此冲突的论点时,就会更明白其中的意义了。有时候从一个作者的书中摘出来的章节,由另一个作者的书的某个章节作补充或评论,实际上可以帮助读者对第二位作者有更多的了解。\n第三,如果主题阅读运用在许多不同的主题上,当你发现同一个章节被主题工具书引述在许多不同主题之下的时候,这件事情本身就很有指导阅读的效果。随着读者针对不同的主题要对这些章节进行多少不同的诠释,他会发现这些章节含有丰富的意义。这种多重诠释的技巧,不只是阅读技巧中的基本练习,同时也会训练我们的头脑面对任何含义丰富的章节时,能习惯性地作出适当的调整。\n因为我们相信,对想做这个层次的阅读的读者来说,无论他是资深的学者或初学者,主题工具书都很有帮助,因此我们称这一阅读层次为主题阅读。我们希望读者能原谅我们一点点的自我耽溺。为了回报您的宽容,我们要指出很重要的一点。主题阅读可以说有两种,一种是单独使用的主题阅读,一种是与主题工具一起并用。后一种可以当作是构成前一种阅读计划的一部分,一开始由这里着手,是最聪明的做法。而前一种主题阅读所应用的范围要比后一种广义许多。\n6.构成主题阅读的原则 # 有些人说主题阅读(就上述广义的定义来说)是不可能做到的事。他们说在一个作者身上强加一套语言,即使是最“中立”的一套词汇(就算真有这回事的话),也是错的。作者本身的词汇是神圣不可侵犯的,因为阅读一本书时绝不能“脱离上下文”,而且将一组词汇转成另一种解释总是很危险的,因为文字并不像数学符号那么容易控制。此外,反对者认为主题阅读牵涉的作者太广,时空不同,基本的风格与性质也不 同,而主题阅读就像是将他们都聚在同一个时空,彼此一起讨论—这完全扭曲了事实的真相。每位作者都有自己的天地,虽然同一位作者在不同时空所写的作品之间可能有些联系(他们提醒说即使这样也很危险),但是在这位作者与另一位作者之间却没有明显的联系。最后, 他们坚持,作者所讨论的主题比不上讨论的方法重要。他们说风格代表一个人,如果我们忽略作者是如何谈一件事,却只顾他谈的是什么事,结果只会两头落空,什么也没了解到。\n当然,我们对所有这些指控都不同意,我们要依序回答这些指控。让我们一次谈一个。\n第一,是关于词汇的问题。否认一个概念可以用不同的词汇来说明,就像否认一种语言可以翻译成另一种语言。当然,这样的否认是刻意制造出来的。譬如最近我们阅读《古兰经》的一个新译本,前言一开始便说要翻译《古兰经》是不可能的事。但是因为译者接着又解释他是如何完成的,所以我们只能假设他的意思是:要翻译这样一本被众人视为神圣的典籍,是一件极为困难的事。我们也同意。不过困难并不代表做不到。\n事实上,所谓作者本身的词汇是神圣不可侵犯的说法,其实只是在说要将一种说法翻译成另一种说法是非常困难的。这一点我们也同意。但是,同样的,困难并非不可能做到。\n其次,谈到作者各自区隔与独立的特性。这就像说有一天亚里士多德走进我们办公室(当然穿着长袍),身边跟着一位又懂现代英语又懂古希腊语的翻译,而我们却无法听懂他讲什么,他也无法听懂我们讲什么一样。我们不相信有这回事。毫无疑问,亚里士多德对他看到的许多事一定觉得很讶异,但我们确信在十分钟之内,只要我们想,我们就能跟他一起讨论某个我们共同关心的问题。对于一些特定的概念一定会发生困难,但是只要我们能够发现,就能解决。\n如果这是可行的(我们不认为任何人会否认),那么让一本书经由翻译—也就是主题阅读的读者—与另一本书的作者“谈话”,并不是不可能的事。当然,这需要很谨慎,而且你要把双方的语言—也就是两本书的内容—了解得越透彻越好。这些问题并非不能克服,如果你觉得无法克服只是在自欺欺人。\n最后,谈到风格的问题。我们认为,这就像是说人与人之间无法作理性的沟通,而只能作情绪上的沟通—就像你跟宠物沟通的层次。如果你用很愤怒的腔调对你的狗说:“我爱你!”它会吓得缩成一团,并不知道你在说什么。有谁能说:人与人之间的语言沟通,除了语气与姿势外就没有其他的东西?说话的语气是很重要的—尤其当沟通的主要内容是情绪关系的时候;而当我们只能听(或者看?)的时候,肢体语言中可能就有些要告诉我们的事情。但是人类的沟通,不只这些东西。如果你问一个人出口在哪里?他告诉你沿着B走廊就会看到。这时他用的是什么语气并不重要。他可能对也可能错,可能说实话也可能撒谎,但是重点在你沿着B走廊走,很快就能找到出口了。你知道他说的是什么,也照着做了,这跟他如何说这句话一点关系也没有。\n只要相信翻译是可行的(因为人类一直在做这件事),书与书之间就能彼此对谈(因为人类也一直在这么做)。只要愿意这么做,人与人之间也有理性客观的沟通能力(因为我们能彼此互相学习),所以我们相信主题阅读是可行的。\n7.主题阅读精华摘要 # 我们已经谈完主题阅读了。让我们将这个层次的阅读的每个步骤列举出来。\n我们说过,在主题阅读中有两个阶段。一个是准备阶段,另一个是主题阅读本身。让我们复习一下这些不同的步骤:\n一、 观察研究范围:主题阅读的准备阶段\n(1) 针对你要研究的主题,设计一份试验性的书目。你可以参考图书馆目录、专家的建议与书中的书目索引。\n(2) 浏览这份书目上所有的书,确定哪些与你的主题相关,并就你的主题建立起清楚的概念。\n二、 主题阅读:阅读所有第一阶段收集到的书籍\n(1) 浏览所有在第一阶段被认定与你主题相关的书,找出最相关的章节。\n(2) 根据主题创造出一套中立的词汇,带引作者与你达成共识—无论作者是否实际用到这些词汇,所有的作者,或至少绝大部分的作者都可以用这套词汇来诠释。\n(3) 建立一个中立的主旨,列出一连串的问题—无论作者是否明白谈过这些问题,所有的作者,或者至少大多数的作者都要能解读为针对这些问题提供了他们的回答。\n(4) 界定主要及次要的议题。然后将作者针对各个问题的不同意见整理陈列在各个议题之旁。你要记住,各个作者之间或之中,不见得一定存在着某个议题。有时候,你需要针对一些不是作者主要关心范围的事情,把他的观点解读,才能建构出这种议题。\n(5) 分析这些讨论。这得把问题和议题按顺序排列,以求突显主题。比较有共通性的议题,要放在比较没有共通性的议题之前。各个议题之间的关系也要清楚地界定出来。注意:理想上,要一直保持对话式的疏离与客观。要做到这一点,每当你要解读某个作家对一个议题的观点时,必须从他自己的文章中引一段话来并列。\n第二十一章 阅读与心智的成长 # 我们已经完成了在本书一开始时就提出的内容大要。我们已经说明清楚,良好的阅读基础在于主动的阅读。阅读时越主动,就读得越好。\n所谓主动的阅读,也就是能提出问题来。我们也指出在阅读任何一本书时该提出什么样的问题,以及不同种类的书必须怎样以不同的方式回答这些问题。\n我们也区分并讨论了阅读的四种层次,并说明这四个层次是累积渐进的,前面或较低层次的内容包含在后面较高层次的阅读里。接着,我们刻意强调后面较高层次的阅读,而比较不强调前面较低层次的阅读。因此,我们特别强调分析阅读与主题阅读。因为对大多数的读者来说,分析阅读可能是最不熟悉的一种阅读方式,我们特别花了很长的篇幅来讨论,定出规则,并说明应用的方法。不过分析阅读中的所有规则,只要照最后一章所说的略加调整,就同样适用于接下来的主题阅读。\n我们完成我们的工作了,但是你可能还没有完成你的工作。我们用不着再提醒你,这是一本实用性的书,或是阅读这种书的读者有什么特殊的义务。我们认为,如果读者阅读了一本实用的书,并接受作者的观点,认同他的建议是适当又有效的,那么读者一定要照着这样的建议行事。你可能不接受我们所支持的主要目标—也就是你应该有能力读得更透彻—也不同意我们建议达到目标的方法—也就是检视阅读、分析阅读与主题阅读的规则。(但如果是这样,你可能也读不到这一页了。)不过如果你接受这个目标,也同意这些方法是适当的,那你就一定要以自己以前可能从没有经历过的方式来努力阅读了。\n这就是你的工作与义务。我们能帮得上什么忙吗?\n我们想应该可以。这个工作主要的责任在你—你要做这所有的事(同时也获得所有的利益)。不过有几件关于目标与手段的事情还没谈到。现在就让我们先谈谈后者吧!\n1.好书能给我们什么帮助 # “手段\u0026quot;(means)这两个字可以解释成两种意义。在前面的章节中,我们将手段当作是阅读的规则,也就是使你变成一个更好的阅读者的方法。但是手段也可以解释为你所阅读的东西。空有方法却没有可以运用的材料,就和空有材料却没有可以运用的方法一样是毫无用处的。\n以“手段”的后一种意思来说,未来提升你阅读能力的手段其实是你将阅读的那些书。我们说过,这套阅读方法适用于任何一本,以及任何一种你所阅读的书—无论是小说还是非小说,想像文学还是论说性作品,实用性还是理论性。但是事实上,起码就我们在探讨分析阅读与主题阅读过程中所显示的这套方法并不适用于所有的书。原因是有些书根本用不上这样的阅读。\n我们在前面已经提过这一点了,但我们想要再提一遍,因为这与你马上要做的工作有关。.如果你的阅读目的是想变成一个更好的阅读者,你就不能摸到任何书或文章都读。如果你所读的书都在你的能力范围之内,你就没法提升自己的阅读能力。你必须能操纵超越你能力的书,或像我们所说的,阅读超越你头脑的书。只有那样的书能帮助你的思想增长_除非你能增长心智,否则你学不到东西。\n因此,对你来说最重要的是,你不只要能读得好,还要有能力分辨出哪些书能帮助你增进阅读能力。一本消遣或娱乐性的书可能会给你带来一时的欢愉,但是除了享乐之外,你也不可能再期待其他的收获了。我们并不是反对娱乐性的作品,我们要强调的是这类书无法让你增进阅读的技巧。只是报导一些你不知道的事实,却没法让你增进对这些事实的理解的书,也是同样的道理。为了讯息而阅读,就跟为了娱乐阅读一样,没法帮助你心智的成长。也许看起来你会以为是有所成长,但那只是因为你脑袋里多了一些你没读这本书之前所没有的讯息而已。然而,你的心智基本上跟过去没什么两样,只是阅读数量改变了,技巧却毫无进步。\n我们说过很多次,一个好的读者也是自我要求很高的读者。他在阅读时很主动,努力不懈。现在我们要谈的是另外一些观念。你想要用来练习阅读技巧,尤其是分析阅读技巧的书,一定要对你也有所要求。这些书一定要看起来是超越你的能力才行。你大可不必担心真的如此,只要你能运用我们所说的阅读技巧,没有一本书能逃开你的掌握。当然,这并不是说所有的技巧可以一下子像变魔术一样让你达到目标。无论你多么努力,总会有些书是跑在你前面的。事实上,这些书就是你要找的书,因为它们能让你变成一个更有技巧的读者。\n有些读者会有错误的观念,以为那些书—对读者的阅读技巧不断提出挑战的书籍—都是自己不熟悉的领域中的书。结果一般人都相信,对大多数读者来说,只有科学作品,或是哲学作品才是这种书。但是事实并非如此。我们已经说过,伟大的科学作品比一些非科学的书籍还要容易阅读,因为这些科学作者很仔细地想要跟你达成共识,帮你找出关键主旨,同时还把论述说明清楚。在文学作品中,找不到这样的帮助,所以长期来说,那些书才是要求最多,最难读的书。譬如从许多方面来说,荷马的书就比牛顿的书难读—尽管你在第一次读的时候,可能对荷马的体会较多。荷马之所以难读,是因为他所处理的主题是很难写好的东西。\n我们在这里所谈的困难,跟阅读一本烂书所谈的困难是不同的。阅读一本烂书也是很困难的事,因为那样的书会抵消你为分析阅读所作的努力,每当你认为能掌握到什么的时候又会溜走。事实上,一本烂书根本不值得你花时间去努力,甚至根本不值得作这样的尝试。你努力半天还是一无所获。\n读一本好书,却会让你的努力有所回报。最好的书对你的回馈也最多。当然,这样的回馈分成两种:第一,当你成功地阅读了一本难读的好书之后,你的阅读技巧必然增进了。第二—长期来说这一点更重要—一本好书能教你了解这个世界以及你自己。你不只更懂得如何读得更好,还更懂得生命。你变得更有智慧,而不只是更有知识—像只提供讯息的书所形成的那样。你会成为一位智者,对人类生命中永恒的真理有更深刻的体认。\n毕竟,人间有许多问题是没有解决方案的。一些人与人之间,或人与非人世界之间的关系,谁也不能下定论。这不光在科学与哲学的领域中是如此,因为关于自然与其定律,存在与演变,谁都还没有,也永远不可能达到最终的理解,就是在一些我们熟悉的日常事物,诸如男人与女人,父母与孩子,或上帝与人之间的关系,也都如此。这事你不能想太多,也想不好。伟大的经典就是在帮助你把这些问题想得更清楚一点,因为这些书的作者都是比一般人思想更深刻的人。\n2.书的金字塔 # 西方传统所写出的几百万册的书籍中,百分之九十九都对你的阅读技巧毫无帮助。这似乎是个令人困恼的事实,不过连这个百分比也似乎高估了。但是,想想有这么多数量的书籍,这样的估算还是没错。有许多书只能当作娱乐消遣或接收资讯用。娱乐的方式有很多种,有趣的资讯也不胜枚举,但是你别想从中学习到任何重要的东西。事实上,你根本用不着对这些书做分析阅读。扫描一下便够了。\n第二种类型的书籍是可以让你学习的书—学习如何阅读,如何生活。只有百分之一,千分之一,甚或万分之一的书籍合乎这样的标准。这些书是作者的精心杰作,所谈论的也是人类永远感兴趣,又有特殊洞察力的主题。这些书可能不会超过几千本,对读者的要求却很严苛,值得做一次分析阅读—一次。如果你的技巧很熟练了,好好地阅读过一次,你就能获得所有要获得的主要概念了。你把这本书读过一遍,便可以放回架上。你知道你用不着再读一遍,但你可能要常常翻阅,找出一些特定的重点,或是重新复习一下一些想法或片段。(你在这类书中的空白处所做的一些笔记,对你会特别有帮助。)\n你怎么知道不用再读那本书了呢?因为你在阅读时,你的心智反应已经与书中的经验合而为一了。这样的书会增长你的心智,增进你的理解力。就在你的心智成长,理解力增加之后,你了解到—这是多少有点神秘的经验—这本书对你以后的心智成长不会再有帮助了。你知道你已经掌握这本书的精髓了。你将精华完全吸收了。你很感激这本书对你的贡献,但你知道它能付出的仅止于此了。\n在几千本这样的书里,还有更少的一些书—很可能不到一百种—却是你读得再通,也不可能尽其究竟。你要如何分辨哪些书是属于这一类的呢?这又是有点神秘的事了,不过当你尽最大的努力用分析阅读读完一本书,把书放回架上的时候,你心中会有点疑惑,好像还有什么你没弄清楚的事。我们说“疑惑”,是因为在这个阶段可能仅只是这种状态。如果你确知你错过了什么,身为分析阅读者,就有义务立刻重新打开书来,厘清自己的问题是什么。事实上,你没法一下子指出问题在哪里,但你知道在哪里。你会发现自己忘不了这本书,一直想着这本书的内容,以及自己的反应。最后,你又重看一次。然后非常特殊的事就发生了。\n如果这本书是属于前面我们所说第二种类型的书,重读的时候,你会发现书中的内容好像比你记忆中的少了许多。当然,原因是在这个阶段中你的心智成长了许多。你的头脑充实了,理解力也增进了。书籍本身并没有改变,改变的是你自己。这样的重读,无疑是让人失望的。\n但是如果这本书是属于更高层次的书—只占浩瀚书海一小部分的书—你在重读时会发现这本书好像与你一起成长了。你会在其中看到新的事物—一套套全新的事物—那是你以前没看到的东西。你以前对这本书的理解并不是没有价值(假设你第一次就读得很仔细了),真理还是真理,只是过去是某一种面貌,现在却呈现出不同的面貌。\n一本书怎么会跟你一起成长呢?当然这是不可能的。一本书只要写完出版了,就不会改变了。只是你到这时才会开始明白,你最初阅读这本书的时候,这本书的层次就远超过你,现在你重读时仍然超过你,未来很可能也一直超过你。因为这是一本真正的好书—我们可说是伟大的书—所以可以适应不同层次的需要。你先前读过的时候感到心智上的成长,并不是虚假的。那本书的确提升了你。但是现在,就算你已经变得更有智慧也更有知识,这样的书还是能提升你,而且直到你生命的尽头。\n显然并没有很多书能为我们做到这一点。我们评估这样的书应该少于一百本。但对任何一个特定的读者来说,数目还会更少。人类除了心智力量的不同之外,还有许多其他的不同。他们的品味不同,同一件事对这个人的意义就大过对另一个人。你对牛顿可能就从没有对莎士比亚的那种感觉,这或许是因为你能把牛顿的书读得很好,所以用不着再读一遍,或许是因为数学系统的世界从来就不是你能亲近的领域。如果你喜欢数学—像达尔文就是个例子—牛顿跟其他少数的几本书对你来说就是伟大的作品,而不是莎士比亚。\n我们并不希望很权威地告诉你,哪些书对你来说是伟大的作品。不过在我们的第一个附录中,我们还是列了一些清单,因为根据我们的经验,这些书对许多读者来说都是很有价值的书。我们的重点是,你该自己去找出对你有特殊价值的书来。这样的书能教你很多关于阅读与生命的事情。这样的书你会想一读再读。这也是会帮助你不断成长的书。\n3.生命与心智的成长 # 有一种很古老的测验—上一个世纪很流行的测验—目的在于帮你找出对你最有意义的书目。测验是这样进行的:如果你被警告将在一个无人荒岛度过余生,或至少很长的一段时间,而假设你有时间作一些准备,可以带一些实际有用的物品到岛上,还能带十本书去,你会选哪十本?\n试着列这样一份书单是很有指导性的,这倒不只是因为可以帮助你发现自己最想一读再读的书是哪些。事实上,和另外一件事比起来,这一点很可能是微不足道的。那件事就是:当你想像自己被隔绝在一个没有娱乐、没有资讯、没有可以理解的一般事物的世界时,比较起来你是否会对自己了解得更多一点?记住,岛上没有电视也没有收音机,更没有图书馆,只有你跟十本书。\n你开始想的时候,会觉得这样想像的情况有点稀奇古怪,不太真实。当真如此吗?我们不这么认为。在某种程度上,我们都跟被放逐到荒岛上的人没什么两样。我们面对的都是同样的挑战—如何找出内在的资源,过更美好的人类生活的挑战。\n人类的心智有很奇怪的一点,主要是这一点划分了我们心智与身体的截然不同。我们的身体是有限制的,心智却没有限制。其中一个迹象是,在力量与技巧上,身体不能无限制地成长。人们到了30岁左右,身体状况就达到了巅峰,随着时间的变化,身体的状况只有越来越恶化,而我们的头脑却能无限地成长与发展下去。我们的心智不会因为到了某个年纪死就停止成长,只有当大脑失去活力,僵化了,才会失去了增加技巧与理解力的力量。\n这是人类最明显的特质,也是万物之灵与其他动物最主要不同之处。其他的动物似乎发展到某个层次之后,便不再有心智上的发展。但是人类独有的特质,却也潜藏着巨大的危险。心智就跟肌肉一样,如果不常运用就会萎缩。心智的萎缩就是在惩罚我们不经常动脑。这是个可怕的惩罚,因为证据显示,心智萎缩也可能要人的命。除此之外,似乎也没法说明为什么许多工作忙碌的人一旦退休之后就会立刻死亡。他们活着是因为工作对他们的心智上有所要求,那是一种人为的支撑力量,也就是外界的力量。一旦外界要求的力量消失之后,他们又没有内在的心智活动,他们便停止了思考,死亡也跟着来了。\n电视、收音机及其他天天围绕在我们身边的娱乐或资讯,也都是些人为的支撑物。它们会让我们觉得自己在动脑,因为我们要对外界的刺激作出反应。但是这些外界刺激我们的力量毕竟是有限的。像药品一样,一旦习惯了之后,需要的量就会越来越大。到最后,这些力量就只剩下一点点,甚或毫无作用了。这时,如果我们没有内在的生命力量,我们的智力、品德与心灵就会停止成长。当我们停止成长时,也就迈向了死亡。\n好的阅读,也就是主动的阅读,不只是对阅读本身有用,也不只是对我们的工作或事业有帮助,更能帮助我们的心智保持活力与成长。\n"},{"id":134,"href":"/zh/docs/culture/%E5%A6%82%E4%BD%95%E9%98%85%E8%AF%BB%E4%B8%80%E6%9C%AC%E4%B9%A6/%E7%AC%AC%E4%B8%89%E7%AF%87_%E9%98%85%E8%AF%BB%E4%B8%8D%E5%90%8C%E8%AF%BB%E7%89%A9%E7%9A%84%E6%96%B9%E6%B3%95/","title":"第三篇 阅读不同读物的方法","section":"如何阅读一本书","content":"第三篇 阅读不同读物的方法\n第十三章 如何阅读实用型的书 # 在任何艺术或实务的领域中,有些规则太通用这一点是很令人扫兴的。越通用的规则就越少,这算是一个好处。而越通用的规则,也越容易理解—容易学会与使用这些规则。但是,说实在的,当你置身错综复杂的实际情况,想要援用一些规则的时候,你也会发现越通用的规则离题越远。\n我们前面谈过分析阅读的规则,一般来说是适用于论说性的作品—也就是说任何一种传达知识的书。但是你不能只用一般通用的方法来读任何一本书。你可能读这本书那本书,或是任何一种特殊主题的书,可能是历史、数学、政治论文或科学研究,或是哲学及神学理论,因此,在运用以下这些规则时,你一定要有弹性,并能随时调整。幸运的是,当你开始运用这些规则时,你会慢慢感觉到这些规则是如何在不同的读物上发挥作用。\n要特别提醒的是,在第十一章结尾时所说明的十五个阅读规则并不适用于阅读小说或诗集。一本虚构作品的纲要架构,与论说性的作品是完全不同的。小说、戏剧与诗并不是照着共识、主旨、论述来发展的。换句话说,这些作品的基本内容没有逻辑可言,要评论这些作品也要有不同的前提才行。然而,如果你认为阅读富有想像力的作品毫无规则可言,那也是错的。事实上,下一章我们会讨论到阅读那种作品的另一套应用规则。那些规则一方面本身就很有效,另一方面如果能检验这些规则和阅读论说性作品规则的不同之处,还可以帮助你对阅读论说性作品的规则多一层认识。\n你用不着担心又要学一整套十五个或更多的阅读小说与诗的规则。你会很容易了解到这两种规则之间的关联性。其中也包括了我们一再强调的事实,你在阅读时一定要能提出问题来,尤其是四个最特殊的问题,不论在阅读什么样的书时都要能提出来。这四个问题与任何一本书都有关,不论是虚构或非虚构,不论是诗、历史、科学或哲学。我们已经知道阅读论说性作品的规则如何互相连贯,又是如何从这四个问题中发展出来的。同样的,阅读富有想像力作品的规则也是来自这四个问题,只不过这两类作品的题材不同,会造成规则上的部分差异。\n因此,在这一篇里,比起阅读的规则,我们会谈更多有关这几个问题的话题。我们会偶尔提一个新规则,也会重新调整某一个旧的规则。不过大多数时候,既然我们谈的是阅读不同读物的方法,我们会强调基本要问的不同问题,以及会获得什么样的不同的回答。\n在论说性作品的部分,我们谈过基本上要区分出实用性与理论性,两种作品—前者是有关行动的问题,后者只和要传递的知识有关。我们也说过,理论性的作品可以进一步划分为历史、科学(与数学)、哲学。实用性作品则没有任何界限,因此我们要进一步分析这类书的特质,并提供一些阅读时的建议指南与方法。\n1.两种实用性的书 # 关于实用性的书有一件事要牢记在心:任何实用性的书都不能解决该书所关心的实际问题。一本理论性的作品可以解决自己提出的问题。但是实际的问题却只能靠行动来解决。当你的实际问题是如何赚钱谋生时,一本教你如何交朋友或影响别人的书,虽然可能建议你很多事,但却不能替你解决问题。没有任何捷径能解决这个问题,只能靠你自己去赚钱谋生才能解决。\n以本书为例。这是一本实用的书,如果你对这本书的实用性(当然也可能只是理论性)感兴趣,那你就是想要解决学习阅读的问题。但除非你真的学到了,你不可能认为那些问题都解决,消失不见了。本书没法为你解决那些问题,只能帮助你而已。你必须自己进行有活力的阅读过程,不只是读这本书,还要读很多其他的书。这也是为什么老话说:只有行动能解决问题。行动只能在现世发生,而不是在书本中发生。\n每个行动发生时都有特殊情况,都发生在不同的时间、地点与特殊环境中。你没法照一般的反应来行动。要立即采取行动的特殊判断力,更是极为特别。这可以用文字来表达,却几乎没见过。你很难在书中找到这样的说明,因为实用书的作者不能亲身体验读者在面临的特殊状况时,必须采取的行动。他可能试着想要帮忙,但他不能提供现场的实际建议。只有另一个置身一模一样情况的人,才能帮得上忙。\n然而,实用性的书多少还是可以提供一些可以应用在同类型特殊状况中的通用规则。任何人想要使用这样的书,一定要把这些规则运用在特殊的状况下,因此一定要练习特殊的判断力才行。换句话说,读者一定要能加上一点自己的想法,才能运用在实际的状况中。他要能更了解实际状况,更有判断力,知道如何将规则应用在这样的状况中。\n任何书里包含了规则—原理、准则或任何一种一般的指导—你都要认定是一本实用性的书。但是一本实用性的书所包含的不只是规则而已。它可能会说明规则底下的原理,使之浅显易懂.譬如在这本与阅读有关的特殊主题的书中,我们不断地简要阐释文法、修辞与逻辑原理,来解说阅读规则。规则底下的原理通常都很科学,换言之,属于理论性的知识。规则与原理的结合,就是事物的理论。因此,我们谈造桥的理论,也谈打桥牌的理论。我们的意思是,理论性的原则会归纳出出色的行事规则。\n实用性的书因此可分为两种类型。其中一种,就像本书一样,或是烹饪书、驾驶指南,基本上都是在说明规则的。无论其中谈论到什么问题,都是为了说明规则而来的。这类书很少有伟大的作品。另一类的 实用书主要是在阐述形成规则的原理。许多伟大的经济、政治、道德巨著就属于这一类。\n这样的区分并不是绝对的。在一本书中,同时可以找到原理与规 则。重点在特别强调其中哪一项。要将这两种类型区分出来并不困 难。不管是在什么领域中,谈规则的书都可以立刻认出来是实用性的。 一本谈实用原理的书,乍看之下会以为是理论性的书。从某个程度来说,的确没错。它所讨论的是一种特殊状况中的理论。无论如何,你还是看得出来它是实用性的书。它要处理的那些问题的本质会露底。这样的书所谈的总是人类行为领域中,怎样可能做得更好或更糟。\n在阅读一本以规则为主的书时,要找寻的主旨当然是那些规则。 阐述这些规则通常是用命令句,而不是叙述句。那是一种命令。譬如说:“及时一针,胜过事后九针。”这样的规则也可以改为叙述式的说法:“如果你及时补上一针,就省下后来要补的九针。”两个句子都是在提示 争取时间的价值,命令式的语句比较强烈,但却不见得就比较容易记住。\n无论是叙述句或命令句,你总是能认出一个规则来,因为它在建议你某件事是值得做的,而且一定会有收获。因此,要你与作者达成共识的那条命令式的阅读规则,也可以改成建议式的说法:“成功的阅读牵涉到读者与作者达成共识。”“成功”这两个字就说明了一切,意味着这种阅读是值得去做的一件事。\n这类实用书的论述都是在向你表示:它们所说的规则都是确切可 行的。作者可能会用原理来说明这些规则的可信度,或是告诉你一些实例,证明这些规则是可行的。看看这两种论述,诉诸原理的论述通常比较没有说服力,但却有一个好处。比起举实例的方法,诉诸原理的论述比较能将规则的理由说明得清楚一些。\n在另一种实用性书中,主要谈的是规则背后的原理。当然,其中的主旨与论述看起来就跟纯理论性的书一模一样。其中的主旨是在说明某件事的状态,而论述就是在强调真的是如此。\n但是阅读这样的一本书,与阅读纯理论的书还是有很大的不同。因为要解决的问题终究是实用的问题—行动的问题,人类在什么状态下可以做得更好或更糟的问题—所以当聪明的读者看到“实用原理”这样的书时,总是能读出言外之意。他可能会看出那些虽然没有明说,但却可以由原理衍生出来的规则。他还会更进一步,找出这些规则应该如何实际应用。\n除非这样阅读,否则一本实用的书便没有被实用地阅读。无法让一本实用的书被实用地阅读,就是失败的阅读。你其实并不了解这本书,当然也不可能正确地评论这本书了。如果在原理中能找到可以理解的规则,那么也就可以在由原理引导出来的规则或建议的行动中,找到实用原理的意义。\n这些是你要了解任何一种实用性书籍,或是在作某种批评时的最高原则。在纯理论性的书中,相同或反对的意见是与书中所谈的真理有关。但是现实的真理与理论的真理不同。行为规则要谈得上是真理,有两种情况:一是真的有效;二是这样做能带引你到正确的结果,达到你的期望。\n假设作者认为你应该寻求的正确结果,你并不以为然,那么就算他的建议听起来很完整,由于那个目标的缘故,你可能还是不会同意他的观点。你会因此而判断他的书到底实不实用。如果你不认同仔细、头脑清楚地阅读是件值得做的事情,那么纵使本书的规则真的有效,这本书对你来说还是没什么实用性。\n注意这段话的意义。在评断一本理论性的书时,读者必须观察他自己与作者之间的原理与假设的一致性或差异性。在评断一本实用性的书时,所有的事都与结果及目标有关。如果你不能分享马克思对经济价值的狂热,他的经济教条与改革措施对你来说就有点虚假或无关197痛痒。譬如你可能和埃德蒙·柏克(Edmund Burke)一样,认为维持现状就是最好的策略,而且在全面考量过后,你相信还有比改变资本不平等更重要的事。你的判断主要是与结果达成共识,而非方法。就算 方法非常真实有用,如果所达到的目的是我们不关心或不期望的结果, 我们也不会有半点兴趣的。\n2.说服的角色 # 以上的简单讨论,可以给你一些线索。当你在阅读任何一种实用书时,一定要问你自己两个主要的问题。第一:作者的目的是什么?第二:他建议用什么方法达到这个目的?以原理为主的书要比以规则 为主的书还要难回答这两个问题。在这些书中,目的与方法可能都不很明显。但如果你想要了解与评论实用性的书,就必须回答这两个问题。\n还要提醒你的是,前面我们讨论过的实用作品的写作问题。每一本实用的书中都混杂着雄辩或宣传。我们还没读过一本政治哲学的书—无论是多理论性的,无论谈的是多么“深奥”的原理—是不是想说服读者有关“最好的政府形态”的道理。相同的,道德理论的书也想 要说服读者有关“美好生活”的道理,同时建议一些达到目标的方法。我们也一直试着要说服你照某种特定的方式来阅读一本书,以达到你可能想要追求的理解力。\n你可以知道为什么实用书的作者多少都是个雄辩家或宣传家。因为你对他作品最终的评断是来自你是否接受他的结论.与他提议的方法。这完全要看作者能不能将你引导到他的结论上。要这么做,他讨论的方法必须要能打动你的心智。他可能必须激起你的情绪反应,左右你的意志。\n这并没有错,也没有恶意。这正是实用书的特性,一个人必须要被 说服,以采取特定的思想与行动。实际的思考与行动除了需要理智以 外,情感也是重要的因素。没有人可以没有受到感动,却认真采取实际 评论或行动的。如果可以的话,这个世界可能会比较美好,但一定是个不同的世界。一本实用书的作者认知不到这一点,就不算成功。一位读者如果认知不到这一点,就像买了一堆货物,却不知道自己买了些什么。\n不想被宣传所困惑,就得了解宣传的内容是什么。难以察觉的隐藏式雄辩是最狡猾的。那会直接打动你的心,而不经过你的头脑,就像是从背后吓你一跳,把你吓得魂不附体一样。这样的宣传手法就像是你吞了一颗药,自己却完全不知道。宣传的影响力是很神秘的,事后你并不知道自己为什么会那样感觉与思考。\n一个人如果真正读懂了一本实用的书,他知道这本书的基本共识、主旨、论述是什么,就能觉察出作者的雄辩。他会觉察到某一段话是“情绪用字”。他知道自己是被说服的对象,他有办法处理这些诉求的重点。他对推销有抵抗力,但并不是百分之百的需要。对推销有抵抗力是好的,能帮你避免在匆忙又欠考虑的情况下买东西。但是,一个读者如果完全不接受所有内容的诉求,那就不必阅读实用性的书了。\n另外还有一个重点。因为实用问题的特性,也因为所有实用作品中都混杂了雄辩,作者的“性格”在实用书中就比理论书中还要来得重要。你在读一本数学用书时,用不着知道作者是谁。他的理论不是好就是坏,这跟他的人格怎样一点关系也没有。但是为了要了解与评断一本道德的论述、政治论文或经济论著,你就要了解一点作者的人格、生活与时代背景。譬如在读亚里士多德的《政治学》之前,就非常需要知道希腊的社会背景是奴隶制的。同样的,在读《君主论》之前,就要知道马基雅维里当时意大利的政治情况,与他跟美第奇家族的关系。因此,在读霍布斯的《利维坦》一书时,就要了解他生活在英国的内战时期,社会中充满暴力与混乱,使整个时代都沉浸在悲哀的病态之中。\n3.赞同实用书之后 # 我们确定你已经看出来了,你在读一本书时要提出的四个问题,到了读实用性的书时有了一点变化。我们就来说明一下这些变化。\n第一个问题:这本书是在谈些什么?并没有改变多少。因为一本实用的书是论说性的,仍然有必要回答这个问题,并作出这本书的大纲架构。\n然而,虽然读任何书都得想办法找出一个作者的问题是什么(规则四涵盖这一点),不过在读实用性的书时,格外是一个决定性的关键。我们说过,你一定要了解作者的目的是什么。换句话说,你一定要知道他想解决的问题是什么。你一定要知道他想要做些什么—因为,在实用性的书中,知道他要做的是什么,就等于是知道他想要你做的是什么。这当然是非常重要的事了。\n第二个问题的变化也不大。为了要能回答关于这本书的意义或内容,你仍然要能够找出作者的共识、主旨与论述。但是,这虽然是第二阶段最后的阅读工作(规则八),现在却显得更重要了。你还记得规则八要你说出哪些是作者已经解决的问题,哪些是还没有解决的问题。在阅读实用性的书籍时,这个规则就有变化了。你要发现并了解作者所建议的、达到他目标的方法。换句话说,在阅读实用性书时,如果规则四调整为:“找出作者想要你做什么。”规则八就该调整为:“了解他要你这么做的目的。”\n第三个问题:内容真实吗?比前两个改变得更多了。在理论性作品中,当你根据自己的知识来比较作者对事物的描绘与说明时,这个问题的答案便出来了。如果这本书所描述的大致与你个人的体验相似时,你就必须承认那是真实的,或至少部分是真实的。实用性的书,虽然也会与真实作比较,但最主要的却是你能不能接受作者的宗旨—他最终的目标,加上他建议的达成目标的方法—这要看你认为追求的是什么,以及什么才是最好的追求方法而定。\n第四个问题:这本书与我何干?可说全部改变了。如果在阅读一本理论性的书之后,你对那个主题的观点多少有点变化了,你对一般事物的看法也就会多少有些调整。(如果你并不觉得需要调整,可能你并没有从那本书中学到什么。)但是这样的调整并不是惊天动地的改变,毕竟,这些调整并不一定需要你探取行动。\n赞同一本实用性的书,却确实需要你采取行动。如果你被作者说服了,他所提议的结论是有价值的,甚至进一步相信他的方法真的能达到目的,那就很难拒绝作者对你的要求了。你会照着作者希望你做的方式来行动。\n当然,我们知道这种情形并不一定会发生。但我们希望你了解的是,如果你不这样做的话,到底代表什么意思。那就表示虽然这个读者表面上同意了作者的结论,也接受了他提出来的方法,但是实际上并没有同意,也没有接受。如果他真的都同意也接受了,他没有理由不采取行动。\n我们用一个例子来说明一下。如果读完本书的第二部分,你(1)同意分析阅读是值得做的。(2)接受这些阅读规则,当作是达到目标的基本要件,你会像我们现在所说的一样,开始照着阅读起来。如果你没有这么做,可能并不是你偷懒或太累了,而是你并不真的同意(1)或(2)。\n在这个论述中有一个明显的例外。譬如你读了一篇文章,是关于如何做巧克力慕斯的。你喜欢巧克力慕斯,也赞同这个作者的结论是对的。你也接受了这个作者所建议的达到目标的方法—他的食谱。但你是男性读者,从不进厨房,也没做过慕斯。在这样的情况中,我们的观点是否就不成立了?\n并不尽然。这正好显示出我们应该要提到的,区分各种类型实用书的重要性。某些作者提出的结论是很通用或一般性的—可供所有的人类使用—另外一些作者的结论却只有少数人能运用。如果结论是通用的—譬如像本书,所谈的是使所有人都能阅读得更好,而不是只有少数人—那么我们所讨论的便适用于每位读者。如果结论是经过筛选的,只适用于某个阶层的人,那么读者便要决定他是否属于那个阶层了。如果他属于那个阶层,这些内容就适合他应用,他多少也有义务照作者的建议采取行动。如果他不属于这个阶层,他可能就没有这样的义务。\n我们说“可能没有这样的义务”,是因为很可能这位读者只是被自己愚弄了,或误解了他自己的动机,而认为自己并不属于那个结论所牵涉的阶层。以巧克力慕斯的例子来说,他不采取行动,可能是表示:虽然慕斯是很可口的东西,但是别人—或许是他妻子—应该做给他吃。在许多例子中,我们承认这个结论是可取的,方法也是可行的,但我们却懒得去做。让别人去做,我们会说,这就算是交待了。\n当然,这个问题主要不是阅读的,而是心理的问题。心理问题会影响我们阅读实用性的作品,因此我们在这里有所讨论。\n第十四章 如何阅读想像文学 # 到目前为止,本书已经讨论的只是大部分人阅读的一半而已。不过,这恐怕也是广义的估算。或许一般人真正花时间阅读的只是报纸与杂志,以及与个人工作有关的读物。就以书籍来说,我们读的小说也多于非小说。而在非小说的领域中,像报章杂志,与当代重大新闻有关的议题最受欢迎。\n我们在前面所设定的规则并不是在欺骗你。在讨论细节之前,我们说明过,我们必须将范围限制在严肃的非小说类中。如果同时解说想像文学与论说性作品,会造成困扰。但是现在我们不能再忽略这一类型的作品了。\n在开始之前,我们要先谈一个有点奇怪的矛盾说法。阅读想像文学的问题比阅读论说性作品的问题更为困难。然而,比起阅读科学、哲学、政治、经济与历史,一般人却似乎更广泛地拥有阅读文学的技巧。为什么会出现这种情况呢?\n当然,也许很多人只是欺骗自己有阅读小说的能力。从我们的教学经验中,当我们问到一个人为什么喜欢小说时,他总是表现出瞳目结舌的样子。很明显,他们乐在其中,但是他们说不出来乐在哪里,或是哪一部分的内容让他们觉得愉悦。这可能说明了,人们可能是好的小说读者,却不是好的评论者。我们怀疑这只是部分的真相。评论式的阅读依赖一个人对一本书的全盘了解。这些说不出他们喜欢小说的理由的人,可能只是阅读了表象,而没有深入内里。无论如何,这个矛盾的概念还不只于此。想像文学的主要目的是娱乐,而非教育。以娱乐为主的读物比教育为主的读物容易讨好,但要知道为什么能讨好则比较困难。要分析美丽,比美丽本身困难多了。\n要将这个重点说清楚,需要对美学作更进一步的分析。我们没法在这里这么做。但是,我们能给你一些如何阅读想像文学的建议。一开始,我们会从否定的说法谈起,而不建立一些规则。其次,我们要用类推的方法,简短地将阅读非小说的规则转化为阅读小说的规则。最后,在下一章,我们会谈到阅读特殊形态的想像文学时所发生的问题,像是小说、戏剧与抒情诗。\n1.读想像文学的“不要” # 为了要用否定的形态来作说明,一开始就有必要掌握论说性作品与文学作品的差异。这些区别会解释为什么我们阅读小说不能像阅读哲学作品一样,或是像证明数学理论那样阅读诗。\n最明显的差别,前面已经提过,与两种文体的目标有关。论说性作品要传达的是知识—在读者经验中曾经有过或没有过的知识。想像文学是在阐述一个经验本身—那是读者只能借着阅读才能拥有或分享的经验—如果成功了,就带给读者一种享受。因为企图不同,这两种不同的作品对心智便有不同的诉求。\n我们都是经由感官与想像来体验事情。我们都是运用判断与推论,也就是理智,才能理解事情。这并不是说我们在思考时用不上想像力,或我们的感官经验完全独立于理性的洞察与反应之外。关键在强调哪一方面的问题而已。小说主要是运用想像力。这也是为什么称之为想像文学的原因,这与理性的科学或哲学相反。\n有关想像文学的事实,带引出我们要建议的否定的指令:不要抗拒想像文学带给你的影响力。\n我们讨论过很多主动的阅读方法。这适用于任何一本书。但在论说性作品与想像文学中,适用的方法却不大相同。阅读论说性作品,读者应该像个捕食的小鸟,经常保持警觉,随时准备伸出利爪。在阅读诗与小说时,相同的活动却有不同的表现方法。如果容许的话,我们可以说那是有点被动的活动,或者,更恰当的说法应该是,那是带着活力的热情。在阅读一个故事时,我们一定要用那样的方式来表现,让故事在我们身上活动。我们要让故事贯穿我们,做任何它想要做的事。我们一定得打开心灵,接纳它。\n我们应该感激论说性的作品—哲学、科学、数学—这些学科塑造出我们活着的真实世界。但我们也不能活在一个完全是这些东西的世界里,偶尔我们也要摆脱一下这些东西。我们并不是说想像文学永远或基本上是逃避现实的。如果从一般的观点来看,逃避的概念是很可鄙的。但事实上就算我们真的要逃避现实,应该也是逃避到一个更深沉、或更伟大的真实里。这是我们内在的真实世界,我们独特的世界观。发现这个真相让我们快乐。这个经验会深深满足我们平时未曾接触的部分自我。总之,阅读一部伟大的文学作品的规则应该以达成某种深沉的经验为目标。这些规则应该尽可能去除我们体验这种深刻感受的阻碍。\n论说性作品与想像文学的基本不同,又造成另一个差异。因为目标完全不同,这两种作品的写法必然不同。想像文学会尽量使用文字潜藏的多重字义,好让这些字特有的多元性增加文章的丰富性与渲染力。作者会用隐喻的方式让整本书整合起来,就像注重逻辑的作者会用文字将单一的意义说明清楚一样。但丁的《神曲》使用的是一般的诗与小说,但每个人阅读起来却各有不同的体会。论说性作品的逻辑目标则是完全清晰,毫无言外之意的解说。在字里行间不能有其他的含意。任何相关与可以陈述的事都得尽可能说个一清二楚才行。相反地,想像文学却要依赖文字中的言外之意。多重含意的隐喻在字里行间所传达的讯息,有时比文字本身还要丰富。整首诗或故事所说的东西,不是语言或文字所能描述的。\n从这个事实,我们得到另一个否定的指令:在想像文学中,不要去找共识、主旨或论述。那是逻辑的,不是诗的,二者完全不同。诗人马克·范多伦(Mark Van Doren)曾经说:“在诗与戏剧中,叙述是让人更模糊的一种媒介。”譬如,你根本就无法在一首抒情诗的任何文句中找到任何他想要“说明”的东西。然而整首诗来看,所有字里行间的关联与彼此的互动,却又陈述了某种完全超越主旨的东西。(然而,想像文学包含的要素也类似共识、主旨、论述,我们待会再讨论。)\n当然,我们可以从想像文学中学习,从诗、故事,特别是戏剧中学习—但是与我们从哲学或科学的书中学习的方法不同。我们都懂得从经验中学习—我们每天生活中的经验。所以,我们也可以从小说在我们想像中所创造出来的经验中学习。在这样的状况下,诗与故事能带给我们愉悦,同时也能教育我们。但这与科学及哲学教导我们的方式不同。论说性的作品不会提供我们新奇的经验。他们所指导的经验是我们已经有的或可以获得的。这也是为什么说论说性作品是教导我们基本的原理,而想像文学则藉由创造我们可以从中学习的经验,教导我们衍生的意义。为了从这样的书中学习,我们要从自己的经验中思考。为了从哲学与科学的书中学习,我们首先必须了解他们的思想。\n最后一个否定的指令:不要用适用于传递知识的,与真理一致的标准来批评小说。对一个好故事来说,所谓“真理”就是一种写实,一种内在可能性,或与真实的神似。那一定要像个故事,但用不着像在做研究或实验一样来形容生活的事实或社会的真相。许多世纪前,亚里士多德强调:“诗与政治对正确的标准是不一致的。”或是说,与物理学或心理学也是不一致的。如果是解剖学、地理或历史作品,被当作是专门的论述,却出现技术上的错误,那就应该被批评。但将事实写错却不会影响到一本小说,只要它能自圆其说,将整体表现得活灵活现便行了。我们阅读历史时,希望多少能看到事实。如果没有看到史实,我们有权利抱怨。我们阅读小说时,我们想要的是一个故事,这个故事只要确实可能在小说家笔下所创造,再经过我们内心重新创造的世界中发生,就够了。\n我们读了一本哲学的书,也了解了之后,我们会做什么呢?我们会考验这本书,与大家共通的经验作对照—这是它的灵感起源,这也是它惟一存在的理由。我们会说:这是真的吗?我们也有这样的感觉吗?我们是不是总是这样想,却从来没有意识到?以前或许很模糊的事,现在是不是却很明显了?作者的理论或说明虽然可能很复杂,是不是却比我们过去对这个观念的混淆来得清楚,也简单多了?\n如果我们能很肯定地回答上述问题,我们与作者之间的沟通便算是建立起来了。当我们了解,也不反对作者的观点时,我们一定要说:“这确实是我们共通的观念。我们测验过你的理论,发现是正确的。”\n但是诗不一样。我们无法依据自己的经验来评断《奥赛罗》(Othello),除非我们也是摩尔人,也和被怀疑不贞的威尼斯淑女结婚。而就算如此,也不是每一个摩尔人都是奥赛罗,每一个威尼斯淑女都是苔丝德蒙娜。而大部分这样的夫妻婚姻都可能很幸福,不会碰到阴险的伊亚格。事实上,这么不幸的人,万中不见一。奥赛罗与这出戏一样,都是独一无二的。\n2.阅读想像文学的一般规则 # 为了让上面所谈的“不要”的指令更有帮助,一定还需要一些建设性的建议。这些建议可以由阅读论说性作品的规则中衍生出来。\n前面我们谈过阅读论说性作品的三组规则,第一组是找出作品的整体及部分结构,第二组是定义与诠释书中的共识、主旨与论述。第三组是评论作者的学说,以赞同或反对的意见完成我们对他的作品的理解。我们称这三组规则为架构性、诠释性与评论性的。同样,在阅读诗、小说与戏剧时,我们也可以发现类似的规则。\n首先,我们可以将架构性的规则—拟大纲的规则—改变为适合阅读小说的规则:\n(1)你必须将想像文学作品分类。抒情诗在叙述故事时,基本上是以表达个人情绪的经验为主。小说与戏剧的情节比较复杂,牵涉到许多角色,彼此产生互动与反应,以及在过程中情感的变化。此外,每个人都知道戏剧与小说不同,因为戏剧是以行动与说话来叙述剧情的。(在后面我们会谈到一些有趣的例外。)剧作家不需要自己现身说法,小说家却经常这么做。所有这些写作上的差异,带给读者不同的感受。因此,你应该能一眼看出你在读的是哪一种作品。\n(2)你要能抓住整本书的大意。你能不能掌握这一点,要看你能不能用一两句话来说明整本书的大意。对论说性的作品来说,重点在作者想要解决的主要问题上。因此,这类书的大意可以用解决间题的方程式,或对问题的回答来作说明。小说的整体大意也与作者面对的问题有关,而我们知道这个问题就是想要传达一个具体的经验,所以一篇故事的大意总是在情节之中。除非你能简要地说明剧情—不是主旨或论述—否则你还是没有抓住重点。在情节中就有大意。\n要注意到,我们所说的整体情节与小说中所要使用的独特语言之间毫无冲突之处。就是一首抒情诗也有我们这里所谓的“情节”。然而,不论是抒情诗、小说,还是戏剧的“情节”,指的都只是其中的架构或场景,而不是读者透过作品在心中重新创造的具体经验。情节代表的是整本作品的大意,而整本作品才是经验本身。这就像对论说性作品作一个逻辑上的总结,就代表了对书中的论述作个总结。\n(3)你不仅要能将整本书简化为大意,还要能发现整本书各个部分是如何架构起来的。在论说性作品中,部分的架构是与整体架构有关的,部分问题的解决对整体问题的解决是有帮助的。在小说中,这些部分就是不同的阶段,作者借此发展出情节来—角色与事件的细节。在安排各个部分的架构上,这两种类型的书各有巧妙。在科学或哲学的作品中,各个部分必须有条理,符合逻辑。在故事中,这些部分必须要在适当的时机与规划中出现,也就是从开头、中间到结尾的一个过程。要了解一个故事的架构,你一定要知道故事是从哪里开始的—当然,不一定是从第一页开始的—中间经过些什么事,最后的结局是什么。你要知道带来高潮的各种不同的关键是什么,高潮是在哪里、又如何发生的,在这之后的影响又是什么?(我们说“在这之后的影响”并不是说故事结束之后的事,没有人能知道那些事。我们的意思是在故事中的高潮发生之后,带来什么样的后果。)\n随着我们刚刚所提的重点,出现了一个重要的结果。在论说性作品中,各个部分都可以独立解读,而小说却不同。欧几里得将他的《几何原理》分成三十个部分发表,或照他所说的分成三十册发表,其中每一部分都可以单独阅读。这是论说性作品中组织得最完整的一个例子。其中的每个部分或章节,分开来看或合起来看都有意义。但是一本小说中的一章,剧本中的一幕,或是一句诗从整体中抽出来之后,通常就变得毫无意义了。\n其次,阅读小说时候的诠释规则是什么?我们在前面谈过,诗与逻辑作品所使用的语言是不同的,因此在找出共识、主旨与论述时,所使用的规则也要有点变化。我们知道我们不该这么做的,不过我们非得找出类似的规则才行。\n(1)小说的要素是插曲、事件、角色与他们的思想、言语、感觉及行动。这些都是作者所创造出来的世界中的要素。作者操纵着这些要素的变化来说故事。这些要素就是逻辑作品中的共识。就像你要跟逻辑作品的作者达成共识一样,你也要能熟知每个事件与人物的细节。如果你对角色并不熟悉,也无法对事件感同身受,你就是还没有掌握到故事的精髓。\n(2)共识与主旨有关。小说的要素与整个表现的场景或背景有关。一个富有想像力的作者创造出一个世界来,他的角色在其中“生活,行动,有自己的天地。”因此,阅读小说时类似指导你找出作者主旨的规则,可以说明如下:在这个想像的世界中宾至如归。知道一切事件的进行,就像你亲临现场,身历其境。变成其中的一个成员,愿意与其中的角色做朋友,运用同情心与洞察力参与事件的发生,就像你会为朋友的遭遇所做的事一样。如果你能这么做,小说中的要素便不会再像一个棋盘上机械式移动的孤单棋子,你会找出其间的关联性,赋予他们真正存活的活力。\n\u0026lt;3)如果说论说性作品中有任何活动,那就是论述的发展。由证据与理由到结构的一个逻辑性的演变。在阅读这样的一本书时,必须追踪论述的发展。先找出共识与主旨之后,然后分析其推论。而在诠释小说的阅读中,也有类似的最后一个规则。你对角色都熟悉了,你加人了这个想像的世界,与他们生活在一起,同意这个社会的法律,呼吸同样的空气,品味同样的食物,在同样的高速公路上旅行。现在,你一定要跟随他们完成这场探险。这些场景或背景,社会的组合,是小说中各个要素之间静态的联系(如同主旨一样)。而情节的披露(如同论述或推论)是动态的联系。亚里士多德说情节是一个故事的灵魂。要把一个故事读好,你就要能把手指放在作者的脉搏上,感觉到每一次的心跳。\n结束讨论小说的类似阅读规则之前,我们要提醒你,不要太仔细检验这些类似的规则。这些类似的规则就像是一个隐喻或象征,如果压迫得太用力,可能就会崩溃了。我们所建议的三个列出大纲的步骤,可以让你逐步了解作者如何在想像的世界中完成一个作品。这不但不会破坏你阅读小说或戏剧的乐趣,还能加强你的乐趣,让你对自己喜乐的来源有更多的了解。你不但知道自己喜欢什么,还知道为什么会喜欢。\n另一个提醒:前面所说的规则主要适用于小说与戏剧。引申到有故事叙述的抒情诗,也同样适用。没有故事叙述的抒情诗,仍然可以适用这个规则,只是没那么贴切。一首抒情诗是在呈现一个具体的经验,就像一个长篇故事一样,想要在读者心中重新塑造这种经验。就算最短的诗里也有开始,过程与结束。就像任何经验都有时间顺序一样,无论多么短暂飘渺的经验都是如此。在短短的抒情诗中,虽然角色可能非常少,但至少永远有一个角色—诗人本身。\n第三,也是最后一个,小说的阅读批评规则是什么?你可能记得我们在论说性作品中作的区隔,也就是根据一般原理所作的批评,与根据个人特殊观点所作的评论—特殊评论。根据一般原理的部分,只要作一点变化就行了。在论说性作品中,这个规则是:在你还不了解一本书之前,不要评论一本书—不要说你同意或反对这个论点。所以在这里,类似的规则是:在你衷心感激作者试着为你创造的经验之前,不要批评一本想像的作品。\n这里有一个重要的推论。一个好读者不会质疑作者所创造出来,然后在他自己心中又重新再创造一遍的世界。亨利·詹姆斯(HenryJames)在《小说的艺术》(The Art of Fiction)中曾说道:“我们要接纳作者的主题、想法与前提。我们所能批评的只是他所创造出来的结果。”这就是说,我们要感激作者将故事写出来。譬如故事发生在巴黎,就不该坚持说如果发生在明尼苏达州的明尼阿波里斯市会比较好。但是我们有权利批评他所写的巴黎人与巴黎这个城市。\n换句话说,对于小说,我们不该反对或赞成,而是喜欢或不喜欢。我们在批评论说性作品时,关心的是他们所陈述的事实。在批评唯美文学时,就像字义所形容的,我们主要关心的是它的美丽。这样的美丽,与我们深切体会之后的喜悦密切呼应。\n让我们在下面重述一下这些规则。在你说自己喜欢或不喜欢一本文学作品之前,首先你要能真正努力过并欣赏作者才行。所谓欣赏,指的是欣赏作者借着你的情绪与想像力,为你创造的一个世界。因此,如果你只是被动地阅读一本小说(事实上,我们强调过,要热情地阅读),是没法欣赏一本小说的。就像在阅读哲学作品时,被动的阅读也一样无法增进理解力的。要做到能够欣赏,能够理解,在阅读时一定要主动,要把我们前面说过的,所有分析阅读的规则全拿出来用才行。\n你完成这样的阅读阶段后,就可以作评论了。你的第一个评论自然是一种你的品味。但是除了说明喜欢或不喜欢之外,还要能说出为什么。当然,你所说的原因,可能真的是在批评书的本身,但乍听之下,却像是在批评你自己—你的偏好与偏见—而与书无关。因此,要完成批评这件事,你要客观地指出书中某些事件造成你的反感。你不只要能说明你自己为什么喜欢或不喜欢,还要能表达出这本书中哪些地方是好的,哪些是不好的,并说明理由才行。\n你越能明白指出诗或小说带给你喜悦的原因,你就越了解这本书的优点是什么。你会慢慢建立起批评的标准,你也会发现许多跟你有同样品味的人与你一起分享你的论点。你还可能会发现一件我们相信如此的事:懂得阅读方法的人,文学品味都很高。\n第十五章 阅读故事、戏剧与诗的一些建议 # 在前一章里,我们已经谈过阅读想像文学的一般规则,同样也适用于更广义的各种想像文学—小说、故事,无论是散文或诗的写法(包括史诗);戏剧,不论是悲剧、喜剧或不悲不喜;抒情诗,无论长短或复杂程度。\n这些一般规则运用在不同的想像文学作品时,就要作一些调整。在这一章里,我们会提供一些调整的建议。我们会特别谈到阅读故事、戏剧、抒情诗的规则,还会包括阅读史诗及伟大的希腊悲剧时,特殊问题的注意事项。\n在开始之前,必须再提一下前面已经提过的阅读一本书的四个问题。这四个问题是主动又有要求的读者一定会对一本书提出来的问题,在阅读想像文学作品时也要提出这些问题来。\n你还记得前三个问题是:第一,这整本书的内容是在谈些什么?第二,内容的细节是什么?是如何表现出来的?第三,这本书说的是真实的吗?全部真实或部分真实?前一章已经谈过这三个规则运用在想像文学中的方法了。要回答第一个问题,就是你能说出关于一个故事、戏剧或诗的情节大意,并要能广泛地包括故事或抒情诗中的动作与变化。要回答第二个问题,你就要能辨识剧中所有不同的角色,并用你自己的话重新叙述过发生在他们身上的关键事件。要回答第三个问题,就是你能合理地评断一本书的真实性。这像一个故事吗?这本书能满足你的心灵与理智吗?你欣赏这本书带来的美吗?不管是哪一种观点,你能说出理由吗?\n第四个问题是,这本书与我何关?在论说性作品中,要回答这个问题就是要采取一些行动。在这里,“行动”并不是说走出去做些什么。我们说过,在阅读实用性书时,读者同意作者的观点—也就是同意最后的结论—就有义务采取行动,并接受作者所提议的方法。如果论说性的作品是理论性的书时,所谓的行动就不是一种义务的行为,而是精神上的行动。如果你同意那样的书是真实的,不论全部或部分,你就一定要同意作者的结论。如果这个结论暗示你对事物的观点要作一些调整,那么你多少都要调整一下自己的看法。\n现在要认清楚的是,在想像文学作品中,第四个也是最后一个问题要作一些相当大的调整。从某方面来说,这个间题与阅读诗与故事毫无关系。严格说起来,在你读好了—也就是分析好了小说、戏剧或诗之后,是用不着采取什么行动的。在你采取类似的分析阅读,回答前面三个问题之后,你身为读者的责任就算尽到了。\n我们说“严格说起来”,是因为想像文学显然总是会带引读者去做各种各样的事。比起论说性作品,有时候一个故事更能带动一个观点—在政治、经济、道德上的观点。乔治·奥威尔(George Orwell)的,《动物农庄》(Animal Farm)与《一九八四》都强烈地攻击极权主义。赫胥黎(Aldous Huxley)的《美丽新世界》(Brave New World)则激烈地讽刺科技进步下的暴政。索尔仁尼琴(Alexander Solzhenitsyn)的《第一圈》(The First Circle)告诉我们许多琐碎、残酷又不人道的苏联官僚政治问题,那比上百种有关事实的研究报告还要惊人。那样的作品在人类历史上被查禁过许多次,原因当然很明显。怀特(E. B. White)曾经说过:“暴君并不怕唠叨的作家宣扬自由的思想—他害怕一个醉酒的诗人说了一个笑话,吸引了全民的注意力。”\n不过,阅读故事与小说的主要目的并不是要采取实际的行动。想像文学可以引导出行动,但却并非必要,因为它们属于纯艺术的领域。\n所谓“纯”艺术,并不是因为“精致”或“完美”,而是因为作品本身就是一个结束,不再与其他的影响有关。就如同爱默生所说的,美的本身就是存在的惟一理由。\n因此,要将最后一个问题应用在想像文学中,就要特别注意。如果你受到一本书的影响,而走出户外进行任何行动时,要问问你自己,那本书是否包含了激励你的宣言,让你产生行动力?诗人,正确来说,不是要来提出宣言的。不过许多故事与诗确实含有宣言主张,只是被深藏起来而已。注意到他们的想法,跟着作出反应并没有问题。但是要记得,你所留意的与反应出来的是另外一些东西,而不是故事或诗的本身。这是想像文学本身就拥有的自主权。要把这些文学作品读通,你惟一要做的事就是去感受与体验。\n1.如何阅读故事书 # 我们要给你阅读故事书的第一个建议是:快读,并且全心全意地读。理想上来说,一个故事应该一口气读完,但是对忙碌的人来说,要一口气读完长篇小说几乎是不可能的事。不过,要达到这个理想,最接近的方法就是将阅读一篇好故事的时间压缩到合理的长度。否则你可能会忘了其间发生的事情,也会漏掉一些完整的情节,最后不知道自己在读的是什么了。\n有些读者碰到自己真正喜欢的小说时,会想把阅读的时间拉长,好尽情地品味,浸淫在其中。在这样的情况中,他们可能并不想借着阅读小说,来满足他们对一些未知事件或角色的了解。在后面我们会再谈到这一点。\n我们的建议是要读得很快,而且全神投人。我们说过,最重要的是要让想像的作品在你身上发生作用。这也就是说,让角色进入你的心灵之中,相信其中发生的事件,就算有疑惑也不要怀疑。在你了解一个角色为什么要做这件事之前,不要心存疑虑。尽量试着活在他的世界里,而不是你的世界,这样他所做的事就很容易理解了。除非你真的尽力“活在”这样的虚构世界中,否则不要任意批评这个世界。\n下面的规则中,我们要让你自己回答第一个问题,那也是阅读每一本书时要提出的间题—这整本书在谈些什么?除非你能很快读完,否则你没法看到整个故事的大要。如果你不专心一致地读,你也会漏掉其中的细节。\n根据我们的观察,一个故事的词义,存在于角色与事件之中。你要对他们很熟悉,才能厘清彼此的关系。有一点要提醒的,以《战争与和平》为例,许多读者开始阅读这本小说巨著时,都会被一堆出场的人物所混淆了,尤其是那些名字听起来又陌生得不得了。他们很快便放弃了这本书,因为他们立刻认为自己永远不会搞清楚这些人彼此之间的关系了。对任何大部头的小说而言,都是如此—而如果小说真的很好,我们可希望它越厚越好。\n对懦弱的读者来说,这样的情况还不只发生在阅读上。当他们搬到一个新的城市或郊区,开始上新的学校或开始新的工作,甚至刚到达一个宴会里时,都会发生类似的情形。在这样的情境中,他们并不会放弃。他们知道过一阵子之后,个人就会融人整体中,朋友也会从那一批看不清长相的同事、同学与客人中脱颖而出。我们可能没办法记住一个宴会中所有人的姓名,但我们会记起一个跟我们聊了一小时的男人,或是我们约好下次要见面的一个女人,或是跟我们孩子同校的一个家长。在小说中也是同样的情况。我们不期望记住每一个名字,许多人不过是背景人物,好衬托出主角的行动而已。无论如何,当我们读完《战争与和平》或任何大部头的书时,我们就知道谁是重要的人物,我们也不会忘记。虽然托尔斯泰的作品是我们很多年前读的书,但是皮埃尔、安德鲁、娜塔莎、玛丽公主、尼可拉斯—这些名字会立刻回到我们的记忆中。\n不管发生了多少事件,我们也会很快就明白其中哪些才是重要的。一般来说,作者在这一点上都会帮上很多忙。他们并不希望读者错过主要的情节布局,所以他们从不同的角度来铺陈。但我们的重点是:就算一开始不太清楚,也不要焦虑。事实上,一开始本来就是不清楚的。故事就像我们的人生一样,在生命中,我们不可能期望了解每一件发生在我们身上的事,或把一生全都看清楚。但是,当我们回顾过去时,我们便了解为什么了。所以,读者在阅读小说时,全部看完之后再回顾一下,就会了解事件的关联与活动的前后顺序了。\n所有这些都回到同一个重点:你一定要读完一本小说之后,才能谈你是否把这个故事读通了。无论如何,矛盾的是,在小说的最后一页,故事就不再有生命了。我们的生活继续下去,故事却没有。走出书本之外,那些角色就没有了生命力。在阅读一本小说时,在第一页之前,到最后一页之后,你对那些角色会发生些什么事所产生的想像,跟下一个阅读的人没什么两样。事实上,这些想像都是毫无意义的。有些人写了《哈姆雷特》的前部曲,但是都很可笑。当《战争与和平》一书结束后,我们也不该问皮埃尔与娜塔莎的结局是什么?我们会满意莎士比亚或托尔斯泰的作品,部分原因是他们在一定的时间里讲完了故事,而我们的需求也不过如此。\n我们所阅读的大部分是故事书,各种各样的故事。不能读书的人,也可以听故事。我们甚至还会自己编故事。对人类而言,小说或虚构的故事似乎是不可或缺的。为什么?\n其中一个理由是:小说能满足我们潜意识或意识中许多的需要。如果只是触及意识的层面,像论说性作品一样,当然是很重要的。但小说一样也很重要,因为它触及潜意识的层面。\n简单来说—如果要深人讨论这个主题会很复杂—我们喜欢某种人,或讨厌某种人,但却并不很清楚为什么。如果是在小说中,某个人受到奖励或处罚,我们都会有强烈的反应。我们会甚至因而对这本书有艺术评价之外的正面或负面的印象。\n譬如小说中的一个角色继承了遗产,或发了大财,我们通常也会跟着高兴。无论如何,这只有当角色是值得同情时才会发生—意思就是我们认同他或她的时候。我们并不是说我们也想继承遗产,只是说我们喜欢这本书而已。\n或许我们都希望自己拥有的爱比现在拥有的还要丰富。许多小说是关于爱情的—或许绝大多数—当我们认同其中恋爱的角色时,我们会觉得快乐。他们很自由,而我们不自由。但我们不愿意承认这一点,因为这会让我们觉得我们所拥有的爱是不完整的。\n其实,在每个人的面具之下,潜意识里都可能有些虐待狂或被虐狂。这些通常在小说中获得了满足,我们会认同那位征服者或被虐者,或是两者皆可。在这样的状况中,我们只会简单地说:我们喜欢“那种小说”—用不着把理由说得太清楚。\n最后,我们总是怀疑生命是不公平的。为什么好人受苦,坏人却成功?我们不知道,也无法知道为什么,但这个事实让所有的人焦虑。在故事中,这个混乱又不愉快的情况被矫正过来了,我们觉得格外满足。\n在故事书中—小说、叙事诗或戏剧—公理正义确实是存在的。人们得到他们该得的。对书中的角色来说,作者就像上帝一样,依照他们的行为给他们应得的奖励或惩罚。在一个好故事中,在一个能满足我们的故事中,至少该做到这一点。关于一个坏故事最惹人厌的一点是,一个人受奖励或惩罚一点都不合情合理。真正会说故事的人不会在这一点上出错。他要说服我们:正义—我们称之为诗的正义(poetic justice)—已经战胜了。\n大悲剧也是如此。可怕的事情发生在好人身上,我们眼中的英雄不该承受这样的厄运,但最后也只好理解命运的安排。而我们也非常渴望能与他分享他的领悟。如果我们知道如此—我们也能面对自己在现实世界中所要碰上的事了。《我要知道为什么》(I Want to knowWhy)是舍伍德·安德森(Sherwood Anderson)所写的一个故事,也可以用作许多故事的标题。那个悲剧英雄确实学到了为什么,当然过程很困难,而且是在生活都被毁了之后才明白的。我们可以分享他的洞察力,却不需要分享他的痛苦遭遇。\n因此,在批评小说时,我们要小心区别这两种作品的差异:一种是满足我们个人特殊潜意识需求的小说—那会让我们说:“我喜欢这本书,虽然我并不知道为什么。”另一种则是满足大多数人潜意识需求的小说。用不着说,后者会是一部伟大的作品,世代相传,永不止息。只要人活着一天,这样的小说就能满足他,给他一些他需要的东西—对正义的信念与领悟,平息心中的焦虑。我们并不知道,也不能确定真实的世界是很美好的。但是在伟大的作品中,世界多多少少是美好的。只要有可能,我们希望能经常住在那个故事的世界里。\n2.关于史诗的重点 # 在西方传统作品中,最伟大的荣耀,也最少人阅读的就是史诗了。特别像是荷马的《伊里亚特》与《奥德赛》,维吉尔的《埃涅阿斯纪》,但丁的《神曲》与弥尔顿的《失乐园》。其中的矛盾之处值得我们注意。\n从过去二千五百年以来只写成极少数的史诗就可以看出来,这是人类最难写的一种作品。这并不是我们不愿意尝试,几百首史诗都曾经开始写过,其中像华兹华斯(Wordsworth)的《序曲》(Prelude)、拜伦(Byron)的《唐璜》(Don Juan),都已经写了大部分,却并没有真正完成。执着于这份工作,而且能完成工作的诗人是值得荣耀的。而更伟大的荣耀是属于写出那五本伟大作品的诗人,但这样的作品并不容易阅读。\n这并不只是因为这些书都是用韵文写的—除了原本就是以英语写作的《失乐园》之外,其他的史诗都有散文的诠释作品出现,以帮助我们理解。真正的困难似乎在于如何跟随作品逐步升高那种环绕着主题的追寻。阅读任何一部重要的史诗对读者来说都有额外的要求—要求你集中注意力,全心参与并运用想像力。阅读史诗所要求的努力确实是不简单的。\n大部分人都没注意到,只不过因为不肯付出这种努力来阅读,我们的损失有多大。因为好的阅读—我们该说是分析阅读—能让我们收获良多,而阅读史诗,至少就像阅读其他小说作品一样,能让我们的心灵更上层楼。不幸的是,如果读者不能善用阅读技巧来阅读这些史诗,将会一无所获。\n我们希望你能痛下决心,开始阅读这五本史诗,你会逐步了解这些作品的。如果你这么做,我们确定你不会失望。你还可能享受到更进一步的满足感。荷马、维吉尔、但丁与弥尔顿—每一个优秀的诗人都是他们的读者,其他作者也不用说。这五本书再加上《圣经》,是任何一个认真的读书计划所不可或缺的读物。\n3.如何阅读戏剧 # 一个剧本是一篇小说、故事,同时也真的该像读一个故事一样阅读。因为剧本不像小说将背景描绘得清楚,或许读者阅读的时候要更主动一些,才能创造出角色生活与活动的世界的背景。不过在阅读时,两者的基本问题是相似的。\n然而,其中还是有一个重要的差异。你在读剧本时,不是在读一个已经完全完成的作品。完成的剧本(作者希望你能领会的东西)只出现在舞台的表演上。就像音乐一样必须能倾听,阅读剧本所缺乏的就是身体语言实际的演出。读者必须自己提供那样的演出。\n要做到这一点的惟一方法是假装看到演出的实景。因此,一旦你发现这个剧本谈的是什么,不论是整体或部分,一旦你能回答有关阅读的所有问题后,你就可以开始导演这个剧本。假设你有六七个演员在你眼前,等待你的指令。告诉他们如何说这一句台词,如何演那一幕。解释一下重要的句子,说明这个动作如何让整出戏达到高潮。你会玩得很开心,也会从这出戏中学到很多。\n有个例子可以说明我们的想法。在《哈姆雷特》第二幕第二场中,波隆尼尔向国王与王后密告哈姆雷特的愚行,因为他爱上了奥菲莉雅,而她会阻碍王子的前程。国王与王后有点迟疑,波隆尼尔便要国王跟他躲在挂毯后面,好偷听哈姆雷特与奥菲莉雅的谈话。这一幕出现在第二幕第二场中,原文第160至170行。很快地,哈姆雷特读着书上场了,他对波隆尼尔说的话像打哑谜,于是波隆尼尔说道:“他虽疯,但却有一套他自己的理论。”过了一阵子,第三幕的开头,哈姆雷特进场,说出了著名的独白:“要活,还是要死?”然后奥菲莉雅出现在他眼前,打断了他的话。他与她说了一段话,看起来神智正常,但突然间他狂叫道:“啊!啊!你是真诚的吗?”(第三幕,第一场,103行)。现在的问题是:哈姆雷特是否偷听到波隆尼尔与国王准备侦察他的对话?或是他听到了波隆尼尔说要“让我的女儿去引诱他”?如果真是如此,那么哈姆雷特与波隆尼尔及奥菲莉雅的对话代表的都是同一件事。如果他并没有听到这个密谋,那又是另一回事了。莎士比亚并没有留下任何舞台指导,读者(或导演)必须自己去决定。你自己的判断会是了解整出剧的中心点。\n莎士比亚的许多剧本都需要读者这样主动地阅读。我们的重点是,无论剧作家写得多清楚,一字不误地告诉我们发生了什么事,还是很值得做这件事。(我们没法抱怨说听不清楚,因为对白全在我们眼前。)如果你没有将剧本搬上心灵的舞台演出过,或许你还不能算是读过剧本了。就算你读得再好,也只是读了一部分而已。\n前面我们提过,这个阅读规则有一个有趣的例外,就是剧作家不能像小说家一样对读者直接说话。(菲尔丁所写的《汤姆琼斯》就会直接向读者发言,这也是一部伟大的小说。)其中有两个例外前后将近相差了二十五世纪之久。阿里斯托芬(Aristophanes),古希腊的喜剧剧作家,写过一些所谓的“古老喜剧”(Old Comedy)的例子留传下来。在阿里斯托芬的戏剧中,经常会或至少会有一次,主要演员从角色中脱身而出,甚至走向观众席,发表一场政治演说,内容与整出戏是毫无关联的。那场演说只是在表达作者个人的感觉而已。现在偶尔还有戏剧会这么做—没有一项有用的艺术手法是会真正失传的—只是他们表现的手法或许比不上阿里斯托芬而已。\n另一个例子是萧伯纳,他不但希望自己的剧本能够演出,还希望能让读者阅读。他出版了所有的剧本,甚至有一本《心碎之家》(Heart break House)是在演出之前就出版的。在剧本之前,他写了很长的序言,解释剧本的意义,还告诉读者如何去理解这出剧。(在剧本中他还附上详尽的舞台指导技巧。)要阅读萧伯纳式的剧本,却不读萧伯纳所写的前言,就等于是拒绝了作者最重要的帮助,不让他辅助你理解这出戏。同样地,一些现代剧作家也学习萧伯纳的做法,但都比不上他的影响力。\n另一点建议可能也有帮助,尤其是在读莎士比亚时更是如此。我们已经提过,在阅读剧本时最好是一气呵成,才能掌握住整体的感觉。但是,许多剧本都是以韵文写的,自从1600年以来语言变化之后,韵文的句子读起来就相当晦涩,因此,把剧本大声地读出来倒经常是不错的方法。要慢慢读,就像是听众在听你说话一样,还是带着感情读—也就是说要让那些句子对你别有深意。这个简单的建议会帮助你解决许多问题。只有当这样做之后还有问题,才要找注解来帮助你阅读。\n4.关于悲剧的重点 # 大多数剧本是不值得阅读的。我们认为这是因为剧本并不完整。剧本原来就不是用来阅读的—而是要演出的。有许多伟大的论说性作品,也有伟大的小说、故事与抒情诗,却只有极少数的伟大剧本。无论如何,这些少数的剧作—埃斯库罗斯(Aeschylus)、索福克勒斯(Sophocles)、欧里庇得斯(Euripedes)的悲剧,莎士比亚的戏剧,莫里哀(Moliere)的喜剧及少数的现代作品—都是非常伟大的作品。因为在他们的作品中包含了人类所能表现的既深刻又丰富的洞察力。\n在这些剧本中,对初学者来说,希腊悲剧可能是最难人门的。其中一个原因是,在古代,这些悲剧是一次演出三幕的,三幕谈的都是同一个主题,但是今天除了埃斯库罗斯的《俄瑞斯底亚》(Oresteia)之外,其他的都只剩下独幕剧。另一个原因是,几乎很难在心中模拟这些悲剧,因为我们完全不知道希腊的导演是如何演出这样的戏剧的。还有一个原因,这些悲剧通常来自一些故事,这对当时的观众来说是耳熟能详的事,对我们而言却只是一个剧本。以俄狄浦斯的故事为例,尽管我们非常熟悉那个故事,就像我们熟悉华盛顿与樱桃树的故事一样,但是看索福克勒斯如何诠释这个故事是一回事,把《俄狄浦斯王》当作一个主要的故事,然后来想像这个熟悉的故事所提供的背景是什么,又是另一回事。\n不过,这些悲剧非常有力量,虽然有这么多障碍却仍然流传至今。把这些剧本读好是很重要的,因为它们不只告诉我们有关这个世界的一切,也是一种文学形式的开端,后来的许多剧作家如拉辛(Racine)及奥尼尔(O\u0026rsquo; Neil)都是以此为基础的。下面还有两点建议可能对你阅读希腊悲剧有帮助。\n第一,记住悲剧的精髓在时间,或是说缺乏时间。如果在希腊悲剧中有足够的时间,就没有解决不了的事。问题是时间永远不够。决定或选择都要在一定的时刻完成,没有时间去思考,衡量轻重。因为就算悲剧英雄也是会犯错的—或许是特别会犯错—所作的决定也是错的。对我们来说很容易看出来该做些什么,但我们能在有限的时间中看清楚一切吗?在阅读希腊悲剧时,你要一直把这个问题放在心中。\n第二,我们确实知道在希腊的戏剧中,所有的悲剧演员都穿一种高出地面几英寸的靴子(他们也戴面具)。叙述旁白的演员虽然有时会戴面具,但不会穿这种靴子。因此,一边是悲剧的主角,另一边是叙述旁白的演员,两相比较之下,就可以看出极大的差异了。因此你要记得,在读旁白的部分时,你要想像这些台词是跟你一般身高的人所说出来的话,而在读悲剧人物的台词时,你要想像这是出自一个大人物的口中,他们不只是在形象上,在实际身高上也高出你一截。\n5.如何阅读抒情诗(Lyric Poetry) # 最简单的有关诗的定义(就跟这个标题一样,这里所谓的诗是有所限制的),就是诗人所写的东西。这样的定义看起来够浅显明白了,但是仍然有人会为此争执不已。他们认为诗是一种人格的自然宣泄,可能借文字表达出来,也可能借身体的行动传达出来,或是由音乐宣泄出来,甚至只是一种感觉而已。当然,诗与这些都有点关系,诗人也能接受这样的说法。关于诗有一种很古老的观念,那就是诗人要向内心深处探索,才能创造出他们的诗句。因此,他们的心灵深处是一片神秘的“创造之泉”。从这个角度来看,任何人在任何时间,只要处于孤独又敏感的状态,都可以创造出诗句来。虽然我们都承认这样的定义已经说中了要点,不过下面我们要用来说明诗的又是更狭窄的定义。无论我们心中如何激荡着原始的诗情,但是诗仍是由文字组成的,而且是以条理分明,精巧熟练的方式所组合出来的。\n另一种关于诗的定义,同样也包含了一些要点。那就是诗(主要是抒情诗)如果不是赞美,或是唤起行动(通常是革命行动),或者如果不是以韵文写作,特别是运用所谓“诗的语言”来写作,那就算不上是真正的诗。在这个定义中,我们故意将一些最新跟最旧的理论融合起来。我们的观点是,所有这些定义,包括我们还会提到的一些定义,那太狭隘了。而上一段所说的诗的定义,又太广泛了。\n在狭隘与广泛的定义之间,有一个核心概念,那就是只要他们觉得适合,就会承认那是诗了。如果我们想要特别说明出这核心概念是什么,我们就是在给自己找麻烦,而我们不打算这么做。此外,我们也确定你知道我们在谈的是什么。我们十之八九敢肯定,或是百分之九十九确定你会同意我们所说的X是诗,Y不是诗的道理。这个概念足够说明我们的议题了。\n许多人相信他们不能读抒情诗—尤其是现代诗。他们认为这种诗读起来很困难,含糊不清又复杂无比,需要花上很多的注意力,自己要很努力才行,因此实在不值得花上这么多时间来读。我们要说两个观念:第一,抒情诗,任何现代诗,只要你肯拿起来读,你会发现并不像你想的要花那么大的工夫。其次,那绝对是值得你花时间与精力去做的事。\n我们并不是说你在读诗就不用花精神。一首好诗可以用心研读,一读再读,并在你一生当中不断地想起这首诗。你会在诗中不断地找到新点子、新的乐趣与启示,对你自己及这个世界产生新的想法。我们的意思是;接近一首诗,研读这首诗,并不像你以为的那样困难。\n阅读抒情诗的第一个规则是:不论你觉得自己懂不懂,都要一口气读完,不要停。这个建议与阅读其他类型书的建议相同,只是比起阅读哲学或科学论文,甚至小说或戏剧,这个规则对诗来说更重要。\n事实上,许多人在阅读诗,尤其是现代诗时会有困难,因为他们并不知道阅读诗的第一个规则。面对艾略特、迪兰·托马斯(DylanThomas)或其他“费解”的现代诗时,他们决定全神投人,但读了第一行或第一段之后便放弃了。他们没法立即了解这行诗,便以为整首诗都是如此了。他们在字谜间穿梭,想重新组合混乱的语法,很快地他们放弃了,并下结论说:他们怀疑现代诗对他们而言是太难理解了。\n不光是现代抒情诗难懂。许多好诗用词都很复杂,而且牵涉到他们当时的语言与思想。此外,许多外表看起来很简单的诗,其实内在的架构都很复杂。\n但是任何一首诗都有个整体大意。除非我们一次读完,否则无法理解大意是什么,也很难发现诗中隐藏的基本感觉与经验是什么。尤其是在一首诗中,中心思想绝不会在第一行或第一段中出现的。那是整首诗的意念,而不是在某一个部分里面。\n阅读抒情诗的第二个规则是:重读一遍—大声读出来。我们在前面这样建议过,譬如像是诗般的戏剧如莎士比亚的作品就要朗诵出声来。读戏剧,那会帮助你了解。读诗,这却是基本。你大声朗诵诗句,会发现似乎说出来的字句可以帮助你更了解这首诗。如果你朗诵出来,比较不容易略过那些不了解的字句,你的耳朵会抗议你的眼睛所忽略的地方。诗中的节奏或是有押韵的地方,能帮助你把该强调的地方突显出来,增加你对这首诗的了解。最后,你会对这首诗打开心灵,让它对你的心灵发生作用—一如它应有的作用。\n在阅读抒情诗时,前面这两个规则比什么都重要。我们认为如果一个人觉得自己不能读诗,只要能遵守前面这两个规则来读,就会发现比较容易一些了。一旦你掌握住一首诗的大意时,就算是很模糊的大意,你也可以开始提出问题来。就跟论说性作品一样,这是理解之钥。\n对论说性作品所提出的问题是文法与逻辑上的问题。对抒情诗的问题却通常是修辞的问题,或是句法的问题。你无法与诗人达成共识,但是你能找出关键字。你不会从文法中分辨出来,而是从修辞上找到。 为什么诗中有些字会跳出来,凝视着你?是因为节奏造成的?还是押韵的关系?还是这个字一直在重复出现?如果好几段谈的都是同样的概念,那么彼此之间到底有什么关联?你找出的答案能帮助你了解这首诗。在大部分好的抒情诗中,都存在着一些冲突。有时是对立的两方—或是个人,或是想像与理想的象征—出场了,然后形容双方之间的冲突。如果是这样的写法,就很容易掌握。但是通常冲突是隐藏在其中,没有说出口的。譬如大多数的伟大抒情诗—或许最主要的都是如此—所谈的都是爱与时间、生与死、短暂的美与永恒的胜利之间的冲突。但是在诗的本身,却可能看不到这些字眼。\n有人说过,所有莎士比亚的十四行诗都是在谈他所谓的“贪婪的时间”造成的毁坏。有些诗确实是如此,因为他一再地强调出来:\n我曾窥见时间之手的残酷\n被陈腐的岁月掩埋就是辉煌的代价\n这是第64首十四行诗,列举了时间战胜了一切,而人们却希望能与时间对抗。他说:\n断垣残壁让我再三思量\n岁月终将夺走我的爱人这样的十四行诗当然没有问题。在第116首的名句中,同样包含了下面的句子:\n爱不受时间愚弄,虽然红唇朱颜\n敌不过时间舞弄的弯刀;\n爱却不因短暂的钟点与周期而变貌,\n直到末日尽头仍然长存。\n而在同样有名的第138首十四行诗中,开始时是这么写的:\n我的爱人发誓她是真诚的\n我真的相信她,虽然我知道她在说谎\n谈的同样是时间与爱的冲突,但是“时间”这两个字却没有出现在诗中。\n这样你会发现读诗并不太困难。而在读马维尔(Marvell )的庆典抒情诗《给害羞的女主人》(To His Coy Mistress)时,你也不会有困难。因为这首诗谈的是同样的主题,而且一开始便点明了:\n如果我们拥有全世界的时间,\n这样的害羞,女郎,绝不是罪过。\n但是我们没有全世界的时间,马维尔继续说下去:\n在我背后我总是听见\n时间的马车急急逼进;\n无垠的远方横亘在我们之上\n辽阔的沙漠永无止境。\n因此,他恳求女主人:\n让我们转动全身的力量\n让全心的甜蜜融入舞会中,\n用粗暴的争吵撕裂我们的欢愉\n彻底的挣脱生命的铁门。\n这样,虽然我们不能让阳光\n静止,却能让他飞奔而去。\n阿契伯·麦克莱西(Archibald MacLeisch)的诗《你,安德鲁·马维尔》(You,Andrew Marvell),可能比较难以理解,但所谈的主题却\n是相同的。这首诗是这样开始的:\n在这里脸孔低垂到太阳之下\n在这里望向地球正午的最高处\n感觉到阳光永远的来临\n黑夜永远升起麦克莱西要我们想像一个人(诗人?说话的人?读者?)躺在正午的阳光下—同样的,在这灿烂温暖的当儿,警觉到“尘世黑暗的凄凉”。他想像夕阳西沉的阴影—所有历史上依次出现过又沉没了的夕阳—吞噬了整个世界,淹没了波斯与巴格达……他感到“黎巴嫩渐渐淡出,以及克里特”,“与西班牙沉人海底、非洲海岸的金色沙滩也消失了”……“现在海上的一束亮光也不见了”。他最后的结论是:\n在这里脸孔沉落到太阳之下\n感觉到多么快速,多么神秘,\n夜晚的阴影来临了……\n这首诗中没有用到“时间”这两个字,也没有谈到爱情。此外,诗的标题让我们联想到马维尔的抒情诗的主题:“如果我们拥有全世界的时间”。因此,这首诗的组合与标题诉求的是同样的冲突,在爱(或生命)与时间之间的冲突—这样的主题也出现在我们所提的其他诗之中。\n关于阅读抒情诗,还有最后的一点建议。一般来说,阅读这类书的读者感觉到他们一定要多知道一点关于作者及背景的资料,其实他们也许用不上这些资料。我们太相信导论、评论与传记—但这可能只是因为我们怀疑自己的阅读能力。只要一个人愿意努力,几乎任何人都能读任何诗。你发现任何有关作者生活与时代的资讯,只要是确实的都有帮助。但是关于一首诗的大量背景资料并不一定保证你能了解这首诗。要了解一首诗,一定要去读它—一遍又一遍地读。阅读任何伟大的抒情诗是一生的工作。当然,并不是说你得花一生的时间来阅读伟大的抒情诗,而是伟大的抒情诗值得再三玩味。而在放下这首诗的时候,我们对这首诗所有的体会,可能更超过我们的认知。\n第十六章 如何阅读历史书 # “历史”就跟“诗”一样,含有多重意义。为了要让这一章对你有帮助,我们一定要跟你对这两个字达成共识—也就是说我们是如何运用这两个字的。\n首先,就事实而言的历史(history as fact)与就书写记录而言的历史(history as a written record of the tacts)是不同的。显然,在这里我们要用的是后者的概念,因为我们谈的是“阅读”,而事实是无法阅读的。所谓历史书有很多种书写记录的方式。收集特定事件或时期的相关资料,可以称作那个时期或事件的历史。口头采访当事人的口述记录,或是收集这类的口述记录,也可以称作那个事件或那些参与者的历史。另外一些出发点相当不同的作品,像是个人日记或是信件收集,也可以整理成一个时代的历史。历史这两个字可以用在,也真的运用在几乎各种针对某一段时间,或读者感兴趣的事件上所写的读物。\n下面我们所要用到的“历史”这两个字,同时具有更狭义与更广义的含义。所谓更狭义,指的是我们希望限制在针对过去某段时期、某个事件或一连串的事件,来进行基本上属于叙事风格,多少比较正式的描述。这也是“历史”的传统词义,我们毋须为此道歉。就像我们为抒情诗所下的定义一样,我们认为你会同意我们所采用的一般定义,而我们也会将焦点集中在这种一般类型上。\n但是,在更广义的部分,我们比当今许多流行的定义还要广。我们认为,虽然并不是所有的历史学家都赞同,但我们还是强调历史的基本是叙事的,所谓的事指的就是“故事”,这两个字能帮助我们理解基本的含意。就算是一堆文状的收集,说的还是“故事”。这些故事可能没有解说—因为历史学家可能没有将这些资料整理成“有意义的”秩序。但不管有没有秩序,其中都隐含着主题。否则,我们认为这样的收集就不能称之为那个时代的历史。\n然而,不论历史学家赞不赞同我们对历史的理念,其实都不重要。我们要讨论的历史书有各种写作形态,至少你可能会想要读其中的一两种。在这一点上我们希望能帮助你使把劲。\n1.难以捉摸的史实 # 或许你加人过陪审团,倾听过像车祸这类单纯的事件。或许你加人的是高等法院陪审团,必须决定一个人是否杀了另一个人。如果这两件事你都做过,你就会知道要一个人回忆他亲眼见到的事情,将过去重新整理出来有多困难—就是一个小小的单纯事件也不容易。\n法庭所关心的是最近发生的事件与现场目击的证人,而且对证据的要求是很严格的。一个目击者不能假设任何事,不能猜测,不能保证,也不能评估(除非是在非常仔细的情况掌控之下)。当然,他也不可以说谎。\n在所有这些严格规范的证据之下,再加上详细检验之后,身为陪审团的一员,你是否就能百分之百地确定,你真的知道发生了什么事吗?\n法律的设定是你不必做到百分之百的确定。因为法律设定陪审团的人心中总是有些怀疑的感觉。实际上,为了审判可以有这样与那样的不同决定,法律虽然允许这些怀疑影响你的判断,但一定要“合理”才行。换句话说,你的怀疑必须强到要让你的良心觉得困扰才行。\n历史学家所关心的是已经发生的事件,而且绝大部分是发生在很久以前的事件。所有事件的目击者都死了,他们所提的证据也不是在庭上提出的—也就是没有受到严格、仔细的规范。这样的证人经常在猜测、推想、估算、设定与假设。我们没法看到他们的脸孔,好推测他们是否在撒谎(就算我们真的能这样判断一个人的话)。他们也没有经过严格检验。没有人能保证他们真的知道他们在说些什么。\n所以,如果一个人连一件单纯的事都很难确知自己是否明白,就像法庭中的陪审团难下决定一样,那么想知道历史上真正发生了什么事的困难就更可想而知了。一件历史的“事实”—虽然我们感觉很相信这两个字代表的意义,但却是世上最难以捉摸的。\n当然,某一种历史事实是可以很确定的。1861年4月12日,美国在桑姆特要塞掀起了内战;1865年4月9日,李将军在阿波米脱克斯法庭向格兰特将军投降,结束了内战。每个人都会同意这些日期。虽然不是绝无可能,但总不太可能当时全美国的日历都不正确。\n但是,就算我们确实知道内战是何时开始,何时结束,我们又从中学到了什么?事实上,这些日期确实被质疑着—不是因为所有的日历都错了,而是争论的焦点在这场内战是否应该起于1860年的秋天,林肯当选总统,而结束于李将军投降后五天,林肯被刺为止。另外一些人则声称内战应该开始得更早一点—要比1861年还早个五到十或二十年—还有,我们也知道到1865年美国一些边睡地带仍然继续进行着战争,因此北方的胜利应该推迟到1865年的5月、6月或7月。甚至还有人认为美国的内战直到今天也没有结束—除非哪一天美国的黑人能获得完全的自由与平等,或是南方各州能脱离联邦统治,或是联邦政府可以下达各州的控制权能够确立,并为所有美国人所接受,否则美国的内战就永远称不上结束。\n你可以说,至少我们知道,不论内战是不是从桑姆特之役开始,这场战役确实是发生在1861年4月12日。这一点是毋庸置疑的—我们前面提过,这是在特定限制之下的史实。但是为什么会有桑姆特之役?这显然是另一个问题。在那场战役之后,内战是否仍然可以避免呢?如果可以,我们对一个多世纪之前,一个如此这般的春日,所发生的如此这般的战役,还会如此关心吗?如果我们不关心—我们对许多确实发生过,但自己却一无所知的战役都不关心—那么桑姆特之役仍然会是一件意义重大的史实吗?\n2.历史的理论 # 如果非要分类不可的话,我们应该把历史,也就是过去的故事—归类为小说,而非科学—就算不分类,如果能让历史停格在这两类书之中的话,那么通常我们会承认,历史比较接近小说,而非科学。\n这并不是说历史学家在捏造事实,就像诗人或小说家那样。不过,太强调这些作家都是在编造事实,也可能自我麻烦。我们说过,他们在创造一个世界。这个新世界与我们所居住的世界并非截然不同—事实上,最好不是—而一个诗人也是人,透过人的感官进行自己的学习。他看事情跟我们没什么两样(虽然角度可能比较美好或有点不同)。他的角色所用的语言也跟我们相同(否则我们没法相信他们)。只有在梦中,人们才会创造真正不同的全新世界—但是就算在最荒谬的梦境中,这些想像的事件与生物也都是来自每天的生活经验,只是用一种奇异而崭新的方法重新组合起来而已。\n当然,一个好的历史学家是不会编造过去的。他认为自己对某些观念、事实,或精准的陈述责无旁贷。不过,有一点不能忘记的是,历史学家一定要编纂一些事情。他不是在许多事件中找出一个共通的模式,就是要套上一个模式。他一定要假设他知道为什么这些历史上的人物会做出这些事。他可能有一套理论或哲学,像是上帝掌管人间的事物一样,编纂出适合他理论的历史。或者,他会放弃任何置身事外或置身其上的模式,强调他只是在如实报导所发生过的事件。但是即使如此,他也总不免要指出事件发生的原因及行为的动机。你在读历史书时,最基本的认知就是要知道作者在运作的是哪一条路。\n不想采取这个或那个立场,就得假设人们不会故意为某个目的而做一件事,或者就算有目的,也难以察觉—换句话说,历史根本就没有模式可循。\n托尔斯泰对历史就有这样的理论。当然,他不是历史学家,而是小说家。但是许多历史学家也有同样的观点,近代的历史学家更是如此。托尔斯泰认为,造成人类行为的原因太多,又太复杂,而且动机又深深隐藏在潜意识里,因此我们无法知道为什么会发生某些事。\n因为关于历史的理论不同,因为历史家的理论会影响到他对历史事件的描述,因此如果我们真的想要了解一个事件或时期的历史,就很有必要多看一些相关的论著。如果我们所感兴趣的事件对我们又有特殊意义的话,就更值得这么做了。或许对每个美国人来说,知道一些有关内战的历史是有特殊意义的。我们仍然生活在那场伟大又悲惨的冲突的余波中,我们生活在这件事所形成的世界中。但是如果我们只是经由一个人的观点,单方面的论断,或是某个现代学院派历史学家来观察的话,是没法完全理解这段历史的。如果有一天,我们打开一本新的美国内战史,看到作者写着:“公正客观的美国内战史—由南方的观点谈起”,那这位作者看起来是很认真的。或许他真的如此,或许这样的公正客观真的可能。无论如何,我们认为每一种历史的写作都必定是从某个观点出发的。为了追求真相,我们必须从更多不同的角度来观察才行。\n3.历史中的普遍性 # 关于一个历史事件,我们不见得总能读到一种以上的书。当我们做不到的时候,我们必须承认,我们没有那么多机会提出问题,以学习到有关的事实—明白真正发生了什么。不过,这并不是阅读历史的惟一理由。可能会有人说,只有专业历史学家,那个写历史的人,才应该严格检验他的资料来源,与其他相反的论点作仔细的核对验证。如果他知道关于这个主题他该知道些什么,他就不会产生误解。我们,身 为历史书的半吊子读者,介于专业历史学家与阅读历史纯粹只是好玩,不负任何责任的外行读者之间。\n让我们用修昔底德(Thucydides)做例子。你可能知道他写过一本有关公元前五世纪末,伯罗奔尼撒战争的史实,这是当时惟一的一本主要的历史书。在这样的情况下,没有人能查证他作品的对错。那么,我们也能从这样的书中学到什么吗?\n希腊现在只是个小小的国家。一场发生在25世纪以前的战争,对今天的我们真的起不了什么作用。每一个参与战事的人都早已长眠,而引发战争的特殊事件也早已不再存在。胜利者到了现在也毫无意义了,失败者也不再有伤痛。那些被征服又失落的城市已化作烟尘。事实上,如果我们停下来想一想,伯罗奔尼撒战争所遗留下来的似乎也就只有修昔底德这本书了。\n但是这样的记录还是很重要的。因为修昔底德的故事—我们还是觉得用这两个字很好—影响到后来人类的历史。后代的领导者会读修昔底德的书。他们会发现自己的处境仿佛与惨遭分割的希腊城邦的命运一样,他们把自己比作雅典或斯巴达。他们把修昔底德当作借口或辩解的理由,甚至行为模式的指引。结果,就因为修昔底德在公元前5世纪的一些观点,整个世界的历史都逐渐被一点点虽然极为微小,却仍然可以察觉的改变所影响。因此我们阅读修昔底德的历史,不是因为他多么精准地描述出在他写书之前的那个世界,而是因为他对后代发生的事有一定的影响力。虽然说起来很奇怪,但是我们阅读他的书是为了想要了解目前发生的事。\n亚里士多德说:“诗比历史更有哲学性。”他的意思是诗更具一般性,更有普遍影响力。一首好诗不只在当时当地是一首好诗,也在任何时间任何地点都是好诗。这样的诗对所有人类来说都有意义与力量。历史不像诗那样有普遍性。历史与事件有关,诗却不必如此。但是一本好的历史书仍然是有普遍性的。\n修昔底德说过,他写历史的原因是:希望经由他所观察到的错误,以及他个人受到的灾难与国家所受到的苦楚,将来的人们不会重蹈覆辙。他所描述的人们犯下的错误,不只对他个人或希腊有意义,对整个人类来说更有意义。在二千五百年以前,雅典人与斯巴达人所犯的错误,今天人们仍然同样在犯—或至少是非常接近的错误—修昔底德以降,这样的戏码一再上演。\n如果你阅读历史的观点是设限的,如果你只想知道真正发生了什么事,那你就不会从修昔底德,或任何一位好的历史学家手中学到东西。如果你真把修昔底德读通了,你甚至会扔开想要深究当时到底发生了什么事的念头。\n历史是由古到今的故事。我们感兴趣的是现在—以及未来。有一部分的未来是由现在来决定的。因此,你可以由历史中学习到未来的事物,甚至由修昔底德这样活在二千年前的人身上学到东西。\n总之,阅读历史的两个要点是:第一,对你感兴趣的事件或时期,尽可能阅读一种以上的历史书。第二,阅读历史时,不只要关心在过去某个时间、地点真正发生了什么事,还要读懂在任何时空之中,尤其是现在,人们为什么会有如此这般行动的原因。\n4.阅读历史书要提出的问题 # 尽管历史书更接近小说,而非科学,但仍然能像阅读论说性作品一样来阅读,也应该如此阅读。因此,在阅读历史时,我们也要像阅读论说性作品一样,提出基本的问题。因为历史的特性,我们要提出的问题有点不同,所期待的答案也稍微不同。\n第一个问题关心的是,每一本历史书都有一个特殊而且有限定范围的主题。令人惊讶的是,通常读者很容易就看出这样的主题,不过,不见得会仔细到看出作者为自己所设定的范围。一本美国内战的书,固然不是在谈19世纪的世界史,可能也不涉及1860年代的美国西部史。虽然不应该,但它可能还是把当年的教育状况,美国西部拓荒的历史或美国人争取自由的过程都略过不提。因此,如果我们要把历史读好,我们就要弄清楚这本书在谈什么,没有谈到的又是什么。当然,如果我们要批评这本书,我们一定要知道它没谈到的是什么。一位作者不该因为他没有做到他根本就没想做的事情而受到指责。\n根据第二个问题,历史书在说一个故事,而这个故事当然是发生在一个特定的时间里。一般的纲要架构因此决定下来了,用不着我们去搜寻。但是说故事的方法有很多种,我们一定要知道这位作者是用什么方法来说故事的。他将整本书依照年代、时期或世代区分为不同的章节?还是按照其他的规则定出章节?他是不是在这一章中谈那个时期的经济历史,而在别章中谈战争、宗教运动与文学作品的产生?其中哪一个对他来说最重要?如果我们能找出这些,如果我们能从他的故事章节中发现他最重视的部分,我们就能更了解他。我们可能不同意他对这件事的观点,但我们仍然能从他身上学到东西。\n批评历史有两种方式。我们可以批评—但永远要在我们完全了解书中的意义之后—这本历史书不够逼真。也许我们觉得,人们就是不会像那样行动的。就算历史学家提供出资料来源,就算我们知道这些是相关的事实,我们仍然觉得他误解了史实,他的判断失真,或是他无法掌握人性或人类的事物。譬如,我们对一些老一辈历史学家的作品中没有包括经济事务,就可能会有这种感觉。对另一些书中所描述的一些大公无私,有太多高贵情操的“英雄”人物,我们也会抱持着怀疑的态度。\n另一方面,我们会认为—尤其是我们对这方面的主题有特殊研究时—作者误用了资料。我们发现他竟然没有读过我们曾经读过的某本书时,会有点生气的感觉。他对这件事所掌握的知识可能是错误的。在这种状况下,他写的就不是一本好的历史书。我们希望一位历史学家有完备知识。\n第一种批评比较重要。一个好的历史学家要能兼具说故事的人与科学家的能力。他必须像某些目击者或作家说一些事情确实发生过一样,知道一些事情就是可能发生过。\n关于最后一个问题:这与我何干?可能没有任何文学作品能像历史一样影响人类的行为。讽刺文学及乌托邦主义的哲学对人类的影响不大。我们确实希望这个世界更好,但是我们很少会被一些只会挖苦现实,只是区别出理想与现实的差异这类作者的忠告所感动。历史告诉我们人类过去所做的事,也经常引导我们作改变,尝试表现出更好的自我。一般来说,政治家接受历史的训练会比其他的训练还要收获良多。历史会建议一些可行性,因为那是以前的人已经做过的事。既然是做过的事,就可能再做一次—或是可以避免再做。\n因此,“与我何干”这个问题的答案,就在于实务面,也就是你的政治行为面。这也是为什么说要把历史书读好是非常重要的。不幸的是,政治领导人物固然经常根据历史知识来采取行动,但却还不够。这个世界已经变得很渺小又危机四伏,每个人都该开始把历史读好才行。\n5.如何阅读传记与自传 # 传记是一个真人的故事。这种作品一直以来就是有混合的传统,因此也保持着混杂的特性。\n有些传记作者可能会反对这样的说法。不过,一般来说,一本传记是关于生活、历史、男人或女人及一群人的一种叙述。因此,传记也跟历史一样有同样的问题。读者也要问同样的问题—作者的目的是什么?他所谓真实包含哪些条件?—这也是在读任何一本书时都要提出的问题。\n传记有很多种类型。“定案本”(definitive)的传记是对一个人的一生作详尽完整的学术性报告,这个人重要到够得上写这种完结篇的传记。定案本的传记绝不能用来写活着的人。这类型的传记通常是先出现好几本非定案的传记之后,才会写出来。而那些先出的传记当中总会有些不完整之处。在写作这样的传记时,作者要阅读所有的资料及信件,还要查证大批当代的历史。因为这种收集资料的能力,与用来写成一本好书的能力不同,因此“定案本”的传记通常是不太容易阅读的。这是最可惜的一点。一本学术性的书不一定非要呆板难读不可。鲍斯韦尔(Boswell)的《约翰逊传》(Life of Johnson)就是一本伟大的传记,但却精彩绝伦。这确实是一本定案本的传记(虽然之后还出现了其他的约翰逊传记),但是非常独特有趣。\n一本定案本的传记是历史的一部分—这是一个人和他生活的那个时代的历史,就像从他本人的眼中所看到的一样。应该用读历史的方法来读这种传记。“授权本”(authorized)传记又是另一回事了。这样的工作通常是由继承人,或是某个重要人物的朋友来负责的。因为他们的写作态度很小心,因此这个人所犯的错,或是达到的成就都会经过润饰。有时候这也会是很好的作品,因为作者的优势—其他作者则不见得—能看到所有相关人士所掌控的资料。当然,授权本的传记不能像定案本的传记那样受到相同的信任。读这种书不能像读一般的历史书一样,读者必须了解作者可能会有偏见—这是作者希望读者能用这样的想法来看书中的主角,这也是他的朋友希望世人用这样的眼光来看他。\n授权本的传记是一种历史,却是非常不同的历史。我们可以好奇什么样利害关系的人会希望我们去了解某一个人的私生活,但我们不必指望真正了解这个人的私生活真相。在阅读授权本的传记时,这本书通常在告诉我们有关当时的时代背景,人们的生活习惯与态度,以及当时大家接受的行为模式—关于不可接受的行为也同时作了点暗示及推论。如果我们只读了单方面的官方传记,我们不可能真的了解这个人的真实生活,就像我们也不可能指望了解一场战役的真相一样。要得到真相,必须要读所有正式的文件,询问当时在场的人,运用我们的头脑从混乱中理出头绪来。定案本的传记已经做过这方面的工作了,授权本的传记(几乎所有活着的人的传记都属于这一种)还有很多要探索的。\n剩下的是介于定案本与授权本之间的传记。或许我们可以称这种传记是一般的传记。在这种传记中,我们希望作者是正确的,是了解事实的。我们最希望的是能超越另一个时空,看到一个人的真实面貌。人是好奇的动物,尤其是对另一个人特别的好奇。\n这样的书虽然比不上定案本的传记值得信任,却很适合阅读。如果世上没有了艾萨克·沃顿(Izaak Walton)为他的朋友,诗人约翰·多恩(John Donne)与乔治·赫伯特(George Herbert)所写的《传记》(Lives)〔沃顿最著名的作品当然是《钓客清话》(The Compleat Angler)],或是约翰·丁达尔(John Tyndall)为朋友迈克尔·法拉第(Michael Faraday)写的《发明家法拉第》(Faraday the Discoverer),这世界将会逊色不少。\n有些传记是教诲式的,含有道德目的。现在很少人写这类传记了,以前却很普遍。(当然,儿童书中还有这样的传记。)普鲁塔克(Plu-tarch)的《希腊罗马名人传》(Lives of the Noble Grecians and Romans)就是这种传记。普鲁塔克告诉人们有关过去希腊、罗马人的事迹,以帮助当代人也能有同样的高贵情操,并帮助他们避免落入过去的伟人所常犯—或确实犯下的错误。这是一本绝妙的作品。虽然书中有许多关于某个人物的叙述,但我们并不把这本书当作收集资料的传记来读,而是一般生活的读物。书中的主角都是有趣的人物,有好有坏,但绝不会平淡无奇。普鲁塔克自己也了解这一点。他说他原本要写的是另一本书,但是在写作的过程中,他却发现在“让这些人物一个个进出自己的屋子之后”,却是自己受益最多,受到很大的启发。\n此外,普鲁塔克所写的其他的历史作品对后代也有相当的影响力。譬如他指出亚历山大大帝模仿阿喀琉斯的生活形态(他是从荷马的书中学到的),所以后代的许多征服者也模仿普鲁塔克所写的亚历山大大帝的生活方式。\n自传所呈现的又是不同的有趣问题。首先要问的是,是否有人真的写出了一本真实的自传?如果了解别人的生活很困难,那么了解自己的生活就更困难了。当然,所有自传所写的都是还未完结的生活。\n没有人能反驳你的时候,你可能会掩盖事实,或夸大事实,这是无可避免的事。每个人都有些不愿意张扬的秘密,每个人对自己都有些幻想,而且不太可能承认这些幻想是错误的。无论如何,虽然不太可能写一本真实的自传,但也不太可能整本书中都是谎言。就像没有人能撒谎撒得天衣无缝,即使作者想要掩盖一些事实,自传还是会告诉我们一些有关作者的真面目。\n一般人都容易认为卢梭的《忏悔录》或同一时期的某部其他作品(约18世纪中叶),是真正称得上自传的开始。这样就忽略了像奥古斯丁的《忏悔录》(Confessions)及蒙田的《散文集》(Essays)。真正的错误还不在这里。事实上,任何人所写的任何主题多少都有点自传的成分。像柏拉图的《理想国》(Republic)、弥尔顿的《失乐园》或歌德的《浮士德》(Faust)中,都有很强烈的个人的影子—只是我们没法一一指认而已。如果我们对人性感兴趣,在合理的限度内,我们在阅读任何一本书的时候,都会张开另一只眼睛,去发现作者个人的影子。\n自传在写得过火时,会陷人所谓“感情谬误\u0026quot;(pathetic fallacy)的状态中,但这用不着过度担心。不过我们要记得,没有任何文字是自己写出来的—我们所阅读到的文字都是由人所组织撰写出来的。柏拉图与亚里士多德说过一些相似的事,也说过不同的事。但就算他们完全同意彼此的说法,他们也不可能写出同样的一本书,因为他们是不同的人。我们甚至可以发现在阿奎那的作品《神学大全》,这样一部显然一切摊开来的作品中,也有些隐藏起来的东西。\n因此,所谓正式的(formal)自传并不是什么新的文学形式。从来就没有人能让自己完全摆脱自己的作品。蒙田说过:“并不是我在塑造我的作品,而是我的作品在塑造我。一本书与作者是合而为一的,与自我密切相关,也是整体生活的一部分。”他还说:“任何人都能从我的书中认识我,也从我身上认识我的书。”这不只对蒙田如此,惠特曼谈到他的《草叶集))(Leaves of Grass)时说:“这不只是一本书,接触到这本书时,也就是接触到一个生命。”\n在阅读传记与自传时还有其他的重点吗?这里还有一个重要的提醒。无论这类书,尤其是自传,揭露了多少有关作者的秘密,我们都用不着花上一堆时间来研究作者并未言明的秘密。此外,由于这种书比较更像是文学小说,而不是叙事或哲学的书,是一种很特别的历史书,因此我们还有一点点想提醒大家的地方。当然,你该记得,如果你想知道一个人的一生,你就该尽可能去阅读你能找到的资料,包括他对自己一生的描述(如果他写过)。阅读传记就像阅读历史,也像阅读历史的原因。对于任何自传都要有一点怀疑心,同时别忘了,在你还不了解一本书之前,不要妄下论断。至于“这本书与我何干?\u0026lsquo;\u0026lsquo;这个问题,我们只能说:传记,就跟历史一样,可能会导引出某个实际的、良心的行动。传记是有启发性的。那是生命的故事,通常是成功者一生的故事—也可以当作我们生活的指引。\n6.读关于当前的事件 # 我们说过,分析阅读的规则适用于任何作品,而不只是书。现在我们要把这个说法作个调整,分析阅读并不是永远都有必要的。我们所阅读的许多东西都用不上分析阅读的努力跟技巧,那也就是我们所谓第三层次的阅读能力。此外,虽然这样的阅读技巧并不一定要运用出来,但是在阅读时,四个基本问题是一定要提出来的。当然,即使当你在面对我们一生当中花费很多时间阅读的报纸、杂志、当代话题之类的书籍时,也一定要提出这些问题来。\n毕竟,历史并没有在一千年或一百年前停顿下来,世界仍在继续运转,男男女女继续写作世上在发生些什么事情,以及事情在如何演变。或许现代的历史没法跟修昔底德的作品媲美,但这是要由后代来评价的。身为一个人及世界的公民,我们有义务去了解围绕在我们身边的世界。\n接下来的问题就是要知道当前确实发生了些什么事。我们用“确实”这两个字是有用意的。法文是用“确实”(actualites)这两个字代表新闻影片。所谓当前发生的事件(current events),也就是跟“新闻”这两个字很类似。我们要如何获得新闻,又如何知道我们获得的新闻是真实的?\n你会立刻发现我们面对的问题与历史本身的问题是一样的。就像我们无法确定过去的事实一样,我们不能确定我们所获得的是不是事实—我们也无法确定我们现在所知道的是事实。但是我们还是要努力去了解真实的情况。\n如果我们能同时出现在任何地方,收听到地球上所有的对话,看穿所有活着的人的心里,我们就可以确定说我们掌握了当前的真实情况。但是身为人类就有先天的限制,我们只能仰赖他人的报导。所谓记者,就是能掌握一小范围内所发生的事,再将这些事在报纸、杂志或书中报导出来的人。我们的资讯来源就要靠他们了。\n理论上,一位记者,不论是哪一类的记者,都该像一面清澈的玻璃,让真相反映出来—或透射过来。但是人类的头脑不是清澈的玻璃,不是很好的反映材料,而当真相透射过来时,我们的头脑也不是很好的过滤器。它会将自认为不真实的事物排除掉。当然,记者不该报导他认为不真实的事。但是,他也可能会犯错。\n因此,最重要的是,在阅读当前事件的报导时,要知道是谁在写这篇报导。这里所说的并不是要认识那位记者,而是要知道他写作的心态是什么。滤镜式的记者有许多种类型,要了解记者心中戴着什么样的过滤器,我们一定要提出一连串的问题。这一连串的问题与任何一种报导现状的作品都有关。这些问题是:(1)这个作者想要证明什么?(2)他想要说服谁?(3)他具有的特殊知识是什么?(4)他使用的特殊语言是什么?(5)他真的知道自己在说些什么吗?\n大体而言,我们可以假设关于当前事件的书,都是想要证明什么事情。通常,这件事情也很容易发现。书衣上通常就会将这本书的主要内容写出来了。就算没有出现在封面,也会出现在作者的前言中。\n问过作者想要证明的是什么之后,你就要问作者想要说服的是什么样的人了?这本书是不是写给那些“知道内情的人”(in the know)—你是其中一个吗?那本书是不是写给一小群读过作者的描绘之后能快速采取某种行动的读者,或者,就是为一般人写的?如果你并不属于作者所诉求的对象,可能你就不会有兴趣阅读这样的一本书。\n接下来,你要发现作者假设你拥有哪种特定的知识。这里所说的“知识”含意很广,说成“观念”或“偏见”可能还更适合一些。许多作者只是为了同意他看法的读者而写书。如果你不同意作者的假设,读这样的书只会使你光火而已。\n作者认为你与他一起分享的假设,有时很难察觉出来。巴兹尔·威利(Basil Willey)在《17世纪背景》(The Seventeenth Century Background)一书中说:\n想要知道一个人惯用的假设是极为困难的,所谓‘以教条为事实\u0026rsquo;,在运用形上学的帮助以及长期苦思之后,你会发现教条就是教条,却绝不是事实。他继续说明要找出不同时代的“以教条为事实”的例子很容易,而这也是他在书中想要做的事。无论如何,阅读当代作品时,我们不会有时空的隔阂,因此我们除了要厘清作者心中的过滤器之外,也要弄清楚自己的想法才行。\n其次,你要问作者是否使用了什么特殊的语言?在阅读杂志或报纸时,这个问题尤其重要。阅读所有当代历史书的时候也用得上这个问题。特定的字眼会激起我们特定的反应,却不会对一个世纪以后的人发生作用。譬如“共产主义”或“共产党”就是一个例子。我们应该能掌握相关的反应,或至少知道何时会产生这样的反应。\n最后,你要考虑五个间题中的最后一个问题,这也可能是最难回答的问题。你所阅读的这位报导作者真的知道事实吗?是否知道被报导的人物私下的思想与决定?他有足够的知识以写出一篇公平客观的报导吗?\n换句话说,我们所强调的是:我们要注意的,不光是一个记者可能会有的偏差。我们最近听到许多“新闻管理”(management of thenews)这样的话题。这样的观念不只对我们这些大众来说非常重要,对那些“知道内情”的记者来说更重要。但是他们未必清楚这一点。一个记者尽管可能抱持着最大的善意,一心想提供读者真实的资料,在一些秘密的行动或协议上仍然可能“知识不足”。他自己可能知道这一点,也可能不知道。当然,如果是后者,对读者来说就非常危险了。\n你会注意到,这里所提的五个问题,其实跟我们说过阅读论说性作品时要提出的问题大同小异。譬如知道作者的特殊用语,就跟与作者达成共识是一样的。对身为现代读者的我们来说,当前事件的著作或与当代有关的作品传达的是特殊的问题,因此我们要用不同的方法来提出这些疑问。\n也许,就阅读这类书而言,整理一堆“规则”还比不上归纳为一句警告。这个警告就是:读者要擦亮眼睛(Caveat lector)!在阅读亚里士多德、但丁或莎士比亚的书时,读者用不着担这种心。而写作当代事件的作者却可能(虽然不见得一定)在希望你用某一种方式了解这件事的过程中,有他自己的利益考虑。就算他不这么想,他的消息来源也会这么想。你要搞清楚他们的利益考虑,阅读任何东西都要小心翼翼。\n7.摘的注意事项 # 我们谈过在阅读任何一种作品时,都有一种基本的区别—为了获得资讯而阅读,还是为了理解而阅读。其实,作这种区别还有另一种后续作用。那就是,有时候我们必须阅读一些有关理解的资讯—换言之,找出其他人是如何诠释事实的。让我们试着说明如下。\n我们阅读报纸、杂志,甚至广告,主要都是为了获得资讯。这些资料的量太大了,今天已没有人有时间去阅读所有的资讯,顶多阅读一小部分而己。在这类阅读领域中,大众的需要激发了许多优秀的新事业的出现。譬如像《时代》(Time)或《新闻周刊)) (Newsweek),这种新闻杂志,对大多数人来说就有难以言喻的功能,因为它们能代替我们阅读新闻,还浓缩成包含最基本要素的资讯。这些杂志新闻写作者基本上都是读者。他们阅读新闻的方法,则已经远远超越一般读者的能力。\n对《读者文摘》(Reader\u0026rsquo;s Digest)这类出版品来说,也是同样的情况。这样的杂志声称要给读者一种浓缩的形式,让我们将注意力由一般杂志转移到一册塞满资讯的小本杂志上。当然,最好的文章,就像最好的书一样,是不可能经过浓缩而没有遗珠之憾的。譬如像蒙田的散文如果出现在现代的期刊上,变成一篇精华摘要,是绝对没法满足我们的。总之,在这样的情况下,浓缩的惟一功能就是激励我们去阅读原著。至于一般的作品,浓缩是可行的,而且通常要比原著还好。因为一般的文字主要都是与资讯有关的。要编纂《读者文摘》或同类期刊的技巧,最重要的就是阅读的技巧,然后是写作要清晰简单。我们没几个人拥有类似的技巧—就算有时间的话—它为做了我们自己该做的事,将核心的资讯分解开来,然后以比较少的文字传达出主题。\n毕竟,最后我们还是得阅读这些经过摘要的新闻与资讯的期刊。如果我们希望获得资讯,不论摘要已经做得多好,我们还是无法避免阅读这件事。在所有分析的最后一步,也就是阅读摘要这件事情,与杂志编辑以紧凑的方式浓缩原文的工作是一样的。他们已经替我们分担了一些阅读的工作,但不可能完全取代或解决阅读的问题。因此,只有当我们尽心阅读这些摘要,就像他们在之前的尽心阅读以帮助我们作摘要一样,他们的功能对我们才会真正有帮助。\n这其中同时涉及为了增进理解而阅读,以及为了获得资讯而阅读这两件事。显然,越是浓缩过的摘要,筛选得越厉害。如果一千页的作品摘成九百页,这样的问题不大。如果一千页的文字浓缩成十页或甚至一页,那么到底留下来的是些什么东西就是个大向题了。内容被浓缩得越多,我们对浓缩者的特质就更要有所了解。我们在前面所提出的“警告”在这里的作用就更大了。毕竟,在经过专业浓缩过的句子中,读者更要能读出言外之意才行。你没法找回原文,看看是删去了哪些,你必须要从浓缩过的文字中自己去判定。因此,阅读文摘,有时是最困难又自我要求最多的一种阅读方式。\n第十七章 如何阅读科学与数学 # 这一章的标题可能会让你误解。我们并不打算给你有关阅读任何一种科学与数学的建议。我们只限定自己讨论两种形式的书:一种是在我们传统中,伟大的科学与数学的经典之作。另一种则是现代科普著作。我们所谈的往往也适用于阅读一些主题深奥又特定的研究论文,但是我们不能帮助你阅读这类文章。原因有两个,第一个很简单,我们没有资格这么做。\n第二个则是:直到大约19世纪末,主要的科学著作都是给门外汉写的。这些作者—像伽利略、牛顿与达尔文—并不反对他们领域中的专家来阅读,事实上,他们也希望接触到这样的读者。但在那个时代,爱因斯坦所说的“科学的快乐童年时代”,科学专业的制度还没有建立起来。聪明又能阅读的人阅读科学书就跟阅读历史或哲学一样,中间没有艰困与速度的差距,也没有不能克服的障碍。当代的科学著作,并没有明显表示出要忽视一般读者或门外汉。不过大多数现代科学著作并不关心门外汉读者的想法,甚至也不想尝试让这样的读者理解。\n今天,科学论文已经变成专家写给专家看的东西了。就某个严肃的科学主题的沟通中,读者也要有相对的专业知识才行,通常不是这个领域中的读者根本无法阅读这类文章。这样的倾向有明显的好处,这使科学的进步更加快速。专家之间彼此交换专业知识,很快就能互相沟通,达到重点—他们很快便能看出问题所在,并想办法解决。但是付出的代价也很明显。你—也就是我们在本书中所强调的一般水平的读者—就没法阅读这类文章了。\n事实上,这样的情况也已经出现在其他的领域中,只是科学的领域更严重一些罢了。今天,哲学家也不再为专业的哲学家以外的读者写作,经济学家只写给经济学家看,甚至连历史学家都开始写专业的论著。而在科学界,专家透过专业论文来作沟通早已是非常重要的方式,比起写给所有读者的那种传统叙事性的写法,这样的方式更方便彼此的意见交流。\n在这样的情况下,一般的读者该怎么办呢?他不可能在任何一个领域中都成为专家。他必须退一步,也就是阅读流行的科普书。其中有些是好书,有些是坏书。但是我们不仅要知道这中间的差别,最重要的是还要能在阅读好书时达到充分的理解。\n1.了解科学这一门行业 # 科学史是学术领域中发展最快速的一门学科。在过去的几年当中,我们看到这个领域在明显地改变。“严肃的”科学家瞧不起科学历史家,是没多久以前的事。在过去,科学历史家被认为是以研究历史为主,因为他们没有能力拓展真正的科学领域。这样的态度可以用萧伯纳的一句名言来作总结:“有能力的人,就去做。没有能力的人,就去教。”\n目前已经很少听到有关这种态度的描述了。科学史这个部门已经变得很重要,卓越的科学家们研究也写出有关科学的历史。其中有个例子就是“牛顿工业\u0026quot;(Newton Industry)。目前,许多国家都针对牛顿的理论及其独特的人格,作密集又大量的研究。最近也出版了六七本相关的书籍。原因是科学家比以前更关心科学这个行业本身了。\n因此,我们毫不迟疑地要推荐你最少要阅读一些伟大的科学经典巨著。事实上,你真的没有借口不阅读这样的书。其中没有一本真的很难读,就算牛顿的《自然哲学的数学原理》(Mathematical Principles of Natural Philosophy),只要你真的肯努力,也是可以读得通的。\n这是我们给你最有帮助的建议。你要做的就是运用阅读论说性作品的规则,而且要很清楚地知道作者想要解决的问题是什么。这个分析阅读的规则适用于任何论说性的作品,尤其适用于科学与数学的作品。\n换句话说,你是门外汉,你阅读科学经典著作并不是为了要成为现代专业领域的专家。相反地,你阅读这些书只是为了了解科学的历史与哲学。事实上,这也是一个门外汉对科学应有的责任。只有当你注意到伟大的科学家想要解决的是什么问题时—注意到问题的本身及问题的背景—你的责任才算结束了。\n要跟上科学发展的脚步,找出事实、假定、原理与证据之间的相互关联,就是参与了人类理性的活动,而那可能是人类最成功的领域。也许,光这一点就能印证有关科学历史研究的价值了。此外,这样的研究还能在某种程度上消除一些对科学的谬误。最重要的是,那是与教育的根本相关的脑力活动,也是从苏格拉底到我们以来,一直被认为是中心的目标,也就是透过怀疑的训练,而释放出一个自由开放的心灵。\n2.阅读科学经典名著的建议 # 所谓科学作品,就是在某个研究领域中,经过实验或自然观察得来的结果,所写成的研究报告或结论。叙述科学的问题总要尽量描述出正确的现象,找出不同现象之间的互动关系。\n伟大的科学作品,尽管最初的假设不免个人偏见,但不会有夸大或宣传。你要注意作者最初的假设,放在心上,然后把他的假设与经过论证之后的结论作个区别。一个越“客观”的科学作者,越会明白地要求你接受这个、接受那个假设。科学的客观不在于没有最初的偏见,而在于坦白承认。\n在科学作品中,主要的词汇通常都是一些不常见的或科技的用语。这些用语很容易找出来,你也可以经由这些用语找到主旨。主旨通常都是很一般性的。科学不是编年史,科学家跟历史学家刚好相反,他们要摆脱时间与地点的限制。他要说的是一般的现象,事物变化的一般规则。\n在阅读科学作品时,似乎有两个主要的难题。一个是有关论述的问题。科学基本上是归纳法,基本的论述也就是经由研究查证,建立出来的一个通则—可能是经由实验所创造出来的一个案例,也可能是长期观察所收集到的一连串案例。还有另外一些论述是运用演绎法来推论的。这样的论述是借着其他已经证明过的理论,再推论出来的。在讲求证据这一点上,科学与哲学其实差异不大。不过归纳法是科学的特质。\n会出现第一个困难的原因是:为了了解科学中归纳法的论点,你就必须了解科学家引以为理论基础的证据。不幸的是,那是很难做到的事。除了手中那本书之外,你仍然一无所知。如果这本书不能启发一个人时,读者只有一个解决办法,就是自己亲身体验以获得必要的特殊经验。他可能要亲眼看到实验的过程,或是去观察与操作书中所提到的相同的实验仪器。他也可能要去博物馆观察标本与模型。\n任何人想要了解科学的历史,除了阅读经典作品外,还要能自己做实验,以熟悉书中所谈到的关系重大的实验。经典实验就跟经典作品一样,如果你能亲眼目睹,亲自动手做出伟大科学家所形容的实验,那也是他获得内心洞察力的来源,那么对于这本科学经典巨著,你就会有更深人的理解。\n这并不是说你一定要依序完成所有的实验才能开始阅读这本书。以拉瓦锡(Lavoisier)的《化学原理》(Elements of Chemistry)为例,这本书出版于1789年,到目前已不再被认为是化学界有用的教科书了,一个高中生如果想要通过化学考试,也绝不会笨到来读这本书。不过在当时他所提出来的方法仍是革命性的,他所构思的化学元素大体上我们仍然沿用至今。因此阅读这本书的重点是:你用不着读完所有的细节才能获得启发。譬如他的前言便强调了科学方法的重要,便深具启发性。拉瓦锡说:\n任何自然科学的分支都要包含三个部分:在这个科学主题中的连续事实,呈现这些事实的想法,以及表达这些事实的语言……因为想法是由语言来保留与沟通的,如果我们没法改进科学的本身,就没法促进科学语言的进步。换个角度来看也一样,我们不可能只改进科学的语言或术语,却不改进科学的本身。这正是拉瓦锡所做的事。他借着改进化学的语言以推展化学,就像牛顿在一个世纪以前将物理的语言系统化、条理化,以促进物理的进步—你可能还记得,在这样的过程中,他发展出微积分学。\n提到微积分使我们想到在阅读科学作品时的第二个困难,那就是数学的问题。\n3.面对数学的问题 # 很多人都很怕数学,认为自己完全无法阅读这样的书。没有人能确定这是什么原因。一些心理学家认为这就像是“符号盲\u0026quot; (Symbleblindness)无法放下对实体的依赖,转而理解在控制之下的符号转换。或许这有点道理,但文字也转换,转换得多少比较更不受控制,甚至也许更难以理解。还有一些人认为问题出在数学的教学上。如果真是如此,我们倒要松口气,因为近来有许多研究已经投注在如何把数学教好这个问题上了。\n其中的部分原因是没有人告诉我们,或是没有早点告诉我们,好让我们深人了解:数学其实是一种语言,我们可以像学习自己的语言一样学习它。在学习自己的语言时,我们要学两次:第一次是学习如何说话,第二次是学习如何阅读。幸运的是,数学只需要学一次,因为它完全是书写的语言。\n我们在前面说过,学习新的书写语言,牵涉到基础阅读的问题。当我们在小学第一次接受阅读指导时,我们的问题在要学习认出每一页中出现的特定符号,还要记得这些符号之间的关系。就算是后来变成阅读高手的人,偶尔还是要用基础阅读来阅读。譬如我们看到一个不认得的字时,还是得去翻字典。如果我们被一个句子的句法搞昏头时,也得从基础的层次来解决。只有当我们解决了这些问题时,我们的阅读能力才能更上层楼。\n数学既然是一种语言,那就拥有自己的字汇、文法与句法(Syntax),初学者一定要学会这些东西。特定的符号或符号之间的关系要记下来。因为数学的语言与我们常用的语言不同,问题也会不同,但从理论上来说,不会难过我们学习英文、法文或德文。事实上,从基础阅读的层次来看,可能还要简单一点。\n任何一种语言都是一种沟通的媒介,借着语言人们能彼此了解共同的主题。一般日常谈话的主题不外是关于情绪上的事情或人际关系。其实,如果是两个不同的人,对于那样的主题彼此未必能完全沟通。但是不同的两个人,撇开情绪性的话题,却可以共同理解与他们无关的第三种事件,像电路、等腰三角形或三段论法。原因是当我们的话题牵涉到情绪时,我们很难理解一些言外之意。数学却能让我们避免这样的问题。只要能适当地运用数学的共识、主旨与等式,就不会有情绪上言外之意的问题。\n除此之外,也没有人告诉我们,至少没有早一点告诉我们,数学是如何优美、如何满足智力的一门学问。如果任何人愿意费点力气来读数学,要领略数学之美永远不嫌晚。你可以从欧几里得开始,他的《几何原理》是所有这类作品中最清晰也最优美的作品。\n让我们以《几何原理》第一册的前五个命题来作说明。(如果你手边有这本书,你该打开来看看。)基本几何学的命题有两种:(1)有关作图问题的叙述。(2)有关几何图形与各相关部分之间的关系的定理。作图的问题必须着手去做,定理的问题就得去证明。在欧几里得作图问题的结尾部分,通常会有Q. E. F. (Quod erat faciendum)的字样,意思是“作图完毕”,而在定理的结尾,你会看到Q. E. D. (Quod eratdemonstrandum)的字样,意思是“证明完毕,,。\n《几何原理》第一册的前三个命题的问题,都是与作图有关的。为什么呢?一个答案是这些作图是为了要证明定理用的。在前四个命题中,我们看不出来,到了第五个,就是定理的部分,我们就可以看出来了。譬如等腰三角形(一个三角形有两个相等的边)的两底角相等,这就需要运用上“命题三”,一条短线取自一条长线的道理。而“命题三”又跟“命题二”的作图有关,“命题二”则跟“命题一”的作图有关,所以为了要证明“命题五”,就必须要先作三个图。\n我们也可以从另外一个目的来看作图的问题。作图很明显地与公设(postulate)相似,两者都声称几何的运作是可以执行出来的。在公设的案例中,这个可能性是假定(assumed)出来的。在命题的案例中,那是要证明(proved)出来的。当然,要这样证明,需要用到公设。因此,举例来说,我们可能会疑惑是否真的有“定义二0”中所定义的等边三角形这回事。但是我们用不着为这些数学物件是否存在而困扰,至少我们可以看到“命题一”所说的:基于有这些直线与圆的假定,自然可以导引出有像等边三角形这样东西的存在了。\n我们再回到“命题五”,有关等腰三角形的内角相同的定理。要达到这个结论,牵涉前面许多命题与公设,并且必须证明本身的命题。这样就可以看出,如果某件事为真(也就是我们有一个等腰三角形的假设),并且如果其他某些附加条件也成立(定义、公设与前面其他的命题),那么另一件事(也就是结论)亦为真。命题所重视的是“若……则”这样的关系。命题要确定的不是假设是否为真,也不是结论是否为真—除非假设为真的时候。而除非命题得到证明,否则我们就无法确认假设和结论的关系是否为真。命题所证明的,纯粹是这种关系是否为真。别无其他。\n说这样的东西是优美的,有夸大其词吗?我们并不这么认为。我们在这里所谈的只是针对一个真正有范围限制的问题.作出真正逻辑的解释。在解释的清晰与问题范围有限制的特质之中,有一种特别的吸引力。在一般的谈话中,就算是非常好的哲学家在讨论,也没法将问题如此这般说得一清二楚。而在哲学问题中,即使用上逻辑的概念,也很难像这样清晰地解说出来。\n关于前面所列举的“命题五”的论点,与最简单的三段论法之间的差异性,我们再作些说明。所谓三段论法就是:\n所有的动物终有一死;\n所有的人都是动物;\n因此,所有的人终有一死。\n这个推论也确实适用于某些事。我们可以把它想成是数学上的推论。假定有动物及人这些东西,再假设动物是会死的。那就可以导引出像前面所说三角形那样确切的结论了。但这里的问题是动物和人是确切存在的,我们是就一些真实存在的东西来假设一些事情。我们一定得用数学上用不着的方法,来检验我们的假设。欧几里得的命题就不担心这一点。他并不在意到底有没有等腰三角形这回事。他说的是,如果有等腰三角形,如果如此定义,那一定可以导引出两个底角相同的结论。你真的用不着怀疑这件事—永远不必。\n4.掌握科学作品中的数学问题 # 关于欧几里得的话题已经有点离题了。我们所关心的是在科学作品中有相当多的数学问题,而这也是一个主要的阅读障碍。关于这一点有几件事要说明如下。\n第一,你至少可以把一些比你想像的基础程度的数学读得更明白。我们已经建议你从欧几里得开始,我们确定你只要花几个晚上把《几何原理》读好,就能克服对数学的恐惧心理。读完欧几里得之后,你可以进一步,看看其他经典级的希腊数学大师的作品—阿基米德(Archimedes) ,阿波罗尼乌斯(Apollonius),尼科马科斯(Nicomachus)。这些书并不真的很难,而且你可以跳着略读。\n这就带人了我们要说的第二个重点。如果你阅读数学书的企图是要了解数学本身,当然你要读数学,从头读到尾—手上还要拿枝笔,这会比阅读任何其他的书还需要在书页空白处写些笔记。但是你的企图可能并非如此,而是只想读一本有数学在内的科学书,这样跳着略读反而是比较聪明的。\n以牛顿的《自然哲学的数学原理》为例,书中包含了很多命题,有作图问题与定理。但你用不着真的每一个都仔细地去读,尤其第一次从头看一遍的时候更是如此。先看定理的说明,再看看结论,掌握一下这是如何证明出来的。读读引理(lemmas)及系理(corollaries)的说明,再读所谓旁注(scholiums)(基本上这是讨论命题与整个问题之间的关系)。这么做了之后,你会看到整本书的全貌,也会发现牛顿是如何架构这个系统的—哪个先哪个后,各个部分又如何密切呼应起来。用这样的方法读这本书,觉得困难就不要看图表(许多读者是这么做的),只挑你感兴趣的内容来看,但要确定没错过牛顿所强调的重点。其中一个重点出现在第三卷的结尾,名称是“宇宙系统”,牛顿称之为一般的旁注,不但总结了前人的重点,也提出了一个物理学上几乎所有后人都会思考的伟大问题。\n牛顿的《光学》(Optics)也是另一部伟大的科学经典作品,你应该也试着读一下。其实书中谈到的数学部分不多,但你一开始看时可能不这么认为,因为书中到处都是图表。其实这些图表只是用来说明牛顿的实验:让阳光穿过一个小洞,射进一个黑暗的房间,用棱镜截取光线,下面放一张白纸,就可以看到光线中各种不同的颜色呈现在纸上。你自己就可以很简单地重复这样的实验,这是做起来很好玩的事,因为色彩很美丽,而且描绘得一清二楚。除了有关这个实验的形容,你还会想读一下有关不同定理或命题的说明,以及三卷书中每卷结尾部分的讨论,牛顿在这里会对他的发现作个总结,并指出其意义。第三卷的结尾尤其出名,在这里牛顿对科学这个行业作了一些说明,很值得一读。\n科学作品中经常会包括数学,主要因为我们前面说过数学精确、清晰与范围限定的特质。有时候你能读懂一些东西,却用不着深人数学的领域,像牛顿的书就是个例子。奇怪的是,就算数学对你来说可怕得不得了,但是一点也没有数学有时造成的麻烦还可能更大呢!譬如在伽利略的《两种新科学》中,这是物质能量与运动的名作,对现代读者来说特别困难,因为基本上这不是数学的书,而是以对话形式来进行的。对话的形式被诸如柏拉图的大师运用在舞台或哲学讨论上,非常适合,运用在科学的讨论上就不太适合了。因此要明白伽利略到底谈的是什么其实是很困难的。不过如果你试着读一下,你会发现他在谈一些革新的创见。\n当然,并不是所有的科学经典作品都用上了数学,或是一定要用数学。像希腊医学之父,希波克拉底(Hippocrates)的作品就没有数学。你可以很容易读完这本书,发现希波克拉底的医学观点—预防胜于治疗的艺术。不幸的是,现代已经不流行这样的想法。威廉·哈维讨论血液循环的问题,或是威廉·吉伯特讨论磁场的问题,都与数学无关。只要你记住,你的责任不是成为这个主题的专家,而是要去了解相关的问题,在阅读时就会轻松许多。\n5.关于科普书的重点 # 从某一方面而言,关于阅读科普书,我们没有什么更多的话要说了。就定义上来说,这些书—不论是书或文章—都是为广泛的大众而写的,而不只是为专家写的。因此,如果你已经读了一些科学的经典名作,这类流行书对你来说就毫无问题了。这是因为这些书虽然与科学有关,但一般来说,读者都已经避免了阅读原创性科学巨著的两个难题。第一,他们只谈论一点相关的实验内容(他们只报告出实验的结果)。第二,内容只包括一点数学(除非是以数学为主的畅销书)。\n科普文章通常比科普书要容易阅读,不过也并非永远如此。有时候这样的文章很好—像《科学美国人》(Scientific American)月刊或更专业的《科学》(Science)周刊。当然,无论这些刊物有多好,编辑有多仔细多负责任,都还是会出现上一章结尾时所谈到的问题。在阅读这些文章时,我们就得靠记者为我们过滤资讯了。如果他们是好的记者,我们就很幸运。如果不是,我们就一无所获。\n阅读科普书绝对比阅读故事书要困难得多。就算是一篇三页没有实验报告,没有图表,也没有数学方程式需要读者去计算的有关DNA的文章,阅读的时候如果你不全神贯注,就是没法理解。因此,在阅读这种作品时所需要的主动性比其他的书还要多。要确认主题。要发现整体与部分之间的关系。要与作者达成共识。要找出主旨与论述。在评估或衡量意义之前,要能完全了解这本书才行。现在这些规则对你来说应该都很熟悉了。但是在这里运用起来更有作用。\n短文通常都是在传递资讯,你阅读的时候用不着太多主动的思考。你要做的只是去了解,明白作者所说的话,除此之外大多数情况就用不着花太大的力气了。至于阅读另外一些很出色的畅销书,像怀特海的《数学人门》(Introduction to Mathematics)、林肯·巴内特(LincolnBarnett)的《宇宙和爱因斯坦博士》、巴瑞·康孟纳(Barry Commoner)的《封闭的循环》(The Closing Circle)等等,需要的则比较多了。康孟纳的书更是如此,他所谈的主题—环保危机—对现代的我们来说都很感兴趣又很重要。他的书写得很密实,需要一直保持注意力。整本书就是一个暗示,仔细的读者不该忽略才对。虽然这不是实用的作品,不是我们在第十三章中所谈到的作品,但是书中的结论对我们的生活有重大影响。书中的主题—环保危机—谈的就是这个。环保问题是我们的问题,如果出现了危机,我们就不得不注意。就算作者没有说明—事实上他说了—我们还是身处在危机中。在面对危机时,(通常)会出现特定的反应,或是停止某种反应。因此康孟纳的书虽然基本上是理论性的,但已经超越了理论,进人实用的领域。\n这并不是说康孟纳的书特别重要,而怀特海或巴内特的书不重要。《宇宙和爱因斯坦博士》写出来之后,像这样一本为一般读者所写,研究原子的历史的理论书,让大家警觉到以刚发明不久的原子弹为主要代表、但不是全部代表的原子物理本质上的严重危机。因此,理论性的书一样会带来实际的结果。就算现代人不注意逐渐逼近的原子或核战争,阅读这类书仍然有实际的需要。因为原子或核物理是我们这个年代最伟大的成就,为我们带来许多美好的承诺,同样也带来许多重大危机。一个有知识、而且有心的读者应该尽可能阅读有关这方面的书籍。\n在怀特海的《数学人门》中,是另一个有点不同的重要讯息。数学是现代几个重要的神秘事物之一。或许,也是最有指标性的一个,在我们社会中占有像古代宗教所占有的地位。如果我们想要了解我们存活的这个年代,我们就该了解一下数学是什么,数学家是如何运用数学,如何思考的。怀特海的作品虽然没有深人讨论这个议题,但对数学的原理却有卓越的见解。如果这本书对你没有其他的作用,至少也对细心的读者显示了数学家并不是魔术师,而是个普通的人。这样的发现,对一个想要超越一时一地的思想与经验,想要扩大自己领域的读者来说尤其重要。\n第十八章 如何阅读哲学书 # 小孩常会问些伟大的问题:“为什么会有人类?”、“猫为什么会那样做?”、“这世界最初名叫什么?”、“上帝创造世界的理由是什么?”这些话从孩子的口中冒出来,就算不是智慧,至少也是在寻找智慧。根据亚里士多德的说法,哲学来自怀疑。那必然是从孩提时代就开始的疑问,只是大多数人的疑惑也就止于孩提时代。\n孩子是天生的发问者。并不是因为他提出的问题很多,而是那些问题的特质,使他与成人有所区别。成人并没有失去好奇心,好奇心似乎是人类的天生特质,但是他们的好奇心在性质上有了转化。他们想要知道事情是否如此,而非为什么如此。但是孩子的问题并不限于百科全书中能解答的问题。\n从托儿所到大学之间,发生了什么事使孩子的问题消失了?或是使孩子变成一个比较呆板的成人,对于事实的真相不再好奇?我们的头脑不再被好问题所刺激,也就不能理解与欣赏最好的答案的价值。要知道答案其实很容易。但是要发展出不断追根究底的心态,提出真正有深度的问题—这又是另一回事了。\n为什么孩子天生就有的心态,我们却要努力去发展呢?在我们成长的过程中,不知是什么原因,成人便失去了孩提时代原本就有的好奇心。或许是因为学校教育使头脑僵化了—死背的学习负荷是主因,尽管其中有大部分或许是必要的。另一个更可能的原因是父母的错。就算有答案,我们也常告诉孩子说没有答案,或是要他们不要再问问题了。碰到那些看来回答不了的问题时,我们觉得困窘,便想用这样的方法掩盖我们的不自在。所有这些都在打击一个孩子的好奇心。他可能会以为问问题是很不礼貌的行为。人类的好问从来没有被扼杀过,但却很快地降格为大部分大学生所提的问题—他们就像接下来要变成的成人一样,只会问一些资讯而已。\n对这个问题我们没有解决方案,当然也不会自以为是,认为我们能告诉你如何回答孩子们所提出来的深刻问题。但是我们要提醒你一件很重要的事,就是最伟大的哲学家所提出来的深刻问题,正是孩子们所提出的问题。能够保留孩子看世界的眼光,又能成熟地了解到保留这些问题的意义,确实是非常稀有的能力—拥有这种能力的人也才可能对我们的思想有重大的贡献。\n我们并不一定要像孩子般地思考,才能了解存在的问题。孩子们其实并不了解,也没法了解这样的问题—就算真有人能了解的话。但是我们一定要能够用赤子之心来看世界,怀疑孩子们怀疑的问题,间他们提出的问题。成人复杂的生活阻碍了寻找真理的途径。伟大的哲学家总能厘清生活中的复杂,看出简单的差别—只要经由他们说明过,原先困难无比的事就变得很简单了。如果我们要学习他们,提问题的时候就一定也要有孩子气的单纯—而回答时却成熟而睿智。\n1.哲学家提出的问题 # 这些哲学家所提出的“孩子气的单纯”问题,到底是些什么问题?我们写下来的时候,这些问题看起来并不简单,因为要回答起来是很困难的。不过,由于这些问题都很根本也很基础,所以乍听之下很简单。\n下面就拿“有”或“存在”这样的问题作例子:存在与不存在的区别在哪里?所有存在事物的共同点是什么?每一种存在事物的特质是什么?事物存在的方法是否各有不同—各有不同的存在形式?是否某些事物只存在心中,或只为了心灵而存在?而存在于心灵之外的其他事物,是否都为我们所知,或是否可知?是否所有存在的事物都是具体的,或是在具体物质之外仍然存在着某些事物?是否所有的事都会改变,还是有什么事是永恒不变的?是否任何事物都有存在的必要?还是我们该说:目前存在的事物不见得从来都存在?是否可能存在的领域要大于实际存在的领域?\n一个哲学家想要探索存在的特质与存在的领域时,这些就是他们会提出来的典型问题。因为是问题,并不难说明或理解,但要回答,却难上加难—事实上困难到即使是近代的哲学家,也无法作出满意的解答。\n哲学家会提的另一组问题不是存在,而是跟改变或形成有关。根据我们的经验,我们会毫不迟疑地指出某些事物是存在的,但是我们也会说所有这些事物都是会改变的。它们存在过,却又消失了。当它们存在时,大多数都会从一个地方移动到另一个地方,其中有许多包括了质与量上的改变:它们会变大或变小,变重或变轻,或是像成熟的苹果与过老的牛排,颜色会有改变。\n改变所牵涉到的是什么呢?在每一个改变的过程中,是否有什么坚持不变的东西?以及这个坚持不变的东西是否有哪些方面还是要遭逢改变?当你在学习以前不懂的东西时,你因为获得了知识而在某方面有了改变,但你还是和以前一样是同一个人。否则,你不可能说因为学习而有所改变了。是否所有的改变都是如此?譬如对于生死这样巨大的改变—也就是存在的来临与消失—是否也是如此?还是只对一些不太重要的改变,像某个地区内的活动、成长或某种质地上的变动来说,才如此?不同的改变到底有多少种?是否所有的改变都有同样的基本要素或条件?是否所有这些因素或条件都会产生作用?我们说造成改变的原因是什么意思呢?在改变中是否有不同的原因呢?造成改变—或变化的原因,跟造成存在的原因是相同的吗?\n哲学家提出这样的问题,就是从注意事物的存在到注意事物的转变,并试着将存在与改变的关系建立起来。再强调一次,这些问题并不难说明及理解,但要回答得清楚又完整却极不容易。从上面两个例子中,你都可以看出来,他们对我们所生活的世界抱持着一种多么孩子气的单纯心态。\n很遗憾,我们没有多余的篇幅继续深人探讨所有这些问题。我们只能列举一些哲学家提出并想要解答的问题。那些伺题不只关于存在或改变,也包括必然性与偶然性,物质与非物质,自然与非自然,自由与不确定性(indeterminacy) ,人类心智的力量与人类知识的本质及范围,以及自由意志的问题。\n就我们用来区别理论与实用领域的词义而言,以上这些问题都是属于思辩性或理论性的问题。但是你知道,哲学并不只限于理论性的问题而已。\n以善与恶为例。孩子特别关心好跟坏之间的差别,如果他们弄错了,可能还会挨打。但是直到我们成人之后,对这两者之间的差异也不会停止关心。在善与恶之间,是否有普遍被认可的区别?无论在任何情况中,是否某些事永远是好的,某些事永远是坏的?或是就像哈姆雷特引用蒙田的话:“没有所谓好跟坏,端看你怎么去想它。”\n当然,善与恶跟对与错并不相同。这两组词句所谈的似乎是两种不同的事。尤其是,就算我们会觉得凡是对的事情就是善的,但我们可能不觉得凡是错的事情就一定是恶的。那么,要如何才能清楚地区分呢?\n“善”是重要的哲学字眼,也是我们日常生活重要的字眼。想要说明善的意义,是一件棘手的事。在你弄清楚以前,你已经深陷哲学的迷思中了。有许多事是善的,或像我们常用的说法,有许多善行。能将这些善行整理出条理来吗?是不是有些善行比另一些更重要?是否有些善行要依赖另一些善行来完成?在某些情况中,是否两种善行会互相抵触,你必须选择一种善行,而放弃另一种?\n同样的,我们没有篇幅再深入讨论这个问题。我们只能在这个实用领域中再列举一些其他问题。有些问题不只是善与恶、对与错或是善行的等级,同时是义务与责任,美德与罪行,幸福与人生的目标,人际关系与社会互动之中的公理及正义,礼仪与个人的关系,美好的社会与公平的政府与合理的经济,战争与和平等问题。\n我们所讨论的两种问题,区分出两种主要不同的哲学领域。第一组,关于存在与变化的问题,与这个世界上存在与发生的事有关。这类问题在哲学领域中属于理论或思辩型的部分。第二组,关于善与恶,好与坏的问题,和我们应该做或探寻的事有关,我们称这是隶属于哲学中实用的部分,更正确来说该是规范(normative)的哲学。一本教你做些什么事的书,像烹饪书,或是教你如何做某件事,像驾驶手册,用不着争论你该不该做个好厨师或好驾驶,他们假设你有意愿要学某件事或做某件事,只要教你如何凭着努力做成功而已。相对的,哲学规范的书基本上关心的是所有人都应该追求的目标—像过好生活,或组织一个好社会—与烹饪书或驾驶手册不同的是,他们就应该运用什么方法来达成目的的这一点上,却仅仅只会提供一些最普遍的共识。\n哲学家提出来的问题,也有助于哲学两大领域中次分类的区分。如果思辩或理论型的哲学主要在探讨存在的问题,那就属于形上学。如果问题与变化有关—关于特质与种类的演变,变化的条件与原因—就是属于自然哲学的。如果主要探讨的是知识的问题—关于我们的认知,人类知识的起因、范围与限制,确定与不确定的问题—那就属于认识论(epistemology)的部分,也称作知识论。就理论与规范哲学的区分而言,如果是关于如何过好生活,个人行为中善与恶的标准,这都与伦理学有关,也就是理论哲学的领域;如果是关于良好的社会,个人与群体之间的行为问题,则是政治学或政治哲学的范畴,也就是规范哲学的领域。\n2.现代哲学与传承 # 为了说明简要,让我们把世上存在及发生了什么事,或人类该做该追求的问题当作“第一顺位问题”。我们要认知这样的问题。然后是“第二顺位问题”:关于我们在第一顺位问题中的知识,我们在回答第一顺位问题时的思考模式,我们如何用语言将思想表达出来等问题。\n区别出第一顺位与第二顺位问题是有帮助的。因为那会帮助我们理解近年来的哲学界发生了什么变化。当前主要的专业哲学家不再相信第一顺位的问题是哲学家可以解决的问题。目前大多数专业哲学家将心力投注在第二顺位的问题上,经常提出来的是如何用言语表达思想的问题。\n往好处想,细部挑剔些总没什么坏处。问题在于今天大家几乎全然放弃了第一顺位的疑问,也就是对门外汉读者来说最可能感兴趣的那些问题。事实上,今天的哲学,就像当前的科学或数学一样,已经不再为门外汉写作了。第二顺位的问题,几乎可以顾名思义,都是些诉求比较窄的问题,而专业的哲学家,就像科学家一样,他们惟一关心的只有其他专家的意见。\n这使得现代哲学作品对一个非哲学家来说格外难读—就像科学书对非科学家来说一样的困难。只要是关于第二顺位的哲学作品,我们都无法指导你如何去阅读。不过,还是有一些你可以读的哲学作品,我们相信也是你该读的书。这些作品提出的问题是我们所说的第一顺位问题。毫无意外的,这些书主要也是为门外汉而写的,而不是专业哲学家写给专业同行看的。\n上溯至1930年或稍晚一点,哲学书是为一般读者而写作的。哲学家希望同行会读他们的书,但也希望一般有知识的读者也能读。因为他们所提的问题,想要回答的问题都是与一般人切身相关的,因此他们认为一般人也该知道他们的思想。\n从柏拉图以降,所有哲学经典巨著,都是从这个观点来写作的。一般门外汉的读者也都能接受这样的书,只要你愿意,你就能读这些书。我们在这一章所说的一切,都是为了鼓励你这么做。\n3.哲学的方法 # 至少就提出与回答第一顺位问题的哲学而言,了解哲学方法的立足点是很重要的。假设你是一个哲学家,你对我们刚才提的那些孩子气的单纯问题感到很头痛—像任何事物存在的特质,或是改变的特质与成因等问题。那你该怎么做?\n如果你的问题是科学的,你会知道要如何回答。你该进行某种特定的研究,或许是发展一种实验,以检验你的回答,或是广泛地观察各种现象以求证。如果你的问题是关于历史的,你会知道也要做一些研究,当然是不同的研究。但是要找出普遍存在的特质,却没有实验方法可循。而要找出改变是什么,事情为什么会改变,既没有特殊的现象可供你观察,更没有文献记载可以寻找阅读。你惟一能做的是思考问题本身,简单来说,哲学就是一种思考,别无他物。\n当然,你并不是在茫然空想。真正好的哲学并不是“纯”思维—脱离现实经验的思考。观念是不能任意拼凑的。回答哲学问题,有严格的检验,以确认答案是否合乎逻辑。但这样的检验纯粹是来自一般的经验—你身而为人就有的经验,而不是哲学家才有的经验。你透过人类共同经验而对“改变”这种现象的了解,并不比任何人差—有关你的一切,都是会改变的。只要改变的经验持续下去,你就可以像个伟大的哲学家一样,思考有关改变的特质与起因。而他们之所以与你不同,就在他们的思想极为缜密:他们能整理出所有可能问到的最尖锐的问题,然后再仔细清楚地找出答案来。他们用什么方法找出答案来呢?不是观察探索,也不是寻找比一般人更多的经验,而是比一般人更深刻地思考这个问题。\n了解这一点还不够。我们还要知道哲学家所提出来与回答的问题,并非全部都是真正哲学的问题。他们自己没法随时觉察到这一点,因而在这一点上的疏忽或错误,常会让洞察力不足的读者倍增困扰。要避免这样的困难,读者必须有能力把哲学家所处理真正哲学性的问题,和他们可能处理,但事实上应该留给后来科学家来寻找答案的其他问题作一区别。哲学家看不出这样的问题可以经由科学研究来解决的时候,就会被误导—当然,在他写作的那个年代,他很可能料想不到有这一天。\n其中一个例子是古代哲学家常会问天体(celestrial bodies)与地体(terrestrial bodies)之间的关系。因为没有望远镜的帮助,在他们看来,天体的改变移动只是位置的移动,从没有像动物或植物一样诞生与消失的问题,而且也不会改变尺寸或性质。因为天体只有一种改变的方式—位置的移动—而地体的改变却是不同的方式,古人便下结论说组成天体的成分必然是不同的。他们没有臆测到,他们也不可能臆测到,在望远镜发明之后,我们会知道天体的可变性远超过我们一般经验所知。因此,过去认为应该由哲学家回答的问题,其实该留到后来由科学家来探索。这样的调查研究是从伽利略用望远镜发现木星的卫星开始的,这引发了后来开普勒(Kepler)发表革命性的宣言:天体的性质与地球上的物体完全一样。而这又成了后来牛顿天体机械理论的基础,在物理宇宙中,各运动定律皆可适用。\n整体来说,除了这些可能会产生的困扰之外,缺乏科学知识的缺点并不影响到哲学经典作品的本身。原因是当我们在阅读一本哲学书时,所感兴趣的是哲学的问题,而不是科学或历史的问题。在这里我们要冒着重复的风险再说一次,我们要强调的是,要回答哲学的问题,除了思考以外,别无他法。如果我们能建造一架望远镜或显微镜,来检验所谓存在的特质,我们当然该这么做,但是不可能有这种工具的。\n我们并不想造成只有哲学家才会犯我们所说的错误的印象。假设有一位科学家为人类该过什么样的生活而困扰。这是个规范哲学的问题,除了思考以外没有别的回答方法。但是科学家可能不了解这一点,而认为某种实验或研究能给他答案。他可能会去问一千个人他们想要过什么样的生活,然后他的答案便是根据这些回答而来的。但是,显然他的答案是毫无意义哟,就像亚里士多德对天体的思考一样是离题的。\n4.哲学的风格 # 虽然哲学的方法只有一种,但是在西方传统中,伟大的哲学家们至少采用过五种论述的风格。研究或阅读哲学的人应该能区别出其间的不同之处,以及各种风格的优劣。\n(1)哲学对话:第一种哲学的论说形式,虽然并不是很有效,但首次出现在柏拉图的《对话录)) (Dialogues)中。这种风格是对话的,甚至口语的,一群人跟苏格拉底讨论一些主题(或是后来一些对话讨论中,是和一个名叫“雅典陌生人”\u0026quot;[the Athenian Stranger]的人来进行的)。通常在一阵忙乱的探索讨论之后,苏格拉底会开始提出一连串的问题,然后针对主题加以说明。在柏拉图这样的大师手中,这样的风格是启发性的,的确能引领读者自己去发现事情。这样的风格再加上苏格拉底的故事的高度戏剧性—或是说高度的喜剧性—就变得极有力量。\n柏拉图却一声不响地做到了。怀特海有一次强调,全部西方哲学,不过是“柏拉图的注脚”。后来的希腊人自己也说:“无论我想到什么,都会碰到柏拉图的影子。”无论如何,不要误会了这些说法。柏拉图自己显然并没有哲学系统或教条—若不是没有教条,我们也没法单纯地保持对话,提出问题。因为柏拉图,以及在他之前的苏格拉底,已经把后来的哲学家认为该讨论的所有重要问题,几乎都整理、提问过了。\n(2)哲学论文或散文:亚里士多德是柏拉图最好的学生,他在柏拉图门下学习了二十年。据说他也写了对话录,却完全没有遗留下来。所遗留下来的是一些针对不同的主题,异常难懂的散文或论文。亚里士多德无疑是个头脑清晰的思想家,但是所存留的作品如此艰涩,让许多学习者认为这些原来只是演讲或书本的笔记—不是他自己的笔记,就是听到大师演讲的学生记录下来的。我们可能永远不知道事情的真相,但是无论如何,亚里士多德的文章是一种哲学的新风格。\n亚里士多德的论文所谈论的主题,所运用的各种不同的叙述方式,都表现出他的研究发现,也有助于后来几个世纪中建立起哲学的分科与方法。关于他的作品,一开始是一些所谓很普及的作品—大部分是对话录,传到今天只剩下一些残缺不全的资料。再来是文献的收集,我们知道其中最重要的是希腊158个城邦的个别宪法。其中只有雅典的宪法存留下来,那是1890年从一卷纸莎草资料中发现的。最后是他主要的论文,像《物理学》、《形上学》(Metaphysics)、《伦理学》、《政治学》与《诗学》。这些都是纯粹的哲学作品,是一些理论或规范。其中有一本《灵魂论》(On the Soul)则是混合了哲学理论与早期的科学研究。其他一些诸如生物论文的作品,则是自然历史中主要的科学著作。\n虽然从哲学的观点来看,康德受到柏拉图的影响很大,但是他采用了亚里士多德的论说方法。与亚里士多德不同的是,康德的作品是精致的艺术。他的书中会先谈到主要问题,然后有条不紊地从方方面面完整地讨论主题,最后,或是顺便再讨论一些特殊的问题。也许,康德与亚里士多德作品的清楚明白,立足于他们处理一个主题的秩序上。我们可以从他们的作品中看到哲学论述的开头、发展与结尾。同时,尤其是在亚里士多德的作品中,我们会看到他提出观点与反对立场。因此,从某个角度来看,论文的形式与对话的形式差不多。但是在康德或亚里士多德的作品中都不再有戏剧化的表现手法,不再像柏拉图是由立场与观点的冲突来表达论说,而是由哲学家直接叙述自己的观点。\n(3)面对异议:中世纪发展的哲学风格,以圣托马斯·阿奎那的《神学大全》为极致,兼有前述两者的风貌。我们说过,哲学中不断提到的问题大部分是柏拉图提出的;我们应该也谈到,苏格拉底在对话过程中问的是那种小孩子才会问的简单又深刻的向题。而亚里士多德,我们也说过,他会指出其他哲学家的不同意见,并作出回应。\n阿奎那的风格,结合了提出问题与面对异议的两种形态。《神学大全》分成几个部分:论文、问题与决议。所有文章的形式都相同。先是提出问题,然后是呈现对立面(错误)的回答,然后演绎一些支持这个错误回答的论述,然后先以权威性的经文(通常摘自《圣经》)来反驳这些论述,最后,阿奎那提出自己的回答或解决方案。开头一句话一定是:“我回答如下”,陈述他自己的观点之后,针对每一个错误回答的论述作出回应。\n对一个头脑清晰的人来说,这样整齐有序的形式是十分吸引人的。但这并不是托马斯式的哲学中最重要的一点。在阿奎那的作品中,最重要的是,他能明确指陈各种冲突,将不同的观点都说明出来,然后再面对所有不同的意见,提出自己的解决方案。从对立与冲突中,让真理逐渐浮现,这是中世纪非常盛行的想法。在阿奎那的时代,哲学家接受这样的方式,事实上是因为他们随时要准备当众,或在公开的论争中为自己的观点作辩护—这些场合通常群聚着学生和其他利害相关的人。中世纪的文化多半以口述方式流传,部分原因可能是当时书籍很少,又很难获得。一个主张要被接受,被当作是真理,就要能接受公开讨论的测试。哲学家不再是孤独的思考者,而是要在智力的市场上(苏格拉底可能会这么说),接受对手的挑战。因此,《神学大全》中便渗透了这种辩论与讨论的精神。\n(4)哲学系统化:在17世纪,第四种哲学论说形式又发展出来了。这是两位著名的哲学家,笛卡尔与斯宾诺莎所发展出来的。他们着迷于数学如何组织出一个人对自然的知识,因此他们想用类似数学组织的方式,将哲学本身整理出来。\n笛卡尔是伟大的数学家,虽然某些观点可能是错的,也是一位值得敬畏的哲学家。基本上,他尝试要做的是为哲学披上数学的外衣—给哲学一些确定的架构组织,就像二千年前,欧几里得为几何学所作的努力。在这方面,笛卡尔并不算完全成功,但是他主张思想要清楚又独立,对照着当时混乱的知识氛围,其影响在相当程度上是不言自明的。他也写一些多少有点传统风格的哲学论文,其中包括一些他对反对意见的回应。\n斯宾诺莎将这样的概念发展到更深的层次。他的《伦理学》(Ethics)是用严格的数学方式来表现的,其中有命题、证明、系理、引理、旁注等等。然而,关于形上学或伦理道德的问题,用数学的方法来解析不能让人十分满意,数学的方法还是比较适合几何或其他的数学问题,而不适合用在哲学问题上。当你阅读斯宾诺莎的时候,可以像你在阅读牛顿的时候那样略过很多地方,在阅读康德或亚里士多德时,你什么也不能略过,因为他们的理论是一直连续下来的。读柏拉图时也不能省略,你漏掉一点就像看一幕戏或读一首诗时,错过了其中一部分,这样整个作品就不完整了。\n或许,我们可以说,遣字用句并没有绝对的规则。问题是,像斯宾诺莎这样用数学的方法来写哲学的作品,是否能达到令人满意的结果?就像伽利略一样,用对话的形式来写科学作品,是否能产生令人满意的科学作品?事实上,这两个人在某种程度上都无法与他们想要沟通的对象作沟通,看起来,这很可能在于他们所选择的沟通形式。\n(5)格言形式:还有另一种哲学论说形式值得一提,只不过没有前面四种那么重要。这就是格言的形式,是由尼采在他的书《查拉图斯特拉如是说))(Thus Spake Zarathustra)中所采用的,一些现代的法国哲学家也运用这样的方式。上个世纪这样的风格之所以受到欢迎,可能是因为西方的读者对东方的哲学作品特别感兴趣,而那些作品就多是用格言的形式写作的。这样的形式可能也来自帕斯卡尔的《沉思录》(Pensees)。当然,帕斯卡尔并不想让自己的作品就以这样简短如谜的句子面世,但是在他想要以文章形式写出来之前,他就已经去世了。\n用格言的形式来解说哲学,最大的好处在于有启发性。这会给读者一个印象,就像在这些简短的句子中还有言外之意,他必须自己运用思考来理解—他要能够自己找出各种陈述之间的关联,以及不同论辩的立足点。同样地,这样的形式也有很大的缺点,因为这样的形式完全没法论说。作者就像个撞了就跑的司机,他碰触到一个主题,谈到有关的真理与洞见,然后就跑到另一个主题上,却并没有为自己所说的话作适当的辩解。因此,格言的形式对喜欢诗词的人来说是很有意思的,但对严肃的哲学家来说却是很头痛的,因为他们希望能跟随着作者的思想,对他作出评论。\n到目前为止,我们知道在西方的文化传统中,没有其他重要的哲学形式了。(像卢克莱修的《物性论》[On the Nature of Things]并不是特例,这本书原是以韵文写作,但是风格发展下去,跟其他的哲学论文又差不多了。不管怎么说,今天我们读到的一般都是翻译成散文的版本。)也就是说,所有伟大的哲学作品都不出这五种写作形式,当然,有时哲学家会尝试一种以上的写作方式。不论过去或现在,哲学论文或散文都可能是最普遍的形式,从最高超最困难的作品,像康德的书,到最普遍的哲学论文都包括在其中。对话形式是出了名的难写,而几何形式是既难读又难写。格言形式对哲学家来说是绝对不能满意的。而托马斯形式则是现代较少采用的一种方式。或许这也是现代读者不喜欢的一种方式,只是很可惜这样的方式却有很多的好处。\n5.阅读哲学的提示 # 到目前为止,读者应该很清楚在阅读任何哲学作品时,最重要的就是要发现问题,或是找到书中想要回答的问题。这些问题可能详细说明出来了,也可能隐藏在其中。不管是哪一种,你都要试着找出来。\n作者会如何回答这些问题,完全受他的中心思想与原则的控制。在这一方面作者可能也会说明出来,但不一定每本书都如此。我们前面已经引述过巴兹尔·威利的话,要找出作者隐藏起来、并未言明的假设,是多么困难—也多么重要的—事情。这适用于每一种作品。运用在哲学书上尤其有力。\n伟大的哲学作品不至于不诚实地隐藏起他们的假设,或是提出含混不清的定义或假定。一位哲学家之所以伟大,就是因为他能比其他的作者解说得更淋漓尽致。此外,伟大的哲学家在他的作品背后,都有自己特定的中心思想与原则。你可以很容易就看出他是否清楚地写在你读的那本书里。但是他也可能不这么做,保留起来在下一本书里再说明白。也可能他永远都不会明讲,但是在每本书里都有点到。\n这样的中心思想的原则,很难举例说明。我们所举出的例子可能会引起哲学家的抗议,我们在这里也没有多余的空间能为自己的选择作辩解。然而,我们可以指出柏拉图一个中心思想的原则是什么—他认为,有关哲学主题的对话,可能是人类所有活动中最重要的一个活动。在柏拉图的各种对话中,几乎看不到他明讲这种观点—只有《自辩篇》(Apology)中苏格拉底讲过没有反省的生活是不值得活下去的生活,以及柏拉图在《第七封信》(Seventh Letter)中提到过。重点是,柏拉图在许多其他地方都提到这样的观点,虽然使用的字数不多。譬如在《诡辩篇》(Protagoras)中,诡辩者罗普罗泰格拉斯不愿意继续跟苏格拉底谈话时,旁边的听众就表现出很不满意的样子。另一个例子是在《理想国》第一卷,克法洛斯刚好有事要办,便离去了。虽然并没有详尽的说明,但柏拉图想要说的似乎是:一个人不论是为了任何理由而拒绝参与追求真理,都是人性最深沉的背叛。但是,就像我们强调过的,一般人并不会把这一点当作柏拉图的一个“观念”,因为在他的作品中,几乎从没有明白地讨论过这一点。\n我们可以在亚里士多德中找到其他的例子。在阅读亚里士多德的书时,一开始就要注意到一件重要的事:在他所有作品中,所讨论的问题都是彼此相关的。他在《工具论》(Organon)中详细说明的逻辑基本原则,在《物理学》中却是他的假设。其次,由于部分原因归之于这些论文都是未完成的工作,因此他中心思想的原则也就没法到处都很清楚地说明出来。《伦理学》谈到很多事:幸福、习惯、美德、喜悦等等—可以写上一长串。但是只有最细心的读者才能看出他所领悟的原则是什么。这个领悟就是幸福是善的完整(whole of the good),而不是最高的(highest)善,因为如果是那样,那就只有一种善了。认知到这一点,我们可以看出幸福并不是在追求自我完美或自我改进的善,虽然这些在一些部分的善中是最高的。幸福,如亚里士多德所言,是一个完整生命的品质。他所说的“完整”不只是从一时的观点来看,也是从整体生命的所有角度来看的。因而我们现在或许可以说,一个幸福的人,是具现了生命的完整,而且一生都保持这种完整的人。这一点几乎影响到《伦理学》中所有其它想法与观点的中心思想,但是在书中却并没有怎么明白说明。\n再举个例子。康德的成熟思想通常被认为是批判的哲学。他自己将“批判主义”与“教条主义”作了比较,把过去许多哲学家归类为后者。他所谓的“教条主义”,就是认为只要凭着思考,用不着考虑本身的局限性,人类的知性就可以掌握最重要的真理。照康德的看法,人类的第一要务就是要严格地检查并评估心智的资源与力量。因此,人类心智的局限就是康德中心思想的原则,在他之前没有任何一位哲学家这样说过。在《纯粹理性批判》中,这个概念被清楚地解说出来了。但是在康德主要的美学著作《批判力批判(Critique o f Judgment)中,却没有说明出来,而只是假设如此。然而,不管怎么说,在那本书里,这还是他的中心思想原则。\n关于由哲学作品中找出中心思想的原则,我们能说的就是这些,因为我们不确定能否告诉你如何找到这样的中心思想。有时候那需要花上许多年的时间,阅读很多书,然后又重新阅读过,才能找到。对一个思虑周详的好读者来说,这是一个理想的目标,毕竟,你要记得,如果你想要了解你的作者,这还是你必需要做的事。尽管要找出中心思想的原则很困难,但是我们仍然不主张你走捷径,去阅读一些关于哲学家生活或观察点的书。你自己找到的原则,会比其他人的观点还更有价值。\n一旦你找到作者中心思想的原则后,你就会想要看作者怎能将这样的概念在整本书中贯彻到底。遗憾的是,哲学家们,就算是最好的哲学家,通常也做不到这一点。爱默生说过,一贯性“是小智小慧的骗人伎俩\u0026quot;(hobgoblin of little minds)。虽然我们也该记住这个非常轻松的说法,但也不该忘了,哲学家前后不一致是个非常严重的问题。如果哲学家前后说法不一,你就要判断他所说的两个想法中哪一个才是真的—他在前面说的原则,还是最后没有从原则中导引出来的结论?或许你会决定两者都不可信。\n阅读哲学作品有些特点,这些特点和哲学与科学的差异有关。我们这里所谈的哲学只是理论性作品,如形上学的论述或关于自然哲学的书。\n哲学问题是要去解说事物的本质,而不像科学作品要的是描述事物的本质。哲学所询问的不只是现象之间的联系,更要追寻潜藏在其中的最终原因与条件。要回答这些问题,只有清楚的论述与分析,才能让我们感到满意。\n因此,读者最要花力气的就是作者的词义与基本主旨。虽然哲学家跟科学家一样,有一些专门的技术用语,但他们表达思想的词句通常来自日常用语,只是用在很特殊的意义上。读者需要特别注意这一点。如果他不能克服自己,总是想将一个熟悉的字看作一般意义的想法,最后他会让整本书变成胡说八道又毫无意义。\n哲学讨论的基本词义就像科学作品一样,当然是抽象的。其实,任何具有共通性的知识,除了抽象的词义外,无从表达。抽象并没什么特别难的。我们每天都在运用,也在各谈话中运用这些抽象词义。不过,似乎很多人都为“抽象”或“具体”的用词而感到困扰。\n每当你一般性地谈到什么事情,你就使用抽象的字眼。你经由感官察觉到的永远是具体与个别的,而你脑中所想的永远是抽象又普遍的。要了解一个“抽象的字眼”,就要掌握这个字眼所表达的概念。所谓你对某件事“有了概念”,也就是你对自己具体经验到的某些事情的普遍性层面有了了解。你不能看到,碰触到,甚或想像到这里所谓的普遍性层面。如果你做得到,那么感官与思想就毫无差别了。人们总想想像出是什么概念在困扰他们,最后却会对所有抽象的东西感到绝望。\n在阅读科学作品时,归纳性的论证是读者特别需要注意的地方。在哲学作品中也是一样,你一定要很注意哲学家的原则。这很可能是一些他希望你跟他一起接受的假设,也可能是一些他所谓的自明之理。假设的本身没有问题。但就算你有自己相反的假设,也不妨看看他的假设会如何导引下去。假装相信一些其实你并不相信的事,是很好的心智训练。当你越清楚自己的偏见时,你就越不会误判别人的偏见了。\n另外有一种原则可能会引起困扰。哲学作品几乎没有不陈述一些作者认为不证自明的主旨。这种主旨都直接来自经验,而不是由其他主旨证明而来。\n要记住的是,我们前面已经提过不只一次,这些来自哲学本身的经验,与科学家的特殊经验不同,是人类共同的经验。哲学家并没有在实验室中工作,也不做田野研究调查。因此要了解并测验一位哲学家的主要原则,你用不着借重经由方法调查而获得的特殊经验,这种额外的助力。他诉求的是你自己的普通常识,以及对你自己所生存的这个世界的日常观察。\n换句话说,你在阅读哲学书时要用的方法,就跟作者在写作时用的方法是一样的。哲学家在面对问题时,除了思考以外,什么也不能做。读者在面对一本哲学书时,除了阅读以外,什么也不能做—那也就是说,要运用你的思考。除了思考本身外,没有任何其他的帮助。\n这种存在于读者与一本书之间的必要的孤独,是我们在长篇大论讨论分析阅读时,一开始就想像到的。因此你可以知道,为什么我们在叙述并说明阅读的规则、认为这些规则用在哲学书上的时候,会比其他书来得更适用。\n6.厘清你的思绪 # 一本好的哲学理论的书,就像是好的科学论文,不会有滔滔雄辩或宣传八股的文字。你用不着担心作者的“人格”问题,也不必探究他的社会或经济背景。不过,找一些周详探讨过这个问题的其他伟大的哲学家的作品来读,对你来说会有很实际的帮助。在思想的历史上,这些哲学家彼此之间已经进行了长久的对话。在你确认自己能明白其中任何一人在说些什么之前,最好能仔细倾听。\n哲学家彼此意见往往不合这一点,不应该是你的困扰。这有两个原因。第一,如果这些不同的意见一直存在,可能就指出一个没有解决,或不能解决的大问题。知道真正的奥秘所在是件好事。第二,哲学家意见合不合其实并不重要,你的责任只是要厘清自己的思路。就哲学家透过他们的作品而进行的长程对话,你一定要能判断什么成立,什么不成立才行。如果你把一本哲学书读懂了—意思是也读懂了其他讨论相同主题的书—你就可以有评论的立场了。\n的确,哲学问题的最大特色就在每个人必须为自己回答这些间题。采用别人的观点并没有解决这些问题,只是在逃避问题而已。你的回答一定要很实在,而且还要有理论根据。总之,这跟科学研究不同,你无法依据专家的证词来回答。\n原因是,哲学家所提出的问题,比其他任何人所提的问题都简单而重要。孩子除外。\n7.关于神学的重点 # 神学有两种类型,自然神学(natural theology)与教义神学(dogmatic theoloev)。自然神学是哲学的一支,也是形而上学的最后一部分。譬如你提出一个问题,因果关系是否永无止境?每件事是否都有起因?如果你的答案是肯定的,你可能会陷入一种永无止境的循环当中。因此,你可能要设定某个不因任何事物而发生的原始起因的别称。亚里士多德称这种没有起因的原因是“不动的原动者”(unmoved mover)。你可以另外命名—甚至可以说那不过是上帝的别称—但是重点在,你要透过不需要外力支援的—自然进行的—思考,达成这番认知。\n教义神学与哲学则不同,因为教义神学的首要原则就是某个宗教的教徒所信奉的经文。教义神学永远依赖教义与宣扬教义的宗教权威人士。\n如果你没有这样的信仰,也不属于某个教派,想要把教义神学的书读好,你就得拿出读数学的精神来读。但是你得永远记住,在有关信仰的文章中,信仰不是一种假设。对有信仰的人来说,那是一种确定的知识,而不是一种实验性的观点。\n今天许多读者了解这一点似乎很困难。一般来说,在面对教义神学的书时,他们会犯一两个错。第一个错是拒绝接受—即使是暂时的接受—作者首要原则的经文。结果,读者一直跟这些首要原则挣扎,根本注意不到书的本身。第二个错是认为,既然整本书的首要原则是教义的,依据这些教义而来的论述,这些教义所支持的推论,以及所导引出来的结论,都必然也都是属于教义的。当然,如果我们接受某些原则,立足于这些原则的推论也能令人信服,那么我们就必须接受这样所得出的结论—至少在那些原则的范围内如此。但是如果推论是有问题的,那么原来再可以接受的首要原则,也会导出无效的结论。\n谈到这里,你该明白一个没有信仰的读者要阅读神学书时有多困难了。在阅读这样的书时,他要做的就是接受首要原则是成立的,然后用阅读任何一本好的论说性作品都该有的精神来阅读。至于一个有信仰的读者在阅读与自己信仰有关的书籍时,要面对的则是另一些困难了。这些问题并不只限于阅读神学才出现。\n8.如何阅读“经书” # 有一种很有趣的书,一种阅读方式,是我们还没提到的。我们用“经书\u0026quot;(canonical)来称呼这种书,如果传统一点,我们可能会称作“圣\u0026quot;(sacred)或“神书”(holy)。但是今天这样的称呼除了在某些这类书上还用得着之外,已经不适用于所有这类书籍了。\n一个最基本的例子就是《圣经》。这本书不是被当作文学作品来读,而是被当作神的话语来读。\n经书的范围不只这些明显的例子。任何一个机构—教会、政党或社会—在其他的功能之外,如果(1)有教育的功能,(2)有一套要教育的课本(a body of doctrine to teach),(3)有一群虔诚又顺服的成员,那么属于这类组织的成员在阅读的时候都会必恭必敬。他们不会—也不能—质疑这些对他们而言就是“经书”的书籍的权威与正确的阅读方法。信仰使得这些信徒根本不会发现“神圣的”经书中的错误,更别提要找出其中道理不通的地方。\n正统的犹太人是以这样的态度来阅读《旧约》的。基督徒则是这样阅读《新约》。回教徒是这样读《古兰经》。马克思主义信徒则是这样阅读马克思或列宁的作品,有时看政治气候的转变,也会这样读斯大林的作品。弗洛伊德心理学的信徒就是这样读弗洛伊德的。美国的陆军军官是这样读步兵手册的。你自己也可以想出更多的例子。\n事实上,对大多数人来说,就算没有严重到那个程度,在阅读某些必须要当作经典的作品时,也是抱着这种心态来读的。一位准律师为了通过律师考试,一定要用虔敬的心来阅读某些特定的教材,才能在考试中赢得高分。对医生或其他专业人士来说也都是如此。事实上,对大多数人来说,还在学生时代时,我们都会依照教授的说法,“虔诚地”阅读教科书。(当然,并不是所有的教授都会把跟他唱反调的学生判为不及格!)\n这种阅读的特质,我们或许可以用“正统”两个字来概括。这两个字几乎是放诸四海皆准的,在英文中,“正统”(orthodox)原始的字根来自希腊文,意思是“正确观点”。这类作品是一本或惟一的一本正确的读物,阅读任何其他的作品都会带来危机,从考试失去高分到灵魂遭天谴都有可能。这样的特质是有义务性的。一个忠诚的读者在阅读经书时,有义务要从中找到意义,并能从其他的“事实”中举证其真实性。如果他自己不能这么做,他就有义务去找能做到的人。这个人可能是牧师或祭司,或是党派中的上级指导者,或是他的教授。在任何状况中,他都必须接受对方提供给他的解决之道。他的阅读基本上是没有自由可言的。相对地,他也会获得阅读其他书所没有的一种满足感当作回报。\n其实我们该停止了。阅读《圣经》的问题—如果你相信那是神的话语—是阅读领域中最困难的一个问题。有关如何阅读《圣经》的书,加起来比所有其他指导阅读的书的总和还多。所谓上帝的话语,是人类所能阅读的作品中最困难的一种,而如果你真的相信那是上帝的话语,对你来说也是最重要的一种。信徒阅读这本书要付出的努力和难度成正比。至少在欧洲的传统中,《圣经》是一本有多重意义的书。在所有的书籍中,那不只是读者最广泛,同时也是被最仔细地阅读的一本书。\n第十九章如何阅读社会科学 # 社会科学的观念与术语几乎渗透了所有我们今天在阅读的作品中。\n譬如像现代的新闻记者,不再限定自己只报导事实。只有在报纸头版出现,简短的“谁—发生了什么事—为什么发生—何时何地发生”新闻提要,才是以事实为主。一般来说,记者都会将事实加上诠释、评论、分析,再成为新闻报导。这些诠释与评论都是来自社会科学的观念与术语。\n这些观念与术语也影响到当代许多书籍与文章,甚至可以用社会评论来作一个归类。我们也看到许多文学作品是以这类的主题来写作的:种族问题、犯罪、执法、贫穷、教育、福利、战争与和平、好政府与坏政府。这类文学作品便是向社会科学借用了思想意识与语言。\n社会科学作品并不只限定于非小说类。仍然有一大批重要的当代作家所写的是社会科学的小说。他们的目标是创立一个人造的社会模型,能够让我们在科技的发展之下,检验出社会受到的影响。在小说、戏剧、故事、电影、电视中,对社会的权力组织、各种财富与所有权、财富的分配都作了淋漓尽致的描绘、谴责与赞扬。这些作品被认为有社会意义,或是包含了“重要的讯息”。在这同时,他们取得也散播了社会科学的元素。\n此外,无论是任何社会、经济或政治的问题,几乎全都有专家在作研究。这些专家不是自己作研究,就是由直接面对这些问题的官方单位邀请来做。在社会科学专家的协助下,这些问题有系统地阐释出来,并要想办法解决这些问题。\n社会科学的成长与普及,最重要的因素是在高中与大专教育中引进了社会科学。事实上,选修社会科学课程的学生,远比选修传统文学或语言课程的学生还要多很多。而选修社会科学的学生也远超过选修“纯”科学的学生。\n1.什么是社会科学? # 我们在谈论社会科学时,好像是在谈一个完全独立的学科。事实上并非如此。\n究竟社会科学是什么呢?有一个方法可以找出答案,就是去看看大学中将哪些学科与训练课程安排在这样的科系之下。社会科学的部门中通常包括了人类学、经济学、政治学与社会学。为什么没有包括法律、教育、商业、社会服务与公共行政呢?所有这些学科也都是运用社会科学的概念与方法才发展出来的啊?对于这个问题,最常见的回答是:后面这些学科的目的,在于训练大学校园以外的专业工作者,而前面所提的那些学科却是比较专注于追求人类社会的系统知识,通常是在大学校园中进行的。\n目前各个大学都有建立跨科系的研究中心或机构的趋势。这些研究中心超越传统社会科学与专业科系的界限,同时针对许多理论与方法的研究,其中包括了统计学、人口学、选举学(关于选举与投票的科学)、政策与决策制定、人事训练管理、公共行政、人类生态学,以及其他等等。这些中心产生的研究与报告,往往结合了十多种以上的专业。光是要辨认这许多种专业努力的结果就已经够复杂了,更别提还要判断这些发现与结论是否成立。\n那么心理学呢?一些划分严格的社会科学家会将心理学排除在社会科学之外,因为他们认为心理学所谈的是个人的特质问题,而社会科学关心的却是文化、制度与环境因素。一些区分比较没那么严格的学者,则认为生理心理学应该归类为生物科学,而不论是正常或变态心理学则该隶属于社会科学,因为个人与社会整体是不可分割的。\n附带一提的是,在现在的社会科学课程中,心理学是最受学生欢迎的一门课。如果全国统计起来,选修心理学的学生可能比任何其他课系的学生都要多。有关心理学的著作,从最专业到最普遍的都出版了许多。\n那么行为科学呢?他们在社会科学中担任什么样的角色?依照原始的用法,行为科学中包括了社会学、人类学、行为生物学、经济学、地理学、法律、心理学、精神病学与政治科学。行为科学特别强调对可观察、可测量的行为作系统化的研究,以获得可被证实的发现。近年来,行为科学几乎跟社会科学变成同义词了,但许多讲究传统的人反对这样的用法。\n最后要谈的是,历史呢?大家都知道,社会科学引用历史的研究,是为了取得资料,并为他们的推论作例证。然而,虽然历史在叙述特殊事件与人物时,在知识的架构上勉强称得上科学,但是就历史本身对人类行为与发展模式及规则所提供的系统知识而言,却称不上科学。\n那么,我们能给社会科学下个定义吗?我们认为可以,至少就这一章的目的来说可以。诸如人类学、经济学、政治学、社会学的学科,都是组成社会科学的核心,几乎所有的社会科学家都会将这些学科归纳进来。此外,我们相信大部分社会科学家应该会认为,即使不是全部,但大部分有关法律、教育、公共行政的作品,及一部分商业、社会服务的作品,再加上大量的心理学作品,也都适合社会科学的定义。我们推测这样的定义虽然并不精密,但你可以明白接下来我们要说的了。\n2.阅读社会科学的容易处 # 绝大部分社会科学看起来都像是非常容易阅读的作品。这些作品的内容通常取材自读者所熟悉的经验—在这方面,社会科学就跟诗与哲学一样—论说的方式也经常是叙述式的,这对读过小说与历史的读者来说都很熟悉。\n此外,我们都已经很熟悉社会科学的术语,而且经常在使用。诸如文化(比较文化、反文化、次文化)、集团、疏离、地位、输入/输出、下层结构、伦理、行为、共识等很多这样的术语,几乎是现代人交谈与阅读时经常会出现的字眼。\n想想“社会”,这是一个多么变色龙的词,前面不知可以加上多少形容词,但它总是在表达一种人民群居生活,而非离群索居的广阔定义。我们听到过失序的社会、不健全的社会、沉默的社会、贪婪的社会、富裕的社会……,我们可以从英文字典中第一个字母找起,最后找到“发酵的”(zymotic)社会这样的形容词—这是指持续动荡的社会,就跟我们所处的社会一样。\n我们还可以把“社会”看作是形容词,同样有许多熟悉的意义。像社会力量、社会压力、社会承诺,当然还有无所不在的社会问题。在阅读或写作社会科学时,最后一种是特别容易出现的题材。我们敢打赌,如果不是在最近几周,也是在最近的几个月内,你总可能读过,甚至写过有关“政治、经济与社会问题”的文章。当你阅读或写作时,你可能很清楚政治与经济问题所代表的意义,但是你,或是作者所说的社会问题,到底指的是什么呢?\n社会学家在写作时所用的术语及隐喻,加上字里行间充满深刻的情感,让我们误以为这是很容易阅读的。书中所引用的资料对读者来说是很熟悉的,的确,那是他们天天读到或听到的字眼。此外,读者的态度与感觉也都跟着这些问题的发展紧密联系在一起。哲学问题所谈论的也是我们一般知道的事情,但是通常我们不会“投人”哲学问题中。不过对于社会科学所讨论的问题,我们都会有很强烈的意见。\n3.阅读社会科学的困难处 # 说来矛盾,我们前面所说的让社会科学看来很容易阅读的因素,却也是让社会科学不容易阅读的因素。譬如我们前面所提到的最后一个因素—你身为一个读者,要对作者的观点投人一些看法。许多读者担心,如果承认自己与作者意见不合,而且客观地质疑自己阅读的作品,是一种对自己投人不忠的行为。但是,只要你是用分析阅读来阅读,这样的态度是必要的。我们所谈的阅读规则中已经指出了这样的态度,至少在做大纲架构及诠释作品的规则中指出过。如果你要回答阅读任何作品都该提出的头两个问题,你一定要先检查一下你自己的意见是什么。如果你拒绝倾听一位作者所说的话,你就无法了解这本书了。\n社会科学中熟悉的术语及观点,同时也造成了理解上的障碍。许多社会科学家自己很清楚这个问题。他们非常反对在一般新闻报导或其他类型的写作中,任意引用社会科学的术语及观点。譬如国民生产总值(GNP Gross National Product)这个概念,在严肃的经济作品中,这个概念有特定限制的用法。但是,一些社会科学家说,许多记者及专栏作者让这个概念承担了太多的责任。他们用得太浮滥,却完全不知道真正的意义是什么。显然,如果在你阅读的作品中,作者将一个自己都不太清楚的词句当作是关键字,那你一定也会跟着摸不着头脑的。\n让我们把这个观点再说明清楚一点。我们要先把社会科学与自然科学—物理、化学等—区分出来。我们已经知道,科学作品(指的是后面那种“科学”)的作者会把假设与证明说得十分清楚,同时也确定读者很容易与他达成共识,并找到书中的主旨。因为在阅读任何论说性作品时,与作者达成共识并找到主旨是最重要的一部分,科学家的作法等于是帮你做了这部分的工作。不过你还是会发现用数学形式表现的作品很难阅读,如果你没法牢牢掌握住论述、实验,以及对结论的观察基础,你会发现很难对这本书下评论—也就是回答“这是真实的吗?”“这本书与我何干?”的问题。然而,有一点很重要的是,阅读科学作品要比阅读任何其他论说性作品都来得容易。\n换句话说,自然科学的作者必须做的是“把他的用语规定出来”—这也就是说,他告诉你,在他的论述中有哪些基本的词义,而他会如何运用。这样的说明通常会出现在书的一开头,可能是解释、假设、公理等等。既然说明用语是这个领域中的特质,因此有人说它们像是一种游戏,或是有“游戏的架构”。说明用语就像是一种游戏规则。如果你想打扑克牌,你不会争论三张相同的牌,是否比两对的牌要厉害之类的游戏规则。如果你要玩桥牌,你也不会为皇后可以吃杰克(同一种花色),或是最高的王牌可以吃任何一张牌(在定约桥牌中)这样的规则而与人争辩。同样地,在阅读自然科学的作品时,你也不会与作者争辩他的使用规则。你接受这些规则,开始阅读。\n直到最近,在自然科学中已经很普遍的用语说明,在社会科学中却仍然不太普遍。其中一个理由是,社会科学并不能数学化,另一个理由是在社会或行为科学中,要说明用语比较困难。为一个圆或等腰三角形下定义是一回事,而为经济萧条或心理健康下定义又是另一回事。就算一个社会科学家想要为这样的词义下定义,他的读者也会想质疑他的用法是否正确。结果,社会科学家只好在整本书中为自己的词义挣扎不已—他的挣扎也带给读者阅读上的困难。\n阅读社会科学作品最困难的地方在于:事实上,在这个领域中的作品是混杂的,而不是纯粹的论说性作品。我们已经知道历史是如何混杂了虚构与科学,以及我们阅读时要如何把这件事谨记在心。对于这种混杂,我们已经很熟悉,也有大量的相关经验。但在社会科学的状况却完全不同。太多社会科学的作品混杂了科学、哲学与历史,甚至为了加强效果,通常还会带点虚构的色彩。\n如果社会科学只有一种混杂法,我们也会很熟悉,因为历史就是如此。但是实际上并非如此。在社会科学中,每一本书的混杂方式都不同,读者在阅读时必须先确定他在阅读的书中混杂了哪些因素。这些因素可能在同一本书中就有所变动,也可能在不同的书中有所变动。要区分清楚这一切,并不容易。\n你还记得分析阅读的第一个步骤是回答这个问题:这是本什么样的书?如果是小说,这个问题相当容易回答。如果是科学或哲学作品,也不难。就算是形式混杂的历史,一般来说读者也会知道自己在读的是历史。但是组成社会科学的不同要素—有时是这种,有时是那种,有时又是另一种模式—使我们在阅读任何有关社会科学的作品时,很难回答这个问题。事实上,这就跟要给社会科学下定义是同样困难的事。\n不过,分析阅读的读者还是得想办法回答这个问题。这不只是他要做的第一件工作,也是最重要的工作。如果他能够说出他所阅读的这本书是由哪些要素组成的,他就能更进一步理解这本书了。\n要将一本社会科学的书列出纲要架构不是什么大问题,但是要与作者达成共识,就像我们所说的,这可是极为困难的事。原因就在于作者无法将自己的用语规则说明清楚。不过,还是可以对关键字有些概括性的了解。从词义看到主旨与论述,如果是本好书,这些仍然都不是问题。但是最后一个问题:这与我何干?就需要读者有点自制力了。这时,我们前面提过的一种情况就可能发生—读者可能会说:“我找不出作者的缺点,但是我就是不同意他的看法。”当然,这是因为读者对作者的企图与结论已经有偏见了。\n4.阅读社会科学作品 # 在这一章里,我们说过很多次“社会科学作品”,却没说过“社会科学书”。这是因为在阅读社会科学时,关于一个主题通常要读好几本书,而不会只读一本书。这不只是因为社会科学是个新领域,只有少数经典作品,还因为我们在阅读社会科学时,主要的着眼点在一个特殊的事件或问题上,而非一个特殊的作者或一本书。譬如我们对强制执行法感兴趣,我们会同时读上好几本相关的书。或许我们关心的是种族、教育、税收与地方政府的问题,这也是同样的状况。基本上,在这些领域中,并没有什么权威的著作,因此我们必须读很多本相关的书。而社会科学家本身也有一个现象,就是为了要能跟得上时代,他们必须不断地推陈出新,重新修订他们的作品,新作品取代旧作品,过时的论述也不断被淘汰了。\n在某个程度上说,如我们所看到的,哲学也会发生同样的状况。要完全了解一位哲学家,你应该阅读这位哲学家自己在阅读的书,以及影响他的其他哲学家的书。在某种程度上,历史也是如此。我们提到过,如果你想要发现过去的事实,你最好多读几本书,而不是只读一本书。不过在这些情况中,你找到一本主要的、权威的著作的可能,是相当大的。社会科学中却并非如此,因此在阅读这类书时更需要同时阅读许多相关书籍了。\n分析阅读的规则并不适用于就一个主题同时阅读很多本书的情况。分析阅读适用于阅读个别的书籍。当然,如果你想要善用这些规则,就要仔细地研究观察。接下来要介绍的新的阅读规则,则需要我们通过第三层次的阅读(分析阅读),才能进人这第四层次的阅读(主题阅读)。我们现在就准备要讨论第四层次的阅读。因为社会科学作品有这样的特质,所以必须要用这样的阅读。\n指出这一点,就可以说明为什么我们会把社会科学的问题放在本书第三篇的最后来讨论。现在你应该明白为什么我们会这样整理我们的讨论。一开始我们谈的是如何阅读实用性作品,这与其他的阅读完全不同,因为读者有特定的义务,也就是如果他同意作者的观点,就要采取行动。然后我们讨论小说与诗,提出和阅读论说性作品不同的问题。最后,我们讨论的是三种理论性的论说作品—科学与数学、哲学、社会科学。社会科学放在最后,是因为这样的书需要用上主题阅读。因此这一章可说是第三篇的结尾,也是第四篇的引言。\n"},{"id":135,"href":"/zh/docs/culture/%E5%A6%82%E4%BD%95%E9%98%85%E8%AF%BB%E4%B8%80%E6%9C%AC%E4%B9%A6/%E7%AC%AC%E4%BA%8C%E7%AF%87_%E9%98%85%E8%AF%BB%E7%9A%84%E7%AC%AC%E4%B8%89%E4%B8%AA%E5%B1%82%E6%AC%A1_%E5%88%86%E6%9E%90%E9%98%85%E8%AF%BB/","title":"第二篇 阅读的第三个层次:分析阅读","section":"如何阅读一本书","content":"第二篇 阅读的第三个层次:分析阅读\n第六章 一本书的分类 # 在本书的一开头,我们就已经说过了,这些阅读的规则适用于任何你必须读或想要读的读物。然而,在说明分析阅读,也就是这第二篇的内容中,我们却似乎要忽略这个原则。我们所谈的阅读,就算不全是,也经常只是指“书”而言。为什么呢?\n答案很简单。阅读一整本书,特别是又长又难读的一本书,要面对的是一般读者很难想像,极为艰困的问题。阅读一篇短篇故事,总比读一本小说来得容易。阅读一篇文章,总比读一整本同一个主题的书籍来得轻松。但是如果你能读一本史诗或小说,你就能读一篇抒情诗或短篇故事。如果你能读一本理论的书—一本历史、哲学论述或科学理论—你就可以读同一个领域中的一篇文章或摘要。\n因此,我们现在要说的阅读技巧,也可以应用在其他类型的读物上。你要了解的是,当我们提到读书的时候,所说明的阅读规则也同样适用于其他比较易于阅读的资料。虽然这些规则程度不尽相当,应用在后者身上时,有时候作用不尽相同,但是只要你拥有这些技巧,懂得应用,总可以比较轻松。\n1.书籍分类的重要性 # 分析阅读的第一个规则可以这么说:规则一,你一定要知道自己在读的是哪一类书,而且要越早知道越好。最好早在你开始阅读之前就先知道。\n譬如,你一定要知道,在读的到底是虚构的作品—小说、戏剧、史诗、抒情诗—还是某种论说性的书籍?几乎每个读者在看到一本虚构的小说时都会认出来,所以就会认为要分辨这些并不困难—其实不然。像《波特诺的牢骚》(Portnoy\u0026rsquo;s、Complaint),是小说还是心理分析的论著?《裸体午宴)(Naked Lunch)是小说,还是反对药物泛滥的劝导手册,像那些描述酒精的可怕,以帮助读者戒酒之类的书?《飘》(Gone With The Wind)是爱情小说,还是美国内战时期的南方历史?《大街》(Main Street)与《愤怒的葡萄))(The Grapes o f Wrath),一本都会经验,一本农村生活,到底是纯文学,还是社会学的论著?\n当然,这些书都是小说,在畅销书排行榜上,都是排在小说类的。但是问这些问题并不荒谬。光是凭书名,像《大街》或《米德尔顿》,很难猜出其中写的是小说,还是社会科学论述。在当代的许多小说中,有太多社会科学的观点,而社会科学的论著中也有很多小说的影子,实在很难将二者区别开来。但是还有另一些科学—譬如物理及化学—出现在像是科幻小说《安珠玛特病毒》(The Andromeda Strain),或是罗伯特·海莱因(Robert Heinlein)、亚瑟·克拉克(Arthur C. Clarke)的书中。而像《宇宙与爱因斯坦博士》(The Universe and Dr. Einstein)这本书,明明不是小说,却几乎跟有“可读性”的小说一模一样。或许就像福克纳(William Faulkner)所说的,这样的书比其他的小说还更有可读性。\n一本论说性的书的主要目的是在传达知识。“知识”在这样的书中被广泛地解说着。任何一本书,如果主要的内容是由一些观点、理论、假设、推断所组成,并且作者多少表示了这些主张是有根据的,有道理的,那这种传达知识的书,就是一本论说性(expository)的书。就跟小说一样,大多数人看到论说性的书也一眼就能辨识出来。然而,就像要分辨小说与非小说很困难一样,要区别出如此多样化的论说性书籍也并非易事。我们要知道的不只是哪一类的书带给我们指导,还要知道是用什么方法指导。历史类的书与哲学类的书,所提供的知识与启发方式就截然不同。在物理学或伦理学上,处理同一个问题的方法可能也不尽相同。更别提各个不同作者在处理这么多不同问题时所应用的各种不同方法了。\n因此,分析阅读的第一个规则,虽然适用于所有的书籍,却特别适合用来阅读非小说,论说性的书。你要如何运用这个规则呢?尤其是这个规则的最后那句话?\n之前我们已经建议过,一开始时,你要先检视这本书—用检视阅读先浏览一遍。你读读书名、副标题、目录,然后最少要看看作者的序言、摘要介绍及索引。如果这本书有书衣,要看看出版者的宣传文案。这些都是作者在向你传递讯号,让你知道风朝哪个方向吹。如果你不肯停、看、听,那也不是他的错。\n2.从一本书的书名中你能学到什么 # 对于作者所提出的讯号视而不见的读者,比你想像中还要多得多。我们跟学生在一起,就已经一再感觉如此了。我们问他们这本书在说些什么?我们要他们用最简单的通常用语,告诉我们这本书是哪一类的书。这是很好的,也是要开始讨论一本书几乎必要的方式。但是,我们的问题,却总是很难得到任何答案。\n我们举一两个这种让人困扰的例子吧!1859年,达尔文(CharlesDarwin)出版了一本很有名的书。一个世纪之后,所有的英语国家都在庆贺这本书的诞生。这本书引起无止境的争论,不论是从中学到一点东西,还是没学到多少东西的评论者,一致肯定其影响力。这本书谈论的是人类的进化,书名中有个“种\u0026quot;(species)字。到底这个书名在说些什么?\n或许你会说那是《物种起源))(The Origin of Species),这样说你就对了。但是你也可能不会这样说,你可能会说那是《人种起源》(TheOrigin of the Species).最近我们问了一些年纪在25岁左右,受过良好教育的年轻人,到底达尔文写的是哪一本书,结果有一半以上的人说是《人种起源》。出这样的错是很明显的,他们可能从来没有读过那本书,只是猜想那是一本谈论人类种族起源的书。事实上,这本书跟这个主题只有一点点关联,甚至与此毫无关系。达尔文是在后来才又写了一本与此有关的书《人类始祖》(The Descent of Man)。《物种起源》,就像书名所说的一样,书中谈的是自然世界中,大量的植物、动物一开始是从少量的族群繁衍出来的,因此他声明了“物竞天择”的原理。我们会指出这个普遍的错误,是因为许多人以为他们知道这本书的书名,而事实上只有少之又少的人真的用心读过书名,也想过其中的含意。\n再举一个例子。在这个例子中,我们不要你记住书名,但去想想其中的含意。吉朋写了一本很有名的书,而且还出名地长,是一本有关罗马帝国的书,他称这本书为《罗马帝国衰亡史》。几乎每个人拿到那本书都会认得这个书名,还有很多人即使没看到书,也知道这个书名。事实上,“衰亡”已经变成一个家喻户晓的用语了。虽然如此,当我们问到同样一批二十五岁左右,受过良好教育的年轻人,为什么第一章要叫做:《安东尼时代的帝国版图与武力》时,他们却毫无头绪。他们并没有看出整本书的书名既然叫作“衰亡史”,叙事者当然就应该从罗马帝国极盛时期开始写,一直到帝国衰亡为止。他们无意识地将“衰亡”两个字转换成“兴亡”了。他们很困惑于书中并没有提到罗马共和国,那个在安东尼之前一个半世纪就结束的时代。如果他们将标题看清楚一点,就算以前不知道,他们也可以推断安东尼时代就是罗马帝国的巅峰时期。阅读书名,换句话说,可以让阅读者在开始阅读之前,获得一些基本的资讯。但是他们不这么做,甚至更多人连不太熟悉的书也不肯看一下书名。\n许多人会忽略书名或序言的原因之一是,他们认为要将手边阅读的这本书做分类是毫无必要的。他们并没有跟着分析阅读的第一个规则走。如果他们试着跟随这个规则,那就会很感激作者的帮忙。显然,作者认为,让读者知道他在写的是哪一类的书是很重要的。这也是为什么他会花那么多精神,不怕麻烦地在前言中做说明,通常也试着想要让他的书名—至少副标题—是让人能理解的。因此,爱因斯坦与英费尔德(Infeld)在他们所写的《物理之演进(The Evolution o fPhysics)一书的前言中告诉读者,他们写的是一本“科学的书,虽然很受欢迎,但却不能用读小说的方法来读”。他们还列出内容的分析表,提醒读者进一步了解他们概念中的细节。总之,列在一本书前面那些章节的标题,可以进一步放大书名的意义。\n如果读者忽略了这一切,却答不出“这是一本什么样的书”的问题,那他只该责怪自己了。事实上,他只会变得越来越困惑。如果他不能回答这个问题,如果他从没问过自己这个问题,他根本就不可能回答随之而来的,关于这本书的其他问题。\n阅读书名很重要,但还不够。除非你能在心中有一个分类的标准,否则世上再清楚的书名,再详尽的目录、前言,对你也没什么帮助。\n如果你不知道心理学与几何学都是科学,或者,如果你不知道这两本书书名上的“原理”与“原则”是大致相同的意思(虽然一般而言不尽相同),你就不知道欧几里得(Euclid)的《几何原理》(Elements of Geometry)与威廉·詹姆斯(William James)的《心理学原理》(Principlesof Psychology)是属于同一种类的书—此外,除非你知道这两本书是不同类型的科学,否则就也无法进一步区分其间的差异性。相同的,以亚里士多德的《政治学))(The Politics)与亚当·斯密的《国富论》为例,除非你了解一个现实的问题是什么,以及到底有多少不同的现实问题,否则你就无法说出这两本书相似与相异之处。\n书名有时会让书籍的分类变得比较容易一些。任何人都会知道欧几里得的《几何原理》、笛卡尔的《几何学》(Geometry)与希尔伯特(HilBert)的《几何基础)(Foundations of Geometry)都是数学的书,彼此多少和同一个主题相关。但这不是百试百中。光是从书名,也可能并不容易看出奥古斯丁的《上帝之城》(The City of God)、霍布斯的《利维坦》(Leviathan)与卢梭的《社会契约论》(Social Contract)都是政治的论述—虽然,如果仔细地阅读这三本书的章名,会发现它们都想探讨的一些共同问题。\n再强调一次,光是将书籍分类到某一个种类中还是不够的。要跟随第一个阅读步骤,你一定要知道这个种类的书到底是在谈些什么?书名不会告诉你,前言等等也不会说明,有时甚至整本书都说不清楚,只有当你自己心中有一个分类的标准,你才能做明智的判断。换句话说,如果你想简单明白地运用这个规则,那就必须先使这个规则更简单明白一些。只有当你在不同的书籍之间能找出区别,并且定出一些合理又经得起时间考验的分类时,这个规则才会更简单明白一些。\n我们已经粗略地谈过书籍的分类了。我们说过,主要的分类法,一种是虚构的小说类,另一种是传达知识,说明性的论说类。在论说性的书籍中,我们可以更进一步将历史从哲学中分类出来,也可以将这二者从科学与数学中区分出来。\n到目前为止,我们都说得很清楚。这是一个相当清楚的书籍分类法,大多数人只要想一想这个分类法,就能把大多数书都做出适当的分类了。但是,并不是所有的书都可以。\n问题在于我们还没有一个分类的原则。在接下来更高层次的阅读中,我们会谈更多有关分类的原则。现在,我们要确定的是一个基本的分类原则,这个原则适用于所有的论说性作品。这也就是用来区分理论性与实用性作品的原则。\n3.实用性VS.理论性作品 # 所有的人都会使用“实用”跟“理论”这两个字眼,但并不是每个人都说得出到底是什么意思—像那种既现实又坚决的人,当然就更如此,他们最不信任的就是理论家,特别是政府里的理论家。对这样的人来说,“理论”意味着空想或不可思议,而“实用”代表着某种有效的东西,可以立即换成金钱回来。这里面确实有一些道理。实用是与某种有效的做法有关,不管是立即或长程的功效。而理论所关注的却是去明白或了解某件事。如果我们仔细想想这里所提出来的粗略的道理,就会明白知识与行动之间的区别,正是作者心目中可能有的两种不同的概念。\n但是,你可能会问,我们在看论说性的作品时,不就是在接受知识的传递吗?这样怎么会有行动可言?答案是,当然有,明智的行动就是来自知识。知识可以用在许多方面,不只是控制自然,发明有用的机器或工具,还可以指导人类的行为,在多种技术领域中校正人类的运作技巧。这里我们要举的例子是纯科学与应用科学的区别,或是像通常非常粗糙的那种说法,也就是科学与科技之间的区别。\n有些书或有些老师,只对他们要传达的知识本身感兴趣。这并不是说他们否定知识的实用性,或是他们坚持只该为知识而知识。他们只是将自己限制在某一种沟通或教学方式中,而让其他人去用别的方式。其他这些人的兴趣则在追求知识本身以外的事上,他们关切的是哪些知识能帮忙解决的人生问题。他们也传递知识,但永远带着一种强调知识的实际应用的观点。\n要让知识变成实用,就要有操作的规则。我们一定要超越“知道这是怎么回事”,进而明白“如果我们想做些什么,应该怎么利用它”。概括来说,这也就是知与行的区别。理论性的作品是在教你这是什么,实用性的作品在教你如何去做你想要做的事,或你认为应该做的事。\n本书是实用的书,而不是理论的书。任何一本指南类的书都是实用的。任何一本书告诉你要该做什么,或如何去做,都是实用的书。因此,你可以看出来,所有说明某种艺术的学习技巧,任何一个领域的实用手册,像是工程、医药或烹饪,或所有便于分类为“教导性”(moral)的深奥论述,如经济、伦理或政治问题的书,都是实用的书。我们在后面会说明为什么这类书,一般称作“规范性”(normative)的书,会在实用类的书中作一个很特别的归类。\n或许没有人会质疑我们将艺术的学习技巧,或实用手册、规则之类的书归类为论说性的书籍。但是我们前面提过的那种现实型的人,可能会反对我们将伦理,或经济类的书也归类为实用的书。他会说那样的书并不实用,因为书中所说的并没有道理,或者行不通。\n事实上,就算一本经济的书没有道理,是本坏书,也不影响这一点。严格来说,任何一本教我们如何生活,该做什么,不该做什么,同时说明做了会有什么奖赏,不做会有什么惩罚的伦理的书,不论我们是否同意他的结论,都得认定这是一本实用的书。(有些现代的社会学研究只提供人类的行为观察,而不加以批判,既非伦理也无关实用,那就是理论型的书—科学作品。)\n在经济学中也有同样的状况。经济行为的研究报告,数据分析研究,这类工作是理论性的,而非实用的。除此之外,一些通常教导我们如何认知经济生活环境(个别的或社会整体的),教导我们该做不该做的事,如果不做会有什么惩罚等,则是实用的书。再强调一次,我们可能不同意作者的说法,但是我们的不同意,并不能将这类书改变为非实用的书。\n康德写了两本有名的哲学著作,一本是《纯粹理性批判》(The Critique of Pure Reason),另一本是《实践理性批判》(The Critique ofPractical Reason)。第一本是关于知,我们何以知(不是指如何知,而是我们为何就是知),以及什么是我们能知与不能知的事。这是一本精彩绝伦的理论性书籍。《实践理性批判》则是关于一个人应该如何自我管理,而哪些是对的、有道德的品行。这本书特别强调责任是所有正确行为的基础,而他所强调的正是现代许多读者所唾弃的想法。他们甚至会说,如果相信责任在今天仍然是有用的道德观念,那是“不实际的”想法。当然,他们的意思是,从他们看来,康德的基本企图就是错误的。但是从我们的定义来看,这并不有损于这是一本实用的书。\n除了实用手册与(广义的)道德论述之外,另一种实用型的作品也要提一下。任何一种演说,不论是政治演说或道德规劝,都是想告诉你该做些什么,或你该对什么事有什么样的反应。任何人就任何一个题目写得十分实用的时候,都不只是想要给你一些建议,而且还想说服你跟随他的建议。因此在每一种道德论述的文字中,都包含了雄辩或规劝的成分。这样的状况也出现在教导某种艺术的书本中,如本书便是。因此,除了想要教你如何读得更好之外,我们试着,也将一直继续尝试说服你作这样的努力。\n虽然实用的书都是滔滔雄辩又忠告勉励,但是滔滔雄辩又忠告勉励的书却不见得都实用。政治演说与政治论文大有不同,而经济宣传文告与经济问题的分析也大有出人。《共产党宣言》(The Communist Manifesto)是一篇滔滔雄辩,但马克思的《资本论》(Capital)却远不止于此。\n有时你可以从书名中看出一本书是不是实用的。如果标题有“……的技巧”或“如何……”之类的字眼,你就可以马上归类。如果书名的领域你知道是实用的,像是伦理或政治,工程或商业,还有一些经济、法律、医学的书,你都可以相当容易地归类。\n书名有时能告诉你的资讯还不止于此。洛克(John Locke)写了两本书名很相近的书:《论人类悟性》(An Essay Concerning Human Understanding) 及《论文明政府的起源、扩张与终点》(A Treatise Concerning the Origin, Extent,and End of Civil Government),哪一本是理论的,哪一本又是实用的书呢?\n从书名,我们可以推论说第一本是理论的书,因为任何分析讨论的书都是理论的书,第二本则是实用的书,因为政府的问题就是他们的实际问题。但是运用我们所建议的检视阅读,一个人可以超越书名来作判断。洛克为《论人类悟性》写了一篇前言介绍,说明他企图探索的是“人类知识的起源、真理与极限”,和另一本书的前言很相似,却有一个重要的不同点。在第一本书中,洛克关心的是知识的确实性或有效性,另一本书所关心的却是政府的终点或目的。质疑某件事的有效性是理论,而质疑任何事的目的,却是实用。\n在说明检视阅读的艺术时,我们提醒过你在读完前言或索引之后,不要停下来,要看看书中的重点摘要部分。此外也要看看这本书的开头跟结尾,以及主要的内容。\n有时候,从书名或前言等还是无法分辨出一本书的类型时,就很必要从一本书的主要内容来观察。这时候,你得倚赖在主体内文中所能发现的蛛丝马迹。只要注意内容的文字,同时将分类的基本条件放在心中,你不必读太多就应该能区分出这是哪一类的书了。\n一本实用的书会很快就显露它的特质,因为它经常会出现“应该”和“应当”、“好”和“坏”、“结果”和“意义”之类的字眼。实用书所用到的典型陈述,是某件事应该做完(或做到);这样做(或制造)某个东西是对的;这样做会比那样做的结果好;这样选择要比那样好,等等。相反的,理论型的作品却常常说“是”,没有“应该”或“应当”之类的字眼。那是在表示某件事是真实的,这些就是事实,不会说怎样换一个样子更好,或者按照这个方法会让事情变得更好等等。\n在谈论有关理论性书籍的话题之前,让我们先提醒你,那些问题并不像你分辨该喝咖啡或牛奶那样简单。我们只不过提供了一些线索,让你能开始分辨。等你对理论与实用的书之区别懂得越多,你就越能运用这些线索了。\n首先,你要学习去怀疑一切。在书籍分类上,你要有怀疑心。我们强调过经济学的书基本上通常是实用性的书,但仍然有些经济学的书是纯理论的。同样的,虽然谈理解力的书基本上通常是理论性的书,仍然有些书(大部分都很恐怖)却要教你“如何思想”。你也会发现很多作者分不清理论与实用的区别,就像一个小说家搞不清楚什么是虚构故事,什么是社会学。你也会发现一本书有一部分是这一类,另一部分却是别一类,斯宾诺莎的《伦理学》(Ethics)就是这样。然而,这些都在提醒你身为一个读者的优势,透过这个优势,你可以发现作者是如何面对他要处理的问题。\n4.理论性作品的分类 # 照传统的分法,理论性的作品会被分类为历史、科学和哲学等等。所有的人都约略知道其间的差异性。但是,如果你要作更仔细的划分与更精确的区隔时,困难就来了。此刻,我们先避过这样的危险,作一个大略的说明吧。\n以历史书来说,秘诀就在书名。如果书名中没有出现“历史”两个字,其他的前言等等也会告诉我们这本书所缺的东西是发生在过去—不一定是远古时代,当然,也很可能是发生在昨天的事。历史的本质就是口述的故事,历史是某个特殊事件的知识,不只存在于过去,而且还历经时代的不同有一连串的演变。历史家在描述历史时,通常会带有个人色彩—个人的评论、观察或意见。\n历史就是纪事(Chronotopic)。在希腊文中,chronos的意思是时间,topos的意思是地点。历史就是在处理一些发生在特定时间,特定地点的真实事件。“纪事”这两个字就是要提醒你这一点。\n科学则不会太在意过去的事,它所面对的是可能发生在任何时间、地点的事。科学家寻求的是定律或通则。他要知道在所有的情况或大多的情况中,事情是如何发生的,而不像历史学家要知道为什么某个特定的事件,会发生在过去某个特定的时间与地点。\n科学类的书名所透露的讯息,通常比历史类的书要少。有时会出现“科学”两个字,但大部分出现的是心理学、几何学或物理学之类的字眼。我们必须要知道这本书所谈论的主题是哪一类的,像几何学当然就是科学,而形上学就是哲学的。问题在很多内容并不是一清二楚的,在很多时候,许多科学家与哲学家都将物理学与心理学纳入自己研究的范围。碰到“哲学”与“科学”这两个词时,麻烦就会出现了,因为他们已经被运用得太广泛了。亚里士多德称自己的作品《物理学》(Physics)是科学论述,但如果以目前的用法,我们该归类为哲学类。牛顿将自己伟大的作品定名为《自然哲学的数学原理》(Mathematical Princ-ples of Natural Philosophy),而我们却认为是科学上的伟大著作。\n哲学比较像科学,不像历史,追求的是一般的真理,而非发生在过去的特定事件,不管那个过去是近代或较远的年代。但是哲学家所提出的问题跟科学家又不一样,解决问题的方法也不相同。\n既然书名或前言之类的东西并不能帮助我们确定一本书是哲学或科学的书,那我们该怎么办?有一个判断依据我们认为永远有效,不过你可能要把一本书的内容读了相当多之后才能应用。如果一本理论的书所强调的内容,超乎你日常、例行、正常生活的经验,那就是科学的书。否则就是一本哲学的书。\n这样的区别可能会让你很惊讶。让我们说明一下。(记住,这只适用于科学或哲学的书,而不适用于其他类型的书。)伽利略的《两种新科学》(Two New Sciences)要你发挥想像力,或在实验室中以斜面重复某种实验。牛顿的《光学》(Opticks)则提到以棱镜、镜面与特殊控制的光线,在暗室中做实验。这些作者所提到的特殊经验,可能并不是他们自己真的在实验室中完成的。达尔文所写的《物种起源》是他自己经过多年实地观察才得到的报告。虽然这些事实可以,也已经由其他的观察家在作过同样的努力之后所证实,但却不是一般人在日常生活中所能查证的。\n相对的,哲学家所提出来的事实或观察,不会超越一般人的生活经验。一个哲学家对读者所提及的事,都是自己正常及普通的经验,以证明或支持他所说的话。因此,洛克的《论人类悟性》是心理学中的哲学作品。而弗洛伊德的作品却是科学的。洛克所讨论的重点都来自我们生活中所体验的心路历程,而弗洛伊德提出的却是报告他在精神分析诊所中所观察到的临床经验。\n另一个伟大的心理学家,威廉·詹姆斯,采取的是有趣的中间路线。他提出许多细节,只有受过训练的细心的专家才会注意到,但他也常向读者查证,由他们自己的经验来看,他的理论是否正确。所以詹姆斯的作品《心理学原理》是科学也是哲学的,虽然基本上仍然以科学为主。\n如果我们说科学家是以实验为基础,或仰赖精确的观察研究,而哲学家只是坐在摇椅上的思考者,大部分人都能接受这样的差异比较,不会有什么意见。这种对比的说法,应该不致令人不快。确实有某些问题,非常重要的问题,一个懂得如何利用人类共通经验来思考的人,可以坐在摇椅上就想出解决的方案。也有些其他的问题,却绝不是坐在摇椅中思考就能解决的。要解决那样的问题必须要作研究调查—在实验室中作实验或作实地考察—要超越一般例行的生活经验才行。在这样的情况中,特殊的经验是必要的。\n这并不是说哲学家就是纯粹的思考者,而科学家只是个观察者。他们都同样需要思考与观察,只是他们会就不同的观察结果来思考。不论他们如何获得自己想要证明的结论,他们证明的方法就是各不相同:科学家会从他特殊经验的结果作举证,哲学家却会以人类的共通性作例证。\n哲学或科学的书中,经常会出现这种方法的差异性,而这也会让你明白你在读的是什么样的书。如果你能把书中所提到的经验类别当作了解内容的条件,那么你就会明白这本书是哲学或科学的作品了。\n明白这一点是很重要的。因为哲学家与科学家除了所依赖的经验不同之外,他们思考的方式也并不全然相同。他们论证问题的方式也不同。你一定要有能力在这些不同种类的论证中,看得出是哪些关键的词目或命题构成了其间的差异—这里我们谈得有点远了。\n在历史书方面的状况也类似。历史学家的说法跟科学家、哲学家也不相同。历史学家论证的方式不同,说明事实的方式也不一样。何况典型的历史书都是以说故事的形态出现。不管说的是事实或小说,说故事就是说故事。历史学家的文词必须要优美动人,也就是说他要遵守说一个好故事的规则。因此,无论洛克的《论人类悟性》或牛顿的《自然哲学的数学原理》有多杰出伟大,却都不是很好的故事书。\n你可能会抗议我们对书籍的分类谈得太琐碎了,至少,对一个还没开始读的人来说太多了。这些事真的有那么重要吗?\n为了要消除你的抗议,我们要请你想一件事情。如果你走进一间教室,老师正在讲课或指导学生,你会很快地发现这间教室是在上历史、科学或哲学课。这跟老师讲课的方式有关,他使用的词句,讨论的方式,提出的问题,期望学生作出的答案,都会表现出他隶属的是哪个学科。如果你想继续很明白地听下去,先了解这一点是很重要的。\n简单来说,不同的课程有不同的教法,任何一个老师都知道这一点。因为课程与教法的不同,哲学老师会觉得以前没有被其他哲学老师教过的学生比较好教,而科学老师却会希望学生已经被其他科学老师有所训练过。诸如此类。\n就像不同的学科有不同的教法一样,不同的课程也有不同的学习方法。学生对老师的教法,多少要有一些相对的回应。书本与阅读者之间的关系,跟老师和学生之间的关系是相同的。因此,既然书本所要传达给我们的知识不同,对我们的指导方式也会不同。如果我们要跟随这些书本的指导,那就应该学习以适当的态度来阅读不同的书。\n第七章 透视一本书 # 每一本书的封面之下都有一套自己的骨架。作为一个分析阅读的读者,你的责任就是要找出这个骨架。\n一本书出现在你面前时,肌肉包着骨头,衣服裹着肌肉,可说是盛装而来。你用不着揭开它的外衣,或是撕去它的肌肉,才能得到在柔软表皮下的那套骨架。但是你一定要用一双X光般的透视眼来看这本书,因为那是你了解一本书,掌握其骨架的基础。\n知道掌握一本书的架构是绝对需要的,这能带引你发现阅读任何一本书的第二及第三个规则。我们说的是“任何一本书”。这些规则适用于诗集,也适用于科学书籍,或任何一种论说性作品。当然,根据书本的不同,这些规则在应用时会各不相同。一本小说和一本政治论述的书,整体结构不同,组成的篇章不同,次序也不同。但是,任何一本值得读的书,都会有一个整体性与组织架构。否则这本书会显得乱七八糟,根本没法阅读。而烂书就是如此。\n我们会尽量简单地叙述这两个规则。然后我们会加以说明及解释。\n分析阅读的第二个规则是:使用一个单一的句子,或最多几句话(一小段文字)来叙述整本书的内容。\n这就是说你要尽量简短地说出整本书的内容是什么。说出整本书在干什么,跟说出这本书是什么类型是不同的(这在规则一已经说明过了)。“干什么”这个字眼可能会引起误解。从某一方面来说,每一本书都有一个“干什么”的主题,整本书就是针对这个主题而展开。如果你知道了,就明白了这是什么样的书。但“干什么”还有另一个层面的意思,就是更口语化的意义。我们会问一个人是干什么的,他想做什么等等。所以,我们也可以揣测一个作者想要干什么,想要做什么。找出一本书在干什么,也就是在发现这本书的主题或重点。\n一本书是一个艺术作品。(我们又要提醒你了,不要将“艺术”想得太狭隘。我们不想、也不只是在强调“纯艺术”。一本书是一个有特别技巧的人所做的成品,他创作的就是书,而其中一本我们正在这里受益。)就一本书就是一件艺术品的立场来说,书除了要外观的精致之外,相对应地,还要有更接近完美、更具有渗透力的整体内容。这个道理适用于音乐或美术,小说或戏剧,传递知识的书当然也不例外。\n对于“整体内容”这件事,光是一个模糊的认知是不够的,你必须要确切清楚地了解才行。只有一个方法能知道你是否成功了。你必须能用几句话,告诉你自己,或别人,这整本书在说的是什么。(如果你要说的话太多,表示你还没有将整体的内容看清楚,而只是看到了多样的内容。)不要满足于“感觉上的整体”,自己却说不出口。如果一个阅读者说:“我知道这本书在谈什么,但是我说不出来。”应该是连自己也骗不过的。\n第三个规则可以说成是:将书中重要篇章列举出来,说明它们如何按照顺序组成一个整体的架构。\n这个规则的理由很明显。如果一个艺术作品绝对简单,当然可能没有任何组成部分。但这从来就不可能存在。人类所知的物质,或人类的产品中,没有一样是绝对简单的。所有的东西都是复杂的组合体。当你看一个整体组成复杂的东西的时候,如果只看出它“怎样呈现一体”的面貌,那是还没有掌握精髓,你还必须要明白它“怎样呈现多个”的面貌—但不是各自为政,互不相干的“多个”,而是互相融合成有机体的“多个”。如果组成的各个部分之间没有有机的关联,一定不会形成一个整体。说得严格一点,根本不会有整体,只是一个集合体而已。\n这就像是一堆砖头,跟一栋由砖头建造起来的房子是有区别的。而一栋单一的房子,与一整组的房子也不相同。一本书就像一栋单一的房子。那是一栋大厦,拥有许多房间,每层楼也都有房间,有不同的尺寸与形状,不同的外观,不同的用途。这些房间是独立的,分离的。每个房间都有自己的架构与装湟设计,但却不是完全独立与分离的。这些房间是用门、拱门、走廊、楼梯串连起来的,也就是建筑师所谓的“动线\u0026quot;(traffic pattern)架构。因为这些架构是彼此连结的,因此每个部分在整体的使用功能上都要贡献出一己的力量。否则,这栋房子便是不适于居住的。\n这样的比喻简直是接近完美了。一本好书,就像一栋好房子,每个部分都要很有秩序地排列起来。每个重要部分都要有一定的独立性。就像我们r看到的,每个单一部分有自己的室内架构,装湟的方式也可能跟其他部分不同。但是却一定要跟其他部分连接起来—这是与功能相关—否则这个部分便无法对整体的智能架构作出任何贡献了。\n就像一栋房子多少可以居住一样,一本书多少也可以阅读一下。可读性最高的作品是作者达到了建筑学上最完整的整体架构。最好的书都有最睿智的架构。虽然他们通常比一些差一点的书要复杂一些,但他们的复杂也是一种单纯,因为他们的各个部分都组织得更完善,也更统一。\n这也是为什么最好的书,也是可读性最高的书的理由之一。比较次级的作品,在阅读时真的会有一些比较多的困扰。但是要读好这些书—就它们原本所值得的程度读好—你就要从中找出它们的规划,当初如果这些作者自己把规划弄得更清楚一些,这些书都可能再更好一些。但只要大致还可以,只要内容不仅是集合体,还够得上是某种程度的整体组合,那其中就必然有一个架构规划,而你一定要找出来才行。\n1.结构与规划:叙述整本书的大意 # 让我们回到第二个规则,也就是要你说出整本书的大意。对这个规则的运用再作一些说明,或许能帮助你确实用上这个技巧。\n让我们从最出名的一个例子来说吧!你在学校大概听过荷马的《奥德赛))(Odyssey)。就算没有,你一定也听过奥德赛—或尤利西斯,罗马人这么叫他—的故事。这个男人在特洛伊围城之战之后,花了十年时间才回到家乡,却发现忠心的妻子佩尼洛普被一些追求者包围着。就像荷马所说的,这是一个精致而复杂的故事,充满了兴奋刺激的海上、陆上冒险,有各种不同的插曲与复杂的情节。但整个故事仍然是一个整体,一个主要的情节牵扯着所有的事情连结在一起。\n亚里士多德在他的《诗学》(Poetics)中,坚称这是非常好的故事、小说或戏剧的典范。为了支持他的观点,他说他可以用几句话将《奥德赛》的精华摘要出来:\n某个男人离家多年。海神嫉妒他,让他一路尝尽孤独和悲伤。在这同时,他的家乡也濒临险境。一些企图染指他妻子的人尽情挥霍他的财富,对付他的儿子。最后在暴风雨中,他回来了,他让少数几个人认出他,然后亲手攻击那些居心不良的人,摧毁了他们之后,一切又重新回到他手中。\n“这个,”亚里士多德说,“就是情节的真正主干,其他的都是插曲。”\n你用这样的方式来了解一个故事之后,透过整体调性统一的叙述,就能将不同的情节部分放人正确的位置了。你可能会发现这是很好的练习,可以用来重新看你以前看过的小说。找一些好书来看,像是菲尔丁(Fielding)的《汤姆琼斯》 (Tome Jones)、陀思妥耶夫斯基(Dostoevsky)的《罪与罚)(Crime and Punishment)或乔伊斯(Joyce)的现代版《尤利西斯》(Ulysses)等。以《汤姆琼斯》的情节为例,可以简化为一个熟悉的公式:男孩遇到女孩,男孩失掉女孩,男孩又得到女孩。这真的是每一个罗曼史的情节。认清这一点,也就是要明白,为什么所有的故事情节不过那几个的道理。同样的基本情节,一位作者写出来的是好故事或坏故事,端看他如何装点这副骨架。\n你用不着光靠自己来发掘故事的情节。作者通常会帮助你。有时候,光读书名就好了。在18世纪,作者习惯列出详细的书名,告诉读者整本书在说些什么。杰瑞米·科利尔(Jeremy Collier),一位英国的牧师,用了这样一个书名来攻击王权复兴时期的戏剧之猥亵—或许我们该说是色情—《英国戏剧的不道德与猥亵之一瞥—从古典的观点来探讨》(A Short View of the Immorality and Profaneness of the English Stage,together with the Sense o f Antiquity upon this Argument)。比起今天许多人的习惯性反应,他的抨击倒真的是学养甚佳。从这个书名你可以想像得出来,科利尔一定在书中引述了许多恶名昭彰的不道德的例子,而且从古人的争论当中找出许多例子来支持他的观点。譬如柏拉图说的,舞台使年轻人腐败堕落,或是早期教会里的神父所说的,戏剧是肉体与魔鬼的诱惑。\n有时候作者会在前言说明他整体内容的设计。就这一点而言,论说性的书籍不同于小说。一位科学或哲学的作者没有理由让你摸不着头脑。事实上,他让你的疑虑减到越少,你就会越乐意继续努力阅读他的思想。就像报纸上的新闻报导一样,论说性的书开宗明义就会将要点写在第一段文字中。\n如果作者提供帮助,不要因为太骄傲而拒绝。但是,也不要完全依赖他在前言中所说的话。一个作者最好的计划,就像人或老鼠经常在作的计划一样,常常会出错。你可以借着作者对内容提示的指引来读,但永远要记得,最后找出一个架构是读者的责任,就跟当初作者有责任自己设定一个架构一样。只有当你读完整本书时,才能诚实地放下这个责任。\n希罗多德(Herodotus)所写有关希腊民族与波斯民族战争的《历史》中,有一段引言介绍,可说是相当精华的摘要:\n这本书是希罗多德所作的研究。他出版这本书是希望提醒人们,前人所做的事情,以免希腊人与巴比伦人伟大的事迹失去了应得的光荣,此外还记录下他们在这些夙怨中的领土状态。\n对一个读者来说,这是很棒的开头,简要地告诉了你整本书要说的是什么。\n但是你最好不要就停在那里。在你读完希罗多德九个部分的历史之后,你很可能会发现这段说明需要再丰富一些,才能把全书的精神呈现出来。你可能想要再提一下那些波斯国王—居鲁士(Cyrus),大流士(Darius)与薛西斯(Xerxes),以德密托克里斯(Themistocles)为代表的那些希腊英雄,以及许多动人心魄的事件,诸如黑勒斯波(Hellespont)海峡之横越,还有像德默皮烈之役(Thermopylae)及撒拉密斯之役(Salamis)那些战役。\n其他所有精彩绝伦的细节,都是希罗多德为了烘托他的高潮而给你准备的,在你的结构大纲中,大可删去。注意,在这里,整个历史才是贯穿全体的主要脉络,这跟小说有点相像。既然关心的是整体的问题,在阅读历史时跟小说一样,阅读的规则在探索的都是同样的答案。\n还要再补充一些说明。让我们以一本实用的书做例子。亚里士多德的《伦理学》可以简述为:\n这本书是在探索人类快乐的本质,分析在何种状态下,人类会获得或失去快乐,并说明在行为与思想上该如何去做,才能变得快乐或避免不幸。虽然其他美好的事物也被认可为幸福快乐的必要条件,像是财富、健康、友谊与生活在公正的社会中,但原则上还是强调以培养道德与心智上的善行为主。\n另一本实用的作品是亚当·斯密的《国富论》。一开始,作者就写了一篇“本书计划”的声明来帮助读者。但这篇文章有好几页长。整体来说可以缩简为以下的篇幅:\n本书在探讨国家财富的资源。任何一个封劳力分工为主的经济体制,都要考虑到薪资的给付,资本利润的回收,积欠地主的租金等关系,这些就是物品价格的基本因素。本书讨论到如何更多元化地有效运用资本,并从金钱的起源与使用,谈到累积资本及使用资本。本书借着检验不同国家在不同状况下的富裕发展,比较了不同的政经系统,讨论了自由贸易的好处。\n如果一个读者能用这样的方法掌握住《国富论》的重点,并对马克思的《资本论》作同样的观察,他就很容易看出,过去两个世纪以来最有影响力的这两本书之间有什么关联了。\n达尔文的《物种起源》是另一个好例子,可以帮我们看到科学类理论作品的整体性。这本书可以这么说:\n这本书所叙述的是,生物在数不清世代中所产生的变化,以及新种类的动物或植物如何从其中演变出来。本书讨论了动物在畜养状态下的变化,也讨论了动物在自然状态下的变化,进而说明“物竞天择,适者生存”之类的原理,如何形成并维持一个个族群。此外,本书也主张,物种并不是固定、永恒不变的族群,而是在世代交替中,由比较小的转变成比较明显的、固定的特征。有一些地层中的绝种动物,以及胚胎学与解剖学的比较证据,可以支持这些论点。\n这段说明看来好像很难一口消化,但是对许多19世纪的读者来说,那本书的本身才更难消化—部分原因,是他们懒得花精神去找出书中真正的意旨。\n最后,让我们以洛克的《论人类悟性》当作哲学类理论性作品的例子。你大概还记得我们谈到洛克自己说他的作品是“探讨人类知识的起源、真理与极限,并同时讨论信仰、观点与核准的立场与程度”。作者对自己作品的规划说明得这么精彩,我们当然不会和他争辩什么,不过,我们想要再加两点附带的补充说明,以便把这篇论文第一部分和第三部分的精神也表达清楚。我们会这么加一段话:本书显示出人类没有与生俱来的观念,人类所有的知识都是由经验而来的。本书并论及语言是一个传递思想的媒介—适当的使用方法与最常出现的滥用,在本书中都有指证。\n在继续讨论之前,我们要提醒你两件事。首先,一位作者,特别是好的作者,会经常想要帮助你整理出他书中的重点。尽管如此,当你要求读者择要说出一本书的重点时,大多数人都会一脸茫然。一个原因是今天的人们普遍不会用简明的语言表达自己,另一个原因,则是他们忽视了阅读的这一条规则。当然,这也说明太多读者根本就不注意作者的前言,也不注意书名,才会有这样的结果。\n其次,是要小心,不要把我们提供给你的那些书的重点摘要,当作是它们绝对又惟一的说明。一本书的整体精神可以有各种不同的诠释,没有哪一种一定对。当然,某些诠释因为够精简、准确、容易理解,就是比另一些诠释好。不过,也有些南辕北辙的诠释,不是高明得不相上下,就是烂得不相上下。\n我们在这里谈的一些书的整体重点,跟作者的解释大不一样,但并不觉得需要道歉。你的摘要也可以跟我们的大不一样。毕竟,虽然是同一本书,但对每个阅读者来说都是不同的。如果这种不同透过读者的诠释来表达,毫不足为奇。但,这也不是说就可以爱怎么说就怎么说。虽然读者不同,书的本身还是一样的,不论是谁作摘要,还是有一个客观的标准来检验其正确与真实性。\n2.驾驭复杂的内容:为一本书拟大纲的技巧 # 现在我们来谈另一个结构的规则,这个规则要求我们将一本书最重要的部分照秩序与关系,列举出来。这是第三个规则,与第二个规则关系很密切。一份说明清楚的摘要会指出全书最重要的构成部分。你看不清楚这些构成部分,就没法理解全书。同样的,除非你能掌握全书各个部分之间的组织架构,否则你也无法理解全书。\n那么,为什么要弄两个规则,而不是一个?主要是为了方便。用两个步骤来掌握一个复杂又未划分的架构,要比一个步骤容易得多。第二个规则在指导你注意一本书的整体性,第三个则在强调一本书的复杂度。要这样区分还有另一个理由。当你掌握住一本书的整体性时,便会立刻抓住其中一些重要的部分。但是这每个部分的本身通常是很复杂,各有各的内在结构需要你去透视。因此第三个规则所谈的,不只是将各个部分排列出来,而且要列出各个部分的纲要,就像是各个部分自成一个整体,各有各的整体性与复杂度。\n根据第三个规则,可以有一套运用的公式。这个公式是可以通用的。根据第二个规则,我们可以说出这本书的内容是如此这般。做完这件事之后,我们可以依照第三个规则,将内容大纲排列如下:(1)作者将全书分成五个部分,第一部分谈的是什么,第二部分谈的是什么,第三部分谈的是别的事,第四部分则是另外的观点,第五部分又是另一些事。(2)第一个主要的部分又分成三个段落,第一段落为X,第二段落为Y,第三段落为Z。(3)在第一部分的第一阶段,作者有四个重点,第一个重点是A,第二个重点是B,第三个重点是C,第四个重点是D等等。\n你可能会反对这样列大纲。照这样阅读岂不是要花上一辈子的时间才能读完一本书了?当然,这只是一个公式而已。这个规则看起来似乎要你去做一件不可能做到的事。但事实上,一个优秀的阅读者会习惯性地这么做,而且轻而易举。他可能不会全部写出来,在阅读时也不会在口头上说出来。但是如果你问他这本书的整体架构时,他就会写出一些东西来,而大概就跟我们所说的公式差不多。\n“大概”这两个字可以舒解一下你的焦虑。一个好的规则总是会将最完美的表现形容出来。但一个人可以做一个艺术家,却不必做个理想的艺术家。如果他大概可以依照这个规则,就会是个很好的练习者了。我们所说明的规则是个理想的标准。如果你能作出一个草稿来,跟这里所要求的很类似,就该感到满足了。\n就算你已经很熟练阅读技巧了,你也不一定读每本书都要用上同样的力气。你会发现在某些书上运用这些技巧是个浪费。就是最优秀的阅读者也只会选少数相关的几本书,依照这个规则的要求做出近似的大纲来。在大多数情况下,他们对一本书的架构有个粗浅的了解已经很满意了。你所做的大纲与规则相近的程度,是随你想读的书的特质而变化的。但是不管千变万化,规则本身还是没有变。不论你是完全照做,或是只掌握一个形式,你都得了解要如何跟着规则走才行。\n你要了解,影响你执行这个规则的程度的因素,不光是时间和力气而已。你的生命是有限的,终有一死。一本书的生命也是有限的,就算不死,也跟所有人造的东西一样是不完美的。因为没有一本书是完美的,所以也不值得为任何一本书写出一个完美的纲要。你只要尽力而为就行了。毕竟,这个规则并没有要你将作者没有放进去的东西加在里面。你的大纲是关于作品本身的纲要,而不是这本书要谈的主题的纲要。或许某个主题的纲要可以无限延伸,但那却不是你要为这本书写的纲要—你所写的纲要对这个主题多少有点规范。不过,你可不要觉得我们在鼓励你偷懒。因为就算你真想跟随这个规则,也还是不可能奋战到底的。\n用一个公式将一本书各个部分的秩序与关系整理出来,是非常艰难的。如果举几个实例来说明,或许会觉得容易些,不过,要举例来说明这个规则,还是比举例说明另一个抓出重点摘要的规则要难多了。毕竟,一本书的重点摘要可以用一两个句子,或是几段话就说明清楚了。但是对一本又长又难读的书,要写出仔细又适当的纲要,将各部分,以及各部分中不同的段落,各段落中不同的小节,一路细分到最小的结构单位都写清楚,可是要花上好几张纸才能完成的工作。\n理论上来说,这份大纲可以比原著还要长。中世纪有些对亚里士多德作品的注释,都比原著还长。当然,他们所含的是比大纲还要多的东西,因为他们是一句一句地解释作者的想法。有些现代的注释也是如此,像一些对康德《纯粹理性批判》一书所作的注释便是一个例子。莎士比亚的注释剧本集也是如此,其中包括了详尽无比的纲要与其他的论述,往往有原著的好几倍长—甚或十倍之长。如果你想要知道照这条规则可以做到多详尽的地步,不妨找一些这类的注释来看看。阿奎那(Aquinas)在注解亚里士多德的书时,每个注释的起头都要针对亚里士多德在他作品中表达的某个重点,拟一份漂亮的纲要,然后不厌其烦地说明这个重点如何与亚里士多德的全书融为一体,这个重点和前后文又有多么密切的关系。\n让我们找一些比亚里士多德的论述要简单一点的例子。亚里士多德的文章是最紧凑简洁的,要拿他的作品来拟大纲,必然费时又困难。为了要举一个适当的例子,让我们都同意一点:就算我们有很长的篇幅可以用,我们还是放弃把这个例子举到尽善尽美程度的想法吧。\n美国联邦宪法是很有趣又实用的文献,也是组织整齐的文字。如果你检验一下,会很容易找出其中的重要部分来。这些重要部分本来就标示得很清楚,不过你还是得下点功夫作些归纳。以下是这份大纲的建议写法:第一:前言,声明宪法的目的。第二:第一条,关于政府立法部门的问题。第三:第二条,关于政府行政部门的问题。第四:第三条,关于政府司法部门的问题。第五:第四条,关于州政府与联邦政府之间的关系。第六:第五、六、七条,关于宪法修正案的问题,宪法有超越所有法律、提供认可之地位。第七:宪法修正案的前十条,构成人权宣言。第八:其他持续累积到今天的修正案。\n这些是主要的归纳。现在就用其中的第二项,也就是宪法第一条为例,再列一些纲要。就跟其他的条一样,这一条(Article)也区分为几个款(Section)。以下是我们建议的纲要写法:\n二之一:第一款,制定美国国会的立法权,国会分成两个部分,参议院与众议院。\n二之二:第二、三款,个别说明参议院与众议院的组成架构,与成员的条件。此外,惟有众议院有弹劾的权力,唯有参议院有审理弹劾的权力。\n二之三:第四、五款,关于国会两院的选举,内部的组织与事务。\n二之四:第六款,关于两院所有成员的津贴与薪金的规定,并设定成员使用公民权的限制。\n二之五:第七款,设定政府立法与行政部门之间的关系,说明总统的否决权。\n二之六:第八款,说明国会的权力。\n二之七:第九款,说明第八款国会权力的限制。\n二之八:第十款,说明各州的权力限制,以及他们必须要把某些权力交给国会的情况。\n然后,我们可以将其他要项也都写出类似的纲要。都完成之后,再回头写每一个小款的纲要。其中有些小款,像是第一条的第八款,需要再用许多不同的主题与次主题来确认。\n当然,这只是其中一种方法。还有很多其他拟定纲要的方法。譬如前三项可以合并归纳为一个题目,或者,不要将宪法修正案区分为两项来谈,而是根据所处理问题的性质,将修正案划分为更多项来谈。我们建议你自己动手,用你的观点将宪法区分为几个主要的部分,列出大纲。你可以做得比我们更详细,在小款中再区分出小点来。你可能读过宪法很多次了,但是以前可能没用过这种方法来读,现在你会发现用这样的方法来阅读一份文献,会看到许多以前你没看到的东西。\n接下来是另外一个例子,也是很短的例子。我们已经将亚里士多德的《伦理学》做过重点摘要,现在让我们首次试着将全书的结构作一个近似的说明。全书可以区分为以下的几个重要部分:一,把快乐当作是生命的终极目标,讨论快乐与其他善行的关系。二,讨论天生自然的行为,与养成好习惯、坏习惯的关系。三,讨论伦理与智性中各种不同的善行与恶行。四,讨论非善非恶的道德状态。五,讨论友谊。六,也是最后一个,讨论喜悦,并完成一开始所谈有关人类快乐的主题。\n这个大纲显然与《伦理学》的十卷内容并不完全相符合。因为第一部分是第一卷中所谈论的内容。第二部分包含了第二卷及第三卷的前半部内容。第三部分则从第三卷后半部一直延伸到第六卷。讨论享乐的最后一部分则是包含了第七卷的结尾与第十卷的开头。\n我们要举出这个例子,是要让你明白,你用不着跟着书上所出现的章节来归纳一本书的架构。当然,原来的结构可能比你区分的纲要好,但也很可能比不上你的纲要。无论如何,你得自己拟纲要就对了。作者拟定了纲要,以写出一本好书。而你则要拟定你的纲要,才能读得明白。如果他是个完美的作家,而你是个完美的读者,那你们两个人所列的纲要应该是相同的。如果你们两人之中有人偏离了朝向完美的努力,那结果就免不了产生许多出人。\n这并不是说你可以忽略作者所设定的章节与段落的标题,我们在做美国宪法的纲要时,并没有忽略这些东西,但我们也没有盲从。那些章节是想要帮助你,就跟书名与前言一样。不过你应该将这些标题当作是你自己活动的指南,而不是完全被动地仰赖它们。能照自己所列的纲要执行得很完美的作者,寥寥无几。但是在一本好书里,经常有许多作者的规划是你一眼看不出来的。表象可能会骗人的。你一定要深人其间,才能发现真正的架构。\n找出真正的架构到底有多重要呢?我们认为非常重要。用另一种方法来说,就是除非你遵循规则三—要求你说明组成整体的各个部分—否则就没有办法有效地运用规则二—要求你作全书的重点摘要。你可能有办法粗略地瞄一本书,就用一两个句子说出全书的重点摘要,而且还挺得体。但是你却无法真的知道到底得体在哪里。另一个比你仔细读过这本书的人,就可能知道得体在哪里,因而对你的说法给予很高的评价。但是对你来说,那只能算是你猜对了,运气很好罢了。因此说,要完成第二个规则,第三个规则是绝对必要的。\n我们会用一个简单的例子向你说明我们的想法。一个两岁的孩子,刚开始说话,可能会说出:“二加二等于四”这样的句子。的确,这句话是千真万确的,但我们可能会因此误下结论,认为这个孩子懂数学。事实上,这个孩子可能根本不知道自己在说些什么。因此,虽然这句话是正确的,这个孩子还是需要接受这方面的训练。同样的,你可能猜对了一本书的主题重点,但你还是需要自我训练,证明你是“如何”,又“为什么”这么说。因此,要求你将书中的重要部分列出纲要,并说明这些部分如何印证、发展出全书的主题,就有助于你掌握全书的重点摘要。\n3.阅读与写作的互惠技巧 # 乍看之下,我们前面讨论的两个阅读规则,看起来就跟写作规则一\n样。的确没错。写作与阅读是一体两面的事,就像教书与被教一样。\n如果作者跟老师无法将自己要传达的东西整理出架构,不能整合出要\n讲的各个部分的顺序,他们就无法指导读者和学生去找出他们要讲的重点,也没法发现全书的整体架构。\n尽管这些规则是一体两面,但实行起来却不相同。读者是要“发现”书中隐藏着的骨架。而作者则是以制造骨架为开始,但却想办法把骨架“隐藏”起来。他的目的是,用艺术的手法将骨架隐藏起来,或是说,在骨架上添加血肉。如果他是个好作者,就不会将一个发育不良的骨架埋藏在一堆肥肉里,同样的,也不会瘦得皮包骨,让人一眼就看穿。如果血肉匀称,也没有松弛的赘肉,那就可以看到关节,可以从身体各个部位的活动中看出其中透露的言语。\n为什么这么说呢?为什么论说性的书,这种本来就想条理井然地传达一种知识的书,不能光是把主题纲要交待清楚便行?原因是,不仅大多数人都不会读纲要,而且对一位自我要求较高的读者来说,他并不喜欢这样的书,他会认为他可以做自己分内的事,而作者也该做他自己分内的事。还有更多的原因。对一本书来说,血肉跟骨架是一样重要的。书,真的就跟人或动物是一模一样的。—血肉,就是为纲要所作的进一步详细解释,或是我们有时候所说的“解读”(read out)。血肉,为全书增添了必要的空间与深度。对动物来说,血肉就是增加了生命。因此,根据一个纲要来写作一本书,不论这个纲要详尽的程度如何,都在给予这本书一种生命,而这种效果是其他情况所达不到的。\n我们可以用一句老话来概括以上所有的概念,那就是一个作品应该有整体感,清楚明白,前后连贯。这确实是优秀写作的基本准则。我们在本章所讨论的两个规则,都是跟随这个写作准则而来的。如果这本书有整体的精神,那我们就一定要找出来。如果全书是清楚明白又前后一贯的,我们就要找出其间的纲要区隔,与重点的秩序来当作回报。所谓文章的清楚明白,就是跟纲要的区隔是否清楚有关,所谓文章的前后一贯,就是能把不同的重点条理有序地排列出来。\n这两个规则可以帮助我们区分好的作品与坏的作品。如果你运用得已经成熟了,却不论花了多少努力来了解一本书的重点,还是没法分辨出其间的重点,也找不出彼此之间的关系,那么不管这本书多有名,应该还是一本坏书。不过你不该太快下这样的结论,或许错误出在你身上,而不是书的本身。无论如何,千万不要在读不出头绪的时候,就总以为是自己的问题。事实上,无论你身为一个读者的感受如何,通常问题还是出在书的本身。因为大多数的书—绝大多数—的作者,都没有依照这些规则来写作,因而就这一点来说,都可以说是很糟。\n我们要再强调的是,这两个规则不但可以用来阅读一整本论说性的书,也可以用来阅读其中某个特别重要的部分。如果书中某个部分是一个相当独立又复杂的整体,那么就要分辨出这部分的整体性与复杂性,才能读得明白。传达知识的书,与文学作品、戏剧、小说之间,有很大的差异。前者的各个部分可以是独立的,后者却不能。如果一个人说他把那本小说已经“读到够多,能掌握主题了”,那他一定根本不知道自己在说些什么。这句话一定不通,因为一本小说无论好坏都是一个整体,所有的概念都是一个整体的概念,不可能只读了一部分就说懂得了整体的概念。但是你读亚里士多德的《伦理学》或达尔文的《物种起源》,却可以光是仔细地阅读某一个部分,就能得到整体的概念。不过,在这种情况下,你就做不到规则三所说的了。\n4.发现作者的意图 # 在这一章,我们还想再讨论另一条阅读规则。这个规则可以说得简短一点,只需要一点解释,不需要举例。如果你已经在运用规则二跟规则三了的话,那这一条规则就不过是换种说法而已。但是重复说明这个规则很有帮助,你可以借此用另一个角度来了解全书与各个重要部分。\n这第四个规则可以说是:找出作者要问的问题。一本书的作者在开始写作时,都是有一个问题或一连串的问题,而这本书的内容就是一个答案,或许多答案。\n作者可能会,也可能不会告诉你他的问题是什么,就像他可能会,也可能不会给你他工作的果实,也就是答案。不论他会不会这么做—尤其是不会的情况—身为读者,你都有责任尽可能精确地找出这些问题来。你应该有办法说出整本书想要解答的问题是什么。如果主要的问题很复杂,又分成很多部分,你还要能说出次要的问题是什么。你应该不只是有办法完全掌握住所有相关的问题,还要能明智地将这些问题整合出顺序来。哪一个是主要的,哪个是次要的?哪个问题要先回答,哪些是后来才要回答的?\n从某方面来说,你可以看出这个规则是在重复一些事情,这些事情在你掌握一本书的整体精神和重要部分的时候已经做过了。然而,这个规则的确可以帮你做好这些事。换句话说,遵守规则四,能让你和遵守前两条规则产生前后呼应的效果。\n虽然你对这个规则还不像其他两个规则一样熟悉,但这个规则确实能帮助你应对一些很困难的书。但我们要强调一点:我们不希望你落入批评家所认为的“意图谬误\u0026quot;(intentional fallacy)。这种谬误就是你认为自己可以从作者所写的作品中看透他的内心。这样的状况特别会出现在文学作品中。譬如,想从《哈姆雷特》来分析莎士比亚的心理,就是一个严重的错误。然而,就真是一本诗集,这个规则也能极有助于你说出作者想要表达的是什么。对论说性的书来说,这个规则的好处当然就更明显。但是,大多数读者不论其他技巧有多熟练,还是会忽略这个规则。结果,他们对一本书的主题或重点就可能很不清楚,当然,所列出的架构也是一团混乱。他们看不清一本书的整体精神,因为他们根本不知道整本书为什么要有这样的整体精神。他们所理解的整本书的骨架,也欠缺这个骨架最后想说明的目的。\n如果你能知道每个人都会问的一些问题,你就懂得如何找出作者的问题。这个可以列出简短的公式:某件事存在吗?是什么样的事?发生的原因是什么?或是在什么样的情况下存在?或为什么会有这件事的存在?这件事的目的是什么?造成的影响是什么?特性及特征是、什么?与其他类似事件,或不相同事件的关联是什么?这件事是如何进行的?以上这些都是理论性的问题。有哪些结果可以选择?应该采取什么样的手段才能获得某种结果?要达到某个目的,应该采取哪些行动?以什么顺序?在这些条件下,什么事是对的,或怎样才会更好,而不是更糟?在什么样的条件下,这样做会比那样做好一些?以上这些都是实用的问题。\n这些问题还不够详尽,但是不论阅读理论性还是实用性的书,这些都是经常会出现的典型问题。这会帮助你发现一本书想要解决的问题。在阅读富有想像力的文学作品时,这些问题要稍作调整,但还是非常有用。\n5.分析阅读的第一个阶段 # 我们已经说明也解释了阅读的前四个规则。这些是分析阅读的规则。如果在运用之前能先做好检视阅读,会更能帮助你运用这些规则。\n最重要的是,要知道这前四个规则是有整体性,有同一个目标的。这四个规则在一起,能提供读者对一本书架构的认识。当你运用这四个规则来阅读一本书,或任何又长又难读的书时,你就完成了分析阅读的第一个阶段。\n除非你是刚开始练习使用分析阅读,否则你不该将“阶段”一词当作一个前后顺序的概念。因为你没有必要为了要运用前四个规则,而将一本书读完,然后为了要运用其他的规则,再重新一遍又一遍地读。真正实际的读者是一次就完成所有的阶段。不过,你要了解的是,在分析阅读中,要明白一本书的架构是有阶段性的进展的。\n换一种说法是,运用这前四个规则,能帮助你回答关于一本书的一些基本问题。你会想起第一个问题是:整本书谈的是什么?你也会想起,我们说这是要找出整本书的主题,以及作者是如何运用一些根本性的次要主题或议题,按部就班来发展这个主题。很明显的,运用这前四个阅读规则,能提供你可以回答这个问题的大部分内容—不过这里要指出一点,等你可以运用其他规则来回答其他问题的时候,你回答这个问题的精确度会提高许多。\n既然我们已经说明了分析阅读的第一个阶段,让我们暂停一下,将这四个规则按照适当的标题,顺序说明一下:分析阅读的第一阶段,或,找出一本书在谈些什么的四个规则:\n(1)\n依照书本的种类与主题作分类。\n(2)\n用最简短的句子说出整本书在谈些什么。\n(3)\n按照顺序与关系,列出全书的重要部分。将全书的纲要拟出来之后,再将各个部分的纲要也一一列出。\n(4)找出作者在问的问题,或作者想要解决的问题。\n第八章 与作者找出共通的词义 # 如果你运用了前一章结尾时所谈到的前四个规则,你就完成了分析阅读的第一个阶段。这四个规则在告诉你一本书的内容是关于什么,要如何将架构列成纲要。现在你准备好要进行第二个阶段了。这也包括了四个阅读规则。第一个规则,我们简称为“找出共通的词义”。\n在任何一个成功的商业谈判中,双方找出共同的词义,也就是达成共识(coming to terms),通常是最后一个阶段。剩下惟一要做的就是在底线上签字。但是在用分析阅读阅读一本书时,找出共通的词义却是第一个步骤。除非读者与作者能找出共通的词义,否则想要把知识从一方传递到另一方是不可能的事。因为词义(term)是可供沟通的知识的基本要素。\n1.单字vs.词义 # 词义和单字(word)不同—至少,不是一个没有任何进一步定义的单字。如果词义跟单字完全相同,你只需要找出书中重要的单字,就能跟作者达成共识了。但是一个单字可能有很多的意义,特别是一个重要的单字。如果一个作者用了一个单字是这个意义,而读者却读成其他的意义,那这个单字就在他们之间擦身而过,他们双方没有达成共识。只要沟通之中还存有未解决的模糊地带,就表示没有达成沟通,或者顶多说还未达成最好的沟通。\n看一下“沟通”(communication)这个字,字根来自“共通”(common)。我们谈一个社群(community),就是一群有共通性的人。而沟通是一个人努力想要跟别人(也可能是动物或机器)分享他的知识、判断与情绪。只有当双方对一些事情达成共识,譬如彼此对一些资讯或知识都有分享,沟通才算成功。\n当知识沟通的过程中产生模糊地带时,双方惟一共有的是那些在讲在写、在听在读的单字。而只要模糊地带还存在,就表示作者和读者之间对这些单字的意义还没有共识。为了要达成完全的沟通,最重要的是双方必须要使用意义相同的单字—简单来说,就是,找出共通的词义达成共识。双方找出共通的词义时,沟通就完成了,两颗心也奇迹似地拥有了相同的想法。\n词义可以定义为没有模糊地带的字。这么说并非完全正确,因为严格来说,没有字是没有模糊地带的。我们应该说的是:当一个单字使用得没有模糊意义的时候,就是一个词义了。字典中充满了单字。就这些单字都有许多意义这一点而言,它们几乎都意义模糊。但是一个单字纵然有很多的意义,每一次使用却只能有一种意义。当某个时间,作者与读者同时在使用同一个单字,并采取惟一相同的意义时,在那种毫无模糊地带的状态中,他们就是找出共通的词义了。\n你不能在字典中找到词义,虽然那里有制造词义的原料。词义只有在沟通的过程中才会出现。当作者尽量避免模糊地带,读者也帮助他,试着跟随他的字义时,双方才会达成共识。当然,达成共识的程度有高下之别。达成共识是作者与读者要一起努力的事。因为这是阅读与写作的艺术要追求的终极成就,所以我们可以将达成共识看作是一种使用文字的技巧,以达到沟通知识的目的。\n在这里,如果我们专就论说性作家或论说性的作品来举例子,可能会更清楚一些。诗与小说不像论说性的作品—也就是我们所说的传达广义知识的作品—那么介意文字的模糊地带。有人说,最好的诗是含有最多模糊地带的。也有人很公允地说,一个优秀的诗人,不时会故意在作品中造成一些模糊。这是关于诗的重要观点,我们后面会再讨论这个问题。这是诗与其他论说性、科学性作品最明显的不同之处。\n我们要开始说明第五个阅读规则了(以论说性的作品为主)。简略来说就是:你必须抓住书中重要的单字,搞清楚作者是如何使用这个单字的。不过我们可以说得更精确又优雅一些:规则五,找出重要单字,透过它们与作者达成共识。要注意到这个规则共分两个部分,第一个部分是找出重要单字,那些举足轻重的单字。第二部分是确认这些单字在使用时的最精确的意义。\n这是分析阅读第二阶段的第一个规则,目标不是列出一本书的架构纲要,而是诠释内容与讯息。这个阶段的其他规则将会在下一章讨论到,意义也跟这个规则一样。那些规则也需要你采取两个步骤:第一个步骤是处理语言的问题。第二个步骤是超越语言,处理语言背后的思想涵义。\n如果语言是纯粹又完美的思想媒介,这些步骤就用不着分开来了。如果每个单字只有一个意义,如果使用单字的时候不会产生模糊地带,如果,说得简短一点,每个单字都有一个理想的共识,那么语言就是个透明的媒介了。读者可以直接透过作者的文字,接触到他内心的思想。如果真是如此,分析阅读的第二个阶段就完全用不上了。对文字的诠释也毫无必要了。\n当然,实际情况并非如此。不必难过,想刻意制造一个不可能实现的理想语言的方案—像是哲学家莱布尼兹和他学生想要做的事—也是枉然。事实上,如果他们成功了,这世上就不再有诗了。因此,在论说性的作品中,惟一要做的事就是善用语言。想要做到这一点,惟一的路就是当你在传递、接受知识时,要尽可能巧妙地运用语言的技巧。\n因为语言并不是完美的传递知识的媒介,因而在沟通时也会有形成障碍的作用。追求具备诠释能力的阅读,规则就在克服这些障碍。我们可以期望一个好作者尽可能穿过语言所无法避免形成的障碍,和我们接触,但是我们不能期望只由他一个人来做这样的工作。我们应该在半途就跟他相会。身为读者,我们应该从我们这一边来努力打通障碍。两个心灵想透过语言来接触,需要作者与读者双方都愿意共同努力才行。就像教学,除非被教的学生产生呼应的活力,否则光靠老师是行不通的。作者也是一样,不论他写作技巧如何,如果读者没有呼应的技巧,双方就不可能达成沟通。如果不是这样,双方不论付出多大的努力,各行其是的阅读和写作技巧终究不会将两个心灵联系在一起。就像在一座山的两边分头凿隧道一样,不论花了多少力气,如果双方不是照着同样的工程原理来进行计算,就永远不可能相遇。\n就像我们已经指出的,每一种具备诠释能力的阅读都包含两个步骤。暂且用些术语吧,我们可以说这些规则是具有文法与逻辑面向的。文法面向是处理单字的。逻辑面向是处理这些单字的意义,或说得更精确一点,是处理词义的。就沟通而言,每个步骤都不可或缺。如果在运用语言时毫无思想,就没有任何沟通可言。而没有了语言,思想与知识也无法沟通。文法与逻辑是艺术,它们和语言有关;语言与思想有关,而思想又与语言有关。这也是为什么透过这些艺术,阅读与写作的技巧会增进的原因。\n语言与思想的问题—特别是单字与词义之间的差异—是非常重要的。因此我们宁愿冒着重复的风险,也要确定这个重点被充分了解。这个重点就是,一个单字可能代表许多不同的词义,而一个词义可以用许多不同的单字来解释。让我们以下面的例子来做说明。在我们的讨论中,“阅读”这两个字已经出现过许多不同的意义。让我们挑出其中三个意义:当我们谈到“阅读”时,可能是指(1)为娱乐而阅读;(2)为获得资讯而阅读;(3)为追求理解力而阅读。\n让我们用X来代表“阅读”这两个字,而三种意义以a,b,c来代替。那么Xa,Xb,Xc代表什么?那不是三个不同的单字,因为X始终并没有改变。但那是三种不同的词义—如果你身为读者,我们身为作者,都知道X在这里指的是什么意思的话。如果我们在一个地方写了Xa,而你读起来却是Xb,那我们写的,你读的都是同一个单字,却是不同的意义。这个模糊的意义会中止,或至少妨碍我们的沟通。只有当你看到这个单字的时候所想的字义跟我们想的一样,我们之间才有共同的思想。我们的思想不会在X中相遇,而只会在Xa,Xb或Xc中 相遇。这样我们才算找出共通的词义。\n2.找出关键字 # 现在我们准备要为找出共通词义的这个规则加点血肉了。怎样才能找出共通词义?在一本书中,要怎样才能找出那些重要的字,或所谓的关键字来?有一件事你可以确定:并不是作者所使用的每一个字都很重要。更进一步说,作者所使用的字大多数都不重要。只有当他以特殊的方法来运用一些字的时候,那些字对他来说,对身为读者的我们来说,才是重要的。当然,这并不是百分之百的,总有程度之不同。或许文字多少都有重要性,但我们所关心的只是在一本书中,哪些字要比其他的字更重要一些。在某种极端情况下,一个作者所用的字可能就和街坊邻居的遣词用字是一模一样的。由于作者所用的这些字跟一般人日常谈话是相同的,读者应该不难理解才对。他很熟悉这些字眼的模糊地带,也习惯于在上下文不同的地方看出不同的含义来。\n譬如爱丁顿(A. S. Eddington)的《物理世界的本质》》(The Nature of the Physical World)一书出现“阅读”这个字的时候,他谈的是“仪表阅读”(pointer-readings),专门以科学仪器上的指针与仪表为对象的阅读。他在这里所用的“阅读”,是一般常用的意思之一。对他来说那不是特殊的专业用语。他用一般的含义,就可以说明他要告诉读者的意思。就算他在这本书其他地方把“阅读”作为其他不同的意义来用—譬如说,他用了个“阅读本质\u0026quot;(reading nature)的句子—他还是相信读者会注意到在这里一般的“阅读”已经转换为另一个意义了。读者做不到这一点的话,他就没法跟朋友谈话,也不能过日常生活了。\n但是爱丁顿在使用“原因\u0026quot;(cause)这个字的时候就不能如此轻松了。这可能是个很平常的字眼,但是当他在讨论因果论的时候用到这个字,肯定是用在一个非常特别的意义上。这个字眼如果被误解了,他和读者之间一定会产生困扰。同样的,在本书中,“阅读”这个字眼是非常重要的。我们不能只以一般的看法来运用。\n一个作者用字,泰半和一般人谈话时的用字差不多—这些字都有不同的意义,讲话的人也相信随着上下文的变化,对方可以自动就找出其不同的意义。知道这一点,有助于找出那些比较重要的字眼。然而,我们不要忘了,在每天的日常谈话中,不同的时间、地点下,同一个熟悉的字也可能变得没那么熟悉。当代作者所使用的字,大多都是今天日常生活中所使用的含义。你会懂,是因为你也活在今天。但是阅读一些过去人所写的书,要找出作者在当时时空背景下照大多数人习惯而使用的那些字眼的意思,就可能困难许多了。加上有些作者会故意用古字,或是陈旧的含义,就更增加了复杂度。这问题就跟翻译外文书是一样的。\n尽管如此,任何一本书中的泰半字句,都可以像是跟朋友说话中的遣字用词那样阅读。打开我们这本书,翻到任何一页,用这样的方法算算我们使用了哪些字:介词、连接词、冠词,以及几乎全部的动词、名词、副词与形容词。在这一章,到目前为止其实只出现了几个重要的字关键:“单字”、“词义”、“模糊”、“沟通”,或顶多再加一两个其他重要的字。当然,“模糊”显然是最重要的字,其他的字眼都跟它有关。\n如果你不想办法了解这些关键字所出现的那些段落的意思,你就没法指出哪些字是关键字了。这句话听起来有点矛盾。如果你了解那些段落的意思,当然会知道其中哪几个字是非常重要的。如果你并不完全了解那些段落的意思,很可能是因为你并不清楚作者是如何使用一些特定的字眼。如果你把觉得有困扰的字圈出来,很可能就找出了作者有特定用法的那些字了。之所以会如此,是因为如果作者所用的都只是一般日常用语的含义,对你来说就根本不存在有困扰的问题了。\n因此,从一个读者的角度来看,最重要的字就是那些让你头痛的字。这些字很可能对作者来说也很重要。不过,有时也并非如此。\n也很可能,对作者来说很重要的字,对你却不是问题—因为你已经了解了这些字。在这种状况下,你与作者就是已经找出共通的词义,达成共识了。只有那些还未达成共识的地方,还需要你的努力。\n3.专门用语及特殊字汇 # 到目前为止,我们谈的都是消极地排除日常用语的方法。事实上,你也会发现一些对你来说并不是日常用语的字,因而发现那是一些重要的字眼。这也是为什么这些字眼会困扰到你。但是,是否有其他方法能找出重要的字眼?是否有更积极的方法能找出这些关键字?\n确实有几个方法。第一个,也是最明显的信号是,作者开诚布公地强调某些特定的字,而不是其他的字。他会用很多方法来做这件事。他会用不同的字体来区分,如加括号,斜体字等记号以提醒你。他也会明白地讨论这些字眼不同的意义,并指出他是如何在书中使用这些不同的字义,以引起你对这些字的注意。或是他会借着这个字来命名另外一个东西的定义,来强调这个字。\n如果一个人不知道在欧几里得的书中,“点”、“线”、“面”、“角”、“平行线”等是最重要的字眼,他就无法阅读欧几里得的书了。这些字都是欧几里得为几何学所定义的一些东西的名称。还有另外一些重要的字,像是“等于”、“整体”、“部分”等,但这些字都不是任何定义的名称。你因为从定理中看到这些字眼而知道是重要的字。欧几里得在一开始就详述了这些主要的定理,以便帮助你了解书的内容。你可以猜到描述这些定理的词义都是最根本的,而那些底下划了线的单字,就是这些词义。你对这些单字可能不会有什么问题,因为都是一般口语里使用的单字,而欧几里得似乎就是想这样使用这些字的。\n你可能会说,如果每个作者都像欧几里得一样,阅读这件事没什么困难嘛!当然,这是不可能的—尽管有人认为任何主题都能用几何的方法来详细叙述。在数学上行得通的步骤—叙述和证明的方法—不一定适用于其他领域的知识。但无论如何,我们只要能指出各种论述的共通点是什么就够了。那就是每一个知识领域都有独特的专门用语(technical vocabulary)。欧几里得一开头就将这些用语说明得一清二楚。其他用几何方法写作的作者,像伽利略或牛顿也都是如此。其他领域,或用其他不同写法写的书,专门用语就得由读者自己找出来了。\n如果作者自己没有指出来,读者就要凭以往对这个主题的知识来寻找。如果他在念达尔文或亚当‘斯密的作品之前,有一些生物学或经济学的知识,当然比较容易分辨出其中的专门用语。分析一本书的架构的规则,这时可能帮得上忙。如果你知道这是什么种类的书,整本书在谈的主题是什么,有哪些重要的部分,将大大帮助你把专门用语从一般用语中区分出来。作者的书名、章节的标题、前言,在这方面也都会有些帮助。\n举例来说,这样你就可以明白对亚当·斯密而言,“财富”就是专门用语,“物种”则是达尔文的专门用语。因为一个专门用语会带出另一个专门用语,你只能不断地发现同样形式的专门用语。你很快就能将亚当·斯密所使用的重要字眼列出来了:劳工、资本、土地、薪资、利润、租金、商品、价格、交易、成品、非成品、金钱等等。有些字则是在达尔文的书中你一定不会错过的:变种、种属、天择、生存、适应、杂种、适者、宇宙。\n某些知识领域有一套完整的专门用语,在一本这种主题的书中找出重要的单字,相形之下就很容易了。就积极面来说,只要熟悉一下那个领域,你就能找出这些专门的单字;就消极面来说,你只要看到不是平常惯见的单字,就会知道那些字一定是专门用语。遗憾的是,许多领域都并未建立起完善的专门用语系统。\n哲学家以喜欢使用自己特有的用语而闻名。当然,在哲学领域中,有一些字是有着传统涵义的。虽然不见得每个作者使用这些字的时候意思都相同,但这些字讨论某些特定问题的时候,还是一些专门用语。可是哲学家经常觉得需要创造新字,或是从日常用语中找出一些字来当作是专门用语。后者常会误导读者,因为他会以为自己懂得这个字义,而把它当作是日常用语。不过,大多数好的作者都能预见这样的困扰,只要出现这样的字义时,都会事先做详尽的说明解释。\n另外一个线索是,作者与其他作者争执的某个用语就是重要的字。当你发现一位作者告诉你某个特定的字曾经被其他人如何使用,而他为什么选择不同的用法时,你就可以知道这个字对他来说意义非凡。\n在这里我们强调的是专门用语的概念,但你绝不要把它看得太狭隘了。作者还有些用来阐述自己主旨及重要概念,数量相对而言比较少的特殊用语(special vocabulary)。这些字眼是他要作分析与辩论时用的。如果他想要作最初步的沟通,其中有一些字他会用很特殊的方法来使用,而另外一些字则会依照这个领域中传统的方法来运用。不论是哪一种情况,这些字对他来说都重要无比。而对身为读者的你来说,应该也同样重要才对。除此之外,任何其他字义不明的字,对你也很重要。\n大多数读者的问题,在于他们根本就不太注意文字,找不出他们的困难点。他们区分不出自己很明白的字眼与不太明白的字眼。除非你愿意努力去注意文字,找出它们所传递的意义,否则我们所建议帮助你在一本书里找出重要字句的方法就一点用也没有了。如果读者碰到一个不了解的字不愿意深思,或至少作个记号,那他不了解的这个字就一定会给他带来麻烦。\n如果你在读一本有助于增进理解力的书,那你可能无法了解这本书里的每一个字,是很合理的。如果你把它们都看作是日常用语,像是报纸新闻那样容易理解的程度,那你就无法进一步了解这本书了。你会变成看书就像在看报纸一样—如果你不试着去了解一本书,这本书对你就一点启发也没有了。\n大多数人都习惯于没有主动的阅读。没有主动的阅读或是毫无要求的阅读,最大的问题就在读者对字句毫不用心,结果自然无法跟作者达成共识了。\n4.找出字义 # 找出重要的关键字只是开始的工作。那只是在书中标明了你需要努力的地方而已。这第五个阅读规则还有另一个部分。让我们来谈谈这个部分的问题吧!假设你已经将有问题的字圈出来了,接下来怎么办?\n有两种主要的可能:一是作者在全书每个地方用到这个字眼的时候都只有单一的意义,二是同一个字他会使用两三种意义,在书中各处不断地变换字义。第一种情况,这个单字代表着单一的词义。使用关键字都局限于单一意义的例子,最出名的就是欧几里得。第二种情况,那些单字就代表着不同的词义。\n要了解这些不同的状况,你就要照下面的方法做:首先,要判断这个字是有一个还是多重意义。如果有多重意义,要看这些意义之间的关系如何。最后,要注意这些字在某个地方出现时,使用的是其中哪一种意义。看看上下文是否有任何线索,可以让你明白变换意义的理由。最后这一步,能让你跟得上字义的变化,也就是跟作者在使用这些字眼时一样变化自如。\n但是你可能会抱怨,这样什么都清楚了,可是什么也不清楚了。你到底要怎样才能掌握这许多不同的意思呢?答案很简单,但你可能不满意。耐心与练习会让你看到不同的结果。答案是:你一定要利用上下文自己已经了解的所有字句,来推敲出你所不了解的那个字的意义。不论这个方法看起来多么像是在绕圈子,但却是惟一的方法。\n要说明这一点,最简单的方法就是看定义的例子。定义是许多字组合起来的。如果你不了解其中任何一个字,你就无法了解为这些定义内容而取名的那个字的意思了。“点”是几何学中基本的字汇,你可以认为自己知道这个字的用法(在几何学中),但欧几里得想要确定你只能以惟一的意义来使用这个字。他为了让你明白他的意思,一开始就把接下来要取名为“点”的这个东西详加定义。他说:“点,不含有任何部分。”(A point is that which has no part.)\n这会怎样帮助你与他达成共识呢?他假设,你对这句话中的其他每一个字都了解得非常清楚。你知道任何含有“部分”的东西,都是一个复杂的“整体\u0026quot;(whole)。你知道复杂的相反就是简单。要简单就是不要包含任何部分,你知道因为使用了“是”(is)和“者\u0026quot;(that\nwhich)这些字眼,所指的东西一定是某种“个体”(entity)。顺便一提的是,依此类推,如果没有任何一样实体东西是没有“部分”的,那么欧几里得所谈的“点”,就不可能是物质世界中的个体。\n以上的说明,是你找出字义的一个典型过程。你要用自己已经了解的一些字义来运作这个过程。如果一个定义里的每个字都还需要去定义时,那没有任何一个东西可以被定义了。如果书中每个字对你来说都陌生无比,就像你在读一本完全陌生的外文书一样的话,你会一点进展也没有。\n这就是一般人所说的,这本书读起来就像是希腊文的意思。如果这本书真的是用希腊文写的,可能这样说还公平一些。但他们只是不想去了解这本书,而不是真的看到了希腊文。任何一本书中的字,大部分都是我们所熟悉的。这些熟悉的字围绕着一些陌生的字,一些专门用语,一些可能会给读者带来困扰的字。这些围绕着的字,就是用来解读那些不懂的字的上下文。读者早就有他所需要的材料来做这件事了。\n我们并不是要假装这是一件很容易的事。我们只是坚持这并不是做不到的事。否则,没有任何人能借着读书来增进理解力。事实上,一本书之所以能给你带来新的洞察力或启发,就是因为其中有一些你不能一读即懂的字句。如果你不能自己努力去了解这些字,那就不可能学会我们所谈的这种阅读方法。你也不可能作到自己阅读一本书的时候,从不太了解进展到逐渐了解的境界。\n要做到这件事,没有立竿见影的规则。整个过程有点像是在玩拼图时尝试错误的方法。你所拼起来的部分越多,越容易找到还没拼的部分,原因只不过剩下的部分减少了。一本书出现在你面前时,已经有一堆各就各位的字。一个就位的字就代表一个词义。当你和作者用同样一个意思来使用这个字的时候,这个字就因为这个意思而被定位了。剩下的那些字也一定要找到自己的位置。你可以这样试试,那样试试,帮它们找到自己的定位。你越了解那些已经就位的文字所局部透露的景象,就越容易和剩余的文字找出共通的词义来拼好全景。每个字都找到定位,接下来的调整就容易多了。\n当然,这当中你一定会出错的。你可能以为自己已经找到某个字的归属位置与意义,但后来才发现另外一个字更适合,因而不得不整体重作一次调整。错误一定会被更正的,因为只要错误还没有被发现,整个全图就拼不出来。一旦你在这样的努力中有了找出共通词义的经验后,你很快就有能力检验自己了。你会知道自己成功了没有。当你还不了解时,你再也不会漫不经心地自以为已经了解了。\n将一本书比作拼图,其中有一个假设其实是不成立的。当然,一个好的拼图是每个部分都吻合全图的。整张图形可以完全拼出来。理想上一本好书也该是如此,但世界上并没有这样一本书。只能说如果是好书,作者会把所有的词义都整理得很清楚,很就位,以便读者能充分理解。这里,就像我们谈过的其他阅读规则一样,坏书不像好书那样有可读性。除了显示它们有多坏之外,怎么阅读它们这些规则完全帮不上。如果作者用字用得模糊不清,你根本就搞不清楚他说的是什么。你只会发现他并不知道自己说的是什么。\n但是你会问了,如果一个作者使用一个字的多重意义,难道就不是用字用得模糊不清吗?作者使用一个字,特别是非常重要的字时,包含多重意义不是很平常的事吗?\n第一个问题的答案是:不是。第二个答案是:没错。所谓用字模糊不清,是使用这个字的多重意义时,没有区别或指出其中相关的意义。(譬如我们在这一章使用“重要”这个词的时候可能就有模糊不清的现象,因为我们并没有清楚强调这是对作者来说很重要,还是对读者来说很重要。)作者这么做,就会让读者很难与他达成共识,但是作者在使用某个重要的字眼时,如果能区别其中许多不同的意义,让读者能据以辨识,那就是和读者达成共识了。\n你不要忘了一个单字是可以代表许多不同词义的。记住这件事的一个方法,是区分作者的用语(vocabulary)与专业术语(terminology)之间的不同。如果你把重要的关键字列出一张清单,再在旁边一栏列出这些字的重要意义,你就会发现用语与专业术语之间的关系了。\n另外还有一些更复杂的情况。首先,一个可以有许多不同意义的字,在使用的时候可以只用其中一个意义,也可以把多重意义合起来用。让我们再用“阅读”来当例子。在本书某些地方,我们用来指阅读任何一种书籍。在另一些地方,我们指的是教导性的阅读,而非娱乐性的阅读。还有一些其他地方,我们指的更是启发性的阅读,而非只是获得资讯。\n现在我们用一些符号来比喻,就像前面所做的,这三种不同意思的阅读,就分别是Xa,Xb及Xc。第一个地方所指的阅读是Xabc,第二个地方是Xbc,第三个是Xc。换句话说,如果这几个意思是相关的,那我们可以用一个字代表所有的状况,也可以代表部分的状况,或只是一种状况。只要把每一种用法都区分清楚,每次使用这个字就有一个不同的词义。\n其次,还有同义字的问题。除非是数学的作品,否则一个同样的字使用了一遍又一遍,看起来很别扭又无趣。因此许多好作者会在书中使用一些意义相同或是非常相似的不同的字,来代替行文中那些重要的字眼。这个情况跟一个字能代表多重意义的状况刚好相反,在这里,同一个词义,是由两个以上的同义字所代表的。\n接下来我们要用符号来解释这个问题。假设X跟Y是不同的两个字,譬如说是“启发”与“领悟”。让a代表这两个字都想表达的一个意思,譬如说“理解力的增进”,那么Xa与Ya虽然字面不同,代表的却是同样的词义。我们说阅读让我们“领悟”,或说阅读给我们“启发”,说的是同样的一种阅读。因为这两个句子说的是同样的意义。字面是不同的,但你要掌握的词义却只有一种。\n当然,这是非常重要的。如果你以为作者每次更换字眼就更换了词义,那就和你以为他每次使用同一个字都用的是同一个词义一样,犯了大错。当你将作者的用语与专业术语分别记下来的时候,要把这一点放在心上。你会发现两种关系。一种是单一个字可能与好几个词义有关,而一个词义也可能与好几个字有关。\n第三点,也是最后一点,就是片语(phrase)的问题。如果一个片语是个独立的单位,也就是说它完整,可以当一个句子的“主语\u0026quot;(subject)或“谓语\u0026quot;(predicate),那就可以把它当一个单一的字来看。这个片语就像单一的字一样可以用来形容某件事。\n因此,一个词义,可以只用一个字,也可以用一个片语来表达。所有单字与词义之间的关系,都成立于片语与词义之间的关系。两个片语所代表的可能是同一个词义,一个片语也可能表达好几个词义,这完全要看组成片语的字是如何应用的。\n一般说来,一个片语比较不会像单一的字那么容易产生模糊不清的情况。因为那是一堆字的组合,上下文的字都互相有关联,因而单个的字的意思都比较受局限。这也是为什么当作者想确定读者能充分了解他意思的时候,会喜欢用比较细致的片语来取代单字的原因。\n再作一个说明就应该很清楚了。为了确定你跟我们对于阅读这件事达成了共识,我们用类似“启发性的阅读”的句子来代替“阅读”这两个字。为了更确定清楚,我们又用了类似“如何运用你的心智来阅读一本书,也就是如何让自己从不太理解到逐渐理解的一个过程”的长句子来说明一个词义,这个词义也就是本书最强调的一种阅读。但这个词义却分别用了一个字、一个片语及一个长句子来作说明。\n这是很难写的一章,可能也是很难读的一章。原因很清楚。如果我们不用一些文法与逻辑的字眼来说明文字与词义之间的关系,我们所讨论的阅读规则就没办法让你完全清楚地理解。\n事实上,我们所谈的只是其中的一小部分。如果要完全说清楚可能要花上许多章的篇幅。我们只是将最核心部分说明清楚了。我们希望我们的说明足以在你练习时提供有用的指导。你练习得越多,越会感激那些错综复杂的问题。你也会想知道一些文学与隐喻的用字方法,抽象与具象字眼之区别,以及特殊名称与普通名称之分。你也会对所谓定义这件事感兴趣:定义一个字和定义一件事的差别是什么?为什么有些字无法定义的,却有明确的意义,等等等等。你会想要找出所谓“文字的情绪性用途”是什么意思?那就是运用文字唤醒情绪,感动一个人采取行动,或是改变思想,这是与传达知识不同的用途。你甚至会有兴趣了解日常“理性”(rational)的谈话,与“情绪性”(bizarre)或“疯狂”(crazy)的对话有何不同—后两种谈话是精神状态受到干扰,使用的每个字都很怪异,出乎意外,却又有清楚的弦外之音。\n如果因为练习分析阅读而引发你的兴趣,你可以利用这种阅读多读一点和这些主题相关的书。在阅读这些书时,你会获得更多的好处,因为你是在阅读的经验中,提出了自己的问题而去找这些书的。文法与逻辑学,是架构以上这些规则的基础,如果你想研究这两门学问,必须实际运用才有用。\n你也可能并不想再研究下去。就算你不想,只要你肯花一点精神,在读一本书的时候,找出重要的关键字,确认每个字不同意义的转换,并与作者找出共通的词义,你对一本书的理解力就会大大增加了。很少有一些习惯上的小小改变,会产生如此宏大的效果。\n第九章 判断作者的主旨 # 书的世界与生意的世界一样,不但要懂得达成共识,还要懂得提案。买方或卖方的提案是一种计划、一种报价或承诺。在诚实的交易中,一个人提案,就是声明他准备依照某种模式来做事的意图。成功的谈判协商,除了需要诚实外,提案还要清楚,有吸引力。这样交易的双方才能够达成共识。\n书里的提案,也就是主旨,也是一种声明。那是作者在表达他对某件事的判断。他断言某件他认为是真的事,或否定某件他判断是假的事。他坚持这个或那个是事实。这样的提案,是一种知识的声明,而不是意图的声明。作者的意图可能在前言的一开头就告诉我们了。就一部论说性的作品来说,通常他会承诺要指导我们做某件事。为了确定他有没有遵守这些承诺,我们就一定要找出他的主旨(propositions)才行。\n一般来说,阅读的过程与商业上的过程正好相反。商人通常是在找出提案是什么后,才会达成共识。但是读者却要先与作者达成共识,才能明白作者的主旨是什么,以及他所声明的是什么样的判断。这也是为什么分析阅读的第五个规则会与文字及词义有关,而第六个,也就是我们现在要讨论的,是与句子及提案有关的规则。\n第七个规则与第六个规则是息息相关的。一位作者可能借着事件、事实或知识,诚实地表达自己的想法。通常我们也是抱着对作者的信任感来阅读的。但是除非我们对作者的个性极端感兴趣,否则只是知道他的观点并不能满足我们。作者的主旨如果没有理论的支持,就只是在抒发个人想法罢了。如果是这本书、这个主题让我们感兴趣,而不是作者本身,那么我们不只想要知道作者的主张是什么,还想知道为什么他认为我们该被说服,以接受这样的观点。\n因此,第七个规则与各种论述(arguments)有关。一种说法总是受到许多理由、许多方法的支持。有时候我们可以强力主张真实,有时候则顶多谈谈某件事的可能。但不论哪种论点都要包含一些用某种方式表达的陈述。“因为”那样,所以会说这样。“因为”这两个字就代表了一个理由。\n表达论述时,会使用一些字眼把相关的陈述联系起来,像是:“如果”真是如此,“那么”就会那样。或“因为”如此,“所以”那样。或“根据”这个论述,那就会如此这般。在本书较前面的章节中,也出现这种前后因果相关的句子。因为对我们这些离开学校的人来说,我们了解到,如果我们还想要继续学习与发现,就必须知道如何能让一本书教导我们。在那样的情况中,“如果”我们想要继续学习,“那么”我们就要知道如何从书中,从一个不在我们身边的老师那儿学习。\n一个论述总是一套或一连串的叙述,提供某个结论的根据或理由。因此,在说明论点时,必须要用到一段文字,或至少一些相关的句子来阐述。一开始可能不会先说论点的前提或原则,但那却是结论的来源。如果这个论述成立,那么结论一定是从前提中推演出来的。不过这么说也并不表示这个结论就一定真实,因为可能有某个或所有的前提假设都是错的。\n我们说明这些规则的顺序,都是有文法与逻辑的根据的。我们从共识谈到主旨,再谈到论点,表达的方法是从字(与词)到一个句子,再到一连串的句子(或段落)来作说明。我们从最简单的组合谈到复杂的组合。当然,一本书含有意义的最小单位就是“字”。但是如果说一本书就是一连串字的组合,没有错,却并不恰当。书中也经常把一组组的字,或是一组组的句子来当单位。一个主动的读者,不只会注意到字,也会注意到句子与段落。除此之外,没有其他方法可以发现一个作者的共识、主旨与论点。\n我们把分析阅读谈到这里时—目的是在诠释作者的意图—似乎和第一个阶段的发展方向背道而驰—第一阶段的目的是掌握结构大纲。我们原先从将一本书当作是个整体,谈到书中的主要部分,再谈到次要的部分。不过你可能也猜得到,这两种方法会有交集点。书中的主要部分,与主要的段落都包含了许多主旨,通常还有许多论点。如果你继续将一本书细分成许多部分,最后你会说:“在这一部分,导引出来了下面这些重点。”现在,每一个重点都像是主旨,而其中有一些主旨可能还组成一个论述。\n因此,这两个过程,掌握大纲与诠释意图,在主旨与论述的层次中互相交集了。你将一本书的各个部分细分出来,就可以找出主旨与论述。然后你再仔细分析一个论述由哪些主旨,甚至词义而构成。等这两个步骤你都完成时,就可以说是真的了解一本书的内容了。\n1.句子与主旨 # 我们已经提到,在这一章里,我们还会讨论与这个规则有关的其他的事。就像关于字与共识的问题一样,我们也要谈语言与思想的关系。句子与段落是文法的单位、语言的单位。主旨与论述是逻辑的单位,也就是思想与知识的单位。\n我们在这里要面对的问题,跟上一章要面对的问题很相似。因为语言并不是诠释思想最完美的媒介;因为一个字可以有许多意义,而不只一个字也可能代表同一种的意义,我们可以看出一个作者的用语与专业术语之间的关系有多复杂了。一个字可能代表多重的意思,一个意思也可能以许多字来代表。\n数学家将一件上好的外套上的纽扣与纽扣洞之间,比喻成一对一的关系。每一个纽扣有一个适合的纽扣洞,每一个纽扣洞也有一个适合的纽扣。不过,重点是:字与意思之间的关系并不是一对一的。在应用这个规则时,你会犯的最大错误就是认为在语言及思想或知识之间,是一对一的关系。\n事实上,聪明一点的做法是,即使是纽扣与纽扣洞之间的关系,也不要作太简单的假设。男人西装外套的袖子上面有纽扣,却没有纽扣洞。外套穿了一阵子,上面也可能只有洞,而没有纽扣。\n让我们说明句子与主旨之间的关系。并不是一本书中的每一句话都在谈论主旨。有时候,一些句子在表达的是疑问。他们提出的是问题,而不是答案。主旨则是这些问题的答案。主旨所声明的是知识或观点。这也是为什么我们说表达这种声明的句子是叙述句(declarative),而提出问题的句子是疑问句(interrogative)\no其他有些句子则在表达希望或企图。这些句子可能会让我们了解一些作者的意图,却并不传达他想要仔细推敲的知识。\n除此之外,并不是每一个叙述句都能当作是在表达一个主旨。这么说至少有两个理由。第一个是事实上,字都有歧义,可以用在许多不同的句子中。因此,如果字所表达的意思改变了,很可能同样的句子却在阐述不同的主旨。“阅读就是学习”,这是一句简单的陈述。但是有时候,我们说“学习”是指获得知识,而在其他时候我们又说学习是发展理解力。因为意思并不一样,所以主旨也都不同。但是句子却是相同的。\n另一个理由是,所有的句子并不像“阅读就是学习”这样单纯。当一个简单的句子使用的字都毫无歧义时,通常在表达的是一个单一的主旨。但就算用字没有歧义,一个复合句也可能表达一个或两个主旨。一个复合句其实是一些句子的组合,其间用一些字如“与”、“如果……就”或“不但……而且”来作连接。你可能会因而体认到,一个复合句与一小段文章段落之间的差异可能很难区分。一个复合句也可以用论述方式表达许多不同的主旨。\n那样的句子可能很难诠释。让我们从马基雅维里(Niccolo Machiavelli)的《君主论》(The Prince)中找一段有趣的句子来作说明:\n一个君王就算无法赢得人民的爱戴,也要避免憎恨,以唤起人民的敬畏;因为只要他不剥夺人民的财产与女人,他就不会被憎恨,也就可以长长久久地承受人民的敬畏。在文法上来说,这是一个单一的句子,不过却十分复杂。分号与“因为”是全句的主要分段。第一个部分的主旨是君王应该要以某种方法引起人民的敬畏。\n而从“因为”开始,事实上是另一句话。(这也可以用另一种独立的叙述方式:“他之所以能长久承受人民敬畏,原因是……”等等。)这个句子至少表达了两个主旨:(1)一个君王应该要引起人民敬畏的原因是,只要他不被憎恨,他就能长长久久地被人民敬畏着。(2)要避免被人民憎恨,他就不要去剥夺人民的财产与女人。\n在一个又长又复杂的句子里,区分出不同的主旨是很重要的。不论你想要同意或不同意马基雅维里的说法,你都要先了解他在说的是什么意思。但是在这个句子中,他谈到的是三件事。你可能不同意其中的一点,却同意其他两点。你可能认为马基雅维里是错的,因为他在向所有的君王推广恐怖主义。但你可能也注意到他精明地说,最好不要让人民在敬畏中带有恨意。你可能也会同意不要剥夺人民的财产与女人,是避免憎恨的必要条件。除非你能在一个复杂句中辨认出不同的主旨,否则你无法判断这个作者在谈些什么。\n律师都非常清楚这个道理。他们会仔细看原告陈述的句子是什么,被告否认的说法又是什么。一个简单的句子:“约翰·唐签了三月二十四日的租约。”看起来够简单了,但却说了不只一件事,有些可能是真的,有些却可能是假的。约翰·唐可能签了租约,但却不是在三月二十四日,而这个事实可能很重要。简单来说,就算一个文法上的单一句子,有时候说的也是两个以上的主旨。\n在区分句子与主旨之间,我们已经说得够清楚了。它们并不是一对一的关系。不只是一个单一的句子可以表达出不同的主旨,不管是有歧义的句子或复合句都可以,而且同一个主旨也能用两个或更多不同的句子来说明。如果你能抓住我们在字里行间所用的同义字,你就会知道我们在说:“教与学的功能是互相连贯的”与“传授知识与接受知识是息息相关的过程”这两句话时,所谈的是同一件事。\n我们不再谈文法与逻辑相关的重点,而要开始谈规则了。在这一章里,就跟上一章一样,最难的就是要停止解释。无论如何,我们假设你已经懂一点文法了。我们并不是说你一定要完全精通语句结构,但你应该注意一个句子中字的排列顺序,与彼此之间的关系。对一个阅读者来说,有一些文法的知识是必要的。除非你能越过语言的表象,看出其中的意义,否则你就无法处理有关词义、主旨与论述—思想的要素—的问题。只要文字、句子与段落是不透明的、未解析的,他们就是沟通的障碍,而不是媒介。你阅读了一些字,却没有获得知识。\n现在来谈规则。你在上一章已经看到第五个规则了:找出关键字,与作者达成共识。第六个规则可以说是:将一本书中最重要的句子圈出来,找出其中的主旨。第七个规则是:从相关文句的关联中,设法架构出一本书的基本论述。等一会儿你会明白,在这个规则中,我们为什么不用“段落”这样的字眼。\n顺便一提的是,这些新规则与前面所说的与作者达成共识的规则一样,适用于论说性的作品。当你在念一本文学作品—小说、戏剧与诗时,这些关于主旨与论述的规则又大不相同。后面我们会谈到在应用时要如何作些改变,以便阅读那些书籍。\n2.找出关键句 # 在一本书中,最重要的句子在哪里?要如何诠释这些句子,才能找到其中包含的一个或多个主旨?\n再一次,我们的重点在于挑出什么才是重要的。我们说一本书中真正的关键句中只有少数的几句话,并不是说你就可以忽略其他的句子。当然,你应该要了解每一个句子。而大多数的句子,就像大多数的文字一样,对你来说都是毫无困难的。我们在谈速读时提到过,在读这些句子时可以相当快地读过去。从一个读者的观点来看,对你重要的句子就是一些需要花一点努力来诠释的句子,因为你第一眼看到这些句子时并不能完全理解。你对这些句子的理解,只及于知道其中还有更多需要理解的事。这些句子你会读得比较慢也更仔细一点。这些句子对作者来说也许并不是最重要的,但也很可能就是,因为当你碰到作者认为最重要的地方时,应该会特别吃力。用不着说,你在读这些部分 时应该特别仔细才好。\n从作者的观点来看,最重要的句子就是在整个论述中,阐述作者判断的部分。一本书中通常包含了一个以上或一连串的论述。作者会解释为什么他现在有这样的观点,或为什么他认为这样的情况会导致严重的后果。他也可能会讨论他要使用的一些字眼。他会批评别人的作品。他会尽量加人各种相关与支持的论点。但他沟通的主要核心是他所下的肯定与否定的判断.以及他为什么会这么做的理由。因此,要掌握住重点,就要从文章中看出浮现出来的重要句子。\n有些作者会帮助你这么做。他们会在这些字句底下划线。他们不是告诉你说这些是重点,就是用不同的印刷字体将主要的句子凸显出来。当然,如果你阅读时昏昏沉沉的,这些都帮不上忙了。我们碰到过许多读者或学生,根本不注意这些已经弄得非常清楚的记号。他们只是一路读下去,而不肯停下来仔细地观察这些重要的句子。\n有少数的书会将主旨写在前面,用很明显的位置来加以说明。欧几里得就给了我们一个最明显的例子。他不只一开始就说明他的定义,假设及原理—他的基本主旨—同时还将每个主旨都加以证明。你可能并不了解他的每一种说法,也可能不同意他所有的论点,但你却不能不注意到这些重要的句子,或是证明他论述的一连串句子。\n圣托马斯·阿奎那写的《神学大全》(Summa Theologica),解说重要句子的方式也是将这些重点特别凸显出来。他用的方式是提出问题。在每一个段落的开始会先提出问题来。这些问题都暗示着阿奎那想要辩解的答案,且包括了完全相对立的说法。阿奎那想要为自己的想法辩护时,会用“我的回答”这样的句子标明出来。在这样的书—既说明理由,又说出结论的书中,没有理由说看不到重要的句子。但是对一些把任何内容都同等重视的读者来说,这样的书还是一团迷雾。他们在阅读时不管是快或慢,都以同样的速度阅读全书。而这通常也意味着所有的内容都不太重要。\n除了这些特别标明重点、提醒读者注意哪些地方很需要诠释的书之外,找出重要的句子其实是读者要替自己做的工作。他可以做的事有好几件。我们已经提过其中一件了。如果他发现在阅读时,有的一读便懂,有的却难以理解,他就可以认定这个句子是含有主要的意义了。或许你开始了解了,阅读的一部分本质就是被困惑,而且知道自己被困惑。怀疑是智慧的开始,从书本上学习跟从大自然学习是一样的。如果你对一篇文章连一个问题也提不出来,那么你就不可能期望一本书能给你一些你原本就没有的视野。\n另一个找出关键句的线索是,找出组成关键句的文字来。如果你已经将重要的字圈出来了,它一定会引导你看到值得注意的句子。因此在诠释阅读法中,第一个步骤是为第二个步骤作准备的。反之亦然。很可能你是因为对某些句子感到困惑,而将一些字作上记号的。事实上,虽然我们在说明这些规则时都固定了前后的顺序,但你却不一定要依照这个顺序来阅读。词义组成了主旨,主旨中又包含了词汇。如果你知道这个字要表达的意思,你就能抓住这句话中的主旨了。如果你了解了一句话要说明的主旨,你也就是掌握了其中词义的意思。\n接下来的是更进一步找出最主要的主旨的线索。这些主旨一定在一本书最主要的论述中—不是前提就是结论。因此,如果你能依照顺序找出这些前后相关的句子—找出有始有终的顺序,你可能就已经找到那些重要的关键句子了。\n我们所说的顺序,要有始有终。任何一种论述的表达,都需要花点时间。你可以一口气说完一句话,但你要表达一段论述的时候却总要有些停顿。你要先说一件事,然后说另一件事,接下来再说另一件事。一个论述是从某处开始,经过某处,再到达某处的。那是思想的演变移转。可能开始时就是结论,然后再慢慢地将理由说出来。也可能是先说出证据与理由,再带引你达到结论。\n当然,这里还是相同的道理:除非你知道怎么运用,否则线索对你来说是毫无用处的。当你看到某个论述时,你要去重新整理。虽然有过一些失望的经验,我们仍然相信,人类头脑看到论述时之敏感,一如眼睛看到色彩时的反应。(当然,也可能有人是“论述盲”的!)但是如果眼睛没有张开,就看不到色彩。头脑如果没有警觉,就无法察觉论述出现在哪里了。\n许多人认为他们知道如何阅读,因为他们能用不同的速度来阅读。但是他们经常在错误的地方暂停,慢慢阅读。他们会为了一个自己感兴趣的句子而暂停,却不会为了感到困扰的句子而暂停。事实上,在阅读非当代作品时,这是最大的障碍。一本古代的作品包含的内容有时很令人感到新奇,因为它们与我们熟知的生活不同。但是当你想要在阅读中获得理解时,你要追寻的就不是那种新奇的感觉了。一方面你会对作者本身,或对他的语言,或他使用的文字感兴趣,另一方面,你想要了解的是他的思想。就因为有这些原因,我们所讨论的规则是要帮助你理解一本书,而不是满足你的好奇心。\n3.找出主旨 # 假设你已经找到了重要的句子,接下来就是第六个规则的另一个,要求了。你必须找出每个句子所包含的主旨。这是你必须知道句子在说什么的另一种说法。当你发现一段话里所使用的文字的意义时,你就和作者找到了共识。同样的,诠释过组成句子的每个字,特别是关键字之后,你就会发现主旨。\n再说一遍,除非你懂一点文法,否则没法做好这件事。你要知道形容词与副词的用法,而动词相对于名词的作用是什么,一些修饰性的文字与子句,如何就它们所修饰的字句加以限制或扩大等等。理想上,你可以根据语句结构的规则,分析整个句子。不过你用不着很正式地去做这件事。虽然现在学校中并不太重视文法教学,但我们还是假设你已经懂一点文法了。我们不能相信你不懂这回事,不过在阅读的领域中,可能你会因为缺少练习而觉得生疏。\n在找出文字所表达的意思与句子所阐述的主旨之间,只有两个不同之处。一个是后者所牵涉的内容比较多。就像你要用周边的其他字来解释一个特殊的字一样,你也要借助前后相关的句子来了解那个问题句。在两种情况中,都是从你了解的部分,进展到逐渐了解你原来不懂的部分。\n另一个不同是,复杂的句子通常要说明的不只一个主旨。除非你能分析出所有不同,或相关的主旨,否则你还是没有办法完全诠释一个重要的句子。要熟练地做到这一点,就需要常常练习。试着在本书中找出一些复杂的句子,用你自己的话将其中的主旨写出来。列出号码,找出其间的相关性。\n“用你自己的话来说”,是测验你懂不懂一个句子的主旨的最佳方法。如果要求你针对作者所写的某个句子作解释,而你只会重复他的话,或在前后顺序上作一些小小的改变,你最好怀疑自己是否真的了解了这句话。理想上,你应该能用完全不同的用语说出同样的意义。当然,这个理想的精确度又可以分成许多程度。但是如果你无法放下作者所使用的字句,那表示他所传给你的,只是这个“字”,而不是他的“思想或知识”。你知道的只是他的用字,而不是他的思想。他想要跟你沟通的是知识,而你获得的只是一些文字而已。\n将外国语文翻译成英文的过程,与我们所说的这个测验有关。如果你不能用英文的句子说出法文的句子要表达的是什么,那你就知道自己其实并不懂这句法文。就算你能,你的翻译可能也只停留在口语程度—因为就算你能很精确地用英文复述一遍,你还是可能不清楚法文句子中要说明的是什么。\n要把一句英文翻译成另一种语文,就更不只是口语的问题了。你所造出来的新句子,并不是原文的口语复制。就算精确,也只是意思的精确而已。这也是为什么说如果你想要确定自己是否吸收了主旨,而不只是生吞活剥了字句,最好是用这种翻译来测试一下。就算你的测验失败了,你还是会发现自己的理解不及在哪里。如果你说你了解作者在说些什么,却只能重复作者所说过的话,那一旦这些主旨用其他字句来表达时,你就看不出来了。\n一个作者在写作时,可能会用不同的字来说明同样的主旨。读者如果不能经由文字看出一个句子的主旨,就容易将不同的句子看作是在说明不同的主旨。这就好像一个人不知道2+2=4跟4-2=2虽然是不同的算式,说明的却是同一个算术关系—这个关系就是四是二的双倍,或二是四的一半。\n你可以下结论说,这个人其实根本不懂这个问题。同样的结论也可以落在你身上,或任何一个无法分辨出用许多相似句子说明同一个主旨的人,或是当你要他说出一个句子的主旨时,他却无法用自己的意思作出相似的说明。\n这里已经涉及主题阅读—就同一个主题,阅读好几本书。不同的作者经常会用不同的字眼诉说同一件事,或是用同样的字眼来说不同的事。一个读者如果不能经由文字语言看出意思与主旨,就永远不能作相关作品的比较。因为口语的各不相同,他会误以为一些作者互不同意对方的说法,也可能因为一些作者叙述用语相近,而忽略了他们彼此之间的差异。\n还有另一个测验可以看出你是否了解句中的主旨。你能不能举出一个自己所经历过的主旨所形容的经验,或与主旨有某种相关的经验?你能不能就作者所阐述的特殊情况,说明其中通用于一般的道理?虚构一个例子,跟引述一个真实的例子都行。如果你没法就这个主旨举任何例子或作任何说明,你可能要怀疑自己其实并不懂这个句子在说些什么。\n并不是所有的主旨都适用这样的测验方法。有些需要特殊的经验,像是科学的主旨你可能就要用实验室来证明你是否明白了。但是主要的重点是很清楚的。主旨并非存在于真空状态,而是跟我们生存的世界有关。除非你能展示某些与主旨相关的,实际或可能的事实,否则你只是在玩弄文字,而非理解思想或知识。\n让我们举一个例子。在形上学中,一个基本的主旨可以这样说明:“除了实际存在的事物,没有任何东西能发生作用。”我们听到许多学生很自满地向我们重复这个句子。他们以为只要以口语完美地重复这个句子,就对我们或作者有交待了。但是当我们要他们以不同的句子说明这句话中的主旨时,他们就头大了。很少有人能说出:如果某个东西不存在,就不能有任何作用之类的话。但是这其实是最浅显的即席翻译—至少,对任何一个懂得原句主旨的人来说,是非常浅显的。\n既然没有人能翻译出来,我们只好要他们举出一个主旨的例证。如果他们之中有人能说出:只靠可能会卞的雨滴,青草是不会滋长的;或者,只靠可能有的储蓄,一个人的存款账目是不会增加的。这样我们就知道他们真的抓到主旨了。\n“口语主义”(verbalism)的弊端,可以说是一种使用文字,没有体会其中的思想传达,或没有注意到其中意指的经验的坏习惯。那只是在玩弄文字。就如同我们提出来的两个测验方法所指出的,不肯用分析阅读的人,最容易犯玩弄文字的毛病。这些读者从来就没法超越文字本身。他们只能记忆与背诵所读的东西而已。现代教育家所犯的一个最大的错误就是违反了教育的艺术,他们只想要背诵文字,最后却适得其反。没有受过文法和逻辑艺术训练的人,他们在阅读上的失败—以及处处可见的“口语主义”—可以证明如果缺乏这种训练,会如何成为文字的奴隶,而不是主人。\n4.找出论述 # 我们已经花了很多时间来讨论主旨。现在来谈一下分析阅读的第七个规则。这需要读者处理的是一堆句子的组合。我们前面说过,我们不用“读者应该找出最重要的段落”这样的句子来诠释这条阅读规则,是有理由的。这个理由就是,作者写作的时候,并没有设定段落的定则可循。有些伟大的作家,像蒙田、洛克或普鲁斯特,写的段落奇长无比;其他一些作家,像马基雅维里、霍布斯或托尔斯泰,却喜欢短短的段落。现代人受到报纸与杂志风格的影响,大多数作者会将段落简化,以符合快速与简单的阅读习惯。譬如现在这一段可能就太长了。如果我们想要讨好读者,可能得从“有些伟大的作家”那一句另起一段。\n这个问题不只跟长度有关。还牵涉到语言与思想之间关系的问题。指导我们阅读的第七个规则的逻辑单位,是“论述”—一系列先后有序,其中某些还带有提出例证与理由作用的主旨。如同“意思”之于文字,“主旨”之于句子,“论述”这个逻辑单位也不会只限定于某种写作单位里。一个论述可能用一个复杂的句子就能说明。可能用一个段落中的某一组句子来说明。可能等于一个段落,但又有可能等于好几个段落。\n另外还有一个困难点。在任何一本书中都有许多段落根本没有任何论述—就连一部分也没有。这些段落可能是一些说明证据细节,或者如何收集证据的句子。就像有些句子因为有点离题比较远而属于次要,段落也有这种情况。用不着说,这部分可以快快地读过去。\n因此,我们建议第七个规则可以有另一个公式:如果可以,找出书中说明重要论述的段落。但是,如果这个论述并没有这样表达出来,你就要去架构出来。你要从这一段或那一段中挑选句子出来,然后整理出前后顺序的主旨,以及其组成的论述。\n等你找到主要的句子时,架构一些段落就变得很容易了。有很多方法可试。你可以用一张纸,写下构成一个论述的所有主旨。通常更好的方法是,就像我们已经建议过的,在书的空白处作上编号,再加上其他记号,把一些应该排序而读的句子标示出来。\n读者在努力标示这些论述的时候,作者多少都帮得上一点忙。一个好的论说性书籍的作者会想要说出自己的想法,而不是隐藏自己的想法。但并不是每个好作者用的方法都一模一样。像欧几里得、伽利略、牛顿(以几何学或数学方式写作的作者),就很接近这样的想法:一个段落就是一个论述。在非数学的领域中,大多数作者不是在一个段落里通常会有一两个以上的论点,就是一个论述就写上好几段。\n一本书的架构比较松散时,段落也比较零乱。你经常要读完整章的段落,才能找出几个可供组合一个论述的句子。有些书会让你白费力气,有些书甚至不值得这么做。\n一本好书在论述进行时会随时作摘要整理。如果作者在一章的结尾为你作摘要整理,或是摘在某个精心设计的部分,你就要回顾一下刚才看的文章,找出他作摘要的句子是什么。在《物种起源》中,达尔文在最后一章为读者作全书的摘要,题名为“精华摘要与结论”。看完全书的读者值得受到这样的帮助。没看过全书的人,可就用不上了。\n顺便一提,如果在进行分析阅读之前,你已经浏览过一本书,你会知道如果有摘要,会在哪里。当你想要诠释这本书时,你知道如何善用这些摘要。\n一本坏书或结构松散的书的另一个征兆是忽略了论述的步骤。有时候这些忽略是无伤大雅,不会造成不便,因为纵使主旨不清楚,读者也可以借着一般的常识来补充不足之处。但有时候这样的忽略却会产生误导,甚至是故意的误导。一些演说家或宣传家最常做的诡计就是留下一些未说的话,这些话与他们的论述极为有关,但如果说得一清二楚,可能就会受到挑战。我们并不担心一位想要指导我们的诚恳的作者使用这样的手法。但是对一个用心阅读的人来说,最好的法则还是将每个论述的步骤都说明得一清二楚。\n不论是什么样的书,你身为读者的义务都是一样的。如果这本书有一些论述,你应该知道是些什么论述,而能用简洁的话说出来。任何一个好的论述都可以作成简要的说明。当然,有些论述是架构在其他的论述上。在精细的分析过程中,证实一件事可能就是为了证实另一件事。而这一切又可能是为了作更进一步的证实。然而,这些推理的单位都是一个个的论述。如果你能在阅读任何一本书时发现这些论述,你就不太可能会错过这些论述的先后顺序了。\n你可能会抗议,这些都是说来容易的事。但是除非你能像一个逻辑学家那样了解各种论述的架构,否则当作者并没有在一个段落中说明清楚这论述时,谁能在书中找出这些论述,更别提要架构出来?\n这个问题的答案很明显,对于论述,你用不着像是一个逻辑学者一样来研究。不论如何,这世上只有相对少数的逻辑学者。大多数包含着知识,并且能指导我们的书里,都有一些论述。这些论述都是为一般读者所写作的,而不是为了逻辑专家写的。\n在阅读这些书时用不着伟大的逻辑概念。我们前面说过,在阅读的过程中你能让大脑不断地活动,能跟作者达成共识,找到他的主旨,那么你就能看出他的论述是什么了。而这也就是人类头脑的自然本能。\n无论如何,我们还要谈几件事,可能会有助于你进一步应用这个阅读规则。首先,要记住所有的论述都包含了一些声明。其中有些是你为什么该接受作者这个论述的理由。如果你先找到结论,就去看看理由是什么。如果你先看到理由,就找找看这些理由带引你到什么样的结论上。\n其次,要区别出两种论述的不同之处。一种是以一个或多个特殊的事实证明某种共通的概念,另一种是以连串的通则来证明更进一步的共通概念。前者是归纳法,后者是演绎法。但是这些名词并不重要。重点在如何区分二者的能力。\n在科学著作中,看一本书是用推论来证实主张,还是用实验来证实主张,就可以看出两者的区别。伽利略在《两种新科学》中,借由实验结果来说明数学演算早就验证的结论。伟大的生理学家威廉·哈维(William Harvey)在他的书《心血运动论》(On the Motion of\ntheHeart)中写道:“经由推论与实验证明,心室的脉动会让血液流过肺部及心脏,再推送到全身。”有时候,一个主旨是有可能同时被一般经验的推论,及实验两者所支持的。有时候,则只有一种论述方法。\n第三,找出作者认为哪些事情是假设,哪些是能证实的或有根据的,以及哪些是不需要证实的自明之理。他可能会诚实地告诉你他的假设是什么,或者他也可能很诚实地让你自己去发掘出来。显然,并不1是每件事都是能证明的,就像并不是每个东西都能被定义一样。如果每一个主旨都要被证实过,那就没有办法开始证实了。像定理、假设或推论,就是为了证实其他主旨而来的。如果这些其他的主旨被证实了,就可以作更进一步论证的前提了。\n换句话说,每个论述都要有开端。基本上,有两种开始的方法或地方:一种是作者与读者都同意的假设,一种是不论作者或读者都无法否认的自明之理。在第一种状况中,只要彼此认同,这个假设可以是任何东西。第二个情况就需要多一点的说明了。\n近来,不言自明的主旨都被冠上“废话重说\u0026quot;\n(tautology)的称呼。这个说法的背后隐藏着一种对细微末节的轻蔑态度,或是怀疑被欺骗的感觉。这就像是兔子正在从帽子里被揪出来。你对这个事实下了一个定义,然后当他出现时,你又一副很惊讶的样子。然而,不能一概而论。\n譬如在“父亲的父亲就是祖父”,与“整体大于部分”两个主旨之间,就有值得考虑的差异性。前面一句话是自明之理,主旨就涵盖在定义之中。那只是肤浅地掩盖住一种语言的约定:“让我们称父母的父母为祖父母。”这与第二个主旨的情形完全不同。我们来看看为什么会这样。\n“整体大于部分。”这句话在说明我们对一件事的本质,与他们之间关系的了解,不论我们所使用的文字或语言有什么变迁,这件事都不会改变的。定量的整体,一定可以区分成是量的部分,就像一张纸可以切成两半或分成四份一样。既然我们已经了解了一个定量的整体(指任何一种有限的定量的整体),也知道在定量的整体中很明确的某一部分,我们就可以知道整体比这个部分大,或这个部分比整体小了。到目前为止,这些都是口头上的说明,我们并不能为“整体”或“部分”下定义。这两个概念是原始的或无法定义的观念,我们只能借着整体与部分之间的关系,表达出我们对整体与部分的了解。\n这个说法是一种不言自明的道理—尤其当我们从相反的角度来看,一下子就可以看出其中的错误。我们可以把一张纸当作是一个“部分”,或是把纸切成两半后,将其中的一半当作是“整体”,但我们不能认为这张纸在还没有切开之前的“部分”,小于切开来后的一半大小的“整体”。无论我们如何运用语言,只有当我们了解定量的整体与其中明确的部分之后,我们才能说我们知道整体大于部分了。而我们所知道的是存在的整体与部分之间的关系,不只是知道名词的用法或意义而已。\n这种不言自明的主旨是不需要再证实,也不可否认的事实。它们来自一般的经验,也是普通常识的一部分,而不是有组织的知识;不隶属哲学、数学,却更接近科学或历史。这也是为什么欧几里得称这种概念为“普通观念\u0026quot;(Common notion)。尽管像洛克等人并不认为如此,但这些观念还是有启迪的作用。洛克看不出一个没有启发性的主旨(像关于祖父母的例子),和一个有启发性的主旨(像整体与部分关系的例子),两者之间到底有什么不同—后者对我们真的有教育作用,如果我们不学习就不会明白其中的道理。今天有些人认为所有的这类主旨都是“废话重说”,也是犯了同样的错误。他们没看出来有些所谓的“废话重说”确实能增进我们的知识—当然,另外有一些则的确不能。\n5.找出解答 # 这三个分析阅读的规则—关于共识、主旨与论述—可以带出第八个规则了,这也是诠释一本书的内容的最后一个步骤。除此之外,那也将分析阅读的第一个阶段(整理内容大纲)与第二阶段(诠释内容)连接起来了。\n在你想发现一本书到底在谈些什么的最后一个步骤是:找出作者在书中想要解决的主要问题(如果你回想一下,这在第四个规则中已经谈过了)。现在,你已经跟作者有了共识,抓到他的主旨与论述了,你就该检视一下你收集到的是什么资料,并提出一些更进一步的问题来。作者想要解决的问题哪些解决了?为了解决问题,他是否又提出了新问题?无论是新问题或旧问题,哪些是他知道自己还没有解决的?一个好作者,就像一个好读者一样,应该知道各个问题有没有解决—当然,对读者来说,要承认这个状况是比较容易的。\n诠释作品的阅读技巧的最后一部分就是:规则八,找出作者的解答。你在应用这个规则及其他三个规则来诠释作品时,你可以很清楚地感觉到自己已经开始在了解这本书了。如果你开始读一本超越你能力的书—也就是能教导你的书—你就有一段长路要走了。更重要的是,你现在已经能用分析阅读读完一本书了。这第三个,也是最后一个阶段的工作很容易。你的心灵及眼睛都已经打开来了,而你的嘴闭上了。做到这一点时,你已经在伴随作者而行了。从现在开始,你可以有机会与作者辩论,表达你自己的想法。\n6.分析阅读的第二个阶段 # 我们已经说明清楚分析阅读的第二个阶段。换句话说,我们已经准备好材料,要回答你在看一本书,或任何文章都应该提出来的第二个基本问题了。你会想起第二个问题是:这本书的详细内容是什么?如何叙述的?只要运用五到八的规则,你就能回答这个问题。当你跟作者达成共识,找出他的关键主旨与论述,分辨出如何解决他所面对的问题,你就会知道他在这本书中要说的是什么了。接下来,你已经准备好要问最后的两个基本问题了。\n我们已经讨论完分析阅读的另一个阶段,就让我们暂停一下,将这个阶段的规则复述一遍:\n分析阅读的第二个阶段,或找出一本书到底在说什么的规则(诠释一本书的内容):\n(5)诠释作者使用的关键字,与作者达成共识。\n(6)从最重要的句子中抓出作者的重要主旨。\n(7)找出作者的论述,重新架构这些论述的前因后果,以明白作者的主张。\n(8)确定作者已经解决了哪些问题,还有哪些是未解决的。在未解决的问题中,确定哪些是作者认为自己无法解决的问题。\n第十章 公正地评断一本书 # 在上一章的结尾,我们说,我们走了一段长路才来到这里。我们已经学过如何为一本书列出大纲。我们也学过诠释书本内容的四个规则。现在我们准备要做的就是分析阅读的最后一个阶段。在这个阶段中,你前面所做的努力都会有回报了。\n阅读一本书,是一种对话。或许你不这么认为,因为作者一路说个不停,你却无话可说。如果你这么想,你就是并不了解作为一个读者的义务—你也并没有掌握住自己的机会。\n事实上,读者才是最后一个说话的人。作者要说的已经说完了,现在该读者开口了。一本书的作者与读者之间的对话,就跟平常的对话没有两样,每个人都有机会开口说话,也不会受到干扰。如果读者没受过训练又没礼貌,这样的对话可能会发生任何事,却绝不会井井有条。可怜的作者根本没法为自己辩护。他没法说:“喂!等我说完,你再表示不同的意见可以吗?”读者误解他,或错过重点时,他也没法抗议。\n在一般的交谈中,必须双方都很有礼貌才能进行得很好。我们所想的礼貌却并不是一般社交礼仪上的礼貌。那样的礼貌其实并不重要。真正重要的是遵守思维的礼节。如果没有这样的礼节,谈话会变成争吵,而不是有益的沟通。当然,我们的假设是这样的谈话跟严肃的问题有关,一个人可以表达相同或不同的意见。他们能不能把自己表达得很好就变得很重要了。否则这个活动就毫无利益而言了。善意的对话最大的益处就是能学到些什么。\n在一般谈话来说有道理的事,对这种特殊的交谈情况—作者与读者借一本书来进行对话—又更有道理一些。我们姑且认为作者受过良好的训练,那么在一本好书中,他的谈话部分就扮演得很好,而读者要如何回报呢?他要如何圆满地完成这场交谈呢?\n读者有义务,也有机会回话。机会很明显。没有任何事能阻碍一个读者发表自己的评论。无论如何,在读者与书本之间的关系的本质中,有更深一层的义务关系。\n如果一本书是在传递知识的,作者的目标就是指导。他在试着教导读者。他想要说服或诱导读者相信某件事。只有当最后读者说:“我学到了。你已经说服我相信某些事是真实的,或认为这是可能发生的”,这位作者的努力才算成功了。但是就算读者未被说服或诱导,作者的企图与努力仍然值得尊敬。读者需要还他一个深思熟虑的评断。如果他不能说:“我同意。”至少他也要有不同意的理由,或对间题提出怀疑的论断。\n其实我们要说的前面已经不知说过多少次了。一本好书值得主动地阅读。主动的阅读不会为了已经了解一本书在说些什么而停顿下来,必须能评论,提出批评,才算真正完成了这件事。没有自我期许的读者没法达到这个要求,也不可能作到分析或诊释一本书。他不但没花心力去理解一本书,甚至根本将书搁在一边,忘个一干二净。这比不会赞赏一本书还糟,因为他对这本书根本无可奉告。\n1.受教是一种美德 # 我们前面所说的读者可以回话,并不是回与阅读无关的事。现在是分析阅读的第三个阶段。跟前面的两个阶段一样,这里也有一些规则。有些规则是一般思维的礼节。在这一章中,我们要谈的就是这个问题。其他有关批评观点的特殊条件,将会在下一章讨论到。\n一般人通常认为,水准普通的读者是不够格评论一本好书的。读者与作者的地位并不相等。在这样的观点中,作者只能接受同辈作家的批评。记得培根曾建议读者说:“阅读时不要反驳或挑毛病;也不要太相信,认为是理所当然;更不要交谈或评论。只要斟酌与考虑。”瓦尔特·司各特(Sir Walter Scott)要把“阅读时怀疑,或轻蔑作者的人”大加挞伐。\n当然,说一本书如何毫无瑕疵,因而对作者产生多少崇敬等等,这些话是有些道理,但却也有不通之处。读者或许像个孩子,因此一位伟大的作者可以教育他们,但这并不是说他们就没有说话的权利。塞万提斯说:“没有一本书会坏到找不到一点好处的。”或许他是对的,或许也是错的。更确定的说法该是:没有一本书会好到无懈可击。\n的确,如果一本书会启发读者,就表示作者高于读者,除非读者完全了解这本书,否则是不该批评的。但是等他们能这么做时,表示他们已经自我提升到与作者同样的水平了。现在他们拥有新的地位,可以运用他们的特权。如果他们现在不运用自己批评的才能,对作者来说就是不公平的事。作者已经完成他的工作—让读者与他齐头并进。这时候读者就应该表现得像是他的同辈,可以与他对话或回话。\n我们要讨论的是受教的美德—这是一种长久以来一直受到误解的美德。受教通常与卑躬屈膝混为一谈。一个人如果被动又顺从,可能就会被误解为他是受教的人。相反的,受教或是能学习是一种极为主动的美德。一个人如果不能自动自发地运用独立的判断力,他根本就不可能学习到任何东西。或许他可以受训练,却不能受教。因此,最能学习的读者,也就是最能批评的读者。这样的读者在最后终于能对一本书提出回应,对于作者所讨论的问题,会努力整理出自己的想法。\n我们说“最后”,是因为要能受教必须先完全听懂老师的话,而且在批评以前要能完全了解。我们还要加一句:光是努力,并不足以称得上受教。读者必须懂得如何评断一本书,就像他必须懂得如何才能了解一本书的内容。这第三组的阅读规则,也就是引导读者在最后一个阶段训练自己受教的能力。\n2.修辞的作用 # 我们经常发现教学与受教之间的关系是互惠的,而一个作者能深思熟虑地写作的技巧,和一个读者能深思熟虑地掌握这本书的技巧之间,也有同样的互惠关系。我们已经看到好的写作与阅读,都是以文法与逻辑的原则为基础规则。到现在为止,我们所讨论的规则都与作者努力达到能被理解的地步,而读者努力作到理解作品的地步有关。这最后阶段的一些规则,则超越理解的范畴,要作出评论。于是,这就涉及修辞。\n当然,修辞有很多的用途。我们通常认为这与演说或宣传有关。但是以最普通的意义来说,修辞和人类的任何一种沟通都有关,如果我们在说话,我们不只希望别人了解我们,也希望别人能同意我们的话。如果我们沟通的目的是很认真的,我们就希望能说服或劝导对方—更精确地说,说服对方接受我们的理论,劝导对方最终受到我们的行为与感觉的影响。\n在作这样的沟通时,接受的一方如果也想同样认真,那就不但要有回应,还要做一个负责的倾听者。你对自己所听到的要有回应,还要注意到对方背后的意图。同时,你还要能有自己的主见。当你有自己的主见时,那就是你的主张,不是作者的主张了。如果你不靠自己,只想依赖别人为你作判断,那你就是在做奴隶,不是自由的人了。思想教育之受推崇,正因如此。\n站在叙述者或作者的角度来看,修辞就是要知道如何去说服对方。因为这也是最终的目标,所有其他的沟通行为也必须做到这个程度才行。在写作时讲求文法与逻辑的技巧,会使作品清晰,容易理解,也是达到目标的一个过程。相对的,在读者或听者的立场,修辞的技巧是知道当别人想要说服我们时,我们该如何反应。同样的,文法及逻辑的技巧能让我们了解对方在说什么,并准备作出评论。\n3.暂缓评论的重要性 # 现在你可以看出来,在精雕细琢的写作或阅读过程中,文法、逻辑和修辞这三种艺术是如何协调与掌控的。在分析阅读前两个阶段的技巧中,需要精通文法与逻辑。在第三个阶段的技巧中,就要靠修辞的艺术了。这个阶段的阅读规则建立在最广义的修辞原则上。我们会认为这些原则代表一种礼节,让读者不只是有礼貌,还能有效地回话的礼节。(虽然这不是一般的认知,但是礼节应该要有这两个功能,而不是只有前面一项礼貌的功能。)\n你大概已经知道第九个阅读规则是什么了。前面已经讲过很多遍了。除非你听清楚了,也确定自己了解了,否则就不要回话。除非你真的很满意自己完成的前两个阅读阶段,否则不会感觉到可以很自由地表达自己的想法。只有当你做到这些事时,你才有批评的权力,也有责任这么做。\n这就是说,事实上,分析阅读的第三阶段最后一定要跟着前两个阶段来进行。前面两个阶段是彼此连贯的,就是初学者也能将两者合并到某种程度,而专家几乎可以完全连贯合并。他可以将整体分成许多部分,同时又能找出思想与知识的要素,与作者达成共识,找出主旨与论述,再重新架构出一个整体。此外,对初学者来说,前面两个阶段所需要做的工作,其实只要做好检视阅读就已经完成一大部分了。但是就下评论来说,即使是阅读专家,也必须跟初学者一样,不等到他完全了解是不能开始的。\n以下就是我们再详细说明的第九个规则:在你说出“我同意”,“我不同意”,或“我暂缓评论”之前,你一定要能肯定地说:“我了解了。”上述三种意见代表了所有的评论立场。我们希望你不要弄错了,以为所谓评论就是要不同意对方的说法。这是非常普遍的误解。同意对方说法,与不同意对方说法都一样要花心力来作判断的。同意或不同意都有可能对,也都有可能不对。毫无理解便同意只是愚蠢,还不清楚便不同意也是无礼。\n虽然乍看之下并不太明显,但暂缓评论也是评论的一种方式。那是一种有些东西还未表达的立场。你在说的是,无论如何,你还没有被说服。\n你可能会怀疑,这些不过是普通常识,为什么要大费周章地说明?有两个理由。第一点,前面已经说过,许多人会将评论与不同意混为一谈(就算是“建设性”的批评也是不同意)。其次,虽然这些规则看起来很有理,在我们的经验中却发现很少有人能真正运用。这就是古人说的光说不练的道理。\n每位作者都有被瞎批评的痛苦经验。这些批评者并不觉得在批评之前应该要做好前面的两个阅读步骤。通常这些批评者会认为自己不需要阅读,只需要评论就可以了。演讲的人,都会碰上一些批评者其实根本不了解他在说的是什么,就提出尖锐问题的经验。你自己就可能记得这样的例子:一个人在台上讲话,台下的人一口气或最多两口气就冒出来:“我不知道你在说什么,但我想你错了。”\n对于这样的批评,根本不知从何答起。你惟一能做的是有礼貌地请他们重述你的论点,再说明他们对你的非难之处。如果他们做不到,或是不能用他们自己的话重述你的观点,你就知道他们其实并不了解你在说什么。这时你不理会他们的批评是绝对有道理的。他们的意见无关紧要,因为那些只是毫无理解的批评而已。只有当你发现某个人像你自己一般真的知道你在说什么的时候,你才需要为他的同意而欢喜,或者为他的反对而苦恼。\n这么多年来教学生阅读各种书籍的经验中,我们发现遵守规则的人少,违反规则的人很多。学生经常完全不知道作者在说些什么,却毫不迟疑地批评起作者来。他们不但对自己不懂的东西表示反对意见,更糟的是,就算他们同意作者的观点,也无法用自己的话说出个道理来。他们的讨论,跟他们的阅读一样,都只是些文字游戏而已。由于他们缺乏理解,无论肯定或否定的意见就都毫无意义,而且无知。就算是暂缓评论,如果对自己暂缓评论的内容是些什么并不明所以的话,这种暂缓的立场也不见得有什么高明。\n关于这个规则,下面还有几点是要注意的。如果你在读一本好书,在你说出“我懂了”之前,最好迟疑一下。在你诚实又自信地说出这句话之前,你有一堆的工作要做呢!当然,在这一点上,你要先评断自己的能力,而这会让你的责任更加艰巨。\n当然,说出“我不懂”也是个很重要的评断,但这只能在你尽过最大努力之后,因为书而不是你自己的理由才能说这样的话。如果你已经尽力,却仍然无法理解,可能是这本书真的不能理解。对一本书,尤其是一本好书来说,这样的假设是有利的。在阅读一本好书时,无法理解这本书通常是读者的错。因此,在分析阅读中,要进人第三阶段之前,必须花很多时间准备前面两个阶段的工作。所以当你说“我不懂”时,要特别注意其中并没有错在你自己身上的可能。\n在以下的两种状况中,你要特别注意阅读的规则。如果一本书你只读了一部分,就更难确定自己是不是了解了这本书,在这时候你的批评也就要更小心。还有时候,一本书跟作者其他的书有关,必须看了那本书之后才能完全理解。在这种情况中,你要更小心说出“我懂了”这句话,也要更慢慢地举起你评论的长矛。\n对于这种自以为是的状况,有一个很好的例子。许多文学评论家任意赞成或反对亚里士多德的《诗学》,却并不了解他在分析诗的主要论点,其实立足于他其他有关心理学、逻辑与形上学的一些著作之上。他们其实根本不知道自己在赞成或反对的是什么。\n同样的状况也发生在其他作者身上,像柏拉图、康德、亚当·斯密与马克思等人—这些人不可能在一本书中将自己所有的思想与知识全部写出来。而那些评论康德《纯粹理性批判》,却根本没看过他《实践理性批判》的人;批评亚当·斯密的《国富论》,却没看过他《道德情操论》(Theory of Moral Sentiments)的人;或是谈论《共产党宣言》,却没有看过马克思《资本论》的人,他们都是在赞成或反对一些自己并不了解的东西。\n4.避免争强好辩的重要性 # 评论式阅读的第二个规则的道理,与第一个一样清楚,但需要更详尽的说明与解释。这是规则十:当你不同意作者的观点时,要理性地表达自己的意见,不要无理地辩驳或争论。如果你知道或怀疑自己是错的,就没有必要去赢得那场争辩。事实上,你赢得争辩可能真的会在世上名噪一时,但长程来说,诚实才是更好的策略。\n我们先从柏拉图与亚里士多德的例子来谈这个规则。在柏拉图的《会饮篇》(Symposium)中,有一段对话:\n“我不能反驳你,苏格拉底,”阿加顿说:“让我们假设你说的都对好了。”\n“阿加顿,你该说你不能反驳真理,因为苏格拉底是很容易被反驳的。”亚里士多德的《诗学》中也提到了这一段。他说:\n“其实这就是我们的责任。为了追求真理,要毁掉一些我们内心最亲近的事物,尤其像我们这样的哲学家或热爱智慧的人更是如此。因为,纵使双方是挚友,我们对真理的虔诚却是超越友谊的。”\n柏拉图与亚里士多德给了我们一个大多数人忽略的忠告。大多数人会以赢得辩论为目标,却没想到要学习的是真理。\n把谈话当作是战争的人,要赢得战争就得为反对而反对,不论自己对错,都要反对成功。抱持着这种心态来阅读的人,只是想在书中找出反对的地方而已。这些好辩的人专门爱在鸡蛋里挑骨头,对自己的心态是否偏差,则完全置之不顾。\n读者在自己书房和一本书进行对话的时候,没有什么可以阻止他去赢得这场争辩。他可以掌控全局。作者也不在现场为自己辩护。如果他想要作者现身一下的虚荣,他可以很容易就做到这一点。他几乎不必读完全书就能做到。他只要翻一下前面几页就够了。\n但是,如果他了解到,在与作者—活着或死了的老师—对话中,真正的好处是他能从中学到什么;如果他知道所谓的赢只在于增进知识,而不是将对方打败,他就会明白争强好辩是毫无益处的。我们并不是说读者不可以极端反对或专门挑作者的毛病,我们要说的只是:就像他反对一样,他也要有同意的心理准备。不论要同意还是反对,他该顾虑的都只有一点—事实,关于这件事的真理是什么。\n这里要求的不只是诚实。读者看到什么应该承认是不必说的。当必须同意作者的观点,而不是反对的,也不要有难过的感觉。如果有这样的感觉,他就是个积习已深的好辩者。就这第二个规则而言,这样的读者是情绪化的,而不是理性的。\n5.化解争议 # 第三个规则与第二个很接近。所叙述的是在提出批评之前的另一个条件。这是建议你把不同的观点当作是有可能解决的问题。第二个规则是敦促你不要争强好辩,这一个规则是提醒你不要绝望地与不同的意见对抗。一个人如果看不出所有理性的人都可能达成一致的意见,那他就会对波涛汹涌的讨论过程感到绝望。注意我们说的是“可能达成一致的意见”,而不是说每个有理性的人都会达成一致的意见。就算他们现在不同意,过一阵子他们也可能变成同意。我们要强调的重点是,除非我们认为某个不同的意见终究有助于解决某个问题,否则就会徒乱心意。\n人们确实会同意、也会不同意的两个事实,来自人类复杂的天性。人是理性的动物。理性是人类表达同意的力量泉源。人类的兽性与理性中不完美的部分,则是造成许多不同意的原因。人是情绪与偏见的动物。他们必须用来沟通的语言是不完美的媒介,被情绪遮盖着,被个人的喜好渲染着,被不恰当的思想穿梭着。不过在人是理性的程度之内,这些理解上的困难是可以克服的。从误解而产生的不同意见只是外表的,是可以更正的。\n当然,还有另一种不同意是来自知识的不相当。比较无知的人和超越自己的人争论时,经常会错误地表示反对的意见。然而,学识比较高的人,有权指正比较无知的人所犯的错误。这种不同意见所造成的争论也是可以更正的。知识的不相当永远可以用教导来解决。\n还有一些争论是被深深隐藏起来的,而且还可能是沉潜在理性之中。这种就很难捉摸,也难以用理性来说明。无论如何,我们刚刚所说是大部分争论形式—只要排除误解,增加知识就能解决这些争论。这两种解药尽管经常很困难,通常却都管用。因此,一个人在与别人对话时,就算有不同的意见,最后还是有希望达成共识。他应该准备好改变自己的想法,才能改变别人的想法。他永远要先想到自己可能误解了,或是在某一个问题上有盲点。在争论之中,一个人绝不能忘了这是教导别人,也是自己受教的一个机会。\n问题在许多人并不认为争议是教导与受教的一个过程。他们认为任何事都只是一个观点问题。我有我的观点,你也有你的,我们对自己的观点都有神圣不可侵犯的权利,就像我们对自己的财产也有同样的权利。如果沟通是为了增进知识,从这个角度出发的沟通是不会有收获的。这样的交谈,顶多像是一场各持己见的乒乓球赛,没有人得分,没有人赢,每个人都很满意,因为自己没有输—结果,到最后他还是坚持最初的观点。\n如果我们也是这样的观点,我们不会—也写不出这本书来。相反的,我们认为知识是可以沟通传达的,争议可以在学习中获得解决。如果真正的知识(不是个人的意见)是争议的焦点,那么在大多数情况下,这些争议或者只是表面的,借由达成共识或心智的交流就可以消除,或者就算真正存在,仍然可以借由长期的过程以事实与理性来化解。有理性的争议方法就是要有长久的耐心。简短来说,争议是可争辩的事物。除非双方相信透过相关证据的公开,彼此可以借由理性来达成一种理解,进而解决原始的争议议题,否则争议只是毫无意义的事。\n第三个规则要如何应用在读者与作者的对话中呢?这个规则要怎样转述成阅读的规则呢?当读者发现自己与书中某些观点不合时,就要运用到这个规则了。这个规则要求他先确定这个不同的意见不是出于误解。再假设这个读者非常注意,除非自己真的了解,而且确实毫无疑问,否则不会轻易提出评断的规则,那么,接下来呢?\n接下来,这个规则要求他就真正的知识与个人的意见作出区别。还要相信就知识而言,这个争议的议题是可以解决的。如果他继续进一步追究这个问题,作者的观点就会指引他,改变他的想法。如果这样的状况没有发生,就表示他的论点可能是正确的,至少在象征意义上,他也有能力指导作者。至少他可以希望如果作者还活着,还能出席的话,作者也可能改变想法。\n你可能还记得上一章的结尾部分谈过一点这个主题。如果一个作者的主旨没有理论基础,就可以看作是作者个人的意见。一个读者如果不能区别出知识的理论说明与个人观点的阐述,那他就无法从阅读中学到东西。他感兴趣的顶多只是作者个人,把这本书当作是个人传记来读而已。当然,这样的读者无所谓同意或不同意,他不是在评断这本书,而是作者本身。\n无论如何,如果读者基本的兴趣是书籍本身,而不是作者本身,对于自己有责任评论这件事就要认真地对待。在这一点上,读者要就真正的知识与他个人观点以及作者个人观点之不同之处,作出区分。因此,除了表达赞成或反对的意见之外,读者还要作更多的努力。他必须为自己的观点找出理由来。当然,如果他赞同作者的观点,就是他与作者分享同样的理论。但是如果他不赞同,他一定要有这么做的理论基础。否则他就只是把知识当作个人观点来看待了。\n因此,以下是规则十一,尊重知识与个人观点的不同,在作任何评断之前,都要找出理论基础。\n顺便强调的是,我们并不希望大家认为我们主张有许多“绝对”的知识。我们前一章提到的自明之理,对我们来说是不能证明,也无法否定的真理。然而,大多数的知识都无法做到绝对的地步。我们所拥有的知识都是随时可以更正的。我们所知道的知识都有理论支持,或至少有一些证据在支持着,但我们不知道什么时候会出现新的证据,或许就会推翻我们现在相信的事实。\n不过这仍然不会改变我们一再强调区别知识与意见的重要性。如果你愿意,那么知识存在于可以辩护的意见之中—那些有某种证据支持的意见。因此,如果我们真的知道些什么,我们就要相信我们能以自己所知来说服别人。至于“意见”,就我们一直使用这个字眼的意义来说,代表没有理论支持的评断。所以谈到“意见”的时候,我们一直和“只是”或“个人”等词汇联用。当我们除了个人的感觉与偏见,并没有其他证据或理由来支持一个陈述,就说某件事是真理的话,那未免儿戏了。相对地如果我们手中有一些有理性的人都能接受的客观证据,我们就可以说这是真理,而我们也知道这么说没错。\n现在我们要摘要说明这一章所讨论的三个规则。这三个规则在一起所说明的是批评式阅读的条件,而在这样的阅读中,读者应该能够与作者“辩论”。\n第一:要求读者先完整地了解一本书,不要急着开始批评。第二:恳请读者不要争强好辩或盲目反对。第三:将知识上的不同意见看作是大体上可以解决的问题。这个规则再进一步的话,就是要求读者要为自己不同的意见找到理论基础,这样这个议题才不只是被说出来,而且会解释清楚。只有这样,才有希望解决这个问题。\n第十一章 赞同或反对作者 # 一个读者所能说的第一件事是他读懂了,或是他没读懂。事实上,他必须先说自己懂了,这样才能说更多的话。如果他没懂,就应该心平气和地回头重新研究这本书。\n在第二种难堪的情况中,有一个例外。“我没懂”这句话也可能本身就是个评论。但下这个评论之前,读者必须有理论支持才行。如果问题出在书本,而不是读者自己,他就必须找出问题点。他可以发现这本书的架构混乱,每个部分都四分五裂,各不相干,或是作者谈到重要的字眼时模棱两可,造成一连串的混淆困扰。在这样的状态中,读者可以说这本书是没法理解的,他也没有义务来作评论。\n然而,假设你在读一本好书,也就是说这是一本可以理解的书。再假设最后你终于可以说:“我懂了!”再假设除了你看懂了全书之外,还对作者的意见完全赞同,这样,阅读工作才算是完成了。分析阅读的过程已经完全结束。你已经被启发,被说服或被影响了。当然,如果你对作者的意见不同意或暂缓评论,我们还会有进一步的考量。尤其是不同意的情况比较常见。\n作者与读者争辩—并希望读者也能提出辩驳时—一个好的读者一定要熟悉辩论的原则。在辩论时他要有礼貌又有智慧。这也是为什么在这本有关阅读的书中,要另辟一章来谈这个问题的原因。当读者不只是盲目地跟从作者的论点,还能和作者的论点针锋相对时,他最后才能提出同意或反对的有意义的评论。\n同意或反对所代表的意义值得我们进一步讨论。一位读者与作者达成共识后,掌握住他的主旨与论述,便是与作者心意相通了。事实上,诠释一本书的过程是透过言语的媒介,达到心灵上的沟通。读懂一本书可以解释为作者与读者之间的一种认同。他们同意用这样的说法来说明一种想法。因为这样的认同,读者便能透过作者所用的语言,看出他想要表达的想法。\n如果读者读懂了一本书,怎么会不同意这本书的论点呢?批评式阅读要求他保持自己的想法。但是当他成功地读懂这本书时,便是与作者的心意合一了。这时他还有什么空间保持自己的想法呢?\n有些人不知道所谓的“同意”其实是包含两种意义的,于是,错误的观念就形成前面的难题。结果,他们误以为两人之间如果可以互相了解,便不可能不同意对方的想法。他们认为反对的意见纯粹来自不了解。\n只要我们想想作者都是在对我们所生活的世界作出评论,这个错误就很容易看出来了。他声称提供给我们有关事物存在与行动的理论知识,或是我们该做些什么的实务知识,当然,他可能是对的,也可能是错的。只有当他说的是事实,而且提出相关的证据时,他的说法才成立。否则就是毫无根据的说辞。\n譬如你说:“所有的人都是平等的。”我们可能会认为你说的是人生而具有的智慧、力量与其他能力都是相同的。但就我们对事实的观察,我们不同意你的观点。我们认为你错了。但也可能我们误解你了。或许你要说的是每个人的政治权利是平等的。因为我们误解了你的意思,所以我们的不同意是毫无意义的。现在假设这个误解被纠正了。仍然可能有两种回答。我们可以同意,也可以不同意。但是这时如果我们不同意,我们之间就出现了一个真正的议题。我们了解你的政治立场,但我们的立场与你相反。\n只有当双方都了解对方所说的内容时,关于事实或方向的议题—关于一件事是什么或该如何做的议题—才是真实的。在讨论一件事时,双方都要对文字上的应用没有意见之后,才能谈到同意或不同意的观点。这是因为(不是尽管),当你透过对一本书的诠释理解,与作者达成了共识之后,才可以决定同意他的论点,或是不同意他的立场。\n1.偏见与公正 # 现在我们来谈谈你读懂了一本书,但是却不同意作者的状况。如果你都接受前一章所谈的规则,那么你的不同意就是因为作者在某一点上出错了。你并没有偏见,也不是情绪化。因为这是事实,那么要作到理想化的辩论就必须满足以下三种条件:\n第一点,因为人有理性的一面,又有动物的一面,所以在争辩时就要注意你会带进去的情绪,或是在当场引发的脾气。否则你的争论会流于情绪化,而不是在说理了。当你的情绪很强烈时,你可能会认为自己很有道理。\n第二点,你要把自己的前提或假设摊出来。你要知道你的偏见是什么—这也是你的预先评断。否则你就不容易接受对手也有不同假设的权利。一场好的辩论是不会为假设而争吵的。譬如作者明白地请你接受某个前提假设,你就不该因为也可以接受相反的前提假设就不听他的请求。如果你的偏见正好在相反的那一边,而你又不肯承认那就是偏见,你就不能给作者一个公平的机会表达意见了。\n第三点也是最后一点,派别之争几乎难以避免地会造成一些盲点,要化解这些盲点,应尽力尝试不偏不倚。当然,争论而不想有派别之分是不可能的事。但是在争论时应该多一点理性的光,少一点激情的热,每个参与辩论的人至少都该从对方的立场来着想。如果你不能用同理心来阅读一本书,你的反对意见会更像是争吵,而不是文明的意见交流。\n理想上,这三种心态是明智与有益的对话中必要的条件。这三种要件显然也适用在阅读上—那种作者与读者之间的对话上。对一个愿意采取理性争论方式的读者来说,每一个建议对他都是金玉良言。\n但这只是理想,仅能做到近似而已。我们不敢对人抱持这样的奢望。我们得赶快承认,我们也充分地注意到自己的缺点。我们也会违反我们自己所定的辩论中该有的明智规则。我们发现自己也会攻击一本书,而不是在评论,我们也会穷追猛打,辩不过的时候也继续反对,把自己的偏见讲得理直气壮,好像我们比作者要更胜一筹似的。\n然而,无论如何,我们仍然相信,作者与读者的对话及批评式的阅读,是可以相当有纪律的。因此,我们要介绍一套比较容易遵守,可以取代这三种规则的替代方法。这套方法指出四种站在对立角度来评论一本书之道。我们希望即使读者想要提出这四种评论时,也不会陷人情绪化或偏见的状态中。\n以下是这四点的摘要说明。我们的前提是读者能与作者进行对话,并能回应他所说的话。在读者说出:“我了解,但我不同意。”之后,他可以用以下的概念向作者说明:(1)你的知识不足(uninformed)。(2)你的知识有错误(misinformed)。(3)你不合逻辑—你的推论无法令人信服。(4)你的分析不够完整。\n这四点可能并不完整,不过我们认为已经够了。无论如何,这确实是一位读者在不同意时,基本上可以作出的重点声明。这四个声明多少有点独立性。只用其中一点,不会妨害到其他重点的运用。每个重点或全部的重点都可以用上,因为这些重点是不会互相排斥的。\n不过,再强调一次,读者不能任意使用这些评论,除非他确定能证明这位作者是知识不足、知识有误或不合逻辑。一本书不可能所有的内容都是知识不足或知识有误。一本书也不可能全部都不合逻辑。而要作这样评论的读者,除了要能精确地指认作者的问题之外,还要能进一步证明自己的论点才行。他要为自己所说的话提出理由来。\n2.判断作者的论点是否正确 # 这四个重点之中,第四个重点与前三个略微不同,我们会继续讨论这一点。我们先简单地谈一下前三点,再谈第四点。\n(1)说一位作者知识不足,就是在说他缺少某些与他想要解决的问题相关的知识。在这里要注意的是,除非这些知识确实相关,否则就没有理由作这样的评论。要支持你的论点,你就要能阐述出作者所缺乏的知识,并告诉他这些知识如何与这个问题有关,如果他拥有这些知识会如何让他下一个不同的结论。\n我们还要补充说明一点。达尔文缺乏基因遗传学的知识,这些是由孟德尔及后继者研究证实的知识。在他的《物种起源》中,最大的缺点就是他对遗传机能的知识一无所知。吉朋,则缺乏一些后来的历史学家研究证明所显示出罗马沦亡的关键点。通常,在科学与历史中,前人缺乏的知识都是由后来的人发掘出来的。科技的进步与时间的延长,使得大部分的研究调查都能做到这一点。但在哲学领域中,状况却可能相反。似乎时间越久远,知识只有衰退,而毫无增进。譬如古人就已经懂得分辨出人的意识、想像与理解力。在18世纪,休谟(DavidHume)的作品中对人的想像与思想的区别一无所知,然而早期的哲学家早已建立起这个概念了。\n(2)说一位作者的知识错误,就是说他的理念不正确。这样的错误可能来自缺乏知识,但也可能远不只于此。不论是哪一种,他的论点就是与事实相反。作者所说的事实或可能的事实,其实都是错的,而且是不可能的。这样的作者是在主张他自己其实并没有拥有的知识,当然,除非这样的缺点影响到作者的结论,否则并没必要指出来。要作这个评论,你必须要能说明事实,或是能采取比作者更有可能性的相反立场来支持你的论点。\n譬如斯宾诺莎的一本政治论著中,谈到民主是比专制更原始的一种政治形态。这与已经证实的政治史实完全相反。斯宾诺莎这个错误的观点,影响到他接下来的论述。亚里士多德误以为在动物的传宗接代中,雌性因素扮演着重要的角色,结果导致一个难以自圆其说的生殖过程的结论。阿奎那的错误在他认为天体与星球是截然不同的,因为他认为前者只会改变位置,此外无从改变。现代的天文学家更正了这个错误,而使得古代及中世纪的天文学往前迈进一大步。但是他这个错误只与部分内容相关。他出了这个错,却不影响他在形上学的论点,他认为所有可知觉的事物都是由内容及形式所组成的。\n前两点的批评是互相有关联的。知识不足,就可能造成我们所说的知识错误。此外,任何人的某种知识错误,也就是在那方面知识不足。不过,这两种不足在消极与积极面上的影响,还是有差别的。缺乏相关的知识,就不太可能解决某个特定的问题,或支持某一种结论。错误的知识却会引导出错误的结论,与站不住脚的解答。这两个评论合.在一起,指出的是作者的前提有缺陷。他需要充实知识。他的证据与论点无论在质与量上都还不够好。\n(3)说一位作者是不合逻辑的,就是说他的推论荒谬。、一般来说,荒谬有两种形态。一种是缺乏连贯,也就是结论冒出来了,却跟前面所说的理论连不起来。另一种是事件变化的前后不一致,也就是作者所说的两件事是前后矛盾的。要批评这两种问题,读者一定要能例举精确的证据,而那是作者的论点中所欠缺的使人信服的力量。只要当主要的结论受到这些荒谬推论的影响时,这个缺点才要特别地提出来。一本书中比较无关的部分如果缺乏信服力,也还说得过去。\n第三点比较难以例举说明。因为真正的好书,很少在推论上出现明显的错误。就算真的发生了,通常也是精巧地隐藏起来,只有极有洞察力的读者才能发掘出来。但是我们可以告诉你一个出现在马基雅维里的《君主论》中的谬论。马基雅维里说:\n所有的政府,不论新或旧,主要的维持基础在法律。如果这个政府没有很好的武装力量,就不会有良好的法律。也就是说,只要政府有很好的武装力量,就会有好的法律。\n所谓良好的法律来自良好的警察力量,所谓只要警察力量是强大的,法律也自然是良好的,是不通的。我们暂且忽略这个议题中高度的可疑性。我们关心的只是其中的连贯性。就算我们说快乐来自于健康比好法律来自有效力的警察力量还要有道理一些,但是也不能跟着说:健康的人都是快乐的人。\n在霍布斯的《法律的原理》(Elements of Law)中,他主张所有的物体不过是在运动中的物质数量而已。他说在物体的世界中是没有品质可言的。但是在另一个地方,他主张人本身就不过是个物体,一组在运动中的原子的组合。他一方面承认人有感官品质的存在—颜色、气味、味觉等等—一方面又说这都不过是大脑中原子的运动所造成的。这个结论与前面第一个论点无法呼应,在那个论点中他说的是在运动中的物体是没有品质的。他所说的所有运动中的物体,应该也包括任何一组特殊的物体,大脑的原子运动自然也该在其中才对。\n这第三个批评点与前两个是互相关联的。当然,有时候作者可能没法照他自己所提的证据或原则得出结论。这样他的推论就不够完整。但是这里我们主要关心的还是一个作者的理论根据很好,导出来的结论却很差的情况。发现作者的论点没有说服人的力量,是因为前提不正确或证据不足,虽然很有趣,但却一点也不重要。\n如果一个人设定了很完整的前提,结论却伺题百出,那从某个角度而言,就是他的知识有错误。不过,到底这些错误的论述来自推论有毛病的问题,还是因为一些其他的缺点,特别像是相关知识不足等等,这两者之间的差异倒是值得我们细细推敲的.\n3.判断作者论述的完整性 # 我们刚谈过的前面三个批评点,是与作者的声明与论述有关的。让我们谈一下读者可以采取的第四个批评点。这是在讨论作者是否实际完成了他的计划—也就是对于他的工作能否交待的满意度。\n在开始之前,我们必须先澄清一件事。如果你说你读懂了,而你却找不出证据来支持前面任何一个批评点·的话,这时你就有义务要同意作者的任何论点。这时你完全没有自主权。你没有什么神圣的权利可以决定同意或不同意。\n如果你不能用相关证据显示作者是知识不足、知识有误,或不合逻辑,你就不能反对他。你不能像很多学生或其他人说的:“你的前提都没有错,推论也没问题,但我就是不同意你的结论。”这时候你惟一能说的可能只是你“不喜欢”这个结论。你并不是在反对。你只在表达你的情绪或偏见。如果你已经被说服了,就该承认。(如果你无法提出证据来支持前三项批评点,但仍然觉得没有被作者说服,可能在一开始时你就不该说你已经读懂了这本书。)\n前面三个批评点与作者的共识、主旨与论述有关。这些是作者开始写作时要用来解决问题的要素。第四点—这本书是否完整了—与整本书的架构有关。\n(4)说一位作者的分析是不完整的,就是说他并没有解决他一开始提出来的所有问题,或是他并没有尽可能善用他手边的资料,或是他并没有看出其间的含意与纵横交错的关系,或是他没法让自己的想法与众不同。但这还不够去说一本书是不完整的。任何人都可以这样评论一本书。人是有限的,他们所做的任何工作也都是有限的,不完整的。因此,作这样的评论是毫无意义的。除非读者能精确地指出书中的问题点—不论是来自他自己的努力求知,或是靠其他的书帮忙—才能作这样的批评。\n让我们作一个简要的说明。在亚里士多德的《政治学》中,有关政府形态的分析是不完整的。因为他受时代的限制,以及他错误地接受奴隶制度,亚里士多德没有想到,或说构想到,真正的民主架构在人民的普选权。他也没法想像到代议政治与现代的联邦体制。如果有的话,他的分析应该延伸到这些政治现实才行。欧几里得的《几何原理》也是叙述不完整。因为欧几里得没想到平行线之间其他的公理。现代几何学提出了其他的假设,补足了这个缺陷。杜威的《如何思考》(How We Think),关于思考的分析是不完整的。因为他没有提到在阅读时产生的思考,在老师指导之下的思考,以及在研究发现时所产生的思考。对相信人类永生的基督徒而言,埃比克泰德(Epictetus)或奥勒留(Marcus Aurelius)有关人类幸福的论述也是不完整的。\n严格来说,第四点并不能作为不同意一个作者的根据。我们只能就作者的成就是有限的这一点而站在对立面上。然而,当读者找不出任何理由提出其他批评点而同意一本书的部分理论时,或许会因为这第四点,关于一本书是不完整的论点,而暂缓评论整本书。站在读者的立场,暂缓评论一本书就是表示作者并没有完全解决他提出的问题。\n阅读同样领域的书,可以用这四种评论的标准来作比较。如果一本书能比另一本书说出较多的事实,错误也较少,就比较好一点。但如果我们想要借读书来增进知识,显然一本能对主题作最完整叙述的书是最好的。这位作者可能会缺乏其他作者所拥有的知识;这位作者所犯的错误,可能是另一位作者绝不会发生的;即使是相同的根据,这位作者的说服力也可能比不上另一位作者。但是唯有比较每位作者在分析论点时的完整性,才是真正有深度的比较。比较每本书里有效而且突出的论点有多少,就可以当作评断其完整性的参考了。这时你会发现能与作者找出共同的词义是多么有用了。突出的词义越多,突出的论述也就越多。\n你也可能观察到第四个批评点与分析阅读的三个阶段是息息相关的。在拟大纲的最后阶段,就是要知道作者想要解决的问题是什么。诠释一本书的最后阶段,就是要知道作者解决了哪些问题,还有哪些问题尚未解决。批评一本书的最后阶段,就是要检视作者论述的完整性。这跟全书大纲,作者是否把问题说明清楚,也跟诠释一本书,衡量他多么完满地解决了问题都有关。\n4.分析阅读的三阶段 # 现在我们已经大致完成了分析阅读的举证与讨论。我们现在要把所有的规则按适当的次序,用合宜的标题写出来:\n一、 分析阅读的第一阶段:找出一本书在谈些什么的规则\n(1)\n依照书的种类与主题来分类。\n(2)\n使用最简短的文字说明整本书在谈些什么。\n(3)\n将主要部分按顺序与关联性列举出来。将全书的大纲列举出来,并将各个部分的大纲也列出来。\n(4)\n确定作者想要解决的问题。\n二、 分析阅读的第二阶段:诊释一本书的内容规则\n(5)诊释作者的关键字,与他达成共识。\n(6)由最重要的句子中,抓住作者的重要主旨。\n(7)知道作者的论述是什么,从内容中找出相关的句子,再重新架构出来。\n(8)确定作者已经解决了哪些问题,还有哪些是没解决的。再判断哪些是作者知道他没解决的问题。\n三、 分析阅读的第三阶段:像是沟通知识一样地评论一本书的规则\nA.\n智慧礼节的一般规则\n(9)除非你已经完成大纲架构,也能诠释整本书了,否则不要轻易批评。(在你说出:“我读懂了!”之前,不要说你同意、不同意或暂缓评论。)\n(10)不要争强好胜,非辩到底不可。\n(11)在说出评论之前,你要能证明自己区别得出真正的知识与个人观点的不同。B.批评观点的特别标准\n(12)证明作者的知识不足。\n(13)证明作者的知识错误。\n(14)证明作者不合逻辑。\n(15)证明作者的分析与理由是不完整的。\n注意:关于最后这四点,前三点是表示不同意见的准则,如果你无法提出相关的佐证,就必须同意作者的说法,或至少一部分说法。你只能因为最后一点理由,对这本书暂缓评论。\n本书在第七章结尾时,已经提出分析阅读的前四个规则,以便帮助你回答对一本书提出来的一个基本向题:这本书大体上来说是在谈些什么?同样的,在第九章的结尾,诠释一本书的四个规则能帮助你回答第二个问题,这也是你一定会问的问题:这本书详细的内容是什么?作者是如何写出来的?很清楚,剩下来的七个阅读规则—评论的智慧礼节、批评观点的特别标准—能帮助你回答第三与第四个基本问题。你一定还记得这两个问题:这是真实的吗?有意义吗?\n“这是真实的吗?\u0026lsquo;\u0026lsquo;的问题,可以拿来问我们阅读的任何一种读物。我们可以对任何一种读物提出“真实性”的疑问—数学、科学、哲学、历史与诗。人类发挥心智所完成的作品,如果就其真实性而受到赞美,可说是再也没有比这更高的评价了。同样的,就其真实性而进行批评,也是认真对待一部正经作品的态度。但奇怪的是,近几年来,在西方社会中,第一次出现了这种最高评价的标准逐渐丧失的现象。赢得批评家的喝彩,广受大众瞩目的书本,几乎都是在嘲弄事实的作品—越是夸大,效果越好。大部分读者,特别是阅读流行读物的读者,在使用不同的评论标准赞美或责难一本书—这本书是否新奇、哗众取宠,有没有诱惑力,有没有威力能迷惑读者的心等等,而不是在这本书的真实性,论点是否清晰,或是启发人心的力量上。这些标准之所以落伍,或许是现代有许多非科学类的作者,他们对于真实性要求不高的原因。我们可以推想这样的危机:如果任何有关真实的作品不再是关心的焦点时,那么愿意写作、出版、阅读这样的书的人就更少了。\n除非你阅读的东西在某种程度上是真实的,否则你用不着再读下去。就算是这样,你还是要面对最后一个问题。如果你是为了追求知识而阅读,除非你能判断作者所提出的事实的意义,或者应该具备的意义,否则称不上有头脑的阅读。作者所提出的事实,很少没经过有意无意的诠释。尤其如果你读的是文摘类的作品,那都是根据某种意义,或某种诠释原则而过滤过的事实。如果你阅读的是启发性的作品,这个问题更是没有终了的时刻。在学习的任何一个阶段,你都要回顾一下这个问题:“这究竟有没有意义?”\n我们已经提过的这四个问题,总结了身为读者应尽的义务。前三个,与人类语言的沟通天性有关。如果沟通并不复杂,就用不着做出大纲来。如果语言是完美的沟通媒介,而不是有点不透明,就用不着诠释彼此的想法了。如果错误与无知不会局限真实或知识,我们也根本用不着批评T。第四个间题区别T讯息(information)与理解( understanding)之间的差异。如果你阅读的读物是以传递讯息为主,你就要自己更进一步,找出其中的启发性来。即使你被自己阅读的东西所启发了,你也还要继续往前探索其中的意义。\n在进入本书的第三篇之前,或许我们该再强调一次,这些分析阅读的规则是一个理想化的阅读。没有多少人用过这样的方法来阅读一本书。而使用过这些方法的人,可能也没办法用这些规则来阅读许多本书。无论如何,这些规则只是衡量阅读层次的理想标准。你是个好读者,也就能达到你应该达到的阅读层次。\n当我们说某人读书“读得很好”(Well-read)时,我们心中应该要有这些标准来作衡量的依据。太多时候,我们是用这样的句子来形容一个人阅读的量,而非阅读的质。一个读得很广泛,却读不精的人,与其值得赞美,不如值得同情。就像霍布斯所说:“如果我像一般人一样读那么多书,我就跟他们一样愚蠢了。”\n伟大的作者经常也是伟大的读者,但这并不是说他们阅读所有的书。只是在我们的生活中,阅读是不可或缺的。在许多例子中,他们所阅读的书比我们在大学念的书还要少,但是他们读得很精。因为他们精通自己所阅读的书,他们的程度就可以跟作者相匹敌。他们有权被称作权威人士。在这种状况下,很自然地,一个好学生通常会变成老师,而一位好的读者也会变成作者。\n我们并不是企图要指引你开始写作,而是要提醒你,运用本书所提供的规则,仔细地阅读一本书,而不是浮面地阅读大量的书,就是一个好读者能达到的理想境界了。当然,许多书都值得精读。但有更多的书只要浏览一下就行了。要成为一个好读者,就要懂得依照一本书的特质,运用不同的阅读技巧来阅读。\n第十二章 辅助阅读 # 除了书籍本身之外,任何辅助阅读我们都可以称作是外在的阅读。所谓“内在阅读”(intrinsic reading),意思是指阅读书籍的本身,与所有其他的书都是不相关的。而“外在阅读”(extrinsic reading)指的是我们借助其他一些书籍来阅读一本书。到目前为止,我们故意避免谈到外在的辅助阅读。我们前面所谈的阅读规则,是有关内在阅读的规则—并不包括到这本书以外的地方寻找意义。有好几个理由让我们坚持到现在,一直将焦点集中在身为读者的基本工作上—拿起一本书来研究,运用自己的头脑来努力\u0026rsquo;,不用其他的帮助。但是如果一直这样做下去,可能就错了。外在阅读可以帮上这个忙。有时候还非要借助外在阅读,才能完全理解一本书呢!\n我们一直到现在才提出外在阅读的一个理由是:在理解与批评一本书的过程中,内在与外在的阅读通常会混在一起。在诠释、批评与做大纲时,我们都难免受到过去经验的影响。在阅读这本书之前,我们一定也读过其他的书。没有人是从分析阅读开始阅读第一本书的。我们可能不会充分对照其他书籍或自己生活里的经验,但是我们免不了会把某一位作者对某件事的声明与结论,拿来跟我们所知的,许多不同来源的经验作比较。这也就是俗话说的,我们不应该,也不可能完全孤立地阅读一本书。\n但是要拖到现在才提出外在阅读的主要理由是:许多读者太依赖外在的辅助了,我们希望你了解这是毫无必要的。阅读一本书时,另一只手上还拿着一本字典,其实是个坏主意。当然这并不是说你在碰到生字时也不可以查字典。同样地,一本书困扰住你时,我们也不会建议你去阅读评论这本书的文章。整体来说,在你找寻外力帮助之前,最好能自己一个人阅读。如果你经常这么做,最后你会发现越来越不需要外界的助力了。\n外在的辅助来源可以分成四个部分。在这一章中,我们会依照以下的顺序讨论出来:第一,相关经验。第二,其他的书。第三,导论与摘要。第四,工具书。\n要如何运用或何时运用这些外在的辅助资料,我们无法针对特例一一说明,但我们可以作一般性的说明。根据一般的阅读常识来说,你依照内在阅读的规则尽力将一本书读完之后,却还是有一部分不懂或全部都不懂时,就应该要找外在的帮助了。\n1.相关经验的角色 # 有两种形态的相关经验可以帮助我们了解在阅读时有困难的书。在第六章,我们已经谈到一般经验与特殊经验的不同之处。一般经验适用于任何一个活着的男人跟女人。特殊经验则需要主动地寻找,只有当一个人碰到困难时才会用得上。特殊经验的最佳例子就是在实验室中进行的实验,但也不一定需要有实验室。譬如一位人类学家的特殊经验可以是旅行到亚马逊流域,去研究一个尚未被开发的原始土著的居住形态。他因此增加了一些别人没有的特殊经验,也是许多人不可能有的经验。如果大多数科学家探险过那个区域之后,他的经验就失去了独特性。同样的,太空人登陆月球也是非常特殊的经验,而月球并不是一般人习以为常的实验室。大多数人并没有机会知道居住在没有空气的星球上是什么滋味,而在这成为一般经验之前,大多数人还是会保持这样的状态。同样的,上面有庞大地心引力的木星,在一般人心中也会继续想成一个像实验室般的地方,而且可能会一直如此。\n一般的经验并不一定要每个人都有才叫一般。一般(Common)与全体(Universal)是有点差别的。譬如并不是每个人都能经历生下来就有父母的经验,因为有些人一出生就是孤儿。然而,家庭生活却是一般人的普通经验,因为这是大多数男人跟女人在正常生活中的体验。同样的,性爱也不是每个人都有的经验,但是这是个共通的经验,因此我们称这个经验为一般经验。有些男人或女人从没有过这样的经验,但是这个经验被绝大多数的人类共享着,因此不能称作特殊经验。(这并不是说性爱经验不能在实验室中作研究,实际上也有很多人在做了。)被教导也不是每个人都有的经验,有些人从未上过学,但是这也属于一般经验。\n这两种经验主要是跟不同的书籍有关。一般经验在一方面与阅读小说有关,另一方面与阅读哲学书籍有关。判断一本小说的写实性,完全要依赖一般的经验。就像所有的人一样,我们从自己的生活体验来看这本书是真实或不够真实。哲学家与诗人一样,也是诉诸人类的共通经验。他并没有在实验室工作,或到外面作专门的研究调查。因此你用不着外界特殊经验的辅助,就能理解一位哲学家的主要原则。他谈的是你所知道的一般经验,与你每天生活中所观察到的世界。\n特殊经验主要是与阅读科学性作品有关。要理解与判断一本科学作品所归纳的论点,你就必须了解科学家所作的实验报告与证明。有时候科学家在形容一个实验时栩栩如生,你读起来一点困难也没有。有时说明图表会帮助你了解这些像是奇迹般的描述。\n阅读历史作品,同时与一般经验及特殊经验都有关。这是因为历史掺杂着虚构与科学的部分。从一方面来说,口述历史是个故事,有情节、角色、插曲、复杂的动作、高潮、余波。这就像一般经验也适用于阅读小说跟戏剧一样。但是历史也像科学一样,至少有些历史学家自己研究的经验是相当独特的。他可能有机会阅读到一些机密文件,而一般人如果阅读这些文件是会有麻烦的。他可能作过广泛的研究,不是进人残存的古老文明地区,就是访问过偏远地区的人民生活。\n要怎样才能知道你是否适当地运用自己的经验,来帮助你读懂一本书呢?最确定的测验方式就是我们讨论过的方式,跟测验你的理解力一样,问问你自己:在你觉得自己了解了的某一点上,能不能举出一个实例来?很多次我们要学生这么做,学生们却答不出来。他们看起来是了解了某个重点,但叫他起来举例说明时,他又是一脸茫然的样子。显然,他们并不是真的读懂了那本书。在你不太确定自己有没有掌握一本书时,不妨这样测验一下你自己。以亚里士多德在《伦理学》中讨论的道德为例,他一再强调,道德意味着过与不及之间的状态。他举出了一些具体的例子,你能同样举出类似的例子吗?如果可以,你就大致了解了他的重点。否则你该重新回到原点,再读一次他的论点。\n2.其他的书可以当作阅读时的外在助力 # 在后面我们会讨论到主题阅读,那是在同一个主题下,阅读很多本书。此刻,我们要谈的是阅读其他的书籍,以辅助我们阅读某一本书的好处。\n我们的建议尤其适用于所谓巨著。一般人总是抱着热忱想要阅读巨著,但是当他绝望地感觉到自己无法理解这本书时,热忱很快便消退了。其中一个原因,当然是因为一般人根本不知道要如何好好地阅读一本书。但还不只如此,还有另一个原因:他们认为自己应该能够读懂自己所挑选的第一本书,用不着再读其他相关的著作。他们可能想要阅读联邦公报,却没有事前先看过联邦条例和美国宪法。或是他们读了这些书,却没有看看孟德斯鸿的《论法的精神》与卢梭的《社会契约论》。\n许多伟大的作品不只是互相有关联,而且在写作时还有特定的先后顺序,这都是不该忽略的事。后人的作品总是受到前人的影响。如果你先读前一位的作品,他可能会帮助你了解后人的作品。阅读彼此相关的书籍‘,依照写作的时间顺序来读,对你了解最后写的作品有很大帮助。这就是外在辅助阅读的基本常识与规则。\n外在辅助阅读的主要功用在于延伸与一本书相关的内容脉络。我们说过文章的脉络有助于诠释字义与句子,找出共识与主旨。就像一整本书的脉络是由各个部分贯穿起来一样,相关的书籍也能提供一个大型的网路脉络,以帮助你诠释你正在阅读的书。\n我们经常会发现,一本伟大的著作总会有很长的对话部分。伟大的作者也是伟大的读者,想要了解他们,不妨读一读他们在读的书。身为读者,他们也是在与作者对话,就像我们在跟我们所阅读的书进行对话一样。只不过我们可能没写过其他的书。\n想要加人这样的谈话,我们一定要读与巨著相关的著作,而且要依照写作前后的年表来阅读。有关这些书的对话是有时间顺序的。时间顺序是最基本的,千万不要忽略了。阅读的顺序可以是从现代到过去,也可以从过去到现代。虽然从过去读到现代的作品因为顺其自然而有一定的好处,不过年代的角度也可以倒过来观察。\n顺便提醒一下,比起科学与小说类的书,阅读历史与哲学的书时,比较需要阅读相关的书籍。尤其是阅读哲学书时更重要,因为哲学家互相都是彼此了不起的读者。在小说与戏剧中,这就比较不重要了。如果真是好作品,可以单独阅读。当然一些文评家并不想限制自己这么做。\n3.如何运用导读与摘要 # 第三种外在的辅助阅读包括导读(commentary)与摘要(abstract)。这里要强调的是,在运用这些资料时要特别聪明,也就是要尽量少用。这么说有两个理由。\n第一,一本书的导读并不一定都是对的。当然,这些导读的用处很大,但却并不像我们希望的那样经常有用。在大学的书店里,到处都有阅读手册(handbook)与阅读指南(manual)。高中生也常到书店买这类书。这种书就经常产生误导。这些书都号称可以帮助学生完全了解老师指定他们阅读的某本书,但是他们的诠释有时错得离谱,除此之外,他们也实际上惹怒了一些老师与教授。\n但是就这些导读的书籍而言,我们不能不承认它们往往对考试过关大有助益。此外,好像是为了要与某些被惹恼的老师取得平衡,有些老师上课也会使用这些书。\n尽量少用导读的第二个原因是,就算他们写对了,可能也不完整。因此,你可能在书中发现一些重点,而那些导读者却没有发现到。阅读这类导读,尤其是自以为是的导读,会限制你对一本书的理解,就算你的理解是对的。\n因此,我们要给你一些关于如何使用导读的建议。事实上,这已经很相当于外在阅读的基本规则。内在阅读的规则是在阅读一本书之前,你要先看作者的序与前言。相反地,外在的阅读规则是除非你看完了一本书,否则不要看某个人的导读。这个规则尤其适用于一些学者或评论家的导言。要正确地运用这些导读,必须先尽力读完一本书,然后还有些问题在干扰着你时,你才运用这些导读来解答问题。如果你先读了这些导读,可能会让你对这本书产生曲解。你会只想看那些学者或批评家提出的重点,而无法看到可能同样重要的其他论点。\n如果是用这样的方法阅读,附带读一些这类的导读书籍是很有趣的事。你已经读过全书,也都了解了。而那位导读者也读过这本书,甚至可能读了好几次,他对这本书有自己的理解。你接近他的作品时,基本上是与他站在同一个水平上的。然而如果你在阅读全书之前,先看了他的导读手册,你就隶属于他了。\n要特别注意的是,你必须读完全书之后,才能看这类诠释或导读手册,而不是在之前看。如果你已经看过全书,知道这些导读如果有错,是错在哪里,那么这样的导读就不会对你造成伤害。但是如果你完全依赖这样的书,根本没读过原书,你的麻烦就大了。\n还有另一个重点。如果你养成了依赖导读的习惯,当你找不到这类书时,你会完全不知所措。你可能可以借着导读来了解某一本作品,但一般而言,你不会是个好读者。\n这里所说的外在阅读的规则也适用于摘录或情节摘要之类的作品。他们有两种相关的用途,也只有这两种。第一,如果你已经读过一本书,这些摘要能唤醒你的记忆。理想上,在分析阅读时,你就该自己作这样的摘要。但如果你还没这样做,一份内容摘要对你来说是有帮助的。第二,在主题阅读时,摘要的用处很大,你可以因此知道某些特定的议题是与你的主题密切相关的。摘要绝不能代替真正的阅读,但有时却能告诉你,你想不想或需不需要读这本书。\n4.如何运用工具书 # 工具书的类型有许多种。下面是我们认为最主要的两种:字典与百科全书。无论如何,对于其他类型的工具书,我们也还是有很多话要说的。\n虽然这是事实,但可能很多人不了解,那就是在你能运用工具书之前,你自己已经具备了很多知识:尤其是你必须有四种基本的知识。因此,工具书对矫正无知的功能是有限的。那并不能帮助文盲,也不能代替你思考。\n要善用工具书,首先你必须有一些想法,不管是多模糊的想法,那就是你想要知道些什么?你的无知就像是被光圈围绕着的黑暗。你一定要能将光线带进黑暗之中才行。而除非光圈围绕着黑暗,否则你是无法这么做的。换句话说,你一定要能对工具书问一个明智的问题。否则如果你只是仿徨迷失在无知的黑幕中,工具书也帮不上你的忙。\n其次,你一定要知道在哪里找到你要找的答案。你要知道自己问的是哪一类的问题,而哪一类的工具书是回答这类问题的。没有一本工具书能回答所有的问题,无论过去或现在,所有的工具书都是针对特定问题而来的。尤其是,事实上,在你能有效运用工具书之前,你必须要对主要类型的工具书有一个全盘的了解。\n在工具书对你发挥功用之前,你还必须有第三种知识。你必须要知道这本书是怎么组织的。如果你不知道要如何使用这本工具书的特殊功能,那就无助于你知道自己想要的是什么,也不知道该用哪种工具书。因此,阅读工具书跟阅读其他的书籍一样,也是有阅读的艺术的。此外,编辑工具书的技巧也有关系。作者或编者应该知道读者在找的是什么样的资料,然后编排出读者需要的内容。不过,他可能没办法先预测到这一点,这也是为什么这个规则要你在阅读一本书之前,先看序言与前言的原因。在阅读工具书时也一样,要看完编辑说明如何使用这本书之后,才开始阅读内容。\n当然,工具书并不能回答所有的问题。你找不到任何一本工具书,能同时回答在托尔斯泰的《人类的生活)(What Men Live By)中,上帝对天使提出的三个问题:“人类的住所是什么?”、“人类缺乏的是什么?”、“人类何以为生?”你也没法找到托尔斯泰另一个问题的答案。他的另一个故事的篇名是:“一个人需要多大的空间?”这类问题可说是不胜枚举。只有当你知道一本工具书能回答哪类问题,不能回答哪一类问题时,这本工具书对你才是有用的。这个道理也适用于一般人所共同认同的事物。在工具书中你只能看到约定俗成的观念,未获得普遍支持的论点不会出现在这种书中,虽然有时候也会悄悄挤进一两则惊人之论。\n我们都同意,在工具书中可以找到人的生卒年份,以及类似的事实。我们也相信工具书能定义字或事物,以及描绘任何历史事件。我们不同意的是,一些道德问题,有关人类未来的问题等等,这类间题却无法在工具书中找到答案。我们假定在我们生活的时代,物质世界是有秩序的,因此所有东西都可以在工具书中找到。但是事实并非如此,因此,历史性的工具书就很有趣,因为它能告诉我们,在人类可知的事物中,人们的观点是如何变迁的。\n要明智地运用工具书的第四个条件就是:你必须知道你想要找的是什么,在哪一种工具书中能找到这样的东西。你也要知道如何在工具书中找到你要的资料,还要能确定该书的编者或作者知道哪个答案。在你使用工具书之前,这些都是你应该清楚知道的事。对一无所知的人来说,工具书可说是毫无用处。工具书并不是茫然无知的指南。\n5.如何使用字典 # 字典是一种工具书,以上所说的工具书问题在使用时都要考虑进去。但是字典也被当作是一种好玩的读物。在无聊的时候可以坐下来对它进行挑战。毕竟这比其他许多消磨时间的方法高明许多。\n字典中充满了晦涩的知识,睿智繁杂的资讯。更重要的是,当然,字典也有严肃的用途。要能善用字典,就必须知道字典这种书的特点在哪里。\n桑塔亚那(Santayana)评论希腊民族是在欧洲历史中,惟一未受教育的一群人。他的话有双重的意义。当然,他们大部分人是没受过教育的,但即使是少数有知识的—有闲阶级—的人,就教育要接受外来大师的熏陶这一点而言,也是没有受过教育的。所谓教育,是由罗马人开始的,他们到学校受希腊人的指导,征服希腊后与希腊文化接触,而变得文明起来。\n所以,一点也用不着惊讶,世上最早的字典是关于荷马书中专门用语的字典,以帮助罗马人阅读《伊利亚特》及《奥德赛》,及其他同样运用荷马式古典字汇的希腊书籍。同样的,今天我们也需要专门用语字典才能阅读莎士比亚,或是乔臾的书。\n中世纪出现了许多字典,通常是有关世界知识的百科全书,还包括一些学习论述中最重要的技巧的讨论。在文艺复兴时期,出现了外语字典(希腊文与拉丁文双语),因为当时主要的教育是用古代语言教学的,事实上也必须有这类字典才行。纵使所谓鄙村野语—意大利语、法语、英语—慢慢取代拉丁文,成为学习使用的语言,追求学问仍然是少数人的特权。在这样的情况下,字典是只属于少数人的读物,主要用作帮助阅读与写作重要的文学作品。\n因此,我们可以看出来,从一开始,教育的动机便左右了字典的编排,当然,保留语言的纯粹与条理是另一个原因。就后一个原因而言,有些字典的目的却刚好相反,像《牛津英语字典》,开始于1857年,就是一个新的里程碑。在这本字典中不再规定用法,而是精确地呈现历史上出现的各种用法—最好的与最坏的都有,同时取材自通俗作品与高雅的作品。把自己看作是仲裁者的字典编辑,与把自己看作是历史学家的字典编辑之间的冲突,可以暂时搁在一边,毕竟,不论字典是如何编辑的,主要目的还是教育的工具。\n这个事实与善用一本字典,当作是外在辅助阅读工具的规则有关。阅读任何一本书的第一个规则是:知道这是一本什么样的书。也就是说,知道作者的意图是什么,在他的书中你可以看到什么样的资讯。如果你把一本字典当作是查拼字或发音的指南,你是在使用这本书,但却用得不够好。如果你了解字典中富含历史资料,并清楚说明有关语言的成长与发展,你会多花点注意力,不只是看每个字下面列举的意义,还会看看它们之间的秩序与关系。\n最重要的是,如果你想要自己进修,可以依照一本字典的基本意图来使用—当作是帮助阅读的工具,否则你会觉得太困难了。因为在字典中包含了科技的字汇、建筑用语、文学隐喻,甚至非常熟悉的字的过时用法。\n当然,想要读好一本书,除了作者使用字汇所造成的问题外,还有许多其他的问题。我们一再强调我们反对—特别是第一次阅读一本难读的书时—一手拿着书,另一手拿着字典。如果一开始阅读你就要查很多生字的话,你一定会跟不上整本书的条理。字典的基本用途是在你碰到一个专业术语,或完全不认识的字时,才需要使用上。即使如此,在你第一次阅读一本好书时,也不要急着使用字典,除非是那个字与作者的主旨有很大的关联,才可以查证一下。\n其他还有一些负面的告诫。如果你想要从字典中找出有关解决共产主义、正义、自由这类问题的结论,绝对是最讨人厌的。字典的编纂者可能是用字的权威专家,却不是最高的智慧根源。另一条否定的规则是:不要囫囵吞枣地将字典背下来。不要为了想立即增进字汇能力,就将一连串的生字背下来,那些字义跟你的实际生活经验一点关联也没有。简单来说,字典是关于字的一本书,而不是关于事的一本书。\n如果我们记得了这些,便可以推衍出一些明智地使用字典的规则。于是我们可以从四个方面来看待文字:\n(1)文字是物质的—可以写成字,也可以说出声音。因此,在拼字与发音上必须统一,虽然这种统一常被特例变化所破坏,但并不像你某些老师所说的那样重要。\n(2)文字是语言的一部分。在一个较复杂的句子或段落的结构中,文字扮演了文法上的角色。同一个字可以有多种不同的用法,随着不同的谈话内容而转变意义,特别是在语音变化不明显的英文中更是如此。\n(3)文字是符号—这些符号是有意义的,不只一种意义,而是很多种意义。这些意义在许多方面是互相关联的。有时候会细微地变化成另一种意义,有时候一个字会有一两组完全不相干的意义。因为意义上的相通,不同的字也可能互相连接起来—就像同义字,不同的字却有同样的意义。或是反义字,不同的字之间有相反或对比的意义。此外,既然文字是一种符号,我们就将字区分为专有名词与普通名词(根据他们指的是一件事,或是很多的事);具体名词或抽象名词(根据他们指的是我们能感知的事,或是一些我们能从心里理解,却无法由外在感知的事)。\n最后,(4)文字是约定俗成的—这是人类创造的符号。这也是为什么每个字都有历史,都有历经变化的文化背景。从文字的字根、字首、字尾,到词句的来源,我们可以看出文字的历史。那包括了外形的变化,在拼字与发音上的演变,意义的转变,哪些是古字、废字,哪些是现代的标准字,哪些是习惯用语,或口语、俚语。\n一本好字典能回答这四个不同类型的有关文字的问题。要善用一本字典,就是要知道问什么样的问题,如何找到答案。我们已经将问题建议出来了,字典应该告诉你如何找到解答。\n字典是一种完美的自修工具书,因为它告诉你要注意什么,如何诠释不同的缩写字,以及上面所说的四种有关文字符号的知识。任何人不善读一本字典开头时所作的解释以及所列的缩写符号,那用不好字典就只能怪他自己了。\n6.如何使用百科全书 # 我们所说的有关字典的许多事也适用于百科全书。跟字典一样,百科全书也是种好玩的读物,既有娱乐消遣价值,对某些人来说还能镇定神经。但是和字典一样,如果你想通读百科全书,那是没有意义的。一个将百科全书强记在心的人,会有被封为“书呆子”的危险。\n许多人用字典找出一个字的拼法与读法。百科全书相似的用法是查出时间、地点等简单的事实。但如果只是这样,那是没有善用、或误用了百科全书。就跟字典一样,百科全书也是教育与知识的工具。看看百科全书的历史,你就能确定这一点。\n虽然百科全书(encyclopedia)这个字来自希腊文,希腊却没有百科全书,同样地,他们也没有字典。百科全书这个字对他们来说,并不是指一本有关知识的书,或是沉淀知识的书,而是知识的本身—所有受过教育的人都该有的知识。同样的,又是罗马人发现百科全书的必要性。最早的一本百科全书是由罗马人普林尼(Pliny)编纂的。\n最有趣的是,第一本依照字母排列顺序编辑的百科全书是在1700年才出现的。从此大部分重要的百科全书都是照字母顺序来排列的。这是解决所有争议最简单的方法,也使得百科全书的编辑迈进了一大步。\n百科全书与光是文字的书所产生的问题有点不同。对一本字典来说,按字母排列是最自然不过的事了。但是世界上的知识—这是百科全书的主题—能以字母来排列吗?显然不行。那么,要如何安排出秩序来呢?这又跟知识是如何安排出秩序有关了。\n知识的顺序是随着时代而变迁的。在过去,所有相关的知识是以七种教育艺术来排列的—文法、修辞、逻辑三学科,与算术、几何、天文、音乐四学科组合而成。中世纪的百科全书显现出这样的安排。因为大学是照这样的系统来安排课程的,学生也照样学习,因此这样的安排对教育是有用的。\n现代的大学与中世纪的大学大不相同了,这些改变也反映在百科全书的编纂上。知识是按专业来区分的,大学中不同的科系也大致是照这样的方法来区分的。但是这样的安排,虽然大致上来自百科全书的背景,但仍然受到用字母编排资料的影响。\n这个内在的结构—借用社会学家的术语—就是善用百科全书的人要去找出来的东西。的确没错,他基本上要找的是真实的知识,但他不能单独只看一种事实。百科全书所呈现给他的是经过安排的一整套的事实—一整套彼此相关的事实。因此,百科全书和一般光提供讯息的书不同,它所能提供的理解取决于你对这些相关事实之间的关系的了解。\n在字母编排的百科全书中,知识之间的关联变得很模糊。而以主题来编排的百科全书,当然就能很清楚看出其间的相关性。但是以主题编排的百科全书也有许多缺点,其中有许多事实是一般人不会经常使用到的。理想上,最好的百科全书应该是又以主题,又按字母来编排的。它呈现的材料以一篇篇文章表现时,是按字母来排列,但其中又包括某个主题的关键与大纲—基本上就是一个目录。(目录是在编排一本书的文章时用的,与索引不同。索引是用字母排列的。)以我们所知,目前市面上还没有这样的百科全书,但值得努力去尝试一下。\n使用百科全书,读者必须要依赖编者的帮忙与建议。任何一本好的百科全书都有引言,指导读者如何有效地运用这本书,你一定要照着这些指示阅读。通常,这些引言都会要使用者在翻开字母排列的内容之前,先查证一下索引。在这里,索引的功能就跟目录一样,不过并不十分理想。因为索引是在同一个标题下,把百科全书中分散得很广,但是和某一个相关主题有关的讨论都集中起来。这反映一个事实,虽然索引是照字母排列的,但是下一层的细分内容,却是按照主题编排的。而这些主题又必须是按字母排列的,虽然这也并不是最理想的编排。因此,一本真正好的百科全书,像大英百科全书的索引,有一部分就可以看出他们整理知识的方法。因为这个原因,一个读者如果不能善用索引,无法让百科全书为己所用,也只能怪他自己了。\n关于使用百科全书,跟字典一样,也有一些负面的告诫。就跟字典一样,百科全书是拿来阅读好书用的—坏书通常用不着百科全书,但是同样的,最聪明的做法是不要被百科全书限制住了。这又跟字典一样,百科全书不是拿来解决某个不同观点的争论用的。不过,倒是可以用来快速而且一劳永逸地解决相关事实的争论。从一开始,事实就是没有必要争论的。一本百科全书会让这种徒劳无益的争吵变得毫无必要,因为百科全书中所记载的全是事实。理想上,除了事实外,百科全书里应该没有别的东西。最后,虽然不同的字典对文字的说明有同样的看法,但是百科全书对事实的说明却不尽相同。因此,如果你真的对某个主题很感兴趣,而且要靠着百科全书的说明来理解的话,不要只看一本百科全书,要看一种以上的百科全书,选择在不同的时间被写过很多次的解释。\n我们写过几个要点,提供给使用字典的读者。在百科全书的例子中,与事实相关的要点是相同的。因为字典是关于文字的,而百科全书是关于事实的。\n(1)事实是一种说法(proposition)说明一个事实时,会用一组文字来表达。如“亚伯拉罕·林肯出生于1809年2月12日。”或“金的原子序是79。”事实不像文字那样是物质的,但事实仍然需要解释。为了全盘地了解知识,你必须知道事实的意义—这个意义又如何影响到你在找寻的真理。如果你知道的只是事实本身,表示你了解的并不多。\n(2)事实是一种“真实”的说法(\u0026ldquo;True\u0026rdquo; proposition)事实不是观点。当有人说:“事实上……”的时候,表示他在说的是一般人同意的事。他不是说,也不该说,以他个人或少数人的观察,得来的事实是如此这般。百科全书的调性与风格,就在于这种事实的特质。一本百科全书如果包含了编者未经证实的观点,就是不诚实的做法。虽然一本百科全书也可能报导观点(譬如说某些人持这样的主张,另一些人则又是另一种主张),但却一定要清楚标明出来。由于百科全书必须只报导事实,不掺杂观点(除了上述的方法),因而也限制了记载的范围。它不能处理一些未达成共识的主题—譬如像道德的问题。如果真的要处理这些问题,只能列举人们各种不同的说法。\n(3)事实是真相的反映—事实可能是(1)一个资讯;(2)不受怀疑的推论。不管是哪一种,都代表着事情的真相。(林肯的生日是一个资讯。金原子的序号是一个合理的推论。)因此,事实如果只是对真相提出一点揣测,那就称不上是观念或概念,以及理论。同样地,对真相的解释(或部分解释),除非众所公认是正确的,否则就不能算是事实。\n在最后一点上,有一个例外。如果新理论与某个主题、个人或学派有关时,即使这个理论不再正确,或是尚未全部证实,百科全书仍然可以完全或部分报导。譬如我们不再相信亚里士多德对天体的观察是真确的,但是在亚里士多德的理论部分我们还是可以将它记录下来。\n(4)事实是某种程度上的约定俗成—我们说事实会改变。我们的意思是,某个时代的事实,到了另一个时代却不是事实了。但既然事实代表“真实”,当然是不会变的。因为真实,严格来说是不会变的,事实也不会变。不过所有我们认为是真实的主旨,并不一定都是真实的。我们一定要承认的是,任何我们认为是真实的主旨,都可能被更有包容力、或更正确的观察与调查证明是错的。与科学有关的事实更是如此。\n事实—在某种程度上—也受到文化的影响。譬如一个原子能科学家在脑中所设定的真实是十分复杂的,因此对他来说,某些特定的事实就跟在原始人脑中所想像与接受的不同了。这并不是说科学家与原始人对任何事实都无法取得共鸣,譬如说他们都会同意,二加二等于四,物质的整体大于部分。但是原始人可能不同意科学家所认为的原子核微粒的事实,科学家可能也不同意原始人所说的法术仪式的事实。(这是很难写的一段,因为我们文化背景的影响,我们会想同意科学家的说法,而很想在原始人认为的事实这两个字上加引号。这就是真正的重点所在。)\n如果你记住前面有关事实的叙述,一本好的百科全书会回答你有关事实的所有问题。将百科全书当作是辅助阅读的艺术,也就是能对事实提出适当问题的艺术。就跟字典一样,我们只是帮你提出问题来,百科全书会提供答案的。\n还要记得一点,百科全书不是追求知识最理想的途径。你可能会从其中条理分明的知识中,获得启发,但就算是在最重要的问题上,百科全书的启发性也是有限的。理解需要很多相关条件,在百科全书中却找不到这样的东西。\n百科全书有两个明显的缺失。照理说,百科全书是不记载论点的。除非这个论点已经被广泛接受了,或已成为历史性的趣味话题。因此,在百科全书中,主要缺少的是说理的写法。此外,百科全书虽然记载了有关诗集与诗人的事实,但是其中却不包含诗与想像的文学作品。因为想像与理性都是追求理解必要的条件,因此在求知的过程中,百科全书无法让人完全满意,也就不可避免了。\n"},{"id":136,"href":"/zh/docs/technology/MySQL/_how_mysql_run_/08/","title":"08数据的家--MySQL的数据目录","section":"_MySQL是怎样运行的_","content":" # # 数据库和文件系统的工具 # 数据目录的结构 # 表在文件系统中的表示 # kjskfjksdf\ns ksfd\nInnoDB是如何存储表数据的1 # 系统表空间1 # 撒旦发就\n系统表空间2 # 撒旦发就\n系统表空间3 # 撒旦发就\nInnoDB是如何存储表数据的2 # "},{"id":137,"href":"/zh/docs/technology/MySQL/_%E9%AB%98%E6%80%A7%E8%83%BDMySQL_/%E5%B0%81%E9%9D%A2-%E7%89%88%E6%9D%83/","title":"封面-版权","section":"高性能 My SQL","content":"\n内容简介\n本书是MySQL领域的经典之作,拥有广泛的影响力。第3版更新了大量的内容,不但涵盖了最新MySQL 5.5版本的新特性,也讲述了关于固态盘、高可扩展性设计和云计算环境下的数据库相关的新内容,原有的基准测试和性能优化部分也做了大量的扩展和补充。全书共分为16章和6个附录,内容涵盖MySQL架构和历史,基准测试和性能剖析,数据库软硬件性能优化,复制、备份和恢复,高可用与高可扩展性,以及云端的MySQL和MySQL相关工具等方面的内容。每一章都是相对独立的主题,读者可以有选择性地单独阅读。\n本书不但适合数据库管理员(DBA)阅读,也适合开发人员参考学习。不管是数据库新手还是专家,相信都能从本书有所收获。\n©2012 by Baron Schwartz,Peter Zaitsev,Vadim Tkachenko.\nSimplified Chinese Edition,jointly published by O\u0026rsquo;Reilly Media,Inc. and Publishing House of Electronics Industry,2013. Authorized translation of the English edition,2012 O\u0026rsquo;Reilly Media,Inc.,the owner of all rights to publish and sell the same.\nAll rights reserved including the rights of reproduction in whole or in part in any form.\n本书简体中文版专有出版权由O\u0026rsquo;Reilly Media,Inc.授予电子工业出版社。未经许可,不得以任何方式复制或抄袭本书的任何部分。专有出版权受法律保护。\n版权贸易合同登记号图字:01-2013-1661\n图书在版编目(CIP)数据\n高性能MySQL:第3版/(美)施瓦茨(Schwartz,B.),(美)扎伊采夫(Zaitsev,P.),(美)特卡琴科(Tkachenko,V.)著;宁海元等译.—北京:电子工业出版社,2013.5\n书名原文:High Performance MySQL,Third Edition\nISBN 978-7-121-19885-4\nⅠ.①高… Ⅱ.①施… ②扎… ③特… ④宁… Ⅲ.①关系数据库系统 Ⅳ.①TP311.138\n中国版本图书馆CIP数据核字(2013)第054420号\n策划编辑:张春雨\n责任编辑:白 涛 贾 莉\n封面设计:Karen Montgomery 张 健\n印 刷:三河市鑫金马印装有限公司\n装 订:三河市鑫金马印装有限公司\n出版发行:电子工业出版社\n北京市海淀区万寿路173信箱 邮编:100036\n开 本:787×980 1/16\n印 张:50\n字 数:1040千字\n印 次:2013年5月第1次印刷\n定 价:128.00元\n凡所购买电子工业出版社图书有缺损问题,请向购买书店调换。若书店售缺,请与本社发行部联系,联系及邮购电话:(010)88254888。\n质量投诉请发邮件至zlts@phei.com.cn,盗版侵权举报请发邮件至dbqq@phei.com.cn。\n服务热线:(010)88258888。\nO\u0026rsquo;Reilly Media,Inc.介绍\nO\u0026rsquo;Reilly Media通过图书、杂志、在线服务、调查研究和会议等方式传播创新知识。自1978年开始,O\u0026rsquo;Reilly一直都是前沿发展的见证者和推动者。超级极客们正在开创着未来,而我们关注真正重要的技术趋势——通过放大那些“细微的信号”来刺激社会对新科技的应用。作为技术社区中活跃的参与者,O\u0026rsquo;Reilly的发展充满了对创新的倡导、创造和发扬光大。\nO\u0026rsquo;Reilly为软件开发人员带来革命性的“动物书”;创建第一个商业网站(GNN);组织了影响深远的开放源代码峰会,以至于开源软件运动以此命名;创立了Make杂志,从而成为DIY革命的主要先锋;公司一如既往地通过多种形式缔结信息与人的纽带。O\u0026rsquo;Reilly的会议和峰会集聚了众多超级极客和高瞻远瞩的商业领袖,共同描绘出开创新产业的革命性思想。作为技术人士获取信息的选择,O\u0026rsquo;Reilly现在还将先锋专家的知识传递给普通的计算机用户。无论是通过书籍出版、在线服务或者面授课程,每一项O\u0026rsquo;Reilly的产品都反映了公司不可动摇的理念——信息是激发创新的力量。\n业界评论 # “O\u0026rsquo;Reilly Radar博客有口皆碑。”\n——Wired\n“O\u0026rsquo;Reilly凭借一系列(真希望当初我也想到了)非凡想法建立了数百万美元的业务。”\n——Business 2.0\n“O\u0026rsquo;Reilly Conference是聚集关键思想领袖的绝对典范。”\n——CRN\n“一本O\u0026rsquo;Reilly的书就代表一个有用、有前途、需要学习的主题。”\n——Irish Times\n“Tim是位特立独行的商人,他不光放眼于最长远、最广阔的视野并且切实地按照Yogi Berra的建议去做了:‘如果你在路上遇到岔路口,走小路(岔路)。’回顾过去Tim似乎每一次都选择了小路,而且有几次都是一闪即逝的机会,尽管大路也不错。”\n——Linux Journal\n译者序\n在互联网行业,MySQL数据库毫无疑问已经是最常用的数据库。LAMP(Linux+Apache+MySQL+PHP)甚至已经成为专有名词,也是很多中小网站建站的首选技术架构。我所在的公司淘宝网,在2003年非典肆虐期间创立时,选择的就是LAMP架构,当时MySQL的版本还是4.0。但是到了2003年底,由于业务超预期的增长,MySQL 4.0(当时用的还是MyISAM引擎)的很多缺点在高并发大压力下暴露了出来,于是技术上开始改用商业的Oracle数据库。随后几年Oracle加小型机和高端存储的数据库架构支撑了淘宝网业务的爆炸式增长,数据库也从最初的两三个库增长到十几个库,并且每个库的硬件已经逐步升级到顶配,“天花板”很明显地摆在了眼前。于是在2008年,基于PC服务器的MySQL数据库再次成为DBA团队的选择,这时候MySQL的稳定版本已经升级到5.0,并且5.1也已经在开发中,性能和特性相对于2003年的时候已经有了非常大的提升。淘宝网的数据库架构也逐渐从垂直拆分走向水平拆分,在大规模水平集群的架构设计中,开源的MySQL受到的关注度越来越高,并且一年多来的实践也证明了MySQL(存储引擎主要使用的是InnoDB)在高压力下的可用性。于是从2009年开始,后来颇受外界关注的所谓“去IOE”开始实施,经过三年多的架构改造,到2012年整个淘宝网的核心交易系统已经全部运行在基于PC服务器的MySQL数据库集群中,全部实例数超过2000个。今年的“双11”大促中,MySQL单库经受了最高达6.5万的QPS,某个拥有32个节点的核心集群的总QPS则稳定在86万以上,并且在整个大促(包括之前三年的“双11”大促)期间,数据库未发生过任何影响大促的重大故障。当然,这个结果,也得益于淘宝网整个应用架构的设计,以及这几年来革命性的闪存设备的迅猛发展。\n2008年,淘宝DBA团队准备从Oracle转向MySQL的时候,团队中的大多数人对MySQL的了解都非常之少。当时国内技术圈对MySQL的讨论也不多见,网上能找到的大多数中文资料基本上关注的还是如何安装,如何配置主备复制等。而MySQL中文类的书籍,大部分还是和PHP放在一起,作为PHP开发中的一环来讲述的。所以当我们发现mysqlperformanceblog.com这个相当专业的国外博客的时候,无不欣喜莫名。同时也知道了博客的作者们2008年出版的High Performance MySQL第二版(中文版于2010年1月出版),这本书被很多MySQL DBA们奉为圭臬,书的三位主要作者Baron Schwartz、Peter Zaitsev和Vadim Tkachenko也在MySQL DBA圈中耳熟能详,他们组建的Percona公司和Percona Server分支版本以及XtraDB存储引擎也逐渐为国内DBA所熟知。2011年12月,淘宝网和O\u0026rsquo;Reilly在北京联合举办的Velocity China 2011技术大会上,我们有幸邀请到Percona公司的华人专家季海东(目前已离职)来介绍MySQL 5.5 InnoDB/XtraDB的性能优化和诊断方法。在季海东先生的引荐下,我们也和Peter通过Skype电话会议有过沟通,介绍了MySQL在淘宝的应用情况,我们对MySQL一些特性的需求,以及对MySQL做的一些patch,并随后保持了密切的邮件联系。有了这些铺垫,我们对于在生产系统中采用Percona Server 5.5也有了更大的信心,如今已有超过1000个Percona Server 5.5的实例在线上运行。所以今年上半年电子工业出版社的张春雨(侠少)编辑找到我来翻译本书的第三版的时候,很是激动,一口应承。\n考虑到这么经典的书应该尽快地和读者见面,故此我邀请了团队中的MySQL专家周振兴(花名:苏普)、彭立勋、翟卫祥(花名:印风)、刘辉(花名:希羽)一起来翻译。其中,我负责前、推荐序和第1、2、3章,周振兴负责第5、6、7章,彭立勋负责第4、8、9、14章,翟卫祥负责第10、11、12、13章,刘辉负责第15、16章和附录部分,最后由我负责统稿。所以毫无疑问,这本书是团队合作的结晶。虽然我们满怀激情,但由于都是第一次参与翻译技术书籍,确实对困难有些预估不足,加上下半年为了准备“双11”等各种大促,需要在DBA团队满负荷的工作间隙挤出个人时间,初稿出来后,由于每个人翻译风格不太一致,几次审稿修订,也让本书的编辑李云静和白涛吃了不少苦头,在此对大家表示深深的感谢,是大家不懈的努力,才使得本书能够顺利地和读者见面。但书中肯定还存在不少问题,恳请读者不吝指出,欢迎大家和我的新浪微博 http://weibo.com/NinGoo进行互动。\n同时还要感谢本书第二版的译者们,他们娴熟的语言技巧给了我们很多的参考。也要感谢帮助审稿的同事们,包括但并不仅限于张新铭(花名:俊达)、张瑞(花名:张瑞)、吴学章(花名:维西)等,彭立勋甚至还发动了他女朋友加入到审稿工作中,在此一并表示感谢。当然,最后还要感谢我的妻子Lalla,在我占用了大量周末时间的时候能够给予支持,并承担了全部的家务,让我以译书为借口毫无心理负担地偷懒。\n宁海元(花名:江枫)\n2013年3月于余杭\n目录\nO\u0026rsquo;Reilly Media,Inc.介绍\n译者序\n推荐序\n前言\n第1章 MySQL架构与历史 1.1 MySQL逻辑架构 1.1.1 连接管理与安全性\n1.1.2 优化与执行\n1.2 并发控制 1.2.1 读写锁\n1.2.2 锁粒度\n1.3 事务 1.3.1 隔离级别\n1.3.2 死锁\n1.3.3 事务日志\n1.3.4 MySQL中的事务\n1.4 多版本并发控制\n1.5 MySQL的存储引擎 1.5.1 InnoDB存储引擎\n1.5.2 MyISAM存储引擎\n1.5.3 MySQL内建的其他存储引擎\n1.5.4 第三方存储引擎\n1.5.5 选择合适的引擎\n1.5.6 转换表的引擎\n1.6 MySQL时间线(Timeline)\n1.7 MySQL的开发模式\n1.8 总结\n第2章 MySQL基准测试 2.1 为什么需要基准测试\n2.2 基准测试的策略 2.2.1 测试何种指标\n2.3 基准测试方法 2.3.1 设计和规划基准测试\n2.3.2 基准测试应该运行多长时间\n2.3.3 获取系统性能和状态\n2.3.4 获得准确的测试结果\n2.3.5 运行基准测试并分析结果\n2.3.6 绘图的重要性\n2.4 基准测试工具 2.4.1 集成式测试工具\n2.4.2 单组件式测试工具\n2.5 基准测试案例 2.5.1 http_load\n2.5.2 MySQL基准测试套件\n2.5.3 sysbench\n2.5.4 数据库测试套件中的dbt2 TPC-C测试\n2.5.5 Percona的TPCC-MySQL测试工具\n2.6 总结\n第3章 服务器性能剖析 3.1 性能优化简介 3.1.1 通过性能剖析进行优化\n3.1.2 理解性能剖析\n3.2 对应用程序进行性能剖析 3.2.1 测量PHP应用程序\n3.3 剖析MySQL查询 3.3.1 剖析服务器负载\n3.3.2 剖析单条查询\n3.3.3 使用性能剖析\n3.4 诊断间歇性问题 3.4.1 单条查询问题还是服务器问题\n3.4.2 捕获诊断数据\n3.4.3 一个诊断案例\n3.5 其他剖析工具 3.5.1 使用USER_STATISTICS表\n3.5.2 使用strace\n3.6 总结\n第4章 Schema与数据类型优化 4.1 选择优化的数据类型 4.1.1 整数类型\n4.1.2 实数类型\n4.1.3 字符串类型\n4.1.4 日期和时间类型\n4.1.5 位数据类型\n4.1.6 选择标识符(identifier)\n4.1.7 特殊类型数据\n4.2 MySQL schema设计中的陷阱\n4.3 范式和反范式 4.3.1 范式的优点和缺点\n4.3.2 反范式的优点和缺点\n4.3.3 混用范式化和反范式化\n4.4 缓存表和汇总表 4.4.1 物化视图\n4.4.2 计数器表\n4.5 加快ALTER TABLE操作的速度 4.5.1 只修改.frm文件\n4.5.2 快速创建MyISAM索引\n4.6 总结\n第5章 创建高性能的索引 5.1 索引基础 5.1.1 索引的类型\n5.2 索引的优点\n5.3 高性能的索引策略 5.3.1 独立的列\n5.3.2 前缀索引和索引选择性\n5.3.3 多列索引\n5.3.4 选择合适的索引列顺序\n5.3.5 聚簇索引\n5.3.6 覆盖索引\n5.3.7 使用索引扫描来做排序\n5.3.8 压缩(前缀压缩)索引\n5.3.9 冗余和重复索引\n5.3.10 未使用的索引\n5.3.11 索引和锁\n5.4 索引案例学习 5.4.1 支持多种过滤条件\n5.4.2 避免多个范围条件\n5.4.3 优化排序\n5.5 维护索引和表 5.5.1 找到并修复损坏的表\n5.5.2 更新索引统计信息\n5.5.3 减少索引和数据的碎片\n5.6 总结\n第6章 查询性能优化 6.1 为什么查询速度会慢\n6.2 慢查询基础:优化数据访问 6.2.1 是否向数据库请求了不需要的数据\n6.2.2 MySQL是否在扫描额外的记录\n6.3 重构查询的方式 6.3.1 一个复杂查询还是多个简单查询\n6.3.2 切分查询\n6.3.3 分解关联查询\n6.4 查询执行的基础 6.4.1 MySQL客户端/服务器通信协议\n6.4.2 查询缓存\n6.4.3 查询优化处理\n6.4.4 查询执行引擎\n6.4.5 返回结果给客户端\n6.5 MySQL查询优化器的局限性 6.5.1 关联子查询\n6.5.2 UNION的限制\n6.5.3 索引合并优化\n6.5.4 等值传递\n6.5.5 并行执行\n6.5.6 哈希关联\n6.5.7 松散索引扫描\n6.5.8 最大值和最小值优化\n6.5.9 在同一个表上查询和更新\n6.6 查询优化器的提示(hint)\n6.7 优化特定类型的查询 6.7.1 优化COUNT()查询\n6.7.2 优化关联查询\n6.7.3 优化子查询\n6.7.4 优化GROUP BY和DISTINCT\n6.7.5 优化LIMIT分页\n6.7.6 优化SQL_CALC_FOUND_ROWS\n6.7.7 优化UNION查询\n6.7.8 静态查询分析\n6.7.9 使用用户自定义变量\n6.8 案例学习 6.8.1 使用MySQL构建一个队列表\n6.8.2 计算两点之间的距离\n6.8.3 使用用户自定义函数\n6.9 总结\n第7章 MySQL高级特性 7.1 分区表 7.1.1 分区表的原理\n7.1.2 分区表的类型\n7.1.3 如何使用分区表\n7.1.4 什么情况下会出问题\n7.1.5 查询优化\n7.1.6 合并表\n7.2 视图 7.2.1 可更新视图\n7.2.2 视图对性能的影响\n7.2.3 视图的限制\n7.3 外键约束\n7.4 在MySQL内部存储代码 7.4.1 存储过程和函数\n7.4.2 触发器\n7.4.3 事件\n7.4.4 在存储程序中保留注释\n7.5 游标\n7.6 绑定变量 7.6.1 绑定变量的优化\n7.6.2 SQL接口的绑定变量\n7.6.3 绑定变量的限制\n7.7 用户自定义函数\n7.8 插件\n7.9 字符集和校对 7.9.1 MySQL如何使用字符集\n7.9.2 选择字符集和校对规则\n7.9.3 字符集和校对规则如何影响查询\n7.10 全文索引 7.10.1 自然语言的全文索引\n7.10.2 布尔全文索引\n7.10.3 MySQL 5.1中全文索引的变化\n7.10.4 全文索引的限制和替代方案\n7.10.5 全文索引的配置和优化\n7.11 分布式(XA)事务 7.11.1 内部XA事务\n7.11.2 外部XA事务\n7.12 查询缓存 7.12.1 MySQL如何判断缓存命中\n7.12.2 查询缓存如何使用内存\n7.12.3 什么情况下查询缓存能发挥作用\n7.12.4 如何配置和维护查询缓存\n7.12.5 InnoDB和查询缓存\n7.12.6 通用查询缓存优化\n7.12.7 查询缓存的替代方案\n7.13 总结\n第8章 优化服务器设置 8.1 MySQL配置的工作原理 8.1.1 语法、作用域和动态性\n8.1.2 设置变量的副作用\n8.1.3 入门\n8.1.4 通过基准测试迭代优化\n8.2 什么不该做\n8.3 创建MySQL配置文件 8.3.1 检查MySQL服务器状态变量\n8.4 配置内存使用 8.4.1 MySQL可以使用多少内存\n8.4.2 每个连接需要的内存\n8.4.3 为操作系统保留内存\n8.4.4 为缓存分配内存\n8.4.5 InnoDB缓冲池(Buffer Pool)\n8.4.6 MyISAM键缓存(Key Caches)\n8.4.7 线程缓存\n8.4.8 表缓存(Table Cache)\n8.4.9 InnoDB数据字典(Data Dictionary)\n8.5 配置MySQL的I/O行为 8.5.1 InnoDB I/O配置\n8.5.2 MyISAM的I/O配置\n8.6 配置MySQL并发 8.6.1 InnoDB并发配置\n8.6.2 MyISAM并发配置\n8.7 基于工作负载的配置 8.7.1 优化BLOB和TEXT的场景\n8.7.2 优化排序(Filesorts)\n8.8 完成基本配置\n8.9 安全和稳定的设置\n8.10 高级InnoDB设置\n8.11 总结\n第9章 操作系统和硬件优化 9.1 什么限制了MySQL的性能\n9.2 如何为MySQL选择CPU 9.2.1 哪个更好:更快的CPU还是更多的CPU\n9.2.2 CPU架构\n9.2.3 扩展到多个CPU和核心\n9.3 平衡内存和磁盘资源 9.3.1 随机I/O和顺序I/O\n9.3.2 缓存,读和写\n9.3.3 工作集是什么\n9.3.4 找到有效的内存/磁盘比例\n9.3.5 选择硬盘\n9.4 固态存储 9.4.1 闪存概述\n9.4.2 闪存技术\n9.4.3 闪存的基准测试\n9.4.4 固态硬盘驱动器(SSD)\n9.4.5 PCIe存储设备\n9.4.6 其他类型的固态存储\n9.4.7 什么时候应该使用闪存\n9.4.8 使用Flashcache\n9.4.9 优化固态存储上的MySQL\n9.5 为备库选择硬件\n9.6 RAID性能优化 9.6.1 RAID的故障转移、恢复和镜像\n9.6.2 平衡硬件RAID和软件RAID\n9.6.3 RAID配置和缓存\n9.7 SAN和NAS 9.7.1 SAN基准测试\n9.7.2 使用基于NFS或SMB的SAN\n9.7.3 MySQL在SAN上的性能\n9.7.4 应该用SAN吗\n9.8 使用多磁盘卷\n9.9 网络配置\n9.10 选择操作系统\n9.11 选择文件系统\n9.12 选择磁盘队列调度策略\n9.13 线程\n9.14 内存交换区\n9.15 操作系统状态 9.15.1 如何阅读vmstat的输出\n9.15.2 如何阅读iostat的输出\n9.15.3 其他有用的工具\n9.15.4 CPU密集型的机器\n9.15.5 I/O密集型的机器\n9.15.6 发生内存交换的机器\n9.15.7 空闲的机器\n9.16 总结\n第10章 复制 10.1 复制概述 10.1.1 复制解决的问题\n10.1.2 复制如何工作\n10.2 配置复制 10.2.1 创建复制账号\n10.2.2 配置主库和备库\n10.2.3 启动复制\n10.2.4 从另一个服务器开始复制\n10.2.5 推荐的复制配置\n10.3 复制的原理 10.3.1 基于语句的复制\n10.3.2 基于行的复制\n10.3.3 基于行或基于语句:哪种更优\n10.3.4 复制文件\n10.3.5 发送复制事件到其他备库\n10.3.6 复制过滤器\n10.4 复制拓扑 10.4.1 一主库多备库\n10.4.2 主动-主动模式下的主-主复制\n10.4.3 主动-被动模式下的主-主复制\n10.4.4 拥有备库的主-主结构\n10.4.5 环形复制\n10.4.6 主库、分发主库以及备库\n10.4.7 树或金字塔形\n10.4.8 定制的复制方案\n10.5 复制和容量规划 10.5.1 为什么复制无法扩展写操作\n10.5.2 备库什么时候开始延迟\n10.5.3 规划冗余容量\n10.6 复制管理和维护 10.6.1 监控复制\n10.6.2 测量备库延迟\n10.6.3 确定主备是否一致\n10.6.4 从主库重新同步备库\n10.6.5 改变主库\n10.6.6 在一个主-主配置中交换角色\n10.7 复制的问题和解决方案 10.7.1 数据损坏或丢失的错误\n10.7.2 使用非事务型表\n10.7.3 混合事务型和非事务型表\n10.7.4 不确定语句\n10.7.5 主库和备库使用不同的存储引擎\n10.7.6 备库发生数据改变\n10.7.7 不唯一的服务器ID\n10.7.8 未定义的服务器ID\n10.7.9 对未复制数据的依赖性\n10.7.10 丢失的临时表\n10.7.11 不复制所有的更新\n10.7.12 InnoDB加锁读引起的锁争用\n10.7.13 在主-主复制结构中写入两台主库\n10.7.14 过大的复制延迟\n10.7.15 来自主库的过大的包\n10.7.16 受限制的复制带宽\n10.7.17 磁盘空间不足\n10.7.18 复制的局限性\n10.8 复制有多快\n10.9 MySQL复制的高级特性\n10.10 其他复制技术\n10.11 总结\n第11章 可扩展的MySQL 11.1 什么是可扩展性 11.1.1 正式的可扩展性定义\n11.2 扩展MySQL 11.2.1 规划可扩展性\n11.2.2 为扩展赢得时间\n11.2.3 向上扩展\n11.2.4 向外扩展\n11.2.5 通过多实例扩展\n11.2.6 通过集群扩展\n11.2.7 向内扩展\n11.3 负载均衡 11.3.1 直接连接\n11.3.2 引入中间件\n11.3.3 一主多备间的负载均衡\n11.4 总结\n第12章 高可用性 12.1 什么是高可用性\n12.2 导致宕机的原因\n12.3 如何实现高可用性 12.3.1 提升平均失效时间(MTBF)\n12.3.2 降低平均恢复时间(MTTR)\n12.4 避免单点失效 12.4.1 共享存储或磁盘复制\n12.4.2 MySQL同步复制\n12.4.3 基于复制的冗余\n12.5 故障转移和故障恢复 12.5.1 提升备库或切换角色\n12.5.2 虚拟IP地址或IP接管\n12.5.3 中间件解决方案\n12.5.4 在应用中处理故障转移\n12.6 总结\n第13章 云端的MySQL 13.1 云的优点、缺点和相关误解\n13.2 MySQL在云端的经济价值\n13.3 云中的MySQL的可扩展性和高可用性\n13.4 四种基础资源\n13.5 MySQL在云主机上的性能 13.5.1 在云端的MySQL基准测试\n13.6 MySQL数据库即服务(DBaaS) 13.6.1 Amazon RDS\n13.6.2 其他DBaaS解决方案\n13.7 总结\n第14章 应用层优化 14.1 常见问题\n14.2 Web服务器问题 14.2.1 寻找最优并发度\n14.3 缓存 14.3.1 应用层以下的缓存\n14.3.2 应用层缓存\n14.3.3 缓存控制策略\n14.3.4 缓存对象分层\n14.3.5 预生成内容\n14.3.6 作为基础组件的缓存\n14.3.7 使用HandlerSocket和memcached\n14.4 拓展MySQL\n14.5 MySQL的替代品\n14.6 总结\n第15章 备份与恢复 15.1 为什么要备份\n15.2 定义恢复需求\n15.3 设计MySQL备份方案 15.3.1 在线备份还是离线备份\n15.3.2 逻辑备份还是物理备份\n15.3.3 备份什么\n15.3.4 存储引擎和一致性\n15.4 管理和备份二进制日志 15.4.1 二进制日志格式\n15.4.2 安全地清除老的二进制日志\n15.5 备份数据 15.5.1 生成逻辑备份\n15.5.2 文件系统快照\n15.6 从备份中恢复 15.6.1 恢复物理备份\n15.6.2 还原逻辑备份\n15.6.3 基于时间点的恢复\n15.6.4 更高级的恢复技术\n15.6.5 InnoDB崩溃恢复\n15.7 备份和恢复工具 15.7.1 MySQL Enterprise Backup\n15.7.2 Percona XtraBackup\n15.7.3 mylvmbackup\n15.7.4 Zmanda Recovery Manager\n15.7.5 mydumper\n15.7.6 mysqldump\n15.8 备份脚本化\n15.9 总结\n第16章 MySQL用户工具 16.1 接口工具\n16.2 命令行工具集\n16.3 SQL实用集\n16.4 监测工具 16.4.1 开源的监控工具\n16.4.2 商业监控系统\n16.4.3 Innotop的命令行监控\n16.5 总结\n附录A MySQL分支与变种\n附录B MySQL服务器状态\n附录C 大文件传输\n附录D EXPLAIN\n附录E 锁的调试\n附录F 在MySQL上使用Sphinx\n索引\n推荐序\n很多年前我就是这本书的“粉丝”了,这是一本伟大的书,第三版尤其如此。这些世界级的专家不仅仅分享他们的专业知识,也花了很多时间来更新和添加新的章节,且都是高品质的内容。本书有大量关于如何获得MySQL高性能的细节信息,并且关注的是提升性能的过程,而不仅仅是描述事实结果和琐碎的细枝末节。这本书将告诉读者如何将事情做得更好,不管MySQL在不同版本中的行为有多么大的改变。\n毫无疑问,本书的作者是唯一有资格来写这么一本书的人,他们经验丰富,有合理的方法,关注效率,并且精益求精。说到经验丰富,本书的作者已经在MySQL性能领域工作多年,从MySQL还没有什么可扩展性和可测量性的时代,直到现在这些方面已经有了长足的进步。而说到合理的方法,他们简直把这件事情当成了科学,首先定义需要解决的问题,然后通过合理的猜测和精确的测量来解决问题。\n我对作者在效率方面的关注尤其印象深刻。作为顾问,他们时间宝贵。客户是按照他们的时间付费的,所以都希望能更快地解决问题。所以本书作者定义了一整套的流程,开发了很多的工具,让事情变得正确和高效。在本书中,作者详细描述了这些流程,并且发布了工具的源代码。\n最后,本书作者在工作上一直精益求精。比如从吞吐量到响应时间的关注,致力于了解MySQL在新硬件上的性能表现,追求新的技能如排队理论对性能的影响,等等。\n我相信本书预示了MySQL的光明前景。MySQL已经支持高要求的工作负载,本书作者也在努力提升MySQL社区内对性能的认识。同时,他们还直接为性能提升做出了贡献,包括XtraDB和XtraBackup。一直以来我从他们身上学到了不少东西,也希望读者多花点时间读读本书,一定会同样有所收益。\n——Mark Callaghan,Facebook软件工程师\n前言\n我们写这本书不仅仅是为了满足MySQL应用开发者的需求,也是为了满足MySQL数据库管理员的需要。我们假定读者已经有了一定的MySQL基础。我们还假定读者对于系统管理、网络和类Unix的操作系统都有一些了解。\n本书的第二版为读者提供了大量的信息,但没有一本书是可以涵盖一个主题的所有方面的。在第二版和第三版之间的这段时间里,我们记录了数以千计有趣的问题,其中有些是我们解决的,也有一些是我们观察到其他人解决的。当我们在规划第三版的时候发现,如果要把这些主题完全覆盖,可能三千页到五千页的篇幅都还不够,这样本书的完成就遥遥无期了。在反思这个问题后,我们意识到第二版强调的广泛的覆盖度事实上有其自身的限制,从某种意义上来说也没有引导读者如何按照MySQL的方式来思考问题。\n所以第三版和第二版的关注点有很大的不同。我们虽然还是会包含很多的信息,并且会强调同样的诸如可靠性和正确性的目标,但我们也会在本书中尝试更深入的讨论:我们会指出MySQL为什么会这样做,而不是MySQL做了什么。我们会使用更多的演示和案例学习来将上述原则落地。通过这样的方式,我们希望能够尝试回到下面这样的问题:“给出MySQL的内部结构和操作,对于实际应用能带来什么帮助?为什么能有这样的帮助?如何让MySQL适合(或者不适合)特定的需求?”\n最后,我们希望关于MySQL内部原理的知识能够帮助大家解决本书没有覆盖到的一些情况。我们更希望读者能培养发现新问题的洞察力,能学习和实践合理的方式来设计、维护和诊断基于MySQL的系统。\n本书是如何组织的 # 本书涵盖了许多复杂的主题。在这里,我们将解释一下是如何将这些主题有序地组织在一起的,以便于阅读和学习。\n概述 # 第1章是非常基础的一章,在更深入地学习之前建议先熟悉一下这部分内容。在有效地使用MySQL之前应当理解它是如何组织的。本章解释了MySQL的架构及其存储引擎的关键设计。如果读者还不太熟悉关系数据库和事务的基础知识,本章也可以带来一点帮助。如果之前已经对其他关系数据库如Oracle比较熟悉,本章也可以帮助读者了解MySQL的入门知识。本章还包括了一点MySQL的历史背景:MySQL随着时间的演进、最近的公司所有权更替,以及我们认为比较重要的内容。\n打造坚实的基础 # 本书前几章的内容在今后使用MySQL的过程中可能会被不断地引用到,它们是非常基础的内容。\n第2章讨论了基准测试的基础,例如服务器可以处理的工作负载的类型、处理特定任务的速度等。基准测试是一项至关重要的技能,可用于评估服务器在不同负载下的表现,但也要明白在什么情况下基准测试不能发挥作用。\n第3章介绍了我们常用于故障诊断和服务器性能问题分析的一种面向响应时间的方法。该方法已经被证明可以解决我们曾碰到过的一些极为棘手的问题。当然也可以选择修改我们所使用的方法(实际上我们的方法也是从Cary Millsap的方法修改而来的),但无论如何,至少不能没有方法胡乱猜测。\n从第4章到第6章,连续介绍了三个关于良好的数据库逻辑设计和物理设计基础的话题。第4章涵盖了不同数据类型的细节差别以及表设计的原则。第5章则展开讨论了索引,这是数据库的物理设计。对于索引的深入理解和利用是高效使用MySQL的基础,相信这一章会经常需要回头翻看。而第6章则包含了分析MySQL的查询是如何执行的,以及如何利用查询优化器的话题。该章也包含了大量常见类型查询的例子,演示了MySQL是如何做好工作的,以及如何改写查询以利用MySQL的特性。\n到此为止,已经覆盖了关于数据库的基础内容:表、索引、数据和查询。第7章则在MySQL基础知识之外介绍了MySQL的高级特性是如何工作的。这章的内容包括分区、存储引擎、触发器,以及字符集。MySQL中这些特性的实现可能不同于其他数据库,可能之前读者并不清楚这些不同,因此理解它们对于性能可能会带来新的收益。\n配置应用程序 # 接下来的两章讲述的是如何让MySQL、应用程序及硬件一起很好地工作。第8章介绍了如何配置MySQL,以便更好地利用硬件,达到更好的可靠性和鲁棒性。第9章解释了如何让操作系统和硬件工作得更好。另外也深入讨论了固态硬盘,为高可扩展性应用发挥更好的性能提供了硬件配置的建议。\n上面两章都一定程度地涉及了MySQL的内部知识。这将会是一个反复出现的主题,附录中也会有相关内容可以学习到MySQL的内部是如何实现的,理解了这些知识将帮助读者更好地理解某些现象背后的原理。\n作为基础设施组件的MySQL # MySQL不是存在于真空中的,而是应用整体的一个环节,因此需要考虑整个应用架构的鲁棒性。下面的章节将告诉我们该如何做到这一点。\n第10章讨论了MySQL的杀手级特性:能够设置多个服务器从一台主服务器同步数据。不幸的是,复制可能也是MySQL给很多用户带来困扰的一个特性。但实际上不应该发生这样的情况,本章将告诉你如何让复制运行得更好。\n第11章讨论了什么是可扩展性(这和性能不是一回事),应用和系统为什么会无法扩展,该怎么改善扩展性。如果能够正确地处理,MySQL的可扩展性是足以应付任何需求的。第12章讲述的是和可扩展性相关但又完全不同的主题:如何保障MySQL稳定而正确地持续运行。第13章将告诉你当MySQL在云计算环境中运行时会有什么不同的事情发生。第14章解释了什么是全方位的优化(full-stack optimization),就是从前端到后端的整体优化,从用户体验开始直到数据库。\n即使是世界上设计最好、最具可扩展性的架构,如果停电会导致彻底崩溃,无法抵御恶意攻击,解决不了应用的bug和程序员的错误,以及其他一些灾难场景,那就不是什么好的架构。第15章讨论了MySQL数据库各种备份与恢复的场景。这些策略可以帮助读者减少在各种不可抗的硬件失效时的宕机时间,保证在各种灾难下的数据最终可恢复。\n其他有用的主题 # 在本书的最后一章以及附录中,我们探讨了一些无法明确地放到前面章节的内容,以及一些被前面多个章节引用而需要特别注意的主题。\n第16章探索了一些可以帮助用户更有效地管理和监控MySQL服务器的工具,有些是开源的,也有些是商业的。\n附录A介绍了近年来成长迅速的三个主要的非MySQL官方版本,其中一个是我们公司在维护的产品。知道还有其他什么是可用的选择是有价值的;很多MySQL难以解决的棘手问题在其他的变种版本中说不定就不是问题了。这三个版本中的两个(Percona Server和MariaDB)是MySQL的完全可替换版本,所以尝试使用的成本相对来说是很低的。当然,在这里我们也需要补充一点,Oracle提供的MySQL官方版本对于大多数用户来说都能服务得很好。\n附录B演示了如何检查MySQL服务器。知道如何从服务器获取状态信息是非常重要的;而了解这些状态代表的意义则更加重要。这里将覆盖SHOW INNODB STATUS的输出结果,因此这里包含了InnoDB事务存储引擎的深入信息。在这个附录中讨论了很多InnoDB的内部信息。\n附录C演示了如何高效地将大文件从一个地方复制到另外一个地方。如果要管理大量的数据,这种操作是经常都会碰到的。附录D演示了如何真正地使用并理解EXPLAIN命令。附录E演示了如何破除不同查询所请求的锁互相干扰的问题。最后,附录F介绍了Sphinx,一个基于MySQL的高性能的全文索引系统。\n软件版本与可用性 # MySQL是一个移动靶。从Jeremy写作本书第一版到现在,MySQL已经发布了好几个版本。当本书第一版的初稿交给出版社的时候,MySQL 4.1和5.0还只是alpha版本,而如今MySQL 5.1和5.5已经是很多在线应用的主力版本。在我们写完这第三版的时候,MySQL 5.6也即将发布。\n本书的内容并不依赖某一个具体的版本。相反,我们会利用自己在实际环境中获得的更广泛的知识。本书的核心内容主要关注MySQL 5.1和5.5版本,因为我们认为这是“当前”的版本。本书的大多数例子都假设运行在MySQL 5.1的某个成熟版本上,比如MySQL 5.1.50或者更高的版本。对于在旧版本中可能不存在,或者只在即将到来的5.6版本中出现的特性或者功能,我们也会特别标注出来。然而,关于某个MySQL版本的特性的权威指南还是要看官方文档。在阅读本书时,建议随时访问在线官方文档的相关内容( http://dev.mysql.com/doc/)。\nMySQL的另外一个伟大特点是能够运行在现今流行的所有平台:Mac OS X,Windows,GNU/Linux,Solaris,FreeBSD,以及只要你能举出名字的其他平台。然而,本书主要基于GNU/Linux(1)和其他类Unix系统。Windows的用户可能会碰到一些困难。比如说文件路径就和Windows完全不一样。我们也会引用一些Unix的命令行工具,我们假设读者能够知道Windows上对应的工具是什么(2)。\n在Windows上搞MySQL的另外一个难点是Perl。MySQL中有很多有用的工具是用Perl写的。在本书的一些章节中,也有一些Perl脚本,在此基础上可以构建更加复杂的工具。Percona Toolkit是不可多得的MySQL管理工具,也是用Perl写的。然而,Windows平台默认是没有Perl环境的。为了使用这些工具,需要从ActiveState下载Perl的Windows版本,以及访问MySQL所需要的一些额外的模块(DBI和DBD::MySQL)。\n本书使用的约定 # 下面是本书中使用的一些约定。\n斜体(Italic)\n新的名字、URL、邮件地址、用户名、主机名、文件名、文件扩展名、路径名、目录,以及Unix命令和工具都使用斜体表示。\n等宽字体(Constant width)\n包括代码元素、配置选项、数据库和表名、变量和值、函数、模块、文件内容、命令输出等,使用的是等宽字体。\n加粗的等宽字体(Constant width bold)\n命令或者其他需要用户输入的文本,命令输出中需要强调的某些内容,会使用加粗的等宽字体。\n斜体的等宽字体(Constant width italic)\n需要用户替换的文本以斜体的等宽字体表示。\n这个图标表示提示、建议,或者一般的记录。\n这个图标表示一个警告或者提醒。\n使用示例代码 # 本书的目标是为了帮助读者更好地工作。一般来说,你可以在程序或者文档中使用本书中的代码。只要不是大规模地复制重要的代码,使用的时候不需要联系我们。例如,你编写的程序中如果只是使用了本书部分的代码片段则无须取得授权,而出售或者分发O\u0026rsquo;Reilly书籍示例代码的CD-ROM盘片则需要经过授权。引用本书的代码回答问题也无须取得授权,而大量引用本书的示例代码到产品文档中则需要获取授权。\n示例代码维护在 http://www.highperfmysql.com站点中,会及时保持更新。但我们无法确保代码会跟随每一个MySQL的小版本进行更新和测试。\n我们欢迎大家在使用了本书代码后进行反馈,但这不是一个强制要求。反馈时请提供标题、作者、出版公司和ISBN。例如:“High Performance MySQL,Third Edition,by Baron Schwartz et al.(O\u0026rsquo;Reilly). Copyright 2012 Baron Schwartz,Peter Zaitsev,and Vadim Tkachenko,978-1-449-31428-6”。\n如果你使用了本书的代码,但又不在上面描述的一些无须授权的范围之内,不确定是否需要获取授权时,请联系 permissions@oreilly.com。\nSafari在线书店 # Safari在线书店( www.safaribooksonline.com)是一家提供定制服务的数字图书馆,提供技术和商务领域内顶级作家的高质量内容的书籍和音像制品。很多技术专家、软件开发者、Web设计师、商务人士和创新专家都将Safari在线书店作为他们研究、解决问题、学习和认证练习的首选资料来源。\nSafari在线书店为组织、政府机构和个人提供了一系列的产品组合和定价计划。订阅者可以访问数以千计的图书、培训视频和手稿,这些存在于一个可搜索的数据库中,涵盖的出版公司有O\u0026rsquo;Reilly Media,Prentice Hall Professional,Addison-Wesley Professional,Microsoft Press,Sams,Que,Peachpit Press,Focal Press,Cisco Press,John Wiley\u0026amp;Sons,Syngress,Morgan Kaufmann,IBM Redbooks,Packt,Adobe Press,FT Press,Apress,Manning,New Riders,McGraw-Hill,Jones\u0026amp;Bartlett,Course Technology,等等。如需了解更多关于Safari在线书店的情况,请访问在线网站。\n如何联系我们 # 若有关于本书的任何评论或者问题,请和出版公司联系。\n美国:\nO\u0026rsquo;Reilly Media,Inc.\n1005 Gravenstein Highway North\nSebastopol,CA 95472\n中国:\n北京市西城区西直门南大街2号成铭大厦C座807室(100035)\n奥莱利技术咨询(北京)有限公司\n本书有一个配套的网页,上面列出了勘误表、示例代码及其他相关信息。下面是此网页的地址:\nhttp://shop.oreilly.com/product/0636920022343.do\n如果有关于本书的评论和技术问题,也可以通过邮件进行沟通:\nbookquestions@oreilly.com\n如果想了解更多关于我们出版公司的书籍、会议、资源中心和O\u0026rsquo;Reilly网络的信息,请访问网站:\nhttp://www.oreilly.com\n我们的Facebook: http://facebook.com/oreilly\n我们的Twitter: http://twitter.com/oreillymedia\n我们的YouTube: http://www.youtube.com/oreillymedia\n当然,读者也可以直接和作者取得联系,可以访问作者的公司网站 http://www.percona.com。我们将乐于收到大家的反馈。\n本书第三版的致谢 # 感谢以下人员给予的各种帮助:Brian Aker,Johan Andersson,Espen Braekken,Mark Callaghan,James Day,Maciej Dobrzanski,Ewen Fortune,Dave Hildebrandt,Fernando Ipar,Haidong Ji,Giuseppe Maxia,Aurimas Mikalauskas,Istvan Podor,Yves Trudeau,Matt Yonkovit,Alex Yurchenko。感谢Percona公司的所有员工,多年来为本书提供了无数的支持。感谢很多著名博主(3)和技术大会的演讲者,他们为本书的很多思想提供了大量的素材,尤其是Yoshinori Matsunobu。另外也要感谢本书前面两版的作者:Jeremy D. Zawodny、Derek J. Balling和Arjen Lentz。感谢Andy Oram、Rachel Head,以及O\u0026rsquo;Reilly的整个编辑团队,你们为本书的出版和发行做了卓有成效的工作。非常感谢Oracle的才华横溢且专注的MySQL团队,以及所有之前的MySQL开发者,不管你现在是在SkySQL还是在Monty团队。\nBaron也要感谢他的妻子Lynn、他的母亲Connie,以及他的岳父母Jane和Roger,感谢他们一如既往地支持他的工作,尤其是不断地鼓励他,并且承担了所有的家务和照顾整个家庭的重任。也要感谢Peter和Vadim,你们是如此优秀的老师和同事。Baron将此版本献给Alan Rimm-Kaufman,以纪念他给予的伟大的爱和鼓励,这些都将永志不忘。\n本书第二版的致谢 # Sphinx的开发者Andrew Aksyonoff编写了附录F。我们非常感谢他首次对此进行深入的讨论。\n在编写本书的时候,我们得到了很多人的无私帮助。在此无法一一列举——我们真的非常感谢MySQL社区和MySQL AB公司的每一个人。下面是对本书做出了直接贡献的人,如有遗漏,还请见谅。他们是:Tobias Asplund,Igor Babaev,Pascal Borghino,Roland Bouman,Ronald Bradford,Mark Callaghan,Jeremy Cole,Britt Crawford和他的HiveDB项目,Vasil Dimov,Harrison Fisk,Florian Haas,Dmitri Joukovski和他的Zmanda项目(同时感谢Dmitri为解释LVM快照提供的配图),Alan Kasindorf,Sheeri Kritzer Cabral,Marko Makela,Giuseppe Maxia,Paul McCullagh,B. Keith Murphy,Dhiren Patel,Sergey Petrunia,Alexander Rubin,Paul Tuckfield,Heikki Tuuri,以及Michael“Monty”Widenius。在这里还要特别感谢O\u0026rsquo;Reilly的编辑Andy Oram和助理编辑Isabel Kunkle,以及审稿人Rachel Wheeler,同时也要感谢O\u0026rsquo;Reilly团队的其他所有成员。\n来自Baron # 我要感谢我的妻子Lynn Rainville和小狗Carbon。如果你也曾写过一本书,我相信你就能体会到我是如何地感谢他们。我也非常感谢Alan Rimm-Kaufman和我在Rimm-Kaufman集团的同事,在写书的过程中,他们给了我支持和鼓励。谢谢Peter、Vadim和Arjen,是你们给了我梦想成真的机会。最后,我要感谢Jeremy和Derek为我们开了个好头。\n来自Peter # 我从事MySQL性能和可扩展性方面的演讲、培训和咨询工作已经很多年了,我一直想把它们扩展到更多的受众。因此,当Andy Oram加入到本书的编写当中时,我感到非常兴奋。此前我没有写过书,所以我对所需要的时间和精力都毫无把握。一开始我们谈到只对第一版做一些更新,以跟上MySQL最新的版本升级,但我们想把更多新素材加入到书中,结果几乎相当于重写了整本书。\n这本书是真正的团队合作的结晶。因为我忙于Percona公司的事情——这是我和Vadim的咨询公司,而且英语并非我的第一语言,所以我们有着不同的角色分工。我负责提供大纲和技术性内容,评审所有的材料,在写作的时候再进行修订和扩展。当Arjen(MySQL文档团队的前负责人)加入之后,我们就开始勾画出整个提纲。在Baron加入后,一切才开始真正行动起来,他能够以不可思议的速度编写出高质量的内容。Vadim则在深入检查MySQL源代码和提供基准测试及其他研究来巩固我们的论点时提供了巨大的帮助。\n当我们编写本书时,我们发现有越来越多的领域需要刨根问底。本书的大部分主题,如复制、查询优化、InnoDB、架构和设计都足以单独成书。因此,有时候我们不得不在某个点停止深入,把余下的材料用在将来可能出版的新版本中,或者我们的博客、演讲和技术文章中。\n本书的评审者给了我们非常大的帮助,无论是来自MySQL AB公司内部的人员,还是外部的人员,他们都是MySQL领域最优秀的世界级专家。其中包括MySQL的创建者Michael Widenius、InnoDB的创建者Heikki Tuuri、MySQL优化器团队的负责人Igor Babaev,以及其他人。\n我还要感谢我的妻子Katya Zaytseva,我的孩子Ivan和Nadezhda,他们允许我把家庭时间花在了本书的写作上。我也要感谢Percona的员工,当我在公司里“人间蒸发”去写书时,他们承担了日常事务的处理工作。当然,我也要感谢O\u0026rsquo;Reilly和Andy Oram让这一切成为可能。\n来自Vadim # 我要感谢Peter,能够在本书中和他合作,我感到十分开心,期望在其他项目中能继续共事。我也要感谢Baron,他在本书的写作过程中起了很大的作用。还有Arjen,跟他一起工作非常好玩。我还要感谢我们的编辑Andy Oram,他抱着十二万分的耐心和我们一起工作。此外,还要感谢MySQL团队,是他们创造了这个伟大的软件。我还要感谢我们的客户给予我调优MySQL的机会。最后,我要特别感谢我的妻子Valerie,以及我们的孩子Myroslav和Timur,他们一直支持我,帮助我一步步前进。\n来自Arjen # 我要感谢Andy的睿智、指导和耐心,感谢Baron中途加入到我们当中来、感谢Peter和Vadim坚实的背景信息和基准测试。也要感谢Jeremy和Derek在第一版中打下的基础。在我的书上,Derak题写着:“要诚实——这就是我的所有要求”。\n我也要感谢我在MySQL AB公司时的所有同事,在那里我获得了关于本书主题的大多数知识。在此,我还要特别提到Monty,我一直认为他是令人自豪的MySQL之父,尽管他的公司如今已经成为Sun公司的一部分。我要感谢全球MySQL社区里的每一个人。\n最后同样重要的是,我要感谢我的女儿Phoebe,在她尚年少的生活舞台上,不用关心什么是MySQL,也不用考虑Wiggles指的是什么东西。从某些方面来讲,无知就是福。它能给予我们一个全新的视角来看清生命中真正重要的是什么。对于读者,祝愿你们的书架上又增添了一本有用的书,还有,不要忘记你的生活。\n本书第一版的致谢 # 要完成这样一本书的写作,离不开许许多多人的帮助。没有他们的无私援助,你手上的这本书就可能仍然是我们显示器屏幕四周的那一堆贴纸。这是本书的一部分,在这里,我们可以感谢每一个曾经帮助我们脱离困境的人,而无须担心突然奏响的背景音乐催促我们闭上嘴巴赶紧走掉——如同你在电视里看到的颁奖晚会那样。\n如果没有编辑Andy Oram坚决的督促、请求、央求和支持,我们就无法完成本书。如果要找对于本书最负责的一个人,那就是Andy。我们真的非常感谢每周一次的唠唠叨叨的会议。\n然而,Andy不是一个人在战斗。在O\u0026rsquo;Reilly,还有一批人都参与了将那些小贴纸变成你正在看的这本书的工作。所以我们也要感谢那些在生产、插画和销售环节的人们,感谢你们把这本书变成实体。当然,也要感谢Tim O\u0026rsquo;Reilly,是他持久不变地承诺为广大开源软件出版一批业内最好的图书。\n最后,我们要把感谢给予那些同意审阅本书不同版本草稿,并告诉我们哪里有错误的人们:我们的评审者。他们把2003年假期的一部分时间用在了审阅这些格式粗糙,充满了打字符号、误导性的语句和彻底的数学错误的文本上。我们要感谢(排名不分先后):Brian“Krow”Aker,Mark“JDBC”Matthews,Jeremy“the other Jeremy”Cole,Mike“VBMySQL.com( http://vbmysql.com)”Hillyer,Raymond“Rainman”De Roo,Jeffrey“Regex Master”Friedl,Jason DeHaan,Dan Nelson,Steve“Unix Wiz”Friedl,Kasia“Unix Girl”Trapszo。\n来自Jeremy # 我要在此感谢Andy,是他同意接纳这个项目,并持续不断地鞭策我们加入新的章节内容。Derek的帮助也非常关键,本书最后的20%~30%内容由他一手完成,这使得我们没有错过下一个目标日期。感谢他同意中途加入进来,代替我只能偶尔爆发一下的零星生产力,完成了关于XML的烦琐工作、第10章、附录F,以及我丢给他的那些活儿。\n我也要感谢我的父母,在多年以前他们就给我买了Commodore 64电脑,他们不仅在前10年里容忍了我就像要以身相许般的对电子和计算机技术的痴迷,并在之后还成为我不懈学习和探索的支持者。\n接下来,我要感谢在过去几年里在Yahoo!布道推广MySQL时遇到的那一群人。跟他们共事,我感到非常愉快。在本书的筹备阶段,Jeffrey Friedl和Ray Goldberger给了我鼓励和反馈意见。在他们之后还有Steve Morris、James Harvey和Sergey Kolychev容忍了我在Yahoo! Finance MySQL服务器上做着看似固定不变的实验,即使这打扰了他们的重要工作。我也要感谢Yahoo!的其他成员,是他们帮我发现了MySQL上的那些有趣的问题和解决方法。还有,最重要的是要感谢他们对我有足够的信任和信念,让我把MySQL用在Yahoo!重要和可见的部分业务上。\nAdam Goodman,一位出版家和Linux Magazine的拥有者,他帮助我轻装上阵为技术受众撰写文章,并在2001年下半年第一次出版了我的MySQL相关的长篇文章。自那以后,他教授给我更多他所能认识到的关于编辑和出版的技能,还鼓励我通过在杂志上开设月度专栏在这条路上继续走下去。谢谢你,Adam。\n我要感谢Monty和David,感谢你们与这个世界分享了MySQL。说到MySQL AB,也要感谢在那里的其他“牛”人,是他们鼓励我写成本书:Kerry,Larry,Joe,Marten,Brian,Paul,Jeremy,Mark,Harrison,Matt,以及团队中的其他人。他们真的非常棒。\n最后,我要感谢我博客的读者,是他们鼓励我撰写基于日常工作的非正式的MySQL及其他技术文章。最后同样重要的是,感谢Goon Squad。\n来自Derek # 就像Jeremy一样,有太多同样的原因,我也要感谢我的家庭。我要感谢我的父母,是他们不停地鼓励我去写一本书,哪怕他们头脑中都没有任何和它相关的东西。我的祖父母给我上了两堂很有价值的课:美元的含义,以及我跟电脑相爱有多深,他们还借钱给我去购买了我平生第一台电脑:Commodore VIC-20。\n我万分感谢Jeremy邀请我加入他那旋风般的写作“过山车”中来。这是一个很棒的体验,我希望将来还能跟他一起工作。\n我要特别感谢Raymond De Roo,Brian Wohlgemuth,David Calafrancesco,Tera Doty,Jay Rubin,Bill Catlan,Anthony Howe,Mark O\u0026rsquo;Neal,George Montgomery,George Barber,以及其他无数耐心听我抱怨的人,我从他们那里了解到我所讲述的内容是否能让门外汉也能理解,或者仅仅得到一个我所希望的笑脸。没有他们,这本书可能也能写出来,但我几乎可以肯定我会在这个过程中疯掉。\n————————————————————\n(1) 为了避免产生疑惑,如果我们指的是内核的时候用的是Linux,如果指的是支持应用的整个操作系统环境的时候用的是GNU/Linux。\n(2) 可以从 http://unxutils.sourceforge.net或者 http://gnuwin32.sourceforge.net获得Unix工具的Windows兼容版本。\n(3) 在 http://planet.mysql.com网站上可以找到很多优秀的技术博客。\n"},{"id":138,"href":"/zh/docs/technology/MySQL/_%E9%AB%98%E6%80%A7%E8%83%BDMySQL_/%E7%AC%AC9%E7%AB%A0%E6%93%8D%E4%BD%9C%E7%B3%BB%E7%BB%9F%E5%92%8C%E7%A1%AC%E4%BB%B6%E4%BC%98%E5%8C%96/","title":"第9章操作系统和硬件优化","section":"高性能 My SQL","content":"第9章 操作系统和硬件优化\nMySQL服务器性能受制于整个系统最薄弱的环节,承载它的操作系统和硬件往往是限制因素。磁盘大小、可用内存和CPU资源、网络,以及所有连接它们的组件,都会限制系统的最终容量。因此,需要小心地选择硬件,并对硬件和操作系统进行合适的配置。例如,若工作负载是I/O密集型的,一种方法是设计应用程序使得最大限度地减少MySQL的I/O操作。然而,更聪明的方式通常是升级I/O子系统,安装更多的内存,或重新配置现有的磁盘。\n硬件的更新换代非常迅速,所以本章有关特定产品或组件的内容可能将很快变得过时。像往常一样,我们的目标是帮助提升对这些概念的理解,这样对于即使没有直接覆盖到的知识也可以举一反三。这里我们将通过现有的硬件来阐明我们的观点。\n9.1 什么限制了MySQL的性能 # 许多不同的硬件都可以影响MySQL的性能,但我们认为最常见的两个瓶颈是CPU和I/O资源。当数据可以放在内存中或者可以从磁盘中以足够快的速度读取时,CPU可能出现瓶颈。把大量的数据集完全放到大容量的内存中,以现在的硬件条件完全是可行的(1)。\n另一方面,I/O瓶颈,一般发生在工作所需的数据远远超过有效内存容量的时候。如果应用程序是分布在网络上的,或者如果有大量的查询和低延迟的要求,瓶颈可能转移到网络上,而不再是磁盘I/O(2)。\n第3章中提及的技巧可以帮助找到系统的限制因素,但即使你认为已经找到了瓶颈,也应该透过表象去看更深层次的问题。某一方面的缺陷常常会将压力施加在另一个子系统,导致这个子系统出问题。例如,若没有足够的内存,MySQL可能必须刷出缓存来腾出空间给需要的数据——然后,过了一小会,再读回刚刚刷新的数据(读取和写入操作都可能发生这个问题)。本来是内存不足,却导致出现了I/O容量不足。当找到一个限制系统性能的因素时,应该问问自己,“是这个部分本身的问题,还是系统中其他不合理的压力转移到这里所导致的?”在第3章的诊断案例中也有讨论到这个问题。\n还有另外一个例子:内存总线的瓶颈也可能表现为CPU问题。事实上,我们说一个应用程序有“CPU瓶颈”或者是“CPU密集型”,真正的意思应该是计算的瓶颈。接下来将深入探讨这个问题。\n9.2 如何为MySQL选择CPU # 在升级当前硬件或购买新的硬件时,应该考虑下工作负载是不是CPU密集型。\n可以通过检查CPU利用率来判断是否是CPU密集型的工作负载,但是仅看CPU整体的负载是不合理的,还需要看看CPU使用率和大多数重要的查询的I/O之间的平衡,并注意CPU负载是否分配均匀。本章稍后讨论的工具可以用来弄清楚是什么限制了服务器的性能。\n9.2.1 哪个更好:更快的CPU还是更多的CPU # 当遇到CPU密集型的工作时,MySQL通常可以从更快的CPU中获益(相对更多的CPU)。\n但这不是绝对的,因为还依赖于负载情况和CPU数量。更古老的MySQL版本在多CPU上有扩展性问题,即使新版本也不能对单个查询并发利用多个CPU。因此,CPU速度限制了每个CPU密集型查询的响应时间。\n当我们讨论CPU的时候,为保证本文易于阅读,对某些术语将不会做严格的定义。现在一般的服务器通常都有多个插槽(Socket),每个插槽上都可以插一个有多个核心的CPU(有独立的执行单元),并且每个核心可能有多个“硬件线程”。这些复杂的架构需要有点耐心去了解,并且我们不会总是明确地区分它们。不过,在一般情况下,当谈到CPU速度的时候,谈论的其实是执行单元的速度,当提到的CPU数量时,指的通常是在操作系统上看到的数量,尽管这可能是独立的执行单元数量的多倍(3)。\n这几年CPU在各个方面都有了很大的提升。例如,今天的Intel CPU速度远远超过前几代,这得益于像直接内存连接(directly attached memory)技术以及PCIe卡之类的设备互联上的改善等。这些改进对于存储设备尤其有效,例如Fusion-io和Virident的PCIe闪存驱动器。\n超线程的效果相比以前也要好得多,现在操作系统也更了解如何更好地使用超线程。而以前版本的操作系统无法识别两个虚拟处理器实际上是在同一芯片上,认为它们是独立的,于是会把任务安排在两个实际上是相同物理执行单元上的虚拟处理器。实际上单个执行单元并不是真的可以在同一时间运行两个进程,所以这样做会发生冲突和争夺资源。而同时其他CPU却可能在闲置,从而浪费资源。操作系统需要能感知超线程,因为它必须知道什么时候执行单元实际上是闲置的,然后切换相应的任务去执行。这个问题之前常见的原因是在等待内存总线,可能花费需要高达一百个CPU周期,这已经类似于一个轻量级的I/O等待。新的操作系统在这方面有了很大的改善。超线程现在已经工作得很好。过去,我们时常提醒人们禁用它,但现在已经不需要这样做了。\n这就是说,现在可以得到大量的快速的CPU——比本书的第2版出版的时候要多得多。所以多和快哪个更重要?一般来说两个都想要。从广义上来说,调优服务器可能有如下两个目标:\n低延时(快速响应)\n要做到这一点,需要高速CPU,因为每个查询只能使用一个CPU。\n高吞吐\n如果能同时运行很多查询语句,则可以从多个CPU处理查询中受益。然而,在实践中,还要取决于具体情况。因为MySQL还不能在多个CPU中完美地扩展,能用多少个CPU还是有极限的。在旧版本的MySQL中(MySQL 5.1以后的版本已经有一些提升),这个限制非常严重。在新的版本中,则可以放心地扩展到16或24个CPU,或者更多,取决于使用的是哪个版本(Percona往往在这方面略占优势)。\n如果有多路CPU,并且没有并发执行查询语句,MySQL依然可以利用额外的CPU为后台任务(例如清理InnoDB缓冲、网络操作,等等)服务。然而,这些任务通常比执行查询语句更加轻量化。\nMySQL复制(将在下一章中讨论)也能在高速CPU下工作得非常好,而多CPU对复制的帮助却不大。如果工作负载是CPU密集型,主库上的并发任务传递到备库以后会被简化为串行任务,这样即使备库硬件比主库好,也可能无法保持跟主库之间的同步。也就是说,备库的瓶颈通常是I/O子系统,而不是CPU。\n如果有一个CPU密集型的工作负载,考虑是需要更快的CPU还是更多CPU的另外一个因素是查询语句实际在做什么。在硬件层面,一个查询可以在执行或等待。处于等待状态常见的原因是在运行队列中等待(进程已经是可运行状态,但所有的CPU都忙)、等待闩锁(Latch)或锁(Lock)、等待磁盘或网络。那么你期望查询是等待什么呢?如果等待闩锁或锁,通常需要更快的CPU;如果在运行队列中等待,那么更多或者更快的CPU都可能有帮助。(也可能有例外,例如,查询等待InnoDB日志缓冲区的Mutex,直到I/O完成前都不会释放——这可能表明需要更多的I/O容量)。\n这就是说,MySQL在某些工作负载下可以有效地利用很多CPU。例如,假设有很多连接查询的是不同表(假设这些查询不会造成表锁的竞争,实际上对MyISAM和MEMORY表可能会有问题),并且服务器的总吞吐量比任何单个查询的响应时间都更重要。吞吐量在这种情况下可以非常高,因为线程可以同时运行而互不争用。\n再次说明,在理论上这可能更好地工作:不管查询是读取不同的表还是相同的表, InnoDB都会有一些全局共享的数据结构,而MyISAM在每个缓冲区都有全局锁。而且不仅仅是存储引擎,服务器层也有全局锁。以前InnoDB承担了所有的骂名,但最近做了一些改进后,暴露了服务器层中的其他瓶颈。例如臭名昭著的LOCK_open互斥量(Mutex),在MySQL 5.1和更早版本中可能就是个大问题,另外还有其他一些服务器级别的互斥量(例如查询缓存)。\n通常可以通过堆栈跟踪来诊断这些类型的竞争问题,例如Percona Toolkit中的pt-pmp工具。如果遇到这样的问题,可能需要改变服务器的配置,禁用或改变引起问题的组件,进行数据分片(Sharding),或者通过某种方式改变做事的方法。这里无法列举所有的问题和相应的解决方案,但是一旦有一个确定的诊断,答案通常是显而易见的。大部分不幸遇到的问题都是边缘场景,最常见的问题随着时间的推移都在服务器上被修复了。\n9.2.2 CPU架构 # 可能99%以上的MySQL实例(不含嵌入式使用)都运行在Intel或者AMD芯片的x86架构下。本书中我们基本都是针对这种情况。\n64位架构现在都是默认的了,32位CPU已经很难买到了。MySQL在64位架构上工作良好,尽管有些事暂时不能利用64位架构来做。因此,如果使用的是较老旧版本的MySQL,在64位服务器上可能要小心。例如,在MySQL 5.0发布的早期时候,每个MyISAM键缓冲区被限制为4 GB,由一个32位整数负责寻址。(可以创建多个键缓冲区来解决这个问题。)\n确保在64位硬件上使用64位操作系统!最近这种情况已经不太常见了,但以前经常可以遇到,大多数主机托管提供商暂时还是在服务器上安装32位操作系统,即使是64位CPU。32位操作系统意味着不能使用大量的内存:尽管某些32位系统可以支持大量的内存,但不能像64位系统一样有效地利用,并且在32位系统上,任何一个单独的进程都不能寻址4 GB以上的内存。\n9.2.3 扩展到多个CPU和核心 # 多CPU在联机事务处理(OLTP)系统的场景中非常有用。这些系统通常执行许多小的操作,并且是从多个连接发起请求,因此可以在多个CPU上运行。在这样的环境中,并发可能成为瓶颈。大多数Web应用程序都属于这一类。\nOLTP服务器一般使用InnoDB,尽管它在多CPU的环境中还存在一些未解决的并发问题。然而,不只是InnoDB可能成为瓶颈:任何共享资源都是潜在的竞争点。InnoDB之所以获得大量关注是因为它是高并发环境下最常见的存储引擎,但MyISAM在大压力时的表现也不好,即使不修改任何数据只是读取数据也是如此。许多并发瓶颈,如InnoDB的行级锁和MyISAM的表锁,没有办法优化——除了尽可能快地处理任务之外,没有别的办法解决,这样,锁就可以尽快分配给等待的任务。如果一个锁是造成它们(其他任务)都在等待的原因,那么不管有多少CPU都一样。因此,即使是一些高并发工作负载,也可以从更快的CPU中受益。\n实际上有两种类型的数据库并发问题,需要不同的方法来解决,如下所示。\n逻辑并发问题\n应用程序可以看到资源的竞争,如表或行锁争用。这些问题通常需要好的策略来解决,如改变应用程序、使用不同的存储引擎、改变服务器的配置,或使用不同的锁定提示或事务隔离级别。\n内部并发问题\n比如信号量、访问InnoDB缓冲池页面的资源争用,等等。可以尝试通过改变服务器的设置、改变操作系统,或使用不同的硬件解决这些问题,但通常只能缓解而无法彻底消灭。在某些情况下,使用不同的存储引擎或给存储引擎打补丁,可以帮助缓解这些问题。\nMySQL的“扩展模式”是指它可以有效利用的CPU数量,以及在压力不断增长的情况下如何扩展,这同时取决于工作负载和系统架构。通过“系统架构”的手段是指通过调整操作系统和硬件,而不是通过优化使用MySQL的应用程序。CPU架构(RISC、CISC、流水线深度等)、CPU型号和操作系统都影响MySQL的扩展模式。这也是为什么说基准测试是非常重要的:一些系统可以在不断增加的并发下依然运行得很好,而另一些的表现则糟糕得多。\n有些系统在更多的处理器下甚至可能降低整体性能。这是相当普遍的情况,我们了解到许多人试图升级到有多个CPU的系统,最后只能被迫恢复到旧系统(或绑定MySQL进程到其中某些核心),因为这种升级反而降低了性能。在MySQL 5.0时代,Google的补丁和Percona Server出现之前,能有效利用的CPU核数是4核,但是现在甚至可以看到操作系统报告多达80个“CPU”的服务器。如果规划一个大的升级,必须要同时考虑硬件、服务器版本和工作负载。\n某些MySQL扩展性瓶颈在服务器层,而其他一些在存储引擎层。存储引擎是怎么设计的至关重要,有时更换到一个不同的引擎就可以从多处理器上获得更多效果。\n我们看到在世纪之交围绕处理器速度的战争在一定程度上已经平息,CPU厂商更多地专注于多核CPU和多线程的变化。CPU设计的未来很可能是数百个处理器核心,四核心和六核心的CPU在今天是很常见的。不同厂商的内部架构差异很大,不可能概括出线程、CPU和内核之间的相互作用。内存和总线如何设计也是非常重要的。归根结底,多个内核和多个物理CPU哪个更好,这是由硬件体系结构决定的。\n现代CPU的另外两个复杂之处也值得提一下。首先是频率调整。这是一种电源管理技术,可以根据CPU上的压力而动态地改变CPU的时钟速度。问题是,它有时不能很好地处理间歇性突发的短查询的情况,因为操作系统可能需要一段时间来决定CPU的时钟是否应该变化。结果,查询可能会有一段时间速度较慢,并且响应时间增加了。频率调整可能使间歇性的工作负载性能低下,但可能更重要的是,它会导致性能波动。\n第二个复杂之处是boost技术,这个技术改变了我们对CPU模式的看法。我们曾经以为四核2GHz CPU有四个同样强大的核心,不管其中有些是闲置或非闲置。因此,一个完美的可扩展系统,当它使用所有四个内核的时候,可以预计得到四倍的提升。但是现在已经不是这样了,因为当系统只使用一个核心时,处理器会运行在更高的时钟速度上,例如3GHz。这给很多的规划容量和可扩展性建模的工具出了一个难题,因为系统性能表现不再是线性的变化了。这也意味着,“空闲CPU”并不代表相同规模的资源浪费,如果有一台服务器上只运行了备库的复制,而复制执行是单线程的,所以有三个CPU是空闲的,因此认为可以利用这些CPU资源执行其他任务而不影响复制,可能就想错了。\n9.3 平衡内存和磁盘资源 # 配置大量内存最大的原因其实不是因为可以在内存中保存大量数据:最终目的是避免磁盘I/O,因为磁盘I/O比在内存中访问数据要慢得多。关键是要平衡内存和磁盘的大小、速度、成本和其他因素,以便为工作负载提供高性能的表现。在讨论如何做到这一点之前,暂时先回到基础知识上来。\n计算机包含一个金字塔型的缓存体系,更小、更快、更昂贵的缓存在顶端,如图9-1所示。\n图9-1:缓存层级\n在这个高速缓存层次中,最好是利用各级缓存来存放“热点”数据,以获得更快的访问速度,通常使用一些启发式的方法,例如“最近被使用的数据可能很快再次被使用”以及“相邻的数据可能很快需要使用”,这些算法非常有效,因为它们参考了空间和时间的局部性原理。\n从程序员的视角来看,CPU寄存器和高速缓存是透明的,并且与硬件架构相关。管理它们是编译器和CPU的工作。然而,程序员会有意识地注意到内存和硬盘的不同,并且在程序中通常区分使用它们(4)。\n在数据库服务器上尤其明显,其行为往往非常符合我们刚才提到的预测算法所做的预测。设计良好的数据库缓存(如InnoDB缓冲池),其效率通常超过操作系统的缓存,因为操作系统缓存是为通用任务设计的。数据库缓存更了解数据库存取数据的需求,它包含特殊用途的逻辑(例如写入顺序)以帮助满足这些需求。此外,系统调用不需要访问数据库中的缓存数据。\n这些专用的缓存需求就是为什么必须平衡缓存层次结构以适应数据库服务器特定的访问模式的原因。因为寄存器和芯片上的高速缓存不是用户可配置的,内存和存储是唯一可以改变的东西。\n9.3.1 随机I/O和顺序I/O # 数据库服务器同时使用顺序和随机I/O,随机I/O从缓存中受益最多。想像有一个典型的混合工作负载,均衡地包含单行查找与多行范围扫描,可以说服自己相信这个说法。典型的情况是“热点”数据随机分布。因此,缓存这些数据将有助于避免昂贵的磁盘寻道。相反,顺序读取一般只需要扫描一次数据,所以缓存对它是没用的,除非能完全放在内存中缓存起来。\n顺序读取不能从缓存中受益的另一个原因是它们比随机读快。这有以下两个原因:\n顺序I/O比随机I/O快。\n顺序操作的执行速度比随机操作快,无论是在内存还是磁盘上。假设磁盘每秒可以做100个随机I/O操作,并且可以完成每秒50MB的顺序读取(这大概是消费级磁盘现在能达到的水平)。如果每行100字节,随机读每秒可以读100行,相比之下顺序读可以每秒读500000行——是随机读的5000倍,或几个数量级的差异。因此,在这种情况下随机I/O可以从缓存中获得很多好处。\n顺序访问内存行的速度也快于随机访问。现在的内存芯片通常每秒可以随机访问约250000次100字节的行,或者每秒500万次的顺序访问。请注意,内存随机访问速度比磁盘随机访问快了2 500倍,而内存中顺序访问只有磁盘10倍的速度。\n存储引擎执行顺序读比随机读快。\n一个随机读一般意味着存储引擎必须执行索引操作。(这个规则也有例外,但对InnoDB和MyISAM都是对的)。通常需要通过B树的数据结构查找,并且和其他值比较。相反,连续读取一般需要遍历一个简单的数据结构,例如链表。这样就少了很多工作,反复这样操作,连续读取的速度就比随机读取要快了。\n最后,随机读取通常只要查找特定的行,但不仅仅只读取一行——而是要读取一整页的数据,其中大部分是不需要的。这浪费了很多工作。另一方面,顺序读取数据,通常发生在想要的页面上的所有行,所以更符合成本效益。\n综上所述,通过缓存顺序读取可以节省一些工作,但缓存随机读取可以节省更多的工作。换句话说,如果能负担得起,增加内存是解决随机I/O读取问题最好的办法。\n9.3.2 缓存,读和写 # 如果有足够的内存,就完全可以避免磁盘读取请求。如果所有的数据文件都可以放在内存中,一旦服务器缓存“热”起来了,所有的读操作都会在缓存命中。虽然还是会有逻辑读取,不过物理读取就没有了。但写入是不同的问题。写入可以像读一样在内存中完成,但迟早要被写入到磁盘,所以它是需要持久化的。换句话说,缓存可延缓写入,但不能像消除读取一样消除写入。\n事实上,除了允许写入被延迟,缓存可以允许它们被集中操作,主要通以下两个重要途径:\n多次写入,一次刷新\n一片数据可以在内存中改变很多次,而不需要把所有的新值写到磁盘。当数据最终被刷新到磁盘后,最后一次物理写之前发生的修改都被持久化了。例如,许多语句可以更新内存中的计数器。如果计数器递增100次,然后写入到磁盘,100次修改就被合并为一次写。\nI/O合并\n许多不同部分的数据可以在内存中修改,并且这些修改可以合并在一起,通过一次磁盘操作完成物理写入。\n这就是为什么许多交易系统使用预写日志(WAL)策略。预写日志采用在内存中变更页面,而不马上刷新到磁盘上的策略,因为刷新磁盘通常需要随机I/O,这非常慢。相反,如果把变化的记录写到一个连续的日志文件,这就很快了。后台线程可以稍后把修改的页面刷新到磁盘;并在刷新过程中优化写操作。\n写入从缓冲中大大受益,因为它把随机I/O更多地转换到连续I/O。异步(缓冲)写通常是由操作系统批量处理,使它们能以更优化的方式刷新到磁盘。同步(无缓冲)写必须在写入到磁盘之后才能完成。这就是为什么它们受益于RAID控制器中电池供电的回写(Write-Back)高速缓存(我们稍后讨论RAID)。\n9.3.3 工作集是什么 # 每个应用程序都有一个数据的“工作集”——就是做这个工作确实需要用到的数据。很多数据库都有大量不在工作集内的数据。\n可以把数据库想象为有抽屉的办公桌。工作集就是放在桌面上的完成工作必须使用的文件。桌面是这个比喻中的主内存,而抽屉就是硬盘。\n就像完成工作不需要办公桌里每一张纸一样,也不需要把整个数据库装到内存中来获得最佳性能——只需要工作集就可以。\n工作集大小的不同取决于应用程序。对于某些应用程序,工作集可能是总数据大小的1%,而对于其他应用,也可能接近100%。当工作集不能全放在内存中时,数据库服务器必须在磁盘和内存之间交换数据,以完成工作。这就是为什么内存不足可能看起来却像I/O问题。有时没有办法把整个工作集的数据放在内存中,并且有时也并不真的想这么做(例如,若应用需要大量的顺序I/O)。工作集能否完全放在内存中,对应用程序体系结构的设计会产生很大的影响。\n工作集可以定义为基于时间的百分比。例如,一小时的工作集可能是一个小时内数据库使用的95%的页面,除了5%的最不常用的页面。百分比是考虑这个问题最有用的方式,因为每小时可能需要访问的数据只有1%,但超过24小时,需要访问的数据可能会增加到整个数据库中20%的不同页面。根据需要被缓存起来的数据量多少,来思考工作集会更加直观,缓存的数据越多,工作负载就越可能成为CPU密集型。如果不能缓存足够的数据,工作集就不能完全放在内存中。\n应该依据最常用的页面集来考虑工作集,而不是最频繁读写的页面集。这意味着,确定工作集需要在应用程序内有测量的模块,而不能仅仅看外部资源的利用,例如I/O访问,因为页面的I/O操作跟逻辑访问页面不是同一回事。例如,MySQL可能把一个页面读入内存,然后访问它数百万次,但如果查看strace,只会看到一个I/O操作。缺乏确定工作集所需的检测模块,最大的原因是没有对这个主题有较多的研究。\n工作集包括数据和索引,所以应该采用缓存单位来计数。一个缓存单位是存储引擎工作的数据最小单位。\n不同存储引擎的缓存单位大小是不一样的,因此也使得工作集的大小不一样。例如, InnoDB在默认情况下是16 KB的页。如果InnoDB做一个单行查找需要读取磁盘,就需要把包含该行的整个页面读入缓冲池进行缓存,这会引起一些缓存的浪费。假设要随机访问100字节的行。InnoDB将用掉缓冲池中很多额外的内存来缓存这些行,因为每一行都必须读取和缓存一个完整的16KB页面。因为工作集也包括索引,InnoDB也会读取并缓存查找行所需的索引树的一部分。InnoDB的索引页大小也是16 KB,这意味着访问一个100字节的行可能一共要使用32 KB的缓存空间(有可能更多,这取决于索引树有多深)。因此,缓存单位也是在InnoDB中精心挑选聚集索引非常重要的另一个原因。聚集索引不仅可以优化磁盘访问,还可以帮助在同一页面存储相关的数据,因此在缓存中可以尽量放下整个工作集。\n9.3.4 找到有效的内存/磁盘比例 # 找到一个良好的内存/磁盘比例最好的方式是通过试验和基准测试。如果可以把所有东西放入内存,你就大功告成了——后面没有必要再为此考虑什么。但大多数的时候不可能这么做,所以需要用数据的一个子集来做基准测试,看看将会发生什么。测试的目标是一个可接受的缓存命中率。缓存未命中是当有查询请求数据时,数据不能在内存中命中,服务器需要从磁盘获取数据。\n缓存命中率实际上也会决定使用了多少CPU,所以评估缓存命中率的最好方法是查看CPU使用率。例如,若CPU使用了99%的时间工作,用了1%的时间等待I/O,那缓存命中率还是不错的。\n让我们考虑下工作集是如何影响高速缓存命中率的。首先重要的一点,要认识到工作集不仅是一个单一的数字而是一个统计分布,并且缓存命中率是非线性分布的。例如,有10GB内存,并且缓存未命中率为10%,你可能会认为只需要增加11%以上的内存(5),就可以降低缓存的未命中率到0。但实际上,诸如缓存单位的大小之类的问题会导致缓存效率低下,可能意味着理论上需要50GB的内存,才能把未命中率降到1%。即使与一个完美的缓存单位相匹配,理论预测也可能是错误的:例如数据访问模式的因素也可能让事情更复杂。解决1%的缓存未命中率甚至可能需要500GB的内存,这取决于具体的工作负载!\n有时候很容易去优化一些可能不会带来多少好处的地方。例如,10%的未命中率可能导致80%的CPU使用率,这已经是相当不错的了。假设增加内存,并能够让缓存未命中率下降到5%,简单来说,将提供另外约6%的数据给CPU。再简化一下,也可以说,把CPU使用率增加到了84.8%。然而,考虑到为了得到这个结果需要购买的内存,这可不一定是一个大胜利。在现实中,因为内存和磁盘访问速度之间的差异、CPU真正操作的数据,以及许多其他因素,降低缓存未命中率到5%可能都不会太多改变CPU使用率。\n这就是为什么我们说,你应该争取一个可接受的缓存命中率,而不是将缓存未命中率降低到零。没有一个应该作为目标的数字,因为“可以接受”怎么定义,取决于应用程序和工作负载。有些应用程序有1%的缓存未命中都可以工作得非常好,而另一些应用实际上需要这个比例低到0.01%才能良好运转。(“良好的缓存未命中率”是个模糊的概念,其实有很多方法来进一步计算未命中率。)\n最好的内存/磁盘的比例还取决于系统上的其他组件。假设有16 GB的内存、20 GB的数据,以及大量未使用的磁盘空间系统。该系统在80%的CPU利用率下运行得很好。如果想在这个系统上放置两倍多的数据,并保持相同的性能水平,你可能会认为只需要让CPU数量和内存量也增加到两倍。然而,即使系统中的每个组件都按照增加的负载扩展相同的量(一个不切实际的假设),这依然可能会使得系统无法正常工作。有20GB数据的系统可能使用了某些组件超过50%的容量——例如,它可能已经用掉了每秒I/O最大操作数的80%。并且在系统内排队也是非线性的。服务器将无法处理两倍的负载。因此,最好的内存/磁盘比例取决于系统中最薄弱的组件。\n9.3.5 选择硬盘 # 如果无法满足让足够的数据在内存中的目标——例如,估计将需要500 GB的内存才能完全让CPU负载起当前的I/O系统——那么应该考虑一个更强大的I/O子系统,有时甚至要以牺牲内存为代价,同时应用程序的设计应该能处理I/O等待。\n这听起来似乎有悖常理。毕竟,我们刚刚说过,更多的内存可以缓解I/O子系统的压力,并减少I/O等待。为什么要加强I/O子系统呢,如果只增加内存能解决问题吗?答案就在所涉及的因素之间的平衡,例如读写之间的平衡,每个I/O操作的大小,以及每秒有多少这样的操作发生。例如,若需要快速写日志,就不能通过增加大量有效内存来避免磁盘写入。在这种情况下,投资一个高性能的I/O系统与带电池支持的写缓存或固态存储,可能是个更好的主意。\n作为一个简要回顾,从传统磁盘读取数据的过程分为三个步骤:\n移动读取磁头到磁盘表面上的正确位置。 等待磁盘旋转,所有所需的数据在读取磁头下。 等待磁盘旋转过去,所有所需的数据都被读取磁头读出。 磁盘执行这些操作有多快,可以浓缩为两个数字:访问时间(步骤1和2合并)和传输速度。这两个数字也决定延迟和吞吐量。不管是需要快速访问时间还是快速的传输速度——或混合两者——依赖于正在运行的查询语句的种类。从完成一次磁盘读取所需要的总时间来说,小的随机查找以步骤1和2为主,而大的顺序读主要是第3步。\n其他一些因素也可以影响磁盘的选择,哪个重要取决于应用。假设正在为一个在线应用选择磁盘,例如一个受欢迎的新闻网站,有大量小的磁盘随机读取。可能需要考虑下列因素:\n存储容量\n对在线应用来说容量很少成为问题,因为现在的磁盘通常足够大了。如果不够,用RAID把小磁盘组合起来是标准做法(6)。\n传输速度\n现代磁盘通常数据传输速度非常快,正如我们前面看到的。究竟多快主要取决于主轴转速和数据存储在磁盘表面上的密度,再加上主机系统的接口的限制(许多现代磁盘读取数据的速度比接口可以传输的快)。无论如何,传输速度通常不是在线应用的限制因素,因为它们一般会做很多小的随机查找。\n访问时间\n对随机查找的速度而言,这通常是个主要因素,所以应该寻找更快的访问时间的磁盘。\n主轴转速\n现在常见的转速是7 200RPM、10000RPM,以及15000RPM。转速不管对随机查找还是顺序扫描都有很大影响。\n物理尺寸\n所有其他条件都相同的情况下,磁盘的物理尺寸也会带来差别:越小的磁盘,移动读取磁头需要的时间就越短。服务器级的2.5英寸磁盘性能往往比它们的更大的盘更快。它们还可以节省电力,并且通常可以融入机箱中。\n和CPU一样,MySQL如何扩展到多个磁盘上取决于存储引擎和工作负载。InnoDB能很好地扩展到多个硬盘驱动器。然而,MyISAM的表锁限制其写的可扩展性,因此写繁重的工作加在MyISAM上,可能无法从多个驱动器中收益。虽然操作系统的文件系统缓冲和后台并发写入会有点帮助,但MyISAM相对于InnoDB在写可扩展性上有更多的限制。\n和CPU一样,更多的磁盘也并不总是更好。有些应用要求低延迟需要的是更快的驱动器,而不是更多的驱动器。例如,复制通常在更快的驱动器上表现更好,因为备库的更新是单线程的。(7)\n9.4 固态存储 # 固态(闪存)存储器实际上是有30年历史的技术,但是它作为新一代驱动器而成为热门则是最近几年的事。固态存储现在越来越便宜,并且也更成熟了,它正在被广泛使用,并且可能会在不久的将来在多种用途上代替传统磁盘。\n固态存储设备采用非易失性闪存芯片而不是磁性盘片组成。它们也被称为NVRAM,或非易失性随机存取存储器。固态存储设备没有移动部件,这使得它们表现得跟硬盘驱动器有很大的不同。我们将详细探讨其差异。\n目前MySQL用户感兴趣的技术可分为两大类:SSD(固态硬盘)和PCIe卡。SSD通过实现SATA(串行高级技术附件)接口来模拟标准硬盘,所以可以替代硬盘驱动器,直接插入服务器机箱中的现有插槽。PCIe卡使用特殊的操作系统驱动程序,把存储设备作为一个块设备输出。PCIe和SSD设备有时可以简单地都认为是SSD。\n下面是闪存性能的快速小结。高质量闪存设备具备:\n相比硬盘有更好的随机读写性能。闪存设备通常读明显比写要快。 相比硬盘有更好的顺序读写性能。但是相比而言不如随机I/O的改善那么大,因为硬盘随机I/O比顺序I/O要慢得多。入门级固态硬盘的顺序读取实际上还可能比传统硬盘慢。 相比硬盘能更好地支持并发。闪存设备可以支持更多的并发操作,事实上,只有大量的并发请求才能真正实现最大吞吐量。 最重要的事情是提升随机I/O和并发性。闪存记忆体可以在高并发下提供很好的随机I/O性能,这正是范式化的数据库所需要的。设计非范式化的Schema最常见的原因之一是为了避免随机I/O,并且使得查询可能转化为顺序I/O。\n因此,我们相信固态存储未来将从根本上改变RDBMS技术。当前这一代的RDBMS技术几十年来都是为机械磁盘做优化的。同样成熟和深入的研究工作在固态存储上还没有真正出现(8)。\n9.4.1 闪存概述 # 硬盘驱动器使用旋转盘片和可移动磁头,其物理结构决定了磁盘固有的局限性和特征。对固态存储也是一样,它是构建在闪存之上的。不要以为固态存储很简单,实际上比硬盘驱动器在某些方面更复杂。闪存的限制实际上是相当严重的,并且难以克服,所以典型的固态设备都有错综复杂的架构、缓存,以及独有的“法宝”。\n闪存的最重要的特征是可以迅速完成多次小单位读取,但是写入更有挑战性。闪存不能在没有做擦除操作前改写一个单元(Cell)(9),并且一次必须擦除一个大块——例如,512 KB。擦除周期是缓慢的,并且最终会磨损整个块。一个块可以容忍的擦除周期次数取决于所使用的底层技术,有关这些内容我们稍后再讲。\n写入的限制是固态存储复杂的原因。这也是为什么一些设备供应商在设备的稳定、性能的一致性等方面和其他供应商有区别的原因。“魔法”全部都在其专有的固件、驱动程序,以及其他零零碎碎的东西里,这些东西使得固态设备良好运转。为了使写入表现良好,并避免闪存块过早损耗完寿命,设备必须能够搬迁页面并执行垃圾收集和所谓的磨损均衡。写放大用于描述数据从一个地方移动到另一个地方的额外写操作,多次写数据和元数据导致局部块经常写。如果你有兴趣,维基百科中的写放大的文章,是个学习的好地方,可以从其中了解更多关于闪存的知识。\n垃圾收集对理解闪存很重要。为了保持一些块是干净的并且可以被写入,设备需要回收脏块。这需要设备上有一些空闲空间。无论是设备内部有一些看不到的预留空间,或者通过不写那么多数据来预留需要的空间——不同的设备可能有所不同。无论哪种方式,设备填满了,垃圾收集就必须更加努力地工作,以保持一些块是干净的,所以写放大的倍数就增加了。\n因此,许多设备在被填满后会开始变慢。到底会慢多少,不同的制造商和型号之间有所不同,依赖于设备的架构。有些设备为高性能而设计,即使写得非常满,依然可以保持高性能。但是,通常一个100GB的文件在160GB和320GB的SSD上表现完全不同。速度下降是由于没有空闲块时必须等待擦写完成所造成的。写到一个空闲块只需要花费数百微秒,但是擦写慢得多——通常需要几个毫秒。\n9.4.2 闪存技术 # 有两种主要的闪存设备类型,当考虑购买闪存存储时,理解两者之间的不同是很重要的。这两种类型分别是单层单元(SLC)和多层单元(MLC)。\nSLC的每个单元存储数据的一个比特:可以是0或1。SLC相对更昂贵,但非常快,并且擦写寿命高达100000个写周期,具体值取决于供应商和型号。这听起来好像不多,但在现实中一个好的SLC设备应该持续使用大约20年左右,甚至比卡上安装的控制器更耐用和可靠。缺点则是存储密度相对较低,所以不能在每个设备上得到那么多空间。MLC每个单元存储2个比特、3个比特的设备也正在进入市场。这使得通过MLC设备获得更高的存储密度(更大的容量)成为可能。成本更低了,但是速度和耐擦写性也下降了。一个不错的MLC设备可能被定为10000个写循环周期。\n可以在大众市场上购买到这两种类型的闪存设备,它们之间的竞争有助于闪存的发展。目前,SLC仍持有“企业”级服务器的存储解决方案的声誉,通常被视为消费级的MLC设备,一般使用在笔记本电脑和数码相机等地方。然而,这种情况正在改变,出现了一种新兴的所谓企业级MLC(eMLC)存储。\nMLC技术的发展是很有意思的,如果正在考虑购买闪存存储,这个发展方向值得密切关注。MLC非常复杂,包含很多有助于设备质量和性能的重要因素。任何给定的芯片仅靠自身是不能持久化的,因为有着相对较短的信号保持周期,以及较高的错误率必须纠正。随着市场转移到更小、密度更高的芯片,其中的芯片单元可以存储3比特,单个芯片变得更不可靠以及更容易出错。\n然而,这并不是一个不可逾越的工程问题。厂商正在制造一些有越来越多隐藏容量的设备,因此有足够的内部冗余。尽管闪存厂商非常注意保护自己的商业秘密,还是有传言称,某些设备可能有比它标称大小多出高达两倍的存储空间。使MLC芯片更耐用的另一种方法是通过固件逻辑。平衡磨损和重映射的算法是非常重要的。\n寿命的长短取决于真实的容量,固件逻辑等——所以最终是因供应商而异的。我们听说过在几个星期里密集使用导致设备报废的报告!\n因此,MLC设备最关键的环节是内置的算法和智能。制造一个好的MLC设备比制造一个SLC设备难得多,但也是可能的。随着工程学的伟大进步,以及容量和密度的增加,一些最好的供应商提供的设备,是值得用eMLC这个标签的。这个领域随着时间的推移进步得很快,本书对MLC与SLC的意见可能很快会变得过时。\n设备的寿命还剩多久?\nVirident担保其FlashMax 1.4 TB MLC设备可以持续写入15 PB数据,但这是在闪存级别的数据,用户可见的写入是会放大的。我们跑了一个小实验来发现特定的工作负载下的写入放大因子。\n我们创建了一个500GB的数据集,然后在上面运行tpcc-mysql基准测试,跑了一个小时。在这个小时里,/proc/diskstats报告了984GB的写入,然后Virident配置工具显示在闪存层有1 125GB的写入,因此写入放大因子是1.14。记住,如果设备上消耗了更多空间,这个值会更高,并且这个值的浮动还基于写入方式是顺序还是随机。\n在这样的比率下,如果不间断地跑一年半的基准测试,就可以用完设备的寿命。当然,真实的工作负载很少是写密集型的,所以这个卡在实际使用中应该可以持续工作很多年。这个观点不是说该设备将很快磨损——它是说,写放大系数是很难预测的,需要检查设备,根据工作量来查看它的行为。\n容量对寿命的影响也很大,正如我们已经提到的。更大容量的设备会使得寿命显著增长,这是为什么MLC越来越流行——最近我们看到足够大的容量可以延长寿命是有理由的。\n9.4.3 闪存的基准测试 # 对闪存设备进行基准测试是复杂并且困难的。有很多情况会导致测试错误,需要了解特定设备的知识,并且需要有极大的耐心和关注,才能正确地操作。\n闪存设备有一个三阶段模式,我们称为A-B-C性能特性。它们开始阶段运行非常快(阶段A),然后垃圾回收器开始工作,这将导致在一段时间内,设备处于过渡到稳定状态(阶段B)的阶段,最后设备进入一个稳定状态(状态C)。所有我们测试过的设备都有这个特点。\n当然,我们感兴趣的是阶段C的性能,所以基准测试只需要测量这个部分的运行过程。这意味着基准测试要做的不仅仅是基准测试:还需要先进行一下预热,然后才能进行基准测试。但是,定义预热的终点和基准测试的起点会非常棘手。\n设备、文件系统,以及操作系统通过不同方式提供TRIM命令的支持,这个命令标记空间准备重用。有时当删除所有文件时设备会被TRIM。如果在基准测试运行的情况下发生,设备将重置到阶段A,然后必须重新执行A和B之间的运行阶段。另一个因素是设备被填充得很满或者不满时,不同的性能表现。一个可重复的基准测试必须覆盖到所有这些因素。\n通过上述分析,可知基准测试的复杂性,所以就算厂商如实地报告测试结果,但对于外行来说,厂商的基准测试和规格说明书依然可能有很多“坑”。\n通常可以从供应商那得到四个数字。这里有一个设备规格的例子:\n设备读取性能最高达520 MB/s。 设备写入性能最高达480 MB/s。 设备持续写入速度可以稳定在420 MB/s。 设备每秒可以执行70000个4 KB的写操作。 如果再次复核这些数字,你会发现峰值4KB写入达到70000个IOPS(每秒输入/输出操作),这么算每秒写入大约只有274 MB/s,这比第二点和第三点中说明的高峰写入带宽少了很多。这是因为达到峰值写入带宽时是用更大的块,例如64 KB或128 KB。用更小的块大小来达到峰值IOPS。\n大部分应用不会写这么大的块。InnoDB写操作通常是16KB或512字节的块组合到一起写回。因此,设备应该只有274MB/s的写出带宽——这是阶段A的情况,在垃圾回收器开启和设备达到长期稳定的性能等级前!\n在我们的博客中,可以找到目前的MySQL基准测试,以及在固态硬盘上裸设备文件I/O的工作负载: http://www.ssdperformanceblog.com和 http://www.mysqlperformanceblog.com。\n9.4.4 固态硬盘驱动器(SSD) # SSD模拟SATA硬盘驱动器。这是一个兼容性功能:替换SATA硬盘不需要任何特殊的驱动程序或接口。\n英特尔的X-25E驱动器可能是我们今天看到在服务器中最常见的固态硬盘,但也有很多其他选择。X-25E是为“企业”级消费市场开发的,但也有用MLC存储的X-25M,这是为笔记本电脑用户等大众市场准备的。此外,英特尔还销售320系列,也有很多人正在使用。再次,这仅仅是一个供应商——还有很多,在这本书去印刷的时候,我们所写的关于SSD的一些东西可能已经过时。\n关于SSD的好处是,它们有大量的品牌和型号相对是比较便宜的,同时它们比硬盘快了很多。最大的缺点是,它们并不总是像硬盘一样可靠,这取决于品牌和型号。直到最近,大多数设备都没有板载电池,但大多数设备上有一个写缓存来缓冲写入。写入缓存在没有电池备份的情况下并不能持久化,但是在快速增长的写负载下,它不能关闭,否则闪存存储无法承受。所以,如果禁用了驱动器的高速缓存以获得真正持久化的存储,将会更快地耗完设备寿命,在某些情况下,这将导致保修失效。\n有些厂家完全不急于告诉购买他们固态硬盘的客户关于SSD的特点,并且他们对设备的内部架构等细节守口如瓶。是否有电池或电容保护写缓存的数据安全,在电源故障的情况下,通常是一个悬而未决的问题。在某些情况下,驱动器会接受禁用缓存的命令,但忽略了它。所以,除非做过崩溃试验,否则真的有可能不知道驱动器是否是持久化的,我们对一些驱动器进行了崩溃测试,发现了不同的结果。如今,一些驱动器有电容器保护缓存,使其可以持久化,但一般来说,如果驱动器不是自夸有一个电池或电容,那么它就没有。这意味着在断电的情况下不是持久化的,所以可能出现数据已经损坏却还不知情的情况。SSD是否配置电容或电池是我们必须关注的特性。\n通常,使用SSD都是值得的。但底层技术的挑战是不容易解决的。很多厂家做出的驱动器在高负载下很快就崩溃了,或不提供持续一致的性能。一些低端的制造商有一个习惯,每次发布新一代驱动器,就声称他们已经解决了老一代的所有问题。这往往是不真实的,当然,如果关心可靠性和持续的高性能,“企业级”的设备通常值得它的价钱。\n用SSD做RAID # 我们建议对SATA SSD盘使用RAID(Redundant Array of Inexpensive Disks,磁盘冗余阵列)。单一驱动器的数据安全是无法让人信服的。\n许多旧的RAID控制器并不支持SSD。因为它们假设管理的是机械硬盘,包括写缓冲和写排序这些特性都是为机械硬盘而设计的。这不但纯属无效工作,也会增加响应时间,因为SSD暴露的逻辑位置会被映射到底层闪存记忆体中的任意位置。现在这种情况好一点。有些RAID控制器的型号末尾有一个字母,表明它们是为SSD做了准备的。例如, Adaptec控制器用Z标识。\n然而,即使支持闪存的控制器,也不一定真的就对闪存支持很好。例如,Vadim对Adaptec 5805Z控制器进行了基准测试,他用了多种驱动器做RAID 10,16个并发操作500 GB的文件。结果是很糟糕的:95%的随机写延迟在两位数的毫秒,在最坏的情况下,超过一秒钟(10)。(期望的应该是亚毫秒级写入。)\n这种特定的比较,是一家客户为了看到Micron SSD是否会比64GB的Intel SSD更好而做的,该比较是基于相同的配置的。当为英特尔驱动器进行基准测试时,我们发现了相同的性能特征。因此,我们尝试了一些其他驱动器的配置,不管有没有SAS扩展器,看看会发生什么。表9-1显示了这个结果。\n表9-1:在Adaptec RAID控制器上用SSD进行的基准测试 这些结果都没有达到我们对这么多驱动器的期望。在一般情况下,RAID控制器的性能表现,只能满足对6~8个驱动器的期望,而不是几十个。原因很简单,RAID控制器达到了瓶颈。这个故事的重点是,在对硬件投入巨资前,应该先仔细进行基准测试——结果可能与期望的有相当大的区别。\n9.4.5 PCIe存储设备 # 相对于SATA SSD,PCIe设备没有尝试模拟硬盘驱动器。这种设计是好事:服务器和硬盘驱动器之间的接口不能完全发挥闪存的性能。SAS/SATA互联带宽比PCIe要低,所以PCIe对高性能需求是更好的选择。PCIe设备延迟也低得多,因为它们在物理上更靠近CPU。\n没有什么比得上从PCIe设备上获得的性能。缺点就是它们太贵了。\n所有我们熟悉的型号都需要一个特殊的驱动程序来创建块设备,让操作系统把它认成一个硬盘驱动器。这些驱动程序使用着混合磨损均衡和其他逻辑的策略;有些使用主机系统的CPU和内存,有些使用板载的逻辑控制器和RAM(内存)。在许多场景下,主机系统有丰富的CPU和RAM资源,所以相对于购买一个自身有这些资源的卡,利用主机上的资源实际上是更划算的策略。\n我们不建议对PCIe设备建RAID。它们用RAID就太昂贵了,并且大部分设备无论以何种方式,都有它们自己板载的RAID。我们并不真的知道控制器坏了以后会怎么样,但是厂商说他们的控制器通常跟网卡或者RAID控制器一样好,看起来确实是这样。换句话说,这些设备的平均无故障时间间隔(MTBF)接近于主板,所以对这些设备使用RAID只是增加了大量成本而没有多少好处。\n有许多家供应商在生产PCIe闪存卡。对MySQL用户来说最著名的厂商是Fusion-io和Virident,但是像Texas Memory Systems、STEC和OCZ这样的厂商也有用户。SLC和MLC都有相应的PCIe卡产品。\n9.4.6 其他类型的固态存储 # 除了SSD和PCIe设备,也有其他公司的产品可以选择,例如Violin Memory、SandForce和Texas Memory Systems。这些公司提供有几十TB存储容量,本质上是闪存SAN的大箱子。它们主要用于大型数据中心存储的整合。虽然价格非常昂贵,但是性能也非常高。我们知道一些使用的例子,并在某些场景下测量过性能。这些设备能够提供相当好的延迟,除了网络往返时间——例如,通过NFS有小于4毫秒的延迟。\n然而这些都不适合一般的MySQL市场。它们的目标更针对其他数据库,如Oracle,可以用来做共享存储集群。一般情况下,MySQL在如此大规模的场景下,不能有效利用如此强大的存储优势,因为在数十个TB的数据下MySQL很难良好地工作——MySQL对这样一个庞大的数据库的回答是,拆分、横向扩展和无共享(Shared-nothing)架构。\n虽然专业化的解决方案可能能够利用这些大型存储设备——例如Infobright可能成为候选人。ScaleDB可以部署在共享存储(Shared-storage)架构,但我们还没有看到它在生产环境应用,所以我们不知道其工作得如何。\n9.4.7 什么时候应该使用闪存 # 固态存储最适合使用在任何有着大量随机I/O工作负载的场景下。随机I/O通常是由于数据大于服务器的内存导致的。用标准的硬盘驱动器,受限于转速和寻道延迟,无法提供很高的IOPS。闪存设备可以大大缓解这种问题。\n当然,有时可以简单地购买更多内存,这样随机工作负载就可以转移到内存,I/O就不存在了。但是当无法购买足够的内存时,闪存也可以提供帮助。另一个不能总是用内存解决的问题是,高吞吐的写入负载。增加内存只能帮助减少写入负载到磁盘,因为更多的内存能创造更多的机会来缓冲、合并写。这允许把随机写转换为更加顺序的I/O。\n然而,这并不能无限地工作下去,一些事务或插入繁忙的工作负载不能从这种方法中获益。闪存存储在这种情况下却也有帮助。\n单线程工作负载是另一个闪存的潜在应用场景。当工作负载是单线程的时候,它是对延迟非常敏感的,固态存储更低的延迟可以带来很大的区别。相反,多线程工作负载通常可以简单地加大并行化程度以获得更高的吞吐量。MySQL复制是单线程工作的典型例子,它可以从低延迟中获得很多收益。在备库跟不上主库时,使用闪存存储往往可以显著提高其性能。\n闪存也可以为服务器整合提供巨大的帮助,尤其是PCIe方式的。我们已经看到了机会,把很多实例整合到一台物理服务器——有时高达10或15倍的整合都是可能的。更多关于这个话题的信息,请参见第11章。\n然而闪存也可能不一定是你要的答案。一个很好的例子是,像InnoDB日志文件这样的顺序写的工作负载,闪存不能提供多少成本与性能优势,因为在这种情况下,闪存连续写方面不比标准硬盘快多少。这样的工作负载也是高吞吐的,会更快耗尽闪存的寿命。在标准硬盘上存放日志文件通常是一个更好的主意,用具有电池保护写缓存的RAID控制器。\n有时答案在于内存/磁盘的比例,而不只是磁盘。如果可以买足够的内存来缓存工作负载,就会发现这更便宜,并且比购买闪存存储设备更有效。\n9.4.8 使用Flashcache # 虽然有很多因素需要在闪存、硬盘和RAM之间权衡,在存储层次结构中,这些设备没有被当作一个整体处理。有时可以使用磁盘和内存技术的结合,这就是Flashcache。\nFlashcache是这种技术的一个实现,可以在许多系统上发现类似的使用,例如Oracle数据库、ZFS文件系统,甚至许多现代的硬盘驱动器和RAID控制器。下面讨论的许多东西应用广泛,但我们将只专注于Flashcache,因为它和厂商、文件系统无关。\nFlashcache是一个Linux内核模块,使用Linux的设备映射器(Device Mapper)。它在内存和磁盘之间创建了一个中间层。这是Facebook开源和使用的技术之一,可以帮助其优化数据库负载。\nFlashcache创建了一个块设备,并且可以被分区,也可以像其他块设备一样创建文件系统,特点是这个块设备是由闪存和磁盘共同支撑的。闪存设备用作读取和写入的智能高速缓存。\n虚拟块设备远比闪存设备要大,但是没关系,因为数据最终都存储在磁盘上。闪存设备只是去缓冲写入和缓存读取,有效弥补了服务器内存容量的不足(11)。\n这种性能有多好呢?Flashcache似乎有相对较高的内核开销。(设备映射并不总是像看起来那么有效,但我们还没深入调查找出原因。)但是,尽管Flashcache理论上可能更高效,但最终的性能表现并不如底层的闪存存储那么好,不过它仍然比磁盘快很多,所以还是值得考虑的方案。\n我们用包含数百个基准测试的一系列测试来评估Flashcache的性能,但是我们发现在人工模拟的工作负载下,测出有意义的数据是非常困难的。于是我们得出结论,虽然并不清楚Flashcache通常对写负载有多大好处,但是对读肯定是有帮助的。于是它适合这样的情况使用:有大量的读I/O,并且工作集比内存大得多。\n除了实验室测试,我们有一些生产环境中应用Flashcache的经验。想到的一个例子是,有个4TB的数据库,这个数据库遇到了很大的复制延迟。我们给系统加了半个TB的Virident PCIe卡作为存储。然后安装了Flashcache,并且把PCIe卡作为绑定设备的闪存部分,复制速度就翻了一倍。\n当闪存卡用得很满时使用Flashcache是最经济的,因此选择一张写得很满时其性能不会降低多少的卡非常重要。这就是为什么我们选择Virident卡。\nFlashcache就是一个缓存系统,所以就像任何其他缓存一样,它也有预热问题。虽然预热时间可能会非常长。例如,在我们刚才提到的情况下,Flashcache需要一个星期的预热,才能真正对性能产生帮助。\n应该使用Flashcache吗?根据具体情况可能会有所不同,所以我们认为在这一点上,如果你觉得不确定,最好得到专家的意见。理解Flashcache的机制和它们如何影响你的数据库工作集大小是很复杂的,在数据库下层(至少)有三层存储:\n首先,是InnoDB缓冲池,它的大小跟工作集大小一起可以决定缓存的命中率。缓存命中是非常快的,响应时间非常均匀。 在缓冲池中没有命中,就会到Flashcache设备上去取,这就会产生分布比较复杂的响应时间。Flashcache的缓存命中率由工作集大小和闪存设备大小决定。从闪存上命中比在磁盘上查找要快得多。 Flashcache设备缓存也没有命中,那就得到磁盘上找,这也会看到分布相当均匀的比较慢的响应时间。 有可能还有更多层次:例如,SAN或RAID控制器的缓存。\n这有一个思维实验,说明这些层是如何交互的。很显然,从Flashcache设备访问的响应时间不会像直接访问闪存设备那么稳定和高速。但是想象一下,假设有1TB的数据,其中100 GB在很长一段时间会承受99%的I/O操作。也就是说,大部分时候99%的工作集只有100 GB。\n现在,假设有以下的存储设备:一个很大的RAID卷,可以执行1000 IOPS,以及一个可以达到100000 IOPS的更小的闪存设备。闪存设备不足以存放所有的数据——假设只有128 GB——因此单独使用闪存不是一种可能的选择。如果用闪存设备做Flashcache,就可以期望缓存命中远远快于磁盘检索,但Flashcache整体比单独使用闪存设备要慢。我们坚持用数字说话,如果90%的请求落到Flashcache设备,相当于达到50000 IOPS。这个思维实验的结果是什么呢?有两个要点:\n系统使用Flashcache比不使用的性能要好很多,因为大多数在缓冲池未命中的页面访问都被缓存在闪存卡上,相对于磁盘可以提供快得多的访问速度。(99%的工作集可以完全放在闪存卡上。) Flashcache设备上有90%的命中率意味着有10%没有命中。因为底层的磁盘只能提供1000 IOPS,因此整个Flashcache设备可以支持10000的IOPS。为了明白为什么是这样的,想象一下如果我们要求不止于此会发生什么:10%的I/O操作在缓存中没有命中而落到了RAID卷上,则肯定要求RAID卷提供超过1000 IOPS,很显然是没法处理的。因此,即使Flashcache比闪存卡慢,系统作为一个整体仍然受限于RAID卷,不止是闪存卡或Flashcache。 归根到底,Flashcache是否合适是一个复杂的决定,涉及的因素很多。一般情况下,它似乎最适合以读为主的I/O密集型负载,并且工作集太大,用内存优化并不经济的情况。\n9.4.9 优化固态存储上的MySQL # 如果在闪存上运行MySQL,有一些配置参数可以提供更好的性能。InnoDB的默认配置从实践来看是为硬盘驱动器定制的,而不是为固态硬盘定制的。不是所有版本的InnoDB都提供同样等级的可配置性。尤其是很多为提升闪存性能设计的参数首先出现在Percona Server中,尽管这些参数很多已经在Oracle版本的InnoDB中实现,或者计划在未来的版本中实现。\n改进包括:\n增加InnoDB的I/O容量\n闪存比机械硬盘支持更高的并发量,所以可以增加读写I/O线程数到10或15来获得更好的结果。也可以在2000~20000范围内调整innodb_io_capacity选项,这要看设备实际上能支撑多大的IOPS。尤其是对Oracle官方的InnoDB这个很有必要,内部有更多算法依赖于这个设置。\n让InnoDB日志文件更大\n即使最近版本的InnoDB中改进了崩溃恢复算法,也不应该把磁盘上的日志文件调得太大,因为崩溃恢复时需要随机I/O访问,会导致恢复需要很长一段时间。闪存存储让这个过程快很多,所以可以设置更大的InnoDB日志文件,以帮助提升和稳定性能。对于Oracle官方的InnoDB,这个设置尤其重要,它维持一个持续的脏页刷新比例有点麻烦,除非有相当大的日志文件——4GB或者更大的日志文件,在写的时候对服务器来说是个不错的选择。Percona Server和MySQL 5.6支持大于4GB的日志文件。\n把一些文件从闪存转移到RAID\n除了把InnoDB日志文件设置得更大,把日志文件从数据文件中拿出来,单独放在一个带有电池保护写缓存的RAID组上而不是固态设备上,也是个好主意。这么做有几个原因。一个原因是日志文件的I/O类型,在闪存设备上不比在这样一个RAID组上要快。InnoDB写日志是以512字节为单位的顺序I/O写下去,并且除了崩溃恢复会顺序读取,其他时候绝不会去读。这样的I/O操作类型用闪存设备是很浪费的。并且把小的写入操作从闪存转移到RAID卷也是个好主意,因为很小的写入会增加闪存设备的写放大因子,会影响一些设备的使用寿命。大小写操作混合到一起也会引起某些设备延时的增加。\n基于相同的原因,有时把二进制日志文件转移到RAID卷也会有好处。并且你可能会认为ibdata1文件也适合放在RAID卷上,因为ibdata1文件包含双写缓冲(Doublewrite Buffer)和插入缓冲(Insert Buffer),尤其是双写缓冲会进行很多重复写入。在Percona Server中,可以把双写缓冲从ibdata1文件中拿出来,单独存放到一个文件,然后把这个文件放在RAID卷上。\n还有另一个选择:可以利用Percona Server的特性,使用4KB的块写事务日志,而不是512字节。因为这会匹配大部分闪存本身的块大小,所以可以获得更好的效果。所有的上述建议是对特定硬件而言的,实际操作的时候可能会有所不同,所以在大规模改动存储布局之前要确保已经理解相关的因素——并辅以适当的测试。\n禁用预读\n预读通过通知和预测读取模式来优化设备的访问,一旦认为某些数据在未来需要被访问到,就会从设备上读取这些数据。实际上在InnoDB中有两种类型的预读,我们发现在多种情况下的性能问题,其实都是预读以及它的内部工作方式造成的。在许多情况下开销比收益大,尤其是在闪存存储,但我们没有确凿的证据或指导,禁用预读究竟可以提高多少性能。\n在MySQL 5.1的InnoDB Plugin中,MySQL禁用了所谓的“随机预读”,然后在MySQL 5.5又重新启用了它,可以在配置文件用一个参数配置。Percona Server能让你在旧版本里也一样可以配置为random(随机)或linear read-ahead(线性预读)。\n配置InnoDB刷新算法\n这决定InnoDB什么时候、刷新多少、刷新哪些页面,这是个非常复杂的主题,这里我们没有足够的篇幅来讨论这些具体的细节。这也是个研究比较活跃的主题,并且实际上在不同版本的InnoDB和MySQL中有多种有效的算法。\n标准InnoDB算法没有为闪存存储提供多少可配置性,但是如果用的是Percona XtraDB(包含在Percona Server和MariaDB中),我们建议设置innodb_adaptive_checkpoint选项为keep_average,不要用默认值estimate。这可以确保更持续的性能,并且避免服务器抖动,因为estimate算法会在闪存存储上引起抖动。我们专门为闪存存储开发了keep_average,因为我们意识到对于闪存设备,把希望操作的大量I/O推到设备上,并不会引起瓶颈或发生抖动。\n另外,建议为闪存设备设置innodb_flush_neighbor_pages=0。这样可以避免InnoDB尝试查找相邻的脏页一起刷写。这个算法可能会导致更大块的写、更高的延迟,以及内部竞争。在闪存存储上这完全没必要,也不会有什么收益,因为相邻的页面单独刷新不会冲击性能。\n禁用双写缓冲的可能\n相对于把双写缓存转移到闪存设备,可以考虑直接关闭它。有些厂商声称他们的设备支持16KB的原子写入,使得双写缓冲成为多余的。如果需要确保整个存储系统被配置得可以支持16KB的原子写入,通常需要O_DIRECT和XFS文件系统。\n没有确凿的证据表明原子操作的说法是真实的,但由于闪存存储的工作方式,我们相信写数据文件发生页面写一部分的情况是大大减少的,并且这个收益在闪存设备上比在传统磁盘上要高得多,禁用双写缓冲在闪存存储上可以提高MySQL整体性能差不多50%,尽管我们不知道这是不是100%安全的,但是你可以考虑下这么做。\n限制插入缓冲大小\n插入缓冲(在新版InnoDB中称为变更缓冲(Change Buffer))设计来用于减少当更新行时不在内存中的非唯一索引引起的随机I/O。在硬盘驱动器上,减少随机I/O可以带来巨大的性能提升。对某些类型的工作负载,当工作集比内存大很多时,差异可能达到近两个数量级。插入缓冲在这类场景下就很有用。\n然而,对闪存就没有必要了。闪存上随机I/O非常快,所以即使完全禁用插入缓冲,也不会带来太大影响,尽管如此,可能你也不想完全禁用插入缓存。所以最好还是启用,因为I/O只是修改不在内存中的索引页面的开销的一部分。对闪存设备而言,最重要的配置是控制最大允许的插入缓冲大小,可以限制为一个相对比较小的值,而不是让它无限制地增长,这可以避免消耗设备上的大量空间,并避免ibdata1文件变得非常大的情况。在本书写作的时候,标准InnoDB还不能配置插入缓存的容量上限,但是在Percona XtraDB(Percona Server和MariaDB都包含XtraDB)里可以。MySQL 5.6里也会增加一个类似的变量。\n除了上述的配置建议,我们还提出或讨论了其他一些闪存优化策略。然而,不是所有的策略都非常容易明白,所以我们只是提到了一部分,最好自己研究在具体情况下的好处。首先是InnoDB的页大小。我们发现了不同的结果,所以我们现在还没有一个明确的建议。好消息是,在Percona Server中不需要重编译也能配置页面大小,在MySQL 5.6中这个功能也可能实现。以前版本的MySQL需要重新编译服务器才能使用不同大小的页面,所以大部分情况都是运行在默认的16KB页面。当页面大小更容易让更多人进行实验时,我们期待更多非标准页面大小的测试,可能能从中得到很多重要的结论。\n另一个提到的优化是InnoDB页面校验(Checksum)的替代算法。当存储系统响应很快时,校验值计算可能开始成为I/O相关操作中显著影响时间的因素,并且对某些人来说这个计算可能替代I/O成为新的瓶颈。我们的基准测试还没有得出可适用于普遍场景的结论,所以每个人的情况可能有所不同。Percona XtraDB允许修改校验算法,MySQL 5.6也有了这个功能。\n可能已经提醒过了,我们提到的很多功能和优化在标准版本的InnoDB中是无效的。我们希望并且相信我们引入Percona Server和XtraDB中的改进点,最终将会被广大用户接受。与此同时,如果正使用Oracle官方MySQL分发版本,依然可以对服务器采取措施为闪存进行优化。建议使用innodb_file_per_table,并且把数据文件目录放到闪存设备。然后移动ibdata1和日志文件,以及其他所有日志文件(二进制日志、复制日志,等等),到RAID卷,正如我们之前讨论的。这会把随机I/O集中到闪存设备上,然后把大部分顺序写入的压力尽可能转移出闪存,因而可以节省闪存空间并且减少磨损。\n另外,所有版本的MySQL服务器,都应该确认超线程开启了。当使用闪存存储时,这有很大的帮助,因为磁盘通常不再是瓶颈,任务会更多地从I/O密集变为CPU密集。\n9.5 为备库选择硬件 # 为备库选择硬件与为主库选择硬件很相似,但是也有些不同。如果正计划着建一个备库做容灾,通常需要跟主库差不多的配置。不管备库是不是仅仅作为一个主库的备用库,都应该强大到足以承担主库上发生的所有写入,额外的不利因素是备库只能序列化串行执行。(下一章有更多关于这方面的内容)。\n备库硬件主要考虑的是成本:需要在备库硬件上花费跟主库一样多的成本吗?可以把备库配置得不一样以便从备库上获得更多性能吗?如果备库跟主库工作负载不一样,可以从不一样的硬件配置上获得隐含的收益吗?\n这一切都取决于备库是否只是备用的,你可能希望主库和备库有相同的硬件和配置,但是,如果只是用复制作为扩展更多读容量的方法,那备库可以有多种不同的捷径。例如,可能在备库使用不一样的存储引擎,并且有些人使用更便宜的硬件或者用RAID 0代替RAID 5或RAID 10。也可以取消一些一致性和持久性的保证让备库做更少的工作。\n这些措施在大规模部署的情况下具有很好的成本效益,但是在小规模的情况下,可能只会使事情变得更加复杂。在实践中,似乎大多数人都会选择以下两种策略为备库选择硬件:主备使用相同的硬件,或为主库购买新的硬件,然后让备库使用主库淘汰的老硬件。\n在备库很难跟上主库时,使用固态硬盘有很大的意义。很好的随机I/O性能有助于缓解单个复制线程的影响。\n9.6 RAID性能优化 # 存储引擎通常把数据和索引都保存在一个大文件中,这意味着用RAID(Redundant Array of Inexpensive Disks,磁盘冗余阵列)存储大量数据通常是最可行的方法(12)。RAID可以帮助做冗余、扩展存储容量、缓存,以及加速。但是从我们看到的一些优化案例来说,RAID上有多种多样的配置,为需求选择一个合适的配置非常重要。\n我们不想覆盖所有的RAID等级,或者深入细节来分析不同的RAID等级分别如何存储数据。关于这个主题有很多好资料,在一些书籍和在线文档可以找到(13)。因此,我们专注于怎样配置RAID来满足数据库服务器的需求。最重要的RAID级别如下:\nRAID 0\n如果只是简单地评估成本和性能,RAID 0是成本最低和性能最高的RAID配置(但是,如果考虑数据恢复的因素,RAID 0的代价会非常高)。因为RAID 0没有冗余,建议只在不担心数据丢失的时候使用,例如备库或者因某些原因只是“一次性”使用的时候。典型的案例是可以从另一台备库轻易克隆出来的备库服务器。再次说明, RAID 0没有提供任何冗余,即使R在RAID中表示冗余。实际上,RAID 0阵列的损坏概率比单块磁盘要高,而不是更低!\nRAID 1\nRAID 1在很多情况下提供很好的读性能,并且在不同的磁盘间冗余数据,所以有很好的冗余性。RAID 1在读上比RAID 0快一些。它非常适合用来存放日志或者类似的工作,因为顺序写很少需要底层有很多磁盘(随机写则相反,可以从并发中受益)。这通常也是只有两块硬盘又需要冗余的低端服务器的选择。\nRAID 0和RAID 1很简单,在软件中很好实现。大部分操作系统可以很简单地用软件创建RAID 0和RAID 1。\nRAID 5\nRAID 5有点吓人,但是对某些应用,这是不可避免的选择,因为价格或者磁盘数量(例如需要的容量用RAID 1无法满足)的原因。它通过分布奇偶校验块把数据分散到多个磁盘,这样,如果任何一个盘的数据失效,都可以从奇偶校验块中重建。但如果有两个磁盘失效了,则整个卷的数据无法恢复。就每个存储单元的成本而言,这是最经济的冗余配置,因为整个阵列只额外消耗了一块磁盘的存储空间。\n在RAID 5上随机写是昂贵的,因为每次写需要在底层磁盘发生两次读和两次写,以计算和存储校验位。如果写操作是顺序的,那么执行起来会好一些,或者有很多物理磁盘也行。另外说一下,随机读和顺序读都能很好地在RAID 5下执行(14)。RAID 5用作存放数据或者日志是一种可接受的选择,或者是以读为主的业务,不需要消耗太多写I/O的场景。\nRAID 5最大的性能消耗发生在磁盘失效时,因为数据需要重分布到其他磁盘。这会严重影响性能,如果有很多磁盘会更糟糕。如果在重建数据时还保持服务器在线服务,那就别指望重建的速度或者阵列的性能会好。如果使用RAID 5,最好有一些机制可以做故障迁移,当有问题的时候让一台机器不再提供服务,另一台接管。不管怎样,对系统做一下故障恢复时的性能测试很有必要,这样就可以知道故障恢复时的性能表现到底如何。如果一块磁盘失效,RAID组在重建过程中,会导致磁盘性能下降,使用这个存储的服务器整体性能可能会不成比例地被影响到慢两倍到五倍。\nRAID 5的奇偶校验块会带来额外的性能开销,这会限制它的可扩展性,超过10块硬盘后RAID 5就不能很好地扩展,RAID缓存也会有些问题。RAID 5的性能严重依赖于RAID控制器的缓存,这可能跟数据库服务器需要的缓存冲突了。我们稍后会讨论缓存。\n尽管RAID 5有这么多问题,但有个有利因素是它非常受欢迎。因此,RAID控制器往往针对RAID 5做了高度优化,虽然有理论极限,但是智能控制器充分利用高速缓存使得RAID 5在某些场景下有时可以达到接近RAID 10的性能。实际上这可能反映了RAID 10的控制器缺少很好的优化,但不管是什么原因,这就是我们所见到的。\nRAID 10\nRAID 10对数据存储是个非常好的选择。它由分片的镜像组成,所以对读和写都有良好的扩展性。相对于RAID 5,重建起来很简单,速度也很快。另外RAID 10还可以在软件层很好地实现。\n当失去一块磁盘时,性能下降还是比较明显的,因为条带可能成为瓶颈(15)。性能可能下降为50%,具体要看工作负载。需要注意的一件事是,RAID控制器对RAID 10采用了一种“串联镜像”的实现。这不是最理想的实现,由于条带化的缺点是“最经常访问的数据可能仅被放置在一对机械磁盘上,而不是分布很多份,”所以可能会遇到性能不佳的情况。\nRAID 50\nRAID 50由条带化的RAID5组成,如果有很多盘的话,这可能是RAID 5的经济性和RAID 10的高性能之间的一个折中。它的主要用处是存放非常庞大的数据集,例如数据仓库或者非常庞大的OLTP系统。\n表9-2是多种RAID配置的总结。\n表9-2:RAID等级之间的比较 9.6.1 RAID的故障转移、恢复和镜像 # RAID配置(除了RAID 0)都提供了冗余。这很重要,但很容易让人低估磁盘同时发生故障的可能性。千万不要认为RAID能提供一个强有力的数据安全性保证(16)。\nRAID不能消除甚至减少备份的需求。当出现问题的时候,恢复时间要看控制器、RAID等级、阵列大小、硬盘速度,以及重建阵列时是否需要保持服务器在线。\n硬盘在完全相同的时间损坏是有可能的。例如,峰值功率或过热,可以很容易地废掉两个或更多的磁盘。然而,更常见的是,两个密切相关的磁盘(17)出现故障。许多这样的隐患可能被忽视了。一个常见的情况是,很少被访问的数据,在物理媒介上损坏了。这可能几个月都检测不到,直到尝试读取这份数据,或另一个硬盘也失效了,然后RAID控制器尝试使用损坏的数据来重建阵列。越大的硬盘驱动器,越容易发生这种情况。\n这就是为什么做RAID阵列的监控如此重要。大部分控制器提供了一些软件来报告阵列的状态,并且需要持续跟踪这些状态,因为不这么做可能就会忽略了驱动器失效。你可能丧失恢复数据和发现问题的时机,当第二块硬盘损坏时,已经晚了。因此应该配置一个监控系统来提醒硬盘或者RAID卷发生降级或失效了。\n对阵列积极地进行定期一致性检查,可以减少潜在的损坏风险。某些控制器有后台巡检(Background Patrol Read)功能,当所有驱动器都在线服务时,可以检查媒介是否有损坏并且修复,也可以帮助避免此类问题的发生。在恢复时,非常大型的阵列可能会降低检查速度,所以创建大型阵列时一定要确保制定了相应的计划。\n也可以添加一个热备盘,这个盘一般是未使用状态,并且配置为备用状态,有硬盘坏了之后控制器会自动把这块盘恢复为使用状态。如果依赖于每个服务器的可用性(18),这是一个好主意。对只有少数硬盘驱动器的服务器,这么做是很昂贵的,因为一个空闲磁盘的成本比例比较高,但如果有多个磁盘,而不设一个热备盘,就是愚蠢的做法。请记住,更多的磁盘驱动器会让发生故障的概率迅速增加。\n除了监控硬盘失效,还应该监控RAID控制器的电池备份单元以及写缓存策略。如果电池失效,大部分控制器默认设置会禁用写缓存,把缓存策略从WriteBack改为WriteThrough。这可能导致服务器性能下降。很多控制器会通过一个学习过程周期性地对电池充放电,在这个过程中缓存是被禁用的。RAID控制器管理工具应该可以浏览和配置电池充放电计划,不会让人措手不及。\n也许希望把缓存策略设为WriteThrough来测试系统,这样就可以知道系统性能的期望值。也许需要计划电池充放电的周期,安排在晚上或者周末,重新配置服务器修改innodb_flush_log_at_trx_commit和sync_binlog变量,或者在电池充放电时简单地切换到另一台服务器。\n9.6.2 平衡硬件RAID和软件RAID # 操作系统、文件系统和操作系统看到的驱动器数量之间的相互作用可以是复杂的。Bug、限制或只是错误配置,都可能会把性能降低到远远低于理论值。\n如果有10块硬盘,理想中应该能够承受10个并行的请求,但有时文件系统、操作系统或RAID控制器会把请求序列化。面对这个问题一个可行的办法是尝试不同的RAID配置。例如,如果有10个磁盘,并且必须使用镜像冗余,性能也要好,可以考虑下面几种配置:\n配置一个包含五个镜像对(RAID 1)的RAID 10卷(19)。操作系统只会看到一个很大的单独的硬盘卷,RAID控制器会隐藏底层的10块硬盘。 在RAID控制器中配置五个RAID 1镜像对,然后让操作系统使用五个卷而不是一个卷。(20) 在RAID控制器中配置五个RAID 1镜像对,然后使用软件RAID 0把五个卷做成一个逻辑卷,通过部分硬件、部分软件的实现方式,有效地实现了RAID 10。(21) 哪个选项是最好的?这依赖于系统中所有的组件如何相互协作。不同的配置可能获得相同的结果,也可能不同。\n我们已经提醒了多种配置可能导致串行化。例如,ext3文件系统每个inode有一个单一的Mutex,所以当InnoDB是配置为innodb_flush_method=O_DIRECT(常见的配置)时,在文件系统会有inode级别的锁定。这使得它不可能对文件进行I/O并发操作,因而系统表现会远低于其理论上的能力。\n我们见过的另一个案例,请求串行地发送到一个10块盘的RAID10卷中的每个设备,使用ReiserFS文件系统,InnoDB打开了innodb_file_per_table选项。尝试在硬件RAID 1的基础上用软件RAID 0做成RAID 10的方式,获得了五倍多的吞吐,因为存储系统开始表现出五个硬盘同时工作的特性,而不再是一个了。造成这种情况的是一个已经被修复的Bug,但是这是一个很好的例证,说明这类事情可能发生。\n串行化可能发生在任何的软件或硬件堆栈层。如果看到这个问题发生了,可能需要更改文件系统、升级内核、暴露更多的设备给操作系统,或使用不同的软件或硬件RAID组合方式。应该检查你的设备的并发性以确保它确实是在做并发I/O(本章稍后有更多关于这个话题的内容)。\n最后,当准备上线一种新服务器时,不要忘了做基准测试!这会帮助你确认能获得所期望的性能。例如,若一个硬盘驱动器每秒可以做200个随机读,一个有8个硬盘驱动器的RAID 10卷应该接近每秒1 600个随机读。如果观察到的结果比这个少得多,比如说每秒500个随机读,就应该研究下哪里可能有问题了。确保基准测试对I/O子系统施加了跟MySQL一样的方式的压力——例如,使用O_DIRECT标记,并且如果使用没有打开innodb_file_per_table选项的InnoDB,要用一个单一的文件测试I/O性能。通常可以使用sysbench来验证新的硬件设置都是正确的。\n9.6.3 RAID配置和缓存 # 配置RAID控制器通常有几种方法,一是可以在机器启动时进入自带的设置工具,或从命令行中运行。虽然大多数控制器提供了很多选项,但其中有两个是我们特别关注的,一是条带化阵列的块大小(Chunk Size),还有就是控制器板载缓存(也称为RAID缓存,我们使用术语)。\nRAID条带块大小 # 最佳条带块大小和具体工作负载以及硬件息息相关。从理论上讲,对随机I/O来说更大的块更好,因为这意味着更多的读取可以从一个单一的驱动器上满足。\n为什么会是这样?在工作负载中找出一个典型的随机I/O操作,如果条带的块大小足够大,至少大到数据不用跨越块的边界,就只有单个硬盘需要参与读取。但是,如果块大小比要读取的数据量小,就没有办法避免多个硬盘参与读取。\n这只是理论上的观点。在实践中,许多RAID控制器在大条带下工作得不好。例如,控制器可能用缓存中的缓存单元大小作为块大小,这可能有浪费。控制器也可能把块大小、缓存大小、读取单元的大小(在一个操作中读取的数据量)匹配起来。如果读的单位太大, RAID缓存可能不太有效,最终可能会读取比真正需要的更多的数据,即使是微小的请求。当然,在实践中很难知道是否有数据会跨越多个驱动器。即使块大小为16 KB,与InnoDB的页大小相匹配,也不能让所有的读取对齐16 KB的边界。文件系统可能会把文件分成片段,每个片段的大小通常与文件系统的块大小对齐,大部分文件系统的块大小为4KB。一些文件系统可能更聪明,但不应该指望它。\n可以配置系统以便从应用到底层存储所有的块都对齐:InnoDB的块、文件系统的块、LVM,分区偏移、RAID条带、磁盘扇区。我们的基准测试表明,当一切都对齐时,随机读和随机写的性能可能分别提高15%和23%。对齐一切的精密技术太特殊了,无法在这细说,但其他很多地方有很多不错的信息,包括我们的博客, http://www.mysqlperformanceblog.com。\nRAID缓存 # RAID缓存就是物理安装在RAID控制器上的(相对来说)少量内存。它可以用来缓冲硬盘和主机系统之间的数据。下面是RAID卡使用缓存的几个原因:\n缓存读取\n控制器从磁盘读取数据并发送到主机系统后,通过缓存可以存储读取的数据,如果将来的请求需要相同的数据,就可以直接使用而无须再次去读盘。\n这实际上是RAID缓存一个很糟糕的用法。为什么呢?由于操作系统和数据库服务器有自己更大得多的缓存。如果数据在这些上层缓存中命中了,RAID缓存中的数据就不会被使用。相反,如果上层的缓存没有命中,就有可能在RAID缓存中命中,但这个概率是微乎其微的。因为RAID缓存要小得多,几乎肯定会被刷新掉,被其他数据填上去了。无论哪种方式,缓冲读都是浪费RAID缓存的事。\n缓存预读数据\n如果RAID控制器发现连续请求的数据,可能会决定做预读操作——就是预先取出估计很快会用到的数据。在数据被请求之前,必须有地方放这些数据。这也会使用RAID缓存来放。预读对性能的影响可能有很大的不同,应该检查确保预读确实有帮助。如果数据库服务器做了自己的智能预读(例如InnoDB的预读),RAID控制器的预读可能就没有帮助,甚至可能会干扰所有重要的缓冲和同步写入。\n缓冲写入\nRAID控制器可以在高速缓存里缓冲写操作,并且一段时间后再写到硬盘。这样做有双重的好处:首先,可以快得多地返回给主机系统写“成功”的信号,远远比写入到物理磁盘上要快;其次,可以通过积累写操作从而更有效地批量操作(22)。\n内部操作\n某些RAID的操作是非常复杂的——尤其是RAID 5的写入操作,其中要计算校验位,用来在发生故障时重建数据。控制器做这类内部操作需要使用一些内存。\n这也是RAID 5在一些RAID控制器上性能差的原因:为了好的性能需要读取大量数据到内存。有些控制器不能较好地平衡缓存写和RAID 5校验位操作所需要的内存。\n一般情况下,RAID控制器的内存是一种稀缺资源,应该尽量用在刀刃上。缓存读取通常是一种浪费,但是缓冲写入是加速I/O性能的一个重要途径。许多控制器可以选择如何分配内存。例如,可以选择有多少缓存用于写入和多少用于读取。对于RAID 0、RAID 1和RAID 10,应该把控制器缓存100%分配给写入用。对于RAID 5,应该保留一些内存给内部操作。通常这是正确的建议,但并不总是适用——不同的RAID卡需要不同的配置。\n当正在用RAID缓存缓冲写入时,许多控制器可以配置延迟写入多久时间(例如一秒钟、五秒钟,等等)是可以接受的。较长的延迟意味着更多的写入可以组合在一起更有效地刷新到磁盘。缺点是写入会变得更加“突发的”。但这不是一件坏事,除非应用一连串的写请求把控制器的缓存填满了才被刷新到磁盘。如果没有足够的空间存放应用程序的写入请求,写操作就会等待。保持短的延迟意味着可以有更多的写操作,并且会更低效(23),但能抚平性能波动,并且有助于保持更多的空闲缓存,来接收应用程序的爆发请求。(我们在这里简化了——事实上控制器往往很复杂,不同的供应商有自己的均衡算法,所以我们只是试图覆盖基本原则。)\n写入缓冲对同步写入非常有用,例如事务日志和二进制日志(sync_binlog设置为1)调用的fsync(),但是除非控制器有电池备份单元(BBU)或其他非易失性存储(24),否则不应该启用RAID缓存。不带BBU的情况下缓冲写,在断电时,有可能损坏数据库,甚至是事务性文件系统。然而,如果有BBU,启用写入缓存可以提升很多日志刷新的工作的性能,例如事务提交时刷新事务日志。\n最后要考虑的是,许多硬盘驱动器有自己的缓存,可能有“假”的fsync()操作,欺骗RAID控制器说数据已被写入物理介质(25)。有时可以让硬盘直接挂载(而不是挂到RAID控制器上),让操作系统管理它们的缓存,但这并不总是有效。这些缓存通常在做fsync()操作时被刷新,另外同步I/O也会绕过它们直接访问磁盘,但是再次提醒,硬盘驱动器可能会骗你。应该确保这些缓存在fsync()时真的刷新了,否则就禁用它们,因为磁盘缓存没有电池供电(所以断电会丢失)。操作系统或RAID固件没有正确地管理硬盘管理已经造成了许多数据丢失的案例。\n由于这个以及其他原因,当安装新硬件时,做一次真实的宕机测试(比如拔掉电源)是很有必要的。通常这是找出隐藏的错误配置或者诡异的硬盘问题的唯一办法。在 http://brad.livejournal.com/2116715.html有个方便的脚本可以使用。\n为了测试是否真的可以依赖RAID控制器的BBU,必须像真实情况一样切断电源一段时间,因为某些单元断电超过一段时间后就可能会丢失数据。这里再次重申,任何一个环节出现问题都会使整个存储组件失效。\n9.7 SAN和NAS # SAN(Storage Area Network)和NAS(Network-Attached Storage)是两个外部文件存储设备加载到服务器的方法。不同的是访问存储的方式。访问SAN设备时通过块接口,服务器直接看到一块硬盘并且可以像硬盘一样使用,但是NAS设备通过基于文件的协议来访问,例如NFS或SMB。SAN设备通常通过光纤通道协议(FCP)或iSCSI连接到服务器,而NAS设备使用标准的网络连接。还有一些设备可以同时通过这两种方式访问,比如NetApp Filer存储系统。\n在接下来的讨论中,我们将把这两种类型的存储统一称为SAN。在后面的阅读应该记住这一点。主要区别在于作为文件还是块设备访问存储。\nSAN允许服务器访问非常大量的硬盘驱动器——通常在50块以上——并且通常配置大容量智能高速缓存来缓冲写入。块接口在服务器上以逻辑单元号(LUN)或者虚拟卷(除非使用NFS)出现。许多SAN也允许多节点组成集群来获得更好的性能或者增加存储容量。\n目前新一代SAN跟几年前的不同。许多新的SAN混合了闪存和机械硬盘,而不仅仅是机械硬盘了。它们往往有大到TB级或以上的闪存作为缓存,不像旧的SAN,只有相对较小的缓存。此外,旧的SAN无法通过配置更大的缓存层来“扩展缓冲池”,而新的SAN有时可以。因此,相比之下新的SAN可以提供更好的性能。\n9.7.1 SAN基准测试 # 我们已经测试了多个SAN厂商的多种产品。表9-3展示了一些低并发场景下的典型测试结果。\n表9-3:以16KB为单位同步单线程操作单个4GB文件的IOPS 具体的SAN厂商名字和配置做了保密处理,但是可以透露的是这些都不是便宜的SAN。测试都是用同步的16 KB操作,模拟InnoDB配置在O_DIRECT模式时的操作方式。\n从表9-3中可以得出什么结论?我们测试的系统不是都可以直接比较的,所以盯着这些好看的数据点来看不能客观地做出评价。然而,这些结果很好地说明了这类设备的总体性能表现,SAN可以承受大量的连续写入,因为可以缓冲并合并I/O。SAN提供顺序读取没有问题,因为可以做预读并从缓存中提出数据。在随机写上会慢一些,因为写入操作不能较好地合并。因为读取通常在缓存中无法命中,必须等待硬盘驱动器响应,所以SAN很不适合做随机读取。最重要的是,服务器和SAN之间有传输延迟。这是为什么通过NFS连接SAN时,提供的每秒随机读还不如一块本地磁盘的原因。\n我们已经用较大尺寸的文件做了基准测试,但没有用其他尺寸的文件在上述的系统中测试。然而,无论结果如何,可以预见的是:不管多么强大的SAN,对于小的随机操作,都无法获得良好的响应时间和吞吐量。延时的大部分都是由于服务器和SAN之间的链路导致的。\n我们的基准测试显示的每秒操作吞吐量,并没有说出完整的故事。至少有三个重要指标:每秒吞吐量字节数、并发性和响应时间。在一般情况下,相对于直接连接存储(DAS), SAN无论读写都可以提供更好的顺序I/O吞吐量。大多数SAN可以支持大量的并发性,但基准测试只有一个线程,这可以说明最坏的情况。但是,当工作集不能放到SAN的缓存时,随机读在吞吐量和延迟方面将变得很差,甚至延迟将高于直接访问本地存储。\n9.7.2 使用基于NFS或SMB的SAN # 某些SAN,例如NetApp Filer存储,通常通过NFS访问,而不是通过光纤或者iSCSI。这曾经是我们希望避免的情况,但是NFS今天比以前好了很多。通过NFS可以获得相当好的性能,尽管需要专门配置网络。SAN厂商提供的最佳实践指导可以帮助了解怎样配置。\n主要考虑的事情是NFS协议自身怎样影响性能。许多文件元信息操作,通常在本地文件系统或者SAN设备(非NAS)的内存中执行,但是在NAS上可能需要一次网络来回发送。例如,我们提醒过把二进制日志存在NFS上会损害服务器性能,即使关闭sync_binlog也无济于事。\n也可以通过SMB协议访问SAN或者NAS,需要考虑的问题类似:可能有更多的网络通信,会对延迟产生影响。对传统桌面用户这没什么影响,他们通常只是在挂载的驱动器上存储电子表格或者其他文档,或者只是为了备份复制一些东西到另一台服务器。但是用作MySQL读写它的文件,就会有严重的性能问题。\n9.7.3 MySQL在SAN上的性能 # I/O基准测试只是一种观察的方式,MySQL在SAN上具体性能表现如何?在许多情况下, MySQL运行得还可以,可以避免很多SAN可能导致性能下降的情况。仔细地做好逻辑和物理设计,包括索引和适当的服务器硬件(尽量配置更多的内存!)可避免很多的随机I/O操作,或者可以转化为顺序的I/O。然而,应该知道的是,通过一段时间的运行,这种系统可以达到一个微妙的平衡——引入一个新的查询,Schema的变化或频繁的操作,都很容易扰乱这种平衡。\n例如,一个SAN用户,我们知道他对每天的性能表现非常满意,直到有一天他想清理一张变得非常大的旧表中的大量数据行。这会导致一个长时间运行的DELETE语句,每秒只能删几百行,因为删除每行所需的随机I/O,SAN无法有效快速地执行。有没有办法来加快操作,它只是要花费很长的时间才能完成。另一个让他大吃一惊的事是,当对一个大表执行ALTER类似的操作时明显速度减慢。\n这些都是些典型的例子,哪些工作放在SAN上不合适:执行大量的随机I/O的单线程任务。在当前版本的MySQL中,复制是另一个单线程任务。因此,备库的数据存储在SAN上,可能更容易落后于主库。批处理作业也可能运行得更慢。在非高峰时段或周末执行一个一次性的延迟敏感的操作是可以的,但是服务器的很多部分依然需要很好的性能,例如拷贝、二进制日志,以及InnoDB的事务日志上总是需要很好的小随机I/O性能。\n9.7.4 应该用SAN吗 # 嗯,这是个长期存在的问题——在某些情况下,数百万美元的问题。有很多因素要考虑,以下我们列出其中的几个。\n备份\n集中存储使备份更易于管理。当所有数据都存储在一个地方时,可以只备份SAN,只要确保已经确认过了所有的数据都在。这简化了问题,例如“你确定我们要备份所有的数据吗?”此外,某些设备有如连续数据保护(CDP)以及强大的快照功能等功能,使得备份更容易、更灵活。\n简化容量规划\n不确定需要多大容量吗?SAN可以提供这种能力——购买大容量存储、分享给很多应用,并且可以调整大小并按需求重新发布。\n存储整合还是服务器整合\n某些CIO盘点数据中心运行了哪些东西时,可能会得出结论说大量的I/O容量被浪费了,这是把存储空间和I/O容量混为一谈了。毫无疑问的是,如果集中存储可以确保更好地利用存储资源,但这样做将会如何影响使用存储的系统?典型的数据库操作在性能上可以达到数量级的差异,因此可能会发现,如果集中存储可能需要增加10倍的服务器(或更多)才能处理原来的工作。尽管数据中心的I/O容量在SAN上可以更好地被利用,但是会导致其他系统无法充分被利用(例如数据库服务器花费大量时间等待I/O、应用程序服务器花费大量时间等待数据库,依此类推)。在现实中我们已经看到过很多通过分散存储来整合服务器并削减成本的例子。\n高可用\n有时人们认为SAN是高可用解决方案。之所以会这样认为,可能是因为对高可用的真实含义的理解出现了分歧,我们将在第12章给出建议。\n根据我们的经验,SAN经常与故障和停机联系在一起,这不是因为它们不可靠——它们没什么问题,也确实很少出故障——只是因为人们都不愿意相信这样的工程奇迹其实也会坏的,因而缺乏这方面的准备。此外,SAN有时是一个复杂的、神秘的黑盒子,当出问题的时候没有人知道该如何解决,并且价格昂贵,难以快速构建管理SAN所需的专业知识。大多数的SAN都对外缺乏可见性(就是个黑盒子),这也是为什么不应该只是简单地信任SAN管理员、支持人员或管理控制台的原因。我们看到过所有这三种人都错了的情况:当SAN出了问题,如出现硬盘驱动器故障导致性能下降(26)的案例。这是另一个推荐使用sysbench的理由:sysbench可以快速地完成一个I/O基准测试以证明是否是SAN的问题。\n服务器之间的交互\n共享存储可能会导致看似独立的系统实际上是相互影响的,有时甚至会很严重。例如,我们知道一个SAN用户有个很粗放的认识,当开发服务器上有I/O密集型操作时,会引起数据库服务器几乎陷于停顿。批处理作业、ALTER TABLE、备份——任何一个系统上产生大量的I/O操作都可能会导致其他系统的I/O资源不足。有时的影响远远比直觉想象的糟糕,一个看似不起眼的操作可能会导致严重的性能下降。\n成本\n成本是什么?管理和行政费用?每秒I/O操作数(IOPS)中每个I/O操作的成本?标价?\n有充分的理由使用SAN,但无论销售人员说什么,至少从MySQL需要的性能类型来看,SAN不是最佳的选择。(选择一个SAN供应商并跟它们的销售谈,你可能听到他们一般也是同意的,然后告诉你他们的产品是一个例外。)如果考虑性价比,结论会更加清楚,因为闪存存储或配置有电池支持写缓存的RAID控制器加上老式硬盘驱动器,可以在低得多的价格下提供更好的性能。\n关于这个话题,不要忘了让销售给你两台SAN的价格。至少需要两台,否则这台昂贵的SAN可能会成为故障中的单点。\n有许多“血泪史”可以引以为戒,这不是试图吓唬你远离SAN。我们知道的SAN用户都非常地爱这些存储!如果正在考虑是否使用SAN,最重要的事情是想清楚要解决什么问题。SAN可以做很多事情,但解决性能问题只是其中很小的一部分。相比之下,当不要求很多高性能的随机I/O,但是对某些功能感兴趣的话,如快照、存储整合、重复数据删除和虚拟化,SAN可能非常适合。\n因此,大多数Web应用不应该让数据库使用SAN,但SAN在所谓的企业级应用很受欢迎。企业通常不太受预算限制,所以能够负担得起作为“奢侈品”的SAN。(有时SAN甚至作为一种身份的象征!)\n9.8 使用多磁盘卷 # 我们迟早都会碰到文件应该放哪的问题,因为MySQL创建了多种类型的文件:\n数据和索引文件 事务日志文件 二进制日志文件 常规日志(例如,错误日志、查询日志和慢查询日志) 临时文件和临时表 MySQL没有提供复杂的空间管理功能。默认情况下,只是简单地把每个Schema的文件放入一个单独的目录。有少量选项来控制数据文件放哪。例如,可以指定MyISAM表的索引位置,也可以使用MySQL 5.1的分区表。\n如果正在使用InnoDB默认配置,所有的数据和文件都放在一组数据文件(共享表空间)中,只有表定义文件放在数据目录。因此,大部分用户把所有的数据和文件放在了单独的卷。\n然而,有时使用多个卷可以帮助解决I/O负载高的问题。例如,一个批处理作业需要写入很多数据到一张巨大的表,将这张表放在单独的卷上,可以避免其他查询的I/O受到影响。理想的情况下,应该分析不同数据的I/O访问类型,才能把数据放在适当的位置,但这很难做到,除非已经把数据放在不同的卷上。\n你可能已经听过标准建议,就是把事务日志和数据文件放在不同的卷上面,这样日志的顺序I/O和数据的随机I/O不会互相影响。但是除非有很多硬盘(20或更多)或者闪存存储,否则在这样做之前应该考虑清楚代价。\n二进制日志和数据文件分离的真正的优势,是减少事故中同时丢失数据和日志文件的可能性。如果RAID控制器上没有电池支持的写缓存,把它们分开是很好的做法。\n但是,如果有备用电池单元,分离卷就可能不是想象中那么必要了。性能差异很小是主要原因。这是因为即使有大量的事务日志写入,其中大部分写入都很小。因此,RAID缓存通常会合并I/O请求,通常只会得到每秒的物理顺序写请求。这通常不会干预数据文件的随机I/O,除非RAID控制器真的整体上饱和了。一般的日志,其中有连续的异步写入、负荷也低,可以较好地与数据分享一个卷。\n将日志放在独立的卷是否可以提升性能?通常情况下是的,但是从成本的角度来看这个问题,是否真的值得这么做,答案往往是否定的,尽管很多人不这么认为。\n原因是:为事务日志提供专门的硬盘是很昂贵的。假设有六个硬盘驱动器,最常规的做法是把所有六块盘放到一个RAID卷,或者分成两部分,四个放数据,两个放事务日志。不过如果这样做,就减少了三分之一的硬盘放数据文件,这会导致性能显著地下降。此外,专门提供两个驱动器,对负载的影响也微不足道(假设RAID控制器有电池支持的写缓存)。\n另一方面,如果有很多硬盘,投入一些给事务日志可能会从中受益。例如,一共有30块硬盘,可以分两块硬盘(配置为一个RAID 1的卷)给日志,能让日志写尽可能快。对于额外的性能,也可以在RAID控制器中分配一些写缓存空间给这个RAID卷。\n成本效益不是唯一考虑的因素。可能想保持InnoDB的数据和事务日志在同一个卷的另一个原因是,这种策略可以使用LVM快照做无锁的备份。某些文件系统允许一致的多卷快照,并且对这些文件系统,这是一个很轻量的操作,但对于ext3有很多东西需要注意。(也可以使用Percona XtraBackup来做无锁备份,关于此主题更多的信息,请参阅第15章)\n如果已经启用sync_binlog,二进制日志在性能方面与事务日志相似了。然而,二进制日志存储跟数据放在不同的卷,实际上是一个好主意——把它们分开存放更安全,因此即使数据丢失,二进制日志也可以保存下来。这样,可以使用二进制日志做基于时间点的恢复。这方面的考虑并不适用于InnoDB的事务日志,因为没有数据文件,它们就没用了,你不能将事务日志应用到昨晚的备份。(事务日志和二进制日志之间的区别在其他数据库的DBA看来,很难搞明白,在其他数据库这就是同一个东西。)\n另外一个常见的场景是分离出临时目录的文件,MySQL做filesorts(文件排序)和使用磁盘临时表时会写到临时目录。如果这些文件不会太大的话,最好把它们放在临时内存文件系统,如tmpfs。这是速度最快的选择。如果在你的系统上这不可行,就把它们放在操作系统盘上。\n典型的磁盘布局是有操作系统盘、交换分区和二进制日志的盘,它们放在RAID 1卷上。还要有一个单独的RAID 5或RAID 10卷,放其他的一切东西。\n9.9 网络配置 # 就像延迟和吞吐量是硬盘驱动器的限制因素一样,延迟和带宽(实际上和吞吐量是同一回事)也是网络连接的限制因素。对于大多数应用程序来说,最大的问题是延时。典型的应用程序都需要传输很多很小的网络包,并且每次传输的轻微延迟最终会被累加起来。\n运行不正常的网络通常也是主要的性能瓶颈之一。丢包是一个普遍存在的问题。即使1%的丢包率也足以造成显著的性能下降,因为在协议栈的各个层次都会利用各种策略尝试修复问题,例如等待一段时间再重新发送数据包,这就增加了额外的时间。另一个常见的问题是域名解析系统(DNS)损坏或者变慢了。\nDNS足以称为“阿基里斯之踵”,因此在生产服务器上启用skip_name_resolve是个好主意。损坏或缓慢的DNS解析对许多应用程序都是个问题,对MySQL尤为严重。当MySQL收到一个连接请求时,它同时需要做正向和反向DNS查找。有很多原因可能导致这个过程出问题。当问题出现时,会导致连接被拒绝,或者使得连接到服务器的过程变慢,这通常都会造成严重的影响,甚至相当于遭遇了拒绝服务攻击(DDOS)。如果启用skip_name_resolve选项,MySQL将不会做任何DNS查找的工作。然而,这也意味着,用户账户必须在host列使用具有唯一性的IP地址,“localhost”或者IP地址通配符。那些在host列使用主机名的用户账户都将不能登录。\n典型的Web应用中另一个常见的问题来源是TCP积压,可以通过MySQL的back_log选项来配置。这个选项控制MySQL的传入TCP连接队列的大小。在每秒有很多连接创建和销毁的环境中,默认值50是不够的。设置不够的症状是,客户端会看到零星的“连接被拒绝”的错误,配以三秒超时规则。在繁忙的系统中这个选项通常应加大。把这个选项增加到数百甚至数千,似乎没有任何副作用,事实上如果你看得远一些,可能还需要配置操作系统的TCP网络设置。在GNU/Linux系统,需要增加somaxconn限制,默认只有128,并且需要检查sysctl的tcp_max_syn_back_log设置(在本节稍后有一个例子)。\n应该设计性能良好的网络,而不是仅仅接受默认配置的性能。首先,分析节点之间有多少跳跃点,以及物理网络布局之间的映射关系。例如,假设有10个网页服务器,通过千兆以太网(1 GigE)连接到“网页”交换机,这个交换机也通过千兆网络连接到“数据库”交换机。如果不花时间去追踪连接,可能不会意识到从所有数据库服务器到所有网页服务器的总带宽是有限的!并且每次跨越交换机都会增加延时。\n监控网络性能和所有网络端口的错误是正确的做法,要监控服务器、路由器和交换机的每个端口。多路由流量绘图器(Multi Router Traffic Grapher),或者说MRTG( http://oss.oetiker.ch/mrtg/),对设备监控而言是个靠得住的开源解决方案。其他常见的网络性能监控工具(与设备监控不同)还有Smokeping( http://oss.oetiker.ch/smokeping/)和Cacti( http://www.cacti.net)。\n网络物理隔离也是很重要的因素。城际网络相比数据中心的局域网的延迟要大得多,即使从技术上来说带宽是一样的。如果节点真的相距甚远,光速也会造成影响。例如,在美国的西部和东部海岸都有数据中心,相隔约3000公里。光的速度是186000米每秒,因此一次通信不可能低于16毫秒,往返至少需要32毫秒。物理距离不仅是性能上的考虑,也包括设备之间通信的考虑。中继器、路由器和交换机,所有的性能都会有所降级。再次,越广泛地分隔开的网络节点,连接的不可预知和不可靠因素越大。\n尽可能避免实时的跨数据中心的操作是明智的(27)。如果不可能做到这一点,也应该确保应用程序能正常处理网络故障。例如,我们不希望看到由于Web服务器通过丢包严重的网络连接远程的数据中心时,由于Apache进程挂起而新建了很多进程的情况发生。\n在本地,请至少用千兆网络。骨干交换机之间可能需要使用万兆以太网。如果需要更大的带宽,可以使用网络链路聚合:连接多个网卡(NIC),以获得更多的带宽。链路聚合本质上是并行网络,作为高可用策略的一部分也很有帮助。\n如果需要非常高的吞吐量,也许可以通过改变操作系统的网络配置来提高性能。如果连接不多,但是有很多的查询和很大的结果集,则可以增加TCP缓冲区的大小。具体的实现依赖于操作系统,对于大多数的GNU/Linux系统,可以改变*/etc/sysctl.conf中的值并执行sysctl-p*,或者使用*/proc文件系统写入一个新的值到/proc/sys/net/*里面的文件。搜索“TCP tuning guide”,可以找到很多好的在线教程。\n然而,调整设置以有效地处理大量连接和小查询的情况通常更重要。比较常见的调整之一,就是要改变本地端口的范围。系统的默认值如下:\n[root@server ~]# ** cat /proc/sys/net/ipv4/ip_local_port_range** 32768 61000 有时你也许需要改变这些值,调整得更大一些。例如:\n[root@server ~]# ** echo 1024 65535 \u0026gt; /proc/sys/net/ipv4/ip_local_port_range** 如果要允许更多的连接进入队列,可以做如下操作:\n[root@server ~]# ** echo 4096 \u0026gt; /proc/sys/net/ipv4/tcp_max_syn_backlog** 对于只在本地使用的数据库服务器,对于连接端还未断开,但是通信已经中断的事件中使用的套接字,可以缩短TCP保持状态的超时时间。在大多数系统上默认是一分钟,这个时间太长了:\n[root@server ~]# ** echo \u0026lt;value\u0026gt; \u0026gt; /proc/sys/net/ipv4/tcp_fin_timeout** 这些设置大部分时间可以保留默认值。通常只有当发生了特殊情况,例如网络性能极差或非常大量的连接,才需要进行修改。在互联网上搜索“TCP variables”,可以发现很多不错的阅读资料,除了上面提到的,还能看到很多其他的变量。\n9.10 选择操作系统 # GNU/Linux如今是高性能MySQL最常用的操作系统,但是MySQL本身可以运行在很多操作系统上。\nSolaris是SPARC硬件上的领跑者,在x86硬件上也可以运行。Solaris常用在要求高可靠的应用上面。Solaris在某些方面的易用性可能没有GNU/Linux的名气大,但确实是一个坚固的操作系统,包含许多先进的功能。尤其是Solaris 10增加了ZFS文件系统,包含了很多先进的故障排除工具(如DTrace)、良好的多线程性能,以及称为Solaris Zones的虚拟化技术,有助于资源管理。\nFreeBSD是另一种选择。它历来与MySQL配合有一些问题,大多时候都涉及到线程支持,但新的版本要好得多。如今,看到MySQL在FreeBSD上大规模部署的场景并不是什么稀罕事。ZFS也可以在FreeBSD上使用。\n通常用于开发和桌面应用程序的MySQL选择的是Windows。也有企业级的MySQL部署在Windows上,但一般的企业级MySQL更多的还是部署在类UNIX操作系统上。我们不希望引起任何有关操作系统的争论,需要指出的是在异构操作系统环境中使用MySQL是不存在问题的。在类UNIX的操作系统上运行的MySQL服务器,同时在Windows上运行Web服务器,然后通过高品质的.NET连接器(这是MySQL免费提供的)进行连接,这是一个非常合理的架构。从UNIX连接到Windows上的MySQL服务器和连接到另一台UNIX上的MySQL服务器一样简单。\n当选择操作系统时,如果使用的是64位架构的硬件(见前面介绍的“CPU架构”),请确保安装的是64位版本的操作系统。\n谈到GNU/Linux发行版时,个人的喜好往往是决定性的因素。我们认为最好的策略是使用专为服务器应用程序设计的发行版,而不是桌面发行版。要考虑发行版的生命周期、发布和更新政策,并检查供应商的支持是否有效。红帽子企业版Linux是一个高品质、稳定的发行版;CentOS是一个受欢迎的二进制兼容替代品(免费),但已经因为延后时间较长获得了一些批评;还有Oracle发行的Oracle Enterprise Linux;另外,Ubuntu和Debian也是流行的发行版。\n9.11 选择文件系统 # 文件系统的选择非常依赖于操作系统。在许多系统中,如Windows就只有一到两个选择,而且只有一个(NTFS)真的是能用的。比较而言,GNU/Linux则支持多种文件系统。\n许多人想知道哪个文件系统在GNU/Linux上能提供最好的MySQL性能,或者更具体一些,哪个对InnoDB和MyISAM而言是最好的选择。实际的基准测试表明,大多数文件系统在很多方面都非常接近,但测试文件系统的性能确实是一件烦心事。文件系统的性能是与工作负载相关的,没有哪个文件系统是“银弹”。大部分情况下,给定的文件系统不会明显地表现得与其他文件系统不一样。除非遇到了文件系统的限制,例如,它怎么支持并发、怎么在多文件下工作、怎么对文件切片,等等。\n要考虑的更重要的问题是崩溃恢复时间,以及是否会遇到特定的限制,如目录下有许多文件会导致运行缓慢(这是ext2和旧版本ext3下一个臭名昭著的问题,但当前版本的ext3和ext4中用dir_index选项解决了问题)。文件系统的选择对确保数据安全是非常重要的,所以我们强烈建议不要在生产系统做实验。\n如果可能,最好使用日志文件系统,例如ext3、ext4、XFS、ZFS或者JFS。如果不这么做,崩溃后文件系统的检查可能耗费相当长的时间。如果系统不是很重要,非日志文件系统性能可能比支持事务的好。例如,ext2可能比ext3工作得好,或者可以使用tunefs关闭ext3的日志记录功能。挂载时间对某些文件系统也是一个因素。例如,ReiserFS,在一个大的分区上可能用很长时间来挂载和执行日志恢复。\n如果使用ext3或者其继承者ext4,有三个选项来控制数据怎么记日志,这可以放在*/etc/fstab*中作为挂载选项:\ndata=writeback\n这个选项意味着只有元数据写入日志。元数据写入和数据写入并不同步。这是最快的配置,对InnoDB来说通常是安全的,因为InnoDB有自己的事务日志。唯一的例外是,崩溃时恰好导致*.frm*文件损坏了。\n这里给出一个使用这个配置可能导致问题的例子。当程序决定扩展一个文件使其更大,元数据(文件大小)会在数据实际写到(更大的)文件之前记录并写下操作情况。结果就是文件的尾部——最新扩展的区域——会包含垃圾数据。\ndata=ordered\n这个选项也只会记录元数据,但提供了一些一致性保证,在写元数据之前会先写数据,使它们保持一致。这个选项只是略微比writeback选项慢,但是崩溃时更安全。\n在此配置中,如果我们再次假设程序想要扩展一个文件,该文件的元数据将不能反映文件的新大小,直到驻留在新扩展区域中的数据被写到文件中了。\ndata=journal\n此选项提供了原子日志的行为,在数据写入到最终位置之前将记录到日志中。这个选项通常是不必要的,它的开销远远高于其他两个选项。然而,在某些情况下反而可以提高性能,因为日志可以让文件系统延迟把数据写入最终位置的操作。\n不管哪种文件系统,都有一些特定的选项最好禁用,因为它们没有提供任何好处,反而增加了很多开销。最有名的是记录访问时间的选项,甚至读文件或目录时也要进行一次写操作。在*/etc/fstab中添加noatime、nodiratime挂载选项可以禁用此选项,这样做有时可以提高5%~10%的性能,具体取决于工作负载和文件系统(虽然在其他场景下差别可能不是太大)。下面是/etc/fstab*中的一个例子,对ext3选项做设置的行:\n/dev/sda2 /usr/lib/mysql ext3 noatime,nodiratime,data=writeback 0 1 还可以调整文件系统的预读的行为,因为这可能也是多余的。例如,InnoDB有自己的预读策略,所以文件系统的预读就是重复多余的。禁用或限制预读对Solaris的UFS尤其有利。使用O_DIRECT选项会自动禁用预读。\n一些文件系统可能不支持某些需要的功能。例如,若让InnoDB使用O_DIRECT刷新方式,文件系统能支持Direct I/O是非常重要的。此外,一些文件系统处理大量底层驱动器比其他的文件系统更好,举例来说XFS在这方面通常比ext3好。最后,如果打算使用LVM快照来初始化备库或进行备份,应该确认选择的文件系统和LVM版本能很好地协同工作。\n表9-4某些常见文件系统的特性总结。\n表9-4:常见文件系统特性 文件系统 操作系统 支持日志 大目录 ext2 GNU/Linux 否 否 ext3 GNU/Linux 可选 可选/部分 ext4 GNU/Linux 是 是 HFS Plus Mac OS 可选 是 JFS GNU/Linux 是 否 NTFS Windows 是 是 ReiserFS GNU/Linux 是 是 UFS (Solaris) Solaris 是 可调的 UFS (FreeBSD) FreeBSD 否 可选/部分 UFS2 FreeBSD 否 可选/部分 XFS GNU/Linux 是 是 ZFS Solaris, FreeBSD 是 是\n我们通常建议客户使用XFS文件系统。ext3文件系统有太多严重的限制,例如inode只有一个互斥变量,并且fsync()时会刷新所有脏块,而不只是单个文件。很多人感觉ext4文件系统用在生产环境有点太新了,不过现在似乎正日益普及。\n9.12 选择磁盘队列调度策略 # 在GNU/Linux上,队列调度决定了到块设备的请求实际上发送到底层设备的顺序。默认情况下使用cfq(Completely Fair Queueing,完全公平排队)策略。随意使用的笔记本和台式机使用这个调度策略没有问题,并且有助于防止I/O饥饿,但是用于服务器则是有问题的。在MySQL的工作负载类型下,cfq会导致很差的响应时间,因为会在队列中延迟一些不必要的请求。\n可以用下面的命令来查看系统所有支持的以及当前在用的调度策略:\n** $ cat /sys/block/sda/queue/scheduler** noop deadline [cfq] 这里sda需要替换成想查看的磁盘的盘符。在我们的例子中,方括号表示正在使用的调度策略。cfq之外的两个选项都适合服务器级的硬件,并且在大多数情况下,它们工作同样出色。noop调度适合没有自己的调度算法的设备,如硬件RAID控制器和SAN。deadline则对RAID控制器和直接使用的磁盘都工作良好。我们的基准测试显示,这两者之间的差异非常小。重要的是别用cfq,这可能会导致严重的性能问题。\n不过这个建议也需要有所保留的,因为磁盘调度策略实际上在不同的内核有很多不一样的地方,千万不能望文生义。\n9.13 线程 # MySQL每个连接使用一个线程,另外还有内部处理线程、特殊用途的线程,以及所有存储引擎创建的线程。在MySQL 5.5中,Oracle提供了一个线程池插件,但目前尚不清楚在实际应用中能获得什么好处。\n无论哪种方式,MySQL都需要大量的线程才能有效地工作。MySQL确实需要内核级线程的支持,而不只是用户级线程,这样才能更有效地使用多个CPU。另外也需要有效的同步原子,例如互斥变量。操作系统的线程库必须提供所有的这些功能。\nGNU/Linux提供两个线程库:LinuxThreads和新的原生POSIX线程库(NPTL)。LinuxThreads在某些情况下仍然使用,但现在的发行版已经切换到NPTL,并且大部分应用已经不再加载LinuxThreads。NPTL更轻量,更高效,也不会有那些LinuxThreads遇到的问题。\nFreeBSD会加载许多线程库。从历史上看,它对线程的支持很弱,但现在已经变得好多了,在一些测试中,甚至优于SMP系统上的GNU/Linux。在FreeBSD 6和更新版本,推荐的线程库是libthr,早期版本使用的linuxthreads,是FreeBSD从GNU/Linux上移植的LinuxThreads库。\n通常,线程问题都是过去的事了,现在GNU/Linux和FreeBSD都提供了很好的线程库。\nSolaris和Windows一直对线程有很好的支持,尽管直到5.5发布之前,MyISAM都不能在Windows下很好地使用线程,但5.5里有显著的提升。\n9.14 内存交换区 # 当操作系统因为没有足够的内存而将一些虚拟内存写到磁盘就会发生内存交换(28)。内存交换对操作系统中运行的进程是透明的。只有操作系统知道特定的虚拟内存地址是在物理内存还是硬盘。\n内存交换对MySQL性能影响是很糟糕的。它破坏了缓存在内存的目的,并且相对于使用很小的内存做缓存,使用交换区的性能更差。MySQL和存储引擎有很多算法来区别对待内存中的数据和硬盘上的数据,因为一般都假设内存数据访问代价更低。\n因为内存交换对用户进程不可见,MySQL(或存储引擎)并不知道数据实际上已经移动到磁盘,还会以为在内存中。\n结果会导致很差的性能。例如,若存储引擎认为数据依然在内存,可能觉得为“短暂”的内存操作锁定一个全局互斥变量(例如InnoDB缓冲池Mutex)是OK的。如果这个操作实际上引起了硬盘I/O,直到I/O操作完成前任何操作都会被挂起。这意味着内存交换比直接做硬盘I/O操作还要糟糕。\n在GNU/Linux上,可以用vmstat(在下一部分展示了一些例子)来监控内存交换。最好查看si和so列报告的内存交换I/O活动,这比看swpd列报告的交换区利用率更重要。swpd列可以展示那些被载入了但是没有被使用的进程,它们并不是真的会成为问题。我们喜欢si和so列的值为0,并且一定要保证它们低于每秒10个块。\n极端的场景下,太多的内存交换可能导致操作系统交换空间溢出。如果发生了这种情况,缺乏虚拟内存可能让MySQL崩溃。但是即使交换空间没有溢出,非常活跃的内存交换也会导致整个操作系统变得无法响应,到这种时候甚至不能登录系统去杀掉MySQL进程。有时当交换空间溢出时,甚至Linux内核都会完全hang住。\n绝不要让系统的虚拟内存溢出!对交换空间利用率做好监控和报警。如果不知道需要多少交换空间,就在硬盘上尽可能多地分配空间,这不会对性能造成冲击,只是消耗了硬盘空间。有些大的组织清楚地知道内存消耗将有多大,并且内存交换被非常严格地控制,但是对于只有少量多用途的MySQL实例,并且工作负载也多种多样的环境,通常不切实际。如果后者的描述更符合实际情况,确认给服务器一些“呼吸”的空间,分配足够的交换空间。\n在特别大的内存压力下经常发生的另一件事是内存不足(OOM),这会导致踢掉和杀掉一些进程。在MySQL进程这很常见。在另外的进程上也挺常见,比如SSH,甚至会让系统不能从网络访问。可以通过设置SSH进程的oom_adj或oom_score_adj值来避免这种情况。\n可以通过正确地配置MySQL缓冲来解决大部分内存交换问题,但是有时操作系统的虚拟内存系统还是会决定交换MySQL的内存。这通常发生在操作系统看到MySQL发出了大量I/O,因此尝试增加文件缓存来保存更多数据时。如果没有足够的内存,有些东西就必须被交换出去,有些可能就是MySQL本身。有些老的Linux内核版本也有一些适得其反的优先级,导致本不应该被交换的被交换出去,但是在最近的内核都被缓解了。\n有些人主张完全禁用交换文件。尽管这样做有时在某些内核拒绝工作的极端场景下是可行的,但这降低了操作系统的性能(在理论上不会,但是实际上会的)。同时这样做也是很危险的,因为禁用内存交换就相当于给虚拟内存设置了一个不可动摇的限制。如果MySQL需要临时使用很大一块内存,或者有很耗内存的进程运行在同一台机器(如夜间批量任务),MySQL可能会内存溢出、崩溃,或者被操作系统杀死。\n操作系统通常允许对虚拟内存和I/O进行一些控制。我们提供过一些GNU/Linux上控制它们的办法。最基本的方法是修改*/proc/sys/vm/swappiness*为一个很小的值,例如0或1。这告诉内核除非虚拟内存完全满了,否则不要使用交换区。下面是如何检查这个值的例子:\n** $ cat /proc/sys/vm/swappiness** 60 这个值显示为60,这是默认的设置(范围是0~100)。对服务器而言这是个很糟糕的默认值。这个值只对笔记本适用。服务器应该设置为0:\n** $ echo 0 \u0026gt; /proc/sys/vm/swappiness** 另一个选项是修改存储引擎怎么读取和写入数据。例如,使用innodb_flush_method=O_DIRECT,减轻I/O压力。Direct I/O并不缓存,因此操作系统并不能把MySQL视为增加文件缓存的原因。这个参数只对InnoDB有效。你也可以使用大页,不参与换入换出。这对MyISAM和InnoDB都有效。\n另一个选择是使用MySQL的memlock配置项,可以把MySQL锁定在内存。这可以避免交换,但是也可能带来危险:如果没有足够的可锁定内存,MySQL在尝试分配更多内存时会崩溃。这也可能导致锁定的内存太多而没有足够的内存留给操作系统。\n很多技巧都是对于特定内核版本的,因此要小心,尤其是当升级的时候。在某些工作负载下,很难让操作系统的行为合情合理,并且仅有的资源可能让缓冲大小达不到最满意的值。\n9.15 操作系统状态 # 操作系统会提供一些帮助发现操作系统和硬件正在做什么的工具。在这一节,我们会展示一些例子,包括关于怎样使用两个最常用的工具——iostat和vmstat。如果系统不提供它们中的任何一个,有可能提供了相似的替代品。因此,我们的目的不是让大家熟练使用iostat和vmstat,而是告诉你用类似的工具诊断问题时应该看什么指标。\n除了这些,操作系统也许还提供了其他的工具,如mpstat或者sar。如果对系统的其他部分感兴趣,例如网络,你可能希望使用ifconfig(除了其他信息,它能显示发生了多少次网络错误)或者netstat。\n默认情况下,vmstat和iostat只是生成一个报告,展示自系统启动以来很多计数器的平均值,这其实没什么用。然而,两个工具都可以给出一个间隔参数,让它们生成增量值的报告,展示服务器正在做什么,这更有用。(第一行显示的是系统启动以来的统计,通常可以忽略这一行。)\n9.15.1 如何阅读vmstat的输出 # 我们先看一个vmstat的例子。用下面的命令让它每5秒钟打印出一个报告:\n可以用Ctrl+C停止vmstat。可以看到输出依赖于所用的操作系统,因此可能需要阅读一下手册来解读报告。\n刚启动不久,即使采用增量报告,第一行的值还是显示自系统启动以来的平均值,第二行开始展示现在正在发生的情况,接下来的行会展示每5秒的间隔内发生了什么。每一列的含义在头部,如下所示:\nprocs\nr这一列显示了多少进程正在等待CPU,b列显示多少进程正在不可中断地休眠(通常意味着它们在等待I/O,例如磁盘、网络、用户输入,等等)。\nmemory\nswpd列显示多少块被换出到了磁盘(页面交换)。剩下的三个列显示了多少块是空闲的(未被使用)、多少块正在被用作缓冲,以及多少正在被用作操作系统的缓存。\nswap\n这些列显示页面交换活动:每秒有多少块正在被换入(从磁盘)和换出(到磁盘)。它们比监控swpd列重要多了。\n大部分时间我们喜欢看到si和so列是0,并且我们很明确不希望看到每秒超过10个块。突发性的高峰一样很糟糕。\nio\n这些列显示有多少块从块设备读取(bi)和写出(bo)。这通常反映了硬盘I/O。\nsystem\n这些列显示了每秒中断(in)和上下文切换(cs)的数量。\ncpu\n这些列显示所有的CPU时间花费在各类操作的百分比,包括执行用户代码(非内核)、执行系统代码(内核)、空闲,以及等待I/O。如果正在使用虚拟化,则第五个列可能是st,显示了从虚拟机中“偷走”的百分比。这关系到那些虚拟机想运行但是系统管理程序转而运行其他的对象的时间。如果虚拟机不希望运行任何对象,但是系统管理程序运行了其他对象,这不算被偷走的CPU时间。\nvmstat的输出跟系统有关,所以如果看到跟我们展示的例子不同的输出,应该阅读系统的vmstat(8)手册。一个重要的提示是:内存、交换区,以及I/O统计是块数而不是字节。在GNU/Linux,块大小通常是1 024字节。\n9.15.2 如何阅读iostat的输出 # 现在让我们转移到iostat(29)。默认情况下,它显示了与vmstat相同的CPU使用信息。我们通常只是对I/O统计感兴趣,所以使用下面的命令只展示扩展的设备统计:\n** $ iostat -dx 5** Device: rrqm/s wrqm/s r/s w/s rsec/s wsec/s avgrq-sz avgqu-sz await svctm %util sda 1.6 2.8 2.5 1.8 138.8 36.9 40.7 0.1 23.2 6.0 2.6 与vmstat一样,第一行报告显示的是自系统启动以来的平均值(通常删掉它节省空间),然后接下来的报告显示了增量的平均值,每个设备一行。\n有多种选项显示和隐藏列。官方文档有点难以理解,因此我们必须从源码中挖掘真正显示的内容是什么。说明的列信息如下:\nrrqm/s和wrqm/s\n每秒合并的读和写请求。“合并的”意味着操作系统从队列中拿出多个逻辑请求合并为一个请求到实际磁盘。\nr/s和w/s\n每秒发送到设备的读和写请求。\nrsec/s和wsec/s\n每秒读和写的扇区数。有些系统也输出为rkB/s和wkB/s,意为每秒读写的千字节数。为了简洁,我们省略了那些指标说明。\navgrq-sz\n请求的扇区数。\navgqu-sz\n在设备队列中等待的请求数。\nawait\n磁盘排队上花费的毫秒数。很不幸,iostat没有独立统计读和写的请求,它们实际上不应该被一起平均。当你诊断性能案例时这通常很重要。\nsvctm\n服务请求花费的毫秒数,不包括排队时间。\n%util\n至少有一个活跃请求所占时间的百分比。如果熟悉队列理论中利用率的标准定义,那么这个命名很莫名其妙。它其实不是设备的利用率。超过一块硬盘的设备(例如RAID控制器)比一块硬盘的设备可以支持更高的并发,但是%util从来不会超过100%,除非在计算时有四舍五入的错误。因此,这个指标无法真实反映设备的利用率,实际上跟文档说的相反,除非只有一块物理磁盘的特殊例子。\n可以用iostat的输出推断某些关于机器I/O子系统的实际情况。一个重要的度量标准是请求服务的并发数。因为读写的单位是每秒而服务时间的单位是千分之一秒,所以可以利用利特尔法则(Little\u0026rsquo;s Law)得到下面的公式,计算出设备服务的并发请求数(30):\nconcurrency = (r/s + w/s) * (svctm/1000) 这是一个iostat的输出示例:\nDevice: rrqm/s wrqm/s r/s w/s rsec/s wsec/s avgrq-sz avgqu-sz await svctm %util sda 105 311 298 820 3236 9052 10 127 113 9 96 把数字带入并发公式,可以得到差不多9.6的并发性(31)。这意味着在一个采样周期内,这个设备平均要服务9.6次的请求。例子来自于一个10块盘的RAID 10卷,所以操作系统对这个设备的并行请求运行得相当好。另一方面,这是一个出现串行请求的设备:\nDevice: rrqm/s wrqm/s r/s w/s rsec/s wsec/s avgrq-sz avgqu-sz await svctm %util sdc 81 0 280 0 3164 0 11 2 7 3 99 并发公式展示了这个设备每秒只处理一个请求。两个设备都接近于满负荷利用,但是它们的性能表现完全不一样。如果设备一直都像这些例子展示的一样忙,那么应该检查一下并发性,不管是不是接近于设备中的物理盘数,都需要注意。更低的值则说明有如文件系统串行的问题,就像我们前面讨论的。\n9.15.3 其他有用的工具 # 我们展示vmstat和iostat是因为它们部署最广泛,并且vmstat通常默认安装在许多类UNIX操作系统上。然而,每种工具都有自身的限制,例如莫名奇妙的度量单位、当操作系统更新统计时取样间隔不一致,以及不能一次性看到所有重要的点。如果这些工具不能符合需求,你可能会对*dstat(http://dag.wieers.com/home-made/dstat/)或collectl (http://collectl.sourceforge.net)*感兴趣。\n我们也喜欢用mpstat来观察CPU统计;它提供了更好的办法来观察CPU每个工作是如何进行的,而不是把它们搅在一块。有时在诊断问题时这非常重要。当分析硬盘I/O利用的时候,blktrace可能也非常有用。\n我们自己开发了iostat的替代品,叫做pt-diskstats。这是Percona Toolkit的一部分。它解决了一些对iostat的抱怨,例如显示读写统计的方式,以及缺乏对并发量的可见性。它也是交互式的,并且是按键驱动的,所以可以放大缩小、改变聚集、过滤设备,以及显示和隐藏列。即使没有安装这个工具,也可以通过简单的Shell脚本来收集一些硬盘统计状态,这个工具也支持分析这样采集出来的文本。可以抓取一些硬盘活动样本,然后发邮件或者保存起来,稍后分析。实际上,我们第3章中介绍的pt-stalk、pt-collect、和pt-sift三件套,都被设计得可以跟pt-diskstats很好地配合。\n9.15.4 CPU密集型的机器 # CPU密集型服务器的vmstat输出通常在us列会有一个很高的值,报告了花费在非内核代码上的CPU时钟;也可能在sy列有很高的值,表示系统CPU利用率,超过20%就足以令人不安了。在大部分情况下,也会有进程队列排队时间(在r列报告的)。下面是一个例子:\n注意,这里也有合理数量的上下文切换(在cs列),除非每秒超过100000次或更多,一般都不用担心上下文切换。当操作系统停止一个进程转而运行另一个进程时,就会产生上下文切换。\n例如,一查询语句在MyISAM上执行了一个非覆盖索引扫描,就会先从索引中读取元素,然后根据索引再从磁盘上读取页面。如果页面不在操作系统缓存中,就需要从磁盘进行物理读取,这就会导致上下文切换中断进程处理,直到I/O完成。这样一个查询可以导致大量的上下文切换。\n如果我们在同一台机器观察iostat的输出(再次剔除显示启动以来平均值的第一行),可以发现磁盘利用率低于50%:\n这台机器不是I/O密集型的,但是依然有相当数量的I/O发生,在数据库服务器中这种情况很少见。另一方面,传统的Web服务器会消耗大量CPU资源,但是很少发生I/O,所以Web服务器的输出不会像这个例子。\n9.15.5 I/O密集型的机器 # 在I/O密集型工作负载下,CPU花费大量时间在等待I/O请求完成。这意味着vmstat会显示很多处理器在非中断休眠(b列)状态,并且在wa这一列的值很高,下面是个例子:\n这台机器的iostat输出显示硬盘一直很忙:(32)\n%util的值可能因为四舍五入的错误超过100%。什么迹象意味着机器是I/O密集的呢?只要有足够的缓冲来服务写请求,即使机器正在做大量的写操作,也可能可以满足,但是却通常意味着硬盘可能会无法满足读请求。这听起来好象违反直觉,但是如果思考读和写的本质,就不会这么认为了:\n写请求能够缓冲或同步操作。它们可以被我们本书讨论过的任意一层缓冲:操作系统层、RAID控制器层,等等。 读请求就其本质而言都是同步的。当然程序可以猜测到可能需要某些数据,并异步地提前读取(预读)。无论如何,通常程序在继续工作前必须得到它们需要的数据。这就强制读请求为同步操作:程序必须被阻塞直到请求完成。 想想这种方式:你可以发出一个写请求到缓冲区的某个地方,然后过一会完成。甚至可以每秒发出很多这样的请求。如果缓冲区正确工作,并且有足够的空间,每个请求都可以很快完成,并且实际上写到物理硬盘是被重新排序后更有效地批量操作的。然而,没有办法对读操作这么做——不管多小或多少的请求,都不可能让硬盘响应说“这是你的数据,我等一会读它”。这就是为什么读需要I/O等待是可以理解的原因。\n9.15.6 发生内存交换的机器 # 一台正在发生内存交换的机器可能在swpd列有一个很高的值,也可能不高。但是可以看到si和so列有很高的值,这是我们不希望看到的。下面是一台内存交换严重的机器的vmstat输出:\n9.15.7 空闲的机器 # 为完整起见,下面也给出一台空闲机器上的vmstat输出。注意,没有在运行或被阻塞的进程,idle列显示CPU是100%的空闲。这个例子来源于一台运行红帽子企业版Linux 5(RHEL 5)的机器,并且st列展示了从“虚拟机”偷来的时间。\n9.16 总结 # 为MySQL选择和配置硬件,以及根据硬件配置MySQL,并不是什么神秘的艺术。通常,对于大部分目标需要的都是相同的技巧和知识。当然,也需要知道一些MySQL特有的特点。\n我们通常建议大部分人在性能和成本之间找到一个好的平衡点。首先,出于多种原因,我们喜欢使用廉价服务器。举个例子,如果在使用服务器的过程中遇到了麻烦,并且在诊断时需要停止服务,或者希望只是简单地把出问题的服务器用另一台替换,如果使用的是一台$5000的廉价服务器,肯定比使用一台超过$50000或者更贵的服务器要简单得多。MySQL通常也更适应廉价服务器,不管是从软件自身而言还是从典型的工作负载而言。\nMySQL需要的四种基本资源是:CPU、内存、硬盘以及网络资源。网络一般不会作为很严重的瓶颈出现,而CPU、内存和磁盘通常是主要的瓶颈所在。对MySQL而言,通常希望有很多快速CPU可以用,但如果必须在快和多之间做选择,则一般会选择更快而不是更多(其他条件相同的情况下)。\nCPU、内存以及磁盘之间的关系错综复杂,一个地方的问题可能会在其他地方显现出来。在对一个资源抛出问题时,问问自己是不是可能是由另外的问题导致的。如果遇到硬盘密集的操作,需要更多的I/O容量吗?或者是更多的内存?答案取决于工作集大小,也就是给定的时间内最常用的数据集。\n在本书写作的过程中,我们觉得以下做法是合理的。首先,通常不要超过两个插槽。现在即使双路系统也可以提供很多CPU核心和硬件线程了,而且四路服务器的CPU要贵得多。另外,四路CPU的使用不够广泛(也就意味着缺少测试和可靠性),并且使用的是更低的时钟频率。最终,四路插槽的系统跨插槽的同步开销也显著增加。在内存方面,我们喜欢用价格经济的服务器内存。许多廉价服务器目前有18个DIMM槽,单条8GB的DIMM是最好的选择——每GB的价格与更低容量的DIMM相比差不多,但是比16GB的DIMM便宜多了。这是为什么我们今天看到很多服务器是144GB的内存的原因。这个等式会随着时间的变化而变化——可能有一天具有最佳性价比的是16GB的DIMM,并且服务器出厂的内存槽数量也可能不一样——但是一般的原则还是一样的。\n持久化存储的选择本质上归结为三个选项,以提高性能的次序排序:SAN、传统硬盘,以及固态存储设备。\n当需要功能和纯粹的容量时,SAN是不错的。它们对许多工作负载都运行得不错,但缺点是很昂贵,并且对小的随机I/O操作有很大的延时,尤其是使用更慢的互联方式(如NFS)或工作集太大不足以匹配SAN内存的缓存时,延时会更大。要注意SAN的性能突变的情况,并且要非常小心避免灾难的场景。 传统硬盘很大,便宜,但是对随机读很慢。对大部分场景,最好的选择是服务器硬盘组成RAID 10卷。通常应该使用带有电池保护单元的RAID控制器,并且设置写缓存为WriteBack策略。这样一个配置对大部分工作负载都可以运行良好。 固态盘相对比较小并且昂贵,但是随机I/O非常快。一般分为两类:SSD和PCIe设备。广泛地来说,SSD更便宜,更慢,但缺少可靠性验证。需要对SSD做RAID以提升可靠性,但是大多数硬件RAID控制器不擅长这个任务(33)。PCIe设备很昂贵并且有容量限制,但是非常快并且可靠,而且不需要RAID。 固态存储设备可以很大地提升服务器整体性能。有时候一个不算昂贵的SSD,可以帮助解决经常在传统硬盘上遇到的特定工作负载的问题,如复制。如果真的需要很强的性能,应该使用PCIe设备。增加高速I/O设备会把服务器的性能瓶颈转移到CPU,有时也会转移到网络。\nMySQL和InnoDB并不能完全发挥高端固态存储设备的性能,并且在某些场景下操作系统也不能发挥。但是提升依然很明显。Percona Server对固态存储做了很多改进,并且很多改进在5.6发布时已经进入了MySQL主干代码。\n对操作系统而言,只有很少的一些重要配置需要关注,大部分是关于存储、网络和虚拟内存管理的。如果像大部分MySQL用户一样使用GNU/Linux,建议采用XFS文件系统,并且为服务器的页面交换倾向率(swapiness)和硬盘队列调度器设置恰当的值。有一些网络参数需要改变,可能还有一些其他的地方(例如禁用SELinux)需要调优,但是前面说的那些改动的优先级应该更高一些。\n————————————————————\n(1) 普通PC Server也能配到192GB内存。——译者注\n(2) 网络吞吐也是一种I/O。——译者注\n(3) 超线程技术。——译者注\n(4) 然而,程序可能依赖大量在操作系统内存中缓存的数据,对程序来说,概念上属于“在磁盘上”的数据。例如,MyISAM就是这么做的,它把数据文件放在磁盘上,并通过操作系统缓存磁盘上的数据,使其访问速度更快。\n(5) 正确的数字是11%而不是10%。10%的未命中率对应90%的命中率,所以你需要用10GB除以90%,就是11.111GB。\n(6) 有趣的是,有些人故意买更大容量的磁盘,然后只使用20%~30%的容量。这增加了数据局部性和减少寻道时间,有时可以证明值得它们高的价格。\n(7) 5.6也可以按库做多线程复制。——译者注\n(8) 有些公司声称,他们抛弃过去主轴(机械)的羁绊,从一个干净的石板开始。温和的怀疑是有道理的;解决RDBMS的挑战是不容易的。\n(9) 这是一种简化,但细节在这里并不重要。如果你喜欢,可以阅读维基百科上的更多信息。\n(10) 但这不是全部。我们在基准测试后检查了驱动器,并且发现两块SSD坏盘,有一块不一致。\n(11) 意思就是内存放不下要缓存的数据时,换出到Flashcache上,Flashache的闪存设备可以帮助继续缓存,而不会立刻落到磁盘。——译者注\n(12) 分区(看第7章)是另一个好办法,因为它通常把文件分成多份,你可以放在不同的磁盘上。但是,相对于分区,RAID对于很大数据是一个更简单的解决方案。这不需要你手动进行负载平衡或者在负载分布发生变化时进行干预,并且可以提供冗余,而你不能把分区文件放在不同的磁盘。\n(13) 两个很好的RAID学习资源是维基百科上的文章( http://en.wikipedia.org/wiki/RAID)和AC&NC教程 http://www.acnc.com/04_00.html。\n(14) 因为读取并不需要写校验位。——译者注\n(15) 意思是损失一块盘,读取的时候本来可以从相互镜像的两块盘中同时读,少了一块盘就只能从另一块镜像盘上去读了。——译者注\n(16) 尤其是SSD盘,同时损坏的可能性是比较大的。——译者注\n(17) 例如一份数据的两个镜像就在这两个盘上。——译者注\n(18) 就是每个服务器上的数据都会被业务使用,没有机器作为备用的。——译者注\n(19) 就是先做五个两块盘的RAID 1,然后再把五个镜像对做成RAID 0,形成RAID 10。——译者注\n(20) 就是做五个两块盘的RAID 1,然后交给操作系统使用。——译者注\n(21) 有些RAID卡不支持直接做RAID 10,只能做成几组RAID 1,然后由操作系统LVM再做RAID 0,最终形成RAID 10。——译者注\n(22) 可以缓冲随机I/O部分合并为顺序I/O。——译者注\n(23) 因为没有充分地合并I/O。——译者注\n(24) 有几种技术,包括电容器和闪存存储,但这里我们都归结到BBU这一类。。\n(25) 就是fsync只是刷新到了硬盘上的缓存,这个缓存是没有电池的,所以掉电会丢失数据。——译者注\n(26) 基于网络的SAN管理控制台坚持所有硬盘驱动器是健康的——直到我们要求管理员按Shift+F5来禁用他的浏览器缓存并强制刷新控制台!\n(27) 复制不算实时跨数据中心操作,它不是实时的,并且通常把数据复制到一个远程位置有助于提升数据安全性(容灾)。我们下一章会更多覆盖这个内容。\n(28) 内存交换有时称为页面交换。从技术上来说,它们是不同的东西,但是人们通常把它们作为同义词。\n(29) 我们本书展示的iostat的例子为了印刷被稍微重排了:我们减少了小数位来避免换行。我们是在GNU/Linux上展示例子。其他操作系统输出可能不完全一样。\n(30) 另一种计算并发的方式是通过平均队列大小、服务时间,以及平均等待时间:(avuqu_sz*svctm)/await。\n(31) 如果你做这个计算,会得到大约10,因为为了格式化我们已经取整了iostat的输出。相信我们,确实是9.6。\n(32) 在书的第二版中,我们混淆了“总是很忙”和“完全饱和”。总是在做事的硬盘并不总是达到极限,因为它们可能也能支持一些并发。\n(33) 有些RAID控制器对SSD支持很差,做了RAID性能下降。——译者注\n"},{"id":139,"href":"/zh/docs/technology/MySQL/_%E9%AB%98%E6%80%A7%E8%83%BDMySQL_/%E7%AC%AC8%E7%AB%A0%E4%BC%98%E5%8C%96%E6%9C%8D%E5%8A%A1%E5%99%A8%E8%AE%BE%E7%BD%AE/","title":"第8章优化服务器设置","section":"高性能 My SQL","content":"第8章 优化服务器设置\n在这一章,我们将解释为这是我的撒旦JFK数据库嘎斯公开就开始打山豆根士大夫 圣诞节复活节是是国家开始大幅机啊可是对方看见噶开暗杀是的JFK开始讲课的感觉爱看书的JFK史蒂夫卡卡萨丁咖啡碱撒快递费始东方会i二位人家儿童科技数据库的房价开始JFK注释MySQL服务器创建一个靠谱的配置文件的过程。这是一个很绕的过程,有很多有意思的关注点和值得关注的思路。关注这些点很有必要,因为创建一个好配置的最快方法不是从学习配置项开始,也不是从问哪个配置项应该怎么设置或者怎么修改开始,更不是从检查服务器行为和询问哪个配置项可以提升性能开始。最好是从理解MySQL内核和行为开始。然后可以利用这些知识来指导配置MySQL。最后,可以将想要的配置和当前配置进行比较,然后纠正重要并且有价值的不同之处。\n人们经常问,“我的服务器有32GB内存,12核CPU,怎样配置最好?”很遗憾,问题没这么简单。服务器的配置应该符合它的工作负载、数据,以及应用需求,并不仅仅看硬件的情况。\nMySQL有大量可以修改的参数——但不应该随便去修改。通常只需要把基本的项配置正确(大部分情况下只有很少一些参数是真正重要的),应该将更多的时间花在schema的优化、索引,以及查询设计上。在正确地配置了MySQL的基本配置项之后,再花力气去修改其他配置项的收益通常就比较小了。\n从另一方面来说,没用的配置导致潜在风险的可能更大。我们碰到过不止一个“高度调优”过的服务器不停地崩溃,停止服务或者运行缓慢,结果都是因为错误的配置导致的。我们将花一点时间来解释为什么会发生这种情况,并且告诉大家什么是不该做的。\n那么什么是该做的呢?确保基本的配置是正确的,例如InnoDB的Buffer Pool和日志文件缓存大小,如果想防止出问题(提醒一下,这样做通常不能提升性能——它们只能避免问题),就设置一个比较安全和稳健的值,剩下的配置就不用管了。如果碰到了问题,可以使用第3章提到的技巧小心地进行诊断。如果问题是由于服务器的某部分导致的,而这恰好可以通过某个配置项解决,那么需要做的就是更改配置。\n有时候,在某些特定的场景下,也有可能设置某些特殊的配置项会有显著的性能提升。但无论如何,这些特殊的配置项不应该成为服务器基本配置文件的一部分。只有当发现特定的性能问题才应该设置它们。这就是为什么我们不建议通过寻找有问题的地方修改配置项的原因。如果有些地方确实需要提升,也需要在查询响应时间上有所体现。最好是从查询语句和响应时间入手来开始分析问题,而不是通过配置项。这可以节省大量的时间,避免很多的问题。\n另一个节省时间和避免麻烦的好办法是使用默认配置,除非是明确地知道默认值会有问题。很多人都是在默认配置下运行的,这种情况非常普遍。这使得默认配置是经过最多实际测试的。对配置项做一些不必要的修改可能会遇到一些意料之外的bug。\n8.1 MySQL配置的工作原理 # 在讨论如何配置MySQL之前,我们先来解释一下MySQL的配置机制。MySQL对配置要求非常宽松,但是下面这些建议可能会为你节省大量的工作和时间。\n首先应该知道的是MySQL从哪里获得配置信息:命令行参数和配置文件。在类UNIX系统中,配置文件的位置一般在*/etc/my.cnf或者/etc/mysql/my.cnf*。如果使用操作系统的启动脚本,这通常是唯一指定配置设置的地方。如果手动启动MySQL,例如在测试安装时,也可以在命令行指定设置。实际上,服务器会读取配置文件的内容,删除所有注释和换行,然后和命令行选项一起处理。\n关于术语的说明:因为很多MySQL命令行选项跟服务器变量相同,我们有时把选项和变量替换使用。大部分变量和它们对应的命令行选项名称一样,但是有一些例外。例如,\u0026ndash;memlock选项设置了locked_in_memory变量。\n任何打算长期使用的设置都应该写到全局配置文件,而不是在命令行特别指定。否则,如果偶然在启动时忘了设置就会有风险。把所有的配置文件放在同一个地方以方便检查也是个好办法。\n一定要清楚地知道服务器配置文件的位置!我们见过有些人尝试修改配置文件但是不生效,因为他们修改的并不是服务器读取的文件,例如Debian下,/etc/mysql/my.cnf才是MySQL读取的配置文件,而不是*/etc/my.cnf*。有时候好几个地方都有配置文件,也许是因为之前的系统管理员也没搞清楚情况(因此在各个可能的位置都放了一份)。如果不知道当前使用的配置文件路径,可以尝试下面的操作:\n** $ which mysqld** /usr/sbin/mysqld ** $ /usr/sbin/mysqld --verbose --help | grep -A 1 'Default options'** Default options are read from the following files in the given order: /etc/mysql/my.cnf ~/.my.cnf /usr/etc/my.cnf 对于服务器上只有一个MySQL实例的典型安装,这个命令很有用。也可以设计更复杂的配置,但是没有标准的方法告诉你怎么来做。MySQL发行版包含了一个现在废弃了的程序,叫mysqlmanager,可以在一个有多个独立部分的配置文件上运行多个实例。(现在已经被一样古老的mysqld_multi脚本替代。)然而许多操作系统发行版本在启动脚本中并不包含或使用这个程序。实际上,很多系统甚至没有使用MySQL提供的启动脚本。\n配置文件通常分成多个部分,每个部分的开头是一个用方括号括起来的分段名称。MySQL程序通常读取跟它同名的分段部分,许多客户端程序还会读取client部分,这是一个存放公用设置的地方。服务器通常读取mysqld这一段。一定要确认配置项放在了文件正确的分段中,否则配置是不会生效的。\n8.1.1 语法、作用域和动态性 # 配置项设置都使用小写,单词之间用下画线或横线隔开。下面的例子是等价的,并且可能在命令行和配置文件中都看到这两种格式:\n/usr/sbin/mysqld --auto-increment-offset=5 /usr/sbin/mysqld --auto-increment-offset=5 我们建议使用一种固定的风格。这样在配置文件中搜索配置项时会容易得多。\n配置项可以有多个作用域。有些设置是服务器级的(全局作用域),有些对每个连接是不同的(会话作用域),剩下的一些是对象级的。许多会话级变量跟全局变量相等,可以认为是默认值。如果改变会话级变量,它只影响改动的当前连接,当连接关闭时所有参数变更都会失效。下面有一些例子,你应该清楚这些不同类型的行为:\nquery_cache_sizey变量是全局的。 sort_buffer_sizey变量默认是全局相同的,但是每个线程里也可以设置。 join_buffer_sizey变量也有全局默认值且每个线程是可以设置的,但是若一个查询中关联多张表,可以为每个关联分配一个关联缓冲(join buffer),所以每个查询可能有多个关联缓冲。 另外,除了在配置文件中设置变量,有很多变量(但不是所有)也可以在服务器运行时修改。MySQL把这些归为动态配置变量。下面的语句展示了动态改变sort_buffer_size的会话值和全局值的不同方式:\nSET sort_buffer_size = \u0026lt;* value* \u0026gt;; SET GLOBAL sort_buffer_size = \u0026lt;* value* \u0026gt;; SET @@sort_buffer_size := \u0026lt;* value* \u0026gt;; SET @@session.sort_buffer_size := \u0026lt;* value* \u0026gt;; SET @@global.sort_buffer_size := \u0026lt;* value* \u0026gt;; 如果动态地设置变量,要注意MySQL关闭时可能丢失这些设置。如果想保持这些设置,还是需要修改配置文件。\n如果在服务器运行时修改了变量的全局值,这个值对当前会话和其他任何已经存在的会话都不起效果,这是因为会话的变量值是在连接创建时从全局值初始化来的。在每次变更之后,应该检查SHOW GLOBAL VARIABLES的输出,确认已经按照期望变更了。\n有些变量使用了不同的单位,所以必须知道每个变量的正确单位。例如,table_cache变量指定了表可以被缓存的数量,而不是表可以被缓存的字节数。key_buffer_size则是以字节为单位,还有一些其他变量指定的是页的数量或者其他单位,例如百分比。\n许多变量可以通过后缀指定单位,例如1M表示一百万字节。然而,这只能在配置文件或者作为命令行参数时有效。当使用SQL的SET命令时,必须使用数字值1048576,或者1024*1024这样的表达式。但在配置文件中不能使用表达式。\n有个特殊的值可以通过SET命令赋值给变量:DEFAULT。把这个值赋给会话级变量可以把变量改为使用全局值,把它赋值给全局变量可以设置这个变量为编译内置的默认值(不是在配置文件中指定的值)。当需要重置会话级变量的值回到连接刚打开的时候,这是很有用的。建议不要对全局变量这么用,因为可能它做的事不是你希望的,它不会把值设置到服务器刚启动时候的那个状态。\n8.1.2 设置变量的副作用 # 动态设置变量可能导致意外的副作用,例如从缓冲中刷新脏块。务必小心那些可以在线更改的设置,因为它们可能导致数据库做大量的工作。\n有时可以通过名称推断一个变量的作用。例如,max_heap_table_size的作用就像听起来那样:它指定隐式内存临时表最大允许的大小。然而,命名约定并不完全一样,所以不能总是通过看名称来猜测一个变量有什么效果。\n让我们来看一些常用的变量和动态修改它们的效果。\nkey_buffer_size\n设置这个变量可以一次性为键缓冲区(key buffer,也叫键缓存key cache)分配所有指定的空间。然而,操作系统不会真的立刻分配内存,而是到使用时才真正分配。例如设置键缓冲的大小为1GB,并不意味着服务器立刻分配1GB的内存。(我们下一章会讨论如何查看服务器的内存使用。)\nMySQL允许创建多个键缓存,这一章后面我们会探讨这个问题。如果把非默认键缓存的这个变量设置为0,MySQL将丢弃缓存在该键缓存中的索引,转而使用默认键缓存,并且当不再有任何引用时会删除该键缓存。为一个不存在的键缓存设置这个变量,将会创建新的键缓存。对一个已经存在的键缓存设置非零值,会导致刷新该键缓存的内容。这会阻塞所有尝试访问该键缓存的操作,直到刷新操作完成。\ntable_cache_size\n设置这个变量不会立即生效——会延迟到下次有线程打开表才有效果。当有线程打开表时,MySQL会检查这个变量的值。如果值大于缓存中的表的数量,线程可以把最新打开的表放入缓存;如果值比缓存中的表数小,MySQL将从缓存中删除不常使用的表。\nthread_cache_size\n设置这个变量不会立即生效——将在下次有连接被关闭时产生效果。当有连接被关闭时,MySQL检查缓存中是否还有空间来缓存线程。如果有空间,则缓存该线程以备下次连接重用;如果没有空间,它将销毁该线程而不再缓存。在这个场景中,缓存中的线程数,以及线程缓存使用的内存,并不会立刻减少;只有在新的连接删除缓存中的一个线程并使用后才会减少。(MySQL只在关闭连接时才在缓存中增加线程,只在创建新连接时才从缓存中删除线程。)\nquery_cache_size\nMySQL在启动的时候,一次性分配并且初始化这块内存。如果修改这个变量(即使设置为与当前一样的值),MySQL会立刻删除所有缓存的查询,重新分配这片缓存到指定大小,并且重新初始化内存。这可能花费较长的时间,在完成初始化之前服务器都无法提供服务,因为MySQL是逐个清理缓存的查询,不是一次性全部删掉。\nread_buffer_size\nMySQL只会在有查询需要使用时才会为该缓存分配内存,并且会一次性分配该参数指定大小的全部内存。\nread_rnd_buffer_size\nMySQL只会在有查询需要使用时才会为该缓存分配内存,并且只会分配需要的内存大小而不是全部指定的大小。(max_read_rnd_buffer_size这个名字更能表达这个变量实际的含义。)\nsort_buffer_size\nMySQL只会在有查询需要做排序操作时才会为该缓存分配内存。然后,一旦需要排序,MySQL就会立刻分配该参数指定大小的全部内存,而不管该排序是否需要这么大的内存。\n我们在其他地方也对这些参数做过更多细节的说明,这里不是一个完整的列表。这里的目的只是简单地告诉大家,当修改一些常见的变量时,会有哪些期望的行为发生。\n对于连接级别的设置,不要轻易地在全局级别增加它们的值,除非确认这样做是对的。有一些缓存会一次性分配指定大小的全部内存,而不管实际上是否需要这么大,所以一个很大的全局设置可能导致浪费大量内存。更好的办法是,当查询需要时在连接级别单独调大这些值。\n最常见的例子是sort_buffer_size,该参数控制排序操作的缓存大小,应该在配置文件里把它配置得小一些,然后在某些查询需要排序时,再在连接中把它调大。在分配内存后, MySQL会执行一些初始化的工作。\n另外,即使是非常小的排序操作,排序缓存也会分配全部大小的内存,所以如果把参数设置得超过平均排序需求太多,将会浪费很多内存,增加额外的内存分配开销。许多读者认为内存分配是一个很简单的操作,听到内存分配的代价可能会很吃惊。不需要深入很多技术细节就可以讲清楚为什么内存分配也是昂贵的操作,内存分配包括了地址空间的分配,这相对来说是比较昂贵的。特别在Linux上,内存分配根据大小使用多种开销不同的策略。\n总的来说,设置很大的排序缓存代价可能非常高,所以除非确定必须要这么大,否则不要增加排序缓存的大小。\n如果查询必须使用一个更大的排序缓存才能比较好地执行,可以在查询执行前增加sort_buffer_size的值,执行完成后恢复为DEFAULT。\n下面是一个实际的例子:\nSET @@session.sort_buffer_size := \u0026gt;* value* \u0026gt;; -- Execute the query... SET @@session.sort_buffer_size := DEFAULT; 可以将类似的代码封装在函数中以方便使用。其他可以设置的单个连接级别的变量有read_buffer_size、read_rnd_buffer_size、tmp_table_size、以及myisam_sort_buffer_size(在修复表的操作中会用到)。\n如果有需要也可以保存并还原原来的自定义值,可以像下面这样做:\nSET @saved_\u0026lt;* unique_variable_name* \u0026gt; := @@session.sort_buffer_size; SET @@session.sort_buffer_size := \u0026lt;value\u0026gt;; -- Execute the query... SET @@session.sort_buffer_size := @saved_\u0026lt;* unique_variable_name* \u0026gt;; 排序缓冲大小是关注的众多“调优”中一个设置。一些人似乎认为越大越好,我们甚至见过把这个变量设为1GB的。这可能导致服务器尝试分配太多内存而崩溃,或者为查询初始化排序缓存时消耗大量的CPU,这不是什么出乎意料的事。从MySQL的Bug 37359可以看到有关于这个问题的细节。\n不要把排序缓存大小放在太重要的位置。查询真的需要128MB的内存来排序10行数据然后返回给客户端吗?思考一下查询语句是什么类型的排序、多大的排序,首先考虑通过索引和SQL写法来避免排序(看第5章和第6章),这比调优排序缓存要快得多。并且应该仔细分析查询开销,看看排序是否是无论如何都需要重点关注的部分。第3章有一个例子,一个查询执行了一个排序,但是没有花很多排序时间。\n8.1.3 入门 # 设置变量时请小心,并不是值越大就越好,而且如果设置的值太高,可能更容易导致问题:可能会由于内存不足导致服务器内存交换,或者超过地址空间。(1)\n应该始终通过监控来确认生产环境中变量的修改,是提高还是降低了服务器的整体性能。基准测试是不够的,因为基准测试不是真实的工作负载。如果不对服务器的性能进行实际的测量,可能性能降低了都没有发现。我们见过很多情况,有人修改了服务器的配置,并认为它提高了性能,其实服务器的整体性能恶化了,因为在一个星期或一天的不同时间,工作负载是不一样的。\n如果你经常做笔记,在配置文件中写好注释,可能会节省自己(和同事)大量的工作。一个更好的主意是把配置文件置于版本控制之下。无论如何,这是一个很好的做法,因为它让你有机会撤销变更。要降低管理很多配置文件的复杂性,简单地创建一个从配置文件到中央版本控制库的符号链接。\n在开始改变配置之前,应该优化查询和schema,至少先做明显要做的事情,例如添加索引。如果先深入调整配置,然后修改了查询语句和schema,也许需要回头再次评估配置。请记住,除非硬件、工作负载和数据是完全静态的,否则都可能需要重新检查配置文件。实际上,大部分人的服务器甚至在一天中都没有稳定的工作负载——意味着对上午来说“完美”的配置,下午就不对了!显然,追求传说中的“完美”配置是完全不切实际的。因此,没有必要榨干服务器的每一点性能,实际上,这种调优的时间投入产出是非常小的。我们建议在“足够好”的时候就可以停下了,除非有理由相信停下会导致放弃重大的性能提升的机会。\n8.1.4 通过基准测试迭代优化 # 你也许期望(或者相信自己会期望)通过建立一套基准测试方案,然后不断迭代地验证对配置项的修改来找到最佳配置方案。通常我们都不建议大家这么做。这需要做非常多的工作和研究,并且大部分情况下潜在的收益是非常小的,这可能导致巨大的时间浪费。而把时间花在检查备份、监控执行计划的变动之类的事情上,可能会更有意义。\n即使更改一个选项后基准测试出现了提升,也无法知道长期运行后这个变更会有什么副作用。基准测试也不能衡量一切,或者没有运行足够长的时间来检测系统的长期稳定性,修改就可能导致如周期性性能抖动或者周期性的慢查询等问题。这是很难察觉到的。\n有的时候我们运行某些组合的基准测试,来仔细验证或压测服务器的某些特定部分,使得我们可以更好地理解这些行为。一个很好的例子是,我们使用了很多年的一些基准测试,用来理解InnoDB的刷新行为,来寻找更好的刷新算法,以适应多种工作负载和多种硬件类型。我们经常测试各种各样的设置,来理解它们的影响以及怎么优化它们。但这不是一件简单的事——这可能会花费很多天甚至很多个星期——而且对大部分人来说这没有收益,因为服务器特定部分的认识局限往往会掩盖了其他问题。例如,有时我们发现,特定的设置项组合,在特定的边缘场景可能有更好的性能,但是在实际生产环境这些配置项并不真的合适,例如,浪费大量的内存,或者优化了吞吐量却忽略了崩溃恢复的影响。\n如果必须这样做,我们建议在开始配置服务器之前,开发一个定制的基准测试包。你必须做这些事情来包含所有可能的工作负载,甚至包含一些边缘的场景,例如很庞大很复杂的查询语句。在实际的数据上重放工作负载通常是一个好办法。如果已经定位到了一个特定的问题点——例如一个查询语句运行很慢——也可以尝试专门优化这个点,但是可能不知道这会对其他查询有什么负面影响。\n最好的办法是一次改变一个或两个变量,每次一点点,每次更改后运行基准测试,确保运行足够长的时间来确认性能是否稳定。有时结果可能会令你感到惊讶,可能把一个变量调大了一点,观察到性能提升,然后再调大一点,却发现性能大幅下降。如果变更后性能有隐患,可能是某些资源用得太多了,例如,为缓冲区分配太多内存、频繁地申请和释放内存。另外,可能导致MySQL和操作系统或硬件之间的不匹配。例如,我们发现sort_buffer_size的最佳值可能会被CPU缓存的工作方式影响,还有read_buffer_size需要服务器的预读和I/O子系统的配置相匹配。更大并不总是更好,还可能更糟糕。一些变量也依赖于一些其他的东西,这需要通过经验和对系统架构的理解来学习。\n什么情况下进行基准测试是好的建议\n对于前面提到不建议大多数人执行基准测试的情况也有例外的时候。我们有时会建议人们跑一些迭代基准测试,尽管通常跟“服务器调优”有不同的内容。这里有一些例子:\n如果有一笔大的投资,如购买大量新的服务器,可以运行一下基准测试以了解硬件需求。(这里的上下文指是容量规划,不是服务器调优),我们特别喜欢对不同大小的InnoDB缓冲池进行基准测试,这有助于我们制定一个“内存曲线”,以展示真正需要多少内存,不同的内存容量如何影响存储系统的要求。 如果想了解InnoDB从崩溃中恢复需要多久时间,可以反复设置一个备库,故意让它崩溃,然后“测试”InnoDB在重启中需要花费多久时间来做恢复。这里的背景是做高可用性的规划。 以读为主的应用程序,在慢查询日志中捕捉所有的查询(或者用pt-query-digest分析TCP流量)是个很好的主意,在服务器完全打开慢查询日志记录时,使用pt-log-player重放所有的慢查询,然后用pt-query-digest来分析输出报告。这可以观察在不同硬件、软件和服务器设置下,查询语句运行的情况。例如,我们曾经帮助客户评估迁移到更多的内存但硬盘更慢的服务器上的性能变化。大多数查询变得更快,但一些分析型查询语句变慢,因为它们是I/O密集型的。这个测试的上下文背景就是不同工作负载的比较。 8.2 什么不该做 # 在我们开始配置服务器之前,希望鼓励大家去避免一些我们已经发现有风险或有害的做法。警告:本节有些观点可能会让有些人不舒服!\n首先,不要根据一些“比率”来调优。一个经典的按“比率”调优的经验法则是,键缓存的命中率应该高于某个百分比,如果命中率过低,则应该增加缓存的大小。这是非常错误的意见。无论别人怎么跟你说,缓存命中率跟缓存是否过大或过小没有关系。首先,命中率取决于工作负载——某些工作负载就是无法缓存的,不管缓存有多大——其次,缓存命中没有什么意义,我们将在后面解释原因。有时当缓存太小时,命中率比较低,增加缓存的大小确实可以提高命中率。然而,这只是个偶然情况,并不表示这与性能或适当的缓存大小有任何关系。\n这种相关性,有时候看起来似乎真正的问题是,人们开始相信它们将永远是真的。Oracle DBA很多年前就放弃了基于命中率的调优,我们希望MySQL DBA也能跟着走(2)。我们更强烈地希望人们不要去写“调优脚本”,把这些危险的做法编写到一起,并教导成千上万的人这么做。这引出了我们第二个不该做的建议:不要使用调优脚本!有几个这样的可以在互联网上找到的脚本非常受欢迎,最好是忽略它们(3)。\n我们还建议避免调(tuning)这个词,我们在前面几段中使用这个词是有点随意的。我们更喜欢使用“配置(Configuration)”或“优化(Optimize)”来代替(只要这是你真正在做的,见第3章)。“调优”这个词,容易让人联想到一个缺乏纪律的新手对服务器进行微调,并观察发生了什么。我们建议上一节的练习最好留给那些正在研究服务器内核的人。“调优”服务器可能浪费大量的时间。\n另外说一句,在互联网搜索如何配置并不总是一个好主意。在博客、论坛等地方(4)都可能找到很多不好的建议。虽然许多专家在网上贡献了他们了解的东西,但并不总是能容易地分辨出哪些是正确的建议。我们也不能给出中肯的建议在哪里能找到真正的专家(5)。但我们可以说,可信的、声誉好的MySQL服务供应商一般比简单的互联网搜索更安全,因为有好的客户才可能做出正确的事情。然而,即使是他们的意见,没有经过测试和理解就使用,也可能有危险,因为它可能对某种解决方案有了思维定势,跟你的思维不一样,可能用了一种你无法理解的方法。\n最后,不要相信很流行的内存消耗公式——是的,就是MySQL崩溃时自身输出的那个内存消耗公式(我们这里就不再重复了)。这个公式已经很古老了,它并不可靠,甚至也不是一个理解MySQL在最差情况下需要使用多少内存的有用的办法。在互联网上可能还会看到这个公式的很多变种。即使在原公式上增加了更多原来没有考虑到的因素,还是有同样的缺陷。事实上不可能非常准确地把握MySQL内存消耗的上限。MySQL不是一个完全严格控制内存分配的数据库服务器。这个结论可以非常简单地证明,登录到服务器,并执行一些大量消耗内存的查询:\nmysql\u0026gt; ** SET @crash_me_1 := REPEAT('a', @@max_allowed_packet);** mysql\u0026gt; ** SET @crash_me_2 := REPEAT('a', @@max_allowed_packet);** # ... run a lot of these ... mysql\u0026gt; ** SET @crash_me_1000000 := REPEAT('a', @@max_allowed_packet);** 在一个循环中运行这些语句,每次都创建新的变量,最后服务器内存必然耗尽,然后系统崩溃!运行这个测试不需要任何特殊权限。\n在本节我们试图说明的观点是,有时候我们在那些认为我们很傲慢的人面前变得不受欢迎,他们认为我们正在试图诋毁他人,把自己塑造成唯一的权威,或者觉得我们是在试图推销我们的服务。我们的目的不是利己。我们只是看到非常多很糟糕的建议,如果没有足够的经验,这看上去似乎还是合理的。另外我们重复这么多次说明这些糟糕的建议,因为我们认为揭穿一些神话是很重要的,并提醒我们的读者要小心他们信任的那些人的专业水准。我们还是尽量避免在这里继续说这些不好听的吧。\n8.3 创建MySQL配置文件 # 正如我们在本章开头提到的,没有一个适合所有场景的“最佳配置文件”,比方说,对一台有16 GB内存和12块硬盘的4路CPU服务器,不会有一个相应的“最佳配置文件”。应该开发自己的配置,因为即使是一个好的起点,也依赖于具体是如何使用服务器的。\nMySQL编译的默认设置并不都是靠谱的,虽然其中大部分都比较合适。它们被设计成不要使用大量的资源,因为MySQL的使用目标是非常灵活的,它并没有假设自己是服务器上唯一的应用。默认情况下,MySQL只是使用恰好足够的资源来启动,运行一些少量数据的简单查询。如果有超过几MB的数据,就一定会需要自己定制MySQL配置。\n你可能会先从一个包含在MySQL发行版本中的示例配置文件开始,但这些示例配置有自己的问题。例如,它们有很多注释掉的设置,可能会诱使你认为应该选择一个值,并取消注释(这有点让人联想到Apache配置文件)。同时它们有很多乏味的注释,只是为了解释选项的含义,但这些解释并不总是通顺、完整甚至正确的,有些选项甚至并不适用于流行的操作系统!最后,这些示例相对于现代的硬件和工作负载,总是过时的。\nMySQL专家们关于如何解决这些问题多年来进行了许多对话,但这些问题依然存在。下面是我们的建议:不要使用这些文件作为(创建配置文件的)起点,也不要使用操作系统的安装包自带的配置文件。最好是从头开始。\n这就是本章要做的事情。实际上MySQL的可配置性太强也可以说是个弱点,看起来好像需要花很多时间在配置上,其实大多数配置的默认值已经是最佳配置了,所以最好不要改动太多配置,甚至可以忘记某些配置的存在。这就是为什么我们为本书创建了一个完整的最小的示例配置文件,可以作为自己的服务器配置文件的一个好的起点。有一些配置项是必选的,我们将在本章稍后解释。下面就是这个基础配置文件:\n[mysqld] # GENERAL datadir = /var/lib/mysql socket = /var/lib/mysql/mysql.sock pid_file = /var/lib/mysql/mysql.pid user = mysql port = 3306 storage_engine = InnoDBdefault_storage_engine # INNODB innodb_buffer_pool_size = \u0026lt;value\u0026gt; innodb_log_file_size = \u0026lt;value\u0026gt; innodb_file_per_table = 1 innodb_flush_method = O_DIRECT # MyISAM key_buffer_size = \u0026lt;value\u0026gt; # LOGGING log_error = /var/lib/mysql/mysql-error.log log_slow_queries = /var/lib/mysql/mysql-slow.logslow_query_log # OTHER tmp_table_size = 32M max_heap_table_size = 32M query_cache_type = 0 query_cache_size = 0 max_connections = \u0026lt;value\u0026gt; thread_cache_size = \u0026lt;value\u0026gt;thread_cache table_cache_size = \u0026lt;value\u0026gt;table_cache open_files_limit = 65535 [client] socket = /var/lib/mysql/mysql.sock port = 3306 和你见过的其他配置文件(6)相比,这里的配置选项可能太少了。但实际上已经超过了许多人的需要。有一些其他类型的配置选项可能也会用到,比如二进制日志,我们会在本章后面以及其他章节覆盖这些内容。\n配置文件的第一件事是设置数据的位置。我们选择了 /var/lib/mysql 路径存储数据,因为在许多类UNIX系统中这是最常见的位置。选择另外的位置也没有错,可以根据需要决定。我们把PID文件也放到相同的位置,但许多操作系统希望放在 /var/run 目录下,这也可以。只需要简单地为这些选项配置一下就可以了。顺便说一下,不要把Socket文件和PID文件放到MySQL编译默认的位置,在不同的MySQL版本里这可能会导致一些错误。最好明确地设置这些文件的位置。(这么说并不是建议选择不同的位置,只是建议确保在 my.cnf 文件中明确指定了这些文件的存放地点,这样升级MySQL版本时这些路径就不会改变。)\n这里还指定了操作系统必须用mysql用户来运行mysqld进程。需要确保这个账户存在,并且拥有操作数据目录的权限。端口设置为默认的3306,但有时可能需要修改一下。\n我们选择InnoDB作为默认的存储引擎,这个值得向大家解释一下。InnoDB在大多数情况下是最好的选择,但并不总是如此。例如,一些第三方的软件,可能假设默认存储引擎是MyISAM,所以创建表时没有指定存储引擎。这可能会导致软件故障,例如,这些应用可能会假定可以创建全文索引(7)。默认存储引擎也会在显式创建临时表时用到,可能会引起服务器做一些意料之外的工作。如果希望持久化的表使用InnoDB,但所有临时表使用MyISAM,那应该确保在CREATE TABLE语句中明确指定了存储引擎。\n一般情况下,如果决定使用一个存储引擎作为默认引擎,最好显式地进行配置。许多用户认为只使用了某个特定的存储引擎,但后来发现正在用的其实是另一个引擎,就是因为默认配置的是另外一个引擎。\n接下来我们将阐述InnoDB的基础配置。InnoDB在大多数情况下如果要运行得很好,配置大小合适的缓冲池(Buffer Pool)和日志文件(Log File)是必须的。默认值都太小了。其他所有的InnoDB设置都是可选的,尽管示例配置中因为可管理性和灵活性的原因启用了innodb_file_per_table。设置InnoDB日志文件的大小和innodb_fush_method是本章后面要讨论的主题,其中innodb_fush_method是类UNIX系统特有的选项。\n有一个流行的经验法则说,应该把缓冲池大小设置为服务器内存的约75%~80%。这是另一个偶然有效的“比率”,但并不总是正确的。有一个更好的办法来设置缓冲池大小,大致如下:\n从服务器内存总量开始。 减去操作系统的内存占用,如果MySQL不是唯一运行在这个服务器上的程序,还要扣掉其他程序可能占用的内存。 减去一些MySQL自身需要的内存,例如为每个查询操作分配的一些缓冲。 减去足够让操作系统缓存InnoDB日志文件的内存,至少是足够缓存最近经常访问的部分。(此建议适用于标准的MySQL,Percona Server可以配置日志文件用O_DIRECT方式打开,绕过操作系统缓存),留一些内存至少可以缓存二进制日志的最后一部分也是个很好的选择,尤其是如果复制产生了延迟,备库就可能读取主库上旧的二进制日志文件,给主库的内存造成一些压力。 减去其他配置的MySQL缓冲和缓存需要的内存,例如MyISAM的键缓存(Key Cache),或者查询缓存(Query Cache)。 除以105%,这差不多接近InnoDB管理缓冲池增加的自身管理开销。 把结果四舍五入,向下取一个合理的数值。向下舍入不会太影响结果,但是如果分配太多可能就会是件很糟糕的事情。 我们对这里有些内存总量相关的问题有一点感到厌倦——什么是“操作系统的一个位(Bit)”?那是变化的,在本章和这本书的其余部分,我们将对此做一定深度的讨论。你必须了解你的系统,并且估算它需要多少内存才能良好地运转。这是为什么一个适合所有场景的配置文件是不存在的。经验,以及有时一点数学知识将给你提供指导。\n下面是一个例子,假设有一个192GB内存的服务器,只运行MySQL并且只使用InnoDB,没有查询缓存(Query Cache),也没有非常多的连接连到服务器。如果日志文件总大小是4 GB,可能会像这样处理:“我认为所有内存的5%或者2GB,取较大的那个,应该足够操作系统和MySQL的其他内存需求,为日志文件减去4 GB,剩下的都给InnoDB用”。结果差不多是177 GB,但是配置得稍微低一点可能是个好主意。比如可以先配置缓存池为168GB。在服务器实际运行中若发现还有不少内存没有分配使用,在出于某些目的有机会重启时,可以再适当调大缓冲池的大小。\n如果有大量MyISAM表需要缓存它们的索引,结果自然会有很大不同。在Windows下这也是完全不同的,大多数的MySQL版本在Windows下使用大内存都有问题(虽然在MySQL 5.5中有所改进),或者是出于某种原因不使用O_DIRECT也会有不同的结果。\n正如你所看到的,从一开始就获得精确的设置并不是关键。从一个比默认值大一点但不是大得很离谱的安全值开始是比较好的,在服务器运行一段时间后,可以看看服务器真实情况需要使用多少内存。这些东西是很难预测,因为MySQL的内存利用率并不总是可以预测的:它可能依赖很多的因素,例如查询的复杂性和并发性。如果是简单的工作负载,MySQL的内存需求是非常小的——大约256 KB的每个连接。但是,使用临时表、排序、存储过程等的复杂查询,可能使用更多的内存。\n这就是我们选择一个非常安全的起点的原因。可以看到,即使是保守的InnoDB的缓冲池设置,实际上也是服务器内存的87.5%——超过75%,这就是为什么我们说简单地按比例是不正确的方法的原因。\n我们建议,当配置内存缓冲区的时候,宁可谨慎,而不是把它们配置得过大。如果把缓冲池配置得比它可以设的值少了20%,很可能只会对性能产生小的影响,也许就只影响几个百分点。如果设置得大了20%,则可能会造成更严重的问题:内存交换、磁盘抖动,甚至内存耗尽和硬件死机。\n这份InnoDB配置的例子说明了我们配置服务器的首选途径:了解它内部做了什么,以及参数之间如何相互影响,然后再决定。\n时间改变一切\n精确地配置MySQL的内存缓冲区随着时间的推移变得不那么重要。当一个强大的服务器只有4 GB内存的时候,我们努力地平衡其资源使它可以运行1000个连接。这通常需要我们为MySQL保留1GB的内存,这是服务器总内存的四分之一,而且会极大地影响我们设置缓冲池的大小。Time Changes Everything The need to configure MySQL\u0026rsquo;s memory buffers precisely has become less important over time. When a powerful server had 4 GB of memory, we worked hard to balance its resources so it could run a thousand connections. This typically required us to re-\n如今类似的服务器有144 GB的内存,但是在大多数应用中我们通常看到的连接数是相同的,每个连接的缓冲区并没有真的改变太多。因此,我们可能会慷慨地为MySQL保留4GB的内存,这只是九牛一毛而已。它不会对我们的缓冲池的大小设置产生太大影响。\n示例配置文件中的其他一些设置,大多是不言自明的,其中很多配置都是是与否的判断。在本章的其余部分,我们将探讨其中的几个。可以看到,我们已经启用日志记录、禁用了查询缓存,等等。在这一章的后面,我们还将讨论一些安全性和完整性的设置,它可以使服务器更强健,并对防止数据损坏和其他问题非常有帮助。我们并没有在这里展示这些设置。\n这里需要解释的一个选项是open_files_limit。在典型的Linux系统上我们把它设置得尽可能大。现代操作系统中打开文件句柄开销都很小。如果这个参数不够大,将会碰到经典的24号错误,“打开的文件太多(too many open files)”。\n跳过其他的直接看到末尾,在配置文件的最后一节,是为了如mysql和mysqladmin之类的客户端程序做的设置,可以简化这些程序连接到服务器的步骤。应该为客户端程序设置那些匹配服务器的配置项。\n8.3.1 检查MySQL服务器状态变量 # 有时可以使用SHOW GLOBAL STATUS的输出,作为配置的输入,以更好地通过工作负载来自定义配置。为了达到最佳效果,既要看绝对值,又要看值是如何随时间而改变的,最好为高峰和非高峰时间的值做几个快照。可以使用以下命令每隔60秒来查看状态变量的增量变化:\n** $ mysqladmin extended-status -ri60** 在解释配置设置的时候,我们经常会提到随着时间的推移各种状态变量的变化。所以通常可以预料到需要分析如刚才那个命令的输出的情况。有一些有用的工具,如Percona Toolkit中的pt-mext或PT-mysql-summary,可以简洁地显示状态计数器的变化,不用直接看那些SHOW命令的输出。\n好吧,前面的内容算是预热,接下来我们将进入一些服务器内核的东西,并将相关的配置建议穿插在其中。然后再回头来看示例配置文件,就会有足够的背景知识来选择适当的配置选项的值了。\n8.4 配置内存使用 # 配置MySQL正确地使用内存量对高性能是至关重要的。肯定要根据需求来定制内存使用。可以认为MySQL的内存消耗分为两类:可以控制的内存和不可以控制的内存。无法控制MySQL服务器运行、解析查询,以及其内部管理所消耗的内存,但是为特定目的而使用多少内存则有很多参数可以控制(8)。用好可以控制的内存并不难,但需要对配置的含义非常清楚。\n像前面展示的,按下面的步骤来配置内存:\n确定可以使用的内存上限。 确定每个连接MySQL需要使用多少内存,例如排序缓冲和临时表。 确定操作系统需要多少内存才够用。包括同一台机器上其他程序使用的内存,如定时任务。 把剩下的内存全部给MySQL的缓存,例如InnoDB的缓冲池,这样做很有意义。 我们将在后面的章节详细说明这些步骤,然后我们对各种MySQL的缓存需求做更细节的分析。\n8.4.1 MySQL可以使用多少内存 # 在任何给定的操作系统上,MySQL都有允许使用的内存上限。基本出发点是机器上安装了多少物理内存。如果服务器就没装这么多内存,MySQL肯定也不能用这么多内存。\n还需要考虑操作系统或架构的限制,如32位操作系统对一个给定的进程可以处理多少内存是有限制的。因为MySQL是单进程多线程的运行模式,它整体可用的内存量也许会受操作系统位数的严格限制——例如,32位Linux内核通常限制任意进程可以使用的内存量在2.5GB~2.7GB范围内。运行时地址空间溢出是非常危险的,可能导致MySQL崩溃。现在这种情况非常难得一见,但以前这种情况很常见。\n有许多其他的操作系统——特殊的参数和古怪的事情必须考虑到,例如不只是每个进程有限制,而且堆栈大小和其他设置也有限制。系统的glibc库也可能限制每次分配的内存大小。例如,若glibc库支持单次分配的最大大小是2GB,那么可能就无法设置innodb_buffer_pool的值大于2 GB。\n即使在64位服务器上,依然有一些限制。例如,许多我们讨论的缓冲区,如键缓存(Key Buffer),在5.0以及更早的MySQL版本上,有4GB的限制,即使在64位服务器上也是如此。在MySQL 5.1中,部分限制被取消了,在MySQL手册中记载了每个变量的最大值,有需要可以查阅。\n8.4.2 每个连接需要的内存 # MySQL保持一个连接(线程)只需要少量的内存。它还要求一个基本量的内存来执行任何给定查询。你需要为高峰时期执行的大量查询预留好足够的内存。否则,查询执行可能因为缺乏内存而导致执行效率不佳或执行失败。\n知道在高峰时期MySQL将消耗多少内存是非常有用的,但一些习惯性用法可能意外地消耗大量内存,这使得对内存使用量的预测变得比较困难。绑定变量就是一个例子,因为可以一次打开很多绑定变量语句。另一个例子是InnoDB数据字典(关于这个后面我们再细说)。\n当预测内存峰值消耗时,没必要假设一个最坏情况。例如,配置MySQL允许最多100个连接,在理论上可能出现100个连接同时在运行很大的查询,但在现实情况中,这可能不会发生。例如,设置myisam_sort_buffer_size为256MB,最差情况下至少需要使用25 GB内存,但这种最差情况在实际中几乎是不可能发生的。使用了许多大的临时表或复杂存储过程的查询,通常是导致高内存消耗最可能的原因。\n相对于计算最坏情况下的开销,更好的办法是观察服务器在真实的工作压力下使用了多少内存,可以在进程的虚拟内存大小那里看到。在许多类UNIX系统里,可以观察top命令中的VIRT列,或者ps命令中的VSZ列的值。下一章有更多关于如何监视内存使用情况的信息。\n8.4.3 为操作系统保留内存 # 跟查询一样,操作系统也需要保留足够的内存给它工作。如果没有虚拟内存正在交换(Paging)到磁盘,就是表明操作系统内存足够的最佳迹象。(关于这个话题,请参阅下一章。)\n至少应该为操作系统保留1GB~2GB的内存——如果机器内存更多就再多预留一些。我们建议2 GB或总内存的5%作为基准,以较大者为准。为了安全再额外增加一些预留,并且如果机器上还在运行内存密集型任务(如备份),则可以再多增加一些预留。不要为操作系统的缓存增加任何内存,因为它们可能会变得非常大。操作系统通常会利用所有剩下的内存来做文件系统缓存,我们认为,这应该从操作系统自身的需求里分离出来。\n8.4.4 为缓存分配内存 # 如果服务器只运行MySQL,所有不需要为操作系统以及查询处理保留的内存都可以用作MySQL缓存。\n相比其他,MySQL需要为缓存分配更多的内存。它使用缓存来避免磁盘访问,磁盘访问比内存访问数据要慢得多。操作系统可能会缓存一些数据,这对MySQL有些好处(尤其是对MyISAM),但是MySQL自身也需要大量内存。\n下面是我们认为对大部分情况来说最重要的缓存:\nInnoDB缓冲池 InnoDB日志文件和MyISAM数据的操作系统缓存 MyISAM键缓存 查询缓存 无法手工配置的缓存,例如二进制日志和表定义文件的操作系统缓存 还有些其他缓存,但是它们通常不会使用太多内存。我们在前面的章节中讨论了查询缓存(Query Cache)的细节,所以接下来的部分我们专注于InnoDB和MyISAM良好工作需要的缓存。\n如果只使用单一存储引擎,配置服务器就简单多了。如果只使用MyISAM表,就可以完全关闭InnoDB,而如果只使用InnoDB,就只需要分配最少的资源给MyISAM(MySQL内部系统表采用MyISAM)。但是如果正混合使用各种存储引擎,就很难在它们之间找到恰当的平衡。我们发现最好的办法是先做一个有根据的猜测,然后在运行中观察服务器(再进行调整)。\n8.4.5 InnoDB缓冲池(Buffer Pool) # 如果大部分都是InnoDB表,InnoDB缓冲池或许比其他任何东西更需要内存。InnoDB缓冲池并不仅仅缓存索引:它还会缓存行的数据、自适应哈希索引、插入缓冲(Insert Buffer)、锁,以及其他内部数据结构。InnoDB还使用缓冲池来帮助延迟写入,这样就能合并多个写入操作,然后一起顺序地写回。总之,InnoDB严重依赖缓冲池,你必须确认为它分配了足够的内存,通常就像这一章前面展示的那样处理。可以使用通过SHOW命令得到的变量或者例如innotop这样的工具监控InnoDB缓冲池的内存利用情况。\n如果数据量不大,并且不会快速增长,就没必要为缓冲池分配过多的内存。把缓冲池配置得比需要缓存的表和索引还要大很多实际上没有什么意义。当然,对一个迅速增长的数据库做超前的规划没有错,但有时我们也会看到一个巨大的缓冲池只缓存一点点数据,这就没有必要了。\n很大的缓冲池也会带来一些挑战,例如,预热和关闭都会花费很长的时间。如果有很多脏页在缓冲池里,InnoDB关闭时可能会花费较长的时间,因为在关闭之前需要把脏页写回数据文件。也可以强制快速关闭,但是重启时就必须多做更多的恢复工作,也就是说无法同时加速关闭和重启两个动作。如果事先知道什么时候需要关闭InnoDB,可以在运行时修改innodb_max_dirty_pages_pct变量,将值改小,等待刷新线程清理缓冲池,然后在脏页数量较少时关闭。可以监控the Innodb_buffer_pool_pages_dirty状态变量或者使用innotop来监控SHOW INNODB STATUS来观察脏页的刷新量。\n更小的innodb_max_dirty_pages_pct变量值并不保证InnoDB将在缓冲池中保持更少的脏页。它只是控制InnoDB是否可以“偷懒(Lazy)”的阈值。InnoDB默认通过一个后台线程来刷新脏页,并且会合并写入,更高效地顺序写出到磁盘。这个行为之所以被称为“偷懒(Lazy)”,是因为它使得InnoDB延迟了缓冲池中刷写脏页的操作,直到一些其他数据必须使用空间时才刷写。当脏页的百分比超过了这个阈值,InnoDB将快速地刷写脏页,尝试让脏页的数量更低。当事务日志没有足够的空间剩余时,InnoDB也将进入“激烈刷写(Furious Flushing)”模式,这就是大日志可以提升性能的一个原因。\n当有一个很大的缓冲池,重启后服务器也许需要花很长的时间(几个小时甚至几天)来预热缓冲池,尤其是磁盘很慢的时候。在这种情况下,可以利用Percona Server的功能来重新载入缓冲池的页(9),从而节省时间。这可以让预热时间减少到几分钟。MySQL 5.6也提供了一个类似的功能。这个功能对复制尤其有好处,因为单线程复制导致备库需要额外的预热时间。\n如果不能使用Percona Server的快速预热功能,也可以在重启后立刻进行全表扫描或者索引扫描,把索引载入缓冲池。这是比较粗暴的方式,但是有时候比什么都不做还是要好。可以使用init_file设置来实现这个功能。把SQL放到一个文件里,然后当MySQL启动的时候来执行。文件名必须在init_file选项中指定,文件中可以包含多条SQL命令,每一条单独一行(不允许使用注释)。\n8.4.6 MyISAM键缓存(Key Caches) # MyISAM的键缓存也被称为键缓冲,默认只有一个键缓存,但也可以创建多个。不像InnoDB和其他一些存储引擎,MyISAM自身只缓存索引,不缓存数据(依赖操作系统缓存数据)。如果大部分是MyISAM表,就应该为键缓存分配比较多的内存。\n最重要的配置项是key_buffer_size。任何没有分配给它的内存(10)都可以被操作系统缓存利用。MySQL 5.0有一个规定的有效上限是4GB,不管系统是什么架构。MySQL 5.1允许更大的值。可以查看正在使用的MySQL版本的官方手册来了解这个限制。\n在决定键缓存需要分配多少内存之前,先去了解MyISAM索引实际上占用多少磁盘空间是很有帮助的。肯定不需要把键缓冲设置得比需要缓存的索引数据还大。查询INFORMATION_SCHEMA表的INDEX_LENGTH字段,把它们的值相加,就可以得到索引存储占用的空间:\nSELECT SUM(INDEX_LENGTH) FROM INFORMATION_SCHEMA.TABLES WHERE ENGINE='MYISAM'; 如果是类UNIX系统,也可以使用下面的命令:\n** $ du -sch `find /path/to/mysql/data/directory/ -name\u0026quot;*.MYI\u0026quot;`** 应该把键缓存设置得多大?不要超过索引的总大小,或者不超过为操作系统缓存保留总内存的25%~50%,以更小的为准。\n默认情况下,MyISAM将所有索引都缓存在默认键缓存中,但也可以创建多个命名的键缓冲。这样就可以同时缓存超过4GB的内存。如果要创建名为key_buffer_1和key_buffer_2的键缓冲,每个大小为1GB,则可以在配置文件中添加如下配置项:\nkey_buffer_1.key_buffer_size = 1G key_buffer_2.key_buffer_size = 1G 现在有了三个键缓冲:两个由这两行配置明确定义,还有一个是默认键缓冲。可以使用CACHE INDEX命令来将表映射到对应的缓冲区。使用下面的语句,让MySQL使用key_buffer_1缓冲区来缓存t1和t2表的索引:\n现在当MySQL从这些表的索引读取块时,将会在指定的缓冲区内缓存这些块。也可以把表的索引预载入到缓存中,通过init_file设置或者LOAD INDEX命令:\n任何没明确指定映射到哪个键缓冲区的索引,在MySQL第一次需要访问*.MYI*文件的时候,都会被分配到默认缓冲区。\n可以通过SHOW STATUS和SHOW VARIABLES命令的信息来监控键缓冲的使用情况。下面的公式可以计算缓冲区的使用率:\n100 - ( (Key_blocks_unused * key_cache_block_size) * 100 / key_buffer_size ) 如果服务器运行了很长一段时间后,还是没有使用完所有的键缓冲,就可以把缓冲区调小一点。\n键缓冲命中率有什么意义?正如我们之前解释的那样,这个数字没什么用。例如,99%和99.9%之间看起来差别很小,但实际上代表了10倍的差距。缓存命中率也是和应用相关的:有些应用可以在95%的命中率下工作良好,但是也有些应用可能是I/O密集型的,必须在99.9%的命中率下工作。甚至有可能在恰当大小的缓存设置下获得99.99%的命中率。\n从经验上来说,每秒缓存未命中的次数要更有用。假定有一个独立的磁盘,每秒可以做100个随机读。每秒5次缓存未命中可能不会导致I/O繁忙,但是每秒80次缓存未命中则可能出现问题。可以使用下面的公式来计算这个值:\nKey_reads / Uptime 通过间隔10~100秒来计算这段时间内缓存未命中次数的增量值,可以获得当前性能的情况。下面的命令可以每10秒钟获取一次状态值的变化量:\n** $ mysqladmin extended-status -r -i 10 | grey Key_reads** 记住,MyISAM使用操作系统缓存来缓存数据文件,通常数据文件比索引要大。因此,把更多的内存保留给操作系统缓存而不是键缓存是有意义的。即使你有足够的内存来缓存所有索引,并且键缓存命中率很低,当MySQL尝试读取数据文件时(不是索引文件),在操作系统层还是可能发生缓存未命中,这对MySQL完全透明,MySQL并不能感知到。因此,这种情况下可能会有大量数据文件缓存未命中,这和索引的键缓存未命中率是完全不相关的。\n最后,即使没有任何MyISAM表,依然需要将key_buffer_size设置为较小的值,例如32M。MySQL服务器有时会在内部使用MyISAM表,例如GROUP BY语句可能会使用MyISAM做临时表。\nMySQL键缓存块大小(Key Block Size) # 块大小也是很重要的(尤其是写密集型负载),因为它影响了MyISAM、操作系统缓存,以及文件系统之间的交互。如果缓存块太小了,可能会碰到写时读取(read-around write),就是操作系统在执行写操作之前必须先从磁盘上读取一些数据。下面说明一下这种情况是怎么发生的,假设操作系统的页大小是4KB(在x86架构上通常都是这样),并且索引块大小是1KB:\nMyISAM请求从磁盘上读取1KB的块。 操作系统从磁盘上读取4KB的数据并缓存,然后发送需要的1KB数据给MyISAM。 操作系统丢弃缓存数据以给其他数据腾出缓存。 MyISAM修改1KB的索引块,然后请求操作系统把它写回磁盘。 操作系统从磁盘读取同一个4KB的数据,写入操作系统缓存,修改MyISAM改动的这1KB数据,然后把整个4KB的块写回磁盘。 在第5步中,当MyISAM请求操作系统去写4KB页的部分内容时,就发生了写时读取(read-around write)。如果MyISAM的块大小跟操作系统的相匹配,在第5步的磁盘读就可以避免(11)。\n很遗憾,MySQL 5.0以及更早的版本没有办法配置索引块大小。但是,在MySQL 5.1以及更新版本中,可以设置MyISAM的索引块大小跟操作系统一样,以避免写时读取。myisam_block_size变量控制着索引块大小。也可以指定每个索引的块大小,在CREATE TABLE或者CREATE INDEX语句中使用KEY_BLOCK_SIZE选项即可,但是因为同一个表的所有索引都保存在同一个文件中,因此该表所有索引的块大小都需要大于或者等于操作系统的块大小,才能避免由于边界对齐导致的写时读取。(例如,若同一个表的两个索引,一个块大小是1KB,另一个是4KB。那么4KB的索引块边界很可能和操作系统的页边界是不对齐的,这样还是会发生写时读取。)\n8.4.7 线程缓存 # 线程缓存保存那些当前没有与连接关联但是准备为后面新的连接服务的线程。当一个新的连接创建时,如果缓存中有线程存在,MySQL从缓存中删除一个线程,并且把它分配给这个新的连接。当连接关闭时,如果线程缓存还有空间的话,MySQL又会把线程放回缓存。如果没有空间的话,MySQL会销毁这个线程。只要MySQL在缓存里还有空闲的线程,它就可以迅速地响应连接请求,因为这样就不用为每个连接创建新的线程。\nthread_cache_size变量指定了MySQL可以保持在缓存中的线程数。一般不需要配置这个值,除非服务器会有很多连接请求。要检查线程缓存是否足够大,可以查看Threads_created状态变量。如果我们观察到很少有每秒创建的新线程数少于10个的时候,通常应该尝试保持线程缓存足够大,但是实际上经常也可能看到每秒少于1个新线程的情况。\n一个好的办法是观察Threads_connected变量并且尝试设置thread_cache_size足够大以便能处理业务压力正常的波动。例如,若Threads_connected通常保持在100~120,则可以设置缓存大小为20。如果它保持在500~700,200的线程缓存应该足够大了。可以这样认为:在700个连接的时候,可能没有线程在缓存中;在500个连接的时候,有200个缓存的线程准备为负载再次增加到700个连接时使用。\n把线程缓存设置得非常大在大部分时候是没有必要的,但是设置得很小也不能节省太多内存,所以也没什么好处。每个在线程缓存中的线程或者休眠状态的线程,通常使用256KB左右的内存。相对于正在处理查询的线程来说,这个内存不算很大。通常应该保证线程缓存足够大,以避免Threads_created频繁增长。如果这个数字很大(例如,几千个线程),可能需要把thread_cache_size设置得稍微小一些,因为一些操作系统不能很好地处理庞大的线程数,即使其中大部分是休眠的。\n8.4.8 表缓存(Table Cache) # 表缓存和线程缓存的概念是相似的,但存储的对象代表的是表。每个在缓存中的对象包含相关表*.frm*文件的解析结果,加上一些其他数据。准确地说,在对象里的其他数据的内容依赖于表的存储引擎。例如,对MyISAM,是表的数据和索引的文件描述符。对于Merge表则可能是多个文件描述符,因为Merge表可以有很多的底层表。\n表缓存可以重用资源。举个实际的例子,当一个查询请求访问一张MyISAM表, MySQL也许可以从缓存的对象中获取到文件描述符。尽管这样做可以避免打开一个文件描述符的开销,但这个开销其实并不大。打开和关闭文件描述符在本地存储是很快的,服务器可以轻松地完成每秒100万次的操作(尽管这跟网络存储不同)。对MyISAM表来说,表缓存的真正好处是,可以让服务器避免修改MyISAM文件头来标记表“正在使用中”(12)。\n表缓存的设计是服务器和存储引擎之间分离不彻底的产物,属于历史问题。表缓存对InnoDB重要性就小多了,因为InnoDB不依赖它来做那么多的事(例如持有文件描述符,InnoDB有自己的表缓存版本)。尽管如此,InnoDB也能从缓存解析的*.frm*文件中获益。\n在MySQL 5.1版本中,表缓存分离成两部分:一个是打开表的缓存,一个是表定义缓存(通过table_open_cache和table_defnition_cache变量来配置)。其结果是,表定义(解析.frm文件的结果)从其他资源中分离出来了,例如表描述符。打开的表依然是每个线程、每个表用的,但是表定义是全局的,可以被所有连接有效地共享。通常可以把table_definition_cache设置得足够高,以缓存所有的表定义。除非有上万张表,否则这可能是最简单的方法。\n如果Opened_tables状态变量很大或者在增长,可能是因为表缓存不够大,那么可以人为增加table_cache系统变量(或者是MySQL 5.1中的table_open_cache)。然而,当创建和删除临时表时,要注意这个计数器的增长,如果经常需要创建和删除临时表,那么该计数器就会不停地增长。\n把表缓存设置得非常大的缺点是,当服务器有很多MyISAM表时,可能会导致关机时间较长,因为关机前索引块必须完成刷新,表都必须标记为不再打开。同样的原因,也可能使FLUSH TABLES WITH READ LOCK操作花费很长一段时间。更为严重的是,检查表缓存算法不是很有效,稍后会更详细地说明。\n如果遇到MySQL无法打开更多文件的错误(可以使用perror工具来检查错误号代表的含义),那么可能需要增加MySQL允许打开文件的数量。这可以通过在my.cnf文件中设置open_files_limit服务器变量来实现。\n线程和表缓存实际上用的内存并不多,相反却可以有效节约资源。虽然创建一个新线程或者打开一个新的表,相对于其他MySQL操作来说代价并不算高,但它们的开销是会累加的。所以缓存线程和表有时可以提升效率。\n8.4.9 InnoDB数据字典(Data Dictionary) # InnoDB有自己的表缓存,可以称为表定义缓存或者数据字典,在目前的MySQL版本中还不能对它进行配置。当InnoDB打开一张表,就增加了一个对应的对象到数据字典。每张表可能占用4KB或者更多的内存(尽管在MySQL 5.1中对空间的需求小了很多)。当表关闭的时候也不会从数据字典中移除它们。\n因此,随着时间的推移,服务器可能出现内存泄露,导致数据字典中的元素不断地增长。但这不是真的内存泄露,只是没有对数据字典实现任何一种缓存过期策略。通常只有当有很多(数千或数万)张大表时才是个问题。如果这个问题有影响,可以使用Percona Server,有一个选项可以控制数据字典的大小,它会从数据字典中移除没有使用的表。MySQL 5.6尚未发布的版本中也有个类似的功能。\n另一个性能问题是第一次打开表时会计算统计信息,这需要很多I/O操作,所以代价很高。相比MyISAM,InnoDB没有将统计信息持久化,而是在每次打开表时重新计算,在打开之后,每隔一段过期时间或者遇到触发事件(改变表的内容或者查询INFORMATION_SCHEMA表,等等),也会重新计算统计信息。如果有很多表,服务器可能会花费数个小时来启动并完全预热,在这个时候服务器可能花费更多的时间在等待I/O操作,而不是做其他事。可以在Percona Server(在MySQL 5.6中也可以,但是叫做innodb_analyze_is_persistent)中打开innodb_use_sys_stats_table选项来持久化存储统计信息到磁盘,以解决这个问题。\n即使在启动之后,InnoDB统计操作还可能对服务器和一些特定的查询产生冲击。可以关闭innodb_stats_on_metadata选项来避免耗时的表统计信息刷新。当例如IDE这样的工具执行INFORMATION_SCHEMA表的查询时,关闭这个选项后的表现是很不一样的(当然是快了不少)。\n如果设置了InnoDB的innodb_file_per_table选项(后面会描述),InnoDB任意时刻可以保持打开.ibd文件的数量也是有其限制的。这由InnoDB存储引擎负责,而不是MySQL服务器管理,并且由innodb_open_files来控制。InnoDB打开文件和MyISAM的方式不一样,MyISAM用表缓存来持有打开表的文件描述符,而InnoDB在打开表和打开文件之间没有直接的关系。InnoDB为每个.ibd文件使用单个、全局的文件描述符。如果可以,最好把innodb_open_files的值设置得足够大以使服务器可以保持所有的*.ibd*文件同时打开。\n8.5 配置MySQL的I/O行为 # 有一些配置项影响着MySQL怎样同步数据到磁盘以及如何做恢复操作。这些操作对性能的影响非常大,因为都涉及到昂贵的I/O操作。它们也表现了性能和数据安全之间的权衡。通常,保证数据立刻并且一致地写到磁盘是很昂贵的。如果能够冒一点磁盘写可能没有真正持久化到磁盘的风险,就可以增加并发性和减少I/O等待,但是必须决定可以容忍多大的风险。\n8.5.1 InnoDB I/O配置 # InnoDB不仅允许控制怎么恢复,还允许控制怎么打开和刷新数据(文件),这会对恢复和整体性能产生巨大的影响。尽管可以影响它的行为,InnoDB的恢复流程实际上是自动的,并且经常在InnoDB启动时运行。撇开恢复并假设InnoDB没有崩溃或者出错, InnoDB依然有很多需要配置的地方。它有一系列复杂的缓存和文件设计可以提升性能,以及保证ACID特性,并且每一部分都是可配置的,图8-1阐述了这些文件和缓存。\n对于常见的应用,最重要的一小部分内容是InnoDB日志文件大小、InnoDB怎样刷新它的日志缓冲,以及InnoDB怎样执行I/O。\n图8-1:InnoDB的缓存和文件\nInnoDB事务日志\nInnoDB使用日志来减少提交事务时的开销。因为日志中已经记录了事务,就无须在每个事务提交时把缓冲池的脏块刷新(flush)到磁盘中。事务修改的数据和索引通常会映射到表空间的随机位置,所以刷新这些变更到磁盘需要很多随机I/O。InnoDB假设使用的是常规磁盘(机械磁盘),随机I/O比顺序I/O要昂贵得多,因为一个I/O请求需要时间把磁头移到正确的位置,然后等待磁盘上读出需要的部分,再转到开始位置。\nInnoDB用日志把随机I/O变成顺序I/O。一旦日志安全写到磁盘,事务就持久化了,即使变更还没写到数据文件。如果一些糟糕的事情发生了(例如断电了),InnoDB可以重放日志并且恢复已经提交的事务。\n当然,InnoDB最后还是必须把变更写到数据文件,因为日志有固定的大小。InnoDB的日志是环形方式写的:当写到日志的尾部,会重新跳转到开头继续写,但不会覆盖还没应用到数据文件的日志记录,因为这样做会清掉已提交事务的唯一持久化记录。\nInnoDB使用一个后台线程智能地刷新这些变更到数据文件。这个线程可以批量组合写入,使得数据写入更顺序,以提高效率。实际上,事务日志把数据文件的随机I/O转换为几乎顺序的日志文件和数据文件I/O。把刷新操作转移到后台使查询可以更快完成,并且缓和查询高峰时I/O系统的压力。\n整体的日志文件大小受控于innodb_log_file_size和innodb_log_files_in_group两个参数,这对写性能非常重要。日志文件的总大小是每个文件的大小之和。默认情况下,只有两个5MB的文件,总共10MB。对高性能工作来说这太小了。至少需要几百MB,或者甚至上GB的日志文件。\nInnoDB使用多个文件作为一组循环日志。通常不需要修改默认的日志数量,只修改每个日志文件的大小即可。要修改日志文件大小,需要完全关闭MySQL,将旧的日志文件移到其他地方保存,重新配置参数,然后重启。一定要确保MySQL干净地关闭了,或者还有日志文件可以保证需要应用到数据文件的事务记录,否则数据库就无法恢复了!当重启服务器的时候,查看MySQL的错误日志。在重启成功之后,才可以删除旧的日志文件。\n日志文件大小和日志缓存。要确定理想的日志文件大小,必须权衡正常数据变更的开销和崩溃恢复需要的时间。如果日志太小,InnoDB将必须做更多的检查点,导致更多的日志写。在极个别情况下,写语句可能被拖累,在日志没有空间继续写入前,必须等待变更被应用到数据文件。另一方面,如果日志太大了,在崩溃恢复时InnoDB可能不得不做大量的工作。这可能极大地增加恢复时间,尽管这个处理在新的MySQL版本中已经改善很多。\n数据大小和访问模式也将影响恢复时间。假设有一个1TB的数据和16GB的缓冲池,并且全部日志大小是128MB。如果缓冲池里有很多脏页(例如,页被修改了还没被刷写回数据文件),并且它们均匀地分布在1TB数据中,崩溃后恢复将需要相当长一段时间。InnoDB必须从头到尾扫描日志,仔细检查数据文件,如果需要还要应用变更到数据文件。这是很庞大的读写操作!另一方面,如果变更是局部性的——就是说,如果只有几百MB数据被频繁地变更——恢复可能就很快,即使数据和日志文件很大。恢复时间也依赖于普通修改操作的大小,这跟数据行的平均长度有关系。较短的行使得更多的修改可以放在同样的日志中,所以InnoDB可能必须在恢复时重放更多修改操作(13)。\n当InnoDB变更任何数据时,会写一条变更记录到内存日志缓冲区。在缓冲满的时候、事务提交的时候,或者每一秒钟,InnoDB都会刷写缓冲区的内容到磁盘日志文件——无论上述三个条件哪个先达到。如果有大事务,增加日志缓冲区(默认1MB)大小可以帮助减少I/O。变量innodb_log_buffer_size可以控制日志缓冲区的大小。\n通常不需要把日志缓冲区设置得非常大。推荐的范围是1MB~8MB,一般来说足够了,除非要写很多相当大的BLOB记录。相对于InnoDB的普通数据,日志条目是非常紧凑的。它们不是基于页的,所以不会浪费空间来一次存储整个页。InnoDB也使得日志条目尽可能地短。有时甚至会保存为函数号和C函数的参数!\n较大的日志缓冲区在某些情况下也是有好处的:可以减少缓冲区中空间分配的争用。当配置一台有大内存的服务器时,有时简单地分配32MB~128MB的日志缓冲,因为花费这么点相对(整机)而言比较小的内存并没有什么不好,还可以帮助避免压力瓶颈。如果有问题,瓶颈一般会表现为日志缓冲Mutex的竞争。\n可以通过检查SHOW INNODB STATUS的输出中LOG部分来监控InnoDB的日志和日志缓冲区的I/O性能,通过观察Innodb_os_log_written状态变量来查看InnoDB对日志文件写出了多少数据。一个好用的经验法则是,查看10~100秒间隔的数字,然后记录峰值。可以用这个来判断日志缓冲是否设置得正好。例如,若看到峰值是每秒写100KB数据到日志,那么1MB的日志缓冲可能足够了。也可以使用这个衡量标准来决定日志文件设置多大会比较好。如果峰值是100KB/s,那么256MB的日志文件足够存储至少2 560秒的日志记录。这看起来足够了。作为一个经验法则,日志文件的全部大小,应该足够容纳服务器一个小时的活动内容。\nInnoDB怎样刷新日志缓冲。当InnoDB把日志缓冲刷新到磁盘日志文件时,先会使用一个Mutex锁住缓冲区,刷新到所需要的位置,然后移动剩下的条目到缓冲区的前面。当Mutex释放时,可能有超过一个事务已经准备好刷新其日志记录。InnoDB有一个Group Commit功能,可以在一个I/O操作内提交多个事务,但是在MySQL 5.0中当打开二进制日志时这个功能就不能用了。我们在前一章写了一些关于Group Commit的东西。\n日志缓冲必须被刷新到持久化存储,以确保提交的事务完全被持久化了。如果和持久相比更在乎性能,可以修改innodb_flush_log_at_trx_commit变量来控制日志缓冲刷新的频繁程度。可能的设置如下:\n0\n把日志缓冲写到日志文件,并且每秒钟刷新一次,但是事务提交时不做任何事。\n1\n将日志缓冲写到日志文件,并且每次事务提交都刷新到持久化存储。这是默认的(并且是最安全的)设置,该设置能保证不会丢失任何已经提交的事务,除非磁盘或者操作系统是“伪”刷新。\n2\n每次提交时把日志缓冲写到日志文件,但是并不刷新。InnoDB每秒钟做一次刷新。0与2最重要的不同是(也是为什么2是更合适的设置),如果MySQL进程“挂了”, 2不会丢失任何事务。如果整个服务器“挂了”或者断电了,则还是可能会丢失一些事务。\n了解清楚“把日志缓冲写到日志文件”和“把日志刷新到持久化存储”之间的不同是很重要的。在大部分操作系统中,把缓冲写到日志只是简单地把数据从InnoDB的内存缓冲转移到了操作系统的缓存,也是在内存里,并没有真的把数据写到了持久化存储。\n因此,如果MySQL崩溃了或者电源断电了,设置0和2通常会导致最多一秒的数据丢失,因为数据可能只存在于操作系统的缓存。我们说“通常”,因为不论如何InnoDB会每秒尝试刷新日志文件到磁盘,但是在一些场景下也可能丢失超过1秒的事务,例如当刷新被推迟了。\n与此相反,把日志刷新到持久化存储意味着InnoDB请求操作系统把数据刷出缓存,并且确认写到磁盘了。这是一个阻塞I/O的调用,直到数据被完全写回才会完成。因为写数据到磁盘比较慢,当innodb_flush_log_at_trx_commit被设置为1时,可能明显地降低InnoDB每秒可以提交的事务数。今天的高速驱动器(14)可能每秒只能执行一两百个磁盘事务,受限于磁盘旋转速度和寻道时间。\n有时硬盘控制器或者操作系统假装做了刷新,其实只是把数据放到了另一个缓存,例如磁盘自己的缓存。这更快但是很危险,因为如果驱动器断电,数据依然可能丢失。这甚至比设置innodb_flush_log_at_trx_commit为不为1的值更糟糕,因为这可能导致数据损坏,不仅仅是丢失事务。\n设置innodb_flush_log_at_trx_commit为不为1的值可能导致丢失事务。然而,如果不在意持久性(ACID中的D),那么设置为其他的值也是有用的。也许你只是想拥有InnoDB的其他一些功能,例如聚簇索引、防止数据损坏,以及行锁。但仅仅因为性能原因用InnoDB替换MyISAM的情况也并不少见。\n高性能事务处理需要的最佳配置是把innodb_flush_log_at_trx_commit设置为1且把日志文件放到一个有电池保护的写缓存的RAID卷中。这兼顾了安全和速度。事实上,我们敢说任何希望能扛过高负荷工作负载的产品数据库服务器,都需要有这种类型的硬件。\nPercona Server扩展了innodb_fush_log_at_trx_commit变量,使得它成为一个会话级变量,而不是一个全局变量。这允许有不同的性能和持久化要求的应用,可以使用同样的数据库,同时又避免了标准MySQL提供的一刀切的解决方案。\nInnoDB怎样打开和刷新日志以及数据文件 # 使用innodb_fush_method选项可以配置InnoDB如何跟文件系统相互作用。从名字来看,会以为只能影响InnoDB怎么写数据,实际上还影响了InnoDB怎么读数据。Windows和非Windows的操作系统对这个选项的值是互斥的:async_unbuffered、unbuffered和normal只能在Windows下使用,并且Windows下不能使用其他的值。在Windows下默认值是unbuffered,其他操作系统都是fdatasync。(如果SHOW GLOBAL VARIABLES显示这个变量为空,意味着它被设置为默认值了。)\n改变InnoDB执行I/O操作的方式可以显著地影响性能,所以请确认你明白了在做什么后再去做改动!\n这是个有点难以理解的选项,因为它既影响日志文件,也影响数据文件,而且有时候对不同类型的文件的处理也不一样。如果有一个选项来配置日志,另一个选项来配置数据文件,这样最好了,但实际上它们混合在同一个配置项中。\n下面是一些可能的值:\nfdatasync\n这在非Windows系统上是默认值:InnoDB用fsync()来刷新数据和日志文件。\nInnoDB通常用fsync()代替fdatasync(),即使这个值似乎表达的是相反的意思。fdatasync()跟fsync()相似,但是只刷新文件的数据,而不包括元数据(最后修改时间,等等)。因此,fsync()会导致更多的I/O。然而InnoDB的开发者都很保守,他们发现某些场景下fdatasync()会导致数据损坏。InnoDB决定了哪些方法可以更安全地使用,有一些是编译时设置的,也有一些是运行时设置的。它使用尽可能最快的安全方法。\n使用fsync()的缺点是操作系统至少会在自己的缓存中缓冲一些数据。理论上,这种双重缓冲是浪费的,因为InnoDB管理自己的缓冲比操作系统能做的更加智能。然而,最后的影响跟操作系统和文件系统非常相关。如果能让文件系统做更智能的I/O调度和批量操作,双重缓冲可能并不是坏事。有的文件系统和操作系统可以积累写操作后合并执行,通过对I/O重新排序来提升效率,或者并发写入多个设备。它们也可能做预读优化,例如,若连续请求了几个顺序的块,它会通知硬盘预读下一个块。\n有时这些优化有帮助,有时没有。如果你好奇你的系统中的fsync()会做哪些具体的事,可以阅读系统的帮助手册,看下fsync(2)。\ninnodb_file_per_table选项会导致每个文件独立地做fsync(),这意味着写多个表不能合并到一个I/O操作。这可能导致InnoDB执行更多的fsync()操作。\nO_DIRECT\nInnoDB对数据文件使用O_DIRECT标记或directio()函数,这依赖于操作系统。这个设置并不影响日志文件并且不是在所有的类UNIX系统上都有效。但至少GNU/Linux、FreeBSD,以及Solaris(5.0以后的新版本)是支持的。不像O_DSYNC标记,它同时会影响读和写。\n这个设置依然使用fsync()来刷新文件到磁盘,但是会通知操作系统不要缓存数据,也不要用预读。这个选项完全关闭了操作系统缓存,并且使所有的读和写都直接通过存储设备,避免了双重缓冲。\n在大部分系统上,这个实现用fcntl()调用来设置文件描述符的O_DIRECT标记,所以可以阅读fcntl(2)的手册页来了解系统上这个函数的细节。在Solaris系统,这个选项用directio()。\n如果RAID卡支持预读,这个设置不会关闭RAID卡的预读(15)。这个设置只能关闭操作系统和文件系统的预读。\n如果使用O_DIRECT选项,通常需要带有写缓存的RAID卡,并且设置为Write-Back策略(16),因为这是典型的唯一能保持好性能的方法。当InnoDB和实际存储设备之间没有缓冲时使用O_DIRECT,例如当RAID卡没有写缓存时,可能导致严重的性能下降。现在有了多个写线程,这个问题稍微小一点(并且MySQL 5.5提供了原生异步I/O),但是通常还是有问题。\n这个选项可能导致服务器预热时间变长,特别是操作系统的缓存很大的时候。也可能导致小容量的缓冲池(例如,默认大小的缓冲池)比缓冲I/O(Buffered IO)方式操作要慢的多。这是因为操作系统不会通过保持更多数据在自己的缓存中来“帮助”(提升性能)。如果需要的数据不在缓冲池,InnoDB将不得不直接从磁盘读取。\n这个选项不会对innodb_file_per_table产生任何额外的损失。相反,如果不用innodb_file_per_table,当使用O_DIRECT时,可能由于一些顺序I/O而遭受性能损失。这种情况的发生是因为一些文件系统(包括Linux所有的ext文件系统)每个inode有一个Mutex。当在这些文件系统上使用O_DIRECT时,确实需要打开innodb_file_per_table。我们下一章会更深入地探究文件系统。\nALL_O_DIRECT\n这个选项在Percona Server和MariaDB中可用。它使得服务器在打开日志文件时,也能使用标准MySQL中打开数据文件的方式(O_DIRECT)。\nO_DSYNC\n这个选项使日志文件调用open()函数时设置O_SYNC标记。它使得所有的写同步——换个说法,只有数据写到磁盘后写操作才返回。这个选项不影响数据文件。\nO_SYNC标记和O_DIRECT标记的不同之处在于O_SYNC没有禁用操作系统层的缓存。因此,它没有避免双重缓冲,并且它没有使写操作直接操作到磁盘。用了O_SYNC标记,在缓存中写数据,然后发送到磁盘。\n使用O_SYNC标记做同步写操作,听起来可能跟fsync()做的事情非常相似,但是它们两个的实现无论在操作系统层还是在硬件层都非常不同。用了O_SYNC标记后,操作系统可能把“使用同步I/O”标记下传给硬件层,告诉设备不要使用缓存。另一方面,fsync()告诉操作系统把修改过的缓冲数据刷写到设备上,如果设备支持,紧接着会传递一个指令给设备刷新它自己的缓存,所以,毫无疑问,数据肯定记录在了物理媒介上。另一个不同是,用了O_SYNC的话,每个write()或pwrite()操作都会在函数完成之前把数据同步到磁盘,完成前函数调用是阻塞的。相对来看,不用O_SYNC标记的写入调用fsync()允许写操作积累在缓存(使得每个写更快),然后一次性刷新所有的数据。\n再一次吐槽下这个名称,这个选项设置O_SYNC标记,不是O_DSYNC标记,因为InnoDB开发者发现了O_DSYNC的Bug。O_SYNC和O_DSYNC类似于fysnc()和fdatasync():O_SYNC同时同步数据和元数据,但是O_DSYNC只同步数据。\nasync_unbuffered\n这是Windows下的默认值。这个选项让InnoDB对大部分写使用没有缓冲的I/O;例外是当innodb_flush_log_at_trx_commit设置为2的时候,对日志文件使用缓冲I/O。\n这个选项使得InnoDB在Windows 2000、XP,以及更新版本中对数据读写都使用操作系统的原生异步(重叠的)I/O。在更老的Windows版本中,InnoDB使用自己用多线程模拟的异步I/O。\nunbuffered\n只对Windows有效。这个选项与async_unbuffered类似,但是不使用原生异步I/O。\nnormal\n只对Windows有效。这个选项让InnoDB不要使用原生异步I/O或者无缓冲I/O。\nNosync和littlesync\n只为开发使用。这两个选项在文档中没有并且对生产环境来说不安全,不应该使用这个。\n如果这些看起来像是一堆不带建议的说明,那么下面是一些建议:如果使用类UNIX操作系统并且RAID控制器带有电池保护的写缓存,我们建议使用O_DIRECT。如果不是这样,默认值或者O_DIRECT都可能是最好的选择,具体要看应用类型。\nInnoDB表空间 # InnoDB把数据保存在表空间内,本质上是一个由一个或多个磁盘文件组成的虚拟文件系统。InnoDB用表空间实现很多功能,并不只是存储表和索引。它还保存了回滚日志(旧版本行)、插入缓冲(Insert Buffer)、双写缓冲(Doublewrite Buffer,后面的章节里就会描述),以及其他内部数据结构。\n配置表空间。通过innodb_data_file_path配置项可以定制表空间文件。这些文件都放在innodb_data_home_dir指定的目录下。这是一个例子:\ninnodb_data_home_dir = /var/lib/mysql/ innodb_data_file_path = ibdata1:1G;ibdata2:1G;ibdata3:1G 这里在三个文件中创建了3GB的表空间。有时人们并不清楚可以使用多个文件分散驱动器的负载,像这样:\ninnodb_data_file_path = /disk1/ibdata1:1G;/disk2/ibdata2:1G;... 在这个例子中,表空间文件确实放在代表不同驱动器的不同目录中,InnoDB把这些文件首尾相连组合起来。因此,通常这种方式并不能获得太多收益。InnoDB先填满第一个文件,当第一个文件满了再用第二个,如此循环;负载并没有真的按照希望的高性能方式分布。用RAID控制器是分布负载更聪明的方式。\n为了允许表空间在超过了分配的空间时还能增长,可以像这样配置最后一个文件自动扩展:\n...ibdata3:1G:autoextend 默认的行为是创建单个10MB的自动扩展文件。如果让文件可以自动扩展,那么最好给表空间大小设置一个上限,别让它扩展得太大,因为一旦扩展了,就不能收缩回来。例如,下面的例子限制了自动扩展文件最多到2GB:\n...ibdata3:1G:autoextend:max:2G 管理一个单独的表空间可能有点麻烦,尤其是如果它是自动扩展的,并且希望回收空间时(因为这个原因,我们建议关闭自动扩展功能,至少设置一个合理的空间范围)。回收空间唯一的方式是导出数据,关闭MySQL,删除所有文件,修改配置,重启,让InnoDB创建新的数据文件,然后导入数据。InnoDB这种表空间管理方式很让人头疼——不能简单地删除文件或者改变大小。如果表空间损坏了,InnoDB会拒绝启动。对日志文件也一样的严格。如果像MyISAM一样随便移动文件,千万要谨慎!\ninnodb_file_per_table选项让InnoDB为每张表使用一个文件,MySQL 4.1和之后的版本都支持。它在数据字典存储为“表名.ibd”的数据。这使得删除一张表时回收空间简单多了,并且可以容易地分散表到不同的磁盘上。然而,把数据放到多个文件,总体来说可能导致更多的空间浪费,因为把单个InnoDB表空间的内部碎片浪费分布到了多个*.ibd*文件。对于非常小的表,这个问题更大,因为InnoDB的页大小是16 KB。即使表只有1 KB的数据,仍然需要至少16 KB的磁盘空间。\n即使打开innodb_file_per_table选项,依然需要为回滚日志和其他系统数据创建共享表空间。没有把所有数据存在其中是明智的做法,但最好还是关闭它的自动增长,因为无法在不重新导入全部数据的情况下给共享表空间瘦身。\n一些人喜欢使用innodb_file_per_table,只是因为特别容易管理,并且可以看到每个表的文件。例如,可以通过查看文件的大小来确认表的大小,这比用SHOW TABLE STATUS来看快多了,这个命令需要执行很多复杂的工作来判断给一个表分配了多少页面。\n设置innodb_file_per_table也有不好的一面:更差的DROP TABLE性能。这可能足以导致显而易见的服务器端阻塞。因为有如下两个原因: 删除表需要从文件系统层去掉(删除)文件,这可能在某些文件系统(ext3,说的就是你)上会很慢。可以通过欺骗文件系统来缩短这个过程:把.ibd文件链接到一个0字节的文件,然后手动删除这个文件,而不用等待MySQL来做。 当打开这个选项,每张表都在InnoDB中使用自己的表空间。结果是,移除表空间实际上需要InnoDB锁定和扫描缓冲池,查找属于这个表空间的页面,在一个有庞大的缓冲池的服务器上做这个操作是非常慢的。如果打算删除很多InnoDB表(包括临时表)并且用了innodb_file_per_table,可能会从Percona Server包含的一个修复中获益,它可以让服务器慢慢地清理掉属于被删除表的页面。只需要设置innodb_lazy_drop_table这个选项。 什么是最终的建议?我们建议使用innodb_file_per_table并且给共享表空间设置大小范围,这样可以过得舒服点(不用处理那些空间回收的事)。如果遇到任何头痛的场景,就像上面说的,考虑用下Percona的那个修复。\n提醒一下,事实上没有必要把InnoDB文件放在传统的文件系统上。像许多的传统数据库服务器一样,InnoDB提供使用裸设备的选项——例如,一个没有格式化的分区——作为它的存储。然而,今天的文件系统已经可以存放足够大的文件,所以已经没有必要使用这个选项。使用裸设备可能提升几个百分点的性能,但是我们不认为这点小提升足以抵消这样做带来的坏处,我们不能直接用文件管理数据。当把数据存在一个裸设备分区时,不能使用mv、cp或其他任何工具来操作它。最终,这点小的性能收益显然不值得。\n行的旧版本和表空间 在一个写压力大的环境下,InnoDB的表空间可能增长得非常大。如果事务保持打开状态很久(即使它们没有做任何事),并且使用默认的REPEATABLE READ事务隔离级别,InnoDB将不能删除旧的行版本,因为没提交的事务依然需要看到它们。InnoDB把旧版本存在共享表空间,所以如果有更多的数据在更新,共享表空间会持续增长。有时这个问题并非是没提交的事务的原因,也可能是工作负载的问题:清理过程只有一个线程处理,直到最近的MySQL版本才改进,这可能导致清理线程处理速度跟不上旧版本行数增加的速度。\n无论发生何种情况,SHOW INNODB STATUS的数据都可以帮助定位问题。查看历史链表的长度会显示了回滚日志的大小,以页为单位。\n分析TRANSACTIONS部分的第一行和第二行可以证实这个观点,这部分展示了当前事务号以及清理线程完成到了哪个点。如果这个差距很大,可能有大量的没有清理的事务。\n这有个例子:\n------------ TRANSACTIONS ------------ Trx id counter 0 80157601 Purge done for trx's n:o \u0026lt;0 80154573 undo n:o \u0026lt;0 0 事务标识是一个64比特的数字,由两个32比特的数字(在更新版本的InnoDB中这是个十六进制的数字)组成,所以需要做一点数学计算来计算差距。在这个例子中就很简单了,因为最高位是0:那么有80 157 601–80 154 573=3 028个“潜在的”没有被清理的事务(innotop可以做这个计算)。我们说“潜在的”,是因为这跟有很多没有清理的行是有很大区别的。只有改变了数据的事务才会创建旧版本的行,但是有很多事务并没有修改数据(相反的,一个事务也可能修改很多行)。\n如果有个很大的回滚日志并且表空间因此增长很快,可以强制MySQL减速来使InnoDB的清理线程可以跟得上。这听起来不怎么样,但是没办法。否则,InnoDB将保持数据写入,填充磁盘直到最后磁盘空间爆满,或者表空间大于定义的上限。\n为了控制写入速度,可以设置innodb_max_purge_lag变量为一个大于0的值。这个值表示InnoDB开始延迟后面的语句更新数据之前,可以等待被清除的最大的事务数量。你必须知道工作负载以决定一个合理的值。例如,事务平均影响1KB的行,并且可以容许表空间里有100MB的未清理行,那么可以设置这个值为100000。\n牢记,没有清理的行版本会对所有的查询产生影响,因为它们事实上使得表和索引更大了。如果清理线程确实跟不上,性能可能显著的下降。设置innodb_max_purge_lag变量也会降低性能,但是它的伤害较少。(17)\n在更新版本的MySQL中,甚至在更早版本的Percona Server和MariaDB,清理过程已经显著地提升了性能,并且从其他内部工作任务中分离出来。甚至可以创建多个专用的清理线程来更快地做这个后台工作。如果可以利用这些特性,会比限制服务器的服务能力要好得多。\n双写缓冲(Doublewrite Buffer) # InnoDB用双写缓冲来避免页没写完整所导致的数据损坏。当一个磁盘写操作不能完整地完成时,不完整的页写入就可能发生,16KB的页可能只有一部分被写到磁盘上。有多种多样的原因(崩溃、Bug,等等)可能导致页没有写完整。双写缓冲在这种情况发生时可以保证数据完整性。\n双写缓冲是表空间一个特殊的保留区域,在一些连续的块中足够保存100个页。本质上是一个最近写回的页面的备份拷贝。当InnoDB从缓冲池刷新页面到磁盘时,首先把它们写(或者刷新)到双写缓冲,然后再把它们写到其所属的数据区域中。这可以保证每个页面的写入都是原子并且持久化的。\n这意味着每个页都要写两遍?是的,但是因为InnoDB写页面到双写缓冲是顺序的,并且只调用一次fsync()刷新到磁盘,所以实际上对性能的冲击是比较小的——通常只有几个百分点,肯定没有一半那么多,尽管这个开销在SSD上更明显,我们下一章会讨论这个问题。更重要的是,这个策略允许日志文件更加高效。因为双写缓冲给了InnoDB一个非常牢固的保证,数据页不会损坏,InnoDB日志记录没必要包含整个页,它们更像是页面的二进制变化量。\n如果有一个不完整的页写到了双写缓冲,原始的页依然会在磁盘上它的真实位置。当InnoDB恢复时,它将用原始页面替换掉双写缓冲中的损坏页面。然而,如果双写缓冲成功写入,但写到页的真实位置失败了,InnoDB在恢复时将使用双写缓冲中的拷贝来替换。InnoDB知道什么时候页面损坏了,因为每个页面在末尾都有校验值(Checksum)。校验值是最后写到页面的东西,所以如果页面的内容跟校验值不匹配,说明这个页面是损坏的。因此,在恢复的时候,InnoDB只需要读取双写缓冲中每个页面并且验证校验值。如果一个页面的校验值不对,就从它的原始位置读取这个页面。\n有些场景下,双写缓冲确实没必要——例如,你也许想在备库上禁止双写缓冲。此外一些文件系统(例如ZFS)做了同样的事,所以没必要再让InnoDB做一遍。可以通过设置innodb_doublewrite为0来关闭双写缓冲。在Percona Server中,可以配置双写缓冲存到独立的文件中,所以可以把这部分工作压力分离出来放在单独的盘上。\n其他的I/O配置项 # sync_binlog选项控制MySQL怎么刷新二进制日志到磁盘。默认值是0,意味着MySQL并不刷新,由操作系统自己决定什么时候刷新缓存到持久化设备。如果这个值比0大,它指定了两次刷新到磁盘的动作之间间隔多少次二进制日志写操作(如果autocommit被设置了,每个独立的语句都是一次写,否则就是一个事务一次写)。把它设置为0和1以外的值是很罕见的。\n如果没有设置sync_binlog为1,那么崩溃以后可能导致二进制日志没有同步事务数据。这可以轻易地导致复制中断,并且使得及时恢复变得不可能。无论如何,可以把这个值设置为1来获得安全的保障。这样就会要求MySQL同步把二进制日志和事务日志这两个文件刷新到两个不同的位置。这可能需要磁盘寻道,相对来说是个很慢的操作。\n像InnoDB日志文件一样,把二进制日志放到一个带有电池保护的写缓存的RAID卷,可以极大地提升性能。事实上,写和刷新二进制日志缓存其实比InnoDB事务日志要昂贵多了,因为不像InnoDB事务日志,每次写二进制日志都会增加它们的大小。这需要每次写入文件系统都更新元信息。所以,设置sync_binlog=1可能比innodb_fush_log_at_trx_commit=1对性能的损害要大得多,尤其是网络文件系统,例如NFS。\n一个跟性能无关的提示,关于二进制日志:如果希望使用expire_logs_days选项来自动清理旧的二进制日志,就不要用rm命令去删。服务器会感到困惑并且拒绝自动删除它们,并且PURGE MASTER LOGS也将停止工作。解决的办法是,如果发现了这种情况,就手动重新同步“主机名-bin.index”文件,可以用磁盘上现有日志文件的列表来更新。\n我们将在下一章更深入地涉及RAID,但是值得在这里重复一下,把带有电池保护写缓存的高质量RAID控制器设置为使用写回(Writeback)策略,可以支持每秒数千的写入,并且依然会保证写到持久化存储。数据写到了带有电池的高速缓存,所以即使系统断电它也能存在。但电源恢复时,RAID控制器会在磁盘被设置为可用前,把数据从缓存中写到磁盘。因此,一个带有电池保护写缓存的RAID控制器可以显著地提升性能,这是非常值得的投资。当然,SSD存储是另一个选择,我们也会在下一章讲到。\n8.5.2 MyISAM的I/O配置 # 让我们从分析MyISAM怎么为索引操作I/O开始。MyISAM通常每次写操作之后就把索引变更刷新磁盘。如你打算在一张表上做很多修改,那么毫无疑问,批量操作会更快一些。一种办法是用LOCK TABLES延迟写入,直到解锁这些表。这是个提升性能的很有价值的技巧,因为它使得你精确控制哪些写被延迟,以及什么时候把它们刷到磁盘。可以精确延迟那些希望延迟的语句。\n通过设置delay_key_write变量,也可以延迟索引的写入。如果这么做,修改的键缓冲块直到表被关闭才会刷新。(18)可能的配置如下:\nOFF\nMyISAM每次写操作后刷新键缓冲(键缓存,Key Buffer)中的脏块到磁盘,除非表被LOCK TABLES锁定了。\nON\n打开延迟键写入,但是只对用DELAY_KEY_WRITE选项创建的表有效。\nALL\n所有的MyISAM表都会使用延迟键写入。\n延迟键写入在某些场景下可能很有帮助,但是通常不会带来很大的性能提升。当键缓冲的读命中很好但写命中不好时,数据又比较小,这可能很有用。当然也有一小部分缺点:\n如果服务器缓存并且块没有被刷到磁盘,索引可能会损坏。 如果很多写被延迟了,MySQL可能需要花费更长时间去关闭表,因为必须等待缓冲刷新到磁盘。在MySQL 5.0这可能引起很长的表缓存锁。 由于上面提到的原因,FLUSH TABLES可能需要很长时间。如果为了做逻辑卷(LVM)快照或者其他备份操作,而执行FLUSH TABLES WITH READ LOCK,那可能增加操作的时间。 键缓冲中没有刷回去的脏块可能占用空间,导致从磁盘上读取的新块没有空间存放。因此,查询语句可能需要等待MyISAM释放一些键缓存的空间。 另外,除了配置MyISAM的索引I/O还可以配置MyISAM怎样尝试从损坏中恢复。myisam_recover选项控制MyISAM怎样寻找和修复错误。需要在配置文件或者命令行中设置这个选项。可以通过下面的SQL语句查看选项的值,但是不能修改(这不是个印刷错误——系统里变量名跟命令的变量名有差异):\nmysql\u0026gt; ** SHOW VARIABLES LIKE 'myisam_recover_options';** 打开这个选项通知MySQL在表打开时,检查是否损坏,并且在找到问题的时候进行修复。可以设置的值如下:\nDEFAULT(或者不设置)\n使MySQL尝试修复任何被标记为崩溃或者没有标记为完全关闭的表。默认值不要求在恢复时执行其他动作。跟大多数变量不同,这里DEFAULT值不是重置变量的值为编译值;它本质上意味着“没有设置”。\nBACKUP\n让MySQL将数据文件的备份写到*.BAK*文件,以便随后进行检查。\nFORCE\n即使*.MYD*文件中丢失的数据可能超过一行,也让恢复继续。\nQUICK\n除非有删除块,否则跳过恢复。块中有已经删除的行也依然会占用空间,但是可以被后面的INSERT语句重用。这可能比较有用,因为MyISAM大表的恢复可能花费相当长的时间。\n可以使用多个设置,用逗号分隔。例如“BACKUP,FORCE”会强制恢复并且创建备份。这是为什么我们在这一章前面部分的示例配置中这么用的原因。\n我们建议打开这个选项,尤其是只有一些小的MyISAM表时。服务器运行着一些损坏的MyISAM表是很危险的,因为它们有时可以导致更多数据损坏,甚至服务器崩溃。然而,如果有很大的表,原子恢复是不切实际的:它导致服务器打开所有的MyISAM表时都会检查和修复,这是低效的做法。在这段时间,MySQL会阻止连接做任何工作。如果有一大堆的MyISAM表,比较好的主意还是启动后用CHECK TABLES和REPAIR TABLES命令来做(19),这样对服务器影响比较少。不管哪种方式,检查和修复表都是很重要的。\n打开数据文件的内存映射(MMAP)访问是另一个有用的MyISAM选项。内存映射使得MyISAM直接通过操作系统的页面缓存访问*.MYD*文件,避免系统调用的开销。在MySQL 5.1和更新的版本中,可以通过myisam_use_mmap选项打开内存映射。更老版本的MySQL只能对压缩的MyISAM表使用内存映射。\n8.6 配置MySQL并发 # 当MySQL承受高并发压力时,可能会遇到不曾遇到过的瓶颈。这个章节阐述了当这些问题出现的时候,怎样去发现它们,以及在MyISAM和InnoDB遇到这样的压力时怎样获得尽可能最好的性能。\n8.6.1 InnoDB并发配置 # InnoDB是为高性能设计的,在最近几年它的提升非常明显,但依然不完美。InnoDB架构在有限的内存、单CPU、单磁盘的系统中仍然暴露出一些根本性问题。在高并发场景下, InnoDB的某些方面的性能可能会降低,唯一的办法是限制并发。可以参考第3章中使用的技巧来诊断并发问题。\n如果在InnoDB并发方面有问题,解决方案通常是升级服务器。相比当前的版本,像MySQL 5.0和早期的MySQL 5.1这样的旧版本,在高并发下完全是个悲剧。所有的东西都在全局Mutex(例如,缓冲池Mutex)上排队,导致服务器几乎陷入停顿。如果升级到某个更新版本的MySQL,在大部分场景都不再需要限制并发。\n如果需要这么做,这里会介绍它是怎么工作的。InnoDB有自己的“线程调度器”控制线程怎么进入内核访问数据,以及它们在内核中一次可以做哪些事。最基本的限制并发的方式是使用innodb_thread_concurrency变量,它会限制一次性可以有多少线程进入内核,0表示不限制。如果在旧的MySQL版本里有InnoDB并发问题,这个变量是最重要的配置之一(20)。\n在任何架构和业务压力下,给这个变量设置个“靠谱”的值都很重要,理论上,下面的公式可以给出一个这样的值:\n并发值=CPU数量*磁盘数量*2 但是在实践中,使用更小的值会更好一点。必须做实验来找出适合系统的最好的值。\n如果已经进入内核的线程超过了允许的数量,新的线程就无法再进入内核。InnoDB使用两段处理来尝试让线程尽可能高效地进入内核。两段策略减少了因操作系统调度引起的上下文切换。线程第一次休眠innodb_thread_sleep_delay微秒,然后再重试。如果它依然不能进入内核,则放入一个等待线程队列,让操作系统来处理。\n第一阶段默认的休眠时间是10000微秒。当CPU有大量的线程处在“进入队列前的休眠”状态,因而没有被充分利用时,改变这个值在高并发环境里可能会有帮助。如果有大量的小查询,默认值可能也太大了,因为这增加了10毫秒的查询延时。\n一旦线程进入内核,它会有一定数量的“票据(Tickets)”,可以让它“免费”返回内核,不需再做并发检查。这限制了一个线程回到其他等待线程之前可以做多少事。innodb_concurrency_tickets选项控制票据的数量。它很少需要修改,除非有很多运行时间极长的查询。票据是按查询授权的,不是按事务。一旦查询完成,它没用完的票据就销毁了。除了缓冲池和其他结构的瓶颈,还有另一个提交阶段的并发瓶颈,这个时候I/O非常密集,因为需要做刷新操作。innodb_commit_concurrency变量控制有多少个线程可以在同一时间提交。如果innodb_thread_concurrency配置得很低也有大量的线程冲突,那么配置这个选项可能会有帮助。\n最后,有一个新的解决方案值得考虑:使用线程池(Thread Pool)来限制并发。原始的线程池实现已经随着MySQL 6.0的代码树一起被废弃了,并且有严重缺陷。但是MariaDB已经重新实现了,并且Oracle最近放出了一个商业插件可以为MySQL 5.5提供线程池功能。对这些东西我们都没有足够的经验来指导你怎么做,你也许会更加困惑,因为我们会指出这两种实现似乎都不满足Facebook,它在自己内部私有的MySQL分支中有一个叫做“准入控制”的特殊功能。如果可能的话,在这本书的第4版我们将分享一些线程池的知识,以及什么时候它们可以工作,什么时候不能工作。\n8.6.2 MyISAM并发配置 # 在某些条件下,MyISAM也允许并发插入和读取,这使得可以“调度”某些操作以尽可能少地产生阻塞。\n在讲述MyISAM的并发设置之前,理解MyISAM是怎样删除和插入行的,是非常重要的。删除操作不会重新整理整个表,它们只是把行标记为删除,在表中留下“空洞”。MyISAM倾向于在可能的时候填满这些空洞,在插入行时重新利用这些空间。如果没有空洞了,它就把新行插入表的末尾。\n尽管MyISAM是表级锁,它依然可以一边读取,一边并发追加新行。这种情况下只能读取到查询开始时的所有数据,新插入的数据是不可见的。这样可以避免不一致读。\n然而,若表中间的某些数据变动了的话,还是难以提供一致读。MVCC是解决这个问题最流行的方法:一旦修改者创建了新版本,它就让读取者读数据的旧版本。可是, MyISAM并不像InnoDB那样支持MVCC,所以除非插入操作在表的末尾,否则不能支持并发插入。\n通过设置concurrent_insert这个变量,可以配置MyISAM打开并发插入,可以配置为如下值:\n0\nMyISAM不允许并发插入,所有插入都会对表加互斥锁。\n1\n这是默认值。只要表中没有空洞,MyISAM就允许并发插入。\n2\n这个值在MySQL 5.0以及更新版本中有效。它强制并发插入到表的末尾,即使表中有空洞。如果没有线程从表中读取数据,MySQL将把新行放在空洞里。使用这个设置通常会使表更加碎片化。\n如果合并操作可以更加高效,也可以配置MySQL对一些操作进行延迟。举个实例,可以通过delay_key_write变量延迟写索引,正如这一章前面我们提到的。这牵涉到熟悉的权衡:立即写索引(安全但是昂贵),或者等待但是祈求在写发生前别断电(更快,但是遇到崩溃时可能引起巨大的索引损坏,因为索引文件已经过期了)。\n也可以让INSERT、REPLACE、DELETE、以及UPDATE语句的优先级比SELECT语句更低,设置low_priority_updates选项就可以了。这相当于把LOW_PRIORITY修饰符应用到全局UPDATE语句。当使用MyISAM时,这是个非常重要的选项,这让SELECT语句可以获得相当好的并发度,否则一小部分获取高优先级写锁的语句就可能导致SELECT无法获取资源。\n最后,尽管InnoDB的扩展性问题更经常被提及,但是MyISAM一样也有长时间获取Mutex的问题。在MySQL 4.0和更早版本里,有一个全局的Mutex保护所有的键缓存I/O,在多处理器和多磁盘环境下很容易引起扩展性问题。MySQL 4.1的键缓存代码做了改进,就不再有这些问题了,但是它依然对每个键缓冲区持有一个Mutex。当一个线程从键缓冲中复制键数据块到本地磁盘时会有竞争,从磁盘上读取时就没这个问题。磁盘瓶颈没了,但是当你在键缓冲里访问数据时,另一个瓶颈出现了。有时可以围绕这个问题把键缓冲分成多个区,但是这条路不总是行得通。例如,只涉及一个独立索引的时候,这问题就没有办法解决。于是,在多处理器的机器上SELECT查询并发可能相对单CPU的机器显著下降,即使当时只有这些SELECT查询在执行。\nMariaDB提供分开的(分区的)键缓冲,如果经常遇到这个问题,也许可以带来帮助。\n8.7 基于工作负载的配置 # 配置服务器的一个目标是把它定制得符合特定的工作负载。这需要精通所有类型的服务器活动的数量、类型,以及频率——不仅仅是查询语句,也包括其他的活动,例如连接服务器以及刷新表。\n第一件应该做的事情是熟悉你的服务器,如果还没做就赶紧。了解什么样的查询跑在上面。用例如innotop这样的工具来监控它,用pt-query-digest来创建查询报告。这不仅帮助你全面地了解服务器正在做什么,还可以知道查询花费大量时间做了哪些事。第3章阐明了怎么把这些东西找出来。\n当服务器在满载情况下运行时,请尝试记录所有的查询语句,因为这是最好的方式来查看哪种类型的查询语句占用资源最多。同时,创建processlist快照,通过state或者command字段来聚合它们(innotop可以实现,或者可以使用第3章展示的脚本)。例如,是否大量地在复制数据到临时表,或者排序数据?如果有,也许需要优化查询语句,以及查看临时表和排序缓冲配置项。\n8.7.1 优化BLOB和TEXT的场景 # BLOB和TEXT列对MySQL来说是特殊类型的场景(我们把所有BLOB和TEXT都简单称为BLOB类型,因为它们属于相同类型的数据)。BLOB值有几个限制使得服务器对它的处理跟其他类型不一样。一个最重要的注意事项是,服务器不能在内存临时表中存储BLOB值(21),因此,如果一个查询涉及BLOB值,又需要使用临时表——不管它多小——它都会立即在磁盘上创建临时表。这样效率很低,尤其是对小而快的查询。临时表可能是查询中最大的开销。\n有两种办法来减轻这个不利的情况:通过SUBSTRING()函数(第4章有更多关于这个函数的细节)把值转换为VARCHAR,或者让临时表更快一些。\n让临时表运行更快的最好方式是,把它们放在基于内存的文件系统(GNU/Linux上是tmpfs)。这会降低一些开销,尽管这依然比内存表慢许多。因为操作系统会避免把数据写到磁盘,所以内存文件系统可以帮助提升性能(22)。一般的文件系统也会在内存中缓存,但是操作系统会每隔几秒就刷新一次。tmpfs文件系统从来不会刷新,它就是为低开销和简单起见而设计的。例如,没必要为这个文件系统预备任何恢复方案。这使得它更快。\n服务器设置里控制临时表文件放在哪的是tmpdir。建议监控文件系统使用率以保证有足够的空间存放临时表。如果需要,可以指定多个临时表存放位置,MySQL将会轮询使用。\n如果BLOB列非常大,并且用的是InnoDB,也许可以调大InnoDB日志缓冲大小。在这一章前面有更多关于这方面的内容。\n对于很长的变长列(例如,BLOB、TEXT,以及长字符列),InnoDB存储一个768字节的前缀在行内(23)。如果列的值比前缀长,InnoDB会在行外分配扩展存储空间来存剩下的部分。它会分配一个完整的16KB的页,像其他所有的InnoDB页面一样,每个列都有自己的页面(不同的列不会共享扩展存储空间)。InnoDB一次只为一个列分配一个页的扩展存储空间,直到使用了超过32个页以后,就会一次性分配64个页面。\n注意,我们说过InnoDB可能会分配扩展存储空间。如果总的行长(包括大字段的完整长度)比InnoDB的最大行长限制要短(比8KB小一些),InnoDB将不会分配扩展存储空间,即使大字段(Long column)的长度超过了前缀长度。\n最后,当InnoDB更新存储在扩展存储空间中的大字段时,将不会在原来的位置更新。而是会在扩展存储空间中写一个新值到一个新的位置,并且不会删除旧的值。\n所有这一切都有以下后果:\n大字段在InnoDB里可能浪费大量空间。例如,若存储字段值只是比行的要求多了一个字节,也会使用整个页面来存储剩下的字节,浪费了页面的大部分空间。同样的,如果有一个值只是稍微超过了32个页的大小,实际上就需要使用96个页面。 扩展存储禁用了自适应哈希,因为需要完整地比较列的整个长度,才能发现是不是正确的数据(哈希帮助InnoDB非常快速地找到“猜测的位置”,但是必须检查“猜测的位置”是不是正确)。因为自适应哈希是完全的内存结构,并且直接指向Buffer Pool中访问“最”频繁的页面,但对于扩展存储空间却无法使用自适应哈希。 太长的值可能使得在查询中作为WHERE条件不能使用索引,因而执行很慢。在应用WHERE条件之前,MySQL需要把所有的列读出来,所以可能导致MySQL要求InnoDB读取很多扩展存储,然后检查WHERE条件,丢弃所有不需要的数据。查询不需要的列绝不是好主意,在这种特殊的场景下尤其需要避免这样做。如果发现查询正遇到这个限制带来的问题,可以尝试通过覆盖索引来解决部分问题。 如果一张表里有很多大字段,最好是把它们组合起来单独存到一个列里面,比如说用XML文档格式存储。这让所有的大字段共享一个扩展存储空间,这比每个字段用自己的页要好。 有时候可以把大字段用COMPRESS()压缩后再存为BLOB,或者在发送到MySQL前在应用程序中进行压缩,这可以获得显著的空间优势和性能收益。 8.7.2 优化排序(Filesorts) # 从第6章我们知道MySQL有两种排序算法。如果查询中所有需要的列和ORDER BY的列总大小超过max_length_for_sort_data字节,则采用two-pass算法。或者当任何需要的列——即使没有被ORDER BY使用的列——是BLOB或者TEXT,也会采用这个算法。(可以用SUBSTRING()把这些列转换一下,就可以用single-pass算法了。)\nMySQL有两个变量可以控制排序怎样执行。通过修改max_length_for_sort_data变量(24)的值,可以影响MySQL选择哪种排序算法。因为single-pass算法为每行需要排序的数据创建一个固定大小的缓冲,对于VARCHAR列,在和max_length_for_sort_data比较时,使用的是其定义的最大长度,而不是所存储数据的实际长度。这也是为什么我们建议只选择必要的列的一个原因。\n当MySQL必须排序BLOB或TEXT字段时,它只会使用前缀,然后忽略剩下部分的值。这是因为缓冲只能分配固定大小的结构体来保存要排序的值,然后从扩展存储空间中复制前缀到这个结构体中。使用max_sort_length变量可以指定这个前缀有多大。\n可惜,MySQL无法查看它用了哪个算法。如果增加了max_length_for_sort_data变量的值,磁盘使用率上升了,CPU使用率下降了,并且Sort_merge_passes状态变量相对于修改之前开始很快地上升,也许是强制让很多的排序使用了single-pass算法。\n8.8 完成基本配置 # 我们已经完成了服务器内核的旅程——希望你喜欢这个旅程!现在让我们回到示例配置,并且看下怎样修改剩下的配置。\n我们已经讨论了怎样设置一般的选项,例如数据目录、InnoDB和MyISAM缓存、日志,还有其他的一些。让我们重温剩下的那些:\ntmp_table_size和max_heap_table_size\n这两个设置控制使用Memory引擎的内存临时表能使用多大的内存。如果隐式内存临时表的大小超过这两个设置的值,将会被转换为磁盘MyISAM表,所以它的大小可以继续增长。(隐式临时表是一种并非由自己创建,而是服务器创建,用于保存执行中的查询的中间结果的表。)\n应该简单地把这两个变量设为同样的值。我们的示例配置文件中选择了32M。这可能不够,但是要谨防这个变量太大了。临时表最好呆在内存里,但是如果它们被撑得很大,实际上还是让它们使用磁盘比较好,否则可能会让服务器内存溢出。\n假设查询语句没有创建庞大的临时表(通常可以通过合理的索引和查询设计来避免),那把这些变量设大一点,免得需要把内存临时表转换为磁盘临时表。这个过程可以在SHOW PROCESSLIST中看到。\n可以查看服务器的SHOW STATUS计数器在某段时间内的变化,以此来查看创建临时表的频率以及是否是磁盘临时表。你不能判断一张(临时)表是先创建为内存表然后被转换为了磁盘表,还是一开始就创建的磁盘表(可能因为有BLOB字段),但是至少可以看到创建磁盘临时表有多频繁。仔细检查Created_tmp_disk_tables和Created_tmp_tables变量。\nmax_connections\n这个设置的作用就像一个紧急刹车,以保证服务器不会因应用程序激增的连接而不堪重负。如果应用程序有问题,或者服务器遇到如连接延迟的问题,会创建很多新连接。但是如果不能执行查询,那打开一个连接没有好处,所以被“太多的连接”的错误拒绝是一种快速而代价小的失败方式。\n把max_connections设置得足够高,以容纳正常可能达到的负载,并且要足够安全,能保证允许你登录和管理服务器。例如,若认为正常情况将有300或者更多连接,则可以设置为500或者更多。如果不知道将会有多少连接,500也不是一个不合理的起点。默认值是100,对大部分应用来说这都不够。\n要时时小心可能遇到连接限制的突然袭击。例如,若重新启动应用服务器,可能没有把它的连接关闭干净,同时MySQL可能没有意识到它们已经被关闭了。当应用服务器重新开始运转,并试图打开到数据库的连接,就可能由于挂起的连接还没有超时,而使新连接被拒绝。\n观察Max_used_connections状态变量随着时间的变化。这个是高水位标记,可以告诉你服务器连接是不是在某个时间点有个尖峰。如果这个值达到了max_connections,说明客户端至少被拒绝了一次,并且当它重现的时候,应该使用第3章中的技巧来抓取服务器的活动状态。\nthread_cache_size\n设置这个变量,可以通过观察服务器一段时间的活动,来计算一个有理有据的值。观察Threads_connected状态变量并且找到它在一般情况下的最大值和最小值。你也许希望把线程缓存设置得足够大,以在高峰和低谷时都足够,甚至可能更大方一些,因为就算设置得有点太大了,一般也不是大问题。你也许可以设置为波动范围两到三倍的大小。例如,若Threads_connected状态从150变化到175,可以设置线程缓存为75。但是也不用设置得非常大,因为保持大量等待连接的空闲线程并没有什么真正的用处。250的上限是个不错的估算值(或者256,如果你喜欢2的次方。)也可以观察Threads_created状态随着时间的变化。如果这个值很大或者一直增长,这是另一个线索,告诉你可能需要调大thread_cache_size变量。查看Threads_cached来看有多少线程已经在缓存中了。\n一个相关的状态变量是Slow_launch_threads。这个状态如果是个很大的值,那么意味着某些情况延迟了连接分配新线程。这也是个线索,可能服务器有些问题了,但是不能明确地指出是哪出问题了。一般来说,可能是系统过载了,导致操作系统不能为新创建的线程调度CPU。这不是说你就需要增加线程缓存的大小了。你应该诊断这个问题并且修复它,而不是用缓存来掩盖问题,因为这还可能导致其他问题。\ntable_cache_size\n这个缓存(或者在MySQL 5.1中被分成两个缓存区)应该被设置得足够大,以避免总是需要重新打开和重新解析表的定义。你可以通过观察Open_tables的值及其在一段时间的变化来检查该变量。如果你看到Opened_tables每秒变化很大,那么table_cache值可能不够大。隐式临时表也可能导致打开表的数量不断增长,即使表缓存并没有用满,所以这可能也没什么问题。\n该问题的线索应该是Opened_tables不断地增长,即使Open_tables并不跟table_cache_size一样大。\n虽然表缓存很有用,也不应该把这个变量设置得太大。表缓存可能在两种情况下适得其反。\n首先,MySQL没有一个很有效的方法来检查缓存,所以如果真的太大了,可能效率会下降。在大部分情况下,不应该把它设置得大于10000,或者是10 240,如果喜欢使用2的N次方的话。(25)\n第二个原因是有些类型的工作负载是不能缓存的。如果工作负载不是可缓存的,不管把缓存设置得多大,任何访问都无法在缓存命中,忘记缓存吧,把它设置为0!这可以避免情况变得更糟糕,缓存不命中比昂贵的缓存检查后再不命中还是要好的。什么类型的工作负载不是可缓存的?如果有几万或几十万张表,并且它们都很均匀地被使用,就不可能把它们全缓存了,最好把这个变量设得小一点。当系统上有数量非常多的并行应用而其中没有一个是非常忙碌的,有时候这是适当的。\n这个值从max_connections的10倍开始设置是比较有道理的,但是再次说明,在大部分场景下最好保持在10000以下甚至更低。\n还有其他一些类型的设置可能经常会包含在配置文件中,包括二进制日志以及复制设置。二进制日志对恢复到某个时间点,以及复制是非常有用的,另外复制还有一些它自己的设置。我们会在本书后面的章节中覆盖复制和备份的重要设置。\n8.9 安全和稳定的设置 # 基本配置设置到位后,可能希望启用一些使服务器更安全和更可靠的设置。它们中的一些会影响性能,因为保证安全性和可靠性往往要付出一些代价。有些人意识到了:他们能阻止愚蠢的错误发生,比如把无意义的数据插入服务器,以及一些变动在日常操作中没有啥区别,只是在很边缘的情况防止糟糕的事情发生。\n让我们首先来看看收集的一些对一般服务器都有用的配置项:\nexpire_logs_days\n如果启用了二进制日志,应该打开这个选项,可以让服务器在指定的天数之后清理旧的二进制日志。如果不启用,最终服务器的空间会被耗尽,导致服务器卡住或崩溃。我们建议把这个选项设置得足够从两个备份之前恢复(在最近的备份失败的情况下)。即使每天都做备份,还是建议留下7~14天的二进制日志。从我们的经验来看,当遇到一些不常见的问题时,你会感谢有这一两个星期的二进制日志。例如重搭一个备机再次尝试赶上主库。应该保持足够多的二进制日志,遇到这些情况时可以给自己一些呼吸的空间。\nmax_allowed_packet\n这个设置防止服务器发送太大的包,也会控制多大的包可以被接收。默认值可能太小了,但设置得太大也可能有危险。如果设置得太小,有时复制上会出问题,通常表现为备库不能接收主库发过来的复制数据。你也许需要增加这个设置到16MB或者更大。这些文档里没有,但这个选项也控制在一个用户定义的变量的最大值,所以如果需要非常大的变量,要小心——如果超过这个变量的大小,它们可能被截断或者设置为NULL。\nmax_connect_errors\n如果有时网络短暂抽风了,或者应用配置出现错误,或者有另外的问题,如权限,在短暂的时间内不断地尝试连接,客户端可能被列入黑名单,然后将无法连接,直到再次刷新主机缓存。这个选项的默认设置太小了,很容易导致问题。你也许希望增加这个值,实际上,如果知道服务器可以充分抵御蛮力攻击,可以把这个值设得非常大,以有效地禁用主机黑名单。\nskip_name_resolve\n这个选项禁用了另一个网络相关和鉴权认证相关的陷阱:DNS查找。DNS是MySQL连接过程中的一个薄弱环节。当连接服务器时,默认情况下,它试图确定连接和使用的主机的主机名,作为身份验证凭据的一部分。(就是说,你的凭据是用户名,主机名、以及密码——并不只是用户名和密码)但是验证主机来源,服务器需要执行DNS的正向和反向查找。要是DNS有问题就悲剧了,在某些时间点这是必然的事。当发生这样的情况时,所有事都会堆积起来,最终导致连接超时。为了避免这种情况,我们强烈建议设置这个选项,在验证时关闭DNS查找。然而,如果这么做,需要把基于主机名的授权改为用IP地址、通配符,或者特定主机名“localhost”,因为基于主机名的账号会被禁用。\nsql_mode\n这个设置可以接受多种多样的值来改变服务器行为。我们不建议只是为了好玩而改变这个值;最好在大多数情况下让MySQL像MySQL,不要尝试让它的行为像其他数据库服务器。(许多客户端和图形界面工具,除了MySQL还有它们自己的SQL方言,例如,若修改它用更符合ANSI的SQL,有些操作会没法做。)然而,有些选项值是很有用的,有些在具体情况可能是值得考虑的。建议查看文档中下面这些选项,并且考虑使用它们:STRICT_TRANS_TABLES、ERROR_FOR_DIVISION_BY_ZERO、NO_AUTO_CREATE_USER、NO_AUTO_VALUE_ON_ZERO、NO_ENGINE_SUBSTITUTION、NO_ZERO_DATE、NO_ZERO_IN_DATE和ONLY_FULL_GROUP_BY。\n然而,要意识到对已经存在的应用修改这些设置值可不是个好主意,因为这么做可能让服务器跟应用预期不兼容。人们不经意间写的查询中应用的列不在GROUP BY中,或者使用聚合函数,这种情况非常常见,例如,若想打开ONLY_FULL_GROUP_BY选项,最好首先在开发或未上线服务器上做一下测试,一旦要在生产环境部署则必须确认所有地方都可以工作。\nsysdate_is_now\n这是另一个可能导致与应用预期向后不兼容的选项。但如果不是明确需要SYSDATE()函数的非确定性行为(非确定性行为可能会导致复制中断或者使得基于时间点的备份恢复结果不可信),那么你可能希望打开该选项以确保SYSDATE()函数有确定的行为。\n下面的选项可以控制复制行为,并且对防止备库出问题非常有帮助:\nread_only\n这个选项禁止没有特权的用户在备库做变更,只接受从主库传输过来的变更,不接受从应用来的变更。我们强烈建议把备库设置为只读模式。\nskip_slave_start\n这个选项阻止MySQL试图自动启动复制。因为在不安全的崩溃或其他问题后,启动复制是不安全的,所以需要禁用自动启动,用户需要手动检查服务器,并确定它是安全的之后再开始复制。\nslave_net_timeout\n这个选项控制备库发现跟主库的连接已经失败并且需要重连之前等待的时间。默认值是一个小时,太长了。设置为一分钟或更短。\nsync_master_info、sync_relay_log、sync_relay_log_info\n这些选项,在MySQL 5.5以及更新版本中可用,解决了复制中备库长期存在的问题:不把它们的状态文件同步到磁盘,所以服务器崩溃后可能需要人来猜测复制的位置实际上在主库是哪个位置,并且可能在中继日志(Relay Log)里有损坏。这些选项使得备库崩溃后,更容易从崩溃中恢复。这些选项默认是不打开的,因为它们会导致备库额外的fsync()操作,可能会降低性能。如果有很好的硬件,我们建议打开这些选项,如果复制中出现fsync()造成的延时问题,就应该关闭它们。\nPercona Server中有一种侵入性更小的方式来做这些工作,即打开innodb_overwrite_relay_log_info选项。这可以让InnoDB在事务日志中存储复制的位置,这是完全事务化的,并且不需要任何额外的fsync()操作。在崩溃恢复期间, InnoDB会检查复制的元信息文件,如果文件过期了就更新为正确的位置。\n8.10 高级InnoDB设置 # 回到第1章我们讨论的InnoDB历史:首先是内建(built-in)的版本,然后有了两个有效版本,现在更新的版本再次变成了一个。更新的InnoDB代码有更多的功能和非常好的扩展性。如果正在使用MySQL 5.1,应该明确地配置MySQL忽略旧版本的InnoDB而使用新版的。这将极大地提升服务器性能。需要打开ignore_builtin_innodb选项,然后配置plugin_load选项把InnoDB作为插件打开。建议参考InnoDB文档中对应平台上的扩展语法(26)。\n对于新版本的InnoDB,有一些新的选项可以用。如果启用,它们中有些对服务器性能相当重要,也有一些安全性和稳定性的选项,如下所示。\ninnodb\n这个看似平淡无奇的选项实际上非常重要,如果把这个值设置为FORCE,只有在InnoDB可以启动时,服务器才会启动。如果使用InnoDB作为默认存储引擎,这一定是你期望的结果。你应该不会希望在InnoDB失败(例如因为错误的配置而导致的不可启动)的情况下启动服务器,因为写的不好的应用可能之后会连接到服务器,导致一些无法预知的损失和混乱。最好是整个服务器都失败,强制你必须查看错误日志,而不是以为服务器正常启动了。\ninnodb_autoinc_lock_mode\n这个选项控制InnoDB如何生成自增主键值,某些情况下,例如高并发插入时,自增主键可能是个瓶颈。如果有很多事务等待自增锁(可以在SHOW ENGINE INNODB STATUS里看到),应该审视这个变量的设置。手册上已经详细解释了该选项的行为,在此我们就不再重复了。\ninnodb_buffer_pool_instances\n这个选项在MySQL 5.5和更新的版本中出现,可以把缓冲池切分为多段,这可能是在高负载的多核机器上提升MySQL可扩展性最重要的一个方式了。多个缓冲池分散了工作压力,所以一些全局Mutex竞争就没有那么大了。\n目前尚不清楚什么情况下应该选择多个缓冲池实例。我们运行过八个实例的基准,但是直到MySQL 5.5已经广泛部署了很长一段时间,我们依然不明白多个缓冲池实例的一些微妙之处。\n我们不是暗示MySQL 5.5没有在生产环境广泛部署。只是对我们已经帮助解决过的大部分互斥锁相互争用的极端场景的用户来说,升级可能需要很多个月的时间来计划、验证,并执行。这些用户有时运行着高度定制化的MySQL版本,使得更加倍谨慎地对待升级。当越来越多的这类用户升级到MySQL 5.5,并以他们独特的方式进行压力验证,我们可能会学到关于多缓冲池的一些我们没见过的有趣的事情。也许直到那时,我们才可以说运行八个缓冲池实例是非常有益的。\n值得注意的是Percona Server用了不同的方法来解决InnoDB互斥锁争用问题。相对于把缓冲池分成多个——一个在许多像InnoDB的系统下经过检验无可否认的方法——我们选择把一些全局Mutex拆分为更细、更专用的Mutex。我们的测试显示最好的方式是结合这两种方法,在Percona Server 5.5版本中已经可用了:多缓冲区和更细粒度的锁。\ninnodb_io_capacity\nInnoDB曾经在代码里写死了假设服务器运行在每秒100个I/O操作的单硬盘上。默认值很糟糕。现在可以告诉InnoDB服务器有多大的I/O能力。InnoDB有时需要把这个设置得相当高(在像PCI-E SSD这样极快的存储设备上需要设置为上万)才能稳定地刷新脏页,原因解释起来相当复杂。\ninnodb_read_io_threads和innodb_write_io_threads\n这些选项控制有多少后台线程可以被I/O操作使用。最近版本的MySQL里,默认值是4个读线程和4个写线程,对大部分服务器这都足够了,尤其是MySQL 5.5里面可以用操作系统原生的异步I/O以后。如果有很多硬盘并且工作负载并发很大,可以发现这些线程很难跟上,这种情况下可以增加线程数,或者可以简单地把这个选项的值设置为可以提供I/O能力的磁盘数量(即使后面是一个RAID控制器)。\ninnodb_strict_mode\n这个设置让MySQL在某些条件下把警告改成抛错,尤其是无效的或者可能有风险的CREATE TABLE选项。如果打开这个设置,就必然会检查所有CREATE TABLE选项,因为它不会让你创建一些用起来比较爽(但是有隐患)的表。有时这有点悲观,过于严格了。当尝试恢复备份时可能就不希望打开这个选项了。\ninnodb_old_blocks_time\nInnoDB有个两段缓冲池LRU(最近最少使用)链表,设计目的是防止换出长期使用很多次的页面。像mysqldump产生的这种一次性的(大)查询,通常会读取页面到缓冲池的LRU列表,从中读取需要的行,然后移动到下一页。理论上,两段LRU链表将阻止此页取代很长一段时间内都需要用到的页面被放入“年轻(Young)”子链表,并且只在它已被浏览过多次后将其移动到“年老(Old)”子链表。但是InnoDB默认没有配置为防止这种情况,因为页内有很多行,所以从页面读取的行的多次访问,会导致它立即被转移到“年老(Old)”子链表,对那些需要长时间缓存的页面带来换出的压力。\n这个变量指定一个页面从LRU链表的“年轻”部分转移到“年老”部分之前必须经过的毫秒数。默认情况下它设置为0,将它设为诸如1000毫秒(一秒)这样的小一点的值,在我们的基准测试中已被证明非常有效。\n8.11 总结 # 在阅读完这一章节之后,你应该有了一个比默认设置好得多的服务器配置。服务器应该更快更稳定了,并且除非运行出现了罕见的状况,都应该没有必要再去做优化配置的工作了。\n复习一下,我们建议从参考示例配置文件开始,设置符合服务器和工作负载的基本选项,增加安全性和完整性所需的选项,并且,如果合适的话,在MySQL 5.5中配置新版的InnoDB Plugin才有的配置项。这就是关于优化服务器配置所需要做的全部的事情。\n如果使用的是InnoDB,最重要的选项是下面这两个:\ninnodb_buffer_pool_size innodb_log_file_size 恭喜你——你解决了我们见过的真实存在的配置问题中的绝大部分!如果使用我们的在线配置工具 http://tools.percona.com,对这些问题和其他配置选项的使用,会得到很好的建议。\n我们也提出了很多关于不要做什么的建议。其中最重要的是不要“调优”服务器;不要使用比率、公式或“调优脚本”作为设置配置变量的基础;不要信任来自互联网上的不明身份的人的意见;不要为了看起来很糟糕的事情去不断地刷SHOW STATUS。如果有些设置其实是错误的,在剖析服务器性能时也会展现出来。\n有几个重要的设置没有在本章讨论,主要是因为它们是为特定类型的硬件和工作负载服务的。我们暂不讨论这些设置,因为我们相信,任何关于怎样设置的意见,都需要与内部流程的解释工作一起来做。这给我们带来了下一章,它会告诉你如何优化MySQL的硬件和操作系统,反之亦然。\n————————————————————\n(1) 我们见过的一个常见的错误是,配置一台新服务器的内存是另一台已经存在的服务器的两倍,并且——使用旧服务器的配置作为基线——创建一份新的配置,只是简单地在旧服务器的配置上乘以2。这不起作用。\n(2) 如果你还是不相信“按比率调优”的方法是错误的,请阅读Cary Millsap的Optimizing Oracle Performance(O\u0026rsquo;Reilly出版)。他甚至为这个主题专门写了一个附录,提供了一个可以智能地产生任何你想要的命中率的工具,甚至不管系统正运行得多么糟糕都可以做到很好的命中率!当然,这一切的目的都是为了说明比率是多么无用。\n(3) 一个例外:我们维护了一个(好用的)免费的在线配置工具,在 http://tools.percona.com。是的,我们确实有倾向性。\n(4) 问:(坏的)查询是如何形成的?答:这需要去问那些杀死了坏查询的DBA是怎么回事,查询本身是不可能回击的。\n(5) Percona当然认为在Percona邮件组能找到真正的专家,所以说他们不能中肯。——译者注\n(6) 问:为排序缓存(Sort Buffer)和读缓存(Read Buffer)设置大小的选项在哪?答:它们已经很专注自己的事情了,除非觉得默认值不够好,否则保留默认值就可以了。\n(7) InnoDB在5.1/5.5中都不支持全文索引,直到5.6版本InnoDB才支持全文索引。——译者注\n(8) 例如Join Buffer/Sort Buffer等。——译者注\n(9) 这个功能是Dump/Restore of the Buffer Pool,详情查看: http://www.percona.com/doc/percona-server/5.5/management/innodb_lru_dump_restore.html。——译者注\n(10) 当然还要排除各种操作系统自身占用的内存,还有MySQL自身占用的内存等。——译者注\n(11) 理论上,如果能确认原生4KB的数据依然在操作系统缓存中,读操作就不需要了。然而,你没法控制操作系统把哪些块放到缓存中。通过fncore工具可以看到哪些块在缓存中,地址在: http://net.doit.wisc.edu/~plonka/fncore/。\n(12) “打开的表(Opened Table)”的概念,可能有点混乱。当不同的查询同时访问一张表,或者是一个单独的查询引用同一张表超过一次,比如子查询或者自关联,MySQL都会对一张表作为打开状态多次计数。MyISAM表的索引文件包含一个计数器,MyISAM表打开时递增,关闭时递减。这使得对于MyISAM表可以看到是不是关闭干净了:如果首次打开一个表,计数器不为零,说明表没有关闭干净。\n(13) 对于好奇的人,Percona Server的innodb_recovery_stats选项可以帮助你从执行崩溃恢复的立场来理解服务器的工作负载。\n(14) 我们说的是基于旋转盘片的机械磁盘,不是SSD盘,它们的性能特点完全不一样。\n(15) RAID卡的预读控制必须在RAID卡的设置中调整。——译者注\n(16) 就是写入会在RAID卡缓存上进行缓冲,不直接写到硬盘。——译者注\n(17) 请注意,这种实现思路是一个存在很多争议的话题,请看MySQL的bug 60776来获得更多细节信息。\n(18) 表可能因为多种原因被关闭。例如,服务器因为表缓存没有空间了就会关闭表,或者有人执行了FLUSH TABLES。\n(19) 一些Debian系统会自动做这些事,像一个钟摆的摆动,朝着不同的方向不停地摇摆。只是把这个行为配置为Debian默认做的事不是一个好主意,应该由DBA来决定。\n(20) 事实上,在某些工作负载下,并发限制实现可能自己就成为了系统的瓶颈,所以有时它需要打开,但另一些时候它需要关闭。性能分析会告诉你该怎么做。\n(21) 最近版本的Percona Server对某些场景消除了这个限制。\n(22) 如果操作系统把它交换(Swap)出内存,数据依然会到磁盘。\n(23) 这个长度足够在列上创建一个255字符的索引,即使是utf8的(每个字符可能需要三个字节)。前缀是InnoDB的Antelope文件格式特有的,MySQL 5.1和更新版本中的Barracuda格式(默认不打开的)没有前缀。\n(24) 在带有LIMIT语句的查询中,MySQL 5.6会改变排序缓冲的用法,并且会修正一个可导致执行一个昂贵的安装历程而使用庞大的排序缓冲的问题,所以如果升级到了MySQL 5.6,需要特别小心地检查这些设置中任何自定义的设置。\n(25) 你听说过一个关于二进制的笑话嘛?世界上有10种人:部分是懂二进制的,部分不懂二进制。还有另外10种人:一些认为二进制/十进制的笑话有意思,一些是精虫上脑。我们不会说我们是否认为这是滑稽的。\n(26) 在Percona Server中,只有一个版本的InnoDB,并且是内建的,所以你不需要禁用一个版本然后载入另一个版本替换它。\n"},{"id":140,"href":"/zh/docs/technology/MySQL/_%E9%AB%98%E6%80%A7%E8%83%BDMySQL_/%E7%AC%AC7%E7%AB%A0MySQL%E9%AB%98%E7%BA%A7%E7%89%B9%E6%80%A7/","title":"第7章MySQL高级特性","section":"高性能 My SQL","content":"第7章 MySQL高级特性\nMySQL从5.0和5.1版本开始引入了很多高级特性,例如分区、触发器等,这对有其他关系型数据库使用背景的用户来说可能并不陌生。这些新特性吸引了很多用户开始使用MySQL。不过,这些特性的性能到底如何,还需要用户真正使用过才能知道。本章我们将为大家介绍,在真实的世界中,这些特性表现如何,而不是只简单地介绍参考手册或者宣传材料上的数据。\n7.1 分区表 # 对用户来说,分区表是一个独立的逻辑表,但是底层由多个物理子表组成。实现分区的代码实际上是对一组底层表的句柄对象(Handler Object)的封装。对分区表的请求,都会通过句柄对象转化成对存储引擎的接口调用。所以分区对于SQL层来说是一个完全封装底层实现的黑盒子,对应用是透明的,但是从底层的文件系统来看就很容易发现,每一个分区表都有一个使用#分隔命名的表文件。\nMySQL实现分区表的方式——对底层表的封装——意味着索引也是按照分区的子表定义的,而没有全局索引。这和Oracle不同,在Oracle中可以更加灵活地定义索引和表是否进行分区。\nMySQL在创建表时使用PARTITION BY子句定义每个分区存放的数据。在执行查询的时候,优化器会根据分区定义过滤那些没有我们需要数据的分区,这样查询就无须扫描所有分区——只需要查找包含需要数据的分区就可以了。\n分区的一个主要目的是将数据按照一个较粗的粒度分在不同的表中。这样做可以将相关的数据存放在一起,另外,如果想一次批量删除整个分区的数据也会变得很方便。\n在下面的场景中,分区可以起到非常大的作用:\n表非常大以至于无法全部都放在内存中,或者只在表的最后部分有热点数据,其他均是历史数据。 分区表的数据更容易维护。例如,想批量删除大量数据可以使用清除整个分区的方式。另外,还可以对一个独立分区进行优化、检查、修复等操作。 分区表的数据可以分布在不同的物理设备上,从而高效地利用多个硬件设备。 可以使用分区表来避免某些特殊的瓶颈,例如InnoDB的单个索引的互斥访问、ext3文件系统的inode锁竞争等。 如果需要,还可以备份和恢复独立的分区,这在非常大的数据集的场景下效果非常好。 MySQL的分区实现非常复杂,我们不打算介绍实现的全部细节。这里我们将专注在分区性能方面,所以如果想了解更多的关于分区的基础知识,我们建议阅读MySQL官方手册中的“分区”一节,其中介绍了很多分区相关的基础知识。另外,还可以阅读CREATE TABLE、SHOW CREATE TABLE、ALTER TABLE和INFORMATION_SCHEMA.PARTITIONS、EXPLAIN关于分区部分的介绍。分区特性使得CREATE TABLE和ALTER TABLE命令变得更加复杂了。\n分区表本身也有一些限制,下面是其中比较重要的几点:\n一个表最多只能有1024个分区。 在MySQL 5.1中,分区表达式必须是整数,或者是返回整数的表达式。在MySQL 5.5中,某些场景中可以直接使用列来进行分区。 如果分区字段中有主键或者唯一索引的列,那么所有主键列和唯一索引列都必须包含进来。 分区表中无法使用外键约束。 7.1.1 分区表的原理 # 如前所述,分区表由多个相关的底层表实现,这些底层表也是由句柄对象(Handler object)表示,所以我们也可以直接访问各个分区。存储引擎管理分区的各个底层表和管理普通表一样(所有的底层表都必须使用相同的存储引擎),分区表的索引只是在各个底层表上各自加上一个完全相同的索引。从存储引擎的角度来看,底层表和一个普通表没有任何不同,存储引擎也无须知道这是一个普通表还是一个分区表的一部分。\n分区表上的操作按照下面的操作逻辑进行:\nSELECT查询\n当查询一个分区表的时候,分区层先打开并锁住所有的底层表,优化器先判断是否可以过滤部分分区,然后再调用对应的存储引擎接口访问各个分区的数据。\nINSERT操作\n当写入一条记录时,分区层先打开并锁住所有的底层表,然后确定哪个分区接收这条记录,再将记录写入对应底层表。\nDELETE操作\n当删除一条记录时,分区层先打开并锁住所有的底层表,然后确定数据对应的分区,最后对相应底层表进行删除操作。\nUPDATE操作\n当更新一条记录时,分区层先打开并锁住所有的底层表,MySQL先确定需要更新的记录在哪个分区,然后取出数据并更新,再判断更新后的数据应该放在哪个分区,最后对底层表进行写入操作,并对原数据所在的底层表进行删除操作。\n有些操作是支持过滤的。例如,当删除一条记录时,MySQL需要先找到这条记录,如果WHERE条件恰好和分区表达式匹配,就可以将所有不包含这条记录的分区都过滤掉。这对UPDATE语句同样有效。如果是INSERT操作,则本身就是只命中一个分区,其他分区都会被过滤掉。MySQL先确定这条记录属于哪个分区,再将记录写入对应的底层分区表,无须对任何其他分区进行操作。\n虽然每个操作都会“先打开并锁住所有的底层表”,但这并不是说分区表在处理过程中是锁住全表的。如果存储引擎能够自己实现行级锁,例如InnoDB,则会在分区层释放对应表锁。这个加锁和解锁过程与普通InnoDB上的查询类似。\n后面我们会通过一些例子来看看,当访问一个分区表的时候,打开和锁住所有底层表的代价及其带来的后果。\n7.1.2 分区表的类型 # MySQL支持多种分区表。我们看到最多的是根据范围进行分区,每个分区存储落在某个范围的记录,分区表达式可以是列,也可以是包含列的表达式。例如,下表就可以将每一年的销售额存放在不同的分区里:\nCREATE TABLE sales ( order_date DATETIME NOT NULL, -- Other columns omitted ) ENGINE=InnoDB PARTITION BY RANGE(YEAR(order_date)) ( PARTITION p_2010 VALUES LESS THAN (2010), PARTITION p_2011 VALUES LESS THAN (2011), PARTITION p_2012 VALUES LESS THAN (2012), PARTITION p_catchall VALUES LESS THAN MAXVALUE ); PARTITION分区子句中可以使用各种函数。但有一个要求,表达式返回的值要是一个确定的整数,且不能是一个常数。这里我们使用函数YEAR(),也可以使用任何其他的函数,如TO_DAYS()。根据时间间隔进行分区,是一种很常见的分区方式,后面我们还会再回过头来看这个例子,看看如何优化这个例子来避免一些问题。\nMySQL还支持键值、哈希和列表分区,这其中有些还支持子分区,不过我们在生产环境中很少见到。在MySQL 5.5中,还可以使用RANGE COLUMNS类型的分区,这样即使是基于时间的分区也无须再将其转化成一个整数,后面将详细介绍。\n在我们看过的一个子分区的案例中,对一个类似于前面我们设计的按时间分区的InnoDB表,系统通过子分区可降低索引的互斥访问的竞争。最近一年的分区的数据会被非常频繁地访问,这会导致大量的互斥量的竞争。使用哈希子分区可以将数据切成多个小片,大大降低互斥量的竞争问题。\n我们还看到的一些其他的分区技术包括:\n根据键值进行分区,来减少InnoDB的互斥量竞争。 使用数学模函数来进行分区,然后将数据轮询放入不同的分区。例如,可以对日期做模7的运算,或者更简单地使用返回周几的函数,如果只想保留最近几天的数据,这样分区很方便。 假设表有一个自增的主键列id,希望根据时间将最近的热点数据集中存放。那么必须将时间戳包含在主键当中才行,而这和主键本身的意义相矛盾。这种情况下也可以使用这样的分区表达式来实现相同的目的:HASH(id DIV 1000000),这将为100万数据建立一个分区。这样一方面实现了当初的分区目的,另一方面比起使用时间范围分区还避免了一个问题,就是当超过一定阈值时,如果使用时间范围分区就必须新增分区。 7.1.3 如何使用分区表 # 假设我们希望从一个非常大的表中查询出一段时间的记录,而这个表中包含了很多年的历史数据,数据是按照时间排序的,例如,希望查询最近几个月的数据,这大约有10亿条记录。可能过些年本书会过时,不过我们还是假设使用的是2012年的硬件设备,而原表中有10TB的数据,这个数据量远大于内存,并且使用的是传统硬盘,不是闪存(多数SSD也没有这么大的空间)。你打算如何查询这个表?如何才能更高效?\n首先很肯定:因为数据量巨大,肯定不能在每次查询的时候都扫描全表。考虑到索引在空间和维护上的消耗,也不希望使用索引。即使真的使用索引,你会发现数据并不是按照想要的方式聚集的,而且会有大量的碎片产生,最终会导致一个查询产生成千上万的随机I/O,应用程序也随之僵死。情况好一点的时候,也许可以通过一两个索引解决一些问题。不过多数情况下,索引不会有任何作用。这时候只有两条路可选:让所有的查询都只在数据表上做顺序扫描,或者将数据表和索引全部都缓存在内存里。\n这里需要再陈述一遍:在数据量超大的时候,B-Tree索引就无法起作用了。除非是索引覆盖查询,否则数据库服务器需要根据索引扫描的结果回表,查询所有符合条件的记录,如果数据量巨大,这将产生大量随机I/O,随之,数据库的响应时间将大到不可接受的程度。另外,索引维护(磁盘空间、I/O操作)的代价也非常高。有些系统,如Infobright,意识到这一点,于是就完全放弃使用B-Tree索引,而选择了一些更粗粒度的但消耗更少的方式检索数据,例如在大量数据上只索引对应的一小块元数据。\n这正是分区要做的事情。理解分区时还可以将其当作索引的最初形态,以代价非常小的方式定位到需要的数据在哪一片“区域”。在这片“区域”中,你可以做顺序扫描,可以建索引,还可以将数据都缓存到内存,等等。因为分区无须额外的数据结构记录每个分区有哪些数据——分区不需要精确定位每条数据的位置,也就无须额外的数据结构——所以其代价非常低。只需要一个简单的表达式就可以表达每个分区存放的是什么数据。\n为了保证大数据量的可扩展性,一般有下面两个策略:\n全量扫描数据,不要任何索引。\n可以使用简单的分区方式存放表,不要任何索引,根据分区的规则大致定位需要的数据位置。只要能够使用WHERE条件,将需要的数据限制在少数分区中,则效率是很高的。当然,也需要做一些简单的运算保证查询的响应时间能够满足需求。使用该策略假设不用将数据完全放入到内存中,同时还假设需要的数据全都在磁盘上,因为内存相对很小,数据很快会被挤出内存,所以缓存起不了任何作用。这个策略适用于以正常的方式访问大量数据的时候。警告:后面我们会详细解释,必须将查询需要扫描的分区个数限制在一个很小的数量。\n索引数据,并分离热点。\n如果数据有明显的“热点”,而且除了这部分数据,其他数据很少被访问到,那么可以将这部分热点数据单独放在一个分区中,让这个分区的数据能够有机会都缓存在内存中。这样查询就可以只访问一个很小的分区表,能够使用索引,也能够有效地使用缓存。\n仅仅知道这些还不够,MySQL的分区表实现还有很多陷阱。下面我们看看都有哪些,以及如何避免。\n7.1.4 什么情况下会出问题 # 上面我们介绍的两个分区策略都基于两个非常重要的假设:查询都能够过滤(prunning)掉很多额外的分区、分区本身并不会带来很多额外的代价。而事实证明,这两个假设在某些场景下会有问题。下面介绍一些可能会遇到的问题。\nNULL值会使分区过滤无效\n关于分区表一个容易让人误解的地方就是分区的表达式的值可以是NULL:第一个分区是一个特殊分区。假设按照PARTITION BY RANGE YEAR(order_date)分区,那么所有order_date为NULL或者是一个非法值的时候,记录都会被存放到第一个分区(1)。现在假设有下面的查询:WHERE order_date BETWEEN \u0026lsquo;2012-01-01\u0026rsquo; AND \u0026lsquo;2012-01-31\u0026rsquo;。实际上,MySQL会检查两个分区,而不是之前猜想的一个:它会检查2012年这个分区,同时它还会检查这个表的第一个分区。检查第一个分区是因为YEAR()函数在接收非法值的时候可能会返回NULL值,那么这个范围的值可能会返回NULL而被存放到第一个分区了。这一点对于其他很多函数,例如TO_DAYS()也一样。(2)\n如果第一个分区非常大,特别是当使用“全量扫描数据,不要任何索引”的策略时,代价会非常大。而且扫描两个分区来查找列也不是我们使用分区表的初衷。为了避免这种情况,可以创建一个“无用”的第一个分区,例如,上面的例子中可以使用PARTITION p_nulls VALUES LESS THAN(0)来创建第一个分区。如果插入表中的数据都是有效的,那么第一个分区就是空的,这样即使需要检测第一个分区,代价也会非常小。\n在MySQL 5.5中就不需要这个优化技巧了,因为可以直接使用列本身而不是基于列的函数进行分区:PARTITION BY RANGE COLUMNS(order_date)。所以这个案例最好的解决方法是能够直接使用MySQL 5.5的这个语法。\n分区列和索引列不匹配\n如果定义的索引列和分区列不匹配,会导致查询无法进行分区过滤。假设在列a上定义了索引,而在列b上进行分区。因为每个分区都有其独立的索引,所以扫描列b上的索引就需要扫描每一个分区内对应的索引。如果每个分区内对应索引的非叶子节点都在内存中,那么扫描的速度还可以接受,但如果能跳过某些分区索引当然会更好。要避免这个问题,应该避免建立和分区列不匹配的索引,除非查询中还同时包含了可以过滤分区的条件。\n听起来避免这个问题很简单,不过有时候也会遇到一些意想不到的问题。例如,在一个关联查询中,分区表在关联顺序中是第二个表,并且关联使用的索引和分区条件并不匹配。那么关联时针对第一个表符合条件的每一行,都需要访问并搜索第二个表的所有分区。\n选择分区的成本可能很高\n如前所述分区有很多类型,不同类型分区的实现方式也不同,所以它们的性能也各不相同。尤其是范围分区,对于回答“这一行属于哪个分区”、“这些符合查询条件的行在哪些分区”这样的问题的成本可能会非常高,因为服务器需要扫描所有的分区定义的列表来找到正确的答案。类似这样的线性搜索的效率不高,所以随着分区数的增长,成本会越来越高。\n我们所实际碰到的类似这样的最糟糕的一次问题是按行写入大量数据的时候。每写入一行数据到范围分区的表时,都需要扫描分区定义列表来找到合适的目标分区。可以通过限制分区的数量来缓解此问题,根据实践经验,对大多数系统来说,100个左右的分区是没有问题的。\n其他的分区类型,比如键分区和哈希分区,则没有这样的问题。\n打开并锁住所有底层表的成本可能很高\n当查询访问分区表的时候,MySQL需要打开并锁住所有的底层表,这是分区表的另一个开销。这个操作在分区过滤之前发生,所以无法通过分区过滤降低此开销,并且该开销也和分区类型无关,会影响所有的查询。这一点对一些本身操作非常快的查询,比如根据主键查找单行,会带来明显的额外开销。可以用批量操作的方式来降低单个操作的此类开销,例如使用批量插入或者LOAD DATA INFILE、一次删除多行数据,等等。当然同时还是需要限制分区的个数。\n维护分区的成本可能很高\n某些分区维护操作的速度会非常快,例如新增或者删除分区(当删除一个大分区可能会很慢,不过这是另一回事)。而有些操作,例如重组分区或者类似ALTER语句的操作:这类操作需要复制数据。重组分区的原理与ALTER类似,先创建一个临时的分区,然后将数据复制到其中,最后再删除原分区。\n如上所述,分区表不是什么“银弹”。下面是目前分区实现中的一些其他限制:\n所有分区都必须使用相同的存储引擎。 分区函数中可以使用的函数和表达式也有一些限制。 某些存储引擎不支持分区。 对于MyISAM的分区表,不能再使用LOAD INDEX INTO CACHE操作。 对于MyISAM表,使用分区表时需要打开更多的文件描述符。虽然看起来是一个表,其实背后有很多独立的分区,每一个分区对于存储引擎来说都是一个独立的表。这样即使分区表只占用一个表缓存条目,文件描述符还是需要多个。因此,即使已经配置了合适的表缓存,以确保不会超过操作系统的单个进程可以打开的文件描述符的个数,但对于分区表而言,还是会出现超过文件描述符限制的问题。 最后,需要指出的是较老版本的MySQL问题会更多些。所有的软件都是有bug的。分区表在MySQL 5.1中引入,在后面的5.1.40和5.1.50之后修复了很多分区表的bug。在MySQL 5.5中,分区表又做了很多改进,这才使得分区表可以逐步考虑用在生产环境了。在即将发布的MySQL 5.6版本中,分区表做了更多的增强,例如新引入的ALTER TABLE EXCHANGE PARTITION。\n7.1.5 查询优化 # 引入分区给查询优化带来了一些新的思路(同时也带来新的bug)。分区最大的优点就是优化器可以根据分区函数来过滤一些分区。根据粗粒度索引的优势,通过分区过滤通常可以让查询扫描更少的数据(在某些场景下)。\n所以,对于访问分区表来说,很重要的一点是要在WHERE条件中带入分区列,有时候即使看似多余的也要带上,这样就可以让优化器能够过滤掉无须访问的分区。如果没有这些条件,MySQL就需要让对应存储引擎访问这个表的所有分区,如果表非常大的话,就可能会非常慢。\n使用EXPLAIN PARTITION可以观察优化器是否执行了分区过滤,下面是一个示例:\nmysql\u0026gt; ** EXPLAIN PARTITIONS SELECT * FROM sales \\G** *************************** 1. row *************************** id: 1 select_type: SIMPLE table: sales_by_day partitions: p_2010,p_2011,p_2012 type: ALL possible_keys: NULL key: NULL key_len: NULL ref: NULL rows: 3 Extra: 正如你所看到的,这个查询将访问所有的分区。下面我们在WHERE条件中再加入一个时间限制条件:\nmysql\u0026gt; ** EXPLAIN PARTITIONS SELECT * FROM sales_by_day WHERE day \u0026lt; '2011-01-01'\\G** *************************** 1. row *************************** id: 1 select_type: SIMPLE table: sales_by_day partitions: p_2011,p_2012 MySQL优化器已经很善于过滤分区。比如它能够将范围条件转化为离散的值列表,并根据列表中的每个值过滤分区。然而,优化器也不是万能的。下面查询的WHERE条件理论上可以过滤分区,但实际上却不行:\nmysql\u0026gt; ** EXPLAIN PARTITIONS SELECT * FROM sales_by_day WHERE YEAR(day) = 2010\\G** *************************** 1. row *************************** id: 1 select_type: SIMPLE table: sales_by_day partitions: p_2010,p_2011,p_2012 MySQL只能在使用分区函数的列本身进行比较时才能过滤分区,而不能根据表达式的值去过滤分区,即使这个表达式就是分区函数也不行。这就和查询中使用独立的列才能使用索引的道理是一样的(参考第5章的相关内容)。所以只需要把上面的查询等价地改写为如下形式即可:\nmysql\u0026gt; ** EXPLAIN PARTITIONS SELECT * FROM sales_by_day** -\u0026gt; ** WHERE day BETWEEN '2010-01-01' AND '2010-12-31'\\G** *************************** 1. row *************************** id: 1 select_type: SIMPLE table: sales_by_day partitions: p_2010 这里写的WHERE条件中带入的是分区列,而不是基于分区列的表达式,所以优化器能够利用这个条件过滤部分分区。一个很重要的原则是:即便在创建分区时可以使用表达式,但在查询时却只能根据列来过滤分区。\n优化器在处理查询的过程中总是尽可能聪明地去过滤分区。例如,若分区表是关联操作中的第二张表,且关联条件是分区键,MySQL就只会在对应的分区里匹配行。(EXPLAIN无法显示这种情况下的分区过滤,因为这是运行时的分区过滤,而不是查询优化阶段的。)\n7.1.6 合并表 # 合并表(Merge table)是一种早期的、简单的分区实现,和分区表相比有一些不同的限制,并且缺乏优化。分区表严格来说是一个逻辑上的概念,用户无法访问底层的各个分区,对用户来说分区是透明的。但是合并表允许用户单独访问各个子表。分区表和优化器的结合更紧密,这也是未来发展的趋势,而合并表则是一种将被淘汰的技术,在未来的版本中可能被删除。\n和分区表类似的是,在MyISAM中各个子表可以被一个结构完全相同的逻辑表所封装。可以简单地把这个表当作一个“老的、早期的、功能有限的”的分区表,因为它自身的特性,甚至可以提供一些分区表没有的功能(3)。\n合并表相当于一个容器,里面包含了多个真实表。可以在CREATE TABLE中使用一种特别的UNION语法来指定包含哪些真实表。下面是一个创建合并表的例子:\n注意到,这里最后建立的合并表和前面的各个真实表字段完全相同,在合并表中有的索引各个真实子表也有,这是创建合并表的前提条件。另外还注意到,各个子表在对应列上都有主键限制,但是最终的合并表中仍然出现了重复值,这是合并表的另一个不足:合并表中的每一个子表行为和表定义都是相同,但是合并表在全局上并不受这些条件限制。\n这里的语法INSERT_METHOD=LAST告诉MySQL,将所有的INSERT语句都发送给最后一个表。指定FIRST或者LAST关键字是唯一可以控制行插入到合并表的哪一个子表的方式(当然,还是可以直接在SQL中明确地操作任何一个子表)。而分区表则有更多的方式可以控制数据写入到哪一个子表中。\nINSERT语句的执行结果可以在最终的合并表中看到,也可以在对应的子表中看到:\n合并表还有些有趣的限制和特性,例如,在删除合并表或者删除一个子表的时候会怎样?删除一个合并表,它的子表不会受任何影响,而如果直接删除其中一个子表则可能会有不同的后果,这要视操作系统而定。例如在GNU/Linux上,如果子表的文件描述还是被打开的状态,那么这个表还存在,但是只能通过合并表才能访问到:\n合并表还有很多其他的限制和行为,下面列举的这几点需要在使用的时候时刻记住。\n在使用CREATE语句创建一个合并表的时候,并不会检查各个子表的兼容性。如果子表的定义稍有不同,那么MySQL就可能创建出一个后面无法使用的合并表。另外,如果在成功创建了合并表后再修改某个子表的定义,那么之后再使用合并表可能会看到这样的报错:ERROR 1168 (HY000):Unable to open underlying table which is differently defined or of non-MyISAM type or doesn\u0026rsquo;t exist。\n根据合并表的特性,不难发现,在合并表上无法使用REPLACE语法,无法使用自增字段。更多的细节请参阅MySQL官方手册。\n如果一个查询访问合并表,那么它需要访问所有子表。这会让根据键查找单行的查y询速度变慢,如果能够只访问一个对应表,速度肯定将更快。所以,限制合并表中的子表数量很重要,特别是当合并表是某个关联查询的一部分的时候,因为这时访问一个表的记录数可能会将比较操作传递到关联的其他表中,这时减少记录的访问就是减少整个关联操作。当你打算使用合并表的时候,还需要记住以下几点:\n执行范围查询时,需要在每一个子表上各执行一次,这比直接访问单个表的性─能要差很多,而且子表越多,性能越糟。\n全表扫描和普通表的全表扫描速度相同。\n在合并表上做唯一键和主键查询时,一旦找到一行数据就会停止。所以一旦查─询在合并表的某一个子表中找到一行数据,就会立刻返回,不会再访问任何其他的表。\n子表的读取顺序和CREATE TABLE语句中的顺序相同。如果需要频繁地按照某个特定顺序访问表,那么可以通过这个特性来让合并排序操作更高效。\n因为合并表的各个子表可以直接被访问,所以它还具有一些MySQL 5.5分区所不能提供的特性:\n一个MyISAM表可以是多个合并表的子表。 可以通过直接复制y*.frm、.MYI、.MYD*文件,来实现在不同的服务器之间复制各个子表。 在合并表中可以很容易地添加新的子表:直接修改合并表的定义就可以了。 可以创建一个合并表,让它只包含需要的数据,例如只包含某个时间段的数据,而在分区表中是做不到这一点的。 如果想对某个子表做备份、恢复、修改、修复或者别的操作时,可以先将其从合并表中删除,操作结束后再将其加回去。 可以使用myisampack来压缩所有的子表。 相反,分区表的子表都是被MySQL隐藏的,只能通过分区表去访问子表。\n7.2 视图 # MySQL 5.0版本之后开始引入视图。视图本身是一个虚拟表,不存放任何数据。在使用SQL语句访问视图的时候,它返回的数据是MySQL从其他表中生成的。视图和表是在同一个命名空间,MySQL在很多地方对于视图和表是同样对待的。不过视图和表也有不同,例如,不能对视图创建触发器,也不能使用DROP TABLE命令删除视图。\n在MySQL官方手册中对如何创建和使用视图有详细的介绍,本书不会详细介绍这些。我们将主要介绍视图是如何实现的,以及优化器如何处理视图,通过了解这些,希望可以让大家在使用视图时获得更高的性能。我们将使用示例数据库world来演示视图是如何工作的:\nmysql\u0026gt; ** CREATE VIEW Oceania AS** -\u0026gt; ** SELECT * FROM Country WHERE Continent = 'Oceania'** -\u0026gt; ** WITH CHECK OPTION;** 实现视图最简单的方法是将SELECT语句的结果存放到临时表中。当需要访问视图的时候,直接访问这个临时表就可以了。我们先来看看下面的查询:\nmysql\u0026gt; ** SELECT Code, Name FROM Oceania WHERE Name = 'Australia';** 下面是使用临时表来模拟视图的方法。这里临时表的名字是为演示用的:\nmysql\u0026gt; ** CREATE TEMPORARY TABLE TMP_Oceania_123 AS** -\u0026gt; ** SELECT * FROM Country WHERE Continent = 'Oceania';** mysql\u0026gt; ** SELECT Code, Name FROM TMP_Oceania_123 WHERE Name = 'Australia';** 这样做会有明显的性能问题,优化器也很难优化在这个临时表上的查询。实现视图更好的方法是,重写含有视图的查询,将视图的定义SQL直接包含进查询的SQL中。下面的例子展示的是将视图定义的SQL合并进查询SQL后的样子:\nmysql\u0026gt; ** SELECT Code, Name FROM Country** -\u0026gt; ** WHERE Continent = 'Oceania' AND Name = 'Australia';** MySQL可以使用这两种办法中的任何一种来处理视图。这两种算法分别称为合并算法(MERGE)和临时表算法(TEMPTABLE)(4),如果可能,会尽可能地使用合并算法。MySQL甚至可以嵌套地定义视图,也就是在一个视图上再定义另一个视图。可以在EXPLAIN EXTENDED之后使用SHOW WARNINGS来查看使用视图的查询重写后的结果。\n如果是采用临时表算法实现的视图,EXPLAIN中会显示为派生表(DERIVED)。图7-1展示了这两种实现的细节。\n图7-1:视图的两种实现\n如果视图中包含GROUY BY、DISTINCT、任何聚合函数、UNION、子查询等,只要无法在原表记录和视图记录中建立一一映射的场景中,MySQL都将使用临时表算法来实现视图。上面列举的可能不全,而且这些规则在未来的版本中也可能会改变。如果你想确定MySQL到底是使用合并算法还是临时表算法,可以EXPLAIN一条针对视图的简单查询:\n这里的select_type为“DERIVED”,说明该视图是采用临时表算法实现的。不过要注意:如果产生的底层派生表很大,那么执行EXPLAIN可能会非常慢。因为在MySQL 5.5和更老的版本中,EXPLAIN是需要实际执行并产生该派生表的。\n视图的实现算法是视图本身的属性,和作用在视图上的查询语句无关。例如,可以为一个基于简单查询的视图指定使用临时表算法:\n** CREATE ALGORITHM=TEMPTABLE VIEW v1 AS SELECT * FROM** sakila.actor; 实现该视图的SQL本身并不需要临时表,但基于该视图无论执行什么样的查询,视图都会生成一个临时表。\n7.2.1 可更新视图 # 可更新视图(updatable view)是指可以通过更新这个视图来更新视图涉及的相关表。只要指定了合适的条件,就可以更新、删除甚至向视图中写入数据。例如,下面就是一个合理的操作:\nmysql\u0026gt; ** UPDATE Oceania SET Population = Population * 1.1 WHERE Name = 'Australia';** 如果视图定义中包含了GROUP BY、UNION、聚合函数,以及其他一些特殊情况,就不能被更新了。更新视图的查询也可以是一个关联语句,但是有一个限制,被更新的列必须来自同一个表中。另外,所有使用临时表算法实现的视图都无法被更新。\n在上一节定义视图时使用的CHECK OPTION子句,表示任何通过视图更新的行,都必须符合视图本身的WHERE条件定义。所以不能更新视图定义列以外的列,比如上例中不能更新Continent列,也不能插入不同Continent值的新数据,否则MySQL会报如下的错误:\nmysql\u0026gt; ** UPDATE Oceania SET Continent = 'Atlantis';** ERROR 1369 (HY000):CHECK OPTION failed 'world.Oceania' 某些关系数据库允许在视图上建立INSTEAD OF触发器,通过触发器可以精确控制在修改视图数据时做些什么。不过MySQL不支持在视图上建任何触发器。\n7.2.2 视图对性能的影响 # 多数人认为视图不能提升性能,实际上,在MySQL中某些情况下视图也可以帮助提升性能。而且视图还可以和其他提升性能的方式叠加使用。例如,在重构schema的时候可以使用视图,使得在修改视图底层表结构的时候,应用代码还可能继续不报错的运行。\n可以使用视图实现基于列的权限控制,却不需要真正的在系统中创建列权限,因此没有额外的开销。\nCREATE VIEW public.employeeinfo AS SELECT firstname, lastname -- but not socialsecuritynumber FROM private.employeeinfo; GRANT SELECT ON public.* TO public_user; 有时候也可以使用伪临时视图实现一些功能。MySQL虽然不能创建只在当前连接中存在的真正的临时视图,但是可以建一个特殊名字的视图,然后在连接结束的时候删除该视图。这样在连接过程中就可以在FROM子句中使用这个视图,和使用子查询的方式完全相同,因为MySQL在处理视图和处理子查询的代码路径完全不同,所以它们的性能也不同。下面是一个例子:\n-- Assuming 1234 is the result of CONNECTION_ID() CREATE VIEW temp.cost_per_day_1234 AS SELECT DATE(ts) AS day, sum(cost) AS cost FROM logs.cost GROUP BY day; SELECT c.day, c.cost, s.sales FROM temp.cost_per_day_1234 AS c INNER JOIN sales.sales_per_day AS s USING(day); DROP VIEW temp.cost_per_day_1234; 我们这里使用连接ID作为视图名字的一部分来避免冲突。在应用发生崩溃和别的意外导致未清理临时视图的时候,这个技巧使得清理临时视图变得很简单。详细的信息可以参考后面的“丢失的临时表”。\n使用临时表算法实现的视图,在某些时候性能会很糟糕(虽然可能比直接使用等效查询语句要好一点)。MySQL以递归的方式执行这类视图,先会执行外层查询,即使外层查询优化器将其优化得很好,但是MySQL优化器可能无法像其他的数据库那样做更多的内外结合的优化。外层查询的WHERE条件无法“下推”到构建视图的临时表的查询中,临时表也无法建立索引(5)。下面是一个例子,还是基于temp.cost_per_day_1234这个视图:\nmysql\u0026gt; ** SELECT c.day, c.cost, s.sales** -\u0026gt; ** FROM temp.cost_per_day_1234 AS c** -\u0026gt; ** INNER JOIN sales.sales_per_day AS s USING(day)** -\u0026gt; ** WHERE day BETWEEN '2007-01-01' AND '2007-01-31';** 在这个查询中,MySQL先执行视图的SQL生成临时表,然后再将sales_per_day和临时表进行关联。这里的WHERE子句中的BETWEEN条件并不能下推到视图当中,所以视图在创建的时候仍然需要将所有的数据都放到临时表当中,而不仅仅是一个月的数据。而且临时表中不会有索引。这个案例中,索引还不是问题:MySQL将临时表作为关联顺序中的第一个表,因此这里可以使用sales_per_day中的索引。不过,如果是对两个视图做关联的话,优化器就没有任何索引可以使用了。\n视图还引入了一些并非MySQL特有的其他问题。很多开发者以为视图很简单,但实际上其背后的逻辑可能非常复杂。开发人员如果没有意识到视图背后的复杂性,很可能会以为是在不停地重复查询一张简单的表,而没有意识到实际上是代价高昂的视图。我们见过不少案例,一条看起来很简单的查询,EXPLAIN出来却有几百行,因为其中一个或者多个表,实际上是引用了很多其他表的视图。\n如果打算使用视图来提升性能,需要做比较详细的测试。即使是合并算法实现的视图也会有额外的开销,而且视图的性能很难预测。在MySQL优化器中,视图的代码执行路径也完全不同,这部分代码测试还不够全面,可能会有一些隐藏缺陷和问题。所以,我们认为视图还不是那么成熟。例如,我们看到过这样的案例,复杂的视图和高并发的查询导致查询优化器花了大量时间在执行计划生成和统计数据阶段,这甚至会导致MySQL服务器僵死,后来通过将视图转换成等价的查询语句解决了问题。这也说明视图——即使是使用合并算法实现的——并不总是有很优化的实现。\n7.2.3 视图的限制 # 在其他的关系数据库中你可能使用过物化视图,MySQL还不支持物化视图(物化视图是指将视图结果数据存放在一个可以查看的表中,并定期从原始表中刷新数据到这个表中)。MySQL也不支持在视图中创建索引。不过,可以使用构建缓存表或者汇总表的办法来模拟物化视图和索引。可以直接使用Justin Swanhart\u0026rsquo;s的工具Flexviews来实现这个目的。参考第4章可以获得更多的相关细节。\nMySQL视图实现上也有一些让人烦恼的地方。例如,MySQL并不会保存视图定义的原始SQL语句,所以如果打算通过执行SHOW CREATE VIEW后再简单地修改其结果的方式来重新定义视图,可能会大失所望。SHOW CREATE VIEW出来的视图创建语句将以一种不友好的内部格式呈现,充满了各种转义符和引号,没有代码格式化,没有注释,也没有缩进。\n如果打算重新修改一个视图,并且没法找到视图的原始的创建语句的话,可以通过使用视图的*.frm文件的最后一行获得一些信息。如果有FILE权限,甚至可以直接使用SQL语句中的LOAD_FILE()来读取.frm*中的视图创建信息。再加上一些字符处理工作,就可以获得一个完整的视图创建语句了,感谢Roland Bouman创造性的实现:\n7.3 外键约束 # InnoDB是目前MySQL中唯一支持外键的内置存储引擎,所以如果需要外键支持那选择就不多了(PBXT也有外键支持)。\n使用外键是有成本的。比如外键通常都要求每次在修改数据时都要在另外一张表中多执行一次查找操作。虽然InnoDB强制外键使用索引,但还是无法消除这种约束检查的开销。如果外键列的选择性很低,则会导致一个非常大且选择性很低的索引。例如,在一个非常大的表上有status列,并希望限制这个状态列的取值,如果该列只能取三个值——虽然这个列本身很小,但是如果主键很大,那么这个索引就会很大——而且这个索引除了做这个外键限制,也没有任何其他的作用了。\n不过,在某些场景下,外键会提升一些性能。如果想确保两个相关表始终有一致的数据,那么使用外键比在应用程序中检查一致性的性能要高得多,此外,外键在相关数据的删除和更新上,也比在应用中维护要更高效,不过,外键维护操作是逐行进行的,所以这样的更新会比批量删除和更新要慢些。\n外键约束使得查询需要额外访问一些别的表,这也意味着需要额外的锁。如果向子表中写入一条记录,外键约束会让InnoDB检查对应的父表的记录,也就需要对父表对应记录进行加锁操作,来确保这条记录不会在这个事务完成之时就被删除了。这会导致额外的锁等待,甚至会导致一些死锁。因为没有直接访问这些表,所以这类死锁问题往往难以排查。\n有时,可以使用触发器来代替外键。对于相关数据的同时更新外键更合适,但是如果外键只是用作数值约束,那么触发器或者显式地限制取值会更好些。(这里,可以直接使用ENUM类型。)\n如果只是使用外键做约束,那通常在应用程序里实现该约束会更好。外键会带来很大的额外消耗。这里没有相关的基准测试的数据,不过我们碰到过很多案例,在对性能进行剖析时发现外键约束就是瓶颈所在,删除外键后性能立即大幅提升。\n7.4 在MySQL内部存储代码 # MySQL允许通过触发器、存储过程、函数的形式来存储代码。从MySQL 5.1开始,还可以在定时任务中存放代码,这个定时任务也被称为“事件”。存储过程和存储函数都被统称为“存储程序”。\n这四种存储代码都使用特殊的SQL语句扩展,它包含了很多过程处理语法,例如循环和条件分支等(6)。不同类型的存储代码的主要区别在于其执行的上下文——也就是其输入和输出。存储过程和存储函数都可以接收参数然后返回值,但是触发器和事件却不行。\n一般来说,存储代码是一种很好的共享和复用代码的方法。Giuseppe Maxia和其他一些人也建立了一些通用的存储过程库,在网站 http://mysql-sr-lib.sourceforge.net可以找到。不过因为不同的关系数据库都有各自的语法规则,所以不同的数据库很难复用这些存储代码(DB2是一个例外,它和MySQL基于相同的标准,有着非常类似的语法)(7)。\n这里将主要关注存储代码的性能,而不是如何实现。如果你打算学习如何编写存储过程,那么Guy Harrison和Steven Feuerstein编写的MySQL Stored Procedure Programming(O\u0026rsquo;Reilly)应该会有帮助。\n有人倡导使用存储代码,也有人反对。这里我们不站在任何一边,只是列举一下在MySQL中使用存储代码的优点和缺点。首先,它有如下优点:\n它在服务器内部执行,离数据最近,另外在服务器上执行还可以节省带宽和网络延迟。 这是一种代码重用。可以方便地统一业务规则,保证某些行为总是一致,所以也可以为应用提供一定的安全性。 它可以简化代码的维护和版本更新。 它可以帮助提升安全,比如提供更细粒度的权限控制。一个常见的例子是银行用于转移资金的存储过程:这个存储过程可以在一个事务中完成资金转移和记录用于审计的日志。应用程序也可以通过存储过程的接口访问那些没有权限的表。 服务器端可以缓存存储过程的执行计划,这对于需要反复调用的过程,会大大降低消耗。 因为是在服务器端部署的,所以备份、维护都可以在服务器端完成。所以存储程序的维护工作会很简单。它没什么外部依赖,例如,不依赖任何Perl包和其他不想在服务器上部署的外部软件。 它可以在应用开发和数据库开发人员之间更好地分工。不过最好是由数据库专家来开发存储过程,因为不是每个应用开发人员都能写出高效的SQL查询。 存储代码也有如下缺点:\nMySQL本身没有提供好用的开发和调试工具,所以编写MySQL的存储代码比其他的数据库要更难些。 较之应用程序的代码,存储代码效率要稍微差些。例如,存储代码中可以使用的函数非常有限,所以使用存储代码很难编写复杂的字符串维护功能,也很难实现太复杂的逻辑。 存储代码可能会给应用程序代码的部署带来额外的复杂性。原本只需要部署应用代码和库表结构变更,现在还需要额外地部署MySQL内部的存储代码。 因为存储程序都部署在服务器内,所以可能有安全隐患。如果将非标准的加密功能放在存储程序中,那么若数据库被攻破,数据也就泄漏了。但是若将加密函数放在应用程序代码中,那么攻击者必须同时攻破程序和数据库才能获得数据。 存储过程会给数据库服务器增加额外的压力,而数据库服务器的扩展性相比应用服务器要差很多。 MySQL并没有什么选项可以控制存储程序的资源消耗,所以在存储过程中的一个小错误,可能直接把服务器拖死。 存储代码在MySQL中的实现也有很多限制——执行计划缓存是连接级别的,游标的物化和临时表相同,在MySQL 5.5版本之前,异常处理也非常困难,等等。(我们会在介绍它的各个特性的同时介绍相关的限制)。简而言之,较之T-SQL或者PL/SQL,MySQL的存储代码功能还非常非常弱。 调试MySQL的存储过程是一件很困难的事情。如果慢日志只是给出CALL XYZ(\u0026lsquo;A\u0026rsquo;),通常很难定位到底是什么导致的问题,这时不得不看看存储过程中的SQL语句是如何编写的。(这在Percona Server中可以通过参数控制。) 它和基于语句的二进制日志复制合作得并不好。在基于语句的复制中,使用存储代码通常有很多的陷阱,除非你在这方面的经验非常丰富或者非常有耐心排查这类问题,否则需要谨慎使用。 这个缺陷列表很长——那么在真实世界中,这意味着什么?我们来看一个真实世界中弄巧成拙的案例:在一个实例中,创建了一个存储过程来给应用程序访问数据库中的数据,这使得所有的数据访问都需要通过这个接口,甚至很多根据主键的查询也是如此,这大概使系统的性能降低了五倍左右。\n最后,存储代码是一种帮助应用隐藏复杂性,使得应用开发更简单的方法。不过,它的性能可能更低,而且会给MySQL的复制等增加潜在的风险。所以当你打算使用存储过程的时候,需要问问自己,到底希望程序逻辑在哪儿实现:是数据库中还是应用代码中?这两种做法都可以,也都很流行。只是当你编写存储代码的时候,你需要明白这是将程序逻辑放在数据库中。\n7.4.1 存储过程和函数 # MySQL的架构本身和优化器的特性使得存储代码有一些天然的限制,它的性能也一定程度受限于此。在本书编写的时候,有如下的限制:\n优化器无法使用关键字DETERMINISTIC来优化单个查询中多次调用存储函数的情况。 优化器无法评估存储函数的执行成本。 每个连接都有独立的存储过程的执行计划缓存。如果有多个连接需要调用同一个存储过程,将会浪费缓存空间来反复缓存同样的执行计划。(如果使用的是连接池或者是持久化连接,那么执行计划缓存可能会有更长的生命周期。) 存储程序和复制是一组诡异组合。如果可以,最好不要复制对存储程序的调用。直接复制由存储程序改变的数据则会更好。MySQL 5.1引入的行复制能够改善这个问题。如果在MySQL 5.0中开启了二进制日志,那么要么在所有的存储过程中都增加DETERMINISTIC限制或者设置MySQL的选项log_bin_trust_function_creators。 我们通常会希望存储程序越小、越简单越好。希望将更加复杂的处理逻辑交给上层的应用实现,通常这样会使代码更易读、易维护,也会更灵活。这样做也会让你拥有更多的计算资源,潜在的还会让你拥有更多的缓存资源(8)。\n不过,对于某些操作,存储过程比其他的实现要快得多——特别是当一个存储过程调用可以代替很多小查询的时候。如果查询很小,相比这个查询执行的成本,解析和网络开销就变得非常明显。为了证明这一点,我们先创建一个简单的存储过程,用来写入一定数量的数据到一个表中,下面是存储过程的代码:\n1 DROP PROCEDURE IF EXISTS insert_many_rows; 2 3 delimiter // 4 5 CREATE PROCEDURE insert_many_rows (IN loops INT) 6 BEGIN 7 DECLARE v1 INT; 8 SET v1=loops; 9 WHILE v1 \u0026gt; 0 DO 10 INSERT INTO test_table values(NULL,0, 11 'qqqqqqqqqqwwwwwwwwwweeeeeeeeeerrrrrrrrrrtttttttttt', 12 'qqqqqqqqqqwwwwwwwwwweeeeeeeeeerrrrrrrrrrtttttttttt'); 13 SET v1 = v1 - 1; 14 END WHILE; 15 END; 16 // 17 18 delimiter ; 然后对该存储过程执行基准测试,看插入一百万条记录的时间,并和通过客户端程序逐条插入一百万条记录的时间进行对比。这里表结构和硬件并不重要——重要的是两种方式的相对速度。另外,我们还测试了使用MySQL Proxy连接MySQL来执行客户端程序测试的性能。为了让事情简单,整个测试在一台服务器上完成,包括客户端程序和MySQL Proxy实例。表7-1展示了测试结果。\n表7-1:写入一百万数据所花费的总时间 写入方式 总消耗时间 存储过程 101 sec 客户端程序 279 sec 使用MySQL Proxy的客户端程序 307 sec\n可以看到存储过程要快很多,很大程度因为它无须网络通信开销、解析开销和优化器开销等。\n我们将在本章的后半部分介绍如何维护存储过程。\n7.4.2 触发器 # 触发器可以让你在执行INSERT、UPDATE或者DELETE的时候,执行一些特定的操作。可以在MySQL中指定是在SQL语句执行前触发还是在执行后触发。触发器本身没有返回值,不过它们可以读取或者改变触发SQL语句所影响的数据。所以,可以使用触发器实现一些强制限制,或者某些业务逻辑,否则,就需要在应用程序中实现这些逻辑。\n因为使用触发器可以减少客户端和服务器之间的通信,所以触发器可以简化应用逻辑,还可以提高性能。另外,还可以用于自动更新反范式化数据或者汇总表数据。例如,在示例数据库Sakila中,我们可以使用触发器来维护film_text表。\nMySQL触发器的实现非常简单,所以功能也有限。如果你在其他数据库产品中已经重度依赖触发器,那么在使用MySQL的时候需要注意,很多时候MySQL触发器的表现和预想的并不一样。特别需要注意以下几点:\n对每一个表的每一个事件,最多只能定义一个触发器(换句话说,不能在AFTER INSERT上定义两个触发器)。 MySQL只支持“基于行的触发”——也就是说,触发器始终是针对一条记录的,而不是针对整个SQL语句的。如果变更的数据集非常大的话,效率会很低。 下面这些触发器本身的限制也适用于MySQL:\n触发器可以掩盖服务器背后的工作,一个简单的SQL语句背后,因为触发器,可能包含了很多看不见的工作。例如,触发器可能会更新另一个相关表,那么这个触发器会让这条SQL影响的记录数翻一倍。 触发器的问题也很难排查,如果某个性能问题和触发器相关,会很难分析和定位。 触发器可能导致死锁和锁等待。如果触发器失败,那么原来的SQL语句也会失败。如果没有意识到这其中是触发器在搞鬼,那么很难理解服务器抛出的错误代码是什么意思。 如果仅考虑性能,那么MySQL触发器的实现中对服务器限制最大的就是它的“基于行的触发”设计。因为性能的原因,很多时候无法使用触发器来维护汇总和缓存表。使用触发器而不是批量更新的一个重要原因就是,使用触发器可以保证数据总是一致的。\n触发器并不能一定保证更新的原子性。例如,一个触发器在更新MyISAM表的时候,如果遇到什么错误,是没有办法做回滚操作的。这时,触发器可以抛出错误。假设你在一个MyISAM表上建立一个AFTER UPDATE的触发器,用来更新另一个MyISAM表。如果触发器在更新第二个表的时候遇到错误导致更新失败,那么第一个表的更新并不会回滚。\n在InnoDB表上的触发器是在同一个事务中完成的,所以它们执行的操作是原子的,原操作和触发器操作会同时失败或者成功。不过,如果在InnoDB表上建触发器去检查数据的一致性,需要特别小心MVCC,稍不小心,你可能会获得错误的结果。假设,你想实现外键约束,但是不打算使用InnoDB的外键约束。若打算编写一个BEFORE INSERT触发器来检查写入的数据对应列在另一个表中是存在的,但若你在触发器中没有使用SELECT FOR UPDATE,那么并发的更新语句可能会立刻更新对应记录,导致数据不一致。我们不是危言耸听,让大家不要使用触发器。相反,触发器非常有用,尤其是实现一些约束、系统维护任务,以及更新反范式化数据的时候。\n还可以使用触发器来记录数据变更日志。这对实现一些自定义的复制会非常方便,比如需要先断开连接,然后修改数据,最后再将所有的修改重新合并回去的情况。一个简单的例子是,一组用户各自在自己的个人电脑上工作,但他们的操作都需要同步到一台主数据库上,然后主数据库会将他们所有人的操作都分发给每个人。实现这个系统需要做两次同步操作。触发器就是构建整个系统的一个好办法。每个人的电脑上都可以使用一个触发器来记录每一次数据的修改,并将其发送到主数据库中。然后,再使用MySQL的复制将主数据库上的所有操作都复制一份到本地并应用。这里需要额外注意的是,如果触发器基于有自增主键的记录,并且使用的是基于语句的复制,那么自增长可能会在复制中出现不一致。\n有时候可以使用一些技巧绕过触发器是“基于行的触发”这个限制。Roland Bouman发现,对于BEFORE触发器除了处理的第一条记录,触发器函数ROW_COUNT()总是会返回1。可以使用这个特点,使得触发器不再是针对每一行都运行,而是针对一条SQL语句运行一次。这和真正意义上的单条SQL语句的触发器并不相同,不过可以使用这个技术来模拟单条SQL语句的BEFORE触发器。这个行为可能是MySQL的一个缺陷,未来版本中可能会被修复,所以在使用这个技巧的时候,需要先验证在你的MySQL版本中是否适用,另外,在升级数据库的时候还需要检查这类触发器是否还能够正常工作。下面是一个使用这个技巧的例子:\nCREATE TRIGGER fake_statement_trigger BEFORE INSERT ON sometable FOR EACH ROW BEGIN DECLARE v_row_count INT DEFAULT ROW_COUNT(); IF v_row_count \u0026lt;\u0026gt; 1 THEN -- Your code here END IF; END; 7.4.3 事件 # 事件是MySQL 5.1引入的一种新的存储代码的方式。它类似于Linux的定时任务,不过是完全在MySQL内部实现的。你可以创建事件,指定MySQL在某个时候执行一段SQL代码,或者每隔一个时间间隔执行一段SQL代码。通常,我们会把复杂的SQL都封装到一个存储过程中,这样事件在执行的时候只需要做一个简单的CALL调用。\n事件在一个独立事件调度线程中被初始化,这个线程和处理连接的线程没有任何关系。它不接收任何参数,也没有任何的返回值。可以在MySQL的日志中看到命令的执行日志,还可以在表INFORMATION_SCHEMA.EVENTS中看到各个事件状态,例如这个事件最后一次被执行的时间等。\n类似的,一些适用于存储过程的考虑也同样适用于事件。首先,创建事件意味着给服务器带来额外工作。事件实现机制本身的开销并不大,但是事件需要执行SQL,则可能会对性能有很大的影响。更进一步,事件和其他的存储程序一样,在和基于语句的复制一起工作时,也可能会触发同样的问题。事件的一些典型应用包括定期地维护任务、重建缓存、构建汇总表来模拟物化视图,或者存储用于监控和诊断的状态值。\n下面的例子创建了一个事件,它会每周一次针对某个数据库运行一个存储过程(后面我们将展示如何创建这个存储过程):\nCREATE EVENT optimize_somedb ON SCHEDULE EVERY 1 WEEK DO CALL optimize_tables('somedb'); 你可以指定事件本身是否被复制。根据需要,有时需要被复制,有时则不需要。看前面的例子,你可能会希望在所有的备库上都运行OPTIMIZE TABLE,不过要注意如果所有的备库同时执行,可能会影响服务器的性能(会对表加锁)。\n最后,如果一个定时事件执行需要很长的时间,那么有可能会出现这样的情况,即前面一个事件还未执行完成,下一个时间点的事件又开始了。MySQL本身不会防止这种并发,所以需要用户自己编写这种情况下的防并发代码。你可以使用函数GET_LOCK()来确保当前总是只有一个事件在被执行:\nCREATE EVENT optimize_somedb ON SCHEDULE EVERY 1 WEEK DO BEGIN DECLARE CONTINUE HANLDER FOR SQLEXCEPTION BEGIN END; IF GET_LOCK('somedb', 0) THEN DO CALL optimize_tables('somedb'); END IF; DO RELEASE_LOCK('somedb'); END 这里的“CONTINUE HANLDER”用来确保,即使当事件执行出现了异样,仍然会释放持有的锁。\n虽然事件的执行是和连接无关的,但是它仍然是线程级别的。MySQL中有一个事件调度线程,必须在MySQL配置文件中设置,或者使用下面的命令来设置:\nmysql\u0026gt; ** SET GLOBAL event_scheduler := 1;** 该选项一旦设置,该线程就会执行各个用户指定的事件中的各段SQL代码。你可以通过观察MySQL的错误日志来了解事件的执行情况。\n虽然事件调度是一个单独的线程,但是事件本身是可以并行执行的。MySQL会创建一个新的进程用于事件执行。在事件的代码中,如果你调用函数CONNECTION_ID(),也会返回一个唯一值,和一般的线程返回值一样——虽然事件和MySQL的连接线程是无关的(这里的函数CONNECTION_ID()返回的只是线程ID)。这里的进程和线程生命周期就是事件的执行过程。可以通过SHOW PROCESSLIST中的Command列来查看,这些线程的该列总是显示为“Connect”。\n虽然事件处理进程需要创建一个线程来真正地执行事件,但该线程在时间执行结束后会被销毁,而不会放到线程缓存中,并且状态值Threads_created也不会被增加。\n7.4.4 在存储程序中保留注释 # 存储过程、存储函数、触发器、事件通常都会包含大量的重要代码,在这些代码中加上注释就非常有必要了。但是这些注释可能不会存储在MySQL服务器中,因为MySQL的命令行客户端会自动过滤注释(命令行客户端的这个“特性”令人生厌,不过这就是生活)。\n一个将注释存储到存储程序中的技巧就是使用版本相关的注释,因为这样的注释可能被MySQL服务器执行(例如,只有版本号大于某个值的时候才执行的代码)。服务器和客户端都知道这不是普通的注释,所以也就不会删除这些注释。为了让这样的“版本相关的代码”不被执行,可以指定一个非常大的版本号,例如99 999。我们现在给触发器加上一些注释文档,让它更易读:\nCREATE TRIGGER fake_statement_trigger BEFORE INSERT ON sometable FOR EACH ROW BEGIN DECLARE v_row_count INT DEFAULT ROW_COUNT(); ** /*!99999 ROW_COUNT() is 1 except for the first row, so this executes** ** only once per statement. */** IF v_row_count \u0026lt;\u0026gt; 1 THEN -- Your code here END IF; END; 7.5 游标 # MySQL在服务器端提供只读的、单向的游标,而且只能在存储过程或者更底层的客户端API中使用。因为MySQL游标中指向的对象都是存储在临时表中而不是实际查询到的数据,所以MySQL游标总是只读的。它可以逐行指向查询结果,然后让程序做进一步的处理。在一个存储过程中,可以有多个游标,也可以在循环中“嵌套”地使用游标。MySQL的游标设计也为粗心的人“准备”了陷阱。因为是使用临时表实现的,所以它在效率上给开发人员一个错觉。需要记住的最重要的一点是:当你打开一个游标的时候需要执行整个查询。考虑下面的存储过程:\n1 CREATE PROCEDURE bad_cursor() 2 BEGIN 3 DECLARE film_id INT; 4 DECLARE f CURSOR FOR SELECT film_id FROM sakila.film; 5 OPEN f; 6 FETCH f INTO film_id; 7 CLOSE f; 8 END 从这个例子中可以看到,不用处理完所有的数据就可以立刻关闭游标。使用Oracle或者SQL Server的用户不会认为这个存储过程有什么问题,但是在MySQL中,这会带来很多的不必要的额外操作。使用SHOW STATUS来诊断这个存储过程,可以看到它需要做1000个索引页的读取,做1000个写入。这是因为在表sakila.film中有1000条记录,而所有这些读和写都发生在第五行的打开游标动作。\n这个案例告诉我们,如果在关闭游标的时候你只是扫描一个大结果集的一小部分,那么存储过程可能不仅没有减少开销,相反带来了大量的额外开销。这时,你需要考虑使用LIMIT来限制返回的结果集。\n游标也会让MySQL执行一些额外的I/O操作,而这些操作的效率可能非常低。因为临时内存表不支持BLOB和TEXT类型,如果游标返回的结果包含这样的列的话,MySQL就必须创建临时磁盘表来存放,这样性能可能会很糟。即使没有这样的列,当临时表大于tmp_table_size的时候,MyQL也还是会在磁盘上创建临时表。\nMySQL不支持客户端的游标,不过客户端API可以通过缓存全部查询结果的方式模拟客户端的游标。这和直接将结果放在一个内存数组中来维护并没有什么不同。参考第6章,你可以看到更多关于一次性读取整个结果集到客户端时的性能。\n7.6 绑定变量 # 从MySQL 4.1版本开始,就支持服务器端的绑定变量(prepared statement),这大大提高了客户端和服务器端数据传输的效率。你若使用一个支持新协议的客户端,如MySQL CAPI,就可以使用绑定变量功能了。另外,Java和.NET的也都可以使用各自的客户端Connector/J和Connector/NET来使用绑定变量。最后,还有一个SQL接口用于支持绑定变量,后面我们将讨论这个(这里容易引起困扰)。\n当创建一个绑定变量SQL时,客户端向服务器发送了一个SQL语句的原型。服务器端收到这个SQL语句框架后,解析并存储这个SQL语句的部分执行计划,返回给客户端一个SQL语句处理句柄。以后每次执行这类查询,客户端都指定使用这个句柄。\n绑定变量的SQL,使用问号标记可以接收参数的位置,当真正需要执行具体查询的时候,则使用具体值代替这些问号。例如,下面是一个绑定变量的SQL语句:\nINSERT INTO tbl(col1, col2, col3) VALUES (?, ?, ?); 可以通过向服务器端发送各个问号的取值和这个SQL的句柄来执行一个具体的查询。反复使用这样的方式执行具体的查询,这正是绑定变量的优势所在。具体如何发送取值参数和SQL句柄,则和各个客户端的编程语言有关。使用Java和.NET的MySQL连接器就是一种办法。很多使用MySQL C语言链接库的客户端可以提供类似的接口,需要根据使用的编程语言的文档来了解如何使用绑定变量。\n因为如下的原因,MySQL在使用绑定变量的时候可以更高效地执行大量的重复语句:\n在服务器端只需要解析一次SQL语句。 在服务器端某些优化器的工作只需要执行一次,因为它会缓存一部分的执行计划。 以二进制的方式只发送参数和句柄,比起每次都发送ASCII码文本效率更高,一个二进制的日期字段只需要三个字节,但如果是ASCII码则需要十个字节。不过最大的节省还是来自于BLOB和TEXT字段,绑定变量的形式可以分块传输,而无须一次性传输。二进制协议在客户端也可能节省很多内存,减少了网络开销,另外,还节省了将数据从存储原始格式转换成文本格式的开销。 仅仅是参数——而不是整个查询语句——需要发送到服务器端,所以网络开销会更小。 MySQL在存储参数的时候,直接将其存放到缓存中,不再需要在内存中多次复制。 绑定变量相对也更安全。无须在应用程序中处理转义,一则更简单了,二则也大大减少了SQL注入和攻击的风险。(任何时候都不要信任用户输入,即使是使用绑定变量的时候。)\n可以只在使用绑定变量的时候才使用二进制传输协议。如果使用普通的mysql_query()接口则不会使用二进制传输协议。还有一些客户端让你使用绑定变量,先发送带参数的绑定SQL,然后发送变量值,但是实际上,这些客户端只是模拟了绑定变量的接口,最后还是会直接用具体值代替参数后,再使用mysql_query()发送整个查询语句。\n7.6.1 绑定变量的优化 # 对使用绑定变量的SQL,MySQL能够缓存其部分执行计划,如果某些执行计划需要根据传入的参数来计算时,MySQL就无法缓存这部分的执行计划。根据优化器什么时候工作,可以将优化分为三类。在本书编写的时候,下面的三点是适用的。\n在准备阶段\n服务器解析SQL语句,移除不可能的条件,并且重写子查询。\n在第一次执行的时候\n如果可能的话,服务器先简化嵌套循环的关联,并将外关联转化成内关联。\n在每次SQL语句执行时\n服务器做如下事情:\n过滤分区。 如果可能的话,尽量移除COUNT()、MIN()和MAX()。 移除常数表达式。 检测常量表。 做必要的等值传播。 分析和优化ref、range和索引优化等访问数据的方法。 优化关联顺序。 参考第6章,可以了解更多关于这些优化的信息。理论上,有些优化只需要做一次,但实际上,上面的操作还是都会被执行。\n7.6.2 SQL接口的绑定变量 # 在4.1和更新的版本中,MySQL支持了SQL接口的绑定变量。不使用二进制传输协议也可以直接以SQL的方式使用绑定变量。下面案例展示了如何使用SQL接口的绑定变量:\n当服务器收到这些SQL语句后,先会像一般客户端的链接库一样将其翻译成对应的操作。\n这意味着你无须使用二进制协议也可以使用绑定变量。\n正如你看到的,比起直接编写的SQL语句,这里的语法看起来有一些怪怪的。那么,这种写法实现的绑定变量到底有什么优势呢?\n最主要的用途就是在存储过程中使用。在MySQL 5.0版本中,就可以在存储过程中使用绑定变量,其语法和前面介绍的SQL接口的绑定变量类似。这意味,可以在存储过程中构建并执行“动态”的SQL语句,这里的“动态”是指可以通过灵活地拼接字符串等参数构建SQL语句。例如,下面的示例存储过程中可以针对某个数据库执行OPTIMIZE TABLE的操作:\nDROP PROCEDURE IF EXISTS optimize_tables; DELIMITER // CREATE PROCEDURE optimize_tables(db_name VARCHAR(64)) BEGIN DECLARE t VARCHAR(64); DECLARE done INT DEFAULT 0; DECLARE c CURSOR FOR SELECT table_name FROM INFORMATION_SCHEMA.TABLES WHERE TABLE_SCHEMA = db_name AND TABLE_TYPE = 'BASE TABLE'; DECLARE CONTINUE HANDLER FOR SQLSTATE '02000' SET done = 1; OPEN c; tables_loop: LOOP FETCH c INTO t; IF done THEN LEAVE tables_loop; END IF; SET @stmt_text := CONCAT(\u0026quot;OPTIMIZE TABLE \u0026quot;, db_name, \u0026quot;.\u0026quot;, t); PREPARE stmt FROM @stmt_text; EXECUTE stmt; DEALLOCATE PREPARE stmt; END LOOP; CLOSE c; END// DELIMITER ; 可以这样调用这个存储过程:\nmysql\u0026gt; ** CALL optimize_tables('sakila')** 另一种实现存储过程中循环的办法是:\nREPEAT FETCH c INTO t; IF NOT done THEN SET @stmt_text := CONCAT(\u0026quot;OPTIMIZE TABLE \u0026quot;, db_name, \u0026quot;.\u0026quot;, t); PREPARE stmt FROM @stmt_text; EXECUTE stmt; DEALLOCATE PREPARE stmt; END IF; UNTIL done END REPEAT; 这两种循环结构最重要的区别在于:REPEAT会为每个循环检查两次循环条件。在这个例子中,因为循环条件检查的是一个整数判断,并不会有什么性能问题,如果循环的判断条件非常复杂的话,则需要注意这两者的区别。\n像这样使用SQL接口的绑定变量拼接表名和库名是很常见的,这样的好处是无须使用任何参数就能完成SQL语句。而库名和表名都是关键字,在二进制协议的绑定变量中是不能将这两部分参数化的。另一个经常需要动态设置的就是LIMIT子句,因为二进制协议中也无法将这个值参数化。\n另外,编写存储过程时,SQL接口的绑定变量通常可以很大程度地帮助我们调试绑定变量,如果不是在存储过程中,SQL接口的绑定变量就不是那么有用了。因为SQL接口的绑定变量,它既没有使用二进制传输协议,也没有能够节省带宽,相反还总是需要增加至少一次额外网络传输才能完成一次查询。所有只有在某些特殊的场景下SQL接口的绑定变量才有用,比如当SQL语句非常非常长,并且需要多次执行的时候。\n7.6.3 绑定变量的限制 # 关于绑定变量的一些限制和注意事项如下:\n绑定变量是会话级别的,所以连接之间不能共用绑定变量句柄。同样地,一旦连接断开,则原来的句柄也不能再使用了。(连接池和持久化连接可以在一定程度上缓解这个问题。) 在MySQL 5.1版本之前,绑定变量的SQL是不能使用查询缓存的。 并不是所有的时候使用绑定变量都能获得更好的性能。如果只是执行一次SQL,那么使用绑定变量方式无疑比直接执行多了一次额外的准备阶段消耗,而且还需要一次额外的网络开销。(要正确地使用绑定变量,还需要在使用完成后,释放相关的资源。) 当前版本下,还不能在存储函数中使用绑定变量(但是存储过程中可以使用)。 如果总是忘记释放绑定变量资源,则在服务器端很容易发生资源“泄漏”。绑定变量 SQL总数的限制是一个全局限制,所以某一个地方的错误可能会对所有其他的线程都产生影响。 有些操作,如BEGIN,无法在绑定变量中完成。 不过使用绑定变量最大的障碍可能是:它是如何实现以及原理是怎样的,这两点很容易让人困惑。有时,很难解释如下三种绑定变量类型之间的区别是什么:\n客户端模拟的绑定变量\n客户端的驱动程序接收一个带参数的SQL,再将指定的值带入其中,最后将完整的查询发送到服务器端。\n服务器端的绑定变量\n客户端使用特殊的二进制协议将带参数的字符串发送到服务器端,然后使用二进制协议将具体的参数值发送给服务器端并执行。\nSQL接口的绑定变量\n客户端先发送一个带参数的字符串到服务器端,这类似于使用PREPARE的SQL语句,然后发送设置参数的SQL,最后使用EXECUTE来执行SQL。所有这些都使用普通的文本传输协议。\n7.7 用户自定义函数 # 从很早开始,MySQL就支持用户自定义函数(UDF)。存储过程只能使用SQL来编写,而UDF没有这个限制,你可以使用支持C语言调用约定的任何编程语言来实现。\nUDF必须事先编译好并动态链接到服务器上,这种平台相关性使得UDF在很多方面都很强大。UDF速度非常快,而且可以访问大量操作系统的功能,还可以使用大量库函数。使用SQL实现的存储函数在实现一些简单操作上很有优势,诸如计算球体上两点之间的距离,但是如果操作涉及到网络交互,那么只能使用UDF了。同样地,如果需要一个MySQL不支持的统计聚合函数,而且无法使用SQL编写的存储函数来实现的话,通常使用UDF是很容易实现的。\n能力越大,责任越大。所以在UDF中的一个错误很可能会让服务器直接崩溃,甚至扰乱服务器的内存或者数据,另外,所有C语言具有的潜在风险,UDF也都有。\n和使用SQL语言编写存储程序不同,UDF无法读写数据表——至少,无法在调用UDF的线程中使用当前事务处理的上下文来读写数据表。这意味着,它更适合用作计算或者与外面的世界交互。MySQL已经支持越来越多的方式和外面的资源交互了。Brian Aker和Patrick Galbraith创建的与memcached通信的函数就是一个UDF很好的案例(参考: http://tangent.org/586/Memcached_Functions_for_MySQL.html)。\n如果打算使用UDF,那么在MySQL版本升级的时候需要特别注意做相应的改变,因为很可能需要重新编译这些UDF,或者甚至需要修改UDF来让它能在新的版本中工作。还需要注意的是,你需要确保UDF是线程安全的,因为它们需要在MySQL中执行,而MySQL是一个纯粹的多线程环境。\n现在已经有很多写好的UDF直接提供给MySQL使用,还有很多UDF的示例可供参考,以便完成自己的UDF。现在UDF最大的仓库是 http://www.mysqludf.org。\n下面是一个用户自定义函数NOW_USEC()的代码,这个函数在第10章中我们将用它来测量复制的速度:\n#include \u0026lt;my_global.h\u0026gt; #include \u0026lt;my_sys.h\u0026gt; #include \u0026lt;mysql.h\u0026gt; #include \u0026lt;stdio.h\u0026gt; #include \u0026lt;sys/time.h\u0026gt; #include \u0026lt;time.h\u0026gt; #include \u0026lt;unistd.h\u0026gt; extern \u0026quot;C\u0026quot; { my_bool now_usec_init(UDF_INIT *initid, UDF_ARGS *args, char *message); char *now_usec( UDF_INIT *initid, UDF_ARGS *args, char *result, unsigned long *length, char *is_null, char *error); } my_bool now_usec_init(UDF_INIT *initid, UDF_ARGS *args, char *message) { return 0; } char *now_usec(UDF_INIT *initid, UDF_ARGS *args, char *result, unsigned long *length, char *is_null, char *error) { struct timeval tv; struct tm* ptm; char time_string[20]; /* e.g. \u0026quot;2006-04-27 17:10:52\u0026quot; */ char *usec_time_string = result; time_t t; /* Obtain the time of day, and convert it to a tm struct. */ gettimeofday (\u0026amp;tv, NULL); t = (time_t)tv.tv_sec; ptm = localtime (\u0026amp;t); /* Format the date and time, down to a single second. */ | Chapter 7: Advanced MySQL Features strftime (time_string, sizeof (time_string), \u0026quot;%Y-%m-%d %H:%M:%S\u0026quot;, ptm); /* Print the formatted time, in seconds, followed by a decimal point * and the microseconds. */ sprintf(usec_time_string, \u0026quot;%s.%06ld\\n\u0026quot;, time_string, tv.tv_usec); *length = 26; return(usec_time_string); } 参考前一章中的案例学习,可以看到如何使用用户自定义函数来解决一些棘手的问题。我们在Percona Toolkit中也使用了UDF来完成一些工作,例如高效的数据复制校验,或者在Sphinx索引之前使用UDF来预处理一些问题等。UDF是一款非常强大的工具。\n7.8 插件 # 除了UDF,MySQL还支持各种各样的插件。这些插件可以在MySQL中新增启动选项和状态值,还可以新增INFORMATION_SCHEMA表,或者在MySQL的后台执行任务,等等。在MySQL 5.1和更新的版本中,MySQL新增了很多的插件接口,使得你无须直接修改MySQL的源代码就可以大大扩展它的功能。下面是一个简单的插件列表。\n存储过程插件\n存储过程插件可以帮你在存储过程运行后再处理一次运行结果。这是一个很古老的插件了,和UDF有些类似,多数人都可能忘记了这个插件的存在。内置的PROCEDURE ANALYSE就是一个很好的示例。\n后台插件\n后台插件可以让你的程序在MySQL中运行,可以实现自己的网络监听、执行自己的定期任务。后台插件的一个典型例子就是在Percona Server中包含的Handler-Socket插件。它监听一个新的网络端口,使用一个简单的协议可以帮你无须使用SQL接口直接访问InnoDB数据,这也使得MySQL能够像一些NoSQL一样具有非常高的性能。\nINFORMATION_SCHEMA插件\n这个插件可以提供一个新的内存INFORMATION_SCHEMA表。\n全文解析插件\n这个插件提供一种处理文本的功能,可以根据自己的需求来对一个文档进行分词,所以如果给定一个PDF文档目录,可以使用这个插件对这个文档进行分词处理。也可以用此来增强查询执行过程中的词语匹配功能。\n审计插件\n审计插件在查询执行的过程中的某些固定点被调用,所以它可以用作(例如)记录MySQL的事件日志。\n认证插件\n认证插件既可以在MySQL客户端也可在它的服务器端,可以使用这类插件来扩展MySQL的认证功能,例如可以实现PAM和LDAP认证。\n要了解更多细节,可以参考MySQL的官方手册,或者读读由Sergei Golubchik和Andrew Hutchings (Packt)编写的MySQL 5.1 Plugin Development。如果你需要一个插件,但是却不知道怎么实现,有很多公司都提供这类咨询服务,例如Monty Program、Open Query、Percona和SkySQL。\n7.9 字符集和校对 # 字符集是指一种从二进制编码到某类字符符号的映射,可以参考如何使用一个字节来表示英文字母。“校对”是指一组用于某个字符集的排序规则。MySQL 4.1和之后的版本中,每一类编码字符都有其对应的字符集和校对规则(9)。MySQL对各种字符集的支持非常完善,但是这也带来了一定的复杂性,某些场景下甚至会有一定的性能牺牲。(另外,曾经Drizzle放弃了所有的字符集,所有字符全部统一使用UTF-8。)\n本节将解释在实际使用中,你可能最需要的一些设置和功能。如果想了解更多细节,可以详细地阅读MySQL官方手册的相关章节。\n7.9.1 MySQL如何使用字符集 # 每种字符集都可能有多种校对规则,并且都有一个默认的校对规则。每个校对规则都是针对某个特定的字符集的,和其他的字符集没有关系。校对规则和字符集总是一起使用的,所以后面我们将这样的组合也统称为一个字符集。\nMySQL有很多的选项用于控制字符集。这些选项和字符集很容易混淆,一定要记住:只有基于字符的值才真正的“有”字符集的概念。对于其他类型的值,字符集只是一个设置,指定用哪一种字符集来做比较或者其他操作。基于字符的值能存放在某列中、查询的字符串中、表达式的计算结果中或者某个用户变量中,等等。\nMySQL的设置可以分为两类:创建对象时的默认值、在服务器和客户端通信时的设置。\n创建对象时的默认设置 # MySQL服务器有默认的字符集和校对规则,每个数据库也有自己的默认值,每个表也有自己的默认值。这是一个逐层继承的默认设置,最终最靠底层的默认设置将影响你创建的对象。这些默认值,至上而下地告诉MySQL应该使用什么字符集来存储某个列。\n在这个“阶梯”的每一层,你都可以指定一个特定的字符集或者让服务器使用它的默认值:\n创建数据库的时候,将根据服务器上的character_set_server设置来设定该数据库的默认字符集。 创建表的时候,将根据数据库的字符集设置指定这个表的字符集设置。 创建列的时候,将根据表的设置指定列的字符集设置。 需要记住的是,真正存放数据的是列,所以更高“阶梯”的设置只是指定默认值。一个表的默认字符集设置无法影响存储在这个表中某个列的值。只有当创建列而没有为列指定字符集的时候,如果没有指定字符集,表的默认字符集才有作用。\n服务器和客户端通信时的设置 # 当服务器和客户端通信的时候,它们可能使用不同的字符集。这时,服务器端将进行必要的翻译转换工作:\n服务器端总是假设客户端是按照character_set_client设置的字符来传输数据和SQL语句的。 当服务器收到客户端的SQL语句时,它先将其转换成字符集character_set_connection。它还使用这个设置来决定如何将数据转换成字符串。 当服务器端返回数据或者错误信息给客户端时,它会将其转换成character_set_result。 图7-2展示了这个过程。\n图7-2:客户端和服务器的字符集\n根据需要,可以使用SET NAMES或者SET CHARACTER SET语句来改变上面的设置。不过在服务器上使用这个命令只能改变服务器端的设置。客户端程序和客户端的API也需要使用正确的字符集才能避免在通信时出现问题。\n假设使用latin1字符集(这是默认字符集)打开一个连接,并使用SET NAMES utf8来告诉服务器客户端将使用UTF-8字符集来传输数据。这样就创建了一个不匹配的字符集,可能会导致一些错误甚至出现一些安全性问题。应当先设置客户端字符集然后使用函数mysql_real_escape_string()在需要的时候进行转义。在PHP中,可以使用mysql_set_charset()来修改客户端的字符集。\nMySQL如何比较两个字符串的大小 # 如果比较的两个字符串的字符集不同,MySQL会先将其转成同一个字符集再进行比较。如果两个字符集不兼容的话,则会抛出错误,例如“ERROR 1267(HY000):Illegal mix of collations”。这种情况下需要通过函数CONVERT()显式地将其中一个字符串的字符集转成一个兼容的字符集。MySQL 5.0和更新的版本经常会做这样的隐式转换,所以这类错误通常是在MySQL 4.1中比较常见。\nMySQL还会为每个字符串设置一个“可转换性”(10)。这个设置决定了值的字符集的优先级,因而会影响MySQL做字符集隐式转换后的值。另外,也可以使用函数CHARSET()、COLLATION()、和COERCIBILITY()来定位各种字符集相关的错误。\n还可以使用前缀和COLLATE子句来指定字符串的字符集或者校对字符集。例如,下面的示例中使用了前缀(由下画线开始)来指定utf8字符集,还使用了COLLATE子句指定了使用二进制校对规则:\n一些特殊情况 # MySQL的字符集行为中还是有一些隐藏的“惊喜”的。下面列举了一些需要注意的地方:\n诡异的character_set_database设置\ncharacter_set_database设置的默认值和默认数据库的设置相同。当改变默认数据库的时候,这个变量也会跟着变。所以当连接到MySQL实例上又没有指定要使用的数据库时,默认值会和character_set_server相同。\nLOAD DATA INFILE\n当使用LOAD DATA INFILE的时候,数据库总是将文件中的字符按照字符集character_set_database来解析。在MySQL 5.0和更新的版本中,可以在LOAD DATA INFILE中使用子句CHARACTER SET来设定字符集,不过最好不要依赖这个设定。我们发现指定字符集最好的方式是先使用USE指定数据库,再执行SET NAMES来设定字符集,最后再加载数据。MySQL在加载数据的时候,总是以同样的字符集处理所有数据,而不管表中的列是否有不同的字符集设定。\nSELECT INTO OUTFILE\nMySQL会将SELECT INTO OUTFILE的结果不做任何转码地写入文件。目前,除了使用函数CONVERT()将所有的列都做一次转码外,还没有什么别的办法能够指定输出的字符集。\n嵌入式转义序列\nMySQL会根据character_set_client的设置来解析转义序列,即使是字符串中包含前缀或者COLLATE子句也一样。这是因为解析器在处理字符串中的转义字符时,完全不关心校对规则——对解析器来说,前缀并不是一个指令,它只是一个关键字而已。\n7.9.2 选择字符集和校对规则 # MySQL 4.1和之后的版本支持很多的字符集和校对规则,包括支持使用Unicode编码的多字节UTF-8字符集(MySQL支持UTF-8的一个三字节子集,这几乎可以包含世界上的所有字符集)。可以使用命令SHOW CHARACTERSET和SHOW COLLATION来查看MySQL支持的字符集和校对规则。\n极简原则\n在一个数据库中使用多个不同的字符集是一件很让人头疼的事情,字符集之间的不兼容问题会很难缠。有时候,一切都看起来正常,但是当某个特殊字符出现的时候,所有类型的操作都可能会无法进行(例如多表之间的关联)。你可以使用ALTER TABLE命令将对应列转成相互兼容的字符集,还可以使用编码前缀和COLLATE子句将对应的列值转成兼容的编码。\n正确的方法是,最好先为服务器(或者数据库)选择一个合理的字符集。然后根据不同的实际情况,让某些列选择合适的字符集。\n对于校对规则通常需要考虑的一个问题是,是否以大小写敏感的方式比较字符串,或者是以字符串编码的二进制值来比较大小。它们对应的校对规则的前缀分别是_cs、_ci和_bin,根据需要很容易选择。大小写敏感和二进制校对规则的不同之处在于,二进制校对规则直接使用字符的字节进行比较,而大小写敏感的校对规则在多字节字符集时,如德语,有更复杂的比较规则。\n在显式设置字符集的时候,并不是必须同时指定字符集和校对规则的名字。如果缺失了其中一个或者两个,MySQL会使用可能的默认值来进行填充。表7-2表示了MySQL如何选择字符集和校对规则。\n表7-2:MySQL如何选择字符集和校对规则 用户设置 返回结果的字符集 返回结果的校对规则 同时设置字符集和校对规则 与用户设置相同 与用户设置相同 仅设置字符集 与用户设置相同 与字符集的默认校对规则相同 仅设置校对规则 与校对规则对应的字符集相同 与用户设置相同 都未设置 使用默认值 使用默认值\n下面的命令展示了在创建数据库、表、列的时候如何显式地指定字符集和校对规则:\nCREATE DATABASE d CHARSET latin1; CREATE TABLE d.t( col1 CHAR(1), col2 CHAR(1) CHARSET utf8, col3 CHAR(1) COLLATE latin1_bin ) DEFAULT CHARSET=cp1251; 这个表最后的字符集和校对规则如下:\n7.9.3 字符集和校对规则如何影响查询 # 某些字符集和校对规则可能会需要更多的CPU操作,可能会消耗更多的内存和存储空间,甚至还会影响索引的正常使用。所以在选择字符集的时候,也有一些需要注意的地方。\n不同的字符集和校对规则之间的转换可能会带来额外的系统开销。例如,数据表sakila.film在列title上有索引,可以加速下面的ORDER BY查询:\nmysql\u0026gt; ** EXPLAIN SELECT title, release_year FROM sakila.film ORDER BY title\\Gmysql\u0026gt; EXPLAIN SELECT title, release_year FROM sakila.film ORDER BY title\\G** *************************** 1. row *************************** id: 1 select_type: SIMPLE table: film type: index possible_keys: NULL key: idx_title key_len: 767 ref: NULL rows: 953 Extra: 只有排序查询要求的字符集与服务器数据的字符集相同的时候,才能使用索引进行排序。索引根据数据列的校对规则(11)进行排序,这里使用的是utf8_general_ci。如果希望使用别的校对规则进行排序,那么MySQL就需要使用文件排序:\nmysql\u0026gt; ** EXPLAIN SELECT title, release_year** -\u0026gt; ** FROM sakila.film ORDER BY title COLLATE utf8_bin\\G** *************************** 1. row *************************** id: 1 select_type: SIMPLE table: film type: ALL possible_keys: NULL key: NULL key_len: NULL ref: NULL rows: 953 Extra: ** Using filesort** 为了能够适应各种字符集,包括客户端字符集、在查询中显式指定的字符集,MySQL会在需要的时候进行字符集转换。例如,当使用两个字符集不同的列来关联两个表的时候,MySQL会尝试转换其中一个列的字符集。这和在数据列外面封装一个函数一样,会让MySQL无法使用这个列上的索引。如果你不确定MySQL内部是否做了这种转换,可以在EXPLAIN EXTENDED后使用SHOW WARNINGS来查看MySQL是如何处理的。从输出中可以看到查询中使用的字符集,也可以看出MySQL是否做了字符集转换操作。\nUTF-8是一种多字节编码,它存储一个字符会使用变长的字节数(一到三个字节)。在MySQL内部,通常使用一个定长的空间来存储字符串,再进行相关操作,这样做的目的是希望总是保证缓存中有足够的空间来存储字符串。例如,一个编码是UTF-8的CHAR(10)需要30个字节,即使最终存储的时候没有存储任何“多字节”字符也是一样。变长的字段类型(VARCHAR TEXT)存储在磁盘上时不会有这个困扰,但当它存储在临时表中用来处理或者排序时,也总是会分配最大可能的长度。\n在多字节字符集中,一个字符不再是一个字节。所以,在MySQL中有两个函数LENGTH()和CHAR_LENGTH()来计算字符串的长度,在多字节字符集中,这两个函数的返回结果会不同。如果使用的是多字节字符集,那么确保在统计字符集的时候使用CHAR_LENGTH()。(例如需要做SUBSTRING()操作的时候)。其实,在应用程序中也同样要注意多字节字符集的这个问题。\n另一个“惊喜”可能是关于索引限制方面的。如果要索引一个UTF-8字符集的列, MySQL会假设每一个字符都是三个字节,所以最长索引前缀的限制一下缩短到原来的三分之一了:\n注意到,MySQL的索引前缀自动缩短到333个字符了:\nmysql\u0026gt; ** SHOW CREATE TABLE big_string\\G** *************************** 1. row *************************** Table: big_string Create Table: CREATE TABLE `big_string` ( `str` varchar(500) default NULL, KEY `str` (`str`(333)) ) ENGINE=MyISAM DEFAULT CHARSET=utf8 如果你不注意警告信息也没有再重新检查表的定义,可能不会注意到这里仅仅是在该列的前缀上建立了索引。这会对MySQL使用索引有一些影响,例如无法使用索引覆盖扫描。\n也有人建议,直接使用UTF-8字符集,“整个世界都清净了”。不过从性能的角度来看这不是一个好主意。根据存储的数据,很多应用无须使用UTF-8字符集,如果坚持使用UTF-8,只会消耗更多的磁盘空间。\n在考虑使用什么字符集的时候,需要根据存储的具体内存来决定。例如,存储的内容主要是英文字符,那么即使使用UTF-8也不会消耗太多的存储空间,因为英文字符在UTF-8字符集中仍然使用一个字节。但如果需要存储一些非拉丁语系的字符,如俄语、阿拉伯语,那么区别会很大。如果应用中只需要存储阿拉伯语,那么可以使用cp1256字符集,这个字符集可以用一个字节表示所有的阿拉伯语字符。如果还需要存储别的语言,那么就应该使用UTF-8了,这时相同的阿拉伯语字符会消耗更多的空间。类似地,当从某个具体的语种编码转换成UTF-8时,存储空间的使用会相应增加。如果使用的是InnoDB表,那么字符集的改变可能导致数据的大小超过可以在页内存储的临界值,需要保存在额外的外部存储区,这会导致很严重的空间浪费,还会带来很多空间碎片。\n有时候根本不需要使用任何的字符集。通常只有在做大小写无关的比较、排序、字符串操作(例如SUBSTRING()的时候才需要使用字符集。如果你的数据库不关心字符集,那么可以直接将所有的东西存储到二进制列中,包括UTF-8编码数据也可以存储在其中。这么做,可能还需要一个列记录字符的编码集。虽然很多人一直都是这么用的,但还是有不少事项需要注意。这会导致很多难以排查的错误,例如,忘记了多个字节才是一个字符时,还继续使用SUBSTRING()和LENGTH()做字符串操作,就会出错。如果可能,我们建议尽量不要这样做。\n7.10 全文索引 # 通过数值比较、范围过滤等就可以完成绝大多数我们需要的查询了。但是,如果你希望通过关键字的匹配来进行查询过滤,那么就需要基于相似度的查询,而不是原来的精确数值比较。全文索引就是为这种场景设计的。\n全文索引有着自己独特的语法。没有索引也可以工作,如果有索引效率会更高。用于全文搜索的索引有着独特的结构,帮助这类查询找到匹配某些关键字的记录。\n你可能没有在意过全文索引,不过至少应该对一种全文索引技术比较熟悉:互联网搜索引擎。虽然这类搜索引擎的索引对象是超大量的数据,并且通常其背后都不是关系型数据库,不过全文索引的基本原理都是一样的。\n全文索引可以支持各种字符内容的搜索(包括CHAR、VARCHAR和TEXT类型),也支持自然语言搜索和布尔搜索。在MySQL中全文索引有很多的限制(12),其实现也很复杂,但是因为它是MySQL内置的功能,而且满足很多基本的搜索需求,所以它的应用仍然非常广泛。本章我们将介绍如何使用全文索引,以及如何为应用设计更高性能的全文索引。在本书编写时,在标准的MySQL中,只有MyISAM引擎支持全文索引。不过在还没有正式发布的MySQL 5.6中,InnoDB已经实验性质地支持全文索引了。除此,还有第三方的存储引擎,如Groonga,也支持全文索引。\n事实上,MyISAM对全文索引的支持有很多的限制,例如表级别锁对性能的影响、数据文件的崩溃、崩溃后的恢复等,这使得MyISAM的全文索引对于很多应用场景并不合适。所以,多数情况下我们建议使用别的解决方案,例如Sphinx、Lucene、Solr、Groonga、Xapian或者Senna,再或者可以等MySQL 5.6版本正式发布后,直接使用InnoDB的全文索引。如果MyISAM的全文索引确实能满足应用的需求,那么可以继续阅读本节。\nMyISAM的全文索引作用对象是一个“全文集合”,这可能是某个数据表的一列,也可能是多个列。具体的,对数据表的某一条记录,MySQL会将需要索引的列全部拼接成一个字符串,然后进行索引。\nMyISAM的全文索引是一类特殊的B-Tree索引,共有两层。第一层是所有关键字,然后对于每一个关键字的第二层,包含的是一组相关的“文档指针”。全文索引不会索引文档对象中的所有词语,它会根据如下规则过滤一些词语:\n停用词列表中的词都不会被索引。默认的停用词根据通用英语的使用来设置,可以使用参数ft_stopword_file指定一组外部文件来使用自定义的停用词。 对于长度大于ft_min_word_len的词语和长度小于ft_max_word_len的词语,都不会被索引。 全文索引并不会存储关键字具体匹配在哪一列,如果需要根据不同的列来进行组合查询,那么不需要针对每一列来建立多个这类索引。\n这也意味着不能在MATCH AGAINST子句中指定哪个列的相关性更重要。通常构建一个网站的搜索引擎是需要这样的功能,例如,你可能希望优先搜索出那些在标题中出现过的文档对象。如果需要这样的功能,则需要编写更复杂的查询语句。(后面将会为大家展示如何实现。)\n7.10.1 自然语言的全文索引 # 自然语言搜索引擎将计算每一个文档对象和查询的相关度。这里,相关度是基于匹配的关键词个数,以及关键词在文档中出现的次数。在整个索引中出现次数越少的词语,匹配时的相关度就越高。相反,非常常见的单词将不会搜索,即使不在停用词列表中出现,如果一个词语在超过50%的记录中都出现了,那么自然语言搜索将不会搜索这类词语。(13)\n全文索引的语法和普通查询略有不同。可以根据WHERE子句中的MATCH AGAINST来区分查询是否使用全文索引。我们来看一个示例。在标准的数据库Sakila中,数据表film_text在字段title和description上建立了全文索引:\n下面是一个使用自然语言搜索的查询:\nMySQL将搜索词语分成两个独立的关键词进行搜索,搜索在title和description字段组成的全文索引上进行。注意,只有一条记录同时包含全部的两个关键词,有三个查询结果只包含关键字“casualties”(这是整个表中仅有的三条包含该关键词的记录),这三个结果都在结果列表的前面。这是因为查询结果是根据与关键词的相似度来进行排序的。\n和普通查询不同,这类查询自动按照相似度进行排序。在使用全文索引进行排序的时候,MySQL无法再使用索引排序。所以如果不想使用文件排序的话,那么就不要在查询中使用ORDER BY子句。\n从上面的示例可以看到,函数MATCH()将返回关键词匹配的相关度,是一个浮点数字。你可以根据相关度进行匹配,或者将此直接展现给用户。在一个查询中使用两次MATCH()函数并不会有额外的消耗,MySQL会自动识别并只进行一次搜索。不过,如果你将MATCH()函数放到ORDER BY子句中,MySQL将会使用文件排序。\n在MATCH()函数中指定的列必须和在全文索引中指定的列完全相同,否则就无法使用全文索引。这是因为全文索引不会记录关键字是来自哪一列的。\n这也意味着无法使用全文索引来查询某个关键字是否在某一列中存在。这里介绍一个绕过该问题的办法:根据关键词在多个不同列的全文索引上的相关度来算出排名值,然后依此来排序。我们可以在某一列上加上如下索引:\nmysql\u0026gt; ** ALTER TABLE film_text ADD FULLTEXT KEY(title) ;** 这样,我们可以将title匹配乘以2来提高它的相似度的权重:\n因为上面的查询需要做文件排序,所以这并不是一个高效的做法。\n7.10.2 布尔全文索引 # 在布尔搜索中,用户可以在查询中自定义某个被搜索的词语的相关性。布尔搜索通过停用词列表过滤掉那些“噪声”词,除此之外,布尔搜索还要求搜索关键词长度必须大于ft_min_word_len,同时小于ft_max_word_len(14)。搜索返回的结果是未经排序的。\n当编写一个布尔搜索查询时,可以通过一些前缀修饰符来定制搜索。表7-3列出了最常用的修饰符。\n表7-3:布尔全文索引通用修饰符 Example Meaning dinosaur 包含“dinosaur”的行rank值更高 ~dinosaur 包含“dinosaur”的行rank值更低 +dinosaur 行记录必须包含“dinosaur” -dinosaur 行记录不可以包含“dinosaur” dino* 包含以“dino”开头的单词的行rank值更高\n还可以使用其他的操作,例如使用括号分组。基于此,就可以构造出一些复杂的搜索查询。\n还是继续用sakila.film_text来举例,现在我们需要搜索既包含词“factory”又包含“casualties”的记录。在前面,我们已经使用自然语言搜索查询实现找到这两个词中的任何一个的SQL写法。使用布尔搜索查询,我们可以指定返回结果必须同时包含“factory”和“casualties”:\n查询中还可以使用括号进行“短语搜索”,让返回结果精确匹配指定的短语:\n短语搜索的速度会比较慢。只使用全文索引是无法判断是否精确匹配短语的,通常还需要查询原文确定记录中是否包含完整的短语。由于需要进行回表过滤,所以速度会很慢。\n要完成上面的查询,MySQL需要先从索引中找出所有同时包含“spirited”和“casualties”的索引条目,然后取出这些记录再判断是否是精确匹配短语。因为这个操作会先从索引中过滤出一些记录,所以通常认为这样做的速度是很快的——比LIKE操作要快很多。事实上,这样做的确很快,但是搜索的关键词不能是太常见的词语。如果搜索的关键词太常见,因为前一步的过滤会返回太多的记录需要判断,因此LIKE操作反而更快。这种情况下LIKE操作是完全的顺序读,相比索引返回值的随机读,会快很多。\n只有MyISAM引擎才能使用布尔全文索引,但并不是一定要有全文索引才能使用布尔全文搜索。当没有全文索引的时候,MySQL就通过全表扫描来实现。所以,你甚至还可以在多表上使用布尔全文索引,例如在一个关联结果上进行。只不过,因为是全表扫描,速度可能会很慢。\n7.10.3 MySQL 5.1中全文索引的变化 # 在MySQL 5.1中引入了一些和全文索引相关的改进,包括一些性能上的提升和新增插件式的解析,通过此用户可以自己定制增强搜索功能。例如,插件可以改变索引文本的方式。可以用更灵活的方式进行分词(例如,可以指定C++作为一个单独的词语)、预处理、可以对不同的文档类型进行索引(如PDF),还可以做一些自定义的词干规则。插件还可以直接影响全文搜索的工作方式——例如,直接使用词干进行搜索。\n7.10.4 全文索引的限制和替代方案 # MySQL的全文索引实现有很多的设计本身带来的限制。在某些场景下这些限制是致命的,不过也有很多办法绕过这些限制。\n例如,MySQL全文索引中只有一种判断相关性的方法:词频。索引也不会记录索引词在字符串中的位置,所以位置也就无法用在相关性上。虽然大多数情况下,尤其是数据量很小的时候,这些限制都不会影响使用,但也可能不是你所想要的。而且MySQL的全文索引也没有提供其他可选的相关性排序算法。(它无法存储基于相对位置的相关性排序数据。)\n数据量的大小也是一个问题。MySQL的全文索引只有全部在内存中的时候,性能才非常好。如果内存无法装载全部索引,那么搜索速度可能会非常慢。当你使用精确短语搜索时,想要好的性能,数据和索引都需要在内存中。相比其他的索引类型,当INSERT、UPDATE和DELETE操作进行时,全文索引的操作代价都很大:\n修改一段文本中的100个单词,需要100次索引操作,而不是一次。 一般来说列长度并不会太影响其他的索引类型,但是如果是全文索引,三个单词的文本和10000个单词的文本,性能可能会相差几个数量级。 全文索引会有更多的碎片,可能需要做更多的OPTIMIZE TABLE操作。 全文索引还会影响查询优化器的工作。索引选择、WHERE子句、ORDER BY都有可能不是按照你所预想的方式来工作:\n如果查询中使用了MATCH AGAINST子句,而对应列上又有可用的全文索引,那么MySQL就一定会使用这个全文索引。这时,即使有其他的索引可以使用,MySQL也不会去比较到底哪个索引的性能更好。所以,即使这时有更合适的索引可以使用, MySQL仍然会置之不理。 全文索引只能用作全文搜索匹配。任何其他操作,如WHERE条件比较,都必须在MySQL完成全文搜索返回记录后才能进行。这和其他普通索引不同,例如,在处理WHERE条件时,MySQL可以使用普通索引一次判断多个比较表达式。 全文索引不存储索引列的实际值。也就不可能用作索引覆盖扫描。 除了相关性排序,全文索引不能用作其他的排序。如果查询需要做相关性以外的排序操作,都需要使用文件排序。 让我们看看这些限制如何影响查询语句。来看一个例子,假设有一百万个文档记录,在文档的作者author字段上有一个普通的索引,在文档内容字段content上有全文索引。现在我们要搜索作者是123,文档中又包含特定词语的文档。很多人可能会按照下面的方式来写查询语句:\n... WHERE MATCH(content) AGAINST ('High Performance MySQL') AND author = 123; 而实际上,这样做的效率非常低。因为这里使用了MATCH AGAINST,而且恰好上面有全文索引,所以MySQL优先选择使用全文索引,即先搜索所有的文档,查找是否有包含关键词的文档,然后返回记录看看作者是否是123。所以这里也就没有使用author字段上的索引。\n一个替代方案是将author列包含到全文索引中。可以在author列的值前面附上一个不常见的前缀,然后将这个带前缀的值存放到一个单独的filters列中,并单独维护该列(也许可以使用触发器来做维护工作)。\n这样就可以扩展全文索引,使其包含filters列,上面的查询就可以改写为:\n... WHERE MATCH(content, filters) AGAINST ('High Performance MySQL +author_id_123' IN BOOLEAN MODE); 这个案例中,如果author列的选择性非常高,那么MySQL能够根据作者信息很快地将需要过滤的文档记录限制在一个很小的范围内,这个查询的效率也就会非常好。如果author列的选择性很低,那么这个替代方案的效率会比前面那个更糟,所以使用的时候要谨慎。\n全文索引有时候还可以实现一些简单的“边框”搜索。例如,希望搜索某个坐标范围时,将坐标按某种方式转换成文本再进行全文索引。假设某条记录的坐标为X=123和Y=456。可以按照这样的方式交错存储坐标:XY142536,然后对此进行全文索引。这时,希望查询某矩形——X取值100至199,Y取值400至499——范围时,可以在查询直接搜索“+XY14*”。这比使用WHERE条件过滤的效率要高很多。\n全文索引的另一个常用技巧是缓存全文索引返回的主键值,这在分页显示的时候经常使用。当应用程序真的需要输出结果时,才通过主键值将所有需要的数据返回。这个查询就可以自由地使用其他索引、或者自由地关联其他表。\n虽然只有MyISAM表支持全文索引,但是如果仍然希望使用InnoDB或其他引擎,可以将原表复制到一个备库,再将备库上的表改成MyISAM并建上相应的全文索引。如果不希望在另一个服务器上完成查询,还可以对表进行垂直拆分,将需要索引的列放到一个单独的MyISAM表中。\n将需要索引的列额外地冗余在另一个MyISAM表中也是一个办法。在测试库中sakila.film_text就是使用这个策略,这里使用触发器来维护这个表的数据。最后,你还可以使用一个包含内置全文索引的引擎,如Lucene或者Sphinx。更多关于Shpinx的内容请参考附录F。\n因为使用全文索引的时候,通常会返回大量结果并产生大量随机I/O,如果和GROUP BY一起使用的话,还需要通过临时表或者文件排序进行分组,性能会非常非常糟糕。这类查询通常只是希望查询分组后的前几名结果,所以一个有效的优化方法是对结果集进行抽样而不是精确计算。例如,仅查询前面的1000条记录,进行分组并返回前几名的结果。\n7.10.5 全文索引的配置和优化 # 全文索引的日常维护通常能够大大提升性能。“双B-Tree”的特殊结构、在某些文档中比其他文档要包含多得多的关键字,这都使得全文索引比起普通索引有更多的碎片问题。所以需要经常使用OPTIMIZE TABLE来减少碎片。如果应用是I/O密集型的,那么定期地进行全文索引重建可以让性能提升很多。\n如果希望全文索引能够高效地工作,还需要保证索引缓存足够大,从而保证所有的全文索引都能够缓存在内存中。通常,可以为全文索引设置单独的键缓存(Key cache),保证不会被其他的索引缓存挤出内存。键缓存的配置和使用可以参考第8章。\n提供一个好的停用词表也很重要。默认的停用词表对常用英语来说可能还不错,但是如果是其他语言或者某些专业文档就不合适了,例如技术文档。例如,若要索引一批MySQL相关的文档,那么最好将mysql放入停用词表,因为在这类文档中,这个词会出现得非常频繁。\n忽略一些太短的单词也可以提升全文索引的效率。索引单词的最小长度可以通过参数ft_min_word_len配置。修改该参数可以过滤更多的单词,让查询速度更快,但是也会降低精确度。还需要注意一些特殊的场景,有时确实需要索引某些非常短的词语。例如,对一个电子消费品文档进行索引,除非我们允许对很短的单词进行索引,否则搜索“cd player”可能会返回大量的结果。因为单词“cd”比默认允许的最短长度4还要小,所以这里只会对“Player”进行搜索,而通常搜索“cd player”的客户,其实对MP3或者DVD播放器并不感兴趣。\n停用词表和允许最小词长都可以通过减少索引词语来提升全文索引的效率,但是同时也会降低搜索的精确度。这需要根据实际的应用场景找到合适的平衡点。如果你希望同时获得好的性能和好的搜索质量,那么需要自己定制这些参数。一个好的办法是通过日志系统来研究用户的搜索行为,看看一些异常的查询,包括没有结果返回的查询或者返回过多结果的用户查询。通过这些用户行为和被搜索的内容来判断应该如何调整索引策略。\n需要注意,当调整“允许最小词长”后,需要通过OPTIMIZE TABLE来重建索引才会生效。另一个参数ft_max_word_len和该参数行为类似,它限制了允许索引的最大词长。\n当向一个有全文索引的表中导入大量数据的时候,最好先通过命令DISABLE KEYS来禁用全文索引,然后在导入结束后使用ENABLE KYES来建立全文索引。因为全文索引的更新是一个消耗很大的操作,所以上面的细节会帮你节省大量时间。另外,这样还顺便为全文索引做了一次碎片整理工作。\n如果数据集特别大,则需要对数据进行手动分区,然后将数据分布到不同的节点,再做并行的搜索。这是一个复杂的工作,最好通过一些外部的搜索引擎来实现,如Lucene或者Sphinx。我们的经验显示这样做性能会有指数级的提升。\n7.11 分布式(XA)事务 # 存储引擎的事务特性能够保证在存储引擎级别实现ACID(参考前面介绍的“事务”),而分布式事务则让存储引擎级别的ACID可以扩展到数据库层面,甚至可以扩展到多个数据库之间——这需要通过两阶段提交实现。MySQL 5.0和更新版本的数据库已经开始支持XA事务了。\nXA事务中需要有一个事务协调器来保证所有的事务参与者都完成了准备工作(第一阶段)。如果协调器收到所有的参与者都准备好的消息,就会告诉所有的事务可以提交了,这是第二阶段。MySQL在这个XA事务过程中扮演一个参与者的角色,而不是协调者。\n实际上,在MySQL中有两种XA事务。一方面,MySQL可以参与到外部的分布式事务中;另一方面,还可以通过XA事务来协调存储引擎和二进制日志。\n7.11.1 内部XA事务 # MySQL本身的插件式架构导致在其内部需要使用XA事务。MySQL中各个存储引擎是完全独立的,彼此不知道对方的存在,所以一个跨存储引擎的事务就需要一个外部的协调者。如果不使用XA协议,例如,跨存储引擎的事务提交就只是顺序地要求每个存储引擎各自提交。如果在某个存储提交过程中发生系统崩溃,就会破坏事务的特性(要么就全部提交,要么就不做任何操作)。\n如果将MySQL记录的二进制日志操作看作一个独立的“存储引擎”,就不难理解为什么即使是一个存储引擎参与的事务仍然需要XA事务了。在存储引擎提交的同时,需要将“提交”的信息写入二进制日志,这就是一个分布式事务,只不过二进制日志的参与者是MySQL本身。\nXA事务为MySQL带来巨大的性能下降。从MySQL 5.0开始,它破坏了MySQL内部的“批量提交”(一种通过单磁盘I/O操作完成多个事务提交的技术),使得MySQL不得不进行多次额外的fsync()调用(15)。具体的,一个事务如果开启了二进制日志,则不仅需要对二进制日志进行持久化操作,InnoDB事务日志还需要两次日志持久化操作。换句话说,如果希望有二进制日志安全的事务实现,则至少需要做三次fsync()操作。唯一避免这个问题的办法就是关闭二进制日志,并将innodb_support_xa设置为0(16)。\n但这样的设置是非常不安全的,而且这会导致MySQL复制也没法正常工作。复制需要二进制日志和XA事务的支持,另外——如果希望数据尽可能安全——最好还要将sync_binlog设置成1,这时存储引擎和二进制日志才是真正同步的。(否则,XA事务支持就没有意义了,因为事务提交了二进制日志却可能没有“提交”到磁盘。)这也是为什么我们强烈建议使用带电池保护的RAID卡写缓存:这个缓存可以大大加快fsync()操作的效率。\n下一章我们将更进一步地介绍如何配置事务日志和二进制日志。\n7.11.2 外部XA事务 # MySQL能够作为参与者完成一个外部的分布式事务。但它对XA协议支持并不完整,例如,XA协议要求在一个事务中的多个连接可以做关联,但目前的MySQL版本还不能支持。\n因为通信延迟和参与者本身可能失败,所以外部XA事务比内部消耗会更大。如果在广域网中使用XA事务,通常会因为不可预测的网络性能导致事务失败。如果有太多不可控因素,例如,不稳定的网络通信或者用户长时间地等待而不提交,则最好避免使用XA事务。任何可能让事务提交发生延迟的操作代价都很大,因为它影响的不仅仅是自己本身,它还会让所有参与者都在等待。\n通常,还可以使用别的方式实现高性能的分布式事务。例如,可以在本地写入数据,并将其放入队列,然后在一个更小、更快的事务中自动分发。还可以使用MySQL本身的复制机制来发送数据。我们看到很多应用程序都可以完全避免使用分布式事务。\n也就是说,XA事务是一种在多个服务器之间同步数据的方法。如果由于某些原因不能使用MySQL本身的复制,或者性能并不是瓶颈的时候,可以尝试使用。\n7.12 查询缓存 # 很多数据库产品都能够缓存查询的执行计划,对于相同类型的SQL就可以跳过SQL解析和执行计划生成阶段。MySQL在某些场景下也可以实现,但是MySQL还有另一种不同的缓存类型:缓存完整的SELECT查询结果,也就是“查询缓存”。本节将详细介绍这类缓存。\nMySQL查询缓存保存查询返回的完整结果。当查询命中该缓存,MySQL会立刻返回结果,跳过了解析、优化和执行阶段。\n查询缓存系统会跟踪查询中涉及的每个表,如果这些表发生变化,那么和这个表相关的所有的缓存数据都将失效。这种机制效率看起来比较低,因为数据表变化时很有可能对应的查询结果并没有变更,但是这种简单实现代价很小,而这点对于一个非常繁忙的系统来说非常重要。\n查询缓存对应用程序是完全透明的。应用程序无须关心MySQL是通过查询缓存返回的结果还是实际执行返回的结果。事实上,这两种方式执行的结果是完全相同的。换句话说,查询缓存无须使用任何语法。无论是MySQL开启或关闭查询缓存,对应用程序都是透明的(17)。\n随着现在的通用服务器越来越强大,查询缓存被发现是一个影响服务器扩展性的因素。它可能成为整个服务器的资源竞争单点,在多核服务器上还可能导致服务器僵死。后面我们将详细介绍如何配合查询缓存,但是很多时候我们还是认为应该默认关闭查询缓存,如果查询缓存作用很大的话,那就配置一个很小的查询缓存空间(如几十兆)。后面我们将解释如何判断在你的系统压力下打开查询缓存是否有好处。\n7.12.1 MySQL如何判断缓存命中 # MySQL判断缓存命中的方法很简单:缓存存放在一个引用表中,通过一个哈希值引用,这个哈希值包括了如下因素,即查询本身、当前要查询的数据库、客户端协议的版本等一些其他可能会影响返回结果的信息。\n当判断缓存是否命中时,MySQL不会解析、“正规化”或者参数化查询语句,而是直接使用SQL语句和客户端发送过来的其他原始信息。任何字符上的不同,例如空格、注释——任何的不同——都会导致缓存的不命中。(18)所以在编写SQL语句的时候,需要特别注意这点。通常使用统一的编码规则是一个好的习惯,在这里这个好习惯会让你的系统运行得更快。\n当查询语句中有一些不确定的数据时,则不会被缓存。例如包含函数NOW()或者CURRENT_DATE()的查询不会被缓存。类似的,包含CURRENT_USER或者CONNECTION_ID()的查询语句因为会根据不同的用户返回不同的结果,所以也不会被缓存。事实上,如果查询中包含任何用户自定义函数、存储函数、用户变量、临时表、mysql库中的系统表,或者任何包含列级别权限的表,都不会被缓存。(如果想知道所有情况,建议阅读MySQL官方手册。)\n我们常听到:“如果查询中包含一个不确定的函数,MySQL则不会检查查询缓存”。这个说法是不正确的。因为在检查查询缓存的时候,还没有解析SQL语句,所以MySQL并不知道查询语句中是否包含这类函数。在检查查询缓存之前,MySQL只做一件事情,就是通过一个大小写不敏感的检查看看SQL语句是不是以SEL开头。\n准确的说法应该是:“如果查询语句中包含任何的不确定函数,那么在查询缓存中是不可能找到缓存结果的”。因为即使之前刚刚执行了这样的查询,结果也不会放在查询缓存中。MySQL在任何时候只要发现不能被缓存的部分,就会禁止这个查询被缓存。\n所以,如果希望换成一个带日期的查询,那么最好将日期提前计算好,而不要直接使用函数。例如:\n... DATE_SUB(CURRENT_DATE, INTERVAL 1 DAY) -- Not cacheable! ... DATE_SUB('2007-07-14', INTERVAL 1 DAY) -- Cacheable 因为查询缓存是在完整的SELECT语句基础上的,而且只是在刚收到SQL语句的时候才检查,所以子查询和存储过程都没办法使用查询缓存。在MySQL 5.1之前的版本中,绑定变量也无法使用查询缓存。\nMySQL的查询缓存在很多时候可以提升查询性能,在使用的时候,有一些问题需要特别注意。首先,打开查询缓存对读和写操作都会带来额外的消耗:\n读查询在开始之前必须先检查是否命中缓存。 如果这个读查询可以被缓存,那么当完成执行后,MySQL若发现查询缓存中没有这个查询,会将其结果存入查询缓存,这会带来额外的系统消耗。 这对写操作也会有影响,因为当向某个表写入数据的时候,MySQL必须将对应表的所有缓存都设置失效。如果查询缓存非常大或者碎片很多,这个操作就可能会带来很大系统消耗(设置了很多的内存给查询缓存用的时候)。 虽然如此,查询缓存仍然可能给系统带来性能提升。但是,如上所述,这些额外消耗也可能不断增加,再加上对查询缓存操作是一个加锁排他操作,这个消耗可能不容小觑。\n对InnoDB用户来说,事务的一些特性会限制查询缓存的使用。当一个语句在事务中修改了某个表,MySQL会将这个表的对应的查询缓存都设置失效,而事实上,InnoDB的多版本特性会暂时将这个修改对其他事务屏蔽。在这个事务提交之前,这个表的相关查询是无法被缓存的,所以所有在这个表上面的查询——内部或外部的事务——都只能在该事务提交后才被缓存。因此,长时间运行的事务,会大大降低查询缓存的命中率。\n如果查询缓存使用了很大量的内存,缓存失效操作就可能成为一个非常严重的问题瓶颈。如果缓存中存放了大量的查询结果,那么缓存失效操作时整个系统都可能会僵死一会儿。因为这个操作是靠一个全局锁操作保护的,所有需要做该操作的查询都要等待这个锁,而且无论是检测是否命中缓存、还是缓存失效检测都需要等待这个全局锁。第3章中有一个真实的案例,为大家展示查询缓存过大时带来的系统消耗。\n7.12.2 查询缓存如何使用内存 # 查询缓存是完全存储在内存中的,所以在配置和使用它之前,我们需要先了解它是如何使用内存的。除了查询结果之外,需要缓存的还有很多别的维护相关的数据。这和文件系统有些类似:需要一些内存专门用来确定哪些内存目前是可用的、哪些是已经用掉的、哪些用来存储数据表和查询结果之前的映射、哪些用来存储查询字符串和查询结果。\n这些基本的管理维护数据结构大概需要40KB的内存资源,除此之外,MySQL用于查询缓存的内存被分成一个个的数据块,数据块是变长的。每一个数据块中,存储了自己的类型、大小和存储的数据本身,还外加指向前一个和后一个数据块的指针。数据块的类型有:存储查询结果、存储查询和数据表的映射、存储查询文本,等等。不同的存储块,在内存使用上并没有什么不同,从用户角度来看无须区分它们。\n当服务器启动的时候,它先初始化查询缓存需要的内存。这个内存池初始是一个完整的空闲块。这个空闲块的大小就是你所配置的查询缓存大小再减去用于维护元数据的数据结构所消耗的空间。\n当有查询结果需要缓存的时候,MySQL先从大的空间块中申请一个数据块用于存储结果。这个数据块需要大于参数query_cache_min_res_unit的配置,即使查询结果远远小于此,仍需要至少申请query_cache_min_res_unit空间。因为需要在查询开始返回结果的时候就分配空间,而此时是无法预知查询结果到底多大的,所以MySQL无法为每一个查询结果精确分配大小恰好匹配的缓存空间。\n因为需要先锁住空间块,然后找到合适大小数据块,所以相对来说,分配内存块是一个非常慢的操作。MySQL尽量避免这个操作的次数。当需要缓存一个查询结果的时候,它先选择一个尽可能小的内存块(也可能选择较大的,这里将不介绍细节),然后将结果存入其中。如果数据块全部用完,但仍有剩余数据需要存储,那么MySQL会申请一块新数据块——仍然是尽可能小的数据块——继续存储结果数据。当查询完成时,如果申请的内存空间还有剩余,MySQL会将其释放,并放入空闲内存部分。图7-3展示了这个过程(19)。\n图7-3:查询缓存如何分配内存来存储结果数据\n我们上面说的“分配内存块”,并不是指通过函数malloc()向操作系统申请内存,这个操作只在初次创建查询缓存的时候执行一次。这里“分配内存块”是指在空闲块列表中找到一个合适的内存块,或者从正在使用的、待淘汰的内存块中回收再使用。也就是说,这里MySQL自己管理一大块内存,而不依赖操作系统的内存管理。\n至此,一切都看起来很简单。不过实际情况比图7-3要更复杂。例如,我们假设平均查询结果非常小,服务器在并发地向不同的两个连接返回结果,返回完结果后MySQL回收剩余数据块空间时会发现,回收的数据块小于query_cache_min_res_unit,所以不能够直接在后续的内存块分配中使用。如果考虑到这种情况,数据块的分配就更复杂些,如图7-4所示。\n图7-4:查询缓存中存储查询结果后剩余的碎片\n在收缩第一个查询结果使用的缓存空间时,就会在第二个查询结果之间留下一个“空隙”——一个非常小的空闲空间,因为小于query_cache_min_res_unit而不能再次被查询缓存使用。这类“空隙”我们称为“碎片”,这在内存管理、文件系统管理上都是经典问题。有很多种情况都会导致碎片,例如缓存失效时,可能导致留下太小的数据块无法在后续缓存中使用。\n7.12.3 什么情况下查询缓存能发挥作用 # 并不是什么情况下查询缓存都会提高系统性能的。缓存和失效都会带来额外的消耗,所以只有当缓存带来的资源节约大于其本身的资源消耗时才会给系统带来性能提升。这跟具体的服务器压力模型有关。\n理论上,可以通过观察打开或者关闭查询缓存时候的系统效率来决定是否需要开启查询缓存。关闭查询缓存时,每个查询都需要完整的执行,每一次写操作执行完成后立刻返回;打开查询缓存时,每次读请求先检查缓存是否命中,如果命中则立刻返回,否则就完整地执行查询,每次写操作则需要检查查询缓存中是否有需要失效的缓存,然后再返回。这个过程还比较简单明了,但是要评估打开查询缓存是否能够带来性能提升却并不容易。还有一些外部的因素需要考虑,例如,查询缓存可以降低查询执行的时间,但是却不能减少查询结果传输的网络消耗,如果这个消耗是系统的主要瓶颈,那么查询缓存的作用也很小。\n因为MySQL在SHOW STATUS中只能提供一个全局的性能指标,所以很难根据此来判断查询缓存是否能够提升性能(20)。很多时候,全局平均不能反映实际情况。例如,打开查询缓存可以使得一个很慢的查询变得非常快,但是也会让其他查询稍微慢一点点。有时候如果能够让某些关键的查询速度更快,稍微降低一下其他查询的速度是值得的。不过,这种情况我们推荐使用SQL_CACHE来优化对查询缓存的使用。\n对于那些需要消耗大量资源的查询通常都是非常适合缓存的。例如一些汇总计算查询,具体的如COUNT()等。总地来说,对于复杂的SELECT语句都可以使用查询缓存,例如多表JOIN后还需要做排序和分页,这类查询每次执行消耗都很大,但是返回的结果集却很小,非常适合查询缓存。不过需要注意的是,涉及的表上UPDATE、DELETE和INSERT操作相比SELECT来说要非常少才行。\n一个判断查询缓存是否有效的直接数据是命中率,就是使用查询缓存返回结果占总查询的比率。当MySQL接收到一个SELECT查询的时候,要么增加Qcache_hits的值,要么增加Com_select的值。所以查询缓存命中率可以由如下公式计算:Qcache_hits/(Qcache_hits+Com_select)。\n不过,查询缓存命中率是一个很难判断的数值。命中率多大才是好的命中率?具体情况要具体分析。只要查询缓存带来的效率提升大于查询缓存带来的额外消耗,即使30%命中率对系统性能提升也有很大好处。另外,缓存了哪些查询也很重要,例如,被缓存的查询本身消耗非常巨大,那么即使缓存命中率非常低,也仍然会对系统性能提升有好处。所以,没有一个简单的规则可以判断查询缓存是否对系统有好处。\n任何SELECT语句没有从查询缓存中返回都称为“缓存未命中”。缓存未命中可能有如下几种原因:\n查询语句无法被缓存,可能是因为查询中包含一个不确定的函数(如CURRENT_DATA),或者查询结果太大而无法缓存。这都会导致状态值Qcache_not_cached增加。 MySQL从未处理这个查询,所以结果也从不曾被缓存过。 还有一种情况是虽然之前缓存了查询结果,但是由于查询缓存的内存用完了,MySQL需要将某些缓存“逐出”,或者由于数据表被修改导致缓存失效。(后续会详细介绍缓存失效。) 如果你的服务器上有大量缓存未命中,但是实际上绝大数查询都被缓存了,那么一定是有如下情况发生:\n查询缓存还没有完成预热。也就是说,MySQL还没有机会将查询结果都缓存起来。 查询语句之前从未执行过。如果你的应用程序不会重复执行一条查询语句,那么即使完成预热仍然会有很多缓存未命中。 缓存失效操作太多了。 缓存碎片、内存不足、数据修改都会造成缓存失效。如果配置了足够的缓存空间,而且query_cache_min_res_unit设置也合理的话,那么缓存失效应该主要是数据修改导致的。可以通过参数Com_*来查看数据修改的情况(包括Com_update,Com_delete,等等),还可以通过Qcache_lowmem_prunes来查看有多少次失效是由于内存不足导致的。\n在考虑缓存命中率的同时,通常还需要考虑缓存失效带来的额外消耗。一个极端的办法是,对某一个表先做一次只有查询的测试,并且所有的查询都命中缓存,而另一个相同的表则只做修改操作。这时,查询缓存的命中率就是100%。但因为会给更新操作带来额外的消耗,所以查询缓存并不一定会带来总体效率的提升。这里,所有的更新语句都会做一次缓存失效检查,而检查的结果都是相同的,这会给系统带来额外的资源浪费。所以,如果你只是观察查询缓存的命中率的话,可能完全不会发现这样的问题。\n在MySQL中如果更新操作和带缓存的读操作混合,那么查询缓存带来的好处通常很难衡量。更新操作会不断地使得缓存失效,而同时每次查询还会向缓存中再写入新的数据。所以只有当后续的查询能够在缓存失效前使用缓存才会有效地利用查询缓存。\n如果缓存的结果在失效前没有被任何其他的SELECT语句使用,那么这次缓存操作就是浪费时间和内存。我们可以通过查看Com_select和Qcache_inserts的相对值来看看是否一直有这种情况发生。如果每次查询操作都是缓存未命中,然后需要将查询结果放到缓存中,那么Qcache_inserts的大小应该和Com_select相当。所以在缓存完成预热后,我们总希望看到Qcache_inserts远远小于Com_select。不过由于缓存和服务器内部的复杂和多样性,仍然很难说,这个比率是多少才是一个合适的值。\n所以,上面的“命中率”和“INSERTS和SELECT比率”都无法直观地反应查询缓存的效率。那么还有什么直观的办法能够反映查询缓存是否对系统有好处?这里推荐查看另一个指标:“命中和写入”的比率,即Qcache_hits和Qcache_inserts的比值。根据经验来看,当这个比值大于3:1时通常查询缓存是有效的,不过这个比率最好能够达到10:1。如果你的应用没有达到这个比率,那么就可以考虑禁用查询缓存了,除非你能够通过精确的计算得知:命中带来的性能提升大于缓存失效的消耗,并且查询缓存并没有成为系统的瓶颈。\n每一个应用程序都会有一个“最大缓存空间”,甚至对一些纯读的应用来说也一样。最大缓存空间是能够缓存所有可能查询结果的缓存空间总和。理论上,对多数应用来说,这个数值都会非常大。而实际上,由于缓存失效的原因,大多数应用最后使用的缓存空间都比预想的要小。即使你配置了足够大的缓存空间,由于不断地失效,导致缓存空间一直都不会接近“最大缓存空间”。\n通常可以通过观察查询缓存内存的实际使用情况,来确定是否需要缩小或者扩大查询缓存。如果查询缓存空间长时间都有剩余,那么建议缩小;如果经常由于空间不足而导致查询缓存失效,那么则需要增大查询缓存。不过需要注意,如果查询缓存达到了几十兆这样的数量级,是有潜在危险的。(这和硬件以及系统压力大小有关)。\n另外,可能还需要和系统的其他缓存一起考虑,例如InnoDB的缓存池,或者MyISAM的索引缓存。关于这点是没法简单给出一个公式或者比率来判断的,因为真正的平衡点与应用程序有很大的关系。\n最好的判断查询缓存是否有效的办法还是通过查看某类查询时间消耗是否增大或者减少来判断。Percona Server通过扩展慢查询可以观察到一个查询是否命中缓存。如果查询缓存没有为系统节省时间,那么最好禁用它。\n7.12.4 如何配置和维护查询缓存 # 一旦理解查询缓存工作的原理,配置起来就很容易了。它也只有很少的参数可供配置,如下所示。\nquery_cache_type\n是否打开查询缓存。可以设置成OFF、ON或DEMAND。DEMAND表示只有在查询语句中明确写明SQL_CACHE的语句才放入查询缓存。这个变量可以是会话级别的也可以是全局级别的(会话级别和全局级别的概念请参考第8章)。\nquery_cache_size\n查询缓存使用的总内存空间,单位是字节。这个值必须是1 024的整数倍,否则MySQL实际分配的数据会和你指定的略有不同。\nquery_cache_min_res_unit\n在查询缓存中分配内存块时的最小单位。在前面我们已经介绍了这个参数,后面我们还将进一步讨论它。\nquery_cache_limit\nMySQL能够缓存的最大查询结果。如果查询结果大于这个值,则不会被缓存。因为查询缓存在数据生成的时候就开始尝试缓存数据,所以只有当结果全部返回后, MySQL才知道查询结果是否超出限制。\n如果超出,MySQL则增加状态值Qcache_not_cached,并将结果从查询缓存中删除。如果你事先知道有很多这样的情况发生,那么建议在查询语句中加入SQL_NO_CACHE来避免查询缓存带来的额外消耗。\nquery_cache_wlock_invalidate\n如果某个数据表被其他的连接锁住,是否仍然从查询缓存中返回结果。这个参数默认是OFF,这可能在一定程序上会改变服务器的行为,因为这使得数据库可能返回其他线程锁住的数据。将参数设置成ON,则不会从缓存中读取这类数据,但是这可能会增加锁等待。对于绝大数应用来说无须注意这个细节,所以默认设置通常是没有问题的。\n配置查询缓存通常很简单,但是如果想知道修改这些参数会带来哪些改变,则是一项很复杂的工作。后续的章节,我们将帮助你来决定怎样设置这些参数。\n减少碎片 # 没什么办法能够完全避免碎片,但是选择合适的query_cache_min_res_unit可以帮你减少由碎片导致的内存空间浪费。设置合适的值可以平衡每个数据块的大小和每次存储结果时内存块申请的次数。这个值太小,则浪费的空间更少,但是会导致更频繁的内存块申请操作;如果这个值设置得太大,那么碎片会很多。调整合适的值其实是在平衡内存浪费和CPU消耗。\n这个参数的最合适的大小和应用程序的查询结果的平均大小直接相关。可以通过内存实际消耗(query_cache_size−Qcache_free_memory)除以Qcache_queries_in_cache计算单个查询的平均缓存大小。如果你的应用程序的查询结果很不均匀,有的结果很大,有的结果很小,那么碎片和反复的内存块分配可能无法避免。如果你发现缓存一个非常大的结果并没有什么意义(通常确实是这样),那么你可以通过参数query_cache_limit限制可以缓存的最大查询结果,借此大大减少大的查询结果的缓存,最终减少内存碎片的发生。\n还可以通过参数Qcache_free_blocks来观察碎片。参数Qcache_free_blocks反映了查询缓存中空闲块的多少,在图7-4的配置中我们看到,有两个空闲块。最糟糕的情况是,任何两个存储结果的数据块之间都有一个非常小的空闲块。所以如果Qcache_free_blocks大小恰好达到Qcache_total_blocks/2,那么查询缓存就有严重的碎片问题。而如果你还有很多空闲块,而状态值Qcache_lowmem_prunes还不断地增加,则说明由于碎片导致了过早地在删除查询缓存结果。\n可以使用命令FLUSH QUERY CACHE完成碎片整理。这个命令会将所有的查询缓存重新排序,并将所有的空闲空间都聚集到查询缓存的一块区域上。不过需要注意,这个命令并不会将查询缓存清空,清空缓存由命令RESET QUERY CACHE完成。FLUSH QUERY CACHE会访问所有的查询缓存,在这期间任何其他的连接都无法访问查询缓存,从而会导致服务器僵死一段时间,使用这个命令的时候需要特别小心这点。另外,根据经验,建议保持查询缓存空间足够小,以便在维护时可以将服务器僵死控制在非常短的时间内。\n提高查询缓存的使用率 # 如果查询缓存不再有碎片问题,但你仍然发现命中率很低,还可能是查询缓存的内存空间太小导致的。如果MySQL无法为一个新的查询缓存结果的时候,则会选择删除某个老的缓存结果。\n当由于这个原因导致删除老的缓存结果时,会增加状态值Qcache_lowmem_prunes。如果这个值增加得很快,那么可能是由下面两个原因导致的:\n如果还有很多空闲块,那么碎片可能是罪魁祸首(参考前面的小节)。 如果这时没什么空闲块了,就说明在这个系统压力下,你分配的查询缓存空间不够大。你可以通过检查状态值Qcache_free_memory来查看还有多少没有使用的内存。 如果空闲块很多,碎片很少,也没有什么由于内存导致的缓存失效,但是命中率仍然很低,那么很可能说明,在你的系统压力下,查询缓存并没有什么好处。一定是什么原因导致查询缓存无法为系统服务,例如有大量的更新或者查询语句本身都不能被缓存。\n如果在观察命中率时,仍然无法确定查询缓存是否给系统带来了好处,那么可以通过禁用它,然后观察系统的性能,再重新打开它,观察性能变化,据此来判断查询缓存是否给系统带来了好处。可以通过将query_cache_size设置成0,来关闭查询缓存。(改变query_cache_type的全局值并不会影响已经打开的连接,也不会将查询缓存的内存释放给系统。)你还可以通过系统测试来验证,不过一般都很难精确地模拟实际情况。\n图7-5展示了一个用来分析和配置查询缓存的流程图。\n图7-5:如何分析和配置查询缓存\n7.12.5 InnoDB和查询缓存 # 因为InnoDB有自己的MVCC机制,所以相比其他存储引擎,InnoDB和查询缓存的交互要更加复杂。MySQL 4.0版本中,在事务处理中查询缓存是被禁用的,从4.1和更新的InnoDB版本开始,InnoDB会控制在一个事务中是否可以使用查询缓存,InnoDB会同时控制对查询缓存的读(从缓存中获取查询结果)和写操作(向查询缓存写入结果)。\n事务是否可以访问查询缓存取决于当前事务ID,以及对应的数据表上是否有锁。每一个InnoDB表的内存数据字典都保存了一个事物ID号,如果当前事务ID小于该事务ID,则无法访问查询缓存。\n如果表上有任何的锁,那么对这个表的任何查询语句都是无法被缓存的。例如,某个事务执行了SELECT FOR UPDATE语句,那么在这个锁释放之前,任何其他的事务都无法从查询缓存中读取与这个表相关的缓存结果。\n当事务提交时,InnoDB持有锁,并使用当前的一个系统事务ID更新当前表的计数器。锁一定程度上说明事务需要对表进行修改操作,当然有可能事务获得锁,却不进行任何更新操作,但是如果想更新任何表的内容,获得相应锁则是前提条件。InnoDB将每个表的计数器设置成某个事务ID,而这个事务ID就代表了当前存在的且修改了该表的最大的事务ID。\n那么下面的一些事实也就成立:\n所有大于该表计数器的事务才可以使用查询缓存。例如当前系统的事务ID是5,且事务获取了该表的某些记录的锁,然后进行事务提交操作,那么事务1至4,都不应该再读取或者向查询缓存写入任何相关的数据。 该表的计数器并不是直接更新为对该表进行加锁的事务ID,而是被更新成一个系统事务ID。所以,会发现该事务自身后续的更新操作也无法读取和修改查询缓存。 查询缓存存储、检索和失效操作都是在MySQL层面完成,InnoDB无法绕过或者延迟这个行为。但InnoDB可以在事务中显式地告诉MySQL何时应该让某个表的查询缓存都失效。在有外键限制的时候这是必须的,例如某个SQL语句有ON DELETE CASCADE,那么相关联表的查询缓存也是要一起失效的。\n原则上,在InnoDB的MVCC架构下,当某些修改不影响其他事务读取一致的数据时,是可以使用查询缓存的。但是这样实现起来会非常复杂,InnoDB做了一个简化,让所有有加锁操作的事务都不使用任何查询缓存,这个限制其实并不是必须的。\n7.12.6 通用查询缓存优化 # 库表结构的设计、查询语句、应用程序设计都可能会影响到查询缓存的效率。除了前文介绍的之外,这里还有一些要点需要注意:\n用多个小表代替一个大表对查询缓存有好处。这个设计将会使得失效策略能够在一个更合适的粒度上进行。当然,不要让这个原则过分影响你的设计,毕竟其他的一些优势可能很容易就弥补了这个问题。 批量写入时只需要做一次缓存失效,所以相比单条写入效率更好。(另外需要注意,不要同时做延迟写和批量写,否则可能会因为失效导致服务器僵死较长时间。) 因为缓存空间太大,在过期操作的时候可能会导致服务器僵死。一个简单的解决办法就是控制缓存空间的大小(query_cache_size),或者直接禁用查询缓存。 无法在数据库或者表级别控制查询缓存,但是可以通过SQL_CACHE和SQL_NO_CACHE来控制某个SELECT语句是否需要进行缓存。你还可以通过修改会话级别的变量query_cache_type来控制查询缓存。 对于写密集型的应用来说,直接禁用查询缓存可能会提高系统的性能。关闭查询缓存可以移除所有相关的消耗。例如将query_cache_size设置成0,那么至少这部分就不再消耗任何内存了。 因为对互斥信号量的竞争,有时直接关闭查询缓存对读密集型的应用也会有好处。如果你希望提高系统的并发,那么最好做一个相关的测试,对比打开和关闭查询缓存时候的性能差异。 如果不想所有的查询都进入查询缓存,但是又希望某些查询走查询缓存,那么可以将query_cache_type设置成DEMAND,然后在希望缓存的查询中加上SQL_CACHE。这虽然需要在查询中加入一些额外的语法,但是可以让你非常自由地控制哪些查询需要被缓存。相反,如果希望缓存多数查询,而少数查询又不希望缓存,那么你可以使用关键字SQL_NO_CACHE。\n7.12.7 查询缓存的替代方案 # MySQL查询缓存工作的原则是:执行查询最快的方式就是不去执行,但是查询仍然需要发送到服务器端,服务器也还需要做一点点工作。如果对于某些查询完全不需要与服务器通信效果会如何呢?这时客户端的缓存可以很大程度上帮你分担MySQL服务器的压力。我们将在第14章详细介绍更多关于缓存的内容。\n7.13 总结 # 本章详细介绍了前面各个章节中提到的一些MySQL特性。这里我们将再来回顾一下其中的一些重点内容。\n分区表\n分区表是一种粗粒度的、简易的索引策略,适用于大数据量的过滤场景。最适合的场景是,在没有合适的索引时,对其中几个分区进行全表扫描,或者是只有一个分区和索引是热点,而且这个分区和索引能够都在内存中;限制单表分区数不要超过150个,并且注意某些导致无法做分区过滤的细节,分区表对于单条记录的查询并没有什么优势,需要注意这类查询的性能。\n视图\n对好几个表的复杂查询,使用视图有时候会大大简化问题。当视图使用临时表时,无法将WHERE条件下推到各个具体的表,也不能使用任何索引,需要特别注意这类查询的性能。如果为了便利,使用视图是很合适的。\n外键\n外键限制会将约束放到MySQL中,这对于必须维护外键的场景,性能会更高。不过这也会带来额外的复杂性和额外的索引消耗,还会增加多表之间的交互,会导致系统中更多的锁和竞争。外键可以被看作是一个确保系统完整性的额外的特性,但是如果设计的是一个高性能的系统,那么外键就显得很臃肿了。很多人在更在意系统的性能的时候都不会使用外键,而是通过应用程序来维护。\n存储过程\nMySQL本身实现了存储过程、触发器、存储函数和事件,老实说,这些特性并没什么特别的。而且对于基于语句的复制还有很多问题。通常,使用这些特性可以帮你节省很多的网络开销——很多情况下,减少网络开销可以大大提升系统的性能。在某些经典的场景下你可以使用这些特性(例如中心化业务逻辑、绕过权限系统,等等),但需要注意在MySQL中,这些特性并没有别的数据库系统那么成熟和全面。\n绑定变量\n当查询语句的解析和执行计划生成消耗了主要的时间,那么绑定变量可以在一定程度上解决问题。因为只需要解析一次,对于大量重复类型的查询语句,性能会有很大的提高。另外,执行计划的缓存和传输使用的二进制协议,这都使得绑定变量的方式比普通SQL语句执行的方式要更快。\n插件\n使用C或者C++编写的插件可以让你最大程度地扩展MySQL功能。插件功能非常强大,我们已经编写了很多UDF和插件,在MySQL中解决了很多问题。\n字符集\n字符集是一种字节到字符之间的映射,而校对规则是指一个字符集的排序方法。很多人都使用Latin1(默认字符集,对英语和某些欧洲语言有效)或者UTF-8。如果使用的是UTF-8,那么在使用临时表和缓冲区的时候需要注意:MySQL会按照每个字符三个字节的最大占用空间来分配存储空间,这可能消耗更多的内存或者磁盘空间。注意让字符集和MySQL字符集配置相符,否则可能会由于字符集转换让某些索引无法正常使用。\n全文索引\n在本书编写的时候只有MyISAM支持全文索引,不过据说从MySQL 5.6开始, InnoDB也将支持全文索引。MyISAM因为在锁粒度和崩溃恢复上的缺点,使得在大型全文索引场景中基本无法使用。这时,我们通常帮助客户构建和使用Sphinx来解决全文索引的问题。\nXA事务\n很少有人用MySQL的XA事务特性。除非你真正明白参数innodb_support_xa的意义,否则不要修改这个参数的值,并不是只有显式使用XA事务时才需要设置这个参数。InnoDB和二进制日志也是需要使用XA事务来做协调的,从而确保在系统崩溃的时候,数据能够一致地恢复。\n查询缓存\n完全相同的查询在重复执行的时候,查询缓存可以立即返回结果,而无须在数据库中重新执行一次。根据我们的经验,在高并发压力环境中查询缓存会导致系统性能的下降,甚至僵死。如果你一定要使用查询缓存,那么不要设置太大内存,而且只有在明确收益的时候才使用。那该如何判断是否应该使用查询缓存呢?建议使用Percona Server,观察更细致的日志,并做一些简单的计算。还可以查看缓存命中率(并不总是有用)、“INSERTS和SELECT比率”(这个参数也并不直观)、或者“命中和写入比率”(这个参考意义较大)。查询缓存是一个非常方便的缓存,对应用程序完全透明,无须任何额外的编码,但是,如果希望有更高的缓存效率,我们建议使用memcached或者其他类似的解决方案。第14章介绍了更多的细节供大家参考。\n————————————————————\n(1) 因为可以在这里存放一个非法的日期,所以甚至当order_date是一个非NULL值的时候,仍然会出现这样情况。\n(2) 从用户角度来看,这应该是一个缺陷,不过从MySQL开发者的角度来看这是一个特性。\n(3) 这些特性也是一些“鲜为人知的犀利”特性。\n(4) 这里的“temp table”并不是指真正的物理上存在的临时表。没有经过这些改进和试验,MySQL视图也不会有现在的效率。\n(5) 在MySQL 5.6中可能会有所改进,但是在本书写作的时候5.6还没有发布。\n(6) 这个语法是SQL/PSM的一个子集,SQL/PSM是SQL标准中的持久化存储模块,在ISO/IEC 9075-4:2003(E)中定义。\n(7) 有一些专门用作移植的工具,例如tsql2mysql项目就是专门用于移植SQL Server上的存储过程。参考: http://sourceforge.net/projects/tsql2mysql。\n(8) 通常各个层都有自己的缓存。——译者注\n(9) MySQL 4.0和更早的版本中,如果设置服务器的全局设置,有几种8字节的字符集可以选择。\n(10) coercibility()函数的返回值。——译者注\n(11) 即排序规则。——译者注\n(12) 在MySQL 5.1中,可以使用全文解析器插件来扩展全文索引的功能。不过,MySQL的全文索引本身还是有很多限制的,可能导致无法在你的应用场景中使用。我们将在附录F中介绍如何将Sphinx作为一个MySQL内部搜索引擎来使用。\n(13) 在测试使用时的一个常见错误就是,只是用很小的数据集合进行全文索引,所以总是无法返回结果。原因在于,每个搜索关键词都可能在一半以上的记录里面出现过。\n(14) 事实上,全文索引根本不会对太短或者太长的词语进行索引,但是这里说的不是一回事。一般地, MySQL本身并不会因为搜索关键词过长或过短而忽略这些词语,但是查询优化器的某些部分却可能这样做。\n(15) 在撰写本书的时候,“批量提交”的问题已经有了很多解决方案,其中至少有三种是很优秀的。还需要进一步观察到底MySQL官方会采用哪一种,到底到哪个版本MySQL才会合并到源码。目前,使用MariaDB和Percona Server就可以避免这个问题。\n(16) 一个常见的误区是认为innodb_support_xa只有在需要XA事务时才需要打开。这是错误的:该参数还会控制MyQSL内部存储引擎和二进制日志之间的分布式事务。如果你真正关心你的数据,你需要将这个参数打开。\n(17) 有一种方式查询缓存可能和原生的SQL工作方式有所不同:默认的,当要查询的表被LOCK TABLES锁住时,查询仍然可以通过查询缓存返回数据。你可以通过参数query_cache_wlock_invalidate打开或者关闭这种行为。\n(18) 对于这个规则,Percona Server是个例外。它会先将所有的注释语句删除,然后再比较查询语句是否有缓存。这是一个通用的需求,这样可以在查询语句中带入更多的处理过程信息。前面第3章我们介绍的MySQL监控系统就依赖于此。\n(19) 这里绘制的查询缓存内存分配图,仍然是一种简化的情况。MySQL实际管理查询缓存的方式比这要更复杂。如果你想知道更多的细节,在源代码文件sql/sql_cache.cc开头的注释中有非常详细的解释。\n(20) Percona和MariaDB对MySQL慢日志进行了改进,会记录慢日志中的查询是否命中查询缓存。\n"},{"id":141,"href":"/zh/docs/technology/MySQL/_%E9%AB%98%E6%80%A7%E8%83%BDMySQL_/%E7%AC%AC6%E7%AB%A0%E6%9F%A5%E8%AF%A2%E6%80%A7%E8%83%BD%E4%BC%98%E5%8C%96/","title":"第6章查询性能优化","section":"高性能 My SQL","content":"第6章 查询性能优化\n前面的章节我们介绍了如何设计最优的库表结构、如何建立最好的索引,这些对于高性能来说是必不可少的。但这些还不够——还需要合理的设计查询。如果查询写得很糟糕,即使库表结构再合理、索引再合适,也无法实现高性能。\n查询优化、索引优化、库表结构优化需要齐头并进,一个不落。在获得编写MySQL查询的经验的同时,也将学习到如何为高效的查询设计表和索引。同样的,也可以学习到在优化库表结构时会影响到哪些类型的查询。这个过程需要时间,所以建议大家在学习后面章节的时候多回头看看这三章的内容。\n本章将从查询设计的一些基本原则开始——这也是在发现查询效率不高的时候首先需要考虑的因素。然后会介绍一些更深的查询优化的技巧,并会介绍一些MySQL优化器内部的机制。我们将展示MySQL是如何执行查询的,你也将学会如何去改变一个查询的执行计划。最后,我们要看一下MySQL优化器在哪些方面做得还不够,并探索查询优化的模式,以帮助MySQL更有效地执行查询。\n本章的目标是帮助大家更深刻地理解MySQL如何真正地执行查询,并明白高效和低效的原因何在,这样才能充分发挥MySQL的优势,并避开它的弱点。\n6.1 为什么查询速度会慢 # 在尝试编写快速的查询之前,需要清楚一点,真正重要是响应时间。如果把查询看作是一个任务,那么它由一系列子任务组成,每个子任务都会消耗一定的时间。如果要优化查询,实际上要优化其子任务,要么消除其中一些子任务,要么减少子任务的执行次数,要么让子任务运行得更快(1)。\nMySQL在执行查询的时候有哪些子任务,哪些子任务运行的速度很慢?这里很难给出完整的列表,但如果按照第3章介绍的方法对查询进行剖析,就能看到查询所执行的子任务。通常来说,查询的生命周期大致可以按照顺序来看:从客户端,到服务器,然后在服务器上进行解析,生成执行计划,执行,并返回结果给客户端。其中“执行”可以认为是整个生命周期中最重要的阶段,这其中包括了大量为了检索数据到存储引擎的调用以及调用后的数据处理,包括排序、分组等。\n在完成这些任务的时候,查询需要在不同的地方花费时间,包括网络,CPU计算,生成统计信息和执行计划、锁等待(互斥等待)等操作,尤其是向底层存储引擎检索数据的调用操作,这些调用需要在内存操作、CPU操作和内存不足时导致的I/O操作上消耗时间。根据存储引擎不同,可能还会产生大量的上下文切换以及系统调用。\n在每一个消耗大量时间的查询案例中,我们都能看到一些不必要的额外操作、某些操作被额外地重复了很多次、某些操作执行得太慢等。优化查询的目的就是减少和消除这些操作所花费的时间。\n再次申明一点,对于一个查询的全部生命周期,上面列的并不完整。这里我们只是想说明:了解查询的生命周期、清楚查询的时间消耗情况对于优化查询有很大的意义。有了这些概念,我们再一起来看看如何优化查询。\n6.2 慢查询基础:优化数据访问 # 查询性能低下最基本的原因是访问的数据太多。某些查询可能不可避免地需要筛选大量数据,但这并不常见。大部分性能低下的查询都可以通过减少访问的数据量的方式进行优化。对于低效的查询,我们发现通过下面两个步骤来分析总是很有效:\n确认应用程序是否在检索大量超过需要的数据。这通常意味着访问了太多的行,但有时候也可能是访问了太多的列。 确认MySQL服务器层是否在分析大量超过需要的数据行。 6.2.1 是否向数据库请求了不需要的数据 # 有些查询会请求超过实际需要的数据,然后这些多余的数据会被应用程序丢弃。这会给MySQL服务器带来额外的负担,并增加网络开销(2),另外也会消耗应用服务器的CPU和内存资源。\n这里有一些典型案例:\n查询不需要的记录\n一个常见的错误是常常会误以为MySQL会只返回需要的数据,实际上MySQL却是先返回全部结果集再进行计算。我们经常会看到一些了解其他数据库系统的人会设计出这类应用程序。这些开发者习惯使用这样的技术,先使用SELECT语句查询大量的结果,然后获取前面的N行后关闭结果集(例如在新闻网站中取出100条记录,但是只是在页面上显示前面10条)。他们认为MySQL会执行查询,并只返回他们需要的10条数据,然后停止查询。实际情况是MySQL会查询出全部的结果集,客户端的应用程序会接收全部的结果集数据,然后抛弃其中大部分数据。最简单有效的解决方法就是在这样的查询后面加上LIMIT。\n多表关联时返回全部列\n如果你想查询所有在电影Academy Dinosaur中出现的演员,千万不要按下面的写法编写查询:\nmysql\u0026gt; ** SELECT * FROM sakila.actor** -\u0026gt; ** INNER JOIN sakila.film_actor USING(actor_id)** -\u0026gt; ** INNER JOIN sakila.film USING(film_id)** -\u0026gt; ** WHERE sakila.film.title = 'Academy Dinosaur';** 这将返回这三个表的全部数据列。正确的方式应该是像下面这样只取需要的列:\nmysql\u0026gt; ** SELECT sakila.actor.* FROM sakila.actor...;** 总是取出全部列\n每次看到SELECT *的时候都需要用怀疑的眼光审视,是不是真的需要返回全部的列?很可能不是必需的。取出全部列,会让优化器无法完成索引覆盖扫描这类优化,还会为服务器带来额外的I/O、内存和CPU的消耗。因此,一些DBA是严格禁止SELECT *的写法的,这样做有时候还能避免某些列被修改带来的问题。\n当然,查询返回超过需要的数据也不总是坏事。在我们研究过的许多案例中,人们会告诉我们说这种有点浪费数据库资源的方式可以简化开发,因为能提高相同代码片段的复用性,如果清楚这样做的性能影响,那么这种做法也是值得考虑的。如果应用程序使用了某种缓存机制,或者有其他考虑,获取超过需要的数据也可能有其好处,但不要忘记这样做的代价是什么。获取并缓存所有的列的查询,相比多个独立的只获取部分列的查询可能就更有好处。\n重复查询相同的数据\n如果你不太小心,很容易出现这样的错误——不断地重复执行相同的查询,然后每次都返回完全相同的数据。例如,在用户评论的地方需要查询用户头像的URL,那么用户多次评论的时候,可能就会反复查询这个数据。比较好的方案是,当初次查询的时候将这个数据缓存起来,需要的时候从缓存中取出,这样性能显然会更好。\n6.2.2 MySQL是否在扫描额外的记录 # 在确定查询只返回需要的数据以后,接下来应该看看查询为了返回结果是否扫描了过多的数据。对于MySQL,最简单的衡量查询开销的三个指标如下:\n响应时间 扫描的行数 返回的行数 没有哪个指标能够完美地衡量查询的开销,但它们大致反映了MySQL在内部执行查询时需要访问多少数据,并可以大概推算出查询运行的时间。这三个指标都会记录到MySQL的慢日志中,所以检查慢日志记录是找出扫描行数过多的查询的好办法。\n响应时间 # 要记住,响应时间只是一个表面上的值。这样说可能看起来和前面关于响应时间的说法有矛盾?其实并不矛盾,响应时间仍然是最重要的指标,这有一点复杂,后面细细道来。\n响应时间是两个部分之和:服务时间和排队时间。服务时间是指数据库处理这个查询真正花了多长时间。排队时间是指服务器因为等待某些资源而没有真正执行查询的时间——可能是等I/O操作完成,也可能是等待行锁,等等。遗憾的是,我们无法把响应时间细分到上面这些部分,除非有什么办法能够逐个测量上面这些消耗,不过很难做到。一般最常见和重要的等待是I/O和锁等待,但是实际情况更加复杂。\n所以在不同类型的应用压力下,响应时间并没有什么一致的规律或者公式。诸如存储引擎的锁(表锁、行锁)、高并发资源竞争、硬件响应等诸多因素都会影响响应时间。所以,响应时间既可能是一个问题的结果也可能是一个问题的原因,不同案例情况不同,除非能够使用第3章的“单个查询问题还是服务器问题”一节介绍的技术来确定到底是因还是果。\n当你看到一个查询的响应时间的时候,首先需要问问自己,这个响应时间是否是一个合理的值。实际上可以使用“快速上限估计”法来估算查询的响应时间,这是由TapioLahdenmaki和Mike Leach编写的Relational Database Index Design and the Optimizers(Wiley出版社)一书提到的技术,限于篇幅,在这里不会详细展开。概括地说,了解这个查询需要哪些索引以及它的执行计划是什么,然后计算大概需要多少个顺序和随机I/O,再用其乘以在具体硬件条件下一次I/O的消耗时间。最后把这些消耗都加起来,就可以获得一个大概参考值来判断当前响应时间是不是一个合理的值。\n扫描的行数和返回的行数 # 分析查询时,查看该查询扫描的行数是非常有帮助的。这在一定程度上能够说明该查询找到需要的数据的效率高不高。\n对于找出那些“糟糕”的查询,这个指标可能还不够完美,因为并不是所有的行的访问代价都是相同的。较短的行的访问速度更快,内存中的行也比磁盘中的行的访问速度要快得多。\n理想情况下扫描的行数和返回的行数应该是相同的。但实际情况中这种“美事”并不多。例如在做一个关联查询时,服务器必须要扫描多行才能生成结果集中的一行。扫描的行数对返回的行数的比率通常很小,一般在1:1和10:1之间,不过有时候这个值也可能非常非常大。\n扫描的行数和访问类型 # 在评估查询开销的时候,需要考虑一下从表中找到某一行数据的成本。MySQL有好几种访问方式可以查找并返回一行结果。有些访问方式可能需要扫描很多行才能返回一行结果,也有些访问方式可能无须扫描就能返回结果。\n在EXPLAIN语句中的type列反应了访问类型。访问类型有很多种,从全表扫描到索引扫描、范围扫描、唯一索引查询、常数引用等。这里列的这些,速度是从慢到快,扫描的行数也是从小到大。你不需要记住这些访问类型,但需要明白扫描表、扫描索引、范围访问和单值访问的概念。\n如果查询没有办法找到合适的访问类型,那么解决的最好办法通常就是增加一个合适的索引,这也正是我们前一章讨论过的问题。现在应该明白为什么索引对于查询优化如此重要了。索引让MySQL以最高效、扫描行数最少的方式找到需要的记录。\n例如,我们看看示例数据库Sakila中的一个查询案例:\nmysql\u0026gt; ** SELECT *FROM sakila.film_actor WHERE film_id = 1;** 这个查询将返回10行数据,从EXPLAIN的结果可以看到,MySQL在索引idx_fk_film_id上使用了ref访问类型来执行查询:\nmysql\u0026gt; EXPLAIN SELECT * FROM sakila.film_actor WHERE film_id = 1\\G *************************** 1. row *************************** id: 1 select_type: SIMPLE table: film_actor type: ref possible_keys: idx_fk_film_id key: idx_fk_film_id key_len: 2 ref: const rows: 10 Extra: EXPLAIN的结果也显示MySQL预估需要访问10行数据。换句话说,查询优化器认为这种访问类型可以高效地完成查询。如果没有合适的索引会怎样呢?MySQL就不得不使用一种更糟糕的访问类型,下面我们来看看如果我们删除对应的索引再来运行这个查询:\nmysql\u0026gt; ** ALTER TABLE sakila.film_actor DROP FOREIGN KEY fk_film_actor_film;** mysql\u0026gt; ** ALTER TABLE sakila.film_actor DROP KEY idx_fk_film_id;** mysql\u0026gt; ** EXPLAIN SELECT * FROM sakila.film_actor WHERE film_id = 1\\G** *************************** 1. row *************************** id: 1 select_type: SIMPLE table: film_actor type: ALL possible_keys: NULL key: NULL key_len: NULL ref: NULL rows: 5073 Extra: Using where 正如我们预测的,访问类型变成了一个全表扫描(ALL),现在MySQL预估需要扫描5073条记录来完成这个查询。这里的“Using Where”表示MySQL将通过WHERE条件来筛选存储引擎返回的记录。\n一般MySQL能够使用如下三种方式应用WHERE条件,从好到坏依次为:\n在索引中使用WHERE条件来过滤不匹配的记录。这是在存储引擎层完成的。 使用索引覆盖扫描(在Extra列中出现了Using index)来返回记录,直接从索引中过滤不需要的记录并返回命中的结果。这是在MySQL服务器层完成的,但无须再回表查询记录。 从数据表中返回数据,然后过滤不满足条件的记录(在Extra列中出现Using Where)。这在MySQL服务器层完成,MySQL需要先从数据表读出记录然后过滤。 上面这个例子说明了好的索引多么重要。好的索引可以让查询使用合适的访问类型,尽可能地只扫描需要的数据行。但也不是说增加索引就能让扫描的行数等于返回的行数。例如下面使用聚合函数COUNT()的查询(3):\nmysql\u0026gt; ** SELECT actor_id,** COUNT(*)** FROM sakila.film_actor GROUP BY actor_id;** 这个查询需要读取几千行数据,但是仅返回200行结果。没有什么索引能够让这样的查询减少需要扫描的行数。\n不幸的是,MySQL不会告诉我们生成结果实际上需要扫描多少行数据(4),而只会告诉我们生成结果时一共扫描了多少行数据。扫描的行数中的大部分都很可能是被WHERE条件过滤掉的,对最终的结果集并没有贡献。在上面的例子中,我们删除索引后,看到MySQL需要扫描所有记录然后根据WHERE条件过滤,最终只返回10行结果。理解一个查询需要扫描多少行和实际需要使用的行数需要先去理解这个查询背后的逻辑和思想。\n如果发现查询需要扫描大量的数据但只返回少数的行,那么通常可以尝试下面的技巧去优化它:\n使用索引覆盖扫描,把所有需要用的列都放到索引中,这样存储引擎无须回表获取对应行就可以返回结果了(在前面的章节中我们已经讨论过了)。 改变库表结构。例如使用单独的汇总表(这是我们在第4章中讨论的办法)。 重写这个复杂的查询,让MySQL优化器能够以更优化的方式执行这个查询(这是本章后续需要讨论的问题)。 6.3 重构查询的方式 # 在优化有问题的查询时,目标应该是找到一个更优的方法获得实际需要的结果——而不一定总是需要从MySQL获取一模一样的结果集。有时候,可以将查询转换一种写法让其返回一样的结果,但是性能更好。但也可以通过修改应用代码,用另一种方式完成查询,最终达到一样的目的。这一节我们将介绍如何通过这种方式来重构查询,并展示何时需要使用这样的技巧。\n6.3.1 一个复杂查询还是多个简单查询 # 设计查询的时候一个需要考虑的重要问题是,是否需要将一个复杂的查询分成多个简单的查询。在传统实现中,总是强调需要数据库层完成尽可能多的工作,这样做的逻辑在于以前总是认为网络通信、查询解析和优化是一件代价很高的事情。\n但是这样的想法对于MySQL并不适用,MySQL从设计上让连接和断开连接都很轻量级,在返回一个小的查询结果方面很高效。现代的网络速度比以前要快很多,无论是带宽还是延迟。在某些版本的MySQL上,即使在一个通用服务器上,也能够运行每秒超过10万的查询,即使是一个千兆网卡也能轻松满足每秒超过2000次的查询。所以运行多个小查询现在已经不是大问题了。\nMySQL内部每秒能够扫描内存中上百万行数据,相比之下,MySQL响应数据给客户端就慢得多了。在其他条件都相同的时候,使用尽可能少的查询当然是更好的。但是有时候,将一个大查询分解为多个小查询是很有必要的。别害怕这样做,好好衡量一下这样做是不是会减少工作量。稍后我们将通过本章的一个示例来展示这个技巧的优势。\n不过,在应用设计的时候,如果一个查询能够胜任时还写成多个独立查询是不明智的。例如,我们看到有些应用对一个数据表做10次独立的查询来返回10行数据,每个查询返回一条结果,查询10次!\n6.3.2 切分查询 # 有时候对于一个大查询我们需要“分而治之”,将大查询切分成小查询,每个查询功能完全一样,只完成一小部分,每次只返回一小部分查询结果。\n删除旧的数据就是一个很好的例子。定期地清除大量数据时,如果用一个大的语句一次性完成的话,则可能需要一次锁住很多数据、占满整个事务日志、耗尽系统资源、阻塞很多小的但重要的查询。将一个大的DELETE语句切分成多个较小的查询可以尽可能小地影响MySQL性能,同时还可以减少MySQL复制的延迟。例如,我们需要每个月运行一次下面的查询:\nmysql\u0026gt; ** DELETE FROM messages WHERE created \u0026lt; DATE_SUB(NOW(),INTERVAL 3 MONTH);** 那么可以用类似下面的办法来完成同样的工作:\nrows_affected = 0 do { rows_affected = do_query( \u0026#34;DELETE FROM messages WHERE created \u0026lt; DATE_SUB(NOW(),INTERVAL 3 MONTH) LIMIT 10000\u0026#34;) } while rows_affected \u0026gt; 0 一次删除一万行数据一般来说是一个比较高效而且对服务器(5)影响也最小的做法(如果是事务型引擎,很多时候小事务能够更高效)。同时,需要注意的是,如果每次删除数据后,都暂停一会儿再做下一次删除,这样也可以将服务器上原本一次性的压力分散到一个很长的时间段中,就可以大大降低对服务器的影响,还可以大大减少删除时锁的持有时间。\n6.3.3 分解关联查询 # 很多高性能的应用都会对关联查询进行分解。简单地,可以对每一个表进行一次单表查询,然后将结果在应用程序中进行关联。例如,下面这个查询:\nmysql\u0026gt; ** SELECT * FROM tag** -\u0026gt; ** JOIN tag_post ON tag_post.tag_id=tag.id** -\u0026gt; ** JOIN post ON tag_post.post_id=post.id** -\u0026gt; ** WHERE tag.tag='mysql';** 可以分解成下面这些查询来代替:\nmysql\u0026gt; ** SELECT * FROM tag_post WHERE tag_id=1';** mysql\u0026gt; ** SELECT * FROM tag_post WHERE tag_id=1234;** mysql\u0026gt; ** SELECT * FROM post WHERE post.id in (123,456,567,9098,8904);** 到底为什么要这样做?乍一看,这样做并没有什么好处,原本一条查询,这里却变成多条查询,返回的结果又是一模一样的。事实上,用分解关联查询的方式重构查询有如下的优势:\n让缓存的效率更高。许多应用程序可以方便地缓存单表查询对应的结果对象。例如,上面查询中的tag已经被缓存了,那么应用就可以跳过第一个查询。再例如,应用中已经缓存了ID为123、567、9098的内容,那么第三个查询的IN()中就可以少几个ID。另外,对MySQL的查询缓存来说(6),如果关联中的某个表发生了变化,那么就无法使用查询缓存了,而拆分后,如果某个表很少改变,那么基于该表的查询就可以重复利用查询缓存结果了。 将查询分解后,执行单个查询可以减少锁的竞争。 在应用层做关联,可以更容易对数据库进行拆分,更容易做到高性能和可扩展。 查询本身效率也可能会有所提升。这个例子中,使用IN()代替关联查询,可以让MySQL按照ID顺序进行查询,这可能比随机的关联要更高效。我们后续将详细介绍这点。 可以减少冗余记录的查询。在应用层做关联查询,意味着对于某条记录应用只需要查询一次,而在数据库中做关联查询,则可能需要重复地访问一部分数据。从这点看,这样的重构还可能会减少网络和内存的消耗。 更进一步,这样做相当于在应用中实现了哈希关联,而不是使用MySQL的嵌套循环关联。某些场景哈希关联的效率要高很多(本章后续我们将讨论这点)。 在很多场景下,通过重构查询将关联放到应用程序中将会更加高效,这样的场景有很多,比如:当应用能够方便地缓存单个查询的结果的时候、当可以将数据分布到不同的MySQL服务器上的时候、当能够使用IN()的方式代替关联查询的时候、当查询中使用同一个数据表的时候。\n6.4 查询执行的基础 # 当希望MySQL能够以更高的性能运行查询时,最好的办法就是弄清楚MySQL是如何优化和执行查询的。一旦理解这一点,很多查询优化工作实际上就是遵循一些原则让优化器能够按照预想的合理的方式运行。\n换句话说,是时候回头看看我们前面讨论的内容了:MySQL执行一个查询的过程。根据图6-1,我们可以看到当向MySQL发送一个请求的时候,MySQL到底做了些什么:\n图6-1:查询执行路径\n客户端发送一条查询给服务器。 服务器先检查查询缓存,如果命中了缓存,则立刻返回存储在缓存中的结果。否则进入下一阶段。 服务器端进行SQL解析、预处理,再由优化器生成对应的执行计划。 MySQL根据优化器生成的执行计划,调用存储引擎的API来执行查询。 将结果返回给客户端。 上面的每一步都比想象的复杂,我们在后续章节中将继续讨论。我们会看到在每一个阶段查询处于何种状态。查询优化器是其中特别复杂也特别难理解的部分。还有很多的例外情况,例如,当查询使用绑定变量后,执行路径会有所不同,我们将在下一章讨论这点。\n6.4.1 MySQL客户端/服务器通信协议 # 一般来说,不需要去理解MySQL通信协议的内部实现细节,只需要大致理解通信协议是如何工作的。MySQL客户端和服务器之间的通信协议是“半双工”的,这意味着,在任何一个时刻,要么是由服务器向客户端发送数据,要么是由客户端向服务器发送数据,这两个动作不能同时发生。所以,我们无法也无须将一个消息切成小块独立来发送。\n这种协议让MySQL通信简单快速,但是也从很多地方限制了MySQL。一个明显的限制是,这意味着没法进行流量控制。一旦一端开始发生消息,另一端要接收完整个消息才能响应它。这就像来回抛球的游戏:在任何时刻,只有一个人能控制球,而且只有控制球的人才能将球抛回去(发送消息)。\n客户端用一个单独的数据包将查询传给服务器。这也是为什么当查询的语句很长的时候,参数max_allowed_packet就特别重要了(7)。一旦客户端发送了请求,它能做的事情就只是等待结果了。\n相反的,一般服务器响应给用户的数据通常很多,由多个数据包组成。当服务器开始响应客户端请求时,客户端必须完整地接收整个返回结果,而不能简单地只取前面几条结果,然后让服务器停止发送数据。这种情况下,客户端若接收完整的结果,然后取前面几条需要的结果,或者接收完几条结果后就“粗暴”地断开连接,都不是好主意。这也是在必要的时候一定要在查询中加上LIMIT限制的原因。\n换一种方式解释这种行为:当客户端从服务器取数据时,看起来是一个拉数据的过程,但实际上是MySQL在向客户端推送数据的过程。客户端不断地接收从服务器推送的数据,客户端也没法让服务器停下来。客户端像是“从消防水管喝水”(这是一个术语)。\n多数连接MySQL的库函数都可以获得全部结果集并缓存到内存里,还可以逐行获取需要的数据。默认一般是获得全部结果集并缓存到内存中。MySQL通常需要等所有的数据都已经发送给客户端才能释放这条查询所占用的资源,所以接收全部结果并缓存通常可以减少服务器的压力,让查询能够早点结束、早点释放相应的资源。\n当使用多数连接MySQL的库函数从MySQL获取数据时,其结果看起来都像是从MySQL服务器获取数据,而实际上都是从这个库函数的缓存获取数据。多数情况下这没什么问题,但是如果需要返回一个很大的结果集的时候,这样做并不好,因为库函数会花很多时间和内存来存储所有的结果集。如果能够尽早开始处理这些结果集,就能大大减少内存的消耗,这种情况下可以不使用缓存来记录结果而是直接处理。这样做的缺点是,对于服务器来说,需要查询完成后才能释放资源,所以在和客户端交互的整个过程中,服务器的资源都是被这个查询所占用的(8)。\n我们看看当使用P H P的时候是什么情况。首先,下面是我们连接M y S Q L的通常写法:\n\u0026lt;?php $link = mysql_connect(\u0026#39;localhost\u0026#39;, \u0026#39;user\u0026#39;, \u0026#39;p4ssword\u0026#39;); $result = mysql_query(\u0026#39;SELECT * FROM HUGE_TABLE\u0026#39;, $link); while ( $row = mysql_fetch_array($result) ) { // Do something with result } ?\u0026gt;} 这段代码看起来像是只有当你需要的时候,才通过循环从服务器端取出数据。而实际上,在上面的代码中,在调用mysql_query()的时候,PHP就已经将整个结果集缓存到内存中。下面的while循环只是从这个缓存中逐行取出数据,相反如果使用下面的查询,用mysql_unbuffered_query()代替mysql_query(),PHP则不会缓存结果:\n\u0026lt;?php $link = mysql_connect(\u0026#39;localhost\u0026#39;, \u0026#39;user\u0026#39;, \u0026#39;p4ssword\u0026#39;); $result = mysql_unbuffered_query(\u0026#39;SELECT * FROM HUGE_TABLE\u0026#39;, $link); while ( $row = mysql_fetch_array($result) ) { // Do something with result } ?\u0026gt; 不同的编程语言处理缓存的方式不同。例如,在Perl的DBD:mysql驱动中需要指定C连接库的mysql_use_result属性(默认是mysql_buffer_result)。下面是一个例子\n#!/usr/bin/perl use DBI; my $dbh = DBI-\u0026gt;connect('DBI:mysql:;host=localhost', 'user', 'p4ssword'); my $sth = $dbh-\u0026gt;prepare('SELECT * FROM HUGE_TABLE', { mysql_use_result =\u0026gt; 1 }); $sth-\u0026gt;execute(); while ( my $row = $sth-\u0026gt;fetchrow_array() ) { # Do something with result } 注意到上面的prepare()调用指定了mysql_use_result属性为1,所以应用将直接“使用”返回的结果集而不会将其缓存。也可以在连接MySQL的时候指定这个属性,这会让整个连接都使用不缓存的方式处理结果集:\nmy $dbh = DBI-\u0026gt;connect('DBI:mysql:;mysql_use_result=1', 'user', 'p4ssword'); 查询状态 # 对于一个MySQL连接,或者说一个线程,任何时刻都有一个状态,该状态表示了MySQL当前正在做什么。有很多种方式能查看当前的状态,最简单的是使用SHOW FULL PROCESSLIST命令(该命令返回结果中的Command列就表示当前的状态)。在一个查询的生命周期中,状态会变化很多次。MySQL官方手册中对这些状态值的含义有最权威的解释,下面将这些状态列出来,并做一个简单的解释。\nSleep\n线程正在等待客户端发送新的请求。\nQuery\n线程正在执行查询或者正在将结果发送给客户端。\nLocked\n在MySQL服务器层,该线程正在等待表锁。在存储引擎级别实现的锁,例如InnoDB的行锁,并不会体现在线程状态中。对于MyISAM来说这是一个比较典型的状态,但在其他没有行锁的引擎中也经常会出现。\nAnalyzing and statistics\n线程正在收集存储引擎的统计信息,并生成查询的执行计划。\nCopying to tmp table [on disk]\n线程正在执行查询,并且将其结果集都复制到一个临时表中,这种状态一般要么是在做GROUP BY操作,要么是文件排序操作,或者是UNION操作。如果这个状态后面还有“on disk”标记,那表示MySQL正在将一个内存临时表放到磁盘上。\nThe thread is\n线程正在对结果集进行排序。\nSending data\n这表示多种情况:线程可能在多个状态之间传送数据,或者在生成结果集,或者在向客户端返回数据。\n了解这些状态的基本含义非常有用,这可以让你很快地了解当前“谁正在持球”(9)。在一个繁忙的服务器上,可能会看到大量的不正常的状态,例如statistics正占用大量的时间。这通常表示,某个地方有异常了,可以通过使用第3章的一些技巧来诊断到底是哪个环节出现了问题。\n6.4.2 查询缓存**(10)** # 在解析一个查询语句之前,如果查询缓存是打开的,那么MySQL会优先检查这个查询是否命中查询缓存中的数据。这个检查是通过一个对大小写敏感的哈希查找实现的。查询和缓存中的查询即使只有一个字节不同,那也不会匹配缓存结果(11),这种情况下查询就会进入下一阶段的处理。\n如果当前的查询恰好命中了查询缓存,那么在返回查询结果之前MySQL会检查一次用户权限。这仍然是无须解析查询SQL语句的,因为在查询缓存中已经存放了当前查询需要访问的表信息。如果权限没有问题,MySQL会跳过所有其他阶段,直接从缓存中拿到结果并返回给客户端。这种情况下,查询不会被解析,不用生成执行计划,不会被执行。在第7章中的查询缓存一节,你将学习到更多细节。\n6.4.3 查询优化处理 # 查询的生命周期的下一步是将一个SQL转换成一个执行计划,MySQL再依照这个执行计划和存储引擎进行交互。这包括多个子阶段:解析SQL、预处理、优化SQL执行计划。这个过程中任何错误(例如语法错误)都可能终止查询。这里不打算详细介绍MySQL内部实现,而只是选择性地介绍其中几个独立的部分,在实际执行中,这几部分可能一起执行也可能单独执行。我们的目的是帮助大家理解MySQL如何执行查询,以便写出更优秀的查询。\n语法解析器和预处理 # 首先,MySQL通过关键字将SQL语句进行解析,并生成一棵对应的“解析树”。MySQL解析器将使用MySQL语法规则验证和解析查询。例如,它将验证是否使用错误的关键字,或者使用关键字的顺序是否正确等,再或者它还会验证引号是否能前后正确匹配。\n预处理器则根据一些MySQL规则进一步检查解析树是否合法,例如,这里将检查数据表和数据列是否存在,还会解析名字和别名,看看它们是否有歧义。\n下一步预处理器会验证权限。这通常很快,除非服务器上有非常多的权限配置。\n查询优化器 # 现在语法树被认为是合法的了,并且由优化器将其转化成执行计划。一条查询可以有很多种执行方式,最后都返回相同的结果。优化器的作用就是找到这其中最好的执行计划。\nMySQL使用基于成本的优化器,它将尝试预测一个查询使用某种执行计划时的成本,并选择其中成本最小的一个。最初,成本的最小单位是随机读取一个4K数据页的成本,后来(成本计算公式)变得更加复杂,并且引入了一些“因子”来估算某些操作的代价,如当执行一次WHERE条件比较的成本。可以通过查询当前会话的Last_query_cost的值来得知MySQL计算的当前查询的成本。\n这个结果表示MySQL的优化器认为大概需要做1040个数据页的随机查找才能完成上面的查询。这是根据一系列的统计信息计算得来的:每个表或者索引的页面个数、索引的基数(索引中不同值的数量)、索引和数据行的长度、索引分布情况。优化器在评估成本的时候并不考虑任何层面的缓存,它假设读取任何数据都需要一次磁盘I/O。\n有很多种原因会导致MySQL优化器选择错误的执行计划,如下所示:\n统计信息不准确。MySQL依赖存储引擎提供的统计信息来评估成本,但是有的存储引擎提供的信息是准确的,有的偏差可能非常大。例如,InnoDB因为其MVCC的架构,并不能维护一个数据表的行数的精确统计信息。 执行计划中的成本估算不等同于实际执行的成本。所以即使统计信息精准,优化器给出的执行计划也可能不是最优的。例如有时候某个执行计划虽然需要读取更多的页面,但是它的成本却更小。因为如果这些页面都是顺序读或者这些页面都已经在内存中的话,那么它的访问成本将很小。MySQL层面并不知道哪些页面在内存中、哪些在磁盘上,所以查询实际执行过程中到底需要多少次物理I/O是无法得知的。 MySQL的最优可能和你想的最优不一样。你可能希望执行时间尽可能的短,但是 MySQL只是基于其成本模型选择最优的执行计划,而有些时候这并不是最快的执行方式。所以,这里我们看到根据执行成本来选择执行计划并不是完美的模型。 MySQL从不考虑其他并发执行的查询,这可能会影响到当前查询的速度。 MySQL也并不是任何时候都是基于成本的优化。有时也会基于一些固定的规则,例如,如果存在全文搜索的MATCH()子句,则在存在全文索引的时候就使用全文索引。即使有时候使用别的索引和WHERE条件可以远比这种方式要快,MySQL也仍然会使用对应的全文索引。 MySQL不会考虑不受其控制的操作的成本,例如执行存储过程或者用户自定义函数的成本。 后面我们还会看到,优化器有时候无法去估算所有可能的执行计划,所以它可能错过实际上最优的执行计划。 MySQL的查询优化器是一个非常复杂的部件,它使用了很多优化策略来生成一个最优的执行计划。优化策略可以简单地分为两种,一种是静态优化,一种是动态优化。静态优化可以直接对解析树进行分析,并完成优化。例如,优化器可以通过一些简单的代数变换将WHERE条件转换成另一种等价形式。静态优化不依赖于特别的数值,如WHERE条件中带入的一些常数等。静态优化在第一次完成后就一直有效,即使使用不同的参数重复执行查询也不会发生变化。可以认为这是一种“编译时优化”。\n相反,动态优化则和查询的上下文有关,也可能和很多其他因素有关,例如WHERE条件中的取值、索引中条目对应的数据行数等。这需要在每次查询的时候都重新评估,可以认为这是“运行时优化”。\n在执行语句和存储过程的时候,动态优化和静态优化的区别非常重要。MySQL对查询的静态优化只需要做一次,但对查询的动态优化则在每次执行时都需要重新评估。有时候甚至在查询的执行过程中也会重新优化。(12)\n下面是一些MySQL能够处理的优化类型:\n重新定义关联表的顺序\n数据表的关联并不总是按照在查询中指定的顺序进行。决定关联的顺序是优化器很重要的一部分功能,本章后面将深入介绍这一点。\n将外连接转化成内连接\n并不是所有的OUTER JOIN语句都必须以外连接的方式执行。诸多因素,例如WHERE条件、库表结构都可能会让外连接等价于一个内连接。MySQL能够识别这点并重写查询,让其可以调整关联顺序。\n使用等价变换规则\nMySQL可以使用一些等价变换来简化并规范表达式。它可以合并和减少一些比较,还可以移除一些恒成立和一些恒不成立的判断。例如,(5=5 AND a\u0026gt;5)将被改写为a\u0026gt;5。类似的,如果有 (a\u0026lt;b AND b=c) AND a=5则会改写为b\u0026gt;5 AND b=c AND a=5。这些规则对于我们编写条件语句很有用,我们将在本章后续继续讨论。\n优化COUNT()、MIN()和MAX()\n索引和列是否可为空通常可以帮助MySQL优化这类表达式。例如,要找到某一列的最小值,只需要查询对应B-Tree索引最左端的记录,MySQL可以直接获取索引的第一行记录。在优化器生成执行计划的时候就可以利用这一点,在B-Tree索引中,优化器会将这个表达式作为一个常数对待。类似的,如果要查找一个最大值,也只需读取B-Tree索引的最后一条记录。如果MySQL使用了这种类型的优化,那么在EXPLAIN中就可以看到“Select tables optimized away”。从字面意思可以看出,它表示优化器已经从执行计划中移除了该表,并以一个常数取而代之。\n类似的,没有任何WHERE条件的COUNT(*)查询通常也可以使用存储引擎提供的一些优化(例如,MyISAM维护了一个变量来存放数据表的行数)。\n预估并转化为常数表达式\n当MySQL检测到一个表达式可以转化为常数的时候,就会一直把该表达式作为常数进行优化处理。例如,一个用户自定义变量在查询中没有发生变化时就可以转换为一个常数。数学表达式则是另一种典型的例子。\n让人惊讶的是,在优化阶段,有时候甚至一个查询也能够转化为一个常数。一个例子是在索引列上执行MIN()函数。甚至是主键或者唯一键查找语句也可以转换为常数表达式。如果WHERE子句中使用了该类索引的常数条件,MySQL可以在查询开始阶段就先查找到这些值,这样优化器就能够知道并转换为常数表达式。下面是一个例子:\nMySQL分两步来执行这个查询,也就是上面执行计划的两行输出。第一步先从film表找到需要的行。因为在film_id字段上有主键索引,所以MySQL优化器知道这只会返回一行数据,优化器在生成执行计划的时候,就已经通过索引信息知道将返回多少行数据。因为优化器已经明确知道有多少个值(WHERE条件中的值)需要做索引查询,所以这里的表访问类型是const。\n在执行计划的第二步,MySQL将第一步中返回的film_id列当作一个已知取值的列来处理。因为优化器清楚在第一步执行完成后,该值就会是明确的了。注意到正如第一步中一样,使用flm_actor字段对表的访问类型也是const。\n另一种会看到常数条件的情况是通过等式将常数值从一个表传到另一个表,这可以通过WHERE、USING或者ON语句来限制某列取值为常数。在上面的例子中,因为使用了USING子句,优化器知道这也限制了film_id在整个查询过程中都始终是一个常量——因为它必须等于WHERE子句中的那个取值。\n覆盖索引扫描\n当索引中的列包含所有查询中需要使用的列的时候,MySQL就可以使用索引返回需要的数据,而无须查询对应的数据行,在前面的章节中我们已经讨论过这点了。\n子查询优化\nMySQL在某些情况下可以将子查询转换一种效率更高的形式,从而减少多个查询多次对数据进行访问。\n提前终止查询\n在发现已经满足查询需求的时候,MySQL总是能够立刻终止查询。一个典型的例子就是当使用了LIMIT子句的时候。除此之外,MySQL还有几类情况也会提前终止查询,例如发现了一个不成立的条件,这时MySQL可以立刻返回一个空结果。从下面的例子可以看到这一点:\n从这个例子看到查询在优化阶段就已经终止。除此之外,MySQL在执行过程中,如果发现某些特殊的条件,则会提前终止查询。当存储引擎需要检索“不同取值”或者判断存在性的时候,MySQL都可以使用这类优化。例如,我们现在需要找到没有演员的所有电影(13):\nmysql\u0026gt; ** SELECT film.film_id** -\u0026gt; ** FROM sakila.film** -\u0026gt; ** LEFT OUTER JOIN sakila.film_actor USING(film_id)** -\u0026gt; ** WHERE film_actor.film_id IS NULL;** 这个查询将会过滤掉所有有演员的电影。每一部电影可能会有很多的演员,但是上面的查询一旦找到任何一个,就会停止并立刻判断下一部电影,因为只要有一名演员,那么WHERE条件则会过滤掉这类电影。类似这种“不同值/不存在”的优化一般可用于DISTINCT、NOT EXIST()或者LEFT JOIN类型的查询。\n等值传播\n如果两个列的值通过等式关联,那么MySQL能够把其中一个列的WHERE条件传递到另一列上。例如,我们看下面的查询:\nmysql\u0026gt; ** SELECT film.film_id** -\u0026gt; ** FROM sakila.film** -\u0026gt; ** INNER JOIN sakila.film_actor USING(film_id)** -\u0026gt; ** WHERE film.film_id \u0026gt; 500** 因为这里使用了film_id字段进行等值关联,MySQL知道这里的WHERE子句不仅适用于flm表,而且对于flm_actor表同样适用。如果使用的是其他的数据库管理系统,可能还需要手动通过一些条件来告知优化器这个WHERE条件适用于两个表,那么写法就会如下:\n... WHERE film.film_id \u0026gt; 500 AND film_actor.film_id \u0026gt; 500 在MySQL中这是不必要的,这样写反而会让查询更难维护。\n列表IN()的比较\n在很多数据库系统中,IN()完全等同于多个OR条件的子句,因为这两者是完全等价的。在MySQL中这点是不成立的,MySQL将IN()列表中的数据先进行排序,然后通过二分查找的方式来确定列表中的值是否满足条件,这是一个O(log n)复杂度的操作,等价地转换成OR查询的复杂度为O(n),对于IN()列表中有大量取值的时候,MySQL的处理速度将会更快。\n上面列举的远不是MySQL优化器的全部,MySQL还会做大量其他的优化,即使本章全部用来描述也会篇幅不足,但上面的这些例子已经足以让大家明白优化器的复杂性和智能性了。如果说从上面这段讨论中我们应该学到什么,那就是“不要自以为比优化器更聪明”。最终你可能会占点便宜,但是更有可能会使查询变得更加复杂而难以维护,而最终的收益却为零。让优化器按照它的方式工作就可以了。\n当然,虽然优化器已经很智能了,但是有时候也无法给出最优的结果。有时候你可能比优化器更了解数据,例如,由于应用逻辑使得某些条件总是成立;还有时,优化器缺少某种功能特性,如哈希索引;再如前面提到的,从优化器的执行成本角度评估出来的最优执行计划,实际运行中可能比其他的执行计划更慢。\n如果能够确认优化器给出的不是最佳选择,并且清楚背后的原理,那么也可以帮助优化器做进一步的优化。例如,可以在查询中添加hint提示,也可以重写查询,或者重新设计更优的库表结构,或者添加更合适的索引。\n数据和索引的统计信息 # 重新回忆一下图1-1,MySQL架构由多个层次组成。在服务器层有查询优化器,却没有保存数据和索引的统计信息。统计信息由存储引擎实现,不同的存储引擎可能会存储不同的统计信息(也可以按照不同的格式存储统计信息)。某些引擎,例如Archive引擎,则根本就没有存储任何统计信息!\n因为服务器层没有任何统计信息,所以MySQL查询优化器在生成查询的执行计划时,需要向存储引擎获取相应的统计信息。存储引擎则提供给优化器对应的统计信息,包括:每个表或者索引有多少个页面、每个表的每个索引的基数是多少、数据行和索引长度、索引的分布信息等。优化器根据这些信息来选择一个最优的执行计划。在后面的小节中我们将看到统计信息是如何影响优化器的。\nMySQL如何执行关联查询 # MySQL中“关联”(14)一词所包含的意义比一般意义上理解的要更广泛。总的来说,MySQL认为任何一个查询都是一次“关联”——并不仅仅是一个查询需要到两个表匹配才叫关联,所以在MySQL中,每一个查询,每一个片段(包括子查询,甚至基于单表的SELECT)都可能是关联。\n所以,理解MySQL如何执行关联查询至关重要。我们先来看一个UNION查询的例子。对于UNION查询,MySQL先将一系列的单个查询结果放到一个临时表中,然后再重新读出临时表数据来完成UNION查询。在MySQL的概念中,每个查询都是一次关联,所以读取结果临时表也是一次关联。\n当前MySQL关联执行的策略很简单:MySQL对任何关联都执行嵌套循环关联操作,即MySQL先在一个表中循环取出单条数据,然后再嵌套循环到下一个表中寻找匹配的行,依次下去,直到找到所有表中匹配的行为止。然后根据各个表匹配的行,返回查询中需要的各个列。MySQL会尝试在最后一个关联表中找到所有匹配的行,如果最后一个联表无法找到更多的行以后,MySQL返回到上一层次关联表,看是否能够找到更多的匹配记录,依此类推迭代执行。(15)\n按照这样的方式查找第一个表记录,再嵌套查询下一个关联表,然后回溯到上一个表,在MySQL中是通过嵌套循环的方式实现——正如其名“嵌套循环关联”。请看下面的例子中的简单查询:\nmysql\u0026gt; ** SELECT tbl1.col1, tbl2.col2** -\u0026gt; ** FROM tbl1 INNER JOIN tbl2 USING(col3)** -\u0026gt; ** WHERE tbl1.col1 IN(5,6)** 假设MySQL按照查询中的表顺序进行关联操作,我们则可以用下面的伪代码表示MySQL将如何完成这个查询:\nouter_iter = iterator over tbl1 where col1 IN(5,6) outer_row = outer_iter.next while outer_row inner_iter = iterator over tbl2 where col3 = outer_row.col3 inner_row = inner_iter.next while inner_row output [ outer_row.col1, inner_row.col2 ] inner_row = inner_iter.next end outer_row = outer_iter.netxt end 上面的执行计划对于单表查询和多表关联查询都适用,如果是一个单表查询,那么只需完成上面外层的基本操作。对于外连接上面的执行过程仍然适用。例如,我们将上面查询修改如下:\nmysql\u0026gt; ** SELECT tbl1.col1, tbl2.col2** -\u0026gt; ** FROM tbl1 LEFT OUTER JOIN tbl2 USING(col3)** -\u0026gt; ** WHERE tbl1.col1 IN(5,6);** 对应的伪代码如下,我们用黑体标示不同的部分:\nouter_iter = iterator over tbl1 where col1 IN(5,6) outer_row = outer_iter.next while outer_row inner_iter = iterator over tbl2 where col3 = outer_row.col3 inner_row = inner_iter.next if inner_row while inner_row output [ outer_row.col1, inner_row.col2 ] inner_row = inner_iter.next end else output [ outer_row.col1, NULL ] end outer_row = outer_iter.next end 另一种可视化查询执行计划的方法是根据优化器执行的路径绘制出对应的“泳道图”。如图6-2所示,绘制了前面示例中内连接的泳道图,请从左至右,从上至下地看这幅图。\n图6-2:通过泳道图展示MySQL如何完成关联查询\n从本质上说,MySQL对所有的类型的查询都以同样的方式运行。例如,MySQL在FROM子句中遇到子查询时,先执行子查询并将其结果放到一个临时表中(16),然后将这个临时表当作一个普通表对待(正如其名“派生表”)。MySQL在执行UNION查询时也使用类似的临时表,在遇到右外连接的时候,MySQL将其改写成等价的左外连接。简而言之,当前版本的MySQL会将所有的查询类型都转换成类似的执行计划。(17)\n不过,不是所有的查询都可以转换成上面的形式。例如,全外连接就无法通过嵌套循环和回溯的方式完成,这时当发现关联表中没有找到任何匹配行的时候,则可能是因为关联是恰好从一个没有任何匹配的表开始。这大概也是MySQL并不支持全外连接的原因。还有些场景,虽然可以转换成嵌套循环的方式,但是效率却非常差,后面我们会看一个这样的例子。\n执行计划 # 和很多其他关系数据库不同,MySQL并不会生成查询字节码来执行查询。MySQL生成查询的一棵指令树,然后通过存储引擎执行完成这棵指令树并返回结果。最终的执行计划包含了重构查询的全部信息。如果对某个查询执行EXPLAIN EXTENDED后,再执行SHOW WARNINGS,就可以看到重构出的查询(18)。\n任何多表查询都可以使用一棵树表示,例如,可以按照图6-3执行一个四表的关联操作。\n图6-3:多表关联的一种方式\n在计算机科学中,这被称为一颗平衡树。但是,这并不是MySQL执行查询的方式。正如我们前面章节介绍的,MySQL总是从一个表开始一直嵌套循环、回溯完成所有表关联。所以,MySQL的执行计划总是如图6-4所示,是一棵左测深度优先的树。\n图6-4:MySQL如何实现多表关联\n关联查询优化器 # MySQL优化器最重要的一部分就是关联查询优化,它决定了多个表关联时的顺序。通常多表关联的时候,可以有多种不同的关联顺序来获得相同的执行结果。关联查询优化器则通过评估不同顺序时的成本来选择一个代价最小的关联顺序。\n下面的查询可以通过不同顺序的关联最后都获得相同的结果:\nmysql\u0026gt; ** SELECT film.film_id, film.title, film.release_year, actor.actor_id,** -\u0026gt; ** actor.first_name, actor.last_name** -\u0026gt; ** FROM sakila.film** -\u0026gt; ** INNER JOIN sakila.film_actor USING(film_id)** -\u0026gt; ** INNER JOIN sakila.actor USING(actor_id);** 容易看出,可以通过一些不同的执行计划来完成上面的查询。例如,MySQL可以从film表开始,使用film_actor表的索引film_id来查找对应的actor_id值,然后再根据actor表的主键找到对应的记录。Oracle用户会用下面的术语描述:“film表作为驱动表先查找fle_actor表,然后以此结果为驱动表再查找actor表”。这样做效率应该会不错,我们再使用EXPLAIN看看MySQL将如何执行这个查询:\n*************************** 1. row *************************** id: 1 select_type: SIMPLE table: actor type: ALL possible_keys: PRIMARY key: NULL key_len: NULL ref: NULL rows: 200 Extra: *************************** 2. row *************************** id: 1 select_type: SIMPLE table: film_actor type: ref possible_keys: PRIMARY,idx_fk_film_id key: PRIMARY key_len: 2 ref: sakila.actor.actor_id rows: 1 Extra: Using index *************************** 3. row *************************** id: 1 select_type: SIMPLE table: film type: eq_ref possible_keys: PRIMARY key: PRIMARY key_len: 2 ref: sakila.film_actor.film_id rows: 1 Extra: 这和我们前面给出的执行计划完全不同。MySQL从actor表开始(我们从上面的EXPLAIN结果的第一行输出可以看出这点),然后与我们前面的计划按照相反的顺序进行关联。这样是否效率更高呢?我们来看看,我们先使用STRAIGHT_JOIN关键字,按照我们之前的顺序执行,这里是对应的EXPLAIN输出结果:\nmysql\u0026gt; ** EXPLAIN SELECT STRAIGHT_JOIN film.film_id...\\G** *************************** 1. row *************************** id: 1 select_type: SIMPLE table: film type: ALL possible_keys: PRIMARY key: NULL key_len: NULL ref: NULL rows: 951 Extra: *************************** 2. row *************************** id: 1 select_type: SIMPLE table: film_actor type: ref possible_keys: PRIMARY,idx_fk_film_id key: idx_fk_film_id key_len: 2 ref: sakila.film.film_id rows: 1 Extra: Using index *************************** 3. row *************************** id: 1 select_type: SIMPLE table: actor type: eq_ref possible_keys: PRIMARY key: PRIMARY key_len: 2 ref: sakila.film_actor.actor_id rows: 1 Extra: 我们来分析一下为什么MySQL会将关联顺序倒转过来:可以看到,关联顺序倒转后的第一个关联表只需要扫描很少的行数(19)。在两种关联顺序下,第二个和第三个关联表都是根据索引查询,速度都很快,不同的是需要扫描的索引项的数量是不同的:\n将film表作为第一个关联表时,会找到951条记录,然后对film_actor和actor表进行嵌套循环查询。 如果MySQL选择首先扫描actor表,只会返回200条记录进行后面的嵌套循环查询。 换句话说,倒转的关联顺序会让查询进行更少的嵌套循环和回溯操作。为了验证优化器的选择是否正确,我们单独执行这两个查询,并且看看对应的Last_query_cost状态值。我们看到倒转的关联顺序的预估成本(20)为241,而原来的查询的预估成本为1 154。\n这个简单的例子主要想说明MySQL是如何选择合适的关联顺序来让查询执行的成本尽可能低的。重新定义关联的顺序是优化器非常重要的一部分功能。不过有的时候,优化器给出的并不是最优的关联顺序。这时可以使用STRAIGHT_JOIN关键字重写查询,让优化器按照你认为的最优的关联顺序执行——不过老实说,人的判断很难那么精准。绝大多数时候,优化器做出的选择都比普通人的判断要更准确。\n关联优化器会尝试在所有的关联顺序中选择一个成本最小的来生成执行计划树。如果可能,优化器会遍历每一个表然后逐个做嵌套循环计算每一棵可能的执行计划树的成本,最后返回一个最优的执行计划。\n不过,糟糕的是,如果有超过n个表的关联,那么需要检查n的阶乘种关联顺序。我们称之为所有可能的执行计划的“搜索空间”,搜索空间的增长速度非常块——例如,若是10个表的关联,那么共有3628800种不同的关联顺序!当搜索空间非常大的时候,优化器不可能逐一评估每一种关联顺序的成本。这时,优化器选择使用“贪婪”搜索的方式查找“最优”的关联顺序。实际上,当需要关联的表超过optimizer_search_depth的限制的时候,就会选择“贪婪”搜索模式了(optimizer_search_depth参数可以根据需要指定大小)。\n在MySQL这些年的发展过程中,优化器积累了很多“启发式”的优化策略来加速执行计划的生成。绝大多数情况下,这都是有效的,但因为不会去计算每一种关联顺序的成本,所以偶尔也会选择一个不是最优的执行计划。\n有时,各个查询的顺序并不能随意安排,这时关联优化器可以根据这些规则大大减少搜索空间,例如,左连接、相关子查询(后面我将继续讨论子查询)。这是因为,后面的表的查询需要依赖于前面表的查询结果。这种依赖关系通常可以帮助优化器大大减少需要扫描的执行计划数量。\n排序优化 # 无论如何排序都是一个成本很高的操作,所以从性能角度考虑,应尽可能避免排序或者尽可能避免对大量数据进行排序。\n在第3章中我们已经看到MySQL如何通过索引进行排序。当不能使用索引生成排序结果的时候,MySQL需要自己进行排序,如果数据量小则在内存中进行,如果数据量大则需要使用磁盘,不过MySQL将这个过程统一称为文件排序(filesort),即使完全是内存排序不需要任何磁盘文件时也是如此。\n如果需要排序的数据量小于“排序缓冲区”,MySQL使用内存进行“快速排序”操作。如果内存不够排序,那么MySQL会先将数据分块,对每个独立的块使用“快速排序”进行排序,并将各个块的排序结果存放在磁盘上,然后将各个排好序的块进行合并(merge),最后返回排序结果。\nMySQL有如下两种排序算法:\n两次传输排序(旧版本使用)\n读取行指针和需要排序的字段,对其进行排序,然后再根据排序结果读取所需要的数据行。\n这需要进行两次数据传输,即需要从数据表中读取两次数据,第二次读取数据的时候,因为是读取排序列进行排序后的所有记录,这会产生大量的随机I/O,所以两次数据传输的成本非常高。当使用的是MyISAM表的时候,成本可能会更高,因为MyISAM使用系统调用进行数据的读取(MyISAM非常依赖操作系统对数据的缓存)。不过这样做的优点是,在排序的时候存储尽可能少的数据,这就让“排序缓冲区”(21)中可能容纳尽可能多的行数进行排序。\n单次传输排序(新版本使用)\n先读取查询所需要的所有列,然后再根据给定列进行排序,最后直接返回排序结果。这个算法只在MySQL 4.1和后续更新的版本才引入。因为不再需要从数据表中读取两次数据,对于I/O密集型的应用,这样做的效率高了很多。另外,相比两次传输排序,这个算法只需要一次顺序I/O读取所有的数据,而无须任何的随机I/O。缺点是,如果需要返回的列非常多、非常大,会额外占用大量的空间,而这些列对排序操作本身来说是没有任何作用的。因为单条排序记录很大,所以可能会有更多的排序块需要合并。\n很难说哪个算法效率更高,两种算法都有各自最好和最糟的场景。当查询需要所有列的总长度不超过参数max_length_for_sort_data时,MySQL使用“单次传输排序”,可以通过调整这个参数来影响MySQL排序算法的选择。关于这个细节,可以参考第8章“文件排序优化”。\nMySQL在进行文件排序的时候需要使用的临时存储空间可能会比想象的要大得多。原因在于MySQL在排序时,对每一个排序记录都会分配一个足够长的定长空间来存放。\n这个定长空间必须足够长以容纳其中最长的字符串,例如,如果是VARCHAR列则需要分配其完整长度;如果使用UTF-8字符集,那么MySQL将会为每个字符预留三个字节。我们曾经在一个库表结构设计不合理的案例中看到,排序消耗的临时空间比磁盘上的原表要大很多倍。\n在关联查询的时候如果需要排序,MySQL会分两种情况来处理这样的文件排序。如果ORDER BY子句中的所有列都来自关联的第一个表,那么MySQL在关联处理第一个表的时候就进行文件排序。如果是这样,那么在MySQL的EXPLAIN结果中可以看到Extra字段会有“Using filesort”。除此之外的所有情况,MySQL都会先将关联的结果存放到一个临时表中,然后在所有的关联都结束后,再进行文件排序。这种情况下,在MySQL的EXPLAIN结果的Extra字段可以看到“Using temporary;Using filesort”。如果查询中有LIMIT的话,LIMIT也会在排序之后应用,所以即使需要返回较少的数据,临时表和需要排序的数据量仍然会非常大。\nMySQL 5.6在这里做了很多重要的改进。当只需要返回部分排序结果的时候,例如使用了LIMIT子句,MySQL不再对所有的结果进行排序,而是根据实际情况,选择抛弃不满足条件的结果,然后再进行排序。\n6.4.4 查询执行引擎 # 在解析和优化阶段,MySQL将生成查询对应的执行计划,MySQL的查询执行引擎则根据这个执行计划来完成整个查询。这里执行计划是一个数据结构,而不是和很多其他的关系型数据库那样会生成对应的字节码。\n相对于查询优化阶段,查询执行阶段不是那么复杂:MySQL只是简单地根据执行计划给出的指令逐步执行。在根据执行计划逐步执行的过程中,有大量的操作需要通过调用存储引擎实现的接口来完成,这些接口也就是我们称为“handler API”的接口。查询中的每一个表由一个handler的实例表示。前面我们有意忽略了这点,实际上,MySQL在优化阶段就为每个表创建了一个handler实例,优化器根据这些实例的接口可以获取表的相关信息,包括表的所有列名、索引统计信息,等等。\n存储引擎接口有着非常丰富的功能,但是底层接口却只有几十个,这些接口像“搭积木”一样能够完成查询的大部分操作。例如,有一个查询某个索引的第一行的接口,再有一个查询某个索引条目的下一个条目的功能,有了这两个功能我们就可以完成全索引扫描的操作了。这种简单的接口模式,让MySQL的存储引擎插件式架构成为可能,但是正如前面的讨论,也给优化器带来了一定的限制。\n并不是所有的操作都由handler完成。例如,当MySQL需要进行表锁的时候。handler可能会实现自己的级别的、更细粒度的锁,如InnoDB就实现了自己的行基本锁,但这并不能代替服务器层的表锁。正如我们第1章所介绍的,如果是所有存储引擎共有的特性则由服务器层实现,比如时间和日期函数、视图、触发器等。\n为了执行查询,MySQL只需要重复执行计划中的各个操作,直到完成所有的数据查询。\n6.4.5 返回结果给客户端 # 查询执行的最后一个阶段是将结果返回给客户端。即使查询不需要返回结果集给客户端,MySQL仍然会返回这个查询的一些信息,如该查询影响到的行数。\n如果查询可以被缓存,那么MySQL在这个阶段也会将结果存放到查询缓存中。\nMySQL将结果集返回客户端是一个增量、逐步返回的过程。例如,我们回头看看前面的关联操作,一旦服务器处理完最后一个关联表,开始生成第一条结果时,MySQL就可以开始向客户端逐步返回结果集了。\n这样处理有两个好处:服务器端无须存储太多的结果,也就不会因为要返回太多结果而消耗太多内存。另外,这样的处理也让MySQL客户端第一时间获得返回的结果(22)。\n结果集中的每一行都会以一个满足MySQL客户端/服务器通信协议的封包发送,再通过TCP协议进行传输,在TCP传输的过程中,可能对MySQL的封包进行缓存然后批量传输。\n6.5 MySQL查询优化器的局限性 # MySQL的万能“嵌套循环”并不是对每种查询都是最优的。不过还好,MySQL查询优化器只对少部分查询不适用,而且我们往往可以通过改写查询让MySQL高效地完成工作。还有一个好消息,MySQL 5.6版本正式发布后,会消除很多MySQL原本的限制,让更多的查询能够以尽可能高的效率完成。\n6.5.1 关联子查询 # MySQL的子查询实现得非常糟糕。最糟糕的一类查询是WHERE条件中包含IN()的子查询语句。例如,我们希望找到Sakila数据库中,演员Penelope Guiness(他的actor_id为1)参演过的所有影片信息。很自然的,我们会按照下面的方式用子查询实现:\nmysql\u0026gt; ** SELECT * FROM sakila.film** -\u0026gt; ** WHERE film_id IN(** -\u0026gt; ** SELECT film_id FROM sakila.film_actor WHERE actor_id = 1);** 因为MySQL对IN()列表中的选项有专门的优化策略,一般会认为MySQL会先执行子查询返回所有包含actor_id为1的film_id。一般来说,IN()列表查询速度很快,所以我们会认为上面的查询会这样执行:\n-- SELECT * FROM sakila.film-- SELECT GROUP_CONCAT(film_id) FROM sakila.film_actor WHERE actor_id = 1; -- Result: 1,23,25,106,140,166,277,361,438,499,506,509,605,635,749,832,939,970,980 SELECT * FROM sakila.film WHERE film_id IN(1,23,25,106,140,166,277,361,438,499,506,509,605,635,749,832,939,970,980); 很不幸,MySQL不是这样做的。MySQL会将相关的外层表压到子查询中,它认为这样可以更高效率地查找到数据行。也就是说,MySQL会将查询改写成下面的样子:\nSELECT * FROM sakila.film WHERE EXISTS ( SELECT * FROM sakila.film_actor WHERE actor_id = 1 AND film_actor.film_id = film.film_id); 这时,子查询需要根据film_id来关联外部表film,因为需要film_id字段,所以MySQL认为无法先执行这个子查询。通过EXPLAIN我们可以看到子查询是一个相关子查询(DEPENDENT SUBQUERY)(可以使用EXPLAIN EXTENDED来查看这个查询被改写成了什么样子):\n根据EXPLAIN的输出我们可以看到,MySQL先选择对file表进行全表扫描,然后根据返回的flm_id逐个执行子查询。如果是一个很小的表,这个查询糟糕的性能可能还不会引起注意,但是如果外层的表是一个非常大的表,那么这个查询的性能会非常糟糕。当然我们很容易用下面的办法来重写这个查询:\nmysql\u0026gt; ** SELECT film.* FROM sakila.film** -\u0026gt; ** INNER JOIN sakila.film_actor USING(film_id)** -\u0026gt; ** WHERE actor_id = 1;** 另一个优化的办法是使用函数GROUP_CONCAT()在IN()中构造一个由逗号分隔的列表。有时这比上面的使用关联改写更快。因为使用IN()加子查询,性能经常会非常糟,所以通常建议使用EXISTS()等效的改写查询来获取更好的效率。下面是另一种改写IN()加子查询的办法:\nmysql\u0026gt; ** SELECT * FROM sakila.film** -\u0026gt; ** WHERE EXISTS(** -\u0026gt; ** SELECT * FROM sakila.film_actor WHERE actor_id = 1** -\u0026gt; ** AND film_actor.film_id = film.film_id);** 这里讨论的优化器的限制直到Oracle推出的MySQL 5.5都一直存在。MySQL的另一个分支MariaDB则在原有的优化器的基础上做了大量的改进,例如这里提到的IN()加子查询改进。\n如何用好关联子查询 # 并不是所有关联子查询的性能都会很差。如果有人跟你说:“别用关联子查询”,那么不要理他。先测试,然后做出自己的判断。很多时候,关联子查询是一种非常合理、自然,甚至是性能最好的写法。我们看看下面的例子:\nmysql\u0026gt; ** EXPLAIN SELECT film_id, language_id FROM sakila.film** -\u0026gt; ** WHERE NOT EXISTS(** -\u0026gt; ** SELECT * FROM sakila.film_actor** -\u0026gt; ** WHERE film_actor.film_id = film.film_id** -\u0026gt; ** )\\G** *************************** 1. row *************************** id: 1 select_type: PRIMARY table: film type: ALL possible_keys: NULL key: NULL key_len: NULL ref: NULL rows: 951 Extra: Using where *************************** 2. row *************************** id: 2 select_type: DEPENDENT SUBQUERY table: film_actor type: ref possible_keys: idx_fk_film_id key: idx_fk_film_id key_len: 2 ref: film.film_id rows: 2 Extra: Using where; Using index 一般会建议使用左外连接(LEFT OUTER JOIN)重写该查询,以代替子查询。理论上,改写后MySQL的执行计划完全不会改变。我们来看这个例子:\nmysql\u0026gt; ** EXPLAIN SELECT film.film_id, film.language_id** -\u0026gt; ** FROM sakila.film** -\u0026gt; ** LEFT OUTER JOIN sakila.film_actor USING(film_id)** -\u0026gt; ** WHERE film_actor.film_id IS NULL\\G** *************************** 1. row *********************** id: 1 select_type: SIMPLE table: film type: ALL possible_keys: NULL key: NULL key_len: NULL ref: NULL rows: 951 Extra: *************************** 2. row ********************** id: 1 select_type: SIMPLE table: film_actor type: ref possible_keys: idx_fk_film_id key: idx_fk_film_id key_len: 2 ref: sakila.film.film_id rows: 2 Extra: Using where; Using index; Not exists 可以看到,这里的执行计划基本上一样,下面是一些微小的区别:\n表flm_actor的访问类型一个是DEPENDENT SUBQUERY,而另一个是SIMPLE。这个不同是由于语句的写法不同导致的,一个是普通查询,一个是子查询。这对底层存储引擎接口来说,没有任何不同。 对film表,第二个查询的Extra中没有“Using where”,但这不重要,第二个查询的USING子句和第一个查询的WHERE子句实际上是完全一样的。 在第二个表film_actor的执行计划的Extra列有“Not exists”。这是我们前面章节中提到的提前终止算法(early-termination algorithm),MySQL通过使用“Not exists”优化来避免在表film_actor的索引中读取任何额外的行。这完全等效于直接编写NOT EXISTS子查询,这个执行计划中也是一样,一旦匹配到一行数据,就立刻停止扫描。 所以,从理论上讲,MySQL将使用完全相同的执行计划来完成这个查询。现实世界中,我们建议通过一些测试来判断使用哪种写法速度会更快。针对上面的案例,我们对两种写法进行了测试,表6-1中列出了测试结果。\n表6-1:NOT EXISTS和左外连接的性能比较 查询 每秒查询数结果(QPS) NOT EXISTS 子查询 360 QPS LEFT OUTER JOIN 425 QPS\n我们的测试显示,使用子查询的写法要略微慢些!\n不过每个具体的案例会各有不同,有时候子查询写法也会快些。例如,当返回结果中只有一个表中的某些列的时候。听起来,这种情况对于关联查询效率也会很好。具体情况具体分析,例如下面的关联,我们希望返回所有包含同一个演员参演的电影,因为一个电影会有很多演员参演,所以可能会返回一些重复的记录:\nmysql\u0026gt; ** SELECT film.film_id FROM sakila.film** -\u0026gt; ** INNER JOIN sakila.film_actor USING(film_id);** 我们需要使用DISTINCT和GROUP BY来移除重复的记录:\nmysql\u0026gt; ** SELECT DISTINCT film.film_id FROM sakila.film** -\u0026gt; ** INNER JOIN sakila.film_actor USING(film_id);** 但是,回头看看这个查询,到底这个查询返回的结果集意义是什么?至少这样的写法会让SQL的意义很不明显。如果使用EXISTS则很容易表达“包含同一个参演演员”的逻辑,而且不需要使用DISTINCT和GROUP BY,也不会产生重复的结果集,我们知道一旦使用了DISTINCT和GROUP BY,那么在查询的执行过程中,通常需要产生临时中间表。下面我们用子查询的写法替换上面的关联:\nmysql\u0026gt; ** SELECT film_id FROM sakila.film** -\u0026gt; ** WHERE EXISTS(SELECT * FROM sakila.film_actor** -\u0026gt; ** WHERE film.film_id = film_actor.film_id);** 再一次,我们需要通过测试来对比这两种写法,哪个更快一些。测试结果参考表6-2。\n表6-2:EXISTS和关联性能对比 查询 每秒查询数结果(QPS) INNER JOIN 185 QPS EXISTS子查询 325 QPS\n在这个案例中,我们看到子查询速度要比关联查询更快些。\n通过上面这个详细的案例,主要想说明两点:一是不需要听取那些关于子查询的“绝对真理”,二是应该用测试来验证对子查询的执行计划和响应时间的假设。最后,关于子查询我们需要提到的是一个MySQL的bug。在MYSQL 5.1.48和之前的版本中,下面的写法会锁住table2中的一条记录:\nSELECT ... FROM table1 WHERE col = (SELECT ... FROM table2 WHERE ...); 如果遇到该bug,子查询在高并发情况下的性能,就会和在单线程测试时的性能相差甚远。这个bug的编号是46947,虽然这个问题已经被修复了,但是我们仍然要提醒读者:不要主观猜测,应该通过测试来验证猜想。\n6.5.2 UNION的限制 # 有时,MySQL无法将限制条件从外层“下推”到内层,这使得原本能够限制部分返回结果的条件无法应用到内层查询的优化上。\n如果希望UNION的各个子句能够根据LIMIT只取部分结果集,或者希望能够先排好序再合并结果集的话,就需要在UNION的各个子句中分别使用这些子句。例如,想将两个子查询结果联合起来,然后再取前20条记录,那么MySQL会将两个表都存放到同一个临时表中,然后再取出前20行记录:\n(SELECT first_name, last_name FROM sakila.actor ORDER BY last_name) UNION ALL (SELECT first_name, last_name FROM sakila.customer ORDER BY last_name) LIMIT 20; 这条查询将会把actor中的200条记录和customer表中的599条记录存放在一个临时表中,然后再从临时表中取出前20条。可以通过在UNION的两个子查询中分别加上一个LIMIT 20来减少临时表中的数据:\n(SELECT first_name, last_name FROM sakila.actor ORDER BY last_name LIMIT 20) UNION ALL (SELECT first_name, last_name FROM sakila.customer ORDER BY last_name LIMIT 20) LIMIT 20; 现在中间的临时表只会包含40条记录了,除了性能考虑之外,在这里还需要注意一点:从临时表中取出数据的顺序并不是一定的,所以如果想获得正确的顺序,还需要加上一个全局的ORDER BY和LIMIT操作。\n6.5.3 索引合并优化 # 在前面的章节已经讨论过,在5.0和更新的版本中,当WHERE子句中包含多个复杂条件的时候,MySQL能够访问单个表的多个索引以合并和交叉过滤的方式来定位需要查找的行。\n6.5.4 等值传递 # 某些时候,等值传递会带来一些意想不到的额外消耗。例如,有一个非常大的IN()列表,而MySQL优化器发现存在WHERE、ON或者USING的子句,将这个列表的值和另一个表的某个列相关联。\n那么优化器会将IN()列表都复制应用到关联的各个表中。通常,因为各个表新增了过滤条件,优化器可以更高效地从存储引擎过滤记录。但是如果这个列表非常大,则会导致优化和执行都会变慢。在本书写作的时候,除了修改MySQL源代码,目前还没有什么办法能够绕过该问题(不过这个问题很少会碰到)。\n6.5.5 并行执行 # MySQL无法利用多核特性来并行执行查询。很多其他的关系型数据库能够提供这个特性,但是MySQL做不到。这里特别指出是想告诉读者不要花时间去尝试寻找并行执行查询的方法。\n6.5.6 哈希关联 # 在本书写作的时候,MySQL并不支持哈希关联——MySQL的所有关联都是嵌套循环关联。不过,可以通过建立一个哈希索引来曲线地实现哈希关联。如果使用的是Memory存储引擎,则索引都是哈希索引,所以关联的时候也类似于哈希关联。可以参考第5章的“创建自定义哈希索引”部分。另外,MariaDB已经实现了真正的哈希关联。\n6.5.7 松散索引扫描**(23)** # 由于历史原因,MySQL并不支持松散索引扫描,也就无法按照不连续的方式扫描一个索引。通常,MySQL的索引扫描需要先定义一个起点和终点,即使需要的数据只是这段索引中很少数的几个,MySQL仍需要扫描这段索引中每一个条目。\n下面我们通过一个示例说明这点。假设我们有如下索引(a,b),有下面的查询:\nmysql\u0026gt; ** SELECT ... FROM tbl WHERE b BETWEEN 2 AND 3;** 因为索引的前导字段是列a,但是在查询中只指定了字段b,MySQL无法使用这个索引,从而只能通过全表扫描找到匹配的行,如图6-5所示。\n图6-5:MySQL通过全表扫描找到需要的记录\n了解索引的物理结构的话,不难发现还可以有一个更快的办法执行上面的查询。索引的物理结构(不是存储引擎的API)使得可以先扫描a列第一个值对应的b列的范围,然后再跳到a列第二个不同值扫描对应的b列的范围。图6-6展示了如果由MySQL来实现这个过程会怎样。\n图6-6:使用松散索引扫描效率会更高,但是MySQL现在还不支持这么做\n注意到,这时就无须再使用WHERE子句过滤,因为松散索引扫描已经跳过了所有不需要的记录。\n上面是一个简单的例子,除了松散索引扫描,新增一个合适的索引当然也可以优化上述查询。但对于某些场景,增加索引是没用的,例如,对于第一个索引列是范围条件,第二个索引列是等值条件的查询,靠增加索引就无法解决问题。\nMySQL 5.0之后的版本,在某些特殊的场景下是可以使用松散索引扫描的,例如,在一个分组查询中需要找到分组的最大值和最小值:\nmysql\u0026gt; ** EXPLAIN SELECT actor_id, MAX(film_id)** -\u0026gt; ** FROM sakila.film_actor** -\u0026gt; ** GROUP BY actor_id\\G** *************************** 1. row *************************** id: 1 select_type: SIMPLE table: film_actor type: range possible_keys: NULL key: PRIMARY key_len: 2 ref: NULL rows: 396 Extra: Using index for group-by 在EXPLAIN中的Extra字段显示“Using index for group-by”,表示这里将使用松散索引扫描,不过如果MySQL能写上“loose index probe”,相信会更好理解。\n在MySQL很好地支持松散索引扫描之前,一个简单的绕过问题的办法就是给前面的列加上可能的常数值。在前面索引案例学习的章节中,我们已经看到这样做的好处了。\n在MySQL 5.6之后的版本,关于松散索引扫描的一些限制将会通过“索引条件下推(index condition pushdown)”的方式解决。\n6.5.8 最大值和最小值优化 # 对于MIN()和MAX()查询,MySQL的优化做得并不好。这里有一个例子:\nmysql\u0026gt; ** SELECT MIN(actor_id) FROM sakila.actor WHERE first_name='PENELOPE';** 因为在first_name字段上并没有索引,因此MySQL将会进行一次全表扫描。如果MySQL能够进行主键扫描,那么理论上,当MySQL读到第一个满足条件的记录的时候,就是我们需要找的最小值了,因为主键是严格按照actor_id字段的大小顺序排列的。但是MySQL这时只会做全表扫描,我们可以通过查看SHOW STATUS的全表扫描计数器来验证这一点。一个曲线的优化办法是移除MIN(),然后使用LIMIT来将查询重写如下:\nmysql\u0026gt; ** SELECT actor_id FROM sakila.actor USE INDEX(PRIMARY)** -\u0026gt; ** WHERE first_name = 'PENELOPE' LIMIT 1;** 这个策略可以让MySQL扫描尽可能少的记录数。如果你是一个完美主义者,可能会说这个SQL已经无法表达她的本意了。一般我们通过SQL告诉服务器我们需要什么数据,由服务器来决定如何最优地获取数据,不过在这个案例中,我们其实是告诉MySQL如何去获取我们需要的数据,通过SQL并不能一眼就看出我们其实是想要一个最小值。确实如此,有时候为了获得更高的性能,我们不得不放弃一些原则。\n6.5.9 在同一个表上查询和更新 # MySQL不允许对同一张表同时进行查询和更新。这其实并不是优化器的限制,如果清楚MySQL是如何执行查询的,就可以避免这种情况。下面是一个无法运行的SQL,虽然这是一个符合标准的SQL语句。这个SQL语句尝试将两个表中相似行的数量记录到字段cnt中:\nmysql\u0026gt; ** UPDATE tbl AS outer_tbl** -\u0026gt; ** SET cnt = (** -\u0026gt; ** SELECT count(*) FROM tbl AS inner_tbl** -\u0026gt; ** WHERE inner_tbl.type = outer_tbl.type** -\u0026gt; ** );** ERROR 1093 (HY000): You can't specify target table 'outer_tbl' for update in FROM clause 可以通过使用生成表的形式来绕过上面的限制,因为MySQL只会把这个表当作一个临时表来处理。实际上,这执行了两个查询:一个是子查询中的SELECT语句,另一个是多表关联UPDATE,只是关联的表是一个临时表。子查询会在UPDATE语句打开表之前就完成,所以下面的查询将会正常执行:\nmysql\u0026gt; ** UPDATE tbl** -\u0026gt; ** INNER JOIN(www.it-eboo** -\u0026gt; ** SELECT type, count(*) AS cnt** -\u0026gt; ** FROM tbl** -\u0026gt; ** GROUP BY type** -\u0026gt; ** ) AS der USING(type)** -\u0026gt; ** SET tbl.cnt = der.cnt;** 6.6 查询优化器的提示(hint) # 如果对优化器选择的执行计划不满意,可以使用优化器提供的几个提示(hint)来控制最终的执行计划。下面将列举一些常见的提示,并简单地给出什么时候使用该提示。通过在查询中加入相应的提示,就可以控制该查询的执行计划。关于每个提示的具体用法,建议直接阅读MySQL官方手册。有些提示和版本有直接关系。可以使用的一些提示如下:\nHIGH_PRIORITY和LOW_PRIORITY\n这个提示告诉MySQL,当多个语句同时访问某一个表的时候,哪些语句的优先级相对高些、哪些语句的优先级相对低些。\nHIGH_PRIORITY用于SELECT语句的时候,MySQL会将此SELECT语句重新调度到所有正在等待表锁以便修改数据的语句之前。实际上MySQL是将其放在表的队列的最前面,而不是按照常规顺序等待。HIGH_PRIORITY还可以用于INSERT语句,其效果只是简单地抵消了全局LOW_PRIORITY设置对该语句的影响。\nLOW_PRIORITY则正好相反:它会让该语句一直处于等待状态,只要队列中还有需要访问同一个表的语句——即使是那些比该语句还晚提交到服务器的语句。这就像一个过于礼貌的人站在餐厅门口,只要还有其他顾客在等待就一直不进去,很明显这容易把自己给饿坏。LOW_PRIORITY提示在SELECT、INSERT、UPDATE和DELETE语句中都可以使用。\n这两个提示只对使用表锁的存储引擎有效,千万不要在InnoDB或者其他有细粒度锁机制和并发控制的引擎中使用。即使是在MyISAM中使用也要注意,因为这两个提示会导致并发插入被禁用,可能会严重降低性能。\nHIGH_PRIORITY和LOW_PRIORITY经常让人感到困惑。这两个提示并不会获取更多资源让查询“积极”工作,也不会少获取资源让查询“消极”工作。它们只是简单地控制了MySQL访问某个数据表的队列顺序。\nDELAYED\n这个提示对INSERT和REPLACE有效。MySQL会将使用该提示的语句立即返回给客户端,并将插入的行数据放入到缓冲区,然后在表空闲时批量将数据写入。日志系统使用这样的提示非常有效,或者是其他需要写入大量数据但是客户端却不需要等待单条语句完成I/O的应用。这个用法有一些限制:并不是所有的存储引擎都支持这样的做法;并且该提示会导致函数LAST_INSERT_ID()无法正常工作。\nSTRAIGHT_JOIN\n这个提示可以放置在SELECT语句的SELECT关键字之后,也可以放置在任何两个关联表的名字之间。第一个用法是让查询中所有的表按照在语句中出现的顺序进行关联。第二个用法则是固定其前后两个表的关联顺序。\n当MySQL没能选择正确的关联顺序的时候,或者由于可能的顺序太多导致MySQL无法评估所有的关联顺序的时候,STRAIGHT_JOIN都会很有用。在后面这种情况,MySQL可能会花费大量时间在“statistics”状态,加上这个提示则会大大减少优化器的搜索空间。\n可以先使用EXPLAIN语句来查看优化器选择的关联顺序,然后使用该提示来重写查询,再看看它的关联顺序。当你确定无论怎样的where条件,某个固定的关联顺序始终是最佳的时候,使用这个提示可以大大提高优化器的效率。但是在升级MySQL版本的时候,需要重新审视下这类查询,某些新的优化特性可能会因为该提示而失效。\nSQL_SMALL_RESULT和SQL_BIG_RESULT\n这两个提示只对SELECT语句有效。它们告诉优化器对GROUP BY或者DISTINCT查询如何使用临时表及排序。SQL_SMALL_RESULT告诉优化器结果集会很小,可以将结果集放在内存中的索引临时表,以避免排序操作。如果是SQL_BIG_RESULT,则告诉优化器结果集可能会非常大,建议使用磁盘临时表做排序操作。\nSQL_BUFFER_RESULT\n这个提示告诉优化器将查询结果放入到一个临时表,然后尽可能快地释放表锁。这和前面提到的由客户端缓存结果不同。当你没法使用客户端缓存的时候,使用服务器端的缓存通常很有效。带来的好处是无须在客户端上消耗太多的内存,还可以尽可能快地释放对应的表锁。代价是,服务器端将需要更多的内存。\nSQL_CACHE和SQL_NO_CACHE\n这个提示告诉MySQL这个结果集是否应该缓存在查询缓存中,下一章我们将详细介绍如何使用。\nSQL_CALC_FOUND_ROWS\n严格来说,这并不是一个优化器提示。它不会告诉优化器任何关于执行计划的东西。它会让MySQL返回的结果集包含更多的信息。查询中加上该提示MySQL会计算除去LIMIT子句后这个查询要返回的结果集的总数,而实际上只返回LIMIT要求的结果集。可以通过函数FOUND_ROW()获得这个值。(参阅后面的“SQL_CALC_FOUND_ROWS优化”部分,了解下为什么不应该使用该提示。)\nFOR UPDATE和LOCK IN SHARE MODE\n这也不是真正的优化器提示。这两个提示主要控制SELECT语句的锁机制,但只对实现了行级锁的存储引擎有效。使用该提示会对符合查询条件的数据行加锁。对于INSERT\u0026hellip;SELECT语句是不需要这两个提示的,因为对于MySQL 5.0和更新版本会默认给这些记录加上读锁。(可以禁用该默认行为,但不是个好主意,在后面关于复制和备份的章节中将解释这一点。)\n唯一内置的支持这两个提示的引擎就是InnoDB。另外需要记住的是,这两个提示会让某些优化无法正常使用,例如索引覆盖扫描。InnoDB不能在不访问主键的情况下排他地锁定行,因为行的版本信息保存在主键中。\n糟糕的是,这两个提示经常被滥用,很容易造成服务器的锁争用问题,后面章节我们将讨论这点。应该尽可能地避免使用这两个提示,通常都有其他更好的方式可以实现同样的目的。\nUSE INDEX、IGNORE INDEX和FORCE INDEX\n这几个提示会告诉优化器使用或者不使用哪些索引来查询记录(例如,在决定关联顺序的时候使用哪个索引)。在MySQL 5.0和更早的版本,这些提示并不会影响到优化器选择哪个索引进行排序和分组,在MyQL 5.1和之后的版本可以通过新增选项FOR ORDER BY和FOR GROUP BY来指定是否对排序和分组有效。\nFORCE INDEX和USE INDEX基本相同,除了一点:FORCE INDEX会告诉优化器全表扫描的成本会远远高于索引扫描,哪怕实际上该索引用处不大。当发现优化器选择了错误的索引,或者因为某些原因(比如在不使用ORDER BY的时候希望结果有序)要使用另一个索引时,可以使用该提示。在前面关于如何使用LIMIT高效地获取最小值的案例中,已经演示过这种用法。\n在MySQL 5.0和更新版本中,新增了一些参数用来控制优化器的行为:\noptimizer_search_depth\n这个参数控制优化器在穷举执行计划时的限度。如果查询长时间处于“Statistics”状态,那么可以考虑调低此参数。\noptimizer_prune_level\n该参数默认是打开的,这让优化器会根据需要扫描的行数来决定是否跳过某些执行计划。\noptimizer_switch\n这个变量包含了一些开启/关闭优化器特性的标志位。例如在MySQL 5.1中可以通过这个参数来控制禁用索引合并的特性。\n前两个参数是用来控制优化器可以走的一些“捷径”。这些捷径可以让优化器在处理非常复杂的SQL语句时,仍然可以很高效,但这也可能让优化器错过一些真正最优的执行计划。所以应该根据实际需要来修改这些参数。\nMySQL升级后的验证\n在优化器面前耍一些“小聪明”是不好的。这样做收效甚小,但是却给维护带来了很多额外的工作量。在MySQL版本升级的时候,这个问题就很突出了,你设置的“优化器提示”很可能会让新版的优化策略失效。\nMySQL 5.0版本引入了大量优化策略,在还没有正式发布的5.6版本中,优化器的改进也是近些年来最大的一次改进。如果要更新到这些版本,当然希望能够从这些改进中受益。\n新版MySQL基本上在各个方面都有非常大的改进,5.5和5.6这两个版本尤为突出。升级操作一般来说都很顺利,但仍然建议仔细检查各个细节,以防止一些边界情况影响你的应用程序。不过还好,要避免这些,你不需要付出太多的精力。使用Percona Toolkit中的pt-upgrade工具,就可以检查在新版本中运行的SQL是否与老版本一样,返回相同的结果。\n6.7 优化特定类型的查询 # 这一节,我们将介绍如何优化特定类型的查询。在本书的其他部分都会分散介绍这些优化技巧,不过这里将会汇总一下,以便参考和查阅。\n本节介绍的多数优化技巧都是和特定的版本有关的,所以对于未来MySQL的版本未必适用。毫无疑问,某一天优化器自己也会实现这里列出的部分或者全部优化技巧。\n6.7.1 优化COUNT()查询 # COUNT()聚合函数,以及如何优化使用了该函数的查询,很可能是MySQL中最容易被误解的前10个话题之一。在网上随便搜索一下就能看到很多错误的理解,可能比我们想象的多得多。\n在做优化之前,先来看看COUNT()函数真正的作用是什么。\nCOUNT()的作用 # COUNT()是一个特殊的函数,有两种非常不同的作用:它可以统计某个列值的数量,也可以统计行数。在统计列值时要求列值是非空的(不统计NULL)。如果在COUNT()的括号中指定了列或者列的表达式,则统计的就是这个表达式有值的结果数(24)。因为很多人对NULL理解有问题,所以这里很容易产生误解。如果想了解更多关于SQL语句中NULL的含义,建议阅读一些关于SQL语句基础的书籍。(关于这个话题,互联网上的一些信息是不够精确的。)\nCOUNT()的另一个作用是统计结果集的行数。当MySQL确认括号内的表达式值不可能为空时,实际上就是在统计行数。最简单的就是当我们使用COUNT(*)的时候,这种情况下通配符*并不会像我们猜想的那样扩展成所有的列,实际上,它会忽略所有的列而直接统计所有的行数。\n我们发现一个最常见的错误就是,在括号内指定了一个列却希望统计结果集的行数。如果希望知道的是结果集的行数,最好使用COUNT(*),这样写意义清晰,性能也会很好。\n关于MyISAM的神话 # 一个容易产生的误解就是:MyISAM的COUNT()函数总是非常快,不过这是有前提条件的,即只有没有任何WHERE条件的COUNT(*)才非常快,因为此时无须实际地去计算表的行数。MySQL可以利用存储引擎的特性直接获得这个值。如果MySQL知道某列col不可能为NULL值,那么MySQL内部会将COUNT(col)表达式优化为COUNT(*)。\n当统计带WHERE子句的结果集行数,可以是统计某个列值的数量时,MyISAM的COUNT()和其他存储引擎没有任何不同,就不再有神话般的速度了。所以在MyISAM引擎表上执行COUNT()有时候比别的引擎快,有时候比别的引擎慢,这受很多因素影响,要视具体情况而定。\n简单的优化 # 有时候可以使用MyISAM在COUNT(*)全表非常快的这个特性,来加速一些特定条件的COUNT()的查询。在下面的例子中,我们使用标准数据库world来看看如何快速查找到所有ID大于5的城市。可以像下面这样来写这个查询:\nmysql\u0026gt; ** SELECT COUNT(*) FROM world.City WHERE ID\u0026gt;5;** 通过SHOW STATUS的结果可以看到该查询需要扫描4097行数据。如果将条件反转一下,先查找ID小于等于5的城市数,然后用总城市数一减就能得到同样的结果,却可以将扫描的行数减少到5行以内:\nmysql\u0026gt; ** SELECT (SELECT COUNT(*) FROM world.City) - COUNT(*)** -\u0026gt; ** FROM world.City WHERE ID \u0026lt;= 5;** 这样做可以大大减少需要扫描的行数,是因为在查询优化阶段会将其中的子查询直接当作一个常数来处理,我们可以通过EXPLAIN来验证这点:\n在邮件组和IRC聊天频道中,通常会看到这样的问题:如何在同一个查询中统计同一个列的不同值的数量,以减少查询的语句量。例如,假设可能需要通过一个查询返回各种不同颜色的商品数量,此时不能使用OR语句(比如SELECT COUNT(color=\u0026lsquo;blue\u0026rsquo; OR color=\u0026lsquo;red\u0026rsquo;) FROM items;),因为这样做就无法区分不同颜色的商品数量;也不能在WHERE条件中指定颜色(比如SELECT COUNT(*) FROM items WHERE color=\u0026lsquo;blue\u0026rsquo; AND color=\u0026lsquo;RED\u0026rsquo;;),因为颜色的条件是互斥的。下面的查询可以在一定程度上解决这个问题(25)。\nmysql\u0026gt; ** SELECT SUM(IF(color = 'blue', 1, 0)) AS blue,SUM(IF(color = 'red', 1, 0))** -\u0026gt; ** AS red FROM items;** 也可以使用COUNT()而不是SUM()实现同样的目的,只需要将满足条件设置为真,不满足条件设置为NULL即可:\nmysql\u0026gt; ** SELECT COUNT(color = 'blue' OR NULL) AS blue, COUNT(color = 'red' OR NULL)** -\u0026gt; ** AS red FROM items;** 使用近似值 # 有时候某些业务场景并不要求完全精确的COUNT值,此时可以用近似值来代替。EXPLAIN出来的优化器估算的行数就是一个不错的近似值,执行EXPLAIN并不需要真正地去执行查询,所以成本很低。\n很多时候,计算精确值的成本非常高,而计算近似值则非常简单。曾经有一个客户希望我们统计他的网站的当前活跃用户数是多少,这个活跃用户数保存在缓存中,过期时间为30分钟,所以每隔30分钟需要重新计算并放入缓存。因此这个活跃用户数本身就不是精确值,所以使用近似值代替是可以接受的。另外,如果要精确统计在线人数,通常WHERE条件会很复杂,一方面需要剔除当前非活跃用户,另一方面还要剔除系统中某些特定ID的“默认”用户,去掉这些约束条件对总数的影响很小,但却可能很好地提升该查询的性能。更进一步地优化则可以尝试删除DISTINCT这样的约束来避免文件排序。这样重写过的查询要比原来的精确统计的查询快很多,而返回的结果则几乎相同。\n更复杂的优化 # 通常来说,COUNT()都需要扫描大量的行(意味着要访问大量数据)才能获得精确的结果,因此是很难优化的。除了前面的方法,在MySQL层面还能做的就只有索引覆盖扫描了。如果这还不够,就需要考虑修改应用的架构,可以增加汇总表(第4章已经介绍过),或者增加类似Memcached这样的外部缓存系统。可能很快你就会发现陷入到一个熟悉的困境,“快速,精确和实现简单”,三者永远只能满足其二,必须舍掉其中一个。\n6.7.2 优化关联查询 # 这个话题基本上整本书都在讨论,这里需要特别提到的是:\n确保ON或者USING子句中的列上有索引。在创建索引的时候就要考虑到关联的顺序。当表A和表B用列c关联的时候,如果优化器的关联顺序是B、A,那么就不需要在B表的对应列上建上索引。没有用到的索引只会带来额外的负担。一般来说,除非有其他理由,否则只需要在关联顺序中的第二个表的相应列上创建索引。 确保任何的GROUP BY和ORDER BY中的表达式只涉及到一个表中的列,这样MySQL才有可能使用索引来优化这个过程。 当升级MySQL的时候需要注意:关联语法、运算符优先级等其他可能会发生变化的地方。因为以前是普通关联的地方可能会变成笛卡儿积,不同类型的关联可能会生成不同的结果等。 6.7.3 优化子查询 # 关于子查询优化我们给出的最重要的优化建议就是尽可能使用关联查询代替,至少当前的MySQL版本需要这样。本章的前面章节已经详细介绍了这点。“尽可能使用关联”并不是绝对的,如果使用的是MySQL 5.6或更新的版本或者MariaDB,那么就可以直接忽略关于子查询的这些建议了。\n6.7.4 优化GROUP BY和DISTINCT # 在很多场景下,MySQL都使用同样的办法优化这两种查询,事实上,MySQL优化器会在内部处理的时候相互转化这两类查询。它们都可以使用索引来优化,这也是最有效的优化办法。\n在MySQL中,当无法使用索引的时候,GROUP BY使用两种策略来完成:使用临时表或者文件排序来做分组。对于任何查询语句,这两种策略的性能都有可以提升的地方。可以通过使用提示SQL_BIG_RESULT和SQL_SMALL_RESULT来让优化器按照你希望的方式运行。在本章的前面章节我们已经讨论了这点。\n如果需要对关联查询做分组(GROUP BY),并且是按照查找表中的某个列进行分组,那么通常采用查找表的标识列分组的效率会比其他列更高。例如下面的查询效率不会很好:\nmysql\u0026gt; ** SELECT actor.first_name, actor.last_name, COUNT(*)** -\u0026gt; ** FROM sakila.film_actor** -\u0026gt; ** INNER JOIN sakila.actor USING(actor_id)** -\u0026gt; ** GROUP BY actor.first_name, actor.last_name;** 如果查询按照下面的写法效率则会更高:\nmysql\u0026gt; ** SELECT actor.first_name, actor.last_name, COUNT(*)** -\u0026gt; ** FROM sakila.film_actor** -\u0026gt; ** INNER JOIN sakila.actor USING(actor_id)** -\u0026gt; ** GROUP BY film_actor.actor_id;** 使用actor.actor_id列分组的效率甚至会比使用film_actor.actor_id更好。这一点通过简单的测试即可验证。\n这个查询利用了演员的姓名和ID直接相关的特点,因此改写后的结果不受影响,但显然不是所有的关联语句的分组查询都可以改写成在SELECT中直接使用非分组列的形式的。甚至可能会在服务器上设置SQL_MODE来禁止这样的写法。如果是这样,也可以通过MIN()或者MAX()函数来绕过这种限制,但一定要清楚,SELECT后面出现的非分组列一定是直接依赖分组列,并且在每个组内的值是唯一的,或者是业务上根本不在乎这个值具体是什么:\nmysql\u0026gt; ** SELECT MIN(actor.first_name), MAX(actor.last_name), ...;** 较真的人可能会说这样写的分组查询是有问题的,确实如此。从MIN()或者MAX()函数的用法就可以看出这个查询是有问题的。但若更在乎的是MySQL运行查询的效率时这样做也无可厚非。如果实在较真的话也可以改写成下面的形式:\nmysql\u0026gt; ** SELECT actor.first_name, actor.last_name, c.cnt** -\u0026gt; ** FROM sakila.film_actor** -\u0026gt; ** INNER JOIN (** -\u0026gt; ** SELECT actor_id, COUNT(*) AS cnt** -\u0026gt; ** FROM sakila.film_actor** -\u0026gt; ** GROUP BY actor_id** -\u0026gt; ** ) AS c USING(actor_id) ;** 这样写更满足关系理论,但成本有点高,因为子查询需要创建和填充临时表,而子查询中创建的临时表是没有任何索引的(26)。\n在分组查询的SELECT中直接使用非分组列通常都不是什么好主意,因为这样的结果通常是不定的,当索引改变,或者优化器选择不同的优化策略时都可能导致结果不一样。我们碰到的大多数这种查询最后都导致了故障(因为MySQL不会对这类查询返回错误),而且这种写法大部分是由于偷懒而不是为优化而故意这么设计的。建议始终使用含义明确的语法。事实上,我们建议将MySQL的SQL_MODE设置为包含ONLY_FULL_GROUP_BY,这时MySQL会对这类查询直接返回一个错误,提醒你需要重写这个查询。\n如果没有通过ORDER BY子句显式地指定排序列,当查询使用GROUP BY子句的时候,结果集会自动按照分组的字段进行排序。如果不关心结果集的顺序,而这种默认排序又导致了需要文件排序,则可以使用ORDER BY NULL,让MySQL不再进行文件排序。也可以在GROUP BY子句中直接使用DESC或者ASC关键字,使分组的结果集按需要的方向排序。\n优化GROUP BY WITH ROLLUP # 分组查询的一个变种就是要求MySQL对返回的分组结果再做一次超级聚合。可以使用WITH ROLLUP子句来实现这种逻辑,但可能会不够优化。可以通过EXPLAIN来观察其执行计划,特别要注意分组是否是通过文件排序或者临时表实现的。然后再去掉WITH ROLLUP子句看执行计划是否相同。也可以通过本节前面介绍的优化器提示来固定执行计划。\n很多时候,如果可以,在应用程序中做超级聚合是更好的,虽然这需要返回给客户端更多的结果。也可以在FROM子句中嵌套使用子查询,或者是通过一个临时表存放中间数据,然后和临时表执行UNION来得到最终结果。\n最好的办法是尽可能的将WITH ROLLUP功能转移到应用程序中处理。\n6.7.5 优化LIMIT分页 # 在系统中需要进行分页操作的时候,我们通常会使用LIMIT加上偏移量的办法实现,同时加上合适的ORDER BY子句。如果有对应的索引,通常效率会不错,否则,MySQL需要做大量的文件排序操作。\n一个非常常见又令人头疼的问题就是,在偏移量非常大的时候(27),例如可能是LIMIT 1000,20这样的查询,这时MySQL需要查询10 020条记录然后只返回最后20条,前面10000条记录都将被抛弃,这样的代价非常高。如果所有的页面被访问的频率都相同,那么这样的查询平均需要访问半个表的数据。要优化这种查询,要么是在页面中限制分页的数量,要么是优化大偏移量的性能。\n优化此类分页查询的一个最简单的办法就是尽可能地使用索引覆盖扫描,而不是查询所有的列。然后根据需要做一次关联操作再返回所需的列。对于偏移量很大的时候,这样做的效率会提升非常大。考虑下面的查询:\nmysql\u0026gt; ** SELECT film_id, description FROM sakila.film ORDER BY title LIMIT 50, 5;** 如果这个表非常大,那么这个查询最好改写成下面的样子:\nmysql\u0026gt; ** SELECT film.film_id, film.description** -\u0026gt; ** FROM sakila.film** -\u0026gt; ** INNER JOIN (** -\u0026gt; ** SELECT film_id FROM sakila.film** -\u0026gt; ** ORDER BY title LIMIT 50, 5** -\u0026gt; ** ) AS lim USING(film_id);** 这里的“延迟关联”将大大提升查询效率,它让MySQL扫描尽可能少的页面,获取需要访问的记录后再根据关联列回原表查询需要的所有列。这个技术也可以用于优化关联查询中的LIMIT子句。\n有时候也可以将LIMIT查询转换为已知位置的查询,让MySQL通过范围扫描获得到对应的结果。例如,如果在一个位置列上有索引,并且预先计算出了边界值,上面的查询就可以改写为:\nmysql\u0026gt; ** SELECT film_id, description FROM sakila.film** -\u0026gt; ** WHERE position BETWEEN 50 AND 54 ORDER BY position;** 对数据进行排名的问题也与此类似,但往往还会同时和GROUP BY混合使用。在这种情况下通常都需要预先计算并存储排名信息。\nLIMIT和OFFSET的问题,其实是OFFSET的问题,它会导致MySQL扫描大量不需要的行然后再抛弃掉。如果可以使用书签记录上次取数据的位置,那么下次就可以直接从该书签记录的位置开始扫描,这样就可以避免使用OFFSET。例如,若需要按照租借记录做翻页,那么可以根据最新一条租借记录向后追溯,这种做法可行是因为租借记录的主键是单调增长的。首先使用下面的查询获得第一组结果:\nmysql\u0026gt; ** SELECT * FROM sakila.rental** -\u0026gt; ** ORDER BY rental_id DESC LIMIT 20;** 假设上面的查询返回的是主键为16049到16030的租借记录,那么下一页查询就可以从16030这个点开始:\nmysql\u0026gt; ** SELECT * FROM sakila.rental** -\u0026gt; ** WHERE rental_id \u0026lt; 16030** -\u0026gt; ** ORDER BY rental_id DESC LIMIT 20;** 该技术的好处是无论翻页到多么后面,其性能都会很好。\n其他优化办法还包括使用预先计算的汇总表,或者关联到一个冗余表,冗余表只包含主键列和需要做排序的数据列。还可以使用Sphinx优化一些搜索操作,参考附录F可以获得更多相关信息。\n6.7.6 优化SQL_CALC_FOUND_ROWS # 分页的时候,另一个常用的技巧是在LIMIT语句中加上SQL_CALC_FOUND_ROWS提示(hint),这样就可以获得去掉LIMIT以后满足条件的行数,因此可以作为分页的总数。看起来,MySQL做了一些非常“高深”的优化,像是通过某种方法预测了总行数。但实际上,MySQL只有在扫描了所有满足条件的行以后,才会知道行数,所以加上这个提示以后,不管是否需要,MySQL都会扫描所有满足条件的行,然后再抛弃掉不需要的行,而不是在满足LIMIT的行数后就终止扫描。所以该提示的代价可能非常高。\n一个更好的设计是将具体的页数换成“下一页”按钮,假设每页显示20条记录,那么我们每次查询时都是用LIMIT返回21条记录并只显示20条,如果第21条存在,那么我们就显示“下一页”按钮,否则就说明没有更多的数据,也就无须显示“下一页”按钮了。\n另一种做法是先获取并缓存较多的数据——例如,缓存1000条——然后每次分页都从这个缓存中获取。这样做可以让应用程序根据结果集的大小采取不同的策略,如果结果集少于1000,就可以在页面上显示所有的分页链接,因为数据都在缓存中,所以这样做性能不会有问题。如果结果集大于1000,则可以在页面上设计一个额外的“找到的结果多于1000条”之类的按钮。这两种策略都比每次生成全部结果集再抛弃掉不需要的数据的效率要高很多。\n有时候也可以考虑使用EXPLAIN的结果中的rows列的值来作为结果集总数的近似值(实际上Google的搜索结果总数也是个近似值)。当需要精确结果的时候,再单独使用COUNT(*)来满足需求,这时如果能够使用索引覆盖扫描则通常也会比SQL_CALC_FOUND_ROWS快得多。\n6.7.7 优化UNION查询 # MySQL总是通过创建并填充临时表的方式来执行UNION查询。因此很多优化策略在UNION查询中都没法很好地使用。经常需要手工地将WHERE、LIMIT、ORDER BY等子句“下推”到UNION的各个子查询中,以便优化器可以充分利用这些条件进行优化(例如,直接将这些子句冗余地写一份到各个子查询)。\n除非确实需要服务器消除重复的行,否则就一定要使用UNION ALL,这一点很重要。如果没有ALL关键字,MySQL会给临时表加上DISTINCT选项,这会导致对整个临时表的数据做唯一性检查。这样做的代价非常高。即使有ALL关键字,MySQL仍然会使用临时表存储结果。事实上,MySQL总是将结果放入临时表,然后再读出,再返回给客户端。虽然很多时候这样做是没有必要的(例如,MySQL可以直接把这些结果返回给客户端)。\n6.7.8 静态查询分析 # Percona Toolkit中的pt-query-advisor能够解析查询日志、分析查询模式,然后给出所有可能存在潜在问题的查询,并给出足够详细的建议。这像是给MySQL所有的查询做一次全面的健康检查。它能检测出许多常见的问题,诸如我们前面介绍的内容。\n6.7.9 使用用户自定义变量 # 用户自定义变量是一个容易被遗忘的MySQL特性,但是如果能够用好,发挥其潜力,在某些场景可以写出非常高效的查询语句。在查询中混合使用过程化和关系化逻辑的时候,自定义变量可能会非常有用。单纯的关系查询将所有的东西都当成无序的数据集合,并且一次性操作它们。MySQL则采用了更加程序化的处理方式。MySQL的这种方式有它的弱点,但如果能熟练地掌握,则会发现其强大之处,而用户自定义变量也可以给这种方式带来很大的帮助。\n用户自定义变量是一个用来存储内容的临时容器,在连接MySQL的整个过程中都存在。可以使用下面的SET和SELECT语句来定义它们(28):\nmysql\u0026gt; ** SET @one := 1;** mysql\u0026gt; ** SET @min_actor := (SELECT MIN(actor_id) FROM sakila.actor);** mysql\u0026gt; ** SET @last_week := CURRENT_DATE-INTERVAL 1 WEEK;** 然后可以在任何可以使用表达式的地方使用这些自定义变量:\nmysql\u0026gt; ** SELECT ... WHERE col\u0026lt;=@last_week;** 在了解自定义变量的强大之前,我们再看看它自身的一些属性和限制,看看在哪些场景下我们不能使用用户自定义变量:\n使用自定义变量的查询,无法使用查询缓存。 不能在使用常量或者标识符的地方使用自定义变量,例如表名、列名和LIMIT子句中。 用户自定义变量的生命周期是在一个连接中有效,所以不能用它们来做连接间的通信。 如果使用连接池或者持久化连接,自定义变量可能让看起来毫无关系的代码发生交互(如果是这样,通常是代码bug或者连接池bug,这类情况确实可能发生)。 在5.0之前的版本,是大小写敏感的,所以要注意代码在不同MySQL版本间的兼容性问题。 不能显式地声明自定义变量的类型。确定未定义变量的具体类型的时机在不同MySQL版本中也可能不一样。如果你希望变量是整数类型,那么最好在初始化的时候就赋值为0,如果希望是浮点型则赋值为0.0,如果希望是字符串则赋值为\u0026rsquo;\u0026rsquo;,用户自定义变量的类型在赋值的时候会改变。MySQL的用户自定义变量是一个动态类型。 MySQL优化器在某些场景下可能会将这些变量优化掉,这可能导致代码不按预想的方式运行。 赋值的顺序和赋值的时间点并不总是固定的,这依赖于优化器的决定。实际情况可能很让人困惑,后面我们将看到这一点。 赋值符号:=的优先级非常低,所以需要注意,赋值表达式应该使用明确的括号。使用未定义变量不会产生任何语法错误,如果没有意识到这一点,非常容易犯错。 优化排名语句 # 使用用户自定义变量(29)的一个重要特性是你可以在给一个变量赋值的同时使用这个变量。换句话说,用户自定义变量的赋值具有“左值”特性。下面的例子展示了如何使用变量来实现一个类似“行号(row number)”的功能:\n这个例子的实际意义并不大,它只是实现了一个和该表主键一样的列。不过,我们也可以把这当作一个排名。现在我们来看一个更复杂的用法。我们先编写一个查询获取演过最多电影的前10位演员,然后根据他们的出演电影次数做一个排名,如果出演的电影数量一样,则排名相同。我们先编写一个查询,返回每个演员参演电影的数量:\n现在我们再把排名加上去,这里看到有四名演员都参演了35部电影,所以他们的排名应该是相同的。我们使用三个变量来实现:一个用来记录当前的排名,一个用来记录前一个演员的排名,还有一个用来记录当前演员参演的电影数量。只有当前演员参演的电影的数量和前一个演员不同时,排名才变化。我们先试试下面的写法:\nOops——排名和统计列一直都无法更新,这是什么原因?\n对这类问题,是没法给出一个放之四海皆准的答案的,例如,一个变量名的拼写错误就可能导致这样的问题(这个案例中并不是这个原因),具体问题要具体分析。这里,通过EXPLAIN我们看到将会使用临时表和文件排序,所以可能是由于变量赋值的时间和我们预料的不同。\n在使用用户自定义变量的时候,经常会遇到一些“诡异”的现象,要揪出这些问题的原因通常都不容易,但是相比其带来的好处,深究这些问题是值得的。使用SQL语句生成排名值通常需要做两次计算,例如,需要额外计算一次出演过相同数量电影的演员有哪些。使用变量则可一次完成——这对性能是一个很大的提升。\n针对这个案例,另一个简单的方案是在FROM子句中使用子查询生成一个中间的临时表:\n避免重复查询刚刚更新的数据 # 如果在更新行的同时又希望获得该行的信息,要怎么做才能避免重复的查询呢?不幸的是,MySQL并不支持像PostgreSQL那样的UPDATE RETURNING语法,这个语法可以帮你在更新行的时候同时返回该行的信息。还好在MySQL中你可以使用变量来解决这个问题。例如,我们的一个客户希望能够更高效地更新一条记录的时间戳,同时希望查询当前记录中存放的时间戳是什么。简单地,可以用下面的代码来实现:\nUPDATE t1 SET lastUpdated = NOW() WHERE id = 1; SELECT lastUpdated FROM t1 WHERE id = 1; 使用变量,我们可以按如下方式重写查询:\nUPDATE t1 SET lastUpdated = NOW() WHERE id = 1 AND @now := NOW(); SELECT @now; 上面看起来仍然需要两个查询,需要两次网络来回,但是这里的第二个查询无须访问任何数据表,所以会快非常多。(如果网络延迟非常大,那么这个优化的意义可能不大,不过对这个客户,这样做的效果很好。)\n统计更新和插入的数量 # 当使用了INSERT ON DUPLICATE KEY UPDATE的时候,如果想知道到底插入了多少行数据,到底有多少数据是因为冲突而改写成更新操作的?Kerstian Köhntopp在他的博客上给出了一个解决这个问题的办法(30)。实现办法的本质如下:\nINSERT INTO t1(c1, c2) VALUES(4, 4), (2, 1), (3, 1) ON DUPLICATE KEY UPDATE c1 = VALUES(c1) + ( 0 * ( @x := @x +1 ) ); 当每次由于冲突导致更新时对变量@x自增一次。然后通过对这个表达式乘以0来让其不影响要更新的内容。另外,MySQL的协议会返回被更改的总行数,所以不需要单独统计这个值。\n确定取值的顺序 # 使用用户自定义变量的一个最常见的问题就是没有注意到在赋值和读取变量的时候可能是在查询的不同阶段。例如,在SELECT子句中进行赋值然后在WHERE子句中读取变量,则可能变量取值并不如你所想。下面的查询看起来只返回一个结果,但事实并非如此:\n因为WHERE和SELECT是在查询执行的不同阶段被执行的。如果在查询中再加入ORDER BY的话,结果可能会更不同:\nmysql\u0026gt; ** SET @rownum := 0;** mysql\u0026gt; ** SELECT actor_id, @rownum := @rownum + 1 AS cnt** -\u0026gt; ** FROM sakila.actor** -\u0026gt; ** WHERE @rownum \u0026lt;= 1** -\u0026gt; ** ORDER BY first_name;** 这是因为ORDER BY引入了文件排序,而WHERE条件是在文件排序操作之前取值的,所以这条查询会返回表中的全部记录。解决这个问题的办法是让变量的赋值和取值发生在执行查询的同一阶段:\n小测试:如果在上面的查询中再加上ORDER BY,那会返回什么结果?试试看吧。如果得出的结果出乎你的意料,想想为什么?再看下面这个查询会返回什么,下面的查询中ORDER BY子句会改变变量值,那WHERE语句执行时变量值是多少。\nmysql\u0026gt; ** SET @rownum := 0;** mysql\u0026gt; ** SELECT actor_id, first_name, @rownum AS rownum** -\u0026gt; ** FROM sakila.actor** -\u0026gt; ** WHERE @rownum \u0026lt;= 1** -\u0026gt; ** ORDER BY first_name, LEAST(0, @rownum := @rownum + 1);** 这个最出人意料的变量行为的答案可以在EXPLAIN语句中找到,注意看在Extra列中的“Using where”、“Using temporary”或者“Using filesort”。\n在上面的最后一个例子中,我们引入了一个新的技巧:我们将赋值语句放到LEAST()函数中,这样就可以在完全不改变排序顺序的时候完成赋值操作(在上面例子中,LEAST()函数总是返回0)。这个技巧在不希望对子句的执行结果有影响却又要完成变量赋值的时候很有用。这个例子中,无须在返回值中新增额外列。这样的函数还有GREATEST()、LENGHT()、ISNULL()、NULLIFL()、IF()和COALESCE(),可以单独使用也可以组合使用。例如,COALESCE()可以在一组参数中取第一个已经被定义的变量。\n编写偷懒的UNION # 假设需要编写一个UNION查询,其第一个子查询作为分支条件先执行,如果找到了匹配的行,则跳过第二个分支。在某些业务场景中确实会有这样的需求,比如先在一个频繁访问的表中查找“热”数据,找不到再去另外一个较少访问的表中查找“冷”数据。(区分热数据和冷数据是一个很好的提高缓存命中率的办法)。\n下面的查询会在两个地方查找一个用户——一个主用户表、一个长时间不活跃的用户表,不活跃用户表的目的是为了实现更高效的归档(31):\nSELECT id FROM users WHERE id=123 UNION ALL SELECT id FROM users_archived WHERE id=123; 上面这个查询是可以正常工作的,但是即使在users表中已经找到了记录,上面的查询还是会去归档表users_archived中再查找一次。我们可以用一个偷懒的UNION查询来抑制这样的数据返回,而且只有当第一个表中没有数据时,我们才在第二个表中查询。一旦在第一个表中找到记录,我们就定义一个变量@found。我们通过在结果列中做一次赋值来实现,然后将赋值放在函数GREATEST中来避免返回额外的数据。为了明确我们的结果到底来自哪个表,我们新增了一个包含表名的列。最后我们需要在查询的末尾将变量重置为NULL,这样保证遍历时不干扰后面的结果。完成的查询如下:\nSELECT GREATEST(@found := −1, id) AS id, 'users' AS which_tbl FROM users WHERE id = 1 UNION ALL SELECT id, 'users_archived' FROM users_archived WHERE id = 1 AND @found IS NULL UNION ALL SELECT 1, 'reset' FROM DUAL WHERE ( @found := NULL ) IS NOT NULL; 用户自定义变量的其他用处 # 不仅是在SELECT语句中,在其他任何类型的SQL语句中都可以对变量进行赋值。事实上,这也是用户自定义变量最大的用途。例如,可以像前面使用子查询的方式改进排名语句一样来改进UPDATE语句。\n不过,我们需要使用一些技巧来获得我们希望的结果。有时,优化器会把变量当作一个编译时常量来对待,而不是对其进行赋值。将函数放在类似于LEAST()这样的函数中通常可以避免这样的问题。另一个办法是在查询被执行前检查变量是否被赋值。不同的场景下使用不同的办法。\n通过一些实践,可以了解所有用户自定义变量能够做的有趣的事情,例如下面这些用法:\n查询运行时计算总数和平均值。 模拟GROUP语句中的函数FIRST()和LAST()。 对大量数据做一些数据计算。 计算一个大表的MD5散列值。 编写一个样本处理函数,当样本中的数值超过某个边界值的时候将其变成0。 模拟读/写游标。 在SHOW语句的WHERE子句中加入变量值。 C.J. DATE的难题\nC.J. DATE建议在使用数据库设计方法时尽量让SQL数据库符合传统关系数据库的要求。这也是根据关系模型设计SQL时的初衷,但坦白地说,在这一点上,MySQL远不如其他数据库管理系统做得好。所以如果按照C.J. DATE书中的建议编写的适合关系模型的SQL语句在MySQL中运行的效率并不高,例如编写一个多层的子查询。很不幸,这是因为MySQL本身的限制导致无法按照标准的模式运行。我们强烈建议你阅读这本书SQL and Relational Theory:How to Write Accurate SQL Code( http://shop.xreilly.com/product/0636920022879.do)(O\u0026rsquo;Reilly出版),它将改变你对SQL语句的认识。\n6.8 案例学习 # 通常,我们要做的不是查询优化,不是库表结构优化,不是索引优化也不是应用设计优化——在实践中可能要面对所有这些搅和在一起的情况。本节的案例将为大家介绍一些经常困扰用户的问题和解决方法。另外我们还要推荐Bill Karwin的书SQL Antipatterns(一本实践型的书籍)。它将介绍如何使用SQL解决各种程序员疑难杂症。\n6.8.1 使用MySQL构建一个队列表 # 使用MySQL来实现队列表是一个取巧的做法,我们看到很多系统在高流量、高并发的情况下表现并不好。典型的模式是一个表包含多种类型的记录:未处理记录、已处理记录、正在处理记录等。一个或者多个消费者线程在表中查找未处理的记录,然后声称正在处理,当处理完成后,再将记录更新成已处理状态。一般的,例如邮件发送、多命令处理、评论修改等会使用类似模式。\n通常有两个原因使得大家认为这样的处理方式并不合适。第一,随着队列表越来越大和索引深度的增加,找到未处理记录的速度会随之变慢。你可以通过将队列表分成两部分来解决这个问题,就是将已处理记录归档或者存放到历史表,这可以始终保证队列表很小。\n第二,一般的处理过程分两步,先找到未处理记录然后加锁。找到记录会增加服务器的压力,而加锁操作则会让各个消费者进程增加竞争,因为这是一个串行化的操作。在第11章,我们会看到这为什么会限制可扩展性。\n找到未处理记录一般来说都没问题,如果有问题则可以通过使用消息的方式来通知各个消费者。具体的,可以使用一个带有注释的SLEEP()函数做超时处理,如下:\nSELECT /* waiting on unsent_emails */ SLEEP (10000); 这让线程一直阻塞,直到两个条件之一满足:10000秒后超时,或者另一个线程使用KILL QUERY结束当前的SLEEP。因此,当再向队列表中新增一批数据后,可以通过SHOW PROCESSLIST,根据注释找到当前正在休眠的线程,并将其KILL。你可以使用函数GET_LOCK()和RELEASE_LOCK()来实现通知,或者可以在数据库之外实现,例如使用一个消息服务。\n最后需要解决的问题是如何让消费者标记正在处理的记录,而不至于让多个消费者重复处理一个记录。我们看到大家一般使用SELECT FOR UPDATE来实现。这通常是扩展性问题的根源,这会导致大量的事务阻塞并等待。\n一般,我们要尽量避免使用SELECT FOR UPDATE。不光是队列表,任何情况下都要尽量避免。总是有别的更好的办法实现你的目的。在队列表的案例中,可以直接使用UPDATE来更新记录,然后检查是否还有其他的记录需要处理。我们看看具体实现,我们先建立如下的表:\nCREATE TABLE unsent_emails ( id INT NOT NULL PRIMARY KEY AUTO_INCREMENT -- columns for the message, from, to, subject, etc. status ENUM('unsent', 'claimed', 'sent'), owner INT UNSIGNED NOT NULL DEFAULT 0, ts TIMESTAMP, KEY (owner, status, ts) ); 该表的列owner用来存储当前正在处理这个记录的连接ID,即由函数CONNECTION_ID()返回的ID。如果当前记录没有被任何消费者处理,则该值为0。\n我们还经常看到的一个办法是,如下面所示的一次处理10条记录:\nBEGIN; SELECT id FROM unsent_emails LIMIT 10 FOR UPDATE; -- result: 123, 456, 789 UPDATE unsent_emails SET status = 'claimed', owner = CONNECTION_ID() WHERE id IN(123, 456, 789); COMMIT; 看到这里的SELECT查询可以使用到索引的两个列,因此理论上查找的效率应该更快。问题是,在上面两个查询之间的“间隙时间”,这里的锁会让所有其他同样的查询全部都被阻塞。所有的这样的查询将使用相同的索引,扫描索引相同的部分,所以很可能会被阻塞。\n如果改进成下面的写法,则会更加高效:\nSET AUTOCOMMIT = 1; COMMIT; UPDATE unsent_emails SET status = 'claimed', owner = CONNECTION_ID() WHERE owner = 0 AND status = 'unsent' LIMIT 10; SET AUTOCOMMIT = 0; SELECT id FROM unsent_emails WHERE owner = CONNECTION_ID() AND status = 'claimed'; -- result: 123, 456, 789 根本就无须使用SELECT查询去找到哪些记录还没有被处理。客户端的协议会告诉你更新了几条记录,所以可以知道这次需要处理多少条记录。\n所有的SELECT FOR UPDATE都可以使用类似的方法改写。\n最后还需要处理一种特殊情况:那些正在被进程处理,而进程本身却由于某种原因退出的情况。这种情况处理起来很简单。你只需要定期运行UPDATE语句将它都更新成原始状态就可以了,然后执行SHOW PROCESSLIST,获取当前正在工作的线程ID,并使用一些WHERE条件避免取到那些刚开始处理的进程。假设我们获取的线程ID有(10、20、30),下面的更新语句会将处理时间超过10分钟的记录状态都更新成初始状态:\nUPDATE unsent_emails SET owner = 0, status = \u0026#39;unsent\u0026#39; WHERE owner NOT IN(0, 10, 20, 30) AND status = \u0026#39;claimed\u0026#39; AND ts \u0026lt; CURRENT_TIMESTAMP - INTERVAL 10 MINUTE; 另外,注意看看是如何巧妙地设计索引让这个查询更加高效的。这也是上一章和本章知识的结合。因为我们将范围条件放在WHERE条件的末尾,这个查询恰好能够使用索引的全部列。其他的查询也都能用上这个索引,这就避免了再新增一个额外的索引来满足其他的查询。\n这里我们将总结一下这个案例中的一些基础原则:\n尽量少做事,可以的话就不要做任何事情。除非不得已,否则不要使用轮询,因为这会增加负载,而且还会带来很多低产出的工作。 尽可能快地完成需要做的事情。尽量使用UPDATE代替先SELECT FOR UPDATE再UPDATE的写法,因为事务提交的速度越快,持有的锁时间就越短,可以大大减少竞争和加速串行执行效率。将已经处理完成和未处理的数据分开,保证数据集足够小。 这个案例的另一个启发是,某些查询是无法优化的;考虑使用不同的查询或者不同的策略去实现相同的目的。通常对于SELECT FOR UPDATE就需要这样处理。 有时,最好的办法就是将任务队列从数据库中迁移出来。Redis就是一个很好的队列容器,也可以使用memcached来实现。另一个选择是使用Q4M存储引擎,但我们没有在生产环境使用过这个存储引擎,所以这里也没办法提供更多的参考。RabbitMQ和Gearman(32)也可以实现类似的功能。\n6.8.2 计算两点之间的距离 # 地理信息计算再次出现在我们的书中了。不建议用户使用MySQL做太复杂的空间信息存储——PostgreSQL在这方面是不错的选择——我们这里将介绍一些常用的计算模式。一个典型的例子是计算以某个点为中心,一定半径内的所有点。\n典型的实际案例可能是查找某个点附近所有可以出租的房子,或者社交网站中“匹配”附近的用户,等等。假设我们有如下表:\nCREATE TABLE locations ( id INT NOT NULL PRIMARY KEY AUTO_INCREMENT, name VARCHAR(30), lat FLOAT NOT NULL, lon FLOAT NOT NULL ); INSERT INTO locations(name, lat, lon) VALUES('Charlottesville, Virginia', 38.03, −78.48), ('Chicago, Illinois', 41.85, −87.65), ('Washington, DC', 38.89, −77.04); 这里经度和纬度的单位是“度”,通常我们假设地球是圆的,然后使用两点所在最大圆(半正矢)公式来计算两点之间的距离。现在有坐标latA和lonA、latB和lonB,那么点A和点B的距离计算公式如下:\nACOS( COS(latA) * COS(latB) * COS(lonA - lonB) + SIN(latA) * SIN(latB) ) 计算出的结果是一个弧度,如果要将结果的单位转换成英里或者千米,则需要乘以地球的半径,也就是3 959英里或者6 371千米。假设我们需要找出所有距离Baron所居住的地方Charlottesville 100英里以内的点,那么我们需要将经纬度带入上面的计算公式:\n这类查询不仅无法使用索引,而且还会非常消耗CPU时间,给服务器带来很大的压力,而且我们还得反复计算这个。那要怎样优化呢?\n这个设计中有几个地方可以做优化。第一,看看是否真的需要这么精确的计算。其实这种算法已经有很多不精确的地方了,如下所示:\n两个地方之间的直线距离可能是100英里,但实际上它们之间的行走距离很可能不是这个值。无论你们在哪两个地方,要到达彼此位置的行走距离多半都不是直线距离,路上可能需要绕很多的弯,比如说如果有一条河,需要绕远走到一个有桥的地方。所以,这里计算的绝对距离只是一个参考值。 如果我们根据邮政编码来确定某个人所在的地区,再根据这个地区的中心位置计算他和别人的距离,那么这本身就是一个估算。Baron住在Charlottesville,不过不是在中心地区,他对华盛顿物理位置的中心也不感兴趣。 所以,通常并不需要精确计算,很多应用如果这样计算,多半是认真过头了。这类似于有效数字的估算:计算结果的精度永远都不会比测量的值更高。(换句话说,“错进,错出”。)\n如果不需要太高的精度,那么我们认为地球是圆的应该也没什么问题,其实准确的说应该是椭圆。根据毕达哥拉斯定理,做些三角函数变换,我们可以把上面的公式转换得更简单,只需要做些求和、乘积以及平方根运算,就可以得出一个点是否在另一个点多少英里之内。(33)\n等等,为什么就到这为止?我们是否真需要计算一个圆周呢?为什么不直接使用一个正方形代替?边长为200英里的正方形,一个顶点到中心的距离大概是141英里,这和实际计算的100英里相差得并不是那么远。那我们根据正方形公式来计算弧度为0.0253(100英里)的中心到边长的距离:\nSELECT * FROM locations WHERE lat BETWEEN 38.03 - DEGREES(0.0253) AND 38.03 + DEGREES(0.0253) AND lon BETWEEN −78.48 - DEGREES(0.0253) AND −78.48 + DEGREES(0.0253); 现在我们看看如何使用索引来优化这个查询。简单地,我们可以增加索引(lat,lon)或者(lon,lat)。不过这样做效果并不会很好。正如我们所知,MySQL 5.5和之前的版本,如果第一列是范围查询的话,就无法使用索引后面的列了。因为两个列都是范围的,所以这里只能使用索引的一个列(BETWEEN等效于一个大于和一个小于)。\n我们再次想起了通常使用的IN()优化。我们先新增两个列,用来存储坐标的近似值FLOOR(),然后在查询中使用IN()将所有点的整数值都放到列表中。下面是我们需要新增的列和索引:\nmysql\u0026gt; ** ALTER TABLE locations** -\u0026gt; ** ADD lat_floor INT NOT NULL DEFAULT 0,** -\u0026gt; ** ADD lon_floor INT NOT NULL DEFAULT 0,** -\u0026gt; ** ADD KEY(lat_floor, lon_floor);** mysql\u0026gt; ** UPDATE locations** -\u0026gt; ** SET lat_floor = FLOOR(lat), lon_floor = FLOOR(lon);** 现在我们可以根据坐标的一定范围的近似值来搜索了,这个近似值包括地板值和天花板值,地理上分别对应的是南北。下面的查询为我们只展示了如何查某个范围的所有点;数值需要在应用程序中计算而不是MySQL中:\n现在我们就可以生成IN()列表中的整数了,也就是前面计算的地板和天花板数值之间的数字。下面是加上WHERE条件的完整查询:\nSELECT * FROM locations WHERE lat BETWEEN 38.03 - DEGREES(0.0253) AND 38.03 + DEGREES(0.0253) AND lon BETWEEN −78.48 - DEGREES(0.0253) AND −78.48 + DEGREES(0.0253) AND lat_floor IN(36,37,38,39,40) AND lon_floor IN(-80,-79,-78,-77); 使用近似值会让我们的计算结果有些偏差,所以我们还需要一些额外的条件剔除在正方形之外的点。这和前面使用CRC32做哈希索引类似:先建一个索引帮我们过滤出近似值,再使用精确条件匹配所有的记录并移除不满足条件的记录。\n事实上,到这时我们就无须根据正方形的近似来过滤数据了,我们可以使用最大圆公式或者毕达哥拉斯定理来计算:\nSELECT * FROM locations WHERE lat_floor IN(36,37,38,39,40) AND lon_floor IN(-80,-79,-78,-77) AND 3979 * ACOS( COS(RADIANS(lat)) * COS(RADIANS(38.03)) * COS(RADIANS(lon) - RADIANS(-78.48)) + SIN(RADIANS(lat)) * SIN(RADIANS(38.03)) ) \u0026lt;= 100; 这时计算精度再次回到前面——使用一个精确的圆周——不过,现在的做法更快(34)。只要能够高效地过滤掉大部分的点,例如使用近似整数和索引,之后再做精确数学计算的代价并不大。只是不要直接使用大圆周的算法,否则速度会很慢。\nSphinx有很多内置的地理信息搜索功能,比MySQL实现要好很多。如果正在考虑使用MyISAM的GIS函数,并使用上面的技巧来计算,那么你需要记住:这样做效果并不会很好,MyISAM本身也并不适合大数据量、高并发的应用,另外MyISAM本身还有一些弱点,如数据文件崩溃、表级锁等。\n回顾一下上面的案例,我们采用了下面这些常用的优化策略:\n尽量少做事,可能的话尽量不做事。这个案例中就不要对所有的点计算大圆周公式;先使用简单的方案过滤大多数数据,然后再到过滤出来的更小的集合上使用复杂的公式运算。 快速地完成事情。确保在你的设计中尽可能地让查询都用上合适的索引,使用近似计算(例如本案例中,认为地球是平的,使用一个正方形来近似圆周)来避免复杂的计算。 需要的时候,尽可能让应用程序完成一些计算。例如本案例中,在应用程序中计算所有的三角函数。 6.8.3 使用用户自定义函数 # 当SQL语句已经无法高效地完成某些任务的时候,这里我们将介绍最后一个高级的优化技巧。当你需要更快的速度,那么C和C++是很好的选择。当然,你需要一定的C或C++编程技巧,否则你写的程序很可能会让服务器崩溃。这和“能力越强,责任越大”类似。\n我们将在下一章为你展示如何编写一个用户自定义函数(UDFs),不过这一章就将通过一个案例看看如何用好一个用户自定义函数。有一个客户,在项目中需要如下的功能:“我们需要根据两个随机的64位数字计算它们的XOR值,来看两个数值是否匹配。大约有3500万条的记录需要在秒级别完成。”经过简单的计算就知道,当前的硬件条件下,不可能在MySQL中完成。那如何解决这个问题呢?\n问题的答案是使用Yves Trudeau编写的一个计算程序,这个程序使用SSE4.2指令集,以一个后台程序的方式运行在通用服务器上,然后我们编写一个用户自定义函数,通过简单的网络通信协议和前面的程序进行交互。\nYves的测试表明,分布式运行上面的程序,可以达到在130毫秒内完成4百万次匹配计算。通过这样的方式,可以将密集型的计算放到一些通用的服务器上,同时可以对外界完全透明,看起来是MySQL完成了全部的工作。正如他们在Twitter上说的:#太好了!这是一个典型的业务优化案例,而不仅仅是优化了一个简单的技术问题。\n6.9 总结 # 如果把创建高性能应用程序比作是一个环环相扣的“难题”,除了前面介绍的schema、索引和查询语句设计之外,查询优化应该是解开“难题”的最后一步了。要想写一个好的查询,你必须要理解schema设计、索引设计等,反之亦然。\n理解查询是如何被执行的以及时间都消耗在哪些地方,这依然是前面我们介绍的响应时间的一部分。再加上一些诸如解析和优化过程的知识,就可以更进一步地理解上一章讨论的MySQL如何访问表和索引的内容了。这也从另一个维度帮助读者理解MySQL在访问表和索引时查询和索引的关系。\n优化通常都需要三管齐下:不做、少做、快速地做。我们希望这里的案例能够帮助你将理论和实践联系起来。\n除了这些基础的手段,包括查询、表结构、索引等,MySQL还有一些高级的特性可以帮助你优化应用,例如分区,分区和索引有些类似但是原理不同。MySQL还支持查询缓存,它可以帮你缓存查询结果,当完全相同的查询再次执行时,直接使用缓存结果(回想一下,“不做”)。我们将在下一章中介绍这些特性。\n————————————————————\n(1) 有时候你可能还需要修改一些查询,减少这些查询对系统中运行的其他查询的影响。这种情况下,你是在减少一个查询的资源消耗,这我们在第3章已经讨论过。\n(2) 如果应用服务器和数据库不在同一台主机上,网络开销就显得很明显了。即使是在同一台服务器上仍然会有数据传输的开销。\n(3) 更多内容请参考后面的“优化COUNT()查询”。\n(4) 例如关联查询结果返回的一条记录通常是由多条记录组成。——译者注\n(5) Percona Toolkit中的pt-archiver工具就可以安全而简单地完成这类工作。\n(6) Query Cache。——译者注\n(7) 如果查询太大,服务端会拒绝接收更多的数据并抛出相应错误。\n(8) 你可以使用SQL_BUFFER_RESULT,后面将再介绍这点。\n(9) 回忆一下前面的客户端和服务器的“传球”比喻。——译者注\n(10) 这里是指Query Cache。——译者注\n(11) Percona版本的MySQL中提供了一个新的特性,可以在计算查询语句哈希值时,先将注释移除再算哈希值,这对于不同注释的相同查询可以命中相同的查询缓存结果。\n(12) 例如,在关联操作中,范围检查的执行计划会针对每一行重新评估索引。可以通过EXPLAIN执行计划中的Extra列是否有“range checked for each record”来确认这一点。该执行计划还会增加select_full_range_join这个服务器变量的值。\n(13) 一部电影没有演员,是有点奇怪。不过在示例数据库Sakila中影片SLACKER LIAISONS没有任何演员,它的描述是“鲨鱼和见识过中国古代鳄鱼的学生的简短传说”。\n(14) join。——译者注\n(15) 后面我们会看到MySQL查询执行过程并没有这么简单,MySQL做了很多优化操作。\n(16) MySQL的临时表是没有任何索引的,在编写复杂的子查询和关联查询的时候需要注意这一点。这一点对UNION查询也一样。\n(17) 在MySQL 5.6和MariaDB中有了重大改变,这两个版本都引入了更加复杂的执行计划。\n(18) MySQL根据执行计划生成输出。这和原查询有完全相同的语义,但是查询语句可能并不完全相同。\n(19) 严格来说,MySQL并不根据读取的记录来选择最优的执行计划。实际上,MySQL通过预估需要读取的数据页来选择,读取的数据页越少越好。不过读取的记录数通常能够很好地反映一个查询的成本。\n(20) 查询的cost。——译者注\n(21) 内存。——译者注\n(22) 可以通过一些办法来影响这个行为——例如,我们可以使用SQL_BUFFER_RESULT。参考后面的“查询优化提示”。\n(23) 相当于Oracle中的跳跃索引扫描(skip index scan)。——译者注\n(24) 而不是NULL。——译者注\n(25) 也可以写成这样的SUM()表达式:SUM(color=\u0026lsquo;blue\u0026rsquo;),SUM(color=\u0026lsquo;red\u0026rsquo;)。\n(26) 值得一提的是,MariaDB修复了这个限制。\n(27) 翻页到非常靠后的页面。——译者注\n(28) 在某些场景下,也可以直接使用=进行赋值,不过为了避免歧义,建议始终使用:=。\n(29) 为行文方便,后面在不引起歧义的情况下将简称为“变量”。——译者注\n(30) 参考 http://mysqldump.azundris.com/archives/86-Down-the-dirty-road.html。\n(31) Baron认为在一些社交网站上归档一些常见不活跃用户后,用户重新回到网站时有这样的需求,当用户再次登录时,一方面我们需要将其从归档中重新拿出来,另外,还可以给他发送一份欢迎邮件。这对一些不活跃的用户是非常好的一个优化。在第11章我们还会再次讨论这个问题。\n(32) 参考 http://www.rabbitmq.com和 http://gearman.org。\n(33) 要想有更多的优化,你可以将三角函数的计算放到应用中,而不要在数据库中计算。三角函数是非常消耗CPU的操作。如果将坐标都转换成弧度存放,则对数据库来说就简化了很多。为了保证我们的案例简单,不要引入太多别的因子,所以这里我们将不再做更多的优化了。\n(34) 再一次,需要使用应用程序中的代码来计算这样的表达式COS(RADIANS(38.03))。\n"},{"id":142,"href":"/zh/docs/technology/MySQL/_%E9%AB%98%E6%80%A7%E8%83%BDMySQL_/%E7%AC%AC5%E7%AB%A0%E5%88%9B%E5%BB%BA%E9%AB%98%E6%80%A7%E8%83%BD%E7%9A%84%E7%B4%A2%E5%BC%95/","title":"第5章创建高性能的索引","section":"高性能 My SQL","content":"第5章 创建高性能的索引\n索引(在MySQL中也叫做“键(key)”)是存储引擎用于快速找到记录的一种数据结构。这是索引的基本功能,除此之外,本章还将讨论索引其他一些方面有用的属性。\n索引对于良好的性能非常关键。尤其是当表中的数据量越来越大时,索引对性能的影响愈发重要。在数据量较小且负载较低时,不恰当的索引对性能的影响可能还不明显,但当数据量逐渐增大时,性能则会急剧下降(1)。\n不过,索引却经常被忽略,有时候甚至被误解,所以在实际案例中经常会遇到由糟糕索引导致的问题。这也是我们把索引优化放在了靠前的章节,甚至比查询优化还靠前的原因。\n索引优化应该是对查询性能优化最有效的手段了。索引能够轻易将查询性能提高几个数量级,“最优”的索引有时比一个“好的”索引性能要好两个数量级。创建一个真正“最优”的索引经常需要重写查询,所以,本章和下一章的关系非常紧密。\n5.1 索引基础 # 要理解MySQL中索引是如何工作的,最简单的方法就是去看看一本书的“索引”部分:如果想在一本书中找到某个特定主题,一般会先看书的“索引”,找到对应的页码。\n在MySQL中,存储引擎用类似的方法使用索引,其先在索引中找到对应值,然后根据匹配的索引记录找到对应的数据行。假如要运行下面的查询:\nmysql\u0026gt; ** SELECT first_name FROM sakila.actor WHERE actor_id=5;** 如果在actor_id列上建有索引,则MySQL将使用该索引找到actor_id为5的行,也就是说,MySQL先在索引上按值进行查找,然后返回所有包含该值的数据行。\n索引可以包含一个或多个列的值。如果索引包含多个列,那么列的顺序也十分重要,因为MySQL只能高效地使用索引的最左前缀列。创建一个包含两个列的索引,和创建两个只包含一列的索引是大不相同的,下面将详细介绍。\n如果使用的是ORM,是否还需要关心索引?\n简而言之:是的,仍然需要理解索引,即使是使用对象关系映射(ORM)工具。\nORM工具能够生产符合逻辑的、合法的查询(多数时候),除非只是生成非常基本的查询(例如仅是根据主键查询),否则它很难生成适合索引的查询。无论是多么复杂的ORM工具,在精妙和复杂的索引面前都是“浮云”。读完本章后面的内容以后,你就会同意这个观点的!很多时候,即使是查询优化技术专家也很难兼顾到各种情况,更别说ORM了。\n5.1.1 索引的类型 # 索引有很多种类型,可以为不同的场景提供更好的性能。在MySQL中,索引是在存储引擎层而不是服务器层实现的。所以,并没有统一的索引标准:不同存储引擎的索引的工作方式并不一样,也不是所有的存储引擎都支持所有类型的索引。即使多个存储引擎支持同一种类型的索引,其底层的实现也可能不同。\n下面我们先来看看MySQL支持的索引类型,以及它们的优点和缺点。\nB-Tree索引 # 当人们谈论索引的时候,如果没有特别指明类型,那多半说的是B-Tree索引,它使用B-Tree数据结构来存储数据(2)。大多数MySQL引擎都支持这种索引。Archive引擎是一个例外:5.1之前Archive不支持任何索引,直到5.1才开始支持单个自增列(AUTO_INCREMENT)的索引。\n我们使用术语“B-Tree”,是因为MySQL在CREATE TABLE和其他语句中也使用该关键字。不过,底层的存储引擎也可能使用不同的存储结构,例如,NDB集群存储引擎内部实际上使用了T-Tree结构存储这种索引,即使其名字是BTREE;InnoDB则使用的是B+Tree,各种数据结构和算法的变种不在本书的讨论范围之内。\n存储引擎以不同的方式使用B-Tree索引,性能也各有不同,各有优劣。例如,MyISAM使用前缀压缩技术使得索引更小,但InnoDB则按照原数据格式进行存储。再如MyISAM索引通过数据的物理位置引用被索引的行,而InnoDB则根据主键引用被索引的行。\nB-Tree通常意味着所有的值都是按顺序存储的,并且每一个叶子页到根的距离相同。图5-1展示了B-Tree索引的抽象表示,大致反映了InnoDB索引是如何工作的。MyISAM使用的结构有所不同,但基本思想是类似的。\n图5-1:建立在B-Tree结构(从技术上来说是B+Tree)上的索引\nB-Tree索引能够加快访问数据的速度,因为存储引擎不再需要进行全表扫描来获取需要的数据,取而代之的是从索引的根节点(图示并未画出)开始进行搜索。根节点的槽中存放了指向子节点的指针,存储引擎根据这些指针向下层查找。通过比较节点页的值和要查找的值可以找到合适的指针进入下层子节点,这些指针实际上定义了子节点页中值的上限和下限。最终存储引擎要么是找到对应的值,要么该记录不存在。\n叶子节点比较特别,它们的指针指向的是被索引的数据,而不是其他的节点页(不同引擎的“指针”类型不同)。图5-1中仅绘制了一个节点和其对应的叶子节点,其实在根节点和叶子节点之间可能有很多层节点页。树的深度和表的大小直接相关。\nB-Tree对索引列是顺序组织存储的,所以很适合查找范围数据。例如,在一个基于文本域的索引树上,按字母顺序传递连续的值进行查找是非常合适的,所以像“找出所有以I到K开头的名字”这样的查找效率会非常高。\n假设有如下数据表:\nCREATE TABLE People ( last_name varchar(50) not null, first_name varchar(50) not null, dob date not null, gender enum('m', 'f') not null, key(last_name, first_name, dob) ); 对于表中的每一行数据,索引中包含了last_name、frst_name和dob列的值,图5-2显示了该索引是如何组织数据的存储的。\n图5-2:B-Tree(从技术上来说是B+Tree)索引树中的部分条目示例\n请注意,索引对多个值进行排序的依据是CREATE TABLE语句中定义索引时列的顺序。看一下最后两个条目,两个人的姓和名都一样,则根据他们的出生日期来排列顺序。\n可以使用B-Tree索引的查询类型。B-Tree索引适用于全键值、键值范围或键前缀查找。其中键前缀查找只适用于根据最左前缀的查找(3)。前面所述的索引对如下类型的查询有效。\n全值匹配\n全值匹配指的是和索引中的所有列进行匹配,例如前面提到的索引可用于查找姓名为Cuba Allen、出生于1960-01-01的人。\n匹配最左前缀\n前面提到的索引可用于查找所有姓为Allen的人,即只使用索引的第一列。\n匹配列前缀\n也可以只匹配某一列的值的开头部分。例如前面提到的索引可用于查找所有以J开头的姓的人。这里也只使用了索引的第一列。\n匹配范围值\n例如前面提到的索引可用于查找姓在Allen和Barrymore之间的人。这里也只使用了索引的第一列。\n精确匹配某一列并范围匹配另外一列\n前面提到的索引也可用于查找所有姓为Allen,并且名字是字母K开头(比如Kim、Karl等)的人。即第一列last_name全匹配,第二列frst_name范围匹配。\n只访问索引的查询\nB-Tree通常可以支持“只访问索引的查询”,即查询只需要访问索引,而无须访问数据行。后面我们将单独讨论这种“覆盖索引”的优化。\n因为索引树中的节点是有序的,所以除了按值查找之外,索引还可以用于查询中的ORDER BY操作(按顺序查找)。一般来说,如果B-Tree可以按照某种方式查找到值,那么也可以按照这种方式用于排序。所以,如果ORDER BY子句满足前面列出的几种查询类型,则这个索引也可以满足对应的排序需求。\n下面是一些关于B-Tree索引的限制:\n如果不是按照索引的最左列开始查找,则无法使用索引。例如上面例子中的索引无法用于查找名字为Bill的人,也无法查找某个特定生日的人,因为这两列都不是最左数据列。类似地,也无法查找姓氏以某个字母结尾的人。 不能跳过索引中的列。也就是说,前面所述的索引无法用于查找姓为Smith并且在某个特定日期出生的人。如果不指定名(first_name),则MySQL只能使用索引的第一列。 如果查询中有某个列的范围查询,则其右边所有列都无法使用索引优化查找。例如有查询WHERE last_name=\u0026lsquo;Smith\u0026rsquo; AND frst_name LIKE \u0026lsquo;J%\u0026rsquo; AND dob=\u0026lsquo;1976-12-23\u0026rsquo;,这个查询只能使用索引的前两列,因为这里LIKE是一个范围条件(但是服务器可以把其余列用于其他目的)。如果范围查询列值的数量有限,那么可以通过使用多个等于条件来代替范围条件。在本章的索引案例学习部分,我们将演示一个详细的案例。 到这里读者应该可以明白,前面提到的索引列的顺序是多么的重要:这些限制都和索引列的顺序有关。在优化性能的时候,可能需要使用相同的列但顺序不同的索引来满足不同类型的查询需求。\n也有些限制并不是B-Tree本身导致的,而是MySQL优化器和存储引擎使用索引的方式导致的,这部分限制在未来的版本中可能就不再是限制了。\n哈希索引 # 哈希索引(hash index)基于哈希表实现,只有精确匹配索引所有列的查询才有效(4)。对于每一行数据,存储引擎都会对所有的索引列计算一个哈希码(hash code),哈希码是一个较小的值,并且不同键值的行计算出来的哈希码也不一样。哈希索引将所有的哈希码存储在索引中,同时在哈希表中保存指向每个数据行的指针。\n在MySQL中,只有Memory引擎显式支持哈希索引。这也是Memory引擎表的默认索引类型,Memory引擎同时也支持B-Tree索引。值得一提的是,Memory引擎是支持非唯一哈希索引的,这在数据库世界里面是比较与众不同的。如果多个列的哈希值相同,索引会以链表的方式存放多个记录指针到同一个哈希条目中。\n下面来看一个例子。假设有如下表:\nCREATE TABLE testhash ( fname VARCHAR(50) NOT NULL, lname VARCHAR(50) NOT NULL, KEY USING HASH(fname) ) ENGINE=MEMORY; 表中包含如下数据:\n假设索引使用假想的哈希函数f(),它返回下面的值(都是示例数据,非真实数据):\nf('Arjen')= 2323 f('Baron')= 7437 f('Peter')= 8784 f('Vadim')= 2458 则哈希索引的数据结构如下: 槽(Slot) 值(Value) 2323 指向第1 行的指针 2458 指向第4 行的指针 7437 指向第2 行的指针 8784 指向第3 行的指针\n注意每个槽的编号是顺序的,但是数据行不是。现在,来看如下查询:\nmysql\u0026gt; ** SELECT lname FROM testhash WHERE fname='Peter';** MySQL先计算\u0026rsquo;Peter\u0026rsquo;的哈希值,并使用该值寻找对应的记录指针。因为f(\u0026lsquo;Peter\u0026rsquo;)=8784,所以MySQL在索引中查找8784,可以找到指向第3行的指针,最后一步是比较第三行的值是否为\u0026rsquo;Peter\u0026rsquo;,以确保就是要查找的行。\n因为索引自身只需存储对应的哈希值,所以索引的结构十分紧凑,这也让哈希索引查找的速度非常快。然而,哈希索引也有它的限制:\n哈希索引只包含哈希值和行指针,而不存储字段值,所以不能使用索引中的值来避免读取行。不过,访问内存中的行的速度很快,所以大部分情况下这一点对性能的影响并不明显。 哈希索引数据并不是按照索引值顺序存储的,所以也就无法用于排序。 哈希索引也不支持部分索引列匹配查找,因为哈希索引始终是使用索引列的全部内容来计算哈希值的。例如,在数据列(A,B)上建立哈希索引,如果查询只有数据列A,则无法使用该索引。 哈希索引只支持等值比较查询,包括=、IN()、\u0026lt;=\u0026gt;(注意\u0026lt;\u0026gt;和\u0026lt;=\u0026gt;是不同的操作)。也不支持任何范围查询,例如WHERE price\u0026gt;100。 访问哈希索引的数据非常快,除非有很多哈希冲突(不同的索引列值却有相同的哈希值)。当出现哈希冲突的时候,存储引擎必须遍历链表中所有的行指针,逐行进行比较,直到找到所有符合条件的行。 如果哈希冲突很多的话,一些索引维护操作的代价也会很高。例如,如果在某个选择性很低(哈希冲突很多)的列上建立哈希索引,那么当从表中删除一行时,存储引擎需要遍历对应哈希值的链表中的每一行,找到并删除对应行的引用,冲突越多,代价越大。 因为这些限制,哈希索引只适用于某些特定的场合。而一旦适合哈希索引,则它带来的性能提升将非常显著。举个例子,在数据仓库应用中有一种经典的“星型”schema,需要关联很多查找表,哈希索引就非常适合查找表的需求。\n除了Memory引擎外,NDB集群引擎也支持唯一哈希索引,且在NDB集群引擎中作用非常特殊,但这不属于本书的范围。\nInnoDB引擎有一个特殊的功能叫做“自适应哈希索引(adaptive hash index)”。当InnoDB注意到某些索引值被使用得非常频繁时,它会在内存中基于B-Tree索引之上再创建一个哈希索引,这样就让B-Tree索引也具有哈希索引的一些优点,比如快速的哈希查找。这是一个完全自动的、内部的行为,用户无法控制或者配置,不过如果有必要,完全可以关闭该功能。\n创建自定义哈希索引。如果存储引擎不支持哈希索引,则可以模拟像InnoDB一样创建哈希索引,这可以享受一些哈希索引的便利,例如只需要很小的索引就可以为超长的键创建索引。\n思路很简单:在B-Tree基础上创建一个伪哈希索引。这和真正的哈希索引不是一回事,因为还是使用B-Tree进行查找,但是它使用哈希值而不是键本身进行索引查找。你需要做的就是在查询的WHERE子句中手动指定使用哈希函数。\n下面是一个实例,例如需要存储大量的URL,并需要根据URL进行搜索查找。如果使用B-Tree来存储URL,存储的内容就会很大,因为URL本身都很长。正常情况下会有如下查询:\nmysql\u0026gt; ** SELECT id FROM url WHERE url=\u0026quot;http://www.mysql.com\u0026quot;;** 若删除原来URL列上的索引,而新增一个被索引的url_crc列,使用CRC32做哈希,就可以使用下面的方式查询:\nmysql\u0026gt; ** SELECT id FROM url WHERE url=\u0026quot;http://www.mysql.com\u0026quot;** -\u0026gt; ** AND url_crc=CRC32(\u0026quot;http://www.mysql.com\u0026quot;);** 这样做的性能会非常高,因为MySQL优化器会使用这个选择性很高而体积很小的基于url_crc列的索引来完成查找(在上面的案例中,索引值为1560514994)。即使有多个记录有相同的索引值,查找仍然很快,只需要根据哈希值做快速的整数比较就能找到索引条目,然后一一比较返回对应的行。另外一种方式就是对完整的URL字符串做索引,那样会非常慢。\n这样实现的缺陷是需要维护哈希值。可以手动维护,也可以使用触发器实现。下面的案例演示了触发器如何在插入和更新时维护url_crc列。首先创建如下表:\nCREATE TABLE pseudohash ( id int unsigned NOT NULL auto_increment, url varchar(255) NOT NULL, url_crc int unsigned NOT NULL DEFAULT 0, PRIMARY KEY(id) ); 然后创建触发器。先临时修改一下语句分隔符,这样就可以在触发器定义中使用分号:\nDELIMITER // CREATE TRIGGER pseudohash_crc_ins BEFORE INSERT ON pseudohash FOR EACH ROW BEGIN SET NEW.url_crc=crc32(NEW.url); END; // CREATE TRIGGER pseudohash_crc_upd BEFORE UPDATE ON pseudohash FOR EACH ROW BEGIN SET NEW.url_crc=crc32(NEW.url); END; // DELIMITER ; 剩下的工作就是验证一下触发器如何维护哈希索引:\n如果采用这种方式,记住不要使用SHA1()和MD5()作为哈希函数。因为这两个函数计算出来的哈希值是非常长的字符串,会浪费大量空间,比较时也会更慢。SHA1()和MD5()是强加密函数,设计目标是最大限度消除冲突,但这里并不需要这样高的要求。简单哈希函数的冲突在一个可以接受的范围,同时又能够提供更好的性能。\n如果数据表非常大,CRC32()会出现大量的哈希冲突,则可以考虑自己实现一个简单的64位哈希函数。这个自定义函数要返回整数,而不是字符串。一个简单的办法可以使用MD5()函数返回值的一部分来作为自定义哈希函数。这可能比自己写一个哈希算法的性能要差(参考第7章),不过这样实现最简单:\n处理哈希冲突。当使用哈希索引进行查询的时候,必须在WHERE子句中包含常量值:\nmysql\u0026gt; ** SELECT id FROM url WHERE url_crc=CRC32(\u0026quot;http://www.mysql.com\u0026quot;)** -\u0026gt; ** AND url=\u0026quot;http://www.mysql.com\u0026quot;;** 一旦出现哈希冲突,另一个字符串的哈希值也恰好是1560514994,则下面的查询是无法正确工作的。\nmysql\u0026gt; ** SELECT id FROM url WHERE url_crc=CRC32(\u0026quot;http://www.mysql.com\u0026quot;);** 因为所谓的“生日悖论”(5),出现哈希冲突的概率的增长速度可能比想象的要快得多。CRC32()返回的是32位的整数,当索引有93000条记录时出现冲突的概率是1%。例如我们将*/usr/share/dict/words*中的词导入数据表并进行CRC32()计算,最后会有98 569行。这就已经出现一次哈希冲突了,冲突让下面的查询返回了多条记录:\n正确的写法应该如下:\n要避免冲突问题,必须在WHERE条件中带入哈希值和对应列值。如果不是想查询具体值,例如只是统计记录数(不精确的),则可以不带入列值,直接使用CRC32()的哈希值查询即可。还可以使用如FNV64()函数作为哈希函数,这是移植自Percona Server的函数,可以以插件的方式在任何MySQL版本中使用,哈希值为64位,速度快,且冲突比CRC32()要少很多。\n空间数据索引(R-Tree) # MyISAM表支持空间索引,可以用作地理数据存储。和B-Tree索引不同,这类索引无须前缀查询。空间索引会从所有维度来索引数据。查询时,可以有效地使用任意维度来组合查询。必须使用MySQL的GIS相关函数如MBRCONTAINS()等来维护数据。MySQL的GIS支持并不完善,所以大部分人都不会使用这个特性。开源关系数据库系统中对GIS的解决方案做得比较好的是PostgreSQL的PostGIS。\n全文索引 # 全文索引是一种特殊类型的索引,它查找的是文本中的关键词,而不是直接比较索引中的值。全文搜索和其他几类索引的匹配方式完全不一样。它有许多需要注意的细节,如停用词、词干和复数、布尔搜索等。全文索引更类似于搜索引擎做的事情,而不是简单的WHERE条件匹配。\n在相同的列上同时创建全文索引和基于值的B-Tree索引不会有冲突,全文索引适用于MATCH AGAINST操作,而不是普通的WHERE条件操作。\n我们将在第7章讨论更多的全文索引的细节。\n其他索引类别 # 还有很多第三方的存储引擎使用不同类型的数据结构来存储索引。例如TokuDB使用分形树索引(fractal tree index),这是一类较新开发的数据结构,既有B-Tree的很多优点,也避免了B-Tree的一些缺点。如果通读完本章,可以看到很多关于InnoDB的主题,包括聚簇索引、覆盖索引等。多数情况下,针对InnoDB的讨论也都适用于TokuDB。\nScaleDB使用Patricia tries(这个词不是拼写错误),其他一些存储引擎技术如InfiniDB和Infobright则使用了一些特殊的数据结构来优化某些特殊的查询。\n5.2 索引的优点 # 索引可以让服务器快速地定位到表的指定位置。但是这并不是索引的唯一作用,到目前为止可以看到,根据创建索引的数据结构不同,索引也有一些其他的附加作用。\n最常见的B-Tree索引,按照顺序存储数据,所以MySQL可以用来做ORDER BY和GROUP BY操作。因为数据是有序的,所以B-Tree也就会将相关的列值都存储在一起。最后,因为索引中存储了实际的列值,所以某些查询只使用索引就能够完成全部查询。据此特性,总结下来索引有如下三个优点:\n索引大大减少了服务器需要扫描的数据量。 索引可以帮助服务器避免排序和临时表。 索引可以将随机I/O变为顺序I/O。 “索引”这个主题完全值得单独写一本书,如果想深入理解这部分内容,强烈建议阅读由Tapio Lahdenmaki和Mike Leach编写的Relational Database Index Design and the Optimizers(Wiley出版社)一书,该书详细介绍了如何计算索引的成本和作用、如何评估查询速度、如何分析索引维护的代价和其带来的好处等。\nLahdenmaki和Leach在书中介绍了如何评价一个索引是否适合某个查询的“三星系统”(three-star system):索引将相关的记录放到一起则获得一星;如果索引中的数据顺序和查找中的排列顺序一致则获得二星;如果索引中的列包含了查询中需要的全部列则获得“三星”。后面我们将会介绍这些原则。\n索引是最好的解决方案吗?\n索引并不总是最好的工具。总的来说,只有当索引帮助存储引擎快速查找到记录带来的好处大于其带来的额外工作时,索引才是有效的。对于非常小的表,大部分情况下简单的全表扫描更高效。对于中到大型的表,索引就非常有效。但对于特大型的表,建立和使用索引的代价将随之增长。这种情况下,则需要一种技术可以直接区分出查询需要的一组数据,而不是一条记录一条记录地匹配。例如可以使用分区技术,请参考第7章。\n如果表的数量特别多,可以建立一个元数据信息表,用来查询需要用到的某些特性。例如执行那些需要聚合多个应用分布在多个表的数据的查询,则需要记录“哪个用户的信息存储在哪个表中”的元数据,这样在查询时就可以直接忽略那些不包含指定用户信息的表。对于大型系统,这是一个常用的技巧。事实上,Infobright就是使用类似的实现。对于TB级别的数据,定位单条记录的意义不大,所以经常会使用块级别元数据技术来替代索引。\n5.3 高性能的索引策略 # 正确地创建和使用索引是实现高性能查询的基础。前面已经介绍了各种类型的索引及其对应的优缺点。现在我们一起来看看如何真正地发挥这些索引的优势。\n高效地选择和使用索引有很多种方式,其中有些是针对特殊案例的优化方法,有些则是针对特定行为的优化。使用哪个索引,以及如何评估选择不同索引的性能影响的技巧,则需要持续不断地学习。接下来的几个小节将帮助读者理解如何高效地使用索引。\n5.3.1 独立的列 # 我们通常会看到一些查询不当地使用索引,或者使得MySQL无法使用已有的索引。如果查询中的列不是独立的,则MySQL就不会使用索引。“独立的列”是指索引列不能是表达式的一部分,也不能是函数的参数。\n例如,下面这个查询无法使用actor_id列的索引:\nmysql\u0026gt; ** SELECT actor_id FROM sakila.actor WHERE actor_id + 1 = 5;** 凭肉眼很容易看出WHERE中的表达式其实等价于actor_id=4,但是MySQL无法自动解析这个方程式。这完全是用户行为。我们应该养成简化WHERE条件的习惯,始终将索引列单独放在比较符号的一侧。\n下面是另一个常见的错误:\nmysql\u0026gt; ** SELECT ... WHERE TO_DAYS(CURRENT_DATE) - TO_DAYS(date_col) \u0026lt;= 10;** 5.3.2 前缀索引和索引选择性 # 有时候需要索引很长的字符列,这会让索引变得大且慢。一个策略是前面提到过的模拟哈希索引。但有时候这样做还不够,还可以做些什么呢?\n通常可以索引开始的部分字符,这样可以大大节约索引空间,从而提高索引效率。但这样也会降低索引的选择性。索引的选择性是指,不重复的索引值(也称为基数,cardinality)和数据表的记录总数(#T)的比值,范围从1/#T到1之间。索引的选择性越高则查询效率越高,因为选择性高的索引可以让MySQL在查找时过滤掉更多的行。唯一索引的选择性是1,这是最好的索引选择性,性能也是最好的。\n一般情况下某个列前缀的选择性也是足够高的,足以满足查询性能。对于BLOB、TEXT或者很长的VARCHAR类型的列,必须使用前缀索引,因为MySQL不允许索引这些列的完整长度。\n诀窍在于要选择足够长的前缀以保证较高的选择性,同时又不能太长(以便节约空间)。前缀应该足够长,以使得前缀索引的选择性接近于索引整个列。换句话说,前缀的“基数”应该接近于完整列的“基数”。\n为了决定前缀的合适长度,需要找到最常见的值的列表,然后和最常见的前缀列表进行比较。在示例数据库Sakila中并没有合适的例子,所以我们从表city中生成一个示例表,这样就有足够的数据进行演示:\nCREATE TABLE sakila.city_demo(city VARCHAR(50) NOT NULL); INSERT INTO sakila.city_demo(city) SELECT city FROM sakila.city; -- Repeat the next statement five times: city_demo; Now randomize the distribution (inefficiently but conveniently): UPDATE sakila.city_demo SET city = (SELECT city FROM sakila.city ORDER BY RAND() LIMIT 1); 现在我们有了示例数据集。数据分布当然不是真实的分布;因为我们使用了RAND(),所以你的结果会与此不同,但对这个练习来说这并不重要。首先,我们找到最常见的城市列表:\n注意到,上面每个值都出现了45~65次。现在查找到最频繁出现的城市前缀,先从3个前缀字母开始:\n每个前缀都比原来的城市出现的次数更多,因此唯一前缀比唯一城市要少得多。然后我们增加前缀长度,直到这个前缀的选择性接近完整列的选择性。经过实验后发现前缀长度为7时比较合适:\n计算合适的前缀长度的另外一个办法就是计算完整列的选择性,并使前缀的选择性接近于完整列的选择性。下面显示如何计算完整列的选择性:\n通常来说(尽管也有例外情况),这个例子中如果前缀的选择性能够接近0.031,基本上就可用了。可以在一个查询中针对不同前缀长度进行计算,这对于大表非常有用。下面给出了如何在同一个查询中计算不同前缀长度的选择性:\n查询显示当前缀长度到达7的时候,再增加前缀长度,选择性提升的幅度已经很小了。\n只看平均选择性是不够的,也有例外的情况,需要考虑最坏情况下的选择性。平均选择性会让你认为前缀长度为4或者5的索引已经足够了,但如果数据分布很不均匀,可能就会有陷阱。如果观察前缀为4的最常出现城市的次数,可以看到明显不均匀:\n如果前缀是4个字节,则最常出现的前缀的出现次数比最常出现的城市的出现次数要大很多。即这些值的选择性比平均选择性要低。如果有比这个随机生成的示例更真实的数据,就更有可能看到这种现象。例如在真实的城市名上建一个长度为4的前缀索引,对于以“San”和“New”开头的城市的选择性就会非常糟糕,因为很多城市都以这两个词开头。\n在上面的示例中,已经找到了合适的前缀长度,下面演示一下如何创建前缀索引:\nmysql\u0026gt; ** ALTER TABLE sakila.city_demo ADD KEY (city(7));** 前缀索引是一种能使索引更小、更快的有效办法,但另一方面也有其缺点:MySQL无法使用前缀索引做ORDER BY和GROUP BY,也无法使用前缀索引做覆盖扫描。\n一个常见的场景是针对很长的十六进制唯一ID使用前缀索引。在前面的章节中已经讨论了很多有效的技术来存储这类ID信息,但如果使用的是打包过的解决方案,因而无法修改存储结构,那该怎么办?例如使用vBulletin或者其他基于MySQL的应用在存储网站的会话(SESSION)时,需要在一个很长的十六进制字符串上创建索引。此时如果采用长度为8的前缀索引通常能显著地提升性能,并且这种方法对上层应用完全透明。\n有时候后缀索引(suffix index)也有用途(例如,找到某个域名的所有电子邮件地址)。MySQL原生并不支持反向索引,但是可以把字符串反转后存储,并基于此建立前缀索引。可以通过触发器来维护这种索引。参考5.1节中“创建自定义哈希索引”部分的相关内容。\n5.3.3 多列索引 # 很多人对多列索引的理解都不够。一个常见的错误就是,为每个列创建独立的索引,或者按照错误的顺序创建多列索引。\n我们会在5.3.4节中单独讨论索引列的顺序问题。先来看第一个问题,为每个列创建独立的索引,从SHOW CREATE TABLE中很容易看到这种情况:\nCREATE TABLE t ( c1 INT, c2 INT, c3 INT, KEY(c1), KEY(c2), KEY(c3) ); 这种索引策略,一般是由于人们听到一些专家诸如“把WHERE条件里面的列都建上索引”这样模糊的建议导致的。实际上这个建议是非常错误的。这样一来最好的情况下也只能是“一星”索引,其性能比起真正最优的索引可能差几个数量级。有时如果无法设计一个“三星”索引,那么不如忽略掉WHERE子句,集中精力优化索引列的顺序,或者创建一个全覆盖索引。\n在多个列上建立独立的单列索引大部分情况下并不能提高MySQL的查询性能。MySQL 5.0和更新版本引入了一种叫“索引合并”(index merge)的策略,一定程度上可以使用表上的多个单列索引来定位指定的行。更早版本的MySQL只能使用其中某一个单列索引,然而这种情况下没有哪一个独立的单列索引是非常有效的。例如,表film_actor在字段film_id和actor_id上各有一个单列索引。但对于下面这个查询WHERE条件,这两个单列索引都不是好的选择:\nmysql\u0026gt; ** SELECT film_id, actor_id FROM sakila.film_actor** -\u0026gt; ** WHERE actor_id = 1 OR film_id = 1;** 在老的MySQL版本中,MySQL对这个查询会使用全表扫描。除非改写成如下的两个查询UNION的方式:\nmysql\u0026gt; ** SELECT film_id, actor_id FROM sakila.film_actor WHERE actor_id = 1** -\u0026gt; ** UNION ALL** -\u0026gt; ** AND actor_id \u0026lt;\u0026gt; 1;** 但在MySQL 5.0和更新的版本中,查询能够同时使用这两个单列索引进行扫描,并将结果进行合并。这种算法有三个变种:OR条件的联合(union),AND条件的相交(intersection),组合前两种情况的联合及相交。下面的查询就是使用了两个索引扫描的联合,通过EXPLAIN中的Extra列可以看到这点:\nmysql\u0026gt; ** EXPLAIN SELECT film_id, actor_id FROM sakila.film_actor** -\u0026gt; ** WHERE actor_id = 1 OR film_id = 1\\G** *************************** 1. row *************************** id: 1 select_type: SIMPLE table: film_actor type: index_merge possible_keys: PRIMARY,idx_fk_film_id key: PRIMARY,idx_fk_film_id key_len: 2,2 ref: NULL rows: 29 Extra: Using union(PRIMARY,idx_fk_film_id); Using where MySQL会使用这类技术优化复杂查询,所以在某些语句的Extra列中还可以看到嵌套操作。\n索引合并策略有时候是一种优化的结果,但实际上更多时候说明了表上的索引建得很糟糕:\n当出现服务器对多个索引做相交操作时(通常有多个AND条件),通常意味着需要一个包含所有相关列的多列索引,而不是多个独立的单列索引。 当服务器需要对多个索引做联合操作时(通常有多个OR条件),通常需要耗费大量CPU和内存资源在算法的缓存、排序和合并操作上。特别是当其中有些索引的选择性不高,需要合并扫描返回的大量数据的时候。 更重要的是,优化器不会把这些计算到“查询成本”(cost)中,优化器只关心随机页面读取。这会使得查询的成本被“低估”,导致该执行计划还不如直接走全表扫描。这样做不但会消耗更多的CPU和内存资源,还可能会影响查询的并发性,但如果是单独运行这样的查询则往往会忽略对并发性的影响。通常来说,还不如像在MySQL 4.1或者更早的时代一样,将查询改写成UNION的方式往往更好。 如果在EXPLAIN中看到有索引合并,应该好好检查一下查询和表的结构,看是不是已经是最优的。也可以通过参数optimizer_switch来关闭索引合并功能。也可以使用IGNORE INDEX提示让优化器忽略掉某些索引。\n5.3.4 选择合适的索引列顺序 # 我们遇到的最容易引起困惑的问题就是索引列的顺序。正确的顺序依赖于使用该索引的查询,并且同时需要考虑如何更好地满足排序和分组的需要(顺便说明,本节内容适用于B-Tree索引;哈希或者其他类型的索引并不会像B-Tree索引一样按顺序存储数据)。\n在一个多列B-Tree索引中,索引列的顺序意味着索引首先按照最左列进行排序,其次是第二列,等等。所以,索引可以按照升序或者降序进行扫描,以满足精确符合列顺序的ORDER BY、GROUP BY和DISTINCT等子句的查询需求。\n所以多列索引的列顺序至关重要。在Lahdenmaki和Leach的“三星索引”系统中,列顺序也决定了一个索引是否能够成为一个真正的“三星索引”(关于三星索引可以参考本章前面的5.2节)。在本章的后续部分我们将通过大量的例子来说明这一点。\n对于如何选择索引的列顺序有一个经验法则:将选择性最高的列放到索引最前列。这个建议有用吗?在某些场景可能有帮助,但通常不如避免随机IO和排序那么重要,考虑问题需要更全面(场景不同则选择不同,没有一个放之四海皆准的法则。这里只是说明,这个经验法则可能没有你想象的重要)。\n当不需要考虑排序和分组时,将选择性最高的列放在前面通常是很好的。这时候索引的作用只是用于优化WHERE条件的查找。在这种情况下,这样设计的索引确实能够最快地过滤出需要的行,对于在WHERE子句中只使用了索引部分前缀列的查询来说选择性也更高。然而,性能不只是依赖于所有索引列的选择性(整体基数),也和查询条件的具体值有关,也就是和值的分布有关。这和前面介绍的选择前缀的长度需要考虑的地方一样。可能需要根据那些运行频率最高的查询来调整索引列的顺序,让这种情况下索引的选择性最高。\n以下面的查询为例:\nSELECT * FROM payment WHERE staff_id = 2 AND ** customer_id** = 584; 是应该创建一个(staff_id,customer_id)索引还是应该颠倒一下顺序?可以跑一些查询来确定在这个表中值的分布情况,并确定哪个列的选择性更高。先用下面的查询预测一下(6),看看各个WHERE条件的分支对应的数据基数有多大:\nmysql\u0026gt; ** SELECT SUM(staff_id = 2), SUM(customer_id = 584) FROM payment\\G** *************************** 1. row *************************** SUM(staff_id = 2): 7992 SUM(customer_id = 584): 30 根据前面的经验法则,应该将索引列customer_id放到前面,因为对应条件值的customer_id数量更小。我们再来看看对于这个customer_id的条件值,对应的staff_id列的选择性如何:\nmysql\u0026gt; ** SELECT SUM(staff_id = 2) FROM payment WHERE customer_id = 584\\G** *************************** 1. row *************************** SUM(staff_id = 2): 17 这样做有一个地方需要注意,查询的结果非常依赖于选定的具体值。如果按上述办法优化,可能对其他一些条件值的查询不公平,服务器的整体性能可能变得更糟,或者其他某些查询的运行变得不如预期。\n如果是从诸如pt-query-digest这样的工具的报告中提取“最差”查询,那么再按上述办法选定的索引顺序往往是非常高效的。如果没有类似的具体查询来运行,那么最好还是按经验法则来做,因为经验法则考虑的是全局基数和选择性,而不是某个具体查询:\nmysql\u0026gt; ** SELECT COUNT(DISTINCT staff_id)/COUNT(*) AS staff_id_selectivity,** \u0026gt; ** COUNT(DISTINCT customer_id)/COUNT(*) AS customer_id_selectivity,** \u0026gt; ** COUNT(*)** \u0026gt; ** FROM payment\\G** *************************** 1. row *************************** staff_id_selectivity: 0.0001 customer_id_selectivity: 0.0373 COUNT(*): 16049 customer_id的选择性更高,所以答案是将其作为索引列的第一列:\nmysql\u0026gt; ** ALTER TABLE payment ADD KEY(customer_id, staff_id);** 当使用前缀索引的时候,在某些条件值的基数比正常值高的时候,问题就来了。例如,在某些应用程序中,对于没有登录的用户,都将其用户名记录为“guset”,在记录用户行为的会话(session)表和其他记录用户活动的表中“guest”就成为了一个特殊用户ID。一旦查询涉及这个用户,那么和对于正常用户的查询就大不同了,因为通常有很多会话都是没有登录的。系统账号也会导致类似的问题。一个应用通常都有一个特殊的管理员账号,和普通账号不同,它并不是一个具体的用户,系统中所有的其他用户都是这个用户的好友,所以系统往往通过它向网站的所有用户发送状态通知和其他消息。这个账号的巨大的好友列表很容易导致网站出现服务器性能问题。\n这实际上是一个非常典型的问题。任何的异常用户,不仅仅是那些用于管理应用的设计糟糕的账号会有同样的问题;那些拥有大量好友、图片、状态、收藏的用户,也会有前面提到的系统账号同样的问题。\n下面是一个我们遇到过的真实案例,在一个用户分享购买商品和购买经验的论坛上,这个特殊表上的查询运行得非常慢:\nmysql\u0026gt; ** SELECT COUNT(DISTINCT threadId) AS COUNT_VALUE** -\u0026gt; ** FROM Message** -\u0026gt; ** WHERE (groupId = 10137) AND (userId = 1288826) AND (anonymous = 0)** -\u0026gt; ** ORDER BY priority DESC, modifiedDate DESC** 这个查询看似没有建立合适的索引,所以客户咨询我们是否可以优化。EXPLAIN的结果如下:\nid: 1 select_type: SIMPLE table: Message type: ref key: ix_groupId_userId key_len: 18 ref: const,const rows: 1251162 Extra: Using where MySQL为这个查询选择了索引(groupId,userId),如果不考虑列的基数,这看起来是一个非常合理的选择。但如果考虑一下user ID和group ID条件匹配的行数,可能就会有不同的想法了:\nmysql\u0026gt; ** SELECT COUNT(*), SUM(groupId = 10137),** -\u0026gt; ** SUM(userId = 1288826), SUM(anonymous = 0)** -\u0026gt; ** FROM Message\\G** *************************** 1. row *************************** count(*): 4142217 sum(groupId = 10137): 4092654 sum(userId = 1288826): 1288496 sum(anonymous = 0): 4141934 从上面的结果来看符合组(groupId)条件几乎满足表中的所有行,符合用户(userId)条件的有130万条记录——也就是说索引基本上没什么用。因为这些数据是从其他应用中迁移过来的,迁移的时候把所有的消息都赋予了管理员组的用户。这个案例的解决办法是修改应用程序代码,区分这类特殊用户和组,禁止针对这类用户和组执行这个查询。\n从这个小案例可以看到经验法则和推论在多数情况是有用的,但要注意不要假设平均情况下的性能也能代表特殊情况下的性能,特殊情况可能会摧毁整个应用的性能。\n最后,尽管关于选择性和基数的经验法则值得去研究和分析,但一定要记住别忘了WHERE子句中的排序、分组和范围条件等其他因素,这些因素可能对查询的性能造成非常大的影响。\n5.3.5 聚簇索引 # 聚簇索引(7)并不是一种单独的索引类型,而是一种数据存储方式。具体的细节依赖于其实现方式,但InnoDB的聚簇索引实际上在同一个结构中保存了B-Tree索引和数据行。当表有聚簇索引时,它的数据行实际上存放在索引的叶子页(leaf page)中。术语“聚簇”表示数据行和相邻的键值紧凑地存储在一起(8)。因为无法同时把数据行存放在两个不同的地方,所以一个表只能有一个聚簇索引(不过,覆盖索引可以模拟多个聚簇索引的情况,本章后面将详细介绍)。\n因为是存储引擎负责实现索引,因此不是所有的存储引擎都支持聚簇索引。本节我们主要关注InnoDB,但是这里讨论的原理对于任何支持聚簇索引的存储引擎都是适用的。\n图5-3展示了聚簇索引中的记录是如何存放的。注意到,叶子页包含了行的全部数据,但是节点页只包含了索引列。在这个案例中,索引列包含的是整数值。\n图5-3:聚簇索引的数据分布\n一些数据库服务器允许选择哪个索引作为聚簇索引,但直到本书写作之际,还没有任何一个MySQL内建的存储引擎支持这一点。InnoDB将通过主键聚集数据,这也就是说图5-3中的“被索引的列”就是主键列。\n如果没有定义主键,InnoDB会选择一个唯一的非空索引代替。如果没有这样的索引,InnoDB会隐式定义一个主键来作为聚簇索引。InnoDB只聚集在同一个页面中的记录。包含相邻键值的页面可能会相距甚远。\n聚簇主键可能对性能有帮助,但也可能导致严重的性能问题。所以需要仔细地考虑聚簇索引,尤其是将表的存储引擎从InnoDB改成其他引擎的时候(反过来也一样)。\n聚集的数据有一些重要的优点:\n可以把相关数据保存在一起。例如实现电子邮箱时,可以根据用户ID来聚集数据,这样只需要从磁盘读取少数的数据页就能获取某个用户的全部邮件。如果没有使用聚簇索引,则每封邮件都可能导致一次磁盘I/O。 数据访问更快。聚簇索引将索引和数据保存在同一个B-Tree中,因此从聚簇索引中获取数据通常比在非聚簇索引中查找要快。 使用覆盖索引扫描的查询可以直接使用页节点中的主键值。 如果在设计表和查询时能充分利用上面的优点,那就能极大地提升性能。同时,聚簇索引也有一些缺点:\n聚簇数据最大限度地提高了I/O密集型应用的性能,但如果数据全部都放在内存中,则访问的顺序就没那么重要了,聚簇索引也就没什么优势了。 插入速度严重依赖于插入顺序。按照主键的顺序插入是加载数据到InnoDB表中速度最快的方式。但如果不是按照主键顺序加载数据,那么在加载完成后最好使用OPTIMIZE TABLE命令重新组织一下表。 更新聚簇索引列的代价很高,因为会强制InnoDB将每个被更新的行移动到新的位置。 基于聚簇索引的表在插入新行,或者主键被更新导致需要移动行的时候,可能面临“页分裂(page split)”的问题。当行的主键值要求必须将这一行插入到某个已满的页中时,存储引擎会将该页分裂成两个页面来容纳该行,这就是一次页分裂操作。页分裂会导致表占用更多的磁盘空间。 聚簇索引可能导致全表扫描变慢,尤其是行比较稀疏,或者由于页分裂导致数据存储不连续的时候。 二级索引(非聚簇索引)可能比想象的要更大,因为在二级索引的叶子节点包含了引用行的主键列。 二级索引访问需要两次索引查找,而不是一次。 最后一点可能让人有些疑惑,为什么二级索引需要两次索引查找?答案在于二级索引中保存的“行指针”的实质。要记住,二级索引叶子节点保存的不是指向行的物理位置的指针,而是行的主键值。\n这意味着通过二级索引查找行,存储引擎需要找到二级索引的叶子节点获得对应的主键值,然后根据这个值去聚簇索引中查找到对应的行。这里做了重复的工作:两次B-Tree查找而不是一次(9)。对于InnoDB,自适应哈希索引能够减少这样的重复工作。\nInnoDB和MyISAM的数据分布对比 # 聚簇索引和非聚簇索引的数据分布有区别,以及对应的主键索引和二级索引的数据分布也有区别,通常会让人感到困扰和意外。来看看InnoDB和MyISAM是如何存储下面这个表的:\nCREATE TABLE layout_test ( col1 int NOT NULL, col2 int NOT NULL, PRIMARY KEY(col1), KEY(col2) ); 假设该表的主键取值为1~10000,按照随机顺序插入并使用OPTIMIZE TABLE命令做了优化。换句话说,数据在磁盘上的存储方式已经最优,但行的顺序是随机的。列col2的值是从1~100之间随机赋值,所以有很多重复的值。\nMyISAM的数据分布。MyISAM的数据分布非常简单,所以先介绍它。MyISAM按照数据插入的顺序存储在磁盘上,如图5-4所示。\n图5-4:MyISAM表layout_test的数据分布\n在行的旁边显示了行号,从0开始递增。因为行是定长的,所以MyISAM可以从表的开头跳过所需的字节找到需要的行(MyISAM并不总是使用图5-4中的“行号”,而是根据定长还是变长的行使用不同策略)。\n这种分布方式很容易创建索引。下面显示的一系列图,隐藏了页的物理细节,只显示索引中的“节点”,索引中的每个叶子节点包含“行号”。图5-5显示了表的主键。\n图5-5:MyISAM表layout_test的主键分布\n这里忽略了一些细节,例如前一个B-Tree节点有多少个内部节点,不过这并不影响对非聚簇存储引擎的基本数据分布的理解。\n那col2列上的索引又会如何呢?有什么特殊的吗?回答是否定的:它和其他索引没有什么区别。图5-6显示了col2列上的索引。\n图5-6:MyISAM表layout_test的col2列索引的分布\n事实上,MyISAM中主键索引和其他索引在结构上没有什么不同。主键索引就是一个名为PRIMARY的唯一非空索引。\nInnoDB的数据分布。因为InnoDB支持聚簇索引,所以使用非常不同的方式存储同样的数据。InnoDB以如图5-7所示的方式存储数据。\n图5-7:InnoDB表layout_test的主键分布\n第一眼看上去,感觉该图和前面的图5-5没有什么不同,但再仔细看细节,会注意到该图显示了整个表,而不是只有索引。因为在InnoDB中,聚簇索引“就是”表,所以不像MyISAM那样需要独立的行存储。\n聚簇索引的每一个叶子节点都包含了主键值、事务ID、用于事务和MVCC(10)的回滚指针以及所有的剩余列(在这个例子中是col2)。如果主键是一个列前缀索引,InnoDB也会包含完整的主键列和剩下的其他列。\n还有一点和MyISAM的不同是,InnoDB的二级索引和聚簇索引很不相同。InnoDB二级索引的叶子节点中存储的不是“行指针”,而是主键值,并以此作为指向行的“指针”。这样的策略减少了当出现行移动或者数据页分裂时二级索引的维护工作。使用主键值当作指针会让二级索引占用更多的空间,换来的好处是,InnoDB在移动行时无须更新二级索引中的这个“指针”。\n图5-8显示了示例表的col2索引。每一个叶子节点都包含了索引列(这里是col2),紧接着是主键值(col1)。\n图5-8展示了B-Tree的叶子节点结构,但我们故意省略了非叶子节点这样的细节。InnoDB的非叶子节点包含了索引列和一个指向下级节点的指针(下一级节点可以是非叶子节点,也可以是叶子节点)。这对聚簇索引和二级索引都适用。\n图5-8:InnoDB表layout_test的二级索引分布\n图5-9是描述InnoDB和MyISAM如何存放表的抽象图。从图5-9中可以很容易看出InnoDB和MyISAM保存数据和索引的区别。\n图5-9:聚簇和非聚簇表对比图\n如果还没有理解聚簇索引和非聚簇索引有什么区别、为何有这些区别及这些区别的重要性,也不用担心。随着学习的深入,尤其是学完本章剩下的部分以及下一章以后,这些问题就会变得越发清楚。这些概念有些复杂,需要一些时间才能完全理解。\n在InnoDB表中按主键顺序插入行 # 如果正在使用InnoDB表并且没有什么数据需要聚集,那么可以定义一个代理键(surrogate key)作为主键,这种主键的数据应该和应用无关,最简单的方法是使用AUTO_INCREMENT自增列。这样可以保证数据行是按顺序写入,对于根据主键做关联操作的性能也会更好。\n最好避免随机的(不连续且值的分布范围非常大)聚簇索引,特别是对于I/O密集型的应用。例如,从性能的角度考虑,使用UUID来作为聚簇索引则会很糟糕:它使得聚簇索引的插入变得完全随机,这是最坏的情况,使得数据没有任何聚集特性。\n为了演示这一点,我们做如下两个基准测试。第一个使用整数ID插入userinfo表:\nCREATE TABLE userinfo ( id int unsigned NOT NULL AUTO_INCREMENT, name varchar(64) NOT NULL DEFAULT '', email varchar(64) NOT NULL DEFAULT '', password varchar(64) NOT NULL DEFAULT '', dob date DEFAULT NULL, address varchar(255) NOT NULL DEFAULT '', city varchar(64) NOT NULL DEFAULT '', state_id tinyint unsigned NOT NULL DEFAULT '0', zip varchar(8) NOT NULL DEFAULT '', country_id smallint unsigned NOT NULL DEFAULT '0', gender ('M','F')NOT NULL DEFAULT 'M', account_type varchar(32) NOT NULL DEFAULT '', verified tinyint NOT NULL DEFAULT '0', allow_mail tinyint unsigned NOT NULL DEFAULT '0', parrent_account int unsigned NOT NULL DEFAULT '0', closest_airport varchar(3) NOT NULL DEFAULT '', PRIMARY KEY (id), UNIQUE KEY email (email), KEY country_id (country_id), KEY state_id (state_id), KEY state_id_2 (state_id,city,address) ) ENGINE=InnoDB 注意到使用了自增的整数ID作为主键(11)。\n第二个例子是userinfo_uuid表。除了主键改为UUID,其余和前面的userinfo表完全相同。\nCREATE TABLE userinfo_uuid ( uuid varchar(36) NOT NULL, ... 我们测试了这两个表的设计。首先,我们在一个有足够内存容纳索引的服务器上向这两个表各插入100万条记录。然后向这两个表继续插入300万条记录,使索引的大小超过服务器的内存容量。表5-1对测试结果做了比较。\n表5-1:向InnoDB表插入数据的测试结果 表名 行数 时间(秒) 索引大小(MB) userinfo 1000000 137 342 userinfo_uuid 1000000 180 544 userinfo 3000000 1233 1036 userinfo_uuid 3000000 4525 1707\n注意到向UUID主键插入行不仅花费的时间更长,而且索引占用的空间也更大。这一方面是由于主键字段更长;另一方面毫无疑问是由于页分裂和碎片导致的。\n为了明白为什么会这样,来看看往第一个表中插入数据时,索引发生了什么变化。图5-10显示了插满一个页面后继续插入相邻的下一个页面的场景。\n图5-10:向聚簇索引插入顺序的索引值\n如图5-10所示,因为主键的值是顺序的,所以InnoDB把每一条记录都存储在上一条记录的后面。当达到页的最大填充因子时(InnoDB默认的最大填充因子是页大小的15/16,留出部分空间用于以后修改),下一条记录就会写入新的页中。一旦数据按照这种顺序的方式加载,主键页就会近似于被顺序的记录填满,这也正是所期望的结果(然而,二级索引页可能是不一样的)。\n对比一下向第二个使用了UUID聚簇索引的表插入数据,看看有什么不同,图5-11显示了结果。\n因为新行的主键值不一定比之前插入的大,所以InnoDB无法简单地总是把新行插入到索引的最后,而是需要为新的行寻找合适的位置——通常是已有数据的中间位置——并且分配空间。这会增加很多的额外工作,并导致数据分布不够优化。下面是总结的一些缺点:\n写入的目标页可能已经刷到磁盘上并从缓存中移除,或者是还没有被加载到缓存中,InnoDB在插入之前不得不先找到并从磁盘读取目标页到内存中。这将导致大量的随机I/O。 因为写入是乱序的,InnoDB不得不频繁地做页分裂操作,以便为新的行分配空间。页分裂会导致移动大量数据,一次插入最少需要修改三个页而不是一个页。 由于频繁的页分裂,页会变得稀疏并被不规则地填充,所以最终数据会有碎片。 在把这些随机值载入到聚簇索引以后,也许需要做一次OPTIMIZE TABLE来重建表并优化页的填充。\n从这个案例可以看出,使用InnoDB时应该尽可能地按主键顺序插入数据,并且尽可能地使用单调增加的聚簇键的值来插入新行。\n图5-11:向聚簇索引中插入无序的值\n顺序的主键什么时候会造成更坏的结果?\n对于高并发工作负载,在InnoDB中按主键顺序插入可能会造成明显的争用。主键的上界会成为“热点”。因为所有的插入都发生在这里,所以并发插入可能导致间隙锁竞争。另一个热点可能是AUTO_INCREMENT锁机制;如果遇到这个问题,则可能需要考虑重新设计表或者应用,或者更改innodb_autoinc_lock_mode配置。如果你的服务器版本还不支持innodb_autoinc_lock_mode参数,可以升级到新版本的InnoDB,可能对这种场景会工作得更好。\n5.3.6 覆盖索引 # 通常大家都会根据查询的WHERE条件来创建合适的索引,不过这只是索引优化的一个方面。设计优秀的索引应该考虑到整个查询,而不单单是WHERE条件部分。索引确实是一种查找数据的高效方式,但是MySQL也可以使用索引来直接获取列的数据,这样就不再需要读取数据行。如果索引的叶子节点中已经包含要查询的数据,那么还有什么必要再回表查询呢?如果一个索引包含(或者说覆盖)所有需要查询的字段的值,我们就称之为“覆盖索引”。\n覆盖索引是非常有用的工具,能够极大地提高性能。考虑一下如果查询只需要扫描索引而无须回表,会带来多少好处:\n索引条目通常远小于数据行大小,所以如果只需要读取索引,那MySQL就会极大地减少数据访问量。这对缓存的负载非常重要,因为这种情况下响应时间大部分花费在数据拷贝上。覆盖索引对于I/O密集型的应用也有帮助,因为索引比数据更小,更容易全部放入内存中(这对于MyISAM尤其正确,因为MyISAM能压缩索引以变得更小)。 因为索引是按照列值顺序存储的(至少在单个页内是如此),所以对于I/O密集型的范围查询会比随机从磁盘读取每一行数据的I/O要少得多。对于某些存储引擎,例如MyISAM和Percona XtraDB,甚至可以通过OPTIMIZE命令使得索引完全顺序排列,这让简单的范围查询能使用完全顺序的索引访问。 一些存储引擎如MyISAM在内存中只缓存索引,数据则依赖于操作系统来缓存,因此要访问数据需要一次系统调用。这可能会导致严重的性能问题,尤其是那些系统调用占了数据访问中的最大开销的场景。 由于InnoDB的聚簇索引,覆盖索引对InnoDB表特别有用。InnoDB的二级索引在叶子节点中保存了行的主键值,所以如果二级主键能够覆盖查询,则可以避免对主键索引的二次查询。 在所有这些场景中,在索引中满足查询的成本一般比查询行要小得多。\n不是所有类型的索引都可以成为覆盖索引。覆盖索引必须要存储索引列的值,而哈希索引、空间索引和全文索引等都不存储索引列的值,所以MySQL只能使用B-Tree索引做覆盖索引。另外,不同的存储引擎实现覆盖索引的方式也不同,而且不是所有的引擎都支持覆盖索引(在写作本书时,Memory存储引擎就不支持覆盖索引)。\n当发起一个被索引覆盖的查询(也叫做索引覆盖查询)时,在EXPLAIN的Extra列可以看到“Using index”的信息(12)。例如,表sakila.inventory有一个多列索引(store_id,flm_id)。MySQL如果只需访问这两列,就可以使用这个索引做覆盖索引,如下所示:\nmysql\u0026gt; ** EXPLAIN SELECT store_id, film_id FROM sakila.inventory\\G** *************************** 1. row *************************** id: 1 select_type: SIMPLE table: inventory type: index possible_keys: NULL key: idx_store_id_film_id key_len: 3 ref: NULL rows: 4673 Extra: Using index 索引覆盖查询还有很多陷阱可能会导致无法实现优化。MySQL查询优化器会在执行查询前判断是否有一个索引能进行覆盖。假设索引覆盖了WHERE条件中的字段,但不是整个查询涉及的字段。如果条件为假(false),MySQL 5.5和更早的版本也总是会回表获取数据行,尽管并不需要这一行且最终会被过滤掉。\n来看看为什么会发生这样的情况,以及如何重写查询以解决该问题。从下面的查询开始:\nmysql\u0026gt; ** EXPLAIN SELECT * FROM products WHERE actor='SEAN CARREY'** -\u0026gt; ** AND title like '%APOLLO%'\\G** *************************** 1. row *************************** id: 1 select_type: SIMPLE table: products type: ref possible_keys: ACTOR,IX_PROD_ACTOR key: ACTOR key_len: 52 ref: const rows: 10 Extra: Using where 这里索引无法覆盖该查询,有两个原因:\n没有任何索引能够覆盖这个查询。因为查询从表中选择了所有的列,而没有任何索引覆盖了所有的列。不过,理论上MySQL还有一个捷径可以利用:WHERE条件中的列是有索引可以覆盖的,因此MySQL可以使用该索引找到对应的actor并检查title是否匹配,过滤之后再读取需要的数据行。 MySQL不能在索引中执行LIKE操作。这是底层存储引擎API的限制,MySQL 5.5和更早的版本中只允许在索引中做简单比较操作(例如等于、不等于以及大于)。MySQL能在索引中做最左前缀匹配的LIKE比较,因为该操作可以转换为简单的比较操作,但是如果是通配符开头的LIKE查询,存储引擎就无法做比较匹配。这种情况下,MySQL服务器只能提取数据行的值而不是索引值来做比较。 也有办法可以解决上面说的两个问题,需要重写查询并巧妙地设计索引。先将索引扩展至覆盖三个数据列(artist,title,prod_id),然后按如下方式重写查询:\nmysql\u0026gt; ** EXPLAIN SELECT *** -\u0026gt; ** FROM products** -\u0026gt; ** JOIN (** -\u0026gt; ** SELECT prod_id** -\u0026gt; ** FROM products** -\u0026gt; ** WHERE actor='SEAN CARREY' AND title LIKE '%APOLLO%'** -\u0026gt; ** ) AS t1 ON (t1.prod_id=products.prod_id)\\G** *************************** 1. row *************************** id: 1 select_type: PRIMARY table: \u0026lt;derived2\u0026gt; ...omitted... *************************** 2. row *************************** id: 1 select_type: PRIMARY table: products ...omitted... *************************** 3. row *************************** id: 2 select_type: DERIVED table: products type: ref possible_keys: ACTOR,ACTOR_2,IX_PROD_ACTOR key: ACTOR_2 key_len: 52 ref: rows: 11 Extra: Using where; Using index 我们把这种方式叫做延迟关联(deferred join),因为延迟了对列的访问。在查询的第一阶段MySQL可以使用覆盖索引,在FROM子句的子查询中找到匹配的prod_id,然后根据这些prod_id值在外层查询匹配获取需要的所有列值。虽然无法使用索引覆盖整个查询,但总算比完全无法利用索引覆盖的好。\n这样优化的效果取决于WHERE条件匹配返回的行数。假设这个products表有100万行,我们来看一下上面两个查询在三个不同的数据集上的表现,每个数据集都包含100万行:\n第一个数据集,Sean Carrey出演了30000部作品,其中有20000部的标题中包含了Apollo。 第二个数据集,Sean Carrey出演了30000部作品,其中40部的标题中包含了Apollo。 第三个数据集,Sean Carrey出演了50部作品,其中10部的标题中包含了Apollo。 使用上面的三种数据集来测试两种不同的查询,得到的结果如表5-2所示。\n表5-2:索引覆盖查询和非覆盖查询的测试结果 数据集 原查询 优化后的查询 示例 1 每秒5 次查询 每秒5 次查询 示例 2 每秒7 次查询 每秒35 次查询 示例 3 每秒2400 次查询 每秒2000 次查询\n下面是对结果的分析:\n在示例1中,查询返回了一个很大的结果集,因此看不到优化的效果。大部分时间都花在读取和发送数据上了。 在示例2中,经过索引过滤,尤其是第二个条件过滤后只返回了很少的结果集,优化的效果非常明显:在这个数据集上性能提高了5倍,优化后的查询的效率主要得益于只需要读取40行完整数据行,而不是原查询中需要的30000行。 在示例3中,显示了子查询效率反而下降的情况。因为索引过滤时符合第一个条件的结果集已经很小,所以子查询带来的成本反而比从表中直接提取完整行更高。 在大多数存储引擎中,覆盖索引只能覆盖那些只访问索引中部分列的查询。不过,可以更进一步优化InnoDB。回想一下,InnoDB的二级索引的叶子节点都包含了主键的值,这意味着InnoDB的二级索引可以有效地利用这些“额外”的主键列来覆盖查询。\n例如,sakila.actor使用InnoDB存储引擎,并在last_name字段有二级索引,虽然该索引的列不包括主键actor_id,但也能够用于对actor_id做覆盖查询:\nmysql\u0026gt; ** EXPLAIN SELECT actor_id, last_name** -\u0026gt; ** FROM sakila.actor WHERE last_name = 'HOPPER'\\G** *************************** 1. row *************************** id: 1 select_type: SIMPLE table: actor type: ref possible_keys: idx_actor_last_name key: idx_actor_last_name key_len: 137 ref: const rows: 2 Extra: Using where; Using index 未来MySQL版本的改进\n上面提到的很多限制都是由于存储引擎API设计所导致的,目前的API设计不允许MySQL将过滤条件传到存储引擎层。如果MySQL在后续版本能够做到这一点,则可以把查询发送到数据上,而不是像现在这样只能把数据从存储引擎拉到服务器层,再根据查询条件过滤。在本书写作之际,MySQL 5.6版本(未正式发布)包含了在存储引擎API上所做的一个重要的改进,其被称为“索引条件推送(index condition pushdown)”。这个特性将大大改善现在的查询执行方式,如此一来上面介绍的很多技巧也就不再需要了。\n5.3.7 使用索引扫描来做排序 # MySQL有两种方式可以生成有序的结果:通过排序操作;或者按索引顺序扫描(13);如果EXPLAIN出来的type列的值为“index”,则说明MySQL使用了索引扫描来做排序(不要和Extra列的“Using index”搞混淆了)。\n扫描索引本身是很快的,因为只需要从一条索引记录移动到紧接着的下一条记录。但如果索引不能覆盖查询所需的全部列,那就不得不每扫描一条索引记录就都回表查询一次对应的行。这基本上都是随机I/O,因此按索引顺序读取数据的速度通常要比顺序地全表扫描慢,尤其是在I/O密集型的工作负载时。\nMySQL可以使用同一个索引既满足排序,又用于查找行。因此,如果可能,设计索引时应该尽可能地同时满足这两种任务,这样是最好的。\n只有当索引的列顺序和ORDER BY子句的顺序完全一致,并且所有列的排序方向(倒序或正序)都一样时,MySQL才能够使用索引来对结果做排序(14)。如果查询需要关联多张表,则只有当ORDER BY子句引用的字段全部为第一个表时,才能使用索引做排序。ORDER BY子句和查找型查询的限制是一样的:需要满足索引的最左前缀的要求;否则,MySQL都需要执行排序操作,而无法利用索引排序。\n有一种情况下ORDER BY子句可以不满足索引的最左前缀的要求,就是前导列为常量的时候。如果WHERE子句或者JOIN子句中对这些列指定了常量,就可以“弥补”索引的不足。\n例如,Sakila示例数据库的表rental在列(rental_date,inventory_id,customer_id)上有名为rental_date的索引。\n(rental_date, inventory_id, customer_id): CREATE TABLE rental ( ... PRIMARY KEY (rental_id), UNIQUE KEY rental_date (rental_date,inventory_id,customer_id), KEY idx_fk_inventory_id (inventory_id), KEY idx_fk_customer_id (customer_id), KEY idx_fk_staff_id (staff_id), ... ); MySQL可以使用rental_date索引为下面的查询做排序,从EXPLAIN中可以看到没有出现文件排序(filesort)操作(15):\nmysql\u0026gt; ** EXPLAIN SELECT rental_id, staff_id FROM sakila.rental** -\u0026gt; ** WHERE rental_date = '2005-05-25'** -\u0026gt; ** ORDER BY inventory_id, customer_id\\G** *************************** 1. row *************************** type: ref possible_keys: rental_date key: rental_date rows: 1 Extra: Using where 即使ORDER BY子句不满足索引的最左前缀的要求,也可以用于查询排序,这是因为索引的第一列被指定为一个常数。\n还有更多可以使用索引做排序的查询示例。下面这个查询可以利用索引排序,是因为查询为索引的第一列提供了常量条件,而使用第二列进行排序,将两列组合在一起,就形成了索引的最左前缀:\n... WHERE rental_date = '2005-05-25' ORDER BY inventory_id DESC; 下面这个查询也没问题,因为ORDER BY使用的两列就是索引的最左前缀:\n下面是一些不能使用索引做排序的查询:\n下面这个查询使用了两种不同的排序方向,但是索引列都是正序排序的:\n... WHERE rental_date = '2005-05-25' ORDER BY inventory_id DESC, customer_id ASC; 下面这个查询的ORDER BY子句中引用了一个不在索引中的列:\n... WHERE rental_date='2005-05-25' ORDER BY inventory_id,staff_id; 下面这个查询在索引列的第一列上是范围条件,所以MySQL无法使用索引的其余列:\n... WHERE rental_date \u0026gt; '2005-05-25' ORDER BY inventory_id, customer_id; 这个查询在y inventory_id列上有多个等于条件。对于排序来说,这也是一种范围查询:\n... WHERE rental_date = '2005-05-25' AND inventory_id IN(1,2) ORDER BY customer_ id; 下面这个例子理论上是可以使用索引进行关联排序的,但由于优化器在优化时将film_actor表当作关联的第二张表,所以实际上无法使用索引:\n使用索引做排序的一个最重要的用法是当查询同时有ORDER BY和LIMIT子句的时候。后面我们会具体介绍这些内容。\n5.3.8 压缩(前缀压缩)索引 # MyISAM使用前缀压缩来减少索引的大小,从而让更多的索引可以放入内存中,这在某些情况下能极大地提高性能。默认只压缩字符串,但通过参数设置也可以对整数做压缩。MyISAM压缩每个索引块的方法是,先完全保存索引块中的第一个值,然后将其他值和第一个值进行比较得到相同前缀的字节数和剩余的不同后缀部分,把这部分存储起来即可。例如,索引块中的第一个值是“perform”,第二个值是“performance”,那么第二个值的前缀压缩后存储的是类似“7,ance”这样的形式。MyISAM对行指针也采用类似的前缀压缩方式。\n压缩块使用更少的空间,代价是某些操作可能更慢。因为每个值的压缩前缀都依赖前面的值,所以MyISAM查找时无法在索引块使用二分查找而只能从头开始扫描。正序的扫描速度还不错,但是如果是倒序扫描——例如ORDER BY DESC——就不是很好了。所有在块中查找某一行的操作平均都需要扫描半个索引块。\n测试表明,对于CPU密集型应用,因为扫描需要随机查找,压缩索引使得MyISAM在索引查找上要慢好几倍。压缩索引的倒序扫描就更慢了。压缩索引需要在CPU内存资源与磁盘之间做权衡。压缩索引可能只需要十分之一大小的磁盘空间,如果是I/O密集型应用,对某些查询带来的好处会比成本多很多。\n可以在CREATE TABLE语句中指定PACK_KEYS参数来控制索引压缩的方式。\n5.3.9 冗余和重复索引 # MySQL允许在相同列上创建多个索引,无论是有意的还是无意的。MySQL需要单独维护重复的索引,并且优化器在优化查询的时候也需要逐个地进行考虑,这会影响性能。\n重复索引是指在相同的列上按照相同的顺序创建的相同类型的索引。应该避免这样创建重复索引,发现以后也应该立即移除。\n有时会在不经意间创建了重复索引,例如下面的代码:\nCREATE TABLE test ( ID INT NOT NULL PRIMARY KEY, A INT NOT NULL, B INT NOT NULL, UNIQUE(ID), INDEX(ID) ) ENGINE=InnoDB; 一个经验不足的用户可能是想创建一个主键,先加上唯一限制,然后再加上索引以供查询使用。事实上,MySQL的唯一限制和主键限制都是通过索引实现的,因此,上面的写法实际上在相同的列上创建了三个重复的索引。通常并没有理由这样做,除非是在同一列上创建不同类型的索引来满足不同的查询需求(16)。\n冗余索引和重复索引有一些不同。如果创建了索引(A,B),再创建索引(A)就是冗余索引,因为这只是前一个索引的前缀索引。因此索引(A,B)也可以当作索引(A)来使用(这种冗余只是对B-Tree索引来说的)。但是如果再创建索引(B,A),则不是冗余索引,索引(B)也不是,因为B不是索引(A,B)的最左前缀列。另外,其他不同类型的索引(例如哈希索引或者全文索引)也不会是B-Tree索引的冗余索引,而无论覆盖的索引列是什么。\n冗余索引通常发生在为表添加新索引的时候。例如,有人可能会增加一个新的索引(A,B)而不是扩展已有的索引(A)。还有一种情况是将一个索引扩展为(A,ID),其中ID是主键,对于InnoDB来说主键列已经包含在二级索引中了,所以这也是冗余的。\n大多数情况下都不需要冗余索引,应该尽量扩展已有的索引而不是创建新索引。但也有时候出于性能方面的考虑需要冗余索引,因为扩展已有的索引会导致其变得太大,从而影响其他使用该索引的查询的性能。\n例如,如果在整数列上有一个索引,现在需要额外增加一个很长的VARCHAR列来扩展该索引,那性能可能会急剧下降。特别是有查询把这个索引当作覆盖索引,或者这是MyISAM表并且有很多范围查询(由于MyISAM的前缀压缩)的时候。\n考虑一下前面“在InnoDB中按主键顺序插入行”一节提到的userinfo表。这个表有1000000行,对每个state_id值大概有20000条记录。在state_id列有一个索引对下面的查询有用,假设查询名为Q1:\nmysql\u0026gt; ** SELECT count(*) FROM userinfo WHERE state_id=5;** 一个简单的测试表明该查询的执行速度大概是每秒115次(QPS)。还有一个相关查询需要检索几个列的值,而不是只统计行数,假设名为Q2:\nmysql\u0026gt; ** SELECT count(*) FROM userinfo WHERE state_id=5;** 对于这个查询,测试结果QPS小于10(17)。提升该查询性能的最简单办法就是扩展索引为(state_id,city,address),让索引能覆盖查询:\nmysql\u0026gt; ** ALTER TABLE userinfo DROP KEY state_id,** -\u0026gt; ** ADD KEY state_id_2 (state_id, city, address);** 索引扩展后,Q2运行得更快了,但是Q1却变慢了。如果我们想让两个查询都变得更快,就需要两个索引,尽管这样一来原来的单列索引是冗余的了。表5-3显示这两个查询在不同的索引策略下的详细结果,分别使用MyISAM和InnoDB存储引擎。注意到只有state_id_2索引时,InnoDB引擎上的查询Q1的性能下降并不明显,这是因为InnoDB没有使用索引压缩。\n表5-3:使用不同索引策略的SELECT查询的QPS测试结果 有两个索引的缺点是索引成本更高。表5-4显示了向表中插入100万行数据所需要的时间。\n表5-4:在使用不同索引策略时插入100万行数据的速度 可以看到,表中的索引越多插入速度会越慢。一般来说,增加新索引将会导致INSERT、UPDATE、DELETE等操作的速度变慢,特别是当新增索引后导致达到了内存瓶颈的时候。解决冗余索引和重复索引的方法很简单,删除这些索引就可以,但首先要做的是找出这样的索引。可以通过写一些复杂的访问INFORMATION_SCHEMA表的查询来找,不过还有两个更简单的方法。可使用Shlomi Noach的common_schema中的一些视图来定位,common_schema是一系列可以安装到服务器上的常用的存储和视图( http://code.google.com/p/common-schema/)。这比自己编写查询要快而且简单。另外也可以使用Percona Toolkit中的pt-duplicate-key-checker,该工具通过分析表结构来找出冗余和重复的索引。对于大型服务器来说,使用外部的工具可能更合适些;如果服务器上有大量的数据或者大量的表,查询INFORMATION_SCHEMA表可能会导致性能问题。\n在决定哪些索引可以被删除的时候要非常小心。回忆一下,在前面的InnoDB的示例表中,因为二级索引的叶子节点包含了主键值,所以在列(A)上的索引就相当于在(A,ID)上的索引。如果有像WHERE A=5 ORDER BY ID这样的查询,这个索引会很有作用。但如果将索引扩展为(A,B),则实际上就变成了(A,B,ID),那么上面查询的ORDER BY子句就无法使用该索引做排序,而只能用文件排序了。所以,建议使用Percona工具箱中的pt-upgrade工具来仔细检查计划中的索引变更。\n5.3.10 未使用的索引 # 除了冗余索引和重复索引,可能还会有一些服务器永远不用的索引。这样的索引完全是累赘,建议考虑删除(18)。有两个工具可以帮助定位未使用的索引。最简单有效的办法是在Percona Server或者MariaDB中先打开userstates服务器变量(默认是关闭的),然后让服务器正常运行一段时间,再通过查询INFORMATION_SCHEMA.INDEX_STATISTICS就能查到每个索引的使用频率。\n另外,还可以使用Percona Toolkit中的pt-index-usage,该工具可以读取查询日志,并对日志中的每条查询进行EXPLAIN操作,然后打印出关于索引和查询的报告。这个工具不仅可以找出哪些索引是未使用的,还可以了解查询的执行计划——例如在某些情况有些类似的查询的执行方式不一样,这可以帮助你定位到那些偶尔服务质量差的查询,优化它们以得到一致的性能表现。该工具也可以将结果写入到MySQL的表中,方便查询结果。\n5.3.11 索引和锁 # 索引可以让查询锁定更少的行。如果你的查询从不访问那些不需要的行,那么就会锁定更少的行,从两个方面来看这对性能都有好处。首先,虽然InnoDB的行锁效率很高,内存使用也很少,但是锁定行的时候仍然会带来额外开销;其次,锁定超过需要的行会增加锁争用并减少并发性。\nInnoDB只有在访问行的时候才会对其加锁,而索引能够减少InnoDB访问的行数,从而减少锁的数量。但这只有当InnoDB在存储引擎层能够过滤掉所有不需要的行时才有效。如果索引无法过滤掉无效的行,那么在InnoDB检索到数据并返回给服务器层以后,MySQL服务器才能应用WHERE子句(19)。这时已经无法避免锁定行了:InnoDB已经锁住了这些行,到适当的时候才释放。在MySQL 5.1和更新的版本中,InnoDB可以在服务器端过滤掉行后就释放锁,但是在早期的MySQL版本中,InnoDB只有在事务提交后才能释放锁。\n通过下面的例子再次使用数据库Sakila很好地解释了这些情况:\nmysql\u0026gt; ** SET AUTOCOMMIT=0;** mysql\u0026gt; ** BEGIN;** mysql\u0026gt; ** SELECT actor_id FROM sakila.actor WHERE actor_id \u0026lt; 5** -\u0026gt; ** AND actor_id \u0026lt;\u0026gt; 1 FOR UPDATE;** 这条查询仅仅会返回2~4之间的行,但是实际上获取了1~4之间的行的排他锁。InnoDB会锁住第1行,这是因为MySQL为该查询选择的执行计划是索引范围扫描:\n换句话说,底层存储引擎的操作是“从索引的开头开始获取满足条件actor_id\u0026lt;5的记录”,服务器并没有告诉InnoDB可以过滤第1行的WHERE条件。注意到EXPLAIN的Extra列出现了“Using where”,这表示MySQL服务器将存储引擎返回行以后再应用WHERE过滤条件。\n下面的第二个查询就能证明第1行确实已经被锁定,尽管第一个查询的结果中并没有这个第1行。保持第一个连接打开,然后开启第二个连接并执行如下查询:\nmysql\u0026gt; ** SET AUTOCOMMIT=0;** mysql\u0026gt; ** BEGIN;** mysql\u0026gt; ** SELECT actor_id FROM sakila.actor WHERE actor_id = 1 FOR UPDATE;** 这个查询将会挂起,直到第一个事务释放第1行的锁。这个行为对于基于语句的复制(将在第10章讨论)的正常运行来说是必要的。(20)\n就像这个例子显示的,即使使用了索引,InnoDB也可能锁住一些不需要的数据。如果不能使用索引查找和锁定行的话问题可能会更糟糕,MySQL会做全表扫描并锁住所有的行,而不管是不是需要。\n关于InnoDB、索引和锁有一些很少有人知道的细节:InnoDB在二级索引上使用共享(读)锁,但访问主键索引需要排他(写)锁。这消除了使用覆盖索引的可能性,并且使得SELECT FOR UPDATE比LOCK IN SHARE MODE或非锁定查询要慢很多。\n5.4 索引案例学习 # 理解索引最好的办法是结合示例,所以这里准备了一个索引的案例。\n假设要设计一个在线约会网站,用户信息表有很多列,包括国家、地区、城市、性别、眼睛颜色,等等。网站必须支持上面这些特征的各种组合来搜索用户,还必须允许根据用户的最后在线时间、其他会员对用户的评分等对用户进行排序并对结果进行限制。如何设计索引满足上面的复杂需求呢?\n出人意料的是第一件需要考虑的事情是需要使用索引来排序,还是先检索数据再排序。使用索引排序会严格限制索引和查询的设计。例如,如果希望使用索引做根据其他会员对用户的评分的排序,则WHERE条件中的age BETWEEN 18 AND 25就无法使用索引。如果MySQL使用某个索引进行范围查询,也就无法再使用另一个索引(或者是该索引的后续字段)进行排序了。如果这是很常见的WHERE条件,那么我们当然就会认为很多查询需要做排序操作(例如文件排序filesort)。\n5.4.1 支持多种过滤条件 # 现在需要看看哪些列拥有很多不同的取值,哪些列在WHERE子句中出现得最频繁。在有更多不同值的列上创建索引的选择性会更好。一般来说这样做都是对的,因为可以让MySQL更有效地过滤掉不需要的行。\ncountry列的选择性通常不高,但可能很多查询都会用到。sex列的选择性肯定很低,但也会在很多查询中用到。所以考虑到使用的频率,还是建议在创建不同组合索引的时候将(sex,country)列作为前缀。\n但根据传统的经验不是说不应该在选择性低的列上创建索引的吗?那为什么这里要将两个选择性都很低的字段作为索引的前缀列?我们的脑子坏了?\n我们的脑子当然没坏。这么做有两个理由:第一点,如前所述几乎所有的查询都会用到sex列。前面曾提到,几乎每一个查询都会用到sex列,甚至会把网站设计成每次都只能按某一种性别搜索用户。更重要的一点是,索引中加上这一列也没有坏处,即使查询没有使用sex列也可以通过下面的“诀窍”绕过。\n这个“诀窍”就是:如果某个查询不限制性别,那么可以通过在查询条件中新增AND SEX IN(\u0026rsquo;m\u0026rsquo;,\u0026lsquo;f\u0026rsquo;)来让MySQL选择该索引。这样写并不会过滤任何行,和没有这个条件时返回的结果相同。但是必须加上这个列的条件,MySQL才能够匹配索引的最左前缀。这个“诀窍”在这类场景中非常有效,但如果列有太多不同的值,就会让IN()列表太长,这样做就不行了。\n这个案例显示了一个基本原则:考虑表上所有的选项。当设计索引时,不要只为现有的查询考虑需要哪些索引,还需要考虑对查询进行优化。如果发现某些查询需要创建新索引,但是这个索引又会降低另一些查询的效率,那么应该想一下是否能优化原来的查询。应该同时优化查询和索引以找到最佳的平衡,而不是闭门造车去设计最完美的索引。\n接下来,需要考虑其他常见WHERE条件的组合,并需要了解哪些组合在没有合适索引的情况下会很慢。(sex,country,age)上的索引就是一个很明显的选择,另外很有可能还需要(sex,country,region,age)和(sex,country,region,city,age)这样的组合索引。\n这样就会需要大量的索引。如果想尽可能重用索引而不是建立大量的组合索引,可以使用前面提到的IN()的技巧来避免同时需要(sex,country,age)和(sex,country,region,age)的索引。如果没有指定这个字段搜索,就需要定义一个全部国家列表,或者国家的全部地区列表,来确保索引前缀有同样的约束(组合所有国家、地区、性别将会是一个非常大的条件)。\n这些索引将满足大部分最常见的搜索查询,但是如何为一些生僻的搜索条件(比如has_pictures、eye_color、hair_color和education)来设计索引呢?这些列的选择性高、使用也不频繁,可以选择忽略它们,让MySQL多扫描一些额外的行即可。另一个可选的方法是在age列的前面加上这些列,在查询时使用前面提到过的IN()技术来处理搜索时没有指定这些列的场景。\n你可能已经注意到了,我们一直将age列放在索引的最后面。age列有什么特殊的地方吗?为什么要放在索引的最后?我们总是尽可能让MySQL使用更多的索引列,因为查询只能使用索引的最左前缀,直到遇到第一个范围条件列。前面提到的列在WHERE子句中都是等于条件,但是age列则多半是范围查询(例如查找年龄在18~25岁之间的人)。\n当然,也可以使用IN()来代替范围查询,例如年龄条件改写为IN(18,19,20,21,22,23,24,25),但不是所有的范围查询都可以转换。这里描述的基本原则是,尽可能将需要做范围查询的列放到索引的后面,以便优化器能使用尽可能多的索引列。\n前面提到可以在索引中加入更多的列,并通过IN()的方式覆盖那些不在WHERE子句中的列。但这种技巧也不能滥用,否则可能会带来麻烦。因为每额外增加一个IN()条件,优化器需要做的组合都将以指数形式增加,最终可能会极大地降低查询性能。考虑下面的WHERE子句:\nWHERE eye_color IN('brown','blue','hazel') AND hair_color IN('black','red','blonde','brown') AND sex IN('M','F') 优化器则会转化成4×3×2=24种组合,执行计划需要检查WHERE子句中所有的24种组合。对于MySQL来说,24种组合并不是很夸张,但如果组合数达到上千个则需要特别小心。老版本的MySQL在IN()组合条件过多的时候会有很多问题。查询优化可能需要花很多时间,并消耗大量的内存。新版本的MySQL在组合数超过一定数量后就不再进行执行计划评估了,这可能会导致MySQL不能很好地利用索引。\n5.4.2 避免多个范围条件 # Avoiding Multiple Range Conditions\n假设我们有一个last_online列并希望通过下面的查询显示在过去几周上线过的用户:\nWHERE eye_color IN('brown','blue','hazel') AND hair_color IN('black','red','blonde','brown') AND sex IN('M','F') AND last_online \u0026gt; DATE_SUB(NOW(), INTERVAL 7 DAY) AND age BETWEEN 18 AND 25 什么是范围条件?\n从EXPLAIN的输出很难区分MySQL是要查询范围值,还是查询列表值。EXPLAIN使用同样的词“range”来描述这两种情况。例如,从type列来看,MySQL会把下面这种查询当作是“range”类型:\nmysql\u0026gt; ** EXPLAIN SELECT actor_id FROM sakila.actor** -\u0026gt; ** WHERE actor_id \u0026gt; 45\\G** ************************* 1. row ************************* id: 1 select_type: SIMPLE table: actor type: range 但是下面这条查询呢?\nmysql\u0026gt; ** EXPLAIN SELECT actor_id FROM sakila.actor** -\u0026gt; ** WHERE actor_id IN(1, 4, 99)\\G** ************************* 1. row ************************* id: 1 select_type: SIMPLE table: actor type: range 从EXPLAIN的结果是无法区分这两者的,但可以从值的范围和多个等于条件来得出不同。在我们看来,第二个查询就是多个等值条件查询。\n我们不是挑剔:这两种访问效率是不同的。对于范围条件查询,MySQL无法再使用范围列后面的其他索引列了,但是对于“多个等值条件查询”则没有这个限制。\n这个查询有一个问题:它有两个范围条件,last_online列和age列,MySQL可以使用last_online列索引或者age列索引,但无法同时使用它们。\n如果条件中只有last_online而没有age,那么我们可能考虑在索引的后面加上last_online列。这里考虑如果我们无法把age字段转换为一个IN()的列表,并且仍要求对于同时有last_online和age这两个维度的范围查询的速度很快,那该怎么办?答案是,很遗憾没有一个直接的办法能够解决这个问题。但是我们能够将其中的一个范围查询转换为一个简单的等值比较。为了实现这一点,我们需要事先计算好一个active列,这个字段由定时任务来维护。当用户每次登录时,将对应值设置为1,并且将过去连续七天未曾登录的用户的值设置为0。\n这个方法可以让MySQL使用(active,sex,country,age)索引。active列并不是完全精确的,但是对于这类查询来说,对精度的要求也没有那么高。如果需要精确数据,可以把last_online列放到WHERE子句,但不加入到索引中。这和本章前面通过计算URL哈希值来实现URL的快速查找类似。所以这个查询条件没法使用任何索引,但因为这个条件的过滤性不高,即使在索引中加入该列也没有太大的帮助。换个角度来说,缺乏合适的索引对该查询的影响也不明显。\n到目前为止,我们可以看到:如果用户希望同时看到活跃和不活跃的用户,可以在查询中使用IN()列表。我们已经加入了很多这样的列表,但另外一个可选的方案就只能是为不同的组合列创建单独的索引。至少需要建立如下的索引:(active,sex,country,age),(active,country,age),(sex,country,age)和(country,age)。这些索引对某个具体的查询来说可能都是更优化的,但是考虑到索引的维护和额外的空间占用的代价,这个可选方案就不是一个好策略了。\n在这个案例中,优化器的特性是影响索引策略的一个很重要的因素。如果未来版本的MySQL能够实现松散索引扫描,就能在一个索引上使用多个范围条件,那也就不需要为上面考虑的这类查询使用IN()列表了。\n5.4.3 优化排序 # 在这个学习案例中,最后要介绍的是排序。使用文件排序对小数据集是很快的,但如果一个查询匹配的结果有上百万行的话会怎样?例如如果WHERE子句只有sex列,如何排序?\n对于那些选择性非常低的列,可以增加一些特殊的索引来做排序。例如,可以创建(sex,rating)索引用于下面的查询:\nmysql\u0026gt; ** SELECT\u0026lt;cols\u0026gt; FROM profiles WHERE sex='M' ORDER BY rating LIMIT 10;** 这个查询同时使用了ORDER BY和LIMIT,如果没有索引的话会很慢。\n即使有索引,如果用户界面上需要翻页,并且翻页翻到比较靠后时查询也可能非常慢。下面这个查询就通过ORDER BY和LIMIT偏移量的组合翻页到很后面的时候:\nmysql\u0026gt; ** SELECT\u0026lt;cols\u0026gt; FROM profiles WHERE sex='M' ORDER BY rating LIMIT 100000; 10;** 无论如何创建索引,这种查询都是个严重的问题。因为随着偏移量的增加,MySQL需要花费大量的时间来扫描需要丢弃的数据。反范式化、预先计算和缓存可能是解决这类查询的仅有策略。一个更好的办法是限制用户能够翻页的数量,实际上这对用户体验的影响不大,因为用户很少会真正在乎搜索结果的第10000页。\n优化这类索引的另一个比较好的策略是使用延迟关联,通过使用覆盖索引查询返回需要的主键,再根据这些主键关联原表获得需要的行。这可以减少MySQL扫描那些需要丢弃的行数。下面这个查询显示了如何高效地使用(sex,rating)索引进行排序和分页:\nmysql\u0026gt; ** SELECT \u0026lt;cols\u0026gt; FROM profiles INNER JOIN (** -\u0026gt; ** SELECT \u0026lt;primary key cols\u0026gt; FROM profiles** -\u0026gt; ** WHERE x.sex='M' ORDER BY rating LIMIT 100000, 10** -\u0026gt; ** ) AS x USING(\u0026lt;primary key cols\u0026gt;);** 5.5 维护索引和表 # 即使用正确的类型创建了表并加上了合适的索引,工作也没有结束:还需要维护表和索引来确保它们都正常工作。维护表有三个主要的目的:找到并修复损坏的表,维护准确的索引统计信息,减少碎片。\n5.5.1 找到并修复损坏的表 # 表损坏(corruption)是很糟糕的事情。对于MyISAM存储引擎,表损坏通常是系统崩溃导致的。其他的引擎也会由于硬件问题、MySQL本身的缺陷或者操作系统的问题导致索引损坏。\n损坏的索引会导致查询返回错误的结果或者莫须有的主键冲突等问题,严重时甚至还会导致数据库的崩溃。如果你遇到了古怪的问题——例如一些不应该发生的错误——可以尝试运行CHECK TABLE来检查是否发生了表损坏(注意有些存储引擎不支持该命令;而有些引擎则支持以不同的选项来控制完全检查表的方式)。CHECK TABLE通常能够找出大多数的表和索引的错误。\n可以使用REPAIR TABLE命令来修复损坏的表,但同样不是所有的存储引擎都支持该命令。如果存储引擎不支持,也可通过一个不做任何操作(no-op)的ALTER操作来重建表,例如修改表的存储引擎为当前的引擎。下面是一个针对InnoDB表的例子:\nmysql\u0026gt; ** ALTER TABLE innodb_tbl ENGINE=INNODB;** 此外,也可以使用一些存储引擎相关的离线工具,例如myisamchk;或者将数据导出一份,然后再重新导入。不过,如果损坏的是系统区域,或者是表的“行数据”区域,而不是索引,那么上面的办法就没有用了。在这种情况下,可以从备份中恢复表,或者尝试从损坏的数据文件中尽可能地恢复数据。\n如果InnoDB引擎的表出现了损坏,那么一定是发生了严重的错误,需要立刻调查一下原因。InnoDB一般不会出现损坏。InnoDB的设计保证了它并不容易被损坏。如果发生损坏,一般要么是数据库的硬件问题例如内存或者磁盘问题(有可能),要么是由于数据库管理员的错误例如在MySQL外部操作了数据文件(有可能),抑或是InnoDB本身的缺陷(不太可能)。常见的类似错误通常是由于尝试使用rsync备份InnoDB导致的。不存在什么查询能够让InnoDB表损坏,也不用担心暗处有“陷阱”。如果某条查询导致InnoDB数据的损坏,那一定是遇到了bug,而不是查询的问题。\n如果遇到数据损坏,最重要的是找出是什么导致了损坏,而不只是简单地修复,否则很有可能还会不断地损坏。可以通过设置innodb_force_recovery参数进入InnoDB的强制恢复模式来修复数据,更多细节可以参考MySQL手册。另外,还可以使用开源的InnoDB数据恢复工具箱(InnoDB Data Recovery Toolkit)直接从InnoDB数据文件恢复出数据(下载地址: http://www.percona.com/software/mysql-innodb-data-recovery-tools/)。\n5.5.2 更新索引统计信息 # MySQL的查询优化器会通过两个API来了解存储引擎的索引值的分布信息,以决定如何使用索引。第一个API是records_in_range(),通过向存储引擎传入两个边界值获取在这个范围大概有多少条记录。对于某些存储引擎,该接口返回精确值,例如MyISAM;但对于另一些存储引擎则是一个估算值,例如InnoDB。\n第二个API是info(),该接口返回各种类型的数据,包括索引的基数(每个键值有多少条记录)。\n如果存储引擎向优化器提供的扫描行数信息是不准确的数据,或者执行计划本身太复杂以致无法准确地获取各个阶段匹配的行数,那么优化器会使用索引统计信息来估算扫描行数。MySQL优化器使用的是基于成本的模型,而衡量成本的主要指标就是一个查询需要扫描多少行。如果表没有统计信息,或者统计信息不准确,优化器就很有可能做出错误的决定。可以通过运行ANALYZE TABLE来重新生成统计信息解决这个问题。\n每种存储引擎实现索引统计信息的方式不同,所以需要进行ANALYZE TABLE的频率也因不同的引擎而不同,每次运行的成本也不同:\nMemory引擎根本不存储索引统计信息。 MyISAM将索引统计信息存储在磁盘中,ANALYZE TABLE需要进行一次全索引扫描来计算索引基数。在整个过程中需要锁表。 直到MySQL 5.5版本,InnoDB也不在磁盘存储索引统计信息,而是通过随机的索y引访问进行评估并将其存储在内存中。 可以使用SHOW INDEX FROM命令来查看索引的基数(Cardinality)。例如:\nmysql\u0026gt; ** SHOW INDEX FROM sakila.actor\\G** *************************** 1. row *************************** Table: actor Non_unique: 0 Key_name: PRIMARY Seq_in_index: 1 Column_name: actor_id Collation: A Cardinality: 200 Sub_part: NULL Packed: NULL Null: Index_type: BTREE Comment: *************************** 2. row *************************** Table: actor Non_unique: 1 Key_name: idx_actor_last_name Seq_in_index: 1 Column_name: last_name Collation: A Cardinality: 200 Sub_part: NULL Packed: NULL Null: Index_type: BTREE Comment: 这个命令输出了很多关于索引的信息,在MySQL手册中对上面每个字段的含义都有详细的解释。这里需要特别提及的是索引列的基数(Cardinality),其显示了存储引擎估算索引列有多少个不同的取值。在MySQL 5.0和更新的版本中,还可以通过INFORMATION_SCHEMA.STATISTICS表很方便地查询到这些信息。例如基于INFORMATION_SCHEMA的表,可以编写一个查询给出当前选择性比较低的索引。需要注意的是,如果服务器上的库表非常多,则从这里获取元数据的速度可能会非常慢,而且会给MySQL带来额外的压力。\nInnoDB的统计信息值得深入研究。InnoDB引擎通过抽样的方式来计算统计信息,首先随机地读取少量的索引页面,然后以此为样本计算索引的统计信息。在老的InnoDB版本中,样本页面数是8,新版本的InnoDB可以通过参数innodb_stats_sample_pages来设置样本页的数量。设置更大的值,理论上来说可以帮助生成更准确的索引信息,特别是对于某些超大的数据表来说,但具体设置多大合适依赖于具体的环境。\nInnoDB会在表首次打开,或者执行ANALYZE TABLE,抑或表的大小发生非常大的变化(大小变化超过十六分之一或者新插入了20亿行都会触发)的时候计算索引的统计信息。\nInnoDB在打开某些INFORMATION_SCHEMA表,或者使用SHOW TABLE STATUS和SHOW INDEX,抑或在MySQL客户端开启自动补全功能的时候都会触发索引统计信息的更新。如果服务器上有大量的数据,这可能就是个很严重的问题,尤其是当I/O比较慢的时候。客户端或者监控程序触发索引信息采样更新时可能会导致大量的锁,并给服务器带来很多的额外压力,这会让用户因为启动时间漫长而沮丧。只要SHOW INDEX查看索引统计信息,就一定会触发统计信息的更新。可以关闭innodb_stats_on_metadata参数来避免上面提到的问题。\n如果使用Percona版本,使用的就是XtraDB引擎而不是原生的InnoDB引擎,那么可以通过innodb_stats_auto_update参数来禁止通过自动采样的方式更新索引统计信息,这时需要手动执行ANALYZE TABLE命令来更新统计信息。如果某些查询执行计划很不稳定的话,可以用该办法固化查询计划。我们当初引入这个参数也正是为了解决一些客户的这种问题。\n如果想要更稳定的执行计划,并在系统重启后更快地生成这些统计信息,那么可以使用系统表来持久化这些索引统计信息。甚至还可以在不同的机器间迁移索引统计信息,这样新环境启动时就无须再收集这些数据。在Percona 5.1版本和官方的5.6版本都已经加入这个特性。在Percona版本中通过innodb_use_sys_stats_table参数可以启用该特性,官方5.6版本则通过innodb_analyze_is_persistent参数控制。\n一旦关闭索引统计信息的自动更新,那么就需要周期性地使用ANALYZE TABLE来手动更新。否则,索引统计信息就会永远不变。如果数据分布发生大的变化,可能会出现一些很糟糕的执行计划。\n5.5.3 减少索引和数据的碎片 # B-Tree索引可能会碎片化,这会降低查询的效率。碎片化的索引可能会以很差或者无序的方式存储在磁盘上。\n根据设计,B-Tree需要随机磁盘访问才能定位到叶子页,所以随机访问是不可避免的。然而,如果叶子页在物理分布上是顺序且紧密的,那么查询的性能就会更好。否则,对于范围查询、索引覆盖扫描等操作来说,速度可能会降低很多倍;对于索引覆盖扫描这一点更加明显。\n表的数据存储也可能碎片化。然而,数据存储的碎片化比索引更加复杂。有三种类型的数据碎片。\n行碎片(Row fragmentation)\n这种碎片指的是数据行被存储为多个地方的多个片段中。即使查询只从索引中访问一行记录,行碎片也会导致性能下降。\n行间碎片(Intra-row fragmentation)\n行间碎片是指逻辑上顺序的页,或者行在磁盘上不是顺序存储的。行间碎片对诸如全表扫描和聚簇索引扫描之类的操作有很大的影响,因为这些操作原本能够从磁盘上顺序存储的数据中获益。\n剩余空间碎片(Free space fragmentation)\n剩余空间碎片是指数据页中有大量的空余空间。这会导致服务器读取大量不需要的数据,从而造成浪费。\n对于MyISAM表,这三类碎片化都可能发生。但InnoDB不会出现短小的行碎片;InnoDB会移动短小的行并重写到一个片段中。\n可以通过执行OPTIMIZE TABLE或者导出再导入的方式来重新整理数据。这对多数存储引擎都是有效的。对于一些存储引擎如MyISAM,可以通过排序算法重建索引的方式来消除碎片。老版本的InnoDB没有什么消除碎片化的方法。不过最新版本InnoDB新增了“在线”添加和删除索引的功能,可以通过先删除,然后再重新创建索引的方式来消除索引的碎片化。\n对于那些不支持OPTIMIZE TABLE的存储引擎,可以通过一个不做任何操作(no-op)的ALTER TABLE操作来重建表。只需要将表的存储引擎修改为当前的引擎即可:\nmysql\u0026gt; ** ALTER TABLE \u0026lt;* table* \u0026gt; ENGINE=\u0026lt;* engine* \u0026gt;;** 对于开启了expand_fast_index_creation参数的Percona Server,按这种方式重建表,则会同时消除表和索引的碎片化。但对于标准版本的MySQL则只会消除表(实际上是聚簇索引)的碎片化。可用先删除所有索引,然后重建表,最后重新创建索引的方式模拟Percona Server的这个功能。\n应该通过一些实际测量而不是随意假设来确定是否需要消除索引和表的碎片化。Percona的XtraBackup有个*\u0026ndash;stats*参数以非备份的方式运行,而只是打印索引和表的统计情况,包括页中的数据量和空余空间。这可以用来确定数据的碎片化程度。另外也要考虑数据是否已经达到稳定状态,如果你进行碎片整理将数据压缩到一起,可能反而会导致后续的更新操作触发一系列的页分裂和重组,这会对性能造成不良的影响(直到数据再次达到新的稳定状态)。\n5.6 总结 # 通过本章可以看到,索引是一个非常复杂的话题! MySQL和存储引擎访问数据的方式,加上索引的特性,使得索引成为一个影响数据访问的有力而灵活的工作(无论数据是在磁盘中还是在内存中)。\n在MySQL中,大多数情况下都会使用B-Tree索引。其他类型的索引大多只适用于特殊的目的。如果在合适的场景中使用索引,将大大提高查询的响应时间。本章将不再介绍更多这方面的内容了,最后值得总的回顾一下这些特性以及如何使用B-Tree索引。\n在选择索引和编写利用这些索引的查询时,有如下三个原则始终需要记住:\n单行访问是很慢的。特别是在机械硬盘存储中(SSD的随机I/O要快很多,不过这一点仍然成立)。如果服务器从存储中读取一个数据块只是为了获取其中一行,那么就浪费了很多工作。最好读取的块中能包含尽可能多所需要的行。使用索引可以创建位置引用以提升效率。 按顺序访问范围数据是很快的,这有两个原因。第一,顺序I/O不需要多次磁盘寻道,所以比随机I/O要快很多(特别是对机械硬盘)。第二,如果服务器能够按需要顺序读取数据,那么就不再需要额外的排序操作,并且GROUP BY查询也无须再做排序和将行按组进行聚合计算了。 索引覆盖查询是很快的。如果一个索引包含了查询需要的所有列,那么存储引擎就不需要再回表查找行。这避免了大量的单行访问,而上面的第1点已经写明单行访问是很慢的。 总的来说,编写查询语句时应该尽可能选择合适的索引以避免单行查找、尽可能地使用数据原生顺序从而避免额外的排序操作,并尽可能使用索引覆盖查询。这与本章开头提到的Lahdenmaki和Leach的书中的“三星”评价系统是一致的。\n如果表上的每一个查询都能有一个完美的索引来满足当然是最好的。但不幸的是,要这么做有时可能需要创建大量的索引。还有一些时候对某些查询是不可能创建一个达到“三星”的索引的(例如查询要按照两个列排序,其中一个列正序,另一个列倒序)。这时必须有所取舍以创建最合适的索引,或者寻求替代策略(例如反范式化,或者提前计算汇总表等)。\n理解索引是如何工作的非常重要,应该根据这些理解来创建最合适的索引,而不是根据一些诸如“在多列索引中将选择性最高的列放在第一列”或“应该为WHERE子句中出现的所有列创建索引”之类的经验法则及其推论。\n那如何判断一个系统创建的索引是合理的呢?一般来说,我们建议按响应时间来对查询进行分析。找出那些消耗最长时间的查询或者那些给服务器带来最大压力的查询(第3章中介绍了如何测量),然后检查这些查询的schema、SQL和索引结构,判断是否有查询扫描了太多的行,是否做了很多额外的排序或者使用了临时表,是否使用随机I/O访问数据,或者是有太多回表查询那些不在索引中的列的操作。\n如果一个查询无法从所有可能的索引中获益,则应该看看是否可以创建一个更合适的索引来提升性能。如果不行,也可以看看是否可以重写该查询,将其转化成一个能够高效利用现有索引或者新创建索引的查询。这也是下一章要介绍的内容。\n如果根据第3章介绍的基于响应时间的分析不能找出有问题的查询呢?是否可能有我们没有注意到的“很糟糕”的查询,需要一个更好的索引来获取更高的性能?一般来说,不可能。对于诊断时抓不到的查询,那就不是问题。但是,这个查询未来有可能会成为问题,因为应用程序、数据和负载都在变化。如果仍然想找到那些索引不是很合适的查询,并在它们成为问题前进行优化,则可以使用pt-query-digest的查询审查“review”功能,分析其EXPLAIN出来的执行计划。\n————————————————————\n(1) 除非特别说明,本章假设使用的都是传统的硬盘驱动器。固态硬盘驱动器有着完全不同的性能特性,本书将对此进行详细的描述。然而即使是固态硬盘,索引的原则依然成立,只是那些需要尽量避免的糟糕索引对于固态硬盘的影响没有传统硬盘那么糟糕。\n(2) 实际上很多存储引擎使用的是B+Tree,即每一个叶子节点都包含指向下一个叶子节点的指针,从而方便叶子节点的范围遍历。对于B-Tree更详细的细节可以参考相关计算机科学方面的书籍。\n(3) 这是MySQL相关的特性,甚至和具体的版本也相关。其他有些数据库也可以使用索引的非前缀部分,虽然使用完全的前缀的效率会更好。MySQL未来也可能会提供这个特性;本章后面也会介绍一些绕过限制的方法。\n(4) 关于哈希表请参考相关计算机科学方面的书籍。\n(5) 参考 http://en.wikipedia.org/wiki/Birthday_problem。——译者注\n(6) 某些优化极客(geek)将这称之为“sarg”,这是“可搜索的参数(searchable argument)”的缩写。好吧,学会了这个词你也是一个极客了。\n(7) Oracle用户可能更熟悉索引组织表(index-organized table)的说法,实际上是一样的意思。\n(8) 这并非总成立,很快就可以看到。\n(9) 顺便提一下,并不是所有的非聚簇索引都能做到一次索引查询就找到行。当行更新的时候可能无法存储在原来的位置,这会导致表中出现行的碎片化或者移动行并在原位置保存“向前指针”,这两种情况都会导致在查找行时需要更多的工作。\n(10) 多版本控制。——译者注\n(11) 值得指出的是,这是一个真实案例中的表,有很多二级索引和列。如果删除这些二级索引只测试主键,那么性能差异将会更明显。\n(12) 很容易把Extra列的“Using index”和type列的“index”搞混淆。其实这两者完全不同,type列和覆盖索引毫无关系;它只是表示这个查询访问数据的方式,或者说是MySQL查找行的方式。MySQL手册中称之为连接方式(join type)。\n(13) MySQL有两种排序算法,更多细节可以阅读第7章。\n(14) 如果需要按不同方向做排序,一个技巧是存储该列值的反转串或者相反数。\n(15) MySQL这里称其为文件排序(filesort),其实并不一定使用磁盘文件。\n(16) 如果索引类型不同,并不算是重复索引。例如经常有很好的理由创建KEY(col)和FULLTEXT KEY(col)两种索引。\n(17) 这里使用了全内存的案例,如果表逐渐变大,导致工作负载变成I/O密集型时,性能测试结果差距会更大。对于COUNT()查询,覆盖索引性能提升100倍也是很有可能的。\n(18) 有些索引的功能相当于唯一约束,虽然该索引一直没有被查询使用,却可能是用于避免产生重复数据的。\n(19) 再说一下,MySQL 5.6对于这里的问题可能会有很大的帮助。\n(20) 尽管理论上使用基于行的日志模式时,在某些事务隔离级别下,服务器不再需要锁定行,但实践中经常发现无法实现这种预期的行为。直到MySQL 5.6.3版本,在read-commit隔离级别和基于行的日志模式下,这个例子还是会导致锁。\n"},{"id":143,"href":"/zh/docs/technology/MySQL/_%E9%AB%98%E6%80%A7%E8%83%BDMySQL_/%E7%AC%AC4%E7%AB%A0Schema%E4%B8%8E%E6%95%B0%E6%8D%AE%E7%B1%BB%E5%9E%8B%E4%BC%98%E5%8C%96/","title":"第4章Schema与数据类型优化","section":"高性能 My SQL","content":"第4章 Schema与数据类型优化\n良好的逻辑设计和物理设计是高性能的基石,应该根据系统将要执行的查询语句来设计schema,这往往需要权衡各种因素。例如,反范式的设计可以加快某些类型的查询,但同时可能使另一些类型的查询变慢。比如添加计数表和汇总表是一种很好的优化查询的方式,但这些表的维护成本可能会很高。MySQL独有的特性和实现细节对性能的影响也很大。\n本章和聚焦在索引优化的下一章,覆盖了MySQL特有的schema设计方面的主题。我们假设读者已经知道如何设计数据库,所以本章既不会介绍如何入门数据库设计,也不会讲解数据库设计方面的深入内容。这一章关注的是MySQL数据库的设计,主要介绍的是MySQL数据库设计与其他关系型数据库管理系统的区别。如果需要学习数据库设计方面的基础知识,建议阅读Clare Churcher的Beginning Database Design(Apress出版社)一书。\n本章内容是为接下来的两个章节做铺垫。在这三章中,我们将讨论逻辑设计、物理设计和查询执行,以及它们之间的相互作用。这既需要关注全局,也需要专注细节。还需要理解整个系统以便弄清楚各个部分如何相互影响。如果在阅读完索引和查询优化章节后再回头来看这一章,也许会发现本章很有用,很多讨论的议题不能孤立地考虑。\n4.1 选择优化的数据类型 # MySQL支持的数据类型非常多,选择正确的数据类型对于获得高性能至关重要。不管存储哪种类型的数据,下面几个简单的原则都有助于做出更好的选择。\n更小的通常更好。\n一般情况下,应该尽量使用可以正确存储数据的最小数据类型(1)。更小的数据类型通常更快,因为它们占用更少的磁盘、内存和CPU缓存,并且处理时需要的CPU周期也更少。\n但是要确保没有低估需要存储的值的范围,因为在schema中的多个地方增加数据类型的范围是一个非常耗时和痛苦的操作。如果无法确定哪个数据类型是最好的,就选择你认为不会超过范围的最小类型。(如果系统不是很忙或者存储的数据量不多,或者是在可以轻易修改设计的早期阶段,那之后修改数据类型也比较容易)。\n简单就好\n简单数据类型的操作通常需要更少的CPU周期。例如,整型比字符操作代价更低,因为字符集和校对规则(排序规则)使字符比较比整型比较更复杂。这里有两个例子:一个是应该使用MySQL内建的类型(2)而不是字符串来存储日期和时间,另外一个是应该用整型存储IP地址。稍后我们将专门讨论这个话题。\n尽量避免NULL\n很多表都包含可为NULL(空值)的列,即使应用程序并不需要保存NULL也是如此,这是因为可为NULL是列的默认属性(3)。通常情况下最好指定列为NOT NULL,除非真的需要存储NULL值。\n如果查询中包含可为NULL的列,对MySQL来说更难优化,因为可为NULL的列使得索引、索引统计和值比较都更复杂。可为NULL的列会使用更多的存储空间,在MySQL里也需要特殊处理。当可为NULL的列被索引时,每个索引记录需要一个额外的字节,在MyISAM里甚至还可能导致固定大小的索引(例如只有一个整数列的索引)变成可变大小的索引。\n通常把可为NULL的列改为NOT NULL带来的性能提升比较小,所以(调优时)没有必要首先在现有schema中查找并修改掉这种情况,除非确定这会导致问题。但是,如果计划在列上建索引,就应该尽量避免设计成可为NULL的列。\n当然也有例外,例如值得一提的是,InnoDB使用单独的位(bit)存储NULL值,所以对于稀疏数据(4)有很好的空间效率。但这一点不适用于MyISAM。\n在为列选择数据类型时,第一步需要确定合适的大类型:数字、字符串、时间等。这通常是很简单的,但是我们会提到一些特殊的不是那么直观的案例。\n下一步是选择具体类型。很多MySQL的数据类型可以存储相同类型的数据,只是存储的长度和范围不一样、允许的精度不同,或者需要的物理空间(磁盘和内存空间)不同。相同大类型的不同子类型数据有时也有一些特殊的行为和属性。\n例如,DATETIME和TIMESAMP列都可以存储相同类型的数据:时间和日期,精确到秒。\n然而TIMESTAMP只使用DATETIME一半的存储空间,并且会根据时区变化,具有特殊的自动更新能力。另一方面,TIMESTAMP允许的时间范围要小得多,有时候它的特殊能力会成为障碍。\n本章只讨论基本的数据类型。MySQL为了兼容性支持很多别名,例如INTEGER、BOOL,以及NUMERIC。它们都只是别名。这些别名可能令人不解,但不会影响性能。如果建表时采用数据类型的别名,然后用SHOW CREATE TABLE检查,会发现MySQL报告的是基本类型,而不是别名。\n4.1.1 整数类型 # 有两种类型的数字:整数(whole number)和实数(real number)。如果存储整数,可以使用这几种整数类型:TINYINT,SMALLINT,MEDIUMINT,INT,BIGINT。分别使用8,16,24,32,64位存储空间。它们可以存储的值的范围从−2(N−1)到2(N−1)−1,其中N是存储空间的位数。\n整数类型有可选的UNSIGNED属性,表示不允许负值,这大致可以使正数的上限提高一倍。例如TINYINT UNSIGNED可以存储的范围是0~255,而TINYINT的存储范围是−128~127。\n有符号和无符号类型使用相同的存储空间,并具有相同的性能,因此可以根据实际情况选择合适的类型。\n你的选择决定MySQL是怎么在内存和磁盘中保存数据的。然而,整数计算一般使用64位的BIGINT整数,即使在32位环境也是如此。(一些聚合函数是例外,它们使用DECIMAL或DOUBLE进行计算)。\nMySQL可以为整数类型指定宽度,例如INT(11),对大多数应用这是没有意义的:它不会限制值的合法范围,只是规定了MySQL的一些交互工具(例如MySQL命令行客户端)用来显示字符的个数。对于存储和计算来说,INT(1)和INT(20)是相同的。\n一些第三方存储引擎,比如Infobright,有时也有自定义的存储格式和压缩方案,并不一定使用常见的MySQL内置引擎的方式。\n4.1.2 实数类型 # 实数是带有小数部分的数字。然而,它们不只是为了存储小数部分;也可以使用DECIMAL存储比BIGINT还大的整数。MySQL既支持精确类型,也支持不精确类型。\nFLOAT和DOUBLE类型支持使用标准的浮点运算进行近似计算。如果需要知道浮点运算是怎么计算的,则需要研究所使用的平台的浮点数的具体实现。\nDECIMAL类型用于存储精确的小数。在MySQL 5.0和更高版本,DECIMAL类型支持精确计算。MySQL 4.1以及更早版本则使用浮点运算来实现DECIAML的计算,这样做会因为精度损失导致一些奇怪的结果。在这些版本的MySQL中,DECIMAL只是一个“存储类型”。\n因为CPU不支持对DECIMAL的直接计算,所以在MySQL 5.0以及更高版本中,MySQL服务器自身实现了DECIMAL的高精度计算。相对而言,CPU直接支持原生浮点计算,所以浮点运算明显更快。\n浮点和DECIMAL类型都可以指定精度。对于DECIMAL列,可以指定小数点前后所允许的最大位数。这会影响列的空间消耗。MySQL 5.0和更高版本将数字打包保存到一个二进制字符串中(每4个字节存9个数字)。例如,DECIMAL(18,9)小数点两边将各存储9个数字,一共使用9个字节:小数点前的数字用4个字节,小数点后的数字用4个字节,小数点本身占1个字节。\nMySQL 5.0和更高版本中的DECIMAL类型允许最多65个数字。而早期的MySQL版本中这个限制是254个数字,并且保存为未压缩的字符串(每个数字一个字节)。然而,这些(早期)版本实际上并不能在计算中使用这么大的数字,因为DECIMAL只是一种存储格式;在计算中DECIMAL会转换为DOUBLE类型。\n有多种方法可以指定浮点列所需要的精度,这会使得MySQL悄悄选择不同的数据类型,或者在存储时对值进行取舍。这些精度定义是非标准的,所以我们建议只指定数据类型,不指定精度。\n浮点类型在存储同样范围的值时,通常比DECIMAL使用更少的空间。FLOAT使用4个字节存储。DOUBLE占用8个字节,相比FLOAT有更高的精度和更大的范围。和整数类型一样,能选择的只是存储类型;MySQL使用DOUBLE作为内部浮点计算的类型。\n因为需要额外的空间和计算开销,所以应该尽量只在对小数进行精确计算时才使用DECIMAL——例如存储财务数据。但在数据量比较大的时候,可以考虑使用BIGINT代替DECIMAL,将需要存储的货币单位根据小数的位数乘以相应的倍数即可。假设要存储财务数据精确到万分之一分,则可以把所有金额乘以一百万,然后将结果存储在BIGINT里,这样可以同时避免浮点存储计算不精确和DECIMAL精确计算代价高的问题。\n4.1.3 字符串类型 # MySQL支持多种字符串类型,每种类型还有很多变种。这些数据类型在4.1和5.0版本发生了很大的变化,使得情况更加复杂。从MySQL 4.1开始,每个字符串列可以定义自己的字符集和排序规则,或者说校对规则(collation)(更多关于这个主题的信息请参考第7章)。这些东西会很大程度上影响性能。\nVARCHAR和CHAR类型 # VARCHAR和CHAR是两种最主要的字符串类型。不幸的是,很难精确地解释这些值是怎么存储在磁盘和内存中的,因为这跟存储引擎的具体实现有关。下面的描述假设使用的存储引擎是InnoDB和/或者MyISAM。如果使用的不是这两种存储引擎,请参考所使用的存储引擎的文档。\n先看看VARCHAR和CHAR值通常在磁盘上怎么存储。请注意,存储引擎存储CHAR或者VARCHAR值的方式在内存中和在磁盘上可能不一样,所以MySQL服务器从存储引擎读出的值可能需要转换为另一种存储格式。下面是关于两种类型的一些比较。\nVARCHAR\nVARCHAR类型用于存储可变长字符串,是最常见的字符串数据类型。它比定长类型更节省空间,因为它仅使用必要的空间(例如,越短的字符串使用越少的空间)。有一种情况例外,如果MySQL表使用ROW_FORMAT=FIXED创建的话,每一行都会使用定长存储,这会很浪费空间。\nVARCHAR需要使用1或2个额外字节记录字符串的长度:如果列的最大长度小于或等于255字节,则只使用1个字节表示,否则使用2个字节。假设采用latin1字符集,一个VARCHAR(10)的列需要11个字节的存储空间。VARCHAR(1000)的列则需要1002个字节,因为需要2个字节存储长度信息。\nVARCHAR节省了存储空间,所以对性能也有帮助。但是,由于行是变长的,在UPDATE时可能使行变得比原来更长,这就导致需要做额外的工作。如果一个行占用的空间增长,并且在页内没有更多的空间可以存储,在这种情况下,不同的存储引擎的处理方式是不一样的。例如,MyISAM会将行拆成不同的片段存储,InnoDB则需要分裂页来使行可以放进页内。其他一些存储引擎也许从不在原数据位置更新数据。\n下面这些情况下使用VARCHAR是合适的:字符串列的最大长度比平均长度大很多;列的更新很少,所以碎片不是问题;使用了像UTF-8这样复杂的字符集,每个字符都使用不同的字节数进行存储。\n在5.0或者更高版本,MySQL在存储和检索时会保留末尾空格。但在4.1或更老的版本,MySQL会剔除末尾空格。\nInnoDB则更灵活,它可以把过长的VARCHAR存储为BLOB,我们稍后讨论这个问题。\nCHAR\nCHAR类型是定长的:MySQL总是根据定义的字符串长度分配足够的空间。当存储CHAR值时,MySQL会删除所有的末尾空格(在MySQL 4.1和更老版本中VARCHAR也是这样实现的——也就是说这些版本中CHAR和VARCHAR在逻辑上是一样的,区别只是在存储格式上)。CHAR值会根据需要采用空格进行填充以方便比较。\nCHAR适合存储很短的字符串,或者所有值都接近同一个长度。例如,CHAR非常适合存储密码的MD5值,因为这是一个定长的值。对于经常变更的数据,CHAR也比VARCHAR更好,因为定长的CHAR类型不容易产生碎片。对于非常短的列,CHAR比VARCHAR在存储空间上也更有效率。例如用CHAR(1)来存储只有Y和N的值,如果采用单字节字符集(5)只需要一个字节,但是VARCHAR(1)却需要两个字节,因为还有一个记录长度的额外字节。\nCHAR类型的这些行为可能有一点难以理解,下面通过一个具体的例子来说明。首先,我们创建一张只有一个CHAR(10)字段的表并且往里面插入一些值:\nmysql\u0026gt; CREATE TABLE char_test( char_col CHAR(10)); mysql\u0026gt; INSERT INTO char_test(char_col) VALUES -\u0026gt; ('string1'), (' string2'), ('string3 ') 当检索这些值的时候,会发现string3末尾的空格被截断了。\n如果用VARCHAR(10)字段存储相同的值,可以得到如下结果(6):\n数据如何存储取决于存储引擎,并非所有的存储引擎都会按照相同的方式处理定长和变长的字符串。Memory引擎只支持定长的行,即使有变长字段也会根据最大长度分配最大空间(7)。不过,填充和截取空格的行为在不同存储引擎都是一样的,因为这是在MySQL服务器层进行处理的。\n与CHAR和VARCHAR类似的类型还有BINARY和VARBINARY,它们存储的是二进制字符串。二进制字符串跟常规字符串非常相似,但是二进制字符串存储的是字节码而不是字符。填充也不一样:MySQL填充BINARY采用的是\\0(零字节)而不是空格,在检索时也不会去掉填充值(8)。\n当需要存储二进制数据,并且希望MySQL使用字节码而不是字符进行比较时,这些类型是非常有用的。二进制比较的优势并不仅仅体现在大小写敏感上。MySQL比较BINARY字符串时,每次按一个字节,并且根据该字节的数值进行比较。因此,二进制比较比字符比较简单很多,所以也就更快。\n慷慨是不明智的\n使用VARCHAR(5)和VARCHAR(200)存储’hello’的空间开销是一样的。那么使用更短的列有什么优势吗?\n事实证明有很大的优势。更长的列会消耗更多的内存,因为MySQL通常会分配固定大小的内存块来保存内部值。尤其是使用内存临时表进行排序或操作时会特别糟糕。在利用磁盘临时表进行排序时也同样糟糕。\n所以最好的策略是只分配真正需要的空间。\nBLOB和TEXT类型 # BLOB和TEXT都是为存储很大的数据而设计的字符串数据类型,分别采用二进制和字符方式存储。\n实际上,它们分别属于两组不同的数据类型家族:字符类型是TINYTEXT,SMALLTEXT,TEXT,MEDIUMTEXT,LONGTEXT;对应的二进制类型是TINYBLOB,SMALLBLOB,BLOB,MEDIUMBLOB,LONGBLOB。BLOB是SMALLBLOB的同义词,TEXT是SMALLTEXT的同义词。\n与其他类型不同,MySQL把每个BLOB和TEXT值当作一个独立的对象处理。存储引擎在存储时通常会做特殊处理。当BLOB和TEXT值太大时,InnoDB会使用专门的“外部”存储区域来进行存储,此时每个值在行内需要1~4个字节存储一个指针,然后在外部存储区域存储实际的值。\nBLOB和TEXT家族之间仅有的不同是BLOB类型存储的是二进制数据,没有排序规则或字符集,而TEXT类型有字符集和排序规则。\nMySQL对BLOB和TEXT列进行排序与其他类型是不同的:它只对每个列的最前max_sort_length字节而不是整个字符串做排序。如果只需要排序前面一小部分字符,则可以减小max_sort_length的配置,或者使用ORDER BY SUSTRING(column,length)。\nMySQL不能将BLOB和TEXT列全部长度的字符串进行索引,也不能使用这些索引消除排序。(关于这个主题下一章会有更多的信息。)\n磁盘临时表和文件排序\n因为Memory引擎不支持BLOB和TEXT类型,所以,如果查询使用了BLOB或TEXT列并且需要使用隐式临时表,将不得不使用MyISAM磁盘临时表,即使只有几行数据也是如此(Percona Server的Memory引擎支持BLOB和TEXT类型,但直到本书写作之际,同样的场景下还是需要使用磁盘临时表)。\n这会导致严重的性能开销。即使配置MySQL将临时表存储在内存块设备上(RAM Disk),依然需要许多昂贵的系统调用。\n最好的解决方案是尽量避免使用BLOB和TEXT类型。如果实在无法避免,有一个技巧是在所有用到BLOB字段的地方都使用SUBSTRING(column,length)将列值转换为字符串(在ORDER BY子句中也适用),这样就可以使用内存临时表了。但是要确保截取的子字符串足够短,不会使临时表的大小超过max_heap_table_size或tmp_table_size,超过以后MySQL会将内存临时表转换为MyISAM磁盘临时表。\n最坏情况下的长度分配对于排序的时候也是一样的,所以这一招对于内存中创建大临时表和文件排序,以及在磁盘上创建大临时表和文件排序这两种情况都很有帮助。\n例如,假设有一个1000万行的表,占用几个GB的磁盘空间。其中有一个utf8字符集的VARCHAR(1000)列。每个字符最多使用3个字节,最坏情况下需要3000字节的空间。如果在ORDER BY中用到这个列,并且查询扫描整个表,为了排序就需要超过30GB的临时表。\n如果EXPLAIN执行计划的Extra列包含”Using temporary”,则说明这个查询使用了隐式临时表。\n使用枚举(ENUM)代替字符串类型 # 有时候可以使用枚举列代替常用的字符串类型。枚举列可以把一些不重复的字符串存储成一个预定义的集合。MySQL在存储枚举时非常紧凑,会根据列表值的数量压缩到一个或者两个字节中。MySQL在内部会将每个值在列表中的位置保存为整数,并且在表的*.frm*文件中保存“数字-字符串”映射关系的“查找表”。下面有一个例子:\nmysql\u0026gt; CREATE TABLE enum_test( -\u0026gt; e ENUM ('fish', 'apple', 'dog') NOT NULL -\u0026gt; ); mysql\u0026gt; INSERT INTO enum_test(e) VALUES('fish'), ('dog'), ('apple'); 这三行数据实际存储为整数,而不是字符串。可以通过在数字上下文环境检索看到这个双重属性:\n如果使用数字作为ENUM枚举常量,这种双重性很容易导致混乱,例如ENUM(\u0026lsquo;1\u0026rsquo;,\u0026lsquo;2\u0026rsquo;,\u0026lsquo;3\u0026rsquo;)。建议尽量避免这么做。\n另外一个让人吃惊的地方是,枚举字段是按照内部存储的整数而不是定义的字符串进行排序的:\n一种绕过这种限制的方式是按照需要的顺序来定义枚举列。另外也可以在查询中使用FIELD()函数显式地指定排序顺序,但这会导致MySQL无法利用索引消除排序。\n如果在定义时就是按照字母的顺序,就没有必要这么做了。\n枚举最不好的地方是,字符串列表是固定的,添加或删除字符串必须使用ALTER TABLE。因此,对于一系列未来可能会改变的字符串,使用枚举不是一个好主意,除非能接受只在列表末尾添加元素,这样在MySQL 5.1中就可以不用重建整个表来完成修改。\n由于MySQL把每个枚举值保存为整数,并且必须进行查找才能转换为字符串,所以枚举列有一些开销。通常枚举的列表都比较小,所以开销还可以控制,但也不能保证一直如此。在特定情况下,把CHAR/VARCHAR列与枚举列进行关联可能会比直接关联CHAR/VARCHAR列更慢。\n为了说明这个情况,我们对一个应用中的一张表进行了基准测试,看看在MySQL中执行上面说的关联的速度如何。该表有一个很大的主键:\nCREATE TABLE webservicecalls ( day date NOT NULL, account smallint NOT NULL, service varchar(10) NOT NULL, method varchar(50) NOT NULL, calls int NOT NULL, items int NOT NULL, time float NOT NULL, cost decimal(9,5) NOT NULL, updated datetime, PRIMARY KEY (day, account, service, method) ) ENGINE=InnoDB; 这个表有11万行数据,只有10MB大小,所以可以完全载入内存。service列包含了5个不同的值,平均长度为4个字符,method列包含了71个值,平均长度为20个字符。\n我们复制一下这个表,但是把service和method字段换成枚举类型,表结构如下:\nCREATE TABLE webservicecalls_enum ( ... omitted ... service ENUM(...values omitted...) NOT NULL, method ENUM(...values omitted...) NOT NULL, ... omitted ... ) ENGINE=InnoDB; 然后我们用主键列关联这两个表,下面是所使用的查询语句:\nmysql\u0026gt; SELECT SQL_NO_CACHE COUNT(*) -\u0026gt; FROM webservicecalls -\u0026gt; JOIN webservicecalls USING(day, account, service, method); 我们用VARVHAR和ENUM分别测试了这个语句,结果如表4-1所示。\n表4-1:连接VARCHAR和ENUM列的速度 测试 QPS VARCHAR 关联 VARCHAR 2.6 VARCHAR 关联 ENUM 1.7 ENUM 关联 VARCHAR 1.8 ENUM 关联 ENUM 3.5\n从上面的结果可以看到,当把列都转换成ENUM以后,关联变得很快。但是当VARCHAR列和ENUM列进行关联时则慢很多。在本例中,如果不是必须和VARCHAR列进行关联,那么转换这些列为ENUM就是个好主意。这是一个通用的设计实践,在“查找表”时采用整数主键而避免采用基于字符串的值进行关联。\n然而,转换列为枚举型还有另一个好处。根据SHOW TABLE STATUS命令输出结果中Data_length列的值,把这两列转换为ENUM可以让表的大小缩小1/3。在某些情况下,即使可能出现ENUM和VARCHAR进行关联的情况,这也是值得的(9)。同样,转换后主键也只有原来的一半大小了。因为这是InnoDB表,如果表上有其他索引,减小主键大小会使非主键索引也变得更小。稍后再解释这个问题。\n4.1.4 日期和时间类型 # MySQL可以使用许多类型来保存日期和时间值,例如YEAR和DATE。MySQL能存储的最小时间粒度为秒(MariaDB支持微秒级别的时间类型)。但是MySQL也可以使用微秒级的粒度进行临时运算,我们会展示怎么绕开这种存储限制。\n大部分时间类型都没有替代品,因此没有什么是最佳选择的问题。唯一的问题是保存日期和时间的时候需要做什么。MySQL提供两种相似的日期类型:DATETIME和TIMESTAMP。对于很多应用程序,它们都能工作,但是在某些场景,一个比另一个工作得好。让我们来看一下。\nDATETIME\n这个类型能保存大范围的值,从1001年到9999年,精度为秒。它把日期和时间封装到格式为YYYYMMDDHHMMSS的整数中,与时区无关。使用8个字节的存储空间。\n默认情况下,MySQL以一种可排序的、无歧义的格式显示DATETIME值,例如“2008-01-16 22:37:08”。这是ANSI标准定义的日期和时间表示方法。\nTIMESTAMP\n就像它的名字一样,TIMETAMP类型保存了从1970年1月1日午夜(格林尼治标准时间)以来的秒数,它和UNIX时间戳相同。TIMESTAMP只使用4个字节的存储空间,因此它的范围比DATETIME小得多:只能表示从1970年到2038年。MySQL提供了FROM_UNIXTIME()函数把Unix时间戳转换为日期,并提供了UNIX_TIMESTAMP()函数把日期转换为Unix时间戳。\nMySQL 4.1以及更新的版本按照DATETIME的方式格式化TIMESTAMP的值,但是MySQL 4.0以及更老的版本不会在各个部分之间显示任何标点符号。这仅仅是显示格式上的区别,TIMESTAMP的存储格式在各个版本都是一样的。\nTIMESTAMP显示的值也依赖于时区。MySQL服务器、操作系统,以及客户端连接都有时区设置。\n因此,存储值为0的TIMESTAMP在美国东部时区显示为“1969-12-31 19:00:00”,与格林尼治时间差5个小时。有必要强调一下这个区别:如果在多个时区存储或访问数据,TIMESTAMP和DATETIME的行为将很不一样。前者提供的值与时区有关系,后者则保留文本表示的日期和时间。\nTIMESTAMP也有DATETIME没有的特殊属性。默认情况下,如果插入时没有指定第一个TIMESTAMP列的值,MySQL则设置这个列的值为当前时间(10)。在插入一行记录时,MySQL默认也会更新第一个TIMESTAMP列的值(除非在UPDATE语句中明确指定了值)。你可以配置任何TIMESTAMP列的插入和更新行为。最后,TIMESTAMP列默认为NOT NULL,这也和其他的数据类型不一样。\n除了特殊行为之外,通常也应该尽量使用TIMESTAMP,因为它比DATETIME空间效率更高。有时候人们会将Unix时间截存储为整数值,但这不会带来任何收益。用整数保存时间截的格式通常不方便处理,所以我们不推荐这样做。\n如果需要存储比秒更小粒度的日期和时间值怎么办?MySQL目前没有提供合适的数据类型,但是可以使用自己的存储格式:可以使用BIGINT类型存储微秒级别的时间截,或者使用DOUBLE存储秒之后的小数部分。这两种方式都可以,或者也可以使用MariaDB替代MySQL。\n4.1.5 位数据类型 # MySQL有少数几种存储类型使用紧凑的位存储数据。所有这些位类型,不管底层存储格式和处理方式如何,从技术上来说都是字符串类型。\nBIT\n在MySQL 5.0之前,BIT是TINYINT的同义词。但是在MySQL 5.0以及更新版本,这是一个特性完全不同的数据类型。下面我们将讨论BIT类型新的行为特性。\n可以使用BIT列在一列中存储一个或多个true/false值。BIT(1)定义一个包含单个位的字段,BIT(2)存储2个位,依此类推。BIT列的最大长度是64个位。\nBIT的行为因存储引擎而异。MyISAM会打包存储所有的BIT列,所以17个单独的BIT列只需要17个位存储(假设没有可为NULL的列),这样MyISAM只使用3个字节就能存储这17个BIT列。其他存储引擎例如Memory和InnoDB,为每个BIT列使用一个足够存储的最小整数类型来存放,所以不能节省存储空间。\nMySQL把BIT当作字符串类型,而不是数字类型。当检索BIT(1)的值时,结果是一个包含二进制0或1值的字符串,而不是ASCII码的“0”或“1”。然而,在数字上下文的场景中检索时,结果将是位字符串转换成的数字。如果需要和另外的值比较结果,一定要记得这一点。例如,如果存储一个值b'00111001\u0026rsquo;(二进制值等于57)到BIT(8)的列并且检索它,得到的内容是字符码为57的字符串。也就是说得到ASCII码为57的字符“9”。但是在数字上下文场景中,得到的是数字57:\n这是相当令人费解的,所以我们认为应该谨慎使用BIT类型。对于大部分应用,最好避免使用这种类型。\n如果想在一个bit的存储空间中存储一个true/false值,另一个方法是创建一个可以为空的CHAR(0)列。该列可以保存空值(NULL)或者长度为零的字符串(空字符串)。\nSET\n如果需要保存很多true/false值,可以考虑合并这些列到一个SET数据类型,它在MySQL内部是以一系列打包的位的集合来表示的。这样就有效地利用了存储空间,并且MySQL有像FIND_IN_SET()和FIELD()这样的函数,方便地在查询中使用。它的主要缺点是改变列的定义的代价较高:需要ALTER TABLE,这对大表来说是非常昂贵的操作(但是本章的后面给出了解决办法)。一般来说,也无法在SET列上通过索引查找。\n在整数列上进行按位操作\n一种替代SET的方式是使用一个整数包装一系列的位。例如,可以把8个位包装到一个TINYINT中,并且按位操作来使用。可以在应用中为每个位定义名称常量来简化这个工作。\n比起SET,这种办法主要的好处在于可以不使用ALTER TABLE改变字段代表的“枚举”值,缺点是查询语句更难写,并且更难理解(当第5个bit位被设置时是什么意思?)。一些人非常适应这种方式,也有一些人不适应,所以是否采用这种技术取决于个人的偏好。\n一个包装位的应用的例子是保存权限的访问控制列表(ACL)。每个位或者SET元素代表一个值,例如CAN_READ、CAN_WRITE,或者CAN_DELETE。如果使用SET列,可以让MySQL在列定义里存储位到值的映射关系;如果使用整数列,则可以在应用代码里存储这个对应关系。这是使用SET列时的查询:\n如果使用整数来存储,则可以参考下面的例子:\n这里我们使用MySQL变量来定义值,但是也可以在代码里使用常量来代替。\n4.1.6 选择标识符(identifier) # 为标识列(identifier column)选择合适的数据类型非常重要。一般来说更有可能用标识列与其他值进行比较(例如,在关联操作中),或者通过标识列寻找其他列。标识列也可能在另外的表中作为外键使用,所以为标识列选择数据类型时,应该选择跟关联表中的对应列一样的类型(正如我们在本章早些时候所论述的一样,在相关的表中使用相同的数据类型是个好主意,因为这些列很可能在关联中使用)。\n当选择标识列的类型时,不仅仅需要考虑存储类型,还需要考虑MySQL对这种类型怎么执行计算和比较。例如,MySQL在内部使用整数存储ENUM和SET类型,然后在做比较操作时转换为字符串。\n一旦选定了一种类型,要确保在所有关联表中都使用同样的类型。类型之间需要精确匹配,包括像UNSIGNED这样的属性(11)。混用不同数据类型可能导致性能问题,即使没有性能影响,在比较操作时隐式类型转换也可能导致很难发现的错误。这种错误可能会很久以后才突然出现,那时候可能都已经忘记是在比较不同的数据类型。\n在可以满足值的范围的需求,并且预留未来增长空间的前提下,应该选择最小的数据类型。例如有一个state_id列存储美国各州的名字(12),就不需要几千或几百万个值,所以不需要使用INT。TINYINT足够存储,而且比INT少了3个字节。如果用这个值作为其他表的外键,3个字节可能导致很大的性能差异。下面是一些小技巧。\n整数类型\n整数通常是标识列最好的选择,因为它们很快并且可以使用AUTO_INCREMENT。\nENUM和SET类型\n对于标识列来说,EMUM和SET类型通常是一个糟糕的选择,尽管对某些只包含固定状态或者类型的静态“定义表”来说可能是没有问题的。ENUM和SET列适合存储固定信息,例如有序的状态、产品类型、人的性别。\n举个例子,如果使用枚举字段来定义产品类型,也许会设计一张以这个枚举字段为主键的查找表(可以在查找表中增加一些列来保存描述性质的文本,这样就能够生成一个术语表,或者为网站的下拉菜单提供有意义的标签)。这时,使用枚举类型作为标识列是可行的,但是大部分情况下都要避免这么做。\n字符串类型\n如果可能,应该避免使用字符串类型作为标识列,因为它们很消耗空间,并且通常比数字类型慢。尤其是在MyISAM表里使用字符串作为标识列时要特别小心。MyISAM默认对字符串使用压缩索引,这会导致查询慢得多。在我们的测试中,我们注意到最多有6倍的性能下降。\n对于完全“随机”的字符串也需要多加注意,例如MD5()、SHA1()或者UUID()产生的字符串。这些函数生成的新值会任意分布在很大的空间内,这会导致INSERT以及一些SELECT语句变得很慢(13):\n因为插入值会随机地写到索引的不同位置,所以使得INSERT语句更慢。这会导致页分裂、磁盘随机访问,以及对于聚簇存储引擎产生聚簇索引碎片。关于这一点第5章有更多的讨论。 SELECT语句会变得更慢,因为逻辑上相邻的行会分布在磁盘和内存的不同地方。 随机值导致缓存对所有类型的查询语句效果都很差,因为会使得缓存赖以工作的访问局部性原理失效。如果整个数据集都一样的“热”,那么缓存任何一部分特定数据到内存都没有好处;如果工作集比内存大,缓存将会有很多刷新和不命中。 如果存储UUID值,则应该移除“-”符号;或者更好的做法是,用UNHEX()函数转换UUID值为16字节的数字,并且存储在一个BINARY(16)列中。检索时可以通过HEX()函数来格式化为十六进制格式。\nUUID()生成的值与加密散列函数例如SHA1()生成的值有不同的特征:UUID值虽然分布也不均匀,但还是有一定顺序的。尽管如此,但还是不如递增的整数好用。\n当心自动生成的schema\n我们已经介绍了大部分重要数据类型的考虑(有些会严重影响性能,有些则影响较小),但是我们还没有提到自动生成的schema设计有多么糟糕。\n写得很烂的schema迁移程序,或者自动生成schema的程序,都会导致严重的性能问题。有些程序存储任何东西都会使用很大的VARCHAR列,或者对需要在关联时比较的列使用不同的数据类型。如果schema是自动生成的,一定要反复检查确认没有问题。\n对象关系映射(ORM)系统(以及使用它们的“框架”)是另一种常见的性能噩梦。一些ORM系统会存储任意类型的数据到任意类型的后端数据存储中,这通常意味着其没有设计使用更优的数据类型来存储。有时会为每个对象的每个属性使用单独的行,甚至使用基于时间戳的版本控制,导致单个属性会有多个版本存在。\n这种设计对开发者很有吸引力,因为这使得他们可以用面向对象的方式工作,不需要考虑数据是怎么存储的。然而,“对开发者隐藏复杂性”的应用通常不能很好地扩展。我们建议在用性能交换开发人员的效率之前仔细考虑,并且总是在真实大小的数据集上做测试,这样就不会太晚才发现性能问题。\n4.1.7 特殊类型数据 # 某些类型的数据并不直接与内置类型一致。低于秒级精度的时间戳就是一个例子;本章的前面部分也演示过存储此类数据的一些选项。\n另一个例子是一个IPv4地址。人们经常使用VARCHAR(15)列来存储IP地址。然而,它们实际上是32位无符号整数,不是字符串。用小数点将地址分成四段的表示方法只是为了让人们阅读容易。所以应该用无符号整数存储IP地址。MySQL提供INET_ATON()和INET_NTOA()函数在这两种表示方法之间转换。\n4.2 MySQL schema设计中的陷阱 # 虽然有一些普遍的好或坏的设计原则,但也有一些问题是由MySQL的实现机制导致的,这意味着有可能犯一些只在MySQL下发生的特定错误。本节我们讨论设计MySQL的schema的问题。这也许会帮助你避免这些错误,并且选择在MySQL特定实现下工作得更好的替代方案。\n太多的列\nMySQL的存储引擎API工作时需要在服务器层和存储引擎层之间通过行缓冲格式拷贝数据,然后在服务器层将缓冲内容解码成各个列。从行缓冲中将编码过的列转换成行数据结构的操作代价是非常高的。MyISAM的定长行结构实际上与服务器层的行结构正好匹配,所以不需要转换。然而,MyISAM的变长行结构和InnoDB的行结构则总是需要转换。转换的代价依赖于列的数量。当我们研究一个CPU占用非常高的案例时,发现客户使用了非常宽的表(数千个字段),然而只有一小部分列会实际用到,这时转换的代价就非常高。如果计划使用数千个字段,必须意识到服务器的性能运行特征会有一些不同。\n太多的关联\n所谓的“实体-属性-值”(EAV)设计模式是一个常见的糟糕设计模式,尤其是在MySQL下不能靠谱地工作。MySQL限制了每个关联操作最多只能有61张表,但是EAV数据库需要许多自关联。我们见过不少EAV数据库最后超过了这个限制。事实上在许多关联少于61张表的情况下,解析和优化查询的代价也会成为MySQL的问题。一个粗略的经验法则,如果希望查询执行得快速且并发性好,单个查询最好在12个表以内做关联。\n全能的枚举\n注意防止过度使用枚举(ENUM)。下面是我们见过的一个例子:\nCREATE TABLE ... ( country enum('','0','1','2',...,'31') 这种模式的schema设计非常凌乱。这么使用枚举值类型也许在任何支持枚举类型的数据库都是一个有问题的设计方案,这里应该用整数作为外键关联到字典表或者查找表来查找具体值。但是在MySQL中,当需要在枚举列表中增加一个新的国家时就要做一次ALTER TABLE操作。在MySQL 5.0以及更早的版本中ALTER TABLE是一种阻塞操作;即使在5.1和更新版本中,如果不是在列表的末尾增加值也会一样需要ALTER TABLE(我们将展示一些骇客式的方法来避免阻塞操作,但是这只是骇客的玩法,别轻易用在生产环境中)。\n变相的枚举\n枚举(ENUM)列允许在列中存储一组定义值中的单个值,集合(SET)列则允许在列中存储一组定义值中的一个或多个值。有时候这可能比较容易导致混乱。这是一个例子:\nCREATE TABLE ... ( is_default set ('Y','N') NOT NULL default 'N' 如果这里真和假两种情况不会同时出现,那么毫无疑问应该使用枚举列代替集合列。\n非此发明(Not Invent Here)的NULL\n我们之前写了避免使用NULL的好处,并且建议尽可能地考虑替代方案。即使需要存储一个事实上的“空值”到表中时,也不一定非得使用NULL。也许可以使用0、某个特殊值,或者空字符串作为代替。\n但是遵循这个原则也不要走极端。当确实需要表示未知值时也不要害怕使用NULL。在一些场景中,使用NULL可能会比某个神奇常数更好。从特定类型的值域中选择一个不可能的值,例如用−1代表一个未知的整数,可能导致代码复杂很多,并容易引入bug,还可能会让事情变得一团糟。处理NULL确实不容易,但有时候会比它的替代方案更好。\n下面是一个我们经常看到的例子:\nCREATE TABLE ...( dt DATETIME NOT NULL DEFAULT '0000-00-00 00:00:00' 伪造的全0值可能导致很多问题(可以配置MySQL的SQL_MODE来禁止不可能的日期,对于新应用这是个非常好的实践经验,它不会让创建的数据库里充满不可能的值)。值得一提的是,MySQL会在索引中存储NULL值,而Oracle则不会。\n4.3 范式和反范式 # 对于任何给定的数据通常都有很多种表示方法,从完全的范式化到完全的反范式化,以及两者的折中。在范式化的数据库中,每个事实数据会出现并且只出现一次。相反,在反范式化的数据库中,信息是冗余的,可能会存储在多个地方。\n如果不熟悉范式,则应该先学习一下。有很多这方面的不错的书和在线资源;在这里,我们只是给出阅读本章所需要的这方面的简单介绍。下面以经典的“雇员,部门,部门领导”的例子开始: EMPLOYEE DEPARTMENT HEAD Jones Accounting Jones Smith Engineering Smith Brown Accounting Jones Green Engineering Smith\n这个schema的问题是修改数据时可能发生不一致。假如Say Brown接任Accounting部门的领导,需要修改多行数据来反映这个变化,这是很痛苦的事并且容易引入错误。如果“Jones”这一行显示部门的领导跟“Brown”这一行的不一样,就没有办法知道哪个是对的。这就像是有句老话说的:“一个人有两块手表就永远不知道时间”。此外,这个设计在没有雇员信息的情况下就无法表示一个部门——如果我们删除了所有Accounting部门的雇员,我们就失去了关于这个部门本身的所有记录。要避免这个问题,我们需要对这个表进行范式化,方式是拆分雇员和部门项。拆分以后可以用下面两张表分别来存储雇员表: EMPLOYEE_NAME DEPARTMENT Jones Accounting Smith Engineering Brown Accounting Green Engineering\n和部门表: DEPARTMENT HEAD Accounting Jones Engineering Smith\n这样设计的两张表符合第二范式,在很多情况下做到这一步已经足够好了。然而,第二范式只是许多可能的范式中的一种。\n这个例子中我们使用姓(Last Name)作为主键,因为这是数据的“自然标识”。从实践来看,无论如何都不应该这么用。这既不能保证唯一性,而且用一个很长的字符串作为主键是很糟糕的主意。\n4.3.1 范式的优点和缺点 # 当为性能问题而寻求帮助时,经常会被建议对schema进行范式化设计,尤其是写密集的场景。这通常是个好建议。因为下面这些原因,范式化通常能够带来好处:\n范式化的更新操作通常比反范式化要快。 当数据较好地范式化时,就只有很少或者没有重复数据,所以只需要修改更少的数据。 范式化的表通常更小,可以更好地放在内存里,所以执行操作会更快。 很少有多余的数据意味着检索列表数据时更少需要DISTINCT或者GROUP BY语句。还是前面的例子:在非范式化的结构中必须使用DISTINCT或者GROUP BY才能获得一份唯一的部门列表,但是如果部门(DEPARTMENT)是一张单独的表,则只需要简单的查询这张表就行了。 范式化设计的schema的缺点是通常需要关联。稍微复杂一些的查询语句在符合范式的schema上都可能需要至少一次关联,也许更多。这不但代价昂贵,也可能使一些索引策略无效。例如,范式化可能将列存放在不同的表中,而这些列如果在一个表中本可以属于同一个索引。\n4.3.2 反范式的优点和缺点 # 反范式化的schema因为所有数据都在一张表中,可以很好地避免关联。\n如果不需要关联表,则对大部分查询最差的情况——即使表没有使用索引——是全表扫描。当数据比内存大时这可能比关联要快得多,因为这样避免了随机I/O(14)。\n单独的表也能使用更有效的索引策略。假设有一个网站,允许用户发送消息,并且一些用户是付费用户。现在想查看付费用户最近的10条信息。如果是范式化的结构并且索引了发送日期字段published,这个查询也许看起来像这样:\nmysql\u0026gt; SELECT message_text, user_name -\u0026gt; FROM message -\u0026gt; INNER JOIN user ON message.user_id=user.id -\u0026gt; WHERE user.account_type='premiumv -\u0026gt; ORDER BY message.published DESC LIMIT 10; 要更有效地执行这个查询,MySQL需要扫描message表的published字段的索引。对于每一行找到的数据,将需要到user表里检查这个用户是不是付费用户。如果只有一小部分用户是付费账户,那么这是效率低下的做法。\n另一种可能的执行计划是从user表开始,选择所有的付费用户,获得他们所有的信息,并且排序。但这可能更加糟糕。\n主要问题是关联,使得需要在一个索引中又排序又过滤。如果采用反范式化组织数据,将两张表的字段合并一下,并且增加一个索引(account_type,published),就可以不通过关联写出这个查询。这将非常高效:\nmysql\u0026gt; SELECT message_text,user_name -\u0026gt; FROM user_messages -\u0026gt; WHERE account_type='premium' -\u0026gt; ORDER BY published DESC -\u0026gt; LIMIT 10; 4.3.3 混用范式化和反范式化 # 范式化和反范式化的schema各有优劣,怎么选择最佳的设计?\n事实是,完全的范式化和完全的反范式化schema都是实验室里才有的东西:在真实世界中很少会这么极端地使用。在实际应用中经常需要混用,可能使用部分范式化的schema、缓存表,以及其他技巧。\n最常见的反范式化数据的方法是复制或者缓存,在不同的表中存储相同的特定列。在MySQL 5.0和更新版本中,可以使用触发器更新缓存值,这使得实现这样的方案变得更简单。\n在我们的网站实例中,可以在user表和message表中都存储account_type字段,而不用完全的反范式化。这避免了完全反范式化的插入和删除问题,因为即使没有消息的时候也绝不会丢失用户的信息。这样也不会把user_message表搞得太大,有利于高效地获取数据。\n但是现在更新用户的账户类型的操作代价就高了,因为需要同时更新两张表。至于这会不会是一个问题,需要考虑更新的频率以及更新的时长,并和执行SELECT查询的频率进行比较。\n另一个从父表冗余一些数据到子表的理由是排序的需要。例如,在范式化的schema里通过作者的名字对消息做排序的代价将会非常高,但是如果在message表中缓存author_name字段并且建好索引,则可以非常高效地完成排序。\n缓存衍生值也是有用的。如果需要显示每个用户发了多少消息(像很多论坛做的),可以每次执行一个昂贵的子查询来计算并显示它;也可以在user表中建一个num_messages列,每当用户发新消息时更新这个值。\n4.4 缓存表和汇总表 # 有时提升性能最好的方法是在同一张表中保存衍生的冗余数据。然而,有时也需要创建一张完全独立的汇总表或缓存表(特别是为满足检索的需求时)。如果能容许少量的脏数据,这是非常好的方法,但是有时确实没有选择的余地(例如,需要避免复杂、昂贵的实时更新操作)。\n术语“缓存表”和“汇总表”没有标准的含义。我们用术语“缓存表”来表示存储那些可以比较简单地从schema其他表获取(但是每次获取的速度比较慢)数据的表(例如,逻辑上冗余的数据)。而术语“汇总表”时,则保存的是使用GROUP BY语句聚合数据的表(例如,数据不是逻辑上冗余的)。也有人使用术语“累积表(Roll-Up Table)”称呼这些表。因为这些数据被“累积”了。\n仍然以网站为例,假设需要计算之前24小时内发送的消息数。在一个很繁忙的网站不可能维护一个实时精确的计数器。作为替代方案,可以每小时生成一张汇总表。这样也许一条简单的查询就可以做到,并且比实时维护计数器要高效得多。缺点是计数器并不是100%精确。\n如果必须获得过去24小时准确的消息发送数量(没有遗漏),有另外一种选择。以每小时汇总表为基础,把前23个完整的小时的统计表中的计数全部加起来,最后再加上开始阶段和结束阶段不完整的小时内的计数。假设统计表叫作msg_per_hr并且这样定义:\nCREATE TABLE msg_per_hr ( hr DATETIME NOT NULL, cnt INT UNSIGNED NOT NULL, PRIMARY KEY(hr) ); 可以通过把下面的三个语句的结果加起来,得到过去24小时发送消息的总数。我们使用LEFT(NOW(),14)来获得当前的日期和时间最接近的小时:\nmysql\u0026gt; ** SELECT SUM(cnt) FROM msg_per_hr** -\u0026gt; ** WHERE hr BETWEEN** -\u0026gt; ** CONCAT(LEFT(NOW(), 14), '00:00') - INTERVAL 23 HOUR** -\u0026gt; ** AND CONCAT(LEFT(NOW(), 14), '00:00') - INTERVAL 1 HOUR;** mysql\u0026gt; ** SELECT COUNT(*) FROM message** -\u0026gt; ** WHERE posted \u0026gt;= NOW() - INTERVAL 24 HOUR** -\u0026gt; ** AND posted \u0026lt; CONCAT(LEFT(NOW(), 14), '00:00') - INTERVAL 23 HOUR;** mysql\u0026gt; ** SELECT COUNT(*) FROM message** -\u0026gt; ** WHERE posted \u0026gt;= CONCAT(LEFT(NOW(), 14), '00:00');** 不管是哪种方法——不严格的计数或通过小范围查询填满间隙的严格计数——都比计算message表的所有行要有效得多。这是建立汇总表的最关键原因。实时计算统计值是很昂贵的操作,因为要么需要扫描表中的大部分数据,要么查询语句只能在某些特定的索引上才能有效运行,而这类特定索引一般会对UPDATE操作有影响,所以一般不希望创建这样的索引。计算最活跃的用户或者最常见的“标签”是这种操作的典型例子。\n缓存表则相反,其对优化搜索和检索查询语句很有效。这些查询语句经常需要特殊的表和索引结构,跟普通OLTP操作用的表有些区别。\n例如,可能会需要很多不同的索引组合来加速各种类型的查询。这些矛盾的需求有时需要创建一张只包含主表中部分列的缓存表。一个有用的技巧是对缓存表使用不同的存储引擎。例如,如果主表使用InnoDB,用MyISAM作为缓存表的引擎将会得到更小的索引占用空间,并且可以做全文搜索。有时甚至想把整个表导出MySQL,插入到专门的搜索系统中获得更高的搜索效率,例如Lucene或者Sphinx搜索引擎。\n在使用缓存表和汇总表时,必须决定是实时维护数据还是定期重建。哪个更好依赖于应用程序,但是定期重建并不只是节省资源,也可以保持表不会有很多碎片,以及有完全顺序组织的索引(这会更加高效)。\n当重建汇总表和缓存表时,通常需要保证数据在操作时依然可用。这就需要通过使用“影子表”来实现,“影子表”指的是一张在真实表“背后”创建的表。当完成了建表操作后,可以通过一个原子的重命名操作切换影子表和原表。例如,如果需要重建my_summary,则可以先创建my_summary_new,然后填充好数据,最后和真实表做切换:\nmysql\u0026gt; ** DROP TABLE IF EXISTS my_summary_new, my_summary_old;** mysql\u0026gt; ** CREATE TABLE my_summary_new LIKE my_summary;** -- populate my_summary_new as desired mysql\u0026gt; ** RENAME TABLE my_summary TO my_summary_old, my_summary_new TO my_summary;** 如果像上面的例子一样,在将my_summary这个名字分配给新建的表之前将原始的my_summary表重命名为my_summary_old,就可以在下一次重建之前一直保留旧版本的数据。如果新表有问题,则可以很容易地进行快速回滚操作。\n4.4.1 物化视图 # 许多数据库管理系统(例如Oracle或者微软SQL Server)都提供了一个被称作物化视图的功能。物化视图实际上是预先计算并且存储在磁盘上的表,可以通过各种各样的策略刷新和更新。MySQL并不原生支持物化视图(我们将在第7章详细探讨支持这种视图的细节)。然而,使用Justin Swanhart的开源工具Flexviews( http://code.google.com/p/flexviews/),也可以自己实现物化视图。Flexviews比完全自己实现的解决方案要更精细,并且提供了很多不错的功能使得可以更简单地创建和维护物化视图。它由下面这些部分组成:\n变更数据抓取(Change Data Capture,CDC)功能,可以读取服务器的二进制日志并且解析相关行的变更。 一系列可以帮助创建和管理视图的定义的存储过程。 一些可以应用变更到数据库中的物化视图的工具。 对比传统的维护汇总表和缓存表的方法,Flexviews通过提取对源表的更改,可以增量地重新计算物化视图的内容。这意味着不需要通过查询原始数据来更新视图。例如,如果创建了一张汇总表用于计算每个分组的行数,此后增加了一行数据到源表中,Flexviews简单地给相应的组的行数加一即可。同样的技术对其他的聚合函数也有效,例如SUM()和AVG()。这实际上是有好处的,基于行的二进制日志包含行更新前后的镜像,所以Flexviews不仅仅可以获得每行的新值,还可以不需要查找源表就能知道每行数据的旧版本。计算增量数据比从源表中读取数据的效率要高得多。\n因为版面的限制,这里我们不会完整地探讨怎么使用Flexviews,但是可以给出一个概略。先写出一个SELECT语句描述想从已经存在的数据库中得到的数据。这可能包含关联和聚合(GROUP BY)。Flexviews中有一个辅助工具可以转换SQL语句到Flexviews的API调用。Flexviews会做完所有的脏活、累活:监控数据库的变更并且转换后用于更新存储物化视图的表。现在应用可以简单地查询物化视图来替代查询需要检索的表。\nFlexviews有不错的SQL覆盖范围,包括一些棘手的表达式,你可能没有料到一个工具可以在MySQL服务器之外处理这些工作。这一点对创建基于复杂SQL表达式的视图很有用,可以用基于物化视图的简单、快速的查询替换原来复杂的查询。\n4.4.2 计数器表 # 如果应用在表中保存计数器,则在更新计数器时可能碰到并发问题。计数器表在Web应用中很常见。可以用这种表缓存一个用户的朋友数、文件下载次数等。创建一张独立的表存储计数器通常是个好主意,这样可使计数器表小且快。使用独立的表可以帮助避免查询缓存失效,并且可以使用本节展示的一些更高级的技巧。\n应该让事情变得尽可能简单,假设有一个计数器表,只有一行数据,记录网站的点击次数:\nmysql\u0026gt; ** CREATE TABLE hit_counter (** -\u0026gt; ** cnt int unsigned not null** -\u0026gt; ** ) ENGINE=InnoDB;** 网站的每次点击都会导致对计数器进行更新:\nmysql\u0026gt; ** UPDATE hit_counter SET cnt = cnt + 1;** 问题在于,对于任何想要更新这一行的事务来说,这条记录上都有一个全局的互斥锁(mutex)。这会使得这些事务只能串行执行。要获得更高的并发更新性能,也可以将计数器保存在多行中,每次随机选择一行进行更新。这样做需要对计数器表进行如下修改:\nmysql\u0026gt; ** CREATE TABLE hit_counter (** -\u0026gt; ** slot tinyint unsigned not null primary key,** -\u0026gt; ** cnt int unsigned not null** -\u0026gt; ** ) ENGINE=InnoDB;** 然后预先在这张表增加100行数据。现在选择一个随机的槽(slot)进行更新:\nmysql\u0026gt; ** UPDATE hit_counter SET cnt = cnt + 1 WHERE slot = RAND() * 100;** 要获得统计结果,需要使用下面这样的聚合查询:\nmysql\u0026gt; ** SELECT SUM(cnt) FROM hit_counter;** 一个常见的需求是每隔一段时间开始一个新的计数器(例如,每天一个)。如果需要这么做,则可以再简单地修改一下表设计:\nmysql\u0026gt; ** CREATE TABLE daily_hit_counter (** -\u0026gt; ** day date not null,** -\u0026gt; ** slot tinyint unsigned not null,** -\u0026gt; ** cnt int unsigned not null,** -\u0026gt; ** primary key(day, slot)** -\u0026gt; ** ) ENGINE=InnoDB;** 在这个场景中,可以不用像前面的例子那样预先生成行,而用ON DUPLICATE KEY UPDATE代替:\nmysql\u0026gt; ** INSERT INTO daily_hit_counter(day, slot, cnt)** -\u0026gt; ** VALUES(CURRENT_DATE, RAND() * 100, 1)** -\u0026gt; ** ON DUPLICATE KEY UPDATE cnt = cnt + 1;** 如果希望减少表的行数,以避免表变得太大,可以写一个周期执行的任务,合并所有结果到0号槽,并且删除所有其他的槽:\nmysql\u0026gt; ** UPDATE daily_hit_counter as c** -\u0026gt; ** INNER JOIN (** -\u0026gt; ** SELECT day, SUM(cnt) AS cnt, MIN(slot) AS mslot** -\u0026gt; ** FROM daily_hit_counter** -\u0026gt; ** GROUP BY day** -\u0026gt; ** ) AS x USING(day)** -\u0026gt; ** SET c.cnt = IF(c.slot = x.mslot, x.cnt, 0),** -\u0026gt; ** c.slot = IF(c.slot = x.mslot, 0, c.slot);** mysql\u0026gt; ** DELETE FROM daily_hit_counter WHERE slot \u0026lt;\u0026gt; 0 AND cnt = 0;** 更快地读,更慢地写\n为了提升读查询的速度,经常会需要建一些额外索引,增加冗余列,甚至是创建缓存表和汇总表。这些方法会增加写查询的负担,也需要额外的维护任务,但在设计高性能数据库时,这些都是常见的技巧:虽然写操作变得更慢了,但更显著地提高了读操作的性能。\n然而,写操作变慢并不是读操作变得更快所付出的唯一代价,还可能同时增加了读操作和写操作的开发难度。\n4.5 加快ALTER TABLE操作的速度 # MySQL的ALTER TABLE操作的性能对大表来说是个大问题。MySQL执行大部分修改表结构操作的方法是用新的结构创建一个空表,从旧表中查出所有数据插入新表,然后删除旧表。这样操作可能需要花费很长时间,如果内存不足而表又很大,而且还有很多索引的情况下尤其如此。许多人都有这样的经验,ALTER TABLE操作需要花费数个小时甚至数天才能完成。\nMySQL 5.1以及更新版本包含一些类型的“在线”操作的支持,这些功能不需要在整个操作过程中锁表。最近版本的InnoDB(15)也支持通过排序来建索引,这使得建索引更快并且有一个紧凑的索引布局。\n一般而言,大部分ALTER TABLE操作将导致MySQL服务中断。我们会展示一些在DDL操作时有用的技巧,但这是针对一些特殊的场景而言的。对常见的场景,能使用的技巧只有两种:一种是先在一台不提供服务的机器上执行ALTER TABLE操作,然后和提供服务的主库进行切换;另外一种技巧是“影子拷贝”。影子拷贝的技巧是用要求的表结构创建一张和源表无关的新表,然后通过重命名和删表操作交换两张表。也有一些工具可以帮助完成影子拷贝工作:例如,Facebook数据库运维团队( https://launchpad.net/mysqlatfacebook)的“online schema change”工具、Shlomi Noach的openark toolkit( http://code.openark.org/),以及Percona Toolkit( http://www.percona.com/software/)。如果使用Flexviews(参考4.4.1节),也可以通过其CDC工具执行无锁的表结构变更。\n不是所有的ALTER TABLE操作都会引起表重建。例如,有两种方法可以改变或者删除一个列的默认值(一种方法很快,另外一种则很慢)。假如要修改电影的默认租赁期限,从三天改到五天。下面是很慢的方式:\nmysql\u0026gt; ** ALTER TABLE sakila.film** -\u0026gt; ** MODIFY COLUMN rental_duration TINYINT(3) NOT NULL DEFAULT 5;** SHOW STATUS显示这个语句做了1000次读和1000次插入操作。换句话说,它拷贝了整张表到一张新表,甚至列的类型、大小和可否为NULL属性都没改变。\n理论上,MySQL可以跳过创建新表的步骤。列的默认值实际上存在表的*.frm*文件中,所以可以直接修改这个文件而不需要改动表本身。然而MySQL还没有采用这种优化的方法,所有的MODIFY COLUMN操作都将导致表重建。\n另外一种方法是通过ALTER COLUMN(16)操作来改变列的默认值:\nmysql\u0026gt; ** ALTER TABLE sakila.film** -\u0026gt; ** ALTER COLUMN rental_duration SET DEFAULT 5;** 这个语句会直接修改*.frm*文件而不涉及表数据。所以,这个操作是非常快的。\n4.5.1 只修改.frm文件 # 从上面的例子我们看到修改表的*.frm*文件是很快的,但MySQL有时候会在没有必要的时候也重建表。如果愿意冒一些风险,可以让MySQL做一些其他类型的修改而不用重建表。\n我们下面要演示的技巧是不受官方支持的,也没有文档记录,并且也可能不能正常工作,采用这些技术需要自己承担风险。建议在执行之前首先备份数据!\n下面这些操作是有可能不需要重建表的:\n移除(不是增加)一个列的AUTO_INCREMENT属性。 增加、移除,或更改ENUM和SET常量。如果移除的是已经有行数据用到其值的常量,查询将会返回一个空字串值。 基本的技术是为想要的表结构创建一个新的*.frm文件,然后用它替换掉已经存在的那张表的.frm*文件,像下面这样:\n创建一张有相同结构的空表,并进行所需要的修改(例如增加ENUM常量)。 执行FLUSH TABLES WITH READ LOCK。这将会关闭所有正在使用的表,并且禁止任何表被打开。 交换*.frm*文件. 执行UNLOCK TABLES来释放第2步的读锁。 下面以给sakila.flm表的rating列增加一个常量为例来说明。当前列看起来如下:\n假设我们需要为那些对电影更加谨慎的父母们增加一个PG-14的电影分级:\nmysql\u0026gt; ** CREATE TABLE sakila.film_new LIKE sakila.film;** mysql\u0026gt; ** ALTER TABLE sakila.film_new** -\u0026gt; ** MODIFY COLUMN rating ENUM('G','PG','PG-13','R','NC-17', 'PG-14')** -\u0026gt; ** DEFAULT 'G';** mysql\u0026gt; ** FLUSH TABLES WITH READ LOCK;** 注意,我们是在常量列表的末尾增加一个新的值。如果把新增的值放在中间,例如PG-13之后,则会导致已经存在的数据的含义被改变:已经存在的R值将变成PG-14,而已经存在的NC-17将成为R,等等。\n接下来用操作系统的命令交换*.frm*文件:\n/var/lib/mysql/sakila# mv film.frm film_tmp.frm /var/lib/mysql/sakila# mv film_new.frm film.frm /var/lib/mysql/sakila# mv film_tmp.frm film_new.frm 再回到MySQL命令行,现在可以解锁表并且看到变更后的效果了:\nmysql\u0026gt; ** UNLOCK TABLES;** mysql\u0026gt; ** SHOW COLUMNS FROM sakila.film LIKE 'rating'\\G** *************************** 1. row *************************** Field: rating Type: enum('G','PG','PG-13','R','NC-17','PG-14') 最后需要做的是删除为完成这个操作而创建的辅助表:\nmysql\u0026gt; DROP TABLE sakila.film_new; 4.5.2 快速创建MyISAM索引 # 为了高效地载入数据到MyISAM表中,有一个常用的技巧是先禁用索引、载入数据,然后重新启用索引:\nmysql\u0026gt; ** ALTER TABLE test.load_data ENABLE KEYS;** -- load the data mysql\u0026gt; ** ALTER TABLE test.load_data ENABLE KEYS;** 这个技巧能够发挥作用,是因为构建索引的工作被延迟到数据完全载入以后,这个时候已经可以通过排序来构建索引了。这样做会快很多,并且使得索引树(17)的碎片更少、更紧凑。\n不幸的是,这个办法对唯一索引无效,因为DISABLE KEYS只对非唯一索引有效。MyISAM会在内存中构造唯一索引,并且为载入的每一行检查唯一性。一旦索引的大小超过了有效内存大小,载入操作就会变得越来越慢。\n在现代版本的InnoDB版本中,有一个类似的技巧,这依赖于InnoDB的快速在线索引创建功能。这个技巧是,先删除所有的非唯一索引,然后增加新的列,最后重新创建删除掉的索引。Percona Server可以自动完成这些操作步骤。\n也可以使用像前面说的ALTER TABLE的骇客方法来加速这个操作,但需要多做一些工作并且承担一定的风险。这对从备份中载入数据是很有用的,例如,当已经知道所有数据都是有效的并且没有必要做唯一性检查时就可以这么来操作。\n再次说明,这是没有文档说明并且不受官方支持的技巧。若使用的话,需要自己承担风险,并且操作之前一定要先备份数据。\n下面是操作步骤:\n用需要的表结构创建一张表,但是不包括索引。 载入数据到表中以构建*.MYD*文件。 按照需要的结构创建另外一张空表,这次要包含索引。这会创建需要的*.frm和.MYI*文件。 获取读锁并刷新表。 重命名第二张表的*.frm和.MYI*文件,让MySQL认为是第一张表的文件。 释放读锁。 使用REPAIR TABLE来重建表的索引。该操作会通过排序来构建所有索引,包括唯一索引。 这个操作步骤对大表来说会快很多。\n4.6 总结 # 良好的schema设计原则是普遍适用的,但MySQL有它自己的实现细节要注意。概括来说,尽可能保持任何东西小而简单总是好的。MySQL喜欢简单,需要使用数据库的人应该也同样会喜欢简单的原则:\n尽量避免过度设计,例如会导致极其复杂查询的schema设计,或者有很多列的表设计(很多的意思是介于有点多和非常多之间)。 使用小而简单的合适数据类型,除非真实数据模型中有确切的需要,否则应该尽可能地避免使用NULL值。 尽量使用相同的数据类型存储相似或相关的值,尤其是要在关联条件中使用的列。 注意可变长字符串,其在临时表和排序时可能导致悲观的按最大长度分配内存。 尽量使用整型定义标识列。 避免使用MySQL已经遗弃的特性,例如指定浮点数的精度,或者整数的显示宽度。 小心使用ENUM和SET。虽然它们用起来很方便,但是不要滥用,否则有时候会变成陷阱。最好避免使用BIT。 范式是好的,但是反范式(大多数情况下意味着重复数据)有时也是必需的,并且能带来好处。第5章我们将看到更多的例子。预先计算、缓存或生成汇总表也可能获得很大的好处。Justin Swanhart的Flexviews工具可以帮助维护汇总表。\n最后,ALTER TABLE是让人痛苦的操作,因为在大部分情况下,它都会锁表并且重建整张表。我们展示了一些特殊的场景可以使用骇客方法;但是对大部分场景,必须使用其他更常规的方法,例如在备机执行ALTER并在完成后把它切换为主库。本书后续章节会有更多关于这方面的内容。\n————————————————————\n(1) 例如只需要存0~200,tinyint unsigned更好。——译者注\n(2) date,time,datatime——译者注\n(3) 如果定义表结构时没有指定列为NOT NULL,默认都是允许为NULL的。\n(4) 很多值为NULL,只有少数行的列有非NULL值。——译者注\n(5) 记住字符串长度定义不是字节数,是字符数。多字节字符集会需要更多的空间存储单个字符。\n(6) string3尾部的空格还在。——译者注\n(7) Percona Server里的Memory引擎支持变长的行。\n(8) 如果需要在检索时保持值不变,则需要特别小心BINARY类型,MySQL会用\\0将其填充到需要的长度。\n(9) 这很可能可以节省I/O。——译者注\n(10) TIMESTAMP的行为规则比较复杂,并且在不同的MySQL版本里会变动,所以你应该验证数据库的行为是你需要的。一个好的方式是修改完TIMESTAMP列后用SHOW CREATE TABLE命令检查输出。\n(11) 如果使用的是InnoDB存储引擎,将不能在数据类型不是完全匹配的情况下创建外键,否则会有报错信息:“ERROR 1005(HY000):Can\u0026rsquo;t create table”,这个信息可能让人迷惑不解,这个问题在MySQL邮件组也经常有人抱怨(但奇怪的是,在不同长度的VARCHAR列上创建外键又是可以的)。\n(12) 这是关联到另一张存储名字的表的ID。——译者注\n(13) 另一方面,对一些有很多写的特别大的表,这种伪随机值实际上可以帮助消除热点。\n(14) 全表扫描基本上是顺序I/O,但也不是100%的,跟引擎的实现有关。——译者注\n(15) 就是所谓的“InnoDB plugin”,MySQL 5.5和更新版本中唯一的InnoDB。请参考第1章中关于InnoDB发布历史的细节。\n(16) ALTER TABLE允许使用ALTER COLUMN、MODIFY COLUMN和CHANGE COLUMN语句修改列。这三种操作都是不一样的。\n(17) 如果使用的是LOAD DATA FILE,并且要载入的表是空的,MyISAM也可以通过排序来构造索引。\n"},{"id":144,"href":"/zh/docs/technology/MySQL/_%E9%AB%98%E6%80%A7%E8%83%BDMySQL_/%E7%AC%AC3%E7%AB%A0%E6%9C%8D%E5%8A%A1%E5%99%A8%E6%80%A7%E8%83%BD%E5%89%96%E6%9E%90/","title":"第3章服务器性能剖析","section":"高性能 My SQL","content":"第3章 服务器性能剖析\n在我们的技术咨询生涯中,最常碰到的三个性能相关的服务请求是:如何确认服务器是否达到了性能最佳的状态、找出某条语句为什么执行不够快,以及诊断被用户描述成“停顿”、“堆积”或者“卡死”的某些间歇性疑难故障。本章将主要针对这三个问题做出解答。我们将提供一些工具和技巧来优化整机的性能、优化单条语句的执行速度,以及诊断或者解决那些很难观察到的问题(这些问题用户往往很难知道其根源,有时候甚至都很难察觉到它的存在)。\n这看起来是个艰巨的任务,但是事实证明,有一个简单的方法能够从噪声中发现苗头。这个方法就是专注于测量服务器的时间花费在哪里,使用的技术则是性能剖析(profiling)。在本章,我们将展示如何测量系统并生成剖析报告,以及如何分析系统的整个堆栈(stack),包括从应用程序到数据库服务器到单个查询。\n首先我们要保持空杯精神,抛弃掉一些关于性能的常见的误解。这有一定的难度,下面我们一起通过一些例子来说明问题在哪里。\n3.1 性能优化简介 # 问10个人关于性能的问题,可能会得到10个不同的回答,比如“每秒查询次数”、“CPU利用率”、“可扩展性”之类。这其实也没有问题,每个人在不同场景下对性能有不同的理解,但本章将给性能一个正式的定义。我们将性能定义为完成某件任务所需要的时间度量,换句话说,性能即响应时间,这是一个非常重要的原则。我们通过任务和时间而不是资源来测量性能。数据库服务器的目的是执行SQL语句,所以它关注的任务是查询或者语句,如SELECT、UPDATE、DELETE等(1)。数据库服务器的性能用查询的响应时间来度量,单位是每个查询花费的时间。\n还有另外一个问题:什么是优化?我们暂时不讨论这个问题,而是假设性能优化就是在一定的工作负载下尽可能地(2)降低响应时间。\n很多人对此很迷茫。假如你认为性能优化是降低CPU利用率,那么可以减少对资源的使用。但这是一个陷阱,资源是用来消耗并用来工作的,所以有时候消耗更多的资源能够加快查询速度。很多时候将使用老版本InnoDB引擎的MySQL升级到新版本后,CPU利用率会上升得很厉害,这并不代表性能出现了问题,反而说明新版本的InnoDB对资源的利用率上升了。查询的响应时间则更能体现升级后的性能是不是变得更好。版本升级有时候会带来一些bug,比如不能利用某些索引从而导致CPU利用率上升。CPU利用率只是一种现象,而不是很好的可度量的目标。\n同样,如果把性能优化仅仅看成是提升每秒查询量,这其实只是吞吐量优化。吞吐量的提升可以看作性能优化的副产品(3)。对查询的优化可以让服务器每秒执行更多的查询,因为每条查询执行的时间更短了(吞吐量的定义是单位时间内的查询数量,这正好是我们对性能的定义的倒数)。\n所以如果目标是降低响应时间,那么就需要理解为什么服务器执行查询需要这么多时间,然后去减少或者消除那些对获得查询结果来说不必要的工作。也就是说,先要搞清楚时间花在哪里。这就引申出优化的第二个原则:无法测量就无法有效地优化。所以第一步应该测量时间花在什么地方。\n我们观察到,很多人在优化时,都将精力放在修改一些东西上,却很少去进行精确的测量。我们的做法完全相反,将花费非常多,甚至90%的时间来测量响应时间花在哪里。如果通过测量没有找到答案,那要么是测量的方式错了,要么是测量得不够完整。如果测量了系统中完整而且正确的数据,性能问题一般都能暴露出来,对症下药的解决方案也就比较明了。测量是一项很有挑战性的工作,并且分析结果也同样有挑战性,测出时间花在哪里,和知道为什么花在那里,是两码事。\n前面提到需要合适的测量范围,这是什么意思呢?合适的测量范围是说只测量需要优化的活动。有两种比较常见的情况会导致不合适的测量:\n在错误的时间启动和停止测量。 测量的是聚合后的信息,而不是目标活动本身。 例如,一个常见的错误是先查看慢查询,然后又去排查整个服务器的情况来判断问题在哪里。如果确认有慢查询,那么就应该测量慢查询,而不是测量整个服务器。测量的应该是从慢查询的开始到结束的时间,而不是查询之前或查询之后的时间。\n完成一项任务所需要的时间可以分成两部分:执行时间和等待时间。如果要优化任务的执行时间,最好的办法是通过测量定位不同的子任务花费的时间,然后优化去掉一些子任务、降低子任务的执行频率或者提升子任务的效率。而优化任务的等待时间则相对要复杂一些,因为等待有可能是由其他系统间接影响导致,任务之间也可能由于争用磁盘或者CPU资源而相互影响。根据时间是花在执行还是等待上的不同,诊断也需要不同的工具和技术。\n刚才说到需要定位和优化子任务,但只是一笔带过。一些运行不频繁或者很短的子任务对整体响应时间的影响很小,通常可以忽略不计。那么如何确认哪些子任务是优化的目标呢?这个时候性能剖析就可以派上用场了。\n如何判断测量是正确的?\n如果测量是如此重要,那么测量错了会有什么后果?实际上,测量经常都是错误的。对数量的测量并不等于数量本身。测量的错误可能很小,跟实际情况区别不大,但错的终归是错的。所以这个问题其实应该是:“测量到底有多么不准确?”这个问题在其他一些书中有详细的讨论,但不是本书的主题。但是要意识到使用的是测量数据,而不是其所代表的实际数据。通常来说,测量的结果也可能有多种模糊的表现,这可能导致推断出错误的结论。\n3.1.1 通过性能剖析进行优化 # 一旦掌握并实践面向响应时间的优化方法,就会发现需要不断地对系统进行性能剖析(profiling)。\n性能剖析是测量和分析时间花费在哪里的主要方法。性能剖析一般有两个步骤:测量任务所花费的时间;然后对结果进行统计和排序,将重要的任务排到前面。\n性能剖析工具的工作方式基本相同。在任务开始时启动计时器,在任务结束时停止计时器,然后用结束时间减去启动时间得到响应时间。也有些工具会记录任务的父任务。这些结果数据可以用来绘制调用关系图,但对于我们的目标来说更重要的是,可以将相似的任务分组并进行汇总。对相似的任务分组并进行汇总可以帮助对那些分到一组的任务做更复杂的统计分析,但至少需要知道每一组有多少任务,并计算出总的响应时间。通过性能剖析报告(profile report)可以获得需要的结果。性能剖析报告会列出所有任务列表。每行记录一个任务,包括任务名、任务的执行时间、任务的消耗时间、任务的平均执行时间,以及该任务执行时间占全部时间的百分比。性能剖析报告会按照任务的消耗时间进行降序排序。\n为了更好地说明,这里举一个对整个数据库服务器工作负载的性能剖析的例子,主要输出的是各种类型的查询和执行查询的时间。这是从整体的角度来分析响应时间,后面会演示其他角度的分析结果。下面的输出是用Percona Toolkit中的pt-query-digest(实际上就是著名的Maatkit工具中的mk-query-digest)分析得到的结果。为了显示方便,对结果做了一些微调,并且只截取了前面几行结果:\nRank Response time Calls R/Call Item ==== ================ ===== ====== ======= 1 11256.3618 68.1% 78069 0.1442 SELECT InvitesNew 2 2029.4730 12.3% 14415 0.1408 SELECT StatusUpdate 3 1345.3445 8.1% 3520 0.3822 SHOW STATUS 上面只是性能剖析结果的前几行,根据总响应时间进行排名,只包括剖析所需要的最小列组合。每一行都包括了查询的响应时间和占总时间的百分比、查询的执行次数、单次执行的平均响应时间,以及该查询的摘要。通过这个性能剖析可以很清楚地看到每个查询相互之间的成本比较,以及每个查询占总成本的比较。在这个例子中,任务指的就是查询,实际上在分析MySQL的时候经常都指的是查询。\n我们将实际地讨论两种类型的性能剖析:基于执行时间的分析和基于等待的分析。基于执行时间的分析研究的是什么任务的执行时间最长,而基于等待的分析则是判断任务在什么地方被阻塞的时间最长。\n如果任务执行时间长是因为消耗了太多的资源且大部分时间花费在执行上,等待的时间不多,这种情况下基于等待的分析作用就不大。反之亦然,如果任务一直在等待,没有消耗什么资源,去分析执行时间就不会有什么结果。如果不能确认问题是出在执行还是等待上,那么两种方式都需要试试。后面会给出详细的例子。\n事实上,当基于执行时间的分析发现一个任务需要花费太多时间的时候,应该深入去分析一下,可能会发现某些“执行时间”实际上是在等待。例如,上面简单的性能剖析的输出显示表InvitesNew上的SELECT查询花费了大量时间,如果深入研究,则可能发现时间都花费在等待I/O完成上。\n在对系统进行性能剖析前,必须先要能够进行测量,这需要系统可测量化的支持。可测量的系统一般会有多个测量点可以捕获并收集数据,但实际系统很少可以做到可测量化。大部分系统都没有多少可测量点,即使有也只提供一些活动的计数,而没有活动花费的时间统计。MySQL就是一个典型的例子,直到版本5.5才第一次提供了Performance Schema,其中有一些基于时间的测量点(4),而版本5.1及之前的版本没有任何基于时间的测量点。能够从MySQL收集到的服务器操作的数据大多是show status计数器的形式,这些计数器统计的是某种活动发生的次数。这也是我们最终决定创建Percona Server的主要原因,Percona Server从版本5.0开始提供很多更详细的查询级别的测量点。\n虽然理想的性能优化技术依赖于更多的测量点,但幸运的是,即使系统没有提供测量点,也还有其他办法可以展开优化工作。因为还可以从外部去测量系统,如果测量失败,也可以根据对系统的了解做出一些靠谱的猜测。但这么做的时候一定要记住,不管是外部测量还是猜测,数据都不是百分之百准确的,这是系统不透明所带来的风险。\n举个例子,在Percona Server 5.0中,慢查询日志揭露了一些性能低下的原因,如磁盘I/O等待或者行级锁等待。如果日志中显示一条查询花费10秒,其中9.6秒在等待磁盘I/O,那么追究其他4%的时间花费在哪里就没有意义,磁盘I/O才是最重要的原因。\n3.1.2 理解性能剖析 # MySQL的性能剖析(profile)将最重要的任务展示在前面,但有时候没显示出来的信息也很重要。可以参考一下前面提到过的性能剖析的例子。不幸的是,尽管性能剖析输出了排名、总计和平均值,但还是有很多需要的信息是缺失的,如下所示。\n值得优化的查询(worthwhile query)\n性能剖析不会自动给出哪些查询值得花时间去优化。这把我们带回到优化的本意,如果你读过Cary Millsap的书,对此就会有更多的理解。这里我们要再次强调两点:第一,一些只占总响应时间比重很小的查询是不值得优化的。根据阿姆达尔定律(Amdahl\u0026rsquo;s Law),对一个占总响应时间不超过5%的查询进行优化,无论如何努力,收益也不会超过5%。第二,如果花费了1000美元去优化一个任务,但业务的收入没有任何增加,那么可以说反而导致业务被逆优化了1000美元。如果优化的成本大于收益,就应当停止优化。\n异常情况\n某些任务即使没有出现在性能剖析输出的前面也需要优化。比如某些任务执行次数很少,但每次执行都非常慢,严重影响用户体验。因为其执行频率低,所以总的响应时间占比并不突出。\n未知的未知(5)\n一款好的性能剖析工具会显示可能的“丢失的时间”。丢失的时间指的是任务的总时间和实际测量到的时间之间的差。例如,如果处理器的CPU时间是10秒,而剖析到的任务总时间是9.7秒,那么就有300毫秒的丢失时间。这可能是有些任务没有测量到,也可能是由于测量的误差和精度问题的缘故。如果工具发现了这类问题,则要引起重视,因为有可能错过了某些重要的事情。即使性能剖析没有发现丢失时间,也需要注意考虑这类问题存在的可能性,这样才不会错过重要的信息。我们的例子中没有显示丢失的时间,这是我们所使用工具的一个局限性。\n被掩藏的细节\n性能剖析无法显示所有响应时间的分布。只相信平均值是非常危险的,它会隐藏很多信息,而且无法表达全部情况。Peter经常举例说医院所有病人的平均体温没有任何价值(6)。假如在前面的性能剖析的例子的第一项中,如果有两次查询的响应时间是1秒,而另外12771次查询的响应时间是几十微秒,结果会怎样?只从平均值里是无法发现两次1秒的查询的。要做出最好的决策,需要为性能剖析里输出的这一行中包含的12773次查询提供更多的信息,尤其是更多响应时间的信息,比如直方图、百分比、标准差、偏差指数等。\n好的工具可以自动地获得这些信息。实际上,pt-query-digest就在剖析的结果里包含了很多这类细节信息,并且输出在剖析报告中。对此我们做了简化,可以将精力集中在重要而基础的例子上:通过排序将最昂贵的任务排在前面。本章后面会展示更多丰富而有用的性能剖析的例子。\n在前面的性能剖析的例子中,还有一个重要的缺失,就是无法在更高层次的堆栈中进行交互式的分析。当我们仅仅着眼于服务器中的单个查询时,无法将相关查询联系起来,也无法理解这些查询是否是同一个用户交互的一部分。性能剖析只能管中窥豹,而无法将剖析从任务扩展至事务或者页面查看(page view)的级别。也有一些办法可以解决这个问题,比如给查询加上特殊的注释作为标签,可以标明其来源并据此做聚合,也可以在应用层面增加更多的测量点,这是下一节的主题。\n3.2 对应用程序进行性能剖析 # 对任何需要消耗时间的任务都可以做性能剖析,当然也包括应用程序。实际上,剖析应用程序一般比剖析数据库服务器容易,而且回报更多。虽然前面的演示例子都是针对MySQL服务器的剖析,但对系统进行性能剖析还是建议自上而下地进行(7),这样可以追踪自用户发起到服务器响应的整个流程。虽然性能问题大多数情况下都和数据库有关,但应用导致的性能问题也不少。性能瓶颈可能有很多影响因素:\n外部资源,比如调用了外部的Web服务或者搜索引擎。 应用需要处理大量的数据,比如分析一个超大的XML文件。 在循环中执行昂贵的操作,比如滥用正则表达式。 使用了低效的算法,比如使用暴力搜索算法(naïve search algorithm)来查找列表中的项。 幸运的是,确定MySQL的问题没有这么复杂,只需要一款应用程序的剖析工具即可(作为回报,一旦拥有这样的工具,就可以从一开始就写出高效的代码)。\n建议在所有的新项目中都考虑包含性能剖析的代码。往已有的项目中加入性能剖析代码也许很困难,新项目就简单一些。\n性能剖析本身会导致服务器变慢吗?\n说“是的”,是因为性能剖析确实会导致应用慢一点;说“不是”,是因为性能剖析可以帮助应用运行得更快。先别急,下面就解释一下为什么这么说。\n性能剖析和定期检测都会带来额外开销。问题在于这部分的开销有多少,并且由此获得的收益是否能够抵消这些开销。\n大多数设计和构建过高性能应用程序的人相信,应该尽可能地测量一切可以测量的地方,并且接受这些测量带来的额外开销,这些开销应该被当成应用程序的一部分。Oracle的性能优化大师Tom Kyte曾被问到Oracle中的测量点的开销,他的回答是,测量点至少为性能优化贡献了10%。对此我们深表赞同,而且大多数应用并不需要每天都运行详细的性能测量,所以实际贡献甚至要超过10%。即使不同意这个观点,为应用构建一些可以永久使用的轻量级的性能剖析也是有意义的。如果系统没有每天变化的性能统计,则碰到无法提前预知的性能瓶颈就是一件头痛的事情。发现问题的时候,如果有历史数据,则这些历史数据价值是无限的。而且性能数据还可以帮助规划好硬件采购、资源分配,以及预测周期性的性能尖峰。\n那么何谓“轻量级”的性能剖析?比如可以为所有SQL语句计时,加上脚本总时间统计,这样做的代价不高,而且不需要在每次页面查看(page view)时都执行。如果流量趋势比较稳定,随机采样也可以,随机采样可以通过在应用程序中设置实现:\n\u0026lt;?php $profiling_enabled=rand(0,100)\u0026gt;99; ?\u0026gt; 这样只有1%的会话会执行性能采样,来帮助定位一些严重的问题。这种策略在生产环境中尤其有用,可以发现一些其他方法无法发现的问题。\n几年前在写作本书的第二版的时候,流行的Web编程语言和框架中还没有太多现成的性能剖析工具可以用于生产环境,所以在书中展示了一段示例代码,可以简单而有效地复制使用。而到了今天,已经有了很多好用的工具,要做的只是打开工具箱,就可以开始优化性能。\n首先,这里要“兜售”的一个好工具是一款叫做New Relic的软件即服务(software-as-a-service)产品。声明一下我们不是“托”,我们一般不会推荐某个特定公司或产品,但这个工具真的非常棒,建议大家都用它。我们的客户借助这个工具,在没有我们帮助的情况下,解决了很多问题;即使有时候找不到解决办法,但依然能够帮助定位到问题。New Relic会插入到应用程序中进行性能剖析,将收集到的数据发送到一个基于Web的仪表盘,使用仪表盘可以更容易利用面向响应时间的方法分析应用性能。这样用户只需要考虑做那些正确的事情,而不用考虑如何去做。而且New Relic测量了很多用户体验相关的点,涵盖从Web浏览器到应用代码,再到数据库及其他外部调用。\n像New Relic这类工具的好处是可以全天候地测量生产环境的代码——既不限于测试环境,也不限于某个时间段。这一点非常重要,因为有很多剖析工具或者测量点的代价很高,所以不能在生产环境全天候运行。在生产环境运行,可以发现一些在测试环境和预发环境无法发现的性能问题。如果工具在生产环境全天候运行的成本太高,那么至少也要在集群中找一台服务器运行,或者只针对部分代码运行,原因请参考前面的“性能剖析本身会导致服务器变慢吗?”。\n3.2.1 测量PHP应用程序 # 如果不使用New Relic,也有其他的选择。尤其是对PHP,有好几款工具都可以帮助进行性能剖析。其中一款叫做xhprof( http://pecl.php.net/package/xhprof),这是Facebook开发给内部使用的,在2009年开源了。xhprof有很多高级特性,并且易于安装和使用,它很轻量级,可扩展性也很好,可以在生产环境大量部署并全天候使用,它还能针对函数调用进行剖析,并根据耗费的时间进行排序。相比xhprof,还有一些更底层的工具,比如xdebug、Valgrind和cachegrind,可以从多个角度对代码进行检测(8)。有些工具会产生大量输出,并且开销很大,并不适合在生产环境运行,但在开发环境却可以发挥很大的作用。\n下面要讨论的另外一个PHP性能剖析工具是我们自己写的,基于本书第二版的代码和原则扩展而来,名叫IfP(instrumentation-for-php),代码托管在Goole Code上( http://code.google.com/p/instrumentation-for-php/)。Ifp并不像xhprof一样对PHP做深入的测量,而是更关注数据库调用。所以当无法在数据库层面进行测量的时候,Ifp可以很好地帮助应用剖析数据库的利用率。Ifp是一个提供了计数器和计时器的单例类,很容易部署到生产环境中,因为不需要访问PHP配置的权限(对很多开发人员来说,都没有访问PHP配置的权限,所以这一点很重要)。\nIfp不会自动剖析所有的PHP函数,而只是针对重要的函数。例如,对于某些需要剖析的地方要用到自定义的计数器,就需要手工启动和停止。但Ifp可以自动对整个页面的执行进行计时,这样对自动测量数据库和memcached的调用就比较简单,对于这种情况就无须手工启动或者停止。这也意味着,Ifp可以剖析三种情况:应用程序的请求(如page view)、数据库的查询和缓存的查询。Ifp还可以将计数器和计时器输出到Apache,通过Apache可以将结果写入到日志中。这是一种方便且轻量的记录结果的方式。Ifp不会保存其他数据,所以也不需要有系统管理员的权限。\n使用Ifp,只需要简单地在页面的开始处调用start_request()。理想情况下,在程序的一开始就应当调用:\nrequire_once('Instrumentation.php'); Instrumentation::get_instance()-\u0026gt;start_request(); 这段代码注册了一个shutdown函数,所以在执行结束的地方不需要再做更多的处理。\nIfp会自动对SQL添加注释,便于从数据库的查询日志中更灵活地分析应用的情况,通过SHOW PROCESSLIST也可以更清楚地知道性能低的查询出自何处。大多数情况下,定位性能低下查询的来源都不容易,尤其是那些通过字符串拼接出来的查询语句,都没有办法在源代码中去搜索。那么Ifp的这个功能就可以帮助解决这个问题,它可以很快定位到查询是从何处而来的,即使应用和数据库中间加了代理或者负载均衡层,也可以确认是哪个应用的用户,是哪个页面请求,是源代码中的哪个函数、代码行号,甚至是所创建的计数器的键值对。下面是一个例子:\n** --File: index.php Line: 118 Function: fullCachePage request_id: ABC session_id: XYZ** ** SELECT * FROM ...** 如何测量MySQL的调用取决于连接MySQL的接口。如果使用的是面向对象的mysqli接口,则只需要修改一行代码:将构造函数从mysqli改为可以自动测量的mysqli_x即可。mysqli_x构造函数是由Ifp提供的子类,可以在后台测量并改写查询。如果使用的不是面向对象的接口,或者是其他的数据库访问层,则需要修改更多的代码。如果数据库调用不是分散在代码各处还好,否则建议使用集成开发环境(IDE)如Eclipse,这样修改起来要容易些。但不管从哪个方面来看,将访问数据库的代码集中到一起都可以说是最佳实践。\nIfp的结果很容易分析。Percona Toolkit中的pt-query-digest能够很方便地从查询注释中抽取出键值对,所以只需要简单地将查询记录到MySQL的日志文件中,再对日志文件进行处理即可。Apache的mod_log_config模块可以利用Ifp输出的环境变量来定制日志输出,其中的宏%D还可以以微秒级记录请求时间。\n也可以通过LOAD DATA INFILE将Apache的日志载入到MySQL数据库中,然后通过SQL进行查询。在Ifp的网站上有一个PDF的幻灯片,详细给出了使用示例,包括查询和命令行参数都有。\n或许你会说不想或者没时间在代码中加入测量的功能,其实这事比想象的要容易得多,而且花在优化上的时间将会由于性能的优化而加倍地回报给你。对应用的测量是不可替代的。当然最好是直接使用New Relic、xhprof、Ifp或者其他已有的优化工具,而不必重新去发明“轮子”。\nMySQL企业监控器的查询分析功能\nMySQL的企业监控器(Enterprise Monitor)也是值得考虑的工具之一。这是Oracle提供的MySQL商业服务支持中的一部分。它可以捕获发送给服务器的查询,要么是通过应用程序连接MySQL的库文件实现,要么是在代理层实现(我们并不太建议使用代理层)。该工具有设计良好的用户界面,可以直观地显示查询的剖析结果,并且可以根据时间段进行缩放,例如可以选择某个异常的性能尖峰时间来查看状态图。也可以查看EXPLAIN出来的执行计划,这在故障诊断时非常有用。\n3.3 剖析MySQL查询 # 对查询进行性能剖析有两种方式,每种方式都有各自的问题,本章会详细介绍。可以剖析整个数据库服务器,这样可以分析出哪些查询是主要的压力来源(如果已经在最上面的应用层做过剖析,则可能已经知道哪些查询需要特别留意)。定位到具体需要优化的查询后,也可以钻取下去对这些查询进行单独的剖析,分析哪些子任务是响应时间的主要消耗者。\n3.3.1 剖析服务器负载 # 服务器端的剖析很有价值,因为在服务器端可以有效地审计效率低下的查询。定位和优化“坏”查询能够显著地提升应用的性能,也能解决某些特定的难题。还可以降低服务器的整体压力,这样所有的查询都将因为减少了对共享资源的争用而受益(“间接的好处”)。降低服务器的负载也可以推迟或者避免升级更昂贵硬件的需求,还可以发现和定位糟糕的用户体验,比如某些极端情况。\nMySQL的每一个新版本中都增加了更多的可测量点。如果当前的趋势可靠的话,那么在性能方面比较重要的测量需求很快能够在全球范围内得到支持。但如果只是需要剖析并找出代价高的查询,就不需要如此复杂。有一个工具很早之前就能帮到我们了,这就是慢查询日志。\n捕获MySQL的查询到日志文件中 # 在MySQL中,慢查询日志最初只是捕获比较“慢”的查询,而性能剖析却需要针对所有的查询。而且在MySQL 5.0及之前的版本中,慢查询日志的响应时间的单位是秒,粒度太粗了。幸运的是,这些限制都已经成为历史了。在MySQL 5.1及更新的版本中,慢日志的功能已经被加强,可以通过设置long_query_time为0来捕获所有的查询,而且查询的响应时间单位已经可以做到微秒级。如果使用的是Percona Server,那么5.0版本就具备了这些特性,而且Percona Server提供了对日志内容和查询捕获的更多控制能力。\n在MySQL的当前版本中,慢查询日志是开销最低、精度最高的测量查询时间的工具。如果还在担心开启慢查询日志会带来额外的I/O开销,那大可以放心。我们在I/O密集型场景做过基准测试,慢查询日志带来的开销可以忽略不计(实际上在CPU密集型场景的影响还稍微大一些)。更需要担心的是日志可能消耗大量的磁盘空间。如果长期开启慢查询日志,注意要部署日志轮转(log rotation)工具。或者不要长期启用慢查询日志,只在需要收集负载样本的期间开启即可。\nMySQL还有另外一种查询日志,被称之为“通用日志”,但很少用于分析和剖析服务器性能。通用日志在查询请求到服务器时进行记录,所以不包含响应时间和执行计划等重要信息。MySQL 5.1之后支持将日志记录到数据库的表中,但多数情况下这样做没什么必要。这不但对性能有较大影响,而且MySQL 5.1在将慢查询记录到文件中时已经支持微秒级别的信息,然而将慢查询记录到表中会导致时间粒度退化为只能到秒级。而秒级别的慢查询日志没有太大的意义。\nPercona Server的慢查询日志比MySQL官方版本记录了更多细节且有价值的信息,如查询执行计划、锁、I/O活动等。这些特性都是随着处理各种不同的优化场景的需求而慢慢加进来的。另外在可管理性上也进行了增强。比如全局修改针对每个连接的long_query_time的阈值,这样当应用使用连接池或者持久连接的时候,可以不用重置会话级别的变量而启动或者停止连接的查询日志。总的来说,慢查询日志是一种轻量而且功能全面的性能剖析工具,是优化服务器查询的利器。\n有时因为某些原因如权限不足等,无法在服务器上记录查询。这样的限制我们也常常碰到,所以我们开发了两种替代的技术,都集成到了Percona Toolkit中的pt-query-digest中。第一种是通过*\u0026ndash;processlist*选项不断查看SHOW FULL PROCESSLIST的输出,记录查询第一次出现的时间和消失的时间。某些情况下这样的精度也足够发现问题,但却无法捕获所有的查询。一些执行较快的查询可能在两次执行的间隙就执行完成了,从而无法捕获到。\n第二种技术是通过抓取TCP网络包,然后根据MySQL的客户端/服务端通信协议进行解析。可以先通过tcpdump将网络包数据保存到磁盘,然后使用pt-query-digest的*\u0026ndash;type=tcpdump*选项来解析并分析查询。此方法的精度比较高,并且可以捕获所有查询。还可以解析更高级的协议特性,比如可以解析二进制协议,从而创建并执行服务端预解析的语句(prepared statement)及压缩协议。另外还有一种方法,就是通过MySQL Proxy代理层的脚本来记录所有查询,但在实践中我们很少这样做。\n分析查询日志 # 强烈建议大家从现在起就利用慢查询日志捕获服务器上的所有查询,并且进行分析。可以在一些典型的时间窗口如业务高峰期的一个小时内记录查询。如果业务趋势比较均衡,那么一分钟甚至更短的时间内捕获需要优化的低效查询也是可行的。\n不要直接打开整个慢查询日志进行分析,这样做只会浪费时间和金钱。首先应该生成一个剖析报告,如果需要,则可以再查看日志中需要特别关注的部分。自顶向下是比较好的方式,否则有可能像前面提到的,反而导致业务的逆优化。\n从慢查询日志中生成剖析报告需要有一款好工具,这里我们建议使用pt-query-digest,这毫无疑问是分析MySQL查询日志最有力的工具。该工具功能强大,包括可以将查询报告保存到数据库中,以及追踪工作负载随时间的变化。\n一般情况下,只需要将慢查询日志文件作为参数传递给pt-query-digest,就可以正确地工作了。它会将查询的剖析报告打印出来,并且能够选择将“重要”的查询逐条打印出更详细的信息。输出的报告细节详尽,绝对可以让生活更美好。该工具还在持续的开发中,因此要了解最新的功能请阅读最新版本的文档。\n这里给出一份pt-query-digest输出的报告的例子,作为进行性能剖析的开始。这是前面提到过的一个未修改过的剖析报告:\n# Profile # Rank Query ID Response time Calls R/Call V/M Item # ==== ================== ================ ===== ====== ===== ======= # 1 0xBFCF8E3F293F6466 11256.3618 68.1% 78069 0.1442 0.21 SELECT InvitesNew? # 2 0x620B8CAB2B1C76EC 2029.4730 12.3% 14415 0.1408 0.21 SELECT StatusUpdate? # 3 0xB90978440CC11CC7 1345.3445 8.1% 3520 0.3822 0.00 SHOW STATUS # 4 0xCB73D6B5B031B4CF 1341.6432 8.1% 3509 0.3823 0.00 SHOW STATUS # MISC 0xMISC 560.7556 3.4% 23930 0.0234 0.0 \u0026lt;17 ITEMS\u0026gt; 可以看到这个比之前的版本多了一些细节。首先,每个查询都有一个ID,这是对查询语句计算出的哈希值指纹,计算时去掉了查询条件中的文本值和所有空格,并且全部转化为小写字母(请注意第三条和第四条语句的摘要看起来一样,但哈希指纹是不一样的)。该工具对表名也有类似的规范做法。表名InvitesNew后面的问号意味着这是一个分片(shard)的表,表名后面的分片标识被问号替代,这样就可以将同一组分片表作为一个整体做汇总统计。这个例子实际上是来自一个压力很大的分片过的Facebook应用。\n报告中的V/M列提供了方差均值比(variance-to-mean ratio)的详细数据,方差均值比也就是常说的离差指数(index of dispersion)。离差指数高的查询对应的执行时间的变化较大,而这类查询通常都值得去优化。如果pt-query-digest指定了*\u0026ndash;explain*选项,输出结果中会增加一列简要描述查询的执行计划,执行计划是查询背后的“极客代码”。通过联合观察执行计划列和V/M列,可以更容易识别出性能低下需要优化的查询。\n最后,在尾部也增加了一行输出,显示了其他17个占比较低而不值得单独显示的查询的统计数据。可以通过*\u0026ndash;limit和\u0026ndash;outliers*选项指定工具显示更多查询的详细信息,而不是将一些不重要的查询汇总在最后一行。默认只会打印时间消耗前10位的查询,或者执行时间超过1秒阈值很多倍的查询,这两个限制都是可配置的。\n剖析报告的后面包含了每种查询的详细报告。可以通过查询的ID或者排名来匹配前面的剖析统计和查询的详细报告。下面是排名第一也就是“最差”的查询的详细报告:\n查询报告的顶部包含了一些元数据,包括查询执行的频率、平均并发度,以及该查询性能最差的一次执行在日志文件中的字节偏移值,接下来还有一个表格格式的元数据,包括诸如标准差一类的统计信息(9)。\n接下来的部分是响应时间的直方图。有趣的是,可以看到上面这个查询在Query_time distribution部分的直方图上有两个明显的高峰,大部分情况下执行都需要几百毫秒,但在快三个数量级的部分也有一个明显的尖峰,几百微秒就能执行完成。如果这是Percona Server的记录,那么在查询日志中还会有更多丰富的属性,可以对查询进行切片分析到底发生了什么。比如可能是因为查询条件传递了不同的值,而这些值的分布很不均衡,导致服务器选择了不同的索引;或者是由于查询缓存命中等。在实际系统中,这种有两个尖峰的直方图的情况很少见,尤其是对于简单的查询,查询越简单执行计划也越稳定。\n在细节报告的最后部分是方便复制、粘贴到终端去检查表的模式和状态的语句,以及完整的可用于EXPLAIN分析执行计划的语句。EXPLAIN分析的语句要求所有的条件是文本值而不是“指纹”替代符,所以是真正可直接执行的语句。在本例中是执行时间最长的一条实际的查询。\n确定需要优化的查询后,可以利用这个报告迅速地检查查询的执行情况。这个工具我们经常使用,并且会根据使用的情况不断进行修正以帮助提升工具的可用性和效率,强烈建议大家都能熟练使用它。MySQL本身在未来或许也会有更多复杂的测量点和剖析工具,但在本书写作时,通过慢查询日志记录查询或者使用pt-query-digest分析tcpdump的结果,是可以找到的最好的两种方式。\n3.3.2 剖析单条查询 # 在定位到需要优化的单条查询后,可以针对此查询“钻取”更多的信息,确认为什么会花费这么长的时间执行,以及需要如何去优化。关于如何优化查询的技术将在本书后续的一些章节讨论,在此之前还需要介绍一些相关的背景知识。本章的主要目的是介绍如何方便地测量查询执行的各部分花费了多少时间,有了这些数据才能决定采用何种优化技术。\n不幸的是,MySQL目前大多数的测量点对于剖析查询都没有什么帮助。当然这种状况正在改善,但在本书写作之际,大多数生产环境的服务器还没有使用包含最新剖析特性的版本。所以在实际应用中,除了SHOW STATUS、SHOW PROFILE、检查慢查询日志的条目(这还要求必须是Percona Server,官方MySQL版本的慢查询日志缺失了很多附加信息)这三种方法外就没有什么更好的办法了。下面将逐一演示如何使用这三种方法来剖析单条查询,看看每一种方法是如何显示查询的执行情况的。\n使用SHOW PROFILE # SHOW PROFILE命令是在MySQL 5.1以后的版本中引入的,来源于开源社区中的Jeremy Cole的贡献。这是在本书写作之际唯一一个在GA版本中包含的真正的查询剖析工具。默认是禁用的,但可以通过服务器变量在会话(连接)级别动态地修改。\n** mysql\u0026gt; SET profiling = 1;** 然后,在服务器上执行的所有语句,都会测量其耗费的时间和其他一些查询执行状态变更相关的数据。这个功能有一定的作用,而且最初的设计功能更强大,但未来版本中可能会被Performance Schema所取代。尽管如此,这个工具最有用的作用还是在语句执行期间剖析服务器的具体工作。\n当一条查询提交给服务器时,此工具会记录剖析信息到一张临时表,并且给查询赋予一个从1开始的整数标识符。下面是对Sakila样本数据库的一个视图的剖析结果(10):\nmysql\u0026gt; ** SELECT * FROM sakila.nicer_but_slower_film_list;** [query results omitted] 997 rows in set (0.17 sec) 该查询返回了997行记录,花费了大概1/6秒。下面看一下SHOW PROFILES有什么结果:\n首先可以看到的是以很高的精度显示了查询的响应时间,这很好。MySQL客户端显示的时间只有两位小数,对于一些执行得很快的查询这样的精度是不够的。下面继续看接下来的输出:\n剖析报告给出了查询执行的每个步骤及其花费的时间,看结果很难快速地确定哪个步骤花费的时间最多。因为输出是按照执行顺序排序,而不是按花费的时间排序的——而实际上我们更关心的是花费了多少时间,这样才能知道哪些开销比较大。但不幸的是无法通过诸如ORDER BY之类的命令重新排序。假如不使用SHOW PROFILE命令而是直接查询INFORMATION_SCHEMA中对应的表,则可以按照需要格式化输出:\n效果好多了!通过这个结果可以很容易看到查询时间太长主要是因为花了一大半的时间在将数据复制到临时表这一步。那么优化就要考虑如何改写查询以避免使用临时表,或者提升临时表的使用效率。第二个消耗时间最多的是“发送数据(Sending data)”,这个状态代表的原因非常多,可能是各种不同的服务器活动,包括在关联时搜索匹配的行记录等,这部分很难说能优化节省多少消耗的时间。另外也要注意到“结果排序(Sorting result)”花费的时间占比非常低,所以这部分是不值得去优化的。这是一个比较典型的问题,所以一般我们都不建议用户在“优化排序缓冲区(tuning sort buffer)”或者类似的活动上花时间。\n尽管剖析报告能帮助我们定位到哪些活动花费了最多的时间,但并不会告诉我们为什么会这样。要弄清楚为什么复制数据到临时表要花费这么多时间,就需要深入下去,继续剖析这一步的子任务。\n使用SHOW STATUS # MySQL的SHOW STATUS命令返回了一些计数器。既有服务器级别的全局计数器,也有基于某个连接的会话级别的计数器。例如其中的Queries(11)在会话开始时为0,每提交一条查询增加1。如果执行SHOW GLOBAL STATUS(注意到新加的GLOBAL关键字),则可以查看服务器级别的从服务器启动时开始计算的查询次数统计。不同计数器的可见范围不一样,不过全局的计数器也会出现在SHOW STATUS的结果中,容易被误认为是会话级别的,千万不要搞迷糊了。在使用这个命令的时候要注意几点,就像前面所讨论的,收集合适级别的测量值是很关键的。如果打算优化从某些特定连接观察到的东西,测量的却是全局级别的数据,就会导致混乱。MySQL官方手册中对所有的变量是会话级还是全局级做了详细的说明。\nSHOW STATUS是一个有用的工具,但并不是一款剖析工具(12)。SHOW STATUS的大部分结果都只是一个计数器,可以显示某些活动如读索引的频繁程度,但无法给出消耗了多少时间。SHOW STATUS的结果中只有一条指的是操作的时间(Innodb_row_lock_time),而且只能是全局级的,所以还是无法测量会话级别的工作。\n尽管SHOW STATUS无法提供基于时间的统计,但对于在执行完查询后观察某些计数器的值还是有帮助的。有时候可以猜测哪些操作代价较高或者消耗的时间较多。最有用的计数器包括句柄计数器(handler counter)、临时文件和表计数器等。在附录B中会对此做更详细的解释。下面的例子演示了如何将会话级别的计数器重置为0,然后查询前面(“使用SHOW PROFILE”一节)提到的视图,再检查计数器的结果:\n从结果可以看到该查询使用了三个临时表,其中两个是磁盘临时表,并且有很多的没有用到索引的读操作(Handler_read_rnd_next)。假设我们不知道这个视图的具体定义,仅从结果来推测,这个查询有可能是做了多表关联(join)查询,并且没有合适的索引,可能是其中一个子查询创建了临时表,然后和其他表做联合查询。而用于保存子查询结果的临时表没有索引,如此大致可以解释这样的结果。\n使用这个技术的时候,要注意SHOW STATUS本身也会创建一个临时表,而且也会通过句柄操作访问此临时表,这会影响到SHOW STATUS结果中对应的数字,而且不同的版本可能行为也不尽相同。比较前面通过SHOW PROFILES获得的查询的执行计划的结果来看,至少临时表的计数器多加了2。\n你可能会注意到通过EXPLAIN查看查询的执行计划也可以获得大部分相同的信息,但EXPLAIN是通过估计得到的结果,而通过计数器则是实际的测量结果。例如,EXPLAIN无法告诉你临时表是否是磁盘表,这和内存临时表的性能差别是很大的。附录D包含更多关于EXPLAIN的内容。\n使用慢查询日志 # 那么针对上面这样的查询语句,Percona Server对慢查询日志做了哪些改进?下面是“使用SHOW PROFILE”一节演示过的相同的查询执行后抓取到的结果:\n# Time: 110905 17:03:18 # User@Host: root[root] @ localhost [127.0.0.1] # Thread_id: 7 Schema: sakila Last_errno: 0 Killed: 0 # Query_time: 0.166872 Lock_time: 0.000552 Rows_sent: 997 Rows_examined: 24861 Rows_affected: 0 Rows_read: 997 # Bytes_sent: 216528 Tmp_tables: 3 Tmp_disk_tables: 2 Tmp_table_sizes: 11627188 # InnoDB_trx_id: 191E # QC_Hit: No Full_scan: Yes Full_join: No Tmp_table: Yes Tmp_table_on_disk: Yes # Filesort: Yes Filesort_on_disk: No Merge_passes: 0 # InnoDB_IO_r_ops: 0 InnoDB_IO_r_bytes: 0 InnoDB_IO_r_wait: 0.000000 # InnoDB_rec_lock_wait: 0.000000 InnoDB_queue_wait: 0.000000 # InnoDB_pages_distinct: 20 # PROFILE_VALUES ... Copying to tmp table: 0.090623... [omitted] SET timestamp=1315256598; SELECT * FROM sakila.nicer_but_slower_film_list; 从这里看到查询确实一共创建了三个临时表,其中两个是磁盘临时表。而SHOW PROFILE看起来则隐藏了信息(可能是由于服务器执行查询的方式有不一样的地方造成的)。这里为了方便阅读,对结果做了简化。但最后对该查询执行SHOW PROFILE的数据也会写入到日志中,所以在Percona Server 中甚至可以记录SHOW PROFILE的细节信息。\n另外也可以看到,慢查询日志中详细记录的条目包含了SHOW PROFILE和SHOW STATUS所有的输出,并且还有更多的信息。所以通过pt-query-digest发现“坏”查询后,在慢查询日志中可以获得足够有用的信息。查看pt-query-digest的报告时,其标题部分一般会有如下输出:\n# Query 1:0 QPS, 0x concurrency, ID 0xEE758C5E0D7EADEE at byte 3214_____ 可以通过这里的字节偏移值(3214)直接跳转到日志的对应部分,例如用下面这样的命令即可:\ntail -c +3214 /path/to/query.log | head -n100 这样就可以直接跳转到细节部分了。另外,pt-query-digest能够处理Percona Server在慢查询日志中增加的所有键值对,并且会自动在报告中打印更多的细节信息。\n使用Performance Schema # Using the Performance Schema\n在本书写作之际,在MySQL 5.5中新增的Performance Schema表还不支持查询级别的剖析信息。Performance Schema还是非常新的特性,并且还在快速开发中,未来的版本中将会包含更多的功能。尽管如此,MySQL 5.5的初始版本已经包含了很多有趣的信息。例如,下面的查询显示了系统中等待的主要原因:\n目前还有一些限制,使得Performance Schema还无法被当作一个通用的剖析工具。首先,它还无法提供查询执行阶段的细节信息和计时信息,而前面提供的很多现有的工具都已经能做到这些了。其次,还没有经过长时间、大规模使用的验证,并且自身的开销也还比较大,多数比较保守的用户还对此持有疑问(不过有理由相信这些问题很快都会被修复的)。\n最后,对大多数用户来说,直接通过Performance Schema的裸数据获得有用的结果相对来说过于复杂和底层。到目前为止实现的这个特性,主要是为了测量当为提升服务器性能而修改MySQL源代码时使用,包括等待和互斥锁。MySQL 5.5中的特性对于高级用户也很有价值,而不仅仅为开发者使用,但还是需要开发一些前端工具以方便用户使用和分析结果。目前就只能通过写一些复杂的语句去查询大量的元数据表的各种列。这在使用过程中需要花很多时间去熟悉和理解。\n在MySQL 5.6或者以后的版本中,Performance Schema将会包含更多的功能,再加上一些方便使用的工具,这样就更“爽”了。而且Oracle将其实现成表的形式,可以通过SQL访问,这样用户可以方便地访问有用的数据。但其目前还无法立即取代慢查询日志等其他工具用于服务器和查询的性能优化。\n3.3.3 使用性能剖析 # 当获得服务器或者查询的剖析报告后,怎么使用?好的剖析报告能够将潜在的问题显示出来,但最终的解决方案还需要用户来决定(尽管报告可能会给出建议)。优化查询时,用户需要对服务器如何执行查询有较深的了解。剖析报告能够尽可能多地收集需要的信息、给出诊断问题的正确方向,以及为其他诸如EXPLAIN等工具提供基础信息。这里只是先引出话题,后续章节将继续讨论。\n尽管一个拥有完整测量信息的剖析报告可以让事情变得简单,但现有系统通常都没有完美的测量支持。从前面的例子来说,我们虽然推断出是临时表和没有索引的读导致查询的响应时间过长,但却没有明确的证据。因为无法测量所有需要的信息,或者测量的范围不正确,有些问题就很难解决。例如,可能没有集中在需要优化的地方测量,而是测量了服务器层面的活动;或者测量的是查询开始之前的计数器,而不是查询开始后的数据。\n也有其他的可能性。设想一下正在分析慢查询日志,发现了一个很简单的查询正常情况下都非常快,却有几次非常不合理地执行了很长时间。手工重新执行一遍,发现也非常快,然后使用EXPLAIN查询其执行计划,也正确地使用了索引。然后尝试修改WHERE条件中使用不同的值,以排除缓存命中的可能,也没有发现有什么问题,这可能是什么原因呢?\n如果使用官方版本的MySQL,慢查询日志中没有执行计划或者详细的时间信息,对于偶尔记录到的这几次查询异常慢的问题,很难知道其原因在哪里,因为信息有限。可能是系统中有其他东西消耗了资源,比如正在备份,也可能是某种类型的锁或者争用阻塞了查询的进度。这种间歇性的问题将在下一节详细讨论。\n3.4 诊断间歇性问题 # 间歇性的问题比如系统偶尔停顿或者慢查询,很难诊断。有些幻影问题只在没有注意到的时候才发生,而且无法确认如何重现,诊断这样的问题往往要花费很多时间,有时候甚至需要好几个月。在这个过程中,有些人会尝试以不断试错的方式来诊断,有时候甚至会想要通过随机地改变一些服务器的设置来侥幸地找到问题。\n尽量不要使用试错的方式来解决问题。这种方式有很大的风险,因为结果可能变得更坏。这也是一种令人沮丧且低效的方式。如果一时无法定位问题,可能是测量的方式不正确,或者测量的点选择有误,或者使用的工具不合适(也可能是缺少现成的工具,我们已经开发过工具来解决各个系统不透明导致的问题,包括从操作系统到MySQL都有)。\n为了演示为什么要尽量避免试错的诊断方式,下面列举了我们认为已经解决的一些间歇性数据库性能问题的实际案例:\n应用通过curl从一个运行得很慢的外部服务来获取汇率报价的数据。 memcached缓存中的一些重要条目过期,导致大量请求落到MySQL以重新生成缓存条目。 DNS查询偶尔会有超时现象。 可能是由于互斥锁争用,或者内部删除查询缓存的算法效率太低的缘故,MySQL的查询缓存有时候会导致服务有短暂的停顿。 当并发度超过某个阈值时,InnoDB的扩展性限制导致查询计划的优化需要很长的时间。 从上面可以看到,有些问题确实是数据库的原因,也有些不是。只有在问题发生的地方通过观察资源的使用情况,并尽可能地测量出数据,才能避免在没有问题的地方耗费精力。\n下面不再多费口舌说明试错的问题,而是给出我们解决间歇性问题的方法和工具,这才是“王道”。\n3.4.1 单条查询问题还是服务器问题 # 发现问题的蛛丝马迹了吗?如果有,则首先要确认这是单条查询的问题,还是服务器的问题。这将为解决问题指出正确的方向。如果服务器上所有的程序都突然变慢,又突然都变好,每一条查询也都变慢了,那么慢查询可能就不一定是原因,而是由于其他问题导致的结果。反过来说,如果服务器整体运行没有问题,只有某条查询偶尔变慢,就需要将注意力放到这条特定的查询上面。\n服务器的问题非常常见。在过去几年,硬件的能力越来越强,配置16核或者更多CPU的服务器成了标配,MySQL在SMP架构的机器上的可扩展性限制也就越来越显露出来。尤其是较老的版本,其问题更加严重,而目前生产环境中的老版本还非常多。新版本MySQL依然也还有一些扩展性限制,但相比老版本已经没有那么严重,而且出现的频率相对小很多,只是偶尔能碰到。这是好消息,也是坏消息:好消息是很少会碰到这个问题;坏消息则是一旦碰到,则需要对MySQL内部机制更加了解才能诊断出来。当然,这也意味着很多问题可以通过升级到MySQL新版本来解决(13)。\n那么如何判断是单条查询问题还是服务器问题呢?如果问题不停地周期性出现,那么可以在某次活动中观察到;或者整夜运行脚本收集数据,第二天来分析结果。大多数情况下都可以通过三种技术来解决,下面将一一道来。\n使用SHOW GLOBAL STATUS # 这个方法实际上就是以较高的频率比如一秒执行一次SHOW GLOBAL STATUS命令捕获数据,问题出现时,则可以通过某些计数器(比如Threads_running、Threads_connected、Questions和Queries)的“尖刺”或者“凹陷”来发现。这个方法比较简单,所有人都可以使用(不需要特殊的权限),对服务器的影响也很小,所以是一个花费时间不多却能很好地了解问题的好方法。下面是示例命令及其输出:\n$ mysqladmin ext -i1 | awk ' /Queries/{q=$4-qp;qp=$4} /Threads_connected/{tc=$4} /Threads_running/{printf \u0026quot;%5d %5d %5d\\n\u0026quot;, q, tc, $4}' 2147483647 136 7 798 136 7 767 134 9 828 134 7 683 134 7 784 135 7 614 134 7 108 134 24 187 134 31 179 134 28 1179 134 7 1151 134 7 1240 135 7 1000 135 7 这个命令每秒捕获一次SHOW GLOBAL STATUS的数据,输出给awk计算并输出每秒的查询数、Threads_connected和Threads_running(表示当前正在执行查询的线程数)。这三个数据的趋势对于服务器级别偶尔停顿的敏感性很高。一般发生此类问题时,根据原因的不同和应用连接数据库方式的不同,每秒的查询数一般会下跌,而其他两个则至少有一个会出现尖刺。在这个例子中,应用使用了连接池,所以Threads_connected没有变化。但正在执行查询的线程数明显上升,同时每秒的查询数相比正常数据有严重的下跌。\n如何解析这个现象呢?凭猜测有一定的风险。但在实践中有两个原因的可能性比较大。其中之一是服务器内部碰到了某种瓶颈,导致新查询在开始执行前因为需要获取老查询正在等待的锁而造成堆积。这一类的锁一般也会对应用服务器造成后端压力,使得应用服务器也出现排队问题。另外一个常见的原因是服务区突然遇到了大量查询请求的冲击,比如前端的memcached突然失效导致的查询风暴。\n这个命令每秒输出一行数据,可以运行几个小时或者几天,然后将结果绘制成图形,这样就可以方便地发现是否有趋势的突变。如果问题确实是间歇性的,发生的频率又较低,也可以根据需要尽可能长时间地运行此命令,直到发现问题再回头来看输出结果。大多数情况下,通过输出结果都可以更明确地定位问题。\n使用SHOW PROCESSLIST # 这个方法是通过不停地捕获SHOW PROCESSLIST的输出,来观察是否有大量线程处于不正常的状态或者有其他不正常的特征。例如查询很少会长时间处于“statistics”状态,这个状态一般是指服务器在查询优化阶段如何确定表关联的顺序——通常都是非常快的。另外,也很少会见到大量线程报告当前连接用户是“未经验证的用户(Unauthenticated\nuser)”,这只是在连接握手的中间过程中的状态,当客户端等待输入用于登录的用户信息的时候才会出现。\n使用SHOW PROCESSLIST命令时,在尾部加上\\G可以垂直的方式输出结果,这很有用,因为这样会将每一行记录的每一列都单独输出为一行,这样可以方便地使用sort|uniq|sort一类的命令来计算某个列值出现的次数:\n** $ mysql -e 'SHOW PROCESSLIST\\G' | grep State: | sort | uniq -c | sort -rn** 744 State: 67 State: Sending data 36 State: freeing items 8 State: NULL 6 State: end 4 State: Updating 4 State: cleaning up 2 State: update 1 State: Sorting result 1 State: logging slow query 如果要查看不同的列,只需要修改grep的模式即可。在大多数案例中,State列都非常有用。从这个例子的输出中可以看到,有很多线程处于查询执行的结束部分的状态,包括“freeing items”、“end”、“cleaning up”和“logging slow query”。事实上,在案例中的这台服务器上,同样模式或类似的输出采样出现了很多次。大量的线程处于“freeing items”状态是出现了大量有问题查询的很明显的特征和指示。\n用这种技术查找问题,上面的命令行不是唯一的方法。如果MySQL服务器的版本较新,也可以直接查询INFORMATION_SCHEMA中的PROCESSLIST表;或者使用innotop工具以较高的频率刷新,以观察屏幕上出现的不正常查询堆积。上面演示的这个例子是由于InnoDB内部的争用和脏块刷新所导致,但有时候原因可能比这个要简单得多。一个经典的例子是很多查询处于“Locked”状态,这是MyISAM的一个典型问题,它的表级别锁定,在写请求较多时,可能迅速导致服务器级别的线程堆积。\n使用查询日志 # 如果要通过查询日志发现问题,需要开启慢查询日志并在全局级别设置long_query_time为0,并且要确认所有的连接都采用了新的设置。这可能需要重置所有连接以使新的全局设置生效;或者使用Percona Server的一个特性,可以在不断开现有连接的情况下动态地使设置强制生效。\n如果因为某些原因,不能设置慢查询日志记录所有的查询,也可以通过tcpdump和pt-query-digest工具来模拟替代。要注意找到吞吐量突然下降时间段的日志。查询是在完成阶段才写入到慢查询日志的,所以堆积会造成大量查询处于完成阶段,直到阻塞其他查询的资源占用者释放资源后,其他的查询才能执行完成。这种行为特征的一个好处是,当遇到吞吐量突然下降时,可以归咎于吞吐量下降后完成的第一个查询(有时候也不一定是第一个查询。当某些查询被阻塞时,其他查询可以不受影响继续运行,所以不能完全依赖这个经验)。\n再重申一次,好的工具可以帮助诊断这类问题,否则要人工去几百GB的查询日志中找原因。下面的例子只有一行代码,却可以根据MySQL每秒将当前时间写入日志中的模式统计每秒的查询数量:\n** $ awk '/^# Time:/{print$3,$4,c;c=0}/^# User/{c++}' slow-query.log** 080913 21:52:17 51 080913 21:52:18 29 080913 21:52:19 34 080913 21:52:20 33 080913 21:52:21 38 080913 21:52:22 15 080913 21:52:23 47 080913 21:52:24 96 080913 21:52:25 6 080913 21:52:26 66 080913 21:52:27 37 080913 21:52:28 59 从上面的输出可以看到有吞吐量突然下降的情况发生,而且在下降之前还有一个突然的高峰,仅从这个输出而不去查询当时的详细信息很难确定发生了什么,但应该可以说这个突然的高峰和随后的下降一定有关联。不管怎么说,这种现象都很奇怪,值得去日志中挖掘该时间段的详细信息(实际上通过日志的详细信息,可以发现突然的高峰时段有很多连接被断开的现象,可能是有一台应用服务器重启导致的。所以不是所有的问题都是MySQL的问题)。\n理解发现的问题(Making sense of the findings) # 可视化的数据最具有说服力。上面只演示了很少的几个例子,但在实际情况中,利用上面的工具诊断时可能产生大量的输出结果。可以选择用gnuplot或R,或者其他绘图工具将结果绘制成图形。这些绘图工具速度很快,比电子表格要快得多,而且可以对图上的一些异常的地方进行缩放,这比在终端中通过滚动条翻看文字要好用得多,除非你是“黑客帝国”中的矩阵观察者(14)。\n我们建议诊断问题时先使用前两种方法:SHOW STATUS和SHOW PROCESSLIST。这两种方法的开销很低,而且可以通过简单的shell脚本或者反复执行的查询来交互式地收集数据。分析慢查询日志则相对要困难一些,经常会发现一些蛛丝马迹,但仔细去研究时可能又消失了。这样我们很容易会认为其实没有问题。\n发现输出的图形异常意味着什么?通常来说可能是查询在某个地方排队了,或者某种查询的量突然飙升了。接下来的任务就是找出这些原因。\n3.4.2 捕获诊断数据 # Capturing Diagnostic Data\n当出现间歇性问题时,需要尽可能多地收集所有数据,而不只是问题出现时的数据。虽然这样会收集大量的诊断数据,但总比真正能够诊断问题的数据没有被收集到的情况要好。\n在开始之前,需要搞清楚两件事:\n一个可靠且实时的“触发器”,也就是能区分什么时候问题出现的方法。 一个收集诊断数据的工具。 诊断触发器 # 触发器非常重要。这是在问题出现时能够捕获数据的基础。有两个常见的问题可能导致无法达到预期的结果:误报(false positive)或者漏检(false negative)。误报是指收集了很多诊断数据,但期间其实没有发生问题,这可能浪费时间,而且令人沮丧。而漏检则指在问题出现时没有捕获到数据,错失了机会,一样地浪费时间。所以在开始收集数据前多花一点时间来确认触发器能够真正地识别问题是划算的。\n那么好的触发器的标准是什么呢?像前面的例子展示的,Threads_running的趋势在出现问题时会比较敏感,而没有问题时则比较平稳。另外SHOW PROCESSLIST中线程的异常状态尖峰也是个不错的指标。当然除此之外还有很多的方法,包括SHOW INNODB STATUS的特定输出、服务器的平均负载尖峰等。关键是找到一些能和正常时的阈值进行比较的指标。通常情况下这是一个计数,比如正在运行的线程的数量、处于“freeing items”状态的线程的数量等。当要计算线程某个状态的数量时,grep的*-c*选项非常有用:\n** $ mysql -e 'SHOW PROCESSLIST\\G' | grep -c \u0026quot;State: freeing items\u0026quot;** 36 选择一个合适的阈值很重要,既要足够高,以确保在正常时不会被触发;又不能太高,要确保问题发生时不会错过。另外要注意,要在问题开始时就捕获数据,就更不能将阈值设置得太高。问题持续上升的趋势一般会导致更多的问题发生,如果在问题导致系统快要崩溃时才开始捕获数据,就很难诊断到最初的根本原因。如果可能,在问题还是涓涓细流的时候就要开始收集数据,而不要等到波涛汹涌才开始。举个例子,Threads_connected偶尔出现非常高的尖峰值,在几分钟时间内会从100冲到5000或者更高,所以设置阈值为4999也可以捕获到问题,但为什么非要等到这么高的时候才收集数据呢?如果在正常时该值一般不超过150,将阈值设置为200或者300会更好。\n回到前面关于Threads_running的例子,正常情况下的并发度不超过10。但是阈值设置为10并不是一个好注意,很可能会导致很多误报。即使设置为15也不够,可能还是会有很多正常的波动会到这个范围。当并发运行线程到15的时候可能也会有少量堆积的情况,但可能还没到问题的引爆点。但也应该在糟糕到一眼就能看出问题前就清晰地识别出来,对于这个例子,我们建议阀值可以设置为20。\n我们当然希望在问题确实发生时能捕获到数据,但有时候也需要稍微等待一下以确保不是误报或者短暂的尖峰。所以,最后的触发条件可以这样设置:每秒监控状态值,如果Threads_running连续5秒超过20,就开始收集诊断数据(顺便说一句,我们的例子中问题只持续了3秒就消失了,这是为了使例子简单而设置的。3秒的故障不容易诊断,而我们碰到过的大部分问题持续时间都会更长一些)。\n所以我们需要利用一种工具来监控服务器,当达到触发条件时能收集数据。当然可以自己编写脚本来实现,不过不用那么麻烦,Percona Toolkit中的pt-stalk就是为这种情况设计的。这个工具有很多有用的特性,只要碰到过类似问题就会明白这些特性的必要性。例如,它会监控磁盘的可用空间,所以不会因为收集太多的数据将空间耗尽而导致服务器崩溃。如果之前碰到过这样的情况,你就会理解这一点了。\npt-stalk的用法很简单。可以配置需要监控的变量、阈值、检查的频率等。还支持一些比实际需要更多的花哨特性,但在这个例子中有这些已经足够了。在使用之前建议先阅读附带的文档。pt-stalk还依赖于另外一个工具执行真正的收集工作,接下来会讨论。\n需要收集什么样的数据 # 现在已经确定了诊断触发器,可以开始启动一些进程来收集数据了。但需要收集什么样的数据呢?就像前面说的,答案是尽可能收集所有能收集的数据,但只在需要的时间段内收集。包括系统的状态、CPU利用率、磁盘使用率和可用空间、ps的输出采样、内存利用率,以及可以从MySQL获得的信息,如SHOW STATUS、SHOW PROCESSLIST和SHOW INNODB STATUS。这些在诊断问题时都需要用到(可能还会有更多)。\n执行时间包括用于工作的时间和等待的时间。当一个未知问题发生时,一般来说有两种可能:服务器需要做大量的工作,从而导致大量消耗CPU;或者在等待某些资源被释放。所以需要用不同的方法收集诊断数据,来确认是何种原因:剖析报告用于确认是否有太多工作,而等待分析则用于确认是否存在大量等待。如果是未知的问题,怎么知道将精力集中在哪个方面呢?没有更好的办法,所以只能两种数据都尽量收集。\n在GNU/Linux平台,可用于服务器内部诊断的一个重要工具是oprofile。后面会展示一些例子。也可以使用strace剖析服务器的系统调用,但在生产环境中使用它有一定的风险。后面还会继续讨论它。如果要剖析查询,可以使用tcpdump。大多数MySQL版本无法方便地打开和关闭慢查询日志,此时可以通过监听TCP流量来模拟。另外,网络流量在其他一些分析中也非常有用。\n对于等待分析,常用的方法是GDB的堆栈跟踪(15)。MySQL内的线程如果卡在一个特定的地方很长时间,往往都有相同的堆栈跟踪信息。跟踪的过程是先启动gdb,然后附加(attach)到mysqld进程,将所有线程的堆栈都转储出来。然后可以利用一些简短的脚本将类似的堆栈跟踪信息做汇总,再利用sort|uniq|sort的“魔法”排序出总计最多的堆栈信息。稍后将演示如何用pt-pmp工具来完成这个工作。\n也可以使用SHOW PROCESSLIST和SHOW INNODB STATUS的快照信息观察线程和事务的状态来进行等待分析。这些方法都不完美,但实践证明还是非常有帮助的。\n收集所有的数据听起来工作量很大。或许读者之前已经做过类似的事情,但我们提供的工具可以提供一些帮助。这个工具名为pt-collect,也是Percona Toolkit中的一员。pt-collect一般通过pt-stalk来调用。因为涉及很多重要数据的收集,所以需要用root权限来运行。默认情况下,启动后会收集30秒的数据,然后退出。对于大多数问题的诊断来说,这已经足够,但如果有误报(false positive)的问题出现,则可能收集的信息就不够。这个工具很容易下载到,并且不需要任何配置,配置都是通过pt-stalk进行的。系统中最好安装gdb和oprofile,然后在pt-stalk中配置使用。另外mysqld也需要有调试符号信息(16)。当触发条件满足时,pt-collect会很好地收集完整的数据。它也会在目录中创建时间戳文件。在本书写作之际,这个工具是基于GNU/Linux的,后续会迁移到其他操作系统,这是一个好的开始。\n解释结果数据 # 如果已经正确地设置好触发条件,并且长时间运行pt-stalk,则只需要等待足够长的时间来捕获几次问题,就能够得到大量的数据来进行筛选。从哪里开始最好呢?我们建议先根据两个目的来查看一些东西。第一,检查问题是否真的发生了,因为有很多的样本数据需要检查,如果是误报就会白白浪费大量的时间。第二,是否有非常明显的跳跃性变化。\n在服务器正常运行时捕获一些样本数据也很重要,而不只是在有问题时捕获数据。这样可以帮助对比确认是否某些样本,或者样本中的某部分数据有异常。例如,在查看进程列表(process list)中查询的状态时,可以回答一些诸如“大量查询处于正在排序结果的状态是不是正常的”的问题。\n查看异常的查询或事务的行为,以及异常的服务器内部行为通常都是最有收获的。查询或事务的行为可以显示是否是由于使用服务器的方式导致的问题:性能低下的SQL查询、使用不当的索引、设计糟糕的数据库逻辑架构等。通过抓取TCP流量或者SHOW PROCESSLIST输出,可以获得查询和事务出现的地方,从而知道用户对数据库进行了什么操作。通过服务器的内部行为则可以清楚服务器是否有bug,或者内部的性能和扩展性是否有问题。这些信息在类似的地方都可以看到,包括在oprofile或者gdb的输出中,但要理解则需要更多的经验。\n如果遇到无法解释的错误,则最好将收集到的所有数据打包,提交给技术支持人员进行分析。MySQL的技术支持专家应该能够从数据中分析出原因,详细的数据对于支持人员来说非常重要。另外也可以将Percona Toolkit中另外两款工具pt-mysql-summary和pt-summary的输出结果打包,这两个工具会输出MySQL的状态和配置信息,以及操作系统和硬件的信息。\nPercona Toolkit还提供了一款快速检查收集到的样本数据的工具:pt-sift。这个工具会轮流导航到所有的样本数据,得到每个样本的汇总信息。如果需要,也可以钻取到详细信息。使用此工具至少可以少打很多字,少敲很多次键盘。\n前面我们演示了状态计数器和线程状态的例子。在本章结束之前,将再给出一些oprofile和gdb的输出例子。下面是一个问题服务器上的oprofile输出,你能找到问题吗?\n** samples % image name app name symbol name** 893793 31.1273 /no-vmlinux /no-vmlinux (no symbols) 325733 11.3440 mysqld mysqld Query_cache::free_memory_block() 117732 4.1001 libc libc (no symbols) 102349 3.5644 mysqld mysqld my_hash_sort_bin 76977 2.6808 mysqld mysqld MYSQLparse() 71599 2.4935 libpthread libpthread pthread_mutex_trylock 52203 1.8180 mysqld mysqld read_view_open_now 46516 1.6200 mysqld mysqld Query_cache::invalidate_query_block_list() 42153 1.4680 mysqld mysqld Query_cache::write_result_data() 37359 1.3011 mysqld mysqld MYSQLlex() 35917 1.2508 libpthread libpthread __pthread_mutex_unlock_usercnt 34248 1.1927 mysqld mysqld __intel_new_memcpy 如果你的答案是“查询缓存”,那么恭喜你答对了。在这里查询缓存导致了大量的工作,并拖慢了整个服务器。这个问题是一夜之间突然发生的,系统变慢了50倍,但这期间系统没有做过任何其他变更。关闭查询缓存后系统性能恢复了正常。这个例子比较简单地解释了服务器内部行为对性能的影响。\n另外一个重要的关于等待分析的性能瓶颈分析工具是gdb的堆栈跟踪。下面是对一个线程的堆栈跟踪的输出结果,为了便于印刷做了一些格式化:\nThread 992 (Thread 0x7f6ee0111910 (LWP 31510)): #0 0x0000003be560b2f9 in pthread_cond_wait@@GLIBC_2.3.2 () from /libpthread.so.0 #1 0x00007f6ee14f0965 in os_event_wait_low () at os/os0sync.c:396 #2 0x00007f6ee1531507 in srv_conc_enter_innodb () at srv/srv0srv.c:1185 #3 0x00007f6ee14c906a in innodb_srv_conc_enter_innodb () at handler/ha_innodb.cc:609 #4 ha_innodb::index_read () at handler/ha_innodb.cc:5057 #5 0x00000000006538c5 in ?? () #6 0x0000000000658029 in sub_select() () #7 0x0000000000658e25 in ?? () #8 0x00000000006677c0 in JOIN::exec() () #9 0x000000000066944a in mysql_select() () #10 0x0000000000669ea4 in handle_select() () #11 0x00000000005ff89a in ?? () #12 0x0000000000601c5e in mysql_execute_command() () #13 0x000000000060701c in mysql_parse() () #14 0x000000000060829a in dispatch_command() () #15 0x0000000000608b8a in do_command(THD*) () #16 0x00000000005fbd1d in handle_one_connection () #17 0x0000003be560686a in start_thread () from /lib64/libpthread.so.0 #18 0x0000003be4ede3bd in clone () from /lib64/libc.so.6 #19 0x0000000000000000 in ?? () 堆栈需要自下而上来看。也就是说,线程当前正在执行的是pthread_cond_wait函数,这是由os_event_wait_low调用的。继续往下,看起来是线程试图进入到InnoDB内核(srv_conc_enter_innodb),但被放入了一个内部队列中(os_event_wait_low),原因应该是内核中的线程数已经超过innodb_thread_concurrency的限制。当然,要真正地发挥堆栈跟踪的价值需要将很多的信息聚合在一起来看。这种技术是由Domas Mituzas推广的,他以前是MySQL的支持工程师,开发了著名的穷人剖析器“poor man\u0026rsquo;s profiler”。他目前在Facebook工作,和其他人一起开发了更多的收集和分析堆栈跟踪的工具。可以从他的这个网站发现更多的信息: http://www.poormansprofiler.org。\n在Percona Toolkit中我们也开发了一个类似的穷人剖析器,叫做pt-pmp。这是一个用shell和awk脚本编写的工具,可以将类似的堆栈跟踪输出合并到一起,然后通过sort|uniq|sort将最常见的条目在最前面输出。下面是一个堆栈跟踪的完整例子,通过此工具将重要的信息展示了出来。使用了-l5选项指定了堆栈跟踪不超过5层,以免因太多前面部分相同而后面部分不同的跟踪信息而导致无法聚合到一起的情况,这样才能更好地显示到底在哪里产生了等待:\n** $ pt-pmp -l 5 stacktraces.txt** 507 pthread_cond_wait,one_thread_per_connection_end,handle_one_connection, start_thread,clone 398 pthread_cond_wait,os_event_wait_low,srv_conc_enter_innodb, innodb_srv_conc_enter_innodb,ha_innodb::index_read 83 pthread_cond_wait,os_event_wait_low,sync_array_wait_event,mutex_spin_wait, mutex_enter_func 10 pthread_cond_wait,os_event_wait_low,os_aio_simulated_handle,fil_aio_wait, io_handler_thread 7 pthread_cond_wait,os_event_wait_low,srv_conc_enter_innodb, innodb_srv_conc_enter_innodb,ha_innodb::general_fetch 5 pthread_cond_wait,os_event_wait_low,sync_array_wait_event,rw_lock_s_lock_spin, rw_lock_s_lock_func 1 sigwait,signal_hand,start_thread,clone,?? 1 select,os_thread_sleep,srv_lock_timeout_and_monitor_thread,start_thread,clone 1 select,os_thread_sleep,srv_error_monitor_thread,start_thread,clone 1 select,handle_connections_sockets,main 1 read,vio_read_buff,::??,my_net_read,cli_safe_read 1 pthread_cond_wait,os_event_wait_low,sync_array_wait_event,rw_lock_x_lock_low, rw_lock_x_lock_func 1 pthread_cond_wait,MYSQL_BIN_LOG::wait_for_update,mysql_binlog_send, dispatch_command,do_command 1 fsync,os_file_fsync,os_file_flush,fil_flush,log_write_up_to 第一行是MySQL中非常典型的空闲线程的一种特征,所以可以忽略。第二行才是最有意思的地方,看起来大量的线程正在准备进入到InnoDB内核中,但都被阻塞了。从第三行则可以看到许多线程都在等待某些互斥锁,但具体的是什么锁不清楚,因为堆栈跟踪更深的层次被截断了。如果需要确切地知道是什么互斥锁,则需要使用更大的-l选项重跑一次。一般来说,这个堆栈跟踪显示很多线程都在等待进入到InnoDB,这是为什么呢?这个工具并不清楚,需要从其他的地方来入手。\n从前面的堆栈跟踪和oprofile报表来看,如果不是MySQL和InnoDB源码方面的专家,这种类型的分析很难进行。如果用户在进行此类分析时碰到问题,通常需要求助于这样的专家才行。\n在下面的例子中,通过剖析和等待分析都无法发现服务器的问题,需要使用另外一种不同的诊断技术。\n3.4.3 一个诊断案例 # 在本节中,我们将逐步演示一个客户实际碰到的间歇性性能问题的诊断过程。这个案例的诊断需要具备MySQL、InnoDB和GNU/Linux的相关知识。但这不是我们要讨论的重点。要尝试从疯狂中找到条理:阅读本节并保持对之前的假设和猜测的关注,保持对之前基于合理性和基于可度量的方式的关注,等等。我们在这里深入研究一个具体和详细的案例,为的是找到一个简单的一般性的方法。\n在尝试解决其他人提出的问题之前,先要明确两件事情,并且最好能够记录下来,以免遗漏或者遗忘:\n首先,问题是什么?一定要清晰地描述出来,费力去解决一个错误的问题是常有的事。在这个案例中,用户抱怨说每隔一两天,服务器就会拒绝连接,报max_connections错误。这种情况一般会持续几秒到几分钟,发生的时间非常随机。 其次,为解决问题已经做过什么操作?在这个案例中,用户没有为这个问题做过任何操作。这个信息非常有帮助,因为很少有其他事情会像另外一个人来描述一件事情发生的确切顺序和曾做过的改变及其后果一样难以理解(尤其是他们还是在经过几个不眠之夜后满嘴咖啡味道地在电话里绝望呐喊的时候)。如果一台服务器遭受过未知的变更,产生了未知的结果,问题就更难解决了,尤其是时间又非常有限的时候。 搞清楚这两个问题后,就可以开始了。不仅需要去了解服务器的行为,也需要花点时间去梳理一下服务器的状态、参数配置,以及软硬件环境。使用pt-summary和pt-mysql-summary工具可以获得这些信息。简单地说,这个例子中的服务器有16个CPU核心,12GB内存,数据量有900MB,且全部采用InnoDB引擎,存储在一块SSD固态硬盘上。服务器的操作系统是GNU/Linux、MySQL版本5.1.37,使用的存储引擎版本是InnoDB plugin 1.0.4。之前我们已经为这个客户解决过一些异常问题,所以对其系统已经比较了解。过去数据库从来没有出过问题,大多数问题都是由于应用程序的不良行为导致的。初步检查了服务器也没有发现明显的问题。查询有一些优化的空间,但大多数情况下响应时间都不到10毫秒。所以我们认为正常情况下数据库服务器运行良好(这一点比较重要,因为很多问题一开始只是零星地出现,慢慢地累积成大问题。比如RAID阵列中坏了一块硬盘这种情况)。\n这个案例研究可能有点乏味。这里我们不厌其烦地展示所有的诊断数据,解释所有的细节,对几个不同的可能性深入进去追查原因。在实际工作中,其实不会对每个问题都采用这样缓慢而冗长的方式,也不推荐大家这样做。这里只是为了更好地演示案例而已。\n我们安装好诊断工具,在Threads_connected上设置触发条件,正常情况下Threads_connected的值一般都少于15,但在发生问题时该值可能飙升到几百。下面我们会先给出一个样本数据的收集结果,后续再来评论。首先试试看,你能否从大量的输出中找出问题的重点在哪里:\n查询活动从1000到10000的QPS,其中有很多是“垃圾”命令,比如ping一下服务器确认其是否存活。其余的大部分是SELECT命令,大约每秒300~2000次,只有很少的UPDATE命令(大约每秒五次)。\n在SHOW PROCESSLIST中主要有两种类型的查询,只是在WHERE条件中的值不一样。下面是查询状态的汇总数据:\n** $ grep State: processlist.txt | sort | uniq -c | sort -rn** 161 State: Copying to tmp table 156 State: Sorting result 136 State: statistics 50 State: Sending data 24 State: NULL 13 State: 7 State: freeing items 7 State: cleaning up 1 State: storing result in query cache 1 State: end 大部分查询都是索引扫描或者范围扫描,很少有全表扫描或者表关联的情况。\n每秒大约有20~100次排序,需要排序的行大约有1000到12000行。\n每秒大约创建12~90个临时表,其中有3~5个是磁盘临时表。\n没有表锁或者查询缓存的问题。\n在SHOW INNODB STATUS中可以观察到主要的线程状态是“flushing buffer pool pages”,但只有很少的脏页需要刷新(Innodb_buffer_pool_pages_dirty),Innodb_buffer_pool_pages_flushed也没有太大的变化,日志顺序号(log sequence number)和最后检查点(last checkpoint)之间的差距也很少。InnoDB缓存池也还远没有用满;缓存池比数据集还要大很多。大多数线程在等待InnoDB队列:“12 queries inside InnoDB,495 queries in queue”(12个查询在InnoDB内部执行,495个查询在队列中)。\n每秒捕获一次iostat输出,持续30秒。从输出可以发现没有磁盘读,而写操作则接近了“天花板”,所以I/O平均等待时间和队列长度都非常高。下面是部分输出结果,为便于打印输出,这里截取了部分字段:\nr/s w/s rsec/s wsec/s avgqu-sz await svctm %util 1.00 500.00 8.00 86216.00 5.05 11.95 0.59 29.40 0.00 451.00 0.00 206248.00 123.25 238.00 1.90 85.90 0.00 565.00 0.00 269792.00 143.80 245.43 1.77 100.00 0.00 649.00 0.00 309248.00 143.01 231.30 1.54 100.10 0.00 589.00 0.00 281784.00 142.58 232.15 1.70 100.00 0.00 384.00 0.00 162008.00 71.80 238.39 1.73 66.60 0.00 14.00 0.00 400.00 0.01 0.93 0.36 0.50 0.00 13.00 0.00 248.00 0.01 0.92 0.23 0.30 0.00 13.00 0.00 408.00 0.01 0.92 0.23 0.30 vmstat的输出也验证了iostat的结果,并且CPU的大部分时间是空闲的,只是偶尔在写尖峰时有一些I/O等待时间(最高约占9%的CPU)。\n是不是感觉脑袋里塞满了东西?当你深入一个系统的细节并且没有任何先入为主(或者故意忽略了)的观念时,很容易碰到这种情况,最终只能检查所有可能的情况。很多被检查的地方最终要么是完全正常的,要么发现是问题导致的结果而不是问题产生的原因。尽管此时我们会有很多关于问题原因的猜测,但还是需要继续检查下面给出的oprofile报表,并且在给出更多数据的时候添加一些评论和解释:\nsamples % image name app name symbol name 473653 63.5323 no-vmlinux no-vmlinux /no-vmlinux 95164 12.7646 mysqld mysqld /usr /libexec/mysqld 53107 7.1234 libc-2.10.1.so libc-2.10.1.so memcpy 13698 1.8373 ha_innodb.so ha_innodb.so build_template() 13059 1.7516 ha_innodb.so ha_innodb.so btr_search_guess_on_hash 11724 1.5726 ha_innodb.so ha_innodb.so row_sel_store_mysql_rec 8872 1.1900 ha_innodb.so ha_innodb.so rec_init_offsets_comp_ordinary 7577 1.0163 ha_innodb.so ha_innodb.so row_search_for_mysql 6030 0.8088 ha_innodb.so ha_innodb.so rec_get_offsets_func 5268 0.7066 ha_innodb.so ha_innodb.so cmp_dtuple_rec_with_match 这里大多数符号(symbol)代表的意义并不是那么明显,而大部分的时间都消耗在内核符号(no-vmlinux)(17)和一个通用的mysqld符号中,这两个符号无法告诉我们更多的细节(18)。不要被多个ha_innodb.so符号分散了注意力,看一下它们占用的百分比就知道了,不管它们在做什么,其占用的时间都很少,所以应该不会是问题所在。这个例子说明,仅仅从剖析报表出发是无法得到解决问题的结果的。我们追踪的数据是错误的。如果遇到上述例子这样的情况,需要继续检查其他的数据,寻找问题根源更明显的证据。\n到这里,如果希望从gdb的堆栈跟踪进行等待分析,请参考3.4.2节的最后部分内容。那个案例就是我们当前正在诊断的这个问题。回想一下,当时的堆栈跟踪分析的结果是正在等待进入到InnoDB内核,所以SHOW INNODB STATUS的输出结果中有“12 queries inside InnoDB,495 queries in queue”。\n从上面的分析发现问题的关键点了吗?没有。我们看到了许多不同问题可能的症状,根据经验和直觉可以推测至少有两个可能的原因。但也有一些没有意义的地方。如果再次检查一下iostat的输出,可以发现wsec/s列显示了至少在6秒内,服务器每秒写入了几百MB的数据到磁盘。每个磁盘扇区是512B,所以这里采样的结果显示每秒最多写入了150MB数据。然而整个数据库也只有900MB大小,系统的压力又主要是SELECT查询。怎么会出现这样的情况呢?\n对一个系统进行检查的时候,应该先问一下自己,是否也碰到过上面这种明显不合理的问题,如果有就需要深入调查。应该尽量跟进每一个可能的问题直到发现结果,而不要被离题太多的各种情况分散了注意力,以致最后都忘记了最初要调查的问题。可以把问题写在小纸条上,检查一个划掉一个,最后再确认一遍所有的问题都已经完成调查(19)。\n在这一点上,我们可以直接得到一个结论,但却可能是错误的。可以看到主线程的状态是InnoDB正在刷新脏页。在状态输出中出现这样的情况,一般都意味着刷新已经延迟了。我们知道这个版本的InnoDB存在“疯狂刷新”的问题(或者也被称为检查点停顿)。发生这样的情况是因为InnoDB没有按时间均匀分布刷新请求,而是隔一段时间突然请求一次强制检查点导致大量刷新操作。这种机制可能会导致InnoDB内部发生严重的阻塞,导致所有的操作需要排队等待进入内核,从而引发InnoDB上一层的服务器产生堆积。在第2章中演示的例子就是一个因为“疯狂刷新”而导致性能周期性下跌的问题。很多类似的问题都是由于强制检查点导致的,但在这个案例中却不是这个问题。有很多方法可以证明,最简单的方法是查看SHOW STATUS的计数器,追踪一下Innodb_buffer_pool_pages_flushed的变化,之前已经提到了,这个值并没有怎么增加。另外,注意到InnoDB缓冲池中也没有大量的脏页需要刷新,肯定不到几百MB。这并不值得惊讶,因为这个服务器的工作压力几乎都是SELECT查询。所以可以得到一个初步的结论,我们要关注的不是InnoDB刷新的问题,而应该是刷新延迟的问题,但这只是一个现象,而不是原因。根本的原因是磁盘的I/O已经饱和,InnoDB无法完成其I/O操作。至此我们消除了一个可能的原因,可以从基于直觉的原因列表中将其划掉了。\n从结果中将原因区别出来有时候会很困难。当一个问题看起来很眼熟的时候,也可以跳过调查阶段直接诊断。当然最好不要走这样的捷径,但有时候依靠直觉也非常重要。如果有什么地方看起来很眼熟,明智的做法还是需要花一点时间去测量一下其充分必要条件,以证明其是否就是问题所在。这样可以节省大量时间,避免查看大量其他的系统和性能数据。不过也不要过于相信直觉而直接下结论,不要说“我之前见过这样的问题,肯定就是同样的问题”。而是应该去收集相关的证据,尤其是能证明直觉的证据。\n下一步是尝试找出是什么导致了服务器的I/O利用率异常的高。首先应该注意到前面已经提到过的“服务器有连续几秒内每秒写入了几百MB数据到磁盘,而数据库一共只有900MB大小,怎么会发生这样的情况?”,注意到这里已经隐式地假设是数据库导致了磁盘写入。那么有什么证据表明是数据库导致的呢?当你有未经证实的想法,或者觉得不可思议时,如果可能的话应该去进行测量,然后排除掉一些怀疑。\n我们看到了两种可能性:要么是数据库导致了I/O(如果能找到源头的话,那么可能就找到了问题的原因);要么不是数据库导致了所有的I/O而是其他什么导致的,而系统因为缺少I/O资源影响了数据库性能。我们也很小心地尽力避免引入另外一个隐式的假设:磁盘很忙并不一定意味着MySQL会有问题。要记住,这个服务器主要的压力是内存读取,所以也很可能出现磁盘长时间无法响应但没有造成严重问题的现象。\n如果你一直跟随我们的推理逻辑,就可以发现还需要回头检查一下另外一个假设。我们已经知道磁盘设备很忙,因为其等待时间很高。对于固态硬盘来说,其I/O平均等待时间一般不会超过1/4秒。实际上,从iostat的输出结果也可以发现磁盘本身的响应还是很快的,但请求在块设备队列中等待很长的时间才能进入到磁盘设备。但要记住,这只是iostat的输出结果,也可能是错误的信息。\n究竟是什么导致了性能低下?\n当一个资源变得效率低下时,应该了解一下为什么会这样。有如下可能的原因:\n资源被过度使用,余量已经不足以正常工作。 资源没有被正确配置。 资源已经损坏或者失灵。 回到上面的例子中,iostat的输出显示可能是磁盘的工作负载太大,也可能是配置不正确(在磁盘响应很快的情况下,为什么I/O请求需要排队这么长时间才能进入到磁盘?)。然而,比较系统的需求和现有容量对于确定问题在哪里是很重要的一部分。大量的基准测试证明这个客户使用的这种SSD是无法支撑几百MB/s的写操作的。所以,尽管iostat的结果表明磁盘的响应是正常的,也不一定是完全正确的。在这个案例中,我们没有办法证明磁盘的响应比iostat的结果中所说的要慢,但这种情况还是有可能的。所以这不能改变我们的看法:可能是磁盘被滥用(20),或者是错误的配置,或者两者兼而有之,是性能低下的罪魁祸首。\n在检查过所有诊断数据之后,接下来的任务就很明显了:测量出什么导致了I/O消耗。不幸的是,客户当前使用的GNU/Linux版本对此的支持不力。通过一些工作我们可以做一些相对准确的猜测,但首先还是需要探索一下其他的可能性。我们可以测量有多少I/O来自MySQL,但客户使用的MySQL版本较低以致缺乏一些诊断功能,所以也无法提供确切有利的支持。\n作为替代,基于我们已经知道MySQL如何使用磁盘,我们来观察MySQL的I/O情况。通常来说,MySQL只会写数据、日志、排序文件和临时表到磁盘。从前面的状态计数器和其他信息来看,首先可以排除数据和日志的写入问题。那么,只能假设MySQL突然写入大量数据到临时表或者排序文件,如何来观察这种情况呢?有两个简单的方法:一是观察磁盘的可用空间,二是通过lsof命令观察服务器打开的文件句柄。这两个方法我们都采用了,结果也足以满足我们的需求。下面是问题期间每秒运行df–h的结果:\nFilesystem Size Used Avail Use% Mounted on /dev/sda3 58G 20G 36G 36% / /dev/sda3 58G 20G 36G 36% / /dev/sda3 58G 19G 36G 35% / /dev/sda3 58G 19G 36G 35% / /dev/sda3 58G 19G 36G 35% / /dev/sda3 58G 19G 36G 35% / /dev/sda3 58G 18G 37G 33% / /dev/sda3 58G 18G 37G 33% / /dev/sda3 58G 18G 37G 33% / 下面则是lsof的数据,因为某些原因我们每五秒才收集一次。我们简单地将mysqld在*/tmp*中打开的文件大小做了加总,并且把总大小和采样时的时间戳一起输出到结果文件中:\n$ awk ' /mysqld.*tmp/ { total += $7; } /^Sun Mar 28/ \u0026amp;\u0026amp; total { printf \u0026quot;%s %7.2f MB\\n\u0026quot;, $4, total/1024/1024; total = 0; }' lsof.txt 18:34:38 1655.21 MB 18:34:43 1.88 MB 18:34:48 1.88 MB 18:34:53 1.88 MB 18:34:58 1.88 MB 从这个数据可以看出,在问题之初MySQL大约写了1.5GB的数据到临时表,这和之前在SHOW PROCESSLIST中有大量的“Copying to tmp table”相吻合。这个证据表明可能是某些效率低下的查询风暴耗尽了磁盘资源。根据我们的工作直觉,出现这种情况比较普遍的一个原因是缓存失效。当memcached中所有缓存的条目同时失效,而又有很多应用需要同时访问的时候,就会出现这种情况。我们给开发人员出示了部分采样到的查询,并讨论这些查询的作用。实际情况是,缓存同时失效就是罪魁祸首(这验证了我们的直觉)。一方面开发人员在应用层面解决缓存失效的问题;另一方面我们也修改了查询,避免使用磁盘临时表。这两个方法的任何一个都可以解决问题,当然最好是两个都实施。\n如果读者一直顺着我们前面的思路读下来,可能还会有一些疑问。在这里我们可以稍微解释一下(我们在本章引用的方法在审阅的时候已经检查过一遍):\n为什么我们不一开始就优化慢查询?\n因为问题不在于慢查询,而是“太多连接”的错误。当然,因为慢查询,太多查询的时间过长而导致连接堆积在逻辑上也是成立的。但也有可能是其他原因导致连接过多。如果没有找到问题的真正原因,那么回头查看慢查询或其他可能的原因,看是否能够改善是很自然的事情(21)。但这样做大多时候会让问题变得更糟。如果你把一辆车开到机械师那里抱怨说有异响,假如机械师没有指出异响的原因,也不去检查其他的地方,而是直接做了四轮平衡和更换变速箱油,然后把账单扔给你,你也会觉得不爽的吧?\n但是查询由于糟糕的执行计划而执行缓慢不是一种警告吗?\n在事故中确实如此。但慢查询到底是原因还是结果?在深入调查前是无法知晓的。记住,在正常的时候这个查询也是正常运行的。一个查询需要执行filesort和创建临时表并不一定意味着就是有问题的。尽管消除filesort和临时表通常来说是“最佳实践”。\n通常的“最佳实践”自然有它的道理,但不一定是解决某些特殊问题的“灵丹妙药”。比如说问题可能是因为很简单的配置错误。我们碰到过很多这样的案例,问题本来是由于错误的配置导致的,却去优化查询,这不但浪费了时间,也使得真正问题被解决的时间被拖延了。\n如果缓存项被重新生成了很多次,是不是会导致产生很多同样的查询呢?\n这个问题我们确实还没有调查到。如果是多线程重新生成同样的缓存项,那么确实有可能导致产生很多同样的查询(这和很多同类型的查询不同,比如WHERE子句中的参数可能不一样)。注意到这样会刺激我们的直觉,并更快地带我们找到问题的解决方案。\n每秒有几百次SELECT查询,但只有五次UPDATE。怎么能确定这五次UPDATE的压力不会导致问题呢?\n这些UPDATE有可能对服务器造成很大的压力。我们没有将真正的查询语句展示出来,因为这样可能会将事情搞得更杂乱。但有一点很明确,某种查询的绝对数量不一定有意义。\nI/O风暴最初的证据看起来不是很充分?\n是的,确实是这样。有很多种解释可以说明为什么一个这么小的数据库可以产生这么大量的写入磁盘,或者说为什么磁盘的可用空间下降得这么快。这个问题中使用的MySQL和GNU/Linux版本都很难对一些东西进行测量(但不是说完全不可能)。尽管在很多时候我们可能扮演“魔鬼代言人”的角色,但我们还是以尽量平衡成本和潜在的利益为第一优先级。越是难以准确测量的时候,成本/收益比越攀升,我们也更愿意接受不确定性。\n之前说过“数据库过去从来没出过问题”是一种偏见吗?\n是的,这就是偏见。如果抓住问题,很好;如果没有,也可以是证明我们都有偏见的很好例子。\n至此我们要结束这个案例的学习了。需要指出的是,如果使用了诸如New Relic这样的剖析工具,即使没有我们的参与,也可能解决这个问题。\n3.5 其他剖析工具 # 我们已经演示了很多剖析MySQL、操作系统及查询的方法。我们也演示了那些我们觉得很有用的案例。当然,通过本书,我们还会展示更多工具和技术来检查和测量系统。但是等一下,本章还有更多工具没介绍呢。\n3.5.1 使用USER_STATISTICS表 # Percona Server和MariaDB都引入了一些额外的对象级别使用统计的INFORMATION_SCHEMA表,这些最初是由Google开发的。这些表对于查找服务器各部分的实际使用情况非常有帮助。在一个大型企业中,DBA负责管理数据库,但其对开发缺少话语权,那么通过这些表就可以对数据库活动进行测量和审计,并且强制执行使用策略。对于像共享主机环境这样的多租户环境也同样有用。另外,在查找性能问题时,这些表也可以帮助找出数据库中什么地方花费了最多的时间,或者什么表或索引使用得最频繁,抑或最不频繁。下面就是这些表:\n这里我们不会详细地演示针对这些表的所有有用的查询,但有几个要点要说明一下:\n可以查找使用得最多或者使用得最少的表和索引,通过读取次数或者更新次数,或者两者一起排序。 可以查找出从未使用的索引,可以考虑删除之。 可以看看复制用户的CONNECTED_TIME和BUSY_TIME,以确认复制是否会很难跟上主库的进度。 在MySQL 5.6中,Performance Schema中也添加了很多类似上面这些功能的表。\n3.5.2 使用strace # strace工具可以调查系统调用的情况。有好几种可以使用的方法,其中一种是计算系统调用的时间并打印出来:\n这种用法和oprofile有点像。但是oprofile还可以剖析程序的内部符号,而不仅仅是系统调用。另外,strace拦截系统调用使用的是不同于oprofile的技术,这会有一些不可预期性,开销也更大些。strace度量时使用的是实际时间,而oprofile使用的是花费的CPU周期。举个例子,当I/O等待出现问题的时候,strace能将它们显示出来,因为它从诸如read或者pread64这样的系统调用开始计时,直到调用结束。但oprofile不会这样,因为I/O系统调用并不会真正地消耗CPU周期,而只是等待I/O完成而已。\n我们会在需要的时候使用oprofile,因为strace对像mysqld这样有大量线程的场景会产生一些副作用。当strace附加上去后,mysqld的运行会变得很慢,因此不适合在产品环境中使用。但在某些场景中strace还是相当有用的,Percona Toolkit中有一个叫做pt-ioprofile的工具就是使用strace来生成I/O活动的剖析报告的。这个工具很有帮助,可以证明或者驳斥某些难以测量的情况下的一些观点,此时其他方法很难达到目的(如果运行的是MySQL 5.6,使用Performance Schema也可以达到目的)。\n3.6 总结 # 本章给出了一些基本的思路和技术,有助于你成功地进行性能优化。正确的思维方式是开启系统的全部潜力和应用本书其他章节提供的知识的关键。下面是我们试图演示的一些基本知识点:\n我们认为定义性能最有效的方法是响应时间。 如果无法测量就无法有效地优化,所以性能优化工作需要基于高质量、全方位及完整的响应时间测量。 测量的最佳开始点是应用程序,而不是数据库。即使问题出在底层的数据库,借助良好的测量也可以很容易地发现问题。 大多数系统无法完整地测量,测量有时候也会有错误的结果。但也可以想办法绕过一些限制,并得到好的结果(但是要能意识到所使用的方法的缺陷和不确定性在哪里)。 完整的测量会产生大量需要分析的数据,所以需要用到剖析器。这是最佳的工具,可以帮助将重要的问题冒泡到前面,这样就可以决定从哪里开始分析会比较好。 剖析报告是一种汇总信息,掩盖和丢弃了太多细节。而且它不会告诉你缺少了什么,所以完全依赖剖析报告也是不明智的。 有两种消耗时间的操作:工作或者等待。大多数剖析器只能测量因为工作而消耗的时间,所以等待分析有时候是很有用的补充,尤其是当CPU利用率很低但工作却一直无法完成的时候。 优化和提升是两回事。当继续提升的成本超过收益的时候,应当停止优化。 注意你的直觉,但应该只根据直觉来指导解决问题的思路,而不是用于确定系统的问题。决策应当尽量基于数据而不是感觉。 总体来说,我们认为解决性能问题的方法,首先是要澄清问题,然后选择合适的技术来解答这些问题。如果你想尝试提升服务器的总体性能,那么一个比较好的起点是将所有查询记录到日志中,然后利用pt-query-digest工具生成系统级别的剖析报告。如果是要追查某些性能低下的查询,记录和剖析的方法也会有帮助。可以把精力放在寻找那些消耗时间最多的、导致了糟糕的用户体验的,或者那些高度变化的,抑或有奇怪的响应时间直方图的查询。当找到了这些“坏”查询时,要钻取pt-query-digest报告中包含的该查询的详细信息,或者使用SHOW PROFILE及其他诸如EXPLAIN这样的工具。\n如果找不到这些查询性能低下的原因,那么也可能是遇到了服务器级别的性能问题。这时,可以较高精度测量和绘制服务器状态计数器的细节信息。如果通过这样的分析重现了问题,则应该通过同样的数据制定一个可靠的触发条件,来收集更多的诊断数据。多花费一点时间来确定可靠的触发条件,尽量避免漏检或者误报。如果已经可以捕获故障活动期间的数据,但还是无法找到其根本原因,则要么尝试捕获更多的数据,要么尝试寻求帮助。\n我们无法完整地测量工作系统,但说到底它们都是某种状态机,所以只要足够细心,逻辑清晰并且坚持下去,通常来说都能得到想要的结果。要注意的是不要把原因和结果搞混了,而且在确认问题之前也不要随便针对系统做变动。\n理论上纯粹的自顶向下的方法分析和详尽的测量只是理想的情况,而我们常常需要处理的是真实系统。真实系统是复杂且无法充分测量的,所以我们只能根据情况尽力而为。使用诸如pt-query-digest和MySQL企业监控器的查询分析器这样的工具并不完美,通常都不会给出问题根源的直接证据。但真的掌握了以后,已经足以完成大部分的优化诊断工作了。\n————————————————————\n(1) 本书不会严格区分查询和语句,DDL和DML等。不管给服务器发送什么命令,关心的都是执行命令的速度。本书将使用“查询”一词泛指所有发送给服务器的命令。\n(2) 本书尽量避免从理论上来阐述性能优化一词,如果有兴趣可以参考阅读另外两篇文章。在Percona的网站( http://www.percona.com)上,有一篇名为Goal-Driven Performance Optimization的白皮书,这是一篇紧凑的快速参考页。另外一篇是Cary Millsap的Optimizing Oracle Performance(O\u0026rsquo;Reilly出版)。Cary的优化方法,被称为R方法,是Oracle世界的优化黄金定律。\n(3) 也有人将优化定义为提升吞吐量,这也没有什么问题,但本书采用的不是这个定义,因为我们认为响应时间更重要,尽管吞吐量在基准测试中更容易测量。\n(4) MySQL 5.5的Performance Schema也没有提供查询级别的细节数据,要到MySQL 5.6才提供。\n(5) 在此向Donald Rumsfeld道歉。他的评论尽管听起来可笑,但实际上非常有见地。\n(6) 啊!(这只是个玩笑,我们并不坚持。)\n(7) 我们将在后面展示例子,因为需要有一些先验知识,这个问题跟底层相关,所以我们先跳过自顶向下的方法。\n(8) 不像PHP,大部分其他编程语言都有一些内建的剖析功能。例如Ruby可以使用-r选项,Perl则可以使用perl-d:DProf,等等。\n(9) 这里已经是尽可能地简化描述了,实际上Percona Server的查询日志报告会包含更多细节信息,可以帮助理解为什么某条查询花费了144ms去获取一行数据,这个时间实在是太长了。\n(10) 整个视图太长,无法在书中全部打印出来,但Sakila数据库可以从MySQL网站上下载到。\n(11) 原文用的Queries,实际上这里有点问题,虽然文档上也说这个参数是会话级的,但在MySQL 5.1/5.5多个版本中实际查询时发现其是全局级别的。——译者注\n(12) 如果你有本书的第二版,可能会注意到我们正在彻底改变这一点。\n(13) 再次强调,在没有足够的理由确信这是解决办法之前,不要随便去做升级操作。\n(14) 到目前为止我们还没发现红衣女,如果发现了,一定会让你知道的。\n(15) 警告:使用GDB是有侵入性的。它会暂时造成服务器停顿,尤其是有很多线程的时候,甚至有可能造成崩溃。但有时候收益还是大于风险的。如果服务器本身问题已经严重到无法提供服务了,那么使用GBD再造成一些暂停也就无所谓了。\n(16) 有时候为了“优化”而不安装符号信息,实际上这样做不会有多少优化的效果,反而会造成诊断问题更困难。可以使用nm工具检查是否安装了符号信息,如果没有,则可以通过安装MySQL的debuginfo包来安装。\n(17) 理论上,我们需要内核符号(kernel symbol)才能理解内核中发生了什么。实际上,安装内核符号可能会比较麻烦,并且从vmstat的输出可以看到系统CPU的利用率很低,所以即使安装了,很可能也会发现内核大多数是处于“sleeping”(睡眠)状态的。\n(18) 这看起来是一个编译有问题的MySQL版本。\n(19) 或者换个说法,不要把所有的鸡蛋都混在一个篮子里。\n(20) 也有人会拨打1-800热线电话。\n(21) 就像常说的“当你手中有了锤子,所有的东西看起来都是钉子”一样。\n"},{"id":145,"href":"/zh/docs/technology/MySQL/_%E9%AB%98%E6%80%A7%E8%83%BDMySQL_/%E7%AC%AC2%E7%AB%A0MySQL%E5%9F%BA%E5%87%86%E6%B5%8B%E8%AF%95/","title":"第2章MySQL基准测试","section":"高性能 My SQL","content":"第2章 MySQL基准测试\n基准测试(benchmark)是MySQL新手和专家都需要掌握的一项基本技能。简单地说,基准测试是针对系统设计的一种压力测试。通常的目标是为了掌握系统的行为。但也有其他原因,如重现某个系统状态,或者是做新硬件的可靠性测试。本章将讨论MySQL和基于MySQL的应用的基准测试的重要性、策略和工具。我们将特别讨论一下sysbench,这是一款非常优秀的MySQL基准测试工具。\n2.1 为什么需要基准测试 # 为什么基准测试很重要?因为基准测试是唯一方便有效的、可以学习系统在给定的工作负载下会发生什么的方法。基准测试可以观察系统在不同压力下的行为,评估系统的容量,掌握哪些是重要的变化,或者观察系统如何处理不同的数据。基准测试可以在系统实际负载之外创造一些虚构场景进行测试。基准测试可以完成以下工作,或者更多:\n验证基于系统的一些假设,确认这些假设是否符合实际情况。 重现系统中的某些异常行为,以解决这些异常。 测试系统当前的运行情况。如果不清楚系统当前的性能,就无法确认某些优化的效果如何。也可以利用历史的基准测试结果来分析诊断一些无法预测的问题。 模拟比当前系统更高的负载,以找出系统随着压力增加而可能遇到的扩展性瓶颈。 规划未来的业务增长。基准测试可以评估在项目未来的负载下,需要什么样的硬件,需要多大容量的网络,以及其他相关资源。这有助于降低系统升级和重大变更的风险。 测试应用适应可变环境的能力。例如,通过基准测试,可以发现系统在随机的并发峰值下的性能表现,或者是不同配置的服务器之间的性能表现。基准测试也可以测试系统对不同数据分布的处理能力。 测试不同的硬件、软件和操作系统配置。比如RAID 5还是RAID 10更适合当前的系统?如果系统从ATA硬盘升级到SAN存储,对于随机写性能有什么帮助?Linux 2.4系列的内核会比2.6系列的可扩展性更好吗?升级MySQL的版本能改善性能吗?为当前的数据采用不同的存储引擎会有什么效果?所有这类问题都可以通过专门的基准测试来获得答案。 证明新采购的设备是否配置正确。笔者曾经无数次地通过基准测试来对新系统进行压测,发现了很多错误的配置,以及硬件组件的失效等问题。因此在新系统正式上线到生产环境之前进行基准测试是一个好习惯,永远不要相信主机提供商或者硬件供应商的所谓系统已经安装好,并且能运行多快的说法。如果可能,执行实际的基准测试永远是一个好主意。 基准测试还可以用于其他目的,比如为应用创建单元测试套件。但本章我们只关注与性能有关的基准测试。\n基准测试的一个主要问题在于其不是真实压力的测试。基准测试施加给系统的压力相对真实压力来说,通常比较简单。真实压力是不可预期而且变化多端的,有时候情况会过于复杂而难以解释。所以使用真实压力测试,可能难以从结果中分析出确切的结论。\n基准测试的压力和真实压力在哪些方面不同?有很多因素会影响基准测试,比如数据量、数据和查询的分布,但最重要的一点还是基准测试通常要求尽可能快地执行完成,所以经常给系统造成过大的压力。在很多案例中,我们都会调整给测试工具的最大压力,以在系统可以容忍的压力阈值内尽可能快地执行测试,这对于确定系统的最大容量非常有帮助。然而大部分压力测试工具不支持对压力进行复杂的控制。务必要记住,测试工具自身的局限也会影响到结果的有效性。\n使用基准测试进行容量规划也要掌握技巧,不能只根据测试结果做简单的推断。例如,假设想知道使用新数据库服务器后,系统能够支撑多大的业务增长。首先对原系统进行基准测试,然后对新系统做测试,结果发现新系统可以支持原系统40倍的TPS(每秒事务数),这时候就不能简单地推断说新系统一定可以支持40倍的业务增长。这是因为在业务增长的同时,系统的流量、用户、数据以及不同数据之间的交互都在增长,它们不可能都有40倍的支撑能力,尤其是相互之间的关系。而且当业务增长到40倍时,应用本身的设计也可能已经随之改变。可能有更多的新特性会上线,其中某些特性可能对数据库造成的压力远大于原有功能。而这些压力、数据、关系和特性的变化都很难模拟,所以它们对系统的影响也很难评估。\n结论就是,我们只能进行大概的测试,来确定系统大致的余量有多少。当然也可以做一些真实压力测试(和基准测试有区别),但在构造数据集和压力的时候要特别小心,而且这样就不再是基准测试了。基准测试要尽量简单直接,结果之间容易相互比较,成本低且易于执行。尽管有诸多限制,基准测试还是非常有用的(只要搞清楚测试的原理,并且了解如何分析结果所代表的意义)。\n2.2 基准测试的策略 # 基准测试有两种主要的策略:一是针对整个系统的整体测试,另外是单独测试MySQL。这两种策略也被称为集成式(full-stack)以及单组件式(single-component)基准测试。针对整个系统做集成式测试,而不是单独测试MySQL的原因主要有以下几点:\n测试整个应用系统,包括Web服务器、应用代码、网络和数据库是非常有用的,因为用户关注的并不仅仅是MySQL本身的性能,而是应用整体的性能。 MySQL并非总是应用的瓶颈,通过整体的测试可以揭示这一点。 只有对应用做整体测试,才能发现各部分之间的缓存带来的影响。 整体应用的集成式测试更能揭示应用的真实表现,而单独组件的测试很难做到这一点。 另外一方面,应用的整体基准测试很难建立,甚至很难正确设置。如果基准测试的设计有问题,那么结果就无法反映真实的情况,从而基于此做的决策也就可能是错误的。\n不过,有时候不需要了解整个应用的情况,而只需要关注MySQL的性能,至少在项目初期可以这样做。基于以下情况,可以选择只测试MySQL:\n需要比较不同的schema或查询的性能。 针对应用中某个具体问题的测试。 为了避免漫长的基准测试,可以通过一个短期的基准测试,做快速的“周期循环”,来检测出某些调整后的效果。 另外,如果能够在真实的数据集上执行重复的查询,那么针对MySQL的基准测试也是有用的,但是数据本身和数据集的大小都应该是真实的。如果可能,可以采用生产环境的数据快照。\n不幸的是,设置一个基于真实数据的基准测试复杂而且耗时。如果能得到一份生产数据集的拷贝,当然很幸运,但这通常不太可能。比如要测试的是一个刚开发的新应用,它只有很少的用户和数据。如果想测试该应用在规模扩张到很大以后的性能表现,就只能通过模拟大量的数据和压力来进行。\n2.2.1 测试何种指标 # 在开始执行甚至是在设计基准测试之前,需要先明确测试的目标。测试目标决定了选择什么样的测试工具和技术,以获得精确而有意义的测试结果。可以将测试目标细化为一系列的问题,比如,“这种CPU是否比另外一种要快?”,或“新索引是否比当前索引性能更好?”\n有时候需要用不同的方法测试不同的指标。比如,针对延迟(latency)和吞吐量(throughput)就需要采用不同的测试方法。\n请考虑以下指标,看看如何满足测试的需求。\n吞吐量\n吞吐量指的是单位时间内的事务处理数。这一直是经典的数据库应用测试指标。一些标准的基准测试被广泛地引用,如TPC-C(参考 http://www.tpc.org),而且很多数据库厂商都努力争取在这些测试中取得好成绩。这类基准测试主要针对在线事务处理(OLTP)的吞吐量,非常适用于多用户的交互式应用。常用的测试单位是每秒事务数(TPS),有些也采用每分钟事务数(TPM)。\n响应时间或者延迟\n这个指标用于测试任务所需的整体时间。根据具体的应用,测试的时间单位可能是微秒、毫秒、秒或者分钟。根据不同的时间单位可以计算出平均响应时间、最小响应时间、最大响应时间和所占百分比。最大响应时间通常意义不大,因为测试时间越长,最大响应时间也可能越大。而且其结果通常不可重复,每次测试都可能得到不同的最大响应时间。因此,通常可以使用百分比响应时间(percentile response time)来替代最大响应时间。例如,如果95%的响应时间都是5毫秒,则表示任务在95%的时间段内都可以在5毫秒之内完成。\n使用图表有助于理解测试结果。可以将测试结果绘制成折线图(比如平均值折线或者95%百分比折线)或者散点图,直观地表现数据结果集的分布情况。通过这些图可以发现长时间测试的趋势。本章后面将更详细地讨论这一点。\n并发性\n并发性是一个非常重要又经常被误解和误用的指标。例如,它经常被表示成多少用户在同一时间浏览一个Web站点,经常使用的指标是有多少个会话(1)。然而,HTTP协议是无状态的,大多数用户只是简单地读取浏览器上显示的信息,这并不等同于Web服务器的并发性。而且,Web服务器的并发性也不等同于数据库的并发性,而仅仅只表示会话存储机制可以处理多少数据的能力。Web服务器的并发性更准确的度量指标,应该是在任意时间有多少同时发生的并发请求。\n在应用的不同环节都可以测量相应的并发性。Web服务器的高并发,一般也会导致数据库的高并发,但服务器采用的语言和工具集对此都会有影响。注意不要将创建数据库连接和并发性搞混淆。一个设计良好的应用,同时可以打开成百上千个MySQL数据库服务器连接,但可能同时只有少数连接在执行查询。所以说,一个Web站点“同时有50000个用户”访问,却可能只有10~15个并发请求到MySQL数据库。\n换句话说,并发性基准测试需要关注的是正在工作中的并发操作,或者是同时工作中的线程数或者连接数。当并发性增加时,需要测量吞吐量是否下降,响应时间是否变长,如果是这样,应用可能就无法处理峰值压力。\n并发性的测量完全不同于响应时间和吞吐量。它不像是一个结果,而更像是设置基准测试的一种属性。并发性测试通常不是为了测试应用能达到的并发度,而是为了测试应用在不同并发下的性能。当然,数据库的并发性还是需要测量的。可以通过sysbench指定32、64或者128个线程的测试,然后在测试期间记录MySQL数据库的Threads_running状态值。在第11章将讨论这个指标对容量规划的影响。\n可扩展性\n在系统的业务压力可能发生变化的情况下,测试可扩展性就非常必要了。第11章将更进一步讨论可扩展性的话题。简单地说,可扩展性指的是,给系统增加一倍的工作,在理想情况下就能获得两倍的结果(即吞吐量增加一倍)。或者说,给系统增加一倍的资源(比如两倍的CPU数),就可以获得两倍的吞吐量。当然,同时性能(响应时间)也必须在可以接受的范围内。大多数系统是无法做到如此理想的线性扩展的。随着压力的变化,吞吐量和性能都可能越来越差。\n可扩展性指标对于容量规范非常有用,它可以提供其他测试无法提供的信息,来帮助发现应用的瓶颈。比如,如果系统是基于单个用户的响应时间测试(这是一个很糟糕的测试策略)设计的,虽然测试的结果很好,但当并发度增加时,系统的性能有可能变得非常糟糕。而一个基于不断增加用户连接的情况下的响应时间测试则可以发现这个问题。\n一些任务,比如从细粒度数据创建汇总表的批量工作,需要的是周期性的快速响应时间。当然也可以测试这些任务纯粹的响应时间,但要注意考虑这些任务之间的相互影响。批量工作可能导致相互之间有影响的查询性能变差,反之亦然。\n归根结底,应该测试那些对用户来说最重要的指标。因此应该尽可能地去收集一些需求,比如,什么样的响应时间是可以接受的,期待多少的并发性,等等。然后基于这些需求来设计基准测试,避免目光短浅地只关注部分指标,而忽略其他指标。\n2.3 基准测试方法 # 在了解基本概念之后,现在可以来具体讨论一下如何设计和执行基准测试。但在讨论如何设计好的基准测试之前,先来看一下如何避免一些常见的错误,这些错误可能导致测试结果无用或者不精确:\n使用真实数据的子集而不是全集。例如应用需要处理几百GB的数据,但测试只有1GB数据;或者只使用当前数据进行测试,却希望模拟未来业务大幅度增长后的情况。 使用错误的数据分布。例如使用均匀分布的数据测试,而系统的真实数据有很多热点区域(随机生成的测试数据通常无法模拟真实的数据分布)。 使用不真实的分布参数,例如假定所有用户的个人信息(profile)都会被平均地读取(2)。 在多用户场景中,只做单用户的测试。 在单服务器上测试分布式应用。 与真实用户行为不匹配。例如Web页面中的“思考时间”。真实用户在请求到一个页面后会阅读一段时间,而不是不停顿地一个接一个点击相关链接。 反复执行同一个查询。真实的查询是不尽相同的,这可能会导致缓存命中率降低。而反复执行同一个查询在某种程度上,会全部或者部分缓存结果。 没有检查错误。如果测试的结果无法得到合理的解释,比如一个本应该很慢的查询突然变快了,就应该检查是否有错误产生。否则可能只是测试了MySQL检测语法错误的速度了。基准测试完成后,一定要检查一下错误日志,这应当是基本的要求。 忽略了系统预热(warm up)的过程。例如系统重启后马上进行测试。有时候需要了解系统重启后需要多长时间才能达到正常的性能容量,要特别留意预热的时长。反过来说,如果要想分析正常的性能,需要注意,若基准测试在重启以后马上启动,则缓存是冷的、还没有数据,这时即使测试的压力相同,得到的结果也和缓存已经装满数据时是不同的。 使用默认的服务器配置。第3章将详细地讨论服务器的优化配置。 测试时间太短。基准测试需要持续一定的时间。后面会继续讨论这个话题。 只有避免了上述错误,才能走上改进测试质量的漫漫长路。\n如果其他条件相同,就应努力使测试过程尽可能地接近真实应用的情况。当然,有时候和真实情况稍有些出入问题也不大。例如,实际应用服务器和数据库服务器分别部署在不同的机器。如果采用和实际部署完全相同的配置当然更真实,但也会引入更多的变化因素,比如加入了网络的负载和速度等。而在单一节点上运行测试相对要容易,在某些情况下结果也可以接受,那么就可以在单一节点上进行测试。当然,这样的选择需要根据实际情况来分析是否合适。\n2.3.1 设计和规划基准测试 # 规划基准测试的第一步是提出问题并明确目标。然后决定是采用标准的基准测试,还是设计专用的测试。\n如果采用标准的基准测试,应该确认选择了合适的测试方案。例如,不要使用TPC-H测试电子商务系统。在TPC的定义中,“TPC-H是即席查询和决策支持型应用的基准测试”,因此不适合用来测试OLTP系统。\n设计专用的基准测试是很复杂的,往往需要一个迭代的过程。首先需要获得生产数据集的快照,并且该快照很容易还原,以便进行后续的测试。\n然后,针对数据运行查询。可以建立一个单元测试集作为初步的测试,并运行多遍。但是这和真实的数据库环境还是有差别的。更好的办法是选择一个有代表性的时间段,比如高峰期的一个小时,或者一整天,记录生产系统上的所有查询。如果时间段选得比较小,则可以选择多个时间段。这样有助于覆盖整个系统的活动状态,例如每周报表的查询、或者非峰值时间运行的批处理作业(3)。\n可以在不同级别记录查询。例如,如果是集成式(full-stack)基准测试,可以记录Web服务器上的HTTP请求,也可以打开MySQL的查询日志(Query Log)。倘若要重演这些查询,就要确保创建多线程来并行执行,而不是单个线程线性地执行。对日志中的每个连接都应该创建独立的线程,而不是将所有的查询随机地分配到一些线程中。查询日志中记录了每个查询是在哪个连接中执行的。\n即使不需要创建专用的基准测试,详细地写下测试规划也是必需的。测试可能要多次反复运行,因此需要精确地重现测试过程。而且也应该考虑到未来,执行下一轮测试时可能已经不是同一个人了。即使还是同一个人,也有可能不会确切地记得初次运行时的情况。测试规划应该记录测试数据、系统配置的步骤、如何测量和分析结果,以及预热的方案等。\n应该建立将参数和结果文档化的规范,每一轮测试都必须进行详细记录。文档规范可以很简单,比如采用电子表格(spreadsheet)或者记事本形式,也可以是复杂的自定义的数据库。需要记住的是,经常要写一些脚本来分析测试结果,因此如果能够不用打开电子表格或者文本文件等额外操作,当然是更好的。\n2.3.2 基准测试应该运行多长时间 # 基准测试应该运行足够长的时间,这一点很重要。如果需要测试系统在稳定状态时的性能,那么当然需要在稳定状态下测试并观察。而如果系统有大量的数据和内存,要达到稳定状态可能需要非常长的时间。大部分系统都会有一些应对突发情况的余量,能够吸收性能尖峰,将一些工作延迟到高峰期之后执行。但当对机器加压足够长时间之后,这些余量会被消耗尽,系统的短期尖峰也就无法维持原来的高性能。\n有时候无法确认测试需要运行多长的时间才足够。如果是这样,可以让测试一直运行,持续观察直到确认系统已经稳定。下面是一个在已知系统上执行测试的例子,图2-1显示了系统磁盘读和写吞吐量的时序图。\n图2-1:扩展基准测试的I/O性能图\n系统预热完成后,读I/O活动在三四个小时后曲线趋向稳定,但写I/O至少在八小时内变化还是很大,之后有一些点的波动较大,但读和写总体来说基本稳定了(4)。一个简单的测试规则,就是等系统看起来稳定的时间至少等于系统预热的时间。本例中的测试持续了72个小时才结束,以确保能够体现系统长期的行为。\n一个常见的错误的测试方式是,只执行一系列短期的测试,比如每次60秒,并在此测试的基础上去总结系统的性能。我们经常可以听到类似这样的话:“我尝试对新版本做了测试,但还不如旧版本快”,然而我们分析实际的测试结果后发现,测试的方式根本不足以得出这样的结论。有时候人们也会强调说不可能有时间去测试8或者12个小时,以验证10个不同并发性在两到三个不同版本下的性能。如果没有时间去完成准确完整的基准测试,那么已经花费的所有时间都是一种浪费。有时候要相信别人的测试结果,这总比做一次半拉子的测试来得到一个错误的结论要好。\n2.3.3 获取系统性能和状态 # 在执行基准测试时,需要尽可能多地收集被测试系统的信息。最好为基准测试建立一个目录,并且每执行一轮测试都创建单独的子目录,将测试结果、配置文件、测试指标、脚本和其他相关说明都保存在其中。即使有些结果不是目前需要的,也应该先保存下来。多余一些数据总比缺乏重要的数据要好,而且多余的数据以后也许会用得着。需要记录的数据包括系统状态和性能指标,诸如CPU使用率、磁盘I/O、网络流量统计、SHOW GLOBAL STATUS计数器等。\n下面是一个收集MySQL测试数据的shell脚本:\n#!/bin/sh INTERVAL=5 PREFIX=$INTERVAL-sec-status RUNFILE=/home/benchmarks/running mysql -e 'SHOW GLOBAL VARIABLES' \u0026gt;\u0026gt; mysql-variables while test -e $RUNFILE; do file=$(date +%F_%I) sleep=$(date +%s.%N | awk \u0026quot;{print $INTERVAL - (\\$1 % $INTERVAL)}\u0026quot;) sleep $sleep ts=\u0026quot;$(date +\u0026quot;TS %s.%N %F %T\u0026quot;)\u0026quot; loadavg=\u0026quot;$(uptime)\u0026quot; echo \u0026quot;$ts $loadavg\u0026quot; \u0026gt;\u0026gt; $PREFIX-${file}-status mysql -e 'SHOW GLOBAL STATUS' \u0026gt;\u0026gt; $PREFIX-${file}-status \u0026amp; echo \u0026quot;$ts $loadavg\u0026quot; \u0026gt;\u0026gt; $PREFIX-${file}-innodbstatus mysql -e 'SHOW ENGINE INNODB STATUS\\G' \u0026gt;\u0026gt; $PREFIX-${file}-innodbstatus \u0026amp; echo \u0026quot;$ts $loadavg\u0026quot; \u0026gt;\u0026gt; $PREFIX-${file}-processlist mysql -e 'SHOW FULL PROCESSLIST\\G' \u0026gt;\u0026gt; $PREFIX-${file}-processlist \u0026amp; echo $ts done echo Exiting because $RUNFILE does not exist. 这个shell脚本很简单,但提供了一个有效的收集状态和性能数据的框架。看起来好像作用不大,但当需要在多个服务器上执行比较复杂的测试的时候,要回答以下关于系统行为的问题,没有这种脚本的话就会很困难了。下面是这个脚本的一些要点:\n迭代是基于固定时间间隔的,每隔5秒运行一次收集的动作,注意这里sleep的时间有一个特殊的技巧。如果只是简单地在每次循环时插入一条“sleep 5”的指令,循环的执行间隔时间一般都会稍大于5秒,那么这个脚本就没有办法通过其他脚本和图形简单地捕获时间相关的准确数据。即使有时候循环能够恰好在5秒内完成,但如果某些系统的时间戳是15:32:18.218192,另外一个则是15:32:23.819437,这时候就比较讨厌了。当然这里的5秒也可以改成其他的时间间隔,比如1、10、30或者60秒。不过还是推荐使用5秒或者10秒的间隔来收集数据。 每个文件名都包含了该轮测试开始的日期和小时。如果测试要持续好几天,那么这个文件可能会非常大,有必要的话需要手工将文件移到其他地方,但要分析全部结果的时候要注意从最早的文件开始。如果只需要分析某个时间点的数据,则可以根据文件名中的日期和小时迅速定位,这比在一个GB以上的大文件中去搜索要快捷得多。 每次抓取数据都会先记录当前的时间戳,所以可以在文件中搜索某个时间点的数据。也可以写一些awk或者sed脚本来简化操作。 这个脚本不会处理或者过滤收集到的数据。先收集所有的原始数据,然后再基于此做分析和过滤是一个好习惯。如果在收集的时候对数据做了预处理,而后续分析发现一些异常的地方需要用到更多的原始数据,这时候就要“抓瞎”了。 如果需要在测试完成后脚本自动退出,只需要删除*/home/benchmarks/running*文件即可。 这只是一段简单的代码,或许不能满足全部的需求,但却很好地演示了该如何捕获测试的性能和状态数据。从代码可以看出,只捕获了MySQL的部分数据,如果需要,则很容易通过修改脚本添加新的数据捕获。例如,可以通过pt-diskstats工具(5)捕获*/proc/diskstats*的数据为后续分析磁盘I/O使用。\n2.3.4 获得准确的测试结果 # 获得准确测试结果的最好办法,是回答一些关于基准测试的基本问题:是否选择了正确的基准测试?是否为问题收集了相关的数据?是否采用了错误的测试标准?例如,是否对一个I/O密集型(I/O-bound)的应用,采用了CPU密集型(CPU-bound)的测试标准来评估性能?\n接着,确认测试结果是否可重复。每次重新测试之前要确保系统的状态是一致的。如果是非常重要的测试,甚至有必要每次测试都重启系统。一般情况下,需要测试的是经过预热的系统,还需要确保预热的时间足够长(请参考前面关于基准测试需要运行多长时间的内容)、是否可重复。如果预热采用的是随机查询,那么测试结果可能就是不可重复的。\n如果测试的过程会修改数据或者schema,那么每次测试前,需要利用快照还原数据。在表中插入1000条记录和插入100万条记录,测试结果肯定不会相同。数据的碎片度和在磁盘上的分布,都可能导致测试是不可重复的。一个确保物理磁盘数据的分布尽可能一致的办法是,每次都进行快速格式化并进行磁盘分区复制。\n要注意很多因素,包括外部的压力、性能分析和监控系统、详细的日志记录、周期性作业,以及其他一些因素,都会影响到测试结果。一个典型的案例,就是测试过程中突然有cron定时作业启动,或者正处于一个巡查读取周期(Patrol Read cycle),抑或RAID卡启动了定时的一致性检查等。要确保基准测试运行过程中所需要的资源是专用于测试的。如果有其他额外的操作,则会消耗网络带宽,或者测试基于的是和其他服务器共享的SAN存储,那么得到的结果很可能是不准确的。\n每次测试中,修改的参数应该尽量少。如果必须要一次修改多个参数,那么可能会丢失一些信息。有些参数依赖其他参数,这些参数可能无法单独修改。有时候甚至都没有意识到这些依赖,这给测试带来了复杂性(6)。\n一般情况下,都是通过迭代逐步地修改基准测试的参数,而不是每次运行时都做大量的修改。举个例子,如果要通过调整参数来创造一个特定行为,可以通过使用分治法(divide-and-conquer,每次运行时将参数对分减半)来找到正确的值。\n很多基准测试都是用来做预测系统迁移后的性能的,比如从Oracle迁移到MySQL。这种测试通常比较麻烦,因为MySQL执行的查询类型与Oracle完全不同。如果想知道在Oracle运行得很好的应用迁移到MySQL以后性能如何,通常需要重新设计MySQL的schema和查询(在某些情况下,比如,建立一个跨平台的应用时,可能想知道同一条查询是如何在两个平台运行的,不过这种情况并不多见)。\n另外,基于MySQL的默认配置的测试没有什么意义,因为默认配置是基于消耗很少内存的极小应用的。有时候可以看到一些MySQL和其他商业数据库产品的对比测试,结果很让人尴尬,可能就是MySQL采用了默认配置的缘故。让人无语的是,这样明显有误的测试结果还容易变成头条新闻。\n固态存储(SSD或者PCI-E卡)给基准测试带来了很大的挑战,第9章将进一步讨论。\n最后,如果测试中出现异常结果,不要轻易当作坏数据点而丢弃。应该认真研究并找到产生这种结果的原因。测试可能会得到有价值的结果,或者一个严重的错误,抑或基准测试的设计缺陷。如果对测试结果不了解,就不要轻易公布。有一些案例表明,异常的测试结果往往都是由于很小的错误导致的,最后搞得测试无功而返(7)。\n2.3.5 运行基准测试并分析结果 # 一旦准备就绪,就可以着手基准测试,收集和分析数据了。\n通常来说,自动化基准测试是个好主意。这样做可以获得更精确的测试结果。因为自动化的过程可以防止测试人员偶尔遗漏某些步骤,或者误操作。另外也有助于归档整个测试过程。\n自动化的方式有很多,可以是一个Makefile文件或者一组脚本。脚本语言可以根据需要选择:shell、PHP、Perl等都可以。要尽可能地使所有测试过程都自动化,包括装载数据、系统预热、执行测试、记录结果等。\n一旦设置了正确的自动化操作,基准测试将成为一步式操作。如果只是针对某些应用做一次性的快速验证测试,可能就没必要做自动化。但只要未来可能会引用到测试结果,建议都尽量地自动化。否则到时候可能就搞不清楚是如何获得这个结果的,也不记得采用了什么参数,这样就很难再通过测试重现结果了。\n基准测试通常需要运行多次。具体需要运行多少次要看对结果的记分方式,以及测试的重要程度。要提高测试的准确度,就需要多运行几次。一般在测试的实践中,可以取最好的结果值,或者所有结果的平均值,抑或从五个测试结果里取最好三个值的平均值。可以根据需要更进一步精确化测试结果。还可以对结果使用统计方法,确定置信区间(confidence interval)等。不过通常来说,不会用到这种程度的确定性结果(8)。只要测试的结果能满足目前的需求,简单地运行几轮测试,看看结果的变化就可以了。如果结果变化很大,可以再多运行几次,或者运行更长的时间,这样都可以获得更确定的结果。\n获得测试结果后,还需要对结果进行分析,也就是说,要把“数字”变成“知识”。最终的目的是回答在设计测试时的问题。理想情况下,可以获得诸如“升级到4核CPU可以在保持响应时间不变的情况下获得超过50%的吞吐量增长”或者“增加索引可以使查询更快”的结论。如果需要更加科学化,建议在测试前读读null hypothesis一书,但大部分情况下不会要求做这么严格的基准测试。\n如何从数据中抽象出有意义的结果,依赖于如何收集数据。通常需要写一些脚本来分析数据,这不仅能减轻分析的工作量,而且和自动化基准测试一样可以重复运行,并易于文档化。下面是一个非常简单的shell脚本,演示了如何从前面的数据采集脚本采集到的数据中抽取时间维度信息。脚本的输入参数是采集到的数据文件的名字。\n#!/bin/sh # This script converts SHOW GLOBAL STATUS into a tabulated format, one line # per sample in the input, with the metrics divided by the time elapsed # between samples. awk ' BEGIN { printf \u0026quot;#ts date time load QPS\u0026quot;; fmt = \u0026quot; %.2f\u0026quot;; } /^TS/ { # The timestamp lines begin with TS. ts = substr($2, 1, index($2, \u0026quot;.\u0026quot;) - 1); load = NF - 2; diff = ts - prev_ts; prev_ts = ts; printf \u0026quot;\\n%s %s %s %s\u0026quot;, ts, $3, $4, substr($load, 1, length($load)-1); } /Queries/ { printf fmt, ($2-Queries)/diff; Queries=$2 } ' \u0026quot;$@\u0026quot; 假设该脚本名为analyze,当前面的脚本生成状态文件以后,就可以运行该脚本,可能会得到如下的结果:\n[baron@ginger ~]$ ./analyze 5-sec-status-2011-03-20 #ts date time load QPS 1300642150 2011-03-20 17:29:10 0.00 0.62 1300642155 2011-03-20 17:29:15 0.00 1311.60 1300642160 2011-03-20 17:29:20 0.00 1770.60 1300642165 2011-03-20 17:29:25 0.00 1756.60 1300642170 2011-03-20 17:29:30 0.00 1752.40 1300642175 2011-03-20 17:29:35 0.00 1735.00 1300642180 2011-03-20 17:29:40 0.00 1713.00 1300642185 2011-03-20 17:29:45 0.00 1788.00 1300642190 2011-03-20 17:29:50 0.00 1596.40 第一行是列的名字;第二行的数据应该忽略,因为这是测试实际启动前的数据。接下来的行包含Unix时间戳、日期、时间(注意时间数据是每5秒更新一次,前面脚本说明时曾提过)、系统负载、数据库的QPS(每秒查询次数)五列,这应该是用于分析系统性能的最少数据需求了。接下来将演示如何根据这些数据快速地绘成图形,并分析基准测试过程中发生了什么。\n2.3.6 绘图的重要性 # 如果你想要统治世界,就必须不断地利用“阴谋”(9)。而最简单有效的图形,就是将性能指标按照时间顺序绘制。通过图形可以立刻发现一些问题,而这些问题在原始数据中却很难被注意到。或许你会坚持看测试工具打印出来的平均值或其他汇总过的信息,但平均值有时候是没有用的,它会掩盖掉一些真实情况。幸运的是,前面写的脚本的输出都可以定制作为gnuplot或者R绘图的数据来源。假设使用gnuplot,假设输出的数据文件名是QPS-per-5-seconds:\ngnuplot\u0026gt; plot \u0026quot;QPS-per-5-seconds\u0026quot; using 5 w lines title\u0026quot;QPS\u0026quot; 该gnuplot命令将文件的第五列qps数据绘成图形,图的标题是QPS。图2-2是绘制出来的结果图。\n图2-2:基准测试的QPS图形\n下面我们讨论一个可以更加体现图形价值的例子。假设MySQL数据正在遭受“疯狂刷新(furious flushing)”的问题,在刷新落后于检查点时会阻塞所有的活动,从而导致吞吐量严重下跌。95%的响应时间和平均响应时间指标都无法发现这个问题,也就是说这两个指标掩盖了问题。但图形会显示出这个周期性的问题,请参考图2-3。\n图2-3显示的是每分钟新订单的交易量(NOTPM,new-order transactions per minute)。从曲线可以看到明显的周期性下降,但如果从平均值(点状虚线)来看波动很小。一开始的低谷是由于系统的缓存是空的,而后面其他的下跌则是由于系统刷新脏块到磁盘导致。如果没有图形,要发现这个趋势会比较困难。\n图2-3:一个30分钟的dbt2测试的结果\n这种性能尖刺在压力大的系统比较常见,需要调查原因。在这个案例中,是由于使用了旧版本的InnoDB引擎,脏块的刷新算法性能很差。但这个结论不能是想当然的,需要认真地分析详细的性能统计。在性能下跌时,SHOW ENGINE INNODB STATUS的输出是什么?SHOW FULL PROCESSLIST的输出是什么?应该可以发现InnoDB在持续地刷新脏块,并且阻塞了很多状态是“waiting on query cache lock”的线程,或者其他类似的现象。在执行基准测试的时候要尽可能地收集更多的细节数据,然后将数据绘制成图形,这样可以帮助快速地发现问题。\n2.4 基准测试工具 # 没有必要开发自己的基准测试系统,除非现有的工具确实无法满足需求。下面的章节会介绍一些可用的工具。\n2.4.1 集成式测试工具 # 回忆一下前文提供的两种测试类型:集成式测试和单组件式测试。毫不奇怪,有些工具是针对整个应用进行测试,也有些工具是针对MySQL或者其他组件单独进行测试的。集成式测试,通常是获得整个应用概况的最佳手段。已有的集成式测试工具如下所示。\nab\nab是一个Apache HTTP服务器基准测试工具。它可以测试HTTP服务器每秒最多可以处理多少请求。如果测试的是Web应用服务,这个结果可以转换成整个应用每秒可以满足多少请求。这是个非常简单的工具,用途也有限,只能针对单个URL进行尽可能快的压力测试。关于ab的更多信息可以参考 http://httpd.apache.org/docs/2.0/programs/ab.html。\nhttp_load\n这个工具概念上和ab类似,也被设计为对Web服务器进行测试,但比ab要更加灵活。可以通过一个输入文件提供多个URL,http_load在这些URL中随机选择进行测试。也可以定制http_load,使其按照时间比率进行测试,而不仅仅是测试最大请求处理能力。更多信息请参考 http://www.acme.com/software/http-load/。\nJMeter\nJMeter是一个Java应用程序,可以加载其他应用并测试其性能。它虽然是设计用来测试Web应用的,但也可以用于测试其他诸如FTP服务器,或者通过JDBC进行数据库查询测试。\nJMeter比ab和http_load都要复杂得多。例如,它可以通过控制预热时间等参数,更加灵活地模拟真实用户的访问。JMeter拥有绘图接口(带有内置的图形化处理的功能),还可以对测试进行记录,然后离线重演测试结果。更多信息请参考 http://jakarta.apache.org/jmeter/。\n2.4.2 单组件式测试工具 # 有一些有用的工具可以测试MySQL和基于MySQL的系统的性能。2.5节将演示如何利用这些工具进行测试。\nmysqlslap\nmysqlslap( http://dev.mysql.com/doc/refman/5.1/en/mysqlslap.html)可以模拟服务器的负载,并输出计时信息。它包含在MySQL 5.1的发行包中,应该在MySQL 4.1或者更新的版本中都可以使用。测试时可以执行并发连接数,并指定SQL语句(可以在命令行上执行,也可以把SQL语句写入到参数文件中)。如果没有指定SQL语句,mysqlslap会自动生成查询schema的SELECT语句。\nMySQL Benchmark Suite(sql-bench)\n在MySQL的发行包中也提供了一款自己的基准测试套件,可以用于在不同数据库服务器上进行比较测试。它是单线程的,主要用于测试服务器执行查询的速度。结果会显示哪种类型的操作在服务器上执行得更快。\n这个测试套件的主要好处是包含了大量预定义的测试,容易使用,所以可以很轻松地用于比较不同存储引擎或者不同配置的性能测试。其也可以用于高层次测试,比较两个服务器的总体性能。当然也可以只执行预定义测试的子集(例如只测试UPDATE的性能)。这些测试大部分是CPU密集型的,但也有些短时间的测试需要大量的磁盘I/O操作。\n这个套件的最大缺点主要有:它是单用户模式的,测试的数据集很小且用户无法使用指定的数据,并且同一个测试多次运行的结果可能会相差很大。因为是单线程且串行执行的,所以无法测试多CPU的能力,只能用于比较单CPU服务器的性能差别。使用这个套件测试数据库服务器还需要Perl和BDB的支持,相关文档请参考 http://dev.mysql.com/doc/en/mysql-benchmarks.html/。\nSuper Smack\nSuper Smack( http://vegan.net/tony/supersmack/)是一款用于MySQL和PostgreSQL的基准测试工具,可以提供压力测试和负载生成。这是一个复杂而强大的工具,可以模拟多用户访问,可以加载测试数据到数据库,并支持使用随机数据填充测试表。测试定义在“smack”文件中,smack文件使用一种简单的语法定义测试的客户端、表、查询等测试要素。\nDatabase Test Suite\nDatabase Test Suite是由开源软件开发实验室(OSDL,Open Source Development Labs)设计的,发布在SourceForge网站( http://sourceforge.net/projects/osdldbt/)上,这是一款类似某些工业标准测试的测试工具集,例如由事务处理性能委员会(TPC,Transaction Processing Performance Council)制定的各种标准。特别值得一提的是,其中的dbt2就是一款免费的TPC-C OLTP测试工具(未认证)。之前本书作者经常使用该工具,不过现在已经使用自己研发的专用于MySQL的测试工具替代了。\nPercona\u0026rsquo;s TPCC-MySQL Tool\n我们开发了一个类似TPC-C的基准测试工具集,其中有部分是专门为MySQL测试开发的。在评估大压力下MySQL的一些行为时,我们经常会利用这个工具进行测试(简单的测试,一般会采用sysbench替代)。该工具的源代码可以在 https://launchpad.net/perconatools下载,在源码库中有一个简单的文档说明。\nsysbench\nsysbench( https://launchpad.net/sysbench)是一款多线程系统压测工具。它可以根据影响数据库服务器性能的各种因素来评估系统的性能。例如,可以用来测试文件I/O、操作系统调度器、内存分配和传输速度、POSIX线程,以及数据库服务器等。sysbench支持Lua脚本语言( http://www.lua.org),Lua对于各种测试场景的设置可以非常灵活。sysbench是我们非常喜欢的一种全能测试工具,支持MySQL、操作系统和硬件的硬件测试。\nMySQL的BENCHMARK()函数\nMySQL有一个内置的BENCHMARK()函数,可以测试某些特定操作的执行速度。参数可以是需要执行的次数和表达式。表达式可以是任何的标量表达式,比如返回值是标量的子查询或者函数。该函数可以很方便地测试某些特定操作的性能,比如通过测试可以发现,MD5()函数比SHA1()函数要快:\n执行后的返回值永远是0,但可以通过客户端返回的时间来判断执行的时间。在这个例子中可以看到MD5()执行比SHA1()要快。使用BENCHMARK()函数来测试性能,需要清楚地知道其原理,否则容易误用。这个函数只是简单地返回服务器执行表达式的时间,而不会涉及分析和优化的开销。而且表达式必须像这个例子一样包含用户定义的变量,否则多次执行同样的表达式会因为系统缓存命中而影响结果(10)。\n虽然BENCHMARK()函数用起来很方便,但不合适用来做真正的基准测试,因为很难理解真正要测试的是什么,而且测试的只是整个执行周期中的一部分环节。\n2.5 基准测试案例 # 本节将演示一些利用上面提到的基准测试工具进行测试的真实案例。这些案例未必涵盖所有测试工具,但应该可以帮助读者针对自己的测试需要来做出判断和选择,并作为入门的开端。\n2.5.1 http_load # 下面通过一个简单的例子来演示如何使用http_load。首先创建一个urls.txt文件,输入如下的URL:\nhttp://www.mysqlperformanceblog.com/ http://www.mysqlperformanceblog.com/page/2/ http://www.mysqlperformanceblog.com/mysql-patches/ http://www.mysqlperformanceblog.com/mysql-performance-presentations/ http://www.mysqlperformanceblog.com/2006/09/06/slow-query-log-analyzes-tools/ http_load最简单的用法,就是循环请求给定的URL列表。测试程序将以最快的速度请求这些URL:\n** $ http_load -parallel 1 -seconds 10 urls.txt** 19 fetches, 1 max parallel, 837929 bytes, in 10.0003 seconds 44101.5 mean bytes/connection 1.89995 fetches/sec, 83790.7 bytes/sec msecs/connect: 41.6647 mean, 56.156 max, 38.21 min msecs/first-response: 320.207 mean, 508.958 max, 179.308 min HTTP response codes: code 200 - 19 测试的结果很容易理解,只是简单地输出了请求的统计信息。下面是另外一个稍微复杂的测试,还是尽可能快地循环请求给定的URL列表,不过模拟同时有五个并发用户在进行请求:\n** $ http_load -parallel 5 -seconds 10 urls.txt** 94 fetches, 5 max parallel, 4.75565e+06 bytes, in 10.0005 seconds 50592 mean bytes/connection 9.39953 fetches/sec, 475541 bytes/sec msecs/connect: 65.1983 mean, 169.991 max, 38.189 min msecs/first-response: 245.014 mean, 993.059 max, 99.646 min HTTP response codes: code 200 - 94 另外,除了测试最快的速度,也可以根据预估的访问请求率(比如每秒5次)来做压力模拟测试。\n** $ http_load -rate 5 -seconds 10 urls.txt** 48 fetches, 4 max parallel, 2.50104e+06 bytes, in 10 seconds 52105 mean bytes/connection 4.8 fetches/sec, 250104 bytes/sec msecs/connect: 42.5931 mean, 60.462 max, 38.117 min msecs/first-response: 246.811 mean, 546.203 max, 108.363 min HTTP response codes: code 200 - 48 最后,还可以模拟更大的负载,可以将访问请求率提高到每秒20次请求。请注意,连接和请求响应时间都会随着负载的提高而增加。\n** $ http_load -rate 20 -seconds 10 urls.txt** 111 fetches, 89 max parallel, 5.91142e+06 bytes, in 10.0001 seconds 53256.1 mean bytes/connection 11.0998 fetches/sec, 591134 bytes/sec msecs/connect: 100.384 mean, 211.885 max, 38.214 min msecs/first-response: 2163.51 mean, 7862.77 max, 933.708 min HTTP response codes: code 200 -- 111 2.5.2 MySQL基准测试套件 # MySQL基准测试套件(MySQL Benchmark Suite)由一组基于Perl开发的基准测试工具组成。在MySQL安装目录下的sql-bench子目录中包含了该工具。比如在Debian GNU/Linux系统上,默认的路径是*/usr/share/mysql/sql-bench*。\n在用这个工具集测试前,应该读一下README文件,了解使用方法和命令行参数说明。如果要运行全部测试,可以使用如下的命令:\n** $ cd /usr/share/mysql/sql-bench/** sql-bench$ ./run-all-tests --server=mysql --user=root --log --fast Test finished. You can find the result in: output/RUN-mysql_fast-Linux_2.4.18_686_smp_i686 运行全部测试需要比较长的时间,有可能会超过一个小时,其具体长短依赖于测试的硬件环境和配置。如果指定了*\u0026ndash;log命令行,则可以监控到测试的进度。测试的结果都保存在output*子目录中,每项测试的结果文件中都会包含一系列的操作计时信息。下面是一个具体的例子,为方便印刷,部分格式做了修改。\nsql-bench$ tail −5 output/select-mysql_fast-Linux_2.4.18_686_smp_i686 Time for count_distinct_group_on_key (1000:6000): 34 wallclock secs ( 0.20 usr 0.08 sys + 0.00 cusr 0.00 csys = 0.28 CPU) Time for count_distinct_group_on_key_parts (1000:100000): 34 wallclock secs ( 0.57 usr 0.27 sys + 0.00 cusr 0.00 csys = 0.84 CPU) Time for count_distinct_group (1000:100000): 34 wallclock secs ( 0.59 usr 0.20 sys + 0.00 cusr 0.00 csys = 0.79 CPU) Time for count_distinct_big (100:1000000): 8 wallclock secs ( 4.22 usr 2.20 sys + 0.00 cusr 0.00 csys = 6.42 CPU) Total time: 868 wallclock secs (33.24 usr 9.55 sys + 0.00 cusr 0.00 csys = 42.79 CPU) 如上所示,count_distinct_group_on_key(1000:6000)测试花费了34秒(wallclock secs),这是客户端运行测试花费的总时间;其他值(包括usr,sys,cursr,csys)则占了测试的0.28秒的开销,这是运行客户端测试代码所花费的时间,而不是等待MySQL服务器响应的时间。而测试者真正需要关心的测试结果,是除去客户端控制的部分,即实际运行时间应该是33.72秒。\n除了运行全部测试集外,也可以选择单独执行其中的部分测试项。例如可以选择只执行\ninsert测试,这会比运行全部测试集所得到的汇总信息给出更多的详细信息:\nsql-bench$ ** ./test-insert** Testing server 'MySQL 4.0.13 log' at 2003-05-18 11:02:39 Testing the speed of inserting data into 1 table and do some selects on it. The tests are done with a table that has 100000 rows. Generating random keys Creating tables Inserting 100000 rows in order Inserting 100000 rows in reverse order Inserting 100000 rows in random order Time for insert (300000): 42 wallclock secs ( 7.91 usr 5.03 sys + 0.00 cusr 0.00 csys = 12.94 CPU) Testing insert of duplicates Time for insert_duplicates (100000): 16 wallclock secs ( 2.28 usr 1.89 sys + 0.00 cusr 0.00 csys = 4.17 CPU) 2.5.3 sysbench # sysbench可以执行多种类型的基准测试,它不仅设计用来测试数据库的性能,也可以测试运行数据库的服务器的性能。实际上,Peter和Vadim最初设计这个工具是用来执行MySQL性能测试的(尽管并不能完成所有的MySQL基准测试)。下面先演示一些非MySQL的测试场景,来测试各个子系统的性能,这些测试可以用来评估系统的整体性能瓶颈。后面再演示如何测试数据库的性能。\n强烈建议大家都能熟悉sysbench测试,在MySQL用户的工具包中,这应该是最有用的工具之一。尽管有其他很多测试工具可以替代sysbench的某些功能,但那些工具有时候并不可靠,获得的结果也不一定和MySQL性能相关。例如,I/O性能测试可以用iozone、bonnie++等一系列工具,但需要注意设计场景,以便可以模拟InnoDB的磁盘I/O模式。而sysbench的I/O测试则和InnoDB的I/O模式非常类似,所以fleio选项是非常好用的。\nsysbench的CPU基准测试 # 最典型的子系统测试就是CPU基准测试。该测试使用64位整数,测试计算素数直到某个最大值所需要的时间。下面的例子将比较两台不同的GNU/Linux服务器上的测试结果。第一台机器的CPU配置如下:\n[server1 ~]$ ** cat /proc/cpuinfo** ... model name : AMD Opteron(tm) Processor 246 stepping : 1 cpu MHz : 1992.857 cache size : 1024 KB 在这台服务器上运行如下的测试:\n[server1 ~]$ ** sysbench --test=cpu --cpu-max-prime=20000 run** sysbench v0.4.8: multithreaded system evaluation benchmark ... Test execution summary: ** total time:** ** 121.7404s** 第二台服务器配置了不同的CPU:\n[server2 ~]$ ** cat /proc/cpuinfo** ... model name : Intel(R) Xeon(R) CPU 5130 @ 2.00GHz stepping : 6 cpu MHz : 1995.005 测试结果如下:\n[server1 ~]$ ** sysbench --test=cpu --cpu-max-prime=20000 run** sysbench v0.4.8: multithreaded system evaluation benchmark ... Test execution summary: ** total time:** ** 61.8596s** 测试的结果简单打印出了计算出素数的时间,很容易进行比较。在上面的测试中,第二台服务器的测试结果显示比第一台快两倍。\nsysbench的文件I/O基准测试 # 文件I/O(fileio)基准测试可以测试系统在不同I/O负载下的性能。这对于比较不同的硬盘驱动器、不同的RAID卡、不同的RAID模式,都很有帮助。可以根据测试结果来调整I/O子系统。文件I/O基准测试模拟了很多InnoDB的I/O特性。\n测试的第一步是准备(prepare)阶段,生成测试用到的数据文件,生成的数据文件至少要比内存大。如果文件中的数据能完全放入内存中,则操作系统缓存大部分的数据,导致测试结果无法体现I/O密集型的工作负载。首先通过下面的命令创建一个数据集:\n** $ sysbench --test=fileio --file-total-size=150G prepare** 这个命令会在当前工作目录下创建测试文件,后续的运行(run)阶段将通过读写这些文件进行测试。第二步就是运行(run)阶段,针对不同的I/O类型有不同的测试选项:\nseqwr\n顺序写入。\nseqrewr\n顺序重写。\nseqrd\n顺序读取。\nrndrd\n随机读取。\nrndwr\n随机写入。\nrdnrw\n混合随机读/写。\n下面的命令运行文件I/O混合随机读/写基准测试:\n** $ sysbench --test=fileio --file-total-size=150G --file-test-mode=rndrw/** ** --init-rng=on--max-time=300--max-requests=0 run** 结果如下:\nsysbench v0.4.8: multithreaded system evaluation benchmark Running the test with following options: Number of threads: 1 Initializing random number generator from timer. Extra file open flags: 0 128 files, 1.1719Gb each 150Gb total file size Block size 16Kb Number of random requests for random IO: 10000 Read/Write ratio for combined random IO test: 1.50 Periodic FSYNC enabled, calling fsync() each 100 requests. Calling fsync() at the end of test, Enabled. Using synchronous I/O mode Doing random r/w test Threads started! Time limit exceeded, exiting... Done. Operations performed: 40260 Read, 26840 Write, 85785 Other = 152885 Total Read 629.06Mb Written 419.38Mb Total transferred 1.0239Gb (3.4948Mb/sec) 223.67 Requests/sec executed Test execution summary: total time: 300.0004s total number of events: 67100 total time taken by event execution: 254.4601 per-request statistics: min: 0.0000s avg: 0.0038s max: 0.5628s approx. 95 percentile: 0.0099s Threads fairness: events (avg/stddev): 67100.0000/0.00 execution time (avg/stddev): 254.4601/0.00 输出结果中包含了大量的信息。和I/O子系统密切相关的包括每秒请求数和总吞吐量。在上述例子中,每秒请求数是223.67 Requests/sec,吞吐量是3.4948MB/sec。另外,时间信息也非常有用,尤其是大约95%的时间分布。这些数据对于评估磁盘性能十分有用。\n测试完成后,运行清除(cleanup)操作删除第一步生成的测试文件:\n** $ sysbench --test=fileio --file-total-size=150G cleanup** sysbench的OLTP基准测试 # OLTP基准测试模拟了一个简单的事务处理系统的工作负载。下面的例子使用的是一张超过百万行记录的表,第一步是先生成这张表:\n** $ sysbench --test=oltp --oltp-table-size=1000000 --mysql-db=test/** ** --mysql-user=root prepare** sysbench v0.4.8: multithreaded system evaluation benchmark No DB drivers specified, using mysql Creating table 'sbtest'... Creating 1000000 records in table 'sbtest'... 生成测试数据只需要上面这条简单的命令即可。接下来可以运行测试,这个例子采用了8个并发线程,只读模式,测试时长60秒:\n** $ sysbench --test=oltp --oltp-table-size=1000000 --mysql-db=test --mysql-user=root/** ** --max-time=60 --oltp-read-only=on --max-requests=0 --num-threads=8 run** sysbench v0.4.8: multithreaded system evaluation benchmark No DB drivers specified, using mysql WARNING: Preparing of \u0026quot;BEGIN\u0026quot; is unsupported, using emulation (last message repeated 7 times) Running the test with following options: Number of threads: 8 Doing OLTP test. Running mixed OLTP test Doing read-only test Using Special distribution (12 iterations, 1 pct of values are returned in 75 pct cases) Using \u0026quot;BEGIN\u0026quot; for starting transactions Using auto_inc on the id column Threads started! Time limit exceeded, exiting... (last message repeated 7 times) Done. OLTP test statistics: queries performed: read: 179606 write: 0 other: 25658 total: 205264 transactions: 12829 (213.07 per sec.) deadlocks: 0 (0.00 per sec.) read/write requests: 179606 (2982.92 per sec.) other operations: 25658 (426.13 per sec.) Test execution summary: total time: 60.2114s total number of events: 12829 total time taken by event execution: 480.2086 per-request statistics: min: 0.0030s avg: 0.0374s max: 1.9106s approx. 95 percentile: 0.1163s Threads fairness: events (avg/stddev): 1603.6250/70.66 execution time (avg/stddev): 60.0261/0.06 如上所示,结果中包含了相当多的信息。其中最有价值的信息如下:\n总的事务数。 每秒事务数。 时间统计信息(最小、平均、最大响应时间,以及95%百分比响应时间)。 线程公平性统计信息(thread-fairness),用于表示模拟负载的公平性。 这个例子使用的是sysbench的第4版,在SourceForge.net可以下载到这个版本的编译好的可执行文件。也可以从Launchpad下载最新的第5版的源代码自行编译(这是一件简单、有用的事情),这样就可以利用很多新版本的特性,包括可以基于多个表而不是单个表进行测试,可以每隔一定的间隔比如10秒打印出吞吐量和响应的结果。这些指标对于理解系统的行为非常重要。\nsysbench的其他特性 # sysbench还有一些其他的基准测试,但和数据库性能没有直接关系。\nmemory内存(memory)\n测试内存的连续读写性能。\n线程(thread)\n测试线程调度器的性能。对于高负载情况下测试线程调度器的行为非常有用。\n互斥锁(mutex)\n测试互斥锁(mutex)的性能,方式是模拟所有线程在同一时刻并发运行,并都短暂请求互斥锁(互斥锁mutex是一种数据结构,用来对某些资源进行排他性访问控制,防止因并发访问导致问题)。\n顺序写(seqwr)\n测试顺序写的性能。这对于测试系统的实际性能瓶颈很重要。可以用来测试RAID控制器的高速缓存的性能状况,如果测试结果异常则需要引起重视。例如,如果RAID控制器写缓存没有电池保护,而磁盘的压力达到了3000次请求/秒,就是一个问题,数据可能是不安全的。\n另外,除了指定测试模式参数(\u0026ndash;test)外,sysbench还有其他很多参数,比如*\u0026ndash;num-threads、\u0026ndash;max-requests和\u0026ndash;max-time*参数,更多信息请查阅相关文档。\n2.5.4 数据库测试套件中的dbt2 TPC-C测试 # 数据库测试套件(Database Test Suite)中的dbt2是一款免费的TPC-C测试工具。TPC-C是TPC组织发布的一个测试规范,用于模拟测试复杂的在线事务处理系统(OLTP)。它的测试结果包括每分钟事务数(tpmC),以及每事务的成本(Price/tpmC)。这种测试的结果非常依赖硬件环境,所以公开发布的TPC-C测试结果都会包含具体的系统硬件配置信息。\ndbt2并不是真正的TPC-C测试,它没有得到TPC组织的认证,它的结果不能直接跟TPC-C的结果做对比。而且本书作者开发了一款比dbt2更好的测试工具,详细情况见2.5.5节。\n下面看一个设置和运行dbt2基准测试的例子。这里使用的是dbt2 0.37版本,这个版本能够支持MySQL的最新版本(还有更新的版本,但包含了一些MySQL不能提供完全支持的修正)。下面是测试步骤。\n1.准备测试数据。\n下面的命令会在指定的目录创建用于10个仓库的数据。每个仓库使用大约700MB磁盘空间,测试所需要的总的磁盘空间和仓库的数量成正比。因此,可以通过-w参数来调整仓库的个数以生成合适大小的数据集。\n** # src/datagen -w 10 -d /mnt/data/dbt2-w10** warehouses = 10 districts = 10 customers = 3000 items = 100000 orders = 3000 stock = 100000 new_orders = 900 Output directory of data files: /mnt/data/dbt2-w10 Generating data files for 10 warehouse(s)... Generating item table data... Finished item table data... Generating warehouse table data... Finished warehouse table data... Generating stock table data... 2.加载数据到MySQL数据库。\n下面的命令创建一个名为dbt2w10的数据库,并且将上一步生成的测试数据加载到数据库中(-d参数指定数据库,-f参数指定测试数据所在的目录)。\n** # scripts/mysql/mysql_load_db.sh -d dbt2w10 -f /mnt/data/dbt2-w10/** ** -s /var/lib/mysql/mysql.sock** 3.运行测试。\n最后一步是运行scripts脚本目录中的如下命令执行测试:\n最重要的结果是输出信息中末尾处的一行:\n3396.95 new-order transactions per minute(NOTPM) 这里显示了系统每分钟可以处理的最大事务数,越大越好(new-order并非一种事务类型的专用术语,它只是表明测试是模拟用户在假想的电子商务网站下的新订单)。\n通过修改某些参数可以定制不同的基准测试。\n-c\n到数据库的连接数。修改该参数可以模拟不同程度的并发性,以测试系统的可扩展性。-e\n-e\n启用零延迟(zero-delay)模式,这意味着在不同查询之间没有时间延迟。这可以对数据库施加更大的压力,但不符合真实情况。因为真实的用户在执行一个新查询前总需要一个“思考时间(think time)”。\n-t\n基准测试的持续时间。这个参数应该精心设置,否则可能导致测试的结果是无意义的。对于I/O密集型的基准测试,太短的持续时间会导致错误的结果,因为系统可能还没有足够的时间对缓存进行预热。而对于CPU密集型的基准测试,这个时间又不应该设置得太长;否则生成的数据量过大,可能转变成I/O密集型。\n这种基准测试的结果,可以比单纯的性能测试提供更多的信息。例如,如果发现测试有很多的回滚现象,那么就可以判定很可能什么地方出现错误了。\n2.5.5 Percona的TPCC-MySQL测试工具 # 尽管sysbench的测试很简单,并且结果也具有可比性,但毕竟无法模拟真实的业务压力。相比而言,TPC-C测试则能模拟真实压力。2.5.4节谈到的dbt2是TPC-C的一个很好的实现,但也还有一些不足之处。为了满足很多大型基准测试的需求,本书的作者重新开发了一款新的类TPC-C测试工具,代码放在Launchpad上,可以通过如下地址获取: https://code.launchpad.net/~percona-dev/perconatools/tpcc-mysql,其中包含了一个README文件说明了如何编译。该工具使用很简单,但测试数据中的仓库数量很多,可能需要用到其中的并行数据加载工具来加快准备测试数据集的速度,否则这一步会花费很长时间。\n使用这个测试工具,需要创建数据库和表结构、加载数据、执行测试三个步骤。数据库和表结构通过包含在源码中的SQL脚本创建。加载数据通过用C写的tpcc_load工具完成,该工具需要自行编译。加载数据需要执行一段时间,并且会产生大量的输出信息(一般都应该将程序输出重定向到文件中,这里尤其应该如此,否则可能丢失滚动的历史信息)。下面的例子显示了配置过程,创建了一个小型(五个仓库)的测试数据集,数据库名为tpcc5。\n\u0026lt;b\u0026gt;$ ./tpcc_load localhost tpcc5 username p4ssword 5\u0026lt;/b\u0026gt; ************************************* *** ###easy### TPC-C Data Loader *** ************************************* \u0026lt;Parameters\u0026gt; [server]: localhost [port]: 3306 [DBname]: tpcc5 [user]: username [pass]: p4ssword [warehouse]: 5 TPCC Data Load Started... Loading Item .................................................. 5000 .................................................. 10000 .................................................. 15000 [output snipped for brevity] Loading Orders for D=10, W= 5 .......... 1000 .......... 2000 .......... 3000 Orders Done. ...DATA LOADING COMPLETED SUCCESSFULLY. 然后,使用tpcc_start工具开始执行基准测试。其同样会产生很多输出信息,还是建议重定向到文件中。下面是一个简单的示例,使用五个线程操作五个仓库,30秒预热时间,30秒测试时间:\n** $ ./tpcc_start localhost tpcc5 username p4ssword 5 5 30 30** *************************************** *** ###easy### TPC-C Load Generator *** *************************************** \u0026lt;Parameters\u0026gt; [server]: localhost [port]: 3306 [DBname]: tpcc5 [user]: username [pass]: p4ssword [warehouse]: 5 [connection]: 5 [rampup]: 30 (sec.) [measure]: 30 (sec.) RAMP-UP TIME.(30 sec.) MEASURING START. 10, 63(0):0.40, 63(0):0.42, 7(0):0.76, 6(0):2.60, 6(0):0.17 20, 75(0):0.40, 74(0):0.62, 7(0):0.04, 9(0):2.38, 7(0):0.75 30, 83(0):0.22, 84(0):0.37, 9(0):0.04, 7(0):1.97, 9(0):0.80 STOPPING THREADS..... \u0026lt;RT Histogram\u0026gt; 1.New-Order 2.Payment 3.Order-Status 4.Delivery 5.Stock-Level \u0026lt;90th Percentile RT (MaxRT)\u0026gt; New-Order : 0.37 (1.10) Payment : 0.47 (1.24) Order-Status : 0.06 (0.96) Delivery : 2.43 (2.72) Stock-Level : 0.75 (0.79) \u0026lt;Raw Results\u0026gt; [0] sc:221 lt:0 rt:0 fl:0 [1] sc:221 lt:0 rt:0 fl:0 [2] sc:23 lt:0 rt:0 fl:0 [3] sc:22 lt:0 rt:0 fl:0 [4] sc:22 lt:0 rt:0 fl:0 in 30 sec. \u0026lt;Raw Results2(sum ver.)\u0026gt; [0] sc:221 lt:0 rt:0 fl:0 [1] sc:221 lt:0 rt:0 fl:0 [2] sc:23 lt:0 rt:0 fl:0 [3] sc:22 lt:0 rt:0 fl:0 [4] sc:22 lt:0 rt:0 fl:0 \u0026lt;Constraint Check\u0026gt; (all must be [OK]) [transaction percentage] Payment: 43.42% (\u0026gt;=43.0%) [OK] Order-Status: 4.52% (\u0026gt;= 4.0%) [OK] Delivery: 4.32% (\u0026gt;= 4.0%) [OK] Stock-Level: 4.32% (\u0026gt;= 4.0%) [OK] [response time (at least 90% passed)] New-Order: 100.00% [OK] Payment: 100.00% [OK] Order-Status: 100.00% [OK] Delivery: 100.00% [OK] Stock-Level: 100.00% [OK] \u0026lt;TpmC\u0026gt; 442.000 TpmC 最后一行就是测试的结果:每分钟执行完的事务数(11)。如果紧挨着最后一行前发现有异常结果输出,比如有关于约束检查的信息,那么可以检查一下响应时间的直方图,或者通过其他详细输出信息寻找线索。当然,最好是能使用本章前面提到的一些脚本,这样就可以很容易获得测试执行期间的详细的诊断数据和性能数据。\n2.6 总结 # 每个MySQL的使用者都应该了解一些基准测试的知识。基准测试不仅仅是用来解决业务问题的一种实践行动,也是一种很好的学习方法。学习如何将问题分解成可以通过基准测试来获得答案的方法,就和在数学课上从文字题目中推导出方程式一样。首先正确地描述问题,之后选择合适的基准测试来回答问题,设置基准测试的持续时间和参数,运行测试,收集数据,分析结果数据,这一系列的训练可以帮助你成为更好的MySQL用户。\n如果你还没有做过基准测试,那么建议至少要熟悉sysbench。可以先学习如何使用oltp和fileio测试。oltp基准测试可以很方便地比较不同系统的性能。另一方面,文件系统和磁盘基准测试,则可以在系统出现问题时有效地诊断和隔离异常的组件。通过这样的基准测试,我们多次发现了一些数据库管理员的说法存在问题,比如SAN存储真的出现了一块坏盘,或者RAID控制器的缓存策略的配置并不是像工具中显示的那样。通过对单块磁盘进行基准测试,如果发现每秒可以执行14000次随机读,那要么是碰到了严重的错误,要么是配置出现了问题(12)。\n如果经常执行基准测试,那么制定一些原则是很有必要的。选择一些合适的测试工具并深入地学习。可以建立一个脚本库,用于配置基准测试,收集输出结果、系统性能和状态信息,以及分析结果。使用一种熟练的绘图工具如gnuplot或者R(不用浪费时间使用电子表格,它们既笨重,速度又慢)。尽量早和多地使用绘图的方式,来发现基准测试和系统中的问题和错误。你的眼睛是比任何脚本和自动化工具都更有效的发现问题的工具。\n————————————————————\n(1) 特别是一些论坛软件,已经让很多管理员错误地相信同时有成千上万的用户正在同时访问网站。\n(2) Justin Bieber,我们爱你。这只是开个玩笑。\n(3) 当然,做这么多的前提是希望获得完美的基准测试结果,实际情况通常不会很顺利。\n(4) 顺便说一下,写I/O的活动图展示的性能非常差。这个系统的稳定状态从性能上来说是一种灾难。已经达到“稳定”可以说是笑话,不过这里我们的重点在于说明系统的长期行为。\n(5) 关于pt-diskstats工具的更多信息,请参考第9章。\n(6) 有时,这并不是问题。例如,如果正在考虑从基于SPARC的Solaris系统迁移到基于x86的GNU/Linux系统,就没有必要测试基于x86的Solaris作为中间过程。\n(7) 本书的任何一位作者都还没发生过这样的事情,仅供参考。\n(8) 如果真的需要科学可靠的结果,应该去读读关于如何设计和执行可控测试的书籍,这个已经超出了本书讨论的范畴。\n(9) 英语中plot既有“阴谋”的意思,也有“绘图”的意思,所以这里是一句双关语。——译者注\n(10) 本书作者之一碰到了这个问题,因为发现循环执行1000次表达式和只执行一次表达式的时间居然差不多,这只能说明缓存命中了。实际上,当碰到此类情况时,第一反应就应当是缓存命中或者出错了。\n(11) 我们是在笔记本电脑上运行这个基准测试的,这只是作为演示用的。真实服务器的速度肯定比这快得多。\n(12) 一块机械磁盘每秒只能执行几百次的随机读操作,因为寻道操作是需要时间的。\n"},{"id":146,"href":"/zh/docs/technology/MySQL/_%E9%AB%98%E6%80%A7%E8%83%BDMySQL_/%E7%AC%AC1%E7%AB%A0MySQL%E6%9E%B6%E6%9E%84%E4%B8%8E%E5%8E%86%E5%8F%B2/","title":"第1章MySQL架构与历史","section":"高性能 My SQL","content":"第1章 MySQL架构与历史\n和其他数据库系统相比,MySQL有点与众不同,它的架构可以在多种不同场景中应用并发挥好的作用,但同时也会带来一点选择上的困难。MySQL并不完美,却足够灵活,能够适应高要求的环境,例如Web类应用。同时,MySQL既可以嵌入到应用程序中,也可以支持数据仓库、内容索引和部署软件、高可用的冗余系统、在线事务处理系统(OLTP)等各种应用类型。\n为了充分发挥MySQL的性能并顺利地使用,就必须理解其设计。MySQL的灵活性体现在很多方面。例如,你可以通过配置使它在不同的硬件上都运行得很好,也可以支持多种不同的数据类型。但是,MySQL最重要、最与众不同的特性是它的存储引擎架构,这种架构的设计将查询处理(Query Processing)及其他系统任务(Server Task)和数据的存储/提取相分离。这种处理和存储分离的设计可以在使用时根据性能、特性,以及其他需求来选择数据存储的方式。\n本章概要地描述了MySQL的服务器架构、各种存储引擎之间的主要区别,以及这些区别的重要性。另外也会回顾一下MySQL的历史背景和基准测试,并试图通过简化细节和演示案例来讨论MySQL的原理。这些讨论无论是对数据库一无所知的新手,还是熟知其他数据库的专家,都不无裨益。\n1.1 MySQL逻辑架构 # 如果能在头脑中构建出一幅MySQL各组件之间如何协同工作的架构图,就会有助于深入理解MySQL服务器。图1-1展示了MySQL的逻辑架构图。\n图1-1:MySQL服务器逻辑架构图\n最上层的服务并不是MySQL所独有的,大多数基于网络的客户端/服务器的工具或者服务都有类似的架构。比如连接处理、授权认证、安全等等。\n第二层架构是MySQL比较有意思的部分。大多数MySQL的核心服务功能都在这一层,包括查询解析、分析、优化、缓存以及所有的内置函数(例如,日期、时间、数学和加密函数),所有跨存储引擎的功能都在这一层实现:存储过程、触发器、视图等。\n第三层包含了存储引擎。存储引擎负责MySQL中数据的存储和提取。和GNU/Linux下的各种文件系统一样,每个存储引擎都有它的优势和劣势。服务器通过API与存储引擎进行通信。这些接口屏蔽了不同存储引擎之间的差异,使得这些差异对上层的查询过程透明。存储引擎API包含几十个底层函数,用于执行诸如“开始一个事务”或者“根据主键提取一行记录”等操作。但存储引擎不会去解析SQL(1),不同存储引擎之间也不会相互通信,而只是简单地响应上层服务器的请求。\n1.1.1 连接管理与安全性 # 每个客户端连接都会在服务器进程中拥有一个线程,这个连接的查询只会在这个单独的线程中执行,该线程只能轮流在某个CPU核心或者CPU中运行。服务器会负责缓存线程,因此不需要为每一个新建的连接创建或者销毁线程(2)。\n当客户端(应用)连接到MySQL服务器时,服务器需要对其进行认证。认证基于用户名、原始主机信息和密码。如果使用了安全套接字(SSL)的方式连接,还可以使用X.509证书认证。一旦客户端连接成功,服务器会继续验证该客户端是否具有执行某个特定查询的权限(例如,是否允许客户端对world数据库的Country表执行SELECT语句)。\n1.1.2 优化与执行 # MySQL会解析查询,并创建内部数据结构(解析树),然后对其进行各种优化,包括重写查询、决定表的读取顺序,以及选择合适的索引等。用户可以通过特殊的关键字提示(hint)优化器,影响它的决策过程。也可以请求优化器解释(explain)优化过程的各个因素,使用户可以知道服务器是如何进行优化决策的,并提供一个参考基准,便于用户重构查询和schema、修改相关配置,使应用尽可能高效运行。第6章我们将讨论更多优化器的细节。\n优化器并不关心表使用的是什么存储引擎,但存储引擎对于优化查询是有影响的。优化器会请求存储引擎提供容量或某个具体操作的开销信息,以及表数据的统计信息等。例如,某些存储引擎的某种索引,可能对一些特定的查询有优化。关于索引与schema的优化,请参见第4章和第5章。\n对于SELECT语句,在解析查询之前,服务器会先检查查询缓存(Query Cache),如果能够在其中找到对应的查询,服务器就不必再执行查询解析、优化和执行的整个过程,而是直接返回查询缓存中的结果集。第7章详细讨论了相关内容。\n1.2 并发控制 # 无论何时,只要有多个查询需要在同一时刻修改数据,都会产生并发控制的问题。本章的目的是讨论MySQL在两个层面的并发控制:服务器层与存储引擎层。并发控制是一个内容庞大的话题,有大量的理论文献对其进行过详细的论述。本章只简要地讨论MySQL如何控制并发读写,因此读者需要有相关的知识来理解本章接下来的内容。\n以Unix系统的email box为例,典型的mbox文件格式是非常简单的。一个mbox邮箱中的所有邮件都串行在一起,彼此首尾相连。这种格式对于读取和分析邮件信息非常友好,同时投递邮件也很容易,只要在文件末尾附加新的邮件内容即可。\n但如果两个进程在同一时刻对同一个邮箱投递邮件,会发生什么情况?显然,邮箱的数据会被破坏,两封邮件的内容会交叉地附加在邮箱文件的末尾。设计良好的邮箱投递系统会通过锁(lock)来防止数据损坏。如果客户试图投递邮件,而邮箱已经被其他客户锁住,那就必须等待,直到锁释放才能进行投递。\n这种锁的方案在实际应用环境中虽然工作良好,但并不支持并发处理。因为在任意一个时刻,只有一个进程可以修改邮箱的数据,这在大容量的邮箱系统中是个问题。\n1.2.1 读写锁 # 从邮箱中读取数据没有这样的麻烦,即使同一时刻多个用户并发读取也不会有什么问题。因为读取不会修改数据,所以不会出错。但如果某个客户正在读取邮箱,同时另外一个用户试图删除编号为25的邮件,会产生什么结果?结论是不确定,读的客户可能会报错退出,也可能读取到不一致的邮箱数据。所以,为安全起见,即使是读取邮箱也需要特别注意。\n如果把上述的邮箱当成数据库中的一张表,把邮件当成表中的一行记录,就很容易看出,同样的问题依然存在。从很多方面来说,邮箱就是一张简单的数据库表。修改数据库表中的记录,和删除或者修改邮箱中的邮件信息,十分类似。\n解决这类经典问题的方法就是并发控制,其实非常简单。在处理并发读或者写时,可以通过实现一个由两种类型的锁组成的锁系统来解决问题。这两种类型的锁通常被称为共享锁(shared lock)和排他锁(exclusive lock),也叫读锁(read lock)和写锁(write lock)。\n这里先不讨论锁的具体实现,描述一下锁的概念如下:读锁是共享的,或者说是相互不阻塞的。多个客户在同一时刻可以同时读取同一个资源,而互不干扰。写锁则是排他的,也就是说一个写锁会阻塞其他的写锁和读锁,这是出于安全策略的考虑,只有这样,才能确保在给定的时间里,只有一个用户能执行写入,并防止其他用户读取正在写入的同一资源。\n在实际的数据库系统中,每时每刻都在发生锁定,当某个用户在修改某一部分数据时,MySQL会通过锁定防止其他用户读取同一数据。大多数时候,MySQL锁的内部管理都是透明的。\n1.2.2 锁粒度 # 一种提高共享资源并发性的方式就是让锁定对象更有选择性。尽量只锁定需要修改的部分数据,而不是所有的资源。更理想的方式是,只对会修改的数据片进行精确的锁定。任何时候,在给定的资源上,锁定的数据量越少,则系统的并发程度越高,只要相互之间不发生冲突即可。\n问题是加锁也需要消耗资源。锁的各种操作,包括获得锁、检查锁是否已经解除、释放锁等,都会增加系统的开销。如果系统花费大量的时间来管理锁,而不是存取数据,那么系统的性能可能会因此受到影响。\n所谓的锁策略,就是在锁的开销和数据的安全性之间寻求平衡,这种平衡当然也会影响到性能。大多数商业数据库系统没有提供更多的选择,一般都是在表上施加行级锁(row-level lock),并以各种复杂的方式来实现,以便在锁比较多的情况下尽可能地提供更好的性能。\n而MySQL则提供了多种选择。每种MySQL存储引擎都可以实现自己的锁策略和锁粒度。在存储引擎的设计中,锁管理是个非常重要的决定。将锁粒度固定在某个级别,可以为某些特定的应用场景提供更好的性能,但同时却会失去对另外一些应用场景的良好支持。好在MySQL支持多个存储引擎的架构,所以不需要单一的通用解决方案。下面将介绍两种最重要的锁策略。\n表锁(table lock) # 表锁是MySQL中最基本的锁策略,并且是开销最小的策略。表锁非常类似于前文描述的邮箱加锁机制:它会锁定整张表。一个用户在对表进行写操作(插入、删除、更新等)前,需要先获得写锁,这会阻塞其他用户对该表的所有读写操作。只有没有写锁时,其他读取的用户才能获得读锁,读锁之间是不相互阻塞的。\n在特定的场景中,表锁也可能有良好的性能。例如,READ LOCAL表锁支持某些类型的并发写操作。另外,写锁也比读锁有更高的优先级,因此一个写锁请求可能会被插入到读锁队列的前面(写锁可以插入到锁队列中读锁的前面,反之读锁则不能插入到写锁的前面)。\n尽管存储引擎可以管理自己的锁,MySQL本身还是会使用各种有效的表锁来实现不同的目的。例如,服务器会为诸如ALTER TABLE之类的语句使用表锁,而忽略存储引擎的锁机制。\n行级锁(row lock) # 行级锁可以最大程度地支持并发处理(同时也带来了最大的锁开销)。众所周知,在InnoDB和XtraDB,以及其他一些存储引擎中实现了行级锁。行级锁只在存储引擎层实现,而MySQL服务器层(如有必要,请回顾前文的逻辑架构图)没有实现。服务器层完全不了解存储引擎中的锁实现。在本章的后续内容以及全书中,所有的存储引擎都以自己的方式显现了锁机制。\n1.3 事务 # 在理解事务的概念之前,接触数据库系统的其他高级特性还言之过早。事务就是一组原子性的SQL查询,或者说一个独立的工作单元。如果数据库引擎能够成功地对数据库应用该组查询的全部语句,那么就执行该组查询。如果其中有任何一条语句因为崩溃或其他原因无法执行,那么所有的语句都不会执行。也就是说,事务内的语句,要么全部执行成功,要么全部执行失败。\n本节的内容并非专属于MySQL,如果读者已经熟悉了事务的ACID的概念,可以直接跳转到1.3.4节。\n银行应用是解释事务必要性的一个经典例子。假设一个银行的数据库有两张表:支票(checking)表和储蓄(savings)表。现在要从用户Jane的支票账户转移200美元到她的储蓄账户,那么需要至少三个步骤:\n检查支票账户的余额高于200美元。 从支票账户余额中减去200美元。 在储蓄账户余额中增加200美元。 上述三个步骤的操作必须打包在一个事务中,任何一个步骤失败,则必须回滚所有的步骤。\n可以用START TRANSACTION语句开始一个事务,然后要么使用COMMIT提交事务将修改的数据持久保留,要么使用ROLLBACK撤销所有的修改。事务SQL的样本如下:\n1 START TRANSACTION; 2 SELECT balance FROM checking WHERE customer_id = 10233276; 3 UPDATE checking SET balance = balance - 200.00 WHERE customer_id = 10233276; 4 UPDATE savings SET balance = balance + 200.00 WHERE customer_id = 10233276; 5 COMMIT; 单纯的事务概念并不是故事的全部。试想一下,如果执行到第四条语句时服务器崩溃了,会发生什么?天知道,用户可能会损失200美元。再假如,在执行到第三条语句和第四条语句之间时,另外一个进程要删除支票账户的所有余额,那么结果可能就是银行在不知道这个逻辑的情况下白白给了Jane 200美元。\n除非系统通过严格的ACID测试,否则空谈事务的概念是不够的。ACID表示原子性(atomicity)、一致性(consistency)、隔离性(isolation)和持久性(durability)。一个运行良好的事务处理系统,必须具备这些标准特征。\n原子性(atomicity)\n一个事务必须被视为一个不可分割的最小工作单元,整个事务中的所有操作要么全部提交成功,要么全部失败回滚,对于一个事务来说,不可能只执行其中的一部分操作,这就是事务的原子性。\n一致性(consistency)\n数据库总是从一个一致性的状态转换到另外一个一致性的状态。在前面的例子中,一致性确保了,即使在执行第三、四条语句之间时系统崩溃,支票账户中也不会损失200美元,因为事务最终没有提交,所以事务中所做的修改也不会保存到数据库中。\n隔离性(isolation)\n通常来说,一个事务所做的修改在最终提交以前,对其他事务是不可见的。在前面的例子中,当执行完第三条语句、第四条语句还未开始时,此时有另外一个账户汇总程序开始运行,则其看到的支票账户的余额并没有被减去200美元。后面我们讨论隔离级别(Isolation level)的时候,会发现为什么我们要说“通常来说”是不可见的。\n持久性(durability)\n一旦事务提交,则其所做的修改就会永久保存到数据库中。此时即使系统崩溃,修改的数据也不会丢失。持久性是个有点模糊的概念,因为实际上持久性也分很多不同的级别。有些持久性策略能够提供非常强的安全保障,而有些则未必。而且不可能有能做到100%的持久性保证的策略(如果数据库本身就能做到真正的持久性,那么备份又怎么能增加持久性呢?)。在后面的一些章节中,我们会继续讨论MySQL中持久性的真正含义。\n事务的ACID特性可以确保银行不会弄丢你的钱。而在应用逻辑中,要实现这一点非常难,甚至可以说是不可能完成的任务。一个兼容ACID的数据库系统,需要做很多复杂但可能用户并没有觉察到的工作,才能确保ACID的实现。\n就像锁粒度的升级会增加系统开销一样,这种事务处理过程中额外的安全性,也会需要数据库系统做更多的额外工作。一个实现了ACID的数据库,相比没有实现ACID的数据库,通常会需要更强的CPU处理能力、更大的内存和更多的磁盘空间。正如本章不断重复的,这也正是MySQL的存储引擎架构可以发挥优势的地方。用户可以根据业务是否需要事务处理,来选择合适的存储引擎。对于一些不需要事务的查询类应用,选择一个非事务型的存储引擎,可以获得更高的性能。即使存储引擎不支持事务,也可以通过LOCK TABLES语句为应用提供一定程度的保护,这些选择用户都可以自主决定。\n1.3.1 隔离级别 # 隔离性其实比想象的要复杂。在SQL标准中定义了四种隔离级别,每一种级别都规定了一个事务中所做的修改,哪些在事务内和事务间是可见的,哪些是不可见的。较低级别的隔离通常可以执行更高的并发,系统的开销也更低。\n每种存储引擎实现的隔离级别不尽相同。如果熟悉其他的数据库产品,可能会发现某些特性和你期望的会有些不一样(但本节不打算讨论更详细的内容)。读者可以根据所选择的存储引擎,查阅相关的手册。\n下面简单地介绍一下四种隔离级别。\nREAD UNCOMMITTED(未提交读)\n在READ UNCOMMITTED级别,事务中的修改,即使没有提交,对其他事务也都是可见的。事务可以读取未提交的数据,这也被称为脏读(Dirty Read)。这个级别会导致很多问题,从性能上来说,READ UNCOMMITTED不会比其他的级别好太多,但却缺乏其他级别的很多好处,除非真的有非常必要的理由,在实际应用中一般很少使用。\nREAD COMMITTED(提交读)\n大多数数据库系统的默认隔离级别都是READ COMMITTED(但MySQL不是)。READ COMMITTED满足前面提到的隔离性的简单定义:一个事务开始时,只能“看见”已经提交的事务所做的修改。换句话说,一个事务从开始直到提交之前,所做的任何修改对其他事务都是不可见的。这个级别有时候也叫做不可重复读(nonrepeatable read),因为两次执行同样的查询,可能会得到不一样的结果。\nREPEATABLE READ(可重复读)\nREPEATABLE READ解决了脏读的问题。该级别保证了在同一个事务中多次读取同样记录的结果是一致的。但是理论上,可重复读隔离级别还是无法解决另外一个幻读(Phantom Read)的问题。所谓幻读,指的是当某个事务在读取某个范围内的记录时,另外一个事务又在该范围内插入了新的记录,当之前的事务再次读取该范围的记录时,会产生幻行(Phantom Row)。InnoDB和XtraDB存储引擎通过多版本并发控制(MVCC,Multiversion Concurrency Control)解决了幻读的问题。本章稍后会做进一步的讨论。\n可重复读是MySQL的默认事务隔离级别。\nSERIALIZABLE(可串行化)\nSERIALIZABLE是最高的隔离级别。它通过强制事务串行执行,避免了前面说的幻读的问题。简单来说,SERIALIZABLE会在读取的每一行数据上都加锁,所以可能导致大量的超时和锁争用的问题。实际应用中也很少用到这个隔离级别,只有在非常需要确保数据的一致性而且可以接受没有并发的情况下,才考虑采用该级别。\n表1-1:ANSI SQL隔离级别 1.3.2 死锁 # 死锁是指两个或者多个事务在同一资源上相互占用,并请求锁定对方占用的资源,从而导致恶性循环的现象。当多个事务试图以不同的顺序锁定资源时,就可能会产生死锁。多个事务同时锁定同一个资源时,也会产生死锁。例如,设想下面两个事务同时处理StockPrice表:\n事务1\nSTART TRANSACTION; UPDATE StockPrice SET close = 45.50 WHERE stock_id = 4 and date = '2002-05-01'; UPDATE StockPrice SET close = 19.80 WHERE stock_id = 3 and date = '2002-05-02'; COMMIT; 事务2\nSTART TRANSACTION; UPDATE StockPrice SET high = 20.12 WHERE stock_id = 3 and date = '2002-05-02'; UPDATE StockPrice SET high = 47.20 WHERE stock_id = 4 and date = '2002-05-01'; COMMIT; 如果凑巧,两个事务都执行了第一条UPDATE语句,更新了一行数据,同时也锁定了该行数据,接着每个事务都尝试去执行第二条UPDATE语句,却发现该行已经被对方锁定,然后两个事务都等待对方释放锁,同时又持有对方需要的锁,则陷入死循环。除非有外部因素介入才可能解除死锁。\n为了解决这种问题,数据库系统实现了各种死锁检测和死锁超时机制。越复杂的系统,比如InnoDB存储引擎,越能检测到死锁的循环依赖,并立即返回一个错误。这种解决方式很有效,否则死锁会导致出现非常慢的查询。还有一种解决方式,就是当查询的时间达到锁等待超时的设定后放弃锁请求,这种方式通常来说不太好。InnoDB目前处理死锁的方法是,将持有最少行级排他锁的事务进行回滚(这是相对比较简单的死锁回滚算法)。\n锁的行为和顺序是和存储引擎相关的。以同样的顺序执行语句,有些存储引擎会产生死锁,有些则不会。死锁的产生有双重原因:有些是因为真正的数据冲突,这种情况通常很难避免,但有些则完全是由于存储引擎的实现方式导致的。\n死锁发生以后,只有部分或者完全回滚其中一个事务,才能打破死锁。对于事务型的系统,这是无法避免的,所以应用程序在设计时必须考虑如何处理死锁。大多数情况下只需要重新执行因死锁回滚的事务即可。\n1.3.3 事务日志 # 事务日志可以帮助提高事务的效率。使用事务日志,存储引擎在修改表的数据时只需要修改其内存拷贝,再把该修改行为记录到持久在硬盘上的事务日志中,而不用每次都将修改的数据本身持久到磁盘。事务日志采用的是追加的方式,因此写日志的操作是磁盘上一小块区域内的顺序I/O,而不像随机I/O需要在磁盘的多个地方移动磁头,所以采用事务日志的方式相对来说要快得多。事务日志持久以后,内存中被修改的数据在后台可以慢慢地刷回到磁盘。目前大多数存储引擎都是这样实现的,我们通常称之为预写式日志(Write-Ahead Logging),修改数据需要写两次磁盘。\n如果数据的修改已经记录到事务日志并持久化,但数据本身还没有写回磁盘,此时系统崩溃,存储引擎在重启时能够自动恢复这部分修改的数据。具体的恢复方式则视存储引擎而定。\n1.3.4 MySQL中的事务 # MySQL提供了两种事务型的存储引擎:InnoDB和NDB Cluster。另外还有一些第三方存储引擎也支持事务,比较知名的包括XtraDB和PBXT。后面将详细讨论它们各自的一些特点。\n自动提交(AUTOCOMMIT) # MySQL默认采用自动提交(AUTOCOMMIT)模式。也就是说,如果不是显式地开始一个事务,则每个查询都被当作一个事务执行提交操作。在当前连接中,可以通过设置AUTOCOMMIT变量来启用或者禁用自动提交模式:\n1或者ON表示启用,0或者OFF表示禁用。当AUTOCOMMIT=0时,所有的查询都是在一个事务中,直到显式地执行COMMIT提交或者ROLLBACK回滚,该事务结束,同时又开始了另一个新事务。修改AUTOCOMMIT对非事务型的表,比如MyISAM或者内存表,不会有任何影响。对这类表来说,没有COMMIT或者ROLLBACK的概念,也可以说是相当于一直处于AUTOCOMMIT启用的模式。\n另外还有一些命令,在执行之前会强制执行COMMIT提交当前的活动事务。典型的例子,在数据定义语言(DDL)中,如果是会导致大量数据改变的操作,比如ALTER TABLE,就是如此。另外还有LOCK TABLES等其他语句也会导致同样的结果。如果有需要,请检查对应版本的官方文档来确认所有可能导致自动提交的语句列表。\nMySQL可以通过执行SET TRANSACTION ISOLATION LEVEL命令来设置隔离级别。新的隔离级别会在下一个事务开始的时候生效。可以在配置文件中设置整个数据库的隔离级别,也可以只改变当前会话的隔离级别:\nmysql\u0026gt; ** SET SESSION TRANSACTION ISOLATION LEVEL READ COMMITTED;** MySQL能够识别所有的4个ANSI隔离级别,InnoDB引擎也支持所有的隔离级别。\n在事务中混合使用存储引擎 # MySQL服务器层不管理事务,事务是由下层的存储引擎实现的。所以在同一个事务中,使用多种存储引擎是不可靠的。\n如果在事务中混合使用了事务型和非事务型的表(例如InnoDB和MyISAM表),在正常提交的情况下不会有什么问题。\n但如果该事务需要回滚,非事务型的表上的变更就无法撤销,这会导致数据库处于不一致的状态,这种情况很难修复,事务的最终结果将无法确定。所以,为每张表选择合适的存储引擎非常重要。\n在非事务型的表上执行事务相关操作的时候,MySQL通常不会发出提醒,也不会报错。有时候只有回滚的时候才会发出一个警告:“某些非事务型的表上的变更不能被回滚”。但大多数情况下,对非事务型表的操作都不会有提示。\n隐式和显式锁定 # InnoDB采用的是两阶段锁定协议(two-phase locking protocol)。在事务执行过程中,随时都可以执行锁定,锁只有在执行COMMIT或者ROLLBACK的时候才会释放,并且所有的锁是在同一时刻被释放。前面描述的锁定都是隐式锁定,InnoDB会根据隔离级别在需要的时候自动加锁。\n另外,InnoDB也支持通过特定的语句进行显式锁定,这些语句不属于SQL规范(3):\nSELECT \u0026hellip; LOCK IN SHARE MODE SELECT \u0026hellip; FOR UPDATE MySQL也支持LOCK TABLES和UNLOCK TABLES语句,这是在服务器层实现的,和存储引擎无关。它们有自己的用途,但并不能替代事务处理。如果应用需要用到事务,还是应该选择事务型存储引擎。\n经常可以发现,应用已经将表从MyISAM转换到InnoDB,但还是显式地使用LOCK TABLES语句。这不但没有必要,还会严重影响性能,实际上InnoDB的行级锁工作得更好。\nLOCK TABLES和事务之间相互影响的话,情况会变得非常复杂,在某些MySQL版本中甚至会产生无法预料的结果。因此,本书建议,除了事务中禁用了AUTOCOMMIT,可以使用LOCK TABLES之外,其他任何时候都不要显式地执行LOCK TABLES,不管使用的是什么存储引擎。\n1.4 多版本并发控制 # MySQL的大多数事务型存储引擎实现的都不是简单的行级锁。基于提升并发性能的考虑,它们一般都同时实现了多版本并发控制(MVCC)。不仅是MySQL,包括Oracle、PostgreSQL等其他数据库系统也都实现了MVCC,但各自的实现机制不尽相同,因为MVCC没有一个统一的实现标准。\n可以认为MVCC是行级锁的一个变种,但是它在很多情况下避免了加锁操作,因此开销更低。虽然实现机制有所不同,但大都实现了非阻塞的读操作,写操作也只锁定必要的行。\nMVCC的实现,是通过保存数据在某个时间点的快照来实现的。也就是说,不管需要执行多长时间,每个事务看到的数据都是一致的。根据事务开始的时间不同,每个事务对同一张表,同一时刻看到的数据可能是不一样的。如果之前没有这方面的概念,这句话听起来就有点迷惑。熟悉了以后会发现,这句话其实还是很容易理解的。\n前面说到不同存储引擎的MVCC实现是不同的,典型的有乐观(optimistic)并发控制和悲观(pessimistic)并发控制。下面我们通过InnoDB的简化版行为来说明MVCC是如何工作的。\nInnoDB的MVCC,是通过在每行记录后面保存两个隐藏的列来实现的。这两个列,一个保存了行的创建时间,一个保存行的过期时间(或删除时间)。当然存储的并不是实际的时间值,而是系统版本号(system version number)。每开始一个新的事务,系统版本号都会自动递增。事务开始时刻的系统版本号会作为事务的版本号,用来和查询到的每行记录的版本号进行比较。下面看一下在REPEATABLE READ隔离级别下,MVCC具体是如何操作的。\nSELECT\nInnoDB会根据以下两个条件检查每行记录:\nInnoDB只查找版本早于当前事务版本的数据行(也就是,行的系统版本号小于或等于事务的系统版本号),这样可以确保事务读取的行,要么是在事务开始前已经存在的,要么是事务自身插入或者修改过的。 行的删除版本要么未定义,要么大于当前事务版本号。这可以确保事务读取到的行,在事务开始之前未被删除。 只有符合上述两个条件的记录,才能返回作为查询结果。\nINSERT\nInnoDB为新插入的每一行保存当前系统版本号作为行版本号。\nDELETE\nInnoDB为删除的每一行保存当前系统版本号作为行删除标识。\nUPDATE\nInnoDB为插入一行新记录,保存当前系统版本号作为行版本号,同时保存当前系统版本号到原来的行作为行删除标识。\n保存这两个额外系统版本号,使大多数读操作都可以不用加锁。这样设计使得读数据操作很简单,性能很好,并且也能保证只会读取到符合标准的行。不足之处是每行记录都需要额外的存储空间,需要做更多的行检查工作,以及一些额外的维护工作。\nMVCC只在REPEATABLE READ和READ COMMITTED两个隔离级别下工作。其他两个隔离级别都和MVCC不兼容(4),因为READ UNCOMMITTED总是读取最新的数据行,而不是符合当前事务版本的数据行。而SERIALIZABLE则会对所有读取的行都加锁。\n1.5 MySQL的存储引擎 # 本节只是概要地描述MySQL的存储引擎,而不会涉及太多细节。因为关于存储引擎的讨论及其相关特性将会贯穿全书,而且本书也不是存储引擎的完全指南,所以有必要阅读相关存储引擎的官方文档。\n在文件系统中,MySQL将每个数据库(也可以称之为schema)保存为数据目录下的一个子目录。创建表时,MySQL会在数据库子目录下创建一个和表同名的*.frm文件保存表的定义。例如创建一个名为MyTable的表,MySQL会在MyTable.frm*文件中保存该表的定义。因为MySQL使用文件系统的目录和文件来保存数据库和表的定义,大小写敏感性和具体的平台密切相关。在Windows中,大小写是不敏感的;而在类Unix中则是敏感的。不同的存储引擎保存数据和索引的方式是不同的,但表的定义则是在MySQL服务层统一处理的。\n可以使用SHOW TABLE STATUS命令(在MySQL 5.0以后的版本中,也可以查询INFORMATION_SCHEMA中对应的表)显示表的相关信息。例如,对于mysql数据库中的user表:\nmysql\u0026gt; ** SHOW TABLE STATUS LIKE 'user' \\G** *************************** 1. row *************************** Name: user Engine: MyISAM Row_format: Dynamic Rows: 6 Avg_row_length: 59 Data_length: 356 Max_data_length: 4294967295 Index_length: 2048 Data_free: 0 Auto_increment: NULL Create_time: 2002-01-24 18:07:17 Update_time: 2002-01-24 21:56:29 Check_time: NULL Collation: utf8_bin Checksum: NULL Create_options: Comment: Users and global privileges 1 row in set (0.00 sec) 输出的结果表明,这是一个MyISAM表。输出中还有很多其他信息以及统计信息。下面简单介绍一下每一行的含义。\nName\n表名。\nEngine\n表的存储引擎类型。在旧版本中,该列的名字叫Type,而不是Engine。\nRow_format\n行的格式。对于MyISAM表,可选的值为Dynamic、Fixed或者Compressed。Dynamic的行长度是可变的,一般包含可变长度的字段,如VARCHAR或BLOB。Fixed的行长度则是固定的,只包含固定长度的列,如CHAR和INTEGER。Compressed的行则只在压缩表中存在,请参考第19页“MyISAM压缩表”一节。\nRows\n表中的行数。对于MyISAM和其他一些存储引擎,该值是精确的,但对于InnoDB,该值是估计值。\nAvg_row_length\n平均每行包含的字节数。\nData_length\n表数据的大小(以字节为单位)。\nMax_data_length\n表数据的最大容量,该值和存储引擎有关。\nIndex_length\n索引的大小(以字节为单位)。\nData_free\n对于MyISAM表,表示已分配但目前没有使用的空间。这部分空间包括了之前删除的行,以及后续可以被INSERT利用到的空间。\nAuto_increment\n下一个AUTO_INCREMENT的值。\nCreate_time\n表的创建时间。\nUpdate_time\n表数据的最后修改时间。\nCheck_time\n使用CKECK TABLE命令或者myisamchk工具最后一次检查表的时间。\nCollation\n表的默认字符集和字符列排序规则。\nChecksum\n如果启用,保存的是整个表的实时校验和。\nCreate_options\n创建表时指定的其他选项。\nComment\n该列包含了一些其他的额外信息。对于MyISAM表,保存的是表在创建时带的注释。对于InnoDB表,则保存的是InnoDB表空间的剩余空间信息。如果是一个视图,则该列包含“VIEW”的文本字样。\n1.5.1 InnoDB存储引擎 # InnoDB是MySQL的默认事务型引擎,也是最重要、使用最广泛的存储引擎。它被设计用来处理大量的短期(short-lived)事务,短期事务大部分情况是正常提交的,很少会被回滚。InnoDB的性能和自动崩溃恢复特性,使得它在非事务型存储的需求中也很流行。除非有非常特别的原因需要使用其他的存储引擎,否则应该优先考虑InnoDB引擎。如果要学习存储引擎,InnoDB也是一个非常好的值得花最多的时间去深入学习的对象,收益肯定比将时间平均花在每个存储引擎的学习上要高得多。\nInnoDB的历史 # InnoDB有着复杂的发布历史,了解一下这段历史对于理解InnoDB很有帮助。2008年,发布了所谓的InnoDB plugin,适用于MySQL 5.1版本,但这是Oracle创建的下一代InnoDB引擎,其拥有者是InnoDB而不是MySQL。这基于很多原因,这些原因如果要一一道来,恐怕得喝掉好几桶啤酒。MySQL默认还是选择了集成旧的InnoDB引擎。当然用户可以自行选择使用新的性能更好、扩展性更佳的InnoDB plugin来覆盖旧的版本。直到最后,在Oracle收购了Sun公司后发布的MySQL 5.5中才彻底使用InnoDB plugin替代了旧版本的InnoDB(是的,这也意味着InnoDB plugin已经是原生编译了,而不是编译成一个插件,但名字已经约定俗成很难更改)。\n这个现代的InnoDB版本,也就是MySQL 5.1中所谓的InnoDB plugin,支持一些新特性,诸如利用排序创建索引(building index by sorting)、删除或者增加索引时不需要复制全表数据、新的支持压缩的存储格式、新的大型列值如BLOB的存储方式,以及文件格式管理等。很多用户在MySQL 5.1中没有使用InnoDB plugin,或许是因为他们没有注意到有这个区别。所以如果你使用的是MySQL 5.1,一定要使用InnoDB plugin,真的比旧版本的InnoDB要好很多。\nInnoDB是一个很重要的存储引擎,很多个人和公司都对其贡献代码,而不仅仅是Oracle公司的开发团队。一些重要的贡献者包括Google、Yasufumi Kinoshita、Percona、Facebook等,他们的一些改进被直接移植到官方版本,也有一些由InnoDB团队重新实现。在过去的几年间,InnoDB的改进速度大大加快,主要的改进集中在可测量性、可扩展性、可配置化、性能、各种新特性和对Windows的支持等方面。MySQL 5.6实验室预览版和里程碑版也包含了一系列重要的InnoDB新特性。\n为改善InnoDB的性能,Oracle投入了大量的资源,并做了很多卓有成效的工作(外部贡献者对此也提供了很大的帮助)。在本书的第二版中,我们注意到在超过四核CPU的系统中InnoDB表现不佳,而现在已经可以很好地扩展至24核的系统,甚至在某些场景,32核或者更多核的系统中也表现良好。很多改进将在即将发布的MySQL 5.6中引入,当然也还有机会做更进一步的改善。\nInnoDB概览 # InnoDB的数据存储在表空间(tablespace)中,表空间是由InnoDB管理的一个黑盒子,由一系列的数据文件组成。在MySQL 4.1以后的版本中,InnoDB可以将每个表的数据和索引存放在单独的文件中。InnoDB也可以使用裸设备作为表空间的存储介质,但现代的文件系统使得裸设备不再是必要的选择。\nInnoDB采用MVCC来支持高并发,并且实现了四个标准的隔离级别。其默认级别是REPEATABLE READ(可重复读),并且通过间隙锁(next-key locking)策略防止幻读的出现。间隙锁使得InnoDB不仅仅锁定查询涉及的行,还会对索引中的间隙进行锁定,以防止幻影行的插入。\nInnoDB表是基于聚簇索引建立的,我们会在后面的章节详细讨论聚簇索引。InnoDB的索引结构和MySQL的其他存储引擎有很大的不同,聚簇索引对主键查询有很高的性能。不过它的二级索引(secondary index,非主键索引)中必须包含主键列,所以如果主键列很大的话,其他的所有索引都会很大。因此,若表上的索引较多的话,主键应当尽可能的小。InnoDB的存储格式是平台独立的,也就是说可以将数据和索引文件从Intel平台复制到PowerPC或者Sun SPARC平台。\nInnoDB内部做了很多优化,包括从磁盘读取数据时采用的可预测性预读,能够自动在内存中创建hash索引以加速读操作的自适应哈希索引(adaptive hash index),以及能够加速插入操作的插入缓冲区(insert buffer)等。本书后面将更详细地讨论这些内容。InnoDB的行为是非常复杂的,不容易理解。如果使用了InnoDB引擎,笔者强烈建议阅读官方手册中的“InnoDB事务模型和锁”一节。如果应用程序基于InnoDB构建,则事先了解一下InnoDB的MVCC架构带来的一些微妙和细节之处是非常有必要的。存储引擎要为所有用户甚至包括修改数据的用户维持一致性的视图,是非常复杂的工作。\n作为事务型的存储引擎,InnoDB通过一些机制和工具支持真正的热备份,Oracle提供的MySQL Enterprise Backup、Percona提供的开源的XtraBackup都可以做到这一点。MySQL的其他存储引擎不支持热备份,要获取一致性视图需要停止对所有表的写入,而在读写混合场景中,停止写入可能也意味着停止读取。\n1.5.2 MyISAM存储引擎 # 在MySQL 5.1及之前的版本,MyISAM是默认的存储引擎。MyISAM提供了大量的特性,包括全文索引、压缩、空间函数(GIS)等,但MyISAM不支持事务和行级锁,而且有一个毫无疑问的缺陷就是崩溃后无法安全恢复。正是由于MyISAM引擎的缘故,即使MySQL支持事务已经很长时间了,在很多人的概念中MySQL还是非事务型的数据库。尽管MyISAM引擎不支持事务、不支持崩溃后的安全恢复,但它绝不是一无是处的。对于只读的数据,或者表比较小、可以忍受修复(repair)操作,则依然可以继续使用MyISAM(但请不要默认使用MyISAM,而是应当默认使用InnoDB)。\n存储 # MyISAM会将表存储在两个文件中:数据文件和索引文件,分别以*.MYD和.MYI*为扩展名。MyISAM表可以包含动态或者静态(长度固定)行。MySQL会根据表的定义来决定采用何种行格式。MyISAM表可以存储的行记录数,一般受限于可用的磁盘空间,或者操作系统中单个文件的最大尺寸。\n在MySQL 5.0中,MyISAM表如果是变长行,则默认配置只能处理256TB的数据,因为指向数据记录的指针长度是6个字节。而在更早的版本中,指针长度默认是4字节,所以只能处理4GB的数据。而所有的MySQL版本都支持8字节的指针。要改变MyISAM表指针的长度(调高或者调低),可以通过修改表的MAX_ROWS和AVG_ROW_LENGTH选项的值来实现,两者相乘就是表可能达到的最大大小。修改这两个参数会导致重建整个表和表的所有索引,这可能需要很长的时间才能完成。\nMyISAM特性 # 作为MySQL最早的存储引擎之一,MyISAM有一些已经开发出来很多年的特性,可以满足用户的实际需求。\n加锁与并发\nMyISAM对整张表加锁,而不是针对行。读取时会对需要读到的所有表加共享锁,写入时则对表加排他锁。但是在表有读取查询的同时,也可以往表中插入新的记录(这被称为并发插入,CONCURRENT INSERT)。\n修复\n对于MyISAM表,MySQL可以手工或者自动执行检查和修复操作,但这里说的修复和事务恢复以及崩溃恢复是不同的概念。执行表的修复可能导致一些数据丢失,而且修复操作是非常慢的。可以通过CHECK TABLE mytable检查表的错误,如果有错误可以通过执行REPAIR TABLE mytable进行修复。另外,如果MySQL服务器已经关闭,也可以通过myisamchk命令行工具进行检查和修复操作。\n索引特性\n对于MyISAM表,即使是BLOB和TEXT等长字段,也可以基于其前500个字符创建索引。MyISAM也支持全文索引,这是一种基于分词创建的索引,可以支持复杂的查询。关于索引的更多信息请参考第5章。\n延迟更新索引键(Delayed Key Write)\n创建MyISAM表的时候,如果指定了DELAY_KEY_WRITE选项,在每次修改执行完成时,不会立刻将修改的索引数据写入磁盘,而是会写到内存中的键缓冲区(in-memory key buffer),只有在清理键缓冲区或者关闭表的时候才会将对应的索引块写入到磁盘。这种方式可以极大地提升写入性能,但是在数据库或者主机崩溃时会造成索引损坏,需要执行修复操作。延迟更新索引键的特性,可以在全局设置,也可以为单个表设置。\nMyISAM压缩表 # 如果表在创建并导入数据以后,不会再进行修改操作,那么这样的表或许适合采用MyISAM压缩表。\n可以使用myisampack对MyISAM表进行压缩(也叫打包pack)。压缩表是不能进行修改的(除非先将表解除压缩,修改数据,然后再次压缩)。压缩表可以极大地减少磁盘空间占用,因此也可以减少磁盘I/O,从而提升查询性能。压缩表也支持索引,但索引也是只读的。\n以现在的硬件能力,对大多数应用场景,读取压缩表数据时的解压带来的开销影响并不大,而减少I/O带来的好处则要大得多。压缩时表中的记录是独立压缩的,所以读取单行的时候不需要去解压整个表(甚至也不解压行所在的整个页面)。\nMyISAM性能 # MyISAM引擎设计简单,数据以紧密格式存储,所以在某些场景下的性能很好。MyISAM有一些服务器级别的性能扩展限制,比如对索引键缓冲区(key cache)的Mutex锁,MariaDB基于段(segment)的索引键缓冲区机制来避免该问题。但MyISAM最典型的性能问题还是表锁的问题,如果你发现所有的查询都长期处于“Locked”状态,那么毫无疑问表锁就是罪魁祸首。\n1.5.3 MySQL内建的其他存储引擎 # MySQL还有一些有特殊用途的存储引擎。在新版本中,有些可能因为一些原因已经不再支持;另外还有些会继续支持,但是需要明确地启用后才能使用。\nArchive引擎 # Archive存储引擎只支持INSERT和SELECT操作,在MySQL 5.1之前也不支持索引。\nArchive引擎会缓存所有的写并利用zlib对插入的行进行压缩,所以比MyISAM表的磁盘I/O更少。但是每次SELECT查询都需要执行全表扫描。所以Archive表适合日志和数据采集类应用,这类应用做数据分析时往往需要全表扫描。或者在一些需要更快速的INSERT操作的场合下也可以使用。\nArchive引擎支持行级锁和专用的缓冲区,所以可以实现高并发的插入。在一个查询开始直到返回表中存在的所有行数之前,Archive引擎会阻止其他的SELECT执行,以实现一致性读。另外,也实现了批量插入在完成之前对读操作是不可见的。这种机制模仿了事务和MVCC的一些特性,但Archive引擎不是一个事务型的引擎,而是一个针对高速插入和压缩做了优化的简单引擎。\nBlackhole引擎 # Blackhole引擎没有实现任何的存储机制,它会丢弃所有插入的数据,不做任何保存。但是服务器会记录Blackhole表的日志,所以可以用于复制数据到备库,或者只是简单地记录到日志。这种特殊的存储引擎可以在一些特殊的复制架构和日志审核时发挥作用。但这种应用方式我们碰到过很多问题,因此并不推荐。\nCSV引擎 # CSV引擎可以将普通的CSV文件(逗号分割值的文件)作为MySQL的表来处理,但这种表不支持索引。CSV引擎可以在数据库运行时拷入或者拷出文件。可以将Excel等电子表格软件中的数据存储为CSV文件,然后复制到MySQL数据目录下,就能在MySQL中打开使用。同样,如果将数据写入到一个CSV引擎表,其他的外部程序也能立即从表的数据文件中读取CSV格式的数据。因此CSV引擎可以作为一种数据交换的机制,非常有用。\nFederated引擎 # Federated引擎是访问其他MySQL服务器的一个代理,它会创建一个到远程MySQL服务器的客户端连接,并将查询传输到远程服务器执行,然后提取或者发送需要的数据。最初设计该存储引擎是为了和企业级数据库如Microsoft SQL Server和Oracle的类似特性竞争的,可以说更多的是一种市场行为。尽管该引擎看起来提供了一种很好的跨服务器的灵活性,但也经常带来问题,因此默认是禁用的。MariaDB使用了它的一个后续改进版本,叫做FederatedX。\nMemory引擎 # 如果需要快速地访问数据,并且这些数据不会被修改,重启以后丢失也没有关系,那么使用Memory表(以前也叫做HEAP表)是非常有用的。Memory表至少比MyISAM表要快一个数量级,因为所有的数据都保存在内存中,不需要进行磁盘I/O。Memory表的结构在重启以后还会保留,但数据会丢失。\nMemroy表在很多场景可以发挥好的作用:\n用于查找(lookup)或者映射(mapping)表,例如将邮编和州名映射的表。 用于缓存周期性聚合数据(periodically aggregated data)的结果。 用于保存数据分析中产生的中间数据。 Memory表支持Hash索引,因此查找操作非常快。虽然Memory表的速度非常快,但还是无法取代传统的基于磁盘的表。Memroy表是表级锁,因此并发写入的性能较低。它不支持BLOB或TEXT类型的列,并且每行的长度是固定的,所以即使指定了VARCHAR列,实际存储时也会转换成CHAR,这可能导致部分内存的浪费(其中一些限制在Percona版本已经解决)。\n如果MySQL在执行查询的过程中需要使用临时表来保存中间结果,内部使用的临时表就是Memory表。如果中间结果太大超出了Memory表的限制,或者含有BLOB或TEXT字段,则临时表会转换成MyISAM表。在后续的章节还会继续讨论该问题。\n人们经常混淆Memory表和临时表。临时表是指使用CREATE TEMPORARY TABLE语句创建的表,它可以使用任何存储引擎,因此和Memory表不是一回事。临时表只在单个连接中可见,当连接断开时,临时表也将不复存在。\nMerge引擎 # Merge引擎是MyISAM引擎的一个变种。Merge表是由多个MyISAM表合并而来的虚拟表。如果将MySQL用于日志或者数据仓库类应用,该引擎可以发挥作用。但是引入分区功能后,该引擎已经被放弃(参考第7章)。\nNDB集群引擎 # 2003年,当时的MySQL AB公司从索尼爱立信公司收购了NDB数据库,然后开发了NDB集群存储引擎,作为SQL和NDB原生协议之间的接口。MySQL服务器、NDB集群存储引擎,以及分布式的、share-nothing的、容灾的、高可用的NDB数据库的组合,被称为MySQL集群(MySQL Cluster)。本书后续会有章节专门来讨论MySQL集群。\n1.5.4 第三方存储引擎 # MySQL从2007年开始提供了插件式的存储引擎API,从此涌出了一系列为不同目的而设计的存储引擎。其中有一些已经合并到MySQL服务器,但大多数还是第三方产品或者开源项目。下面探讨一些我们认为在它设计的场景中确实很有用的第三方存储引擎。\nOLTP类引擎 # Percona的XtraDB存储引擎是基于InnoDB引擎的一个改进版本,已经包含在Percona Server和MariaDB中,它的改进点主要集中在性能、可测量性和操作灵活性方面。XtraDB可以作为InnoDB的一个完全的替代产品,甚至可以兼容地读写InnoDB的数据文件,并支持InnoDB的所有查询。\n另外还有一些和InnoDB非常类似的OLTP类存储引擎,比如都支持ACID事务和MVCC。其中一个就是PBXT,由Paul McCullagh和Primebase GMBH开发。它支持引擎级别的复制、外键约束,并且以一种比较复杂的架构对固态存储(SSD)提供了适当的支持,还对较大的值类型如BLOB也做了优化。PBXT是一款社区支持的存储引擎,MariaDB包含了该引擎。\nTokuDB引擎使用了一种新的叫做分形树(Fractal Trees)的索引数据结构。该结构是缓存无关的,因此即使其大小超过内存性能也不会下降,也就没有内存生命周期和碎片的问题。TokuDB是一种大数据(Big Data)存储引擎,因为其拥有很高的压缩比,可以在很大的数据量上创建大量索引。在本书写作时,这个引擎还处于早期的生产版本状态,在并发性方面还有很多明显的限制。目前其最适合在需要大量插入数据的分析型数据集的场景中使用,不过这些限制可能在后续版本中解决掉。\nRethinkDB最初是为固态存储(SSD)而设计的,然而随着时间的推移,目前看起来和最初的目标有一定的差距。该引擎比较特别的地方在于采用了一种只能追加的写时复制B树(append-only copyon-write B-Tree)作为索引的数据结构。目前还处于早期开发状态,我们还没有测试评估过,也没有听说有实际的应用案例。\n在Sun收购MySQL AB以后,Falcon存储引擎曾经作为下一代存储引擎被寄予期望,但现在该项目已经被取消很久了。Falcon的主要设计者Jim Starkey创立了一家新公司,主要做可以支持云计算的NewSQL数据库产品,叫做NuoDB(之前叫NimbusDB)。\n面向列的存储引擎 # MySQL默认是面向行的,每一行的数据是一起存储的,服务器的查询也是以行为单位处理的。而在大数据量处理时,面向列的方式可能效率更高。如果不需要整行的数据,面向列的方式可以传输更少的数据。如果每一列都单独存储,那么压缩的效率也会更高。\nInfobright是最有名的面向列的存储引擎。在非常大的数据量(数十TB)时,该引擎工作良好。Infobright是为数据分析和数据仓库应用设计的。数据高度压缩,按照块进行排序,每个块都对应有一组元数据。在处理查询时,访问元数据可决定跳过该块,甚至可能只需要元数据即可满足查询的需求。但该引擎不支持索引,不过在这么大的数据量级,即使有索引也很难发挥作用,而且块结构也是一种准索引(quasi-index)。Infobright需要对MySQL服务器做定制,因为一些地方需要修改以适应面向列存储的需要。如果查询无法在存储层使用面向列的模式执行,则需要在服务器层转换成按行处理,这个过程会很慢。Infobright有社区版和商业版两个版本。\n另外一个面向列的存储引擎是Calpont公司的InfiniDB,也有社区版和商业版。InfiniDB可以在一组机器集群间做分布式查询,但目前还没有生产环境的应用案例。\n顺便提一下,在MySQL之外,如果有面向列的存储的需求,我们也评估过LucidDB和MonetDB。在我们的MySQL性能博客(5)上有相应的性能测试数据,或许随着时间的推移,这些数据慢慢会过期,但依然可以作为参考。\n社区存储引擎 # 如果要列举社区提供的所有存储引擎,可能会有两位数,甚至三位数。但是负责任地说,其中大部分影响力有限,很多可能都没有听说过,或者只有极少人在使用。在这里列举了一些,也大都没有在生产环境中应用过,慎用,后果自负。\nAria\n之前的名字是Maria,是MySQL创建者计划用来替代MyISAM的一款引擎。MariaDB包含了该引擎,之前计划开发的很多特性,有些因为在MariaDB服务器层实现,所以引擎层就取消了。在本书写作之际,可以说Aria就是解决了崩溃安全恢复问题的MyISAM,当然也还有一些特性是MyISAM不具备的,比如数据的缓存(MyISAM只能缓存索引)。\nGroonga\n这是一款全文索引引擎,号称可以提供准确而高效的全文索引。\nOQGraph\n该引擎由Open Query研发,支持图操作(比如查找两点之间的最短路径),用SQL很难实现该类操作。\nQ4M\n该引擎在MySQL内部实现了队列操作,而用SQL很难在一个语句实现这类队列操作。\nSphinxSE\n该引擎为Sphinx全文索引搜索服务器提供了SQL接口,在附录F中将做进一步的详细讨论。\nSpider\n该引擎可以将数据切分成不同的分区,比较高效透明地实现了分片(shard),并且可以针对分片执行并行查询(分片可以分布在不同的服务器上)。\nVPForMySQL\n该引擎支持垂直分区,通过一系列的代理存储引擎实现。垂直分区指的是可以将表分成不同列的组合,并且单独存储。但对查询来说,看到的还是一张表。该引擎和Spider的作者是同一人。\n1.5.5 选择合适的引擎 # 这么多存储引擎,我们怎么选择?大部分情况下,InnoDB都是正确的选择,所以Oracle在MySQL 5.5版本时终于将InnoDB作为默认的存储引擎了。对于如何选择存储引擎,可以简单地归纳为一句话:“除非需要用到某些InnoDB不具备的特性,并且没有其他办法可以替代,否则都应该优先选择InnoDB引擎”。例如,如果要用到全文索引,建议优先考虑InnoDB加上Sphinx的组合,而不是使用支持全文索引的MyISAM。当然,如果不需要用到InnoDB的特性,同时其他引擎的特性能够更好地满足需求,也可以考虑一下其他存储引擎。举个例子,如果不在乎可扩展能力和并发能力,也不在乎崩溃后的数据丢失问题,却对InnoDB的空间占用过多比较敏感,这种场合下选择MyISAM就比较合适。\n除非万不得已,否则建议不要混合使用多种存储引擎,否则可能带来一系列复杂的问题,以及一些潜在的bug和边界问题。存储引擎层和服务器层的交互已经比较复杂,更不用说混合多个存储引擎了。至少,混合存储对一致性备份和服务器参数配置都带来了一些困难。\n如果应用需要不同的存储引擎,请先考虑以下几个因素。\n事务\n如果应用需要事务支持,那么InnoDB(或者XtraDB)是目前最稳定并且经过验证的选择。如果不需要事务,并且主要是SELECT和INSERT操作,那么MyISAM是不错的选择。一般日志型的应用比较符合这一特性。\n备份\n备份的需求也会影响存储引擎的选择。如果可以定期地关闭服务器来执行备份,那么备份的因素可以忽略。反之,如果需要在线热备份,那么选择InnoDB就是基本的要求。\n崩溃恢复\n数据量比较大的时候,系统崩溃后如何快速地恢复是一个需要考虑的问题。相对而言,MyISAM崩溃后发生损坏的概率比InnoDB要高很多,而且恢复速度也要慢。因此,即使不需要事务支持,很多人也选择InnoDB引擎,这是一个非常重要的因素。\n特有的特性\n最后,有些应用可能依赖一些存储引擎所独有的特性或者优化,比如很多应用依赖聚簇索引的优化。另外,MySQL中也只有MyISAM支持地理空间搜索。如果一个存储引擎拥有一些关键的特性,同时却又缺乏一些必要的特性,那么有时候不得不做折中的考虑,或者在架构设计上做一些取舍。某些存储引擎无法直接支持的特性,有时候通过变通也可以满足需求。\n你不需要现在就做决定。本书接下来会提供很多关于各种存储引擎优缺点的详细描述,也会讨论一些架构设计的技巧。一般来说,可能有很多选项你还没有意识到,等阅读完本书回头再来看这个问题可能更有帮助些。如果无法确定,那么就使用InnoDB,这个默认选项是安全的,尤其是搞不清楚具体需要什么的时候。\n如果不了解具体的应用,上面提到的这些概念都是比较抽象的。所以接下来会讨论一些常见的应用场景,在这些场景中会涉及很多的表,以及这些表如何选用合适的存储引擎,下一节将进行一些总结。\n日志型应用 # 假设你需要实时地记录一台中心电话交换机的每一通电话的日志到MySQL中,或者通过Apache的mod_log_sql模块将网站的所有访问信息直接记录到表中。这一类应用的插入速度有很高的要求,数据库不能成为瓶颈。MyISAM或者Archive存储引擎对这类应用比较合适,因为它们开销低,而且插入速度非常快。\n如果需要对记录的日志做分析报表,则事情就会变得有趣了。生成报表的SQL很有可能会导致插入效率明显降低,这时候该怎么办?\n一种解决方法,是利用MySQL内置的复制方案将数据复制一份到备库,然后在备库上执行比较消耗时间和CPU的查询。这样主库只用于高效的插入工作,而备库上执行的查询也无须担心影响到日志的插入性能。当然也可以在系统负载较低的时候执行报表查询操作,但应用在不断变化,如果依赖这个策略可能以后会导致问题。\n另外一种方法,在日志记录表的名字中包含年和月的信息,比如web_logs_2012_01或者web_logs_2012_jan。这样可以在已经没有插入操作的历史表上做频繁的查询操作,而不会干扰到最新的当前表上的插入操作。\n只读或者大部分情况下只读的表 # 有些表的数据用于编制类目或者分列清单(如工作岗位、竞拍、不动产等),这种应用场景是典型的读多写少的业务。如果不介意MyISAM的崩溃恢复问题,选用MyISAM引擎是合适的。不过不要低估崩溃恢复问题的重要性,有些存储引擎不会保证将数据安全地写入到磁盘中,而许多用户实际上并不清楚这样有多大的风险(MyISAM只将数据写到内存中,然后等待操作系统定期将数据刷出到磁盘上)。\n一个值得推荐的方式,是在性能测试环境模拟真实的环境,运行应用,然后拔下电源模拟崩溃测试。对崩溃恢复的第一手测试经验是无价之宝,可以避免真的碰到崩溃时手足无措。\n不要轻易相信“MyISAM比InnoDB快”之类的经验之谈,这个结论往往不是绝对的。在很多我们已知的场景中,InnoDB的速度都可以让MyISAM望尘莫及,尤其是使用到聚簇索引,或者需要访问的数据都可以放入内存的应用。在本书后续章节,读者可以了解更多影响存储引擎性能的因素(如数据大小、I/O请求量、主键还是二级索引等)以及这些因素对应用的影响。\n当设计上述类型的应用时,建议采用InnoDB。MyISAM引擎在一开始可能没有任何问题,但随着应用压力的上升,则可能迅速恶化。各种锁争用、崩溃后的数据丢失等问题都会随之而来。\n订单处理 # 如果涉及订单处理,那么支持事务就是必要选项。半完成的订单是无法用来吸引用户的。另外一个重要的考虑点是存储引擎对外键的支持情况。InnoDB是订单处理类应用的最佳选择。\n电子公告牌和主题讨论论坛 # 对于MySQL用户,主题讨论区是个很有意思的话题。当前有成百上千的基于PHP或者Perl的免费系统可以支持主题讨论。其中大部分的数据库操作效率都不高,因为它们大多倾向于在一次请求中执行尽可能多的查询语句。另外还有部分系统设计为不采用数据库,当然也就无法利用到数据库提供的一些方便的特性。主题讨论区一般都有更新计数器,并且会为各个主题计算访问统计信息。多数应用只设计了几张表来保存所有的数据,所以核心表的读写压力可能非常大。为保证这些核心表的数据一致性,锁成为资源争用的主要因素。\n尽管有这些设计缺陷,但大多数应用在中低负载时可以工作得很好。如果Web站点的规模迅速扩展,流量随之猛增,则数据库访问可能变得非常慢。此时一个典型的解决方案是更改为支持更高读写的存储引擎,但有时用户会发现这么做反而导致系统变得更慢了。用户可能没有意识到这是由于某些特殊查询的缘故,典型的如:\nmysql\u0026gt; ** SELECT COUNT(*) FROM table;** 问题就在于,不是所有的存储引擎运行上述查询都非常快:对于MyISAM确实会很快,但其他的可能都不行。每种存储引擎都能找出类似的对自己有利的例子。下一章将帮助用户分析这些状况,演示如何发现和解决存在的这类问题。\nCD-ROM应用 # 如果要发布一个基于CD-ROM或者DVD-ROM并且使用MySQL数据文件的应用,可以考虑使用MyISAM表或者MyISAM压缩表,这样表之间可以隔离并且可以在不同介质上相互拷贝。MyISAM压缩表比未压缩的表要节约很多空间,但压缩表是只读的。在某些应用中这可能是个大问题。但如果数据放到只读介质的场景下,压缩表的只读特性就不是问题,就没有理由不采用压缩表了。\n大数据量 # 什么样的数据量算大?我们创建或者管理的很多InnoDB数据库的数据量在3~5TB之间,或者更大,这是单台机器上的量,不是一个分片(shard)的量。这些系统运行得还不错,要做到这一点需要合理地选择硬件,做好物理设计,并为服务器的I/O瓶颈做好规划。在这样的数据量下,如果采用MyISAM,崩溃后的恢复就是一个噩梦。\n如果数据量继续增长到10TB以上的级别,可能就需要建立数据仓库。Infobright是MySQL数据仓库最成功的解决方案。也有一些大数据库不适合Infobright,却可能适合TokuDB。\n1.5.6 转换表的引擎 # 有很多种方法可以将表的存储引擎转换成另外一种引擎。每种方法都有其优点和缺点。在接下来的章节中,我们将讲述其中的三种方法。\nALTER TABLE # 将表从一个引擎修改为另一个引擎最简单的办法是使用ALTER TABLE语句。下面的语句将mytable的引擎修改为InnoDB:\nmysql\u0026gt; ** ALTER TABLE mytable ENGINE=InnoDB;** 上述语法可以适用任何存储引擎。但有一个问题:需要执行很长时间。MySQL会按行将数据从原表复制到一张新的表中,在复制期间可能会消耗系统所有的I/O能力,同时原表上会加上读锁。所以,在繁忙的表上执行此操作要特别小心。一个替代方案是采用接下来将讨论的导出与导入的方法,手工进行表的复制。\n如果转换表的存储引擎,将会失去和原引擎相关的所有特性。例如,如果将一张InnoDB表转换为MyISAM,然后再转换回InnoDB,原InnoDB表上所有的外键将丢失。\n导出与导入 # 为了更好地控制转换的过程,可以使用mysqldump工具将数据导出到文件,然后修改文件中CREATE TABLE语句的存储引擎选项,注意同时修改表名,因为同一个数据库中不能存在相同的表名,即使它们使用的是不同的存储引擎。同时要注意mysqldump默认会自动在CREATE TABLE语句前加上DROP TABLE语句,不注意这一点可能会导致数据丢失。\n创建与查询(CREATE和SELECT) # 第三种转换的技术综合了第一种方法的高效和第二种方法的安全。不需要导出整个表的数据,而是先创建一个新的存储引擎的表,然后利用INSERT…SELECT语法来导数据:\nmysql\u0026gt; ** CREATE TABLE innodb_table LIKE myisam_table;** mysql\u0026gt; ** ALTER TABLE innodb_table ENGINE=InnoDB;** mysql\u0026gt; ** INSERT INTO innodb_table SELECT * FROM myisam_table;** 数据量不大的话,这样做工作得很好。如果数据量很大,则可以考虑做分批处理,针对每一段数据执行事务提交操作,以避免大事务产生过多的undo。假设有主键字段id,重复运行以下语句(最小值x和最大值y进行相应的替换)将数据导入到新表:\nmysql\u0026gt; ** START TRANSACTION;** mysql\u0026gt; ** INSERT INTO innodb_table SELECT * FROM myisam_table** -\u0026gt; ** WHERE id BETWEEN x AND y;** mysql\u0026gt; ** COMMIT;** 这样操作完成以后,新表是原表的一个全量复制,原表还在,如果需要可以删除原表。如果有必要,可以在执行的过程中对原表加锁,以确保新表和原表的数据一致。\nPercona Toolkit提供了一个pt-online-schema-change的工具(基于Facebook的在线schema变更技术),可以比较简单、方便地执行上述过程,避免手工操作可能导致的失误和烦琐。\n1.6 MySQL时间线(Timeline) # 在选择MySQL版本的时候,了解一下版本的变迁历史是有帮助的。对于怀旧者也可以享受一下过去的好日子里是怎么使用MySQL的。\n版本3.23(2001)\n一般认为这个版本的发布是MySQL真正“诞生”的时刻,其开始获得广泛使用。在这个版本,MySQL依然只是一个在平面文件(Flat File)上实现了SQL查询的系统。但一个重要的改进是引入MyISAM代替了老旧而且有诸多限制的ISAM引擎。InnoDB引擎也已经可以使用,但没有包含在默认的二进制发行版中,因为它太新了。所以如果要使用InnoDB,必须手工编译。版本3.23还引入了全文索引和复制。复制是MySQL成为互联网应用的数据库系统的关键特性(killer feature)。\n版本4.0(2003)\n支持新的语法,比如UNION和多表DELETE语法。重写了复制,在备库使用了两个线程来实现复制,避免了之前一个线程做所有复制工作的模式下任务切换导致的问题。InnoDB成为标准配备,包括了全部的特性:行级锁、外键等。版本4.0中还引入了查询缓存(自那以后这部分改动不大),同时还支持通过SSL进行连接。\n版本4.1(2005)\n引入了更多新的语法,比如子查询和INSERT ON DUPLICATE KEY UPDATE。开始支持UTF-8字符集。支持新的二进制协议和prepared语句。\n版本5.0(2006)\n这个版本出现了一些“企业级”特性:视图、触发器、存储过程和存储函数。老的ISAM引擎的代码被彻底移除,同时引入了新的Federated等引擎。\n版本5.1(2008)\n这是Sun收购MySQL AB以后发布的首个版本,研发时间长达五年。版本5.1引入了分区、基于行的复制,以及plugin API(包括可插拔存储引擎的API)。移除了BerkeyDB引擎,这是MySQL最早的事务存储引擎。其他如Federated引擎也将被放弃。同时Oracle收购的InnoDB Oy(6)发布了InnoDB plugin。\n版本5.5(2010)\n这是Oracle收购Sun以后发布的首个版本。版本5.5的主要改善集中在性能、扩展性、复制、分区、对微软Windows系统的支持,以及一些其他方面。InnoDB成为默认的存储引擎。更多的一些遗留特性和不建议使用的特性被移除。增加了PERFORMANCE_SCHEMA库,包含了一些可测量的性能指标的增强。增加了复制、认证和审计API。半同步复制(semisynchronous replication)插件进入实用阶段。Oracle还在2011年发布了商用的认证插件和线程池(thread pooling)。InnoDB在架构方面也做了较大的改进,比如多个子缓冲池(buffer pool)。\n版本5.6(还未发布)\n版本5.6将包含一些重大更新。比如多年来首次对查询优化器进行大规模的改进,更多的插件API(比如全文索引),复制的改进,以及PERFORMANCE_SCHEMA库增加了更多的性能指标。InnoDB团队也做了大量的改进工作,这些改进在已经发布的里程碑版本和实验室版本中都已经包括。MySQL 5.5主要着重在基础部分的改进和加强,引入了部分新特性。而MySQL 5.6则在MySQL 5.5的基础上提升服务器的开发和性能。\n版本6.0(已经取消)\n版本6.0的概念有些模糊。最早在版本5.1还在开发的时候就宣布要开发版本6.0。传说中宣布要开发的6.0拥有大量的新特性,包括在线备份、服务器层面对所有存储引擎的外键支持,以及子查询的改进和线程池。后来该版本号被取消,Sun将其改为版本5.4继续开发,最后发布时变成版本5.5。版本6.0中很多特性的代码陆续出现在版本5.5和5.6中。\n简单总结一下MySQL的发展史:早期的MySQL是一种破坏性创新(7),有诸多限制,并且很多功能只能说是二流的。但是它的特性支持和较低的使用成本,使得其成为快速增长的互联网时代的杀手级应用。在5.x版本的早期,MySQL引入了视图和存储过程等特性,期望成为“企业级”数据库,但并不算成功,成长并非一帆风顺。从事后分析来看,MySQL 5.0充满了bug,直到5.0.50以后的版本才算稳定。这种情况在MySQL 5.1也依然没有太多改善。版本5.0和5.1的发布都延期了许多时日,而且Sun和Oracle的两次收购也使得社区人士有所担心。但我们认为事情还在按部就班地发展,MySQL 5.5可以说是MySQL历史上质量最高的版本。Oracle收购以后帮助MySQL更好地往企业级应用的方向发展,MySQL 5.6也承诺在功能和性能方面将有显著提升。\n提到性能,我们可以比较一下在不同时代MySQL的性能测试的数据。在目前的生产环境中4.0及更老的版本已经很少见了,所以这里不打算测试4.1之前的版本。另外,如此多的版本如果要做完全等同的测试是比较困难的,具体原因将在后面的章节讨论。我们尝试设计了多个测试方案来尽量保证在不同版本中的基准一致,并为此做了很多努力。表1-2显示了在服务器层面不同并发下的每秒事务数的测试结果。\n表1-2:多个不同MySQL版本的只读测试 注a:在测试的时候,版本5.6还没有GA(正式发布)。\n很容易将表1-2的数据以图的方式展示出来,如图1-2所示。\n图1-2:MySQL不同版本的只读基准测试\n在解释结果之前,需要先介绍一下测试环境。测试的机器是Cisco UCS C250,两颗6核CPU,每个核支持两个线程,内存为384GB,测试的数据集是2.5GB,所以MySQL的buffer pool设置为4GB。采用SysBench的read-only只读测试进行压测,并采用InnoDB存储引擎,所有的数据都可以放入内存,因此是CPU密集型(CPU-bound)的测试。每次测试持续60分钟,每10秒获取一次吞吐量的结果,前面900秒用于预热数据,以避免预热时的I/O影响测试结果。\n现在来看看结果,有两个很明显的趋势。第一个趋势,采用了InnoDB plugin的版本,在高并发的时候性能明显更好,可以说InnoDB plugin的扩展性更好。这是可以预期的结果,旧的版本在高并发时确实存在问题。第二个趋势,新的版本在单线程的时候性能比旧版本更差。一开始可能无法理解为什么会这样,仔细想想就能明白,这是一个非常简单的只读测试。新版本的SQL语法更复杂,针对复杂查询增加了很多特性和改进,这对于简单查询可能带来了更多的开销。旧版本的代码简单,对于简单的查询反而会更有利。\n原计划做一个更复杂的不同并发条件下的读写混合场景的测试(类似TPC-C),但要在不同版本间做到可比较基本是不可能的。一般来说,新版本在复杂场景时性能有更多的优化,尤其是高并发和大数据集的情况下。\n那么该如何选择版本呢?这更多地取决于业务需求而不是技术需求。理想情况下当然是版本越新越好,当然也可以选择等到第一个bug修复版本以后再采用新的大版本。如果应用还没有上线,也可以采用即将发布的新版本,以尽可能地延迟应用上线后的升级操作。\n1.7 MySQL的开发模式 # MySQL的开发过程和发布模型在不同的阶段有很大的变化,但目前已经基本稳定下来。在Oracle定期发布的新里程碑开发版本中,会包含即将在下一个GA(8)版本发布的新特性。这样做是为了测试和获得反馈,请不要在生产环境使用此版本,虽然Oracle宣称每个里程碑版本的质量都是可靠的,并随时可以正式发布(到目前为止也没有任何理由去推翻这个说法)。Oracle也会定期发布实验室预览版,主要包含一些特定的需要评估的特性,这些特性并不保证会在下一个正式版本中包括进去。最终,Oracle会将稳定的特性打包发布一个新的GA版本。\nMySQL依然遵循GPL开源协议,全部的源代码(除了一些商业版本的插件)都会开放给社区。Oracle似乎也理解,为社区和付费用户提供不同的版本并非明智之举。MySQL AB曾经尝试过不同版本的策略,结果导致付费用户变成了“睁眼瞎”,无法从社区的测试和反馈中获得好处。不同版本的策略并不受企业用户的欢迎,所以后来被Sun废除了。\n现在Oracle为付费用户单独提供了一些服务器插件,而MySQL本身还是遵循开源模式。尽管对于私有的服务器插件的发布有一些抱怨,但这只是少数的声音,并且慢慢地在平息。大多数MySQL用户对此并不在意,有需求的用户也能够接受商业授权的付费插件。\n无论如何,不开源的扩展也只是扩展而已,并不会将MySQL变成受限制的非开源模式。没有这些扩展,MySQL也是功能完整的数据库。坦白地说,我们也很欣赏Oracle将更多的特性做成插件的开发模式。如果将特性直接包含在服务器中而不是API的方式,那就更加没有选择了:用户只能接受这种实现,而失去了选择更适合业务的实现的机会。例如,如果Oracle将InnoDB的全文索引功能以API的方式实现,那么就可能以同样的API实现Sphinx或者Lucene的插件,这可能对一些用户更有用。服务器内部的API设计也很干净,这对于提升代码质量非常有帮助,谁不想要这个呢?\n1.8 总结 # MySQL拥有分层的架构。上层是服务器层的服务和查询执行引擎,下层则是存储引擎。虽然有很多不同作用的插件API,但存储引擎API还是最重要的。如果能理解MySQL在存储引擎和服务层之间处理查询时如何通过API来回交互,就能抓住MySQL的核心基础架构的精髓。\nMySQL最初基于ISAM构建(后来被MyISAM取代),其后陆续添加了更多的存储引擎和事务支持。MySQL有一些怪异的行为是由于历史遗留导致的。例如,在执行ALTER TABLE时,MySQL提交事务的方式是由于存储引擎的架构直接导致的,并且数据字典也保存在*.frm*文件中(这并不是说InnoDB会导致ALTER变成非事务型的。对于InnoDB来说,所有的操作都是事务)。\n当然,存储引擎API的架构也有一些缺点。有时候选择多并非好事,而在MySQL 5.0和MySQL 5.1中有太多的存储引擎可以选择。InnoDB对于95%以上的用户来说都是最佳选择,所以其他的存储引擎可能只是让事情变得复杂难搞,当然也不可否认某些情况下某些存储引擎能更好地满足需求。\nOracle一开始收购了InnoDB,之后又收购了MySQL,在同一个屋檐下对于两者都是有利的。InnoDB和MySQL服务器之间可以更快地协同发展。MySQL依然基于GPL协议开放全部源代码,社区和客户都可以获得坚固而稳定的数据库,MySQL正在变得越来越可扩展和有用。\n————————————————————\n(1) InnoDB是一个例外,它会解析外键定义,因为MySQL服务器本身没有实现该功能。\n(2) MySQL 5.5或者更新的版本提供了一个API,支持线程池(Thread-Pooling)插件,可以使用池中少量的线程来服务大量的连接。\n(3) 这些锁定提示经常被滥用,实际上应当尽量避免使用。第6章有更详细的讨论。\n(4) MVCC并没有正式的规范,所以各个存储引擎和数据库系统的实现都是各异的,没有人能说其他的实现方式是错误的。\n(5) mysqlperformanceblog.com。——译者注\n(6) Oracle也已经收购了BerkeyDB。\n(7) “破坏性创新”一词出自Clayton M. Christensen的The Innovator\u0026rsquo;s Dilemma(Harper)。\n(8) GA(Generally Available)的意思是通常可用的版本,对于最挑剔的老板来说,这种版本也意味着达到了满足生产环境中使用的质量标准。\n"},{"id":147,"href":"/zh/docs/technology/MySQL/_%E9%AB%98%E6%80%A7%E8%83%BDMySQL_/%E7%AC%AC16%E7%AB%A0MySQL%E7%94%A8%E6%88%B7%E5%B7%A5%E5%85%B7/","title":"第16章MySQL用户工具","section":"高性能 My SQL","content":"第16章 MySQL用户工具\nMySQL服务器发行包中并没有包含针对许多常用任务的工具,例如监控服务器或比较不同服务器间数据的工具。幸运的是,Oracle的商业版提供了一些扩展工具,并且MySQL活跃的开源社区和第三方公司也提供了一系列的工具,降低了自己“重复发明轮子”的需要。\n16.1 接口工具 # 接口工具可以帮助运行查询,创建表和用户,以及执行其他日常任务等。本节将简单介绍一些用于此用途的最流行的工具。一般可以用SQL查询或命令做所有这些或其中大部分的工作——我们这里讨论的工具只是更为方便,可帮助避免错误和加快工作。\nMySQL Workbench\nMySQL Workbench是一个一站式的工具,可以完成例如管理服务器、写查询、开发存储过程,以及Schema设计图相关的工作。可以通过一个插件接口来编写自己的工具并集成到这个工作平台上,有一些Python脚本和库就使用了这个插件接口。MySQL Workbench有社区版和商业版两个版本,商业版只是增加了其他的一些高级特性。免费版对于大部分需要早已足够了。在 http://www.mysql.com/products/workbench/可以学到更多相关的内容。\nSQLyog\nSQLyog是MySQL最流行的可视化工具之一,有许多很好的特性。它与MySQL Workbench是同级别的工具,但两个工具都有一些对方没有的特性。SQLyog只能在微软的Windows下使用,拥有全部特性的版本需要付费,但有限制功能的免费版本。关于SQLyog的更多信息可以参考 http://www.webyog.com。\nphpMyAdmin\nphpMyAdmin是一个流行的管理工具,运行在Web服务器上,并且提供基于浏览器的MySQL服务器访问接口。尽管基于浏览器的访问有时很好,但phpMyAdmin是个大而复杂的工具,曾被指责有许多安全问题。对此要格外小心。我们建议不要安装在任何可以从互联网访问的地方。更多信息请参考 http://sourceforge.net/projects/phpmyadmin/。\nAdminer\nAdminer是个基于浏览器的安全的轻量级管理工具,它与phpMyAdmin同类。其开发者将其定位为phpMyAdmin的更好的替代品。尽管它看起来更安全,但我们仍建议安装在任何可公开访问的地方时要谨慎。更多详情可参考 http://www.adminer.org。\n16.2 命令行工具集 # MySQL包含了一些命令行工具集,例如mysqladmin和mysqlcheck。这些在MySQL手册上都有提及和记录。MySQL社区同样创建了大量高质量的工具包,并有很好的文档支撑这些实用工具集。\nPercona Toolkit\nPercona Toolkit是MySQL管理员必备的工具包。它源自Baron早期的工具包Maatkit和Aspersa,很多人认为这两个工具应该是正式的MySQL部署必须强制要求使用的。Percona Toolkit包括许多针对类似日志分析、复制完整性检测、数据同步、模式和索引分析、查询建议和数据归档目的的工具。如果刚开始接触MySQL,我们建议首先学习这些关键的工具:pt-mysql-summary、pt-table-checksum、pt-table-sync和pt-query-digest。更多信息可参考 http://www.percona.com/software/。\nMaatkit and Aspersa\n这两个工具约从2006年以某种形式出现,两者都被认为是MySQL用户的基本工具。它们现在已经并入 Percona Toolkit。\nThe openark kit\nShlomi Noach的openark kit( http://code.openark.org/forge/openark-kit)包含了可以用来做一系列管理任务的Python脚本。\nMySQL Workbench工具集\nMySQL Workbench工具集中的某些工具可以作为单独的Python脚本使用。可参考 https://launchpad.net/mysql-utilities。\n除了这些工具外,还有其他一系列没有太正式包装和维护的工具。许多杰出的MySQL社区成员时不时地贡献工具,其中大多数托管在他们自己的网站或MySQL Forge(http:// forge.mysql.com)上。可以通过不时地查看Planet MySQL博客聚合器获取大量的信息( http://planet.mysql.com),但不幸的是这些工具没有一个集中的目录。\n16.3 SQL实用集 # 服务器本身也内置有一系列免费的附加组件和实用集可以使用;其中一些确实相当强大。\ncommon_schema\nShlomi Noach的common_schema项目( http://code.openark.org/forge/common_schema)是一套针对服务器脚本化和管理的强大的代码和视图。common_schema对于MySQL好比jQuery对于JavaScript。\nmysql-sr-lib\nGiuseppe Maxia为MySQL创建了一个存储过程的代码库,可以在* http://www.nongnu.org/mysql-sr-lib/*找到。\nMySQL UDF仓库\nRoland Bouman建立了一个MySQL自定义函数的收藏馆,可以在 http://www. mysqludf.org获取。\nMySQL Forge\n在MySQL Forge上( http://forge.mysql.com),可以找到上百个社区贡献的程序、脚本、代码片断、实用集和技巧及陷阱。\n16.4 监测工具 # 以我们的经验来看,大多数MySQL商店需要提供两种类型的监测工具:健康监测工具——检测到异常时告警——和为趋势、诊断、问题排查、容量规划等记录指标的工具。大多数系统仅在这些任务中的一个方面做得很好,而不能两者兼顾。更不幸的是,有十几种工具可选,使得评估和选择一款适合的工具非常耗时。\n许多监控系统不是专门为MySQL服务器设计。它们是通用系统,用于周期性地检测许多种类型的资源,从机器到路由再到软件(例如MySQL)。它们一般有某些类型的插件架构,经常会伴随有一些MySQL插件。\n一般会在专用服务器上安装监控系统来监测其他服务器。如果是监控重要的系统,它很快会变成架构中至关重要的一部分,因此可能需要采取额外的步骤,例如做监控系统本身的灾备。\n16.4.1 开源的监控工具 # 下面是一些最受欢迎的开源集成监控系统。\nNagios\nNagios( http://www.nagios.org)也许是开源世界中最流行的问题检测和告警系统。它周期性检测监控的服务器并将结果与默认或自定义的阈值相比较。如果结果超出了限制,Nagios会执行某个程序并且(或)把问题的告警发给某些人。Nagios的通信和告警系统可以将告警发给不同的联系人,改变告警,或根据一天中的时间和其他条件将其发送到不同的位置,并且对计划内的宕机可以特殊处理。Nagios同样理解服务之间的依赖,因此,如果是因为中间的路由层宕机或者主机本身宕机导致MySQL实例不可用,Nagios不会发送告警来烦你。\nNagios能将任何一个可执行文件以插件形式运行,只要给予其正确参数就可得到正确输出。因此,Nagios插件在多种语言中都存在,例如shell、Perl、Python、Ruby和其他脚本语言。就算找不到一个能真正满足你需求的插件,自己创建一个也很简单。一个插件只需要接收标准的参数,以一个合适的状态退出,然后选择性地打印Nagios捕获的输出。\n然而,Nagios也有一些严重的缺点。即使你很了解它,也仍然难以维护。它将所有配置保存在文件而不是数据库中。文件有一个特别容易出错的语法,当系统增长和发展时,修改配置文件就很费事。Nagios可扩展性并不好;你可以很容易地写出监控插件,但这也就是你能够做的一切。最后,它的图形化、趋势化和可视化能力都有限。Nagios将一些性能和其他数据存储到MySQL服务器中,一般从中生成图形,但并不像其他一些系统那么灵活。因为不同“政见”的原因,使得上面所有的问题继续变得更糟。因为或真实、或臆测的涉及代码、参与者的问题,Nagios至少分化出了两个分支。两个分支的名字分别是Opsview( http://www.opsview.com)和Icinga( http://www.icinga.org)。它们比Nagios更受到人们的亲睐。\n有一些专门介绍Nagios的书籍;我们倾向于Wolfgang Barth的Nagios System and Network Monitoring(No Starch出版公司)。\nZabbix\nZabbix是一个同时支持监控和指标收集的完整系统。例如,它将所有配置和其他数据存储到数据库而不是配置文件中。它存储了比Nagios更多的数据类型,因而可以得到更好的趋势和历史报表。其网络画图和可视能力也比Nagios更强,配置更简单,更灵活,且更具可扩展性。可参考 http://www.zabbix.com获取更多信息。\nZenoss\nZenoss是用Python写的,拥有一个基于浏览器的用户界面,使用了Ajax,这使它更快和更高效。它可以自动发现网络上的资源,并将监控、告警、趋势、绘图和记录历史数据整合到了一个统一的工具中。Zenoss默认使用SNMP来从远程服务器上收集数据,但也可以使用SSH,并且支持Nagios插件。更多信息请参考http://www. zenoss.com。\nHyperic HQ\nHyperic HQ是一个基于Java的监控系统,比起同级别的其他大部分系统,它更称得上是企业级监控。像Zenoss一样,它可以自动发现网络上的资源和支持Nagios插件,但它的逻辑组织和架构不同,有点“笨重”。更多信息可参考 http://www.hyperic.com。\nOpenNMS\nOpenNMS也是用Java开发,有一个活跃的开发社区。它拥有常规的特性,例如监控和告警,但同样也增加了绘图和趋势功能。它的目标是高性能、可扩展、自动化和灵活。像Hyperic一样,它也致力于为大型和关键系统做企业级监控。更多信息请参考 http://www.opennms.org。\nGroundwork Open Source\nGroundwork Open Source用一个可移植的接口把Nagios和其他几个工具整合到了一个系统中。对于这个工具最好的描述是:如果你是Nagios、Cacti和其他几个工具方面的专家,并且花了许多时间将它们整合一起,那很可能你是在闭门造车。更多信息可参考 http://www.gwos.com。\n相比于集所有功能于一身的系统,还有一系列软件专注于收集指标和画图以及可视化,而不是进行性能监控检查。他们中有很多是建立在RRDTool( http://www.rrdtool.org)之上,存储时序数据到轮询数据库(RRD)文件中。RRD文件自动聚集输入数据,对没有预期传送的输入值进行插值,并有强大的绘图工具可以生成漂亮有特色的图。有很多基于RRDTool的系统,下面是其中最受欢迎的几个。\nMRTG\nMulti Router Traffic Grapher或称MRTG( http://oss.oetiker.ch/mrtg/),是典型的基于RRDTool的系统。最初是为记录网络流量而设计的,但同样可以扩展到用于对其他指标进行记录和绘图。\nCacti\nCacti ( http://www.cacti.net)可能是最流行的基于RRDTool的系统。它采用PHP网页来与RRDTool进行交互,并使用MySQL数据库来定义服务器、插件、图像等。因为是模板驱动,故而可以定义模板然后应用到系统上。Baron为MySQL和其他系统写了一组非常流行的模板;更多信息请参考 http://code.google.com/p/mysql-cacti-templates/。这些也已经被移植到Munin、OpenNMS和Zabbix。\nGanglia\nGanglia( http://ganglia.sourceforge.net)与Cacti类似,但是为监控集群和网格系统而设计,所以可以汇总查看许多服务器的数据,如果需要也可以细分查看单台服务器的详细数据。\nMunin\nMunin( http://munin.projects.linpro.no)收集数据并存入RRDTool中,然后以几个不同级别的粒度生成数据图。它从配置中生成静态HTML文件,因此可以很容易地浏览和查看趋势。定义一个图形较容易;只需要创建一个插件脚本,其命令行帮助输出有一些Munin可以识别的特别语法的画图指令。\n基于RRDTool的系统有些限制,例如不能用标准查询语言来查询存储的数据,不能永久保留数据,存在某些数据不能轻松地使用简单计数器和标准数值表示的问题,需要预先定义指标和图形等。理想情况下,我们需要的监控系统可以接受任何发送给它的指标,而不需要预先进行定义,并且后续可以绘制任意需要的图形,也不需要预先进行定义。可能我们所看到的最接近的系统是Graphite( http://graphite.wikidot.com)。\n这些系统都可以用来对MySQL收集、记录和绘制数据图表并且生成报表,有着不同程度的灵活性,目标也稍微有些不同。但它们都缺乏真正可以在问题出现时及时告警的灵活性。\n我们提到的大多数系统的主要问题是,它们明显是由那些因为现有系统不能满足他们所有需求的人设计的,因此他们又重复设计了另一个无法完全满足其他人的所有需求的系统。大部分这样的系统都有一些基础的限制,例如使用一个奇怪的数据模型存储内部数据,而导致在很多场合都无法很好地工作。在很多时候,这都令人沮丧,使用这些系统都像是把一个圆形的钉子钉到了一个方形的洞里面。\n16.4.2 商业监控系统 # 尽管我们知道许多MySQL用户热衷使用开源工具,但也有许多人愿意为合适的软件买单,只要这些软件可以让工作更好地完成,为他们节省时间,减少烦恼。下面是一些可以利用的商业选件。\nMySQL Enterprise Monitor\nMySQL Enterprise Monitor包含在Oracle的MySQL支持服务中。它将监控、指标和画图、咨询服务和查询分析等特性整合到了一个工具中。通过在服务器上使用agent来监测状态计数器(也包含操作系统的关键指标)。它能以两种方式抓取查询:通过MySQL代理(MySQL Proxy),或使用合适的MySQL连接器,例如Java的Connector/J或PHP的MySQLi。尽管是为监控MySQL而设计的,但某种程度上也可以进行扩展。同样,这个工具也无法监控基础架构中所有的服务器和所有的服务。更多信息请参考 http://www.mysql.com/products/enterprise/monitor.html。\nMONyog\nMONyog( http://www.webyog.com)是一个运行在桌面上的基于浏览器且无agent的监控系统。它会启动一个HTTP服务器,然后就可以通过浏览器来使用此工具。\nNew Relic\nNew Relic( http://newrelic.com)是一个托管式的软件即服务(Saas)的应用性能管理系统,它可以分析整个应用的性能,从应用代码(采用Ruby,PHP,Java和其他语言)到运行在浏览器上的JavaScript,到数据库的SQL调用,甚至是服务器的磁盘空间,CPU利用率和其它指标。\nCirconus\nCirconus( https://circonus.com)是一个源于OmniTI的托管式的软件即服务(SaaS)的指标和告警系统。通过agent从一个或多个服务器上收集指标并转发到Circonus,然后就可以通过一个基于浏览器的仪表盘来查看。\nMonitis\nMonitis( http://monitis.com)是另外一个云托管式的软件即服务(SaaS)的监控系统。它被设计成监控“一切”,这意味着它有点普遍性。它有一个入门级的免费版Monitor.us ( http://mon.itor.us),也有支持MySQL的插件。\nSplunk\nSplunk( http://www.splunk.com)是一个日志聚集器和搜索引擎,可以帮助获得环境中所有机器生成的数据并进行运营分析。\nPingdom\nPingdom ( http://www.pingdom.com)从世界的多个位置来监控网站的可用性和性能。实际上有许多像Pingdom一样的服务,我们并不需要特别推荐某一个这样的服务,但是我们确实建议使用一些外部的监控服务,以便让你在网站不可用时能够及时得到通知。很多类似的服务远不止Ping或获取网页。\n还有许多其他的商业监控工具——我们可以凭印象列举出十几个或更多。对所有监控系统而言,要注意的一点是它们对服务器的影响。有些工具相当直白,因为它们由一些没有实际的大型高负载MySQL系统经验的公司设计。例如,我们不止一次通过禁止每分钟对所有的数据库执行 一次SHOW TABLE STATUS的监控功能来解决突发事件。(这个命令在高I/O限制的系统上特别有破坏性。)频繁查询INFORMATION_SCHEMA表的工具也会导致负面影响。\n16.4.3 Innotop的命令行监控 # 有一些基于命令行的监测工具,它们大部分在某种方面模拟了UNIX中的top工具。其中最精致和最胜任的是innotop(http://code.google.com/p/innotop/),我们将详细探讨。此外,还有几个其他的工具,例如mtop*(http://mtop.sourceforge.net)*、mytop(http://jeremy.zawodny.com/mysql/mytop/)和一些基于网页的mytop克隆版本。\n尽管mytop是MySQL上最原始的top克隆,但innotop比mytop拥有更多功能,这也是我们看重innotop的原因。\n本书的作者之一Balon Schwartz编写了innotop。它展示了服务器正在发生事情的实时更新视图。别去理会它的名称,实际上它不仅仅用于监控InnoDB,还可以监控MySQL任何其他的方面。它也能同时监控多个MySQL 实例,极具可配置性和可扩展性。\n它的功能特性包括以下这些:\n事务列表可以显示InnoDB 当前的全部事务。 查询列表可以显示当前正在运行的查询。 可以显示当前锁和锁等待的列表。 以相对值显示服务器状态和变量的汇总信息。 有多种模式可用来显示InnoDB 内部信息,例如缓冲区、死锁、外键错误、I/O 活动情况、行操作、信号量,以及其他更多的内容。 复制监控,将主服务器和从服务器的状态显示在一起。 显示任意服务器变量的模式。 服务器组可以更方便地组织多台服务器。 在命令行脚本下可以使用非交互式模式。 innotop的安装很容易,可以从操作系统的软件仓库安装,也可以从* http://code.google.com/p/innotop/*下载到本地,然后解压缩,运行标准的make install安装过程。\nperl Makefile.PL make install 一旦安装完成,就可以在命令行里执行innotop,然后它会引导你完成连接到MySQL实例的过程。引导过程会读取*~/.my.cnf*选项文件,这样,除了输入服务器的主机名和按几次Enter键之外,什么都不用做。连接完成以后,就处在T(InnoDB Transaction)模式了,这时,应该可看到InnoDB 事务列表,如图16-1 所示。\n图16-1:处在T(InnoDB Transaction)模式的innotop\n默认情况下,innotop采用过滤器来减少零乱的信息(对于显示的所有信息,都可以定义自己的过滤器或者定制内部的过滤器)。在图16-1里,大多数事务都己经被过滤掉了,只显示出了当前活动的事务。可以按i 键禁掉过滤,让数量众多的事务信息填满整个屏幕。\ninnotop在这个模式下会显示头部信息和主线程列表。头部信息里显示一些InnoDB的总体信息,例如,历史清单的长度、还未清除的InnoDB 事务数目、缓冲池中脏缓冲所占的百分比等。\n你要按的第一个键应该是问号(?),以查看帮助信息。虽然在屏幕上显示出的帮助内容会根据当前模式的不同而不同,但是每一个活动的键都总是会显示出来,因此能看到所有可执行的动作。图16-2显示的是T模式下的帮助信息。\n图16-2:innotop 帮助信息\n在这里不会详细讲解所有的模式,但还是可以从帮助信息里看出,innotop有许许多多的功能特性。\n这里唯一要提及的是一些基本的自定义功能,告诉你如何监控想要监控的信息。innotop的强大功能之一就是能够解释用户定义的表达式,例如Uptime/Questions是生成每秒钟的查询指标。它会显示自服务器启动以来和/或自上次采样之后递增累加的结果值。\n这使得往显示表格里添加自己的列方便很多。例如,在Q(Query List)模式下,头部信息能显示出服务器的一些总体信息。让我们看看怎么将它修改一下,使它能显示出索引键缓存有多满。启动innotop,按下Q键进入Q模式。这时的操作结果看起来像图16-3一样。\n图16-3:Q模式(查询列表)下的innotop\n这个屏幕截图只截取了一部分,因为在这个练习里,我们对查询列表没有兴趣;我们只关心头部信息。\n头部显示了“当前”统计(统计自从上次innotop用服务器上的新数据刷新后的累计增量)和“总计”统计(统计自MySQL服务器启动以来所有的活动,这个实例中是25天前)。头部的每一列都是来自SHOW STATUS和SHOW VARIABLES相对应的变量值。图16-3中显示的头部是内建的,但也很容易增加自定义的。需要做的只是增加一列到头部“表”。按^键来打开表编辑器,然后在提示符后输入q_header来编辑头部表(图16-4)。由于内置有Tab键自动补齐功能,因此可以敲入q然后按Tab键来补充完成整个词。\n图16-4:增加一个头部(开始)\n在此之后,你将会看到Q模式头部的表定义(图16-5)。该表定义显示了表的列。第一列被选中。我们可以移动选项,重新排序和编辑列,还可做其他的很多事情(按?键可以看到一个完整的列表),但我们只打算创建一个新列。按n键然后输入列名(图16-6)。\n图16-5:增加头部(选择)\n图16-6:增加头部(命名列)\n接着,输入列的头部,它将在列的顶部显示(图16-7)。最后,选择列源。这是一个innotop内部编译为函数的表达式。你可以使用SHOW VARIABLES和SHOW STATUS中对应变量的名字,就像是方程中的变量一样。我们使用了一些括号和Perl式“或”默认值以防止被零除,除此而外这个等式相当直白。我们同样可以使用innotop中的percent()转换来以百分比形式格式化结果列;更多信息请参考innotop的文档。图16-8显示了这个表达式。\n图16-7:增加头部(列的文本)\n图16-8:增加头部(要计算的表达式)\n按Enter键,你将会和之前一样看到表的定义,但是在底部有了新增加的列。按几次+键将它往列表上方移,挨着key_buffer_hit列,然后按q键退出表编辑器。瞧,新的列嵌在KCacheHit和BpsIn之间(图16-9)。可以通过定制innotop很容易地监控想要的信息。如果它真的不能满足你的需求,甚至还可以编写对应的插件。更多文档见 http://code.google.com/p/innotop/。\n图16-9:增加头(结果)\n16.5 总结 # 好的工具对管理MySQL至关重要。推荐使用一些已经可用、广泛测试过、流行的工具,例如Percona Toolkit(旧名Maatkit)。当接触新的服务器时,实践中我们首先要做的是运行pt-summary和pt-mysql-summary。如果在一台服务器上工作,可能需要在另外一个终端下运行innotop来观察它以及任何相关的服务器。\n监控工具是另外一个更复杂的话题,这是由于它们对于管理非常重要。如果你是一名开源倡导者,想使用开源的监控系统,或许可以尝试Nagios结合带Baron的 Cacti模板的Cacti,或者尝试Zabbix,前提是作不介意复杂的接口。如果想要监控MySQL的商业工具, MySQL Enterprise Monitor可以胜任,我们知道有很多用户使用得很好。如果想监控整个环境和其中所有软硬件信息,你可能需要自己去做一些调查——这个话题超出了本书讨论的范围。\n"},{"id":148,"href":"/zh/docs/technology/MySQL/_%E9%AB%98%E6%80%A7%E8%83%BDMySQL_/%E7%AC%AC15%E7%AB%A0%E5%A4%87%E4%BB%BD%E4%B8%8E%E6%81%A2%E5%A4%8D/","title":"第15章备份与恢复","section":"高性能 My SQL","content":"第15章 备份与恢复\n如果没有提前做好备份规划,也许以后会发现已经错失了一些最佳的选择。例如,在服务器已经配置好以后,才想起应该使用LVM,以便可以获取文件系统的快照——但这时已经太迟了。在为备份配置系统参数时,可能没有注意到某些系统配置对性能有着重要影响。如果没有计划做定期的恢复演练,当真的需要恢复时,就会发现并没有那么顺利。\n相对于本书的第一版和第二版来说,我们在此假设大部分用户主要使用InnoDB而不是MyISAM。在本章中,我们不会涵盖一个精心设计的备份和恢复解决方案的所有部分——而仅涉及与MySQL相关的部分。我们不打算包括的话题如下:\n安全(访问备份,恢复数据的权限,文件是否需要加密)。 备份存储在哪里,包括它们应该离源数据多远(在一块不同的盘上,一台不同的服务器上,或离线存储),以及如何将数据从源头移动到目的地。 保留策略、审计、法律要求,以及相关的条款。 存储解决方案和介质,压缩,以及增量备份。 存储的格式。 对备份的监控和报告。 存储层内置备份功能,或者其他专用设备,例如预制式文件服务器。 像这样的话题已经在许多书中涉及,例如W. Curtis Preston的Backup& Recouery ( O\u0026rsquo;Reilly)。\n在开始本章之前,让我们先澄清几个核心术语。首先,经常可以听到所谓的热备份、暖备份和冷备份。人们经常使用这些词来表示一个备份的影响:例如,“热”备份不需要任何的服务停机时间。问题是对这些术语的理解因人而异。有些工具虽然在名字中使用了“热备份”,但实际上并不是我们所认为的那样。我们尽量避开这些术语,而直接说明某个特别的技术或工具对服务器的影响。\n另外两个让人困惑的词是还原和恢复。在本章中它们有其特定的含义。还原意味着从备份文件中获取数据,可以加载这些文件到MySQL里,也可以将这些文件放置到MySQL期望的路径中。恢复一般意味着当某些异常发生后对一个系统或其部分的拯救。包括从备份中还原数据,以及使服务器完全恢复功能的所有必要步骤,例如重启MySQL、改变配置和预热服务器的缓存等。\n在很多人的概念中,恢复仅意味着修复崩溃后损坏的表。这与恢复一个完整的服务器是不同的。存储引擎的崩溃恢复要求数据和日志文件一致。要确保数据文件中只包含已经提交的事务所做的修改,恢复操作会将日志中还没有应用到数据文件的事务重新执行。这也许是恢复过程的一部分,甚至是备份的一部分。然而,这和一个意外的DROP TABLE事故后需要做的事是不一样的。\n15.1 为什么要备份 # 下面是备份非常重要的几个理由:\n灾难恢复\n灾难恢复是下列场景下需要做的事情:硬件故障、一个不经意的Bug导致数据损坏,或者服务器及其数据由于某些原因不可获取或无法使用等。你需要准备好应付很多问题:某人偶然连错服务器执行了一个ALTER TABLE(1)的操作,机房大楼被烧毁,恶意的黑客攻击或MySQL的Bug等。尽管遭受任何一个特殊的灾难的几率都非常低,但所有的风险叠加在一起就很有可能会碰到。\n人们改变想法\n不必惊讶,很多人经常会在删除某些数据后又想要恢复这些数据。\n审计\n有时候需要知道数据或Schema在过去的某个时间点是什么样的。例如,你也许被卷入一场法律官司,或发现了应用的一个Bug,想知道这段代码之前干了什么(有时候,仅仅依靠代码的版本控制还不够)。\n测试\n一个最简单的基于实际数据来测试的方法是,定期用最新的生产环境数据更新测试服务器。如果使用备份的方案就非常简单:只要把备份文件还原到测试服务器上即可。检查你的假设。例如,你认为共享虚拟主机供应商会提供MySQL服务器的备份?许多主机供应商根本不备份MySQL服务器,另外一些也仅仅在服务器运行时复制文件,这可能会创建一个损坏的没有用处的备份。\n15.2 定义恢复需求 # 如果一切正常,那么永远也不需要考虑恢复。但是,一旦需要恢复,只有世界上最好的备份系统是没用的,还需要一个强大的恢复系统。\n不幸的是,让备份系统平滑工作比构造良好的恢复过程和工具更容易。原因如下:\n备份在先。只有已经做了备份才可能恢复,因此在构建系统时,注意力自然会集中在备份上。 备份由脚本和任务自动完成。经常不经意地,我们会花些时间调优备份过程。花5分钟来对备份过程做小的调整看起来并不重要,但是你是否天天同样地重视恢复呢? 备份是日常任务,但恢复常常发生在危急情形下。 因为安全的需要,如果正在做异地备份,可能需要对备份数据进行加密,或采取其他措施来进行保护。安全性往往只关注数据被盗用的后果,但是有没有人想过,如果没有人能对用来恢复数据的加密卷解锁,或需要从一个整块的加密文件中抽取单个文件时,损害又是多大? 只有一个人来规划、设计和实施备份。当灾难袭来时,那个人可能不在。因此需要培养几个人并有计划地互为备份,这样就不会要求一个不合格的人来恢复数据。 这里有一个我们看到的真实例子:一个客户报告说当mysqldump加上-d选项后,备份变得像闪电一般快,他想知道为什么没有一个人提出该选项可以如此快地加速备份过程。如果这个客户已经尝试还原这些备份,就不难发现其原因:使用-d选项将不会备份数据!这个客户关注备份,却没有关注恢复,因此完全没有意识到这个问题。\n规划备份和恢复策略时,有两个重要的需求可以帮助思考:恢复点目标(PRO)和恢复时间目标(RTO)。它们定义了可以容忍丢失多少数据,以及需要等待多久将数据恢复。在定义RPO和RTO时,先尝试回答下面几类问题:\n在不导致严重后果的情况下,可以容忍丢失多少数据?需要故障恢复,还是可以接受自从上次日常备份后所有的工作全部丢失?是否有法律法规的要求? 恢复需要在多长时间内完成?哪种类型的宕机是可接受的?哪种影响(例如,部分服务不可用)是应用和用户可以接受的?当那些场景发生时,又该如何持续服务? 需要恢复什么?常见的需求是恢复整个服务器,单个数据库,单个表,或仅仅是特定的事务或语句。 建议将上面这些问题的答案明确地用文档记录下来,同时还应该明确备份策略,以及备份过程。\n备份误区1:“复制就是备份”\n这是我们经常碰到的一个误区。复制不是备份,当然使用RAID阵列也不是备份。为什么这么说?可以考虑一下,如果意外地在生产库上执行了DROP DATABASE,它们是否可以帮你恢复所有的数据?RAID和复制连这个简单的测试都没法通过。它们不是备份,也不是备份的替代品。只有备份才能满足备份的要求。\n15.3 设计MySQL备份方案 # 备份MySQL比看起来难。最基本的,备份仅是数据的一个副本,但是受限于应用程序的要求、MySQL的存储引擎架构,以及系统配置等因素,会让复制一份数据都变得很困难。\n在深入所有选项细节之前,先来看一下我们的建议:\n在生产实践中,对于大数据库来说,物理备份是必需的:逻辑备份太慢并受到资源限制,从逻辑备份中恢复需要很长时间。基于快照的备份,例如Percona XtraBackup和MySQL Enterprise Backup是最好的选择。对于较小的数据库,逻辑备份可以很好地胜任。 保留多个备份集。 定期从逻辑备份(或者物理备份)中抽取数据进行恢复测试。 保存二进制日志以用于基于故障时间点的恢复。expire_logs_days参数应该设置得足够长,至少可以从最近两次物理备份中做基于时间点的恢复,这样就可以在保持主库运行且不应用任何二进制日志的情况下创建一个备库。备份二进制日志与过期设置无关,二进制日志备份需要保存足够长的时间,以便能从最近的逻辑备份进行恢复。 完全不借助备份工具本身来监控备份和备份的过程。需要另外验证备份是否正常。 通过演练整个恢复过程来测试备份和恢复。测算恢复所需要的资源(CPU、磁盘空间、实际时间,以及网络带宽等)。 对安全性要仔细考虑。如果有人能接触生产服务器,他是否也能访问备份服务器?反过来呢? 弄清楚RPO和RTO可以指导备份策略。是需要基于故障时间点的恢复能力,还是从昨晚的备份中恢复但会丢失此后的所有数据就足够了?如果需要基于故障时间点的恢复,可能要建立日常备份并保证所需要的二进制日志是有效的,这样才能从备份中还原,并通过重放二进制日志来恢复到想要的时间点。\n一般说来,能承受的数据丢失越多,备份越简单。如果有非常苛刻的需求,要确保能恢复所有数据,备份就很困难。基于故障时间点的恢复也有几类。一个“宽松”的故障时间点恢复需求意味着需要重建数据,直到“足够接近”问题发生的时刻。一个“硬性”的需求意味着不能容忍丢失任何一个已提交的事务,即使某些可怕的事情发生(例如服务器着火了)。这需要特别的技术,例如将二进制日志保存在一个独立的SAN卷或使用DRBD磁盘复制。\n15.3.1 在线备份还是离线备份 # 如果可能,关闭MySQL做备份是最简单最安全的,也是所有获取一致性副本的方法中最好的,而且损坏或不一致的风险最小。如果关闭了MySQL,就根本不用关心InnoDB缓冲池中的脏页或其他缓存。也不需要担心数据在尝试备份的过程被修改,并且因为服务器不对应用提供访问,所以可以更快地完成备份。\n尽管如此,让服务器停机的代价可能比看起来要更昂贵。即使能最小化停机时间,在高负载和高数据量下关闭和重启MySQL也可能要花很长一段时间,这在第8章中讨论过。我们演示过一些使这个影响最小化的技术,但并不能将其减少为零。因此,必须要设计不需要生产服务器停机的备份。即便如此,由于一致性的需要,对服务器进行在线备份仍然会有明显的服务中断。\n在众多的备份方法中,一个最大问题就是它们会使用FLUSH TABLES WITH READ LOCK操作。这会导致MySQL关闭并锁住所有的表,将MyISAM的数据文件刷新到磁盘上(但InnoDB不是这样的!),并且刷新查询缓存。该操作需要非常长的时间来完成。具体需要多长时间是不可预估的;如果全局读锁要等待一个长时间运行的语句完成,或有许多表,那么时间会更长。除非锁被释放,否则就不能在服务器上更改任何数据,一切都会被阻塞和积压(2)。FLUSH TABLES WITH READ LOCK不像关闭服务器的代价那么高,因为大部分缓存仍然在内存中,并且服务器一直是“预热”的,但是它也有非常大的破坏性。如果有人说这样做很快,可能是准备向你推销某种从来没有在真正的线上服务器上运行过的东西。\n避免使用FLUSH TABLES WITH READ LOCK的最好的方法是只使用InnoDB表。在权限和其他系统信息表中使用MyISAM表是不可避免的,但是如果数据改变量很少(正常情况下),你可以只刷新和锁住这些表,这不会有什么问题。\n在规划备份时,有一些与性能相关的因素需要考虑。\n锁时间\n需要持有锁多长时间,例如在备份期间持有的全局FLUSH TABLES WITH READ LOCK?\n备份时间\n复制备份到目的地需要多久?\n备份负载\n在复制备份到目的地时对服务器性能的影响有多少?\n恢复时间\n把备份镜像从存储位置复制到MySQL服务器,重放二进制日志等,需要多久?\n最大的权衡是备份时间与备份负载。可以牺牲其一以增强另外一个。例如,可以提高备份的优先级,代价是降低服务器性能。\n同样,也可以利用负载的特性来设计备份。例如,如果服务器在晚上的8小时内仅仅有50%的负载,那么可以尝试规划备份,使得服务器的负载低于50%且仍能在8小时内完成。可以采用许多方法来完成这个目标,例如,可以用ionice和nice来提高复制或压缩操作的优先级,使用不同的压缩等级,或在备份服务器上压缩而不是在MySQL服务器上。甚至可以利用lzo或pigz以获取更快的压缩。也可以使用O_DIRECT或fadvise()在复制操作时绕开操作系统的缓存,以避免污染服务器的缓存。像Percona XtraBackup和MySQL Enterprise Backup这样的工具都有限流选项,可在使用pv时加\u0026ndash;rate-limit选项来限制备份脚本的吞吐量。\n15.3.2 逻辑备份还是物理备份 # 有两种主要的方法来备份MySQL数据:逻辑备份(也叫“导出”)和直接复制原始文件的物理备份。逻辑备份将数据包含在一种MySQL能够解析的格式中,要么是SQL,要么是以某个符号分隔的文本(3)。原始文件是指存在于硬盘上的文件。\n任何一种备份都有其优点和缺点。\n逻辑备份 # 逻辑备份有如下优点:\n逻辑备份是可以用编辑器或像grep和sed之类的命令查看和操作的普通文件。当需要恢复数据或只想查看数据但不恢复时,这都非常有帮助。 恢复非常简单。可以通过管道把它们输入到mysql,或者使用mysqlimport。 可以通过网络来备份和恢复——就是说,可以在与MySQL主机不同的另外一台机器上操作。 可以在类似Amazon RDS这样不能访问底层文件系统的系统中使用。 非常灵活,因为mysqldump——大部分人喜欢的工具——可以接受许多选项,例如可以用WHERE子句来限制需要备份哪些行。 与存储引擎无关。因为是从MySQL服务器中提取数据而生成,所以消除了底层数据存储和不同。因此,可以从InnoDB表中备份,然后只需极小的工作量就可以还原到MyISAM表中。而对于原始数据却不能这么做。 有助于避免数据损坏。如果磁盘驱动器有故障而要复制原始文件时,你将会得到一个错误并且/或生成一个部分或损坏的备份。如果MySQL在内存中的数据还没有损坏,当不能得到一个正常的原始文件复制时,有时可以得到一个可以信赖的逻辑备份。 尽管如此,逻辑备份也有它的缺点:\n必须由数据库服务器完成生成逻辑备份的工作,因此要使用更多的CPU周期。 逻辑备份在某些场景下比数据库文件本身更大(4)。ASCII形式的数据不总是和存储引擎存储数据一样高效。例如,一个整型需要4字节来存储,但是用ASCII写入时,可能需要12个字符。当然也可以压缩文件以得到一个更小的备份文件,但这样会使用更多的CPU资源。(如果索引比较多,逻辑备份一般要比物理备份小。) 无法保证导出后再还原出来的一定是同样的数据。浮点表示的问题、软件Bug等都会导致问题,尽管非常少见。 从逻辑备份中还原需要MySQL加载和解释语句,转化为存储格式,并重建索引,所有这一切会很慢。 最大的缺点是从MySQL中导出数据和通过SQL语句将其加载回去的开销。如果使用逻辑备份,测试恢复需要的时间将非常重要。\nPercona Server中包含的mysqldump,在使用InnoDB表时能起到帮助作用,因为它会对输出格式化,以便在重新加载时利用InnoDB的快速建索引的优点。我们的测试显示这样做可以减少2/3甚至更多的还原时间。索引越多,好处越明显。\n物理备份 # 物理备份有如下好处:\n基于文件的物理备份,只需要将需要的文件复制到其他地方即可完成备份。不需要其他额外的工作来生成原始文件。 物理备份的恢复可能就更简单了,这取决于存储引擎。对于MyISAM,只需要简单地复制文件到目的地即可。对于InnoDB则需要停止数据库服务,可能还要采取其他一些步骤。 InnoDB和MyISAM的物理备份非常容易跨平台、操作系统和MySQL版本。(逻辑导出亦如此。这里特别指出这一点是为了消除大家的担心。) 从物理备份中恢复会更快,因为MySQL服务器不需要执行任何SQL或构建索引。如果有很大的InnoDB表,无法完全缓存到内存中,则物理备份的恢复要快非常多——至少要快一个数量级。事实上,逻辑备份最可怕的地方就是不确定的还原时间。 物理备份也有其缺点,比如:\nInnoDB的原始文件通常比相应的逻辑备份要大得多。InnoDB的表空间往往包含很多未使用的空间。还有很多空间被用来做存储数据以外的用途(插入缓冲,回滚段等)。 物理备份不总是可以跨平台、操作系统及MySQL版本。文件名大小写敏感和浮点格式是可能会遇到麻烦。很可能因浮点格式不同而不能移动文件到另一个系统(虽然主流处理器都使用IEEE浮点格式。) 物理备份通常更加简单高效(5)。尽管如此,对于需要长期保留的备份,或者是满足法律合规要求的备份,尽量不要完全依赖物理备份。至少每隔一段时间还是需要做一次逻辑备份。\n除非经过测试,不要假定备份(特别是物理备份)是正常的。对InnoDB来说,这意味着需要启动一个MySQL实例,执行InnoDB恢复操作,然后运行CHECK TABLES。也可以跳过这一操作,仅对文件运行innochecksum,但我们不建议这样做。对于MyISAM,可以运行CHECK TABLES,或者使用mysqlcheck。使用mysqlcheck可以对所有的表执行CHECK TABLES操作。\n建议混合使用物理和逻辑两种方式来做备份:先使用物理复制,以此数据启动MySQL服务器实例并运行mysqlcheck。然后,周期性地使用mysqldump执行逻辑备份。这样做可以获得两种方法的优点,不会使生产服务器在导出时有过度负担。如果能够方便地利用文件系统的快照,也可以生成一个快照,将该快照复制到另外一个服务器上并释放,然后测试原始文件,再执行逻辑备份。\n15.3.3 备份什么 # 恢复的需求决定需要备份什么。最简单的策略是只备份数据和表定义,但这是一个最低的要求。在生产环境中恢复数据库一般需要更多的工作。下面是MySQL备份需要考虑的几点。\n非显著数据\n不要忘记那些容易被忽略的数据:例如,二进制日志和InnoDB事务日志。\n代码\n现代的MySQL服务器可以存储许多代码,例如触发器和存储过程。如果备份了mysql数据库,那么大部分这类代码也备份了,但如果需要还原单个业务数据库会比较麻烦,因为这个数据库中的部分“数据”,例如存储过程,实际是存放在mysql数据库中的。\n复制配置\n如果恢复一个涉及复制关系的服务器,应该备份所有与复制相关的文件,例如二进制日志、中继日志、日志索引文件和.info文件。至少应该包含SHOW MASTER STATUS和/或SHOW SLAVE STATUS的输出。执行FLUSH LOGS也非常有好处,可以让MySQL从一个新的二进制日志开始。从日志文件的开头做基于故障时间点的恢复要比从中间更容易。\n服务器配置\n假设要从一个实际的灾难中恢复,比如说,地震过后在一个新数据中心中构建服务器,如果备份中包含服务器配置,你一定会喜出望外。\n选定的操作系统文件\n对于服务器配置来说,备份中对生产服务器至关重要的任何外部配置,都十分重要。在UNIX服务器上,这可能包括cron任务、用户和组的配置、管理脚本,以及sudo规则。\n这些建议在许多场景下会被当作“备份一切”。然而,如果有大量的数据,这样做的开销将非常高,如何做备份,需要更加明智的考虑。特别是,可能需要在不同备份中备份不同的数据。例如,可以单独地备份数据、二进制日志和操作系统及系统配置。\n增量备份和差异备份 # 当数据量很庞大时,一个常见的策略是做定期的增量或差异备份。它们之间的区别有点容易让人混淆,所以先来澄清这两个术语:差异备份是对自上次全备份后所有改变的部分而做的备份,而增量备份则是自从任意类型的上次备份后所有修改做的备份。\n例如,假如在每周日做一个全备份。在周一,对自周日以来所有的改变做一个差异备份。在周二,就有两个选择:备份自周日以来所有的改变(差异),或只备份自从周一备份后所有的改变(增量)。\n增量和差异备份都是部分备份:它们一般不包含完整的数据集,因为某些数据几乎肯定没有改变。部分备份对减少服务器开销、备份时间及备份空间而言都很适合。尽管某些部分备份并不会真正减少服务器的开销。例如,Percona XtraBackup和MySQL Enterprise Backup,仍然会扫描服务器上的所有数据块,因而并不会节约太多的开销,但它们确实会减少一定量的备份时间和大量用于压缩的CPU时间,当然也会减少磁盘空间使用(6)。\n不要因为会用高级备份技术而自负,解决方案越复杂,可能面临的风险也越大。要注意分析隐藏的危险,如果多次迭代备份紧密地耦合在一起,则只要其中的一次迭代备份有损坏,就可能会导致所有的备份都无效。\n下面有一些建议:\n使用Percona XtraBackup和MySQL Enterprise Backup中的增量备份特性。 备份二进制日志。可以在每次备份后使用FLUSH LOGS来开始一个新的二进制日志,这样就只需要备份新的二进制日志。 不要备份没有改变的表。有些存储引擎,例如MyISAM,会记录每个表最后修改时间。可以通过查看磁盘上的文件或运行SHOW TABLE STATUS来看这个时间。如果使用InnoDB,可以利用触发器记录修改时间到一个小的“最后修改时间”表中,帮助跟踪最新的修改操作。需要确保只对变更不频繁的表进行跟踪,这样才能降低开销。通过定制的备份脚本可以轻松获取到哪些表有变更。\n例如,如果有包含不同语种各个月的名称列表,或者州或区域的简写之类的“查找”表,将它们放在一个单独的数据库中是个好主意,这样就不需要每次都备份这些表。 不要备份没有改变的行。如果一个表只做插入,例如记录网页页面点击的表,那么可以增加一个时间戳的列,然后只备份自上次备份后插入的行。 某些数据根本不需要备份。有时候这样做影响会很大——例如,如果有一个从其他数据构建的数据仓库,从技术上讲完全是冗余的,就可以仅备份构建仓库的数据,而不是数据仓库本身。即使从源数据文件重建仓库的“恢复”时间较长,这也是个好想法。相对于从全备中可能获得的快速恢复时间,避免备份可以节约更多的总的时间开销。临时数据也可以不用备份,例如保留网站会话数据的表。 备份所有的数据,然后发送到一个有去重特性的目的地,例如ZFS文件管理程序。 增量备份的缺点包括增加恢复复杂性,额外的风险,以及更长的恢复时间。如果可以做全备,考虑到简便性,我们建议尽量做全备。\n不管如何,还是需要经常做全备份——建议至少一周一次。你肯定不会希望使用一个月的所有增量备份来进行恢复。即使一周也还是有很多的工作和风险的。\n15.3.4 存储引擎和一致性 # MySQL对存储引擎的选择会导致备份明显更复杂。问题是,对于给定的存储引擎,如何得到一致的备份。\n实际上有两类一致性需要考虑:数据一致性和文件一致性。\n数据一致性 # 当备份时,应该考虑是否需要数据在指定时间点一致。例如,在一个电子商务数据库中,可能需要确保发货单和付款之间一致。恢复付款时如果不考虑相应的发货单,或反过来,都会导致麻烦。\n如果做在线备份(从一个运行的服务器做备份),可能需要所有相关表的一致性备份。这意味着不能一次锁住一张表然后做备份——因而意味着备份可能比预想的要更有侵入性。如果使用的不是事务型存储引擎,则只能在备份时用LOCK TABLES来锁住所有要一起备份的表,备份完成后再释放锁。\nInnoDB的多版本控制功能可以帮到我们。开始一个事务,转储一组相关的表,然后提交事务。(如果使用了事务获取一致性备份,则不能用LOCK TABLES,因为它会隐式地提交事务——详情参见MySQL手册。)只要在服务器上使用REPEATABLE READ事务隔离级别,并且没有任何DDL,就一定会有完美的一致性,以及基于时间点的数据快照,且在备份过程中不会阻塞任何后续的工作。\n尽管如此,这种方法并不能保护逻辑设计很差的应用。假如在电子商务库中插入一条付款记录,提交事务,然后在另外一个事务中插入一条发货单记录。备份过程可能在这两个操作之间开始,备份了付款记录却不包括发货单记录。这就是必须仔细设计事务以确保相关的操作放在一个组内的原因。\n也可以用mysqldump来获得InnoDB表的一致性逻辑备份,采用\u0026ndash;single-transaction选项可以按照我们所描述的那样工作。但是,这可能会导致一个非常长的事务,在某些负载下会导致开销大到不可接受。\n文件一致性 # 每个文件的内部一致性也非常重要——例如,一条大的UPDATE语句执行时备份反映不出文件的状态——并且所有要备份的文件相互间也应一致。如果没有内部一致的文件,还原时可能会感到惊讶(它们可能已经损坏)。如果是在不同的时间复制相关的文件,它们彼此可能也不一致。MyISAM的.MYD和.MYI文件就是个例子。InnoDB如果检测到不一致或损坏,会记录错误日志乃至让服务器崩溃。\n对于非事务性存储引擎,例如MyISAM,可能的选项是锁住并刷新表。这意味着要么用LOCK TABLES和FLUSH TABLES结合的方法以使服务器将内存中的变更刷到磁盘上,要么用FLUSH TABLES WITH READ LOCK。一旦刷新完成,就可以安全地复制MyISAM的原始文件。\n对于InnoDB,确保文件在磁盘上一致更困难。即使使用FLUSH TABLES WITH READ LOCK,InnoDB依旧在后台运行:插入缓存、日志和写线程继续将变更合并到日志和表空间文件中。这些线程设计上是异步的——在后台执行这些工作可以帮助InnoDB取得更高的并发性——正因为如此它们与LOCK TABLES无关。因此,不仅需要确保每个文件内部是一致的,还需要同时复制同一个时间点的日志和表空间文件。如果在备份时有其他线程在修改文件,或在与表空间文件不同的时间点备份日志文件,会在恢复后再次因系统损坏而告终。可以通过下面几个方法规避这个问题。\n等待直到InnoDB的清除线程和插入缓冲合并线程完成。可以观察SHOW INNODB STATUS的输出,当没有脏缓存或挂起的写时,就可以复制文件。尽管如此,这种方法可能需要很长一段时间;因为InnoDB的后台线程涉及太多的干扰而不太安全。所以我们不推荐这种方法。 在一个类似LVM的系统中获取数据和日志文件一致的快照,必须让数据和日志文件在快照时相互一致;单独取它们的快照是没有意义的。在本章后续的LVM快照中会讨论。 发送一个STOP信号给MySQL,做备份,然后再发送一个CONT信号来再次唤醒MySQL。看起来像是一个很少推荐的方法,但如果另外一种方法是在备份过程中需要关闭服务器,则这种方法值得考虑。至少这种技术不需要在重启服务器后预热。 在复制数据文件到其他地方后,就可以释放锁以使MySQL服务器再次正常运行。\n复制 # 从备库中备份最大的好处是可以不干扰主库,避免在主库上增加额外的负载。这是一个建立备库的好理由,即使不需要用它做负载均衡或高可用。如果钱是个问题,也可以把备份用的备库用于其他用途,例如报表服务——只要不对其做写操作,以确保备份时不会修改数据。备库不必只用于备份的目的;只需要在下次备份时能及时跟上主库,即使有时因作为其他用途导致复制延时也没有关系。\n当从备库备份时,应该保存所有关于复制进程的信息,例如备库相对于主库的位置。这对于很多情况都非常有用:克隆新的备库,重新应用二进制日志到主库上以获得指定时间点的恢复,将备库提升为主库等。如果停止备库,需要确保没有打开的临时表,因为它们可能导致不能重启备库。\n故意将一个备库延时一段时间对于某些灾难场景非常有用。例如延时复制一小时,当一个不期望的语句在主库上运行后,将有一个小时的时间观察到并在从中继日志重放之前停掉复制。然后可以将备库提升为主库,重放少量相关的日志事件,跳过错误的语句。这比我们后面将要讨论的指定时间点的恢复技术可能要快很多。Percona Toolkit中pt-slave-delay工具可以帮助实现这个方案。\n备库可能与主库数据不完全一样。许多人认为备库是主库完全一样的副本,但以我们的经验,主库与备库数据不匹配是很常见的,并且MySQL没有方法检测这个问题。检测这个问题的唯一方法是使用Percona Toolkit中的pt-table-checksum之类的工具。\n拥有一个复制的备库可能在诸如主库的硬盘烧坏时提供帮助,但却不能提供保证。复制不是备份。\n15.4 管理和备份二进制日志 # 服务器的二进制日志是备份的最重要因素之一。它们对于基于时间点的恢复是必需的,并且通常比数据要小,所以更容易进行频繁的备份。如果有某个时间点的数据备份和所有从那时以后的二进制日志,就可以重放自从上次全备以来的二进制日志并“前滚”所有的变更。\nMySQL复制也使用二进制日志。因此备份和恢复的策略经常和复制配置相互影响。\n二进制日志很“特别”。如果丢失了数据,你一定不希望同时丢失了二进制日志。为了让这种情况发生的几率减少到最小,可以在不同的卷上保存数据和二进制日志。即使在LVM下生成二进制日志的快照,也是可以的。为了额外的安全起见,可以将它们保存在SAN上,或用DRBD复制到另外一个设备上。\n经常备份二进制日志是个好主意。如果不能承受丢失超过30分钟数据的价值,至少要每30分钟就备份一次。也可以用一个配置\u0026ndash;log_slave_update的只读备库,这样可以获得额外的安全性。备库上日志位置与主库不匹配,但找到恢复时正确的位置并不难。最后,MySQL 5.6版本的mysqlbinlog有一个非常方便的特性,可连接到服务器上来实时对二进制日志做镜像,比起运行一个mysqld实例要简单和轻便。它与老版本是向后兼容的。\n请参考第8章和第10章中我们推荐的关于二进制日志的服务器配置。\n15.4.1 二进制日志格式 # 二进制日志包含一系列的事件。每个事件有一个固定长度的头,其中有各种信息,例如当前时间戳和默认的数据库。可以使用mysqlbinlog工具来查看二进制日志的内容,打印出一些头信息。下面是一个输出的例子。\n1 # at 277 2 #071030 10:47:21 server id 3 end_log_pos 369 Query thread_id=13 exec_time=0 error_code=0 3 SET TIMESTAMP=1193755641/*!*/; 4 insert into test(a) values(2)/*!*/; 第一行包含日志文件内的偏移字节值(本例中为277)。\n第二行包含如下几项。\n事件的日期和时间,MySQL会使用它们来产生SET TIMESTAMP语句。 原服务器的服务器ID,对于防止复制之间无限循环和其他问题是非常有必要的。 end_log_pos,下一个事件的偏移字节值。该值对一个多语句事务中的大部分事件是不正确的。在此类事务过程中,MySQL的主库会复制事件到一个缓冲区,但这样做的时候它并不知道下个日志事件的位置。 事件类型。本例中的类型是Query,但还有许多不同的类型。 原服务器上执行事件的线程ID,对于审计和执行CONNECTION_ID()函数很重要。 exec_time,这是语句的时间戳和写入二进制日志的时间之差。不要依赖这个值,因为它可能在复制落后的备库上会有很大的偏差。 在原服务器上事件产生的错误代码。如果事件在一个备库上重放时导致不同的错误,那么复制将因安全预警而失败。 后续的行包含重放变更时所需的数据。用户自定义的变更和任何其他特定设置,例如当语句执行时有效的时间戳,也将会出现在这里。\n如果使用的是MySQL 5.1中基于行的日志,事件将不再是SQL。而是可读性较差的由语句对表所做变更的“镜像”。\n15.4.2 安全地清除老的二进制日志 # 需要决定日志的过期策略以防止磁盘被二进制日志写满。日志增长多大取决于负载和日志格式(基于行的日志会导致更大的日志记录)。我们建议,如果可能,只要日志有用就尽可能保留。保留日志对于设置复制、分析服务器负载、审计和从上次全备按时间点进行恢复,都很有帮助。当决定想要保留日志多久时,应该考虑这些需求。\n一个常见的设置是使用expire_log_days变量来告诉MySQL定期清理日志。这个变量直到MySQL 4.1才引入;在此之前的版本,必须手动清理二进制日志。因此,你可能看到一些用类似下面的cron项来删除老的二进制日志的建议。\n0 0 * * * /usr/bin/find /var/log/mysql -mtime +* N* -name \u0026quot;mysql-bin.[0-9]*\u0026quot; | xargs rm 尽管这是在MySQL 4.1之前清除日志的唯一办法,但在新版本中不要这么做!用rm删除日志会导致mysql-bin.index状态文件与磁盘上的文件不一致,有些语句,例如SHOW MASTER LOGS可能会受到影响而悄然失败。手动修改mysql-bin.index文件也不会修复这个问题。应该用类似下面的cron命令。\n** 0 0 * * * /usr/bin/mysql -e \u0026quot;PURGE MASTER LOGS BEFORE CURRENT_DATE - INTERVAL***** N***** DAY\u0026quot;** expire_logs_days设置在服务器启动或MySQL切换二进制日志时生效,因此,如果二进制日志从没有增长和切换,服务器不会清除老条目。此设置是通过查看日志的修改时间而不是内容来决定哪个文件需要被清除。\n15.5 备份数据 # 大多数时候,生成备份有好的也有差的方法——有时候显而易见的方法并不是好方法。一个有用的技巧是应该最大化利用网络、磁盘和CPU的能力以尽可能快地完成备份。这是一个需要不断去平衡的事情,必须通过实验以找到“最佳平衡点”。\n15.5.1 生成逻辑备份 # 对于逻辑备份,首先要意识到的是它们并不是以同样方式创建的。实际上有两种类型的逻辑备份:SQL导出和符号分隔文件。\nSQL导出 # SQL导出是很多人所熟悉的,因为它们是mysqldump默认的方式。例如,用默认选项导出一个小表将产生如下(有删减)输出。\n** $ mysqldump test t1** ** -- [Version and host comments]** ** /*!40101 SET @OLD_CHARACTER_SET_CLIENT=@@CHARACTER_SET_CLIENT */;** ** -- [More version-specific comments to save options for restore]** ** --** ** -- Table structure for table `t1`** ** --** ** DROP TABLE IF EXISTS `t1`;** ** CREATE TABLE `t1` (** ** `a` int(11) NOT NULL,** ** PRIMARY KEY (`a`)** ** ) ENGINE=MyISAM DEFAULT CHARSET=latin1;** ** --** ** -- Dumping data for table `t1`** ** --** ** LOCK TABLES `t1` WRITE;** ** /*!40000 ALTER TABLE `t1` DISABLE KEYS */;** ** INSERT INTO `t1` VALUES (1);** ** /*!40000 ALTER TABLE `t1` ENABLE KEYS */;** ** UNLOCK TABLES;** ** /*!40103 SET TIME_ZONE=@OLD_TIME_ZONE */;** ** /*!40101 SET SQL_MODE=@OLD_SQL_MODE */;** ** -- [More option restoration]** 导出文件包含表结构和数据,均以有效的SQL命令形式写出。文件以设置MySQL各种选项的注释开始。这些要么是为了使恢复工作更高效,要么是因为兼容性和正确性。接下来可以看到表结构,然后是数据。最后,脚本重置在导出开始时变更的选项。\n导出的输出对于还原操作来说是可执行的。这很方便,但mysqldump默认选项对于生成一个巨大的备份却不是太适合(后续我们会深入介绍mysqldump的选项)。\nmysqldump不是生成SQL逻辑备份的唯一工具。例如,也可以用mydumper或phpMyAdmin工具来创建(7)。我们想指出的是,不是某一个特定的工具有多大的问题,而是做SQL逻辑备份本身就有一些缺点。下面是主要问题点:\nSchema和数据存储在一起\n如果想从单个文件恢复这样做会非常方便,但如果只想恢复一个表或只想恢复数据就很困难了。可以通过导出两次的方法来减缓这个问题——一次只导出数据,另外一次只导出Schema——但还是会有下一个麻烦。\n巨大的SQL语句\n服务器分析和执行SQL语句的工作量非常大,所以加载数据时会非常慢。\n单个巨大的文件\n大部分文本编辑器不能编辑巨大的或者包含非常长的行的文件。尽管有时候可以用命令行的流编辑器——例如sed或grep——来抽出需要的数据,但保持文件小型化仍然是更合适的。\n逻辑备份的成本很高\n比起逻辑备份这种从存储引擎中读取数据然后通过客户端/服务器协议发送结果集的方式,还有其他更高效的方法。\n这些限制意味着SQL导出在表变大时可能变得不可用。不过,还有另外一个选择:导出数据到符号分隔的文件中。\n符号分隔文件备份 # 可以使用SQL命令 SELECT INTO OUTFILE以符号分隔文件格式创建数据的逻辑备份。(可以用mysqldump的\u0026ndash;tab选项导出到符号分隔文件中)。符号分隔文件包含以ASCII展示的原始数据,没有SQL、注释和列名。下面是一个导出为逗号分隔值(CVS)格式的例子,对于表格形式的数据来说这是一个很好的通用格式。\n** mysql\u0026gt; SELECT * INTO OUTFILE '/tmp/t1.txt'** ** -\u0026gt; FIELDS TERMINATED BY ',' OPTIONALLY ENCLOSED BY '\u0026quot;'** ** -\u0026gt; LINES TERMINATED BY '\\n'** ** -\u0026gt; FROM test.t1;** 比起SQL导出文件,符号分隔文件要更紧凑且更易于用命令行工具操作,这种方法最大的优点是备份和还原速度更快。可以和导出时使用一样的选项,用LOAD DATA INFILE方法加载数据到表中:\n** mysql\u0026gt; LOAD DATA INFILE '/tmp/t1.txt'** ** -\u0026gt; INTO TABLE test.t1** ** -\u0026gt; FIELDS TERMINATED BY ',' OPTIONALLY ENCLOSED BY '\u0026quot;'** ** -\u0026gt; LINES TERMINATED BY '\\n';** 下面这个非正式的测试演示了SQL文件和符号分隔文件在备份和还原上的速度差异。在测试中,我们对生产数据做了些修改。导出的表看起来像下面这样:\n** CREATE TABLE load_test (** ** col1 date NOT NULL,** ** col2 int NOT NULL,** ** col3 smallint unsigned NOT NULL,** ** col4 mediumint NOT NULL,** ** col5 mediumint NOT NULL,** ** col6 mediumint NOT NULL,** ** col7 decimal(3,1) default NULL,** ** col8 varchar(10) NOT NULL default '',** ** col9 int NOT NULL,** ** PRIMARY KEY (col1, col2)** ** ) ENGINE=InnoDB;** 这张表有1500万行,占用近700MB的磁盘空间。表15-1对比了两种备份和还原方法的性能。可以看到测试中还原时间有较大的差异。\n表15-1:SQL和符号分隔导出所用的备份和恢复时间 方法 导出大小 导出时间 还原时间 SQL导出 727 MB 102 600 符号分隔导出 669 MB 86 301\n但是SELECT INTO OUTFILE方法也有一些限制。\n只能备份到运行MySQL服务器的机器上的文件中。(可以写一个自定义的SELECT INTO OUTFILE程序,在读取SELECT结果的同时写到磁盘文件中,我们已经看到有些人采用这种方法。) 运行MySQL的系统用户必须有文件目录的写权限,因为是由MySQL服务器来执行文件的写入,而不是运行SQL命令的用户。 出于安全原因,不能覆盖已经存在的文件,不管文件权限如何。 不能直接导出到压缩文件中。 某些情况下很难进行正确的导出或导入,例如非标准的字符集。 15.5.2 文件系统快照 # 文件系统快照是一种非常好的在线备份方法。支持快照的文件系统能够瞬间创建用来备份的内容一致的镜像。支持快照的文件系统和设备包括FreeBSD的文件系统、ZFS文件系统、GNU/Linux的逻辑卷管理(LVM),以及许多的SAN系统和文件存储解决方案,例如NetApp存储。\n不要把快照和备份相混淆。创建快照是减少必须持有锁的时间的一个简单方法;释放锁后,必须复制文件到备份中。事实上,有些时候甚至可以创建InnoDB快照而不需要锁定。我们将要展示两种使用LVM来对InnoDB文件系统做备份的方法,可以选择最小化锁或零锁的方案。\n快照对于特别用途的备份是一个非常好的方法。一个例子是在升级过程中遇到有问题而回退的情况。可以在升级前创建一个镜像,这样如果升级有问题,只需要回滚到该镜像。可以对任何不确定和有风险的操作都这么做,例如对一个巨大的表做变更(需要多少时间是未知的)。\nLVM快照是如何工作的 # LVM使用写时复制(copy-on-write)的技术来创建快照——例如,对整个卷的某个瞬间的逻辑副本。这与数据库中的MVCC有点像,不同的是它只保留一个老的数据版本。\n注意,我们说的不是物理副本。逻辑副本看起来好像包含了创建快照时卷中所有的数据,但实际上一开始快照是不包含数据的。相比复制数据到快照中,LVM只是简单地标记创建快照的时间点,然后对该快照请求读数据时,实际上是从原始卷中读取的。因此,初始的复制基本上是一个瞬间就能完成的操作,不管创建快照的卷有多大。\n当原始卷中某些数据有变化时,LVM在任何变更写入之前,会复制受影响的块到快照预留的区域中。LVM不保留数据的多个“老版本”,因此对原始卷中变更块的额外写入并不需要对快照做其他更多的工作。换句话说,对每个块只有第一次写入才会导致写时复制到预留的区域。\n现在,在快照中请求这些块时,LVM会从复制块中而不是从原始卷中读取。所以,可以继续看到快照中相同时间点的数据而不需要阻塞任何原始卷。图15-1描述了这个方案。\n图15-1:写时复制技术如何减少单个卷快照需要的大小\n快照会在/dev目录下创建一个新的逻辑卷,可以像挂载其他设备一样挂载它。\n理论上讲,这种技术可以对一个非常大的卷做快照,而只需要非常少的物理存储空间。但是,必须设置足够的空间,保证在快照打开时,能够保存所有期望在原始卷上更新的块。如果不预留足够的写时复制空间,当快照用完所有的空间后,设备就会变得不可用。这个影响就像拔出一个外部设备:任何从设备上读的备份工作都会因I/O错误而失败。\n先决条件和配置 # 创建一个快照的消耗几乎微不足道,但还是需要确保系统配置可以让你获取在备份瞬间的所有需要的文件的一致性副本。首先,确保系统满足下面这些条件。\n所有的InnoDB文件(InnoDB的表空间文件和InnoDB的事务日志)必须是在单个逻辑卷(分区)。你需要绝对的时间点一致性,LVM不能为多于一个卷做某个时间点一致的快照。(这是LVM的一个限制;其他一些系统没有这个问题。) 如果需要备份表定义,MySQL数据目录必须在相同的逻辑卷中。如果使用另外一种方法来备份表的定义,例如只备份Schema到版本控制系统中,就不需要担心这个问题。 必须在卷组中有足够的空闲空间来创建快照。需要多少取决于负载。当配置系统时,应该留一些未分配的空间以便后面做快照。 LVM有卷组的概念,它包含一个或多个逻辑卷。可以按照如下的方式查看系统中的卷组:\n** # vgs** VG #PV #LV #SN Attr VSize VFree vg 1 4 0 wz--n- 534.18G 249.18G 输出显示了一个分布在一个物理卷上的卷组,它有四个逻辑卷,大概有250GB空间空闲。如果需要,可用vgdisplay命令产生更详细的输出。现在让我们看一下系统上的逻辑卷:\n# ** lvs** LV VG Attr LSize Origin Snap% Move Log Copy% home vg -wi-ao 40.00G mysql vg -wi-ao 225.00G tmp vg -wi-ao 10.00G var vg -wi-ao 10.00G 输出显示mysql卷有225GB的空间。设备名是/dev/vg/mysql。这仅是个名字,尽管看起来像一个文件系统路径。更加让人困惑的是,还有个符号链接从相同名字的文件链到*/dev/mapper/vg-mysql的设备节点,用ls和mount*命令可以观察到。\n** # ls -l /dev/vg/mysql** lrwxrwxrwx 1 root root 20 Sep 19 13:08 /dev/vg/mysql -\u0026gt; /dev/mapper/vg-mysql ** # mount | grep mysql** /dev/mapper/vg-mysql on /var/lib/mysql 有了这个信息,就可以创建文件系统快照了。\n创建、挂载和删除LVM快照 # 一条命令就能创建快照。只需要决定快照存放的位置和分配给写时复制的空间大小即可。不要纠结于是否使用比想象中的需求更多的空间。LVM不会马上使用完所有指定的空间,只是为后续使用预留而已。因此多预留一点空间并没有坏处,除非你必须同时为其他快照预留空间。\n让我们来练习创建一个快照。我们给它16GB的写时复制空间,名字为backup_mysql。\n# ** lvcreate --size 16G --snapshot --name backup_mysql /dev/vg/mysql** Logical volume \u0026quot;backup_mysql\u0026quot; created 这里特意命名为backup_mysql卷而不是mysql_backup,是为了避免Tab键自动补全造成误会。这有助于避免因为Tab键自动补全导致突然误删除mysql卷组的可能。\n现在让我们看看新创建的卷的状态。\n** # lvs** LV VG Attr LSize Origin Snap% Move Log Copy% backup_mysql vg swi-a- 16.00G mysql 0.01 home vg -wi-ao 40.00G mysql vg owi-ao 225.00G tmp vg -wi-ao 10.00G var vg -wi-ao 10.00G 可以注意到,快照的属性与原设备不同,而且该输出还显示了一点额外的信息:原始卷组和分配了16GB的写时复制空间目前已经使用了多少。备份时对此进行监控是个非常好的主意,可以知道是否会因为设备写满而备份失败。可以交互地监控设备的状态,或使用诸如Nagios这样的监控系统。\n# ** watch 'lvs | grep backup'** 从前面mount的输出可以看到,mysql卷包含一个文件系统。这意味着快照也同样如此,可以像其他文件系统一样挂载。\n** # mkdir /tmp/backup** ** # mount /dev/mapper/vg-backup_mysql /tmp/backup** ** # ls -l /tmp/backup/mysql** total 5336 -rw-r----- 1 mysql mysql 0 Nov 17 2006 columns_priv.MYD -rw-r----- 1 mysql mysql 1024 Mar 24 2007 columns_priv.MYI -rw-r----- 1 mysql mysql 8820 Mar 24 2007 columns_priv.frm -rw-r----- 1 mysql mysql 10512 Jul 12 10:26 db.MYD -rw-r----- 1 mysql mysql 4096 Jul 12 10:29 db.MYI -rw-r----- 1 mysql mysql 9494 Mar 24 2007 db.frm ... omitted ... 这里只是为了练习,因此我们卸载这个快照并用lvremove命令将其删除。\n** # umount /tmp/backup** ** # rmdir /tmp/backup** ** # lvremove --force /dev/vg/backup_mysql** Logical volume \u0026quot;backup_mysql\u0026quot; successfully removed 用于在线备份的LVM快照 # 现在已经知道如何创建、加载和删除快照,可以使用它们来进行备份了。首先看一下如何在不停止MySQL服务的情况下备份InnoDB数据库,这里需要使用一个全局的读锁。连接MySQL服务器并使用一个全局读锁将表刷到磁盘上,然后获取二进制日志的位置:\nmysql\u0026gt; ** FLUSH TABLES WITH READ LOCK; SHOW MASTER STATUS;** 记录SHOW MASTER STATUS的输出,确保到MySQL的连接处于打开状态,以使读锁不被释放。然后获取LVM的快照并立刻释放该读锁,可以使用UNLOCK TABLES或者直接关闭连接来释放锁。最后,加载快照并复制文件到备份位置。\n这种方法最主要的问题是,获取读锁可能需要一点时间,特别是当有许多长时间运行的查询时。当连接等待全局读锁时,所有的查询都将被阻塞,并且不可预测这会持续多久。\n文件系统快照和InnoDB\n即使锁住所有的表,InnoDB的后台线程仍会继续工作,因此,即使在创建快照时,仍然可以往文件中写入。并且,由于InnoDB没有执行关闭操作,如果服务器意外断电,快照中InnoDB的文件会和服务器意外掉电后文件的遭遇一样。\n这不是什么问题,因为InnoDB是个ACID系统。任何时刻(例如快照时),每个提交的事务要么在InnoDB数据文件中要么在日志文件中。在还原快照后启动MySQL时,InnoDB将运行恢复进程,就像服务器断过电一样。它会查找事务日志中任何提交但没有应用到数据文件中的事务然后应用,因此不会丢失任何事务。这正是要强制InnoDB数据文件和日志文件在一起快照的原因。\n这也是在备份后需要测试的原因。启动一个MySQL实例,把它指向一个新备份,让InnoDB执行崩溃恢复过程,然后检测所有的表。通过这种方法,就不会备份损坏了却还不知道(文件可能由于任何原因损坏)。这么做的另外一个好处是,未来需要从备份中还原时会更快,因为已经在备份上运行过一遍恢复程序了。\n甚至还可以在将快照复制到备份目的地之前,直接在快照上做上面的操作,但增加一点点额外开销。所以需要确保这是计划内的操作。(后面会有更多说明。)\n使用LVM快照无锁InnoDB备份 # 无锁备份只有一点不同。区别是不需要执行FLUSH TABLES WITH READ LOCK。这意味着不能保证MyISAM文件在磁盘上一致,如果只使用InnoDB,这就不是问题。mysql系统数据库中依然有部分MyISAM表,但如果是典型的工作负载,在快照时这些表不太可能发生改变。\n如果你认为mysql系统表可能会变更,那么可以锁住并刷新这些表。一般不会对这些表有长时间运行的查询,所以通常会很快。\nmysql\u0026gt; ** LOCK TABLES mysql.user READ, mysql.db READ, ...;** mysql\u0026gt; ** FLUSH TABLES mysql.user, mysql.db, ...;** 由于没有用全局读锁,因此不会从SHOW MASTER STATUS中获取到任何有用的信息。尽管如此,基于快照启动MySQL(来验证备份的完整性)时,也将会在日志文件中看到像下面的内容。\nInnoDB: Doing recovery: scanned up to log sequence number 0 40817239 InnoDB: Starting an apply batch of log records to the database... InnoDB: Progress in percents: 3 4 5 6 ...[omitted]... 97 98 99 InnoDB: Apply batch completed InnoDB: ** Last MySQL binlog file position 0 3304937, file name** ** /var/log/mysql/mysql-bin.000001** 070928 14:08:42 InnoDB: Started; log sequence number 0 40817239 InnoDB记录了MySQL已经恢复的时间点对应的二进制日志位置。这个二进制日志位置可以用来做基于时间点的恢复。\n使用快照进行无锁备份的方法在MySQL 5.0或更新版本中有变动。这些MySQL版本使用XA来协调InnoDB和二进制日志。如果还原到一个与备份时server_id不同的服务器,服务器在准备事务阶段可能发现这是从另外一个与自己有不同ID的服务器来的。在这种情况下,服务器会变得困惑,恢复事务时可能会卡在PREPARED状态。这种情况很少发生,但是存在可能性。这也是只有经过验证才可以说备份成功的原因。有些备份也许是不能恢复的。\n如果是在备库上获取快照,InnoDB恢复时还会打印如下几行日志。\nInnoDB: In a MySQL replica the last master binlog file InnoDB: position 0 115, file name mysql-bin.001717 输出显示了InnoDB已经恢复的基于主库的二进制日志位置(相对于备库二进制日志位置),这对于基于备库备份或基于其他备库克隆备库来说非常有用。\n规划LVM备份 # LVM快照备份也是有开销的。服务器写到原始卷的越多,引发的额外开销也越多。当服务器随机修改许多不同块时,磁头需要自写时复制空间来来回回寻址,并且将数据的老版本写到写时复制空间。从快照中读取也有开销,因为LVM需要从原始卷中读取大部分数据。只有快照创建后修改过的数据从写时复制空间读取;因此,逻辑顺序读取快照数据实际上也可能导致磁头来回移动。\n所以应该为此规划好快照。快照实际上会导致原始卷和快照都比正常的读/写性能要差——如果使用过多的写时复制空间,性能可能会差很多。这会降低MySQL服务器和复制文件进行备份的性能。我们做了基准测试,发现LVM快照的开销要远高于它本应该有的——我们发现性能最多可能会慢5倍,具体取决于负载和文件系统。在规划备份时要记得这一点。\n规划中另外一个重要的事情是,为快照分配足够多的空间。我们一般采取下面的方法。\n记住,LVM只需要复制每个修改块到快照一次。MySQL写一个块到原始卷中时,它会复制这个块到快照中,然后对复制的块在例外表中生成一个标记。后续对这个块的写不会产生任何到快照的复制。 如果只使用InnoDB,要考虑InnoDB是如何写数据的。InnoDB实际需要对数据写两遍,至少一半的InnoDB的写I/O会到双写缓冲(doublewrite buffer)、日志文件,以及其他磁盘上相对小的区域中。这部分会多次重用相同的磁盘块,因此第一次时对快照有影响,但写过一次以后就不会对快照带来写压力。 接下来,相对于反复修改同样的数据,需要评估有多少I/O需要写入到那些还没有复制到快照写时复制空间的块中,对评估的结果要保留足够的余量。 使用vmstat或iostat来收集服务器每秒写多少块的统计信息。 衡量(或评估)复制备份到其他地方需要多久。换言之,需要在复制期间保持LVM快照打开多长时间。 假设评估出有一半的写会导致往快照的写时复制空间的写操作,并且服务器支持10MB/s的写入。如果需要一个小时(3600s)将快照复制到另外一个服务器上,那么将需要1/2×10MB×3600即18GB的快照空间。考虑到容错,还要增加一些额外的空间。\n有时候当快照保持打开时,很容易计算会有多少数据发生改变。让我们看个例子。BoardReader论坛搜索引擎每个存储节点有约1TB的InnoDB表。但是,我们知道最大的开销是加载新数据。每天新增近10GB的数据,因此50GB的快照空间应该完全足够。然而这样来评估并不总是正确的。假设在某个时间点,有一个长时间运行的依次修改每个分片的ALTER TABLE操作,它会修改超过50GB的数据;在这个时间点,就不能做备份操作。为了避免这样的问题,可以稍后再创建快照,因为创建快照后会导致一个负载的高峰。\n备份误区2:“快照就是备份”\n一个快照,不论是LVM快照、ZFS快照,还是SAN快照,都不是实际的备份,因为它不包含数据的完整副本。正因为快照是写时复制的,所以它只包含实际数据和快照发生的时间点的数据之间的差异数据。如果一个没有被修改的块在备份副本时被损坏,那就没有该块的正常副本可以用来恢复,并且备份副本时每个快照看到的都是相同的损坏的块。可以使用快照来“冻结”备份时的数据,但不要把快照当作一个备份。\n快照的其他用途和替代方案 # 快照有更多的其他用途,而不仅仅用于备份。例如,之前提到,在一个有潜在危险的动作之前生成一个“检查点”会有帮助。有些系统允许将快照提升为原文件系统,这使得回滚到生成快照的时间点的数据非常简单。\n文件系统快照不是取得数据瞬间副本的唯一方法。另外一个选择是RAID分裂:举个例子,如果有一个三磁盘的软RAID镜像,就可以从该RAID组中移出来一个磁盘单独加载。这样做没有写时复制的代价,并且需要时将此类“快照”提升为主副本的操作也很简单。不错,如果要将磁盘加回到RAID集合,就必须重新进行同步。当然,天下没有免费的午餐。\n15.6 从备份中恢复 # 如何恢复数据取决于是怎么备份的。可能需要以下部分或全部步骤。\n停止MySQL服务器。 记录服务器的配置和文件权限。 将数据从备份中移到MySQL数据目录。 改变配置。 改变文件权限。 以限制访问模式重启服务器,等待完成启动。 载入逻辑备份文件。 检查和重放二进制日志。 检测已经还原的数据。 以完全权限重启服务器。 我们在接下来的章节中将演示这些步骤的具体操作。我们也会对本节及本章后面几节提及的一些特殊的备份方法和工具做一些解释。\n如果有机会使用文件的当前版本,就不要用备份中的文件来代替。例如,如果备份包含二进制日志,并且需要重放这些日志来做基于时间点的恢复,那么不要把当前二进制日志用备份中的老的副本替代。如果有需要,可以将其重命名或移动到其他地方。\n在恢复过程中,保证MySQL除了恢复进程外不接受其他访问,这一点往往比较重要。我们喜欢以\u0026ndash;skip-networking和\u0026ndash;socket=/tmp/mysql_recover.sock选项来启动MySQL,以确保它对于已经存在的应用不可访问,直到我们检测完并重新提供服务。这对于按块加载的逻辑备份的恢复来说尤其重要。\n15.6.1 恢复物理备份 # 恢复物理备份往往非常直接——换言之,没有太多的选项。这可能是好事,也可能是坏事,具体取决于恢复的需求。一般过程是简单地复制文件到正确位置。\n是否需要关闭MySQL取决于存储引擎。MyISAM的文件一般相互独立,即使服务器正在运行,简单地复制每个表的.frm、.MYI和.MYD文件也可以正常操作。一旦有任何对此表的查询,或者其他会导致服务器访问此表的操作(例如,执行SHOW TABLES), MySQL都会立刻找到这些表。如果在复制这些文件时表是打开的,可能会有麻烦,因此操作前要么删除或重命名该表,要么使用LOCK TABLES和FLUSH TABLES来关闭它。\nInnoDB的情况有所不同。如果用传统的InnoDB的步骤来还原,即所有表都存储在单个表空间,就必须关闭MySQL,复制或移动文件到正确位置上,然后重启。同样也需要InnoDB的事务日志文件与表空间文件匹配。如果文件不匹配——例如,替换了表空间文件但没有替换事务日志文件——InnoDB将会拒绝启动。这也是将日志和数据文件一起备份非常关键的一个原因。\n如果使用InnoDB file-per-table特性(innodb_file_per_table),InnoDB会将每个表的数据和索引存储于一个.ibd文件中,这就像MyISAM的.MYI和.MYD文件合在一起。可以在服务器运行时通过复制这些文件来备份和还原单个表,但这并不像MyISAM中那样简单。这些文件并不完全独立于InnoDB。每个.ibd文件都有一些内部的信息,保存着它与主(共享)表空间之间的关系。在还原这样的文件时,需要让InnoDB先“导入”这个文件。\n这个过程有许多的限制,如果有需要可以阅读MySQL用户手册中关于每个表使用独立表空间中的部分。最大的限制是只能在当初备份的服务器上还原单个表。用这种配置来备份和还原多个表不是不可能,但可能比想象的要更棘手。\nPercona Server和Percona XtraBackup有一些改进,放宽了部分关于这个过程的限制,例如同一服务器的限制。\n所有这些复杂度意味着还原物理备份会非常乏味,并且容易出错。一个好的值得倡导的规则是,恢复过程越难越复杂,也就越需要逻辑备份的保护。为了防止一些无法意料的情况或者某些无法使用物理备份的场景,准备好逻辑备份总是值得推荐的。\n还原物理备份后启动MySQL # 在启动正在恢复的MySQL服务器之前,还有些步骤要做。\n首先,最重要且最容易忘记的事情,是在启动MySQL服务器之前检查服务器的配置,确保恢复的文件有正确的归属和权限。这些属性必须完全正确,否则MySQL可能无法启动。这些属性因系统的不同而不同,因此要仔细检查是否和之前做的记录吻合。一般都需要mysql用户和组拥有这些文件和目录,并且只有这个用户和组拥有可读/写权限。\n建议观察MySQL启动时的错误日志。在UNIX类系统上,可以如下观察文件。\n$ ** tail -f /var/log/mysql/mysql.err** 注意错误日志的准确位置会有所不同。一旦开始监测文件,就可以启动MySQL服务器并监测错误。如果一切进展顺利,MySQL启动后就有一个恢复好的数据库服务器了。\n观察错误日志对于新的MySQL版本更为重要。老版本在InnoDB有错时不会启动,但新版本不管怎样都会启动,而只是让InnoDB失效。即使服务器看起来启动没有任何问题,也应该对每个数据库运行SHOW TABLE STATUS来再次检测错误日志。\n15.6.2 还原逻辑备份 # 如果还原的是逻辑备份而不是物理备份,则与使用操作系统简单地复制文件到适当位置的方式不同,需要使用MySQL服务器本身来加载数据到表中。\n在加载导出文件之前,应该先花一点时间考虑文件有多大,需要多久加载完,以及在启动之前还需要做什么事情,例如通知用户或禁掉部分应用。禁掉二进制日志也是个好主意,除非需要将还原操作复制到备库:服务器加载一个巨大的导出文件的代价很高,并且写二进制日志会增加更多的(可能没有必要的)开销。加载巨大的文件对于一些存储引擎也有影响。例如,在单个事务中加载100GB数据到InnoDB就不是个好想法,因为巨大的回滚段将会导致问题。应该以可控大小的块来加载,并且逐个提交事务。有两种类型的逻辑备份,所以相应地有两种类型的还原操作。\n加载SQL文件 # 如果有一个SQL导出文件,它将包含可执行的SQL。需要做的就是运行这个文件。假设备份Sakila示例数据库和Schema到单个文件,下面是用来还原的常用命令。\n** $ mysql \u0026lt; sakila-backup.sql** 也可以从mysql命令行客户端用SOURCE命令加载文件。这只是做相同事情的不同方法,不过该方法使得某些事情更简单。例如,如果你是MySQL管理用户,就可以关闭用客户端连接执行时的二进制记录,然后加载文件而不需要重启MySQL服务器。\nmysql\u0026gt; ** SET SQL_LOG_BIN = 0;** mysql\u0026gt; ** SOURCE sakila-backup.sql;** mysql\u0026gt; ** SET SQL_LOG_BIN = 1;** 需要注意的是,如果使用SOURCE,当定向文件到mysql时,默认情况下,发生一个错误不会导致一批语句退出。\n如果备份做过压缩,那么不要分别解压缩和加载。应该在单个操作中完成解压缩和加载。这样做会快很多。\n** $ gunzip –c sakila-backup.sql.gz | mysql** 如果想用SOURCE命令加载一个压缩文件,可参考下节中关于命名管道的讨论。\n如果只想恢复单个表(例如,actor表),要怎么做呢?如果数据没有分行但有schema信息,那么还原数据并不难。\n** $ grep 'INSERT INTO ‘actor‘' sakila-backup.sql | mysql sakila** 或者,如果文件是压缩过的,那么命令如下。\n** $ gunzip –c sakila-backup.sql.gz | grep 'INSERT INTO ‘actor‘'| mysql sakila** 如果需要创建表并还原数据,而在单个文件中有整个数据库,则必须先编辑这个文件。这也是有一些人喜欢导出每个表到各自文件中的原因。大部分编辑器无法应付巨大的文件,尤其如果它们是压缩过的。另外,也不会想实际地编辑文件本身——只想抽取相关的行——因此可能必须做一些命令行工作。使用grep来仅抽出给定表的INSERT语句较简单,就像我们在前面命令中做的那样,但得到CREATE TABLE语句比较难。下面是抽取所需段落的sed脚本。\n$ ** sed -e '/./{H;$!d;}' -e 'x;/CREATE TABLE `actor`/!d;q' sakila-backup.sql** 我们得承认这条命令非常隐晦。如果必须以这种方式还原数据,那只能说明备份设计非常糟糕。如果有一点规划,可能就不会需要痛苦地去尝试弄清楚sed如何工作了。只需要备份每个表到各自的文件,或者可以更进一步,分别备份数据和Schema。\n加载符号分隔文件 # 如果是通过SELECT INTO OUTFILE导出的符号分隔文件,可以使用LOAD DATA INFILE通过相同的参数来加载。也可以用mysqlimport,这是LOAD DATA INFILE的一个包装。这种方式依赖命名约定决定从哪里加载一个文件的数据。\n我们希望你导出了Schema,而不仅是数据。如果是这样,那应该是一个SQL导出,就可以使用上一节中描述的技术来加载。\n使用LOAD DATA INFILE有一个非常好的优化技巧。LOAD DATA INFILE必须直接从文本文件中读取,因此,如果是压缩文件很多人会在加载前先解压缩,这是非常慢的磁盘密集型的操作。然而,在支持FIFO“命名管道”文件的系统如GNU/Linux上,对这种操作有个很好的方法。首先,创建一个命名管道并将解压缩数据流到它里面。\n** $ mkfifo /tmp/backup/default/sakila/payment.fifo** ** $ chmod 666 /tmp/backup/default/sakila/payment.fifo** ** $ gunzip -c /tmp/backup/default/sakila/payment.txt.gz** ** \u0026gt; /tmp/backup/default/sakila/payment.fifo** 注意我们使用了一个大于号字符(\u0026gt;)来重定向解压缩输出到payment.fifo文件中——而不是在不同程序之间创建匿名管道的管道符号。\n管道会等待,直到其他程序打开它并从另外一端读取数据。简单一点说,MySQL服务器可以从管道中读取解压缩后的数据,就像其他文件一样。如果可能,不要忘记禁掉二进制日志。\nmysql\u0026gt; ** SET SQL_LOG_BIN = 0; -- Optional** -\u0026gt; ** LOAD DATA INFILE '/tmp/backup/default/sakila/payment.fifo'** -\u0026gt; ** INTO TABLE sakila.payment;** Query OK, 16049 rows affected (2.29 sec) Records: 16049 Deleted: 0 Skipped: 0 Warnings: 0 一旦MySQL加载完数据,gunzip就会退出,然后可以删除该命令管道。在MySQL命令行客户端使用SOURCE命令加载压缩的文件也可以使用此技术。Percona Toolkit中的pt-fifo-split程序还可以帮助分块加载大文件,而不是在单个大事务中操作,这样效率更高。\n你无法从这里到达那里\n本书的作者之一曾将一列从DATETIME变为TIMESTAMP,以节约空间并使处理过程更快,就像第3章中推荐的那样。结果表定义如下。\n** CREATE TABLE tbl (** ** col1 timestamp NOT NULL,** ** col2 timestamp NOT NULL default CURRENT_TIMESTAMP** ** on update CURRENT_TIMESTAMP,** ** ... more columns ...** ); 这个表定义在MySQL 5.0.40版本上导致了一个语法错误,而这是创建时的版本。可以执行导出,但无法加载。这很奇怪,诸如这样无法预料的错误也是测试备份重要的原因之一。你永远不会知道什么会阻止你还原数据!\n15.6.3 基于时间点的恢复 # 对MySQL做基于时间点的恢复常见的方法是还原最近一次全备份,然后从那个时间点开始重放二进制日志(有时叫“前滚恢复”)。只要有二进制日志,就可以恢复到任何希望的时间点。甚至可以不太费力地恢复单个数据库。\n主要的缺点是二进制日志重放可能会是一个很慢的过程。它大体上等同于复制。如果有一个备库,并且已经测量到SQL线程的利用率有多高,那么对重放二进制日志会有多快就会心里有数了。例如,如果SQL线程约有50%被利用,则恢复一周二进制日志的工作可能在三到四天内完成。\n一个典型场景是对有害的语句的结果做回滚操作,例如DROP TABLE。让我们看一个简化的例子,看只有MyISAM表的情况下该如何做。假如是在半夜,备份任务在运行与下面所列相当的语句,复制数据库到同一服务器上的其他地方。\nmysql\u0026gt; ** FLUSH TABLES WITH READ LOCK;** -\u0026gt; server1# ** cp -a /var/lib/mysql/sakila /backup/sakila;** mysql\u0026gt; ** FLUSH LOGS;** -\u0026gt; server1# ** mysql -e \u0026quot;SHOW MASTER STATUS\u0026quot; --vertical \u0026gt; /backup/master.info;** mysql\u0026gt; ** UNLOCK TABLES;** 然后,假设有人在晚些时间运行下列语句。\nmysql\u0026gt; ** USE sakila;** mysql\u0026gt; ** DROP TABLE sakila.payment;** 为了便于说明,我们先假设可以单独地恢复这个数据库(即此库中的表不涉及跨库查询)。再假设是直到后来出问题才意识到这个有问题的语句。目标是恢复数据库中除了有问题的语句之外所有发生的事务。也就是说,其他表已经做的所有修改都必须保持,包括有问题的语句运行之后的修改。\n这并不是很难做到。首先,停掉MySQL以阻止更多的修改,然后从备份中仅恢复sakila数据库。\nserver1# ** /etc/init.d/mysql stop** server1# ** mv /var/lib/mysql/sakila /var/lib/mysql/sakila.tmp** server1# ** cp –a /backup/sakila /var/lib/mysql** 再到运行的服务器的my.cnf中添加如下配置以禁止正常的连接。\nskip-networking socket=/tmp/mysql_recover.sock 现在可以安全地启动服务器了。\nserver1# ** /etc/init.d/mysql start** 下一个任务是从二进制日志中分出需要重放和忽略的语句。事发时,自半夜的备份以来,服务器只创建了一个二进制日志。我们可以用grep来检查二进制日志文件以找到问题语句。\nserver1# ** mysqlbinlog --database=sakila /var/log/mysql/mysql-bin.000215** ** | grep -B3 -i 'drop table sakila.payment'** # at 352 #070919 16:11:23 server id 1 end_log_pos 429 Query thread_id=16 exec_time=0 error_code=0 SET TIMESTAMP=1190232683/*!*/; DROP TABLE sakila.payment/*!*/; 可以看到,我们想忽略的语句在日志文件中的352位置,下一个语句位置是429。可以用下面的命令重放日志直到352位置,然后从429继续。\nserver1# ** mysqlbinlog --database=sakila /var/log/mysql/mysql-bin.000215** ** --stop-position=352 | mysql -uroot –p** server1# ** mysqlbinlog --database=sakila /var/log/mysql/mysql-bin.000215** ** --start-position=429 | mysql -uroot -p** 接下来要做的是检测数据以确保没有问题,然后关闭服务器并撤消对my.cnf的改变,最后重启服务器。\n15.6.4 更高级的恢复技术 # 复制和基于时间点的恢复使用的是相同的技术:服务器的二进制日志。这意味着复制在恢复时会是个非常有帮助的工具,哪怕方式不是很明显。在本节中我们将演示一些可以用到的方法。这里列出来的不是一个完全的列表,但应该可以为你根据需求设计恢复方案带来一些想法。记得编写脚本,并且对恢复过程中需要用到的所有技术进行预演。\n用于快速恢复的延时复制 # 在本章的前面已经提到,如果有一个延时的备库,并且在备库执行问题语句之前就发现了问题,那么基于时间点的恢复就更快更容易了。\n恢复的过程与本章前几节描述的有点不一样,但思路是相同的。停止备库,用START SLAVE UNTIL来重放事件直到要执行问题语句。接着,执行SET GLOBAL SQL_SLAVE_SKIP_COUNTER=1来跳过问题语句。如果想跳过多个事件,可以设置一个大于1的值(或简单地使用CHANGE MASTER TO来前移备库在日志中的位置)。\n然后要做的就是执行START SLAVE,让备库执行完所有的中继日志。这样就利用备库完成了基于时间点的恢复中所有冗长的工作。现在可以将备库提升为主库,整个恢复过程基本上没有中断服务。\n即使没有延时的备库来加速恢复,普通的备库也有好处,至少会把主库的二进制日志复制到另外的机器上。如果主库的磁盘坏了,备库上的中继日志可能就是唯一能够获取到的最接近主库二进制日志的东西了。\n使用日志服务器进行恢复 # 还有另外一种使用复制来做恢复的方法:设置日志服务器。我们感觉复制比mysqlbinlog更可靠,mysqlbinlog可能会有一些导致异常行为的奇怪的Bug和不常见的情况。使用日志服务器进行恢复比mysqlbinlog更灵活更简单,不仅因为START SLAVE UNTIL选项,还因为那些可以采用的复制规则(例如replicate-do-table)。使用日志服务器,相对其他的方式来说,可以做到更复杂的过滤。\n例如,使用日志服务器可以轻松地恢复单个表。而用mysqlbinlog和命令行工具则要困难得多——事实上,这样做太复杂了,所以我们一般不建议进行尝试。\n假设粗心的开发人员像前面的例子一样删除了同样的表,现在想恢复此误操作,但又不想让整个服务器退到昨晚的备份。下面是利用日志服务器进行恢复的步骤:\n将需要恢复的服务器叫作server1。\n在另外一台叫做server2的服务器上恢复昨晚的备份。在这台服务器上运行恢复进程,以免在恢复时犯错而导致事情更糟。\n按照第10章的做法设置日志服务器来接收server1的二进制日志(复制日志到另外一个服务器并设置日志服务器是个好想法,但是要格外注意。)\n改变server2的配置文件,增加如下内容。\nreplicate-do-table=sakila.payment 重启server2,然后用CHANGE MASTER TO来让它成为日志服务器的备库。配置它从昨晚备份的二进制日志坐标读取。这时候切记不要运行START SLAVE。\n检测server2上的SHOW SLAVE STATUS的输出,验证一切正常。要三思而行!\n找到二进制日志中问题语句的位置,在server2上执行START SLAVE UNTIL来重放事件直到该位置。\n在server2上用STOP SLAVE停掉复制进程。现在应该有被删除表,因为现在从库停止在被删除之前的时间点。\n将所需表从server2复制到server1。\n只有没有任何多表的UPDATE、DELETE或INSERT语句操作这个表时,上述流程才是可行的。任何这样的多表操作语句在被记录的时候,可能是基于多个数据库的状态,而不仅仅是当前要恢复的这个数据库,所以这样恢复出来的数据可能和原始的有所不同。(只有在使用基于语句的二进制日志时才会有这个问题;如果使用的是基于行的日志,重放过程不会碰到这个错误。)\n15.6.5 InnoDB崩溃恢复 # InnoDB在每次启动时都会检测数据和日志文件,以确认是否需要执行恢复过程。而且, InnoDB的恢复过程与我们在本章之前谈论的不是一回事。它并不是恢复备份的数据;而是根据日志文件将事务应用到数据文件,将未提交的变更从数据文件中回滚。\n精确地描述InnoDB如何进行恢复工作,这有点太过复杂。我们要关注的焦点是当InnoDB有严重问题时如何实际执行恢复。\n大部分情况下InnoDB可以很好地解决问题。除非MySQL有Bug或硬件有问题,否则不需要做任何非常规的事情,哪怕是服务器意外断电。InnoDB会在启动时执行正常的恢复,然后就一切正常了。在日志文件中,可以看到如下信息。\nInnoDB: Doing recovery: scanned up to log sequence number 0 40817239 InnoDB: Starting an apply batch of log records to the database... InnoDB会在日志文件中输出恢复进度的百分比信息。有些人说直到整个过程完成才能看到这些信息。耐心点,这个恢复过程是急不来的。如果心急而杀掉进程并重启,只会导致需要更长的恢复时间。\n如果服务器硬件有严重问题,例如内存或磁盘损坏,或遇到了MySQL或InnoDB的Bug,可能就不得不介入,这时要么进行强制恢复,要么阻止正常恢复发生。\nInnoDB损坏的原因 # InnoDB非常健壮且可靠,并且有许多的内建安全检测来防止、检测和修复损坏的数据——比其他MySQL存储引擎要强很多。然而,InnoDB并不能保护自己避免一切错误。\n最起码,InnoDB依赖于无缓存的I/O调用和fsync()调用,直到数据完全地写入到物理介质上才会返回。如果硬件不能保证写入的持久化,InnoDB也就不能保证数据的持久,崩溃就有可能导致数据损坏。\n很多InnoDB损坏问题都是与硬件有关的(例如,因电力问题或内存损坏而导致损坏页的写入)。然而,在我们的经验中,错误配置的硬件是更多的问题之源。常见的错误配置包括打开了不包含电池备份单元的RAID卡的回写缓存,或打开了硬盘驱动器本身的回写缓存。这些错误将会导致控制器或驱动器“撒谎”,在数据实际上只写入到回写缓存上而不是磁盘上时,却说fsync()已经完成。换句话说,硬件没有提供保持InnoDB数据安全的保证。\n有时候机器默认就会这样配置,因为这样做可以得到更好的性能——对于某些场景确实很好,但是对事务数据服务来说却是个大问题。\n如果在网络附加存储(NAS)上运行InnoDB,也可能会遇到损坏,因为对NAS设备来说完成fsync()只是意味着设备接收到了数据。如果InnoDB崩溃,数据是安全的,但如果是NAS设备崩溃就不一定了。\n严重的损坏会使InnoDB或MySQL崩溃,而不那么严重的损坏则可能只是由于日志文件未真正同步到磁盘而丢掉了某些事务。\n如何恢复损坏的InnoDB数据 # InnoDB损坏有三种主要类型,它们对数据恢复有着不同程度的要求。\n二级索引损坏\n一般可以用OPTIMIZE TABLE来修复损坏的二级索引;此外,也可以用SELECT INTO OUTFILE,删除和重建表,然后LOAD DATA INFILE的方法。(也可以将表改为使用MyISAM再改回来。)这些过程都是通过构建一个新表重建受影响的索引,来修复损坏的索引数据。\n聚簇索引损坏\n如果是聚簇索引损坏,也许只能使用innodb_force_recovery选项来导出表(关于这点后续会讲更多)。有时导出过程会让InnoDB崩溃;如果出现这样的情况,或许需要跳过导致崩溃的损坏页以导出其他的记录。聚簇索引的损坏比二级索引要更难修复,因为它会影响数据行本身,但在多数场合下仍然只需要修复受影响的表。\n损坏系统结构\n系统结构包括InnoDB事务日志、表空间的撤销日志(undo log)区域和数据字典。这种损坏可能需要做整个数据库的导出和还原,因为InnoDB内部绝大部分的工作都可能受到影响。\n一般可以修复损坏的二级索引而不丢失数据。然而,另外两种情形经常会引起数据的丢失。如果已经有备份,那最好还是从备份中还原,而不是试着从损坏的文件里去提取数据。\n如果必须从损坏的文件里提取数据,那一般过程是先尝试让InnoDB运行起来,然后使用SELECT INTO OUTFILE导出数据。如果服务器已经崩溃,并且每次启动InnoDB都会崩溃,那么可以配置InnoDB停止常规恢复和后台进程的运行。这样也许可以启动服务器,然后在缺少或不做完整性检查的情况下做逻辑备份。\ninnodb_force_recovery参数控制着InnoDB在启动和常规操作时要做哪一种类型的操作。通常情况下这个值是0,可以增大到6。MySQL使用手册里记录了每个数值究竟会产生什么行为;在此我们不会重复这段信息,但是要告诉你:在有点危险的前提下,可以把这个数值调高到4。使用这个设置时,若有数据页损坏,将会丢失一些数据;如果将数值设得更高,可能会从损坏的页里提取到坏掉的数据,或者增加执行SELECT INTO OUTFILES时崩溃的风险。换句话说,这个值直到4都对数据没有损害,但可能丧失修复问题的机会;而到5 和6 会更主动地修复问题,但损害数据的风险也会很大。\n当把innodb_force_recovery设为大于0的某个值时,InnoDB 基本上是只读的,但是仍然可以创建和删除表。这可以阻止进一步的损坏,InnoDB会放松一些常规检查,以便在发现坏数据时不会特意崩溃。在常规操作中,这样做是有安全保障的,但是在恢复时,最好还是避免这样做。如果需要执行InnoDB 强制恢复,有个好主意是配置MySQL,使它在操作完成之前不接受常规的连接请求。\n如果InnoDB的数据损坏到了根本不能启动MySQL的程度,还可以使用Percona出品的InnoDB Recovery Toolkit从表空间的数据文件里直接抽取数据。这个工具由本书的几个作者开发,可以从http://www.percona.com/software免费获取。Percona Server还有允许服务器在某些表损坏时仍能运行的选项,而不是像MySQL那样在单个表损坏页被检测出时就默认强制崩溃。\n15.7 备份和恢复工具 # 有各种各样的好的和不是那么好的备份工具。我们喜欢对LVM使用mylvmbackup做快照备份,使用Percona Xtrabackup(开源)或MySQL Enterprise Backup(收费)做InnoDB热备份。不建议对大数据量使用mysqldump,因为它对服务器有影响,并且漫长的还原时间不可预知。\n有一些备份工具已经出现多年了,不幸的是有些已经过时。最明显的例子是Maatkit的mk-parallel-dump,它从没有正确运行,甚至被重新设计过好几次还是不行。另外一个工具是mysqlhotcopy,它适合于古老的MyISAM表。大部分场景下这两个工具都无法让人相信数据是安全的,它们会使人误以为备份了数据实际上却非如此。例如,当使用InnoDB的innodb_file_per_table时,mysqlhotcopy会复制.ibd文件,这会使一些人误以为InnoDB的数据已经备份完成。在某些场景下,这两个工具都对服务器有一些负面影响。\n如果你在2008或2009年时在看MySQL的路线图,可能听说过MySQL在线备份。这是一个可以用SQL命令来开始备份和还原的特性。它原本是规划在MySQL 5.2版本中,后来重新安排在了MySQL 6.0中,再后来,据我们所知被永久取消了。\n15.7.1 MySQL Enterprise Backup # 这个工具之前叫做InnoDB Hot Backup或ibbackup,是从Oracle购买的MySQL Enterprise中的一部分。使用此工具备份不需要停止MySQL,也不需要设置锁或中断正常的数据库活动(但是会对服务器造成一些额外的负载)。它支持类似压缩备份、增量备份和到其他服务器的流备份的特性。这是MySQL“官方”的备份工具。\n15.7.2 Percona XtraBackup # Percona XtraBackup与MySQL Enterprise Backup在很多方面都非常类似,但它是开源并且免费的。除了核心备份工具外,还有一个用Perl写的封装脚本,可以提供更多高级功能。它支持类似流、增量、压缩和多线程(并行)备份操作。也有许多特别的功能,用以降低在高负载的系统上备份的影响。\nPercona XtraBackup的工作方式是在后台线程不断追踪InnoDB日志文件尾部,然后复制InnoDB数据文件。这是个轻量级侵入过程,依靠特别的检测机制确保复制的数据是一致的。当所有的数据文件被复制完,日志复制线程就结束了。结果是在不同的时间点的所有数据的副本。然后可以使用InnoDB崩溃恢复代码应用事务日志,以达到所有数据文件一致的状态。这一步叫作准备过程。一旦准备好,备份就会完全一致,并且包含文件复制过程最后时间点已经提交的事务。一切都在MySQL外部完成,因此不需要以任何方式连接或访问MySQL。\n包装脚本包含通过复制备份到原位置的方式进行恢复的能力。还有Lachlan Mulcahy的XtraBack Manager项目,功能更多,详情参见 http://code.google.com/p/xtrabackup-manager/。\n15.7.3 mylvmbackup # Lenz Grimmer的mylvmbackup(http://lenz.homelinux.org/mylvmbackup/)是一个Perl脚本,它通过LVM快照帮助MySQL自动备份。此工具首先获取全局读锁,创建快照,释放锁。然后通过tar压缩数据并移除快照。它通过备份时的时间戳命名压缩包。它还有几个高级选项,但总的来说,这是一个执行LVM备份的非常简单明了的工具。\n15.7.4 Zmanda Recovery Manager # 适用于MySQL的Zmanda Recovery Manager,或ZRM( http://www.zmanda.com),有免费(GPL)和商业两种版本。企业版提供基于网页图形接口的控制台,用来配置、备份、验证、恢复、报告和调度。开源的版本包含了所有核心功能,但缺少一些额外的特性,例如基于网页的控制台。 正如其名,ZRM实际上是一个备份和恢复管理器,而并非单一工具。它封装了自有的基于标准工具和技术,例如mysqldump、LVM快照和Percona XtraBackup等之上的功能。它将许多冗长的备份和恢复工作进行了自动化。\n15.7.5 mydumper # 几名MySQL现在和之前的工程师利用他们多年的经验创建了mydumper,用来替代mysqldump。这是一个多线程(并发)的备份和还原MySQL和Drizzle的工具集,有许多很好的特性。大概有许多人会发现多线程备份和还原的速度是这个工具最吸引人的特色。尽管我们知道有些人在生产环境中使用,但我们还没有在任何产品中使用的经验。可以在 http://www.mydumper.org找到更多信息。\n15.7.6 mysqldump # 大部分人在使用这个与MySQL一起发行的程序,因此,尽管它有缺点,但创建数据和Schema的逻辑备份最常见的选择还是mysqldump。这是一个通用工具,可以用于许多的任务,例如在服务器间复制表。\n** $ mysqldump --host=server1 test t1 | mysql --host=server2 test** 我们在本章中展示了几个用mysqldump创建逻辑备份的例子。该工具默认会输出包含创建表和填充数据的所有需要的命令;也有选项可以控制输出视图、存储代码和触发器。下面有一些典型的例子。\n对服务器上所有的内容创建逻辑备份到单个文件中,每个库中所有的表在相同逻辑时间点备份:\n** $ mysqldump –all-databases \u0026gt; dump.sql** 创建只包含Sakila示例数据库的逻辑备份:\n** $ mysqldump –databases sakila \u0026gt;dump.sql** 创建只包含sakila.actor表的逻辑备份:\n** $ mysqldump sakila actor \u0026gt; dump.sql** 可以使用\u0026ndash;result-file选项来指定输出文件,这可以帮助防止在Windows上发生换行符转换:\n** $ mysqldum sakila actor –result-file=dump.sql** mysqldump的默认选项对于大多数备份目的来说并不够好。多半要显式地指定某些选项以改变输出。下面是一些我们经常使用的选项,可以让mysqldump更加高效,输出更容易使用。\n\u0026ndash;opt\n启用一组优化选项,包括关闭缓冲区(它会使服务器耗尽内存),导出数据时把更多的数据写在更少的SQL语句里,以便在加载的时候更有效率,以及做其他一些有用的事情。更多细节可以阅读帮助文件。如果关闭了这组选项,mysqldump会在把表写到磁盘之前,把它们都导出到内存里,这对于大型的表而言是不切实际的。\n\u0026ndash;allow-keywords, \u0026ndash;quote-names\n使用户在导出和恢复表时,可以使用保留字作为表的名字。\n--complete-insert\n使用户能在不完全相同列的表之间移动数据。\n\u0026ndash;tz-utc\n使用户能在具有不同时区的服务器之间移动数据。\n--lock-all-tables\n使用FLUSH TABLE WITH READ LOCK来获取全局一致的备份。\n\u0026ndash;tab\n用SELECT INTO OUTFILE导出文件。\n--skip-extended-insert\n使每一行数据都有自己的INSERT语句。必要时这可以用于有选择地还原某些行。它的代价是文件更大,导入到MySQL时开销会更大。因此,要确保只有在需要时才启用它。\n如果在mysqldump上使用\u0026ndash;databases或\u0026ndash;all-databases选项,那么最终导出的数据在每个数据库中都一致,因为mysqldump会在同一时间锁定并导出一个数据库里的所有表。然而,来自不同数据库的各个表就未必是相互一致的。使用\u0026ndash;lock-all-tables选项可以解决这个问题。\n对于InnoDB备份,应该增加\u0026ndash;single-transaction选项,这会使用InnoDB的MVCC特性在单个时间点创建一个一致的备份,而不需要使用LOCK TABLES锁定所有表。如果增加*\u0026ndash;master-data*选项,备份还会包括在备份时服务器的二进制日志文件位置,这对基于时间点的恢复和设置复制非常有帮助。然而也要知道,获得日志位置时需要使用FLUSH TABLES WITH READ LOCK冻结服务器。\n15.8 备份脚本化 # 为备份写一些脚本是标准做法。展示一个示例程序,其中必定有很多辅助内容,这只会增加篇幅,在这里我们更愿意列举一些典型的备份脚本功能,展示一些Perl脚本的代码片断。你可以把这些当作可重用的代码块,在创建自己的脚本时可以直接组合起来使用。下面将大致按照使用顺序来展示。\n安全检测\n安全检测可以让自己和同事的生活更简单点——打开严格的错误检测,并且使用英文变量名。\nuse strict; use warnings FATAL =\u0026gt; 'all'; use English qw(-no_match_vars); 如果是在Bash下使用脚本,还可以做更严格的变量检测。下面的设置会让替换中有未定义的变量或程序出错退出时产生一个错误。\nset -u; set -e; 命令行参数\n增加命令行选项处理最好的方法是用标准库,它已包含在Perl标准安装中。\nuse Getopt::Long; Getopt::Long::Configure('no_ignore_case', 'bundling'); GetOptions(....); 连接MySQL\n标准的Perl DBI库几乎无所不在,提供了许多强大和灵活的功能。使用详情请参阅Perldoc(可从 http://search.cpna.org在线获取)。可以像下面这样使用DBI来连接MySQL。\nuse DBI; $dbh = DBI-\u0026gt;connect( 'DBI:mysql:;host-localhost', 'user', 'p4ssword', {RaiseError =\u0026gt; 1}); 对于编写命令行脚本,请阅读标准mysql程序的\u0026ndash;help参数的输出文本。它有许多选项可更友好地支持脚本。例如,在Bash中遍历数据库列表如下。\nmysql -ss -e 'SHOW DATABASES' | while read DB; do ech \u0026quot;${DB}\u0026quot; done 停止和启动MySQL\n停止和启动MySQL最好的方法是使用操作系统推荐的方法,例如运行*/etc/init.d/mysql init*脚本或通过服务控制(在Windows下)。然而这并不是唯一的方法。可以从Perl中用一个已存在的数据库连接来关闭数据库。\n$dbh-\u0026gt;func(\u0026quot;shutdown\u0026quot;, 'admin'); 当这个命令完成时不要太指望MySQL已经被关闭——它可能正在关闭的过程中。也可以通过命令行来停掉MySQL。\n** $ mysqladmin shutdown** 获取数据库和表的列表\n每个备份脚本都会查询MySQL以获取数据库和表的列表。要注意那些实际上并不是数据库的条目,例如一些日志系统中的lost+found文件夹和INFORMATION_SCHEMA。也要确保脚本已经准备好应付视图,同时也要知道SHOW TABLE STATUS在InnoDB中有大量数据时可能耗时很长。\nmysql\u0026gt; ** SHOW DATABASES;** mysql\u0026gt; ** SHOW /*!50002 FULL*/ TABLES FROM \u0026lt;* database* \u0026gt;;** mysql\u0026gt; ** SHOW TABLE STATUS FROM \u0026lt;* database* \u0026gt;;** 对表加锁、刷新并解锁\n如果需要对一个或多个表加锁并且/或刷新,要么按名字锁住所需的表,要么使用全局锁锁住所有的表。\nmysql\u0026gt; ** LOCK TABLES \u0026lt;* database.table* \u0026gt; READ [, ...];** mysql\u0026gt; ** FLUSH TABLES;** mysql\u0026gt; ** FLUSH TABLES \u0026lt;* database.table* \u0026gt; [, ...];** mysql\u0026gt; ** FLUSH TABLES WITH READ LOCK;** mysql\u0026gt; ** UNLOCK TABLES;** 在获取所有的表并锁住它们时要格外注意竞争条件。期间可能会有新表创建,或有表被删除或重命名。如果一个表一个表地锁住然后备份,将无法得到一致性的备份。\n刷新二进制日志\n让服务器开始一个新的二进制日志非常简单(一般在锁住表后但在备份前做这个操作):\nmysql\u0026gt; ** FLUSH LOGS;** 这样做使得恢复和增量备份更简单,因为不需要考虑从一个日志文件中间开始操作。此操作会有一些副作用,比如刷新和重新打开错误日志,也可能销毁老的日志条目,因此,注意不要扔掉需要用到的数据。\n获取二进制日志位置\n脚本应该获取并记录主库和备库的状态——即使服务器仅是个主库或备库。\nmysql\u0026gt; ** SHOW MASTER STATUS\\G** mysql\u0026gt; ** SHOW SLAVE STATUS\\G** 执行这两条语句并忽略错误,以使脚本可以获取到所有可能的信息。\n导出数据\n最好的选择是使用mysqldump、mydumper或SELECT INTO OUTFILE。\n复制数据\n可以使用本章中演示的任何一个方法。\n这些都是构造备份脚本的基础。比较困难的部分是将管理和恢复任务脚本化。如果想获得实现的灵感,可以看看ZRM的源码。\n15.9 总结 # 每个人都知道需要备份,但并不是每个人都意识到需要的是可恢复的备份。有许多方法可以规划能满足恢复需求的备份。为了避免这个问题,我们建议明确并记录恢复点目标和恢复时间目标,并且在选择备份系统时将其作为参考。\n在日常基础上做恢复测试以确保备份可以正常工作也很重要。设置mysqldump并让它在每天晚上运行是很简单的,但很多时候不会意识到数据随着时间已经增长到可能需要几天或几周才能再次导入的地步。最糟糕的是当你真正需要恢复的时候,才发现原来需要这么长时间。毫不夸张地说,一个在几个小时内完成的备份可能需要几周时间来恢复,具体取决于硬件、Schema、索引和数据。\n不要掉进备库就是备份的陷阱。备库对生成备份是一个干涉较少的源,但它不是备份本身。对于RAID卷、SAN和文件系统快照,也同样如此。确保备份可以通过DROP TABLE测试(或“遭受黑客攻击”的测试),也要能通过数据中心失败的测试。如果是基于备库生成备份,确保使用pt-table-checksum验证复制的完整性。\n我们最喜欢的两种备份方式,一种是从文件系统或者SAN快照中直接复制数据文件,一种是使用Percona XtraBackup做热备份。这两种方法都可以无侵入地实现二进制的原始数据备份,这样的备份可以通过启动mysqld实例检查所有的表进行验证。有时候甚至可以一石二鸟:可以在开发或者预发环境每天将备份进行还原来执行恢复测试,然后再将数据导出为逻辑备份。我们也建议备份二进制日志,并且尽可能久地保留多份备份的数据和二进制文件。这样即使最近的备份无法使用了,还可以使用较老的备份来执行恢复或者创建新的备库。\n除了提到的许多开源工具,也有很多很好的商业备份工具,其中最重要的是MySQL Enterprise Backup。对包括在GUI SQL编辑器、服务器管理工具和类似工具中的“备份”工具要特别小心。同样地,有一些出品“一招吃遍天下”的备份工具的公司,对于它们宣称的支持MySQL的“MySQL备份插件”也要特别小心。我们需要的是主要为MySQL设计的优秀备份工具,而不是一个支持上百个其他数据库并恰巧支持MySQL的工具。有许多备份工具的供应者并不知道或明白诸如FLUSH TABLES WITH READ LOCK操作对数据库的影响。在我们看来,使用这种SQL命令的方案应该自动退出“热”备份的行列。如果只使用InnoDB表,就更加不需要这类工具。\n————————————————————\n(1) Baron仍然记得他毕业后的第一个工作,当时他把电子商务网站的生产服务器上的发货表删除了两列。\n(2) 是的,即使SELECT查询也会被阻塞,因为如果有一个查询需要修改某些数据,只要它开始等待表上的写锁,所有尝试获取读锁的查询也必须等待。\n(3) 由mysqldump生成的逻辑备份并不一定是文本文件。SQL导出会包含许多不同的字符集,同样也会包含二进制数据,这些数据并不是有效的字符。对于许多编辑器来说,文件行也可能会太长。但是,大多数这样的文件还是可以被编辑器打开和读取,特别是mysqldump使用了\u0026ndash;hex-blob选项时。\n(4) 以我们的经验,逻辑备份往往比物理备份要小许多,但也并不总是如此。\n(5) 值得一提的是物理备份会更易出错;很难像mysqldump一样简单。\n(6) Percona XtraBackup正在开发“真正的”增量备份特性。它将能够备份变更的块,而不需要扫描每个块。\n(7) 请不要用Maatkit的mk-parallel-dump和mk-parallel-restore工具。它们并不安全。\n"},{"id":149,"href":"/zh/docs/technology/MySQL/_%E9%AB%98%E6%80%A7%E8%83%BDMySQL_/%E7%AC%AC14%E7%AB%A0%E5%BA%94%E7%94%A8%E5%B1%82%E4%BC%98%E5%8C%96/","title":"第14章应用层优化","section":"高性能 My SQL","content":"第14章 应用层优化\n如果在提高MySQL的性能上花费太多时间,容易使视野局限于MySQL本身,而忽略了用户体验。回过头来看,也许可以意识到,或许MySQL已经足够优化,对于用户看到的响应时间而言,其所占的比重已经非常之小,此时应该关注下其他部分了。这是个很不错的观点,尤其是对DBA而言,这是很值得去做的正确的事。但如果不是MySQL,那又是什么导致了问题呢?使用第3章提到的技术,通过测量可以快速而准确地给出答案。如果能顺着应用的逻辑过程从头到尾来剖析,那么找到问题的源头一般来说并不困难。有时,尽管问题在MySQL上,也很容易在系统的另一部分得到解决。\n无论问题出在哪里,都至少可以找到一个靠谱的工具来帮助进行分析,而且通常是免费的。例如,如果有JavaScript或者页面渲染的问题,可以使用包括Firefox浏览器的Firebug插件在内的调优工具,或者使用Yahoo!的YSlow工具。我们在第3章提到了几个应用层工具。一些工具甚至可以剖析整个堆栈:New Relic是一个很好的例子,它可以剖析Web应用的前端、应用以及后端。\n14.1 常见问题 # 我们在应用中反复看到一些相同的问题,经常是因为人们使用了缺乏设计的现成系统或者简单开发的流行框架。虽然有时候可以通过这些框架更快更简单地构建系统,但是如果不清楚这些框架背后做了什么操作,反而会增加系统的风险。\n下面是我们经常会碰到的问题清单,通过这些过程可以激发你的思维。\n什么东西在消耗系统中每台主机的CPU、磁盘、网络,以及内存资源?这些值是否合理?如果不合理,对应用程序做基本的检查,看什么占用了资源。配置文件通常是解决问题最简单的方式。例如,如果Apache因为创建1000个需要50MB内存的工作进程而导致内存溢出,就可以配置应用程序少使用一些Apache工作进程。也可以配置每个进程少使用一些内存。 应用真的需要所有获取到的数据吗?获取1000行数据但只显示10行,而丢弃剩下的990行,这是常见的错误。(如果应用程序缓存了另外的990行备用,这也许是有意的优化。) 应用在处理本应由数据库处理的事情吗,或者反过来?这里有两个例子,从表中获取所有的行在应用中进行统计计数,或者在数据库中执行复杂的字符串操作。数据库擅长统计计数,而应用擅长正则表达式。要善于使用正确的工具来完成任务。 应用执行了太多的查询?ORM宣称的把程序员从写SQL中解放出来的语句接口通常是罪魁祸首。数据库服务器为从多个表匹配数据做了很多优化,因此应用程序完全可以删掉多余的嵌套循环,而使用数据库的关联来代替。 应用执行的查询太少了?好吧,上面只说了执行太多SQL可能成为问题。但是,有时候让应用来做“手工关联”以及类似的操作也可能是个好主意。因为它们允许更细的粒度控制和更有效的使用缓存,以及更少的锁争用,甚至有时应用代码里模拟的哈希关联会更快(MySQL的嵌套循环的关联方法并不总是高效的)。 应用创建了没必要的MySQL连接吗?如果可以从缓存中获得数据,就不要再连接数据库。 应用对一个MySQL实例创建连接的次数太多了吗(也许因为应用的不同部分打开了它们自己的连接)?通常来说更好的办法是重用相同的连接。 应用做了太多的“垃圾”查询?一个常见的例子是发送查询前先发送一个ping命令看数据库是否存活,或者每次执行SQL前选择需要的数据库。总是连接到一个特定的数据库并使用完整的表名也许是更好的方法。(这也使得从日志或者通过SHOW PROCESSLIST看SQL更容易了,因为执行日志中的SQL语句的时候不用再切换到特定的数据库,数据库名已经包含在SQL语句中了。)“预备(Preparing)”连接是另一个常见问题。Java驱动在预备期间会做大量的操作,其中大部分可以禁用。另一个常见的垃圾查询是SET NAMES UTF8,这是一个错误的方法(它不会改变客户端库的字符集,只会影响服务器的设置)。如果应用在大部分情况使用特定的字符集工作,可以修改配置文件把特定字符集设为默认值,而不需要在每次执行时去做修改。 应用使用了连接池吗?这既可能是好事,也可能是坏事。连接池可以帮助限制总的连接数,有大量SQL执行的时候效果不错(Ajax应用是一个典型的例子)。然而,连接池也可能有一些副作用,比如说应用的事务、临时表、连接相关的配置项,以及用户自定义变量之间相互干扰等。 应用是否使用长连接?这可能导致太多连接。通常来说长连接不是个好主意,除非网络环境很慢导致创建连接的开销很大,或者连接只被一或两个很快的SQL使用,或者连接频率很高导致客户端本地端口不够用。如果MySQL的配置正确,也许就不需要长连接了。比如使用skip-name-resolve来避免DNS反向查询,确保 thread_cache足够大,并且增加back_log。可以参考第8章和第9章得到更多的细节。 应用是否在不使用的时候还保持连接打开?如果是这样,尤其是连接到很多服务器时,可能会过多地消耗其他进程所需要的连接。例如,假设你连接到10个MySQL服务器。从一个Apache进程中获取10个连接不是问题,但是任意时刻其中只有1个在真正工作。其他9个大部分时间都处于Sleep状态。如果其中一台服务器变慢了,或者有一个很长的网络请求,其他的服务器就可能因为连接数过多受到影响。解决方案是控制应用怎么使用连接。例如,可以将操作批量地依次发送到每个MySQL实例,并且在下一次执行SQL前关闭每个连接。如果执行的是比较消耗时间的操作,例如调用Web服务接口,甚至可以先关闭MySQL连接,执行耗时的工作,再重新打开MySQL连接继续在数据库上工作。 长连接和连接池的区别可能使人困惑。长连接可能跟连接池有同样的副作用,因为重用的连接在这两种情况下都是有状态的。\n然而,连接池通常不会导致服务器连接过多,因为它们会在进程间排队和共享连接。另一方面,长连接是在每个进程基础上创建,不会在进程间共享。\n连接池也比共享连接的方式对连接策略有更强的控制力。连接池可以配置为自动扩展,但是通常的实践经验是,当遇到连接池完全占满时,应该将连接请求进行排队而不是扩展连接池。这样做可以在应用服务器上进行排队等待,而不是将压力传递到MySQL数据库服务器上导致连接数太多而过载。\n有很多方法可以使得查询和连接更快,但是一般的规则是,如果能够直接避免进行查询和连接,肯定比努力提升查询和连接的性能能获得更好的优化结果。\n14.2 Web服务器问题 # Apache是最流行的Web应用服务器软件。它在许多情况下都运行良好,但如果使用不当也会消耗大量的资源。最常见的问题是保持它的进程的存活(alive)时间过长,或者在各种不同的用途下混合使用,而不是分别对不同类型的工作进行优化。\nApache通常是通过prefork配置来使用mod_php、mod_perl和mod_python模块的。prefork模式会为每个请求预分配进程。因为PHP、Perl和Python脚本是可以定制化的,每个进程使用50MB或100MB内存的情况并不少见。当一个请求完成后,会释放大部分内存给操作系统,但并不是全部。Apache会保持进程处于打开状态以备后来的请求重用。这意味着,如果下一个请求是请求静态文件,比如一个CSS文件或者一张图片,就会出现用一个占用内存很多的进程来为一个很小的请求服务的情况。这就是使用Apache作为通用Web服务器很危险的原因。它的确是为通用目的而设计的,但如果能够有针对性地使用其长处,会获得更好的性能。\n另一个主要的问题是,如果开启了Keep-Alive设置,进程可能很长时间处于繁忙状态。当然,即使没有开启Keep-Alive,某些进程也可能存活很久,“填鸭式”地将内容传给客户端可能导致获取数据很慢(1)。\n人们常犯的另外一个错误,就是保持那些Apache默认开启的模块不动。\n最好能够精简Apache的模块,移除掉那些不需要的。这很简单:只需要检查Apache的配置文件,注释掉不想要的模块,然后重启Apache就行。也可以在php.ini文件中删除不使用的PHP模块。\n最差情况是,如果用一个通用目的的Apache配置直接用于Web服务,最后很可能产生很多重量级的Apache进程。这将浪费Web服务器的资源。它们还可能保持大量MySQL连接,浪费MySQL的资源。下面是一些可以降低服务器负载的方法(2)。\n不要使用Apache来做静态内容服务,或者至少和动态服务使用不同的Apache实例。流行的替代品有Nginx( http://www.nginx.com)和lighttpd ( http://www.lighttpd.net)。\n使用缓存代理服务器,比如Squid或者Varnish,防止所有的请求都到达Web服务器。这个层面即使不能缓存所有页面,也可以缓存大部分页面,并且使用像ESI(Edge Side Includes,参见 http://www.esi.org)这样的技术来将部分页面中的小块的动态内容嵌入到静态缓存部分。 对动态和静态资源都设置过期策略。可以使用Squid这样的缓存代理显式地使内容过期。维基百科就使用了这个技术来清理缓存中变更过的文章。 有时也许还需要修改应用程序,以便得到更长的过期时间。例如,如果你告诉浏览器永久缓存CSS和JavaScript文件,然后对站点的HTML做了一个修改,这个页面渲染将会出问题。这种情况可以为文件的每个版本设定唯一的文件名。例如,你可以定制网站的发布脚本,复制CSS文件到*/css/123_frontpage.css*,这里的123就是版本管理器中的版本号。对图片文件的文件名也可以这么做——永不重用文件名,这样页面就不会在升级时出问题,浏览器缓存多久的文件都没问题。\n不要让Apache填鸭式地服务客户端,这不仅仅会导致慢,也会导致DDoS攻击变得简单。硬件负载均衡器通常可以做缓冲,所以Apache可以快速地完成,让负载均衡器通过缓存响应客户端的请求,也可以在应用服务器前端使用Nginx、Squid或者事件驱动模式下的Apache。 打开gzip压缩。对于现在的CPU而言这样做的代价很小,但是可以节省大部分流量。如果想节省CPU周期,可以使用缓存,或者诸如Nginx这样的轻量级服务器保存压缩过的页面版本。 不要为用于长距离连接的Apache配置启用Keep-Alive选项,因为这会使得重量级的Apache进程存活很长时间。可以用服务器端的代理来处理保持连接的工作,从而防止Apache被客户端拖垮。配置Apache到代理之间的连接使用Keep-Alive是可以的,因为代理只会使用很少的Apache连接去获取数据。图14-1展示了这个区别。 图14-1:代理可以使Apache不被长连接拖垮,产生更少的Apache工作进程。\n这些策略可以使Apache进程存活时间变得很短,所以会有比实际需求更多的进程。无论如何,有些操作依然可能导致Apache进程存活时间太长,并且占用大量资源。举个例子,一个请求查询延时非常大的外部资源,例如远程的Web服务,就会出现Apache进程存活时间太长的问题。这种问题通常是无解的。\n14.2.1 寻找最优并发度 # 每个Web服务器都有一个最佳并发度——就是说,让进程处理请求尽可能快,并且不超过系统负载的最优的并发连接数。这就是我们在第11章说的最大系统容量。进行一个简单的测量和建模,或者只是反复试验,就可以找到这个“神奇的数”,为此花一些时间是值得的。\n对于大流量的网站,Web服务器同一时刻处理上千个连接是很常见的。然而,只有一小部分连接需要进程实时处理。其他的可能是读请求,处理文件上传,填鸭式服务内容,或者只是等待客户端的下一步请求。\n随着并发的增加,服务器会逐渐到达它的最大吞吐量。在这之后,吞吐量通常开始降低。更重要的是,响应时间(延迟)也会因为排队而开始增加。\n为什么会这样呢?试想,如果服务器只有一个CPU,同时接收到了100个请求,会发生什么事情呢?假设CPU每秒能够处理一个请求。即便理想情况下操作系统没有调度的开销,也没有上下文切换的成本,那100个请求也需要CPU花费整整100s才能完成。\n处理请求的最好方法是什么?可以将其一个个地排到队列中,也可以并行地执行并在不同请求之间切换,每次切换都给每个请求相同的服务时间。在这两种情况下,吞吐量都是每秒处理一个请求。然而,如果使用队列(并发=1),平均延时是50s,如果是并发执行(并发=100)则是100s。在实践中,并发执行会使平均延时更高,主要是因为上下文切换的代价。\n对于CPU密集型工作负载,最佳并发度等于CPU数量(或者CPU核数)。然而,进程并不总是处于可运行状态的,因为会有一些阻塞式请求,例如I/O、数据库查询,以及网络请求。因此,最佳并发度通常会比CPU数量高一些。\n可以预测最优并发度,但是这需要精确的分析。尝试不同的并发值,看看在不增加响应时间的情况下的最大吞吐量是多少,或者测量真正的工作负载并且进行分析,这通常更容易。Percona Toolkit的pt-tcp-model工具可以帮助从TCP转储中测量和建模分析系统的可扩展性和性能特性。\n14.3 缓存 # 缓存对高负载应用来说是至关重要的。一个典型的Web应用程序会提供大量的内容,直接生成这些内容的成本比采用缓存要高得多(包含检查和缓存超时的开销),所以采用缓存通常可以获得数量级的性能提升。诀窍是找到正确的粒度和缓存过期策略组合。另外也需要决定哪些内容适合缓存,缓存在哪里。\n典型的高负载应用会有很多层缓存。缓存并不仅仅发生在服务器上,而是在每一个环节,甚至包括用户的Web浏览器(这就是内容过期头的用处)。通常,缓存越接近客户端,就越节省资源并且效率更高。从浏览器缓存提供一张图片比从Web服务器的内存获取快得多,而从服务器的内存读取又比从服务器的磁盘上读取好得多。每种类型的缓存有其不一样的特点,例如容量和延时;在后面的章节我们会解释其中的一部分。\n可以把缓存分成两大类:被动缓存和主动缓存。被动缓存除了存储和返回数据外不做任何事情。当从被动缓存请求一些内容时,要么可以得到结果,要么得到“结果不存在”。被动缓存的一个典型例子是memcached。\n相比之下,主动缓存会在访问未命中时做一些额外的工作。通常会将请求转发送给应用的其他部分来生成请求结果,然后存储该结果并返回给应用。Squid缓存代理服务器就是一个主动缓存。\n设计应用程序时,通常希望缓存是主动的(也可以叫做透明的),因为它们对应用隐藏了检查—生成—存储这个逻辑过程。也可以在被动缓存的前面构建一个主动缓存。\n14.3.1 应用层以下的缓存 # MySQL服务器有自己的内部缓存,但也可以构建你自己的缓存和汇总表。可以对缓存表量身定制,使它们最有效地过滤、排序、与其他表关联、计数,或者用于其他用途。缓存表也比许多应用层缓存更持久,因为在服务器重启后它们还存在。\n在第4章和第5章已经介绍了关于缓存策略的内容,所以在这一章,我们主要关注应用层以及更高层次的缓存。\n缓存并不总是有用\n必须确认缓存真的可以提升性能,因为有时缓存可能没有任何帮助。例如,在实践中发现从Nginx的内存中获取内容比从缓存代理中获取要快。如果代理的缓存在磁盘上则尤其如此。\n原因很简单:缓存自身也有一些开销。比如检查缓存是否存在,如果命中则直接从缓存中返回数据。另外将缓存对象失效或者写入新的缓存对象都会有开销。缓存只在这些开销比没有缓存的情况下生成和提供数据的开销少时才有用。\n如果知道所有这些操作的开销,就可以计算出缓存能提供多少帮助。没有缓存时的开销就是为每个请求生成数据的开销。有缓存时的开销是检查缓存的开销加上缓存不命中的概率乘以生成数据的开销,再加上缓存命中的概率乘以缓存提供数据的开销。\n如果有缓存时的开销比没有时要低,则说明缓存可能有用,但依然不能保证。还要记住,就像从Nginx的内存中获取数据比从代理在磁盘中的缓存获取要好一样,有些缓存的开销比另外一些要低。\n14.3.2 应用层缓存 # 应用层缓存通常在同一台机器的内存中存储数据,或者通过网络存在另一台机器的内存中。\n因为应用可以缓存部分计算结果,所以应用层缓存可能比更低层次的缓存更有效。因此,应用层缓存可以节省两方面的工作:获取数据以及基于这些数据进行计算。一个很好的例子是HTML文本块。应用程序可以生成例如头条新闻的标题这样的HTML片段,并且做好缓存。后续的页面视图就可以简单地插入这个缓存过的文本。一般来说,在缓存数据前对数据做的处理越多,缓冲命中节省的工作就越多。\n但应用层缓存也有缺点,那就是缓存命中率可能更低,并且可能使用较多的内存。假设需要50个不同版本的头条新闻标题,以使不同地区生活的用户看到不同的内容,那就需要足够的内存去存储全部50个版本,任何给定版本的标题命中次数都会更少,并且失效策略也会更加复杂。\n应用缓存有许多种,下面是其中的一小部分。\n本地缓存\n这种缓存通常很小,只在进程处理请求期间存在于进程内存中。本地缓存可以有效地避免对某些资源的重复请求。这种类型的缓存技术并不复杂:通常只是应用代码中的一个变量或者哈希表。例如,假设需要显示一个用户名,而且已经知道其ID,就可以创建一个get_name_from_id() 函数并且在其中增加缓存,像下面这样。\n\u0026lt;?php function get_name_from_id($user_id) { static $name; // static makes the variable persist if ( !$name ) { // Fetch name from database } return $name; } ?\u0026gt; 如果使用的是Perl,那么Memoize模块是函数调用结果标准的缓存方式。\nuse Memoize qw(memoize); memoize 'get_name_from_id'; sub get_name_from_id { my ( $user_id ) = @_; my $name = # get name from database return $name; } 这些技巧都很简单,但却可以为应用程序节省很多工作。\n本地共享内存缓存\n这种缓存一般是中等大小(几个GB),快速,难以在多台机器间同步。它们对小型的半静态位数据比较合适。例如每个州的城市列表,分片数据存储的分区函数(映射表),或者使用存活时间(TTL)策略进行失效的数据等。共享内存最大的好处是访问非常快——通常比其他任何远程缓存访问都要快不少。\n分布式内存缓存\n最常见的分布式内存缓存的例子是memcached。分布式缓存比本地共享内存缓存要大得多,增长也容易。缓存中创建的数据的每一个比特都只有一份副本,这样既不会浪费内存,也不会因为相同的数据存在不同的地方而引入一致性问题。分布式内存非常适合存储共享对象,例如用户资料、评论,以及HTML片段。\n分布式缓存比本地共享缓存的延时要高得多,所以最高效的使用方法是批量进行多个获取操作(例如,在一次循环中获取多个对象)。分布式缓存还需要考虑怎么增加更多的节点,以及某个节点崩溃了怎么处理。对于这两个场景,应用程序必须决定在节点间怎么分布或重分布缓存对象。当缓存集群增加或减少一台服务器时,一致性缓存对避免性能问题而言是非常重要的。在下面这个网站有一个为memcached做的一致性缓存库: http://www.audioscrobbler.net/development/ketama/。\n磁盘上的缓存\n磁盘是很慢的,所以缓存在磁盘上的最好是持久化对象,很难全部装进内存的对象,或者静态内容(例如预处理的自定义图片)。\n对于磁盘上的缓存和Web服务器,一个非常有用的技巧是使用404错误处理机制来捕捉缓存未命中的情况。假设Web应用要在头部展示一张基于用户名(“欢迎回来, John!”)的自定义图片,并且通过*/images/welcomeback/john.jpg*这样的路径引用此图片。如果图片不存在,将会导致一个404错误,并且触发上述错误处理。这个错误处理可以生成图片,在磁盘上存储它,然后发出一个重定向或者将该图片传回浏览器。后续的请求只需要从文件中直接返回图片。\n有很多类型的内容可以使用这种技巧。例如,不用再将最近的标题作为HTML部分进行缓存,可以在JavaScript文件中存储这些东西,然后在网页头中引用这个文件:latest_headlines.js。\n缓存失效很简单:删除文件即可。可以通过执行一个删除N分钟前所创建的文件的定时任务,来实现TTL失效。如果想要限制缓存大小,也可以通过按最近访问时间排序来删除文件,从而实现最近最少使用(LRU)失效算法。\n如果失效策略是基于最近访问时间,则必须在文件系统挂载参数中打开访问时间记录。(忽略noatime选项即可。)如果这么做,应该使用内存文件系统来避免大量磁盘操作。\n14.3.3 缓存控制策略 # 缓存也有像反范式化数据库设计一样的问题:重复数据,也就是说有多个地方需要更新数据,所以需要想办法避免读到脏数据。下面是一些最常见的缓存控制策略。\nTTL(time to live,存活时间)\n缓存对象存储时设置一个过期时间;可以通过清理进程在达到过期时间后删掉对象,或者先留着直到下次访问时再清理(清理后需要使用新的版本替换)。对于数据很少变更或者没有新数据的情况,这是最好的失效策略。\n显式失效\n如果不能接受脏数据,那么进程在更新原始数据时需要同时使缓存失效。这种策略有两个变种:写—失效和写—更新。写—失效策略很简单:只需要标记缓存数据已经过期(是否清理缓存数据是可选的)。写—更新策略需要多做一些工作,因为在更新数据时就需要替换掉缓存项。无论如何,这都是非常有益的,特别是当生成缓存数据代价很昂贵时(写线程也许已经做了)。如果更新缓存数据,后续的请求将不再需要等待应用来生成。如果在后台做失效处理,例如基于TTL的失效,就可以在一个从用户请求完全分离出来的进程中生成失效数据的新版本。\n读时失效\n在更改旧数据时,为了避免要同时失效派生出来的脏数据,可以在缓存中保存一些信息,当从缓存中读数据时可以利用这些信息判断数据是否已经失效。和显式失效策略相比,这样做有很大的优势:成本固定且可以分散在不同时间内。假设要失效一个有一百万缓存对象依赖的对象,如果采用写时失效,需要一次在缓存中失效一百万个对象,即使有高效的方法来找到这些对象,也可能需要很长的时间才能完成。如果采用读时失效,写操作可以立即完成,但后续这一百万对象的读操作可能会有略微的延迟。这样就把失效一百万对象的开销分散了,并且可以帮助避免出现负载冲高和延迟增大的峰值。\n一种最简单的读时失效的办法是采用对象版本控制。使用这种方法,在缓存中存储一个对象时,也可以存储对象所依赖的数据的当前版本号或者时间戳。例如,假设要缓存用户博客日志的统计信息,包括用户发表的博客数。当缓存blog_stats对象时,也可以同时存储用户的当前版本号,因为该统计信息是依赖于用户的。\n不管什么时候更新依赖于用户的数据,都需要更新用户的版本号,假设用户的版本号初始为0,并且由你来生成和缓存统计信息。当用户发表了一篇博客,就增加用户的版本号到1(当然也要同时存储这篇博客,尽管在这个例子并没有用到博客数据)。然后当需要显示统计数据的时候,可以对缓存中blog_stats对象的版本与缓存的用户版本进行比较。因为用户的版本比对象的版本高,所以可以知道缓存的统计信息已经过期了,需要重新计算。\n这是一个非常粗糙的内容失效方式,因为它假设依赖于用户的每一个比特的数据与所有其他数据都有交互。但这个假设并不总是成立的。举个例子,如果一个用户对一篇博客做了编辑,你也需要增加用户的版本号,这就会导致存储的统计信息失效,而实际上统计信息(发表的博客数)并没真的改变。这个取舍是很简单的。一个简单的缓存失效策略不只是更容易创建,也可能更加高效。\n对象版本控制是一种简单的标记缓存方法,它可以处理更复杂的依赖关系。一个标记的缓存可以识别不同类型的依赖,并且分别跟踪每个依赖的版本。回到第11章中图书俱乐部的例子,你可以通过下面的版本号标记评论,使缓存的评论依赖于用户的版本和书的版本:user_ver=1234和book_ver=5678。任一版本号变了,都应该刷新缓存的评论。\n14.3.4 缓存对象分层 # 分层缓存对象对检索、失效和内存利用都有帮助。相对于只缓存对象,也可以缓存对象的ID、对象的ID组等通常需要一起检索的数据。\n电子商务网站的搜索结果是这种技术很好的例子。一次搜索可能返回一个匹配产品的列表,包括名称、描述、缩略图,以及价格。缓存整个列表的效率很低:其他的搜索也可能会包含一些相同的产品,这就会导致数据重复,并且浪费内存。这种策略也使得当一个产品的价格变动时,找出并失效搜索结果变得很困难,因为你必须查看每个列表,找到哪些列表包含了更新过的产品。\n可以缓存关于搜索的最小信息,而不必缓存整个列表,例如返回结果的数量以及列表中的产品ID。然后可以再单独缓存每个产品。这样做可解决两个问题:不会重复存放任何结果数据,也更容易在失效产品的粒度上去失效缓存。\n缺点则是,相对于一次性获得整个搜索结果,必须在缓存中检索多个对象。然而不管怎么说,为搜索结果缓存产品ID的列表都是更有效的做法。先在一个缓存命中返回ID的列表,再使用这些ID去请求缓存获得产品信息。如果缓存允许在一次调用里返回多个结果,第二次请求就可以返回多个产品(memcached通过mget()调用来支持)。\n如果使用不当,这种方法可能会导致奇怪的结果。假设使用TTL策略来失效搜索结果,并且当产品变更时显式地去失效单个产品。现在想象一下,一个产品的描述发生了变化,不再包含搜索中匹配的关键字,但是搜索结果的缓存还没有过期失效。此时用户就会看到错误的搜索结果,因为缓存的搜索结果将会引用这个变化了的产品,即使它不再包含匹配搜索的关键字。\n对于大多数应用程序来说,这不是问题。如果应用程序不能容忍这种情况,可以使用基于版本的缓存,并在执行搜索时在结果中存储产品的版本号。当发现搜索结果在缓存中时,可以将当前搜索结果的版本号和搜索结果中每个产品的版本号做比较。如果发现任何一个产品的版本数据不一致,可以重新搜索并且重新缓存结果。\n这对理解远程缓存访问的花销是多么昂贵非常重要。虽然缓存很快,也可以避免很多工作,但在LAN环境下网络往返缓存服务器通常也需要0.3ms左右。我们见过很多案例,复杂的网页需要一千次左右的缓存访问来组合页面结果,这将会耗费3s左右的网络延时,意味着你的页面可能慢得不可接受,即使它甚至不需要访问数据库!因此,在这种情况下对缓存使用批量获取调用是非常重要的。对缓存进行分层,采用小一些的本地缓存,也可能获得很大的收益。\n14.3.5 预生成内容 # 除了在应用程序级别缓存位数据,也可以在后台预先请求一些页面,并且将结果存为静态页面。如果页面是动态的,也可以预先生成页面的部分内容,然后使用像服务端包含(SSI)这样的技术创建最终页面。这有助于减小预生成内容的大小和开销,否则可能在将不同部分拼装到最终页面的时候,由于微小的变化产生大量的重复内容。几乎可以对任何类型的缓存使用预生成策略,包括memcached。\n预生成内容有几个重要的好处。\n应用代码没有复杂的命中和未命中处理路径。 当未命中的处理路径慢得不可接受时,这种方案可以很好地工作,因为它保证了未命中的情况永远不会发生。实际上,在任何时候设计任何类型的缓存系统,总是应该考虑未命中的路径有多慢。如果平均性能提升很大,但是因为要预生成缓存内容,偶尔有一些请求变得非常缓慢,这时可能比不用缓存还糟糕。性能的持续稳定通常跟高性能一样重要。 预生成内容可以避免在缓存未命中时导致的雪崩效应。 缓存预生成好的内容可能占用大量空间,并且并不总能预生成所有东西。无论是哪种形式的缓存,需要预生成的内容中最重要的部分是那些最经常被请求,或者生成的成本最高的,所以可以通过本章前面提到的404错误处理机制来按需生成。\n预生成的内容有时候也可以从内存文件系统中获益,因为可以避免磁盘I/O。\n14.3.6 作为基础组件的缓存 # 缓存有可能成为基础设施的重要组成部分。也很容易陷入一个陷阱,认为缓存虽然很好用,但并不是重要到非有不可的东西。你也许会辩驳,如果缓存服务器宕机或者缓存被清空,请求也可以直接落在数据库上,系统依然可以正常运行。如果是刚刚将缓存加入应用系统,这也许是对的,但是缓存的加入可以使得在应用压力显著增长时不需要对系统的某些部分同比增加资源投入——通常是数据库部分。因此,系统可能慢慢地变得对缓存非常依赖,却没有被发觉。\n例如, 如果高速缓存命中率是90%,当由于某种原因失去缓存,数据库上的负载将增加到原来的10倍。这很可能导致压力超过数据库服务器的性能极限。\n为了避免像这样的意外,应该设计一些高可用性缓存(包括数据和服务)的解决方案,或者至少是评估好禁用缓存或丢失缓存时的性能影响。比如说可以设计应用在遇到这样的情况时能够进行降级处理。\n14.3.7 使用HandlerSocket和memcached # 相对于数据存储在MySQL中而缓存在MySQL外部的缓存方案,另外有一种替代方法是为MySQL创建一个更快的访问路径,直接绕过使用缓存。对于小而简单的查询语句,很大一部分开销来自解析SQL,检查权限,生成执行计划,等等。如果这种开销可以避免, MySQL在处理简单查询时将非常快。\n目前有两个解决方案可以用所谓的NoSQL方式访问MySQL。第一种是一个后台进程插件,称为HandlerSocket,由DeNA开发,这是日本最大的社交网站。HandlerSocket允许通过一个简单的协议访问InnoDB Handler对象。实际上,也就是绕过了上层的服务器层,通过网络直接连接到了InnoDB引擎层。有报告称HandlerSocket每秒可以执行超过750 000条查询。Percona Server分支中自带了HandlerSocket插件引擎层。\n第二个方案是通过memcached协议访问InnoDB。MySQL 5.6的实验室版本有一个插件提供了这个接口。\n两种方法都有一些限制——特别是memcached的方法,这种方法对很多访问数据的方法都不支持。为什么会希望采用SQL以外的什么办法访问数据呢?除了速度之外,最大的原因可能是简单。这样做最大的好处是可以摆脱缓存,以及所有的失效逻辑,还有为它们服务的额外的基础设施。\n14.4 拓展MySQL # 如果MySQL不能做你需要的事,一种可能是拓展其功能。在这里我们不会展示如何做到这一点,但会提供一些可能的方向。如果你对进一步探索有兴趣,那么有很多很好的在线资源,以及许多关于这些内容的书籍可以参考。\n当我们说“MySQL不能做你需要的事”,我们指的是两件事情:MySQL根本做不到这一点,或者MySQL可以做到,但是只能通过缓慢或笨拙的方法,总之做得不够好。无论哪个都是需要对MySQL拓展的原因。好消息是,MySQL已经越来越模块化和通用。\n存储引擎是拓展MySQL的一个很好的方式。Brian Aker已经写了一个存储引擎的框架,还有一系列介绍有关如何开始编写自己的存储引擎的文章。这是目前几个主要的第三方存储引擎的基础。许多公司都编写了它们自己的内部存储引擎。例如,一些社交网络公司使用了特殊的为社交图形操作设计的存储引擎,我们还知道有个公司定制了一个用于模糊搜索的引擎。写一个简单的自定义存储引擎并不难。\n还可以使用存储引擎作为另一个软件的接口。Sphinx引擎就是一个很好的例子,该引擎是Sphinx全文检索软件的接口(见附录F)。\n14.5 MySQL的替代品 # MySQL并不是适合每一个场景的解决方案。有些工作通常在MySQL以外来做会更好,即使MySQL理论上也可以做到。\n最明显的一个例子是在传统的文件系统中存储文件,而不是在表中。图像文件是经典案例:虽然可以把它们放到一个BLOB列,但这通常不是个好办法(3)。一般的做法是,在文件系统中存储图片或其他大型二进制文件,而在MySQL中只存储文件名;然后应用程序在MySQL之外存取文件。对于Web应用程序,可以把文件名放在 \u0026lt;img\u0026gt;元素的src属性中,这样就可以实现对文件的存取。\n全文检索是另一个最好放在MySQL之外处理的例子——MySQL在全文搜索方面明显不如Lucene和Sphinx。\nNDB API也可能对某些任务有用。例如,尽管MySQL的NDB集群存储引擎(目前还)不适合存储一个高性能Web应用程序的全部数据,但用NDB API直接存储网站会话数据或用户注册信息还是可能的。在如下网站可以了解到更多关于NDB API的内容: http://dev.mysql.com/doc/ndbapi/en/index.html。还有供Apache使用的NDB模块,mod_ndb,可以在* http://code.google.com/p/mod-ndb/*下载。\n最后,对于某些操作——如图形关系和树遍历——关系型数据库并不总是正确的典范。MySQL并不擅长分布式数据处理,因为它缺乏并行执行查询的能力。出于这些目的情况还是建议使用其他工具(可能与MySQL结合)。现在想到的例子包括:\n对于简单的键—值存储,在复制严重落后的非常高速的访问场景中,我们建议用Redis替换MySQL。即使MySQL主库可以承担这样的压力,备库的延迟也是非常让人头疼的。Redis也常用来做队列,因为它对队列操作支持得很好。 Hadoop是房间中的大象,一语双关。混合MySQL/Hadoop的部署在处理大型或半结构化数据时非常常见。 14.6 总结 # 优化并不只是数据库的事。正如我们在第3章建议的,最高形式的优化既包含业务上的,也包含用户层的。全方位的优化才是好的优化。\n一般来说,首先要做的事是测量。认真剖析每一层的问题。哪一层导致了大部分的响应时间?对这一层就要重点关注。如果用户的经验是大部分的时间消耗在浏览器的DOM渲染上面,MySQL只贡献总响应时间的一小部分,那么进一步优化查询语句绝对不可能明显地改善用户体验。在测量完成后,通常很容易理解应该在哪里投入精力。我们建议阅读Steve Souders的两本书(High Performance Web Sites和Even Faster Web Sites),并且建议使用New Relic工具。\n在Web服务器的配置和缓存中经常可以发现大问题,而这些问题往往很容易解决。还有一个固有的观念,“总是数据库的问题”,但这其实是不正确的。应用程序中的其他层也同样重要,它们很可能被错误配置,尽管有时不太明显。特别是缓存,能承受比只使用MySQL要低得多的成本传递大量内容。虽然Apache依然是世界上最流行的Web服务器软件,但它并不总是最合适的工具,因此考虑像Nginx这样的替代方案也是非常有意义的。\n————————————————————\n(1) 填鸭式抓取发生在当一个客户端发起HTTP请求,但是没有迅速获取结果时。直到客户端获取整个结果,HTTP连接——以及处理的Apache进程——都将保持活跃。\n(2) 有一本关于如何优化Web应用的很不错的书——High Performance Web Sites,作者是Steve Souders (O\u0026rsquo;Reilly)。尽管书中大部分内容是从客户的角度来看如何让Web站点运行更快,但是参考他的建议也有利于你的服务器。Steve后续的一本书Even Faster Web Sites也很不错,值得阅读。\n(3) 使用MySQL的复制来快速分布镜像到其他机器更有优势,我们知道一些程序使用这种技术。\n"},{"id":150,"href":"/zh/docs/technology/MySQL/_%E9%AB%98%E6%80%A7%E8%83%BDMySQL_/%E7%AC%AC13%E7%AB%A0%E4%BA%91%E7%AB%AF%E7%9A%84MySQL/","title":"第13章云端的MySQL","section":"高性能 My SQL","content":"第13章 云端的MySQL\n许多人在云中使用MySQL,有时候规模还非常庞大,这并不奇怪。从我们的经验来看,大多数人使用的是Amazon Web Services平台(AWS):特别是Amazon的弹性计算云(Elastic Compute Cloud,EC2),弹性块存储(Elastic Block Store,EBS),以及更小众的关系数据库服务(Relational Database Service,RDS)。\n为了便于讨论MySQL在云中的应用,可以将其粗略分为两类。\nIaaS(基础设施即服务)\nIaas是用于托管自有的MySQL服务器的云端基础架构。可以在云端购买虚拟的服务器资源来安装运行MySQL实例。也可以根据需求随意配置MySQL和操作系统,但没有权限也无法看到处于底层的物理硬件设备。\nDBaaS(数据库即服务)\nMySQL本身作为由云端管理的资源。用户需要先收到MySQL服务器的访问许可(通常是一个连接串)才能访问。也可以配置一些MySQL选项,但没有权限去控制或查看底层的操作系统或虚拟服务器实例。例如 Amazon运行MySQL的RDS。其中一些服务器并非真的使用MySQL,但它们能兼容MySQL协议和查询语言。\n我们讨论的重点主要集中在第一类:云托管平台,例如AWS、Rackspace Cloud以及Joyent(1)。有许多很好的资源介绍如何部署和管理MySQL及其运行所需要的资源,并且也有非常多的平台来完全满足这样的需求,所以我们不会展示代码样例或讨论具体的操作技术。因此,本章关注的重点是,在云端运行MySQL还是在传统服务器上部署MySQL,它们在最终经济上和性能特性上的关键区别是什么。我们假定你对云计算很熟悉。这里不是对云计算概念的简单介绍,我们的目的只是帮助那些还不熟悉在云端部署MySQL的用户在使用时避免一些可能遇到的陷阱。\n一般来说,MySQL能够在云中很好地运行。在云中运行MySQL并不比在其他平台困难,但有一些非常重要的差别。你需要注意这些差别并据此设计应用和架构来获得好的效果。某些场景下在云端托管MySQL并不是非常适合,有时候则很适合,但大多数时候云仅仅是另外一个部署平台而已。\n云是一个部署平台,而不是一种架构,理解这一点很重要。架构会受平台的影响,但平台和架构明显不同。如果你把架构和平台搞混了,就可能会做出不合适的选择而给以后带来麻烦。这也正是我们要花时间讨论云端的MySQL到底有什么不同的原因。\n13.1 云的优点、缺点和相关误解 # 云计算有许多优点,但很少是为MySQL特别设计。有一些书籍已经介绍了相关的话题(2),这里我们不再赘述。不过我们会列出一些比较重要的条目供参考,因为接下来会讨论到云计算的缺点,我们不希望你认为我们是在过分苛求云计算。\n云是一种将基础设施外包出去无须自己管理的方法。你不需要寻找供应商购买硬件,也不需要维护和供应商之间的关系,更无须替换失效的硬盘驱动器等。 云一般是按照即用即付的方式支付,可以把前期的大量资本支出转换为持续的运营成本。 随着供应商发布新的服务和成本降低,云提供的价值越来越大。你自己无须做任何事情(例如升级服务器),就可以从这些提升中获益;随着时间推移你会很容易地获得更多更好的选择并且费用更低。 云能够帮助你轻松地准备好服务器和其他资源,在用完后直接将其关闭,而无须关注怎么处理它们,或者怎么卖掉它们收回成本。 云代表了对基础设施的另一种思考方式——作为通过API来定义和控制的资源——支持更多的自动化操作。从“私有云”中也可以获得这些好处。 当然,不是所有跟云相关的东西都是好的。这里有一些缺点可能会构成挑战(在本章稍后部分我们会列出MySQL特有的缺点)。\n资源是共享并且不可预测的,实际上你可以获得比你支付的更多的资源。这听起来很不错,但却导致容量规划很难做。如果你在不知情的情况下获得了比理应享受到的更多的计算资源,那么就存在这样的风险:别人也许会索要他们应得的资源,这会使你的应用性能退化到应有的水平。一般来说,很难确切地知道本来应该得到多少(资源),大多数云托管服务提供商不会对此给出确切的答案。 无法保证容量和可用性。你可能以为还可以获得新实例,但如果供应商已经超额销售了呢?这在有很多共享资源的情况下会发生,同样也会发生在云中。 虚拟的共享资源导致排查故障更加困难,特别是在无法访问底层物理硬件的情况下无法检查并弄清到底发生了什么。例如,我们曾经看到过一些系统的iostat显示的I/O很正常或者vmstat显示的CPU很正常,而当实际衡量完成一个任务需要的时间时,资源却被系统上的其他东西严重占用了。如果在云平台上出现了性能问题,尤其需要去仔细地分析检测。如果对此并不擅长,可能就无法确认到底是底层系统性能差,还是你做了什么事情导致应用出现不合理的资源需求。 总的来说,云平台上对性能、可用性和容量的透明性和控制力都有所下降。最后,还有一些对云的误解需要记住。\n云天生具备更好的可扩展性\n应用、云的架构,以及管理云服务的组织是不是都是可扩展的。云并不是天生可扩展的,云也仅仅是云而已,选择一个可扩展的平台并不能自动使应用变得可扩展。的确,如果云托管提供商没有超售,那么你可以根据需求来购买资源,但在需要时能够获得资源仅仅是扩展性的一个方面而已。\n云可以自动改善甚至保证可用时间\n一般来说,个别在云端托管的服务器比那些经过良好设计的专用基础设施更容易发生故障或运行中断。但是许多人并没有意识到这一点。例如,有人这样写道:“我们将基础设施升级到基于云构建的系统以保证100%的可用时间和可扩展性”。而就在这之前AWS遭受了两次大规模的运行中断故障,导致很大一部分用户受影响。好的架构能够用不可靠的组件设计出可靠的系统,但通常更可靠的基础设施可以获得更高的可用性。(当然不可能有100%的可用时间的系统。)\n另一方面,购买云计算服务,实际上是购买一个由专家构建的平台。他们已经考虑了许多底层的东西,这意味着你可以更专注于上层工作。如果构建自己的平台而对其中的那些细枝末节并不精通,就可能犯一些初学者的错误,早晚会导致一些宕机时间。从这一点来说,云计算能够帮助改善可用时间。\n云是唯一能提供[这里填入任意的优点]的东西\n事实上,许多云的优点是继承自构建云平台所用到的技术,即使不使用云也可以获得(3)。例如,通过管理得当的虚拟化和容量规划,可以像任何一个云平台那样简单快速地启动(spin up)一台新的机器。完全没必要专门使用云来做到这一点。\n云是一个“银弹”(silver bullet)\n虽然大部分人会认为这很荒谬,但确实有人会这么认为。实际上完全没有这回事。\n无可否认,云计算提供了独特的优点,随着时间的推移,关于云计算是什么,以及它们在什么情况下会有帮助,我们会获得更多的共识。但有一点非常肯定:它是全新的,我们现在所做的任何预测都未必经得起时间的考验。我们会在本书讨论相对安全的部分,而将剩下的部分留给读者讨论。\n13.2 MySQL在云端的经济价值 # 在一些场景下云托管比传统的服务器部署方式更经济。以我们的经验来看,云托管比较适合尚处于初级阶段的企业,或者那些持续接触新概念并且本质上是以适用为主的企业,例如移动应用开发者或游戏开发者。这些技术的市场随着移动计算的扩张出现了爆炸式增长,并且仍然是快速发展的领域。在许多情况下,成功的因素并不为开发者所控制,例如口口相传的推荐或者恰逢重要国际事件的时机。\n我们已经帮助很多公司在云中构建移动应用、社交网络以及游戏应用。其中一个他们大量使用的策略是尽可能又快又便宜地开发和发布应用。如果一个应用碰巧变得流行了,公司将投入资源扩大其规模;否则就会很快终结这些应用。一些公司构建并发布的应用的生命周期甚至只有几个星期,在这样的环境下,可以毫不犹豫地选择云托管。\n如果是一个小规模的公司,可能无法提供足够的硬件来自建数据中心以满足一个非常流行的Facebook应用的发展曲线。我们也协助过一些大型的Facebook应用进行扩展,它们能够以今人惊讶的速度增长——有时甚至会快到让一个主机托管公司耗尽资源。更为严重的是,这些应用的增长是完全无法预测的;它们可能只有极少量的用户(也可能突然有了爆炸性的用户数量增长)。我们在数据中心和云中都遇到过这样的应用。如果是一个小公司,云可以帮你避免前期快速注入大量的资金来获得更快更大规模的风险。\n云的另一种潜在的大用途是运行不是很重要的基础设施,例如集成环境、开发测试平台,以及评估环境。假设部署周期是两个星期。你会每天每个小时都测试部署一次,还是只在项目最后的冲刺时测试?许多用户只是偶尔需要筹划和部署测试环境。在这种场景下,云可以帮助节约不少钱。\n以下是我们使用云的两种方式。第一个是作为我们对技术职员面试的一部分,我们会询问如何解决一些实际的问题。我们使用AMI(Amazon Machine Images)来模拟一些被“破坏”的机器,然后让求职者登录并在服务器上执行一系列任务。我们不必开放他们到内部网络的授权,这种方案显然要方便得多。另一个是作为新项目的工作平台和开发服务器。有一个这样的项目已经在一台云端开发服务器上运行了数个月,而花费不足一美元!这在我们自己的基础设施上是不可能做到的。单是发送一封邮件给系统管理员申请开发服务器的时间价值就不止一美元。\n但是另一方面,云托管对于长期项目而言可能会更加昂贵。如果打算长远地使用云,就需要花时间来计算一下(它是否划算)。除了猜想未来的创新能给云计算和商用硬件带来什么,还需要做基准测试以及一个完整的总体持有成本(TCO)账单。为了理清事情的本质并考虑全面所有相关的细节,你需要把所有的事情最终归结为一个数字:每美元的业务交易数。事情变化得太快,所以我们将这个留给读者思考。\n13.3 云中的MySQL的可扩展性和高可用性 # 正如我们之前提到的,MySQL并不会在云端自动变得更具扩展性。事实上,如果机器的性能较差,会导致过早使用横向扩展策略。况且云托管服务器相比专用的硬件可靠性和可预测性要更差些,所以想在云端获得高可用性需要更多的创新。\n但是总的来说,在云端中扩展MySQL和在其他地方扩展没有太多的差别。最大的不同就是按需提供服务器的能力。但是也有某些限制会导致扩展和高可用实现起来有点麻烦,至少在有些云环境中是这样的。例如,在AWS云平台中,无法使用类似虚拟IP地址的功能来完成快速原子故障转移。像这种对资源的有限控制意味着你需要使用其他办法,例如代理。(ScaleBase也值得去看看。)\n云另外一个迷惑人的地方是梦想中的自动扩展——就是根据需求的增加或减少来启动或关闭实例。尽管对于诸如Web服务器这样的无状态部分是可行的,但对于数据库服务器而言则很难做到,因为它是有状态的。对于一些特定的场景,例如以读为主的应用,可以通过增加备库的方式来获得有限的自动扩展(4),但这并不是一个通用的解决方案。实际上,虽然许多应用在Web层使用了自动扩展,但MySQL并不具备在一个无共享(Shared Nothing)集群中的对等角色服务器之间迁移的能力。你可以通过分片架构来自动重新分片并自动增长或收缩(5),但MySQL本身是无法自动扩展的。\n事实上,因为数据库通常是一个应用系统中主要或唯一的有状态并且持久化的组件,所以把应用服务迁移到云端是很普遍的事情,因为除数据库之外的所有部分都可以从云中收益——Web服务器、工作队列服务器、缓存等——而MySQL只需要处理剩下的东西。毕竟,数据库并非世界的中心。如果应用系统其他部分获得的好处,超过了让MySQL运行得足够好而投入的额外开销和必需的工作量,那这不是一个是否会发生的问题,而是怎么发生的问题。要回答这个问题,最好先了解你在云中可能碰到的额外的挑战。这些通常围绕着数据库服务器的可用资源。\n13.4 四种基础资源 # MySQL需要四种基础资源来完成工作:CPU周期、内存、I/O,以及网络。这四种资源的特性和重要程度在不同的云平台上各不相同。可以通过了解它们的不同之处和对MySQL的影响,以决定是否选择在云中托管MySQL。\nCPU通常很少且慢。在写作本书时最大的标准EC2实例提供8个虚拟CPU核心。EC2提供的虚拟CPU比高端CPU的速度明显要慢很多(可以查看本章稍后的基准测试结果)。虽然可能略有不同,但很可能在大多数云托管平台中这都是一种普遍现象。EC2提供使用多个CPU资源的实例,但它们的最大可用内存却更低。在写作本书时商用服务器能提供几十个CPU核心——甚至更多,如果按硬件线程算的话。(6) 内存大小受限制。最大的EC2实例当前能提供68.4GB的内存。与此相比,商用服务器能提供512GB~1TB的内存。 I/O的吞吐量、延迟以及一致性受到限制。在AWS云中有两个存储选项。\n第一个选择是使用EBS卷,这有点类似云中的SAN。AWS的最佳实践是在用EBS组建的RAID10卷上建立服务器。但是EBS是一个共享资源,就像EC2服务器和EBS服务器之间的网络连接。延迟可能会很高并且不可预测,即使是在适量的吞吐量需求下也是如此。我们已经测得EBS设备的I/O延迟可以达到十几分之一秒。相比之下,直接插在本机的商用硬盘驱动器只需几个毫秒,而闪存设备比硬盘驱动器的速度又要高出几个数量级。但另一方面,EBS卷也有许多很好的特性,例如和其他AWS服务、快照等结合起来使用。\n第二个选择是实例的本地存储。每个EC2服务器有一定数量的本地存储,实际安装在底层服务器上。它能够比EBS提供更多的一致性性能(7),但如果实例停止了就无法做到持久化。正是由于这样的特性导致其不适合大多数的数据库服务器场景。 尽管网络通常是一个变化多端的共享资源,但是性能通常比较好。虽然使用商用硬件可以获得更快更持续的网络性能,但CPU、RAM和I/O更容易成为主要的性能瓶颈,在AWS云中我们还没有遇到过网络性能问题。 正如你所看到的,四种基础资源中有三种在AWS云中是受限的,在某些场景下尤其明显。总的来说,这些基础资源并没有商业硬件那样的性能。下一节我们会讨论这些确切的结论。\n13.5 MySQL在云主机上的性能 # 通常,由于较差的CPU、内存以及I/O性能,在类似AWS这样的云托管平台上MySQL所表现出来的性能并不如在其他地方好。这些情况在不同的云平台之间略有不同,但这依然是普遍的事实(8)。然而对于你的需求而言,云主机可能仍然是一个性能足够高的平台,在某些需求上云平台可能比另外的解决方案要好。\n如果使用更糟糕的硬件来运行MySQL,无法让MySQL性能比托管在云平台上更高,这并不奇怪。真正让人感到困惑的是在相似规格的物理硬件条件下却无法获得同样的运行速度。例如,如果有一台服务器拥有8个CPU核心,16GB内存以及一个中等的RAID阵列,你可能认为能够获得和一个拥有8个EC2计算单元、15GB内存以及少量EBS卷的EC2实例相同的性能,但这是无法保证的。EC2实例的性能可能比你的物理硬件更加多变,特别是它不是一个超大实例时,可以推测它跟其他实例共享了同样的硬件资源。\n稳定性确实非常重要。MySQL和InnoDB尤其不喜欢不稳定的性能——特别是不稳定的I/O性能。I/O操作会请求服务器内部的互斥锁,当持续时间太长时,就会显著地导致很多“阻塞”进程堆积起来,出现令人难以理解的长时间运行的查询语句,以及例如Threads_running或Threads_connected这样的状态变量产生毛刺。\n实际应用中前后不一致或者无法预测的性能导致的结果就是排队变得越来越严重。排队是响应时间和到达间隔时间多变自然会导致的结果,并且有个完整的数学分支专门致力于排队的研究。所有的计算机都是队列系统的网络,当需要请求的资源(CPU、I/O,网络,等等)繁忙时,请求必须等待。当资源性能更加多变时,请求更容易堆叠,会出现更多的排队现象。因此,在大多数云计算平台上很难获得高并发或者稳定的低响应时间。我们有很多次在EC2平台上遭受到这个限制的经验。以我们的经验来看,即便在最大的实例上运行的MySQL,在典型的Web OLTP工作负载上,你能够期待的最高并发度也就是Threads_running值为8~12。根据经验,当超过这个值时,性能会越来越不可接受。\n注意我们所说的“典型的Web OLTP工作负载”,并非所有的工作负载都以相同的方式反映云平台的限制。确实有一些工作负载在云中表现得很好,而有一些则受到严重影响,让我们看看到底有哪些。\n正如我们刚讨论的,需要高并发的工作负载并不是非常适合云计算。对于那些要求非常快的响应时间的应用同样如此。原因可以归结于虚拟CPU的数目和速度方面的限制。每个MySQL查询运行在一个单独的CPU上,所以查询响应时间实际上是由CPU的原始速度决定的。如果期望得到更快的响应时间,就需要更快的CPU。为了支持更高的并发度,你需要更多的CPU。MySQL和InnoDB不会因为运行在大量CPU核心上而提供爆炸式的改进,但目前通常能在至少24个核心上获得比较好的横向扩展,这通常比在云中能够获得的核心数更多。 那些需要大量I/O的工作负载在云中并不总是表现很好。当I/O很慢并且不稳定时,工作会很快中断。但另一方面,如果你的工作负载不需要太多的I/O,不管是吞吐量(每秒的执行量)还是带宽(每秒字节数),MySQL就可以运行得很好。 之前的几点是根据云端的CPU和I/O资源的缺点得出的。那么关于这些你可以做点什么呢?对于CPU限制你做不了太多,不够就是不够。但是I/O则不同。I/O实际上是两种存储器的交换:非永久存储器(RAM)和持久化存储器(磁盘、EBS,或者其他你所拥有的)。因此MySQL的I/O需求会受系统内存大小的影响。当有足够的内存时,可以从缓存中读取数据,从而减少读和写操作的I/O。写入同样可以缓存在内存里,多个对相同内存比特位的写入可以合并成单个I/O操作。\n内存的限制就出现了。当拥有足够的内存来存放工作数据集时(9),某些工作负载的I/O需求可以明显减少。更大的EC2实例也会提供更好的网络性能,更有利于EBS卷的I/O。但如果工作集太大,无法装入可用的最大实例,则I/O需求会逐渐上升,并开始阻塞甚至停止服务,正如我们之前讨论的那样。EC2中内存最大的实例能够很好地为许多工作负载提供足够的内存。但是你需要意识到,预热时间可能会很长;关于这一话题本节后面会有更多的讨论。\n哪种类型的工作负载无法通过增加更多的内存来解决呢?除了缓存外,一些写入很大的工作负载需要的I/O比你能从多数云计算平台上获得的要多。例如,如果每秒执行事务数很多,那么每秒就需要执行更多的I/O操作以保证持久性。你只能从诸如EBS这样的系统中获得这么多的吞吐量。同样地,如果你正在将大量数据写入到数据库中,可能会超过可用的带宽。\n你可能认为通过RAID来为EBS卷进行条带(striping)和镜像可以改善I/O性能。在某种程度上确实有帮助。问题是,当增加更多的EBS卷时,在我们需要某个EBS卷的任意时间点都增加了它性能变差的可能性,而根据InnoDB内部I/O工作的方式,最差的一环通常是整个系统的瓶颈。实际上,我们已经尝试过10和20个EBS卷的RAID 10集合, 20卷的RAID比10卷的遭遇了更多的停顿(stall)问题。当我们测量底层块设备的I/O性能时,很明显只有一或两个EBS卷表现得很慢,但是却已经影响了整个系统。\n你也可以改变应用和服务器来减少I/O需求,考虑周到的逻辑和物理数据库设计(Schema和索引)对于减少I/O请求大有帮助,应用程序优化和查询优化也一样。这是减少I/O最有效的手段。例如插入量很大的工作负载,明智地使用分区,将I/O集中到索引能完全加载到内存中的单个分区上,就会有所帮助。你也可以通过设置innodb_flush_logs_at_trx_commit=2 和来降低持久性,或者将InnoDB事务日志和二进制日志从EBS卷中转移到一个本地驱动器上(尽管这有风险)。但是你从服务器上压榨一点额外的性能越困难,就越不可避免地要引入更大的复杂性(以及它们的成本)。\n此外还可以升级MySQL服务器软件。新版本的MySQL和InnoDB(最新的使用InnoDB Plugin的MySQL 5.1,或者MySQL 5.5及更新的版本)能够提供更好的I/O性能以及更少的内部瓶颈,并且相比5.1及之前的版本遭受的停顿和堆积会少很多。Percona Server在某些工作负载下能够提供更多的好处。例如,Percona Server的快速预热缓冲池特性在服务器重启后能够帮助备用服务器快速运行起来,特别是I/O性能不是很好并且服务器依赖于内存时。这也是我们讨论能在云中获得好的性能的候选场景,这里服务器比备用硬件更容易发生故障。Percona Server能够将预热时间从几个小时甚至几天减少到几分钟。在写作本书时,类似的预热特性在MySQL 5.6的开发里程碑版本里已经可用了。\n尽管最终一个增长的应用总会达到一个顶点,届时你不得不对数据库进行拆分以保证数据能够存放到云中。我们倾向于尽量不拆分,但如果你只有这么点马力,当达到某个点时,就不得不去其他地方(离开这个云),或者将其拆分为多份,使每份数据需要的资源不超过虚拟硬件能提供的。通常当工作集无法适应内存大小时就得要进行分片了,这意味着在最大的EC2实例上的工作集大小为50GB~60GB。与之相对,我们已经有很多在物理硬件上运行几个TB大小级别数据库的经验。在云中你需要更早进行分片。\n13.5.1 在云端的MySQL基准测试 # 我们进行了一些基准测试以说明MySQL在AWS云环境中的性能。当需要大量I/O时要在云中获得始终稳定并且可重现的基准测试结果几乎是不可能的,所以我们选择一个内存中的工作负载,本质上可以衡量除了I/O外的所有因素。我们使用Percona Server 5.5.16,缓冲池为4GB,在一千万行数据上运行标准SysBench只读基准测试。这样就可以根据不同的实例大小进行比较。我们忽略了高频率CPU实例,因为它们实际上比m2.4xlarge 实例的CPU性能要差。我们还引用了一台Cisco服务器作为参考。Cisco机器性能非常高但有点老化了,使用的是两个2.93GHz的Xeon X5670 Nehalem CPU。每个CPU有6个核心,每个核心上有两个硬件线程,在操作系统来看总共有24个CPU。图13-1显示了测试的结果。\n图13-1:使用SysBench对AWS云中的MySQL进行只读基准测试\n根据工作负载和硬件来看,这样的结果并不奇怪。例如,最大的EC2实例最高有8个线程,因为它有8个CPU核心。(读/写工作负载会花费一些CPU之外的时间来做I/O,所以我们能获得超过8个线程的有效并发度)。图13-1可能会让你认为Cisco的优势就是CPU能力,这也是我们原本认为的。所以我们使用SysBench的质数基准测试来测试原始CPU性能。结果如图13-2所示。\n图13-2:使用SysBench对AWS服务器进行CPU质数基准测试\nCisco服务器每个CPU的性能比EC2服务器要低,奇怪么?我们也感到非常奇怪。质数基准测试本质上是原始CPU指令,因此不应该有非常明显的虚拟化开销或者太多的内存交换。对于这样的结果我们的解释是这样的:Cisco服务器的CPU已经使用了很多年了,并且比EC2服务器的要慢。但是对于一些更加复杂的任务,例如运行数据库服务器, EC2服务器会受到虚拟化开销的影响。区分慢CPU、慢内存访问以及虚拟化开销并不总是很容易,但在这个实例中这种区别看起来很明显。\n13.6 MySQL数据库即服务(DBaaS) # 在云端服务器上安装MySQL并不是在云中使用MySQL的唯一方法。已经有越来越多的公司开始将数据库本身作为云资源,称之为数据库即服务(DBaaS,有时候也叫DaaS),这意味着你可以在一个地方使用云中的数据库,而在另外的地方运行真正的服务。虽然我们在本章花很多时间解释了IaaS,但IaaS市场正在快速商品化,我们期望未来重点会转到DBaaS。在写作本书时已经有以下几个DBaaS服务提供商。\n13.6.1 Amazon RDS # 我们发现在Amazon的关系数据库(RDS)上进行的开发比其他任何一个DBaaS提供商都要多很多。Amazon RDS不仅仅是一个兼容MySQL的服务;它事实上就是MySQL,所以能够完全兼容你所拥有的MySQL 服务器(10)并能作为替代品提供服务。我们不是很确定,但如大多数人一样,我们相信RDS是托管在使用EBS卷的EC2机器上——Amazon并没有公布底层的技术,但当你足够了解RDS时,这看起来很明显就是MySQL、EC2以及EBS。\n系统管理职责完全由Amazon来承担。你没有访问EC2机器的权限;只有登入MySQL的访问凭证。你可以创建数据库、插入数据等。你并没有被控制住,如果有需要,可以将数据导出来转移到其他地方,也可以创建卷快照并挂载到其他机器上。\n为了防止你检查或干涉Amazon对服务器或主机实例的管理,RDS做了一些限制。例如一些权限限制。你不能利用SELECT INTO OUTFILE、FILE()、LOAD DATA INFILE或其他方法来通过MySQL访问服务器的文件系统。你不能做任何和复制相关的事情,也不能为自己赋予更高的权限。Amazon通过诸如在系统表上设置触发器等方法来进行阻止。并且作为服务条款的一部分,你要同意不会试图绕过这些限制。\n安装的MySQL版本做了轻微的修改以阻止用户干涉服务器,其他部分看起来和原版MySQL一样。我们对RDS、EBS和EC2做了基准测试,并没有从该平台上发现超出我们预期的变化。也就是说,看起来Amazon并没有对服务器做任何性能增强。\nRDS可以提供一些比较吸引人的好处,这取决于你的具体情况。\n你可以将系统管理甚至许多数据库管理的工作留给Amazon。例如,他们会为你进行复制并保证你不会把事情搞砸。 RDS相比其他选择而言可能更便宜,这取决于你的成本结构和人力资源。 RDS中的限制也许是件好事:Amazon拿走了那把子弹上膛的枪,防止你用它自残。 但是,它也有一些潜在的缺点。\n由于无法控制服务器,也就无法弄清操作系统中到底发生了什么。例如,你无法衡量I/O响应时间和CPU利用率。Amazon通过另一个服务CloudWatch提供了这一功能。它给出了足够的指标用于排查许多性能问题,但有时候你需要原始数据以知道到底发生了什么。(也无法使用类似FILE()这样的函数来访问 /proc/diskstats。) 无法获得完整的慢查询日志文件。你可以指定MySQL将慢查询记录到一个CSV日志表中,但这并不是很好。它会消耗很多服务器资源,并且不会给出精确的查询响应时间。这使得很难去分析和排除SQL故障。 如果你希望得到最新最好的,或者一些性能上的增强,例如那些你可以从Percona Server上获得的提升,那就不走运了,RDS并不提供这些。 你必须依赖Amazon的支持团队来解决一些问题,而这些问题可能本来是你自己可以解决的。例如,假设查询挂起了,或者服务器由于数据损坏崩溃了。你既可以等待Amazon来解决,也可以自己解决。如果是后者你就需要把数据转移到别的地方。你无法通过访问实例本身来解决。如果想这么做,你不得不额外花一些时间并支付额外的资源。这不只是理论上的推测;我们已经接到过许多技术支持请求,这些请求通常需要系统权限以进行故障排查,因此对于RDS用户而言是无法真正解决的。 正如我们所说,在性能方面,RDS跟一个大型大内存的使用EBS存储和原始MySQL的EC2实例相似。如果直接使用EC2和EBS并安装一个高性能版本的MySQL(例如Percona Server),你可以从AWS云中压榨出一点更高的性能,但这不会是一个数量级上的区别。考虑到这一点,有理由根据你的商业需求而非性能需求来决定是否使用RDS。如果确实非常要求高性能,那你根本就不应该使用AWS云。\n13.6.2 其他DBaaS解决方案 # Amazon RDS并不是MySQL用户唯一可选的DBaaS解决方案。还有诸如 FathomDB ( http://fathomdb.com)以及Xeround( http://xeround.com)等服务。但我们并没有足够的第一手经验来介绍它们,因为我们还没有在这些服务上做任何的生产部署。从关于FathomDB的一些有限的公开信息来看,它和Amazon RDS有点类似,虽然它也和AWS云一样可以在Rackspace云上获得。在写作本书时它还处于内部测试阶段。\nXeround 则有很大的不同之处:它是一个分布式服务器集群,前端是一个包含特定存储引擎的MySQL。它似乎和原始版本MySQL有少量的不兼容或不同之处。但它只是最近才发布正式GA版本(GA,generally available),所以现在下定论为时尚早。存储引擎似乎是用于和后台集群系统通信,这看起来有点和NDB CLuster类似。它增加了自动重分布功能,可以在工作负载增加或减少时自动地增加和去除节点(动态扩展)。\n还有许多其他的DBaaS服务,新的服务也在不断地推出。我们这里所写的任何内容都可能在你阅读时已经过时了,所以我们将其留给你自己来研究。\n13.7 总结 # 在云端使用MySQL至少有两种主流的方法:在云服务器上安装MySQL,或者使用DBaaS服务。MySQL能够在云主机上运行得很好,但云环境中的限制常常会导致更早需要进行数据拆分。并且尽管云服务器看起来和你的物理硬件很相似,但可能性能和服务质量要更低。\n有时候似乎有人会说“云就是答案,有什么问题吗?”这是一个极端,但那些认为云是一个银弹的狂热信众,也有类似的问题。数据库所需要的四种基础资源中的三种(CPU、内存和磁盘)在云中明显更差并且/或者效率更低,会直接影响到MySQL的性能。\n但是对于很多工作负载而言,MySQL能够在云中运行得很好。通常来说,如果能将工作集加载到内存中,并且产生的写入负载不超过云能支撑的I/O量,那么就可以获得很好的效果。通过严谨的设计和架构,选择正确的MySQL版本并做合适的配置,可以使你的数据库工作负载和容量能适应云的长处。但是MySQL并不是天生的云数据库;也就是说,它无法完全使用云计算理论上能提供的优点,例如自动扩展。但是一些可替代的技术(例如Xeround)正在尝试解决这些缺点。\n我们已经讨论了很多跟云相关的缺点,这也许会给你一个我们反对云计算的印象。并非如此。这只是因为我们只集中在MySQL上,而不是讨论云计算所有的优点,这可能跟你从其他地方阅读到的非常不一样。我们在试着指出在云端运行MySQL有哪些不同,以及哪些是你需要知道的。\n我们看到在云中最大的成功是由于商业原因做出的决策。即使长期来看每个商业交易的开销在云中会更高,但其他方面的因素,诸如增加了弹性、减少了前期成本、减少了推向市场的时间,以及降低了风险,这可能更重要。并且你的应用中其他和MySQL无关的部分所获得的好处要远远大于(在云端)使用MySQL带来的弊端。\n————————————————————\n(1) OK,我们承认。Amazon网络服务是一个云。本章主要讨论AWS。\n(2) 参阅 George Reese所写的Cloud Application Architectures(O\u0026rsquo;Reilly)。\n(3) 我们不是说这会更加容易或便宜,我们只是说云并不是能获得这些好处的唯一途径。\n(4) Scalr ( http://scalr.net)是一个流行的开源服务,用于在云中进行MySQL复制自动扩展。\n(5) 计算机科学家喜欢将之称为“重大挑战”(non-trivial challenge)。\n(6) 在CPU、RAM以及I/O上,商用硬件能够提供超过MySQL可以有效利用的硬件能力,所以将云与云之外可获得的最强硬件相比较并不是完全公平的。\n(7) 直到写入的时候本地存储才会被分配给实例,导致每个写入的块发生“第一次写处罚”(first-write penalty)。避免这个问题的办法是使用dd去写满设备。\n(8) 如果你相信 http://www.xkcd.com/908/,那么显然所有的云都有同样的缺点,我们刚刚已经提过。\n(9) 参阅第9章关于工作集的定义及其如何影响I/O需求的讨论。\n(10) 除非你使用别的存储引擎或者其他一些非标准的MySQL修改版本。\n"},{"id":151,"href":"/zh/docs/technology/MySQL/_%E9%AB%98%E6%80%A7%E8%83%BDMySQL_/%E7%AC%AC12%E7%AB%A0%E9%AB%98%E5%8F%AF%E7%94%A8%E6%80%A7/","title":"第12章高可用性","section":"高性能 My SQL","content":"第12章 高可用性\n本章将讲述我们提到的复制、可扩展性以及高可用性三个主题中的第三个。归根结底,高可用性实际上意味着“更少的宕机时间”。然而糟糕的是,高可用性经常和其他相关的概念混淆,例如冗余、保障数据不丢失,以及负载均衡。我们希望之前的两章已经为清楚地理解高可用性做了足够的铺垫。跟其他两章一样,这一章也不仅仅是关注高可用性的内容,一些相关的话题也会综合阐述。\n12.1 什么是高可用性 # 高可用性实际上有点像神秘的野兽。它通常以百分比表示,这本身也是一种暗示:高可用性不是绝对的,只有相对更高的可用性。100%的可用性是不可能达到的。可用性的“9”规则是表示可用性目标最普遍的方法。你可能也知道,“5个9”表示99.999%的正常可用时间。换句话说,每年只允许5分钟的宕机时间。对于大多数应用这已经是令人惊叹的数字,尽管还有一些人试图获得更多的“9”。\n每个应用对可用性的需求各不相同。在设定一个可用时间的目标之前,先问问自己,是不是确实需要达到这个目标。可用性每提高一点,所花费的成本都会远超之前;可用性的效果和开销的比例并不是线性的。需要保证多少可用时间,取决于能够承担多少成本。高可用性实际上是在宕机造成的损失与降低宕机时间所花费的成本之间取一个平衡。换句话说,如果需要花大量金钱去获得更好的可用时间,但所带来的收益却很低,可能就不值得去做。总的来说,应用在超过一定的点以后追求更高的可用性是非常困难的,成本也会很高,因此我们建议设定一个更现实的目标并且避免过度设计。幸运的是,建立2个9或3个9的可用时间的目标可能并不困难,具体情况取决于应用。\n有时候人们将可用性定义成服务正在运行的时间段。我们认为可用性的定义还应该包括应用是否能以足够好的性能处理请求。有许多方法可以让一个服务器保持运行,但服务并不是真正可用。对一个很大的服务器而言,重启MySQL之后,可能需要几个小时才能充分预热以保证查询请求的响应时间是可以接受的,即使服务器只接收了正常流量的一小部分也是如此。\n另一个需要考虑的问题是,即使应用并没有停止服务,但是否可能丢失了数据。如果服务器遭遇灾难性故障,可能多少都会丢失一些数据,例如最近已经写入(最新丢失的)二进制日志但尚未传递到备库的中继日志中的事务。你能够容忍吗?大多数应用能够容忍;因为替代方案大多非常昂贵且复杂,或者有一些性能开销。例如,可以使用同步复制,或是将二进制日志放到一个通过DRBD进行复制的设备上,这样就算服务器完全失效也不用担心丢失数据。(但是整个数据中心也有可能会掉电。)\n一个良好的应用架构通常可以降低可用性方面的需求,至少对部分系统而言是这样的,良好的架构也更容易做到高可用。将应用中重要和不重要的部分进行分离可以节约不少工作量和金钱,因为对于一个更小的系统改进可用性会更容易。可以通过计算“风险敞口(risk exposure)”,将失效概率与失效代价相乘来确认高优先级的风险。画一个简单的风险计算表,以概率、代价和风险敞口作为列,这样很容易找到需要优先处理的项目。\n在前一章我们通过讨论如何避免导致糟糕的可扩展性的原因,来推出如何获得更好的可扩展性。这里也会使用相似的方法来讨论可用性,因为我们相信,理解可用性最好的方法就是研究它的反面——宕机时间。接下来的小节我们会讨论为什么会出现宕机。\n12.2 导致宕机的原因 # 我们经常听到导致数据库宕机最主要的原因是编写的SQL查询性能很差,真的是这样吗?2009年我们决定分析我们客户的数据库所遇到的问题,以找出那些真正引起宕机的问题,以及如何避免这些问题(1)。结果证实了一些我们已有的猜想,但也否定了一些(错误的)认识,我们从中学到了很多。\n我们首先对宕机事件按表现方式而非导致的原因进行分类。一般来说,“运行环境”是排名第一的宕机类别,大约35%的事件属于这一类。运行环境可以看作是支持数据库服务器运行的系统和资源集合,包括操作系统、硬盘以及网络等。性能问题紧随其后,也是约占35%;然后是复制,占20%;最后剩下的10%包含各种类型的数据丢失或损坏,以及其他问题。\n我们对事件按类型进行分类后,确定了导致这些事件的原因。以下是一些需要注意的地方:\n在运行环境的问题中,最普遍的问题是磁盘空间耗尽。 在性能问题中,最普遍的宕机原因确实是运行很糟糕的SQL,但也不一定都是这个原因,比如也有很多问题是由于服务器Bug或错误的行为导致的。 糟糕的Schema和索引设计是第二大影响性能的问题。 复制问题通常由于主备数据不一致导致。 数据丢失问题通常由于DROP TABLE的误操作导致,并总是伴随着缺少可用备份的问题。 复制虽然常被人们用来改善可用时间,但却也可能导致宕机。这主要是由于不正确的使用导致的,即便如此,它也阐明了一个普遍的情况:许多高可用性策略可能会产生反作用,我们会在后面讨论这个话题。\n现在我们已经知道了主要宕机类别,以及有什么需要注意,下面我们将专门介绍如何获得高可用性。\n12.3 如何实现高可用性 # 可以通过同时进行以下两步来获得高可用性。首先,可以尝试避免导致宕机的原因来减少宕机时间。许多问题其实很容易避免,例如通过适当的配置、监控,以及规范或安全保障措施来避免人为错误。第二,尽量保证在发生宕机时能够快速恢复。最常见的策略是在系统中制造冗余,并且具备故障转移能力。这两个维度的高可用性可以通过两个相关的度量来确定:平均失效时间(MTBF)和平均恢复时间(MTTR)。一些组织会非常仔细地追踪这些度量值。\n第二步——通过冗余快速恢复——很不幸,这里是最应该注意的地方,但预防措施的投资回报率会很高。接下来我们来探讨一些预防措施。\n12.3.1 提升平均失效时间(MTBF) # 其实只要尽职尽责地做好一些应做的事情,就可以避免很多宕机。在分类整理宕机事件并追查导致宕机的根源时,我们还发现,很多宕机本来是有一些方法可以避免的。我们发现大部分宕机事件都可以通过全面的常识性系统管理办法来避免。以下是从我们的白皮书中摘录的指导性建议,在白皮书中有我们详细的分析结果。\n测试恢复工具和流程,包括从备份中恢复数据。 遵从最小权限原则。 保持系统干净、整洁。 使用好的命名和组织约定来避免产生混乱,例如服务器是用于开发还是用于生产环境。 谨慎安排升级数据库服务器。 在升级前,使用诸如Percona Toolkit中的pt-upgrade之类的工具仔细检查系统。 使用InnoDB并进行适当的配置,确保InnoDB是默认存储引擎。如果存储引擎被禁止,服务器就无法启动。 确认基本的服务器配置是正确的。 通过skip_name_resolve禁止DNS。 除非能证明有效,否则禁用查询缓存。 避免使用复杂的特性,例如复制过滤和触发器,除非确实需要。 监控重要的组件和功能,特别是像磁盘空间和RAID卷状态这样的关键项目,但也要避免误报,只有当确实发生问题时才发送告警。 尽量记录服务器的状态和性能指数,如果可能就尽量久地保存。 定期检查复制完整性。 将备库设置为只读,不要让复制自动启动。 定期进行查询语句审查。 归档并清理不需要的数据。 为文件系统保留一些空间。在GNU/Linux中,可以使用-m选项来为文件系统本身保留空间。还可以在LVM卷组中留下一些空闲空间。或者,更简单的方法,仅仅创建一个巨大的空文件,在文件系统快满时,直接将其删除。(2) 养成习惯,评估和管理系统的改变、状态以及性能信息。 我们发现对系统变更管理的缺失是所有导致宕机的事件中最普遍的原因。典型的错误包括粗心的升级导致升级失败并遭遇一些Bug,或是尚未测试就将Schema或查询语句的更改直接运行到线上,或者没有为一些失败的情况制定计划,例如达到了磁盘容量限制。另外一个导致问题的主要原因是缺少严格的评估,例如因为疏忽没有确认备份是否是可以恢复的。最后,可能没有正确地监控MySQL的相关信息。例如缓存命中率报警并不能说明出现问题,并且可能产生大量的误报,这会使监控系统被认为不太有用,于是一些人就会忽略报警。有时候监控系统失效了,甚至没人会注意到,直至你的老板质问你,“为什么Nagios没有告诉我们磁盘已经满了”。\n12.3.2 降低平均恢复时间(MTTR) # 之前提到,可以通过减少恢复时间来获得高可用性。事实上,一些人走得更远,只专注于减少恢复时间的某个方面:通过在系统中建立冗余来避免系统完全失效,并避免单点失效问题。\n在降低恢复时间上进行投资非常重要,一个能够提供冗余和故障转移能力的系统架构,则是降低恢复时间的关键环节。但实现高可用性不单单是一个技术问题,还有许多个人和组织的因素。组织和个人在避免宕机和从宕机事件中恢复的成熟度和能力层次各不相同。\n团队成员是最重要的高可用性资产,所以为恢复制定一个好的流程非常重要。拥有熟练技能、应变能力、训练有素的雇员,以及处理紧急事件的详细文档和经过仔细测试的流程,对从宕机中恢复有巨大的作用。但也不能完全依赖工具和系统,因为它们并不能理解实际情况的细微差别,有时候它们的行为在一般情况下是正确的,但在某些场景下却会是个灾难!\n对宕机事件进行评估有助于提升组织学习能力,可以帮助避免未来发生相似的错误,但是不要对“事后反思”或“事后的调查分析”期待太高。后见之明被严重曲解,并且一味想找到导致问题的唯一根源,这可能会影响你的判断力(3)。许多流行的方法,例如“五个为什么”,可能会被过度使用,导致一些人将他们的精力集中在找到唯一的替罪羊。很难去回顾我们解决的问题当时所处的状况,也很难理解真正的原因,因为原因通常是多方面的。因此,尽管事后反思可能是有用的,但也应该对结论有所保留。即使是我们给出的建议,也是基于长期研究导致宕机事件的原因以及如何预防它们所得,并且只是我们的观点而已。\n这里我们要反复提醒:所有的宕机事件都是由多方面的失效联合在一起导致的。因此,可以通过利用合适的方法确保单点的安全来避免。整个链条必须要打断,而不仅仅是单个环节。例如,那些向我们求助恢复数据的人不仅遭受数据丢失(存储失效,DBA误操作等),同时还缺少一个可用的备份。\n这样说来,当开始调查并尝试阻止失效或加速恢复时,大多数人和组织不应太过于内疚,而是要专注于技术上的一些措施——特别是那些很酷的方法,例如集群系统和冗余架构。这些是有用的,但要记住这些系统依然会失效。事实上,在本书第二版中提到的MMM复制管理,我们已经失去了兴趣,因为它被证明可能导致更多的宕机时间。你应该不会奇怪一组Perl脚本会陷于混乱,但即使是特别昂贵并精密设计的系统也会出现灾难性的失效——是的,即使是花费了大量金钱的SAN也是如此。我们已经见过太多的SAN失效。\n12.4 避免单点失效 # 找到并消除系统中的可能失效的单点,并结合切换到备用组件的机制,这是一种通过减少恢复时间(MTTR)来改善可用性的方法。如果你够聪明,有时候甚至能将实际的恢复时间降低至0,但总的来说这很困难。(即使一些非常引人注目的技术,例如昂贵的负载均衡器,在发现问题并进行反馈时也会导致一定的延迟。)\n思考并梳理整个应用,尝试去定位任何可能失效的单点。是一个硬盘驱动器,一台服务器,一台交换或路由器,还是某个机架的电源?所有数据都在一个数据中心,或者冗余数据中心是由同一个公司提供的吗?系统中任何不冗余的部分都是一个可能失效的单点。其他比较普遍的单点失效依赖于一些服务,例如DNS、单一网络提供商(4)、单个云“可用区域”,以及单个电力输送网,具体有哪些取决于你的关注点。\n单点失效并不总是能够消除。增加冗余或许也无法做到,因为有些限制无法避开,例如地理位置,预算,或者时间限制等。试着去理解每一个影响可用性的部分,采取一种平衡的观点来看待风险,并首先解决其中影响最大的那个。一些人试图编写一个软件来处理所有的硬件失效,但软件本身导致的宕机时间可能比它节约的还要多。也有人想建立一种“永不沉没”的系统,包括各种冗余,但他们忘记了数据中心可能掉电或失去连接。或许他们彻底忘记了恶意攻击者和程序错误的可能性,这些情况可能会删除或损坏数据——一个不小心执行的DROP TABLE也会产生宕机时间。\n可以采用两种方法来为系统增加冗余:增加空余容量和重复组件。增加容量余量通常很简单——可以使用本章或前一章讨论的任何技术。一个提升可用性的方法是创建一个集群或服务器池,并使用负载均衡解决方案。如果一台服务器失效,其他服务器可以接管它的负载。有些人有意识地不使用组件的全部能力,这样可以保留一些“动态余量”来处理因为负载增加或组件失效导致的性能问题。\n出于很多方面的考虑会需要冗余组件,并在主要组件失效时能有一个备件来随时替换。冗余组件可以是空闲的网卡、路由器或者硬盘驱动器——任何能想到的可能失效的东西。完全冗余MySQL服务器可能有点困难,因为一个服务器在没有数据时毫无用处。这意味着你必须确保备用服务器能够获得主服务器上的数据。共享或复制存储是一个比较流行的办法,但这真的是一个高可用性架构吗?让我们深入其中看看。\n12.4.1 共享存储或磁盘复制 # 共享存储能够为数据库服务器和存储解耦合,通常使用的是SAN。使用共享存储时,服务器能够正常挂载文件系统并进行操作。如果服务器挂了,备用服务器可以挂载相同的文件系统,执行需要的恢复操作,并在失效服务器的数据上启动MySQL。这个过程在逻辑上跟修复那台故障的服务器没什么两样,不过更快速,因为备用服务器已经启动,随时可以运行。当开始故障转移时,检查文件系统、恢复InnoDB以及预热(5)是最有可能遇到延迟的地方,但检测失效本身在许多设置中也会花费很长时间。\n共享存储有两个优点:可以避免除存储外的其他任何组件失效所引起的数据丢失,并为非存储组件建立冗余提供可能。因此它有助于减少系统一些部分的可用性需求,这样就可以集中精力关注一小部分组件来获得高可用性。不过,共享存储本身仍是可能失效的单点。如果共享存储失效了,那整个系统也失效了,尽管SAN通常设计良好,但也可能失效,有时候需要特别关注。就算SAN本身拥有冗余也会失效。\n主动—主动访问模式的共享存储怎么样?\n在一个SAN、NAS或者集群文件系统上以主动—主动模式运行多个实例怎么样?MySQL不能这么做。因为MySQL并没有被设计成和其他MySQL实例同步对数据的访问,所以无法在同一份数据上开启多个MySQL实例。(如果在一份只读的静态数据上使用MyISAM,技术上是可行的,但我们还没有见过任何实际的应用。)(6)\nMySQL的一个名为ScaleDB的存储引擎在底层提供了操作共享存储的API,但我们还没有评估过,也没有见过任何生产环境使用。在写作本书时它还是beta版。\n共享存储本身也有风险,如果MySQL崩溃等故障导致数据文件损坏,可能会导致备用服务器无法恢复。我们强烈建议在使用共享存储策略时选择InnoDB存储引擎或其他稳定的ACID存储引擎。一次崩溃几乎肯定会损坏MyISAM表,需要花费很长时间来修复,并且会丢失数据。我们也强烈建议使用日志型文件系统。我们见过比较严重的情况是,使用非日志型文件系统和SAN(这是文件系统的问题,跟SAN无关)导致数据损坏无法恢复。\n磁盘复制技术是另外一个获得跟SAN类似效果的方法。MySQL中最普遍使用的磁盘复制技术是DRBD( http://www.drbd.org),并结合Linux-HA项目中的工具使用(后面会介绍到)。\nDRBD是一个以Linux内核模块方式实现的块级别同步复制技术。它通过网卡将主服务器的每个块复制到另外一个服务器的块设备上(备用设备),并在主设备提交块之前记录下来(7)。由于在备用DRBD设备上的写入必须要在主设备上的写入完成之前,因此备用设备的性能至少要和主设备一样,否则就会限制主设备的写入性能。同样,如果正在使用DRBD磁盘复制技术以保证在主设备失效时有一个可随时替换的备用设备,备用服务器的硬件应该跟主服务器的相匹配。带电池写缓存的RAID控制器对DRBD而言几乎是必需的,因为在没有这样的控制器时性能可能会很差。\n如果主服务器失效,可以把备用设备提升为主设备。因为DRBD是在磁盘块层进行复制,而文件系统也可能会不一致。这意味着最好是使用日志型文件系统来做快速恢复。一旦设备恢复完成,MySQL还需要运行自身的恢复。原故障服务器恢复后,会与新的主设备进行同步,并假定自身角色为备用设备。\n从如何实际地实现故障转移的角度来看,DRBD和SAN很相似:有一个热备机器,开始提供服务时会使用和故障机器相同的数据。最大的不同是,DRBD是复制存储——不是共享存储——所以当使用DRBD时,获得的是一份复制的数据,而SAN则是使用与故障机器同一物理设备上的相同数据副本。换句话说,磁盘复制技术的数据是冗余的,所以存储和数据本身都不会存在单点失效问题。这两种情况下,当启动备用机器时, MySQL服务器的缓存都是空的。相比之下,备库的缓存至少是部分预热的。\nDRBD有一些很好的特性和功能,可以防止集群软件普遍会遇到的一些问题。一个典型的例子是“脑裂综合征”,在两个节点同时提升自己为主服务器时会发生这种问题。可以通过配置DRBD来防止这种事件发生。但是DRBD也不是一个能满足所有需求的完美解决方案。我们来看看它有哪些缺点:\nDRBD的故障转移无法做到秒级以内。它通常至少需要几秒钟时间来将备用设备提升成主设备,这还不包括任何必要的文件系统恢复和MySQL恢复。 它很昂贵,因为必须在主动—被动模式下运行。热备服务器的复制设备因为处于被动模式,无法用于其他任务。当然这是不是缺点取决于看问题的角度。如果你希望获得真正的高可用性并且在发生故障时不能容忍服务降级,就不应该在一台机器上运行两台服务器的负载量,因为如果这么做了,当其中一台发生故障时,就无法处理这些负载了。可以用这些备用服务器做一些其他用途,例如用作备库,但还是会有一些资源浪费。 对于MyISAM表实际上用处不大,因为MyISAM表崩溃后需要花费很长时间来检查和修复。对任何期望获得高可用性的系统而言,MyISAM都不是一个好选择;请使用InnoDB或其他支持快速、安全恢复的存储引擎来代替MyISAM。 DRBD无法代替备份。如果磁盘由于蓄意的破坏、误操作、Bug或者其他硬件故障导致数据损坏,DRBD将无济于事。此时复制的数据只是被损坏数据的完美副本。你需要使用备份(或MySQL延时复制)来避免这些问题。 对写操作而言增加了负担。具体会增加多少负担呢?通常可以使用百分比来表示,但这并不是一个好的度量方法。你需要理解写入时增加的延迟主要由网络往返开销和远程服务器存储导致,特别是对于小的写入而言延迟会更大。尽管增加的延迟可能也就0.3ms,这看起来比在本地磁盘上I/O的4~10ms的延迟要小很多,但却是正常的带有写缓存的RAID控制器的延迟的3~4倍。使用DRBD导致服务器变慢最常见的原因是MySQL使用InnoDB并采取了完全持久化模式(8),这会导致许多小的写入和fsync()调用,通过DRBD同步时会非常慢。(9) 我们倾向于只使用DRBD复制存放二进制日志的设备。如果主动节点失效,可以在被动节点上开启一个日志服务器,然后对失效主库的所有备库应用这些二进制日志。接下来可以选择其中一个备库提升为主库,以代替失效的系统。\n说到底,共享存储和磁盘复制与其说是高可用性(低宕机时间)解决方案,不如说是一种保证数据安全的方法。只要拥有数据,就可以从故障中恢复,并且比无法恢复的情况的MTTR更低。(即使是很长的恢复时间也比不能恢复要快。)但是相比于备用服务器启动并一直运行的架构,大多数共享存储或磁盘复制架构会增加MTTR。有两种启用备用设备并运行的方法:我们在第10章讨论的标准的MySQL复制,以及接下来会讨论的同步复制。\n12.4.2 MySQL同步复制 # 当使用同步复制时,主库上的事务只有在至少一个备库上提交后才能认为其执行完成。这实现了两个目标:当服务器崩溃时没有提交的事务会丢失,并且至少有一个备库拥有实时的数据副本。大多数同步复制架构运行在主动-主动模式。这意味着每个服务器在任何时候都是故障转移的候选者,这使得通过冗余获得高可用性更加容易。\n在写作本书时,MySQL本身并不支持同步复制(10),但有两个基于MySQL的集群解决方案支持同步复制。你还可以阅读第10章、第11章和第13章讨论的其他产品,例如 Continuent Tungsten 以及Clustrix,这些都相当有意思。\n1.MySQL Cluster # MySQL中的同步复制首先出现在MySQL Cluster(NDB Cluster)。它在所有节点上进行同步的主-主复制。这意味着可以在任何节点上写入;这些节点拥有等同的读写能力。每一行都是冗余存储的,这样即使丢失了一个节点,也不会丢失数据,并且集群仍然能提供服务。尽管MySQL Cluster还不是适用于所有应用的完美解决方案,但正如我们在前一章提到的,在最近的版本中它做了非常快速的改进,现在已经拥有大量的新特性和功能:非索引数据的磁盘存储、增加数据节点能够在线扩展、使用ndbinfo表来管理集群、配置和管理集群的脚本、多线程操作、下推(push-down)的关联操作(现在称为自适应查询本地化)、能够处理BLOB列和很多列的表、集中式的用户管理,以及通过像memcached协议一样的NDB API来实现NoSQL访问。在下一个版本中将包含最终一致运行模式,包括为跨数据中心的主动-主动复制提供事务冲突检测和跨WAN解决方案。简而言之,MySQL Cluster是一项引人注目的技术。\n现在至少有两个为简化集群部署和管理提供附加产品的供应商:Oracle针对MySQL Cluster的服务支持包含了MySQL Cluster Manager工具;Severalnines提供了Cluster Control工具( http://www.severalnines.com),该工具还能够帮助部署和管理复制集群。\n2.Percona XtraDB Cluster # Percona XtraDB Cluster是一个相对比较新的技术,基于已有的XtraDB(InnoDB)存储引擎增加了同步复制和集群特性,而不是通过一个新的存储引擎或外部服务器来实现。它是基于Galera(支持在集群中跨节点复制写操作)实现的(11),这是一个在集群中不同节点复制写操作的库。跟MySQL Cluster类似,Percona XtraDB Cluster提供同步多主库复制(12),支持真正的任意节点写入能力,能够在节点失效时保证数据零丢失(持久性, ACID中的D),另外还提供高可用性,在整个集群没有失效的情况下,就算单个节点失效也没有关系。\nGalera作为底层技术,使用一种被称为写入集合(write-set)复制的技术。写入集合实际上被作为基于行的二进制日志事件进行编码,目的是在集群中的节点间传输并进行更新,但是这不要求二进制日志是打开的。\nPercona XtraDB Cluster的速度很快。跨节点复制实际上比没有集群还要快,因为在完全持久性模式下,写入远程RAM比写入本地磁盘要快。如果你愿意,可以选择通过降低每个节点的持久性来获得更好的性能,并且可以依赖于多个节点上的数据副本来获得持久性。NDB也是基于同样的原理实现的。集群在整体上的持久性并没有降低;仅仅是降低了本地节点的持久性。除此之外,还支持行级别的并发(多线程)复制,这样就可以利用多个CPU核心来执行写入集合。这些特性结合起来使得Percona XtraDB Cluster非常适合云计算环境,因为云计算环境中的CPU和磁盘通常比较慢。\n在集群中通过设置auto_increment_offset和auto_increment_increment来实现自增键,以使节点间不会生成冲突的主键值。锁机制和标准InnoDB完全相同,使用的是乐观并发控制。当事务提交时,所有的更新是序列化的,并在节点间传输,同时还有一个检测过程,以保证一旦发生更新冲突,其中一些更新操作需要丢弃。这样如果许多节点同时修改同样的数据,可能产生大量的死锁和回滚。\nPercona XtraDB Cluster只要集群内在线的节点数不少于“法定人数(quorum)”就能保证服务的高可用性。如果发现某个节点不属于“法定人数”中的一员,就会从集群中将其踢出。被踢出的节点在再次加入集群前必须重新同步。因此集群也无法处理“脑裂综合征”;如果出现脑裂则集群会停止服务。在一个只有两个节点的集群中,如果其中一个节点失效,剩下的一个节点达不到“法定人数”,集群将停止服务,所以实际上最少需要三个节点才能实现高可用的集群。\nPercona XtraDB Cluster有许多优点:\n提供了基于InnoDB的透明集群,所以无须转换到另外的技术,例如NDB这样完全不同的技术需要很多学习成本和管理。 提供了真正的高可用性,所有节点等效,并在任何时候提供读写服务。相比较而言,MySQL内建的异步复制和半同步复制必须要有一个主库,并且不能保证数据被复制到备库,也无法保证备库数据是最新的并能够随时提升为主库。 节点失效时保证数据不丢失。实际上,由于所有的节点都拥有全部数据,因此可以丢失任意一个节点而不会丢失数据(即使集群出现脑裂并停止工作)。这和NDB不同, NDB通过节点组进行分区,当在一个节点组中的所有服务器失效时就可能丢失数据。 备库不会延迟,因为在事务提交前,写入集合已经在集群的所有节点上传播并被确认了。 因为是使用基于行的日志事件在备库上进行更新,所以执行写入集合比直接执行更新的开销要小很多,就和使用基于行的复制差不多。当结合多线程应用的写入集合时,可以使其比MySQL本身的复制更具备可扩展性。 当然我们也需要提及Percona XtraDB Cluster的一些缺点:\n它很新,因此还没有足够的经验来证明其优点和缺点,也缺乏合适的使用案例。 整个集群的写入速度由最差的节点决定。因此所有的节点最好拥有相同的硬件配置,如果一个节点慢下来(例如,RAID卡做了一次battery-learn 循环),所有的节点都会慢下来。如果一个节点接收写入操作变慢的可能性为P,那么有3个节点的集群变慢的可能性为3P。 没有NDB那样节省空间,因为每个节点都需要保存全部数据,而不是仅仅一部分。但另一方面,它基于Percona XtraDB(InnoDB的增强版本),也就没有NDB关于磁盘数据限制的担忧。 当前不支持一些在异步复制中可以做的操作,例如在备库上离线修改schema,然后将其提升为主库,然后在其他节点上重复离线修改操作。当前可替代的选择是使用诸如Percona Toolkit中的在线schema修改工具。不过滚动式schema升级(rolling schema upgrade)在写作本书时也即将发布。 当向集群中增加一个新节点时,需要复制所有的数据,还需要跟上不断进行的写入操作,所以一个拥有大量写入的大型集群很难进行扩容。这实际上限制了集群的数据大小。我们无法确定具体的数据。但悲观地估计可能低至100GB或更小,也可能会大得多。这一点需要时间和经验来证明。 复制协议在写入时对网络波动比较敏感,这可能导致节点停止并从集群中踢出。所以我们推荐使用高性能网络,另外还需要很好的冗余。如果没有可靠的网络,可能会导致需要频繁地将节点加入到集群中。这需要重新同步数据。在写本书时,有一个几乎接近可用的特性,即通过增量状态传输来避免完全复制数据集,因此未来这并不是一个问题。还可以配置Galera以容忍更大的网络延迟(以延迟故障检测为代价),另外更加可靠的算法也计划在未来的版本中实现。 如果没有仔细关注,集群可能会增长得太大,以至于无法重启失效节点,就像在一个合理的时间范围内,如果在日常工作中没有定期做恢复演练,备份也会变得太过庞大而无法用于恢复。我们需要更多的实践经验来了解它事实上是如何工作的。 由于在事务提交时需要进行跨节点通信,写入会更慢,随着集群中增加的节点越来越多,死锁和回滚也会更加频繁。(参阅前一章了解为什么会发生这种情况。) Percona XtraDB Cluster 和Galera 都处于其生命周期的早期,正在被快速地修改和改进。在写作本书时,正在进行或即将进行的改进包括群体行为、安全性、同步性、内存管理、状态转移等。未来还可以为离线节点执行诸如滚动式schema变更的操作。\n12.4.3 基于复制的冗余 # 复制管理器是使用标准MySQL复制来创建冗余的工具(13)。尽管可以通过复制来改善可用性,但也有一些“玻璃天花板”会阻止MySQL当前版本的异步复制和半同步复制获得和真正的同步复制相同的结果。复制无法保证实时的故障转移和数据零丢失,也无法将所有节点等同对待。\n复制管理器通常监控和管理三件事:应用和MySQL间的通信、MySQL服务器的健康度,以及MySQL服务器间的复制关系。它们既可以修改负载均衡的配置,也可以在必要的时候转移虚拟IP地址以使应用连接到合适的服务器上,还能够在一个伪集群中操纵复制以选择一个服务器作为写入节点。大体上操作并不复杂:只需要确定写入不会发送到一个还没有准备好提供写服务的服务器上,并保证当需要提升一台备库为主库时记录下正确的复制坐标。\n这听起来在理论上是可行的,但我们的经验表明实际上并不总是能有效工作。事实上这非常糟糕,有些时候最好有一些轻量级的工具集来帮助从常见的故障中恢复并以很少的开销获得较高的可用性。不幸的是,在写作本书时我们还没有听说任何一个好的工具集可以可靠地完成这一点。稍后我们会介绍两个复制管理器(14),其中一个很新,而另外一个则有很多问题。\n我们发现很多人试图去写自己的复制管理器。他们常常会陷入很多人已经遭遇过的陷阱。自己去写一个复制管理器并不是好主意。异步组件有大量的故障形式,很多你从未亲身经历过,其中一些甚至无法理解,并且程序也无法适当处理,因此从这些异步组件中得到正确的行为相当困难,并且可能遭遇数据丢失的危险。事实上,机器刚开始出现问题时,由一个经验丰富的人来解决是很快的,但如果其他人做了一些错误的修复操作则可能导致问题更严重。\n我们要提到的第一个复制管理器是MMM( http://mysql-mmm.org),本书的作者对于该工具集是否适用于生产环境部署的意见并不一致(尽管该工具的原作者也承认它并不可靠)。我们中有些人认为它在一些人工—故障转移模式下的场景中比较有用,而有些人甚至从不使用这个工具。我们的许多客户在自动—故障转移模式下使用该工具时确实遇到了许多严重的问题。它会导致健康的服务器离线,也可能将写入发送到错误的地点,并将备库移动到错误的坐标。有时混乱就接踵而至。\n另外一个比较新一点的工具是Yoshinori Matsunobu的MHA工具集( http://code.google.com/p/mysql-master-ha/)。它和MMM一样是一组脚本,使用相同的通用技术来建立一个伪集群,但它不是一个完全的替换者;它不会去做太多的事情,并且依赖于Pacemaker来转移虚拟IP地址。一个主要的不同是,MHA有一个很好的测试集,可以防止一些MMM遇到过的问题。除此之外,我们对该工具集还没有更多的认识,我们只和Yoshinori 讨论过,但还没有真正使用过。\n基于复制的冗余最终来说好坏参半。只有在可用性的重要性远比一致性或数据零丢失保证更重要时才推荐使用。例如,一些人并不会真的从他们的网站功能中获利,而是从它的可用性中赚钱。谁会在乎是否出现了故障导致一张照片丢失了几条评论或其他什么东西呢?只要广告收益继续滚滚而来,可能并不值得花更多成本去实现真正的高可用性。但还是可以通过复制来建立“尽可能的”高可用性,当遇到一些很难处理的严重宕机时可能会有所帮助。这是一个大赌注,并且可能对大多数人而言太过于冒险,除非是那些老成(或者专业)的用户。\n问题是许多用户不知道如何去证明自己有资格并评估复制“轮盘赌”是否适合他们。这有两个方面的原因。第一,他们并没有看到“玻璃天花板”,错误地认为一组虚拟IP地址、复制以及管理脚本能够实现真正的高可用性。第二,他们低估了技术的复杂度,因此也低估了严重故障发生后从中恢复的难度。一些人认为他们能够使用基于复制的冗余技术,但随后他们可能会更希望选择一个有更强保障的简单系统。\n其他一些类型的复制,例如DRBD或者SAN,也有它们的缺点——请不要认为我们将这些技术说得无所不能而把MySQL自身的复制贬得一团糟,那不是我们的本意。你可以为DRBD写出低质量的故障转移脚本,这很简单,就像为MySQL复制编写脚本一样。主要的区别是MySQL复制非常复杂,有很多非常细小的差别,并且不会阻止你干坏事。\n12.5 故障转移和故障恢复 # 冗余是很好的技术,但实际上只有在遇到故障需要恢复时才会用到。(见鬼,这可以用备份来实现)。冗余一点儿也不会增加可用性或减少宕机。在故障转移的过程中,高可用性是建立在冗余的基础上。当有一个组件失效,但存在冗余时,可以停止使用发生故障的组件,而使用冗余备件。冗余和故障转移结合可以帮助更快地恢复,如你所知,MTTR的减少将降低宕机时间并改善可用性。\n在继续这个话题之前,我们先来定义一些术语。我们统一使用“故障转移(failover)”,有些人使用“回退”(fallback)表达同一意思。有时候也有人说“切换(switchover)”,以表明一次计划中的切换而不是故障后的应对措施。我们也会使用“故障恢复”来表示故障转移的反面。如果系统拥有故障恢复能力,故障转移就是一个双向过程:当服务器A失效,服务器B代替它,在修复服务器A后可以再替换回来。\n故障转移比仅仅从故障中恢复更好。也可以针对一些情况制订故障转移计划,例如升级、schema变更、应用修改,或者定期维护,当发生故障时可以根据计划进行故障转移来减少宕机时间(改善可用性)。\n你需要确定故障转移到底需要多快,也要知道在一次故障转移后替换一个失效组件应该多快。在你恢复系统耗尽的备件容量之前,会出现冗余不足,并面临额外风险。因此,拥有一个备件并不能消除即时替换失效组件的需求。构建一个新的备用服务器,安装操作系统,并复制数据的最新副本,可以多快呢?有足够的备用机器吗?你可能需要不止一台以上。\n故障转移的缘由各不相同。我们已经讨论了其中的一些,因为负载均衡和故障转移在很多方面很相似,它们之间的分界线比较模糊。总的来说,我们认为一个完全的故障转移解决方案至少能够监控并自动替换组件。它对应用应该是透明的。负载均衡不需要提供这些功能。\n在UNIX领域,故障转移常常使用High Availability Linux 项目( http://linux-ha.org)提供的工具来完成,该项目可在许多类UNIX系统上运行,而不仅仅是Linux。Linux-HA栈在最近几年明显多了很多新特性。现在大多数人认为Pacemaker是栈中的一个主要组件。Pacemaker替代了老的心跳工具。还有其他一些工具实现了IP托管和负载均衡功能。可以将它们跟DRBD和/或者LVS结合起来使用。\n故障转移最重要的部分就是故障恢复。如果服务器间不能自如切换,故障转移就是一个死胡同,只能是延缓宕机时间而已。这也是我们倾向于对称复制布局,例如双主配置,而不会选择使用三台或更多的联合主库(co-master)来进行环形复制的原因。如果配置是对等的,故障转移和故障恢复就是在相反方向上的相同操作。(值得一提的是DRBD具有内建的故障恢复功能。)\n在一些应用中,故障转移和故障恢复需要尽量快速并具备原子性。即便这不是决定性的,不依靠那些不受你控制的东西也依然是个好主意,例如DNS变更或者应用程序配置文件。一些问题直到系统变得更加庞大时才会显现出来,例如当应用程序强制重启以及原子性需求出现时。\n由于负载均衡和故障转移两者联系较紧密,有些硬件和软件是同时为这两个目的设计的,因此我们建议所选择的任何负载均衡技术应该都提供故障转移功能。这也是我们建议避免使用DNS和修改代码来做负载均衡的真实原因。如果为负载均衡采用了这些策略,就需要做一些额外的工作:当需要高可用性时,不得不重写受影响的代码。\n以下小节讨论了一些比较普遍的故障转移技术。可以手动执行或使用工具来实现。\n12.5.1 提升备库或切换角色 # 提升一台备库为主库,或者在一个主—主复制结构中调换主动和被动角色,这些都是许多MySQL故障转移策略很重要的一部分。具体细节参见第10章。正如本章之前提到的,我们不能认定自动化工具总能在所有的情况下做正确的事情——或者至少以我们的名誉担保没有这样的工具。\n你不应该假定在发生故障时能够立刻切换到被动备库,这要看具体的工作负载。备库会重放主库的写入,但如果不用来提供读操作,就无法进行预热来为生产环境负载提供服务。如果希望有一个随时能承担读负载的备库,就要不断地“训练”它,既可以将其用于分担工作负载,也可以将生产环境的读查询镜像到备库上。我们有时候通过监听TCP流量,截取出其中的SELECT查询,然后在备库上重放来实现这个目的。Percona Toolkit中有一些工具可以做到这一点。\n12.5.2 虚拟IP地址或IP接管 # 可以为需要提供特定服务的MySQL实例指定一个逻辑IP地址。当MySQL实例失效时,可以将IP地址转移到另一台MySQL服务器上。这和我们在前一章提到的思想本质上是相同的,唯一的不同是现在是用于故障转移,而不是负载均衡。\n这种方法的好处是对应用透明。它会中断已有的连接,但不要求修改配置。有时候还可以原子地转移IP地址,保证所有的应用在同一时间看到这一变更。当服务器在可用和不可用状态间“摇摆”时,这一点尤其重要。\n以下是它的一些不足之处:\n需要把所有的IP地址定义在同一网段,或者使用网络桥接。 改变IP地址需要系统root权限。 有时候还需要更新ARP缓存。有些网络设备可能会把ARP信息保存太久,以致无法即时将一个IP地址切换到另一个MAC地址上。我们看到过很多网络设备或其他组件不配合切换的例子,结果系统的许多部分可能无法确定IP地址到底在哪里。 需要确定网络硬件支持快速IP接管。有些硬件需要克隆MAC地址后才能工作。 有些服务器即使完全丧失功能也会保持持有IP地址,所以可能需要从物理上关闭或断开网络连接。这就是为人所熟知的“击中其他节点的头部”(shoot the other node in the head,简称STONITH)。它还有一个更加微妙并且比较官方的名字:击剑(fencing)。 浮动IP地址和IP接管能够很好地应付彼此临近(也就是在同一子网内)的机器之间的故障转移。但是最后需要提醒的是,这种策略并不总是万无一失,还取决于网络硬件等因素。\n等待更新扩散\n经常有这种情况,在某一层定义了一个冗余后,需要等待低层执行一些改变。在本章前面的篇幅里,我们指出通过DNS修改服务器是一个很脆弱的解决方案,因为DNS的更新扩散速度很慢,改变IP地址可给予你更多的控制,但在一个LAN中的IP地址同样依赖于更低层——ARP——来扩散更新。\n12.5.3 中间件解决方案 # 可以使用代理、端口转发、网络地址转换(NAT)或者硬件负载均衡来实现故障转移和故障恢复。这些都是很好的解决方案,不像其他方法可能会引入一些不确定性(所有系统组件认同哪一个是主库吗?它能够及时并原子地更改吗?),它们是控制应用和服务器间连接的中枢。但是,它们自身也引入了单点失效,需要准备冗余来避免这个问题。\n使用这样的解决方案,你可以将一个远程数据中心设置成看起来好像和应用在同一个网络里。这样就可以使用诸如浮动IP地址这样的技术让应用和一个完全不同的数据中心开始通信。你可以配置每个数据中心的每台应用服务器,通过它自己的中间件连接,将流量路由到活跃数据中心的机器上。图12-1描述了这种配置。\n图12-1:使用中间件来在各数据中心间路由MySQL连接\n如果活跃数据中心安装的MySQL彻底崩溃了,中间件可以路由流量到另外一个数据中心的服务器池中,应用无须知道这个变化。\n这种配置方法的主要缺点是在一个数据中心的Apache服务器和另外一个数据中心的MySQL服务器之间的延迟比较大。为了缓和这个问题,可以把Web服务器设置为重定向模式。这样通信都会被重定向到放置活跃MySQL服务器的数据中心。还可以使用HTTP代理来实现这一目标。\n图12-1显示了如何使用代理来连接MySQL服务器,也可以将这个方法和许多别的中间件架构结合在一起,例如LVS和硬件负载均衡器。\n12.5.4 在应用中处理故障转移 # 有时候让应用来处理故障转移会更简单或者更加灵活。例如,如果应用遇到一个错误,这个错误外部观察者正常情况下是无法察觉的,例如关于数据库损坏的错误日志信息,那么应用可以自己来处理故障转移过程。\n虽然把故障转移处理过程整合到应用中看起来比较吸引人,但可能没有想象中那么有效。大多数应用有许多组件,例如cron任务、配置文件,以及用不同语言编写的脚本。将故障转移整合到应用中可能导致应用变得太过笨拙,尤其是当应用增大并变得更加复杂时。\n但是将监控构建到应用中是一个好主意,当需要时,能够立刻开始故障转移过程。应用应该也能够管理用户体验,例如提供降级功能,并显示给用户合适的信息。\n12.6 总结 # 可以通过减少宕机来获得高可用性,这需要从以下两个方面来思考:增加两次故障之间的正常运行时间(MTBF),或者减少从故障中恢复的时间(MTTR)。\n要增加两次故障之间的正常运行时间,就要尝试去防止故障发生。悲剧的是,在预防故障发生时,它仍然会觉得你做的不够多,所以预防故障的努力经常会被忽视掉。我们已经着重提到了如何在MySQL系统中预防宕机;具体的细节可以参阅我们的白皮书,从http://www.percona.com上可以获得。试着从宕机中获得经验教训,但也要谨防在故障根源分析和事后检验时集中在某一点上而忽略其他因素。\n缩短恢复时间可能更复杂并且代价很高。从简单和容易的方面来说,可以通过监控来更快地发现问题,并记录大量的度量值以帮助诊断问题。作为回报,有时候可以在发生宕机前就发现问题。监控并有选择地报警以避免无用的信息,但也要及时记录状态和性能度量值。\n另外一个减少恢复时间的策略是为系统建立冗余,并使系统具备故障转移能力,这样当故障发生时,可以在冗余组件间进行切换。不幸的是,冗余会让系统变得相当复杂。现在应用不再是集中化的,而是分布式的,这意味着协调、同步、CAP定理、拜占庭将军问题,以及所有其他各种杂乱的东西。这也是像NDB Cluster这样的系统很难创建并且很难提供足够的通用性来为所有的工作负载提供服务的原因。但这种情况正在改善,也许到本书第四版的时候我们就可以称赞一个或多个集群数据库了。\n本章和前面两章提及的话题常常被放在一起讨论:复制、可扩展性,以及高可用性。我们已经尽量将它们独立开来,因为这有助于理清这些话题的不同之处。那么这三章有哪些关联之处呢?\n在其应用增长时,人们一般希望从他们的数据库中知道三件事:\n他们希望能够增加容量来处理新增的负载而不会损失性能。 他们希望保证不丢失已提交的事务。 他们希望应用能一直在线并处理事务,这样他们就能够一直赚钱。 为了达到这些目的,人们常常首先增加冗余。结合故障转移机制,通过最小化MTTR来提供高可用性。这些冗余还提供了空闲容量,可以为更多的负载提供服务。\n当然,除了必要的资源外,还必须要有一份数据副本。这有助于在损失服务器时避免丢失数据,从而增强持久性。生成数据副本的唯一办法是通过某种方法进行复制。不幸的是,数据副本可能会引入不一致。处理这个问题需要在节点间协调和通信。这给系统带来了额外的负担;这也是系统或多或少存在扩展性问题的原因。\n数据副本还需要更多的资源(例如更多的硬盘驱动器,更多的RAM),这会增加开销。有一个办法可以减少资源消耗和维护一致性的开销,就是为数据分区(分片)并将每个分片分发到特定的系统中。这可以减少需要复制的重复数据的次数,并从资源冗余中分离数据冗余。\n所以,尽管一件事总会导致另外一件事,但我们是在讨论一组相关的观点和实践来达成一系列目的。他们不仅仅是讲述同一件事的不同方式。\n最后,需要选择一个对你和应用有意义的策略。决定选择一个完全的端到端(end-to-end)高可用性策略并不能通过简单的经验法则来处理,但我们给出的一些粗略的指引也许会有所帮助。\n为了获得很短的宕机时间,需要冗余服务器能够及时地接管应用的工作负载。它们必须在线并一直执行查询,而不仅仅是备用,因此它们是“预热”过的,处于随时可用的状态。\n如果需要很强的可用性保证,就需要诸如MySQL Cluster、Percona XtraDB Cluster,或者Clustrix这样的集群产品。如果能容忍在故障转移过程中稍微慢一些,标准的MySQL复制也是个很好的选择。要谨慎使用自动化故障转移机制;如果没有按照正确的方式工作,它们可能会破坏数据。\n如果不是很在意故障转移花费的时间,但希望避免数据丢失,就需要一些强力保证数据的冗余——例如,同步复制。在存储层,这可以通过廉价的DRBD来实现,或者使用两个昂贵的SAN来进行同步复制。也可以选择在数据库层复制数据,可以使用的技术包括 MySQL Cluster、Percona XtraDB Cluster或者Clustrix。也可以使用一些中间件,例如Tungsten Replicator。如果不需要强有力的保护,并且希望尽量保证简单,那么正常的异步复制或半同步复制在开销合理时可能是很好的选择。\n或者也可以将应用放到云中。为什么不呢?这样难道不是能够立刻获得高可用性和无限扩展能力吗?下一章将继续探讨这个问题。\n————————————————————\n(1) 我们在一个冗长的白皮书中完整地描述了对客户的宕机事故的分析,并于随后在另一份白皮书中介绍了如何防止宕机,包括可以定期执行的详细检查清单。本书没有这么多篇幅来描述所有的细节,你可以从Percona的网站( http://www.percona.com)获得这两份白皮书。\n(2) 这是100%跨平台兼容的。\n(3) 这里推荐两篇反驳常识的文章:Richard Cook的论文“How Complex Systems Fail”(http://www. ctlab.org/documents/How%20Complex%20Systems%20Fail.pdf)和Malcolm Gladwell在他的What the Dog Saw(Little, Brown)一书中关于挑战者号航天飞机灾难事件的文章。\n(4) 感觉太偏执了?检查你的冗余网络连接是不是真的连接到不同的互联网主干,确保它们的物理位置不在同一条街道或者同一个电线杆上,这样它们才不会被同一个挖土机或者汽车破坏掉。\n(5) Percona Server提供了一个新特性,能够把buffer pool保存下来并在重启后还原,在使用共享存储时能够很好地工作。这可以减少几个小时甚至好几天的预热时间。MySQL 5.6也有相似的特性。\n(6) MySQL 5.6.8之后InnoDB也增加了一个只读模式,可以只读的方式用多个实例访问一份只读数据文件。——译者注\n(7) 事实上可以调整DRDB的同步级别,将其设置成异步等待远程设备接收数据,或者在远程设备将数据写入磁盘前一直阻塞住。同样,强烈建议为DRBD专门使用一块网卡。\n(8) 这里的意思应该是innodb_flush_log_at_trx_commit=1的情况。——译者注\n(9) 另一方面,大的序列写入又是另外一种情况,由DRBD导致的增加的延迟实际上消失了,但吞吐量的限制依然存在。一个合适的RAID阵列能够提供200~500MB/s的序列写入吞吐,大大超过千兆网络所能获得的吞吐量。\n(10) MySQL 5.5支持半同步复制,参见第10章。\n(11) Galera技术由Codership Oy( http://www.codership.com)开发,可以作为一个补丁在标准的MySQL和InnoDB中使用。Percona XtraDB Cluster除了其他特性和功能外,还包含这组补丁的修改版本. Percona XtraDB Cluster是一个可以直接使用的基于Galera的解决方案。\n(12) 你可以通过配置主备只写入其中一个节点来实现,但在集群配置中,对于这种模式的操作没有什么不同。\n(13) 在本小节我们会很小心,以避免产生混淆。冗余并不等同于高可用性。\n(14) 我们同样在开发基于Pacemaker和Linux-HA栈的解决方案,但并不准备在本书中提及。这个脚注稍后会自毁,10……9……8……\n"},{"id":152,"href":"/zh/docs/technology/MySQL/_%E9%AB%98%E6%80%A7%E8%83%BDMySQL_/%E7%AC%AC11%E7%AB%A0%E5%8F%AF%E6%89%A9%E5%B1%95%E7%9A%84MySQL/","title":"第11章可扩展的MySQL","section":"高性能 My SQL","content":"第11章 可扩展的MySQL\n本章将展示如何构建一个基于MySQL的应用,并且当规模变得越来越庞大时,还能保证快速、高效并且经济。\n有些应用仅仅适用于一台或少数几台服务器,那么哪些可扩展性建议是和这些应用相关的呢?大多数人从不会维护超大规模的系统,并且通常也无法效仿在主流大公司所使用的策略。本章会涵盖这一系列的策略。我们已经建立或者协助建立了许多应用,包括从单台或少量服务器的应用到使用上千台服务器的应用。选择一个合适的策略能够大大地节约时间和金钱。\nMySQL经常被批评很难进行扩展,有些情况下这种看法是正确的,但如果选择正确的架构并很好地实现,就能够非常好地扩展MySQL。但是扩展性并不是一个很好理解的主题,所以我们先来理清一些容易混淆的地方。\n11.1 什么是可扩展性 # 人们常常把诸如“可扩展性”、“高可用性”以及“性能”等术语在一些非正式的场合用作同义词,但事实上它们是完全不同的。在第3章已经解释过,我们将性能定义为响应时间。我们也可以很精确地定义可扩展性,稍后将完整讨论。简要地说,可扩展性表明了当需要增加资源以执行更多工作时系统能够获得划算的等同提升(equal bang for the buck)的能力。缺乏扩展能力的系统在达到收益递减的转折点后,将无法进一步增长。\n容量是一个和可扩展性相关的概念。系统容量表示在一定时间内能够完成的工作量(1),但容量必须是可以有效利用的。系统的最大吞吐量并不等同于容量。大多数基准测试能够衡量一个系统的最大吞吐量,但真实的系统一般不会使用到极限。如果达到最大吞吐量,则性能会下降,并且响应时间会变得不可接受地大且非常不稳定。我们将系统的真实容量定义为在保证可接受的性能的情况下能够达到的吞吐量。这就是为什么基准测试的结果通常不应该简化为一个单独的数字。\n容量和可扩展性并不依赖于性能。以高速公路上的汽车来类比的话:\n性能是汽车的时速。 容量是车道数乘以最大安全时速。 可扩展性就是在不减慢交通的情况下,能增加更多车和车道的程度。 在这个类比中,可扩展性依赖于多个条件:换道设计得是否合理、路上有多少车抛锚或者发生事故,汽车行驶速度是否不同或者是否频繁变换车道——但一般来说和汽车的引擎是否强大无关。这并不是说性能不重要,性能确实重要,只是需要指出,即使系统性能不是很高也可以具备可扩展性。\n从较高层次看,可扩展性就是能够通过增加资源来提升容量的能力。\n即使MySQL架构是可扩展的,但应用本身也可能无法扩展,如果很难增加容量,不管原因是什么,应用都是不可扩展的。之前我们从吞吐量方面来定义容量,但同样也需要从较高的层次来看待容量问题。从有利的角度来看,容量可以简单地认为是处理负载的能力,从不同的角度来考虑负载很有帮助。\n数据量\n应用所能累积的数据量是可扩展性最普遍的挑战,特别是对于现在的许多互联网应用而言,这些应用从不删除任何数据。例如社交网站,通常从不会删除老的消息或评论。\n用户量\n即使每个用户只有少量的数据,但在累计到一定数量的用户后,数据量也会开始不成比例地增长且速度快过用户数增长。更多的用户意味着要处理更多的事务,并且事务数可能和用户数不成比例。最后,大量用户(以及更多的数据)也意味着更多复杂的查询,特别是查询跟用户关系相关时(用户间的关联数可以用N×(N−1)来计算,这里N表示用户数)。\n用户活跃度\n不是所有的用户活跃度都相同,并且用户活跃度也不总是不变的。如果用户突然变得活跃,例如由于增加了一个吸引人的新特性,那么负载可能会明显提升。用户活跃度不仅仅指页面浏览数,即使同样的页面浏览数,如果网站的某个需要执行大量工作的部分变得流行,也可能导致更多的工作。另外,某些用户也会比其他用户更活跃:他们可能比一般人有更多的朋友、消息和照片。\n相关数据集的大小\n如果用户间存在关系,应用可能需要在整个相关联用户群体上执行查询和计算,这比处理一个一个的用户和用户数据要复杂得多。社交网站经常会遇到由那些人气很旺的用户组或朋友很多的用户所带来的挑战(2)。\n11.1.1 正式的可扩展性定义 # 有必要探讨一下可扩展性在数学上的定义了,这有助于在更高层次的概念上清晰地理解可扩展性。如果没有这样的基础,就可能无法理解或精确地表达可扩展性。不过不用担心,这里不会涉及高等数学,即使不是数学天才,也能够很直观地理解它。\n关键是之前我们使用的短语:“划算的等同提升(equal bang for the buck)”。另一种说法是,可扩展性是当增加资源以处理负载和增加容量时系统能够获得的投资产出率(ROI)。假设有一个只有一台服务器的系统,并且能够测量它的最大容量,如图11-1所示。\n图11-1:一个只有一台服务器的系统\n假设现在我们增加一台服务器,系统的能力加倍,如图11-2所示。\n图11-2:一个线性扩展的系统能由两台服务器获得两倍容量\n这就是线性扩展。我们增加了一倍的服务器,结果增加了一倍的容量。大部分系统并不是线性扩展的,而是如图11-3所示的扩展方式。\n图11-3:一个非线性扩展的系统\n大部分系统都只能以比线性扩展略低的扩展系数进行扩展。越高的扩展系数会导致越大的线性偏差。事实上,多数系统最终会达到一个最大吞吐量临界点,超过这个点后增加投入反而会带来负回报——继续增加更多工作负载,实际上会降低系统的吞吐量。(3)\n这怎么可能呢?这些年产生了许多可扩展性模型,它们有着不同程度的良好表现和实用性。我们这里所讲的可扩展性模型是基于某些能够影响系统扩展的内在机制。这就是Neil J. Gunther博士提出的通用可扩展性定律(Universal Scalability Law,USL)。Gunther博士将这些详尽地写到了他的书中,包括Guerrilla Capacity Planning (Springer)。这里我们不会深入到背后的数学理论中,如果你对此感兴趣,他撰写的书籍以及由他的公司Performance Dynamics提供的训练课程可能是比较好的资源。(4)\n简而言之,USL说的是线性扩展的偏差可通过两个因素来建立模型:无法并发执行的一部分工作,以及需要交互的另外一部分工作。为第一个因素建模就有了著名的Amdahl定律,它会导致吞吐量趋于平缓。如果部分任务无法并行,那么不管你如何分而治之,该任务至少需要串行部分的时间。\n增加第二个因素——内部节点间或者进程间的通信——到Amdahl定律就得出了USL。这种通信的代价取决于通信信道的数量,而信道的数量将按照系统内工作者数量的二次方增长。因此最终开销比带来的收益增长得更快,这是产生扩展性倒退的原因。图11-4阐明了目前讨论到的三个概念:线性扩展、Amdahl扩展,以及USL扩展。大多数真实系统看起来更像USL曲线。\n图11-4:线性扩展、AmdahI扩展以及USL扩展定律\nUSL可以应用于硬件和软件领域。对于硬件,横轴表示硬件的数量,例如服务器数量或CPU数量。每个硬件的工作量、数据大小以及查询的复杂度必须保持为常量(5)。对于软件,横轴表示并发度,例如用户数或线程数。每个并发的工作量必须保持为常量。\n有一点很重要,USL并不能完美地描述真实系统,它只是一个简化模型。但这是一个很好的框架,可用于理解为什么系统增长无法带来等同的收益。它也揭示了一个构建高可扩展性系统的重要原则:在系统内尽量避免串行化和交互。\n可以衡量一个系统并使用回归来确定串行和交互的量。你可以将它作为容量规划和性能预测评估的最优上限值。也可以检查系统是怎么偏离USL模型的,将其作为最差下限值以指出系统的哪一部分没有表现出它应有的性能。这两种情况下,USL给出了一个讨论可扩展性的参考。如果没有USL,那即使盯着系统看也无法知道期望的结果是什么。如果想深入了解这个主题,最好去看一下对应的书籍。Gunther博士已经写得很清楚,因此我们不会再深入讨论下去。\n另外一个理解可扩展性问题的框架是约束理论,它解释了如何通过减少依赖事件和统计变化(statistical variation)来改进系统的吞吐量和性能。这在Eliyahu M. Goldratt所撰写的The Goal(North River)一书中有描述,其中有一个关于管理制造业设备的延伸的比喻。尽管这看起来和数据库服务器没有什么关联,但其中包含的法则和排队理论以及其他运筹学方面是一样的。\n扩展模型不是最终定论\n虽然有许多理论,但在现实中能做到何种程度呢?正如牛顿定律被证明只有远低于光速时才合理,那些“扩展性定律”也只是在某些场景下才能很好工作的简化模型。有一种说法认为所有的模型都是错误的,但有一些模型还是有用的,特别是USL能够帮助理解一些导致扩展性差的因素。\n当工作负载和其所运行的系统存在微妙的关系时,USL理论可能失效。例如,一个USL无法很好建模的常见情况是:当集群的总内存由于数据集大小而发生改变时,也会导致系统的行为发生变化。USL不允许比线性更好的可扩展性,但现实中可能会发生这样的事情:增加系统的资源后,原来一部分I/O密集型的工作变成了纯内存工作,因此获得了超过线性的性能扩展。\n还有一些情况,USL无法很好描述系统行为。当系统或数据集大小改变时算法的复杂度可能改变,类似这样的情况下可能就无法建立模型(USL由O(1)复杂度和O(N2)复杂度两部分构成,那么对于诸如O(logN)或者O(NlogN)这样复杂度的部分呢?)。根据一些思考和实际经验,我们可以将USL扩展以覆盖这些比较普遍的场景中的一部分。但这会将一个简单并且有用的模型变得复杂并难以使用。事实上,它在很多情况下都是很好的,足以为你所能想象到的系统行为建立模型。这也是为什么我们发现它是在正确性和有效性之间的一个很好的妥协。\n简单地说:有保留地使用模型,并且在使用中验证你的发现。\n11.2 扩展MySQL # 如果将应用所有的数据简单地放到单个MySQL服务器实例上,则无法很好地扩展,迟早会碰到性能瓶颈。对于许多类型的应用,传统的解决方法是购买更多强悍的机器,也就是常说的“垂直扩展”或者“向上扩展”。另外一个与之相反的方法是将任务分配到多台计算机上,这通常被称为“水平扩展”或者“向外扩展”。我们将讨论如何联合使用向上扩展和向外扩展的解决方案,以及如何使用集群方案来进行扩展。最后,大部分应用还会有一些很少或者从不需要的数据,这些数据可以被清理或归档。我们将这个方案称为“向内扩展”,这么取名是为了和其他策略相匹配。\n11.2.1 规划可扩展性 # 人们通常只有在无法满足增加的负载时才会考虑到可扩展性,具体表现为工作负载从CPU密集型变成I/O密集型,并发查询的竞争,以及不断增大的延迟。主要原因是查询的复杂度增加或者内存中驻留着一部分不再使用的数据或者索引。你可能看到一部分类型的查询发生改变,例如大的查询或者复杂查询常常比那些小的查询更影响系统。\n如果是可扩展的应用,则可以简单地增加更多的服务器来分担负载,这样就没有性能问题了。但如果不是可扩展的,你会发现自己将遭遇到无穷无尽的问题。可以通过规划可扩展性来避免这个问题。\n规划可扩展性最困难的部分是估算需要承担的负载到底有多少。这个值不一定非常精确,但必须在一定的数量级范围内。如果估计过高,会浪费开发资源。但如果低估了,则难以应付可能的负载。\n另外还需要大致正确地估计日程表——也就是说,需要知道底线在哪里。对于一些应用,一个简单的原型可以很好地工作几个月,从而有时间去筹资建立一个更加可扩展的架构。对于其他的一些应用,你可能需要当前的架构能够为未来两年提供足够的容量。\n以下问题可以帮助规划可扩展性:\n应用的功能完成了多少?许多建议的可扩展性解决方案可能会导致实现某些功能变得更加困难。如果应用的某些核心功能还没有开始实现,就很难看出如何在一个可扩展的应用中实现它们。同样地,在知道这些特性如何真实地工作之前也很难决定使用哪一种可扩展性解决方案。 预期的最大负载是多少?应用应当在最大负载下也可以正常工作。如果你的网站和Yahoo! News或者Slashdot的首页一样,会发生什么呢?即使不是很热门的网站,也同样有最高负载。比如,对于一个在线零售商,假日期间——尤其是在圣诞前的几个星期——通常是负载达到巅峰的时候。在美国,情人节和母亲节前的周末对于在线花店来说也是负载高峰期。 如果依赖系统的每个部分来分担负载,在某个部分失效时会发生什么呢?例如,如果依赖备库来分担读负载,当其中一个失效时,是否还能正常处理请求?是否需要禁用一些功能?可以预先准备一些空闲容量来防范这种问题。 11.2.2 为扩展赢得时间 # 在理想情况下,应该是计划先行、拥有足够的开发者、有花不完的预算,等等。但现实中这些情况会很复杂,在扩展应用时常常需要做一些妥协,特别是需要把对系统大的改动推迟一段时间再执行。在深入MySQL扩展的细节前,以下是一些可以做的准备工作:\n优化性能\n很多时候可以通过一个简单的改动来获得明显的性能提升,例如为表建立正确的索引或从MyISAM切换到InnoDB存储引擎。如果遇到了性能限制,可以打开查询日志进行分析,详情请参阅第3章。\n在修复了大多数主要的问题后,会到达一个收益递减点,这时候提升性能会变得越来越困难。每个新的优化都可能耗费更多的精力但只有很小的提升,并会使应用更加复杂。\n购买性能更强的硬件\n升级或增加服务器在某些场景下行之有效,特别是对处于软件生命周期早期的应用,购买更多的服务器或者增加内存通常是个好办法。另一个选择是尽量在一台服务器上运行应用程序。比起修改应用的设计,购买更多的硬件可能是更实际的办法,特别是时间紧急并且缺乏开发者的时候。\n如果应用很小或者被设计为便于利用更多的硬件,那么购买更多的硬件应该是行之有效的办法。对于新应用这是很普遍的,因为它们通常很小或者设计合理。但对于大型的旧应用,购买更多硬件可能没什么效果,或者代价太高。服务器从1台增加到3台或许算不了什么,但从100台增加到300台就是另外一回事了——代价非常昂贵。如果是这样,花一些时间和精力来尽可能地提升现有系统的性能就很划算。\n11.2.3 向上扩展 # 向上扩展(有时也称为垂直扩展)意味着购买更多性能强悍的硬件,对很多应用来说这是唯一需要做的事情。这种策略有很多好处。例如,单台服务器比多台服务器更加容易维护和开发,能显著节约开销。在单台服务器上备份和恢复应用同样很简单,因为无须关心一致性或者哪个数据集是权威的。当然,还有一些别的原因。从复杂性的成本来说,向上扩展比向外扩展更简单。\n向上扩展的空间其实也很大。拥有0.5TB内存、32核(或者更多)CPU以及更强悍I/O性能的(例如PCIe卡的flash存储)商用服务器现在很容易获得。优秀的应用和数据库设计,以及很好的性能优化技能,可以帮助你在这样的服务器上建立一个MySQL大型应用。\n在现代硬件上MySQL能扩展到多大的规模呢?尽管可以在非常强大的服务器上运行,但和大多数数据库服务器一样,在增加硬件资源的情况下MySQL也无法很好地扩展(非常奇怪!)。为了更好地在大型服务器上运行MySQL,一定要尽量选择最新的版本。由于内部可扩展性问题,MySQL 5.0和5.1在大型硬件里的表现并不理想。建议使用MySQL 5.5或者更新的版本,或者Percona Server 5.1及后续版本。即便如此,当前合理的“收益递减点”的机器配置大约是256GB RAM,32核CPU以及一个PCIe flash驱动器。如果继续提升硬件的配置,MySQL的性能虽然还能有所提升,但性价比就会降低,实际上,在更强大的系统上,也可以通过运行多个小的MySQL实例来替代单个大实例,这样可以获得更好的性能。当然,机器配置的变化速度非常快,这个建议也许很快就会过时了。\n向上扩展的策略能够顶一段时间,实际很多应用是不会达到天花板的。但是如果应用变得非常庞大(6),向上扩展可能就没有办法了。第一个原因是钱,无论服务器上运行什么样的软件,从某种角度来看,向上扩展都是个糟糕的财务决策,当超出硬件能够提供的最优性价比时,就会需要非同寻常的特殊配置的硬件,这样的硬件往往非常昂贵。这意味着能向上扩展到什么地步是有实际的限制的。如果使用了复制,那么当主库升级到高端硬件后,一般是不太可能配置出一台能够跟上主库的强大备库的。一个高负载的主库通常可以承担比拥有同样配置的备库更多的工作,因为备库的复制线程无法高效地利用多核CPU和磁盘资源。\n最后,向上扩展不是无限制的,即使最强大的计算机也有限制。单服务器应用通常会首先达到读限制,特别是执行复杂的读查询时。类似这样的查询在MySQL内部是单线程的,因此只能使用一个CPU,这种情况下花钱也无法提升多少性能。即使购买最快的CPU也仅仅会是商用CPU的几倍速度。增加更多的CPU或CPU核数并不能使慢查询执行得更快。当数据变得庞大以至于无法有效缓存时,内存也会成为瓶颈,这通常表现为很高的磁盘使用率,而磁盘是现代计算机中最慢的部分。\n无法使用向上扩展最明显的场景是云计算。在大多数公有云中都无法获得性能非常强的服务器,如果应用肯定会变得非常庞大,就不能选择向上扩展的方式。在第13章我们会深入这个话题。\n因此,我们建议,如果系统确实有可能碰到可扩展性的天花板,并且会导致严重的业务问题,那就不要无限制地做向上扩展的规划。如果你知道应用会变得很庞大,在实现另外一种解决方案前,短期内购买更优的服务器是可以的。但是最终还是需要向外扩展,这也是下一节我们要讲述的主题。\n11.2.4 向外扩展 # 可以把向外扩展(有时也称为横向扩展或者水平扩展)策略划分为三个部分:复制、拆分,以及数据分片(sharding)。\n最简单也最常见的向外扩展的方法是通过复制将数据分发到多个服务器上,然后将备库用于读查询。这种技术对于以读为主的应用很有效。它也有一些缺点,例如重复缓存,但如果数据规模有限这就不是问题。关于这些问题我们在前一章已经讨论得足够多,后面会继续提到。\n另外一个比较常见的向外扩展方法是将工作负载分布到多个“节点”。具体如何分布工作负载是一个复杂的话题。许多大型的MySQL应用不能自动分布负载,就算有也没有做到完全的自动化。本节我们会讨论一些可能的分布负载的方案,并探讨它们的优点和缺点。\n在MySQL架构中,一个节点(node)就是一个功能部件。如果没有规划冗余和高可用性,那么一个节点可能就是一台服务器。如果设计的是能够故障转移的冗余系统,那么一个节点通常可能是下面的某一种:\n一个主—主复制双机结构,拥有一个主动服务器和被动服务器。 一个主库和多个备库。 一个主动服务器,并使用分布式复制块设备(DRBD)作为备用服务器。 一个基于存储区域网络(SAN)的“集群”。 大多数情况下,一个节点内的所有服务器应该拥有相同的数据。我们倾向于把主—主复制架构作为两台服务器的主动—被动节点。\n1.按功能拆分 # 按功能拆分,或者说按职责拆分,意味着不同的节点执行不同的任务。我们之前已经提到了一些类似的实现,在前一章我们描述了如何为OLTP和OLAP工作负载设计不同的服务器。按功能拆分采取的策略比这些更进一步,将独立的服务器或节点分配给不同的应用,这样每个节点只包含它的特定应用所需要的数据。\n这里我们显式地使用了“应用”一词。所指的并不是一个单独的计算机程序,而是相关的一系列程序,这些程序可以很容易地彼此分离,没有关联。例如,如果有一个网站,各个部分无须共享数据,那么可以按照网站的功能区域进行划分。门户网站常常把不同的栏目放在一起;在门户网站,可以浏览网站新闻、论坛,寻求支持和访问知识库,等等。这些不同功能区域的数据可以放到专用的MySQL服务器中,如图11-5所示。\n图11-5:一个门户网站以及专用于不同功能区域的节点\n如果应用很庞大,每个功能区域还可以拥有其专用的Web服务器,但没有专用的数据库服务器这么常见。\n另一个可能的按功能划分方法是对单个服务器的数据进行划分,并确保划分的表集合之间不会执行关联操作。当必须执行关联操作时,如果对性能要求不高,可以在应用中做关联。虽然有一些变通的方法,但它们有一个共同点,就是每种类型的数据只能在单个节点上找到。这并不是一种通用的分布数据方法,因为很难做到高效,并且相比其他方案没有任何优势。\n归根结底,还是不能通过功能划分来无限地进行扩展,因为如果一个功能区域被捆绑到单个MySQL节点,就只能进行垂直扩展。其中的一个应用或者功能区域最终增长到非常庞大时,都会迫使你去寻求一个不同的策略。如果进行了太多的功能划分,以后就很难采用更具扩展性的设计了。\n2.数据分片 # 在目前用于扩展大型MySQL应用的方案中,数据分片(7)是最通用且最成功的方法。它把数据分割成一小片,或者说一块,然后存储到不同的节点中。\n数据分片在和某些类型的按功能划分联合使用时非常有用。大多数分片系统也有一些“全局的”数据不会被分片(例如城市列表或者登录数据)。全局数据一般存储在单个节点上,并且通常保存在类似memcached这样的缓存里。\n事实上,大多数应用只会对需要的数据做分片——通常是那些将会增长得非常庞大的数据。假设正在构建的博客服务,预计会有1000万用户,这时候就无须对注册用户进行分片,因为完全可以将所有的用户(或者其中的活跃用户)放到内存中。假如用户数达到5亿,那么就可能需要对用户数据分片。用户产生的内容,例如发表的文章和评论,几乎肯定需要进行数据分片,因为这些数据非常庞大,并且还会越来越多。\n大型应用可能有多个逻辑数据集,并且处理方式也可以各不相同。可以将它们存储到不同的服务器组上,但这并不是必需的。还可以以多种方式对数据进行分片,这取决于如何使用它们。下文我们会举例说明。\n分片技术和大多数应用的最初设计有着显著的差异,并且很难将应用从单一数据存储转换为分片架构。如果在应用设计初期就已经预计到分片,那实现起来就容易得多。\n许多一开始没有建立分片架构的应用都会碰到规模扩大的情形。例如,可以使用复制来扩展博客服务的读查询,直到它不再奏效。然后可以把服务器划分为三个部分:用户信息、文章,以及评论。可以将这些数据放到不同的服务器上(按功能划分),也许可以使用面向服务的架构,并在应用层执行联合查询。图11-6显示了从单台服务器到按功能划分的演变。\n图11-6:从单个实例到按功能划分的数据存储\n最后,可以通过用户ID来对文章和评论进行分片,而将用户信息保留在单个节点上。如果为全局节点配置一个主—备结构并为分片节点使用主—主结构,最终的数据存储可能如图11-7所示。\n图11-7:一个全局节点和六个主—主结构节点的数据存储方式\n如果事先知道应用会扩大到很大的规模,并且清楚按功能划分的局限性,就可以跳过中间步骤,直接从单个节点升级为分片数据存储。事实上,这种前瞻性可以帮你避免由于粗糙的分片方案带来的挑战。\n采用分片的应用常会有一个数据库访问抽象层,用以降低应用和分片数据存储之间通信的复杂度,但无法完全隐藏分片。因为相比数据存储,应用通常更了解跟查询相关的一些信息。太多的抽象会导致低效率,例如查询所有的节点,可实际上需要的数据只在单一节点上。\n分片数据存储看起来像是优雅的解决方案,但很难实现。那为什么要选择这个架构呢?答案很简单:如果想扩展写容量,就必须切分数据。如果只有单台主库,那么不管有多少备库,写容量都是无法扩展的。对于上述缺点而言,数据分片是我们首选的解决方案。\n分片?还是不分片?\n这是一个问题,对吧?答案很简单:如非必要,尽量不分片。首先看是否能通过性能调优或者更好的应用或数据库设计来推迟分片。如果能足够长时间地推迟分片,也许可以直接购买更大的服务器,升级MySQL到性能更优的版本,然后继续使用单台服务器,也可以增加或减少复制。\n简单的说,对单台服务器而言,数据大小或写负载变得太大时,分片将是不可避免的。如果不分片,而是尽可能地优化应用,系统能扩展到什么程度呢?答案可能会让你很惊讶。有些非常受欢迎的应用,你可能以为从一开始就分片了,但实际上直到已经值数十亿美元并且流量极其巨大也还没有采用分片的设计。分片不是城里唯一的游戏,在没有必要的情况下采用分片的架构来构建应用会步履维艰。\n3.选择分区键(partitioning key) # 数据分片最大的挑战是查找和获取数据:如何查找数据取决于如何进行分片。有很多方法,其中有一些方法会比另外一些更好。\n我们的目标是对那些最重要并且频繁查询的数据减少分片(记住,可扩展性法则的其中一条就是要避免不同节点间的交互)。这其中最重要的是如何为数据选择一个或多个分区键。分区键决定了每一行分配到哪一个分片中。如果知道一个对象的分区键,就可以回答如下两个问题:\n应该在哪里存储数据? 应该从哪里取到希望得到的数据? 后面将展示多个选择和使用分区键的方法。先看一个例子。假设像MySQL NDB Cluster那样来操作,并对每个表的主键使用哈希来将数据分割到各个分片中。这是一种非常简单的实现,但可扩展性不好,因为可能需要频繁检查所有分片来获得需要的数据。例如,如果想查看user3的博客文章,可以从哪里找到呢?由于使用主键值而非用户名进行分割,博客文章可能均匀分散在所有的数据分片中。使用主键值哈希简化了判断数据存储在何处的操作,但却可能增加获取数据的难度,具体取决于需要什么数据以及是否知道主键。\n跨多个分片的查询比单个分片上的查询性能要差,但只要不涉及太多的分片,也不会太糟糕。最糟糕的情况是不知道需要的数据存储在哪里,这时候就需要扫描所有分片。\n一个好的分区键常常是数据库中一个非常重要的实体的主键。这些键值决定了分片单元。例如,如果通过用户ID或客户端ID来分割数据,分片单元就是用户或者客户端。\n确定分区键一个比较好的办法是用实体—关系图,或一个等效的能显示所有实体及其关系的工具来展示数据模型。尽量把相关联的实体靠得更近。这样可以很直观地找出候选分区键。当然不要仅仅看图,同样也要考虑应用的查询。即使两个实体在某些方面是相关联的,但如果很少或几乎不对其做关联操作,也可以打断这种联系来实现分片。\n某些数据模型比其他的更容易进行分片,具体取决于实体—关系图中的关联性程度。图11-8的左边展示了一个易于分片的数据模型,右边的那个则很难分片。\n图11-8:两个数据模型,一个易于分片,另一个则难以分片\n左边的数据模型比较容易分片,因为与之相连的子图中大多数节点只有一个连接,很容易切断子图之间的联系。右边的数据模型则很难分片,因为它没有类似的子图。幸好大多数数据模型更像左边的图。\n选择分区键的时候,尽可能选择那些能够避免跨分片查询的,但同时也要让分片足够小,以免过大的数据片导致问题。如果可能,应该期望分片尽可能同样小,这样在为不同数量的分片进行分组时能够很容易平衡。例如,如果应用只在美国使用,并且希望将数据集分割为20个分片,则可能不应该按照州来划分,因为加利福尼亚的人口非常多。但可以按照县或者电话区号来划分,因为尽管并不是均匀分布的,但足以选择20个集合以粗略地表示等同的密集程度,并且基本上避免跨分片查询。\n4.多个分区键 # 复杂的数据模型会使数据分片更加困难。许多应用拥有多个分区键,特别是存在两个或更多个“维度”的时候。换句话说,应用需要从不同的角度看到有效且连贯的数据视图。这意味着某些数据在系统内至少需要存储两份。\n例如,需要将博客应用的数据按照用户ID和文章ID进行分片,因为这两者都是应用查询数据时使用比较普遍的方式。试想一下这种情形:频繁地读取某个用户的所有文章,以及某个文章的所有评论。如果按用户分片就无法找到某篇文章的所有评论,而按文章分片则无法找到某个用户的所有文章。如果希望这两个查询都落到同一个分片上,就需要从两个维度进行分片。\n需要多个分区键并不意味着需要去设计两个完全冗余的数据存储。我们来看看另一个例子:一个社交网站下的读书俱乐部站点,该站点的所有用户都可以对书进行评论。该网站可以显示所有书籍的所有评论,也能显示某个用户已经读过或评论过的所有书籍。\n假设为用户数据和书籍数据都设计了分片数据存储。而评论同时拥有用户ID和评论ID,这样就跨越了两个分片的边界。实际上却无须冗余存储两份评论数据,替代方案是,将评论和用户数据一起存储,然后把每个评论的标题和ID与书籍数据存储在一起。这样在渲染大多数关于某本书的评论的视图时无须同时访问用户和书籍数据存储,如果需要显示完整的评论内容,可以从用户数据存储中获得。\n5.跨分片查询 # 大多数分片应用多少都有一些查询需要对多个分片的数据进行聚合或关联操作。例如,一个读书俱乐部网站要显示最受欢迎或最活跃的用户,就必须访问每一个分片。如何让这类查询很好地执行,是实现数据分片的架构中最困难的部分。虽然从应用的角度来看,这是一条查询,但实际上需要拆分成多条并行执行的查询,每个分片上执行一条。一个设计良好的数据库抽象层能够减轻这个问题,但类似的查询仍然会比分片内查询要慢并且更加昂贵,所以通常会更加依赖缓存。\n一些语言,如PHP,对并行执行多条查询的支持不够好。普遍的做法是使用C或Java编写一个辅助应用来执行查询并聚合结果集。PHP应用只需要查询该辅助应用即可,例如Web服务或者类似Gearman的工作者服务。\n跨分片查询也可以借助汇总表来执行。可以遍历所有分片来生成汇总表并将结果在每个分片上冗余存储。如果在每个分片上存储重复数据太过浪费,也可以把汇总表放到另外一个数据存储中,这样就只需要存储一份了。\n未分片的数据通常存储在全局节点中,可以使用缓存来分担负载。\n如果数据的均衡分布非常重要,或者没有很好的分区键,一些应用会采用随机分片的方式。分布式检索应用就是个很好的例子。这种场景下,跨分片查询和聚合查询非常常见。跨分片查询并不是数据分片面临的唯一难题。维护数据一致性同样困难。外键无法在分片间工作,因此需要由应用来检查参照一致性,或者只在分片内使用外键,因为分片内的内部一致性可能是最重要的。还可以使用XA事务,但由于开销太大,现实中使用很少。\n还可以设计一些定期执行的清理过程。例如,如果一个用户的读书俱乐部账号到期,并不需要立刻将其移除。可以写一个定期任务将用户评论从每个书籍分片中移除。也可以写一个检查脚本周期性运行以确保分片间的数据一致性。\n6.分配数据、分片和节点 # 分片和节点不一定是一对一的关系,应该尽可能地让分片的大小比节点容量小很多,这样就可以在单个节点上存储多个分片。\n保持分片足够小更容易管理。这将使数据的备份和恢复更加容易,如果表很小,那么像更改表结构这样的操作会更加容易。例如,假设有一个100GB的表,你可以直接存储,也可以将其划分为100个1GB的分片,并存储在单个节点上。现在假如要向表上增加一个索引,在单个100GB的表上的执行时间会比100个1GB分片上执行的总时间更长,因为1GB的分片更容易全部加载到内存中。并且在执行ALTER TABLE时还会导致数据不可用,阻塞1GB的数据比阻塞100GB的数据要好得多。\n小一点的分片也便于转移。这有助于重新分配容量,平衡各个节点的分片。转移分片的效率一般都不高。通常需要先将受影响的分片设置为只读模式(这也是需要在应用中构建的特性),提取数据,然后转移到另外一个节点。这包括使用mysqldump获取数据然后使用mysql命令将其重新导入。如果使用的是Percona Server,可以通过XtraBackup在服务器间转移文件,这比转储和重新载入要高效得多。\n除了在节点间移动分片,你可能还需要考虑在分片间移动数据,并尽量不中断整个应用提供服务。如果分片太大,就很难通过移动整个分片来平衡容量,这时候可能需要将一部分数据(例如一个用户)转移到其他分片。分片间转移数据比转移分片要更复杂,应该尽量避免这么做。这也是我们建议设置分片大小尽量易于管理的原因之一。\n分片的相对大小取决于应用的需求。简单的说,我们说的“易于管理的大小”是指保持表足够小,以便能在5或10分钟内提供日常的维护工作,例如ALTER TABLE、CHECK TABLE或者OPTIMIZE TABLE。\n如果将分片设置得太小,会产生太多的表,这可能引发文件系统或MySQL内部结构的问题。另外太小的分片还会导致跨分片查询增多。\n7.在节点上部署分片 # 需要确定如何在节点上部署数据分片。以下是一些常用的办法:\n每个分片使用单一数据库,并且数据库名要相同。典型的应用场景是需要每个分片都能镜像到原应用的结构。这在部署多个应用实例,并且每个实例对应一个分片时很有用。 将多个分片的表放到一个数据库中,在每个表名上包含分片号(例如bookclub.comments_23)。这种配置下,单个数据库可以支持多个数据分片。 为每个分片使用一个数据库,并在数据库中包含所有应用需要的表。在数据库名中包含分片号(例如表名可能是bookclub_23.comments或者bookclub_23.users等),但表名不包括分片号。当应用连接到单个数据库并且不在查询中指定数据库名时,这种做法很常见。其优点是无须为每个分片专门编写查询,也便于对只使用单个数据库的应用进行分片。 每个分片使用一个数据库,并在数据库名和表名中包含分片号(例如表名可以是bookclub_23.comments_23)。 在每个节点上运行多个MySQL实例,每个实例上有一个或多个分片,可以使用上面提到的方式的任意组合来安排分片。 如果在表名中包含了分片号,就需要在查询模板里插入分片号。常用的方法是在查询中使用特殊的“神奇的”占位符,例如sprintf()这样的格式化函数中的%s,或者使用变量做字符串插值。以下是在PHP中创建查询模板的方法:\n$sql = \u0026quot;SELECT book_id, book_title FROM bookclub_%d.comments_%d...|'; $res = mysql_query(sprintf($sql, $shardno, $shardno), $conn); 也可以就使用字符串插值的方法:\n$sql = \u0026quot;SELECT book_id, book_title FROM bookclub_$shardno.comments_$shardno...\u0026quot;; $res = mysql_query($sql, $conn); 这在新应用中很容易实现,但对于已有的应用则有点困难。构建新应用时,查询模板并不是问题,我们倾向于使用每个分片一个数据库的方式,并把分片号写到数据库名和表名中。这会增加例如ALTER TABLE这类操作的复杂度,但也有如下一些优点:\n如果分片全部在一个数据库中,转移分片会比较容易。 因为数据库本身是文件系统中的一个目录,所以可以很方便地管理一个分片的文件。 如果分片互不关联,则很容易查看分片的大小。 全局唯一表名可避免误操作。如果表名每个地方都相同,很容易因为连接到错误的节点而查询了错误的分片,或者是将一个分片的数据误导入另外一个分片的表中。 你可能想知道应用的数据是否具有某种“分片亲和性”。也许将某些分片放在一起(在同一台服务器,同一个子网,同一个数据中心,或者同一个交换网络中)可以利用数据访问模式的相关性,能够带来些好处。例如,可以按照用户进行分片,然后将同一个国家的用户放到同一个节点的分片上。\n为已有的应用增加分片支持的结果往往是一个节点对应一个分片。这种简化的设计可以减少对应用查询的修改。分片对应用而言通常是一种颠覆性的改变,所以应尽可能简化它。如果在分片后,每个节点看起来就像是整个应用数据的缩略图,就无须去改变大多数查询或担心查询是否传递到期望的节点。\n8.固定分配 # 将数据分配到分片中有两种主要的方法:固定分配和动态分配。两种方法都需要一个分区函数,使用行的分区键值作为输入,返回存储该行的分片。(8)\n固定分配使用的分区函数仅仅依赖于分区键的值。哈希函数和取模运算就是很好的例子。这些函数按照每个分区键的值将数据分散到一定数量的“桶”中。\n假设有100个桶,你希望弄清楚用户111该放到哪个桶里。如果使用的是对数字求模的方式,答案很简单:111对100取模的值为11,所以应该将其放到第11个分片中。\n而如果使用CRC32()函数来做哈希,答案是81。\n固定分配的主要优点是简单,开销低,甚至可以在应用中直接硬编码。\n但固定分配也有如下缺点:\n如果分片很大并且数量不多,就很难平衡不同分片间的负载。 固定分片的方式无法自定义数据放到哪个分片上,这一点对于那些在分片间负载不均衡的应用来说尤其重要。一些数据可能比其他的更加活跃,如果这些热点数据都分配到同一个分片中,固定分配的方式就无法通过热点数据转移的方式来平衡负载。(如果每个分片的数据量切分得比较小,这个问题就没那么严重,根据大数定律,这样做会更容易将热点数据平均分配到不同分片。) 修改分片策略通常比较困难,因为需要重新分配已有的数据。例如,如果通过模10的哈希函数来进行分片,就会有10个分片。如果应用增长使得分片变大,如果要拆分成20个分片,就需要对所有数据重新哈希,这会导致更新大量数据,并在分片间转移数据。 正是由于这些限制,我们倾向于为新应用选择动态分配的方式。但如果是为已有的应用做分片,使用固定分配策略可能会更容易些,因为它更简单。也就是说,大多数使用固定分配的应用最后迟早要使用动态分配策略。\n9.动态分配 # 另外一个选择是使用动态分配,将每个数据单元映射到一个分片。假设一个有两列的表,包括用户ID和分片ID。\nCREATE TABLE user_to_shard ( user_id INT NOT NULL, shard_id INT NOT NULL, PRIMARY KEY (user_id) ); 这个表本身就是分区函数。给定分区键(用户ID)的值就可以获得分片号。如果该行不存在,就从目标分片中找到并将其加入到表中。也可以推迟更新——这就是动态分配的含义。\n动态分配增加了分区函数的开销,因为需要额外调用一次外部资源,例如目录服务器(存储映射关系的数据存储节点)。出于效率方面的考虑,这种架构常常需要更多的分层。例如,可以使用一个分布式缓存系统将目录服务器的数据加载到内存中,因为这些数据平时改动很小。或者更普遍地,你可以直接向USERS表中增加一个shard_id列用于存储分片号。\n动态分配的最大好处是可以对数据存储位置做细粒度的控制。这使得均衡分配数据到分片更加容易,并可提供适应未知改变的灵活性。\n动态映射可以在简单的键—分片(key-to-shard)映射的基础上建立多层次的分片策略。例如,可以建立一个双重映射,将每个分片单元指定到一个分组中(例如,读书俱乐部的用户组),然后尽可能将这些组保持在同一个分片中。这样可以利用分片亲和性,避免跨分片查询。\n如果使用动态分配策略,可以生成不均衡的分片。如果服务器能力不相同,或者希望将其中一些分片用于特定目的(例如归档数据),这可能会有用。如果能够做到随时重新平衡分片,也可以为分片和节点间维持一一对应的映射关系,这不会浪费容量。也有些人喜欢简单的每个节点一个分片的方式。(但是请记住,保持分片尽可能小是有好处的。)动态分配以及灵活地利用分片亲和性有助于减轻规模扩大而带来的跨分片查询问题。假设一个跨分片查询涉及四个节点,当使用固定分配时,任何给定的查询可能需要访问所有分片,但动态分配策略则可能只需要在其中的三个节点上运行同样的查询。这看起来没什么大区别,但考虑一下当数据存储增加到400个分片时会发生什么?固定分配策略需要访问400个分片,而动态分配方式依然只需要访问3个。\n动态分配可以让分片策略根据需要变得很复杂。固定分配则没有这么多选择。\n10.混合动态分配和固定分配 # 可以混合使用固定分配和动态分配。这种方法通常很有用,有时候甚至必须要混合使用。目录映射不太大时,动态分配可以很好胜任。但如果分片单元太多,效果就会变差。\n以一个存储网站链接的系统为例。这样一个站点需要存储数百亿的行,所使用的分区键是源地址和目的地址URL的组合。(这两个URL的任意一个都可能有好几亿的链接,因此,单独一个URL并不适合做分区键)。但是在映射表中存储所有的源地址和目的地址URL组合并不合理,因为数据量太大了,每个URL都需要很多存储空间。\n一个解决方案是将URL相连并将其哈希到固定数目的桶中,然后把桶动态地映射到分片上。如果桶的数目足够大——例如100万个——你就能把大多数数据分配到每个分片上,获得动态分配的大部分好处,而无须使用庞大的映射表。\n11.显式分配 # 第三种分配策略是在应用插入新的数据行时,显式地选择目标分片。这种策略在已有的数据上很难做到。所以在为应用增加分片时很少使用。但在某些情况下还是有用的。\n这个方法是把数据分片号编码到ID中,这和之前提到的避免主—主复制主键冲突策略比较相似。(详情请参阅“在主—主复制结构中写入两台主库”。)\n例如,假设应用要创建一个用户3,将其分配到第11个分片中,并使用BIGINT列的高八位来保存分片号。这样最终的ID就是(11\u0026laquo;56)+3,即792633534417207299。应用可以很方便地从中抽取出用户ID和分片号,如下例所示。\n现在假设要为该用户创建一条评论,并存储在同一个分片中。应用可以为该用户分配一个评论ID 5,然后以同样的方式组合5和分片号11。\n这种方法的好处是每个对象的ID同时包含了分区键,而其他方法通常需要一次关联或查找来确定分区键。如果要从数据库中检索某个特定的评论,无须知道哪个用户拥有它;对象ID会告诉你到哪里去找。如果对象是通过用户ID动态分片的,就得先找到该评论的用户,然后通过目录服务器找到对应的数据分片。\n另一个解决方案是将分区键存储在一个单独的列里。例如,你可能从不会单独引用评论5,但是评论5属于用户3。这种方法可能会让一些人高兴,因为这不违背第一范式;然而额外的列会增加开销、编码,以及其他不便之处。(这也是我们将两值存在单独一列的优点之一。)\n显式分配的缺点是分片方式是固定的,很难做到分片间的负载均衡。但结合固定分配和动态分配,该方法就能够很好地工作。不再像之前那样哈希到固定数目的桶里并将其映射到节点,而是将桶作为对象的一部分进行编码。这样应用就能够控制数据的存储位置,因此可以将相关联的数据一起放到同样的分片中。\nBoardReader( http://boardreader.com)使用了该技术的一个变种:它把分区键编码到Sphinx的文档ID内。这使得在分片数据存储中查找每个查询结果的关联数据变得容易,更多关于Sphinx的内容可以查阅附录F。\n我们讨论了混合分配方式,因为在某些场景下它是有用的。但正常情况下我们并不推荐这样用。我们倾向于尽可能使用动态分配,避免显式分配。\n12.重新均衡分片数据 # 如有必要,可以通过在分片间移动数据来达到负载均衡。举个例子,许多读者可能听一些大型图片分享网站或流行社区网站的开发者提到过用于分片间移动用户数据的工具。在分片间移动数据的好处很明显。例如,当需要升级硬件时,可以将用户数据从旧分片转移到新分片上,而无须暂停整个分片的服务或将其设置为只读。\n然而,我们也应该尽量避免重新均衡分片数据,因为这可能会影响用户使用。在分片间转移数据也使得为应用增加新特性更加困难,因为新特性可能还需要包含针对重新均衡脚本的升级。如果分片足够小,就无须这么做;也可以经常移动整个分片来重新均衡负载,这比移动分片中的部分数据要容易得多(并且以每行数据开销来衡量的话,更有效率)。\n一个较好的策略是使用动态分片策略,并将新数据随机分配到分片中。当一个分片快满时,可以设置一个标志位,告诉应用不要再往这里放数据了。如果未来需要向分片中放入更多数据,可以直接把标记位清除。\n假设安装了一个新的MySQL节点,上面有100个分片。先将它们的标记设置为1,这样应用就知道它们正准备接受新数据。一旦它们的数据足够多时(例如,每个分片10 000个用户),就把标记位设置为0。之后,如果节点因为大量废弃账号导致负载不足,可以重新打开一些分片向其中增加新用户。\n如果升级应用并且增加的新特性会导致每个分片的查询负载升高,或者只是算错了负载,可以把一些分片移到新节点来减轻负载。缺点是操作期间整个分片会变成只读或者处于离线状态。这需要根据实际情况来看是否能接受。\n另外一种使用得较多的策略是为每个分片设置两台备库,每个备库都有该分片的完整数据。然后每个备库负责其中一半的数据,并完全停止在主库上查询。这样每个备库都会有一半它不会用到的数据;我们可以使用一些工具,例如Percona Toolkit的pt-archiver,在后台运行,移除那些不再需要的数据。这种办法很简单并且几乎不需要停机。\n13.生成全局唯一ID # 当希望把一个现有系统转换为分片数据存储时,经常会需要在多台机器上生成全局唯一ID。单一数据存储时通常可以使用AUTO_INCREMENT列来获取唯一ID。但涉及多台服务器时就不凑效了。以下几种方法可以解决这个问题:\n使用auto_increment_increment和auto_increment_offset\n这两个服务器变量可以让MySQL以期望的值和偏移量来增加AUTO_INCREMENT列的值。举一个最简单的场景,只有两台服务器,可以配置这两台服务器自增幅度为2,其中一台的偏移量设置为1,另外一台为2(两个都不可以设置为0)。这样一台服务器总是包含偶数,另外一台则总是包含奇数。这种设置可以配置到服务器的每一个表里。\n这种方法简单,并且不依赖于某个节点,因此是生成唯一ID的比较普遍的方法。但这需要非常仔细地配置服务器。很容易因为配置错误生成重复数字,特别是当增加服务器需要改变其角色,或进行灾难恢复时。\n全局节点中创建表\n在一个全局数据库节点中创建一个包含AUTO_INCREMENT列的表,应用可以通过这个表来生成唯一数字。\n使用memcached\n在memcached的API中有一个incr()函数,可以自动增长一个数字并返回结果。另外也可以使用Redis。\n批量分配数字\n应用可以从一个全局节点中请求一批数字,用完后再申请。\n使用复合值\n可以使用一个复合值来做唯一ID,例如分片号和自增数的组合。具体参阅之前的章节。\n使用GUID值\n可以使用UUID()函数来生成全局唯一值。注意,尽管这个函数在基于语句的复制时不能正确复制,但可以先获得这个值,再存放到应用的内存中,然后作为数字在查询中使用。GUID的值很大并且不连续,因此不适合做InnoDB表的主键。具体参考“和InnoDB主键一致地插入行”。在5.1及更新的版本中还有一个函数UUID_SHORT(),能够生成连续的值,并使用64位代替了之前的128位。\n如果使用全局分配器来产生唯一ID,要注意避免单点争用成为应用的性能瓶颈。\n虽然memcached方法执行速度快(每秒数万个值),但不具备持久性。每次重启memcached服务都需要重新初始化缓存里的值。由于需要首先找到所有分片中的最大值,因此这一过程非常缓慢并且难以实现原子性。\n14.分片工具 # 在设计数据分片应用时,首先要做的事情是编写能够查询多个数据源的代码。\n如果没有任何抽象层,直接让应用访问多个数据源,那绝对是一个很差的设计,因为这会增加大量的编码复杂性。最好的办法是将数据源隐藏在抽象层中。这个抽象层主要完成以下任务:\n连接到正确的分片并执行查询。 分布式一致性校验。 跨分片结果集聚合。 跨分片关联操作。 锁和事务管理。 创建新的数据分片(或者至少在运行时找到新分片)并重新平衡分片(如果有时间实现)。 你可能不需要从头开始构建分片结构。有一些工具和系统可以提供一些必要的功能或专门设计用来实现分片架构。\nHibernate Shards( http://shards.hibernate.org)是一个支持分片的数据库抽象层,基于Java语言的开源的Hibernate ORM库扩展,由谷歌提供。它在Hibernate Core 接口上提供了分片感知功能,所以应用无须专门为分片设计;事实上,应用甚至无须知道它正在使用分片。Hibernate Shards 通过固定分配策略向分片分配数据。另外一个基于Java的分片系统是HiveDB( http://www.hivedb.org)。\n如果使用的是PHP语言,可以使用Justin Swanhart提供的Shard-Query系统( http://code.google.com/p/shard-query/),它可以自动分解查询,并发执行,并合并结果集。另外一些有同样用途的商用系统有ScaleBase( http://www.scalebase.com)、ScalArc( http://www.scalarc.com),以及dbShards( http://www.dbshards.com)。\nSphinx是一个全文检索引擎,虽然不是分片数据存储和检索系统,但对于一些跨分片数据存储的查询依然有用。Sphinx可以并行查询远程系统并聚合结果集。在附录F中会详细讨论Sphinx。\n11.2.5 通过多实例扩展 # 一个分片较多的架构可能会更有效地利用硬件。我们的研究和经验表明MySQL并不能完全发挥现代硬件的性能。当扩展到超过24个CPU核心时,MySQL的性能开始趋于平缓,不再上升。当内存超过128GB时也同样如此,MySQL甚至不能完全发挥诸如Virident或Fusion-io卡这样的高端PCIe flash设备的I/O性能。\n不要在一台性能强悍的服务器上只运行一个服务器实例,我们还有别的选择。你可以让数据分片足够小,以使每台机器上都能放置多个分片(这也是我们一直提倡的),每台服务器上运行多个实例,然后划分服务器的硬件资源,将其分配给每个实例。\n这样做尽管比较烦琐,但确实有效。这是一种向上扩展和向外扩展的组合方案。也可以用其他方法来实现——不一定需要分片——但分片对于在大型服务器上的联合扩展具有天然的适应性。\n一些人倾向于通过虚拟化技术来实现合并扩展,这有它的好处。但虚拟化技术本身有很大的性能损耗。具体损耗多少取决于具体的技术,但通常都比较明显,尤其是I/O非常快的时候损耗会非常惊人。另一种选择是运行多个MySQL实例,每个实例监听不同的网络端口,或绑定到不同的IP地址。\n我们已经在一台性能强悍的硬件上获得了10倍或15倍的合并系数。你需要平衡管理复杂度代价和更优性能的收益,以决定哪种方法是最优的。\n这时候网络可能会成为瓶颈——这个问题大多数MySQL用户都不会遇到。可以通过使用多块网卡并进行绑定来解决这个问题。但Linux内核可能会不理想,这取决于内核版本,因为老的内核对每个绑定设备的网络中断只能使用一个CPU。因此不要把太多的连线绑定到很少的虚拟设备上,否则会遇到内核层的网络瓶颈。新的内核在这一方面会有所改善,所以需要检查你的系统版本,以确定该怎么做。\n另一个方法是将每个MySQL实例绑定到特定的CPU核心上。这有两点好处:第一,由于MySQL内部的可扩展性限制,当核心数较少时,能够在每个核心上获得更好的性能;第二,当实例在多个核心上运行线程时,由于需要在多核心上同步共享数据,因而会有一些额外的开销。这可以避免硬件本身的可扩展性限制。限制MySQL到少数几个核心能够帮助减少CPU核心之间的交互。注意到反复出现的问题了没?将进程绑定到具有相同物理套接字的核心上可以获得最优的效果。\n11.2.6 通过集群扩展 # 理想的扩展方案是单一逻辑数据库能够存储尽可能多的数据,处理尽可能多的查询,并如期望的那样增长。许多人的第一想法就是建立一个“集群”或者“网格”来无缝处理这些事情,这样应用就无须去做太多工作,也不需要知道数据到底存在哪台服务器上。随着云计算的流行,自动扩展——根据负载或数据大小变化动态地在集群中增加/移除服务器——变得越来越有趣。\n在本书第二版时,我们遗憾地看到已有的技术无法完成这一任务。从那时开始,出现了许多被称为NoSQL的技术。许多NoSQL的支持者发表了一些奇怪且未经证实的观点,例如“关系模型无法进行扩展”,或者“SQL无法扩展”。随着新概念的出现,也出现了一些新的术语。最近谁没有听说过最终一致性、BASE、矢量时钟,或者CAP理论呢?\n但随着时间推移,理性开始逐渐回归。经验表明许多NoSQL数据库太过于简单,并且无法完成很多工作(9)。同时一些基于SQL的技术开始出现——例如451集团(451 Group)的Matt Aslett所提到的NewSQL数据库。SQL和NewSQL到底有什么区别呢?NewSQL数据库中SQL及相关技术都不应该成为问题。而可扩展性问题在关系型数据库中是一个实现上的难题,但新的实现正表现出越来越好的结果。\n所有的旧事物都变成新的了吗?是,但也不是。许多关系型数据库集群的高性能设计正在被构建到系统的更低层,在NoSQL数据库中,特别是使用键—值存储时,这一点很明显。例如NDB Cluster并不是一个SQL数据库;它是一个可扩展的数据库,使用其原生API来控制,通常是使用NoSQL,但也可以通过在前端使用MySQL存储引擎来支持SQL。它是一个完全分布式、非共享高性能、自动分片并且不存在单点故障的事务型数据库服务器。最近几年正变得更强大、更复杂,用途也更广泛。同时,NoSQL数据库也逐渐看起来越来越像关系型数据库。有些甚至还开发了类SQL查询语言。未来典型的集群数据库可能更像是SQL和NoSQL的混合体,有多种存取机制来满足不同的使用需求。所以,我们在从NoSQL中汲取优点,但SQL仍然会保留在集群数据库中。\n在写作本书时,和MySQL结合在一起的集群或分布式数据库技术大致包括:NDB Cluster、Clustrix、Percona XtraDB Cluster、Galera、Schooner Active Cluster、Continuent Tungsten、ScaleBase、ScaleArc、dbShards、Xeround、Akiban、VoltDB,以及GenieDB。这些或多或少以MySQL为基础,或通过MySQL进行控制,或是和MySQL相关。本书会讲到这其中的一部分——例如,在第13章我们会讲到Xeround,在第10章我们讲到了Continuent Tungsten和其他几种技术——这里我们同样会对其中的几个进行描述。\n在开始前,需要指出,可扩展性、高可用性、事务性等是数据库系统的不同特性。许多人会感到困惑并将这些当作是相同的东西,但事实上不是。本章我们主要集中讨论可扩展性。但事实上,可扩展的数据库并不一定非常优秀,除非它能保证高性能,谁愿意牺牲高可用性来进行扩展呢?这些特性的组合堪称数据库的必杀技,但这很难实现。当然这不是本章要讨论的内容。\n最后,除NDB Cluster外,大多数NewSQL集群产品都是比较新的事物。我们还没有看到足够多的生产环境部署以完全获知其优点和限制。尽管它们提到了MySQL协议或其他与MySQL相关的地方,但它们毕竟不是MySQL,因此不在本书讨论的范围内。我们仅仅稍微提一下,由你自己来判断它们是否适用。\n1.MySQL Cluster(NDB Cluster) # MySQL Cluster是两项技术的结合:NDB数据库,以及作为SQL前端的MySQL存储引擎。NDB是一个分布式、具备容错性、非共享的数据库,提供同步复制以及节点间的数据自动分片。NDB Cluset存储引擎将SQL转换为NDB API调用,但遇到NDB不支持的操作时,就会在MySQL服务器上执行(NDB是一个键—值数据存储,无法执行类似联接或聚合的复杂操作)。\nNDB是一个非常复杂的数据库,和MySQL几乎完全不同。在使用NDB时甚至可以不需要MySQL:你可以把它作为一个独立的键—值数据库服务器。它的亮点包括非常高的写入和按键查询吞吐量。NDB可以基于键的哈希自动决定哪个节点应该存储给定的数据。当通过MySQL来控制NDB时,行的主键就是键,其他的列是值。\n因为它基于一些新的技术,并且集群具有容错性和分布式特性,所以管理NDB需要非常专业和特殊的技能。有许多动态变化的部分,还有类似升级集群或增加节点的操作必须正确执行以防止意外的问题。NDB是一项开源技术,但也可以从Oracle购买商业支持。商业支持中包括能够获得专门的集群管理产品Cluster Manager,可以自动执行一些枯燥且棘手的任务。(Severalnines同样提供了一个集群管理产品,参见http://www. severalnines.com)。\nMySQL Cluster正在迅速地增加越来越多的特性和功能。例如在最近的版本中,它开始支持更多类型的集群变更而无须停机操作,并且能够在数据存储的节点上执行一些特定类型的查询,以减少数据传递给MySQL层并在其中执行查询的必要性。(这个特性已由关联下推(push-down join)更名为自适应查询本地化(adaptive query localization)。)\nNDB曾经相对其他MySQL存储引擎具有完全不同的性能特性,但最近的版本更加通用化了。它正在成为越来越多应用的更好的解决方案,包括游戏和移动应用。我们必须强调,NDB是一项重要的技术,能够支持全球最大的关键应用,这些应用处于极高的负载下,具有非常严苛的延迟要求以及不间断要求。举个例子,世界上任何一个通过移动电话网络呼叫的电话使用的就是NDB,并且不是临时方案——对于许多移动电话提供商而言,它是一个主要的并且非常重要的数据库。\nNDB需要一个快速且可靠的网络来连接节点。为了获得最好的性能,最好使用特定的高速连接设备。由于大多数情况下需要内存操作,因此服务器间需要大量的内存。\n那么它有什么缺点呢?复杂查询现在支持得还不是很好,例如那些有很多关联和聚合的查询。所以不要指望用它来做数据仓库。NDB是一个事务型系统,但不支持MVCC,所以读操作也需要加锁,也不做任何的死锁检测。如果发生死锁,NDB就以超时返回的方式来解决。还有很多你应该知道的要点和警告,可以专门写一本书了。(有一些关于MySQL Cluster的书,但大多数都过时了,最好的办法是阅读手册。)\n2.CIustrix # Clustrix( http://www.clustrix.com)是一个分布式数据库,支持MySQL协议,所以它可以直接替代MySQL。除了协议外,它是一个全新的技术,并非建立在MySQL的基础之上。它是一个完全支持ACID,支持MVCC的事务型SQL数据库,主要用于OLTP负载场景。Clustrix 在节点间进行数据分片以满足容错性,并对查询进行分发,在节点上并发执行,而不是将所有节点上取得的数据集中起来执行。集群可以在线扩展节点来处理更多的数据或负载。在某些方面Clustrix和MySQL Cluster很像;关键的不同点是,Clustrix是完全分布式执行并且缺少顶层的“代理”或者集群前端的查询协调器(query coordinator)。Clustrix本身能够理解MySQL协议,所以无须MySQL来进行协议转换。相比较而言, MySQL cluster是由三个部分组成的:MySQL,NDB集群存储引擎,以及NDB。\n我们的实验评估和性能测试表明,Clustrix能够提供高性能和可扩展性。Clustrix看起来是一项比较有前景的技术,我们将继续观察和评估。\n3.ScaleBase # ScaleBase( http://www.scalebase.com)是一个软件代理,处于应用和多个后端MySQL服务器之间。它会把发起的查询进行分裂,并将其分发到后端服务器并发执行,然后汇集结果返回给应用。不过在写作本书时,我们还没有使用该产品的经验。另外的竞争产品有ScaleArc( http://www.calearc.com)和dbShards( http://www.dbshards.com)。\n4.GenieDB # GenieDB( http://www.geniedb.com)最开始用于地理上分布部署的NoSQL文档存储。现在它也有一个SQL层,可以通过MySQL存储引擎进行控制。它包含了很多技术,包括本地内存缓存、消息层,以及持久化磁盘数据存储。将这些技术汇集在一起,就可以使用松散的最终一致性,让应用在本地快速执行查询,或是通过分布式集群(会增加网络延迟)来保证最新的数据视图。\n通过存储引擎实现的MySQL兼容层不能提供100%的MySQL特性,但对于支持类似Joomla!、WordPress,以及Drupal 这样的应用已经够用了。MySQL存储引擎的用处主要是使GenieDB能够结合存储引擎获得对ACID的支持,例如InnoDB。GenieDB本身并不是ACID数据库。\n我们还没用应用过GenieDB,也没有看到任何生产环境部署。\n5.Akiban # 对Akiban( http://www.akiban.com)最好的描述应该是查询加速器。它通过存储物理数据来匹配查询模式,使得低开销的跨表关联操作成为可能。尽管类似反范式化(denormalization),但数据层并不是冗余的,所以这和预先计算关联并存储结果的方式是不同的。关联表中元组是互相交错的,所以能够按照关联顺序进行顺序扫描。这就要求管理员确定查询模式能够从所谓的“表组”(table grouping)技术中受益,并需要为查询优化设计表组。目前建议的系统架构是将Akiban配置为MySQL主库的备库,并用它来为可能较慢的查询提供服务。加速系数是一到两个数量级。但是我们还没有看到生产环境部署或者相关的实验评估。(10)\n11.2.7 向内扩展 # 处理不断增长的数据和负载最简单的办法是对不再需要的数据进行归档和清理。这种操作可能会带来显著的成效,具体取决于工作负载和数据特性。这种做法并不用来代替其他策略,但可以作为争取时间的短期策略,也可以作为处理大数据量的长期计划之一。\n在设计归档和清理策略时需要考虑到如下几点。\n对应用的影响\n一个设计良好的归档系统能够在不影响事务处理的情况下,从一个高负载的OLTP服务器上移除数据。这里的关键是能高效地找到要删除的行,然后一小块一小块地移除。通常需要平衡一次归档的行数和事务的大小,以找到一个锁竞争和事务负载量的平衡。还需要设计归档任务在必要的时候让步于事务处理。\n要归档的行\n当知道某些数据不再使用后,就可以立刻清理或归档它们。也可以设计应用去归档那些几乎不怎么使用的数据。可以把归档的数据置于核心表附近,通过视图来访问,或完全转移到别的服务器上。\n维护数据一致性\n当数据间存在联系时,会导致归档和清理工作更加复杂。一个设计良好的归档任务能够保证数据的逻辑一致性,或至少在应用需要时能够保证一致,而无须在大量事务中包含多个表。\n当表之间存在关系时,哪个表首先归档是个问题。在归档时需要考虑孤立行的影响。可以选择违背外键约束(可以通过执行SET FOREIGN_KEY_CHECKS=0禁止InnoDB的外键约束)或暂时把“悬空指针”(dangling pointer)记录放到一边。如果应用层认为这些相关联的表具有层次关系,那么归档的顺序也应该和它一样。例如,如果应用总是先检查订单再检查发货单,就先归档订单。应用应该看不到孤立的发货单,因此接下来就可以将发货单归档。\n避免数据丢失\n如果是在服务器间归档,归档期间可能就无法做分布式事务处理,也有可能将数据归档到MyISAM或其他非事务型的存储引擎中。因此,为了避免数据丢失,在从源表中删除时,要保证已经在目标机器上保存。将归档数据单独写到一个文件里也是个好主意。可以将归档任务设计为能够随时关闭或重启,并且不会引起不一致或索引冲突之类的错误。\n解除归档(unarchiving)\n可以通过一些解除归档策略来减少归档的数据量。它可以帮助你归档那些不确定是否需要的数据,并在以后可以通过选项进行回退。如果可以设置一些检查点让系统来检查是否有需要归档的数据,那么这应该是一个很容易实现的策略。例如,要对不活跃的用户进行归档,检查点就可以设置在登录验证时。如果因为用户不存在导致登录失败,可以去检查归档数据中是否存在该用户,如果有,则从中取出来并完成登录。\nPercona Toolkit包含的工具pt-archiver能够帮助你有效地归档和清理MySQL表,但不提供解除归档功能。\n保持活跃数据独立 # 即使并不真的把老数据转移到别的服务器,许多应用也能受益于活跃数据和非活跃数据的隔离。这有助于高效利用缓存,并为活跃和不活跃的数据使用不同的硬件或应用架构。下面列举了几种做法:\n将表划分为几个部分\n分表是一种比较明智的办法,特别是整张表无法完全加载到内存时。例如,可以把users表划分为active_users和inactive_users表。你可能认为这并不需要,因为数据库本身只缓存“热”数据,但事实上这取决于存储引擎。如果用的是InnoDB,每次缓存一页,而一页能存储100个用户,但只有10%是活跃的,那么这时候InnoDB可能认为所有的页都是“热”的——因此每个“热”页的90%将被浪费掉。将其拆成两个表可以明显改善内存利用率。\nMySQL分区\nMySQL 5.1本身提供了对表进行分区的功能,能够帮助把最近的数据留在内存中。第7章详细介绍了分区表。\n基于时间的数据分区\n如果应用不断有新数据进来,一般新数据总是比旧数据更加活跃。例如,我们知道博客服务的流量大多是最近七天发表的文章和评论。更新的大部分是相同的数据集。因此这些数据被完整地保留在内存中,使用复制来保证在主库失效时有一份可用的备份。其他数据则完全可以放到别的地方去。\n我们也看到过这样一种设计,在两个节点的分片上存储用户数据。新数据总是进入“活跃”节点,该节点使用更大的内存和快速硬盘,另外一个节点存储旧数据,使用非常大(但比较慢)的硬盘。应用假设不太会需要旧数据。对于很多应用而言这是合理的假设,依靠10%的最新数据能够满足90%或更多的请求。\n可以通过动态分片来轻松实现这种策略。例如,分片目录表可能定义如下:\nCREATE TABLE users ( user_id int unsigned not null, shard_new int unsigned not null, shard_archive int unsigned not null, archive_timestamp timestamp, PRIMARY KEY (user_id) ); 通过一个归档脚本将旧数据从活跃节点转移到归档节点,当移动用户数据到归档节点时,更新archive_timestamp列的值。shard_new和shard_archive列记录存储数据的分片号。\n11.3 负载均衡 # 负载均衡的基本思路很简单:在一个服务器集群中尽可能地平均负载量。通常的做法是在服务器前端设置一个负载均衡器(一般是专门的硬件设备)。然后负载均衡器将请求的连接路由到最空闲的可用服务器。图11-9显示了一个典型的大型网站负载均衡设置,其中一个负载均衡器用于HTTP流量,另一个用于MySQL访问。\n图11-9:一个典型的读密集型网站负载均衡架构\n负载均衡有五个常见目的。\n可扩展性\n负载均衡对某些扩展策略有所帮助,例如读写分离时从备库读数据。\n高效性\n负载均衡有助于更有效地使用资源,因为它能够控制请求被路由到何处。如果服务器处理能力各不相同,这就尤为重要:你可以把更多的工作分配给性能更好的机器。\n可用性\n一个灵活的负载均衡解决方案能够使用时刻保持可用的服务器。\n透明性\n客户端无须知道是否存在负载均衡设置,也不需要关心在负载均衡器的背后有多少机器,它们的名字是什么。负载均衡器给客户端看到的只是一个虚拟的服务器。\n一致性\n如果应用是有状态的(数据库事务,网站会话等),那么负载均衡器就应将相关的查询指向同一个服务器,以防止状态丢失。应用无须去跟踪到底连接的是哪个服务器。\n在与MySQL相关的领域里,负载均衡架构通常和数据分片及复制紧密相关。你可以把负载均衡和高可用性结合在一起,部署到应用的任一层次上。例如,可以在MySQL Cluster集群的多个SQL节点上做负载均衡,也可以在多个数据中心间做负载均衡,其中每个数据中心又可以使用数据分片架构,每个节点实际上是拥有多个备库的主—主复制对结构,这里又可以做负载均衡。对于高可用性策略也同样如此:在一个架构里可以配置多层的故障转移机制。\n负载均衡有许多微妙之处,举个例子,其中一个挑战就是管理读/写策略。有些负载均衡技术本身能够实现这一点,但其他的则需要应用自己知道哪些节点是可读的或可写的。\n在决定如何实现负载均衡时,应该考虑到这些因素。有许多负载均衡解决方案可以使用,从诸如Wackamole(http://www.backhand.org/wackamole/)这样基于端点的(peer-based)实现,到DNS、LVS(Linux Virtual Server, http://www.linuxvirtualserver.org)、硬件负载均衡器、TCP代理、MySQL Proxy,以及在应用中管理负载均衡。\n在我们的客户中,最普遍的策略是使用硬件负载均衡器,大多是使用HAProxy( http://haproxy.1wt.eu),它看起来很流行并且工作得很好。还有一些人使用TCP代理,例如Pen( http://siag.nu/pen/)。但MySQL Proxy用得并不多。\n11.3.1 直接连接 # 有些人认为负载均衡就是配置在应用和MySQL服务器之间的东西。但这并不是唯一的负载均衡方法。你可以在保持应用和MySQL连接的情况下使用负载均衡。事实上,集中化的负载均衡系统只有在存在一个对等置换的服务器池时才能很好工作。如果应用需要做一些决策,例如在备库上执行读操作是否安全,就需要直接连接到服务器。\n除了可能出现的一些特定逻辑,应用为负载均衡做决策是非常高效的。例如,如果有两个完全相同的备库,你可以使用其中的一个来处理特定分片的数据查询,另一个处理其他的查询。这样能够有效利用备库的内存,因为每个备库只会缓存一部分数据。如果其中一个备库失效,另外一个备库拥有所有的数据,仍然能提供服务。\n接下来的小节将讨论一些应用直连的常见方法,以及在评估每一个选项时的注意点。\n1.复制上的读/写分离 # MySQL复制产生了多个数据副本,你可以选择在备库还是主库上执行查询。由于备库复制是异步的,因此主要的难点是如何处理备库上的脏数据。应该将备库用作只读的,而主库可以同时处理读和写查询。\n通常需要修改应用以适应这种分离需求。然后应用就可以使用主库来进行写操作,并将读操作分配到主库和备库上;如果不太关心数据是否是脏的,可以使用备库,而对需要即时数据的请求使用主库。我们将这称为读/写分离。\n如果使用的是主动—被动模式的主—主复制对,同样也要考虑这个问题。使用这种配置时,只有主动服务器接受写操作。如果能够接受读到脏数据,可以将读分配给被动服务器。\n最大的问题是如何避免由于读了脏数据引起的奇怪问题。一个典型的例子是当一个用户做了某些修改,例如增加了一条博客文章的评论,然后重新加载页面,但并没有看到更新,因为应用从备库读取到了脏的数据。\n比较常见的读/写分离方法如下:\n基于查询分离\n最简单的分离方法是将所有不能容忍脏数据的读和写查询分配到主动或主库服务器上。其他的读查询分配到备库或被动服务器上。该策略很容易实现,但事实上无法有效地使用备库,因为只有很少的查询能容忍脏数据。\n基于脏数据分离\n这是对基于查询分离方法的小改进。需要做一些额外的工作,让应用检查复制延迟,以确定备库数据是否太旧。许多报表类应用都使用这个策略:只需要晚上加载的数据复制到备库即可,它们并不关心是不是100%跟上了主库。\n基于会话分离\n另一个决定能否从备库读数据的稍微复杂一点的方法是判读用户自己是否修改了数据。用户不需要看到其他用户的最新数据,但需要看到自己的更新。可以在会话层设置一个标记位,表明做了更新,就将该用户的查询在一段时间内总是指向主库。这是我们通常推荐的策略,因为它是在简单和有效性之间的一种很好的妥协。\n如果有足够的想象力,可以把基于会话的分离方法和复制延迟监控结合起来。如果用户在10秒前更新了数据,而所有备库延迟在5秒内,就可以安全地从备库中读取数据。但为整个会话选择同一个备库是一个很好的主意,否则用户可能会奇怪有些备库的更新速度比其他服务器要慢。\n基于版本分离\n这和基于会话的分离方法相似:你可以跟踪对象的版本号以及/或者时间戳,通过从备库读取对象的版本或时间戳来判断数据是否足够新。如果备库的数据太旧,可以从主库获取最新的数据。即使对象本身没有变化,但如果是顶层对象,只要下面的任何对象有变化,也可以增加版本号,这简化了脏数据检查(只需要检查顶层对象一处就能判断是否有更新)。例如,在用户发表了一篇新文章后,可以更新用户的版本。这样就会从主库去读取数据了。\n基于全局版本/会话分离\n这个办法是基于版本分离和基于会话分离的变种。当应用执行写操作时,在提交事务后,执行一次SHOW MASTER STATUS操作。然后在缓存中存储主库日志坐标,作为被修改对象以及/或者会话的版本号。当应用连接到备库时,执行SHOW SLAVE STATUS并将备库上的坐标和缓存中的版本号相对比。如果备库相比记录点更新,就可以安全地读取备库数据。\n大多数读/写分离解决方案都需要监控复制延迟来决策读查询的分配,不管是通过复制或负载均衡器,或是一个中间系统。如果这么做,需要注意通过SHOW SLAVE STATUS得到的Seconds_behind_master列的值并不能准确地用于监控延迟。(详情参阅第10章)。Percona Toolkit中的pt-heartbeat工具能够帮助监控延迟,并维护元数据,例如二进制日志位置,这可以减轻之前我们讨论的一些策略存在的问题。\n如果不在乎用昂贵的硬件来承载压力,也就可以不使用复制来扩展读操作,这样当然更简单。这可以避免在主备上分离读的复杂性。有些人认为这很有意义;也有人认为会浪费硬件。这种分歧是由于不同的目的引起的:你是只需要可扩展性,还是要同时具有可扩展性和高利用率?如果需要高利用率,那么备库除了保存数据副本外还需要承担其他任务,就不得不处理这些额外的复杂度。\n2.修改应用的配置 # 还有一个分发负载的方法是重新配置应用。例如,你可以配置多个机器来分担生成大报表操作的负载。每台机器可以配置成连接到不同的MySQL备库,并为第N个用户或网站生成报表。\n这样的系统很容易实现,但如果需要修改一些代码——包括配置文件修改——会变得脆弱且难以处理。硬编码有着固有的限制,需要在每台服务器上修改硬编码,或者在一个中心服务器上修改,然后通过文件副本或代码控制更新命令“发布”到其他服务器上。如果将配置存储在服务器或缓存中,就可以避免这些麻烦。\n3.修改DNS名 # 这是一个比较粗糙的负载均衡技术,但对于一些简单的应用,为不同的目的创建DNS还是很实用的。你可以为不同的服务器指定一个合适的名字。最简单的方法是只读服务器拥有一个DNS名,而给负责写操作的服务器起另外一个DNS名。如果备库能够跟上主库,那就把只读DNS名指定给备库,当出现延迟时,再将该DNS名指定给主库。\n这种DNS技术非常容易实现,但也有很多缺点。最大的问题是无法完全控制DNS。\n修改DNS并不是立刻生效的,也不是原子的。将DNS的变化传递到整个网络或在网络间传播都需要比较长的时间。 DNS数据会在各个地方缓存下来,它的过期时间是建议性质的,而非强制的。 可能需要应用或服务器重启才能使修改后的DNS完全生效。 多个IP地址共用一个DNS名并依赖于轮询行为来均衡请求,这并不是一个好主意。因为轮询行为并不总是可预知的。 DBA可能没有权限直接访问DNS。 除非应用非常简单,否则依赖于不受控制的系统会非常危险。你可以通过修改/etc/hosts文件而非DNS来改善对系统的控制。当发布一个对该文件的更新时,会知道该变更已经生效。这比等待缓存的DNS失效要好得多。但这仍然不是理想的办法。\n我们通常建议人们构建一个完全不依赖DNS的应用。即使应用很简单也适用,因为你无法预知应用会增长到多大规模。\n4.转移IP地址 # 一些负载均衡解决方案依赖于在服务器间转移虚拟地址(11),一般能够很好地工作。这听起来和修改DNS很像,但完全是两码事。服务器不会根据DNS名去监听网络流量,而是根据指定的IP地址去监听流量,所以转移IP地址允许DNS名保持不变。你可以通过ARP(地址解析协议)命令强制使IP地址的更改快速而且原子性地通知到网络上。\n我们看过的使用最普遍的技术是Pacemaker,这是Linux-HA项目的Heartbeat工具的继承者。你可以使用单个IP地址,为其分配一个角色,例如read-only,当需要在机器间转移IP地址时,它能够感知到。其他类似的工具包括LVS和Wackamole。\n一个比较方便的技术是为每个物理服务器分配一个固定的IP地址。该IP地址固定在服务器上,不再改变。然后可以为每个逻辑上的“服务”使用一个虚拟IP地址。它们能够很方便地在服务器间转移,这使得转移服务和应用实例无须再重新配置应用,因此更加容易。即使不怎么经常转移IP地址,这也是一个很好的特性。\n11.3.2 引入中间件 # 迄今为止,我们所讨论的方案都假定应用跟MySQL服务器是直接相连的。但是许多负载均衡解决方案都会引入一个中间件,作为网络通信的代理。它一边接受所有的通信请求,另一边将这些请求派发到指定的服务器上,然后把执行结果发送回请求的机器上。中间件可以是硬件设备或是软件(12)。图11-10描述了这种架构。这种解决方案通常能工作得很好,当然除非为负载均衡器本身增加冗余,这样才能避免单点故障引起的整个系统瘫痪。从开源软件,如HAProxy,到许多广为人知的商业系统,有许多负载均衡器得到了成功的应用。\n图11-10:作为中间件的负载均衡器\n1.负载均衡器 # 在市场上有许多负载均衡硬件和软件,但很少有专门为MySQL服务器设计的(13)。Web服务器通常更需要负载均衡,因此许多多用途的负载均衡设备都会支持HTTP,而对其他用途则只有一些很少的基本特性。MySQL连接都只是正常的TCP/IP连接,所以可以在MySQL上使用多用途负载均衡器。但由于缺少MySQL专有的特性,因此会多一些限制。\n除非负载均衡器知道MySQL的真实负载,否则在分发请求时可能无法做到很好的负载均衡。不是所有的请求都是等同的,但多用途负载均衡器通常对所有的请求一视同仁。 许多负载均衡器知道如何检查一个HTTP请求并把会话“固定”到一个服务器上以保护在Web服务器上的会话状态。MySQL连接也是有状态的,但负载均衡器可能并不知道如何把所有从单个HTTP会话发送的连接请求“固定”到一个MySQL服务器上。这会损失一部分效率。(如果单个会话的请求都是发到同一个MySQL服务器,服务器的缓存会更有效率。) 连接池和长连接可能会阻碍负载均衡器分发连接请求。例如,假如一个连接池打开了预先配置好的连接数,负载均衡器在已有的四个MySQL服务器上分发这些连接。现在增加了两个以上的MySQL服务器。由于连接池不会请求新连接,因而新的服务器会一直空闲着。池中的连接会在服务器间不公平地分配负载,导致一些服务器超出负载,一些则几乎没有负载。可以在多个层面为连接设置失效时间来缓解这个问题,但这很复杂并且很难做到。连接池方案只有它们本身能够处理负载均衡时才能工作得很好。 许多多用途负载均衡器只会针对HTTP服务器做健康和负载检查。一个简单的负载均衡器最少能够核实服务器在一个TCP端口上接受的连接数。更好的负载均衡器能够自动发起一个HTTP请求,并检查返回值以确定这个Web服务器是否正常运转。MySQL并不接受到3306端口的HTTP请求,因此需要自己来构建健康检查方法。你可以在MySQL服务器上安装一个HTTP服务器软件,并将负载均衡器指向一个脚本,这个脚本检查MySQL服务器的状态并返回一个对应的状态值(14)。最重要的是检查操作系统负载(通过查看/proc/loadavg)、复制状态,以及MySQL的连接数。 2.负载均衡算法 # 有许多算法用来决定哪个服务器接受下一个连接。每个厂商都有各自不同的算法,下面这个清单列出了一些可用的方法:\n随机\n负载均衡器随机地从可用的服务器池中选择一个服务器来处理请求。\n轮询\n负载均衡器以循环顺序发送请求到服务器,例如:A,B,C,A,B,C。\n最少连接数\n下一个连接请求分配给拥有最少活跃连接的服务器。\n最快响应\n能够最快处理请求的服务器接受下一个连接。当服务器池里同时存在快速和慢速服务器时,这很有效。即使同样的查询在不同的场景下运行也会有不同的表现,例如当查询结果已经缓存在查询缓存中,或者服务器缓存中已经包含了所需要的数据时。\n哈希\n负载均衡器通过连接的源IP地址进行哈希,将其映射到池中的同一个服务器上。每次从同一个IP地址发起请求,负载均衡器都会将请求发送给同样的服务器。只有当池中服务器数目改变时这种绑定才会发生变化。\n权重\n负载均衡器能够结合使用上述几种算法。例如,你可能拥有单CPU和双CPU的机器。双CPU机器有接近两倍的性能,所以可以让负载均衡器分派两倍的请求给双CPU机器。\n哪种算法最优取决于具体的工作负载。例如最少连接算法,如果有新机器加入,可能会有大量连接涌入该服务器,而这时候它的缓存还没有包含热数据。本书第一版的作者曾经亲身体验了这种情况。\n你需要通过测试来为你的工作负载找到最好的性能。除了正常的日常运转,还需要考虑极端情况。在比较极端的情况下——例如负载升高,修改模式,或者多台服务器下线——至少要避免系统出现重大错误。\n我们这里只描述了即时处理请求的算法,无须对连接请求排队。但有时候使用排队算法可能更有效。例如,一个算法可能只维护给定的数据库服务器并发数目,同一时刻只允许不超过N个活跃事务。如果有太多的活跃事务,就将新的请求放到一个队列里,然后让可用服务器列表的第一个来处理它。有些连接池也支持队列算法。\n3.在服务器池中增加/移除服务器 # 增加一个服务器到池中并不是简单地插入进去,然后通知负载均衡器就可以了。你可能以为只要不是一下子涌进大量连接请求就可以了,但并不一定如此。有时候你会缓慢增加一台服务器的负载,但一些缓存还是“冷”的服务器可能会慢到在一段时间内都无法处理任何的用户请求。如果用户浏览一个页面需要30秒才能返回数据,即使流量很小,这个服务器也是不可用的。有一个方法可以避免这个问题,在通知负载均衡器有新服务器加入前,可以暂时把SELECT查询映射到一台活跃服务器上。然后在新开启的服务器上读取和重放活跃服务器上的日志文件,或者捕捉生产服务器上的网络通信,并重放它的一部分查询。Percona Toolkit中的pt-query-digest工具能够有所帮助。另一个有效的办法是使用Percona Server或MySQL 5.6的快速预热特性。\n在配置连接池中的服务器时,要保证有足够多未使用的容量,以备在撤下服务器做维护时使用,或者当服务器失效时可以派上用场。每台服务器上都应该保留高于“足够”的容量。\n要确保配置的限制值足够高,即使从池中撤出一些服务器也能够工作。举个例子,如果你发现每个MySQL服务器一般有100个连接,应该设置池中每个服务器的max_connections值为200。这样就算一半的服务器失效,服务器池整体也能处理同样数量的请求。\n11.3.3 一主多备间的负载均衡 # 最常见的复制拓扑结构就是一个主库加多个备库。我们很难绕开这个架构。许多应用都假设只有一个目标机器用于所有的写操作,或者所有的数据都可以从单个服务器上获得。尽管这个架构不太具有很好的可扩展性,但可以通过一些办法结合负载均衡来获得很好的效果。本小节将讲述其中的一些技术。\n功能分区\n正如之前讨论的,对于特定的目的可以通过配置备库或一组备库来极大地扩展容量。一些比较常见的功能包括报表、分析、数据仓库,以及全文检索。在第10章有更多的细节。\n过滤和数据分区\n可以使用复制过滤技术在相似的备库上对数据进行分区(参考第10章)。只要数据在主库上已经被隔离到不同的数据库或表中,这种方法就可以奏效。不幸的是,没有内建的办法在行级别上进行复制过滤。你需要使用一些独创性的技术来实现这一点,例如使用触发器和一组不同的表。\n即使不把数据分区到各个备库上,也可以通过对读进行分区而不是随机分配来提高缓存效率。例如,可以把对以字母A—M开头的用户名的读操作分配给一个给定的备库,把以N—Z开头的分配给另外一个。这能够更好地利用每台机器的缓存,因为分离读更可能在缓存中找到相关的数据。最好的情况下,当没有写操作时,这样使用的缓存相当于两台服务器缓存的总和。相比之下,如果随机地在备库上分配读操作,每个机器的缓存本质上还是重复的数据,而总的有效缓存效率和一个备库缓存一样,不管你有多少台备库。\n将部分写操作转移到备库\n主库并不总是需要处理写操作中的所有工作。你可以分解写查询,并在备库上执行其中的一部分,从而显著减少主库的工作量。更多内容参见第10章。\n保证备库跟上主库\n如果要在备库执行某种操作,它需要即时知道数据处于哪个时间点——哪怕需要等待一会儿才能到达这个点——可以使用函数MASTER_POS_WAIT()阻塞直到备库赶上了设置的主库同步点。另一种替代方案是使用复制心跳来检查延迟情况;更多内容参见第10章。\n同步写操作\n也可以使用MASTER_POS_WAIT()函数来确保写操作已经被同步到一个或多个备库上。如果应用需要模拟同步复制来保证数据安全性,就可以在多个备库上轮流执行MASTER_POS_WAIT()函数。这就类似创建了一个“同步屏障”,当任意一个备库出现复制延迟时,都可能花费很长时间完成,所以最好在确实需要的时候才使用这种方法。(如果你的目的只是确保某些备库拥有事件,可以只等待一台备库接收到事件。MySQL 5.5增加了半同步复制,能够支持这项技术。)\n11.4 总结 # 正确地扩展MySQL并没有看起来那么美好。从第一天就建立下一个Facebook架构,这并不是正确的方式。最好的策略是实现应用所明确需要的,并为可能的快速增长做好预先规划,成功的规划是可以为任何必要的措施筹集资金以满足需求。\n为可扩展性制定一个数学意义上的定义是很有意义的,就像为性能制定了一个精确概念一样。USL能够提供一个有帮助的框架。如果知道系统无法做到线性扩展是因为诸如序列化或交互操作的开销,将可以帮助你避免将这些问题带入到应用中。同时,许多可扩展性问题并不是可以从数学上定义的;可能是由于组织内部的问题,例如缺少团队协作或其他不适当的问题。Neil J. Gunther博士所写的Guerrilla Capacity Planning以及Eliyahu M. Goldratt写的The Goal可以帮助有兴趣的读者了解为什么系统无法扩展。\n在MySQL扩展策略方面,典型的应用在增长到非常庞大时,通常先从单个服务器转移到向外扩展的拥有读备库的架构,再到数据分片和/或者按功能分区。我们并不同意那些提倡为每个应用“尽早分片,尽量分片”(shard early, shard often)的建议。这很复杂且代价昂贵,并且许多应用可能根本不需要。可以花一些时间去看看新的硬件和新版本的MySQL有哪些变化,或者MySQL Cluster有哪些新的进展,甚至去评估一些专门的系统,例如Clustrix。毕竟数据分片是一个手工搭建的集群系统,如果没有必要,最好不要重复发明轮子。\n当存在多个服务器时,可能出现跟一致性或原子性相关的问题。我们看到的最普遍的问题是缺少会话一致性(在网站上发表一篇评论,刷新页面,但找不到刚刚发布的评论),或者无法有效告诉应用哪些服务器是可写的,哪些是可读的。后一种可能更严重,如果将应用的写操作指向多个地方,就会不可避免地遭遇数据问题,需要花费大量时间而且很难解决。负载均衡器可以解决这个问题,但它本身也有一些问题,有时候还会使得原本希望解决的问题恶化。这也是我们在下一章要讲述高可用性的原因。\n————————————————————\n(1) 从物理学来看,单位时间内做的功称为功率(power),而在计算机领域,“power”是一个被反复使用的术语,含义模糊,因此应避免使用它。但是关于容量的精确定义是系统的最大功率输出。\n(2) Justin Bieber, 我们仍然爱你!\n(3) 事实上,“投资产出率”也可以从金融投资的角度来考虑。将一个组件的容量升级到两倍所需要付出的常常不止是最初开销的两倍。虽然在现实世界里我们常常这么考虑,但在讨论中会将其忽略掉,因为它会使一个已经复杂的主题变得更加复杂。\n(4) 你也可以阅读我们的白皮书“Forecasting MySQL Scalability with the Universal Scalability Law”,该书扼要地总结了USL中的数学运算和法则,可以从 http://www.percona.com获得。\n(5) 现实中很难精确定义硬件的可扩展性,因为当你改变你的系统中的服务器数量时很难保证那些变量不变。\n(6) 我们避免使用措辞“web扩展”(web scale),因为它已经变得毫无意义,参阅 http://www.xtranormal.com/ watch/6995033/。\n(7) 分片也被称为“分裂”、“分区”,但是我们使用“分片”以避免混淆。谷歌将它称为“分片”,如果谷歌觉得这样称呼合适,我们采取这种称呼也就合适了。\n(8) 这里的“函数”使用了其数学涵义,表示从输入(域)到输出(区间)的映射。如你所见,可以用很多方式来创建类似的函数,包括在数据库中使用查找表。\n(9) Yeah, yeah, 我们知道,为你的工作选择正确的工具。这里引用显而易见但听起来很有意义的评论。\n(10) 我们将Akiban包含在集群数据库列表中可能并不准确,因为它并不是真正的集群数据库。但在某种程度上它和其他一些NewSQL数据库很像。\n(11) 虚拟IP地址不是直接连接到任何特定的计算机或网络端口,而是“漂浮”在计算机之间。\n(12) 你可以把诸如LVS这样的解决方案配置成只有应用需要创建一个新连接时才参与进来,此后不再作为中间件。\n(13) MySQL Proxy是个例外,但目前还未能证明能够很好地工作,因为它会带来一些问题,例如延迟增加以及可扩展性瓶颈。\n(14) 实际上,如果能编码实现一个监听80端口的程序,或者配置xinetd来调用程序,甚至不需要再安装一个Web服务器。\n"},{"id":153,"href":"/zh/docs/technology/MySQL/_%E9%AB%98%E6%80%A7%E8%83%BDMySQL_/%E7%AC%AC10%E7%AB%A0%E5%A4%8D%E5%88%B6/","title":"第10章复制","section":"高性能 My SQL","content":"第10章 复制\nMySQL内建的复制功能是构建基于MySQL的大规模、高性能应用的基础,这类应用使用所谓的“水平扩展”的架构。我们可以通过为服务器配置一个或多个备库(1)的方式来进行数据同步。复制功能不仅有利于构建高性能的应用,同时也是高可用性、可扩展性、灾难恢复、备份以及数据仓库等工作的基础。事实上,可扩展性和高可用性通常是相关联的话题,我们会在接下来的三章详细阐述。\n本章将阐述所有与复制相关的内容,首先简要介绍复制如何工作,然后讨论基本的复制服务搭建,包括与复制相关的配置以及如何管理和优化复制服务器。虽然本书的主题是高性能,但对于复制来说,我们同样需要关注其准确性和可靠性,因此我们也会讲述复制在什么情况下会失败,以及如何使其更好地工作。\n10.1 复制概述 # 复制解决的基本问题是让一台服务器的数据与其他服务器保持同步。一台主库的数据可以同步到多台备库上,备库本身也可以被配置成另外一台服务器的主库。主库和备库之间可以有多种不同的组合方式。\nMySQL支持两种复制方式:基于行的复制和基于语句的复制。基于语句的复制(也称为逻辑复制)早在MySQL 3.23版本中就存在,而基于行的复制方式在5.1版本中才被加进来。这两种方式都是通过在主库上记录二进制日志(2)、在备库重放日志的方式来实现异步的数据复制。这意味着,在同一时间点备库上的数据可能与主库存在不一致,并且无法保证主备之间的延迟。一些大的语句可能导致备库产生几秒、几分钟甚至几个小时的延迟。\nMySQL复制大部分是向后兼容的,新版本的服务器可以作为老版本服务器的备库,但反过来,将老版本作为新版本服务器的备库通常是不可行的,因为它可能无法解析新版本所采用的新的特性或语法,另外所使用的二进制文件的格式也可能不相同。例如,不能从MySQL 5.1复制到MySQL 4.0。在进行大的版本升级前,例如从4.1升级到5.0,或从5.1升级到5.5,最好先对复制的设置进行测试。但对于小版本号升级,如从5.1.51升级到5.1.58,则通常是兼容的。通过阅读每次版本更新的ChangeLog可以找到不同版本间做了什么修改。\n复制通常不会增加主库的开销,主要是启用二进制日志带来的开销,但出于备份或及时从崩溃中恢复的目的,这点开销也是必要的。除此之外,每个备库也会对主库增加一些负载(例如网络I/O开销),尤其当备库请求从主库读取旧的二进制日志文件时,可能会造成更高的I/O开销。另外锁竞争也可能阻碍事务的提交。最后,如果是从一个高吞吐量(例如5000或更高的TPS)的主库上复制到多个备库,唤醒多个复制线程发送事件的开销将会累加。\n通过复制可以将读操作指向备库来获得更好的读扩展,但对于写操作,除非设计得当,否则并不适合通过复制来扩展写操作。在一主库多备库的架构中,写操作会被执行多次,这时候整个系统的性能取决于写入最慢的那部分。\n当使用一主库多备库的架构时,可能会造成一些浪费,因为本质上它会复制大量不必要的重复数据。例如,对于一台主库和10台备库,会有11份数据拷贝,并且这11台服务器的缓存中存储了大部分相同的数据。这和在服务器上有11路RAID 1类似。这不是一种经济的硬件使用方式,但这种复制架构却很常见,本章我们将讨论解决这个问题的方法。\n10.1.1 复制解决的问题 # 下面是复制比较常见的用途:\n数据分布\nMySQL复制通常不会对带宽造成很大的压力,但在5.1版本引入的基于行的复制会比传统的基于语句的复制模式的带宽压力更大。你可以随意地停止或开始复制,并在不同的地理位置来分布数据备份,例如不同的数据中心。即使在不稳定的网络环境下,远程复制也可以工作。但如果为了保持很低的复制延迟,最好有一个稳定的、低延迟连接。\n负载均衡\n通过MySQL复制可以将读操作分布到多个服务器上,实现对读密集型应用的优化,并且实现很方便,通过简单的代码修改就能实现基本的负载均衡。对于小规模的应用,可以简单地对机器名做硬编码或使用DNS轮询(将一个机器名指向多个IP地址)。当然也可以使用更复杂的方法,例如网络负载均衡这一类的标准负载均衡解决方案,能够很好地将负载分配到不同的MySQL服务器上。Linux虚拟服务器(Linux Virtual Server,LVS)也能够很好地工作,第11章将详细地讨论负载均衡。\n备份\n对于备份来说,复制是一项很有意义的技术补充,但复制既不是备份也不能够取代备份。\n高可用性和故障切换\n复制能够帮助应用程序避免MySQL单点失败,一个包含复制的设计良好的故障切换系统能够显著地缩短宕机时间,我们将在第12章讨论故障切换。\nMySQL升级测试\n这种做法比较普遍,使用一个更高版本的MySQL作为备库,保证在升级全部实例前,查询能够在备库按照预期执行。\n10.1.2 复制如何工作 # 在详细介绍如何设置复制之前,让我们先看看MySQL实际上是如何复制数据的。总的来说,复制有三个步骤:\n在主库上把数据更改记录到二进制日志(Binary Log)中(这些记录被称为二进制日志事件)。 备库将主库上的日志复制到自己的中继日志(Relay Log)中。 备库读取中继日志中的事件,将其重放到备库数据之上。 以上只是概述,实际上每一步都很复杂,图10-1更详细地描述了复制的细节。\n图10-1:MySQL复制如何工作\n第一步是在主库上记录二进制日志(稍后介绍如何设置)。在每次准备提交事务完成数据更新前,主库将数据更新的事件记录到二进制日志中。MySQL会按事务提交的顺序而非每条语句的执行顺序来记录二进制日志。在记录二进制日志后,主库会告诉存储引擎可以提交事务了。\n下一步,备库将主库的二进制日志复制到其本地的中继日志中。首先,备库会启动一个工作线程,称为I/O线程,I/O线程跟主库建立一个普通的客户端连接,然后在主库上启动一个特殊的二进制转储(binlog dump)线程(该线程没有对应的SQL命令),这个二进制转储线程会读取主库上二进制日志中的事件。它不会对事件进行轮询。如果该线程追赶上了主库,它将进入睡眠状态,直到主库发送信号量通知其有新的事件产生时才会被唤醒,备库I/O线程会将接收到的事件记录到中继日志中。\nMySQL 4.0之前的复制与之后的版本相比改变很大,例如MySQL最初的复制功能没有使用中继日志,所以复制只用到了两个线程,而不是现在的三个线程。目前大部分人都是使用的最新版本,因此在本章我们不会去讨论关于老版本复制的更多细节。\n备库的SQL线程执行最后一步,该线程从中继日志中读取事件并在备库执行,从而实现备库数据的更新。当SQL线程追赶上I/O线程时,中继日志通常已经在系统缓存中,所以中继日志的开销很低。SQL线程执行的事件也可以通过配置选项来决定是否写入其自己的二进制日志中,它对于我们稍后提到的场景非常有用。\n图10-1显示了在备库有两个运行的线程,在主库上也有一个运行的线程:和其他普通连接一样,由备库发起的连接,在主库上同样拥有一个线程。\n这种复制架构实现了获取事件和重放事件的解耦,允许这两个过程异步进行。也就是说I/O线程能够独立于SQL线程之外工作。但这种架构也限制了复制的过程,其中最重要的一点是在主库上并发运行的查询在备库只能串行化执行,因为只有一个SQL线程来重放中继日志中的事件。后面我们将会看到,这是很多工作负载的性能瓶颈所在。虽然有一些针对该问题的解决方案,但大多数用户仍然受制于单线程。\n10.2 配置复制 # 为MySQL服务器配置复制非常简单。但由于场景不同,基本的步骤还是有所差异。最基本的场景是新安装的主库和备库,总的来说分为以下几步:\n在每台(3)服务器上创建复制账号。 配置主库和备库。 通知备库连接到主库并从主库复制数据。 这里我们假定大部分配置采用默认值即可,在主库和备库都是全新安装并且拥有同样的数据(默认MySQL数据库)时这样的假设是合理的。接下来我们将展示如何一步步配置复制:假设有服务器server1(IP地址192.168.0.1)和服务器server2(IP地址192.168.0.2),我们将解释如何给一个已经运行的服务器配置备库,并探讨推荐的复制配置。\n10.2.1 创建复制账号 # MySQL会赋予一些特殊的权限给复制线程。在备库运行的I/O线程会建立一个到主库的TCP/IP连接,这意味着必须在主库创建一个用户,并赋予其合适的权限。备库I/O线程以该用户名连接到主库并读取其二进制日志。通过如下语句创建用户账号:\nmysql\u0026gt; GRANT REPLICATION SLAVE, REPLICATION CLIENT ON *.* -\u0026gt; TO repl@'192.168.0.%' IDENTIFIED BY 'p4ssword',; 我们在主库和备库都创建该账号。注意我们把这个账户限制在本地网络,因为这是一个特权账号(尽管该账号无法执行select或修改数据,但仍然能从二进制日志中获得一些数据)。\n复制账户事实上只需要有主库上的REPLICATION SLAVE权限,并不一定需要每一端服务器都有REPLICATION CLIENT权限,那为什么我们要把这两种权限给主/备库都赋予呢?这有两个原因:\n用来监控和管理复制的账号需要REPLICATION CLIENT权限,并且针对这两种目的使用同一个账号更加容易(而不是为某个目的单独创建一个账号)。 如果在主库上建立了账号,然后从主库将数据克隆到备库上时,备库也就设置好了——变成主库所需要的配置。这样后续有需要可以方便地交换主备库的角色。 10.2.2 配置主库和备库 # 下一步需要在主库上开启一些设置,假设主库是服务器server1,需要打开二进制日志并指定一个独一无二的服务器ID(server ID),在主库的my.cnf文件中增加或修改如下内容:\nlog_bin = mysql-bin server_id = 10 实际取值由你决定,这里只是为了简单起见,当然也可以设置更多需要的配置。\n必须明确地指定一个唯一的服务器ID,默认服务器ID通常为1(这和版本相关,一些MySQL版本根本不允许使用这个值)。使用默认值可能会导致和其他服务器的ID冲突,因此这里我们选择10来作为服务器ID。一种通用的做法是使用服务器IP地址的末8位,但要保证它是不变且唯一的(例如,服务器都在一个子网里)。最好选择一些有意义的约定并遵循。\n如果之前没有在MySQL的配置文件中指定log-bin选项,就需要重新启动MySQL。为了确认二进制日志文件是否已经在主库上创建,使用SHOW MASTER STATUS命令,检查输出是否与如下的一致。MySQL会为文件名增加一些数字,所以这里看到的文件名和你定义的会有点不一样。\n备库上也需要在my.cnf中增加类似的配置,并且同样需要重启服务器。\nlog_bin = mysql-bin server_id = 2 relay_log = /var/lib/mysql/mysql-relay-bin| Chapter 10:Chapter 10: Replication Replicationlog_slave_updates = 1 read_only = 1 从技术上来说,这些选项并不总是必要的。其中一些选项我们只是显式地列出了默认值。事实上只有server_id是必需的。这里我们同样也使用了log_bin,并赋予了一个明确的名字。默认情况下,它是根据机器名来命名的,但如果机器名变化了可能会导致问题。为了简便起见,我们将主库和备库上的log-bin设置为相同的值。当然如果你愿意的话,也可以设置成别的值。\n另外我们还增加了两个配置选项:relay_log(指定中继日志的位置和命名)和log_slave_updates(允许备库将其重放的事件也记录到自身的二进制日志中),后一个选项会给备库增加额外的工作,但正如后面将会看到的,我们有理由为每个备库设置该选项。\n有时候只开启了二进制日志,但却没有开启log_slave_updates,可能会碰到一些奇怪的现象,例如,当配置错误时可能会导致备库数据被修改。如果可能的话,最好使用read_only配置选项,该选项会阻止任何没有特权权限的线程修改数据(所以最好不要给予用户超出需要的权限)。但read_only选项常常不是很实用,特别是对于那些需要在备库建表的应用。\n不要在配置文件my.cnf中设置master_port或master_host这些选项,这是老的配置方式,已经被废弃,它只会导致问题,不会有任何好处。\n10.2.3 启动复制 # 下一步是告诉备库如何连接到主库并重放其二进制日志。这一步不要通过修改my.cnf来配置,而是使用CHANGE MASTER TO语句,该语句完全替代了my.cnf中相应的设置,并且允许以后指向别的主库时无须重启备库。下面是开始复制的基本命令:\nmysql\u0026gt; ** CHANGE MASTER TO MASTER_HOST='server1',** -\u0026gt; ** MASTER_USER='repl',** -\u0026gt; ** MASTER_PASSWORD='p4ssword',** -\u0026gt; ** MASTER_LOG_FILE='mysql-bin.000001',** -\u0026gt; ** MASTER_LOG_POS=0;** MASTER_LOG_POS参数被设置为0,因为要从日志的开头读起。当执行完这条语句后,可以通过SHOW SLAVE STATUS语句来检查复制是否正确执行。\nmysql\u0026gt; ** SHOW SLAVE STATUS\\G** *************************** 1. row *************************** Slave_IO_State: Master_Host: server1 Master_User: repl Master_Port: 3306 Connect_Retry: 60 Master_Log_File: mysql-bin.000001 Read_Master_Log_Pos: 4 Relay_Log_File: mysql-relay-bin.000001 Relay_Log_Pos: 4 Relay_Master_Log_File: mysql-bin.000001 Slave_IO_Running: No Slave_SQL_Running: No ...omitted... Seconds_Behind_Master: NULL Slave_IO_State、Slave_IO_Running和Slave_SQL_Running这三列显示当前备库复制尚未运行。聪明的读者可能已经注意到日志的开头是4而不是0,这是因为0其实不是日志真正开始的位置,它仅仅意味着“在日志文件头”,MySQL知道第一个事件从文件的第4位(4)开始读。\n运行下面的命令开始复制:\nmysql\u0026gt; ** START SLAVE;** 执行该命令没有显示错误,现在我们再用SHOW SLAVE STATUS命令检查:\nmysql\u0026gt; ** SHOW SLAVE STATUS\\G** *************************** 1. row ******************* Slave_IO_State: Waiting for master to send event Master_Host: server1 Master_User: repl Master_Port: 3306 Connect_Retry: 60 Master_Log_File: mysql-bin.000001 Read_Master_Log_Pos: 164 Relay_Log_File: mysql-relay-bin.000001 Relay_Log_Pos: 164 Relay_Master_Log_File: mysql-bin.000001 Slave_IO_Running: Yes Slave_SQL_Running: Yes ...omitted... Seconds_Behind_Master: 0 从输出可以看出I/O线程和SQL线程都已经开始运行,Seconds_Behind_Master的值也不再为NULL(稍后再解释Seconds_Behind_Master的含义)。I/O线程正在等待从主库传递过来的事件,这意味着I/O线程已经读取了主库所有的事件。日志位置发生了变化,表明已经从主库获取和执行了一些事件(你的结果可能会有所不同)。如果在主库上做一些数据更新,就会看到备库的文件或者日志位置都可能会增加。备库中的数据同样会随之更新。\n我们还可以从线程列表中看到复制线程。在主库上可以看到由备库I/O线程向主库发起的连接。\nmysql\u0026gt; ** SHOW PROCESSLIST\\G** *************************** 1. row *************************** Id: 55 User: repl Host: replica1.webcluster_1:54813 db: NULL Command: Binlog Dump Time: 610237 State: Has sent all binlog to slave; waiting for binlog to be updated Info: NULL 同样,在备库也可以看到两个线程,一个是I/O线程,一个是SQL线程:\nmysql\u0026gt; ** SHOW PROCESSLIST\\G** *************************** 1. row *************************** Id: 1 User: system user Host: db: NULL Command: Connect Time: 611116 State: Waiting for master to send event Info: NULL *************************** 2. row *************************** Id: 2 User: system user Host: db: NULL Command: Connect Time: 33 State: Has read all relay log; waiting for the slave I/O thread to update it Info: NULL 这些简单的输出来自一台已经运行了一段时间的服务器,所以I/O线程在主库和备库上的Time列的值较大。SQL线程在备库已经空闲了33秒。这意味着33秒内没有重放任何事件。\n这些线程总是运行在“system user”账号下,其他列的值则不相同。例如,当SQL线程回放事件时,Info列可能显示正在执行的查询。\n如果只是想实验MySQL的复制,Giuseppe Maxia的MySQL沙箱脚本( http://mysqlsandbox.net)能够帮助你从一个之前下载的安装包中一次性安装。通过如下命令只需要几次按键和大约15秒,就可以运行一个主库和两个备库:\n$ ** ./set_replication.pl /path/to/mysql-tarball.tar.gz** 10.2.4 从另一个服务器开始复制 # 前面的设置都是假定主备库均为刚刚安装好且都是默认的数据,也就是说两台服务器上数据相同,并且知道当前主库的二进制日志。这不是典型的案例。大多数情况下有一个已经运行了一段时间的主库,然后用一台新安装的备库与之同步,此时这台备库还没有数据。\n有几种办法来初始化备库或者从其他服务器克隆数据到备库。包括从主库复制数据、从另外一台备库克隆数据,以及使用最近的一次备份来启动备库,需要有三个条件来让主库和备库保持同步:\n在某个时间点的主库的数据快照。 主库当前的二进制日志文件,和获得数据快照时在该二进制日志文件中的偏移量,我们把这两个值称为日志文件坐标(log file coordinates)。通过这两个值可以确定二进制日志的位置。可以通过SHOW MASTER STATUS命令来获取这些值。 从快照时间到现在的二进制日志。 下面是一些从别的服务器克隆备库的方法:\n使用冷备份\n最基本的方法是关闭主库,把数据复制到备库(高效复制文件的方法参考附录C)。重启主库后,会使用一个新的二进制日志文件,我们在备库通过执行CHANGE MASTER TO指向这个文件的起始处。这个方法的缺点很明显:在复制数据时需要关闭主库。\n使用热备份\n如果仅使用了MyISAM表,可以在主库运行时使用mysqlhotcopy或rsync来复制数据,更多细节参阅第15章。\n使用mysqldump\n如果只包含InnoDB表,那么可以使用以下命令来转储主库数据并将其加载到备库,然后设置相应的二进制日志坐标:\n** $ mysqldump --single-transaction --all-databases --master-data=1--host=server1 \\** ** | mysql --host=server2** 选项*\u0026ndash;single-transaction使得转储的数据为事务开始前的数据。如果使用的是非事务型表,可以使用\u0026ndash;lock-all-tables*选项来获得所有表的一致性转储。\n使用快照或备份\n只要知道对应的二进制日志坐标,就可以使用主库的快照或者备份来初始化备库(如果使用备份,需要确保从备份的时间点开始的主库二进制日志都要存在)。只需要把备份或快照恢复到备库,然后使用CHANGE MASTER TO指定二进制日志的坐标。第15章会介绍更多的细节,也可以使用LVM快照、SAN快照、EBS快照——任何快照都可以。\n使用Percona Xtrabackup\nPercona的Xtrabackup是一款开源的热备份工具,多年前我们就介绍过。它能够在备份时不阻塞服务器的操作,因此可以在不影响主库的情况下设置备库。可以通过克隆主库或另一个已存在的备库的方式来建立备库。\n在15章会介绍更多使用Percona Xtrabackup的细节。这里会介绍一些相关的功能。创建一个备份(不管是从主库还是从别的备库),并将其转储到目标机器,然后根据备份获得正确的开始复制的位置。\n如果是从主库获得备份,可以从xtrabackup_binlog_pos_innodb文件中获得复制开始的位置。 如果是从另外的备库获得备份,可以从xtrabackup_slave_info文件中获得复制开始的位置。 另外,在第15章提到的InnoDB热备份和MySQL企业版的备份,也是比较好的初始化备库方式。\n使用另外的备库\n可以使用任何一种提及的克隆或者拷贝技术来从任意一台备库上将数据克隆到另外一台服务器。但是如果使用的是mysqldump,\u0026ndash;master-data选项就会不起作用。\n此外,不能使用SHOW MASTER STATUS来获得主库的二进制日志坐标,而是在获取快照时使用SHOW SLAVE STATUS来获取备库在主库上的执行位置。\n使用另外的备库进行数据克隆最大的缺点是,如果这台备库的数据已经和主库不同步,克隆得到的就是脏数据。\n不要使用LOAD DATA FROM MASTER或者LOAD TABLE FROM MASTER!这些命令过时、缓慢,并且非常危险,并且只适用于MyISAM存储引擎。\n不管选择哪种技术,都要能熟练运用,要记录详细的文档或编写脚本。因为可能不止一次需要做这样的事情。甚至当错误发生时,也需要能够处理。\n10.2.5 推荐的复制配置 # 有许多参数来控制复制,其中一些会对数据安全和性能产生影响。稍后我们会解释何种规则在何时会失效。本小节推荐的一种“安全”的配置,可以最小化问题发生的概率。\n在主库上二进制日志最重要的选项是sync_binlog:\nsync_binlog=1 如果开启该选项,MySQL每次在提交事务前会将二进制日志同步到磁盘上,保证在服务器崩溃时不会丢失事件。如果禁止该选项,服务器会少做一些工作,但二进制日志文件可能在服务器崩溃时损坏或丢失信息。在一个不需要作为主库的备库上,该选项带来了不必要的开销。它只适用于二进制日志,而非中继日志。\n如果无法容忍服务器崩溃导致表损坏,推荐使用InnoDB。在表损坏无关紧要时, MyISAM是可以接受的,但在一次备库服务器崩溃重启后,MyISAM表可能已经处于不一致状态。一种可能是语句没有完全应用到一个或多个表上,那么即使修复了表,数据也可能是不一致的。\n如果使用InnoDB,我们强烈推荐设置如下选项:\ninnodb_flush_logs_at_trx_commit=1 # Flush every log write innodb_support_xa=1 # MySQL 5.0 and newer only innodb_safe_binlog # MySQL 4.1 only, roughly equivalent to # innodb_support_xa 这些是MySQL 5.0及最新版本中的默认配置,我们推荐明确指定二进制日志的名字,以保证二进制日志名在所有服务器上是一致的,避免因为服务器名的变化导致的日志文件名变化。你可能认为以服务器名来命名二进制日志无关紧要,但经验表明,当在服务器间转移文件、克隆新的备库、转储备份或者其他一些你想象不到的场景下,可能会导致很多问题。为了避免这些问题,需要给log_bin选项指定一个参数。可以随意地给一个绝对路径,但必须明确地指定基本的命名(正如本章之前讨论的)。\nlog_bin=/var/lib/mysql/mysql-bin # Good; specifies a path and base namelog_bin=/var/lib/mysql/mysql-bin # Good; specifies a path and base name#log_bin # Bad; base name will be server's hostname #log_bin # Bad; base name will be server's hostname 在备库上,我们同样推荐开启如下配置选项,为中继日志指定绝对路径:\nrelay_log=/path/to/logs/relay-bin skip_slave_start read_only 通过设置relay_log可以避免中继日志文件基于机器名来命名,防止之前提到的可能在主库发生的问题。指定绝对路径可以避免多个MySQL版本中存在的Bug,这些Bug可能会导致中继日志在一个意料外的位置创建。skip_slave_start选项能够阻止备库在崩溃后自动启动复制。这可以给你一些机会来修复可能发生的问题。如果备库在崩溃后自动启动并且处于不一致的状态,就可能会导致更多的损坏,最后将不得不把所有数据丢弃,并重新开始配置备库。\nread_only选项可以阻止大部分用户更改非临时表,除了复制SQL线程和其他拥有超级权限的用户之外,这也是要尽量避免给正常账号授予超级权限的原因之一。\n即使开启了所有我们建议的选项,备库仍然可能在崩溃后被中断,因为master.info和中继日志文件都不是崩溃安全的。默认情况下甚至不会刷新到磁盘,直到MySQL 5.5版本才有选项来控制这种行为。如果正在使用MySQL 5.5并且不介意额外的fsync()导致的性能开销,最好设置以下选项:\nsync_master_info = 1 sync_relay_log = 1 sync_relay_log_info = 1 如果备库与主库的延迟很大,备库的I/O线程可能会写很多中继日志文件,SQL线程在重放完一个中继日志中的事件后会尽快将其删除(通过relay_log_purge选项来控制)。但如果延迟非常严重,I/O线程可能会把整个磁盘撑满。解决办法是配置relay_log_space_limit变量。如果所有中继日志的大小之和超过这个值,I/O线程会停止,等待SQL线程释放磁盘空间。\n尽管听起来很美好,但有一个隐藏的问题。如果备库没有从主库上获取所有的中继日志,这些日志可能在主库崩溃时丢失。早先这个选项存在一些Bug,使用率也不高,所以用到这个选项遇到Bug的风险会更高。除非磁盘空间真的非常紧张,否则最好让中继日志使用其需要的磁盘空间,这也是为什么我们没有将relay_log_space_limit列入推荐的配置选项的原因。\n10.3 复制的原理 # 我们已经介绍了复制的一些基本概念,接下来要更深入地了解复制。让我们看看复制究竟是如何工作的,有哪些优点和弱点,最后介绍一些更高级的复制配置选项。\n10.3.1 基于语句的复制 # 在MySQL 5.0及之前的版本中只支持基于语句的复制(也称为逻辑复制),这在数据库领域是很少见的。基于语句的复制模式下,主库会记录那些造成数据更改的查询,当备库读取并重放这些事件时,实际上只是把主库上执行过的SQL再执行一遍。这种方式既有好处,也有缺点。\n最明显的好处是实现相当简单。理论上讲,简单地记录和执行这些语句,能够让主备保持同步。另一个好处是二进制日志里的事件更加紧凑,所以相对而言,基于语句的模式不会使用太多带宽。一条更新好几兆数据的语句在二进制日志里可能只占几十个字节。另外mysqlbinlog工具(本章多处会提到)是使用基于语句的日志的最佳工具。\n但事实上基于语句的方式可能并不如其看起来那么便利。因为主库上的数据更新除了执行的语句外,可能还依赖于其他因素。例如,同一条SQL在主库和备库上执行的时间可能稍微或很不相同,因此在传输的二进制日志中,除了查询语句,还包括了一些元数据信息,如当前的时间戳。即便如此,还存在着一些无法被正确复制的SQL。例如,使用CURRENT_USER()函数的语句。存储过程和触发器在使用基于语句的复制模式时也可能存在问题。\n另外一个问题是更新必须是串行的。这需要更多的锁——有时候要特别关注这一点。另外不是所有的存储引擎都支持这种复制模式。尽管这些存储引擎是包括在MySQL 5.5及之前版本中发行的。\n可以在MySQL手册与复制相关的章节中找到基于语句的复制存在的限制的完整列表。\n10.3.2 基于行的复制 # MySQL 5.1开始支持基于行的复制,这种方式会将实际数据记录在二进制日志中,跟其他数据库的实现比较相像。它有其自身的一些优点和缺点。最大的好处是可以正确地复制每一行。一些语句可以被更加有效地复制。\n基于行的复制没有向后兼容性,和MySQL 5.1一起发布的mysqlbinlog工具可以读取基于行的复制的事件格式(它对人是不可读的,但MySQL可以解释),但是早期版本的mysqlbinlog无法识别这类事件,在遇到错误时会退出。\n由于无须重放更新主库数据的查询,使用基于行的复制模式能够更高效地复制数据。重放一些查询的代价可能会很高。例如,下面有一个查询将数据从一个大表中汇总到小表:\nmysql\u0026gt; ** INSERT INTO summary_table(col1, col2, sum_col3)** -\u0026gt; ** SELECT col1, col2, sum(col3)** -\u0026gt; ** FROM enormous_table** -\u0026gt; ** GROUP BY col1, col2;** 想象一下,如果表enormous_table的列col1和col2有三种组合,这个查询可能在源表上扫描多次,但最终只在目标表上产生三行数据。但使用基于行的复制方式,在备库上开销会小很多。这种情况下,基于行的复制模式更加高效。\n但在另一方面,下面这条语句使用基于语句的复制方式代价会小很多:\nmysql\u0026gt; ** UPDATE enormous_table SET col1 = 0;** 由于这条语句做了全表更新,使用基于行的复制开销会很大,因为每一行的数据都会被记录到二进制日志中,这使得二进制日志事件非常庞大。并且会给主库上记录日志和复制增加额外的负载,更慢的日志记录则会降低并发度。\n由于没有哪种模式对所有情况都是完美的,MySQL能够在这两种复制模式间动态切换。默认情况下使用的是基于语句的复制方式,但如果发现语句无法被正确地复制,就切换到基于行的复制模式。还可以根据需要来设置会话级别的变量binlog_format,控制二进制日志格式。\n对于基于行的复制模式,很难进行时间点恢复,但这并非不可能。稍后讲到的日志服务器对此会有帮助。\n10.3.3 基于行或基于语句:哪种更优 # 我们已经讨论了这两种复制模式的优点和缺点,那么在实际应用中哪种方式更优呢?\n理论上基于行的复制模式整体上更优,并且在实际应用中也适用于大多数场景。但这种方式太新了以至于没有将一些特殊的功能加入到其中来满足数据库管理员的操作需求。因此一些人直到现在还没有开始使用。以下详细地阐述两种方式的优点和缺点,以帮助你决定哪种方式更合适。\n基于语句的复制模式的优点\n当主备的模式不同时,逻辑复制能够在多种情况下工作。例如,在主备上的表的定义不同但数据类型相兼容、列的顺序不同等情况。这样就很容易先在备库上修改schema,然后将其提升为主库,减少停机时间。基于语句的复制方式一般允许更灵活的操作。\n基于语句的方式执行复制的过程基本上就是执行SQL语句。这意味着所有在服务器上发生的变更都以一种容易理解的方式运行。这样当出现问题时可以很好地去定位。\n基于语句的复制模式的缺点\n很多情况下通过基于语句的模式无法正确复制,几乎每一个安装的备库都会至少碰到一次。事实上对于存储过程,触发器以及其他的一些语句的复制在5.0和5.1的一系列版本中存在大量的Bug。这些语句的复制的方式已经被修改了很多次,以使其更好地工作。简单地说:如果正在使用触发器或者存储过程,就不要使用基于语句的复制模式,除非能够清楚地确定不会碰到复制问题。\n基于行的复制模式的优点\n几乎没有基于行的复制模式无法处理的场景。对于所有的SQL构造、触发器、存储过程等都能正确执行。只是当你试图做一些诸如在备库修改表的schema这样的事情时才可能导致复制失败。\n这种方式同样可能减少锁的使用,因为它并不要求这种强串行化是可重复的。\n基于行的复制模式会记录数据变更,因此在二进制日志中记录的都是实际上在主库上发生了变化的数据。你不需要查看一条语句去猜测它到底修改了哪些数据。在某种程度上,该模式能够更加清楚地知道服务器上发生了哪些更改,并且有一个更好的数据变更记录。另外在一些情况下基于行的二进制日志还会记录发生改变之前的数据,因此这可能有利于某些数据恢复。\n在很多情况下,由于无须像基于语句的复制那样需要为查询建立执行计划并执行查询,因此基于行的复制占用更少的CPU。\n最后,在某些情况下,基于行的复制能够帮助更快地找到并解决数据不一致的情况。举个例子,如果是使用基于语句的复制模式,在备库更新一个不存在的记录时不会失败,但在基于行的复制模式下则会报错并停止复制。\n基于行的复制模式的缺点\n由于语句并没有在日志里记录,因此无法判断执行了哪些SQL,除了需要知道行的变化外,这在很多情况下也很重要(这可能在未来的MySQL版本中被修复)。\n使用一种完全不同的方式在备库进行数据变更——而不是执行SQL。事实上,执行基于行的变化的过程就像一个黑盒子,你无法知道服务器正在做什么。并且没有很好的文档和解释。因此当出现问题时,可能很难找到问题所在。例如,若备库使用一个效率低下的方式去寻找行记录并更新,你无法观察到这一点。\n如果有多层的复制服务器,并且所有的都被配置成基于行的复制模式,当会话级别的变量@@binlog_format被设置成STATEMENT时,所执行的语句在源服务器上被记录为基于语句的模式,但第一层的备库可能将其记录成行模式,并传递给其他层的备库。也就是说你期望的基于语句的日志在复制拓扑中将会被切换到基于行的模式。基于行的日志无法处理诸如在备库修改表的schema这样的情况,而基于语句的日志可以。\n在某些情况下,例如找不到要修改的行时,基于行的复制可能会导致复制停止,而基于语句的复制则不会。这也可以认为是基于行的复制的一个优点。该行为可以通过slave_exec_mode来进行配置。\n这些缺点正在被慢慢解决,但直到写作本书时,它们在大多数生产环境中依然存在。\n10.3.4 复制文件 # 让我们来看看复制会使用到的一些文件。前面已经介绍了二进制日志文件和中继日志文件,其实还有其他的文件会被用到。不同版本的MySQL默认情况下可能将这些文件放到不同的目录里,大多取决具体的配置选项。可能在data目录或者包含服务器.pid文件的目录下(对于类UNIX系统可能是*/var/run/mysqld*)。它们的详细介绍如下。\nmysql-bin.index\n当在服务器上开启二进制日志时,同时会生成一个和二进制日志同名的但以.index作为后缀的文件,该文件用于记录磁盘上的二进制日志文件。这里的“index”并不是指表的索引,而是说这个文件的每一行包含了二进制文件的文件名。\n你可能认为这个文件是多余的,可以被删除(毕竟MySQL可以在磁盘上找到它需要的文件)。事实上并非如此,MySQL依赖于这个文件,除非在这个文件里有记录,否则MySQL识别不了二进制日志文件。\nmysql-relay-bin-index\n这个文件是中继日志的索引文件,和mysql-bin.index的作用类似。\nmaster.info\n这个文件用于保存备库连接到主库所需要的信息,格式为纯文本(每行一个值),不同的MySQL版本,其记录的信息也可能不同。此文件不能删除,否则备库在重启后无法连接到主库。这个文件以文本的方式记录了复制用户的密码,所以要注意此文件的权限控制。\nrelay-log.info\n这个文件包含了当前备库复制的二进制日志和中继日志坐标(例如,备库复制在主库上的位置),同样也不要删除这个文件,否则在备库重启后将无法获知从哪个位置开始复制,可能会导致重放已经执行过的语句。\n使用这些文件来记录MySQL复制和日志状态是一种非常粗糙的方式。更不幸的是,它们不是同步写的。如果服务器断电并且文件数据没有被刷新到磁盘,在重启服务器后,文件中记录的数据可能是错误的。正如之前提到的,这些问题在MySQL 5.5里做了改进。\n以*.index作为后缀的文件也与设置expire_logs_days存在交互,该参数定义了MySQL清理过期日志的方式,如果文件mysql-bin.index在磁盘上不存在,在某些MySQL版本自动清理就会不起作用,甚至执行PURGE MASTER LOGS语句也没有用。这个问题的解决方法通常是使用MySQL服务器管理二进制日志,这样就不会产生误解(这意味着不应该使用rm*来自己清理日志)\n最好能显式地执行一些日志清理策略,比如设置expire_logs_days参数或者其他方式,否则MySQL的二进制日志可能会将磁盘撑满。当做这些事情时,还需要考虑到备份策略。\n10.3.5 发送复制事件到其他备库 # log_slave_updates选项可以让备库变成其他服务器的主库。在设置该选项后,MySQL会将其执行过的事件记录到它自己的二进制日志中。这样它的备库就可以从其日志中检索并执行事件。图10-2阐述了这一过程。\n图10-2:将复制事件传递到更多的备库\n在这种场景下,主库将数据更新事件写入二进制日志,第一个备库提取并执行这个事件。这时候一个事件的生命周期应该已经结束了,但由于设置了log_slave_updates,备库会将这个事件写到它自己的二进制日志中。这样第二个备库就可以将事件提取到它的中继日志中并执行。这意味着作为源服务器的主库可以将其数据变化传递给没有与其直接相连的备库上。默认情况下这个选项是被打开的,这样在连接到备库时就不需要重启服务器。\n当第一个备库将从主库获得的事件写入到其二进制日志中时,这个事件在备库二进制日志中的位置与其在主库二进制日志中的位置几乎肯定是不相同的,可能在不同的日志文件或文件内不同的位置。这意味着你不能假定所有拥有同一逻辑复制点的服务器拥有相同的日志坐标。稍后我们会提到,这种情况会使某些任务更加复杂,例如,修改一个备库的主库或将备库提升为主库。\n除非你已经注意到要给每个服务器分配一个唯一的服务器ID,否则按照这种方式配置备库会导致一些奇怪的错误,甚至还会导致复制停止。一个更常见的问题是:为什么要指定服务器ID,难道MySQL在不知道复制命令来源的情况下不能执行吗?为什么MySQL要在意服务器ID是全局唯一的。问题的答案在于MySQL在复制过程中如何防止无限循环。当复制SQL线程读中继日志时,会丢弃事件中记录的服务器ID和该服务器本身ID相同的事件,从而打破了复制过程中的无限循环。在某些复制拓扑结构下打破无限循环非常重要,例如主-主复制结构(5)。\n如果在设置复制的时候碰到问题,服务器ID应该是需要检查的因素之一。当然只检查@@server_id是不够的,它有一个默认值,除非在my.cnf文件或通过SET命令明确指定它的值,复制才会工作。如果使用SET命令,确保同时也更新了配置文件,否则SET命令的设定可能在服务器重启后丢失。\n10.3.6 复制过滤器 # 复制过滤选项允许你仅复制服务器上一部分数据,不过这可能没有想象中那么好用。有两种复制过滤方式:在主库上过滤记录到二进制日志中的事件,以及在备库上过滤记录到中继日志的事件。图10-3显示了这两种类型。\n图10-3:复制过滤选项\n使用选项binlog_do_db和binlog_ignore_db来控制过滤,稍后我们会解释为什么通常不需要开启它们,除非你乐于向老板解释为什么数据会永久丢失并且无法恢复。\n在备库上,可以通过设置replicate_*选项,在从中继日志中读取事件时进行过滤。你可以复制或忽略一个或多个数据库,把一个数据库重写到另外一个数据库,或使用类似LIKE的模式复制或忽略数据库表。\n要理解这些选项,最重要是弄清楚*_do_db和*_ignore_db在主库和备库上的意义,它们可能不会按照你所设想的那样工作。你可能会认为它会根据目标数据库名过滤,但实际上过滤的是当前的默认数据库(6)。也就是说,如果在主库上执行如下语句:\nmysql\u0026gt; ** USE test;** mysql\u0026gt; ** DELETE FROM sakila.film;** *_do_db和*_ignore_db都会在数据库test上过滤DELETE语句,而不是在sakila上。这通常不是想要的结果,可能会导致执行或忽略错误的语句。*_do_db和*_ignore_db有一些作用,但非常有限。必须要很小心地去使用这些参数,否则很容易造成主备不同步或复制出错。\nbinlog_do_db和binlog_ignore_db不仅可能会破坏复制,还可能会导致从某个时间点的备份进行数据恢复时失败。在大多数情况下都不应该使用这些参数。本章稍后部分我们展示了一些使用blackhole表进行复制过滤的方法。\n总地来说,复制过滤随时可能会发生问题。举个例子,假如要阻止赋权限操作传递给备库,这种需求是很普遍的。(提醒一下,这样做可能是错误的,有别的更好的方式来达成真正的目的)。过滤系统表的复制当然能够阻止GRANT语句的复制,但同样也会阻止事件和定时任务的复制。正是这些不可预知的后果,使用复制过滤要非常慎重。更好的办法是阻止一些特殊的语句被复制,通常是设置SQL_LOG_BIN=0,虽然这种方法也有它的缺点。总地来说,除非万不得已,不要使用复制过滤,因为它很容易中断复制并导致问题,在需要灾难恢复时也会带来极大的不方便。\n过滤选项在MySQL文档里介绍得很详细,因此本书不再重复更多的细节。\n10.4 复制拓扑 # 可以在任意个主库和备库之间建立复制,只有一个限制:每一个备库只能有一个主库。有很多复杂的拓扑结构,但即使是最简单的也可能会非常灵活。一种拓扑可以有多种用途。关于使用复制的不同方式可以很轻易地写一本书。\n我们已经讨论了如何为主库设置一个备库,本节我们讨论其他比较普遍的拓扑结构以及它们的优缺点。记住下面的基本原则:\n一个MySQL备库实例只能有一个主库。 每个备库必须有一个唯一的服务器ID。 一个主库可以有多个备库(或者相应的,一个备库可以有多个兄弟备库)。 如果打开了log_slave_updates选项,一个备库可以把其主库上的数据变化传播到其他备库。 10.4.1 一主库多备库 # 除了我们已经提过的两台服务器的主备结构外,这是最简单的拓扑结构。事实上一主多备的结构和基本配置差不多简单,因为备库之间根本没有交互(7),它们仅仅是连接到同一个主库上。图10-4显示了这种结构。\n在有少量写和大量读时,这种配置是非常有用的。可以把读分摊到多个备库上,直到备库给主库造成了太大的负担,或者主备之间的带宽成为瓶颈为止。你可以按照之前介绍的方法一次性设置多个备库,或者根据需要增加备库。\n图10-4:一主多备结构\n尽管这是非常简单的拓扑结构,但它非常灵活,能满足多种需求。下面是它的一些用途:\n为不同的角色使用不同的备库(例如添加不同的索引或使用不同的存储引擎)。 把一台备库当作待用的主库,除了复制没有其他数据传输。 将一台备库放到远程数据中心,用作灾难恢复。 延迟一个或多个备库,以备灾难恢复。 使用其中一个备库,作为备份、培训、开发或者测试使用服务器。 这种结构流行的原因是它避免了很多其他拓扑结构的复杂性。例如:可以方便地比较不同备库重放的事件在主库二进制日志中的位置。换句话说,如果在同一个逻辑点停止所有备库的复制,它们正在读取的是主库上同一个日志文件的相同物理位置。这是个很好的特性,可以减轻管理员许多工作,例如把备库提升为主库。\n这种特性只存在于兄弟备库之间。在没有直接的主备或者兄弟关系的服务器上去比较日志文件的位置要复杂很多。之后我们会提到的许多拓扑结构,例如树形复制或分布式主库,很难计算出复制的事件的逻辑顺序。\n10.4.2 主动-主动模式下的主-主复制 # 主-主复制(也叫双主复制或双向复制)包含两台服务器,每一个都被配置成对方的主库和备库,换句话说,它们是一对主库。图10-5显示了该结构。\n图10-5:主-主复制\n主动-主动模式下主-主复制有一些应用场景,但通常用于特殊的目的。一个可能的应用场景是两个处于不同地理位置的办公室,并且都需要一份可写的数据拷贝。\n这种配置最大的问题是如何解决冲突,两个可写的互主服务器导致的问题非常多。这通常发生在两台服务器同时修改一行记录,或同时在两台服务器上向一个包含AUTO_INCREMENT列的表里插入数据(8)。\nMySQL不支持多主库复制\n多主库复制(multisource replication)特指一个备库有多个主库。不管之前你知道什么,但MySQL(和其他数据库产品不一样)现在不支持如图10-6所示的结构,本章稍后我们会向你介绍如何模仿多主库复制。\n图10-6:MySQL不支持多主库复制\nMySQL 5.0增加了一些特性,使得这种配置稍微安全了点,就是设置auto_increment_increment和auto_increment_offset。通过这两个选项可以让MySQL自动为INSERT语句选择不互相冲突的值。然而允许向两台主库上写入仍然很危险。在两台机器上根据不同的顺序更新,可能会导致数据不同步。例如,一个只有一列的表,只有一行值为1的记录,假设同时执行下面两条语句:\n在第一台主库上:\nmysql\u0026gt; ** UPDATE tbl SET col=col + 1;** 在第二台主库上:\nmysql\u0026gt; ** UPDATE tbl SET col=col + 2;** 那么结果呢?一台服务器上值为4,另一台的值为3,并且没有报告任何复制错误。\n数据不同步还仅仅是开始。当正常的复制发生错误停止了,但应用仍在同时向两台服务器写入数据,这时候会发生什么呢?你不能简单地把数据从一台服务器复制到另外一台,因为这两台机器上需要复制的数据都可能发生了变化。解决这个问题将会非常困难。\n如果足够仔细地配置这种架构,例如很好地划分数据和权限,并且你很清楚自己在做什么,可以避免一些问题(9)。然而这通常很难做好,并且有更好的办法来实现你所需要的。\n总地来说,允许向两个服务器上写入所带来的麻烦远远大于其带来的好处,但下一节描述的主动-被动模式则会非常有用。\n10.4.3 主动-被动模式下的主-主复制 # 这是前面描述的主-主结构的变体,它能够避免我们之前讨论的问题。这也是构建容错性和高可用性系统的非常强大的方式,主要区别在于其中的一台服务器是只读的被动服务器,如图10-7所示。\n图10-7:主动-被动模式下的主-主复制\n这种方式使得反复切换主动和被动服务器非常方便,因为服务器的配置是对称的。这使得故障转移和故障恢复很容易。它也可以让你在不关闭服务器的情况下执行维护、优化表、升级操作系统(或者应用程序、硬件等)或其他任务。\n例如,执行ALTER TABLE操作可能会锁住整个表,阻塞对表的读和写,这可能会花费很长时间并导致服务中断。然而在主-主配置下,可以先停止主动服务器上的备库复制线程(这样就不会在被动服务器上执行任何更新),然后在被动服务器上执行ALTER操作,交换角色,最后在先前的主动服务器上(10)启动复制线程。这个服务器将会读取中继日志并执行相同的ALTER语句。这可能花费很长时间,但不要紧,因为该服务器没有为任何活跃查询提供服务。\n主动-被动模式的主-主结构能够帮助回避许多MySQL的问题和限制,此外还有一些工具可以完成这种类型的操作。\n让我们看看如何配置主-主服务器对,在两台服务器上执行如下设置后,会使其拥有对称的设置:\n确保两台服务器上有相同的数据。 启用二进制日志,选择唯一的服务器ID,并创建复制账号。 启用备库更新的日志记录,后面将会看到,这是故障转移和故障恢复的关键。 把被动服务器配置成只读,防止可能与主动服务器上的更新产生冲突,这一点是可选的。 启动每个服务器的MySQL实例。 将每个主库设置为对方的备库,使用新创建的二进制日志开始工作。 让我们看看主动服务器上更新时会发生什么事情。更新被记录到二进制日志中,通过复制传递给被动服务器的中继日志中。被动服务器执行查询并将其记录到自己的二进制日志中(因为开启了log_slave_updates选项)。由于事件的服务器ID与主动服务器的相同,因此主动服务器将忽略这些事件。在后面的“修改主库”可了解更多的角色切换相关内容。\n设置主动-被动的主-主拓扑结构在某种意义上类似于创建一个热备份,但是可以使用这个“备份”来提高性能,例如,用它来执行读操作、备份、“离线”维护以及升级等。真正的热备份做不了这些事情。然而,你不会获得比单台服务器更好的写性能(稍后会提到)。\n当我们讨论使用复制的场景和用途时,还会提到这种复制方式。它是一种非常常见并且重要的拓扑结构。\n10.4.4 拥有备库的主-主结构 # 另外一种相关的配置是为每个主库增加一个备库,如图10-8所示。\n图10-8:拥有备库的主-主结构\n这种配置的优点是增加了冗余,对于不同地理位置的复制拓扑,能够消除站点单点失效的问题。你也可以像平常一样,将读查询分配到备库上。\n如果在本地为了故障转移使用主-主结构,这种配置同样有用。当主库失效时,用备库来代替主库还是可行的,虽然这有点复杂。同样也可以把备库指向一个不同的主库,但需要考虑增加的复杂度。\n10.4.5 环形复制 # 如图10-9所示,双主结构实际上是环形结构的一种特例(11)。环形结构可以有三个或更多的主库。每个服务器都是在它之前的服务器的备库,是在它之后的服务器的主库。这种结构也称为环形复制(circular replication)。\n环形结构没有双主结构的一些优点,例如对称配置和简单的故障转移,并且完全依赖于环上的每一个可用节点,这大大增加了整个系统失效的几率。如果从环中移除一个节点,这个节点发起的事件就会陷入无限循环:它们将永远绕着服务器链循环。因为唯一可以根据服务器ID将其过滤的服务器是创建这个事件的服务器。总地来说,环形结构非常脆弱,应该尽量避免。\n图10-9:环形复制拓扑\n可以通过为每个节点增加备库的方式来减少环形复制的风险,如图10-10所示。但这仅仅防范了服务器失效的危险,断电或者其他一些影响到网络连接的问题都可能破坏整个环。\n图10-10:拥有备库的环形结构\n10.4.6 主库、分发主库以及备库 # 我们之前提到当备库足够多时,会对主库造成很大的负载。每个备库会在主库上创建一个线程,并执行binlog dump命令。该命令会读取二进制日志文件中的数据并将其发送给备库。每个备库都会重复这样的工作,它们不会共享binlog dump的资源。\n如果有很多备库,并且有大的事件时,例如一次很大的LOAD DATA INFILE操作,主库上的负载会显著上升,甚至可能由于备库同时请求同样的事件而耗尽内存并崩溃。另一方面,如果备库请求的数据不在文件系统的缓存中,可能会导致大量的磁盘检索,这同样会影响主库的性能并增加锁的竞争。\n因此,如果需要多个备库,一个好办法是从主库移除负载并使用分发主库。分发主库事实上也是一个备库,它的唯一目的就是提取和提供主库的二进制日志。多个备库连接到分发主库,这使原来的主库摆脱了负担。为了避免在分发主库上做实际的查询,可以将它的表修改为blackhole存储引擎,如图10-11所示。\n图10-11:一个主库、一个分发主库和多个备库\n很难说当备库数据达到多少时需要一个分发主库。按照通用准则,如果主库接近满负载,不应该为其建立10个以上的备库。如果有少量的写操作,或者只复制其中一部分表,主库就可以提供更多的复制。另外,也不一定只使用一个分发主库。如果需要的话,可以使用多个分发主库向大量的备库进行复制,或者使用金字塔状的分发主库。在某些情况下,可以通过设置slave_compressed_protocol来节约一些主库带宽。这对跨数据中心复制很有好处。\n还可以通过分发主库实现其他目的,例如,对二进制日志事件执行过滤和重写规则。这比在每个备库上重复进行日志记录、重写和过滤要高效得多。\n如果在分发主库上使用blackhole表,可以支持更多的备库。虽然会在分发主库执行查询,但其代价非常小,因为blackhole表中没有任何数据。blockhole表的缺点是其存在Bug,例如在某些情况下会忘记将自增ID写入到二进制日志中。所以要小心使用blackhole表(12)。\n一个比较常见的问题是如何确保分发服务器上的每个表都是blackhole存储引擎。如果有人在主库创建了一个表并指定了不同的存储引擎呢?确实,不管什么时候,在备库上使用不同的存储引擎总会导致同样的问题。常见的解决方案是设置服务器的storage_engine选项:\nstorage_engine=blackhole 这只会影响那些没有指定存储引擎的CREATE TABLE的语句。如果有一个无法控制的应用,这种拓扑结构可能会非常脆弱。可以通过skip_innodb选项禁止InnoDB,将表退化为MyISAM。但你无法禁止MyISAM或者Memory引擎。\n使用分发主库另外一个主要的缺点是无法使用一个备库来代替主库。因为由于分发主库的存在,导致各个备库与原始主库的二进制日志坐标已经不相同(13)。\n10.4.7 树或金字塔形 # 如果正在将主库复制到大量的备库中。不管是把数据分发到不同的地方,还是提供更高的读性能,使用金字塔结构都能够更好地管理,如图10-12所示。\n这种设计的好处是减轻了主库的负担,就像前一节提到的分发主库一样。它的缺点是中间层出现的任何错误都会影响到多个服务器。如果每个备库和主库直接相连就不会存在这样的问题。同样,中间层次越多,处理故障会更困难、更复杂。\n图10-12:金字塔形复制拓扑\n10.4.8 定制的复制方案 # MySQL的复制非常灵活,可以根据需要定制解决方案。典型的定制方案包括组合过滤、分发和向不同的存储引擎复制。也可以使用“黑客手段”,例如,从一个使用blackhole存储引擎的服务器上复制或复制到这样的服务器上(本章已讨论过)。可以根据需要任意设计。这其中最大的限制是合理地监控和管理,以及所拥有资源的约束(网络带宽、CPU能力等)。\n选择性复制 # 为了利用访问局部性原理(locality of reference),并将需要读的工作集驻留在内存中,可以复制少量数据到备库中。如果每个备库只拥有主库的一部分数据,并且将读分配给备库,就可以更好地利用备库的内存。并且每个备库也只有主库一部分的写入负载,这样主库的能力更强并能保证备库延迟。\n这个方案有点类似下一章我们会讨论到的水平数据划分,但它的优势在于主库包含了所有的数据集,这意味着无须为了一条写入查询去访问多个服务器。如果读操作无法在备库上找到数据,还可以通过主库来查询。即使不能从备库上读取所有数据,也可以移除大量的主库读负担。\n最简单的方法是在主库上将数据划分到不同的数据库里。然后将每个数据库复制到不同的备库上。例如,若需要将公司的每一个部门的数据复制到不同的备库,可以创建名为sales、marketing、procurement等的数据库,每个备库通过选项replicate_wild_do_table选项来限制给定数据库的数据。下面是sales数据库的配置:\nreplicate_wild_do_table = sales.% 也可以通过一台分发主库进行分发。举个例子,如果想通过一个很慢或者非常昂贵的网络,从一台负载很高的数据库上复制一部分数据,就可以使用一个包含blackhole表和过滤规则的本地分发主库,分发主库可以通过复制过滤移除不需要的日志。这可以避免在主库上进行不安全的日志选项设定,并且无须传输所有的数据到远程备库。\n分离功能 # 许多应用都混合了在线事务处理(OLTP)和在线数据分析(OLAP)的查询。OLTP查询比较短并且是事务型的,OLAP查询则通常很大,也很慢,并且不要求绝对最新的数据。这两种查询给服务器带来的负担完全不同,因此它们需要不同的配置,甚至可能使用不同的存储引擎或者硬件。\n一个常见的办法是将OLTP服务器的数据复制到专门为OLAP工作负载准备的备库上。这些备库可以有不同的硬件、配置、索引或者不同的存储引擎。如果决定在备库上执行OLAP查询,就可能需要忍受更大的复制延迟或降低备库的服务质量。这意味着在一个非专用的备库上执行一些任务时,可能会导致不可接受的性能,例如执行一条长时间运行的查询。\n无须做一些特殊的配置,除了需要选择忽略主库上的一些数据,前提是能获得明显的提升。即使通过复制过滤器过滤掉一小部分的数据也会减少I/O和缓存活动。\n数据归档 # 可以在备库上实现数据归档,也就是说可以在备库上保留主库上删除过的数据,在主库上通过delete语句删除数据是确保delete语句不传递到备库就可以实现。有两种通常的办法:一种是在主库上选择性地禁止二进制日志,另一种是在备库上使用replicate_ignore_db规则(是的,两种方法都很危险)。\n第一种方法需要先将SQL_LOG_BIN设置为0,然后再进行数据清理。这种方法的好处是不需要在备库进行任何配置,由于SQL语句根本没有记录到二进制日志中,效率会稍微有所提升。最大缺点也正因为没有将在主库的修改记录下来,因此无法使用二进制日志来进行审计或者做按时间点的数据恢复。另外还需要SUPER权限。\n第二种方法是在清理数据之前对主库上特定的数据库使用USE语句。例如,可以创建一个名为purge的数据库,然后在备库的my.cnf文件里设置replicate_ignore_db=purge并重启服务器。备库将会忽略使用了USE语句指定的数据库。这种方法没有第一种方法的缺点,但有另一个小小的缺点:备库需要去读取它不需要的事件。另外,也可能有人在purge数据库上执行非清理查询,从而导致备库无法重放该事件。\nPercona Toolkit中的pt-archiver支持以上两种方式。\n第三种办法是利用binlog_ignore_db来过滤复制事件。但正如之前提到的,这是一种很危险的操作。\n将备库用作全文检索 # 许多应用要求合并事务和全文检索。然而在写作本书时,仅有MyISAM支持全文检索,但是MyISAM不支持事务(在MySQL 5.6有一个实验室预览版本实现了InnoDB的全文检索,但尚未GA)。一个普遍的做法是配置一台备库,将某些表设置为MyISAM存储引擎,然后创建全文索引并执行全文检索查询。这避免了在主库上同时使用事务型和非事务型存储引擎所带来的复制问题,减轻了主库维护全文索引的负担。\n只读备库 # 许多机构选择将备库设置为只读,以防止在备库进行的无意识修改导致复制中断。可以通过设置read_only选项来实现。它会禁止大部分写操作,除了复制线程和拥有超级权限的用户以及临时表操作。只要不给也不应该给普通用户超级权限,这应该是很完美的方法。\n模拟多主库复制 # 当前MySQL不支持多主库复制(一个备库拥有多个主库)。但是可以通过把一台备库轮流指向多台主库的方式来模拟这种结构。例如,可以先将备库指向主库A,运行片刻,再将其指向主库B并运行片刻,然后再次切换回主库A。这种办法的效果取决于数据以及两台主库导致备库所需完成的工作量。如果主库的负载很低,并且主库之间不会产生更新冲突,就会工作得很好。\n需要做一些额外的工作来为每个主库跟踪二进制日志坐标。可能还需要保证备库的I/O线程在每一次循环提取超过需要的数据,否则可能会因为每次循环反复地提取和抛弃大量数据导致主库的网络流量和开销明显增大。\n还可以使用主-主(或者环形)复制结构以及使用blackhole存储引擎表的备库来进行模拟,如图10-13所示。\n图10-13:使用双主结构和blackhole存储引擎表模拟多主复制\n在这种配置中,两台主库拥有自己的数据,但也包含了对方的表,但是对方的表使用blackhole存储引擎以避免在其中存储实际数据。备库和其中任意一个主库相连都可以。备库不使用blackhole存储引擎,因此其对两个主库而言都是有效的。\n事实上并不一定需要主-主拓扑结构来实现,可以简单地将server1复制到server2,再从server2复制到备库。如果在server2上为从server1上复制的数据使用blackhole存储引擎,就不会包含任何server1的数据,如图10-14所示。\n图10-14:另一种模拟多主复制的方法\n这些配置方法常常会碰到一些常见的问题,例如,更新冲突或者建表时明确指定存储引擎。\n另外一个选择是使用Continuent的Tungsten Replicator,我们会在本章稍后部分讨论。\n创建日志服务器 # 使用MySQL复制的另一种用途就是创建没有数据的日志服务器。它唯一的目的就是更加容易重放并且/或者过滤二进制日志事件。就如本章稍后所述,它对崩溃后重启复制很有帮助。同时对基于时间点的恢复也很有帮助,在第15章我们会讨论。\n假设有一组二进制日志或中继日志——可能从备份或者一台崩溃的服务器上获取——希望能够重放这些日志中的事件,可以通过mysqlbinlog工具从其中提取出事件,但更加方便和高效的方法是配置一个没有任何数据的MySQL实例并使其认为这些二进制日志是它拥有的。如果只是临时需要,可以从 http://mysqlsandbox.net上获得一个MySQL沙箱脚本来创建日志服务器。因为无须执行二进制日志,日志服务器也就不需要任何数据。它的目的仅仅是将数据提供给别的服务器(但复制账户还是需要的)。\n我们来看看该策略是如何工作的(稍后会展示一些相关应用)。假设日志被命名为somelog-bin.000001、somelog-bin.000002,等等,将这些日志放到日志服务器的日志文件夹中,假设为*/var/log/mysql*。然后在启动服务器前编辑my.cnf文件,如下所示:\nlog_bin = /var/log/mysql/somelog-bin log_bin_index = /var/log/mysql/somelog-bin.index 服务器不会自动发现日志文件,因此还需要更新日志的索引文件。下面这个命令可以在类UNIX系统上完成(14)。\n** # /bin/ls -1 /var/log/mysql/somelog-bin.[0-9]* \u0026gt; /var/log/mysql/somelog-bin.index** 确保运行MySQL的账户能够读写日志索引文件。现在可以启动日志服务器并通过SHOW MASTER LOGS命令来确保其找到日志文件。\n为什么使用日志服务器比用mysqlbinlog来实现恢复更好呢?有以下几个原因:\n复制作为应用二进制日志的方法已经被大量的用户所测试,能够证明是可行的。mysqlbinlog并不能确保像复制那样工作,并且可能无法正确生成二进制日志中的数据更新。 复制的速度更快,因为无须将语句从日志导出来并传送给MySQL。 可以很容易观察到复制过程。 能够更方便处理错误。例如,可以跳过执行失败的语句。 更方便过滤复制事件。 有时候mysqlbinlog会因为日志记录格式更改而无法读取二进制日志。 10.5 复制和容量规划 # 写操作通常是复制的瓶颈,并且很难使用复制来扩展写操作。当计划为系统增加复制容量时,需要确保进行了正确的计算,否则很容易犯一些复制相关的错误。\n例如,假设工作负载为20%的写以及80%的读。为了计算简单,假设有以下前提:\n读和写查询包含同样的工作量。 所有的服务器是等同的,每秒能进行1000次查询。 备库和主库有同样的性能特征。 可以把所有的读操作转移到备库。 如果当前有一个服务器能支持每秒1000次查询,那么应该增加多少备库才能处理当前两倍的负载,并将所有的读查询分配给备库?\n看上去应该增加两个备库并将1 600次读操作平分给它们。但是不要忘记,写入负载同样增加到了400次每秒,并且无法在主备服务器之间进行分摊。每个备库每秒必须处理400次写入,这意味着每个备库写入占了40%,只能每秒为600次查询提供服务。因此,需要三台而不是两台备库来处理双倍负载。\n如果负载再增加一倍呢?将有每秒800次写入,这时候主库还能处理,但备库的写入同样也提升到80%,这样就需要16台备库来处理每秒3 200次读查询。并且如果再增加一点负载,主库也会无法承担。\n这远远不是线性扩展,查询数量增加4倍,却需要增加17倍的服务器。这说明当为单台主库增加备库时,将很快达到投入远高于回报的地步。这仅仅是基于上面的假设,还忽略了一些事情,例如,单线程的基于语句的复制常常导致备库容量小于主库。真实的复制配置比我们的理论计算还要更差。\n10.5.1 为什么复制无法扩展写操作 # 糟糕的服务容量比例的根本原因是不能像分发读操作那样把写操作等同地分发到更多服务器上。换句话说,复制只能扩展读操作,无法扩展写操作。\n你可能想知道到底有没有办法使用复制来增加写入能力。答案是否定的,根本不行。对数据进行分区是唯一可以扩展写入的方法,我们在下一章会讲到。\n一些读者可能会想到使用主-主拓扑结构(参阅前面介绍的“主动-主动模式下的主-主复制”)并为两个服务器执行写操作。这种配置比主备结构能支持稍微多一点的写入,因为可以在两台服务器之间共享串行化带来的开销。如果每台服务器上执行50%的写入,那复制的执行量也只有50%需要串行化。理论上讲,这比在一台机器上(主库)对100%的写入并发执行,而在另外一台机器(备库)上对100%的写入做串行化要更优。这可能看起来很吸引人,然而这种配置还比不上单台服务器能支持的写入。一个有50%的写入被串行化的服务器性能比一台全部写入都并行化的服务器性能要低。\n这是这种策略不能扩展写入的原因。它只能在两台服务器间共享串行化写入的缺点。所以“链中最弱的一环”并不是那么弱,它只提供了比主动-被动复制稍微好点的性能,但是增加了很大的风险,通常不能带来任何好处,具体原因见下一节。\n10.5.2 备库什么时候开始延迟 # 一个关于备库比较普遍的问题是如何预测备库会在何时跟不上主库。很难去描述备库使用的复制容量为5%与95%的区别,但是至少能够在接近饱和前预警并估计复制容量。\n首先应该观察复制延迟的尖刺。如果有复制延迟的曲线图,需要注意到图上的一些短暂的延迟骤升,这时候可能负载加大,备库短时间内无法跟上主库。当负载接近耗尽备库的容量时,会发现曲线上的凸起会更高更宽。前面曲线的上升角度不变,但随后当备库在产生延迟后开始追赶主库时,将会产生一个平缓的斜坡。这些突起的出现和增长是一个警告信息,意味着已经接近容量限制。\n为了预测在将来的某个时间点会发生什么,可以人为地制造延迟,然后看多久备库能赶上主库。目的是为了明确地说明曲线上的斜坡的陡度。如果将备库停止一个小时,然后开启并在1小时内追赶上,说明正常情况下只消耗了一半的容量。也就是说,如果中午12:00停止备库复制,在1:00开启,并且在2:00追赶上,备库在一小时内完成了两个小时内所有的变更,说明复制可以在双倍速度下运行。\n最后,如果使用的是Percona Server或者MariaDB,也可以直接获取复制的利用率。打开服务器变量userstat,然后执行如下语句:\nmysql\u0026gt; ** SELECT * FROM INFORMATION_SCHEMA.USER_STATISTICS** -\u0026gt; ** WHERE USER='#mysql_system#'\\G** *************************** 1. row *************************** USER: #mysql_system# OTAL_CONNECTIONS: 1 CONCURRENT_CONNECTIONS: 2 CONNECTED_TIME: 46188 BUSY_TIME: 719 ROWS_FETCHED: 0 ROWS_UPDATED: 1882292 SELECT_COMMANDS: 0 UPDATE_COMMANDS: 580431 OTHER_COMMANDS: 338857 COMMIT_TRANSACTIONS: 1016571 ROLLBACK_TRANSACTIONS: 0 可以将BUSY_TIME和CONNECTED_TIME的一半(因为备库有两个复制线程)做比较,来观察备库线程实际执行命令所花费的时间(15)。在我们的例子里,备库大约使用了其3%的能力,这并不意味着它不会遇到偶然的延迟尖刺——如果主库运行了一个超过10分钟才完成的变更,可能延迟的时间和变更执行的时间是相同的——但这很好地暗示了备库能够很快从一个延迟尖刺中恢复。\n10.5.3 规划冗余容量 # 在构建一个大型应用时,有意让服务器不被充分使用,这应该是一种聪明并且划算的方式,尤其在使用复制的时候。有多余容量的服务器可以更好地处理负载尖峰,也有更多能力处理慢速查询和维护工作(如OPTIMIZE TABLE操作),并且能够更好地跟上复制。\n试图同时向主-主拓扑结构的两个节点写入来减少复制问题通常是不划算的。分配给每台机器的读负载应该低于50%,否则,如果某台服务器失效,就没有足够的容量了。如果两台服务器都能够独立处理负载,就用不着担心复制的问题了。\n构建冗余容量也是实现高可用性的最佳方式之一,当然还有别的方式,例如,当错误发生时让应用在降级模式下运行,第12章会介绍更多的细节。\n10.6 复制管理和维护 # 配置复制一般来说不会是需要经常做的工作,除非有很多服务器。但是一旦配置了复制,监控和管理复制拓扑应该成为一项日常工作,不管有多少服务器。\n这些工作应该尽量自动化,但不一定需要自己写工具来实现:在16章我们讨论了几个MySQL工具,其中许多都拥有内建的监控复制的能力或插件。\n10.6.1 监控复制 # 复制增加了MySQL监控的复杂性。尽管复制发生在主库和备库上,但大多数工作是在备库上完成的,这也正是最常出问题的地方。是否所有的备库都在工作?最慢的备库延迟是多大?MySQL本身提供了大量可以回答上述问题的信息,但要实现自动化监控过程以及使复制更健壮还是需要用户做更多的工作。\n在主库上,可以使用SHOW MASTER STATUS命令来查看当前主库的二进制日志位置和配置(详细参阅前面介绍的“配置主库和备库”部分)。还可以查看主库当前有哪些二进制日志是在磁盘上的:\n该命令用于给PURGE MASTER LOGS命令决定使用哪个参数。另外还可以通过SHOW BINLOG EVENTS来查看复制事件。例如,在运行前一个命令后,我们在另一个不曾使用过的服务器上创建一个表,因为知道这是唯一改变数据的语句,而且也知道语句在二进制日志中的偏移量是13634,所以我们可以看到如下内容:\nmysql\u0026gt; ** SHOW BINLOG EVENTS IN 'mysql-bin.000223' FROM 13634\\G** *************************** 1. row *************************** Log_name: mysql-bin.000223 Pos: 13634 Event_type: Query Server_id: 1 End_log_pos: 13723 Info: use `test`; CREATE TABLE test.t(a int) 10.6.2 测量备库延迟 # 一个比较普遍的问题是如何监控备库落后主库的延迟有多大。虽然SHOW SLAVE STATUS输出的Seconds_behind_master列理论上显示了备库的延时,但由于各种各样的原因,并不总是准确的:\n备库Seconds_behind_master值是通过将服务器当前的时间戳与二进制日志中的事件的时间戳相对比得到的,所以只有在执行事件时才能报告延迟。 如果备库复制线程没有运行,就会报延迟为NULL。 一些错误(例如主备的max_allowed_packet不匹配,或者网络不稳定)可能中断复制并且/或者停止复制线程,但Seconds_behind_master将显示为0而不是显示错误。 即使备库线程正在运行,备库有时候可能无法计算延时。如果发生这种情况,备库会报0或者NULL。 一个大事务可能会导致延迟波动,例如,有一个事务更新数据长达一个小时,最后提交。这条更新将比它实际发生时间要晚一个小时才记录到二进制日志中。当备库执行这条语句时,会临时地报告备库延迟为一个小时,然后又很快变成0。 如果分发主库落后了,并且其本身也有已经追赶上它的备库,备库的延迟将显示为0,而事实上和源主库之间是有延迟的。 解决这些问题的办法是忽略Seconds_behind_master的值,并使用一些可以直接观察和衡量的方式来监控备库延迟。最好的解决办法是使用heartbeat record,这是一个在主库上会每秒更新一次的时间戳。为了计算延时,可以直接用备库当前的时间戳减去心跳记录的值。这个方法能够解决刚刚我们提到的所有问题,另外一个额外的好处是我们还可以通过时间戳知道备库当前的复制状况。包含在Percona Toolkit里的pt-heartbeat脚本是“复制心跳”最流行的一种实现。\n心跳还有其他好处,记录在二进制日志中的心跳记录拥有许多用途,例如在一些很难解决的场景下可以用于灾难恢复。\n我们刚刚所描述的几种延迟指标都不能表明备库需要多长时间才能赶上主库。这依赖于许多因素,例如备库的写入能力以及主库持续写入的次数。关于这个话题,详细参阅前面介绍的“何时备库开始延迟”。\n10.6.3 确定主备是否一致 # 在理想情况下,备库和主库的数据应该是完全一样的。但事实上备库可能发生错误并导致数据不一致。即使没有明显的错误,备库同样可能因为MySQL自身的特性导致数据不一致,例如MySQL的Bug、网络中断、服务器崩溃,非正常关闭或者其他一些错误。(16)\n按照我们的经验来看,主备一致应该是一种规范,而不是例外,也就是说,检查你的主备一致性应该是一个日常工作,特别是当使用备库来做备份时尤为重要,因为你肯定不希望从一个已经损坏的备库里获得备份数据。\nMySQL并没有内建的方法来比较一台服务器与别的服务器的数据是否相同。它提供了一些组件来为表和数据生成校验值,例如CHECKSUM TABLE。但当复制正在进行时,这种方法是不可行的。\nPercona Toolkit里的pt-table-checksum能够解决上述几个问题。其主要特性是用于确认备库与主库的数据是否一致。工作方式是通过在主库上执行INSERT\u0026hellip;SELECT查询。\n这些查询对数据进行校验并将结果插入到一个表中。这些语句通过复制传递到备库,并在备库执行一遍,然后可以比较主备上的结果是否一样。由于该方法是通过复制工作的,它能够给出一致的结果而无须同时把主备上的表都锁上。\n通常情况下可以在主库上运行该工具,参数如下:\n$ ** pt-table-checksum --replicate=test.checksum \u0026lt;master_host\u0026gt;** 该命令将检查所有的表,并将结果插入到test.checksum表中。当查询在备库执行完后,就可以简单地比较主备之间的不同了。pt-table-checksum能够发现服务器所有的备库,在每台备库上运行查询,并自动地输出结果。在写作本书时,pt-table-checksum是唯一能够有效地比较主备一致性的工具。\n10.6.4 从主库重新同步备库 # 在你的职业生涯中,也许会不止一次需要去处理未被同步的备库。可能是使用校验工具发现了数据不一致,或是因为已经知道是备库忽略了某条查询或者有人在备库上修改了数据。\n传统的修复不一致的办法是关闭备库,然后重新从主库复制一份数据。当备库数据不一致的问题可能导致严重后果时,一旦发现就应该将备库停止并从生产环境移除,然后再从一个备份中克隆或恢复备库。\n这种方法的缺点是不太方便,特别是数据量很大时。如果能够找出并修复不一致的数据,要比从其他服务器上重新克隆数据要有效得多。如果发现的不一致并不严重,就可以保持备库在线,并重新同步受影响的数据。\n最简单的办法是使用mysqldump转储受影响的数据并重新导入。在整个过程中,如果数据没有发生变化,这种方法会很好。你可以在主库上简单地锁住表然后进行转储,再等待备库赶上主库,然后将数据导入到备库中。(需要等待备库赶上主库,这样就不至于为其他表引入新的不一致,例如那些可能通过和失去同步的表做join后进行数据更新的表)。\n虽然这种方法在许多场景下是可行的,但在一个繁忙的服务器上有可能行不通。另外一个缺点是在备库上通过非复制的方式改变数据。通过复制改变备库数据(通过在主库上执行更新)通常是一种安全的技术,因为它避免了竞争条件和其他意料外的事情。如果表很大或者网络带宽受限,转储和重载数据的代价依然很高。当在一个有一百万行的表上只有一千行不同的数据呢?转储和重载表的数据是非常浪费资源的。\npt-table-sync是Percona Toolkit中的另外一个工具,可以解决该问题。该工具能够高效地查找并解决表之间的不同。它同样通过复制工作,在主库上执行查询,在备库上重新同步,这样就没有竞争条件。它是结合pt-table-checksum生成的checksum表来工作的,所以只能操作那些已知不同步的表的数据块。但该工具不是在所有场景下都有效。为了正确地同步主库和备库,该工具要求复制是正常的,否则就无法工作。pt-table-sync设计得很高效,但当数据量非常大时效率还是会很低。比较主库和备库上1TB的数据不可避免地会带来额外的工作。尽管如此,在那些合适的场景中,该工具依然能节约大量的时间和工作。\n10.6.5 改变主库 # 迟早会有把备库指向一个新的主库的需求。也许是为了更迭升级服务器,或者是主库出现问题时需要把一台备库转换成主库,或者只是希望重新分配容量。不管出于什么原因,都需要告诉其他的备库新主库的信息。\n如果这是计划内的操作,会比较容易(至少比紧急情况下要容易)。只需在备库简单地使用CHANGE MASTER TO命令,并指定合适的值。大多数值都是可选的。只需要指定需要改变的项即可。备库将抛弃之前的配置和中继日志并从新的主库开始复制。同样新的参数会被更新到master.info文件中,这样就算重启,备库配置信息也不会丢失。\n整个过程中最难的是获取新主库上合适的二进制日志位置,这样备库才可以从和老主库相同的逻辑位置开始复制。\n把备库提升为主库要更困难一点。有两种场景需要将备库替换为主库,一种是计划内的提升,一种是计划外的提升。\n计划内的提升 # 把备库提升为主库理论上是很简单的。简单来说,有以下步骤:\n停止向老的主库写入。 让备库追赶上主库(可选的,会简化下面的步骤)。 将一台备库配置为新的主库。 将备库和写操作指向新的主库,然后开启主库的写入。 但这其中还隐藏着很多细节。一些场景可能依赖于复制的拓扑结构。例如,主-主结构和主-备结构的配置就有所不同。\n更深入一点,下面是大多数配置需要的步骤:\n停止当前主库上的所有写操作。如果可以,最好能将所有的客户端程序关闭(除了复制连接)。为客户端程序建立一个“do not run”这样的类似标记可能会有所帮助。如果正在使用虚拟IP地址,也可以简单地关闭虚拟IP,然后断开所有的客户端连接以关闭其打开的事务。 通过FLUSH TABLES WITH READ LOCK在主库上停止所有活跃的写入,这一步是可选的。也可以在主库上设置read_only选项。从这一刻开始,应该禁止向即将被替换的主库做任何写入。因为一旦它不是主库,写入就意味着数据丢失。注意,即使设置read_only也不会阻止当前已存在的事务继续提交。为了更好地保证这一点,可以“kill”所有打开的事务,这将会真正地结束所有写入。 选择一个备库作为新的主库,并确保它已经完全跟上主库(例如,让它执行完所有从主库获得的中继日志)。 确保新主库和旧主库的数据是一致的。可选。 在新主库上执行STOP SLAVE。 在新主库上执行CHANGE MASTER TO MASTER_HOST=\u0026rsquo;\u0026rsquo;,然后再执行RESET SLAVE,使其断开与老主库的连接,并丢弃master.info里记录的信息(如果连接信息记录在my.cnf里,会无法正确工作,这也是我们建议不要把复制连接信息写到配置文件里的原因之一)。 执行SHOW MASTER STATUS记录新主库的二进制日志坐标。 确保其他备库已经追赶上。 关闭旧主库。 在MySQL 5.1及以上版本中,如果需要,激活新主库上事件。 将客户端连接到新主库。 在每台备库上执行CHANGE MASTER TO语句,使用之前通过SHOW MASTER STATUS获得的二进制日志坐标,来指向新的主库。 当将备库提升为主库时,要确保备库上任何特有的数据库、表和权限已经被移除。可能还需要修改备库特有的配置选项,例如innodb_flush_log_at_trx_commit选项。同样的,如果是把主库降级为备库,也要保证进行需要的配置。\n如果主备的配置相同,就不需要做任何改变。\n计划外的提升 # 当主库崩溃时,需要提升一台备库来代替它,这个过程可能就不太容易。如果只有一台备库,可以直接使用这台备库。但如果有超过一台的备库,就需要做一些额外的工作。\n另外,还有潜在的丢失复制事件的问题。可能有主库上已发生的修改还没有更新到它的任何一台备库上的情况。甚至还可能一条语句在主库上执行了回滚,但在备库上没有回滚,这样备库可能超过主库的逻辑复制位置(17)。如果能在某一点恢复主库的数据,也许就可以取得丢失的语句并手动执行它们。\n在以下步骤中,需要确保在计算中使用Master_Log_File和Read_Master_Log_Pos的值。以下是对主备拓扑结构中的备库进行提升的过程:\n确定哪台备库的数据最新。检查每台备库上SHOW SLAVE STATUS命令的输出,选择其中Master_Log_File/read_Master_Log_Pos的值最新的那个。 让所有备库执行完所有其从崩溃前的旧主库那获得的中继日志。如果在未完成前修改备库的主库,它会抛弃剩下的日志事件,从而无法获知该备库在什么地方停止。 执行前一小节的5~7步。 比较每台备库和新主库上的Master_Log_File/Read_Master_Log_Pos的值。 执行前一小节的10~12步。 正如本章开始我们推荐的,假设已经在所有的备库上开启了log_bin和log_slave_updates,这样可以帮助你将所有的备库恢复到一个一致的时间点,如果没有开启这两个选项,则不能可靠地做到这一点。\n确定期望的日志位置 # 如果有备库和新主库的位置不相同,则需要找到该备库最后一条执行的事件在新主库的二进制日志中相应的位置,然后再执行CHANGE MASTER TO。可以通过mysqlbinlog工具来找到备库执行的最后一条查询,然后在主库上找到同样的查询,进行简单的计算即可得到。\n为了便于描述,假设每个日志事件有一个自增的数字ID,最新的备库,也就是新主库,在旧主库崩溃时获得了编号为100的事件,假设有另外两台备库:replica2和replica3。replica2已经获取了99号事件,replica3获取了98号事件。如果把两台备库都指向新主库的同一个二进制日志位置,它们将从101号事件开始复制,从而导致数据不同步。但只要新主库的二进制日志已经通过log_slave_updates打开,就可以在新主库的二进制日志中找到99号和100号日志,从而将备库恢复到一致的状态。\n由于服务器重启,不同的配置,日志轮转或者FLUSH LOGS命令,同一个事件在不同的服务器上可能有不同的偏移量。找到这些事件可能会耗时很长并且枯燥,但是通常没有难度。通过mysqlbinlog从二进制日志或中继日志中解析出每台备库上执行的最后一个事件,并同样使用该命令解析新主库上的二进制日志,找到相同的查询,mysqlbinlog会打印出该事件的偏移量,在CHANGE MASTER TO命令中使用这个值(18)。\n更快的方法是把新主库和停止的备库上的字节偏移量相减,它显示了字节位置的差异。然后把这个值和新主库当前二进制日志的位置相减,就可以得到期望的查询的位置。只需要验证一下就可以据此启动备库。\n让我们看看一个相关的例子,假设server1是server2和server3的主库,其中服务器server1已经崩溃。根据SHOW SLAVE STATUS获得Master_Log_File/Read_Master_Log_Pos的值,server2已经执行完了server1上所有的二进制日志,但server3还不是最新数据。图10-15显示了这个场景(日志事件和偏移量仅仅是为了举例)。\n正如图10-15所示,我们可以肯定server2已经执行完了主库上的所有二进制日志,因为Master_Log_File和Read_Master_Log_Pos值和server1上最后的日志位置是相吻合的,因此我们可以将server2提升为新主库,并将server3设置为server2的备库。\n图10-15:当server1崩溃,server2已追赶上,但server3的复制落后\n应该在server3上为需要执行的CHANGE MASTER TO语句赋予什么样的参数呢?这里需要做一点点计算和调查。server3在偏移量1493停止,比server2执行的最后一条语句的偏移量1582要小89字节。server2正在向偏移量为8167的二进制日志写入,8167-89= 8078,因此理论上我们应该将server3指向server2的日志的偏移量为8078的位置。最好去确认下这个位置附近的日志事件,以确定在该位置上是否是正确的日志事件,因为可能有别的例外,例如有些更新可能只发生在server2上。\n假设我们观察到的事件是一样的,下面这条命令会将server3切换为server2的备库。\nserver2\u0026gt; ** CHANGE MASTER TO MASTER_HOST=\u0026quot;server2\u0026quot;, MASTER_LOG_FILE=\u0026quot;mysql-bin.000009\u0026quot;,** ** MASTER_LOG_POS=8078;** 如果服务器在它崩溃时已经执行完成并记录了超过一个事件,会怎么样呢?因为server2仅仅读取并执行到了偏移位置1582,你可能永远地失去了一个事件。但是如果老主库的磁盘没有损坏,仍然可以通过mysqlbinlog或者从日志服务器的二进制日志中找到丢失的事件。\n如果需要从老主库上恢复丢失的事件,建议在提升新主库之后且在允许客户端连接之前做这件事情。这样就无须在每台备库上都执行丢失的事件,只需使用复制来完成。但如果崩溃的老主库完全不可用,就不得不等待,稍后再做这项工作。\n上述流程中一个可调整的地方是使用可靠的方式来存储二进制日志,如SAN或分布式复制数据库设备(DRBD)。即使主库完全失效,依然能够获得它的二进制日志。也可以设置一个日志服务器,把备库指向它,然后让所有备库赶上主库失效的点。这使得提升一个备库为新的主库没那么重要,本质上这和计划中的提升是相同的。我们将在下一章进一步讨论这些存储选项。\n当提升一台备库为主库时,千万不要将它的服务器ID修改成原主库的服务器ID,否则将不能使用日志服务器从一个旧主库来重放日志事件。这也是确保服务器ID最好保持不变的原因之一。\n10.6.6 在一个主-主配置中交换角色 # 主-主复制拓扑结构的一个好处就是可以很容易地切换主动和被动的角色,因为其配置是对称的。本小节介绍如何完成这种切换。\n当在主-主配置下切换角色时,必须确保任何时候只有一个服务器可以写入。如果两台服务器交叉写入,可能会导致写入冲突。换句话说,在切换角色后,原被动服务器不应该接收到主动服务器的任何二进制日志。可以通过确保原被动服务器的复制SQL线程在该服务器可写之前已经赶上主动服务器来避免。\n通过以下步骤切换服务器角色,可以避免更新冲突的危险:\n停止主动服务器上的所有写入。 在主动服务器上执行SET GLOBAL read_only=1,同时在配置文件里也设置一下read_only,防止重启后失效。但记住这不会阻止拥有超级权限的用户更改数据。如果想阻止所有人更改数据,可以执行FLUSH TABLES WITH READ LOCK。如果没有这么做,你必须kill所有的客户端连接以保证没有长时间运行的语句或者未提交的事务。 在主动服务器上执行SHOW MASTER STATUS并记录二进制日志坐标。 使用主动服务器上的二进制日志坐标在被动服务器上执行SELECT MASTER_POS_WAIT()。该语句将阻塞住,直到复制跟上主动服务器。 在被动服务器上执行SET GLOBAL read_only=0,这样就变换成主动服务器。 修改应用的配置,使其写入到新的主动服务器中。 可能还需要做一些额外的工作,包括更改两台服务器的IP地址,这取决于应用的配置,我们将在下一节讨论这个话题。\n10.7 复制的问题和解决方案 # 中断MySQL的复制并不是件难事。因为实现简单,配置相当容易,但也意味着有很多方式会导致复制停止,陷入混乱并中断。本章描述了一些比较普遍的问题,讨论如何重现这些问题,以及当遇到这些问题时如何解决或者阻止其发生。\n10.7.1 数据损坏或丢失的错误 # 由于各种各样的原因,MySQL的复制并不能很好地从服务器崩溃、掉电、磁盘损坏、内存或网络错误中恢复。遇到这些问题时几乎可以肯定都需要从某个点开始重启复制。\n大部分由于非正常关机后导致的复制问题都是由于没有把数据及时地刷到磁盘。下面是意外关闭服务器时可能会碰到的情况。\n主库意外关闭\n如果没有设置主库的sync_binlog选项,就可能在崩溃前没有将最后的几个二进制日志事件刷新到磁盘中。备库I/O线程因此也可一直处于读不到尚未写入磁盘的事件的状态中。当主库重新启动时,备库将重连到主库并再次尝试去读该事件,但主库会告诉备库没有这个二进制日志偏移量。二进制日志转储线程通常很快,因此这种情况并不经常发生。\n解决这个问题的方法是指定备库从下一个二进制日志的开头读日志。但是一些日志事件将永久地丢失,建议使用Percona Toolkit中的pt-table-checksum工具来检查主备一致性,以便于修复。可以通过在主库开启sync_binlog来避免事件丢失。\n即使开启了sync_binlog,MyISAM表的数据仍然可能在崩溃的时候损坏,对于InnoDB事务,如果innodb_flush_log_at_trx_commit没有设为1,也可能丢失数据(但数据不会损坏)。\n备库意外关闭\n当备库在一次非计划中的关闭后重启时,会去读master.info文件以找到上次停止复制的位置。不幸的是,该文件并没有同步写到磁盘,文件中存储的信息可能是错误的。备库可能会尝试重新执行一些二进制日志事件,这可能会导致唯一索引错误。除非能确定备库在哪里停止(通常不太可能),否则唯一的办法就是忽略那些错误。Percona Toolkit中的pt-slave-restart工具可以帮助完成这一点。\n如果使用的都是InnoDB表,可以在重启后观察MySQL错误日志。InnoDB在恢复过程中会打印出它的恢复点的二进制日志坐标。可以使用这个值来决定备库指向主库的偏移量。Percona Server提供了一个新的特性,可以在恢复的过程中自动将这些信息提取出来,并更新master.info文件,从根本上使得复制能够协调好备库上的事务。MySQL 5.5也提供了一些选项来控制如何将master.info和其他文件刷新到磁盘,这有助于减少这些问题。\n除了由于MySQL非正常关闭导致的数据丢失外,磁盘上的二进制日志或中继日志文件损坏并不罕见。下面是一些更普遍的场景:\n主库上的二进制日志损坏\n如果主库上的二进制日志损坏,除了忽略损坏的位置外你别无选择。可以在主库上执行FLUSH LOGS命令,这样主库会开始一个新的日志文件,然后将备库指向该文件的开始位置。也可以试着去发现损坏区域的结束位置。某些情况下可以通过SET GLOBAL SQL_SLAVE_SKIP_COUNTER = 1来忽略一个损坏的事件。如果有多个损坏的事件,就需要重复该步骤,直到跳过所有损坏的事件。但如果有太多的损坏事件,这么做可能就没有意义了。损坏的事件头会阻止服务器找到下一个事件。这种情况下,可能不得不手动地去找到下一个完好的事件。\n备库上的中继日志损坏\n如果主库上的日志是完好的,就可以通过CHANGE MASTER TO命令丢弃并重新获取损坏的事件。只需要将备库指向它当前正在复制的位置(Relay_Master_Log_File/Exec_Master_Log_Pos)。这会导致备库丢弃所有在磁盘上的中继日志。就这一点而言, MySQL 5.5做了一些改进,它能够在崩溃后自动重新获取中继日志。\n二进制日志与InnoDB事务日志不同步\n当主库崩溃时,InnoDB可能将一个事务标记为已提交,此时该事务可能还没有记录到二进制日志中。除非是某个备库的中继日志已经保存,否则没有任何办法恢复丢失的事务。在MySQL 5.0版本可以设置sync_binlog选项来防止该问题,对于更早的MySQL 4.1可以设置sync_binlog和safe_binlog选项。\n当一个二进制日志损坏时,能恢复多少数据取决于损坏的类型,有几种比较常见的类型:\n数据改变,但事件仍是有效的SQL\n不幸的是,MySQL甚至无法察觉这种损坏。因此最好还是经常检查备库的数据是否正确。在MySQL未来的版本中可能会被修复。\n数据改变并且事件是无效的SQL\n这种情况可以通过mysqlbinlog提取出事件并看到一些错乱的数据,例如:\nUPDATE tbl SET col????????????????? 可以通过增加偏移量的方式来尝试找到下一个事件,这样就可以只忽略这个损坏的事件。\n数据遗漏并且/或者事件的长度是错误的\n这种情况下,mysqlbinlog可能会发生错误退出或者直接崩溃,因为它无法读取事件,并且找不到下一个事件的开始位置。\n某些事件已经损坏或被覆盖,或者偏移量已经改变并且下一个事件的起始偏移量也是错误的\n同样的,这种情况下mysqlbinlog也起不了多少作用。\n当损坏非常严重,通过mysqlbinlog已经无法获取日志事件时,就不得不进行一些十六进制的编辑或者通过一些烦琐的技术来找到日志事件的边界。这通常并不困难,因为有一些可辨识的标记会分割事件。\n如下例所示,首先使用mysqlbinlog找到样例日志的日志事件偏移量:\n$ ** mysqlbinlog mysql-bin.000113 | egrep '^# at '** # at 4 # at 98 # at 185 # at 277 # at 369 # at 447 一个找到日志偏移量的比较简单的方法是比较一下string命令输出的偏移量:\n** $ strings -n 2 -t d mysql-bin.000113** 1 binpC'G 25 5.0.38-Ubuntu_0ubuntu1.1-log 99 C'G 146 std 156 test 161 create table test(a int) 186 C'G 233 std 243 test 248 insert into test(a) values(1) 278 C'G 325 std 335 test 340 insert into test(a) values(2) 370 C'G 417 std 427 test 432 drop table test 448 D'G 474 mysql-bin.000114 有一些可辨别的模式可以帮助定位事件的开头,注意以\u0026rsquo;G结尾的字符串在日志事件开头的一个字节后的位置。它们是固定长度的事件头的一部分。\n这些值因服务器而异,因此结果也可能取决于解析的日志所在的服务器。简单地分析后应该能够从二进制日志中找到这些模式并找到下一个完整的日志事件偏移量。然后通过mysqlbinlog的\u0026ndash;start-position选项来跳过损坏的事件,或者使用CHANGE MASTER TO命令的MASTER_LOG_POS参数。\n10.7.2 使用非事务型表 # 如果一切正常,基于语句的复制通常能够很好地处理非事务型表。但是当对非事务型表的更新发生错误时,例如查询在完成前被kill,就可能导致主库和备库的数据不一致。\n例如,假设更新一个MyISAM表的100行数据,若查询更新到了其中50条时有人kill该查询,会发生什么呢?一半的数据改变了,而另一半则没有,结果是复制必然不同步,因为该查询会在备库重放并更新完100行数据(MySQL随后会在主库上发现查询引起的错误,而备库上则没有报错,此后复制将会发生错误并中断)。\n如果使用的是MyISAM表,在关闭MySQL之前需要确保已经运行了STOP SLAVE,否则服务器在关闭时会kill所有正在运行的查询(包括没有完成的更新)。事务型存储引擎则没有这个问题。如果使用的是事务型表,失败的更新会在主库上回滚并且不会记录到二进制日志中。\n10.7.3 混合事务型和非事务型表 # 如果使用的是事务型存储引擎,只有在事务提交后才会将查询记录到二进制日志中。因此如果事务回滚,MySQL就不会记录这条查询,也就不会在备库上重放。\n但是如果混合使用事务型和非事务型表,并且发生了一次回滚,MySQL能够回滚事务型表的更新,但非事务型表则被永久地更新了。只要不发生类似查询中途被kill这样的错误,这就不是问题:MySQL此时会记录该查询并记录一条ROLLBACK语句到日志中。结果是同样的语句也在备库执行,所有的都很正常。这样效率会低一点,因为备库需要做一些工作并且最后再把它们丢弃掉。但理论上能够保证主备的数据一致。\n目前看来一切很正常。但是如果备库发生死锁而主库没有也可能会导致问题。事务型表的更新会被回滚,而非事务型表则无法回滚,此时备库和主库的数据是不一致的。\n防止该问题的唯一办法是避免混合使用事务型和非事务型表。如果遇到这个问题,唯一的解决办法是忽略错误,并重新同步相关的表。\n基于行的复制不会受这个问题的影响。因为它记录的是数据的更改,而不是SQL语句。如果一条语句改变了一个MyISAM表和一个InnoDB表的某些行,然后主库上发生了一次死锁,InnoDB表的更新会被回滚,而MyISAM表的更新仍会被记录到日志中并在备库重放。\n10.7.4 不确定语句 # 当使用基于语句的复制模式时,如果通过不确定的方式更改数据可能会导致主备不一致。例如,一条带LIMIT的UPDATE语句更改的数据取决于查找行的顺序,除非能保证主库和备库上的顺序相同。例如,若行根据主键排序,一条查询可能在主库和备库上更新不同的行,这些问题非常微妙并且很难注意到。所以一些人禁止对那些会更新数据的语句使用LIMIT。另外一种不确定的行为是在一个拥有多个唯一索引的表上使用REPLACE或者INSERT IGNORE语句——MySQL在主库和备库上可能会选择不同的索引。\n另外还要注意那些涉及INFORMATION_SCHEMA表的语句。它们很容易在主库和备库上产生不一致,其结果也会不同。最后,需要注意许多系统变量,例如@@server_id和@@hostname,在MySQL 5.1之前无法正确地复制。\n基于行的复制则没有上述限制。\n10.7.5 主库和备库使用不同的存储引擎 # 正如本章之前提到的,在备库上使用不同的存储引擎,有时候可以带来好处。但是在一些场景下,当使用基于语句的复制方式时,如果备库使用了不同的存储引擎,则可能造成一条查询在主库和备库上的执行结果不同,例如不确定语句(如前一小节提到的)在主备库使用不同的存储引擎时更容易导致问题。\n如果发现主库和备库的某些表已经不同步,除了检查更新这些表的查询外,还需要检查两台服务器上使用的存储引擎是否相同。\n10.7.6 备库发生数据改变 # 基于语句的复制方式前提是确保备库上有和主库相同的数据,因此不应该允许对备库数据的任何更改(比较好的办法是设置read_only选项)。假设有如下语句:\nmysql\u0026gt; ** INSERT INTO table1 SELECT * FROM table2;** 如果备库上table2的数据和主库上不同,该语句会导致table1的数据也会不一致。换句话说,数据不一致可能会在表之间传播。不仅仅是INSERT\u0026hellip;\u0026hellip;SELECT查询,所有类型的查询都可能发生。有两种可能的结果:备库上发生重复索引键冲突错误或者根本不提示任何错误。如果能报告错误还好,起码能够提示你主备数据已经不一致。无法察觉的不一致可能会悄无声息地导致各种严重的问题。\n唯一的解决办法就是重新从主库同步数据。\n10.7.7 不唯一的服务器ID # 这种问题更加难以捉摸。如果不小心为两台备库设置了相同的服务器ID,看起来似乎没有什么问题,但如果查看错误日志,或者使用innotop查看主库,可能会看到一些古怪的信息。\n在主库上,会发现两台备库中只有一台连接到主库(通常情况下所有的备库都会建立连接以等待随时进行复制)。在备库的错误日志中,则会发现反复的重连和连接断开信息,但不会提及被错误配置的服务器ID。\nMySQL可能会缓慢地进行正确的复制,也可能无法进行正确复制,这取决于MySQL的版本,给定的备库可能会丢失二进制日志事件,或者重复执行事件,导致重复键错误(或者不可见的数据损坏)。也可能因为备库的互相竞争造成主库的负载升高。如果备库竞争非常激烈,会导致错误日志在很短的时间内急剧增大。\n唯一的解决办法是小心设置备库的服务器ID。一个比较好的办法是创建一个主库到备库的服务器ID映射表,这样就可以跟踪到备库的ID信息(19)。如果备库全在一个子网络内,可以将每台机器IP的后八位作为唯一ID。\n10.7.8 未定义的服务器ID # 如果没有在my.cnf里定义服务器ID,可以通过CHANGE MASTER TO来设置备库,但却无法启动复制:\nmysql\u0026gt; ** START SLAVE;** ERROR 1200 (HY000): The server is not configured as slave; fix in config file or with CHANGE MASTER TO 这个报错可能会让人困惑,因为刚刚执行CHANGE MASTER TO设置了备库,并且通过SHOW MASTER STATUS也确认了。执行SELECT @@server_id也可以获得一个值,但这只是默认值,必须为备库显式地设置服务器ID。\n10.7.9 对未复制数据的依赖性 # 如果在主库上有备库不存在的数据库或表,复制会很容易意外中断,反之亦然。假设主库上有一个备库不存在的数据库,命名为scratch。如果在主库上发生对该数据库中表的更新,备库会在尝试重放这些更新时中断。同样的,如果在主库上创建一个备库上已存在的表,复制也可能中断。\n没有什么好的解决办法,唯一的办法就是避免在主库上创建备库上没有的表。\n这样的表是如何创建的呢?有很多可能的方式,其中一些可能更难防范。例如,假设先在备库上创建一个数据库scratch,该数据库在主库上不存在,然后因为某些原因切换了主备。当完成这些后,可能忘记了移除scratch数据库以及它的权限。这时候一些人就可以连接到该数据库并执行一些查询,或者一些定期的任务会发现这些表,并在每个表上执行OPTIMIZE TABLE命令。\n当提升备库为主库时,或者决定如何配置备库时,需要注意这一点。任何导致主备不同的行为都会产生潜在的问题。\n10.7.10 丢失的临时表 # 临时表在某些时候比较有用,但不幸的是,它与基于语句的复制方式是不相容的。如果备库崩溃或者正常关闭,任何复制线程拥有的临时表都会丢失。重启备库后,所有依赖于该临时表的语句都会失败。\n当基于语句进行复制时,在主库上并没有安全使用临时表的方法。许多人确实很喜欢临时表,所以很难去说服他们,但这是不可否认的(20)。不管它们的存在多么短暂,都会使得备库的启动和停止以及崩溃恢复变得困难,即使是在一个事务内使用也一样。(如果在备库使用临时表可能问题会少些,但如果备库本身也是一个主库,问题依然存在。)\n如果备库重启后复制因找不到临时表而停止,可能需要做以下一些事情:可以直接跳过错误,或者手动地创建一个名字和结构相同的表来代替消失的临时表。不管用什么办法,如果写入查询依赖于临时表,都可能造成数据不一致。\n避免使用临时表没有看起来那么难,临时表主要有两个比较有用的特性:\n只对创建临时表的连接可见。所以不会和其他拥有相同名字临时表的连接起冲突。 随着连接关闭而消失,所以无须显式地移除它们。 可以保留一个专用的数据库,在其中创建持久表,把它们作为伪临时表,以模拟这些特性。只需要为它们选择一个唯一的名字。还好这很容易做到:简单地将连接ID拼接到表名之后。例如,之前创建临时表的语句为:CREATE TEMPORARY TABLE top_users(\u0026hellip;),现在则可以执行CREATE TABLE temp.top_users_1234(\u0026hellip;),其中1234是函数CONNECTION_ID()的返回值。当应用不再使用该伪临时表后,可以将其删除或使用一个清理线程来将其移除。表名中使用连接ID可以用于确定哪些表不再被使用——可以通过SHOW PROCESSLIST命令来获得活跃连接列表,并将其与表名中的连接ID相比较(21)。\n使用实体表而非临时表还有别的好处。例如,能够帮助你更容易调试应用程序,因为可以通过别的连接来查看应用正在维护的数据。如果使用的是临时表,可能就没这么容易做到。\n但是实体表可能会比临时表多一些开销,例如创建会更慢,因为为这些表分配的.frm文件需要刷新到磁盘。可以通过禁止sync_frm选项来加速,但这可能会导致潜在的风险。\n如果确实需要使用临时表,也应该在关闭备库前确保Slave_open_temp_tables状态变量值为0。如果不是0,在重启备库后就可能会出现问题。合适的流程是执行STOP SLAVE,检查变量,然后再关闭备库。如果在停止复制前检查变量,可能会发生竞争条件的风险。\n10.7.11 不复制所有的更新 # 如果错误地使用SET SQL_LOG_BIN=0或者没有理解过滤规则,备库可能会丢失主库上已经发生的更新。有时候希望利用此特性来做归档,但常常会导致意外并出现不好的结果。\n例如,假设设置了replicate_do_db规则,把sakila数据库的数据复制到某一台备库上。如果在主库上执行如下语句,会导致主备数据不一致:\nmysql\u0026gt; ** USE test;** mysql\u0026gt; ** UPDATE sakila.actor ...** 其他类型的语句甚至会因为没有复制依赖导致备库复制抛出错误而失败。\n10.7.12 InnoDB加锁读引起的锁争用 # 正常情况下,InnoDB的读操作是非阻塞的,但在某些情况下需要加锁。特别是在使用基于语句的复制方式时,执行INSERT\u0026hellip;SELECT操作会锁定源表上的所有行。MySQL需要加锁以确保该语句的执行结果在主库和备库上是一致的。实际上,加锁导致主库上的语句串行化,以确保和备库上执行的方式相符。\n这种设计可能导致锁竞争、阻塞,以及锁等待超时等情况。一种缓解的办法就是避免让事务开启太久以减少阻塞。可以在主库上尽快地提交事务以释放锁。\n把大命令拆分成小命令,使其尽可能简短。这也是一种减少锁竞争的有效方法。即使有时很难做到,但也是值得的(使用Percona Toolkit中的pt-archiver工具会很简单)。\n另一种方法是替换掉INSERT\u0026hellip;SELECT语句,在主库上先执行SELECT INTO OUTFILE,再执行LOAD DATA INFILE。这种方法更快,并且不需要加锁。这种方法很特殊,但有时还是有用的。最大的问题是为输出文件选择一个唯一的名字,并在完成后清理掉文件。可以通过之前讨论过的CONNECTION_ID()来保证文件名的唯一性,并且可以使用定时任务(UNIX的crontab,Windows平台的计划任务)在连接不再使用这些文件后进行自动清理。\n也可以尝试关闭上面的这种锁机制,而不是使用上面的变通方法。有一种方法可以做到,但在大多数场景下并不是好办法,备库可能会在不知不觉间就失去和主库的数据同步。这也会导致在做恢复时二进制日志变得毫无用处。但如果确实觉得这么做的利大于弊,可以使用下面的办法来关闭这种锁机制:\n# THIS IS NOT SAFE! innodb_locks_unsafe_for_binlog = 1 这使得查询的结果所依赖的数据不再加锁。如果第二条查询修改了数据并在第一条查询之前先提交。在主库和备库上执行这两条语句的结果可能不相同。对于复制和基于时间点的恢复都是如此。\n为了了解锁定读取是如何防止混乱的,假设有两张表:一个没有数据,另一个只有一行数据,值为99。有两个事务更新数据。事务1将第二张表的数据插入到第一张表,事务2更新第二张表(源表),如图10-16所示。\n图10-16:两个事务更新数据,使用共享锁串行化更新\n第二步非常重要,事务2尝试去更新源表,这需要在更新的行上加排他锁(写锁)。排他锁与其他锁是不相容的,包括事务1在行记录上加的共享锁。因此事务2需要等待直到事务1完成。事务按照其提交的顺序在二进制日志中记录,所以在备库重放这些事务时产生相同的结果。\n但从另一方面来说,如果事务1没有在读取的行上加共享锁,就无法保证了。图10-17显示了在没有锁的情况下可能的事件序列。\n图10-17:两个事务更新数据,但未使用共享锁来串行化更新\n如果没有加锁,记录在日志中的事务顺序在主备上可能会产生不同的结果。MySQL会先记录事务2,这会影响到事务1在备库上的结果,而主库上则不会发生,从而导致了主备的数据不一致。\n我们强烈建议在大多数情况下将innodb_locks_unsafe_for_binlog的值设置为0。基于行的复制由于记录了数据的变化而非语句,因此不会存在这个问题。\n10.7.13 在主-主复制结构中写入两台主库 # 试图向两台主库写入并不是一个好主意。如果同时还希望安全地写入两台主库,会碰到很多问题,有些问题可以解决,有些则很难。一个专业人员可能需要经历大量的教训才能明白其中的不同。\n在MySQL 5.0中,有两个变量可以用于帮助解决AUTO_INCREMENT自增主键冲突的问题:auto_increment_increment和auto_increment_offset。可以通过设置这两个变量来错开主库和备库生成的数字,这样可以避免自增列的冲突。\n但是这并不能解决所有由于同时写入两台主库所带来的问题;自增问题只是其中的一小部分。而且这种做法也带来了一些新的问题:\n很难在复制拓扑间做故障转移。 由于在数字之间出现间隙,会引起键空间的浪费。 只有在使用了AUTO_INCREMENT主键的时候才有用。有时候使用AUTO_INCREMENT列作为主键并不总是好主意。 你也可以自己来生成不冲突的主键值。一种办法是创建一个多个列的主键,第一列使用服务器ID值。这种办法很好,但却使得主键的值变得更大,会对InnoDB二级索引键值产生多重影响。\n也可以使用只有一列的主键,在主键的高字节位存储服务器ID。简单的左移位(除法)和加法就可以实现。例如,使用的是无符号BIGINT(64位)的高8位来保存服务器ID,可以按照如下方法在服务器15上插入值11:\nmysql\u0026gt; ** INSERT INTO test(pk_col, ...) VALUES((15 \u0026lt;\u0026lt; 56) + 11, ...);** 如果想把结果转换为二进制,并将其填充为64位,其效果显而易见:\n该方法的缺点是需要额外的方式来产生键值,因为AUTO_INCREMENT无法做到这一点。不要在INSERT语句中将常量15替换为@@server_id,因为这可能在备库产生不同的结果。\n还可以使用MD5()或UUID()等函数来获取伪随机数,但这样做性能可能会很差,因为它们产生的值较大,并且本质上是随机的,这尤其会影响到InnoDB(除非是在应用中产生值,否则不要使用UUID(),因为基于语句的复制模式下UUID()不能正确复制)。\n这个问题很难解决,我们通常推荐重构应用程序,以保证只有一个主库是可写的。谁能想得到呢?\n10.7.14 过大的复制延迟 # 复制延迟是一个很普遍的问题。不管怎么样,最好在设计应用程序时能够让其容忍备库出现延迟。如果系统在备库出现延迟时就无法很好地工作,那么应用程序也许就不应该用到复制。但是也有一些办法可以让备库跟上主库。\nMySQL单线程复制的设计导致备库的效率相当低下。即使备库有很多磁盘、CPU或者内存,也会很容易落后于主库。因为备库的单线程通常只会有效地使用一个CPU和磁盘。而事实上,备库通常都会和主库使用相同配置的机器。\n备库上的锁同样也是问题。其他在备库运行的查询可能会阻塞住复制线程。因为复制是单线程的,复制线程在等待时将无法做别的事情。\n复制一般有两种产生延迟的方式:突然产生延迟然后再跟上,或者稳定的延迟增大。前一种通常是由于一条运行很长时间的查询导致的,而后者即使在没有长时间运行的查询时也会出现。\n不幸的是,目前我们没那么容易确定备库是否接近其容量上限。正如之前提到的。如果负载总是保持均匀的,备库在负载达到99%时和其负载在10%的时候表现的性能相同,但一旦达到100%时就会突然开始产生延迟。但实际上负载不太可能很稳定,所以当备库接近写容量时,就可能在尖峰负载时看到复制延迟的增加。\n当备库无法跟上时,可以记录备库上的查询并使用一个日志分析工具找出哪里慢了。不要依赖于自己的直觉,也不要基于查询在主库上的查询性能进行判断,因为主库和备库性能特征很不相同。最好的分析办法是暂时在备库上打开慢查询日志记录,然后使用第3章讨论的pt-query-digest工具来分析。如果打开了log_slow_slave_statements选项,在标准的MySQL慢查询日志能够记录MySQL 5.1及更新的版本中复制线程执行的语句,这样就可以找到在复制时哪些语句执行慢了。Percona Server和MariaDB允许开启或禁止该选项而无须重启服务器。\n除了购买更快的磁盘和CPU(固态硬盘能够提供极大的帮助,详细参阅第9章),备库没有太多的调优空间。大部分选项都是禁止某些额外的工作以减少备库的负载。一个简单的办法是配置InnoDB,使其不要那么频繁地刷新磁盘,这样事务会提交得更快些。可以通过设置innodb_flush_log_at_trx_commit的值为2来实现。还可以在备库上禁止二进制日志记录,把innodb_locks_unsafe_for_binlog设置为1,并把MyISAM的delay_key_write设置为ALL。但是这些设置以牺牲安全换取速度。如果需要将备库提升为主库,记得把这些选项设置回安全的值。\n不要重复写操作中代价较高的部分 # 重构应用程序并且/或者优化查询通常是最好的保持备库同步的办法。尝试去最小化系统中重复的工作。任何主库上昂贵的写操作都会在每一个备库上重放。如果可以把工作转移到备库,那么就只有一台备库需要执行,然后我们可以把写的结果回传到主库,例如,通过执行LOAD DATA INFILE。\n这里有个例子,假设有一个大表,需要汇总到一个小表中用于日常的操作:\nmysql\u0026gt; ** REPLACE INTO main_db.summary_table (col1, col2, ...)** -\u0026gt; ** SELECT col1, sum(col2, ...)** -\u0026gt; ** FROM main_db.enormous_table GROUP BY col1;** 如果在主库上执行查询,每个备库将同样需要执行庞大的GROUP BY查询。当进行太多这样的操作时,备库将无法跟上。把这些工作转移到一台备库上也许会有帮助。在备库上创建一个特别保留的数据库,用于避免和从主库上复制的数据产生冲突。可以执行以下查询:\nmysql\u0026gt; ** REPLACE INTO summary_db.summary_table (col1, col2, ...)** -\u0026gt; ** SELECT col1, sum(col2, ...)** -\u0026gt; ** FROM main_db.enormous_table GROUP BY col1;** 现在可以执行SELECT INTO OUTFILE,然后再执行LOAD DATA INFILE,将结果集加载到主库中。现在重复工作被简化为LOAD DATA INFILE操作。如果有N个备库,就节约了N-1次庞大的GROUP BY操作。\n该策略的问题是需要处理陈旧数据。有时候从备库读取的数据和写入主库的数据很难保持一致(下一章我们会详细描述这个问题)。如果难以在备库上读取数据,依然能够简化并节省库备工作。如果分离查询的REPLACE和SELECT部分,就可以把结果返回给应用程序,然后将其插入到主库中。首先,在主库执行如下查询:\nmysql\u0026gt; ** SELECT col1, sum(col2, ...) FROM main_db.enormous_table GROUP BY col1;** 然后为结果集的每一行重复执行如下语句,将结果插入到汇总表中:\nmysql\u0026gt; ** REPLACE INTO main_db.summary_table (col1, col2, ...) VALUES (?, ?, ...);** 这种方法再次避免了在备库上执行查询中的GROUP BY部分。将SELECT和REPLACE分离后意味着查询的SELECT操作不会在每一台备库上重放。\n这种通用的策略——节约了备库上昂贵的写入操作部分——在很多情况下很有帮助:计算查询的结果代价很昂贵,但一旦计算出来后,处理就很容易。\n在复制之外并行写入 # 另一种避免备库严重延迟的办法是绕过复制。任何在主库的写入操作必须在备库串行化。因此有理由认为“串行化写入”不能充分利用资源。所有写操作都应该从主库传递到备库吗?如何把备库有限的串行写入容量留给那些真正需要通过复制进行的写入?\n这种考虑有助于对写入进行区分。特别是,如果能确定一些写入可以轻易地在复制之外执行,就可以并行化这些操作以利用备库的写入容量。\n一个很好的例子是之前讨论过的数据归档。OLTP归档需求通常是简单的单行操作。如果只是把不需要的记录从一个表移到另一个表,就没有必要将这些写入复制到备库。可以禁止归档查询记录到二进制日志中,然后分别在主库和备库上单独执行这些归档查询。\n自己复制数据到另外一台服务器,而不是通过复制,这听起来有些疯狂,但却对一些应用有意义,特别是如果应用是某些表的唯一更新源。复制的瓶颈通常集中在小部分表上。如果能在复制之外单独处理这些表,就能够显著地加快复制。\n为复制线程预取缓存 # 如果有正确的工作负载,就能通过预先将数据读入内存中,以受益于在备库上的并行I/O所带来的好处。这种方式并不广为人知。大多数人不会使用,因为除非有正确的工作负载特性和硬件配置,否则可能没有任何用处。我们刚刚讨论过的其他几种变通方式通常是更好的选择,并且有更多的方法来应用它们。但是我们知道也有小部分应用会受益于数据预取。\n有两种可行的实现方法。一种是通过程序实现,略微比备库SQL线程提前读取中继日志并将其转换为SELECT语句执行。这会使得服务器将数据从磁盘加载到内存中,这样当SQL线程执行到相应的语句时,就无须从磁盘读取数据。事实上,SELECT语句可以并行地执行,所以可以加速SQL线程的串行I/O。当一条语句正在执行时,下一条语句需要的数据也正在从磁盘加载到内存中。\n如果满足下面这些条件,预取可能会有效:\n复制SQL线程是I/O密集型的,但备库服务器并不是I/O密集型的。一个完全的I/O密集型服务器不会受益于预取,因为它没有多余的磁盘性能来提供预取。 备库有多个硬盘驱动器,也许8个或者更多。 使用的是InnoDB引擎,并且工作集远不能完全加载到内存中。 一个受益于预读取的例子是随机单行UPDATE语句,这些语句通常在主库上高并发执行。DELETE语句也可能受益于这种方法,但INSERT语句则不太可能会——尤其是当顺序插入时——因为前一次插入已经使索引“预热”了。\n如果表上有很多索引,同样无法预取所有将要被修改的数据。UPDATE语句可能需要更新所有索引,但SELECT语句通常只会读取主键和一个二级索引。UPDATE语句依然需要去读取其他索引的数据以进行更新。在多索引表上这种方法的效率会降低。\n这种技术并不是“银弹”,有很多原因会导致其不能工作,甚至适得其反。只有在清楚硬件和操作系统的状况时才能尝试这种方法。我们知道有些人利用这种办法将复制速度提升了300%到400%,但我们也尝试过很多次,并发现这种方法常常无法工作。正确地设置参数非常重要,但并没有绝对正确的参数组合。\nmk-slave-prefetch是Maatkit中的一款工具,该工具实现了本节所提到的预取策略。mk-slave-prefetch本身有很多复杂的策略以保证其在尽可能多的场景下工作。但缺点是它实在太复杂并且需要许多专业知识来使用。另一款工具是Anders Karlsson的slavereadahead工具,可以从 http://sourceforge.net/projects/slavereadahead/获得。\n另一种方法在写作本书时还正在开发中,它是在InnoDB内部实现的。它可以允许设置事务为特殊的模式,以允许InnoDB执行“假”更新。因此可以使用一个程序来执行这些假更新,这样复制线程就可以更快地执行真正的更新。我们已经在Percona Server中为一个非常流行的互联网网络应用单独开发了该功能。可以去检查一下此特性现在的状态,因为在本书出版时或许已经更新过了。\n如果正在考虑这项技术,可以从一个熟悉其工作原理及可用选项的专家那里获得很好的建议。这应该作为其他方案都不可行时最后的解决办法。\n10.7.15 来自主库的过大的包 # 另一个难以追踪的问题是主库的max_allowed_packet值和备库的不匹配。在这种情况下,主库可能会记录一个备库认为过大的包。当备库获取到该二进制日志事件时,可能会碰到各种各样的问题,包括无限报错和重试,或者中继日志损坏。\n10.7.16 受限制的复制带宽 # 如果使用受限的带宽进行复制,可以开启备库上的slave_compressed_protocol选项(在MySQL 4.0及新版本中可用)。当备库连接主库时,会请求一个被压缩的连接——和MySQL客户端使用的压缩连接一样。使用的压缩引擎是zlib,我们的测试表明它能将文本类型的数据压缩到大约其原始大小的三分之一。其代价是需要额外的CPU时间,包括在主库上压缩数据和在备库上解压数据。\n如果主库和其备库间的连接是慢速连接,可能需要将分发主库和备库分布在同一地点。这样就只有一台服务器通过慢速连接和主库相连,可以减少链路上的带宽负载以及主库的CPU负载。\n10.7.17 磁盘空间不足 # 复制有可能因为二进制日志、中继日志或临时文件将磁盘撑满。特别是在主库上执行了LOAD DATA INFILE查询并在备库开启了log_slave_updates选项。延迟越严重,接收到但尚未执行的中继日志会占用越多的磁盘空间。可以通过监控磁盘并设置relay_log_space选项来避免这个问题。\n10.7.18 复制的局限性 # MySQL复制可能失败或者不同步,不管有没有报错,这是因为其内部的限制导致的。大量的SQL函数和编程实践不能被可靠地复制(本章我们已经讨论了许多这样的例子)。很难确保应用代码里不会出现这样或那样的问题,特别是应用或者团队非常庞大的时候。(22)\n另外一个问题是服务器的Bug,虽然听起来很消极,但大多数MySQL的主版本都存在着历史遗留的复制Bug。特别是每个主版本的第一个版本。诸如存储过程这样的新特性常常会导致更多的问题。\nMySQL复制非常复杂。应用程序越复杂,你就需要越小心。但是如果学会了如何使用,复制会工作得很好。\n10.8 复制有多快 # 关于复制的一个比较普遍的问题是复制到底有多快?简单来讲,它与MySQL从主库复制事件并在备库重放的速度一样快。如果网络很慢并且二进制日志事件很大,记录二进制日志和在备库上执行的延迟可能会非常明显。如果查询需要执行很长时间而网络很快,通常可以认为查询时间占据了更多的复制时间开销。\n更完整的答案是计算每一步花费的时间,并找到应用中耗时最多的那一部分。一些读者可能只关注主库上记录事件和将事件复制到中继日志的时间间隔。对于那些想了解更多细节的读者,我们可以做一个快速的实验。\n我们在本书的第一版详细描述了复制的过程和Giuseppe Maxia提供的测量高精度复制速度的方法(23)。我们创建了一个非确定性的用户自定义函数(UDF),以微秒精度返回系统时间(源代码参阅前面的“用户定义函数”):\n首先将NOW_USEC()函数的值插入到主库的一张表中,然后比较它在备库上的值,以此来测量复制的速度。\n为了测量延迟,我们在一台服务器上开启两个MySQL实例,以避免由于时钟引起的不精确。我们将其中一个实例配置为另一个的备库,然后在主库实例上执行如下语句:\nmysql\u0026gt; ** CREATE TABLE test.lag_test(** -\u0026gt; ** id INT NOT NULL AUTO_INCREMENT PRIMARY KEY,** -\u0026gt; ** now_usec VARCHAR(26) NOT NULL** -\u0026gt; ** );** mysql\u0026gt; ** INSERT INTO test.lag_test(now_usec) VALUES( NOW_USEC() );** 我们使用的是VARCHAR列,因为MySQL内建的时间类型只能精确到秒(尽管一些时间函数可以执行小于秒级别的计算),剩下的就是比较主备的差异。这里我们使用Federated表(24)。在备库上执行:\nmysql\u0026gt; ** CREATE TABLE test.master_val (** -\u0026gt; ** id INT NOT NULL AUTO_INCREMENT PRIMARY KEY** -\u0026gt; ** now_usec VARCHAR(26) NOT NULL** -\u0026gt; ** ) ENGINE=FEDERATED** -\u0026gt; ** connection='mysql://user:pass@127.0.0.1/test/lag_test',;** 简单的关联和TIMESTAMPDIFF()函数可以微秒精度显示主库和备库上执行查询的延迟。\n我们使用Perl脚本向主库中插入1 000行数据,每个插入间有10毫秒的延时,以避免主备实例竞争CPU时间。然后创建一个临时表来存储每个事件的延迟:\nmysql\u0026gt; ** CREATE TABLE test.lag AS** \u0026gt; ** SELECT TIMESTAMPDIFF(FRAC_SECOND, m.now_usec, s.now_usec) AS lag** -\u0026gt; ** FROM test.master_val AS m** -\u0026gt; ** INNER JOIN test.lag_test as s USING(id);** 接着根据延迟时间分组,可以看到最常见的延迟时间是多少:\n结果显示大多数小查询在主库上的执行时间和备库上的执行时间间隔大多数小于0.3毫秒。\n复制过程中没有计算的部分是事件在主库上记录到二进制日志后需要多长时间传递到备库。有必要知道这一点,因为备库越快接收到日志事件越好。如果备库已经接收到了事件,它就能在主库崩溃时提供一个拷贝。\n尽管我们的测量结果没有精确地显示这部分需要多长时间,但理论上非常快(例如,仅仅受限于网络速度)。MySQL二进制日志转储线程并没有通过轮询的方式从主库请求事件,而是由主库来通知备库新的事件,因为前者低效且缓慢。从主库读取一个二进制日志事件是一个阻塞型网络调用,当主库记录事件后,马上就开始发送。因此可以说,只要复制线程被唤醒并且能够通过网络传输数据,事件就会很快到达备库。\n10.9 MySQL复制的高级特性 # Oracle对MySQL 5.5的复制有着明显的改进。更多的特性还在开发中,MySQL 5.6将包含这些新特性。一些改进使得复制更加强健,例如,增加了多线程(并行)复制以减少当前单线程复制的瓶颈。另外,还有一些改进增加了一些高级特性,使得复制更加灵活并可控制。我们不会描述太多尚未GA的功能,但会讨论一些MySQL 5.5关于复制的改进。第一个是半同步复制,基于Google多年前所做的工作。这是自MySQL 5.1引入行复制后最大的改进。它可以帮助你确保备库拥有主库数据的拷贝,减少了潜在的数据丢失危险。\n半同步复制在提交过程中增加了一个延迟:当提交事务时,在客户端接收到查询结束反馈前必须保证二进制日志已经传输到至少一台备库上。主库将事务提交到磁盘上之后会增加一些延迟。同样的,这也增加了客户端的延迟,因此其执行大量事务的速度不会比将这些事务传递给备库的速度更快。\n关于半同步,有一些普遍的误解,下面是它不会去做的:\n在备库提示其已经收到事件前,会阻塞主库上的事务提交。事实上在主库上已经完成事务提交,只有通知客户端被延迟了。 直到备库执行完事务后,才不会阻塞客户端。备库在接收到事务后发送反馈而非完成事务后发送。 半同步不总是能够工作。如果备库一直没有回应已收到事件,会超时并转化为正常的异步复制模式。 尽管如此,这仍然是一个很好用的工具,有助于确保备库提供更好的冗余度和持久性。\n在性能方面,从客户端的角度来看,增加了事务提交的延时,延时的多少取决于网络传输,数据写入和刷新到备库磁盘的时间(如果开启了配置)以及备库反馈的网络时间。听起来似乎这是累加的,但测试证明这些几乎是不重要的,也许延迟是由其他原因引起的。Giuseppe Maxia发现每次提交大约延时200微秒(25)。对于小事务开销可能会比较明显,这也是预期中的。\n事实上半同步复制在某些场景下确实能够提供足够的灵活性以改善性能,在主库关闭sync_binlog的情况下保证更加安全。写入远程的内存(一台备库反馈)比写入本地的磁盘(写入并刷新)要更快。Henrik Ingo运行了一些性能测试表明,使用半同步复制相比在主库上进行强持久化的性能有两倍的改善(26)。在任何系统上都没有绝对的持久化——只有更加高的持久化层次——并且看起来半同步复制应该是一种比其他替代方案开销更小的系统数据持久化方法。\n除了半同步复制,MySQL 5.5还提供了复制心跳,保证备库一直与主库相联系,避免悄无声息地断开连接。如果出现断开的网络连接,备库会注意到丢失的心跳数据。当使用基于行的复制时,还提供了一种改进的能力来处理主库和备库上不同的数据类型。有几个选项可以用于配置复制元数据文件是如何刷新到磁盘以及在一次崩溃后如何处理中继日志,减少了备库崩溃恢复后出现问题的概率。\n我们还没有看到MySQL 5.5对复制的改进大规模地在生产环境进行部署,因此还需要进行更多的研究。\n除了上面提到的,这里简要地列出其他一些改进,包括MySQL以及第三方分支,例如Percona Server以及MariaDB:\nOracle在MySQL 5.6实验室版本和开发里程碑版本中有许多的改进。\n事务复制状态,即使崩溃也不会导致元数据失去同步(Percona Server和 ─MariaDB已经以别的形式实现了)。\n二进制日志的checksum值,用于检测中继日志中损坏的事件。\n备库延迟复制,用于替代Percona Toolkit中的 ─pt-slave-delay工具。\n允许基于行的二进制日志事件也包含在主库执行的SQL。\n实现多线程复制(并行复制)。\nMySQL5.6、Percona Server、Facebook以及MariaDB提供了三种修复方法解决了MySQL 5.0引入的GROUP COMMIT的问题。\n10.10 其他复制技术 # MySQL内建的复制并不是将数据从一台服务器复制到另外一台服务器的唯一办法,尽管大多数时候是最好的办法。(与PostgreSQL相比,MySQL并没有大量附加的复制选项,可能是因为复制功能在早期就已经引入了)。\n我们已经讨论了MySQL复制的一些扩展技术,如Oracle GoldenGate,但对大多数工具我们都不熟悉,因此无法讨论太多。但是有两个我们需要指出来,第一个是Percona XtraDB Cluster的同步复制,我们会在第12章介绍,因为它比较适合在高可用性这一章讲述。另一个是Continuent的Tungsten Replicator ( http://code.google.com/p/tungsten-replicator/)。\nTungsten是一个用Java编写的开源的中间件复制产品。它的功能和Oracle GoldenGate类似,并且看起来在未来发布的版本中将逐步增加许多复杂的特性。在写作本书时,它已经提供了一些特性,例如,在服务器间复制数据、自动数据分片、在备库并发执行更新(多线程复制)、当主库失败时提升备库、跨平台复制,以及多源复制(多个复制源到一个目标)。它是Tungsten数据库clustering suite的开源版本。\nTungsten同样实现了多主库集群,可以把写入指向集群中任意一台服务器。这种架构的实现通常都包含冲突发现与解决。这一点很难做到,并且不总是需要的。Tungsten的实现稍微做了点限制,不是所有的数据都能在所有的节点写入,每个节点被标记为记录系统,以接收特定的数据。例如,在西雅图的办公室可以拥有并写入它的数据,然后复制到休斯敦和巴尔的摩。在休斯敦和巴尔的摩本地可以实现低延迟读数据,但在这里Tungsten不允许写入数据,这样数据冲突就不存在了。当然休斯敦和巴尔的摩可以更新它们自己的数据,并被复制到其他地点。这种“记录系统”方案解决了人们需要在环形结构中频繁调整内建MySQL复制的问题。我们之前讨论的环形复制还远远不够安全或强健。\nTungsten Replicator不仅仅是嵌入或管理MySQL复制,而是直接替代它。它通过读取主库的二进制日志来获得数据更新,那里正是内建MySQL复制工作结束的地方,然后由Tungsten Replicator接管。它读取二进制日志,并抽取出事务,然后在备库执行它们。\n该过程比MySQL复制本身有更丰富的功能集。实际上,Tungsten Replicator是第一个提供MySQL并行复制支持的。虽然我们还没有看到其被应用到生产环境中,但它声称能够提供最多三倍的复制速度改善,具体取决于负载特性。基于该架构以及我们对该产品的了解,这看起来是可信的。\n以下是关于Tungsten Replicator中值得欣赏的部分:\n它提供了内建的数据一致性检查。 提供了插件特性,因此你可以编写自己的函数。MySQL的复制源代码非常难以理解并且很难去修改。即使非常聪明的程序员在试图修改时,也会引入新的Bug。因而能有种途径去修改复制而无须修改MySQL的复制代码,是非常理想的。 拥有全局事务ID,能够帮助你了解每个服务器相互之间的状态而无须去匹配二进制日志名和偏移量。 它是一个高可用的解决方案,能够快速地将一台备库提升为主库。 提供异构数据复制(例如,在MySQL和PostgreSQL之间或者MySQL和Oracle之间)。 支持不同版本的MySQL复制,以防止MySQL复制不能反向兼容。这对某些升级的场景非常有用。当升级运行得不理想时,你可能无法设计一个可行的回滚方案,或者必须升级服务器到一个并不是你期望的版本。 并行复制的设计非常适用于共享应用程序或多任务应用程序。 Java应用能够明确地写入主库并从备库读取。 得益于Giuseppe Maxia作为QA主管的大量工作,现在比以往更加简单并且更加容易配置和管理。 以下是它的一些缺点:\n它比内建的MySQL复制更加复杂,有更多可变动的地方需要配置和管理,毕竟它是一个中间件。 在你的应用栈中需要多学习和理解一个新的工具。 它并不像内建的MySQL复制那样轻量级,并且没有同样的性能。使用Tungsten Replicator进行单线程复制比MySQL的单线程复制要慢。 作为MySQL复制并没有经过广泛的测试和部署,所以Bug和问题的风险很高。 总而言之,我们很高兴Tungsten Replicator是可用的,并且在积极的开发中,稳定地释放新的特性和功能。拥有一个可替代内建MySQL复制的选择,这非常棒,使得MySQL能够适用于更多的应用场景,并且足够灵活,能够满足内建的MySQL复制可能永远无法满足的需求。\n10.11 总结 # MySQL复制是其内建功能中的“瑞士军刀”,显著增加了MySQL的功能和可用性。事实上这也是MySQL这么快就如此流行的关键原因之一。\n尽管复制有许多限制和风险,但大多数相对不重要或者对大多数用户而言是可以避免的。许多缺点只在一些高级特性的特殊行为中,这些特性对少数需要的人而言是有帮助的,但大多数人并不会用到。\n正因为复制提供了如此重要和复杂的功能,服务器本身不提供所有其他你需要的功能,例如,配置、监控、管理和优化。第三方工具可以很好地帮助你。虽然可能有失偏颇,但我们认为最值得关注的工具一定是Percona Toolkit和Percona XtraBackup,它们能够很好地改进你对复制的使用。在使用别的工具前,建议你先检查它们的测试集合,如果没有正式的、自动化的测试集合,在将其应用到你的数据之前请认真考虑。\n对于复制,应该铭记K.I.S.S(27)原则。不要按照想象做事,例如,使用环形复制、黑洞表或者复制过滤,除非确实有需要。使用复制简单地去镜像一份完整的数据拷贝,包括所有的权限。在各方面保持你的主备库相同可以帮助你避免很多问题。\n谈到保持主库和备库相同,这里有一个简短但很重要的列表告诉你在使用复制的时候需要做什么:\n使用Percona Toolkit中的pt-table-checksum以确定备库是主库的真实拷贝。 监控复制以确定其正在运行并且没有落后于主库。 理解复制的异步本质,并且设计你的应用以避免或容忍从备库读取脏的数据。 在一个复制拓扑中不要写入超过一个服务器,把备库配置为只读,并降低权限以阻止对数据的改变。 打开本章所讨论的那些明智并且安全的设置。 正如我们将要在第12章讨论的,复制失败是MySQL故障时间中最普遍的原因之一。为了避免复制的问题,阅读第12章,并尝试应用其给予的建议。你同样也应该通读MySQL手册中关于复制的章节,并了解复制如何工作以及如何去管理它。如果乐于阅读, Charles Bell et al. 所著的MySQL High Availability(O\u0026rsquo;Reilly)一书中有许多关于复制内部的有用信息。但你依然需要阅读手册!\n————————————————————\n(1) 可能有些地方将会复制备库(replica)称为从库(slave),这里我们尽量避免这种叫法。\n(2) 如果对二进制日志感到陌生,可以在第8章、本章剩下的部分以及第15章获得更多的信息。\n(3) 严格来讲这不是必需的,但我们推荐这么做,稍后我们会解释为什么。\n(4) 事实上,正如之前从SHOW MASTER STATUS看到的,真正的日志起始位置是98,一旦备库连接到主库就开始工作,现在连接还未发生。\n(5) 语句在无限循环中来回传递也是多服务器环形复制拓扑结构中比较有意思的话题之一,后面我们会提到。要尽量避免环形复制。\n(6) 如果使用的是基于语句的复制,就会有这样的问题,但基于行的复制方式则不会(另一个远离它们的理由)。\n(7) 从技术上讲这并非正确的。但如果有重复的服务器ID,它们将陷入竞争,并反复将对方从主库上踢出。\n(8) 事实上这些问题经常一周发生三次,并且我们也发现需要好几个月才能解决这些问题。\n(9) 一些,但不是全部——我们可以吹毛求疵,并指出任何你可以想象的漏洞。\n(10) 可以通过设置SQL_LOG_BIN=0来暂时禁止记录二进制日志而无须停止复制。一些语句,例如Optimize TABLE,也支持LOCAL或者NO_WRITE_TO_BINLOG这些停止日志的选项。\n(11) 也许应该说,是更明智的特例。\n(12) 从MySQL Bug 35178和62829开始查阅,总地来说,如果使用的是不标准的存储引擎特性,最好去看看那些打开或者关闭的受影响的Bug。\n(13) 可以使用Percona的工具集中的pt-heartbeat来创建一个粗糙的全局事务ID。这样可以很方便地在多个服务器上寻找二进制日志的位置。因为“心跳表”本身就记录了大概的二进制日志位置。\n(14) 我们明确地使用*/bin/ls*以避免启用通用别名,它们会为终端着色添加转义码。\n(15) 如果复制线程总是在运行,你可以使用服务器的uptime来代替CONNECTED_TIME的一半。\n(16) 如果你正在使用非事务型存储引擎,不首先调用STOP SLAVE就关闭服务器是很不妥当的。\n(17) 这是有可能的,即使MySQL在事务提交前并不记录任何事件。具体参阅“混合事务型和非事务型表”。另外一种场景是主库崩溃后恢复,但没有设置innodb_flush_log_at_trx_commit的值为1,所以可能丢失一些更新。\n(18) 正如前面提到的,pt-heartbeat的心跳记录能够很好地帮助你找到你正在查找的事件的大约位置。\n(19) 也许你想把它保存在服务器中,这不完全是玩笑,可以给ID列添加一个唯一索引。\n(20) 我们已经有人尝试各种方法来解决这个问题,但对于基于语句的复制并没有安全的临时表创建方法。起码一段时期是这样,不管你如何认为,起码我们已经证明了这是不可行的。\n(21) pt-find——另一个Percona Toolkit工具——通过\u0026ndash;connection-id和\u0026ndash;server-id选项能够轻易地移除伪临时表。\n(22) 最近的MySQL版本没有forbid_operations_unsafe_for_replication选项,但它确实对一些不安全的事情起到了警示,甚至拒绝。\n(23) 查看 http://datacharmer.blogspot.com/2006/04/measuring-replication-speed.html。\n(24) 顺便说一下,这也是一些作者唯一一次使用Federated存储引擎。\n(25) 参阅 http://datacharmer.blogspot.com/2011/05/price-of-safe-data-benchmarking-semi.html。\n(26) 参阅 http://openlife.cc/blogs/2011/may/drbd-and-semi-sync-shootout-large-server。\n(27) Keep It Simple, Schwartz!总之一些人认为这是K.I.S.S的含义。\n"},{"id":154,"href":"/zh/docs/culture/%E5%8F%A4%E4%BB%A3%E5%A4%A9%E6%96%87%E5%8E%86%E6%B3%95%E8%AE%B2%E5%BA%A7/%E9%99%84%E5%BD%95-%E9%99%84%E8%A1%A8-%E5%90%8E%E8%AE%B0/","title":"附录-附表-后记","section":"古代天文历法讲座","content":" 附录 # 西周金文“初吉”之研究 # 一、传统解说难于否定\n西周行用朔望月历制,朔与望至关重要。朔称初吉、月吉,或称吉,又叫既死霸(取全是背光面之义,死霸指背光面),或叫朔月。这种种名称,反映了周人对月相的重视以及朔日在历制中的特殊地位。\n传统的解说,初吉即朔。\n《诗·小明》“正月初吉”,毛传:初吉,朔日也。\n《国语·周语》“自今至于初吉”,韦昭注初吉:二月朔日也。\n《周礼》“月吉则属民而读邦法”,郑注月吉:每月朔日也。\n《论语》“吉月必朝服而朝”,孔曰:吉月,月朔也。\n《诗·十月之交》“朔月辛卯”,唐石经作“朔日辛卯”。\n《礼记·祭义》:“朔月月半,君巡牲。”\n《礼记·玉藻》“朔月大牢”,陈澔《礼记集说》:朔月,月朔也。\n日本竹添光鸿《毛诗会笺》云:古人朔日称朔月。《仪礼》《礼记》皆有朔月之文。《尚书》或称元日、上日而不曰朔日。即望亦但曰月几望或既望而不曰望日,故知经文定当以朔月为是也。凡月朔皆称朔月。《论语》亦以月吉为吉月。古人多倒语,犹《书》之“月正元日”乃正月元日也。\n《周礼》“正月之吉”,郑注:吉谓朔日。\n《周礼》“及四时之孟月吉日”,郑注:四孟之月朔日。\n郑玄作为两汉经学之集大成者,对朔为吉日的认识是十分明确的,或称月吉,或称吉日,或称吉,都肯定了朔为吉日这一点。\n朔即月初一,故称初吉,亦属自然,这与望为吉日亦相对应。朔望月历制,朔为吉日,望亦为吉日。《易·归妹》“月几望,吉”可证。\n毛传释初吉为朔日,韦昭注《国语》“初吉”为朔日,反映古人对“初吉”的正确认识。\n尤其当注意的是,初吉为朔的解说,两千年来没有任何一位严肃的学者持有异议。\n我们没有理由不尊重文献。应当说,传统对于初吉的解说是难于否定的,是不容否定的。\n二、朔望月历制\n西周是明白无误的朔望月历制,绝对不是什么“朏为月首”。\n我们从载籍文字中可以找到若干证据:\n《周礼·大史》“掌建邦之六典,以逆邦国之治。……正岁年以序事,颁之于官府及都鄙。(郑注:中数曰岁,朔数曰年。中朔大小不齐,正之以闰若今时历日矣。定四时,以次序授民时之事。)颁告朔于邦国。(郑注:天子班朔于诸侯,诸侯藏之于祖庙。至朔,朝于庙,告而受行之。郑司农云,以十二月朔布告天下诸侯。)”\n这里的告朔之制,当然也包括西周一代。依郑玄说,岁指回归年长度(阳历),年指十二个朔望月长度(阴历),两者不一致,添加闰月来协调,这就是周代的阴阳合历体制。\n西周一代,“保章氏掌天星以志星辰日月之变动”,强调天象的观察与记录;“冯相氏掌十有二岁,十有二月,十有二辰”(《周礼》),侧重在历术的推求。\n《礼记·玉藻》:“天子听朔于南门之外。闰月则阖门左扉,立于其中。”陈澔《集说》引“方氏曰:天子听朔于南门,示受之于天。诸侯听朔于太庙,示受之于祖。原其所自也”。\n历术是皇权的象征,掌握在周天子手中,天子于南门从冯相氏得每年十二个月朔的安排,然后颁朔于诸侯,诸侯藏之祖庙。至朔,朝于庙(即“听朔于太庙”),告而受行之。历术推求的依据是天象,所以“示受之于天”,“原其所自也”。\n《逸周书·史记解》“朔望以闻”,是记周穆王时事。朔望月历制是明明白白的。\n《礼记·祭义》“朔月月半,君巡牲”,这当然是说,初一与十五,人君巡视之。这难道不是朔望月的明证?\n《吕氏春秋》保存了先秦的若干旧说,上至三皇五帝,史料价值不可忽视。《贵因》载:“夫审天者,察列星而知四时,因也。推历者视月行而知晦朔,因也。”\n视月行,就是月相的观察。干什么?确定晦朔而已。很明白,观察月相就是为了确定一年十二个月朔的干支,以“颁告朔于邦国”。\n《逸周书·宝典解》“维王三祀二月丙辰朔”,历日清清楚楚。过去说此篇是记武王的。事实上,历日唯合成王亲政三年,《宝典解》反映了西周初期朔望月历制。《逸周书》成书于西周以后,而这个历日当是前朝的实录,绝不是后人的伪造或推加。这是“朏为月首”说无法作出解释的。\n《汉书·世经》云:“古文《月采》篇曰‘三日曰朏’。”师古注:《月采》,说月之光采,其书则亡。——这也许是记录月相的专著,可惜我们已不能见到了。刘歆是见过的,他持定点说当有充分依据。《月采》明确朏是初三。“朏为月首”是没有依据的。\n大量出土的西周器物证实,西周历制是朔望月而不是“朏为月首”。\n《作册令方彝》:隹八月辰在甲申……丁亥……;隹十月月吉癸未……甲申……乙酉……”“辰在××”是周人表达朔日的一种固定格式,出土器物已有二十余例,校比天象无一不是朔日。推比历朔知:八月甲申朔,初四丁亥;九月甲寅朔(或癸丑朔);十月癸未朔,甲申初二,乙酉初三。“月吉癸未”即朔日癸未,与文献记载亦相吻合。《令方彝》的八月、十月,中间无闰月可插,一个月就只有一个朔日即一个月吉,这怎么能“说明西周时代每个月都可能有若干个吉日”呢?\n西周金文记载初吉尤多,初吉即朔,也只能证明西周是朔望月制而不是“朏为月首”。\n常识告诉我们,历术是关于年月日的协调。日因于太阳出没,白昼黑夜,是计时的基本单位;年以太阳的回归年长度为依据,表现为寒来暑往,草木荣枯,《尧典》“期三百有六旬有六日,以闰月定四时成岁”;而月亮的隐现圆缺,只能靠肉眼观察。西周制历,尚未找到年月日的调配规律,只能随时观察随时置闰,一年十二个月朔的确定也靠“观月行”。这就是西周人频频记录月相的缘由。\n日与年易于感知,观象授时的主要内容是观察月相,两望之间必朔,两朔之间必望,朔望月也是不难掌握的。何况司历专职,勤劬观察,不会将初一说成初二,更不会说成初三。肉眼观察的失朔限度也只在半日之内。\n董作宾先生以为,知道日食就会知道朔,知道月食就会知道望。朔望月历制当追溯到殷商。\n持“朏为月首”说者以为,“朔”字在西周后期才出现,猜想西周前期当是“朏为月首”。殊不知,殷商后期以来,朔望的概念十分明确,表达朔日的词语甚多,初吉为朔,既死霸为朔,月吉(吉月)为朔,“辰在××”为朔,并非一定要用“朔”字不可。\n西周一代,未找到协调年月日的规律,月相的观察就显得特别重要,文献以及出土器物有关月相的记载也就特别的多。到了春秋中期以后,十九年七闰已很明确,连大月设置也逐渐有了规律,朔日的推演已不为难事。所以,鲁文公“四不视朔”,“子贡欲去告朔之饩羊”,不仅证实西周以来的告朔礼制已经走向衰败没落,还反映出四分术的推演已为司历者大体掌握。历术已由观象授时上升到推步制历,已从室外观月步入室内推算。这样,月相的观察与记录自然就不那么重要了。这就是春秋以后,作为月相的“既死霸”“既生霸”“既望”在金文中基本消失的原因。\n三、初吉即朔\n西周金文大量使用“初吉”,凡可考知的,无一不是朔日。\n有的器铭,年、月、月相、日干支俱全,校比天象,十分方便。利用张培瑜先生《中国先秦史历表》,便可一目了然。\n例1,攸从鼎:隹卅又一年三月初吉壬辰。(郭沫若:《两周金文辞大系图录考释》,下简称《大系录》,118)\n校比公元前848年厉王三十一年天象,丑正,三月壬辰朔。\n例2,无其簋:隹十又三年正月初吉壬寅。(《大系录》107)\n校比公元前829年共和十三年天象,丑正,正月壬寅朔。\n例3,虢季子白盘:隹王十有二年,正月初吉丁亥。(《大系录》88)\n校比公元前816年宣王十二年天象,子正,正月丁亥朔(定朔戊子03h49m,合朔在后半夜,失朔不到四小时)。\n例4,叔尃父:隹王元年六月初吉丁亥。(《考古》65.9)\n校比公元前770年平王元年天象,丑正六月丁亥朔(定朔戊子02h01m,失朔仅两小时)。\n厉王以前的若干铜器,因王年尚无共识的结论,仅举几例说明。\n例5,谏簋:隹五年三月初吉庚寅。(《大系录》101)\n校比公元前889年夷王五年天象,丑正,三月庚寅朔。\n例6,王臣簋:隹二年三月初吉庚寅。(《文物》80.5)\n校比公元前915年懿王二年天象,丑正,三月庚寅朔。\n例7,柞钟:隹王三年四月初吉甲寅。(《文物》61.7)\n校比公元前914年懿王三年天象,丑正,四月甲寅朔。此器与王臣簋历日前后连贯,丝毫不乱,列为同一王世之器,更可证初吉即朔。\n总之,初吉即朔,这是金文历日明确记载的,绝不是泛指某月中的任何一日。\n四、关于静簋\n刘雨先生在《再论金文“初吉”》(《中国文物报》,1997-04-20)中把静簋历日作为立论的主要依据,以此否定初吉为朔,这就有必要重点讨论了。\n刘先生说:西周金文中……只有静簋记有两个“初吉”,而且相距不到三个月,没有历律和年代等未知因素干扰,是西周金文中最能说明“初吉”性质的珍贵资料。——这就是他为什么特别重视静簋的原因。\n过去我将静簋视为厉王三十五年器,“六月初吉丁卯”合公元前844年天象,“八月初吉庚寅”合公元前843年天象,两个初吉间隔一年,与何幼琦先生的认识暗合。刘雨先生此文给我以启发,两初吉确实当为一年之内的两初吉,不必间隔一年。不过,两初吉的解说都当指朔日,而不是泛指某月中任何一日。\n排比静簋历朔知:六月丁卯朔,七月当丙申朔(或丁酉朔),八月丙寅朔。\n这个“丙寅”,铸器者并不书为丙寅,而是书为吉日庚寅。这就是静簋“六月初吉丁卯……八月初吉庚寅”的由来。\n我们在研究金文历日中发现,除了丁亥,古人亦视庚寅为吉日。一部《春秋》,经文记有八个庚寅日,几乎都系于公侯卒日,《左传》十一次记庚寅日,几乎都涉及戎事。大事择庚寅必视庚寅为吉利。至于西周铜器铭文,书庚寅者甚夥。查厉宣时代器铭,其书庚寅者多取其吉利,实非庚寅日而多为丙寅或其他寅日。\n例1,盘:隹廿又八年五月既望庚寅。(《大系录》117)\n此器为宣王二十八年器,校比公元前800年天象,冬至月朔甲寅,建寅,五月辛亥朔,既望十六丙寅。盘书为“既望庚寅”,取其吉利。\n例2,克钟:隹十又六年九月初吉庚寅。(《大系录》93)\n例3,克:隹十又八年十又二月初吉庚寅。(《大系录》112)\n克钟与克,作器者同为一人。克钟历日合宣王十六年(前812年)天象,九月庚寅朔。据历朔规律知,有十六年九月初吉庚寅,就不得有十八年十二月初吉庚寅,两器历日彼此不容。现已肯定克钟为宣王器,克历日又不合厉王,只能定为宣王器。\n校比宣王十八年(前810年)天象,建子,十二月戊寅朔。克书戊寅朔为“初吉庚寅”,取庚寅吉利之义。似乎只有这唯一的解说,历日方可无碍。\n金文“庚寅”往往并非实实在在的庚寅日,为取庚寅吉利之义,凡丙寅、戊寅皆可书为庚寅。这就是我们在研究铜器历日中所归纳出来的“庚寅为寅日例”。(见《铜器历日研究》,贵州人民出版社,1999。)\n以此诠释静簋两个初吉历日,并无任何扞格难通之处。只能证明初吉即朔,初吉并不作其他任何解说。\n五、关于师兑簋\n刘雨先生说,静簋并非孤证。又举出师兑簋两器作为初吉非朔的佐证,以此否定传统说法。为了弄清事实真相,看来师兑簋两器也有讨论的必要。\n师兑簋甲:隹元年五月初吉甲寅。(《大系录》146)\n师兑簋乙:隹三年二月初吉丁亥。(《大系录》150)\n按:排比历朔,元年五月甲寅朔,三年二月不得有丁亥朔,只有乙亥朔。从元年五月朔到三年二月朔,其间经21个月,12个大月,9个小月,计621日。干支周60日经十轮,余21日。甲寅去乙亥,在21日。可见任何元年五月甲寅朔到三年二月不可能有丁亥朔。甲寅去丁亥33日,显然不合。师兑簋两器,内容彼此衔接,不可能别作他解。三年二月初吉丁亥,实为二月初吉乙亥。是乙亥书为丁亥。书丁亥者,取其大吉大利之义。\n六十个干支日,丁亥实为一个最大的吉日,故金文多用之。器铭“初吉丁亥”,若以丁亥朔释之,则往往不合。若以乙亥朔或其他亥日解说,则吻合不误。\n《仪礼·少牢馈食礼》“来日丁亥”郑注:“丁未必亥也,直举一日以言之耳。《禘于太庙礼》曰‘日用丁亥’,不得丁亥,则己亥、辛亥亦用之。无则苟有亥焉可也。”郑玄对丁亥的解说再明白不过了,丁亥当以亥日为依托。\n再举一例,伊簋:隹王廿又七年正月既望丁亥。(《大系录》116)\n按:既望十六丁亥,必正月壬申朔。伊簋,郭氏《大系录》,吴其昌氏、容庚氏列为厉王器,董作宾氏列为夷王器,均与实际天象不合。校比宣王二十七年(公元前801年)天象,冬至月朔庚申,建子,正月庚申朔,有既望十六乙亥。器铭书为“既望丁亥”乃取丁亥吉祥之义。\n除此之外,大簋、大鼎、师簋诸器都能说明问题。这就是我们在研究金文历日条例中所定下的“丁亥为亥日例”。(见《铜器历日研究》)\n遍查西周铜器历日,唯丁亥为多,乙亥次之,庚寅又次之。细加考察,乙亥实为吉日丁亥与吉日庚寅之桥梁。至迟商代后期,便视丁亥为吉日。从月相角度说,朔为吉日,望亦为吉日,而真正的月满圆多在十六,故既望亦为吉日。故有初吉乙亥,亦有既望乙亥。有初吉乙亥,必有十六既望庚寅,是庚寅亦得为吉日。故有既望庚寅,又有初吉庚寅。金文中,凡丁亥、乙亥、庚寅,不可都视为实指。凡亥日,或书为丁亥,也可书为乙亥;凡寅日,可书为庚寅,皆取吉利之义。\n总之,在涉及出土器物铭文历日的研究中,我始终觉得,要做到文献材料、器物铭文与实际天象(历朔干支)紧密联系起来,做到“三证合一”,才会有可信的结论。\n六、铜器专家如是说\n这里,我还要引用西北大学张懋镕先生的见解,以正视听。\n他说:“初吉是否为月相语词,恐怕还得由西周金文自身来回答。”\n他列举了鼎、免簋、免盘、簋、方尊等五器铭文之后说:“以上五器记载周王(王后)对臣属的赏赐或册命赏赐,有时间、有地点,其时日自然是具体的某一天。与其他器不同的是,初吉后未有干支日,显而易见,此初吉便是周王(王后)赏赐或册命赏赐的那一天。在免簋中,昧爽在初吉之后,系指初吉的清晨,所以这个初吉日一定是定点的,否则无从附着。昧爽又见于小盂鼎,与免簋相较,益可证明初吉是固定的一天。”\n“不仅初吉是指具体的某一天,其他月相语词也具有这样的特性。”他列举了遹簋、公姞鬲、师趛、七年趞曹鼎之后接着说:“七年趞曹鼎与免簋相类,其‘旦’当指既生霸这一日的早晨。可见,当月相语词后面带有干支日时,干支日就是事情发生的这一天;如果月相语词后面不带干支日,事情就发生在初吉或既生霸、既望、既死霸这一天。”他接着说:\n金文月相词语之所以是定点的,原因在于:\n1.凡带有月相语词的金文,不论其长短,都是记叙文。既为记叙文,不可缺少的就是时间要素,而月相语词正是表示时间的定位。时间必须是具体而不能含糊的。\n2.上举免簋、簋、方尊、七年趞曹鼎属于册命赏赐金文。其内容是周王(王后)对器主职官的任命,任命仪式之隆重,程序之规范,是不言而喻的。册命赏赐关乎器主一生的命运及其家族的兴旺,所以令器主难以忘怀,常常镌之于铜器之上,以求天子保佑,子孙永宝。既然如此,发生这一重大事情的日子是不会被忘记的。上述四例中的初吉和既生霸,自然是某年某月的某一天。\n册命金文中恒见“初吉”,那是因为册命一般在月初进行。说初吉可以是月中的任何一天,不仅悖于情理,也有违于金文本身。\n殷周金文发展的历程,也证明了这一点。先看晚殷金文:\n1.宰椃角:庚申,王才(在)阑。王各(格)宰椃从。易(锡)贝五朋。用乍(作)父丁彝。才(在)六月,隹王廿祀翌又五。\n2.小臣艅尊:丁巳,王省夔且。王易(锡)小臣艅夔贝。隹王来正(征)人方。隹王十祀又五,日。\n3.鬲:戊辰,弜师易(锡)户贝。才(在)十月。隹王廿祀。\n其特点是干支纪日在铭首,年、月在铭末。方法同于殷代甲骨文。显然,在器主眼中,最重要的是被赏赐的具体时日,纪日为主,年、月尚在其次,故常常省去年、月,只保留干支日。这一点在西周早期金文中表现得很充分:\n1.利簋:武王征商,隹甲子朝。\n2.大丰簋:乙亥,王又大丰,王凡三方。\n3.新邑鼎:癸卯,王来奠新邑。\n4.士卿尊:丁巳,王才新邑。\n5.保卣:乙卯,王令保及殷东国五侯,兄六品。……才二月既望。\n成王之后,铭文加长,但事情发生的具体日子是一定会写明的。偶有纪月不纪日者,是有其他原因的。需要说明的是,西周晚期册命金文在月相词语后系干支日,不系者似乎未有。或许是随着时代变迁,金文体例更为整饬的缘故吧。\n我用治铜器的专家张懋镕先生这段文字作为关于金文“初吉”研究的结尾,恐怕是最为恰当不过的了。\n再谈金文之“初吉” # 再谈金文之“初吉”\n一年多来,我从“断代工程”简报上陆续获悉李学勤先生关于“初吉”以及月相名词的解说,如:“吉的意义是朔。月吉(或吉月)就是朔日,因而是定点的。《诗》毛传暗示初吉是定点的”(第41期);“经李学勤先生指示,我们相信《武成》《世俘》诸篇与金文中月相术语有不同的定义,而《武成》《世俘》诸篇的月相采李先生的定点解读”(第44期);“李学勤等从金文研究和文献学的角度都认为定点说难于成立”(第38期);李先生在《“天大曀”与静方鼎》中说“月吉癸未初三日,初吉庚申初四日”(第62期);“这样的吉日多数应发生在每月的月初,但也有一部分会发生在月中或月末。……李学勤、张长寿先生在总结发言中肯定了这一点”(第57期);李学勤先生金文历谱方案:“初吉己卯,先实朔二日。初吉壬辰,初七日。初吉辛巳,初五日。初吉庚戌,先实朔二日。初吉丁亥,初三日。初吉戊申,初九日。初吉丁亥,先实朔一日。初吉庚寅,初一日。初吉庚寅(戌),初四日”(第53期);“在本次会议上,李学勤先生放弃了原来认为‘初吉’表朔日为月相的观点。李学勤先生认为,初吉:有初一(含先实朔一、二日者)、初四、初五、初七、初九、初十等日;既生霸:有初三、初五、初十、十四等日;既望:有十八、十九、二十等日;既死霸:有二十一、二十四、二十八、二十九等日”(第52期)。……这些不一的看法,给人总的感觉是:李先生在月相问题上摇摆不定,陷入一种“二元论”的尴尬境地——又定点又不定点,或者典籍《武成》《世俘》《诗毛传》定点而金文中不定点。因为李先生长期信奉“四分一月”说,要改从定点说就非常之难。最终他放弃了古文献的定点说,而以金文历日的主观解说为依据,走上了“两分”说(既生霸指上半月,既死霸指下半月。见简报第57期),比“四分一月”说走得更远了。在这个基础上主持“夏商周断代工程”探求西周王年,其结论就可想而知了。\n最近,从人大复印资料上读到李学勤先生《由蔡侯墓青铜器看“初吉”和“吉日”》一文,李先生认为,“初吉”不一定是朔日,但包括朔,必在一月之初(不定点的);而“元日”“吉日”与“吉”均同义,即为朔日(定点的)。\n两年前我写有《西周金文“初吉”之研究》一文,载《考古与文物》1999年3期,又收入个人专著《铜器历日研究》(贵州人民出版社,1999)一书,认定“初吉”是指朔日,别无他解。现就李先生文章中涉及关于“初吉”的解说,再谈一下个人的看法。\n一、关于蔡侯墓青铜器的历日 # 李先生文章是从1955年发掘的蔡侯墓入手,论述“初吉”和“吉日”的。\n蔡侯编钟云:“惟正五月初吉孟庚,蔡侯□曰:余惟(虽)末少子,余非敢宁忘,有虔不易,(左)右楚王……建我邦国。”\n李先生认为此器是蔡平侯作器,作于鲁昭公十三年,推出夏正五月戊戌朔,初庚即五月第一个庚日庚子,是初三。\n如果视此器为蔡昭侯作器,结论就大不一样。昭侯乃悼侯之弟,自称“少子”;欲结楚欢,追怀楚平王“建我邦国”亦合情理。楚平王立蔡平侯,“平侯立而杀隐太子,故平侯卒而隐太子之子东国攻平侯子而代立,是为悼侯。悼侯三年卒,弟昭侯申立”。蔡国的动乱,发生在楚平王的眼皮下,昭侯立,不对楚王表忠心是不可能的。\n此器作于昭侯二年(前517年)。“五月初吉孟庚”当指周正五月庚寅朔日。蔡乃姬姓国,用周正当属常理。初吉指朔当无疑义。\n又,蔡侯申盘铭:“元年正月初吉辛亥,蔡侯申虔恭大命……肇天子,用诈(作)大孟姬……敬配吴王……”\n这是指蔡与吴结婚姻之好。李先生说:“唯一合理的解释,是‘元年’为吴王光(阖闾)的元年(前514年),即蔡昭侯五年,鲁昭公二十八年。”于是便推算出,初吉辛亥是初八日。\n这个“元年”如果不是指吴王光元年,说法又不大一样了。\n蔡昭侯即位之初,为避免招祸,不得不结好楚平王。到楚昭王时代,蔡昭侯被“留之楚三年”,“归而之晋,请与晋伐楚。……楚怒,攻蔡,蔡昭侯使其子为质于吴,以共伐楚。冬,与吴王阖闾遂破楚入郢”。这一年,正是陈“怀公元年,吴破楚”。\n昭侯怒楚,“请与晋伐楚”,招致“楚怒,攻蔡”,才与吴结盟,不仅“使其子为质于吴”,还于次年初嫁大孟姬与吴王,选定的日子就是“正月初吉辛亥”。\n这与陈怀公又有什么瓜葛呢?这得从陈蔡的关系上看。陈为妫姓国,在蔡之北,相与为邻。《史记》载:“齐桓公伐蔡,蔡败。南侵楚,至召陵。还过陈,陈大夫辕涛涂恶其过陈,诈齐令出东道。”这是明白无误的唇齿相依的关系。又《史记》载“(蔡)哀侯娶陈”,“(陈)厉公取蔡女”。陈蔡彼此嫁娶,有婚姻关系。楚国灭蔡灭陈,又复蔡复陈,道出了陈蔡的休戚与共。又,公子光“败陈蔡之师”,暗示陈蔡有军事同盟关系。总之,陈蔡始终是坐在一条船上的。到楚昭王时代,楚攻蔡,蔡共吴伐楚,蔡昭侯自然要把新即位的陈怀公拉过来。蔡昭侯嫁大孟姬与吴王,当是通过陈怀公从中拉线。陈怀公在蔡与吴的合婚上是起了重要作用的。昭侯作器,一方面称颂吴王(肇天子),一方面又用陈怀公元年记事,自有他的良苦用心,希望把陈国拉入同一阵容以对付楚国。\n陈怀公元年即鲁定公五年,吴王光十年,蔡昭侯十四年。此时的蔡已与楚彻底决裂,完全倒向了吴国一边。是年周正元月辛亥朔,初吉仍指朔。足见陈蔡均用周正,而不是依附楚国用夏正。\n为什么不必像李学勤先生理解为“吴王光元年”呢?吴王僚八年“吴使公子光伐楚。……因北伐,败陈蔡之师”,“九年(蔡昭侯元年)公子光伐楚”。昭侯初年绝不能与吴国友好。吴王光元年(前514年),蔡昭侯五年,楚昭王二年,蔡与楚还维持着友好关系。到昭侯十年(公子光六年),蔡侯还“朝楚昭王”,还“持美裘二,献其一于昭王,自衣其一”。结果得罪子常,招祸,“留之楚三年”。归蔡之后,昭侯并未亲近吴国,而是“之晋,请与晋伐楚”。可见,蔡昭侯十三年之前并未与吴王光结为婚姻,这个“元年”显然与吴王光无涉。\n再看吴王光鉴、吴王光编钟,均有“吉日初庚”。诚如李先生言,“所叙乃吴王嫁女于蔡之事”,所指乃公元前505年,周正五月庚戌朔。“吉日初庚”是五月初一。\n稍加理顺:公元前506年蔡昭侯十三年,楚怒,攻蔡,蔡昭侯使其子为质于吴,以共伐楚。冬,与吴王阖闾遂破楚入郢。\n公元前505年,蔡昭侯十四年,吴王光十年,陈怀公元年“正月初吉辛亥”(朔日辛亥),昭侯嫁长女给吴王光。五月吉日初庚(朔日庚戌)吴王嫁女于蔡。\n这就是蔡侯墓青铜器涉及的几个历日,铭文所叙,与文献所记吻合。初吉为朔是定点的,并不指朔前或初三、初五或初十。\n二、关于“准此逆推上去” # 李先生文章还引用张永山先生论文的话:“‘初吉’的含义自然是继承西周而来,准此逆推上去,当会对探讨西周月相的真实情况有所裨益。”\n因为李先生认为,蔡侯墓铜器说明,“初吉”不一定是朔日,但必在一月之初,合于王国维先生之说或类似学说。引用张永山的文字,不过也是“准此逆推上去”,西周时代的“初吉”自然也合于王国维先生的“四分一月”说,最终还是回到了他信奉的“月相四分”说的原位。\n这个“准此逆推上去”,貌似有理,实则是以今律古的不可取的手法。\n西周的月相记载,限于观象授时,只能是定点的,失朔限也只在四分术的499分(一日940分)之内,不可能有什么游移。为了证明西周月相干支有两天、三天的活动,有人便引用东汉《说文》“承大月二日,小月三日”关于“朏”的解说,或初二,或初三,有两天的活动。又引用刘熙《释名》释“望”,“月大十六日,小十五日”。或十六,或十五,有两天的活动。殊不知,这是汉代使用四分术推步而导致历法后天的实录。西周人重视月相,肉眼观察,历不成“法”,不得后天。承大月承小月是汉代之说,“准此逆推上去”,以之律古,认为西周一代必得如此,则无根据。\n以蔡侯墓铜器而言,时至春秋后期,“五行说”早已兴起,即使“初吉”可别作解说,也不可准此逆推上去。因为“五行说”是以纪日干支为基础,利用五行相生相克确定吉日与非吉日。准此,则一月内有多个吉日,终于形成了时至今日的流行观念,“初吉”的含义便只能是一月的第一个吉日了。\n从蔡侯墓铜器历日考求,“初吉”仍确指朔日,说明还没有受到“五行说”的影响。尽管如此,还是不必“准此逆推上去”。宁可谨严,不可宽漫。\n再谈吴虎鼎 # 朱凤翰先生主编的《西周诸王年代研究》(贵州人民出版社,1998)列有长安县文管会所藏吴虎鼎,铭文历日是:“唯十有八年十有三月既生霸丙戌,王在周康宫宫。”我注意到李学勤先生在该书《序》中说:“吴虎鼎作于十八年闰月,而同时出现夷王、厉王名号,其系宣王标准器断无疑义。”\n1998年12月我写有《吴虎鼎与厉王纪年》,此文收入我的《铜器历日研究》一书,只是未在刊物上公开发表过罢了。\n最近读到《文津演讲录(二)》中李先生的文章,文中说道:“特别是新发现了一件没有异议的宣王时代的青铜器吴虎鼎,它是周宣王十八年的十三月(是个闰月)铸造的,这年推算正好是闰年。”(该书112页)\n吴虎鼎记录的厉王十八年十三月天象,李先生视为宣王十八年十三月铸造的。李先生还说过:“铭中有夷王之庙,又有厉王之名,所以鼎作为宣王时全无疑义,因为幽王没有十八年,平王则已东迁了。”(《吴虎鼎考释》,载《考古与文物》1998年第3期)进一步,作为“夏商周断代工程”研究的主要依据之一——所谓“支点”,被大加利用,牵动就太大。正因为这样,我就不得不再加辨析,以正是非。\n一、宣王十八年天象 # 月相定点,定于一日。既生霸为望为十五,既生霸丙戌则壬申朔。查看宣王十八年实际天象,加以比较,就可以明了。按旧有观点,宣王十八年是公元前810年;按新出土眉县四十二年、四十三年两器及其他宣王器考知,宣王元年乃公元前826年,十八年是公元前809年。公元前810年实际天象是:子月癸丑71(癸丑02h56m)、丑月壬午、寅月壬子、闰月辛巳……实际用历,建子,正月癸丑、二月壬午……十二月丁丑、十三月丁未(括号内是张培瑜先生《中国先秦史历表》所载定朔的时(h)与分(m))。\n公元前809年实际用历,子正月丙子915(丁丑05h27m),二月丙午……十一月壬申、十二月辛丑764(壬寅02h12m)。\n这哪里有“十八年十三月壬申朔”的影子?除非你将月相“既生霸”胡乱解释为十天半月,才有可能随心所欲地安插。这岂不是太随意了吗?\n还有,公认的十八年克 是宣王器,历日是“隹十又八年十又二月初吉庚寅”。如果吴虎鼎真是宣王十八年器,这个“十三月既生霸丙戌”与“十二月初吉庚寅”又怎么能够联系起来呢?月相定点,“十二月庚寅朔”与“十三月壬申朔”风马牛不相及,怎么能够硬扯在一起呢?丙戌与庚寅相去仅四天,就算你把初吉、既生霸说成十天半月,两者还是风马牛不相及。这就否定了吴虎鼎历日与宣王十八年有关。\n二、厉王十八年天象 # 我们再来看看厉王十八年天象,司马迁《史记》明示,厉王在位三十七年,除了以否定司马迁为荣的少数史学家外,自古以来并无异议。共和元年是公元前841年,前推37年,厉王元年在公元前878年,厉王十八年乃公元前861年。\n公元前861年实际天象是:子月戊申756(己酉05h27m)、丑月戊寅315、寅月丁未814、卯月丁丑373……亥月癸酉605、(接公元前860年)子月癸卯161、丑月壬申660(癸酉04h 20m )、寅月壬寅219(壬寅17h 32m )……\n厉王十八年(公元前861年)实际用历,建丑,正月戊寅、二月丁未、三月丁丑……十二月(子)癸卯、十三月(丑)壬申。——这个“十三月壬申朔”,就是吴虎鼎历日“十三月既生霸丙戌”之所在。\n我们说,吴虎鼎历日合厉王十八年天象,与宣王十八年天象绝不吻合。\n三、涉及的几个问题 # 一件铜器上的历日,它的具体年代只能有一个,唯一解。为什么说法如此不一致呢?\n其一,对月相的不同理解,就是分歧之所在。\n自古以来,月相就是定点的,且定于一日。月相后紧接干支,月相所指之日就是那个干支日。春秋以前,历不成“法”,也就是说没有找到年、月、日的调配规律,大体上只能“一年三百又六旬又六日,以闰月定四时成岁”。年、月、日的调配只能靠“观象日月星辰,敬授民时”。观象,包括星象、物象、气象,而月亮的盈亏又是至关重要的。月缺、月圆,有目共睹,可借以确定与矫正朔望与置闰。在历术未进入室内演算之前,室外观象就是最重要的调历手段,所以月相记录频频。这正是古人留给我们的宝贵遗产。进入春秋后期,人们已掌握了年、月、日调配的规律,有了可供运算的四分历术,即取回归年长度36514日作为历术基础来推演历日,室外观象就显得不那么重要了,月相的记录自然也就随之逐步消失。\n铜器上以及文献上的月相保留了下来,后人就有一个正确理解的问题。\n西周行用朔望月历制,朔与望至关重要。朔称初吉、月吉,或称吉,又叫既死霸,或叫朔月。传统的解说,初吉即朔。《诗·小明》毛传:初吉,朔日也。《国语·周语》韦注:初吉,二月朔日也。《周礼》郑注:月吉,每月朔日也。\n最早对月相加以完整解说的是刘歆。《汉书·世经》中引用他的话:“(既)死霸,朔也;(既)生霸,望也。”他对古文《武成》历日还有若干解说,归纳起来:\n初一:初吉、朔、既死霸\n初二:旁死霸\n初三:朏、哉生霸\n十五:既生霸\n十六:既望、旁生霸\n十七:既旁生霸\n刘歆的理解是对的,月相定点,定于一日。月相不定点,记录月相何用?古文《武成》在月相干支后,又紧记“越×日”“翌日”,月相不定点,就不可能有什么“越×日”“翌日”的记录。《世经》引古文《月采》篇曰:“三日曰朏。”足见刘歆以前的古人,对月相也是作定点解说。望为十五,《释名·释天》“日在东,月在西,遥相望也”。《书·召诰》传:“周公摄政七年二月十五日,日月相望,故记之。”既望指十六,自古及今无异辞。初吉、月吉、朏、望、既望自古以来是定点的,焉有其他月相为不定点乎?明确月相是定点的,即所有月相都是定点的。不可能说,文献上的月相是定点的,而铜器上的月相是不定点的。所以,我们毫不动摇地坚持古已有之的月相定点说。用定点说解释铜器历日,虽然要求严密,难度很大,也正好体现它的科学性、唯一性。\n只是到了近代,王国维先生用四分术周历推算铜器历日,发现自算的天象与历日总有两天、三天的误差,才“悟”出“月相四分”。事实上,静安先生的运算所得并非实际天象,因为四分术“三百年辄差一日”,不计算年差分(3.06分)就得不出实际天象。“月相四分”实不足取。当然,更不可能有什么“月相二分”。按“二分说”,上半月既生霸,下半月既死霸,那真是宽漫无边,解释铜器历日大可以随心所欲了。谁人相信?\n其二,对铭文的理解明显不同。\n李先生反复强调,吴虎鼎“同时出现夷王、厉王名号”,所以“系宣王标准器断无疑义”。\n查吴虎鼎铭:“王在周康宫宫,导入右吴虎,王命膳夫丰生、司空雍毅,(申)敕(厉)王命。”\n关于“康宫宫”,按唐兰先生解说,通夷,宫指夷王之庙。重要的是王命“(申)敕(厉)王命”这一句。后面有“(申)敕(厉)王命”,前一个“王”就一定是指周宣王吗?我们以为,不是。这明明是追记,是叙史。铭文中的“王”,都是确指厉王,即“厉王在夷王庙,右者导引吴虎入内,厉王命膳夫丰生、司空雍毅,重申他厉王的指令”。前两处用“王”,是因后面“厉王”而省,而与宣王无关。正因为这样,这个历日就与它下面的记事(厉王时事)结合,根本不涉及宣王。\n其三,铜器历日不等于铸器时日。\n吴虎鼎历日是叙史,与周宣王无关,更不会是“周宣王十八年十三月铸造的”。这是考古学界常犯的错误,把铜器历日统统视为铸器时日。\n如果排除时王生称说,吴虎鼎作于厉王以后,或共和,或宣王,都不会错。作器者的本义是在显示他(或其先人)曾经在厉王身边的崇高地位,于是追记厉王十八年十三月的往事。类似这种叙史,这种追记,铜器中甚多,如元年曶鼎、十五年趞曹鼎、子犯和钟……这些铜器历日怎么能看成是铸器时日呢!\n簋及穆王年代 # 国家博物馆新藏无盖簋簋,王年、月、月相、日干支四要素俱全,是考察西周年代又一个重要材料。《中国历史文物》2006年第3期发表了王冠英、李学勤先生的文章 [1] ,编辑部“希望能听到更多学者的意见”,进行深入研究。读了王、李二位文字,本人想就此谈谈我的看法,仅供参考。\n簋铭文重要的有两点:其一,关于“”这个人;其二,簋历日及有关时王的年代。\n这个人,在二十四年九月既望(十六)庚寅日,周“王呼作册尹册申命曰:更乃祖服,作家嗣(司)马”。他是承继祖父的官职,祖父叫“幽伯”。这个“册申命”,即重申册命,商周时期应是常见。《帝王世纪》载:“文王即位四十二[年],岁在鹑火,文王更为受命之元年,始称王矣。”文王死后,武王承继,还得商王重申册命。《逸周书·丰保》就记载姬发正式受命为西伯侯,“诸侯咸格来庆”,那是文王死后第四年的事了。《史记·周本纪》载,武王克商后“封周公旦于少昊之虚曲阜,是为鲁公。周公不就封……而使其子代就封于鲁。……伯禽即位之后,有管、蔡等反也”。伯禽在周公摄政七年期间是代父治鲁,到成王亲政,《汉书》载:“元年正月己巳朔,此命伯禽俾侯于鲁之岁也。”师古注:“俾,使也,封之始为诸侯。”这是成王对伯禽重申册命,尽管“伯禽即位”好几年了。“册申命”,在世袭的体制下,并不是自然的交接班,还得天子君王的册封认可,就含有正式任命之义。\n的祖父不过是“家司马”,管理王室事务的某个方面。在二十四年接手之后,受到周王的赏识,几年后得到提升,做了地位很高的引人朝见周王的“司马井伯”。铜器铭文涉及“司马井伯”的,已有十多件,据此系联,可以归并这些铜器为相近的王世,至少不会相距太远。\n关于簋的具体年代,由于年、月、月相、日干支四样俱全,就便于我们考察。因为历日的制定得依据天象,历日自然也是反映天象的。我们可用实际天象勘比历日,得出确切的年月日。当然,这种考校得有个原则,不能凭个人的想当然。比如,月相是定点的,就不能说一个月相管三天两天,七天八天,甚至十天半个月。朏为初三,望为十五,既望为十六,古今一贯,定点的,其他月相怎么就不定点了呢?文献记载:“越若来二月既死魄,越五日甲子朝。”越,铜器用粤,或用雩,都是相距义。“既死魄”不定点,解释为十天半月,何有过五日的甲子?用一“越”字,就肯定了月相定点。\n实际天象是可以推算复原的,用四分术加年差分推算,得平朔平气(合朔、交气取平均值)。 [2] 用现代科技手段,可得出准确的实际天象,张培瑜《中国先秦史历表》有载,可直接利用,免去繁复的运算。\n古文《武成》《逸周书·世俘》记载了克商时日的月朔干支及月相,稍加归纳,得知:正月辛卯朔,二月庚申朔,四月己丑朔。 [3]\n以此勘合实际天象,公元前1044年、前1075年、前1106年具备“正月辛卯朔,二月庚申朔……”。历朔干支周期是三十一年,克商年代必在这三者之中。依据文献记载(纸上材料)、考求铜器铭文(地下材料)、验证实际天象(天上材料),做到“三证合一”,武王克商只能是公元前1106年。 [4] 依据《史记·鲁世家》及《汉书·律历志》记载,西周总年数是:\n武王2年+周公摄政7年+伯禽46年+考公4年+炀公60年+幽公14年+魏(微)公50年+厉公37年+献公32年+真公30年+武公9年+懿公9年+伯御11年+孝公25年=336年。\n从平王东迁公元前770年,前推336年,克商当是公元前1106年。\n《晋书》载,“自周受命至穆王百年”,有人说“受命”指“文王受命”,实乃指武王克商。武王2年+周公摄政7年+成王30年+康王26年+昭王35年=100年,正百年之数。《小盂鼎》铭文旧释“廿又五祀”,当是“卅又五祀”,乃昭王时器。昭王在位三十五年,享年七十岁以上,才可能有一个五十岁的儿子穆王。昭王在位十九年说违背起码的生理常识。\n又,《史记·秦本纪》张守节《正义》云:“年表穆王元年去楚文王元年三百一十八年。”楚文王元年即周庄王八年,合公元前689年。318+689=1007,不算外,穆王元年当是公元前1006年,至克商之年公元前1106年正百年之数。\n穆王在位五十五年,《竹书纪年》《史记·周本纪》均有明确记载。穆王在位的具体年代就明白了,公元前1006年—公元前952年,共王元年当为公元前951年。\n在这样的背景下考求簋及其有关铜器的具体年代才有可能,而簋及有关铜器的历日干支反过来又能验证西周王年的正确与否。其中的关键环节是校比实际天象,铜器历日与实际天象完全吻合,才能坐实铜器的具体年代。\n簋历日:唯廿又四年九月既望庚寅。\n这个二十四年的王,指穆王的话,核对穆王二十四年实际天象,看它是否吻合就行了。穆王元年乃公元前1006年,二十四年即公元前983年。\n查公元前983年实际天象:子月丁卯139分(丁卯08h 51m ),丑月丙寅……未月癸巳812分(癸巳12h 00m ),申月癸亥371分(壬戌20h 19m )…… [5]\n是年建子,正月丁卯朔……九月癸亥朔。癸亥初一,既望十六戊寅。簋书戊寅为庚寅,取庚寅吉利之义。金文历日,书丁亥最多,其次庚寅,校比天象,细加考查,并非都是实实在在的丁亥日、庚寅日。凡亥日可书为丁亥,凡寅日可书为庚寅。丁亥得以亥日为依托,庚寅得以寅日为依托,并非宽泛无边。这就是铜器历日研究归纳出来的“变例”:丁亥为亥日例,庚寅为寅日例。 [6]\n盘:隹廿又八年五月既望庚寅。\n查宣王二十八年公元前800年天象:建寅,五月辛亥朔,既望十六丙寅。盘书丙寅为庚寅,如此而已。\n克钟:隹十又六年九月初吉庚寅。\n克:隹十又八年十又二月初吉庚寅。\n作器者为一人,当是同一王世。据历朔规律知,有十六年初吉庚寅,不得有十八年十二月初吉庚寅,历日不容。查宣王十八年公元前810年天象:是年建子,十二月戊寅朔。是作器者书戊寅为庚寅。克钟合宣王十六年公元前812年天象:建亥,九月辛卯54分(06h 24m ),余分小,实际用历书为庚寅朔。克历日作变例处理,两相吻合。否则,永无解说。\n簋涉及走簋,走簋铭文中有“司马井伯”,这个“井伯”并不是,而是的文祖“幽伯”。十二年走簋在簋前,不在簋之后。这是从历日中考知的。\n走簋:隹王十又二年三月既望庚寅……司马井伯[入]右走。\n查穆王十二年公元前995年天象:建丑,三月乙亥641分(13″23)。乙亥朔,既望十六庚寅。这是实实在在的庚寅日。走簋历日确认这个司马井伯不是,当是的祖父;走簋历日确认穆王十二年天象与之吻合。(见《西周王年论稿》,270页)\n穆王二十七年公元前980年天象:建丑,六月丙子朔。这就与“师奎父鼎”历日吻合。\n师奎父鼎:隹六月既生霸庚寅……司马上井伯右师奎父。既生霸为望为十五,丙子朔初一,有十五庚寅。\n这个司马井伯当然是了。至此,穆王二十七年,做家司马的已是地位很高的司马井伯了。\n师簋、豆闭簋也载有司马井伯。\n师簋:隹二月初吉戊寅……司马井伯右师……\n豆闭簋:隹二月既生霸,辰在戊寅……井伯入右豆闭。\n这是穆王五十三年的事。查穆王五十三年公元前954年天象:建丑,二月戊寅朔。既生霸十五壬辰。初一戊寅,司马井伯[入]右师;十五壬辰,司马井伯[]入右豆闭。\n穆王时代,朔望月历制已经相当成熟了,朔日望日都视为吉日。这里提供两个证据:其一,《逸周书·史记》载:“乃取遂事之要戒,俾戎夫主之,朔望以闻。”这是穆王要左史辑录可鉴戒的史事,每月朔日望日讲给自己听。其二,《穆天子传》记录穆王十三年至十四年的西征史事,月日干支与公元前994年、前993年实际天象完全吻合,《传》除记录日干支外,援例记录季夏丁卯、孟秋丁酉、孟秋癸巳、[仲]秋癸亥、孟冬壬戌,即每月的朔日干支。\n铜器记录大事,日干支基本上都在朔望(含既望),这与朔望月历制视朔望为吉日有关。穆王“朔望以闻”,朔日(初吉戊寅)接见师,十五(既生霸)接见豆闭,既望册申命,都体现了这一文化礼制现象。月相是定点的,记录大事的铜器上的月相更不会有什么游移,也必须是定点的。\n司马井伯,从穆王后期直到共王时代,一直位高权重。师虎簋、趞曹鼎、永盂等都反映了共王一代司马井伯的活动。\n穆王在位五十五年,共王元年即公元前951年。\n师虎簋:隹元年六月既望甲戌……井伯入右师虎。(《大系录》58)\n既望十六甲戌,必己未朔。查共王元年公元前951年天象:子月辛酉245分(辛酉08h 41m )。上年当闰未闰,建亥,二月辛酉,三月庚寅,四月庚申,五月己丑,六月己未,七月戊子。\n这个六月己未朔,就是师虎簋历日之所在。郭沫若定师虎簋为共王元年器,正合。\n趞曹鼎:隹十又五年五月既生霸壬午。(《大系录》39)\n这里说说“永盂”。\n《文物》1972年第1期载,永盂:隹十又二年初吉丁卯。\n历日有误。历日缺月。铭文有井伯,有师奎父,可放在共王世考校。查共王十年公元前942年实际天象:子月己亥146分(07h 42m ),丑月戊辰,寅月戊戌,卯月丁卯。建寅,二月朔(初吉)丁卯。这就是永盂历日之所在。当是:[共]王十年二月初吉丁卯。\n这让我们明白了两点:1.铜器历日也可能出现误记,当然并非一件永盂;2.可以借助实际天象恢复历日的本来面目,纠正误记的历日。\n以上文字,利用实际天象考察铜器历日,自然会得出这样的结论:月相是定点的,簋乃记周穆王二十四年事,穆王元年在公元前1006年,穆王在位五十五年,共王元年即公元前951年。\n伯吕父的王年 # “断代工程简报”151期,发有李学勤先生关于“伯吕父”的文章。该器铭文的王年、月序、月相、干支四样俱全,考证其具体年代是可能的。就此谈谈我的看法。\n铭文所载历日是:惟王元年六月既眚(生)霸庚戌,伯吕又(父)作旅。\n这个历日明白无误是作器时日,从器型学断其大体年代是可行的。陈佩芬先生认为“此的形制、纹饰均属西周晚期”,李先生以为“应排在西周中期后段”。\n以历日勘比天象,西周晚期周王的元年无一可合。“排在西周中期后段”则是唯一的首选。\n月相定点,定于一日。既生霸为望为十五,十五庚戌,月朔为丙申。连读是:[]王元年六月丙申朔,十五既生霸庚戌,伯吕父作旅。\n历日四要素俱全的铜器已有数十件,用历日系联,每一件铜器都不会是孤立的,都可以在具体的年代中找到它的准确位置。这就是历日勘比天象的妙处。董作宾先生就此将铜器列入各个王世,排出共王铜器组、夷王铜器组、厉王铜器组……得出的结论似更可信。\n共和元年为公元前841年,这是没有疑义的。厉王在位三十七年,司马迁有记载,不必推翻。厉王元年为公元前878年。\n有两件铜器的历日与公元前878年的天象吻合。\n师簋:隹王元年正月初吉丁亥。(《大系录》98)\n师兑簋甲:隹元年五月初吉甲寅。(《大系录》146)\n公元前878年实际天象:丑正月丁亥19h56m★;二月丙辰,三月丙戌,四月乙卯,闰月乙酉,五月甲寅18h36m★,六月甲申……(★为符合天象的铜器历日)\n前推,当是夷王。夷王世的铜器有:\n卫盉:隹三年三月既生(死)霸壬寅。(《文物》1976年第5期)\n兮甲盘:唯五年三月既死霸庚寅。(《大系录》134)\n谏簋:隹五年三月初吉庚寅。(《大系录》101)\n大师虘簋:正月既望甲申……隹十又二年。(《考古学报》1956年第4期)\n从夷王末年向前考察实际天象,公元前882年丑正月庚辰02h07m,二月己酉,三月己卯……正月庚辰分数小,司历定己卯★。这就是大师虘簋历日之所在。正月既望十六甲申,则月朔己卯。\n定公元前882年为夷王十二年的话,公元前889年为夷王五年。公元前889年天象:丑正月辛卯,二月庚申,三月庚寅03h08m★,四月己未……三月庚寅就是兮甲盘、谏簋历日之所在。兮甲盘用“既死霸”,谏簋用“初吉”,并无二致。\n前推,公元前891年当为夷王三年。公元前891年天象:上年当闰未闰,子正变亥正,正月癸卯,二月壬申,三月壬寅04h30m★。这就是卫盉历日之所在。卫盉的“既生霸”应是“既死霸”,忌“死”用“生”而已,不为误。这就是“铜器历日研究”中的“既生霸为既死霸例”(详见《铜器历日研究》,贵州人民出版社,1999)。\n用铜器历日勘合天象,夷王元年为公元前893年(鲁厉公卅一年),在位十五年。\n往前,进入另一王世。史书记为孝王,后人多从。用铜器历日考校,当是懿王。用历日系联,这一王世的铜器有:\n元年曶鼎、二年王臣簋、三年柞钟、九年卫鼎、十五年大鼎、二十年休盘、二十二年庚赢鼎。\n庚赢鼎、休盘靠近夷王,不妨以两器为例讨论之。\n庚赢鼎:隹廿又二年四月既望己酉。(《大系录》22)\n休盘:隹廿年正月既望甲戌(壬戌)。(《大系录》143)\n既望己酉,则四月甲午朔;甲与壬形近,既望甲戌则己未朔;既望壬戌则丁未朔。\n公元前897年天象:丑正月丁未651分★(戊申00h11m),二月丁丑……\n公元前895年天象:丑正月乙丑,二月乙未,三月乙丑,四月甲午21h09m★。\n其他多件懿王铜器均可如法一一勘合。得知,公元前895年乃懿王廿二年,夷王元年是公元前893年,懿王在位当是二十三年。\n前推,公元前899年有“天再旦于郑”的天象,不可易。公元前899年合懿王十八年。古多合文,“十八”应是合文,误释为“元”,便出现“懿王元年天再旦于郑”的文字。我们确定夷王之前是懿王,当然与“天再旦于郑”的日食天象有关。“共懿孝夷”的王序,虽有新出“”(李学勤先生释为“佐”)器佐证,那实在是“五世共庙制”造成的误会,当专文解说。实际的王序是:共、孝、懿、夷。那是铜器历日明确告诉了我们的。\n经过历日与天象勘合,十五年大鼎历日合公元前902年天象,九年卫鼎合公元前908年天象,三年柞钟合公元前914年天象,二年王臣簋合公元前915年天象,元年曶鼎合公元前916年天象。\n公元前916年实际用历:丑正月戊辰,二月丁酉,三月丁卯,四月丁酉★,五月丙寅,六月丙申★……(见《西周王年论稿》147~148页)这里的“四月丁酉”就是曶鼎的“四月辰在丁酉”。这里的“六月丙申(朔)”就是伯吕父的“惟王元年六月既生霸庚戌(丙申朔)”。\n不难看出,伯吕父的历日吻合公元前916年实际天象,这个元年的王是懿王。\n结论是明确的:伯吕父乃周懿王元年器,与曶鼎同王同年,其绝对年代是公元前916年。\n关于成钟 # 《上海博物馆集刊》第8期刊发《新获两周青铜器》一文,内有“成钟”一件,钲部与鼓部有文:“隹(唯)十又六年九月丁亥,王在周康徲宫,王寴易成此钟,成其子子孙孙永宝用享。”陈佩芬先生说:“从成钟形式和纹饰判断,这是属于西周中晚期的器。自西周穆王到宣王,王世有十六年以上的仅有孝王和厉王,据《西周青铜器铭文年历表》所载,西周孝王十六年为公元前909年,九月甲申朔,四日得丁亥。西周厉王十六年为公元前863年,九月丙戌朔,次日得丁亥,此两王世均可相合。铭文中虽无月相记载,但都与‘初吉’相合。”\n我们也注意到李学勤先生的文字:“成钟的时代,就铭文内容而言,其实是蛮清楚的。铭中有周康宫夷宫,年数又是十六年,这当不外于厉王、宣王二世。查宣王十六年,为公元前812年,该年历谱已排有克鎛、克钟,云‘十又六年九月初吉庚寅’,据《三千五百年历日天象》,庚寅是该月朔日。成钟与之月分相同,而日为丁亥,丁亥在庚寅前三天,无法相容。再查历谱厉王十六年,是公元前862年,其年九月庚辰朔,丁亥为初八日。这证明,把成钟排在厉王十六年,就历谱来说,刚好是调协的,由此足以加强我们对历谱的信心。”\n综合两位先生的见解,铭文历日应当这样理解:厉王十六年九月初吉丁亥。\n这里有两个重要的问题:厉王十六年是公元前863年,还是公元前862年?初吉是指朔日(定点的),还是指初二、初四或初八(不定点的,包括初一到初八甚至朔前一二日)?\n公元前862年天象:九月庚辰朔。如果初吉定点,指朔日,公元前862年就不可能是厉王十六年,“断代工程”关于西周年代的结论则将从根本上动摇,什么“金文历谱”就成了想当然的摆设。只有将“初吉”理解为上旬中的任何一天,公元前862年才能容纳成钟的历日。与此相应,成钟历日可以适合若干年份的九月上旬的丁亥。如公元前863年、公元前909年等等。排定成钟历日就有很大的随意性,大体上可以随心所欲。\n再看克钟历日:“惟十又六年九月初吉庚寅。”校比宣王十六年公元前812年天象:“九月庚寅朔”,正好吻合。这里,初吉即朔,没有摆动的余地。正因为月相定点不容许有什么摆动,没有随意性,一般人就感到很难,不得不知难而退,避难就易,误信“月相四分”,甚至发明了“月相二分”(一个月相可合上半月或下半月任何一天)。定点的确很难,但体现了它的严密,不容你主观武断,避免了信口雌黄。克钟历日,初吉定点,只能勘合前公元812年天象,坐实在宣王十六年,摆在任何其他地方都不合适,这就叫“对号入座”。\n让我们来分析成钟的历日:十六年九月(初吉)丁亥。用司马迁“厉王在位三十七年”说,厉王十六年为公元前863年。查对公元前863年实际天象:冬至月(子月)朔庚寅、丑月庚申00h18m、寅月己丑、卯月戊午20h16m、辰月戊子、巳月丁巳19h56m、午月丁亥、未月丁巳01h20m、申月丙戌17h19m、酉月丙辰、戌月丙戌01h22m、亥月乙卯。(见张培瑜《中国先秦史历表》,55页)\n对照四分术殷历,公元前863年天象:子月庚寅、丑月庚申66、寅月己丑、卯月己未124、辰月戊子、巳月戊午182、午月丁亥、未月丁巳240、申月丙戌739、酉月丙辰、戌月乙酉797、亥月乙卯。(见张闻玉《西周王年论稿》,303页)\n《历表》用定朔,四分术用平朔,余分略有不同。一般人看来,有卯月、巳月、戌月三个月的干支不合,好像彼此相差一天。因为一个朔望月是29.53日,干支纪日以整数,余数0.53不能用干支表示,而合朔的时刻不可能都在半夜0点,或早或晚,余分就有大有小。表面上干支不合,而余分相差都在0.53日之内。这是定朔与平朔精确程度不同造成的正常差异。余分只要在0.53日(约13小时,四分术499分)之内,都应视为吻合。\n西周观象授时,历不成法,朔闰都由专职的司历通过观测确定。以上为例,卯月余分大,戊午20h16m(合朔在晚上20点16分),司历可定为己未。从四分术角度看,己未124,余分小(合朔在凌晨3点多),司历可定为戊午。申月丙戌(定朔与四分术干支同),余分大,司历不用丙戌而定为“丁亥”。司历一旦确定,颁行天下,这就是“实际用历”。\n可以看出,《历表》用定朔,有连小月(庚申、己丑、戊午),两个连大月(丁巳、丁亥、丁巳;丙戌、丙辰、丙戌)。四分术殷历无连小月,只有连大月,公元前864年最后三个月连大,公元前863年一大一小相间。\n可以推知,公元前863年的实际用历当是:正月(子月)庚寅、二月庚申、三月己丑、四月己未、五月戊子、六月戊午、七月丁亥、八月丁巳、九月丁亥、十月丙辰、十一月丙戌、十二月乙卯。——大体上一大一小相间,取七月、八月连大。\n公元前863年实际用历:九月丁亥朔。这就是成钟“唯十又六年九月丁亥”所记载的历日。成钟的“十六年”,公元前863年,即厉王十六年。\n大量的铜器历日与实际天象勘合,结论都是一个:厉王在位三十七年,厉王元年即公元前878年。根本不存在什么“共和当年改元”的神话(另文述及)。\n李先生的“金文历谱”,也即是“断代工程”的“金文历谱”的根本失误在哪里?这是一个值得认真探讨的问题。\n李先生以铜器器型学为基准,确定器物的王世,再用铭文历日去较比实际天象,历日的月相用宽漫无边的“四分说”甚至“二分说”进行解说,最后排出一个“金文历谱”。\n粗看起来,这样的研究程序也似乎无可挑剔,细细一琢磨,其中的问题就不少。比如,器型涉及制作工艺,可以反映制作的时代,铭文历日是不是就是制作时日?一般青铜器专家总是将铭文历日视为制作时日,器型就成了断代的基本依据。事实上,铭文可以叙史。正如郭沫若先生所说,“其子孙为其祖若父作祭器”,追记先祖功德正是叙史,与史事有关的历日就与器物的制作无关,这几乎是简单的常识。从器型学的角度看,当是西周晚期器物,而铭文记录西周中期甚至前期的史事,也属正常。\n其二,自古以来,月相是定点的且定于一日。月相紧连干支,就是记录那个干支日的月相。古人观察月相做什么?“观象授时”,确定每年的朔闰。这其中,月朔干支尤其重要。月亮的圆缺是确定朔干支的依据,或者说唯一的依据。月相不定点,记录月相何用?初吉为朔,望为十五,既望为十六,朏为初三,是定点的;焉有其他月相为不定点乎?如果一个月相可以上下游移十天半月,紧连的干支怎能纪日?\n其三,排定历谱只能以实际天象为依据,金文历日必须对号入座,舍此别无他法。把铜器器型进行分类,那是古董鉴赏家的方法,不可能在此基础上产生什么“金文历谱”。\n不难明白,由于先排定了器型,器物上的历日便不可能与实际天象吻合,再错下去,就只有对月相进行随心所欲的解说,以求与天象相合。\n比照成钟的历日,不是很能说明这些问题么?\n关于士山盘 # 摘要士山盘铭文历日当是“唯王十又六年九月既生霸丙申”,不是“甲申”。将西周中期诸多铜器历日系联,可归类为同一王世的四组铜器。士山盘历日与共王诸器历日不合,也与元年逆钟、四年散伯车父鼎、六年师伯硕父鼎、八年师才鼎、十二年大鼎等同一王世诸器历日不合,唯合元年曶鼎、二年王臣簋、三年柞钟、九年卫鼎、十五年大鼎等同一王世诸器。懿王元年乃公元前916年。懿王十八年公元前899年四月丁亥朔日食,“天再旦于郑”。“十八”乃合文,后人误释为“元”,便有“懿王元年天再旦于郑”的记载。士山盘历日与公元前901年天象吻合,此乃懿王十六年。\n关键词士山盘丙申既生霸为既死霸例\n从“断代工程”简报上先后读到有关新出现的成钟、士山盘两件器铭历日的文字,李学勤先生以此来“检验《报告》简本中的西周金文历谱”,认为“两器都可以和‘工程’所排历谱调谐,由此可以加强对历谱的信心”。另一位专家陈久金先生同意朱凤瀚《士山盘铭初释》所论,认为“士山盘的历日也合于‘工程’对金文纪时词语‘既生霸’的界说”。\n朱凤瀚先生的文章发于《中国历史文物》2002年1期,还附有相片及拓本,拓本较摄影更为清晰。细审拓本,铭文历日当是“隹王十又六年九月既生霸丙申”,朱先生释为“甲申”。“丙”字在“霸”字右边的“月”下,比较清楚。如果从历日角度研究,这一字之差,牵动就大了,也就无从谈及对“金文历谱”的肯定。\n就成钟历日“隹十又六年九月丁亥”而言,依自古以来的月相定点说来验证历日天象,符合厉王十六年九月丁亥朔。厉王十六年当是公元前863年,而不是“金文历谱”所排定的公元前862年。本人另有专文《关于成钟》,已做了详细讨论。\n现在来讨论一下“士山盘”历日,结论恐怕就与“断代工程”专家们的看法不同。相反,两件器物的历日,足以否定“工程”的“金文历谱”。\n正确识读士山盘历日至关重要:隹王十又六年九月既生霸丙申。\n朱凤瀚先生断为西周中期器,列入共王十六年。我只想说,历日合懿王十六年公元前901年天象。\n我可以列出十个、二十个、三十个以上的“支点”来支持西周总年数是336年的结论。可参考《西周王年足徵》 [7] ,“足徵”,不过就是“证据充足”之义。\n朱先生文章引用了“宰兽簋”历日“六年二月初吉甲戌”(见《文物》1998年第8期)。与天象框合,符合公元前1036年昭王六年天象,建丑,正月甲辰,二月甲戌。初吉指朔日。紧接着有“齐生鲁方彝盖”历日,“八年十二月初吉丁亥”,这与昭王八年公元前1034年天象相吻合:建丑,正月壬戌,二月壬辰……十一月戊午,十二月丁亥。两器历日,前后连贯,依董作宾先生的研究可归入“昭王铜器组”。\n往下,昭王十八年公元前1024年天象,建寅,正月甲午……四月壬戌……八月庚申。这便是“静方鼎”历日:八月初吉庚申,[]月既望丁丑。月相定点,既望十六丁丑,则壬戌朔。四月壬戌朔,正合。“月既望”,非当月既望,而是追记前事,实乃“四月既望丁丑”,与曶鼎铭文追记前事同例。\n接着,昭王“十九年,天大曀,雉兔皆震”,这是公元前1023年午月丙戌的日食天象。查《日月食典》,查张培瑜《历表》(即《中国先秦史历表》,下同),都可以证实公元前1023年六月丙戌日确有日食,这几乎是公元前1023年为昭王十九年的铁证。\n小盂鼎有“廿又五祀”说,又确实存在“卅又五祀”的版本。校比天象,小盂鼎历日“八月既望辰在甲申”合昭王三十五年公元前1007年天象,建子,七月甲寅,八月甲申。“辰在甲申”即甲申朔。\n穆王元年为公元前1006年,在位五十五年。共王元年乃公元前951年。\n师虎簋历日:隹元年六月既望甲戌。(《大系录》58)最早,王国维氏断为宣王元年器,后来郭沫若氏断为共王元年器。六月既望十六甲戌,则六月己未朔。王氏云:“宣王元年六月丁巳朔,十八日得甲戌。是十八日可谓之既望也。”王氏用四分术推算,不知道四分术先天的误差,也读不到张培瑜的《历表》,所以便有“四分月相”的错误结论。\n如果我们自己用四分术加年差分推算,或者直接查对张培瑜氏《历表》,公元前951年(共王元年)与公元前827年(宣王元年),都有六月己未朔。虽然可以肯定月相定点,既望是十六,不可能是十八,而一器合宣王又合共王,该如何解释?\n有历术常识的人都会知道,日干支60日一轮回,月朔干支31年一轮回。公元前951年与公元前827年,正是月朔干支的四个轮回,所以都有六月己未朔。\n应该说,郭沫若氏看到的器物更多,断代更为合理。近年发现虎簋盖,可以与师虎簋联系,师虎簋列为共王元年器,也就顺理成章。\n趞曹鼎:隹十又五年五月既生霸壬午。月相定点,既生霸十五壬午,则五月戊辰朔。大家相信,这是共王标准器。\n查对共王十五年公元前937年天象:建子,正月己巳朔……四月戊戌朔,五月戊辰朔。完全吻合。\n西周中期器物甚多,最值得注意的是几个元年器。明确这些元年器的准确年代,以此为基准,用历日系联其他器物就有可能归类为同一王世的一组铜器,这正是董作宾先生的研究方法。用历日系联,就得对历术有通透的了解,最好是自己能推演实际天象,方可做到心明眼亮,是非分明,从而避免人云亦云。\n涉及西周中期铜器,盛冬铃先生有一篇很好的文章,发在《文史》十七辑。笔者当年从中受到许多启发,才有了尔后对铜器历日的深入研究。盛先生还来不及从历术的角度进行探讨,就过早地走了,实在是铜器考古学界的悲哀。当今,能达到盛冬铃先生研究水平的人似乎太少,而想当然的主观臆度者比比皆是,皮相之见又自视甚高者亦大有人在。\n先说元年器逆钟,历日“隹王元年三月既生霸庚申”。(《考古与文物》1981年第1期)\n今按,既生霸十五庚申,则丙午朔。共王以后,公元前928年天象:建子,正月丁未,二月丁丑,三月丙午(定朔丁未01h50m,余分小,司历定为丙午)。\n这个元年的王,只能是懿王或孝王,共王在位23年得以明确。\n与逆钟历日系联的器物有“散伯车父鼎”,历日“隹王四年八月初吉丁亥”。合公元前925年天象:建子,八月丁亥朔。\n还有“师伯硕父鼎”,历日是“隹六年八月初吉乙巳”。合公元前923年天象:建子,八月乙巳朔。\n还有“师才鼎”,历日是“隹王八祀正月,辰在丁卯”。辰在丁卯即丁卯朔。合公元前921年天象:上年当闰不闰,故建亥,正月丁卯朔。\n还有“大簋”,历日是“隹十又二年二月既生霸丁亥”。既生霸十五丁亥,则癸酉朔。合公元前917年天象,建子,二月癸酉朔。\n这样,从元年逆钟,到四年散伯车父鼎,到六年师伯硕父鼎,到八年师才鼎,到十二年大簋,铜器历日与实际天象完全吻合。以上都是同一王世器。这个元年为公元前928年的王应该是孝王,在共王之后,兄终弟及。共王之后,不是司马迁所记的懿王,懿王应在孝王之后。说见《共孝懿夷王序王年考》 [8] 。\n再看一件元年器曶鼎。\n曶鼎历日:“唯王元年六月,既望乙亥”;“惟王四月既生霸,辰在丁酉”。\n王国维氏以为,四月在六月前,为同一年间事。可从。铭文分三段。此乃立足六月(首段),又追记四月(次段),更追记往“昔”(三段)。\n“辰在丁酉”即丁酉朔,既生霸十五干支辛亥不言自明。当年朔闰是:四月丁酉,五月丙寅,六月丙申。\n丙申朔,既望十六即辛亥。古人记亥日,以乙亥为吉,丁亥为大吉。这两段历日都是辛亥。次段四月不言辛亥,而以月相“既生霸”称之,补充朔日“辰在丁酉”。前段还是避开辛亥,以吉日“乙亥”代之。\n校比公元前916年天象,可考知实际用历是建丑,四月丁酉朔,六月丙申朔。\n详见《曶鼎王年考》。 [9]\n接续下去,王臣簋历日“隹二年三月初吉庚寅”,合公元前915年天象。\n接续下去,柞钟历日“隹王三年四月初吉甲寅”,合公元前914年天象。\n接续下去,卫鼎历日“隹九年正月既死霸庚辰”,合公元前908年天象。\n接续下去,大鼎历日“隹十又五年三月既(死)霸丁亥(乙亥)”,合公元前902年天象。\n公元前899年,懿王十八年的四月丁亥朔日(建丑),天亮后发生了一次最大食分为0.97的日全食,天黑下来,到5.30分,天又亮了。当是“懿王十八年天再旦于郑”。以讹传讹,文献记载为:“懿王元年,天再旦于郑。”古人竖写,“十八”误合为“元”。“合二字为一字之误”,古已有之。最明显的是:“左师触龙言”成了“左师触詟”,迷误了两千余年。前几年出土了地下简文,才算明白了:只有触龙,并无触詟。或者说,“十八”本来就是合文,正如甲文“義京”“雍己”“祖乙”是合文一样,后人将“十八”释读成了“元”。\n以上器物,当归属懿王铜器组,这是借助器物自身的历日系联出来的,没有任何人为的强合或臆度。这是天象,是经得起历史检验的。\n公元前916年是懿王元年,懿王十六年当是公元前901年,是年天象:建丑,正月庚子,二月庚午,三月己亥,五月戊戌,六月戊辰,七月丁酉,八月丁卯,九月丙申,十月丙寅……这里的“九月丙申朔”,就是士山盘历日所反映的天象。\n“士山盘”历日是“既生霸”,月相定点,既死霸为朔为初一,既生霸当是十五。又岂能吻合?\n古今华夏人的文化心态是相通的:图吉利,避邪恶。“死”,不吉利。所以有人就忌讳,自然也有人不忌讳。不忌讳的,直言之,直书之。忌讳的,可以少一“死”字,有意不言不书;也可以改“死”为“生”,图个吉利。\n有意避“死”字不书的,如大鼎,历日“隹十又五年三月既霸丁亥”。我们早先总以为,“既霸”不词,是掉了字,是“历日自误”。经历术考证,乃“既死霸”,当补一“死”字。如果从避讳角度看,乃有意为之,不是误不误的问题。\n改“死”为“生”的忌讳,就是“既生霸为既死霸例”。虽书为“既生霸”,实即“既死霸”(朔日),以望日十五求之,无一处天象符合;以朔日求之,则吻合不误。铜器历日已有数例,我们归纳为“既生霸为既死霸”这一特殊条例,借以解说特殊铜器历日。 [10] 公元前901年懿王十六年,九月丙申朔,这就是士山盘历日“隹王十又六年九月既生(死)霸丙申”的具体天象位置所在。\n结论很清楚:月相定点,定于一日;没有两天、三天的活动,更不得有七天、八天的活动;什么“上半月既生霸、下半月既死霸”更是想当然的梦呓。王国维氏用四分术求天象,没有考虑年差分(就是365.25日与真值365.2422日的误差),便“悟”出“四分月相”,已经与实际天象不合。在“四分”的基础上,“二分月相”走得更远,谁人相信?以此排定“金文历谱”,以此考求西周年代,其结论的错误也就不言而喻了。\n穆天子西征年月日考证——周穆王西游三千年祭 # 公元2007年是周穆王西游三千年的重要纪年,千载难逢。我们应当记得它,应当纪念它。\n周穆王西征,有《穆天子传》为证。事涉“三千年”,当然得从西周的年代说起。\n司马迁《史记》的明确纪年始于西周共和元年,即公元前841年。那之前的年代,都是后人推算的。其中,武王克商的确切年代最为关键。克商年代,至今已有三四十家不同说法。影响大的有两家。旧说,即刘歆之说,克商在公元前1122年,有两千年了,史学界大体依从。新说,当是国家斥巨资集多方面力量,称之为“夏商周断代工程”所得出的结论,克商在公元前1046年。差异如此之大,靠得住吗?\n姑且不说新说、旧说的是非,看一看克商年代的文献依据就能让我们头脑清醒。\n反映克商月朔日干支的文字,一是古文《武成》,一是《逸周书·世俘》。\n《新唐书·历志》称“班(固)氏不知历”,他的《汉书·律历志》多采用刘歆的文字,刘歆在《世经》中引了《周书·武成》:“惟一月壬辰旁死霸,若翌日癸巳,武王乃朝步自周,于征伐纣。”\n又引《武成》曰:“粤若来三[二]月既死霸,粤五日甲子,咸刘商王纣。”\n又引《武成》曰:“惟四月既旁生霸,粤六日庚戌,武王燎于周庙。翌日辛亥,祀于天位。粤五日乙卯,乃以庶国祀馘于周庙。”\n这就是今天我们能见到的古文《武成》。虽也有人提出过异议,史学界还是认同它的真实性。刘歆在引用后还写有他对原文的解说。如:“至庚申,二月朔日也。四日癸亥,至牧野,夜阵。甲子昧爽而合矣。”又说:“明日闰月庚申朔。……四月己丑朔[既]死霸。……是月甲辰望,乙巳旁之。”——这就是刘歆对克商月朔干支的理解。\n稍加排列,是年前几月朔日干支便清清楚楚:一月辛卯朔,二月庚申朔,四月己丑朔。\n再看《逸周书·世俘》:\n维四月乙未日,武王成辟。四方通殷命,有国。\n维一月丙午旁生魄,若翼日丁未,王乃步自于周,征伐商王纣。\n越若来二月既死魄,越五日甲子朝至接于商,则咸刘商王纣,执矢恶臣百人。\n《武成》说,一月初二(旁死霸)壬辰,初三癸巳,“武王乃朝步自周”。《逸周书》说,一月十六(旁生魄)丙午,第二天丁未,“王乃步自于周”。《武成》立足于朔,《世俘》立足于望,日序一致,两者并不矛盾。当是初三癸巳起兵,中间有停留,十七丁未又出发。二月既死魄(庚申朔),第五天“甲子朝至接于商”。《世俘》与《武成》吻合。\n月相的含义也清楚明白:既死魄(霸)为朔为初一,旁死魄(霸)取傍近既死魄之义为初二;既生魄与既死魄相对为望为十五,旁生魄(霸)取傍近既生魄之义为望为十六,既旁生魄(霸)指旁生魄后一日为十七。月相是定点的,定于一日。一个月相不会管两天三天,也不会管七天八天,更不会相当于半个月。这是至关重要的。这是《武成》与《世俘》明白告诉我们的。有的人就是视而不见!\n克商之年前几月的朔干支当是:一月辛卯朔,二月庚申朔,×月庚寅朔,×月己未朔,四月己丑朔。二月至四月间必有一闰,刘歆据四分术朔闰定二月闰,可从。闰二月庚寅朔,三月己未朔。\n以此勘比实际天象,公元前1122年、前1046年皆不符合。历日干支与公元前1044年、前1075年、前1106年天象可合。因为历朔干支周期是三十一年,克商年代必在这三者之中。依据文献记载,考求出土铜器铭文,武王克商只能是公元前1106年。\n1976年于临潼出土利簋,铭文:“王武征商,唯甲子朝。”确证了克商的时日干支。\n西周的总年数,可参照《史记·鲁世家》。因为《鲁世家》记载鲁公在位年数大体完整。《史记·鲁世家》记:封周公旦于少昊之虚曲阜,是为鲁公。周公不就封,留佐武王。武王克殷二年,天下未集,武王有疾,不豫……其后武王既崩,成王少,在襁褓之中。周公恐天下闻武王崩而畔,周公乃践祚,代成王摄行政当国。……于是卒相成王,而使其子伯禽代就封于鲁……伯禽即位之后,有管、蔡等反也。淮夷、徐戎,亦并兴反。于是伯禽率师伐之于慈……遂平徐戎,定鲁。鲁公伯禽卒,子考公酋立。考公四年卒,立弟熙,是为炀公……六年卒,子幽公宰立。幽公十四年,幽公弟照杀幽公而自立是为魏公。魏公五十年卒,子厉公擢立。厉公三十七年卒,鲁人立其弟具,是为献公。献公三十二年卒,子真公濞立。真公十四年,周厉王无道,出奔彘,共和行政。二十九年,周宣王即位。三十年,真公卒,弟敖立,是为武公。武公九年春,武公与长子括、少子戏西朝周宣王。宣王爱戏……卒立戏为太子。夏,武公归而卒,戏立,是为懿公。懿公九年,懿公兄括之子伯御与鲁人攻弑懿公,而立伯御为君。伯御即位十一年,周宣王伐鲁,杀其君伯御……乃立称(鲁懿公弟)于夷宫,是为孝公……孝公二十五年,诸侯畔周,犬戎杀幽王。\n司马迁所记西周一代鲁公年次,大体是清楚的。异议最多只有两处:伯禽年数,炀公年数。伯禽卒于康王十六年,这是明确的。周公摄政,七年而返政成王,“后三十年四月……乙丑,成王崩”。伯禽代父治鲁是在周公摄政之初,而不是成王亲政之后。伯禽治鲁后,有管、蔡等反,淮夷徐戎亦反。接着有周公东征,伯禽亦率师伐徐戎,定鲁。《鲁世家》载,伯禽代父治鲁之后“三年而后报政周公”,“太公亦封于齐,五月而报政周公”,引起周公有“何迟”“何疾”之叹。很清楚,周公与太公受封是在武王克殷之后,伯禽“之鲁”当在周公摄政之初,是代父治鲁。成王亲政元年,“此命伯禽俾侯于鲁之岁也”(《汉书·律历志》),成王正式封伯禽为鲁侯。到康王十六年,伯禽卒。这样,代父治鲁七年,作为鲁侯治鲁四十六年,总计五十三年。这与《史记集解》“成王元年封,四十六年,康王十六年卒”的记载也是吻合的。\n鲁炀公年数,《鲁世家》记“六年”,《汉书·世经》作“《世家》:炀公即位六十年”,汲古阁本《汉书》作“炀公即位十六年”。《世经》同时又记“炀公二十四年正月丙申朔旦冬至”为蔀首之年,至“微(魏)公二十六年正月乙亥朔旦冬至”复为蔀首之年。这就否定了六年说、十六年说。这一蔀七十六年中间,还有幽公十四年,炀公在位必六十年无疑。\n这样,武王2年+周公摄政7年+伯禽46年+考公4年+炀公60年+幽公14年+魏公50年+厉公37年+献公32年+真公30年+武公9年+懿公9年+伯御11年+孝公25年=336年。这是明白无误的《鲁世家》文字,是考证西周一代王年的依据。西周总年数336年,武王克商当在公元前1106年。实际天象,出土铭文,文献记载,都证实了这一结论。\n《史记·封禅书》:“武王克殷二年,天下未宁而崩。”周公摄政七年,返政成王,《汉书·律历志》载:“后三十年四月……乙丑,成王崩。”《竹书纪年》载,康王在位二十六年。昭王在位年数众说纷纭,而小盂鼎铭文旧释“廿又五祀”,当是“卅又五祀”,乃昭王时器,可证昭王在位三十五年。\n武2+摄政7+成30+康26+昭35=100年,正百年之数,这就证实《晋书》所载“自周受命至穆王百年”是靠得住的。前人说“受命”指的是“文王受命”,实则指武王克商。又,昭王在位之年,其说甚多,十九年说影响尤大。《史记》载,穆王即位“春秋已五十矣”,这就否定了昭王在位十九年说、二十四年说(新城新藏)。在位三十五年,昭王年岁当在七十以上,才可能有一个五十岁的儿子穆王。这是简单的生理常识啊!\n又,《史记·秦本纪》张守节《正义》云:“年表穆王元年去楚文王元年三百一十八年。”楚文王元年即周庄王八年,合公元前689年。318+689=1007,不算外,穆王元年当是公元前1006年,上距克商的公元前1106年正是“自周受命至穆王百年”。\n文献记载的穆王高寿长命,都是于史有据的。《史记·周本纪》载:“穆王即位,春秋已五十矣。……穆王立五十五年崩,子共王翳扈立。”《竹书纪年》记穆王“五十五年,王陟于祗宫”。《太平御览》引《帝王世纪》:“五十五年,王年百岁,崩于祗宫。”《尚书·吕刑》载:“唯吕命,王享国百年,耄荒,度作刑,以诘四方。”这里的“百岁”“百年”,当然指的是整数,《帝王世纪》的作者不会不读《史记》。穆王活到一百零五岁,古人不疑,今人反认为不可能,于是穆王在位就有了45年(马承源)、41年(董作宾、刘起益)、37年(丁山、刘雨)、27年(周法高),甚至20年(陈梦家)、14年(何幼琦)种种说法。如此不顾文献,实在令人惊讶。我们说,离开了文献记载,还有什么历史可言啊!百年来,东西方文化交流,西方人怎么“大胆假设”,还可理解,号称史学家的中国人抛弃文献信口雌黄,就不好理解了。\n穆王元年乃公元前1006年,这是不容置疑的。\n弄明白穆王在位的具体年代,公元前1006年—前952年,计55年,再考求穆天子西游的年月日才有可能。\n中华民族最重视史事的记录,汉字的史、事本来就是一个字,帝王身边有史官记言记事,古代史料记载的丰富是不言而喻的。中华民族的历史既是悠长的,更是延绵不断的,这在世界上绝无仅有。《春秋》仅是鲁国的大事记国史,只不过经孔子整理而得以保存下来。其实,各诸侯国都是有国史的,从《竹书纪年》看,周王朝的大事记更不当缺。《逸周书·史记》载,周穆王要左史“取遂事之要戒”,朔日、望日讲给他听。也就是录取史料中的重要的可鉴戒的事,供他参考借鉴。足见周穆王时是有史事记录的,左史才可能给他辑录。可惜,国史仅传下来一部《春秋》,更早的只有一些零散的文字。\n史载,西晋初年汲郡人不准盗取战国古墓,有大量竹简古书,经当时学者荀勖等人整理,一批古籍得以保留下来,其中就有史事记录两种,这就是《竹书纪年》与《穆天子传》。\n《穆天子传》记录周穆王西行的史事,历时两年,远行到今之中亚,文字中干支历日明明白白,地名记载清清楚楚,即便是经过春秋、战国间人整理,作为穆王的史事,还是可信的,不当有什么疑义,更不必看作什么传奇小说,当作古人的故事编写。\n据《艺文类聚》载,“穆王十三年,西征,至于青鸟之所憩”,这当是穆王的初次西行。《穆天子传》卷四载,“比及三年,将复而野”,还要再去。因为传文残缺,无明确年月,只有日干支记录。我们仅能据干支将行程一一复原,再现三千年前穆王西行的史事。\n穆王十三年即公元前994年,我们将前后年次的月朔干支一一列出,穆王的西行也就大体明白了。\n注:84,指四分数小余。07h19m,指合朔07时19分,见张培瑜《中国先秦史历表》。\n郭沫若氏《大系录》61载走簋“隹王十又二年三月既望庚寅”。既望十六庚寅,必乙亥朔。这正合穆王十二年天象:卯月乙亥朔。见上★处。是年建丑,当闰未置闰,转入下年建子。\n郭沫若《大系录》80载,望簋“唯王十又三年六月初吉戊戌”。六月戊戌朔,正合穆王十三年天象:巳月戊戌朔。见上★处。本年不当闰而闰,转入下年建丑。\n以上所列子、丑、寅、卯……是实际天象,是用四分术推算出来的。在不能推步制历的春秋后期以前,是观察星象制历,即观象授时。在没有找到朔闰规律之前,只能随时观察,随时置闰。这样,实际用历与实际天象就不可能完全吻合,允许有一定的误差。月球周期29.53日,有个0.53,半日还稍多。而干支纪日是整数,不可能记“半”,这个0.53必然地前后游移,甲子记为乙丑,乙丑记为甲子,都算正常。还有个置闰问题,按推步制历当闰,而实际用历却未闰,不当闰却又置闰了,建正就有个游移,或建丑或建子,并不固定。懂得以上两点,实际用历与实际天象的勘合与校比,才有可能。\n下面,我们将《穆天子传》有关文字录入,穆王西游的整个行程也就昭白于天下。\n卷一,开篇“饮天子蠲山之上”,说明书已残缺,当有穆天子从宗周洛邑出发过黄河至蠲山的记录。书的首页,按后面的惯例应当是“仲春庚子”“季春庚午”之类的纪时文字。第一个纪日干支是“戊寅”,是在朔日庚午之后,说明穆天子在季春三月初出发,几天后到了黄河之北山西东部的蠲山。从上面公元前994年(穆王十三年)实际天象推知,三月朔庚午(小余46分),分数小,也可以是“三月己巳朔”,顾实《穆天子传西征讲疏》就定“己巳朔”,戊寅初十。顾实在二月后置闰,戊寅就成了“闰二月初十”。本不为错,考虑到接续“望簋”历日,闰二月就不恰当了。\n定三月庚午朔(05h49m)。初九戊寅,天子北征,乃绝漳水。十一庚辰,至于□。十四癸未,雨雪,天子猎于邢山之西阿。十六日乙酉,天子北升于□,天子北征于犬戎。二十一日庚寅,北风雨雪,天子以寒之故,命王属休。二十五日甲午,天子西征,乃绝隃之关隥(今雁门山)。\n四月己亥朔(13h43m)。初一己亥,至于焉居、禺知之平。初三辛丑,天子西征,至于人。初五癸卯〔酉〕(此月无癸酉),天子舍于漆泽,乃西钓于河。初六甲辰,天子猎于渗泽。初八丙午,天子饮于河水之阿。初十戊申〔寅〕(此月无戊寅),天子西征,骛行,至于阳纡之山。十五癸丑,天子大朝于燕然之山,河水之阿。二十日戊午,天子命吉日戊午,天子大服,天子授河宗璧。二十一己未,天子大朝于黄之山。二十七乙丑,天子西济于河。二十八丙寅,天子属官效器。\n五月己巳朔。《传》无载。\n六月戊戌朔(05h34m)。望簋:唯王十又三年六月初吉戊戌。铭文与天象吻合。\n卷二,丁巳……知此前有若干脱漏。丁谦云:距前五十一日。盖自河宗至昆仑、赤水须经西夏、珠余、河首、襄山诸地。五十一日行四千里恰合。\n戊戌朔,二十日丁巳,天子西南升□之所主居。二十一戊午,寿□之人居虑。二十四吉日辛酉,天子升于昆仑之丘,以观黄帝之宫。二十六癸亥,天子具蠲齐牲全,以禋□昆仑之丘。二十七甲子,天子北征,舍于珠泽。\n《传》载“季夏丁卯”,即六月丁卯朔。说明实际用历,前六月戊戌朔,月小,二十九日。而实际天象,午月戊辰朔162分(丁卯15h10m),实际用历午月(后六月)丁卯朔,不用四分术戊辰162分,更近准确。\n闰六月丁卯朔,季夏(初一)丁卯,天子北升于舂山之上以望四野。初六壬申,天子西征。初八甲戌,至于赤乌之人其献酒千斛于天子。十三日己卯,天子北征,赵行□舍。十四日庚辰,济于洋水。十五日辛巳,入于曹奴之人戏觞天子于洋水之上。顾实云:“曹奴当即疏勒。”十六壬午,天子北征,东还。十八日甲申,至于黑水。降雨七日。二十五辛卯,天子北征,东还,乃循黑水。二十七癸巳,至于群玉之山。\n闰六月,月大,三十日。故《传》载“孟秋丁酉”,进入七月。\n七月丁酉朔(02h47m),四分术丁酉朔661分。孟秋初一丁酉,天子北征。初二戊戌,天子西征。初五辛丑,至于剞闾氏。初六壬寅,天子祭于铁山。已祭而行,乃遂西征。初十丙午,至于韩氏。十一日丁未,天子大朝于平衍之中。十三日己酉,天子大飨正工、诸侯、王吏、七萃之士于平衍之中。十四日庚戌,天子西征,至于玄池。天子三日休于玄池之上。十七日癸丑,天子乃遂西征。二十日丙辰,至于苦山。二十一日丁巳,天子西征。二十三日己未,宿于黄鼠之山西(阿)。二十七癸亥,至于西王母之邦。\n卷三,吉日甲子二十八日,天子宾于西王母。二十九乙丑,天子觞西王母于瑶池之上。\n八月丙寅朔(16h58m),四分术丁卯朔220分。《传》无载。\n九月丙申朔(09h51m),四分术丙申朔719分。\n实际用历九月丙申朔。初一丙申。十二丁未,天子饮于温山。十四日己酉,天子饮于溽水之上。六师之人毕聚于旷原。天子三月舍于旷原。六师之人翔畋于旷原。六师之人大畋九日。\n十月丙寅朔(04h42m),四分术丙寅朔278分。\n十一月乙未(23h58m),四分术乙未朔777分。\n十二月乙丑朔(17h48m),四分术乙丑朔333分。\n公元前993年,穆王十四年,上年置闰,闰六月,转入今年建丑,正月乙未朔(08h54m),四分术甲午832分。甲午分数大,与乙未相差无几。实际用历取甲午,或取乙未,均可。\n正月(丑)甲午朔(乙未08h54m)。《传》无记。\n二月(寅)甲子朔391分(甲子20h57m)。《传》无记。\n三月癸巳890分(甲午06h28m)。顾实取甲午朔,己亥初六。癸巳朔,初七己亥,天子东归。初八庚子,至于□之山而休,以待六师之人。\n四月癸亥朔449分(12h25m)。顾实取四月甲子朔,初一甲子,十七庚辰,天子东征。二十日癸未,至于戊□之山。二十二乙酉,天子南征,东还。二十六己丑,至于献水,乃遂东征。\n五月癸巳朔8分(壬辰21h37m)。癸巳分数小,壬辰分数大,朔日近之。因为后有“孟秋癸巳”“(仲)秋癸亥”的文字,顾实取五月甲午朔,虽朔差一日,视为实际用历,可从。这样,从二月甲子朔算起,出现四个连大月,似乎不好理解。考虑到历术的粗略,又是远在千里万里之外的记录,朔差一日,也是情有可原的,未便苛求。否则,后面的“孟秋癸巳”就不好解释了。实际天象不会错,是实际用历出了偏差,将一个小月误记为大月,如此而已。\n五月甲午朔,初六己亥,至于瓜之山。初八辛丑,天子渴于沙衍,求饮未至,七萃之士高奔戎刺其左骖之颈,取其青血以饮天子。十一日甲辰,至于积山之边。十二日乙巳,诸飦献酒于天子。\n六月壬戌朔507分(04h49m)。实际用历,顾实定癸亥朔,朔差一日。\n卷四,初一癸亥,十八庚辰,至于滔水。十九辛巳,天子东征。二十一日癸未,至于苏谷。二十四丙戌,至于长。二十五丁亥,天子升于长,乃遂东征。二十八庚寅,至于重邕氏黑水之阿。\n七月辛卯朔(12h54m),四分术壬辰66分。实际用历,顾实据《传》记“孟秋癸巳”“五日丁酉”定癸巳朔,朔差一日。\n七月初一癸巳,孟秋癸巳,命重邕氏供食于天子之属。“五日丁酉”即初五丁酉,天子升于采石之山,于是取采石焉。天子一月休。\n八月庚申朔(22h56m),四分术辛酉565分。实际用历,顾实据《传》“(仲)秋癸亥”定八月癸亥朔。援例,“季夏丁卯”“孟秋丁酉”“孟秋癸巳”“(仲)秋癸亥”,皆指朔日。四分术,七月壬辰66分,月大,八月壬戌朔。壬戌之去癸亥,还是朔差一日,这是记事者延续前面的失误而不知而不改。这个“失误”仅是今人的认识,反映了当时人的历术水平而已。干支纪日并不紊乱,大原则没有出错,只是在处理月大月小上没有找到规律。到春秋时代,大月小月的周期才得以逐步掌握,从《春秋左氏传》的历日中可以考知。\n(仲)秋癸亥,八月癸亥朔。初一癸亥,天子觞重邕之人 鸳。初三乙丑,天子东征, 鸳送天子至于长沙之山。初四丙寅,天子东征,南还。初七己巳,至于文山。天子三日游于文山。初十壬申(误记“壬寅”,本月无壬寅),天子饮于文山之下。十一癸酉,天子命驾八骏之乘。十二甲戌,巨蒐之人 奴觞天子于焚留之山。十三日乙亥,天子南征阳纡之东尾。十九日辛巳,至于□ 河之水北阿。\n九月庚寅朔(11h58m),四分术辛卯124分,两者误差在半日,算是吻合。实际用历,承上月癸亥朔,本月壬辰朔,与辛卯朔差一日。\n九月壬辰朔,二十二癸丑,天子东征,栢夭送天子至于 人。天子五日休于澡泽之上。二十七戊午,天子东征。\n十月庚申(04h22m),四分术庚申623分。实际用历,承上月壬辰朔,本月壬戌朔。\n“孟冬壬戌”即十月壬戌朔,与上诸例吻合。十月初一壬戌,至于雷首。犬戎胡觞天子于雷首之阿。初二癸亥,天子南征。初五丙寅,天子至于钘山之队(隧)。十二癸酉,天子命驾八骏之乘,赤骥之驷,造父为御。南征翔行,迳绝翟道,升于太行,南济于河,驰驱千里,遂入于宗周。十九庚辰天子大朝于宗周之庙。吉日甲申二十三,天子祭于宗周之庙。二十四乙酉,天子□六师之人于洛水之上。二十六丁亥,天子北济于河。\n十一月己丑(23h22m),四分术庚寅182分,己丑合朔在夜半23h22m,与庚寅吻合。实际用历,承上月壬戌朔,定本月壬辰朔。朔差一日。\n十一月壬辰朔,记“仲冬壬辰”,至累山之上。初六吉日丁酉,天子入于南郑。西征结束。\n以上,我们将《穆天子传》主体文字录入纪时系统,可以弄明白很多问题:\n《穆天子传》是一部珍贵的史料记录,记录了周穆王西征的整个行程,季节时日记载得清清楚楚,历日干支前后连贯,一丝不乱,这就体现了它的真实性与可靠性。说明周穆王时代是有“史记”的,整个西周一代也是有“史记”的,没有这个“源”,就没有《春秋》这个“流”。\n《穆天子传》记录了周穆王十三年、十四年西行的主要活动,反映了三千年前中原与西域与中亚的沟通,各民族的交流往来可追溯到三千年前,穆王西征有开拓性的意义。\n周穆王十三年合公元前994年,十四年合公元前993年,实际天象与《穆天子传》所记历日干支完全吻合,这难道是偶然的吗?历日干支的记录反映了中华民族三千年前的历术水平。借助干支历日的记录,三千年后的今天,我们能够将它们一一复原,本身就说明华夏民族早期的历术水平是高超的,大体准确的,不用说在当时也是首屈一指的。\n干支历日的勘合校比,证实周穆王元年当在公元前1006年,它对于整个西周一代王年的探讨有重要意义。旧说克商在公元前1122年,新说克商在公元前1046年,都会从根本上动摇。\n从观象授时到四分历法——张汝舟与古代天文历法学说 # 张汝舟先生\n【表一】资料图片\n【表二】资料图片\n【表三】\n【表四】\n【求索】\n顾炎武《日知录》有言:“三代以上,人人皆知天文。‘七月流火’,农夫之辞也;‘三星在户’,妇人之语也;‘月离于毕’,戍卒之作也;‘龙尾伏辰’,儿童之谣也。”\n在中国古诗文中提及天文星象的比比皆是,如“七月流火,九月授衣”(《诗经·豳风·七月》);“牵牛西北回,织女东南顾”(晋陆机《拟迢迢牵牛星》);“人生不相见,动如参与商”(唐杜甫《赠卫八处士》);等等。可见,在古代,“观星象”是件寻常事,绝非难事。\n但到了近现代,天文却成为“百姓日用而不知”的学问。所以顾炎武慨叹:“后世文人学士,有问之而茫然不知者矣。”\n20世纪60年代,张汝舟先生凭借其扎实的古汉语功底、精密的考据学研究方法和现代天文历算知识,完整地释读了中国古代天文历法发展主线。从夏商周三代“观象授时”到战国秦汉之际历法的产生与使用过程,他拨开重重迷雾,厘清了天文学史中的诸多疑难问题,使得这一传统绝学恢复其“大道至简”的本质,成为简明、实用的学问。\n考据成果\n《周易》《尚书》《诗经》《春秋》《国语》《左传》《吕氏春秋》《礼记》《尔雅》《淮南子》等古籍中有大量详略不同的星宿记载和天象描述。《史记·天官书》《汉书·天文志》更是古天文学的专门之作。\n夏、商、周三代观象授时的“真相”,经历春秋战国的社会动荡,到汉代已经说不清楚了。历法产生后,不必再详细记录月相,以致古代月相名称“生霸”“死霸”的确切含义竟也失传。自汉代至今,众多学者研究天文历法,著作浩如烟海。研究者受限于时代或者本人天文历算水平,有些谬误甚深,把可靠的古代天文历法宝贵资料弄得迷雾重重。张汝舟先生对此一一加以梳理。\n1.厘清“岁星纪年”迷雾。“岁星纪年”在春秋时期一度行用于世,少数姬姓国及几个星象家都用过。岁星,即木星,运行周期为11.86年,接近12年。“观象”发现岁星每年在星空中走过一辰30°,将周天分为十二辰,岁星每年居一辰,这就是岁星纪年的天象依据。可是,岁星运行周期不是12年整,每过八十余年就发生超辰现象。这是客观规律,无法更改。鲁襄公二十八年(公元前545年),出现了“岁在星纪而淫于玄枵”。“岁星纪年”因此破产,仅行用百余年。而古星历家用以描述岁星运行的十二次(十二宫)名称(星纪、玄枵、娵訾……)却流传下来。而后,星历家又假想一个理想天体“太岁”,与岁星运行方向相反,产生“太岁纪年法”。但终因缺乏实观天象的支撑,也仅昙花一现。另取别名“摄提格”“单阏”“执徐”“大荒落”……作为太岁纪年的名称,代替十二地支。阅读古籍时,将这些“特殊名称”理解为干支的别名即可(见春秋战国时期所用干支纪年别名与干支对应关系表)。\n2.纠正“四象”贻害。张汝舟先生绘制的星历表是依据宋人黄裳《星图》所绘二十八宿次序画的。传统星历表迷信《史记·天官书》的“四象”说,二十八宿分为东方苍龙、北方玄武、西方白虎、南方朱雀。由于四灵要配四象,于是宿位排列颠倒了,后人误排二十八宿、十二宫方向,贻误不浅。(见【表一】)\n张氏星历表(见【表二】)纠正了二十八宿排列次序;删除外圈十二地支;增加“岁差”方向;增加二十八宿上方括号内数字,这是唐宋历家所测,与春秋时期数据差异不大。用此表释读古籍中的天象清晰明了。\n3.否定“三正论”。观象授时时期,古人规定冬至北斗柄起于子月,终于亥月,这是实际天象,不可更改。每年以何月为正月,则会导致月份与季节之间调配不同,这就是“建正”(用历)问题。春秋时期人们迷信帝王嬗代之应,“三正论”大兴,他们认为夏商周三代使用了不同的历法,“夏正建寅,殷正建丑,周正建子”,即夏以寅月为正月,殷以丑月为正月,周以子月为正月。“改正朔”,以示“受命于天”。秦始皇统一中国后,以十月为岁首,也源于此。(见【表三】)\n实际上,四分历产生之前,还只是观象授时,根本不存在夏商周三代不同正朔的历法。所谓周历、殷历、夏历不过是春秋时期各诸侯国所用的子正、丑正、寅正的代称罢了。春秋时代诸侯各国用历不同是事实,实则建正不一。大量铜器历日证明,西周用历建丑为主,失闰才建子建寅。春秋经传历日证明,前期建丑为主,后期建子为主。\n排除“三正论”的干扰,中流伏内的含义才得以显现。依据《夏小正》“八月辰(房宿)伏”“九月内(入或纳)火”“正月初昏参中”“三月参则伏”等连续的星象记载,确定中、流、伏、内是二十八宿每月西移一宫(30°)的定量表述。张汝舟在《〈(夏)小正〉校释》里详加阐释。《诗经·七月》中“七月流火”是实际天象,是七月心宿(大火)在偏西30°的位置,则六月大火正中,这是殷历建丑的标志。毛亨注“七月流火”(“火,大火也;流,下也”)已经不能精确释读天象了。后世多依毛氏阐述,远离了天文的“真相”。(见【表四】)\n4.否定《三统历》。汉代刘歆编制的“三统历”详载于班固《汉书·律历志》,《三统历》被推为我国三大名历(汉《三统历》、唐《大衍历》、元《授时历》)之首,实则徒有虚名。“三统历”本质即为四分历,是《殷历》“甲寅元”的变种,且从未真正行用过。刘歆用“三统历”推算西周纪元元年,但受时代限制,他不明四分术本身的误差,也不知道“岁差”的存在。所以他推算西周历日总有三天、四天的误差。王国维先生即是据《三统历》推算结果悟出“月相四分说”,上了刘歆的当。\n“四象”“三正论”“三统历”“岁星纪年”,张汝舟称之为“四害”。去除“四害”,方能建立正确的星历观。\n四分历法\n语言学家、楚辞学家汤炳正先生曾言:“两千年以来,汝舟先生是第一位真正搞清楚《史记·历书·历术甲子篇》与《汉书·律历志·次度》的学者。”《历术甲子篇》《次度》是中国古代天文历法的两大宝书,尘封两千余年,无人能识。张汝舟先生考据出司马迁所记《历术甲子篇》正是我国第一部历法——四分历;《次度》所记载的实际天象,正是四分历实施之时,在战国初年公元前427年(甲寅年)。依此两部宝书,张汝舟先生还原了我国从战国初到三国蜀汉亡行用了700年的四分历。\n四分历是以365又1/4日为回归年长度,29又499/940日为朔策(平均一月长度),十九年闰七为置闰方法的最简明历法。张汝舟先生熟知现代天文历法体系,明了四分历的误差,发明出3.06年差分的算法,以公元前427年为原点,前加后减,修正四分历的误差。这一算法的发明,使古老的四分历焕发青春。简明的四分历法成为可以独立运用的历法体系,上推几千载,下算数千年。其推算结果,既与现代天文学推测的实际天象相吻合(只有平朔、定朔的误差而已),又与古籍、出土文物中的历点相吻合,客观上验证了张汝舟先生所建立的天文历法体系的正确性。张汝舟先生不仅还原了四分历的使用历史,同时构建了一套完整自洽并可以独立运用的古代天文历法体系。\n张汝舟先生精研古代天文历法,首先应用于西周年代学研究。1964年发表《西周考年》,得出武王克商在公元前1106年,西周总年数336年的确凿结论。\n《史记》年表起于共和元年(公元前841年),共和元年至今近三千年纪年,历历分明。共和之前西周各王年,向无定说。最重要的时间点即是“武王克商”之年。李学勤先生说:“武王克商之年的重要,首先在于这是商周两个朝代的分界点,因此是年代学研究上不可回避的。这一分界点的推定,对其后的西周来说,影响到王年数的估算;对其前的夏商而言,又是其积年的起点。”\n《西周考年》中利用古籍、出土器物的41个宝贵历点(有王年、月份、纪日干支及月相的四要素信息),以天上材料(实际天象)、地下材料(出土文献)与纸上材料(典籍记载)“三证合一”的系统方法论,确证武王克商在公元前1106年。张汝舟先生总结他的方法为一套技术——四分历推步,四个论点——否定“三统历”、否定“三正论”、否定“月相四分说”、确定“失闰限”与“失朔限”。\n“月相四分说”与“月相定点说”是目前史学界针锋相对的两种观点。“月相四分说”是王国维先生在“三统历”基础上悟出的,在夏商周断代工程中进一步演化为“月相二分说”。而张汝舟先生坚持的“月相定点说”是四分历推步的必然结果,有古籍、青铜器中历点一一印证。月相定点与否的争执,本质是对古代四分历法是否有足够清晰认识的问题。\n清儒有言:“不通声韵训诂,不懂天文历法,不能读古书。”诚非虚言。考据古天文历法是一项庞大繁难的系统工程。古天文历法源远流长,张汝舟先生的学术博大精深,本文所述仅是“冰山一角”。我们在从汝舟师学习的过程中有这样的体会:一是要树立正确的星历观点,才不至为千百年来的惑乱所迷;二是要进行认真的推算,达到熟练程度,才能更好地掌握他的整个体系。张汝舟先生古天文历法体系是简明、实用的,用于考证古籍中的疑年问题游刃有余,用于先秦史年代学的研究屡建奇功。\n应用举例\n例1.《尚书·尧典》四仲中星及“岁差”\n《尧典》所记“日中星鸟,以殷仲春”“日永星火,以正仲夏”“宵中星虚,以殷中秋”“日短星昴,以正仲冬”是观象授时的最早星象记录,当时仅凭目力观测,未必十分准确。《尧典》作于西周时代应该无疑。运用张氏星历表计算,南方星宿至东方心宿(大火)的距离为星7/2+张18+翼18+轸17+角12+亢9+氐15+房5+心宿5/2=100度(首尾两星宿用度数1/2,其他星宿顺序相加),心宿至北方虚宿82.75度,虚宿至西方昴宿94.5度,昴宿至星宿88度,四个数相加正合周天365.25度(中国古代一周天为365.25度,等于现代天文学的360°,古代一度略小于1°)。四个星宿大致四分周天,均在90度上下,正对应四个季节时间中点。若昏时观天象,春分时,星宿在南中天。夏至时是大火正中,秋分时是虚宿,冬至时为昴宿。\n东晋成帝时代,虞喜根据《尧典》“日短星昴”的记载,对照当时冬至点日昏中星在壁宿的天象,确认每年冬至日太阳并没有回到星空中原来恒星的位置,而是差了一点儿,这被称为岁差。\n张汝舟先生利用“岁差”,分析古籍中“冬至点”的位置变化,最终得出《次度》所记“星纪:初,斗十二度,大雪;中,牵牛初,冬至;终于婺女七度”是战国初期四分历初创时的实际天象。\n张氏星历表(见【表二】)可以直观解读古籍中的天文天象。\n例2.屈原的出生年月问题\n这是文史界的热门话题。近人多信“岁星纪年”,用所谓“太岁超辰”来推证,生出多种多样的结论,但却无法令人信服。\n《离骚》开篇“摄提贞于孟陬兮,惟庚寅吾以降”,就告诉了我们屈原生于寅年寅月寅日。考虑屈原政治活动的时代背景,其出生年只能在两个寅年,一是公元前355年丙寅(游兆摄提格),一是公元前343年戊寅(徒维摄提格)。我们用四分历推步法来检验(推算过程略)。公元前355年丙寅年寅月没有庚寅日,应该舍弃。公元前343年(楚宣王二十七年),戊寅年正月(寅月)二十一日(庚寅),正是屈原的出生日。这也是清人邹汉勋、陈瑒,近人刘师培的结论,张汝舟《再谈屈原的生卒》又加以申说、推算。\n学术发展\n受历史条件的限制,张汝舟《西周考年》中只用到41个历点。20世纪80年代后,陆续出土上千件西周青铜器,其中四要素俱全者已接近百件。我们积累了文献中16个历点,青铜器82个历点,继续张汝舟先生的学术方向,更进一步确证武王克商之年在公元前1106年,得出西周中期准确的王序王年,排出可靠的《西周历谱》,这些成果见于《西周王年论稿》(贵州人民出版社,1996年),汇总于《西周纪年研究》(贵州大学出版社,2010年)。\n我们以张汝舟先生古代天文历法体系为基础理论,以“三重证据法”为系统方法论,坚持“月相定点”说。针对日益增多的出土铜器铭文,发展出铜器历日研究的正例变例研究方法、铜器王世系联法等理论。我们有《铜器历日研究》(贵州人民出版社,1999年)一书为证。\n我们坚信西周历谱的可靠,是因为每一个历点均与实际天象相合,非人力所能妄为。我们坚守乾嘉学派的学风,“例不十,法不立”,反对孤证。对每一件铜器、每一个古籍文字均详加考据。饶尚宽教授2001年排出《西周历谱》后,又有畯簋、天亡簋等多件新增青铜器的重新释读,均能够一一放入排定的框架,绝无障碍。我们自信地说,今后再有新的历日出现,也必然出不了这个框架。\n“六经皆史,三代乃根”,这几乎是历代文化人的共识。中华文明五千年,她的根在夏商周“三代”。弄明白三代的历史,是中国史学家的职责。2016年科学出版社出版了《夏商周三代纪年》一书。西周年代采用张汝舟先生可靠的336年说,商朝纪年采用628年说,夏朝纪年采用471年说,都做到于史有据。李学勤先生为此书题词:“观天象而推历数,遵古法以建新说。”以此表示肯定。\n随着学术的蓬勃发展,张汝舟先生的弟子、再传弟子不断有著作问世,丰富了其古天文学说。贵州社科院蒋南华教授出版了《中华传统天文历术》(海南出版社,1996年)、《中华古历与推算举要》(与黎斌合著,上海大学出版社,2016年);新疆师大饶尚宽教授出版有《古历论稿》(新疆科技出版社,1994年)、《春秋战国秦汉朔闰表》(商务印书馆,2006年)、《西周历谱》(收入《西周纪年研究》,贵州大学出版社,2010年);后学桂珍明参与编著《夏商周三代纪年》《夏商周三代事略》;后学马明芳女士参与整理古天文学著作,写有普及本《走进天文历法》,并到各地书院面授这一学术。种种说明,古天文“绝学”后继有人,溢彩流光。\n古代天文历法,是“人类第一学,文明第一法”。张汝舟先生古代天文历法体系提供了一套可靠的研究古籍天象的系统理论,必将在未来的应用中发扬光大。\n学人小传\n张汝舟(1899—1982)名渡,自号二毋居士,安徽全椒县章辉乡南张村人。少时家贫而颖异好学,赖宗族资助读书。1919年毕业于全椒县立中学校,无力升学,被荐至江浦县三虞村任塾师八年。1926年考入中央大学国文系,受业于王冬饮、黄季刚、吴霜崖等著名学者门下,学业日进。毕业后,任教于合肥国立六中、湖南蓝田国立师范学院等校。1945年任贵州大学教授。1978年应聘到滁州师专任顾问教授。1982年病逝于滁州师专。曾担任中国训诂学研究会顾问、中国佛教协会理事、《汉语大词典》安徽编纂处复审顾问、安徽省政协委员等社会职务。\n张汝舟从教工作、学术研究相得益彰,一生笔耕不辍,完成书稿近300万字。他学问广博,著述涉及经学、史学、文学、哲学、文字学、声韵学、训诂学、考据学、佛学等各个领域,均有独到见解。他对声韵、训诂、考据学的研究,发扬了章(太炎)、黄(侃)学派声韵训诂学的成果,坚持乾嘉学派的治学方法,凡所称引,必言而有据;他对汉语语法的研究,坚持用中国的语言体系来研究古汉语语法,简明、实用。他在古诗古文方面的著述涉及面甚广,足以展现一代学人的全面风采。他对古代天文历法的研究,于繁芜中见精要,于纷乱中显明晰,完整诠释了古代观象授时及四分历法产生的全过程,独树一帜,自成一家。他为人平易纯朴、恭谨谦逊,遇到不平之事却敢于仗义执言。对青年后学循循善诱、诲人不倦,深受朋辈及后学的尊崇和爱戴。\n张闻玉,1941年生,四川省巴中人,现任贵州大学先秦史研究中心主任,曾在安徽滁州张汝舟先生门下问学,又从金景芳先生学《易》,在高校主讲古代汉语、古代历术、传统小学、三代纪年等课程,从事先秦史学术研究,强调传世文献、出土器物、历日天象“三证合一”;马明芳,毕业于北京大学物理系,师从张闻玉先生。\n本文作者:张闻玉 马明芳(执笔)\n原文刊载于《光明日报》( 2017年06月12日 16版)\n附表一观象授时要籍对照表 # 附表二殷历朔闰中气表 # 附表三术语表 # 蔀:4章,76年,940月,27759日。(章:19年,235月。)\n定气:以太阳在黄道上位置来划分节气,两节气之间的时间长度就会不同。定气反映真实天象。\n平气:两冬至之间的时日二十四等分之,所得为二十四节气之平气。每气为15732日。\n中气:从冬至开始的二十四节气中逢单数的节气。依照《汉书·次度》记载,这十二节气正处于相应宫次的中点,故称中气。\n气余:中气之余分。干支只能记整数,涉及小数得化为余分。\n定朔:朔为初一。合朔时刻不取平均值而采用实际天象的合朔时刻。\n经朔:以四分术推算的平朔。\n平朔:两朔日之间的时日,以平均值29499940日计,为平朔。\n朔策:一个朔望月长度,29.5306日。四分历朔策为29499940日。\n年差分:四分历基本数据是一年36514日,与真值365.2422有误差,每年之差3.06分(一日940分计)即年差分。\n岁实:根据相邻两次冬至时刻而定出的年,即回归年长度,36514日。\n岁差密律:冬至点每年在黄道上西移50.2秒,71年8个月岁差一度。\n算外:自古计数得计入起点日,与算法相差为1,运算计数得加这个“1”,叫算外。\n昏旦中星:观星象可在晨昏两时,观测者头上的星就是中星。我们处北半球,中星总是在偏南的上方。\n去极度:所测天体距北极的角距离。\n入宿度:以二十八宿中某宿的距星为标准,所测天体与这个距星之间的赤经差。\n推步:指室内推算,对日月运行时间进行计算,使回归年与朔望月长度配合得大体一致。\n积年:历术的年月日是有周期的,干支纪年纪日也是周而复始。古人将这个周期无限加大,干支年累积若干倍,甚至上溯到二百七十万年前,目的是追求一个理想的历元。这就是积年、积年术。神秘的数字,并无实际意义。\n二次差:即二次差内插公式。指计算函数的近似值的方法。在已知若干函数值的情况下,构造一简单函数,来代替所计算的函数,达到化难为易的目的。三次差内插法的计算较二次差更为准确。主要征引书目 [TP版(-+29mm,0)%]\n主要征引书目 # 一、张汝舟:二毋室古代天文历法论丛.浙江古籍出版社,1987\n二、饶尚宽:上古天文学讲义(打印本,新疆师大)\n三、中国天文学史.科学出版社,1981\n四、陈遵妫:中国天文学史.上海人民出版社,1980\n五、郑文光:中国天文学源流.科学出版社,1979\n六、历代天文律历等志汇编.中华书局,1975\n七、南京大学编印章太炎先生国学讲演录.1984\n八、冯秀藻、欧阳海:廿四节气.农业出版社,1982\n九、王国维:观堂集林.中华书局,1959\n十、张钰哲主编:天问.江苏科技出版社,1984\n十一、淮南子.诸子集成初编\n十二、礼记.十三经注疏本\n十三、春秋经传集释.四部丛刊初编\n后记 # 汤炳正先生爱孙序波同志为其祖父整理出版了《楚辞讲座》,2006年9月出版后,序波很快于11月寄来一本,要我写点介绍文字。因为新中国成立前汤先生曾在贵州大学任教过,与先师张汝舟先生过从甚密,我也受惠于汤先生,多有交往。我毫无推辞地写了《从〈楚辞讲座〉出版想到的》在《贵州日报》上发表。就中,我对广西师范大学出版社有极好的印象,感到他们的远见卓识,在商潮涌涌的今天,还踏踏实实地在弘扬传统文化,向炎黄子孙推出一本又一本的学术精品,使文化学术界弥漫出一股久违的清新空气。我的文章第一句话就是:“一流的大学出版社未必办在一流的大学,这是指广西师大出版社。”那的确是我真切的感受。其后,序波从短信上告诉我,他已推荐我的《古代天文历法说解》给师大出版社。我知道出版学术著作的艰难,商业运作不赚钱就得赔本,谁个会干?也就并不在意。有老母在堂,春节期间我一直在四川老家省亲,其间序波短信说“出版社同意审稿”,要我马上寄出稿件。3月中旬回到学校后,耽误几天才将稿子邮寄。5月初,编辑王强先生寄来了“出版合同”。审稿的及时、出版的决断,都在我的意料之外。我只得请贵州大学已毕业的古代文学硕士现任教于贵州广播电视大学的邹尤小朋友帮助,完成该书的电子文本。他熬了几个通宵扫描传输,在月内就一一处理完毕。\n古代天文历法,号称“绝学”,海外华人美曰“国宝”。我受教于张汝舟先生,二十多年来,能与交流者寥寥,深感知音难得。反而是老一辈学者如东北师大的陈连庆老先生的苦苦追求,令我感动。周原岐山的庞怀靖老先生,八十几岁高龄,还孜孜不倦地学习、掌握历术的推演,直到弄明白了月相定点,毅然抛弃信奉了几十年的“月相四分”,另做铜器考释文章。庞老先生真正做到了“朝闻道,夕死可矣”。\n古代天文历法,自然是科学的。科学,就无神秘可言,它必须是简明而实用的。要掌握它,也就不难。就其内容,如《尧典》说,两个字:历、象。司马迁《史记》理解为“数、法”。一个是天象,天之道,自然之道,有“法”可依之道;一个是历术,推算之术,以“数”进行推演。日月在天,具备基本的天文知识,古书中的文字就容易把握。涉及历术,就得学会推算,用四分法推算实际天象。推演历日是最重要的步骤,不下这个功夫,历法就无从谈起。我在书后附有几篇文章,算是历术的具体运用,给青年学人一个示范。掌握了历术的推演,在历史年代的研究中,在铜器年代的考释中,你都会感到游刃有余。张汝舟先生在王国维“二重证据法”之外,加一个“天象依据”,做到“三证合一”,结论自然可靠。只有可靠的结论,才能经受时代的验证,对得起子孙后代,三百年也不会过时。“三证合一”,古代天文历法在文史研究中的地位,就不是可有可无的了。你要学会了推算,考释几个历日,你自会感受这门学问的妙不可言,奇妙无穷。她不愧是华夏民族的瑰宝!\n2007年5月30日贵阳花溪寓所\n[1] 李学勤:《论簋的年代》,《中国历史文物》,2006年第3期;王冠英:《簋考释》,《中国历史文物》,2006年第3期。\n[2] 见张闻玉《西周王年论稿》,贵州人民出版社,1996年。其中载《西周朔闰表》,是用四分术推算出来的实际天象。另见张培瑜《中国先秦史历表》,齐鲁书社,1987年。\n[3] 见《西周王年论稿》,第86页。\n[4] 张闻玉:《武王克商在公元前1106年》,见《西周王年论稿》。\n[5] 08h51m,指合朔的时(h)与分(m),准确的实际天象,引自《中国先秦史历表》。\n[6] 张闻玉:《铜器历日研究》,贵州人民出版社,1999年,第36~41页。\n[7] 见《西周诸王年代研究》,贵州人民出版社,1997年,第367~379页。\n[8] 载《人文杂志》,1989年第5期;又见《西周王年论稿》,贵州人民出版社,1996年。\n[9] 载台湾《大陆杂志》,1992年第2期第85卷;又见《西周王年论稿》。\n[10] 见《铜器历日研究》,贵州人民出版社,1999年,第35页。\n"},{"id":155,"href":"/zh/docs/culture/%E5%8F%A4%E4%BB%A3%E5%A4%A9%E6%96%87%E5%8E%86%E6%B3%95%E8%AE%B2%E5%BA%A7/%E7%AC%AC6%E8%AE%B2-%E7%AC%AC7%E8%AE%B2/","title":"第6讲-第7讲","section":"古代天文历法讲座","content":" 第六讲四分历的应用 # 四分历法是观象授时高度发展的产物,古人制定四分历法就是为了取代观象授时,服务于人类社会的生产和生活,这是毫无疑义的。因此,年代学的基础课题就是掌握四分历法,用它来推算上古历点,为解决有关的学术问题服务。特别是近代,出土文物越来越多,古史古事的考订,都需要我们确定其年代及月日。我们依据四分历法仍可以求得密近的实际天象,解决其中的疑难。这正是文史工作者学习古天文历法的目的之一。\n一、应用四分历的原则 # 明确了殷历甲寅元(即《历术甲子篇》)创制于公元前427年,就可以将四分历在实际考证中普遍应用,推算古代典籍及出土的地下器物所载的历点,并在推算中验证殷历甲寅元的正确性。\n四分历是战国初期创制并行用,大体到三国时期的蜀汉废止。如果将四分历广泛应用,必须明确几个问题。\n第一,殷历甲寅元一经创制行用,就成为中华民族的共同财富,通行于当时各国。战国纷争,诸侯力征,不统于王,各国用历也标新立异,所以后人总认为“战国时代各国历法不同”。这只看到了问题的表象。四分历于战国初期行用,这一法则在当时就是不可改变的了。各国用历虽花样繁多,名号各殊,或岁首不同,或建正有异,都只能在四分历法则内改头换面,实质不变也变不了。我们用四分历推算有关历点,是掌握了一个普遍的原则,所得结果自然不会有误。\n战国时代,各国是否一致行用四分历法呢?\n不难明白,历法不是产生于某国某君某人之手,而是历代星历家血汗的结晶。可能经过某些君王(比如魏文侯)的提倡归功于某些星历家(比如楚人甘德、魏人石申)的勤劬。但历法一旦创制就不可能为某国某君所垄断,必然普施于华夏人民足迹之所至。谁会舍先进的历法不用而去吃观象授时的苦头?且战国初期,朝秦暮楚的士大夫比比皆是,历法一经行用自然不受国界的约束。因此四分历必能普施于战国时期各诸侯国。\n再说,经商周至战国初年,干支纪年已千百年不紊,各国都使用一个共同的干支日历,月球的朔望又人人可见,日与月的一致自不待言。有这样一个共同的月历、日历作为基础,历法普施于战国才有可能。\n从现有文献资料看,《孟子》所记时令与《楚辞》所记,仅只是岁首不同而已。据《孟子》载:“七八月之间雨集,沟浍皆盈”(《孟子·离娄下》);又“七八月之间旱则苗槁矣,天油然作云沛然下雨,则苗浡然兴之矣”(《孟子·梁惠王上》)。讲的是下暴雨。我国山东一带下暴雨的时间,当是夏历五、六月。因《孟子》一书的用历是取建子为正,所以与建寅为正的夏历有两月之差,究其实则是指同一天象,《孟子》用的也是四分历。\n据《楚辞·怀沙》载:“滔滔孟夏兮,草木莽莽。”孟夏即四月,草木繁茂,与建寅为正的夏历合。又《楚辞·抽思》:“望孟夏之短夜兮,何晦明之若岁。”讲初夏昼长夜短明显起来,正合夏历。\n秦用四分历,从《史记·秦本纪》中也有反映:“(昭襄王)四十八年十月韩献垣雍。……正月兵罢复守上党,其十月五大夫陵攻赵邯郸。又四十九年正月益发卒佐陵。陵战不善,免,王龁代将,其十月将军张唐攻魏。”此两处,先记秦十月、正月,再记“其十月”(它的十月)。因为兵入赵魏之地,故用赵魏之月序记。足证秦与赵魏同用四分历,只不过秦以十月为岁首,三晋用夏正罢了。\n燕国僻远,用历无考,以理推之,密近三晋。一句话,《历术甲子篇》通用于七国,战国时代实际全用四分历。\n由于齐鲁建子为正,秦历又建亥为首,与楚、晋各异,似乎战国有多种历法了,这便给“三正论”者以生事的机会,造成后世的惑乱。\n战国用历原本四分术,然而为什么名目如此繁多呢?\n首先,列强出于政治斗争的需要,在用历上往往变换一些手法,以示与周王朝分庭抗礼,尽管都用四分历却有意标新立异,独树一帜。\n其次,自封为王,欲兼天下,必然要利用“君权神授”的观念,这就是历志上“改正朔,易服色”的记载,用以表明“受命于天”,从而威天下而揽民心。\n再次,托古作伪以自重,也是列强君王惯用的手法。四分历创制之初,就曾伪称“成汤用事十三年”把创立之功归于前代圣王。秦历托名“颛顼”,也同样出于托古自重。战国时代所谓“周历”“夏历”,莫不如此。汉代有“古六历”之说(黄帝历、颛顼历、夏历、殷历、周历、鲁历),那虽是后人的附会,实际也可见托古作伪的痕迹。\n战国用历从表现形式看,或建正不同(齐鲁建子为正,秦楚三晋建寅为正),或岁首不同(齐以子月为岁首,楚三晋以寅月为岁首,秦以十月为岁首),或历名不同(秦称颛顼历,以别于殷历),如此而已。而其所宗之“法”,也都为四分术。在当时的条件下四分历的周密与完整是无法取代的。\n这种种名目,却给“三正论”制造者以可乘之机。按照“三正论”者对“周正建子、殷正建丑、夏正建寅”的解释,夏、商、周三代使用了不同的历法,即夏代之历以寅月为正,殷代之历以丑月为正,周朝之历以子月为正。夏商周三朝迭相替代,故“改正朔”以示“受命于天”。秦王迷于“三正论”,继周之后以十月为岁首,也有绍续前朝,秉天所命之意。实际上,四分历产生之前,还只是观象授时,根本不存在完整的行用于夏时之夏历,行用于殷商时代之殷历,行用于西周之周历,所谓夏历、殷历、周历,纯然是后人的概念。\n懂得了战国用历的实质,排除“三正论”的干扰,就可以运用四分历进行具体历点的推算。\n第二,四分历取岁实36514日,与实际回归年长度必有误差,307年盈一日。如果将一日化为940分,940÷307=3.06(分/年),即每年有3.06分的误差。这样,以公元前427年四分历行用之时为基点,在它以后的年份每年有+3.06分的误差,在它以前的年份,每年有-3.06分的误差。因此,在推算实际天象时,公元前427年之前的年份,每年要加3.06分;公元前427年之后的年份,每年要减3.06分。这就是前加后减的原则。只有这样,才能得出密近的实际天象。3.06分就是推求实际天象的改正值。\n在四分历行用的年代,由于时人不了解这个误差,自然不可能将误差计算进去。所以,典籍中总有历法与天象不符的记载,汉初“日食在晦”的文字就属此类。我们在考究战国至汉末这段时期的历点时,除了顾及朝代交接和改历等重大问题外,应用四分历进行推算时,不必使用“前加后减”的原则。因为追求实际天象除了验证朔望,反而与实际用历相违。实际用历还不知道这个3.06分。\n第三,公元前427年之前的年份,仍可用四分历推算月日。公元前427年之前,未行用四分历法,还是观象授时阶段。但月相在天,有目共睹,干支纪日从殷商时代已延续不断,人皆遵用。这就构成了历法推算的基础。前代学者依据《春秋》所载月日干支,编制出春秋时代的历谱。张汝舟先生《西周经朔谱》《春秋经朔谱》就立足于殷历的朔闰,取密近的实际天象,将古代文献所记这两个时期的年、月、日一一归队入谱,贯穿解说,对前人之误见逐次加以澄清。因此,“两谱”既是对两周文献纪日的研究成果,也是广大文史工作者研究两周文史的极好工具。\n要之,编定历谱或考释历点,都得以《历术甲子篇》为依据,将四分历普遍地应用于文史研究工作中。\n二、失闰与失朔 # 年、月、日能够有规律地进行调配的真正历法(四分历)产生于战国初期,有历法之前都还是观象授时。观象授时就是制历。制历的主要内容就是告朔和置闰两件大事。告朔是定每月朔日的干支,朔日干支一经确定,其余日序自有干支。置闰是定节气,一年之气,冬至最要紧。冬至一经确定,闰与不闰及全年月序就自然清楚。\n在观象授时阶段,告朔就全凭月相。古人凭月相告朔,承大月二日朏,月牙初见,承小月三日朏,月牙初见(见《说文》)。同理,承大月十五日望,月满圆,承小月十六日望,月满圆。月相分明,只在一天。\n在观象授时阶段,置闰须观斗柄所指方位,观二十八宿中天位置,验之气象、物象,加以土圭测影。随着长年的经验积累,观测仪器的精当,测定气节的准确程度必然逐有提高。前已述及,到春秋中期,十九年七闰的规律就已完全掌握了。\n四分历的回归年长度定为36514日,且使用平朔、平气,所以失闰,特别是失朔还不能完全避免。更何况春秋、西周还处在观象授时的时代,失闰与失朔当是屡见不鲜的。比如,实际是乙丑朔,因为分数小,司历定为甲子朔。如果乙丑分数大,司历定为丙寅朔。这叫失朔。\n失闰,说得确切些,就是失气。实际是子月初冬至,司历错到亥月末,亥月就成了岁首(建亥)。冬至若在下旬,司历错到丑月,丑月就成了岁首(建丑)。失闰由失气而起,我们还叫失闰。\n失朔,失闰,《春秋》有宝贵资料。例如,昭公十五年经朔:\n子月大,己未623分合朔\n丑月小,己丑182分合朔\n寅月大,戊午681分合朔\n卯月大,戊子240分合朔\n辰月大,丁巳740分合朔\n……\n《春秋》载:“二月癸酉,有事于武宫。”“六月丁巳朔,日有食之。”以此二条验谱,己未朔,癸酉乃十五日,子月实《春秋》所书“二月”。“六月丁巳朔”正合辰月。这一年必是建亥为正,子月顺次定为“二月”,辰月顺次定为“六月”,全合。大量材料证实,春秋后期建子为正,现在正月到了亥月,这就是失闰之铁证。\n将一部《春秋》进行研究,可以发现:\n隐、桓、庄、闵共63年49年建丑,8年建寅,6年建子;\n僖、文、宣、成共87年58年建子,16年建丑,13年建亥。\n这说明,前四公,即春秋前期,建丑为正,建子、建寅都算失闰,而没有建亥的。后四公,即春秋后期,建子为正,建亥、建丑都算失闰,而没有建寅的。这又说明,失闰不会超过一个月。按平气计算,一般失闰都在半月之内,只有周幽王六年失闰十七天(据《诗经·十月之交》所给历点推算)。\n《春秋》记37次日食,有5个书月日不书朔。《左传》认为“史失之”,未免武断。因为食不在朔,所以《公羊传》云“或失之前,或失之后”,是正确的。失朔一般在半天之内,只有鲁文公元年“二月癸亥,日有食之”,失朔508分,超过半天(一日940分)。\n为什么要掌握一个失闰限、失朔限呢?这是应用四分历推演经朔考订古籍古器历点必须遵循的准则。如果历点与实际天象所确定的朔、闰相差甚远,失闰超过一月,失朔超过一天,就宁可存疑也断不可硬套,去企求得出一个相合的结论。如果没有一个失闰、失朔限,古器物上的历点就可左右逢源,安在哪一年都会大致相符。记有历点的出土文物,一到专家的手里,考证出的结论往往大相径庭,其道理就在这里。可见,确定失闰限、失朔限是多么重要。它提醒你,要严谨,不可信口雌黄。\n有没有“再失闰”的情况?古籍中确有记载。《汉书·律历志》载,襄公二十七年“九月乙亥朔,是建申之月也。鲁史书:‘十二月乙亥朔,日有食之。’传曰:‘冬十一月乙亥朔,日有食之,於是辰在申,司历过也,再失闰矣。’言时实行以为十一月也,不察其建,不考之于天也”。\n《春秋》经文杜注:“今长历推为十一月朔,非十二月。传曰辰在申,再失闰。若是十二月,则为三失闰,故知经误。”\n《左传》杜注:“谓斗建指申,周十一月今之九月,斗当建戌而在申,故知再失闰也。文十一年三月甲子至今七十一岁应有二十六闰,今长历推得二十四闰,通计少再闰。释例言之详矣。”\n杜预这两条注,将《春秋》经传所记,辨析明白,断定经误传是。传文“再失闰”是可信的。杜以自编《经传长历》验证,确为“再失闰”。《汉书·律历志》解释说,当时是记为十一月的,这种“再失闰”是不观察斗柄所指,不考之于天象的原因。可见,观象授时阶段失闰是不足为怪的,但已不可能在春秋时代出现“再失闰”的怪现象。\n如果用《历术甲子篇》推演,襄公二十七年(公元前546年)朔闰如次。\n是年入辛卯蔀(蔀余27)第三十四年。\n太初三十四年:前大余四十八,小余五百五十二先天+364分\n子月朔己卯552分916己卯十五\n丑月朔己酉111分475己酉 四十五\n寅月朔戊寅610分34 己卯 十五\n卯月朔戊申169分533戊申 四十四\n辰月朔丁丑668分92 戊寅 十四\n巳月朔丁未227分591丁未 四十三\n午月朔丙子726分150丁丑 十三\n未月朔丙午285分649丙午 四十二\n申月朔乙亥784分208丙子 十二\n酉月朔乙巳343分707乙巳 四十一\n戌月朔甲戌842分266乙亥 十一\n亥月朔甲辰401分765甲辰 四十\n甲戌(十)\n春秋后期(襄公)行子正。\n如果记上实际天象3.06×(546-427)=364分。加上364分,则子正十一月乙亥朔,日食,确。\n所谓“斗建申”乙亥朔,是战国人用四分历推算的结果。“九月乙亥朔”更是东汉人的口气。杜预以申月、酉月连大,得戌月乙亥朔。考之实际天象,春秋中期十九年七闰的规律已经掌握,并无“再失闰”这种怪现象。\n三、甲寅元与乙卯元的关系 # 关于古历,经过刘歆的制作,西汉以后就众说纷纭了。《汉书·律历志》云:“三代既没,五伯之末史官丧纪,畴人子弟分散,或在夷狄,故其所记,有黄帝、颛顼、夏、殷、周及鲁历。”这就是古六历说之来源。到了《后汉书·律历志》,又大加发挥:“黄帝造历,元起辛卯,而颛顼用乙卯,虞用戊午,夏用丙寅,殷用甲寅,周用丁巳,鲁用庚子。汉兴承秦,初用乙卯,至武帝元封,不与天合,乃会术士作《太初历》,元以丁丑。”六历之外,又有虞舜之历及太初历,每历之“元”也有了,且历元彼此不同,更显殊异。\n如果认真研究,什么六历、八历,徒有其名而已。南朝大科学家祖冲之说:“古之六术,并同四分。四分之法,久则后天。以食检之,经三百年辄差一日。古历课今,其甚疏者后天过二日有余。以此推之,古术之作,皆汉初周末,理不得远。”(见《宋书·历志》)祖冲之的论断有他的科学基础,回归年长度经过实测,推算所得数据更近准确。他指出古六历均为四分,而四分历法三百年差一日,无疑是正确的。他笼统地将古六历产生的时代归于“汉初周末”,问题并未解决。\n其实,古六历名目虽多,而史籍有据的只有天正甲寅元(殷历)和人正乙卯元(颛顼历),其他四历都是东汉人的附会。\n天正甲寅元与人正乙卯元,即殷历与颛顼历究竟有什么关系?\n《后汉书·律历志》说:“甲寅之元,天正正月甲子朔旦冬至,七曜之起,始于牛初。乙卯之元,人正己巳朔旦立春,三光聚于天庙(即营室)五度。”这就是甲寅元与乙卯元历元近距的天象记录。\n我们知道,立春距冬至是四十六日,营室五度按《开元占经》所列二十八宿的古代距度计算,离牵牛初度也正是四十六度。当时划周天36514度,太阳日行一度。因此,立春时太阳在营室五度也就是冬至时太阳在牵牛初度,甲寅元与乙卯元的天象起点就是一致的了。\n唐一行《大衍历议》引刘向《洪范传》和《后汉书·律历志》刘洪的话,都讲到颛顼历的历元是正月己巳朔旦立春。不过,刘向仍把年名称为甲寅,刘洪却称之为乙卯,日名己巳。颛顼历称乙卯元,又称己巳元,道理亦如此。刘洪称颛顼历年为乙卯,而刘向仍称甲寅,二者是一致的吗?\n近代学者朱文鑫据《后汉书》所记甲寅元与乙卯元的星宿差度,计算天正冬至和人正立春的测定时日,断定天正冬至点的测定早在人正立春点测定之前。(见《历法通志》)学者董作宾进一步推定:殷历天纪甲寅元第十六蔀第一年天正己酉朔旦冬至为其测定行用之时,其第六十二年乙卯岁正月甲寅朔旦立春为人正乙卯元测定行用之时。(见《殷历谱》)他们的研究是有成效的,但仍没有弄清甲寅元与乙卯元的联系和区别,最终未能从根本上解决问题。\n甲寅元与乙卯元有什么联系和区别呢?\n古人迷信阴阳五行,颛顼帝以水德王,秦自以为获水之德,故用颛顼名历,汉高祖也“自以为获水德之瑞”,故袭用秦颛顼历,这似乎是明白无误的,然而,奇怪的是,为什么后世历家总是对此满怀疑虑,不得其解呢?\n北宋刘羲叟作《长历》,用颛顼历推算西汉朔闰往往不合,最后只好说:“汉初用殷历,或云用颛顼历,今两存之。”(见《资治通鉴目录》)清汪日桢说:“秦用此术(指颛顼历乙卯元),以十月为岁首,闰在岁末,谓之后九月,汉初承秦制,或云用殷术,或云用颛顼术,故刘氏长术两存之,今仍其例。”其推算结论是“以史文考之,似殷术为合”。(见《历代长术辑要》)陈垣也认为:“汉未改历前用殷历,或云仍秦制用颛顼历,故刘氏、汪氏两存之。今考纪志多与殷合,故从殷历。”(见《二十史朔闰表》)问题就是这样奇怪。《后汉书》记为“乙卯”,而推算结果又肯定了“殷历(甲寅)”,这究竟为什么?\n前面在考证殷历甲寅元的历元及其近距时,曾经提到天正甲寅元和人正乙卯元的问题,这里有必要进一步探讨。朱文鑫证明了天正冬至点的测定早在人正立春点之前,即甲寅元的产生早于乙卯元。董作宾又推定,殷历天纪甲寅元第十六蔀第一年天正己酉朔旦冬至为其测定行用之时(即公元前427年,甲寅),其第六十二年乙卯岁正月甲寅朔旦立春为人正乙卯元测定行用之时(即公元前366年,乙卯)。但他们都惑于颛顼之名,将秦颛顼历与乙卯元颛顼历混为一谈,自然不得其解。\n我们认为,汉初行用秦颛顼历是完全可信的,秦颛顼历以十月为岁首,秦朝记事起自十月,终于九月,直至汉武帝太初改历以前,均同此例,这是汉初承袭秦颛顼历的铁证。问题在于,秦颛顼历实为殷历甲寅元,只是岁首不同而已;而所谓乙卯颛顼历,虽有六历中颛顼之名,实为殷历甲寅元的“变种”,这是好事者的历法游戏、模仿之作,从未真正行用过。前代历家每每惑于古六历之说,用假颛顼历(乙卯元)取代真颛顼历(甲寅元),拿不曾行用过的乙卯元验证古历点,自然不合,所以,最后都倾向于殷历甲寅元。\n这种论断有根据吗?有的。下面可以用历法来验证。\n427-366=61算外62(年)\n说明乙卯元之历元在甲寅元历元之后六十二年,公元前366年入殷历第十六蔀(己酉45)第六十二年。\n查《历术甲子篇》六十二年(端蒙单阏、乙卯)十二\n大余六小余二百四十六\n大余二十小余八\n据此,可以排出所谓乙卯元测定之年正月朔日立春干支:\n子月小庚午6246冬至甲申 20小余 8\n丑月大己亥35745大寒甲寅50小余 22\n寅月小己巳5304立春己巳5小余 29\n由此可知,乙卯元该年正月(寅)己巳合朔立春,这就是乙卯元近距的首日,故又称“己巳元”。可见乙卯元脱胎于甲寅元,纯系甲寅元的变种。\n但是应该注意到,乙卯元己巳朔并非夜半0时(子),尚有朔余304分,被乙卯元弃而不记(这是历元、首的要求),所以甲寅元与乙卯元的朔余总有304分之差,正因为如此,乙卯元的推算才会干支错乱,与历点不合,这就是乙卯元虽脱胎于甲寅元却运算不准的根源。刘、汪、陈诸家其所以屡遭挫折而后肯定殷历,原因盖出于此。\n以上是以乙卯元断取甲寅元的历点来计算的。若以甲寅元计,殷历第十六蔀当有蔀余45,首日己酉,6+45=51(乙卯),20+45=65-60=5(己巳),该年前十一月应乙卯朔己巳冬至。据此,其推算如下:\n子月小乙卯51246冬至己巳58\n丑月大甲申20745大寒己亥3522\n寅月小甲寅50304立春甲寅5029\n说明按甲寅元计,该年实为甲寅朔日立春。这样,所谓乙卯元就有正月己巳朔立春和正月甲寅朔立春两种说法,其真相就是如此。后人不知其故,作出种种解释,祖冲之说“颛顼历元岁在乙卯;而《命历序》云此术设元岁在甲寅”;《新唐书·历志》说“颛顼历上元甲寅岁,正月甲寅晨初合朔立春,七曜皆直艮维之首”,“其后,吕不韦得之,以为秦法,更考中星,断取近距,以乙卯岁正月己巳合朔立春为上元”等等,不过是一场历史的误会。\n由此可证,汉初行用的不是什么乙卯元,而是殷历甲寅元,不过按秦正朔以十月为岁首,终于九月,故又有颛顼历之名。至于乙卯元,冒名颛顼,以假乱真,其实从来是纸上谈兵,未曾行用,应该否定。\n张汝舟先生在《历术甲子篇浅释》中说:甲寅元的殷历起于周考王十四年年前十一月己酉朔夜半冬至,后六十一年(算外六十二年),即周显王三年(公元前366年)另创新历“人正乙卯元”,与殷历并峙。可是殷历施行已逾六十年,固定了,干支纪年也固定了。颛顼历的创制者只好自称“乙卯元”。殷历是母亲,颛顼历是她生的娃娃,不是事实吗?试检《历术甲子篇》太初六十二年乙卯“前大余六”,即年前十一月庚午朔。十一月、十二月共59天,所以是年正月是己巳朔。年是乙卯,日是己巳。所以“乙卯元”又叫“己巳元”,说明天正与人正的母子关系。\n不难得出结论:秦的颛顼历实是殷历。这个“乙卯元”的颛顼历根本没有施行过。\n四、元光历谱之研究 # “汉武帝元光元年历谱”竹简是1972年临沂银雀山二号墓出土的珍贵文物之一。它基本上完整地记载着汉武帝元光元年一年的历日,是我们探讨汉初历法又一份最直接的材料。\n对于“元光历谱”的研究,已经有陈久金、张培瑜等同志的文章。由于对古代历法的认识不一,研究者所取的角度不同,已有的结论似尚不足以服众。这里依据《史记·历术甲子篇》的记载,来揭示“元光历谱”的隐秘,希望通过讨论求得一个较完满的解释。\n前已述及,对秦汉所用历法,有“殷历”“颛顼历”等不同的称说,究其实,都是四分历,都是以岁实36514日,朔策29444940日分为基本数据的四分古法,这一点当无不同意见。\n出土的元光元年历谱原文是:\n元光元年十月己丑\n十一月己未二十八日冬至丙戌\n十二月戊子\n正月戊午十五日立春壬申\n二月戊子\n三月丁巳\n四月丁亥\n五月丙辰\n六月丙戌三日夏至戊子\n七月乙卯二十日立秋甲戌\n八月乙酉\n九月甲寅\n后九月甲申\n元光二年十月\n根据十二月、正月两个连大月,我们可以列出朔日的小余范围。\n十二月戊子882~939\n正月戊午441~498\n二月戊子0~57\n得出十一月(子)己未的小余范围:383~440分。\n以十月为岁首的所谓“颛顼历”,仍依“归余于终”的故例。如果弄明白它和殷历(甲寅元)的关系,而人正乙卯元的“颛顼历”从未施行过,秦历“颛顼”就仍是殷历。以此探求它的推算起点,其气余就远不及朔余重要,“小余范围”就应予以特别重视。\n元光历谱朔日干支一定,我们便可以由此上推若干年到这种历法的“推算起点”,下推若干年到汉武帝太初元年。如何推演呢?当然得利用确定朔日干支的“小余范围”。\n由于《史记·历术甲子篇》只列子月(十一月)朔干支及余分,我们可将“元光历谱”十一月(子)朔的小余范围己未(383~440)对号入座。核对甲子蔀七十六年的小余,只有\n太初四十八年小余399分\n太初五十二年小余410分\n太初六十九年小余419分\n太初七十三年小余430分\n这四个年头符合“元光历谱”十一月小余的范围。下面我们可一一分析,寻求它的推算起点。\n假设之一,是“元光历谱”合太初四十八年,“大余五十七,小余三百九十九”。“元光历谱”子月朔为己未(55)。则,前大余(57)+蔀余=子月朔(55),57+58=115(逢60去之,即55),蔀余当为58(壬戌)。四分历无壬戌部。此一假设不能存立。假设之二,是“元光历谱”合太初七十三年,“大余二,小余四百三十”。“元光历谱”子月朔己未(55),则蔀余当为53(丁巳)。四分历无丁巳蔀,此一假设亦不能存立。\n假设之三,“元光历谱”记有“后九月”,若从有闰无闰的角度看,太初五十二年有闰。则元光元年当入乙卯(51)蔀第五十二年(大余四)。蔀余51+大余4=子月朔55。\n如果从乙卯蔀前推九蔀,得甲子蔀。76×9+52=736年\n历元甲子蔀首年当在元光元年(公元前134年)之前736年,为公元前870年,这就又与殷历蔀首年不相吻合了。\n从公元前134年起算,章首之年如次:\n134+52=186(不算外,前185年为高祖后三年)\n185+19=204(前204年为汉高祖三年)\n204+19=223(前223年为秦王政二十四年)\n185-19=164(前164年为文帝十六年)\n以上各年均不能充当历元近距,不能作为“元光历谱”这种历法推算的起点。此一假设亦不能成立。\n若元光元年合太初六十九年,“大余五十五,小余四百一十九”则是年合甲子蔀六十九年。其章蔀首年次是:\n134+69=203(不算外,是前202,汉高五年)\n汉高祖五年,是刘邦登基称帝之年。《史记·高祖本纪》:“五年,正月,诸侯及将相相与共请尊汉王为皇帝。甲午乃即皇帝位汜水之阳。”《史记·秦楚之际月表》:“(五年)二月甲午,王更号,即皇帝位于定陶。”\n我们用四分历(殷历)章蔀推算,一一吻合。\n汉高祖五年入殷历丁卯蔀(蔀余3)第七十四年,大余56,小余778。知汉高祖五年子月癸亥(56+3)朔778分。则\n十月甲午朔279分\n十一月癸亥朔778分\n这就是能够以十一月甲子朔做计算起点的缘由。这不是改历,仅是加大分数。加大162分,就改子月癸亥朔为甲子朔。不难看出,《史记·历书》所载帝王“改正朔,易服色”事,是包括了汉高祖刘邦称帝改朔这一史实的。\n如果我们从汉高祖五年(前202年)起,据十九年七闰之成法,将汉初闰年排列,足可以看出其中之规律来。\n明确了置闰的安排,找到了相应的章蔀首日干支及小余,以此为推算的起点,汉初历法的本来面目也就一清二楚了。见《汉初朔闰表》(见下页)。如果用四分历章蔀运算,必须加上162分才能吻合。检验史籍所载汉初历点,更能证实“元光历谱”所反映的汉初历法是以公元前202年为计算起点的四分历。或者说,汉初历法是以殷历做基础,只不过是从公元前202年起多加上162分计算罢了。\n出土的《马王堆导引图》上有一个历点:文帝十二年二月乙巳朔。查上表:文帝十二年十月丙午朔900分,推演:\n十一月丙子459,十二月丙午18,正月乙亥517,二月乙巳76。\n用《历术甲子篇》推演:\n文帝十二年(公元前168年)入丙午蔀(42)第三十二年。\n太初三十二年:大余三十,小余二百九十七\n得知,子月朔十二(42+30),即丙子朔297\n丑月朔乙巳796\n寅月朔乙亥355\n卯月朔甲辰854\n二月甲辰朔854分,与出土所记不合。如果加162分,即将高祖五年(前202年)作为推算起点,推演:\n子月丙子297,加162分,得\n子月丙子459分,丑月丙午18分,\n寅月乙亥517分,卯月乙巳76分。\n如果将汉初百年所记朔晦干支一一核实,用《历术甲子篇》推演,加上162分,均能得出可信的结论。\n五、疑年的答案及其他 # 在史籍记载及出土文物中,留有不少至今未能取得一致看法的历点,我们统称之为“疑年”。造成疑年的事实,有史料本身记述不清的原因,也是古历研究者各执一端、见仁见智的结果。我们如遵循《史记·历术甲子篇》的记载,以此追求密近的实际天象,很多问题还是不难解决的。\n1.关于屈原的生年月日。\n这是文史界的热门话题。近人多信“岁星纪年”,用所谓“太岁超辰法”来推证,生出多种多样的结论,无法令人信服。\n《离骚》开篇:“摄提贞于孟陬兮,惟庚寅吾以降。”就告诉了我们,屈原生于寅年寅月寅日。\n对于屈原生年月日的考证,可以从几个不同角度来进行。比如屈原政治活动的时代背景,《离骚》全诗的语言特色及文意,历法的推算自然也是一个重要方面。这里,就四分历的具体推算来考证屈原生年月日。\n四分历(即殷历甲寅元)创制,行用于周考王十四年,干支纪年起始。否定了“岁星纪年法”,不承认“太岁超辰”,明确战国时代四分历普遍行用,楚用寅正,利用四分历推演屈原生年就是可能的了。\n考虑屈原生年只能在两个寅年,一是公元前355年丙寅(游兆摄提格),一是公元前343年戊寅(徒维摄提格)。\n公元前355年入殷历第十六蔀己酉蔀(45)第七十三年。\n太初七十三年:大余二小余四百三十\n2+45=47(辛亥)\n得知,前355年子月辛亥朔430分\n丑月庚辰朔929分\n寅月庚戌朔488分\n正月(寅)庚戌朔,庚寅日在朔日后41天,不在正月之内。故公元前355年(丙寅)不合“三寅”条件,应该放弃。\n公元前343年入殷历第十七戊子蔀(24)第九年。\n太初九年:闰十三,大余十四小余二十二\n14+24=38(壬寅)\n得知,前343年子月壬寅朔22分\n丑月辛未朔521分\n闰月辛丑朔80分\n寅月庚午朔579分\n正月(寅)庚午朔,庚寅为正月二十一日。\n从《历术甲子篇》数据推演,得知屈原生于戊寅年(前343年)正月(寅)二十一日(庚寅)。这也是清人邹汉勋、陈旸,近人刘师培的结论,张汝舟先生《再谈屈原之生卒》又加以申说、推算。\n其余各家之说,皆可以用四分历推演加以检验,指出粗陋或疏失之处。\n2.武王克商之年。\n关于武王克商年代的考证,说法有三十余家。据《汉书》载,《周书·武成》篇:“粤若来三月既死霸,粤(越)五日甲子,咸刘商王纣。”\n又,《武成》篇载:“惟一月壬辰旁死霸,若翌日癸巳,武王乃朝步自周,于征伐纣。”\n又,《武成》篇载:“惟四月既旁生霸,粤六日庚戌,武王燎于周庙。”\n张汝舟先生在《西周考年》一文中,对古书古器41个历点加以考证,论定武王克商在公元前1106年。对这一结论,我们可以通过演算,求出实际天象,加以证实。对其他各家之说,也可以用推算验证结论的错误而不可信。\n公元前1106年入戊午蔀(54)第六年。\n太初六年:大余一小余三百五十九\n先天:(1106-427)×3.06=2078(分)\n2078÷940=2……余198\n据前加后减的原则2.198+1.359+54=57.557(分)\n得知,前1106年子月辛酉(57)朔557分\n丑月辛卯(定朔辛卯111分)\n寅月庚申(定朔庚申915分)\n卯月庚寅(定朔庚寅657分)\n辰月己未(定朔庚申266分)\n巳月己丑(定朔己丑701分)\n张汝舟先生将定朔算出列于每月后面的括号内。我们用这个实际天象来验证《周书·武成》的记载,则一一吻合。\n是年丑正,一月辛卯朔,旁死霸初二壬辰,初三癸巳。\n二月庚申朔。越五日甲子,武王克商。\n闰月庚寅朔。\n三月庚申朔。\n四月己丑朔。既旁生霸(即十六)甲辰,越六日庚戌(二十二日),武王燎于周庙。\n联系对于月相的正确解释,足证武王克商之年确实是在公元前1106年。\n1978年湖北随县(今随州)擂鼓墩发掘的战国曾侯乙墓有一漆箱,除箱盖上环绕“斗”字的二十八宿名称及苍龙、白虎之形外,还有“甲寅三日”的字样。据专家考证,曾侯乙卒于楚惠王五十六年(公元前433年),“甲寅三日”当为死者卒日。试以四分历验证之。 公元前433年入殷历第十五蔀庚午蔀(6)第七十一年\n太初七十一年:闰十三,大余四十四小余一百七十五\n大余七小余十六\n44+6=50(甲寅)7+6=13(丁丑)\n该年前十一月(子)甲寅朔,丁丑冬至。据此排出朔闰干支:\n子月小甲寅175,冬至丁丑16\n丑月大癸未674,大寒丁未30\n寅月小癸丑233,惊蛰戊寅12\n卯月大壬午732,春分戊申26\n辰月小壬子291,清明己卯 8\n巳月大辛巳790,小满己酉22\n闰月小辛亥349,(无中气)\n午月大庚辰848,夏至庚辰 4\n欲得“甲寅三日(初三)”初一(朔)必为壬子。从上表看,辰月壬子朔,正当寅正三月,这说明曾侯乙卒于楚惠王五十六年(公元前433年)三月初三日。有的考证文章据日人新城新藏《战国秦汉长历图》,定“甲寅三日”为该年五月初三,明显是取子正,这是不妥当的。曾国作为楚国的属国,典章制度、官职名称既然与楚国相同,其用历建正必然与楚国是一致的,曾国不可能独立行事。如前所述,楚以建寅为正,这有《楚辞》大量诗句为证,若用子正是无法解释的。因此,“甲寅三日”应为该年三月初三。\n4.《史记·秦始皇本纪》:“(三十七年)七月丙寅,始皇崩于沙丘平台。”\n秦历托名颛顼,实为四分,只是记事以十月为岁首,并不改寅正月序。下面以四分历验之。\n始皇三十七年(公元前210年)入殷历第十八蔀丁卯蔀(3)第六十六年。\n查《历术甲子篇》太初六十六年闰十三\n大余十三小余二百五十七\n大余四十一小余八\n13+3=16(庚辰)41+3=44(戊申)\n得知,该年前十一月庚辰朔,戊申冬至。据此可排出朔闰:\n子月庚辰257,冬至戊申 8\n丑月己酉756,大寒戊寅22\n闰月己卯315,(无中气)\n寅月戊申814,惊蛰己酉 4\n卯月戊寅373,春分己卯18\n辰月丁未872,清明庚戌 0\n巳月丁丑431,小满庚辰14\n午月丙午930,夏至庚戌28\n未月丙子489,大暑辛巳10\n申月丙午 48,处暑辛亥24\n七月为申月,该年七月丙午朔,“丙寅”为七月二十一日,此日始皇崩于沙丘平台。可见此年置闰并非在所谓“后九月”而是闰在十二月。如果置闰“后九月”那么未月当为七月,该月丙子朔,无丙寅日,怎样解释?\n始皇之崩,何等大事!史官是绝不会记错的。清代汪日桢《历代长术辑要》记该年六月丙午朔,七月乙亥朔,置闰后九月。然而七月乙亥朔而无丙寅日,足见有误。如果立足“后九月”置闰,这个闰月只能放在始皇三十六年。\n5.《汉书·武帝纪》:“元朔二年三月己亥晦,日有食之。”元朔二年为公元前127年,入丙午蔀第七十三年。\n推算得知,子月(十一月)戊申朔430分\n十二月丁丑929\n正月丁未488\n二月丁丑47\n三月丙午546\n四月丙子105\n(下略)\n三月丙午朔,四月丙子朔,则三月乙亥晦。\n可知史书“己亥”乃“乙亥”之误。\n由上数例可知,凭借司马迁为我们保存的历法宝典《历术甲子篇》,对古代历点进行推算检验,不仅可以解决史籍中的许多疑年,且能矫正史料中有关的许多错误。第七讲历法上的几个问题\n第七讲历法上的几个问题 # 前面,我们将张汝舟先生有关古代天文历法的主要内容系统地进行了讲述,又依据《历术甲子篇》所载四分历(即殷历)掌握了推步的方法并用来解决一些实际问题,比如战国以前古历点的推求及汉初历谱的验证。这都说明,四分历在战国秦汉的广泛应用。如果我们立足于张汝舟先生古天文学说的基本观点,对汉代以来纷纭不已的几个历法上的主要问题加以检讨,就会澄清若干悬而不决或决而已误的问题,得到更近于事实的结论,恢复古代历法的本来面目。这样,我们在古代天文历法的学习与研究方面就前进了一大步。\n一、太初改历 # 通过殷历甲寅元的历元和历元近距的考证,通过汉初历点的推算,汉太初以前的用历情况和改历原因实际上已经清楚,为了更系统地说明问题,下面再进一步加以讨论。\n汉武帝元封七年(公元前104年)下诏改历,改年号为“太初”,史称“太初改历”。这次改历在我国历术史上是第一次,承上启下,影响深远,历代正史多有评述,但因种种原因,至今很多问题尚未澄清,从而直接影响到上古天文历法的研究。\n汉武帝继文景之治登上皇位,以自己的雄才大略,内外经营,励精图治,使西汉王朝进入政治稳定、国力强盛、经济繁荣、文化发达的全盛时期。如果说,汉初“天下初定,方纲纪大基,高后女主,皆未遑,故袭秦正朔服色”(《史记·历书》),那么,到了汉武帝时代应该说改历的时机成熟了,条件具备了。而且,武帝作为一代英主,深知“天之历数在尔躬”的古训,明白“改正朔,易服色,所以明受命于天”(《汉书·律历志》)的政治影响,需要借改历来强化统治。但是,并不能得出这样的结论:只要社会条件允许,帝王出于政治需要,就可以随意改历,因为这些只是改历的外部因素,“外因是变化的条件,内因是变化的根据”(《矛盾论》),促成改历的根本原因要从历法本身去找,由用历与天象相适应的程度来决定,如果用历密合天象,改历是多此一举;如果用历明显背离天象,改历则势在必行。\n如前所述,据《史记》《汉书》记载,汉兴以后,多次发生“日食在晦”的反常天象,即所谓“朔晦月见,弦望满亏,多非是”(《汉书·律历志》),这是不容忽视的史实。它明确地告诉我们,此时用历明显不准,已经超越天象一日,这是四分历固有的误差造成的,与前面考证的殷历甲寅元创制行用于公元前427年这一结论是完全相符的。\n历法以固定的章蔀统筹多变的天象,行之日久,必有误差,即就今天使用的历法,也不是绝对准确的,何况古人凭目测天象制历,误差较大并不奇怪。除前面引过的祖冲之的论述之外,刘宋星历家何承天也说:“四分于天,出三百年而盈一日”(《宋书·卷十二》);唐一行又说:“古历与近代密率相较,二百年气差一日,三百年朔差一日。推而上之,久益先天;引而下之,久益后天”(《唐书·历志三上》)。因此,后世历家认为:“四时寒暑之形运于下,天日月星有象而见于上,二者常动而不息,一有一无,出入升降,或迟或疾,不相为谋,其久而不能无差忒者,势使之然也。故为历者,其始未尝不精密,而其后多疏而不合,亦理之然也。”(《唐书·历志一》)又说:“盖天有不齐之运,而历为一定之法,所以既久而不能不差,既差则不可不改也。”(《元史·历志一》)汉初星历家虽然没有如此系统的理论,但他们凭实测发现了问题,因此,元封七年大中大夫公孙卿、壶遂和司马迁等人才联名向汉武帝提出“历纪坏废,宜改正朔”的建议(见《汉书·律历志》)。这个建议符合汉武帝的主观需要,又为当时社会条件所允许,于是很快被武帝采纳,并付诸行动。以上就是太初改历的原因。\n既然太初改历的原因如此,探求太初改历的真相就有了正确的途径。根据前面的考证,汉初行用的是创制于公元前427年的殷历甲寅元,不过以十月(亥)为岁首而已。下面且看史书是怎样记载的:\n《史记·历书》说:“……故袭秦正朔服色。”\n《汉书·律历志》说:“汉兴,方纲纪大基,庶事草创,袭秦正朔,以北平张苍言,用颛顼历……”\n《后汉书·律历志》又说:“汉兴承秦初用乙卯,至武帝元封不于天合,乃会术士作太初历,元用丁丑。”\n史书所记的“承秦正朔”与我们的考证是一致的,但为什么《后汉书·律历志》记“承秦初用乙卯”,而不是“甲寅”呢?这就要弄清“甲寅元”与“乙卯元”的联系和区别。前已讲到,秦的颛顼历就是殷历,所谓“乙卯元”的颛顼历从未施行过。如果排除“乙卯元”的干扰,就可直接探求太初改历。\n元封七年(即太初元年)为公元前104年(丁丑)\n427-104=323算外324(年)\n324÷76=4……余20(年)\n说明元封七年位于殷历第二十蔀(乙酉21)第二十年,即该蔀第二章首年。\n查《历术甲子篇》二十年十二\n大余三十九小余七百五\n大余三十九小余二十四\n39+21=60(0甲子)39+21=60(0甲子)\n705940-2432=0\n说明该年前十一月(子)甲子日酉时(18时)合朔冬至。\n因为太初改历的原因就在年差分积累过大,造成“日食在晦”的反常天象,现在改历者看准了这一时机,为纠正用历的误差,取消朔日余分705和冬至余分24(即消除年差分),便使元封七年十一月甲子日夜半0时合朔冬至无余分,这样就大致避免了“日食在晦”的天象,无疑是个巧妙的方法。同时,还改岁首(以寅月为正月,该年十五个月,其中丙子年前105年三个月,丁丑年前104年十二个月),改年号为“太初”以为纪念。正如《史记·武帝本纪》说:“夏(五月下诏改历),汉改历,以正月为岁首,而色尚黄,官名更印章以五字,因为太初元年。”《史记·历书》亦说:“自是以后,气复正,羽声复清,名复正,变以至子日当冬至,则阴阳离合之道行焉。十一月甲子朔旦冬至已詹,其更以七年为太初元年。”其后附记《历术甲子篇》全文,以明所用的历法准则,这就是发生在元封七年的改历情况。\n这样改历在当时无疑是一场革命,必然触动那些顽固守旧派的神经,遭到他们的反对。汉昭帝元凤三年(公元前78年,太初改历后二十七年)太史令张寿王上书攻击太初改历,坚持所谓“黄帝调律历”,“妄言太初历亏四分日之三,去小余七百五分,以故阴阳不调”。为此满朝震动,争论持续数年之久,最后以张寿王的失败而告终。张寿王墨守成规,反对变革,自然是愚昧可笑的,但他提供的数据无疑给我们透露了太初改历的秘密。至此,所谓汉初承用秦颛顼历的真相可以大白;况且论战最后已经证明,“寿王历乃太史官殷历也”。(均见《汉书·律历志》)\n二、八十一分法 # 据《汉书·律历志》记载,太初改历还采用了邓平的八十一分法。所谓八十一分法,就是以29 43/81日为朔策(朔望月),邓平认为499/940太繁,而26/49<499/940,17/32>499/940,故采用26+17=43做分子,49+32=81为分母,以43/81取代499/940。因此,其章蔀为:\n八十一分法虽来自四分法,43/81比499/940简化,但是其朔策 > (日),其岁实 > (日),所以其精度反不及四分法,这是肯定的。\n《汉书·律历志》虽然记载了邓平八十一分法及其章蔀编制但并未说明邓平八十一分法取代四分法的确切时间,而且太初改历的参与者司马迁著的《史记》对邓平及其八十一分法竟然只字未提,这就引起后世历家的种种猜疑,不得定论,直到现代,古历研究者还在争论着。\n著名天文学家陈遵妫说:“制定太初历的时候,是由司马迁和其他许多历家来共同研究的。不久所决定的历法是《史记·历书》所载的《历术甲子篇》,即以太初元年前十一月甲子朔为历元的四分历法;当时并且还颁布过施行这种历法的诏书。但这种历法把当时人们算为丙子的太初元年,改称为甲寅岁,并以立春正月改为冬至正月;可以说是完全属于理想的历法。以致施行的时候,有些不便,似乎曾经受过各方面的激烈反对,不得不把施行这历法的命令撤回。后来又增加专家,重行研究,不久才决定采用邓平的八十一分法。”(《中国古代天文学简史》)\n何幼琦说,太初改历“是天正派复辟和人正派拨乱反正的公开较量”,“司马迁是失败者,心怀不满,所以在《历书》中既不详述改历的过程,又不附录太初历,反而附载了他的《历术甲子篇》;改洛下为‘落下’也不会是笔误吧”。(见《学术研究》1981年第3期何幼琦《关于“五星占”问题答客难》)\n《中国天文学史》(1981)虽看出其中有问题,但依然认为:“太史令司马迁虽提议改历,却并未想到要改变四分历的计算方法,他在编写《史记·历书》竟不提邓平的八十一分法,而仍以四分法编他的《历术甲子篇》附在后面。显然,他是不同意八十一分法的。”\n他们提出了两个值得注意的问题:(1)《历术甲子篇》出自司马迁编排;(2)《史记·历书》其所以不记邓平法,是因为《历术甲子篇》被否决,司马迁心怀不满,不同意邓平的八十一分法。至于是太初元年改历时就行用邓平法呢,还是《历术甲子篇》行用一段时间后再用邓平法呢?仍无定说。\n我们认为,关于《历术甲子篇》是否是由司马迁编排的,通过前面大量的推算考证已经完全解决,毋庸赘言。关于不记邓平法,是由于司马迁心怀不满、发泄私愤的说法,只是猜测之辞,并没有史料根据。司马迁《史记》的实录精神历来为史家称道,对《史记》颇有微词的班固也不得不承认:“……自刘向、扬雄,博极群书,皆称迁有良史之材,服其善序事理,辨而不华,质而不俚,其文直,其事核,不虚美,不隐恶,故谓之实录。”(《汉书·司马迁传赞》)这也是后世公认的。因此即使司马迁是太初改历的失败者,也不会文过饰非,歪曲史实,以致发展到违抗圣旨、篡改诏书的严重程度,这不仅违背司马迁的人品、性格,而且无异于拿自己的生命开玩笑,再遭一次“李陵之祸”。何况,《历术甲子篇》本来就是行用了三百多年的殷历甲寅元,司马迁不过采自史官旧牒,随文记录而已,即使用邓平法取代《历术甲子篇》,与司马迁有什么利害关系?司马迁用不着心怀不满,故意隐瞒。那么,为什么《史记·历书》不记邓平及其八十一分法呢?合理的解释只能是,太初改历实际上分两步进行。在元封七年进行改历时(消余分、改岁首),邓平尚未参加,八十一分法也没有制定,司马迁只是按当时的实际情况来记载,所以《史记》详记《历术甲子篇》而不记邓平及其八十一分法——司马迁不能未卜而先知!至于邓平八十一分法取代四分法,那已是太初改历的第二步。其时司马迁或衰病无力,或不在人世,所以《史记·历书》根本不提邓平法。\n这种解释的根据是:\n第一,从鉴别史料的角度出发,《史记》产生于《汉书》之前,司马迁身为太史公,学有家传,是太初改历的首倡者和直接参与者,无论从经历还是从学识来说,都比班固更有发言权,更具权威性,因此,对太初改历的记述,《史记》比《汉书》的可靠性更大些。班固《汉书》产生于太初之后180多年,其间又经战乱,史料保存不善是造成错误的一个因素,更主要的是,班固本人对历法不熟习,对改历不清楚,完全迷信刘歆的曲说,所以《汉书·律历志》多录自刘歆《三统历》,论及太初改历自然不能不受其影响。刘歆作为一代学者,他对汉初用历、太初改历都十分清楚,否则,他就不会依据殷历甲寅元编排他的《三统历》(详见下文),但他为了给王莽篡权制造理论根据,故意歪曲史实,巧言夏、商、周三代更替,对后世产生了恶劣影响,贻害无穷。班固不明此理,妄誉刘歆《三统历》“推法密要”,正好暴露了他在这方面的弱点,所以遭到后世的非议。鉴于这种情况,他的《律历志》中记述的太初改历,可信程度就有限了。比如“乃以前历上元泰初四千六百一十七岁,至于元封七年,复得阏逢摄提格之岁”,这一句就很成问题。既然追元封七年之“前历”上元泰初,该用四分法章蔀推算,而:“四千六百一十七岁”却来自八十一分法的元法;“复得”一句更是错把殷历甲寅元的历元当汉太初元年干支号,让人不可理解。\n第二,对比《史记·历书》和《汉书·律历志》,关于太初改历的经过记载不同。\n《史记·历书》说:“至今上即位,招致方士唐都分其天部,而巴落下闳运算转历,然后日辰之度与夏正同。乃改元,更官号,封泰山。”接着记改历诏书,后附《历术甲子篇》,以明示历法根据。记载简明扼要,但清楚合理。\n《汉书·律历志》记载较详,先讲司马迁等人上书建议改历,武帝诏御史大夫兒宽与博士议,问:“今宜何以为正朔?”然后,博士赐等均说:“帝王必改正朔……则今夏时也。臣等闻学褊陋,不能明。”接着又大讲三统之制,后圣复前圣(显然语出刘歆)。在群臣大发议论之时,武帝即下诏改历,“其以七年元年”,结果是公孙卿、壶遂、司马迁等人“定东西,立晷仪,下漏刻”,忙碌一阵之后,却原来这一伙人“不能为算”,于是又招募治历者,邓平等人入选,“都分天部,而闳运算转历”,这样才产生了邓平的八十一分法。\n《汉书·律历志》的记载是值得推敲的,在臣下毫无准备的情况下,武帝怎能先下诏改历、更定年号,而后再做具体工作?欲定“朔晦分至,躔离弦望”,以及“太初本星度新正”,绝非一日之功,必须早有准备,怎能在仓促间得之?更奇怪的是,司马迁身为皇家太史令,学有家传,却“不能为算”,只好请邓平等人来帮忙;何况无论从理论还是从实践上看,八十一分法都比四分法粗疏。至于“后圣复前圣”之说,出自刘歆《三统历》,怎会在太初改历时出现?……这些问题都值得深思。\n第三,关于改历的诏书记载不同。\n汉武帝的改历诏书,无论《史记·历书》或《汉书·律历志》所记,都未提及邓平八十一分法,可见改历之初邓平并未参与。\n《史记·历书》说:“因诏御史曰:‘乃者有司言星度之未定也,广延宣问,以理星度,未能詹也。盖闻昔者黄帝合而不死,名察度验,定清浊,起五部,建气物分数,然盖尚矣。书缺乐弛,朕甚闵焉,朕唯未能循明也。绩日分,率应水德之胜。今日顺夏至,黄钟为宫,林钟为徵,太簇为商,南吕为羽,姑洗为角。自是以后,气复正,羽声复清,名复正变,以至子日当冬至,则阴阳离合之道行焉。十一月甲子朔旦冬至已詹,其更以七年为太初元年。’”\n《汉书·律历志》中,从“乃者有司言历未定”到“未能循明”一段,基本上全抄《史记》,奇怪的是,班固竟不抄完,中间空了关键性的一大段话,最后只录“其以七年为元年”一句作结束。这样改动,文意不通姑且不论,最让人难解的是不合情理,既然“未能循明”,又贸然宣布“其以七年为元年”,如此行事,岂不荒唐!\n根据上述分析,可以得出这样的结论:\n(1)元封七年改历之初,邓平并未参与,更没有八十一分法,用邓平法取代四分法是后来的事。\n(2)《汉书·律历志》记述不清,是造成种种误解的根源。一方面由于年代久远,史料不全,另一方面由于班固本人对汉初用历、太初改历缺乏正确的认识,以致使《汉书·律历志》的记述陷入混乱,前后矛盾;而《史记·历书》出自太初改历当事人之手,自然比较可靠可信。当然,这并不排除《汉书·律历志》保存的其他资料的可靠性,比如前面一再提到的“次度”,就出自《汉书·律历志》。\n那么,邓平八十一分法究竟在什么时候取代了四分法呢?我们不妨从历法上进行一些探讨。\n如前所述,太初元年改历取消前小余705分,后小余24分。\n太初元年实为:\n无大余无小余\n无大余无小余\n如果除去太初元年丁丑(元用丁丑)不记,那么太初元年前十一月甲子日朔夜半冬至无余分,实际上相当于殷历甲寅元第一蔀(甲子蔀)首年,即《历术甲子篇》首年,后面年份的朔闰均可依《历术甲子篇》计算。后人不明其中的缘故,在《历术甲子篇》上加记汉太初以后的年号、年数,误认为《历术甲子篇》是太初改历时司马迁的创作,其原因盖出于此。\n虽然太初改历消除了朔余705分,但并未从根本上改变“日食在晦”的天象,所以,太初改历仍有“日食在晦”的现象发生,这恐怕就是促使邓平等人创制八十一分法的原因。他想用简化朔余、改变章蔀的方法来提高历法的精度。尽管八十一分法疏于四分法,但这种创新精神是可贵的。\n既然太初改历确定以夏正正月(寅)为岁首,那么八十一分法取代四分法的理想时机就应该是“正月朔日立春”,作为历元起点。用这个标准衡量太初元年,显然不合格:\n太初元年立春在年前十二月(丑)十七日,自然不能作为八十一分历的起点,所以,认为自从太初元年改历起就行用邓平八十一分法,是没有历法根据的。\n从历法上看,在太初元年以后的十多年间,只有征和元年(公元前92年)比较理想。\n104-92=12(算外13年)\n查《历术甲子篇》十三年十二\n大余五十小余五百三十二\n大余三无小余\n说明该年前十一月甲寅朔丁卯冬至。据此可得正月朔日立春干支:\n子大甲寅50532冬至丁卯 3 0\n丑小甲申2091大寒丁酉3314\n寅大癸丑49590立春壬子4821\n由此可知该年正月合朔与立春密近(气余甚大),可以作为八十一分历的起点。\n但是作为新历首月应为小月( <30),邓平等人为了避免日食在晦的天象,减少朔余,所以用了首月为小减少余分的方法(即先借半日),从而实现了四分历向八十一分历的顺利过渡。《汉书·律历志》所谓“先籍半日,名曰阳历”,即指此事。邓平历由于其本身精度不及四分法,行用时间并不长,到东汉章帝元和二年(公元85年)只好又改行四分法,那是后来的事了。\n可见,八十一分法取代四分法已到了太初元年之后的第十三年,其时司马迁的《史记》已经完成,司马迁本人或衰病无力,或不在人世,怎么会在《史记》中补记邓平八十一分法呢?司马迁若因此而受到后人责难,岂不冤哉!\n这个八十一分法就是《后汉书·律历志》所说的“三统历”。原文有:“自太初元年始用三统历,施行百有余年,历稍后天,朔先于历,朔或在晦,月或朔见。”\n这个八十一分法也就是《后汉书·律历志》所说的“太初历”。原文是:“至元和二年,太初失天益远,日月宿度相觉浸多。”还有:“(贾)逵论曰:太初历冬至日在牵牛初者,牵牛中星也。”贾逵又曰:“太初历不能下通于今,新历不能上得汉元,一家历法必在三百年之间。”新历指后汉四分历,太初历当然指的是八十一分法。原文还有:“昔太初历之兴也,发谋于元封,启定于元凤,积三十年,是非乃审。”这个发谋于元封的“太初历”还是指的八十一分法。\n总之,古历的惑乱,东汉人已无法说清,贻误后世,更可想见。\n三、关于刘歆的三统历 # 刘歆是西汉学者刘向之子,学有家传,也是西汉后期著名学者。他在其他方面的贡献,这里不必评述,我们只谈刘歆的三统历。\n刘歆的三统历曾与唐一行的大衍历、元郭守敬的授时历并列,被称为中国古代三大名历,其实是徒有虚名。《汉书·律历志》说:“至孝成世,刘向总六历,列是非,作《五纪论》,向子歆究其微妙,作三统历及谱以说春秋,推法密要,故述焉。”可见,班固是刘歆三统历的重要吹捧者。这样,《汉书·律历志》大量记载和宣传刘歆三统历,就是必然的。正因为如此,显露了班固的弱点,引起后世历家的非议。《晋书·律历志(中)》曰:“其后刘歆更造三统,以说《左传》,辩丽非实,班固惑之,采以为志。”《宋书·卷十二》曰:“向子歆作三统历以说《春秋》,属辞比事,虽尽精巧,非其实也。班固谓之密要,故汉《历志》述之。”《宋书·卷十二》何承天说:“刘歆三统法尤复疏阔,方于四分,六千余年又益一日。扬雄心惑其说,采为《太玄》;班固谓之最密,著于《汉志》;司彪因曰:‘自太初元年始用三统历,施行百有余年。’曾不忆刘歆之生,不逮太初,二三君子言历,几乎不知而妄言欤!”\n上述的评论无疑是正确的。\n但是,刘歆三统历究竟从何而来?它与四分法、八十一分法究竟有什么联系和区别?前人尚未论及,为了戳穿刘歆精心安排的骗局,廓清长期危害古历研究的迷雾,张汝舟先生生前进行了精心研究,取得了重大成就。\n首先应该指出,刘歆三统历不同于邓平八十一分法,二者除产生年代相差甚远之外,内部编制也根本不同:邓平八十一分法的三统是章蔀名(一元三统),刘歆三统,实为历名(即孟统、仲统、季统);邓平三统年数相加为一元,刘歆三统,各成一统,虽交错编排,实际自成体系;邓平历为八十一分法,刘歆历为四分法。有人将邓平历与刘歆历混为一谈,甚至认为自太初元年即行用三统历,正如何承天所说,纯属“妄言”。刘歆曾为王莽国师,在太初之后一百多年,太初改历时刘歆尚未出世,何来三统历?\n同时还应该看到,刘歆编三统历完全是为王莽篡权这一政治目的服务的。为了给王莽上台制造理论根据,披上合法的外衣,必须“改正朔,易服色,所以明受命于天也”。刘歆编三统历的背景如此,目的如此,必然要歪曲史实,巧言夏、商、周三代更替,以便为王莽上台鸣锣开道。然而,殷历甲寅元四分法古来行用,深入人心;邓平八十一分法新近改制,天下尽知;刘歆作为一代学者,父子相继,是深知其中的奥秘的,他虽无法另起炉灶,但可以巧立名目,于是暗用四分法岁实,又偷取邓平历的统法(1统81章),使孟、仲、季各成一统,交错迭用,形成三代更替的模式,号称三统历。《汉书·律历志》中那份《三统历章蔀表》就是这种货色,其中仲统(殷)以甲子为元首,季统(夏)以甲辰为元首,孟统(周)以甲申为元首,每统的第八十一章,与元首第一章相同,看来好似神乎其神,异常玄妙,其实,只要结合《世经》(见《汉书·律历志》)有关历点查对,就会发现,刘歆三统历不过是殷历甲寅元的模仿之作。\n三统历将殷太甲元年记为季统七十七章首乙丑,将周公摄政五年(庚寅,1111年)记为孟统二十九章首丁巳,说明三统历的排列,孟统先于殷历四章(即四分法的一蔀)。按四分章蔀计算,先于一蔀,必有蔀余39,孟统起于甲申(20),39+20=59(癸亥),而殷历起于甲子(0),实为殷历甲寅元的首日,所以孟统记事必先于殷历一日(即一位干支)。由此可知,前面考证殷历甲寅元历元时,曾列举《汉书·律历志·世经》的历点:“元帝初元二年十一月癸亥朔旦冬至,殷历以为甲子,以为纪首。”其所以《世经》记为“癸亥”,而殷历认为“甲子”,就是因为《世经》是出自刘歆之手,以孟统记事为主,而殷历实同仲统章蔀,所以记为“甲子”。其他《世经》中的历点,如“(汉高祖皇帝)八年十一月乙巳朔旦冬至,殷历以为丙午”,“(武帝)元朔六年十一月甲申朔旦冬至,殷历认为乙酉”,均同此例,可见刘歆用心良苦!\n前人不明其中道理,深受刘歆三统历的毒害和欺骗,于是导致了月相名称不得定解和月相四分法的产生,因此考证古历点(特别是古器铭文)便困难重重。为了彻底揭开刘歆三统历的神秘外衣,我们可以把所谓“三统”摊开列表(见拉页),加以对照,只要记住仲统即殷历甲寅元,了解孟统和殷历的关系,就照样可以按照《历术甲子篇》来查算其朔闰,推证历点。\n比如“虢季子白盘”盘铭“十二年正月初吉丁亥”,前人定为周宣王十二年正月初三,王国维说:“宣王十二年正月乙酉朔,丁亥乃月三日。”\n周宣王十二年为公元前816年。查《三统历与殷历章蔀对照表》,该年入孟统甲寅(50)蔀六十八年。\n同时又入殷历乙卯(51)蔀六十八年,查《历术甲子篇》六十八年:\n前大余三十一,前小余五百一十二。\n按孟统计算:\n50+31=81(21乙酉)\n说明该年周正月(即子月)乙酉朔,丁亥果为初三。王国维就是这样用孟统计算,把初吉定为月初三,从而形成了他的“月相四分法”。\n若按殷历计算:\n51+31=82(22丙戌)\n殷历创制行用于公元前427年。\n(816-427)×3.06=1190(分)\n1190-940=250(分)\n说明当时先天1日250分\n22.512+1.250=23.762(日加日,分加分)\n即该年应:前大余为23(丁亥),前小余762。即该年正月(子月)丁亥762分合朔,这是当时的天象实况。显然,“初吉丁亥”就是月初一。\n王氏轻信刘歆三统历,既不知孟统先于殷历一日,又不考虑年差分的修正,必然会得出初吉为月初三的结论,最后形成了“月相四分法”。追根溯源,刘歆三统历实为罪魁。\n刘歆要突出三统历的地位,必然要抹杀殷历甲寅元,掩盖历史真相,否则三统历是难以招摇撞骗欺世盗名的,联系《历术甲子篇》多处被篡改,不能不使人对他产生怀疑。后来刘歆虽以谋反罪被迫自杀,三统历也没有实际运用,但它的恶劣影响却长达近两千年,实为历术研究的不幸。\n四、后汉四分历 # 八十一分法粗于四分,使用一久,必与天象不合。《后汉书·律历志》记,汉明帝永平“十二年十一月丙子,诏书令(张)盛、(景)防代(杨)岑署弦望月食加时。四分之术,始颇施行。是时盛、防等未能分明历元,综校分度,故但用其弦望而已”。又说:“至元和二年,《太初》失天益远,日月宿度相觉浸多,而候者皆知冬至之日日在斗二十一度,未至牵牛五度,而以为牵牛中星,后天四分日之三,晦朔弦望差天一日,宿差五度。章帝知其谬错,以问史官,虽知不合,而不能易。故召治历编、李梵等综校其间,二月甲寅遂下诏。”于是四分施行,这就是后汉四分历。\n比较汉武帝改历后行用的八十一分法,后汉四分历的交气、合朔时刻提前了34日,从而利于校正八十一分法后天的现象。后汉四分历把战国以来四分历(殷历)沿用的冬至点在牵牛初度这个位置改正到斗宿2114度;它用黄道度数来计算日、月的运动和位置;它还根据实际观测定下了二十八宿距星间的赤道度数和黄道度数,二十四节气的太阳所在位置和昏旦中星,昼夜漏刻和八尺表的影长等重要数据。这在《后汉书·律历志》中有明确详细记载,这些内容在历法上都是首创。贾逵说:“元和二年八月,诏书曰‘石不可离’,令两候,上得算多者。太史令玄等候元和二年至永元元年,五岁中课日行及冬至斗二十一度四分一,合古历建星《考灵曜》日所起,其星间距度皆如石氏故事。他术以为冬至日在牵牛初者,自此遂黜也。”经过实测,确定冬至点在斗2114度,冬至点在牵牛初度的古制自此不再行用了。\n汉明帝时虽用四分术推定弦望月食,而“未能分明历元,综校分度”,到元和二年行用后汉四分历,才明确以文帝后元三年(公元前161年)十一月夜半朔旦冬至为历元。这正如汉顺帝时代太史令虞恭所言:“建历之本,必先立元,元正然后定日法,法定然后度周天以定分至。三者有程,则历可成也。四分历仲统之元,起于孝文皇帝后元三年,岁在庚辰。上四十五岁,岁在乙未,则汉兴元年也。又上二百七十五,岁在庚申,则孔子获麟。二百七十六万岁,寻之上行,复得庚申。岁岁相承,从下寻上,其执不误。此四分历元明文图谶所著也。”\n东汉纬书奉孔子为圣人,宣传孔子在哀公十四年庚申岁(公元前481年)获得一只麒麟。《春秋元命苞》《易乾凿度》等纬书认为,从获麟那时上推276万年,就是所谓天地开辟的年代。虞恭认为,这就是四分历之元。\n从文帝后元三年上溯到鲁哀公十四年(前481年)是320年(前481—前插拉伸页单插拉伸页双161),这320年加276万年,是2760320年,正好是四分历朔望月、回归年和六十干支周的共同周期1520年的整倍数(1816倍)。\n后汉四分历还认为,自文帝后元三年再上推两元(4560×2=9120年),即公元前9281(9120+161)的年前十一月朔夜半不但是甲子朔冬至,而且还是月食和五星运动的起点。《后汉书·律历志》载:“斗之二十一度,去极至远也,日在焉而冬至,群物於是乎生。故律首黄锺,历始冬至,月先建子,时平夜半。当汉高皇帝受命四十有五岁,阳在上章,阴在执徐,冬十有一月甲子朔旦冬至,日月闰积之数皆自此始,立元正朔,谓之汉历。又上两元,而月食五星之元,并发端焉。”\n改用四分历同历次改历一样,也遭到保守派的反对。安帝延光二年(公元123年)亶诵等人攻击后汉四分历,说什么“《四分》虽密于《太初》,复不正,皆不可用,甲寅元(纬书记载的四分历)与天相应,合图谶,可施行”。甚至说:“孝章改《四分》,灾异卒甚,未有善应。”亶诵等人的言论,受到张衡的有力反驳,他严肃地指出:“天之历教,不可任疑从虚,以非易是。”并讽嘲他们“不以成数相参,考真求实,而泛采妄说”。\n又过五十年,灵帝熹平四年(公元175年),保守派冯光、陈晃等人又出来攻击后汉四分历。他们说:“历元不正,故妖民叛寇益州,盗贼相续为害。”(《后汉书·律历志》)把社会动乱和自然灾异归咎于历元变更。蔡邕等人当即驳斥了这种观点,维护了四分历的顺利推行。\n在围绕后汉四分历的斗争中,产生了东汉末年刘洪的乾象历。乾象历取365145589日为一年,即365.2462日,由十九年七闰规律可推算出乾象历的朔望月数值为29.53054日,即297731457日。它还引进月行迟疾的历法,由此可更准确地推算日食和月食。由于东汉王朝的腐败,终及汉世,乾象历也未被采用。到三国时代的孙吴政权,才于公元223年颁行刘洪的乾象历,曹魏到景初元年(公元237年)颁行杨伟造的景初历。后汉四分历,由汉末延续到蜀汉政权的灭亡,才由泰始历(由景初历改名而来)取代。\n关于后汉四分历与殷历、三统历的关系可见张汝舟先生1959年所制《三统历与殷历章蔀对照表》(见前拉页)。\n五、古历辨惑 # 综上所述,历法中要明确下事。\n1.三套周历\n齐鲁用的四分术周历,实是子正之四分术。战国以降,四分术普行,齐鲁之周历,不过用子正而已,别无新异之处。\n六历中有周历。东汉纬书只谈天正甲寅元、人正乙卯元。其他黄帝历、夏历、周历、鲁历,均是东汉人的附会。六历之周历,不过是一个虚妄的名词而已。唐代《开元占经》记有古六历的上元到开元二年(公元714年,甲寅岁)的积年数字,这些数字也都在276万年以上。古六历之间的差别相对说来反而小得多。对于古六历的积年,都认为是东汉人在原来比较简单的上元积年数据上追加了一种带有神秘性的高位数字而成。《后汉书·律历志》载,蔡邕以为,“历法,黄帝、颛顼、夏、殷、周、鲁,凡六家,各自为元”。前面介绍的后汉四分历,虞恭将孔子获麟之上276万年,作为上古历元。六历积年与此不能说没有关系。\n《开元占经》列古历上元积年表是:\n黄帝历辛卯2,760,863\n颛顼历乙卯2,761,019\n夏历乙丑2,760,589\n殷历甲寅2,761,080\n周历丁巳2,761,137\n鲁历庚子2,761,334\n有人以为,纬书中这类大数字的上元积年的推求,大概在刘歆的“三统历”里就已开始。姑存其说。\n刘歆的“三统”以孟统为周历。这仍是四分术。这个三统的周历与甲寅元殷历的关系,见《三统历与殷历章蔀对照表》。刘歆的三统历并未施行过,后人据以推求西周铜器铭文,多有龃龉。\n这就是同名而实异的三套周历。\n2.两套颛顼历\n六历中的颛顼历,就是东汉盛传的“人正乙卯元”,又称己巳元。东汉刘洪言:“推汉己巳元,则《考灵曜》旃蒙之岁乙卯元也。与(冯)光、(陈)晃甲寅元相经纬。”又说:“乙卯之元人正己巳朔旦立春,三光聚天庙五度。”为什么叫“人正”?区别于殷历以年前十一月甲子朔旦夜半冬至的“天正”。甲寅元与乙卯元的关系,刘洪说:“课两元端,闰余差百五十二分之三,朔三百四,中节之余二十九。”前面已专章讲到甲寅元与乙卯元的关系。用乙卯元的数据,验证秦汉历点,均不相符。可见这个六历中的“颛顼历”从来没有施行过。刘洪说:“甲寅历于孔子时效;己巳颛顼秦所施行,汉兴草创,因而不易。至元封中,迂阔不审,更用太初,应期三百改宪之节。”(《后汉书·律历志》)这个说法是靠不住的。\n秦所施行的颛顼历,非己巳即人正乙卯元之颛顼。秦用颛顼,还是四分术,是殷历的一种变化形式,所不同者,以十月为岁首,闰在岁末,称“后九月”。四分法的殷历行用已久,秦不可变,连以寅为正的月序关系也已深入人心,不可改易。如果用殷历(四分法)验证典籍所记秦汉历点,更能证实秦所用颛顼历就是殷历。\n3.两套三统历\n刘歆的三统历,乃四分术,在《汉书·律历志》中有详细记载:“三代各据一统,明三统常合,而迭为首。……天施复于子,地化自丑毕于辰,人生自寅成于申。故历术三统,天以甲子,地以甲辰,人以甲申。孟仲季迭用事为统首。”刘歆的“三统”,就是孟统、仲统、季统。其章蔀配合各朝纪事,尽在《汉书·律历志》。\n八十一分法之三统,是三统年数加起来为一元,所谓“三统法,得元法”。《汉书·律历志》载:“统母,日法八十一。”孟康注:“分一日为八十一分,为三统之本母也。”《后汉书·律历志》载:“自太初元年始用三统历,施行百有余年,历稍后天,朔先于历,朔或在晦,月或朔见。考其行,日有退无进,月有进无退。建武八年中,太仆朱浮、太中大夫许淑等数上书,言历朔不正,宜当更改。时分度觉差甚微,上以天下初定,未遑考正。”这里的“三统历”,无疑是指八十一分法而言。\n4.两套太初历\n《史记·历术甲子篇》所记“太初”是年号,纪念改历之意,同时还是四分。张汝舟先生考证,武帝太初元年前十多年朔闰只合四分,十多年之后的朔闰只合八十一分。这也符合“昔太初历之兴也,发谋于元封,启定于元凤,积三十年,是非乃审”(《后汉书·律历志》)的记载。元封七年改历,至元凤年间才最后完成。验之朔闰,最初改“十月岁首”为正月岁首,行“无中气置闰”,第二步才行用八十一分法。《史记·历书》大谈太初改历,而无丝毫八十一分法痕迹,又详记了四分法一蔀七十六年朔闰,也反映了这一事实。这是四分术的太初历,即甲寅元殷历。\n《汉书·律历志》所记“太初历”,是明白无误的八十一分法。所谓邓平、落下闳之法,“一月之日二十九日八十一分日之四十三”。《后汉书》载贾逵言:“太初历不能下通于今,新历不能上得汉元。”新历指后汉四分历,“太初历”仍是指八十一分法之历。章帝元和二年之前,上至汉武帝时,数百年均行用八十一分法。东汉人心目中的“太初历”是没有歧义的。\n不通历法而编写《律历志》的班固,误信刘歆三统历“推法密要”;又以为“三代既没,五伯之末,史官丧纪,畴人子弟分散,或在夷狄,故其所记,有黄帝、颛顼、夏、殷、周及鲁历”;又以为汉初“用颛顼历,比于六历,疏阔中最为微近”;又记“乃诏(司马)迁用邓平所造八十一分律历,罢废尤疏远者十七家,复使校历律昏明”;并以太初历为八十一分法之专属,全然勾销了《史记·历术甲子篇》作为历法的功用。\n于是,刘歆所造而并未施行过的三统历身价百倍,迭经渲染,成为古代三大名历之首;于是,古有“六历”之说风行于世,东汉纬书连六历上元积年都推算出来了;于是,六历之颛顼,即从未施行的人正乙卯元与秦所用颛顼历混为一谈;于是,邓平八十一分法得太初历之专名,司马迁《史记·历书》所载竟成了古之遗物,无人过问,后代视《历术甲子篇》为一张历表,皆轻贱之。总之,班固在中国古代历法史上所造成的迷误是应该加以清理的时候了。\n六、岁星纪年 # 从观测天体运行的角度来说,发现木星十二年一周天,并用之纪年,无疑是个伟大的创造。但实践证明,岁星纪年并不理想,因为岁星并非恰好十二年一周天,而是11.8622年一周天,这样每过八十余年就要发生岁星超次(宫)的现象,这是不以人的意志为转移的客观规律。《左传·襄公二十八年》记“岁在星纪而淫于元(玄)枵”,就是古人发现岁星超次的真实可靠的记录。如果我们把这次记载看做首次,那么可以断定,岁星纪年至迟产生在鲁襄公二十八年(公元前545年)以前八十多年,即公元前7世纪,有人认为岁星纪年产生于公元前4世纪初,未免估计不足。\n岁星纪年毕竟属于观象授时的范畴,它要受到天象观测的制约,岁星超次的发现必定给岁星纪年造成混乱,使岁星纪年面临淘汰的危机。因为,既然岁星已经不能成为纪年的永久性标志,岁星纪年赖以存在的基础便随之动摇崩溃,其寿命必然是短暂的,绝非人力所能挽救。同时,岁星纪年与太岁纪年同时并用,时间一长也会引起混乱,所以诗人屈原咏叹上古的著名诗篇《天问》,就有“十二焉分”的疑问,可见岁星纪年和太岁纪年不能长期并存。再说古人对五星运行的观测虽然给天文学留下宝贵的资料,同时也被占星家用来占卜凶吉,充满了迷信色彩,木星的运行尤为占星家看重。上古的星占已如前述,就是到了明末清初,大学问家顾炎武写《日知录》时还说:“吴伐越,岁在越,故卒受其凶。苻(坚)(前)秦灭(前)燕(370年),岁在燕,故(后)燕之复建不过一纪,二者信矣。(南燕)慕容超之亡,岁在齐,而为刘裕所破,国遂以亡,岂非天道有时而不验耶?是以天时不如地利。……以近事考之,岁星当居不居,其地必有殃咎。”可见影响之深远!正因为岁星并非主要和专一用于纪年,所以前面所引《淮南子》《史记》《汉书》都将其归于天文类,并附记于太岁纪年之后,是很有道理的。它告诉我们,岁星纪年的寿命不会很长。\n科学与迷信、真实与虚假总是不相容的,古代星历家也不会长期使用早已破产了的岁星纪年法。现在有人不考虑岁星纪年本身的局限性,无限延长它的寿命,甚至说它一直行用到西汉太初(公元前104年),这显然是不妥当的。试想,就是从鲁襄公二十八年(公元前545年)算起,到西汉太初元年,已有441年。倘若一直行用岁星纪年法,以八十余年超一次计,到太初元年必超五次,加上鲁襄公二十八年超一次,共超六次之多。也就是说,应该“岁在星纪”,却已“淫于鹑首”了。如此纪年,还有什么准确性可言。不能想象,古人竟会如此之愚。有人认为“战国时代不存在岁星超辰的实际问题”,显然是以主观臆断代替客观规律。至于以汉太初元年为寅年来逆推岁星,那更是失之毫厘,差之千里了。\n因为岁星运行的方向与古人所熟悉的天体十二辰(以十二地支配二十八宿)划分的方向正好相反,在实际运用中很不方便,星历家便设想出一个假岁星叫太岁(《汉书·天文志》叫太岁,《史记·天官书》叫岁阴,《淮南子·天文训》叫太阴,名异而实同),让它与真岁星“背道而驰”,与十二辰(即二十八宿)的运行方向相一致,同时另取“摄提格、单阏、执徐、大荒落、敦、协洽、涒滩、作噩、阉茂、大渊献、困敦、赤奋若”(即地支别名,见《尔雅·释天》)等十二名,作为太岁纪年的名称,所以,《周礼》注云:“岁星为阳,右行于天,太岁为阴,左行于地。”(见39页图)\n左行、右行之说,使不少人觉得难解,其实正如《晋书·天文志》所描述的那样:“天旁转如推磨而左行,日月右行,随天左转。故日月实东行,而天牵之以西没。譬之于蚁行磨石之上,磨左旋而蚁右去,磨疾而蚁迟,故不得不随磨以左回焉。”五星的运行与之同理。\n由此可知,岁星纪年与太岁纪年从一开始就既有联系,又有区别,在古人心目中也是十分清楚的。比如:\n《淮南子·天文训》曰:“太阴在寅,岁名曰摄提格,其雄为岁星,舍斗、牵牛。”\n《史记·天官书》曰:“摄提格岁,岁阴左行在寅,岁星右转居丑,正月与斗、牵牛晨出东方,名曰监德。”\n《汉书·天文志》曰:“太岁在寅曰摄提格,岁星正月晨出东方,石氏曰名监德,在斗、牵牛。”\n这些上古天象观测材料,行文上辨析分明,太岁(太阴,岁阴)归太岁,岁星归岁星,而且是太岁在前,岁星附记,二者不容混淆。我们不要以为这些资料出自汉代典籍,便认为这是汉代的星象和纪年法。古人认为“天不变道亦不变”,所以总是把古老的传闻世代相袭记载下来,文中的“石氏”就是战国时代魏国的大星历家石申(著有《石氏星经》),由此可知,这些资料至少产生于战国以前。\n与岁星不同,太岁只是一个假想的天体,正因为其“假”,它不会像真岁星一样要以天象观测为依据,不受什么运行规律的制约,因此也不会像岁星一样存在“超辰”问题,更不会为顺应真岁星超次而超辰,它不过以抽象的代号纪年罢了。当岁星纪年因超次逐渐被淘汰之后,太岁纪年必然会脱离岁星纪年而独立存在,成为不受外来影响的理想的纪年法。“摄提格”等十二名与十二地支相应,实际上就是地支的别名,所以太岁纪年十二年一循环,本质上就是地支纪年,也就是向干支纪年的过渡形式。到了“阏逢、旃蒙、柔兆、强圉、著雍、屠维、上章、重光、玄黓、昭阳”(实为天干)十岁阳之名(见《尔雅·释天》),与“摄提格”等十二岁阴之名相配合,便成了完整的干支纪年,保留在《史记·历书》中的《历术甲子篇》便是以岁阳、岁阴来纪年的(与《尔雅》所记名称略有差异),如“岁名焉逢摄提格”,就是甲寅年。因此,可以这样说《历术甲子篇》创制行用之时,就是干支纪年开始之日。\n星历家其所以用岁阳、岁阴纪年,是为了与干支纪日相区别,正如顾炎武《日知录》卷二十曰:“《尔雅》疏曰:甲至癸为十日,日为阳;寅至丑为十二辰,辰为阴,此二十二名,古人用以纪日,不以纪岁,岁自有阏逢至昭阳十名为岁阳,摄提格至赤奋若十二名为岁名。后人谓甲子岁、癸亥岁非古也。自汉以前,初不假借。《史记·历书》‘太初元年,岁名焉逢摄提格,月名毕聚,日得甲子,夜半朔旦冬至’,其辨析如此。若《吕氏春秋·序意篇》‘维秦八年,岁在涒滩,秋甲子朔’;贾谊《鵩鸟赋》‘单阏之岁兮,四月孟夏,庚子日斜兮鵩集于舍’;许氏《说文后叙》‘粤在永元困顿之年孟陬之月朔日甲子’,亦皆用岁阳岁名不与日同之证。《汉书·郊祀歌》‘天马徕,执徐时’,谓武帝太初四年,岁在庚辰,兵诛大宛也。”但是,岁阳岁名与干支在本质上是一样的,不过名目不同而已。同时,岁阳岁名纪年本身就反映了太岁纪年向干支纪年过渡的历史痕迹,汉以前的所谓太岁纪年无一不与干支纪年相吻合,就是这个道理。后世文人好古,纪年常用岁阳岁阴,司马光《资治通鉴》卷一百七十六《陈纪十》曰“起阏逢执徐,尽著雍涒滩,凡五年”,即从甲辰到戊申共五年;清人许梿《六朝文絜原序》云:“道光五年,岁在旃蒙作噩壮月,海昌许梿书于古韵阁。”“旃蒙作噩壮月”就是乙酉年八月。\n以上是我们对于岁星纪年和太岁纪年的关系,以及岁星纪年被淘汰、太岁纪年向岁阳岁阴纪年(即干支纪年)过渡的认识,还可以用历法运算进一步证实。\n到了清代,钱大昕在《潜研堂文集·太阴太岁辨》中提出:“太阴自太阴,太岁自太岁”,“太阴纪岁、太岁超辰之法,东汉已废而不用”。他认为:(1)太阴、太岁不是一回事;(2)太岁有超辰之法;(3)由此引申出干支纪年起于东汉的说法。钱大昕的观点对后世产生了很大影响,不能不进行一番辨析。\n首先起来驳难钱大昕的是他的学生孙星衍。孙星衍《问字堂卷五·再答钱少詹书》云:“今按《史记》十二诸侯年表自共和迄孔子,太岁未闻超辰,表自庚申纪岁,终于甲子,自属史迁本文,亦不得谓古人不以甲子纪岁。《货殖传》云,‘太阴在卯,穰,明岁衰恶,至午旱,明岁美。’此亦甲子纪岁之明征,不独《后汉书》‘今年岁在辰,来年岁在巳’之文矣。”\n更为有力的驳论出自王引之。他为了全面论述问题,专写《太岁考》一文(见《经义述闻·卷三十》)。他说:“潜研堂文集乃谓太阴、岁阴非太岁,假如太阴与太岁不同,则古人纪岁宜于太岁之外别言太阴,何以《尔雅》言太岁而不及太阴,《淮南》言太阴而不及太岁乎?斯足明太阴之即太岁矣。钱说失之。”又说:“古人言太岁常与岁星相应,故《史记·天官书》有岁阴在卯,岁星居丑之说,而不知岁星之久而超辰。《左传》襄二十八年有曰:岁在星纪而淫于元枵,又曰岁弃其次而旅于明年之次。夫岁星当在星纪而进及元枵,此超辰之渐而谓之曰淫曰旅,则不知有超辰,而以为岁星之赢缩也。……刘歆三统数岁星百四十四年超一次,是岁星超辰之说自刘歆始也。岁星超辰而太岁不与俱超,则不能相应,故又有太岁超辰之说。……干支相承有一定之序,若太岁超辰则百四十四年而越一干支,甲寅之后遂为丙辰,大乱纪年之序者,无此矣。且岁星百四十四年超一辰,则七十二年已超半辰,太岁又将何以应之乎?古人但知岁星岁行一辰而不知其久而超辰,故谓太岁与星岁相应,后人知岁星超辰,则当星自为星,岁自为岁,方得推步之实而合纪年之序,乃必强太岁超辰以应岁星,不亦谬戾而难行乎!故论岁星之行度,则久而超辰,不与太岁相应,古法相应之说断不可泥。论古人之法则,当时且不知岁星之超辰,又安得有太岁超辰之说乎?”他还说:“晓徵(即钱大昕)先生不信高帝元年乙未、太初元年丁丑之说,而以为后人强名之,武帝诏书之乙卯、天马徕之执徐,岂亦后人强名之乎?斯不然矣。”\n王氏此论言之有理,雄辩有力,他否定了“太阴自太阴,太岁自太岁”,否定了太岁超辰法,也否定了干支纪年起于东汉的说法,是完全可信的。遗憾的是,王引之的宏论没有引起后人的重视,钱大昕的观点却发生了很大影响。自郭沫若用太岁超辰法考证屈原生年(见《屈原研究》)以后,特别是浦江清用太岁超辰法具体推算屈原出生年月日(见《屈原生年月日的推算问题》)以后,近年形成风气,效法者有近十家之多,于是屈原生年就有公元前339年、前340年、前341年、前342年、前353年等多种说法。他们所用方法略同,所得结论大异,实际上就宣告了太岁超辰法的破产。因为,既然他们使用太岁超辰法,就必须遵循86年超1辰的法则,而所谓“超辰”又是随着时间的推移逐年递加造成的,推算的起点不同,该“超辰”的年份就不同。所以,无论逆推也好,顺推也好,怎样巧手安排,都无法得出可信的结论,最后只能自相矛盾,互相否定。\n查验五星运行规律作为天象观测的重要内容,后世在长期进行着,但木星作为纪年标准,从发现它超次之日起,便逐步被淘汰,完成了它的历史使命。太岁纪年则向干支纪年过渡,最后进入历法时代。岁星纪年法作为一种历史陈迹,后世仿古者有之,招魂者有之,乱用者亦有之,但大多没有什么实际意义,因为那早已不是岁星纪年的本来面目。\n“岁星纪年”的破产,逼使星历家又回头来重新研究日月运行周期与回归年的配合。此后百把年,才有四分历的创制与使用。\n岁星纪年创立的十二宫(次)的名目本是用来纪年的,恰又与地支十二的数目吻合。昙花一现的岁星虽已过时,而纪年的名目却保留下来并为四分历法创制者所利用,以代替十二辰、十二支,只不过用来纪月而不再纪年了。《汉书·律历志》中“次度”就是这样记载的:“星纪:初,斗十二度,大雪;中,牵牛初,冬至……”这里的“星纪”“玄枵”之称显然指的是纪月了。\n由于有“岁星纪年”这么一段插曲,尔后,纪年的名目又与十二支配合用于纪月,又加干支纪年的行用,史籍上“太岁在寅”“岁在星纪”之类的记载,便叫人迷混不清了。\n我们不妨理出这样一个头绪:\n纪年岁星纪年:星纪、玄枵、娵訾、降娄……\n干支纪年:子、丑、寅、卯……\n纪月十二支:子、丑、寅、卯……\n十二宫次:星纪、玄枵、娵訾、降娄……\n不难看出,十二地支实在是造成迷乱的症结,而好古的文人又从中施放一些烟幕,确令后人糊涂了。\n由于岁星纪年仅在少数几个姬姓国行用,有特定的环境,而且行用时间是短暂的,因此,万不可将它从春秋推及后世,只要把“太岁在寅”“岁在星纪”理解为寅年、子年就够了,况且干支纪年行用以后,“太岁”与木星再也不能与之相提并论了。\n一般历史学家迷于史籍中“太岁在卯”“太岁在寅”等记载,总是认为这是指“岁星在×宫”,造成这种错觉的根本原因就在于对“岁星纪年”行用的历史缺乏正确的估价。当然,史学家迷信“岁星纪年”还有另外的原因,那就是对于干支纪年究竟起于何时的问题缺乏一致的认识。\n鉴于一些史学家惯用“岁星纪年”推算史籍的历点,看来有必要对“岁星纪年”的推算作一番探讨。\n采用“岁星纪年”推考历点,往往是私意确定推算起点,只图自圆其说,不求上下贯通。或因《史记·历书》有“太初元年,岁名焉逢摄提格”的记载,就立太初元年(公元前104年)为“岁在星纪”,定“星纪为寅(摄提格)”,于是以汉太初元年为推算起点。或以《吕氏春秋》所记“维秦八年,岁在涒滩”为依据,定始皇八年为申年,再用岁星纪年周期来上下推算各个历点。起点不可靠,结论自然不会正确。\n须知,“岁星纪年”在《春秋左氏传》上早有记载,岁星纪年的起点应该在那上面去找。《左传·襄公二十八年》:“岁在星纪而淫于玄枵。”岁星在这年跳辰,则襄公二十七年(公元前546年)岁在星纪无疑。又,《左传·昭公三十二年》(公元前510年)载“越得岁”,杜注“是年岁在星纪”。用木星周期核对这两条记载,两相吻合,这难道不是岁星纪年的可靠起点吗?襄公十八年(公元前555年)“岁在娵訾”,则襄十六(前557)“岁在星纪”无疑。\n我们据此列出“岁在星纪”与跳辰之年如下表:\n公元前545年、前462年……前130年为跳辰之年。有了这个“岁星纪年表”,就可以用它检验一切用木星周期推算史载历点的结论是否正确,尽管我们不相信“岁星纪年”有什么生命力。\n七、关于“月相四分”的讨论 # 在上古,月亮关系到人们的生产生活,引起人们丰富的想象,“嫦娥奔月”之类的神话故事在古典文学中是很多的。从天文历法角度来说,古人对于月球的观测主要用于月相纪日,设置闰月和确定月朔(岁首)。\n如前所述,月球是地球的卫星,月球围绕地球运行的轨道(白道)与黄道有5度的倾角,太阳、地球、月球三者的位置常动而不息,所以月相总是呈周期性的变化,古人对于不同的月相定下不同的名称,用以纪日,这在殷周钟鼎铭器和上古文献中留下不少记载。\n如《周书·武成》曰:\n惟一月壬辰旁死霸,若翌日癸巳,武王乃朝步自周,于征伐纣。\n粤若来三月既死霸,粤五日甲子,咸刘商王纣。\n惟四月既旁生霸,粤六日庚戌,武王燎于周庙。翌日辛亥,祀于天位。粤五日乙卯,乃以庶国祀馘于周庙。\n《尚书·顾命》曰:\n惟四月哉生魄,王不怿。甲子王洮颒水,相被冕服,凭玉几。\n《大敦》曰:\n隹王十又二年二月既生霸丁亥。\n对于上面“既死霸、旁死霸、哉生魄、既生霸、旁生霸”以及“初吉、既望”这些名称的含意和指代,古来无定说,所以字典辞书至今无确解。这些名称是古历点的重要组成部分,月相名称无确解,古历点必无定论,考证上古史料便失去可靠的依据。因此,我国信史的起点——周武王克纣之年,至今竟有几十种说法,问题就在这里。\n关于月相名称的解释,除了西汉刘歆之外,近代以俞樾、王国维两家为代表。\n俞樾《春在堂全书》有《生霸死霸考》一文。他认为:“惟以古义言之,则霸者月之光也。朔为死霸之极,望为生霸之极,以三统术言之,即霸者月之无光处也,朔为死霸之始,望为生霸之始,其于古义翩其反矣。”并释月相名称于后:“一日既死霸;二日旁死霸;三日载生霸,亦谓之朏;十五日既生霸;十六日旁生霸;十七日既旁生霸。”他还指出:“夫明生为生霸,则明尽为死霸,是故晦日者死霸也。晦日为死霸,故朔日为既死霸,二日为旁死霸。”\n俞樾主张的是“月相定点说”,以月相名称指代固定的月相,用以纪日,这是符合古历点记事实际的。这种见解难能可贵,为考释迷乱千古的月相名称奠定了基础,可惜诠释未精,尚有漏洞:\n1.月面明暗相依成相,若“霸”只释为“月之光”,“死霸”“生霸”将作何解?\n2.若释月“明生为生霸”,与“载生霸”有什么区别?其后月相名称怎样辨别?\n3.若以“死霸”为晦日,则朔日当为“旁死霸”,为什么又称“既死霸”?如以“既死霸”为朔日,“既生霸”为望日,望日之前一日当为“生霸”,才能与晦日为“死霸”相应,然而,古历点从无此例。\n虽然如此,瑕不掩瑜,俞樾首创系统的“月相定点说”,功不可没。\n后于俞氏的王国维先生在《观堂集林》中也有《生霸死霸考》一文。他分一月之日为四分:初吉一日至七八日;既生霸八九日至十四日;既望十五六至二十三日;既死霸二十三日至晦日。他说:“八九日以降,月虽未满,而未盛之明生已久;二十三日以降,月虽未晦,然始生之明固已死矣。盖月受日光之处,虽同此一面,然自地观之,则二十三日以后月无光之处,正八日以前月有光之处,此即上弦下弦之由分,以始生之明既死,故谓之既死霸。此生霸死霸之确解,亦即古代一月四分之术也。”又说:“凡初吉、既生霸、既望、既死霸,各七日或八日,哉生魄、旁生霸、旁死霸各有五日若六日,而第一日亦得专其名。”\n王国维此说可伸可缩,面面俱到,好似言之成理,万无一失,其实自相矛盾。“未盛之明”自朏日(初三)已渐生,何不称朏日至望日为“既生霸”?“始生之明”自望日后即渐死,何不称既望至晦日为“既死霸”?这样,“月相四分”就变成“月相二分”。古人记月相是为了纪日,古历点中的月相名称总是与纪日干支相连,这是月相定“点(一日)”而不是定“段(数日)”的铁证。否则,纪日干支已经包含在“月相四分”之中,古人又何必另外注明、不惮其烦?再说,月面圆缺不断变化,一个月相名称代七八天不同的月相,有什么实用价值?俞樾《生霸死霸考》说:“使书之载籍而无定名,必使人推求历法而知之,不亦迂远之甚乎?且如成王之崩,何等大事,而其书于史也,止曰:‘惟四月载[哉]生霸[魄]王不怿。’使载生霸无一定之日,则并其下甲子、乙丑莫知为何日矣,古人之文必不若是疏。”这一推论是完全正确的,好似预先就对王国维进行了驳难。虽然王国维补充说“第一日亦得专其名”,但“月相四分”与“专其名”又如何分辨呢?最终只能是主观安排。\n然而,由于王国维在学术界的地位和影响,“月相四分说”广为流传,不少学者引以为据,甚至天文学界都深受其影响。有人以此断定我国远在周代就有现今行用的星期制,有人确实相信“月相四分”。更有甚者如章鸿钊合中(王国维)日(新城新藏)之说主张以“朏”为月始,违背了中国古代礼仪习俗和历法惯例,只能是主观臆断的产物。\n我们认为,古文典籍关于月相名称的记载可以给我们几点启示:\n1.“霸(魄)”字从不单独使用,说明它不是月相名称,不能表示确定的月相;\n2.霸(魄)前加“生、死”二字,构成“生霸(魄)”“死霸(魄)”,它们有各自独立、相互对立的含义,与月相的关系极为密切,但并非月相专名,也不单独表示确定的月相;\n3.“既生霸、旁生霸、哉生霸”等名才是月相名称,是具有特定含义的独立的词。其中的“既、旁、哉”等字有修饰限制作用,是这种月相区别于他种月相的标志和特征,因此,它们是月相名称中不可缺少的组成部分;\n4.月相名称总是与纪日干支紧密配合使用,说明每一个月相名称,只能确指一种固定的月相,用以纪日。\n由此可见,王国维的说法是不可信的,俞越的论点在原则上是正确的。下面我们从考释“霸(魄)”字本义入手,详释月相名称于后。\n霸:许慎《说文》曰:“霸,月始生魄然也。承大月二日,承小月三日。从月,声。《周书》曰哉生霸。”段玉裁注云:“霸魄迭韵,《乡饮酒义》曰,月者三日则成魄。正义云,前月大则月二日生魄,前月小则三日始生魄。马注《康诰》云,魄,朏也。谓月三日始生兆朏,名曰魄。《白虎通》曰,月三日成魄,八日成光,按已上皆谓月初生明为霸。而《律历志》曰,死霸,朔也,生霸,望也。孟康曰,月二日以往明生魄死,故言死魄。魄,月质也。三统说是,则前说非矣。普伯切。《汉志》所引《武成》《顾命》皆作霸,后代魄行而霸废矣,俗用为王霸字,实伯之假借字也。”足见上述解释相互矛盾,实无定见,后世字典辞书依然如此。\n今按“霸”:月貌,霸从月声,为形声字,《说文》曰:“,雨濡革也。从雨革。”为会意字。段注“,雨濡革则虚起,今俗语若扑。”可见“”为“霸”字声符,又兼表义。因为雨下皮革,浸湿处变形虚起,未浸处依然如故,正应日照月球,受光面逐渐变白,背光面暗然转黑之形貌,如同“娶”字之“取”兼表声、义一样。月面明暗相依,变化呈形,“霸”字只是泛称,并不确指某一固定月相。霸、魄迭韵,故相通。若释“霸”为“有光处”或“无光处”,将月面明暗断然分开,各执一端,必然不得其解。《文选·谢庄月赋》“朏魄示冲”一句,李善注:“朏,月未成光;魄,月始生魄然也。”这是因袭旧解,并未注通文意,应释为“朏日(初三)月光初现之形貌”才妥当。\n死魄、生魄:死魄,月面背光处之貌;生魄,月面受光处之貌。《说文》:“死,澌也。”段注:“水部曰,澌,水索也。方言,澌,索也,尽也。”《白虎通》:“死之言澌,精气穷也。”月面背光处之貌,黯然无色,隐入夜空,如精气穷尽,故为“死魄”。\n《说文》又云:“生,进也。”《韵会》:“死之对也。”月面受光处之貌,光生辉现,与“死魄”相对,故为“生魄”。死魄、生魄相互依存,相辅相成,然而,月貌随时变化,天天不同,死魄、生魄并不能用来单独确指某一固定月相,自然不能纪日。\n朔日、既死魄、初吉:月初一。《说文》:“朔,月一日始苏也。”段注:“朔苏迭韵。日部曰晦者,月尽也。尽而苏也。《乐记》注曰,更息曰苏。息,止也,生也;止而生矣。引申为凡始之称,北方曰朔方,亦始之义也。”晦为月尽,朔为月初,贯穿于中国古代天文历法和史书记事的始终。真正合朔的时间很短,先之一瞬则月面之东尚余一丝残光,后之一瞬而月面之西又有一线新辉,然而人们自地目视,朔日太阳、月亮同升,月面隐而不现,即月面全部背光,故称之为“既死魄”。“既”表月相有二义:尽也,已也。段注:“引伸之义为尽也,已也,如《春秋》日有食之既。《周本纪》东西周皆入于秦,周既不祀。”月相名称中“既死魄、既生魄”之“既”当释为“尽”,“既望、既旁生魄”之“既”当释为“已”。“既死魄”即月面尽(全部)为背光之貌,故为朔日、月初一。\n“初吉”,不由月相得名,但有表月相纪日之实。因为朔日为一月之始,古代帝王重“告朔”之礼,以朔日为吉日,望日亦为吉日。故“初吉”实指朔日,即月初一,这就是铭器常以“初吉”记事的缘故。\n初吉指朔,古今无异辞。《诗·小雅·小明》“二月初吉”,毛传:“初吉,朔日也。”《周语上》“自今至于初吉”,韦注:“初吉,二月朔日也。”亦省作“吉”。《论语》:“吉月必朝服而朝。”孔安国注:“吉月,月朔也。”按:吉月犹《小雅·十月之交》言“朔月”,是“吉”即“朔”。《周礼·天官》“正月之吉”,郑注:“吉谓朔日。”\n旁死霸:月初二。“旁死魄”实为“旁既死魄”之省文。《释名》“左边曰旁”,《玉篇》“旁,犹侧也”,此处“旁”为依傍于(既死魄)边侧之义,故“旁死魄”为月初二。\n哉生魄、朏:月初三。《尔雅·释诂》:“哉,始也。”古文哉、才相通。“生魄”为月面受光处之貌。“哉”用以修饰描述,“哉生魄”就是月面开始(才)受光之貌,承小月者本月大,初三可见新月;承大月者本月小,初二偶尔可见一线生魄,但此种情况少有。已有“旁死魄”之名,故“哉生魄”实指月初三。《说文》云:“朏,月未盛之明也,从月出”,为会意字,与“哉生魄”同义,亦为初三。\n望、既生魄:月十五。《说文》:“望,月满也,与日相望。”段注:“日兆月而月乃有光。人自地视之,惟于望得见其光盈。”月满为望,多为月十五,这时月面全部(尽)为受光之貌,故称之“既生魄”。\n既望、旁生魄:月十六,望日之后一日为“既望”。此处“既”应释为“已”,古今无异辞。“旁生魄”为“旁既生魄”之省文,为“既生魄”之后一日,与“旁死魄”同理。\n既旁生魄:月十七,此“既”为“已”,“既旁生魄”为“旁生魄”之后一日。\n由此可见,古人所用的月相名称,只集中表示朔日后三天(初一到初三)和望日后三天(十五到十七),这显然与古人的吉祥记事有关,同时也反映了古人对朔、望之后月貌显著变化的准确认识。其余日期的月相虽然也在变化,但难以精确地命名表述,故用干支纪日配合使用,此亦“月相定点”之一证。另有“月半、上弦、下弦”之名,如《仪礼·士丧礼》“月半不殷奠”,《释名·释天》“弦,月半之名也”,这是后来的补充。\n月相名称是古历点的重要组成部分,因此考释其含义不仅是个训诂问题,而且要受到历法运算的检验,这将留待下面讨论。\n现将月亮出没规律列于后:\n朔月:日出月出,日没月没;\n上弦:中午月出,子夜月没;\n望月:日没月出,日出月没;\n下弦:子夜月出,中午月没。\n归纳一下。从月相定点说,张汝舟先生以为,古人重朔、望,月相就指以朔或望为中心的两三天。\n初一:朔,初吉,吉,既死魄;十五:望,既生魄;\n初二:旁死魄;十六:既望,旁生魄;\n初三:哉生魄,朏;十七:哉死魄,既旁生魄。\n从王国维月相四分说,则\n从月牙初露到月半圆,称初吉。首日朏(初三)。\n从月半圆到满圆,称既生霸。首日哉生霸(初八)。\n从月满圆到月半圆,称既望。首日望(十六)。\n从月半圆到消失,称既死霸。首日哉死霸(二十三)。\n“月相四分”说影响很大,传到日本,研究东洋历法的专家新城新藏氏据此附会,说中国古时每月以初三为月首,至下月初二为一月。国内信其说者,至今犹不乏其人,在文物考古界颇有市场。甚至更有人把“月相四分”与西方七日一星期联系起来,其穿凿程度令人发笑。\n古人凭月相定时日,其重要性可想而知。月相不定点,月相的概念也就毫无价值。如果我们用四分术,每年加上3.06分的误差,以实际天象来验证古器上的历点,“月相四分”说就不攻自破了。\n例一,“师虎簋”记:隹元年六月既望甲戌。\n王国维解释说:“宣王元年六月丁巳朔,十八日得甲戌。是十八日可谓之既望也。”\n王氏定此器为周宣王时铭器,他用刘歆三统历之孟统推算,得不出实际天象,甲戌算到十八去了,不得不用“月相四分”来曲解,硬说十八也可叫既望。\n我们用前面的推算方法验证这个历点:\n公元前827年(宣王元年)入四分历乙卯蔀57年。\n太初五十七年:前大余三十五小余三百二十八\n乙卯蔀蔀余5151+35=86(26庚寅)\n实际天象应是(827-427)×3.06=1224分=1284940日\n26.328+1.284=27.612(日加日,分加分)\n得知,宣王元年子月辛卯(27)日612分合朔\n子月辛卯612分丑月辛酉171分\n寅月庚寅670分卯月庚申229分\n辰月己已728分巳月己未287分\n是年子正,六月己未朔,既望十六,正是甲戌。\n出土文物多是西周时代的史料,这些历点,远在四分历创制之前五六百年。用四分术推算势必相差两日,加之孟统比殷历甲寅元又提早一日,所以,王氏的初吉不在初一,总是在初三或初四。这就“悟”出“月相四分”,加以曲解。\n注:郭沫若定师虎簋为共王器。共王元年为公元前951年。\n例二,“虢季子白盘”记:十二年正月初吉丁亥。\n孙诒让说:“此盘平定张石州孝廉以四分周术推,为周宣王十二年正月三日,副贡(刘寿曾)之弟以三统术推之,亦与张推四分术合。”\n用上面的推算方法,周宣王十二年(公元前816年)当入仲统之甲午蔀第49年,查表:大余五十一,小余七百四十七\n甲午蔀蔀余3030+51=81(21乙酉)\n得知周正子月大乙酉,丁亥初三。\n所谓四分周术,即是三统历之仲统。此张石州氏推算之结果。\n又,是年入孟统甲寅蔀第68年,大余三十一\n甲寅蔀蔀余5050+31=81(21乙酉)\n正月朔乙酉。此乃刘贵曾氏(副贡之弟)所推之结果。\n初吉果月初三乎?实际天象并非如此。\n用四分历近距推算,是年(前816年)入乙卯蔀(蔀余51)第68年。\n太初六十八年:大余三十一小余五百一十二\n51+31=82(22丙戌)\n先天(816-427)×3.06=1190分=1250940日\n22.512+1.250=23.762\n实际天象是正月丁亥762分合朔。\n结论很清楚:丁亥是朔日,是初一,不是初三。朔即初吉。\n金文中备记“王年、月、日、月相”者甚多,其中载有“初吉”字样的也不少,以实际天象考之,无一不是朔日,足证“月相四分”之不可信。\n"},{"id":156,"href":"/zh/docs/culture/%E5%8F%A4%E4%BB%A3%E5%A4%A9%E6%96%87%E5%8E%86%E6%B3%95%E8%AE%B2%E5%BA%A7/%E7%AC%AC4%E8%AE%B2-%E7%AC%AC5%E8%AE%B2/","title":"第4讲-第5讲","section":"古代天文历法讲座","content":" 第四讲二十四节气 # 古代劳动人民在认识自然、改造自然的过程中,创造了先进的耕作制度,形成了精耕细作的优良传统,推动了农业生产不断发展。在漫长的岁月中,对与农业生产紧密相关的农业气象条件,进行过精细的观察、深入的研究,逐步形成了二十四节气,概括了黄河中下游地区农业气候特征。它利用简要的两个字,把这一地区的日地关系、气候特点以及相应的农事活动恰当地表达出来。可以说,二十四节气是古代天文、气候和农业生产实践最成功的结合,从古到今都起着一种简明而又切合农业生产需要的农事历的作用。\n二十四节气一旦形成,劳动人民就因时、因地加以发展,它的应用就不仅仅局限于黄河中下游地区了,而是逐步推广到全国各地,几乎渗透到我们这个农业大国的各个领域,甚至涉及人们的衣食住行。所以,对依据古代天文而形成的这样一部农事历——二十四节气进行一番研究,就是很有必要的了。\n一、先民定时令 # 有了年、月、日的时间概念,并不等于就能得心应手地安排好时令。汉枚乘诗:“野人无历日,鸟啼知四时。”讲的是当时的“野人”,亦可想见先民的时令观念。《后汉书·乌桓鲜卑传》云“见鸟兽孳乳,以别四节”,道理亦同。《魏书》卷一百一讲到宕昌羌族“俗无文字,但候草木荣枯,记其岁时”。宋代洪皓《松漠纪闻》亦云:“女真……其民皆不知记年,问之则曰我见草青几度矣。盖以草青为一岁也。”据此推知,先民的时令,最早主要是靠物象——动植物的表象来确定的。\n《山海经》记载了先民观察太阳升落位置以定季节的材料。《大荒东经》上记有六座日出之山:\n东海之外,大荒之中,有山名曰大言,日月所出。\n大荒之中,有山名曰合虚,日月所出。\n大荒之中,有山名曰明星,日月所出。\n大荒之中,有山名曰鞠陵,于天东极离瞀,日月所出。\n大荒之中,有山名曰猗天苏门,日月所出。\n大荒之中,有山名曰壑明俊疾,日月所出。\n《大荒西经》上记有六座日入之山:\n西海之外,大荒之中,有方山者,上有青树,名曰柜格之松,日月所出入也。\n大荒之中,有山名曰丰沮玉门,日月所入。\n大荒之中,有龙山,日月所入。\n大荒之中,有山名曰日月山,天枢也。吴姖天门,日月所入。\n大荒之中,有山名曰鏖鏊钜,日月所入者。\n大荒之中,有山名曰常阳之山,日月所入。\n大荒之中,有山名曰大荒之山,日月所入。\n这是在不同季节、不同月份,观察到的太阳出山入山的不同位置。这种观察方法同观察鸟啼、鸟兽孳乳、草木荣枯的方法一样,是凭着经验,凭着目睹耳闻的感受,其粗疏是自不待言的。因为观察者的地域毕竟狭小,局限性很大,以此定季节势必误差很大。\n观察太阳运行的另一种方法是观察日影长度的变化。最早当是利用自然的影长,进一步发展就是人为的立竿测影。\n太阳视运动的轨迹无法在天空中标示,反映到地面上就是事物的投影。高山、土阜、树木、房舍,晴日白昼都会留下或长或短的影子。《吕氏春秋》“审堂下之阴,而知日月之行,阴阳之变”,就是这个意思。根据这些影子的长短可以判明时间的早晚,有经验的老人往往判断得十分精确,这无疑是依靠长期的经验积累。\n如果要有意测影以确定时令,这得人为地在平地上立一根规定长度的竿子,把它的影子在地面上标示出来。这根竿子就是“表”,《周髀算经》中称之为“髀”。“表”的影子,古字写作“景”。这就是土圭测景。\n从出土的甲骨文中考察殷商文化,可以明白地看到,殷商时代测定方向、时刻都已比较准确。卜辞中将一天的时刻分为:明(旦)、大采、大食、中日、昃、小食、小采、暮等时间段落。甲骨文中的“昃”字,就是人侧影的象形。作为时段,日侧之时为昃。发掘出的殷代宫殿基址是南北方向的,其方向所指与今天的指南针方向无异。这种方向的确定及中日、昃等时刻的测定,显然和观测日影紧密相关。\n这都说明,殷商时代已有了早期的圭表。实践证明,通过长期测日影的实践就会认识到冬至、夏至、春分、秋分。甲骨卜辞中,有一些文字很可能就是至日的记录。\n有了圭表,就能够比较准确地确定分、至,就可以对闰月的设置(闰在岁末)加以规律化的安排。所以,推知殷商之历应该比较规整,岁首应该比较固定,误差不会大于一个月。有人统计了记有月名的“今何月雨”“田”,其他农事季节及其他天文气象卜辞,证明了殷代月名和季节基本上已有了固定关系。《尧典》“期三百有六旬有六日,以闰月定四时成岁”的记载,大体符合这个时代的情况。\n二、土圭测景 # 日影的长短与寒暑变化有关,这是先民积累的生活常识。要准确地测量寒来暑往的季节变化,很自然地就产生了立竿测影的方法。这是用最简易的天文仪器来研究历法、确定时令,是天文学发展的一次飞跃。\n立竿测影又称土圭测景、圭表测景。表是直立的竿子,圭是平放在地上的玉版。《说文》云:“圭,瑞玉也。上圆下方。”日影长短就从平放的圭上显示出来。土,度也,测量的意思。土圭,就是度圭,测量圭上日影的长短以定时令。远在周代,“表”就规定为八尺,已有了长度标准。《周礼·考工记》云:“土圭尺有五寸,以致日,以土地。”致是推算义,土是量度义。土圭长一尺五,来推算节气日期,量度土地远近。《周礼·夏官司马》云:“土方氏,掌土圭之法以致日景。以土地相宅而建邦国都鄙。”注曰:土方氏,主四方邦国之土地。可见,周代已有人家来掌管土圭测景了。\n《周礼·地官大司徒》云:“日至之影,尺有五寸。”这是说,夏至时,圭上影子有一尺五寸长。这样看来,圭长一尺五寸就远远不够了。《周礼·春官冯相氏》郑玄注云:“冬至,日在牵牛,景丈三尺;夏至,日在东井,景尺五寸。此长短之极,极则气至。冬无潜阳,夏无伏阴。春分,日在娄;秋分,日在角;而月弦于牵牛东井,亦以其景知气至不。春秋冬夏气皆至,则是四时之叙正矣。”圭有多长?当在一丈三尺以上。\n根据《史记》记载,圭表测景当更早在传说中的黄帝时代。《史记·历书》“索隐”说:“黄帝使羲和占日,常仪占月,臾区占星气,伶纶造律品,大挠造甲子,隶首作算数,容成综此六术而著调历也。”不仅有专门测定日影的专家,并在测量日、月、星有关数据的基础上,利用甲子推算,创制时历。《尚书·尧典》“期三百有六旬有六日,以闰月定四时成岁”,可看作是远古时代测量日、月、星而后制历的发展。这就是以岁实366日为基本数据的我国有文字记载的最早的阴阳历。\n制历调历是一件神圣的工作,《尧典》说“允厘百工,庶绩咸熙”,起到一个信治百官、兴起众功的作用。正因为这样,圭表测景就不可能是民间百姓的事,只能在天子或君王旨意下由专职官员负责进行。周代的测景遗址——周公测景台还保留在今天河南登封告成镇(古称阳城)这个地方。\n阳城地处中原,物产丰富,文化发达。周公想迁都中原,视阳城为“地中”,居天下九州中心的意思。《周礼·地官大司徒》云:“以土圭之法测土深,正日景,以求地中。日南则景短,多暑。日北则景长,多寒。日东则景夕,多风。日西则景朝,多阴。日至之景,尺有五寸,谓之地中。天地之所和也,四时之所交也,风雨之所会也,阴阳之所合也。然则百物阜安,乃建王国焉。”如此详细地叙述求地中的方法,“地中”地理位置如此重要,占尽地理之便。这就是周公为迁都造下的舆论。实际上,所谓地中,是指当时国土南北的中心线而已。\n告成镇的周公测景台,有一个高耸的测量台,相当于一个坚固的“表”,平铺于地面的是“量天尺”,也就是一个放大了的石“圭”。现今遗留的测景台,元代初建,明代重修。重修的测景台是正南正北走向,高出圭面8.5米,下面的圭长30.3米。\n从周公在这里主持测景后,历代都在这里进行过测量,至今还有公元724年唐代所立的石“表”,上面刻有“周公测景台”五字。\n三、冬至点的测定 # 我国古代以冬至作为一个天文年度的起算点,冬至的时刻确定得准不准,关系着全年节气的预报。古代天文学家的一项重要任务就是测定准确的冬至时刻。测出两次冬至时刻,就能得到一年的时间长度。这样定出的年,就是回归年,古代称为“岁实”。《后汉书·律历志》说:“日发其端,周而为岁,然其景不复。四周,千四百六十一日而景复初,是则日行之终。以周除日,得三百六十五四分日之一,为岁之日数。”四分历的岁实36514日就是这样测出来的,这是利用冬至日正午日影长度四年之后变化一周这一实测得出的数据。这样的数据,四年之后误差积累才有0.0312日,即不到45分钟。这已是测得很精确的了。可以认为,过四年后,冬至日正午影长大体复回到最初的长度。\n下面介绍祖冲之测刘宋武帝(刘骏)大明五年(公元461年)十一月冬至时刻的方法。文载《宋书·历志》。\n十月十日影一丈七寸七分半\n十月十日影长10.7750尺\n十一月二十五日一丈八寸一分太\n十一月二十五日影长10.8175尺\n二十六日一丈七寸五分强\n二十六日影长10.7508尺\n折取其中,则中天冬至\n冬至应在十月十日与十一月二十五日之间\n应在十一月三日\n正中那一天,即十一月三日\n求其早晚\n求冬至时刻在早晚什么时候\n令后二日影相减,则一日差率也\n一日差率=10.8175-10.7508=0.0667\n倍之为法\n法=0.0667×2=0.1334\n前二日减,以百刻乘之为实\n实=(10.8175-10.7750)×100刻=4.25刻\n以法除实,得冬至加时,在夜半后三十一刻\n冬至时刻=实÷法=4.25÷0.1334=31.86\n因为十月十日和十一月二十五日正午之间的中点是在十一月三日的子夜,冬至时刻从子夜起算。又,古历计算中通常不进位,故31.86刻记为31刻。又,“太”即34;“强”即112。\n不难看出,在只有圭表测影的时代,祖冲之测定冬至时刻的方法确实是大大进步了。\n前已提到,冬至点是指冬至时太阳在恒星间的位置,现代天文学是以赤经、赤纬来表示,我国古代是以距离二十八宿距星的赤经差(称入宿度)来表示。四分历明确记载,冬至点在牵牛初度。冬至点这个数据如何测定,没有留下任何文字记录。《左传》上有两次“日南至”的记载:一是僖公五年“春王正月辛亥朔,日南至”;一是昭公二十年“春王二月己丑,日南至”。说明鲁僖公时代有过日南至的观测,可是没有留下如何观测的记录。唐代僧一行(张遂)在《大衍历议·日度议》提到,古代测定太阳位置的方法是测定昏旦时刻的中星,由此可以推算出夜半时刻中星的位置,在它相对的地方就是夜半时刻太阳的位置。这是间接推求冬至点的方法。《大衍历议》也提到,后来采用直接测量夜半时刻中星的办法。这就要求漏刻(计时工具)有比较稳定的精确度。利用太阳日行一度的规律,求出某日夜半时刻太阳在星空间的位置,就不难求得冬至时刻太阳所在位置,即冬至点的位置。\n冬至点在牵牛初度,这是四分历(殷历)的计算起点。战国时代所谓“颛顼历”取立春时太阳在营室五度为起算点,按中国古度推算,太阳冬至点的位置仍是牵牛初度。这也说明,颛顼历乃是殷历的改头换面,其天象依据全抄殷历。\n四、岁差 # 地球是一个椭圆体,又由于自转轴对黄道平面是倾斜的,地球的赤道部分受到日月等吸引而引起地轴绕黄极作缓慢的移动,大约26000年移动一周,这就是岁周。即是说,经过一年之后,冬至点并不回到原来的位置,而是在黄道上大约每年西移50.2秒,就是71年8个月差一度,依中国古度就是70.64年差一度,所以叫岁差。\n据推算,在公元前2800年右枢最近北极,几乎一致。传说的尧舜时代,右枢距天极约3~4度,仍可称为北极星。\n北极移动曲线图\n北极按箭头方向移动。众星位置是按公元1900年初的北极来表示。\n现在北极星即勾陈一(小熊座α星)离北极一度多,公元2102年最接近北极,那时北极距约27分37秒。《晋志》所谓天枢(鹿豹座∑1694星)是一颗五等星,在中唐时代是理想的北极星。右枢(天龙座α星)是一颗四等星,在公元前2800年最近北极,几乎和北极一致,传说中的尧舜时代,它的北极距约3—4度,仍可称为北极星。\n公元前1100年前后,周初时代,帝星距极六度半。天极附近又只有帝星明亮,便视为北极星。《周髀算经》所谓“北极中大星”,就是指此。如果画出这颗“北极中大星”绕天球北极的圆周运动,就叫做北极璇玑四游。因为距极六度半,可看出明显的旋转位移。这就是《吕氏春秋》所谓“极星与天俱游而天极不移”。到西汉末年,帝星距极八度三,汉人仍依旧说,帝星为北极中大星。\n中唐时代(公元766—835年),天枢是理想的北极星。\n现今,视勾陈一为北极星,它距北极一度多。到公元2102年,勾陈一最接近北天极,距极只有27分37秒。公元7500年,天钩五(仙王座α星)将成为北极星。公元13600年时,明亮的织女星将作为北极星出现在天穹。\n冬至点在黄道上的移动是缓慢的,短时期内不易测出。晋代以前,古人不知有岁差,天周与岁周不分。《吕氏春秋·有始览》“极星与天俱游而天极不移”,也只认为北天极是固定点,注意到了极星不在极点上,北极星与北天极是两码事。冬至点位移,汉代人从实测中是注意到的。汉武帝元封七年(公元前104年)改历,测得元封七年十一月甲子朔旦冬至“日月在建星”。《汉书·律历志》所载《三统历》也提到,经过一元之后,日月五星“进退于牵牛之前四度五分”。这是刘歆的认识。这无异于承认了冬至点已不在牵牛初度了。\n东汉贾逵明白地肯定了冬至点的位置变动。他说:“《石氏星经》曰:黄道规牵牛初值斗二十一度,去极百一十五度。于赤道,斗二十一度也。”这就是汉代石申学派通过实测改进冬至点的数据,明确了冬至赤道位置在斗二十一度。这相当于公元70年左右的天象。东汉四分历所定冬至点在斗二十一度四分之一,就是采用石申学派实测的数据。\n东晋成帝时代,虞喜根据《尧典》“日短星昴”的记载,对照当时冬至日昏中星在壁宿的天象,意识到一个回归年后,太阳没有在天上行一周天,而是“每岁渐差”。他第一次明确提出冬至点有缓慢移动,应该“天自为天,岁自为岁”。太阳从上一个冬至到下个冬至,并没有回到原来恒星间的位置,还不到一周天。于是他称这个现象为岁差,取每岁渐差之义。虞喜把“日短星昴”认定为他之前2700余年的尧的时代的记录,由此求得岁差积五十年差一度。\n虞喜之后,祖冲之首先在历法计算中引进了岁差,他实测得冬至点在斗十五度,得出45年11个月差一度。\n隋代刘焯在他的“皇极历”中,改岁差为75年差一度,比虞喜和祖冲之的推算更接近于实测值。唐宋时代,大都沿用刘焯的岁差数值。\n南宋杨忠辅“统天历”和元代郭守敬“授时历”,采用66年8个月差一度,就更为精密了。\n五、节气的产生 # 冬至点准确测定是产生二十四节气的基础。似乎把两冬至之间的时日二十四等分之,就可以得出二十四节气。事实上,先民认识节气,经历了一个漫长的过程。\n我国是农耕发达最早的国家之一,先民在长期的农业生产中,十分重视天时的作用。《韩非子》说:“非天时,虽十尧不能冬生一穗。”北魏贾思勰著《齐民要术》,提出“顺天时,量地利,则用力少而成功多,任情返道,劳而无获”。天,天时,对农业生产起着重要的作用。\n“天”是什么?天并非自然界和人类社会的最高主宰。荀子认为,“天”是自然界,而自然界的变化是有它的客观规律的,“不为尧存,不为桀亡”,它的变化是客观存在的。\n按现代的说法,“天”指的是宇宙和地球表面的大气层。大气层中出现的种种气象现象,阴晴冷暖,雨雪风霜,直接影响着农业生产。今年五谷丰收,我们说“老天爷帮了忙”;要是减产歉收,我们就说“老天不开眼”。从农业生产角度看,天指的是气象条件,说得确切些,指的是农业气象条件。天时的“时”,农业活动的“时”,不是简单地指时间历程,它要求能反映出农业气象条件,反映四季冷暖及阴晴雨雪的变化。\n二十四节气的节气,是表示一年四季天气变化与农业生产关系的。我国古代,节气简称气,这个“气”,实际是天气、气候的意思。\n从根本上说,二十四节气是由地球绕太阳公转的运动决定的。现代天文学把地球公转一周即一年分为四段,划周天为360度。自春分开始,夏至为90度,秋分为180度,冬至为270度,再至春分合成360度。每一段即每相距90度又分为六个小段。这样,一年便分为二十四个小段,每段的交接点就是二十四节气。西方至今还只有两分、两至,仅具有天文意义。可以说,二十四节气是中华民族几千年来特有的表达农业气象条件的一套完整的时令系统。\n二十四节气始于何时?一般认为,《尚书·尧典》中的仲春、仲夏、仲秋、仲冬就是指春分、夏至、秋分、冬至四气。果真这样,应该看成是二十四节气形成的初始阶段。《左传·昭公十七年》提到传说中的少昊氏设置历官:“凤鸟氏,历正也;玄鸟氏,司分者也;伯赵氏,司至者也;青鸟氏,司启者也;丹鸟氏,司闭者也。”一般都认为,分指春分、秋分,至指夏至、冬至,启指立春、立夏,闭指立秋、立冬。少昊氏时代,以鸟为图腾,物象与时令已密切相关。玄鸟即燕子,春分来秋分去,标志着春分、秋分的到来。伯赵,鸟名,一名,夏至鸣冬至止,标志着夏至、冬至的到来。青鸟、丹鸟均鸟名,分别标志着立春、立夏和立秋、立冬的到来。二分二至和四立,是二十四节气中最重要的八气,也是最先产生的八气。当然不必追溯到传说的少昊时代。\n两分两至虽然能定岁时,但分一年为四个时段,各长九十余天,各段的天气、气候有显著的差异,就远不能满足农业生产上每一环节所要求掌握的天时。所以,必须加以细分。《左传》中多次提到分、至、启、闭,可见四立也产生得很早。分、至加四立,恰好把一年分为八个基本相等的时段,从而把春、夏、秋、冬四季的时间范围确定了下来。这就基本上能够适应农业生产的需要。《吕氏春秋》十二纪中就只记载了这八个节气——立春、春分(日夜分)、立夏、夏至(日长至)、立秋、秋分(日夜分)、立冬、冬至(日短至)。看起来,分、至加四立,有一个较长的稳定时期。在此基础上发展,才形成二十四节气。\n西汉《淮南子》记载了完整的二十四节气,这可能是目前见到的完整二十四节气的最早文字记载。二十四节气的顺序也和现代的完全一致,并确定十五日为一节,以北斗星定节气。《淮南子》说:“日行一度,十五日为一节,以生二十四时之变。斗指子,则冬至……加十五日指癸,则小寒……”\n有人认为二十四节气最早见于《周髀算经》,而《周髀》成书于何时,历来的看法也不一样。李长年认为《周髀算经》是战国前期的书籍,钱宝琮认为《周髀》是公元前100年前后(汉武帝时代)的作品。李俨在《中算史论丛》第一集中认为二十四节气大约是战国前的成果。《逸周书》是从战国魏安釐王墓中发现的,其《时训解》中已有完整的二十四节气记载。不仅如此,每气还分三候,五日为一候,而且物象的描写又十分细致。怎么解释《逸周书·时训解》中细致的物象描写?《左传·僖公五年》载:“凡分、至、启、闭,必书云物,为备故也。”就是说,每逢两分、两至、四立时,必须把当时的天气和物象记录下来,作为准备各项农事活动的依据。详细地记录物象、气象,是先民长期形成的传统,是重视农业生产的必要手段。《吕氏春秋》除了记载二十四气中最重要的八气外,还记载了许多关于温度、降水变化以及由此影响的自然、物候现象。这也是先民记录物象、气象的优良习俗的文字遗迹,与《左传·僖公五年》所载是吻合的。但这并不能说明《吕氏春秋》这部书产生的时代二十四气尚未形成。\n《逸周书》虽有人疑为后人伪托,但战国时代二十四节气已全部形成还是可信的。我们以为,《汉书·次度》所记二十四节气,其顺次与《淮南子》所记汉代节气顺次小有差异,并定“冬至点在牵牛初度”,应看作是战国初期的记载。明确些说,二十四节气在战国之前已经形成。\n六、二十四节气的意义 # 在我国古代,二十四节气的日期是由圭表测景来决定的。《周髀算经》和《后汉书·律历志》等许多古书都记载着二十四节气的日影长短数值。这说明二十四节气实际上是太阳视运动的一种反映,与月亮运动没有丝毫的关系。二十四节气的每一节气都是表示地球在绕太阳运行的轨道上的一定的位置的。地球通过这些位置的时刻,就称交节气。它表明这个节气刚好在这个时刻通过,是在某月、某日、某时、某分交这个节气的。因此,从天文角度来理解节气的时间概念,它是指的瞬间时刻,而不是一个时段。从农业生产的实际出发,农事活动不限于一日,瞬时的气象条件也不能决定农作物的生长发育,它需要一段时间的气象条件作保证。因此,节气必须具有时间幅度,应理解为一段时间,而不是交节气那一天,更不是那一瞬间。\n二十四节气的每一节气都有它特定的意义。仅是节气的名称便点出了这段时间气象条件的变化以及它与农业生产的密切关系。现将每个节气的含义简述如下。\n夏至、冬至,表示炎热的夏天和寒冷的冬天快要到来。我国广大地区,最热的月份是7月,夏至是6月22日,表示最热的夏天快要到了,我国各地最冷的月份是1月,冬至是12月23日,表示最冷的冬天快要到来,所以称作夏至、冬至。夏至日白昼最长,冬至日白昼最短,古代又分别称之为日长至(日北至)和日短至(日南至)。\n春分、秋分,表示昼夜平分。这两天正是昼夜相等,平分了一天,古时统称为日夜分。这两个节气又正处在立春与立夏、立秋与立冬的中间,把春季与秋季各分为两半。\n立春、立夏、立秋、立冬,我国古代天文学上把四立作为四季的开始,自立春到立夏为春季,自立夏到立秋为夏季,自立秋到立冬为秋季,自立冬到立春为冬季。立是开始的意思,因此,这四个节气是指春、夏、秋、冬四季的开始。\n二分、二至、四立,来自天文,但它们中的春、夏、秋、冬四字都具有农业意义,即春种、夏长、秋收、冬藏。春、夏、秋、冬四个字概括了农业生产与气象关系的全过程,反映了一年里的农业气候规律。\n雨水,表示少雨雪的冬季已过,降雨开始,雨量开始逐渐增加了。\n惊蛰,蛰是藏,生物钻到土里冬眠过冬叫入蛰。回春后出土活动,古时认为是被雷震醒的,所以称惊蛰。惊蛰时节,地温渐高,土壤解冻,正是春耕开始时。\n清明,天气晴和,草木现青,处处清洁明净。\n谷雨,降雨明显增加。越冬作物返青拔节,春播作物生根出苗,都需雨水润溉。取雨生百谷意。\n小满,麦类等夏熟作物籽粒开始饱满,但未成熟。\n芒种,小麦、大麦等有芒作物种子已成熟,可以收割。又正是夏播作物播种季节。芒种又称“忙种”,指节气的农事繁忙。\n小暑、大暑,开始炎热称小暑,最热时候称大暑。\n处暑,处是终止、躲藏之意。表示炎夏将去。\n白露,处暑后气温降低快,夜间温度已达成露条件,露水凝结得较多、较重,呈现白露。\n寒露,气温更低,露水更多,有时成冻露,故称寒露。\n霜降,气候已渐寒冷,开始出现白霜。\n小雪、大雪,入冬后开始下雪,称小雪。大雪时,地面可积雪。\n小寒、大寒,一年中最冷的季节。开始寒冷称小寒,最冷时节称大寒。相对小暑、大暑,间隔正半年。\n为便于记忆,民间流行着一首歌诀:\n七、节气的分类 # 从上节二十四节气的含义可以看出,节气可概括分为三类:\n第一类是反映季节的。二分、二至和四立是用来表明季节,划分一年为四季的。二至二分是太阳高度变化的转折点,是从天文角度上来划分的,适用我国全部地区。四立划分四季,有很强的地区性。\n第二类是反映气候特征的。直接反映热量状况的有小暑、大暑、处暑、小寒、大寒五个节气,它们用来表示不同时期寒暑程度以及暑热将去等都很确切。直接反映降水现象的有雨水、谷雨、小雪、大雪四个节气,表明降雨、降雪的时间和其强度。还有三个节气白露、寒露、霜降,讲水汽凝结成露成霜,有水分意义;也反映温度下降过程和气温下降的程度,有热量意义。\n第三类是反映动植物表象的。小满、芒种反映作物成熟和收种情况;惊蛰、清明反映自然现象,都有它们的农业气象意义。\n二十四节气中,直接谈到温度变化的有五个节气。小暑、大暑在7月上旬到8月上旬,说明天气最热。小寒、大寒在1月初到2月初,说明天气最冷。从黄河中下游各地的气候来看是完全符合的。洛阳、郑州、开封、济南等地最冷时段在1月中旬,最热时段在7月下旬。\n从天文角度看,夏至日视太阳最高,冬至日视太阳最低,我国的最热时期不在夏至前后,最冷时期不在冬至前后,这是为什么呢?夏至前后,虽然视太阳最高,辐射最强,地面吸热最多,但地面没有达到积累和保持热量最多之时。夏至以后,地面吸热减少,温度继续升高,直到地面吸收的热量等于它所放出的热量之时,地面温度才不再升高。这便是最热的季节,相当于小暑、大暑节气。过后,地面放出的热量多于地面吸收的热量时,气温开始降低。用类似的道理可以解释小寒、大寒最冷,而不是冬至前后最冷。\n处暑表示炎夏即将过去。从黄河中下游地区立秋以后气温下降趋势可以看出,处暑以前气温下降并不明显,处暑以后气温却急剧下降,是天气转凉的象征,正合处暑的含义。\n前面说过,白露、寒露、霜降既表示水汽凝结现象,也表明温度的下降幅度。黄河中下游地区的初霜期,平均在10月下旬到11月初,与霜降节气的时段完全符合。白露、寒露、霜降如实地反映了黄河中下游地区出露、初霜期的时段。\n二十四节气中有关降水的有四个节气。雨水包含开始下雨和雨量开始增多两个含义。从黄河中下游地区降雨日期和降雨量统计看,雨水节气反映了雨量开始增多的含义。\n谷雨表示降雨有明显增加。黄河中下游地区降水量的变化情况可以证实,谷雨时段的降水量,不仅明显地多于谷雨前的清明时段,也多于其后的立夏节气。所谓“春雨贵如油”反映出谷雨节气雨水对农作物的播种和出苗的重要作用。\n小雪表示已开始降雪。西安等地平均初雪日期在11月下旬。小雪在11月22日,两者相符。\n大雪表示从此雪将大起来。雪大,可以积雪日期和降雪天数较多为尺度来衡量。统计资料告诉我们,西安等地平均积雪初日多在12月上旬至中旬初,12月份以后积雪日数明显增多,各地12月份积雪日数均比11月份高出一倍以上,济南且高出四倍多。这说明大雪节气也反映了黄河中下游地区这段时期的“大雪”气候特征。从统计资料知道,大雪期间降水量并不增加而是逐渐减少。这又看出,大雪并不包含降雪量最大的意思。\n二十四节气中,属于物象的节气有四个。惊蛰在3月6日,取雷鸣开始和地下冬眠的生物开始出土活动,两者有因果关系。黄河中下游地区各地雷暴初日很不规律,或早于惊蛰,或迟于惊蛰,变动范围很大。洛阳的雷暴初日,1951—1970年统计,有早在2月10日的,有晚在5月27日的。这说明惊蛰的意义并非雷始鸣而引起地下冬眠的生物出土活动。冬眠生物复苏的原因不是雷鸣,而主要取决于适宜的温度条件。如果把惊蛰理解为因地温升高蛰伏地下的生物开始出土活动是比较符合实际的。\n清明是4月5日。西安、洛阳等地区这段时期的平均温度为13°C~14℃,年际变化在11℃~18℃之间。这正是初春的气温,气候宜人,草木繁茂,处处明朗清新,春光明媚。\n小满指麦类作物籽粒开始饱满,约相当于乳熟后期。芒种指麦类等有芒作物收获和谷子、黍、稷等作物播种之时。小满指作物行将成熟,芒种指一收一种。据近年物象资料显示,西安一带小麦乳熟后期约为5月中下旬,河南、山东沿黄河一带也大致如此。与小满节气所处时段非常接近。黄河中下游各地小麦都在6月上旬先后成熟,芒种反映了小麦的收获季节。\n总起来看,二十四节气是反映了黄河流域中下游地区的气候特征和农业生产特点的,并将各个时期的农业气象特征概括为简要的名称。仅仅两个字,内容却十分丰富,不仅对古代农业的发展起了很大的作用,就是在今天,仍有现实意义,全国各地都在灵活地运用二十四节来安排农业生产。\n八、节气的应用 # 二十四节气直接反映黄河中下游地区的农业气象特征,对于指导农事活动具有重要的作用。这些地区的劳动人民将节气与几种主要作物的种、收时间联系起来编成谚语,代代相传,节气就直接应用于农业生产了。\n就播种期说,种麦的谚语有:\n寒露到霜降,种麦日夜忙。\n秋分早、霜降迟,只有寒露正当时。\n立冬不交股(分蘖),不如土里捂。\n种高粱、谷子的谚语有:\n清明高粱谷雨谷,立夏芝麻小满黍。\n清明后,谷雨前,高粱苗儿要露尖。\n种棉花的谚语有:\n清明早,小满迟,谷雨种花正当时。\n清明玉米谷雨花,谷子播种到立夏。\n谷雨前,好种棉。\n比较四川、华中地区的谚语“清明前,好种棉”,江浙一带的谚语“要穿棉,棉花种在立夏前”,显出时令的不同。\n就黄河中下游地区收获季节的谚语,也看出节气与农事的联系。\n麦到谷雨谷到秋(立秋),过了霜降刨甘薯。\n麦到谷雨谷到秋,过了天社(秋社)用镰钩(割豆子)。\n谷雨麦怀胎,立夏麦胚黄,芒种见麦茬。\n白露不秀,寒露不收(谷子)。\n处暑见三新(指高粱、小米、棉花开始成熟)。\n处暑见新花。\n芒种不出头(棉花),不如拔了饲老牛。\n二十四节气是古代黄河中下游劳动人民长期进行农业活动的经验总结,随着中华民族经济、文化的发展,二十四节气也在全国各地得到广泛的运用。各地区的劳动人民都是因地、因时灵活地应用二十四节气以指导农业生产,节气在各地又有新的内容。如,各地冬小麦播种的适宜节气,用谚语反映出来就是:\n北疆:“立秋早,寒露迟,白露麦子正当时。”\n南疆:“秋分麦子正当时。”\n甘肃陇南山区:“白露早,寒露迟,秋分种麦正当时。”\n北京地区:“秋分种麦,前十天不早,后十天不晚。”\n河南、山东一带:“骑寒露种麦,十种九得。”\n华中地区:“寒露、霜降种麦正当时。”\n长江中下游地区:“霜降种麦正当时。”\n浙江:“立冬种麦正当时。”“大麦不过年,小麦立冬前。”\n同一个节气,反映在不同地区的动植物表象又是千差万别的。比如清明:\n华北、华中:“清明断雪,谷雨断霜。”\n东北、西北、内蒙古:“清明断雪不断雪,谷雨断霜不断霜。”指当断雪而此地不断,当断霜而此地不断。\n黄河中下游地区:“柳近清明翠缕长,多情右衮不相忘。”\n江南:“清明时节雨纷纷,路上行人欲断魂。”\n岭南:“梅熟迎时雨,苍茫值小春。”\n河西走廊:“绝域阳关道,胡沙与塞尘。三春时有雁,万里少行人。”\n青藏高原、东北北部:“天山雪后渔风寒,横笛偏吹行路难。”\n正因为同一节气各地的气象与物象的差别如此鲜明,各地劳动人民在应用节气指导农业生产时,自然得灵活地因地制宜,才能发挥节气的真正作用。\n这里介绍一首节气歌,也是反映节气与物象、气象关系的。\n立春阳气转,雨水沿河边。\n惊蛰乌鸦叫,春分地皮干。\n清明忙种麦,谷雨种大田。\n立夏鹅毛住,小满雀来全。\n芒种开了铲,夏至不纳棉。\n小暑不算热,大暑三伏天。\n立秋忙打靛,处暑动刀镰。\n白露烟上架,秋分无生田。\n寒露不算冷,霜降变了天。\n立冬交十月,小雪地封严。\n大雪江封冻,冬至冰雪寒。\n小寒过去了,大寒要过年。\n还有人将二十四节气编入诗中,并在每句嵌入一出戏文名,组成二十四节气名诗。这是清末苏州弹词艺人马如飞的创造。\n西园梅放立春先,云镇霄光雨水连。\n惊蛰初交河跃鲤,春分蝴蝶梦花间。\n清明时放风筝误,谷雨西厢好养蚕。\n牡丹亭立夏花零落,玉簪小满布庭前。\n隔溪芒种渔家乐,义侠同耘夏至田。\n小暑白罗衫着体,望河亭大暑对风眠。\n立秋向日葵花放,处暑西楼听晚蝉。\n翡翠园中零白露,秋分折桂月华天。\n烂枯山寒露惊鸿雁,霜降芦花红蓼滩。\n立冬畅饮麒麟阁,绣襦小雪咏诗篇。\n幽闺大雪红炉暖,冬至琵琶懒去弹。\n小寒高卧邯郸梦,一捧雪飘空交大寒。\n九、杂节气 # 古代劳动人民在生活与生产活动中,常用一些简要的词语表示冷、暖、干、湿等气象现象,如三伏、九九之类。它们在一定程度上补充了节气的不足,有人称之为杂节气。“热在中伏”,“冷在三九”,杂节气在人们的生产与生活中有着一定的意义。\n三伏伏的本义是指隐伏,躲避盛暑之义。以后就指一年里最热的日子。一年中最热的日子分为三个时段,即头伏、二伏、三伏。从夏至后第三个庚日算起,第一个顺序十天,叫做头伏或初伏;第二个顺序十天,叫中伏或二伏;立秋后第一个庚日算起,往后顺序十天叫末伏或三伏。\n所谓庚日,指干支纪日逢庚的日子而言。六十甲子,每隔十天就有一个庚日,一个甲子周期有六个庚日,夏至后第三个庚日是公历哪一天呢?阳历一年365天,闰年还要多一天,都不是十的整倍数。因此,今年某一天是庚日,下一年同一天就不可能还是庚日。\n九九指一年中较冷到最冷又回暖的那些日子。把这些日子按九天分为一段,共分九段,顺次称为一九、二九、三九……到八九、九九,共计八十一天,即所谓数九寒天。它是从冬至这天作为一九开始,即从12月22日或23日开始,依日序九天一段,直到惊蛰前两三天而为九九。\n怎样衡量每个九日的寒冷程度呢?黄河中下游地区民间流传着一首九九歌:“一九二九不出手(天气冷了),三九四九河上走(河水结冰),五九六九沿河看柳(柳树发芽),七九河开(江河解冻),八九雁来,九九耕牛遍地走。”歌谣中把整个寒冬的全过程的变化顺次写出来,其中“不出手”“河上走”“沿河看柳”“河开”“雁来”等,实际上是候应。到九九,“耕牛遍地走”,春耕繁忙起来,说明九九歌的目的是为了掌握农时。\n由于各地气候条件的差异,江南地区的九九歌的内容又有不同:“一九二九相见弗出手;三九二十七,篱头吹筚篥(寒风吹得篱笆啪啪响);四九三十六,夜晚如鹭宿(寒夜,人像白鹭蜷曲身体入睡);五九四十五,太阳开门户;六九五十四,贫儿争意气;七九六十三,布衲担头担;八九七十二,猫儿寻阳地;九九八十一,犁耙一齐出。”\n冬有九九,夏亦有九九。宋代周遵道《豹隐纪谈》载有夏至后九九歌:“一九二九,扇子不离手;三九二十七,吃茶如蜜汁;四九三十六,争向路头宿;五九四十五,树头秋叶舞;六九五十四,乘凉不出寺;七九六十三,夜眠寻被单;八九七十二,被单添夹被;九九八十一,家家打炭墼。”这首歌确切地反映了夏至后天气逐渐变热,再转凉变寒的气温变化过程,反映了从夏至后起经小暑、大暑、立秋、处暑到白露这一过程的气候特征对人们生活的影响。\n霉江淮流域一带,一般每年6月上旬以后出现一段阴沉多雨、温高、湿大的天气。这段时期,器物容易发霉,人们称这种天气为霉雨,简称霉。这段时期又是江南梅子成熟的时候,所以又称为梅雨或黄梅雨。两者含义相同,气象学上称为梅雨,但历书上多称霉雨。把霉雨开始之日叫入霉(梅),结束之日叫出霉(梅)。历书上入霉、出霉日期是这样得出来的:《月令广义》(冯应京纂辑)提出“芒种后逢丙入梅,小暑后逢未出梅”,即芒种后第一个丙日称入霉,小暑后第一个未日称出霉。所以入霉总在6月6日到6月15日之间(天干十数),出霉总是在7月8日到19日之间(地支十二数)。\n社日立春后五戊为社。最初系指立春后第五个戊日叫社日,以后立秋后第五个戊日也叫社日,分别称为春社与秋社。春社敬祀土神以祈祷农业丰收,秋社敬祀土神以酬谢农业获得丰收。\n寒食冬至后一百零五日称寒食,刚好是清明日的前一天,所以寒食与清明往往并用,作为节气名称之一。有诗云:“一百五日寒食雨,二十四番花信风。”\n广为流传的《幼学琼林》在叙述杂节气时写道:“二月朔为中和节,三月三为上巳辰。冬至百六是清明,立春五戊为春社。寒食节是清明前一日,初伏日是夏至第三庚。四月乃是麦秋,端午却为蒲节。六月六日节名天贶,五月五日节号天中。”\n十、七十二候 # 上一讲讲到观象授时,观象授时的“观象”,主要是观天象,还要观气象、物象。天象,即日月星辰的运行;物象,即动植物顺应节气而有一定的表象,如“鸿雁来”“桃始华”之类;气象,指风雨雷电、“凉风至”“雷发声”之类。应该说,最早的观察还是从气象、物象开始的,因为气象、物象与先民的生产、生活有切身的利害关系,比起天象来得更直接,显得更具体实在。古代记载观象授时的文字,比如《尧典》《夏小正》《月令》,虽有天象记载,而大量的文字还是关于气象、物象的记录。流传至今的很多农谚,就是观察气象与物象的经验总结。汉代崔寔《四民月令》就是汉代以前关于气象、物象资料的总结。元代末年娄元礼编撰《田家五行》记载了农谚140多条,不少是天象结合气象、物象的内容。如:月晕主风,日晕主雨。一个星,保夜晴。星光闪烁不定,主有风。夏夜见星密,主热。东风急备蓑笠,风急云起,愈急必雨。鸦浴风,鹊浴雨,八哥儿洗浴断风雨。獭窟近水,主旱;登岸,主水。\n上古时代,先民将全年每月的天象、物象、气象,择要记录下来,以此指导农事活动,这在当时无疑具有重要意义,所以古代典籍都非常郑重地加以记载。\n宋代王应麟《玉海》中记载了用鸟兽草木的变动来验证月令的变易,并说:“五日一候,三候一气,故一岁有二十四节气。”这样,一月六候,一岁七十二候,将气象、物象与月、岁的配合规律化,整齐划一。这是把古来的零散、杂乱记载加以集成、整理的结果。\n在研究二十四节气时,有必要讨论一下七十二候。候是气候义。每候有一个相应的物候现象,叫做候应。物候自然包括气象、物象两个内容。七十二候可说是我国古代的物候历。\n最早的物候记载,见于《诗经·七月》,其中“四月秀葽,五月鸣蜩”“五月斯螽动股,六月莎鸡振羽”“八月其获,十月陨箨”等都确切地反映了物候现象与季节、农事活动的密切关系,为后世编制农事历创造了良好的范例。较多的候应记载,见于《大戴礼记》中之《夏小正》及《礼记》中之《月令》。\n《夏小正》很少提到节气,只有启蛰、日冬至可以认为是惊蛰、冬至两节气,候应虽较完整,但不十分系统,各月多少也不一致。如正月所列候应有雁北乡、雉震雊、启蛰、鱼涉负冰、囿有见韭、时有俊风、寒日涤冻涂、田鼠出、獭献鱼、鹰则为鸠、农及雪泽、采芸、柳稊、梅杏杫、桃则华等十五项;而十月则仅有豺祭兽、黑鸟浴、玄雉入于淮为蜃三项。这该怎么解释?\n现代学者有《夏小正》与彝族十月历吻合的见解。他们认为,从《夏小正》中的物候记录来看,基本上符合十月历而与十二月历不合。《夏小正》正月的物候与农历大致相同,但以后便逐渐增大差距。他们认为,《夏小正》的物候记录原本是按十个月排列的,其最后两个月是整理者主观加上去的,无星象文字,物候记录则是从十月中分出的。\n完整的七十二候,最早见于《吕氏春秋》十二纪中,除七十二候外,还记有十余候。可以认为,《吕氏春秋》十二纪取材于《月令》,上溯至《夏小正》,是物候历系统,而并不理会二十四节气。我们以为,《逸周书》反映出,还有一个二十四节气的节气历系统,两者并行不悖。汉代《淮南子》宗法《逸周书》,将七十二候与二十四节气两个系统配合起来,合二为一,成为一个完整的农事历体系。\n《吕氏春秋》十二纪中以每月至少六候编入各月。有的物候现象与节气大体一致。孟春纪中有蛰虫始振;仲春纪中有始雨水;仲夏纪中有小暑至;孟秋纪中有白露降;季秋纪中有霜始降。相应的节气是惊蛰、雨水、小暑、白露、霜降。有的成为七十二候中的候应,如东风解冻、蛰虫始振、鱼上冰、獭祭鱼等。还有的物候文字如天气下降、地气上腾、天地和同与木槿荣、芸始生等就没有编入七十二候中。\n汉代以后,很多农书以二十四节气、七十二候为中心内容作些修改补充,制定出各种农事历、农家历、田家历、田家月令、每月栽种书、每月纪事、逐月事宜等一类的农家历书。各代通行的历书,也将二十四节气和七十二候以及相应的农事活动编了进去。\n七十二候的候应中有生物物候和非生物物候。生物物候中有植物的与动物的。有栽培或饲养的,也有野生的,野生植物八项,栽培植物五项;野生动物最多,有三十八项,饲养的最少,只有一项。非生物物候二十项,其中反自映然现象的七项,反映气象现象的十三项。除野生动物外,以气象为最多。这些候应多确切地反映了天气、气候的变化,包含的面很广泛,且是人们日常生活中最易感知的。比如燕子(玄鸟)春去秋来,鸿雁冬来夏往,反映时令十分准确,历来把它们称为候鸟;而蝉(即蜩)、蚯蚓、蛙(即蝼蝈)等顺季节而隐现也很明显,历来把它们称为候虫。它们的来去、隐现所反映出的时令实际包括了温度、光照气象条件的综合。有些植物如桃、桐、菊、苦菜等开花以及草木的荣枯还反映了过去一定时期内的积温,反映了对水分、光照等条件的要求,反映了当时气象条件的综合。应当说,这些物候现象是气象要素综合影响的结果。所以,七十二候候应所反映的农业气象条件,有它明显的特点:具体简单,用于指导农事活动也来得准确、直接。物候所以起源很早,而且一直沿用至今,原因就在这里。\n如果从物候学观点来看待七十二候,很显然,有些物候现象是不科学的,如:腐草化为萤、雀入大水为蛤、雉入大水为鹰等都是没有的事;虎始交、鹿角解、麋角解等是很难甚至不可能见到。有些候应的意义较为晦涩,如天地始肃、地气上腾、天气下降、闭塞成冬等是无法观测的。有些候应名称不通用,难以准确理解,如仓庚(指莺的一种)、戴胜(一种鸟,状似鹊)、荔(一种草,似蒲而小)、等等。此外,七十二候受了二十四节气的约束,每一个节气非三候不可,五天有一个变化,反而无法充分发挥物候应有的作用。现代的一般历书删去了七十二候,道理就在这里。\n《月令总图》(见本书91页)外圈所列即七十二候顺次配合十二个月。\n十一、四季的划分 # 一年四季,春夏秋冬,按照传统的观念,阴历正、二、三月为春季,四、五、六月为夏季,七、八、九月为秋季,十、冬、腊月为冬季。“一年之计在于春”,春节当然是阴历正月初一了。然而,阴历以月亮盈亏来计算月份,就不能准确地反映季节的变迁。今年正月初一到下年正月初一,可能是354天(平年),也可能是384天(闰年),日数差到30日,所以按朔望月划分季节是不可取的。\n我国古代典籍中多以四立作为四季的开端,每一个节气还有相应的候应作为季节的标志,这种划分标准反映了黄河流域四季分明的气候特点。\n立春。立春第一候候应是东风解冻,作为春季开始的标志。从黄河中下游各地土壤开始解冻日期来看,这一带10厘米深土层开始解冻的平均日期约从1月底到2月上旬,如西安平均为2月2日,开封为1月24日,济南为2月9日,与古代立春节气第一候候应基本一致。再从日最高气温等于或小于0℃的终止日期看,也能说明立春的气候意义。黄河中下游地区各地日最高气温小于或等于0℃终日约为2月11日到2月21日之间。可见,这一地区立春节气白天温度开始上升到0℃以上,土壤解冻,春天即将到来。白居易诗:“野火烧不尽,春风吹又生。”春风或东风,指较暖湿的偏南和偏东风。它们吹来时,野草开始萌动,象征春天将到,土壤开始解冻。从西安地区看,2月份“东风”显著增加。结合土壤解冻日期,“东风解冻”反映了黄河中下游地区2月上旬(立春)的气候特点。\n立冬。立冬第一候候应“水始冰”,作为冬季开始的标志。据《中国气候图简编》看,黄河中下游地区平均开始结冰日期大致为11月1日、11日、21日三条等日期线所通过。此外,黄河中下游地区最低气温等于或小于0℃的开始日期大致在11月1日到11日之间,济南为11月11日,可见立冬开始是与“水始冰”基本一致的。\n立秋。立秋第一候候应为“凉风至”。夏秋之季,北风刮来,给人带来凉意。“凉风至”如可解释为最多风向是偏北或偏北风频率迅速增多,偏南风频率迅速减少,那么黄河中下游地区8月份风向转变情况是与立秋的“凉风至”相一致的。\n立夏。立夏第一候候应为蝼蝈鸣,而目前黄河中下游一带青蛙始鸣日期与立夏第一候蝼蝈鸣是有较大差别的。西安3月上旬,洛阳3月下旬初,德州4月初,安阳4月下旬初,而立夏在5月初。\n如果以四立划分四季,立春就是春季的开始,此时正是阳光从最南的位置(冬至)到适中的位置(春分)的过渡阶段,即是冬季到春季的过渡阶段。真是这样划分四季,那还是不符合天气变化的实际。立春日正是“五九”将尽而“六九”开始之际,天气还相当寒冷。我国北方的立春日,可冷到-20℃左右,因为冬至日太阳在最南的位置,大地丧失热量入不敷出的状况尚未达到顶点,要等一两月后北半球热量丧失过多而气温降到最低,那正是立春日前后。因此,冬季往往到立春前后才最冷,把最冷的立春作为春季的开始,显然是不恰当的。\n天文学上是以春分、夏至、秋分、冬至作为春、夏、秋、冬四季的开始。两分两至是根据视太阳在黄道上运行的位置而制定出来的,因此它不但适用于黄河流域,而且对全国来说都是适用的。这样的四季划分确实反映了自然界的变化,如树木发芽,雷雨出现,草木枯黄,首次见霜等现象,这与以气温变化来决定季节也是大体吻合的。从春分以后,太阳的位置愈来愈高,大地接受到愈来愈多的热量,确实开始了一个温暖的季节。\n现在通用的是从气候学上划分四季,标准是以候平均气温低于10°C为冬季,高于22°C为夏季,界于10°C和22°C之间分别为春季、秋季。按这样的标准,各地四季的长短就大不相同。昆明可以是“四季如春”,青藏高原和东北北部的冬季就十分漫长。\n如果按节气来划分四季,不管是我国古代以四立为标准分出春夏秋冬,还是通行于世的二分、二至划分四季,春夏秋冬四季的时间间隔都完全相等。按气温来划分,我国广大地区是春秋短而冬夏长,这是我国季风气候的一个显著特征。\n十二、平气与定气 # 二十四节气的计算方法,最初是把一个回归年长度均匀地分为二十四等分。四分历的回归年长度为36514日,每一节气的时间长度是36514÷24=15732日。从立春时刻开始,每过15732日就交一个新的节气,这就是平气。清代以前,历法都用平气划分二十四节气。\n太阳周年视运动实际是不等速的。《隋书·天文志》载,北齐天文学家张子信已经发现“日行在春分后则迟,秋分后则速”。\n隋代刘焯在《皇极历》中提出以太阳黄道位置来分节气。他把黄道一周天从冬至开始,均匀地分成二十四份,太阳每走到一个分点就是交一个节气,这叫定气,取每个节气太阳所在位置固定的意思。两个节气之间太阳所走的距离是一定的,而所用的时间长度都不相等。冬至前后太阳移动快,只要十四日多就从一个分点走到下一个分点。夏至太阳移动慢,将近十六日才走到下一个分点。刘焯的定气在民用历本上一直没有采用。\n唐代僧一行《大衍历议·日缠盈缩略例》中批评了刘焯对于太阳运动规律的错误认识。他指出:“焯术于春分前一日最急,后一日最舒;秋分前一日最舒,后一日最急。舒急同于二至,而中间一日平行,其说非是。”他指出的规律是接近实际的:“日南至,日行最急。急而渐损,至春分,及中,而后迟。至日北至,其行最舒。而渐益之,以至秋分,又及中,而后益急。”(见《新唐书·历志》)\n为计算任意时刻的太阳位置,一行发明了不等间距的二次差内插公式,在实际计算中,元代“授时历”已经考虑到三次差。\n不过,清代“时宪历”才用定气注历本。第五讲四分历的编制\n第五讲四分历的编制 # 在有规律地调配年、月、日的历法产生以前,都还是观象授时的阶段。观象,主要是观测星象,是以二十八宿为基准,记述时令的昏旦中星,这是采用二十八宿体系的授时系统。\n由于二十八宿之间跨度广狭相当悬殊,势必影响所确定的时令的准确度。随着农业的精耕细作,对时令的准确性要求越来越高,观星定时令也就发展为以二十四气定时令,这是采用二十四气体系的授时系统。\n二十八宿体系是依据具体的星象以朔望月为基础加置闰月的办法调整年月日的阴阳历系统,二十四气体系是依据太阳周年视运动划分周天为二十四等分,形成纯粹的太阳历系统。到二十四气的产生,记述时令的办法就由观测具体的星象进入了一个可运算的抽象化的时代。二十四气的诞生,是观象授时走向更普遍、更概括,经过抽象化而上升为理论的阶段。到了这时,观象授时才算完成了自己的任务为二十四气所取代了。从此,在我国古代天文学史上,就同时并存有两套不同的授时系统。\n伴随着二十四气而来的,就是古代四分历的出现。\n一、产生四分历的条件 # 所谓“四分历”,是以36514日为回归年长度调整年、月、日周期的历法。冬至起于牵牛初度,则14日记在斗宿末,为斗分,是回归年长度的小数,正好把一日四分,所以古称“四分历”。\n四分历是我国第一部有规律地调配年、月、日的科学历法,它要求有实测的回归年长度36514日,要求有比较准确的朔望月周期。由于是阴阳合历的性质,就必须掌握十九年七闰的规律。只有满足了这些条件,以36514日为回归年长度的四分历的年、月、日推演才有可能进行,四分历才有可能产生。\n关于回归年长度的测量。圭表测景之法在商周时代就已经有了。《尧典》所载“期三百有六旬有六日”的文字,应看作商末或更早的实测。回归年长度定为366日,是不可能产生历法的。古代典籍中,关于冬至日的最早记载,在《左传》中有两次。一次在僖公五年(公元前655年):“春王正月辛亥朔,日南至。”一次在昭公二十年(公元前522年):“春王二月己丑,日南至。”只要不能证实这是古人的凭空编造,就应该承认,在鲁僖公时代,是有过日南至(冬至)的观测的。冬至日期的确定,古代是利用土圭对每天中午表影长度变化的观测得来的。只要长期使用圭表测影来定冬至(或夏至)日期,就可以得到较为准确的回归年长度——36514日。据《后汉书·律历志》载:“日发其端,周而为岁,然其景不复。四周,千四百六十一日而景复初,是则日行之终。以周除日,得三百六十五四分日之一,为岁之日数。”四分历的回归年长度就是这样观测出来的。从《后汉书》的记载看出,利用圭表测影,不难得到四分历所要求的回归年长度:36514日。\n关于朔望月周期。月相在天,容易观测。从一个满月到下一个满月,就得到一个朔望月的长度。如果经常观测,就会知道一个朔望月的长度比29天半稍长。按照朔望月来安排历日,必然是小月和大月相间,而到一定时间之后,还得安插一个连大月。只有掌握了比较准确的朔望月周期,连大月的设置才会显现出它的规律。从文献上考查,《春秋》所记月朔干支告诉我们,春秋中期以前,连大月的安插并无明显的规律性。在鲁襄公二十一年(公元前552年)的九、十两个连大月以后,除襄公二十四年八、九两个月连大外,其余所有连大月的安插都显示了15个月~17个月有一个连大月的间隔规律。这表明,春秋中期以后,四分历所要求的朔望月长度已为司历者所掌握。\n又,据统计,《春秋》37次日食记载中,宣公以前有15次,记明是朔日的只有6次。鲁成公(公元前590年—公元前573年)以后有22次,记明朔日的竟达21次。由此可见,春秋中期以后,朔日的推算已相当准确。这说明,不仅掌握了比较准确的朔望月长度,日月合朔的时刻也定得比较准确。\n关于十九年七闰的规律。《春秋》所记近三百年(前772年—前479年)史料中,有700多个月名,394个干支日名,37个日食记录。后人据此研究,排定春秋时代的全部历谱。晋杜预有《经传长历》,清王韬有《春秋历学三种》,邹伯奇有《春秋经传日月考》,张冕有《春秋至朔通考》,日人新城新藏有《春秋长历》,张汝舟先生编有《春秋经朔谱》,都是研究春秋史的很好工具。从这些历谱可以看出,鲁文公(前626年—前609年)、宣公(前608年—前591年)以前,冬至大都出现在十二月,置闰无明显规律,大、小月安排是随意的。这以后,置闰已大致符合四分历的要求——十九年七闰,大月小月的安排也比较有规律。在没有掌握较准确的回归年长度以前,只能依据观测天象来安插闰月,随时发现季节与月令发生差异就可随时置闰,无规律可言。如果观测出回归年长度为36514日,根据长期的经验积累,人们自会摸索出一些安置闰月的规律。《说文》释:“闰,余分之月,五岁再闰也。”所谓“三年一闰,五年再闰”,是比较古老的置闰法。十九年七闰是四分历法所要求的调整回归年与朔望月长度的必要条件。从前人的研究成果可看出,春秋中期已掌握了十九年七闰的规律。据王韬、新城氏等人的工作统计,自公元前722年到公元前476年间的置闰情况可以列为一表:\n722—704闰7627—6097532—5147\n703—6856608—5908513—4957\n684—6667589—5717494—4767\n665—6477570—5527\n646—6286551—5337\n从表上可看出,从公元前589年(鲁成公二年)以来,十九年七闰已成规律了。结论是:春秋中期以后,产生四分历的条件已经具备。\n二、《次度》及其意义 # 在《汉书·律历志》中,保存了一份珍贵的史料——《次度》。这是一份古代天象实测记录,包含着丰富的内容,涉及古代天文历法研究中一系列基本问题。现介绍如次。原文:\n星纪。初斗十二度,大雪。中牵牛初,冬至(于夏为十一月,商为十二月,周为正月)。终于婺女七度。\n玄枵。初婺女八度,小雪。中危初,大寒(于夏为十二月,商为正月,周为二月)。终于危十五度。\n娵訾。初危十六度,立春。中营室十四度,惊蛰(今曰雨水。于夏为正月,商为二月,周为三月)。终于奎四度。\n降娄。初奎五度,雨水(今曰惊蛰)。中娄四度,春分(于夏为二月,商为三月,周为四月)。终于胃六度。\n大梁。初胃七度,谷雨(今曰清明)。中昴八度,清明(今曰谷雨。于夏为三月,商为四月,周为五月)。终于毕十一度。\n实沈。初毕十二度,立夏。中井初,小满(于夏为四月,商为五月,周为六月)。终于井十五度。\n鹑首。初井十六度,芒种。中井三十一度,夏至(于夏为五月,商为六月,周为七月)。终于柳八度。\n鹑火。初柳九度,小暑。中张三度,大暑(于夏为六月,商为七月,周为八月)。终于张十七度。\n鹑尾。初张十八度,立秋。中翼十五度,处暑(于夏为七月,商为八月,周为九月)。终于轸十一度。\n寿星。初轸十二度,白露。中角十度,秋分(于夏为八月,商为九月,周为十月)。终于氐四度。\n大火。初氐五度,寒露。中房五度,霜降(于夏为九月,商为十月,周为十一月)。终于尾九度。\n析木。初尾十度,立冬。中箕七度,小雪(于夏为十月,商为十一月,周为十二月)。终于斗十一度。\n首先,《次度》依据二十八宿距度,把日期的变更与星象的变化紧密联系起来,形成了二十八宿与二十四节气、十二月的对应关系。一岁二十四节气与二十八宿一周天正好相应。二十八宿的距度明确,《次度》便以精确的宿度来标志节气,比起《月令》以昏旦中星定节气,无疑更加准确而科学。\n其次,春秋中期以后,十九年七闰已经形成规律,平常年十二个朔望月,逢闰年有十三个朔望月,《次度》以平常年份排列,把十二月与二十四节气相配,实际上构成了阴阳合历的格局。同时,也把置闰与节气联系起来,为“无中气置闰法”创造了条件。若按《次度》的二十四节气继续排列下去,闰月就自有恰当的位置。\n第三,《次度》逐月将当时流行的三正月序附记于后,说明《次度》是三正论盛行时期的产物,它不仅适用于建寅为正之历,也适用于建丑为正、建子为正之历,是当时创制历法的天象依据,不受各国建正、岁首异制的影响。又,惊蛰后注明“今曰雨水”,雨水后注明“今曰惊蛰”;谷雨后注明“今曰清明”,清明后注明“今曰谷雨”,说明《次度》是古代遗留的典籍,节气顺次与汉代的不同,一一注明,可见非汉代人的编造。\n第四,《次度》中“星纪,玄枵……”等十二名,本是岁星纪年十二次用以纪年的专用名称,而《次度》却用来纪月。这一变革有很重要的意义。岁星纪年是春秋中期昙花一现的纪年法,它以木星十二岁绕天一周为周期。实际木星周期11.86年,过八十余年必有明显的岁星超次。所以,岁星纪年法不可能长期使用。《次度》用以纪月,说明《次度》产生于岁星纪年法破产之后,它伴随着一种新型的纪年法出现,标志着纪年方法的根本变革。\n最后,《次度》标明冬至点在牵牛初度,这就等于把它产生的年代告诉了我们。今人研究,冬至起于牛初,与公元前450年左右的天象相符。冬至点在牛初,一岁之末必在斗宿26度之后。斗宿计2614度,正是“斗分”。所以《次度》所记,正是四分历的天象。\n总之,《次度》中二十八宿、二十四节气和十二月的完美结合,概括了观象授时的全部成果,形成了阴阳合历的体制,显示了天文观测的高度水准,提供了创制四分历法的天象依据。可以说,《次度》的产生就预示着历法时代的开始。\n三、四分历产生的年代 # 有了《次度》所记天象和时令作为依据,有了观象实测得来的回归年、朔望月长度和十九年七闰的置闰规律,就可以进而制定历法。从《春秋》所记史料研究得知,四分历法的创制当在春秋后期至战国初期的某个时候。\n四分历究竟是什么时候创制、使用的呢?这个问题始终是古代天文历法史上的一大疑难,争论颇多。根据张汝舟先生的考证,四分历创制于战国初期,于周考王十四年(公元前427年)行用。他有什么主要依据呢?\n1.《次度》所载,“星纪”所记冬至点在牵牛初度,这正是创制四分历的实际天象。星纪者,星之序也。星纪起于牛初,最后当然是斗宿,分数14必在斗宿度数之内,这就是星历家所称之“斗分”。没有斗分便没有四分历,而斗分的概念也专属于四分历,它是编制四分历的基本数据。\n汉初的实际天象是冬至点在建星(见《汉书·律历志》)。建星在南斗尾附近。《后汉书·律历志》记冬至点在斗2114度。据岁差密律,每71年8个月,冬至点西移1度。\n5×7123=358.3年\n古人凭肉眼观察,差1度就差70多年。可以推知《次度》保留的是战国初期的实际天象。前已说过,以科学的数据推知,《次度》所显示的是公元前450年左右的实际天象。\n2.《次度》所载春天三个月的节气,顺次是立春、惊蛰、雨水、春分、谷雨、清明,与汉朝以后迄今未变的节气顺次不同。足证《次度》所记之四分历到汉初已行用了相当长一段时间,才有足够的经验加以改进。\n3.有了“斗分”,定岁实为36514日,以它作基础调配年月日,就能得出一个朔望月(朔策)为29499940日。《历术甲子篇》通篇的大余、小余,就反映了四分历的岁实与朔策的调配关系。那通篇的大余、小余使我们明白,《历术甲子篇》就是司马迁为我们保存下来的中国最早的完整的历法。《历术甲子篇》中“焉逢摄提格”之类的称谓就是干支的别名,全篇取甲寅年为太初元年,以甲子月甲子日夜半冬至合朔为历元,其历元近距是周考王十四年(甲寅)己酉日夜半冬至合朔。据此推演下来,千百年之干支纪年,朔日与余分,一一吻合。这不是偶合,是法则,是规律,足证四分历以公元前427年为历元近距之考证不误。\n4.再以《史记》《汉书》所记汉初实际天象说,汉初“日食在晦”频频出现。四分历的岁实是36514日,与实际天象每年实浮3.06分,由此可以推知四分历的行用至汉代已近三百年左右,才会有“后天一日”的记录。“日食在晦”的反常现象正是四分历的固有误差(三百年而盈一日)造成的。确证公元前427年为四分历行用之年是可信的。通过后面的演算,对汝舟先生的结论更会确信不疑。\n5.《汉书·律历志·世经》说:“元帝初元二年十一月癸亥朔旦冬至,殷历以为甲子,以为纪首。”据此,可以进行如下推算。\n汉元帝初元二年为公元前47年(甲戌),殷历以该年十一月的癸亥朔旦冬至为甲子日朔旦冬至(癸亥先于甲子一日,这是刘歆《三统历》造成的),并以为纪首。按四分历章蔀编制,一纪20蔀共1520年,上一纪首当为1520+47=1567年(甲寅),正与《历术甲子篇》首年干支相合,说明公元前1567年(甲寅)既为纪首年,又为甲子蔀首年,这就是所谓历元,即殷历甲寅元。但是,殷历甲寅元并非产生于公元前1567年。《次度》和汉初日食在晦的天象已经告诉我们,它产生于汉初之前300年左右。这就要求创制殷历的这一年作为制历的首年,应该既是甲寅年(作为历元的标志),又是蔀首年(便于起算),可以用推求一蔀76年与60位干支最小公倍数的方法,推算此年:\n由此可知,殷历甲寅元创制之年是公元前427年,此年为甲寅年,位于殷历第十六蔀首年,在太初改历(公元前104年)之前323年,完全满足上述条件和天象、史实记载的要求,因此可以断定,公元前427年为殷历甲寅元创制行用之年。\n由于纪首公元前1567年年前十一月朔旦冬至从甲子日起算,到公元前427年朔旦冬至并不逢甲子:1140×36514÷60=6939……余45(己酉),而是在甲子之后的45位干支己酉(即第十六蔀蔀余),说明己酉为第十六蔀首日,按照“甲寅岁甲子月甲子日夜半甲子时合朔冬至”的要求,公元前427年显然不配称为历元,故称之为“历元近距”。由此我们可以推知,殷历制造者正是以公元前427年(甲寅)首日己酉为基点,逆推历元公元前1567年(甲寅)首日甲子,进而编排《二十蔀首表》的,而《历术甲子篇》就是殷历甲寅元的推算法规。\n生活于公元前4世纪的孟子曾充满自信地说:“天之高也,星辰之远也,苟求其故,千岁之日至,可坐而致也。”(《孟子·离娄下》)这正是当时人们长期运用四分历法,推算时令节气的真实写照。反之,如果当时还处于观象授时阶段,没有行用历法,那么“千岁之日至”何以“坐而致”呢?\n考证出殷历甲寅元(即《历术甲子篇》)创制于公元前427年,就可以用来推算上古历点,并在推算中验证殷历甲寅元的正确性。\n四、四分历的数据 # 四分历的基本数据是定岁实为36514日,推知朔策为29499940日。因为太阳与月亮运行周期都不是日的整倍数,要调配年、月、日以相谐和,就必须有更大的数据,才能反映这种谐和的周期,这就形成了大于年的计算单位:章、蔀、纪、元。\n一章:19年235月\n一蔀:4章76年940月27759日\n一纪:20蔀1520年\n一元:3纪4560年\n岁实是从冬至到下一个冬至的时日,比较好理解。由于月亮圆缺周期是29日多,12个月6大6小(大月30日,小月29日)才354日,还与岁实差1114日,三年置一闰月还有余,所以远古时候我们祖先就懂得“三年一闰,五年再闰”。四分历明确“十九年七闰”,成为规律,所以19年为一章,共235月。19年中设置7个闰月就能调配一年四季与月亮运行周期大体相合。\n要使月亮运行周期(朔望月)与岁实完全调配无余分,19年还做不到,必须76年才有可能,所以又规定一蔀4章76年计940个月,得36514×76=27759日。若以月数(940)除日数,便得朔策499940日。\n历法必须与干支纪日联系在一起。一蔀之日27759日,干支以60为周期:27759÷60=462……余39(日),这就是蔀余。即一蔀之日不是60干支的整倍数,尚余39日(即39位干支),也就是说,若一蔀首日为甲子日,最后一天即为壬寅日。为了构成日数与干支的完整周期,必须以二十蔀为一个单元:\n27759×20÷60=9253(无余数)\n这就是一纪二十蔀的来由,即一纪起自甲子日,终于癸亥日,是9253个完整的干支周期。据此,可制成二十蔀表:\n汝舟先生在表中立了“蔀余”,很重要:“蔀余”指的是每蔀后列之数字。《历术甲子篇》只代表四分历一元之第一蔀(甲子蔀)七十六年。所余前大余为39(即太初第七十七年前大余三十九),进入第二蔀即为癸卯蔀蔀余。以后每蔀递加39,就得该蔀之蔀余。如果递加结果超过了一甲数60,则减去一甲数。\n一纪二十蔀,共1520年,甲子日夜半冬至合朔又回复一次。但1520年还不是干支60的整倍数,所以一元辖三纪,4560年,才能回复到甲寅年甲子月甲子日甲子时(夜半)冬至合朔。这就是一元三纪的来由。\n如果我们将二十蔀首年与公元年份配合起来,就是下面的关系(见下页)。十六蔀己酉,蔀首年是公元前427年,又是公元1094年(北宋哲宗绍兴元年)。公元1930年乃第七戊午蔀首年,公元2006年乃第八丁酉蔀首年。推知2004年当为戊午蔀第七十五年。\n《历术甲子篇》之所以是四分历之“法”,就在于它将甲子蔀(四分历的第一蔀)七十六年的朔闰一一确定下来,使之规律化;由此一蔀可以推知二十蔀,推知整个一元4560年的朔闰规律。我们读懂了《历术甲子篇》的大余、小余,四分历就算通透明白,就可以应用于对证历点考察史料。\n《历术甲子篇》所载之“太初”,乃四分历历元之太初,非汉武帝之年号太初。“太初”前之一“汉”字,是后人妄加。历代星历家对此早有怀疑,但一直未能找到症结所在,致使这部极为重要的历法著述被视为一张普通的历表,淹没了千百年。\n《历术甲子篇》列出每年前大余、前小余、后大余、后小余。“大余者,日也;小余者,日之分数也。”这个解释是对的。\n前大余是记年前十一月朔在哪一天;\n前小余是记当日合朔时的分数(每日以940分计);\n后大余是记年前冬至在哪一天;\n后小余是记冬至日冬至时的分数(每日四分之,化14为832)。\n如:太初二年前大余五十四\n前小余三百四十八\n后大余五\n后小余八\n前大余指合朔干支,查《一甲数次表》,五十四为戊午;前小余即合朔时刻,在348940分。即,太初二年子月戊午348分合朔。\n后大余指冬至干支,查表,五是己巳;后小余即冬至时刻,在832分即14日(卯时)。即,太初二年子月己巳日卯时冬至。\n五、《历术甲子篇》的编制 # 明白了四分历章蔀编制的内在联系,就可以探讨《历术甲子篇》的编制原理。\n要理解《历术甲子篇》,必须首先澄清两个问题:\n1.《历术甲子篇》是一部历法书,不是一份起自汉太初元年(公元前104年)的编年表。在《史记·历书·历术甲子篇》中,在焉逢摄提格太初元年之后,逐一列举了天汉元年、太始元年等年号、年数,直至汉成帝建始四年(公元前29年),因此有人将《历术甲子篇》认定为汉太初改历后行用的太初历或编年表,这是不正确的。细读《史记》,不难发现其中的谬误。\n清张文虎《史记札记》说:“历术甲子篇:《志疑》云此乃当时历家之书,后人谬附增入‘太初’等年号、年数,其所说仍古四分之法,非邓平、落下闳更定之《太初历》也。”\n日本学者泷川资言《史记会注考证》也说:“太初元年至建始元年年号年数,后人妄增。”\n可见前人对此早有觉察。\n现在可进一步确证,太史公司马迁生于汉景帝中元五年(公元前145年),武帝太初元年(公元前104年)参与改历,是年42岁,之后开始撰写《史记》。天汉三年(公元前98年)因李陵事受宫刑,到太始四年(公元前93年,写《报任安书》时)《史记》一书已成,是年53岁。史家认为自此以后,司马迁事迹已不可考,约卒于武帝末年。倘若司马迁活到汉成帝建始四年(公元前29年),当享年117岁,这是不可能的事。由此可知,混入《历术甲子篇》中的年号、年数,断非出自司马迁的手笔,纯系后人妄加。现在应该删去这些年号、年数,恢复《历术甲子篇》作为历法宝书的本来面目。\n2.《历术甲子篇》虽行用日久,但系皇家宝典,外人难以知道其中的奥秘,所以后世曲解误断者自不可免,如其中“大余者,日也;小余者,月也”一句,便不可解,正因为如此,这样一部历法宝书才被埋没了两千年之久。现经张汝舟教授多年研究考订,终于拭去了历史的尘垢,使它焕发出夺目的光彩。以下随文一一说明。\n《历术甲子篇》浅释:\n[原文]元年,岁名焉逢摄提格,月名毕聚,日得甲子,夜半朔旦冬至。\n正北十二无大余无小余无大余无小余[浅释]所谓“甲子篇”,即20蔀中的第1蔀甲子蔀,蔀首日甲子,干支序号为0。1蔀76年,以下顺次排列朔闰谱。这里虽只列1蔀朔闰法,其他19蔀与之同法同理,所不同者唯蔀余(即蔀首日干支序号)而已。\n“元年,岁名焉逢摄提格。”“元年”即四分历甲子蔀第一年;“岁名焉逢摄提格”即该年名为“甲寅”。此处言“岁名”而不说“岁在”,可知此“岁”字不是岁星之“岁”,而只是指此年,与岁星纪年划清了界线。\n“月名毕聚。”《尔雅·释天》“月在甲日毕”,“正月为陬”。作为历法,是以冬至为起算点,冬至正在夏正十一月(子月),即此历以甲子月(子月)起算。聚与陬、娵相通,从《次度》可知,娵訾为寅月,此处“正月为陬”即以寅月为正月。\n“日得甲子,夜半朔旦冬至。”“日得甲子”即甲子蔀首日为甲子;“夜半朔旦冬至”即这天的夜半子时0点合朔冬至。“旦”字后人妄加,应删。将子、丑等十二辰配二十四小时,子时分初、正,包括23到1点两个小时,那是中古以后的事。\n上文告诉我们,这部历法的第一蔀开始于甲寅岁、甲子月、甲子日夜半子时0点合朔冬至,显然这是一个非常理想的时刻,即所谓“历始冬至,月先建子,时平夜半”(《后汉书·律历志》)。\n“正北。”古人以十二地支配四方,子属正北,卯属正东,午属正南,酉属正西。此年前十一月子时0点合朔冬至,故曰“正北”。\n“十二。”记这一年为十二个月,无闰月,平年;有闰月的年份为“闰十三”。\n“无大余,无小余;无大余,无小余。”“前大余”为年前十一月(子月)朔日干支号,“前小余”为合朔余分(朔余),“后大余”为年前十一月冬至干支号,“后小余”为冬至余分(气余)。此处前、后、大、小余均无,即说明在甲子日夜半子时0点合朔冬至,正与前文相应。\n[原文]端蒙单阏二年十二\n大余五十四小余三百四十八\n大余五小余八\n[浅释]此年乙卯年。端蒙,乙;单阏,卯。\n由前文可知,前大余、前小余与年前十一月合朔有关,属于太阴历系统;后大余、后小余与年前十一月冬至有关,属于太阳历系统,这两者的结合,就是阴阳合历,这就是中国历法的特点。\n前“大余五十四”:如前所述,太阴历一年十二个月,六大六小,30×6+29×6=354(日),354÷60=5……余54(日)。查干支表,54为戊午,即知此年前十一月戊午朔。\n前“小余三百四十八”:按四分历章蔀,一个朔望月为12 日(朔策),一年十二个月,29×12+ ×12=348+6 =354 (日),此处只记分子348,不记分母940。\n换句话说,大月30日-29 日= (日)多用了441分;小月29日,尚余499分,一大一小,499-441=58(分)。一年六大月六小月,58×6=348(分),这就是该年前十一月朔余。\n348分意味着什么?化成今天的小时:\n348/940×24=8.885(小时)\n60×0.885=53.1(分)\n60×0.1=6(秒)\n就是说,该年前十一月戊午日八时五十三分六秒合朔。\n后“大余五”:一个回归年36514日,以60干支除之。36514÷60=6……余514(日),后大余只记冬至日干支号五。查干支表五为己巳,即该年前十一月己巳冬至为十一月十二日(朔为戊午)。\n后“小余八”:后大余已记整数五,尚余14,为运算方便,将分子分母同时扩大四倍,即化14为832,此处只记分子八,不记分母,即为后小余。832×24=6(时),即说明该年前十一月己巳(十二日)六时冬至。\n为什么要化14为832?为了便于推算一年二十四节气。因为当时用平气,冬至已定,其他节气均可推出:\n即每个节气均有15日7/32分之差,从冬至起算,逐一叠加,可以算出每个节气的干支和气余。可见四分历创制者是何等聪明智慧、精研巧思!\n明白了《历术甲子篇》元年、二年的编制,就可逐月排出朔、气干支如下:\n由以上推算可知:\n在推算朔日时,由于大月亏441分,小月盈499分,所以凡朔余大于441分者为大月,小于441分者为小月。因为每两月(一大一小)要盈58分,所以逐月积累,小月朔余大于441分变大月,这就出现所谓“连大月”,如二年之辰月。但二年十二个月仍为六大六小,所以该年总日数并未变。有的年份出现连大月,会使全年十二个月变成七大五小(355日),后面将会遇到。\n在节气推算中,后小余(气余)满32进1位干支。每月中气间相隔30日14分,可逐一叠加推出。如前所述,一个回归年(36514日)大于十二个朔望月(354日)1114日,两年即多出22.5日,所以二年亥月(十月)小雪甲辰,已是该月22日了。到了第三年即多出3334日,必置闰月加以调整。\n[原文]游兆执徐三年闰十三\n大余四十八小余六百九十六\n大余十小余十六\n[浅释]此年丙辰年。\n“前大余”:54(二年前大余)+54(二年日干支余数)\n=108。\n108÷60=1……余48(壬子)\n“前小余”:348(二年前小余)+348(二年朔余)\n=696(分)。\n“后大余”:5(二年后大余)+5(二年气干支余数)=10(甲戌)\n“后小余”:8(二年后小余)+8(二年气余)=16(分)\n即该年前十一月壬子朔甲戌冬至。此为闰年,可排出下列朔闰表:\n由上表可知,未月之后应为大月戊申朔,该月晦日应为丁丑;而未月中气大暑丁未,下一个中气处暑戊寅,后于丁丑一天,不在该月之内,该月只有节气立秋壬戌而无中气处暑,故设闰月,此为“无中气置闰”。古人最初采用过岁末置闰,即闰月设置在岁末,但卜辞中就有闰在岁中的记载,可见闰在岁中和闰在岁末有一个相当漫长的并用时期。一般认为,汉太初(公元前104年)改历后才使用闰在岁中(即无中气置闰法),这是值得进一步研究的。下面在推算历点时再进行讨论。\n其实,后大余减前大余,也能大致判断出该年闰年、闰月的情况:\n后大余10-前大余48=70-48=22\n说明该年冬至已到年前十一月二十三日,该年又有回归年与十二朔望月相差的1114日,说明该年必闰。\n1114÷12=0.9375\n二年22.5+0.9375×8=30\n所以三年从冬至起算的第八月后置闰。\n[原文]强梧大荒落四年十二\n大余十二小余六百三\n大余十五小余二十四\n[浅释]此年丁巳年。\n三年为闰年,七大六小30×7+29×6=384(日)\n[48(三年前大余)+384]÷60=7……余12\n故四年前大余为十二。\n696(三年前小余)+348(三年朔余)+499-940=603(前小余)\n后大余逐年递加五,满六十周而复始;后小余逐年递加八,满三十二进一位。以下同理。\n依此,可排出四年朔、气干支:\n因为四年子月大,而戌、亥两月又连大,全年十二月七大五小,共355天,这是推算五年前大余要注意的。\n[原文]徒维敦牂五年十二\n大余七小余十一\n大余二十一无小余\n[浅释]此年戊午年。\n[12(四年前大余)+355(五年日数)]÷60=6……余7\n603(四年前小余)+348-940=11此为前小余。\n24(四年后小余)+8=32(进一位)\n15(四年后大余)+5+1=21\n此为后大余、后小余。\n[原文]祝犁协洽六年闰十三\n大余一小余三百五十九\n大余二十六小余八\n[浅释]此年己未年。\n[7(五年前大余)+354]÷60=6……余1\n11(五年前小余)+348=359\n此为前大余、前小余。后大余、后小余按常规递加。\n26(后大余)-1(前大余)=2525+1114\u0026gt;30\n说明该年冬至已到十一月二十六日,必须置闰。依此,排出该朔闰表:\n[原文]商横涒滩七年十二\n大余二十五小余二百六十六\n大余三十一小余十六\n[浅释]此年庚申年。\n[1(六年前大余)+384(六年总日数)]÷60=6……余25\n359(六年前小余)+348+499-940=266\n此为前大余、前小余。后大余、后小余如常递加。\n[原文]昭阳作噩八年十二\n大余十九小余六百一十四\n大余三十六小余二十四\n[浅释]此年辛酉年。\n推算如前,该年七大五小共355日。\n[原文]横艾淹茂九年闰十三\n大余十四小余二十二\n大余四十二无小余\n[浅释]此年壬戌年。\n推算如前,该年闰十三,六大七小。共383日。\n[原文]尚章大渊献十年十二\n大余三十七小余八百六十九\n大余四十七小余八\n[浅释]此年癸亥年。\n该年七大五小共355日。\n[原文]焉逢困敦十一年闰十三\n大余三十二小余二百七十七\n大余五十二小余一十六\n[浅释]此年甲子年。\n该年闰十三,七大六小共384日。\n[原文]端蒙赤奋若十二年十二\n大余五十六小余一百八十四\n大余五十七小余二十四\n[浅释]此年乙丑年。\n[原文]游兆摄提格十三年十二\n大余五十小余五百三十二\n大余三无小余\n[浅释]此年丙寅年。\n[原文]强梧单阏十四年闰十三\n大余四十四小余八百八十\n大余八小余八\n[浅释]此年丁卯年。\n该年闰十三,七大六小共384日。\n[原文]徒维执徐十五年十二\n大余八小余七百八十七\n大余十三小余十六\n[浅释]此年戊辰年。\n该年七大五小共355日。\n[原文]祝犁大荒落十六年十二\n大余三小余一百九十五\n大余十八小余二十四\n[浅释]此年己巳年。\n[原文]商横敦十七年闰十三\n大余五十七小余五百四十三\n大余二十四无小余\n[浅释]此年庚午年。\n该年闰十三,七大六小共384日。\n[原文]昭阳协洽十八年十二\n大余二十一小余四百五十\n大余二十九小余八\n[浅释]此年辛未年。\n[原文]横艾涒滩十九年闰十三\n大余十五小余七百九十八\n大余三十四小余十六\n[浅释]此年壬申年。\n该年闰十三,七大六小共384日。\n按四分历章蔀十九年七闰为一章,到此十九年七闰已毕,甲子蔀第一章完。在这一章里第3、6、9、11、14、17、19七年为闰年。\n[原文]尚章作噩二十年正西十二\n大余三十九小余七百五\n大余三十九小余二十四\n[浅释]此年癸酉年。\n如前所述,古人以十二地支配四方,此年前十一月合朔冬至同日同时,正是酉时,故标“正西”。其余推算如常,全年七大五小共355日。\n此年为第二章首年。\n[原文]焉逢淹茂二十一年十二\n大余三十四小余一百一十三\n大余四十五无小余\n[浅释]此年甲戌年。\n[原文]端蒙大渊献二十二年闰十三\n大余二十八小余四百六十一\n大余五十小余八\n[浅释]此年乙亥年。\n该年闰十三,七大六小共384日。\n[原文]游兆困敦二十三年十二\n大余五十二小余三百六十八\n大余五十五小余十六\n[浅释]此年丙子年。\n[原文]强梧赤奋若二十四年十二\n大余四十六小余七百一十六\n无大余小余二十四\n[浅释]此年丁丑年。\n全年七大五小共355日。\n[原文]徒维摄提格二十五年闰十三\n大余四十一小余一百二十四\n大余六无小余\n[浅释]此年戊寅年。\n该年闰十三,七大六小共384日。\n[原文]祝犁单阏二十六年十二\n大余五小余三十一\n大余十一小余八\n[浅释]此年己卯年。\n[原文]商横执徐二十七年十二\n大余五十九小余三百七十九\n大余十六小余十六\n[浅释]此年庚辰年。\n[原文]昭阳大荒落二十八年闰十三\n大余五十三小余七百二十七\n大余二十一小余二十四\n[浅释]此年辛巳年。\n该年闰十三,七大六小共384日。\n[原文]横艾敦二十九年十二\n大余十七小余六百三十四\n大余二十七无小余\n[浅释]此年壬午年。\n该年七大五小共355日。\n[原文]尚章协洽三十年闰十三\n大余十二小余四十二\n大余三十二小余八\n[浅释]此年癸未年。\n该年闰十三,七大六小共383日。\n[原文]焉逢涒滩三十一年十二\n大余三十五小余八百八十九\n大余三十七小余十六\n[浅释]此年甲申年。\n该年七大五小共355日。\n[原文]端蒙作噩三十二年十二\n大余三十小余二百九十七\n大余四十二小余二十四\n[浅释]此年乙酉年。\n[原文]游兆淹茂三十三年闰十三\n大余二十四小余六百四十五\n大余四十八无小余\n[浅释]此年丙戌年。\n该年闰十三,七大六小共384日。\n[原文]强梧大渊献三十四年十二\n大余四十八小余五百五十二\n大余五十三小余八\n[浅释]此年丁亥年。\n[原文]徒维困敦三十五年十二\n大余四十二小余九百\n大余五十八小余十六\n[浅释]此年戊子年。\n该年七大五小共355日。\n[原文]祝犁赤奋若三十六年闰十三\n大余三十七小余三百八\n大余三小余二十四\n[浅释]此年己丑年。\n该年闰十三,七小六大共384日。\n[原文]商横摄提格三十七年十二\n大余一小余二百一十五\n大余九无小余\n[浅释]此年庚寅年。\n[原文]昭阳单阏三十八年闰十三\n大余五十五小余五百六十三\n大余十四小余八\n[浅释]此年辛卯年。\n该年闰十三,七大六小共384日。\n到此第二章十九年七闰完,其中第22、25、28、30、33、36、38七年为闰年。\n[原文]横艾执徐三十九年正南十二\n大余十九小余四百七十\n大余十九小余十六\n[浅释]此年壬辰年。\n此年为第三章首年。年前十一月朔日与冬至同日同时正当午时,故标“正南”。\n[原文]尚章大荒落四十年十二\n大余十三小余八百一十八\n大余二十四小余二十四\n[浅释]此年癸巳年。\n该年七大五小共355日。\n[原文]焉逢敦四十一年闰十三\n大余八小余二百二十六\n大余三十无小余\n[浅释]此年甲午年。\n该年闰十三,七大六小共384日。\n[原文]端蒙协洽四十二年十二\n大余三十二小余一百三十三\n大余三十五小余八\n[浅释]此年乙未年。\n[原文]游兆涒滩四十三年十二\n大余二十六小余四百八十一\n大余四十小余十六\n[浅释]此年丙申年。\n[原文]强梧作噩四十四年闰十三\n大余二十小余八百二十九\n大余四十五小余二十四\n[浅释]此年丁酉年。\n该年闰十三,七大六小共384日。\n[原文]徒维淹茂四十五年十二\n大余四十四小余七百三十六\n大余五十一无小余\n[浅释]此年戊戌年。\n该年七大五小共355日。\n[原文]祝犁大渊献四十六年十二\n大余三十九小余一百四十四\n大余五十六小余八\n[浅释]此年己亥年。\n[原文]商横困敦四十七年闰十三\n大余三十三小余四百九十二\n大余一小余十六\n[浅释]此年庚子年。\n该年闰十三。七大六小共384日。\n[原文]昭阳赤奋若四十八年十二\n大余五十七小余三百九十九\n大余六小余二十四\n[浅释]此年辛丑年。\n[原文]横艾摄提格四十九年闰十三\n大余五十一小余七百四十七\n大余十二无小余\n[浅释]此年壬寅年。\n全年闰十三,七大六小共384日。\n[原文]尚章单阏五十年十二\n大余十五小余六百五十四\n大余十七小余八\n[浅释]此年癸卯年。\n全年七大五小共355日。\n[原文]焉逢执徐五十一年十二\n大余十小余六十二\n大余二十二小余十六\n[浅释]此年甲辰年。\n[原文]端蒙大荒落五十二年闰十三\n大余四小余四百一十\n大余二十七小余二十四\n[浅释]此年乙巳年。\n该年闰十三,七大六小共384日。\n[原文]游兆敦五十三年十二\n大余二十八小余三百一十七\n大余三十三无小余\n[浅释]此年丙午年。\n[原文]强梧协洽五十四年十二\n大余二十二小余六百六十五\n大余三十八小余八\n[浅释]此年丁未年。\n该年七大五小共355日。\n[原文]徒维涒滩五十五年闰十三\n大余十七小余七十三\n大余四十三小余十六\n[浅释]此年戊申年。\n该年闰十三。七小六大共383日。\n[原文]祝犁作噩五十六年十二\n大余四十小余九百二十\n大余四十八小余二十四\n[浅释]此年己酉年。\n[原文]商横淹茂五十七年闰十三\n大余三十五小余三百二十八\n大余五十四无小余\n[浅释]此年庚戌年。\n全年闰十三,七大六小共384日。\n到此第三章十九年七闰完,其中第41、44、47、49、52、55、57七年为闰年。\n[原文]昭阳大渊献五十八年正东十二\n大余五十九小余二百三十五\n大余五十九小余八\n[浅释]此年辛亥年。\n此为第四章首年。该年年前十一月合朔冬至同日同时,正当卯时,故标“正东”。\n[原文]横艾困敦五十九年十二\n大余五十三小余五百八十三\n大余四小余十六\n[浅释]此年壬子年。\n[原文]尚章赤奋若六十年闰十三\n大余四十七小余九百三十一\n大余九小余二十四\n[浅释]此年癸丑年。\n该年闰十三,七大六小共384日。\n[原文]焉逢摄提格六十一年十二\n大余十一小余八百三十八\n大余十五无小余\n[浅释]此年甲寅年。\n全年七大五小共355日。\n[原文]端蒙单阏六十二年十二\n大余六小余二百四十六\n大余二十小余八\n[浅释]此年乙卯年。\n[原文]游兆执徐六十三年闰十三\n无大余小余五百九十四\n大余二十五小余十六\n[浅释]此年丙辰年。\n该年闰十三,七大六小共384日。\n[原文]强梧大荒落六十四年十二\n大余二十四小余五百一\n大余三十小余二十四\n[浅释]此年丁巳年。\n[原文]徒维敦 六十五年十二\n大余十八小余八百四十九\n大余三十六无小余\n[浅释]此年戊午年。\n该年七大五小共355日。\n[原文]祝犁协洽六十六年闰十三\n大余十三小余二百五十七\n大余四十一小余八\n[浅释]此年己未年。\n全年七大六小共384日。\n[原文]商横涒滩六十七年十二\n大余三十七小余一百六十四\n大余四十六小余十六\n[浅释]此年庚申年。\n[原文]昭阳作噩六十八年闰十三\n大余三十一小余五百一十二\n大余五十一小余二十四\n[浅释]此年辛酉年。\n全年七大六小共384日。\n[原文]横艾淹茂六十九年十二\n大余五十五小余四百一十九\n大余五十七无小余\n[浅释]此年壬戌年。\n[原文]尚章大渊献七十年十二\n大余四十九小余七百六十七\n大余二小余八\n[浅释]此年癸亥年。\n全年七大五小共355日。\n[原文]焉逢困敦七十一年闰十三\n大余四十四小余一百七十五\n大余七小余十六\n[浅释]此年甲子年。\n全年闰十三,七大六小共384日。\n[原文]端蒙赤奋若七十二年十二\n大余八小余八十二\n大余十二小余二十四\n[浅释]此年乙丑年。\n[原文]游兆摄提格七十三年十二\n大余二小余四百三十\n大余十八无小余\n[浅释]此年丙寅年。\n[原文]强梧单阏七十四年闰十三\n大余五十六小余七百七十八\n大余二十三小余八\n[浅释]此年丁卯年。\n全年闰十三,七大六小共384日。\n[原文]徒维执徐七十五年十二\n大余二十小余六百八十五\n大余二十八小余十六\n[浅释]此年戊辰年。\n全年七大五小共355日。\n[原文]祝犁大荒落七十六年闰十三\n大余十五小余九十三\n大余三十三小余二十四\n[浅释]此年己巳年。\n全年闰十三,七大六小共384日。\n到此第四章十九年七闰完,其中第60、63、66、68、71、74、76七年为闰年。此年亦是甲子蔀最后一年,到此四分历第一蔀(即甲子蔀)结束,但尚有蔀余三十九,且看下文。\n[原文]商横敦七十七年\n右历书:大余者,日也;小余者,月也。端(旃)蒙者,年名也。支:丑名赤奋若,寅名摄提格。干:丙名游兆。正北(原注:冬至加子时),正西(原注:加酉时),正南(原注:加午时),正东(原注:加卯时)。\n[浅释]此年庚午年。\n此年应为四分历第二蔀(癸卯蔀)首年。原文脱落有误,尤其“大余者,日也;小余者,月也”一句,造成历史误解,使《历术甲子篇》竟成读不懂的天书,现经张汝舟先生考订。原文应为:\n商横敦七十七年正北十二\n大余三十九无小余\n大余三十九无小余\n右历书:大余者,日也;小余者,日之余分也。前大余者,年前十一月朔也;后大余者,年前十一月冬至也。前小余者,合朔加时也;后小余者,冬至加时也。端蒙赤奋若者,年干支名也。支:丑名赤奋若,寅名摄提格;干:丙名游兆。正北:合朔冬至加子时;正西:加酉时;正南:加午时;正东:加卯时。\n因为[15(七十六年前大余)+384(七十六年总日数)]÷60=6……余39(癸卯)\n93(七十六年前小余)+348+499-940=0\n33(七十六年后大余)+5+1=39(癸卯)\n24(七十六年后小余)+8=32(进一位为0)\n说明商横敦七十七年前十一月癸卯日夜半子时0点合朔冬至,这正是四分历第二蔀癸卯蔀的起算点。由此起算,癸卯蔀的朔闰推算完全同于甲子蔀。\n《历术甲子篇》虽然只列了甲子蔀七十六年的大余、小余,并依此推算各年朔闰。其实,其他十九蔀均可照此办理(只需加算蔀余),这是一个有规律的固定周期,所以我们称之为“历法”。由《二十蔀表》和《历术甲子篇》的内部编制,我们深深感到,上古星历家、四分历的创制者的确运筹精密,独具匠心,实在令人惊叹!\n为了运算查阅方便,下面特列出甲子蔀朔日表:\n六、入蔀年的推算 # 《历术甲子篇》只列四分历第一蔀七十六年之大余小余,因为是“法”,是规律,自可以一蔀该二十蔀。\n上节所述《历术甲子篇》内部的编制,可利用朔策及气余推算出甲子蔀太初以下七十六年之朔闰,这当然是基本的。在实际应用时,多涉及史料所记载的年代,只需要以历元近距(公元前427年)为基点进行推算。\n要知某年之朔闰,当先以历元近距前427年为依据,算出该年入二十蔀表中某蔀第几年。入蔀年可用146页表。“年”用《历术甲子篇》之年序,从太初元年至太初七十六年查得该年之前大余再加该蔀蔀余,则得该年子月之朔日干支,其余各月朔闰则按上节推算法即得。\n如,睡虎地秦墓竹简载:“秦王二十年四月丙戌朔丁亥。”\n验证这个历点的办法是:\n秦王政二十年为公元前227年。(须查《中国历史纪年表》,下同。)\n推算:427-227=200(年)(上距前427年200年)\n200÷76=2……余48(算外49)\n前427年为己酉蔀第一年,顺推两蔀进丁卯蔀,知前227年为丁卯蔀第四十九年。丁卯蔀蔀余是3。(见145页表)\n查《历术甲子篇》太初四十九年,大余五十一,小余七百四十七。(见上页朔日表)\n蔀余加前大余3+51=54。\n查147页《一甲数次表》,54为戊午。\n得知,前227年子月戊午747分合朔。\n按月推知,得\n丑月戊子,306\n寅月丁巳,805\n卯月丁亥,364\n辰月丙辰,863\n巳月丙戌,422\n午月乙卯(下略)\n巳月(夏历四月)朔丙戌,丁亥是初二。与出土文物所记吻合。\n又,贾谊《鵩鸟赋》:“单阏之岁兮,四月孟夏,庚子日斜兮,鵩集于舍。”\n“单阏”是“卯”的别名。根据贾谊生活时代推知,卯年即丁卯年。单阏乃“强梧单阏”之省称。这是汉文帝六年公元前174年丁卯年。\n推算:427-174=253(年)\n以蔀法除之253÷76=3……余25\n该年为丙午蔀第26年。(前427年在己酉蔀,己酉蔀之后三蔀即丙午蔀。算外,入第26年。)\n查,《历术甲子篇》太初二十六年,大余五,小余三十一。\n蔀余加前大余42+5=47(辛亥)\n得知,前174年子月辛亥日31分合朔。\n按月推之:\n丑月庚辰,530\n寅月庚戌,89\n卯月己卯,588\n辰月己酉,147\n巳月戊寅,646\n午月戊申(下略)\n巳月(夏历四月)戊寅朔,则二十三日庚子。\n贾谊所记乃汉文帝六年(丁卯年)四月二十三日事。\n七、实际天象的推算 # 四分历的岁实为36514日,与一个回归年的实际长度比较密近而并不相等,由此产生的朔策29日499分也就必然与实测有一定误差。所以,四分历使用日久,势必与实际天象不合。南朝天文学家何承天、祖冲之就已经指出四分历的不精。何承天说:“四分于天,出三百年而盈一日,积代不悟。”祖冲之说:“四分之法,久则后天,以食检之,经三百年辄差一日。”因为\n四分历朔策:29 =29.53085106日\n实测朔策:29.530588日\n每月超过实测:0.00026306日\n十九年七闰计235月,每年余0.0032536日\n1÷0.0032536=307(年)\n即307年辄差一日,每日940分计\n940÷307=3.06(分)\n即四分历每年约浮3.06分。\n如果用《历术甲子篇》的“法”来推演,再加上每年所浮3.06分,上推千百年至西周殷商,下推千百年至21世纪的今天,所得朔闰也能与实际天象密近(区别仅在平朔与定朔,平气与定气而已)。\n因为四分历行用于公元前427年,所以推算前427年之前的实际天象,每年当加3.06分;推算前427年之后的实际天象,每年当减3.06分,简言之,即“前加后减”。一些考古学家不明这个道理,用刘歆之孟统推算西周实际天象,总是与铭器所记不合,总是要发生两三天的误差,根本原因就是没有把每年浮3.06分计算在内,最后不得不以“月相四分”来自圆其说。\n这里举几个例子,用3.06分前加后减,求出实际天象。\n例1.《诗·十月之交》:“十月之交,朔月辛卯,日有食之。”这是一次日食的记载,发生在十月朔日辛卯这一天。前人已考定为周幽王六年事,试以四分历法则为基础求出实际天象验之:\n查,周幽王六年为公元前776年。用前节推算方法,先入蔀入年。知前776年入甲午蔀第32年。\n查《历术甲子篇》太初三十二年:前大余三十,小余二百九十七。\n甲午蔀蔀余是30,30+30=60(即0,即甲子)\n按正常推算,前776年子月甲子日297分合朔。因为四分历先天每年浮3.06分,实际天象应从前427年起每年加3.06分。\n即(776-427)×3.06≈1068(分)\n逢940分进一,得1.128\n1.128+0.297(小余)=1.425(日加日,分加分)\n得知公元前776年实际天象:子月乙丑(1)日425分合朔(平朔)。\n该年每月朔闰据此推算为\n子月乙丑425丑月甲午924\n寅月甲子483卯月甲午 42\n辰月癸亥541巳月癸巳100\n午月壬戌599未月壬辰158\n申月辛酉657酉月辛卯216\n戌月庚申715亥月庚寅274\n西周建丑为正,此年失闰建子,十月(酉)辛卯朔,吻合不误,足证《诗·十月之交》所记确为幽王六年事。\n例2.《史记·晋世家》:“五年春,晋文公欲伐曹,假道于卫,卫人弗许。……三月丙午,晋师入曹……四月戊辰,宋公、齐将、秦将与晋侯次城濮。乙巳,与楚兵合战……甲午,晋师还至衡雍,作王宫于践土。”\n晋文公五年为公元前632年,632-427=205(距前427年年数)\n205÷76=2……余53(年)\n76-53=23(算外24)\n该年入四分历第十三蔀(壬子蔀)第二十四年。\n查《历术甲子篇》太初二十四年:大余四十六,小余七百一十六。\n壬子蔀蔀余48+46(前大余)=94(逢60去之,34戊戌)\n四分历先天205×3.06=627(分)\n34.716+0.627=35.403(日加日,分加分。分数940分进一日。)\n得知,公元前632年实际天象:\n晋、楚用寅正,三月(辰)丁酉朔,丙午为三月初十。四月丁卯朔,戊辰为四月初二,己巳为四月初三,甲午为四月二十八日。历历分明。\n例3:《汉书·五行志》:“高帝三年十月甲戌晦,日有食之。”\n汉高帝三年为公元前204年(丁酉年)\n427-204=223\n223÷76=2……余71(算外72)\n该年入第十八蔀(丁卯蔀)第七十二年。\n查《历术甲子篇》七十二年:大余八,小余八十二。\n蔀余3+8(前大余)=11(乙亥)\n说明该年前十一月(子)乙亥朔。汉承秦制,在太初改历前的汉朝记事,都是起自十月,终于九月。十一月乙亥朔,则十月晦必为甲戌,正与《汉书》所记相同。\n为什么日食会发生在晦日呢?这是年差分造成的。如果求出实际天象,日食在晦就很容易得到解释。\n223×3.06≈682(分)\n11.082-0.682=10.340\n实际天象是,该年十一月(子)甲戌(10)日340分合朔。\n可见,因四分历行用日久,年差分积累过大,才发生日食在晦的反常天象。这从历法推算上显示得清清楚楚。\n例4:推算公元1981年实际天象。\n先看陈垣《二十史朔闰表》所列1981年朔日:\n十一月(子)甲寅十二月(丑)甲申\n正月(寅)甲寅二月(卯)癸未\n三月(辰)癸丑四月(巳)壬午\n五月(午)辛亥六月(未)辛巳\n七月(申)庚戌八月(酉)己卯\n九月(戌)己酉十月(亥)己卯\n十一月(子)戊申十二月(丑)戊寅\n用四分历推算,1981年入戊午蔀第52年。\n查《历术甲子篇》太初五十二年:大余四,小余四百一十。\n戊午蔀蔀余54+4(前大余)=58\n四分历先天(427+1981)×3.06=7369(分)\n7369÷940=7……余789\n“前加后减”,58.410-7.789=50.561(日减日,分减分)\n得知:1981年年前十一月甲寅(50)日561分合朔\n据此推演,1981年各月朔日如次。\n两相对照,所不合者正月、三月、八月,如果考虑到它们的分数,则相差不会超过半天。按四分历算,太初五十二年当是闰年,十三个月,这是据“十九年七闰”的成规;今天的阴历(夏历)置闰已不用旧法,闰在1982年。因为今天的阴历(夏历)早已不用平朔、平气,而使用定朔、定气,所以有上述的差别。\n这样的推算在今天虽然没有什么实用价值,但由此可以证实,如果考虑到年差分,修正四分历的误差,仍可以得出密近的实际天象,以上举例都说明了这一点。\n八、古代历法的置闰 # 世界各国现今通用的阳历,或称公历,是以一个回归年长度为依据的历法。一回归年是365日5小时48分46秒,相当于365.2422日。阳历以365日为一年,每年所余0.2422日,累积四年,大约一天。所以阳历每四年增加一天,加在2月末,得366日,这就是阳历的闰年。四年加一天又比回归年实际长度多了44分56秒,积满128年左右,就又多算一天,相当于400年中约多算三天。因此,阳历置闰规定,除公元年数可以4整除的算闰年外,公元世纪的整数,须用400来整除的才算闰年,这就巧妙地在四百年中减去了三天。这就是阳历的置闰。我国的农历,又称“阴历”,主要依据朔望月(月亮绕地球周期),同时兼顾回归年,实质上是一种阴阳合历。朔望月(从朔到朔或从望到望)周期是29.5306日。农历一年十二个月,一般六大六小,只有354日,比一个回归年少11.2422日。不到三年必须加一月,才能使朔望月与回归年相适应。这是用置闰办法来调整回归年与朔望月,使月份与季节大体吻合。中国古代历法的频繁改革,主要内容之一就是调配回归年与朔望月的长度,使之相等。简单地说,就是调整闰周,确定多少年置一闰月。《左传·文公六年》:“闰月不告朔,非礼也。闰以正时,时以作事,事以厚生,生民之道,于是乎在矣,不告朔闰,弃时政也,何以为民?”大意是说,置闰的目的是定季节,定季节的目的是干农活。君王的职责之一就是公告闰朔。如果违背这种制度,怎么治理百姓?\n由于置闰是人为的操作,历代对闰月的安排也就很不相同。已发现的殷墟卜辞中,武丁卜辞多有“十三月”的记载,祖庚、祖甲时代又有“多八月”“冬八月”“冬六月”“冬五月”和“冬十三月”的刻辞。“多”即“闰”,“冬”即“终”也就是“后”的意思。所以“多八月”“冬六月”即“后八月”“后六月”,也是“闰八月”“闰六月”的意思。“冬十三月”即“闰十三月”。卜辞里面,还有“十四月”的记载,古历称之为“再闰”,就是一年置两个闰月。殷周金文里面,“十四月”刻辞并不鲜见。周《金雝公缄鼎》“隹十又四月,既生霸壬午”,就是例子。到春秋时代,这种一年再置闰的情况就没有了。\n闰十三月,就是年终置闰。闰六月、闰八月,算是年中置闰。这说明古历置闰并无规律,这与回归年和朔望月的调配没有找到规律有关。在年、月、日的调配无“法”可依,没有找到规律之前,都是观象授时的时代。观象,主要是观天象,观察日月星辰的运动变化规律。比如昏旦中星的变化和北斗斗柄所指的方向,以此作为置闰的依据。因为是肉眼观测,不可能精确,只能随时观测随时置闰,所以古历多有失闰的记载。多置一闰,子正就成了丑正,少置一闰,丑正就成了子正。据《春秋》经传考证,到春秋中期古人就大体掌握了十九年七闰的方法。\n为什么十九年要置七闰呢?因为春秋中期之后,根据圭表测影的方法已初步掌握了一回归年的长度为36514日。有了这个数据,年、月、日的调配就有了可能。\n四分历由36514日推出朔望月长度(朔策)为29 日。\n十九年中要有235个朔望月才能与十九个回归年日数大体相等。即\n19×365.25≈235×29\n而一年十二个月的话,19×12=228(月),必须加七个闰月才能达到目的。这就是十九年七闰的来源。\n东汉许慎《说文》云:“闰,余分之月,五岁再闰也。告朔之礼,天子居宗庙,闰月居门中,从王在门中。周礼,闰月王居门中终月也。”此处“五岁再闰”就是五个回归年中要置两个闰月。“三年一闰,五年再闰”,这是较古老的方法。\n365.25×5\u0026lt;354×5+60\n1826.25\u0026lt;1830\n五年之中竟有近4天的差误,根本无法持久使用。十九年七闰的规律掌握以后,置闰就有“法”可依了。四分历规定十九年为一章,这个“章”法,就是反映置闰规律的。\n古法有“归余于终”之说,是将闰月放在年终,方便易行。春秋战国时代大多如此。齐鲁建子,闰在亥月后。晋楚建寅,闰在丑月后。秦历以十月为岁首,闰在岁末,称“后九月”。汉初一仍秦法,直至汉武帝太初改历,才改闰在岁末为无中气置闰。这个无中气置闰原则就一直行用到现在,只不过当今对中气的计算更细致更精确罢了。这就是《汉书·律历志》所谓“朔不得中,是为闰月”,闰月设置在没有中气的月份。\n《礼记·月令》注疏者说:“中数曰岁,朔数曰年。中数者,谓十二月中气一周,总三百六十五日四分之一,谓之一岁。朔数者,谓十二月之朔一周,总三百五十四日,谓之为年。”这里把岁与年区分得很清楚:岁是二十四节气(其中十二中气)组成的回归年,是太阳历;年是十二个朔望月组成的太阴年,是太阴历。中国农历是阴阳历系统,必须反映二十四节气和朔望月的配合关系。\n根据张汝舟先生的研究,中国最早的历法就是战国初期创制,行用于周考王十四年(公元前427年)的殷历(称“天正甲寅元”的四分历)。《史记·历术甲子篇》就是这一部历法的文字记录,《汉书·次度》是它的天象依据。《历术甲子篇》所列七十六年前大余,就是年前十一月(子月)朔日干支序数,前小余就是十一月合朔的分数;后大余是冬至日干支序数,后小余是冬至时分数。不难看出,前大余小余是记录朔望月的朔日的,是太阴历系统;后大余、小余是记录冬至(中气)干支及余分的,反映回归年长度,属太阳历系统。两相调配,《历术甲子篇》就是中国最早的一部阴阳合历的历法宝典。\n汉武帝太初改历,一改“归余于终”的古法,行无中气置闰。所谓中气,是指从冬至开始的二十四节气中逢单数的节气。依照《汉书·次度》记载,这十二节气正处于相应宫次的中点(冬至为星纪次中点,大寒为玄枵次中点,惊蛰——汉以后称雨水,是娵訾次中点……),故称中气。其他十二节气,则在各次的初始(星纪之初为大雪,玄枵之初为小雪,娵訾之初为立春……),如竹之结节,故仍称节气。\n因为一个朔望月(29.5306日)比两个中气之间的时间(365.2512日)距离要短约一天,如果从历法计算的起点算,过32个月之后这个差数积就会超过一个月,就会出现一个没有中气的月份,本应在这个月的中气便推移到下个月去了。若不置闰,后面的中气都要迟出一个月。长期下去,各个月份和天象、物象、气象的相对关系就要错乱。三次不置闰,春季就会出现冰天雪地的景象,深秋还是烈日炎炎,历法就失去指导农业生产的意义了。\n中国最早的历法——殷历,即《历术甲子篇》的无中气置闰与今天农历的无中气置闰大不相同。殷历用平朔平气,春夏秋冬一年十二个月均可置闰。从清代“时宪历”起用定气注历,至今未变,闰月多在夏至前后几个月,冬至前后(秋分到次年春分之间)则无闰月。这是因为春分到秋分间太阳视运动要经186天,而从秋分到春分间却只需要179天。日子一短,则节气间相距的日子就短,所以不宜设置闰月。\n我们知道,四分历的岁实是36514日,而平年六大六小,只用去了354日,每年尚余1114日。由于历元是取冬至日合朔,1114必是冬至(气)之余,也称“气余”。按每年余1114日计算,两年则余2224日,经三年则余3334日,就是说3334日之内无中气,所以第三个年头上就必须置闰了。这就是四分历安排置闰的依据。\n气余1114日每年递加,并无困难。由于第三年置闰,又有连大月,全年达384日,比平年(354日)多了30日。所以\n354+3334-384=334(日)\n334日为第三年实际气余。\n第四年再加1114,得15日,由于第四年七大五小,为355日,比平年多1日,所以\n15-1或354+15-355=14(日)\n14日便是第四年气余。\n根据这个办法我们可以将一蔀七十六年各年气余推算出来,也就可以据此考虑闰在某月了。\n前面说过,平年六大六小,每年气余1114日。若将它用一年十二个月平分,则每月气余0.9375日。这样以上年气余为基数,从该年子月开始逐月递加0.9375日,到某月超过30日或29日(小月),便知某月之后是置闰之月。\n如第三年当闰,以上年气余2224日为基数,从子月起逐月递加0.9375日,到第八个月便超过30日了。所以四分历是在第八个月之后置一闰月,夏历用寅正,从子月算起到第八个月,则闰六月。又如第十九年当闰,便以上年气余1912日为基数,从子月起逐月递加0.9375,到第十二个月超过了30日,便在此月之后置闰。\n《历术甲子篇》是通过后大余/后小余反映二十四节气的。后大余是冬至的干支代号,后小余是冬至时的分数。这个小余的分母是32(分),与前小余分数的分母是940(分)不同。为什么要化1/4为8/32?这是便于推算一年二十四节气。因为四分历是平气,冬至一定,其他节气便可逐一推出。\n即两个节气相距15日7分。分母化为32,才会除尽有余分7。从冬至日算起,顺次累加,可以算出一年二十四个节气的干支和气余。\n《历术甲子篇》只列出太初七十六年每年冬至干支及余分,我们可以据此排出七十六年各月的朔、气干支及余分。两个中气相距30日14分,置闰之“法”就反映在朔(前大余)与中气(后大余)的关系上。\n由于朔策数据是29499940,逢小月小余499分,逢大月小余减441分,中气的大小余推演从冬至起每月累加30日14分。\n由于《历术甲子篇》已列出每年年前十一月(子月)朔日及冬至的大小余,便可以从每年的十一月(子月)做起算点推演每月朔日与中气,我们以太初三年作推演示范。\n《历术甲子篇》的后大余是冬至日干支,二十四节气由此推演还好理解,太初三年置闰也很明确,只是从何知道必在六月(未月)之后置闰呢?\n这个闰六月是前大余四十四(戊申朔)与处暑十四(戊寅处暑)的关系确定下来的,戊申朔,处暑戊寅必在下月,则此月无中气,依无中气之月置闰的原则,闰在六月后就可以肯定了。\n所以说,《历术甲子篇》通篇的大余、小余有极其丰富的内容,二十四节气可由此推演,无中气置闰规则也包含其中。\n无中气置闰还有另一种推算方法。一岁36514日,以12除,得302148日。即两中气间隔302148日,上月中气加30日21分,得本月中气。到中气日期超过29或30,小月亦应置闰,中气就在下月初了。《殷历朔闰中气表》就是这样编制的,它的特点是,中气日期不用干支序数而用一月内日的序数,这就与蔀余不发生关系而自成系统了。\n这就是魏晋以前中国古代历法置闰的全部内容。\n九、殷历朔闰中气表 # 中国最早的历法,前人有所谓“古六历”之说——黄帝历、颛顼历、夏历、殷历、周历、鲁历,近人以为都是四分历数据。其实,“古六历”是东汉人的附会。汉代盛传所谓“天正甲寅元”与“人正乙卯元”,其间也有承继关系,人正乙卯元的颛顼历实是天正甲寅元的殷历的变种。所以,中国最早的历法就是天正甲寅元的殷历,就是以寅为正的真夏历假殷历,也就是四分历。历法产生之前,包括“岁星纪年”在内,都还是观象授时阶段。进入“法”的时代,就意味着年、月、日的调配有了可能,也有了规律,由此可以求得密近的实际天象——这是一切历法生命力之所在。\n根据张汝舟先生的苦心研究,《史记·历书·历术甲子篇》就是司马迁为我们保存下来的殷历历法,《汉书·次度》就是殷历历法的天象依据。利用这两篇宝贵资料,可以诠释上古若干天文历法问题。并推算出文献记载的以及出土文物中的若干历点。\n《历术甲子篇》记载了历元太初第一蔀七十六年的子月朔日及合朔分数(前大余、前小余)和冬至日及冬至时分数(后大余、后小余),即公元前1567年至公元前1492年的子月朔日、冬至日及余分。由于是“法”,自可以一蔀该二十蔀,贯通四分历法的古今。\n要推算任何一年的朔与气,必须将该年纳入殷历的某蔀第几年。“蔀”用“殷历二十蔀表”,“年”用《历术甲子篇》之年序。查得该年之前大余,加上该蔀蔀余,就得出该年子月之朔日干支。使用《殷历朔闰中气表》(见书后299页附表二)求各月朔日干支,就更为便捷,只要某月大余加上该年入蔀之蔀余就得该月之朔日干支。《殷历朔闰中气表》已将各年十二中气算出,中气依《次度》立名。表中“惊蛰”即汉以后之“雨水”,表中“清明”即汉以后之“谷雨”。\n由于四分历粗疏,“三百年辄差一日”,每年比实际天象约浮3.06分(940分进位)。要求出实际天象,必须考虑3.06这个年差分。张汝舟先生考订,殷历创制行用于周考王十四年(公元前427年)。所以必须以公元前427年(入己酉蔀元年)为准,前加后减。即前427年之前每年加3.06分,前427年之后每年减3.06分,方能得出密近的实际天象。\n殷历甲寅元一经创制行用,就成为中华民族的共同财富通行于当时各国,所不同者唯岁首和建正而已。四分历法则在当时是不可改变的,认为“战国时代各国历法不同”,没有充分根据。\n公元前427年以前的年份,虽未行用有规则的历法,但朔望在天,有目共睹,加以干支纪日延续不断,历代不紊,构成了历法推算的基础,只要年代确凿,考虑到建正、岁首、置闰等方面情况的不同,仍然可以用四分历推算。第六讲四分历的应用\n"},{"id":157,"href":"/zh/docs/culture/%E5%8F%A4%E4%BB%A3%E5%A4%A9%E6%96%87%E5%8E%86%E6%B3%95%E8%AE%B2%E5%BA%A7/%E7%AC%AC1%E8%AE%B2-%E7%AC%AC3%E8%AE%B2/","title":"第1讲-第3讲","section":"古代天文历法讲座","content":" 第一讲为什么要了解古天文历法 # 我国是世界上文明古国之一,先民出于农事需要,积累了丰富的天文学知识。随着文明的进化,这些丰富的天文学知识,必然反映到记载古代文化的书籍典册之中,遗留于后世。出土的殷商时代甲骨刻辞早就有了某些星宿名称和日食、月食记载。《周易》《尚书》《诗经》《春秋》《国语》《左传》《吕氏春秋》《礼记》《尔雅》《淮南子》等书更有大量的详略不同的星宿记载和天象叙述。《史记·天官书》《汉书·天文志》更是古天文学的专门之作。文史工作者随时接触古代典籍,势必常与古代天文历法打交道。如果对此一知半解或不甚了了,很难谈得上进行深入的研究。就是一般爱好文史的青年,有一定的古天文学知识,对阅读古书也是大有帮助的。\n常识告诉我们,一切与古代典籍有关的学科,无不与时间的记载,也就是古代天文历法有关。清人汪日桢说:“读史而考及于月日干支,小事也,然亦难事也。欲知月日,必求朔闰;欲求朔闰,必明推步……盖其事甚小,为之则难。不知推步者,欲为之而不能为;知推步者,能为之而不屑为也。”(见《历代长术辑要》载《二十四史月日考序目》)可见,古人深知“推步”的重要和“推步”的甘苦。白寿彝教授也指出:“关于时间的记载,是历史记载必要的构成部分,年代学的研究是历史文献学研究的主要课题。”(《人民日报》,1980年12月30日)\n当今的现状是,有关古天文之学众说纷纭,头绪繁杂,令人不知从何下手,欲读不能。一般著述往往博大疏浅,叙史而已,或者演算繁难,玄秘莫测,“不把金针度与人”。读者终书,竟无法找到打开古天文历法大门的钥匙,未免望之兴叹,视为畏途。此篇以基本的天文常识入手,依据本师张汝舟先生星历观点,深入浅出,意欲将古籍中需要涉及的古天文学问题,逐一展开讨论,希望能对校读古籍有所助益,且能由一般文史工作者自行独立推演年月日时,掌握一套基本的“推步”技术,为深入的研究打下扎实的基础。\n一、时间与天文历法 # 中国古代,合天文历法为一事,历法以天象为依据,历法属于实用天文学的重要内容。所以,中国古代文学与年、月、日、时这些时间观念紧密相依。学习古代天文学,就从认识“时间”这个概念开头吧!\n中央人民广播电台每日整点都发出“嘟——嘟——”的时间讯号,以此统一全国民用时间。全国各行各业都按这个统一的标准时间学习和工作。没有统一的时间观念,一切工作都无法正常进行,社会将发生混乱。可知,人类社会对于时间的首要要求,就是有统一的计量标准,不能各自为政,自行其是。远古时代,人类分为若干互不交往的群体,各有自己的一套计时方法。随着社会的进步,交流的频繁,彼此认识到生活在地球这个大家庭里,还必须有统一的国际标准时间来协调全人类的活动,才能促进社会的更大发展。\n在古代,人们对于时间的精确度要求不高,最早是把一天分为朝、午、昏、夜四个时段,后来又分为十个时段、十二个时段,也就大体够用了。随着生产力的发展,要求时间的精确度越来越高。现代科学技术,更要求计量时间不能有一秒的误差。测定人造卫星的位置,如果误差1秒,就有7~8公里的差距。精密的电子工业,无线电技术,运输通讯,卫星、导弹的发射,要求的精确度都很高。因此,现代生活要求有精确的统一的时间计量标准,指导全人类的生产劳动。\n时间不是人的主观臆造。时间是客观存在的与物质运动紧密相连的一种物质存在的形式。人们只能依据物质的运动来规定时间,寻找计时的单位。\n我国古代,先民以太阳东升西落确定一天的时间,单位是日;以月亮的隐现圆缺定一月的时间,单位是月;以寒来暑往及草木禾稼的荣枯定一年的时间,单位是年。远古时代人们的时间计量单位之所以仍有作用,今天还在指导着人们的活动,就在于完全符合人类对时间计量方法的基本要求:既承认时间是物质存在的形式,又以有规律的、匀速的、周而复始的运动形式作为计量标准。这种从不间断的、匀速的、重复出现的物质运动形式,在人们的周围是存在着的,这就是日月星辰的出没所组成的若干天文现象。时间计量单位的确定完全以天象为依据,就是这个道理。尽管上古先民长期坚持“地心说”,认为日月星辰都在围绕着地球转动,但这种周而复始的物质运动形式却是古今一致的。\n在所有的计时单位中,人们把地球自转一周作为计时的最基本单位——日,古人认为是太阳东升西落绕了地球一圈。月、年是比日更大的计时单位。时辰、小时、刻、分、秒,是比日小的计时单位。时、分是日的分数,古人称为日之余分。\n明确了时间的计量单位,还有一个时段和时刻的问题。换句话说,通常所谓“时间”,包含着两个含义:一是指某一瞬间,即古人所谓“时刻”;一是指两个瞬时之间隔,即一个有始有终的长度。从时刻的含义出发,时间有早迟之分。从时段的含义出发,时间有长久与短暂之别。历法中的节气与节气的交替(交节),月亮运行在太阳、地球之间的平面上成一直线的天象(合朔),日与日的交接(夜半0点整)等都应该是指时刻而言,十分确切,具体到某时几分几秒的那一瞬间,毫无含糊。月亮最圆的时间,与合朔时间一样只有那么一瞬时。差一秒还不是最圆,过一秒也不可能最圆。电台报时的“嘟——嘟——”那最后特殊一响,就是时刻概念的具体化。而平常所说的几分、几小时、几日,都是指的一个时段,它必有一个起算时刻。计时的基本单位——日,是从夜半0点起算的,止于24点整。任何一个更长的时段,比如百年、千年,都必须明确它的起算时刻。任何历法都很强调它的起算点,都希望找一个理想的起算时刻作为它的初始,这就是历法之“元”,称“历元”。\n我们的先民,十分重视时间,特别是与农事有关的天时,古籍中记载特多。其实,古人的“天时”,是指一年四季包括风、雨、雷、电等直接关系农事活动的自然现象,古人认为这些是上天主宰的,所以称为“天时”。\n《孟子》云:“不违农时,谷不可胜食也。”\n《荀子》云:“春耕、夏耘、秋收、冬藏,四时不失时,故五谷不绝而百姓有余食也。”\n《韩非子》云:“非天时,虽十尧不能冬生一穗。”\n《吕氏春秋》有:“夫稼,为之者人也,生之者地也,养之者天也。是故得时之稼兴,失时之稼约。”\n《齐民要术》有:“顺天时,量地利,则用力少而成功多,任情返道,劳而无获。”\n《农书》有:“力不失时,则食不困。……故知时为上,知土次之。”\n这些典籍中所谓“时”“天时”,实际是指关系农事成败的气候。气候的变动,与时令的推移有关,也直接与天象关联着,所以也应视为古代天文历法的内容。\n《说文解字》云:“时,四时也。”指的是春夏秋冬四季。据吴泽先生的研究,在殷墟甲骨文中,已出现春夏秋冬四字。春字字形像枝木条达的形状;夏字字形一像草木繁茂之状,一像蝉形,蝉是夏虫,被认为是夏的象征;秋字像果实累累,谷物成熟,正是收获之时;冬字则形如把谷物藏于仓廪之中。这四个字,都与农业有关。春种、夏长、秋收、冬藏,季节、时令都同农事密切相关。\n时间,关系到人类社会的政治、生产、生活等各方面的活动。自古以来,我们的祖先就十分重视年、月、日、时的安排,创制了多种多样的历法;对各项活动发生的年、月、日、时也做了大量的准确记录,保存在浩如烟海的典籍之中。古史古事就靠这些年、月、日、时的记载有了一个清晰的脉络,我们据此研究古代人类社会生活的各个方面。如果没有年、月、日、时的记载,众多的典籍史料就成了一堆杂乱无章的文字记录,其价值也就可想而知。中国古代大量珍贵史料就是靠年、月、日、时的记载而保存下来的。我们还可以用后代的历法依据古籍中年、月、日、时的记载推演出当时的实际天象,解决历史上若干悬而未决的年代问题。如果没有关于时间的文字记载,这种推算也就无法进行。\n二、天文与历法 # 什么是天文?什么是历法?这是首先应该弄清楚的问题。\n《说文》云“文,错画也。象交文”,又说“仰则观象于天”。高诱注《淮南子·天文训》说:“文者象也。天先垂文象日月五星及彗孛,皆谓以谴告一人。故曰天文。”王逸注《楚辞》“象”字云“法也”。《易·系辞》:“天垂象见吉凶,圣人则之。”可见,天文就是天象,就是天法,就是日月星辰在天幕呈现的有规律的运动形式。它不以人的意志为转移,反而影响着支配着人类的各种活动。正因为这样,远古的人就视之为神圣,把天象看成是上帝、上天给人的吉凶预兆,敬若神明。历代君王重视天文,因为它是上天意旨的体现,它直接关系着人类的生产、生活,影响帝王统治权力的基础。\n繁体曆法之曆,最早的写法是秝,后写作、厤,再后写作曆。《玉篇》曰:“稀疏秝秝然。”段玉裁以为:“从二禾,禾之疏密有章也。”《说文》释:“厤,治也。”“,和也。”《释诂》释:“厤,数也。”从这些释义看,就是均匀调治之义。从二禾,禾的生长受日月星辰运行的天象支配,即受日月运行所确定的季节的支配,所以秝、厤与天象有关。\n秝,古书写作,表示人在有庄稼的地里行走,引申为日月运行及日月运行所确定的季节、时令等时间计量。首先,这种运行是有规律的,“疏密有章”;其次,还需要调治,要均匀地调治,使日月运行的时日彼此协调。所以,秝就是均匀地调治天象所显示的年、月、日、时等计量时间单位的手段。\n《史记·历书》以厤为推步学,以象为占验学,把两者的区别说得清清楚楚。占验,当然指天象,指上天通过天象显示给人们的吉凶预兆。推步,就是对日月星辰,主要是日月的运行时间进行计算,使日绕地球一圈所形成的寒暑交替与月绕地球一圈所呈现的圆缺隐现彼此配合得大体一致。这就是制历,也就是推步学。\n历是什么,简单说就是计量年、月、日的方法,就是年、月、日的安排。这种安排、计量的依据是天象变化的规律,是依据日月星辰有规律的运行来确定年、月、日、时和四季、节气,或者说推算天象以定岁时。作为一种纪时系统,目的只能是服务于人类的生产生活。\n一般将历法之“法”,解释为制历的方法。不对。这个“法”,正如语法之“法”,指法则、规律。远古时代的夏商周,当然有它的年月日安排的方法,虽然还比较粗疏,但还有它那时的“历”以指导人的社会生产活动。这种历是否成“法”呢?如果确定一年为“三百有六旬有六日”(《尧典》),是不可能有规律地调配年月日的,还形不成“法”。只有到春秋中期以后,测量出一回归年为36514日,到战国初期创制、行用四分历,才可能有“法”可依,才称得上有了历法。有历法之前,都是根据天象的观测,调整年月日,随时观测,随时调整,这还是观象授时的时代。到了有“法”可依的时代,就有可能将天象的数据抽象化,就有可能依据日月星辰运行的规律,通过演算,上推千百年,下推千百年,考求、预定年、月、日、时。我国最早的一部历法——四分历,就具备了这种条件。 可见,历与历法不能混为一谈。什么是历法呢?历法就是利用天象的变化规律调配年、月、日、时的一种纪时法则。\n历法与天象那么紧密不可分,正是我国古代历法独具的特点。在我国古代,历法就包含在古天文学之中,历法是古代天文学中一个很重要的领域。历法的普遍内容包括节气的安排,一年中月的安排,一月中日的安排以及闰月安插规则,等等。我国古代历法还有关于日食、月食的预报和五大行星运行的推算。总之,离开天文就无所谓历法,历法反映了大量的天文现象,历法中有丰富的天文学内容,历法就是古天文学的一个部分。我国古代合天文、历法为一事,就是这个道理。同样的原因,古人称天文历法为历算、星算、天算、星历……总是将天文、历法合在一起加以表述。\n历法的内容,一部分属于实用天文学的范围,另一部分属于理论天文学的范围。测时与制历就是天文学为生产服务的主要工作。我国古代历法重视对天象的推算,不仅反映了对天文学的重视,也常常以此来考核历法的准确性。古代历法史上的多次改革,其直接原因之一就是由于日食等天象的预推出现了差误。从一定程度上来说,我国古代的编历工作,也就是一种编算天文年历的工作。由此可见,我国古代天文学家何等重视实践与理论的结合。\n正因为这样,当我们谈到古代天文学,那实际已经包括了古代历法的内容。\n三、天文常识 # 人类社会各个民族生活的地域不同,星象与季节的相应关系也不同,但是用天象定岁时都是共同的。古代埃及人重视观测天狼星,因为每年天狼星与太阳一起升起的时候,就预示着尼罗河要泛滥,而尼罗河泛滥带来的肥沃土壤,正是埃及人播种的需要。我国上古的夏朝,重视参宿三星的观察,每年三星昏见西方,就意味着春耕季节的开始,参宿就成了夏族主祭祀的星了。晚起的商族,着重观察黄昏现于东方地平线上的亮星,看中了心宿三星,最亮的心宿二就是“大火”。大火昏见东方,也正是春耕季节播种的日子。大火就成了商族主祭祀的星。所以《公羊传·昭公十七年》载:“大火为大辰,伐为大辰,北极亦为大辰。”何休《公羊解诂》云:“大火谓心星,伐为参星。大火与伐,所以示民时之早晚。”这里所谓“大辰”,就是观察天象的标准星,均指恒星而言。大火为大辰,是就商代而言;伐为大辰,是就夏朝而言;北极亦为大辰,当指以北极星为观察天象的标准的更古时代。于此可见,我国上古对于北极星的认识,起源更早。\n现代天文学知识告诉我们,在太阳系里有水星、金星、地球、火星、木星、土星、天王星、海王星共八大行星围绕着太阳,按照各自的轨道和速度运行着。——古人凭肉眼观测,以地球为中心,早就认识了五大行星(金、木、水、火、土)并了解到它们绕地球一圈的时间,掌握了它们的运行规律。\n地球绕太阳公转的同时,还在自转。公转一周为365.24219日,自转一周为24小时。由于地球自转轨道与公转轨道有23°26′的倾斜角,地球表面受到太阳照射的程度不同(直射或斜射,斜射还有角度的不同),便有了春夏秋冬四季冷暖的变化。\n月球是地球的卫星,它围绕着地球旋转,运行一周为29.53059日,月球本身不发光,人们所见到的月相是月球对太阳光的反射。随着地球、月球与太阳相互位置的变化,月相也周期性变化着。当月亮的背光面对着地球,人们看不到有光的月面,即为朔日(阴历初一);当月亮的受光面全部对着地球,人们看到一轮满月,即为望日(阴历十五)。从朔日到望日,望日到朔日之间还有各种月相。人们根据月相变化和月亮出没时间,便知道阴历的日期。俗话说:“初三初四蛾眉月,初七初八月半边,十五十六月团圆。”这种以月相变化为依据,从朔到朔或从望到望的周期长度,叫朔望月,就是阴历的一个月。\n每一个朔望月,月球都要行经地球和太阳之间的空间一次,如果大体在一个平面上,月球遮住了太阳射向地球的光线,就会发生日食;当地球运行到太阳和月球中间(每月有一次机会),如果大体在一个平面上,地球就会挡住太阳射向月球的光线,就要发生月食。因此,日食总是发生在朔日,月食总是发生在望日。古人特别重视日食的记载,认为是上天对君主的警告,是凶兆。古代天文学家还以日食检验历法的准确性,食不在朔,便据以调历。\n前人是怎样以地球为中心表述日食、月食这些天象的?我们用曾运乾先生《尚书正读》注文来回答这个问题,至少可以给我们一些启发。注云:当朔而日为月所掩,是为日食。当望而月为日所冲,是为月食。又说,古人制字,“朔”“望”“有”均从月得义。朔字从月从屰(屰,不顺也)。月与日同经度而不同纬度,则相屰而为合朔。若同经度而又同纬度,则相屰而为日食。望,为月食专字。从月从壬(壬,朝廷也),取日月相对望也。从亡,遇食则有亡象焉。有,为日食专字。从月,月光蔽其明也。从又,一指蔽前,泰山不见也。则知日月食之由于蔽也。《说文》:“有,不宜有也。春秋传曰,日月又食之。从月又声。”段氏注云:“谓本是不当有而有之称,引申遂为凡有之称。”\n古代先民只是直观地以地球为中心来观测天体的运行,这就是西方科学未传入中国之前我国古代长期行用的地心说。日月星辰的东升西落,实际是因为地球从西向东在转动。这种地心说并非全无道理。比如上和下,是一种比较的说法。在地球上的上与下,其实都是在和地球中心比较,拿地球中心做标准来比较是有道理的。舍此,就无所谓上与下。同样,国际通用的标准时自有好处,而各个地方时更为各地的使用者称便。道理都一样,地心说对观测者似更方便。古人想象,地球四周被巨大的天球包围着,所有的日月星辰都在天球上运行。太阳系八大行星,古人凭肉眼观测,以地球为中心,只能见到金、木、水、火、土五大行星,并掌握了它们各自绕地球一圈的时间及运行规律,记之甚详。古代典籍关于天象的记载,立足于地心说。古代星图、天球仪之类也据此成象。阅读古籍者不可不知。\n四、历的种类 # 人类对天象进行观测以确定计时标准,其中观测的主要对象是日、月的运行,依据日、月的运行周期以制定各自的历法。迄今为止,世界上的历法可分为三类:太阴历、太阳历和阴阳合历。\n甲,太阴历。它是以月球受光面的圆缺晦明变动为基础,利用月球运行周期(朔望月)为标准制定的历法。月亮运行的周期是29.53日,太阴历就用大月(30日)、小月(29日)相间,一大一小来调整。因为每两月有0.06日盈余,还需要配置连大月才能保证月初必朔,月中必望。太阴历以十二个朔望月为一年计算,共354日或355日。它把月相与日期固定地联系在一起,见月相而知日期,知日期亦知月相。这在上古,无疑给人们的生产和生活带来方便。其致命的弱点是,十二个朔望月(平年354日)与太阳的运行周期(即回归年长度365.2422日)不相吻合,太阴历每年与回归年有11日多的时差,积三年就相差34日。这就必将搅乱月份与回归年长度确定的春夏秋冬四季的关系,冷暖四季与月份的关系错乱,又会给人们的生产、生活带来困难。\n从古代历史记载得知,世界上最早制历的国家都首先使用过太阴历,因为月球的盈亏变化对人类而言较为明显而又亲切。上古时代,日苦其短,年嫌其长,月的周期最能适应宗教仪式的需要,朔望月自然就占有了重要的地位。\n伊斯兰教用于祭祀节日的回回历就是现存的唯一纯太阴历。回历以公元622年7月16日,即穆罕默德避难麦加的次日为元年元日,以朔望月计,十二月为一年,每月以月牙初见为第一日,单月30日,双月29日,大月小月相间,全年354日,不置闰月。由于十二个朔望月共354日8时48分34秒,每年多出8小时有余,积三年就多出一天有余。所以,回历每三十年共置十一个闰日。在三十年中,第2、5、7、10、13、16、18、21、24、26、29年为闰年,每年355日,闰日放在十二月。\n由于太阴历和回归年的日差,回历的岁首和节日(如肉孜节、古尔邦节)寒暑不定,便是可以理解的了。\n陈垣先生《二十史朔闰表》附有回历与公元历、阴历的日期对照,便于检查。\n乙,太阳历。它是以太阳的回归年周期为基本数据制定的历法。欧洲太阳历是古罗马恺撒在公元前46年请埃及天文学家索西琴尼斯协助制定的,世称“儒略历”或“旧太阳历”。当时测得的回归年长度为36514日。因此,儒略历规定,每四年中前三年为平年365日,第四年为闰年366日,即逢四或逢四的倍数的年份为闰年。一年十二个月,单月为大月31天,双月为小月30天。起自3月,终于2月,与月相完全无关。因为罗马帝国每年2月(年终)处决犯人,视为不吉,所以减去一日,平年只有29日,闰年为30日。又因为恺撒养子屋大维(奥古斯都)生于8月(小月),又从2月减一日加到8月,变8月小为8月大(31日)。这样,2月即为28日(闰年为29日)。为了避免由于2月小、8月大而造成的7月、8月、9月三个月连大,又改为7月、8月连大,9月、11月为小月,10月、12月为大月。这都是人为的规定。\n公元325年,罗马帝国召开宗教会议,决定统一采用儒略历,并依据当时的天文观测,定3月21日为春分日。\n回归年长度为365.24219日,即365日5时48分46秒。而儒略历是以36514日,即365日6时为数据制定的。两者有11分14秒之差,长期积累就会形成明显误差(128年差1日),这在当时并不为人所知。到公元1582年,人们发现春分点竟在3月11日,与公元325年的春分点相差十日之多,即1258年间(325—1582)间差十日,相当于每400年误差3日。为此,罗马教皇格里高利十三世只好召集学者研究,改革儒略历,采取每400年取消3闰(即400年97闰)的方法,规定把1582年10月4日以后的一天算为1582年10月15日,所有百位数以上的年数能被400除尽者才能算闰年(如1600年,2000年)。这样,一方面纠正了儒略历的误差,另一方面又提高了太阳历的精度。改革以后的儒略历称为格里历,其精确度很高:\n365×400+97=146097(日)\n146097÷400=365.2425(日)\n格里历这个回归年长度365.2425日比现代实测回归年长度只有0.0003日(即近26秒)之差,积累3320年才会有一日的误差。这对日用历来说,已是十分精确的了。\n我国元代郭守敬至元十八年(公元1281年)制定的“授时历”,其回归年长度已达到365.2425日的精确度,比格里历早了三百年。\n当今世界通用公元纪年,共同使用的就是格里历。而公元纪年并不开始于公元元年,而是开始于公元532年(据说基督就诞生在公元532年之前,532年正是我国南朝梁武帝中大通四年)。这是出于宗教的考虑。因为532这个数字正是星期日数7、闰年周期4和所谓月周(即一定历日的时间地球上看到月面形状变化的周期)19(年)的最小公倍数。每过532年,基督教的节日(比如复活节)又会是同一日期、星期和月相。因此,公元532年之前的公元纪年都是后来逆推而定的。\n太阳历以回归年周期为依据,四季与月份的关系稳定。中国古历形成的二十四节气就比较固定地配合在太阳历的一些日子里。\n埃及人在远古时代曾一度使用太阴历,后来因为尼罗河涨水对生产影响极大,需要预报涨水时期,而尼罗河水涨和夏至是在天狼星出现的第一天早晨同时来到。古埃及人知道太阳在天球上的运行与尼罗河的洪水期有关,所以特别注意太阳在一年中各时期的高度,以及日出、日没时间和方位。同时还精密地观测了天狼星及南河三等恒星的周年运动,发现了太阳运行周期——回归年长度为36514日。在公元前2000多年古埃及人就制定了以365日为一年的太阳历,而放弃了太阴历的使用。\n格里历所代表的太阳历也有不便之处,一是每月天数不统一,二是完全排除了月相周期。因此,历法研究者曾提出不少改革格里历的方案,有代表性的方案有两种。\n第一种方案。把一个回归年分为十三个月,每月28日,四个星期,唯独第十三个月为29日(闰年30日)。年终可多休息几日,很便于掌握。这种方案的缺点是无法安排习惯上常用的春夏秋冬四季。\n第二种方案。每年分四个季度,十二个月。每季度第一月为31日,其余两月各30日,共91日,一季度十三周。每季度的头一天为星期日,每季度最后一天是星期六。上半年和下半年各为182日。四个季度加起来364日,剩下一日安排在年末,不列入星期,也不列入日期名称,算做国际新年休息日。闰年多出的另一天安排在6月30日之后,作为休息日,也不列入星期和日期名称。这是1923年国际联盟在日内瓦设立的“修订历法委员会”提出来的方案。几十年来,已有比较一致的肯定意见。这个方案比现行日历优越,不但大月、小月、星期有规律,而且每年十二个月可以平分、三等分、四等分和六等分,便于计划、统计和比较。\n丙,阴阳合历。太阳历仅注重太阳的运行(实际是地球运行所产生的视动),完全与月球的运行无关;而太阴历则只注意月亮的运行,不涉及太阳的回归年长度,这就使得四季变化没有一定的时间,于生产十分不便。于是又有了折中办法,即阴阳合历。所谓折中,就是将太阳历与太阴历结合起来制历,用设置闰月或用其他计算法以调和四季,使季节能近于天时,便利农事。阴阳合历既照顾月相周期,又符合四季变化。\n我国上古自有文字记载以来,一直使用阴阳合历,这正是中华民族“文明”的标志,也正是我们要探讨的主要问题。因为回归年、朔望月和计时的基本单位——日,始终不是整倍数的关系,年与月无法公约。如何调整年、月、日的计量关系,便是提高阴阳合历精度的关键,也是我国千百年来频繁改历的主要原因之一。\n世界上几个文明古国,在上古时代使用的历法,比如希腊历、犹太历、巴比伦历、印度历以及我们的中国古历,可以说都属于阴阳合历的范围。\n以上是就一般历法分类说的。此外,还有一些特殊的历法。比如非洲古国埃塞俄比亚的纪年法与计时法,就与世界通用的不同。埃塞历一年为十三个月,前十二月每月30天,第十三个月平年为5天,闰年为6天。埃塞历的新年在公历的9月11日。埃塞历比公历纪年迟7年8个月10天。埃塞历的计时法,以每天早晨的6点为0时,每天也是24小时。\n此外,还有信奉佛教的国家缅甸,它的历法以开始出现月亮到形成满月之间的时日算做一个月。也就是说,一个月只有两个星期,一年有二十四个月。这个“月”,自然与朔望月不是一回事。\n埃塞历也好,缅历也好,都只能划归太阳历一类,因为它的“年”是符合回归年长度的。\n五、古天文学与星占 # 原始社会时期,生产力十分低下,面对风雨雷电等各种无法解释的自然现象,初民都视之为“神”,认为那是上天的旨意。在没有完全认识自然规律之前的蒙昧时代,以预卜吉凶祸福为目的的星占神学就得到迅速发展,并控制着初民的整个思想领域。天幕上的日食、月食,五大行星的运行,流星、彗星、极光、新星等天象被看作是上天给人的启示或警告。这些天象的发生,也就为星相家所详细记录。在星占学盛行的时代,天文学自然是它的附庸,并成为一种保密的学问,变成支持星占神学的皇室的专有品,由皇家的专门机构如钦天监等把持,甚至规定不准私习天文。我国历代编写的《天文志》,除了讲述星区的划分,解释对宇宙的看法外,充斥了大量的星占学内容,道理就在这里。正因为这样,要了解天意,要利用将要发生的天象作出吉凶祸福的准确预报,就必须对天象作大量的观测、研究,以掌握它的运行变化规律。\n中国古代星占家,不仅观察日月五星的运行,而且还计算它们的运行周期,决定年、月、日、时。这样,客观上就为制历提供了数据。所以,著名的星占家往往就是有成就的天文学家,也就不足为怪了。司马迁《史记》以历为推步学,以象为占验学,正反映了天文与星占的关系。\n据《魏书·崔浩传》载,北魏拓跋氏天文学家崔浩,作过一次被认为“非他人所及”的神占。原文是:\n姚兴(后秦政权)死之前岁(公元415年姚兴死)也,太史奏:荧惑在匏瓜星中,一夜忽然亡失,不知所在。或谓下入危亡之国,将为童谣妖言,而后行其灾祸。太宗(拓跋嗣,明元帝)闻之,大惊。乃召诸硕儒十数人,令与史官求其所诣。浩对曰:“案《春秋左氏传》说,神降于莘,其至之日,各以其物祭也。请以日辰推之,庚午之夕,辛未之朔,天有阴云,荧惑之亡,当在此二日内。庚之与未,皆主于秦,辛为西夷。今姚兴据成阳,是荧惑入秦矣。”诸人皆作色曰:“天上失星,人安能知其所诣,而妄说无徵之言。”浩笑而不应。后八十余日,荧惑果出于东井,留守盘旋。秦中大旱,赤地,昆明池水竭。童谣讹言,国内喧扰。明年,姚兴死,二子交兵,三年国灭。于是诸人服曰:非所及也。\n从天文学的角度看,崔浩不过根据荧惑(火星)的顺、留、逆行,预报了一次火星的运动而已。以星占说附会之,就带上了极其神秘的色彩。\n实际的天象观测受到星占学的束缚,作为实用天文学的古代历法也一样不能摆脱星占学的桎梏。历法的基本数据与理论,都必须符合星占学的意识,星占家都要对之做出他所需要的歪曲解释。如汉代行用过的八十一分法,这不过是四分历法的一种简化形式,它是取朔望月 日为月法,并无神秘之处。在星占盛行的汉代,其解说却令人头晕目眩。刘歆说:\n元始有象一也,春秋二也,三统三也,四时四也,合而为十,成五体。以五乘十,大衍之数也,而道据其一,其余四十九所当用也。故筮以为数,以象两两之,又以象三三之,又以象四四之。又归奇象闰十九,及所据一,加之。因以再扐两之,是为月法之实。如日法得一,则一月之日数也。\n把这段神乎其神的文字用数学公式写出来就是\n星占术的依据是阴阳五行说。阴阳五行说可分为阴阳说和五行说两种,但五行说必含阴阳,而阴阳必含五行。阴阳说以阴阳二气的相对势力为天地万物生成的基础。五行说是以木、火、土、金、水五种物质形式作为构成天地及各种变化规律的基础。阴阳五行说在古代发展为指导人类行为的基本原理,联系着政治、军事、农业、星象乃至伦理、艺术、宗教等各个领域,几乎成了各种学科的总枢,到汉代尤为盛行。董仲舒治公羊之学,刘向治穀梁之学,刘歆治左传,都以阴阳五行为说。《史记·天官书》云:“天有五星,地有五行。”是以地上的五种物质形式相配天上的五颗行星,仅有对照的含义。《汉书》首创《五行志》以日食星象说灾异,算是阴阳五行说渗透天文学的一次总结。其余医卜星相,无不以之为原则。\n《淮南子·天文训》把天地万物,日月星辰的起源,都以阴阳五行说作解释:“宇宙生气,气有涯垠。清阳者,薄靡而为天;重浊者,凝滞而为地。……天地之袭精为阴阳,阴阳之专精为四时,四时之散精为万物。积阳之热气生火,火气之精者为日。积阴之寒气为水,水气之精者为月。日月之淫为精者为星辰。天受日月星辰,地受水潦尘埃。”据此,日叫太阳,月叫太阴,也就明白了。星辰是从日月溢出的气的结合物,则行星与众星不过是阳精与阴精的不同量的结合罢了,这如同“四时之散精为万物”一样。\n《淮南子》以后,对五行的运用更为广泛,五帝、五方、五色、五音、五味、五脏、五数、五器……凡以五为一组的事物都配以五行。就是四时、四方、四相,也牵合为五时、五方、五兽(《天官书》四相加黄龙),以顺应五行之说(见下页表),足见五行说之无孔不入。\n天有五星,地有五行,人有五德(仁、义、礼、智、信)。天、地、人三界是彼此影响、相互关联着的。天上的木星有了异象,地上的木和人心的仁都会有异象发生;天上的土星有了异象,地上的土及人心的礼都会产生变异。星占术就以此为基础解说天象,预卜人世祸福。\n星占术以肉眼能见的五大行星为主要观测对象,五星在古代又各有不同的名称,这些不同的称号又来源于长期的实际观测。\n木星。古称岁星,春秋时代曾用以纪岁。古人已知木星十二年运行一周天,一年行一次,故有岁星纪年之说。\n火星。名荧惑,因为它的光度常有变化,顺行逆行使人迷惑,难以掌握。\n土星。名镇星,古人以为它二十八年运行一周天,一年行一宿,如同二十八宿坐镇天上。\n金星。名太白,因为它光辉夺目,是天球上最明最白的一颗星。\n水星。又名辰星,因为它距太阳最近,相距不及一辰。\n五星的名称,足以反映古人对行星观测的精细及勤劬。古人又观察到木星色青,火星色赤,土星色黄,金星色白,水星色灰,便以五色配五行,这不能不说是天文学的发展丰富了阴阳五行说的内容。\n阴阳五行说,起源于殷代,盛行于汉魏,流传至唐宋,宋代理学家谈性理,奉阴阳五行说为金科玉律。影响及今,中医理论及占卜原理都以阴阳五行为说。\n要之,阴阳五行说是星占的依据,星占与古天文学又密切难分。要想对古天文学有正确的认识,不能不对阴阳五行说有个大致的了解。\n由于阴阳五行说的泛滥,天干地支也蒙上五行说的尘埃,要通读古籍就不可不知它们的关系。\n五行配干支(纳音五行):\n甲子乙丑海中金,丙寅丁卯炉中火。\n戊辰己巳大林木,庚午辛未路旁土。\n壬申癸酉剑锋金,甲戌乙亥山头火。\n丙子丁丑涧下水,戊寅己卯城头土。\n庚辰辛巳白蜡金,壬午癸未杨柳木。\n甲申乙酉泉中水,丙戌丁亥屋上土。\n戊子己丑霹雳火,庚寅辛卯松柏木。\n壬辰癸巳长流水,甲午乙未沙中金。\n丙申丁酉山下火,戊戌己亥平地木。\n庚子辛丑壁上土,壬寅癸卯金泊金。\n甲辰乙巳覆灯火,丙午丁未天河水。\n戊申己酉大驿土,庚戌辛亥钗钏金。\n壬子癸丑桑柏木,甲寅乙卯大溪水。\n丙辰丁巳沙中金,戊午己未天上火。\n庚申辛酉石榴木,壬戌癸亥大海水。\n六、古代天文学在阅读古籍中的作用 # 古代天文历法在研究古代科技史、古代历史、文物考古等方面均有实用意义,这里谈谈它在指导我们阅读古代典籍中的作用。\n清儒有言,不通声韵训诂,不懂天文历法,不能读古书。粗看起来有点夸大其词,细加思忖,不无道理。\n我国是世界文明古国之一,也最早进入人类社会的农牧业时代,对与农耕生活紧密相关的天文学的研究,自然源远流长。先民在这方面积累了极其丰富的知识,并将它广泛地应用到社会生活的各个领域,这在古代典籍中得到了充分的反映。明末大学者顾炎武在《日知录》卷三十里说:\n三代以上,人人皆知天文。“七月流火”,农夫之辞也。“三星在户”,妇人之语也。“月离于毕”,戍卒之作也。“龙尾伏辰”,儿童之谣也。后世文人学士,有问之而茫然不知者。\n古代典籍是古代社会生活的真实记录或艺术化的再现,这就必然要涉及古代天文历法的内容。古代大量的神话传说、民间故事,都源于古人的天文学知识。先民世代相传,不断丰富,一经文人的妙笔加工,就成为我国古代文化遗产的重要组成部分。\n跂彼织女,终日七襄。虽则七襄,不成报章。睆彼牵牛,不以服箱。东有启明,西有长庚。有捄天毕,载施之行。维南有箕,不可以簸扬。维北有斗,不可以挹酒浆。维南有箕,载翕其舌。维北有斗,西柄之揭。(《诗·小雅·大东》)\n诗人在这里运用了“织女、牵牛、启明、长庚、天毕、箕、北斗”等星象,巧积成文,反复歌咏,生动形象地表达了深沉幽思的感情。\n昔高辛氏有二子,伯曰阏伯,季曰实沈,居于旷林,不相能也。日寻干戈,以相征讨。后帝不臧,迁阏伯于商丘,主辰(主祀大火),商人是因,故辰为商星(即心宿);迁实沈于大夏(晋阳),主参(主祀参星),唐人是因……故参为晋星。由是观之,则实沈参神也。(《左传·昭公元年》)\n这是一个影响深远的历史故事:传说中的帝王高辛氏有二子——阏伯、实沈,彼此不和,争斗不已。高辛氏迁阏伯于商丘主商,迁实沈于西方大夏主参,彼出则此没,解决了兄弟间的矛盾。故事虽具有浓郁的神话色彩,却有可靠的天象依据。参宿与商(心宿),一个东升,一个西落,永不相见。因此,后世便以参商喻兄弟不和或久违难见。曹植《与吴质书》:“面有逸景之速,别有参商之阔。”陆机《为顾彦先赠妇诗》:“形影参商乖,音息旷不达。”王勃《七夕赋》:“谓河汉之无浪,似参商之永年。”杜甫《赠卫八处士》更有名句:“人生不相见,动如参与商。”诸多用典,即由此而来。\n古代关于牛郎织女的传说,关于嫦娥奔月的神话,《庄子》中傅说死精神托于箕尾的文字,《列子》中两小儿辩日的记载……无一不与日月星辰永无休止的运转相关。\n如果说上面的举例还属于虚幻不实,出于艺术加工,有牵强附会之嫌的话,那么关于记人记事,确不可疑的实例在古代典籍中也比比皆是。\n帝高阳之苗裔兮,朕皇考曰伯庸。摄提贞于孟陬兮,惟庚寅吾以降。(屈原《离骚》)\n这是自叙家世,自报出生年月日的写实文字,无半点虚浮。这又该如何理解?用什么方法推算年月日才算可靠?千百年来众说纷纭,文史界至今尚在探讨。仿屈原用同一手法记年月日的,还有贾谊《鵩鸟赋》:“单阏之岁兮,四月孟夏,庚子日斜兮,鵩集于舍。”这也要具有古天文学知识才能理解。\n七月流火,九月授衣。一之日觱发,二之日栗烈,无衣无褐,何以卒岁?三之日于耜,四之日举趾。同我妇子,饁彼南亩,田畯至喜。(《诗·七月》)\n这是《诗经》中的名篇,人尽皆知的农事诗。农事必与月份、季节有关,诗中的纪月就标志着用历。而这里的“七月”“九月”的时令与后世的农历(夏历)并不一致,一般注释家认为这是周历与夏历并用。在夏历解释不通的地方,说是用的周历;在周历无法诠释之处,说是用的夏历。这种理解显然不合常理,绝不可能有一首诗兼用两种历。有人认为《七月》属“豳风”,用的是一种古拙的豳历。那又实在缺乏依据,是“大胆假设”的变种了。如果我们将诗中涉及的天象、气象、物象和农事记载,与《夏小正》《月令》《淮南子》等古籍中有关的文字比较,《七月》的用历也就迎刃而解了。\n其次,《吕氏春秋·序意》“维秦八年,岁在涒滩,秋甲子朔。朔之日,良人请问十二纪”的纪年月日,《诗经·十月之交》“十月之交,朔月辛卯,日有食之”所歌咏的中国文献可靠的日食记载,近代以来出土文物中诸多的干支纪日原文,都是不能用想象、用夸张的感情去理解的。\n一句话,要解决这些疑难,要读懂古代典籍,古代天文历法知识是不可或缺的。\n为了更好地说明问题,我们举几个常见又常被误解或忽视的例子,以引起大家对古天文学知识的高度重视。\n第一例。有名的汉乐府民歌《陌上桑》:“日出东南隅,照我秦氏楼。秦氏有好女,自名为罗敷。罗敷喜蚕桑,采桑城南隅。”\n这首一句,文学家的理解一般是不错的,就是“日出东南方”。而有的训诂家为了证明词的偏义,认为只有“日出东方”,没有日出东南方,“东南”义偏在东,“南”字虚拟。认为这是与“便可白公姥”“我有亲父兄”同例。“白公姥”,刘兰芝有姥无公,偏义在姥;“亲父兄”,刘兰芝有兄无父,义偏在兄。抠字眼的训诂家这些刻板的理解,当然会受到文艺评论家的讥笑。这毕竟是文学作品呀!而文艺家的认识“日出东南方”是否就尽善尽美了呢?从古天文学角度看,还有深进一层的必要。\n“日出东南隅”,这是初春的天象。时令规律是,日南至——冬至之后,太阳北回,大地逐渐返春。地处黄河流域的古人眼里,初春季节,太阳从东南方升,从西南方向落。《淮南子》称“天有四维”,“日冬至,出东南维,入西南维……夏至,出东北维,入西北维”。冬至之后春分之前这一段时间,太阳不从正东而从东偏南方向出来。这种观察太阳升起和落山位置以定季节的办法在《山海经》中也有记载。《山海经·大荒东经》就记载了六座日出之山,《山海经·大荒山经》里记载了六座日入之山。六座日出之山,六座日入之山,两两成对。说明古人对不同季节不同月份太阳出山入山时在不同的方位已有了清晰的认识。六座日出之山,从东北到东南,相当于太阳从夏至到下一个夏至往返一次,即一年十二个月太阳出入的不同方位。日出东南隅,这正是初春的天象。\n为什么一开始写一个初春的天象呢?这不仅写罗敷喜蚕桑,初春里就养蚕了,那么勤劳,也衬托了少女罗敷的美丽。虽然诗的后面对罗敷的美有大量描写,但一开头就放她在初春的环境里活动,无异于告诉读者,少女罗敷就如同初春般的含苞待放,如同初春般的令人可亲可近。这个开头就不是一般的交代时令了。\n接着还有“采桑城南隅”一句,这仍是写初春的天象。不到城东、城北、城西采桑,而采桑城南隅。因为初春天,阳气始回,草木萌动,太阳总是从南向照来,靠南的枝、芽、叶、果,总是先生先发,利于率先采摘。一旦阳春三月,就是阴山背后的草木也已蓬勃生机了。所以,“采桑城南隅”也不能简单地理解为在城南采桑而已,仍是以初春嫩桑初发在写罗敷的勤劳与美丽。\n第二例。苏轼有一首词,《江城子·密州出猎》,内中有一句:“会挽雕弓如满月,西北望,射天狼。”注释家对“雕弓”的理解是:弓臂上刻镂花纹的弓。这样的理解有违作者之初志。苏轼在这里以天象入词,指北兵入侵之时,自己虽不能临阵退敌,仍不失慷慨意气。\n天狼星在南天,一等大星,很明亮。古埃及人凭天狼星预报尼罗河水的上涨。《史记·天官书》载:“狼比地有犬星,曰南极老人。”杜甫诗《泊松滋江亭》“今宵南极外,甘作老人星”就是指此。天狼星靠近南极老人,当在南天。如果按注释家的意见,“雕弓”真的指弓,指弓臂上刻花的弓,则只能是西南望或南望了,与“西北望”正相反。其实,“雕弓”也指星官,即天弓“弧矢”星。《史记·天官书》:“弧九星,在狼东南,天之弓也。以伐叛怀远,又主备盗贼之知奸邪者。”《晋书·天文志》:“狼一星,在东井南,为野将,主侵掠。”又:“弧九星,在狼东南,天弓也,主备盗贼,常向于狼。”不难看出,天弓即弧矢,就是对付天狼的。(见上图)\n弧矢、天狼并用,古诗中早有。《楚辞·九歌·东君》:“青云衣兮白霓裳,举长矢兮射天狼;操吾弧兮反沦降,援北斗兮酌桂浆。”《增补事类赋·星象》:“阙邱三水纷汤汤,引弧矢兮射天狼。”注引《天皇会通》:“狼主相侵盗贼也。弧,天弓也,常属矢拟射于狼。”白居易诗《答箭镞》:“寄言控弦者,愿君少留听:何不向西射?西天有天狼;何不向东射?东海有长鲸。”取意亦同。苏词以雕弓这一艺术形象取代弧矢,也是天弓、天狼并用,就是挽弧矢射天狼。\n天狼隐喻辽,而不是西夏。这不是实地的西北望,不必看成实指在西北方的西夏,这是天象上的西北望,得从天象上申说。《宋史·天文志》引武密语:“天弓张,北兵起。”苏词作于宋神宗熙宁八年,当时侵宋的正是北兵辽。《宋史·天文志》载:“弧矢九星,在狼东南,天弓也。……流星入,北兵起,屠城杀将。”同书《流陨》篇记:“(熙宁)八年十月乙未,星出弧矢西北,如杯,东南缓行,至烛没,青白,有尾迹,照地明。”这是苏轼写词的当年有关流星的记载。因为历代天文志都与星占术有密切关系,流星的记载正应验外兵的入侵。可见,当时天象指北兵,即辽兵入侵。正因为这样,苏轼的《江城子·密州出猎》为什么不可以看做是对抗辽兵入侵的战斗演习呢?无疑,这是一首充满豪迈气概的诗篇。\n第三例。关于《周易》丰卦“日中见斗”的理解。列为群经之首的《周易》,历来是被当作卜筮之书看待的。一般将“丰卦”之六二、九四爻辞“丰其蔀,日中见斗”理解为:大房子用草盖房顶,白天能见到北斗星。(见李镜池先生《周易通义》)这是就《周易》卦爻辞的字面意义分别解说的。\n《天文学报》1979年第四期的一篇文章认为,“日中见斗”及九三“日中见沫”两条筮辞就是古代太阳黑子记录的一种表达形式。文章首先肯定这是两条天象记载,进一步肯定是太阳黑子的记录。作者认为,最迟到公元前800年,《周易》成书的时代(李镜池说),中国已有了关于太阳黑子的明文记载,这是世界上最早的记录。\n如果我们用古代天文学常识并结合考据学方法来研究这两条爻辞,就可以得出不同的结论。\n“日中见斗”的“日中”,《周易》经文已无从找到内证,而与《周易》大体同时代的《尚书·尧典》,有“日中星鸟,以殷仲春”这一条观象授时的记录,所谓“日中”是指春分时节,是春天的天象记录。如果这样理解,“日中见斗”的“见”读xian(现),是指春天夜晚北斗现。爻辞“丰其蔀,日中见斗”,“丰其沛,日中见沫”是说,春天来了,满天星斗,北斗最显眼,要观察星象,就在原野上搭个棚,棚顶盖上草。这反映出远古时代初民的穴居生活以及初民对星象的重视。\n这样理解,就与“白天见到北斗星”完全不同,也与“最早的太阳黑子记录”不相干。而哪一种解说最接近事理?请大家自行判断吧。不过,有一点应该注意,解读《周易》中的天象记录,务必参照《尚书·尧典》的文字,才能得其真谛。\n第四例。《左传》关于阏伯、实沈故事的意义。《诗·唐风·绸缪》“绸缪束薪,三星在天”,“绸缪束刍,三星在隅”,“绸缪束楚,三星在户”,历代注诗者对“三星”理解各不相同。有注“三星”为“心宿”的(如朱熹《集传》);有人说第一章指参宿三星,第二章指心宿三星,第三章指河鼓三星(朱文鑫),似不可从;毛传以三星为参宿三星。王力先生主编《古代汉语》面对诸家之说,认为“那要看诗人做诗的时令了”。实际上没有任何结论。\n前引《左传·昭公元年》高辛氏“迁阏伯于商丘,主辰。商人是因,故辰为商星。迁实沈于大夏,主参,唐人是因”。实沈是传说中夏氏族的始祖,以参宿为族星,大夏正是夏代的古都。夏为商汤所灭,其地称为“唐”。《左传·定公四年》记“封唐叔于夏墟”,成王姬诵封其弟于此,称唐叔虞,就是晋国的始祖。\n《唐风》是晋人的民歌,歌颂参宿,以示不忘先祖,不忘本源,也反映了早就灭亡了的夏民族的观星习俗。春秋时代的晋国采用以寅为正的夏历,战国时代韩、赵、魏仍袭其旧。时至今日,山西临汾地区还有观参宿的习俗,称参三星为“三晋”,可见大夏民族的流风余韵,影响何其深远!\n《左传》所记阏伯、实沈之争是上古时代夏商两族长期征战不休的形象化的反映。商族胜了夏族,商族始祖阏伯被尊为老大,夏族始祖实沈就只能屈居老二了。不难看出,《左传》有关文字已打上了商代文化的烙印。\n所以《唐风》所记”三星”是指参宿三星,毛传的解说是可信的。\n明确了夏商两族观测天象各有不同的习惯,选择不同的标准星宿,可以断定,《左传》“阏伯、实沈之争”的记载是商代文化的遗迹。还可以推知,十二地支(子丑寅卯辰巳午未申酉戌亥)当起源于传说中的夏代。因为十二地支之首的“子”字,最早用“崽”字,甲骨文崽作,郑文光同志以为“子”(崽)字是从代表夏族的参星图形衍化而来,觜宿三星形似三根小辫。(见上图)\n第五例。历法与《红楼梦》研究。有人说《红楼梦》是一部奇书,是一部封建社会末期的所谓“百科全书”,充满了许多特异的记载。例如第二十七回,写“宝钗扑蝶”“黛玉葬花”,这也是全书的重要情节之一。书上明白写着,那一天是四月二十六日,交芒种节。按照风俗习惯,芒种这天要摆设各种礼物,祭饯花神。因为芒种一过,便是夏日了,众花皆谢,花神退位,需要饯行。这个芒种节与《红楼梦》,与曹雪芹有什么关系?根据历法我们知道,乾隆元年(公元1736年)的芒种节,正好是四月二十六日(阳历是6月5日),曹雪芹死于乾隆二十八年(公元1763年),终年四十岁。由此推算,乾隆元年曹雪芹正好十三岁,这与黛玉葬花时贾宝玉的年龄相同。这就是《红楼梦》一书为自传说的有力佐证。可以认为,贾宝玉这一艺术形象是以作者自己为模特儿来描写的,虽然我们不会在贾宝玉与曹雪芹之间画等号。研究《红楼梦》的专家周汝昌先生早就提出了这条证据,表达了他的独到见解。可见,搞古代文学研究的人,懂得古代天文历法还是必要的。\n以上举例不过是想说明,一切与古代典籍有关的学科无不与时间的记载(即古代天文历法)有关,甚至古代字书的解说也不例外。《说文解字》释“龙”字:“龙,麟虫之长也。春分而登天,秋分而潜渊。”这是一条什么样的龙呢?春分登天,秋分潜渊。在许慎生活的东汉时代,作为麟虫之长的“龙”早已绝迹,许慎无从得见,也无法交代清楚,就利用天象上的角亢氐房心尾箕——东方苍龙七宿来加以描绘。天幕上的苍龙七宿从春分到秋分这一段时间里,每当初昏时候就横亘南中天。到现在,民间传说还有“二月初二龙抬头”的说法。春分后苍龙七宿现,秋分后苍龙七宿初昏已入地了。原来许慎在用天象释字义。\n古代天文学在阅读古籍中的作用是不可忽视的,当代一些著名学者于此深有体会。王力先生主编的《古代汉语》开了先例,将古代天文历法作为古代汉语基础知识的一部分专章讲授。南京大学程千帆先生将古代天文历法列入古代文化史课程的重要内容,作为研究生的必修课之一。这都是远见卓识之举,将有深远意义。\n我们学习古代天文学知识,不仅仅是为了校读古代典籍,继承优秀文化遗产,发扬我中华民族的古老文化传统。同时可以从中看到我们祖先非凡的聪明才智,激发我们的民族自豪感和爱国热情,攀登新时代的科学高峰。\n七、怎样学好古天文历法 # 我国浩如烟海的古代典籍中涉及天文历法的部分实在不少。从《史记·天官书》始,历代官修正史,大多有《天文志》,详细记述星官及特异的天文现象,对于与生产紧密相关的历法的研究,那就更为丰富了。据朱文鑫先生《历法通志》统计,我国古历在百种以上。可以毫不夸张地说,世界上没有任何一个国家,任何一个民族像我们祖先这样重视和研究历法的。从古至今,研究古天文历法的学人,根据自己的见解,留下了大量的文字。这些材料,头绪纷繁,解说各异,要想找到入学的正确门径确是很难的。再说,中国古代历法,十分重视推步,重视对日、月、五星运动规律的观测与运算,这涉及较为高深的数学知识,对一般文史工作者来说,要读懂这些有关推步的文字实为难事,若为阅读古籍而回头钻研高等数学又实无必要。怎样解决这个矛盾,使一般文史工作者能有一个便捷之法掌握古天文历法这把打开阅读古籍大门的钥匙?\n这就得大体了解近代关于古天文历法研究的一些方法与流派,扬己之长,避己之短,走自己的路,收事半功倍之效。\n我国近代关于古天文历法的研究,无论从规模、质量、成绩几方面看,都是历代封建统治下学者个人奋斗达到的水平所不能相比的。1949年后,研究受到党和政府的重视,不仅有了专门的研究机构,集中了不少专门人才,而且在其他部门从事古天文历法研究者亦逐日增多。尽管科学的研究方法还处于萌芽状态,但就现状看,由于研究者本身所取的角度不同,研究方法也就大不一样,各自具有特点,形成了几个不同的研究流派。这是初入门者不能不知道的。\n1.从历史学角度研究的。以刘坦、浦江清先生为代表。刘坦著《关于岁星纪年》一书,浦先生有《屈原生年月日的推算问题》一文。他们所据春秋、战国、秦、汉诸多记载,不承认“焉逢摄提格”是干支别名,认定那是“岁星纪年”的实录,否定干支纪年起于战国初期。因此,他们以木星运行周期11.8622年为基本数据推考纪年,或从“太岁超辰”之说推求历点。\n2.从考古学角度研究的。以王国维先生为代表。他们根据出土文物、鼎盘铭器上的历点,用刘歆的“孟统”(周历)进行推算。由于不考虑“先天”的条件,西周历法总是与实际天象相差两日到三日。王国维氏著《生霸死霸考》,倡“月相四分”之说,在文物考古界影响很大,至今沿用者不在少数。王氏弟子吴其昌作《金文历朔疏证》,更发挥了王氏的观点。\n3.从现代天文学角度研究的。以朱文鑫、陈遵妫先生为代表。朱文鑫著《历法通志》,陈遵妫有《中国天文学史》一书。目前国内各科研机构(自然科学史研究所、紫金山天文台、北京天文台等)都有这方面的专门小组。其特点是拥有现代天文学的科研手段,所测数据准确,研究者本人精于数学,长于推算。但是,用今天的科学手段比勘古人的肉眼目测,结论往往是有差谬的。如果硬搬《-2500年到+2000年太阳和五星的经度表》以考证古事,又常常有违于古代典籍的记载。\n4.从考据学角度研究的。以张汝舟先生为代表。张氏上世纪50年代末著《西周经朔谱》《春秋经朔谱》《西周考年》等,未遑问世。1979年著《历术甲子篇浅释》《古代天文历法表解》两种,比较集中地代表了他的星历观点。他提出纸上材料(文献记录)、地下材料(出土文物)、天上材料(实际天象)对证。做到“三证合一”才算可靠,尤其重视实际天象。他剔除了《历术甲子篇》中后人的妄改,视它与《次度》为古天文历法之双璧,确证四分历创制于战国初期,行用于周考王十四年(公元前427年)。汝舟先生之说信而有征。经他的友辈和门弟子宣讲阐释,其科学性、实用性已逐渐为文史界所重视。\n笔者于1980年底为通俗地介绍张汝舟先生星历观点,曾编写《古代天文历法浅释》一稿,并由南京大学中文系收入所编《章太炎先生国学讲演录》附录中。《浅释》的前言曾说:古代天文历法这门学问,并不如某些人想象的那么神秘。正因为这样,要学习它、掌握它就不是什么难事。古往今来,有关的材料实在不少,前人的解释也众说纷纭。只要找到一把好的钥匙,这个大门还是容易打开的。至于如何掌握这把钥匙,我在从汝舟师学习的过程中有这样的体会:一是要树立正确的星历观点,才不至为千百年来的惑乱所迷;二是要进行认真的推算,达到熟练程度,才能更好地掌握他的整个体系。由此下去,“博观则有所归宿,精论则有所凭依”。(黄季刚先生语)\n张氏星历观点的运算立足于四分历,这自然最能反映我国古代历法的实际。从现有记载看,中国最早的历法,所谓“古六历”——黄帝历、颛顼历、夏历、殷历、周历、鲁历,都依照四分历数据。在四分历法产生之前,包括“岁星纪年”在内,都还是观象授时阶段。进入“法”的时代,就意味着年、月、日的调配有了可能,也有了规律,可以由此求得密近的实际天象。——这是一切历法生命力之所在。\n一般文史工作者掌握四分历的运算也就够了,这不需要高深的数学知识,不会感到繁难,再进而钻研古天文历法也就有了一个很好的基础。通过演算,相信你会产生越来越大的兴趣,因为它可以引导你解决一些实际问题。你会感到古代天文历法并不如想象的那么玄虚,那么高不可攀。任何一门学问,如果被某些研究者研究得神秘莫测,那就失去了科学的意义。张汝舟先生的星历观点之所以可信,就在于他恢复了这一门学科的本来面目,于古代文献、古代历史、古代文学、古代语言都具有真正的实用价值。\n在当代古天文学研究领域,张氏的观点独具一格,于繁芜中见精要,于纷乱中显明晰,力排众论,自成一家言。这种以古治古的方法,尤其为文史工作者所易接受。掌握张氏的古天文观点与推演方法,于古代文献的释读,于古史古事的考订,都会深感灵便,情趣无限。第二讲纪时系统\n第二讲纪时系统 # 从服务于生产这个角度说,历法是一种纪时系统,是关于年、月、日、时的有规律的安排与记载。日是计时的基本单位,纪日法当最早产生。年,反映了春夏秋冬寒暑交替,直接关系农事的耕种收藏。依章太炎先生说:“年,从禾,人声。”“年”的概念也应当起源甚早。年嫌其长,日苦其短,才利用月亮隐现圆缺的周期纪时,才有朔望月的产生。这种周期不长不短的纪时法最适用于人类生活的需要,配合年、日,行用不衰。这就是年、月、日用于纪时的社会作用。比“日”小的计时单位是时(非四季之时,指时辰、小时、刻),那是起于人类有了一定程度的文明,需要有较细致的时间概念之后。为叙述的方便,我们以纪年、纪月、纪日、纪时的顺次,一一说解。\n一、纪年法 # 帝王纪年法 从西周大量金文及出土的殷商甲骨可以断定,殷商和西周都依商王、周王在位年数来纪年。这就是帝王纪年法。春秋以降,周王权力削弱,各诸侯国均用本地诸侯在位年数纪年。记载鲁史的《春秋》就用鲁侯在位年数纪年。其他诸侯国史虽已不存,但从《国语》中可以看出,各诸侯国都用本国君王在位年数纪年。如:\n《晋语》记献公事:“十七年冬,公使太子伐东山。……二十一年公子重耳出亡。……二十六年献公卒。”\n记文公事:“元年春,公及夫人嬴氏至自王城。……二年春,公以二军下次于阳樊。……文公立四年,楚成王伐宋。”\n记悼公事:“三年公始合诸侯。……四年诸侯会于鸡丘。……十二年公伐郑。”\n又,《越语》:“越王勾践即位三年而伐吴。……四年王召范蠡而问焉。”\n记周王事,仍用周王在位年数纪年。如《周语》记幽王事:“幽王三年西周三川皆震。……十一年幽王乃灭,周乃东迁。”\n记惠王事:“惠王三年边伯、石速、蒍国出王而立王子颓。……十五年有神降于莘。”\n记襄王事:“襄王十三年郑人伐滑。……二十四年秦师将袭郑。过周北门。”\n记景王事:“景王二十一年将铸大钱。……二十三年将铸无射而为之大林。……二十四年钟成。”\n乱世乱时,不统于王,各自为政,就出现了纪年的混乱。纪月起始也各有一套,并不划一。\n年号纪年法 秦始皇统一六国,仍用帝王纪年法。到汉武帝元鼎元年(公元前116年)正式建立年号,并将元鼎以前在位的二十四年每六年追建一个年号。按顺次是建元、元光、元朔、元狩,接着元鼎。这就是中国皇帝年号纪年的开始。皇帝一般在即位时用新年号,中间根据需要可随时更换。年号换得最多的是武则天,她在位二十年(公元684~704年),先后使用过十八个年号,随心所欲,经常一年换用两个年号。从汉武帝起,直到清末,中国历史上使用过的年号共计约六百五十个,其中有不少是重复使用的。重复最多的是“太平”年号,先后用过八次。——这从《中国历史纪年表·年号索引》中一查即得。年号最多用六个字组成,如西夏景宗“天授礼法延祚”,西夏惠宗“天赐礼盛国庆”。一般年号是两个字组成,也有三字、四字的。\n并不是皇帝非用年号不可,就有不用年号的。如西魏的废帝、恭帝和北周的闵帝。也有沿用前帝年号不改的。唐昭宗年号天祐,哀帝沿用不改;辽太祖年号天显,太宗沿用不改;后晋高祖年号天福,出帝沿用不改;金太宗用天会年号,熙宗沿用不改。\n明朝基本上一个皇帝一个年号,只有明成祖夺位后先用了一年“洪武”表示继替朱元璋正统,此后才使用年号“永乐”。明英宗先后两次登极,用了两个年号(用正统十四年,用天顺八年)。清朝皇帝一律一帝一年号。大家习惯于用年号来称呼皇帝本人。说清圣祖、清高宗、清仁宗、清德宗,反而不熟悉,一提康熙、乾隆、嘉庆、光绪,人皆尽知指谁。康熙在位六十一年,乾隆在位六十年,年号使用时间也就最长久。\n年号纪年实有不便,但有影响的帝王年号在典籍中不乏记载,甚至常用一些简称。诸如“太初改历”(汉武帝),“元嘉体”“元嘉草草”(刘宋文帝),“贞观之治”(唐太宗),“开元天宝”(唐玄宗),“元和体”“元和姓纂”“元和郡县志”(唐宪宗),“元祐党争”(宋哲宗),“宣和遗事”(宋徽宗),“靖康耻”(宋钦宗),“永乐大典”(明成祖),“天启通宝”(明熹宗),“启崇遗诗考”(天启,崇祯——明思宗),“康熙字典”(清圣祖),“乾嘉学派”(乾隆,清高宗;嘉庆,清仁宗),等等,我们都是应该弄明白的。\n岁星纪年法 春秋时代,各国纪年以本国君王在位为依准,各有一套,诸多不便。虽然各国纪年都以太阳的回归年周期为基础,但回归年周期与朔望月长度的调配尚未找到理想的规律。天文学家便在总结前人观测行星的经验及资料的基础上,加上亲身的观测,确知木星运行一周天用十二个回归年周期,即十二年,便定木星为岁星,用以纪年。这是企望扩大回归年周期的倍数,以与朔望月协调,使年与月的安排能够规律化。为此,古天文学家把天赤道带均匀地分为十二等分,作为起始的一分就叫做“星纪”。星纪者,星之序也。由西往东,依次是:星纪,玄枵,娵訾,降娄,大梁,实沈,鹑首,鹑火,鹑尾,寿星,大火,析木。这正是岁星行进的方向。这天赤道带的十二等分,叫十二次,岁星一年行经一次,岁星纪年就这样与十二次联系起来。\n岁星纪年法首先出现于《国语》和《左传》。如《周语》“武王伐纣,岁在鹑火”。《左传·襄公二十八年》“岁在星纪而淫于玄枵”。\n岁星纪年法是以天象为基础的纪年法,无疑可以比照各诸侯国的纪年。要是它准确无误的话,自会成为统一的纪年法,流行于普天之下。而木星运行周期并非整十二年,而是11.8622年。经历几个周期,岁星就要超次,如《左传》所记,本应“岁在星纪”,而岁星“淫于玄枵”,岁星纪年就失灵了。古籍中虽有关于岁星纪年的记载,但由于岁星本身运行周期不是整十二年,它便不能长久,它只能是春秋时代昙花一现的纪年法。我们自不可将它扩而广之,延及春秋之后。春秋时代的天文学家虽已观测到岁星的“淫”而有记录,但最早据文字记载正式提出岁星超辰的学人却是汉代的刘歆。\n太岁纪年法 木星运行周期不是整十二年,用实际岁星的位置来纪年就不准确,自不会符合创始者的初衷。人们就另外设想了一个理想的天体,与岁星运行方向相反,从东到西,速度均匀地十二年一周天,仍利用分周天赤道带十二等分的方法,将地平圈分为十二等分,只是方向相反:以玄枵次为子,星纪次为丑,析木次为寅……(见下图)称为十二辰,与岁星纪年的十二次区别。这个理想的天体就称为岁阴、太阴或太岁。太岁和木星保持着大致一定的对应关系。一般是,木星在星纪,太岁在寅;木星在玄枵,太岁在卯……这种用假想的天体——太岁所在的辰来纪年,可以叫做太岁纪年法。\n太岁纪年法十二次与十二辰\n可以明确,太岁纪年法的产生是在岁星纪年失灵之后。太岁是天文学家在不能放弃分周天十二等分而又无法克服木星运行周期非整十二年的矛盾情况下假想的一个理想天体。由于创始行用之初要接续岁星纪年,这个天体——太岁就必然与木星有一定的对应关系,以便有一个接续点,一个起算期。如“木星在星纪,太岁在寅”就是。使用太岁纪年法推算历点者,总是要先确定木星的实际位置,特别是确定木星在星纪的位置,以求找到太岁纪年的起算点,道理就在这里。由于木星周期非整十二年,而理想的太岁周期必须整十二年,这就必然要发生无法调和的矛盾。木星与太岁的对应关系会很快打破,将不再发挥作用。太岁一旦失去了与木星的对应关系,太岁纪年法也就无可依存,自当寿终正寝。如果认为太岁纪年法生命力如何之长久,那也是不合事理的。它仍是春秋后期昙花一现的纪年法,只不过是在岁星纪年法之后,干支纪年之前。\n《周礼注》云:“岁星为阳,右行于天;太岁为阴,左行于地。”由阴阳关系转化为雌雄关系,即岁星为雄,太岁为雌。《淮南子·天文训》所列一套十二个岁名,与太岁居辰有了固定关系。\n太阴在寅,岁名曰摄提格,其雄为岁星,舍斗、牵牛;\n太阴在卯,岁名曰单阏,岁星舍须女、虚、危;\n太阴在辰,岁名曰执徐,岁星舍营室、东壁;\n太阴在巳,岁名曰大荒落,岁星舍奎、娄;\n太阴在午,岁名曰敦牂,岁星舍胃、昴、毕;,\n太阴在未,岁名曰协洽,岁星舍觜、参;\n太阴在申,岁名曰涒滩,岁星舍东井、舆鬼;\n太阴在酉,岁名曰作鄂,岁星舍柳、七星、张;\n太阴在戌,岁名曰阉茂,岁星舍翼、轸;\n太阴在亥,岁名曰大渊献,岁星舍角、亢;\n太阴在子,岁名曰困敦,岁星舍氐、房、心;\n太阴在丑,岁名曰赤奋若,岁星舍尾、箕。\n这与《史记·历术甲子篇》所记岁名大体相同。如果抛开那个假想的天体不论,太岁纪年法就是十二地支纪年法,由此过渡到干支纪年就很好理解。\n后世探讨关于岁星与太岁的文字很多。清代钱大昕、孙星衍、王引之都有专文涉及。近代一些学人如浦江清、郭沫若等就据以推算屈原生年。如果弄清上面的纪年法,这些探讨岁星与太岁纪年的文字都是不难读懂的,也是不难定其是非的。\n岁星纪年因木星周期非整十二年而失灵,太岁纪年亦因之而无用,但划周天为十二等分的辰与次却保存下来,继续发挥作用。因为十二等分正好与十二地支配合,太岁纪年法的十二辰就用十二地支名目与岁星纪年的十二次相对应,由太岁纪年过渡到干支纪年就是十分自然的了。在那同时,天文学家已测得回归年周期为 日,冬至点在牵牛初度。制历有了基本数据,调配年、月、日有了可能,又有天象作依据,四分历由此产生。那时已进入战国时代。\n干支纪年法 天干地支的名目起源很早。郭沫若氏《释支干》以为十二支是从观察天象产生的,郑文光氏以为起源于传说的夏代。因为十二支之首的“子”(崽),甲骨文有作,是从参宿的图形衍化出来的。参宿正是夏氏的族星,是夏民族观测星象的标准星。郑氏的研究以为,十二支的名目都来自天上星宿的图形。不管怎么说,天干地支在秦汉以前已失去了创始的含义。秦汉之后,更无人说得清子丑寅卯、甲乙丙丁的意义。东汉许慎《说文解字》的解说,多从阴阳五行为说,夹杂了更多的汉代人的观念。\n甲骨文中已数次发现完整的六十干支片,那当是纪日所用。由于“三正论”的产生与影响,纪月也用了地支名目,所谓“斗建”。若再由十二次、十二辰转入十二地支纪年,就有彼此相混的可能。所以,四分历创制者本欲用干支纪年而故避子丑寅卯等文字,而用了干支的别名。那就是《史记·历术甲子篇》所载:\n《淮南子·天文训》所记,与此小有出入。\n后世学者文人仿古,往往亦用干支别名纪年。如《说文解字叙》记“粤在永元困敦之年孟陬之月朔日甲申”,困敦是子,徐锴注:汉和帝永元十二年岁在庚子也。魏源写《圣武记》末记“道光二十有二载玄黓摄提格之岁”,《淮南子》玄黓指壬,摄提格指寅,即壬寅年。\n这些古怪的干支别名,或者说太岁纪年法的十二个岁名,从何而来?近人研究,当源于少数民族语言,是民语的音译。陈遵妫先生以为:“这也许是占星术上的术语,因系占星家所创用,所以一般都忘却了意义。”如果进一步推求,我以为这是战国初期楚国星历大家甘德的创作,是楚文化的遗迹。司马迁对楚国文化有相当深入的研究,《史记·律书》所依据的天象就是楚人甘德的体系,《史记·历术甲子篇》所记当同。《历术甲子篇》所反映的“四分历”的创制不能说与当时的大星历家甘德无关。楚文化到汉代已更加昌明,由楚辞发展而来的汉赋几独霸汉代文坛,传习成风。这不仅由于汉高皇帝生于楚,功臣武将大多来于楚,也由于楚文化本身在春秋末期就已有相当实力,足以对后世的文化产生绝大的影响。就天文学而言,楚国在春秋后期就足以与中原各国的水准相匹敌。战国初期之甘德可视为楚国天文学说之集大成者。《淮南子》关于岁星、太阴的记载,已不是初创之作,而是战国初期的遗留文字,或经加工。“太阴在寅,岁名曰摄提格,其雄为岁星,舍斗、牵牛”,只能认为是楚文化的遗风。楚行寅正,寅为初始。“岁星舍斗、牵牛”,斗牛为二十八宿的初始。四分历以牛宿初度为冬至点,岁星居此辰,也有初始之义,这正是战国初期的实际天象。《天文训》所载,就不必看作是汉朝人的创作。\n干支纪年在东汉普行,干支别名纪年就只存在于前代典籍之中。因为官方通行的是帝王年号纪年法,干支别名纪年和干支纪年只能起一个延续久长的纪年作用。\n十二生肖纪年法 干支纪年到了民间,却有超越帝王年号纪年法的永无更换的突出优点,六十年一周期也大体可以记录人生的整个旅程。民间还略去天干成分,用十二种动物表示十二地支,这就是十二生肖纪年法。根据王充《论衡·物势》及《论衡·言毒》篇载,汉代“十二辰禽”——子鼠、丑牛、寅虎、卯兔、辰龙、巳蛇、午马、未羊、申猴、酉鸡、戌狗、亥豕,与流传至今的十二生肖完全一样。\n十二生肖纪年法,以与人类生活相关而常见的动物代替十二支,十二年一周期,形象易记,屈指可数,更称方便。不仅在汉族地区广为流传,而且一直传播到各兄弟民族地区,只不过为适应各民族的生活环境与习惯,取用的十二种动物略有不同罢了。例如云南的傣族用象代替猪(豕),用蛟、大蛇代替龙,用小蛇称蛇;哀牢山的彝族用穿山甲代龙;新疆维吾尔族用鱼代龙;等等。\n藏族的纪年完全接受了十二生肖法,并配合来自汉族的阴阳五行说组成十天干,构成六十循环的纪年法,这可以看成干支纪年法的另一种形式。\n十天干:甲——阳木乙——阴木\n丙——阳火丁——阴火\n戊——阳土己——阴土\n庚——阳金辛——阴金\n壬——阳水癸——阴水\n甲子年称阳木鼠年,乙丑年称阴木牛年,丙寅年称阳火虎年……余可类推。\n1949年后我国已采用国际通用的公元纪年法,这是可以长期延续而永不重复的纪年法,优越之处是显而易见的。公元纪年,使用精确度很高的格里历,三千多年才有一日的误差。\n在介绍了我国从古及今的各种纪年法之后,还有几个问题需要明确,才算真正懂得纪年法的意义与作用。\n1.十二,天之大数\n前面提到,干支纪年法以十天干与十二地支相配组成六十个干支纪年,十二生肖纪年法是十二地支的形象化纪年,岁星纪年法分为十二次,太岁纪年法有十二辰。纪年法的“十二”这个数字太重要了。\n十二,确实是中国古代天文学的一个重要数字。《尚书·尧典》记:“舜受终于文祖……肇十有二州,封十有二山。……咨十有二牧……。”《周礼·春官》载:“冯相氏掌十有二岁,十有二月,十有二辰……。”《左传·哀公七年》载:“周之王也。制礼上物,不过十二,以为天之大数也。”《左传·襄公九年》:“十二年矣,是谓一终,一星终也。”《山海经》记载的神话里有“生月十有二”的帝俊妻常羲,“生岁十有二”的噎鸣。屈原《天问》有“天何所沓?十二焉分”,屈原问:这个“天之大数”怎么来的?\n殷墟甲骨已数次发现完整的干支表,证明武丁时代(公元前14世纪)就有了十二支的划分。十二支应用于天空区划就是十二辰,是将沿着地平线的大圆划为十二等分,以正北为子,向东、东南、向西依次排列子丑寅卯辰巳午未申酉戌亥。这就是以十二支划分的地平方位,正东为卯,正南为午,正西为酉,正北为子。南北经纬线又称子午线,来源于此。\n岁星纪年所用十二次是沿天球赤道,自北向西、向南、向东依次记为星纪、玄枵、娵訾、降娄、大梁、实沈、鹑首、鹑火、鹑尾、寿星、大火、析木。十二次与十二辰方向正好相反。如以十二辰为左旋的话,十二次便是右旋。\n十二次、十二辰当来源于十二支。前已述及,郭沫若氏《释支干》认为十二支是从观察天象诞生的,郑文光氏进一步研究,十二支起源于传说中的夏代,这是根据夏氏的族星参宿的图像衍化为“子”(甲骨文作)确认的。\n干支周期的十干,出自人手有十指,从而有了十进位的记数法。十二支“十二”这个数字,只有十二个朔望月约略等于一年这个周期与人最为亲近。可以说,由一年十二个朔望月产生了“十二”这个天之大数,由此依周天星象产生十二支,由十二支产生了十二次、十二辰。由此演化下去,“十二”的应用就更广泛了。\n2.次和辰\n岁星与星宿对应关系\n岁星居维,宿星二;岁星居中,宿星三《左传·庄公三年》:“凡师,一宿为舍,再宿为信,过信为次。”可见次与宿紧密相关。十二次的得名,当源于二十八宿。而十二次与二十八宿相配并不划一。有的次含三宿,有的次含两宿。长沙马王堆三号汉墓出土的帛书中有“岁星居维,宿星二”,“岁星居中,宿星三”,就是这个意思。\n为什么有“岁星居维”“岁星居中”之分呢?郑文光氏以为,这是远古时代天圆地方说的残存。《淮南子·天文训》有“帝张四维,运之以斗”即是指此。据高诱注:“四角为维。”一个方形的大地,自然有四个角落。岁星运行至此,需要拐弯,只住两“宿”。运行到两维之间,即“中”,是直线行进,就经历三“宿”。(见右图)\n郑氏以为,“十二次并不单纯是天空区划,而是照应到天地关系”,“还保留着天圆地方说的残余”,意味着其来源“甚古”。\n岁星与星宿对应关系 岁星居维,宿星二;岁星居中,宿星三\n关于辰,用法十分广泛,解说也有不同。《左传·昭公七年》:“日月之会是谓辰,故以配日。”《公羊传·昭公十七年》说:“大火为大辰,伐为大辰,北极亦为大辰。”日本天文学家新城新藏以为,观象授时,“所观测之标准星象……通称之谓辰。所以随着时代的不同,它的含义有种种变迁”。\n在中国古籍记载中,“日月星辰”总是相提并论。今人将“星辰”连着解释,以此律古,那就不着边际。《管子·四时》篇记,“东方曰星”,“南方曰日”,“西方曰辰”,“北方曰月”,日月星辰各有所指。《国语·周语》载:“昔武王伐殷,岁在鹑火,月在天驷,日在析木之津,辰在斗柄,星在天鼋,星与日、辰之位皆在北维。”星与辰在这里区别清楚。同样,《尧典》“历象日月星辰,敬授民时”,也应理解成分别为说。\n在上古人眼里,日、月、星、辰并不相混,当各有内涵。日与月自不待说,星当指行星,辰当指恒星。这就包括了肉眼能见的除彗星、流星外的所有天体。星与辰的区别在行与恒,动与不动。金木水火土五大行星称“星”,不与辰相混,辰指水星当在有了十二辰之后。辰虽指恒星,说也笼统。有不动,有不动之动。在所有恒星中,北极星可视为不动之恒星,其余则可视为运转之恒星,所谓“北极亦为大辰”就是这个意思。如果把“大辰”理解为“所观测的标准星象”,那么上古人类最先是以北极星为标准星的。因为所有天体,不考虑岁差,只有北极星是真正不动的,最易识别。现今人们肉眼观星,也总是先找北极星,再取北斗定方位,并确定其他星象,道理相同。依郑文光氏说,到了传说中的夏代,为了农事的需要,观测星象取的标准是参宿(伐星的位置),这就是《公羊传》所谓“伐为大辰”。商代观星取商星(心宿,即大火)为标准星,这就是“大火为大辰”。伐、大火,或者说参宿、商宿,每年春季的黄昏或晨旦都出现在大体相同的位置上。作为“大辰”,仍取一个相对固定的意思。总之,上古典籍中的星与辰是不容混淆的。\n至于十二辰,沈括在《梦溪笔谈》中说:“今考子丑至于戌亥,谓之十二辰者,《左传》云‘日月之会是谓辰’。一岁日月十二会。则十二辰也。”对十二辰的解释历来大都从此。\n总之,十二次与十二辰的划分,除了方向相反以外,其他方面完全一致。\n十二次与十二岁名的对应关系\n3.十二次名的来源\n岁星纪年的十二次,取名都是有来源的,比较清楚。\n星纪:星之序也。表示岁星纪年以此次为首。战国以后历代都将冬至点的牵牛初度安放在星纪次的中点,反映的是战国初期的天象。《淮南子》“岁星舍斗、牵牛”,正是星纪一次。\n玄枵:即传说时代黄帝的儿子,亦写作玄嚣。\n娵訾:是传说时代帝喾的妻子。\n降娄:即奎、娄二宿名。\n大梁:地名。战国魏后期的都城。\n实沈:传说时代高辛氏(帝喾)的次子,即夏族的先祖。\n鹑首、鹑火、鹑尾:把南天一片星座联想成鸟的形象,分鸟头、鸟心、鸟尾三次。\n寿星:传说中的神名。\n析木:地名。属燕国。\n4.公元与干支纪年的换算\n接触文史古籍的同志,经常要遇到公元纪年与干支纪年的换算问题。除了利用《中国历史纪年表》直接查对之外,还有什么简便的换算法呢?这里给大家介绍两种方法。\n第一法,用“一甲数次表”以数学公式推算。\n甲,公元后年干支的推算法。\n公元1年是辛酉年,2年壬戌,3年癸亥,4年甲子。\n4加56才等于60,才能符合一甲数。“56”这个数字就是至关重要的了。干支纪年60年一轮回。凡大于60的干支年序数都必须逢60去之,余数才与60干支序数相吻合。推算法是:\n(x+56)÷60=商数……余数\n余数就是年甲子序数。从“一甲数次表”中可查得。(表见147页)\n为什么“一甲数次表”中,甲子的代号数是0,而不是常用的1?这是因为中国最早的历法——殷历,记载太初历元的甲子朔日与冬至甲子日是用“无大余”表示的。乙丑日用“一”表示,丙寅用“二”表示。“无大余”就是0,0就是甲子的代表数。(见《史记·历术甲子篇》)\n例1求公元1984年的年干支\n(1984+56)÷60=34……无余数\n能被60整除而无余数者(余数为0),则年干支为“甲子”。\n例2求公元3年的年干支\n3+56=59\n年数加56,小于60,这个“和”就是年干支代表数。查“一甲数次表”59为癸亥,则公元3年即癸亥年。\n例3求公元1840年的年干支\n(1840+56)÷60=31……36(余数)\n“一甲数次表”中,36是庚子的代号。公元1840年即庚子年。\n乙,公元前年干支的推算法。\n公元前1年是庚申,公元1年辛酉,2年壬戌,3年癸亥,4年甲子。\n数学上从-1到1,中间还有整数0,间隔是2。而历法纪年无公元0年,从公元前1年直接进入公元后1年。故庚申56加3即得一甲数60。公式为:\n(x+3)÷60=商数……余数\n因为公元前某年是从公元前1年起逆推,必须60减去余数,才得年干支序数。\n例4求公元前427年干支\n(427+3)÷60=7……10(余数)\n60-10=50(干支序数)得甲寅\n例5求公元前1106年干支\n(1106+3)÷60=18……29(余数)\n60-29=31(干支序数)得乙未\n例6求公元前10年干支\n10+3=13\n相加小于60,即以之做余数\n60-13=47(干支序数)得辛亥\n第二法,用“甲子检查表”直接查干支。这是已故历史学家万国鼎先生在《中国历史纪年表》中所载两个表,我们加以介绍。\n先看《公元前甲子检查表》。左边竖行是十干,其余六竖行是十二支。右边一竖行数码是代表个位数公元纪年。公元前1年庚申,公元前2年己未……公元前9年壬子。\n表中间部分五横行数码代表十位数公元纪年,又分三组与下面三方框内数码相衔接,下面三个方框内数码代表百位数和千位数公元纪年。左右两方框的百、千位数各有线条与中间代表十位数的数码相连。中间方框百、千位数就与无框无线的十位数码配合。\n查公元前89年干支。无百位数,百位数就看做0。在下面右边方框内找到0。由线条右上找到十位数,框内找到8。由8直行上,与个位数9的横向相交,得地支“辰”,天干是“壬”。得壬辰,即是年干支。\n又查公元前1066年干支。先在下方框内找到百位千位数10,在中方框内。相衔接的十位数,在中间,无线条系联,在内找到6(十位数)由6直上,与右竖行个位数6横向相交,在“亥”。横看干支是乙亥。即公元前1066年,干支乙亥。\n《公元后甲子检查表》(见下页)使用方法同前表。如,查1981年干支。先在下面三个方框内找到百位千位数19,在中间;则十位数在中间三组中无线条系联之内,找到8;由8直上,与右边竖行个位数1相交在“酉”。得1981年干支辛酉。\n这确实是公元纪年与干支纪年换算最简易之法。然后从干支纪年换算出公元纪年则有一定困难。因为干支60年一轮回,同一个干支年对应一系列的公元纪年,它们之间要么相差60年,要么相差60年的倍数。尽管如此,“检查表”还可以供我们利用,从已知干支查找公元纪年。\n例1胡诠有《戊午上高宗封事》一文,求戊午的公元纪年。\n查法:先找出个位数,戊午是8;再找出百位、千位数;最后判断出十位数。\n胡诠是南宋高宗、孝宗时人。南宋孝宗于公元1163年即位,所以百位千位数是11。已查出个位数是8,戊午之“午”下向,十位数就只能是3或9。结论一定是1138,不可能是1198。\n例2查近代“庚子赔款”的公元纪年。\n查法:先查出个位数。庚子是0;再确定百位千位数,18或19;最后判断出十位数。结果,1840年与1900年都是庚子年。近代史常识告诉我们,庚子年赔款是1900年事,与1840年无关。\n例3求“甲午海战”的公元纪年。\n查法:先查出甲午的个位数4;\n再确定百位千位数18;\n最后查出十位数:3或9。\n近代史常识帮助我们得出结论:甲午海战发生在1894年。\n例4郭沫若氏有《甲申三百年祭》,求甲申的公元纪年。\n查法:先查出甲申的个位数4;\n再确定百位千位数16;\n最后查出十位数4。\n得知:公元1644年甲申。亦知郭文写于1944年前后。\n二、纪月法 # 甲骨文、金文中尚未发现“朔”字,证明“朔”较晚出。因为朔日无月相,肉眼看不见,朔日只能根据月满时日得知。《诗·十月之交》有“朔月辛卯”的记载,至少西周时代已用朔为每月之首日了。\n比“朔”为早,甲骨文中有“朏”字,指新月初见。于是有人据以指出,中国古代最早是以新月初见为一月之首的,像当今的回历一样。这种以造字为说的结论是靠不住的,犹如说“日”字中有阴影,证明中国人早就观察到太阳出现黑子,才造出了一个日字一样。\n可以确切地说,从我国上古制历开始,一贯是以朔作为每月的起首。\n至于纪月法,从甲骨文、金文中可以看出,最早是以数序从一到十二来纪月份的。几千年的文明史都是这样,主要是用数序纪月。\n春秋时代,各诸侯国以自己的君王在位年数纪年,岁首之月也不尽相同,纪年、纪月都呈现出混乱。这时,天文学已相当发达,天象的观测日渐勤奋和准确。天文学家创制了岁星纪年,可据以参照各诸侯国的纪年,还创制了以斗柄所指方位用十二支纪月,可据以对照各国不同的岁首,这就是十二支纪月法。\n十二支纪月以天象为依据。纪年的十二辰,是将地平圈分为十二等分,用十二支定名,北斗柄指向十二辰中的某个方位就是某月:斗柄指向地平圈的寅位,就是寅月;指向卯位就称卯月。这就是所谓“斗建”。典籍中的有关记载还不少。\n十二支纪月是将冬至所在之月的子月(斗柄正北向,指地平圈子位)作为一岁之首,依次到岁终的亥月即十二月,这就是所谓建子为正,称子正。这种用十二支纪月之法,民用日历虽不行用,在古代却有相当影响。据以制历和对照先秦古籍有关文字是缺之不得的。\n星象家不仅用十二支纪月,还配上十干,成为干支纪月。干支纪月法很少有科学上的意义,搞命理预测,推算生辰八字才用到它。\n此外,古人纪月还用别名。《诗·小明》:“昔我往矣,日月方除。”郑笺:四月为除。《国语》载:“至于玄月是也。”玄月指九月。《离骚》有“摄提贞于孟陬兮”。孟陬指春季正月。\n《尔雅·释天·月名》记:“正月为陬,二月为如,三月为窉,四月为余,五月为皋,六月为且,七月为相,八月为壮,九月为玄,十月为阳,十一月为辜,十二月为涂。”\n又,《尔雅·释天·月阳》载:“月在甲曰毕,在乙曰橘,在丙曰修,在丁曰圉,在戊曰厉,在己曰则,在庚曰窒,在辛曰塞,在壬曰终,在癸曰极。”\n知道月名、月阳这些别名,《史记·历书》“月名毕聚”也就好懂了。毕即甲,聚通陬,均取正、首之义。岁首用“子”,斗柄指子位,聚实指“子”。毕聚即甲子月。据此知,《尔雅》之“正月为陬”,是以子月(冬至之月)为首月,《尔雅》用子正(周正)。可见月名、月阳是先秦的文字,汉人录之而已,亦见司马迁《史记·历书》仍有干支纪月的流风。\n由于先秦典籍毁者太多,存者甚少,月名、月阳这些纪月的别称没有全部得到文献上的验证。\n除此之外,一年四季,一年十二月都有种种别名。如古代将音律与历法的纪月联系起来,将十二律分配在十二月上以代月名。文献典籍中这类材料实在不少,我们不可不知。\n二月称酣春,有李贺诗句“劳劳莺燕怨酣春”。\n三月称杪春,取义于岁杪。《礼·王制》:“冢宰制国用,必于岁之杪。”杪春指三月,杪秋指九月,杪冬指十二月。\n四月称麦秋,见《礼·月令》“孟夏麦秋至”,“孟夏之月,农乃登麦”。四月称清和,见谢朓诗“麦候始清和,凉雨销炎燠”。又,曹丕《槐赋》有“伊暮春之既替,即首夏之初期,天清和而温润,气恬淡以安治”。\n五月称小刑,见《淮南子·天文训》:“阴生于午,故五月为小刑。”\n称蒲月,民俗端午节将菖蒲做剑悬于门首,作为应时的辟邪景物,故五月又称蒲月。\n六月称溽暑。见《礼·月令》:“土润溽暑,大雨时行。”谢惠连诗“溽暑扇温飚”,意即六月湿热。\n称徂暑,见《诗·四月》:“四月维夏,六月徂暑。”杜甫诗:“密云虽聚散,徂暑终衰歇。”\n称荷月,荷花盛开之月。江南旧俗以六月二十四日为荷花生日。《内观日疏》云:“六月二十四为观莲节。”《吴郡记》:“荷花荡在葑门之外,每年六月二十四,游人最盛。”\n七月称兰秋,谢惠连诗:“凄凄乘兰秋,言践千里舟。”也称开秋、早秋、新秋。梁元帝《纂要》:七月曰孟秋、首秋、初秋、上秋。又因兰花吐芳而称兰月。\n八月称仲商,见《礼·月令》:“孟秋之月,其音商。”可见古以“商”为秋之音,仲商即仲秋也。\n九月称青女月,《淮南子·天文训》云:“至秋三月,青女乃出,以降霜雪。”相传青女是天神,即青霄玉女,主霜雪之降。\n十月称良月,见《左传·庄公十六年》:“使以十月入。曰良月也,就盈数焉。”数至十为小盈,取其义。\n称朽月,见《礼·月令》:“孟冬之月其味咸。其臭朽。”臭,指以嗅觉闻之。气若有若无为朽。\n十一月称畅月,见《礼·月令》:“仲冬之月命之曰畅月。”孙希旦注:畅,达也;时当闭藏,万物充实。\n十二月称腊月,也叫蜡月、嘉平、清祀。腊,原是祭的别称。古代常于此月行祭祀,故后世称为腊月。\n称暮节,见《初学记》:“十二月也称暮节。”也称暮冬、穷冬、穷纪、晚冬、残冬、三冬。\n称星回节,语出《玉溪编事》:“南诏以十二月十二日为星回节。”\n还有一个四季与月份的配合问题,即所谓“建正”。在制历而尚无规律可循的时代,只能随时观测天象而定季节,设置闰月以确定月份。也就是说,每年春季第一个月是哪一月,并不是固定不变的,或者说岁首并不一致。北斗柄所指的方位是固定的,斗建起于子,终于亥。因为这是天象,不可能含糊。春秋各国的岁首月份,有在子月的,有在丑月的,有在寅月的,这就有一个“建正”问题。\n《左传·昭公十七年》载:“火出,于夏为三月,于商为四月,于周为五月。”这是以天象“火出”为依据,十分客观的记载。\n《史记·历书》云:“夏正以正月,殷正以十二月,周正以十一月。盖三王之正若循环,穷则反本。”这是司马迁立足于夏正为说。\n上两处都触及建正问题,即所谓夏、商、周三正。所谓周正,是以冬至所在之月,斗建子月(夏历十一月)为正月,即建子为正,又称子正;所谓殷正,是以斗建丑月(夏历十二月)为正月,即建丑为正,又称丑正;所谓夏正,是以立春之月,斗建寅月为正月,即建寅为正,又称寅正。春秋时代,依据建正不同,称子正之历为周历,丑正之历为殷历,寅正之历为夏历。\n三正与四季的对应关系是不同的。\n这样一来,春夏秋冬四季则各有所指。先秦典籍记载时令,往往与今人的习俗不同,彼此之间也经常两样。究其实,仍是建正不一所致。\n古人迷信阴阳五行和帝王嬗代之应,春秋时代“三正论”大兴,就是顺应了时代的需要。按照三正论者对“周正建子、殷正建丑、夏正建寅”的解释,夏商周三代使用了不同的历法,即夏代历法以寅月为正,殷代历法以丑月为正,周代历法以子月为正,夏商周三代迭替,故“改正朔”以示“受命于天”。秦王迷于“三正论”,继周之后,以十月为岁首,也有绍续前朝,秉天所命之意。实际上,四分历产生之前,还只是观象授时,根本不存在夏商周三代不同正朔的历法。所谓周历、殷历、夏历,不过是春秋时代各诸侯国使用的子正、丑正、寅正的代称罢了。近代学者新城新藏、郭沫若、钱宝琮对此均有研究,一致否定了“三正论”。张汝舟先生对古书古器留下的四十一个西周历点详加考证,结论是建丑居多,少数失闰建子建寅。(见所著《西周考年》)他的《中国古代天文历法表解》更以大量确证,论定西周承用殷历建丑。这里摘要列举《表解》所列之“表三”:\n表中所列,不仅“火(心宿)”的中流伏内顺次不紊,就是《尧典》与《月令》所举中星亦分明不误。不难看出,《尧典》用夏正不用周正,《夏小正》《诗·七月》《月令》,皆用殷正,不用周正。\n结论只有一个:西周一代并不是建子为正。\n春秋用历,有记载可考。隐公三年寅月己巳朔,经书“二月己巳,日有食之”,当是建丑为正;桓公三年未月定朔壬辰,经书“七月壬辰朔,日有食之”,亦是建丑为正。其他春秋纪日,皆可定出月建。事实是,僖公以前,春秋初期是建丑为正,这自然是赓续西周。不能设想,西周建子为正,到春秋突来一段丑正。\n正因为是观象授时,无历法以确定置闰,确定朔日余分,失闰失朔便极为自然。少置一闰,丑正就成了子正;多置一闰,丑正就成了寅正。到僖公以后,出现建子为正,也就是顺理成章之事。\n到了战国时期,各国普遍行用四分历,建正不同是事实。齐鲁尊周,建子为正;三晋与楚建寅,使用夏正;秦用夏正,又以十月(亥)为岁首。明白这个道理,对阅读古书大有好处。\n《春秋》《孟子》用周正建子,所以《春秋·成公八年》云:“二月无冰。”(适夏正十二月,当冰而不冰,反常天气,故记。)《春秋·庄公七年》云:“秋,大水,无麦苗。”(注:今五月,周之秋。平地出水,漂杀熟麦及五稼之苗。)《孟子·梁惠王上》云:“王知夫苗乎?七八月之间旱,则苗槁矣。天油然作云,沛然下雨,则苗浡然兴之矣。”(朱子集注:“周七八月,夏五六月也。”)《孟子·滕文公上》云:“昔者孔子没。……他日,子夏、子张、子游以有若似圣人。欲以所事孔子事之,强曾子。曾子曰:不可。江汉以濯之,秋阳以暴之(暴,蒲木反),皜皜乎不可尚已。”(集注:“江水多,言濯之洁也。秋日燥烈,言暴之干也。”周正之秋阳,正夏正之赤日炎炎,故言“秋日燥烈”。)这些记载,都可用子月为正解读。《楚辞》用寅正,诗句中明明白白。《九章·抽思》:“望孟夏之短夜兮,何晦明之若岁”;《九章·怀沙》:“滔滔孟夏兮,草木莽莽”;《湘夫人》有“嫋嫋兮秋风,洞庭波兮木叶下”;《九辩》有“秋既先戒以白露兮,冬又申之以严霜”,“无衣裘以御冬兮,恐溘死不得见乎阳春”等,这些文句只能用夏正建寅解释。因为夏正孟夏四月(巳)近于夏至(五月中气),日长夜短,故称“短夜”;南方夏正四月,正值立夏、小满,草木茂盛,才以“莽莽”状之;夏正九秋十冬,节气有白露、霜降、冬至、大寒,才可言“白露”“严霜”;至于衣裘御寒之时,必在夏正冬季,才有“不得见乎阳春”之说。\n《史记·秦本纪》记昭襄王四十二年“十月宣太后薨。……九月穰侯出之陶。……四十八年十月韩献垣雍……王龁将。伐赵。……正月兵罢,复守上党。其十月五大夫陵攻赵邯郸。四十九年正月益发卒佐陵。……其十月将军张唐攻魏”。这里记昭襄王四十二年、四十八年,都先记“十月”,后记“九月”“正月”。这是秦历——颛顼历,起于冬十月(岁首)止于秋九月。昭襄王四十八年、四十九年记有“其十月”。正月前的十月是秦历十月,正月后的十月,是三晋历的十月。三晋以正月(寅正,同秦)为岁首。“其十月”之在秦历,是在明年岁首,今记在本年内,注明“其十月”,犹言“他的十月”。如果三晋不是寅正是周历子正,“其十月”是秦历“八月”,秦史正好用秦历“八月”顺记下来,何来一个“其十月”呢?《秦本纪》中两个“其十月”是三晋用寅正之确证。\n《史记·魏其武安侯列传》载武帝元光五年(公元前130年)十月杀灌夫,十二月晦杀魏其,“其春武安侯病,专呼服谢罪。使巫视鬼者视之,见魏其、灌夫共守,欲杀之”。这里,先记十月,接着记十二月,之后不说“明春”,而说“其春”,是什么原因?因为秦用寅正,使用寅正月序,又以夏正十月为岁首记事,号称颛顼历。汉初承用秦制,也以十月为岁首,当年春天在当年十二月之后,故称“其春”。直到汉代武帝太初(公元前104年)改历之后,才以正月为岁首。此后两千余年,除了王莽和魏明帝(曹叡)时用殷正(建丑),武则天和唐肃宗(李亨)一度用周正(建子)之外,都用夏正建寅,延续至今。今农历称夏历,取建寅为正之夏,非夏朝之夏。\n从中可看出:建正与岁首一般是统一的。但建正多属于天文(斗建),岁首多属于用历,有时并不一致,如秦用夏正建寅的月序,又以十月为岁首记事。其次,有建正并不等于有历法。因为只要纪年月就有建正、有岁首,但不一定就有了制历法则,所以不能说夏历、殷历、周历就是夏、殷、周三代的历法。今农历称夏历,取建寅为正之夏,非夏朝之夏。又,夏正建寅之历,作为战国时代四分历的内容,古称殷历,假托成汤所制。实即假殷历真夏历。假殷历者,取名而已;真夏历者,取寅正之义;具体内容是古四分历的法则所推演的年月日安排。\n在“纪月法”这一部分,还有一个月甲子的推算问题。\n根据“五虎遁”已知:\n甲年和己年正月之甲子为“丙寅”\n乙年和庚年正月之甲子为“戊寅”\n丙年和辛年正月之甲子为“庚寅”\n丁年和壬年正月之甲子为“壬寅”\n戊年和癸年正月之甲子为“甲寅”\n如1984年之年干支为甲子,即“甲年”,正月之月甲子(干支)即丙寅。二月即丁卯,三月戊辰,四月己巳,五月庚午,六月辛未,余顺推。1980年之年干支为庚申,即“庚年”,正月之月甲子即戊寅,二月即己卯,三月庚辰,四月辛巳,五月壬午,六月癸未,余可顺推而出。列表如下。\n所谓“五虎遁”者,记住十干之年的正月五个寅(虎)月干支即可依次顺推之意。编成歌诀即是:\n甲己之年丙作首,\n乙庚之年戊为头,\n丙辛之岁庚寅上。\n丁壬壬寅顺水流,\n若言戊癸何方起,\n甲寅之上去寻求。\n三、纪日法 # 日是最基本的时间计量单位,也是最重要的时间单位。只有认识了日,把日子连续不断地记录下来,才可能产生比日大的时间单位——月、年,安排年、月、日才有可能,制历才有基础。\n日是有长度的时段,有起止时刻,或者说,日与日有一个分界。初民“日出而作,日入而息”,是把白天当一日,夜晚并不重要。有了火的发明,夜以继日,一日一夜合在一起,称为一日。\n《史记·天官书》载,“用昏建者杓”(杓指摇光),“夜半建者横”(横即玉衡),“平旦建者魁”(魁指天枢)。这是讲上古观测北斗星以定季节的三种不同的观测系统。而值得注意的是,先民所选取的观测时刻,正可以代表他们认识日的观念,平旦、黄昏、夜半都可以作为日与日的分界标志。日出(平旦)、日落(黄昏)作为日的分界,标志最为显明,生产力低下的部落人也最易掌握。但冬至日短,夏至日长,日出、日落的时刻一年四季是不固定的。在生产力有了相当发展的阶段显然就不能适应人类社会的需要,必须选用另外的标志以确定日的分界。现代天文学上使用的儒略日制度是以日中做分界标志的,这在实际生活中极为不便。我国古代,至迟春秋时代就以夜半作为日的起点了。\n以夜半划分日期必须有较精确的计时器,这就是漏壶。漏壶是计时刻的。只要用漏壶测得两次日中之间的长度,取其半就能得到较准确的夜半时刻。传说周公已有测景台,春秋时代已有了精确的圭表测影。圭表测影必取日中时刻。从现存文献看,战国初期行用的四分历就以夜半子时为一日的计算起点了,至今不废。\n有了正确的日的概念,古人用什么方法纪日呢?最原始的办法当然是结绳和刻木(竹)。新中国成立前夕,云南的独龙族还用结绳法纪日,佤族则用刻竹法。这都是原始纪日法的遗留。\n有了文字,纪日法就简单多了。甲骨卜辞使用的是干支纪日,如“己巳卜,庚雨”,“乙卯卜,翌丙羽”。现今不仅已数次发现完整的六十干支骨片,还发现有长达五百多天的日数累计结果。有人以为,完整的干支片,就是古人的日历牌。可见干支的创制当在殷商之前。\n干支纪日法是我国古代历法的重要内容。利用古历推算任何日期(包括节气、朔、望),都是首先推出它的干支数。要掌握古代历法的基本知识,就必须学会干支纪日的推算。《左传·宣公二年》“乙丑,赵穿攻灵公于桃园”,《离骚》“唯庚寅吾以降”,贾谊“庚子日斜兮,鵩集于舍”……这些干支纪日的记载,文献中比比皆是。据可靠的资料看,鲁隐公三年(公元前720年)二月己巳日至今,干支纪日从未间断,这是人类社会迄今所知的最长的纪日文字记载。\n干支纪日对于历史学、考古文献学,对于科技史有着重要意义。我国浩如烟海的古代典籍,大量珍贵史料赖干支纪日的行用而有条不紊地留传下来。没有干支纪日,史迹的推算便失去时间脉络,众多原始珍宝就成了杂乱无章的文字记录,价值也就可想而知。\n干支纪日法至今还有它一定的作用。有些历日还必须用干支来推求。如三伏、社日的计算。《幼学琼林》云:“冬至百六是清明,立春五戊为春社。寒食节是清明前一日,初伏日是夏至第三庚。”注说:立秋后戊为秋社。夏至后四庚为中伏,立秋后逢庚为末伏。这就是逢戊记社,逢庚记伏。过去西南一些地方赶场也是按干支纪日,主要是用十二支所代表的十二生肖称呼场地:牛场、马场、龙场、猫场、兔场……七天一大场,五天一小场。逢丑(牛)日赶场的集镇称牛场,逢寅(虎)日赶场的集镇叫猫场,余可推知。镇远侗族婚礼,定在每年阴历十月辛卯、癸卯两个卯日举行。\n干支纪日的局限是明显的。因为干支六十个序数一周期,延续不断,如果不知道朔日干支,就无法明确某个干支在该月的序次。在阅读古籍遇到纪日的干支还必须有专门的朔闰表来检查日子。\n除了干支纪日法还有数序纪日法。最早的数序纪日法资料是1972年于山东临沂出土的汉武帝七年(元光元年,公元前134年)历谱竹简。这份历谱在三十根竹简顶上标了从一到三十的数字,这是每月内各个日子的序数。每根简下面写着各个月中这个日子的干支日名。从那以后,凡出土的汉武帝以来的历谱都记有月内各日的序次数字。尽管有数序纪日法,民用甚为方便,历代史官的记载仍主要采用干支纪日法。\n星期是七天一周的纪日法。按“日、一、二、三、四、五、六”顺次排列。远在古巴比伦时代就采用了星期纪日法,后来和基督教一起传入希腊和罗马,现在已在全世界通用。星期的每一天,按照罗马占星术的观点,是由当时所知道的七颗行星(日、月和五星)中的一个所庇护的。因此,一星期中每一天的命名就用一个星的名字。这些名字到现代还保留在西欧的语言中。\n1583年法国学者斯加利杰(J.J.Scaliger)倡议在公历之外创立一种不间断的纪日尺度。它以太阳周28年、章法19年和律会15年相乘,得7980年为一总,称为儒略周。\n28×19×15=7980\n因为儒略历每年36514日,28年的日数恰为7的倍数,所以一太阳周后某月某日的星期又和以前同。\n章法:19个儒略年比235个朔望月约多一个半小时,可看作大体相等。所以某年1月1日合朔,一章19年后的1月1日仍必合朔。\n律会:当时罗马税周,15年为一周期,和天文没有关系。\n太阳周、章法、律会的“元”都起于儒略历1月1日。于是上溯得公元前4713年1月1日平午为一总的纪元(公元前1年在天文上记为0年,公元前4713年记为公元前4712年)。一切有史以来的时日都可以包括在儒略周一总之内,预推未来可应用的时日也足够了。\n儒略周的连续不断的纪日法在现代天文学上还很有用,因为这种纪日法是脱离年、月羁束的唯一长期纪日法,要想求得两个天象发生的准确时间距离,使用这种纪日法最为方便。\n每年的天文年历登载有每天的儒略日,可以查用。下面摘出近代主要年份元旦儒略周的日数,供查对。\n1800年237,8497\n1840年239,3106\n1900年241,5021\n1920年242,2325\n1950年243,3283\n1970年244,0588\n1980年244,4240\n这里谈谈关于公元日与干支日的推算问题。\n中国古代以干支纪日为主,六十日一轮回,周而复始。现代通行于世的格里历以公元年月数字纪日。两者均可顺推或逆推出若干年的干支日或数字日。二者必有一个彼此换算的问题。公元后若干年的数字纪日常需要换算成干支纪日,近代以前若干年的干支纪日也常需要换算成公元数字纪日。除了查阅陈垣先生《二十史朔闰表》之外,还有什么快速便捷之法?国内对此进行研究者不乏其人。重庆张致中、张幼明兄弟经多年研究,创“万年甲子速查法”,把万年内外的公元数字纪日快速地变换为干支日。张氏兄弟的“速查法”服务于中医研究,于古史、考古、星历等亦有参考价值。我们知道,当干支成为纪日工具时,它便很快被引入医学领域,借以说明人体很多复杂的生理病理现象,创立了相应的中医基础理论和治疗方法,如五运六气、天人相应学说、子午流注针法等等便是。因此,进行中医的理论研究与治疗,均不能离开干支纪日,常常需要将公元年月日的数字纪日迅速地换算出干支日,这是张氏“速查法”的主要作用。\n历法上的运算,当然也离不开干支日,但主要是推求朔日干支。而朔日又依据农历的朔望月,这与公元纪年的“月”内容不一样,是不能不知道的。\n四、纪时法 # 1.时的概念\n时的概念古今是不一致的。《说文》云:“时,四时也。”指一年春夏秋冬四时。古籍中的“时”,多指季节、时令,《孟子》“斧斤以时入山林”就是。这样理解,时就是一个比年小、比月大的时间单位。文史学家常顺次排列为“年、时、月、日”。《春秋经传》记事,多有类似记载。如:\n(文公)“传十六年春王正月及齐平。”\n“夏五月公四不视朔。”\n“秋八月辛未声姜薨。”\n“冬十一月甲寅宋昭公将田孟诸。”\n其中的春、夏、秋、冬,指的是“时”,就是季节。《说文》据以释义。\n纪时法之“时”,是指比日小的时间单位,时辰、时刻之类属此。\n2.地方时、世界时、北京时\n如果没有钟表,人们习惯于把太阳在正南的时刻说成中午12点,此时地球另一面正在背着太阳的地点,必然是夜里12点,也就是午夜0点。这种由观测者所在地,根据太阳位置所确定的时刻叫做地方时。地球绕太阳转动,各地的观测者都有各自正对太阳的时刻,都有各自的正午。只有同一条经线上的地方时才相同,地方时随着地理东西经度的不同而有差别。国际上规定,以通过英国格林尼治天文台的经度(0度)为起点,向东至180度称东经,向西自0度至180度称西经。地球自转一周360度,每小时转过15度(360÷24)。\n每距经度一度,时差4分钟(60÷15)。\n每距经度一分,时差4秒钟(60分为一度)。\n这就是地方时与经度的关系。这样,地球上东西任意两点同一时刻的地方时差,都可以通过两点的经度差算出来。\n随着人类社会彼此交往的频繁,全人类统一的时间系统的建立就是必不可少的了。公元1884年的一次国际会议上,建立了统一的世界纪时的区时系统。规定,将地球表面分成24个时区。太阳每小时所经过的地方即每15经度范围内为一个时区。东经12区,西经12区。东经180度即西经180度作为国际日期变更线。一日之内,东早西迟。人们沿用0时区的区时,即0度经线上的地方平时,作为国际通用的世界时。\n我们常用“北京时间”以统一祖国各地的地方时。北京位于东经116度20分,属于东八区。因此规定东八区的区时为“北京时间”。东八区的区时是指东经120度经线上的地方平时,并不是北京所在东经116度20分线上的地方时。杭州、常州正好位于东经120度经线上,严格说,“北京时间”与杭州、常州的地方平时才正好相当。\n美国时间1971年10月25日夜11点22分联合国大会以压倒性多数通过恢复中华人民共和国在联合国的合法权利,这在当时是一个振奋人心的消息。这是北京时间的几时几分呢?这就涉及区时的关系。\n所谓美国时间,就是指华盛顿时间。华盛顿位于西经77度,属西五区。美国时间就是西五区的区时,也就是西经75度经线上的地方平时。西五区的区时与北京时间即东八区的区时,相差13小时。\n美国时间10月25日晚11时22分正是北京时间10月26日中午12时22分。\n在我国国内,北京时间与各地的地方时差,也可以由各地的经度与东经120度线的经度差推算出来。\n3.古代的测景报时\n中国古代是利用表影的方位来报时,自成系统,这也是观测太阳位置来确定时辰。\n最早当是就近利用自然物(山势、树木)的影长,进一步发展就是立竿测影。古代的“表”,就是竿,直立的竿子。古人十分重视“表”的作用,观测十分勤勉。表的用途甚多,主要有三个:\n一是定方位。《周礼·冬官·考工记》云:“匠人建国,水地以县,县槷而眡以景。”郑玄注:“于所平之地中央,树八尺之臬,以县正之。眡之以景,将以正四方也。”《周礼》的“”,就是郑注的“臬”,实际就是表。《诗·大雅·公刘》:“既景迺冈,相其阴阳。”传云,考于日影,参之高冈。可见,依太阳高度利用日影测量地理位置,那是周代以前就有的了。\n二是报时辰。这是观测表影角度的变化,从日出到日落,以定出一天之内的时间。这种“表”发展为后来的日晷。古人将地平圈从北向东向南向西按十二支顺次分为十二等分,定出地平方位。春分、秋分日出正东而没于正西,即日出卯位没于酉位。冬至日出东南而没于西南,即日出辰位而入于申位。夏至日出东北而没于西北,即日出寅位而入于戌位。这是利用表影的方位来报时。\n三是定时令。观测每天正午的日影长度及其变化,测量回归年长度并确定一年二十四节气。这就是“土圭之法”,所谓“日中,立竿测影”。报时辰的“表”发展成“日晷”,作用也就专门化了。《说文》云:“晷,日景也。”日景即今影子。晷确真是测日影的。故宫太和殿前左边摆着的就是我国传统的赤道式日晷。古代日晷,晷面一般为石质,晷面和地球的赤道面平行。与赤道面平行,必然和地平面成一角度,角度的大小随地理纬度不同而变化。北京地理纬度40度,日晷与地面角度即为40度。晷面中心立一根垂直于晷面的钢制指针,这根指针同地球自转轴的方向也是平行的。晷面边缘刻有子丑寅卯辰巳午未申酉戌亥等十二时辰。每年春分以后,太阳位置升高,看盘上面的针影所指;秋分以后,太阳位置降低,看盘下面的针影所对时辰,十分准确。\n4.十二时辰\n从甲骨文材料看,殷人对每天各个不同时刻,已有专门称呼。大体上是将一日分为四个时段:旦(旦、明、大采),午(中日),昏(昏、昃日),夜(夕、小采)。这种粗略的纪时法,在生产力低下的时代,已足够应用了。\n在一日分四个时段的基础上,利用起源很早的十二地支定时辰,那也是很自然的。顾炎武《日知录》卷二十载:“自汉以下,历法渐密,于是以一日分为十二时,盖不知始于何人,而至今遵用不废……《左氏传》卜楚丘曰:‘日之数十,故有十时。’而杜元凯注则以为十二时。”顾氏以为“一日分为十二,始见于此”。即\n夜半者子也,鸡鸣者丑也,平旦者寅也,日出者卯也,\n食时者辰也,隅中者巳也,日中者午也,日昳者未也,\n哺时者申也,日入者酉也,黄昏者戌也,人定者亥也。\n古籍涉及时辰者不少。《诗·女曰鸡鸣》“女曰鸡鸣,士曰昧旦”(卜辞作未旦、旦);宋玉《神女赋序》“哺夕之后,精神恍忽”;《淮南子·天文训》“(日)至于衡阳,是谓隅中;至于昆吾,是谓正中”;《汉书·游侠传》云“诸客奔走市买,至日昳皆会”;《古诗·孔雀东南飞》“唵唵黄昏后,寂寂人定初”;杜甫诗“荒庭日欲哺”等等。\n顾炎武认为十二时辰的划分起于杜预的注,其实在商周之间就有了。《诗·小雅·大东》曰:“跂彼织女,终日七襄。虽则七襄,不成报章。”郑玄以为:“从旦至暮七辰,辰一移,因谓之七襄。”郑玄讲反了,不是从旦到暮而是指织女星从升到落,在天上走了七个时辰。这个“七襄”,已透露了西周时代一天分十二时辰的消息。\n除了常见的分一日为十二时辰外,还有将昼夜各分为五个时段的,那就是“日之数十,故有十时”。《隋书·天文志》载:“昼:有朝,有禺,有中,有哺,有夕。夜,有甲、乙、丙、丁、戊。”由此又称夜为“五更”。《颜氏家训·书证篇》解释道:“或问:‘一夜何故五更?更何为训?’答曰:汉魏以来,谓为甲夜、乙夜、丙夜、丁夜、戊夜;或云鼓,一鼓、二鼓、三鼓、四鼓、五鼓;亦云一更、二更、三更、四更、五更,以五为节。……所以尔者,假令正月建寅,斗柄夕则指寅,晓则指午矣。自寅至午,凡历五辰,冬之月虽复长短参差,然辰间,阔盈不至六,缩不至四。进退常在五者之间。更,历也,经也,故曰五更尔。”颜氏结合星象说五更,是可信的。\n更有《淮南子·天文训》将白天分为十五个时段:晨明、朏明、旦明、蚤食、晏时、隅中、正中、小还、时、大还、高舂、下舂、悬车、黄昏、定昏。这是就太阳的位置“日出于旸谷”至“日入于虞渊之汜”来划分白昼的。\n宋代以后,又规定把十二时辰的每个时辰平分为初、正两个部分。子初、子正,直到亥初、亥正。初或正都等于一个时辰的二分之一。“小时”之称由此而来。\n5.百刻制度\n与十二时辰同时并行的是昼夜均衡的百刻制度。这应是由“十时”制发展而来的更细致的一种纪时法。百刻制,当以漏壶的产生为基础。漏壶是古人制作的计时仪器,用箭来指示时刻。箭上刻着一条条横道,这就是刻。刻应比箭更早。\n《周礼》一书就有关于漏壶及昼夜时刻划分的记载,那时已有报时的制度和专职人员。《周礼》的内容反映了春秋乃至西周的社会礼制。\n《周礼·春官·鸡人》载:“夜呼旦,以叫百官。”\n《周礼·秋官·司寤氏》载:“掌夜时。以星分夜,以诏夜士夜禁。御晨行者,禁宵行者、夜游者。”这里的“鸡人”“司寤氏”,应是值夜班的专职人员,职责明确。\n《周礼·夏官·絜壶氏》记:“掌絜壶以令军井。……凡军事悬壶以序聚柝……以水火守之,分以日夜。”郑注:“悬壶以为漏,以序聚柝,以次更聚击柝备守也。……以水守壶者为沃漏也,以火守壶者夜则观刻数也。分以日夜者,异昼夜漏也。漏之箭昼夜共百刻,冬夏之间有长短焉。”\n《诗·东方未明》有:“狂夫瞿瞿,不能辰夜,不夙则莫。”毛诗序:“刺无节也。朝廷兴居无节,号令不明,絜壶不能掌其职焉。”严粲以为此诗主刺哀公,兴居无节,故归咎于司漏者以讽之。\n这些记载都说明,时刻制度行用是很早的。\n昼夜漏刻制以太阳出没为基础。秦和西汉规定,冬至日昼漏40刻,夜漏60刻;夏至日昼漏60刻,夜漏40刻;春分秋分则昼夜漏都是50刻。\n古代还明确规定昏旦时刻,以利于早晚观测中星及其他天象。秦汉以前,大体是日出前三刻为旦,日没后三刻为昏。秦汉以后改三刻为二刻半,一直用到明末。东汉以前,从冬至日起,每隔九日昼漏增一刻;夏至日起,每隔九日昼漏减一刻。因冬至与夏至相距百八十二、三天,昼漏或夜漏时刻相差二十刻,约合每九日一刻。《秦会要订补》卷十二《历数上》说:“至冬至,昼漏四十五刻。冬至之后,日长,九日加一刻,以至夏至,昼漏六十五刻。夏至之后,日短,九日减一刻。”昼漏或夜漏时刻虽略有不同,但九日增减一刻却是一致的。\n吕才漏刻图在钟表传入之前,漏壶一直是传统的纪时工具。由于一百刻与十二时辰无整倍数关系,难于协调,于是有改革百刻制的出现。汉成帝、哀帝及新莽时短时间行用过甘忠可推衍的一百二十刻制。梁武帝天监六年(公元507年)曾改为九十六刻制,大同十年(公元544年)又改为一百零八刻制,只用了数十年。陈文帝天嘉年间又复用百刻制。明代末期西学传入九十六刻制,清初定为正式制度,废百刻。\n一个时辰等于八刻又三分之一,这三分之一刻又称为小刻。\n吕才漏刻图\n6.时甲子的推算\n十二支用于纪时,民间又往往配以十干,发展为干支纪时。\n具体推算如下表。\n根据“五鼠遁”,已知:\n甲日和己日子时之干支为甲子,\n乙日和庚日子时之干支为丙子,\n丙日和辛日子时之干支为戊子,\n丁日和壬日子时之干支为庚子,\n戊日和癸日子时之干支为壬子。\n归纳成歌诀,便是:\n甲己还加甲,乙庚丙作首,\n丙辛生戊子,丁壬庚子头,\n戊癸起壬子,周而复始求。\n如公元1949年10月1日干支甲子日,那么甲日之子时干支为甲子,丑时即为乙丑,寅时即为丙寅……亥时即为乙亥。\n又公元1981年10月1日干支壬子日,那么壬日之子时干支为庚子,丑时即为辛丑,寅时即为壬寅……亥时即为辛亥。\n一般十二时辰配24小时,是23~1时为子,1~3时为丑……21~23时为亥。张汝舟先生以为,夜半子时作为一日之首,正如夜半0点作为一日之起始一样。夜半11点59分还是前一日,少一分都不行,少一分都未进入下一天。0点以后才是一日开始,也就是子时的开始。子时应指0点到2点之间这两个小时,余当类推。\n旧有的说法是将子时分为子初、子正,子初指23~24时,子正指0~1时。具体应用时,将“子初”归于上一日,0点是分界线。第三讲观象授时\n第三讲观象授时 # “观象授时”这一术语是清代毕沅在《夏小正考证》中首先提出来的,十分形象地描述了原始民族的天文学知识,也表达了先民在上古时期制历依据天象的事实。我国古籍中《尚书·尧典》《夏小正》《逸周书·时训解》等书里都有不少观象授时的记述。下面就有关的几个主要问题,分别加以解说。\n一、地平方位 # 观测天象,不仅有一个标准的时间计量,还得有一个统一的方位概念。这就得从地平四方说起。\n一分为二,二分为四,就是四方、四时概念的发展。四分体制几乎是世界古老民族都具备的原始计数体制。古巴比伦把宇宙看成一个四等分的圆周,根据月相(新月、上弦、满月、下弦)把一个月分为四等分。古希腊于公元前6世纪就产生了以水、火、气、土为宇宙万物四大本源的理论。古印度有所谓“四大种子”,指的是风、火、水、地。\n中国古代的八卦,事实上也是一种四行理论:天生风,地载山,雷出火,水成泽。何尝不可以看成风、地、火、水四种物质元素?虽然五行——金、木、水、火、土这种多元物质本源论泛滥无涯,但不能否认产生得更早的八卦——即四行的存在。\n甲骨文中已有明确的四方记载:“东土受年,南土受年,西土受年,北土受年。”还有关于四方风的叙述:“东方曰析,南方曰夷,西方曰韦,□□□勹。”(后者阙文,自然指“北方曰勹”。)《山海经》已分大荒东经、南经、西经、北经,也有类似甲骨文中四方和四方风名的描写。《尧典》更将四仲中星所代表的春夏秋冬四季与东南西北四方联系起来。《管子·四时篇》说得很清楚:\n是故阴阳者,天地之大理也。四时者,阴阳之大经也……\n东方曰星,其时曰春,其气曰风。\n南方曰日,其时曰夏,其气曰阳。\n西方曰辰,其时曰秋,其气曰阴。\n北方曰月,其时曰冬,其气曰寒。\n这里的四方、四时、四气与日月星辰配,把天象与地平方位、四季、气令结合起来,顺次井然,脉络清晰。\n前些年发掘出来的殷代宫殿基址,其南北方向跟今天指南针所指方向完全吻合,这说明殷商时代测定东南西北方位已是完全准确的了。这准确的方位,古人是怎样测出的呢?《周髀算经》载:“以日始出,立表而识其晷;日入复识其晷。晷之两端相值者,正东西也。中折之,指表者,正南北也。”或者说,日出时表影的端点和日没时表影的端点的连线,就是正东和正西;线的中点,跟表本身的连线就是正南和正北。\n表示方位的词,一般都用东、南、西、北。东(東),古人以为“从日在木中”,指太阳升起的方位。西,古字形作,下像巢,上像鸟,《说文》云“鸟在巢上也”,又说“日在西方而鸟西”,可见西方之“西”是一个假借字。古人为区别字义而新造一个“栖”字,鸟西作鸟栖,“西”就专表西方了。南,是草木到夏天长满了枝叶的意思,所以“从(pō)(rěn)声”。夏天,太阳从那个方向来,就是南方。北,古时就是“背”字。朝南为正,背南当然是“北”了。古人又说北方是伏方,取万物伏藏的意思。\n中华民族的祖先生息在黄河流域一带,处在北半球,太阳总是在古人视觉的南面运动着,坐北朝南就成了人们生活的习俗,所以古代典籍记载东南西北方位的方法以及左右的概念与我们今天的认识是大不相同的。长沙马王堆三号汉墓出土的地形图及驻军图,都是坐北朝南的,上方指南,下方指北,与当今地图南北向正相反对。《楚辞·哀郢》“上洞庭而下江”也是指方位的,洞庭湖在南、长江在北,用上、下标志。\n因为是坐北朝南,古人的左右就是指东西方位而言了。《史记·项羽本纪》:“纵江东父兄怜而王我,我何面目见之?”《晋书·温峤传》:“江左自有管夷吾,吾复何虑?”江左就是江东。\n古代匈奴有左贤王、右贤王,以今之方位言之,则左在西,右在东,实际上左贤王居东部,右贤王居西部。古人所谓“左地”,即东部地区,“右地”即西部地区。\n古有左将军、右将军,左侍部、右侍部,左庶子、右庶子之类的职官,办公或用事之地,都是左在东,右在西。\n古人观天象以三垣二十八宿为基准。紫微垣有左垣、右垣,太微垣有左垣、右垣,天市垣亦有左垣、右垣。左垣就是东垣,右垣就是西垣。《史记·天官书》:“紫宫左三星曰天枪,右五星曰天棓。”《史记正义》云:“天市垣在太微垣左,房、心东北。”类似以左右指方位的记载在古书中比比皆是。要是我们夜观天象,使用当代星图,就要面对北极星才能切合。如果用旧式星图,必须坐北朝南,才能分出左右,不致迷惑。\n扬雄《解嘲》云:“今大汉左东海,右渠搜,前番禺,后椒途,东南一尉,西北一侯。”即用左右前后,亦指东西南北,方位还是准确的。\n表示方位的左右,古人也常用来表示地位的尊卑。这与中国西高东低的自然地形也是吻合的。左表示东方,又表示地位低下;右表示西方,又表示地位尊显。《史记·廉蔺列传》载:“以相如功大,拜为上卿,位在廉颇之右。”指地位高过廉颇。《后汉书·儒林传》:“(董钧)后坐事,左转骑都尉,年七十卒于家。”左转即左迁,就是降职。左低右高,若设左右二丞相,则右丞相地位高于左丞相;若有东宫西宫,自然西宫尊于东宫。因为右方地位高上,左方地位卑下,亲近赞助用右,疏远贬损用左,《战国策·魏策二》:“衍将右韩而左魏。”即公孙衍亲近、赞助韩国而疏远、损害魏国。\n东南西北也常用来表示地位、身份。历代帝王宝座都朝正南方,故有“南面称王”之说。这就是清代学者凌廷堪所说“堂上南向为尊”。贾谊《过秦论》:“秦并海内,兼诸侯,南面称帝,以养四海。”以坐北朝南为尊。胡诠《戊午上高宗封事》:“向者陛下间关海道,危如累卵,当时尚不肯北面臣虏。”指北面为臣。引申开去,没有占上风,处在下位,打败仗,称“北”。“败北”“追亡逐北”就是此意。这里的“南面”“北面”,是朝南、朝北,不是南方、北方。就座位说,北边的位子是尊位了。东与西,即前面讲的左与右,左东为低下,所以主人之位在东,右面为尊崇,所以宾客之位在西。主人称“东家”“做东”“东道主”,客位称“西席”“西宾”。\n君王南面而坐,公侯将相则东向而朝,以坐西向东为尊贵,这就是凌廷堪所谓“宫中以东向为尊”。《史记·廉蔺列传》载:“今括一旦为将,东向而朝,军吏无敢仰视之者。”《项羽本纪》载:“项王即日因留沛公与饮。项王、项伯东向坐;亚父南向坐——亚父者,范增也;沛公北向坐;张良西向侍。”不难看出,项王座位最尊崇,范增次之,沛公又次之,张良在东且是“侍”。后来樊哙撞进来,还是“披帷西向立”,站在东边。这自然是一张合乎礼仪及习俗的座次表了。\n古书里,又常以山川地势作为定方位的基准。山之南称阳,山之北称阴,水之南称阴,水之北称阳。衡阳,处衡山之南;贵阳,得名于贵山之南;华阴,处华山之北;河阳,指黄河之北;洛阳,处洛河之北;江阴,处长江之南;汉阴,指汉水之南。\n龙、虎也用来表示东西两个方位。《书·传》:“东方成龙形,西方成虎形。”因为“东溟积水成渊,蛟龙生之,西岳山峦潜形,虎豹存焉。”所谓“左青龙、右白虎”,就是东有青龙之象,西有白虎之形。东西有龙虎,南方配以鸟,北方配以龟,就是“南朱雀、北玄武”。综合龙、虎、鸟、龟,古人谓之“四象”。在古代的天文星图中,作为观象授时主要依据的二十八宿,也配以“四象”。传统的二十八宿歌诀是:\n角、亢、氐、房、心、尾、箕——东苍龙,\n斗、牛、女、虚、危、室、壁——北玄武,\n奎、娄、胃、昴、毕、觜、参——西白虎,\n井、鬼、柳、星、张、翼、轸——南朱雀。\n二十八宿配四象,起源很早。《书·尧典》载“二月日中星鸟”就是以井鬼柳星张翼轸之“星”宿为中星,不说“星星”,而说“星鸟”,可见四象的端倪。1979年湖北随县(今随州)出土了一只绘有二十八宿星图的箱盖,已确认是战国初期的实物。箱盖上,二十八宿之外,左绘龙形,右绘虎形,也是表方位的。\n由于在划分恒星群基础上产生的“四象”有着广泛的影响,所以古书利用它表方位的描写就很多。《曲礼》以“前朱雀、后玄武、左青龙、右白虎”这种星宿的布列显示行军布阵之法。张衡在《灵宪》中更有生动的描写:“苍龙连蜷于左,白虎猛踞于右,朱雀奋翼于前,灵龟圈首于后。”这左右前后,自然指的是东西南北方位。《鹖冠子》云“前张后极,左角右钺”,其含义也与《曲礼》《灵宪》所写四象同。张,指井鬼柳星张翼轸——南方七宿朱雀之“张”;极,指北极;角,东方苍龙七宿——角亢氐房心尾箕之“角”;钺,指西方白虎七宿——奎娄胃昴毕觜参之参宿区界内的钺星。由于北方七宿玄武之象隐入地平线下,人所不见,背后唯北极而已,故言“后极”。从表方位角度说,与言“后玄武”是一个意思。《说文》释“龙”字云:“龙,鳞虫之长也。春分而登天,秋分而潜渊。”前句还可以接受,后句“登天”“潜渊”就玄乎了。如果与四象之“左苍龙”联系起来就不难理解。恒星群东方七宿——角亢氐房心尾箕,春分后黄昏时候开始出现在东方,这就是民间所谓“二月初二龙抬头”的传说。经半年,秋分后这条龙就从西方地平线隐没了。这不正是“春分登天”“秋分潜渊”吗?\n四象与方位紧密联系,四象也就可做东南西北的代称了。南京的玄武湖,实际上是北湖的意思。唐朝长安有玄武门,当然是指北门。旧金陵有朱雀门、朱雀桥,长安旧城内有朱雀门,其他各地旧城的朱雀门,都指的是南门。同理,东海之滨的青龙镇,自然坐落在国土之东。其他,称青龙港、青龙河、青龙桥、青龙塔,都标志了其所在的地理位置。古时的“白虎”含贬义,这与原始时代虎豹危害人类生存的事实有关,所以做地名白虎洞者少有,而白虎堂、白虎厅之类已喻义军机紧要,示禁入内之意。\n两千多年来列为群经之首的《周易》是用卦爻辞反映作者思想体系的。《周易》基本卦次是乾 、坤 、震 、艮 、离 、坎 、兑 、巽 。司马迁有“文王拘而演周易”之说,所谓“文王八卦方位”西周时代就有了。其具体方位是:东(震)、南(离)、西(兑)、北(坎)、西北(乾)、西南(坤)、东南(巽)、东北(艮)。搞占卜预测的端公道士,就利用阴 阳 的变化,演示八卦,定出方位,以占筮的卦爻辞进行说解,俘获人心。\n《淮南子》称“天有四维”,这四维,并不指东南西北四方。而是“日冬至,日出东南维,入西南维……夏至,出东北维,入西北维。”这与八卦中的巽(东南)、坤(西南)、艮(东北)、乾(西北)的方位是一致的。\n能推演的历法产生以前还是观象授时。观象不仅可以授时,亦可以定方位。利用北斗七星斗柄所指,就可以确定方位与季节。《夏小正》载“正月斗柄悬在下”,“六月斗柄正在上”。因为北天极处在高空,绕极而转的斗柄悬在下,指北方;正在上,指南方。《淮南子·齐俗训》说:“夫乘舟而惑者不知东西,见斗极则寤矣。”东晋僧人法显《佛国记》云:“大海弥漫无边,不识东西,唯望日、月、星宿而进。”古人在茫茫大海里用北斗、北极星及其他星宿确定方位那是很自然的。保存了一些原始记载的《鹖冠子·环流》说:“斗柄东指,天下皆春;斗柄南指,天下皆夏;斗柄西指,天下皆秋;斗柄北指,天下皆冬。”斗柄的东南西北紧系着天下的春夏秋冬。所以,春夏秋冬也可以配合方位。那就是东方春,南方夏,西方秋,北方冬。\n地平方位图天干地支的使用远在商代以前。发掘出土的甲骨中已数次发现完整的干支表。殷周以降,干支不仅用来纪日、纪月,还用来纪年,也用来表示方位。天干表示方位的方法是,甲乙为东,丙丁为南,戊己为中,庚辛为西,壬癸为北。东南西北中配以十干,这自然是受了阴阳五行学说的影响。地支表示方位,汉代已属常见。《史记·历书》以“正北”代夜半子时,以“正东”代晨旦卯时,以“正西”代黄昏酉时,以“正南”代日中午时。《周髀算经》卷下有“冬至夜极长,日出辰而入申;夏至昼极长,日出寅而入戌”,这显然也是以地支表方位的。子为北,午为南,南北经线我们称之为子午线,一同此理。\n历代制作的浑仪以及天球仪上都装有地平环,一般都用四维、八干、十二支代表二十四个方位,位置的显示就精确得多了。(见右图)汉唐以来的月令图及民间使用的罗盘都用它来表示方位。\n地平方位图\n地平方位图甚至还用于标志汉字古读的声调。唐人在字的四角点四声。张守节《史记正义·论例》“发字例”云:若发平声每从寅位起,上点巳位,去点申位,入点亥位。到宋代改点为圈,位置依旧。对照附图,平上去入四声标志的位置就不难明了。\n二、三垣二十八宿 # 天空间繁星密布,日夜运转,周而复始。怎样从纷繁中理出一个头绪?最简便的就是识别一些亮星,再划分天区,以利观测。中国古代天文学分天区以三垣二十八宿,形成别具一格的划分法,自成独特的观象系统。按照先民的划分,每一天区的星又分成若干群,一群之内的星用各种假想的线连接起来,组成各种图形,并给一个相应的名字。古代称这一星群为星官,《史记·天官书》之“官”,源于此。“虽群星之散乱,但依象而堪核其实。”北斗七星可连成像一只长把的勺和古人用的酒斗,与“斗”相似,星官取名为斗。箕宿四星可连成簸箕的形状,名之曰箕。《诗·大东》:“维南有箕,不可以簸扬。维北有斗,不可以挹酒浆。”是就这两个星官说的。\n三垣的名称,依现存文字记载,完整的提法初见于唐初的《玄象诗》。《史记·天官书》中尚无“天市垣”,称紫微垣为“紫宫”,太微垣只称“太微”。可见形成一个体系较晚。\n二十八宿就不同了。有些星名在甲骨文中就已出现。《尚书·尧典》作为四仲中星的星已包括在二十八宿之中。日本天文学家新城新藏提出西周初年二十八宿已经形成,虽嫌证据不足,但亦不致晚于春秋。1972年湖北随州出土一件漆箱,箱盖上围绕北斗的“斗”字,有一圈二十八宿的名称。这座古墓的时代,比较肯定的说法是春秋末年或战国初期。\n二十八宿的名称是:\n东方七宿:角、亢(天根、本)、氐、房、心(农祥、天驷、大火)、尾、箕;北方七宿:斗、牛(牵牛)、女(须女、婺女)、虚、危、室(定、营室)、壁(东壁);\n西方七宿:奎、娄、胃、昴、毕、觜(觜觿)、参;\n南方七宿:井(东井)、鬼(舆鬼)、柳、星(七星)、张、翼、轸。\n早期星官的命名,都和生产生活中常见的事物有关。\n人物:老人、织女、农丈人;\n动物:鳖、天狼、角、牛、翼、尾;\n用具:五车、天船、斗、轸;、\n农具:箕、定(《尔雅·释器》注:锄属);\n猎具:弧矢、毕(带网的叉子);\n物件:天门、南门、柱、室、井、糠;\n其他:柳、火、天津、江、河。\n人类进入阶级社会,人间的一套政治机构和社会组织也相应地搬到了天上。如帝、太子、上辅、上弼、帝座、侯、宗人、上将、次将、上相、次相、五诸侯……这些帝王将相的名称,大理、天牢、垒壁阵、羽林军、八魁、军市、明堂、灵台、华盖……这些人世间常见的机构、组织、器物专名。正如古人言:\n天市者,原系天之市也。觉亦不应乎人之市。故垣外则有齐楚燕郑并姬周分封之国。垣之中,则有斗、斛、市楼及宗人、宗星、宗正、车肆、列肆、屠肆等星。故凡地之所有者,天亦应地而有之。如少微者,是少于紫微也。微何以言少?试观垣中,则太子、幸臣、从官之类,并三公、九卿之俦,及中独列五帝之座。可知上相、次相、上将、次将,皆属辅嫡子以嗣统者也。至于以列星而论,若有东咸,必有西咸,有南河必有北河,有左旗、左更、左辖、左摄提,则必有右旗、右更、右辖、右摄提,有外屏必有内屏,有三台、三辅必有三公、三师,有杵必有臼。有离瑜必有离珠,有土公而有土公吏、土司空者,由之有水委必有水府,水积之位也。推而广之,言水族者,则有鱼龟与鳖,言昆虫者,则有螣蛇与蜂,言旗者则有节游与弁,言戈者则有鈇钺与籥,言鸟者则鹤与火鸟、天鸤也,言兽者则有马与狗国、天狗也。然而星象甚繁,不能枚举。通盘考核,皆可取配成趣,变化致祥。学者当自举一反三耳。\n入宿度、去极度示意图\n若A为二十八宿距星,B为另一天体,过A、B的赤经圈分别交赤道于a、b。则B天体的入宿度为 ,去极度为 。\n所以,遍观中国的星座如同认识一个完整的封建社会\n古人又是怎样标示这些繁多的天体的具体位置呢?\n从天文学角度说,任何天体的位置可以由赤道坐标系标示也可以由黄道坐标系来标示。中国古代,广泛应用赤道坐标系标注天体位置。赤道,是指天球上一个与天极处处垂直的大圆。赤道坐标的两个分量是入宿度和去极度,即用去极度和入宿度表示天体位置。\n古代分周天36514度,配合二十八宿,一回归年运行一周天。二十八宿都是星群,测量两星群之间的距离得取其中一星为标准,定为距星,下宿距星与本宿距星之间的赤经差,就叫本宿的距度。二十八宿每宿的距度是不等的,加起来合36514度。\n《汉书·律历志·距度》载:\n角十二。亢九。氐十五。房五。心五。尾十八。箕十一。东七十五度。\n斗二十六(又四分之一)。牛八。女十二。虚十。危十七。营室十六。壁九。北九十八度(又四分之一)。\n奎十六。娄十二。胃十四。昴十一。毕十六。觜二。参九。西八十度。\n井三十三。鬼四。柳十五。星七。张十八。翼十八。轸十七。南百十二度。\n这样,每过一天,二十八宿便向西运行一度。每过一回归年,二十八宿便运行一周天。从而把日期的变更与星象的位移紧密联系起来,形成了二十八宿与十二月、二十四节气的对应关系。\n所谓“入宿度”就是以二十八宿中某宿的距星为标准,测出这个天体与这个距星之间的赤经差。如织女星入斗五度,就是说,它在斗宿范围内,与斗宿距星(斗一)的赤经差为五度。\n所谓“去极度”,就是所测天体距北极的角距离。\n弄明白表示天体位置的方法,古籍中有关的记载就可以读懂。如下面是以宋皇祐年间观测为准所编制的《宋代星宿》(日本学者薮内清著)中几个天体的位置。\n表列“赤经”,是指自春分点起沿着与天球周日运动相反的方向量度的数据。从0度到360度。现今之春分点在奎宿内(距星奎二赤经359度50),经井(井一赤经81度39)、角(角一赤经188度96)、斗(斗一赤经266度54)。分周天360度,这是西方用法,中国古代是不用它的。\n用入宿度和去极度标示天体位置的赤道坐标系和观测点的位置无关,而且同一天体的赤道坐标也不随时间而变化。因此在天文历表中,一般都用赤道坐标表示恒星的位置。只有研究太阳系天体位置和运动时,一般采用黄道坐标。\n中国古代分天区为三垣二十八宿,而国标上通行的是划分为八十八个星座。现今人们夜晚观星,有的用西名星座,有的用古代星名,更多的是中西星名杂用。如北京天文馆天象厅演示天象时,《怎样认星》《夏夜星空》《冬夜星空》等认星歌就是中西杂用,《夏夜星空》的认星歌是:\n认星先从北斗来,由北向西再展开。\n两颗极星指北极,向西轩辕十四在。\n大角、角宿沿斗把,天蝎、南斗把头抬。\n顺着银河向北看,天鹰天琴紧相挨。\n天鹅飞在银河上,夏夜星空记心怀。\n中国古代既有独特的观象体系——三垣二十八宿,所以我们得熟悉自己的这一套,便于考之于古籍。这里编一首《星象名称对照歌》,将三垣二十八宿所涉及的西方星座名称与中国古代名称对照起来,帮助记忆。\n《星象名称对照歌》:\n斗转星移满苍穹,中西名称两不同。\n划分三垣廿八宿,勾一为心各西东。\n北极勾陈小熊座,紫微左垣乘天龙。\n轩辕五帝座狮子,内屏端门室女空。\n天市两垣跨巨蛇,宗人宗正蛇夫中。\n织女渐台名天琴,河鼓右旗叫天鹰。\n北斗文昌大熊座,三台靠边熊掌跟。\n大角梗河两摄提,玄戈招摇牧夫星。\n南有角亢嫁室女,氐做天秤也公平。\n房心尾宿在天蝎,箕与斗建人马星。\n牛宿天田在摩羯,女虚坟墓居宝瓶。\n危室雷电如飞马,壁一在南当边兵。\n天大将军与奎北,壁二也归仙女星。\n奎南右更叫双鱼,黄道之上有外屏。\n娄胃左更白羊头,昴毕天关像金牛。\n觜参参旗当猎户,五车在北做御夫。\n井与北河成双子,南河水位小犬居。\n天狼军市大犬座,鬼在巨蟹翼巨爵。\n柳星张摆长蛇阵,轸上乌鸦叫不停。\n二十八宿加三垣,西洋名字要记清。\n隋代丹元子把周天各星的步位,编成一篇七字长歌,文辞浅近,便于传诵,当时就成为初习天文的必读歌诀,非常流行,这就是《唐书·艺文志》初载的《步天歌》。《步天歌》将星空分为三十一大区,即三垣加二十八宿,包括了当时全天已定名的1464颗恒星。我们读着《步天歌》,按着方向,或向东,或向南,由甲星到乙星,到丙星,好像在天上一步一步地走过去一样,条理分明,方便记忆。清代星历家梅文鼎评价《步天歌》:“句中有图,言下见象,或丰或约,无余无失。”\n现今能见到的《步天歌》已非丹元子原文,多源于《仪象考成续编》卷三所载之《星图步天歌》。今录《古今图书集成·乾象典》所载《步天歌》有关紫微垣一段,以示一斑。\n中原北极紫微宫,北极五帝在其中。\n大帝之座第二珠,第三之星庶子居,\n第一号曰为太子,四为后宫五天枢。\n左右四星为四辅,天乙太乙当门路。\n左枢右枢夹南门,左八右七十有五。\n上少宰兮上少弼,上少卫兮少丞数,\n前连左枢共八星,后边门东大赞府。\n少尉上辅少辅继,上卫少卫上丞比,\n以及右枢共七星,两藩营卫于斯至。\n阴德门里两黄聚,尚书以次其位五。\n女史柱史各一星,御女四星天柱五。\n大理两黄阴德边,勾陈尾指北极颠,\n勾陈六星六甲前。天皇独在勾陈里,\n五帝内座后门是。华盖并杠十六星,\n杠作柄象华盖形,盖上连连九个星,\n名曰传舍如连丁。垣外左右各六珠,\n右是内阶左天厨。阶前八星名八穀,\n厨下五个天棓宿。天体六星两枢外,\n内厨两星左枢对。文昌斗上半月形,\n依稀分明六个星。文昌之下曰三师,\n太尊只向中台明。天牢六星太尊边,\n太阳之守四势前。一个宰相太阳侧,\n更有三公柄西偏。杓下元戈一星圆,\n天理四星斗里暗,辅星近著太阳淡,\n北斗之宿七星明,第一主帝为枢精,\n第二第三璇玑是,第四名权第五衡,\n开阳摇光六七名,摇光左三号天枪。\n隋代以前,二十八宿仅指星宿个体,而《步天歌》始,每宿所指已是一大片星区。如讲到角宿,歌词是:\n两星南北正直著。中有平道上天田,\n总是黑星两相连。前有一鸟名进贤,\n平道右畔独渊然。最上三星周鼎形,\n角下天门左平星,双双横于库楼上,\n库楼十星屈曲明。楼中柱有十五星,\n三三相聚如鼎形。其中四星别名衡,\n南门楼外两星横。\n角宿星区所指,北至周鼎(去极64度半),南至南门(去极137度),实际上已包括了十一个星座,南北一大片了。夜观天象,常常以亮星为基准,由此推延开去。西方天文学上表示星的亮度,有一套独特的“星等”系统,天文图上就根据星等标志星宿的亮度。两千年前,希腊天文学家喜帕恰斯把肉眼可见的星按亮度分为六等,最亮的星称为一等星,肉眼刚能看到的星为六等星,其他星按视亮度插入,星越亮,星等越小。后人沿用这套星等系统,并经仪器检验加以精密化,规定:星等相差5等,亮度相差100倍。因此,星等增加一等,亮度变暗1001/5,即2.512倍。\n现今能用望远镜拍摄到暗达23等的星。有几颗亮星比1等星更亮,便向0等、负的等星扩充。最亮的恒星天狼星是-1.45等。金星最亮时达-4.22等,满月是-12.73等,太阳是-26.82等。下面是天空中二十颗最明亮的星的视星等及中西名称对照。\n这二十颗亮星中,南十字座α星与β星是我们北半球区不能见到的,清代以前尚无中文名称。从上可知,只有心宿二(古称火、大火)才是标准的一等星。\n这里我们讲一讲古代天文图。现今常见的天文图有两种,一是《辞海》理科分册所附“天文图”,那是以世界通用的西方八十八个星座划分天区的天文图。还有一种是王力主编《古代汉语》所附伊世同绘“天文图”,图中将我国古代主要星群都绘制出来,图上列有西方星座名称,初学者使用方便,堪称简明。\n东汉官图\n中国古代的天文图是一个圆形图,符合天圆之说。至迟在汉代就比较完备了。蔡邕《月令章句》中有一段文字记叙了当时的天文史官使用的官图。根据构拟,东汉官图如图(见下页)所示:用红色绘出三个不同直径的同心圆,圆心就是北天极。最内的小圆称作内规,也叫恒显圈。最外面的大圆称作外规,即南天可见的界线。中间一个圆代表赤道,它距南北两极相等,所以称“据天地之中”。文字中有“图中赤规截娄,角者是也”的话,所以二十八宿和黄道也就必不可少。除了二十八宿,还应当有中、外星官等。这就是《汉书·天文志》所载“天文在图籍昭昭可知者”的图籍。后代圆形星图的大圈外还标明地理分野、十二辰、十二次;靠内记二十八宿距度,赤经线按二十八宿距度画出,可从图上看出采用赤道坐标系标注天体位置。\n圆形星图的黄道本应是一个扁圆形,但古人也画成了一个正圆,致使星图上赤道以南的星官形状变形很大。隋代前后出现了一种用直角坐标投影的卷形星图,称作横图,弥补了这个缺点。《隋书·经籍志》载“《天文横图》一卷高文洪撰”就是。后来,除了一张表示赤道附近星官的横图,又画了一张以北极为中心的圆形图标注赤极附近的星官。王力《古代汉语》中所附天文图就源于隋唐时代的这种星图,一张横图,一张圆形图。\n这里再给大家介绍两个图表。表一(见下页)显示了中国传统的天文学观点,表二(见下页)是张汝舟先生的创制,反映张氏的古天文观。表一、表二的大圆圈,就是从地球赤道线之北23度半的圆周线上向高空延展而成的。二十八宿罗列在这条线上或稍南或稍北,就在这个大圆圈上移动。这个大圆圈,又名天球上的北回归线,自古一贯称为黄道。南回归线在南半球的高空,我们祖先没有利用它。西方天文学讲的“黄道”,与我国旧说不同。我们用旧说是为便于清楚地说明问题。\n表一向西指的箭头,是表示二十八宿的西行。表二加画一个东指箭头,表示二十八宿却又向东缓慢偏移,形成了“岁差”,从而规定了二十八宿以及北斗柄的运行关系。\n表一的二十八宿配四象,所以必按四象次序,从东方的南端,列角、亢、氐、房、心、尾、箕,向西移动;接着北方七宿斗、牛、女、虚、危、室、壁,也就从北方的东端向西移;西方、南方同样如此排列。这个排列次序,表明二十八宿向西移动。\n表一表二如用《尧典》“日永星火”“宵中星虚”来检验,可见表一之误。按表一所示,虚宿在火(心)宿之西83度弱(心52度加尾18度,加箕11度,加斗2614度,加牛8度,加女12度,加虚102度,得此数)。夏历五月,“日永星火”,即夏至昏火中。二十八宿西移,到八月,火(心)宿已落到地平线下,虚宿应在中天,即“宵中星虚”。按表一,火(心)宿纳入地平,虚宿已在地平之下83度多了,根本不合天象。\n更以岁星东移证明表一的错误。春秋期间,星历家把赤道圈划为十二等分,名十二宫,又名十二次,即星纪、玄枵、娵訾、降娄、大梁、实沈、鹑首、鹑火、鹑尾、寿星、大火、析木。每年岁星(木星)顺序移一次,十二年一转头,叫“岁星纪年”。《国语》《左传》里所谓“岁在星纪”“岁在玄枵”……代表子年、丑年……汉代理解为与岁星运行方向相反的“太岁”年,称为十二辰。《淮南子·天文训》称:“岁星为阳,右行于天;太阴为阴,左行于地。”《史记·天官书》称:“岁阴左行在寅,岁星右转居丑。”太阴、岁阴,就是太岁,也称“假岁星”。表一就在于反映这个“太岁左行”“岁星右行”。由于四象作祟,把宿位排颠倒了,造成混乱,与十二辰不相应,不得不把它也倒过来。反加混乱。从《汉书·律历志》“次度”可看出,星纪是十一月,建子;玄枵是十二月,建丑……只纪月不纪年,足证星纪、玄枵等名目与岁星纪年毫无关系。\n因四象之害,以至引起二十八宿排列之误倒,不得不妄列十二支与传统相反的排列,如表一外列的十二支,形成一个西行的箭头指示,不符合北斗柄东指十二支以定月;不符合冬至点71年8个月西移一度,就是恒星东移一度,十二次也是东移。表二改动的要点在此。\n表二纠正了历代就二十八宿配四象所造成的错误,恢复了二十八宿宿位排列的本来面目。这张表由于调整了二十八宿的位置,调整了十二宫、辰的位置,加了岁差、木星、北斗柄的方向,与传统的排列法比较,不仅彻底摆脱了“四象”的束缚,放正了二十八宿的位置,使“地望”“占卜术”无所依存,而且在以下几个方面有它突出的意义。\n1.二十八宿的运行与二十四节气的配合取得了一致。此表依据《次度》,二十八宿运行方向由东向西(箭头标明),冬至点在牛初,春分点在娄4度,夏至点在井31度,秋分点在角10度,历历分明。节气顺次与二十八宿运行方向一致。\n2.北斗柄方向与四季的方向、二十八宿运行方向吻合。由于北斗柄绕着北极转动,一年四季斗柄指向不同的方位,此表加一个北斗柄方向,与二十四节气顺次配合无误,与二十八宿运行也就吻合。这也表示了古人把北斗、北极与二十八宿紧密相连的观测星象方法。\n3.否定了“岁星纪年”。传统的二十八宿安排有内外十二支排列,这是迷恋岁星纪年,又假想出一个与木星成相反方向运行的假岁星(太岁)。张汝舟先生表二取消了假岁星的安排,明确了木星运行方向,十二支与纪月的“星纪、玄枵、娵訾……”相配合,彻底改变了对“岁星纪年”的认识。昙花一现的“岁星纪年”不过是四分历产生之前观象授时阶段的一个插曲而已。\n4.明确了岁差与岁差的方向。岁差虽是东晋人虞喜发现并加以计算,可是汉代已有明确记载:西汉末冬至点在建星(南斗尾附近),东汉时冬至点在斗宿2114度。冬至点从牛初的移动表明,汉代天文学家已观察到恒星的位移,并记录下来,更证明“冬至点在牛初”是战国初期的实际天象。\n三、《尧典》及四仲中星 # 上古时代,观象授时的历史是相当漫长的。《史记·天官书》载:“昔之传天数者:高辛之前,重、黎;于唐、虞,羲、和;有夏,昆吾;殷商,巫咸;周室,史佚、苌弘;于宋,子韦;郑则裨灶;在齐,甘公;楚,唐昧;赵,尹皋;魏,石申。”《史记·历书》记:“太史公曰:神农以前尚矣,盖黄帝考定星历,建立五行,起消息,正闰余,于是有天地神祇物类之官,是谓五官。各司其序不相乱也。”《国语·楚语》云:“少昊之衰也,九黎乱德,民神杂糅,不可方物。颛顼受之,乃命南正重司天以属神,命火正黎司地以属民。其后三苗复九黎之德,尧复育重黎之后不忘旧者,使复典之,故重黎氏世叙天地。”《国语·郑语》也说:“夫黎为高辛氏火正,以淳耀惇大,天明地德,光昭四海,故命之曰祝融。”\n这些记载都说明,先民对天象的观测可追溯到传说的远古时代。那时,南正火正的职务是分别由两人(重、黎)担任的。到后来,合南正、火正之职由一人主之,又以氏代事,重黎由人名变成职事之名,由二人之名合一名了。《尧典》所叙“羲和”与此大致略同。有以羲和为一人的,有以羲氏和和氏相称的,都不难理解。《楚语》韦昭注云:“尧继高辛氏平三苗之乱,继育重黎之后,使复典天地之官,羲氏和氏是也。”由此看出,传天数者还是祖传,有世家,观测天象的连续性有了保证。典籍中所记的结论也就比较可信,不必看做全是传说虚构。\n现存典籍最早而又比较完整记录观象授时的文字是《尚书·尧典》。对这段文字我们应当高度重视,因为它涉及的内容比较广泛,可以看做是上古观象授时的总结,可从中窥探先民丰富的天文学知识。\n其文曰:“乃命羲和,钦若昊天,历象日月星辰,敬授民时。”\n这一段讲尧用羲氏和氏家族中之贤能者,敬顺天理,观测日月星辰的运行,掌握其规律,以审知时候而授民,便于农事。\n要点:\n1.韦昭云,重黎之后为羲和。郑玄谓,尧育重黎之后羲氏和氏之贤者,使掌旧职天地之官。\n2.历,数也。就是观测。日月星辰,如《管子·四时篇》言,分别有所指。《左传·昭公七年》“晋侯谓伯瑕曰:何谓六物?对曰:岁、时、日、月、星、辰是也”,亦可证之。又,《尸子》“燧人察辰心而出火”,所谓“辰心”,就是恒星心,就是心宿。观心宿昏现而举行“出火”活动。比照《公羊传·昭公十七年》“大火为大辰,伐为大辰,北极亦为大辰”,凡大辰所指,皆为恒星。所以言“大”,是以之为观测星象的标准星。最早,先民是以北极星作为观测天象的标准星的,所以“北极亦为大辰”。夏代以参星(伐在参宿中)作为观测天象的标准星,所以“伐为大辰”。商代以大火(心宿二,即商星)作为观测天象的标准星,所以“大火为大辰”。凡日月星辰并举之“辰”,当指恒星而言。星,当指五星,《左传·昭公七年》“日月之会是谓辰”,那是指“一岁日月十二会,则十二辰”,是明显的时间概念。水星古名辰星,取近日不出一辰。辰星之“辰”是一个空间概念。后代“星辰”连续,泛指除日月之外的所有行星、恒星,也是可以理解的。\n分命羲仲,宅嵎夷,曰旸谷。寅宾出日,平秩东作。日中星鸟,以殷仲春。厥民析,鸟兽孳尾。\n申命羲叔,宅南交,曰明都。平秩南讹,敬致。日永星火,以正仲夏。厥民因,鸟兽希革。\n分命和仲,宅西,曰昧谷。寅饯纳日,平秩西成。宵中星虚,以殷仲秋。厥民夷,鸟兽毛毨。\n申命和叔,宅朔方,曰幽都。平秩朔易。日短星昴,以正仲冬。厥民隩,鸟兽氄毛。\n此四段文字可合读。有祖传专长的天文官分布四方,设固定观测点,进行长期观测,以东南西北、春夏秋冬分叙所观测到的日月星象、民事、物候等。\n要点:\n1.羲仲,官名,指春官。羲叔指夏官。和仲指秋官。和叔指冬官。仲、叔指羲氏和氏家族之子。《楚语》所谓“重黎氏世叙天地”,至夏商为羲氏和氏。\n宅,度。宅,古音定纽铎部;度,定纽模部。纽同,唐铎模对转。度即测量,观测。观测什么?当是表景。以定日出日入的时辰,表则相当于日晷。日中测景定节气,则为土圭。\n嵎夷、南交,不必是实指辽西某地或古交趾。嵎夷泛指东方,南交泛指南方,与下文西、朔方一致。连前“宅”字,当为观测四方表景,定春夏秋冬四时的日出日入时分。\n旸谷、明都、昧谷、幽都,当实指,即具体观测点。旸谷即首阳山谷,在今辽阳境。明都,依郑注增,不可考。昧谷即蒙谷,无考。幽都即幽州。\n2.寅宾,寅,敬也,礼敬如接宾。出日,方出之日。春分日测朝日之景,当在卯时,必先候之,如接宾客。秋分日测夕日之景,当在酉时,有饯别之义,故“寅饯纳日”。\n平秩,当作釆秩,辨别秩序义。东作,东始。言日月之行从东始,即以春分日为起算点。秋分时日月正好运行一年之半,故言“西成”,即西平,取平半义。\n南讹、朔易:讹,动义;易,变义。古称赤道为中衡,北回归线古称内衡,南回归线古称外衡。南讹,言日自内衡南行。朔易,言日自外衡北返。这几句是说,观测日景变化,分别日月运行的起点——东作(春分点),南讹极点(冬至点),中点——西成(秋分点),朔易极点(夏至点)。\n敬致,言冬夏致日,致日犹底日(《左传》)、待日,与寅宾、寅饯参照,言夏冬待正午日出以观表景。\n3.日中星鸟,日永星火,宵中星虚,日短星昴。这是讲春分、夏至、秋分、冬至四个气日的中星。浑言之,仲春的中星是星宿,仲夏的中星是火(心宿二),仲秋的中星是虚宿,仲冬的中星是昴宿,所谓“四仲中星”指此。\n4.析、因、夷、隩,皆为动词,指春夏秋冬四时民众的活动。析,分也,春日万物始动,当分散求食也。因,就也,夏日花果繁茂,当聚合就食也。夷,通怡,秋日果实累累,食多喜悦也。隩,冬日寒气降,当掘室避寒也。这是就上古部族人的活动说的。民以食为天,以“食”为解,更近其实。\n5.鸟兽孳尾、希革、毛毨、氄毛,指鸟兽在不同时令的表象。春季,鸟兽交配繁殖。夏季,毛稀而露皮革。秋季,毛羽鲜洁。冬季,生细毛自温。\n帝曰:咨,汝羲暨和。期三百有六旬有六日,以闰月定四时成岁。允厘百工,庶绩咸熙。\n这一段意思是,帝尧说:你们羲氏和氏子弟,观测天象,得知春夏秋冬一年有366日,又以置闰月的办法调配月与岁,使春夏秋冬四时不差,这就可以信治百官,取得各方面的成功了。这几句可以看做是尧对羲氏和氏勤劬观测验天象的“嘉奖令”。亦见上古帝王对观象制历何等重视,更看出星历在指导生产中的重要作用,在社会职事上的特殊地位。\n《尧典》一书,很多人认为是周代史官根据古代传闻旧说而编写的,还经春秋、战国时代所增补,但所记天象却不是春秋以后的,这一点可以肯定。“日中星鸟,以殷仲春”,“日永星火,以正仲夏”,“宵中星虚,以殷仲秋”,“日短星昴,以正仲冬”,这四句是实际天象的记录,标志着它产生的时代。历代都有人对《尧典》四仲中星进行研究,希望找到产生四仲中星的准确时代。其方法是,依据四颗中星的赤经差,再用岁差法计算出它的年代。近人更应用现代天文学的方法严格地推算。《新唐书·天文志》载,李淳风说:“若冬至昴中,则夏至、秋分,星火、星虚皆在未正之西。若以夏至火中、秋分虚中,则冬至昴在巳正之东。”四仲中星彼此是有矛盾的。正如竺可桢先生说:“以鸟、火、虚三宿而论,至早不能为商代以前之现象。惟星昴则为唐尧以前之天象,与鸟、火、虚三者俱不相合。”他以为观测星昴出现于南中天的冬季,正值农闲,天气寒冷,观测时间一定大为提前。这样解释,四仲中星就只能是殷末周初的天象。这是以鸟、火、虚三宿位置确定的。\n竺可桢先生研究《尧典》四仲中星,还认为四季的划分在认识天象方面起了关键作用。因为先民早就意识到,四季交替与恒星的运转有一种内在的联系,上古“观天数者”主要任务之一就是探索这内在联系的规律性。因此,先民对恒星分布的认识当是起源很早的,这才有《尧典》产生时代的比较准确的四仲中星的记载。\n以现代天文学科学数据逆推,四仲中星是公元前2000年的天象。据发掘陶寺夏墟遗址,确定的夏代纪年:约公元前2500年至公元前1900年,那么,四仲中星当是夏代的星象,考虑到肉眼观测的粗疏,再参证出土的甲骨卜辞,可以断定,至迟到殷商时代(公元前18世纪到前11世纪,准确地说,商代当是公元前1734—前1107年)古人已能用昏南中星测定二分二至,并能用闰月调整朔望月与回归年的关系。回归年计为366日,与真值相差甚远,不能据以创制历法,只能靠观象确定大致的节气时日。不过,更能说明不是什么“朏为月首”。\n《尧典》四仲中星举出了四颗标志星:鸟、火、虚、昴。鸟,即星宿。不言“星星”而言“星鸟”,避不成词。这四颗星是二十八宿中最关键的星,彼此的间距不是精确地相等,但已大致将周天划为四段,已见“四象”的雏形。所谓“星鸟”,以鸟代星宿,足证“四象”是以朱鸟为基础逐步发展完善的。《尧典》四仲中星已有周天恒星分为四方的意思。二十八宿中最关键的四颗星都处在四方的腹心位置。二十八宿当以这四颗星为基础发展完备起来。而二十八宿分为四群的意识产生得也很早,虽不能断定就有“四象”,说《尧典》已见“四象”的端倪还是不错的。\n《左传·昭公十七年》载:“我高祖少皞挚之立也,凤鸟适至,故纪于鸟,为鸟师而鸟名:凤鸟氏,历正也;玄鸟氏,司分者也;伯赵氏,司至者也;青鸟氏,司启者也;丹鸟氏,司闭者也。”凤鸟氏是历正,下面还有四名鸟官分管分(春分、秋分)、至(夏至、冬至)、启(立春、立夏)、闭(立秋、立冬),足见鸟的形象与天文学的密切关系。郑文光以为,鸟与云、火、龙一样,为原始氏族的图腾或自然崇拜。以鸟为图腾的原始氏族,把春天初昏南天的星象描绘成一只鸟,那也是容易理解的。更后,岁星(木星)纪年划周天为十二次,其中鹑首、鹑火、鹑尾三次相连,就是南宫朱鸟,包括井鬼柳星张翼轸七宿。岁星纪年虽行用于春秋中期之后,而“鹑”之名是早就有的了。\n总之,《尧典》所记载的天象,内容是丰富的。元代许谦在《读书丛说》中这样概括它:“仲叔专候天以验历:以日景验,一也;以中星验,二也;既仰观而又俯察于人事,三也;析因夷隩,皆人性不谋而同者,又虑人为或相习而成,则又远诸物,四也。盖鸟兽无智而囿于气,其动出于自然故也。”\n四、《礼记·月令》的昏旦中星 # 除了《尧典》,观象授时的完整记载还保存在《礼记·月令》之中。战国末期的《吕氏春秋》及西汉《淮南子》所记,亦与之大体吻合。前贤多以“月令载于《吕览》”为说,而《月令》对后世的影响是很大的,因为它与农业生产关系密切,直接指导着农事的安排。汉代以后,差不多历代都有类似《月令》的农书或总括天象、节气的《月令图》。著名的如汉代《四民月令》(崔寔)和《唐月令》。清李调元说:“自唐以后,言月令者无虑数十百家。”可见古人对《月令》的重视。\n《礼记·月令》所记天象是:\n孟春之月,日在营室,昏参中,旦尾中。\n仲春之月,日在奎,昏弧*中,旦建星中。\n季春之月,日在胃,昏七星中,旦牵牛中。\n孟夏之月,日在毕,昏翼中,旦婺女中。\n仲夏之月,日在东井,昏亢中,旦危中。\n季夏之月,日在柳,昏火中,旦奎中。\n孟秋之月,日在翼,昏建星中,旦毕中。\n仲秋之月,日在角,昏牵牛中,旦觜觿中。\n季秋之月,日在房,昏虚中,旦柳中。\n孟冬之月,日在尾,昏危中,旦七星中。\n仲冬之月,日在斗,昏东壁中,旦轸中。\n季冬之月,日在婺女,昏娄中,旦氐中。\n*弧在舆鬼南,建星近斗。\n二十八宿横亘一周天,如果知道初昏中星,就能确知其他三个时辰的中星。子、卯、午、酉四个时辰正好一周,处于四个象限,一推即得。正午太阳的位置,就是午时中星的位置,与夜半中星相冲的星度正是太阳所在,这时(午)太阳的视位最好,正好测影。\n我们利用《天文图》找到它的春分点、夏至点、秋分点、冬至点,就可以推知一日四个时辰(卯—旦,午—日中,酉—昏,子—夜半)的中星。因为二十八宿不仅有周年视运动,也有周日视运动,即一日行经一周天(实际是地球自转一周)。\n如果某日晨朝(卯)的星象是《天文图》上春分点的星象,当日正午就是夏至点的星象,当日黄昏就是秋分点的星象,当日夜半就是冬至点的星象。其余可按上表类推。\n《月令》所记是一年十二个月昏、旦、午三个时辰的宿位,夜半(子)的中星自然是容易推出的。应注意的是,冬夏季节昼夜时刻不均,用对应方法(利用四个象限)推求,未必就得实际天象。当然相去是不会远的。如果用二十八宿距度对照《月令》所记,我们可以发现,《月令》宿位已经考虑了四季昼夜时刻不均等的现象,《月令》所记应看做是实际天象的实录。虽然一般的看法以为《月令》录于《吕览》,而《吕览》所记仍来源于前朝史料,非战国末年的观测记录。从《月令》与诸典籍有关星象记载的对照可以看出,《月令》乃丑正实录,当是春秋前期或更早时期的星象记录。\n二十八宿与回归年的节气时令有如此紧密的联系,所以古人用二十八宿的中天位置来表达节气时令。古籍中这方面的记载很多,都应看做是观象授时的文字材料。\n《诗·定之方中》:“定之方中,作于楚宫。”定,即室宿。室宿初昏见于南天正中,正值秋末冬初,农事已毕,可以大兴土木,营建宫室。\n《周礼·夏官》:“季春出火,民咸从之,季秋内火,民亦如之。”此处“火”星即心宿,从商代起就受到重视,古书中多有记载。这里的“出火”,指火星昏现,这里的“内火”,有人解作“火星始伏”,其实“内”与“伏”还不是一回事。辨见后“《诗·七月》的用历”一节。\n《书·传》云:“主春者张,昏中可以种谷;主夏者火,昏中可以种黍;立秋者虚,昏中可以种麦;立冬者昴,昏中可以收敛。”这实际上是对《尧典》四仲中星的解说,只不过把“日中星鸟”之“鸟”理解为张宿。张、火、虚、昴成了春夏秋冬四仲中星,昏现南中天,与农事大有关系。亦见观象授时服务于农事。\n《国语·周语》载:“辰角见而雨毕,天根见而水涸,本见而草木节解。驷见而陨霜,火见而清风戒寒。”这是晨旦观星象的记录,对初秋到深秋的物象变化结合天象作了一番描述。角宿晨见,雨季已毕;天根(氐宿)晨见,河水干涸;本(亢宿)晨见,草木枯落;驷(房宿)晨见,开始降霜;火(心宿)晨见,寒风即至。从星象与时令关系说,“辰角见”即“晨角见”,辰通晨。有人是将“辰角”作为角宿看待的。\n另外,《尚书·洪范》伪孔传:“月经于箕则多风,离于毕则多雨。”就是源于《诗·渐渐之石》“月离于毕,俾滂沱矣”,指月亮经天,在箕宿或毕宿的位置。苏轼《前赤壁赋》“月出于东山之上,徘徊于斗牛之间”,也是以二十八宿(斗、牛)来表述月亮的位置。\n附《月令总图》于下。\n月令总图\n五、北极与北斗 # 地球自转有一定的倾斜度,其自转轴的北端总是正对着天球北极。地球自转,反映出恒星在天幕上的周日视动,地球公转反映出恒星在天幕上的周年视动。在恒星的视运动过程中,天球北极是不动的,其他恒星都在绕着它旋转。身处北半球的华夏族先民,对北极星的观测是高度重视的,所谓“北极亦为大辰”,指的是夏代以前的传说时代以北极星为观测群星运动的标准星。《论语·为政》“为政以德,譬如北辰,居其所,而众星共之”,是以众星绕北极旋转的天象来说明事理。《周礼·冬官·考工记》云:“昼参诸日中之景,夜考之极星,以正朝夕。”后人的记述确也反映了北极星在观象授时的早期所起的重要作用。\n《吕氏春秋·有始览》云:“极星与天俱游而天极不移。”古人已看出,当时的北极星(应是帝星即小熊座β)不在北天极上,北极星也在绕天极旋转,只不过它的视运动轨迹所形成的圆圈很小罢了。这就引出了我国古代天文学中一个独特的概念——璇玑玉衡。\n《尚书·大传》称:“璇者还也,玑者几也,微也。其变几微,而所动者大,谓之璇玑。是故璇玑谓之北极。”《星经》(即《续汉志十注补》)称:“璇玑者谓北极也。”刘向《说苑·辨物》也说:“璇玑谓北辰,勾陈枢星也。”\n不难明白,凡是旋转的东西都可以称为“璇玑”。北极星靠近北天极,也以很小的圆形轨迹绕天极旋转,所以称“北极璇玑”。\n所谓玉衡,是指极星附近很明亮的北斗七星。我国黄河中下游约处于北纬36度,天球北极也高出当地地平线36度。以36度为半径画一个圆,叫恒显圈,其中的星星绕北极旋转而始终不隐入地平线下。北斗七星正处在恒显圈内,终年可见。北斗由七星构成大勺形(见左图)。天枢、天璇、天玑、天权组成斗身,古称魁;玉衡、开阳、摇光组成斗柄,古称杓。《史记·索隐》引《春秋纬·运斗枢》:“斗,第一天枢,第二璇,第三玑,第四权,第五衡,第六开阳,第七摇光。第一至第四为魁,第五至第七为杓,合而为斗。”如图所示,将天璇、天枢连成直线,延长五倍的距离,可以找到北极星。北极星就是北方的标志。古人观星,总是将北极与北斗联系起来,以此定方位,定季节时令。《淮南子·齐俗训》云:“夫乘舟而惑者不知东西,见斗极则寤矣。”《史记·天官书》说:“北斗七星,所谓‘璇玑玉衡以齐七政’。……斗为帝车,运于中央,临制四乡,分阴阳,建四时,均五行,移节度,定诸纪,皆系于斗。”\n如果用地支指代方位,将北斗柄所指与二十八宿、二十四节气配合起来,按斗柄所指定出月份,即所谓“斗建”。\n斗柄所指孟春,日月会于娵訾,斗建寅\n仲春,会于降娄,斗建卯\n季春,会于大梁,斗建辰\n孟夏,会于实沈,斗建巳\n仲夏,会于鹑首,斗建午\n季夏,会于鹑火,斗建未\n孟秋,会于鹑尾,斗建申\n仲秋,会于寿星,斗建酉\n季秋,会于大火,斗建戌\n孟冬,会于析木,斗建亥\n仲冬,会于星纪,斗建子\n季冬,会于玄枵,斗建丑\n是随十二月运会,斗柄随月以建。《淮南子·时则训》与此同理:“孟春之月,招摇指寅,昏参中,旦尾中。……仲春之月,招摇指卯,昏弧中,旦建星中。”《古诗十九首》“明月皎夜光,促织鸣东壁;玉衡指孟冬,众星何历历”,是以斗柄所指来描绘夜色。用招摇,用玉衡,皆同斗柄。在更古的年代,招摇、玄戈也在恒显圈内,所谓“斗九星”即是。\n至于《鹖冠子·环流》所记:“斗柄东指,天下皆春;斗柄南指,天下皆夏;斗柄西指,天下皆秋;斗柄北指,天下皆冬。”那是保留了比较古老的根据斗柄回转而定四时的俗谚。\n肉眼观察到的北极星,位置是固定的,北斗七星在星空中也十分显眼,那就不难测出它们方位的变化。所以,先民观察北斗的回转以定四时。古籍中众多的关于北斗的记载就反映了上古的遗迹。\n毕竟北斗只在一个不大的恒显圈内回转,比不上赤道附近恒星群视运动的视角大,更便于观测。所以,在夏商时代,先民就有观察某些特定恒星以定时令的习惯,夏代以参宿昏现西方,殷代以大火昏现东方,作为春季到来的标志。更进一步,就以二十八宿为背景,测定昏旦中星以定四时。《尚书·尧典》就体现了用四颗恒星的昏中来测定四时的观测方法。\n《史记·天官书》记“杓携龙角(斗柄指向角宿),衡殷南斗(衡对向南斗宿),魁枕参首(1~4星枕于参宿之首)”,是将北斗与二十八宿联系起来,摆到更大的空间来加以描述。这就可以通过对北斗星的观测,估计出处于地平线以下各宿的大约位置。\n所谓“璇玑玉衡”在远古时代就是指北极、北斗。随着观测星象由斗极转移至恒星群,加以观测仪器的创制,“璇玑玉衡”似又有了新的含义。东汉时代起,更有人认为,“璇玑玉衡”是一种天文仪器,如马融、郑玄、蔡邕是。《后汉书·天文志》:“帝在璇玑玉衡以齐七政”,孔安国注云:“在,察也。璇,美玉也。玑、衡,王者正天文之器,可运转者。七政,日月五星各异政。舜察天文,齐七政也。”宋代沈括在《梦溪笔谈》卷七中说:“天文学家有浑仪,测天之器,设于崇台,以候垂象者,即古玑衡是也。”这显然是把璇玑玉衡看做类似浑仪的仪器。\n《隋书·天文志》有一段话比较客观:“璇玑者谓浑天仪也。……而先儒或因星官名,北斗第二星名璇,第三星名玑,第五星名玉衡,仍七政之言,即以为北斗七星。载笔之官,莫之或辨。”可见持北斗说,来源甚早;持天文仪器说,大有人在。\n郑文光氏以为:“星象观测和仪器的发明之间存在一定的关系,天文仪器的设计思想,往往是从星辰的运动得到启示的。”由北极璇玑四游的实际天象,到利用北斗七星回转的斗柄所指定方位,定四时,再进而创造天文仪器——“璇玑玉衡”——浑仪的前身,这就是一条发展的线索。所以郑氏说:“璇玑玉衡既可以是仪器,又可以是星象。或者说,是两者的辩证的统一。”\n六、分野 # 古代的占星术充分利用了各种天文现象,占星对古代天文学的影响是很大的。比如星宿的分野就是明显的反映。《史记·天官书》说:“天则有列宿,地则有州域。”把天上的星宿与地上的州国联系起来,并以星宿的运动及其变异现象来预卜州国的吉凶祸福。列宿配州国,就是所谓的“分野”。\n由于占星术随着时代有所发展,加之占星家各自所采用的系统不同,对于州国的分配方法,各种史料的记载是不一致的。\n有按五星分配的,如《史记·天官书》太史公曰:“二十八舍主十二州。斗秉兼之,所从来久矣。秦之疆也,候在太白,占于狼弧;吴楚之疆,候在荧惑,占于鸟衡;燕齐之疆,候在辰星,占于虚危;宋郑之疆,候在岁星,占于房心;晋之疆,亦候在辰星,占于参罚。”\n有按北斗七星分配的,如《春秋纬》称:“雍州属魁星,冀州属枢星,兖州、青州属机星,徐州、扬州属权星,荆州属衡星,梁州属开星,豫州属摇星。”其中魁星指天璇,枢星指天枢,机星指天机,权星指天权,衡星指玉衡,开星指开阳,摇星指摇光。《月令辑要》卷一所载,按斗九星分野叙说。\n有按十二次分配的,如《周礼·春官·保章氏》郑注称:“九州州中诸国之封域,于星亦有分焉;今其存可言者,十二次之分也。星纪,吴越也;玄枵,齐也;娵訾,卫也;降娄,鲁也;大梁,赵也;实沈,晋也;鹑首,秦也;鹑火,周也;鹑尾,楚也;寿星,郑也;大火,宋也;析木,燕也。”\n有按二十八宿分配的,如《史记·天官书》称:“角、亢、氐,兖州;房、心,豫州;尾、箕,幽州;斗,江、湖;牵女、婺女,扬州;虚、危,青州;营室至东壁,并州;奎、娄、胃,徐州;昴、毕,冀州;觜觿、参,益州;东井、舆鬼,雍州;柳、七星、张,三河;翼、轸,荆州。”\n《吕氏春秋·有始览》分配方法又不同:“天有九野,地有九州。……何谓九野?中央曰钧天,其星角、亢、氐;东方曰苍天,其星房、心、尾;东北曰变天,其星箕、斗、牵牛;北方曰玄天,其星婺女、虚、危、营室;西北曰幽天,其星东壁、奎、娄;西方曰颢天,其星胃、昴、毕;西南曰朱天,其星觜觿、参、东井;南方曰炎天,其星舆鬼、柳、七星;东南曰阳天,其星张、翼、轸。何谓九州?河汉之间为豫州,周也;两河之间为冀州,晋也;河济之间为兖州,卫也;东方为青州,齐也;泗上为徐州,鲁也;东南为扬州,越也;南方为荆州,楚也;西方为雍州,秦也;北方为幽州,燕也。”这是按照中央及八方位把天分为九野,以中、东、北、西、南顺次配以二十八宿(北方独配四宿)。其东方曰苍天,北方曰玄天,西方曰颢天(颢即白义),西南曰朱天,南方曰炎天,是从五行说而来的。其东北曰变天,西北曰幽天,东南曰阳天,是从阴阳说而来的。高诱注说:“东北,水之季,阴气所尽,阳气所始,万物向生,故曰变天。西北,金之季也,将及太阴,故曰幽天。”又说:“钧,平也。为四方主,故曰钧天。”\n《淮南子·天文训》载“天有九野”和《吕氏春秋》同,只是颢天改为昊天,婺女改为须女而已。九野与地上诸国的关系,就明显不同。《天文训》称:“星部地名:角、亢,郑;氐、房、心,宋;尾、箕,燕;斗、牵牛,越;须女,吴;虚、危,齐;营室、东壁,卫;奎、娄,鲁;胃、昴、毕,魏;觜觿、参,赵;东井、舆鬼,秦;柳、七星、张,周;翼、轸,楚。”许慎注说:“角、亢、氐,韩、郑之分野;尾、箕一名析木,燕之分野;斗,吴之分野;牵牛一名星纪,越之分野;虚、危一名玄枵,齐之分野;营室、东壁一名承委,卫之分野;奎、娄一名降娄,鲁之分野;昴、毕一名大梁,赵之分野,觜觿、参一名实沈,晋之分野;柳、七星、张一名鹑火,周之分野;翼、轸一名鹑尾,楚之分野。”\n《汉书·地理志》的分野是:“秦地于天官,东井、舆鬼之分野也。……自井十度至柳三度,谓之鹑首之次,秦之分也。魏地,觜觿、参之分野也。……周地,柳、七星、张之分野也。……自柳三度至张十二度,谓之鹑火之次,周之分也。韩地,角、亢、氐之分野也。……及《诗·风》陈、郑之国,与韩同星分焉。郑国,今河南之新郑,本高辛氏火正祝融之虚也。……自东井六度至亢六度,谓之寿星之次,郑之分野,与韩同分。赵地,昴、毕之分野也。……燕地,尾、箕之分野也。……自危四度至斗六度,谓之析木之次,燕之分也。齐地,虚、危之分野也。……鲁地,奎、娄之分野也。……宋地,房、心之分野也。……卫地,营室、东壁之分野也。……楚地,翼、轸之分野也……吴地,斗分野也。……粤地,牵牛、婺女之分野也。”如果按二十八宿次度排列,就是:\n韩地——角、亢、氐宋地——房、心\n燕地——尾、箕吴地——斗\n粤地——牵牛、婺女齐地——虚、危\n卫地——营室、东壁鲁地——奎、娄\n赵地——昴、毕魏地——觜觿、参\n秦地——东井、舆鬼周地——柳、七星、张\n楚地——翼、轸\n这和上面所列许慎、高诱的分野说完全一样,应看做是东汉时代的分野思想。\n至于分野的来历,《名义考》说:“古者封国,皆有分星,以观妖祥,或系之北斗,如魁主雍;或系之二十八宿,如星纪主吴越;或系之五星,如岁星主齐吴之类。有土南而星北,土东而星西,反相属者,何耶?先儒以为受封之日,岁星所在之辰,其国属焉。吴越同次者,以同日受封也。”这是说,分野主要依据该国受封之日岁星具体时辰具体位置。郑文光氏以为,至少有三国不是这样分的。一个是宋,“大火,宋也”。周克商,封殷商后裔于宋,殷人的族星为大火,仍以大火为宋的分野。一个是周,“鹑火,周也”。周人沿袭殷人后期观察鹑火以定农时的习俗,鹑火于是成了周的分野。一个是晋,“实沈,晋也”。实沈是夏族的始祖,夏为商灭,其地称唐,周成王封其弟于此,称唐叔虞,就是晋国。这三个分野实际上反映了古代不同民族观测的不同的星辰。可见,分野说不能笼统地视为宗教迷信。相反,可以说,我们的研究似乎还未够深入。\n分野与社会人事相配合,成了天人相应,这是占星术的内容。古籍中有关的记载很多。《左传·昭公三十一年》记:“吴其入郢乎。……火胜金。”是就五星分野预卜吉凶的。《史记·天官书》记:“毕曰罕车,为边兵,主弋猎,其大星傍小星为附耳,附耳摇动,有谗乱臣在侧。”《后汉书·天文志》载:“王莽地皇三年十一月,有星孛于张东南,行五日不见。孛星者,恶气所生,为乱兵。……张为周地,星孛于张,东南行即翼、轸之分。翼轸为楚,是周楚之地,将有兵乱。后一年正月,光武起兵舂陵。……(孝安永初)四年六月癸酉,太白入舆鬼。指上阶,为三公。后太尉张禹、司空张敏皆免官。太白入舆鬼,为将凶。后中郎将任尚坐赃千万,槛车徵,弃市。”历代天文志,言及星象,多是这方面的内容。\n古代文学作品关于分野的写法,多是指地域说的,可看成文人以分野用典。如庾信《哀江南赋》说:“以鹑首而赐秦,天何为而此醉?”这是用十二次分野的陈规发问。王勃《滕王阁序》开头四句是:“豫章故郡,洪都新府。星分翼轸,地接衡庐。”翼宿、轸宿的分野是楚,是荆州,包括了洪州郡,滕王阁即在郡治南昌的长洲上。李白《蜀道难》有“扪参历井仰胁息”句,依《史记·天官书》,参宿分野是益州,井宿分野是雍州,“扪参历井”极写从雍州到益州整个路途的艰难。\n七、五星运行 # 除了满天的恒星,天穹中还有肉眼可见的五大行星,古人将它们与日、月合称七政或七曜。《尚书·舜典》有“在璇玑玉衡以齐七政”,后人理解为“日月五星,谓之七政”。五星又称五纬。\n五星都很明亮,比一等星还亮,金星、木星、火星的亮度超过了最亮的恒星——天狼星。加之行星在夜空中的位置常常发生变化,五星早就是先民观测的目标了。民间文学作品更将其当作歌咏的对象。《诗·大东》:“东有启明,西有长庚。”《诗·女曰鸡鸣》:“子兴视夜,明星有烂。”《诗·东门之杨》:“昏以为期,明星煌煌。”证明了先民对行星的认识已相当成熟。\n古人称金星为明星、太白,黎明前见于东方叫启明,黄昏见于西方叫长庚。古人称木星为岁星,火星又名荧惑,土星又叫镇星、填星,称水星为辰星。这些命名,当在春秋时代已经完成。战国时代五行说得以发展,金木水火土之名才冠于行星之上。\n古人观测五星运动是以二十八宿坐标为背景的。《论衡·变虚》“荧惑守心”,是指火星在心宿的位置。邹阳《狱中上梁王书》有“太白食昴”,指金星占了昴宿的位置。\n到了汉代,阴阳五行说得其完备,占星术更有发展,五星的运动也同样被附上吉凶含义。《汉书·天文志》载:“岁星所在,国不可伐,可以伐人。超舍而前为赢,退舍为缩。赢,其国有兵不复;缩,其国有忧,其将死,国倾败。所去失地,所之得地。”“荧惑,曰南方夏火,礼也,视也。礼亏视失,逆夏令,伤火气,罚见荧惑。逆行一舍二舍为不祥,居之三月国有殃,五月受兵,七月国半亡地,九月地大半亡。因与俱出入,国绝祀。……荧惑,天子理也,故曰虽有明天子,必视荧惑所在。”“太白出而留桑榆间,病其下国。上而疾,未尽期日过参天,病其对国。太白经天,天下革,民更王,是为乱纪,人民流亡。昼见与日争明,强国弱,小国强,女主昌。”“辰星,杀伐之气,战斗之象也。与太白俱出东方,皆赤而角,夷狄败,中国胜;与太白俱出西方,皆赤而角,中国败,夷狄胜。”“填星所居,国吉。未当居而居之,若已去而复还居之,国得土,不乃得女子。”等等。显然没有什么科学根据。\n现代天文学告诉我们:\n1.各行星绕日公转的方向(由西向东)是一样的,且跟地球自转方向一致。\n2.行星自转方向几乎相同,也就是自西向东,只有金星和天王星逆向自转。\n3.各行星的轨道接近圆形,且接近于一个平面,即地球轨道平面。\n4.各行星距离太阳有一定的规律。\n在长期的实践过程中,我国古历天文工作者逐渐认识了五星运行的许多特性,逐步掌握了五星运动的规律。《汉书·天文志》说:“古代五星之推无逆行者,至甘氏、石氏经,以荧惑、太白为有逆行。”《隋书·天文志》也说:“古历五星并顺行,秦历始有金、火之逆。又甘、石并时,自有差异。汉初测候,乃知五星皆有逆行。”\n行星在天空星座的背景上自西往东走,叫顺行,反之,叫逆行。顺行时间多,逆行时间少,顺行由快而慢而“留”(不动)而逆行,逆行亦由快而慢而留而顺行。\n行星的真实运动情况\n本来,行星都是由西向东运行的,各自在自己的轨道上绕太阳公转,公转一圈的时间叫做“恒星周期”。水星88日,金星225日,地球1年,火星1.88年,木星11.86年,土星29.46年。恒星周期代表日心运动,而我们是从运动中的地球观察行星的,这就有一个太阳、地球和行星三者之间的关系问题。\n如图示,我们把行星(P)、地球(E)和太阳(S)之间的夹角PES叫距角,即从地球上看,行星和太阳之间的角距离。这个距离可以由太阳和行星的黄经差来表示。黄经即从春分点起,沿黄道大圆所量度的角度。显然,对于外行星(火、木、土等)来说,距角可以从0°到180°,但对于内行星(金、水)则不能超过某一最大值。这一最大值随行星轨道的直径而异,金星为48°,水星为28°,水星离太阳的视距离不过一辰,古人因此称水星为辰星。内行星处在这个最远位置时,在太阳之东叫东大距,在西叫西大距,此时最便于观测。\n当距角∠PES=0°,即行星、太阳和地球处在一条直线上,并且行星和太阳又在同一方向时叫“合”。行星从合到合所需的时间,叫做“会合周期”。水星115.88日,金星583.92日,火星779.94日,木星398.88日,土星378.09日。对内行星来说,尚有上合和下合之分,会合周期从上合或下合算起都行。上合时行星离地球最远,显得小一点,光亮的半面朝着地球,下合时情况相反。合的前后,行星与太阳同时出现,无法看到。合,只能由推算求得。从文字记载看,到东汉四分历(公元85年)才出现了合的概念。\n就内行星来说,上合以后出现在太阳东边,表现为夕始见。此时在天空中顺行,由快到慢,离太阳越来越远。过了东大距以后不久,经过留转变为逆行,过下合以后表现为晨始见,再逆行一段,经过留又表现为顺行,由慢到快,过西大距以至上合,周而复始。在星空背景上所走的轨迹如图示,呈柳叶状。宋代沈括在《梦溪笔谈》卷八里曾说:“予尝考古今历法五星行度,唯逆、留之际最多差。自内而进者,其退必向外,自外而进者,其退必自内。其迹如循柳叶,两末锐,中间往返之道,相去甚远。”\n一个会合周期里内行星在星座间的移动情况(柳叶形)\n和内行星不同,外行星在合以后,不是出现在太阳的东边,而是在西边,表现为晨始见。因为外行星的线速度比太阳的小,虽然仍是顺行,离太阳却越来越远,结果它在星空所走的轨迹如图示,呈“之”字形。其先后次序是:合→西方照→留→冲→留→东方照→合。方照即距角PES<90°。西方照时,行星于日出前出现在正南方天空;东方照时,行星于日落后见于南中天。外行星的逆行发生在冲的前后,两次留之间,这时行星也最亮。正如《史记·天官书》所说:“反逆行,尝盛大而变色。\n一个会合周期里外行星在星座间的移动情况(“之”字形)\n内行星与外行星明显的不同是,内行星有“晨始见”和“夕始见”,而外行星只有“晨始见”。因为外行星在一个会合期内,只有一次(上)合日。《汉书·律历志》所记三统历,把外行星的会合周期叫做“见”(一次始见),内行星的叫做“复”(两次始见)。这说明汉代已注意到了内行星与外行星的区别。\n古人对行星亮度的变化也加以记载。《开元占经》卷六十四所引文字,是战国初期天文学家甘德、石申等人的遗笔,他们将五星亮度强弱分为四类:喜、怒、芒和角。历代多沿用这四个专有名词来描述五星亮度的变化:“润泽和顺为喜”,“光芒隆谓之怒”,“光五寸以内为芒”,“光一尺以内为角,岁星七寸以上为角”。\n关于五星会合周期,唐代大衍历以前,古人的定义是:从晨始见到下次晨始见的时间间隔。大衍历之后,五星会合周期的定义与现代同,指行星连续两次与太阳相合的时间。古人很重视五星会合周期,《汉书·律历志》云“日月如合璧,五星如连珠”,并以此作为理想的历元,历法中的积年法就利用行星会合周期推算出这个“五星连珠”的理想历元。1982年3月出现的天文奇观——九星连珠,是千载难逢的景象。要是发生在古代,又是大吉之兆了。\n古人对五星运行的观测有很高的水平。1974年长沙马王堆三号汉墓(葬于公元前168年)出土的《五星占》(帛书),有六千字的专文记述五星的运行。其中还排列了秦王政元年(公元前246年)到汉文帝三年(前177年)共七十年间土星、木星和金星的位置及五大行星的会合周期。《五星占》是秦汉之际人们对五星认识的宝贵资料。《五星占》中关于金星动态的叙述最为详细:\n秦始皇帝元年正月,太白出东方,[日]行百廿分,[百日;行益疾,日行一度,百六十日;]行有[益]疾,日行一度百八十七分以从日,六十四日而复遝日,晨入东方,凡二百廿四日。浸行百廿日,夕出西方。太白出西方[始日行一度百八十七分,百日],行益徐,日行一度以待之,六十日,[行]有益徐,日行画卌分,六十四日而入西方,凡二百廿四日。伏十六日九十六分。[太白一复]为日五[百八十四日九十六分日,凡出入东、西各五,复]与营室晨出东方,为八岁。\n这段文字井然有序地把金星在一个会合周期内的动态分为:晨出东方——顺行——伏——夕出西方——顺行——伏——晨出东方几个大的阶段,对第一次顺行给出了先缓后急两个不同的速率,对第二次顺行更给出先疾、“益徐”和“有益徐”三个各异的速率。这些描述都是合乎金星运行的事实的。从《五星占》可见当时人们对五星会合周期认识的明显进步,记载金星会合周期的误差已小于0.5日。\n下面将西汉以前古人关于行星周期的认识列为一表(见下表),可看出其观测精确度的不断提高。\n八、《诗·七月》的用历 # 《诗·豳风·七月》是一首上古著名的农事诗,历来为人们所珍视,但是,要想准确、完整地解释这首诗并非一件易事,记事的用历就一直解说不清。《七月》按月份记农事,月份和农事关系极为密切,从而产生了一个令人费解的问题,即《七月》诗的月份是怎样安排的?\n对这个问题,历代学者有不同的解释:《毛诗》主张周正建子,《郑笺》主张夏正建寅,王力《古代汉语》主张周历、夏历并用,高亨《诗经选注》更提出用的是特殊的豳历。然而用周正建子或夏正建寅都无法通释全诗;周历、夏历并用之说好似圆通,其实违反常理,古今中外从无一首兼用两历让人糊涂的怪诗;至于特殊而古拙的豳历说,并无史料依据,只是臆度假想。因此,《七月》诗的用历依然是个谜。\n《毛诗》产生于“三正论”流行的战国末年。毛亨相传为鲁人,齐鲁尊周,建子为正,毛亨用周正解释《七月》诗是很自然的。《毛诗》云:“一之日,十之余也。一之日,周正月也;二之日,殷正月也;三之日,夏正月也;四之日,周四月也。”既然用“十之余”通释“一之日”“二之日”“三之日”“四之日”,那么这四个月就应该是周十一月(戌)、周十二月(亥)、周正月(子)、周二月(丑),即相当于夏正(寅)的九月、十月、十一月、十二月。然而,以夏正计,“九月觱发”“十月栗烈”“十一月于耜”“十二月举趾”,是无论如何也讲不通的。毛亨也感觉难以自圆其说,于是暗中将“四之日”释为“周四月”(按周正十四月应为周二月)。这样一来,周正四月(卯)实际相当于夏正二月(卯),虽然可以疏通下文,但是自己破坏了“一之日,十之余”的前例,令丑、寅两月无着落,所谓周历也就失去了统一性。可见,用周正建子解释《七月》诗是说不通的。\n到了东汉,郑玄释《七月》。因太初改历夏正为岁首深入人心,郑玄也就用夏正建寅笺注《七月》,但同样不能令人信服。比如,“七月鸣”一句,郑笺云:“伯劳(即)鸣,将寒之候也。五月则鸣。豳地晚寒,鸟物之候从其气焉。”就是说,伯劳就该五月鸣,因为豳地(今陕西一带)晚寒,到七月才叫起来。古代陕西一带竟比中原地区晚寒两个月,这是不合情理的。再说,夏正五月芒种、夏至,六月小暑、大暑,何来“将寒之候”?再如“七月食瓜”,夏正七月立秋、处暑,一开始吃瓜就是秋瓜,就不合物候农时,至于“九月筑场圃,十月纳禾稼”,也嫌太晚了。可见,用夏正解释也不妥当。\n更遗憾的是,无论周正也好,夏正也好,都无法准确地解释“七月流火”这一天象。《毛传》释:“火,大火也;流,下也。”后世大多依此阐述。余冠英《诗经选释》说:“秋季黄昏后,大火星向西而下,就叫做‘流火’。”北京大学《先秦文学史参考资料》认为:“每年夏历五月的黄昏,这星出现于正南方,方向最正而位置最高。六月以后,就偏西而下行,所以说是‘流’。”《七月》诗如用夏正,何以不说“六月流火”呢?至于周正七月正当夏正五月,大火星正当南天正中,是不会“流”的。\n我们认为,《七月》除了月份与农事的联系之外,诗中“七月流火”一句反复咏叹,是解决该诗用历的关键,也是该诗用历的标志。如前所述,上古经历了漫长的观象授时时期,古人把星象与时令联系起来,用以安排农事,《七月》诗就是典型的例证。古人心中的“火”,指二十八宿中的心宿,星大而呈红色,人人可见,称为“火”、“大火”,因为它与农事关系极为密切,商周时代对它的出没是相当重视的。《尚书·尧典》已有“日永星火”的记载。古人将一周天分为36514度(以二十八宿为坐标),由于地球公转,二十八宿每天西移一度,每月向西移约三十度。如果定点定时观测,某星宿正月初一在南天正中,二月初一就偏西约三十度,三月初一就偏西约六十度,四月初一就偏西约九十度入西方地平线下。这就是古人观星的“中、流、伏、内”四位。《夏小正》载:“正月初昏参中”“三月参则伏”“八月辰则伏”(传:房星也。房近火,即火伏)、“九月内火”;《月令》亦云“季夏之月(六月)昏火中”,《七月》又云“七月流火”。张汝舟先生考证,《夏小正》《月令》与《七月》星象吻合,建正一致,中流伏内顺次不紊。上述记载不仅明确告诉我们星位变化,而且具体说明了火星(心宿)的运行规律,即“六月火中”“七月流火”“八月火伏”“九月内火”。可见,“流火”之“流”不能只作为“西流”“流下”泛泛解释,应该理解为火星偏西约三十度的态势。火星的中、流、伏、内(纳,或入)表明了不同月份火星在天幕上的不同位置。明白中流伏内的概念,对于古人制历,对于季节的认识,都很有帮助。\n每一星宿都是十二个月一周天,每月某星移动的位置正好与钟表的十二个刻表相合。时钟从十二点到九点的距度正合火星中流伏内的方位。季夏之月(六月)初昏火中,指火星正当顶,在时钟刻度的十二点;七月火西流,指火星向西流下约三十度,在时钟的十一点上;八月再向西流下约三十度,火星在时钟的十点,这时西方日光还强,火星未落已不能再见,所以叫“火伏”;九月,火星再向西流约三十度,相当于时钟九点的位置,与地面平行,火星已潜下,所以叫“内(纳)火”。由此可知,“中”指星宿的位置居上,正南天;“流”指西移约三十度;“伏”指隐而不见,西移约六十度;“内”指落(纳入地平线)而不见。\n如前所述,《尧典》记“日永星火”,即夏至之月火星初昏位于南天正中,《尧典》用夏正,二至(夏至、冬至)二分(春分、秋分)必在仲月,夏至正当夏正五月,正与《夏小正》《七月》《月令》所记星象有一月之差。可见,《尧典》用夏正建寅,则《月令》《七月》《夏小正》必用殷正建丑。春秋前期及至西周一代,用丑正不用子正,不用夏正。《七月》用丑正,正与此合。\n不同建正的星位对比图\n为了说明问题,下面将不同建正的星位加以对比。\n由图可知,若依《尧典》建寅为正用夏历,当为:五月火中,六月流火,七月火伏,八月内火;若依《夏小正》《月令》《七月》建丑为正用殷历,当为:六月火中,七月流火,八月火伏,九月内火;若依周历建子为正,当为:七月火中,八月流火,九月火伏,十月内火。星象如此,非人所能妄测妄断。\n如果我们不计较冬夏黄昏的时差,将《月令》《夏小正》等古籍的初昏中星记载,按中流伏内的规律列出一个表,当时的天象就十分清楚。这个表帮助我们了解星宿运动的一般规律,有助于掌握观察天象的方法。\n再说《七月》。《毛传》认为“一之日,十之余也”,无疑是正确的。《七月》诗不说“十一月”“十二月”“十三月”“十四月”,而说一之日、二之日、三之日、四之日,是为了修辞,不死板,只要理解了“七月流火”一句建丑为正的实质,就不难列出《七月》诗的月序。\n《七月》诗用殷正建丑,《诗经》中并非仅有。《小雅·四月》云:“四月维夏,六月徂暑。”徂者,往也。若按周正建子,其六月相当于夏正四月(巳),正当立夏、小满,五月芒种、夏至,六月才小暑、大暑。周正“六月徂暑”岂不太早?若按夏正建寅,其六月已是小暑、大暑。正当暑天,何“徂”之有?只有用殷正建丑来解释,其六月正当夏正五月,正值芒种、夏至,才正合“六月徂暑”(六月走向暑天)之意。《毛传》云:“六月火星中,暑盛而往矣。”显然不妥。\n所以,张汝舟先生说:《七月》诗开头一句“七月流火”,就把它用的历告诉人们了,何况还有另外一大堆资料可凭呢!\n九、观象授时要籍对照表 # 在有规律地调配年、月、日的历法产生以前,中国古代漫长的岁月都是观象授时的时代。\n对上古先民观象授时,不少典籍都有详略不同的记载,先民将全年每月的天象、气象、物象加以记录,并以此为依据提示人们各月的农事活动。这在当时自有重要意义,文字给以郑重记载,后人视为经典,也就十分自然。\n历代学人对观象授时的有关记载认识不同,在中国古代文化史的研究中便生出许多疑窦,有关典籍的真实面目反而蒙混不清了。迷误世代相传,实有澄清之必要。\n为了对观象授时进行科学的研究,为了阅读有关典籍的方便,我们收录重要典籍的有关文字,依据相同的天象条件排列为一张表(见书后303页附表一),每月分天象(天)、气象(气)、物象(物)、农事活动(事)四项,比照内容,先民观象授时的概况就可了如指掌。\n这样一经对照,我们可以发现:\n1.《尧典》全年仲月星象正与《夏小正》《诗·七月》《月令》《淮南子·时则训》季月星象相应,可见《尧典》为寅正,其余四书为丑正。足见春秋以前,没有子正。《淮南子》虽汉代之书,《时则训》全抄《月令》,《月令》实前朝之旧典。\n2.除《尧典》外,余四书建正一致,但气象、物象小有差异,这是观察时地不同造成的。《月令》记载详于《夏小正》,足证《月令》的问世必在《夏小正》之后。\n3.典籍标明各月星宿“中、流、伏、内”四位,为揭示上古建正提供了天象证据。在这个基础上考证《诗·七月》及其他诗篇的用历,就可排除三正论的干扰,得到可信的结论。张汝舟先生有《〈诗经·七月〉之用历》一文,可供参阅。\n关于《夏小正》有关时令的解说,张汝舟先生有《〈夏小正〉校释》一文,刊于《贵州文史丛刊》1983年第1期,又收入《二毋室古代天文历法论丛》,浙江古籍出版社1987年版。第四讲二十四节气\n"},{"id":158,"href":"/zh/docs/culture/%E5%8F%A4%E4%BB%A3%E5%A4%A9%E6%96%87%E5%8E%86%E6%B3%95%E8%AE%B2%E5%BA%A7/%E7%89%88%E6%9D%83-%E5%BA%8F-%E5%89%8D%E8%A8%80/","title":"版权-序-前言","section":"古代天文历法讲座","content":" 版权信息 # 图书在版编目(CIP)数据\n古代天文历法讲座/张闻玉著.—2版.—桂林: 广西师范大学出版社,2017.10 (中华优秀传统文化名家讲座) ISBN 978-7-5495-9716-1\nⅠ.①古… Ⅱ.①张… Ⅲ.①古历法-基本知识- 中国 Ⅳ.①P194.3\n中国版本图书馆CIP数据核字(2017)第106428号\n广西师范大学出版社出版发行( 广西桂林市中华路22号 邮政编码:541001 )\n出版人:张艺兵\n全国新华书店经销\n开本:700 mm × 970 mm 1/16\n印张:23 字数:280千字\n2017年10月第2版 2017年10月第1次印刷\n印数:0 001~4 000册 定价:59. 80元\n目录\n版权信息\n《古代天文历法讲座》新版序\n序\n前言\n第一讲为什么要了解古天文历法 一、时间与天文历法\n二、天文与历法\n三、天文常识\n四、历的种类\n五、古天文学与星占\n六、古代天文学在阅读古籍中的作用\n七、怎样学好古天文历法\n第二讲纪时系统 一、纪年法\n二、纪月法\n三、纪日法\n四、纪时法\n第三讲观象授时 一、地平方位\n二、三垣二十八宿\n三、《尧典》及四仲中星\n四、《礼记·月令》的昏旦中星\n五、北极与北斗\n六、分野\n七、五星运行\n八、《诗·七月》的用历\n九、观象授时要籍对照表\n第四讲二十四节气 一、先民定时令\n二、土圭测景\n三、冬至点的测定\n四、岁差\n五、节气的产生\n六、二十四节气的意义\n七、节气的分类\n八、节气的应用\n九、杂节气\n十、七十二候\n十一、四季的划分\n十二、平气与定气\n第五讲四分历的编制 一、产生四分历的条件\n二、《次度》及其意义\n三、四分历产生的年代\n四、四分历的数据\n五、《历术甲子篇》的编制\n六、入蔀年的推算\n七、实际天象的推算\n八、古代历法的置闰\n九、殷历朔闰中气表\n第六讲四分历的应用 一、应用四分历的原则\n二、失闰与失朔\n三、甲寅元与乙卯元的关系\n四、元光历谱之研究\n五、疑年的答案及其他\n第七讲历法上的几个问题 一、太初改历\n二、八十一分法\n三、关于刘歆的三统历\n四、后汉四分历\n五、古历辨惑\n六、岁星纪年\n七、关于“月相四分”的讨论\n附录 西周金文“初吉”之研究\n再谈金文之“初吉”\n一、关于蔡侯墓青铜器的历日\n二、关于“准此逆推上去”\n再谈吴虎鼎\n一、宣王十八年天象\n二、厉王十八年天象\n三、涉及的几个问题\n簋及穆王年代\n伯吕父的王年\n关于成钟\n关于士山盘\n穆天子西征年月日考证——周穆王西游三千年祭\n从观象授时到四分历法——张汝舟与古代天文历法学说\n附表一观象授时要籍对照表\n附表二殷历朔闰中气表\n附表三术语表\n主要征引书目\n后记\n《古代天文历法讲座》新版序 # 汤序波\n张闻玉先生是章黄学派在当代传统小学界的重要传人,在古代天文历法研究与西周年代考证等方面成果尤为丰硕,是学界公认的当代天文历法考据学派代表性人物。李学勤先生曾赞许张闻玉先生这方面的研究是“观天象而推历数,遵古法以建新说”。\n天文历法学乃闻玉先生学术中最为重要和精彩的部分,也是他为学的看家本领。窃以为先生学术有两大支撑:一是小学;一是古天文历法。而最有特色、影响最大的当推后者,堪为张门的独门绝技。我们知道,古天文历法至近代已几为绝学,研习殊难。清初,顾炎武在《日知录》卷三十里曾感慨:“三代以上,人人皆知天文”,而“后世文人学士,有问之而茫然不知者矣”。曾国藩在《家训》中曾说“余生平有三耻”,而第一耻即不懂“天文算学”。然而在闻玉先生他们那儿,这门学问并不如人们想象的那么神秘,这得归功于他们当年遇到了“明师”张汝舟(1899—1982)先生。\n汝舟先生系黄侃先生在中央大学时的高弟,向有“博极群书”之誉,曾在贵州高校从教二十七年,“桃李满黔中”。1957年5月,老先生因在省委统战部召开的知识分子座谈会上发表所谓“三化”言论(即奴才进步化、党团宗派化、辩证唯心化),后被打成“极右派”。“文革”中更被遣返故乡滁州南张村。他在困境中,精究古代天文历法不辍,终于拨雾见天,破解了向来被视为“天书”的《史记·历书·历术甲子篇》(四分术法则)和《汉书·律历志·次度》(天象依据),从而建立了完备而独具特色的古天文历法体系,学术贡献殊巨(汝舟先生在郁闷中读懂无人能懂的“天书”《历术甲子篇》,应了古语“文王拘而演《周易》,仲尼厄而作《春秋》”)。对这一体系,殷孟伦先生赞其“尤为绝唱”;王驾吾先生称其“补司马之历,一时无两”;而先祖父汤炳正先生告诉我:“两千年以来,汝舟先生是第一位真正搞清楚《史记·历书·历术甲子篇》与《汉书·律历志·次度》的学者。”\n1980年10月,由黄门高弟南京大学王气中教授、山东大学殷孟伦教授、南京师范大学徐复教授共同发起举办了“中国古代天文历法讲习会”,地点设在老先生任顾问教授的滁州师范专科学校,学习时间为一周。参与者有国内十七个单位的四十余人。当时老先生年事已高,辅导工作主要就是由一年前最先到滁州师专进修的闻玉先生担任。其间,先生完成了自己第一本天文历法论著《古代天文历法浅释》。此书通俗地论述了汝舟先生星历理论,是学习乃师天文历法学的重要入门书。它曾被多所大学翻印作为研究生教材,还收进程千帆先生点校南京大学1984年印的《章太炎先生国学讲演录》的《附中国文化史参考资料辑要》中。此书也是我生平第一次接触到的张氏天文历法学方面的著作,阅后如醍醐灌顶,至为心折。\n如何打开古天文历法学这扇大门,先生在书中写道:“我在从汝舟师学习的过程中有这样的体会:一是要树立正确的星历观点,才不至为千百年来的惑乱所迷;二是要进行认真的推算,达到熟练程度,才能更好地掌握他的整个体系。”又说:“古代天文历法的核心问题就是历术推算,不能掌握实际天象的推演,永远是个门外汉。”“历术,自古以来都认为推步最难,不免望而却步。依张汝舟的研究,利用两张表就能很便捷地推演上下五千年的任何一年的朔闰中气,不过加减乘除而已,平常人都能掌握。”先生整理出版汝舟先生《二毋室古代天文历法论丛》(浙江古籍出版社1987年版),书里也附有此书。先生的天文历法学说,以得汝舟先生天文历法之真传并发扬光大之,学界美誉为“张汝舟—张闻玉天文历法体系”。2009年11 月日本山口大学曾邀请他参加“东亚历法与现代化”的国际论坛,发表有关“东亚历法”的主旨演讲;《香港商报》也曾在封面以整版的篇幅介绍他的学说。先生这方面论著还有《古代天文历法论集》《古代天文历法讲座》二种,尤其是后者曾加印过多次,在读书界影响极大。《南方都市报》2008年3月23日的“国学课”(第四堂)“与古人一起仰望夜空”,所用教材即此书。米鸿宾先生主持的“十翼书院”向学员极力推崇此书,也专请闻玉先生到场讲授。1984年以来,闻玉先生先后到南京大学、湖南师大、东北师大、南昌大学、四川大学等国内高校给文史研究生亲授天文历法知识,让一代年轻学人获益。顺真居士说:“2008年,广西师范大学出版社出版了闻玉先生的《古代天文历法讲座》一书,又使这一传统绝学‘飞入寻常百姓家’,推动了‘国学’在科学性方面的进展,其嘉惠学林、开拓未来,确实是功德无量。”\n“又使这一传统绝学‘飞入寻常百姓家’”,的然。毫无疑问,闻玉先生的《古代天文历法讲座》一书,是打开“张汝舟—张闻玉天文历法体系”之大门最好的钥匙,有着长久的学术生命力。由读是书再进而研习汝舟、闻玉师弟的相关论著,这对未来一代学子于“古天文历法学”之“登堂入室”,自是有所裨益的。\n闻玉先生是书将再版,辱承下顾,徵序于余,理不当应命,义不敢遽辞,乃缀数语聊表钦仰云。后学汤序波敬序于丁酉年二月十九日。\n序 # 王气中\n古代天文历法是古代劳动人民在生产斗争中伟大的发现和创造,它代表着一个民族的文化水平,是古代文明的标志。我国是世界上最早发明天文历法的文明古国之一,我们的祖先被称为“全世界最坚毅、最精明的天文观测者”。远在四五千年以前,我国历史进入有文字记载的初期,我们的祖先已经知道观测天象,根据日、月、星辰的运转和气候的变化以及草木的荣枯和鸟兽的生灭,创造了历法。在现存的古代典籍中保存下来的关于古代天文历法的文献资料,是我们伟大中华民族的宝贵遗产。我国古代,由于生产发展的需要和社会分工,天文历法的管理和编订很早就设有专职人员。到了阶级社会,这些专职管理天文历法的人员逐渐成为统治者的附庸和臣仆,所谓“文史星历”,不得不听从最高统治者的指挥命令,因此观象授时成为国家权力的一部分,改正朔,颁布历法,成为权力的象征。加以古人对于自然现象的观察和理解都还不够精明,在很长的时间内古代天文历法蒙上了一层神秘的外衣,往往和封建迷信纠缠在一起。后之学者在传注古代典籍的时候,因为受到这种影响和局限,不能作出正确的解释。历代相传,以讹传讹,成为阅读古书的障碍。一直到现在,我们虽然已经对于我国古代天文历法有了比较深入的研究,取得了丰富的成果,但由于不能突破前人的束缚,许多重要的问题,尤其汉代以前的历法,仍然得不到确切的解答。\n已故贵州大学张汝舟教授为读通古书,对我国载籍中涉及天文历法的部分,作了深入的研究。他运用深湛的古汉语专业知识和精密的考据方法,结合现代天文科学的成就和地下出土的文物资料,对过去学者的研究成果作了细致的分析研究,去伪存真,去粗取精,建立了古代天文历法的科学体系。根据他的体系来解释汉以前的古代典籍,大都能够破除迷障,贯通大义,一扫我国古代天文历法研究中的重重雾障,为我国古代天文历法的研究开拓一个新的局面。如他认为西周时代并不是用所谓“周正”,而是仍然用殷历,以建丑为正。因此,对于《诗经》中的《豳风·七月》、《大戴记》中的《夏小正》以及《礼记》里面的《月令》等篇都能得到符合实际的解释。如他认为王国维的“月相四分说”是想当然的误解,并没有科学的根据,批判根据王氏“月相四分说”而建立起来的当代古历研究中的种种错误。如他对于日本天文史学者新城新藏定周武王克商之年在公元前1066年的错误,从多方面给以论证,指斥我国现代一些书刊仍然沿袭新城氏之说的谬误。如他对于刘歆的“三统历”,我国相传的“三正论”、“岁星纪年”、二十八宿分“四象”,以及古代相传的积年术和占卜法等等,都据理分析批判,指出它们在古代天文历法研究中的有害影响。所有这些,都是张汝舟先生对于我国古代天文历法研究的巨大贡献。\n闻玉同志受业于张汝舟先生,亲承教言。根据师说,发挥他的心得体会,曾写了《古代天文历法浅释》,先后在南京大学和湖南师范大学两校中文系、东北师范大学历史系,为硕士研究生作过专题讲演,深受同学们的欢迎。这部《古代天文历法讲座》是他在讲析的基础上,参证古籍,考释出土文物并结合教学实践经验,补充修订写成的。\n天文历法是一门专科的学术。我国古代天文历法又有自己的特殊体系和习惯用语,只有运用我国传统的体系的推步方法,许多问题才能迎刃而解。张汝舟先生《二毋室古代天文历法论丛》是一部学术专著,虽然力求浅显易懂,但不能同时兼顾古代天文历法基础知识的解说。因此,初学的人或对古代典籍涉猎不多的读者,阅读他的《论丛》仍然感到困难。这部《讲座》可以说是张先生《论丛》的衍义。\n《讲座》分章对张先生《论丛》作系统的说明。它为一般读者大众说法,补充介绍一些天文历法方面的基础知识和简明的推步方法。读者可以通过这部书对我国古代天文历法的体系获得初步的理解,对于古书中有关天文历法的问题作出确切的解释。如果进一步深入下去,读张汝舟先生的《论丛》就会更容易理解,对于我国古代历法的探索和研究,也会取得入门的途径。\n这部书的特色和价值,读者会自己去体会印证,无待烦言。但可以肯定地说,它是一部有用的、值得一读的关于我国古代历法的好书。\n一九八五年十二月于南京大学\n前言 # 1984年6月,应南京大学中文系及程千帆教授、王气中教授之邀,给南大中文系与南京师大中文系部分研究生讲了一个月(每周四次)古代天文历法,目的是让青年同志们通释古籍中可能遇到的有关问题。当时,只准备了一些粗略的材料。\n我在那次讲授的“开场白”中用了一副对联表述当时的心境:“班门弄斧,诚惶诚恐;大树遮阴,无虑无忧。”因为南京这地方,尤其是南京大学,乃藏龙卧虎之地。南大本校有天文学系,有研究古历的专家。南京有中国最高水平的紫金山天文台,台内有古历专家组。我区区无名,地处边荒,来南京讲古天文,岂不是关公面前舞大刀?自然该诚惶诚恐了。好在众望所归的几位老先生,程先生、王先生、徐复先生、管雄先生以及过世的洪诚先生,都是我的师辈,他们与先师张汝舟先生或先后同学于中央大学文学系,或共事于大江南北,我在南大演讲他的古天文学有如游子归家,是大树底下乘荫凉了,确有无虑无忧的感受。为了驱散我的惶恐,讲课时索性将题目也改作“张汝舟古天文历法”。\n讲授之后,大家反映还好,以为有实用价值。9月秋凉才想到在此基础上写一个讲稿。1985年5月,受湖南师大中文系及宋祚胤教授邀请,赴长沙讲学一月,大体就以此为本。后来,又重新整理,算是有了一个雏形。\n古历法问题,新疆师范大学饶尚宽兄进行过专门的研究,有《古历论稿》若干文字为证。我在各地讲授时直接引用了他的许多材料。\n还应当说明,在天文部分,我采用了郑文光先生的不少观点,他在《中国天文学源流》中有很多精辟的见解,给我不少启发。读者是不难从中看到痕迹的。\n第四讲《二十四节气》,我直接引用了冯秀藻、欧阳海两位专家《廿四节气》中的若干材料。因为是讲稿,要顾及知识的系统性,缺了这一部分就显得不完整。我未能与两位老先生取得联系,愧疚不已。\n稿子整理出来了。只能说,在先师张汝舟先生的导引下,我从前辈学人如陈遵妫、席泽宗诸先生的文字中,大体掌握了古代天文历法这门学问的基础知识,然后薪尽火传,希望被称为“绝学”的古代天文历法代有传人,而我自己的深入研究才刚刚起步。不过,我会切切实实地努力。\n承王气中教授亲切关怀,以八十余岁高龄为这本书稿写了序文,褒美之辞就算是对先师张汝舟先生的深切怀念吧!\n"},{"id":159,"href":"/zh/docs/culture/%E7%BD%AE%E8%BA%AB%E4%BA%8B%E5%86%85/%E4%B8%8B%E7%AF%87/","title":"下篇","section":"置身事内","content":" 下篇 宏观现象 # 上篇介绍了地方政府推动经济发展的模式。这种模式的第一个特点是城市化过程中“重土地、轻人”,优点是可以快速推进城市化和基础设施建设,缺点是公共服务供给不足,推高了房价和居民债务负担,拉大了地区差距和贫富差距。第五章分析这些内容,并介绍土地流转和户籍改革等要素市场的改革。第二个特点是招商引资竞争中“重规模、重扩张”,优点是推动了企业成长和快速工业化,缺点是加重了债务负担。企业、地方政府、居民三部门债务互相作用,加大了经济整体的债务和金融风险。第六章分析这些内容,并介绍“供给侧结构性改革”,详述“去库存、去产能、去杠杆”及“防范化解重大金融风险”。第三个特点是发展战略“重投资、重生产、轻消费”,优点是拉动了经济快速增长,扩大了对外贸易,使我国迅速成为制造业强国,缺点是经济结构不平衡。对内,资源向企业部门转移,居民收入和消费占比偏低,不利于经济长期稳定发展;对外,国内无法消纳的产能向国外输出,加剧了贸易冲突。第七章分析这些内容,并介绍党的十九大重新定义“主要矛盾”后的相关改革,详述“形成以国内大循环为主体、国内国际双循环相互促进的新发展格局”所需要的改革。\n第五章 城市化与不平衡 # 教书久了,对年轻人不同阶段的心态深有体会。大一新生刚从中学毕业,无忧无虑,爱思考“为什么”;大四毕业生和研究生则要走向社会,扛起工作和生活的重担,普遍焦虑,好琢磨“怎么办”。大多数人的困境可以概括为:有心仪工作的城市房价太高,而房价合适的城市没有心仪的工作。梦想买不起,故乡回不去。眼看着大城市一座座高楼拔地而起,却难觅容身之所。为什么房子这么贵?为什么归属感这么低?为什么非要孤身在外地闯荡,不能和父母家人在一起?这些问题都与地方政府推动经济发展的模式有关。\n城市化需要投入大量资金建设基础设施,“土地财政”和“土地金融”是非常有效的融资手段。通过出让城市土地使用权,可以积累以土地为信用基础的原始资本,推动工业化和城市化快速发展。中国特有的城市土地国有制度,为政府垄断土地一级市场创造了条件,将这笔隐匿的财富变成了启动城市化的巨大资本,但也让地方财源高度依赖土地价值,依赖房地产和房价。房价连着地价,地价连着财政,财政连着基础设施投资,于是经济增长、地方财政、银行、房地产之间就形成了“一荣俱荣,一损俱损”的复杂关系。\n这种以土地为中心的城市化忽视了城市化的真正核心:人。地价要靠房价拉动,但房价要由老百姓买单,按揭要靠买房者的收入来还。所以土地的资本化,实质是个人收入的资本化。支撑房价和地价的,是人的收入。忽略了人,忽略了城市化本该服务于人,本该为人创造更好的环境和更高的收入,城市化就入了歧途。\n1980年,我国城镇常住人口占总人口比重不足两成,2019年超过了六成(见图5-1)。短短40年,超过5亿人进了城,这是不折不扣的城市化奇迹。但若按户籍论,2019年的城镇户籍人口只占总人口的44%,比常住人口占比少了16个百分点。也就是说有超过2亿人虽然常住城镇,却没有当地户口,不能完全享受到应有的公共服务(如教育),因为这些服务的供给是按户籍人数来规划的。这种巨大的供需矛盾,让城市新移民没有归属感,难以在城市中安身立命,也让“留守儿童、留守妇女、留守老人”成为巨大的社会问题。近年来一系列改革措施的出台,都是为了扭转这种现状,让城市化以人为本。\n图5-1 城镇人口占总人口比重\n数据来源:万得数据库与国家统计局历年《国民经济和社会发展统计公报》。\n本章第一节分析房价和土地供需间的关系,讨论高房价带来的日益沉重的居民债务负担。第二节分析地区间发展不平衡,其根源之一在于土地和人口等生产要素流动受限,所以近年来在土地流转和户籍制度等方面的改革非常重要。第三节分析我国经济发展过程中出现的贫富差距,这一现象也和房价以及要素市场改革有关。\n第一节 房价与居民债务 # 1994年分税制改革(第二章)是很多重大经济现象的分水岭,也是城市化模式的分水岭。1994年之前实行财政包干制,促进了乡镇企业的崛起,为工业化打下了基础,但农民离土不离乡,大多就地加入乡镇企业,没有大量向城市移民。分税制改革后,乡镇企业式微,农民工大潮开始形成。从图5-1中可以清楚地看到,城镇常住人口自1995年起加速上涨,城市化逐渐进入了以“土地财政”和“土地金融”为主要推手的阶段。这种模式的关键是房价,所以城市化的矛盾焦点也是房价。房价短期内受很多因素影响,但中长期主要由供求决定。无论是发达国家还是发展中国家,房屋供需都与人口结构密切相关,因为年轻人是买房主力。年轻人大都流入经济发达城市,但这些城市的土地供应又受政策限制,因此房屋供需矛盾突出,房价居高不下。\n房价与土地供需 # 现代经济集聚效应很强,经济活动及就业越来越向大城市集中。随着收入增长和生活水平提高,人们高价竞争城市住房。这种需求压力是否会推升房价,取决于房屋和住宅用地供给是否灵活。若政策严重限制了供给,房价上涨就快。一个地区的土地面积虽然固定,但建造住宅的用地指标可以调整;同一块住宅开发用地上,容积率和绿化面积也可以调整。 11 这些调整都受政策的影响。美国虽然是土地私有制,但城市建设和用地规划也要受政府管制。比如旧金山对新建住房的管制就特别严格,所以即使在20世纪90年代房价也不便宜。在21世纪初的房地产投机大潮中,旧金山的住房建设指标并没有增加,房价于是飙升。再比如亚特兰大,住房建设指标能够灵活调整,因此虽然也有大量人口涌入,但房价一直比较稳定。 22 我国的城市化速度很快,居民收入增长的速度也很快,所以住房需求和房价上涨很快。按照国家统计局的数据,自1998年住房商品化改革以来,全国商品房均价在20年间涨了4.2倍。但各地涨幅大不相同。三四线城市在2015年实行货币化棚改(见第六章)之前,房价涨幅和当地人均收入涨幅差不多;但在二线城市,房价就比人均收入涨得快了;到了一线城市,房价涨幅远远超过了收入:2015年之前的十年间,北、上、广、深房价翻了两番,年均增速13%。 33 地区房价差异的主要原因是供需失衡。人口大量涌入的大城市,居住用地的供给速度远赶不上人口增长。2006年至2014年,500万人和1 000万人以上的大城市城区人口增量占全国城区人口增量的近四成,但居住用地增量才占全国增量的两成,房价自然快速上涨。而在300万人以下尤其是100万人以下的小城市中,居住用地增量比城镇人口增量更快,房价自然涨不上去。从地理分布上看,东部地区的城镇人口要比用地增速高出近10%,住房十分紧张;而西部和东北地区则反过来,建设用地指标增加得比人口快。 44 中国对建设用地指标实行严格管理,每年的新增指标由中央分配到省,再由省分配到地方。这些指标无法跨省交易,所以即使面对大量人口流入,东部也无法从西部调剂用地指标。2003年后的十年间,为了支持西部大开发并限制大城市人口规模,用地指标和土地供给不但没有向人口大量流入的东部倾斜,反而更加向中西部和中小城市倾斜。2003年,中西部土地供给面积占全国新增供给的比重不足三成,2014年上升到了六成。2002年,中小城市建成区面积占全国的比重接近一半,2013年上升到了64%。 55 土地流向与人口流向背道而驰,地区间房价差距因此越拉越大。\n然而这种土地倾斜政策并不能改变人口流向,人还是不断向东部沿海和大城市集聚。这些地区不仅房价一直在涨,大学的高考录取分数也一直在涨。中西部房价虽低,但年轻人还是愿意到房价高的东部,因为那里有更多的工作机会和资源。倾斜的土地政策并没有留住人口,也很难留住其他资源。很多资本利用了西部的优惠政策和廉价土地,套取了资源,又回流到东部去“炒”房地产,没在西部留下可持续发展的经济实体,只给当地留下了一堆债务和一片空荡荡的工业园区。\n建设用地指标不能在全国交易,土地使用效率很难提高。地方政府招商引资竞争虽然激烈,也经常以土地作为手段,却很难持续提高土地资源利用效率。发达地区土地需求旺盛,地价大涨,本应增加用地指标,既满足需求也抑制地价。但因为土地分配受制于行政边界,结果却是欠发达地区能以超低价格(甚至免费)大量供应土地。这种“东边干旱,西边浇水”的模式需要改革。2020年,中央提出要对建设用地指标的跨区域流转进行改革,探索建立全国性建设用地指标跨区域交易机制(见第二节),已是针对这一情况的改革尝试。 66 房价与居民债务:欧美的经验和教训 # 居民债务主要来自买房,房价越高,按揭就越高,债务负担也就越重。各国房价上涨都是因为供不应求,一来城市化过程中住房需求不断增加;二来土地和银行按揭的供给都受政治因素影响。\n在西方,“自有住房”其实是个比较新的现象,“二战”之前,大部分人并没有自己的房子。哪怕在人少地多的美国,1900—1940年的自有住房率也就45%左右。“二战”后这一比率才开始增长,到2008年全球金融危机之前达到68%。英国也差不多,“二战”前的自有住房率基本在30%,战后才开始增长,全球金融危机前达到70%。 77 正因为在很长一段时间里英美大部分人都租房,所以主流经济学教材在讲述供需原理时,几乎都会用房租管制举例。1998年,我第一次了解到房租管制,就是在斯蒂格利茨的《经济学》教科书中。逻辑虽容易理解,但并没有直观感受,因为当时我认识的人很少有租房的,农民有宅基地,城里人有单位分房。城市住房成为全民热议的话题,也是个新现象。\n欧美自有住房率不断上升,有两个后果。第一是对待房子的态度变化。对租房族来说,房子就是个住的地方,但对房主来说,房子是最重要的资产。随着房子数量和价格的攀升,房产成了国民财富中最重要的组成部分。1950年至2010年,英国房产价值占国民财富的比例从36%上升到57%,法国从28%升到61%,德国从28%升到57%,美国从38%升到42%。 88 第二个变化是随着房主越来越多,得益于房价上涨的人就越来越多。所以政府为讨好这部分选民,不愿让房价下跌。无房者也想尽快买房,赶上房价上涨的财富快车,政府于是顺水推舟,降低了买房的首付门槛和按揭利率。\n美国房地产市场和选举政治紧密相关。美国的收入不平等从20世纪七八十年代开始迅速扩大,造成了很多政治问题。而推行根本性的教育或税制等方面的改革,政治阻力很大,且难以在短期见效。相比之下,借钱给穷人买房就容易多了,既能缓解穷人的不满,让人人都有机会实现“美国梦”,又能抬高房价,让房主的财富也增加,拉动他们消费,创造更多就业,可谓一举多得。于是政府开始利用房利美(Fannie Mae)和房地美(Freddie Mac)公司(以下简称“两房”)来支持穷人贷款买房。“两房”可以买入银行的按揭贷款,相当于借钱给银行发放更多按揭。 99 1995年,克林顿政府规定“两房”支持低收入者的房贷要占到总资产的42%。2000年,也就是克林顿执政的最后一年,这一比率提高到50%。2004年,小布什政府将这一比率进一步提高到56%。 1010 “两房”也乐此不疲,因为给穷人的贷款利润较高,风险又似乎很低。此外,对购房首付的管制也越来越松。2008年全球金融危机前很多房贷的首付为零,引发了投机狂潮,推动房价大涨。根据Case-Shiller房价指数,2002年至2007年,美国房价平均涨了将近60%。危机之后,房价从2007年的最高点一直下跌到2012年,累积跌幅27%,之后逐步回升,2016年才又回到十年前的高点。\n房价下挫和收入下降会加大家庭债务负担,进而抑制消费。消费占美国GDP的七成,全球金融危机中消费大幅下挫,把经济推向衰退。危机前房价越高的地区,危机中消费下降越多,经济衰退也越严重,失业率越高。 1111 欧洲情况也大致如此。大多数欧洲国家在2008年之前也经历了长达十年的房价上涨。涨幅越大的国家居民债务负担越重(绝大多数债务是房贷),危机中消费下降也越多。 1212 房地产常被称作“经济周期之母”,根源就在于其内在的供需矛盾:一方面,银行可以通过按揭创造几乎无限的新购买力;而另一方面,不可再生的城市土地供给却有限。这对矛盾常常会导致资产泡沫与破裂的周期循环,是金融和房地产不稳定的核心矛盾。而房地产不仅连接着银行,还连接着千家万户的财富和消费,因此影响很大。\n房价与居民债务:我国的情况 # 2008年之后的10年,我国房价急速上涨,按揭总量越来越大,居民债务负担上涨了3倍多(图5-2)。2018年末,居民债务占GDP的比重约为54%,虽仍低于美国的76%,但已接近德国和日本。根据中国人民银行的信贷总量数据,居民债务中有53%是住房贷款,24%是各类消费贷(如车贷)。 1313 这一数据可能还低估了与买房相关的债务。实际上一些消费贷也被用来买了房,比如违规用于购房首付。而且人民银行的数据还无法统计到民间借贷等非正规渠道。\n图5-2 居民债务占GDP比重\n数据来源:IMF全球债务数据库。此处债务仅包括银行贷款和债券。\n图5-2中债务负担的分母是GDP,这一比率常用于跨国比较,但它低估了居民的实际债务负担。还债不能用抽象的GDP,必须用实实在在的收入。2019年末,中国人民银行调查统计司调查了全国3万余户城镇居民(农民负债率一般较低,大多没有房贷)的收入和债务情况。接近六成家庭有负债,平均债务收入比为1.6,也就是说债务相当于1.6倍的家庭年收入。这个负担不低,接近美国。2000年,美国家庭负债收入比约为1.5,2008全球金融危机前飙升至2.1,之后回落到1.7左右。 1414 根据中国人民银行的这项调查,城镇居民2019年的负债中有76%是房贷。而从资产端看,城镇居民的主要财产也就是房子。房产占了家庭资产的近七成,其中六成是住房,一成是商铺。而在美国居民的财富中,72%是金融资产,房产占比不到28%。 1515 中国人财富的压舱石是房子,美国人财富的压舱石是金融资产。这个重大差别可以帮助理解两国的一些基本政策,比如中国对房市的重视以及美国对股市的重视。\n总体看来,我国居民的债务负担不低,且仍在快速上升。最主要的原因是房价上涨。居民债务的攀升已然影响到了消费。以买车为例,这是房子之外最贵的消费品类,对宏观经济非常重要,约占我国社会商品零售总额的10%。车是典型的奢侈品,需求收入弹性很大,收入增加时需求大增,收入减少时需求大减。随着居民债务增加,每月还债后的可支配收入减少,所以经济形势一旦变差,买车需求就会大减。我国家用轿车市场经历了多年高速增长,2018年的私家车数量是2005年的14倍。但是从2018年下半年开始,“贸易战”升级,未来经济形势不确定性增大,轿车销量开始下降,一直到2019年底,几乎每个月同比都在下降。在新冠肺炎疫情影响之下,2020年2月份的销量同比下跌八成,3月份同比下跌四成,各地于是纷纷出台刺激汽车消费的政策。\n房价与居民债务风险 # 按照中国人民银行的调查数据,北京居民的户均总资产(不是净资产,未扣除房贷和其他负债)是893万元,上海是807万元,是新疆(128万元)和吉林(142万元)的六七倍。这个差距大部分来自房价。房价上涨也拉大了同城之内的不平等。房价高的城市房屋空置率往往也高,一边很多人买不起房,一边很多房子空置。如果把房子在内的所有家庭财富(净资产)算在一起的话,按照上述中国人民银行的调查数据,2019年最富有的10%的人占有总财富的49%,而最穷的40%的人只占有总财富的8%。 1616 房价上涨不仅会增加按揭债务负担,还会拉大贫富差距,进而刺激低收入人群举债消费,这一现象被称为“消费下渗”(trickle-down consumption),这在发达国家是很普遍的。 1717 2014—2017年间,我国收入最低的50%的人储蓄基本为零甚至为负(入不敷出)。 1818 自2015年起,信用卡、蚂蚁花呗、京东白条等各种个人消费贷激增。根据中国人民银行关于支付体系运行情况的数据,2016—2018年这三年,银行信用卡和借记卡内合计的应偿还信贷余额年均增幅接近30%。2019年,信用卡风险浮现,各家银行纷纷刹车。\n在负债的人当中,低收入人群的债务负担尤其重。城镇居民的平均债务收入比约为1.6,而年收入6万元以下的家庭债务收入比接近3。资产最少的20%的家庭还会更多使用民间借贷,风险更大。 1919 2020年,随着蚂蚁金服上市被叫停,各种讨论年轻人“纵欲式消费”的文章在社交媒体上讨论热烈,都与消费类债务急升的大背景有关。这种依靠借债的消费无法持续,因为钱都被花掉了,没有形成未来更高的收入,债务负担只会越来越重。\n居民债务居高不下,就很难抵御经济衰退,尤其是房产价格下跌所引发的经济衰退。低收入人群的财富几乎全部是房产,其中大部分是欠银行的按揭,负债率很高,很容易受到房价下跌的打击。在2008年美国的房贷危机中,每4套按揭贷款中就有1套资不抵债,很多穷人的资产一夜清零。2007年至2010年,美国最穷的20%的人,净资产从平均3万美元下降到几乎为零。而最富的20%的人,净资产只下跌了不到10%,从平均320万美元变成了290万美元,而且这种下跌非常短暂。2016年,随着股市和房市的反弹,最富的10%的人实际财富(扣除通货膨胀)比危机前还增长了16%。但收入底部的50%的人,实际财富被腰斩,回到了1971年的水平。40年的积累,在一场危机后荡然无存。 2020 我国房价和居民债务的上涨虽然也会引发很多问题,但不太可能突发美国式的房贷和金融危机。首先,我国住房按揭首付比例一般高达30%,而不像美国在金融危机前可以为零,所以银行风险小。除非房价暴跌幅度超过首付比例,否则居民不会违约按揭,损失掉自己的首付。2018年末,我国个人住房贷款的不良率仅为0.3%。 2121 其次,住房按揭形成的信贷资产,没有被层层嵌套金融衍生品,在金融体系中来回翻滚,规模和风险被放大几十倍。2019年末,我国住房按揭资产证券(RMBS)总量占按揭贷款的总量约3%,而美国这个比率为63%,这还不算基于这种证券的各种衍生产品。 2222 再次,由于资本账户管制,外国资金很少参与我国的住房市场。综上所述,像美国那样由房价下跌引发大量按揭违约,并触发衍生品连锁雪崩,再通过金融市场扩散至全球的危机,在我国不太可能会出现。\n要化解居民债务风险,除了遏制房价上涨势头以外,根本的解决之道还在于提高收入,尤其是中低收入人群的收入,鼓励他们到能提供更多机会和更高收入的地方去工作。让地区间的经济发展和收入差距成为低收入人群谋求发展的机会,而不是变成人口流动的障碍。\n第二节 不平衡与要素市场改革 # 2017年党的十九大报告指出:我国社会主要矛盾已经转化为人民日益增长的美好生活需要和不平衡不充分的发展之间的矛盾。这是自1981年党的十一届六中全会提出“我国所要解决的主要矛盾”(即人民日益增长的物质文化需要同落后的社会生产之间的矛盾)以来,中央首次重新定义“主要矛盾”,说明经济政策的根本导向发生了变化。\n过去40年间,我国居民收入差距有明显扩大,同期很多发达国家的收入差距也在扩大,与它们相比,我国的收入差距有两个特点:一是城乡差距,二是地区差距。2018年,城镇居民人均可支配收入是农村居民的2.7倍,而北京和上海的人均可支配收入是贵州、甘肃、西藏等地的3.5倍。这两项差距都与人口流动受限有关。\n人口流动与收入平衡 # 低收入人群想要提高收入,最直接的方式就是到经济发达城市打工,这些城市能为低技能工作(如快递或家政)提供不错的收入。若人口不能自由流动,被限制在农村或经济落后地区,那人与人之间的收入差距就会拉大,地区和城乡间的收入差距也会拉大。目前,我国人口流动依然受限,以地方政府投资为主推动的城市化和经济发展模式是重要因素之一。重土地轻人,民生支出不足,相关公共服务(教育、医疗、养老等)供给不足,不利于外来人口在城市中真正安家落户,不利于农村转移劳动力在城市中谋求更好的发展。地方政府长期倚重投资,还会导致收入分配偏向资本,降低劳动收入占比,对中低收入人群尤其不利。第七章会讨论这种分配结构及其带来的各种问题,本节先聚焦人口流动问题。\n在深入分析之前,我们先来看看如果人口可以自由流动,地区间平衡是个什么样子。图5-3(a)中的柱子代表美国各州GDP占美国全国的比重,折线则代表各州人口占比。美国各州GDP规模差别很大,仅加州就占了美国GDP的15%,而一些小州的占比连1%都不到。GDP衡量的是经济总量,人口越多的地方GDP自然越大,所以图中折线的高度和柱子高度差不多。假如一个州的GDP占比为3%,人口占比差不多也是3%。换句话说,州与州之间虽然规模差别很大,但人均GDP差别很小,无论生活在哪个州,平均生活水平都差不太多。\n图5-3(a) 2019年美国各州占全国GDP和人口比重\n这种规模不平衡但人均平衡的情况,和我国的情况差别很大。图5-3(b)是我国各省份的情况,柱子与折线的高度差别很大,有高有低,省省不同。在广东、江苏、浙江、上海和北京等发达地区,折线比柱子低很多,人口规模远小于经济规模,更少的人分更多的收入,自然相对富有。而在其他大多数省份,柱子比折线低很多,经济规模小于人口规模,更多的人分更少的收入,自然相对贫穷。\n图5-3(b) 2019年中国各省份占全国GDP和人口的比重 2323 要想平衡地区间的发展差距,关键是要平衡人均差距而不是规模差距。想达到地区间规模的平均是不可能的。让每个城市都像上海和北京一样,或者在内地再造长三角和珠三角这样巨大的工业和物流网络(包括港口),既无可能也无必要。现代经济越来越集聚,即使在欧美和日本,经济在地理上的集聚程度也依然还在加强,没有减弱。 2424 所以理想的状况是达到地区间人均意义上的平衡。而要实现这种均衡,关键是让劳动力自由流动。人的收入不仅受限于教育和技能,也受限于所处环境。目前城镇常住人口只占总人口的六成,还有四成人口在农村,但农业产出仅占GDP的一成。四成人口分一成收入,收入自然就相对低。就算部分农民也从事非农经济活动(这部分很难统计),收入也还是相对低。所以,要鼓励更多人进入城市,尤其是大城市。因为大城市市场规模大,分工细,哪怕低技能的人生产率和收入也更高。比如城市里一个早点摊儿可能就够养活一家人,甚至有机会发展成连锁生意。而在农村,早餐都在家里吃,市场需求小,可能都没有专门做早餐的生意。类似的例子还有家政、外卖、快递、代驾、餐厅服务员等。因为人口密度高和市场需求大所带来的分工细化,这些工作在大城市的收入都不低。\n正是这些看上去低技能的服务业工作,支撑着大城市的繁华,也支撑着所谓“高端人才”的生活质量。若没有物美价廉的服务,生活成本会急升。我家门口有一片商业办公楼宇,离地铁站很近,有不少餐厅。前几年很多服务业人员离开,餐厅成本急升,一些餐厅倒闭了,剩下的也都涨了价,于是带饭上班的白领就多了起来。如果一个城市只想要高技能人才,结果多半会事与愿违:服务业价格会越来越高,收入会被生活成本侵蚀,各种不便利也会让生活质量下降,“高端人才”最终可能也不得不离开。靠行政规划来限制人口规模,成功例子不多。人口不断流入的城市,规划人口往往过少;而人口不断流出的城市,规划人口往往过多。\n城市规模扩大和人口密度上升,不仅能提高本地分工程度和生产率,也能促进城市与城市之间、地区与地区之间的分工。有做高端制造的,也有做中低端制造的,有做大规模农场的,也有搞旅游的。各地区发展符合自身优势的经济模式,互通有无,整体效率和收入都会提高。就算是专搞农业的地方,人均收入也会提升,不仅因为规模化后的效率提升,也因为人口基数少了,流动到其他地方搞工商业去了。\n让更多人进入城市,尤其是大城市,逻辑上的好处是清楚的,但在现实中尚有很多争议,主要是担心人口涌入会造成住房、教育、医疗、治安等资源紧张。这种担心可以理解,任何城市都不可能无限扩张。劳动力自由流动意味着有人来也有人走,若拥挤带来的代价超过收益,自会有人离开。至于教育、医疗等公共服务,缓解压力的根本之道是增加供给,而不是限制需求。涌入城市的人是来工作和谋生的,他们不仅分享资源,也会创造资源。举个例子来说,2019年末,上海60岁以上的老年人口共512万,占户籍总人口的35%,老龄化严重。若没有不断涌入的城市新血,社保怎么维持?养老服务由谁来做?但如果为这些新移民提供的公共服务覆盖有限,孩子上学难,看病报销难,他们便无法安居乐业。存在了很多年的“留守”问题,也还会持续下去。\n土地流转与户籍改革 # 增加城市中的学校和医院数量,可能还相对容易些,增加住房很困难。大城市不仅土地面积有限,而且由于对建设用地指标的管制,就算有土地也盖不了房子。假如用地指标可以跟着人口流动,人口流出地的用地指标减少,人口流入地的指标增多,就可能缓解土地供需矛盾、提高土地利用效率。而要让建设用地指标流转起来,首先是让农村集体用地参与流转。我国的土地分为两类(见第二章):城市土地归国家所有,可以在市场上流转;农村土地归集体所有,流转受很多限制。要想增加城市土地供应,最直接的办法是让市区和近郊的集体建设用地参与流转。比如在北京市域内,集体建设用地占建设用地总量的五成,但容积率平均只有0.3—0.4,建设密度远低于国有土地。上海的集体建设用地占总建设用地三成,开发建设强度也大大低于国有土地。 2525 关于集体土地入市,早在2008年党的十七届三中全会审议通过的《中共中央关于推进农村改革发展若干重大问题的决定》里就有了原则性条款:“逐步建立城乡统一的建设用地市场,对依法取得的农村集体经营性建设用地,必须通过统一有形的土地市场、以公开规范的方式转让土地使用权,在符合规划的前提下与国有土地享有平等权益。”但地方有地方的利益,这些原则当时未能落到实处。2008年后的数年间,地方政府的主要精力还是在“土地财政/金融”的框架下征收集体用地,扩张城市。\n自2015年起,全国33个试点县市开始试行俗称“三块地”的改革,即农村土地征收、集体经营性建设用地入市以及宅基地制度改革。在此之前也有一些零星的地方试点和创新,比较有名的是重庆的“地票”制度。若一个农民进了城,家里闲置两亩宅基地,他可以将其还原成耕地,据此拿到两亩地“地票”,在土地交易所里卖给重庆市域内需要建设指标的区县。按每亩“地票”均价20万元算,扣除两亩地的复耕成本约5万元,净所得为35万元。农户能分到其中85%(其余15%归村集体),差不多30万元,可以帮他在城里立足。每年国家给重庆主城区下达的房地产开发指标约2万亩,“地票”制度每年又多供应了2万亩,相当于土地供给翻了一番,所以房价一直比较稳定。 2626 2017年,中央政府提出,“在租赁住房供需矛盾突出的超大和特大城市,开展集体建设用地上建设租赁住房试点”。 2727 这是一个体制上的突破,意味着城市政府对城市住宅用地的垄断将被逐渐打破。2019年,第一批13个试点城市选定,既包括北、上、广等一线城市,也包括沈阳、南京、武汉、成都等二线城市。 2828 同年,《土地管理法》修正案通过,首次在法律上确认了集体经营性建设用地使用权可以直接向市场中的用地者出让、出租或作价出资入股,不再需要先行征收为国有土地。农村集体经营性用地与城市国有建设用地从此拥有了同等权能,可以同等入市,同权同价,城市政府对土地供应的垄断被打破了。\n所谓“集体经营性建设用地”,只是农村集体建设用地的一部分,并不包括宅基地,后者的面积占集体建设用地的一半。虽然宅基地改革的政策尚未落地,但在住房需求旺盛的地方,宅基地之上的小产权房乃至宅基地本身的“非法”转让,一直存在。2019年新的《土地管理法》对宅基地制度改革只做了些原则性规定:国家允许进城落户的村民依法自愿有偿退出宅基地,鼓励农村集体经济组织及其成员盘活利用闲置宅基地和闲置住宅。2020年,中央又启动了新一轮的宅基地制度改革试点,继续探索“三权分置”,即保障宅基地农户资格权、农民房屋财产权、适度放活宅基地和农民房屋使用权。强调要守住“三条底线”:土地公有制性质不改变、耕地红线不突破、农民利益不受损。在这些改革原则之下,具体的政策细则目前仍在探索阶段。\n土地改革之外,在“人”的城镇化和户籍制度等方面也推出了一系列改革。2013年,首次中央城镇化会议召开,明确提出“以人为本,推进以人为核心的城镇化”。2014年,两会报告中首次把人口落户城镇作为政府工作目标,之后开始改革户籍制度。逐步取消了农业户口与非农业户口的差别,建立了城乡统一的“居民户口”登记制度,并逐步按照常住人口(而非户籍人口)规模来规划公共服务供给,包括义务教育、就业服务、基本养老、基本医疗卫生、住房保障等。 2929 2016年,中央政府要求地方改进用地计划安排,实施“人地挂钩”,要依据土地利用总体规划和上一年度进城落户人口数量,合理安排城镇新增建设用地计划,保障进城落户人口用地需求。 3030 户籍制度改革近两年开始加速。2019年,发改委提出:“城区常住人口100万—300万的Ⅱ型大城市要全面取消落户限制;城区常住人口300万—500万的Ⅰ型大城市要全面放开放宽落户条件,并全面取消重点群体落户限制。超大特大城市要调整完善积分落户政策,大幅增加落户规模、精简积分项目,确保社保缴纳年限和居住年限分数占主要比例。……允许租赁房屋的常住人口在城市公共户口落户。” 3131 目前,在最吸引人的特大和超大城市,落户门槛依然不低。虽然很多特大城市近年都加入了“抢人才大战”,放开了包括本科生在内的高学历人才落户条件,甚至还提供生活和住房补贴等,但这些举措并未惠及农村转移人口。这种情况最近也开始改变。2020年4月以来,南昌、昆明、济南等省会城市先后宣布全面放开本市城镇落户限制,取消落户的参保年限、学历要求等限制,实行“零门槛”准入政策。\n一国之内,产品的流动和市场化最终会带来生产要素的流动和市场化。农产品可以自由买卖,农民可以进城打工,农村土地的使用权最终也该自主转让。人为限定城市土地可以转让而集体土地不能转让,用户籍把人分为三六九等,除非走计划经济的回头路,否则难以持久。就算不谈权利和价值观,随着市场化改革的深入,这些限定性的制度所带来的扭曲也会越来越严重,代价会高到不可维持,比如留守儿童、留守妇女、留守老人所带来的巨大社会问题。\n城市化的核心不应该是土地,应该是人。要实现地区间人均收入均衡、缩小贫富差距,关键也在人。要真正帮助低收入群体,就要增加他们的流动性和选择权,帮他们离开穷地方,去往能为他的劳动提供更高报酬的地方,让他的人力资本更有价值。同时也要允许农民所拥有的土地流动,这些土地资产才会变得更有价值。\n2020年4月发布的《中共中央 国务院关于构建更加完善的要素市场化配置体制机制的意见》(以下简称《意见》),全面阐述了包括土地、劳动力、资本、技术等生产要素的未来改革方向。针对土地,《意见》强调“建立健全城乡统一的建设用地市场……制定出台农村集体经营性建设用地入市指导意见”。针对劳动力,要求“深化户籍制度改革。推动超大、特大城市调整完善积分落户政策,探索推动在长三角、珠三角等城市群率先实现户籍准入年限同城化累计互认。放开放宽除个别超大城市外的城市落户限制,试行以经常居住地登记户口制度。建立城镇教育、就业创业、医疗卫生等基本公共服务与常住人口挂钩机制,推动公共资源按常住人口规模配置。”总的改革方向,就是让市场力量在各类要素分配中发挥更大作用,让资源更加自由流动,提高资源利用效率。\n第三节 经济发展与贫富差距 # 在我国城市化和经济发展的过程中,贫富差距也在扩大。本节讨论这一问题的三个方面。第一,我国十几亿人在40年间摆脱了贫困,大大缩小了全世界70亿人之间的不平等。第二,在经济快速增长过程中,虽然收入差距在拉大,但低收入人群的收入水平也在快速上升,社会对贫富差距的敏感度在一段时间之内没有那么高。第三,在经济增长减速时,社会对不平等的容忍度会减弱,贫富差距更容易触发社会矛盾。\n收入差距 # 中国的崛起极大地降低了全球不平等。按照世界银行对极端贫困人口的定义(每人每天的收入低于1.9美元),全世界贫困人口从1981年的19亿下降为2015年的7亿,减少了12亿(图5-4)。这是个了不起的成就,因为同期的世界总人口还增加了约30亿。但如果不算中国,全球同期贫困人口只减少了不到3亿人。而在1981年至2008年的近30年间,中国以外的世界贫困人口数量基本没有变化。可以说,全球的减贫成绩主要来自中国。 3232 图5-4 世界极端贫困人口数量变化\n数据来源:世界银行。此处极端贫困人口的定义为每人每日收入少于1.9美元。\n中国的崛起也彻底改变了全球收入分布的格局。1990年,全球共有53亿人,其中最穷的一半人中约四成生活在我国,而最富的20%里几乎没有中国人,绝大多数是欧美人。到了2016年,全球人口将近74亿,其中最穷的一半人中只有约15%是中国人,而最富的另一半人中约22%是中国人。我国占全球人口的比重约为19%,因此在全球穷人中中国人占比偏低,在中高收入组别中中国人占比偏高。 3333 按国别分,全球中产阶级人口中我国所占的比重也最大。\n我国的改革开放打破了计划经济时代的平均主义,收入差距随着市场经济改革而扩大。衡量收入差距的常用指标是“基尼系数”,这是一个0到1之间的数字,数值越高说明收入差距越大。20世纪80年代初,我国居民收入的基尼系数约为0.3,2017年上升到了0.47。 3434 按照国家统计局公布的居民收入数据,2019年收入最高的20%人群占有全部收入的48%,而收入最低的20%人群只占有全部收入的4%。\n虽然收入差距在扩大,但因为经济整体在飞速增长,所以几乎所有人的绝对收入都在快速增加。经济增长的果实是普惠的。1988年至2018年,无论是在城镇还是在农村,人均实际可支配收入(扣除物价上涨因素)都增加了8—10倍。无论是低收入人群、中等收入人群还是高收入人群,收入都在快速增加。以城镇居民为例,虽然收入最高的20%其实际收入30年间增长了约13倍,但收入最低的40%和居中的40%的收入也分别增长了6倍和9倍。 3535 经济增长过程伴随着生产率的提高和各种新机会的不断涌现,虽然不一定会降低收入差距,但可以在一定程度上遏制贫富差距在代际间传递。如果每代人的收入都远远高于上一代人,那人们就会更看重自己的劳动收入,继承自父母的财富相对就不太重要。对大多数“70后”来说,生活主要靠自己打拼,因为父母当年收入很低,储蓄也不多。经济和社会的剧烈变化,也要求“70后”必须掌握新的技能、离开家乡在新的地方工作,父母的技能和在家乡的人脉关系,帮助有限。\n但对“80后”和“90后”来说,父母的财富和资源对子女收入的影响就大了。 3636 原因之一是财富差距在其父母一代中就扩大了,财产性收入占收入的比重也扩大了,其中最重要的是房产。在一二线城市,房价的涨幅远远超过了收入涨幅。 3737 房产等有形财产与人力资本不同。人力资本无法在代际之间不打折扣地传承,但房产和存款却可以。聪明人的孩子不见得更聪明,“学霸”的孩子也不见得就能成为“学霸”。即使不考虑后天教育中的不确定性,仅仅是从遗传角度讲,父母一代特别突出的特征(如身高和智商等)也可能在下一代中有所减弱。因为这种“均值回归”现象,人力资本很难百分之百地遗传。但有形资产的传承则不受这种限制,若没有遗产税,100万元传给下一代也还是100万元,100平方米的房子传给下一代也还是100平方米。\n累积的财富差距一般远大于每年的收入差距,因为有财富的人往往更容易积累财富,资产回报更高,可选择的投资方式以及应对风险的手段也更多。如前文所述,按照国家统计局公布的城镇居民收入数据:2019年收入最高的20%的人占有全部收入的48%,而最低的20%的人只占4%。而按照中国人民银行对城镇居民的调查数据,2019年净资产最高的20%的家庭占有居民全部净资产的65%,而最低的20%只占有2%。 3838 在经济发达、资产增值更快的沿海省份,父母累积的财产对子女收入的影响,比在内地省份更大。 3939 当经济增速放缓、新创造的机会变少之后,年轻人间的竞争会更加激烈,而其父母的财富优势会变得更加重要。如果“拼爹”现象越来越严重的话,社会对不平等的容忍程度便会下降,不安定因素会增加。\n对收入差距的容忍度 # 收入差距不可能完全消除,但社会也无法承受过大的差距所带来的剧烈冲突,因此必须把不平等控制在可容忍的范围之内。影响不平等容忍程度的因素有很多,其中最重要的是经济增速,因为经济增速下降首先冲击的是穷人收入。不妨想象正在排队的两队人,富人队伍前进得比穷人快,但穷人队伍也在不停前进,所以排队的穷人相对来说比较有耐心。但如果穷人的队伍完全静止不动,哪怕富人队伍的前进速度也减慢了,困在原地的穷人也会很快失去耐心而骚动起来。这种现象被称为“隧道效应”(tunnel effect),形容隧道中两条车道一动一静时,静的那条的焦虑和难耐。 4040 上文提到,1988年以来,我国城镇居民中高收入群体的实际收入(扣除物价因素)增长了约13倍,低收入群体和中等收入群体的收入也分别增长了6倍和9倍。在“经济蛋糕”膨胀的过程中,虽然高收入群体切走了更大一块,但所有人分到的蛋糕都比以前大多了,因此暂时可以容忍贫富差距拉大。美国情况则不同,自20世纪70年代以来,穷人(收入最低的50%)的实际收入完全没有增长,中产(收入居中的40%)的收入近40年的累积增幅不过区区35%,而富人(收入最高的10%)的收入却增长了2.5倍。因此社会越来越无法容忍贫富差距。2008年的全球金融危机让穷人财富大幅缩水,贫富差距进一步扩大,引发了“占领华尔街运动”,之后特朗普当选,美国政治和社会的分裂越来越严重。\n另一个影响不平等容忍度的因素是人群的相似性。改革开放前后,绝大多数中国人的生活经历都比较相似,或者在农村的集体生产队干活,或者在城镇的单位上班。在这种情况下,有些人先富起来可能会给另一些人带来希望:“既然大家都差不多,那我也可以,也有机会。”20世纪90年代很多人“下海”发了财,而其他人在羡慕之余也有些不屑:“他们哪里比我强?我要去的话我也行,只不过我不想罢了。”但如果贫富差距中参杂了人种、肤色、种姓等因素,那人们感受就不一样了。这些因素无法靠努力改变,所以穷人就更容易愤怒和绝望。最近这些年,美国种族冲突加剧,根本原因之一就是黑人的贫困。黑人家庭的收入中位数不及白人的六成,且这种差距可能一代代延续下去。一个出身贫困(父母家庭收入属于最低的20%)的白人,“逆袭”成为富人(同代家庭收入最高的20%)的概率是10.6%,而继续贫困下去的概率是29%。但对一个出身贫困的黑人来说,“逆袭”的概率只有区区2.5%,但继续贫困的概率却高达37%。 4141 家庭观念也会影响对不平等的容忍度。在家庭观念强的地方,如果子女发展得好、有出息,自己的生活就算是有了保障,对贫富差距容忍度也会比较高,毕竟下一代还能赶上。而影响子女收入最重要的因素就是经济增长的大环境。我国的“70后”和“80后”中绝大多数人的收入都超过父辈。若父母属于收入最低的40%人群,子女收入超过父母的概率接近九成;即便父母属于收入居中的40%人群,子女超越的概率也有七成。这种情况很像美国的“战后黄金一代”。美国的“40后”和“50后”收入超越父母的概率很接近我国的“70后”和“80后”。但到了美国的“80后”,这概率就低多了:如果父母是穷人(收入最低的40%),子女超越的概率还不到六成;若父母是中产(收入居中的40%),子女超越的概率仅四成。 4242 总的来说,经济增长与贫富差距之间的关系非常复杂。经济学中有一条非常有名的“库兹涅茨曲线”,宣称收入不平等程度会随着经济增长先上升而后下降,呈现出“倒U形”模式。这条在20世纪50年代声名大噪的曲线,其实不过是一些欧美国家在“二战”前后那段特殊时期中的特例。一旦把时间拉长、样本扩大,数据中呈现的往往不是“倒U形”,而是贫富差距不断起起伏伏的“波浪形”。 4343 造成这些起落的因素很多,既有内部的也有外部的,既有经济的也有政治的。并没有什么神秘的经济力量会自动降低收入不平等,“先富带动后富”也不会自然发生,而需要政策的干预。不断扩大的不平等会让社会付出沉重的代价,必须小心谨慎地对待。 4444 结语 # 我国的城市化大概可以分为三个阶段。第一阶段是1994年之前,乡镇企业崛起,农民离土不离乡,城市化速度不快。第二阶段是1994年分税制改革后,乡镇企业式微,农民工进城大潮形成。这个阶段的主要特征是土地的城市化速度远远快于人的城市化速度,土地撬动的资金支撑了大规模城市建设,但并没有为大多数城市新移民提供应有的公共服务。第三个阶段是党的十八大以后,随着一系列改革的陆续推行,城市化的重心开始逐步从“土地”向“人”转移。\n城市化和工业化互相作用。上述三个阶段背后的共同动力之一就是工业化。1994年之前,工业和基础设施比较薄弱,小规模的乡镇企业可以迅速切入本地市场,满足本地需求,而农村土地改革也解放了大量劳动力,可以从事非农工作,为乡镇企业崛起创造了条件。到了90年代中后期,工业品出口开始加速。2001年,中国加入WTO和国际竞争体系之后,工业企业必须扩大规模,充分利用规模效应来增强竞争力,同时需要靠近港口以降低出口运输成本。因此制造业开始加速向沿海地区集聚,大量农民工也随之迁徙。如今我国虽已成为“世界工厂”,但产业升级要求制造业企业不断转型,充分利用包括金融、科技、物流等要素在内的生产和销售网络,所以各项产业仍然集聚在沿海或一些中心大城市。这种集聚促进了当地服务业飞速发展,吸纳了从农村以及中小城市转移出来的新增劳动力。这些新一代移民已经适应了城市生活,很多“农二代”已经不具备从事农业生产所需的技能,更希望定居在城市。所以城市化需要转型,以人为本,为人们提供必要的住房、教育、医疗等公共资源。\n在大规模城市化过程中,地方政府背上了沉重的债务。地价和房价飞涨,也让居民背上了沉重的债务。这些累积的债务为宏观经济和金融体系增加了很大风险。最近几年的供给侧结构性改革,首要任务之一就是“去杠杆”,而所谓“三大攻坚战”之首就是“防范化解重大风险”。那么这些风险究竟是什么?如何影响经济?又推行了哪些具体的改革措施?这是下一章的主题。\n扩展阅读 # 地方政府以土地为杠杆撬动的飞速城市化,是历史上的一件大事。如今站在新一轮改革的起点上,上海交通大学陆铭的著作《大国大城:当代中国的统一、发展与平衡》(2016)值得阅读。该书聚焦城市化过程中的“人”,主张扩大城市规模,让更多人定居在城市,在不断集聚中走向地区间人均意义上的平衡。北京大学周其仁的著作《城乡中国(修订版)》(2017)和东南大学华生的著作《城市化转型与土地陷阱》(2014)也是理解城市化的上佳读物。他们在很多问题上持不同观点。兼听则明,读者可自行判断。\n经济学近年来最热门的研究课题就是不平等,优秀的论文和著作很多。对比较严肃的读者,我还是推荐法国经济学家皮凯蒂的著作《21世纪资本论》(2014)。这是本很多人知道但很少人读完的巨著,因为太厚了。但厚有厚的好处,这本大书里散落着很多有意思的内容,作者思考的深度和广度远非各类书评中的“中心思想”所能概括。即便只读该书前两部分,也能学到关于经济发展的很多内容。对非专业读者而言,本书中有些内容不太容易理解,而且没有多少关于中国的内容。我国收入分配研究领域的两位专家,北京师范大学的李实和中国人民大学的岳希明写了一本导读,《〈21世纪资本论〉到底发现了什么》(2015),解释了原作中一些概念,也对我国收入差距的情况做了简要说明和分析。\n11 容积率就是建筑面积和其下土地面积的比值,比值越高,建筑面积越大,楼层越高,容纳的人也越多。给定土地位置,规划容积率越高越值钱。厦门大学傅十和与暨南大学谷一桢等人的论文(Brueckner et al., 2017)发现,我国房地产开发限制越严格的地方,容积率和地价间关联越紧密。\n22 旧金山和亚特兰大的例子来自哈佛大学格莱泽(Glaeser)和沃顿商学院吉尤科(Gyourko)的论文(2018)。\n33 各类城市房价和人均可支配收入数据来自宾夕法尼亚大学方汉明等人的论文(Fang et al., 2015)。\n44 不同地区城镇人口和土地数据来自恒大经济研究院任泽平、夏磊和熊柴的著作(2017)。\n55 数字来自复旦大学韩立彬和上海交通大学陆铭的论文(2018),他们详细分析了土地供给政策倾斜和地区间房价分化。\n66 2020年4月发布了《中共中央 国务院关于构建更加完善的要素市场化配置体制机制的意见》。\n77 美国的数字来自哈佛大学的研究报告(Spader, McCue and Herbert, 2016)。英国的数字来自三位英国经济学家的著作(Ryan-Collins, Lloyd and Macfarlane, 2017)。\n88 欧洲房产价值占国民财富比例大幅上升,与“二战”后经济复苏与重建有关。美国上升幅度相对较小,部分是因为美国在战后成为超级大国,所以作为分母的国民财富增幅巨大。各国财富构成的数据来自巴黎经济学院皮凯蒂(Piketty)和伯克利加州大学祖克曼(Zucman)的论文(2014)。\n99 “两房”并非国企,而是和政府联系非常紧密的私企,属于“政府支持企业”(government-sponsored enterprise),享受各种政府优惠,也承担政策任务。“两房”可以从财政部获取信用额度,几乎相当于政府对其债务的隐形担保,虽然法律上政府并无担保义务。\n1010 数据来自芝加哥大学拉詹(Rajan)的著作(2015)。\n1111 传统的经济周期理论非常注重投资的作用。虽然投资占GDP的比重在发达国家相对较小,但波动远比消费剧烈,常常是经济周期的主要推手。随着对债务研究的深入,经济学家越来越重视消费对经济周期的影响。普林斯顿大学迈恩(Mian)和芝加哥大学苏非(Sufi)的著作(2015)详细介绍了美国居民部门的债务和消费情况。\n1212 美联储旧金山分行的研究报告(Glick and Lansing, 2010)显示:2008年之前的10年间,欧美主要国家的房价和居民负债高度正相关,而负债越多的国家危机之后消费下降也越多。\n1313 剩余23%是各种经营性贷款。我国的统计口径把所有部门分为政府、居民、企业,但居民中还包括各种非法人企业,比如个体户,所以居民贷款中含有经营性贷款。\n1414 我国的数据来自中国人民银行调查统计司的报告(2020)。美国数据来自美联储纽约分行的《家庭债务与信用季报》(Quarterly Report on Household Debt and Credit)。\n1515 美国居民财富组成的数据来自美联储发布的2019年度美国金融账户组成数据。\n1616 中央财经大学张川川、国务院发展研究中心贾珅、北京大学杨汝岱研究了房价和空置率的正向关系,认为二者同时受到收入不平等扩大的影响(2016)。\n1717 即低收入群体通过借贷消费,可参考芝加哥大学贝特朗(Bertrand)和莫尔斯(Morse)的论文(2016)。\n1818 储蓄不平等的数据来自西南财经大学的甘犁、赵乃宝和孙永智等人的研究(2018)。\n1919 中国人民银行调查统计司的报告(2020)指出,资产最少的20%的负债家庭中,民间借贷占债务的比重将近10%。年收入6万元以下家庭的债务收入比数据来自中国人民银行金融稳定分析小组的报告(2019)。\n2020 此处数字来自普林斯顿大学迈恩和芝加哥大学苏非的著作(2015)以及德国波恩大学三位经济学家的论文(Kuhn, Schularick and Steins, 2020)。\n2121 数据来自中国人民银行金融稳定分析小组的报告(2019)。\n2222 我国住房按揭资产证券数据来自万得数据库。2019年末的美国数据也包含了商业地产,按揭总量数据来自美联储,住房按揭资产证券总量数据来自sifma网站。\n2323 本图设计来自上海交通大学陆铭的著作(2016),我更新了数据。\n2424 上海交通大学陆铭的著作(2016)指出,发达国家的经济集聚和城市化还在继续。\n2525 数据来自国务院发展研究中心邵挺、清华大学田莉、中国人民大学陶然的论文(2018)。\n2626 “地票”价格和土地供应数据来自重庆市前市长黄奇帆的著作(2020)。\n2727 2017年发布的《住房城乡建设部 国土资源部关于加强近期住房及用地供应管理和调控有关工作的通知》。\n2828 2019年,国土资源部与住房和城乡建设部印发《利用集体建设用地建设租赁住房试点方案》,确定北京、上海、沈阳、南京、杭州、合肥、厦门、郑州、武汉、广州、佛山、肇庆、成都等13个城市为第一批试点。\n2929 在居民户口制度下,原城镇户口居民基本不受影响,原农业户居民可以继续保有和农村土地相关的权益(如土地承包经营权和宅基地使用权),且在社会保障方面同城镇居民接轨。\n3030 2016年,国土资源部联合五家中央部委印发《关于建立城镇建设用地增加规模同吸纳农业转移人口落户数量挂钩机制的实施意见》。\n3131 国家发展改革委《2019年新型城镇化建设重点任务》。\n3232 世界银行定义的每天1.9美元的极端贫困收入标准,按2011年购买力平价调整后相当于每年2 441元人民币。而我国2011年的农村最低贫困线标准是每年2 300元,城镇的贫困线标准则高于世行标准。\n3333 全球人口按不同收入组别在各国之间的分布,来自“全球不平等实验室”的报告(World Inequality Lab 2017)。\n3434 基尼系数的数字来自北京师范大学李实和朱梦冰的论文(2018)。\n3535 城镇低收入人群的平均实际收入年均增长率为6.2%,中等收入人群为7.6%,高收入人群为8.9%。\n3636 新加坡国立大学樊漪、易君健和浙江大学张俊森的论文(Fan, Yi and Zhang,2021)研究了父母收入对子女收入的影响。\n3737 房价和收入增长数据可参考宾夕法尼亚大学方汉明等人的研究(Fang et al., 2015)。\n3838 数据来自中国人民银行调查统计司的报告(2020)。\n3939 在沿海省份,“80后”收入与其父母收入的相关性,高于“70后”;但在内陆,这一相关性在“80后”与“70后”之间变化不大。这一发现来自新加坡国立大学樊漪、易君健以及浙江大学张俊森的论文(Fan, Yi and Zhang, 2021)。\n4040 这一效应由已故的传奇经济学家赫希曼(Hirschman)提出,详见其文集(Hirschman, 2013)。他也讨论了影响不平等容忍度的诸多因素,如人群相似性与家庭观念等。\n4141 数据来自哈佛大学切蒂(Chetty)等人的论文(2020)。\n4242 子女收入超越父母的概率,称为“绝对流动性”。对我国绝对流动性的估计,来自新加坡国立大学樊漪、易君健以及浙江大学张俊森的论文(Fan, Yi and Zhang, 2021);对美国的估计来自哈佛大学切蒂(Chetty)等人的论文(2017)。在本书写作之际(2020年),中国“90后”才刚刚进入劳动力市场,收入还未稳定下来,数据也有待收集。\n4343 法国经济学家皮凯蒂在著作(2014)中详细分析了“库兹涅茨曲线”理论的来龙去脉。世界银行的米兰诺维奇(Milanovic)在著作(2019)中描述了起起落落的“库兹涅茨波浪”。\n4444 诺贝尔经济学奖得主斯蒂格利茨(Stiglitz)的著作(2013)讨论了不平等的种种代价。斯坦福大学历史学教授沙伊德尔(Scheidel)的著作(2019)指出,历史上不断扩大的不平等几乎都难以善终,最后往往以大规模的暴力和灾难重新洗牌。\n第六章 债务与风险 # 有一对年轻情侣,都在上海的金融行业工作,收入不错。研究生刚毕业没几年,算上年终奖,两人每月到手共5万元。他们对前途非常乐观,又到了谈婚论嫁的年龄,所以决定买房结婚。家里老人凑齐了首付,又贷了几百万元银行按揭,每月还款3万元。上海物价和生活费用不低,年轻人也少不了娱乐和应酬,还完房贷后存不下什么钱。但好在前途光明,再努力几年,收入如果翻一番,房贷压力也就轻了。何况房价一直在涨,就算把买房当投资,回报也不错。\n天有不测风云。从2018年开始,金融行业日子开始不好过。一个人的公司倒闭了,暂时失业;另一个也没了年终奖,收入下降不少。每月到手的钱从5万变成了2万,可按揭3万还雷打不动。老人们手头也不宽裕,毕竟一辈子的积蓄大多已经交了首付,顶多也就能帮着再支撑几个月。于是年轻人找工作的时候也不敢太挑,总算找到了一份收入还过得去的,新冠肺炎疫情又来了……\n人们在乐观时往往会低估负债的风险,过多借债。当风险出现时,又会因为债务负担沉重而缺乏腾挪空间,没办法应对。从上述故事中可以看到,就算房价不下跌,债务负担重的家庭也面临至少三大风险。一是债务缺乏弹性。若顺风顺水发了财,债务不会跟着水涨船高;可一旦倒了霉,债务也一分不会少。二是收入变化弹性很大。影响个人收入的因素数之不尽,宏观的、行业的、公司的、领导的、同事的、个人的……谁能保证自己未来几十年收入只涨不跌?就算不会失业,收入也不下降,但只要收入增长缓慢或不增长,对于高负债的家庭就已经构成了风险。既要还本又要付息,每个月紧巴巴的“月光”生活,能挺几年?第三个风险来自家庭支出的变动。突然有事要用钱怎么办?家里老人生病怎么办?要养孩子怎么办?\n可见债务负担如果过重,会产生各种难以应对的风险。2018年末,我国的债务总量达到了GDP的258%(图6-1),已经和美国持平(257%),超过了德国(173%),也远高于一些发展中大国,比如巴西(158%)和印度(123%)。而且我国债务增长的速度快于这些国家,债务总量在10年间增加了5.5倍。即便我国经济增长强劲,同期GDP还增加了2.8倍,但债务占GDP的比重在10年间还是翻了一番,引发了国内外的广泛关注和担忧。近几年供给侧结构性改革中的诸多举措,尤其是“去产能”“去库存”“去杠杆”,都与债务问题和风险有关。\n图6-1 中、美、德、日四国债务占各自GDP比重\n数据来源:IMF全球债务数据库。此处债务仅包括银行贷款和债券。\n债务占GDP的比重是否就能衡量真实的债务负担,目前尚有争议。当利率为零甚至是负的时候,只要名义GDP(内含物价)保持上涨,债务占GDP的比重可能不是个大问题,至少对政府的公共债务来说不是大问题,可以不断借新还旧。 11 但对居民和企业而言,债务总量快速上升,依然会带来很大风险。第五章已经分析了居民债务的风险,本章重点分析企业和银行的风险。\n第一节和第二节解释债务的一般经济学原理。这部分介绍欧美情况多一点,希望读者明白我国债务问题虽有诸多特色,但与欧美也有不少相似之处,前车可鉴。第三节分析我国债务的成因、风险、后果。无论是居民、企业还是政府,负债都与地方政府推动经济发展的模式有关。第四节讨论如何偿还已有债务和遏制新增债务。\n第一节 债务与经济衰退 # 经济的正常运行离不开债务。企业在卖出产品收到货款之前,需要先建设厂房,购买设备,支付工资,这些支出通常需要从银行贷款。个人买房也往往需要贷款,否则光靠一点点储蓄去全款买房,恐怕退休之前都买不起。政府也常需要借钱,否则无力建设周期长、投资大的基础设施。\n债务关系让经济各部门之间的联系变得更加紧密,任何部门出问题都可能传导到其他部门,一石激起千层浪,形成系统风险。银行既贷款给个人,也贷款给企业。若有人不还房贷,银行就会出现坏账,需要压缩贷款;得不到贷款的企业就难以维持,需要减产裁员;于是更多人失去工作,还不上房贷;银行坏账进一步增加,不得不继续压缩贷款……如此,恶性循环便产生了。如果各部门负债都高,那应对冲击的资源和办法就不多,风吹草动就可能引发危机。这类危机往往来势汹汹,暴发和蔓延速度很快,原因有二。\n第一,负债率高的经济中,资产价格的下跌往往迅猛。若债务太重,收入不够还本,甚至不够还息,就只能变卖资产,抛售的人多了,资产价格就会跳水。这种情况屡见不鲜。 22 2008—2009年的美国次贷危机中,美国家庭房贷负担很重,很多人不得不卖房,房价不到一年就跌了两成。2011—2012年,借钱炒房的“温州炒房团”和温州中小企业资金链断裂,导致房产纷纷被抛售,温州房价一年内跌了近三成。 33 2013—2014年,内蒙古和晋陕等地的煤炭企业崩盘。很多煤老板曾在煤价上涨时大肆借债扩张,煤价大跌后无力还债,大幅折价变卖豪车和房产。\n第二,资产价格下跌会引起信贷收缩,导致资金链断裂。借债往往需要抵押物(如房产和煤矿),若抵押物价值跳水,债权人(通常是银行)坏账就会飙升,不得不大幅缩减甚至干脆中止新增信贷,导致债务人借不到钱,资金链断裂,业务难以为继。2004—2008年,爱尔兰经济过热,信贷供给年均增速为20%。2009年,美国次贷危机波及爱尔兰,银行业出现危机,2009—2013年信贷增速剧烈收缩至1.3%,导致大量企业资金链断裂。 44 在2011—2012年的温州民间借贷危机中,一些人跑路逃债,信任危机迅速发酵,所有人都捂紧钱包,信用良好的人也借不到钱。可见债务危机往往也会殃及那些债务水平健康的部门,形成连锁反应,造成地区性甚至全国范围的经济衰退。\n一个部门的负债对应着另一个部门的资产。债务累积或“加杠杆”的过程,就是人与人之间商业往来增加的过程,会推动经济繁荣。而债务紧缩或“去杠杆”也就是商业活动减少的过程,会带来经济衰退。举例来说,若房价下跌,老百姓感觉变穷了,就会勒紧裤腰带、压缩消费。东西卖不出去,企业收入减少,就难以还债,债务负担过高的企业就会破产,银行会出现坏账,压缩贷款,哪怕好企业的日子也更紧了。这个过程中物价和工资会下跌(通货紧缩),而欠的钱会因为物价下跌变得更值钱了,实际债务负担就更重了。 55 发达国家经济中最重要的组成部分是消费,对经济影响很大。美国的消费约占GDP七成,2008年的全球金融危机中消费大幅下挫,成了经济衰退的主要推手。危机之前房价越高的州,老百姓债务负担越重,消费下降也越多,经济衰退越严重。在欧洲,2008年之前房价涨幅越大的国家,居民债务负担越重,危机中消费下降也越多。 66 债务带来的经济衰退还会加剧不平等(第五章),因为债务危机对穷人和富人的打击高度不对称。这种不对称源于债的特性,即法律优先保护债权人的索赔权,而欠债的无论是公司还是个人,即使破产也要清算偿债。以按揭为例,穷人因为收入低,买房借债的负担也重,房价一旦下跌,需要先承担损失,直到承担不起破产了,损失才转到银行及其债主或股东,后者往往是更富的人。换句话说,债务常常把风险集中到承受能力最弱的穷人身上。一个比较极端的例子是西班牙。在大多数国家,还不起房贷的人可以宣布破产,银行把房子收走,也就两清了。但在西班牙,哪怕房主把房子给了银行并宣布破产,也只能免于偿还按揭利息,本金仍然要偿还,否则累计的罚金和负债将一直存在,会上失信名单,很难正常生活。在金融危机中,这项法律引起了社会的不满和动荡,开锁匠和警察拒绝配合银行驱逐房主。破产了也消不掉的债成了沉重的负担:全球金融危机爆发五年后,西班牙是全球经济衰退最严重的国家之一。 77 第二节 债台为何高筑:欧美的教训 # 债务源于人性:总想尽早满足欲望,又对未来盲目乐观,借钱时总觉得将来能还上。但人性亘古不变,债务周期却有起有落,每一次起伏都由特定的外部因素推动,这些因素会引发乐观情绪、刺激人们借债,也会增加资金供给、为借债大开方便之门。\n20世纪80年代以来欧美的政治经济环境,刺激了居民对房子的需求(第五章)。但买房的前提是银行愿意放贷,否则需求就无法转化为实际购买力。若只是借贷需求增加而资金供给不增加,那利息就会上涨,需求会被抑制,贷款数量和债务水平不一定会上升。居民和企业的债务规模,换个角度看也就是银行的信贷和资产规模。所以要理解债务的增长,首先要理解银行为什么会大量放贷。\n资金供给与银行管制 # 资金供给的增加源于金融管制的放松。一方面,银行越做越大,创造的信贷越来越多;另一方面,金融创新和衍生品层出不穷,整个金融部门的规模和风险也越滚越大。\n全球金融自由化浪潮始于20世纪70年代布雷顿森林体系的解体。在布雷顿森林体系下,各国货币以固定比例与美元挂钩,美元则以固定比例与黄金挂钩。要维持这一固定汇率体系,各国都需要充足的外汇储备去干预市场,防止汇率波动。所以国际资本流动的规模不能太大,否则就可能冲破某些国家的外汇储备,威胁整个体系。而要限制国际资本流动,就要限制国内银行放贷规模,否则借到钱的居民或企业就会增加消费品或投资品的进出口,过量的国际贸易和结算会引发过量的国际资本流动。\n布雷顿森林体系解体后,发达国家之间实行了浮动汇率,放开了跨境资本流动。企业和居民既可以从本国银行借钱,也可以从外国银行借钱,所以单方面管控国内银行的信贷规模就没用了,于是各国纷纷放松了对银行和金融机构的业务限制,自由化浪潮席卷全球。但银行危机也随之而来。1980年至2010年,全球发生了153次银行危机,平均每年5次。而在布雷顿森林体系下,1945年至1970年,全球总共才发生了2次银行危机。纵观整个19世纪和“二战”之前,全球银行危机的频率都与国际资本流动规模高度相关。 88 金融风险的核心是银行,历次金融危机几乎都伴随着银行危机。简单说来原因有四。 99 第一,银行规模大、杠杆高。美国银行业资产规模在1950年只占GDP的六成,而到了2008年全球金融危机之前已经超过了GDP,且银行自有资本占资产规模的比重下降到了5%左右。换句话说,美国银行业在用5块钱的本钱做着100块钱的生意,平均杠杆率达到了20倍。理论上只要亏5%,银行就蚀光了本。欧洲银行的杠杆率甚至更高,风险可想而知。 1010 第二,银行借进来的钱很多是短期的(比如活期存款),但贷出去的钱却大都是长期的(比如企业贷款),这种负债和资产的期限不匹配会带来流动性风险。一旦储户集中提取存款,银行贷出去的钱又不能立刻收回来,手里钱不够,会出大乱子。后来银行业引入了存款保险制度,承诺对个人存款进行保险,才缓解了挤提风险,但并没有完全解除。现代银行业务复杂,早已不是简单的存贷款机构,很多负债并非来自个人存款,而是来自货币基金和对冲基金,并不受存款保险制度保护。 1111 一旦机构客户信心不足或急需流动性,也会形成挤提。\n第三,银行信贷大都和房地产有关,常常与土地和房产价值一同起落,放大经济波动。银行因为杠杆率高,所以要特别防范风险,贷款往往要求抵押物。土地和房子就是最好的抵押物,不会消失也不会跑掉,价值稳定,潜在用途广,就算砸手里也不难转让出去。因此银行喜欢贷款给房地产企业,也喜欢做居民按揭。2012年,英国的银行贷款中79%都和住房或商业地产有关,其中65%是按揭。美国的银行贷款中也有接近七成是按揭或其他房地产相关贷款。平均来看,欧美主要国家的银行信贷中将近六成都是按揭或不动产行业贷款。 1212 所以房地产周期和银行信贷周期常常同步起伏,而这两个行业的杠杆率又都不低,也就进一步放大了经济波动。\n土地价值顺着经济周期起落,繁荣时地价上涨,衰退时地价下跌。而以土地为抵押物的银行信贷也顺着土地价值起落:地价上涨,抵押物价值上行,银行利润上升,资本充足率也上升,更加愿意多放贷,为此不惜降低放贷标准,逐渐积累了风险。经济衰退时,上述过程逆转。所以银行很少雪中送炭,却常常晴天送伞,繁荣时慷慨解囊、助推经济过热,衰退时却捂紧口袋、加剧经济下行。举例来说,在21世纪初的爱尔兰,大量银行资金涌入房地产行业,刺激房价飞涨。金融危机前,爱尔兰房地产建设投资占GDP比重由4%升至9%,建筑部门的就业人数也迅速增加。危机之后,房地产行业萎缩严重,急需周转资金,然而银行的信贷增速却从危机前的每年20%剧烈收缩至1.3%,很多企业因缺乏资金而倒闭,失业率居高不下。 1313 第四,银行风险会传导到其他金融部门。比如银行可以把各种按揭贷款打包成一个证券组合,卖给其他金融机构。这种业务挫伤了银行信贷分析的积极性。如果银行借出去的钱转手就能打包卖给下家,那银行就不会在乎借钱的人是不是真的有能力还钱。击鼓传花的游戏,传的是什么东西并不重要,只要有人接盘就行。在2008年金融危机中,美国很多按揭贷款的质量很差,借款人根本没能力还钱,有人甚至用宠物的名字都能申请到按揭,所以这次危机也被称为“次贷危机”。\n随着银行和其他金融机构之间的交易越来越多,整个金融部门的规模也越滚越大,成了经济中最大的部门。金融危机前,金融部门的增加值已经占到美国GDP的8%。 1414 频繁的金融活动并没有提高资本配置的效率,反而给经济带来了不必要的成本。过多的短期交易扩大了市场波动,挤压了实体经济的发展空间。资金和资源在金融体系内部空转,但实体经济的蛋糕却没有做大。而且大量金融交易都是业内互相“薅羊毛”,所以“军备竞赛”不断升级,大量投资硬件,高薪聘请人才,导致大量高学历人才放弃本专业而转投金融部门。 1515 金融危机后,金融部门的过度繁荣引发了各界的反思和批评,也引发了“占领华尔街”之类的社会运动。\n国际不平衡与国内不平等 # 金融自由化浪潮为借贷打开了方便之门,但如果没有大量资金涌入金融系统,借贷总量也难以增加。以美国为例,这些资金来源有二。其一,一些国家把钱借给了美国,比如我国就是美国最大的债主之一。其二,美国国内不平等急剧扩大,财富高度集中,富人有了更多花不完的钱可以借给穷人。\n中国等东亚国家借钱给美国,与贸易不平衡有关。2018年,中美双边贸易逆差约4 000亿美元,也就是说美国需要从全世界借入4 000亿美元来为它从中国额外的进口买单,其中最主要的债主就是中国和其他东亚国家。后者在1997年东亚金融危机中吃过美元储备不足的大亏,所以之后大量增加美元储备,买入美国国债或其他证券,相当于把钱借给了美国。这种现象被美联储前主席本·伯南克(Ben Bernanke)称为“全球储蓄过剩”。他认为这些钱流入美国后压低了美国利率,推动了房地产投机,是引发2008年全球金融危机的重要原因。\n然而借钱给美国的还有欧洲,后者受美国金融危机冲击最大、损失也最大。美国各种金融“毒资产”的最大海外持有者并不在亚洲,而在欧洲。东亚借钱给美国与贸易不平衡有关,资金主要是单向流动。而欧洲和美国的贸易基本平衡,资金主要是双向流动:欧洲借钱给美国,美国也借钱给欧洲。这种资本流动总量虽巨大,但双向抵销后的净流量却不大。正是这种“净流量小”的假象掩盖了“总流量大”的风险。“你借我、我借你”的双向流动,让围绕“毒资产”的交易规模越滚越大,风险也越来越大。比如,一家德国银行可以在美国发行用美元计价的债券,借入美元,然后再用这些美元购买美国的房贷抵押证券,钱又流回了美国。这家银行的负债和资产业务都是美元业务,仿佛是一家美国银行,只不过总部设在德国罢了。类似的欧洲银行很多。金融危机前,跨大西洋的资本流动远多于跨太平洋资本流动。而在危机中,美联储为救市所发放的紧急贷款,实际上大部分给了欧洲银行。 1616 国际资本流入美国,也有美国自身的原因,否则为什么不流入其他国家?美元是全世界最重要的储备货币,以美元计价的金融资产也是最重要的投资标的,受到全球资金的追捧,所以美国可以用很低的利率从全球借钱。大量资本净流入美国,会加剧美国贸易逆差,因为外国人手里的美元也不是自己印出来的,而是把商品和服务卖给美国换来的。为保持美元的国际储备货币地位,美国的对外贸易可能需要常年保持逆差,以向世界提供更多美元。但持续的逆差会累积债务,最终威胁美元的储备货币地位,这个逻辑也被称为“特里芬悖论”。 1717 所以如今的全球经济失衡,是贸易失衡和美元地位带来的资本流动失衡所共同造就的。\n国际资本流入不是美国可贷资金增加的唯一原因,另一个重要原因是国内的贫富差距。如果全部财富集中在极少数人手中,富人就会有大量的闲置资金可以借贷,而大部分穷人则需要借钱生存,债务总量就会增加。假如一个国家只有两个人,每人需要消费50元才能活下去。若总产出100元被二人平分,那总消费就等于产出,既没有储蓄也没有负债。但若甲分得100元而乙分得0元,那甲就花50元存50元,乙就需要借50元,这个国家的储蓄率和负债率就都变成了50%。\n在大多数发达国家,过去40年国内贫富差距的扩大都伴随着国内债务水平的上升。 1818 以美国为例:2015年,最富有的10%的人占有将近一半的全部收入,而40年前只占35%。换句话说,40年前每生产100元,富人拿35元,其他人拿65元,但如今变成了对半分,富人从国民收入这块蛋糕里多切走了15%。与这个收入转移幅度相比,常被政客们说起的中美双边贸易“巨额”逆差,2018年只占美国GDP的2%不到。\n如果不看每年收入的差距而看累积的财富差距的话,不平等就更加惊人。2015年,美国最富有的10%的人占有了全部财富的78%。 1919 富人的钱花不完,消费远低于收入,就产生了大量储蓄。过去40年,美国国内最富有的1%的人的过剩储蓄,与伯南克所谓的由海外涌入美国的全球过剩储蓄,体量相当。 2020 理论上讲,这些富人的储蓄可以借给国内,也可以借给国外。但事实上,美国国内资金并没有流出,反而有大量国际资本流入了美国,因此富人的储蓄必然是借给了国内的企业、政府或居民。然而在全球金融危机前的几十年,美国国内企业的投资不增反降,政府每年的赤字和借债也相对稳定,所以富人的储蓄实际上就是借给了其他居民(穷人),变成了他们的债务。\n穷人借债主要是买房,因此富人的余钱也就通过银行等金融中介流向了房地产。金融危机前,美国银行业将近七成的贷款是按揭或其他房地产相关贷款。所以大部分银行并没有把社会闲散资金导向实体企业,变成生产性投资,而是充当了富人借钱给穷人买房的中介。这种金融服务的扩张,降低了资金配置效率,加大了风险。\n这种金融资源“脱实向虚”的现象,在我国也引发了广泛关注。在2019年的上海“陆家嘴论坛”上,中国银行保险监督管理委员会主席郭树清就强调要提高资金使用效率,解决好“脱实向虚”问题,下大力气清理金融体系内部的空转资金。而且特别强调了房地产金融化的问题:“一些房地产企业融资过度挤占了信贷资源,导致资金使用效率进一步降低,助长了房地产投资投机行为。”\n实体企业投资需求不足 # 债务本身并不可怕,如果借来的钱能用好,投资形成的资产能增加未来收入,还债就不成问题。假如资金能被实体企业投资所吸纳,就不会流到房地产和金融行业去推升资产泡沫。然而在过去40年间,主要发达国家的投资占GDP的平均比重从20世纪70年代的28%下跌到了20%。 2121 一个原因是大公司把投资转移到了发展中国家(包括中国),制造业整体外迁。而制造业又是重资产和重投资的行业,所以国内制造业占比下降就推动了投资下降。同时,随着通信技术的发展,机器变得越来越智能化,需要运用大量软件和服务,而设备本身的相对价值越来越低。所以大量投资进入了所谓的“无形资产”和服务业,而服务业更依赖于人的集聚,也就推升了对特定地段的住房和社交空间(即各类商业地产)的需求。 2222 实体投资下降的另一个原因是发达国家经济的整体竞争性在减弱:行业集中度越来越高,大企业越变越大。理论上说,这不一定是坏事,若明星企业通过竞争击败对手、占据市场后依然锐意进取、积极创新,那么投资和生产率还会继续上升。然而实际情况是,美国各行业集中度的提高与企业规模的扩张,往往伴随着投资下降和生产率降低。 2323 大量资金的涌入增加了资金供给,而企业投资需求不足又降低了资金需求,所以发达国家的长期实际利率(扣除物价因素)在过去40年间一直稳步下降,如今基本为零。 2424 因为缺乏能获得长期稳定收益的资产,各种短期投机便大行其道,所谓“金融创新”层出不穷,“房地产泡沫”一个接一个。金融危机之后,美联储常年的宽松货币政策让短期利率也变得极低,大企业便借机利用融资优势大肆购并小企业,进一步增加了行业集中度,降低了竞争。这种低利率环境也把大量追逐回报的资金推入了股市,推高了股价。而美国最富的10%的人掌握着90%的股市资产,贫富差距进一步拉大。 2525 这种情况也引起了我国政策制定者的警惕。2019年,中国人民银行行长易纲指出:“在缺乏增长点的情况下,央行给银行体系提供流动性,但商业银行资金贷不出去,容易流向资产市场。放松货币条件总体上有利于资产持有者,超宽松的货币政策可能加剧财富分化,固化结构扭曲,使危机调整的过程更长。” 2626 第三节 中国的债务与风险 # 我国债务迅速上涨的势头始于2008年。当年金融危机从美国蔓延至全球,严重打击了我国的出口。为防止经济下滑,中央立即出台了财政刺激计划,同时放宽了许多金融管制以及对地方政府的投融资限制,带动了基础设施投资大潮,也推动了大量资金涌入房地产。在不断的投资扩张和房价上涨中,融资平台、房地产企业、贷款买房的居民,债务都迅速上升。其他企业(尤其是国有企业)也在宽松的金融环境中举债扩张,但投资回报率却在下降,积累了低效产能。债务(分子)比GDP(分母)增长速度快,因此债务负担越来越重。\n与其他发展中国家相比,我国外债水平很低,债务基本都是以人民币计价的内债,所以不太可能出现国际上常见的外债危机,像希腊的主权债务危机和每过几年就要上演一次的阿根廷债务危机。根据国家外汇管理局的《中国国际收支报告》,我国2019年末外债余额占GDP的比重只有14%(国际公认安全线是20%),外汇储备是短期外债的2.6倍(国际公认安全线是1倍),足够应对短期偿付。而且即使在外债中也有35%是以人民币计价,违约风险很小。\n债务累积过程简述:2008—2018年 # 图6-2描述了中国始于2008年的债务累积过程。\n图6-2 中国的宏观债务占GDP比重\n数据来源:IMF全球债务数据库。此处债务仅包括银行贷款和债券。\n2008年至2009年,为应对全球金融危机,我国迅速出台“4万亿”计划,其中中央政府投资1.18万亿元(包括对汶川地震重建的拨款),地方政府投资2.82万亿元。为配合政策落地、帮助地方政府融资,中央放松了对地方融资平台的限制(第三章),同时不断降准降息,放宽银行信贷。这些资金找到了基建和房地产两大载体,相关投资迅猛增加。比如地方政府配合当时的铁道部,大量借债建设高铁:全国铁路固定资产投资从2007年的2 500亿元,猛增到2009年的7 000亿元和2010年的8 300亿元。\n2010年至2011年,前期刺激下的经济出现过热迹象,再加上猪肉价格大涨的影响,通货膨胀抬头,所以货币政策开始收紧。到了2011年年中,欧债危机爆发,国内制造业陷入困境,于是央行在2012年又开始降准降息,并放松了对地方融资平台发债的限制,城投债于是激增,净融资额比上年翻了一番还多。也是从2012年开始,以信托贷款为主的“影子银行” 2727 开始扩张,把大量资金引向融资平台,推动当年基建投资猛涨,债务负担从2012年起再次加速上涨。这一时期,中央开始加强了对房地产行业的控制和监管。\n2015年遭遇“股灾”,前些年投资过度造成的产能过剩和房地产库存问题也开始凸显。2015年末,美联储退出量化宽松,美元开始加息,再加上一系列内外因素,导致2015—2016年连续两年的大量资本流出,人民币对美元汇率一路贬值,接近破七。央行于是连续降准降息,财政部开始置换地方债(第三章),中央也放松了对房地产的调控,全国棚户区改造从实物安置转变为货币化安置,带动房价进一步上涨。同时,“影子银行”开始“变形”:信托贷款在严监管下大幅萎缩,而银行理财产品规模开始爆发,流向融资平台和房地产行业的资金总量没有减少,总体债务负担在2015年又一次加速增长。\n2016年,在货币化“棚改”的帮助下,三四线城市房地产去库存告一段落,中央在年底首次提出“房住不炒”的定位,全面收紧房地产调控。也是在这一年,“去产能”改革开始见效,工业企业利润率开始回升,工业品出厂价格指数结束了长达五年的下跌,首次转正。\n2018年上半年,在连续两年相对宽松的外部条件下,央行等四部委联合出台“资管新规”,严控“影子银行”,试图降低累积多年的金融风险。信用和资金开始收缩,民营企业的融资困境全面暴露。下半年,“中美贸易战”开始,经济增长继续放缓。2018年末,我国债务总量占比达到GDP的258%:其中居民债务为54%,政府债务为51%,非金融企业为154%(图6-3)。在政府债务中,中央国债约为17%,地方政府债务为34%。 2828 第三章和第五章已经分别讨论过地方政府债务和居民债务,此处不再赘述,本章重点介绍企业债务以及债主银行的风险。\n图6-3 2018年中、美、德、日四国各部门债务占GDP比重\n数据来源:IMF全球债务数据库。此处债务仅包括银行贷款和债券。\n企业债务 # 从图6-3中可以看出,我国居民债务负担接近发达国家,政府债务负担低于发达国家,但企业债务负担远高于发达国家。2018年,美国和德国企业债务占GDP的比重分别为75%和58%,但我国高达154%。原因之一是资本市场发展不充分,企业融资以债务尤其是银行贷款为主,股权融资占比很低。2018年末,企业债务总额约140万亿元,但在境内的股票融资余额不过区区7万亿元。 2929 企业债务中第一个广受关注的问题是地方政府融资平台企业的债务,约占GDP的40%,资金主要投向基础设施,项目回报率很低(平均1%左右)。第三章已详细讨论过这类债务,不再赘述。\n第二个问题是所谓“国进民退”现象。2008年以后,国有企业规模快速扩张,但效率比私营企业低,多占用的资金没有转化为同比例的新增收入,推升了整体债务负担。按照财政部《中国财政年鉴》中的数据,1998—2007年的10年间,国企资产总额只增长了1.6倍,但2008—2017年这10年间却激增了4.4倍,负债总额也相应增长了4.7倍,占GDP的比重从78%变成144%。 3030 但国企总利润占GDP的比重却从4.2%下降到了3.9%,营业收入占GDP比重从72%下降到了65%。 3131 上述国企数据被广泛使用,但这些数据及相关研究尚有诸多不明之处,很难确知国企的整体情况。 3232 国有工业类企业中的数据更清楚一些,因为国家统计局一直记录详细的工企数据。2008—2017年,国有工业企业的资产和负债规模相对GDP来说并没有大幅扩张,只是略有上升,基本稳定,所以在工业领域并没有出现明显的“国进民退”现象。国企的资产负债率一直高于私营企业,但利润率却较低。2008年“4万亿”计划出台之后,国企与私企的利润率差距进一步扩大,2013年之后才开始缩小。这一变化的主要原因不在国企,而是私企利润率在“4万亿”计划之后飙升,2012年以后回落。可能的一个原因是在信贷宽松的刺激之下,很多有资源、有关系的私营企业(比如上市公司)大肆扩张,偏离主营业务,去“炒地皮”和“炒房子”,虽然获得了短期收益,但最终造成资金使用效率的下降。 3333 低效率乃至亏损的国企或大中型私企,若不能破产重组,常年依靠外力“输血”,挤占有限的信贷资源,变成“僵尸企业”,就会拉低经济整体效率,推升宏观债务负担。针对这一情况,近年来的改革重点包括:推进国企混改,限制地方政府干预;加强金融监管,从源头上拧紧资金的水龙头;在要素市场上推行更加全面的改革,让市场力量在资金、土地、技术、劳动力等生产要素配置中发挥更大作用;改革和完善《企业破产法》,在债务重整过程中“去行政化”,避免地方官员主导企业破产重组,损害债权人利益(比如第四章中的江西赛维案)。\n关于企业债务的第三个广受关注的问题是房地产企业的债务问题。房地产是支柱型产业,不仅本身规模巨大,而且直接带动钢铁、玻璃、家具、家电等众多行业。以2013年为例,房地产及其直接相关行业创造的增加值占GDP的比重超过15%,且增速极快,对GDP增长率的贡献接近30%。 3434 由于房地产开发需要大量资金去购置土地,建设周期也很长,所以企业经营依赖负债,资产负债率接近80%,流动性风险很大。一旦举债渠道受阻,企业就难以为继。举个例子,在上市房企中,与“买地”有关的成本约占总成本的五六成。 3535 在购置土地环节,发达国家一般要求企业使用自有资本金,而我国允许房企借钱“买地”,这就刺激了房企竞相抬高地价和储备土地。储备的土地又可以作为抵押去撬动更多借贷资金,进而储备更多土地,所以房企规模和债务都越滚越大。\n2018年,我国房企总债务占GDP的比重达到了75%,且大量债务来自“影子银行”或其他监管薄弱的渠道。 3636 房企的现金流依赖房产预售款和个人按揭,这两项收入占2018年实际到位资金的将近一半。一旦由于疫情等外部冲击原因出现房产销售问题,房企就可能面临资金链断裂的风险。2020年,这类风险开始显现,无论是泰禾集团的违约还是恒大集团的“内部文件”,都吸引了广泛关注。一旦房企出现债务危机,无疑会冲击金融系统和宏观经济。而且房价连着地价,地价高低又直接影响地方政府收入,危及地方政府及融资平台的债务偿付能力。2020年8月,城乡住房建设部、中国人民银行出台了对重点房地产企业资金监测和融资管理规则,针对企业的关键债务指标画下“三道红线”,也规定企业不得再挪用贷款购地或竞买炒作土地。 3737 还有一种房企债务是在海外发行的美元债,在外国发行,以外币计价,所以不计入外汇管理局的宏观外债统计口径。截至2019年7月末,这类海外债余额约1 739亿美元。其中可能有风险,因为大多数房企并没有海外收入。自2019年7月起,发改委收紧了房企在海外发债。2020年上半年,这类债务开始净减少。 3838 总体看来,我国企业债务负担较重,应对风险的能力受限。若遭遇重大外部冲击,就可能面临债务违约风险。而企业裁员甚至倒闭,会降低居民收入,加大居民的风险,也加大其债主银行的风险。\n银行风险 # 无论是居民债务还是企业债务,都是从债务人角度看待风险,要想完整理解债务风险,还需要了解债权人的风险。中国的债权人主要是银行,不仅发放贷款,也持有大多数债券。上文讨论的欧美银行业的很多风险点,同样适用于我国。首先是对信贷放松管制,银行规模迅速膨胀。2008年的“4万亿”计划,不仅是财政刺激,也是金融刺激,存款准备金率和基准贷款利率大幅下调。银行信贷总额占GDP的比重从2008年的1.2左右一路上升到2016年的2.14。\n其次是银行偏爱以土地和房产为抵押物的贷款。我再用两个小例子来详细解释一下。先看住房按揭。银行借给张三100万元买房,实质不是房子值100万元,而是张三值100万元,因为他未来有几十年的收入。但未来很长,张三有可能还不了钱,所以银行要张三先抵押房子,才肯借钱。房子是个很好的抵押物,不会消失且容易转手,只要这房子还有人愿意买,银行风险就不大。若没有抵押物,张三的风险就是银行的风险,但有了抵押物,风险就由张三和银行共担。张三还要付30万元首付,相当于抵押了100万元的房子却只借到了70万元,银行的安全垫很厚。再来看企业贷款。银行贷给企业家李四500万元买设备,实质也不是因为设备值钱,而是用设备生产出的产品值钱,这500万元来源于李四公司未来数年的经营收入。但作为抵押物,设备的专用性太强,价值远不如住房或土地,万一出事,想找到人接盘并不容易。就算有人愿意接,价格恐怕也要大打折扣,所以银行风险不小。但若李四的企业有政府担保,甚至干脆就是国企,银行风险就小多了。\n所以如果优良的抵押物(住房和土地)越来越多,或者有政府信用担保的企业越来越多,那银行就有动力不断扩大信贷规模。在我国这样一个银行主导的金融体系中,地方融资平台能抵押的土地增加、涌入城市买房的人增加、地方政府的隐性担保增加等,都会从需求端刺激信贷规模的扩张。所以商业银行的信贷扩张,固然离不开宽松的货币环境,但也同样离不开信贷需求的扩张,离不开地方政府的土地金融和房地产繁荣,此所谓“银根连着地根”。\n第三是银行风险会传导到其他金融部门,这与“影子银行”的兴起有关。所谓“影子银行”,就是类似银行的信贷业务,却不在银行的资产负债表中,不受银行监管规则的约束。银行是金融体系核心,规模大,杠杆高,又涉及千家万户的储蓄,牵一发动全身,所以受严格监管。若某房地产企业愿意用10%的利息借钱,银行想借,但我国严格限制银行给房企的贷款量,怎么办?银行可以卖给老百姓一个理财产品,利息5%,再把筹来的钱委托给信托公司,让信托公司把钱借给房企。在这笔“银信合作”业务中,发行的理财产品不算银行储蓄,委托给信托公司的投资不算银行贷款,所以这笔“表外业务”就绕开了对银行的监管,是一种“影子银行”业务。\n有借钱需求的公司很多,愿意买银行理财产品的老百姓也很多,所以“影子银行”风生水起。相关的监管措施效果有限,往往是“按下葫芦起了瓢”。限制了“银信合作”业务,“银证信合作”业务又兴起:银行把钱委托给券商的资管计划,再让券商委托给信托公司把钱借给企业。管来管去,银行的钱到处跑,渠道越拉越长,滋润着中间各类资管行业欣欣向荣,整个金融业规模越滚越大。21世纪初,金融业增加值占GDP的比重大约在4%左右,而2015—2019年平均达到了8%,相当于美国在全球金融危机前的水平。但美国的资本市场是汇聚了全世界的资金后才达到这个规模,我国的资本市场尚未完全开放,金融业规模显然过大了。资金在金融系统内转来转去,多转一道就多一道费用,利息就又高了一点,等转到实体企业手中的时候,利息已经变得非常高,助推了各种投机行为和经济“脱实向虚”。此外,银行理财产品虽然表面上不在银行资产负债表中,银行既不保本也不保息,但老百姓认为银行要负责,而银行也确实为出问题的产品兜过底。这种刚性兑付的压力加大了银行和金融机构的风险。 3939 我国各种“影子银行”业务大都由银行主导,是银行链条的延伸,因此也被称为“银行的影子”。这与国外以非银金融机构主导的“影子银行”不同。中国的业务模式大多简单,无非多转了两道手而已,证券化程度不高,衍生品很少,参与的国际资本也很少,所以监管难度相对较低。2018年“资管新规”出台,就拧紧了“影子银行”的总闸,也打断了各种通道。但这波及的不仅是想借钱的房地产企业和政府融资平台,也挤压了既没有土地抵押也没有政府背书的中小私营企业,它们融资难和融资贵的问题在“资管新规”之后全面暴露。\n第四节 化解债务风险 # 任何国家的债务问题,解决方案都可以分成两个部分:一是偿还已有债务;二是遏制新增债务,改革滋生债务的政治、经济环境。\n偿还已有债务 # 对债务人来说,偿债是个算术问题:或提高收入,或压缩支出,或变卖资产拆东补西。实在还不上,就只能违约,那债权人就要受损。最大的债权人是银行,若出现大规模坏账,金融系统会受到冲击。\n如果借来的钱能用好,能变成优质资产、产生更高收入,那债务负担就不是问题。但如果投资失败或干脆借钱消费挥霍,那就没有新增收入,还债就得靠压缩支出:居民少吃少玩,企业裁员控费,政府削减开支。但甲的支出就是乙的收入,甲不花钱乙就不挣钱,乙也得压缩支出。大家一起勒紧裤腰带,整个经济就会收缩,大家的收入一起减少。若收入下降得比债务还快,债务负担就会不降反升。这个过程很痛苦,日子紧巴巴,东西没人买,物价普遍下跌,反而会加重实际债务负担,因为钱更值钱了。如果抛售资产去还债,资产价格就下跌,银行抵押物价值就下降,风险上升,可能引发连锁反应。\n以地方政府为例。政府借债搞土地开发和城市化,既能招商引资提高税收,又能抬高地价增加收入,一举两得,债务负担似乎不是大问题。可一旦经济下行,税收减少,土地卖不上价钱,诸多公共支出又难以压缩,债务负担就会加重,就不得不转让和盘活手里的其他资产,比如国有企业。最近一两年经济压力大,中央又收紧了融资渠道,于是地方的国企混改就加速了。比如在2019年,珠海国资委转让了部分格力电器的股份,芜湖国资委也转让了部分奇瑞汽车的股份。\n还债让债务人不好过,赖账让债权人不好过。所以偿债过程很痛苦,还有可能陷入经济衰退。相比之下,增发货币也能缓解债务负担,似乎还不那么痛苦,因为没有明显的利益受损方,实施起来阻力也小。增发货币的方式大概有三类。第一类是以增发货币来降低利率,这是2008年全球金融危机前的主流做法。低利率既能减少利息支出,也能刺激投资和消费,提振经济。若经济增长、实际收入增加,就可以减轻债务负担。就算实际收入不增加,增发货币也能维持稳定温和的通货膨胀,随着物价上涨和时间推移,债务负担也会减轻,因为欠的债慢慢也就不值钱了。\n第二类方式是“量化宽松”,即央行增发货币来买入各类资产,把货币注入经济,这是金融危机后发达国家的主流做法。在危机中,很多人变卖资产偿债,资产市价大跌,连锁反应后果严重。央行出手买入这些资产,可以托住资产价格,同时为经济注入流动性,让大家有钱还债,缓解债务压力。从记账角度看,增发的货币算央行负债,所以“量化宽松”不过是把其他部门的负债转移到了央行身上,央行自身的资产负债规模会迅速膨胀。但只要这些债务以本国货币计价,理论上央行可以无限印钱,想接手多少就接手多少。这种做法不一定会推高通货膨胀,因为其他经济部门受债务所困,有了钱都在还债,没有增加支出,也就没给物价造成压力。欧美日在2008年全球金融危机之后都搞了大规模“量化宽松”,都没有出现通货膨胀。\n“量化宽松”的主要问题是难以把增发的货币转到穷人手中,因此难以刺激消费支出,还会拉大贫富差距。央行“发钱”的方式是购买各种金融资产,所以会推高资产价格,受益的是资产所有者,也就是相对富裕的人。2008年全球金融危机之后,美国“零利率”和“量化宽松”维持了好些年,股市大涨,房价也反弹回危机前的水平,但底层百姓并没得到什么实惠,房子在危机中已经没了,手里也没多少股票,眼睁睁看着富人财富屡创新高,非常不满(第五章)。这种不满情绪的高涨对政局的影响也从选举上反映了出来。2020年新冠肺炎疫情在美国暴发后,美联储再次开闸放水,资产负债表规模在3个月内扩张了六成以上,而随后的经济反弹被戏称为“K形反弹”:富人往上,穷人向下。\n第三类增加货币供给的做法是把债务货币化。政府加大财政支出去刺激经济,由财政部发债融资,央行直接印钱买过来,无需其他金融机构参与也无需支付利息,这便是所谓“赤字货币化”。2008年全球金融危机后,前两类增发货币的方式基本已经做到了尽头,而经济麻烦依然不断,新冠肺炎疫情雪上加霜,所以近两年对“赤字货币化”这种激进政策的讨论异常热烈,支持这种做法的所谓“现代货币理论”(Modern Monetary Theory,MMT)也进入了大众视野。\n“赤字货币化”的核心,是用无利率的货币替代有利率的债务,以政府预算收支的数量代替金融市场的价格(即利率)来调节经济资源配置。从理论上说,若私人部门陷入困境,而政府治理能力和财政能力过硬,“赤字货币化”也不是不能做。但若政府能力如此过硬却还是陷入了需要货币化赤字的窘境,那也正说明外部环境相当恶劣莫测。在这种情况下,“赤字货币化”的效果不能仅从理论推断,要看历史经验。从历史上看,大搞“赤字货币化”的国家普遍没有好下场,会引发物价飞涨的恶性通货膨胀,损害货币和国家信用,陷经济于混乱。这种后果每本宏观经济学教科书里都有记录,“现代货币理论”的支持者当然也知道。但他们认为历史上那些恶性通货膨胀的根源不在货币,而在于当时恶劣的外部条件(如动荡和战争)摧毁了产能、削弱了政府,若产能和政府都正常,就可以通过货币化赤字来提振经济。可这又回到了根本问题:若产能和政府都正常,怎么会陷入需要货币化赤字的困境?背后的根本原因能否靠货币化赤字化解?财政花钱要花在哪里?谁该受益谁该受损?\n国民党政府就曾经搞过赤字化,彻底搞垮了货币经济。抗日战争结束后,国民党政府大手花钱打内战。仅1948年上半年的财政赤字就已经是1945年全年赤字的780倍。央行新发行的货币(“法币”)几乎全部用来为政府垫款,仅1948年上半年新发行的纸币数量就是1945年全年新增发行量的194倍。物价完全失控。1948年8月的物价是1946年初的558万倍。很多老百姓放弃使用法币,宁可以物易物或使用黄金。1948年8月,国民党推行币制改革,用金圆券替换法币,但政府信用早已尽失。仅8个月后,以金圆券计价的物价就又上涨了112倍。据季羡林先生回忆,当时清华大学教授们领了工资以后要立刻跑步去买米,“跑快跑慢价格不一样!” 4040 我国目前的货币政策比较谨慎,国务院和央行都数次明确表态不搞“大水漫灌”,“不搞竞争性的零利率或量化宽松政策”。 4141 主要原因可能有二:第一,政府不愿看到宽松的货币政策再次推高房价,“房住不炒”是个底线原则;第二,货币政策治标不治本,无法从根本上解决债务负担背后的经济增速放缓问题,因为这是结构性的问题,是地方政府推动经济发展的模式问题。\n遏制新增债务 # 理解了各类债务的成因之后,也就不难理解遏制新增债务的一些基本原则:限制房价上涨,限制“土地财政”和“土地金融”,限制政府担保和国有企业过度借贷,等等。但困难在于,就算搞清楚了原因,也不一定就能处理好后果,因为“因”毕竟是过去的“因”,但“果”却是现在的“果”,时过境迁,很多东西都变了。好比一个人胡吃海塞成了大胖子,要想重获健康,少吃虽然是必须的,但简单粗暴的节食可能会出大问题,必须小心处理肥胖引起的很多并发症。\n反过来看,当年种下的“因”,也有当年的道理,或干脆就是不得已而为之。当下债务问题的直接起因是2008年的全球金融危机。当时金融海啸一浪高过一浪,出口订单锐减,若刺激力度不够,谁也不知道后果会如何。虽然现在回过头看,有不少声音认为“4万亿”计划用力过猛,但历史不能假设。\n再比如,政府通过或明或暗的担保来帮助企业借款,不一定总是坏事。在经济发展早期,有很多潜在收益很高的项目,由于金融市场不发达、制度不规范,不确定性很强,很难吸引足够的投资。正是由于有了政府担保,这些项目才得以进行。但随着市场经济不断发展,粗放式投资就能带来高收益的项目减少,融资需求逐渐多元化,若此时政府仍过多干预,难免把资金导入低效率的企业,造成过剩产能,挤占其他企业的发展空间。投资效益越来越低,对经济的拉动效果也越来越弱,债务负担和偿债风险就越来越高。\n总的说来,我国的债务问题是以出口和投资驱动的经济体系的产物。2008年之后,净出口对GDP的拉动作用减弱,所以国内投资就变得更加重要(见下一章的图7-4以及相关详细解释)。而无论是基建还是房地产投资,都由掌握土地和银行系统的政府所驱动,由此产生的诸多债务,抛开五花八门的“外衣”,本质上都是对政府信用的回应。所形成的债务风险,虽然表现为债主银行的风险,但最终依然是政府风险。最近几年围绕供给侧结构性改革所推行的一系列重大经济金融改革,包括严控房价上涨、“资管新规”、限制土地融资、债务置换、“反腐”、国企混改等,确实有效遏制了新增债务的增长,但是高度依赖负债和投资的发展模式还没有完成转型,因此限制债务虽限制了这种模式的运转,但并不会自动转化为更有效率的模式,于是经济增速下滑。\n限制债务增长的另一项根本性措施是资本市场改革,改变以银行贷款为主的间接融资体系,拓展直接融资渠道,既降低债务负担,也提高资金使用效率。与债权相比,股权的约束力更强。一来股东风险共担,共赚共赔;二来股权可以转让,股价可以约束公司行为。哪怕同样是借债,债券的约束力也比银行贷款强,因为债券也可以转让。\n这些年资本市场的改革进展相对缓慢,根本原因并不难理解。融资体系和投资体系是一体两面:谁来做投资决策,谁就该承担投资风险,融资体系也就应该把资源和风险向谁集中。若投资由政府和国企主导,风险也自然该由它们承担。目前的融资体系正是让政府承担风险的体系,因为银行的风险最终是政府的风险。以2018年的固定资产投资为例,按照国家统计局的口径,“民间投资”占62%,政府和国企占38%。但这个比例大大低估了政府的影响,很多私人投资是在政府产业政策的扶持之下才上马的。在房地产开发中,投资总额的四到五成是用来从政府手里买地的。这种投资结构所对应的风险自然主要由政府及其控制的金融机构承担。根据中国人民银行行长易纲的测算,2018年,我国金融资产中72%的风险由金融机构和政府承担。1995年和2007年,这个比例分别是74%和70%,多年来变化并不大。 4242 因此政府和国企主导投资与国有银行主导融资相辅相成,符合经济逻辑。这一体系在过去的经济增长中发挥过很大作用,但如果投资主体不变,权力不下放给市场,那想要构建降低政府和银行风险的直接融资体系、想让分散的投资者去承担风险,就不符合“谁决策谁担风险”的逻辑,自然进展缓慢。当然以直接融资为主的资本市场也不是万灵药,华尔街的奇迹和灾祸都不少。在我国将来的金融体系中,究竟间接和直接融资各占多大比重,国有金融企业和机构(包括政策性银行和社保基金等)在其中该扮演何种角色,都还是未知数。\n总的来看,我国债务风险的本质不是金融投机的风险,而是财政和资源分配机制的风险。这些机制不是新问题,但债务负担在这十年间迅速上升,主要是因为这一机制已经无法持续拉动GDP增长。无论是实际生产率的增长还是通货膨胀速度,都赶不上信贷或债务增长的速度,所以宏观上就造成了高投资挤压消费,部分工业产能过剩和部分地区房地产投资过剩,同时伴随着腐败和行政效率降低。这种经济增长方式无法持续。最近几年的改革力图扭转这种局面,让市场在资源配置中,尤其是在土地和资本等要素配置中起更大作用。\n结语 # 本章分析了我国债务的情况,聚焦企业和银行风险,结合前几章讨论过的政府和居民债务风险,希望能帮助读者理解债务风险的大概。债务问题不是简单的货币和金融问题,其根源在于我国经济发展的模式和结构,所以在降债务的过程中伴随着一系列深层次的结构性改革。然而导致目前债务问题的直接起因,却是2008年的全球金融危机和几年后的欧债危机。这两次危机对世界格局的影响,远超“9·11”事件。为应对巨大的外部冲击,我国迅速出台了“4万亿”计划,稳定了我国和世界经济,但同时也加剧了债务负担和产能过剩。\n产能过剩可以从三个角度去理解。第一是生产效率下降。宏观上表现为GDP增速放缓,低于债务增速,所以宏观债务负担加重。微观上表现为地方政府过度投资、不断为一些“僵尸企业”输血,扭曲了资源配置,加重了政府和企业的债务负担。而且地方政府的“土地财政”和“土地金融”模式过度依赖地价上涨和房地产繁荣,推升了房价和居民债务负担,也加大了银行风险。\n第二个角度是国际失衡。地方政府重视投资、生产和企业税收,相对忽视消费、民生和居民收入,造成经济结构失衡,分配体制偏向资本,劳动收入偏低,所以消费不足,必须向国外输出剩余产能。我国和韩国、日本等东亚邻居不同,体量巨大,所以对国际经济体系冲击巨大,贸易冲突由此而来。\n第三个角度是产业升级。因为产能过剩,我国制造业竞争激烈,价格和成本不断降低,不仅冲击了外国的中低端制造业,也冲击了本国同行。要想在国内市场上存活和保持优势,头部企业必须不断创新,进入附加值更高的环节。所以我国制造业的质量和技术含量在竞争中不断上升,在全球价值链上不断攀升,也带动了技术创新和基础科学的进步,进一步冲击了发达国家主导的国际分工体系。\n从第三章开始一直到本章结束,本书已经详细分析了第一个角度,也为理解第二和第三个角度打下了微观基础。下一章将展开讨论我国对国际经济体系的冲击,并且从国际冲突的角度出发,由外向内再度审视国内经济结构的失衡问题。\n扩展阅读 # 最近10年,从债务角度反思2008年全球金融危机的好书很多。普林斯顿大学迈恩和芝加哥大学苏非的著作《房债:为什么会出现大衰退,如何避免重蹈覆辙》(2015)是一本关于美国房地产及债务的通俗作品。对一般读者来说,该书可能关注面有些狭窄,细节也过于详尽;但对于经济学专业的学生,该书值得细读,可以学习如何从微观数据中清楚地解答重要的宏观问题。英国经济学家特纳的著作《债务和魔鬼:货币、信贷和全球金融体系重建》(2016)是针对债务问题更加全面的通俗作品,思路清楚,文笔流畅,可以结合英国央行前行长金关于银行和金融系统的杰作《金融炼金术的终结:货币、银行与全球经济的未来》(2016)一起阅读,会大有收获。经济史专家图兹的著作Crashed: How a Decade of Financial Crises Changed the World(2018)全面而细致地记录了2008年全球金融危机之后的10年间世界政治、经济格局的深刻变化,非常精彩。著名对冲基金——桥水基金的创始人达利欧(Dalio)对债务问题有多年的思考和实践,其著作《债务危机:我的应对原则》(2019)不受经济学理论框框的限制,更加简练直接。\n关于中国债务问题有很多讨论和研究,但大多是学术文献或行业报告,全面系统的普及性读物较少。南京大学-约翰斯·霍普金斯大学中美文化研究中心研究员包尔泰(Armstrong-Taylor)就中国的金融体制和债务写过一本简明通俗的书Debt and Distortion: Risks and Reforms in the Chinese Financial System(2016),是很好的入门读物。彭博的经济学家欧乐鹰(Orlik)最近出版了一本小书China:the Bubble that Never Pops(2020),标题很有趣,“中国,永不破裂的泡沫”。该书回顾了改革开放以来历次债务危机的前因后果和化解办法。作者坦言,中国经济发展史也是各种“中国崩溃论”的失败史。在别人忙着讥讽“水多加面,面多加水”的手忙脚乱时,作者问:馒头为什么越蒸越大?\n银行是金融系统的核心,也是金融危机的风暴眼。理解银行的风险需要深入了解其具体业务,这是近些年关于金融危机的研究中最有意思的部分。虽然从宏观角度分析危机也很有意思,但只有深入了解具体业务细节,才能真正对现实的复杂性和吊诡之处产生敬畏之心,避免夸夸其谈。虽然业务内容比较专业,但有两本书写的相对简明,是很好的入门读物。一本是耶鲁大学戈顿的《银行的秘密:现代金融生存启示录》(2011),另一本是英国经济学家米尔恩(Milne)的The Fall of the House of Credit: What Went Wrong in Banking and What Can be Done to Repair the Damage?(2009)。获过奥斯卡奖的电影《大空头》中也有很多金融业务细节,很精彩。本片虽然取材自真人真事,但主人公们其实不是真正从做空金融危机中赚到大钱的人,还差得远。如果想听听这次危机中“大钱”交易的故事,祖克曼的《史上最伟大的交易》(2018)是一部可以当小说看的杰作。\n关于我国银行系统的风险和改革,两位参与其中的投行经济学家沃尔特和豪伊写了一本很专业但不难理解的书,《红色资本:中国的非凡崛起和脆弱的金融基础》(2013),值得一读。高盛前总裁、美国前财政部长保尔森也参与和见证了我国金融改革,他的回忆录《与中国打交道:亲历一个新经济大国的崛起》(2016)中有很多轶事。人民银行副行长潘功胜的著作《大行蝶变:中国大型商业银行复兴之路》(2012)则从中国银行家的角度回顾和分析了大型商业银行的改革历程,也值得一读。\n11 详见哈佛大学福尔曼(Furman)和萨默斯(Summers)的论文(2020)。\n22 为了还债而低价变卖金融资产的行为,术语称为“fire sale”。读者可参考哈佛大学施莱弗(Shleifer)和芝加哥大学维什尼(Vishny)对这一现象的简明介绍(2011)。\n33 衡量美国房价的常用指标是Case-Shiller指数,2008年初大约为180,2009年跌穿150。根据南京大学包尔泰(Paul Armstrong-Taylor)著作(2016)中的数据,温州二手房的价格指数从2011年1月的120跌到2012年1月的85。\n44 爱尔兰数据来自英国经济学家特纳(Turner)的著作(2016)。\n55 这种“债务—通货紧缩—经济萧条”螺旋式下行的逻辑,被美国经济学家费雪(Fisher)称为“债务—通缩循环”,可以解释1929—1933年的世界经济大萧条。这种理论后来被日本经济学家辜朝明发展成为“资产负债表衰退理论”,用来解释日本20世纪90年代初开始的长期衰退以及美国次贷危机后的衰退(辜朝明,2016)。布朗大学埃格特松(Eggertsson)和诺贝尔奖得主克鲁格曼(Krugman)的文章(2012)系统地阐述了这一思路。\n66 普林斯顿大学迈恩和芝加哥大学苏非的著作(2015)详细介绍了美国居民的债务和消费情况。美联储旧金山分行的研究(Glick and Lansing, 2010)表明,欧美主要国家2008年之前10年的房价和居民负债高度正相关,而负债越多的国家,在危机中消费下降也越多。\n77 西班牙的例子来自普林斯顿大学迈恩和芝加哥大学苏非的著作(2015)。\n88 哈佛大学莱因哈特(Reinhart)和罗格夫(Rogoff)在其著作(2012)中统计了过去200年全球主要国家的银行危机次数,发现危机频率和国际资本流动规模高度正相关。\n99 关于银行风险,英国中央银行前行长默文·金(Mervyn King)的著作(2016)精彩而深刻。\n1010 根据英格兰银行的报告(Haldane, Brennan and Madouros,2010),2007年美国主要商业银行的杠杆率(总资产/一级核心资本)在20倍左右(如美洲银行将近21倍),投资银行则在30倍左右(如雷曼兄弟将近28倍)。而欧洲的德意志银行是52倍,瑞银是58倍。\n1111 这类资金中数额最大的一类是“回购”(repo),可以理解为一种短期抵押借款。耶鲁大学戈顿(Gorton)的著作(2011)对这项重要业务做了简明而精彩的介绍。\n1212 数据来自英国经济学家特纳的著作(2016)以及旧金山美联储霍尔达(Jordà)、德国波恩大学舒拉里克(Schularick)和戴维斯加州大学泰勒(Taylor)的合作研究(2016)。此外,三位德国经济学家的研究(Knoll, Schularick and Steger, 2017)指出,房价在20世纪70年代布雷顿森林体系解体后开始加速上涨,比人均GDP增速快得多,这可能跟金融放松管制后大量资金进入房地产市场有关。\n1313 爱尔兰的数据来自特纳的著作(2016)。银行信贷的顺周期特性也反映在利息变动中。一旦形势不好,利息也往往迅速升高。纽约大学的格特勒(Gertler)和吉尔克里斯特(Gilchrist)的论文(2018)描述了2008—2009年全球金融危机期间各种利息的变动和金融机构的行为。\n1414 数字来自英格兰银行的报告(Haldane, Simon and Madouros, 2010)。\n1515 纽约大学菲利蓬(Philippon)和弗吉尼亚大学雷谢夫(Reshef)的论文(2012)研究了美国金融部门对高学历人才的挤占。\n1616 国际清算行的研究人员分解了各大陆间的金融资本流动总量,而不是仅仅关注“净流量”(Avdjiev, McCauley and Shin, 2016)。跨大西洋金融机构间的紧密联系,有复杂的成因和后果,经济史专家图兹(Tooze)的著作(2018)对此有非常详尽和精彩的论述。\n1717 这个理论是针对布雷顿森林体系以及之前的金本位所提出的,能否直接套用到如今美国的贸易赤字问题上,尚有争议。不能据此认为,人民币想要成为国际货币,我国现在的贸易顺差就必须变成逆差。\n1818 普林斯顿大学迈恩(Mian)、哈佛大学斯特劳布(Straub)、芝加哥大学苏非(Sufi)等人的论文详细阐述了发达国家中收入不平等和债务上升之间的关联(2020a)。\n1919 收入和财富不平等的数据来自德国波恩大学库恩(Kuhn)、舒拉里克(Schularick)和施泰因(Steins)的论文(2020)。\n2020 富人的钱借给了穷人,逻辑上很好理解,但实际上并不容易证实,需要拨开金融中介的重重迷雾,搞清楚资金的来龙去脉。美国最富有的1%的人储蓄增加,对应着最穷的90%的人债务增加,是普林斯顿大学迈恩(Mian)、哈佛大学斯特劳布(Straub)、芝加哥大学苏非(Sufi)等人最近的发现(2020b)。\n2121 数据同样来自迈恩(Mian)、斯特劳布(Straub)和苏非(Sufi)的论文(2020a)。\n2222 两位英国经济学家的著作(Haskel and Westlake, 2018)指出:1990年至2014年,资本设备相对于当期产品和服务的价格下降了33%。该书把发达国家称为“没有资本的资本主义”(capitalism without capital),即更重视无形资产和人才的资本主义,并深入探讨了这种经济中的生产率停滞和收入不平等。\n2323 纽约大学菲利蓬(Philippon)写过一本精彩的小书(2019),分析美国经济越来越偏离自由市场,竞争力不断下降。\n2424 数据来自迈恩(Mian)、斯特劳布(Straub)和苏非(Sufi)的论文(2020a)。\n2525 数据来自库恩(Kuhn)、舒拉里克(Schularick)和施泰因(Steins)的论文(2020)。富人的九成资产都是股票,这一比例自1950年至今变化不大。\n2626 见易纲的文章(2019)。\n2727 关于“影子银行”,详见本章后文“银行风险”小节。\n2828 很多人认为地方政府债务不仅应包括政府的显性债务,也应包括其关联企业的债务,所以债务负担不止34%。但如果把这些隐性债务算到政府头上的话,就不能再重复算到企业头上,因此这类争议并不影响对总体债务水平的估算。\n2929 企业债务总额按图6-3中占GDP的比重推出。图中的企业不包括金融企业,这是计算宏观债务负担时的国际惯例。图中债务是指企业部门作为一个整体从外部(如银行)借债的总额,不包括企业间相互的债务债权关系(如业务往来产生的应收账款等)。我国非金融企业在境内股票融资的总额,来自中国人民银行发布的“社会融资规模存量统计表”。\n3030 这个比重与图6-3中企业债务占GDP的154%,不能直接相比。图中的企业债务是企业作为一个整体的对外债务总额,只包括贷款和债券;而此处的144%也包含了企业间的应付账款等。企业间的相互债务在计算企业总体对外债务负担时会互相抵消。但如果只计算部分企业比如国企的债务规模时,这些企业间的债务也应该算上。关于国企的宏观数据有两套口径,一套是国资委的,一套是财政部的,后一套涵盖范围更广,因为很多国企不归国资委管。本节只讨论非金融类企业,因为图6-3中的企业债务统计仅包括非金融企业。\n3131 与GDP最可比的企业数据应该是增加值,但国企整体增加值很难估算,各种估计方法都有不小缺陷。按张春霖的估计(2019),国企增加值占GDP的比重最近10年变化不大。\n3232 比如,2017年的所有国企资产中有45%被归类为“社会服务业”和“机关社团及其他”,而这两类企业的营业收入几乎可以忽略不计。财政部没有详细解释这些究竟是什么企业。再比如,按资产规模算,2017年至少24%的国企应被归类为基础设施企业,其中也包括地方政府融资平台。这类企业对经济的贡献显然不能只看自身的资产回报,还应该考虑它们对其他经济部门的带动作用和贡献,但这些贡献很难估计。\n3333 关于私营企业包括工业企业在“4万亿”计划之后的扩张和效率下降,可以参考清华大学白重恩、芝加哥大学谢长泰、香港中文大学宋铮的研究(Bai, Hsieh and Song,2016)。在房地产繁荣和房价高涨的刺激下,2007年至2015年,A股上市公司中的非房地产企业也大量购入了商业地产和住宅,总金额占其投资总额三成,这个数据来自香港浸会大学陈婷、北京大学周黎安和刘晓蕾、普林斯顿大学熊伟等人的研究(Chen et al.,2018)。\n3434 这种估计比较复杂,涉及房地产相关各行业的投入产出模型。此处的估计结果来自国家统计局许宪春等人的论文(2015)。\n3535 对上市房企成本构成的估计,来自恒大经济研究院任泽平、夏磊和熊柴的著作(2017)。\n3636 这里的75%与图6-3中企业债务占GDP的154%,不能直接相比。图中的企业债务只包括了贷款和债券,而这里的债务还包括房企的应付账款等其他债务。在我国会计制度下,预售房产生的收入,也就是在房子交接前所形成的预收房款,记为房企的负债。A股上市房企中,这部分预收款约占总负债的1/3。我国严格限制商业银行给房企贷款,所以在房企的负债中,国内银行贷款的比重一直维持在10%—15%。然而这种对资金渠道的限制很难影响资金最终流向,大量资金通过各种渠道包括“影子银行”流入了房企。\n3737 所谓“三道红线”,是要求房企在剔除预收款后的资产负债率不得高于70%、净负债率不得高于100%、现金短债比不小于1倍。\n3838 关于房企海外债的数据和监管,可参考《财新周刊》2019年第29期的文章《房企境外发债为何收紧》及2020年第37期的文章《房企降杠杆开始》。\n3939 上海高级金融学院朱宁的著作(2016)系统地阐释了这种所谓“刚性泡沫”现象。\n4040 本段中的数字根据民国著名金融家张嘉璈书中(2018)的统计数据计算得出。关于季羡林先生的故事,来自北京大学周其仁的回忆(2012)。\n4141 2019年末,易纲在《求是》发表文章,阐述了货币政策的目标和理念(2019)。\n4242 数据来源于易纲的论文(2020)。\n第七章 国内国际失衡 # 十多年前,我去过一次开曼群岛,那是当时我去过的最远的地方。我在一个岛上看到了一家中餐馆,印象很深,觉得不管哪里都有中国人在做生意。又过了两年,我在波多黎各的一家旅游纪念品商店门上看到一块告示:“本店不卖中国货”。我特地进去看了看,除了当地人的一些手工品之外,义乌货其实不少。\n早在2007年,美国就出了一本畅销书,叫《离开中国制造的一年》(A Year Without“Made in China”),讲美国一家人试着不用中国货的生活实验。书本身乏善可陈,但其中的一些情绪在美国普通百姓中颇具代表性。这些情绪在之后的十多年间慢慢发酵,民间反全球化倾向越来越明显。2018年6月,世界银行前首席经济学家巴苏(Basu)教授来复旦大学经济学院演讲,谈到中美贸易战时说:“我来自印度,过去的大半辈子,一直都是发达国家用各种手段打开发展中国家市场,要求贸易。没想到世界有一天会倒过来。”\n我国经济的崛起直接得益于全球化,但因为自身体量大,也给全球体系带来了巨大冲击。2001年加入WTO之后,我国迅速成为“世界工厂”。2010年,制造业增加值超过美国,成为全球第一。2019年,制造业增加值已占到全球的28%(图7-1)。我国出口的产品不仅数量巨大,技术含量也在不断提升。2019年出口产品中的三成可以归类为“高技术产品”,而在这类高技术产品的全球总出口中,我国约占四分之一。由于本土制造业体量巨大,全球产业链在向我国集聚,也带动了本土供应商越来越壮大。因此我国出口模式早已不是简单的“来料加工”,绝大部分出口价值均由本土创造。2005年,我国每出口100美元就有26美元是从海外进口的零部件价值,只有74美元的价值来自国内(包括在国内设厂的外资企业生产的价值)。2015年,来自海外供应链的价值从26%下降到了17%。 11 图7-1 各国制造业增加值占全球比重\n数据来源:世界银行。七国集团即英、美、日、德、法、意、加。\n这些巨大的成功背后,也隐藏着两重问题。第一是内部经济结构失衡:重生产、重投资,相对轻民生、轻消费,导致与巨大的产能相比,国内消费不足,而消化不了的产品只能对外输出。这就带来了第二个问题:国外需求的不稳定和贸易冲突。过去20年,世界制造业中我国的占比从5%上升到28%,对应的是“七国集团”占比从62%下降到37%,而所有其他国家占比几乎没有变化(图7-1)。这背后不仅是中国经济面貌翻天覆地的变化,也是发达国家经济结构的巨大变化。面对剧烈调整,出现贸易冲突甚至贸易战,一点也不奇怪。\n本章第一节分析国内经济结构的失衡问题,这与地方政府发展经济的模式直接相关,也影响了对外贸易失衡。第二节以中美贸易战为例,讨论中国经济对外国形成的冲击和反弹。在这些大背景下,2020年中央提出“推动形成以国内大循环为主体、国内国际双循环相互促进的新发展格局”。第三节分析这一格局所需要的条件和相关改革。\n第一节 低消费与产能过剩 # 我国经济结构失衡的最突出特征是消费不足。在2018年GDP中,居民最终消费占比只有44%,而美国这一比率将近70%,欧盟和日本也在55%左右。 22 从20世纪80年代到2010年,我国总消费(居民消费+政府消费)占GDP的比重从65%下降到了50%,下降了足足15个百分点,之后逐步反弹到了55%(图7-2)。居民消费占GDP的比重从80年代的54%一直下降到2010年的39%,下降了15个百分点。图中总消费和居民最终消费间的差距就是政府消费,一直比较稳定,占GDP的11%左右。\n图7-2 中国消费占GDP比重\n数据来源:《中国统计年鉴2020》。\n居民消费等于收入减去储蓄,下面这个简单的等式更加清楚地说明了这几个变量间的关系:\n所以当我们观察到消费占GDP的比重下降时,无非就是两种情况:或者GDP中可供老百姓支配的收入份额下降了,或者老百姓把更大一部分收入存了起来,储蓄率上升了。实际上这两种情况都发生了。在图7-3中可以看到,从20世纪90年代到2010年,居民可支配收入占GDP的比重从70%下降到了60%,下降了10个百分点,之后逐步反弹回65%。而居民储蓄率则从21世纪初的25%上升了10个百分点,最近几年才有所回落。这一降一升,都与地方政府推动经济发展的模式密切相关,对宏观经济影响很大。\n图7-3 居民可支配收入占GDP比重及储蓄占可支配收入比重\n数据来源:《中国统计年鉴2020》。\n居民高储蓄 # 我国居民储蓄率很高,20世纪90年代就达到了25%—30%。同期美国的储蓄率仅为6%—7%,欧洲主要国家比如德、法就是9%—10%。日本算是储蓄率高的,也不过12%—13%。国家之间储蓄率的差异,可以用文化、习惯甚至语言和潜意识来解释。可能中国人历来就是特别勤俭,舍不得花钱。前些年有一个很吸引眼球的研究,讲世界各地的语言与储蓄率之间的关系。很多语言(如英语)是有时态的,因此在讲到“过去”“现在”“未来”时,语法要改变,会让人产生一种“疏离感”,未来跟现在不是一回事,何必担心未来,活在当下就好。因此说这种语言的人储蓄率较低。很多语言(如汉语和德语)没有时态,“往日之我”“今日之我”“明日之我”绵延不断,因此人们储蓄率也较高。 33 天马行空的理论还有不少,但语言、文化、习惯等因素长期不变,解释不了我国储蓄率近些年的起起落落,所以还得从分析经济环境的变化入手。目前主流的解释是计划生育、政府民生支出不足、房价上涨三者的共同作用。 44 计划生育后,人口中的小孩占比迅速下降,工作年龄人口(14—65岁)占比上升,他们是储蓄主力,所以整体储蓄率从20世纪80年代就开始上升。孩子数量减少后,“养儿防老”的功效大打折扣,父母必须增加储蓄来养老。虽然父母会对仅有的一个孩子加大培养力度,增加相关支出尤其是教育支出,但从整体来看,孩子数量的减少还是降低了育儿支出,增加了居民储蓄。21世纪初,独生子女们开始陆续走上工作岗位,而随着城市化大潮、商品房改革和房价上涨,他们不仅要攒钱买房、结婚、培养下一代,还要开始分担多位父母甚至祖父母的养老和医疗支出,储蓄率于是再次攀升。 55 这一过程中的几个要素,都与地方政府有关。首先是房价上涨,这与地方政府以“土地财政”和“土地金融”推动城市化的模式密切相关(第二章和第五章)。在那些土地供应受限和房价上涨快的地区,居民要存钱付首付、还按揭,储蓄率自然上升,消费下降。虽然房价上涨会增加有房者的财富,理论上可能刺激消费,降低储蓄,但大多数房主只有一套房,变现能力有限,消费水平主要还是受制于收入,房价上升的“财富效应”并不明显。所以整体上看,房价上升拉低了消费,提高了储蓄。 66 其次,地方政府“重土地轻人”的发展模式将大量资源用在了基础设施建设和招商引资上,民生支出比如公立教育和卫生支出相对不足(第五章)。而且教育和医疗等领域由于体制原因,市场化供给受限,市场化服务价格偏高,所以家庭需要提高储蓄以应对相关支出。这也造成了一个比较独特的现象:我国老年人的储蓄率偏高。一般来讲,人在年轻时储蓄,年老时花钱,因此老年人储蓄率一般偏低。但我国老人的储蓄率也很高,因为要补贴儿女的住房支出和第三代的教育费用,还有自身的医疗费用等。此外,地方政府常年按照户籍人口规模来规划公共服务供给,满足不了没有户籍的常住人口的需要。这些人难以把妻儿老小接到身边安心生活,因此在耐用品消费、住房和教育消费等方面都偏低。他们提高了储蓄,把钱寄回了外地家里。这些外来人口数量庞大,也推高了整体储蓄率。 77 居民收入份额低 # 居民消费不足不仅是因为储蓄率高,能省,也是因为确实没钱。从21世纪初开始,在整个经济蛋糕的分配中,居民收入的份额就一直在下降,最多时下降了10个百分点,之后又反弹回来5个百分点(图7-3)。在经济发展过程中,这种先降后升的变化并不奇怪。在发展初期,工业化进程要求密集的资本投入,资本所得份额自然比在农业社会中高。与一把锄头一头牛的农业相比,一堆机器设备的工业更能提高劳动生产率和劳动收入水平,但劳动所得在总产出中的占比也会相对资本而下降。20世纪90年代中后期,我国工业化进程开始加速,大量农业劳动力转移到工业,因此劳动相对于资本的所得份额降低了。此外,在工业部门内部,与民营企业相比,国有企业有稳定就业和工资的任务,雇工人数更多,工资占比更大,因此90年代中后期的大规模国企改革也降低了经济中劳动收入所占的份额。 88 随着经济的发展,服务业逐渐兴起,劳动密集程度高于工业,又推动了劳动收入占比的回升。\n在这一结构转型过程中,地方政府推动工业化的方式加速了资本份额的上升和劳动份额的下降。第二至第四章介绍了地方招商引资和投融资模式,这是一个“重企业、重生产、重规模、重资产”的模式。地方政府愿意扶持“大项目”,会提供各种补贴,包括廉价土地、贷款贴息、税收优惠等,这都会刺激企业加大资本投入,相对压缩人力需求。虽然相对发达国家而言,我国工业整体上还是劳动密集型的,但相对我国庞大的劳动力规模而言,工业确实存在资本投入过度的扭曲现象。加入WTO之后,一方面,进口资本品关税下降,增加了企业的资本投入;另一方面,工业在东南沿海集聚引发大规模人口迁移,而与户籍和土地有关的政策抬高了房价和用工成本,不利于外来人口安居乐业,“用工荒”现象屡有发生,企业于是更加偏向资本投入。 99 当然,资本相对劳动价格下降后,企业是否会使用更多资本,还取决于生产过程中资本和劳动的可替代性。如今各种信息技术让机器变得越来越“聪明”,能做的事越来越多,对劳动的替代性比较高,所以机器相对劳动的价格下降后,的确挤出了劳动。 1010 举个例子,我国是世界上最大的工业机器人使用国,2016年就已占到了世界工业机器人市场的三成,一个重要原因就是用工成本上升。 1111 从收入角度看,国民经济分配中居民占比下降,政府和企业的占比就必然上升。同理,从支出角度看,居民消费占比下降,政府和企业支出占比就会上升,这些支出绝大多数用于了投资。也就是说,居民收入转移到了政府和企业手中,变成了公路和高铁等基础设施、厂房和机器设备等,而老百姓汽车和家电等消费品占比则相对降低。此外,总支出中还有一块是外国人的支出,也就是我国的出口。居民消费支出占比下降,不仅对应着投资占比上升,也对应着出口占比上升。因此在很长一段时间里,拉动我国GDP增长的主力是投资和出口,而国内消费则相对不振。\n该如何评价这种经济发展模式?首先要注意上文讲的都是相对份额,不是绝对数量。整个经济规模在急速膨胀,老百姓的收入占比虽然相对下降了,但水平在迅速上升。消费和投资水平也都在迅速上涨,只不过速度快慢有别罢了。\n从经济增长角度看,资本占比上升意味着人均资本数量增加,这是提高生产率和实现工业化的必经阶段。我国几十年内走完了西方几百年的工业化进程,必然要经历资本积累阶段。欧美和日韩也是如此。英国的“圈地运动”和马克思描述的“原始资本”积累过程,读者们想必耳熟能详。近些年兴起的“新资本主义史”,核心议题之一正是欧美资本积累过程中的“强制性”,比如欧洲列强对殖民地的压榨和美国的奴隶制等。 1212 而在“东亚奇迹”中,人民的勤奋、高储蓄、高投资和资本积累举世闻名。我国也不例外。除了人民吃苦耐劳之外,各种制度也在加快资本积累。比如计划经济时期的粮食“统购统销”、工农业产品价格“剪刀差”等,都是把剩余资源从农业向工业转移。而在城镇,为了降低企业使用资金的成本,刺激投资和工业化,银行压低了给企业的贷款利息。为了保证银行的运转和利差收入,银行给居民储蓄的利率就被压低了。这种“金融抑制”降低了居民的收入。而居民在低利率下为了攒足够的钱,也提高了储蓄率,降低了消费。 1313 若单纯从经济增长的逻辑出发,穷国底子薄,增长速度应该更快,而像美国这样的巨无霸,每年即便只增长1%—2%,从绝对数量上看也非常惊人,很不容易。假如穷国增长快而富国增长慢的话,久而久之,各国的经济发展水平应该趋同。但实际上并非如此——除了一个部门例外,那就是制造业。制造业生产率低的国家,生产率进步确实快,而制造业生产率高的国家,进步也的确慢。 1414 可见制造业的学习效应极强,是后发国家赶超的基石。久而久之,后发国家的制造业生产率就有机会与先进国家“趋同”。那为什么经济整体却没有“趋同”呢?最关键的原因,是很多国家无法组织和动员更多资源投入制造业,无法有效启动和持续推进工业化进程。\n因此,在经济发展初期,将更多资源从居民消费转为资本积累,变成基础设施和工厂,可以有效推动经济起飞和产业转型,提高生产率和收入。而且起步时百废待兴,基础设施和工业水平非常落后,绝大多数投资都有用,都有回报,关键是要加大投资,加速资本积累。而在资本市场和法律机制还不健全的情况下,以信用等级高的政府和国企来调动资源,主导基础设施和工业投资,是有效的方式。\n但当经济发展到一定阶段后,这种方式就不可持续了,会导致四个问题。第一,基础设施和工业体系已经比较完善,投资什么都有用的时代过去了,投资难度加大,因此投资决策和调配资源的体制需要改变,地方政府主导投资的局面需要改变。这方面前文已说过多次(第三章和第六章),不再赘述。第二,由于老百姓收入和消费不足,无法消化投资形成的产能,很多投资不能变成有效的收入,都浪费掉了,所以债务负担越积越重,带来了一系列风险(第六章),这种局面也必须改变。第三,劳动收入份额下降和资本收入份额上升,会扩大贫富差距。因为与劳动相比,资本掌握在少数人手中。贫富差距持续扩大会带来很多问题,社会对此的容忍度是有限的(第五章)。第四,由于消费不足和投资过剩,过剩产能必须向国外输出,而由于我国体量巨大,输出产能会加重全球贸易失衡,引发贸易冲突(见下节)。\n在这个大背景下,党的十九大报告将我国社会的主要矛盾修改为“人民日益增长的美好生活需要和不平衡不充分的发展之间的矛盾”。所谓“不平衡”,既包括城乡间和地区间不平衡以及贫富差距(第五章),也包括投资和消费等经济结构不平衡。而“不充分”的一个重要方面,就是指老百姓收入占比不高,“获得感”不够。\n针对居民收入占比过低的问题,党的十九大提出要“提高就业质量和人民收入水平”,并明确了如下原则:“破除妨碍劳动力、人才社会性流动的体制机制弊端,使人人都有通过辛勤劳动实现自身发展的机会。完善政府、工会、企业共同参与的协商协调机制,构建和谐劳动关系。坚持按劳分配原则,完善按要素分配的体制机制,促进收入分配更合理、更有序。鼓励勤劳守法致富,扩大中等收入群体,增加低收入者收入,调节过高收入,取缔非法收入。坚持在经济增长的同时实现居民收入同步增长、在劳动生产率提高的同时实现劳动报酬同步提高。拓宽居民劳动收入和财产性收入渠道。履行好政府再分配调节职能,加快推进基本公共服务均等化,缩小收入分配差距。”\n如果人们把收入中的固定比例用于消费,那要想提高消费占GDP的比重,只让居民收入增长与经济增长“同步”是不够的,必须让居民收入增长快于经济增长,居民收入份额才能提高,居民消费占GDP的比重也才能提高。2020年11月,国务院副总理刘鹤在《人民日报》发表题为“加快构建以国内大循环为主、国内国际双循环相互促进的新发展格局”的文章,其中就提到“要坚持共同富裕方向,改善收入分配格局,扩大中等收入群体,努力使居民收入增长快于经济增长”。\n要落实十九大提出的这些原则,需要很多具体改革。第二章介绍了公共支出方面的改革,要求地方政府加大民生支出。第三章介绍了官员评价体系的改革,要求地方官员重视民生支出和解决不平衡不充分的问题。第五章介绍了要素市场改革,试图提高劳动力收入,降低房价和居民债务负担,以增加消费。这里再举一例,即国有企业资本划转社保基金的改革。\n在国民收入分配中,居民收入份额的下降很大程度上对应着企业留存收入份额(即“企业储蓄”)的上升。要想增加居民收入,就要把这些企业留存资源转给居民。民营企业整体利润率比国企高,所以留存收入或“总储蓄”较多,但这些钱都用作了投资,还不够,所以“净储蓄”是负的,还要融资。而国企整体盈利和“总储蓄”比民营企业少,但“净储蓄”却是正的。“净储蓄”虽是正的,国企的平均分红率比民营企业要低。 1515 2017年,国务院提出将国有企业(中央和地方)包括金融机构的股权划归社保基金,划转比例统一为10%。2019年改革提速,要求央企在2019年完成划转,地方国企在2020年底基本完成划转。 1616 这项改革涉及数万亿元资金和盘根错节的利益,难度很大,但必须下决心完成。毕竟,在当初的社保改革中,国企退休老职工视同已经缴费,造成的社保基金收支缺口也理应由国企资产来填补。2019年底,央企1.3万亿元的划转已经完成。本章写作时的2020年初,地方国企的划转还在推进过程中。\n产能过剩、债务风险、外部失衡 # 在一个开放的世界中,内部失衡必然伴随着外部失衡。本国生产的东西若不能在本国消化,就只能对外输出。GDP由三大部分组成:消费、投资、净出口(出口减进口)。我国加入WTO之后,投资和净出口占比猛增(图7-4),消费占比自然锐减(图7-2)。这种经济结构比较脆弱,不可持续。一来外国需求受国外政治、经济变化影响很大,难以掌控;二来投资占比不可能一直保持在40%以上的高位。超出消费能力的投资会变成过剩产能,浪费严重。欧美发达国家投资占GDP的比重只有20%—23%。\n图7-4 净出口与投资占GDP比重\n数据来源:《中国统计年鉴2020》。“净出口”按支出法GDP计算。\n虽然从会计核算角度讲,投资确实可以提升当下的GDP数字,但若投资形成的资产不能提高生产率、带来更高的收入,不能成为未来更高的消费,这种投资就没有形成实质性的财富,就是浪费。假如政府借钱修了一条路,很多人都用,降低了通勤和物流成本,提高了生产率,那就是很好的投资。但若政府不断挖了修、修了再挖,或干脆把路修到人迹罕至之处,经济账就算不回来了。这些工程所带来的收入远远抵不上成本,结果就是债务越积越高。虽然修路时的GDP上升了,但实际资源是被浪费掉了。这种例子并不罕见。当下尚未将这些损失入账,但未来迟早会出现在账上。\n投资和消费失衡不是新问题。早在2005—2007年,我国家庭收入和消费占GDP的比重就已经下降到了低点(图7-2和图7-3)。当时政府已经意识到了这个问题,时任国务院总理温家宝在2007年就曾提出:“中国经济存在着巨大问题,依然是不稳定、不平衡、不协调、不可持续的结构性的问题”,比如“投资与消费者之间不协调,经济增长过多地依赖于投资和外贸出口”。 1717 但2008年全球金融危机爆发,我国出口锐减,不得已出台“4万亿”计划,加大投资力度,导致投资占GDP的比重从已然很高的40%进一步上升到47%(图7-4),虽然弥补了净出口下降造成的GDP缺口,稳定了经济增长,但也强化了结构失衡问题。2011年又逢欧债危机,所以始终没有机会切实调整经济结构。2007—2012年,消费占比、居民收入占比、居民储蓄率几乎没有变化(图7-2和图7-3)。由于国内居民收入和消费不足,国外需求也不足,所以企业投资实体产业的动力自然也就不足,导致大量投资流入了基础设施投资和房地产,带动了房价和地价飙升,提升了债务负担和风险(第三章到第六章)。直到2012年党的十八大之后,才开始逐步推行系统的“供给侧结构性改革”。\n因为我国消费占比过低,纵然极高的投资率也还是无法完全消纳所有产出,剩余必须对外出口。我国出口常年大于进口,也就意味着必然有其他国家的进口常年大于出口,其中主要是美国。由于我国体量巨大,对国际贸易的冲击也巨大,所带来的经济调整并不轻松。\n当然,国内和国际是一体两面,国内失衡会导致国际失衡,而国际失衡反过来也可以导致国内失衡。我国国内失衡,生产多消费少,必须向外输出剩余。但反过来看,美国人大手支出,高价向我国购买,我国的相应资源也会从本国消费者向出口生产企业转移,以满足外国需求,这就加剧了国内的消费和生产失衡。2001年“9·11”事件之后到全球金融危机之前,美国发动全球反恐战争,消耗了大量资源,同时国内房地产持续升温,老百姓财富升值,也加大了消费,这些需求中很大一部分都要靠从中国进口来满足。美国由此累积了巨大的对外债务,最大的债主之一就是中国,同时也加剧了我国内部的经济失衡。全球金融危机之后,中美两国都开始了艰难的调整和再平衡。我国的调整包括“供给侧结构性改革”、要素市场改革,以及提出“国内大循环为主、国际国内双循环相互促进”的发展战略,等等。在美国,这种调整伴随着政治极化、贸易保护主义兴起等现象。\n因此,贸易问题从来不是单纯的贸易问题,贸易冲突的根源也往往不在贸易本身。在一个开放的世界中,国内经济结构的重大调整,会直接影响到贸易总量。资源在居民、企业、政府间的不同分配格局,也会造成生产和投资相对消费的比重变化,进而影响经济的内外平衡。人们常说“外交是内政的延续”,从宏观角度看,对外贸易失衡也是内部结构失衡的延续。\n第二节 中美贸易冲突 # 各国内部经济结构的平衡程度,会反映到其国际收支状况中。我国国内产出没有被国内消费和投资完全消耗掉,因此出口大于进口,经常账户(可以简单理解为货物和服务进出口情况的总结)是顺差,对外净输出。美国的国内产出满足不了本国消费和投资需求,因此进口大于出口,经常账户是逆差,对外净输入。图7-5描绘了从20世纪90年代至今的国际收支失衡情况,有的国家顺差(黑线之上,大于零),有的国家逆差(黑线之下,小于零)。逻辑上,全球经常账户总差额在各国互相抵消后应该为零。但在现实统计数据中,由于运输时滞或因逃税而虚报等原因,这个差额约占全球GDP的0.3%。\n图7-5 经常账户差额占全球GDP比重\n数据来源:万得数据库。\n图7-5有两个显著的特点。第一,20世纪90年代的失衡情况不严重,约占全球GDP的0.5%以内。从21世纪初开始失衡加剧,在全球金融危机前达到顶峰,约占全球GDP的1.5%—2%。危机后,失衡情况有所缓解,下降到全球GDP的1%以内。第二,全球经常账户的逆差基本全部由美国构成,而顺差大都由中国、欧洲和中东构成。我国在加入WTO之后飞速发展,占全球顺差的份额扩大了不少,也带动了石油等大宗商品的“超级周期”,油价飞涨,中东地区顺差因此大增。金融危机后,美国消费支出降低,同时美国国内的页岩油气革命彻底改变了其天然气和石油依赖进口的局面,而转为世界上最重要的油气生产国和出口国,油气的国际价格因此大跌,既降低了美国国际收支的逆差,也降低了中东地区国际收支的顺差。2017年,中国超过加拿大,成为美国原油最大的进口国。 1818 美国可以吸纳其他国家的对外净输出,当然离不开美国的经济实力和美元的国际储备货币地位。美国每年进口都大于出口,相当于不断从国外“借入”资源,是世界最大的债务国。但这些外债几乎都以美元计价,原则上美国总可以“印美元还债”,不会违约。换句话说,只要全世界还信任美元的价值,美国就可以源源不断地用美元去换取他国实际的产品和资源,这是一种其他国家所没有的、实实在在的“挥霍的特权”(exorbitant privilege)。 1919 在美国的所有贸易逆差中,与中国的双边逆差所占比重不断加大,从21世纪头几年的四分之一上升到了最近五年的五成到六成。因此美国虽和多国都有贸易冲突,但一直视中国为最主要对手。 2020 就业与政治冲击 # 在中美贸易冲突中,美国政客和媒体最常提起的话题之一就是“中国制造抢走了美国工人的工作”。主要论据如下:20世纪90年代美国制造业就业占劳动人口的比重一直比较稳定,但在中国加入WTO之后,中国货冲击美国各地,工厂纷纷转移至海外,制造业就业占比大幅下滑。受中国货冲击越严重的地区,制造业就业下滑越多。 2121 从数据上看,似乎确实有这个现象。图7-6中两条黑线中间的部分显示:20世纪90年代,美国制造业就业占劳动人口的比重稳定在15%左右,从2001年开始加速下滑,2008年全球金融危机前下降到了11%。然而在两条黑线之外,更明显的现象是制造业就业从70年代开始就一直在下降,从26%一直下降到个位数。就算把21世纪初下滑的4个百分点全赖在和中国的贸易头上,美国学界和媒体所谓的“中国综合征”在这个大趋势里也无足轻重。此外,虽然制造业就业一直在下跌,但是从1970年到2013年,制造业创造的增加值占美国GDP的比重一直稳定在13%左右。 2222 人虽少了,但产出并没有减少,这是典型的技术进步和生产率提高的表现。机器替代了人工而已,并没什么特别之处。农业技术进步也曾让农民越来越少,但农业产出并没有降低。另一方面,从中国进口的产品价格低廉,降低了使用这些产品的部门的成本,刺激了其规模和就业扩张,其中既有制造业也有服务业。虽然确有部分工人因工厂关闭而失业,但美国整体就业情况并未因中美贸易而降低。 2323 图7-6 美国制造业就业占工作年龄人数比重\n数据来源:FRED数据库,美联储圣路易斯分行。\n注:横轴刻度为当年1月1日。\n然而在民粹主义和反全球化情绪爆发的年代,讲道理没人听。失业的原因有很多,技术进步、公司管理不善、市场需求变化等。但如今不少美国人,似乎普遍认为“全球化”才是祸根,“贸易保护”才是良方。最近的一个基于大规模网络民调的实验很能说明问题。实验人员给被试者看一则新闻,说一家美国公司做出了一些经营调整,既不说调整原因,也没说要裁员,但特朗普的支持者中就有接近两成的人建议“贸易保护”。如果调整一下这则新闻的内容,提到裁员,但明确说原因不是因为贸易冲击,而是因为经营不善或市场变化等其他因素,特朗普支持者中建议“贸易保护”的人会上升到将近三成。如果再调整一下,明确说裁员是因为贸易冲击,特朗普支持者中建议“贸易保护”的人将达到半数。而此时就算政治倾向偏中间,甚至偏克林顿的人,建议“贸易保护”的倾向也会大幅上升。这些倾向不只是说说而已,会直接影响投票结果。 2424 技术冲击 # 中国制造业崛起和中美贸易对美国的就业冲击其实不重要。相比之下,对美国的技术冲击和挑战更加实实在在,这也是中美贸易冲突和美国技术遏制可能会长期化的根本原因。虽然制造业占美国就业的比重已是个位数,但制造业依旧是科技创新之本,美国研发支出和公司专利数量的六七成均来自制造业企业。 2525 图7-7描绘了我国各项指标相对美国的变化。首先是制造业增加值。1997年,我国制造业增加值只相当于美国的0.14,但2010年就超过了美国,2018年已经相当于美国的1.76倍。其次是技术,衡量指标是国际专利的申请数量,数据来自世界知识产权组织(WIPO)的“专利合作条约”(PCT)系统。自1978年该系统运作以来,美国在2019年首次失去了世界第一的位置,被中国超越。再次是更加基础的科学,衡量指标是国际高水平论文的发表数量,即“自然指数”(Nature Index)。这项指数只包括各学科中国际公认的82本高质量学报上发表的论文,从中计算各国作者所占比例。2012年,我国的数量只相当于美国的0.24,略高于德国和日本,但2019年已经达到了美国的0.66,相当于德国的3倍,日本的4.4倍。\n图7-7 中美科技相对变化(美国各项指标设为1)\n数据来源:制造业增加值数据来自世界银行;国际专利申请数量来自世界知识产权组织;国际论文发表数量来自“自然指数”。\n这些数量指标当然不能完全代表质量。但在工业和科技领域,没有数量做基础,也就谈不上质量。此外,这些数据都是每年新增的流量,不是累积的存量。若论累积的科技家底,比如专利保有量和科研水平,中国还远远赶不上美国。这就好比一个年轻人,多年努力后年薪终于突破百万,赶上了公司高管的水平,但老资格的高管们早已年薪百万了几十年,累积的财富和家底自然要比年轻人厚实得多。但这个年薪百万的流量确实传递了一个强烈的信号:年轻人已非昔日吴下阿蒙,已经具备了挣钱的能力,势头很猛,未来可期,累积家底大约只是时间问题。如今人们对“中国制造”的产品质量的认可度远高于10年前,这个认知有个滞后的过程。对技术和科学,也是同样的道理。\n对站在科技前沿的国家来说,新技术的发明和应用一般从科学研究和实验室开始,再到技术应用和专利阶段,然后再到大规模工业量产。但对一个后起的发展中国家来说,很多时候顺序是反过来的:先从制造环节入手,边干边学,积累技术和经验,然后再慢慢根据自身需要改进技术,创造一些专利。产品销量逐步扩大、技术逐步向前沿靠拢之后,就有了更多资源投入研发,推进更基础、应用范围更广的科研项目。2010年,我国制造业增加值超过美国。又过了10年,2019年中国的国际专利申请数量超过美国。而按照目前的科学论文增长率,2025年左右中国就可能超过美国(图7-7)。\n所以对后发国家来说,工业制造是科技进步的基础。世界上没有哪个技术创新大国不是制造业大国(至少曾经是)。而从制造业环节切入全球产业链分工,也是非常正确的方式,因为制造业不仅有学习效应,还有很强的集聚效应和规模效应。最近十几年,我国制造业产业链的优势一直在自我强化,不断吸引供应链上的外国企业来中国设厂,而本国的上下游厂商也发展迅猛,产业链协同创新的效应也很强。我国出口产品中最大的一类是通信技术设备和相关电子产品(比如手机)。2005年,这类出口品中海外进口部件价值的占比高达43%,本土创造的价值只有57%。但到了2015年,来自海外的价值下降到了30%。 2626 我用苹果公司生产的iPhone来举个例子。多年前,媒体和分析家中流传一种说法:一台“中国制造”的iPhone,卖大几百美元,但中国大陆贡献的价值只不过是富士康区区二三美元的组装费。最近两年,仍然时不时还会看到有人引用这个数据,但这与事实相差太远。苹果公司每年都会公布前200家供应商名单,这些公司占了苹果公司原材料、制造和组装金额的98%。在2019年版的名单中,中国大陆和香港的企业一共有40家,其中大陆企业30家,包括多家上市公司。 2727 在A股市场上,早有所谓的“果链概念”,包括制造iPhone后盖的蓝思科技、摄像头模组的欧菲光、发声单元的歌尔股份、电池的德赛电池等上市公司。虽然很难估计在一台iPhone中,中国(含香港)产业链贡献的精确增加值,但从国内外一些“拆机报告”中估计的各种零部件价格看,中国(含香港)企业应该贡献了iPhone硬件价值的两成左右。\n从理论上说,中美贸易不一定会损害美国的科技创新。虽然一些实力较弱的企业在和中国的竞争中会丧失优势,利润减少,不得不压缩研发支出和创新活动,最终可能倒闭。但对于很多大公司来说,把制造环节搬到中国,靠近全球最大也是增长最快的市场,会多赚很多钱,再将这些利润投入位于美国的研发部门,不断创新和提升竞争优势,最终美国的整体创新能力不一定会受负面影响。 2828 但在美国政坛和媒体中,这些年保守心态占了上风,对华技术高压政策可能会持续下去。假如世界上最大的市场和最强的科创中心渐行渐远的话,对双方乃至全世界都会是很大的损失。毕竟我国在基础科研质量、科技成果转化效率等方面,还有很长的路要走,而美国要想在全球再找一个巨大的市场,也是天方夜谭。没有了市场,美国公司持续不断的高额研发支出很难持续,也就难以长久维持技术优势。同时,技术高压虽然可能让我国企业在短期内受挫,但很多相对落后的国产技术也因此获得了市场机会,可能提高市场份额和收入,进而增大研发力度,进入“市场—研发—迭代—更大市场”的良性循环,最终实现国产替代。但这一切的前提,是我国国内市场确实能继续壮大,国民消费能继续提升,能真正支撑起“国内大循环为主体”的“双循环”模式。\n第三节 再平衡与国内大循环 # 我国的经济发展很大程度得益于全球化,借助巨大的投资和出口,几十年内就成长为工业强国和世界第二大经济体。2019年,我国GDP相当于1960年全球GDP的总量(扣除物价因素后)。但过去的发展模式无法持续,经济结构内外失衡严重,而国际局势也日趋复杂,中央于是在2020年提出了“加快构建以国内大循环为主体、国内国际双循环相互促进的新发展格局”。这是一个发展战略上的转型。\n从本章的分析角度看,这一战略转型的关键是提高居民收入和消费。虽然政府目前仍然强调“供给侧结构性改革”,但所谓“供给”和“需求”,不是两件不同的事,只是看待同一件事的不同角度。比如从供给角度看是调节产能,从需求角度看就是调整投资支出;从供给角度看是产业升级,从需求角度看也就是收入水平和消费结构的升级。2020年12月的中央经济工作会议提出,“要紧紧扭住供给侧结构性改革这条主线,注重需求侧管理,打通堵点,补齐短板,贯通生产、分配、流通、消费各环节,形成需求牵引供给、供给创造需求的更高水平动态平衡”。\n要提高居民收入,就要继续推进城市化,让人口向城市尤其是大城市集聚。虽然制造业是生产率和科技进步的主要载体,但从目前的技术发展和发达国家的经验看,制造业的进一步发展吸纳不了更多就业。产业链全球化之后,标准化程度越来越高,大多数操作工序都由机器完成。比较高端的制造业,资本密集度极高,自动化车间里没有几个工人。美国制造业虽然一直很强大,但吸纳的就业越来越少(图7-6),这个过程不会逆转。所以解决就业和提高收入必须依靠服务业的大发展,而这只能发生在人口密集的城市中。不仅传统的商铺和餐馆需要人流支撑,新兴的网约车、快递、外卖等都离不开密集的人口。要继续推进城市化,必须为常住人口提供相应的公共服务,让他们在城市中安居乐业。这方面涉及的要素市场改革,包括户籍制度和土地制度的改革,第五章已经详细阐释过。\n要提高居民收入和消费,就要把更多资源从政府和企业手中转移出来,分配给居民。改革的关键是转变地方政府在经济中扮演的角色,遏制其投资冲动,降低其生产性支出,加大民生支出。这会带来四个方面的重要影响。其一,加大民生支出,能改变“重土地、轻人”的城市化模式,让城市“以人为本”,让居民安居乐业,才能降低储蓄和扩大消费。其二,加大民生支出,可以限制地方政府用于投资的生产性支出。在目前的经济发展阶段,实业投资已经变得非常复杂,以往的盲目投资所带来的浪费日趋严重,降低了居民部门可使用的实际资源。而且实业投资过程大多不可逆,所以地方政府一旦参与,就不容易退出(第三章)。即便本地企业没有竞争力,政府也可能不得不持续为其输血,挤占了资源,降低了全国统一市场的效率(第四章)。其三,推进国内大循环要求提升技术,攻克各类“卡脖子”的关键环节。而科技进步最核心的要素是“人”。因此地方政府加大教育、医疗等方面的民生支出,正是对“人力资本”的投资,长远看有利于科技进步和经济发展。其四,加大民生支出,遏制投资冲动,还可能降低地方政府对“土地财政”和“土地金融”发展模式的依赖,限制其利用土地加大杠杆,撬动信贷资源,降低对土地价格的依赖,有利于稳定房价,防止居民债务负担进一步加重而侵蚀消费(第五章)。\n要提高居民收入,还要扩宽居民的财产性收入,发展各种直接融资渠道,让更多人有机会分享经济增长的果实,这就涉及金融体系和资本市场的改革。但正如第六章所言,融资和投资是一体两面,如果投资决策的主体不改变,依然以地方政府和国企为主导,那融资体系也必然会把资源和风险向它们集中,难以实质性地推进有更广泛主体参与的直接融资体系。\n“双循环”战略在强调“再平衡”和扩大国内大市场的同时,也强调了要扩大对外开放。如果说出口创造了更多制造业就业和收入的话,那进口也可以创造更多服务业就业和收入,包括商贸、仓储、物流、运输、金融、售后服务等。随着我国生产率的提高,人民币从长期看还会继续升值,扩大进口可以增加老百姓的实际购买力,扩大消费选择,提升生活水平,也能继续增强我国市场在国际上的吸引力。\n世上从来没有抽象的、畅通无阻的市场。市场从建立到完善,其规模和效率都需要逐步提升,完善的市场本就是经济发展的结果,而不是前提。我国疆域广阔、人口众多,建立和打通全国统一的商品和要素市场,实现货物和人的互联互通,难度不亚于一次小型全球化,需要多年的建设和制度磨合。过去几十年,从铁路到互联网,我国各类基础设施发展极快,为全国统一大市场的发展打下了坚实基础,也冲击着一些旧有制度的藩篱。未来,只有继续推进各类要素的市场化改革,继续扩大开放,真正转变地方政府角色,从生产型政府转型为服务型政府,才能实现国内市场的巨大潜力,推动我国迈入中高收入国家行列。\n结语 # 本书介绍了我国地方政府推动经济发展的模式,从微观机制开始,到宏观现象结束。总结一下,这一模式有三大特点。第一个特点是城市化过程中“重土地、轻人”。第二个特点是招商引资竞争中“重规模、重扩张”。第三个特点是经济结构上“重投资、重生产、轻消费”。第五章和第六章分析了前两个特点的得失,并介绍了相关改革。本章则分析了第三个特点。其优点是能快速扩大投资和对外贸易,利用全球化的契机拉动经济快速增长,但缺点是经济结构失衡。对内,资源向企业和政府转移,居民收入和消费占比偏低,不利于经济长期发展;对外,国内无法消纳的产能向国外输出,加剧了贸易冲突。\n经济结构再平衡,从来不是一件容易的事,往往伴随着国内的痛苦调整和国际冲突。2008年全球金融危机之后,全球经济进入大调整期,而我国作为全球经济增长的火车头和第二大经济体,百年来首次成为世界经济的主角,对欧美主导的经济和技术体系造成了巨大冲击,也面临巨大反弹和调整。其实对于常年关注我国经济改革的人来说,过去的40年中没有几年是容易的,经历过几次大的挑战和危机。所以我常跟学生调侃说经济增长不是请客吃饭,是玩儿命的买卖。站在岸边只看到波澜壮阔,看不见暗潮汹涌。\n至于说落后的工业国在崛起过程中与先进国之间的种种冲突,历史上是常态。盖因落后国家的崛起,必然带有两大特征:一是对先进国的高效模仿和学习;二是结合本土实际,带有本国特色,发展路径与先进国有诸多不同之处。虽然第一个特征也常被先进国斥为“抄袭”,但第二个特征中所蕴含的不同体制以及与之伴生的不同思想和意识,先进国恐怕更难接受。 2929 未来不可知,对中国经济的观察者而言,真正重要的是培养出一种“发展”的观念。一方面,理解发展目的不等于发展过程,发达国家目前的做法不一定能解决我们发展中面临的问题;另一方面,情况在不断变化,我们过去的一些成功经验和发展模式也不可能一直有效。若不能继续改革,过去的成功经验就可能成为负担甚至陷阱。要始终坚持实事求是,坚持具体问题具体分析,抛开意识形态,不断去解决实践中所面临的问题,走一条适合自己的发展道路。下一章会展开讨论这些观点。\n扩展阅读 # 国际经济的力量深刻影响着国际关系和新闻中的天下大事,热闹而精彩。但国际经济学分析绕不开经常账户和汇率等基础知识,因此下文中的推荐阅读,可能需要些知识背景才能完全理解,但我尽量挑通俗而准确的读物,相信关心这些现象的读者能够读懂。\n国际经济现象一环扣一环,冲击和调整一波接一波。今天回看2008年全球金融危机后的10年,世界经济政治格局已经发生了深刻的变化,其背后的经济因素和逻辑,第六章曾推荐过的经济史专家图兹的杰作Crashed: How a Decade of Financial Crises Changed the World(2018)值得再次推荐。站在全球的角度再往前看,2008年的危机又是怎么来的呢?这就不得不说到另一件影响深远的大事:1997—1998年的亚洲金融危机。香港证监会原主席沈联涛的著作《十年轮回:从亚洲到全球的金融危机》(2015)阐述了1997—2008年间的全球经济金融变迁,是一本杰作。那从1997年再往前呢?回到风云变幻、自由市场思潮席卷全球的20世纪七八十年代,美联储前主席沃尔克和日本大藏省前副相行天丰雄合著的《时运变迁》(2016)也是一本杰作。他们亲历了石油危机、布雷顿森林体系解体、拉美债务危机、广场协议等一系列历史事件,思考深度和叙事细节,别人当然比不了。从更宏观的角度和更长的历史视角切入,伯克利加州大学埃森格林的杰作《资本全球化:一部国际货币体系史(原书第3版)》(2020)解释了国际货币和金融体系在过去百年间的演变,以及相关的各种政经大事。\n北京大学光华管理学院佩蒂斯的两本书从多个角度解释了国际不平衡的前因后果,通俗易懂:《大失衡:贸易、冲突和世界经济的危险前路》(2014)及Trade Wars are Class Wars: How Rising Inequality Distorts the Global Economy and Threatens International Peace(Klein and Pettis,2020)。虽然我并不认同其中的不少分析,但大多数是对“量”和“度”的分歧,我认为一些事情没有他强调的那么重要,但我很赞成他从多个角度解读国际收支失衡。2008年全球金融危机前,国际失衡程度到达顶峰,中国社会科学院余永定的文集《见证失衡:双顺差、人民币汇率和美元陷阱》(2010)正收录了他从1996年至2009年发表的各类评论和分析文章。这本书很好,但需要一定的知识储备才能看懂。与事后回顾类的文章相比,看事件发生当时的分析,情境感更强;而读者借助事后诸葛的帮助,也更能学习和领会到面对不可知的未来时,每个人思考和推理的局限性。\n日内瓦高级国际关系及发展学院鲍德温的著作《大合流:信息技术和新全球化》(2020)是一本关于全球化的好书,简明通俗。他把全球化分为三个阶段:货物的全球化、信息的全球化、人的全球化。其中对“全球价值链”的现状和发展有很多精彩的分析。全球化当然也冲击了各国的政治体系,哈佛大学罗德里克的《全球化的悖论》(2011)阐述了一个“三元悖论”:深度全球化、政策自主性、民主政治,三者之间不可兼得。其中不少论述对我很有启发。2019年获奥斯卡最佳纪录片奖的《美国工厂》,讲述了中国企业福耀玻璃在美国开工厂的故事,从中可以看到中国制造对美国的冲击,也能体会到制造业回流美国的难度。\n至于中国崛起对世界和美国的冲击,光是最近几年出版的著作都可以说是汗牛充栋了。从“中国统治世界”到“修昔底德陷阱”再到各种版本的“中国崩溃论”,各种身份的作者、各种角度的理论、各种可能的预测,眼花缭乱。这里谨推荐一本历史学家王赓武的杰作China Reconnects: Joining a Deep-rooted Past to a New World Order(Wang,2019)。王教授的人生经历是不可复制的。他是出生在海外的华裔,解放战争时在南京读书,“二战”后辗转东南亚、英国、澳大利亚等地工作居住,又在风云际会的20世纪八九十年代做了10年香港大学校长,最后回到新加坡。其一生不仅精研中国史,还在数个独特的岗位上亲历了各种政经大事。他能在2019年89岁高龄时出版这样一本小书,谈谈他的思考和观察,非常珍贵。其中见识,胜过无数东拼西凑的见闻。\n11 制造业和出口总量数据来自世界银行。出口品中来自海外的增加值占比,来自经济合作与发展组织(OECD)的TiVA(trade in value-added)数据库。\n22 此处使用“实际最终消费支出”,即考虑了各种转移支付之后的实际支出,要高于按GDP支出法直接计算的消费支出。\n33 参见洛杉矶加州大学行为经济学家陈(Chen)的论文(2013)。\n44 IMF的张龙梅等人的论文(Zhang et al., 2018)总结了解释中国储蓄率变化的各种研究。\n55 南加州大学伊莫若霍罗格鲁(Imrohoroglu)和康涅狄格大学赵开的论文(2018)及伦敦经济学院金刻羽等人的论文(Choukhmane, Coeurdacier and Jin, 2019)讨论了“养儿防老”和计划生育等因素对储蓄率的综合影响。\n66 中央财经大学陈斌开和北京大学杨汝岱的论文(2013)分析了各地土地供给和住房价格对城镇居民储蓄的影响,认为房价是储蓄上升的主要推手。西南财经大学万晓莉和严予若以及北京师范大学方芳的论文(2017)估计了房价上涨对消费影响的“财富效应”非常小,影响消费的主因还是收入。\n77 IMF的张龙梅等人(Zhang et al., 2018)对比了我国和其他国家在公共教育、医疗、养老等方面的支出差异。IMF的夏蒙(Chamon)和康奈尔大学的普拉萨德(Prasad)在一份研究中(2010)描绘了我国老年人的高储蓄率,认为城镇居民在教育和医疗上的高支出是推高储蓄率的主因。中央财经大学陈斌开、上海交通大学陆铭、同济大学钟宁桦(2010)分析了我国城市移民消费不足的问题。\n88 经济发展会导致产业结构变化,推动劳动收入份额起伏,可参考复旦大学罗长远、张军的论文(2009)与清华大学白重恩、钱震杰的论文(2009),后者也估计了国企改革的影响。\n99 上海交通大学陆铭的著作(2016)分析了这种“过度资本化”的制度成因。北京大学余淼杰和梁中华的论文(2014)指出,加入WTO后,企业引进资本品和技术的成本下降,刺激了企业用资本替换劳动。\n1010 有个经济学概念叫“资本对劳动的替代弹性”。该弹性若大于1,资本相对价格下降后,企业就会使用更多资本、更少劳动,导致收入分配中劳动的份额下降。复旦大学陈登科和陈诗一的论文(2018)指出上述替代弹性在我国工业企业中大于1。明尼苏达大学卡拉巴布尼斯(Karabarbounis)和芝加哥大学奈曼(Neiman)的论文(2014)指出,资本品价格相对下降引起的劳动份额占比下降,是个全球性的现象。\n1111 武汉大学陈虹和李丹丹,以及圣地亚哥加州大学贾瑞雪和斯坦福大学李宏斌等人的论文(Chen et al., 2019)介绍了我国工业机器人的应用情况。\n1212 哈佛大学史学家贝克特的著作(2019)是“新资本主义史”代表作之一,是一部杰作。但其中一些失实和夸大之处,也招致了经济史学家的批评,比如戴维斯加州大学奥姆斯特德(Olmstead)和密歇根大学罗德(Rhode)的精彩论文(2018)。\n1313 关于“亚洲奇迹”和“中国奇迹”这种“重积累、重投资”的模式(其实相当程度上是工业化的一般模式),有两本书作了系统、深入且生动通俗的描述和分析。一本来自史塔威尔(2014),另一本来自中国社会科学院的蔡昉、李周与北京大学的林毅夫(2014)。\n1414 哈佛大学罗德里克(Rodrik)的论文(2013)描述和分析了全球制造业生产率的“趋同”现象。\n1515 IMF的张龙梅等人(Zhang et al., 2018)估计了国企和民企的储蓄率和分红率。公司储蓄率或留存利润的上升,也是个全球性的现象,比如美国苹果公司账上的天量现金。这方面的研究很多,可参考明尼苏达大学卡拉巴布尼斯(Karabarbounis)和芝加哥大学奈曼(Neiman)近几年的论文(Chen, Karabarbounis and Neiman, 2017;Karabarbounis and Neiman, 2019)。\n1616 2017年,国务院印发《划转部分国有资本充实社保基金实施方案》。2019年,财政部、人力资源社会保障部、国资委、国家税务总局、证监会等五部门联合印发《关于全面推开划转部分国有资本充实社保基金工作的通知》。\n1717 参见十届全国人大五次会议闭幕后温家宝答中外记者问(2007年3月)。\n1818 国际石油市场的变化总是引人遐想,充斥着各种阴谋论和地缘政治分析。但这些起伏背后最重要的因素依然是市场供求。中化集团王能全的著作(2018)分析了最近几十年的石油市场起伏,事实清楚,数据翔实,是很好的参考读物。\n1919 美元特权的源起和影响,著述很多,可参考伯克利加州大学艾肯格林(Eichengreen)的通俗介绍(2019)。\n2020 美国贸易逆差和中美双边贸易差额的数据,来自美国的BEA和人口普查局(Census Bureau)。\n2121 麻省理工学院的奥托尔(Autor)等人的论文影响很大(Autor, Dorn and Hanson,2013)。\n2222 这是在调整完价格因素之后的比重,数据来自哈佛大学罗德里克(Rodrik)的论文(2016)。\n2323 从中国的进口刺激了很多部门的就业,尤其是使用中国货作为投入的部门。详细分析和证据来自乔治梅森大学王直和哥伦比亚大学魏尚进等人的研究(Wang et al., 2018)以及斯坦福大学布鲁姆(Bloom)等人的研究(2019)。\n2424 实验结果来自哈佛大学迪泰拉(Di Tella)和罗德里克(Rodrik)的研究(2020)。麻省理工学院的奥托尔(Autor)等人的论文(2020)指出,那些受贸易冲击较大的地区,投票中的政治倾向两级分化更为严重。\n2525 数据来自麻省理工学院的奥托尔及乔治亚理工学院舒翩等人的研究(Autor et al.,2019)。\n2626 数据来自经济合作与发展组织(OECD)的TiVA数据库。\n2727 公司的具体名单和简要介绍,可参考宁南山发表在其公众号的文章《从2019年苹果全球200大供应商看全球电子产业链变化》。\n2828 这方面的理论可参考哈佛大学阿吉翁(Aghion)等人的论文(2018)。\n2929 哈佛大学历史学家格申克龙(Gerschenkron)的杰作(2012)详细阐述了这两大特征所带来的冲突。\n第八章 总结:政府与经济发展 # 关于经济学家的笑话特别多,每个经济学学生都知道起码十个八个,编一本笑话集应该没问题。经济学家们也经常自嘲。有一段时间,美国经济学会年会还专门设置了脱口秀环节,供本专业人士吐槽。有个笑话是这么讲的。一个物理学家、一个化学家和一个经济学家漂流到孤岛上,饥肠辘辘。这时海面上漂来一个罐头。物理学家说:“我们可以用岩石对罐头施以动量,使其表层疲劳而断裂。”化学家说:“我们可以生火,然后把罐头加热,使它膨胀以至破裂。”经济学家则说:“假设我们有一个开罐头的起子……”\n任何理论当然都需要假设,否则说不清楚。有些假设不符合现实,但是否会削弱甚至推翻其理论,还要依据理论整体来评判。但一旦走出书斋,从理论思考走到现实应用和政策建议,就必须要符合实际,要考虑方案的可行性。所以在经济学理论研究与现实应用之间,常常存在着鸿沟。做过美联储副主席的普林斯顿大学经济学家艾伦·布林德(Alan Blinder)曾发明过一条“经济政策的墨菲定律”:在经济学家理解最透、共识最大的问题上,他们对政策的影响力最小;在经济学家理解最浅、分歧最大的问题上,他们对政策的影响力最大。\n依托市场经济的理论来研究中国经济,有个很大的好处,就是容易发现问题,觉察到各种各样的“扭曲”和“错配”。但从发现问题到提出解决方案之间,还有很长的路要走。不仅要摸清产生问题的历史和现实根源,还要深入了解各种可行方案的得失。现实世界中往往既没有皆大欢喜的改革,也没有一无是处的扭曲。得失利弊,各个不同。以假想的完善市场经济为思考和判断基准,不过是无数可能的基准之一,换一套“假想”和“标准”,思路可能完全不同。正如在本书开篇引用的哈佛大学经济史家格申克龙的话:“一套严格的概念框架无疑有助于厘清问题,但也经常让人错把问题当成答案。社会科学总渴望发现一套‘放之四海而皆准’的方法和规律,但这种心态需要成熟起来。不要低估经济现实的复杂性,也不要高估科学工具的质量。”\n经济落后的国家之所以落后,正是因为它缺乏发达国家的很多硬件或软件资源,缺乏完善的市场机制。所以在推进工业化和现代化的过程中,落后国家所采用的组织和动员资源的方式,注定与发达国家不同。落后国家能否赶超,关键在于能否找到一套适合国情的组织和动员资源的方式,持续不断地推动经济发展。所谓“使市场在资源配置中起决定性作用”,站在今天的角度向前看,是未来改革和发展的方向,但回过头往后看,市场经济今天的发展状况也是几十年来经济、政府、社会协同发展和建设的结果。毫无疑问,我国的经济发展和市场化改革是由政府强力推动的。但就算是最坚定的市场改革派,1980年的时候恐怕也想象不到今天我国市场经济的深度和广度。本书的主题就是介绍我国发展经济的一些具体做法,这显然不是一套照搬照抄欧美国家的模式。利弊得失,相信读者可以判断。\n作为一名发展经济学家,我理解市场和发展的复杂互动过程,不相信单向因果关系。有效的市场机制本身就是不断建设的结果,这一机制是否构成经济发展的前提条件,取决于发展阶段。在经济发展早期,市场机制缺失,政府在推动经济起飞和培育各项市场经济制度方面,发挥了主导作用。但随着经济的发展和市场经济体系的不断完善,政府的角色也需要继续调整。\n强调政府的作用,当然不是鼓吹计划经济。过去苏联式的计划经济有两大特征。第一是只有计划,否认市场和价格机制,也不允许其他非公有制成分存在。第二是封闭,很少参与国际贸易和全球化。如今这两个特点早已不复存在,硬谈中国为计划经济,离题万里。\n本章第一节总结和提炼本书的主题之一,即地方政府间招商引资的竞争。第二节讨论政府能力的建设和角色的转变,总结本书介绍的“生产型政府”的历史作用和局限,也解释向“服务型政府”转型的必要性。第三节总结本书的关键视角:要区分经济发展过程和发展目标。既不要高估发达国家经验的普适性,也不要高估自己过去的成功经验在未来的适用性。老话说回来,还是要坚持“实事求是”,坚持“具体问题具体分析”,在实践中不断探索和解决问题,一步一个脚印,继续推进改革。\n第一节 地区间竞争 # 经济发展的核心原则,就是优化资源配置,提高使用效率,尽量做到“人尽其才,物尽其用”。实现这一目标要依靠竞争。我国改革的起点是计划经济,政府不仅直接掌控大量资源,还能通过政策间接影响资源分配,这种状况在渐进性的市场化改革中会长期存在。所以要想提高整体经济的效率,就要将竞争机制引入政府。理论上有两种做法。第一种是以中央政府为主,按功能划分许多部委,以部委为基本单位在全国范围内调动资源。竞争主要体现在中央设定目标和规划过程中部委之间的博弈。比如在计划经济时期,中央主管工业的就有七八个部委(一机部、二机部等)。这种自上而下的“条条”式竞争模式源自苏联。第二种是以地方政府为主,在设定经济发展目标之后,放权给地方政府,让它们发挥积极性,因地制宜,在实际工作中去竞争资源。这是一种自下而上的“块块”式的竞争模式。 11 即使在计划经济时期,这两种模式也一直并存,中央集权和地方分权之间的平衡一直在变动和调整。毛泽东主席也并不信奉苏联模式,1956年在著名的《论十大关系》中他就说过:“我们的国家这样大,人口这样多,情况这样复杂,有中央和地方两个积极性,比只有一个积极性好得多。我们不能像苏联那样,把什么都集中到中央,把地方卡得死死的,一点机动权也没有。”\n改革开放以后,地方政府权力扩大,“属地管理”和“地方竞争”就构成了政府间竞争的基本模式。第一章到第四章详细介绍了这一模式。这种竞争不仅是资源的竞争,也是地方政策、营商环境、发展模式之间的竞争。“属地管理”有利于地区性的政策实验和创新,因为毕竟是地方性实验,成功了可以总结和推广经验,失败了也可以将代价和风险限制在当地,不至于影响大局。比如1980年设立第一批四个“经济特区”(深圳、珠海、汕头和厦门)时,政治阻力不小,所以才特意强调叫“经济特区”而不是“特区”,以确保只搞经济实验。当时邓小平对习仲勋说:“中央没有钱,可以给些政策,你们自己去搞,杀出一条血路来。” 22 在工业化进程中搞地方竞争,前提是大多数地区的工业基础不能相差太远,否则资源会迅速向占绝对优势的地区集聚,劣势地区很难发展起来。计划经济时期,中国的工业体系在地理分布上比较分散,为改革开放之初各地的工业发展和竞争奠定了基础。而导致这种分散分布的重要原因,是1964年开始的“三线建设”。当时国际局势紧张,为了备战,中央决定改变当时工业过于集中、资源都集中在大城市的局面,要求“一切新的建设项目应摆在三线,并按照分散、靠山、隐蔽的方针布点,不要集中在某几个城市,一线的重要工厂和重点高等院校、科研机构,要有计划地全部或部分搬迁到三线”。 33 在接下来的10年中,我国将所有工业投资中的四成投向了三线地区,即云贵川渝、宁夏、甘肃、陕南、赣西北、鄂西和湖南等地区。到了20世纪70年代末,三线地区的工业固定资产增加了4.3倍,职工人数增加了2.5倍,工业总产值增加了3.9倍。 44 “三线建设”既建设了工厂和研究机构,也建设了基础设施,在中西部省份建立了虽不发达但比较全面的工业生产体系,彻底改变了工业布局。这种分散在各地的工业知识和体系,为改革后当地乡镇企业和私营企业的发展创造了条件。乡镇企业不仅生产满足当地消费需求的轻工业品,而且借助与国企“联营”等各种方式进入了很多生产资料的制造环节,为整个工业体系配套生产,获取了更复杂的生产技术和知识。电视剧《大江大河》中,小雷家村的乡镇企业就通过与附近的国营企业合作,开办了铜厂和电缆厂等,这在当时是普遍现象。90年代中后期乡镇企业改制以后,各地区各行业中都涌现出了一大批民营工业企业,其技术基础很多都源于三线建设时期建设的国营工厂。 55 第四章曾解释过这种分散化的乡镇工业企业的另一个重要功能,即培训农民成为工人。“工业化”最核心的一环就是把农民变成工人。这不仅仅是工作的转变,也是思想观念和生活习惯的彻底转变。要让农民斩断和土地的联系,成为可靠的、守纪律的、能操作机械的工人,并不容易。不是说人多就能成为促进工业化的人口红利,一支合格的产业工人大军,在很多人口众多的落后国家,实际上非常稀缺。 66 正是因为有了在分散的工业体系和知识环境下孕育的乡镇企业,正是因为其工厂“离土不离乡”,才成了培训农民成为工人的绝佳场所。而且在销售本地工业品的过程中,农民不仅积累了商业经验,也扩大了与外界的接触。于是在20世纪90年代后期和21世纪初开始的工业加速发展中,我国才有了既熟悉工厂又愿意外出闯荡打工的大量劳动力。\n这种分散的体系,以一个全国整合的、运行良好的市场经济体系为标准来评价,是低效率的。但从发展的角度看,这个评价标准并不合适。我国疆域广阔、各地风俗文化差异很大。改革开放之初,基础设施不发达,经济落后而分散,只能走各地区独自发展再逐步整合的道路。在社会改革和变化过程中,人们需要时间调整和适应。变化速度的快慢,对身处其中的人而言,感受天差地别。一个稳定和持续的改革过程,必须为缓冲和适应留足时间和资源。若单纯从理论模型出发来认识经济效率,那么这些缓冲机制,无论是社会自发建立还是政府有意设计,都会被解读为“扭曲”或“资源错配”,因其未能实现提高效率所要求的“最优资源配置”。但这种“最优”往往不过是空中楼阁。虽然人人都知道工业比农业生产效率高得多,但要让几亿农民离开土地进入工厂,是个漫长的过程,需要几代人的磨合和冲突。激进改革多半欲速不达,以社会动乱收场。\n地方政府竞争中的关键一环,是“以经济建设为中心”来评价地方主官,并将这种评价纳入升迁考核。各地政府不仅要在市场上竞争,还要在官场上竞争。这种“官场+市场”体制,有三个特点。 77 第一,将官员晋升的政治激励和地区经济表现挂钩。虽然经济建设或GDP目标在官员升迁中的具体机制尚有争议(第三章),但无人否认经济发展是地方主官的工作重点和主要政绩。第二,以市场竞争约束官员行为。虽然地方主官和政府对企业影响极大,但企业的成败,最终还是由其在全国市场乃至全球市场中的竞争表现来决定。这些外部因素超出了当地政府的掌控范围。因此,要想在竞争中取胜,地方政府的决策和资源调配,也要考虑市场竞争,考虑效益和成本。此外,资本、技术、人才等生产要素可以在地区之间流动(虽然仍有障碍),如果地方政府恣意妄为,破坏营商环境,资源就可能流出,导致地方经济衰败。第三,当地的经济表现能为地方官员和政府工作提供及时的反馈。一方面,在“属地管理”体制中,更熟悉地方环境的当地政府在处理当地信息和反馈时,比上级政府或中央政府更有优势(第一章)。另一方面,当地发展经济的经验和教训也会随着地方官员的升迁而产生超越本地的影响。由于常年以经济建设作为政府主要工作目标,各级政府的主官在经济工作方面都积累了相当的经验。中央的主要领导绝大多数也都曾做过多地的主官,也有丰富的经济工作经验。这对一个政府掌控大量资源调配的经济体系而言,不无益处。\n“官场+市场”的竞争体制,可以帮助理解我国经济的整体增长,但这种体制的运行效果,各地差异很大。官员或政府间的竞争,毕竟不是市场竞争,核心差别有三。第一,缺乏真正的淘汰机制。地方政府就算不思进取,也不会像企业一样倒闭。政绩不佳的官员虽然晋升机会可能较少,但只要不违法乱纪,并不会因投资失败或经济低迷而承担个人损失。第二,绝大多数市场竞争是“正和博弈”,有合作共赢、共同做大蛋糕的可能。而官员升迁则是“零和博弈”,晋升位置有限,甲上去了,乙就上不去。所以在地区经济竞争中会产生地方保护主义,甚至出现“以邻为壑”的恶性竞争现象。第三,市场和公司间的竞争一般是长期竞争,延续性很强。但地方官员任期有限,必须在任期内干出政绩,且新官往往不理旧账,因此会刺激大干快上的投资冲动,拉动地区GDP数字快速上涨,不惜忽视长期风险和债务负担。\n这三大差别增加了地区间竞争所产生的代价,也可能滋生腐败(第三章)。此外,政府不是企业,不能以经济效益为单一目标,还要承担多重民生和社会服务职能。在工业化和城市化发展初期,经济增长是地方政府最重要的目标,与企业目标大体一致,可以共同推进经济发展。但在目前的发展阶段,政府需要承担起更加多元的职能,将更多资源投入教育、医疗、社会保障等民生领域,改变与市场和企业的互动方式,由“生产型政府”向“服务型政府”转型。\n第二节 政府的发展与转型 # 社会发展是个整体,不仅包括企业和市场的发展,也包括政府的发展,相辅相成。国家越富裕,政府在国民经济中所占的比重也往往越大,而不是越小,这一现象也被称为“瓦格纳法则”。因为随着国家越来越富裕,民众对政府服务的需求会越来越多,政府在公立教育、医疗、退休金、失业保险等方面的支出都会随之增加。而随着全球化的深入,各种外来冲击也大,所以政府要加强各种“保险”功能。 88 另一方面,当今很多贫穷落后国家的共同点之一就是政府太弱小,可能连社会治安都维持不了,更无法为经济发展创造稳定环境。经济富裕、社会安定、政府得力是国家繁荣的三大支柱,缺一不可。 99 就拿法治能力来说,虽然经济理论和所谓“华盛顿共识”都将产权保护视作发展市场经济的前提,但在现实中,保护产权的能力只能在经济和政府发展的过程中逐步提升。换句话说,对发达国家而言,保护好产权是经济进一步发展的前提;但对发展中国家而言,有效的产权保护更可能是发展的结果。把产权保护写成法律条文很容易,但假如社会上偷盗猖獗,政府抓捕和审判的能力都很弱,法条不过是一纸空文。再比如,处理商业纠纷需要大量专业的律师和法官,需要能追查或冻结财产的专业金融人士和基础设施,否则既难以审判,更难以执行。但这些软件和硬件资源都需要长期的投入和积累。第四章中讲过,复杂的产品和产业链涉及诸多交易主体和复杂商业关系,投资和交易金额往往巨大,所以对合同制订和执行的法制环境以及更广义的营商环境都有很高要求。2000年至2018年,我国出口商品的复杂程度从世界第39位上升到了第18位 1010 ,背后是我国营商环境的逐步改善。正如前述按照世界银行公布的“营商环境便利度”排名,我国已从2010年的世界第89位上升至2020年的第31位。\n对发展中国家而言,市场和政府的关系,不是简单的一进一退的问题,而是政府能否为市场运行打造出一个基本框架和空间的问题。这需要投入很多资源,一步一步建设。如果政府不去做这些事,市场经济和所谓“企业家精神”,不会像变戏法一样自动出现。\n在任何国家,正式法律体系之外还存在大量政府管制。“打官司”毕竟是一件费时费钱的事儿,不仅诉讼成本高昂,败诉方还可以不断上诉,可能旷日持久。不仅如此,修订法律也不是小事,需要很长时间。相比之下,政府的管制和规定有时更加灵活有效,可以作为法制的补充。比如19世纪末的美国,工业化和铁路建设突飞猛进,但也发生了大量工伤事故,死亡率高,官司不断。但败诉公司有权有势,不断上诉,最终约四成的案子干脆没有赔偿。就算有赔付,数额也不大,平均不超过8个月的工资。这种不公正刺激了政府管制的兴起。在事故造成伤害之前,在打官司之前,就可以依据政府管制和规定来进行各种安全检查,防范风险。 1111 有效的政府管制同样需要政府有足够的能力和资源。随着经济和社会的发展,管制和法制之间的相对重要性也会不断发展变化。一方面,全社会投入法治建设的资源不断增加,法治的基础设施不断完善,效率不断提高。另一方面,民众和公司也变得更加富有,可以承担更高的诉讼成本,对法治的有效需求也会增加。因此法制相对于管制会变得更重要。这是经济和政治整体发展的结果,不可能一蹴而就。\n从国防到社会治安,从基础设施到基本社会保障,都要花钱,所以有效的政府必须要有足够的收入。可收税从来都不容易,征税和稽查能力也需要长期建设,不断完善。就拿征收个人所得税来说,政府要有能力追踪每个人的各种收入,能核实可以抵扣的支出,能追查和惩处偷税漏税行为。这需要强大的信息追踪和处理能力。即便在以个人所得税为最主要税种的欧美发达国家,足额收税也是个难题。富人会利用各种手段避税。比如在2016年和2017年,身为富豪并入主白宫的特朗普,连续两年都只缴了750美元的联邦所得税。2018年特朗普税收改革之后,美国最富有的400个亿万富翁实际缴纳的所得税率只有约20%,甚至低于收入排在50%以后的美国人。就拿扎克伯格来说,坐拥脸书公司的两成股份,2018年脸书的利润是200亿美元,那扎氏的收入是否就是40亿美元呢?不是的。因为脸书不分红,只要扎氏不卖股权,他的“收入”几乎是零。公司还将利润大都转到了“避税天堂”开曼群岛,再加上种种财务运作,也避掉了很多公司所得税。 1212 正因为个人所得税不易征收,所以发展中国家的税制大都与发达国家不同。我国第一大税种是增值税,2019年占全国税入的40%;第二大是公司所得税,占24%。相比之下,个人所得税只占不到7%。与个人所得税相比,增值税的征收难度要小很多。一来有发票作为凭证,二来买家和卖家利益不一致,可以互相监督。理论上,卖家希望开票金额少一点甚至不开票,可以少缴税;而买家希望开票金额越大越好,可以多抵税。因此两套票据可以互相比对,降低造假风险。但在现实中,国人对虚开发票和假发票都不陌生。尤其是20世纪末和21世纪初,违规发票泛滥。2001年初,在全部参与稽核的进项发票中,涉嫌违规的发票比例高达8.5%。 1313 随着2003年“金税工程二期”的建设完成,增值税发票的防伪、认证、稽核、协查等系统全面电子化,才逐渐消除了假发票问题,之后的增值税收入大幅增长。 1414 目前,“金税工程三期”也已完成。2020年在手机上用“个人所得税App”进行过“综合所得年度汇算清缴”的读者,应该记得其中信息的详细和准确程度,也就不难理解这种“征税能力”需要长期建设。\n从以上例子可以看出,无论是政府服务的质量,还是政府收入的数量,都在不断发展和变化。“有为政府”和“有效市场”一样,都不是天然就存在的,需要不断建设和完善。市场经济的形式和表现,要受到政府资源和能力的制约,而政府的作用和角色,也需要不断变化,以适应不同发展阶段的不同要求。\n在经济发展早期,市场不完善甚至缺失,政府能力于是成了市场能力的补充或替代。经济落后的国家之所以落后,正是因为它缺乏先进国家完善的市场和高效的资源配置方式。这些本就是经济发展所需要达到的目标,而很难说是经济发展的前提。对落后国家而言,经济发展的关键在于能否在市场机制不完善的情况下,找到其他可行的动员和调配资源的方式,推动经济增长,在增长过程中获得更多资源和时间去建设和完善市场经济。比如说,发达国家有完善的资本市场和法律体系,可以把民间积累的大量财富引导到相对可靠的企业家手中,创造出更多财富。而在改革开放之初,我国资本市场和法律体系远远谈不上健全,民间财富也极为有限,社会风气也不信任甚至鄙视民营企业和个体户。这些条件都限制了当时推动经济发展的可行方式。\n因此落后国家在推进工业化和现代化的过程中,所采用的组织和动员资源的方式,必定与先进国家不同。所谓“举国体制”也好,“集中力量办大事”也罢,在很多方面并不是中国特色。今日的很多发达国家在历史上也曾是落后国家,大多也经历过政府主导资源调配的阶段。但各国由于历史、社会、政治情况不同,政府调配资源的方式、与市场互动和协调的方式也都不同。本书阐述的“地方分权竞争+中央协调”或“官场+市场”的模式,属于中国特色。\n当然,并不是所有的政府干预都能成功。以工业化进程中对“幼稚产业”的贸易保护为例。有的国家比如韩国,在抬高关税、保护本国工业企业的同时,积极提倡出口,以国际市场竞争来约束本国企业,迫使其提高效率,并且随着工业发展逐步降低乃至取消保护,最终培育出一批世界级的企业。但也有很多国家,比如拉美和东南亚的一些国家,对“幼稚产业”的保护难以“断奶”,形成了寻租的利益集团和低效的垄断,拖累了经济发展。在更加复杂的大国比如中国,两种状况都存在。既有在国际竞争中脱颖而出的杰出企业,也有各种骗补和寻租的低效企业。这种结果上的差异,源于各国和各地政商关系的差异。所谓强力政府,不仅在于它有能力和资源支持企业发展,也在于它有能力拒绝对企业提供帮助。 1515 经济发展,需要不断动员土地、劳动、资本等资源并将其投入生产,满足社会需要。计划经济体制下可以动员资源,但难以满足社会需要,无法形成供需良性互动的循环,生产率水平也很低。因此我国的市场化改革始于满足社会需要。1981年党的十一届六中全会提出“我国所要解决的主要矛盾”,就是“人民日益增长的物质文化需要同落后的社会生产之间的矛盾”。在改革过程中,由于各种市场都不完善,法制也不健全,私人部门很难克服各种协调困难和不确定性,政府和国企于是主导投资,深度介入了工业化和城市化的进程。这一模式的成就有目共睹,也推动了市场机制的建立和完善。\n但这种模式不能一成不变,过去的成功经验不见得能适应当下和未来的需要。所谓“政府能力”,不仅包括获取资源的能力,也包括政府随着经济发展而不断调整自身角色和作用方式的能力。当经济发展到一定阶段后,市场机制已经相对成熟,法治的基础设施也已经建立,民间的各种市场主体已经积累了大量资源,市场经济的观念也已经深入人心,此时若仍将资源继续向政府和国企集中,效率就会大打折扣。投资、融资、生产都需要更加分散化的决策。市场化改革要想更进一步,“生产型政府”就需要逐步向“服务型政府”转型。\n第七章讲过,要调整经济结构失衡,关键是将更多资源从政府和国企转到居民手中,在降低政府投资支出的同时加大其民生支出。经济发达国家,政府支出占GDP的比重往往也高,其中大部分是保障民生的支出。就拿经济合作与发展组织国家来说,在教育、医疗、社会保障、养老方面的政府平均支出占到GDP的24%,而我国只有13%。 1616 一方面,随着国家变富裕,民众对这类服务的需求会增加;另一方面,市场经济内在的不稳定和波动会产生失业和贫富差距等问题,需要政府和社会的力量去做缓冲。就拿贫富差距扩大来说,政府的再分配政策不仅包括对富人多征税,还包括为穷人多花钱,把支出真正花在民生上。\n城市化是一个不可逆的过程,目前的土地和户籍改革都承认了这种不可逆性。在发展过程中遭遇冲击,回到乡村可能是权宜之计,但不是真正有效的长期缓冲机制。还是要在城市中建立缓冲机制,加大教育、医疗、住房等支出,让人在城市中安居乐业。\n加大民生支出,也是顺应经济发展阶段的要求。随着工业升级和技术进步,工业会越来越多地使用机器,创造就业的能力会减弱,这个过程很难逆转。所以大多数就业都要依靠服务业的发展,而后者离不开城市化和人口密度。 1717 如果服务业占比越来越高,“生产投资型政府”就要向“服务型政府”转型,原因有二。其一,与重规模、标准化的工业生产相比,服务业规模通常较小,且更加灵活多变,要满足各种非标准化、本地化的需求。在这种行业中,政府“集中力量办大事”的投资和决策机制,没有多大优势。其二,“投资型”和“服务型”的区别并非泾渭分明。“服务型”政府实质上就是投资于“人”的政府。服务业(包括科技创新)的核心是人力资本,政府加大教育、医疗等民生支出,也就是在加大“人力资本”投资。但因为服务业更加灵活和市场化,政府在这个领域的投入是间接的、辅助性的,要投资和培育更一般化的人力资本,而非直接主导具体的项目。\n扩大民生支出的瓶颈是地方政府的收入。第一章分析了事权划分的逻辑,这些逻辑决定了民生支出的主力必然是地方政府而不是中央政府。2019年,政府在教育、医疗、社会保障的总支出中,地方占96%,中央只占4%。中央通过转移支付机制,有效地推动了地区间基本公共服务支出的均等化(第二章),但这并没有改变地方民生支出主要依靠地方政府的事实。在分税制改革、公司所得税改革、营改增改革之后(第二章),中国目前缺乏属于地方的主体税种。以往依托税收之外的“土地财政”和“土地金融”模式已经无法再持续下去,因此要想扩大民生支出,可能需要改革税制,将税入向地方倾斜。目前讨论的热点方向是开征房产税。虽然这肯定是个地方税种,但改革牵一发动全身,已经热议了多年,也做了试点,但仍未实质推进。\n第三节 发展目标与发展过程 # 主流的新古典经济学是一套研究市场和价格机制运行的理论。在很多核心议题上,这套理论并不考虑“国别”,抽象掉了政治、社会、历史等重要因素。但对于发展中国家而言,核心议题并不是良好的市场机制如何运行,而是如何逐步建立和完善市场经济体制。因此,发展中国家所采用的资源动员和配置方式,肯定与发达国家不同。诸多发展中国家所采用的具体方式和路径,当然也各不相同。\n经济发展的核心是提高生产率。对处于技术前沿的发达国家来说,提高生产率的关键是不断探索和创新。其相对完善的市场经济是一套分散化的决策体系,其中的竞争和价格机制有利于不断“试错”和筛选胜者。但对发展中国家来说,提高生产率的关键不是探索未知和创新,而是学习已知的技术和管理模式,将更多资源尽快组织和投入到学习过程中,以提高学习效率。这种“组织学习模式”与“探索创新模式”所需要的资源配置方式,并不一样。我国的经济学者早在20年前就已经讨论过这两种模式的不同。问题的核心在于:后进国家虽然有模仿和学习先进国家技术的“后发优势”,但其“组织学习模式”不可能一直持续下去。当技术和生产率提高到一定水平之后,旧有的模式若不能成功转型为“探索创新模式”,就可能会阻碍经济进一步发展,“后发优势”可能变成“后发劣势”。 1818 本书一直强调发展过程与发展目标不同。照搬发达国家的经验,解决不了我们发展中所面临的很多问题。但我们自己走过的路和过去的成功经验,也不一定就适用于未来,所以本书不仅介绍了过往模式的成就,也花了大量篇幅来介绍隐忧和改革。我个人相信,如果“组织学习模式”不止一种,“探索创新模式”自然也不止一种,欧美模式不一定就是最优的模式。\n不仅发展中国家和发达国家不同,发展中国家各自的发展模式也不同。 1919 从宏观角度看,很多成功的发展中国家有诸多相似之处,比如资本积累的方式、出口导向的发展战略、产业政策和汇率操控、金融抑制等。但在不同国家,贯彻和执行这些战略或政策的具体方式并不相同。行之有效的发展战略和政策,必须符合本国国情,受本国特殊历史和社会条件的制约。哪个国家也不是一张白纸,可以随便画美丽的图画。什么可以做,什么不可以做,每个国家不一样。本书阐述的我国政治经济体制,有三大必要组件:掌握大量资源并可以自主行动的地方政府,协调和控制能力强的中央政府,以及人力资本雄厚和组织完善的官僚体系。这三大“制度禀赋”源自我国特殊的历史,不是每个国家都有的。\n不仅国与国之间国情和发展路径有别,在中国这样一个大国内部,各个省的发展方式和路径也不尽相同。第一章开篇就提到,若单独计算经济体量,广东、浙江、江苏、山东、河南都是世界前20的经济体,都相当于一个中等欧洲国家的规模。如果这些欧洲国家的经济发展故事可以写很多本书和论文,我国各省独特的发展路径当然也值得单独研究和记录。 2020 可惜目前的经济学术潮流是追求“放之四海而皆准”的理论,国别和案例研究式微,被称为“轶事证据”(anecdotal evidence),听起来就很不“科学”,低人一等。我对这种风气不以为然。虽然我从抽象和一般化的发展经济学理论中学到了很多,但对具体的做法和模式更感兴趣,所以本书介绍了很多具体案例和政策。\n各国的政治和社会现实,决定了可行的经济发展政策的边界。就拿工业化和城市化来说,无疑是经济发展的关键。从表面看,这是个工业生产技术和基础设施建设的问题,各国看起来都差不多。但看深一层,这是个农民转变为工人和市民的问题,这个演变过程,各国差别就大了。在我国,可行的政策空间和演变路径受三大制度约束:农村集体所有制、城市土地公有制、户籍制度。所以中国的工业化才离不开乡镇企业的发展,城市化才离不开“土地财政”和“土地金融”。这些特殊的路径,我认为才是研究经济发展历程中最有意思的东西。\n可行的政策不仅受既有制度的约束,也受既有利益的约束。政策方案的设计,必须考虑到利益相关人和权力持有者的利益。既要提高经济效率,也要保证做决策的人或权力主体的利益不受巨大损害,否则政策就难以推行。 2121 可行的经济政策是各种利益妥协的结果,背后是各国特殊的政治体制和议程。在这个过程中,不仅激励相容的机制重要,文化的制约也重要。比如政治经济学中有个重要概念叫“精英俘获”(elite capture),一个例子就是地方政治精英被地方利益集团俘获,损害民众利益。在我国历史上,这一“山高皇帝远”的问题就长期存在,应对之道不仅有各类制度建设,也从来没离开过对官僚群体统一的意识形态和道德教化(第一章)。\n另一个例子是自由贸易和保护主义的冲突。支持自由贸易的概念和理论,几乎算是经济学中最强有力的逻辑,但往往也突破不了现实利益的枷锁。只要学过经济学,都知道比较优势和自由贸易能让国家整体得益。但整体得益不等于让每个人都得益。从理论上讲,即便有人受损,也该支持自由贸易,因为整体得益远大于部分损失,只要从受益方那里拿一点利益出来,就足够补偿受损方且有余。但在现实中,补偿多少?怎么补偿?往往涉及复杂的政治博弈。补偿可能迟迟落实不到位,最终是受益者得益越来越多,而受损者却屡遭打击。虽说平均值是变好了,但那些受损的人的生活不是理论上的平均数字,他们会为了自己的利益而反抗和行动,这是保护主义的根源。 2222 最后,与主要研究成熟市场的新古典经济学相比,研究发展过程的经济学还包括两大特殊议题,一是发展顺序,二是发展节奏。在现实中,这两个问题常常重合。但对研究者而言,第一个问题的重点是“结构”,第二个问题的重点是“稳定”或“渐进性”。\n改革方向和改革过程是两回事。就算每个人都对改革方向和目的有共识(事实上不可能),但对改革路径和步骤也会有分歧。什么事先办,什么事后办,不容易决定。每一步都有人受益、有人受损,拼命争取和拼命抵制的都大有人在。就算能看清对岸的风景,也不见得就能摸着石头成功过河,绊脚石或深坑比比皆是。20世纪中叶,“二战”刚刚结束,出现了大批新兴国家,推动了发展经济学的兴起。当时研究的重点就是发展顺序或结构转型问题。后来这一研究范式逐渐式微。最近10年,北京大学林毅夫教授领衔的研究中心开始重新重视结构转型问题,其理论称为“新结构经济学”,依托“比较优势”的基本逻辑来解释发展次序和结构转型,也称为“第三代发展经济学”。这一思路目前尚有很多争议,但无疑是非常重要的探索方向。 2323 经济发展必然要改变旧有的生活方式,重新分配利益,所以必然伴随着矛盾和冲突。政府的关键作用之一,就是调控改变速度的快慢。社会变化过程快慢之间,对身处其中的人而言,感受天差地别。对于环境的变化,人们需要时间去适应。人不是机器部件,不可能瞬间调整,也没有人能一直紧跟时代,所以稳定的改革过程要留下足够的时间和资源去缓冲。这种“渐进性改革”中的各种缓冲机制,往往会拖低效率,所以常常被解读为“扭曲”和“资源错配”。但任何成功的转型过程都离不开缓冲机制。\n经济发展是个连续的过程。当下最重要的问题不是我国的GDP总量哪年能超过美国,而是探讨我国是否具备了下一步发展的基础和条件:产业升级和科技进步还能继续齐头并进吗?还有几亿的农民能继续城市(镇)化吗?贫富差距能控制在社会可承受的范围内吗?在现有的基础上,下一步改革的重点和具体政策是什么?因此本书在每个重要议题之后,都尽量介绍了当下正在实施的政策和改革,以便读者了解政策制定者对现实的把握和施政思路。有经济史学家在研究美国崛起的过程时曾言:“在成功的经济体中,经济政策一定是务实的,不是意识形态化的。是具体的,不是抽象的。” 2424 结语 # 经济学是对经济现象的解读。现象复杂多变,偶然因素非常重要,过往并非必然,未来也不能确定。但经济学研究依然是有意义的。它能从过往事件的来龙去脉中提取一些因素,思考这些因素的不同组合,形成对事件的多种解读,给人启发。但什么是相关因素?怎么组合?又如何解读?这些都与所研究事件的所在环境密不可分。任何合格的理论当然都能自圆其说,但应用理论要跳出理论本身,才能审视其适用性和实用性,这种应用因时、因地、因人而异。\n对相关因素的提取和组合,本质上是对“何谓重要”这一问题的反复考量,其判断标准只能在比较中产生。这一“比较”的视野,要在空间和时间两个维度展开,既包括跨地区、跨国家的比较,也包括跨时期的比较。研究者不仅要深入了解本国现状和历史,也要了解所比较国家的现状和历史。比较数据和表面现象容易,但要比较数据产生的过程和现象发生的机制就难了,而这些往往更加有用。发展经济学的核心就是理解发展过程,因此必须理解初始条件和路径依赖,对“历史”的延续性和强大力量心存敬畏,对简单套用外来理论心存疑虑。\n无论如何,经济学的主要作用仍是发现和提出问题,而解决问题的具体方案只能在实践中摸索和产生。学术的这一“提问”作用不应被夸大,也不应被贬低。世事复杂,逻辑和理论之外的不可控因素太多,所以具体问题的解决方案,只能在实践中不断权衡、取舍、调整、改进。但发现和提出好的问题,是解决问题的第一步,且“提问”本身,往往已蕴含了对解决思路的探索。切中要害的问题,必然基于对现实情况的深刻理解。因此,无论是理论家还是实践者,“实事求是”和“具体问题具体分析”都是不会过时的精神。\n扩展阅读 # 培养“比较”视野需要大量阅读,这也是本书设立“扩展阅读”部分的初衷。我个人偏爱经济史,所以把最后这部分留给经济史。这个领域的大作很多,以下三本入门读物的共同点是简明通俗,篇幅虽不长,但介绍了很多重要现象,提出了不少重要问题:英国史学家艾伦的《全球经济史》(2015),乔治梅森大学戈德斯通的《为什么是欧洲?世界史视角下的西方崛起》(2010),哈佛大学弗里登的《20世纪全球资本主义的兴衰》(2017)。希望这些书能激发读者兴趣,之后去做深入了解。我个人也经常翻阅卡尔·波兰尼、亚历山大·格申克龙、艾瑞克·霍布斯鲍姆、乔尔·莫基尔等人的杰作,大都有中译本。都是些老书,常读常新。熟悉这些著作的读者应该能在本书的很多地方看到《经济落后的历史透视》(格申克龙,2012)和《大转型:我们时代的政治与经济起源》(波兰尼,2020)的影子。\n国内的经济学学生很了解美国的经济学理论,但不太了解美国经济发展的历史过程。我推荐两种读物。第一本是西北大学戈登的《美国增长的起落》(2018)。经济发展和科技进步会给生活带来翻天覆地的变化,本书从很长的时间线上对此做了生动细致的描述和分析,是本大部头,细节丰富,读者的印象和感受会很深。另一本是伯克利加州大学科恩(Cohen)和德隆(DeLong)合著的Concrete Economics:the Hamilton Approach to Economic Growth and Policy(2016),这本书着重强调政府在美国经济发展中的作用。该实行产业政策就实行产业政策、该保护贸易就保护贸易、该操控汇率就操控汇率,坚持务实精神,具体问题具体分析,才有美国的今天。借回顾历史之机,作者们批评了20世纪80年代之后席卷美国和全球的自由市场思潮。\n在写作本章的过程中,在东亚研究领域负有盛名的哈佛大学教授傅高义辞世。他的杰作《邓小平时代》清晰易懂,细致流畅,影响很大。改革开放是个伟大的时代,这本书记录了这个伟大开端,放在这里推荐,再合适不过。\n11 第一种竞争模式被称为“U型”(unitary),第二种被称为“M型”(multidivision),都是公司治理中常用的结构模式。“U型”公司按功能划分部门,比如生产、销售、采购等。而“M型”公司则分成几个子品牌或事业部,各成系统,彼此独立性很强。哈佛大学诺贝尔奖得主马斯金(Maskin)、清华大学钱颖一、香港大学许成钢的论文(Maskin, Qian and Xu, 2000)将这种公司治理结构的理论用于研究我国中央和地方政府关系。\n22 经济特区的故事详见傅高义的杰作(2013)。香港大学许成钢的论文(Xu, 2011)解释了地区竞争有利于地方性的政策创新和实验。\n33 1964年8月19日,李富春、罗瑞卿、薄一波向毛泽东、党中央提交的报告。\n44 见薄一波的著作(2008)以及华中师范大学严鹏的著作(2018)。\n55 宾夕法尼亚州立大学樊静霆和密歇根州立大学邹奔的论文(Fan and Zou, 2019)分析了“三线建设”对当地工业企业尤其是民营企业长期发展的积极影响。\n66 哈佛大学历史学家格申克龙在著作(2012)中指出,很多落后国家虽人口众多,却极度缺乏合格的产业工人,“创造一支名副其实的产业工人大军,是最困难和耗时的过程”。\n77 北京大学周黎安的论文(2018)详细阐述了“官场+市场”机制及其优缺点。下文内容取材于该文。\n88 哈佛大学罗德里克(Rodrik)的论文(1998)探讨了全球化与“大政府”之间的正向关系。\n99 伦敦政治经济学院贝斯利(Besley)和斯德哥尔摩大学佩尔松(Persson)的著作(2011)详细阐述了这三大支柱的理论联系,下文中关于税收能力和法制能力的内容受该书启发。\n1010 产品复杂度的度量来自哈佛大学国际发展中心的“The Atlas of Economic Complexity”项目。\n1111 关于美国政府管制的兴起和现状,以及与法制之间关系的研究,参见哈佛大学格莱泽(Glaser)和施莱弗(Shleifer)的论文(2003),以及芝加哥大学莫里根(Mulligan)和哈佛大学施莱弗(Shleifer)的论文(2005)。\n1212 美国富人税率数据和扎克伯格的例子,来自伯克利加州大学塞兹(Saez)和祖克曼(Zucman)的著作(2019)。\n1313 数据来自2002年国家税务总局局长金人庆在全国税务系统信息化建设工作会议上的讲话《统一思想 做好准备 大力推进税收信息化建设》。\n1414 关于“金税工程二期”对增值税收入影响的估计,来自复旦大学樊海潮、刘宇及美国西北大学钱楠筠等人的论文(Fan et al., 2020)。\n1515 伯克利加州大学巴尔丹(Bardhan)的论文(2016)总结和讨论了各国保护政策和产业政策的得失成败。他特别强调了“幼稚产业”保护承诺的“时间不一致”问题,也就是起初设计好了将来要“断奶”的保护,最终却迟迟无法“断奶”的问题。\n1616 数据来自IMF的张龙梅等人的论文(Zhang et al., 2018)。\n1717 服务业发展离不开人口密度,主要原因在于大多数服务(比如餐馆或理发店)都不能跨地区贸易,需要面对面交易。上海交通大学钟粤俊和陆铭以及复旦大学奚锡灿的论文(2020)分析了我国各地区人口密度和服务业发展之间的正相关关系。\n1818 关于“后发优势”和“后发劣势”的讨论,详见哥伦比亚萨克斯(Sachs)、戴维斯加州大学胡永泰、莫纳什大学杨小凯的研究以及林毅夫的论文(2003)。诺贝尔奖得主斯蒂格利茨和哥伦比亚大学格林沃尔德的著作(2017)系统地阐释了学习和经济发展的关系,在这个框架下讨论了一系列主流经济学中视为“扭曲”的政策的积极意义,包括产业政策和贸易保护等,是一部杰作。\n1919 哈佛大学罗德里克的著作(2009)系统地阐述了这一点。\n2020 其实何止是省,我国很多市的发展故事和模式也各具特色。这方面深入的研究并不多,感兴趣的读者可以参考如下著作,很有意思。复旦大学章奇和北京大学刘明兴关于浙江模式的著作(2016);复旦大学张军主编的关于深圳模式的论文集(2019)。再早一点,还有国家发展改革委张燕生团队关于佛山模式的研究报告(2001),浙江大学史晋川团队关于温州模式的研究报告(2002)。\n2121 清华大学钱颖一的论文集(Qian, 2017)详细阐述了这一点。\n2222 哈佛大学罗德里克的著作(2018)阐述了贸易理论和现实利益之间的冲突。\n2323 关于这一学说的基本框架,参见林毅夫的著作(2014),其中也包括了很多学者对这一理论的讨论以及林教授的回应。\n2424 参见伯克利加州大学科恩(Cohen)和德隆(DeLong)的著作(2016)。\n结束语 # 写书是需要幻觉的,我必须坚信这本书很重要,很有意义,我才能坚持写完它。但写完了,也就不再需要这种幻觉支撑了。中国经济这台热闹炫目的大戏,说不尽,这本书只是我的一点模糊认识,一鳞半爪都谈不上,盲人摸象更贴切些。凯恩斯在《论概率》中说过一段话,概括了我在写作本书过程中的心理状态:\n写这样一本书,若想说清观点,作者有时必须装得成竹在胸一点。想让自己的论述站得稳,便不能甫一下笔就顾虑重重。论述这些问题实非易事,我有时轻描淡写,斩钉截铁,但其实心中始终有所疑虑,也许读者能够体谅。\n过去40年,我国的名义GDP增长了242倍,大家从每个月挣二三十元变成了挣四五千元,动作稍微慢一点,就被时代甩在了后面。身在其中的风风火火、慌慌张张、大起大落、大喜大悲,其他国家的人无论有多少知识和理论,都没有切身感受。\n我出生于1980年,长在内蒙古的边陲小镇,在北京、大连、上海、深圳、武汉都长期待过,除了在美国读书和生活的六七年,没离开过这片滚滚红尘。虽然见过的问题和麻烦可以再写几本书,但经历和见闻让我对中国悲观不起来。我可以用很多理论来分析和阐述这种乐观,但从根本上讲,我的乐观并不需要这些头头是道的逻辑支撑,它就是一种朴素的信念:相信中国会更好。这种信念不是源于学术训练,而是源于司马迁、杜甫、苏轼,源于“一条大河波浪宽”,源于对中国人勤奋实干的钦佩。它影响了我看待问题的角度和处理信息的方式,我接受这种局限性,没有改变的打算。\n没人知道未来会怎样。哪怕只是五六十年,也是一个远超认知的时间跨度,信念因此重要。1912年,溥仪退位,旧制度天崩地裂,新时代风起云涌,直到改革开放,仿佛已经历了几个世纪,但实际不过66年。\n所以这本书没什么宏大的构思和框架,也没有预测,就是介绍些当下的情况,如果能帮助读者理解身边的一些事情,从热闹的政经新闻中看出些门道,从严肃的政府文件中觉察出些机会,争取改善一下生活,哪怕只是增加些谈资,也足够了。我是个经济学家,基于专业训练的朴素信念也有一个:生活过得好一点,比大多数宏伟更宏伟。\n参考文献 # 艾肯格林,巴里(2019),《嚣张的特权:美元的国际化之路及对中国的启示》,陈召强译,中信出版社。\n艾伦,罗伯特(2015),《全球经济史》,陆赟译,译林出版社。\n埃森格林,巴里(2020),《资本全球化:一部国际货币体系史(原书第3版)》,麻勇爱译,机械工业出版社。\n白重恩、钱震杰(2009),《国民收入的要素分配:统计数据背后的故事》,载《经济研究》第3期。\n鲍德温,理查德(2020),《大合流:信息技术和新全球化》,李志远、刘晓捷、罗长远译,格致出版社。\n保尔森,亨利(2016),《与中国打交道:亲历一个新经济大国的崛起》,王宇光等译,香港中文大学出版社。\n贝克特,斯文(2019),《棉花帝国:一部资本主义全球史》,徐轶杰、杨燕译,民主与建设出版社。\n编委会(2013),《国家开发银行史:1994—2012》,中国金融出版社。\n波兰尼,卡尔(2020),《大转型:我们时代的政治与经济起源》,冯钢、刘阳译,当代世界出版社。\n薄一波(2008),《若干重大决策与事件的回顾》,中共党史出版社。\n蔡昉、李周、林毅夫(2014),《中国的奇迹:发展战略与经济改革(增订版)》,格致出版社。\n陈斌开、李银银(2020),《再分配政策对农村收入分配的影响——基于税费体制改革的经验研究》,载《中国社会科学》第2期。\n陈斌开、陆铭、钟宁桦(2010),《户籍制约下的居民消费》,载《经济研究》增刊。\n陈斌开、杨汝岱(2013),《土地供给、住房价格与中国城镇居民储蓄》,载《经济研究》第1期。\n陈登科、陈诗一(2018),《资本劳动相对价格、替代弹性与劳动收入份额》,载《世界经济》第12期。\n陈硕、朱琳(2020),《市场转型与腐败治理:基于官员个体证据》,复旦大学经济学院工作论文。\n陈晓红、朱蕾、汪阳洁(2019),《驻地效应——来自国家土地督察的经验证据》,载《经济学(季刊)》第1期。\n达利欧,瑞(2019),《债务危机:我的应对原则》,赵灿等译,中信出版社。\n党均章、王庆华(2010),《地方政府融资平台贷款风险分析与思考》,载《银行家》第4期。\n德·索托,赫尔南多(2007),《资本的秘密》,于海生译,华夏出版社。\n范子英、李欣(2014),《部长的政治关联效应与财政转移支付分配》,载《经济研究》第6期。\n方红生、张军(2013),《攫取之手、援助之手与中国税收超GDP增长》,载《经济研究》第3期。\n冯军旗(2010),《中县干部》,北京大学博士学位论文。\n傅高义(2013),《邓小平时代》,冯克利译,生活·读书·新知三联书店。\n弗里登,杰弗里(2017),《20世纪全球资本主义的兴衰》,杨宇光译,上海人民出版社。\n福山,弗朗西斯(2014),《政治秩序的起源:从前人类时代到法国大革命》,毛俊杰译,广西师范大学出版社。\n傅勇、张晏(2007),《中国式分权与财政支出结构偏向:为增长而竞争的代价》,载《管理世界》第3期。\n甘犁、赵乃宝、孙永智(2018),《收入不平等、流动性约束与中国家庭储蓄率》,载《经济研究》第12期。\n高翔、龙小宁(2016),《省级行政区划造成的文化分割会影响区域经济么?》,载《经济学(季刊)》第2期。\n戈德斯通,杰克(2010),《为什么是欧洲?世界史视角下的西方崛起》,关永强译,浙江大学出版社。\n戈登,罗伯特(2018),《美国增长的起落》,张林山等译,中信出版集团。\n戈顿,加里(2011),《银行的秘密:现代金融生存启示录》,陈曦译,中信出版社。\n葛剑雄(2013),《统一与分裂:中国历史的启示》,商务印书馆。\n格申克龙,亚历山大(2012),《经济落后的历史透视》,张凤林译,商务印书馆。\n弓永峰、林劼(2020),《“逆全球化”难撼中国光伏产业链优势地位》,中信证券研报。\n辜朝明(2016),《大衰退:宏观经济学的圣杯》,喻海翔译,东方出版社。\n韩立彬、陆铭(2018),《供需错配:解开中国房价分化之谜》,载《世界经济》第10期。\n韩茂莉(2015),《中国历史地理十五讲》,北京大学出版社。\n洪正、张硕楠、张琳(2017),《经济结构、财政禀赋与地方政府控股城商行模式选择》,载《金融研究》第10期。\n华生(2014),《城市化转型与土地陷阱》,东方出版社。\n黄奇帆(2020),《分析与思考:黄奇帆的复旦经济课》,上海人民出版社。\n姜超、朱征星、杜佳(2018),《地方政府隐性债务规模有多大?》,海通证券研报。\n金,默文(2016),《金融炼金术的终结:货币、银行与全球经济的未来》,束宇译,中信出版社。\n金观涛、刘青峰(2010),《兴盛与危机:论中国社会超稳定结构》,法律出版社。\n景跃进、陈明明、肖滨(2016),《当代中国政府与政治》,中国人民大学出版社。\n克鲁格曼,保罗(2002),《地理和贸易》,张兆杰译,北京大学出版社。\n孔飞力(2014),《叫魂:1768年中国妖术大恐慌》,陈兼、刘昶译,生活·读书·新知三联书店。\n拉詹,拉古拉迈(2015),《断层线:全球经济潜在的危机》,李念等译,中信出版社。\n莱因哈特,卡门、肯尼斯·罗格夫(2012),《这次不一样:八百年金融危机史》,綦相译,机械工业出版社。\n李侃如(2010),《治理中国:从革命到改革》,胡国成、赵梅译,中国社会科学出版社。\n李萍(主编)(2010),《财政体制简明图解》,中国财政经济出版社。\n李实、岳希明(2015),《〈21世纪资本论〉到底发现了什么》,中国财政经济出版社。\n李实、朱梦冰(2018),《中国经济转型40年中居民收入差距的变动》,载《管理世界》第12期。\n李学文、卢新海、张蔚文(2012),《地方政府与预算外收入:中国经济增长模式问题》,载《世界经济》第8期。\n林毅夫(2003),《后发优势与后发劣势——与杨小凯教授商榷》,载《经济学(季刊)》第4期。\n林毅夫(2014),《新结构经济学:反思经济发展与政策的理论框架(增订版)》,北京大学出版社。\n林毅夫、巫和懋、邢亦青(2010),《“潮涌现象”与产能过剩的形成机制》,载《经济研究》第10期。\n刘克崮、贾康主编(2008),《中国财税改革三十年:亲历与回顾》,经济科学出版社。\n刘守英(2018),《土地制度与中国发展》,中国人民大学出版社。\n刘守英,杨继东(2019),《中国产业升级的演进与政策选择——基于产品空间的视角》,载《管理世界》第6期。\n楼继伟(2013),《中国政府间财政关系再思考》,中国财政经济出版社。\n楼继伟(2018),《事权与支出责任划分改革的有关问题》,载《比较》第4期。\n楼继伟、刘尚希(2019),《新中国财税发展70年》,人民出版社。\n路风(2016),《光变:一个企业及其工业史》,当代中国出版社。\n路风(2019),《走向自主创新:寻找中国力量的源泉》,中国人民大学出版社。\n路风(2020),《新火:走向自主创新2》,中国人民大学出版社。\n陆铭(2016),《大国大城:当代中国的统一、发展与平衡》,上海人民出版社。\n罗长远、张军(2009),《经济发展中的劳动收入占比:基于中国产业数据的实证研究》,载《中国社会科学》第4期。\n罗德里克,丹尼(2009),《相同的经济学,不同的政策处方:全球化、制度建设和经济增长》,张军扩、侯永志等译,中信出版社。\n罗德里克,丹尼(2011),《全球化的悖论》,廖丽华译,中国人民大学出版社。\n罗德里克,丹尼(2018),《贸易的真相:如何构建理性的世界经济》,卓贤译,中信出版社。\n马光荣、张凯强、吕冰洋(2019),《分税与地方财政支出结构》,载《金融研究》第8期。\n麦克劳,托马斯(1999),《现代资本主义:三次工业革命中的成功者》,赵文书、肖锁章译,江苏人民出版社。\n迈恩,阿蒂夫、阿米尔·苏非(2015),《房债:为什么会出现大衰退,如何避免重蹈覆辙》,何志强、邢增艺译,中信出版社。\n米兰诺维奇,布兰科(2019),《全球不平等》,熊金武、刘宣佑译,中信出版社。\n缪小林、王婷、高跃光(2017),《转移支付对城乡公共服务差距的影响——不同经济赶超省份的分组比较》,载《经济研究》第2期。\n诺顿,巴里(2020),《中国经济:适应与增长(第二版)》,安佳译,上海人民出版社。\n潘功胜(2012),《大行蝶变:中国大型商业银行复兴之路》,中国金融出版社。\n佩蒂斯,迈克尔(2014),《大失衡:贸易、冲突和世界经济的危险前路》,王璟译,译林出版社。\n皮凯蒂,托马斯(2014),《21世纪资本论》,巴曙松译,中信出版社。\n任泽平、夏磊、熊柴(2017),《房地产周期》,人民出版社。\n沙伊德尔,沃尔特(2019),《不平等社会:从石器时代到21世纪,人类如何应对不平等》,颜鹏飞等译,中信出版社。\n邵朝对、苏丹妮、包群(2018),《中国式分权下撤县设区的增长绩效评估》,载《世界经济》第10期。\n邵挺、田莉、陶然(2018),《中国城市二元土地制度与房地产调控长效机制:理论分析框架、政策效应评估与未来改革路径》,载《比较》第6期。\n沈联涛(2015),《十年轮回:从亚洲到全球的金融危机(第三版)》,杨宇光、刘敬国译,上海远东出版社。\n史晋川(等)(2002),《制度变迁与经济发展:温州模式研究》,浙江大学出版社。\n史塔威尔,乔(2014),《亚洲大趋势》,蒋宗强译,中信出版社。\n斯蒂格利茨,约瑟夫(2013),《不平等的代价》,张子源译,机械工业出版社。\n斯蒂格利茨,约瑟夫,布鲁斯·格林沃尔德(2017),《增长的方法:学习型社会与经济增长的新引擎》,陈宇欣译,中信出版社。\n谭之博、周黎安、赵岳(2015),《省管县改革、财政分权与民生——基于“倍差法”的估计》,载《经济学(季刊)》第3期。\n唐为(2019),《分权、外部性与边界效应》,载《经济研究》第3期。\n唐为、王媛(2015),《行政区划调整与人口城市化:来自撤县设区的经验证据》,载《经济研究》第9期。\n特纳,阿代尔(2016),《债务和魔鬼:货币、信贷和全球金融体系重建》,王胜邦、徐惊蛰、朱元倩译,中信出版社。\n田毅、赵旭(2008),《他乡之税:一个乡镇的三十年,一个国家的“隐秘”财政史》,中信出版社。\n万晓莉、严予若、方芳(2017),《房价变化、房屋资产与中国居民消费——基于总体和调研数据的证据》,载《经济学(季刊)》第2期。\n王能全(2018),《石油的时代》,中信出版社。\n王瑞民、陶然(2017),《中国财政转移支付的均等化效应:基于县级数据的评估》,载《世界经济》第12期。\n王绍光(1997),《分权的底限》,中国计划出版社。\n沃尔克,保罗、行天丰雄(2016),《时运变迁:世界货币、美元地位与人民币的未来》,于杰译,中信出版社。\n沃尔特,卡尔、弗雷泽·豪伊(2013),《红色资本:中国的非凡崛起和脆弱的金融基础》,祝捷、刘骏译,东方出版中心。\n吴军(2019),《浪潮之巅(第四版)》,人民邮电出版社。\n吴敏、周黎安(2018),《晋升激励与城市建设:公共品可视性的视角》,载《经济研究》第12期。\n吴毅(2018),《小镇喧嚣:一个乡镇政治运作的演绎与阐释》,生活·读书·新知三联书店。\n巫永平(2017),《谁创造的经济奇迹?》,生活·读书·新知三联书店。\n席鹏辉、梁若冰、谢贞发(2017),《税收分成调整、财政压力与工业污染》,载《世界经济》第10期。\n席鹏辉、梁若冰、谢贞发、苏国灿(2017),《财政压力、产能过剩与供给侧改革》,载《经济研究》第9期。\n许宪春、贾海、李皎、李俊波(2015),《房地产经济对中国国民经济增长的作用研究》,载《中国社会科学》第1期。\n徐业坤、马光源(2019),《地方官员变更与企业产能过剩》,载《经济研究》第5期。\n严鹏(2018),《简明中国工业史(1815—2015)》,电子工业出版社。\n杨海生、陈少凌、罗党论、佘国满(2014),《政策不稳定性与经济增长:来自中国地方官员变更的经验证据》,载《管理世界》第9期。\n姚洋、张牧扬(2013),《官员绩效与晋升锦标赛:来自城市数据的证据》,载《经济研究》第1期。\n叶恩华,布鲁斯·马科恩(2016),《创新驱动中国:中国经济转型升级的新引擎》,陈召强、段莉译,中信出版社。\n易纲(2019),《坚守币值稳定目标 实施稳健货币政策》,载《求是》第23期。\n易纲(2020),《再论中国金融资产结构及政策含义》,载《经济研究》第3期。\n尹恒、朱虹(2011),《县级财政生产性支出偏向研究》,载《中国社会科学》第1期。\n余淼杰、梁中华(2014),《贸易自由化与中国劳动收入份额——基于制造业贸易企业数据的实证分析》,载《管理世界》第7期。\n余永定(2010),《见证失衡:双顺差、人民币汇率和美元陷阱》,生活·读书·新知三联书店。\n袁健聪、徐涛、王喆、敖翀、李超(2020),《新材料行业面板材料系列报告》,中信证券研报。\n张川川、贾珅、杨汝岱(2016),《“鬼城”下的蜗居:收入不平等与房地产泡沫》,载《世界经济》第2期。\n张春霖(2019),《从数据看全球金融危机以来中国国有企业规模的加速增长》,载《比较》第6期。\n张嘉璈(2018),《通胀螺旋:中国货币经济全面崩溃的十年1939—1949》,中信出版社。\n张军、樊海潮、许志伟、周龙飞(2020),《GDP增速的结构性下调:官员考核机制的视角》,载《经济研究》第5期。\n张军(主编)(2019),《深圳奇迹》,东方出版社。\n章奇、刘明兴(2016),《权力结构、政治激励和经济增长:基于浙江民营经济发展经验的政治经济学分析》,格致出版社、上海人民出版社。\n张五常(2017),《中国的经济制度》,中信出版社。\n张五常(2019),《经济解释(2019增订版)》,中信出版社。\n张燕生(等)(2001),《政府与市场:中国经验》,中信出版社。\n赵婷、陈钊(2019),《比较优势与中央、地方的产业政策》,载《世界经济》第10期。\n郑思齐、孙伟增、吴璟、武赟(2014),《以地生财,以财养地——中国特色城市建设投融资模式研究》,载《经济研究》第8期。\n中国人民银行金融稳定分析小组(2019),《中国金融稳定报告2019》,中国金融出版社。\n中国人民银行调查统计司(2020),《中国城镇居民家庭资产负债调查报告》。\n钟粤俊、陆铭、奚锡灿(2020),《集聚与服务业发展——基于人口空间分布的视角》,载《管理世界》第11期。\n周飞舟(2012),《以利为利:财政关系与地方政府行为》,上海三联书店。\n周黎安(2016),《行政发包的组织边界:兼论“官吏分途”与“层级分流”现象》,载《社会》第1期。\n周黎安(2017),《转型中的地方政府:官员激励与治理(第二版)》,格致出版社、上海人民出版社。\n周黎安(2018),《“官场+市场”与中国增长故事》,载《社会》第2期。\n周其仁(2012),《货币的教训:汇率与货币系列评论》,北京大学出版社。\n周其仁(2017),《城乡中国(修订版)》,中信出版社。\n周雪光(2016),《从“官吏分途”与“层级分流”:帝国逻辑下的中国官僚人事制度》,载《社会》第1期。\n周振鹤(2014),《中国地方行政制度史》,上海人民出版社。\n朱宁(2016),《刚性泡沫》,中信出版社。\n朱玥(2019),《周期的力量,成长的锋芒:光伏产业15年复盘与展望》,兴业证券研报。\n祖克曼,格里高利(2018),《史上最伟大的交易》,施轶译,中国人民大学出版社。\nAcemoglu, Daron, Ufuk Akcigit, Douglas Hanley, and William Kerr 20162016 ,“Transition to Clean Technology,”Journal of Political Economy 124 11 :52-104.\nAghion, Philippe, Antonin Bergeaud, Matthieu Lequien, and Marc J. Melitz 20182018 ,“The Impact of Exports on Innovation: Theory and Evidence,”NBER Working Paper 24600.\nAghion, Philippe, Jing Cai, Mathias Dewatripont, Luosha Du, Ann Harrison,and Patrick Legros 20152015 ,“Industrial Policy and Competition,”American Economic Journal: Macroeconomics 7 44 : 1-32.\nAghion, Philippe, and Jean Tirole 19971997 ,“Formal and Real Authority in Organizations,”Journal of Political Economy 105 11 : 1-29.\nAkerlof, George A. 20202020 ,“Sins of Omission and the Practice of Economics,”Journal of Economic Literature 58 22 : 405-418.\nAlchian, Armen A. 19501950 ,“Uncertainty, Evolution, and Economic Theory,”Journal of Political Economy 58 33 : 211-221.\nAlesina, Alberto, and Enrico Spolaore 20032003 , The Size of Nations, MIT Press.\nAng, Yuen Yuen 20202020 , China\u0026rsquo;s Gilded Age: the Paradox of Economic Boom and Vast Corruption, Cambridge University Press.\nAppelbaum, Eileen, and Rosemary Batt 20142014 , Private Equity at Work: When Wall Street Manages Main Street, Russell Sage Foundation.\nArmstrong-Taylor, Paul 20162016 , Debt and Distortion: Risks and Reforms in the Chinese Financial System, Palgrave Macmillan.\nAutor, David, David Dorn, and Gordon Hanson 20132013 ,“The China Syndrome: Local Labor Market Effects of Import Competition in the United States,”American Economic Review 103 66 : 2121-2168.\nAutor, David, David Dorn, Gordon Hanson and Kaveh Majlesi 20202020 ,“Importing Political Polarization? The Electoral Consequences of Rising Trade Exposure,”American Economic Review 110 1010 : 3139-3183.\nAutor, David, David Dorn, Gordon H. Hanson, Gary Pisano, and Pian Shu 20202020 ,“Foreign Competition and Domestic Innovation: Evidence from US Patents,”American Economic Review: Insights, forthcoming.\nAvdjiev, Stefan, Robert N. McCauley, and Hyun Song Shin 20162016 ,“Breaking Free of the Triple Coincidence in International Finance,”Economic Policy 31 8787 : 409-451.\nBai, Chong-En, Chang-Tai Hsieh, and Zheng Song 20162016 ,“The Long Shadow of a Fiscal Expansion,”*Brookings Papers on Economic Activity,*Fall: 129-165.\nBardhan, Pranab 20162016 ,“State and Development: The Need for a Reappraisal of the Current Literature,”Journal of Economic Literature 54 33 : 862-892.\nBertrand, Marianne, and Adair Morse 20162016 ,“Trickle-down Consumption,”Review of Economics and Statistics 98 55 : 863-879.\nBesley, Timothy, and Torsten Persson 20112011 , Pillars of Prosperity: the Political Economics of Development Clusters, Princeton University Press.\nBloom, Nicholas 20142014 ,“Fluctuations in Uncertainty,”Journal of Economic Perspectives 28 22 : 153-176.\nBloom, Nicholas, Kyle Handley, Andre Kurman, and Phillip Luck 20192019 ,“The Impact of Chinese Trade on US Employment: The Good, The Bad, and The Debatable,”Working Paper.\nBrueckner, Jan K., Shihe Fu, Yizhen Gu, and Junfu Zhang 20172017 ,“Measuring the Stringency of Land Use Regulation: the Case of China\u0026rsquo;s Building Height Limits,”* Review of Economics and Statistics* 99, no. 4:663-677.\nCai, Hongbin, Yuyu Chen, and Qing Gong 20162016 ,“Polluting Thy Neighbor:Unintended Consequences of China\u0026rsquo;s Pollution Reduction Mandates,”Journal of Environmental Economics and Management 76: 86-104.\nChamon, Marcos D., and Eswar S. Prasad 20102010 ,“Why Are Saving Rates of Urban Households in China Rising?”*American Economic Journal:*Macroeconomics 2 11 : 93-130.\nChen, M. Keith 20132013 ,“The Effect of Language on Economic Behavior:Evidence from Savings Rates, Health Behaviors, and Retirement Assets,”American Economic Review 103 22 : 690-731.\nChen, Peter, Loukas Karabarbounis, and Brent Neiman 20172017 ,“The Global Rise of Corporate Saving,”Journal of Monetary Economics 89: 1-19.\nChen, Shuo, Xinyu Fan, Zhitao Zhu 20202020 ,“The Promotion Club,”Working Paper.\nChen, Ting, Laura Xiaolei Liu, Wei Xiong, and Li-An Zhou 20182018 ,“Real Estate Boom and Misallocation of Capital in China,”Working Paper.\nCheng, Hong, Ruixue Jia, Dandan Li, and Hongbin Li 20192019 ,“The Rise of Robots in China,”Journal of Economic Perspectives 33, no. 2: 71-88.\nCherif, Reda, and Fuad Hasanov 20192019 ,“The Return of the Policy that Shall Not Be Named: Principles of Industrial Policy,”IMF Working Paper.\nChetty, Raj, David Grusky, Maximilian Hell, Nathaniel Hendren, Robert Manduca, and Jimmy Narang 20172017 ,“The Fading American Dream:Trends in Absolute Income Mobility since 1940,”Science 356 63366336 :398-406.\nChetty, Raj, Nathaniel Hendren, Maggie R. Jones, and Sonya R. Porter 20202020 ,“Race and Economic Opportunity in the United States: An Intergenerational Perspective,”Quarterly Journal of Economics 135 22 :711-783.\nChoukhmane, Taha, Nicholas Coeurdacier, and Keyu Jin 20192019 ,“The Onechild Policy and Household Savings,”Working Paper.\nCohen, Stephen S., and J. Bradford DeLong 20162016 , Concrete Economics: The Hamilton Approach to Economic Growth and Policy, Harvard Business Review Press.\nCunningham, Edward, Tony Saich, and Jesse Turiel 20202020 ,“UnderstandingCCP Resilience: Surveying Chinese Public Opinion through Time,”Harvard Kennedy School Ash Center Policy Report.\nDi Tella, Rafael, and Dani Rodrik 20202020 ,“Labour Market Shocks and the Demand for Trade Protection: Evidence from Online Surveys,”Economic Journal 130 628628 : 1008-1030.\nEggertsson, Gauti B., and Paul Krugman 20122012 ,“Debt, Deleveraging, and the Liquidity Trap: A Fisher-Minsky-Koo Approach,”Quarterly Journal of Economics 127 33 : 1469-1513.\nFan, Haichao, Yu Liu, Nancy Qian, and Jaya Wen 20202020 ,“Computerizing VAT Invoices in China,”NBER Working Paper 24414.\nFan, Jingting, and Ben Zou 20192019 ,“Industrialization from Scratch: The‘Construction of Third Front’ and Local Economic Development in China\u0026rsquo;s Hinterland,”Working Paper.\nFan, Yi, Junjian Yi, and Junsen Zhang 20212021 ,“Rising Intergenerational Income Persistence in China,”*American Economic Journal: Economic Policy *13 11 : 202-230.\nFang, Hanming, Quanlin Gu, Wei Xiong, and Li-An Zhou 20152015 ,“Demystifying the Chinese Housing Boom,”NBER Macro Annual Vol.30Vol.30 : 105-166.\nFurman, Jason, and Lawrence Summers 20202020 ,“A Reconsideration of Fiscal Policy in the Era of Low Interest Rates,”Brookings Working Paper.\nGertler, Mark, and Simon Gilchrist 20182018 , “What Happened: Financial Factors in the Great Recession,”Journal of Economic Perspective 32 33 :3-30.\nGlaeser, Edward, and Joseph Gyourko 20182018 ,“The Economic Implications of Housing Supply,”Journal of Economic Perspectives 32 11 : 3-30.\nGlaeser, Edward, and Andrei Shleifer 20032003 ,“The Rise of the Regulatory State,”Journal of Economic Literature, 41 22 : 401-425.\nGlick, Reuven, and Kevin J. Lansing 20102010 ,“Global Household Leverage,House Prices, and Consumption,”Federal Reserve Bank of San Francisco Economic Letter.\nGomory, Ralph E., and William J. Baumol 20002000 , Global Trade and Conflicting National Interests, MIT Press.\nHaldane, Andrew, Simon Brennan, and Vasileios Madouros 20102010 ,“The Contribution of the Financial Sector: Miracle or Mirage?”A Technical Report at the London School of Economics.\nHart, Oliver 19951995 , Firms, Contracts, and Financial Structure, Clarendon Press.\nHart, Oliver, Andrei Shleifer, and Robert W. Vishny 19971997 ,“The Proper Scope of Government: Theory and an Application to Prisons,”Quarterly Journal of Economics 112 44 : 1127-1161.\nHaskel, Jonathan, and Stian Westlake 20182018 , Capitalism Without Capital: the Rise of the Intangible Economy, Princeton University Press.\nHavranek, Tomas, and Zuzana Irsova 20112011 ,“Estimating Vertical Spillovers from FDI: Why Results Vary and What the True Effect is,”Journal of International Economics 85 22 : 234-244.\nHe, Guojun, Shaoda Wang, and Bing Zhang 20202020 ,“Leveraging Political Incentives for Environmental Regulation: Evidence from Chinese Manufacturing Firms,”Quarterly Journal of Economics.\nHirschman, Albert O. 20132013 ,“The Changing Tolerance for Income Inequality in the Course of Economic Development,”The Essential Hirschman, Ed. by Jeremy Adelman: 74-101, Princeton University Press.\nHuang, Zhangkai, Lixing Li, Guangrong Ma, and Lixin Colin Xu 20172017 ,“Hayek, Local Information, and Commanding Heights: Decentralizing State-Owned Enterprises in China,”* American Economic Review* 107 88 :2455-2478.\nJia, Ruixue, Masayuki Kudamatsu, and David Seim 20152015 ,“Political Selection in China: the Complementary Roles of Connections and Performance,”Journal of the European Economic Association, 13 44 , 631-668.\nJin, Hehui, Yingyi Qian, and Barry R. Weingast 20052005 ,“Regional decentralization and fiscal incentives: Federalism, Chinese style,”Journal of Public Economics 89 9−109-10 : 1719-1742.\nJordà, Òscar, Moritz Schularick, and Alan M. Taylor 20162016 ,“The Great Mortgaging: Housing Finance, Crises and Business Cycles,”Economic Policy 31 8585 : 107-152.\nImrohoroǧlu, Ayşe, and Kai Zhao 20182018 ,“The Chinese Saving Rate: Long-Term Care Risks, Family Insurance, and Demographics,”Journal of Monetary Economics 96: 33-52.\nKarabarbounis, Loukas, and Brent Neiman 20142014 ,“The Global Decline of the Labor Share,”Quarterly Journal of Economics 129 11 : 61-103.\nKarabarbounis, Loukas, and Brent Neiman 20192019 ,“Accounting for Factorless Income,”NBER Macroeconomics Annual 33 11 : 167-228.\nKlein, Matthew C., and Michael Pettis 20202020 , Trade Wars are Class Wars:How Rising Inequality Distorts the Global Economy and Threatens International Peace, Yale University Press.\nKnoll, Katharina, Moritz Schularick, and Thomas Steger 20172017 ,“No Price Like Home: Global House Prices, 1870-2012,”American Economic Review 107 22 : 331-353.\nKreps, David 20182018 , The Motivation Toolkit: How to Align Your Employees’Interests with Your Own, Findaway World, LLC.\nKrugman, Paul 19871987 ,“The Narrow Moving Band, the Dutch Disease, and the Competitive Consequences of Mrs. Thatcher: Notes on Trade in the Presence of Dynamic Scale Economies,”Journal of Development Economics 27 1−21-2 : 41-55.\nKuhn, Moritz, Moritz Schularick, and Ulrike I. Steins 20202020 ,“Income and Wealth Inequality in America: 1949-2016,”Journal of Political Economy.\nKung, James Kai-Sing, and Lin Yi-min 20072007 ,“The Decline of Township-and-Village Enterprises in China\u0026rsquo;s Economic Transition,”World Development 35 44 : 569-584.\nLane, Nathan 20192019 ,“Manufacturing Revolutions: Industrial Policy and Industrialization in South Korea,”Working Paper.\nLevchenko, Andrei A 20072007 ,“Institutional Quality and International Trade,”Review of Economic Studies 74 33 : 791-819.\nLi, Pei, Yi Lu, and Jin Wang 20162016 ,“Does Flattening Government Improve Economic Performance? Evidence from China,”Journal of Development Economics 123: 18-37.\nLi, Xing, Chong Liu, Xi Weng, and Li-An Zhou 20192019 ,“Target Setting in Tournaments: Theory and Evidence from China,”Economic Journal 129 1010 : 2888-2915.\nLiu, Ernest 20192019 ,“Industrial Policies in Production Networks,”Quarterly Journal of Economics 134 44 : 1883-1948.\nMaskin, Eric, Yingyi Qian, and Chenggang Xu 20002000 ,“Incentives,Information, and Organizational Form,”Review of Economic Studies 67 22 : 359-378.\nMelitz, Marc J., and Daniel Trefler 20122012 ,“Gains from Trade when Firms Matter,”Journal of Economic Perspectives 26 22 : 91-118.\nMichalopoulos, Stelios 20122012 ,“The Origins of Ethnolinguistic Diversity,”American Economic Review 102 44 : 1508-1539.\nMian, Atif R., Ludwig Straub, and Amir Sufi 2020a2020a ,“Indebted Demand,”NBER Working Paper No. w26940.\nMian, Atif R., Ludwig Straub, and Amir Sufi 2020b2020b ,“The Saving Glut ofthe Rich and the Rise in Household Debt,”NBER Working Paper No.w26941.\nMilne, Alistair 20092009 , The Fall of the House of Credit: What Went Wrong in Banking and What Can be Done to Repair the Damage? Cambridge University Press.\nMulligan, Casey and Andrei Shleifer 20052005 ,“The Extent of the Market and the Supply of Regulation,”Quarterly Journal of Economics 120: 1445-1473.\nOlmstead, Alan L., and Paul W. Rhode 20182018 ,“Cotton, Slavery, and the New History of Capitalism,”Explorations in Economic History 67: 1-17.\nOrlik, Thomas 20202020 , China: the Bubble that Never Pops, Oxford University Press.\nPhilippon, Thomas, and Ariell Reshef 20122012 ,“Wages and human capital in the US finance industry: 1909-2006,”Quarterly Journal of Economics 127 44 : 1551-1609.\nPhilippon, Thomas 20192019 , The Great Reversal: How America Gave Up on Free Markets, Harvard University Press.\nPiketty, Thomas, Li Yang, and Gabriel Zucman 20192019 ,“Capital Accumulation, Private Property, and Rising Inequality in China, 1978-2015,”American Economic Review 109 77 : 2469-2496.\nPiketty, Thomas, and Gabriel Zucman 20142014 ,“Capital is Back: Wealth-Income Ratios in Rich Countries 1700-2010,”*Quarterly Journal of Economics *129: 1255-1310.\nPrendergast, Canice, and Robert Topel 19961996 ,“Favoritism in Organizations,”Journal of Political Economy 104 55 : 958-978.\nQian, Yingyi 20172017 , How Reform Worked in China: the Transition from Plan to Market, the MIT Press\nREN21 20202020 , Renewables 2020 Global Status Report, Renewable EnergyPolicy Network for the 21st Century.\nRodrik, Dani 19981998 ,“Why Do More Open Economies Have Bigger Governments?”Journal of Political Economy 106 55 : 997-1032.\nRodrik, Dani 20132013 ,“Unconditional Convergence in Manufacturing,”Quarterly Journal of Economics 128 11 : 165-204.\nRodrik, Dani 20162016 ,“Premature Deindustrialization,”Journal of Economic Growth 21 11 : 1-33.\nRyan-Collins, Josh, Toby Lioyd, and Laurie Macfarlane 20172017 , Rethinking the Economics of Land and Housing, Zed Books.\nSaez, Emmanuel, and Gabriel Zucman 20192019 , The Triumph of Injustice:How the Rich Dodge Taxes and How to Make Them Pay, W.W. Norton \u0026amp;Company.\nShiller, Robert J. 20202020 , Narrative Economics: How Stories Go Viral and Drive Major Economic Events, Princeton University Press.\nShleifer, Andrei, and Robert Vishny 20112011 ,“Fire Sales in Finance and Macroeconomics,”Journal of Economic Perspectives 25 11 : 29-48.\nSivaram, Varun 20182018 , Taming the Sun: Innovation to Harness Solar Energy and Power the Planet, MIT press.\nSpader, Jonathan, Daniel McCue, and Christopher Herbert 20162016 ,“Homeowner Households and the U.S. Homeownership Rate: Tenure Projections for 2015-2035,”Working Paper.\nTooze, Adam 20182018 , Crashed: How a Decade of Financial Crises Changed the World, Penguin.\nWallis, John J. 20062006 ,“The Concept of Systematic Corruption in American History,”Corruption and Reform: Lessons from America\u0026rsquo;s Economic History, University of Chicago Press: 23-62.\nWang, Gungwu 20192019 , China Reconnects: Joining a Deep-rooted Past to a New World Order, World Scientific.\nWang, Zhi, Shang-Jin Wei, Xinding Yu, Kunfu Zhu 20182018 ,“Re-examining the Effects of Trading With China on Local Labor Markets: A Supply Chain Perspective,”NBER Working Paper 24886.\nWang, Zhi, Qinghua Zhang, and Li-An Zhou 20202020 ,“Career Incentives of City Leaders and Urban Spatial Expansion in China,”*Review of Economics and Statistics *102 55 : 897-911.\nWorld Inequality Lab 20172017 , World Inequality Report 2018.\nXu, Chenggang 20112011 ,“The Fundamental Institutions of China\u0026rsquo;s Reforms and Development,”Journal of Economic Literature, 49 44 : 1076-1151.\nYergin, Deniel 20202020 The New Map: Energy, Climate, and the Clash of Nations, Penguin Press.\nYoung, Alwyn 19911991 ,“Learning by Doing and the Dynamic Effects of International Trade,”Quarterly Journal of Economics 106 22 : 369-405.\nZhang, Longmei, Ray Brooks, Ding Ding, Haiyan Ding, Hui He, Jing Lu,and Rui Mano 20182018 ,“China\u0026rsquo;s High Savings: Drivers, Prospects, and Policies,”IMF Working Papers.\nZhang, Bing, Xiaolan Chen, and Huanxiu Guo 20182018 ,“Does Central Supervision Enhance Local Environmental Enforcement? Quasi-experimental Evidence from China,”Journal of Public Economics 164:70-90.\nZhang, Zhiwei, and Yi Xiong 20192019 ,“Infrastructure Financing,”Working Paper.\n"},{"id":160,"href":"/zh/docs/culture/%E7%BD%AE%E8%BA%AB%E4%BA%8B%E5%86%85/%E4%B8%8A%E7%AF%87/","title":"上篇","section":"置身事内","content":"\n图书在版编目(CIP)数据\n置身事内:中国政府与经济发展/兰小欢著.—上海:上海人民出版社,2021\nISBN 978-7-208-17133-6\nⅠ.①置… Ⅱ.①兰… Ⅲ.①行政管理部门—关系—经济发展—研究—中国 Ⅳ.①D630.1②F124\n中国版本图书馆CIP数据核字(2021)第095010号\n书 名:置身事内:中国政府与经济发展\n作 者:兰小欢\n出品人:姚映然\n责任编辑:贾忠贤 曹迪辉\n转 码:欣博友\nISBN:978-7-208-17133-6/F·2693\n本书版权,为北京世纪文景文化传播有限责任公司所有,非经书面授权,不得在任何地区以任何方式进行编辑、翻印、仿制或节录。\n豆瓣小站:世纪文景\n新浪微博:@世纪文景\n微信号:shijiwenjing2002\n发邮件至wenjingduzhe@126.com订阅文景每月书情\n目 录 # 前言 从了解现状开始\n上篇 微观机制\n第一章 地方政府的权力与事务\n第一节 政府治理的特点\n第二节 外部性与规模经济\n第三节 复杂信息\n第四节 激励相容\n第五节 招商引资\n结语\n扩展阅读\n第二章 财税与政府行为\n第一节 分税制改革\n第二节 土地财政\n第三节 纵向不平衡与横向不平衡\n结语\n扩展阅读\n第三章 政府投融资与债务\n第一节 城投公司与土地金融\n第二节 地方政府债务\n第三节 招商引资中的地方官员\n结语\n扩展阅读\n第四章 工业化中的政府角色\n第一节 京东方与政府投资\n第二节 光伏发展与政府补贴\n第三节 政府产业引导基金\n结语\n扩展阅读\n下篇 宏观现象\n第五章 城市化与不平衡\n第一节 房价与居民债务\n第二节 不平衡与要素市场改革\n第三节 经济发展与贫富差距\n结语\n扩展阅读\n第六章 债务与风险\n第一节 债务与经济衰退\n第二节 债台为何高筑:欧美的教训\n第三节 中国的债务与风险\n第四节 化解债务风险\n结语\n扩展阅读\n第七章 国内国际失衡\n第一节 低消费与产能过剩\n第二节 中美贸易冲突\n第三节 再平衡与国内大循环\n结语\n扩展阅读\n第八章 总结:政府与经济发展\n第一节 地区间竞争\n第二节 政府的发展与转型\n第三节 发展目标与发展过程\n结语\n扩展阅读\n结束语\n参考文献\n献给我的父亲母亲\n事莫明于有效,论莫定于有证。\n——王充《论衡》\n社会进程本是整体,密不可分。所谓经济,不过是研究者从这洪流中人工提炼出的部分事实。何谓经济,本身已然是种抽象,而之后大脑还须经过若干抽象,方能复刻现实。没有什么事是纯粹经济的,其他维度永远存在,且往往更为重要。\n——约瑟夫·熊彼特《经济发展理论》\n一套严格的概念框架无疑有助于厘清问题,但也经常让人错把问题当成答案。社会科学总渴望发现一套“放之四海而皆准”的方法和规律,但这种心态需要成熟起来。不要低估经济现实的复杂性,也不要高估科学工具的质量。\n——亚历山大·格申克龙《经济落后的历史透视》\n前言 从了解现状开始 # 这本书讲的是我们国家的经济故事,其中有让我们骄傲的繁华,也有让我们梦碎的房价。这本书写给大学生和对经济话题感兴趣的读者,希望能帮他们理解身边的世界,从热闹的政经新闻中看出些门道,从乏味的政府文件中觉察出些机会。\n本书主角既不是微观的价格机制,也不是宏观的经济周期,而是政府和政策,内容脱胎于我在复旦大学和香港中文大学(深圳)的课程讲义。我剔除了技术细节,尽量用通俗的语言讲述核心的内容和观念:在我国,政府不但影响“蛋糕”的分配,也参与“蛋糕”的生产,所以我们不可能脱离政府谈经济。必须深入了解这一政治经济机体如何运作,才可能对其进行判断。我们生活在这个机体中,我们的发展有赖于对这个机体的认知。要避免把舶来的理论化成先入为主的判断——看到现实与理论不符,便直斥现实之非,进而把要了解的现象变成了讥讽的对象——否则就丧失了“同情的理解”的机会。\n我国的政治经济现象非常复杂,不同的理论和信息都只能反映现象的不同侧面,至于哪个侧面有用,由读者决定。对从事经济实务工作(如金融和投资)的读者,我希望能帮助他们了解日常业务之外的政治经济背景,这些背景的变化往往对行业有深远的影响。对经济学专业的大学生,由于他们所学的西方理论和中国现实之间脱节严重,我将中国政府作为本书分析的主角,希望可以帮助构建二者之间的桥梁。对非经济学专业的读者,我希望这本书能帮助他们读懂国家政经大事和新闻。\n本书注重描述现实,注重解释“是什么”和“为什么”。当不可避免涉及“怎么办”的时候,则注重解释当下正在实施的政策和改革。对读者来说,了解政府认为应该怎么办,比了解“我”认为应该怎么办,重要得多。\n本书结构与数据说明 # 本书以我国地方政府投融资为主线,分上下两篇。上篇解释微观机制,包括地方政府的基本事务、收入、支出、土地融资和开发、投资和债务等;下篇解释这些微观行为对宏观现象的影响,包括城市化和工业化、房价、地区差异、债务风险、国内经济结构失衡、国际贸易冲突等。最后一章提炼和总结全书内容。\n本书力求简明扼要,突出主要逻辑和重点事实,不会过多展开细节。有兴趣深究的读者可以参考每章末尾“扩展阅读”中推荐的读物。\n本书使用了很多数据,若处处标注来源,会影响阅读。所以对于常见数据,如直接来自《中国统计年鉴》或万得数据库中的数据,我没有标注来源,但读者应该很容易就能找到。只有那些非常用数据或转引自他人研究的数据,我才注明出处。\n本书虽为大众读者所写,但严格遵循学术规范,使用了大量前沿研究成果,可用作大学相关课程的参考资料。与各章节内容相匹配的课件,可通过扫描本书前勒口的二维码获取。\n感谢 # 本书使用的数据和文献,跨度很大。引用的260多种文献中,绝大多数发表于2010年之后。假如没有近些年本土经济学研究的飞速发展,没有海外对中国经济研究的日渐深入,我不可能整理出这么多素材。\n复旦大学经济学院是研究中国经济问题的重镇,向来重视制度和历史分析,也积极参与现实和政策讨论,我对中国经济的深入学习和研究,是在这里开始的。学院几乎每周都有十数场报告,既有前沿学术探讨和热点政策分析,也有与业界和政府的交流讨论,在这种氛围中,研究者自然而然会关注现实问题。本书几乎每一章的主题,复旦的同事都有研究和著述,我从他们那里学到了很多。在复旦工作的六七年中,我几乎每周都参加陈钊和陆铭等同仁组织的学习讨论小组,本书中的很多想法都源于这些讨论。\n2017—2018年,我做了大量实地调研,与很多企业家、投资人和政府官员交流,这些经历影响了本书的视角和框架。感谢在这个过程中帮助过我的很多领导和业界精英。\n本书涵盖的主题跨度很大,在写作和学习过程中,我请教了很多同事,他们给了我巨大的帮助和鼓励。尤其要感谢陈硕、陈婷、董丰、刘志阔、吴乐旻,他们仔细阅读了本书的初稿章节,提供了大量宝贵建议。感谢我的教学和研究助理拜敏旸、丁关祖、李嵩同学,他们帮我收集了很多数据。同样也感谢那些帮我审读书稿的聪慧可爱的同学,他们阅读了部分章节的初稿,在内容编排和文字上提出了很多宝贵建议,提高了本书的可读性。\n最后,感谢上海人民出版社和世纪文景的钱敏、贾忠贤、曹迪辉三位编辑老师。他们的专业素养是这本书质量的保证,他们的专业精神和对“出一本好书”的热情与执着,让我感动。\n本书的一切错漏之处都归我自己。希望读者批评指正,争取有机会再版时改正。\n上篇 微观机制 # 地方政府是经济发展中的关键一环,事务繁杂,自主权力很大。本篇第一章介绍决定地方事务范围的主要因素,这些因素不会经常变化,所以地方政府要办的事、要花的钱也不会有巨大变动。一旦收入发生大幅变动,收支矛盾就会改变政府行为。第二章介绍1994年分税制改革的前因后果。这次改革对地方政府影响深远,改变了地方政府发展经济的模式,催生了“土地财政”和“土地金融”,成为地方政府推动快速城市化和工业化的资金来源。第三章和第四章详细介绍其中的逻辑、机制、案例,同时解释地方政府的债务和风险,以及相关改革。这些内容是理解下篇宏观经济现象的微观基础。\n第一章 地方政府的权力与事务 # 中学时我听过一件事,一直记着:美国就算只把加州单拿出来,也是世界第六大经济体!当时我想,美国有50个州,那真是富强得难以想象。后来我才知道,加州的GDP占美国GDP总量的15%,是美国的第一经济大州,远超其他州。不过这种类比很好记,我现在也常在课堂上套用:广东和江苏相当于世界上第13和第14大经济体,超过西班牙和澳大利亚。山东、浙江、河南每一个单独计算都是世界前20大经济体,其中,河南仅次于荷兰。\n几年前,美国麻省理工学院的一个创业团队想进入中国市场。公司做汽车智能驾驶配件,小有规模,势头不错。两个创始人都是20多岁的小伙子,有全球眼光,想在新一轮融资时引入中国的战略投资者。当时我正为湖北省政府的投资基金做咨询,就给两人介绍了湖北总体及武汉的汽车产业情况。他们是头一次来中国,对湖北完全没概念。于是我就套用了上述类比:湖北的GDP总量与阿根廷相当,所以湖北省投资基金类似于阿根廷的主权基金。两人一听眼睛就亮了。几年时间一晃而过,2019年湖北的GDP已经接近瑞士,而阿根廷却再次陷入衰退,其GDP已不足湖北的七成。\n我国规模超大,人口、面积、经济总量都与一个大洲的体量相当,各省份的规模也大都抵得上一个中型国家,且相互之间差异极大:新疆的面积是海南的47倍;广东的人口是西藏的33倍,GDP总量是后者的62倍;北京的人均GDP是甘肃的5倍。这种经济发展水平的差异远大于美国各州。美国最富的纽约州人均GDP也不过是最穷的密西西比州的2.3倍。 11 不仅如此,我国各地风俗、地理、文化差异也大,仅方言就有上百种,治理难度可想而知。\n要理解政府治理和运作的模式,首先要了解权力和资源在政府体系中的分布规则,既包括上下级政府间的纵向分布,也包括同级政府间的横向分布。本章将介绍政府间事权划分的基本逻辑。第一节简要介绍行政体系的几个特点。第二节至第四节结合实际情况,详述事权划分三原则:外部性和受益范围原则、信息复杂性原则、激励相容原则。 22 第五节介绍地方政府的招商引资工作。发展经济是政府的核心任务,而招商引资需要调动各种资源和手段,所涉具体事务既深且广,远超主流经济学教科书中“公共服务”或“公共物品”的讨论范畴。了解招商引资,是理解地方政府深度融入经济发展过程的起点。\n第一节 政府治理的特点 # 图1-1描绘了中国的五级政府管理体系:中央—省—市—县区—乡镇。这一体系从历史上的“中央—省—郡县”三级体系演变而来。中华人民共和国成立后,在省以下设了“专区”或“地区”。20世纪50年代开始试行“以市管县”,但在改革开放之前,市的数目不足200个。随着工业化和城市化的发展,1983年开始,“以市管县”在全国推行,大多数“地区”都改成了“地级市”,城市数目大幅增加到600多个(图1-1中地级市与县级市之和)。 33 目前依然存在的“地区”,大都在地广人稀的边疆省份,面积很大,如内蒙古的锡林郭勒盟和新疆的阿克苏地区。在县乡一级,帝制时期的地方精英自治体制(所谓“皇权不下县”)随帝制瓦解而终结。民国至新中国初期,政权逐渐延伸到了县以下的乡镇和城市的街道。在乡以下的村落,则实行村民自治,因为行政能力毕竟有限,若村落也建制,那财政供养人口又要暴涨一个数量级。\n图1-1 中华人民共和国行政区划(2018年)\n注:括号中的数字为对应的行政单位数目(单位:个)。\n资料来源:民政部网站。\n现实情况当然远比简化的“五级”复杂。比如,同样都是地级市,省会城市与一般城市的政治地位与经济资源完全不同。再比如,同样都是县级单位,县级市、县、市辖区之间也有重大差别:在土地和经济事务上,县级市的权力比县大,而县的权力又比一般市辖区大。这五级体系也在不断改革,如近些年的“撤县设市”“撤县设区”“省直管县”“撤乡设镇”等。即使在省级层面上也时有重大变革,如1988年设立海南省、1997年设立重庆直辖市。而最近几年提出的“长三角一体化”“粤港澳大湾区”等国家战略,也会对现有行政区域内的权力和资源分配产生深远影响。\n我国政府体制有深厚的历史和文化渊源,且不断发展变化,非常复杂,研究专著汗牛充栋。本章结尾的“扩展阅读”会推荐几本相关读物,本节只简要介绍几个与经济发展密切相关的体制特点。\n**中央与地方政府。**央地关系历来是研究很多重大问题的主线。一方面,维持大一统的国家必然要求维护中央权威和统一领导;另一方面,中国之大又决定了政治体系的日常运作要以地方政府为主。历史上,央地间的权力平衡需要各种制度去维护,一旦失控,王朝就可能分裂甚至覆灭。小说《三国演义》以“话说天下大势,分久必合,合久必分”开头,正体现了这种平衡之难。按照历史学家葛剑雄的统计,从公元前221年秦统一六国到1911年清朝结束,我国“统一”(即基本恢复前朝疆域且保持中原地区相对太平)的时间不过950年,占这一历史阶段的45%,而分裂时间则占55%,可见维持大一统国家并不容易。 44 如今,央地关系的重要性也体现在宪法中。现行宪法的第一条和第二条规定了国体和政体,紧接着第三条便规定了央地关系的总原则:“中央和地方的国家机构职权的划分,遵循在中央的统一领导下,充分发挥地方的主动性、积极性的原则。”这是一条高度抽象和灵活的原则,之后的章节会结合具体内容来展开讨论。\n**党和政府。**中国共产党对政府的绝对领导是政治生活的主题。简单说来,党负责重大决策和人事任免,政府负责执行,但二者在组织上紧密交织、人员上高度重叠,很难严格区分。本书主题是经济发展,无须特别强调党政之分,原因有三。其一,地方经济发展依托地方政府。地方党委书记实质上依然是地方官,权力通常无法超越本地。 55 其二,制约政府间事权划分的因素,也制约着各级党委的分工。比如,信息沟通既是困扰上下级政府的难题,也是困扰上下级党委的难题。所以在讨论事权划分原理时,无须特别区分党和政府。其三,地方经济事务由政府部门推动和执行。虽然各部门都由党委领导,但地方上并无常设的专职党委机构来领导日常经济工作。假如本书主题是法制建设,那这种党政不分的分析框架就不准确,会遗漏关键党委机构的作用,比如政法委和纪委。 66 **条块分割,多重领导。**我国政治体系的一个鲜明特点是“层层复制”:中央的主要政治架构,即党委、政府、人大、政协等,省、市、县三级都完全复制,即所谓“四套班子”。中央政府的主要部委,除外交部等个别例外,在各级政府中均有对应部门,比如中央政府有财政部、省政府有财政厅、市县政府有财政局等。这种从上到下的部门垂直关系,被称为“条条”,而横向的以行政区划为界的政府,被称为“块块”。大多数地方部门都要同时接受“条条”和“块块”的双重领导。拿县教育局来说,既要接受市教育局的指导,又要服从县委、县政府的领导。通常情况下,“条条”关系是业务关系,“块块”关系才是领导关系,因为地方党委和政府可以决定人事任免。\n**上级领导与协调。**在复杂的行政体系中,权力高度分散在各部门,往往没有清晰的法律界限,所以一旦涉及跨部门或跨地区事务,办起来就比较复杂,常常理不清头绪,甚至面对相互矛盾的信息。部门之间也存在互相扯皮的问题,某件事只要有一个部门反对,就不容易办成。尤其当没有清楚的先例和流程时,办事人员会在部门之间“踢皮球”,或者干脆推给上级,所以权力与决策会自然而然向上集中。制度设计的一大任务就是要避免把过多决策推给上级,减轻上级负担,提高决策效率,所以体制内简化决策流程的原则之一,就是尽量在能达成共识的最低层级上解决问题。 77 若是部门事务,本部门领导就可以决定;若是经常性的跨部门事务,则设置上级“分管领导”甚至“领导小组”来协调推进。比如经济事务,常常需要财政、工商、税务、发改委等多部门配合,因为发展经济是核心任务,所以地方大都有分管经济的领导,级别通常较高,比如常务副市长(一般是市委常委)。\n**官僚体系。**所有规章制度都必须由人来执行和运作。同样的制度在不同的人手中,效果可能完全不同,所以无论是国家还是公司,人事制度都是组织机构的核心。我国是世界上第一个发展出完善、专业、复杂的官僚体系的国家。早在秦统一六国之前,各国就已开始通过军功和学问等渠道来吸纳人才,且官职不可继承,逐渐削弱由血缘关系决定的贵族统治体系。唐朝以后,以科举为基础、具有统一意识形态的庞大官僚体系,成为政治和社会稳定的支柱之一。 88 科举选拔出的官僚,既为政治领导,也为道德表率,不仅是政治体制的核心,也是维护国家和社会统一的文化与意识形态载体。这一体系的三大特点延续至今:官员必须学习和贯彻统一的意识形态;官员由上级任命;地方主官需要在多地轮换任职。在维持大一统的前提下,这些特点都是央地关系平衡在人事制度上的体现。\n总的来说,我国有一套立足于自身历史和文化的政治制度。像所有政治制度一样,实际的权力运作与纸面的规章制度并不完全一致,但也绝不是任性随意的。在任何体制下,权力运作都受到两种约束:做事的能力及做事的意愿。前者取决于掌握的资源,后者取决于各方的积极性和主动性。接下来我们就来讨论这些约束条件的影响。\n第二节 外部性与规模经济 # 地方政府权力的范围和边界,由行政区划决定。我国实行“属地管理”,地方事权与行政区划密不可分,所以我们先从行政区划角度来分析权力划分。影响行政区划的首要因素是“外部性”,这是个重要的经济学概念,简单来说就是人的行为影响到了别人。在公共场合抽烟,让别人吸二手烟,是负外部性;打流感疫苗,不仅自己受益,也降低了他人的感染风险,是正外部性。\n一件事情该不该由地方自主决定,可以从外部性的角度来考虑。若此事只影响本地,没有外部性,就该由本地全权处理;若还影响其他地方,那上级就该出面协调。比如市里建个小学,只招收本市学生,那市里就可以做决定。但如果本市工厂污染了其他城市,那排污就不能只由本市说了算,需要省里协调。如果污染还跨省,可能就需要中央来协调。因此行政区域大小应该跟政策影响范围一致。若因行政区域太小而导致影响外溢、需要上级协调的事情过多,本级政府也就失去了存在的意义。反过来讲,行政区划也限定了地方可调配的资源,限制了其政策的影响范围。\n公共物品和服务的边界 # 按照经典经济学的看法,政府的核心职能是提供公共物品和公共服务,比如国防和公园。这类物品一旦生产出来,大家都能用,用的人越多就越划算——因为建造和维护成本也分摊得越薄,这就是“规模经济”。但绝大部分公共物品只能服务有限人群。一个公园虽然免费,但人太多就会拥挤,服务质量会下降,且住得远的人来往不便,所以公园不能只建一个。一个城市总要划分成不同的区县,而行政边界的划分跟公共服务影响范围有关。一方面,因为规模经济,覆盖的人越多越划算,政区越大越好;另一方面,受制于人们获取这些服务的代价和意愿,政区不能无限扩大。 99 这道理看似不起眼,但可以帮助我们理解很多现象,小到学区划分、大到国家规模。比如,古代王朝搞军事扩张,朝廷就要考虑扩张的限度。即便有实力,是否就越大越好?政府职能会不会鞭长莫及?边远地区的人是否容易教化和统治?汉武帝时武功极盛,但对国家资源的消耗也大。等到其子昭帝继位,便召开了历史上著名的“盐铁会议”,辩论武帝时的种种国策,其会议记录就是著名的《盐铁论》。其中《地广第十六》中就有关于国土扩张的辩论,反对方说:“秦之用兵,可谓极矣,蒙恬斥境,可谓远矣。今踰蒙恬之塞,立郡县寇虏之地,地弥远而民滋劳……张骞通殊远,纳无用,府库之藏,流于外国……”意思是边远之地物产没什么用,人也野蛮,而且那么远,制度也不容易实施,实在没必要扩张。这些话颇有道理,支持方不易反驳,于是就开始了人身攻击。 1010 其实按照我们的理论,人身攻击大可不必,若想支持扩张,多说说规模经济的好处便是。美国独立战争结束后,13个州需要决定是否建立一个中央联邦政府。反对的人不少。毕竟刚打跑了英国主子,何必马上给自己立个新主子?所以赞同的人就得想办法说服民众,宣传联邦的好处,他们写了不少文章,这些小文章后来就成了美国的国民经典《联邦党人文集》。其中编号第13的文章出自汉密尔顿之手,正是讲一个大政府比13个小政府更省钱的道理,也就是规模经济。\n政府公共服务的覆盖范围也与技术和基础设施有关。比如《新闻联播》,是不是所有人都有电视或网络可以收看?是不是所有人都能听懂普通话?是不是所有人的教育水平都能听懂基本内容?这些硬件和软件的基础非常重要。所以秦统一六国后,立刻就进行了“车同轨、书同文”以及统一货币和度量衡的改革。\n以公共物品的规模经济和边界为切入点,也可以帮助理解中央和地方政府在分工上的一些差异。比如国防支出几乎全部归中央负担,因为国防体系覆盖全体国民,不能遗漏任何一个省。而中小学教育受制于校舍和老师等条件,规模经济较小,主要覆盖当地人,所以硬件和教师支出大都归地方负担。但教材内容却不受物理条件限制,而且外部性极强。如果大家都背诵李白、杜甫、司马迁的作品,不仅能提高自身素养,而且有助于彼此沟通,形成共同的国民意识,在一些基本问题和态度上达成共识。所以教育的日常支出虽由地方负责,但教材编制却由中央主导,教育部投入了很多资源。2019年底,教育部印发《中小学教材管理办法》,加强了国家统筹,对思想政治(道德与法治)、语文、历史课程教材,实行国家统一编写、统一审核、统一使用。\n假如各个市、各个县所提供的公共服务性质和内容都差不多,基础设施水平也没什么差异,那各地的行政区划面积是不是就该相等呢?当然也不是,还要取决于影响公共服务效果的其他因素。\n人口密度、地理与文化差异 # 第一个重要因素是人口密度。我国幅员辽阔,但人口分布极不平衡。如果从黑龙江的瑷珲(今黑河市南)到云南的腾冲之间画一条直线,把国土面积一分为二,东边占了43%的面积却住了94%的人口,而西边占了57%的面积却只住了6%的人口。 1111 西边人口密度比东边低得多,行政单位面积自然就大得多。面积最大的四个省级单位(新疆、西藏、内蒙古、青海)都在西边,合计占国土面积的一半。新疆有些地区的面积比东部一个省的面积还要大,但人口却尚不及东部一个县多。 1212 按人口密度划分行政区域的思路非常自然。提供公共物品和服务需要成本,人多,不仅税收收入多,而且成本能摊薄,实现规模收益。人口稠密的地方,在比较小的范围内就可以服务足够多的人,实现规模收益,因此行政区域面积可以小一些;而地广人稀的地方,行政区域就该大一些。中国历代最重要的基层单位是县,而县域的划分要依据人口密度,这是早在秦汉时期就定下的基本规则之一,所谓“民稠则减,稀则旷”(《汉书·百官公卿表》)。随着人口密度的增加,行政区域的面积应该越变越小,数目则应该越变越多。所以随着古代经济中心和人口从北方转移到南方,行政区划也就慢慢从“北密南稀”变成了“南密北稀”。以江西为例,西汉时辖19县,唐朝变成34县,南宋时更成为粮食主产区,达到68县,清朝进一步变成81县。 1313 第二个重要因素是地理条件。古代交通不便,山川河流也就成了行政管理的自然边界,历史地理学家称之为“随山川形变”,由唐朝开国后提出:“然天下初定,权置州郡颇多,太宗元年,始命并省,又因山川形便,分天下为十道”(《新唐书·地理志》)。所谓“十道”,基本沿长江、黄河、秦岭等自然边界划分。唐后期演化为40余方镇,很多也以山川为界,比如江西和湖南就以罗霄山脉为界,延续至今。现今省界中仍有不少自然边界:海南自不必说,山西、陕西以黄河为界,四川、云南、西藏则以长江(金沙江)为界,湖北、重庆以巫山为界,广东、广西则共属岭南。\n第三个重要因素是语言文化差异。汉语的方言间有差异,汉语与少数民族语言也有差异。若语言不通,政务管理或公共服务可能就需要差异化,成本会因此增加,规模收益降低,从而影响行政区域划分。当然,语言差异和地理差异高度相关。方言之形成,多因山川阻隔而交流有限。世界范围内,一国若地形或适耕土地分布变异大,人口分布就比较分散,国内的语言变异往往也就更丰富。 1414 我国各省间方言不同,影响了省界划分。而省内市县之间,口音也常有差异,这影响了省内的行政区划。浙江以吴语方言复杂多变闻名,而吴语方言的分布与省内地市的划分高度重合:台州属于吴语台州片,温州属于瓯江片,金华则属于婺州片。同一市内,语言文化分布也会影响到区县划分。杭州下辖的淳安和建德两县,属于皖南的徽文化和徽语区,而其他县及杭州市区,则属于吴语区的太湖片。感兴趣的读者可以对比行政区划地图与《中国语言地图集》,非常有意思。\n理解了这些因素,就能理解很多政策和改革。比如,随着经济活动和人口集聚,需要打破现有的行政边界,在更大范围内提供无缝对接的标准化公共服务,所以就有了各种都市圈的规划,有些甚至上升到了国家战略,比如长三角一体化、京津冀一体化、粤港澳大湾区等。再比如,地理阻隔不利沟通,但随着基础设施互联互通,行政区划也可以简化,比如撤县设区。此外,理解了方言和文化的多样性,也就理解了推广普通话和共同的文化历史教育对维护国家统一的重要性。\n当然,无论是人口密度、地理还是语言文化,都只是为理解行政区划勾勒了一个大致框架,无法涵盖所有复杂情况。其一,人口密度变化频繁,但行政区域的调整要缓慢得多。虽然一些人口流入地可以“撤县建区”来扩张城市,但人口流出地却很少因人口减少去裁撤行政单位,一般只是合并一些公共设施来降低成本,比如撤并农村中小学。其二,古代行政区划除“随山川形变”外,也遵循“犬牙交错”原则,即为了政治稳定需要,人为打破自然边界,不以天险为界来划分行政区,防止地方势力依天险制造分裂。元朝在这方面走了极端,设立的行省面积极大,几乎将主要天险完全消融在各行省内部,但效果并不好。其三,方言与文化区域经常被行政区划割裂。比如客家话虽是主要方言,但整个客家话大区被江西、福建、广东三省分割。再比如有名的苏南、苏北之分:苏州、无锡、常州本和浙江一样同属吴语区,却与讲江淮官话的苏北一道被划进了江苏省。\n行政交界地区的经济发展 # 我国经济中有个现象:处在行政交界(尤其是省交界处)的地区,经济发展普遍比较落后。省级的陆路交界线共66条,总长度5.2万公里,按边界两侧各15公里计算,总面积约156万平方公里,占国土面积的六分之一。然而,在2012年592个国家扶贫开发工作重点县中,却有超过一半位于省交界处,贫困发生率远高于非边界县。 1515 这一俗称“三不管地带”的现象,也可以用公共物品规模效应和边界的理论来解释。首先,一省之内以省会为政治经济中心,人口最为密集,公共物品的规模经济效应最为显著。但几乎所有省会(除南京和西宁外)无一临近省边界,这种地理距离限制了边界地区获取公共资源。其次,省边界的划分与地理条件相关。诸多省界县位于山区,坡度平均要比非省界县高35%,不利于经济发展,比如山西、河北边界的太行山区,江西、福建边界的武夷山区,湖北、河南、安徽边界的大别山区等。再次,省界划分虽与方言和地方文化有关,但并不完全重合。一省之内主流文化一般集中在省会周围,而省界地区往往是本省的非主流文化区,其方言也有可能与主流不同。比如江西、福建、广东交界处的客家话区,与三省主流的赣语、闽语、粤语都不相同。再比如安徽北部,基本属于河南、山东一脉的中原官话区,与省内主流的江淮官话不同。这些边界地区,在本省之内与主流文化隔阂,而与邻省同文化区的交流又被行政边界割裂,不利于经济发展。 1616 这些因素在民国时期已存在,所以“三不管地带”才为革命时期的中国共产党提供了广阔空间。家喻户晓的革命圣地井冈山,就位于湖南、江西交界处的罗霄山脉之中。其他很多著名的革命根据地也在省界处,比如陕甘宁边区、晋察冀边区、鄂豫皖边区、湘鄂赣边区等。红军长征中非常重要的“四渡赤水”,就发生在川黔滇边界的赤水河地区。 1717 从公共物品角度看,边界地区首先面临的是基础设施如道路网络的不足。20世纪八九十年代,省边界处的“断头路”并不罕见。1992年我从内蒙古乘车到北京,途经山西和河北,本来好好的路,到了省界处路况就变差,常常要绕小道。若是晚间,还有可能遇到“路霸”。即使到了2012年,路网交通中的“边界效应”(省界地区路网密度较低)依然存在,虽然比以前改善了很多。即使在排除了经济发展、人口密度、地形等因素之后,“边界效应”也还是存在的,不过只限于由省政府投资的高速公路和省道中,在由中央政府投资的国道和铁路中则不存在,可见省政府不会把有限的资源优先配置到边界地区。 1818 随着经济发展和我国基础设施建设的突飞猛进,如今省界处的交通已不再是大问题。\n另一个曾长期困扰边界公共治理的问题是环境污染,尤其是跨省的大江、大河、大湖,比如淮河、黄河、太湖等流域的污染。这是典型的跨区域外部性问题。直到中央在2003年提出“科学发展观”,并且在“十五”和“十一五”规划中明确了降低水污染的具体目标之后,水质才开始显著改善。但省界处的问题依然没有完全解决。一些省份把水污染严重的企业集中到了本省边缘的下游区域,虽然本省的平均污染水平降低了,下游省份的污染却加重了。 1919 跨区域外部性问题可以通过跨区域的共同上级来协调,这也是为什么行政区域不仅要做横向划分,也要做纵向的上下级划分。下级之间一旦出现了互相影响、难以单独决断的事务,就要诉诸上级决策。反过来看,各级政府的权力都是由上级赋予的,而下放哪些权力也和外部性有关。在外部性较小的事务上,下级一般会有更大决策权。虽然从原则上说,上级可以干预下级的所有事务,但在现实工作中,干预与否、干预到什么程度、能否达到干预效果,都受制于公共事务的外部性大小、规模经济、跨地区协调的难度等。\n行政边界影响经济发展,地方保护主义和市场分割现象今天依然存在,尤其在生产要素市场上,用地指标和户籍制度对土地和人口流动影响很大。从长期看,消除这种现象需要更深入的市场化改革。但在中短期内,调整行政区划、扩大城市规模乃至建设都市圈也能发挥作用。目前的行政区划继承自古代社会和计划经济时期,并不能完全适应工业与现代服务业急速的发展和集聚。而且在像中国这样一个地区差异极大的大国,建设产品和要素的全国统一大市场必然是个长期过程,难免要先经过区域性整合。\n区域性整合的基本单位是城市,但在城市内部,首先要整合城乡。在市管县体制下,随着城市化的发展,以工业和服务业为经济支柱的市区和以农业为主的县城之间,对公共服务需求的差别会越来越大。调和不同需求、利用好有限的公共资源,就成了一大难题。改革思路有二:一是加强县的独立性和自主性,弱化其与市区的联系。第二章将展开讨论这方面的改革,包括扩权强县、撤县设市、省直管县等。二是扩张城市,撤县设区。1983—2015年,共有92个地级市撤并了134个县或县级市。 2020 比如北京市原来就8个区,现在是16个,后来的8个都是由县改区,如通州区(原通县)和房山区(原房山县)。上海现有16个市辖区,青浦、奉贤、松江、金山等区也是撤县设区改革的结果。\n撤县设区扩张了城市面积,整合了本地人口,将县城很多农民转化为了市民,有利于充分利用已有的公共服务,发挥规模收益。很多撤县设区的城市还吸引了更多外来人口。 2121 这些新增人口扩大了市场规模,刺激了经济发展。撤县设区也整合了对城市发展至关重要的土地资源。随着区县合并,市郊县的大批农村土地被转为城市建设用地,为经济发展提供了更大空间。但在这个过程中,由于城乡土地制度大不相同,产生了很多矛盾和冲突,之后章节会详细讨论。\n第三节 复杂信息 # 中国有句老话叫“山高皇帝远”,常用来形容本地当权者恣意妄为、肆无忌惮,因为朝廷不知情,也就管不了,可见信息对权力的影响。行之有效的管理,必然要求掌握关键信息。然而信息复杂多变,持续地收集和分析信息需要投入大量资源,代价不小。所以有信息优势的一方,或者说能以更低代价获取信息的一方,自然就有决策优势。\n信息与权力 # 我国政府各层级之间的职能基本同构,上级领导下级。原则上,上级对下级的各项工作都有最终决策权,可以推翻下级所有决定。但上级不可能掌握和处理所有信息,所以很多事务实际上由下级全权处理。即使上级想干预,常常也不得不依赖下级提供的信息。比如上级视察工作,都要听取下级汇报,内容是否可靠,上级不见得知道。如果上级没有独立的信息来源,就可能被下级牵着鼻子走。\n所以上级虽然名义上有最终决定权,拥有“形式权威”,但由于信息复杂、不易处理,下级实际上自主性很大,拥有“实际权威”。维护两类权威的平衡是政府有效运作的关键。若下级有明显信息优势,且承担主要后果,那就该自主决策。若下级虽有信息优势,但决策后果对上级很重要,上级就可能多干预。但上级干预可能会降低下级的工作积极性,结果不一定对上级更有利。 2222 以国企改革为例。一家国企该由哪一级政府来监管?该是央企、省属国企,还是市属国企?虽然政府名义上既管辖本级国企,也管辖下级国企,但下级国企实际上主要由下级政府管辖。在国企分级改革中,获取信息的难易程度是重要的影响因素。如果企业离上级政府很远,交通不便,且企业间差异又很大,上级政府就很难有效处理相关信息,所以更可能下放管辖权。但如果企业有战略意义,对上级很重要,那无论地理位置如何,都由上级管辖。 2323 在实际工作中,“上级干预”和“下级自主”之间,没有黑白分明的区别,是个程度问题。工作总要下级来做,不可能没有一点自主性;下级也总要接受上级的监督和评价,不可能完全不理上级意见。但无论如何,信息优势始终是权力运作的关键要素。下级通常有信息优势,所以如果下级想办某件事,只要上级不明确反对,一般都能办,即使上级反对也可以变通着干,所谓“县官不如现管”;如果下级不想办某事,就可以拖一拖,或者干脆把皮球踢给上级,频繁请示,让没有信息优势的上级来面对决策的困难和风险,最终很可能就不了了之。即使是上级明确交代的事情,如果下级不想办,那办事的效果也会有很大的弹性,所谓“上有政策,下有对策”。\n实际权威来自信息优势,这一逻辑也适用于单位内部。单位领导虽有形式权威和最终决策权,但具体工作大都要求专业知识和经验,所以专职办事的人员实际权力很大。比如古代的官和吏,区别很大。唐朝以后,“官”基本都是科举出身的读书人,下派到地方任职几年,大多根本不熟悉地方事务,所以日常工作主要依靠当地的“吏”。这些生于斯长于斯的吏,实际权力大得很,是地方治理的支柱,不但不受官员调动的影响,甚至不受改朝换代的影响。清人朱克敬《瞑庵杂识》中有一位吏的自我定位如下:“凡属事者如客,部署如车,我辈如御,堂司官如骡,鞭之左右而已。”意思是说衙门就像车,来办事就像坐车,当官的是骡子,我们才是车把式,决定车的方向。 2424 信息复杂性和权力分配是个普遍性的问题,不是中国特色。在各国政府中,资深技术官僚都有信息优势,在诸多事务上比频繁更换的领导实权更大。比如英国的内阁部门长官随内阁选举换来换去,而各部中工作多年的常务次官(permanent secretary)往往更有实权。著名的英国政治喜剧《是,大臣》(Yes, Minister)正是讲述新上任的大臣被常务次官耍得团团转的故事。\n上节讨论过的限制公共服务范围的诸多因素,如人口密度、地理屏障、方言等,也可以视作收集信息的障碍。因此信息不仅可以帮助理解上下级的分权,也可以帮助理解平级间的分权。行政区划,不仅受公共服务规模经济的影响,也受获取信息比较优势的影响。\n信息获取与隐瞒 # 获取和传递信息需要花费大量时间精力,上级要不断向下传达,下级要不断向上汇报,平级要不断沟通,所以体制内工作的一大特点就是“文山会海”。作为信息载体的文件和会议也成了权力的载体之一,而一套复杂的文件和会议制度就成了权力运作不可或缺的部分。\n我国政府上下级之间与各部门之间的事权,大都没有明确的法律划分,主要依赖内部规章制度,也即各类文件。为了减少信息传递的失真和偏误,降低传递成本,文件类型有严格的区分,格式有严格的规范,报送有严格的流程。按照国务院2012年最新的《党政机关公文处理工作条例》(以下简称《条例》),公文共分15种,既有需要下级严格执行的“决定”和“命令”,也有可以相对灵活处理的“意见”和“通知”,还有信息含量较低的“函”和“纪要”等。每种公文的发文机关、主送机关、紧急程度以及密级,都有严格规定。为了防止信息泛滥,公文的发起和报送要遵循严格的流程。比如说,《条例》规定,“涉及多个部门职权范围内的事务,部门之间未协商一致的,不得向下行文”,这也是为了减少产生无法落实的空头文件。\n会议制度也很复杂。什么事项该上什么会,召集谁来开会,会议是以讨论为主还是需要做出决定,这些事项在各级政府中都有相应的制度。比如在中央层面,就有中央政治局常委会会议、中央政治局会议、中央工作会议、中央委员会全体会议、党的全国代表大会等。\n因为关键信息可能产生重大实际影响,所以也可能被利益相关方有意扭曲和隐瞒,比如地方的GDP数字。政府以经济建设为中心,国务院每年都有GDP增长目标,所以GDP增长率的高低也是衡量地方官员政绩的重要指标。 2525 绝大部分省份公布的增长目标都会高于中央,而绝大多数地市的增长目标又会高于本省。比如2014年中央提出的增长目标是7.5%,但所有省设定的目标均高于7.5%,平均值是9.7%。到了市一级,将近九成的市级目标高于本省,平均值上涨到10.6%。 2626 这种“层层加码”现象的背后,既有上级层层施压和摊派的因素,也有下级为争取表现而主动加压的因素。但这些目标真能实现么?2017—2018年两年,不少省份(如辽宁、内蒙古、天津等)主动给GDP数字“挤水分”,幅度惊人,屡见报端。\n因为下级可能扭曲和隐瞒信息,所以上级的监督和审计就非常必要,既要巡视督察工作,也要监督审查官员。但监督机制本身也受信息的制约。我举两个例子,第一个是国家土地督察制度。城市化过程中土地价值飙升,违法现象(越权批地、非法占用耕地等)层出不穷,且违法主体很多是地方政府或相关机构,其下属的土地管理部门根本无力防范和惩处。2006年,中央建立国家土地督察制度,在国土资源部(现改为自然资源部)设立国家土地总督察(现改为国家自然资源总督察),并向地方派驻国家土地监督局(现改为国家自然资源督察局)。这一督察机制总体上遏制了土地违法现象。但中央派驻地方的督察局只有9个,在督察局所驻城市,对土地违法的震慑和查处效果比其他城市更强,这种明显的“驻地效应”折射出督察机制受当地信息制约之影响。 2727 第二个例子是水污染治理。与GDP数字相比,水污染指标要简单得多,收集信息也不复杂,所以中央环保部门早在20世纪90年代就建立了“国家地表水环境监测系统”,在各主要河流和湖泊上设置了水质自动监测站,数据直报中央。但在20世纪90年代,经济发展目标远比环保重要,所以这些数据主要用于科研而非环保监督。2003年,中央提出“科学发展观”,并且在“十五”和“十一五”规划中明确了降低水污染的具体目标,地方必须保证达标。虽然数据直报系统杜绝了数据修改,但并不能完全消除信息扭曲。一个监测站只能监测上游下来的水,监测不到本站下游的水,所以地方政府只要重点降低监测站上游的企业排污,就可以改善上报的污染数据。结果与监测站下游的企业相比,上游企业的排放减少了近六成。虽然总体污染水平降低了,但污染的分布并不合理,上游企业承担了过度的环保成本,可能在短期内降低了其总体效益。 2828 正因为信息复杂多变,模糊不清的地方太多,而政府的繁杂事权又没有清楚的法律界定,所以体制内的实际权力和责任都高度个人化。我打个比方来说明规则模糊不清和权力个人化之间的关系。大学老师考核学生一般有两种方式:考试或写论文。若考卷都是标准化的选择题,那老师虽有出题的权力,但不能决定最后得分。但若考卷都是主观题,老师给分的自由度和权力就大一些。若是研究生毕业论文,不存在严格的客观判断标准,导师手中的权力就更大了,所以研究生称导师为“老板”,而不会称其他授课教师为“老板”。\n如果一件事的方方面面都非常清楚,有客观评价的标准,那权力分配就非常简单:参与各方立个约,权责利都协商清楚,照办即可。就像选择题的答题卡一样,机器批阅,没有模糊空间,学生考100分就是100分,老师即使不喜欢也没有办法。但大多数事情都不可能如此简单清楚,千头万绪的政府工作尤其如此:一件事该不该做?要做到什么程度?怎么样算做得好?做好了算谁的功劳?做砸了由谁负责?这些问题往往没有清楚的标准。一旦说不清楚,谁说了算?所谓权力,实质就是在说不清楚的情况下由谁来拍板决策的问题。 2929 如果这种说不清的情况很多,权力就一定会向个人集中,这也是各地区、各部门“一把手负责制”的根源之一,这种权力的自然集中可能会造成专权和腐败。\n因为信息复杂,不可信的信息比比皆是,而权力和责任又高度个人化,所以体制内的规章制度无法完全取代个人信任。上级在提拔下级时,除考虑工作能力外,关键岗位上都要尽量安排信得过的人。\n第四节 激励相容 # 如果一方想做的事,另一方既有意愿也有能力做好,就叫激励相容。政府内部不仅要求上下级间激励相容,也要求工作目标和官员自身利益之间激励相容。本节只讨论前者,第三章再讨论官员的激励。\n上级政府想做的事大概分两类,一类比较具体,规则和流程相对明确,成果也比较容易衡量和评价。另一类比较抽象和宽泛,比如经济增长和稳定就业,上级往往只有大致目标,需要下级发挥主动性和创造性调动资源去达成。对于这两类事务,事权划分是不同的。\n垂直管理 # 在专业性强、标准化程度高的部门,具体而明确的事务更多,更倾向于垂直化领导和管理。比如海关,主要受上级海关的垂直领导,所在地政府的影响力较小。这种权力划分符合激励相容原则:工作主要由系统内的上级安排,所以绩效也主要由上级评价,而无论是职业升迁还是日常福利,也都来自系统内部。\n还有一些部门,虽然工作性质也比较专业,但与地方经济密不可分,很多工作需要本地配合,如果完全实行垂直管理可能会有问题。比如工商局,在1999年的改革中,“人财物”收归省级工商部门统管,初衷是为了减少地方政府对工商部门的干扰,打破地方保护,促进统一市场形成。但随着市场经济的蓬勃发展和多元化,工商局的行政手段的效力一直在减弱,而垂直管理带来的激励不相容问题也越来越严重。工商工作与所在地区密不可分,但因为垂直管理,当地政府对工商系统的监督和约束都没有力度。在一系列事故尤其是2008年震动全国的“毒奶粉”事件之后,2011年中央再次改革,恢复省级以下工商部门的地方政府分级管理体制,经费和编制由地方负担,干部升迁改为地方与上级工商部门双重管理,以地方管理为主。 3030 2018年机构改革后,工商局并入市场监督管理局,由地方政府分级管理。\n所有面临双重领导的部门,都有一个根本的激励机制设计问题:到底谁是主要领导?工作应该向谁负责?假如所有领导的目标和利益都一样,激励机制就不重要。在计划经济时代,部门间没什么大的利益冲突,所以对干部进行意识形态教化相对有效,既能形成约束,也有利于交流和推进工作。但在市场经济改革之后,利益不仅大了,而且多元化了,部门之间、上下级之间的利益冲突时有发生,“统一思想”和“大局观”虽依然重要,但只讲这些就不够了,需要更加精细的激励机制。最起码,能评价和奖惩工作业绩的上级,能决定工作内容的上级,受下级工作影响最大的上级,应该尽量是同一上级。\n当上下级有冲突的时候,改革整个部门的管理体制只是解决方式之一,有时“微调”手段也很有效。拿环保来说,在很长一段时间内,上级虽重视环境质量,但下级担心环保对经济发展的负面影响。上下级间的激励不相容,导致政策推行不力,环境质量恶化。 3131 但随着技术进步,中央可以直接监控污染企业。2007年,国家环保总局把一些重污染企业纳入国家重点监控企业名单,包括3 115家废水排放企业,3 592家废气排放企业,以及658家污水处理厂。这些企业都要安装一套系统,自动记录实时排放数据并直接传送到国家环保监控网络。这套技术系统限制了数据造假,加强了监管效果,大幅降低了污染,但没有从根本上改变环保管理体制,日常执法依然由地方环保部门负责。 3232 随着中央越来越重视环保,跨地区协调的工作也越来越多,环保部门的权力也开始上收。2016年,省级以下环保机构调整为以省环保厅垂直领导为主,所在地政府的影响大大降低。这次调整吸取了工商行政管理体制改革中的一些教训。比如在工商部门垂直领导时期,不仅市级领导干部由省里负责,市级以下的领导也基本由省里负责,这就不利于市县上下级的沟通和制约。所以在环保体制改革中,县环保局调整为市局的派出分局,由市局直接管理,领导班子也由市局任免。 3333 地方管理 # 对于更宏观的工作,比如发展经济,涉及方方面面,需要地方调动各种资源。激励相容原则要求给地方放权:不仅要让地方负责,也要与地方分享发展成果;不仅要能激励地方努力做好,还要能约束地方不要搞砸,也不要努力过头。做任何事都有代价,最优的结果是让效果和代价匹配,而不是不计代价地达成目标。若不加约束,地方政府要实现短期经济高速增长目标并不难,可以尽情挥霍手中的资源,大肆借债、寅吃卯粮来推高增长数字,但这种结果显然不是最优的。\n激励相容原则首先要求明确地方的权利和责任。我国事权划分的一大特点是“属地管理”:一个地区谁主管谁负责,以行政区划为权责边界。这跟苏联式计划经济从上到下、以中央部委为主调动资源的方式不同。属地管理兼顾了公共服务边界问题和信息优势问题,同时也给了地方政府很大的权力,有利于调动其积极性。1956年,毛泽东在著名的《论十大关系》中论述“中央和地方的关系”时就提到了这一点:“我们的国家这样大,人口这样多,情况这样复杂,有中央和地方两个积极性,比只有一个积极性好得多。我们不能像苏联那样,把什么都集中到中央,把地方卡得死死的,一点机动权也没有。”\n其次是权力和资源的配置要制度化,不能朝令夕改。无论对上级还是对下级,制度都要可信,才能形成明确的预期。制度建设,一方面是靠行政体制改革(比如前文中的工商和环保部门改革)和法制建设,另一方面是靠财政体制改革。明确了收入和支出的划分,也就约束了谁能调用多少资源,不能花过头的钱,也不能随意借债,让预算约束“硬”起来。\n来自外部的竞争也可以约束地方政府。如果生产要素(人、财、物)自由流动,“用脚投票”,做得不好的地方就无法吸引资金和人才。虽然地方政府不是企业,不至于破产倒闭,但减少低效政府手中的资源,也可以提高整体效率。\n小结:事权划分三大原则 # 第二至第四节讨论了事权划分的三大原则:公共服务的规模经济、信息复杂性、激励相容。这三种视角从不同角度出发,揭示现象的不同侧面,但现象仍然是同一个现象,所以这三种视角并不冲突。比如行政区划,既与公共服务的规模有关,也和信息管理的复杂性有关,同时又为激励机制设定了权责边界。再比如基础设施建设,既能扩展公共服务的服务范围,又能提高信息沟通效率,还可以方便人、财、物流通,增强各地对资源的竞争,激励地方励精图治。\n三大原则的共同主题是处理不同群体的利益差别与冲突。从公共服务覆盖范围角度看,不同人对公共服务的评价不同,享受该服务的代价不同,所以要划分不同的行政区域。从信息复杂性角度看,掌握不同信息的人,看法和判断不同,要把决策权交给占据信息优势的一方。从激励相容角度看,上下级的目标和能力不同,所以要设立有效的机制去激励下级完成上级的目标。假如不同群体间完全没有差别和冲突,那事权如何划分就不重要,对结果影响不大。完全没有冲突当然不可能,但如果能让各个群体对利益和代价的看法趋同,也能消解很多矛盾,增强互信。所以国家对其公民都有基本的共同价值观教育,包括历史教育和国家观念教育。而对官员群体,我国自古以来就重视共同价值观的培养与教化,今天依然如此。\n上述三个原则虽不足以涵盖现实中所有的复杂情况,但可以为理解事权划分勾勒一个大致框架,帮助我们理解目前事权改革的方向。2013年,党的十八届三中全会通过了《中共中央关于全面深化改革若干重大问题的决定》,其中对事权改革方向的阐述就非常符合这些原则:“适度加强中央事权和支出责任,国防、外交、国家安全、关系全国统一市场规则和管理等作为中央事权;部分社会保障、跨区域重大项目建设维护等作为中央和地方共同事权,逐步理顺事权关系;区域性公共服务作为地方事权。” 3434 2016年,《国务院关于推进中央与地方财政事权和支出责任划分改革的指导意见》发布,将十八届三中全会的决定进一步细化,从中可以更清楚地看到本章讨论的三大原则:“要逐步将国防、外交、国家安全、出入境管理、国防公路、国界河湖治理、全国性重大传染病防治、全国性大通道、全国性战略性自然资源使用和保护等基本公共服务确定或上划为中央的财政事权……要逐步将社会治安、市政交通、农村公路、城乡社区事务等受益范围地域性强、信息较为复杂且主要与当地居民密切相关的基本公共服务确定为地方的财政事权……要逐步将义务教育、高等教育、科技研发、公共文化、基本养老保险、基本医疗和公共卫生、城乡居民基本医疗保险、就业、粮食安全、跨省(区、市)重大基础设施项目建设和环境保护与治理等体现中央战略意图、跨省(区、市)且具有地域管理信息优势的基本公共服务确定为中央与地方共同财政事权,并明确各承担主体的职责。”\n既然是改革的方向,也就意味着目前尚有诸多不完善之处。比如涉及国防和国家安全的事务,原则上都应该主要或完全由中央负责,但国际界河(主要在东北和西南)和海域的管理与治理目前仍主要由地方负责。再比如养老和医疗保险,对形成全国统一的劳动力市场非常重要,应由中央为主管辖,但目前的管理相当碎片化。而对于本该属于地方的事权,中央虽应保留介入的权力,但过分介入往往会造成地方退出甚至完全放手,效果不一定好。如何从制度上限制过度介入,真正理顺事权关系,也需要进一步改革。\n第五节 招商引资 # 地方政府的权力非常广泛。就发展经济而言,其所能调动的资源和采取的行动远远超过主流经济学强调的“公共服务”或“公共物品”范围。地方政府不仅可以为经济发展创造环境,它本身就是经济发展的深度参与者,这一点在招商引资过程中体现得淋漓尽致。招商引资不仅是招商局的部门职能,也是以经济建设为中心的地方政府的核心任务,是需要调动所有资源和手段去实现的目标。很多地方政府都采用“全民招商”策略,即几乎所有部门(包括教育和卫生部门)都要熟悉本地招商政策,要在工作和社交中注意招商机会。\n要招商,就要有工业园区或产业园区,这涉及土地开发、产业规划、项目运作等一系列工作,第二章至第四章会详细解释。这里只要了解:地方政府是城市土地的所有者,为了招商引资发展经济,会把工业用地以非常优惠的价格转让给企业使用,并负责对土地进行一系列初期开发,比如“七通一平”(通电、通路、通暖、通气、给水、排水、通信,以及平整场地)。\n对于规模较大的企业,地方通常会给予很多金融支持。比如以政府控制的投资平台入股,调动本地国企参与投资,通过各种方式协助企业获得银行贷款,等等。对一些业务比较复杂、所在行业管制较严的企业,地方也会提供法律和政策协助。比如一些新能源汽车企业,并没有生产汽车的牌照,而要获取牌照(无论是新发,还是收购已有牌照)很不容易,需要和工信部、发改委等中央部门打交道,这其中企业的很多工作都有地方政府的协助。与企业相比,地方政府更加熟悉部委人脉和流程。再比如近年兴起的网络安全和通信服务行业,都受国家管制,需要地方协助企业去获得各类许可。还有些行业对外商投资有准入限制,也需要地方政府去做很多协助落地的工作。\n地方政府还可以为企业提供补贴和税收优惠。补贴方式五花八门,比如研发补贴和出口补贴等。常见的税收优惠如企业所得税的“三免三减半”,即对新开业企业头三年免征所得税,之后三年减半征收。 3535 还有一些针对个人的税收优惠政策。比如对于规模很大的企业,地方政府常常对部分高管的个人收入所得税进行返还。我国高收入人群的所得税边际税率很高,年收入超过96万元的部分税率是45%,所以税收返还对高管个人来说有一定吸引力。对企业高管或特殊人才,若有需要,地方政府也会帮助安排子女入学、家人就医等。\n创造就业是地方经济工作的重点,也是维护社会稳定不可或缺的条件。对新设的大中型企业,地方政府会提供很多招工服务,比如协助建设职工宿舍、提供公共交通服务等。大多数城市还对高学历人才实行生活或住房补贴。\n总的来说,对企业至关重要的生产要素,地方政府几乎都有很强的干预能力。其中土地直接归政府所有,资金则大多来自国有银行主导的金融体系和政府控制的其他渠道,比如国有投融资平台。对于劳动力,政府控制着户口,也掌握着教育和医疗等基本服务的供给,还掌握着土地供应,直接影响住房分配。而生产中的科技投入,也有相当大一部分来自公立大学和科研院所。除此之外,地方政府还有财税政策、产业政策、进出口政策等工具,都可能对企业产生重大影响。\n这种“混合经济”体系,不是主流经济学教科书中所说的政府和市场的简单分工模式,即政府负责提供公共物品、市场主导其他资源配置;也不是简单的“政府搭台企业唱戏”模式。而是政府及其各类附属机构(国企、事业单位、大银行等)深度参与大多数生产和分配环节的模式。在我国,想脱离政府来了解经济,是不可能的。\n结语 # 本章讨论了事权划分的三种理论:公共服务的规模经济与边界、信息复杂性、激励相容。这些理论为理解政府职能分工勾勒了一个大致框架,虽各有侧重,但彼此相通。社会科学的理论,刻意追求标新立异没有意义。社会现象非常复杂,单一理论只能启示某个侧面,要从不同理论中看到共同之处,方能融会贯通。\n地方政府不止提供公共服务,也深度参与生产和分配。其间得失,之后的章节会结合具体情况展开讨论。若无视这种现实、直接套用主流经济学中“有限政府”的逻辑,容易在分析中国问题时产生扭曲和误解。不能脱离政府来谈经济,是理解中国经济的基本出发点。实事求是了解现状,才能依托现实提炼理论,避免用理论曲解现实,也才能真正深入思考政府在几十年来经济发展过程中扮演的角色。\n本章讨论的事权划分,是理解政府间资源分配的基础。决定了干哪些事,才能决定用哪些资源。所以下一章所讨论的政府财权和财力的划分,以本章的事权划分为基础。财权领域虽改革频频,但事权划分却相对稳定,因为其决定因素也相对稳定:地理和语言文化边界长期稳定,信息和激励问题也一直存在。\n扩展阅读 # 关于中国政府和政治,美国布鲁金斯学会资深专家李侃如的著作《治理中国:从革命到改革》(2010)是很好的入门读物。这本书介绍了中国政治的基本历史遗产及现状,阐释了其演变逻辑,可读性很强。但该书成书于1995年(英文原名为Governing China: From Revolution Through Reform),修订于2003年(2010年是中译本的出版年份),没有涉及最近十多年的重大改革。作为补充,清华大学景跃进、复旦大学陈明明、中山大学肖滨合编的《当代中国政府与政治》(2016)是同类教材中可读性较强的一部,内容比较全面,对党政关系、政法系统、宣传系统、军事系统都有介绍。\n我国政府的运作模式有深厚的历史渊源。复旦大学历史地理学家葛剑雄教授的著作《统一与分裂:中国历史的启示》(2013)深入浅出地描述和分析了中国历史上统一和分裂的现象,是很好的普及读物。本章阐释的所有理论在该书中都能找到有趣的佐证。已故哈佛大学历史学家孔飞力的杰作《叫魂》(2014)也与本章内容相关。该书讲述了乾隆盛世年间的一场荒诞事故:本是某些地方流民和乞丐的零星骗局,却被乾隆解读成了要颠覆朝廷的大阴谋,于是发动了全国大清查,造成了朝野和民间的大恐慌,最终却在无数冤案之后不了了之。该书很多史料来自御笔亲批的奏折,从中尤其可以看到信息之关键:诸多信息都在奏折的来往中被扭曲和误解,最终酿成大乱。\n11 此处略去人口极少的首都华盛顿特区。\n22 这三项原则的简单论述,见财政部前部长楼继伟的著作(2013)。\n33 1959年,人大常委会通过《关于直辖市和较大的市可以领导县、自治县的决定》,开始市领导县的改革。1982年,中央发布《改革地区体制,实行市领导县体制的通知》,1983年开始在全国试行。详情见清华大学景跃进、复旦大学陈明明、中山大学肖滨合编的教科书(2016)。\n44 数据来自复旦大学历史地理学家葛剑雄的著作(2013)。\n55 有一种情况例外,即一些重要地区的书记也是上级党委常委,如省会城市的书记也是省委常委。\n66 省委或市委的直属机构一般包括办公厅、纪委、政法委、组织部、宣传部、统战部、机关和政策研究部门。在组织人事、政法、教育宣传等领域内,党委有独立于政府的职能部门,但在财经领域,党政之分通常并不重要。当然,在中央层面有一些直属小组,负责领导主要经济政策的制定,比如中央财经领导小组。\n77 这项原则来自美国布鲁金斯学会李侃如的观察,他的著作(2010)对中国政治和政府的观察与分析很有见地。\n88 斯坦福大学政治学教授福山在其著作(2014)中阐述了现代政治秩序的三大基石:政府、法治、民主。其中的“政府”,也就是脱离血缘关系、由专门人才主导的管理机构,起源于中国。香港中文大学金观涛和刘青峰的著作(2010)曾用“超稳定结构”来描述历经王朝更迭的中国古代社会,这一结构由经济、政治、意识形态三大子系统组成。具有统一意识形态的官僚和儒生,是该结构日常运作的关键,也是在结构破裂和王朝崩溃之后修复机制的关键。世界历史上,王朝崩溃并不罕见,但只有中国能在崩溃后不断修复和延续,历经千年。\n99 本节依据的理论由哈佛大学经济学教授阿尔伯托·阿莱西纳(Alberto Alesina)及其合作者在一系列开创性论文中提出并完善。相关的数学模型、经验证据、历史和现实案例,都收入在他们的书中(Alesina and Spolaore, 2003)。在本节的写作过程中,阿莱西纳教授于2020年5月23日突发疾病离世,享年63岁。这是现代政治经济学研究的重大损失。\n1010 这段人身攻击有些名气,大约是古今中外某些当权者辱骂知识分子的通用套路,大意如下:你智商要真高,怎么做不了官?你财商要真高,怎么那么穷?你们不过是些夸夸其谈之辈,地位不高还爱质疑上司,穷成那样还说富人的坏话,样子清高实则卑鄙,妄发议论,哗众取宠。俸禄吃不饱,家里没余粮,破衣烂衫,也配谈论朝堂大事?何况拓边打仗之事呢!(原文为:“挟管仲之智者,非为厮役之使也。怀陶朱之虑者,不居贫困之处。文学能言而不能行,居下而讪上,处贫而非富,大言而不从,高厉而行卑,诽誉訾议,以要名采善于当世。夫禄不过秉握者,不足以言治,家不满檐石者,不足以计事。儒皆贫羸,衣冠不完,安知国家之政,县官之事乎?何斗辟造阳也!”)\n1111 这条线的提出者是华东师范大学已故地理学家胡焕庸先生,故也被称为“胡焕庸线”。\n1212 广东省人口数量超过百万的县级单位有几十个,任何一个都比新疆的阿勒泰或哈密地区的总人口多。\n1313 数据来自复旦大学历史地理学家周振鹤的著作(2014)。下一段内容也多取材自该书。\n1414 地理和语言多样性之间的关系,来自布朗大学米哈洛普洛斯(Michalopoulos)的研究(2012)。\n1515 地理数据来自北京大学周黎安的著作(2017),贫困县数据来自上海财经大学唐为的论文(2019)。\n1616 本段中坡度的数据来自上海财经大学唐为的论文(2019)。广东外语外贸大学的高翔与厦门大学的龙小宁(2016)则指出,文化与本省主流不同的省界地区,经济发展相对落后。\n1717 关于革命根据地的内容,来自北京大学韩茂莉的著作(2015)。\n1818 关于道路密度的研究来自上海财经大学唐为的论文(2019)。\n1919 工业水污染向本省下游区域集中这个现象,来自香港大学蔡洪斌、北京大学陈玉宇和北卡罗来纳大学宫晴等人的论文(Cai, Chen and Gong, 2016)。\n2020 数据来自南开大学邵朝对、苏丹妮、包群等人的论文(2018)。\n2121 上海财经大学唐为和华东师范大学王媛的论文(2015)发现撤县设区会增加外来人口。\n2222 “形式权威”(formal authority)和“实际权威”(real authority)的理论,来自哈佛大学阿吉翁(Aghion)与诺贝尔经济学奖得主图卢兹大学梯若尔(Tirole)的论文(1997)。\n2323 关于信息和国企分级的关系,来自清华大学黄张凯、北京大学李力行、中国人民大学马光荣与世界银行徐立新等人的论文(Huang et al., 2017)。\n2424 中国自魏晋以来出现“官吏分途”,即官吏虽同在官僚机构共生共事,但在录用、晋升、俸禄等方面相互隔绝。对这一制度流变的分析描述及对理解当今官僚体系的启示,读者可参考斯坦福大学周雪光(2016)与北京大学周黎安(2016)的精彩文章。\n2525 2020年,受新冠肺炎疫情影响,国务院没有设定GDP增长目标,属20余年来首次。\n2626 数据及关于GDP指标“层层加码”现象的详细讨论,见北京大学厉行、刘冲、翁翕、周黎安等人的论文(Li et al., 2019)。\n2727 9个驻地是:北京、沈阳、上海、南京、济南、广州、武汉、成都、西安。关于“驻地效应”的检验,来自湖南商学院的陈晓红、朱蕾和中南大学汪阳洁等人的论文(2018)。\n2828 关于水质监测站与临近企业排放行为的讨论,来自香港科技大学何国俊、芝加哥大学王绍达和南京大学张炳等人的论文(He, Wang and Zhang, 2020)。\n2929 从经济学的合同理论出发,合同不可能事先写清楚所有情况,所以权力的实质就是在这些不确定情况下的决定权,可以称为“剩余控制权”(residual control rights)。以这种视角来分析权力的理论始于诺贝尔经济学奖得主、哈佛大学教授奥利弗·哈特(Oliver Hart),详见其著作(1995)。他用“剩余控制权”的思路去理解“产权”的本质,即在合同说不清楚的情况下对财产的处置权。而更加广泛的权力或权威,可以视为在各种模糊情况下的决定权。\n3030 参见2011年发布的《国务院办公厅关于调整省级以下工商质监行政管理体制加强食品安全监管有关问题的通知》,此文件现已失效。\n3131 除环保之外,其他领域内也有类似冲突:上级重视质量而下级重视成本,下级为了降低成本会不惜损害质量。这种冲突并不总是因为双方信息不对称。即便没有信息问题,也有能力问题。只要上级没有能力完全取代下级,这种冲突就可能会发生。此时放权会降低质量,收权又会降低工作效率,就需要妥协和平衡。哈佛大学哈特(Hart)、施莱弗(Shleifer)和芝加哥大学维什尼(Vishny)的论文(1997)详细探讨了这类问题。\n3232 关于国家重点监控企业的研究,来自南京大学张炳、四川大学陈晓兰、南京审计大学郭焕修等人的论文(Zhang, Chen and Guo, 2018)。2016年,重点监控企业已经增加到14 312家。\n3333 详见中央和国务院于2016年9月联合印发的《关于省以下环保机构监测监察执法垂直管理制度改革试点工作的指导意见》。\n3434 引文中的强调格式是我在引用时加上的,本书余下部分涉及此类情况皆如此。\n3535 我国实行分税制,按照中央和省的分税比例,企业所得税六成归中央,剩余部分由省、市、区县来分。企业所得税减免,一般都是减免企业所在地的地方留存部分。但对一些国家支持的行业,比如集成电路,企业的全部所得税都可以“三免三减半”。\n第二章 财税与政府行为 # 我很喜欢两部国产电视剧,一部是《大明王朝1566》,一部是《走向共和》。这两部剧有个共同点:开场第一集中,那些历史上赫赫有名的大人物们,出场都没有半点慷慨激昂或阴险狡诈的样子,反倒都在做世上最乏味的事——算账。大明朝的阁老们在算国库的亏空和来年的预算,李鸿章、慈禧和光绪则在为建海军和修颐和园的费用伤脑筋。然而算着算着,观众就看到了刀光剑影,原来所有的政见冲突和人事谋略,都隐在这一两一两银子的账目之中。\n要真正理解政府行为,必然要了解财税。道理很朴素:办事要花钱,如果没钱,话说得再好听也难以落实。要想把握政府的真实意图和动向,不能光读文件,还要看政府资金的流向和数量,所以财政从来不是一个纯粹的经济问题。党的十八届三中全会通过了《中共中央关于全面深化改革若干重大问题的决定》,明确了财政的定位和功能:“财政是国家治理的基础和重要支柱,科学的财税体制是优化资源配置、维护市场统一、促进社会公平、实现国家长治久安的制度保障。”\n我对政府和财政一直非常有兴趣,在美国读博士期间,修习了一整年的“公共财政”课程。第一学期学习财政收入,即与各类税收有关的理论和实证;第二学期学习财政支出,即各类政府支出的设计和实施效果。与我同级的博士生中,美国和非美国同学各占一半,但只有我一个非美国人选修了这门课,可能是因为涉及大量美国制度细节,外国人理解起来比较吃力,兴趣也不大。这门课程对我理解美国有很大帮助,但后来我到复旦大学讲授研究生课程“公共经济学研究”,备课时却很吃力,因为在美国学过的东西大都不能直接拿来用,跟中国情况很不一样,美国的教科书也不好用,要自己准备授课讲义。关键在于中美政府在经济运行中扮演的角色不一样,所做的事情也不一样,而财政体制要为政府事务服务,因此不能直接拿美国的财政理论往中国硬套。何况中国几十年来一直在改革,政府事务也经历了很多重大变革,财税体制自然也在随之不断变革。而财税体制变革牵一发动全身,影响往往复杂深远,我花了好几年边讲边学,才多少摸到了些门道。\n上一章介绍了政府的事权划分。而事权必然要求相应的财力支持,否则事情就办不好。所以从花钱的角度看,“事权与财力匹配”或者说“事权与支出责任匹配”这个原则,争议不大。但从预算收入的角度看,地方政府是否也应该有与事权相适应的收钱的权力,让“事权与财权匹配”,这个问题争议就大了。暂先不管这些争议,实际情况是地方政府的支出和收入高度不匹配。从图2-1可以看出,自1994年实行分税制以来,地方财政预算支出就一直高于预算收入。近些年地方预算支出占全国预算支出的比重为85%,但收入的占比只有50%—55%,入不敷出的部分要通过中央转移支付来填补。\n图2-1 地方公共预算收支占全国收支的比重\n数据来源:万得数据库。\n1994年分税制改革对政府行为和经济发展影响深远。本章第一节介绍这次改革的背景和过程,加深我们对央地关系的理解。第二节分析改革对地方经济发展方式的影响,介绍地方政府为了应对财政压力而发展出的“土地财政”,这是理解城市化和债务问题的基础。第三节讨论分税制造成的基层财政压力与地区间不平衡,并介绍相关改革。\n第一节 分税制改革 # 财政乃国之根本,新中国成立以来经历了艰辛复杂的改革历程。这方面专著很多,本章结尾的“扩展阅读”会推荐几种读物。本节无意追溯完整的改革历程,只从1985年开始谈。1985—1993年,地方政府的收入和支出是比较匹配的(图2-1),这种“事权和财权匹配”的体制对经济发展影响很大,也造成很多不良后果,催生了1994年的分税制改革。\n“财政包干”及后果:1985—1993年 # 如果要用一个词来概括20世纪80年代中国经济的特点,非“承包”莫属:农村搞土地承包,城市搞企业承包,政府搞财政承包。改革开放之初,很多人一时还无法立刻接受“私有”的观念,毕竟之前搞了几十年的计划经济和公有制。我国的基本国策决定了不能对所有权做出根本性变革,只能对使用权和经营权实行承包制,以提高工作积极性。财政承包始于1980年,中央与省级财政之间对收入和支出进行包干,地方可以留下一部分增收。1980—1984年是财政包干体制的实验阶段,1985年以后全面推行,建立了“分灶吃饭”的财政体制。 11 既然是承包,当然要根据地方实际来确定承包形式和分账比例,所以财政包干形式五花八门,各地不同。比较流行的一种是“收入递增包干”。以1988年的北京为例,是以1987年的财政收入为基数,设定一个固定的年收入增长率4%,超过4%的增收部分都归北京,没超过的部分则和中央五五分成。假如北京1987年收入100亿元,1988年收入110亿元,增长10%,那超过了4%增长的6亿元都归北京,其余104亿元和中央五五分成。\n广东的包干形式更简单,1988年上解中央14亿元,以后每年在此基础上递增9%,剩余的都归自己。1988年,广东预算收入108亿元,上解的14亿元不过只占13%。而且广东预算收入的增长速度远高于9%(1989年比上年增加了27%),上解负担实际上越来越轻,正因如此,广东对后来的分税制改革一开始是反对的。相比之下,上海的负担就重多了。上海实行“定额上解”,每年雷打不动上缴中央105亿元。1988年,上海的预算收入是162亿元,上解105亿元,占比65%,财政压力很大。\n财政承包制下,交完了中央的,剩下的都是地方自己的,因此地方有动力扩大税收来源,大力发展经济。 22 一种做法就是大力兴办乡镇企业。乡镇企业可以为地方政府贡献两类收入。第一是交给县政府的增值税(增值税改革前也叫产品税)。企业只要开工生产,不管盈利与否都得交增值税,规模越大缴税越多,所以县政府有很强的动力做大、做多乡镇企业。20世纪80年代中期以后,乡镇企业数量和规模迅速扩大,纳税总额也急速增长。在其发展鼎盛期的1995年,乡镇企业雇工人数超过6 000万。乡镇企业为地方政府贡献的第二类收入是上缴的利润,主要交给乡镇政府和村集体作为预算外收入。当时乡镇企业享受税收优惠,所得税和利润税都很低,1980年的利润税仅为6%,1986年上升到20%,所以企业税后利润可观,给基层政府创造了不少收入。 33 20世纪80年代是改革开放的起步时期,在很多根本性制度尚未建立、观念尚未转变之前,各类承包制有利于调动全社会的积极性,推动社会整体走出僵化的计划经济,让人们切实感受到收入增长,逐渐转变观念。但也正是因为改革转型的特殊性,很多承包制包括财政包干制注定不能持久。财政包干造成了“两个比重”不断降低:中央财政预算收入占全国财政预算总收入的比重越来越低,而全国财政预算总收入占GDP的比重也越来越低(图2-2)。不仅中央变得越来越穷,财政整体也越来越穷。\n图2-2 “两个比重”的变化情况\n数据来源:万得数据库。\n中央占比降低很容易理解。地方经济增长快,20世纪80年代物价涨得也快,所以地方财政收入相比于跟中央约定的固定分成比例增长更快,中央收入占比自然不断下降。至于预算总收入占GDP比重不断降低,原因则比较复杂。一方面,这跟承包制本身的不稳定有关。央地分成比例每隔几年就要重新谈判一次,若地方税收收入增长很快,下次谈判时可能会处于不利地位,落得一个更高的上缴基数和更吃亏的分成比例。为避免“鞭打快牛”,地方政府有意不让预算收入增长太快。另一方面,这也跟当时盛行的预算外收入有关。虽然地方预算内的税收收入要和中央分成,但预算外收入则可以独享。如果给企业减免税,“藏富于企业”,再通过其他诸如行政收费、集资、摊派、赞助等手段收一些回来,就可以避免和中央分成,变成可以完全自由支配的预算外收入。地方政府因此经常给本地企业违规减税,企业偷税漏税也非常普遍,税收收入自然上不去,但预算外收入却迅猛增长。1982—1992年,地方预算外收入年均增长30%,远超过预算内收入年均19%的增速。1992年,地方预算外收入达到了预算内收入的86%,相当于“第二财政”了。 44 “两个比重”的下降严重削弱了国家财政能力,不利于推进改革。经济改革让很多人的利益受损,中央必须有足够的财力去补偿,才能保障改革的推行,比如国企改革后的职工安置、裁军后的退伍军人转业等。而且像我国这样的大国,改革后的地区间发展差异很大(东中西部差异、城乡差异等),要创造平稳的环境,就需要缩小地区间基本公共服务差异,也需要中央财政的大量投入,否则连推行和保障义务教育都有困难。如果中央没钱,甚至要向地方借钱,那也就谈不上宏观调控的能力。正如时任财政部部长的刘仲藜所言:\n毛主席说,“手里没把米,叫鸡都不来”。中央财政要是这样的状态,从政治上来说这是不利的,当时的财税体制是非改不可了。……\n……财政体制改革决定里有一个很重要的提法是“为了国家长治久安”。当时的理论界对我讲,财政是国家行政能力、国家办事的能力,你没有财力普及义务教育、救灾等,那就是空话。因此,“国家长治久安”这句话写得是有深意的。 55 分税制改革与央地博弈 # 1994年的分税制改革把税收分为三类:中央税(如关税)、地方税(如营业税)、共享税(如增值税)。同时分设国税、地税两套机构,与地方财政部门脱钩,省以下税务机关以垂直管理为主,由上级税务机构负责管理人员和工资。这种设置可以减少地方政府对税收的干扰,保障中央税收收入,但缺点也很明显:两套机构导致税务系统人员激增,提高了税收征管成本,而且企业需要应付两套人马和审查,纳税成本也高。2018年,分立了24年的国税与地税再次开始合并。\n分税制改革中最重要的税种是增值税,占全国税收收入的1/4。改革之前,增值税(即产品税)是最大的地方税,改革后变成共享税,中央拿走75%,留给地方25%。假如改革前的1993年,地方增值税收入为100亿元,1994年改革后增长为110亿元,那么按照新税制,地方拿25%,收入一下就从1993年的100亿元下降到了27.5亿元。为防止地方收入急剧下跌,中央设立了“税收返还”机制:保证改革后地方增值税收入与改革前一样,新增部分才和中央分。1994年,地方可以拿到102.5亿元,而不是27.5亿元。因此改革后增值税占地方税收收入的比重没有急速下跌,而是缓慢地逐年下跌(图2-3)。\n图2-3 地方税收收入中不同税种所占比重\n数据来源:万得数据库。\n分税制改革,地方阻力很大。比如在财政包干制下过得很舒服的广东省,就明确表示不同意分税制。与广东的谈判能否成功,关系到改革能否顺利推行。时任财政部长刘仲藜和后来的财政部长项怀诚的回忆,生动地再现了当时的激烈博弈:\n(项怀诚)分税制的实施远比制订方案要复杂,因为它涉及地方的利益。当时中央财政收入占整个财政收入的比重不到30%,我们改革以后,中央财政收入占整个国家财政收入的比重达到55%,多大的差别!所以说,分税制的改革,必须要有领导的支持。为了这项改革的展开,朱镕基总理 66 亲自带队,用两个多月的时间先后走了十几个省,面对面地算账,深入细致地做思想工作……为什么要花这么大的力气,一个省一个省去跑呢,为什么要由一个中央常委、国务院常务副总理带队,一个省一个省去谈呢?因为只有朱总理去才能够和第一把手省委书记、省长面对面地交谈,交换意见。有的时候,书记、省长都拿不了主意的,后面还有很多老同志、老省长、老省委书记啊。如果是我们去,可能连面都见不上。\n(刘仲藜)与地方谈的时候气氛很紧张,单靠财政部是不行的,得中央出面谈。在广东谈时,谢飞 77 同志不说话,其他的同志说一条,朱总理立即给驳回去。当时有个省委常委、组织部长叫符睿(音) 88 就说:“朱总理啊,你这样说我们就没法谈了,您是总理,我们没法说什么。”朱总理就说:“没错,我就得这样,不然,你们谢飞同志是政治局委员,他一说话,那刘仲藜他们说什么啊,他们有话说吗?!就得我来讲。”一下就给驳回去了。这个场面紧张生动,最后应该说谢飞同志不错,广东还是服从了大局,只提出了两个要求:以1993年为基数、减免税过渡。 99 这段故事我上课时经常讲,但很多学生不太理解为何谈判如此艰难:只要中央做了决策,地方不就只有照办的份儿吗?“00后”一代有这种观念,不难理解。一方面,经过分税制改革后多年的发展,今天的中央政府确实要比20世纪80年代末和90年代初更加强势;另一方面,公众所接触的信息和看到的现象,大都已经是博弈后的结果,而缺少社会阅历的学生容易把博弈结果错当成博弈过程。其实即使在今天,中央重大政策出台的背后,也要经过很多轮的征求意见、协商、修改,否则很难落地。成功的政策背后是成功的协商和妥协,而不是机械的命令与执行,所以理解利益冲突,理解协调和解决机制,是理解政策的基础。\n广东当年提的要求中有一条,“以1993年为基数”。这条看似不起眼,实则大有文章。地方能从“税收返还”中收到多少钱,取决于它在“基年”的增值税收入,所以这个“基年”究竟应该是哪一年,差别很大。中央与广东的谈判是在1993年9月,所以财政部很自然地想把“基年”定为1992年。时光不能倒流,地方做不了假。可一旦把“基年”定在1993年,那到年底还有三个多月,地方可能突击收税,甚至把明年的税都挪到今年来收,大大抬高税收基数,以增加未来的税收返还。所以财政部不同意广东的要求。但为了改革顺利推行,中央最终做了妥协,决定在全国范围内用1993年做基年。这个决定立刻引发了第四季度的收税狂潮,根据项怀诚和刘克崮的回忆:\n(项怀诚)实际上,9月份以后确实出现了这些情况。在那一年,拖欠了多年的欠税,都收上来了。一些地方党政领导亲自出马,贷款交税,造成了1993年后4个月财政收入大幅度增加。\n(刘克崮)……分别比上年同期增长60%、90%、110%和150%,带动全年地方税收增长了50%~60% 1010 。 1111 由于地方突击征税,图2-3中增值税占地方税收的比重在1993年出现了明显反常的尖峰。这让1994年的财政陷入了困境,中央承诺的税收返还因为数额剧增而无法到位,预算迟迟做不出来。这些问题又经过了很多协商和妥协才解决。但从图2-3可以看到,当2001年推行所得税分成改革时,突击征税现象再次出现。\n企业所得税是我国的第二大税种,2018年占全国税收收入的23%。2002年改革之前,企业所得税按行政隶属关系上缴:中央企业交中央,地方企业交地方。地方企业比中央企业多,所以六成以上的所得税交给了地方。地方政府自然就有动力创办价高利大的企业,比如烟厂和酒厂,这些都是创税大户。20世纪90年代,各地烟厂、酒厂越办越多,很多地方只抽本地牌子的烟、喝本地牌子的啤酒,这种严重的地方保护主义不利于形成全国统一市场,也不利于缩小地区间的经济差距。在2002年的所得税改革中,除一些特殊央企的所得税归中央外,所有企业的所得税中央和地方六四分成(仅2002年当年为五五分)。为防止地方收入下降,同样也设置了税收返还机制,并把2001年的所得税收入定为返还基数。所以2001年的最后两个月,地方集中征税做大基数,财政部和国务院办公厅不得不强调“地方各级人民政府要从讲政治的高度,进一步提高认识,严格依法治税,严禁弄虚作假。2002年1月国务院有关部门将组织专项检查,严厉查处作假账和人为抬高基数的行为。对采取弄虚作假手段虚增基数的地方,相应扣减中央对地方的基数返还,依法追究当地主要领导和有关责任人员的责任。” 1212 但从图2-3中可以看出,2001年不正常的企业所得税收入依然非常明显。 1313 分税制是20世纪90年代推行的根本性改革之一,也是最为成功的改革之一。改革扭转了“两个比重”不断下滑的趋势(图2-2):中央占全国预算收入的比重从改革前的22%一跃变成55%,并长期稳定在这一水平;国家预算收入占GDP的比重也从改革前的11%逐渐增加到了20%以上。改革大大增强了中央政府的宏观调控能力,为之后应付一系列重大冲击(1997年亚洲金融危机、2008年全球金融危机和汶川地震等)奠定了基础,也保障了一系列重大改革(如国企改革和国防现代化建设)和国家重点建设项目的顺利实施。分税制也从根本上改变了地方政府发展经济的模式。\n第二节 土地财政 # 分税制并没有改变地方政府以经济建设为中心的任务,却减少了其手头可支配的财政资源。虽然中央转移支付和税收返还可以填补预算内收支缺口,但发展经济所需的诸多额外支出,比如招商引资和土地开发等,就需要另筹资金了。一方面,地方可以努力增加税收规模。虽然需要和中央分成,但蛋糕做大后,自己分得的收入总量也会增加。另一方面,地方可以增加预算外收入,其中最重要的就是围绕土地出让和开发所产生的“土地财政”。\n招商引资与税收 # 给定税率的情况下,想要增加税收收入,要么靠扩大税源,要么靠加强征管。分税制改革之后,全国预算收入占GDP的比重逐步上升(参见图2-2),部分原因可以归结为加强了征管力度,但更重要的原因是扩大了税源。 1414 改革前,企业的大多数税收按隶属关系上缴,改革后则变成了在所在地上缴,这自然会刺激地方政府招商引资。地方政府尤其青睐重资产的制造业,一是因为投资规模大,对GDP的拉动作用明显;二是因为增值税在生产环节征收,跟生产规模直接挂钩;三是因为制造业不仅可以吸纳从农业部门转移出的低技能劳动力,也可以带动第三产业发展,增加相关税收。\n因为绝大多数税收征收自企业,且多在生产环节征收,所以地方政府重视企业而相对轻视民生,重视生产而相对轻视消费。以增值税为例,虽然企业可以层层抵扣,最终支付税金的一般是消费者(增值税发票上会分开记录货款和税额,消费者支付的是二者之和),但因为增值税在生产环节征收,所以地方政府更加关心企业所在地而不是消费者所在地。这种倚重生产的税制,刺激了各地竞相投资制造业、上马大项目,推动了制造业迅猛发展,加之充足高效的劳动力资源和全球产业链重整等内外因素,我国在短短二三十年内就成为世界第一制造业大国。当然,这也付出了相应的代价。比如说,地方为争夺税收和大工业项目,不惜放松环保监督,损害了生态环境,推高了过剩产能。2007—2014年,地方政府的工业税收收入中,一半来自过剩产能行业。而在那些财政压力较大的地区,工业污染水平也普遍较高。 1515 不仅九成的税收征收自企业,税收之外的其他政府收入基本也都征收自企业,比如土地转让费和国有资本经营收入等。社保费中个人缴纳的比例也低于企业缴纳的比例。所以在分税制改革后的头些年,地方政府在财政支出上向招商引资倾斜(如基础设施建设、企业补贴等),而民生支出(教育、医疗、环保等)相对不足。 1616 2002年,中央提出“科学发展观”,要求“统筹经济社会发展、统筹人与自然和谐发展”,要求更加重视民生支出。由于第一章中讨论过的规模经济、信息复杂性等原因,民生支出基本都由地方政府承担,所以地方支出占比从2002年开始快速增长,从70%一直增长到了85%(图2-1)。\n总的来看,分税制改革后,地方政府手中能用来发展经济的资源受到了几方面的挤压。首先,预算内财政支出从重点支持生产建设转向了重点支持公共服务和民生。20世纪90年代中后期,财政支出中“经济建设费”占40%,“社会文教费”(科教文卫及社会保障)只占26%。到了2018年,“社会文教费”支出占到了40%,“经济建设费”则下降了。 1717 其次,分税制改革前,企业不仅缴税,还要向地方政府缴纳很多费(行政收费、集资、摊派、赞助等),这部分预算外收入在改革后大大减少。90年代中后期,乡镇企业也纷纷改制,利润不再上缴,基层政府的预算外收入进一步减少。最后,2001年的税改中,中央政府又拿走了所得税收入的60%,加剧了地方财政压力。地方不得不另谋出路,寻找资金来源,轰轰烈烈的“土地财政”就此登场。\n初探土地财政 # 我国实行土地公有制,城市土地归国家所有,农村土地归集体所有。农地要转为建设用地,必须先经过征地变成国有土地,然后才可以用于发展工商业或建造住宅(2019年《中华人民共和国土地管理法》修正案通过,对此进行了改革,详见第三章),所以国有土地的价值远远高于农地。为什么会有这种城乡割裂的土地制度?追根溯源,其实也没有什么惊天动地的大道理和顶层设计,不过是从1982年宪法开始一步步演变成今天这样罢了。 1818 虽说每一步变化都有道理,针对的都是当时亟待解决的问题,但演变到今天,已经造成了巨大的城乡差别、飞涨的城市房价以及各种棘手问题。2020年,中共中央和国务院发布的《关于构建更加完善的要素市场化配置体制机制的意见》中,首先提到的就是“推进土地要素市场化配置”,而第一条改革意见就是打破城乡割裂的现状,“建立健全城乡统一的建设用地市场”。可见政策制定者非常清楚当前制度的积弊。第三章会详细讨论相关改革,此处不再赘述。\n1994年分税制改革时,国有土地转让的决定权和收益都留给了地方。当时这部分收益很少。一来虽然乡镇企业当时还很兴盛,但它们占用的都是农村集体建设用地,不是城市土地。二来虽然城市土地使用权当时就可以有偿转让,不必再像计划经济体制下那样无偿划拨,但各地为了招商引资(尤其是吸引外资),土地转让价格大都非常优惠,“卖地收入”并不多。\n1998年发生了两件大事,城市土地的真正价值才开始显现。第一是单位停止福利分房,逐步实行住房分配货币化,商品房和房地产时代的大幕拉开。1997—2002年,城镇住宅新开工面积年均增速为26%,五年增长了近4倍。第二是修订后的《中华人民共和国土地管理法》开始实施,基本上锁死了农村集体土地的非农建设通道,规定了农地要想转为建设用地,必须经过征地后变成国有土地,这也就确立了城市政府对土地建设的垄断权力。 1919 1999年和2000年这两年的国有土地转让收入并不高(图2-4),因为尚未普遍实行土地“招拍挂”(招标、拍卖、挂牌)制度。当时的土地转让过程相当不透明,基本靠开发商各显神通。比如有些开发商趁着国有企业改革,拿到了企业出让的土地,再从城市规划部门取得开发许可,只需支付国家规定的少量土地出让金,就可以搞房地产开发。这是个转手就能发家致富的买卖,其中的腐败可想而知。\n图2-4 国有土地转让收入占地方公共预算收入的比重\n数据来源:历年《中国国土资源统计年鉴》。\n2001年,为治理土地开发中的腐败和混乱,国务院提出“大力推行招标拍卖”。2002年,国土部明确四类经营用地(商业、旅游、娱乐、房地产)采用“招拍挂”制度。于是各地政府开始大量征收农民土地然后有偿转让,土地财政开始膨胀。土地出让收入从2001年开始激增,2003年就已经达到了地方公共预算收入的55%(图2-4)。2008年全球金融危机之后,在财政和信贷政策的共同刺激之下,土地转让收入再上一个台阶,2010年达到地方公共预算收入的68%。最近两年这一比重虽有所下降,但土地转让收入的绝对数额还在上涨,2018年达到62 910亿元,比2010年高2.3倍。\n所谓“土地财政”,不仅包括巨额的土地使用权转让收入,还包括与土地使用和开发有关的各种税收收入。其中大部分税收的税基是土地的价值而非面积,所以税收随着土地升值而猛增。这些税收分为两类,一类是直接和土地相关的税收,主要是土地增值税、城镇土地使用税、耕地占用税和契税,其收入百分之百归属地方政府。2018年,这四类税收共计15 081亿元,占地方公共预算收入的15%,相当可观。另一类税收则和房地产开发和建筑企业有关,主要是增值税和企业所得税。2018年,这两种税收中归属地方的部分(增值税五成,所得税四成)占地方公共预算收入的比重为9%。 2020 若把这些税收与土地转让收入加起来算作“土地财政”的总收入,2018年“土地财政”收入相当于地方公共预算收入的89%,是名副其实的“第二财政”。\n土地转让虽然能带来收入,但地方政府也要负担相关支出,包括征地拆迁补偿和“七通一平”等基础性土地开发支出。从近几年的数字看,跟土地转让有关的支出总体与收入相当,有时甚至比收入还高。2018年,国有土地使用权出让金收入为62 910亿元,支出则为68 167亿元。光看这一项,地方政府还入不敷出。当然地方政府本来也不是靠卖地赚钱,它真正要的是土地开发之后吸引来的工商业经济活动。\n从时间点上看,大规模的土地财政收入始于21世纪初。2001年所得税改革后,中央财政进一步集权,拿走了企业所得税的六成。从那以后,地方政府发展经济的方式就从之前的“工业化”变成了“工业化与城市化”两手抓:一方面继续低价供应大量工业用地,招商引资;另一方面限制商住用地供给,从不断攀升的地价中赚取土地垄断收益。这些年出让的城市土地中,工业用地面积约占一半,但出让价格极低:2000年每平方米是444元,2018年是820元,只涨了85%。而商业用地价格增长了4.6倍,住宅用地价格更是猛增了7.4倍(图2-5)。\n图2-5 100个重点城市土地出让季度平均成交价\n数据来源:万得数据库。\n所以商住用地虽然面积上只占出让土地的一半,但贡献了几乎所有的土地使用权转让收入。因此“土地财政”的实质是“房地产财政”。一方面,各地都补贴工业用地,大力招商引资,推动了制造业迅猛发展;另一方面,随着工业化和城市化的发展,大量新增人口涌入经济发达地区,而这些地方的住宅用地供给却不足,房价自然飞涨,带动地价飞涨,土地拍卖的天价“地王”频出。这其中的问题及改革之道,第三章会展开分析。\n税收、地租与地方政府竞争 # 让我们后退一步,看清楚地方政府究竟在干什么。所谓经济发展,无非就是提高资源使用效率,尽量做到“人尽其才,物尽其用”。而我国是一个自然资源相对贫乏的国家,在经济起步阶段,能利用的资源主要就是人力和土地。过去几十年的很多重大改革,大都和盘活这两项资源、提高其使用效率有关。与人力相比,土地更容易被资本化,将未来收益一股脑变成今天高升的地价,为地方政府所用。所以“土地财政”虽有种种弊端,但确实是过去数年城市化和工业化得以快速推进的重要资金来源。\n前文说过,地方在招商引资和城市化过程中,会利用手中一切资源,所以需要通盘考量税收和土地,平衡税收收入和土地使用权转让收入,以达到总体收入最大化。地方政府压低工业用地价格,因为工业对经济转型升级的带动作用强,能带来增值税和其他税收,还能创造就业。而且工业生产率提升空间大、学习效应强,既能帮助本地实现现代化,也能带动服务业的发展,拉动商住用地价格上涨。工业生产上下游链条长,产业集聚和规模经济效果显著,若能发展出特色产业集群(如佛山的陶瓷),也就有了长久的竞争优势和稳定的税收来源。此外,地方之间招商引资竞争非常激烈。虽说工业用地和商住用地都由地方政府垄断,但工业企业可以落地的地方很多,所以在招商引资竞争中地方政府很难抬高地价。商住用地则不同,主要服务本地居民,土地供应方的垄断力量更强,更容易抬高地价。\n经济学家张五常曾做过一个比喻:地方政府就像一家商场,招商引资就是引入商铺。商铺只要交一个低廉的入场费用(类似工业用地转让费),但营业收入要和商场分成(类似增值税,不管商铺是否盈利,只要有流水就要分成)。商场要追求总体收入最大化,所以既要考虑入门费和租金的平衡,也要考虑不同商铺间的平衡。一些商铺大名鼎鼎,能为商场带来更大客流,那商场不仅可以免除它们的入门费,还可以降低分成,甚至可以倒贴(类似地方给企业的各种补贴)。 2121 以行政区划为单位、以税收和土地为手段展开招商引资竞争,且在上下级政府间层层承包责任和分享收益,这一制度架构对分税制改革后经济的飞速发展,无疑有很强的解释力。但随着时代发展,这种模式的弊端和负面效果也越来越明显,需要改革。首先是地方政府的债务问题(见第三章)。土地的资本化运作,本质是把未来的收益抵押到今天去借钱,如果借来的钱投资质量很高,转化成了有价值的资产和未来更高的收入,那债务就不是大问题。但地方官员任期有限,难免会催生短视行为,寅吃卯粮,过度借债去搞大项目,搞“面子工程”,功是留在当代了,利是不是有千秋,就是下任领导的事了。如此一来,投资质量下降,收益不高,债务负担就越来越重。\n若仅仅只是债务问题,倒也不难缓解。最近几年实施了一系列财政和金融改革,实际上已经遏制住了债务的迅猛增长。但经济增速随之放缓,说明资源的使用效率仍然不高。就拿土地来说,虽然各地都有动力调配好手中的土地资源,平衡工业和商住用地供给,但在全国范围内,土地资源和建设用地分配却很难优化。地区间虽然搞竞争,但用地指标不能跨省流动到效率更高的地区。珠三角和长三角的经济突飞猛进,人口大量涌入,却没有足够的建设用地指标,工业和人口容量都遭遇了人为的限制。寸土寸金的上海,却保留着289.6万亩农田(2020年的数字),可以说相当不经济。同时,中西部却有大量闲置甚至荒废的产业园区。虽然地广人稀的西北本就有不少荒地,所以真实的浪费情况可能没有媒体宣扬的那么夸张,但这些用地指标本可以分给经济更发达的地区。如果竞争不能让资源转移到效率更高的地方,那这种竞争就和市场竞争不同,无法长久地提高整体效率。一旦投资放水的闸门收紧,经济增长的动力立刻不足。\n可是制度一直如此,为什么前些年问题似乎不大?因为经济发展阶段变了。在工业化和城市化初期,传统农业生产率低,只要把农地变成工商业用地,农业变成工商业,效率就会大大提升。但随着工业化的发展,市场竞争越来越激烈,技术要求越来越高,先进企业不仅需要土地,还需要产业集聚、研发投入、技术升级、物流和金融配套等,很多地方并不具备这些条件,徒有大量建设用地指标又有何用?改革的方向是清楚的。2020年,中共中央和国务院发布的《关于构建更加完善的要素市场化配置体制机制的意见》中,放在最前面的就是“推进土地要素市场化配置”。要求不仅要在省、市、县内部打破城乡建设用地之间的市场壁垒,建设一个统一的市场,盘活存量建设用地,而且要“探索建立全国性的建设用地、补充耕地指标跨区域交易机制”,以提高土地资源在全国范围内的配置效率。\n第三节 纵向不平衡与横向不平衡 # 分税制改革之后,中央拿走了收入的大头,但事情还是要地方办,所以支出的大头仍留在地方,地方收支差距由中央转移支付来填补。从全国总数来看,转移支付足够补上地方收支缺口。 2222 但总数能补上,不等于每级政府都能补上,也不等于每个地区都能补上。省里有钱,乡里不见得有钱;广州有钱,兰州不见得有钱。这种纵向和横向的不平衡,造成了不少矛盾和冲突,也催生了很多改革。\n基层财政困难 # 分税制改革之后,中央和省分成,省也要和市县分成。可因为上级权威高于下级,所以越往基层分到的钱往往越少,但分到的任务却越来越多,出现了“财权层层上收,事权层层下压”的局面。改革后没几年,基层财政就出现了严重的困难。20世纪90年代末有句顺口溜流行很广:“中央财政蒸蒸日上,省级财政稳稳当当,市级财政摇摇晃晃,县级财政哭爹叫娘,乡级财政精精光光。”\n从全国平均来看,地方财政预算收入(本级收入加上级转移支付)普遍仅够给财政供养人员发工资,但地区间差异很大。在东部沿海,随着工业化和城市化的大发展,可以从“土地财政”中获取大量额外收入,一手靠预算财政“吃饭”,一手靠土地财政“办事”。但在很多中西部县乡,土地并不值钱,财政收入可能连发工资都不够,和用于办事的钱相互挤占,连“吃饭财政”都不算,要算“讨饭财政”。 2323 基层政府一旦没钱,就会想办法增收,以保持正常运转。20世纪90年代末到21世纪初,农村基层各种乱收费层出不穷,农民的日子不好过,干群关系紧张,群体性事件频发。基层政府各种工程欠款(会转化为包工头拖欠农民工工资,引发讨薪事件)、拖欠工资、打白条等,层出不穷。2000年初,湖北监利县棋盘乡党委书记李昌平给时任国务院总理朱镕基写信,信中的一句话轰动全国:“农民真苦,农村真穷,农业真危险。”这个“三农问题”,就成了21世纪初政策和改革的焦点之一。\n20世纪90年代的财政改革及其他根本性改革(如国企改革和住房改革),激化了一些社会矛盾,这是党的十六大提出“和谐社会”与“科学发展观”的时代背景。与“科学发展观”对应的“五个统筹”原则中,第一条就是“统筹城乡发展”。 2424 从2000年开始,农村税费改革拉开帷幕,制止基层政府乱摊派和乱收费,陆续取消了“三提五统”和“两工”等。 2525 2006年1月1日,农业税彻底废止。这是一件具有历史意义的大事,终结了农民缴纳了千年的“皇粮国税”。这些税费改革不仅提高了农民收入,也降低了农村的贫富差距。 2626 之所以能推行这些改革,得益于我国加入世界贸易组织(WTO)之后飞速发展的工商业,使得国家财政不再依赖于农业税费。2000年至2007年,农业部门产值占GDP的比重从15%下降到了10%,而全国税收总收入却增加了3.6倍(未扣除物价因素)。\n农村税费改革降低了农民负担,但也让本就捉襟见肘的基层财政维持起来更加艰难,所以之后的改革就加大了上级的统筹和转移支付力度。\n其一,是把农村基本公共服务开支纳入国家公共财政保障范围,由中央和地方政府共同负担。比如2006年开始实施的农村义务教育经费保障机制改革,截至2011年,中央财政一共安排了3 300亿元农村义务教育改革资金,为约1.3亿名农村义务教育阶段的学生免除了学杂费和教科书费。 2727 再比如2003年开始的新型农村合作医疗制度(“新农合”)与2009年开始的新型农村社会养老保险制度(“新农保”)等,均有从中央到地方的各级财政资金参与。\n其二,是在转移支付制度中加入激励机制,鼓励基层政府达成特定目标,并给予奖励。比如2005年开始实施的“三奖一补”,就对精简机构和人员的县乡政府给予奖励。 2828 冗员过多一直是政府顽疾,分税制改革后建立的转移支付体系中,相当一部分转移支付是为了维持基层政府正常运转和保障人员工资。财政供养人员(即有编制的人员)越多,得到的转移支付越多,这自然会刺激地方政府扩编。从1994年到2005年,地方政府的财政供养人员(在职加退休)猛增了60%,从2 981万人增加到4 778万人。2005年实行“三奖一补”之后,2006年财政供养人口下降了318万。之后又开始缓慢上升,2008年达到4 631万。2009年后,财政供养人员的数据不再公布。 2929 其三,是把基层财政资源向上一级政府统筹,比如2003年开始试点的“乡财县管”改革。农村税费改革后,乡镇一级的财政收入规模和支出范围大大缩减,乡镇冗员问题、管理问题、债务问题就变得突出。通过预算共编、票据统管、县乡联网等手段,把乡镇财政支出的决定权上收到县,有利于规范乡镇行为,也有利于在县域范围内实现乡镇之间公共服务的均等化。根据财政部网站,截至2012年底,86%的乡镇都已经实施了“乡财县管”。\n让县政府去统筹乡镇财务,那县一级的财政紧张状况又该怎么办呢?在市管县的行政体制下,县的收入要和市里分账,可市财政支出和招商引资却一直偏向市区,“市压县,市刮县,市吃县”现象严重,城乡差距不断拉大。而且很多城市本身经济发展水平也不高,难以对下辖县产生拉动作用,所以在21世纪初,全国开始推行“扩权强县”和“财政省直管县”改革。前者给县里下放一些和市里等同的权限,比如土地审批、证照发放等;后者则让县财政和省财政直接发生关系,绕开市财政,在财政收支权力上做到县市平级。这些改革增加了县一级的财政资源,缩小了城乡差距。 3030 “乡财县管”和“省直管县”改革,实质上把我国五级的行政管理体制(中央—省—市—区县—乡镇)在财政管理体制上“拉平”了,变成了三级体制(中央—省—市县)。县里的财政实力固然是强了,但是否有利于长远经济发展,则不一定。“省直管县”这种做法源于浙江,20世纪90年代就在全省施行,效果很好。但浙江情况特殊,县域经济非常强劲,很多县乃至乡镇都有特色产业集群。2019年,浙江省53个县(含县级市)里,18个是全国百强县。但在其他一些省份,“省直管县”改革至少遭遇了两个困难。首先是省里管不过来。改革前,一个省平均管12个市,改革后平均管52个市县。钱和权给了县,但监管跟不上,县域出现了种种乱象,比如和土地有关的腐败盛行。其次,县市关系变动不一定有利于县的长远发展。以前县归市管,虽受一层“盘剥”,但跟市区通常还是合作大于竞争。但改革以后,很多情况下竞争就大于合作了,导致县域经济“孤岛化”比较严重。尤其在经济欠发达地区,市的实力本就不强,现在进一步分裂成区和县,更难以产生规模和集聚效应。经济弱市的“小马”本就拉不动下辖县的“大车”,但改革并没有把“小马”变成“大马”,反倒把“大车”劈成了一辆辆“小车”,结果是小城镇遍地开花,经济活动和人口不但没有向区域经济中心的市区集聚,反而越搞越散。从现有研究来看,省直管县之后,虽然县里有了更多资源,但人均GDP增速反而放缓了。 3131 总体来看,分税制改革后,基层财政出现了不少困难,引发了一系列后续改革,最终涉及了财税体制的层级问题。到底要不要搞扁平化,学发达国家搞三级财政?是不是每个省都应该搞?对于相关改革效果和未来方向,目前仍有争议。\n地区间不平等 # 我国地区间经济发展的差距从20世纪90年代中后期开始扩大。由于出口飞速增长,制造业自然向沿海省份集聚,以降低出口货运成本。这种地理分布符合经济规律,并非税收改革的后果。但随着产业集聚带来的优势,地区间经济发展水平和财力差距也越拉越大。公共财政的一个主要功能就是再分配财政资源,平衡地区间的人均公共服务水平(教育、医疗等),所以中央也开始对中西部地区进行大规模转移支付。1995年至2018年,转移支付总额从665亿元增加到了61 649亿元,增加了93倍,远高于地方财政收入的增长率,占GDP的比重也从1%升至7%。 3232 80%以上的转移支付都到了中西部地区,这保障了地区间人均财政支出的均等化。 3333 虽然目前东部和中西部的公共服务水平差异依然明显,但如果没有中央转移支付,地区差异可能更大。\n图2-6描绘了最富的3个省(江苏、浙江、广东)与最穷的3个省(云南、贵州、甘肃)之间人均财政收支的差距。以2018年为例,苏浙粤的人均财政收入和人均GDP是云贵甘的2.7倍,但由于中央的转移支付,这些省份的人均财政支出基本持平,人均财政支出的差距在过去20年中也一直远小于人均财政收入。自2005年起,地区间人均财政支出差距进一步收窄,这与上节提到的“三奖一补”政策有关。虽然省一级的人均财政支出基本均衡,但到了县一级,地区间差距就大了。以2009年为例,人均财政支出最高的1%的县,支出是最低的1%的县的19倍。 3434 这种基层间的差距和上节讨论过的纵向差距有关:越往基层分到的钱越少,省级的差距到了基层,就被层层放大了。\n图2-6 苏浙粤与云贵甘人均财力之比\n数据来源:万得数据库。\n注:江苏、浙江、广东的人均GDP最高,而云南、贵州、甘肃则最低。此处未包括4个直辖市和西藏自治区,这些地区和其他省份不太具有可比性。\n中央对地方的转移支付大概可以分为两类:一般性转移支付(2009年之后改称“均衡性转移支付”)和专项转移支付。 3535 简单来说,前者附加条件少,地方可自行决定用途,而后者必须专款专用。为什么要指定资金用途、不让地方自主决策呢?因为无条件的均衡性转移支付是为了拉平地区差距,所以越穷的地方拿到的钱越多,地方也就越缺乏增收动力。而且均衡性转移支付要保证政府运作和公务员工资,可能会刺激财政供养人员增加,恶化冗员问题。\n专项转移支付约占转移支付总额的四成,一般以“做项目”的形式来分配资金,专款专用,可以约束下级把钱花在上级指定的地方,但在实际操作中,这种转移支付加大了地区间的不平等。 3636 经济情况越好、财力越雄厚的地区,反而可能拿到更多的专项转移支付项目,原因有三。第一,上级分配项目时一般不会“撒胡椒面儿”,而是倾向于集中财力投资大项目,并且交给有能力和条件的地区来做,所谓“突出重点,择优支持”。第二,2015年之前,许多项目都要求地方政府提供配套资金,只有有能力配套的地方才有能力承接大项目,拿到更多转移支付。 3737 第三,项目审批过程中人情关系在所难免。很多专项资金是由财政部先拨款给各部委后再层层下拨,所以就有了“跑部钱进”的现象,而经济发达地区往往与中央部委的关系也更好。 3838 公共财政的重要功能是实现人均公共服务的均等化,虽然我国在这方面已取得了长足进展,但可改进的空间依然很大。从目前情况来看,东中西部省份之间、同一省份的城乡之间、同一城市的户籍人口和非户籍人口之间,公共服务的差别依然很大。第五章将会继续探讨这些地区间不均衡、人与人不平等的问题。\n结语 # 要深入了解政府,必须了解财税。本章介绍了1994年分税制改革的逻辑和后果。图2-7总结了本章各部分内容之间的关系。从中可以看到,制度改革必须不断适应新的情况和挑战。理解和评价改革,不能生搬硬套某种抽象的哲学或理论标准,而必须深入了解改革背景和约束条件,仔细考量在特定时空条件下所产生的改革效果。只有理解了分税制改革的必要性和成功经验,才能理解其中哪些元素已经不适应新情况,需要继续改革。\n图2-7 第二章内容小结\n分税制之后兴起的“土地财政”,为地方政府贡献了每年五六万亿的土地使用权转让收入,着实可观,但仍不足以撬动飞速的工业化和城市化。想想每年的基础设施建设投入,想想高铁从起步到普及不过区区十年,钱从哪里来?每个城市都在大搞建设,高楼、公园、道路、园区……日新月异,钱从哪里来?所以土地真正的力量还不在“土地财政”,而在以土地为抵押而撬动的银行信贷与其他各路资金。“土地财政”一旦嫁接了资本市场,加上了杠杆,就成了“土地金融”,能像滚雪球般越滚越大,推动经济飞速扩张,也造就了地方政府越滚越多的债务,引发了一系列宏观经济问题。“土地金融”究竟是怎么回事?政府究竟是如何融资和投资的?中外媒体和分析家们都很关心的地方政府债务,究竟是什么情况?这些是下一章的内容。\n扩展阅读 # 财政和税收本身就是一个专业,涉及内容繁多,很多大学都有专门的系所甚至学院。本章重点不是财税本身,而是以财税的视角去理解地方政府行为。北京大学周飞舟的著作《以利为利:财政关系与地方政府行为》(2012)与本章视角类似,但更加系统和全面,详细介绍了从新中国成立初期一直到21世纪初主要财政改革的前因后果,有不少一手调研资料,逻辑性和结构都很好,是一本优秀的入门读物。\n要深入了解城市的土地财政,就必须了解农村的土地制度,因为城市的新增建设用地大都是从农村征收来的。北京大学周其仁的著作《城乡中国》(2017年修订版)阐释了城乡土地制度,既追溯了过往,也剖析了当下,语言轻松,说理清楚。中国人民大学刘守英的著作《土地制度与中国发展》(2018)则更为全面和详细,适合进阶参考。\n财政和土地制度是国家大事,改变着每个人的生活。要想真正理解这些改革,需要深入基层,观察这些改革如何影响了官员、企业家和普通人的行为,如何改变了他们之间的关系。华中科技大学吴毅的著作《小镇喧嚣:一个乡镇政治运作的演绎与阐释》(2018年新版)是一份非常详细和生动的记录。这本社会学著作以50万字的篇幅记录了21世纪初中部某小镇上发生的很多事,大都围绕经济问题展开。故事本身以及作者的评论,都很精彩,能让人看到“上面”来的改革对基层个体的重大影响。在另一部类似的杰作《他乡之税:一个乡镇的三十年,一个国家的“隐秘”财政史》(2008)中,田毅和赵旭记录了一个北方小镇的故事。与吴毅之作不同,本书的叙事从1978年开始,记录了30年间财政变革给基层带来的种种变化。读者尤其可以了解分税制后基层财政的悬浮和空转状态,了解农业税费改革之前基层盛行的“买税”或“协税”现象。\n11 本小节重点参考了北京大学周飞舟的著作(2012),这是理解中华人民共和国成立之后财政改革历程和逻辑的极佳读物。\n22 这种激励效果的理论和证据,可参考金和辉、清华大学钱颖一、斯坦福大学温加斯特(Weingast)等人的合作研究(Jin, Qian and Weingast, 2005)。\n33 乡镇企业税收和雇工人数的数据来自香港科技大学龚启圣和林益民的研究(Kung and Lin,2007)。乡镇企业利润税率的数据来自圣地亚哥加州大学诺顿(Naughton)的著作(2020)。\n44 香港中文大学王绍光在其著作(1997)中讨论了央地之间囚徒困境式的博弈:地方政府预料到中央在重新谈判中可能“鞭打快牛”,所以不愿意努力征税。这个理论只是猜测,很难验证。而把预算内收入转成预算外收入的逻辑,有数据支持,参见来自华中科技大学李学文和卢新海、浙江大学张蔚文等人的合作研究(2012)。\n55 引文来自财政部财政科学研究所刘克崮和贾康主编的财政改革回忆录(2008)。\n66 1993年带队赴各省份做工作的朱镕基同志时任国务院副总理。\n77 时任广东省委书记,应为“谢非”。\n88 应该是傅锐。\n99 引文出自刘克崮和贾康(2008)。\n1010 此处回忆可能有偏差,1993年全年地方税收收入比1992年增长了38%。\n1111 引文参见刘克崮和贾康(2008)。\n1212 《国务院办公厅转发财政部关于2001年11月和12月上中旬地方企业所得税增长情况报告的紧急通知》。\n1313 图2-3中,企业所得税在2000年就开始大幅攀升,这可能是由于统计口径调整。2000年前的企业所得税统计只包括国有企业和集体企业,之后则包括了所有企业。有一种方法可以剔除统计口径调整所带来的影响,就是比较同一年中财政预算和决算两个数字。若无特殊情况,这两个数字应该差别不大。根据2002年《中国财政年鉴》,2001年地方企业所得税收入的预算数是1 049亿元,但决算数是1 686亿元,增长了61%。这种暴增在其他税种中是没有的。比如当时改革没有涉及的营业税,预算1 830亿元,决算1 849亿元;而早已经完成改革的增值税,预算是1 229亿元,决算是1 342亿元。再比如同样经历改革但没有“基数投机”冲动的中央企业所得税,预算是937亿元,决算是945亿元。\n1414 浙江大学方红生和复旦大学张军(2013)总结了分税制改革之后关于税收征管力度的研究。\n1515 地方税收压力恶化了工业污染,推升了过剩产能,相关证据来自中国社会科学院席鹏辉和厦门大学梁若冰、谢贞发(2017)以及苏国灿等人的研究(2017)。\n1616 分税制改革后,地方财政支出重生产而轻民生,证据很多。比如复旦大学傅勇和张晏对省级支出的研究(2007),中国人民大学马光荣和吕冰洋及中国社会科学院张凯强对地级市支出的研究(2019),北京师范大学尹恒和北京大学朱虹对县级支出的研究(2011)等。\n1717 2007年,我国重新调整了政府收支预算科目分类,所以没有办法直接比较2007年前后政府预算内支出中用于经济建设的比例,但无疑是下降了。\n1818 对新中国成立后土地产权制度演变过程和逻辑有兴趣的读者,可以参考本章结尾的“扩展阅读”。\n1919 虽然法律规定集体土地还可以用于乡镇企业建设,但随着乡镇企业纷纷开始所有制改革,真正的乡镇企业越来越少,因此这个规定意义不大。此外,1997年以后实行用地规模和指标审批管理制度,省级政府自然将紧缺的建设用地指标优先分配给省会和城市市区,给到县城的用地指标很少,而大部分集体建设土地位于县城。关于这方面更详细的介绍,可参考中国人民大学刘守英的著作(2018)第八章。本段中用到的数字也来自该书。\n2020 在2016年营业税改增值税以前,房地产开发和建筑企业缴纳的主要是营业税,百分之百归地方,不用和中央分成,所以占地方公共预算收入的比重甚至更高。以2013年为例,房地产开发和建筑企业缴纳的营业税和归属地方的所得税加起来相当于地方公共预算收入的16%。本段中的数字均出自历年的《中国税务年鉴》。\n2121 详细的阐释可参考张五常的著作(2017)。\n2222 从1994年分税制改革之后一直到2008年,每年中央转移支付总额都高于地方预算收支缺口,一般要高10%—20%。2009年“4万亿”财政金融刺激之后,地方可以通过发债来融资,收支缺口才开始大于中央转移支付(2015年新版预算法之后,省级政府才可以发债。但在2009年至2014年间,财政部可以代理省级政府发债)。\n2323 对基层财政的“悬浮”状态和政府运转中的种种困难,田毅和赵旭的著作(2008)以及吴毅的著作(2018)中都有生动的记录和深刻的分析。\n2424 2003年党的十六届三中全会提出了与“科学发展观”相适应的“五个统筹”:统筹城乡发展、统筹区域发展、统筹经济社会发展、统筹人与自然和谐发展、统筹国内发展和对外开放。\n2525 改革前的农村税费负担,大概可以分为“税”“费”“工”三类。税,即“农业五税”:农业税、农业特产税、屠宰税、涉农契税、耕地占用税。费,即所谓“三提五统”:村集体的三项提留费用(村干部管理费、村公积金、村公益金)和乡政府的五项统筹费用(教育附加、计划生育、优抚、民兵训练、乡村道路建设)。工,就是“两工”:农村义务工和劳动积累工,主要用于植树造林、防汛、修缮校舍等。\n2626 参见中央财经大学陈斌开和北京大学李银银的合作研究(2020)。\n2727 数字来自财政部前部长楼继伟和财政科学研究院院长刘尚希的合作著作(2019)。\n2828 “三奖一补”包括:对财政困难的县乡政府增加县乡税收收入、省市级政府增加对财政困难县财力性转移支付给予奖励;对县乡政府精简机构和人员给予奖励;对产粮大县给予奖励;对此前缓解县乡财政困难工作做得好的地方给予补助。\n2929 数据来自财政部预算司原司长李萍主编的读物(2010)。如果按照2006—2008年的年均增速1.8%推算,2018年地方财政供养人员应该在5 500万人左右。根据楼继伟(2013)的数据,全国的公务员中,地方占94%(2011年)。如果这个比例也适用于全部财政供养人员,那2018年全国财政供养人员总数(在职加退休)大概是5 850万。\n3030 关于“省直管县”改革的研究很多,有兴趣的读者可以参考复旦大学谭之博、北京大学周黎安、中国人民银行赵岳等人的合作研究(2015)。\n3131 “省直管县”改革引发的土地腐败和经济增速放缓,及改革前后省政府管理的行政单位数目,来自浙江大学李培、清华大学陆毅、香港科技大学王瑾的合作研究(Li, Lu and Wang, 2016)。\n3232 1995年的转移支付数据来自《1995年全国地市县财政统计资料》,2018年的数据来自财政部网站公布的《关于2018年中央决算的报告》。\n3333 根据财政部公布的《关于2018年中央决算的报告》,当年85%的转移支付用在了中西部地区。而根据云南财经大学缪小林和高跃光以及云南大学王婷等人的计算(2017),从1995年到2014年,80%以上的转移支付都分配到了中西部地区。\n3434 目前可得的县级财政支出数据只到2009年,来自《2009年全国地市县财政统计资料》。我在计算时仅包括了县和县级市,不包括市区,也没有包括4个直辖市和西藏自治区。\n3535 广义转移支付还应包括税收返还,但这部分钱本就属于地方,不包含在本段的统计中。\n3636 专项转移支付实际上增大了地方人均财力的差别,这方面证据很多,比如中国宏观经济研究院王瑞民和中国人民大学陶然的研究(2017)。\n3737 2015年2月,国务院发布《关于改革和完善中央对地方转移支付制度的意见》,明确“中央在安排专项转移支付时,不得要求地方政府承担配套资金”。\n3838 部委的人情关系在专项资金分配中有重要作用,参见上海财经大学范子英和华中科技大学李欣的研究(2014)。\n第三章 政府投融资与债务 # 暨南大学附近有个地方叫石牌村,是广州最大的城中村之一,很繁华。前两年有个美食节目探访村中一家卖鸭仔饭的小店,东西好吃还便宜,一份只卖12元。中年老板看上去非常朴实,主持人问他:“挣得很少吧?”他说:“挣得少,但是我生活得很开心。因为我自己……我告诉你,我也是有……不是很多钱啦,有10栋房子可以收租。”主持人一脸“怪不得”的样子对着镜头哈哈大笑起来:“为什么可以卖12元?因为他有10套房子可以收租!”老板平静地纠正他:“是10栋房哦,不是10套房哦,10栋房,一栋有7层。”主持人的大笑被这突如其来的70层楼拍扁在了脸上……\n很多人都有过幻想:要是老家的房子和地能搬到北、上、广、深就好了。都是地,人家的怎么就那么值钱?区区三尺土地,为什么一旦变成房本,别人就得拿大半辈子的收入来换?\n再穷的国家也有大片土地,土地本身并不值钱,值钱的是土地之上的经济活动。若土地只能用来种小麦,价值便有限,可若能吸引来工商企业和人才,价值想象的空间就会被打开,笨重的土地就会展现出无与伦比的优势:它不会移动也不会消失,天然适合做抵押,做各种资本交易的压舱标的,身价自然飙升。土地资本化的魔力,在于可以挣脱物理属性,在抽象的意义上交易承诺和希望,将过去的储蓄、现在的收入、未来的前途,统统汇聚和封存在一小片土地上,使其价值暴增。由此产生的能量不亚于科技进步,支撑起了工业化和城市化的巨大投资。经济发展的奥秘之一,正是把有形资产转变成为这种抽象资本,从而聚合跨越空间和时间的资源。 11 上一章介绍了城市政府如何平衡工业用地和商住用地供应,一手搞“工业化”,一手搞“城市化”,用土地使用权转让费撑起了“第二财政”。但这种一笔一笔的转让交易并不能完全体现土地的金融属性。地方政府还可以把与土地相关的未来收入资本化,去获取贷款和各类资金,将“土地财政”的规模成倍放大为“土地金融”。\n本章第一节用实例来解释这种“土地金融”与政府投资模式。第二节介绍这种模式的弊端之一,即地方政府不断加重的债务负担。与政府债务相关的各项改革中也涉及对官员评价和激励机制的改革,因此第三节将展开分析地方官员在政府投融资过程中的角色和行为。\n第一节 城投公司与土地金融 # 实业投资要比金融投资复杂得多,除了考虑时间、利率、风险等基本要素之外,还要处理现实中的种种复杂情况。基金经理从股票市场上买入千百个散户手中的股票,和地产开发商从一片土地上拆迁掉千百户人家的房子,虽然同样都是购买资产,都算投资,但操作难度完全不同。后者若没有政府介入,根本干不成。实业投资通常是个连续的过程,需要不断投入,每个阶段打交道的对象不同,所需的专业和资源不同,要处理的事务和关系也不同。任一阶段出了纰漏,都会影响整个项目。就拿盖一家商场来说,前期的土地拆迁、中期的开发建设、后期的招商运营,涉及不同的专业和事务,往往由不同的主体来投资和运作,既要考虑项目整体的连续性,也要处理每一阶段的特殊性。\n我国政府不但拥有城市土地,也掌控着金融系统,自然会以各种方式参与实业投资,不可能置身事外。但实业投资不是买卖股票,不能随时退出,且投资过程往往不可逆:未能完成或未能正常运转的项目,前期的投入可能血本无归。所以政府一旦下场就很难抽身,常常不得不深度干预。在很长一段时期内,中国GDP增长的主要动力来自投资,这种增长方式必然伴随着政府深度参与经济活动。这种方式是否有效率,取决于经济发展阶段,本书下篇将深入探讨。但我们首先要了解政府究竟是怎么做投资的。本节聚焦土地开发和基础设施投资,下一章再介绍工业投资。\n地方政府融资平台:从成都“宽窄巷子”说起 # 法律规定,地方政府不能从银行贷款,2015年之前也不允许发行债券,所以政府要想借钱投资,需要成立专门的公司。 22 这类公司大都是国有独资企业,一般统称为“地方政府融资平台”。这个名称突出了其融资和负债功能,所以经济学家和财经媒体在谈及这些公司时,总是和地方债务联系在一起。但这些公司的正式名称可不是“融资平台”,而大都有“建设投资”或“投资开发”等字样,突出自身的投资功能,因此也常被统称为“城投公司”。比如芜湖建设投资有限公司(奇瑞汽车大股东)和上海城市建设投资开发总公司(即上海城投集团),都是当地国资委的全资公司。还有一些公司专门开发旅游景点,名称中一般有“旅游发展”字样,比如成都文化旅游发展集团,也是成都市政府的全资公司,开发过著名景点“宽窄巷子”。\n“宽窄巷子”这个项目,投融资结构比较简单,从立项开发到运营管理,由政府和国企一手包办。“宽窄巷子”处于历史文化保护区域,开发过程涉及保护、拆迁、修缮、重建等复杂问题,且投资金额很大,周期很长,盈利前景也不明朗,民营企业难以处理。因此这个项目从2003年启动至今,一直由两家市属全资国企一手操办:2007年之前由成都城投集团负责,之后则由成都文旅集团接手。2008年景区开放,一边运营一边继续开发,直到2019年6月,首期开发才正式完成,整整花了16年。从文旅集团接手开始算,总共投入约6.5亿元,其中既有银行贷款和自有收入,也有政府补贴。\n成都文旅集团具有政府融资平台类公司的典型特征。\n第一,它持有从政府取得的大量土地使用权。这些资产价值不菲,再加上公司的运营收入和政府补贴,就可以撬动银行贷款和其他资金,实现快速扩张。2007年,公司刚成立时注册资本仅5亿元,主业就是开发“宽窄巷子”。2018年,注册资本已达31亿元,资产204亿元,下属23家子公司,项目很多。 33 第二,盈利状况依赖政府补贴。2015—2016年,成都文旅集团的净利润为6 600多万元,但从政府接收到的各类补贴总额超过2亿元。补贴种类五花八门,除税收返还之外,还有纳入公共预算的专项补贴。比如成都市公共预算内有一个旅游产业扶持基金,2012—2015年间,每年补贴文旅集团1亿元以上。政府还可以把土地使用权有偿转让给文旅集团,再以某种名义将转让费原数返还,作为补贴。 44 2015年新《预算法》要求清理各种补贴。2017—2018年,文旅集团的净利润近1.4亿元,补贴则下降到不足4 000万元。\n盈利状况依赖政府补贴,是否就是没效率?不能一概而论。融资平台公司投资的大多数项目都有基础设施属性,项目本身盈利能力不强,否则也就无需政府来做了。这类投资的回报不能只看项目本身,要算上它带动的经济效益和社会效益。但说归说,这些“大账”怎么算,争议很大。经济学的福利分析,作为一种推理逻辑有些用处,但从中估算出的具体数字并不可靠。2009年初,我第一次去“宽窄巷子”,当时还是个新景点,人并不多。2016年夏天,我第二次去,这里已经成了著名景点,人山人海。根据文旅集团近几年披露的财务数据,“宽窄巷子”每年接待游客2 000万人次,集团营收八九千万元,净利两三千万元,且增速很快,经济效益很好。但就算没有净利甚至亏损,这项目就不成功么?也难说。2 000万游客就算人均消费50元,每年10亿元的体量也是个相当不错的小经济群。而且还带动着周边商业、餐饮、交通的繁荣,社会效益也不错。对政府和老百姓来说,这也许比项目本身的盈利能力更加重要。\n第三,政府的隐性担保可以让企业大量借款。银行对成都文旅集团的授信额度为176亿元,而文旅集团发行的债券,评级也是AA+。该公司是否应有这么高的信用,见仁见智。但市场一般认为,融资平台公司背后有政府的隐性担保。所谓“隐性”,是因为《担保法》规定政府不能为融资平台提供担保。但其实政府不断为融资平台注入各类资产,市场自然认为这些公司不会破产,政府不会“见死不救”,所以风险很低。\n“宽窄巷子”这个项目比较特殊,大多数类似的城市休闲娱乐项目并不涉及大量历史建筑的修缮和保护,开发过程也没这么复杂,可以由民营企业完成。比如遍及全国的万达广场和著名的上海新天地(还有武汉天地、重庆天地等),都是政府一次性出让土地使用权,由民营企业(万达集团和瑞安集团)开发和运营。当地的融资平台公司一般只参与前期的拆迁和土地整理。用术语来说,一块划出来的“生地”,平整清理后才能成为向市场供应的“熟地”,这个过程称为“土地一级开发”。“一级开发”投入大、利润低,且涉及拆迁等复杂问题,一般由政府融资平台公司完成。之后的建设和运营称为“二级开发”,大都由房地产公司来做。\n工业园区开发:苏州工业园区vs华夏幸福 # 从运营模式上看,成都文旅集团有政府融资平台企业的显著特点,但大多数融资平台的主业不是旅游开发,而是工业园区开发和城市基础设施建设。工业园区或开发区在我国遍地开花。2018年,国家级开发区共552家,省级1 991家,省级以下不计其数。 55 苏州工业园区是规模最大也是最成功的国家级开发区之一,占地278平方公里。2019年,园区GDP为2 743亿元,公共财政预算收入370亿元,经济体量比很多地级市还大。 66 如此大规模园区的开发和运营,自然要比“宽窄巷子”复杂得多,参与公司众多,主力是两家国企:兆润集团负责土地整理和基础设施建设(土地一级开发),2019年底刚上市的中新集团负责建设、招商、运营(土地二级开发)。\n兆润集团(全称“苏州工业园区兆润投资控股集团有限公司”)就是一家典型的融资平台公司。这家国有企业由园区管委会持有100%股权,2019年注册资本169亿元,主营业务是典型的“土地金融”:管委会把土地以资本形式直接注入兆润,由它做拆迁及“九通一平”等基础开发,将“生地”变成可供使用的“熟地”,再由管委会回购,在土地市场上以招拍挂等形式出让,卖给中新集团这样的企业去招商和运营。兆润集团可以用政府注入的土地去抵押贷款,或用未来土地出让受益权去质押贷款,还可以发债,而还款来源就是管委会回购土地时支付的转让费及各种财政补贴。兆润集团手中土地很多,在高峰期的2014年末,其长期抵押借款超过200亿元,质押借款超过100亿元。 77 与成都宽窄巷子或上海新天地这样的商业项目相比,开发工业园区更像基础设施项目,投资金额大(因为面积大)、盈利低,大都由融资平台类国企主导开发,之后交给政府去招商引资。而招商引资能否成功,取决于地区经济发展水平和营商环境。像上海和苏州工业园区这种地方,优秀企业云集,所以招商的重点是“优中选优”,力争更好地聚合本地产业资源和比较优势。我去过苏州工业园区三次,每次都感叹其整洁和绿化环境,不像一个制造业企业云集的地方。2019年,园区进出口总额高达871亿美元。虽说其飞速发展借了长三角的东风,但运营水平如此之高,园区管委会和几家主要国企功不可没。\n而在很多中西部市县,招商就困难多了。地理位置不好,经济发展水平不高,政府财力和人力都有限,除了一些土地,没什么家底。因此有些地方干脆就划一片地出来,完全依托民营企业来开发产业园区,甚至连招商引资也一并委托给这些企业。\n这类民企的代表是华夏幸福,这家上市公司的核心经营模式是所谓的“产城结合”,即同时开发产业园区和房地产。简单来说,政府划一大片土地给华夏幸福,既有工业用地,也有商住用地,面积很大,常以“平方公里”为度量单位。华夏幸福不仅负责拆迁和平整,也负责二级开发。在让该公司声名大噪的河北固安高新区项目中,固安县政府签给华夏幸福的土地总面积超过170平方公里。2017年第11期《财新周刊》对华夏幸福做了深度报道,称其为“造城者”,不算夸张。\n工业园区开发很难盈利。招商引资本就困难,而想培育一个园区,需要引入一大批企业,过程更是旷日持久,所以华夏幸福赚钱主要靠开发房地产。所谓“产城结合”,“产”是旗帜,“城”是重点,需要用卖房赚到的快钱去支持产业园区运营。按照流程,政府委托华夏幸福做住宅用地的一级开发,之后这片“熟地”要还给政府,再以招拍挂等公开方式出让给中标的房地产企业。假如在这一环节中华夏幸福没能把地拿回来,也就赚不到房地产二级开发的大钱。但据《财新周刊》报道,在实际操作中,主导一级开发的华夏幸福是近水楼台,其他企业很难参与其产业园区中的房地产项目。\n用房地产的盈利去反哺产业园区,这听起来很像第二章所描述的政府“土地财政”:一手低价供应工业用地,招商引资,换取税收和就业;一手高价供应商住用地,取得卖地收入。但政府“亏本”招商引资,图的是税收和就业,可作为民企的华夏幸福,又能从工业园区发展中得到什么呢?答案是它也可以和政府分享税收收益。园区内企业缴纳的税收(地方留存部分),减去园区运营支出,华夏幸福和政府可按约定比例分成。按照法律,政府不能和企业直接分享税收,但可以购买企业服务,以产业发展服务费的名义来支付约定的分成。\n政府付费使用私营企业开发建设的基础设施(如产业园区),不算什么新鲜事。这种模式叫“政府和社会资本合作”(Public-Private Partnership, PPP),源于海外,不是中国的发明。如果非要说中国特色,可能有二。第一是项目多,规模大。截至2020年5月,全国入库的PPP项目共9 575个,总额近15万亿元,但真正开工建设的项目只有四成。第二个特色是“社会资本”大都不是民营企业,而是融资平台公司或其他国企,比如本节中提到的成都文旅集团、兆润集团、中新集团等。截至2019年末,在所有落地的PPP项目中,民营企业参与率不过三成,大都只做些独立项目,比如垃圾或污水处理。 88 像华夏幸福这样负责园区整体开发的民企并不多,它打造的河北固安高新区项目也是国家级PPP示范项目。最近两年,对房地产行业以及土地市场的限制越来越严,一些大型传统房企也开始探索这种“产城融合”模式,效果如何尚待观察。\n第二章提到,政府搞城市开发和招商引资,就像运营一个商场,需要用不同的代价引入不同的商铺,实现总体收入最大化。政府还可以把这一整套运作都“外包”给如华夏幸福之类的民营企业,让后者深度参与到招商引资的职能中来。\n诺贝尔经济学奖获得者罗纳德·科斯(Ronald Coase)早在20世纪30年代就问过:“企业和市场的边界在哪里?”“市场如果有效,为什么会有企业?”这些问题不容易回答。如果追问下去,企业和政府的边界又在哪里?从纸面定义看,各种实体似乎泾渭分明,但从实际业务和行为模式来看,融资平台类公司就是企业和政府的混合体,而民营企业如华夏幸福,又承担着政府的招商职能。现实世界中没有定义,只有现象,只有环环相扣的权责关系。或者按张五常的说法,只有一系列合约安排。 99 要想理解这些现象,需要深入调研当事人面临的各种约束,包括能力、资源、政策、信息等,简单的政府—市场二元观,没什么用。\n第二节 地方政府债务 # 图3-1总结了“土地财政”和“土地金融”的逻辑。1994年分税制改革后,中央拿走了大部分税收。但因为有税收返还和转移支付,地方政府维持运转问题不大。但地方还要发展经济,要招商引资,要投资,都需要钱。随着城市化和商品房改革,土地价值飙升,政府不仅靠土地使用权转让收入支撑起了“土地财政”,还将未来的土地收益资本化,从银行和其他渠道借入了天量资金,利用“土地金融”的巨力,推动了快速的工业化和城市化。但同时也积累了大量债务。这套模式的关键是土地价格。只要不断地投资和建设能带来持续的经济增长,城市就会扩张,地价就会上涨,就可以偿还连本带利越滚越多的债务。可经济增速一旦放缓,地价下跌,土地出让收入减少,累积的债务就会成为沉重的负担,可能压垮融资平台甚至地方政府。\n图3-1 “土地财政”与“土地金融”\n资料来源:清华大学郑思齐等人的合作研究(2014)。\n地方债的爆发始于2008—2009年。为应对从美国蔓延至全球的金融危机,我国当时迅速出台“4万亿”计划:中央政府投资1.18万亿元(包括汶川地震重建的财政拨款),地方政府投资2.82万亿元。为配合政策落地、帮助地方政府融资,中央也放宽了对地方融资平台和银行信贷的限制。2008年,全国共有融资平台公司3 000余家,2009年激增至8 000余家,其中六成左右是县一级政府融资平台。快速猛烈的经济刺激,对提振急速恶化的经济很有必要,但大水漫灌的结果必然是泥沙俱下。财政状况不佳的地方也能大量借钱,盈利前景堪忧的项目也能大量融资。短短三五年,地方政府就积累了天量债务。直到十年后的今天,这些债务依然没有完全化解,还存在不小的风险。\n为政府开发融资:国家开发银行与城投债 # 实业投资是个复杂而漫长的过程,不是只看着财务指标下注后各安天命,而需要在各个阶段和各个环节处理各种挑战,所以精心选择投资合作伙伴至关重要。我国大型项目的投资建设,无论是基础设施还是工业项目,大都有政府直接参与,主体五花八门:有投融资平台类国企,有相关行业国企,也有科研和设计院所等单位。在现有的金融体制下,有国企参与或有政府背书的项目,融资比较容易。本节聚焦城投公司的基础设施项目融资,第四章和第六章会讨论工业项目中的政府投融资,以及由政府信用催生的过度投资和债务风险。\n20世纪八九十年代,大部分城市建设经费要靠财政拨款。1994年分税制改革后,地方财力吃紧,城市化又在加速,城建经费非常紧张,如果继续依靠财政从牙缝里一点点抠,大规模城建恐怕遥遥无期。但要想在城市建设开发中引入银行资金,需要解决三个技术问题。第一,需要一个能借款的公司,因为政府不能直接从银行贷款;第二,城建开发项目繁复,包括自来水、道路、公园、防洪,等等,有的赚钱,有的赔钱,但缺了哪个都不行,所以不能以单个项目分头借款,最好捆绑在一起,以赚钱的项目带动不赚钱的项目;第三,仅靠财政预算收入不够还债,要能把跟土地有关的收益用起来。\n为解决这三个问题,城投公司就诞生了。发明这套模式的是国家开发银行。1998年,国家开发银行(以下简称“国开行”)和安徽芜湖市合作,把8个城市建设项目捆绑在一起,放入专门创立的城投公司芜湖建投,以该公司为单一借款人向国开行借款10.8亿元。这对当时的芜湖来说是笔大钱,为城市建设打下了基础。当时还不能用土地生财,只能靠市财政全面兜底,用预算安排的偿还基金做偿债来源。2002年,全国开始推行土地“招拍挂”,政府授权芜湖建投以土地出让收益做质押作为还款保证。2003年,在国开行和天津的合作中,开始允许以土地增值收益作为贷款还款来源。这些做法后来就成了全国城投公司的标准模式。 1010 国开行是世界上最大的开发性银行,2018年资产规模超过16万亿元人民币,约为世界银行的5倍。 1111 2008年之前,国开行是城投公司最主要的贷款来源。2008年“4万亿”财政金融刺激之后,各种商业银行包括“工农中建”四大行和城市商业银行(以下简称“城商行”),才开始大规模贷款给城投公司。2010年,在地方融资平台公司的所有贷款中,国开行约2万亿元,四大行2万亿元,城商行2.2万亿元,其他股份制银行与农村合作金融机构合计1万亿元,城商行已经和国开行、四大行平起平坐。 1212 城商行主要由地方政府控制。2015年,七成左右的城商行的第一股东是地方政府。 1313 在各地招商引资竞争中,金融资源和融资能力是核心竞争力之一,因此地方政府往往掌控至少一家银行,方便为融资平台公司和基础设施建设提供贷款。但城商行为融资平台贷款存在两大风险。其一,基础设施建设项目周期长,需要中长期贷款。国开行是政策性银行,有稳定的长期资金来源,适合提供中长期贷款。但商业银行的资金大都来自短期存款,与中长期贷款期限不匹配,容易产生风险。其二,四大行的存款来源庞大稳定,可以承受一定程度的期限错配。但城商行的存款来源并不稳定,自有资本也比较薄弱,所以经常需要在资本市场上融资,容易出现风险。以包商银行为例,2019年被监管机构接管,2020年提出破产申请,属银行业20年来首次。该行吸收的存款占其负债总额不足一半,剩余负债几乎全部来自银行同业业务。 1414 这个例子虽然极端,但在4万亿刺激后的10年中,全国中小城商行普遍高度依赖同业融资。流动性一旦收紧,就可能引发连锁反应。\n城投公司最主要的融资方式是银行贷款,其次是发行债券,即通常所说的城投债。与贷款相比,发行债券有两个理论上的好处。其一,把债券卖给广大投资者可以分散风险,而贷款风险都集中在银行系统;其二,债券可以交易,价格和利率时时变动,反映了市场对风险的看法。高风险债券价格更低,利率更高。灵活的价格机制可以把不同风险的债券分配给不同类型的投资者,提高了配置效率。\n但对城投债来说,这两个理论上的好处基本都不存在。第一,绝大多数城投债都在银行间市场发行,七八成都被商业银行持有,流动性差,风险依然集中在银行系统。第二,市场认为城投债有政府隐性担保,非常安全。缺钱的地方明明风险不小,但若发债时提高一点利率,也会受市场追捧。 1515 事实证明市场是理性的。城投债从2008年“4万亿”刺激后开始爆发,虽经历了大小数次改革和清理整顿,但整体违约率极低。这个低风险高收益的“怪胎”对债券市场发展影响很大,积累的风险其实不小。\n地方债务与风险 # 地方政府的债务究竟有多少,没人知道确切数字。账面上明确的“显性负债”不难算,麻烦主要在于各种“隐性负债”,其中融资平台公司的负债占大头。中外学术界和业界对中国的地方政府债务做了大量研究,所估计的地方债务总额在2015年到2017年间约为四五十万亿元,占GDP的五六成,其中三四成是隐性负债。 1616 地方债总体水平虽然不低,但也不算特别高。就算占GDP六成,再加上中央政府国债,政府债务总额占GDP的比重也不足八成。相比之下,2018年美国政府债务占GDP的比重为107%,日本更是高达237%。 1717 而且我国地方政府借来的钱,并没有多少用于政府运营性支出,也没有像一些欧洲国家如希腊那样去支付社会保障,而主要是投资在了基础设施项目上,形成了实实在在的资产。虽然这些投资项目的回报率很低,可能平均不到1%,但如果“算大账”,事实上也拉动了GDP,完善了基础设施,方便了民众生活,整体经济与社会效益可能比项目回报率高。 1818 此外,我国政府外债很少。根据国家外汇管理局《2019年中国国际收支报告》中的数据,2019年末广义政府(政府加央行)的外债余额为3 072亿美元,仅占GDP的2%。\n但是债务风险不能只看整体,因为欠债的不是整体而是个体。如果某人欠了1亿元,虽然理论上全国人民每人出几分钱就够还了,但实际上这笔债务足以压垮这个人。地方债也是一样的道理,不能用整体数字掩盖局部风险。纵向上看,层级越低的政府负担越重,风险越高。县级债务负担远高于省级,因为县级的经济发展水平更低,财政收入更少。横向上看,中西部的债务负担和风险远高于东部。 1919 虽然从经济分析的角度看,地方政府投资的项目有很多外溢的经济效益和社会效益,但在现实世界里,还债需要借款人手中实打实的现金,虚的效益没用。融资平台投资回报率低,收入就低,还债就有困难。由于有地方政府背后支持,这些公司只要能还上利息和到期的部分本金,就能靠借新还旧来滚动和延续其余债务。但大多数融资平台收入太少,就算是只还利息也要靠政府补贴。2017年,除了北京、上海、广东、福建、四川和安徽等六省市外,其他省份的融资平台公司的平均收入,若扣除政府补贴,都无法覆盖债务利息支出。 2020 但政府补贴的前提是政府有钱,这些钱主要来自和土地开发有关的各种收入。一旦经济遇冷,地价下跌,政府也背不起这沉重的债务。\n地方债的治理与改革 # 对地方债务的治理始于2010年,十年间兜兜转转,除了比较细节的监管措施,重要的改革大概有四项。第一项就是债务置换,从2015年新版《预算法》生效后开始,到2019年基本完成。简单来说,债务置换就是用地方政府发行的公债,替换一部分融资平台公司的银行贷款和城投债。这么做有三个好处。其一,利率从之前的7%—8%甚至更高,降低到了4%左右,大大减少了利息支出,缓解了偿付压力。低利率也有利于改善资本市场配置资金的效率。融资平台占用了大量银行贷款,也发行了大量城投债,因为有政府隐性担保,市场认为这些借款风险很低,但利率却高达7%—8%,银行(既是贷款主体,也是城投债主要买家)当然乐于做大这个低风险高收益的业务,不愿意冒险借钱给其他企业,市场平均利率和融资成本也因此被推高。这种情况严重削弱了利率调节资金和风险的功能,需要改革。其二,与融资平台贷款和城投债相比,政府公债的期限要长得多。因为基础设施投资的项目周期也很长,所以债务置换就为项目建设注入了长期资金,不用在短期债务到期后屡屡再融资,降低了期限错配和流动性风险。其三,至少从理论上说,政府信用要比融资平台信用更高,债务置换因此提升了信用级别。\n债务置换是为了限制债务增长,规范借债行为,所以地方政府不能无限制地发债去置换融资平台债务。中央对发债规模实行限额管理:总体限额由国务院确定并报全国人大或全国人大常委会批准,各地区限额则由财政部根据各地债务风险和财力测算决定,报国务院批准。这种数量管制的好处是限额不能突破,是硬约束;坏处是比较僵硬,不够灵活。经济发达地区可能有更多更好的项目,但因为超过了限额而无法融资,而欠发达地区一些不怎么样的项目,却因为在限额之内就能借到钱。\n第二项改革是推动融资平台转型,厘清与政府之间的关系,剥离其为政府融资的功能,同时破除政府对其形成的“隐性”担保。融资平台公司业务中一些公共服务性质强的城建或基建项目,可以剥离出来,让地方政府用债务置换的方式承接过去,也可以用PPP模式来继续建设。然而很多平台公司负债累累,地方政府有限的财力也只能置换一部分,剩余债务剥离不了,公司转型就很困难。而且要想转型为普通国企,公司的业务和治理架构都要改变,业务要能产生足够的现金流,公司领导也不能再由官员兼任。所以融资平台转型并不容易,目前还远未完成。在这种情况下要想遏制其债务继续增长,就要制止地方政府继续为其提供隐性担保。在近几年的多起法院判例中,地方政府提供的担保函均被判无效。2017年,财政部问责了多起违规担保。比如重庆黔江区财政局曾为当地的融资平台公司出具过融资产品本息承诺函,后黔江区政府、区财政局、融资平台公司的有关负责人均被处分。 2121 第三项改革是约束银行和各类金融机构,避免大量资金流入融资平台。这部分监管的难点不在银行本身,而在各类影子银行业务。第六章会详谈相关改革,包括2018年出台的“资管新规”。\n第四项改革就是问责官员,对过度负债的行为终身追责。这项改革从2016年开始。2018年,中共中央办公厅和国务院办公厅正式下发《地方政府隐性债务问责办法》,要求官员树立正确的政绩观,严控地方政府债务增量,终身问责,倒查责任。最近几年也确实问责了一些干部,案件类型主要集中在各类违规承诺,比如上文中提到的对重庆黔江区政府的问责。这种明显违规的操作容易查处,但更重要的是那些没有明显违规举债却把钱投资到了没效益的项目上的操作,这类行为难以确定和监管。深层次的改革,需要从根本上约束官员的投资冲动。那么,这种冲动的体制根源在哪里呢?\n第三节 招商引资中的地方官员 # 几年前,我参加中部某市的招商动员大会,有位招商业绩不错的干部分享心得:“要对招商机会有敏感度,要做一个执着的跟踪者,不能轻言放弃。要在招商中锻炼自己,做到‘铜头、铁嘴、顺风耳、橡皮腰、茶壶肚、兔子腿’。”铜头,是指敢闯、敢创造机会;铁嘴,是指能说会道,不怕磨破嘴皮;顺风耳和兔子腿,指消息灵通且行动敏捷;茶壶肚,指能喝酒、能社交。这些形容非常形象,容易理解。我当时不太懂什么是“橡皮腰”,后来听他解释:“要尊重客商,身段该软的时候要能弯得下腰,但在谈判过程中也不能随便让步,若涉及本市重要利益,该把腰挺起来的时候也要挺直了。”这些特点让我联想到了推销员。他接下来讲的话又让我想到了客服:“要关注礼仪,注重细节,做到四条。第一,要信守承诺;第二,要记得回电话,客商的电话、信息要及时回复;第三,遇到事情用最快的速度、最高的效率去处理;第四,要做个有心人,拜访客商要提前做好准备工作。”\n当然,台上做报告可以把话说得很漂亮,现实中可能是另一回事。后来我和该市的招商干部打了几次交道,他们确实非常主动,电话打得很勤,有的项目就算已经表示过不适合引入该地区,对方也还会反复联系,拿新的条件和方案不断试探,不会轻易放弃。在几次交流中我了解到,该市招商工作的流程设置得很好,相关激励机制也比较到位,虽然地区资源和条件有限,但招商工作的确做得有声有色。\n我国官僚体系庞大,官僚体系自古就是政治和社会支柱之一,而且一直有吸纳社会精英的传统,人力资源雄厚。根据2010年的第六次人口普查,25—59岁的城市人口中上过大学的(包括专科)约占22%,但在政府工作人员中上过大学的超过一半。在25—40岁的政府工作人员中,上过大学的超过七成,而同龄城市人口中上过大学的只有三成。 2222 如今社会虽然早已多元化了,优秀人才选择很多,但“学而优则仕”的传统和价值观一直都在,且政府依然是我国最有资源和影响力的部门,所以每年公务员考试都非常火爆,至少要达到大专以上文化程度才能报考,而录取比例也非常低。\n本节聚焦地方官员。从人数构成上看,地方官员是官僚体系的绝对主体。按公务员总人数算,中央公务员只占6%,若把各类事业单位也算上,中央只占4%。这在世界各主要国家中是个异数。美国中央政府公务员占比为19%,日本为14%,德国为11%,而经济合作与发展组织(OECD)成员国的平均值高达41%。 2323 官员政绩与激励机制 # 事在人为,人才的选拔和激励机制是官僚体制的核心,决定着政府运作的效果。所谓激励机制,简单来说就是“胡萝卜加大棒”:事情做好了对个人有什么好处?搞砸了有什么坏处?因为发展经济是地方政府的核心任务,所以激励机制需要将干部个人得失与本地经济发展情况紧密挂钩,既要激励地方主官,也要激励基层公务员。\n从“胡萝卜”角度看,经济发展是地方官的主要政绩,对其声望和升迁有重要影响。而对广大普通政府工作人员而言,职务晋升机会虽然很少,但实际收入与本地财政情况密切相关,也和本部门、本单位的绩效密切相关,这些又都取决于本地的经济发展。从“大棒”角度看,一方面有党纪国法的监督惩罚体系,另一方面也有地区间招商引资的激烈竞争。为防止投资和产业流失,地方官员需要改善本地营商环境,提高效率。若某部门为了部门利益而损害整体营商环境,或部门间扯皮降低了行政效率,上级出于政绩考虑也会进行干预。\n地方主官任期有限,要想在任内快速提升经济增长,往往只能加大投资力度,上马各种大工程、大项目。以市委书记和市长为例,在一个城市的平均任期不过三四年,而基础设施或工业项目最快也要两三年才能完成,所以“新官上任三把火”烧得又快又猛:上任头两年,基础设施投资、工业投资、财政支出往往都会快速上涨。而全国平均每年都有三成左右的地级市要更换市长或市委书记,所以各地的投资都热火朝天,“政治-投资周期”比较频繁。 2424 投资需要资金,需要土地财政和土地金融的支持。所以在官员上任的前几年,土地出让数量一般都会增加。而新增的土地供应大多位于城市周边郊区,所以城市发展就呈现出了一种“摊大饼”的态势:建设面积越扩越大,但普遍不够紧凑,通勤时间长、成本高,加重了拥挤程度,也不利于环保。 2525 虽然官员的晋升动机与促进经济增长目标之间不冲突,也对地区经济表现有相当的解释力,但这种偏重投资的增长模式会造成很多不良后果。 2626 2016年之前,官员升迁或调任后就无需再对任内的负债负责,而新官又通常不理旧账,会继续加大投资,所以政府债务不断攀升。在经济发展到一定阶段之后,低风险高收益的工业投资项目减少,基础设施和城市建设投资的经济效益也在减弱,继续加大投资会降低经济整体效率,助推产能过剩。此外,出于政绩考虑,地方官员在基础设施投资方面常常偏重“看得见”的工程建设,比如城市道路、桥梁、地铁、绿地等,相对忽视“看不见”的工程,比如地下管网。所以每逢暴雨,“看海”的城市就很多。 2727 因为官员政绩激励对地方政府投资有重要影响,所以近年来在“去杠杆、去库存、去产能”等供给侧结构性重大改革中,也包含了对地方官员政绩考核的改革。2013年,中组部发布《关于改进地方党政领导班子和领导干部政绩考核工作的通知》,特别强调:“不能仅仅把地区生产总值及增长率作为考核评价政绩的主要指标,不能搞地区生产总值及增长率排名。中央有关部门不能单纯以地区生产总值及增长率来衡量各省(自治区、直辖市)发展成效。地方各级党委政府不能简单以地区生产总值及增长率排名评定下一级领导班子和领导干部的政绩和考核等次。”明确了“选人用人不能简单以地区生产总值及增长率论英雄”这项通知之后,再加上一系列财政和金融改革措施,地方GDP增长率和固定资产投资增长率开始下降。 2828 2019年,中共中央办公厅印发《党政领导干部考核工作条例》,明确在考核地方党委和政府领导班子的工作实绩时,要看“全面工作”,“看推动本地区经济建设、政治建设、文化建设、社会建设、生态文明建设,解决发展不平衡不充分问题,满足人民日益增长的美好生活需要的情况和实际成效”。\n在官员考核和晋升中,政绩非常重要,但这不代表人情关系不重要。无论是公司还是政府,只要工作业绩不能百分百清楚地衡量(像送快递件数那样),那上级的主观评价就是重要的,与上级的人情关系就是重要的。人情和业绩之间可能互相促进:业绩突出容易受领导青睐,而领导支持也有助于做好工作。但如果某些领导为扩大自己的权力和影响,在选人用人中忽视工作业绩,任人唯亲,就可能打击下属的积极性。在这类问题突出的地区,官僚体系为了约束领导的“任性”,可能在晋升中搞论资排辈,因为年龄和工龄客观透明,不能随便修改。但如此一来,政府部门的工作效率和积极性都会降低。 2929 虽然地方官场的人情关系对于局部的政治经济生态会有影响,但是否重要到对整体经济现象有特殊的解释力,我持怀疑态度。一方面,地方之间有竞争关系,会限制地方官员恣意行事;另一方面,人情关系网依赖其中的关键人物,不确定性很大,有“一人得道鸡犬升天”,就有“树倒猢狲散”。但无论是张三得志还是李四倒霉,工作都还是一样要继续做,发展经济也一样还是地方政府工作的主题。\n政绩和晋升无疑对地方一把手和领导班子成员非常重要,却无法激励绝大多数公务员。他们的日常工作与政绩关系不大,晋升希望也十分渺茫。在庞大的政府工作人员群体中,“县处级”及以上的干部大约只占总人数的1%。平均来说,在一个县里所有的正科实职干部中,每年升副县级的概率也就1%,而从副县级干部到县委副书记,还要经历好几个岗位和台阶,动辄数年乃至数十年。 3030 因此绝大多数政府工作人员最在意的激励并不是晋升,而是实际收入以及一些工作福利,包括工资、奖金、补助、补贴、实惠的食堂、舒适的办公条件,等等。这些收入和福利都与本地经济发展和地区财政紧密相关,在地区之间甚至同一地区的部门之间,差异很大。大部分人在日常工作中可以感受到这种差异,知道自己能从本地发展和本单位发展中得到实惠。若有基层部门破坏营商环境,也会受到监督和制约。\n经济学家注重研究有形的“奖惩”,强调外部的激励机制和制度环境,但其实内心的情感驱动也非常重要。任何一个组织,无论是公司还是政府,都不可能只靠外部奖惩来激励员工。外部奖惩必然要求看得见的工作业绩,而绝大多数工作都不像送快递,没有清清楚楚且可以实时衡量的业绩,因此需要使命感、价值观、愿景等种种与内心感受相关的驱动机制。“不忘初心”“家国情怀”“为人民服务”等,都是潜在的精神力量。而“德才兼备、以德为先”的干部选拔原则,也正是强调了内在驱动和自我约束的重要性。 3131 腐败与反腐败 # 政府投资和土地金融的发展模式,一大弊端就是腐败严重。与土地有关的交易和投资往往金额巨大,且权力高度集中在个别官员手中,极易滋生腐败。近些年查处的大案要案大多与土地有关。在最高检《检察日报》从2008年到2013年报道的腐败案例中,近一半与土地开发有关。 3232 随着融资平台和各种融资渠道的兴起,涉嫌腐败的资金又嫁接上了资本市场和金融工具,变得更加隐秘和庞大。党的十八大以来,“反腐败”成为政治生活的主题之一,并一直保持了高压态势。截至2019年底,全国共立案审查县处级及以上干部15.6万人,包括中管干部414人和厅局级干部1.8万人。 3333 从经济发展的角度看,我国的腐败现象有两个显著特点。第一,腐败与经济高速增长长期并存。这与“腐败危害经济”这一过度简单化的主流观念冲突,以腐败为由唱空中国经济的预测屡屡落空。第二,随着改革的深入,政府和市场间关系在不断变化,腐败形式也在不断变化。20世纪80年代的腐败案件大多与价格双轨制下的“官倒”和各种“投机倒把”有关;90年代的案件则多与国企改革和国有资产流失有关;21世纪以来,与土地开发相关的案件成了主流。 3434 要理解腐败和经济发展之间的关系,关键是要理解不同腐败类型的不同影响。腐败大概可以分为两类。第一类是“掠夺式”腐败,比如对私营企业敲诈勒索、向老百姓索贿、盗用挪用公款等,这类腐败对经济增长和产权保护极其有害。随着我国各项制度和法制建设的不断完善、各种监督技术的不断进步,这类腐败已大大减少。比如在20世纪八九十年代,不规范的罚款和乱收费很多,常见的解决方式是私下给办事人员现金,以免去更高额的罚款或收费。如今这种情况少多了,罚款要有凭证,要到特定银行或通过手机缴纳,钱款来去清楚,很难贪腐。我国也基本没有南亚和非洲一些国家常见的“小偷小摸”式腐败,比如在机场过检时在护照里夹钱、被警察找茬要钱等。近些年,我国整体营商环境不断改善。按照世界银行公布的“营商环境便利度”排名,我国从2010年的全球第89位上升至2020年的第31位,而进入我国的外商直接投资近五年来也一直保持在每年1 300亿美元左右的高位。\n第二类腐败是“官商勾连共同发财式”腐败。比如官员利用职权把项目批给关系户企业,而企业不仅要完成项目、为官员贡献政绩,也要在私下给官员很多好处。这类腐败发生在招商引资过程中,而相关投资和建设可以促进经济短期增长,所以腐败在一段时期内可以和经济增长并存。但从经济长期健康发展来看,这类腐败会带来四大恶果。其一,长期偏重投资导致经济结构扭曲,资本收入占比高而劳动收入占比低,老百姓收入和消费增长速度偏慢。第七章会讨论这种扭曲。其二,扭曲投资和信贷资源配置,把大量资金浪费在效益不高的关系户项目上,推升债务负担和风险。第六章会讨论这种风险。其三,权钱交易扩大了贫富差距。第五章会分析不平等对经济发展的影响。其四,地方上可能形成利益集团,不仅可能限制市场竞争,也可能破坏政治生态,出现大面积的“塌方式腐败”。党的十八大以来,中央数次强调党内决不允许搞团团伙伙、拉帮结派、利益输送,强调构建新型政商关系,针对的就是这种情况。\n党的十八大以来的反腐运动,是更为广阔的系统性改革的一部分,其中既包括“去杠杆”等经济结构改革,也包括防范金融风险改革,还包括各类生产要素尤其是土地的市场化改革。这些改革的根本目的,是转变过去的经济发展模式,所以需要打破在旧有模式下形成的利益集团。在改革尚未完成之前,反腐败会长期保持高压态势。2020年,哈佛大学的研究人员公布了一项针对我国城乡居民独立民调的结果,这项调查从2003年开始,访谈人数超过3万人。调查结果显示,党的十八大以后的反腐成果得到了广泛的认可。2016年,约65%的受访者认为地方政府官员整体比较清廉,而2011年这一比例只有35%。 3535 居民对中央政府的满意度长期居于高位,按百分制计算约83分;对地方政府的满意度则低一些,省政府约78分,县乡政府约70分。\n但在尚未完成转型之前,习惯了旧有工作方式的地方官员在反腐高压之下难免会变得瞻前顾后、缩手缩脚。2016年,中央开始强调“庸政懒政怠政也是一种腐败”,要破除“为官不为”。2018年,中共中央办公厅印发《关于进一步激励广大干部新时代新担当新作为的意见》,强调“建立健全容错机制,宽容干部在改革创新中的失误错误,把干部在推进改革中因缺乏经验、先行先试出现的失误错误,同明知故犯的违纪违法行为区分开来;把尚无明确限制的探索性试验中的失误错误,同明令禁止后依然我行我素的违纪违法行为区分开来;把为推动发展的无意过失,同为谋取私利的违纪违法行为区分开来。”这些措施如何落到实处,还有待观察。\n改革开放40年以来,社会财富飞速增长,腐败现象在所难免。美国在19世纪末和20世纪初的所谓“镀金年代”中,各类腐败现象也非常猖獗,“裙带关系”愈演愈烈,经济腐化政治,政治又反过来腐化经济,形成了所谓的“系统性腐败”(systematic corruption)。之后经过了数十年的政治和法治建设,才逐步缓解。 3636 从长期来看,反腐败是国家治理能力建设的一部分,除了专门针对腐败的制度建设之外,更为根本的措施还是简政放权、转变政府角色。正如党的十九大报告所提出的,要“转变政府职能,深化简政放权,创新监管方式,增强政府公信力和执行力,建设人民满意的服务型政府”。\n结语 # 1994年分税制改革后,财权集中到了中央,但通过转移支付和税收返还,地方政府有足够的财力维持运转。但几乎所有省份,无论财政收入多寡,债务都在飞速扩张。可见政府债务问题根源不在收入不够,而在支出太多,因为承担了发展经济的任务,要扮演的角色太多。因此债务问题不是简单的预算“软约束”问题,也不是简单修改政府预算框架的问题,而是涉及政府角色的根本性问题。改革之道在于简政放权,从生产投资型政府向服务型政府逐步转型。\n算账要算两边,算完了负债,当然还要算算借债投资所形成的资产,既包括基础设施,也包括实体企业。给基础建设投资算账,不能只盯着项目本身的低回报,还要算给经济和社会带来的整体效益。但说归说,这笔“大账”怎么算并没有一致认可的标准,争议很大。然而无论怎么争,这笔账总归应该考虑人口密度和设施利用率。在小城市修地铁、在百万人口的城市规划建设几十万人口的新城、在远离供应链的地方建产业园区,再怎么吹得天花乱坠,也很难让人看到效益。至于实体企业,很多行业在资金“大水”漫灌之下盲目扩张,导致产能过剩和产品价格下跌。但同时也有很多行业在宽松的投资环境中迅速成长,跻身世界一流水准,为产业转型升级做出了卓越贡献,比如光电显示、光伏、高铁产业等。下一章就来讲讲它们的故事。\n扩展阅读 # 本章讨论的所有话题,包括拆迁、招商引资、地方债务、户籍与城市化等,都能在周浩导演的杰出纪录片《大同》(又名《中国市长》)中看到。该片记录了大同市原市长耿彦波重建这座城市的故事。2013年,耿彦波调离大同,至今已过去七年有余,如今网络上针对当年那场造城运动以及耿彦波本人的评论褒贬不一,对照影片中记录的各种当年的故事和冲突,引人深思。\n篇幅所限,本章没有展开分析官员行为对经济的各种影响。北京大学周黎安的杰作《转型中的地方政府:官员激励与治理(第二版)》(2017)全面、系统、深入地探讨了这个问题。冯军旗在北京大学的博士论文《中县干部》(2010)生动细致,是了解我国县域官场的上佳之作。密歇根大学洪源远的著作China\u0026rsquo;s Gilded Age: the Paradox of Economic Boom and Vast Corruption(Ang, 2020)讨论了我国近些年来的各类腐败现象,与美国过去及现在的腐败做了对比,解释了腐败为什么可以与经济增长共存。该书也对研究腐败的文献做了全面的梳理,有参考价值。\n如今的主流经济学教材中很少涉及“土地”。在生产和分配中,一般只讲劳动和资本两大要素,土地仅被视作资本的一种。而在古典经济学包括经典马克思主义经济学的传统中,土地和资本是分开的,地主和资本家也是两类人。这种变化与经济发展的阶段有关:在工业和服务业主导的现代经济中,农业的地位大不如前,所以农业最重要的资本投入——“土地”——也就慢慢被“资本”吞没了。然而土地和一般意义上的资本毕竟不同(供给量固定、没有折旧等),且如今房产和地产已成为国民财富中最重要的组成部分,所以应该重新把土地纳入主流微观和宏观经济学的框架,而不是仅将其归类到“城市经济学”或“房地产经济学”等分支。几位英国经济学家的著作Rethinking the Economics of Land and Housing(Ryan-Collins, Lloyd and Macfarlane, 2017)是一次有意义的尝试。\n11 秘鲁经济学家赫尔南多·德·索托(Hernando de Seto)的名著《资本的秘密》(2007)对资本的属性有极佳的论述。\n22 中国人民银行制定的《贷款通则》中对借款人资格做了严格限定,排除了地方政府。1995年版的《预算法》规定地方政府不得发行债券,2014年修订版则允许省级政府发债。\n33 相关数据来自公司发债时披露的信息和报表,读者可以到上海清算所网站下载。\n44 2009年末,成都市财政局把关于“宽窄巷子”历史文化保护区项目的一笔土地出让金3 769.82万元,以“补贴收入”的形式全额返还给了文旅集团,专项用于“宽窄巷子”项目的宣传推广。\n55 数据来自《2018年中国开发区审核公告目录》,由发改委和科技部等六部门联合发布。\n66 数字来自苏州工业园区管委会主页。我的家乡包头市,人口为290万人,2019年的GDP也只有2 715亿元,公共预算收入不过152亿元。\n77 数据来自兆润集团发债的募集说明书和相关评级公告。兆润集团的业务有很多,除开发园区土地之外,也开发房地产。\n88 与PPP相关的数据来自财政部“政府和社会资本合作中心”网站(www.cpppc.org)。\n99 张五常在其著作(2019)第四卷中深入探讨了关于合约选择的一般性理论。\n1010 关于“芜湖模式”的来龙去脉,可参考《国家开发银行史(1994—2012)》(编委会,2013)。\n1111 国开行和世界银行的资产规模来自各自年报。世界银行资产规模仅包括国际复兴开发银行(IBRD)和国际开发协会(IDA)。\n1212 数据来自中国邮政储蓄银行风险管理部党均章和王庆华的文章(2010)。\n1313 数据来自西南财经大学洪正、张硕楠、张琳等人的合作研究(2017)。\n1414 数据来自《财新周刊》2019年第21期的文章《央行银保监联合接管包商银行全纪录 首次有限打破同业刚兑》。\n1515 2010—2012年,中央连续出台政策,收紧了银行对融资平台的贷款,也收紧了信托等融资渠道。为绕开这些管制,融资平台开始大量发行城投债,不惜支付更高利息。\n1616 对隐性负债的估计有很多,数据来源差不多,结果大同小异。此处的数字参考了三种文献:清华大学白重恩、芝加哥大学谢长泰及香港中文大学宋铮的论文(Bai, Hsieh and Song, 2016);海通证券姜超、朱征星、杜佳的研报(2018);德意志银行张智威和熊奕的论文(Zhang and Xiong, 2019)。\n1717 美国和日本的数据来自国际货币基金组织(IMF)全球债务数据库,详见第六章图6-3。\n1818 德意志银行的张智威和熊奕(Zhang and Xiong, 2019)计算了1 109家地方政府融资平台公司的资产回报率。2016年,回报率的中位数只有0.8%。\n1919 越穷的省债务负担越重。上海交通大学的陆铭在著作中(2016)分析了这一关系。\n2020 德意志银行的张智威和熊奕(Zhang and Xiong, 2019)计算了1 109家地方政府融资平台公司的“利息覆盖率”,即公司收入除以利息支出得到的比值。如果这个比值大于1,就有能力付息。\n2121 详见《财新周刊》2017年第21期的封面文章《再查地方隐性负债》。\n2222 城市人口的教育数据来自国家统计局《中国2010年人口普查资料》。政府工作人员的教育数据来自2006—2013年的“中国社会综合调查”微观数据。\n2323 这些数字来自财政部前部长楼继伟的文章(2018)。\n2424 北京大学姚洋和上海财经大学张牧扬(2013)收集了1994—2008年间241个城市1 671名市长和市委书记的数据,发现他们在一个城市的平均任期是3.8年,中位数是3年。中山大学杨海生和罗党论等人(2014)则收集了1999—2013年间近400个地级市的市长和市委书记的资料,发现平均每年都有近三成的地级市中至少有1个人职务发生变更。大量研究显示,地区经济指标随地方主官任期变动,读者可参考北京大学周黎安著作(2017)第六章对这些研究的总结。\n2525 复旦大学王之、北京大学张庆华和周黎安等人的论文发现,市领导升迁和城市面积扩张之间有正向关系(Wang, Zhang and Zhou, 2020)。\n2626 关于省、市、县各级地方主官晋升和当地经济表现之间的关系,研究非常多,北京大学周黎安的著作(2017)对此进行了系统的梳理和总结。\n2727 辽宁大学徐业坤和马光源(2019)研究了官员变更和本地工业企业产能过剩之间的关系。对外经贸大学吴敏和北京大学周黎安(2018)研究了官员晋升和城市“可见”基础设施建设投入之间的关系。\n2828 2013年之后,省市GDP和投资增长率有所下降,这一现象及相关解释可以参考复旦大学张军和樊海潮等人的论文(2020)。\n2929 在我国官场晋升中,政绩和人情都重要,是互补关系,读者可参考圣地亚哥加州大学贾瑞雪、大阪大学下松真之和斯德哥尔摩大学大卫·塞姆(David Seim)等人的论文(Jia, Kudamatsu and Seim, 2015)。关于组织中人情关系和工作表现的基本经济学理论,可以参考芝加哥大学普伦德加斯特(Prendergast)和托佩尔(Topel)的论文(1996),以及复旦大学陈硕、长江商学院范昕宇、香港中文大学朱志韬的论文(Chen,Fan and Zhu, 2020)。\n3030 “县处级”干部占政府工作人员的比例,来自密歇根大学洪源远的著作(Ang,2020)。基层正科晋升副县级的概率,来自北京大学周黎安的著作(2017)。\n3131 斯坦福大学的克雷普斯(Kreps)教授是经济激励理论的大家,他写过一本关于“公司如何激励员工”的通俗小书(2018)。在这本书中,经济学强调的“外部激励”(incentive)只占一部分,而管理学更加重视的“内心驱动”(motivation)则占了大量篇幅。\n3232 数据来自复旦大学陈硕和中山大学朱琳的合作研究(2020)。\n3333 数据来自中央纪委国家监委网站上署名钟纪言的文章《把“严”的主基调长期坚持下去》。\n3434 改革开放以来各种腐败形式的详细数据和分析,可以参考复旦大学陈硕和中山大学朱琳的论文(2020)以及密歇根大学洪源远的著作(Ang, 2020)。\n3535 数据来自哈佛大学坎宁安(Cunningham)、赛什(Saich)和图列尔(Turiel)等人的研究报告(2020)。\n3636 关于美国这一时期的腐败和治理,马里兰大学经济史学家沃利斯(Wallis)的论文(2006)很精彩。\n第四章 工业化中的政府角色 # 2019年初,我访问台北,遇到一位美国企业的本地高管,他说:“你们大陆的经济学跟我在哈佛商学院学的不一样啊,市场竞争和供给需求嘛,你们企业背后都有政府补贴和支持,我们怎么竞争得过,企业都被搞死了哇,这么搞不行哇。”我说:“×总,这企业又不是人,哪有什么死活,就是资源重组嘛。台湾工程师现在在大陆的工资比以前高,产品质量比以前好,价格比以前便宜,不是挺好吗?贵公司去年在武汉落地的厂子,投资百亿元,跟地方政府要补贴和优惠的时候,那可是一点也不让步,一点也不‘市场经济’啊,哈哈。”他说:“补贴嘛,能拿还是要多拿。哎,你回去可别说不该给我们补贴呀!”\n现实世界没有黑白分明的“市场”和“政府”分界,只有利益关系环环相扣的各种组合。我国经济改革的起点是计划经济,所以地方政府掌握着大量资源(土地、金融、国企等),不可避免会介入实业投资。由于实业投资的连续性、复杂性和不可逆性(第三章),政府的介入必然也是深度的,与企业关系复杂而密切,不容易退出。\n在每个具体行业中,由于技术、资源、历史等因素,政企合作的方式各不相同。钢铁是一回事,芯片是另一回事。因此,讨论和分析政府干预和产业政策,不能脱离具体行业细节,否则易流于空泛。社会现象复杂多变,任何理论和逻辑都可以找到不少反例,因为逻辑之外还有天时、地利、人和,不确定性和人为因素对结果影响非常大,而结果又直接影响到对过程和理论的评判。成功了才是宝贵经验,失败了只有惨痛教训。产业政策有成功有失败,市场决策也有成功有失败,用一种成功去质疑另一种失败,或者用一种失败去推崇另一种成功,争论没有尽头。\n因此,本章的重点是具体案例。行业和企业如何借力政府来发展?实行了哪些具体政策?政府资金如何投入和退出,又如何影响行业兴衰和技术起落?首先要了解基本事实和经过,才能评判结果。经济学的数学模型和统计数据不是讲道理的唯一形式,也不一定是最优形式,具体的案例故事常常比抽象的道理更有力量,启发更大。 11 在行业或产业研究中,案例常常包含被模型忽视的大量重要信息,尤其是头部企业的案例。依赖企业财务数据的统计分析,通常强调行业平均值。但平均值信息有限,因为大多数行业“二八分化”严重,头部企业与中小企业基本没有可比性。财务数据也无法捕捉大企业的关键特征:大企业不仅是技术的汇聚点和创新平台,也是行业标准的制定者和产业链核心,与政府关系历来深厚复杂,在资本主义世界也是如此。\n本章前两节是两个行业案例:液晶显示和光伏。叙述的切入点依然是地方政府投融资。读者可以再次看到地方融资平台或城投公司、招商引资竞争、土地金融等,只不过这一次的投资对象不是基础设施和产业园区,而是具体的工业企业。第三节介绍近些年兴起的政府产业投资基金,这种基金不仅是一种新的招商引资方式和产业政策工具,也是一种以市场化方式使用财政资金的探索。\n第一节 京东方与政府投资 # 2020年“双11”期间,戴尔27寸高清液晶显示屏在天猫的售价为949元。2008年,戴尔27寸液晶显示器售价7 599元,还远达不到高清,不是窄边框,也没有护眼技术。2020年,3 000多元就可以买到70寸的高清液晶电视,各种国产品牌都有。而在2008年,只有三星和索尼能生产这么大的液晶电视,售价接近40万元,是今天价格的100倍,在当时相当于北京、上海的小半套房。\n惊人的价格下跌背后是技术进步和国产替代。显示屏和电视,硬件成本近八成来自液晶显示面板。2008年,面板行业由日韩和中国台湾企业主导,大陆企业的市场占有率可以忽略不计。2012年,我国进口显示面板总值高达500亿美元,仅次于集成电路、石油和铁矿石。到了2020年,大陆企业在全球市场的占有率已接近四成,成为世界第一,彻底摆脱了依赖进口的局面,涌现出了一批重量级企业,如京东方、华星光电、深天马、维信诺等。国产显示面板行业的崛起不仅推动了彩电和显示器等价格的直线下降,也推动了华为和小米等国产手机价格的下降,促成了使用液晶屏幕的各类国产消费电子品牌的崛起。\n在显示面板企业的发展过程中,地方政府的投资发挥了关键作用。以规模最大也最重要的公司京东方为例,其液晶显示面板在手机、平板电脑、笔记本电脑、电视等领域的销量近些年来一直居于全球首位。 22 根据其2020年第三季度的报告,前六大股东均是北京、合肥、重庆三地国资背景的投资公司,合计占股比例为23.8%。其中既有综合类国资集团(如北京国有资本经营管理中心),也有聚焦具体行业的国有控股集团(如北京电子控股),还有上一章讨论的地方城投公司(如合肥建投和重庆渝富)。投资方式既有直接股权投资,也有通过产业投资基金(见本章第三节)进行的投资。\n京东方和政府投资的故事 # 20世纪90年代末和21世纪初,我国大陆彩电行业的重头戏码是各种价格战。当时大陆的主流产品还是笨重的显像管(CRT)电视,建设了大量显像管工厂。但其时国际技术主流却已转向了平板液晶显示,彻底取代显像管之势不可逆转,而占液晶电视成本七八成的显示面板,大陆却没有相关技术,完全依赖进口。大陆花了近20年才让彩电工业价值链的95%实现了本土化,但由于没跟上液晶显示的技术变迁,一夜之间价值链的80%又需要依赖进口。 33 而主要的面板厂商都在日韩和中国台湾,他们常常联手操纵价格和供货量。2001年至2006年,三星、LG、奇美、友达、中华映管、瀚宇彩晶等六家主要企业,在韩国和中国台湾召开了共计53次“晶体会议”,协商作价和联合操纵市场,使得液晶面板一度占到电视机总成本的八成。2013年,发改委依照《价格法》(案发时候还没有《反垄断法》,后者自2008年起施行)中操纵市场价格的条款,罚了这六家企业3.5亿元。欧美也对如此恶劣的价格操纵行为做了处罚:欧盟罚了他们6.5亿欧元,美国罚了他们13亿美元。 44 在这一背景下,具有自主技术和研发能力的京东方逐渐进入了人们的视野。这家企业的前身是老国企“北京电子管厂”,经过不断改制和奋斗,21世纪初已经具备了生产小型液晶显示面板的能力。这些能力大多源自2005年在北京亦庄经济技术开发区建设的5代线,这是国内第二条5代线,当时非常先进,距离全球第一条5代线(韩国LG)的建成投产时间也不过三年。 55 这条生产线收购自韩国企业,投资规模很大。当时的融资计划是设立一家公司在中国香港上市,为项目建设融资,但这个上市计划失败了。可生产线已经开始建设,各种设备的订单也已经下了,于是在北京市政府与国开行的协调下,9家银行组成银团,由建设银行北京分行牵头,贷款给京东方7.4亿美元。北京市政府也提供了28亿元的借款,以国资委的全资公司北京工业发展投资管理有限公司为借款主体。这笔政府借款后来转为了股份,在二级市场套现后还赚了一笔。此外,在5代线建设运营期间,北京市政府还先后给予两次政策贴息共1.8亿元,市财政局也给了一笔专项补助资金5 327万元。 66 天有不测风云。京东方5代线的运气不好,在液晶面板大起大落的行业周期中,投在了波峰,产在了波谷。其主打产品即17寸显示屏的价格从动工建设时的每片300美元暴跌到了量产时的每片150美元。2005年和2006年两年,京东方亏损了33亿元,北京市政府无力救助。若银团贷款不能展期,就会有大麻烦。银团贷款展期必须所有参与的银行都同意,而9家银行中出资最少的1家小银行不同意,反复协调后才做通工作,但其中的风险和难度也让京东方从此改变了融资模式。其后数条生产线的建设都采用股权融资:先向项目所在地政府筹集足够的资本金,剩余部分再使用贷款。\n2008年,京东方决定在成都建设4.5代线,第一次试水新的融资模式。这条生产线总投资34亿元,其中向成都市两家城投公司定向增发股票18亿元,剩余16亿元采用银团贷款,由国开行牵头。两家城投公司分别是成都市政府的全资公司成都工业投资集团(现名成都产业投资集团)和成都高新区管委会的全资公司成都高新投资集团。这两家公司不仅有大量与土地开发和融资相关的业务(见第三章),也是当地国资最重要的产业投资平台。与北京5代线项目相比,成都4.5代线的资本金充足多了,京东方运气也好多了。这条以小屏幕产品为主的生产线,投产后正好赶上了智能手机的爆发,一直盈利,也为京东方布局手机屏幕领域占了先机。\n但当时最赚钱的市场还是电视。主流的27寸和32寸大屏幕电视,显示面板完全依赖进口。但建设一条可生产大屏幕的6代线(可生产18—37寸屏幕)所需投资超过百亿元,融资是个大问题。2005—2006年,国内彩电巨头TCL、创维、康佳、长虹等计划联手解决“卡脖子”问题,于是拉来了京东方,在深圳启动了“聚龙计划”,想借助财力雄厚的深圳市政府的投资,在当地建设6代线。但信息流出后,日本夏普开始游说深圳市政府,提出甩开技术落后的京东方,帮深圳建设一条投资280亿元的7.5代线。由于夏普的技术和经验远胜京东方,深圳市政府于是在2007年与夏普签署合作协议,京东方出局,“聚龙计划”流产。但仅一个多月之后,夏普就终止了与深圳的合作。当时上海的上广电(上海广电信息产业股份有限公司)也计划和京东方在昆山合作建设一条6代线,但夏普再次上门搅局,提出与上广电合作,将京东方踢出局。随后不久,夏普再次找借口退出了与上广电的合作。\n夏普的两次搅局推迟了我国高世代产线的建设,但也给了合肥一个与京东方合作的机会。2008年的合肥,财政预算收入301亿元,归属地方的只有161亿元,想建一条投资175亿元的6代线,非常困难,经济和政治决策风险都很大。但当时的合肥亟待产业升级、提振经济发展,领导班子下了很大决心,甚至传说一度要停了地铁项目来建设这条6代线。融资方案仍然采用京东方在成都项目中用过的股票定向增发,但因为投资金额太大、合肥政府财力不足,所以这次增发对象不限于政府,也面向社会资本。但合肥政府承诺出资60亿元,并承诺若社会资本参与不足、定向增发不顺利时,兜底出资90亿元,可以说是把家底押上了。在这个过程中,夏普又来搅局,但因为京东方已经吃过两次夏普的亏,所以在与合肥合作之初就曾问过市领导:如果夏普来了怎么办?领导曾表示过绝不动摇,所以这次搅局没有成功。\n上一章说过,在经济发展起步阶段,资本市场和信用机制都不完善,因此以信用级别高的政府为主体来融资和投资,更为可行。这不仅适用于与土地有关的债务融资,也适用于股权融资。在合肥6代线项目的股票定向增发上,市政府参与的主体又是两家城投公司,市政府的全资公司合肥建投和高新区管委会的全资公司合肥鑫城。 77 二者的参与带动了社会资本:2009年的这次定向增发一共融资120亿元,两家城投公司一共只出资了30亿元,其他8家社会投资机构出资90亿元。 88 与成都项目类似,定向增发之外,京东方再次利用了国开行牵头的银团贷款,金额高达75亿元。\n合肥6代线是我国第一条高世代生产线,也是新中国成立以来安徽省最大的一笔单体工业投资。这条生产线生产出了大陆第一台32寸液晶屏幕,让合肥一跃成为被关注的高技术制造业基地。不仅很多中央领导来视察,周边经济发达的江浙沪领导也都组团来考察,为合肥和安徽政府赢得了声誉。京东方后来又在合肥建设了8.5代(2014年投产)和10.5代生产线(2018年投产),吸引了大量上下游厂商落地合肥,形成了产业集群,使合肥成为我国光电显示产业的中心之一。\n2008年全球金融危机爆发和“4万亿”计划出台之后,京东方进入了快速扩张阶段。2009年初,中央首次将发展“新型显示器件”列入政策支持范围。 99 6月,合肥6代线开工建设。8月,京东方8.5代线的奠基仪式在北京亦庄经济技术开发区举行,彻底打破了韩日和中国台湾地区对大陆的技术和设厂封锁。接下来的一两个月内,坐不住的境外厂商开始迅速推进与大陆的实质性合作。夏普和南京的熊猫集团开始合资建线,LG和广州签约建设8代线,三星则和苏州签约建设7.5代线。中国台湾的面板厂商也开始呼吁台湾当局放开对大陆的技术限制,允许台商在大陆设厂。但这些合资项目并没有获得我国政府的快速批准,京东方赢得了一些发展时间。\n在这一快速扩张阶段,京东方的基本融资模式都是“扩充资本金+银团贷款”。地方政府投资平台既可以参与京东方股票定向增发来扩充其资本金,也可以用土地使用权收益入股。在鄂尔多斯生产线的建设过程中,地方政府甚至拿出了10亿吨煤矿的开采权。此外,地方城投公司也可以委托当地银行向京东方提供低息甚至免息委托贷款。比如在北京亦庄8.5代线的建设过程中,亦庄开发区的全资公司亦庄国投就曾委托北京银行向京东方贷款2亿元,年利率仅为0.01%。 1010 再比如,2015年京东方在成都高新区建设新的产线,高新区管委会的全资公司成都高投就先后向京东方提供委托贷款44亿元,年利率为4.95%,但所有利息都由高新区政府全额补贴。 1111 2014年,京东方做了最大的一笔股票定向增发,总额为449亿元,用于北京、重庆、合肥等地的产线建设。这笔增发的参与者中前三位都是当地的政府投资平台:北京约85亿元,重庆约62亿元,合肥约60亿元。 1212 2015年之后,随着新世代产线的投资规模越来越大,京东方基本上停止了新的股票定向增发,而让地方政府平台公司通过银团贷款或其他方式去筹集资金。比如2015年开工建设的合肥10.5代线项目,计划投资400亿元,项目资本金220亿元,银团贷款180亿元。在这220亿中,市政府通过本地最大的城投公司合肥建投筹集180亿,京东方自筹40亿。筹资过程中也利用了政府产业投资基金(如合肥芯屏产业投资基金)这一新的方式引入了外部资金(见本章第三节)。 1313 京东方的发展路径并非孤例。位列国内显示面板第二位的TCL华星光电虽然是民营企业,但同样也是在政府投资推动下发展的。2007年“聚龙计划”流产后,TCL集团的董事长李东生屡次尝试与外商合资引进高世代面板产线,均告失败,于是他与深圳市政府商议组建团队自主建设8.5代线。该项目计划投资245亿元,是深圳历史上最大的单体投资工业项目。首期出资100亿,TCL从社会上募集50亿,深圳市通过国资委旗下的投资公司深圳市投资控股有限公司出资50亿(具体由子公司深超投资执行)。这个项目风险很大,因为TCL和京东方不同,并没有相关技术储备和人才,基本依靠从台湾挖来的工程师团队。深圳市政府为降低风险,还将15%的股份卖给了三星。这些股份后来大部分被湖北省政府的投资基金收购,用于建设华星光电在武汉的生产线。2013—2017年,华星光电营业收入从155亿元涨到306亿元,净利润从3.2亿元涨到49亿元。正是因为有华星光电,在家电行业逐渐败退的TCL集团才成功转向面板生产,2019年正式更名为TCL科技。 1414 经济启示 # 现代工业的规模经济效应很强。显示面板行业一条生产线的投资动辄百亿,只有大量生产才能拉低平均成本。因此新企业的进入门槛极高,不仅投资额度大,还要面对先进入者已经累积的巨大成本和技术优势。若新企业成功实现大规模量产,不仅自身成本会降低,还会抢占旧企业的市场份额,削弱其规模经济,推高其生产成本,因此一定会遭遇旧企业的各种打压,比如三星可以打价格战,夏普也可以到处搅局。\n经济学教科书中关于市场竞争的理论一般都是讲国内市场,不涉及国际市场,所以新进入者可以寻求一切市场手段去打破在位者的优势,比如资本市场并购、挖对方技术团队等。若在位者的打压手段太过分,还可以诉诸《反垄断法》。但在国际市场上,由国界和政治因素造成的市场扭曲非常多。关税和各种非关税壁垒不过是常规手段,价格操控、技术封锁、并购审查等也是家常便饭。比如中国公司去海外溢价收购外国公司,标的公司闻风股价大涨,股东开心,皆大欢喜,但对方政府却不允许,市场经济的道理讲不通。若资源不能流动和重组,市场竞争、优胜劣汰及比较优势等传统经济学推理的有效性,都会受到挑战。\n行政手段造成的扭曲往往只有行政力量才能破解,但这并不意味着政府就一定该帮助国内企业进入某个行业,关键还要看国内市场规模。在一个只有几百万人口的小国,政府若投资和补贴国内企业,这些企业无法利用国内市场的规模经济来降低成本,必须依赖出口,那政府的投入实际上是在补贴外国消费者。但在我国,使用液晶屏幕的很多终端产品比如电视和手机,其全球最大的消费市场就在国内,所以液晶显示产业的外溢性极强。若本国企业能以更低的价格生产(不一定非要有技术优势,能够拉低国际厂商的漫天要价也可以),政府就可以考虑扶持本国企业进入,这不仅能打破国际市场的扭曲和垄断,还可以降低国内下游产业的成本,促进其发展。 1515 政府投资上游产业的同时也促进下游产业的发展,这种例子有不少。20世纪70年代初,美国在越南战争中失利,重新调整亚洲战略。尼克松宣布终止对其亚洲盟友的直接军事支持。时任韩国总统朴正熙相应地调整了产业发展战略,着力发展重工业,以夯实国防基础。自1973年起,韩国政府通过国家投资基金(National Investment Fund)和韩国产业银行(Korea Development Bank)将大量资金投入六大“战略行业”:钢铁、有色金属、造船、机械、电子、石化。这一产业发展战略在当时受到了很多质疑。1974年,世界银行在一份报告中明确表示,对韩国的产业目标能否实现持保留意见,认为这些产业不符合韩国的比较优势,并建议把纺织业这个资金和技术壁垒较低的行业作为工业化的突破口。 1616 韩国人没听他们的。后来不仅这些战略行业本身发展得很好,培育了世界一流的造船业以及浦项制铁和三星电子这样的世界顶尖企业,而且大大降低了下游产业投入品的价格,推动了下游产业如汽车行业的发展,培育出了现代集团这样的一流车企。1979年,朴正熙遇刺身亡,韩国产业政策开始转型,原有的很多扶持政策被废止。但这些产业的基础已经扎稳,后来长期保持着良好的发展。 1717 京东方和华星光电等企业的崛起,带动了整个光电显示产业链向我国集聚。这也是规模效应的体现,因为规模不够就吸引不到上下游企业向周围集聚。一旦行业集聚形成,企业自身的规模经济效应就会和行业整体的规模经济效应叠加,进一步降低运输和其他成本。光电显示面板产业规模大、链条长,目前很多上游环节(显示材料、生产设备等)依然由国外厂商主导,利润率高于面板制造环节。但京东方等国内面板生产企业的发展,拉动了众多国内企业进入其供应链,而其中用到的很多技术和材料,也可以用于其他产业(比如半导体),从而带动了我国很多相关行业的发展。不仅如此,无论是京东方的竞争对手还是合作伙伴,诸多海外企业纷纷在我国设厂,也带动了我国上游配套企业的发展。 1818 规模经济和产业集聚也会刺激技术创新。市场大,利润就大,就能支撑更大规模的研发投入。产业的集聚还会带来技术和知识的外溢,促进创新。根据世界知识产权组织(WIPO)每年的报告,从2016年到2019年,国际专利申请数量最多的全球十大公司中,每年都有京东方(还有华为)。\n创新当然是经济持续增长的源动力,但创新是买不来的,只能靠自己做。创新必须基于知识和经验的积累,所以只能自己动手“边做边学”,否则永远也学不会。只有自己动手,不是靠简单的模仿和引进,才能真正明白技术原理,才能和产业链上的厂商深入交流,才能学会修改设计以适应本土客户的要求,也才能逐步实现自主创新。若单纯依靠进口或引进,没有自己设厂和学习的机会,那本国的技术就难以进步,很多关键技术都会受制于人,这样的国际分工和贸易并不利于长期经济增长。 1919 很多关于我国工业发展的纪录片中都详细记录了我国各行业工人、工程师、科学家们在生产过程中的艰难摸索和自主创新,本章的“扩展阅读”中会推荐其中一些作品。这就好比学生学习写论文,不自己动手研究、动手做、动手写,只靠阅读别人的东西,理解永远只能停留在表面,停留在知识消费的层次,不可能产出新知。就算全天下的论文和书籍都摆在面前,一个人也不会自动成为科学家。\n强调自主创新不是提倡闭关锁国。当然没必要所有事情都亲力亲为,况且贸易开放也是学习的捷径,和独立自主并不矛盾。 2020 但在大多数工业化国家,相当大一部分研发支出和技术创新均来自本土的大型制造业(非自然资源类)企业。 2121 这也正是我们从京东方的发展故事中所看到的。像我国这样一个大国,需要掌握的核心技术及产品种类和数量,远远多过一些中小型国家。第七章会进一步讨论这个话题。\n“东亚经济奇迹”一个很重要的特点,就是政府帮助本土企业进入复杂度很高的行业,充分利用其中的学习效应、规模效应和技术外溢效应,迅速提升本土制造业的技术能力和国际竞争力。假如韩国按照其1970年显示出的“比较优势”来规划产业,就应该听从世界银行的建议去发展纺织业。但韩国没有这么做,而是一头扎进了本国根本没有的产业。到了1990年,韩国最具“比较优势”的十大类出口商品,比如轮船和电子产品,1970年时根本就不存在。 2222 可见“比较优势”具有很大的不确定性,是可以靠人为创造的。其实“比较优势”并不神秘,就是机会成本低的意思。而对于没干过的事情,事前其实无从准确判断机会成本,没干过怎么知道呢?\n中国也是如此。政府和私人部门合力进入很多复杂的、传统上没有比较优势的行业,但经过多年发展,其产品如今在国际上已经有了比较优势。 2323 从2000年到2018年,我国出口商品的复杂程度从世界第39位上升到了第18位。 2424 这不仅反映了技术能力和基础设施等硬件质量的提升,也反映了营商环境和法制环境等软件质量的提升。因为复杂的产品和产业链涉及诸多交易主体和复杂商业关系,投资和交易金额往往巨大,所以对合同的制订和执行、营商环境稳定性、合作伙伴间信任关系等都有很高要求。各国产品的复杂程度与本国法制和营商环境之间直接相关。 2525 而按照世界银行公布的“营商环境便利度”排名,我国已从2010年的世界第89位上升至2020年的第31位。\n地方政府竞争 # 在各地招商引资竞争中,地方政府为了吸引京东方落户本地,开出的条件十分优厚。上一章曾讨论过,城投公司的基础设施投资,不能只看项目本身的财务回报,还要看对当地经济的整体带动。这道理对产业类投资也适用。京东方不仅自身投资规模巨大,且带来的相关上下游企业的投资也很大,带动的GDP、税收及就业十分可观。曾有合肥市政府相关人士反驳外界对其投资京东方的质疑:“不要以为我们不会算账,政府是要算细账的。一个京东方生产线,从开始建就能拉动300亿元的工业投资,建成之后的年产值就是千亿级别。从开建到完全投产不到五年时间,五年打造一个千亿级别的高新技术产业,这种投资效率非常高了。” 2626 新兴制造业在地理上的集聚效应很强,因为扎堆生产可以节约原材料和中间投入的运输成本,而且同行聚集在一起有利于知识和技术交流,外溢效应很强。因此产业集群一旦形成,自身引力会不断加强,很难被外力打破。但在产业发展早期,究竟在哪个城市形成产业集群,却有很多偶然因素。 2727 大部分新兴制造业对自然条件要求不高,不会特别依赖先天自然资源,而且我国基础设施发达,物流成本低,所以一些内陆的中心城市虽然没有沿海城市便利,但条件也不是差很多。这些城市若能吸引一些行业龙头企业落户,就有可能带来一大片相关企业,在新兴产业的发展中占得一席之地,比如合肥的京东方和郑州的富士康等。\n由于京东方生产线投资巨大,很自然首先要谋求与财力雄厚的深圳或上海合作,但两次都被夏普搅局,就给了合肥和成都机会。2001年,中国加入WTO后,广东和江浙沪发展迅猛,而合肥、成都、武汉等内地中心城市则亟待产业转型,提振经济发展,这些城市为此愿意冒险,全力投资新兴产业。京东方在合肥先后投资建设了三条生产线,吸引了大量配套企业,使合肥成为我国光电显示产业的主要基地之一。已如前文所述,这一产业使用的很多技术又与其他产业直接相关,比如芯片和半导体,所以合肥政府利用和京东方合作的经验和产业基础,后来又吸引了兆易创新等半导体行业龙头企业,设立了合肥长鑫,成为我国内存(DRAM)制造产业的中心之一。2008年至2019年,合肥的实际GDP(扣除物价因素)上涨了3.4倍,高于全国GDP同期上涨幅度(2.3倍)。2020年,合肥GDP总量破万亿,新晋“万亿GDP城市”(2020年末共有23个城市)。\n这种发展效应自然会引发其他地区的模仿,不少城市都上马了液晶面板生产线,而政府扶持也吸引了一些并无技术实力和竞争力的小企业进入该行业,引发了对产能过剩的担忧。 2828 显示面板是一个周期性极强的行业:市场价格高涨时很多企业进入,供给快速增加,推动价格大跌,让不少企业倒闭,而低价又会刺激和创造出更多新的需求和应用场景,推动需求和价格再次上涨。这种周期性的产能过剩已经清洗掉了很多企业,行业中心也在一轮轮的清洗中从美国转到日本,再到韩国和我国台湾地区,再到大陆。也许在未来的世界,屏幕会无处不在,连房间的整面墙壁甚至窗户,都会是屏幕。但也有可能会有不可思议的“黑科技”出世,完全消灭掉现有显示技术,就像当年液晶技术消灭掉显像管技术一样。没人能够预知未来,但招商引资竞争所引发的重复建设确实屡见不鲜,尤其在那些技术门槛较低、投资额度较小的行业,比如曾经的光伏行业。\n第二节 光伏发展与政府补贴 # 光伏就是用太阳能发电。2012年前后,我国很多光伏企业倒闭,全行业进入寒冬。所以在很长一段时间里,无论在政府、学术界还是媒体眼中,光伏都是产业政策和政府补贴失败的“活靶子”。但假如有人在当年滔天的质疑声中悄悄买入一些光伏企业的股票,比如隆基股份,现在也有几十倍的收益了。实际上,经过当年的行业洗牌之后,我国的光伏产业已经成为全球龙头,国内企业(包括其海外工厂)的产能占全球八成。该产业的几乎全部关键环节,如多晶硅、硅片、电池、组件等,我国企业都居于主导地位。 2929 在规模经济和技术进步的驱动之下,光伏组件的价格在过去十年(2010—2019)下降了85%,同期的全球装机总量上升了16倍。我国国内市场也已成为全球最大的光伏市场,装机总量占全球的三分之一。 3030 光伏已经和高铁一样,成为“中国制造”的一张名片。\n光伏产业的故事 # 20世纪70年代,阿拉伯世界禁运石油,油价飙涨,“石油危机”爆发,刺激了美国政府扶持和发展新能源产业。卡特政府大量资助光伏技术研究,补贴产业发展。80年代初,美国光伏市场占全球市场的85%以上。但随后里根上台,油价回落,对光伏的支持和优惠政策大都废止。产业链开始向政府补贴更慷慨的德国和日本转移。这一时期,澳大利亚新南威尔士大学的马丁·格林(Martin Green)教授发展了很多新技术,极大提升了光伏发电的效率,被誉为“光伏之父”。他的不少学生后来都成了我国光伏产业的中坚,其中就包括施正荣博士。 3131 2001年,施正荣在无锡市政府的支持下创办了尚德,占股25%,无锡的三家政府投资平台(如无锡国联发展集团)和五家地方国企(如江苏小天鹅集团)共出资600万美元,占股75%。可以说无锡政府扮演了尚德“天使投资人”的角色。2005年,尚德成为中国首家在纽交所上市的“民营企业”,因为在上市前引入了高盛等外资,收购了全部国资股份。施本人的持股比例也达到46.8%,上市后一跃成为中国首富。这种造富的示范效应非常强烈,刺激各地政府纷纷上马光伏项目。2005年,在江西新余市政府的一系列扶持之下,赛维集团成立。2007年就成为江西首家在纽交所上市的公司,创始人彭小峰成为江西首富。2010年,在海内外上市的中国光伏企业已超过20家。 3232 2008年,各地加大了对基础设施和工业项目的投资,包括光伏。主要手段还是廉价土地、税收优惠、贴息贷款等。在刺激政策与地方政府的背书之下,尚德和赛维等龙头企业开始大规模负债扩张。2011年初,尚德规模已经不小,但无锡政府又提出“5年内再造一个尚德”,划拨几百亩土地,鼓励尚德再造一个5万人的工厂,并帮助其获得银行贷款。2011年,赛维已经成了新余财政的第一贡献大户,创造就业岗位2万个,纳税14亿元,相当于当年新余财政总收入的12%。 3333 与以满足国内需求为主的液晶显示面板行业不同,这一时期的光伏产品主要出口欧美市场,尤其是德国和西班牙,因为其发电成本远高于火电和水电,国内消费不起。2011年的光伏出口中,57%出口欧洲,15%出口美国。虽然国内从2009年起也陆续引入了一些扶持和补贴政策(如“金太阳工程”),补贴光伏装机,但总量并不大。2010年,国内市场只占我国光伏企业销量的6%。 3434 因为光伏发电成本远高于传统能源,所以光伏的海外需求也离不开政府补贴。欧洲的补贴尤其慷慨。德国不仅对装机有贷款贴息优惠,还在2000年就引入了后来被全球广泛借鉴的“标杆电价”补贴(feed-in tariff,FiT)。光伏要依靠太阳能,晚上无法发电,电力供应不稳定,会对电网造成压力,因此电网一般不愿意接入光伏电站。但在“标杆电价”制度下,电网必须以固定价格持续购买光伏电量,期限20年,该价格高于光伏发电成本。这种价格补贴会加到终端电价中,由最终消费者分摊。这个固定价格会逐渐下调,以刺激光伏企业技术进步,提高效率。但事实上,价格下降速度慢于光伏的技术进步和成本下降速度,所以投资光伏发电有利可图。可以说我国光伏产业不仅是国内地方政府扶持出来的,也得益于德国、西班牙、意大利等国政府的“扶持”。在欧美市场,我国企业借助规模效应、政府补贴以及产业集聚带来的成本优势,对其本土企业造成了不小冲击。\n2009年到2011年,美国金融危机和欧债危机相继爆发,欧洲各国大幅削减光伏补贴。同时,为应对我国企业的冲击,美国和欧盟从2011年底开始陆续对我国企业展开“反倾销,反补贴”调查,关税飙升。其实,这一时期我国专门针对光伏的补贴总量很有限,大部分补贴不过都是地方招商引资中的常规操作,比如土地优惠和贷款贴息,并非具体针对光伏。只有光伏产业集聚的江苏省在2009年率先推出了与德国类似的“标杆电价”补贴,确定2009年光伏电站入网电价为每度2.15元,远高于每度约0.4元的煤电上网电价。补贴资金源于向省内电力用户(不包括居民和农业生产用电)收取电价附加费,建立省光伏发电扶持专项资金。为鼓励企业提高效率、降低成本,江苏将2010年和2011年的“标杆电价”降为每度1.7元和1.4元。 3535 在2008年金融危机和“双反”调查前几年,我国光伏企业已经在急速扩张中积累了大量产能和债务,如今出口需求锐减,大量企业开始破产倒闭,包括曾经风光无限的尚德和赛维,光伏产业进入寒冬。在这种背景之下,光伏的主要市场开始逐渐向国内转移。\n2011年,中央政府开始分阶段对光伏施行“标杆电价”补贴,要求电网按固定价格(1.15元/度)全额购买光伏电量,并从2013年起实行地区差别定价。 3636 具体来说,是把全国分为三类资源区,Ⅰ类是西北光照强的地区,Ⅱ类是中西部,Ⅲ类是东部,每度电上网电价分别定为0.9/0.98/1元。与当时煤电的平均上网电价约0.4元相比,相当于每度电补贴0.6元。对分布式光伏则每度电补贴0.42元。在资金来源方面,是向电力终端用户征收“可再生能源电价附加”,上缴中央国库,进入“可再生能源发展基金”。除中央的电价补贴之外,很多省市也有地方电价补贴。比如上海就设立了“可再生能源和新能源发展专项资金”,对光伏电站实行每度电0.3元的固定补贴,资金来自本级财政预算和本市实行的差别电价电费收入。 3737 与世界各国一样,我国的电价补贴也随时间逐步下调,以引导光伏企业不断降低成本。2016—2017年,我国两次调低三类地区的“标杆电价”至每度电0.65/0.75/0.85元,下降幅度达到28%/23%/15%。实际上,企业的效率提升和成本降幅远快于补贴降幅,同期光伏组件价格每年的下降幅度均超过30%,所以投资光伏电站有利可图,装机规模因此快速上升。2016—2017年两年,我国光伏组件产量占全球产量的73%,而光伏装机量占全球的51%,不仅是全球最大的产地,也成了最大的市场。 3838 但装机量的急速上涨造成了补贴资金严重不足,拖欠补贴现象严重。如果把对风电的欠补也算上的话,2018年6月,可再生能源补贴的拖欠总额达到1 200亿元。很多光伏电站建在阳光充足且地价便宜的西部,但当地人口密度低、经济欠发达,用电量不足,消纳不了这么多电。跨省配电不仅成本高,且面临配电体系固有的很多制度扭曲,所以电力公司经常以未拿到政府拖欠的补贴为由,拒绝给光伏电厂结算,导致甘肃、新疆等西部省份的“弃光”现象严重。 3939 在这种大背景下,2018年5月31号“531新政”出台,大幅降低了补贴电价,也大幅缩减了享有补贴的新增装机总量,超过这个量的新增装机,不再能享受补贴指标。这个政策立即产生了巨大的行业冲击,影响不亚于当年欧美的“双反”。当年第四季度,政策重新转暖。9月,欧盟取消了对我国企业长达五六年的“双反”措施,光伏贸易恢复正常。欧盟的“双反”并未能挽救欧洲企业,除了在最上游的硅料环节,大多欧洲企业已经退出光伏产业。2019年,我国开始逐步退出固定电价的补贴方式,实行市场竞价。而由于多年的技术积累和规模经济,光伏度电成本已经逼近燃煤电价,正在迈入平价上网时代。2020年,海内外上市的中国光伏企业股价飞涨,反映了市场对光伏技术未来的乐观预期。\n经济启示 # 如果承认全球变暖事关人类存亡,那就必须发展可再生能源。即便不承认全球变暖,但承认我国传统能源严重依赖进口的局面构成了国家安全隐患,那也必须发展新能源。但传统能源已经积累了多年的技术和成本优势,新能源在刚进入市场时是没有竞争力的。就拿十几年前的光伏来说,度电成本是煤电的十几倍甚至几十倍,若只靠市场和价格机制,没人会用光伏。但新能源的技术升级和成本下降,只有在大规模的生产和市场应用中才能逐步发生,不可能只依靠实验室。实验技术再突破,若没有全产业链的工业化量产和技术创新,就不可能实现规模经济和成本下降。研发和创新从来不只是象牙塔里的活动,离不开现实市场,也离不开边干边学的企业。\n所以新能源技术必须在没有竞争优势的时候就进入市场,这时候只有两个办法:第一是对传统能源征收高额碳税或化石燃料税,增加其成本,为新能源的发展制造空间;第二是直接补贴新能源行业。第一种办法明显不够经济,因为在新能源发展早期,传统能源占据九成以上的市场,且成本低廉,对其征收重税会大大加重税收负担,造成巨大扭曲。所以更加合理的做法是直接补贴新能源,加速其技术进步和成本降低,待其市场份额不断扩大、成本逼近传统能源之后,再逐渐降低补贴,同时对传统能源征税,加速其退出。 4040 因此无论是欧美还是日韩,光伏的需求都是由政府补贴创造出来的。中国在开始进入这个行业时,面临的是一个“三头在外”的局面:需求和市场来自海外,关键技术和设备来自海外,关键原材料也来自海外。所以基本就是一个代工行业,处处受制于人。但当时光伏发电成本太高,国内市场用不起。在地方政府廉价的土地和信贷资源支持下,大量本土光伏企业在海外打“价格战”,用低价占领市场,并在这个过程中不断技术创新,逐步进入技术更复杂的产业链上游,以求在产能过剩导致的激烈竞争中占据优势。但由于最终市场在海外,所以一旦遭遇欧美“双反”,就从需求端打击了全行业,导致大量企业倒闭。\n但企业不是“人”,不会在“死”后一了百了,积累的技术、人才、行业知识和经验,并不会随企业破产而消失。一旦需求回暖,这些资源就又可以重新整合。2013年以后,国内市场需求打开,光伏发展进入新阶段。因为整条产业链都在国内,所以同行沟通成本更低,开始出现全产业链的自主和协同创新,各环节共同优化,加速了技术进步和成本下降。这又进一步扩大了我国企业的竞争优势,更好地打开了国外市场。2018年以后,不仅欧洲“双反”结束,低价高效的光伏技术也刺激了全球需求的扩张,全球市场遍地开花。我国企业当年开拓海外市场的经验和渠道优势,现在又成了它们竞争优势的一部分。\n从光伏产业的发展来看,政府的支持和补贴与企业成功不存在必然的因果关系。欧美日等先进国家不仅起步早、政府补贴早,而且企业占据技术、原料和设备优势,在和中国企业的竞争中还借助了“双反”等一系列贸易保护政策,但它们的企业最终衰落,纷纷退出市场。无论是补贴也好、贸易保护也罢,政策最多可以帮助企业降低一些财务风险和市场风险,但政府不能帮助企业克服最大的不确定性,即在不断变化的市场中发展出足够的能力和竞争优势。如果做不到这一点,保护和补贴政策最终会变成企业的寻租工具。这一点不仅对中国适用,对欧美也适用。但这个逻辑不能构成反对所有产业政策的理由。产业发展,无论政府是否介入,都没有必然的成功或失败。就新能源产业而言,补贴了虽然不见得会成功,但没有补贴这个行业就不可能存在,也就谈不上在发展过程中逐渐摆脱对补贴的依赖了。\n从光伏产业的发展中,我们还可以看到“东亚产业政策模式”的另一个特点:强调出口。当国内市场有限时,海外市场可以促进竞争,迫使企业创新。补贴和优惠政策难免会产生一些低效率的企业,但这些企业在面对挑剔的海外客户时,是无法过关的。而出口量大的公司,往往是效率相对高的公司,它们市场份额的扩大,会吸纳更多的行业资源,压缩国内低效率同行的生存空间,淘汰一些落后产能。 4141 当然,像我国这样的大国,要应对的国际局势变幻比小国更加复杂,所以不断扩大和稳定国内市场,才是行业长期发展的基础。另一方面,若地方政府利用行政手段阻碍落后企业破产,就会阻碍优胜劣汰和效率提升,加剧产能过剩的负面影响。\n地方政府竞争与重复建设 # 地方政府招商引资的优惠政策,会降低产业进入门槛,可能会带来重复投资和产能过剩。这是在关于我国产业政策的讨论中经常被批评的弊端,光伏也是常被提及的反面教材。过度投资和产能过剩本身并不是什么新鲜事,就算没有政府干预,也是市场运行的常态。因为投资面对的是不可知的未来,自由市场选择的投资水平不可能恰好适应未来需求。尤其产业投资具有很强的不可逆性,没下注的还可以驻足观望,但下了注的往往难以收手,所以投资水平常常不是过少就是过多。若市场乐观情绪弥漫,投资者往往一拥而上,导致产能过剩,产品价格下跌,淘汰一批企业,而价格下跌可能刺激新一轮需求上升,引发新的过剩投资。这种供需动态匹配和调整过程中周期性的产能过剩是市场经济的常态。但也正是因为这种产能过剩,企业才不得不在这场生存游戏中不断创新,增加竞争优势,加速优胜劣汰和技术进步。 4242 在我国,还有起码三个重要因素加剧了“重复投资”。首先,在发展中国家可以看到发达国家的发展过程,知道很多产品的市场需求几乎是确定的,也知道相关的生产技术是可以复制的。比如大家都知道中国老百姓有钱之后会买冰箱、彩电、洗衣机,需求巨大,也能引进现成的生产技术,而国内产能还没发展起来,人人都有机会,所以投资一拥而上。其次,地方政府招商引资的很多优惠和补贴,比如低价土地和贴息贷款,都发生在工厂建设阶段,且地方领导更换频繁,倘若谈好的项目不赶紧上马,时间拖久了优惠政策可能就没有了。虽然企业不能完全预料建成投产后的市场需求,但投产后市场若有变化,总是有办法通过调整产量去适应。但如果当下不开工建设,很多机会和资源就拱手让人了,所以要“大干快上”。再次,地方往往追随中央的产业政策。哪怕本地条件不够,也可能投资到中央指定的方向上,这也是会引发各地重复投资的因素之一。 4343 “重复投资”并不总是坏事。在经济发展早期,各地政府扶持下的工业“重复投资”至少有两个正面作用。首先,当地工厂不仅提供了就业,也为当地农民转变为工人提供了学习场所和途径。“工业化”最核心的一环是把农民变成工人,这不仅仅是工作的转变,也是思想观念和生活习惯的彻底转变。这个转变不会自动发生,需要学习和培训,而这种学习和培训只能在工厂中完成。在乡镇企业兴起的年代,统一的国内大市场尚未形成,各地都在政府扶持下重复建设各种小工厂,生产效率和技术水平都很低。但正是这种“离土不离乡”的工厂,让当地农民熟悉了工业和工厂,培养了大量工人,为后来我国加入WTO后真正利用劳动力优势成为世界工厂奠定了基础。从这个角度看,“工厂”承担了类似“学校”的教育功能,有很强的正外部性,应当予以扶持和补贴。\n“重复投资”的第二个好处是加剧竞争。蜂拥而上的低水平产能让“价格战”成为我国很多产品的竞争常态。所以在很长一段时间内,“成本创新”是本土创新的主流。虽然西方会将此讥讽为“仿造”和“山寨”,但其实成本创新和功能简化非常重要。因为很多在发达国家已经更新迭代了多年的产品,小到家电大到汽车,我国消费者都是第一次使用。这些复杂精密的产品价格高昂,让试用者望而却步。如果牺牲一些功能和质量能让价格大幅下降,就有利于产品推广。当消费者开始熟悉这些产品后,会逐步提升对质量的需求。正因如此,很多国产货都经历了所谓“山寨+价格战”的阶段。但行业正是在这种残酷的竞争中迅速洗牌,将资源和技术快速向头部企业集中,质量迅速提高。就拿家电行业来说,国产货从起步到质优价廉、服务可靠、设计精美,占领了大部分国内市场,也就是20年的时间。其他很多消费者熟悉的产品,也大都如此。 4444 所以不管有没有政府扶持,要害都不是“重复建设”,而是“保持竞争”。市场经济的根本优势不是决策优势。面对不可知的未来,谁也看不清,自由市场上,失败也比成功多得多。市场经济的根本优势是可以不断试错,在竞争中优胜劣汰。 4545 能保持竞争性的产业政策,与只扶持特定企业的政策相比,效果往往更好。 4646 但所谓“特定”,不好界定。就算中央政府提倡的产业政策是普惠全行业的,并不针对特定企业,但到了地方政府,政策终归要落实到“特定”的本地企业头上。若地方政府保护本地企业,哪怕是低效率的“僵尸企业”也要不断输血和挽救,做不到“劣汰”,竞争的效果就会大打折扣,导致资源的错配和浪费。这是很多经济学家反对产业政策的主要原因。尤其是,我国地方政府有强烈的“大项目”偏好,会刺激企业扩张投资。企业一旦做大,就涉及就业、稳定和方方面面的利益,不容易破产重组。这在曾经的光伏巨头——江西赛维的破产重整案中表现得淋漓尽致。\n如前所述,2011年,赛维已经成了新余财政的第一贡献大户,创造就业岗位2万个,纳税14亿元,相当于当年新余财政总收入的12%。在政府背书之下,赛维获得了大量银行授信,远超其资产规模。自2012年起,赛维的债务就开始违约。地方政府屡次注入资金,并动员包括国开行在内的数家银行以各种方式救助,结果却越陷越深。2016年,赛维总资产为137亿元,但负债高达516亿元,严重资不抵债。其破产重整方案由地方政府直接主导,损害了债权人利益。当受偿率太低的债权人无法接受重整方案时,地方法院又强制裁决,引发了媒体、法律和金融界的高度关注。 4747 所以产业政策要有退出机制,若效率低的企业不能退出,“竞争性”就是一句空话。“退出机制”有两层含义。第一是政策本身要设计退出机制。比如光伏的“标杆电价”补贴,一直在降低,所有企业都非常清楚补贴会逐渐退出,平价上网时代终会来临,所以有动力不断提升效率和降低成本。第二是低效企业破产退出的渠道要顺畅。这不仅涉及产业政策,也涉及更深层次的要素配置市场化改革。如果作为市场主体和生产要素载体的企业退出渠道不畅,要素配置的市场化改革也就难以深化。然而“破产难”一直是我国经济的顽疾。一方面,债权银行不愿走破产程序,因为会暴露不良贷款,无法再掩盖风险;另一方面,地方政府也不愿企业(尤其是大企业)走破产程序,否则职工安置和民间借贷等一系列矛盾会公开化。在东南沿海等市场化程度较高的地区,破产程序相对更加规范。同样是光伏企业,无锡尚德和上海超日的破产重整就更加市场化,债权人的受偿率要比江西赛维高很多,这两个案例均被最高人民法院列为了“2016年十大破产重整典型案例”。但总体看来,无论是破产重整还是破产清算,我国在企业退出方面的制度改革和建设还有很长的路要走。\n第三节 政府产业引导基金 # 最近几年,产业升级和科技创新是个热门话题。一讲到对高科技企业的资金支持,大多数人首先会想到硅谷风格的风险投资。然而美式的风险投资基金不可能直接大规模照搬到我国,而是在移植和适应我国的政治经济土壤的过程中,与地方政府的财政资金实现了嫁接,产生了政府产业引导基金。这种地方政府投资高新产业的方式,脱胎于地方政府投融资的传统模式。在地方债务高企和“去产能、去杠杆”等改革的大背景下,政府引导基金从2014年开始爆发式增长,规模在五年内翻了几番。根据清科的数据,截至2019年6月,国内共设立了1 686只政府引导基金,到位资金约4万亿元;而根据投中的数据,引导基金数量为1 311只,规模约2万亿元。 4848 政府产业引导基金既是一种招商引资的新方式和新的产业政策工具,也是一种以市场化方式使用财政资金的探索。理解这种基金不仅有助于理解我国的产业发展,也是深入了解“渐进性改革”的绝佳范例。引导基金和私募基金这种投资方式紧密结合,所以要了解引导基金,需要先从了解私募基金开始。\n私募基金与政府引导基金 # 私募基金,简单说来就是一群人把钱交给另一群人去管理和投资,分享投资收益。称其为“私募”,是为了和公众经常买卖的“公募”基金区别开。私募基金对投资人资格、募资和退出方式等都有特殊规定,不像公募基金的份额那样可以每天买卖。图4-1描绘了私募基金的基本运作方式。出钱的人叫“有限合伙人”(limited partner,以下简称LP),管钱和投资的人叫“普通合伙人”(general partner,以下简称GP)。LP把钱交给GP投资和运作,同时付给GP两种费用:一种是基本管理费。一般是投资总额的2%,无论亏赚,每年都要交。另一种是绩效提成,行话叫“carry”。若投资赚了钱,GP要先偿还LP的本金和事先约定的基本收益(一般为8%),若还有多余利润,GP可从中提成,一般为20%。\n图4-1 私募基金基本运作模式\n举个简化的例子。LP投资100万元,基金延续两年,GP每年从中收取2万元管理费。若两年后亏了50万,那GP就只能挣两年总共4万的管理费,把剩下的46万还给LP,LP认亏。若两年后挣了50万,GP先把本金100万还给LP,再给LP约定的每年8%的收益,也就是16万。GP自己拿4万元管理费,剩下30万元的利润,GP提成20%也就是6万,剩余24万归LP。最终,GP挣了4万元管理费和6万元提成,LP连本带利总共拿回140万元。\nGP的投资对象既可以是上市公司公开交易的股票(二级市场),也可以是未上市公司的股权(一级市场),还可以是上市公司的定向增发(一级半市场)。若投资未上市公司的股权,那最终的“退出”方式就有很多种,比如把公司包装上市后出售股权、把股权出售给公司管理层或其他投资者、把公司整体卖给另一家并购方等。\nLP和GP这种特殊的称呼和合作方式,法律上称为“有限合伙制”。与常见的股份制公司相比,“有限合伙”最大的特点是灵活。股份制公司一般要求“同股同权”和“同股同利”。无论持股多少,每一股附带的投票权和分红权是一样的,持有的股票数量越多,权利越多。但在“有限合伙”中,出钱的是LP,做投资决定的却是GP,LP的权利相当有限。不仅如此,若最后赚了钱,最初基本没出钱的GP也可以分享利润的20%。 4949 此外,股份公司在注册时默认是永续经营的,但私募基金却有固定存续期,一般是7—10年。在此期限内,基金要经历募资、投资、管理、退出等四个阶段(统称“募投管退”),到期后必须按照合伙协议分钱和散伙。 5050 在这种合作方式下,活跃在投资舞台镁光灯下的自然就是做具体决策的GP。很多投资业绩出众的GP管理机构和明星管理人大名鼎鼎。他们的投资组合不仅财务回报率高,而且包括了诸多家喻户晓的明星企业,行业影响力很大。这些明星GP受市场资金追捧,募集的基金规模动辄百亿元。\n相比之下,出钱的LP们反倒低调得多。国际上规模大的LP大都是机构投资者,比如美国最大的LP就包括加州公立系统雇员养老金(CalPERS)和宾州公立学校雇员退休金(PSERS)等。一些国家的主权投资机构也是声誉卓著的LP,比如新加坡的淡马锡和GIC、挪威主权财富基金(GPFG)等。而国内最大的一类LP就是政府产业引导基金,其中既有中央政府的基金比如规模庞大的国家集成电路产业投资基金(即著名的“大基金”),也有地方政府的基金,比如深圳市引导基金及其管理机构深圳创新投资基团(即著名的“深创投”)。\n与地方政府投资企业的传统方式相比,产业引导基金或投资基金有三个特点。第一,大多数引导基金不直接投资企业,而是做LP,把钱交给市场化的私募基金的GP去投资企业。一支私募基金的LP通常有多个,不止有政府引导基金,还有其他社会资本。因此通过投资一支私募基金,有限的政府基金就可以带动更多社会资本投资目标产业,故称为“产业引导”基金。同时,因为政府引导基金本身就是一支基金,投资对象又是各种私募基金,所以也被称为“基金中的基金”或“母基金”(fund of funds, FOF)。第二,把政府引导基金交给市场化的基金管理人运作,实质上是借用市场力量去使用财政资金,其中涉及诸多制度改革,也在实践中遭遇了各种困难(见下文)。第三,大多数引导基金的最终投向都是“战略新兴产业”,比如芯片和新能源汽车,而不允许投向基础设施和房地产,这有别于基础设施投资中常见的政府和社会资本合作的PPP模式(见第三章)。\n上一章介绍城投公司的时候解释过,政府不可以直接向银行借贷,所以需要设立城投公司。政府当然也不可以直接去资本市场上做股权投资,所以在设立引导基金之后,也需要成立专门的公司去管理和运营这支基金,通过这些公司把基金投资到其他私募基金手中。这些公司的运作模式大概分为三类。第一类与城投公司类似,是政府独资公司,如曾经投资过京东方的北京亦庄国投,就由北京经济技术开发区国有资产管理办公室持有100%股权。第二类是混合所有制公司。比如受托管理深圳市引导基金的深创投,其第一大股东是深圳市国资委,但持股占比只有28%左右。第三类则有点像上一章中介绍的华夏幸福。很多小城市的引导基金规模很小,政府没有能力也没有必要为其组建一家专业的基金管理公司,所以干脆把钱委托给市场化的母基金管理人去运营,比如盛世投资集团。\n政府引导基金的概念很容易理解。在国际上,作为机构投资者的LP早就有了多年的运作经验,组建了国际行业协会,与全球各种机构型LP分享投资与治理经验。 5151 但从我国实践来看,政府引导基金的发展,需要三个外部条件。首先是制度条件。要想让财政预算资金进入风险很大的股权投资领域,必须要有制度和政策指引,否则没人敢做。其次是资本市场的发育要比较成熟。政府基金要做LP,市场上最起码得有足够多的GP去管理这些资金,还要有足够大的股权交易市场和退出渠道,否则做不起来。再次是产业条件。产业引导基金最终要流向高技术、高风险的战略新兴行业,而只有经济发展到一定阶段后,这样的企业才会大批出现。\n政府引导基金兴起的制度条件 # 2005年,发改委和财政部等部门首次明确了国家与地方政府可以设立创业投资引导基金,通过参股和提供融资担保等方式扶持创投企业的设立与发展。 5252 2007年,新修订的《合伙企业法》施行,LP/GP式的基金运作模式正式有了法律保障。本土第一批有限合伙制人民币基金随后成立。2008年,国务院为设立引导基金提供了政策基础,明确其宗旨是“发挥财政资金的杠杆放大效应,增加创业投资资本的供给,克服单纯通过市场配置创业投资资本的市场失灵问题”。明确了政府引导基金可以按照“母基金”的方式运作,可以引入社会资本共同设立“子基金”,增加对创业企业的投资。同时要求引导基金按照“政府引导、市场运作、科学决策、防范风险”的原则进行市场化运作。这16个字成了各地引导基金设立和运作的基本原则。 5353 政府的钱以“股权”形式进入还未上市的企业之后,如果有一天企业上市,这些“国有股份”怎么办?要不要按照规定在IPO(首次公开募股)时将10%的股份划转给社保基金? 5454 如果要划转,那无论是地方政府还是其他政府出资人,恐怕都不愿意。因此,为提高国有资本从事创业投资的积极性,2010年财政部等部门规定:符合条件的国有创投机构和国有创投引导基金,可在IPO时申请豁免国有股转持义务。 5555 GP的收费也是个问题。虽然2%的管理费和20%的业绩提成是国际惯例,但如果掌管的是财政资金,也该收取这么高比例的提成么?2011年,财政部和发改委确认了财政资金与社会资本收益共享、风险共担的原则,明确了GP在收取管理费(一般按1.5%—2.5%)的基础上可以收取增值收益部分的20%,相当于承认了GP创造的价值,不再将GP仅仅视作投资“通道”。 5656 以上政策为政府产业引导基金奠定了制度基础,但其爆发式发展却是在2014年前后,最直接的“导火索”是围绕新版《预算法》的一系列改革。改革之前,地方政府经常利用预算内设立的各种专项基金去招商引资,为企业提供补贴(如第三章中介绍的成都市政府对成都文旅的补贴)。而在2014年改革后,国务院开始严格限制地方政府对企业的财政补贴。这些原本用于补贴和税收优惠的财政资金,就必须寻找新的载体和出路,不能趴在账上。因为新《预算法》规定,连续两年还没花出去的钱,可能将被收归同级或上级财政统筹使用。 5757 到了这个阶段,基本制度框架已经搭好,地方政府也需要为一大笔钱寻找出路,产业引导基金已是蓄势待发。但这毕竟是个新事物,还需要更详细的操作指南。自2015年起,财政部和发改委陆续出台了一系列针对政府引导基金的管理细则,为各地提供了行动指南。其中最重要的是两点。第一,再次明确“利益共享、风险共担”原则,允许使用财政资金的政府投资基金出现亏损。第二,明确了财政部门虽然出资,但“一般不参与基金日常管理事务”,并且明确要求各地财政部门配合,“积极营造政府投资基金支持产业发展的良好环境”,推动政府投资基金实现市场化运作。 5858 之后,政府引导基金就进入了爆发期。根据清科数据,2013年全国设立的政府引导基金已到位资金约400亿元,而2014年一年就暴增至2 122亿元,2015年3 773亿元,2016年超过了1万亿元。很多著名的产业引导基金都创办于这一阶段,比如2014年工信部设立的“国家集成电路产业投资基金”(即“大基金”),首期规模将近1 400亿元。大多数地方政府的引导基金也成立于这个阶段。\n政府引导基金兴起的金融和产业条件 # 引导基金大多采用“母基金”方式运行,与社会资本共同投资于市场化的私募基金,通过后者投资未上市公司的股权。这种模式的繁荣,需要三个条件:有大量的社会资本可以参与投资、有大量的私募基金管理人可以委托、有畅通的投资退出渠道。其中最重要的是畅通的资本市场退出渠道。\n21世纪头十年,为资本市场发展打下制度基础的是三项政策。第一,2003年党的十六届三中全会通过《中共中央关于完善社会主义市场经济体制若干问题的决定》,2004年国务院发布《关于推进资本市场改革开放和稳定发展的若干意见》,为建立多层次资本市场体系,完善资本市场结构和风险投资机制等奠定了制度基础。第二,2005年开始的股权分置改革,解决了非流通股上市流通的问题,是证券市场发展史上里程碑式的改革。 5959 第三,2006年新修订的《公司法》开始实施,正式把发起人股和风投基金持股区别对待。上市后发起人股仍实行3年禁售,但风投基金的禁售期可缩短至12个月,拓宽了退出渠道。同年,证监会以部门规章的形式确立了IPO的审核标准。 6060 这些政策出台前后,上海和深圳的交易所也做了很多改革,拓宽了上市渠道。2004年和2009年,中小企业板和创业板分别在深交所开板。2013年,新三板扩容全国。2019年,科创板在上交所开市,并试行注册制。国内上市渠道拓宽后,改变了过去股权投资机构“两头在外”(海外募资,海外上市、退出)的尴尬格局。2008年全球金融危机后,我国的股权投资基金开始由人民币基金主导,外币基金不再重要。\n至于可以与政府引导基金合作的“社会资本”,既包括大型企业的投资部门,也包括其他资本市场的机构投资者。后者也随着21世纪的各项改革而逐步“解放”,开始进入股权投资基金行业。比如,在2010年至2014年间,保监会的一系列规定让保险资金可以开始投资非上市公司股权以及创投基金。 6161 所以2006年至2014年,我国的股权投资基金发展很快,一大批优秀的市场化基金管理机构和人才开始涌现。2014年,境内IPO重启,股权投资市场开始加速发展。也是从这一年起,政府引导基金的发展趋势和股权投资基金整体的发展趋势开始合流,政府资金开始和社会资本融合,出现了以市场化方式运作财政资源的重要现象。政府引导基金也逐渐成为各类股权投资基金最为重要的LP之一。\n绝大多数政府引导基金最终都投向了战略性新兴产业(以下简称“战新产业”),这是由这类产业的三大特性决定的。首先,扶持和发展战新产业是国家战略,将财政预算资金形成的引导基金投向这些产业,符合政策要求,制度上有保障。从“十二五”规划到“十三五”规划,国务院都对发展战新产业做了专门的规划,将其视为产业政策的重中之重。要求2015年战新产业增加值占GDP的比重需达到8%(已实现);2020年达到15%;2030年,战新产业应该发展成推动我国经济持续健康发展的主导力量,使我国成为世界战新产业重要的制造中心和创新中心。在这两个五年规划中,都提出要加大和创新财税与金融政策对战新产业的支持,明确鼓励发挥财政资金引导作用,吸引社会资本,扩大投资规模,促进战新产业快速发展。\n其次,战新产业处于技术前沿,高度依赖研发和创新,不确定性很大,所以更需要能共担风险并能为企业解决各类问题的“实力派”股东。从企业角度看,引入政府基金作为战略投资者,不仅引入了资金,也引入了能帮企业解决困难的政府资源。而从政府角度看,股权投资最终需要退出,不像补贴那样有去无回。因此至少从理论上说,与不用偿还的补贴相比,产业基金对被投企业有更强的约束。\n再次,很多战新产业正处在发展早期,尚未形成明显的地理集聚,这让很多地方政府(如投资京东方的合肥、成都、武汉等)看到了在本地投资布局的机会。而“十三五”关于发展战新产业的规划也鼓励地方以产业链和创新协同发展为途径,发展特色产业集群,带动区域经济转型,形成创新经济集聚发展新格局。\n引导基金的成绩与困难 # 最近几年,在公众熟知的很多新技术领域,比如新能源、芯片、人工智能、生物医药、航空航天等,大多数知名企业和投资基金的背后都有政府引导基金的身影。2018年3月,美国贸易代表办公室(USTR)发布了针对我国产业和科技政策的“301调查报告”,其中专门用了一节来讲各类政府产业引导基金。 6262 调查发布后,该报告中提到的基金还收到了一些同行发来的“赞”:工作业绩突出啊,连美国人都知道你们了。\n引导基金成效究竟如何,当然取决于这些新兴产业未来的发展情况。成了,引导基金就是巨大的贡献;不成,就是巨大的浪费。投资的道理由结果决定,历来如此,当下言之尚早。从目前情况看,撇开投资方向和效益不论,引导基金的运营也面临多种困难和挑战。与上一章中的地方政府融资平台不同,这些困难不是因为地价下跌或债台高筑,而属于运用财政资金做风险投资的体制性困难。主要有四类。\n第一类是财政资金保值增值目标与风险投资可能亏钱之间的矛盾。虽然原则上引导基金可以亏钱,但对基金的经营管理者而言,亏了钱不容易向上级交待。当然,对大多数引导基金而言,只要不亏大钱,投资的财务回报率高低并非特别重要,关键还是招商引资,借助引导基金这个工具把产业带回本地,但这就带来了第二类困难。\n第二类困难源自财政资金的地域属性与资本无边界之间的矛盾。在成熟的资本市场上,机构类LP追求的就是财务回报,并不关心资金具体流向什么区域,哪里挣钱就去哪里。但地方政府引导基金源自地方财政,本质还是招商引资工具,所以不可能让投资流到外地去,一定要求把产业带到本地来。但前两章反复强调过,无论是土地还是税收优惠,都无法改变招商引资的根本决定因素,即本地的资源禀赋和经济发展前景。在长三角、珠三角以及一些中心城市,大企业云集,各种招商引资工具包括引导基金,在完成招商目标方面问题不大。但在其他地区,引导基金招商作用其实不大,反而造成了新的扭曲。有些地方为吸引企业,把本该是股权投资的引导基金变成了债权工具。比如说,引导基金投资一亿元,本应是股权投资,同赚同亏,但基金却和被投企业约定:若几年后赚了钱,企业可以低价回购这一亿元的股权,只要支付本金再加基本利率(2%—5%)就行;若企业亏了钱,可能也需要通过其他方式来偿还这一亿元本金。这就不是股权投资了,而是变相的低息贷款。再比如,引导基金为吸引其他社会资本一起投资,承诺未来可以收购这些社会资本的股权份额,相当于给这些资本托了底,消除了它们的投资风险,但同时也给本地政府增加了一笔隐性负债。这种“名股实债”的方式违背了股权投资的原则,也违背了“去杠杆”和解决地方政府债务问题的初衷,是中央明确禁止的。 6363 第三类困难源于资本市场。股权投资对市场和资金变化非常敏感,尤其在私募基金领域。在一支私募基金中,作为LP之一的政府引导基金出资份额一般不会超过20%。换句话说,若没有其他80%的社会资本,这支私募基金就可能募集失败。在2018年“资管新规”出台(见第六章)之后,各种社会资本急剧萎缩,大批私募基金管理机构倒闭,很多引导基金也独木难支,难有作为。\n第四类困难是激励机制。私募基金行业收入高,对人才要求也高。而引导基金的管理机构脱胎自政府和国企,一般没有市场化的薪酬,吸引不了很多专业人才,所以才采用“母基金”的运作方式,把钱交给市场化的私募基金GP去管理。但要想和GP有效沟通、监督其行为、完成产业投资目标,引导基金管理机构的业务水平也不能落伍,也需要吸引和留住人才,所以需要在体制内调整薪酬结构。各地做法差异很大。在市场化程度高、制度比较灵活的地方如深圳,薪酬也更加灵活一些。但在大部分地区,薪酬激励机制仍是很难突破的瓶颈。\n结语 # 经济发展是企业、政府、社会合力的结果,具体合作方式取决于各自占有的资源,而这些资源禀赋的分布格局由历史决定。我国的经济改革脱胎于计划经济,政府手中掌握大量对产业发展至关重要的资源,如土地、银行、大学和科研机构等,所以必然会以各种方式深度参与工业化进程。政府和市场间没有黑白分明的界限,几乎所有的重要现象,都是这两种组织和资源互动的结果。要想认识复杂的世界,需要小心避免政府和市场的二分法,下过于简化的判断。\n因此,本章尽量避免抽象地谈论产业发展和政府干预,着重介绍了两个具体行业的发展过程和一个特定产业政策工具的运作模式,希望帮助读者了解现象的复杂和多面性。大到经济发展模式、小到具体产业政策,不存在脱离了具体场景、放之四海而皆准的答案,必须具体问题具体分析,并根据现实变化不断调整。政策工具需要不断发展和变化,因为政府能力和市场条件也在不断发展和变化。在这个意义上,深入了解发达国家的真实发展历程,了解其经历的具体困难和脱困方式,比夸夸其谈的“华盛顿共识”更有启发。\n20世纪90年代中期至21世纪初期,基础设施不完善、法制环境不理想、资本市场和社会信用机制不健全,因此以信用级别高的地方政府和国企为主体、以土地为杠杆,可以撬动大量资源,加速投资进程,推动快速城市化和工业化。这种模式的成就有目共睹,但也会带来如下后果:与土地相关的腐败猖獗;城市化以“地”为本,忽略了“人”,民生支出不足,教育、医疗等公共服务供给滞后;房价飞涨,债务急升;经济过度依赖投资,既表现在民众收入不高所以消费不足,也表现在过剩产能无法被国内消化、向国际输出时又引起贸易失衡和冲突。这些都是近些年的热点问题,催生了诸多改革,本书下篇将逐一展开讨论。\n扩展阅读 # 工业生产离日常生活比较远,所以如果对这个话题感兴趣,最好还是先从感性认识入手。近些年中央电视台拍了很多关于我国工业的纪录片,其中《大国重器》《超级工程》《创新中国》《大国工匠》《军工记忆》等都值得一看。工业和技术的发展很不容易,有很多重要的非经济因素,如奋斗精神和家国情怀等,这些在上述影像记录中都能看到。\n关于行业和企业的研究著作,我首先推荐北京大学路风的《光变:一个企业及其工业史》(2016),讲的是京东方和光电显示行业的故事。这本大书充满了精彩的细节,虽然是单一行业和企业的故事,但如此深度和详细的记录,国内罕见。其中很多对技术工人和经理的访谈非常宝贵,有很多被传统分析和理论抽象掉的重要信息。路风教授研究其他行业的文章结集也都很好,比如《走向自主创新:寻找中国力量的源泉》(2019)和《新火:走向自主创新2》(2020),讲述了我国汽车、大飞机、核能、高铁等行业的发展故事。而对于新工业革命、信息技术、人工智能等领域中的企业和投资故事,吴军的《浪潮之巅》通俗精彩,已经出到了第四版(2019)。\n如果想从更宏大的历史背景和国家兴衰角度去看待工业投资和发展,我从诸多杰作中推荐三种读物。其共同点是“能大能小”,讲的是经济发展和历史大故事,但切入点还是具体产业和企业。哈佛商学院麦克劳的《现代资本主义:三次工业革命中的成功者》(1999)和哈佛大学史学家贝克特的《棉花帝国》(2019)都是杰作,书名解释了内容。史塔威尔的《亚洲大趋势》(2014)讲的是我们的近邻日韩以及中国自己的成功故事,也对比了一些东南亚的失败故事,思路和结构清楚,案例易懂。无论是制度也好、战略也罢,终究离不开人事关系。事在人为,理解这其中所蕴含的随机性,是理解所谓“entrepreneurship”的起点。相比于“企业家精神”,这个词更应该翻译为“进取精神”,不仅企业家,官员、科学家、社会各界都离不开这种精神。\n11 关于这个道理,更详细的阐述可以参考诺贝尔经济学奖得主乔治·阿克尔洛夫(George Akerlof)的文章(2020)和另一位诺奖得主罗伯特·希勒(Robert Shiller)的著作(2020)。\n22 数据来自中信证券袁健聪等人的行业分析报告(2020)。\n33 数据来自北京大学路风的企业史杰作(2016)。如无注明,本节关于京东方发展历程的介绍均来自该书。\n44 见财新网2013年1月4日报道《发改委就三星等垄断液晶面板价格案答问》,以及《中国贸易报》2016年8月2日报道《中企面临反垄断三大挑战四大风险》。\n55 简单来说,“X代线”中的X数字越高,产出的屏幕就越大。比如5代线的主打产品是17英寸屏,6代线的主打产品是32英寸屏。\n66 贷款和补贴的具体数字,来自财新网2007年4月16日的文章《疯狂的液晶》。\n77 合肥建投通过其全资子公司合肥蓝科投资有限公司参与了这次增发。\n88 数据来自京东方2009—2011年的年报。从京东方的角度看,大规模定向增发的募资其实不容易,需要找到足够多的机构投资者。倘若合肥政府财力雄厚的话,本不需要这么麻烦。\n99 2009年国务院发布《电子信息产业调整和振兴规划》。\n1010 见京东方2009年年报。\n1111 这部分贷款贴息由高新区政府支付给成都高投,算作这家城投公司的营业外收入,而不算在京东方的账上,虽然补贴的是京东方。这些财务细节来自成都高新投资集团在债券市场上的募集说明书,感兴趣的读者可以从上海清算所的网站上下载。\n1212 见京东方2014年年报。\n1313 关于合肥建投对10.5代线的资金投入细节,来自其公司网站上的“大事记”。\n1414 华星光电和TCL的数据来自《财新周刊》2019年第2期文章《李东生闯关》。\n1515 产业之间的联系紧密而复杂,犹如巨网。有些产业的外溢性极强,政府若扶持这些产业,会对整个经济产生正面影响。这方面研究很多,比如普林斯顿大学刘斯原的论文(Liu, 2019)。\n1616 关于世界银行的质疑,来自史塔威尔(Studwell)关于亚洲发展的著作(2014)。\n1717 关于韩国20世纪70年代的产业政策有很多研究,基本过程和事实是清楚的。但严谨的微观数据分析尤其是对上下游产业的价格和产出等影响的估计,最近才有,详见澳大利亚国立大学莱恩(Lane)的论文(2019)。当然,政府扶持不是产业发展的充分条件,还有很多其他的因素在发挥作用,但政府扶持和大量资金的投入无疑是这些产业高速发展的必要条件。\n1818 关于光电显示产业链国产化的分析报告很多,因为其中不少国内企业规模已经不小,成了上市公司,比如三利谱和精测电子等。外商直接投资(FDI)对本地供应链企业的正面拉动作用,有很多研究,几乎算国际经济学领域的定论了,读者可以参考哈夫拉内克(Havranek)和伊尔索娃(Irsova)的总结性论文(2011)。\n1919 国际贸易并不是无条件双赢的。在引入动态规模经济和学习效应之后,自由贸易可能会损害一国的经济增长和社会福利。读者可参考诺贝尔奖得主克鲁格曼(Krugman)的论文(1987)、伦敦政经学院阿温·杨(Alwyn Young)的论文(1991)或数学家戈莫里(Gomory)和经济学家鲍莫尔(Baumol)的著作(2000)。\n2020 关于自主创新,北京大学路风的著作(2016,2019,2020)中有很多精彩而独到的分析。\n2121 具体数据参见IMF两位经济学家的论文(Cherif and Hasanov, 2019)。\n2222 韩国的出口数据来自IMF两位经济学家的论文(Cherif and Hasanov, 2019)。北京大学林毅夫关于“比较优势”和产业升级的理论被称为“新结构经济学”,读者可以阅读他的著作(2014),其中也包括很多学者对这一理论的讨论以及林教授的回应。这些对话和争论非常精彩,可以帮助理解和澄清很多问题。\n2323 中国人民大学刘守英和杨继东(2019)统计了我国1 240种出口商品的“显性比较优势”,其中有196种商品2016年在国际上有比较优势,但1995年没有。这些新增产品很多来自复杂程度较高的行业,比如机械、电器、化工等。\n2424 产品复杂度的数据来自哈佛大学国际发展中心的“The Atlas of Economic Complexity”项目。\n2525 密歇根大学列夫琴科(Levchenko)的论文(2007)分析了制度质量和产品复杂性之间的关联。\n2626 来自2013年《环球企业家》的文章《烧钱机器京东方:国开行200亿融资背后的政商逻辑》。转引自搜狐财经频道https://m.sohu.com/n/364543762/。\n2727 在“新经济地理学”或“空间经济学”的理论中,产业一旦形成,经济力量就会加速地理集聚。但对集聚的具体位置而言,“初始条件”影响很大,对初始条件的微小干预就可能影响最终的产业地理格局。无论是中国还是美国,若追溯很多产业集聚地区的历史根源,都会发现一些偶然因素曾发挥过关键作用,比如在京东方的例子中是合肥时任领导的支持。克鲁格曼写过一本小册子(2002),讲述了这种偶然性和经济力量的结合对产业地理格局的影响。\n2828 见《财新周刊》2019年第44期的文章《面板产能过剩 地方国资投资冲动暗藏隐忧》。\n2929 我国光伏产业规模的数据来自中信证券弓永峰和林劼的研究报告(2020)。\n3030 全球光伏组件价格的降幅估算来自能源专家、普利策奖得主耶金(Yergin)的著作(2020)。全球和我国装机总量的数据来自全球可再生能源行业智库REN21的报告(2020)。\n3131 关于光伏产业的早期发展史,可以参考西瓦拉姆(Sivaram)的专著(2018)。\n3232 光伏上市公司数量来自《中国改革》2010年第4期文章《太阳能中国式跃进》。\n3333 数据来自《财新周刊》2017年第37期的文章《破产重整的赛维样本》。\n3434 光伏企业数量和出口的数据来自兴业证券朱玥的报告(2019)。国内市场销量占比数据来自西瓦拉姆(Sivaram)的著作(2018)。\n3535 参见《江苏省光伏发电推进意见》(苏政办发[2009]85号)。\n3636 2011年7月,发改委发布《关于完善太阳能光伏发电上网电价政策的通知》,核定上网电价为每度电1.15元。2013年8月,在《国家发展改革委关于发挥价格杠杆作用促进光伏产业健康发展的通知》发布后,开始实行分区上网电价。\n3737 上海对不同用电量实行差别定价。《上海市可再生能源和新能源发展专项资金扶持办法》中详细规定了对海上风电和光伏电站的补贴办法。\n3838 数据来自兴业证券朱玥的报告(2019)。\n3939 欠补总额的数据来自《财新周刊》2018年第25期的封面文章《巨额补贴难支 光伏断奶》。关于电网建设和消纳新能源电量之间的矛盾,相关报道很多,在此不一一列举。\n4040 关于传统能源向新能源转变的动态过程及其中最优的税收和补贴政策组合,可以参考麻省理工学院的阿西莫格鲁(Acemoglu)等人的论文(2016)。\n4141 进入全球市场会提升本国企业效率,不仅是由于基于比较优势的国际分工可以提升效率,也是由于更大规模的市场会提升高效企业的市场份额,压缩低效企业的生存空间,这便是经典贸易理论“Melitz模型”的核心思想。读者可以参考哈佛大学梅里兹(Melitz)和多伦多大学特雷夫莱(Trefler)的介绍性文章(2012)。\n4242 不确定性对投资行为和经济周期的影响,是经济学的重要议题之一,有很长的研究传统。读者可参考斯坦福大学布鲁姆(Bloom)对这个领域精彩且通俗的介绍(2014)。\n4343 第一个因素被称为“潮涌现象”,详见北京大学林毅夫、巫和懋和邢亦青的论文(2010)。第二个因素被称为“Oi-Hartman-Abel”效应,即企业可以通过扩张(此处指建厂)获得好处,同时可以通过收缩(此处指投产后调整产能)避免风险,详见斯坦福大学布鲁姆(Bloom)的论文(2014)。第三个因素,即各地产业扶持目标逐渐和中央产业政策趋同的现象,见复旦大学赵婷和陈钊的论文(2019)。\n4444 中欧商学院叶恩华(George Yip)和布鲁斯·马科恩(Bruce Mckern)的著作(2016)系统分析了我国企业成本创新的很多案例。\n4545 关于市场经济的核心不是决策优势而是优胜劣汰的思想,已故经济学家阿尔钦(Alchian)半个多世纪前的文章(1950)今天看依然精彩。\n4646 我国竞争性的产业政策,比如针对全行业的补贴、税收减免、低息贷款等,对提升行业技术水平和效率有正面作用,参见哈佛大学阿吉翁(Aghion)和马里兰大学蔡婧等人的论文(2015)。\n4747 详细情况可以参考《财新周刊》2017年第37期的报道《破产重整的赛维样本》。\n4848 清科和投中是两家研究私募基金的国内机构。私募基金的信息披露不完全、不透明,所以不同的估计差别很大,此处的数字引自《财新周刊》2020年第39期的封面报道《监管十万亿私募股权基金》。在私募基金行业,基金的目标规模通常并不重要,实际募资和到位资金一般远小于目标规模。媒体上经常看到的天文数字般的政府引导基金目标规模,没有太大意义。\n4949 在实际运作中,LP也会设计各种各样的机制来监督和激励GP,使其行事符合LP利益。比如出资较多的LP可能要求参与GP的投资决策,在GP的投资决策委员会中有一席投票权或否决权,或者派出观察员。再比如在组建基金时,GP通常也会象征性地投入一些钱,以示与LP利益绑定,一般就是LP出资总额的1%—2%。\n5050 私募基金兴起于美国,与资本市场上的“杠杆收购”(leverage buy-out)紧密相关。这种金融工具的兴起,背后有复杂的经济和社会背景,包括对公司角色认知的转变、全球化后劳资关系的转变、金融管制放松等。美国智库CEPR的阿佩尔鲍姆(Appelbaum)和康奈尔大学巴特(Batt)的著作(2014)对私募基金和杠杆收购的时代背景和逻辑做了精彩的分析和介绍。\n5151 如2002年在华盛顿成立的“机构类LP协会”(Institutional Limited Partner Association, ILPA)。\n5252 2005年,国家发展改革委与科技部、财政部、商务部、中国人民银行等多部委联合发布《创业投资企业管理暂行办法》。\n5353 2008年,国务院办公厅转发发展改革委等部门《关于创业投资引导基金规范设立与运作的指导意见》。\n5454 2002年财政部规定,海外上市的国有企业要把发行股数的10%划转给全国社保基金理事会持有。2009年,财政部、国资委、证监会、社保基金会联合印发《境内证券市场转持部分国有股充实全国社会保障基金实施办法》:凡在境内IPO的含国有股的股份有限公司,除国务院另有规定的,均须按IPO时实际发行股份数量的10%,将部分国有股转由社保基金会持有;国有股东持股数量少于应转持股份数量的,按实际持股数量转持。\n5555 2010年,财政部联合国资委和证监会、社保基金会发布《关于豁免国有创业投资机构和国有创业投资引导基金国有股转持义务有关问题的通知》。\n5656 2011年,财政部和发改委发布《新兴产业创投计划参股创业投资基金管理暂行办法》。\n5757 2014年,国务院发布《国务院关于清理规范税收等优惠政策的通知》,规定“未经国务院批准,各地区、各部门不得对企业规定财政优惠政策。对违法违规制定与企业及其投资者(或管理者)缴纳税收或非税收入挂钩的财政支出优惠政策,包括先征后返、列收列支、财政奖励或补贴,以代缴或给予补贴等形式减免土地出让收入等,坚决予以取消”。关于财政结余资金,新版《预算法》规定:“各级政府上一年预算的结转资金,应当在下一年用于结转项目的支出;连续两年未用完的结转资金,应当作为结余资金管理。”\n5858 2015年最后两个月,财政部连续发布《政府投资基金暂行管理办法》和《关于财政资金注资政府投资基金支持产业发展的指导意见》。\n5959 我国股市上曾经有三分之二的股权不能流通,这种“股”和“权”分置的状况让非流通股股东的权益受到严重限制,造成了很多扭曲。2005年4月,证监会发布《关于上市公司股权分置改革试点有关问题的通知》,股权分置改革开始。\n6060 2006年,证监会发布《首次公开发行股票上市管理办法》。\n6161 2010年,保监会发布《保险资金股权投资暂行办法》,允许符合条件的保险公司直接投资于非上市公司股权,或者将总资产的4%投资于股权投资基金。2014年,保监会发布《关于保险资金投资创业投资基金有关事项的通知》,为险资进入创投基金扫清了障碍。\n6262 见美国贸易代表办公室的“301调查报告”:“Findings of the Investigation into China\u0026rsquo;s Acts, Policies, and Practices Related to Technology Transfer, Intellectual Property, and Innovation”。\n6363 根据海通证券姜超、朱征星、杜佳等人的估计(2018),截至2017年底,由PPP和政府基金所形成的各类“名股实债”,总额约3万亿元左右。2017年初,发改委出台《政府出资产业投资基金管理暂行办法》,明确禁止“名股实债等变相增加政府债务的行为”。\n"},{"id":161,"href":"/zh/docs/life/20250103/","title":"随想","section":"生活","content":" 学习知识是我们理解世界的一种方式。当知识与现实不符时,你可以说是知识错了,也可以说是你理解错了,但这都不重要,重要的是要将思维,改成与现实世界相符的方向来理解,毕竟我们是生活在现实世界中,而不是理想中。穷则独善其身,达则兼济天下。 用科技解释现象是一种理解世界的方式,用阴阳五行命理也是一种。最重要的是要过的心安理得,有理可循。 "},{"id":162,"href":"/zh/docs/culture/%E4%B8%AD%E5%9B%BD%E9%80%9A%E5%8F%B2%E5%90%95%E6%80%9D%E5%8B%89/%E4%B8%8B%E7%AF%87-%E4%B8%AD%E5%9B%BD%E6%96%87%E5%8C%96%E5%8F%B2/","title":" 下编-中国文化史","section":"中国通史(吕思勉)","content":" 第三十七章 婚姻 # 《易经》的《序卦传》说:“有天地,然后有万物;有万物,然后有男女;有男女,然后有夫妇;有夫妇,然后有父子;有父子,然后有君臣。”这是古代哲学家所推想的社会起源。他们以为隆古的社会,亦像后世一般,以一夫一妇为基本,成立一个家庭,由此互相联结,成为更大的组织。此等推想,确乎和我们根据后世的制度,以推想古代的情形的脾胃相合。所以几千年来,会奉为不刊之典。然而事实是否如此,却大是一个疑问了。\n自有历史以来,不过几千年,社会的情形,却已大有改变了。设使我们把历史抹杀了,根据现在的情形,去臆测周、秦、汉、魏、唐、宋时的状况,那给研究过历史的人听了,一定是一场大笑话,何况邃古之事,去今业已几万年几十万年呢?不知古代的真相,而妄以己意推测,其结果,必将以为自古至今,不过如此,实系因缘起灭的现象,都将认为天经地义,不可变更。这就将发生许多无谓的争执,不必要的保守,而进化的前途被其阻碍了。所以近几十年来,史前史的发现,实在是学术上的一个大进步。而其在社会组织方面,影响尤大。\n据近代社会学家所研究:人类男女之间,本来是没有什么禁例的。其后社会渐有组织,依年龄的长幼,分别辈行。当此之时,同辈行之男女,可以为婚,异辈行则否。更进,乃于亲族之间,加以限制。最初是施诸同母的兄弟姊妹的。后来渐次扩充,至凡同母系的兄弟姊妹,都不准为婚,就成所谓氏族(Sib)了。此时异氏族之间,男女仍是成群的,此一群之男,人人可为彼一群之女之夫;彼一群之女,人人可为此一群之男之妻;绝无所谓个别的夫妇。其后禁例愈繁,不许相婚之人愈多。于是一个男子,有一个正妻;一个女子,有一个正夫。然除此之外,尚非不许与其他的男女发生关系,而夫妻亦不必同居,其关系尚极疏松。更进,则夫妻必须同居(一夫一妻,或一夫多妻),关系更为永久,遂渐成后世的家庭了。所以人类的婚姻,是以全无禁例始,逐渐发生加繁其禁例,即缩小其通婚的范围,而成为今日的形态的。以一夫一妻的家庭,为原始的男女关系,实属错误。\n主张一夫一妻的家庭,为男女原始关系的形态的,不过说:人类是从猿猴进化而来的,猿猴已有家庭,何况人类?然谓猿猴均有家庭,其观察本不正确(详见李安宅译《两性社会学》附录《近代人类学与阶级心理》第四节。商务印书馆本)。即舍此勿论,猿猴也是人类祖先的旁支,而非其正系。据生物学家之说,动物的聚居,有两种形式:一如猫虎等,雌雄同居,以传种之时为限;幼儿成长,即与父母分离,是为家庭动物。一如犬马等,其聚居除传种外,兼以互相保卫为目的;历时可以甚久,为数可以甚多,是为社群动物。人类无爪牙齿角以自卫,倘使其聚居亦以家庭为限,在隆古之世,断乎无以自存,而且语言也必不会发达。所以原始人类的状况,我们虽不得而知,其为社群而非家庭,则殆无疑义。猿类的进化不如人类,以生物界的趋势论,实渐走上衰亡之路,怕正以其群居本能,不如人类之故。而反说人类的邃初,必与猿猴一样,实未免武断偏见了。何况人类的性质,如妒忌及性的羞耻等,均非先天所固有(此观小孩便可知。动物两性聚居,只有一夫一妻,一夫多妻两种形式,人类独有一妻多夫,尤妒忌非先天性质之明证);母爱亦非专施诸子女等,足以证明其非家庭动物的,还很多呢。\n现代的家庭,与其说是源于人的本性,倒不如说是源于生活情形(道德不道德的观念,根于习惯;习惯源于生活)。据社会学家所考究:在先史时期,游猎的阶级极为普遍。游猎之民,都是喜欢掠夺的,而其时可供掠夺之物极少,女子遂成为掠夺的目的。其后虑遭报复,往往掠夺之后,遗留物件,以为交换。此时的掠夺,实已渐成为贸易。女子亦为交换品之一。是为掠夺的变相,亦开卖买的渊源。掠夺来的女子,是和部族中固有的女子,地位不同的。她是掠夺她的人的奴隶,须负担一切劳役。此既足以鼓励男子,使之从事于掠夺,又婚姻之禁例渐多,本部族中的女子,可以匹合者渐少,亦益迫令男子从事于向外掠夺。所以家庭的起源,是由于女子的奴役;而其需要,则是立在两性分工的经济原因上的。与满足性欲,实无多大关系。原始人除专属于他的女子以外,满足性欲的机会,正多着呢。游猎之民,渐进而为畜牧,其人之好战斗,喜掠夺,亦与游猎之民同(凡畜牧之民,大抵兼事田猎),而其力且加强(因其食物充足,能合大群;营养佳良,体格强壮之故),牧群须人照管,其重劳力愈甚,而掠夺之风亦益烈。只有农业是源于搜集的,最初本是女子之事。低级的农业,亦率由女子任其责。其后逐渐发达,成为生活所必资。此时经济的主权,操于女子之手。土田室屋及农具等,率为女子所有。部族中人,固不愿女子出嫁;女子势亦无从出嫁;男子与女子结婚者,不得不入居女子族中,其地位遂成为附属品。此时女子有组织,男子则无(或虽有之而不关重要),所以社会上有许多公务,其权皆操于女子之手(如参与部族会议,选举酋长等。此时之女子,亦未尝不从事于后世家务一类的事务,然其性质,亦为公务,与后世之家务,迥乎不同),实为女子的黄金时代。所谓服务婚的制度,即出现于此时。因为结婚不能徒手,而此时的男子,甚为贫乏,除劳力之外,实无可以为聘礼之物之故。其后农业更形重要,男子从事于此者益多。导致以男子为之主,而女子为之辅。于是经济的主权,再入男子之手。生活程度既高,财产渐有赢余,职业日形分化。如工商等业,亦皆为男子之事。个人私产渐兴,有财富者即有权力,不乐再向女子的氏族中作苦,乃以财物偿其部族的损失,而娶女以归。于是服务婚渐变为卖买婚,女子的地位,又形低落了。\n以上所述,都是社会学家的成说。返观我国的古事,也无乎不同。《白虎通义·三皇篇》说,古代的人,“知其母而不知其父”,这正是古代的婚姻,无所谓夫妇的证据。人类对于男女性交,毫无限制的时代,去今已远,在书本上不易找到证据。至于辈行婚的制度,则是很明白无疑的。《礼记·大传》说宗子合族之礼道:“同姓从宗合族属,异姓主名治际会。名著而男女有别。其夫属乎父道者,妻皆母道也;其夫属乎子道者,妻皆妇道也。谓弟之妻为妇者,是嫂亦可谓之母乎?名者,人治之大者也,可无慎乎?”这正是古代婚姻,但论辈行一个绝好的遗迹。这所谓同姓,是指父系时代本氏族里的人。用现在的话来说,就是老太爷、老爷、少爷们。异姓,郑《注》说:“谓来嫁者”,就是老太太、太太、少太太们。从宗,是要依着血系的枝分派别的,如先分为老大房、老二房、老三房,再各统率其所属的房分之类,参看下章自明。主名,郑《注》说:“主于妇与母之名耳。”谓但分别其辈行,而不复分别其枝派。质而言之,就是但分为老太太、太太、少太太,而不再问其孰为某之妻,孰为某之母。“谓弟之妻为妇者,是嫂亦可谓之母乎。”翻做现在的话,就是:“把弟媳妇称为少太太,算做儿媳妇一辈,那嫂嫂难道可称为老太太,算做母亲一辈么?”如此分别,就可以称为男女有别,可见古代婚姻,确有一个专论辈行的时代,在周代的宗法中,其遗迹还未尽泯。夏威夷人对于父、伯叔父、舅父,都用同一的称呼。中国人对于舅,虽有分别,父与伯叔父,母与伯叔母、从母,也是没有分别的。伯父只是大爷,叔父、季父,只是三爷、四爷罢了。再推而广之,则上一辈的人,总称为父兄,亦称父老。老与考为转注(《说文》),最初只是一语,而考为已死之父之称。下一辈则总称子弟。《公羊》何《注》说:“宋鲁之间,名结婚姻为兄弟。”(僖公二十五年)可见父母兄弟等,其初皆非专称。资本主义的社会学家说:这不是野蛮人不知道父与伯叔父、舅父之别,乃是知道了而对于他们,仍用同一的称呼。殊不知野蛮人的言语,总括的名词,虽比我们少,各别的名词却比我们多。略知训诂的人皆知之(如古鸟称雌雄,兽称牝牡,今则总称雌雄,即其一例)。既知父与伯叔父、舅父之别,而仍用同一的称呼,这在我们,实在想不出这个理由来。难者将说:父可以不知道,母总是可以知道的,为什么母字亦是通称呢?殊不知大同之世,“人不独亲其亲,不独子其子”,生物学上的母,虽止一个,社会学上的母,在上一辈中,是很普遍的。父母之恩,不在生而在养,生物学上的母,实在是无甚关系的,又何必特立专名呢?然则邃初所谓夫妇之制和家庭者安在?《尔雅·释亲》:兄弟之妻,“长妇谓稚妇为娣妇,娣妇谓长妇为姒妇”,这就是现在的妯娌。而女子同嫁一夫的,亦称先生者为姒,后生者为娣。这也是辈行婚的一个遗迹。\n社会之所以有组织,乃是用以应付环境的。其初,年龄间的区别,实在大于两性间的区别(后来受文化的影响,此等区别,才渐渐转变。《商君书·兵守篇》说,军队的组织,以壮男为一军,壮女为一军,男女之老弱者为一军,其视年龄的区别,仍重于两性的区别)。所以组织之始,是按年龄分辈分的。而婚姻的禁例,亦起于此。到后来,便渐渐依血统区别了。其禁例,大抵起于血缘亲近之人之间。违犯此等禁例者,俗语谓之“乱伦”,古语则谓之“鸟兽行”,亦谓之“禽兽行”。惩罚大抵是很严重的。至于扩而充之,对母方或父方有血缘关系之人,概不许结婚,即成同姓不婚之制(中国古代的姓,相当于现在社会学上所谓氏族,参看下章)。同姓不婚的理由,昔人说是“男女同姓,其生不蕃”(《左传》僖公二十三年郑叔詹说)。“美先尽矣,则相生疾。”(同上,昭公七年郑子产说)又说是同姓同德,异姓异德(《国语·晋语》司空季子说),好像很知道遗传及健康上的关系的。然(一)血族结婚,有害遗传,本是俗说,科学上并无证据。(二)而氏族时代所谓同姓,亦和血缘远近不符。(三)至谓其有害于健康,则更无此说。然则此等都是后来附会之说,并不是什么真正的理由。以实际言,此项禁例,所以能维持久远的,大概还是由于《礼记·郊特牲》所说的“所以附远厚别”。因为文化渐进,人和人之间,妒忌之心,渐次发达,争风吃醋的事渐多,同族之中,必有因争色而致斗乱的,于是逐渐加繁其禁例,最后,遂至一切禁断。而在古代,和亲的交际,限于血缘上有关系的人。异姓间的婚姻,虽然始于掠夺,其后则渐变为卖买,再变为聘娶,彼此之间,无复敌意,而且可以互相联络了。试看春秋战国之世,以结婚姻为外交手段者之多,便可知《郊特牲》附远二字之确。这是同姓不婚之制,所以逐渐普遍,益臻固定的理由。及其既经普遍固定之后,则制度的本身,就具有很大的威权,更不必要什么理由了。\n妒忌的感情,是何从而来的呢?前文不是说,妒忌不是人的本性么?然两性间的妒忌,虽非人之本性,而古人大率贫穷,物质上的缺乏,逼着他不能不生出产业上的嫉妒来。掠夺得来的女子,既是掠夺者的财产,自然不能不努力监视着她。其监视,固然是为着经济上的原因,然他男子设或与我的奴隶,发生性的关系,就很容易把她带走,于是占有之欲,自物而扩及于人,而和此等女子发生性的关系,亦非得其主人许可,或给以某种利益,以为交换不可了(如租凭、借贷、交换等。《左传》襄公二十八年,庆封与卢蒲嫳易内;昭公二十八年,祁胜与邬臧通室;现在有等地方,还有租妻之俗,就是这种制度的遗迹)。再进,产业上的妒忌,渐变成两性间的妒忌,而争风吃醋之事遂多。内婚的禁忌,就不得不加严,不得不加密了。所以外婚的兴起,和内婚的禁止,也是互为因果的。\n掠夺婚起于游猎时代,在中国古书上,也是确有证据的。《礼记·月令》《疏》引《世本》说:太昊始制嫁娶以俪皮为礼。托诸太昊,虽未必可信,而俪皮是两鹿皮,见《公羊》庄公二十二年何《注》,这确是猎人之物。古婚礼必用雁,其理由,怕亦不过如此。又婚礼必行之昏时,亦当和掠夺有关系。\n中国农业起于女子,捕鱼在古代,亦为女子之事,说见第四十七章。农渔之民,都是食物饶足,且居有定地的,畋猎对于社会的贡献比较少,男子在经济上的权力不大,所以服务婚之制,亦发生于此时。赘婿即其遗迹。《战国·秦策》说:太公望是齐之逐夫,当即赘婿。古代此等婚姻,在东方,怕很为普遍的。《汉书·地理志》说:齐襄公淫乱,姑姊妹不嫁。“于是下令国中:民家长女不得嫁,名曰巫儿,为家主祠,嫁者不利其家。民至今以为俗。”把此等风俗的原因,归诸人君的一道命令,其不足信,显而易见。其实齐襄公的姑姊妹不嫁,怕反系受这种风俗的影响罢?《公羊》桓公二年,有楚王妻媦之语(何《注》:媦,妹也)。可见在东南的民族,内婚制维持较久。《礼记·大传》说:“四世而缌,服之穷也。五世袒免,杀同姓也。六世亲属竭矣,其庶姓别于上(庶姓见下章),而戚单于下(单同殚),婚姻可以通乎?系之以姓而弗别,缀之以族而弗殊,虽百世而婚姻不通者,周道然也。”然则男系同族,永不通婚,只是周道。自殷以上,六世之后,婚姻就可以通的。殷也是东方之国。《汉书·地理志》又说燕国的风俗道:“初太子丹宾养勇士,不爱后宫美女,民化以为俗,至今犹然。宾客相过,以妇侍宿。嫁娶之夕,男女无别,反以为荣。后稍颇止,然终未改。”不知燕丹的举动,系受风俗的影响,反以为风俗源于燕丹,亦与其论齐襄公同病。而燕国对于性的共有制,维持较久,则于此可见。燕亦是滨海之地。然则自东南亘于东北,土性肥沃,水利丰饶,农渔二业兴盛之地,内婚制及母系氏族,都是维持较久的。父系氏族,当起于猎牧之民。此可见一切社会制度,皆以经济状况为其根本原因。\n人类对于父母亲族,总只能注意其一方,这是无可如何的。所以在母系氏族内,父方的亲族,并不禁止结婚;在父系氏族内,母方的亲族亦然;且有两个氏族,世为婚姻的。中国古代,似亦如此。所以夫之父与母之兄弟同称(舅)。夫之母与父之姊妹同称(姑)。可见母之兄弟,所娶者即父之姊妹(并非亲姊妹,不过同氏族的姊妹行而已)。而我之所嫁,亦即父之氏族中之男子,正和我之母与我之父结婚同。古代氏族,又有在氏族之中,再分支派的。如甲乙两部族,各分为一二两组。甲一之女,必与乙二之男结婚,生子则属于甲二。甲二之女,必与乙一之男结婚,生子则属于甲一。乙组的女子亦然(此系最简单之例,实际还可以更繁复)。如此,则祖孙为同族人,父子则否。中国古代,似亦如此。所以祭祀之礼:“孙可以为王父尸,子不可以为父尸。”(《礼记·曲礼》)。殇与无后者,必从祖祔食,而不从父祔食(《礼记·曾子问》)。\n近亲结婚,在法律上本有禁令的,并不限于父系。如《清津》:“娶己之姑舅两姨姊妹者,杖八十,并离异。”即是。然因此等风俗,根深柢固,法律就成为具文了。\n古代所谓同姓,是自认为出于同一始祖的(在父系氏族,则为男子。在母系氏族,则为女子),虽未必确实,他们固自以为如此。同姓与否,和血缘的远近,可谓实无关系。然他们认为同姓则同德,不可结婚,异姓则异德,可以结婚,理由虽不确实,办法尚觉一致。至后世所谓同姓,则并非同出于一源;而同出于一源的,却又不必同姓。如王莽,以姚、妫、陈、田皆黄、虞后,与己同姓,令元城王氏,勿得与四姓相嫁娶(《汉书·莽传》),而王、孙咸,以得姓不同,其女转嫁为莽妻(《汉书·传》),此等关系,后世都置诸不论了。所谓同姓异姓,只是以父系的姓,字面上的同异为据,在理论上,可谓并无理由,实属进退失据。此因同姓不婚之制,已无灵魂,仅剩躯壳之故。总而言之,现在的所谓姓氏,从各方面而论,都已毫无用处,不过是社会组织上的老废物罢了。参看下章自明。\n婚礼中的聘礼,即系卖买婚的遗迹,古礼称为“纳征”。《礼记·内则》说:“聘则为妻,奔则为妾”;《曲礼》说:“买妾不知其姓则卜之”;则买妾是真给身价的,聘妻虽具礼物,不过仅存形式,其意已不在于利益了。\n古代婚礼,传于后世的,为《仪礼》中的《士昏礼》。其节目有六:即(一)纳采(男氏遣使到女氏去求婚),(二)问名(女氏许婚之后,再请问许婚的是哪一位姑娘?因为纳采时只申明向女氏的氏族求婚,并未指明哪一个人之故),(三)纳吉(女氏说明许婚的系哪一位姑娘之后,男氏归卜之于庙。卜而得吉,再使告女氏),(四)纳征(亦谓之纳币。所纳者系玄束帛及俪皮),(五)请期(定吉日。吉日系男氏所定,三请于女氏,女氏不肯定,然后告之),(六)亲迎(新郎亲到女氏。执雁而入,揖让升堂,再拜奠雁。女父带着新娘出来,交结他。新郎带着新娘出门。新娘升车,新郎亲为之御。车轮三转之后,新郎下车,由御者代御。新郎先归,在门首等待。新娘车至,新郎揖之而入。如不亲迎的,则新郎三月后往见舅姑。亲迎之礼,儒家赞成,墨家是反对的,见《礼记·哀公问》、《墨子·非儒篇》),是为六礼。亲迎之夕,共牢而食,合卺而酳(古人的宴会,猪牛羊等,都是每人一份的。夫妻则两个人合一份,是谓同牢。把一个瓢破而为两,各用其半,以为酒器,是为合卺。这表示“合体,同尊卑”的意思)。其明天,“赞妇见于舅姑”。又明天,“舅姑共飨妇”。礼成之后,“舅姑先降自西阶(宾阶),妇降自阼阶。”(东阶,主人所行。古人说地道尊右,故让客人走西阶。)表明把家事传给他,自己变做客人的意思。此礼是限于适妇的,谓之“著代”,亦谓之“授室”。若舅姑不在,则三月而后庙见。《礼记·曾子问》说:“女未庙见而死,归葬于女氏之党,示未成妇。”诸侯嫁女,亦有致女之礼,于三月之后,遣大夫操礼而往,见《公羊》成公九年。何《注》说:“必三月者,取一时,足以别贞信。”然则古代的婚礼,是要在结婚三个月之后,才算真正成立的。若在三月之内分离,照礼意,还只算婚姻未完全成立,算不得离婚。这也可见得婚姻制度初期的疏松。\n礼经所说的婚礼,是家族制度全盛时的风俗,所以其立意,全是为家族打算的。《礼记·内则》说:“子甚宜其妻,父母不说,出。子不宜其妻,父母曰:是善事我,子行夫妇之礼焉,没身不衰。”可见家长权力之大。《昏义》说:“成妇礼,明妇顺,又申之以著代,所以重责妇顺焉也。妇顺也者,顺于舅姑,和于室人,而后当于夫,以成丝麻布帛之事,以审守委积盖藏。是故妇顺备而后内和理,内和理而后家可长久也,故圣王重之。”尤可见娶妇全为家族打算的情形。《曾子问》说:“嫁女之家,三夜不息烛,思相离也”,这是我们容易了解的。又说:“取妇之家,三日不举乐,思嗣亲也。”此意我们就不易了解了。原来现代的人,把结婚看作个人的事情,认为是结婚者的幸福,所以多有欢乐的意思。古人则把结婚看做为家族而举行的事情。儿子到长大能娶妻,父母就近于凋谢了,所以反有感伤的意思。《曲礼》说:“昏礼不贺,人之序也”,也是这个道理。此亦可见当时家族主义的昌盛,个人价值,全被埋没的一斑。\n当这时代,女子遂成为家族的奴隶,奴隶是需要忠实的,所以贞操就渐渐的被看重。“贞妇”二字,昉见于《礼记·丧服四制》。春秋时,鲁君的女儿,有一个嫁给宋国的,称为宋伯姬。一天晚上,宋国失火,伯姬说:“妇人夜出,必待傅姆。”(傅姆是老年的男女侍从。必待傅姆,是不独身夜行,以避嫌疑的意思)傅姆不至,不肯下堂,遂被火烧而死。《春秋》特书之,以示奖励(《公羊》襄公三十年)。此外儒家奖励贞节之说,还有许多,看刘向的《列女传》可知。刘向是治鲁诗的,《列女传》中,有许多是儒家相传的诗说。秦始皇会稽刻石说:“饰省宣义,有子而嫁,倍死不贞。防隔内外,禁止淫佚,男女洁诚。夫为寄豭,杀之无罪,男秉义程。妻为逃嫁,子不得母,咸化廉清。”按《管子·八观篇》说:“闾干无阖,外内交通,则男女无别矣。”又说:“食谷水,巷凿井;场圃接,树木茂;宫墙毁坏,门户不闭,外内交通;则男女之别,无自正矣。”(《汉书·地理志》说:郑国土陋而险,山居谷汲,男女亟聚会,故其俗淫)。这即是秦始皇所谓防隔内外。乃是把士大夫之家,“深宫固门,阍寺守之,男不入,女不出”的制度(见《礼记·内则》),推广到民间去。再嫁未必能有什么禁令,不过宣布其是倍死不贞,以示耻辱,正和奖励贞节,用意相同。寄豭是因奸通而寄居于女子之家的,杀之无罪;妻为逃嫁,则子不得母,其制裁却可谓严厉极了。压迫阶级所组织的国家,其政令,自然总是为压迫阶级张目的。\n虽然如此,罗马非一日之罗马,古代疏松的婚姻制度,到底非短期间所能使其十分严紧的。所以表显于古书上的婚姻,要比后世自由得多。《左传》昭公元年,载郑国徐吾犯之妹美,子南业经聘定了她,子皙又要强行纳聘。子皙是个强宗,国法奈何不得他。徐吾犯乃请使女自择,以资决定。这虽别有用意,然亦可见古代的婚嫁,男女本可自择。不过“男不亲求,女不亲许”(见《公羊》僖公十四年),必须要有个媒妁居间;又必须要“为酒食以召乡党僚友”(《礼记·典礼》),以资证明罢了。婚约的解除,也颇容易。前述三月成妇之制,在结婚三个月之后,两造的意见,觉得不合,仍可随意解除,这在今日,无论哪一国,实都无此自由。至于尚未同居,则自然更为容易。《礼记·曾子问》说:“昏礼:既纳币,有吉日,女之父母死,则如之何?孔子曰:婿使人吊。如婿之父母死,则女之家亦使人吊。婿已葬,婿之伯父,致命女氏曰:某之子有父母之丧,不得嗣为兄弟,使某致命。女氏许诺,而弗敢嫁,礼也。婿免丧,女之父母使人请,婿弗取而后嫁之,礼也。女之父母死,婿亦如之。”一方等待三年,一方反可随意解约,实属不近情理。迂儒因生种种曲说。其实这只是《礼记》文字的疏忽。孔子此等说法,自为一方遭丧而一方无意解约者言之。若其意欲解约,自然毫无限制。此乃当然之理,在当日恐亦为常行之事,其事无待论列,故孔子不之及。记者贸然下了“而弗敢嫁,礼也”六字,一似非等待不可的,就引起后人的误会了。离婚的条件,有所谓七出,亦谓之七弃(一、无子,二、淫佚,三、不事舅姑,四、口舌,五、盗窃,六、嫉妒,七、恶疾)。又有所谓三不去(一、尝更三年丧不去,二、贱取贵不去,三、有所受无所归不去)。与五不娶并列(一、丧妇长女,二、世有恶疾,三、世有刑人,四、乱家女,五、逆家女),见于《大戴礼记·本命篇》,和《公羊》庄公二十七年何《注》,皆从男子方面立说。此乃儒家斟酌习俗,认为义所当然,未必与当时的法律习惯密合。女子求去,自然也有种种条件,为法律习惯所认许的,不过无传于后罢了。观汉世妇人求去者尚甚多(如朱买臣之妻等),则知古人之于离婚,初不重视。夫死再嫁,则尤为恒事。这是到宋以后,理学盛行,士大夫之家,更看重名节,上流社会的女子,才少有再嫁的,前代并不如此。《礼记·郊特牲》说:“一与之齐,终身不改,故夫死不嫁。”这是现在讲究旧礼教的迂儒所乐道的。然一与之齐,终身不改,乃是说不得以妻为妾,并非说夫死不嫁。《白虎通义·嫁娶篇》引《郊特牲》,并无“故夫死不嫁”五字,郑《注》亦不及此义,可见此五字为后人所增。郑《注》又说:“齐或为醮”,这字也是后人所改的。不过郑氏所据之本,尚作齐字,即其所见改为醮字之本,亦尚未窜入“故夫死不嫁”五字罢了。此可见古书逐渐窜改之迹。\n后世男子的权利,愈行伸张,则其压迫女子愈甚。此可于其重视为女时的贞操,及其贱视再醮妇见之。女子的守贞,实为对于其夫之一种义务。以契约论,固然只在婚姻成立后,持续时为有效,以事实论,亦只须如此。所以野蛮社会的风俗,无不是如此的,而所谓文明社会,却有超过这限度的要求。此无他,不过压迫阶级的要求,更进一步而已。女子的离婚,在后世本较古代为难,因为古代的财产,带家族共有的意思多,一家中人,当然都有享受之份。所以除所谓有所受无所归者外,离婚的女子,都不怕穷无所归。后世的财产,渐益视为个人所有,对于已嫁大归之女,大都不愿加以扶养;而世俗又贱视再醮之妇,肯娶者少,弃妇的境遇,就更觉凄惨可怜了。法律上对于女子,亦未尝加以保护。如《清律》:“凡妻无应出及义绝之状而出之者,杖八十。虽犯七出,有三不去而出之者,减二等,追还完聚。”似乎是为无所归的女子特设的保护条文。然追还完聚之后,当如何设法保障,使其不为夫及夫之家族中人所虐待,则绝无办法。又说:“若夫妻不相和谐而两愿离者不坐。”不相和谐,即可离异,似极自由。然夫之虐待其妻者,大都榨取其妻之劳力以自利,安能得其愿离?离婚而必以两愿为条件,直使被虐待者永无脱离苦海之日。而背夫私逃之罪,则系“杖一百,从夫嫁卖”。被虐待的女子,又何以自全呢?彻底言之:现在所谓夫妇制度,本无维持之价值。然进化非一蹴所可几,即制度非旦夕所能改。以现在的立法论,在原则上当定:一、离婚之诉,自妻提出者无不许。二、其生有子女者,抚养归其母,费用则由其父负担。三、夫之财产中,其一部分,应视为其妻所应得,离婚后当给与其妻。四、夫妻异财者勿论。其同财者,嫁资应视为妻之私财,离婚时给还其妻;其业经销用者应赔偿。这固不是根本解决的办法,然在今日,立法上亦只得如此了。而在今日,立法上亦正该如此。\n古书中所载的礼,大抵是父系家庭时代的习惯风俗。后世社会组织,迄未改变,所以奉其说为天经地义。而因此等说法,被视为天经地义之故,亦有助于此制度之维持。天下事原总是互为因果的。但古书中的事实,足以表示家族主义形成前的制度的亦不少,此亦不可不注意。《礼记·礼运》:“合男女,颁爵位,必当年德。”《管子·幼官篇》,亦有“合男女”之文。合男女,即《周官》媒氏及《管子·入国篇》的合独之政。《周官》媒氏:“凡男女自成名以上,皆书年月日名焉。令男三十而娶,女二十而嫁。中春之月,令会男女。于是时也,奔者不禁(谓不备聘娶之礼,说见下)。司男女之无夫家者而会之。”合独为九惠之政之一。其文云:“取鳏寡而和合之,与田宅而家室之,三年然后事之。”此实男女妃合,不由家族主持,而由部族主持之遗迹。其初盖普遍如此。到家族发达之后,部族于通常之结婚,才置诸不管,而只干涉其违法者,而救济其不能婚嫁者了。当男女婚配由部族主持之世,结婚的年龄,和每年中结婚的季节,都是有一定的。婚年:儒家的主张,是男三十而娶,女二十而嫁。《礼记·曲礼》、《内则》等篇,都是如此。《大戴礼记·本命篇》说,这是中古之礼。太古五十而室,三十而嫁。《墨子》(《节用》)、《韩非子》(《外储说右下》),则说男二十而娶,女十五而嫁。结婚的年龄,当然不能斠若画一。王肃说:男十六而精通,女十四而能化,自此以往,便可结婚;所谓三十、二十等,乃系为之极限,使不可过。又所谓男三十,女二十,不过大致如此,并非必以三十之男,配二十之女,其说自通(见《诗·摽有梅疏》)。《大戴礼》说:三十而室,二十而嫁,天子庶人同礼。《左传》说:天子十五而生子;三十而室,乃庶人之礼(《五经异义》)。贵族生计,较庶人为宽裕,结婚年龄,可以提早,说亦可通。至《墨子》、《韩非子》之说,则系求蕃育人民之意,古代此等政令甚多,亦不足怪。所可怪者,人类生理,今古相同,婚配的要求,少壮之时,最为急切,太古时何以能迟至五十、三十?按罗维(Robert H.Lowie)所著的《初民社会》(吕叔湘译,商务印书馆本),说巴西的波洛洛人(Bororo),男女性交和结婚,并非一事。当其少年时,男女之间,早已发生性的关系,然常是过着浪漫的生活,并不专于一人。倒是年事较长,性欲较淡,彼此皆欲安居时,才择定配偶,相与同居。按人类的性质,本是多婚的。男女同居,乃为两性间的分工互助,实与性欲无甚关系。巴洛洛人的制度,实在是较为合理的。社会制度,往往早期的较后期的为合理(这是因已往的文化,多有病态,时期愈晚,病态愈深之故)。中国太古之世,婚年较晚的理由,也可以借鉴而明了。人类性欲的开始,实在二七、二八之年。自此以往,更阅数年,遂臻极盛(此系中国古说,见《素问·上古天真论》。《大戴礼记》、《韩诗外传》、《孔子家语》等说皆同),根于生理的欲念,宜宣泄不宜抑压。抑压之,往往为精神病的根源。然后世将经济上的自立,责之于既结婚的夫妇,则非十余龄的男女所及;又教养子女之责,专由父母任之,亦非十余龄的男女所能,遂不得不将结婚的年龄展缓。在近代,并有因生计艰难,而抱独身主义的。性欲受抑压而横溢,个人及社会两方面,均易招致不幸的结果。这亦是社会制度与人性不能调和的一端。倘使将经济及儿童教养的问题,和两性问题分开,就不至有此患了。所以目前的办法在以节育及儿童公育,以救济迟婚及独身问题。结婚的季节,《春秋繁露》说:“霜降逆女,冰泮杀止。”(《循天之道篇》)《荀子》同(《大略篇》)。王肃说:自九月至正月(见《诗·绸缪疏》)。其说良是。古人冬则居邑,春则居野(参看第四十二、第五十章)。结婚的月份,实在是和其聚居的时期相应的。仲春则婚时已过,至此而犹不克婚,则其贫不能备礼可知,所以奔者不禁了。\n多妻之源,起于男子的淫侈。生物界的事实,两性的数目,常大略相等。婚姻而无禁例,或虽有禁例而不严密则已,若既限定对于法定的配偶以外,不许发生性的关系,而又有若干人欲多占异性为己有,则有多占的人,即有无偶的人。所以古今中外,有夫妇之制的社会,必皆以一夫一妻为原则。但亦总有若干例外。古代贵族,妻以外发生性的关系的人有两种:一种是妻家带来的,谓之媵。一种是自己家里所固有的,谓之妾(后世媵之实消灭,故其名称亦消灭,但以妾为配偶以外发生性的关系之人之总称)。媵之义为送,即妻家送女的人(并限于女子,如伊尹为有莘氏媵臣是),与婿家跟着新郎去迎接新娘的御相同。媵御的原始,实犹今日结婚时之男女傧相,本无可发生性的关系的理由。后来有特权的男子,不止娶于一家,正妻以外的旁妻,无以名之,亦名之曰媵,媵遂有正妻以外之配偶之义。古代的婚姻,最致谨于辈行,而此规则,亦为有特权者所破坏。娶一妻者,不但兼及其娣,而且兼及其姪,于是有诸侯一娶九女之制。取一国则二国往媵,各以侄娣从。一娶九女之制,据《白虎通义·嫁娶篇》说,天子与诸侯同。亦有以为天子娶十二女的,如《春秋繁露·爵国篇》是。此恐系以天子与诸侯同礼为不安而改之。其实在古代,天子诸侯,在实际上,未必有多大的区别。《礼记·昏义》末节说:天子有一后,三夫人,九嫔,二十七世妇,八十一御妻。按《昏义》为《仪礼·士昏礼》之传,传文皆以释经,独《昏义》此节,与经无涉,文亦不类传体,其说在他处又无所见,而适与王莽立后,备和、嫔、美、御,和人三,嫔人九,美人二十七,御人八十一之制相合(见《汉书·莽传》),其为后人窜入,自无可疑。《冠义》说:“无大夫冠礼而有其昏礼?古者五十而后爵,何大夫冠礼之有?”五十而后娶,其为再娶可知。诸侯以一娶九女之故,不得再娶(《公羊》庄公十九年)。大夫若亦有媵,安得再娶?管氏有三归,孔子讥其不俭(《论语·八佾》:包咸云:三归,娶三姓女),即系讥其僭人君之礼。所以除人君以外,是决无媵的。至于妾,则为家中的女子,得与家主相接之义。家族主义发达的时代,门以内的事情,国法本不甚干涉。家主在家庭中的地位,亦无人可以制裁他。家中苟有女奴,家主要破坏她的贞操,自无从加以制裁。所以有妾与否,是个事实问题,在法律上,或者并无制限。然古代依身份而立别的习惯,是非常之多的,或有制限,亦未可知。后世等级渐平,依身份而立区别的习惯,大半消除,娶妾遂成为男子普遍的权利了。虽然如此,法律上仍有依身份之贵贱,而定妾之有无多寡的。如《唐书·百官志》:亲王有孺人二人,媵十人;二品媵八人;国公及三品媵六人;四品媵四人;五品媵三人。《明律》:民年四十以上无子者,方听娶妾,违者笞四十。但此等法律,多成具文,而在事实上,则多妻之权利,为富者所享受。适庶之别,古代颇严。因为古代等级,本来严峻,妻和妾一出于贵族,一出于贱族,其在社会上的身份,本相悬殊之故。后世等级既平,妻妾之身份,本来的相差,不如前代之甚,所以事实上贵贱之相差亦较微。仅在法律上、风俗上,因要维持家庭间的秩序,不得不略存区别而已。\n《颜氏家训》说:“江左不讳庶孽,丧室之后,多以妾媵终家事。河北鄙于侧室,不预人流,是以必须重娶,至于三四。”这是江左犹沿古代有媵不再娶的旧风,河北就荡然了。但以妾媵终家事,必本有妾媵而后能然。如其无之,自不能不再娶。再娶自不能视之为妾。《唐书·儒学传》说:“郑余庆庙有二妣,疑于袝祭,请于有司。博士韦公肃议曰:古诸侯一娶九女,故庙无二适。自秦以来有再娶,前娶后继皆适也,两袝无嫌。”自秦以来有再娶,即因封建破坏,无复一娶九女及三归等制度之故。韦公肃之议,为前娶后继,皆为适室礼文上的明据。但从礼意上说,同时不能有二嫡的,所以世俗所谓兼祧双娶,为法律所不许(大理院解释,以后娶者为妾)。\n人类的性质,本来是多婚的(男女皆然),虽由社会的势力,加以压迫,终不能改变其本性。所以压迫之力一弛,本性随即呈露。在现社会制度之下,最普遍而易见的,是为通奸与卖淫。通奸,因其为秘密之事,无从统计其多少。然就现社会和历史记载上观察,实可信其极为普遍。卖淫亦然。社会学家说:“凡是法律和习惯限制男女性交之处,即有卖淫之事,随之出现。”史家推原卖淫之始,多以为起于宗教卖淫。王书奴著《中国倡伎会》(生活书店本),亦力主此说。然原始宗教界中淫乱的现象,实未可称为卖淫。因为男女的交际,其初本极自由。后来强横的男子,虽把一部分女子占为己有,然只限于平时。至于众人集会之时,则仍须回复其故态。所以各个民族,往往大集会之时,即为男女混杂之际。如郑国之俗,三月上巳之日,于溱、洧两水之上,招魂续魄,拂除不祥,士女往观而相谑(《韩诗》说,据陈乔枞《三家诗遗说考》)。《史记·滑稽列传》载淳于髡说:“州闾之会,男女杂坐。行酒稽留,六博投壶,相引为曹。握手无罚,目眙不禁。前有堕珥,后有遗簪。”“日暮酒阑,合尊促坐。男女同席,履舄交错,杯盘狼籍。堂上烛灭,主人留髡而送客。罗襦襟解,微闻芗泽。”又如前文所引的燕国“嫁娶之夕,男女无别”都是。宗教上的寺院等,也是大众集会之地;而且是圣地;其地的习惯,是不易破坏的。《汉书·礼乐志》说:汉武帝立乐府,“采诗夜诵”。颜师古《注》说:“其言辞或秘,不可宣露,故于夜中歌诵。”按《后汉书·高句骊传》说:其俗淫。暮夜辄男女群聚为倡乐。高句骊是好祠鬼神的,而乐府之立,亦和祭礼有关。然则采诗夜诵,怕不仅因其言辞或秘罢?男女混杂之事,后世所谓邪教中,亦恒有之,正和邪有何标准?不过古代之俗,渐与后世不合,则被目为邪而已。然则宗教中初期的淫乱,实不可谓之卖淫。不过限制男女交际的自由,往往与私有财产制度,伴随而起。既有私有财产,自有所谓卖买;既有所谓卖买,淫亦自可为卖买的标的。在此情形之下,本非卖买之事,变为卖买的多了,亦不仅淫之一端。\n卖淫的根源,旧说以为起于齐之女闾。其事见于《战国策》的《东周策》。《东周策》载一个辩士的话道:“国必有诽誉。忠臣令诽在己,誉在上。齐桓公宫中七市,女闾七百,国人非之,管仲故为三归之家,以掩桓公非,自伤于民也。”则市与女闾,确为淫乐之地。《商君书·垦令篇》说:“令军市无有女子”;又说:“轻惰之民,不游军市,则农民不淫”,亦市为淫乐之地之一证。女闾则他处无文。按《太平御览》引《吴越春秋》说:“勾践输有过寡妇于山上,使士之忧思者游之,以娱其意”(今本无),亦即女闾之类。女闾,盖后世所谓女户者所聚居。女户以女为户主,可见其家中是没有壮男的。《周官》内宰:“凡建国,佐后立市”;《左传》昭公二十年,晏婴说:“内宠之妾,肆夺于市”,则古代的市,本由女子管理。所以到后来,聚居市中的女子还很多。市和女闾,都不过因其为女子聚居之所,遂成为纵淫之地罢了。其初,也未必是卖淫的。\n卖淫的又一来源,是为女乐。女乐是贵族家里的婢妾,擅长歌舞等事的,令其“执技以事上”。婢妾的贞操,本来是没有保障的,自不因其为音乐队员而有异。封建制度破坏,贵族的特权,为平民所僭者甚多,自将流布于民间。《史记·货殖列传》说:赵国的女子,“鼓鸣瑟,跕屣(现在的拖鞋,在古时为舞屣),游媚贵富,入后宫,遍诸侯。”“郑、卫俗与赵相类。”又说:“今夫赵女郑姬,设形容,揳鸣琴,揄长袂,蹑利屣,目挑心招,出不远千里,不择老少者,奔富厚也。”即其事。倡伎本来是对有技艺的人的称谓,并非专指女子。所以女子有此等技艺的,还特称为女伎。然其实是性的诱惑的成分多,欣赏其技艺的成分少。于是倡伎转变为女子卖淫者的称谓,其字也改从女旁了(即娼妓。男子之有技艺者,不复称倡伎)。为倡伎之女子,本系婢妾之流,故自古即可卖买。《战国·韩策》说:“韩卖美人,秦买之三千金”其证。后世当娼妓的,也都是经济上落伍的人,自然始终是可以买卖的了。资本的势力愈盛,遂并有买得女子,使操淫业以谋利的。古代的女伎,系婢妾所为,后世政治上还沿袭其遗制,是为乐户。系以罪人家属没入者为之。唐时,其籍属于太常。其额设的乐员,属于教坊司。此系国家的女乐队员,但因其本为贱族,贞操亦无保障,官员等皆可使之执技荐寝以自娱,是为官妓。军营中有时亦有随营的女子,则谓之营妓。民间女子卖淫的,谓之私娼。在本地的称土娼,在异乡的称流娼。清世祖顺治十六年,停止教坊女乐,改用内监。世宗雍正七年,改教坊司为和声署。是时各地方的乐户,亦皆除籍为民。于是在法律上除去一种贱族,亦无所谓官妓。但私娼在当时则是无从禁止的。律例虽有“举贡生员,宿娼者斥革”的条文,亦不过为管束举、贡、生员起见而已,并非禁娼。\n古代掠夺婚姻的习惯,仍有存于后世的。赵翼《陔余丛考》说:“村俗有以婚姻议财不谐,而纠众劫女成婚者,谓之抢亲。《北史·高昂传》:昂兄乾,求博陵崔圣念女为婚,崔不许。昂与兄往劫之。置女村外,谓兄曰:何不行礼?于是野合而归。是劫婚之事,古亦有之。然今俗劫婚,皆已经许字者,昂所劫则未字,固不同也。”按《清律》:“凡豪势之人,强夺良家妻女,奸占为妻妾者绞。配与子孙、弟侄、家人者,罪亦如之。”此指无婚姻契约而强抢的。又说:“应为婚者,虽已纳聘财,期未至,而男家强娶者,笞五十。”(指主婚人)“女家悔盟,男家不告官司强抢者,照强娶律减二等。”此即赵氏所谓已经许字之女,照法律亦有罪,但为习俗所囿,法律多不能实行。又有男女两家,因不能负担结婚时的费用,私相协议,令男家以强抢的形式出之的。则其表面为武力的,内容实为经济的了。抢孀等事,亦自古即有。《潜夫论·断讼篇》云:“贞洁寡妇,遭直不仁世叔、无义兄弟,或利其聘币,或贪其财贿,或私其儿子,则迫胁遣送,有自缢房中,饮药车上,绝命丧躯,孤捐童孩者。”又有“后夫多设人客,威力胁载者”。这其中,亦含有武力的经济的两种成分。\n卖买婚姻,则无其名而有其实。《断讼篇》又说:“诸女一许数家,虽生十子,更百赦,勿令得蒙一,还私家,则此奸绝矣。不则髡其夫妻,徙千里外剧县,乃可以毒其心而绝其后。”《抱朴子·弭讼篇》,述其姑子刘士由之论说:“末世举不修义,许而弗与。讼阋秽缛,烦塞官曹。今可使诸争婚者,未及同牢,皆听义绝,而倍还酒礼,归其币帛。其尝已再离,一倍裨聘(裨即现在赔偿的赔字)。其三绝者,再倍裨聘。如此,离者不生讼心,贪者无利重受。”葛洪又申说自己的意见道:“责裨聘倍,贫者所惮,丰于财者,则适其愿矣。后所许者,或能富殖,助其裨聘,必所甘心。然则先家拱默,不得有言,原情论之,能无怨叹乎?”葛洪之意,要令“女氏受聘,礼无丰约(谓不论聘财多少),皆以即日报版。又使时人署姓名于别版,必十人以上,以备远行及死亡。又令女之父兄若伯叔,答婿家书,必手书一纸。若有变悔而证据明者,女氏父母兄弟,皆加刑罚罪”。可见汉晋之世卖买婚姻之盛。后世契约效力较强,此等事无人敢做,但嫁女计较聘礼,娶妻觊觎妆奁,其内容还是一样的,此非经济制度改变,无法可以改良了。\n后世的婚姻,多全由父母做主,本人概不与闻,甚至有指腹为婚等恶习(见《南史·韦放传》。按《清律》,指腹为婚有禁),这诚然是很坏的。然论者遂以夫妇之道苦,概归咎于婚姻的不自由,则亦未必其然。人之性,本是多婚的,男女皆然,所以爱情很难持之永久。即使结婚之时,纯出两情爱慕,绝无别种作用,搀杂其间,尚难保其永久,何况现在的婚姻,有别种作用搀杂的,且居多数呢?欲救夫妇道苦之弊,与其审慎于结婚之时,不如宽大于离婚之际,因为爱情本有变动,结婚时无论如何审慎,也控制不住后来的变化的。习俗所以重视离婚,法律也尽力禁阻,不过是要维持家庭。然家庭制度,实不是怎么值得维持的东西,参看下章可明。\n统观两性关系,自氏族时代以后,即已渐失其正常。其理由:因女子在产育上,所负的责任,较男子为多。因而其斗争的力量,较男子为弱。不论在人类凭恃武力相斗争,或凭恃财力相斗争的时代,女子均渐沦于被保护的地位,失其独立,而附属于男子。社会的组织,宜于宽平坦荡,个个人与总体直接。若多设等级,使这一部分人,隶属于那一部分人,那不公平的制度就要逐渐发生,积久而其弊愈深了。近代女权的渐渐伸张,实因工业革命以来,女子渐加入社会的机构,非如昔日蛰居家庭之中,专做辅助男子的事情之故。女子在产育上多尽了责任,男子就该在别一方面多尽些义务,这是公道。乘此机会压迫女子,多占权利,是很不正当的。而欲实行公道,则必自铲除等级始。所以有人说:社群制度是女子之友,家庭制度是女子之敌。然则“女子回到家庭去”这口号,当然只有开倒车的人,才会去高呼了。人家都说现在的女学生坏了,不如从前旧式的女子,因其对于家政生疏了,且不耐烦。殊不知这正是现代女子进步之征兆。因为对于家政生疏,对于参与社会的工作,却熟练了。这正是小的、自私的、自利的组织,将逐渐破坏;大的、公平的、博爱的制度,将逐渐形成的征兆。贤母良妻,只是贤奴良隶。此等教育,亦只好落伍的国家去提倡。我们该教一切男女以天下为公的志愿,广大无边的组织。\n第三十八章 族制 # 人是非团结不能生存的。当用何法团结呢?过去的事情,已非我们所能尽知;将来的事情,又非我们所能预料。我们现在只能就我们所知道的,略加说述而已。\n在有史时期,血缘是人类团结的一个重要因素。人恒狃于其所见闻,遂以此为人类团结唯一的因素,在过去都是如此,在将来也非如此不可了。其实人类的团结,并非是专恃血缘的。极远之事且勿论,即上章所说的以年龄分阶层之世,亦大率是分为老、壮、幼三辈(间有分为四辈的,但以分做三辈为最普通。《礼记·礼运》说:“使老有所终,壮有所用,幼有所长”;《论语·雍也篇》说:“老者安之,朋友信之,少者怀之”,亦都是分为三辈),而不再问其人与人间的关系的。当此之时,哪有所谓夫妇、父子、兄弟之伦呢?《礼记·礼运》说:大同之世,“人不独亲其亲,不独子其子”,《左传》载富辰的话,也说“大上以德抚民,其次亲亲,以相及也”(僖公二十四年)。可见亲族关系,是后起的情形了。\n人类愈进步,则其分化愈甚,而其组织的方法亦愈多。于是有所谓血族团体。血族团体,其初必以女子为中心。因为夫妇之伦未立,父不可知;即使可知,而父子的关系,亦不如母子之密之故。如上章所述,人类实在是社群动物,而非家庭动物,所以其聚居,并不限于两代。母及同母之人以外,又有母的母,母的同母等。自己而下推,亦是如此。逐渐成为母系氏族。每一个母系氏族,都有一个名称,是即所谓姓。一姓总有一个始祖母的,如殷之简狄,周之姜嫄即是。简狄之子契,姜嫄之子稷,都是无父而生的。因为在传说中,此等始祖母,本来无夫之故。记载上又说她俩都是帝喾之妃,一定是后来附会的(契、稷皆无父而生,见《诗·玄鸟》、《生民》。《史记·殷周本纪》所载,即是《诗》说。据陈乔枞《三家诗遗说考》所考证,太史公是用鲁诗说的。姜嫄、简狄,皆帝喾之妃,见《大戴礼记·帝系篇》。《史记·五帝本纪》,亦用其说)。\n女系氏族的权力,亦有时在男子手中(参看下章),此即所谓舅权制。此等权力,大抵兄弟相传,而不父子相继。因为兄弟是同氏族人,父子则异氏族之故。我国商朝和春秋时的鲁国、吴国,都有兄弟相及的遗迹(鲁自庄公以前,都一代传子,一代传弟,见《史记·鲁世家》),这是由于东南一带,母系氏族消灭较晚之故,已见上章。\n由于生业的转变,财产和权力,都转入男子手中,婚姻非复男子入居女子的氏族,而为女子入居男子的氏族(见上章)。于是组织亦以男为主,而母系氏族,遂变为父系氏族。商周自契稷以后,即奉契、稷为始祖,便是这种转变的一件史实。\n族之组织,是根据于血缘的。血缘之制既兴,人类自将据亲等的远近,以别亲疏。一姓的人口渐繁,又行外婚之制,则同姓的人,血缘不必亲,异姓的人,血缘或转相接近。所谓族与姓,遂不得不分化为两种组织。族制,我们所知道的,是周代的九族:一、父姓五服以内。二、姑母和他的儿子。三、姊妹和他的儿子。四、女儿和他的儿子。是为父族四;五、母的父姓,即现在所谓外家。六、母的母姓,即母亲的外家。七、母的姊妹和她们的儿子。是为母族三;八、妻之父姓。九、妻之母姓。是为妻族二。这是汉代今文家之说,见于《五经异义》(《诗·葛藟疏》引),《白虎通·宗族篇》同。古文家说,以上自高祖,下至玄孙为九族,此乃秦汉时制,其事较晚,不如今文家所说之古了。然《白虎通义》又载或说,谓尧时父母妻之族各三,周贬妻族以附父族,则今文家所说,亦已非极古之制。《白虎通义》此段,文有脱误,尧时之九族,无从知其详。然观下文引《诗》“邢侯之姨”,则其中该有妻之姊妹。总而言之:族制是随时改变的,然总是血缘上相近的人,和后世称父之同姓为族人,混同姓与同族为一不同,则是周以前所同的。九族中人,都是有服的。其无服的,则谓之党(《礼记·奔丧》郑《注》),是为父党,母党,妻党。\n同姓的人,因人口众多,血缘渐见疏远,其团结,是否因此就松懈了呢?不。所谓九族者,除父姓外,血缘上虽然亲近,却不是同居的。同姓则虽疏远而仍同居,所以生活共同,利害亦共同。在同居之时,固有其紧密的组织;即到人口多了,不能不分居,而彼此之间,仍有一定的联结,此即所谓宗法。宗法和古代的社会组织,有极大的关系。今略述其制如下:\n(一)凡同宗的人,都同奉一个始祖(均系此始祖之后)。\n(二)始祖的嫡长子,为大宗宗子。自此以后,嫡长子代代承袭,为大宗宗子。凡始祖的后人,都要尊奉他,受他的治理。穷困的却亦可以受他的救济。大宗宗子和族人的关系,是不论亲疏远近,永远如此的,是谓大宗“百世不迁”。\n(三)始祖之众子(嫡长子以外之子),皆别为小宗宗子。其嫡长子为继祢小宗。继祢小宗的嫡长子为继祖小宗。继祖小宗的嫡长子为继曾祖小宗。继曾祖小宗的嫡长子为继高祖小宗。继祢小宗,亲兄弟宗事他(受他治理,亦受他救济)。继祖小宗,从兄弟宗事他。继曾祖小宗,再从兄弟宗事他。继高祖小宗,三从兄弟宗事他。至四从兄弟,则与继六世祖之小宗宗子,亲尽无服,不再宗事他。是为小宗“五世则迁”(以一人之身论,当宗事与我同高、曾、祖、考四代的小宗宗子及大宗宗子。故曰:“小宗四,与大宗凡五。”)\n(四)如此,则或有无宗可归的人。但大宗宗子,还是要管理他,救济他的。而同出于一始祖之人,设或殇与无后,大宗的宗子,亦都得祭祀他。所以有一大宗宗子,则活人的治理、救济,死人的祭祀问题,都解决了。所以小宗可绝,大宗不可绝。大宗宗子无后,族人都当绝后以后大宗。\n以上是周代宗法的大略,见于《礼记·大传》的。《大传》所说大宗的始祖,是国君的众子。因为古者诸侯不敢祖天子,大夫不敢祖诸侯(《礼记·郊特牲》谓不敢立其庙而祭之。其实大宗的始祖,非大宗宗子,亦不敢祭。所以诸侯和天子,大夫和诸侯,大宗宗子和小宗宗子,小宗宗子和非宗子,其关系是一样的),所以国君的众子,要别立一宗。郑《注》又推而广之,及于始适异国的大夫。据此,宗法之立,实缘同出一祖的人太多了,一个承袭始祖的地位的人,管理有所不及,乃不得不随其支派,立此节级的组织,以便管理。迁居异地的人,旧时的族长,事实上无从管理他。此等组织,自然更为必要了。观此,即知宗法与封建,大有关系。因为封建是要将本族的人,分一部分出去的。有宗法的组织,则封之者和所封者之间,就可保持着一种联结了。然则宗法确能把同姓中亲尽情疏的人,联结在一起。他在九族之中,虽只联结得父姓一族。然在父姓之中,所联结者,却远较九族之制为广。怕合九族的总数,还不足以敌他。而且都是同居的人,又有严密的组织。母系氏族中,不知是否有与此相类的制度。即使有之,其功用,怕亦不如父系氏族的显著。因为氏族从母系转变到父系,本是和斗争有关系的。父系氏族而有此广大严密的组织,自然更能发挥其斗争的力量。我们所知,宗法之制,以周代为最完备,周这个氏族,在斗争上,是得到胜利的。宗法的组织,或者也是其中的一个原因。\n有族制以团结血缘相近的人,又有宗法以团结同出一祖的人,人类因血族而来的团结,可谓臻于极盛了。然而当其极盛之时,即其将衰之候。这是什么原因呢?社会组织的变化,经济实为其中最重要的原因。当进化尚浅之时,人类的互助,几于有合作而无分工。其后虽有分工,亦不甚繁复。大家所做的事,既然大致相同,又何必把过多的人联结在一起?所以人类联结的广大,是随着分工的精密而进展的。分工既密之后,自能将毫不相干的人,联结在一起。此等互相倚赖的人,虽然彼此未必相知,然总必直接间接,互相接触。接触既繁,前此因不相了解而互相猜忌的感情,就因之消除了。所以商业的兴起,实能消除异部族间敌对的感情。分工使个性显著。有特殊才能的人,容易发挥其所长,获得致富的机会。氏族中有私财的人逐渐多,卖买婚即于此时成立。说见上章。于是父权家庭成立了。孟子说:当时农夫之家,是五口和八口。说者以为一夫上父母下妻子;农民有弟,则为余夫,要另行授田(《梁惠王》及《滕文公上篇》),可见其家庭,已和现在普通的家庭一样了。士大夫之家,《仪礼·丧服传》说大功同财,似乎比农民的家庭要大些。然又说当时兄弟之间的情形道:“有东宫,有西宫,有南宫,有北宫,异居而同财。有余则归之宗,不足则资之宗。”则业已各住一所屋子,各有各的财产,不过几房之中,还保有一笔公款而已。其联结,实在是很薄弱的,和农夫的家庭,也相去无几了。在当时,只有有广大封土的人,其家庭要大些。这因为(一)他的原始,是以一氏族征服异氏族,而食其租税以自养的,所以宜于聚族而居,常作战斗的戒备。只要看《礼记》的《文王世子》,就知道古代所谓公族者,是怎样一个组织了。后来时异势殊,这种组织,实已无存在的必要。然既已习为故常,就难于猝然改革。这是一切制度,都有这惰性的。(二)其收入既多,生活日趋淫侈,家庭中管事服役的奴仆,以及技术人员,非常众多,其家庭遂特别大。这只要看《周官》的《天官》,就可以知道其情形。然此等家庭,随着封建的消灭,而亦渐趋消灭了。虽不乏新兴阶级的富豪,其自奉养,亦与素封之家无异,但毕竟是少数。于是氏族崩溃,家庭代之而兴。家庭的组织,是经济上的一个单位,所以是尽相生相养之道的。相生相养之道,是老者需人奉养,幼者需人抚育。这些事,自氏族崩溃后,既已无人负责,而专为中间一辈所谓一夫一妇者的责任。自然家庭的组织,不能不以一夫上父母下妻子为范围了。几千年以来,社会的生活情形,未曾大变,所以此种组织,迄亦未曾改变。\n看以上所述,可见族制的变迁,实以生活为其背景;而生活的变迁,则以经济为其最重要的原因。因为经济是最广泛,和社会上个个人,都有关系;而且其关系,是永远持续,无时间断的。自然对于人的影响,异常深刻,各种上层组织,都不得不随其变迁而变迁;而精神现象,亦受其左右而不自知了。在氏族时代,分工未密,一个氏族,在经济上,就是一个自给自足的团体。生活既互相倚赖,感情自然容易密切。不但对于同时的人如此,即对于以往的人亦然。因为我所赖以生存的团体,是由前人留遗下来的。一切知识技术等,亦自前辈递传给后辈。这时候的人,其生活,实与时间上已经过去的人关系深,而与空间上并时存在的人关系浅。尊祖、崇古等观念,自会油然而生。此等观念,实在是生活情形所造成的。后人不知此理,以为这是伦理道德上的当然,而要据之以制定人的生活,那就和社会进化的趋势,背道而驰了。大家族、小家庭等字样,现在的人用来,意义颇为混淆。西洋人学术上的用语,称一夫一妇,包括未婚子女的为小家庭;超过于此的为大家庭。中国社会,(一)小家庭和(二)一夫上父母下妻子的家庭,同样普遍。(三)兄弟同居的,亦自不乏。(四)至于五世同居,九世同居,宗族百口等,则为罕有的现象了。赵翼《陔余丛考》,尝统计此等极大的家庭(第四种),见于正史孝义、孝友传的:《南史》三人,《北史》十二人,《唐书》三十八人,《五代史》二人,《宋史》五十人,《元史》五人,《明史》二十六人。自然有(一)不在孝义、孝友传,而散见于他篇的;(二)又有正史不载,而见于他书的;(三)或竟未见记载的。然以中国之大,历史上时间之长,此等极大的家庭,总之是极少数,则理有可信。此等虽或由于伦理道德的提倡(顾炎武《华阴王氏宗祠记》:“程朱诸子,卓然有见于遗经。金元之代,有志者多求其说于南方,以授学者。及乎有明之初,风俗淳厚,而爱亲敬长之道,达诸天下。其能以宗法训其家人,或累世同居,称为义门者,往往而有。”可见同居之盛,由于理学家的提倡者不少),恐仍以别有原因者居多(《日知录》:“杜氏《通典》言:北齐之代,瀛、冀诸刘,清河张、宋,并州王氏,濮阳侯族,诸如此辈,将近万室。《北史·薛胤传》:为河北太守,有韩马两姓,各二千余家。今日中原北方,虽号甲族,无有至千丁者。户口之寡,族姓之衰,与江南相去敻绝。”陈宏谋《与杨朴园书》:“今直省惟闽中、江西、湖南,皆聚族而居,族各有祠。”则聚居之风,古代北盛于南,近世南盛于北,似由北齐之世,丧乱频仍,民皆合族以自卫;而南方山岭崎岖之地进化较迟,土著者既与合族而居之时,相去未远;流移者亦须合族而居,互相保卫之故)。似可认为古代氏族的遗迹,或后世家族的变态。然氏族所以崩溃,正由家族潜滋暗长于其中。此等所谓义门,纵或有古代之遗,亦必衰颓已甚。况又有因环境的特别,而把分立的家庭,硬行联结起来的。形式是而精神非,其不能持久,自然无待于言了。《后汉书·樊宏传》,说他先代三世共财,有田三百余顷。自己的田地里,就有陂渠,可以互相灌注。又有池鱼,牧畜,有求必给。“营理产业,物无所弃(这是因其生产的种类较多之故)。课役童隶,各得其宜。”(分工之法)要造器物,则先种梓漆。简直是一个大规模的生产自给自足的团体。历代类乎氏族的大家族,多有此意。此岂不问环境所可强为?然社会的广大,到底非此等大家族所能与之相敌,所以愈到后世,愈到开化的地方,其数愈少。这是类乎氏族的大家族,所以崩溃的真原因,毕竟还在经济上。但在政治上,亦自有其原因。因为所谓氏族,不但尽相生相养之责,亦有治理其族众之权。在国家兴起以后,此项权力,实与国权相冲突。所以国家在伦理上,对于此等大家族,虽或加以褒扬,而在政治上,又不得不加以摧折。所谓强宗巨家,遂多因国家的干涉,而益趋于崩溃了。略大于小家庭的家庭(第二、第三种)表面上似为伦理道德的见解所维持(历代屡有禁民父母在别籍异财等诏令,可参看《日知录》卷十三《分居》条),实则亦为经济状况所限制。因为在经济上,合则力强,分则力弱,以昔时的生活程度论,一夫一妇,在生产和消费方面,实多不能自立的。儒者以此等家庭之多,夸奖某地方风俗之厚,或且自诩其教化之功,就大谬不然了。然经济上虽有此需要,而私产制度,业已深入人心,父子兄弟之间,亦不能无分彼此。于是一方面牵于旧见解,迫于经济情形,不能不合;另一方面,则受私有财产风气的影响,而要求分;暗斗明争,家庭遂成为苦海。试看旧时伦理道德上的教训,戒人好货财、私妻子。而薄父母兄弟之说之多,便知此项家庭制度之岌岌可危。制度果然自己站得住,何须如此扶持呢?所以到近代,除极迂腐的人外,亦都不主张维持大家庭。如李绂有《别籍异财议》,即其一证。至西洋文化输入,论者更其提倡小家庭,而排斥大家庭了。然小家庭又是值得提倡的么?\n不论何等组织,总得和实际的生活相应,才能持久。小家庭制度,是否和现代人的生活相应呢?历来有句俗话,叫做“养儿防老,积谷防饥”。可见所谓家庭,实以扶养老者、抚育儿童,为其天职。然在今日,此等责任,不但苦于知识之不足(如看护病人,抚养教育儿童,均须专门知识),实亦为其力量所不及(兼日力财力言之。如一主妇不易看顾多数儿童,兼操家政。又如医药、教育的费用,不易负担)。在古代,劳力重于资本,丁多即可致富,而在今日,则适成为穷困的原因。因为生产的机键,自家庭而移于社会了,多丁不能增加生产,反要增加消费(如纺织事业)。儿童的教育,年限加长了,不但不能如从前,稍长大即为家庭挣钱,反须支出教育费。而一切家务,合之则省力,分之则多费的(如烹调、浣濯)。又因家庭范围太小,而浪费物质及劳力。男子终岁劳动,所入尚不足以赡其家。女子忙得和奴隶一般,家事还不能措置得妥帖。于是独身、晚婚等现象,相继发生。这些都是舶来品,和中国旧俗,大相径庭,然不久,其思想即已普遍于中流社会了。凡事切于生活的,总是容易风行的,从今以后,穷乡僻壤的儿女,也未必死心塌地,甘做家庭的奴隶了。固然,个人是很难打破旧制度,自定办法的。而性欲出于天然,自能把许多可怜的儿女,牵入此陈旧组织之中。然亦不过使老者不得其养,幼者不遂其长,而仍以生子不举等人为淘汰之法为救济罢了。这种现象,固已持续数千年,然在今日,业经觉悟之后,又何能坐视其如此呢?况且家庭的成立,本是以妇女的奴役为其原因的。在今日个人主义抬头,人格要受尊重的时代,妇女又何能长此被压制呢?资本主义的学者,每说动物有雌雄两性,共同鞠育其幼儿,而其同居期限,亦因以延长的,以为家庭的组织,实根于人类的天性,而无可改变。姑无论其所说动物界的情形,并不确实。即使退一步,承认其确实,而人是人,动物是动物;人虽然亦是动物之一,到底是动物中的人;人类的现象,安能以动物界的现象为限?他姑弗论,动物雌雄协力求食,即足以哺育其幼儿,人,为什么有夫妇协力,尚不能养活其子女的呢?或种动物,爱情限于家庭,而人类的爱情,超出于此以外,这正是人之所以为人,人之所以异于动物。论者不知人之爱家,乃因社会先有家庭的组织,使人之爱,以此形式而出现,正犹水之因方而为圭,遇圆而成璧;而反以为人类先有爱家之心,然后造成家庭制度;若将家庭破坏,便要“疾病不养;老幼孤独,不得其所”(《礼记·乐记》:“强者胁弱,众者暴寡;知者诈愚,勇者苦怯;疾病不养,老幼孤独,不得其所,此大乱之道也”),这真是倒果为因。殊不知家庭之制,把人分为五口八口的小团体,明明是互相倚赖的,偏使之此疆彼界,处于半敌对的地位,这正是疾病之所以不养,老幼孤独之所以不得其所。无后是中国人所引为大戚的,论者每说,这是拘于“不孝有三,无后为大”之义(《孟子·离娄上篇》)。而其以无后为不孝,则是迷信“鬼犹求食”(见《左传》宣公四年),深虑祭祀之绝。殊不知此乃古人的迷信,今人谁还迷信鬼犹求食来?其所以深虑无后,不过不愿其家之绝;所以不愿其家之绝,则由于人总有尽力经营的一件事,不忍坐视其灭亡,而家是中国人所尽力经营的,所以如此。家族之制,固然使人各分畛域,造成互相敌对的情形,然此自制度之咎,以爱家者之心论:则不但(一)夫妇、父子、兄弟之间,互尽扶养之责。(二)且推及于凡与家族有关系的人(如宗族姻亲等)。(三)并且悬念已死的祖宗。(四)以及未来不知谁何的子孙。前人传给我的基业,我必不肯毁坏,必要保持之,光大之,以传给后人,这正是极端利他心的表现。利他心是无一定形式的,在何种制度之下,即表现为何种形式。然而我们为什么要拘制着他,一定只许他在这种制度中表现呢?\n以上论族制的变迁,大略已具。现再略论继承之法。一个团体,总有一个领袖。在血缘团体之内,所谓父或母,自然很容易处于领袖地位的。父母死后,亦当然有一个继承其地位的人。女系氏族,在中国历史上,可考的有两种继承之法:(一)是以女子承袭财产,掌管祭祀。前章所述齐国的巫儿,即其遗迹。这大约是平时的族长。(二)至于战时及带有政治性质的领袖,则大约由男子尸其责,而由弟兄相及。殷代继承之法,是其遗迹。男系氏族,则由父子相继。其法又有多端:(一)如《左传》文公元年所说:“楚国之举,恒在少者。”这大约因幼子恒与父母同居,所以承袭其遗产(蒙古人之遗产,即归幼子承袭。其幼子称斡赤斤,译言守灶)。(二)至于承袭其父之威权地位,则自以长子为宜,而事实上亦以长子为易。(三)又古代妻妾,在社会上之地位亦大异。妻多出于贵族,妾则出于贱族,或竟是无母家的。古重婚姻,强大的外家及妻家,对于个人,是强有力的外援(如郑庄公的大子忽,不婚于齐,后来以无外援失位);对于部族,亦是一个强有力的与国,所以立子又以嫡为宜。周人即系如此。以嫡为第一条件,长为第二条件。后来周代的文化,普行于全国,此项继承之法,遂为法律和习惯所共认了。然这只是承袭家长的地位,至于财产,则总是众子均分的(《清律》:分析家财、田产,不问妻、妾、婢生,但以子数均分。奸生之子,依子量与半分。无子立继者,与私生子均分)。所以中国的财产,不因遗产承袭,而生不均的问题。这是众子袭产,优于一子袭产之点。\n无后是人所不能免的,于是发生立后的问题。宗法盛行之世,有一大宗宗子,即生者的扶养,死者的祭祀,都可以不成问题,所以立后问题,容易解决。宗法既废,势非人人有后不可,就难了。在此情形之下,解决之法有三:(一)以女为后。(二)任立一人为后,不问其为同异姓。(三)在同姓中择立一人为后。(一)于情理最近,但宗祧继承,非徒承袭财产,亦兼掌管祭祀。以女为后,是和习惯相反的(春秋时,郑国以外孙为后,其外孙是莒国的儿子,《春秋》遂书“莒人灭郑”,见《公羊》襄公五、六年。按此实在是论国君承袭的,乃公法上的关系,然后世把经义普遍推行之于各方面,亦不管其为公法私法了)。既和习惯相反,则觊觎财产的人,势必群起而攻,官厅格于习俗,势必不能切实保护。本欲保其家的,或反因此而发生纠纷,所以势不能行。(二)即所谓养子,与家族主义的重视血统,而欲保其纯洁的趋势不合。于是只剩得第(三)的一途。法律欲维持传统观念,禁立异姓为后,在同姓中并禁乱昭穆之序(谓必辈行相当,如不得以弟为子等。其实此为古人所不禁,所谓“为人后者为之子”,见《公羊》成公十五年)。于是欲人人有后益难,清高宗时,乃立兼祧之法,以济其穷(一人可承数房之祀。生子多者,仍依次序,分承各房之后。依律例:大宗子兼祧小宗,小宗子兼祧大宗,皆以大宗为重,为大宗父母服三年,为小宗父母服期。小宗子兼祧小宗,以本生为重,为本生父母服三年,为兼祧父母服期。此所谓大宗,指长房,所谓小宗,指次房以下,与古所谓大宗小宗者异义。世俗有为本生父母及所兼祧之父母均服三年的,与律例不合)。宗祧继承之法,进化至此,可谓无遗憾了。然其间却有一难题。私有财产之世,法律理应保护个人的产权。他要给谁就给谁,要不给谁就不给谁。为后之子,既兼有承袭财产之权利,而法律上替他规定了种种条件,就不啻干涉其财产的传授了。于是传统的伦理观念,和私有财产制度,发生了冲突。到底传统的伦理观念是个陈旧不切实际的东西,表面上虽然像煞有介事,很有威权,实际上已和现代人的观念不合了。私有财产制度,乃现社会的秩序的根柢,谁能加以摇动?于是冲突之下,伦理观念,乃不得不败北而让步。法律上乃不得不承认所谓立爱,而且多方保护其产权(《清律例》:继子不得于所后之亲,听其告官别立。其或择立贤能,及所亲爱者,不许宗族以次序告争,并官司受理)。至于养子,法律虽禁其为嗣(实际上仍有之),亦不得不听其存在,且不得不听其酌给财产(亦见《清律例》)。因为国家到底是全国人民的国家,在可能范围内,必须兼顾全国人民各方面的要求,不能专代表家族的排外自私之念。在现制度之下,既不能无流离失所之人;家族主义者流,既勇于争袭遗产,而怯于收养同宗;有异姓的人肯收养他,国家其势说不出要禁止。不但说不出要禁止,在代表人道主义和维持治安的立场上说,无宁还是国家所希望的。既承认养子的存在,在事实上,自不得不听其酌给遗产了。这也是偏私的家族观念,对于公平的人道主义的让步,也可说是伦理观念的进步。\n假使宗祧继承的意思,而真是专于宗祧继承,则拥护同姓之男,排斥亲生之女,倒也还使人心服。因为立嗣之意,无非欲保其家,而家族的存在,是带着几分斗争性质的。在现制度之下,使男子从事于斗争,确较女子为适宜(这并非从个人的身心能力上言,乃是从社会关系上言),这也是事实。无如世俗争继的,口在宗祧,心存财产,都是前人所谓“其言蔼如,其心不可问”的。如此而霸占无子者的财产,排斥其亲生女,就未免使人不服了。所以有国民政府以来,废止宗祧继承,男女均分遗产的立法。这件事于理固当,而在短时间内,能否推行尽利,却是问题。旧律,遗产本是无男归女,无女入官的(近人笔记云:“宋初新定《刑统》,户绝资产下引《丧葬令》;诸身丧户绝者,所有部曲、客女、奴婢、店宅、资财,并令近亲转易货卖,将营葬事,及量营功德之外,余财并与女。无女均入以次近亲。无亲戚者,官为检校。若亡人在日,自有遗嘱处分,证验分明者,不用此令。此《丧葬令》乃《唐令》,知唐时所谓户绝,不必无近亲。虽有近亲,为营丧葬,不必立近亲为嗣子,而远亲不能争嗣,更无论矣。虽有近亲,为之处分,所余财产,仍传之亲女,而远亲不能争产,更无论矣。此盖先世相传之法,不始于唐。”按部曲、客女,见第四十章)。入官非人情所愿,强力推行,必多流弊,或至窒碍难行(如隐匿遗产,或近亲不易查明,以致事悬不决,其间更生他弊等)。归之亲女,最协人情。然从前的立嗣,除祭祀外,尚有一年老奉养的问题。而家族主义,是自私的。男系家族,尤其以男子为本位,而蔑视女子的人格。女子出嫁之后,更欲奉养其父母,势实有所为难。所以旧时论立嗣问题的人,都说最好是听其择立一人为嗣,主其奉养、丧葬、祭祀,而承袭其遗产。这不啻以本人的遗产,换得一个垂老的扶养,和死后的丧葬祭祀。今欲破除迷信,祭祀固无问题,对于奉养及丧葬,似亦不可无善法解决。不有遗产以为交易,在私有制度之下,谁肯顾及他人的生养死葬呢?所以有子者遗产男女均分,倒无问题,无子者财产全归于女,倒是有问题的。所以变法贵全变,革命要彻底。枝枝节节而为之,总只是头痛医头,脚痛医脚的对症疗法。\n姓氏的变迁,今亦须更一陈论。姓的起源,是氏族的称号,由女系易而为男系,说已见前。后来姓之外又有所谓氏。什么叫做氏呢?氏是所以表一姓之中的支派的。如后稷之后都姓姬,周公封于周,则以周为氏;其子伯禽封于鲁,则以鲁为氏(国君即以国为氏);鲁桓公的三子,又分为孟孙、叔孙、季孙三氏是。始祖之姓,谓之正姓,氏亦谓之庶姓。正姓是永远不改的,庶姓则随时可改。因为同出于一祖的人太多了,其支分派别,亦不可无专名以表之,而专名沿袭太久,则共此一名的人太多,所以又不得不改(改氏的原因甚多,此只举其要改的根本原理。此外如因避难故而改氏以示别族等,亦是改氏的一种原因)。《后汉书·羌传》说:羌人种姓中,出了一个豪健的人,便要改用他的名字做种姓。如爰剑之后,五世至研,豪健,其子孙改称研种;十三世至烧当,复豪健,其子孙又改称烧当种是。这正和我国古代的改氏,原理相同。假如我们在鲁国,遇见一个人,问他尊姓,他说姓姬。这固然足以表示他和鲁君是一家。然而鲁君一家的人太多了,鲁君未必能个个照顾到,这个人,就未必一定有势力,我们听了,也未必肃然起敬。假若问他贵氏,他说是季孙,我们就知道他是赫赫有名的正卿的一家。正卿的同族,较之国君的同姓,人数要少些,其和正卿的关系,必较密切,我们闻言之下,就觉得炙手可热,不敢轻慢于他了。这是举其一端,其余可以类推(如以技为官,以官为氏,问其氏,即既可知其官,又可知其技)。所以古人的氏,确是有用的。至于正姓,虽不若庶姓的亲切,然婚姻之可通与否,全论正姓的异同。所以也是有用的。顾炎武《原姓篇》说春秋以前,男子称氏,女子称姓(在室冠之以序,如叔隗、季隗之类。出嫁,更冠以其夫之氏族,如宋伯姬、赵姬、卢蒲姜之类。在其所适之族,不必举出自己的氏族来,则亦以其父之氏族冠之,如骊姬、梁嬴之类。又有冠之以谥的,如成风、敬姜之类),这不是男子不论姓,不过举氏则姓可知罢了。女子和社会上无甚关系,所以但称姓而不称其氏,这又可以见得氏的作用。\n贵族的世系,在古代是有史官为之记载的。此即《周官》小史之职。记载天子世系的,谓之帝系;记载诸侯卿大夫世系的,谓之世本。这不过是后来的异名,其初原是一物。又瞽矇之职,“讽诵诗,世奠系”(疑当作奠世系)。《注》引杜子春说:谓瞽矇“主诵诗,并诵世系”。世系而可诵,似乎除统绪之外,还有其性行事迹等。颇疑《大戴礼记》的《帝系姓》,原出于小史所记;《五帝德》则是原出于瞽矇所诵的(自然不是完全的),这是说贵族。至于平民,既无人代他记载,而他自己又不能记载,遂有昧于其所自出的。《礼记·曲礼》谓买妾不知其姓,即由于此。然而后世的士大夫,亦多不知其姓氏之所由来的。这因为谱牒掌于史官,封建政体的崩溃,国破家亡,谱牒散失,自然不能知其姓氏之所由来了。婚姻的可通与否,既不复论古代的姓,新造姓氏之事亦甚少。即有之,亦历久不改。阅一时焉,即不复能表示其切近的关系,而为大多数人之所共,与古之正姓同。姓遂成为无用的长物,不过以其为人人之所有,囿于习惯,不能废除罢了。然各地方的强宗巨家,姓氏之所由来,虽不可知,而其在实际上的势力自在。各地方的人,也还尊奉他。在秦汉之世,习为固然,不受众人的注意。汉末大乱,各地方的强宗巨家,开始播迁,到了一个新地方,还要表明其本系某地方的某姓;而此时的选举制度,又重视门阀。于是又看重家世,而有魏晋以来的谱学了。见第四十章。\n第三十九章 政体 # 社会发达到一定的程度,国家就出现了。在国家出现之前,人类团结的方法,只靠血缘,其时重要的组织,就是氏族,对内的治理,对外的防御,都靠着它。世运渐进,血缘相异的人,接触渐多,人类的组织,遂不复以血统相同为限,聚居一地方的,亦不限于血统相同的人。于是氏族进而为部落。统治者的资格,非复族长而为酋长。其统治亦兼论地域,开国家领土的先河了。\n从氏族变为部落,大概经过这样的情形。在氏族的内部,因职业的分化,家族渐渐兴起。氏族的本身,遂至崩溃。各家族非如其在氏族时代,绝对平等,而有贫富之分。财富即是权力,氏族平和的情形,遂渐渐破坏,贫者和富者之间,发生了矛盾,不得不用权力统治。其在异氏族之间,则战斗甚烈。胜者以败者为俘虏,使服劳役,是为奴隶。其但征收其贡赋的,则为农奴。农奴、奴隶和主人之间,自然有更大的矛盾,需要强力镇压。因此故,益促成征服氏族的本身,发生变化。征服氏族的全体,是为平民。其中掌握事权的若干人,形成贵族。贵族中如有一个最高的首领,即为君主的前身。其初是贵族与平民相去近,平民和农奴、奴隶相去远。其后血统相同的作用渐微,掌握政权与否之关系渐大,则平民与农奴、奴隶相去转近,而其与贵族相去转远(参看下章)。但平民总仍略有参政之权,农奴和奴隶则否。政权的决定,在名义上最后属于一人的,是为君主政体。属于较少数人的,是为贵族政体。属于较多数人的,是为民主政体。这种分类之法,是出于亚里斯多德(Aristotle)的。虽与今日情形不同,然以论古代的政体,则仍觉其适合。\n氏族与部落,在实际上,是不易严密区分的。因为进化到部落时代,其内部,总还保有若干氏族时代的意味。从理论上言,则其团结,由于血统相同(虽实际未必相同,然苟被收容于其团体之内,即亦和血统相同的人,一律看待),而其统治,亦全本于亲族关系的,则为氏族。其不然的,则为部落。因其二者杂糅,不易区别,我们亦可借用《辽史》上的名词,称之为部族(见《营卫志》)。至于古代所谓国家,其意义,全和现在不同。古所谓国,是指诸侯的私产言之。包括(一)其住居之所,(二)及其有收益的土地。大夫之所谓家者亦然(古书上所谓国,多指诸侯的都城言。都城的起源,即为诸侯的住所。诸侯的封域以内,以财产意义言,并非全属诸侯所私有。其一部分,还是要用以分封的。对于此等地方,诸侯仅能收其贡而不能收其税赋。其能直接收其税赋,以为财产上的收入的,亦限于诸侯的采地。《尚书大传》说:“古者诸侯始受封,必有采地。其后子孙虽有罪黜,其采地不黜,使子孙贤者守之世世,以祠其始受封之人,此之谓兴灭国,继绝世”,即指此。采地从财产上论,是应该包括于国字之内的。《礼记·礼运》说:“天子有田以处其子孙,诸侯有国以处其子孙。”乃所谓互言以相备。说天子有田,即见得诸侯亦有田;说诸侯有国,即见得天子亦有国。在此等用法之下,田字的意义,亦包括国,国字的意义,亦包括田。乃古人语法如此)。今之所谓国家,古无此语。必欲求其相近的,则为“社稷”二字或“邦”字。社是土神,稷是谷神,是住居于同一地方的人,所共同崇奉的。故说社稷沦亡,即有整个团体覆灭之意。邦和封是一语。封之义为累土。两个部族交界之处,把土堆高些,以为标识,则谓之封。引申起来,任用何种方法,以表示疆界,都可以谓之封(如掘土为沟,以示疆界,亦可谓之封。故今辽宁省内,有地名沟帮子。帮字即邦字,亦即封字。上海洋泾浜之浜字,亦当作封)。疆界所至之地,即谓之邦。古邦字和国字,意义本各不同。汉高祖名邦,汉人讳邦字,都改作国。于是国字和邦字的意义混淆了。现在古书中有若干国字,本来是当作邦字的。如《诗经》里的“日辟国百里”、“日蹙国百里”便是。封域可以时有赢缩,城郭是不能时时改造的(國与域同从或声,其初当亦系一语,则国亦有界域之意。然久已分化为两语了。古书中用国字域字,十之九,意义是不同的)。\n贵族政体和民主政体,在古书上,亦未尝无相类的制度。然以大体言之,则君权之在中国,极为发达。君主的第一个资格,是从氏族时代的族长,沿袭而来的,所以古书上总说君是民之父母。其二则为政治或军事上之首领。其三则兼为宗教上之首领。所以天子祭天地,诸侯祭社稷等(《礼记·王制》),均有代表其群下而为祭司之权,而《书经》上说:“天降下民,作之君,作之师”(《孟子·梁惠王下篇》引),君主又操有最高的教育之权。\n君主前身,既然是氏族的族长,所以他的继承法,亦即是氏族族长的继承法。已见前章。在母系社会,则为兄终弟及,在父系社会,则为父死子继。当其为氏族族长时,无甚权利可争,而其关系亦小,所以立法并不十分精密。《左传》昭公二十六年,王子朝告诸侯,说周朝的继承法,适庶相同则论年,“年钧以德,德钧则卜”。两个人同年,是很容易的事情,同月,同日,同时则甚难,何至辨不出长幼来,而要用德、卜等漫无标准的条件?可见旧法并不甚密。《公羊》隐公元年何《注》说:“礼:适夫人无子,立右媵。右媵无子,立左媵。左媵无子,立适姪娣。适姪娣无子,立右媵姪娣。右媵姪娣无子,立左媵姪娣。质家亲亲先立娣。文家尊尊先立姪(《春秋》以殷为质家,周为文家),适子有孙而死,质家亲亲先立弟,文家尊尊先立孙。其双生,质家据见立先生,文家据本意立后生。”定得非常严密。这是后人因国君的继承,关系重大而为之补充的,乃系学说而非事实。\n周厉王被逐,宣王未立,周召二公,共和行政,凡十四年。主权不属于一人,和欧洲的贵族政体,最为相像。按《左传》襄公十四年,卫献公出奔,卫人立公孙剽,孙林父、甯殖相之,以听命于诸侯,此虽有君,实权皆在二相,和周召的共和,实际也有些相像。但形式上还是有君的。至于鲁昭公出奔,则鲁国亦并未立君,季氏对于国政,决不能一人专断,和共和之治,相像更甚了。可见贵族政体,古代亦有其端倪,不过未曾发达而成为一种制度。\n至于民主政治,则其遗迹更多了。我们简直可以说:古代是确有这种制度,而后来才破坏掉的。《周官》有大询于众庶之法,乡大夫“各帅其乡之众寡而致于朝”,小司寇“摈以序进而问焉”。其事项:为询国危,询国迁,询立君。按《左传》定公八年,卫侯欲叛晋,朝国人,使王孙贾问焉。哀公元年,吴召陈怀公,怀公亦朝国人而问,此即所谓询国危;盘庚要迁都于殷,人民不肯,盘庚“命众悉造于庭”,反复晓谕。其言,即今《书经》里的《盘庚篇》。周太王要迁居于岐,“属其父老而告之”(《孟子·梁惠王下篇》),此即所谓询国迁;《左传》昭公二十四年,周朝的王子朝和敬王争立,晋侯使士景伯往问。士伯立于乾祭(城门名),而问于介众(介众,大众)。哀公二十六年,越人纳卫侯,卫人亦致众而问。此即所谓询立君。可见《周官》之言,系根据古代政治上的习惯,并非理想之谈。《书经·洪范》:“汝则有大疑,谋及乃心,谋及卿士,谋及庶人,谋及卜筮。汝则从,龟从,筮从,卿士从,庶民从,是之谓大同。身其康强,子孙其逢,吉。汝则从,龟从,筮从,卿士逆,庶民逆,吉。卿士从,龟从,筮从,汝则逆,庶民逆,吉。庶民从,龟从,筮从,汝则逆,卿士逆,吉。汝则从,龟从,筮逆,卿士逆,庶民逆,作内吉,作外凶。龟筮共违于人,用静吉,用作凶。”此以一君主,二卿士,三庶人,四龟,五筮,各占一权,而以其多少数定吉凶,亦必系一种会议之法。并非随意询问。至于随意询问之事,如《孟子》所谓“国人皆曰贤,然后察之,见贤焉,然后用之”,“国人皆曰不可,然后察之,见不可焉,然后去之”,“国人皆曰可杀,然后察之,见可杀焉,然后杀之”(《梁惠王下篇》),以及《管子》所谓啧室之议等(见《桓公问篇》),似乎不过是周谘博采,并无必从的义务。然其初怕亦不然。野蛮部落,内部和同,无甚矛盾,舆论自极忠实。有大事及疑难之事,会议时竟有须全体通过,然后能行,并无所谓多数决的。然则舆论到后来,虽然效力渐薄,竟有如郑人游于乡校,以议执政,而然明欲毁乡校之事(见《左传》襄公三十年)。然在古初,必能影响行政,使当局者不能不从,又理有可信了。原始的制度,总是民主的。到后来,各方面的利害、冲突既深;政治的性质,亦益复杂,才变而由少数人专断。这是普遍的现象,无足怀疑的。有人说:中国自古就是专制,国人的政治能力,实在不及西人,固然抹杀史实。有人举此等民权遗迹以自豪,也是可以不必的。\n以上所述,是各部族内部的情形。至于合全国而观之,则是时正在部族林立之世。从前的史家,率称统一以前为封建时代,此语颇须斟酌。学术上的用语,不该太拘于文字的初诂。封建二字,原不妨扩而充之,兼包列国并立的事实,不必泥定字面,要有一个封他的人。然列国本来并立,和有一个封他的人,二者之间,究应立一区别。我以为昔人所谓封建时代,应再分为(一)部族时代,或称先封建时代;(二)封建时代较妥。所谓封建,应指(甲)慑服异部族,使其表示服从;(乙)打破异部族,改立自己的人为酋长;(丙)使本部族移殖于外言之。\n中国以统一之早,闻于世界。然秦始皇的灭六国,事在民国纪元前2132年,自此上溯至有史之初,似尚不止此数,若更加以先史时期,则自秦至今的年代,几乎微末不足道了。所以历史上像中国这样的大国,实在是到很晚的时期才出现的。\n从部族时代,进而至于封建时代,是从无关系进到有关系,这是统一的第一步。更进而开拓荒地,互相兼并,这是统一的第二步。这其间的进展,全是文化上的关系。因为必先(一)国力充实,然后可以征服他国。(二)亦必先开拓疆土,人口渐多,经济渐有进步,国力方能充实。(三)又必开拓渐广,各国间壤地相接,然后有剧烈的斗争。(四)而交通便利,风俗渐次相同,便于统治等,尤为统一必要的条件。所以从分立而至于统一,全是一个文化上的进展。向来读史的人,都只注意于政治方面,实在是挂一漏万的。\n要知道封建各国的渐趋于统一,只要看其封土的扩大,便可知道。今文家说列国的封土,是天子之地方千里,公、侯皆方百里,伯七十里,子、男五十里,不满五十里的为附庸(《孟子·万章下篇》、《礼记·王制》)。古文家则说:公方五百里,侯四百里,伯三百里,子二百里,男百里(《周官》大司徒)。这固然是虚拟之辞,不是事实(不论今古文和诸子书,所说的制度,都是著书的人,以为该怎样办所拟的一个草案,并不全是古代的事实),然亦必以当时的情势为根据。《穀梁》说:“古者天子封诸侯,其地足以容其民,其民足以满城而自守也。”(襄公二十九年)这是古代封土,必须有一个制限,而不容任意扩大的原因。今古文异说,今文所代表的,常为早一时期的制度,古文所代表的则较晚。秦汉时的县,大率方百里(见《汉书·百官公卿表》),可见方百里实为古代的一个政治区域,此今文家大国之封所由来。其超过于此的,如《礼记·明堂位》说:“成王封周公于曲阜,地方七百里。”《史记·汉兴以来诸侯年表》说:“周封伯禽、康叔于鲁、卫,地各四百里;太公于齐,兼五侯地。”这都是后来开拓的结果,而说者误以为初封时的事实的。列国既开拓至此,谈封建制度的人,自然不能斫而小之,亦不必斫而小之,就有如古文家所说的制度了。以事实言之:今文家所说的大国,在东周时代,已是小国。古文家所说的大国,则为其时的次等国。至其时的所谓大国,则子产称其“地方数圻”(圻同畿,即方数千里,见《左传》襄公三十五年)。《孟子》说:“海内之国,方千里者九,齐集有其一。”(《梁惠王上篇》)惟晋、楚、齐、秦等足以当之。此等大国,从无受封于人的;即古文家心目中,以为当封建之国,亦不能如此其大,所以谈封建制度的不之及。\n此等大国,其实际,实即当时谈封建制度者之所谓王。《礼记》说:“天无二日,民无二王”(《曾子问》),这只是古人的一个希望,事实上并不能如此。事实上,当时的中国,是分为若干区域,每区域之中,各自有王的。所以春秋时吴、楚皆称王,战国时七国亦皆称王。公、侯、伯、子、男等,均系美称。论其实,则在一国之内,有最高主权的,皆称为君(《礼记·曲礼》:“九州之伯,入天子之国曰牧,于外曰侯,于其国曰君”)。其为一方所归往的,即为此一区域中的王。《管子·霸言》说:“强国众,则合强攻弱以图霸;强国少,则合小攻大以图王。”此为春秋时吴、楚等国均称王,而齐晋等国仅称霸的原因。因为南方草昧初开,声明文物之国少,肯承认吴、楚等国为王;北方鲁、卫、宋、郑等国,就未必肯承认齐、晋为王了。倒是周朝,虽然弱小,然其称王,是自古相沿下来的,未必有人定要反对它。而当时较大之国,其初大抵是它所封建,有同姓或亲戚的关系,提起它来,还多少有点好感;而在国际的秩序上,亦一时不好否认它,于是齐桓、晋文等,就有挟天子以令诸侯之举了。霸为伯的假借字。伯的本义为长。《礼记·王制》说:“千里之外设方伯。五国以为属,属有长。十国以为连,连有帅。三十国以为卒,卒有正。二百一十国以为州,州有伯。八州,八伯,五十六正,百六十八帅,三百三十六长。八伯各以其属,属于天子之老二人。分天下以为左右,曰二伯。”这又是虚拟的制度,然亦有事实做根据的。凡古书所说朝贡、巡守等制度,大抵是邦畿千里之内的规模(或者还更小于此。如《孟子·梁惠王下篇》说天子巡守的制度,是“春省耕而补不足,秋省敛而助不给”,这只是后世知县的劝农)。后人扩而充之,以为行之于如《禹贡》等书所说的九州之地,于理就不可通了(春天跑到泰山,夏天跑到衡山,秋天跑到华山,冬天跑到恒山,无论其为回了京城再出去,或者从东跑到南,从南跑到西,从西跑到北,总之来不及),然其说自有所本。《公羊》隐公五年说:“自陕以东,周公主之;自陕以西,召公主之”,此即二伯之说所由来。分《王制》的九州为左右,各立一伯,古无此事;就周初的封域,分而为二,使周公、召公各主其一,则不能谓无此事的。然则所谓八州、八伯,恐亦不过就王畿之内,再分为九,天子自治其一,而再命八个诸侯,各主一区而已。此项制度,扩而大之,则如《左传》僖公四年,管仲对楚使所说:“昔召康公命我先君太公曰:五侯九伯,女实征之,以夹辅周室。赐我先君履,东至于海,西至于河,南至于穆陵,北至于无棣。”等于《王制》中所说的一州之伯了。此自非周初的事实,然管仲之说,亦非凭空造作,亦仍以小规模的伯为根据。然则齐桓、晋文等,会盟征伐,所牵连而及的,要达于《王制》所说的数州之广,其规模虽又较大,而其霸主之称,还是根据于此等一州之伯的,又可推而知了。春秋时晋、楚、齐、秦等国,其封土,实大于殷周之初。其会盟征伐的规模,亦必较殷周之初,有过之无不及。特以强国较多,地丑德齐,莫能相尚,不能称王(吴、楚等虽称王,只是在一定区域之内,得其小国的承认)。至于战国时,就老实不客气,各自在其区域之中,建立王号了。然此时的局势,却又演进到诸王之上,要有一个共主,而更高于王的称号,从来是没有的。乃借用天神之名,而称之为帝。齐湣王和秦昭王,曾一度并称东西帝;其后秦围邯郸,魏王又使辛垣衍劝赵尊秦为帝,即其事。此时研究历史的人,就把三代以前的酋长,拣了五个人,称之为五帝(所以太昊、炎帝、黄帝、少昊、颛顼之称,是人神相同的)。后来又再推上去,在五帝以前,拣了三个酋长,以说明社会开化的次序。更欲立一专名以名之,这却真穷于辞了。乃据“始王天下”之义,加“自”字于“王”字之上,造成一个“皇”字,而有所谓三皇(见《说文》。皇王二字,形异音同,可知其实为一语)。至秦王政并天下,遂合此二字,以为自己的称号,自汉以后,相沿不改。\n列国渐相吞并,在大国之中,就建立起郡县制度来。《王制》说:“天子之县内诸侯,禄也;外诸侯,嗣也。”又说:“诸侯之大夫,不世爵禄。”可见内诸侯和大夫,法律上本来不该世袭的。事实上虽不能尽然,而亦不必尽不然,尤其是在君主权力扩张的时候。倘使天子在其畿内,大国的诸侯在其国内,能切实将此制推行,而于其所吞灭之国,亦能推行此制,封建就渐变为郡县了。(一)春秋战国时,灭国而以为县的很多,如楚之于陈、蔡即是。有些灭亡不见记载,然秦汉时的县名,和古国名相同的甚多,亦可推见其本为一国,没入大国之中,而为其一县。(二)还有卿大夫之地,发达而成为县的。如《左传》昭公二年,晋分祁氏之田以为七县,羊舌氏之田以为三县是。(三)又有因便于战守起见,有意设立起来的,如商君治秦,并小都、乡、邑,聚以为县是(见《史记·商君列传》)。至于郡,则其区域本较县为小,且为县所统属(《周书·作雒篇》:“千里百县,县有四郡”)。其与县分立的,则较县为荒陋(《左传》哀公二年,赵简子誓师之辞,说“克敌者上大夫受县,下大夫受郡”)。然此等与县分立之郡,因其在边地之故,其兵力反较县为充足,所以后来在军事上须要控扼之地,转多设立(甘茂谓秦王曰:“宜阳大县也,上党、南阳,积之久矣,名曰县,其实郡也。”春申君言于楚王曰:“淮北地边齐,其事急,请以为郡便。”皆见《史记》本传)。事实上以郡统制县,保护县,亦觉便利,而县遂转属于郡。战国时,列国的设郡,还是在沿边新开辟之地的(如楚之巫、黔中,赵之云中、雁门、代郡,燕之上谷、渔阳、右北平、辽西、辽东郡等)。到秦始皇灭六国后,觉得到处都有驻兵镇压的必要,就要分天下为三十六郡了。\n封建政体,沿袭了几千年,断无没有反动之力之理。所以秦灭六国未几,而反动即起。秦汉之间以及汉初的封建,是和后世不同的。在后世,像晋朝、明朝的封建,不过出于帝王自私之心。天下的人,大都不以为然。即封建之人,对于此制,亦未必敢有何等奢望,不过舍此别无他法,还想借此牵制异姓,使其不敢轻于篡夺而已。受封者亦知其与时势不宜,惴惴然不敢自安。所以唐太宗要封功臣,功臣竟不敢受(见《唐书·长孙无忌传》)。至于秦汉间人,则其见解大异。当时的人,盖实以封建为当然,视统一转为变局。所以皆视秦之灭六国为无道之举,称之为暴秦,为强虎狼之秦。然则前此为六国所灭之国如何呢?秦灭六国,当恢复原状,为六国所灭之国,岂不当—一兴灭继绝吗?倘使以此为难,论者自将无辞可对。然大多数人的见解,是不能以逻辑论,而其欲望之所在,亦是不可以口舌争的。所以秦亡之后,在戏下的诸侯,立即决定分封的方法。当时所封建的:是(一)六国之后,(二)亡秦有功之人。此时的封建,因汉高祖藉口于项王背约,夺其关中之地而起兵,汉代史家所记述,遂像煞是由项王一个人作主,其实至少是以会议的形式决定的。所以在《太史公自序》里,还无意间透露出一句真消息来,谓之“诸侯之相王”。当时的封爵,分为二等:大者王,小者侯,这是沿袭战国时代的故事的(战国时,列国封其臣者,或称侯,或称君,如穰侯、文信侯、孟尝君、望诸君等是。侯之爵较君为高,其地当亦较君为大。此时所封的国,大小无和战国之君相当的,故亦无君之称)。诸侯之大者皆称王,项羽以霸王为之长,而义帝以空名加于其上,也是取法于东周以后,实权皆在霸主,而天王仅存虚名的。以大体言,实不可谓之不惬当。然人的见解,常较时势为落后。人心虽以为允洽,而事势已不容许,总是不能维持的。所以不过五年,而天下复归于统一了。然而当时的人心,仍未觉悟,韩信始终不肯背汉,至后来死于吕后之手,读史者多以为至愚。其实韩信再老实些,也不会以汉高祖为可信。韩信当时的见解,必以为举天下而统属于一人,乃事理所必无。韩信非自信功高,以为汉终不夺其王,乃汉夺其王之事,为信当时所不能想象。此恐非独韩信如此,汉初的功臣,莫不如此。若使当时,韩信等预料奉汉王以皇帝的空名,汉王即能利用之把自己诛灭,又岂肯如此做?确实,汉高祖翦灭所封的异姓,也是一半靠阴谋,一半靠实力的,并非靠皇帝的虚名。若就法理而论,就自古相传列国间的习惯,当时的人心认为正义者论,皇帝对于当时的王,可否如此任意诛灭呢?也还是一个疑问。所以汉高祖的尽灭异姓之国(楚王韩信,梁王彭越,韩王信,淮南王英布,燕王臧荼、卢绾。惟长沙王吴芮仅存),虽然不动干戈,实在和其尽灭戏下所封诸国,是同样的一个奇迹。不但如此,汉高祖所封同姓诸国,后来酝酿成吴、楚七国这样的一个大乱,竟会在短期间戡定;戡定之后,景帝摧抑诸侯,使不得自治民补吏;武帝又用主父偃之策,令诸侯各以国邑,分封子弟,而汉初的封建,居然就名存而实亡,怕也是汉初的人所不能预料的。\n封建的元素,本有两个:一为爵禄,受封者与凡官吏同。一为君国子民,子孙世袭,则其为部落酋长时固有的权利,为受封者所独。后者有害于统一,前者则不然。汉世关内侯,有虚名而无土地,后来列侯亦有如此的(《文献通考·封建考》云:“秦、汉以来,所谓列侯者,非但食其邑入而已,可以臣吏民,可以布政令,若关内侯,则惟以虚名受廪禄而已。西都景、武而后,始令诸侯王不得治民,汉置内史治之。自是以后,虽诸侯王,亦无君国子民之实,不过食其所封之邑入,况列侯乎?然所谓侯者,尚裂土以封之也。至东都,始有未与国邑,先赐美名之例,如灵寿王、征羌侯之类是也。至明帝时,有四姓小侯,乃樊氏、郭氏、明氏、马氏诸外戚子弟,以少年获封者。又肃宗赐东平王苍列侯印十九枚,令王子五岁以上能趋拜者,皆令带之。此二者,皆是未有土地,先佩印,受俸廪。盖至此,则列侯有同于关内侯者矣。”),然尚须给以廪禄。唐宋以后,必食实封的,才给以禄,则并物质之耗费而亦除去之,封建至此,遂全然无碍于政治了。\n后世在中国境内,仍有封建之实的,为西南的土官。土官有两种:一是文的,如土知府、土知州、土知县之类。一是武的,凡以司名的,如宣抚司、招讨司、长官司之类皆是。听其名目,全与流官相同。其实所用的都是部族酋长,依其固有之法承袭。外夷归化中国,中国给以名号(或官或爵),本是各方面之所同,不但西南如此。但其距中国远的,实力不及,一至政教衰微之世,即行离叛而去,这正和三代以前的远国一样。惟西南诸土司,本在封域之内,历代对此的权力,渐形充足,其管理之法,亦即随之而加严。在平时,也有出贡赋,听征调的。这亦和古代诸侯对王朝,小国对大国的朝贡及从征役一样。至其(一)对中国犯顺;(二)或其部族之中,自相争阋;(三)诸部族之间,互相攻击;(四)又或暴虐其民等,中国往往加以讨伐。有机会,即废其酋长,改由中国政府派官治理,是谓“改土归流”,亦即古代之变封建为郡县。自秦至今,近二千二百年,此等土官,仍未尽绝,可见封建政体的铲除,是要随着社会文化的进步,不是政治单方面的事情了。\n封建之世,所谓朝代的兴亡,都是以诸侯革天子之命。此即以一强国,夺一强国的地位,或竟灭之而已。至统一之世,则朝代的革易,其形式有四:(一)为旧政权的递嬗。又分为(甲)中央权臣的篡窃,(乙)地方政权的入据。前者如王莽之于汉,后者如朱温之于唐。(二)为新政权的崛起,如汉之于秦。(三)为异族的入据,如前赵之于晋,金之于北宋,元之于南宋,清之于明。(四)为本族的恢复,如明之于元。而从全局观之,则(一)有仍为统一的,(二)有暂行分裂的。后者如三国、南北朝、五代都是。然这只是政权的分裂,社会文化久经统一,所以政权的分立,总是不能持久的。从前读史的人,每分政情为(一)内重,(二)外重,(三)内外俱轻三种。内重之世,每有权臣篡窃之变。外重之世,易招强藩割据之忧。内外俱轻之世,则草泽英雄,乘机崛起;或外夷乘机入犯。惟秦以过刚而折,为一个例外。\n政权当归诸一人,而大多数人,可以不必过问;甚或以为不当过问,此乃事势积重所致,断非论理之当然。所以不论哪一国,其原始的政治,必为民主。后来虽因事势的变迁,专制政治逐渐兴起,然民主政治,仍必久之而后消灭。观前文所述,可以见之。大抵民主政治的废坠:(一)由于地大人众,并代表会议而不能召集。(二)大众所议,总限于特殊的事务,其通常的事务,总是由少数主持常务的人执行的。久之,此少数人日形专擅,对于该问大众的特殊事务,亦复独断独行。(三)而大众因情势涣散,无从起而加以纠正。专制政治就渐渐形成了。这是形式上的变迁。若探求其所以然,则国家大了,政情随之复杂,大的、复杂的事情,普通人对之不感兴趣,亦不能措置。此实为制度转变的原因。\n然民主的制度,可以废坠,民主的原理,则终无灭绝之理。所以先秦诸子,持此议论的即很多。因后世儒术专行,儒家之书,传者独多,故其说见于儒家书中的亦独多,尤以《孟子》一书,为深入人心。其实孟子所诵述的,乃系孔门的书说,观其论尧、舜禅让之语,与伏生之《尚书大传》,互相出入可知(司马迁《五帝本纪》亦采儒家书说)。两汉之世,此义仍极昌明。汉文帝元年,有司请立太子。文帝诏云:“朕既不德,上帝神明未歆享;天下人民,未有慊志;今纵不能博求天下贤圣有德之人而禅天下焉,而曰豫建太子,是重吾不德也,谓天下何?”此虽系空言,然天下非一人一家所私有之义,则诏旨中也明白承认了。后来眭孟上书,请汉帝谁差天下(谁差,访求、简择之义),求索贤人,禅以帝位,而退自封百里,尤为历代所无。效忠一姓,汉代的儒家,实不视为天经地义。刘歆系极博通的人,且系汉朝的宗室,而反助王莽以篡汉;扬雄亦不反对王莽,即由于此。但此等高义,懂得的只有少数人,所以不久即湮晦,而君臣之义,反日益昌盛了。\n王与君,在古代是有分别的,说已见前。臣与民亦然。臣乃受君豢养的人,效忠于其一身,及其子嗣,尽力保卫其家族、财产,以及荣誉、地位的。盖起于(一)好战的酋长所豢养的武士,(二)及其特加宠任的仆役。其初,专以效忠于一人一家为主。后来(一)人道主义渐形发达。(二)又从利害经验上,知道要保一人一家的安全,或求其昌盛,亦非不顾万民所能。于是其所行者,渐须顾及一国的公益。有时虽违反君主一人一家的利益,而亦有所不能顾。是即大臣与小臣,社稷之臣与私暱嬖倖的区别。然其道,毕竟是从效忠于一人一家,进化而来的,终不能全免此项色彩。至民则绝无效忠于君的义务。两者区别,在古代本极明白,然至后世,却渐渐湮晦了。无官职的平民,亦竟有效忠一姓的,如不仕新朝之类。这在古人看起来,真要莫名其妙了(异民族当别论。民族兴亡之际,是全民族都有效忠的义务的。顾炎武《日知录·正始》条,分别亡国亡天下,所谓亡天下,即指民族兴亡言,古人早见及此了)。至于国君失政,应该诛杀改立之义,自更无人提及。\n剥极则复,到晚明之世,湮晦的古义,才再露一线的曙光。君主之制,其弊全在于世袭。以遗传论,一姓合法继承的人,本无代代皆贤之理。以教育论,继嗣之君,生来就居于优越的地位,志得意满;又和外间隔绝了,尤其易于不贤。此本显明之理,昔人断非不知,然既无可如何,则亦只好置诸不论不议之列了。君主的昏愚、淫乱、暴虐,无过于明朝之多。而时势危急,内之则流寇纵横,民生憔悴:外之则眼看异族侵入,好容易从胡元手里恢复过来的江山,又要沦于建夷之手。仁人君子,蒿目时艰,深求致祸之原,图穷而匕首见,自然要归结到政体上了。于是有黄宗羲的《明夷待访录》出现,其《原君》、《原臣》两篇,于“天下者天下之天下”之义,发挥得极为深切,正是晴空一个霹雳。但亦只是晴空一个霹雳而已。别种条件,未曾完具,当然不会见之于行动的。于是旁薄郁积的民主思想,遂仍潜伏着,以待时势的变化。\n近百年来的时势,四夷交侵,国家民族,都有绝续存亡的关系,可谓危急极了。这当然不是一个单纯的政治问题。但社会文化和政治的分野,政治力量的界限,昔人是不甚明白的。眼看着时势的危急,国事的败坏,当然要把其大部分的原因,都归到政治上去,当然要发动了政治上的力量来救济它,当然要拟议及于政体。于是从戊戌变法急转直下,而成为辛亥革命。中国的民主政治,虽然自己久有根基,而亲切的观感,则得之于现代的东西列强。代议政体,自然要继君主专制而起。但代议政体,在西洋自有其历史的条件,中国却无有。于是再急转直下,而成为现在的党治。\n中国古代,还有一个极高的理想,那便是孔子所谓大同,老子所谓郅治,许行所谓贤者与民并耕而食,饔飧而治。这是超出于政治范围之外的,因为国家总必有阶级,然后能成立,而孔、老、许行所想望的境界,则是没有阶级的。参看下两篇自明。\n第四十章 阶级 # 古代部族之间,互相争斗,胜者把败者作为俘虏,使之从事于劳役,是为奴隶;其但收取其赋税的,则为农奴。已见上章。古代奴婢之数,似乎并不甚多(见下)。最严重的问题,倒在征服者和农奴之间。国人和野人,这两个名词,我们在古书上遇见时,似不觉其间有何严重的区别。其实两者之间,是有征服和被征服的关系的。不过其时代较早,古书上的遗迹,不甚显著,所以我们看起来,不觉得其严重罢了。所谓国人,其初当系征服之族,择中央山险之地,筑城而居。野人则系被征服之族,在四面平夷之地,从事于耕耘。所以(一)古代的都城,都在山险之处。国内行畦田,国外行井田。(二)国人充任正式军队,野人则否。参看第四十四、第四十五、第五十三章自明。上章所讲大询于众庶之法,限于乡大夫之属。乡是王城以外之地,乡人即所谓国人。厉王的被逐,《国语》说:“国人莫敢言,道路以目。”然则参与国政,和起而为反抗举动的,都是国人。若野人,则有行仁政之君,即歌功颂德,襁负而归之;有行暴政之君,则“逝将去汝,适彼乐土”,在可能范围之内逃亡而已。所以一个国家,其初立国的基本,实在是靠国人的,即征服部族的本族。国人和野人之间,其初当有一个很严的界限;彼此之间,还当有很深的仇恨。后来此等界限,如何消灭?此等仇恨,如何淡忘呢?依我推想,大约因:(一)距离战争的年代远了,旧事渐被遗忘。(二)国人移居于野,野人亦有移居于国的,居地既近,婚姻互通。(三)征服部族,是要朘削被征服的部族以自肥的,在经济上,国人富裕而野人贫穷;又都邑多为工商及往来之人所聚会,在交通上,国人频繁而野人闭塞;所以国人的性质较文,野人的性质较质。然到后来,各地方逐渐发达,其性质,亦变而相近了。再到后来,(四)选举的权利,(五)兵役的义务,亦渐扩充推广,而及于野人,则国人和野人,在法律上亦无甚区别,其畛域就全化除了。参看第四十三、第四十五两章自明。\n征服之族和被征服之族的区别,可说全是政治上的原因。至于职业上的区别,则已带着经济上的原因了。古代职业的区别,是为士、农、工、商。士是战士的意思,又是政治上任事而未有爵者之称,可见古代的用人,专在战士中拔擢。至于工商,则专从事于生业。充当战士的人,虽不能全不务农,但有种专务耕种的农民,却是不服兵役的。所以《管子》上有士之乡和工商之乡(见《小匡篇》)。《左传》宣公十二年说,楚国之法,“荆尸而举(荆尸,该是一种组织军队的法令),商、农、工、贾,不败其业。”有些人误以为古代是全国皆兵,实在是错误的,参看第四十五章自明。士和卿大夫,本来该没有多大的区别,因为同是征服之族,服兵役,古代政权和军权,本是混合不分的。但在古代,不论什么职业,多是守之以世。所以《管子》又说:“士之子恒为士,农之子恒为农,工之子恒为工,商之子恒为商。”(《小匡》)政治上的地位,当然不是例外,世官之制既行,士和大夫之间,自然生出严重的区别来,农、工、商更不必说了。此等阶级,如何破坏呢?其在经济上,要维持此等阶级,必须能维持严密的职业组织。如欲使农之子恒为农,则井田制度,必须维持。欲使工之子恒为工,商之子恒为商,则工官和公家对于商业的管理规则,亦必须维持。然到后来,这种制度,都破坏了。农人要种田,你没有田给他种,岂能不许他从事别种职业?工官制度破坏了,所造之器,不足以给民用,民间有从事制造的人,你岂能禁止他?尤其是经济进步,交换之事日多,因而有居间卖买的人,又岂能加以禁止?私产制度既兴,获利的机会无限,人之趋利,如水就下,旧制度都成为新发展的障碍了,古代由社会制定的职业组织,如何能不破坏呢?在政治上:则因(一)贵族的骄淫矜夸,自趋灭亡,而不得不任用游士(参看第四十三章)。(二)又因有土者之间,互相争夺,败国亡家之事,史不绝书。一国败,则与此诸侯有关之人,都夷为平民。一家亡,则与此大夫有关的人,都失其地位。(三)又古代阶级,并未像喀斯德(caste)这样的严峻,彼此不许通婚。譬如《左传》定公九年,载齐侯攻晋夷仪,有一个战士,唤作敝无存,他的父亲,要替他娶亲,他就辞谢,说:“此役也,不死,反必娶于高、国。”(齐国的两个世卿之家)可见贵族与平民通婚,是容易的。婚姻互通,社会地位的变动,自然也容易了。这都是古代阶级所以渐次破坏的原因。\n奴隶的起源,由于以异族为俘虏。《周官》五隶:曰罪隶,曰蛮隶,曰闽隶,曰夷隶,曰貉隶。似乎后四者为异族,前一者为罪人。然罪人是后起的。当初本只以异族为奴隶,后来本族有罪的人,亦将他贬入异族群内,当他异族看待,才有以罪人为奴隶的事。参看第四十六章自明。经学中,今文家言,是“公家不畜刑人,大夫弗养;屏诸四夷,不及以政”(谓不使之当徭役。见《礼记·王制》);古文家言,则“墨者使守门,劓者使守关,宫者使守内,刖者使守囿”(《周官》秋官掌戮)。固然,因刑人多了,不能尽弃而不用,亦因今文所说的制度较早,初期的奴隶,多数是异族,仇恨未忘,所以不敢使用他了(《梁》襄公二十九年:礼,君不使无耻,不近刑人,不押敌,不迩怨)。不但如此,社会学家言:氏族时代的人,不惯和同族争斗,镇压本部族之职,有时不肯做,宁愿让异族人做的。《周官》蛮、闽、夷、貉四隶,各服其邦之服,执其邦之兵,以守王宫及野之厉禁,正是这个道理。这亦足以证明奴隶的源出于异族。女子为奴隶的谓之婢。《文选·司马子长报任安书》李《注》引韦昭云:“善人以婢为妻生子曰获,奴以善人为妻生子曰臧。齐之北鄙,燕之北郊,凡人男而归婢谓之臧,女而归奴谓之获。”可见奴婢有自相嫁娶,亦有和平民婚配的。所以良贱的界限,实亦不甚严峻。但一方面有脱离奴籍的奴隶,一方面又有沦为奴隶的平民,所以奴婢终不能尽绝。这是关系整个社会制度的了。奴隶的免除,有两种方法:一种是用法令。《左传》襄公三十二年,晋国的大夫栾盈造反。栾氏有力臣曰督戎,国人惧之。有一个奴隶,唤作斐豹的,和执政范宣子说道:“苟焚丹书,我杀督戎。”宣子喜欢道:你杀掉他,“所不请于君焚丹书者,有如日”。斐豹大约是因犯罪而为奴隶,丹书就是写他的罪状的。一种是以财赎。《吕氏春秋·察微篇》说:鲁国之法,“鲁人有为臣妾于诸侯者,赎之者取金于府。”这大约是俘虏一类。后世奴隶的免除,也不外乎这两种方法。\n以上是封建时代的事。封建社会的根柢,是“以力相君”。所以在政治上占优势的人,在社会上的地位,亦占优胜。到资本主义时代,就大不然了。《汉书·货殖列传》说:“昔先王之制:自天子、公、侯、卿、大夫、士,至于皂隶,抱关击柝者,其爵禄、奉养、宫室、车服、棺槨、祭祀、死生之制,各有差品,小不得僭大,贱不得逾贵。”又说:后来自诸侯大夫至于士庶人,“莫不离制而弃本。稼穑之民少,商旅之民多;谷不足而货有余。”(谷货,犹言食货。谷、食,本意指食物,引申起来,则包括一切直接供给消费之物。货和化是一语。把这样东西,变成那样,就是交换的行为。所以货是指一切商品)于是“富者木土被文锦,犬马余肉粟,而贫者短褐不完,唅粟饮水。其为编户齐民同列,而以财力相君,虽为仆隶,犹无愠色”。这几句话,最可代表从封建时代到资本主义时代的变迁。封建社会的根源,是以武力互相掠夺。人人都靠武力互相掠夺,则人人的生命财产,俱不可保。这未免太危险。所以社会逐渐进步,武力掠夺之事,总不能不悬为厉禁。到这时代,有钱的人,拿出钱来,就要看他愿否。于是有钱就是有权力。豪爽的武士,不能不俯首于狡猾悭吝的守财奴之前了。这是封建社会和资本主义社会转变的根源。平心而论:资本主义的惨酷,乃是积重以后的事。当其初兴之时,较之武力主义,公平多了,温和多了,自然是人所欢迎的。资本主义所以能取武力主义而代之,其根源即在于此。然前此社会的规则,都是根据武力优胜主义制定的,不是根据富力优胜主义制定的。武力优胜主义,固然也是阶级的偏私,且较富力优胜主义为更恶。然而人们,(一)谁肯放弃其阶级的偏私?(二)即有少数大公无我的人,亦不免为偏见所蔽,视其阶级之利益,即为社会全体的利益;以其阶级的主张,即为社会全体的公道,这是无可如何的事。所以资本主义的新秩序,用封建社会的旧眼光看起来,是很不入眼的;总想尽力打倒他,把旧秩序回复。商鞅相秦,“明尊卑爵秩等级。各以差次名田宅臣妾。衣服以家次。有功者显荣,无功者虽富无所纷华。”(《史记》本传)就是代表这种见解,想把富与贵不一致的情形,逆挽之,使其回复到富与贵相一致的时代的。然而这如何办得到呢?封建时代,统治者阶级的精神,最紧要的有两种:一是武勇,一是不好利。惟不好利,故富贵不能淫,贫贱不能移。惟能武勇,故威武不能屈。这是其所以能高居民上,维持其治者阶级的地位的原因。在当时原非幸致。然而这种精神,也不是从天降,从地出;或者如观念论者所说,在上者教化好,就可以致之的。人总是随着环境变迁的。假使人而不能随着环境变迁,则亦不能制驭环境,而为万物之灵了。在封建主义全盛时,治者阶级因其靠武力得来的地位的优胜,不但衣食无忧,且其生活,总较被治的人为优裕,自然可以不言利。讲到武勇,则因前此及其当时,他们的生命,是靠腕力维持的(取之于自然界者如田猎。取之于人者,则为战争和掠夺),自能养成其不怕死不怕苦痛的精神。到武力掠夺,悬为厉禁,被治者的生活,反较治者为优裕;人类维持生活最好的方法,不是靠腕力限之于自然界,或夺之于团体之外,而反是靠智力以剥削团体以内的人;则环境大变了。治者阶级的精神,如何能不随之转变呢?于是滔滔不可挽了。在当时,中坚阶级的人,因其性之所近,分为两派:近乎文者则为儒,近乎武者则为侠。古书多以儒侠并称,亦以儒墨并称,可见墨即是侠。儒和侠,不是孔、墨所创造的两种团体,倒是孔、墨就社会上固有的两种阶级,加以教化,加以改良的。在孔、墨当日,何尝不想把这两个阶级振兴起来,使之成为国家社会的中坚?然而滔滔者终于不可挽了。儒者只成为“贪饮食,惰作务”之徒(见《墨子·非儒篇》),侠者则成为“盗跖之居民间者”(《史记·游侠列传》)。质而言之,儒者都是现在志在衣食,大些则志在富贵的读书人。侠者则成为现在上海所谓白相人了。我们不否认,有少数不是这样的人,然而少数总只是少数。这其原理,因为在生物学上,人,大多数总是中庸的,而特别的好,和特别的坏,同为反常的现象。所以我们赞成改良制度,使大多数的中人,都可以做好人;不赞成认现社会的制度为天经地义,责成人在现制度之下做好人,陈义虽高,终成梦想。直到汉代,想维持此等阶级精神,以为国家社会的中坚的,还不乏其人。试看贾谊《陈政事疏》所说圣人有金城之义,董仲舒对策说食禄之家不该与民争利一段(均见《汉书》本传),便可见其大概。确实,汉朝亦还有此种人。如盖宽饶,“刚直高节,志在奉公。”儿子步行戍边,专务举发在位者的弊窦,又好犯颜直谏,这确是文臣的好模范。又如李广,终身除射箭外无他嗜好,绝不言利,而于封侯之赏,却看得很重。广为卫青所陷害而死,他的儿子敢,因此射伤卫青,又给霍去病杀掉,汉武帝都因其为外戚之故而为之讳,然李广的孙儿子陵,仍愿为武帝效忠。他敢以步卒五千,深入匈奴。而且“事亲孝,与士信,临财廉,取与义,分别有让,恭俭下人”(见《汉书·司马迁传》迁报任安书),这真是一个武士的好模范。还有那奋不顾身,立功绝域的傅介子、常惠、陈汤、班超等,亦都是这一种人。然而滔滔者终于不可挽了。在汉代,此等人已如凤毛麟角,魏晋以后,遂绝迹不可复见。岂无好人?然更不以封建时代忠臣和武士的性质出现了。过去者已去,如死灰之不可复燃。后人谈起这种封建时代的精神来,总觉得不胜惋惜。然而无足惜也。这实在不是什么好东西。当时文臣的见解,已不免于褊狭。武人则更其要不得。譬如李广,因闲居之时,灞陵尉得罪了他(如灞陵尉之意,真在于奉公守法,而不是有意与他为难,还不能算得罪他,而且是个好尉),到再起时,就请尉与俱,至军而斩之,这算什么行为?他做陇西太守时,诈杀降羌八百余人,岂非武士的耻辱?至于一班出使外国之徒,利于所带的物品,可以干没;还好带私货推销,因此争求奉使。到出使之后,又有许多粗鲁的行为,讹诈的举动,以致为国生事,引起兵端(见《史记·大宛列传》),这真是所谓浪人,真是要不得的东西。中国幸而这种人少,要是多,所引起的外患,怕还不止五胡之乱。\n封建时代的精神过去了。社会阶级,遂全依贫富而分。当时所谓富者,是(一)大地主,(二)大工商家,详见下章。晁错《贵粟疏》说:“今法律贱商人,商人已富贵矣;尊农夫,农夫已贫贱矣。俗之所贵,主之所贱;吏之所卑,法之所尊。上下相反,好恶乖迕,而欲国富法立,不可得也。”可见法律全然退处于无权了。\n因资本的跋扈,奴婢之数,遂大为增加。中国古代,虽有奴婢,似乎并不靠他做生产的主力。因为这时候,土地尚未私有,旧有的土地,都属于农民。君大夫有封地的,至多只能苛取其租税,强征其劳力(即役),至于夺农民的土地为己有,而使奴隶从事于耕种,那是不会有这件事的(因为如此,于经济只有不利。所以虽有淫暴之君,亦只会弃田以为苑囿。到暴力一过去,苑囿就又变做田了)。大规模的垦荒,或使奴隶从事于别种生产事业,那时候也不会有。其时的奴隶,只是在家庭中,以给使令,或从事于消费品的制造(如使女奴舂米、酿酒等),为经济的力量所限,其势自不能甚多。到资本主义兴起后,就不然了。(一)土地既已私有,原来的农奴,都随着土地,变成地主的奴隶。王莽行王田之制,称奴隶为“私属”,和田地都不得卖买。若非向来可以卖买,何必有此法令呢?这该是秦汉之世,奴婢增多的一大原因(所以奴婢是由俘虏、罪人两政治上的原因造成的少,由经济上的原因造成的多)。(二)农奴既变为奴隶,从事于大规模的垦荒的,自然可以购买奴隶,使其从事耕作。(三)还可以使之从事于别种事业。如《史记·货殖列传》说:刁閒收取桀黠奴,使之逐渔盐商贾之利。所以又说童手指千,比千乘之家。如此,奴婢越多越富,其数就无制限了。此时的奴婢,大抵是因贫穷而鬻卖的。因贫穷而卖身,自古久有其事。所以《孟子·万章上篇》,就有人说:百里奚自鬻于秦养牲者之家。然在古代,此等要不能甚多。至汉代,则贾谊说当时之民,岁恶不入,就要“请爵卖子”,成为经常的现象了。此等奴婢,徒以贫穷之故而卖身,和古代出于俘虏或犯罪的,大不相同,国家理应制止及救济。然当时的国家,非但不能如此,反亦因之以为利。如汉武帝,令民入奴婢,得以终身复;为郎的增秩。其时行算缗之法,遣使就郡国治隐匿不报的人的罪,没收其奴婢甚多,都把他们分配到各苑和各机关,使之从事于生产事业(见《史记·平准书》)。像汉武帝这种举动,固然是少有的,然使奴婢从事于生产事业者,必不限于汉武帝之世,则可推想而知,奴隶遂成为此时官私生产的要角了。汉末大乱,奴婢之数,更行增多。后汉光武一朝,用法令强迫释放奴婢很多(均见《后汉书》本纪)。然亦不过救一时之弊,终不能绝其根株。历代救济奴隶之法:(一)对于官奴婢,大抵以法令赦免。(二)对于私奴婢:则(甲)以法令强迫释放;(乙)官出资财,替他赎身;(丙)勒令以买直为佣资,计算做工的时期,足满工资之数,便把他放免。虽有此法,亦不过去其太甚而已。用外国人作奴婢,后世还是有的。但非如古代的出于俘虏,而亦出于鬻卖。《汉书·西南夷列传》和《货殖列传》,都有所谓“僰僮”,就是当时的商人,把他当作商品贩卖的。《北史·四裔传》亦说:当时的人,多买獠人作奴仆。因此,又引起政治上的侵略。梁武帝时,梁、益二州,岁岁伐獠以自利。周武帝平梁、益,亦命随近州镇,年年出兵伐獠,取其生口,以充贱隶。这在后世,却是少有的事,只有南北分立之世,财力困窘,政治又毫无规模,才会有之。至于贩卖,却是通常现象。如唐武后大足元年,敕北方缘边诸郡,不得畜突厥奴婢;穆宗长庆元年,诏禁登、莱州及缘海诸道,纵容海贼,掠卖新罗人为奴婢,就可见海陆两道,都有贩卖外国人口的了。南方的黑色人种,中国谓之昆仑。唐代小说中,多有昆仑奴的记载,更和欧洲人的贩卖黑奴相像。然中国人亦有自卖或被卖做外国人的奴隶的。宋太宗淳化二年,诏陕西缘边诸郡:先因岁饥,贫民以男女卖与戎人,官遣使者,与本道转运使,分以官财物赎,还其父母;真宗天禧三年,诏自今掠卖人口入契丹界者,首领并处死,诱至者同罪,未过界者,决杖黥配(均见《文献通考》),就是其事。\n后汉末年,天下大乱,又发生所谓部曲的一个阶级。部曲二字,本是军队中一个组织的名称(《续汉书·百官志》大将军营五部,部下有曲,曲下有屯)。丧乱之际,人民无家可归,属于将帅的兵士,没有战事的时候,还是跟着他生活。或者受他豢养或者替他工作。事实上遂发生隶属的状态。用其力以生产,在经济上是有利的,所以在不招兵的时候,将帅也要招人以为部曲了(《三国志·李典传》说:典有宗族部曲三千余家,就是战时的部曲,平时仍属于将帅之证。《卫觊传》说:觊镇关中时,四方流移之民,多有回关中的,诸将多引为部曲,就是虽不招兵之时,将帅亦招人为部曲之证)。平民因没有资本,或者需要保护,一时应他的招。久之,此等依赖关系,已成过去,而其身份,被人歧视,一时不能回复,遂成为另一阶级。部曲的女子,谓之客女。历代法律上,奴婢伤害良人,罪较平民互相伤害为重。良人伤害奴婢,则罪较平民互相伤害为轻。其部曲、客女,伤害平民的罪,较平民加重,较奴婢减轻;平民伤害部曲、客女的,亦较伤害奴婢加重,较其互相伤害减轻。所以部曲的地位,是介于良贱之间的。历魏、晋、南北朝至唐、宋,都有这一阶级。\n使平民在某种程度以内,隶属于他人,亦由来甚久。《商君书·竟内篇》说:“有爵者乞无爵者以为庶子。级乞一人。其无役事也(有爵者不当差徭,在自己家里的时候),庶子役其大夫,月六日。其役事也,随而养之。”(有爵者替公家当差徭时,庶子亦跟着他出去)这即是《荀子·议兵篇》所说秦人五甲首而隶五家之制。秦爵二十级(见《汉书·百官公卿表》)。级级都可乞人为役,则人民之互相隶属者甚多,所以鲁仲连要说秦人“虏使其民”了。晋武帝平吴以后,王公以下,都得荫人为衣食客及佃客。其租调及力役等,均入私家。此即汉世封君食邑户的遗法,其身份仍为良民。辽时有所谓二税户,把良民赐给僧寺,其税一半输官,一半输寺(金世宗时免之),亦是为此。此等使人对人直接征收,法律上虽限于某程度以下的物质或劳力,然久之,总易发生广泛的隶属关系,不如由国家征收,再行给与之为得。\n封建时代的阶级,亦是相沿很久的,岂有一废除即铲灭净尽之理?所以魏晋以后,又有所谓门阀的阶级。魏晋以后的门阀,旧时的议论,都把九品中正制度(见第四十三章),看作它很重要的原因,这是错误的。世界上哪有这种短时间的政治制度,能造成如此深根固柢的社会风尚之理?又有说:这是由于五胡乱华,衣冠之族,以血统与异族混淆为耻,所以有这风尚的。这也不对。当时的区别,明明注重于本族士庶之间。况且五胡乱华,至少在西晋的末年,声势才浩大的,而刘毅在晋初,已经说当时中正的品评,上品无寒门,下品无世族了。可见门阀之制,并非起源于魏晋之世。然则其缘起安在呢?论门阀制度的话,要算唐朝的柳芳,说得最为明白(见《唐书·柳冲传》)。据他的说法:则七国以前,封建时代的贵族,在秦汉之世,仍为强家。因为汉高祖起于徒步,用人不论家世,所以终两汉之世,他们在政治上,不占特别的势力。然其在社会上,势力仍在。到魏晋以后,政治上的势力,和社会上的势力合流,门阀制度,就渐渐固定了。这话是对的。当时政治上扶植门阀制度的,就是所谓九品中正(见第四十三章)。至于在社会上,则因汉末大乱,中原衣冠之族,开始播迁。一个世家大族,在本地方,是人人知其为世家大族的,用不着自行表暴。迁徙到别的地方,就不然了。琅邪王氏是世族,别地方的王氏则不然。博陵崔氏是世族,别地方的崔氏则不然。一处地方,就迁来一家姓王的,姓崔的,谁知道他是哪里的王?哪里的崔呢?如此,就不得不郑重声明,我是琅邪王而非别的王氏;是博陵崔而非别的崔氏了。这是讲门阀的所以要重视郡望的原因。到现在,我们旧式婚姻的简帖上,还残留着这个老废物。这时候,所谓门第的高下,大概是根据于:(一)本来门第的高下。这是相沿的事实,为本地方人所共认,未必有谱牒等物为据。因为古代谱牒,都是史官所记。随着封建的崩坏,久已散佚无存了。(二)秦汉以来,世家大族,似乎渐渐的都有谱牒(《隋书》著录,有家谱、家传两门。《世说新语》《注》,亦多引人家的家谱)。而其事较近,各家族中,有何等人物、事迹,亦多为众人所能知、所能记,在这时期以内,一个家族中,要多有名位显著的人,而切忌有叛逆等大恶的事。如此,历时稍久,即能受人承认,为其地之世家(历时不久的,虽有名位显著的人,人家还只认为暴发户,不大看得起他。至于历时究要多久,那自然没有明确的界限)。(三)谱牒切忌佚亡,事迹切忌湮没。傥使谱牒已亡;可以做世家的条件的事迹,又无人能记忆;或虽能记忆,而不能证明其出于我之家族中;换言之,即不能证明我为某世家大族或有名位之人之后;我的世族的资格,就要发生动摇了。要之,不要证据的事,要没人怀疑;要有证据的事,则人证物证,至少要有一件存在,这是当时判定世族资格的条件。谱牒等物,全由私家掌管,自然不免有散佚、伪造等事。政治总是跟着社会走的。为要维持此等门阀制度,官家就亦设立谱局,与私家的谱牒互相钩考,“有司选举,必稽谱籍而考其真伪”了(亦柳芳语)。\n当这时代,寒门世族,在仕途上优劣悬殊;甚至婚姻不通,在社交上的礼节,亦不容相并(可参考《陔余丛考·六朝重氏族》条)。此等界限,直至唐代犹存。《唐书·高士廉传》及《李义府传》说:太宗命士廉等修《氏族志》,分为九等,崔氏犹为第一,太宗列居第三。又说:魏大和中,定望族七姓,子孙迭为婚姻。唐初作《氏族志》,一切降之。后房玄龄、魏徵、李勣等,仍与为婚,故其望不减。义府为子求婚不得,乃奏禁焉。其后转益自贵,称禁婚家,男女潜相聘娶,天子不能禁。《杜羔传》说:文宗欲以公主降士族,曰:“民间婚姻,不计官品,而尚阀阅。我家二百年天子,反不若崔、卢邪?”可见唐朝中叶以后,此风尚未铲除。然此时的门阀,已只剩得一个空壳,经不起雨打风吹,所以一到五代时,就成“取士不问家世,婚姻不问阀阅”之局了(《通志·氏族略》)。这时候的门阀,为什么只剩一个空壳呢?(一)因自六朝以来,所谓世族,做事太无实力。这只要看《廿二史札记·江左诸帝皆出庶族》、《江左世族无功臣》、《南朝多以寒人掌机要》各条可见。(二)则世族多贪庶族之富,与之通婚;又有和他通谱,及把自己的家谱出卖的。看《廿二史札记·财昏》、《日知录·通谱》两条可见。(三)加以隋废九品中正,唐以后科举制度盛行,世族在选举上,亦复不占便宜。此时的门阀,就只靠相沿已久,有一种惰力性维持,一受到(四)唐末大乱、谱牒沦亡的打击,自然无以自存了。门阀制度,虽盛于魏晋以后,然其根源,实尚远在周秦以前,到门阀制度废除,自古相传的阶级,就荡然以尽了(指由封建势力所造成的阶级)。\n然本族的阶级虽平,而本族和异族之间,阶级复起。这就不能不叹息于我族自晋以后武力的衰微了。中国自汉武帝以后,民兵渐废。此时的兵役,多以罪人和奴隶充之,亦颇用异族人为兵。东汉以后,杂用异族之风更盛。至五胡乱华之世,遂习为故常(别见第四十五章)。此时的汉人和异族之间,自然不能不发生阶级。史称北齐神武帝,善于调和汉人和鲜卑。他对汉人则说:“鲜卑人是汝作客(犹今言雇工),得汝一斛粟,一匹绢,为汝击贼,令汝安宁,汝何为凌之?”对鲜卑人则说:“汉人是汝奴。夫为汝耕,妇为汝织,输汝粟帛,令汝温饱,汝何为疾之?”就俨然一为农奴,一为战士了。但此时期的异族,和自女真以后的异族,有一个大异点。自辽以前(契丹为鲜卑宇文氏别部,实仍系五胡的分支),外夷率以汉族为高贵而攀援之,并极仰慕其文化,不恤牺牲其民族性,而自愿同化于汉族。至金以后则不然。这只要看五胡除羯以外,无不冒托神明之胄(如拓跋氏自称黄帝之后,宇文氏自称炎帝之后是),金以后则无此事;北魏孝文帝,自愿消灭鲜卑语,奖励鲜卑人与汉人通婚,自然是一个极端的例子,然除此以外,亦未有拒绝汉族文化的。金世宗却极力保存女真旧风及其语言文字。这大约由于自辽以前的异族,附塞较久,濡染汉人文化较深,金、元、清则正相反之故。渤海与金、清同族,而极仰慕汉人的文化,似由其先本与契丹杂居营州,有以致之,即其一证。对于汉族的压制剥削,亦是从金朝以后,才深刻起来的。五胡虽占据中原,只是一部分政权,入于其手。其人民久与汉族杂居,并未闻至此时,在社会上,享有何等特别的权利(至少在法律上大致如此)。契丹是和汉人不杂居的。其国家的组织,分为部族和州县两部分,彼此各不相干(设官分南北面,北面以治部族,南面以治州县)。财赋之官,虽然多在南面,这是因汉族的经济,较其部族为发达之故,还不能算有意剥削汉人。到金朝,则把猛安谋克户迁入中原。用集团之制,与汉族杂居,以便镇压。因此故,其所耕之地,不得不连成片段。于是或藉口官地,强夺汉人的土地(如据梁王庄、太子务等名目,硬说其地是官地之类),或口称与汉人互换,而实系强夺。使多数人民流离失所。初迁入时,业已如此。元兵占据河北后,尽将军户(即猛安谋克户)迁于河南,又是这么一次。遂至和汉人结成骨仇血怨,酿成灭亡以后大屠戮的惨祸了(见《廿二史札记·金末种人被害之惨》条)。元朝则更为野蛮。太宗时,其将别迭,要把汉人杀尽,空其地为牧场,赖耶律楚材力争始止(见《元史·耶律楚材传》)。元朝分人为蒙古、色目(犹言诸色人等,包括蒙古及汉族以外的人。其种姓详见《辍耕录》)、汉人(灭金所得的中国人)、南人(灭宋所得的中国人)四种,一切权利,都不平等(如各官署的长官,必用蒙古人。又如学校及科举,汉人、南人的考试较难,而出身反劣)。汉人入奴籍的甚多(见《廿二史札记·元初诸将多掠人为私户》条)。明代奴仆之数骤增(见《日知录·奴仆》条),怕和此很有关系。清朝初入关时,亦圈地以给旗民。其官缺,则满、汉平分。又有蒙古、汉军、包衣(满洲人的奴仆)的专缺。刑法,则宗室、觉罗(显祖之后称宗室,自此以外称觉罗。宗室俗称黄带子,觉罗俗称红带子,因其常系红黄色的带子为饰。凡汉人杀伤红黄带子者,罪加一等。惟在茶坊酒肆中则否,以其自亵身份也)及旗人,审讯的机关都不同(宗室、觉罗,由宗人府审讯。与人民讼者,会同户、刑部。包衣由内务府慎刑司审讯。与人民讼者,会同地方官。旗人由将军、都统、副都统审讯),且都有换刑(宗室以罚养赡银代笞、杖,以板责、圈禁代徒、流、充军。雍正十二年,并推及觉罗。其死罪则多赐自尽。旗人以鞭责代笞、杖,枷号代徒、流、充军。死刑以斩立决为斩监候,斩监候为绞),都是显然的阶级制度。民族愈开化,则其自觉心愈显著,其斗争即愈尖锐。处于现在生存竞争的世界,一失足成千古恨,再回头是百年身,诚不可以不凛然了(近来有一派议论,以为满、蒙等族,现在既已与汉族合为一个国族了,从前互相争斗的事,就不该再提及,怕的是挑起恶感。甚至有人以为用汉族二字,是不甚妥当的。说这是外国人分化我们的手段,我们不该盲从。殊不知历史是历史,现局是现局。不论何国、何族,在以往,谁没有经过斗争来?现在谁还在这里算陈账?若虑挑起恶感,而于以往之事,多所顾忌而不敢谈,则全部历史,都只好拉杂摧烧之了。汉族二字不宜用,试问在清朝时代的满汉二字,民国初年的汉、满、蒙、回、藏五族共和等语,当改作何字?历史是一种学术,凡学术都贵真实。只要忠实从事,他自然会告诉你所以然的道理,指示你当遵循的途径。现在当和亲的道理,正可从从前的曾经斗争里看出来,正不必私智穿凿,多所顾虑)。总而言之:凡阶级的所以形成,其根源只有两种:一种是武力的,一种是经济的。至于种族之间,则其矛盾,倒是较浅的。近代的人,还有一种缪见,以为种族是一个很大的界限,同种间的斗争,只是一时的现象,事过之后,关系总要比较亲切些。殊不知为人类和亲的障碍的,乃是民族而非种族。种族的同异在体质上,民族的同异在文化上。体质上的同异,有形状可见,文化上的同异,无迹象可求。在寻常人想起来,总以为种族的同异,更难泯灭,这就是流俗之见,需要学术矫正之处。从古以来,和我们体质相异的人,如西域深目高鼻之民,南方卷发黑身之族,为什么彼我之间,没有造成严重的阶级呢?总而言之:社会的组织,未能尽善,则集团与集团之间,利害不能无冲突。“利惟近者为可争,害惟近者为尤切。”这是事实。至于体质异而利害无冲突,倒不会有什么剧烈的斗争的。这是古今中外的历史,都有很明白的证据的。所以把种族看做严重的问题,只是一个俗见。\n近代有一种贱民。其起源,或因民族的异同,或因政治上的措置,或则社会上积习相沿,骤难改易。遂至造成一种特别阶级。这在清朝时,法律上都曾予以解放。如雍正元年,于山、陕的乐户,绍兴的惰民;五年于徽州的伴档,宁国的世仆;八年于常熟、昭文的丐户,都令其解放同于平民。乾隆三十六年,又命广东的疍户,浙江的九姓渔户,及各省有似此者,均查照雍正元年成案办理。这自然是一件好事情。但社会上的歧视,往往非政治之力所能转移。所以此等阶级,现在仍未能完全消灭。这是有待于视压迫为耻辱的人,继续努力的了。\n阶级制度,在古昔是多少为法律所维持的。及文化进步,觉得人视人为不平等,不合于理,此等法律,遂逐渐取消。然社会上的区别,则不能骤泯。社会阶级的区别,显而易见的,是生活的不同。有形的如宫室、衣服等,无形的如语言、举动等。其间的界限,为社会所公认。彼此交际之间,上层阶级,会自视为优越,而对方亦承认其优越;下层阶级,会被认为低微,而其人亦自视为低微。此等阶级的区别,全由习惯相沿。而人之养成其某阶级的气质,则由于教育(广义的);维持其某阶级的地位,则由于职业。旧时社会所视为最高阶级的,乃读书做官的人,即所谓士。此种人,其物质的享受,亦无以逾于农工商。但所得的荣誉要多些。所以农工商还多希望改而为士,而士亦不肯轻弃其地位(旧时所谓书香之家,虽甚贫穷,不肯轻易改业,即由于此)。这还是封建残余的势力。此外则惟视其财力的厚薄,以判其地位的高低。所谓贫富,应以维持其所处的阶级的生活为标准。有余的谓之富,仅足的谓之中人,不足的谓之贫。此自非指一时的状况言,而当看其地位是否稳固。所谓稳固,包含三条件:即(一)财产收人,较劳力收入为稳固。(二)有保障的职业,较无保障的为稳固。(三)独立经营的职业,较待人雇用的为稳固。阶级的升降,全然视其财力。财力足以上升,即可升入上层阶级。财力不能维持,即将落入下层阶级。宫室衣服等,固然如此,即教育职业亦然。如农工商要改做士,则必须有力量能从师读书;又必须有力量能与士大夫交际,久之,其士大夫的气质,乃得养成。此系举其一端,其他可以类推。总之,除特别幸运的降临,凡社会上平流而进的,均必以经济上的地位为其基础。下层社会中人,总想升人上层的;上层社会中人,则想保持其地位。旧时的教育,如所谓奋勉以求上进,如所谓努力勿坠其家声等,无论其用意如何,其内容总不外乎此。至于(一)铲除阶级;(二)组织同阶级中人,以与异阶级相斗争,则昔时无此思想。此因(一)阶级间之相去,并不甚远;(二)而升降也还容易之故。新式产业兴起以后,情形就与从前不同。从前所谓富、中人、贫,相去实不甚远的,今则相去甚远(所谓中产阶级,当分新旧两种:旧的,如旧式的小企业等,势将逐渐为大企业所吞并。新的,如技术、管理人员等,则皆依附大资本家以自存。其生活形式,虽与上层阶级为侪,其经济地位的危险,实与劳工无异。既无上升之望,则终不免于坠落。所以所谓中间者,实不能成为阶级)。从下级升至上级,亦非徒恃才能,所能有济(昔时的小富,个人的能力及际遇,足以致之,今之大富豪则不然。现在文明之国,所谓实业领袖,多系富豪阶级中人,由别阶级升入的很少)。于是虽无世袭之名,而有世袭之实。上级的地位,既不易变动,下级的恶劣境遇,自然不易脱离。环境逼迫着人改变思想,阶级斗争之说,就要风靡一时了。铲除阶级,自是美事。但盲动则不免危险;且亦非专用激烈手段,所能有济,所以举措不可不极审慎。\n第四十一章 财产 # 要讲中国的经济制度,我们得把中国的历史,分为三大时期:有史以前为第一期。有史以后,讫于新室之末,为第二期。自新室亡后至现在,为第三期。自今以后,则将为第四期的开始。\n孔子作《春秋》,把二百四十二年,分为三世:第一期为乱世,第二期为升平世,第三期为太平世。这无疑是想把世运逆挽而上,自乱世进入升平,再进入太平的。然则所谓升平、太平,是否全是孔子的理想呢?我们试看,凡先秦诸子,无不认为邃古之世,有一个黄金时代,其后乃愈降而愈劣,即可知孔子之言,非尽理想,而必有其历史的背景。《礼记·礼运》所说的大同、小康,大约就是这个思想的背景罢?大同是孔子认为最古的时代,最好的,小康则渐降而劣,再降就入于乱世了。所谓升平,是想把乱世逆挽到小康,再进而达于大同,就是所谓太平了,这是无可疑的。然则所谓大同、小康,究竟是何时代呢?\n人是非劳动不能生存的,而非联合,则其劳动将归于无效,且亦无从劳动起,所以《荀子》说人不群则不能胜物(见《王制篇》。胜字读平声,作堪字解,即担当得起的意思。物字和事字通训。能胜物,即能担当得起事情的意思,并非谓与物争斗而胜之)。当这时代,人是“只有合力以对物,断无因物而相争”的,许多社会学家,都证明原始时代的人,没有个人观念。我且无有,尚何有于我之物?所以这时代,一切物都是公有的。有种东西,我们看起来,似乎是私有(如衣服及个人所用的器具之类),其实并不是私有,不过不属于这个人,则无用,所以常常附属于他罢了。以财产之承袭论,亦是如此(氏族时代,男子的遗物,多传于男子,女子的遗物,多传于女子,即由于此)。当这时代,人与人之间,既毫无间隔,如何不和亲康乐呢?人类经过原始共产时代、氏族共产时代,以入于家族集产时代,在氏族、家族时代,似已不免有此疆彼界之分,然其所含的公共性质还很多。孔子所向往的大同,无疑的,是在这一个时代以前。今试根据古书,想象其时的情形如下。\n这时代,无疑是个农业时代。耕作的方法,其初该是不分疆界的,其后则依家族之数,而将土地分配(所以孔子说“男有分,女有归”),此即所谓井田制度。井田的制度,是把一方里之地,分为九区。每区一百亩。中间的一区为公田,其外八区为私田。一方里住八家,各受私田百亩。中间的公田,除去二十亩,以为八家的庐舍(一家得二亩半),还有八十亩,由八家公共耕作。其收入,是全归公家的。私田的所入,亦即全归私家。此即所谓助法。如其田不分公私,每亩田上的收获,都酌提若干成归公,则谓之彻法。土田虽有分配,并不是私人所有的,所以有“还受”和“换主易居”之法(受,谓达到种田的年龄,则受田于公家。还,谓老了,达到无庸种田的年龄,则把田还给公家。因田非私人所有,故公家时时可重行分配,此即所谓“再分配”。三年一换主易居,即再分配法之一种)。在所种之田以外,大家另有一个聚居之所,是之谓邑。合九方里的居民,共营一邑,故一里七十二家(见《礼记·杂记》《注》引《王度记》。《公羊》何《注》举成数,故云八十家。邑中宅地,亦家得二亩半,合田间庐舍言之,则曰“五亩之宅”),八家共一巷。中间有一所公共的建筑,是为“校室”。春、夏、秋三季,百姓都在外种田,冬天则住在邑内。一邑之中,有两个老年的人做领袖。这两个领袖,后世的人,用当时的名称称呼他,谓之父老、里正。古代的建筑,在街的两头都有门,谓之闾。闾的旁边,有两间屋子,谓之塾。当大家要出去种田的时候,天亮透了,父老和里正,开了闾门,一个坐在左塾里,一个坐在右塾里,监督着出去的人。出去得太晚了;或者晚上回来时,不带着薪樵以预备做晚饭,都是要被诘责的。出入的时候,该大家互相照应。所带的东西轻了,该帮人家分拿些。带的东西重了,可以分给人家代携,不必客气。有年纪、头发花白的人,该让他安逸些,空手走回来。到冬天,则父老在校室里,教训邑中的小孩子,里正则催促人家“缉绩”。住在一条巷里的娘们,聚在一间屋子里织布,要织到半夜方休。以上所说的,是根据《公羊》宣公十五年何《注》、《汉书·食货志》,撮叙其大略。这虽是后来人传述的话,不全是古代的情形,然还可根据着他,想象一个古代农村社会的轮廓。\n农田以外的土地,古人总称为山泽。农田虽按户口分配,山泽是全然公有的。只要依据一定的规则,大家都可使用(如《孟子》所说的“数罟不入洿池”,“斧斤以时入山林”等。田猎的规则,见《礼记·王制》。《周官》有山虞、林衡、川衡、泽虞、迹人、卝人等官,还是管理此等地方,监督使用的人,必须遵守规则,而且指导他使用的方法的,并不封禁)。\n这时候,是无所谓工业的。简单的器具,人人会造,较繁复的,则有专司其事的人。但这等人,绝不是借此以营利的。这等人的生活资料,是由大家无条件供给他的,而他所制造的器具,也无条件供给大家用。这是后来工官之本。\n在本部族之内,因系公产,绝无所谓交易。交易只行于异部族之间。不过以剩余之品,互相交换,绝无新奇可喜之物。所以许行所主张的贸易,会简单到论量不论质(见《孟子·滕文公上篇》)。而《礼记·郊特牲》说:“四方年不顺成,八蜡不通。”(言举行蜡祭之时,不许因之举行定期贸易)蜡祭是在农功毕后举行的,年不顺成,就没有剩余之品可供交易了。此等交易,可想见其对于社会经济,影响甚浅。\n倘在特别情形之下,一部族中,缺少了甚么必要的东西,那就老实不客气,可以向人家讨,不必要有什么东西交换。后来国际间的乞籴,即原于此。如其遇见天灾人祸,一个部族的损失,实在太大了,自己无力回复,则诸部族会聚集起来,自动替他填补的。《春秋》襄公三十年,宋国遇到火灾,诸侯会于澶渊,以更宋所丧之财(更为继续之意,即现在的赓字),亦必是自古相沿的成法。帮助人家工作,也不算得什么事的。《孟子》说:“汤居亳,与葛为邻。葛伯放而不祀。汤使人问之曰:何为不祀?曰:无以供牺牲也。汤使遗之牛羊。葛伯食之,又不以祀。汤又使人问之曰:何为不祀?曰:无以供粢盛也。汤使亳众往为之耕。”(《滕文公下》)这件事,用后世人的眼光看起来,未免不近情理。然如齐桓公会诸侯而城杞(《春秋》僖公十四年),岂不亦是替人家白效劳么?然则古代必有代耕的习惯,才会有这传说。古代国际间有道义的举动还很多,据此推想,可以说:都是更古的部族之间留传下来的。此即孔子所谓“讲信修睦”。\n虽然部族和部族之间,有此好意,然在古代,部族乞助于人的事,总是很少的。因为他们的生活,是很有规范的,除非真有不可抗拒的灾祸,决不会沦于穷困。他们生活的规范,是怎样呢?《礼记·王制》说:冢宰“以三十年之通制国用,量入以为出。”“三年耕,必有一年之食。九年耕,必有三年之食。以三十年之通,虽有凶旱水溢,民无菜色。”这在后来,虽然成为冢宰的职责,然其根源,则必是农村固有的规范。不幸而遇到凶年饥馑,是要合全部族的人,共谋节省的。此即所谓凶荒札丧的变礼。在古代,礼是人人需要遵守的。其所谓礼,都是切于生活的实际规则,并不是什么虚文。所以《礼记·礼器》说:“年虽大杀,众不恇惧,则上之制礼也节矣。”\n一团体之中,如有老弱残废的人,众人即无条件养活他。《礼记·王制》说:孤、独、鳏、寡,“皆有常饩”。又说:“喑、聋、跛、躃、断者(骨节断的人)、侏儒(体格不及标准。该包括一切发育不完全的人),百工各以其器食之。”旧说:看他会做什么工,就叫他做什么工。这解释怕是错的。这一句和上句,乃是互言以相备。说对孤、独、鳏、寡供给食料,可见对此等残废的人,亦供给食料;说对此等残废的人,供给器用,可见对孤、独、鳏、寡亦供给器用。乃古人语法如此。《荀子·王制篇》作“五疾上收而养之”可证。\n此等规则都实行了,确可使匹夫、匹妇,无不得其所的;而在古代,社会内部无甚矛盾之世,我们亦可以相信其曾经实行过的。如此,又何怪后人视其时为黄金时代呢?视古代为黄金时代,不但中国,希腊人也有这样思想的。物质文明和社会组织,根本是两件事。讲物质文明,后世确是进步了。以社会组织论,断不能不承认是退步的。\n有许多遗迹,的确可使我们相信,在古代财产是公有的。《书经·酒诰篇》说:“群饮,汝勿佚,尽执拘以归于周,予其杀。”这是周朝在殷朝的旧土,施行酒禁时严厉的诰诫。施行酒禁不足怪,所可怪的,是当此酒禁严厉之时,何不在家独酌?何得还有群饮触犯禁令的人,致烦在上者之诰诫?然则其所好者,在于饮呢?还是在于群呢?不论什么事,根深柢固,就难于骤变了。汉时的赐酺,不也是许民群饮么?倘使人之所好,只在于饮而不在于群,赐酺还算得什么恩典?可见古人好群饮之习甚深。因其好群饮之习甚深,即可想见其在邃古时,曾有一个共食的习惯。家家做饭自己吃,已经是我们的耻辱了。《孟子》又引晏子说:“师行而粮食。”粮同量,谓留其自吃的部分,其余尽数充公。这在晏子时,变成虐政了,然推想其起源,则亦因储藏在人家的米,本非其所私有,不过借他的房屋储藏(更古则房屋亦非私有),所以公家仍可随意取去。\n以上所说,都是我们根据古籍所推想的大同时代的情形。虽然在古籍中,已经不是正式记载,而只是遗迹,然有迹则必有迹所自出之履,这是理无可疑的。然则到后来,此等制度,是如何破坏掉的呢?\n旷观大势,人类全部历史,不外自塞而趋于通。人是非不断和自然争斗,不能生存的。所联合的人愈多,则其对自然争斗的力愈强。所以文明的进步,无非是人类联合范围的扩大。然人类控制自然的力量进步了,控制自己的力量,却不能与之并进。于是天灾虽澹,而人祸复兴。\n人类的联合,有两种方法:一种是无分彼此,通力合作,一种则分出彼此的界限来。既分出彼此的界限,而又要享受他人劳动的结果,那就非于(甲)交易、(乙)掠夺两者之中,择行其一不可了。而在古代,掠夺的方法,且较交易为通行。在古代各种社会中,论文化,自以农业社会为最高;论富力,亦以农业社会为较厚,然却很容易被人征服。因为(一)农业社会,性质和平,不喜战斗。(二)资产笨重,难于迁移。(三)而猎牧社会,居无定所,去来飘忽,农业社会,即幸而战争获胜,亦很难犁庭扫穴,永绝后患。(四)他们既习于战斗,(五)又是以侵略为衣食饭碗的,得隙即来。农业社会,遂不得不于可以忍受的条件之下,承认纳贡而言和;久之,遂夷为农奴;再进一步,征服者与被征服者,关系愈益密切,遂合为一个社会,一为治人者,食于人者,一为治于人者,食人者了。封建时代阶级制度的成立,即缘于此(参看上章)。\n依情理推想,在此种阶级之下,治者对于被治者,似乎很容易为极端之剥削的。然(一)剥削者对于被剥削者,亦必须留有余地,乃能长保其剥削的资源。(二)剥削的宗旨,是在于享乐的,因而是懒惰的,能够达到剥削的目的就够了,何必干涉人家内部的事情?(三)而剥削者的权力,事实上亦或有所制限,被剥削者内部的事情,未必容其任意干涉。(四)况且两个社会相遇,武力或以进化较浅的社会为优强,组织必以进化较深的社会为坚凝。所以在军事上,或者进化较深的社会,反为进化较浅的社会所征服;在文化上,则总是进化较浅的社会,为进化较深的社会所同化的。职是故,被征服的社会,内部良好的组织,得以保存。一再传后,征服者或且为其所同化,而加入于其组织之中。古语说君者善群(这群字是动词,即组织之义),而其所以能群,则由于其能明分(见《荀子·王制》、《富国》两篇)。据此义,则征服之群之酋长,业已完全接受被征服之群之文化,依据其规则,负起组织的责任来了。当这时代,只有所谓君大夫,原来是征服之族者,拥有广大的封土,收入甚多,与平民相悬绝。此外,社会各方面的情形,还无甚变更。士,不过禄以代耕,其生活程度,与农夫相仿佛。农则井田之制仍存,工商亦仍无大利可牟。征服之族,要与被征服之族在经济上争利益者,亦有种种禁例,如“仕则不稼,田则不渔”之类(见《礼记·坊记》。《大学》:孟献子曰:“畜马乘,不察于鸡豚;伐冰之家,不畜牛羊。”董仲舒对策,说公仪子相鲁,之其家,见织帛,怒而出其妻;食于舍而茹葵,愠而拔其葵。曰:“吾已食禄,又夺园夫红女利乎?”此等,在后来为道德上的教条,在当初,疑有一种禁令)。然则社会的内部,还是和亲康乐的,不过在其上层,多养着一个寄生者罢了。虽然和寄生虫并存,还不至危及生命健康,总还算一个准健康体,夫是之谓小康。\n小康时代,又成过去,乱世就要来了。此其根源:(一)由初期的征服者,虽然凭恃武力,然其出身多在瘠苦之地,其生活本来是简陋的。凡人之习惯,大抵不易骤变,俭者之不易遽奢,犹奢者之不能复俭。所以开国之主,总是比较勤俭的。数传之后,嗣世之君,就都变成生于深宫之中,长于阿保之手的纨袴子弟了。其淫侈日甚,则其对于人民之剥削日重,社会上的良好规制,遂不免受其影响(如因政治不善,而人民对于公田耕作不热心,因此发生履亩而税的制度,使井田制度受其影响之类)。(二)则商业发达了,向来自行生产之物,可以不生产而求之于人;不甚生产之物,或反可多生产以与人交易。于是旧组织不复合理,而成为获利的障碍,就不免堕坏于无形了。旧的组织破坏了,新的组织,再不能受理性的支配,而一任事势的推迁。人就控制不住环境,而要受环境的支配了。\n当这时代,经济上的变迁,可以述其荦荦大端如下:\n一、因人口增加,土地渐感不足,而地代因之发生。在这情形之下,土地荒废了,觉得可惜,于是把向来田间的空地,留作道路和备蓄泄之用的,都加以垦辟,此即所谓“开阡陌”(开阡陌之开,即开垦之开。田间的陆地,总称阡陌。低地留作蓄水泄水之用的,总称沟洫。开阡陌时,自然把沟洫也填没了。参看朱子《开阡陌辨》)。这样一来,分地的标记没有了,自然可随意侵占,有土之君,利于租税之增加,自然也不加以禁止,或且加以倡导,此即孟子所谓“暴君污吏,必慢其经界”(《滕文公上篇》)。一方面靠暴力侵占,一方面靠财力收买,兼并的现象,就陆续发生了。\n二、山泽之地,向来作为公有的,先被有权力的封君封禁起来,后又逐渐入于私人之手(《史记·平准书》说:汉初山川、园池,自天子至于封君,皆各为私奉养。此即前代山泽之地。把向来公有的山泽,一旦作为私有,在汉初,决不会,也决不敢有这无理的措置,可见自秦以前,早已普遍加以封禁了。管子官山府海之论,虽然意在扩张国家的收入,非以供私人之用,然其将公有之地,加以封禁则同。《史记·货殖列传》所载诸大企业家,有从事于畜牧的,有从事于种树的,有从事于开矿的,都非占有山泽之地不行。这大约是从人君手里,以赏赐、租、买等方法取得的)。\n三、工业进化了,器用较昔时为进步,而工官的制造,未必随之进步。或且以人口增加而工官本身,未尝扩张,量的方面,亦发生问题。旧系家家自制之物,至此求之于市者,亦必逐渐增加。于是渐有从事于工业的人,其获利亦颇厚。\n四、商人,更为是时活跃的阶级。交换的事情多了,居间的商人,随之而增多,这是势所必至的。商业的性质,是最自利的。依据它的原理,必须以最低的价格(只要你肯卖)买进,最高的价格(只要你肯买)卖出。于是生产者、消费者同受剥削,而居间的阶级独肥。\n五、盈天地之间者皆物,本说不出什么是我的,什么是你的。所以分为我的、你的,乃因知道劳力的可贵,我花了劳力在上面的东西,就不肯白送给你。于是东西和东西,东西和劳力,劳力和劳力,都可以交换。于是发生了工资,发生了利息。在封建制度的初期,封君虽然霸占了许多财产,还颇能尽救济的责任,到后来,便要借此以博取利息了。孟子述晏子的话,说古代的巡狩,“春省耕而补不足,秋省敛而助不给”(《梁惠王下篇》)。而《战国策》载冯煖为孟尝君收债,尽焚其券以市义,就显示着这一个转变。较早的时代,只有封君是有钱的,所以也只有封君放债。后来私人有钱的渐多,困穷的亦渐众,自然放债取利的行为,渐渐的普遍了。\n六、在这时代,又有促进交易和放债的工具发生,是为货币的进步(别见《货币篇》)。货币愈进步,则其为用愈普遍,于是交易活泼,储蓄便利,就更增进人的贪欲(物过多则无用,所以在实物经济时代,往往有肯以之施济的。货币既兴,此物可以转变为他物,储蓄的亦只要储蓄其价值,就不容易觉得其过剩了)。\n在这种情形之下,就发生下列三种人:\n一、大地主。其中又分为(甲)田连阡陌及(乙)擅山泽之利的两种人。\n二、大工商家。古代的工业家,大抵自行贩卖,所以古人统称为商人。然从理论上剖析之,实包括工业家在内,如汉时所称之“盐铁”(谓制盐和鼓铸铁器的人)。其营业,即是侧重在制造方面的。\n三、子钱家。这是专以放债取息为营业的。要知道这时代的经济情形,最好是看《史记》的《货殖列传》。然《货殖列传》所载的,只是当时的大富豪。至于富力较逊,而性质相同的(小地主、小工商及小的高利贷者)那就书不胜书了。\n精神现象,总是随着生活环境而变迁的。人,是独力很难自立的,所以能够生存,无非是靠着互助。家族制度盛行,业已把人分成五口、八口的一个个的小单位。交易制度,普遍的代替了分配、互助之道,必以互相剥削之方法行之,遂更使人们的对立尖锐。人,在这种情形之下,要获得一个立足之地甚难,而要堕落下去则甚易。即使获得了一个立足之地,亦是非用强力,不易保持的。人们遂都汲汲惶惶,不可终日。董仲舒说:“天下攘攘,皆为利往;天下熙熙,皆为利来。”《史记·货殖列传》有一段,剖析当时所谓贤士、隐士、廉吏、廉贾、壮士、游侠、妓女、政客、打猎、赌博、方技、犯法的吏士、农、工、商贾,各种人的用心,断言他的内容,无一而非为利。而又总结之曰:“此有智尽能索耳,终不余力而让财矣。”《韩非子》说:无丰年旁入之利,而独以完给者,非力则俭。无饥寒疾病祸罪之殃,而独以贫穷者,非侈则惰。征敛于富人,以布施于贫家,是夺力俭而与侈惰(《显学篇》)。话似近情,然不知无丰年旁入之利,无饥寒疾病祸罪之殃的条件,成立甚难;而且侈惰亦是社会环境养成的。谁之罪?而独严切的责备不幸的人,这和“不独亲其亲,不独子其子”,“货恶其弃于地也,不必藏于己;力恶其不出于身也,不必为己”的精神,竟不像是同一种动物发出来的了。人心大变,此即所谓乱世。\n孔子所谓小康之世,大约从有史时代就开始的。因为我们有确实的历史,始于炎、黄之际,已经是一个干戈扰攘的世界了。至于乱世,其机缄,亦是早就潜伏的,而其大盛,则当在东周之后。因为封建制度,是自此以后,才大崩溃的(封建制度的崩溃,不是什么单纯的政治作用,实在是社会文化进步,而后政治作用随之的,已见第三十九章。新文化的进步,就是旧组织的崩溃)。然在东周以后,社会的旧组织,虽已崩溃,而人们心上,还都觉得这新成立的秩序为不安;认为它是变态,当有以矫正之。于是有两汉时代不断的社会改革运动。酝酿久之,到底有新室的大改革。这大改革失败了,人们才承认社会组织的不良,为与生俱来,无可如何之事,把病态认为常态了。所以我说小康的一期,当终于新室之末。\n汉代人的议论,我们要是肯细看,便可觉得他和后世的议论,绝不相同。后世的议论,都是把社会组织的缺陷,认为无可如何的事,至多只能去其太甚。汉代人的议论,则总是想彻底改革的。这个,只要看最著名的贾谊、董仲舒的议论,便可见得。若能细读《汉书》的《王贡两龚鲍》和《眭两夏侯京翼李传》,就更可明白了。但他们有一个通弊,就是不知道治者和被治者,根本上是两个对立的阶级。不知领导被压迫阶级,以图革命,而专想借压迫阶级之力,以为人民谋解放。他们误以为治者阶级,便是代表全社会的正义的,而不知道这只是治者阶级中的最少数。实际,政治上的治者阶级,便是经济上的压迫阶级,总是想榨取被治阶级(即经济上的被压迫阶级)以牟利的。治者阶级中最上层的少数人,只是立于两者之间,使此两阶级得以保持一个均衡,而实际上还是偏于治者一方面些。要想以它为发力机,鼓动了多数治者,为被治者谋幸福,真是缘木求鱼,在理论上决不容有这回事。理所可有,而不能实现之事多矣,理所必无,而能侥幸成功之事,未之前闻。这种错误,固然是时代为之,怪不得古人。然而不能有成之事,总是不能有成,则社会科学上的定律,和自然科学上的定律,一样固定,决不会有例外。\n在东周之世,社会上即已发生两种思潮:一是儒家,主张平均地权,其具体办法,是恢复井田制度。一是法家,主张节制资本,其具体办法,是(甲)大事业官营;(乙)大商业和民间的借贷,亦由公家加以干涉(见《管子·轻重》各篇)。汉代还是如此。汉代儒家的宗旨,也是要恢复井田的。因为事不易行,所以让步到“限民名田”。其议发于董仲舒。哀帝时,师丹辅政,业已定有办法,因为权戚所阻挠,未能实行。法家的主张,桑弘羊曾行之。其最重要的政策,是盐铁官卖及均输。均输是官营商业。令各地方,把商人所贩的出口货做贡赋,官贩卖之于别地方。弘羊的理论,略见《盐铁论》中。著《盐铁论》的桓宽,是反对桑弘羊的(《盐铁论》乃昭帝时弘羊和贤良文学辩论的话,桓宽把它整理记录下来的。贤良文学,都是治儒家之学的。弘羊则是法家,桓宽亦信儒家之学),其记录,未必会有利于弘羊,然而我们看其所记弘羊的话,仍觉得光焰万丈,可知历来以弘羊为言利之臣,专趋承武帝之意,替他搜括,实在是错误的。但弘羊虽有此种抱负,其筹款的目的是达到了,矫正社会经济的目的,则并未达到。汉朝所实行的政策,如减轻田租,重农抑商等,更其无实效可见了。直到汉末,王莽出来,才综合儒法两家的主张,行一断然的大改革。\n在中国经学史中,有一重公案,便是所谓今古文之争。今古文之争,固然自有其学术上的理由,然和政治的关系亦绝大。提倡古文学的刘歆、王莽,都是和政治很有关系的人。我们向来不大明白他们的理由,现在却全明白了。王莽是主张改革经济制度的人。他的改革,且要兼及于平均地权和节制资本两方面。今文经是只有平均地权的学说,而无节制资本的学说的。这时候,社会崇古的风气正盛。欲有所作为,不得不求其根据于古书。王莽要兼行节制资本的政策,自不得不有取于古文经了。这是旁文。我们现在且看王莽所行的政策:\n(一)他把天下的田,都名为王田(犹今言国有),奴婢名为私属,都不得卖买。男口不盈八,而田过一井的,分余田与九族乡党。\n(二)设立六筦之制:(甲)盐,(乙)酒,(丙)铁,(丁)山泽,(戊)五均赊贷,(己)铁布铜冶。其中五均赊贷一项,是控制商业及借贷的。余五项,系将广义的农业和工业,收归官营。\n(三)五均,《汉书·食货志》《注》引邓展,谓其出于河间献王所传的《乐语》、《乐元语》。臣瓒引其文云:“天子取诸侯之土,以立五均,则市无二贾,四民常均;强者不得困弱,富者不得要贫;则公家有余,恩及小民矣。”这是古代的官营商业。其为事实或法家的学说未可知,而要为王莽的政策所本。王莽的制度:是改长安东西市令,又于洛阳、邯郸、临淄、宛、成都五处,都设司市师(师是长官之意),各以四时仲月(二、五、八、十一月),定该区中货物的平价。货物实系有用而滞销的,照他的本钱买进。物价腾贵,超过平价一钱时(汉时钱价贵,故超过一钱,即为腾贵),则照平价出卖。又在司市师之下,设泉府丞(丞是副官的意思),经营各种事业的人,都要收税,名之为贡(其额按纯利十分之一)。泉府收了这一笔贡,用以借给困乏的人。因丧祭等事而借的,只还本,不取息,借以营利的,取年息十分之一。\n王莽的变法,成功的希望是不会有的,其理由已述于前。固然,王莽的行政手段很拙劣,但这只是枝节。即使手段很高强,亦不会有成功的希望。因为根本上注定要失败的事,决不是靠手段补救得来的。但是王莽的失败,不是王莽一个人的失败,乃是先秦以来言社会改革者公共的失败。因为王莽所行,并不是王莽一个人的意见,乃是先秦以来言社会改革者公共的意见。王莽只是集此等意见的大成。经过这一次改革失败之后,人遂群认根本改革为不可能,想把乱世逆挽之而至于小康的思想,从此告终了。中国的社会改革运动,至此遂告长期的停顿。\n虽然在停顿时期,枝节的改革,总还不能没有的。今亦略述其事如下:\n当这时代,最可纪念的,是平和的、不彻底的平均地权运动。激烈的井田政策既经绝望,平和的限民名田政策,还不能行,于是又有一种议论,说平均地权之策,当行之于大乱之后,地广人稀,土田无主之日。于是有晋朝的户调式,北魏的均田令,唐朝的租庸调法。这三法的要点是:(一)因年龄、属性之别,以定受田的多少。(二)在北魏的均田令中,有露田和桑田的区别。唐朝则名为口分田和世业田。桑田和世业田,是可以传世的,露田和口分田,则受之于官,仍要还之于官。(三)唐制又有宽狭乡之别。田亩之数,足以照法令授与的为宽乡,不足的为狭乡。狭乡授田,减宽乡之半。(四)有余田的乡,是要以给比连之乡的。州县亦是如此。(五)徙乡和贫无以葬的人,得卖世业田。自狭乡徙宽乡的,得并卖口分田(口分田非其所有,无可卖之理。这该是奖励人民从狭乡迁到宽乡去的意思。法律上的解释,等于官收其田而卖却之,而将卖田所得之款,发给为奖励费。许其自卖,只是手续简便些罢了)。(六)虽然如此,世业田仍有其一定制限,买进的不得超过此限度,在最小限度以内,亦不得再卖却。统观三法,立法之意,是不夺其私有之田,无田者则由官给,希冀减少反抗,以渐平均地权,其立法之意诚甚善。然其实行至何程度,则殊可疑(晋法定后,天下旋乱,曾否实行,论者甚至有怀疑的。北魏及唐,曾实行至何程度,历史上亦无明确的记载),即使实行了,而人总是有缓急的;缓急的时候,不能不希望通融,在私产制度之下,谁肯白借给你来?救济的事业,无论如何,是不能普遍的(救济事业之量,决不能等于社会上需要救济之量,这是有其理论上的根据的。因为救济人者,必先自觉有余,然后能斥其所余以救济人。然救济人者的生活程度,必高于所救济的人,因而他所拿出来的,均摊在众人头上,必不能使被救济者之生活程度,与救济之者相等。而人之觉得足不足,并不是物质上真有什么界限,而往往是和他人的生活状况相比较的。如此,故被救济者在心理上永无满足之时。又在现在的社会组织之下,一个人的财富,往往是从剥削他人得来的,而他的自觉有余必在先,斥其余以救济他人必在后。自剥削至于救济,其中必经过相当的时间。在此时间之中,被剥削者,必已负有很大的创伤,即使把所剥削去的全数都还了他,亦已不够回复,何况还不能全数还他呢),于是不得不有抵卖之品。而贫民是除田地之外,无物可以抵卖的。如此,地权即使一度平均,亦很难维持永久。何况并一度之平均而不可得呢?再者:要调剂土满和人满,总不能没有移民,而在现在的文化状况之下,移民又是很难实行的。所以此等平均地权的方法,不论事实,在理论上已是很难成立的了。据记载,唐朝当开元时,其法业已大坏。至德宗建中元年(民国纪元前1132年),杨炎为相,改租庸调法为两税法,人民有田无田,田多田少,就无人过问了。自晋武帝太康元年(民国纪元前1632年),平吴行户调法至此,前后适五百年。自此以后,国家遂无复平均地权的政策。间或丈量,不过为平均赋税起见,而亦多不能彻底澄清。兼并现象,依然如故,其中最厉害的,为南宋时浙西一带的兼并。因为这时候,建都在临安,浙西一带,阔人多了,竞以兼并为事。收租奇重。宋末,贾似道要筹款,就用低价硬买做官田。田主固然破产了。佃户自此要向官家交租,又非向私家交租时“额重纳轻”之比,人民已受了一次大害。到明初平张士诚,太祖恶其民为士诚守,对于苏松、嘉湖之田,又定以私租为官税。后来虽屡经减免,直到现在,这一带田赋之重,还甲于全国。兼并的影响,亦可谓深了。\n物价的高低,东汉以后,更无人能加以干涉。只有食粮,关系人民的利害太切了,国家还不能全然放任。安定谷价的理论,始于李悝。李俚说籴(谷价),甚贱伤农,甚贵伤民(此民字指谷之消费者,与农为谷之生产者立于对待的地位),主张当新谷登场时,国家收买其一部分,至青黄不接时卖出,以保持谷的平价。汉宣帝时,谷价大贱,大司农耿寿昌,于若干地方行其法,名其仓为常平仓。此法虽不为牟利起见,然卖出之价,必比买进之价略高,国家并无所费,而人民实受其益,实可称法良意美。然在古代,谷物卖买未盛则有效。至后世,谷物的市场日广,而官家的资本甚微,则即使实力奉行,亦难收控制市场之效;何况奉行者又多有名无实,甚或并其名而无之呢?所以常平仓在历代法令上,虽然是有的时候多,实际上并无效力。隋文帝时,工部尚书长孙平创义仓之法,令人民于收成之日,随意功课,即于当社立仓存贮。荒歉之时,用以救济。后周时有惠民仓。将杂配钱(一种杂税的名目)的几分之几,折收谷物,以供凶年平籴之用。宋时又有广惠仓。募人耕没入和户绝田,收其租以给郭内穷苦的人民。这都是救济性质。直到王安石出来,行青苗法,才推广之,以供借贷之用。青苗法是起于李参的。李参在陕西做官时,命百姓自度耕种的赢余,告贷于官。官贷之以钱。乃秋,随赋税交还。王安石推行其法于诸路。以常平、广惠仓所储的钱谷为贷本(仓本所以贮谷,后世因谷的储藏不便,亦且不能必得,遂有兼储钱的。需用时再以钱买谷,或竟发钱),当时反对者甚多,然其本意是好的,不过官不是推行此法的机关,不免有弊罢了(反对青苗的人,有的说它取息二分太重,这是胡说,当时民间利率,实远重于此。青苗之弊:在于(一)人民不敢与官交涉。(二)官亦不能与民直接,势必假手于吏胥,吏胥多数是要作弊的,人民更不敢与之交涉。(三)于是听其自然,即不能推行。(四)强要推行,即不免抑配。(五)借出之款,或不能偿还,势必引起追呼。(六)又有勒令邻保均赔的。(七)甚有无赖子弟,谩昧尊长,钱不入家。或他人冒名诈请,莫知为谁的。总而言之,是由于办理的机关的不适宜)。南宋孝宗乾道四年,建州大饥。朱子请于府,得常平仓粟六百石,以为贷本。人民夏天来借的,到冬加二归还。以后逐年如此。小荒则免其半息,大荒则全免其息。如此十四年,除将原本六百石还官外,并将余利,造成仓廒,得粟三千一百石,以为社仓。自此借贷就不再收息了。朱子此法,其以社为范围,与长孙平的义仓同。不但充平籴及救济,而兼供借贷,与王安石的青苗法同。以社为范围,则易于管理,易于监察,人民可以自司其事。如此,则有将死藏的仓谷出贷,化为有用的资本之利,而无青苗法与官交涉之弊。所以历来论者,都以为此法最善;有与其提倡常平、义仓,不如提倡社仓的倾向。义仓不如社仓,诚然无可争辩,这是后起者自然的进步。常平和社仓,则根本不是一件事。常平是官办的,是和粮食商人斗争的。义仓和社仓,都是农民互助的事。固然,农民真正充足了,商人将无所施其剥削,然使将现在社会上一切剥削农民之事,都铲除了,农民又何至于不足呢?固然,当时的常平仓,并没有控制市场之力;至多当饥荒之际,开办平籴,惠及城市之人。然此乃常平办理之不得其法,力量的不够,并不是其本质不好。依正义及经济政策论,国家扶助农民和消费者,铲除居间者的剥削,还是有这义务,而在政策上也是必要的。所以常平和社仓,至少该并行不废。再者,青苗法以官主其事,固然不好,社仓以人民主其事,也未必一定会好的。因为土豪劣绅,和贪官污吏,是同样要吮人膏血的,并无彼此之分。主张社仓的,说社仓范围小,十目所视,十手所指,管理的人,难于作弊。然而从来土豪劣绅,都是明中把持、攘夺,并不是暗中攫取的。义仓创办未几,即或因人民不能管理,而移之于县。社仓,据《文献通考》说:亦是“事久而弊,或主之者倚公以行私,或官司移用而无可给,或拘纳息米而未尝除,甚者拘催无异正赋”。以为非有“仁人君子,以公心推而行之”不为功。可见防止贪污土劣的侵渔,仍不能无藉于人民的自卫了。平抑粮食以外他种物价之事,东汉以后无之。只有宋神宗熙宁五年,曾立市易司,想平抑京师的物价,然其后事未能行。\n●卖田契\n借贷,亦始终是剥削的一种方法。最初只有封君之类是有钱的人,所以也只有他们能营高利贷的事业。后来事实虽然变换了,还有借他们出面的。如《汉书·谷永传》说:当时的掖庭狱,“为人起债(代人放债),分利受谢”是。亦有官自放债的。如隋初尝给内官以公廨钱,令其回易生利,这种公廨钱,就是可以放债的。其类乎封建财产的,则南北朝以后,僧寺颇多殷富,亦常为放债的机关。私人放债取利,较大的,多为商贾所兼营,如《后汉书·桓谭传》:谭上疏陈时政,说:“今富商大贾,多放钱货,中家子弟,为之保役”,则并有代他奔走的人了。《元史·耶律楚材传》说:当时的回鹘,多放羊羔利(利上起利)。回纥也是从西域到中国来经商的。这是因商人手中,多有流动资本,所以兼营此业最便。至于土豪劣绅之类,即在本地方营高利贷业的,其规模自然较此为小,然其数则甚多,而其手段亦极酷辣。《宋史·食货志》载司马光疏,说当时的农民,“幸而收成,公私之债,交争互夺;谷未离场,帛未下机,已非己有”;《陈舜俞传》说:当时放债的人,虽“约偿缗钱,而谷粟、布缕、鱼盐、薪蔌、耰锄、斧锜之属,皆杂取之”;便可见其一斑了。大抵借贷有对人信用和对物信用两种。对物信用,须能鉴别其物,知其时价;对人信用,则须调查其人之财产及行为,亦有一番事情,且须有相当知识。这在放债者方面,亦须有一种组织。所以逐渐发达,而成为近代的钱庄及当铺。\n中国历代,社会上的思想,都是主张均贫富的,这是其在近代所以易于接受社会主义的一个原因。然其宗旨虽善,而其所主张的方法,则有未善。这因历代学者,受传统思想的影响太深,而对于现实的观察太浅之故。在中国,思想界的权威,无疑是儒家。儒家对于社会经济的发展,认识本不如法家的深刻,所以只主张平均地权,而忽略了资本的作用。这在当时,还无怪其然(古代学问的发达,不能不为地域所限。儒学盛于鲁。法家之学,托诸管子,疑其初盛于齐。《史记·货殖列传》说:太公封于齐,地泻卤,人民寡,太公劝女工,极技巧,通鱼盐,人物归之,襁至而辐凑,齐冠带衣履天下。这或者出于附会。然齐鱼盐工商之业皆盛,则是不诬的。齐国在当时,资本必较发达,所以节制资本的思想,就起于其地了),然至后世,学者的眼光,仍限于这一个圈子里,就可怪了。如前述汉代儒家的议论,即其一证。宋学兴起,在中国思想界,是最有特色的。宋儒亦很留心于政治和社会问题。而纯粹的宋学家,亦只重视复井田为致太平之策,那又是其一证。然此犹其小者。至其大者,则未审国家的性质。不知国家是阶级时代的产物,治者阶级,总是要剥削被治者以牟利的。其中虽有少数大公无我的人,然而总只是少数。其力量,较诸大多数的通常人,远觉绵薄。即使这少数人而得位乘时,使其监督大多数人,不敢放手虐民,即所谓去其泰甚,已觉得异常吃力。至于根本上改变其性质,则其事必不可能。如此,所以历代所谓治世的政治,往往是趋于放任的;而一行干涉的政策,则往往召乱。然则但靠国家之力,如何能均平贫富呢?新莽以此失败了,而后世的人,还是这种思想。我们试看王安石的《度支副使厅壁题名记》,他说:“合天下之众者财,理天下之财者法,守天下之法者吏也。吏不良,则有法而莫守;法不善,则有财而莫理;有财而莫理,则阡陌闾巷之贱人,皆能私取予之势,擅万物之利,以与人主争黔首,而放其无穷之欲;非必贵强桀大而后能如是;而天子犹为不失其民者,盖特号而已耳。虽欲食蔬衣敝,憔悴其身,愁思其心,以幸天下之给足而安吾政,吾知其犹不得也。然则善吾法而择吏以守之,以理天下之财,虽上古尧、舜,犹不能毋以此为急务,而况于后世之纷纷乎?”他看得天下之物,是天下人所公有;当由一个代表正义的人,为之公平分配,而不当由自私自利的人,擅其利而私其取予,以役使众人;其意昭然若揭。然欲以此重任,责之于后世的所谓天子,云胡可得呢?中国读书人所以有这思想,是因为其受传统思想的影响太深,在传统思想上,说这本是君之责任故。然在极古的时代,君权大而其所治之国小;而且大同时代的规则,尚未尽废,或者可以做到几分。在后世,则虽甚神圣,亦苦无下手之处了。而中国讲改革的人,都希望着他,如何能不失败呢?龚自珍是近代最有思想的人。他的文集里,有一篇文章,标题为《平均篇》,畅发一切乱源,根本都在经济上分配的不平。最高的治法,是能使之平均。就其现象,与之相安,则不足道。其观察亦可谓极深刻。然问其方法,则仍是希望握政权者,审察各方面的情形,而有以措置之,则仍是一条不通的路而已。龚氏是距离现在不过百年的人,而其思想如此,可见旧日的学者,其思想,全然局限于这一个范围之中。这是时代为之,自然怪不得古人。然在今日,却亦不可不知道昔人所走的路,是一条不通的路,而再奉其思想为金科玉律。\n现代的经济情形,和从前又大不相同了。自从西力东侵以来,我们的经济,已非复闭关独立之世,而与世界息息相通。在工业革命以前,最活跃的是商人阶级。所以历代的议论,都主张重农抑商。自工业革命以后,则商人反成为工业家的附属,不过略沾其余润,所以中国推销洋货的人,即世所称为买办阶级者,在中国社会里,虽俨然是个富豪,而以世界眼光观之,则仍不免在小贫之列。在现代的经济状况之下,断不容我们固步自封。世界的经济情形,自从工业发达了,积集的资本遂多,而金融资本,又极跋扈。工业品是要寻求销路的,而且还要霸占资源,就是固定和流动的资本,也要输出国外,皆不得不以武力保其安全。于是资本主义发展而成为帝国主义。历代的劳资对立,资本家是在国内的,现在则资本家在国外。于是民生问题和民族问题,并为一谈,再不能分离解决了。我们现在,该如何审慎、勇敢、强毅,以应付这一个目前的大问题呢?\n第四十二章 官制 # 官制是政治制度中最繁复的一门。(一)历代设官既多,(二)而又时有变迁。(三)它的变迁,又不是审察事实和制度不合,而条理系统地改正的,而是听其迁流之所至。于是有有其名而无其实的,亦有有其实而无其名的。名实既不相符,循其名遂不能知其实。而各官的分职,亦多无理论可循。求明白其真相,就很不容易了。然官制毕竟是政治的纲领。因为国家要达其目的,必须有人以行之。这行之之人,就是所谓官。所以明于一时代所设之官,即能知其时所行之政。对于历代的官制,若能知其变迁,即亦能知其政治的变迁了。\n人的见解,总是较时代落后一些的。时代只有新的,而人之所知,却限于旧。对付未来的方法,总是根据既往的情形,斟酌而出之的。所以无论如何,不能全合。制度才定出来,即已不适于用。制度是拗不过事实的,(一)非格不能行,(二)即名存实亡,这是一切制度都如此的,而官制亦不能例外。我国的官制,大略可分为六期:(一)自周以前,为列国时代的制度。(二)而秦及汉初统一时代的制度,即孕育于其末期。(三)因其大体自列国时代蜕化而来,和统一时代不甚适合,不久即生变迁。各方面变迁的结果,极其错杂不整。直至唐朝,才整理之,成为一种有系统的制度。(四)然整理甫经就绪,又和事实不符。唐中叶以后,又生变迁,而宋朝沿袭之。(五)元以异族,入主中原,其设施自有特别之处。明朝却沿袭着它。清朝的制度,又大略沿袭明朝。然因实际情形的不同,三朝的制度,又自有其大相违异之处。(六)清朝末叶,因为政体改变,官制亦随之改变。然行之未久,成效不著。直至今日,仍在动荡不定之中。以上略举其变迁的大概,以下再略加说明。因为时间所限,亦只能揭举其大纲而已。\n官有内外之分。内官即中央政府之官,是分事而治的。全国的政务,都汇集于此,依其性质而分类,一官管理一类的事。又有综合全般状况,以决定施政的方针的,是即所谓宰相。外官则分地而治。在其地界以内,原则上各事都要管的。出于地界以外,则各事一概不管。地方区划,又依等级而分大小。上级大的区划,包含若干下级小的区划。在行政上,下级须听上级的指挥。这是历代官制的通则。\n列国并立之世,到春秋战国时代,已和统一时代的制度相近了。因为此时期,大国之中,业已包含若干郡县。但其本身,仍只等于后世一个最大的政治区域。列国官制:今文家常说三公、九卿、二十七大夫、八十一元士。但这只是爵,没有说出他的职守来。三公依今文家说,是司马、司徒、司空。九卿无明文。古文家说,以太师、太傅、太保为三公。少师、少傅、少保为三孤。冢宰(天官)、司徒(地官)、宗伯(春官)、司马(夏官)、司寇(秋官)、司空(冬官),为六卿(许慎《五经异义》)。按今文说的三公,以配天、地、人(司马主天,司徒主人,司空主地)。古文说的六卿,以配天、地、四时。此外还有以五官配五行等说法(见《左传》昭公十七年、二十九年。《春秋繁露·五行相胜篇》)。这不过取古代的官,随意拣几个,编排起来,以合于学说的条理而已。和古代的事实,未必尽合。古代重要的官,不尽于此;并非这几个官特别重要,不过这几个官,亦是重要的罢了。司马是管军事的,司徒是统辖人民的,司空是管建设事务的。古代穴居,是就地面上凿一个窟窿,所以谓之司空(空即现在所用的孔字)。《周官》冬官亡佚,后人以《考工记》补之(其实这句话也靠不住。性质既不相同,安可相补?不过《考工记》也是讲官制的。和《周官》性质相类,昔人视为同类之书,合编在一起,后人遂误以为补罢了)。《周官》说实未尝谓司空掌工事,后世摹仿《周官》而设六部,却以工部拟司空,这是后人之误,不可以说古事的。冢宰总统百官,兼管宫内的事务,其初该是群仆的领袖。所以大夫之家亦有宰。至于天子诸侯,则实际本来差不多的。天子和诸侯、大国和小国制度上的差异,不过被著书的人说得如此整齐,和实际亦未必尽合。宗伯掌典礼,和政治关系最少,然在古代迷信较深之世,祭祀等典礼,是看得颇为隆重的。司寇掌刑法,其初当是军事裁判(说详第四十六章)。三公坐而论道,三孤为之副,均无职事。按《礼记·曾子问》说:“古者男子,内有傅,外有慈母。”《内则》说:国君世子生,“择于诸母与可者,必求其宽裕慈惠,温良恭俭,慎而寡言者,使为子师,其次为慈母,其次为保母。”太师、太傅、太保,正和师、慈、保三母相当。古夫亦训傅,两字盖本系一语,不可以称妇人,故变文言慈。然则古文的三公,其初乃系天子私人的侍从,本与政事无关系,所以无职事可言。《周官》说坐而论道之文,乃采诸《考工记》,然《考工记》此语(“坐而论道,谓之王公”),是指人君言,不是指大臣言的,说《周官》者实误采。总而言之:今文古说,都系春秋战国时的学说,未必和古代的事实密合。然后世厘定制度的人,多以经说为蓝本。所以虽非古代的事实,却是后世制度的渊源。\n列国时代的地方区划,其大的,不过是后世的乡镇。亦有两种说法:《尚书大传》说:“古八家而为邻,三邻而为朋,三朋而为里(七十二家,参看上章),五里而为邑,十邑而为都,十都而为师,州十有二师焉。”这是今文说。《周官》则乡以五家为比,比有长。五比为闾,闾有胥。四闾为族,族有师。五族为党,党有正。五党为州,州有长。五州为乡,乡有大夫。遂以五家为邻,邻有长。五邻为里,里有宰。四里为酂,酂有长。五酂为鄙,鄙有师。五鄙为县,县有正。五县为遂,遂有大夫。这是古文说。这两种说法,前者和井田之制相合,后者和军队编制相合,在古代该都是有的。后来井田之制破坏,所以什伍之制犹存,今文家所说的组织,就不可见了。\n汉初的官制,是沿袭秦朝的。秦制则沿自列国时代。中央最高的官为丞相。秦有左、右,汉通常只设一丞相。丞相之副为御史大夫(中央之官,都是分事而治的。只有御史是皇帝的秘书,于事亦无所不预,所以在事实上成为丞相的副手。汉时丞相出缺,往往以御史大夫升补),武官通称为尉。中央最高的武官,谓之太尉。这是秦及汉初的制度。今文经说行后,改太尉为司马,丞相为司徒,御史大夫为司空,谓之三公,并称相职。又以太常(本名奉常,掌宗庙礼仪)、光禄勋(本名郎中令,掌宫、殿,掖门户)、卫尉(掌宫门卫屯兵)、太仆(掌舆马)、廷尉(掌刑辟,尝改为大理)、大鸿胪(本名典客,掌归义蛮夷)、宗正(掌亲属)、大司农(本名治粟内史,掌谷货)、少府(掌山海池泽之税),为九卿。这不过取应经说而已,并无他种意义。三公分部九卿(太常、光禄勋、卫尉属司马,太仆、廷尉、大鸿胪属司徒,宗正、大司农、少府属司空),亦无理论根据。有大事仍合议。后汉司马仍称太尉。司徒、司空,均去大字,余皆如故。\n外官:秦时以郡统县。又于各郡都设监御史。汉不遣监御史,丞相遣使分察州(按州字并非当时的区域名称,后人无以名之,乃名之为州。所以截至成帝改置州牧以前,州字只是口中的称呼,并非法律上的名词)。武帝时,置部刺史十三人,奉诏书六条,分察诸郡(一、条察强宗巨家。二、条察太守侵渔聚敛。三、条察失刑。四、条察选举不平。五、条察子弟不法,都是专属太守的。六、条察太守阿附豪强)。成帝时,以何武之言,改为州牧。哀帝时复为刺史。后又改为州牧。后汉仍为刺史,而止十二州,一州属司隶校尉(武帝置,以治巫蛊的,后遂命其分察一部分郡国)。按《礼记·王制》说:“天子使其大夫为三监,监于方伯之国,国三人”,这或者附会周初的三监,说未必确,然天子遣使监视诸侯(实即大国之君,遣使监视其所封或所属的小国),则事所可有。大夫之爵,固较方伯为低。秦代御史之长,爵不过大夫。汉刺史秩仅六百石,太守则两千石。以卑临尊,必非特创之制,必然有所受之。以事实论,监察官宜用年少新进的人,任事的官,则宜用有阅历有资望之士,其措置亦很适宜的。何武说:“古之为治者,以尊临卑,不以卑临尊”,不但不合事宜,亦且不明经义。旧制恢复,由于朱博,其议论具载《汉书》,较之何武,通达多了。太守,秦朝本单称守,汉景帝改名。秦又于各郡置尉,景帝亦改为都尉。京师之地,秦时为内史所治。汉武帝改称京兆尹,又分其地置左冯翊、右扶风,谓之三辅。诸王之国,设官本和汉朝略同。亦有内史以治民。七国乱后,景帝乃令诸侯王不得自治民,改其丞相之名为相,使之治民,和郡守一样。县的长官,其秩是以户数多少分高下的。民满万户以上称令,不满万户称长。这由于古代的政治,是属人主义,而非属地主义之故。侯国的等级,与县相同。皇太后、公主所食的县称为邑。县中兼有蛮夷的谓之道。这亦是封建制度和属人主义的色彩。\n●汉代官制\n秦汉时的县,就是古代的国,读第三十九章可见。县令就是古代的国君,只能总握政治的枢机,发踪指示,监督其下。要他直接办事,是做不到的。所以真正的民政,非靠地方自治不可。后世地方自治之制,日以废坠,所以百事俱废。秦汉时则还不然。据《汉书·百官公卿表》和《续汉书·百官志》:其时的制度系以十家为什,五家为伍,一里百家,有里魁检察善恶,以告监官。十里一亭,亭有长。十亭一乡,乡有三老,有秩啬夫、游徼。三老管教化,体制最尊。啬夫职听讼,收赋税,其权尤重。人民竟有知啬夫而不知有郡县的(见《后汉书·爰延传》),和后世绝不相同。\n以上所述,是秦及汉初的制度。行之未几,就起变迁了。汉代的丞相,体制颇尊,权限亦广。所谓尚书,乃系替天子管文书的,犹之管衣服的谓之尚衣,管食物的谓之尚食,不过是现在的管卷之流。其初本用士人,汉武帝游宴后庭,才改用宦官,谓之中书谒者令。武帝死后,此官本可废去,然自武帝以来,大将军成为武官中的高职。昭宣之世,霍光以大将军掌握政权。其时的丞相,都是无用或年老的人,政事悉从中出,沿袭未改。成帝时,才罢中书宦官,然尚书仍为政本,分曹渐广。后汉光武,要行督责之术。因为宰相都是位高望重的人,不便督责他,于是崇以虚名,而政事悉责成尚书。尚书之权遂更大。魏武帝握权,废三公,恢复丞相和御史大夫之职。此时相府复有大权,然只昙花一现。魏文帝篡汉后,丞相之官,遂废而不设。自魏晋至南北朝,大抵人臣将篡位时则一设之,已篡则又取消。此时的尚书,为政务所萃,然其亲近又不敌中书。中书是魏武帝为魏王时所设的秘书监,文帝篡位后改名的,常和天子面议机密。所以晋初荀勖从中书监迁尚书令,人家贺他,他就发怒道:“夺我凤皇池,诸君何贺焉”了。侍中是加官,在宫禁之中,伺候皇帝的。汉初多以名儒为之。从来贵戚子弟,多滥居其职。宋文帝自荆州入立,信任王府旧僚,都使之为侍中,与之谋诛徐羡之等,于是侍中亦参机要。至唐代,遂以中书、门下、尚书三省为相职。中书主取旨,门下主封驳,尚书承而行之。尚书诸曹,魏晋后增置愈广,皆有郎以办事。尚书亦有兼曹的。隋时,始以吏、户、礼、兵、刑、工六曹分统诸司。六曹皆置侍郎,诸司则但置郎,是为后世以六部分理全国政务之始。三公一类的官,魏晋后亦时有设置,都不与政事,然仍开府分曹,设置僚属。隋唐始仿《周官》,以太师、太傅、太保为三公,少师、少傅、少保为三孤,都不设官属。则真成一个虚名,于财政亦无所耗费了。九卿一类的官,以性质论,实在和六部重复的。然历代都相沿,未曾并废。御史大夫改为司空后,御史的机关仍在。其官且有增置。唐时分为三院:曰台院,侍御史属之。曰殿院,殿中侍御史属之。曰监院,监察御史属之。御史为天子耳目,历代专制君主,都要防臣下的壅蔽,所以其权日重。\n前汉的改刺史为州牧,为时甚暂。至后汉末年,情形就大不同了。后汉的改刺史为州牧,事在灵帝中平五年,因四方叛乱频仍,刘焉说由刺史望轻而起。普通的议论,都说自此以后,外权就重了。其实亦不尽然。在当时,并未将刺史尽行改作州牧(大抵资深者为牧,资浅者仍为刺史,亦有由刺史而升为牧的)。然无论其为刺史,为州牧,实际上都变成了郡的上级官,而非复监察之职。而且都有兵权,如此,自然要尾大不掉了。三国分离,刺史握兵之制,迄未尝改。其为乱源,在当时是人人知道的。所以晋武帝平吴后,立即罢州牧,省刺史的兵,去其行政之权,复还监察之职。这真是久安长治之规。惜乎“虽有其言,不卒其事。”(《续汉书·百官志》《注》语)。而后世论者,转以晋武帝的罢州郡兵备,为召乱的根源,真是徇名而不察其实了。东晋以后,五胡扰乱,人民到处流离播迁,这时候的政治,还是带有属人主义的。于是随处侨置州郡,州的疆域,遂愈缩愈小,浸至与郡无异了(汉朝只有十三州,梁朝的疆域,远小于汉,倒有一百零七州)。此时外权之重,则有所谓都督军事,有以一人而督数州的,亦有以一人而督十数州的。甚至有称都督中外诸军的。晋南北朝,都是如此。后周则称为总管。隋时,并州郡为一级(文帝开皇三年,罢郡,以州统县,职同郡守。炀帝改州为郡),并罢都督府。唐初,又有大总管、总管,后改称大都督、都督,后又罢之。分天下为若干道,设观察使等官,还于监察之旧。\n唐代的官制,乃系就东汉、魏、晋、南北朝的制度,整理而成的。其实未必尽合当时的时势。所以定制未几,变迁又起。三省长官,都不除人。但就他官加一同中书门下平章事等名目,就视为相职了。而此两省的长官,实亦仍合议于政事堂,并非事后审查封驳。都督虽经废去,然中叶以后,又有所谓节度使(参看第四十五章),所驻扎的地方,刺史多由其兼领。支郡的刺史,亦都被其压迫而失职。其专横,反较前代的刺史更甚。这两端,是变迁最大的。而中叶以后,立检校、试、摄、判、知等名目,用人多不依资格,又为宋朝以差遣治事的根源。\n宋朝设中书省于禁中。宰相称同平章事,次相称参知政事。自唐中叶以后,户部不能尽总天下的财赋,分属于度支、盐铁二使。宋朝即合户部、度支、盐铁为三司,各设使、副,分案办事。又设三司使副以总之,号为计相。枢密使,唐时以宦官为之,本主传达诏命。后因宦官握兵,遂变为参与兵谋之官。宋朝亦以枢密院主兵谋。指挥使,本藩镇手下的军官。梁太祖篡位后,未加改革,遂成天子亲军。宋朝的禁军,都隶属殿前司、侍卫马军亲军司、侍卫步军亲军司。各设指挥使,谓之三衙。宋初的官,仅以寓禄秩(即借以表明其官有多大,所食的俸禄有多少),而别以差遣治事。名为某官的人,该官的职守,都是与他无涉的。从表面上看来,可谓错乱已极。但差遣的存废、离合,都较官缺为自由,可以密合事情。所以康有为所著《官制议》,有《宋官制最善》一篇,极称道其制。宋朝的改革官制,事在神宗元丰中,以《唐六典》为模范,然卒不能尽行。以三省长官为相职之制,屡经变迁,卒仍复于一个同平章事,一个参知政事之旧;枢密主兵之制,本来未能革除;三衙之制,亦未能改,便可见其一斑。\n宋初惩藩镇的跋扈,悉召诸节镇入朝,赐第留之京师,而命朝臣出守列郡,谓之权知军州事。特设通判,以分其权。县令亦命京朝官出知,以削藩镇之权,而重亲民之选。特设的使官最多。其重要的,如转运使,总一路的财赋;发运使,漕淮、浙、江、湖六路之粟。他如常平茶盐、茶马、坑冶、市舶,亦都设立提举司,以集事权于中央。太宗命诸路转运使,各命常参官一人,纠察州军刑狱。真宗时,遂独立为一司,称为提点刑狱,简称提刑。是为司法事务,设司监察之始。南渡后,四川有总领财赋。三宣抚司罢后(见第四十五章),亦设总领以筹其饷。仍带专一报发御前军马文字衔,则参预并及于军政了。\n元朝以中书省为相职,枢密使主兵谋,御史台司纠察。尚书省之设,专以位置言利之臣。言利之臣败,省亦旋废。而六部仍存,为明清两朝制度所本。设宣政院于中央,以辖吐蕃之境,亦为清代理藩院之制所本。元代制度,关系最大的是行省。前代的尚书行台等,都是暂设的,以应付临时之事,事定即撤。元朝却于中原之地,设行中书省十,行御史台二,以统辖路府州县。明朝虽废之而设布政、按察两司,区域则仍元之旧。清朝又仍明之旧。虽然略有分析,还是庞大无伦,遂开施政粗疏,尾大不掉之渐了。唐初,惟京兆、河南称府设尹,后来梁州以为德宗所巡幸,亦升为兴元府。宋朝则大州皆升为府,几有无州不府之势。其监司所辖的区域则称为路。元于各路设宣慰司,以领府州县而上属于省。然府亦有不隶路而直隶于省的。州有隶于府的,亦有不隶于府,而直隶于路的,其制度殊为错杂。\n明清两朝的制度,大体相沿。其中关系最大的,在内为宰相的废罢,在外为省制的形成。明初本亦设中书省,以为相职。后因胡惟庸谋反,大祖遂废其官,并谕后世子孙,不得议设宰相。臣下有请设宰相的,处以极刑。于是由天子亲领六部。此非嗣世之主所能,其权遂渐入殿阁学士之手。清世宗时,又设立军机处。机要之事,均由军机处径行,事后才下内阁,内阁就渐渐的疏阔了。六部:历代皆以尚书为主,侍郎为副。清代尚侍皆满汉并置。吏、户、兵三部,又有管部大臣,以至权责不一。明废宰相后,政务本由六部直接处理。后虽见压于内阁,究竟权力还在。吏、兵二部,尤真有用人及指挥军事之权,清朝则内官五品,外官道府以上,全由内阁主持。筹边之权,全在军机。又明朝六部用人,多取少年新进,清朝则一循资格,内官迁转极难,非六七十不得至尚侍。管部又系兼差,不能负责。于是事事照例敷衍,行政全无生气了。\n御史一官,至明代而其权益重,改名为都察院。都御史、副都御史、佥都御史均分置左、右。又有分道的监察御史。在外则巡按清军、提督学校、巡漕、巡盐等事,一以委之,而巡按御史代天子巡狩,其权尤重。这即是汉朝刺史之职。既有巡按,本可不必再行遣使。即或有特别事务,非遣使不可,亦以少为佳。然后来所谓巡抚者,愈遣而愈频繁。因其与巡按御史不相统属,权限不免冲突,乃派都御史为之。其兼军务的加提督衔,辖多事重的,则称总督。清代总督均兼兵部尚书,右都御史、巡抚均兼兵部侍郎,右副都御史,又均有提督军务,兼理粮饷之衔,成为常设的官了。给事中一官,前代都隶门下省。明废门下省,而仍存给事中,独立为一官,分吏、户、礼、兵、刑、工六科,以司审查封驳。其所驳正,谓之科参,在明代是很有权威的,清世宗将给事中隶属于都察院,就将审查和纠察,混为一谈了。翰林在唐朝,为艺能之士如(书、画、弈棋等)待诏之所,称为杂流,与学士资望悬绝,玄宗时,命文学之士居翰林中,称为供奉。与集贤殿学士,分掌制诰。后改称为学士,别立学士院,即以翰林名之。中叶后颇参机密,王叔文要除宦官,即居翰林中,可见其地位的重要。宋代专以居文学之士,其望愈清。至明中叶后,则非进士不入翰林,非翰林不入内阁,六部长官,亦多自此而出。其重要,更非前代所及了。\n外官:明废行省,于府州之上,设布政、按察两司,分理民政及刑事,实仍为监司之官。监司之官,侵夺地方官权限,本来在所不免。清代督抚既成为常设之官,又明代布政司的参政参议,分守各道,按察司的副使佥事,分巡各道的,至清朝,亦失其本来的性质,而在司府之间,俨若别成为一级。以府州领县,为唐宋相沿之制。元时,令知州兼理附郭县事,明时遂省县入州,于是州无附郭县。又有不领县而隶属于府的,遂有直隶州与散州之别。清时,同知、通判有驻地的谓之厅,亦或属于府,或直达布政司,称为散厅及直隶厅。地方制度,既极错杂。而(一)督抚,(二)司,(三)道,(四)府、直隶州、厅,(五)县、散州、厅,实际成为五级。上级的威权愈大,下级的展布愈难。积弊之深,和末造中央威权的不振,虽有别种原因,官制的不善,是不能不尸其咎的。\n藩属之地,历代都不设官治理其民,而只设官监督其酋长,清朝还是如此的。奉天、吉林、黑龙江三省,清朝称为发祥之地。其实真属于满洲部落的,不过兴京一隅。此外奉天全省,即前代的辽东、西,本系中国之地。吉、黑二省,亦是分属许多部落的,并非满洲所有。此等人民,尚在部落时代,自不能治以郡县制度。清朝又立意封锁东三省,不许汉人移殖。所以其治理之法,不但不能进步,而反有趋于退步之势。奉天一省,只有奉天和锦州二府,其余均治以将军、副都统等军职。蒙古、新疆、西藏,亦都治以驻防之官。这个固然历代都是如此,然清朝适当西力东侵之时,就要情见势绌了。末年回乱平后,改新疆为行省。日俄战后,改东三省为行省。蒙古、西藏,亦图改省,而未能成功。藩属之地,骤图改省,是不易办到的。不但该地方的人民,感觉不安。即使侥幸成功,中国亦无治理其地的人才。蒙、藏的情形,和新疆、东三省是不同的。东三省汉人已占多数,新疆汉人亦较多,蒙、藏则异于是。自清末至民国初年,最好是将联邦之法,推行之于蒙、藏,中央操外交、军事、交通、币制之权,余则听其自治。清季既不审外藩情势,和内地的不同,操之过急,以致激而生变。民国初年,又不能改弦易辙,许其自治,以生其回面内向之心,杜绝强邻的觊觎。因循既久,收拾愈难,这真是贾生所说,可为痛哭、流涕、长太息的了。\n以上是中国的旧官制,中西交通以来,自然不能没有变动。其首先设立的,是总理各国事务衙门。实因咸丰八年,中英《天津条约》规定要就大学士、尚书中简定一员,和英国使臣接洽而起,不过迫于无可如何,并非有意改革。内乱平后,意欲振兴海军,乃设立海军衙门。后来却将其经费,移以修理颐和园,于是中日战后,海军衙门反而裁撤了。庚子以后,又因条约,改总理衙门为外务部,班列六部之前。其时举办新政,随事设立了许多部处。立宪议起,改革旧官制,增设新机关,共成外务、吏、民政(新设的巡警部改)、度支(户部改。新设的财政处、税务处并入)、礼(太常、光禄、鸿胪三寺并入)、学(新设的学务处改,国子监并入)、陆军(兵部改,太仆寺和新设的练兵处并入)、农工商(工部改,新设的商部并入)、邮传、理藩(理藩院改)、法(刑部改)十一部,除外务部有管理事务大臣、会办大臣各一人外,余均设尚书一人、侍郎二人,不分满汉。都察院亦改设都御史一人、副都御史二人(前此左都御史,满汉各一。左副都御史各二。右都御史、副都御史但为督抚兼衔)。大理寺改为院,以司最高审判。宣统二年,立责任内阁,设总协理大臣。裁军机处及新设的政务处及吏、礼二部(其事务并入内阁),而增设海军部及军谘府(今之参谋部)。改尚书为大臣,与总协理负连带责任。外官则仍以督抚为长官。于其下设布政、提法(按察司改)、提学、盐运、交涉五司,劝业、巡警二道,而裁分巡、分守道。此等制度,行之为日甚浅,初无功过可言。若从理论上评论:内官增设新官,将旧官删除归并,在行政系统上,自然较为分明,于事实亦较适切。若论外官,则清末之所以尾大不掉,行政粗疏,其症结实在于省制。当时论者,亦多加以攻击。然竟未能改革,相沿以迄于今,这一点不改革,就全部官制,都没有更新的精神了。\n民国成立,《临时政府组织大纲》定行政分五部,为外交、内务、财政、军务、交通。这是根据理论规定的,后修改此条。设陆军、海军、外交、司法、财政、内务、教育、实业、交通九部。其时采美国制,不设总理。孙文逊位后,袁世凯就职北京,《临时政府组织大纲》改为《临时约法》,设总理,分实业为农林、工商二部。三年,袁世凯召开约法会议修改《临时约法》为《中华民国约法》(即所谓《新约法》)。复废总理,设国务卿,并农林、工商二部为农商部。袁世凯死后,黎元洪为总统,复设总理。外官:民军起义时,掌握一省军权的称都督。管理民政的称民政长。废司,道,府,直隶州、厅及散州、厅的名称,但存县。袁世凯改都督为将军,民政长为巡按使,于其下设道尹。护国军起,掌军权的人,复称都督。黎元洪为总统,改将军、都督都称督军,巡按使称省长。其兼握几省兵权,或所管之地,跨及数省的,则称巡阅使。裁兵议起,又改称督理或督办军务善后事宜,然其尾大不掉如故。国民党秉政,在训政时期内,以党代人民行使政权,而以国民政府行使治权。其根本精神,和历代的官制,大不相同,其事又当别论。\n无官之名,而许多行政事务,实在倚以办理的为吏。凡行政,必须依照一定的手续。因此职司行政的人,必须有一定的技术。这种技术,高级官员往往不甚娴习,甚或不能彻底通晓,非有受过教育,经过实习的专门人员以辅助之不可。此等责任,从前即落在胥吏肩上。所以行政之权,亦有一部分操于其手。失去了他,事情即将无从进行的。吏之弊,在于只知照例。照例就是依旧,于是凡事都无革新的精神。照例的意思,在于但求无过,于是凡事都只重形式,而不问实际。甚至利用其专门智识以舞弊。所以历来论政的人,无不深恶痛绝于吏,尤以前清时代为甚,然其论亦有所蔽。因为非常之事,固然紧要,寻常政务,实更为紧要而不可一日停滞。专重形式,诚然不好,然设形式上的统一不能保持,政治必将大乱。此前清末年,所以诏裁胥吏,而卒不能行。其实从前所谓吏,即现在所谓公务员,其职实极重要,而其人亦实不能缺。从前制度的不善,在于(一)视其人太低,于是其人不思进取,亦不求名誉,而惟利是图。(二)又其人太无学识,所以只能办极呆板的事。公务员固以技术为要,然学识亦不可全无,必有相当的学识,然后对于所行之政,能够通知其原理,不至因过于呆板而反失原意。又行政的人,能通知政治的原理,则成法的缺点,必能被其发现。于立法的裨益,实非浅鲜。昔时之胥吏,是断不足以语此的。(三)其尤大的,则在于无任用之法,听其私相传授,交结把持。自民国以来,因为政治之革新,法律的亟变,已非复旧时的胥吏所能通晓,所以其人渐归自然淘汰,然现在公务员的任用、考核,亦尚未尽合法,这是行政的基础部分,断不可不力求改良的。\n古代官职的大小,是以朝位和命数来决定的。所谓命数,就是车服之类的殊异。古人所以看得此等区别,甚为严重。然因封建制度的破坏,此等区别,终于不能维持了。朝位和俸禄的多少,虽可分别高低,终嫌其不甚明显,于是有官品之别。官品起于南北朝以来。南朝陈分九品。北朝魏则九品之中,复分正从;四品以下,且有上中下阶,较为复杂。宋以后乃专以九品分正从。官品之外,封爵仍在。又有勋官、散官等,以处闲散无事的官员。此等乃国家酬庸之典,和官品的作用,各不相同的。\n官俸,历代更厚薄不同,而要以近代之薄为最甚。古代大夫以上,各有封地。家之贫富,视其封地之大小、善恶,与官职的高下无关。无封地的,给之禄以代耕,是即所谓官俸。古代官俸,多用谷物,货币盛行以后,则钱谷并给。又有实物之给,又有给以公田的。明初尚有此制,不知何时废坠,专以银为官俸。而银价折合甚高,清朝又沿袭其制,于是官吏多苦贫穷。内官如部曹等,靠印结等费以自活,外官则靠火耗及陋规。上级官不亲民的,则诛求于下属。京官又靠外官的馈赠。总而言之,都是非法。然以近代官俸之薄,非此断无以自给的。而有等机关,收取此等非法的款项,实亦以其一部分支给行政费用,并非全入私囊。所以官俸的问题,极为复杂。清世宗时,曾因官俸之薄,加给养廉银,然仍不足支持。现代的官俸,较之清代,已稍觉其厚。然究尚失之于薄,而下级的公务员尤甚。又司法界的俸禄,较之行政界,不免相形见绌,这亦是亟须加以注意的。\n第四十三章 选举 # 国家,因为要达其目的,设立许多机关,这许多机关,都是要有人主持的。主持这些机关的人,用何法取得呢?这便是选举问题。\n选举是和世袭对立的。世袭之法,一个位置出缺,便有一个合法继承的人,不容加以选择。选举之法则不然,它是毫无限制,可以任有选举权者,选举最适宜的人去担任的。这是就纯粹的选举和世袭说;亦有从两方面说,都不很纯粹的,如虽可选择,仍限于某一些人之内之类是。但即使是不纯粹的选举,也总比纯粹的世袭好些。西洋某史家曾把中国两汉时代的历史,和罗马相比较,他说:凡罗马衰亡的原因,中国都有的。却有一件事,为中国所有,罗马所无,那便是选举。观此,便知选举制度关系之重大了。\n选举制度,在三代以前,是与世袭并行的。俞正燮《癸巳类稿》,有一篇《乡兴贤能论》,说得最好,他说:古代的选举,是限于士以下的,大夫以上是世官。这是什么理由呢?第四十章已经说过:原始的政治,总是民主的,到后来,专制政治,才渐渐兴起,如其一个国家是以征服之族和被征服之族组成的,高级的位置自然不容被征服之族染指。即使原是一族,而专制政治既兴,掌握政权的人,也就渐渐的和群众离开了。所以选举仅限于士以下。\n士以下的选举,乃系古代部族,专制政治尚未兴起时的制度,留遗下来的。其遗迹略见于《周官》。据《周官》所载:凡是乡大夫的属官,都有考察其民德行道艺之责。三年大比,则举出其贤者能者,“献贤能之书于王”。《周官》说:“此之谓使民兴贤,入使治之;使民兴能,出使长之。”俞正燮说:入使治之,是用为乡吏(即比闾族党之长,见上章);出使长之,是用为伍长,这是不错的。比闾族党等,当系民主部族固有的组织,其首领,都是由大众公举的。专制政体兴起后,只是把一个强有力的组织,加于其上,而于此等团体固有的组织,并未加以破坏,所以其首领还是出于公举的,不过专制的政府,也要加以相当的参预干涉罢了(如虽由地方公举,然仍须献贤能之书于王)。\n在封建政体的初期,上级的君大夫等,其品性,或者比较优良,但到后来,就渐渐的腐化了。由于上级的腐化,和下级的进步(参看第四十章),主持国政者,为求政治整饬起见,不得不逐渐引用下级分子,乡间的贤能,渐有升用于朝廷的机会,那便是《礼记·王制》所说的制度。据《王制》说:是乡论秀士,升诸司徒,曰选士。司徒论选士之秀者,而升诸学,曰俊士。既升于学,则称造士。大乐正论造士之秀者,以告于王,而升诸司马,曰进士。司马辨论官材(官指各种机关,谓分别其材能,适宜于在何种机关中办事),论进士之贤者,以告于王,然后因其材而用之。按《周官》司士,掌群臣之版(名籍),以治其政令,岁登下其损益之数,也是司马的属官。《礼记·射义》说:古者“诸侯贡士于天子,天子试之于射宫。其容体比于礼,其节比于乐,而中多者,得与于祭。其容体不比于礼,其节不比于乐,而中少者,不得与于祭。”以中之多少,定得与于祭与否,可见射宫即在太庙之中。古代规制简陋,全国之中,只有一所讲究的屋子,谓之明堂。也就是宗庙,就是朝廷,就是君主所居的宫殿,而亦即是其讲学的学校,到后来,这许多机关才逐渐分离,而成为各别的建筑(详见第五十一章)。合观《周官》、《王制》、《射义》之文,可知在古代,各地方的贡士,是专讲武艺的。到后来,文治渐渐兴起,于是所取的人才,才不限于一途(所以司马要辨论官材,此时的司马,乃以武职兼司选举,并非以武事做选举的标准了)。此为选举之逐渐扩大,亦即世袭之渐被侵蚀。\n到战国之世,世变益亟,腐败的贵族,再也支持不了此刻的政治。而且古代的贵族,其地位,是与君主相逼的,起于孤寒之士则不然,君主要整顿政治,扩充自己的权力,都不得不用游士。而士人,也有怀抱利器,欲奋志于功名的。又有蒿目时艰,欲有所藉手,以救生民于涂炭的。于是君主和游士相合,以打击贵族,贵族中较有为的,亦不得不引用游士。选举之局益盛,世袭之制愈微。然这时候,游士还是要靠上级的人引用的。到秦末,豪杰起而亡秦,则政权全入下级社会之手,更无所谓贵族和游士的对立了。此为汉初布衣将相之局(《廿二史札记》有此一条,可参看),在此情势之下,用人自然不拘门第,世袭之局,乃于此告终。\n汉以后,选举之途,重要的,大概如下所述:\n(一)征召:这是天子仰慕某人的才德,特地指名,请他到京的。往往有聘礼等很恭敬的手续。\n(二)辟举:汉世相府等机关,僚属多由自用,谓之辟。所辟的人,并无一定的资格,做过高官的人,以至布衣均可。\n(三)荐举:其途甚广。做官的人,对于自己手下的属员,或虽未试用,而深知其可用的人,都可以荐举。就是不做官的布衣,深知什么人好,也未始不可以上书荐举的,并可上书求自试。此等在法律上都毫无制限,不过事实上甚少罢了。\n(四)吏员:此系先在各机关中服务,或因法律的规定,或由长官的保荐,由吏而变做官的。各机关中的吏,照法律上讲,都可以有出路。但其出路的好坏,是各时代不同的。大体古代优而后世劣。\n(五)任子:做到某级官吏,或由在上者的特恩,可以保荐他的儿子,得一个出身,在汉世谓之任子(亦可推及孙、弟、兄弟之子孙等)。任的本义为保,但其实,不过是一种恩典罢了,被保者设或犯罪,保之者,未必负何等责任的。任在后世谓之荫。明以后,又有荫子入监之例,即使其入国子监读书。国家既可施恩,又不令不学无术的人滥竽充选,立法之意,是很好的。惜乎入监读书,徒有其名罢了。\n(六)专门技术人员:此等人员,其迁转,是限于一途的。其技术,或由自习而国家擢用,或即在本机关中养成。如天文、历法、医学等官是(此制起源甚古。《王制》:“凡执技以事上者,不贰事,不移官”,即是)。\n(七)捐纳:这即是出钱买官做。古书中或称此为赀选,其实是不对的。赀选见《汉书·景帝本纪》后二年,乃因怕吏的贪赃,假定有钱的人,总要少贪些,于是限定有家赀若干,乃得为吏。这只是为吏的一个条件,与出钱买官做,全然无涉。又爵只是一个空名,所以卖爵也不能算做卖官的。暗中的卖官鬻爵,只是腐败的政治,并非法律所许,亦不能算做选举的一途(历代卖官之事见后)。\n以上都是入官之途。但就历代立法者的意思看起来,这些都只能得通常之才,其希望得非常之材的,则还在\n(八)学校\n(九)科举\n两途。学校别于第五十一章中详之。科举又可分为(甲)乡贡,(乙)制科。乡贡是导源于汉代的郡国选举的。以人口为比例,由守相岁举若干人。制科,则汉代往往下诏,标出一个科名,如贤良方正、直言极谏等类,令内外官吏荐举(何等官吏,有选举之权,亦无一定,由诏书临时指定),其科目并无限制。举行与否,亦无一定。到唐代,才特立制科之名。\n汉代的用人,是没有什么阶级之见的。唐柳芳论氏族,所谓“先王公卿之胄,才则用,不才弃之”(见《唐书·柳冲传》),但是(一)贵族的势力,本来潜伏着;(二)而是时的选举,弊窦又甚多,遂至激成九品中正之制,使贵族在选举上,气焰复张。这时候选举上的弊窦如何呢?自其表面言之,则(甲)如贵人的请托。如《后汉书·种暠传》说:河南尹田歆,外甥王谌名知人。歆谓之曰:“今当举六孝廉,多得贵戚书令,不宜相违。欲自用一名士,以报国家,尔助我求之。”便可见当时风纪之坏。然(乙)贵人的请托,实缘于士人的奔走。看《潜夫论》(《务本》、《论荣》、《贤难》、《考绩》、《本政》、《潜叹》、《实贡》、《交际》等篇)、《申鉴》(《时事》)、《中论》(《考伪》、《谴交》)、《抱朴子》(《审举》、《交际》、《名实》、《汉过》)诸书可知。汉代士人的出路,是或被征辟,或被郡县署用,或由公卿郡国举荐,但此等安坐不易得之。于是或矫激以立名;或则结为徒党,互相标榜,奔走运动。因其徒党众多,亦自成为一种势力,做官的人,也有些惧怕他;在积极方面,又结交之以谋进取。于是有荒废了政事,去酬应他们的。又有丰其饮食居处,厚其送迎,以敷衍他们的,官方因之大坏。究之人多缺少,奔走运动的人,还是有得有不得。有些人,因为白首无成,反把家资耗废了,无颜回家,遂至客死于外。这实在不成事体,实有制止他们在外浮游的必要。又因当时的选举,是注重品行的,而品行必须在本乡才看得出,于是举士必由乡里,而九品中正之制以生。\n九品中正之制,起于曹魏的吏部尚书陈群。于各州置大中正,各郡置中正。依据品行,将所管人物,分为上上、上中、上下、中上、中中、中下、下上、下中、下下九等。这是因历来论人,重视乡评,所以政治上有此措置。但(一)乡评的所谓好人,乃社会上的好人,只须有德,政治上所用的人,则兼须有才。所以做中正的人,即使个个都能秉公,他所以为好的人,也未必宜于政治。(二)何况做中正的人,未必都能公正,(甲)徇爱憎,(乙)快恩仇,(丙)慑势,(丁)畏祸等弊,不免继之而起呢?其结果,就酿成晋初刘毅所说的,“惟能知其阀阅,非复辨其贤愚”,以致“上品无寒门,下品无世族”了。因为世族是地方上有势力之家,不好得罪他,至于寒门,则是自安于卑贱的,得罪了他,亦不要紧。这是以本地人公开批评本地的人物,势必如此而后已的。九品中正,大家都知道是一种坏的制度。然直至隋文帝开皇年间才罢。前后历三百四五十年。这制度,是门阀阶级造成的,而其维持门阀阶级之力亦极大,因为有些制度后,无论在中央政府和地方政府,世族和寒门的进用,都绝对不同了(如后魏之制,士人品第有九,九品以外,小人之官,复有七等。又如蔡兴宗守会稽郡,举孔仲智子为望计,贾原平子为望孝。仲智高门,原平一邦至行,遂与相敌,当时亦以为异数)。\n九品中正之制既废,科举就渐渐的兴起了。科举之制,在取士上,是比较公平的、切实的,这是人人所承认的,为什么兴起如此之晚呢?用人的条件,第一是德,第二是才,第三才数到学识。这是理论上当然的结果,事实上也无人怀疑。考试之所觇,只是学识。这不是说才德可以不论,不过明知才德无从考校,与其因才德之无从考校,并其学识的试验而豁免之,尚不如就其学识而试验之,到底还有几分把握罢了。这种见解,是要积相当经验,才会有的。所以考试之制,必至唐宋之世,才会兴盛。考试之制,其起源是颇远的。西汉以前本无所谓考试(晁错、董仲舒等的对策,乃系以其人为有学问而请教之,并非疑其意存冒滥,加以考试。所以策否并无一定,一策意有未尽,可以至于再策三策,说见《文献通考》)。直至东汉顺帝之世,郡国所举的人,实在太不成话了。左雄为尚书令,乃建议“诸生试家法,文吏试笺奏”(家法,指所习的经学言),史称自是牧守莫敢轻举,察选清平,就可见得考试的效验了。但是自此以后,其法未曾认真推行。历魏晋南北朝至隋,仍以不试为原则。科举之制兴于唐,其科目甚多(秀才系最高科目,高宗永徽二年后停止。此外尚有俊士、明法、明字、明算、一史、三史、开元礼、道举、童子等科,均见《唐书·选举志》),常行的为明经和进士。进士科是始于隋的,其起源,历史记载,不甚清楚。据杨绾说:其初尚系试策,不知什么时候,改试了诗赋。到唐朝,此科的声光大好。这是社会上崇尚文辞的风气所造成的。唐时,进士科虽亦兼试经义及策,然所重的是诗赋。明经所重的是帖经、墨义。诗赋固然与政治无涉,经学在政治上,有用与否,自今日观之,亦成疑问。这话对从前的人,自然是无从说起,但像帖经、墨义所考的只是记诵(帖经、墨义之式,略见《文献通考》。其意,帖经是责人默写经文,墨义则责人默写传注,和今学校中专责背诵教科书的考试法一般),其无用,即在当日,亦是显而易见的。为什么会有这种奇异的考试法呢?这是因为把科举看做抡才大典,换言之,即在官吏登庸法上,看做惟一拔取人才之途,怕还是宋以后的事,在唐以前,至多只是取才的一途罢了。所以当时的进士,虽受俗人看重,然在政治上,则所取的人并不多,而其用之亦不重(唐时所取进士,不过二三十人,仍须应吏部释褐试,或被人荐举,方得入官,授官亦不过丞尉;见《日知录·中式额数》、《出身授官》两条)。可见科举初兴,不过沿前代之法而渐变,并非有什么隆重的意思,深厚的期望,存乎其间了。所以所试的不过是诗赋和帖经、墨义。帖经、墨义所试,大约是当时治经的成法,诗赋疑沿自隋朝。隋炀帝本好辞华,所设的进士科,或者不过是后汉灵帝的鸿都门学之类(聚集一班会做辞赋和写字的人,其中并有流品极杂的,见《后汉书》本纪及《蔡邕传》)。进土科的进而为抡才之路,正和翰林的始居杂流,后来变成清要一样。这是制度本身的变化,不能执后事以论其初制的。科举所试之物,虽不足取,然其取士之法,则确是进步而可纪念的。唐制,愿应举者皆“怀牒自列于州县”。州县先试之,而后送省(尚书省)。初由户部“集阅”,考功员外郎试之。玄宗开元时,因考功员外郎望轻,士子不服,乃移其事于礼部。宋太祖时,知贡举的人,有以不公被诉的,太祖乃在殿廷上自行复试。自此省试之外,又有殿试。前此的郡国选举,其权全操于选举之人。明明有被选举之才,而选举不之及,其人固无如之何。到投牒自列之制兴,则凡来投牒者,即使都为州县所不喜,亦不得不加以考试,而于其中取出若干人;而州县所私爱的人,苟无应试的能力,即虽欲举之而不得。操选举之权者,大受限制,被选举之权,即因此而扩大。此后白屋之士,可以平步青云;有权的人,不能把持地位,都是受此制度之赐。所以说其制度是大可纪念的。考试的规则逐渐加严,亦是助成选举制度的公平的。唐时,考官和士子交通,还在所不禁。考官采取声誉,士子托人游扬,或竟自怀所作文字投谒,都不算犯法的事。晚唐以后,规则逐渐加严,禁怀挟和糊名易书等制度,逐渐兴起。明清继之,考试关防,日益严密。此似于人格有损,但利禄之途,应试者和试之者,都要作弊,事实上亦是不得不然的。\n以上所说的,均系乡贡之制。至于制科,则由天子亲策,其科目系随时标出。举行与否,亦无一定。唐代故事,详见《文献通考·选举考》中。\n对于科举的重视,宋甚于唐,所以改革之声,亦至宋而后起。科举之弊有二:(一)学非所用,(二)所试者系一日之短长。从经验上证明:无学者亦可弋获,真有学问者,或反见遗。对于第一弊,只须改变其所试之物即可。对于第二弊,则非兼重学校不行。不然,一个来应试的人,究曾从事于学问与否,是无从调查的。仁宗时范仲淹的改革,便针对着这两种弊窦:(一)罢帖经、墨义,而将诗赋策论通考为去取(唐朝的进士,亦兼试帖经及策,明经亦兼试策,但人之才力有限,总只能专精一门,所以阅卷者亦只注重一种,其余的都不过敷衍了事。明清时代,应科举的人,只会做四书文,亦由于此)。(二)限定应试的人,必须在学三百日,曾经应试的人一百日。他的办法,很受时人反对,罢相未几其法即废。到神宗熙宁时,王安石为相,才大加以改革。安石之法:(一)罢诸科,独存进士。这是因社会上的风气,重进士而轻诸科起的。(二)进士罢试诗赋,改试论、策。其帖经、墨义,则改试大义(帖经专责记诵,大义是要说明义理,可以发抒意见的)。(三)别立新科明法,以待不能改业的士子。(四)安石是主张学校养士的,所以整顿太学,立三舍之法,以次递升。升至上舍生,则可免发解及礼部试,特赐之第。熙宁贡举法,亦为旧党所反对。他们的理由是:(一)诗赋声病易晓,策论汗漫难知,因此看卷子难了。这本不成理由。诗赋既是无用之学,即使去取公平,又有何益呢?(二)但他们又有如苏轼之说,谓以学问论,经义、策、论,似乎较诗赋为有用。以实际论,则诗赋与策、论、经义,同为无用。得人与否,全看君相有无知人之明。取士之法,如科举等,根本无甚关系,不过不能不有此一法罢了。这话也是不对的。科举诚不能皆得人,然立法之意,本不过说这是取士的一法,并没有说有此一法之后,任用时之衡鉴,任用后之考课,都可置诸不论。况且国家取士之途,别种都是注重经验的;或虽注重学识,而非常行之法,只有学校、科举,是培养、拔擢有学识的人的常法。有学识的人,固然未必就能办事,然办事需用学识的地方,究竟很多(大概应付人事,单靠学识无用,决定政策等,则全靠学识)。“人必先知其所事者为何事,然后有欲善其事之心”,所以学识和道德,亦有相当的关系。衡鉴之明,固然端赖君相,然君相决不能向全国人中,漫无标准,像淘沙般去觅取。终必先有一法,就全体之中,取出一部分人来,再于其中施以简择。此就全体之中取出其一部分人之法,惟有科举是注重学识的,如何能视之过轻?经义、策、论,固亦不过纸上空谈,然其与做官所需要的学识关系的疏密,岂能视之与诗赋同等?所以旧党的议论,其实是不通的。然在当时,既成为一种势力,即不能禁其不抬头。于是至元祐之世,而熙宁之法复废。熙宁贡举之法虽废,旧法却亦不能回复了。因为考试是从前读书人的出身之路,所试非其所习,习科举之业的人,是要反对的。熙宁变法时,反对者之多,其理由实亦在此。到元祐要回复旧法时,又有一班只习于新法的人,要加以反对了。于是折衷其间,分进士为诗赋、经义两科。南宋以后,遂成定制。连辽、金的制度,也受其影响(金诗赋、经义之外,又有律科。诗赋、经义称进士,律科称举人。又有女真进士科,则但试策论,系金世宗所立。辽、金科目,均须经过乡、府、省三试。省试由礼部主持,即明清的会试。元、明、清三代,都只有会试和本省的乡试)。\n近代科举之法,起于元而成于明。元代的科举,分蒙古、色目人和汉人、南人为两榜。蒙古、色目人考两场:首场经义,次场策论。汉人、南人考三场:首场经义,次场古赋和诏、诰、表,三场策论。这是(一)把经义、诗赋,并做一科了。(二)而诸经皆以宋人之说为主,以及(三)乡会试所试相同,亦皆为明清所沿袭。明制:首场试四书五经义,次场试论判,又于诏、诰、表内科一道,三场试策。清制:首场试四书义及诗一首,次场试五经义,三场亦试策。明清所试经义,其体裁是有一定的。(一)要代圣贤立言。(二)其文体系逐段相对,谓之八股(八股文体的性质,尽于此二语:(一)即文中的话不算自己所说,而算代圣贤说一篇较详尽的话。(二)则历来所谓对偶文字,系逐句相对,而此则系逐段相对,所以其体裁系特别的。又八股文长短亦有定限。在清代,是长不能过七百字,短不能不满三百字。此等规则,虽亦小有出入,但原则上是始终遵守的。因有(一)之条件,所以文中不能用后世事,这是清代学者,疏于史事的一个原因)。其式为明太祖及刘基所定,故亦谓之制义。其用意,大概是防士子之竞鹜新奇的(科举名额有定,而应试者多。如清末,江南乡试,连副贡取不满二百人,而应试者数逾二万。限于一定的题目,在几篇文字内,有学问者亦无所见其长。于是有将文字做得奇奇怪怪,以期动试官之目的,此弊在宋代已颇有)。明清时代科举之弊,在于士子只会做几篇四书义,其余全是敷衍了事,等于不试。士子遂至一物不知。此其弊,由于立法的未善。因为人之能力,总是有限的,一个人不过懂得一门两门。所以历代考试之法,无不分科,就其所习而试之。经义、诗赋的分科,就等于唐朝的明经进士。这二者,本来不易兼通。而自元以来,并二者为一。三场所试的策,绝无范围。所以元、明、清三朝的科举,若要实事求是,可说是无人能应。天下事,责人以其所不能为者,人将并其所能为者而亦不为,这是无可如何的事。明清科举致弊之原,即在于此。宋代改革科举之意,是废诗赋而存经义、策论,这个办法,被元、明、清三代的制度推翻了。其学校及科举并用之意,到明朝,却在形式上办到。明制,是非国子监生和府州县学生,不能应科举的(府州县学生应科举,是先须经过督学使者的试验的,谓之科考。科考录取的人,才得应乡试。但后来,除文字违式者外,大抵是无不录取的。非学生,明代间取一二,谓之“充场儒士”,其数极少)。所以《明史》谓其“学校储材,以待科举”。按科举所试,仅系一日之短长,故在事实上,并无学问,而年少气盛,善于作应试文字者,往往反易弋获,真有学问者反难。学校所授,无论如何浅近,苟使认真教学,学生终必在校肄习几年,必不能如科举之一时弋取。但课试等事,极易徒有其名,学问之事,亦即有名无实。毕业实毕年限之弊,实自古有之,并不自今日始。使二者相辅而行,确系一良好的制度,但制度是拗不过事实的。入学校应科举的人,其意既在于利禄,则学问仅系工具(所以从前应举的人,称应举所作文字为敲门砖),利禄才是目的。目的的达到,是愈速愈好的。(一)假使科举与学校并行,年少气盛的人,亦必愿应科举而不愿入学校。(二)况且应试所费,并来往程途计之,远者亦不过数月,平时仍可自谋生活,学校则不能然。所以士之贫者,亦能应科举而不能入学校。(三)何况学校出身,尚往往不及科举之美。职是故,明朝行学校储才以待科举之制后,就酿成这样的状况:(一)国子监是自有出身的,但其出身不如科举之美,则士之衰老无大志者都归之。(二)府州县学,既并无出身;住在学校里,又学不到什么,人家又何苦而来“坐学”?作教官的人,亦是以得禄为目的的。志既在于得禄,照经济学的原理讲,是要以最少的劳费,得最大的效果的。不教亦无碍于利禄,何苦而定要教人?于是府州县学,就全然有名无实了。明初对于国子监,看得极为隆重。所以后来虽然腐败,总还维持着一个不能全不到校的局面,到清朝,便几乎和府州县学一样了。\n制科在唐朝,名义上是极为隆重的。但因其非常行之典,所以对于社会的影响,不如乡贡的深刻。自宋以后,大抵用以拔取乡贡以外的人才,但所取者,亦不过长于辞章,或学问较博之士(设科本意,虽非如此,然事实上不过如此,看《宋史·选举志》可知)。清圣祖康熙十八年,高宗乾隆元年,曾两次举行博学鸿词科,其意还不过如此。德宗光绪二十五年,诏开经济特科,时值变法维新之际,颇有登用人才之意。政变以后,朝廷无复此意,直到二十九年,才就所举的人,加以考试,不过敷衍了事而已。\n科举在从前,实在是一种文官考试。所试的科目,理应切于做官之用。然而历代所试,都不是如此的。这真是一件极奇怪的事。要明白此弊之由来,则非略知历史上此制度的发展不可。古代的用人,本来只求有做官的智识技能(此智识两字,指循例办公的智识言,等于后世的幕友胥吏,不该括广泛的智识),别无所谓学问的。后来社会进化了,知道政治上的措置,必须通知原理,并非循例办事而已足。于是学问开始影响政治,讲学问的人,亦即搀入政治界中。秦朝的禁“以古非今”,只许学习“当代法令”,“欲学法令,以吏为师”,是和此趋势相反的。汉朝的任用儒生,则顺此趋势而行。这自然是一种进步。但既知此,则宜令做官的人,兼通学问,不应将做官的人,与学问之士,分为两途,同时并用。然汉朝却始终如此。只要看当时的议论,总是以儒生、文吏并举,便可知道。《续汉书·百官志》《注》引应劭《汉官仪》,载后汉光武帝的诏书,说“丞相故事,四科取士:(一)曰德行高妙,志节清白。(二)曰学通行修,经中博士。(三)曰明达法令,足以决疑,能案章覆问,文中御史。(四)曰刚毅多略,遭事不惑,明足以决,才任三辅令”。第一种是德行,第四种是才能,都是无从以文字考试的。第二种即系儒生,第三种即系文吏。左雄考试之法,所试的亦系这两科。以后学者的议论,如《抱朴子》的《审举篇》,极力主张考试制度,亦说律令可用试经之法试之。国家的制度,则唐时明法仍与明经并行,所沿袭的还系汉制。历千年而不知改变,已足惊奇。其后因流俗轻视诸科,把诸科概行废去,明法一科,亦随之而废,当官所需用的智识技能,在文官考试中,遂至全然不占地位。(一)政治上的制度,既难于改变;(二)而迂儒又有一种见解,以为只要经学通了,便一切事情,都可对付,法令等实用不着肄习,遂益使此制度固定了。历史上有许多制度,凭空揣度,是无从明白其所以然的。非考其事实,观其变迁不可。科举制度,只是其一端罢了。\n近代的科举制度,实成于明太祖之手。然太祖并非重视科举的人。太祖所最重的是荐举,次之则是学校。当时曾令内外大小臣工,皆得荐举,被荐而至的,又令其转荐,由布衣至大僚的,不可胜数。国子监中,优礼名师,规则极严,待诸生亦极厚,曾于一日之中,擢六十四人为布、按两司官。科举初设于洪武三年,旋复停办,至十五年乃复设。当时所谓三途并用,系指(一)荐举,(二)进士贡监,(三)吏员(见《日知录·通经为吏》条)。一再传后,荐举遂废,学校浸轻,而科举独重。此由荐举用人,近于破格,非中主所能行。学校办理不能认真,近于今所谓毕业即毕年限。\n●明清科举考试层级\n科举(一)者为习惯所重,(二)则究尚有一日之短长可凭,所以为社会所重视。此亦不能谓绝无理由。然凡事偏重即有弊,何况科举之本身,本无足取呢?明制:进士分为三甲。一甲三人,赐进士及第。二甲若干人,赐进士出身。三甲若干人,赐同进士出身。一甲第一人,授翰林院修撰。第二、第三人授编修。二、三甲均得选庶吉士。庶吉士本系进士观政在翰林院、承敕监等衙门者之称。明初,国子监学生,派至各衙门实习的,谓之历事。进士派至各衙门实习的,谓之观政。使其于学理之外,再经验实事,意本甚善。然后亦成为具文。庶吉士初本不专属翰林。成祖时,命于进士二甲以下,择取文理优者,为翰林院庶吉士,自此才为翰林所专。后复命就学文渊阁。选翰(翰林院)、詹(詹事府)官教习。三年学成,考试授官,谓之散馆。出身特为优异。清制:二、三甲进士,亦得考选庶吉士。其肄业之地,谓之庶常馆。选满汉学士各一人教习,视为储才之地。然其所习者,不过诗赋、小楷而已。乡举在宋朝还不过是会试之阶,并不能直接入官。明世始亦为入仕之途。举贡既特异于杂流,进士又特异于举贡。所谓三途并用者,遂成(一)进士,(二)举贡,(三)吏员(见《明史·选举志》)。在仕途中,举贡亦备遭轻视排挤,杂流更不必论了。清制以科目、贡监、荫生为正途,荐举、捐纳、吏员为异途,异途之受歧视亦殊甚。然及末造,捐纳大行,仕途拥挤,亦虽欲歧视而不可得了。\n卖官之制,起于汉武帝。《史记·平准书》所谓“入羊为郎”、“入财者得补郎”、“吏得入谷补官”、买武功爵者试补吏皆是。后世虽有秕政,然不为法令。明有纳粟入监之例,亦仍须入监读书。清则仅存虚名。实官捐,顺康时已屡开,嘉道后尤数,内官自郎中,外官自道府而下,皆可报捐。直至光绪二十七年才停,从学校、科举、吏员等出身之士,虽不必有学识,究不容一物不知,捐纳则更无制限,而其数又特多。既系出资买来,自然视同营业。清季仕途人员的拥塞,流品的冗杂,贪污的特盛,实在和捐纳之制,是大有关系的。\n元代各机关长官,多用蒙古人。清朝则官缺分为满、汉、包衣、汉军、蒙古,这实在是一种等级制度(已见第四十章)。满缺有一部分是专属于宗室的,其选举权在宗人府;包衣属内务府,均不属吏部。\n以上所说,大略都是取士之制,即从许多人民中,拔擢出一部分人来,给他以做官的资格。其就已有做官资格的人,再加选试,而授之以官,则普通称为“铨选”。其事于古当属司马,说已见前。汉朝,凡有做官的资格,而还未授官的,皆拜为郎,属于光禄勋,分属五官中郎将、左中郎将、右中郎将,谓之三署郎。光禄勋岁于其中举茂材四行。其选授之权,初属三公府,东西曹主其事。后来尚书的吏曹,渐起而攘夺其权。灵帝时,吕强上言:“旧典选举,委任三府。”“今但任尚书,或复敕用。”可见到后汉末,三公已不大能参预选举了。曹魏以后,既不设宰相,三公等官,亦不复参与政事,选权遂专归尚书。唐制:文选属于吏部,武选属于兵部。吏部选官,自六品以下,都先试其书、判,观其身、言。五品以上则不试,上其名于中书门下。宋初,选权分属中书、枢密及审官院,吏部惟注拟州县官。熙宁改制,才将选权还之吏部。神宗说古者文武不分途,不以文选属吏,武选属兵为然。于是文武选皆属吏部,由尚书、侍郎,分主其事。明清仍文选属吏,武选属兵。明代吏部颇有大权,高官及边任等,虽或由廷推,或由保举,然实多由吏部主其事。清代则内分于军机、内阁,外分于督、抚,吏部所司,真不过一吏之任而已。外官所用僚属,自南北朝以前,均由郡县长官,自行选用(其权属于功曹),所用多系本地人。隋文帝始废之,佐官皆由吏部选授。此与选法之重资格而轻衡鉴,同为一大变迁,而其原理是相同的,即不求有功,但求防弊。士大夫蔽于阶级意识,多以此等防弊之法为不然。然事实上,所谓官僚阶级,总是以自利为先,国事为后的。无以防之,势必至于泛滥不可收拾。所以防弊之法,论者虽不以为然,然事实上卒不能废,且只有日益严密。\n用人由用之者察度其才不才,谓之衡鉴。鉴是取譬于镜子,所以照见其好坏;衡则取喻于度量衡,所以定其程度的。用人若在某范围之中,用之者得以自由决定其取舍,不受何等法律的限制,则谓之有衡鉴之权。若事事须依成法办理,丝毫不能自由,即谓之依据资格。二者是正相反对的。资格用人,起于后魏崔亮的停年格,专以停解先后为断,是因胡灵后秉政,许武人入选,仕途拥挤,用此为手段,以资对付的。崔亮自己亦不以为然。北齐文襄帝做尚书时,就把它废掉了。唐开元时,裴光庭又创循资格。然自中叶以后,检校、试、摄、判、知之法大行,皆以资格不相当之人任事,遂开宋朝以差遣治事之端。明孙丕扬创掣签法。资格相同者,纳签于筒,在吏部堂上,由候选者亲掣(不到者由吏部堂官代掣)。当时亦系用以对付中人请托的(见于慎行《笔麈》),然其后卒不能废。大抵官吏可分为政务官和事务官。政务官以才识为重,自不能专论资格。事务官不过承上官之命,依据法律,执行政务。其事较少变化。用法能得法外意,虽是极好的事,然其事太无凭据,若都藉口学识,破弃资格,一定得才的希望少,徇私的弊窦多。所以破格用人,只可视为偶然之事,在常时必不能行,历来诋资格之论,都是凭臆为说,不察实际情形的。\n回避之法,亦是防弊的一端。此事古代亦无之。因为回避之法,不外两端:(一)系防止人与人间的关系,(二)则防止人与其所治的地方的关系。在世官制度之下,世家大族,左右总是姻亲;而地不过百里,东西南北,亦总系父母之邦,何从讲起回避?地方既小,政治之监察既易,舆论之指摘亦严,要防止弊窦,亦正无藉乎回避。所以回避之法,在封建制度下,是无从发生的。郡县制度的初期,还毫无形迹,如严助、朱买臣均以吴人而为会稽守,即其明证。东汉以后,此制渐渐发生。《后汉书·蔡邕传》说:时制婚姻之家,及两州人士,不得对相监临,因此有三互之法(《注》:三互,谓婚姻之家,及两州人不得交互为官也),是为回避之法之始。然其法尚不甚严。至近世乃大为严密。在清代,惟教职止避本府,余皆须兼避原籍、寄籍及邻省五百里以内。京官父子、祖孙,不得同在一署。外官则五服之内,母、妻之父及兄弟、女婿、外甥、儿女姻亲、师生,均不得互相统属(皆以卑避尊)。此等既以防弊,亦使其人免得为难,在事实上亦不得不然。惟近代省区太大,服官的离本籍太远,以致不悉民情风俗,甚至言语不通,无从为治。以私计论,来往川资,所费大巨,到任时已不易筹措,罢官后竟有不能归家的,未免迫人使入于贪污,亦是立法未善之处。\n选举之法,无论如何严密,总不过慎之于任用之初。(一)人之究有德行才识与否,有时非试之以事不能知;(二)亦且不能保其终不变节。(三)又监督严密,小人亦可为善,监督松弛,中人不免为非;所以考课之法,实较选举更为重要。然其事亦倍难。因为(一)考试之法,可将考者与被考者隔离;(二)且因其时间短,可用种种方法防弊;(三)不幸有弊,所试以文字为凭,亦易于复试磨勘,在考课则办不到。考课之法,最早见于书传的,是《书经》的三载考绩,三考黜陟(《尧典》,今本《舜典》)。《周官》太宰,以八柄诏王驭群臣(一曰爵,二曰禄,三曰予,四曰置,五曰生,六曰夺,七曰废,八曰诛),亦系此法。汉朝京房欲作考功课吏法,因此为石显所排。王符著《潜夫论》极称之,谓为致太平之甚(见《考绩篇》)。魏世刘劭,亦曾受命作都官考课及说略。今其所著《人物志》具存,论观人之法极精,盖远承《文王官人》之绪(《大戴礼记》篇名。《周书》亦有此篇,但称《官人》)。按京房尝受学焦延寿,延寿称“得我道以亡身者,京生也”。京房《易》学,虽涉荒怪,然汉世如此者甚多,何致有亡身之惧?疑《汉书》文不完具。京房课吏之法,实受诸延寿,得我道以亡身之说,实指课吏之法言之。如此,则考课之法,在古代亦系专门之业,而至后来乃渐失其传者了。后世无能讲究此学的。其权,则初属于相府,后移于尚书,而专属于吏部。虽有种种成法,皆不过奉行故事而已(吏部系总考课的大成的。各机关的属官,由其长官考察;下级机关,由上级机关考察,为历代所同。考课有一定年限。如明代,京官六年一考察,谓之京察。外官三年一考察,谓之外察,亦谓之大计,武职谓之军政。清朝均三年一行。考察有一定的项目,如清朝文官,以守、才、政、年为四格。武官又别有字样,按格分为三等。又文武官均以不谨、疲软、浮躁、才力不及、年老、有疾为六法。犯此者照例各有处分。然多不核其实,而人事的关系却颇多。高级的官,不由吏、兵部决定的,明有自陈,清有由部开列事实请旨之法,余皆由吏、兵部处理)。\n第四十四章 赋税 # 中国的赋税,合几千年的历史观之,可以分为两大类:其(一)以最大多数的农民所负担的田税、军赋、力役为基本,随时代变化,而成为种种形式。自亡清以前,始终被看做是最重要的赋税。其(二)自此以外的税,最初无有,后来逐渐发生,逐渐扩张,直至最近,才成为重要部分。\n租、税、赋等字样,在后世看起来,意义无甚区别,古代则不然。汉代的田税,古人称之为税,亦即后世所谓田赋。其收取,据孟子说,有贡、助、彻三法。夏后氏五十而贡,殷人七十而助,周人百亩而彻(五十、七十当系夏殷顷亩,较周为小,不然,孟子所说井田之制,就不可通了)。又引龙子的话,说“贡者,校数岁之中以为常”,即是取几年的平均额,以定一年的税额。乐岁不能多,凶年不能减。所以龙子诋为恶税。助法,据孟子说,是将一方里之地,分为九百亩。中百亩为公田,外八百亩为私田。一方里之地,住居八家。各受私田百亩。共耕公田。公田所入,全归公家;私田所入,亦全归私家,不再收税。彻则田不分公私,而按亩取其几分之几。按贡法当是施之被征服之族的。此时征服之族与被征服之族,尚未合并为一,截然是两个团体。征服之族,只责令被征服之族,每年交纳农作品若干。其余一切,概非所问(此时纳税的实系被征服之族之团体,而非其个人),所以有此奇异的制度。至于助、彻,该是平和部族中自有的制度,在田亩自氏族分配于家族时代发生的(参看第三十八、第四十一两章自明)。三者的税额,孟子说:“其实皆十一也。”这亦不过以大略言之。助法,照孟子所说,明明是九一,后儒说:公田之中,以二十亩为庐舍,八家各耕公田十亩,则又是十一分之一。古人言语粗略,计数更不精确,这是不足以为怀疑孟子的话而加以责难的根据。古代的田制有两种:一种是平正之地,可用正方形式分划,是为井田。一种是崎岖之地,面积大小,要用算法扯算的,是为畦田(即圭田)。古代征服之族,居于山险之地,其地是不能行井田的,所以孟子替滕文公规划,还说“请野九一而助,国中什一使自赋”。既说周朝行彻法,又说虽周亦助,也是这个道理(参看第四十章自明)。\n赋所出的,是人徒、车、辇、牛、马等,以供军用。今文家说:十井出兵车一乘(《公羊》宣公十年,昭公元年何《注》)。古文家据《司马法》,而《司马法》又有两说:一说以井十为通,通为匹马,三十家,出士一人,徒二人。通十为成,成十为终,终十为同,递加十倍(《周官》小司徒郑《注》引)。又一说以四井为邑,四邑为丘,有戎马一匹,牛三头。四丘为甸,出戎马四匹,兵车一乘,牛十二头,甲士三人,步卒七十二人(郑注《论语·学而篇》“道千乘之国”引之,见《小司徒疏》)。今文家所说的制度,常较古文家早一时期,说已见前。古文家所说的军赋,较今文家为轻,理亦由此(《司马法》实战国时书。战国时国大了,所以分担的军赋也轻)。\n役法,《礼记·王制》说:“用民之力,岁不过三日。”《周官·均人》说:丰年三日,中年二日,无年一日。《小司徒》说:“上地家七人,可任也者家三人。中地家六人,可任也者二家五人。下地家五人,可任也者家二人。凡起徒役,毋过家一人,以其余之羡。惟田与追胥竭作。”案田与追胥,是地方上固有的事,起徒役则是国家所要求于人民的。地方上固有的事,总是与人民利害相关的,国家所要求于人民的,则利害未必能一致,或且相反。所以法律上不得不分出轻重。然到后来,用兵多而差徭繁,能否尽守此规则,就不可知了。古代当兵亦是役的一种。《王制》说:“五十不从力政(政同征,即兵役外的力役),六十不与服戎。”《周官·乡大夫》说:“国中自七尺以及六十,野自六尺以及六十有五皆征之。”《疏》说七尺是二十岁,六尺是十五岁。六尺是未成年之称,其说大约是对的。然则后期的徭役,也比前期加重了。\n以上是古代普遍的赋税。至于山林川泽之地,则古代是公有的。手工业,简易的人人会做,艰难的由公家设官经营。商业亦是代表部族做的(说已见第四十一章),既无私有的性质,自然无所谓税。然到后来,也渐渐的有税了。《礼记·曲礼》:“问国君之富,数地以对,山泽之所出。”古田地字通用,田之外兼数山泽,可见汉世自天子至封君,将山川、园池、市井租税之入,皆作为私奉养,由来已久(参看第四十一章)。市井租税,即系商税。古代工商业的分别,不甚清楚,其中亦必包含工税。按《孟子·王制》,都说“市廛而不税,关讥而不征。”廛是民居区域之称。古代土地公有,什么地方可以造屋,什么地方可以开店,都要得公家允许的,不能乱做。所以《孟子·滕文公上篇》,记“许行自楚之滕,踵门而告文公曰:闻君行仁政,愿受一廛而为氓,文公与之处”。然则市廛而不税,即系给与开店的地方,而不收其税,这是指后世所谓“住税”而言,在都邑之内。关讥而不征,自然是指后世所谓“过税”而言。然则今文住税、过税俱无。而《周官》司市,必“凶荒札丧”,才“市无征而作布”(造货币);司关必凶荒才“无关、门之征”(门谓城门),则住税过税都有了。又《孟子·公孙丑下篇》说:“古之为市者”,“有司者治之耳。有贱丈夫焉,必求龙断而登之,以左右望而罔市利。人皆以为贱,故从而征之”,龙即陇字。龙断,谓陇之断者。一个人占据了,第二个人再不能走上去与之并处。罔即今网字。因为所居者高,所见者远,遥见主顾来了,可以设法招徕;而人家也容易望见他,自可把市利一网打尽了。这是在乡赶集的,而亦有税,可见商税的无孔不入了。此等山川、园池、市肆租税,都是由封建时代各地方的有土之君,各自征收的,所以很缺乏统一性。\n赋税的渐增,固由有土者的淫侈,战争的不息,然社会进化,政务因之扩张,支出随之巨大,亦是不可讳的。所以白圭说:“吾欲二十而取一。”孟子即说:“子之道,貉道也。”貉“无城郭,宫室,宗庙祭祀之礼。无诸侯币帛饔飧,无百官有司,故二十取一而足”。然则赋税的渐增,确亦出于事不获已。倘使当时的诸侯大夫,能审察情势,开辟利源,或增设新税,或就旧税之无害于人民者而增加其税额,原亦不足为病。无如当时的诸侯大夫,多数是不察情势,不顾人民的能否负担,而一味横征暴敛。于是田租则超过十一之额,而且有如鲁国的履亩而税(见《春秋》宣公十五年。此因人民不尽力于公田,所以税其私田),井田制度破坏尽了。力役亦加多日数,且不依时令,致妨害人民的生业。此等证据,更其举不胜举。无怪乎当时的仁人君子,都要痛心疾首了。然这还不算最恶的税。最恶的税,是一种无名的赋。古书中赋字有两义:一是上文所述的军赋,这是正当的。还有一种则是不论什么东西,都随时责之于民。所以《管子》说:“岁有凶穰,故谷有贵贱。令有缓急,故物有轻重。”(《国蓄篇》)轻就是价贱,重就是价贵。在上者需用某物,不管人民的有无,下令责其交纳,人民只得求之于市,其物的价格就腾贵,商人就要因此剥削平民了。《管子》又说:以室庑籍,以六畜籍,以田亩籍,以正人籍,以正户籍。籍即是取之之意。以室庑籍,当谓按户摊派。以田亩籍,则按田摊派。正人、正户,当系别于穷困疲羸的人户而言。六畜,谓畜有六畜之家,当较不养者为富(《山权数》云:“若岁凶旱水泆,民失本,则修宫室台榭,以前无狗后无彘者为庸。”此以家无孳畜为贫穷的证据),所以以之为摊派的标准。其苛细可谓已甚了。古代的封君,就是后世乡曲的地主。后世乡曲的地主,需要什么东西,都取之于佃户的,何况古代的封君,兼有政治上的权力呢?无定时、无定物、无定数,这是最恶的税。\n秦汉之世,去古未远,所以古代租税的系统,还觉分明。汉代的田租,就是古代的税,其取之甚轻。高祖时,十五税一。文帝从晁错之说,令民入粟拜爵,十三年,遂全除田租。至景帝十年,乃令民半出租,为三十而税一。后汉初年,尝行十一之税。天下已定,仍三十而税一。除灵帝曾按亩敛修宫钱外,始终无他横敛(修宫钱只是横敛,实不能算增加田租),可谓轻极了。但古代的田,是没有私租的,汉世则正税之外,还有私租,所以国家之所取虽薄,农民的负担,仍未见减轻,还只有加重(王莽行王田之制时,诏书说汉时的私租,“厥名三十,实十税五”,则合三十税一的官租,是三十分之十六了)。汉代的口钱,亦称算赋。民年十五至五十六,出钱百二十,以食天子。武帝又加三钱,以补车骑马。见《汉书·高帝纪》四年、《昭帝纪》元凤四年《注》引如淳说引《汉仪注》。按《周官》太宰九赋,郑《注》说赋是“口率出泉”。又说:“今之算泉,民或谓之赋,此其旧名与?”泉钱一字。观此,知汉代的算赋,所谓人出百二十钱以食天子者,乃古代横敛的赋所变。盖因其取之无定时、无定物、无定数,实在太暴虐了,乃变为总取钱若干,而其余一切豁免。这正和五代时的杂征敛,宋世变为沿纳;明时的加派,变为一条鞭一样(见下)。至于正当的赋,则本是供军用的,所以武帝又加三钱以补车骑马。汉代的钱价,远较后世为贵,人民对于口钱的负担,很觉其重。武帝令民生子三岁出口钱,民至于生子不举。元帝时,贡禹力言之。帝乃令民七岁乃出口钱。见《汉书·贡禹传》。役法:《高帝纪》二年《注》引如淳说,《律》:年二十三,傅之畴官,各从其父畴学之。畴之义为类。古行世业之法,子弟的职业,恒与父兄相同(所谓士之子恒为士,农之子恒为农,工之子恒为工,商之子恒为商。参看阶级章)。而每一类的人,都有其官长(《国语·周语》说“宣王要料民于太原,仲山父谏,说“古者不料民而知其多少。司民协孤终,司商协民姓,司徒协旅,司寇协奸,牧协职,工协革,场协入,廪协出,是则少多死生,出入往来,皆可知也。”这即是各官各知其所管的民数的证据),此即所谓畴官。傅之畴官,就是官有名籍,要负这一类中人所应负的义务了。这该是古制,汉代的人民,分类未必如古代之繁,因为世业之制破坏了。但法律条文,是陈旧的东西,事实虽变,条文未必随之而变。如淳所引的律文,只看作民年二十三,就役籍有名,该当一切差徭就够了。景帝二年,令民年二十始傅。又将其提早了三年。役法是征收人民的劳力的,有役法,则公家举办事业,不必要出钱雇工,所以在财政上,也是一笔很大的收入。\n财政的规模,既经扩张,自当创设新税。创设新税,自当用间接之法,避免直接取之于农民。此义在先秦时,只有法家最明白。《管子·海王篇》说,要直接向人民加赋,是人人要反对的。然盐是无人不吃的;铁器亦不论男女,人人要用,如针、釜、耒、耜之类。在盐铁上加些微之价,国家所得,已不少了。这是盐铁官卖或收税最古的理论。此等税或官卖,古代亦必有行之者。汉代郡国,有的有盐官、铁官、工官(收工物税)、都水官(收渔税),有的又没有,即由于此。当此之时,自应由中央统筹全局,定立税法;或由中央直接征收,或则归之于地方。但当时的人,不知出此。桑弘羊是治法家之学的,王莽实亦兼采法家之说(见第四十一章),所以弘羊柄用时,便筦盐铁、榷酒酤,并行均输、算缗之法(千钱为缗,估计资本所值之数,按之抽税),王莽亦行六筦之制(见第四十一章),然行之既未尽善,当时的人,又大多数不懂得此种理论。汲黯说:天子只该“食租衣税”。晋初定律,把关于酒税等的法令,都另编为令,出之于律之外,为的是律文不可时改,而此等税法,在当时,是认为不正当,天下太平之后,就要废去的(见《晋书·刑法志》)。看这两端,便知当时的人,对于间接税法,如何的不了解。因有此等陈旧的见解,遂令中国的税法,久之不能改良。\n田租、口赋两种项目,是从晋定《户调式》以后,才合并为一的。户调之法,实起源于后汉之末。魏武帝平河北,曾下令:田租之外,只许每户取绵绢若干,不准多收(见《三国魏志·武帝纪》建安九年《注》)。大约这时候,一、人民流离,田亩荒废,有能从事开垦的,方招徕之不暇,不便从田租上诛求。二、人民的得钱,是比较艰难的(这个历代情形都如此。所以租税征收谷帛,在前代,是有益于农民的。必欲收钱,在征收租税时,钱价就昂贵,谷帛的价,就相对下落了)。汉世钱价贵,丧乱之际,卖买停滞,又不能诛求其口钱。所以不如按户责令交纳布帛之类。这原是权宜之法。但到晋武帝平吴,制为定式之后,就成为定法了。户调之法,是与官授田并行的。当时男子一人,占田七十亩;女子三十亩。其外,丁男课田五十亩,丁女二十亩;次丁男半之,女则不课。丁男之户,岁输绢三匹,绵三斤。女及次丁男为户者半输。北魏孝文帝均田令,亦有授田之法(已见第四十一章)。唐时,丁男给田一顷,以二十亩为永业,余为口分。每年输粟三石,谓之租。看地方的出产,输绵及丝麻织品,谓之调。力役每年二十日,遇闰加两日,不役的纳绢三尺,谓之庸。立法之意,本是很好的。但到后来,田不能授,而赋税却是按户征收了。你实际没有田,人家说官话不承认。兼并的人,都是有势力的,也无人来整顿他。于是无田的人,反代有田的人出税。人皆托于宦、学、释、老,或诈称客户以自免。其弊遂至不可收拾。当这时代,要想整顿,(一)除非普加清厘,责令兼并的人,将多余的田退还,由官分给无田者。(二)次则置兼并者于不问,而以在官的闲田,补给无田的人。其事都不能行。(三)于是德宗时,杨炎为相,牺牲了社会政策的立法,专就财政上整顿,就有财产之人而收其税,令于夏秋两季交纳(夏输毋过六月,秋输毋过十一月),是为两税。两税法的精意,全在“户无主客,以见居为簿;从无丁中,以贫富为差”十八个字。社会立法之意,虽然牺牲了,以财政政策而论,是不能不称为良法的。\n“两税以资产为宗”,倘使就此加以研究改良,使有产者依其财产的多少,分别等第,负担赋税,而于无产者则加以豁免,则虽不能平均负赋,而在财政上,还不失公平之道,倒也是值得称许的。然后此的苛税,仍是向大多数农民剥削。据《宋史·食货志》所载,宋时的赋税:有田亩之赋和城郭之赋,这是把田和宅地分别征收的,颇可称为合理。又有丁口之赋,则仍是身税。又有杂变之赋,亦称为沿纳,是两税以外,苛取于民,而后遂变为常税的,在理论上就不可容恕了。但各地方的税率,本来轻重不一。苛捐杂税,到整理之时,还能定为常赋,可见在理论上虽说不过去,在事实上为害还是不很大的。其自晚唐以来,厉民最甚,直至明立一条鞭之法,为害才稍除的,则是役法。\n力役是征收人民的劳力的。人民所最缺乏的是钱,次之是物品。至于劳力,则农家本有余闲,但使用之不失其时,亦不过于苛重,即于私人无害,而于公家有益。所以役法行之得当,亦不失为一种良好的赋税(所以现行征工之法,限定可以征工的事项,在立法上是对的)。但是晚唐以后的役法,其厉民却是最甚的。其原因:由于此时之所以役民者,并非古代的力役之征,而是庶人在官之事。古代的力役之征,如筑城郭、宫室,修沟渠、道路等,都是人人所能为的;而且其事可以分割,一人只要应役几日,自然不虑其苛重了。至于在官的庶人,则可分为府、史、胥、徒四种。府是看守财物的。史是记事的。胥是才智之称,所做的,当系较高的杂务。“徒,众也”,是不须才智,而只要用众力之时所使用的,大概用以供奔走。古代事务简单,无甚技术关系,即府、史亦是多数人所能做,胥、徒更不必论了。但此等事务,是不能朝更暮改的。从事其间的,必须视为长久的职业,不能再从事于私人的事业,所以必须给之禄以代耕。后世社会进步了,凡事都有技术的关系,筑城郭、宫室,修沟渠、道路等事,亦有时非人人所能为,何况府、史、胥、徒呢(如徒,似乎是最易为的。然在后世,有追捕盗贼等事,亦非人人所能)?然晚唐以后,却渐根据“丁”、“资”,以定户等而役之。(一)所谓丁资,计算已难平允;(二)而其所以役之之事,又本非其所能为;(三)而官又不免加以虐使,于是有等职务,至于破产而不能给。人民遂有因此而不敢同居,不敢从事生产,甚至有自杀以免子孙之役的。真可谓之残酷无伦了。欲救此弊,莫如分别役的性质。可以役使人民的,依旧签差。不能役使人民的,则由公家出钱雇人充任。这本不过恢复古代力役之征,庶人在官,各不相涉的办法,无甚稀奇,然宋朝主张改革役法的王安石,亦未计及此。王安石所行的法,谓之免役。案宋代役法,原有签差雇募之分。雇役之法:(一)者成为有给职,其人不至因荒废私计而无以为生。(二)者有等事情,是有人会做,有人不会做的,不会做的人要赔累,会做的人则未必然。官出资雇募,应募的自然都是会做这事情的人,决不至于受累,所以雇役之法,远较差役为良。但当时行之,甚不普遍。安石行免役之法:使向来应役的人,出免役钱;不役的人,出助役钱,官以其钱募人充役。此法从我们看来,所失者,即在于未曾分别役的性质,将可以签差之事,仍留为力役之征,而一概出钱雇募。使(一)农民本可以劳力代实物或货币的,亦概须以实物或货币纳税。(二)而公家本可征收人民劳力的事,亦因力役的习惯亡失,动须出钱雇募。于是有许多事情,尤其是建设事务,因此废而不举。这亦是公家的一笔损失。但就雇役和差役两法而论,则雇役之法,胜于差役多了。而当时的旧党,固执成见。元祐时,司马光为相,竟废雇役而仍行差役。此后虽亦差雇并行,总是以差为主,民受其害者又数百年。\n田租、口赋、力役以外的赋税,昔人总称为杂税。看这名目,便有轻视它,不列为财政上重要收入的意思。这是前人见解的陈旧,说已见前。然历代当衰乱之际,此等赋税,还总是有的。如《隋书·食货志》说:晋过江后,货卖奴婢、马牛、田宅、价值万钱者,输钱四百,买者一百,卖者三百,谓之“散估”,此即今日的契税。又说:都东方山津、都西石头津,都有津主,以收荻、炭、鱼、薪之税,十取其一;淮北大市百余,小市十余,都置官司收税,此即商税中之过税及住税。北朝则北齐后主之世,有关、市、邸、店之税。北周宣帝时,有入市税。又酒坊、盐池、盐井,北周亦皆有禁。到隋文帝时,却把这些全数豁免,《文献通考·国用考》盛称之。然以现代财政学的眼光评论,则还是陈旧的见解。到唐中叶以后,藩镇擅土,有许多地方,赋税不入于中央;而此时税法又大坏,中央收入减少,乃不得不从杂税上设法。宋有天下以后,因养兵特多,此等赋税,不能裁撤,南渡以后,国用更窘,更要加意整顿。于是此等杂税,遂渐渐的附庸蔚为大国了。不论在政治上、社会上,制度的改变,总是由事实逼迫出来的多,在理论指导之下发明的少。这亦是政治家的一种耻辱。\n杂税之中,最重要的是盐税。其法,始于唐之第五琦,而备于刘晏。籍民制盐(免其役),谓之灶户,亦谓之亭户。制成之盐,卖之商人,听其所之,不复过问。后人称之为就场征税。宋朝则有(一)官鬻,(二)通商两法。而通商之中,又分为二:(甲)径售之于商人,(乙)则称为入边、入中。入边是“入边刍粟”的略称,入中则是“入中钱帛”的略称。其事,还和茶法及官卖香药、宝货有关系。茶税,起于唐德宗时。其初是和漆与竹木并税的。后曾裁撤,旋又恢复,且屡增其额。其法亦系籍民制造,谓之园户。园户制成的茶,由官收买,再行卖给商人。官买茶的钱,是预给园户的,谓之“本钱”。在江陵、真州、海州、汉阳军、无为军、蕲州的蕲口,设立六个榷货务。除淮南十三场所出的茶以外,都送到这六个榷货务出卖(惟川峡、广南,听其自卖,而禁出境)。京城亦有榷货务,则是只收钱帛而不给货的。宋初,以河东的盐,供给河北的边备。其卖盐之法:是令商人入刍粟于国家指定之处,由该地方的官吏点收,给与收据,估计其价若干,由商人持此据至国家卖盐之处,照价给之以盐,是为入边刍粟;其六榷货务出卖的茶,茶是在各榷货务取,钱帛是在京师榷货务付出的,是为入中钱帛,这是所以省运输之费,把漕运和官卖,合为一事办理的,实在是个良法。至于香药、宝货,则是当时对外贸易的进口货,有半官卖性质的。有时亦以补充入边入中的不足,谓之三说(此即今兑换之兑字。兑换之兑无义,乃脱换之省写,脱说古通用)。有时并益以缗钱,谓之四说。以盐供入边入中之用,其弊在于虚估。点收的官吏和商人串通了,将其所入之物,高抬价格,官物便变成贱价出卖,公家大受损失了。有一个时期,曾废除估价,官以实物卖出,再将所得的钱,辇至出刍粟之处买入(这不啻入边之法已废,仅以官卖某物之价,指定供给某处的边费而已)。但虚估之事,是商人和官吏,都有利益的,利之所在,自然政策易于摇动,不久其法复废。到蔡京出来,其办法却聪明了。他对于商人要贩卖官盐的,给之以引。引分为长、短。有若干引,则准做若干盐的卖买,而这引是要卖钱的。这不是卖盐,只是出卖贩盐的许可证了。茶,先已计算官给本钱所得的息,均摊之于园户,作为租税,而许其与商人直接卖买。至此亦行引法,谓之茶引。蔡京是个贪污奸佞的人,然其所立盐茶之法,是颇为简易的,所以其后遂遵行不变。但行之既久,弊窦又生。因为国家既把盐卖给大商人,不能不保证其销路。于是藉国家的权力,指定某处地方,为某处所产之盐行销之地,是为“引地”。其事起于元朝,至清代而其禁极严。盐的引额,是看销费量而定的,其引地则看水陆运道而定,两者都不能无变更,而盐法未必随之而变,商人恃有法律保护,高抬盐价,于是私盐盛行。因私盐盛行之故,不得不举办缉私,其费用亦极大,盐遂成为征收费极巨的赋税。宋朝入边入中之法,明朝还仿其意而行之。明初,取一部分的盐,专与商人输粮于边的相交易,谓之中盐。运粮至边方,国家固然困难,商人也是困难的。计算收买粮食,运至边方,还不如在边方开垦之有利,商人遂有自出资本,雇人到边上开垦的,谓之商屯。当时的开平卫,就是现在的多伦县一带,土地垦辟了许多。后来因户部改令商人交纳银两,作为库储,商屯才渐次撤废。按移民实边,是一件最难的事。有移殖能力的人,未必有移殖的财力。国家出资移民,又往往不能得有移殖能力的人,空耗财力,毫无成绩。商人重利,其经营,一定比官吏切实些。国家专卖之物,如能划出一部分,专和商人出资移民的相交易,一定能奖励私人出资移民的。国家只须设官管理,规定若干条法律,使资本家不至剥削农民就够了。这是前朝的成法,可以师其意而行之的。又明初用茶易西番之马,含有振兴中国马政,及制驭西番两种用意。因为内地无广大的牧场,亦且天时地利等,养马都不如西番的适宜,而西番马少,则不能为患。其用意,亦是很深远的。当时成绩极佳。后因官吏不良,多与西番私行交易,好马自私,驽马入官,而其法才坏。现在各民族都是一家,虽不必再存什么制驭之意,然藉此以振兴边方的畜牧,亦未尝不是善策。这又是前朝的成法,可以师其意而变通之的。\n酒:历代有禁时多,征榷时少。因为昔人认酒为糜谷,而其物人人能制,要收税或官卖,是极难的。历代收酒税认真的,莫如宋朝。其事亦起于唐中叶以后。宋时,诸州多置“务”自酿。县和镇乡,则有许民酿而收其税的。其收税,多用投标之法,认税最多的人,许其酿造,谓之“扑买”。承酿有一定年限。不及年限,而亏本停止,谓之“败阙”。官吏为维持税收起见,往往不许其停业。于是有勒令婚丧之家,买酒若干的;甚有均摊之于民户的,这变成强迫买酒了,如何可行?但酒税在北宋,只用为地方经费,如“酬奖役人”之类(当重难差徭的,以此调剂它)。到南宋,就列为中央经费了。官吏要维持收入,也是不得不然的。收酒税之法,最精明的,是赵开的“隔酿”,亦称为“隔槽”。行之于四川,由官辟酿酒的场所,备酿酒的器具,使凡要酿酒的,都自备原料,到这里来酿。出此范围之外,便一概是私酒。这是为便于缉私起见,其立法是较简易的,不过取民未免太苛罢了。\n阬冶:在唐朝,或属州郡,或隶盐铁使。宋朝,或官置盐、冶、场、务,或由民承买,而以分数中卖于官,皆属转运使。元朝矿税称为税课,年有定额。此外还有许多无定额的,总称为额外课(额外课中,通行全国的,为契税及历本两项)。\n商税是起于唐朝的藩镇的,宋朝相沿未废。分为住税和过税。住税千分之三十,过税千分之二十。州县多置“监”、“务”收取,关镇亦有设置的。其所税之物,随地不同。照法律都应揭示明白,但实际能否如此,就不可知了。唐宋时的商税,实际上是无甚关系的。关系重要的,倒要推对外的市舶司。\n市舶司起于唐朝。《文献通考》说:唐有市舶使,以右威卫中郎将周庆立为之。代宗广德元年,有广州市舶使吕太一。按庆立事见《新唐书·柳泽传》,吕太一事见《旧唐书·代宗本纪》。又《新书·卢怀慎传》说怀慎之子奂,“天宝初为南海太守,污吏敛手,中人之市舶者,亦不敢干其法。”合此数事观之,似乎唐时的市舶使,多用中人,关系还不甚重要。到宋朝就不然了。宋朝在杭州、明州、秀州、温州、泉州及密州的板桥镇(就是现在的青岛),均曾设立市舶司。海舶至,先十榷其一。其香药、宝货,又须先尽官买,官买足了,才得和人民交易。香药、宝货,为三说之一(已见前)。南宋时又用以称提关会(关子、会子系南宋时纸币之名。提高其价格,谓之称提),可见其和财政大有关系了。元明亦有市舶司。明朝的市舶司,意不在于收税,而在于管理外商。因为明初沿海已有倭寇之故。中叶以后,废司不设。中外互市,无人管理。奸商及各地方的势家,因而欺侮夷人,欠其货款不还,为激成倭寇肆扰原因之一。\n赋役之法,至近代又有变迁。《元史·食货志》说:元代的租税,取于内郡的,丁税、地税分为两,是法唐之租庸调的。取于江南的合为一,是法唐朝的两税的。这不过是名目上的异同,实际都是分两次征收,和两税之法无异。总而言之,从杨炎创两税以后,征收的时期,就都没有改变了。元朝又有所谓丝料、包银。丝料之中,又分二户丝和五户丝,二户丝入官,五户丝输于本位(后妃、公主、宗王、功臣的分地)。包银每户四两,二两收银,二两折收丝绢颜色。这该是所以代户役的,然他役仍不能免。按户役变成赋税,而仍责令人民应役;杂税变成正税,而后来需用杂物,又随时敛取于民,这是历代的通病,正不独元朝为然。明初的赋役,就立法言之,颇为整饬。其制度的根本,是黄册和鱼鳞册两种册籍。黄册以户为主,记各户所有的丁、粮(粮指所有的田),根据之以定赋役。鱼鳞册以田为主,记其地形、地味及所在,而注明其属于何人。黄册由里长管理,照例应有两本。一本存县官处,一本存里长处,半年一换。各户丁粮增减,里长应随时记入册内,半年交官,将存在官处的一本,收回改正。其立法是很精明的。但此等责任,是否里长所能尽?先是一个问题。况且赋役是弊窦很多的。一切恶势力,是否里长所能抗拒?里长是否即系此等黑幕中的一个人?亦是很难说的。所以后来,两册都失实了。明代的役法,分为力差和银差。力差还是征收其劳力的,银差则取其实物及货币。田税是有定额的,役法则向系量出为入。后来凡有需要,即取之于民,谓之加派。无定时,无定额,人民大困。役法向来是按人户的等第,以定其轻重、免否的。人户的等第,则根据丁口资产的多寡推定,是谓“人户物力”。其推定,是很难公平的。因为有些财产,不能隐匿,而所值转微(如牛及农具、桑树等);有些财产,易于隐匿,而所值转巨(如金帛等)。况且人户的规避,吏胥的任意出入,以及索诈、受贿等,都在所不免。历代讫无善策,以除其弊。于是发生专论丁粮和兼论一切资产的问题。论道理,自以兼论一切资产为公平。论手续,却以专论丁粮为简便。到底因为调查的手续太繁了,弊窦太多了,斟酌于二者之间,还是以牺牲理论的公平,而求手续的简便为有利,于是渐趋于专论丁粮之途。加派之弊,不但在其所取之多,尤在于其无定额、无定时,使百姓无从预计。于是有一条鞭之法。总算一州县每一年所需用之数,按阖境的丁粮均摊。自此以外,不得再有征收。而其所谓丁者,并非实际的丁口,乃系通计一州县所有的丁额,摊派之于有田之家,谓之“丁随粮行”。明朝五年一均役,清朝三年一编审,后亦改为五年,所做的都系此项工作。质而言之,乃因每隔几年,贫富的情形变换了,于是将丁额改派一次,和调查丁口,全不相干。役法变迁至此,可谓已行免役之法,亦可谓实已加重田赋而免其役了。加赋偏于田亩,是不合理的。因为没有专令农民负担的理由。然加农民之田赋而免其役,较之唐宋后之役法,犹为此善于彼。因为役事无法分割,负担难得公平。改为征其钱而免其役,就不然了。况且有丁负担赋税的能力小,有产负担赋税的能力大,将向来有丁的负担,转移于有粮之家,也是比较合理的。这是税法上自然的进化。一条鞭之法,起源于江西,后渐遍行于全国,其事在明神宗之世。从晚唐役法大坏至此,约历八百年左右,亦可谓之长久了。这是人类不能以理智支配事实,而听其自然迁流之弊。职是故,从前每州县的丁额,略有定数,不会增加。因为增丁就是增赋,当时推行,已觉困难;后来征收,更觉麻烦,做州县官的人,何苦无事讨事做?清圣祖明知其然,所以落得慷慨,下诏说,康熙五十年以后新生的人丁,永不加赋。到雍正时,就将丁银摊入地粮了。这是事势的自然,不论什么人,生在这时候,都会做的,并算不得什么仁政。从前的人,却一味歌功颂德。不但在清朝时候如此,民国时代,有些以遗老自居的人,也还是这样,这不是没有历史知识,就是别有用心了。清朝因有圣祖之诏,所以始终避免加赋之名。但后来田赋的附加很多,实在亦与加赋无异。又古代的赋税,所税者何物,所取者即系何物。及货币通行以后,渐有(一)径收货币,(二)或本收货物之税,亦改收货币的。(三)又因历代(甲)币制紊乱,(乙)或数量不足,(丙)又或官吏利于上下其手,有本收此物,而改收他物的。总之收税并非全收货币。明初,收本物的谓之“本色”,收货币的谓之“折色”。宣宗以后,纸币废而不行,铜钱又缺乏,赋税渐改征银。田赋在收本色时,本来有所谓耗。系因(子)改装,搬运时,不免有所损失;(丑)又收藏之后,或有腐败及虫蛀、鼠窃等,乃于收税之时,酌加若干。积少成多,于官吏颇有裨益。改收银两以后,因将碎银熔成整铤,经火亦有耗损,乃亦于收银时增加若干,谓之“火耗”。后来制钱充足,收赋时改而收钱,则因银钱的比价,并无一定,官吏亦可将银价抬高,其名目则仍谓之火耗,此亦为农民法外的负担。但从前州县官的行政经费,是不够的,非藉此等弥补不可,所以在币制改革以后,亦仍许征税的人,于税收中提取若干成,作为征收之费。\n近代田赋而外,税收发达的,当推关、盐两税。盐税自南宋以后,收入即逐渐增加。元明清三朝,均为次于田赋的重要赋税。关税起于明宣宗时。当时因纸币跌价,增设若干新税,并增加旧税税额,以收回钞票。后来此等新增的税目和税额,有仍复其旧的,有相沿未废的。关税亦为相沿未废者之一,故称为钞关。清朝称为常关。常关为数有限,然各关都有分关,合计之数亦不少。太平军兴之后,又有所谓厘金,属于布政司而不属于中央。于水陆要路设卡,以多为贵,全不顾交通上自然的形势,以致一种货物的运输,有重复收税,至于数次的,所税的货物及其税额,亦无一定,实为最恶的税法。新海关设于五口通商以后,当时未知关税的重要,贸然许外人以协定税率。庚子战后,因赔款的负担重了,《辛丑和约》我国要求增税。外人乃以裁厘为交换条件。厘不能裁,增税至百分之十二点五之议,亦不能行。民国时代,我国参加欧战,事后在美国所开太平洋会议中,提出关税自主案。外人仍只许我开关税会议,实行《辛丑条约》。十四年开会时,我国又提出关税自主案。许于十八年与裁厘同时并行,同时拟定七级税则,实际上得各国的承认。国民政府宣布关税自主,与各友邦或订关税条约,或于通商条约中订有关涉关税的条款。十八年,先将七级税实施。至二十年,将厘金裁撤后,乃将七级税废去,另订税则颁布。主权一经受损,其恢复之难如此,亦可为前车之鉴了。关、盐两税之外,清代较为重要的,是契税、当税、牙税。此等税意亦在于加以管理,不尽在增加收入。其到晚近才发达的,则有烟酒税、印花税、矿税、所得税。其重要的货物,如卷烟、麦粉、棉纱、火柴、水泥、薰烟、啤酒、洋酒等,则征收统税。国民政府将此等税和关税、盐税、牙税、当税,均列为中央收入。田赋划归地方,和契税、营业税,同为地方收入大宗。军兴以来,各地方有许多苛捐杂税,则下令努力加以废除。在理论上,赋税已渐上轨道,但在事实上,则还待逐渐加以整顿罢了。\n第四十五章 兵制 # 中国的兵制,约可分为八期。\n第一期,在古代,有征服之族和被征服之族的区别。征服之族,全体当兵,被征服之族则否,是为部分民兵制。\n第二期,后来战争剧烈了,动员的军队多,向来不服兵役的人民,亦都加入兵役,是为全体皆兵制。\n第三期,天下统一了,不但用不着全体皆兵,即一部分人当兵,亦觉其过剩。偶尔用兵,为顾恤民力起见,多用罪人及降服的异族。因此,人民疏于军事,遂招致降服的异族的叛乱,是即所谓五胡乱华。而中国在这时代,因乱事时起,地方政府擅权,中央政府不能驾驭,遂发生所谓州郡之兵。\n第四期,五胡乱华的末期,异族渐次和中国同化了,人数减少,而战斗顾甚剧烈,不得已,乃用汉人为兵。又因财政艰窘,不得不令其耕以自养。于是又发生一种部分民兵制,是为周、隋、唐的府兵。\n第五期,承平之世,兵力是不能不腐败的。府兵之制,因此废坏。而其时适值边方多事,遂发生所谓藩镇之兵。因此,引起内乱。内乱之后,藩镇遍于内地,唐室卒因之分裂。\n第六期,宋承唐、五代之后,竭力集权于中央。中央要有强大的常备军。又觑破兵民分业,在经济上的利益。于是有极端的募兵制。\n第七期,元以异族,入主中原,在军事上,自然另有一番措置。明朝却东施效颦。其结果,到底因淤滞而败。\n第八期,清亦以异族入主,然不久兵力即腐败。中叶曾因内乱,一度建立较强大的陆军。然值时局大变,此项军队,应付旧局面则有余,追随新时代则不足。对外屡次败北,而国内的军纪,却又久坏,遂酿成晚清以来的内乱。直至最近,始因外力的压迫,走上一条旷古未有的新途径。\n以上用鸟瞰之法,揭示一个大纲。以下再逐段加以说明。\n第一期的阶级制度,看第四十、第四十四两章,已可明白。从前的人,都说古代是寓兵于农的,寓兵于农,便是兵农合一,井田既废,兵农始分,这是一个重大的误解。寓兵于农,乃谓以农器为兵器,说见《六韬·农器篇》。古代兵器是铜做的,农器是铁做的。兵器都藏在公家,临战才发给(所谓授甲、授兵),也只能供给正式军队用,乡下保卫团一类的兵,是不能给与的。然敌兵打来,不能真个制梃以自卫。所以有如《六韬》之说,教其以某种农器,当某种兵器。古无称当兵的人为兵的,寓兵于农,如何能释为兵农合一呢?江永《群经补义》中有一段,驳正此说。他举《管子》的参国伍鄙,参国,即所谓制国以为二十一乡,工商之乡六,士乡十五,公和高子、国子,各帅五乡。伍鄙,即三十家为邑,十邑为卒,十卒为乡,三乡为县,十县为属,乃所以处农人(按所引《管子》,见《小匡篇》)。又引阳虎欲作乱,壬辰戒都车,令癸巳至(按见《左传》定公八年)。以证兵常近国都。其说可谓甚精。按《周官·夏宫·序官》:王六军,大国三军,次国二军,小国一军。《大司徒》,五家为比,五比为闾,四闾为族,五族为党,五党为州,五州为乡;《小司徒》,五人为伍,五伍为两,四两为卒,五卒为旅,五旅为师,五师为军,则六军适出六乡。六乡之外有六遂,郑《注》说:遂之军法如六乡。其实乡列出兵法,无田制,遂陈田制,无出兵法,郑《注》是错误的(说本朱大韶《实事求是斋经义》、《司马法非周制说》)。六乡出兵,六遂则否,亦兵在国中之证。这除用征服之族居国,被征服之旅居野,无可解释。或谓难道古代各国,都有征服和被征服的阶级吗?即谓都有此阶级,亦安能都用此治法,千篇一律呢?殊不知(一)古代之国,数逾千百,我们略知其情形的,不过十数,安知其千篇一律?(二)何况制度是可以互相模仿的。世既有黩武之国,即素尚平和之国,亦不得不肆力于军事组织以相应,既肆力于军事组织,其制度,自然可以相像的。所以虽非被征服之族,其中的军事领袖及武士,亦可以逐渐和民众相离,而与征服之族,同其位置。(三)又况战士必须讲守御,要讲守御,自不得不居险;而农业,势不能不向平原发展;有相同的环境,自可有相同的制度。(四)又况我们所知道的十余国,如求其根源,都是同一或极相接近的部族,又何怪其文化的相同呢?所以以古代为部分民兵制,实无疑义。\n古代之国,其兵数是不甚多的。说古代军队组织的,无人不引据《周官》。不过以《周官》之文,在群经中独为完具罢了。其实《周官》之制,是和他书不合的。按《诗经·鲁颂》:“公徒三万,”则万人为一军。《管子·小匡篇》说军队组织之法正如此(五人为伍,五十人为小戎,二百人为卒,二千人为旅,万人一军)。《白虎通义·三军篇》说:“虽有万人,犹谦让,自以为不足,故复加二千人”,亦以一军为本万人。《说文》以四千人为一军,则据既加二千人后立说。《梁》襄公十一年,“古者天子六师,诸侯一军”(这个军字,和师字同义。变换其字面,以免重复,古书有此文法),一师当得二千人。《公羊》隐公五年何《注》:“二千五百人称师,天子六师,方伯二师,诸侯一师”,“五百”二字必后人据《周官》说妄增。然则古文家所说的军队组织,较今文家扩充了,人数增多了。此亦今文家所说制度,代表较早的时期,古文家说,代表较晚的时期的一证。当兵的一部分人,居于山险之地,山险之地,是行畦田之制的,而《司马法》所述赋法,都以井田之制为基本,如此,当兵的义务,就扩及全国人了。《司马法》之说,已见第四十四章,兹不再引。按《司马法》以终十为同,同方百里,同十为封,封十为畿,畿方千里。如前一说:一封当得车千乘,士万人,徒二万人;一畿当得车万乘,士十万人,徒二十万人。后一说:一同百里,提封万井,除山川、沈斥、城池、邑居、园囿、术路外,定出赋的六千四百井,所以有戎马四百匹,兵车百乘。一封有戎马四千匹,兵车千乘。一畿有戎马四万匹,兵车万乘。见于《汉书·刑法志》。若计其人数,则一同七千五百,一封七万五千,一畿七十五万。《史记·周本纪》说:牧野之战,纣发卒七十万人,以拒武王;《孙子·用间篇》说:“内外骚动,殆于道路,不得操事者,七十万家”,都系本此以立说。《司马法》之说,固系学者所虚拟,亦必和实际的制度相近。春秋时,各国用兵,最多不过数万。至战国时,却阬降斩级,动以万计。此等记载,必不能全属子虚,新增的兵,从何处来呢?我们看《左传》成公二年,记齐顷公鞍战败北逃回去的时候,“见保者曰:勉之,齐师败矣”,可见其时正式的军队虽败于外,各地方守御之兵仍在。而《战国策》载苏秦说齐宣王之言,说“韩魏战而胜秦,则兵半折,四竟不守;战而不胜,国以危亡随其后”,可见各地方守御之兵,都已调出去,充作正式军队了。这是战国时兵数骤增之由。在中国历史上,真正全国皆兵的,怕莫此时若了。\n秦汉统一以后,全国皆兵之制,便开始破坏。《汉书·刑法志》说:“天下既定,踵秦而置材官于郡国。”《后汉书·光武纪》《注》引《汉官仪》(建武七年)说:“高祖令天下郡国,选能引关、蹶张、材力武猛者,以为轻车骑士、材官、楼船。常以立秋后讲肄课试。”则汉兵制实沿自秦。《汉书·高帝纪》《注》引《汉仪注》(二年)说:“民年二十三为正,一岁为卫士,一岁为材官骑士,习射御骑驰战陈,年五十六衰老,乃得免为庶民,就田里。”《昭帝纪》《注》引如淳说(元凤四年):“更有三品,有卒更,有践更,有过更。古者正卒无常,人皆当迭为之,是为卒更。贫者欲得雇更钱者,次直者出钱雇之,月二千,是为践更。天下人皆直戍边三日,亦名为更,律所谓繇戍也。不可人人自行三日戍,又行者不可往便还,因便住,一岁一更,诸不行者,出钱三百入官,官以给戍者,是为过更。”此为秦汉时人民服兵役及戍边之制。法虽如此,事实上已不能行。晁错说秦人谪发之制,先发吏有谪及赘婿、贾人,后以尝有市籍者,又后以大父母、父母尝有市籍者,后入闾取其左(见《汉书》本传),此即汉世所谓七科谪(见《汉书·武帝纪》天汉四年《注》引张晏说)。二世时,山东兵起,章邯亦将骊山徒免刑以击之。则用罪人为兵,实不自汉代始。汉自武帝初年以前,用郡国兵之时多,武帝中年以后,亦多用谪发。此其原因,乃为免得扰动平民起见。《贾子书·属远篇》说:“古者天子地方千里,中之而为都,输将繇使,远者不五百里而至。公侯地百里,中之而为都,输将繇使,远者不五十里而至。秦输将起海上,一钱之赋,十钱之费弗能致。”此为古制不能行的最大原因。封建时代,人民习于战争,征戍并非所惧。然路途太远,旷日持久,则生业尽废。又《史记·货殖传》说,七国兵起,长安中列侯封君行从军旅,赍贷子钱。则当时从军的人,所费川资亦甚巨。列侯不免借贷,何况平民?生业尽废,再重以路途往来之费,人民在经济上,就不堪负担了。这是物质上的原因。至于在精神上,小国寡民之时,国与民的利害,较相一致,至国家扩大时,即不能尽然,何况统一之后?王恢说战国时一代国之力,即可以制匈奴(见《汉书·韩安国传》)。而秦汉时骚动全国,究竟宣、元时匈奴之来朝,还是因其内乱之故,即由于此。在物质方面,人民的生计,不能不加以顾恤;在精神方面,当时的用兵,不免要招致怨恨,就不得不渐废郡国调发之制,而改用谪发、谪戍了。这在当时,亦有令农民得以专心耕种之益。然合前后而观之,则人民因此而忘却当兵的义务,而各地方的武备,也日益空虚了。所以在政治上,一时的利害,有时与永久的利害,是相反的。调剂于二者之间,就要看政治家的眼光和手腕了。\n民兵制度的破坏,形式上是在后汉光武之时的。建武六年,罢郡国都尉官。七年,罢轻车骑士、材官、楼船。自此各郡国遂无所谓兵备了(后来有些紧要的去处,亦复置都尉。又有因乱事临时设立的。然不是经常、普遍的制度),而外强中弱之机,亦于此时开始。汉武帝置七校尉,中有越骑、胡骑,及长水(见《汉书·百官公卿表》。长水,颜师古云:胡名)。其时用兵,亦兼用属国骑等,然不恃为主要的兵力。后汉光武的定天下,所靠的实在是上谷、渔阳的兵,边兵强而内地弱的机缄,肇见于此。安帝以后,羌乱频仍,凉州一隅,迄未宁静,该地方的羌、胡,尤强悍好斗。中国人好斗的性质,诚不能如此等浅演的降夷,然战争本不是单靠野蛮好杀的事。以当时中国之力,谓不足以制五胡的跳梁,决无此理。五胡乱华的原因,全由于中国的分裂。分裂之世,势必军人专权,专权的军人,初起时或者略有权谋,或则有些犷悍的性质。然到后来,年代积久了,则必入于骄奢淫逸。一骄奢淫逸,则政治紊乱,军纪腐败,有较强的外力加以压迫,即如山崩川溃,不可复止。西晋初年,君臣的苟安、奢侈,正是军阀擅权的结果,五胡扰乱的原因。五胡乱华之世,是不甚用中国人当兵的(说已见第四十章)。其时用汉兵的,除非所需兵数太多,异族人数不足,乃调发以充数。如石虎伐燕,苻秦寇晋诸役是。这种军队,自然不会有什么战斗力的(军队所靠的是训练。当时的五胡,既不用汉人做主力的军队,自然无所谓训练。《北齐书·高昂传》说:高祖讨尒朱兆于韩陵,昂自领乡人部曲三千人。高祖曰:“高都督纯将汉儿,恐不济事,今当割鲜卑兵千余人,共相参杂,于意如何?”昂对曰:“敖曹所将部曲,练习已久,前后战斗,不减鲜卑。今若杂之,情不相合。愿自领汉军,不烦更配。”高祖然之。及战,高祖不利,反借昂等以致克捷。可见军队只重训练,并非民族本有的强弱)。所以从刘、石倡乱以来,至于南北朝之末,北方的兵权,始终在异族手里。这是汉族难于恢复的大原因。不然,五胡可乘的机会,正多着呢?然则南方又何以不能乘机北伐?此则仍由军人专横,中央权力不能统一之故。试看晋朝东渡以后,荆、扬二州的相持,宋、齐、梁、陈之世,中央和地方政府互相争斗的情形,便可知道。\n北强南弱之势,是从东晋后养成的。三国以前,军事上的形势,是北以持重胜,南以剽悍胜。论军队素质的佳良,虽南优于北,论社会文明的程度,则北优于南,军事上胜败的原因,实在于此。后世论者,以为由于人民风气的强弱,实在是错误的(秦虽并六国,然刘邦起沛,项籍起吴,卒以亡秦,实在是秦亡于楚。所以当时的人,还乐道南公“亡秦必楚”之言,以为应验。刘项成败,原因在战略上,不关民气强弱,是显而易见的。吴、楚七国之乱,声势亦极煊赫,所以终于无成,则因当时天下安定,不容有变,而吴王又不知兵之故。孙策、孙权、周瑜、鲁肃、诸葛恪、陆逊、陆抗等,以十不逮一的土地人民,矫然与北方相抗,且有吞并中原之志,而魏亦竟无如之何,均可见南方风气的强悍)。东晋以后,文明的重心,转移于南,训卒厉兵,本可于短期间奏恢复之烈。所以终无成功,而南北分裂,竟达于269年之久,其结果且卒并于北,则全因是时,承袭汉末的余毒,(一)士大夫衰颓不振,(二)军人拥兵相猜,而南方的政权,顾全在此等北来的人手中之故。试设想:以孙吴的君臣,移而置之于东晋,究竟北方能否恢复?便可见得。“洒落君臣契,飞腾战伐名”,无怪杜甫要对吕蒙营而感慨了。经过这长时期的腐化,而南弱的形势遂成。而北方当是时,则因长期的战斗,而造成一武力重心。赵翼《廿二史札记》有一条,说周、隋、唐三代之祖,皆出武川,可见自南北朝末至唐初,武力的重心,实未曾变。按五胡之中,氐、羌、羯民族皆小,强悍而人数较多的,只有匈奴、鲜卑。匈奴久据中原之地,其形势实较鲜卑为佳。但其人太觉凶暴,羯亦然。被冉闵大加杀戮后,其势遂衰。此时北方之地,本来即可平靖。然自东晋以前,虎斗龙争,多在今河北、河南、山东、山西、陕西数省境内。辽宁、热、察、绥之地,是比较安静的。鲜卑人休养生息于此,转觉气完力厚。当时的鲜卑人,实在是乐于平和生活,不愿向中原侵略的。所以北魏平文帝、昭成帝两代,都因要南侵为其下所杀(见《魏书·序纪》)。然到道武帝,卒肆其凶暴,强迫其下,侵入中原(道武帝伐燕,大疫,群下咸思还北。帝曰:“四海之人,皆可与为国,在吾所以抚之耳,何恤乎无民?”群臣乃不敢复言,见《魏书·本纪》皇始二年。按《序纪》说:穆帝“明刑峻法,诸部民多以违命得罪。凡后期者,皆举部戮之。或有室家相携,而赴死所。人问何之?答曰:当往就诛。”其残酷如此。道武帝这话,已经给史家文饰得很温婉的了。若照他的原语,记录下来,那便是“你们要回去,我就要把你们全数杀掉”。所以群臣不敢复言了)。此时割据中原的异族,既已奄奄待毙,宋武帝又因内部矛盾深刻,不暇经略北方,北方遂为所据。然自孝文帝南迁以前,元魏立国的重心,仍在平城。属于南方的侵略,仅是发展问题,对于北方的防御,却是生死问题,所以要于平城附近设六镇,以武力为拱卫。南迁以后,因待遇的不平等,而酿成六镇之乱。因六镇之乱而造成一个尒朱氏。连高氏、贺拔氏、宇文氏等,一齐带入中原。龙争虎斗者,又历五六十年,然后统一于隋。隋唐先世,到底是汉族还是异族,近人多有辩论。然民族是论文化的,不是论血统的。近人所辩论的,都是血统问题,在民族斗争史上,实在无甚意义。至于隋唐的先世,曾经渐染胡化,也是武川一系中的人物,则无可讳言。所以自尒朱氏之起至唐初,实在是武川的武力,在政治舞台上活跃的时代。要到唐贞观以后,此项文化的色彩,才渐渐淡灭(唐初的隐太子、巢剌王、常山愍王等,还都系带有胡化色彩的人)。五胡乱华的已事,虽然已成过去,然在军事上,重用异族的风气,还有存留。试看唐朝用番将番兵之多,便可明白。论史者多以汉唐并称。论唐朝的武功,其成就,自较汉朝为尤大。然此乃世运为之(主要的是中外交通的进步)。若论军事上的实力,则唐朝何能和汉朝比?汉朝对外的征讨,十之八九是发本国兵出去打的,唐朝则多是以夷制夷。这以一时论,亦可使中国的人民,减轻负担,然通全局而观之,则亦足以养成异族强悍,汉族衰颓之势。安禄山之所以蓄意反叛,沙陀突厥之所以横行中原,都由于此。就是宋朝的始终不振,也和这有间接的关系。因为久已柔靡的风气,不易于短时期中训练之使其变为强悍。而唐朝府兵的废坏,亦和其搁置不用,很有关系的。\n府兵之制起于周。籍民为兵,蠲其租调,而令刺史以农隙教练。分为百府,每府以一郎将主之,而分属于二十四军(当时以一柱国主二大将,一将军统二开府,开府各领一军),其众合计不满五万。隋、唐皆沿其制,而分属于诸卫将军。唐制,诸府皆称折冲府。各置折冲都尉,而以左右果毅校尉副之。上府兵一千二百人,中府千人,下府八百人。民年二十服兵役,六十而免。全国六百三十四府,在关中的有二百六十一府,以为强干弱枝之计。府兵之制:平时耕以自养。战时调集,命将统之。师还则将上所佩印、兵各还其府。(一)无养兵之费,而有多兵之用。(二)兵皆有业之民,无无家可归之弊。(三)将帅又不能拥兵自重。这是与藩镇之兵及宋募兵之制相较的优点。从前的论者多称之。但兵不惟其名,当有其实。唐朝府兵制度存在之时,得其用者甚少。此固由于唐时征讨,多用番兵,然府兵恐亦未足大用。其故,乃当时的风气使之,而亦可谓时势及国家之政策使之。兵之精强,在于训练。主兵者之能勤于训练,则在预期其军队之有用。若时值承平,上下都不以军事为意,则精神不能不懈弛;精神一懈弛,训练自然随之而废了。所以唐代府兵制度的废坏,和唐初时局的承平,及唐代外攘,不甚调发大兵,都有关系。高宗、武后时,业已名存实亡。到玄宗时,就竟不能给宿卫了(唐时宿卫之兵,都由诸府调来,按期更换,谓之“番上”。番即现在的班字)。时相张说,知其无法整顿,乃请宿卫改用募兵,谓之骑,自此诸府更徒存虚籍了。\n唐初边兵屯戍的,大的称军,小的称城镇守捉,皆有使以主之。统属军、城镇守捉的曰道。道有大总管,后改称大都督。大都督带使持节的,人称之为节度使。睿宗后遂以为官名。唐初边兵甚少。武后时,国威陵替。北则突厥,东北则奚、契丹,西南则吐蕃皆跋扈。玄宗时,乃于边陲置节度使,以事经略。而自东北至西北边之兵尤强,天下遂成偏重之势。安禄山、史思明皆以胡人而怀野心,卒酿成天宝之乱。乱后藩镇遂遍于内地。其中安史余孽,唐朝不能彻底铲除,亦皆授以节度使。诸镇遂互相结约,以土地传子孙,不奉朝廷的命令。肃、代二世,皆姑息养痈。德宗思整顿之,而兵力不足,反召朱泚之叛。后虽削平朱泚,然河北、淮西,遂不能问。宪宗以九牛二虎之力,讨平淮西,河北亦闻风自服。然及穆宗时,河北即复叛。自此终唐之世,不能戡定了。唐朝藩镇,始终据土自专的,固然只有河北。然其余地方,亦不免时有变乱。且即在平时,朝廷指挥统驭之力,亦总不甚完全。所以肃、代以还,已隐伏分裂之势。至黄巢乱后,遂溃决不可收拾了。然藩镇固能梗命,而把持中央政府,使之不能振作的,则禁军之患,尤甚于藩镇。\n禁军是唐初从征的兵,无家可归的。政府给以渭北闲田,留为宿卫,号称元从禁军。此本国家施恩之意,并非仗以战斗。玄宗时破吐蕃,于临洮之西置神策军。安史之乱,军使成如璆遣将卫伯玉率千人入援,屯于陕州。后如璆死,神策军之地,陷于吐蕃,乃即以伯玉为神策军节度使,仍屯于陕,而中官鱼朝恩以观军容使监其军。伯玉死,军遂统于朝恩。代宗时,吐蕃陷长安,代宗奔陕,朝恩以神策军扈从还京。其后遂列为禁军,京西多为其防地。德宗自奉天归,怀疑朝臣,以中官统其军。其时边兵赏赐甚薄,而神策军颇为优厚,诸将遂多请遥隶神策军,军额扩充至十五万。中官之势,遂不可制。“自穆宗以来八世,而为宦官所立者七君。”(《唐书·僖宗纪》赞语。参看《廿二史札记·唐代宦官之祸》条)顺宗、文宗、昭宗皆以欲诛宦官,或遭废杀,或见幽囚。当时的宦官,已成非用兵力,不能铲除之势。然在宦官监制之下,朝廷又无从得有兵力(文宗时,郑注欲夺宦官之兵而败。昭宗欲自练兵以除宦官而败)。召外兵,则明知宦官除而政权将入除宦官者之手,所以其事始终无人敢为。然相持至于唐末,卒不得不出于此一途。于是宦官尽而唐亦为朱梁所篡了。宦官之祸,是历代多有的,拥兵为患的,却只是唐朝(后汉末,蹇硕欲图握兵,旋为何进所杀)。总之,政权根本之地,不可有拥兵自重的人,宦官亦不过其中之一罢了。\n禁兵把持于内,藩镇偃蹇于外,唐朝的政局,终已不可收拾,遂分裂而为五代十国。唐时的节度使,虽不听政府的命令,而亦不能节制其军队。军队不满意于节度使,往往哗变而杀之,而别立一人。政府无如之何,只得加以任命。狡黠的人,遂运动军士,杀军帅而拥戴自己。即其父子兄弟相继的,亦非厚加赏赐,以饵其军士不可。凡此者,殆已成为通常之局。所谓“地擅于将,将擅于兵”。五代十国,惟南平始终称王,余皆称帝,然论其实,则仍不过一节度使而已。宋太祖黄袍加身,即系唐时拥立节度使的故事,其余证据,不必列举。事势至此,固非大加整顿不可。所以宋太祖务要削弱藩镇,而加强中央之兵。\n宋朝的兵制:兵之种类有四:曰禁军,是为中央军,均属三衙。曰厢军,是为地方兵,属于诸州。曰乡兵,系民兵,仅保卫本地方,不出戍。曰番兵,则系异族团结为兵,而用乡兵之法的。太祖用周世宗之策,将厢军之强者,悉升为禁军,其留为厢军者,不甚教阅,仅堪给役而已。乡兵、番兵,本非国家正式的军队,可以弗论。所以武力的重心,实在禁军。全国须戍守的地方,乃遣禁军更番前往,谓之“番戍”。昔人议论宋朝兵制的,大都加以诋毁。甚至以为唐朝的所以强,宋朝的所以弱,即由于藩镇的存废。这真是瞽目之谈。唐朝强盛时,何尝有什么藩镇?到玄宗设立藩镇时,业已因国威陵替,改取守势了。从前对外之策,重在防患未然。必须如汉之设度辽将军、西域都护,唐之设诸都护府,对于降伏的部落,(一)监视其行动,(二)通达其情意,(三)并处理各部族间相互的关系。总而言之,不使其(一)互相并吞,(二)坐致强大,是为防患未然。其设置,是全然在夷狄境内,而不在中国境内的,此之谓“守在四夷”。是为上策。经营自己的边境,已落第二义了。然果其士马精强,障塞完固,中央的军令森严,边将亦奉令维谨,尚不失为中策。若如唐朝的藩镇擅土,则必自下策而入于无策的。因为军队最怕的是骄,骄则必不听命令,不能对外,而要内讧;内讧,势必引外力以为助,这是千古一辙的。以唐朝幽州兵之强,而不能制一契丹,使其坐大;藩镇遍于内地,而黄巢横行南北,如入无人之境,卒召沙陀之兵,然后把他打平;五代时,又因中央和藩镇的内讧,而引起契丹的侵入,都是铁一般强,山一般大的证据。藩镇的为祸为福,可无待于言了。宋朝的兵,是全出于招募的,和府兵之制相反,论者亦恒右唐而左宋,这亦是耳食之谈。募兵之制,虽有其劣点,然在经济上及政治上,亦自有其相当的价值。天下奸悍无赖之徒,必须有以销纳之,最好是能惩治之、感化之,使改变其性质,此辈在经济上,即是所谓“无赖”,又其性质,不能勤事生产,欲惩治之、感化之极难。只有营伍之中,规律最为森严,或可约束之,使之改变。此辈性行虽然不良,然苟能束之以纪律,其战斗力,不会较有身家的良民为差,或且较胜。利用养兵之费,销纳其一部分,既可救济此辈生活上的无赖,而饷项亦不为虚糜。假若一个募兵,在伍的年限,是十年到二十年,则其人已经过长期的训练;裁遣之日,年力就衰,大多数的性质,必已改变,可以从事于生产,变做一个良民了。以经济原理论,本来宜于分业,平民出饷以养兵,而于战阵之事,全不过问,从经济的立场论,是有益无损的。若谓行募兵之制,则民不知兵,则举国皆兵,实至今日乃有此需要。在昔日,兵苟真能御敌,平民原不须全体当兵。所以说募兵之制,在经济上和政治上,自有其相当的价值。宋代立法之时,亦自有深意。不过所行不能副其所期,遂至利未形而害已见罢了。宋朝兵制之弊在于:(一)兵力的逐渐腐败。(二)番戍之制:(甲)兵不知将,将不知兵,既不便于指挥统驭。(乙)而兵士居其地不久,既不熟习地形,又和当地的人民,没有联络。(丙)三年番代一次,道途之费,却等于三年一次出征。(三)而其尤大的,则在带兵的人,利于兵多,(子)既可缺额刻饷以自肥,(丑)又可役使之以图利。乞免者既不易得许;每逢水旱偏灾,又多以招兵为救荒之策,于是兵数递增。宋开国之时,不满二十万。太祖末年,已增至三十七万。太宗末年,增至六十六万。真宗末年,增至九十一万。仁宗时,西夏兵起,增至一百二十五万。后虽稍减,仍有一百一十六万。欧阳修说:“天下之财,近自淮甸,远至吴、楚,莫不尽取以归京师。晏然无事,而赋敛之重,至于不可复加。”养兵之多如此,即使能战,亦伏危机,何况并不能战,对辽、对夏,都是隐忍受侮;而西夏入寇时,仍驱乡兵以御敌呢?当时兵多之害,人人知之,然皆顾虑召变而不敢裁。直至王安石出,才大加淘汰。把不任禁军的降为厢军,不任厢军的免为民,兵额减至过半。又革去番戍之制,择要地使之屯驻,而置将以统之(以第一、第二为名,全国共九十一将)。安石在军事上,虽然无甚成就,然其裁兵的勇气,是值得称道的。惟其所行民兵之制,则无甚成绩,而且有弊端。\n王安石民兵之法,是和保伍之制连带的。他立保甲之法,以十家为一保,设保长。五十家为一大保,设大保长。五百家为一都保,设都保正、副。家有两丁的,以其一为保丁。其初日轮若干人儆盗。后乃教以武艺,籍为民兵。民兵成绩,新党亦颇自诩(如《宋史》载章惇之言,谓“仕宦及有力之家,子弟欣然趋赴,马上艺事,往往胜诸军”之类)。然据《宋史》所载司马光、王岩叟的奏疏,则其(一)有名无实,以及(二)保正长巡检使等的诛求,真是暗无天日。我们不敢说新党的话,全属子虚,然这怕是少数,其大多数,一定如旧党所说的。因为此等行政上的弊窦,随处可以发现。民兵之制,必要的条件有二:(一)为强敌压迫于外。如此,举国上下,才有忧勤惕厉的精神,民虽劳而不怨。(二)则行政上的监督,必须严密。官吏及保伍之长,才不敢倚势虐民。当时这两个条件,都是欠缺的,所以不免弊余于利。至于伍保之法,起源甚古。《周官》大司徒说:“令五家为比,使之相保。五比为闾,使之相受。四闾为族,使之相葬。五族为党,使之相救。五党为州,使之相赒。五州为乡,使之相宾。”这原和《孟子》“死徙无出乡,乡田同井,出入相友,守望相助,疾病相扶持”之意相同,乃使之互相救恤。商君令什伍相司(同伺)连坐,才使之互相稽查。前者为社会上固有的组织,后者则政治上之所要求。此惟乱时可以行之。在平时,则犯大恶者(如谋反叛逆之类)非极其秘密,即徒党众多,声势浩大(如江湖豪侠之类);或其人特别凶悍,为良民所畏(如土豪劣绅之类),必非人民所能检举。若使之检举小恶,则徒破坏社会伦理,而为官吏开敲诈之门,其事必不能行。所以自王安石创此法后,历代都只于乱时用以清除奸轨,在平时总是有名无实,或并其名而无之的(伍保之法,历代法律上本来都有,并不待王安石的保甲,然亦都不能行)。\n裁募兵,行民兵,是宋朝兵制的一变。自此募兵之数减少。元祐时,旧党执政,民兵之制又废。然募兵之额,亦迄未恢复。徽宗时,更利其缺额,封桩其饷,以充上供,于是募兵亦衰。至金人入犯,以陕西为著名多兵之地,种师道将以入援,仅得一万五千人而已。以兵多著名的北宋,而其结果至于如此,岂非奇谈?\n南渡之初,军旅寡弱。当时诸将之兵,多是靠招降群盗或招募,以资补充的。其中较为强大的,当推所谓御前五军。杨沂中为中军,总宿卫。张俊为前军,韩世忠为后军,岳飞为左军,刘光世为右军,皆屯驻于外,是为四大将。光世死,其军叛降伪齐(一部分不叛的,归于张俊),以四川吴玠之军补其缺。其时岳飞驻湖北,韩世忠驻淮东,张俊驻江东,皆立宣抚司。宗弼再入犯,秦桧决意言和,召三人入京,皆除枢密副使,罢三宣抚司,以副校统其兵,称为统制御前军马。驻扎之地仍旧,谓之某州驻扎御前诸军。四川之兵,亦以御前诸军为号。直达朝廷,帅臣不得节制。其饷,则特设总领以司之,不得自筹。其事略见《文献通考·兵考》。\n北族在历史上,是个侵略民族。这是地理条件所决定的。在地理上,(一)瘠土的民族,常向沃土的民族侵略。(二)但又必具有地形平坦,利于集合的条件。所以像天山南路,沙漠绵延,人所居住的,都是星罗棋布的泉地,像海中的岛屿一般;又或仰雪水灌溉,依天山之麓而建国;以至青海、西藏,山岭崎岖,交通太觉不便,则土虽瘠,亦不能成为侵略民族。历史上侵掠的事实,以蒙古高原为最多,而辽、吉二省间的女真,在近代,亦曾两度成为侵略民族。这是因为蒙古高原,地瘠而平,于侵掠的条件,最为完具。而辽吉二省,地形亦是比较平坦的,且与繁荣的地方相接近,亦有以引起其侵略之欲望。北族如匈奴、突厥等,虽然强悍,初未尝侵入中国。五胡虽占据中国的一部分,然久居塞内,等于中国的乱民,而其制度亦无足观。只有辽、金、元、清四朝,是以一个异民族的资格,侵入中国的;而其制度,亦和中国较有关系。今略述其事如下。\n四朝之中,辽和中国的关系最浅。辽的建国,系合部族及州县而成。部族是它的本族,和所征服的北方的游牧民族。州县则取自中国之地。其兵力,亦是以部族为基本的。部族的离合,及其所居之地,都系由政府指定,不能自由。其人民全体皆隶兵籍。当兵的素质,极为佳良。《辽史》称其“各安旧风,狃习劳事,不见纷华异物而迁。故能家给人足,戎备整完。卒之虎视四方,强朝弱附,部族实为之爪牙”,可谓不诬了。但辽立国虽以部族为基本,而其组织军队,亦非全不用汉人。世徒见辽时的五京乡丁,只保卫本地方,不出戍,以为辽朝全不用汉人做正式军队,其实不然。辽制有所谓宫卫军者,每帝即位,辄置之。出则扈从,入则居守,葬则因以守陵。计其丁数,凡有四十万八千,出骑兵十万一千。所谓不待调发州县部族,而十万之兵已具。这是辽朝很有力的常备军。然其置之也,则必“分州县,析部族”。又太祖征讨四方,皇后述律氏居守,亦摘蕃汉精锐三十万为属珊军。可见辽的军队中,亦非无汉人了。此外辽又有所谓大首领部族军,乃亲王大臣的私甲,亦可率之以从征。国家有事,亦可向其量借。又北方部族,服属于辽的,谓之属国,亦得向其量借兵粮。契丹的疆域颇大,兵亦颇多而强,但其组织不坚凝。所以天祚失道,金兵一临,就土崩瓦解。这不是辽的兵力不足以御金,乃是并没有从事于抵御。其立国本无根柢,所以土崩瓦解之后,亦就更无人从事于复国运动。耶律大石虽然有意于恢复,在旧地,亦竟不能自立了。\n金朝的情形,与辽又异。辽虽风气敦朴,然畜牧极盛,其人民并不贫穷的。金则起于瘠土,人民非常困穷。然亦因此而养成其耐劳而好侵掠的性质。《金史》说其“地狭产薄,无事苦耕,可致衣食;有事苦战,可致俘获”,可见其侵掠的动机了。金本系一小部族,其兵,全系集合女真诸部族而成。战时的统帅,即系平时的部长。在平时称为孛堇,战时则称为猛安谋克。猛安译言千夫长,谋克译言百夫长,这未必真是千夫和百夫,不过依其众寡,略分等级罢了。金朝的兵,其初战斗力是极强的,但迁入中原之后,腐败亦很速。看《廿二史札记·金用兵先后强弱不同》一条,便可知道。金朝因其部落的寡少,自伐宋以后,即参用汉兵。其初契丹、渤海、汉人等,投降金朝的,亦都授以猛安谋克。女真的猛安谋克户,杂居汉地的,亦听其与契丹、汉人相婚姻,以相固结。熙宗以后,渐想把兵柄收归本族。于是罢汉人和渤海人猛安谋克的承袭。移剌窝斡乱后,又将契丹户分散,隶属于诸猛安谋克。自世宗时,将猛安谋克户移入中原,其人既已腐败到既不能耕,又不能战,而宣宗南迁,仍倚为心腹,外不能抗敌,而内敛怨于民。金朝的速亡,实在是其自私本族,有以自召之的。总而言之:文明程度落后的民族,与文明程度较高的民族遇,是无法免于被同化的。像金朝、清朝这种用尽心机,而仍不免于灭亡,还不如像北魏孝文帝一般,自动同化于中国的好。\n元朝的兵制,也是以压制为政策的。其兵出于本部族的,谓之蒙古军。出于诸部族的,谓之探马赤军。既入中原后,取汉人为军,谓之汉军。其取兵之法,有以户论的,亦有以丁论的。兵事已定之后,曾经当过兵的人,即定入兵籍,子孙世代为兵。其贫穷的,将几户合并应役。甚贫或无后的人,则落其兵籍,别以民补。此外无他变动。其灭宋所得的兵,谓之新附军。带兵的人,“视兵数之多寡,为爵秩之崇卑”,名为万户、千户、百户。皆分上、中、下。初制,万户千户死阵者,子孙袭职,死于病者降一等。后来不论大小及身故的原因,一概袭职。所以元朝的军官,可视为一个特殊阶级。世祖和二三大臣定计:使宗王分镇边徼及襟喉之地。河、洛、山东,是他们所视为腹心之地,用蒙古军、探马赤军戍守。江南则用汉军及新附军,但其列城,亦有用万户、千户、百户戍守的。元朝的兵籍,汉人是不许阅看的。所以占据中国近百年,无人知其兵数。观其屯戍之制,是很有深心的。但到后来,其人亦都入洪炉而俱化。末叶兵起时,宗王和世袭的军官,并不能护卫它。\n●元·铜火铳(复制品)长43厘米,口径5.3厘米。为元代具有较大威力的火器。火器本由宋发明,后传至蒙古汗国。蒙军西征时,便是以火炮攻克中亚、欧洲的城堡,而火炮的西传,加速了欧洲封建时代的结束\n元朝以异族入据中国,此等猜防之法,固然无怪其然。明朝以本族人做本族的皇帝,却亦暗袭其法,那就很为无谓了。明制:以五千六百人为卫。一千一百十二人为千户所,一百十二人为百户所(什伍之长,历代都即在其什伍之人数内,明朝则在其外。每一百户所,有总旗二人,小旗十人,所以共为一百十二人)。卫设都指挥使,隶属于五军都督府。兵的来路有三种:第(一)种从征,是开国时固有的兵。第(二)种归附,是敌国兵投降的。第(三)种谪发,则是刑法上罚令当兵的,俗话谓之“充军”。从征和归附,固然是世代为兵,谪发亦然。身死之后,要调其继承人,继承人绝掉,还要调其亲族去补充的,谓之“句丁”。这明是以元朝的兵籍法为本,而加以补充的。五军都督府,多用明初勋臣的子孙,也是模仿元朝军官世袭之制。治天下不可以有私心。有私心,要把一群人团结为一党,互相护卫,以把持天下的权利,其结果,总是要自受其害的。军官世袭之制,后来腐败到无可挽救,即其一端。金朝和元朝,都是异族,他们社会进化的程度本浅,离封建之世未远,猛安谋克和万户千户百户,要行世袭之制,还无怪其然。明朝则明是本族人,却亦重视开国功臣的子孙,把他看做特别阶级,其私心就不可恕了。抱封建思想的人,总以为某一阶级的人,其特权和权利,既全靠我做皇帝才能维持,他们一定会拥护我。所以把这一阶级的人,看得特别亲密。殊不知这种特权阶级,到后来荒淫无度,知识志气,都没有了,何谓权利?怕他都不大明白。明白了,明白什么是自己的权利了,明白自己的权利,如何才得维持了,因其懦弱无用,眼看着他人抢夺他的权利,他亦无如之何。所谓贵戚世臣,理应与国同休戚的,却从来没有这回事,即由于此。武力是不能持久的。持久了,非腐败不可。这其原因,由于战争是社会的变态而非其常态。变态是有其原因的,原因消失了,变态亦即随之而消失。所以从历史上看来,从没有一支真正强盛到几十年的军队(因不遇强敌,甚或不遇战事,未至溃败决裂,是有的。然这只算是侥幸。极强大的军队,转瞬化为无用,这种事实,是举不胜举的。以宋武帝的兵力,而到文帝时即一蹶不振,即其一例。又如明末李成梁的兵力,亦是不堪一击的,侥幸他未与满洲兵相遇罢了。然而军事的败坏,其机实隐伏于成梁之时,这又是其一例。军队的腐败,其表现于外的,在精神方面,为士气的衰颓;在物质方面,则为积弊的深痼。虽有良将,亦无从整顿,非解散之而另造不可。世人不知其原理,往往想就军队本身设法整顿,其实这是无法可设的。因为军队是社会的一部分,不能不受广大社会的影响。在社会学上,较低的原理,是要受较高的原理的统驭的)。“兵可百年不用,不可一日无备”,这种思想,亦是以常识论则是,而经不起科学评判的。因为到有事时,预备着的军队,往往无用,而仍要临时更造。府兵和卫所,是很相类的制度。府兵到后来,全不能维持其兵额。明朝对于卫所的兵额,是努力维持的,所以其缺额不至如唐朝之甚。然以多数的兵力,对北边,始终只能维持守势(现在北边的长城,十分之九,都是明朝所造)。末年满洲兵进来,竟尔一败涂地,则其兵力亦有等于无。此皆特殊的武力不能持久之证。\n清朝太祖崛起,以八旗编制其民。太宗之世,蒙古和汉人归降的,亦都用同一的组织。这亦和金朝人以猛安谋克授渤海、汉人一样。中国平定之后,以八旗兵驻防各处,亦和金朝移猛安谋克户于中原,及元朝镇戍之制,用意相同。惟金代的猛安谋克户,系散居于民间;元朝万户分驻各处,和汉人往来,亦无禁限。清朝驻防的旗兵,则系和汉人分城而居的,所以其冲突不如金元之烈。但其人因此与汉人隔绝,和中国的社会,全无关系,到末造,要筹划旗民生计,就全无办法了。清代的汉兵,谓之绿旗,亦称绿营。中叶以前的用兵,是外征以八旗为主,内乱以绿营为主的。八旗兵在关外时,战斗之力颇强。中国军队强悍的,亦多只能取守势,野战总是失利时居多(洪承畴松山之战,是其一例)。然入关后腐败亦颇速。三藩乱时,八旗兵已不足用了。自此至太平天国兴起时,内地粗觉平安,对外亦无甚激烈的战斗。武功虽盛,实多侥天之幸。所以太平军一起,就势如破竹了。\n中国近代,历史上有两种潮流潜伏着,推波助澜,今犹未已,非通观前后,是不能觉悟出这种趋势来的。这两种潮流:其(一)是南方势力的兴起。南部数省,向来和大局无甚关系。自明桂王据云贵与清朝相抗,吴三桂举兵,虽然终于失败,亦能震荡中原;而西南一隅,始隐然为重于天下。其后太平军兴,征伐几遍全国。虽又以失败终,然自清末革命,至国民政府北伐之成功,始终以西南为根据。现在的抗战,还是以此为民族复兴的策源地的。其(二)是全国皆兵制的恢复。自秦朝统一以后,兵民渐渐分离,至后汉之初,而民兵之制遂废,至今已近二千年了。康有为说,中国当承平时代,是没有兵的。虽亦有称为兵的一种人,其实性质全与普通人民无异(见《欧洲十一国游记》)。此之谓有兵之名,无兵之实。旷观历代,都是当需要用兵时,则产生出一支真正的军队来;事过境迁,用兵的需要不存,此种军队,亦即凋谢,而只剩些有名无实的军队,充作仪仗之用了。此其原理,即由于上文所说的战争是社会的变态,原不足怪。但在今日,帝国主义跋扈之秋,非恢复全国皆兵之制,是断不足以自卫的。更无论扶助其他弱小民族了。这一个转变,自然是极艰难。但环境既已如此,决不容许我们的不变。当中国和欧美人初接触时,全未知道需要改变。所想出来的法子,如引诱他们上岸,而不和他在海面作战;如以灵活的小船,制他笨重的大船等,全是些闭着眼睛的妄论。到咸同间,外患更深了。所谓中兴将帅,(一)因经验较多,(二)与欧美人颇有相当的接触,才知道现在的局面,非复历史上所有。欲图适应,非有相当的改革不可。于是有造成一支军队以适应时势的思想。设船政局、制造局,以改良器械;陆军则改练洋操;亦曾成立过海军,都是这种思想的表现。即至清末,要想推行征兵制。其实所取的办法,离民兵之制尚远,还不过是这种思想。民国二十余年,兵制全未革新,且复演了历史上武人割据之局。然时代的潮流,奔腾澎湃,终不容我不卷入旋涡。抗战以来,我们就一步步的,走入举国皆兵之路了。这两种文化,现在还在演变的中途,我们很不容易看出其伟大。然在将来,作历史的人,一定要认此为划时代的大转变,是毫无可疑的。这两种文化,实在还只是一种。不过因为这种转变,强迫着我们,发生一种新组织,以与时代相适应,而时代之所以有此要求,则缘世界交通而起。在中国,受世界交通影响最早的是南部。和旧文化关系最浅的,亦是南部,受旧文化的影响较浅,正是迎受新文化的一个预备条件。所以近代改革的原动力,全出于南方;南方始终代表着一个开明的势力(太平天国,虽然不成气候,湘淮军诸首领,虽然颇有学问,然以新旧论,则太平天国,仍是代表新的,湘淮军人物,仍是代表旧的。不过新的还未成熟,旧的也还余力未尽罢了)。千回百折,似弱而卒底于有成。\n几千年以来,内部比较平安,外部亦无真正大敌。因此,养成中国(一)长期间无兵,只有需要时,才产生真正的军队;(二)而这军队,在全国人中,只占一极小部分。在今日,又渐渐的改变,而走上全国皆兵的路了。而亘古未曾开发的资源,今日亦正在开发。以此广大的资源,供此众多民族之用,今后世界的战争,不更将增加其惨酷的程度么?不,战争只是社会的变态。现在世界上战争的惨酷,都是帝国主义造成的,这亦是社会的一个变态,不过较诸既往,情形特别严重罢了。变态是决不能持久的。资本的帝国主义,已在开始崩溃了。我们虽有横绝一世的武力,大势所趋,决然要用之于打倒帝国主义之途,断不会加入帝国主义之内,而成为破坏世界和平的一分子。\n第四十六章 刑法 # 谈中国法律的,每喜考究成文法起于何时。其实这个问题,是无关紧要的。法律的来源有二:一为社会的风俗,一为国家对于人民的要求。前者即今所谓习惯,是不会著之于文字的。然其对于人民的关系,则远较后者为切。\n中国刑法之名,有可考者始于夏。《左传》昭公六年,载叔向写给郑子产的信,说:“夏有乱政而作《禹刑》,商有乱政而作《汤刑》,周有乱政而作《九刑》。”这三种刑法的内容,我们无从知其如何,然叔向这一封信,是因子产作刑书而起的。其性质,当和郑国的刑书相类。子产所作的刑书,我们亦无从知其如何,然昭公二十九年,《左传》又载晋国赵鞅铸刑鼎的事。杜《注》说:子产的刑书,也是铸在鼎上的。虽无确据,然士文伯讥其“火未出而作火以铸刑器”,其必著之金属物,殆无可疑。所能著者几何?而《书经·吕刑》说:“墨罚之属千;劓罚之属千;剕罚之属五百;宫罚之属三百;大辟之罚,其属二百;五刑之属三千。”请问如何写得下?然则《吕刑》所说,其必为习惯而非国家所定的法律,很明白可见了。个人在社会之中,必有其所当守的规则。此等规则,自人人如此言之,则曰俗。自一个人必须如此言之,则曰礼(故曰礼者,履也)。违礼,就是违反习惯,社会自将加以制裁,故曰:“出于礼者入于刑。”或疑三千条规则,过于麻烦,人如何能遵守?殊不知古人所说的礼,是极其琐碎的。一言一动之微,莫不有其当守的规则。这在我们今日,亦何尝不如此?我们试默数言语动作之间,所当遵守的规则,何减三千条?不过童而习之,不觉得其麻烦罢了。《礼记·礼器》说“曲礼三千”,《中庸》说“威仪三千”,而《吕刑》说“五刑之属三千”,其所谓刑,系施诸违礼者可知。古以三为多数。言千乃举成数之辞。以十言之而觉其少则曰百,以百言之而犹觉其少则曰千,墨劓之属各千,犹言其各居总数三分之一。剕罚之属五百,则言其居总数六分之一。还有六分之一,宫罚又当占其五分之三,大辟占其五分之二,则云宫罚之属三百,大辟之罚其属二百,这都是约略估计之辞。若真指法律条文,安得如此整齐呢?然则古代人民的生活,其全部,殆为习惯所支配是无疑义了。\n社会的习惯,是人人所知,所以无待于教。若有国有家的人所要求于人民的,人民初无从知,则自非明白晓谕不可。《周官》布宪,“掌宪邦之刑禁(‘宪谓表而县之’,见《周官·小宰注》),正月之吉,执邦之旌节,以宣布于四方。”而州长、党正、族师、闾胥,咸有属民读法之举。天、地、夏、秋四官,又有县法象魏之文。小宰、小司徒、小司寇、士师等,又有徇以木铎之说。这都是古代的成文法,用言语、文字或图画公布的。在当时,较文明之国,必无不如此。何从凿求其始于何时呢?无从自知之事,未尝有以教之,自不能以其违犯为罪,所以说“不教而诛谓之虐”(《论语·尧曰》)。而三宥、三赦之法,或曰不识,或曰遗忘,或曰老旄,或曰蠢愚(《周官·司刺》),亦都是体谅其不知的。后世的法律,和人民的生活,相去愈远;其为人民所不能了解,十百倍于古昔;初未尝有教之之举,而亦不以其不知为恕。其残酷,实远过于古代。即后世社会的习惯,责人以遵守的,亦远不如古代的合理。后人不自哀其所遭遇之不幸,而反以古代的法律为残酷,而自诩其文明,真所谓“溺人必笑”了。\n刑字有广狭二义:广义包括一切极轻微的制裁、惩戒、指摘、非笑而言。“出于礼者入于刑”,义即始此。曲礼三千,是非常琐碎的,何能一有违犯,即施以惩治呢?至于狭义之刑,则必以金属兵器,加伤害于人身,使其蒙不可恢复的创伤,方足当之。汉人说:“死者不可复生,刑者不可复属。”义即如此。此为刑字的初义,乃起于战阵,施诸敌人及间谍内奸的,并不施诸本族。所以司用刑之官曰士师(士是战士,士师谓战士之长),曰司寇。《周官》司徒的属官,都可以听狱讼,然所施之惩戒,至于圜土,嘉石而止(见下)。其附于刑者必归于士,这正和今日的司法机关和军法审判一般。因为施刑的器具(兵器),别的机关里,是没有的。刑之施及本族,当系俘异族之人,以为奴隶,其后本族犯罪的人,亦以为奴隶,而侪诸异族,乃即将异族的装饰,施诸其人之身。所以越族断发文身,而髠和黥,在我族都成为刑罪。后来有暴虐的人,把他推而广之,而伤残身体的刑罚,就日出不穷了。五刑之名,见于《书经·吕刑》。《吕刑》说:“苗民弗用灵,制以刑,惟作五虐之刑曰法。爰始淫为劓、刵、椓、黥。”劓、刵、椓、黥,欧阳、大小夏侯作膑、宫、劓、割头、庶勍(《虞书》标题下《疏》引)。膑即剕。割头即大辟。庶勍的庶字不可解,勍字即黥字,是无疑义的。然则今本的劓、刵、椓、黥是误字。《吕刑》的五刑,实苗民所创(苗民的民字乃贬辞,实指有苗之君,见《礼记·缁衣疏》引《吕刑》郑《注》)。《国语·鲁语》臧文仲说:“大刑用甲兵,其次用斧钺。中刑用刀锯,其次用钻窄。薄刑用鞭朴。大者陈之原野,小者肆之市、朝。”(是为“五服三次”。《尧典》说:“五刑有服,五服三就”,亦即此)大刑用甲兵,是指战阵。其次用斧钺,是指大辟。中刑用刀锯指劓、腓、宫。其次用钻窄指墨。薄刑用鞭朴,虽非金属兵器,然古人亦以林木为兵。(《吕览·荡兵》:“未有蚩尤之时,民固剥林木以战矣。”)《左传》僖公二十七年,楚子玉治兵,鞭七人,可见鞭亦军刑。《尧典》:“象以典刑,流宥五刑,鞭作官刑,朴作教刑。金作赎刑。”象以典刑,即《周官》的县法象魏。流宥五刑,当即《吕刑》所言之五刑。金作赎刑,亦即《吕刑》所言之法。所以必用金,是因古者以铜为兵器。可见所谓“亏体”之刑,全是源于兵争的。至于施诸本族的,则古语说“教笞不可废于家”,大约并鞭、朴亦不能用。最严重的,不过逐出本族之外,是即所谓流刑。《王制》的移郊、移逐、屏诸远方,即系其事。《周官》司寇有圜土、嘉石,皆役诸司空。圜土、嘉石,都是监禁;役诸司空,是罚做苦工,怕已是施诸奴隶的,未必施诸本族了。于此见残酷的刑罚,全是因战争而起的。五刑之中,妇人的宫刑,是闭于宫中(见《周官·司刑》郑《注》),其实并不亏体。其余是无不亏体的。《周官·司刑载》五刑之名,惟膑作刖,余皆与《吕刑》同。《尔雅·释言》及《说文》,均以、刖为一事。惟郑玄《驳五经异义》说:“皋陶改为膑为剕,周改剕为刖。”段玉裁《说文》髌字《注》说:膑是髌的俗字,乃去膝头骨,刖则汉人之斩止,其说殊不足据(髌乃生理名词,非刑名)。当从陈乔枞说,以为斩左趾,跀为并斩右趾为是(见《今文尚书·经说考》)。然则五刑自苗民创制以来,至作《周官》之时,迄未尝改。然古代亏体之刑,实并不止此。见于书传的,如斩(古称斩谓腰斩。后来战阵中之斩级,事与刑场上的割头异,无以名之,借用腰斩的斩字。再后来,斩字转指割头而言,腰斩必须要加一个腰字了)、磔(裂其肢体而杀之。《史记·李斯列传》作矺,即《周官·司戮》之辜)、膊(谓去衣磔之,亦见《周官·司戮》)、车裂(亦曰)、缢(《左传》哀公二年,“绞缢以戮”。绞乃用以缢杀人之绳,后遂以绞为缢杀)、焚(亦见《司戮》)、烹(见《公羊》庄公四年)、脯醢等都是。脯醢当系食人之族之俗,后变为刑法的。刵即馘(割耳),亦源于战争。《孟子》说文王之治岐也,罪人不孥(《梁惠王下篇》)。《左传》昭公二十二年引《康诰》,亦说父子兄弟,罪不相及。而《书经·甘誓》、《汤誓》,都有孥戮之文。可见没入家属为奴婢,其初亦是军法。这还不过没为奴隶而已,若所谓族诛之刑,则亲属都遭杀戮。这亦系以战阵之法,推之刑罚的。因为古代两族相争,本有杀戮俘虏之事。强宗巨家,一人被杀,其族人往往仍想报复,为预防后患起见,就不得不加以杀戮了。《史记·秦本纪》:文公二十年,“法初有三族之罪”(父母、兄弟、妻子),此法后相沿甚久。魏晋南北朝之世,政敌被杀的,往往牵及家属。甚至嫁出之女,亦不能免。可见战争的残酷了。\n古代的用法,其观念,有与后世大异的。那便是古代的“明刑”,乃所以“弼教”(“明于五刑,以弼五教”,见《书经·尧典》),而后世则但求维持形式上的互助。人和人的相处,所以能(一)平安无事,(二)而且还可以有进步,所靠的全是善意。苟使人对人,人对社会,所怀挟的全是善意,一定能彼此相安,还可以互相辅助,日进无疆,所做的事情,有无错误,倒是无关紧要的。若其彼此之间,都怀挟敌意,仅以慑于对方的实力、社会的制裁,有所惮而不敢为;而且进而作利人之事,以图互相交换,则无论其所行的事,如何有利于人,有利于社会,根本上总只是商业道德。商业道德,是决无以善其后的。人,本来是不分人我,不分群己的。然到后来,社会的组织复杂了,矛盾渐渐深刻,人我群己的利害,渐渐发生冲突,人,就有破坏他人或社会的利益以自利的。欲救此弊,非把社会阶级彻底铲除不可。古人不知此义,总想以教化来挽回世风。教化之力不足,则辅之以刑罚。所以其用法,完全注重于人的动机。所以说《春秋》断狱重志(《春秋繁露·精华篇》),所以说:“听讼吾犹人也,必也使无讼乎?无情者不得尽其辞,大畏民志,此谓知本。”(《大学》)此等希望,自然要终成泡影的。法律乃让步到不问人的动机,但要求其不破坏我所要维持的秩序为止。其用心如何,都置诸不问。法律至此,就失其弼教的初意,而只成为维持某种秩序的工具了。于是发生“说官话”的现象。明知其居心不可问,如其行为无可指摘,即亦无如之何。法律至此,乃自成为反社会之物。\n有一事,是后世较古代为进步的。古代氏族的界限,还未化除。国家的权力,不能侵入氏族团体之内,有时并不能制止其行动。(一)氏族员遂全处于其族长权力之下。此等风气在家族时代,还有存留。(二)而氏族与氏族间的争斗,亦往往靠实力解决。《左传》成公三年,知被楚国释放的时候,说“首(父),其请于寡君,而以戮于宗,亦死且不朽”。昭公二十一年,宋国的华费遂说:“吾有谗子而弗能杀。”可见在古代,父可专杀其子。《白虎通义·诛伐篇》却说“父杀其子当诛”了。《礼记》的《曲礼》、《檀弓》,均明著君父、兄弟、师长,交游报仇之礼。《周官》的调人,是专因报仇问题而设立的。亦不过令有仇者避之他处;审查报仇的合于义与否;禁止报仇不得超过相当限度而已,并不能根绝其事。报仇的风气,在后世虽相沿甚久,习俗上还视为义举,然在法律上,总是逐步遭遇到禁止的,这都是后世法律,较之古代进步之处。但家长或族长,到现在,还略有处置其家人或族众的权力,国家不能加以干涉,使人人都受到保护;而国家禁止私人复仇,而自己又不能真正替人民伸雪冤屈,也还是未尽善之处。\n法律是不能一天不用的,苟非文化大变,引用别一法系的法律,亦决不会有什么根本的改革。所以总是相承而渐变。中国最早的法典,是李悝的《法经》。据《晋书·刑法志》所载陈群《魏律序》,是悝为魏文侯相,撰次诸国法所为。魏文侯在位,据《史记·六国表》,是自周威烈王二年至安王十五年,即民国纪元前2336年至2298年。可谓很古老的了。撰次,便是选择排比。这一部书,在当时,大约所参考者颇博,且曾经过一番斟酌去取,依条理系统编排的,算做一部佳作。所以商君“取之以相秦”,没有重纂。这时候的趋势,是习惯之力(即社会制裁),渐渐的不足以维持社会,而要乞灵于法律。而法律还是谨守着古老的规模,所规定之事极少,渐觉其不够用,法经共分六篇:《魏律序》举其篇目,是(一)盗,(二)贼,(三)网,(四)捕,(五)杂,(六)又以一篇著其加减。盗是侵犯人的财产。贼是伤害人的身体。盗贼须网捕,所以有网捕两篇。其余的则并为杂律。古人著书,常将重要的事项,独立为篇,其余则并为一篇,总称为杂。一部自古相传的医书,号为出于张仲景的,分为伤寒、杂病两大部分(杂病或作卒病,乃误字),即其一证。网捕盗贼,分为四篇,其余事项,共为一篇,可见《法经》视盗贼独重,视其余诸事项都轻,断不足以应付进步的社会。汉高祖入关,却更做了一件违反进化趋势的事。他说:“吾与父老约法三章耳:杀人者死,伤人及盗抵罪。余悉除去秦法。”因为约法三章四字,给人家用惯了,很有些人误会:这是汉高祖与人民立约三条。其实据陈群《魏律序》,李悝《法经》的体例,是“集类为篇,结事为章”的。每一篇之中,包含着许多章。“吾与父老约:法,三章耳”,当以约字断句,法字再一读。就是说六篇之法,只取三章,其余五篇多,都把它废掉了。秦时的民不聊生,实由于政治太不安静。专就法律立论,则由于当时的狱吏,自成一种风气,用法务取严酷。和法律条文的多少,实在没有关系。但此理是无从和群众说起的。约法三章,余悉除去,在群众听起来,自然是欢欣鼓舞的了。这事不过是一时收买人心之术,无足深论。其事自亦不能持久。所以《汉书·刑法志》说:天下既定,“三章之法,不足以御奸”。萧何就把六篇之法恢复,且增益三篇;叔孙通又益以律所不及的旁章十八篇,共有二十七篇了。当时的趋势,是(一)法律内容要扩充,(二)既扩充了,自应依条理系统,加以编纂,使其不至杂乱。第一步,汉初已这么做了。武帝时,政治上事务繁多,自然需要更多的法律。于是张汤、赵禹又加增益,律共增至六十篇。又当时的命令,用甲、乙、丙、丁编次,通称谓之“令甲”,共有三百余篇。再加以断事的成案,即当时所谓比,共有九百零六卷。分量已经太多了,而编纂又极错乱。“盗律有贼伤之例,贼律有盗章之文。”引用既难,学者乃为之章句(章句二字,初指一种符号,后遂用以称注释,详见予所撰《章句论》。商务印书馆本),共有十余家。于是断罪所当由用者,合二万六千二百七十二条,七百七十三万二千二百余言。任何人不能遍览,奸吏因得上下其手,“所欲活者傅生议,所欲陷者予死比”。所以条理系统地编纂一部法典,实在是当时最紧要的事。汉宣帝时,郑昌即创其议。然终汉世,未能有成。魏篡汉后,才命陈群等从事于此。制成新律十八篇,未及颁行而亡。晋代魏后,又命贾充等复加订定,共为二十篇。于泰始四年,大赦天下颁行之,是为《晋律》。泰始四年,为民国纪元前1644年。\n《晋律》大概是将汉朝的律、令、比等,删除复重,加以去取,依条理系统编纂而成的。这不过是一个整理之业。但还有一件事可注意的,则儒家的宗旨,在此时必有许多,掺入法律之中,而成为条文。汉人每有援经义以折狱的。现代的人,都以为奇谈。其实这不过是广义的应用习惯。广义的习惯法,原可包括学说的。当时儒学盛行,儒家的学说,自然要被应用到法律上去了。《汉书》《注》引应劭说:董仲舒老病致仕。朝廷每有政议,数遣廷尉张汤至陋巷,问其得失。于是作《春秋折狱》二百三十二事。汉文帝除肉刑诏,所引用的就是《书》说(见下)。汉武帝亦使吕步舒(董仲舒弟子)治淮南狱。可见汉时的律、令、比中,掺入儒家学说处决不少。此等儒家学说,一定较法家为宽仁的。因为法家偏重伸张国家的权力,儒家则注重保存社会良好的习惯。章炳麟《太炎文录》里,有《五朝法律索隐》一篇,说《晋律》是极为文明的,但北魏以后,参用鲜卑法,反而改得野蛮了。如《晋律》,父母杀子同凡论,而北魏以后,都得减轻。又如走马城市杀人者,不得以过失论(依此,则现在马车、摩托,在市上杀人的,都当以故杀论。因为城市中行人众多,是行车者所预知的,而不特别小心,岂得谓之过失?难者将说:“如此,在城市中将不能行车了。文明愈进步,事机愈紧急,时间愈宝贵,处处顾及步行的人,将何以趋事赴功呢?”殊不知事机紧急,只是一个藉口。果有间不容发的事,如军事上的运输,外交上的使命,以及弭乱、救火、急救疾病等事,自可别立为法,然在今日,撞伤路人之事,由于此等原因者,共有几分之几呢?曾记在民国十至十二年之间,上海某外人,曾因嫌人力车夫走得慢,下车后不给车资,直向前行。车夫向其追讨,又被打伤。经领事判以监禁之罪。后其人延律师辩护,乃改为罚锾了事。问其起衅之由,则不过急欲赴某处宴会而已。从来鲜车怒马疾驰的人,真有紧急事情的,不知有百分之一否?真正紧要的事情,怕还是徒行或负重的人做的),部民杀长吏者同凡论,常人有罪不得赎等,都远胜于别一朝的法律。父杀其子当诛,明见于《白虎通义》,我们可以推想,父母杀子同凡论,渊源或出于儒家。又如法家,是最主张摧抑豪强的。城市走马杀人同凡论,或者是法家所制定。然则法律的改良,所得于各家的学说者当不少。学者虽然亦不免有阶级意识,究竟是为民请命之意居多。从前学者,所做的事情,所发的言论,我们看了,或不满意,此乃时代为之。近代的人,有时严责从前的学者,而反忽忘了当时的流俗,这就未免太不知社会的情形了。\n《晋律》订定以后,历代都大体相沿。宋、齐是未曾定律的。梁、陈虽各定律,大体仍沿《晋律》。即魏、周、齐亦然,不过略参以鲜卑法而已。《唐律》是现尚存在的,体例亦沿袭旧观。辽太祖时,定治契丹及诸夷之法,汉人则断以律令。太宗时,治渤海人亦依汉法。道宗时,以国法不可异施,将不合于律令者别存之。此所谓律令,还是唐朝之旧。金当熙宗时,始合女真旧制及隋、唐、辽、宋之法,定《皇统制》。然仍并用古律。章宗泰和时定律,《金史》谓其实在就是《唐律》。元初即用金律。世祖平宋以后,才有所谓《至元新格》、《大元通制》等,亦不过将新生的法令事例,加以编辑而已。明太祖定《大明律》,又是一准《唐律》的。《法律》又以《明律》为本。所以从《晋律》颁行以后,直至清末采用西法以前,中国的法律,实际无大改变。\n法律的性质,既如此陈旧,何以仍能适用呢?(一)由向来的法律,只规定较经久之事。如晋初定律,就说关于军事、田农、酤酒等,有权设其法,未合人心的,太平均当删除,所以不入于律,别以为令。又如北齐定律,亦有《新令》四十卷和《权令》二卷,与之并行。此等区别,历代都有。总之非极永久的部分,不以入律,律自然可少变动了。(二)则律只揭举大纲。(甲)较具体及(乙)变通的办法,都在令及比之中。《唐书·刑法志》说:“唐之刑书有四:曰律、令、格、式。令者,尊卑贵贱之等数,国家之制度也。格者,百官有司所常行之事也。式者,其所常守之法也(宋神宗说:‘设于此以待彼之谓格,使彼效之谓式。’见《宋史·刑法志》)。凡邦国之政,必从事于此三者。其有所违,及人之为恶而入于罪戾者,一断以律。”令、格、式三者,实不可谓之刑书。不过现代新生的事情,以及办事所当依据的手续,都在其中,所以不得不与律并举。律所载的事情,大约是很陈旧而不适宜于具体应用的,但为最高原理所自出,又不便加以废弃。所以宋神宗改律、令、格、式之名为敕、令、格、式,而“律恒存乎敕之外”。这即是实际的应用,全然以敕代律了。到近代,则又以例辅律。明孝宗弘治十三年,刑官上言:“中外巧法吏,或借例便私,律浸格不用。”于是下尚书,会九卿议,增历年问刑条例,经久可行者二百九十七条。自是以后,律例并行。清朝亦屡删定刑例。至乾隆以后,遂载入律内,名为《大清律例》。按例乃据成案编纂而成,成案即前世所谓比。律文仅举大纲,实际应用时,非有业经办理的事情,以资比附不可,此比之所以不能不用。然成案太多,随意援引,善意者亦嫌出入太大,恶意者则更不堪设想,所以又非加以限制不可。由官加以审定,把(一)重复者删除;(二)可用者留;(三)无用者废;(四)旧例之不适于用者,亦于同时加以废止。此为官修则例之所由来,不徒(一)杜绝弊端,(二)使办事者得所依据,(三)而(甲)社会上新生的事态,日出不穷;(乙)旧有之事,定律时不能无所遗漏;(丙)又或法律观念改易,社会情势变迁,旧办法不适于今;皆不可不加补正。有新修刑例以济之,此等问题,就都不足为患了。清制:刑例五年一小修,十年一大修(事属刑部,临时设馆),使新成分时时注入于法律之中;陈旧而不适用者,随时删除,不致壅积。借实际的经验,以改良法律,实在是很可取法的。\n刑法自汉至隋,起了一个大变化。刑字既引申为广义,其初义,即专指伤害人之身体,使其蒙不可恢复的创伤的,乃改称为“肉刑”。晚周以来,有一种象刑之论,说古代对于该受五刑的人,不须真加之以刑,只要异其冠服以为戮。此乃根据于《尧典》之“象以典刑”的,为儒家的书说。按象以典刑,恐非如此讲法(见前)。但儒家所说的象刑,在古代是确有其事的。《周官》有明刑(见司救)、明梏(见掌囚),乃是将其人的姓名罪状,明著之以示人。《论衡·四讳篇》说:当时“完城旦以下,冠带与俗人殊”,可见历代相沿,自有此事,不过在古代,风气诚朴,或以此示戒而已足,在后世则不能专恃此罢了。儒家乃根据此种习俗,附会《书经》象以典刑之文,反对肉刑的残酷。汉孝文帝十三年,齐太仓令淳于公有罪,当刑。防狱逮系长安。淳于公无男,有五女。会逮,骂其女曰:“生子不生男,缓急非有益也。”其少女缇萦,自伤悲泣。乃随其父至长安,上书愿没入为官婢,以赎父刑罪。书奏,天下怜悲其意。遂下令曰:“盖闻有虞氏之时,画衣冠异章服以为戮而民弗犯,何治之至也?今法有肉刑三,而奸不止,其咎安在?夫刑至断肢体,刻肌肤,终身不息,何其刑之痛而不德也?岂称为民父母之意哉?其除肉刑,有以易之。”于是有司议:当黥者髠钳为城旦舂。当劓者笞三百。当斩左趾者笞五百。当斩右趾者弃市。按诏书言今法有肉刑三,《注》引孟康曰:“黥、劓二,斩左右趾合一,凡三也。”而景帝元年诏,说孝文皇帝除宫刑。诏书下文刻肌肤指黥,断肢体指劓及斩趾,终身不息当指宫,则是时实并宫刑废之。惟系径废而未尝有以为代,故有司之议不之及。而史亦未尝明言。此自古人文字疏略,不足为怪。至景帝中元年,《纪》载“死罪欲腐者许之”,则系以之代死罪,其意仍主于宽恤。然宫刑自此复行,直至隋初方除。象刑之论,《荀子》极驳之。《汉书·刑法志》备载其说,自有相当的理由。然刑狱之繁,实有别种原因,并非专用酷刑可止。《庄子·则阳篇》说:“柏矩至齐,见辜人焉。推而强之。解朝服而幕之。号天而哭之。曰:子乎子乎?天下有大菑,子独先离之。曰:莫为盗,莫为杀人。荣辱立,然后睹所病,货财聚,然后睹所争,今立人之所病,聚人之所争,穷困人之身,使无休时,欲无至此,得乎。匿为物而愚不识,大为难而罪不敢,重为任而罚不胜,远其涂而诛不至。民知力竭,则以伪继之。日出多伪,士民安取不伪?夫力不足则伪,知不足则欺,财不足则盗。盗窃之行,于谁责而可乎?”这一段文字,见得所谓犯罪者,全系个人受社会的压迫,而无以自全;受社会的教育,以至不知善恶(日出多伪,士民安取不伪),其所能负的责任极微。更以严刑峻法压迫之,实属不合于理。即不论此,而“民不畏死,奈何以死惧之”(《老子》),于事亦属无益。所以“孟氏使阳肤为士师,问于曾子。曾子曰:上失其道,民散久矣。如得其情,则哀矜而勿喜”(《论语·子张》),这固然不是彻底的办法。然就事论事,操司法之权的,存心究当如此。司法上的判决,总不能无错误的。别种损失,总还可设法回复,惟有肉刑,是绝对无法的,所以古人视之甚重。这究不失为仁人君子的用心。后来反对废除肉刑的人,虽亦有其理由,然肉刑究竟是残酷的事,无人敢坚决主张,始终没有能够恢复。这其中,不知保全了多少人。孝文帝和缇萦,真是历史上可纪念的人物了。反对废除肉刑的理由安在呢?《文献通考》说:“汉文除肉刑,善矣,而以髠笞代之。髠法过轻,而略无惩创;笞法过重,而至于死亡。其后乃去笞而独用髠。减死罪一等,即止于髠钳;进髠钳一等,即入于死罪。而深文酷吏,务从重比,故死刑不胜其众。魏晋以来病之。然不知减笞数而使之不死,徒欲复肉刑以全其生,肉刑卒不可复,遂独以髠钳为生刑。所欲活者傅生议,于是伤人者或折肢体,而才翦其毛发。所欲陷者与死比,于是犯罪者既已刑杀,而复诛其宗亲。轻重失宜,莫此为甚。隋唐以来,始制五刑,曰笞、杖、徒、流、死。此五者,即有虞所谓鞭、朴、流、宅,虽圣人复起,不可偏废也。”按自肉刑废除之后,至于隋代制定五刑之前,刑法上的问题,在于刑罚的等级太少,用之不得其平。所以司法界中有经验的人士,间有主张恢复肉刑的。而读书偏重理论的人,则常加反对。恢复肉刑,到底是件残酷的事,无人敢坚决主张,所以肉刑终未能复。到隋朝制定五刑以后,刑罚的等级多了,自无恢复肉刑的必要,从此以后,也就无人提及了。自汉文帝废除肉刑至此,共历七百五十余年。一种制度的进化,可谓不易了。\n隋唐的五刑,是各有等级的。其中死刑分斩、绞两种。而除前代的枭首、裂等。元以异族入主中原,立法粗疏,且偏于暴虐。死刑有斩无绞。又有凌迟处死,以处恶逆。明清两代均沿之。明代将刑法军政,并为一谈。五刑之外,又有所谓充军。分附近、沿海、边远、烟瘴、极边五等(清分附近、近边、边远、极边、烟瘴五等)。有终身、永远两种。永远者身死之后,又勾摄其子孙;子孙绝者及其亲属(已见上章)。明制:“二死三流,同为一减。”太祖为求人民通晓法律起见,采辑官民过犯条文,颁行天下,谓之《大诰》。囚有《大诰》的,罪得减等。后来不问有无,一概作为有而减等。于是死刑减至流刑的,无不以《大诰》再减,流刑遂等于不用。而充军的却很多。清朝并不藉谪发维持军籍,然仍沿其制,为近代立法史上的一个污点。\n●唐律疏议\n刑法的改良,起于清末的改订旧律。其时改笞杖为罚金,以工作代徒流。后来定《新刑律》,才分主刑为死刑(用绞,于狱中行之)、无期徒刑、有期徒刑、拘役、罚金五种。从刑为没收,褫夺公权两种。\n审判机关,自古即与行政不分。此即《周官》地官所谓“地治者”。但属于秋官的官,如乡士(掌国中)、遂士(掌四郊)、县士(掌野)、方士(掌都家)等,亦皆以掌狱讼为职。地官、秋官,本当有行政官与军法审判之别,读前文可明,但到后来,这两者的区别,就渐渐的泯灭了。欧洲以司法独立为恤刑之法,中国则以(一)缩小下级官吏定罪的权限,如(二)增加审级,为恤刑之法。汉代太守便得专杀,然至近代,则府、厅、州、县,只能决徒以下的罪,流刑必须由按察司亲审,死刑要待御笔勾决了。行政、司法机关既不分,则行政官吏等级的增加,即为司法上审级的增加。而历代于固有的地方官吏以外,又多临时派官清理刑狱。越诉虽有制限,上诉是习惯上得直达皇帝为止的,即所谓叩阍。宋代初命转运使派官提点刑狱,后独立为一司,明朝继之,设按察司,与布政使并立,而监司之官,始有专司刑狱的。然及清代,其上级的督抚,亦都可受理上诉。自此以上,方为京控(刑部、都察院、提督,均可受理)。临时派官复审,明代尤多。其后朝审、秋审,遂沿为定制。清代秋审是由督抚会同两司举行的。决定后由刑部汇奏。再命三法司(见下)复审,然后御笔勾决,死刑乃得执行。在内的则由六部、大理寺、通政司、都察院会审,谓之朝审。此等办法,固得慎重刑狱之意。然审级太多,则事不易决。又路途遥远,加以旷日持久,人证物证,不易调齐,或且至于湮灭,审判仍未必公平,而人民反因狱事拖延受累。所以此等恤刑之法,亦是有利有弊的。\n司法虽不独立,然除特设的司法官吏而外,干涉审判之官,亦应以治民之官为限。如此,(一)系统方不紊乱。(二)亦且各种官吏,对于审判,未必内行,令其干涉,不免无益有损。然历代既非司法之官,又非治民之官,而参与审判之事者,亦在所不免。如御史,本系监察之官,不当干涉审判。所以弹劾之事,虽有涉及刑狱的,仍略去告诉人的姓名,谓之风闻。唐代此制始变,且命其参与推讯,至明,遂竟称为三法司之一了。而如通政司、翰林院、詹事府、五军都督等,无不可临时受命,与于会审之列,更属莫名其妙。又司法事务,最忌令军政机关参与。而历代每将维持治安及侦缉罪犯之责,付之军政机关。使其获得人犯之后,仍须交给治民之官,尚不易非理肆虐,而又往往令其自行治理,如汉代的司隶校尉,明代的锦衣卫、东厂等,尤为流毒无穷。\n审判之制,贵于速断速决,又必熟悉本地方的民情。所以以州县官专司审制,于事实嫌其不给。而后世的地方官,多非本地人,亦嫌其不悉民情。廉远堂高,官民隔膜,吏役等遂得乘机舞弊。司法事务的黑暗,至于书不胜书。人民遂以入公门为戒。官吏无如吏役何,亦只得劝民息讼。国家对于人民的义务,第一事,便在保障其安全及权利,设官本意,惟此为急。而官吏竟至劝人民不必诉讼,岂非奇谈?古代所谓“地治者”,本皆后世乡吏之类,汉代啬夫,还是有听讼之职的(《汉书·百官公卿表》)。爰延为外黄乡啬夫,民至不知有郡县(《后汉书》本传),其权力之大可知。然治者和被治者,既形成两个阶级,治者专以朘削被治者为生,则诉讼正是朘削的好机会,畀乡吏以听讼之权,流弊必至不可究诘。所以至隋世,遂禁止乡官听讼。《日知录·乡亭之职》一条说:“今代县门之前,多有榜曰:诬告加三等,越诉笞五十。此先朝之旧制。今人谓不经县官而上诉司府,谓之越诉,是不然。《太祖实录》:洪武二十七年,命有司择民间高年老人,公正可任事者,理其乡之辞讼。若户婚、田宅、斗殴者,则会里胥决之。事涉重者,始白于官。若不由里老处分,而径诉县官,此之谓越诉也。”则明太祖尝有意恢复乡官听讼之制。然《注》又引宣德七年陕西按察佥事林时之言,谓“洪武中,天下邑里,皆置申明,旌善二亭,民有善恶则书之,以示劝惩。凡户婚、田土、斗殴常事,里老于此剖决。今亭宇多废,善恶不书。小事不由里老,辄赴上司。狱讼之繁,皆由于此”。则其事不久即废。今乡官听讼之制,固不可行。然法院亦难遍设。民国十五年,各国所派的司法调查委员(见下),以通计四百万人乃有一第一审法院,为我国司法状况缺点之一。中国人每笑西洋人的健讼,说我国人无须警察、司法,亦能相安,足见道德优于西人。其实中国人的不愿诉讼,怕也是司法状况的黑暗逼迫而成的,并非美事。但全靠法院平定曲直,确亦非良好现象。不须多设法院,而社会上亦能发扬正义,抑强扶弱,不至如今日之豪暴横行;乡里平亭,权又操于土豪劣绅之手,是为最善。那就不得不有望于风俗的改良了。\n古代的法律,本来是属人主义的,中国疆域广大,所包含的民族极多。强要推行同一的法律,势必引起纠纷。所以自古即以“不求变俗”为治(《礼记·曲礼》),统一以后,和外国交通,亦系如此。《唐律》:化外人犯罪,就依其国法治之。必两化外人相犯,不能偏据一国的法律,才依据中国法律治理。这种办法,固然是事实相沿,然决定何者为罪的,根本上实在是习惯。两族的习惯相异,其所认为犯罪之事,即各不相同。“照异族的习惯看起来,虽确有犯罪的行为,然在其本人,则实无犯罪的意思。”在此情形之下,亦自以按其本族法律治理为公平。但此项办法,只能适用于往来稀少之时。到近代世界大通,交涉之事,日益繁密,其势就不能行了。中国初和外国订约时,是不甚了然于另一新局面的来临的。一切交涉,都根据于旧见解以为应付,遂贸然允许了领事裁判权。而司法界情形的黑暗(主要的是司法不独立,监狱的黑暗,滥施刑讯及拘押等),有以生西人的戒心,而为其所藉口,亦是无可讳言的(从事有领事裁判权的国家,如土耳其,有虐待异教徒的事实,我国则无之。若说因习惯的不同,则应彼此皆有)。中外条约中,首先获得领事裁判权的是英国,后来各国相继获得。其条文彼此互异,然因各国条约均有最惠国条款,可以互相援引,所以实际上并无甚异同。有领判权之国,英、美、意、挪威、日本,均在我国设立法院。上海的会审公廨,且进而涉及原被告均为华人的事件。其损害我国的主权,自然无待于言了。然各国亦同蒙其不利(最重要的,如领事不晓法律,各国相互之间,亦须各归其国的领事审判。一件事情,关涉几国人的,即须分别向各国起诉。又上诉相距太远,即在中国设有法院之国亦然,其他更不必论了)。且领事裁判权存在,中国决不能许外国人在内地杂居,外人因此自限制其权利于通商口岸,亦殊不值得。取消领事裁判权之议,亦起于《辛丑条约》。英、美、日三国商约,均有俟我法律及司法制度改良后,撤消领事裁判权的条文。太平洋会议,我国提出撤消领事裁判权案,与会各国,允共同派员,到中国来调查:(一)各国在我国的领事裁判权的现状,(二)我国的法律,(三)司法制度,(四)司法行政情形,再行决定。十五年,各国派员来华调查,草有报告书,仍主从缓。国民政府和意、丹、葡、西四国,订立十九年一月一日放弃领事裁判权的条约。比约则订明另定详细办法。倘详细办法尚未订定,而现有领事裁判权之国,过半数放弃,则比国亦放弃。中国在诸约中,订定(一)十九年一月一日以前,颁布民商法;(二)撤消领事裁判权之后,许外人内地杂居;(三)彼此侨民课税,不得高于他国人,或异于他国人,以为交换条件。然此约订定之后,迄今未能实行。惟墨西哥于十八年十一月,自动宣言放弃(德、奥、俄等国,欧战后即失其领事裁判权)。\n撤消领事裁判权,其实是不成问题的,只要我国司法,真能改良,自不怕不能实行。我国的司法改良,在于(一)彻底改良司法界的状况,(二)且推行之及于全国,此即所谓“司法革命”、“司法普及”。既须经费,又须人才,又须行政上的努力,自非易事。自前清末年订定四级三审制(初级、地方、高等三厅及大理院。初审起于初级厅的,上诉终于高等厅,起于地方厅的,终于大理院)至民国二十二年,改为三级三审(地方法院、高等法院、最高法院)。前此司法多由县知事兼理,虽订有种种章程,究竟行政司法,分划不清。二十四年起,司法部已令全国各地,遍设法院。这都是比较合理的。真能推行尽利,我国的司法自可焕然改观了。\n第四十七章 实业 # 农工商三者,并称实业,而三者之中,农为尤要。必有农,然后工商之技,乃可得而施。中国从前,称农为本业,工商为末业,若除去其轻视工商,几乎视为分利之意,而单就本末两字的本义立论,其见解是不错的。所以农业的发达,实在是人类划时代的进步。有农业,然后人类的食物,乃能为无限制的扩充,人口的增加,才无限制。人类才必须定居。一切物质文明,乃有基础。精神文化,亦就渐次发达了。人类至此,剩余的财产才多,成为掠夺的目的。劳力亦更形宝贵,相互间的战争,自此频繁,社会内部的组织,亦更形复杂了。世界上的文明,起源于几个特别肥沃的地点。比较正确的历史,亦是自此开始的。这和农业有极深切的关系,而中国亦是其中之一。\n在农业开始以前,游猎的阶段,很为普遍。在第三十七章中,业经提及。渔猎之民,视其所居之地,或进为畜牧,或进为农耕。中国古代,似乎是自渔猎径进于农耕的。传说中的三皇:燧人氏钻木取火,教民熟食,以避腥臊伤害肠胃,显然是渔猎时代的酋长。伏羲,亦作庖牺。皇甫谧《帝王世纪》,说为“取牺牲以供庖厨”(《礼记·月令疏》引),实为望文生义。《白虎通义·号篇》云:“下伏而化之,故谓之伏羲”,则羲字与化字同义,所称颂的乃其德业。至于其时的生业,则《易·系辞传》明言其“为网罟以田以渔”,其为渔猎时代的酋长,亦无疑义。伏羲之后为神农。“斫木为耜,揉木为耒”,就正式进入农业时代,我国文明的历史,从此开始了。三皇之后为五帝。颛顼、帝喾,可考的事迹很少。黄帝“教熊、羆、貔、貅、虎”,以与神农战,似乎是游牧部落的酋长。然这不过是一种荒怪的传说,《五帝本纪》同时亦言其“艺五种”,而除此之外,亦绝无黄帝为游牧民族的证据。《尧典》则有命羲和“历象日、月、星辰,敬授民时”之文。《尧典》固然是后人所作,并非当时史官的记录。然后人所作,亦不能谓其全无根据。殷周之祖,是略与尧舜同时的。《诗经》中的《生民》、《公刘》,乃周人自述其祖宗之事,当不致全属子虚。《书经》中的《无逸》,乃周公诰诫成王之语,述殷周的历史,亦必比较可信。《无逸》中述殷之祖甲云:“其在祖甲,不义惟王,旧为小人。作其即位,爰知小人之依。”(祖甲实即太甲。“不义惟王,旧为小人”,正指其为伊尹所放之事)述高宗云:“旧劳于外,爰暨小人。”皆显见其为农业时代的贤君。周之先世,如太王、王季、文王等,更不必论了。古书的记载,诚多未可偏信。然合全体而观之,自五帝以来,社会的组织,和政治上的斗争,必与较高度的文明相伴,而非游牧或渔猎部族所能有。然则自神农氏以后,我国久已成为农业发达的民族了。古史年代,虽难确考,然孟子说:“由尧舜至于汤,五百有余岁。由汤至于文王,五百有余岁。由文王至于孔子,五百有余岁。”(《尽心下篇》)和韩非子所谓殷周七百余岁,虞夏二千余岁(《显学篇》);乐毅《报燕惠王书》所谓“收八百岁之畜积”(谓齐自周初建国,至为昭王所破时),大致都相合的,决不会是臆造。然则自尧舜至周末,当略近二千年。自秦始皇统一天下至民国纪元,相距2132年。自尧舜追溯农业发达之时,亦必在千年左右。我国农业发达,总在距今五千年之前了。\n中国的农业,是如何进化的呢?一言以蔽之,曰:自粗耕进于精耕。古代有爰田之法。爰田即系换田。据《公羊》宣公十五年何《注》,是因为地有美恶,“肥饶不得独乐,硗确不得独苦”,所以“三年一换主易居”。据《周官》大司徒:则田有不易,一易,再易之分。不易之地,是年年可种的。一易之地,种一年要休耕一年。再易之地,种一年要休耕两年。授田时:不易之地,一家给一百亩。一易之地,给二百亩。再易之地,给三百亩。古代的田亩,固然较今日为小。然一夫百亩,实远较今日农夫所耕为大。而其成绩,则据《孟子》(《万章下篇》)和《礼记·王制》所说:是上农夫食九人,其次食八人,其次食七人,其次食六人,下农夫食五人。较诸现在,并不见得佳良,可见其耕作之法,不及今人了。汉朝有个大农业家赵过,能为代田之法。把一亩分做三个甽,播种于其中。甽以外的高处谓之陇。苗生叶以后,要勤除陇上之草,因而把陇上的土,倾颓下来,使其附着苗根。如此逐渐为之,到盛暑,则“陇尽而根深”,能够“耐风与旱”。甽和陇,是年年更换的,所以谓之代田(见《汉书·食货志》)。后来又有区田之法。把田分为一块一块的,谓之区。隔一区,种一区。其锄草和颓土,亦与代田相同。《齐民要术》(见下)极称之。后世言农业的人,亦多称道其法。但据近代研究农业的人说:则“代田区田之法,不外乎所耕者少,而耕作则精。近世江南的农耕,较诸古人所谓代田区田,其精勤实无多让。其田并不番休,而地力亦不见其竭。则其施肥及更换所种谷物之法,亦必有精意存乎其间。”这许多,都是农业自然的进步。总而言之:农业有大农制和小农制。大农制的长处,在于资本的节约,能够使用机械,及人工的分配得宜。小农制的长处,则在以人尽其劳,使地尽其力。所以就一个人的劳力,论其所得的多少,是大农制为长。就土地同一的面积,论其所得的多少,则小农制为胜。中国农夫的技能,在小农制中,总可算首屈一指了。这都是长时间自然的进化。\n中国农业进化的阻力,约有三端:(一)为讲究农学的人太少。即使有之,亦和农民隔绝,学问不能见诸实用。古代有许多教稼的官。如《周官》大司徒,“辨十有二壤之物而知其种”。司稼,“巡邦野之稼,而辨穜稑之种。周知其名与其所宜地,以为法而悬于邑闾”。这些事,都是后世所没有的。李兆洛《凤台县志》说,凤台县人所种的地,平均是一人十六亩。穷苦异常,往往不够本,一到荒年,就要无衣无食。县人有一个唤做郑念祖的,雇佣了一个兖州人。问他:你能种多少园地?他说两亩,还要雇一个人帮忙。问他要用多少肥料?他说一亩田的肥料,要值到两千个铜钱。间壁的农人听了大笑,说:我种十亩地,只花一千个铜钱的肥料,收获的结果,还往往不够本呢?郑念祖对于这个兖州人,也是将信将疑。且依着他的话试试看呢,因其用力之勤,施肥之厚,人家的作物,都没有成熟,他的先就成熟了,而且长得很好。争先入市,获利甚多。到人家蔬果等上市时,他和人家一块卖的,所得的都是赢利了。李兆洛据此一例,很想募江南的农民为农师,以开水田。这不过是一个例。其余类乎此的情形,不知凡几。使农民互相师,已可使农业获有很大的进步,何况益之以士大夫?何况使士大夫与农民互相师,以学理经验,交相补足呢?(二)古代土地公有,所以沟洫阡陌等,都井井有条。后世则不然。土地变为私有,寸寸割裂。凡水旱蓄泄等事,总是要费掉一部分土地的,谁肯牺牲?凡一切公共事业的规划,其根源,实即公共财产的规划。所以土地公有之世,不必讲地方自治,而自治自无不举。土地既已私有,公共的事务,先已无存。间有少数非联合不能举办的,则公益和私益,多少有些冲突。于是公益的举措,固有的荡然无存,当兴的阙而莫举;而违反公益之事,且日出不穷。如滥伐林木,破坏堤防,壅塞沟渠等都是。而农田遂大受其害。其最为显著的,就是水利。(三)土地既然私有了,人民谁不爱护其私产?但必使其俯仰有余,且勤劳所得,可以为其所有,农民才肯尽力。如其一饱且不可得,又偶有赢余,即为强有力者剥削以去,人民安得不苟偷呢?然封建势力和高利贷的巧取豪夺,则正是和这原则相反的。这也是农田的一个致命伤。职是故,农业有其进化的方面,而亦有其退化的方面。进退相消,遂成为现在的状况。\n中国现在农业上的出路,是要推行大农制。而要推行大农制,则必须先有大农制所使用的器具。民国十七年春,俄国国营农场经理马克维次(Markevich),有多余不用的机犁百架,召集附近村落的农民,许租给他们使用,而以他们所有的土地,共同耕种为条件。当时加入的农民,其耕地,共计九千余亩。到秋天,增至二万四千余亩。事为共产党所闻,于是增制机犁,并建造使用机犁的动力场。至明年,遂推行其法于全国。是为苏俄集合农场的起源(据张君劢《史泰林治下之苏俄》。再生杂志社本)。天下事口说不如实做。瘏口哓音,说了半天的话,人家还是不信。实在的行动当前,利害较然可见,就无待烦言了。普通的议论,都说农民是最顽固的、守旧的。其实这是农民的生活,使其如此。现在为机器时代。使用旧式的器具,决不足以与之相敌。而全国最多数的农民,因其生活,而滞留于私有制度下自私自利的思想,亦实为文化进步的障碍。感化之法,单靠空言启牖,是无用的。生活变则思想变;生产的方法变,则生活变。“牖民孔易”,制造出耕作用的机械来,便是化除农民私见的方法。并不是要待农民私见化除了,机械才可使用。\n中国的农学,最古的,自然是《汉书·艺文志》诸子略中的农家。其所著录先秦的农书,今已不存。先秦农家之说,存于今的,只有《管子》中的《地员》,《吕氏春秋》中的《任地》、《辨土》、《审时》数篇。汉代农家所著之书,亦俱亡佚。诸家征引,以氾胜之书为最多。据《周官》草人疏说,这是汉代农书中最佳的,未知信否。古人著述,流传到现在的,以后魏贾思勰的《齐民要术》为最早。后世官修的巨著,有如元代的《农桑辑要》,清代的《授时通考》;私家的巨著,有如元王桢的《农书》,明徐光启的《农政全书》等,均在子部农家中。此项农书,所包颇广。种植而外,蚕桑、苹果、树木、药草、孳畜等,都包括其中。田制、劝课、救荒之法,亦均论及,尚有茶经、酒史、食谱、花谱、相牛经、相马经等,前代亦隶农家,清四库书目改入谱录类。兽医之书,则属子部医家。这些,都是和农业有关系的。旧时种植之法,未必都能适用于今。然要研究农业历史的人,则不可以不读。\n蚕桑之业,起于黄帝元妃嫘祖,语出《淮南·蚕经》(《农政全书》引),自不足信。《易·系辞传》说:“黄帝、尧、舜,垂衣裳而天下治。”《疏》云:“以前衣皮,其制短小,今衣丝麻布帛,所作衣裳,其制长大,故云垂衣裳也。”亦近附会。但我国的蚕业,发达是极早的。孟子说:“五亩之宅,树之以桑,七十者可以衣帛矣。”(《梁惠王上篇》)久已成为农家妇女普遍的职业了。古代蚕利,盛于北方。《诗经》中说及蚕桑的地方就很多。《禹贡》兖州说桑土既蚕,青州说厥篚厂丝。厂是山桑,这就是现在的野蚕丝了。齐纨、鲁缟,汉世最为著名。南北朝、隋、唐货币都通用布帛。唐朝的调法,亦兼收丝麻织品。元朝还有五户丝及二户丝。可见北方蚕桑之业,在元代,尚非不振,然自明以后,其利就渐限于东南了。唐甄《潜书》说:“蚕桑之利,北不逾淞,南不逾浙,西不通湖,东不至海,不过方千里,外此则所居为邻,相隔一畔而无桑矣(此以盛衰言之,并非谓绝对无有,不可拘泥)。甚矣民之惰也。”大概中国文化,各地不齐,农民愚陋,只会蹈常习故。便是士和工商亦然。所以全国各地,风气有大相悬殊的。《日知录》说:“华阴王宏撰著议,以为延安一府,布帛之价,贵于西安数倍。”又引《盐铁论》说:“边民无桑麻之利,仰中国丝絮而后衣。夏不释褐,冬不离窟。”崔寔《政论》说:“仆前为五原太守,土俗不知缉绩。冬积草伏卧其中。若见吏,以草缠身,令人酸鼻。”顾氏说:“今大同人多是如此。妇人出草,则穿纸袴。”可见有许多地方,荒陋的情形,竟是古今一辙。此等情形,昔人多欲以补救之法,责之官吏,间亦有能行之的。如清乾隆时,陈宏谋做陕西巡抚。曾在西安、三原、凤翔设蚕馆、织局,招南方机匠为师。又教民种桑。桑叶、茧丝,官家都许收买,使民节节得利,可以踊跃从事,即其一例。但究不能普遍。今后交通便利,资本的流通,遍及穷乡僻壤,此等情形,必将渐渐改变了。\n林政:愈到后世而愈坏。古代的山林,本是公有的,使用有一定的规则,如《礼记·王制》说“草木黄落,然后入山林”是。亦或设官管理,如《周官》的林衡是。又古代列国并立,务于设险,平地也有人造的森林,如《周官》司险,设国之五沟、五涂,而树之林,以为阻固是。后世此等事都没有了。造林之事极少,只是靠天然的使用。所以愈开辟则林木愈少。如《汉书·地理志》说,天水、陇西,山多林木,人民都住在板屋里。又如近代,内地的木材,出于四川、江西、贵州,而吉、黑二省,为全国最大的森林区域,都是比较上少开辟的地方。林木的缺乏,积极方面,由于国家不知保护森林,更不知造林之法。如清朝梅曾亮,有《书棚民事》一篇。他说当他替安徽巡抚董文恪做行状时,遍览其奏议,见其请准棚民开山的奏折,说棚民能攻苦食淡于崇山峻岭,人迹不通之处,开种旱谷,有裨民食,和他告讦的人,都是溺于风水之说,至有以数百亩之田,保一棺之土的,其说必不可听。梅氏说:“予览其说而是之。”又说:“及予来宣城,问诸乡人,则说:未开之山,土坚石固,草树茂密,腐叶积数年,可二三寸。每天雨,从树至叶,从叶至土石,历石罅滴沥成泉,其下水也缓。又水缓而土不随其下。水缓,故低田受之不为灾;而半月不雨,高田犹受其灌溉。今以斤斧童其山,而以锄犁疏其土,一雨未毕,沙石随下,其情形就大不然了。”梅氏说:“予亦闻其说而是之。”又说:“利害之不能两全也久矣。由前之说,可以息事。由后之说,可以保利。若无失其利,而又不至于董公之所忧,则吾盖未得其术也。”此事之是非,在今日一言可决。而当时或不之知,或作依违之论。可见昔人对于森林的利益,知之不甚透澈。自然不知保护,更说不到造林;历代虽有课民种桑枣等法令,亦多成为具文了。消极方面,则最大的为兵燹的摧残,而如前述开垦时的滥伐,甚至有放火焚毁的,亦是其一部分的原因。\n渔猎畜牧,从农业兴起以后,就不被视为主要的事业。其中惟因猎,因和武事有关,还按时举行,藉为阅习之用。渔业,则被视为鄙事,为人君所弗亲。观《左传》隐公五年所载臧僖伯谏观渔之辞可见。牧业,如《周官》之牧人、牛人、充人等,所豢养的,亦仅以供祭祀之用。只有马,是和军事、交通,都有关系的,历代视之最重,常设“苑”、“监”等机关,择适宜之地,设官管理。其中如唐朝的张万岁等,亦颇有成绩。然能如此的殊不多。以上是就官营立论。至于民间,规模较大的,亦恒在缘边之地。如《史记·货殖列传》说,天水、陇西、北地、上郡,畜牧为天下饶。又如《后汉书·马援传》说,援亡命北地,因留畜牧,役属数百家。转游陇汉间,因处田牧,至有牛马羊数千头,谷数万斛是。内地民家,势不能有大规模的畜牧。然苟能家家畜养,其数亦必不少。如《史记·平准书》说,武帝初年,“众庶街巷有马,阡陌之间成群”。元朔六年,卫青、霍去病出塞,私负从马至十四万匹(《汉书·匈奴列传》。颜师古《注》:“私负衣装者,及私将马从者,皆非公家发与之限。”),实在是后世所少见的。民业虽由人民自营,然和国家的政令,亦有相当的关系。唐玄宗开元九年,诏“天下之有马者,州县皆先以邮递军旅之役,定户复缘以升之,百姓畏苦,乃多不畜马,故骑射之士减曩时”。元世祖至元二十三年,六月,括诸路马。凡色目人有马者,三取其二。汉民悉入官。敢匿与互市者罪之。《明实录》言:永乐元年,七月,上谕兵部臣曰:“比闻民间马价腾贵,盖禁民不得私畜故也。其榜谕天下,听军民畜马勿禁。”(据《日知录·马政》条)然则像汉朝,不但无畜马之禁,且有马复令者(有车骑马一匹者,复卒三人,见《食货志》),民间的畜牧,自然要兴盛了。但这只能藏富于民,大规模的畜牧,还是要在边地加以提倡的。《辽史·食货志》述太祖时畜牧之盛,“括富人马不加多,赐大小鹘军万余匹不加少”。又说:“自太宗及兴宗,垂二百年,群牧之盛如一日。天祚初年,马犹有数万群,群不下千匹。”此等盛况,各个北族盛时,怕都是这样的,不过不能都有翔实的记载罢了。此其缘由:(一)由于天时地利的适宜。(二)亦由其地尚未开辟,可充牧场之地较多。分业应根据地理。蒙、新、青、藏之地,在前代或系域外,今则都在邦域之中,如何设法振兴,不可不极端努力了。\n渔税,历代视之不甚重要,所以正史中关于渔业的记载亦较少。然古代庶人,实以鱼鳖为常食(见第四十九章)。《史记·货殖列传》说:太公封于齐,地潟卤,人民寡,太公实以通鱼盐为致富的一策。这或是后来人的托辞,然春秋战国时,齐国渔业的兴盛,则可想见了。《左传》昭公三年,晏子说陈氏厚施于国,“鱼盐蜃蛤,弗加于海”(谓不封禁或收其税)。汉耿寿昌为大司农,增加海租三倍(见《汉书·食货志》)。可见缘海河川,渔业皆自古即盛。此等盛况,盖历代皆然。不过“业渔者类为穷海、荒岛、河上、泽畔居民,任其自然为生。内地池畜鱼类,一池一沼,只供文人学士之倘佯,为诗酒闲谈之助。所以自秦汉至明,无兴革可言,亦无记述可见”罢了(采李士豪、屈若《中国渔业史》说,商务印书馆本)。然合沿海及河湖计之,赖此为生的,何止千万?组织渔业公司,以新法捕鱼,并团结渔民,加以指导保护等,均起于清季。国民政府对此尤为注意。并曾豁免渔税,然成效尚未大著。领海之内,时时受人侵渔。二十六年,中日战事起后,沿海多遭封锁,渔场受侵夺,渔业遭破坏的尤多。\n狭义的农业,但指种植而言。广义的,则凡一切取得物质的方法,都包括在内,矿业,无疑的也是广义农业的一部分了。《管子·地数篇》说:“葛卢之山,发而出水,金从之,蚩尤受而制之,以为剑、铠、矛、戟。”“雍狐之山,发而出水,金从之,蚩尤受而制之,以为雍狐之戟、芮戈。”我们据此,还可想见矿业初兴,所采取的,只是流露地表的自然金属。然《管子》又说:“上有丹砂者,下有黄金;上有慈石者,下有铜金;上有陵石者,下有铅、锡、赤铜;上有赭者下有铁,此山之见荣者也。”荣即今所谓矿苗,则作《管子》书时,已知道察勘矿苗之法了。近代机器发明以来,煤和铁同为生产的重要因素。在前世,则铁较重于煤。至古代,因为技术所限,铜尤要于铁。然在古代,铜的使用,除造兵器以外,多以造宝鼎等作为玩好奢侈之品,所以《淮南子·本经篇》说:“衰世镌山石,手金玉,擿蚌蜃,销铜铁,而万物不滋。”将铜铁和金玉、蚌蜃(谓采珠)同视。然社会进化,铁器遂日形重要。《左传》僖公十八年,“郑伯始朝于楚。楚子赐之金。既而悔之。与之盟,曰:无以铸兵。”可见是时的兵器,还以南方为利。兵器在后汉以前,多数是用铜造的(参看《日知录·铜》条)。然盐铁,《管子》书已并视为国家重要的财源(见第四十四章),而《汉书·地理志》说,江南之俗,还是“火耕水耨”。可见南方的农业,远不如北方的发达。古代矿业的发明,一定是南先于北。所以蚩尤尸作兵之名。然到后来,南方的文明程度,转落北方之后,则实以农业进步迟速之故。南方善造铜兵,北方重视铁铸的农器,正可为其代表。管子虽有盐铁国营之议,然铁矿和冶铸,仍入私人之手。只看汉世所谓“盐铁”者(此所谓盐铁,指经营盐铁事业的人而言),声势极盛,而自先秦时代残留下来的盐官、铁官,则奄奄无生气可知。后世也还是如此。国家自己开的矿,是很少的。民间所开,大抵以金属之矿为多。采珠南海有之。玉多来自西域。\n工业:在古代,简单的是人人能做的。其较繁难的,则有专司其事的人。此等人,大抵由于性之所近,有特别的技巧。后来承袭的人,则或由社会地位关系,或由其性之所近。《考工记》所谓“知者创物,巧者述之,守之世,谓之工”。此等专门技术,各部族的门类,各有不同。在这一部族,是普通的事,人人会做的,在别一部族,可以成为专门之技。所以《考工记》说:“粤无镈,燕无函,秦无庐,胡无弓车。”(谓无专制此物之人)又说:“粤之无镈也,非无镈也(言非无镈其物),夫人而能为镈也。”燕之函,秦之庐,胡之弓车说亦同。此等规模,该是古代公产部族,相传下来的。后世的国家沿袭之,则为工官。《考工记》的工官有两种:一种称某人,一种称某氏。称某人的,当是技术传习,不以氏族为限的,称某氏的则不然。工用高曾之规矩,古人传为美谈。此由(一)古人生活恬淡,不甚喜矜奇斗巧。(二)又古代社会,范围窄狭,一切知识技能,得之于并时观摩者少,得之于先世遗留者多,所以崇古之情,特别深厚。(三)到公产社会专司一事的人,变成国家的工官,则工业成为政治的一部分。政治不能废督责,督责只能以旧式为标准。司制造的人,遂事事依照程式,以求免过(《礼记·月令》说:“物勒工名,以考其成。”《中庸》说:“日省月试,饩廪称事,所以来百工也。”可见古代对于工业督责之严)。(四)封建时代,人的生活是有等级的,也是有规范的。竞造新奇之物,此二者均将被破坏。所以《礼记·月令》说:“毋或作为淫巧,以荡上心。”《荀子·王制》说:“雕琢文采,不敢造于家。”而《礼记·王制》竟说:“作奇技奇器以疑众者杀。”此等制度,后人必将议其阻碍工业的进步,然在保障生活的规范,使有权力和财力的人,不能任意享用,而使其余的人,(甲)看了起不平之念;(乙)或者不顾财力,互相追逐,致以社会之生活程度衡之,不免流于奢侈,是有相当价值的,亦不可以不知道。即谓专就技巧方面立论,此等制度阻碍进步也是冤枉的。为什么呢?\n社会的组织,暗中日日变迁,而人所设立的机关,不能与之相应,有用的逐渐变为无用,而逐渐破坏。这在各方面皆然,工官自亦非例外。(一)社会的情形变化了,而工官未曾扩充,则所造之物,或不足以给民用。(二)又或民间已发明新器,而工官则仍守旧规,则私家之业渐盛。(三)又封建制度破坏,被灭之国,被亡之家,所设立之机关,或随其国家之灭亡而被废,技术人员也流落了。如此,古代的工官制度,就破坏无余了。《史记·货殖列传》说:“用贫求富,农不如工,工不如商”;《汉书·地理志》所载,至汉代尚存的工官,寥寥无几,都代表这一事实。《汉书·宣帝纪赞》,称赞他“信赏必罚,综核名实”,“技巧工匠,自元成间鲜能及之”。陈寿《上诸葛氏集表》,亦称其“工械技巧,物究其极”(《三国蜀志·诸葛亮传》),实在只是一部分官制官用之物罢了,和广大的社会工业的进退,是没有关系的。当这时代,工业的进化安在呢?世人每举历史上几个特别智巧的人,几件特别奇异之器,指为工业的进化,其实是不相干的。公输子能削竹木以为,飞之三日不下(见《墨子·鲁门篇》、《淮南子·齐俗训》),这自然是瞎说,《论衡·儒增篇》,业经驳斥他了。然如后汉的张衡、曹魏的马钧、南齐的祖冲之、元朝的郭守敬(马钧事见《三国魏志·杜夔传》《注》,余皆见各史本传),则其事迹决不是瞎说的。他们所发明的东西安在呢?崇古的人说:“失传了。这只是后人的不克负荷,并非中国人的智巧,不及他国人。”喜新的人不服,用滑稽的语调说道:“我将来学问够了,要做一部中国学术失传史。”(见从前北京大学所出的《新潮杂志》)其实都不是这一回事。一种工艺的发达,是有其社会条件的。指南针,世界公认是中国人发明的。古代曾用以驾车,现在为什么没有?还有那且走且测量路线长短的记里鼓车,又到什么地方去了?诸葛亮改良连弩,马钧说:我还可以再改良,后来却不曾实行,连诸葛亮发明的木牛流马,不久也失传了。假使不在征战之世,诸葛亮的心思,也未必用之于连弩。假使当时魏蜀的争战,再剧烈些,别方面的势力,再均平些,竟要靠连弩以决胜负,魏国也未必有马钧而不用。假使魏晋以后,在商业上,有运巴蜀之粟,以给关中的必要,木牛流马,自然会大量制造,成为社会上的交通用具的。不然,谁会来保存它?同理:一时代著名的器物,如明朝宣德、成化,清朝康熙、雍正、乾隆年间的瓷器,为什么现在没有了?这都是工业发达的社会条件。还有技术方面,也不是能单独发达的。一器之成,必有互相连带的事物。如公输子以竹木为,飞之三日,固然是瞎说。王莽时用兵,募有奇技的人。有人自言能飞。试之,取大鸟翮为两翼,飞数百步而坠(见《汉书·王莽传》),却决不是瞎说的,其人亦不可谓之不巧。假使生在现在,断不能谓其不能发明飞机。然在当日,现今飞机上所用种种机械,一些没有,自然不能凭空造成飞行器具。所以社会条件不备具,技术的发展,而不依着一定的顺序,发明是不会凭空出现的。即使出现了,也只等于昙花一现。以为只要消费自由,重赏之下,必有勇夫,工艺自然会不断的进步,只是一个浅见。\n工官制度破坏后,中国工业的情形,大概是这样的:根于运输的情形,寻常日用的器具,往往合若干地方,自成一个供求的区域。各区域之间,制造的方法,和其所用的原料等,不必相同。所以各地方的物品,各有其特色。(一)此等工人,其智识,本来是蹈常习故的。(二)加以交换制度之下,商品的生产,实受销场的支配,而专司销售的商人,其见解,往往是陈旧的。因为旧的东西,销路若干,略有一定,新的就没有把握了。因此,商人不欢迎新的东西,工人亦愈无改良的机会。(三)社会上的风气,也是蹈常习故的人居其多数。所以其进步是比较迟滞的。至于特别著名的工业品,行销全国的,亦非没有。则或因(一)天产的特殊,而制造不能不限于其地。(二)或因运输的方便,别地方的出品,不能与之竞争。(三)亦或因历史上技术的流传,限于一地。如湖笔、徽墨、湘绣等,即其一例。\n●轮船招商总局\n近代的新式工业,是以机制品为主的。自非旧式的手工业所能与之竞争。经营新式工业,既须人才,又须资本,中外初通时的工商家,自不足以语此,自非赖官力提倡不可。然官家的提倡,亦殊不得法。同治初年,制造局、造船厂等的设立,全是为军事起见,不足以语于实业。光绪以后所办的开平煤矿、甘肃羊毛厂、湖北铁厂、纱厂等,亦因办理不得其法,成效甚少。外货既滔滔输入,外人又欲在通商口岸,设厂制造,利用我低廉的劳力,且省去运输之费。自咸丰戊午、庚申两约定后,各国次第与我订约,多提出此项要求。中国始终坚持未许。到光绪甲午,和日本战败,订立《马关条约》,才不得已而许之。我国工业所受的压迫,遂更深一层,想挣扎更难了。然中国的民智,却于甲午之后渐开,经营的能力,自亦随之而俱进。近数十年来,新兴的工业,亦非少数,惜乎兴起之初,未有通盘计划,而任企业之家,人自为战,大多数都偏于沿江沿海。二十六年,战事起后,被破坏的,竟达百分之七十。这亦是一个很大的创伤。然因此而(一)内地的宝藏,获得开发,交通逐渐便利。(二)全盘的企业,可获得一整个的计划,非复枝枝节节而为之。(三)而政治上对于实业的保障,如关税壁垒等,亦将于战后获得一条出路。因祸而为福,转败而为功,就要看我们怎样尽力奋斗了。\n商业当兴起时,和后来的情形,大不相同。《老子》说:“郅治之极,邻国相望,鸡犬之声相闻,民各甘其食,美其服,安其俗,乐其业,至老死不相往来。”这是古代各部族最初孤立的情形。到后来,文化逐渐进步,这种孤立状况,也就逐渐打破了。然此时的商人,并非各自将本求利,乃系为其部族做交易。部族是主人,商人只是夥友,盈亏都由部族担负,商人只是替公众服务而已。此时的生意,是很难做的。(一)我们所要的东西,哪一方面有?哪一方面价格低廉?(二)与人交换的东西,哪一方面要?哪一方面价格高昂?都非如后世的易于知道。(三)而重载往来,道途上且须负担危险。商人竭其智力,为公众服务,实在是很可敬佩的。而商人的才智,也特别高。如郑国的弦高,能却秦师,即其一证(《左传》僖公三十三年)。此等情形,直到东西周之世,还有留遗。《左传》昭公十六年,郑国的子产,对晋国的韩宣子说:“昔我先君桓公,与商人皆出自周。庸次比耦,以艾杀此地,斩之蓬蒿藜藿而共处之。”开国之初,所以要带着一个商人走,乃是因为草创之际,必要的物品,难免缺乏,庚财(见第四十一章)、乞籴,都是不可必得的。在这时候,就非有商人以济其穷不可了。卫为狄灭,文公立国之后,要注意于通商(《左传》闵公二年),亦同此理。此等商人,真正是消费者和生产者的朋友。然因社会组织的变迁,无形之中,却逐渐变做他们的敌人而不自知了。因为交换的日渐繁盛,各部族旧有的经济组织,遂不复合理,而逐渐的遭遇破坏。旧组织既破坏,而无新组织起而代之。人遂不复能更受社会的保障,其作业,亦非为社会而作,于是私产制度兴起了。在私产制度之下,各个人的生活,是要自己设法的。然必不能物物皆自为而后用之。要用他人所生产的东西,只有(一)掠夺和(二)交换两种方法。掠夺之法,是不可以久的。于是交易大盛。然此时的交易,非复如从前行之于团体与团体之间,而是行之于团体之内的。人人直接交易,未免不便,乃渐次产生居间的人。一方面买进,一方面卖出,遂成为现在的所谓商业。非交易不能生活,非藉居间的人不能交易,而商业遂隐操社会经济的机键。在私产制度之下,人人的损益,都是要自己打算的。各人尽量寻求自己的利益。而生产者要找消费者、消费者要找生产者极难,商人却处于可进可退的地位,得以最低价(只要生产者肯忍痛卖)买进,最高价(只要消费者能够忍痛买)卖出,生产者和消费者,都无如之何。所以在近代工业资本兴起以前,商人在社会上,始终是一个优胜的阶级。\n商业初兴之时,只有现在所谓定期贸易。《易经·系辞传》说:神农氏“日中为市,致天下之民,聚天下之货,交易而退,各得其所”,就指示这一事实的。此等定期贸易,大约行之于农隙之时,收成之后。所以《书经·酒诰》说:农功既毕,“肇牵车牛远服贾”。《礼记·郊特牲》说:“四方年不顺成,八蜡不通”;“顺成之方,其蜡乃通”(蜡祭是行于十二月的。因此,举行定期贸易)。然不久,经济愈形进步,交易益见频繁,就有常年设肆的必要了。此等商肆,大者设于国中,即《考工记》所说“匠人营国,面朝后市”。小者则在野田墟落之间,随意陈列货物求售,此即《公羊》何《注》所谓“因井田而为市”(宣公十五年)。《孟子》所谓“有贱丈夫焉,必求龙断而登之”,亦即此类,其说已见第四十四章了。《管子·乘马篇》说:“聚者有市,无市则民乏。”可见商业和人民的关系,已密接而不可分离了。古代的大商人,国家管理之颇严,《管子·揆度篇》说:“百乘之国,中而立市,东西南北,度五十里。”千乘之国,万乘之国,也是如此。这是规定设市的地点的。《礼记·王制》列举许多不鬻于市的东西。如(一)圭璧金璋,(二)命服命车,(三)宗庙之器,(四)牺牲,(五)锦文珠玉成器,是所以维持等级制度的。(六)奸色乱正色,(七)衣服饮食,是所以矫正人民的生活规范的。(八)布帛精粗不中度,幅广狭不中量,(九)五谷不时,(十)果实未熟,(十一)木不中伐,(十二)禽兽鱼鳖不中杀,是所以维持社会的经济制度,并保障消费人的利益的。总之,商人的交易,受着干涉的地方很多。《周官》司市以下各官,则是所以维持市面上的秩序的。我们可想见,在封建制度之下,商人并不十分自由。封建政体破坏了,此等规则,虽然不能维持,但市总还有一定的区域。像现在通衢僻巷,到处可以自由设肆的事,是没有的。北魏胡灵后时,税入市者人一钱,即其明证。《唐书·百官志》说:“市皆建标筑土为候。凡市日,击鼓三百以会众,日入前七刻,击钲三百而散。”则市之聚集,仍有定期,更无论非市区了。现在设肆并无定地,交易亦无定时,这种情形,大约是唐中叶以后,逐渐兴起的。看宋朝人所著的《东京梦华录》(孟元老著)、《武林旧事》(周密著)等书可见。到这地步,零售商逐渐增多,商业和人民生活的关系,亦就更形密切了。\n商业初兴时,所运销的,还多数是奢侈品,所以专与王公贵人为缘。子贡结驷连骑,束帛之币,以聘享诸侯(《史记·货殖列传》)。晁错说汉朝的商人,“交通王侯,力过吏势”(《汉书·食货志》),即由于此。此等商人,看似势力雄厚,其实和社会的关系,是比较浅的。其厕身民众之间,做屯积和贩卖的工作的,则看似低微,而其和社会的关系,反较密切。因为这才真正是社会经济的机键。至于古代的贱视商人,则(一)因封建时代的人,重视掠夺,而贱视平和的生产事业。(二)因当时的商业,多使贱人为之。如刁间收取桀黠奴,使之逐渔盐商贾之利是(《史记·货殖列传》)。此等风气,以两汉时代为最甚。后世社会阶级,渐渐平夷,轻视商人,亦就不如此之甚了。抑商则另是一事。轻商是贱视其人,抑商则敌视其业。因为古人视商业为末业,以为不能生利。又因其在社会上是剥削阶级,然抑商的政令,在事实上,并不能减削商人的势力。\n国际间的贸易,自古即极兴盛。因为两国或两民族,地理不同,生产技术不同,其需要交易,实较同国同族人为尤甚。试观《史记·货殖列传》所载,凡和异国异族接境之处,商务无不兴盛(如天水、陇西、北地、上郡、巴、蜀、上谷至辽东等),便可知道。汉朝尚绝未知西域为何地,而邛竹杖、蜀布,即已远至其地,商人的辗转贩运,其能力亦可惊异了。《货殖传》又说:番禺为珠玑、瑇瑁、果、布之凑。这许多,都是后来和外洋互市的商品(布当即棉布),可知海路的商业,发达亦极早。中国和西域的交通,当分海、陆两路。以陆路论:《汉书·西域传》载杜钦谏止遣使报送罽宾使者的话,说得西域的路,阻碍危险,不可胜言,而其商人,竟能冒险而来。以海路论:《汉书·地理志》载中国人当时的海外航线,系自广东的徐闻出发。所经历的地方,虽难悉考,其终点黄支国,据近人所考证,即系印度的建志补罗(冯承钧《中国南洋交通史》上编第一章)。其后大秦王安敦,自日南徼外,遣使通中国,为中欧正式交通之始。两晋南北朝之世,中国虽然丧乱,然河西、交、广,都使用金银。当时的中国,是并不以金银为货币的,独此两地,金银获有货币的资格,即由于与外国通商之故。可见当中国丧乱时,中外的贸易,依然维持着。承平之世,特别如唐朝、元朝等,疆域扩张,声威远播之时,更不必说了。但此时所贩运的总带有奢侈品性质(如香药、宝货便是,参看第四十四章),对于普通人民的生活,关系并不深切。到近代产业革命以后,情形就全不相同了。\n第四十八章 货币 # 交换是现社会重要的经济机构,货币则是交换所藉之以行的。所以货币制度的完善与否,和经济的发达、安定,都有很大的关系。中国的货币制度,是不甚完善的。这是因为(一)中国的经济学说,注重于生产消费,而不甚注重于交换,于此部分,缺乏研究。(二)又疆域广大,各地方习惯不同,而行政的力量甚薄,不能控制之故。\n中国古代,最普遍的货币,大约是贝。所以凡货财之类,字都从贝,这是捕渔的民族所用。亦有用皮的。所以国家以皮币行聘礼,婚礼的纳征,亦用鹿皮,这当是游猎民族所用。至农耕社会,才普遍使用粟帛。所以《诗经》说“握粟出卜”,又说“抱布贸丝”。珠玉金银铜等,都系贵族所需要。其中珠玉之价最贵,金银次之,铜又次之,所以《管子》说:“以珠玉为上币,黄金为中币,刀布为下币。”(《国蓄》)古代的铜价,是比较贵的。《史记·货殖列传》、《汉书·食货志》,说当时的粜价,都是每石自二十文至八十文。当时的衡量,都约当现代五分之一。即当时的五石,等于现在的一石(当时量法用斛,衡法称石,石与斛的量,大略相等),其价为一百文至四百文。汉宣帝时,谷石五钱,则现在的一石谷,只值二十五文。如此,零星贸易,如何能用钱?所以孟子问陈相:许行的衣冠械器,从何而来?陈相说:都是以粟易之(《滕文公上篇》)。而汉朝的贤良文学,说当时买肉吃的人,也还是“负粟而往,易肉而归”(《盐铁论·散不足篇》),可见自周至汉,铜钱的使用,并不十分普遍。观此,才知道古人所以有许多主张废除货币的。若古代的货币使用,其状况一如今日,则古人即使有这主张,亦必审慎考虑,定有详密的办法,然后提出,不能说得太容易了。自周至汉,尚且如此,何况夏殷以前?所以《说文》说:“古者货贝而宝龟,周而有泉,至秦废贝行钱。”《汉书·食货志》说货币的状况:“自夏殷以前,其详靡记”,实在最为确实。《史记·平准书》说:“虞夏之币,金为三品:或黄,或白,或赤,或钱,或布,或刀,或龟贝。”《平准书》本非《史记》原文,这数语又附著篇末,其为后人所窜入,不待言而可明了。《汉书·食货志》又说:“大公为周立九府圜法。黄金方寸而重一斤。钱圜函方(函即俗话钱眼的眼字),轻重以铢。布帛广二尺二寸为辐,长四丈为匹。大公退,又行之于齐。”按《史记·货殖列传》说:“管子设轻重九府。”《管晏列传》说:“吾读管氏《牧民》、《山高》、《乘马》、《轻重》、《九府》”,则所谓九府圜法,确系齐国的制度。但其事起于何时不可知。说是太公所立,已嫌附会,再说是太公为周所立,退而行之于齐,就更为无据了。古代的开化,东方本早于西方。齐国在东方,经济最称发达。较整齐的货币制度,似乎就是起于齐国的。《管子·轻重》诸篇,多讲货币、货物相权之理,可见其时货币的运用,已颇灵活。《管子》虽非管仲所著,却不能不说是齐国的书。《说文》说周而有泉,可见铜钱的铸造,是起于周朝,而逐渐普遍于各地方的。并非一有铜钱,即各处普遍使用。\n古代的铜钱,尚且价格很贵,而非普通所能使用,何况珠玉金银等呢?这许多东西,何以会与铜钱并称为货币?这是因为货币之始,乃是用之于远方,而与贵族交易的。《管子》说:“玉起于禺氏,金起于汝、汉,珠起于赤野。东西南北,距周七千八百里(《通典》引作七八千里),水绝壤断,舟车不能通。先王为其途之远,其至之难,故托用于其重。”(《国蓄》)又说:“汤七年旱,禹五年水,汤以庄山之金,禹以历山之金铸币,而赎人之无卖子者。”(《山权数》)此等大批的卖买,必须求之于贵族之家。因为当时,只有贵族,才会有大量的谷物存储(如《山权数篇》又言丁氏之家粟,可食三军之师)。于此,可悟古代商人,多与贵族交接之理,而珠玉金银等的使用,亦可无疑义了。珠玉金银等,价均太贵,不适宜于普通之用。只有铜,价格稍贱,而用途极广,是普通人所宝爱,而亦是其所能使用的。铜遂发达而成普通的货币,具有铸造的形式。其价值极贵的,则渐以黄金为主,而珠玉等都被淘汰。\n钱圜函方,一定是取象于贝的。所以钱的铸造,最初即具有货币的作用。其为国家因民间习用贝,又宝爱铜,而铸作此物,抑系民间自行制造不可知。观《汉书》轻重以铢四字,可见齐国的铜钱,轻重亦非一等。限制其轻重必合于铢的整数,正和限制布帛的长阔一样。则当时的钱,种类似颇复杂。观此,铜钱的铸造,其初似出于民间,若源出国家,则必自始就较整齐了。此亦可见国家自能发动的事情,实在很少,都不过因社会固有的事物,从而整齐之罢了。到货币广行以后,大量的铸造,自然是出于国家。因为非国家,不能有这大量的铜。但这只是事实如此。货币不可私铸之理,在古代,似乎不甚明白的。所以汉文帝还放民私铸。\n《汉书·食货志》说:“秦并天下,币为二等。黄金以镒为名,上币。铜钱质如周钱,文曰半两,重如其文。而珠玉龟贝银锡之属,为器饰宝藏,不为币。然各随时而轻重无常。”可见当时的社会,对于珠玉、龟贝、银锡等,都杂用为交易的媒介,而国家则于铜钱之外,只认黄金。这不可谓非币制的一进化。《食货志》又说,汉兴,以为秦钱重,难用,更令民铸荚钱。《高后本纪》:二年,行八铢钱。应劭曰:“本秦钱。质如周钱,文曰半两,重如其文。即八铢也。汉以其太重,更铸荚钱。今民间名榆荚钱是也。民患其太轻。至此复行八铢钱。”六年,行五分钱。应劭曰:“所谓荚钱者。文帝以五分钱太轻小,更作四铢钱。文亦曰半两。今民间半两钱最轻小者是也。”按既经铸造的铜钱,自与生铜不同。但几种货币杂行于市,民必信其重者,而疑其轻者;信其铸造精良者,而疑其铸造粗恶者,这是无可如何之事。古代货币,虽有多种并行,然其价格,随其大小而不齐,则彼此不会互相驱逐。今观《汉书·食货志》说:汉行荚钱之后,米至石万钱,马至匹百金。汉初虽有战争,并未至于白骨蔽野,千里无人烟,物价的昂贵,何得如此?况且物价不应同时并长。同时并长,即非物价之长,而为币价之跌,其理甚明。古一两重二十四铢。八铢之重,只得半两钱三分之二,四铢只得三分之一,而其文皆曰半两,似乎汉初货币,不管其实重若干,而强令其名价相等。据此推测,汉初以为秦钱重难用,似乎是一个藉口。其实是藉发行轻货,以为筹款之策的。所以物价因之增长。其时又不知货币不可私铸之理。文帝放民私铸,看《汉书》所载贾谊的奏疏,其遗害可谓甚烈。汉武帝即位后,初铸三铢钱,又铸赤仄,又将鹿皮造成皮币,又用银锡造作白金三等,纷扰者久之。后来乃将各种铜钱取消,专铸五铢钱。既禁民私铸,并不许郡国铸造,而专令上林三官铸(谓水衡都尉属官均官、钟官、辨铜三令丞),无形中暗合货币学理。币制至此,始获安定。直至唐初,才另铸开元通宝钱。自此以前,历朝所铸的钱,都以五铢为文。五铢始终是最得人民信用的钱。\n●五铢钱\n汉自武帝以后,币制是大略稳定的。其间惟王莽一度改变币制,为五物、六名、二十八品(金、银、龟、贝、钱、布为六名。钱布均用铜,故为五物。其值凡二十八等),然旋即过去。至后汉光武,仍恢复五铢钱。直至汉末,董卓坏五铢钱,更铸小钱,然后钱法渐坏。自此经魏、晋、南北朝,政治紊乱,币制迄未整饬。其中最坏的,如南朝的鹅眼、环钱,至于“入水不沉,随手破碎”。其时的交易,则多用实物做媒介。和外国通商之处,则或兼用金银。如《隋书·食货志》说:“梁初,只有京师及三吴、荆、郢、江、襄、梁、益用钱。其余州郡,则杂以谷帛。交、广全用金银。又说:陈亡之后,岭南诸州,多以钱米布交易。河西诸郡,或用西域金银之钱都是。直到唐初,铸开元通宝钱,币制才算复一整理。然不久私铸即起。\n用金属做货币,较之珠玉布帛等,固然有种种优点,但亦有两种劣点。其(一)是私销私铸的无法禁绝。私铸,旧说以“不爱铜不惜工”敌之。即是使铸造的成本高昂,私铸无利可图。但无严切的政令以辅之,则恶货币驱逐良货币,既为经济上不易的原则,不爱铜,不惜工,亦徒使国家增加一笔消耗而已。至于私销,则简直无法可禁。其(二)为钱之不足于用。社会经济,日有进步,交易必随之而盛。交易既盛,所需的筹码必多。然铜系天产物,开矿又极劳费,其数不能骤增。此系自然的原因。从人为方面论,历代亦从未注意于民间货币的足不足,而为之设法调剂,所以货币常感不足于用。南北朝时,杂用实物及外国货币,币制的紊乱,固然是其一因,货币数量的缺乏,怕亦未尝非其一因。此等现象,至唐代依然如故。玄宗开元二十二年,诏庄宅口马交易,并先用绢布绫罗丝棉等。其余市买,至一千以上,亦令钱物并用。违者科罪。便是一个证据。当这时代,纸币遂应运而生。\n纸币的前身是飞钱。《唐书·食货志》说:贞元时,商贾至京师,委钱诸道进奏院及诸军诸使富家,以轻装趋四方,合券乃取之,号飞钱。这固然是汇兑,不是纸币。然纸币就因之而产生了。《文献通考·钱币考》说:初蜀人以铁钱重,私为券,谓之交子,以便贸易。富人十六户主之。其后富人稍衰,不能偿所负,争讼数起。寇瑊尝守蜀,乞禁交子。薛田为转运使,议废交子则贸易不便。请官为置务,禁民私造。诏从其请。置交子务于益州。《宋史·薛田传》说:未报,寇瑊守益州,卒奏用其议。蜀人便之。《食货志》说:真宗时,张咏镇蜀。患蜀人铁钱重,不便贸易。设质剂之法。一交一缗,以三年为一界而换之。六十五年为二十二界。谓之交子。富民十六户主之。三说互歧,未知孰是。总之一交一缗,以三年为一界,总是事实。一交为一缗,则为数较小,人人可以使用。以三年为一界,则为时较长,在此期间,即具有货币的效用,真可谓之纸币,而非复汇兑券了。然云废交子则贸易不便,则其初,亦是以搬运困难,而图藉此以省费的。其用意,实与飞钱相类。所以说纸币,是从汇兑蜕化而出的。\n交子务既由官置,交子遂变为官发的纸币。神宗熙宁间,因河东苦铁钱,置务于潞州。后又行之于陕西。徽宗崇宁时,蔡京又推行之于各处。后改名为钱引。其时惟闽、浙、湖、广不行。推行的区域,已可谓之颇广了。此种纸币,系属兑换性质。必须可兑现钱,然后能有信用。然当时已有滥发之弊,徽宗时,遂跌至一缗仅值钱数十。幸其推行的范围虽广,数量尚不甚多,所以对于社会经济,不发生甚大的影响。南宋高宗绍兴元年,令榷货务造关子。二十九年,户部始造会子。仍以三年为一界。行至十八界为止。第十九界,贾似道仍改造关子。南宋的交子,有展限和两界并行之弊。因之各界价格不等。宁宗嘉定四年,遂令十七、十八两界,更不立限,永远行使。这很易至于跌价。然据《宋史·食货志》:度宗咸淳四年,以近颁关子,贯作七百七十文足。十八界会子,贯作二百五十七文足。三准关子一,同现钱行使。此时宋朝已近灭亡,关子仅打七七折,较诸金朝,成绩好得多了。\n金朝的行纸币,始于海陵庶人贞元二年。以一贯、二贯、三贯、五贯、十贯为大钞,一百、二百、三百、五百、七百为小钞。当时说是铜少的权制。但(一)开矿既非易事,括民间铜器以铸,禁民间私藏铜器及运铜器出境,都是苛扰的事。铸钱因此不易积极进行。(二)当时亦设有铸钱的监,乃多毁旧钱以铸。新钱虽然铸出,旧钱又没有了。(三)既然钱钞并行,循恶货币驱逐良货币的法则,人民势必将现钱收藏,新铸的钱,转瞬即行匿迹。因此,铜钱永无足时,纸币势必永远行使。然使发行得法,则纸币与铜钱并行,本来无害,而且是有益的,所以《金史·食货志》说:章宗即位之后,有人要罢钞法。有司说:“商旅利其致远,往往以钱买钞。公私俱便之事,岂可罢去?”这话自是事实。有司又说:“止因有厘革之限,不能无疑。乞削七年厘革之限,令民得常用。”(岁久字文磨灭,许于所在官库纳旧换新,或听便支钱)做《食货志》的人说:“自此收敛无术,出多入少,民浸轻之。”其实收敛和厘革,系属两事。苟能审察经济情形,不至滥发,虽无厘革之限何害?若要滥发,即有厘革之限,又何难扩充其每界印造之数,或数界并行呢?所以章宗时的有司,实在并没有错。而后来的有司,“以出钞为利,收钞为讳”,却是该负极大责任的。平时已苦钞多,宣宗南迁以后,更其印发无限。贞祐二年,据河东宣抚使胥鼎说,遂致每贯仅值一文。\n●交子\n钞法崩溃至此,业已无法挽救。铜钱则本苦其少,况经纸币驱逐,一时不能复出。银乃乘机而兴。按金银用为交易的媒介,由来已久,读前文所述可见。自经济进步以后,铜钱既苦其少,又苦运输的困难,当这时候,以金银与铜相辅而行,似极便利。然自金末以前,讫未有人想到这个法子,这是什么理由呢?原来货币是量物价的尺。尺是可有一,不可有二的。既以铜钱为货币,即不容铜钱之外,更有他种货币。(一)废铜钱而代以金银,固然无此情理。(二)将金银亦铸为货币,与铜钱严定比价,这是昔人想不到的。如此,金银自无可做货币的资格了。难者要说:从前的人,便没有专用铜钱。谷物布帛等,不都曾看做货币的代用品么?这话固然不错。然在当时,金银亦何尝不是货币的代用品。不过其为用,不如谷物布帛的普遍罢了。金银之用,为什么不如谷帛的普遍?须知价格的根源,生于价值,金银在现今,所以为大众所欢迎,是因其为交换之媒介,既广且久,大家对它,都有一种信心,拿出去,就什么东西可以换到。尤其是,现今世界各国,虽然都已用纸,而仍多用金银为准备。金银换到货币,最为容易,且有定价,自然为人所欢迎。这是货币制度,替金银造出的价值,并不是金银本身,自有价值。假使现在的货币,都不用金银做准备,人家看了金银,也不当它直接或间接的货币,而只当它货物。真要使用它的人,才觉得它有价值。如此,金银的价值必缩小;要它的人,亦必减少;金银的用途,就将大狭了。如此,便可知道自金末以前,为什么中国人想不到用金银做货币。因为价格生于价值,其物必先有人要,然后可做交易的媒介,而金银之为物,在从前是很少有人要的。因为其为物,对于多数人是无价值(金银本身之用,不过制器具,供玩好,二者都非通常人所急)。\n到金朝末年,经济的情形,又和前此不同了。前此货币紊乱之时,系以恶的硬币,驱逐良的硬币。此时则系以纸币驱逐硬币。汉时钱价甚昂,零星交易,并不用钱,已如前述。其后经济进步,交易渐繁,货币之数,势必随公铸私造而加多。货币之数既多,其价格必日跌。于是零星贸易,渐用货币。大宗支付,转用布帛。铜钱为纸币驱逐以尽,而纸币起码是一百文,则零星贸易,无物可用了。势不能再回到古代的以粟易之,而布帛又不可尺寸分裂,乃不得已而用银。所以银之起,乃是所以代铜钱,供零星贸易之用的,并非嫌铜钱质重值轻,用之以图储藏和运输之便。所以到清朝,因铸钱的劳费,上谕屡次劝人民兼用银两,人民总不肯听。这个无怪其然。因为他们心目之中,只认铜钱为货币。储藏了银两,银两对铜钱涨价,固然好了,对铜钱跌价,他们是要认为损失的。他们不愿做这投机事业。到清末,要以银为主币,铜为辅币,这个观念,和普通一般人说明,还是很难的。因为他们从不了解:有两种东西可以同时并认为货币。你对他说:以银为主币,铜为辅币,这个铜币,就不该把它看做铜,也不该把它看做铜币,而该看作银圆的几分之几,他们亦很难了解。这个,似乎是他们的愚笨,其实他们的意见是对的。因为既不看做铜,又不看做铜币,那么,为什么不找一种本无价值的东西,来做银圆的代表,而要找着铜币呢?铜的本身,是有价值的,因而是有价格的,维持主辅币的比价,虽属可能,究竟费力。何不用一张纸,写明铜钱若干文,派它去充个代表,来得直捷痛快呢?他们的意见是对的。他们而且已经实行了。那便是飞钱、交子等物。这一种事情,如能顺利发达,可使中国货币的进化,早了一千年。因为少数的交易用铜钱,多数的授受,嫌钱笨重的,则以纸做钱的代表,如此,怎样的巨数,亦可以变为轻赍,而伸缩又极自由,较之用金银,实在合理得许多。而惜乎给国家攫取其发行之权,以济财政之急,把这自然而合理的进化拗转了。\n于此,又可知道纸币之弊。黄金为什么不起而代之,而必代之以银。从前的人,都说古代的黄金是多的,后世却少了,而归咎于佛事的消耗(顾炎武《日知录》,赵翼《廿二史札记》、《陔余丛考》,都如此说),其实不然。王莽败亡时,省中黄金万斤为一匮者,尚有六十匮。其数为六十万斤。古权量当今五分之一,则得今十二万斤,即一百九十二万两。中国人数,号称四万万。女子当得半数,通常有金饰的,以女子为多。假使女子百人之中,有一人有金饰,其数尚不及一两。现在民间存金之数,何止如此?《齐书·东昏侯纪》,谓其“后宫服御,极选珍奇。府库旧物,不复周用。贵市民间。金银宝物,价皆数倍,京邑酒租,皆折使输金,以为金涂”。这几句话,很可说明历史记载,古代金多,后世金少的原因。古代人民生活程度低。又封建之世,服食器用,皆有等差。平民不能僭越。珠玉金银等,民间收藏必极少。这个不但金银如此,怕铜亦是如此。秦始皇的销兵,人人笑其愚笨。然汉世盗起,必劫库兵。后汉时羌人反叛,因归服久了,无复兵器,多执铜镜以象兵。可见当时民间兵器实不多。不但兵器不多,即铜亦不甚多。所以贾谊整理币制之策,是“收铜勿令布”。若铜器普遍于民间,亦和后世一样,用什么法子收之勿令布呢?铜尚且如此,何况金银?所以古代所谓金多,并非金真多于后世,乃是以聚而见其多。后世人民生活程度渐高;服食器用,等差渐破,以朝廷所聚之数,散之广大的民间,就自然不觉其多了。读史的人,恒不免为有明文的记载所蔽,而忽略于无字句处。我之此说,一定有人不信。因为古书明明记载汉时黄金的赏赐,动辄数十斤数百斤,甚且有至数千斤的,如何能不说古代的黄金,多于后世呢?但是我有一个证据,可以折服他。王莽时,黄金一斤直钱万,朱提银八两为一流,直钱一千五百八十,他银一流直钱千,则金价五倍于银。《日知录》述明洪武初,金一两等于银五两,则金银的比价,汉末与明初相同。我们既不见古书上有大量用银的记载,亦不闻佛法输入以后,银有大量的消耗,然则古书所载黄金大量使用之事,后世不见,并非黄金真少,只是以散而见其少,其事了然可见了。大概金银的比价,在前代,很少超过十倍的。然则在金朝末年,社会上白银固多,黄金亦不甚少。假使用银之故,是嫌铜币的笨重,而要代之以质小值巨之物,未尝不可舍银而取金,至少可以金银并用。然当时绝不如此。这明明由于银之起,乃所以代铜钱,而非以与铜钱相权,所以于金银二者之中,宁取其价之较低者。于此,可见以金银铜三品,或金银二品,或银铜二品为货币,并非事势之自然。自然之势,是铜钱嫌重,即走向纸币一条路的。\n金银二物,旧时亦皆铸有定形。《清文献通考》说:“古者金银皆有定式,必铸成币而后用之。颜师古注《汉书》,谓旧金虽以斤为名,而官有常形制。亦犹今时吉字金锭之类。武帝欲表祥瑞,故改铸为麟趾蹄之形,以易旧制。然则麟趾蹄,即当时金币式也。汉之白选与银货,亦即银币之式。《旧唐书》载内库出方圆银二千一百七十二两,是唐时银亦皆系铸成。”按金属货币之必须铸造,一以保证其成色,一亦所以省秤量之烦。古代金银虽有定形,然用之必仍以斤两计,似乎其分量的重轻,并无一定。而其分量大抵较重,尤不适于零星贸易之用。《金史·食货志》说:“旧例银每锭五十两,其直百贯。民间或有截凿之者,其价亦随低昂。”每锭百贯,其不能代铜钱可知。章宗承安二年,因钞法既敝,乃思乞灵于银。改铸银,名承安宝货。自一两至十两,分为五等,每两折钞二贯。公私同见钱行使,亦用以代钞本。后因私铸者多,杂以铜锡,浸不能行。五年,遂罢之。宣宗时,造贞祐宝券及兴定宝泉,亦皆与银相权。然民间但以银论价。于是限银一两,不得超过宝泉三百贯(按宝泉法价,每二贯等于银一两)。物价在三两以下者,不许用银。以上者三分为率,一分用银,二分用宝泉。此令既下,“商旅不行,市肆昼闭”,乃复取消。至哀宗正大间,民间遂全以银市易。《日知录》说:“此今日上下用银之始。”其时正值无钱可用的时候,其非用以与钱相权,而系以之代钱,显然可见了。\n元明两朝,当开国之初,都曾踌躇于用钱、用钞之间。因铜的缺乏,卒仍舍钱而用钞。元初有行用钞,其制无可考。世祖中统元年,始造交钞,以丝为本。是年十月,又造中统宝钞。分十、二十、三十、五十、一百、二百、五百、一贯、二贯(此据《食货志》。《王文统传》云:中统钞自十文至二贯凡十等。疑《食货志》夺三百一等)。每一贯同交钞一两,两贯同白银一两。又以文绫织为中统银货。分一两、二两、三两、五两、十两五等。每两同白银一两,未曾发行。至元十二年,添造厘钞。分一文、二文、三文三等。十五年,以不便于民罢。二十四年,造至元钞。自二贯至五文,凡十一等。每一贯当中统钞五贯,二贯等于银一两,二十贯等于金一两。武宗至大二年,以物重钞轻,改造至大银钞。自二两至二厘,共十三等。每一两准至元钞五贯,白银一两,赤金一钱。仁宗即位,以倍数太多,轻重失宜,罢至大银钞,其中统、至元二钞,则终元世常行。按元朝每改钞一次,辄准旧钞五倍,可见当其改钞之时,即系钞价跌至五分之一之时。货币跌价,自不免影响于民生。所以“实钞法”,实在是当时的一个大问题。元初以丝为钞本,丝价涨落太大,用作钞本,是不适宜的。求其价格变动较少的,自然还是金属。金属中的金银,都不适于零星贸易之用。厘钞及十文五文之钞,行用亦实不适宜。所以与其以金银为钞本,实不如以铜钱为钞本。元朝到顺帝至正年间,丞相脱脱,才有此议。下诏:以中统钞一贯,权铜钱一千,准至元钞二贯。铸至正通宝钱,与历代铜钱并用。这实在是一个贤明的办法。然因海内大乱,军储赏犒,每日印造,不可数计。遂至“交料散满人间”,“人视之若敝楮”了。明初,曾设局铸钱。至洪武七年,卒因铜之不给,罢铸钱局而行钞。大明宝钞,以千文准银一两,四贯准黄金一两。后因钞价下落,屡次鬻官物,或税收限定必纳宝钞以收钞。然终于不能维持。至宣宗宣德三年,遂停止造钞。其时增设新税,或加重旧税的税额,专收钞而焚之。钞法既平之后,有些新税取消,税额复旧,有的就相沿下去了。钞关即是其中之一。自此租税渐次普遍收银,银两真成为通用的货币了。\n主币可以用纸,辅币则必须用金属。因其授受繁,纸易敝坏,殊不经济。所以以铜钱与纸币并行,实最合于理想。元明两朝,当行钞之时,并不铸钱。明朝到后来,铸钱颇多,却又并不行钞了,清朝亦然。顺康雍乾四朝,颇能实行昔人不爱铜不惜工之论。按分厘在古代,本系度名而非衡名。衡法以十黍为累,十累为铢,二十四铢为两。因其非十进,不便计算,唐朝铸开元通宝钱,乃以一两的十分之一,即二铢四累,为其一个的重量。宋太宗淳化二年,乃改衡法。名一两的十分之一为一钱,一钱的十分之一为一分,一分的十分之一为一厘。钱即系以一个铜钱之重为名。分厘之名,则系借诸度法的。依照历朝的成法,一个铜钱,本来只要重一钱。顺康雍乾四朝所铸,其重量却都超过一钱以上,铸造亦颇精工。可谓有意于整顿币制了。惜乎于货币的原理未明,所以仍无成效可见。怎样说清朝的货币政策,不合货币原理呢?按(一)货币最宜举国一律。这不是像邮票一般,过了若干时间,就不复存在的。所以邮票可以花样翻新,货币则不宜然。此理在唐朝以前,本来明白。所以汉朝的五铢钱,最得人民信用,自隋以前,所铸的铜钱,即多称五铢。唐初改铸开元通宝,大约是因当时钱法大坏,想与民更始的,揣度当时的意思,或者想以开元为全国唯一通行的钱。所以后世所铸的钱,仍系开元通宝(高宗的乾封泉宝,肃宗的乾元重宝、重轮乾元等,虽都冠之以年号,然皆非小平钱,当时不认为正式的货币)。不过其统一的目的,未能达到罢了。宋以后才昧于此理,把历朝帝皇的年号,铸在铜钱之上。于是换一个皇帝,就可以有一种钱文(年号时有改变,则还可以不止一种)。货币形式的不统一,不是事实使然,竟是立法如此了。甚至像明朝世宗,不但铸嘉靖年号的铜钱,还补铸前此历朝未铸的年号。这不是把铜钱不看做全国的通货,而看做皇帝一个人的纪念品么?若使每朝所铸的,只附铸一个年号,以表明其铸造的年代,而其余一切,都是一律,这还可以说得过去。而历代又不能然。清朝亦是如此。且历朝所铸的铜钱,重量时有出入。这不是自己先造成不统一么?(二)虽然如此,但得所铸的钱,不至十分恶劣,则在专制时代,即但以本朝所铸之钱为限,而禁绝其余的恶薄者,亦未始不可以小康。此即明代分别制钱和古钱的办法(明天启、崇祯间,括古钱以充废铜,以统一币制论,实在是对的),但要行此法,有一先决问题,即必须先使货币之数足用。若货币之数,实在不足于用,交易之间,发生困难,就无论何等恶劣的货币,人民也要冒险使用,禁之不可胜禁,添出整理的阻力来了。自明废除纸币以后,直至清朝,要把铜钱铸到人民够用,是极不容易办到的。当此之时,最好将纸币和铜钱相权。而明清皆不知出此,听任银铜并行。又不知规定其主辅的关系。在明朝,租税主于收银,铜钱时有禁令,人民怀疑于铜钱之将废,不敢收受,大为铜钱流通之害。清朝则人民认铜钱为正货,不愿收受银两。而政府想要强迫使用,屡烦文告,而卒不能胜。而两种货币,同时并行,还生出种种弊窦(如租税征收等)。不明经济原理之害,真可谓生于其心,害于其政了。\n外国银钱的输入,并不始于近代。《隋书·食货志》说南北朝时河西、交、广的情形,已见前。《日知录》引唐韩愈《奏状》,说五岭卖买一以银。元稹奏状,说自岭以南,以金银为货币。张籍诗说:海国战骑象,蛮州市用银。《宋史·仁宗纪》:景祐二年,诏诸路岁输缗钱,福建、二广以银。《集释》说:顺治六、七年间,海禁未设,市井贸易,多以外国银钱。各省流行,所在多有。禁海之后,绝迹不见。这可见外国货币之侵入,必限于与外国通商之时,及与外国通商之地。前此中外交通,时有绝续,又多限于一隅,所以不能大量侵入。到五口通商以后,情形就大不相同了。外国铸造的货币,使用的便利,自胜于我国秤量的金银(其秤量之法,且不划一)。外国银圆,遂滔滔输入,而以西班牙、墨西哥两国的为多。中国的自铸,始于光绪十三年(广东总督张之洞所为)。重量形式,都模仿外国银圆,以便流通。此时铜钱之数,颇感不足。光绪二十七年,广东开铸铜元,因其名价远超于实价,获利颇多。于是各省竞铸铜元,以谋余利,物价为之暴腾。小平钱且为其驱逐以尽,民生大感困苦。光绪三十年,度支部奏厘定币制,以银圆为本位货币,民国初年仍之。其时孙文创用纸币之议,举国的人,多不解其理论,非难蜂起。直到最近,国民政府树立法币制度,才替中国的货币,画一个新纪元。\n●袁大头\n第四十九章 衣食 # 《礼记·礼运》说:“昔者先王未有宫室,冬则居营窟,夏则居橧巢。未有火化,食草木之实,鸟兽之肉,饮其血,茹其毛。未有麻丝,衣其羽皮。后圣有作,然后修火之利。笵金,合土,以为台榭、宫室、牖户。以炮,以燔,以亨,以炙,以为醴酪。治其麻丝,以为布帛。”这是古人总述衣食住的进化的。(一)古代虽无正确的历史,然其荦荦大端,应为记忆所能及。(二)又古人最重古。有许多典礼,虽在进化之后,已有新的、适用的事物,仍必保存其旧的、不适用的,以资纪念。如已有酒之后,还要保存未有酒时的明水(见下),即其一例。此等典礼的流传,亦使人容易记忆前代之事。所以《礼运》这一段文字,用以说明古代衣食住进化的情形,是有用的。\n据这一段文字,古人的食料共有两种:即(一)草木之实,(二)鸟兽之肉,(三)但还漏列了一种重要的鱼。古人以鱼鳖为常食。《礼记·王制》说:“国君无故不杀牛,大夫无故不杀羊,士无故不杀犬豕。”又说:“六十非肉不饱。”《孟子》说:“鸡、豚、狗、彘之畜,无失其时,七十者可以食肉矣。”(《梁惠王上篇》)则兽肉为贵者,老者之食。又说:“数罟不入洿池,鱼鳖不可胜食也”,与“不违农时,谷不可胜食也”并举。《诗经·无羊篇》:“牧人乃梦,众维鱼矣。大人占之,众惟鱼矣,实维丰年。”郑《笺》说:“鱼者,庶人之所以养也。今人众相与捕鱼,则是岁熟相供养之祥。”《公羊》宣公六年,晋灵公使勇士杀赵盾。窥其户,方食鱼飧。勇士曰:“嘻!子诚仁人也。为晋国重卿,而食鱼飧,是子之俭也。”均鱼为大众之食之征。此等习惯,亦必自隆古时代遗留下来的。我们可以说:古人主要的食料有三种:(一)在较寒冷或多山林的地方,从事于猎,食鸟兽之肉,饮其血,茹其毛,衣其羽皮。(二)在气候炎热、植物茂盛的地方,则食草木之实。衣的原料麻、丝,该也是这种地方发明的。(三)在河湖的近旁则食鱼。\n古代的食物虽有这三种,其中最主要的,怕还是第二种。因为植物的种类多,生长容易。《墨子·辞过篇》说:“古之民,素食而分处。”孙诒让《闲诂》说:“素食,谓食草木。素,疏之假字。疏,俗作蔬。”按古疏食两字有两义:(一)是谷物粗疏的,(二)指谷以外的植物。《礼记·杂记》:“孔子曰:吾食于少施氏而饱,少施氏食我以礼。吾祭,作而辞曰:疏食不足祭也。吾飧,作而辞曰:疏食也,不足以伤吾子。”《疏》曰:“疏粗之食,不可强饱,以致伤害。”是前一义。此所谓疏食,是后一义,因其一为谷物,一非谷物,后来乃加一草字头,以资区别。《礼记·月令》:仲冬之月,“山林薮泽,有能取蔬食,田猎禽兽者,野虞教道之。其有相侵夺者,罪之不赦”。《周官》太宰九职,八曰臣妾,聚敛疏材。《管子·七臣七主篇》云:“果蓏素食当十石”,《八观篇》云:“万家以下,则就山泽”。可见蔬食为古代重要的食料,到春秋战国时,还能养活很多的人口。至于动物,则其数量是比较少的。饮血茹毛,现在只当作形容野蛮人的话,其实在古代确是事实。《义疏》引“苏武以雪杂羊毛而食之”,即其确证。隆古时代,苏武在北海边上的状况,决不是常人所难于遭遇的。《诗经·豳风》:“九月筑场圃。”郑《笺》云:“耕治之以种菜茹。”《疏》云:“茹者,咀嚼之名,以为菜之别称,故书传谓菜为茹。”菜即今所谓蔬,乃前所释疏食中的第二义。后世的菜,亦是加以选择,然后种植的,吃起来并不费力。古代的疏食,则是向山林薮泽中,随意取得的野菜,其粗疏而有劳咀嚼,怕和鸟兽的毛,相去无几。此等事实,均逼着人向以人工生产食物的一条路上走。以人工生产食料,只有畜牧和耕种两法。畜牧须有适宜的环境,而中国无广大的草原(古代黄河流域平坦之地,亦沮洳多沼泽),就只有走向种植一路了。\n古人在疏食时代的状况,虽然艰苦,却替后人造下了很大的福利。因为所吃的东西多了,所以知道各种植物的性质。我国最古的药书,名为《神农本草经》。《淮南子·修务训》说:“神农尝百草之滋味,水泉之甘苦,一日而遇七十毒。”此乃附会之辞,古所谓神农,乃农业两字之义,并非指姜姓的炎帝其人。《礼记·月令》说“毋发令而待,以妨神农之事”,义即如此。《孟子·滕文公上篇》“有为神农之言者许行”,义亦如此。《神农本草经》,乃农家推源草木性味之书,断非一个人的功绩。此书为中国几千年来药物学的根本。其发明,全是由于古代的人们,所吃的植物,种类甚多之故。若照后世人的吃法,专于几种谷类和菜蔬、果品,便一万年,也不会发明什么《本草》的。\n一方面因所食之杂,而发现各种植物的性质;一方面即从各种植物中,淘汰其不适宜于为食料的,而栽培其宜于作食物的。其第(一)步,系从各种植物中,取出谷类,作为主食品。其第(二)步,则从谷类之中,再淘汰其粗的,而存留其精的。所以古人说百谷,后来便说九谷,再后来又说五谷。到现在,我们认为最适宜的主食品,只有稻和麦两种了。《墨子·辞过篇》说:“圣人作,诲男耕稼树艺,以为民食。其为食也,足以增气充虚,强体适腹而已矣。”《吕氏春秋·审时篇》说:“得时之稼,其臭香,其味甘,其气章。百日食之,耳目聪明,心意睿智,四卫变强(《注》:‘四卫,四肢也。’)气不入,身无苛殃。黄帝曰:四时之不正也,正五谷而已矣。”观此,便知农业的发明、进步,和人民的营养、健康,有如何重要的关系了。\n古人所豢养的动物,以马、牛、羊、鸡、犬、豕为最普通,是为六畜(《周官》职方氏,谓之六扰。名见郑《注》)。马牛都供交通耕种之用,故不甚用为食料。羊的畜牧,需要广大的草地,也是比较贵重的。鸡、犬、豕则较易畜养,所以视为常食。古人去渔猎时代近,男子畜犬的多。《管子·山权数》说:“若岁凶旱,水泆,民失本,则修宫室台榭,以前无狗,后无彘者为庸。”可见狗的畜养,和猪一样普遍。大概在古代,狗是男子所常畜,猪则是女子所畜的。家字从宀从豕,后世人不知古人的生活,则觉其难于解释。若知道古代的生活情形,则解释何难之有?猪是没有自卫能力的,放浪在外,必将为野兽所吞噬,所以不得不造屋子给它住。这种屋子,是女子所专有的。所以引申起来,就成为女子的住所的名称了。《仪礼·乡饮酒礼》记:“其牲狗”,《礼记·昏义》:“舅姑入室,妇以特豚馈”。可见狗是男子供给的肉食,猪是女子供给的肉食。后来肉食可以卖买,男子就有以屠狗为业的了。牛马要供给交通耕种之用,羊没有广大的草地,可资放牧,这种情形,后世还是和古代一样的,狗却因距离游猎时代远,畜养的人少了,猪就成为通常食用的兽。\n烹调方法的进步,也是食物进化中一种重要的现象。其根本,由于发明用火。而陶器制造的成功,也是很有关系的。《礼运》云:“夫礼之初,始诸饮食。其燔黍而捭豚,污尊而抔饮,蒉桴而土鼓,犹若可以致其敬于鬼神。”《注》云:“中古未有釜甑,释米,捭肉,加于烧石之上而食之耳。今北狄犹然。”此即今人所谓“石烹”。下文的《注》云:“炮,裹烧之也。燔,加于火上。亨,煮之镬也。炙,贯之火上。”其中只有烹,是陶器发明以后的方法。据社会学家说:陶器的发明,实因烧熟食物时,怕其枯焦,涂之以土,此正郑《注》所谓裹烧。到陶器发明以后,食物煮熟时,又可加之以水。有种质地,就更易融化。调味料亦可于取熟时同煮。烹调之法,就更易进行了。烹调之法,不但使(一)其味加美,亦能(二)杀死病菌,(三)使食物易于消化,于卫生是很有关系的。\n饮食的奢侈,亦是以渐而致的。《盐铁论·散不足篇》:贤良说:“古者燔黍食稗,而烨豚以相飨(烨当即捭字)。其后乡人饮酒,老者重豆,少者立食,一酱一肉,旅饮而已。及其后,宾昏相召,则豆羹白饭,綦脍熟肉。今民间酒食,殽旅重叠,燔炙满案。古者庶人粝食藜藿,非乡饮酒,腊,祭祀无酒肉。今闾巷县伯,阡陌屠沽,无故烹杀,相聚野外,负粟而往,挈肉而归。古者不粥絍(当作饪,熟食也),不市食。及其后,则有屠沽、沽酒、市脯、鱼盐而已。今熟食遍列,殽施成市。”可见汉代人的饮食,较古代为侈。然《论衡·讥日篇》说:“海内屠肆,六畜死者,日数千头。”怕只抵得现在的一个上海市。《隋书·地理志》说:梁州、汉中的人,“性嗜口腹,多事田渔。虽蓬室柴门,食必兼肉”。其生活程度,就又非汉人所及了。凡此,都可见得社会的生活程度,在无形中逐渐增高。然其不平均的程度,亦随之而加甚。《礼记·王制》说:“三年耕,必有一年之食,九年耕,必有三年之食。以三十年之通,虽有凶旱水溢,民无菜色,然后天子食,日举,以乐。”《玉藻》说:“至于八月不雨,君不举。”《曲礼》说:“岁凶,年不顺成,君膳不祭肺,马不食谷,大夫不食粱,士饮酒不乐。”这都是公产时代同甘共苦的遗规。然到战国时,孟子就以“庖有肥肉,厩有肥马,民有饥色,野有饿莩”,责备梁惠王了。我们试看《周官》的膳夫,《礼记》的《内则》,便知道当时的人君和士大夫的饮食,是如何奢侈。“朱门酒肉臭,路有冻死骨,荣枯咫尺异,惆怅难再述”,正不待盛唐的诗人,然后有这感慨了。\n《战国·魏策》说:“昔者帝女令仪狄作酒而美,进之禹。禹饮而甘之。遂疏仪狄,绝旨酒,曰:后世必有以酒亡其国者。”昔人据此,遂以仪狄为造酒的人。然仪狄只是作酒而美,并非发明造酒。古人所谓某事始于某人,大概如此。看《世本作篇》,便可知道。酒是要用谷类酿造的(《仪礼·聘礼》注:“凡酒,稻为上,黍次之,粟次之。”)其发明,必在农业兴起之后。《礼运》说:“污尊而抔饮。”郑《注》说:“污尊,凿地为尊也。抔饮,手掬之也。”这明明是喝的水。《仪礼·士昏礼疏》引此,谓其时未有酒醴,其说良是。《礼运》《疏》说凿地而盛酒,怕就未必然了。《明堂位》说:“夏后氏尚明水,殷人尚醴,周人尚酒。”凡祭祀所尚,都是现行的东西,前一时期的东西。据此,则酿酒的发明,还在夏后氏之先。醴之味较酒为醇,而殷人尚醴,周人尚酒;《周官》酒正,有五齐、三酒、四饮,四饮最薄,五齐次之,三酒最厚,而古人以五齐祭,三酒饮;可见酒味之日趋于厚。读《书经》的《酒诰》,《诗经》的《宾之初筵》等篇,可见古人酒德颇劣。现在的中国人,却没有酗酒之习,较之欧美人,好得多了。\n就古书看起来,古人的酒量颇大。《史记·滑稽列传》载淳于髡说:臣饮一斗亦醉,一石亦醉,固然是讽谕之辞,然《考工记》说:“食一豆肉,饮一豆酒,中人之食。”《五经异义》载《韩诗》说:古人的酒器:“一升曰爵,二升曰觚,三升曰觯,四升曰角,五升曰散。”古《周礼》说:“爵一升,觚三升,献以爵而酬以觚,一献而三酬,则一豆矣。”一豆就是一斗。即依《韩诗》说,亦得七升。古量法当今五分之一,普通人亦无此酒量。按《周官》浆人,六饮有凉。郑司农云:“以水和酒也。”此必古有此事,不然,断不能臆说的。窃疑古代献酬之礼,酒都是和着水喝的,所以酒量各人不同,而献酬所用的酒器,彼此若一。\n刺激品次于酒而兴起的为茶。茶之本字为荼。《尔雅·释木》:“槚,苦荼。”《注》云:“树小如栀子,冬生叶,可煮作羹饮。今呼早采者为茶,晚取者为茗,一名荈。蜀人名之苦荼。”按荼系苦菜之称。荼之味微苦。我们创造一句新的言语,是不容易的。遇有新事物须命名时,往往取旧事物和它相类的,小变其音,以为新名。在单音语盛行时,往往如此。而造字之法,亦即取旧字而增减改变其笔画,以为新字。如角甪、刀刁,及现在所造的乒乓等字皆其例。所以从荼字会孕育出茶的语言文字来(语言从鱼韵转入麻韵,文字减去一画)。茶是出产在四川,而流行于江南的。《三国吴志·韦曜传》说:孙皓强迫群臣饮酒时,常密赐茶荈以当酒。《世说新语》谓王濛好饮茶。客至,尝以是饷之。士大夫欲诣濛,辄曰:今日有水厄。即其证。《唐书·陆羽传》说:“羽嗜茶。著经三篇,言茶之源、之法、之具尤备。天下益知饮茶矣。其后尚茶成风,回纥入朝,始驱马市茶。”则茶之风行全国,浸至推及外国,是从唐朝起的。所以唐中叶后,始有茶税。然据《金史》说:金人因所需的茶,全要向宋朝购买,认为费国用而资敌。章宗承安四年,乃设坊自造,至泰和五年罢。明年,又定七品以上官方许食茶。据此,即知当时的茶,并不如今日的普遍。如其像现在一样,全国上下,几于无人不饮,这种禁令,如何能立呢?平话中《水浒传》的蓝本,是比较旧的。现行本虽经金圣叹改窜,究竟略存宋元时的旧面目。书中即不甚见饮茶,渴了只是找酒喝。此亦茶在宋元时还未如今日盛行的证据。《日知录》引唐綦毋《茶饮序》云:“释滞消壅,一日之利暂佳,瘠气侵精,终身之害斯大。”宋黄庭坚《茶赋》云:“寒中瘠气,莫甚于茶。”则在唐宋时,茶还带有药用的性质,其刺激性,似远较今日之茶为烈。古人之茶系煎饮,亦较今日的用水泡饮为烦。如此看来,茶的名目,虽今古相同,其实则大相殊异了。这该是由于茶的制法,今古不同,所以能减少其有害的性质,而成为普遍的饮料。这亦是饮食进化的一端。\n次于茶而兴起的为烟草。其物来自吕宋。名为菸,亦名淡巴菰(见《本草》),最初莆田人种之。王肱枕《蚓庵琐语》云:“烟叶出闽中,边上人寒疾,非此不治。关外至以一马易一觔。崇祯中,下令禁之。民间私种者问徒刑。利重法轻,民冒禁如故。寻下令:犯者皆斩。然不久,因军中病寒不治,遂弛其禁。予儿时尚不识烟为何物,崇祯末,三尺童子,莫不吃烟矣。”(据《陔余丛考》转引)据此,则烟草初行时,其禁令之严,几与现在的鸦片相等。烟草可治寒疾,说系子虚,在今日事极明白。军中病寒,不过弛禁的一藉口而已。予少时曾见某书,说明末北边的农夫,有因吸烟而醉倒田中的(此系予十余龄时所见,距今几四十年,不能忆其书名。藏书毁损大半,仅存者尚在游击区中,无从查检)。在今日,无论旱烟、水烟、卷烟,其性质之烈,均不能至此。则烟草的制法,亦和茶一般,大有改良了。然因此而引起抽吸大烟,则至今仍遗害甚烈。\n罂粟之名,始见于宋初的《开宝本草》。宋末杨士瀛的《直指方》,始云其壳可以治痢。明王玺《医林集要》,才知以竹刀刮出其津,置瓷器内阴干。每服用小豆一粒,空心温水化下,然皆以作药用。俞正燮《癸巳类稿》云:“明四译馆同文堂外国来文八册,有译出暹罗国来文,中有进皇帝鸦片二百斤,进皇后鸦片一百斤之语。又《大明会典》九十七、九十八,各国贡物,暹罗、爪哇、榜葛剌三国,俱有乌香,即鸦片。”则明时此物确系贡品。所以神宗皇帝,久不视朝,有疑其为此物所困的。然其说亦无确据。今人之用作嗜好品,则实由烟草引起。清黄玉圃《台海使槎录》云:“鸦片烟,用麻葛同雅士切丝,于铜铛内煎成鸦片拌烟。用竹筩,实以棕丝,群聚吸之。索直数倍于常烟。”《雍正朱批谕旨》:七年,福建巡抚刘世明,奏漳州知府李国治,拿得行户陈达私贩鸦片三十四斤,拟以军罪。臣提案亲讯。陈达供称鸦片原系药材,与害人之鸦片烟,并非同物。当传药商认验。佥称此系药材,为治痢必须之品,并不能害人。惟加入烟草同熬,始成鸦片烟。李国治妄以鸦片为鸦片烟,甚属乖谬,应照故入人罪例,具本题参。则其时的鸦片,尚未能离烟草而独立。后来不知如何,单独抽吸,其害反十百倍于烟草了。\n中国食物从外国输入的甚多。其中最重要的,自然当推蔗糖,其法系唐太宗时,得之于摩揭它的,见《唐书·西域传》。前此的饴,是用米麦制的。大徐《说文》新附字中,始有糖字。字仍从米,释以饴而不及蔗,可见宋初蔗糖尚未盛行。北宋末,王灼撰《糖霜谱》,始备详其产地及制法。到现在,蔗糖却远盛于饴糖了。此外菜类如苜蓿,果品如西瓜等,自外国输入的还很多。现在不及备考。\n中国人烹调之法,在世界上是首屈一指的。康有为《欧洲十一国游记》,言之最详。但调味之美,和营养之佳良,系属两事,不可不知。又就各项费用在全体消费中所占的成分看,中国人对于饮食,是奢侈的。康有为《物质救国论》说:国民的风气,侈居为上,侈衣次之,侈食为下。这亦是我国民不可不猛省的。\n衣服的进化,当分两方面讲:一是材料,一是裁制的方法。\n《礼运》说“未有麻丝,衣其羽皮”。这只是古人衣服材料的一种。还有一种,是用草的。《礼记·郊特牲》说:“黄衣黄冠而祭,息田夫也。野夫黄冠。黄冠,草服也。大罗氏,天子之掌鸟兽者也,诸侯贡属焉。草笠而至,尊野服也。”《诗经》:“彼都人士,台笠缁撮。”《毛传》:“台所以御暑,笠所以御雨也。”《郑笺》:“台,夫须也。都人之士,以台为笠。”《左传》襄公十四年,晋人数戎子驹支道:“乃祖吾离,被苫盖。”《注》:“盖,苫之别名。”《疏》云:“言无布帛可衣,惟衣草也。”《墨子·辞过》云:“古之民未知为衣服时,衣皮带茭。”孙诒让《闲诂》说:“带茭,疑即《丧服》之绞带,亦即《尚贤篇》所谓带索。”按《仪礼·丧服传》云:“绞带者,绳带也。”又《孟子·尽心上篇》:“舜视弃天下,犹弃敝屣也。”《注》云:“屣,草履。”《左传》僖公四年,“共其资粮屝屦。”《注》云:“屝,草屦。”可见古人衣服冠履,都有用草制的。大概古代渔猎之民,以皮为衣服的材料。所以《诗经·采菽》郑《笺》说黻道:“古者田渔而食,因衣其皮,先知蔽前,后知蔽后。”(参看下文)而后世的甲,还是用革制的。戴在头上的有皮弁,束在身上的有革带,穿在脚上的有皮屦(夏葛屦,冬皮屦,见《仪礼·士冠礼》、《士丧礼》,履以丝为之,见《方言》)。农耕之民,则以草为衣服的材料。所以《郊特牲》说黄衣黄冠是野服。《禹贡》:扬州岛夷卉服,冀州岛夷皮服(岛当作鸟,《疏》言伪孔读鸟为岛可见)。观野蛮人的生活,正可知道我族未进化时的情形。\n麻丝的使用,自然是一个大发明。丝的使用,起于黄帝元妃嫘祖,说不足信,已见上章。麻的发明,起于何时,亦无可考。知用麻丝之后,织法的发明,亦为一大进步。《淮南子·氾论训》说:“伯余之初作衣也,麻索缕,手经指挂,其成犹网罗。后世为之机杼胜复,以领其用,而民得以揜形御寒。”手经指挂,是断乎不能普遍的。织法的发明,真是造福无穷的了。但其始于何时,亦不可考。丝麻发明以后,皮和草的用途,自然渐渐的少了。皮的主要用途只是甲。至于裘,则其意不仅在于取暖,而兼在于美观。所以古人的著裘,都是把毛著在外面,和现在人的反着一样(《新序·杂事》:“虞人反裘而负薪,彼知惜其毛,不知皮尽而毛无所傅。”)外面罩着一件衣服,谓之裼衣。行礼时,有时解开裼衣,露出里面的裘来,有时又不解开,把它遮掩掉,前者谓之裼,后者谓之袭。藉此变化,以示美观(无裼衣谓之“表裘”为不敬。绤之上,亦必加以禅衣谓之袗)。穷人则着毛织品,谓之褐。褐倒是专为取暖起见的。现在畜牧和打猎的事业都衰了,丝棉较皮货为贱。古代则不然。裘是比较普遍的,丝棉更贵。二十可以衣裘帛(《礼记·内则》),五十非帛不暖(《礼记·王制》)。庶人亦得衣犬羊之裘,即其明证。丝棉新的好的谓之纩,陈旧的谓之絮。见《说文》。\n现在衣服材料,为用最广的是木棉。其普遍于全国,是很晚的。此物,《南史·林邑传》谓之吉贝,误为木本。《新唐书》作古贝,才知为草本。《南史》姚察门生送南布一端;白居易《布裘诗》:“桂布白似雪”,都是指棉布而言。但只限于交、广之域。宋谢枋得《谢刘纯父惠木棉诗》:“嘉树种木棉,天何厚八闽?”才推广到福建。《元史·世祖本纪》:至元二十六年,置浙江、江东西、湖广、福建木棉提举司,则推广到长江流域了。其所以能推广,和纺织方法,似乎很有关系的。《宋史·崔与之传》:琼州以吉贝织为衣衾,工作由妇人。陶宗仪《辍耕录》说:松江土田硗瘠,谋食不给,乃觅木棉种于闽、广。初无踏车椎弓之制。其功甚难。有黄道婆,自崖州来,教以纺织,人遂大获其利。未几,道婆卒,乃立祠祀之。木棉岭南久有,然直至宋元间才推行于北方,则因无纺织之法,其物即无从利用,无利之可言了。所以农、工两业,是互相倚赖,互相促进的(此节略据《陔余丛考》)。\n衣服裁制的方法:最早有的,当即后来所谓黻。亦作。此物在后来,是著在裳之外,以为美观的。但在邃初,则当系亲体的。除此之外,全身更无所有。所以《诗经·郑笺》说:“古者田渔而食,因衣其皮,先知蔽前,后知蔽后。”衣服的起源,从前多以为最重要的原因是御寒,次之是蔽体。其实不然。古人冬则穴居,并不藉衣服为御寒之具。至于裸露,则野蛮人绝不以为耻,社会学上证据甚多。衣服的缘起,多先于下体,次及上体;又多先知蔽前,后知蔽后,这是主张衣服缘起,由于以裸露为耻者最大的证据。据现在社会学家的研究,则非由于以裸露为耻,而转系籍装饰以相挑诱。因为裸露是人人所同,装饰则非人人所有,加以装饰,较诸任其自然,刺激性要重些。但蔽其前为韨,兼蔽其后即为裳了。裳而加以袴管(古人谓之),短的谓之裈,长的谓之袴,所以《说文》称袴为胫衣,昔人所谓贫无袴,裈还是有的,并非裸露。又古人的袴、裆都是不缝合的,其缝合的谓之穷袴,转系特别的。见《汉书·外戚传》。这可见裈和袴,都是从裳变化出来的,裳在先,裈和袴在后。裳幅前三后四,都正裁。吉服襞绩(打裥)无数,丧服三襞绩(《仪礼·丧服》郑《注》)。着在上半身的谓之衣。其在内的:短的谓之襦。长的,有着(装绵),谓之袍,无着谓之衫。古代袍、衫不能为礼服,其外必再加以短衣和裳。戴在头上的,最尊重的是冕。把木头做骨子,外面用布糊起来,上面是玄色,下面是朱色。戴在头上,前面是低一些的。前有旒,据说是把五彩的绳,穿了一块块的玉,垂在前面。其数,天子是十二,此外九旒、七旒等,以次减杀。两旁有纩,是用黄绵,大如丸,挂在冕上面的,垂下来,恰与两耳相当。后来以玉代黄绵,谓之瑱。冕,当系野蛮时代的装饰,留遗下来的。所以其形状,在我们看起来,甚为奇怪,古人却以为最尊之服。次于冕者为弁,以皮为之。其形状亦似冕。但无旒及等,戴起来前后平。冠是所以豢发的。其形状,同现在旧式丧礼中孝子戴的丧冠一样。中间有一个梁,阔两寸。又以布围发际,自前而后,谓之武。平居的冠,和武是连在一起的。否则分开,临时才把它合起来。又用两条组,连在武上,引至颐下,将它结合,是为缨。有余,就把它垂下来,当作一种装饰,谓之。冠不用簪,冕弁则用簪。簪即女子之笄,古人重露发,必先把“缁”套起来,结之为,然后固之以冠。冠用缨,冕弁则把一条组结在右笄上,垂下来,经过颐下,再绕上去,结在左笄上。冠是成人的服饰,亦是贵人的服饰,所以有罪要免冠。至于今之脱帽,则自免胄蜕化而来。胄是武人的帽子,因为怕受伤之故,下垂甚深,几于把脸都遮蔽掉了,看不见。所以要使人认识自己,必须将胄免去。《左传》哀公十六年,楚国白公作乱,国人专望叶公来救援。叶公走到北门,“或遇之,曰:君胡不胄?国人望君,如望慈父母焉。盗贼之矢若伤君,是绝民望也。若之何不胄?乃胄而进。又遇一人,曰:君胡胄?国人望君,如望岁焉,日日以几,若见君面,是得艾也。民知不死,其亦夫有奋心。犹将旌君以徇于国,而又掩面以绝民望,不亦甚乎?乃免胄而进。”可见胄的作用。现在的脱帽,是采用欧洲人的礼节。欧洲人在中古时代,战争是很剧烈的。免胄所以使人认识自己,握手所以表示没有兵器。后遂相沿,为寻常相见之礼。中国人模仿它,其实是无谓的。有人把脱帽写作免冠,那更和事实不合了。古代庶人是不冠的,只用巾。用以覆髻,则谓之帻。《后汉书·郭泰传》《注》引周迁《舆服杂事》说:“巾以葛为之,形如,本居士野人所服。”《玉篇》:“帽也。”《隋书·舆服志》:“帽,古野人之服。”则巾和帽是很相近的。著在脚上的谓之袜。其初亦以革为之。所以其字从韦作。袜之外为屦。古人升堂必脱屦。脱屦则践地者为袜,立久了,未免污湿,所以就坐又必解袜。见《左传》哀公二十五年。后世解袜与否无文,然脱屦之礼,则相沿甚久。所以剑履上殿,看做一种殊礼。《唐书》:棣王琰有两妾争宠。求巫者密置符于琰履中。或告琰厌魅,帝伺其朝,使人取其履验之,果然。则唐时入朝,已不脱履。然刘知幾以释奠皆衣冠乘马,奏言冠履只可配车,今袜而镫,跣而鞍,实不合于古。则祭祀还是要脱履的。大概跣礼之废,(一)由于靴之渐行,(二)由于席地而坐,渐变为高坐,参看后文及下章自明。古人亦有现在的绑腿,谓之逼,亦谓之邪幅,又谓之行縢。本是上路用的,然亦以之为饰。宋绵初《释服》说“解袜则见逼。《诗》云:邪幅在下,正是燕饮而跣以为欢之时”,则逼着在袜内。《日知录》说:“今之村民,往往行縢而不袜,古人之遗制也。吴贺邵美容止,常著袜,希见其足,则汉魏之世,不袜而见足者尚多。”又说袜字的从衣,始见于此,则渐变而成今日的袜了。窃疑袜本亦田猎之民之服,农耕之民,在古代本是跣足的。中国文化,本来起自南方,所以行礼时还必跣。\n衣服的初兴,虽非以蔽体为目的,然到后来,着衣服成了习惯,就要把身体的各部分,都遮蔽起来,以为恭敬了。所以《礼记》的《深衣篇》说:“短毋见肤。”作事以短衣为便,今古皆然。古代少者贱者,是多服劳役的。《礼记·曲礼》说:“童子不衣裘裳。”《内则》说:“十年,衣不帛,袴。”就是短衣,袴就是不裳。《左传》昭公二十五年,师己述童谣,说“鹆跦跦,公在乾侯,征褰与襦”。褰即是袴(《说文》)。此皆服劳役者不着裳之证。然襦袴在古人,不能算做礼服,外必加之以裳。既然如此,自以照现在人的样子,于襦袴之外,罩上一件长衫为便。然古人习于衣裳、袍衫之外,亦必加之以裳。于是从古代的衣裳,转变到现在的袍衫,其间必以深衣为过渡。深衣的意思,是和现在的女子所着的衣裙合一的衣服差不多的。形式上是上衣下裳,实则缝合在一起。裳分为十二幅,前后各六。中间四幅对开。边上两幅斜裁,成两三角形。尖端在上。所以其裳之下端与上端(腰间)是三与二之比。如此,则不须襞绩,自亦便于行动了。深衣是白布做的,却用绸镶边,谓之纯。无纯的谓之褴褛,尤为节俭(今通作蓝缕,其义为破,此是一义)。士以上别有朝祭之衣,庶人则即以深衣为吉服。未成年者亦然。所以戴德《丧服·变除》说:“童子当室(为父后),其服深衣不裳。”然自天子至于士,平居亦都是着一件深衣的。这正和现在的劳动者平时着短衣,行礼时着袍衫,士大夫阶级,平时着袍衫,行礼时别有礼服一样。然古人苟非极隆重的典礼,亦都可以着深衣去参与的。所以说“可以为文,可以为武,可以摈相,可以治军旅”(《礼记·深衣》)。民国以来,将平时所着的袍和马褂,定为常礼服。既省另制礼服之费,又省动辄更换之烦,实在是很合理的。\n《仪礼·士丧礼》疏,谓上下通直,不别衣裳者曰“通裁”,此为深衣改为长袍之始。然古人用之殊不广。后汉以后,始以袍为朝服。《续汉书·舆服志》说:若冠通天冠,则其服为深衣服。有袍,随五时色。刘昭《注》云:“今下至贱吏、小史,皆通制袍、禅衣、皂缘领袖为朝服。”《新唐书·车服志》:中书令马周上议:“礼无服衫之文。三代之制有深衣,请加襕袖褾襈,为士人上服。开胯者名曰缺胯,庶人服之。”据此,则深衣与袍衫之别,在于有缘无缘。其缺胯,就是现在的袍衫了。任大椿《深衣释例》说:“古以殊衣裳者为礼服,不殊衣裳者为燕服。后世自冕服外,以不殊衣裳者为礼服,以殊衣裳者为燕服。”此即所谓裙襦。妇人以深衣之制为礼服,不殊衣裳。然古乐府《陌上桑》云:“湘绮为下裳,紫绮为上襦”,则襦与裳亦各别。然仍没有不着裳的。隋唐以来,乃有所谓袴褶。(《急就篇》注云:“褶,其形若袍,短身广袖。”)天子亲征及中外戒严时,百官服之,实为戎服。\n曾三异《同话录》云:“近岁衣制,有一种长不过腰,两袖仅掩肘,名曰貉袖。起于御马院圉人。短前后襟者,坐鞍上不妨脱著,以其便于控驭也。”此即今之马褂。《陔余丛考》说:就是古代的半臂。《三国魏志·杨阜传》说:明帝著帽,披绫半袖,则其由来已久。《玉篇》说:裆,其一当胸,其一当背。《宋书·薛安都传》载他着绛衲两当衫,驰入贼阵。《隋书·舆服志》:诸将军侍从之服,有紫衫金玳瑁装裆甲,紫衫金装裆甲,绛衫银装裆甲。《宋史·舆服志》,范质议《开元礼》:武官陪立大仗,加螣蛇裆甲,《陔余丛考》说:就是今演剧时将帅所被金银甲。按现在我们所着,长不过腰,而无两袖的,北方谓之坎肩,南方有若干地方,谓之马甲。大概系因将帅服之之故。宋人谓之背子。见《石林燕语》。\n●古代服饰\n衣服不论在什么时代,总是大同小异的。强人人之所好,皆出于同,自然决无此理。何况各地方的气候,各种人的生活,还各有不同呢?但衣服既和社交有关,社会亦自有一种压力。少数的人,总要改从多数的。昔人所谓“十履而一跣,则跣者耻;十跣而一履,则履者耻”。其间别无他种理由可言。《礼记·王制》:“关执禁以讥,禁异服,察异言。”其意乃在盘诘形迹可疑的人,并不在于划一服饰。《周官》大司徒,以本俗六安万民,六曰同衣服,意亦在于禁奢,非强欲使服饰齐一。服饰本有一种社会压力,不会大相悬殊的。至于小小的异同,则无论何时,皆不能免。《礼记·儒行》:“鲁哀公问于孔子曰:夫子之服,其儒服与?孔子对曰:丘少居鲁,衣逢掖之衣。长居宋,冠章甫之冠。丘闻之也,君子之学也博,其服也乡。丘不知儒服。”观此数语,衣服因地方、阶级,小有异同,显然可见。降逮后世,叔孙通因高祖不喜儒者,改着短衣楚制(见《史记》本传)。《盐铁论》载桑弘羊之言,亦深讥文学之儒服(见《相刺篇》、《刺议篇》),可见其情形还是一样的。因为社会压力,不能施于异地方和异阶级的人。然及交通进步,各阶级的交往渐多,其压力,也就随之而增大了。所以到现代,全世界的服饰,且几有合同而化之观。日本变法以后,几于举国改着西装。中国当戊戌变法时,康有为亦有改服饰之议。因政变未成。后来自刻《戊戌奏稿》,深悔其议之孟浪,而自幸其未果行。在所著《欧洲十一国游记》中,尤极称中国服饰之美。其意是(一)中国的气候,备寒、温、热三带,所以其材料和制裁的方法,能适应多种气候,合于卫生。(二)丝织品的美观,为五洲所无。(三)脱穿容易。(四)贵族平民,服饰有异,为中西之所同。中国从前,平民是衣白色的。欧洲则衣黑色。革命时,欧人疾等级之不平,乃强迫全国上下,都着黑色。中国则不然。等级渐即平夷,采章遂遍及于氓庶。质而言之:西洋是强贵族服平民之服,中国则许平民服贵族之服。所以其美观与否,大相悬殊。这一点,西人亦有意见相同的。民国元年,议论服制时,曾有西人作论载诸报端,说西方的服饰,千篇一律,并无趣味,劝中国人不必摹仿。我以为合古今中外而观之,衣服不过南北两派。南派材料轻柔,裁制宽博。北派材料紧密,裁制狭窄。这两派的衣服,本应听其并行;且折衷于二者之间,去其极端之性的。欧洲衣服,本亦有南北两派。后来改革之时,偏重北派太甚了。中国则颇能折二者之中,保存南派的色彩较多。以中西的服饰相较,大体上,自以中国的服饰为较适宜。现在的崇尚西装,不过一时的风气罢了。\n中国的衣服,大体上可谓自行进化的。其仿自外国的,只有靴。《广韵》八戈引《释名》,说“靴本胡服,赵武灵王所服”。《北史》载慕容永被擒,居长安,夫妻卖靴以自活。北齐亡后,妃嫔入周的亦然。可见南北朝时,汉人能制靴者尚少,其不甚用靴可知。然唐中叶以后,朝会亦渐渐的着靴,朱文公《家礼》,并有襕衫带靴之制了。《说文》:“鞮,革履也。”《韵会》引下有“胡人履连胫,谓之络缇”九字。此非《说文》之文,必后人据靴制增入。然可悟靴所以广行之故。因为连胫,其束缚腿部较紧,可以省却行縢。而且靴用革制,亦较能抵御寒湿,且较绸布制者,要坚固些(此以初兴时论,后来靴亦不用革)。\n古代丧服,以布之精粗力度,不是讲究颜色的。素服则用白绢,见《诗经·棘人》疏。因为古代染色不甚发达,上下通服白色,所以颜色不足为吉凶之别。后世彩色之服,行用渐广,则忌白之见渐生。宋程大昌《演繁露》说:“《隋志》:宋齐之间,天子宴私著白高帽。隋时以白通为庆吊之服。国子生亦服白纱巾。晋人着白接篱,窦苹《酒谱》曰:接篱,巾也。南齐桓崇祖守寿春,着白纱帽,肩舆上城。今人必以为怪。古未有以白色为忌也。郭林宗遇雨垫巾,李贤《注》云:周迁《舆服杂事》曰,巾以葛为之,形如。本居士野人所服。魏武造,其巾乃废。今国子学生服焉,以白纱为之。是其制皆不忌白也。《乐府白纻歌》曰:质如轻云色如银,制以为袍余作巾。今世人丽妆,必不肯以白纻为衣。古今之变,不同如此。《唐六典》:天子服有白纱帽。其下服如裙、襦、袜皆以白。视朝听讼,燕见宾客,皆以进御。然其下注云:亦用乌纱。则知古制虽存,未必肯用,习见忌白久矣。”读此,便知忌白的由来。按染色之法,见于《周官》天官染人,地官染草,及《考工记》锺氏,其发明亦不可谓不早。但其能普遍于全社会,却是另一问题。绘绣之法,见《书经·皋陶谟》(今本《益稷》)《疏》。昔人误以绘为画。其实绘之本义,乃谓以各色之丝,织成织品。见于宋绵庄《释服》,其说是不错的。染色、印花等事,只要原料减贱,机器发明,制造容易,所费人工不多,便不得谓之奢侈。惟有手工,消费人工最多,总是奢侈的事。现在的刺绣,虽然是美术,其实是不值得提倡的。因为天下无衣无褐的人,正多着呢。\n第五十章 住行 # 住居,亦因气候地势的不同,而分为巢居、穴居两种。《礼运》说:“冬则居营窟,夏则居橧巢。”(见上章)《孟子》亦说:“下者为巢,上者为营窟。”(《滕文公下篇》)大抵温热之地为巢。干寒之地,则为营窟。巢居,现在的野蛮人,犹有其制。乃将大树的枝叶,接连起来,使其上可以容人,而将树干凿成一级一级的,以便上下。亦有会造梯的。人走过后,便将梯收藏起来。《淮南子·本经训》所谓“托婴儿于巢上”,当即如此。后来会把树木砍伐下来,随意植立,再于其上横架着许多木材,就成为屋子的骨干。穴居又分復穴两种:(一)最初当是就天然的洞窟,匿居其中的。(二)后来进步了,则能于地上凿成一个窟窿,而居其中,此之谓穴。古代管建设的官,名为司空,即由于此。(三)更进,能在地面上把土堆积起来,堆得像土窑一般,而于其上开一个窟窿,是之谓復,亦作复。再进化而能版筑,就成为墙的起源了。以栋梁为骨骼,以墙为肌肉,即成所谓宫室。所以直至现在,还称建筑为土木工程。\n中国民族,最初大约是湖居的。(一)水中可居之处称洲,人所聚居之地称州,州洲虽然异文,实为一语,显而易见(古州岛同音,洲字即岛字)。(二)古代有所谓明堂,其性质极为神秘。一切政令,都自此而出(读惠栋《明堂大道录》可见)。阮元说:这是由于古代简陋,一切典礼,皆行于天子之居,后乃礼备而地分(《揅经室集明堂说》),这是不错的。《史记·封禅书》载公玉带上《明堂图》,水环宫垣,上有楼,从西南入,名为昆仑,正是岛居的遗象。明堂即是大学,亦称辟雍。辟壁同字,正谓水环宫垣。雍即今之壅字,壅塞、培壅,都指土之增高而言,正像湖中岛屿。(三)《易经》泰卦上六爻辞,“城复于隍”。《尔雅·释言》:“隍,壑也。”壑乃无水的低地。意思还和环水是一样的。然则不但最初的建筑如明堂者,取法于湖居,即后来的造城,必环绕之以濠沟,还是从湖居的遗制,蜕化而出的。\n文化进步以后,不藉水为防卫,则能居于大陆之上。斯时藉山以为险阻。读第四十、第四十四、第四十五三章,可见。章炳麟《太炎文集》有《神权时代天子居山说》,可以参考。再进步,则城须造在较平坦之地,而藉其四周的山水以为卫,四周的山水,是不会周匝无缺的,乃用人工造成土墙,于其平夷无险之处,加以补足,是之谓郭。郭之专于一面的,即为长城。城是坚实可守的,郭则工程并不坚实,而且其占地太大,必不能守。所以古代只有守城,绝无守郭之事。即长城亦是如此。中国历代,修造长城,有几个时期。(一)为战国以前。齐国在其南边,造有长城,秦、赵、燕三国,亦在北边造有长城。后来秦始皇把它连接起来,就是俗话所称为万里长城的。此时南方的淮夷、北方的匈奴,都是小部落。到汉朝,匈奴强大了,入塞的动辄千骑万骑,断非长城所能御;而前后两呼韩邪以后,匈奴又宾服了,所以终两汉四百年,不闻修造长城。魏晋时,北方丧乱,自然讲不到什么远大的防御规模。拓跋魏时,则于北边设六镇,藉兵力以为防卫,亦没有修造长城的必要,(二)然至其末年,情形就大不相同了。隋代遂屡有修筑。此为修造长城的第二时期。隋末,突厥强大了,又非长城所能御。后来的回纥、契丹亦然。所以唐朝又无修筑长城之事。(三)契丹亡后,北方的游牧部族,不能统一,又成小小打抢的局面。所以金朝又要修造一道边墙,从静州起,迤逦东北行,达女真旧地。此为修造长城的第三时期。元朝自然无庸修造长城。(四)明时,既未能将蒙古征服,而蒙古一时亦不能统一。从元朝的汉统断绝以后,至达延汗兴起以前,蒙古对中国,并无侵犯,而只有盗塞的性质,所以明朝又修长城,以为防卫。现代的长城,大概是明朝遗留下来的。总而言之,小小的寇盗,屯兵防之,未免劳费,无以防之又不可。造长城,实在是最经济的方法。从前读史的人,有的称秦始皇造长城,能立万世夷夏之防,固然是梦话。有的议论他劳民伤财,也是胡说的。晁错说秦朝北攻胡貉,置塞河上,只是指秦始皇时使蒙恬新辟之土。至于其余的长城,因战国时秦、赵、燕三国之旧,缮修起来的,却并没有费什么工力。所以能在短时间之内,即行成功。不然,秦始皇再暴虐,也无法于短时间之内,造成延袤万余里的长城的。汉代的人,攻击秦朝暴虐的很多,未免言过其实,然亦很少提及长城的,就是一个证据。\n古代的房屋,有平民之居和士大夫之居两种。士大夫之居,前为堂,后为室。室之左右为房。堂只是行礼之地,人是居于室中的(室之户在东南,牖在西南,北面亦有牖,谓之北牖。室之西南隅,即牖下,地最深隐,尊者居之,谓之奥。西北隅为光线射入之地,谓之屋漏。东北隅称宦。宦养也,为饮食所藏。东南隅称窔,亦深隐之义。室之中央,谓之中霤,为雨水所溜入。此乃穴居时代,洞穴开口在上的遗象。古之牖即今之窗,是开在墙上的。其所谓窗,开在屋顶上,今人谓之天窗)。平民之居,据晁错《移民塞下疏》说:“古之徙远方以实广虚也,先为筑室。家有一堂二内。”《汉书》注引张晏曰:“二内,二房也。”此即今三开间的屋。据此,则平民之居,较之士大夫之居,就是少了一个堂。这个到现在还是如此。士大夫之家,前有厅事,即古人所谓堂。平民之家无有。以中间的一间屋,行礼待客,左右两间供住居,即是一堂二内之制。简而言之,就是以室为堂,以房为室罢了。古总称一所屋子谓之宫。《礼记·内则》说:“由命士以上,父子皆异宫”,则一对成年的夫妻,就有一所独立的屋子。后世则不然。一所屋子,往往包含着许多进的堂和内,而前面只有一个厅事。这就是许多房和室,合用一个堂,包含在一个宫内,较古代经济多了。这大约因为古代地旷人稀,地皮不甚值钱,后世则不然之故。又古代建筑,技术的关系浅,人人可以自为,士大夫之家,又可役民为之。后世则建筑日益专门,非雇人为之不可(《论衡·量知篇》:“能斫削柱梁,谓之木匠。能穿凿穴坎,谓之土匠。”则在汉代,民间建筑,亦已有专门的人)。这亦是造屋的人,要谋节省的一个原因。\n古人造楼的技术,似乎是很拙的。所以要求眺望之所,就只得于城阙之上。阙是门旁墙上的小屋。天子诸侯的宫门上,也是有的。因其可以登高眺远,所以亦谓之观。《礼记·礼运》:“昔者仲尼与于蜡宾,事毕,出游于观之上”,即指此。古所谓县法象魏者,亦即其地。魏与巍同字,大概因其建筑高,所以称之为魏。象字当本指法象言,与建筑无涉。因魏为县法之地,单音字变为复音词时,就称其地为象魏了。《尔雅·释宫》:“四方而高曰台。有木者谓之榭。陕而修曲曰楼。”(陕同狭)《注》云:“台,积土为之。”榭是在土台之上,再造四方的木屋。楼乃榭之别名,不过其形状有正方修曲之异而已,这都是供游观眺望之所,并不是可以住人的。《孟子·尽心下篇》:“孟子之滕,馆于上宫。”赵《注》说:“上宫,楼也。”这句话恐未必确。因为造楼之技甚拙,所以中国的建筑,是向平面发展,而不是向空中发展的。所谓大房屋,只是地盘大,屋子多,将许多屋连结而成,而两层、三层的高楼很少。这个和建筑所用的材料,亦有关系。因为中国的建筑,用石材很少,所用的全是土木,木的支持力固不大,土尤易于倾圮。炼熟的土,即砖瓦,要好些,然其发达似甚晚。《尔雅·释宫》:“瓴甋谓之甓。”“庙中路谓之唐。”甓即砖。《诗经·陈风》说“中唐有甓”,则砖仅用以铺路。其墙,大抵是用土造的。土墙不好看,所以富者要被以文锦。我们现在婚、丧、生日等事,以绸缎等物送人,谓之幛,还是这个遗俗;而纸糊墙壁,也是从此蜕化而来的。《晋书·赫连勃勃载记》说他蒸土以筑统万城,可见当时砖尚甚少。不然,何不用砖砌,而要临时蒸土呢?无怪古代的富者,造屋只能用土墙了。建筑材料,多用土木,和古代建筑的不能保存,也有关系。因为其不如石材的能持久。而用木材太多,又易于引起火患。前代的杭州,近代的汉口,即其殷鉴。\n建筑在中国,是算不得发达的。固然,研究起世界建筑史来,中国亦是其中的一系(东洋建筑,有三大系统:(一)中国,(二)印度,(三)回教,见伊东忠太《中国建筑史》,商务印书馆本)。历代著名的建筑,如秦之阿房宫,汉之建章宫,陈后主的临春、结绮、望春三阁,隋炀帝的西苑,宋徽宗的艮岳,清朝的圆明园、颐和园,以及私家的园林等,讲究的亦属不少。然以中国之大言之,究系沧海一粟。建筑的技术,详见宋朝的《营造法式》、明朝的《天工开物》等书。虽然亦有可观,然把别种文明比例起来,则亦无足称道。此其所以然:(一)因(甲)古代的造屋,乃系役民为之,滥用民力,是件暴虐的事。(乙)又古代最讲究礼,生活有一定的规范,苟非无道之君,即物力有余,亦不敢过于奢侈。所以政治上相传,以卑宫室为美谈,事土木为大戒。(二)崇闳壮丽的建筑,必与迷信为缘。中国人对于宗教的迷信,是不深的。祭神只是临时设坛或除地,根本便没有建筑。对于祖宗的祭祀,虽然看得隆重,然庙寝之制,大略相同。后世立家庙等,亦受古礼的限制,不能任意奢侈。佛教东来,是不受古礼限制的,而且其教义,很能诱致人,使其布施财物。道家因之,亦从事于模仿,寺观遂成为有名的建筑,印度的建筑术,亦因此而输入中国。南朝四百八十寺,多少楼台烟雨中,一时亦呈相当的盛况。然此等迷信,宋学兴起以后,又渐渐的淡了。现在佛寺、道观虽多,较之缅甸、日本等国,尚且不逮。十分崇闳壮丽的建筑,亦复很少,不过因其多在名山胜地,所以为人所赞赏罢了。(三)游乐之处,古代谓之苑囿。苑是只有草木的,囿是兼有禽兽的。均系将天然的地方,划出一区来,施以禁御,而于其中射猎以为娱,收其果实等以为利,根本没有什么建筑物。所以其大可至于方数十里(文王之囿,方七十里,齐宣王之囿,方四十里,见《孟子·梁惠王下篇》)。至于私家的园林,则其源起于园。园乃种果树之地,因于其间叠石穿池,造几间房屋,以资休憩,亦不是甚么奢侈的事。后来虽有踵事增华,刻意经营的人,究竟为数亦不多,而且其规模亦不大。以上均系中国建筑不甚发达的原因。揆厥由来,乃由于(一)政治的比较清明,(二)迷信的比较不深,(三)经济的比较平等。以物质文明言,固然较之别国,不免有愧色,以文化论,倒是足以自豪的。\n朱熹说:“教学者如扶醉人,扶得东来西又倒。”个人的为学如是,社会的文化亦然。奢侈之弊,中国虽比较好些,然又失之简陋了。《日知录·馆舍》条说:“读孙樵《书褒城驿壁》,乃知其有沼,有鱼,有舟。读杜子美《秦州杂诗》,又知其驿之有池,有林,有竹。今之驿舍,殆于隶人之垣矣。予见天下州之为唐旧治者,其城郭必皆宽广,街道必皆正直。廨舍之为唐旧创者,其基址必皆宏敞。宋以下所置,时弥近者制弥陋。”亭林的足迹,所至甚多,而且是极留心观察的人,其言当极可信。此等简陋苟且,是不能藉口于节俭的。其原因安在呢?亭林说:是由于“国家取州县之财,纤豪尽归之于上,而吏与民交困,遂无以为修举之资”。这固然是一个原因。我以为(一)役法渐废,公共的建筑,不能征工,而必须雇工。(二)唐以前古市政的规制犹存,宋以后逐渐破坏(如第四十七章所述,唐设市还有定地,开市还有定期,宋以后渐渐不然,亦其一证),亦是重要的原因。\n从西欧文明输入后,建筑之术,较之昔日,可谓大有进步了;所用的材料亦不同,这确是文明进步之赐。惟住居与衣食,关系民生,同样重要。处处须顾及大多数人的安适,而不容少数人恃其财力,任意横行,和别种事情,也是一样的。古代的居民,本来有一定的规划。《王制》所谓“司空执度以度地,居民山川沮泽(看地形),时四时(看气候)。”即其遗制。其大要,在于“地、邑、民居,必参相得”。地就是田。有多少田,要多少人种,就建筑多少人守卫所要的城邑,和居住所需的房屋。据此看来,现在大都市中的拥挤,就是一件无法度而不该放任的事情了。宫室的等级和限制,历代都是有的(可参看《明史·舆服志》所载宫室制度)。依等级而设限制,现在虽不容仿效,然限制还是该有的。对外的观瞻,也并不系于建筑的侈俭。若因外使来游,而拆毁贫民住居的房子,这种行为,就要成为隋炀帝第二了。\n讲宫室既毕,请再略讲室中的器用。室中的器用,最紧要的,就是桌椅床榻等。这亦是所以供人居处,与宫室同其功的。古人都席地而坐。其坐,略似今日的跪,不过腰不伸直。腰伸直便是跪,顿下便是坐。所以古礼跪而行之之时颇多。因为较直立反觉便利。其凭藉则用几,据阮谌《礼图》,长五尺,广一尺,高一尺二寸(《礼记·曾子问》《疏》引)。较现在的凳还低,寝则有床。所以《诗经》说:“乃生男子,载寝之床。”后来坐亦用床。所以《高士传》说:管宁居辽东,坐一木榻,五十余年,未尝箕股,其榻当膝处皆穿(《三国魏志》本传《注》引)。观此,知其坐亦是跪坐。现在的垂足而坐,是胡人之习,从西域输入的。所坐的床,亦谓之胡床。从胡床输入后,桌椅等物,就渐渐兴起了。古人室中,亦生火以取暖。《汉书·食货志》说:“冬民既入,妇人同巷相从夜绩。”“必相从者,所以省费燎火。”颜师古说:“燎所以为明,火所以为温也。”这种火,大约是煴火,是贫民之家的样子。《左传》昭公十年说,宋元公(为太子时),恶寺人柳,欲杀之。到元公的父亲死了,元公继位为君,柳伺候元公将到之处,先炽炭于位,将至则去之,到葬时,又有宠。又定公三年,说邾子自投于床,废于炉炭(《注》“废,堕也”),遂卒。则贵族室中取暖皆用炭,从没有用炕的。《日知录》说:“《旧唐书·东夷高丽传》:冬月皆作长坑,下然煴火以取暖,此即今之土炕也,但作坑字。”则此俗源于东北夷。大约随女真输入中国北方的,实不合于卫生。\n论居处及所用的器物既竟,还要略论历代的葬埋。古代的葬有两种:孟子所谓“其亲死,则举而委之于壑。”(《滕文公上篇》)盖田猎之民所行。《易经·系辞传》说:“古之葬者,厚衣之以薪,葬之中野”,则农耕之民之俗。一个贵族,有其公共的葬地。一个都邑,亦有其指定卜葬的区域。《周官》冢人掌公墓之地,墓大夫掌凡邦墓之地域是其制。后世的人说:古人重神不重形。其理由:是古不墓祭。然孟子说齐有东郭墦间之祭者(《离娄下篇》),即是墓祭。又说孔子死后,子贡“筑室于场,独居三年然后归”(《滕文公上篇》),此即后世之庐墓。《礼记·曲礼》:“大夫士去其国,止之曰:奈何去坟墓也?”《檀弓》:“去国则哭于墓而后行,反其国不哭,展墓而入。”又说:“大公封于营丘,比及五世,皆反葬于周。”则古人视坟墓,实不为不重。大概知识程度愈低,则愈相信虚无之事。愈高,则愈必耳闻目见,而后肯信。所以随着社会的开化,对于灵魂的迷信,日益动摇,对于体魄的重视,却日益加甚。《檀弓》说:“延陵季子适齐。比其反也,其长子死,葬于嬴博之间。”“既封,左袒,右还其封,且号者三,曰:骨肉归复于土,命也。若魂气,则无不之也,无不之也,而遂行。”这很足以表示重视精神,轻视体魄的见解,怕反是吴国开化较晚,才如此的。如此,富贵之家,有权力的,遂尽力于厚葬。厚葬之意,不徒爱护死者,又包含着一种夸耀生人的心思,而发掘坟墓之事,亦即随之而起。读《吕览·节丧》、《安死》两篇可知。当时墨家主张薄葬,儒家反对他,然儒家的葬礼,较之流俗,亦止可谓之薄葬了。学者的主张,到底不能挽回流俗的波靡。自汉以后,厚葬之事,还书不胜书。且将死者的葬埋,牵涉到生人的祸福,而有所谓风水的迷信。死者无终极(汉刘向《谏成帝起昌陵疏》语),人人要保存其棺槨,至于无穷,其势是决不能行的。佛教东来,火葬之俗,曾一时盛行(见《日知录·火葬》条),实在最为合理。惜乎宋以后,受理学的反对,又渐渐的式微了。现在有一部分地方,设立公墓。又有提倡深葬的。然公墓究仍不免占地,深葬费人力过多,似仍不如火葬之为得。不过风俗是守旧的,断非一时所能改变罢了。\n交通、通信,向来不加区别。其实二者是有区别的。交通是所以运输人身,通信则所以运输人的意思。自有通信的方法,而后人的意思,可以离其身体而独行,于精力和物力方面,有很大的节省。又自电报发明后,意思的传达,可以速于人身的运输,于时间方面,节省尤大。\n交通的发达,是要看地势的。水陆是其大别。水路之中,河川和海道不同。海道之中,沿海和远洋的航行,又有区别。即陆地,亦因其为山地、平地、沙漠等而有不同。野蛮时代,各部族之间,往往互相猜忌,不但不求交通的便利,反而有意阻塞交通,其时各部族所居之地,大概是颇险峻的。对外的通路,只有曲折崎岖的小路,异部族的人,很难于发现使用。《庄子·马蹄篇》说:古代“山无徯隧,泽无舟梁”。所指的,便是这时代。到人智稍进,能够降丘宅土,交通的情形,就渐和往昔不同了。\n中国的文化,是导源于东南,而发达于西北的。东南多水,所以水路的交通,南方较北方为发达。西北多陆,所以陆路的交通,很早就有可观。陆路交通的发达,主要的是由牛马的使用和车的发明。此二者,都是大可节省人力的。《易经·系辞传》说“服牛乘马,引重致远”,虽不能确定其在何时,然其文承黄帝、尧、舜垂衣裳而天下治之下,可想见黄帝、尧、舜时,车的使用,必已很为普遍了。车有两种:一种是大车,用牛牵曳的,用以运输。一种是小车,即兵车,人行亦乘之,驾以马。用人力推曳的谓之辇。《周官》乡师《注》引《司马法》,说夏时称为余车,共用二十人,殷时称胡奴车,用十人,周时称为辎辇,用十五人。这是供战时运输用的,所以其人甚多。《说文》:“辇,挽车也。从车。”\n训并行,虽不必定是两人,然其人数必不能甚多。这是民间运输用的。贵族在宫中,亦有时乘坐。《周官》巾车,王后的五路,有一种唤做辇车,即其物。此制在后世仍通行。\n道路:在都邑之中,颇为修整。《考工记》:匠人,国中经涂九轨。野涂亦九轨。环涂(环城之道)七轨。《礼记·王制》:“道路,男子由右,妇人由左,车从中央。”俱可见其宽广。古代的路,有一种是路面修得很平的,谓之驰道。非驰道则不能尽平。国中之道,应当都是驰道。野外则不然。古代田间之路,谓之阡陌,与沟洫相辅而行。所以《礼记·月令》《注》说:“古者沟上有路。”沟洫阡陌之制,照《周官》遂人等官所说,是占地颇多的。虽亦要因自然的地势,未必尽合乎准绳,然亦必较为平直。不过书上所说的,是理想中的制度,事实上未必尽能如此。《左传》成公五年,梁山崩,晋侯以传召伯宗。行辟重(使载重之车让路)。重人曰:“待我,不如捷之速也。”可见驿路上还不能并行两车。《仪礼·既夕礼》:“商祝执功布,以御柩执披。”《注》云:“道有低仰倾亏,则以布为左右抑扬之节,使引者执披者知之。”《礼记·曲礼》:“送葬不避涂潦”,可见其路之不尽平坦。后人夸称古代的道路,如何宽平,恐未必尽合于事实了。大抵古人修造路面的技术甚拙。其路面,皆如今日的路基,只是土路。所以时时要修治。不修治,就“道茀不可行”。\n水路:初有船的时候,只是现在所谓独木舟。《易经·系辞传》说“刳木为舟,剡木为楫”,《淮南子·说山训》说“古人见窾木而知舟”,所指的都是此物。稍进,乃知集板以为舟。《诗经》说:“就其深矣,方之舟之。”《疏》引《易经》云:“利涉大川,乘木舟虚。”又引《注》云:“舟谓集版,如今船,空大木为之曰虚,总名皆曰舟。”按方、旁、比、并等字,古代同音通用。名舟为方,正因其比木为之之故。此即后世的舫字。能聚集许多木板,以成一舟,其进步就容易了。渡水之法,大抵狭小的水,可以乘其浅落时架桥。桥亦以木为之。即《孟子》所说的“岁十一月徒杠成,十二月舆梁成”(《离娄下篇》)《尔雅·释宫》:“石杠谓之倚。”又说:“堤谓之梁。”《注》云:“即桥也。或曰:石绝水者为梁,见《诗传》。”则后来亦用石了。较阔的水,则接连了许多船渡过去。此即《尔雅》所说的“天子造舟”,后世谓之浮桥。亦有用船渡过去的,则《诗经》所说的“谁谓河广,一苇杭之”。然徒涉的仍不少。观《礼记·祭义》、谓孝子“道而不径,舟而不游”可见。航行的技术,南方是胜于北方的。观《左传》所载,北方只有僖公十三年,晋饥,乞粜于秦,秦输之粟,自雍及绛相继,命之曰泛舟之役,为自水路运输,此外泛舟之事极少。南方则吴楚屡有水战,而哀公十年,吴徐承且率舟师自海道伐齐。可见不但内河,就沿海交通,亦已经开始了。《禹贡》九州贡路,都有水道。《禹贡》当是战国时书,可以窥见当时交通的状况。\n从平地发展到山地,这是陆地交通的一个进步,可以骑马的发达为征。古书不甚见骑马之事。后人因谓古人不骑马,只用以驾车。《左传》昭公二十五年,“左师展将以公乘马而归。”《疏》引刘炫说,以为是骑马之渐。这是错误的。古书所以不甚见骑马,(一)因其所载多贵族之事,贵族多是乘车的。(二)则因其时的交通,仅及于平地。《日知录》说:“春秋之世,戎狄杂居中夏者,大抵在山谷之间,兵车之所不至。齐桓、晋文,仅攘而却之,不能深入其地者,用车故也。中行穆子之败狄于大卤,得之毁车崇卒。而智伯欲伐仇犹,遗之大钟以开其道,其不利于车可知矣。势不得不变而为骑。骑射,所以便山谷也。胡服,所以便骑射也。”此虽论兵事、交通的情形,亦可以借鉴而明。总而言之,交通所至之地愈广,而道路大抵失修,用车自不如乘马之便。骑乘的事,就日盛一日了。\n“水性使人通,山性使人塞。”水性是流动的,虽然能阻碍人,使其不得过去,你只要能利用它,它却可以帮你活动,节省你的劳力。山却不然,会多费你的抵抗力的。所以到后世,水路的交通,远较陆路交通为发达。长江流域的文明,本落黄河流域之后,后来却反超过其上,即由于此。唐朝的刘晏说:“天下诸津,舟航所聚,旁通巴汉,前指闽越,七泽十薮,三江五湖,控引河洛,兼包淮海,弘舸巨舰,千轴万艘,交贸往来,昧旦永日。”可以见其盛况了。《唐语林补遗》说:“凡东南都邑,无不通水。故天下货利,舟楫居多。舟船之盛,尽于江西。编蒲为帆,大者八十余幅。江湖语曰:水不载万。言大船不过八九千石。”明朝郑和航海的船,长四十四丈,宽十八丈,共有六十二只。可以见其规模的弘大了。\n因为水路交通利益之大,所以历代开凿的运河极多,长在一千里以下的运河,几乎数不着它。中国的大川,都是自西向东的,南北的水路交通,很觉得不便。大运河的开凿,多以弥补这缺憾为目的。《左传》哀公九年,“吴城邗,沟通江淮”。此即今日的淮南运河。《史记·河渠书》说:“荥阳下引河东南为鸿沟,以通宋、郑、陈、蔡、曹、卫,与济、汝、淮、泗会。”鸿沟的遗迹,虽不可悉考,然其性质,则极似现在的贾鲁河,乃是所以沟通河淮两流域的。至后汉明帝时:则有从荥阳通至千乘的汴渠。此因当时的富力,多在山东,所以急图东方运输的便利。南北朝以后,富力集中于江淮,则运输之路亦一变。隋开通济渠,自东都引谷洛二水入河。又自河入汴,自汴入淮,以接淮南的邗沟。自江以南,则自京口达余杭,开江南河八百里。此即今日的江南运河。唐朝江淮漕转;二月发扬州。四月,自淮入汴。六、七月到河口,八九月入洛。自此以往,因有三门之险,乃陆运以入于渭。宋朝建都汴京,有东西南北四河。东河通江淮(亦称里河),西河通怀、孟。南河通颍、寿(亦称外河。现在的惠民河,是其遗迹),北河通曹、濮。四河之中,东河之利最大。淮南、浙东西、荆湖南北之货,都自此入汴京。岭表的金银香药,亦陆运至虔州入江。陕西的货,有从西河入汴的,亦有出剑门,和四川的货,同至江陵入江的。宋史说东河所通,三分天下有其二,虽是靠江淮等自然的动脉,运河连接之功,亦不可没的。元朝建都北平,交通之目的又异。乃引汶水分流南北,而现在的大运河告成。\n海路的交通,已略见第四十七章。唐咸通时,用兵交阯,湖南、江西,运输甚苦,润州人陈磻石创议海运。从扬子江经闽、广到交阯。大船一艘,可运千石。军需赖以无缺。是为国家由海道运粮之始。元、明、清三代,虽有运河,仍与海运并行。海运所费,且较河运为省。近代轮船未行以前,南北海道的运输,亦是很盛的。就到现在,南如宁波,北如营口,帆船来往的仍甚多。\n水道的交通,虽极发达,陆路的交通,却是颇为腐败的。《日知录》说当时的情形:“涂潦遍于郊关,污秽钟于辇毂。”(《街道》条)又说:“古者列树以表道。”“下至隋唐之代,而官槐官柳,亦多见之诗篇。”“近代政废法弛,任人斫伐。周道如砥,若彼濯濯”(《官树》条)。“《唐六典》:凡天下造舟之梁四,石柱之梁四,木柱之梁三,巨梁十有一,皆国工修之。其余皆所管州县,随时营葺。其大津无梁,皆给船人,量其大小难易,以定其差等。今畿甸荒芜,桥梁废坏。雄莫之间,秋水时至,年年陷境。曳轮招舟,无赖之徒,藉以为利。潞河舟子,勒索客钱,至烦章劾。司空不修,长吏不问,亦已久矣。(《原注》:“成化八年,九月,丙申,顺天府府尹李裕言:本府津渡之处,每岁水涨,及天气寒冱,官司修造渡船,以便往来。近为无赖之徒,冒贵戚名色,私造渡船,勒取往来人财物,深为民害。乞敕巡按御史,严为禁止。从之。”)况于边陲之境,能望如赵充国治湟陿以西道桥七十所,令可至鲜水,从枕席上过师哉?”(《桥梁》条)观此,知路政之不修,亦以宋以后为甚。其原因,实与建筑之颓败相同。前清末年,才把北京道路,加以修理。前此是与顾氏所谓“涂潦遍于郊关,污秽钟于辇毂”,如出一辙的。全国除新开的商埠外,街道比较整齐宽阔的,没有几处。南方多走水道,北方旱路较多,亦无不崎岖倾仄。间有石路,亦多年久失修。路政之坏,无怪全国富庶之区,都偏于沿江沿海了。\n因路政之坏,交通乃不能利用动物之力而多用人力。《史记·夏本纪》:“山行乘车”,《河渠书》作“山行即桥”。按禹乘四载,又见《吕览·慎势》,《淮南·齐俗训》、《修务训》,《汉书·沟洫志》。又《史记集解》引《尸子》及徐广说,所作字皆互异。山行车与桥外,又作梮,作蔂,作樏,作欙,蔂、樏、欙,系一字,显而易见。梮字见《玉篇》,云“舆,食器也。又土轝也。”雷浚《说文外编》云:“土轝之字,《左传》作梮(按见襄公九年)。《汉书·五行志》引作车,《说文》:“车,大车驾马也。”按《孟子》:“反蘽梩而掩之。”《赵注》云:“梩,笼臿之属,可以取土者也。”蔂、樏、欙并即虆梩,与梮并为取土之器,驾马则称为车,亦以音借而作桥。后又为之专造轿字,则即淮南王《谏伐闽越书》所谓“舆轿而逾岭”。其物本亦车属,后因用诸山行,乃以人舁之。所以韦昭说:“梮木器,如今舆状,人举以行。”此物在古代只用诸山行,后乃渐用之平地。王安石终身不乘肩舆,可见北宋时用者尚少,南渡以后,临安街道,日益狭窄,乘坐的人,就渐渐的多了(《明史·舆服志》:宋中兴以后,以征伐道路险阻,诏百官乘轿,名曰竹轿子,亦曰竹舆)。\n行旅之人不论在路途上,以及到达地头之后,均须有歇宿之所。古代交通未盛,其事率由官营。《周官》野庐氏,“比国郊及野之道路,宿息,井树。”遗人,“凡国野之道:十里有庐,庐有饮食。三十里有宿,宿有路室,路室有委。五十里有市,市有候馆,候馆有积。”都是所以供给行旅的。到达之后,则“卿馆于大夫,大夫馆于士,士馆于工商”(《仪礼·觐礼》)。此即《礼记·曾子问》所谓“卿大夫之家曰私馆”。另有“公宫与公所为”,谓之公馆。当时的农民,大概无甚往来,所以只有卿士大夫和工商之家,从事于招待,但到后来,农民出外的也多了。新旅客的增加,必非旧式的招待所能普遍应付,就有借此以图利的,是为逆旅。《商君书·垦令篇》说:“废逆旅,则奸伪躁心私交疑农之民不行。逆旅之民,无所于食,则必农。”这只是陈旧的见解。《晋书·潘岳传》说,当时的人,以逆旅逐末废农,奸淫亡命之人,多所依凑。要把它废掉。十里置一官,使老弱贫户守之。差吏掌主,依客舍之例收钱。以逆旅为逐末废农,就是商君的见解。《左传》僖公二年,晋人假道于虞以伐虢,说“虢为不道,保于逆旅,以侵敝邑之南鄙”。可见晋初的人,说逆旅使奸淫亡命,多所依凑,也是有的。但以大体言之,则逆旅之设,实所以供商贾之用,乃是随商业之盛而兴起的。看潘岳的驳议,便可明白。无法废绝商业,就无法废除逆旅。若要改为官办,畀差主之吏以管理之权,一定要弊余于利的。潘岳之言,亦极有理。总而言之:(一)交通既已兴盛,必然无法遏绝,且亦不宜遏绝。(二)官吏经营事业,其秩序必尚不如私人。两句话,就足以说明逆旅兴起的原因了。汉代的亭,还是行人歇宿之所。甚至有因一时没有住屋,而借居其中的(见《汉书·息夫躬传》)。魏晋以后,私人所营的逆旅,日益兴盛,此等官家的事业,就愈益废坠,而浸至于灭绝了。\n接力赛跑,一定较诸独走长途,所至者要远些,此为邮驿设置的原理。《说文》:“邮,境上行书舍也。”是专以通信为职的。驿则所以供人物之往来。二者设置均甚早。人物的往来,并不真要靠驿站。官家的通信,却非有驿站不可。在邮政电报开办以前,官家公文的传递,实利赖之。其设置遍于全国。元代疆域广大,藩封之地,亦均设立,以与大汗直辖之地相连接。规模不可谓不大。惜乎历代邮驿之用,都止于投递公文,未能推广之以成今日的邮政。民间寄书,除遣专使外,就须展转托人,极为不便。到清代,人民乃有自营的信局。其事起于宁波,逐渐推广,几于遍及全国。而且推及南洋。其经营的能力,亦不可谓之不伟大了。\n铁路、轮船、摩托车、有线、无线电报的发明,诚足使交通、通信焕然改观。这个,诚然是文明进步之赐,然亦看用之如何。此等文明利器,能用以开发世界上尚未开发的地方,诚足为人类造福。若只在现社会机构之下,为私人所有,用以为其图利的手段,则其为祸为福,诚未易断言,现代的物质文明,有人歌诵他,有人咒诅它。其实物质文明的本身,是不会构祸的,就看我们用之是否得当。中国现在的开发西南、西北,在历史上,将会成为一大事。交通起于陆地,进及河川、沿海,再进及于大洋,回过来再到陆地,这是世界开发必然的程序。世界上最大最未开发的地方,就是亚洲的中央高原,其中又分为两区:(一)为蒙古、新疆等沙漠地带,(二)为西康、青海、西藏等高原。中国现在,开发西南、西北,就是触着这两大块未开辟之地。我们现在还不觉得,将来,这两件事的成功,会使世界焕然改观,成为另一个局面。\n第五十一章 教育 # 现在所谓教育,其意义,颇近乎从前所谓习。习是人处在环境中,于不知不觉之间,受其影响,不得不与之俱化的。所谓入芝兰之室,久而不闻其香;居鲍鱼之肆,久而不知其臭。所以古人教学者,必须慎其所习。孟母教子,要三次迁居,古训多重亲师取友,均系此意。因此,现代所谓教育,要替学者另行布置出一个环境来。此自非古人所及。古人所谓教,只是效法的意思。教人以当循之道谓之攴学;受教于人而效法之,则谓之学,略与现在狭义的教育相当。人的应付环境,不是靠生来的本能,而是靠相传的文化。所以必须将前人之所知所能,传给后人。其机关,一为人类所附属的团体,即社团或家庭,一为社会中专司保存智识的部分,即教会。\n读史的人,多说欧洲的教育学术,和宗教的关系深,中国的教育学术和宗教的关系浅。这话诚然不错。但只是后世如此。在古代,中国的教育学术和宗教的关系,也未尝不密切。这是因为司高等教育的,必为社会上保存智识的一部分,此一部分智识,即所谓学术,而古代的学术,总是和宗教有密切关系的缘故。古代的太学,名为辟雍,与明堂即系同物(见第四十三、第五十两章)。所以所谓太学,即系王宫的一部分。蔡邕《明堂论》引《易传》说:“太子旦入东学,昼入南学,暮入西学。在中央曰太学,天子之所自学也。”(脱北学一句)又引《礼记·保傅篇》说:“帝入东学,上亲而贵仁。入西学,上贤而贵德。入南学,上齿而贵信。入北学,上贵而尊爵。入太学,承师而问道。”所指的,都是此种王宫中的太学。后来文化进步,一切机关,都从王宫中分析出来,于是明堂之外,别有所谓太学。此即《礼记·王制》所说的“太学在郊”。《王制》又说“小学在公宫南之左”。按小学亦是从王宫中分化出来的。古代门旁边的屋子唤做塾。《礼记·学记》说:“古之教者家有塾。”可见贵族之家,子弟是居于门侧的。《周官》教国子的有师氏、保氏。师氏居虎门之左,保氏守王闱。蔡邕说南门称门,西门称闱。汉武帝时,公玉带上《明堂图》,水环宫垣,上有楼,从西南入(见第五十章)。可见古代的明堂,只西南两面有门,子弟即居于此(子弟居于门侧,似由最初使壮者任守卫之故)。后来师氏、保氏之居门闱,小学之在公宫南之左,地位方向,还是从古相沿下来的。师氏所教的为三德(一曰至德,以为道本。二曰敏德,以为行本。三曰孝德,以知逆恶。按至德,大概是古代宗教哲学上的训条,孝德是社会政治上的伦理训条)、三行(一曰孝行,以亲父母。二曰友行,以尊贤良。三曰顺行,以事师长),保氏所教的为六艺(一曰五礼,二曰六乐,三曰五射,四曰五御,五曰六书,六曰九数)、六仪(一曰祭祀之容,二曰宾客之容,三曰朝廷之容,四曰丧纪之容,五曰军旅之容,六曰车马之容),这是古代贵族所受的小学教育。至于太学,则《王制》说“春秋教以礼乐,冬夏教以诗书”。此所谓礼乐,自与保氏所教六艺中的礼乐不同,当是宗教中高等的仪式所用。诗即乐的歌辞。书当系教中的古典。古代本没有明确的历史,相沿的传说,都是和宗教夹杂的,印度即系如此。然则此等学校中,除迷信之外,究竟还有什么东西没有呢?有的:(一)为与宗教相混合的哲学。先秦诸子的哲学、见解,大概都自此而出,看第五十三章可明。(二)为涵养德性之地。梁启超是不信宗教的。当他到美洲去时,每逢星期日,却必须到教堂里去坐坐。意思并不是信他的教,而是看他们礼拜的秩序,听其音乐,以安定精神。这就是子夏说“学而优则仕,仕而优则学”之理(《论语·子张篇》)。仕与事相通,仕就是办事。办事有余力,就到学校中去涵养德性,一面涵养德性,一面仍应努力于当办之事,正是德育、智育并行不悖之理。管太学的官,据《王制》是大乐正,据《周官》是大司乐。俞正燮《癸巳类稿》有《君子小人学道是歌义》,说古代乐之外无所谓学,尤可见古代太学的性质。古代乡论秀士,升诸司徒,司徒升之于学,学中的大乐正,再升诸司马,然后授之以官。又诸侯贡士,天子试之于射宫。其容体比于礼,其节比于乐,而中多者,则得与于祭(均见第四十三章)。这两事的根源,是同一的。即人之用舍,皆决之于宗教之府。“出征执有罪,反释奠于学”(《礼记·王制》),这是最不可解的。为什么明明是用武之事,会牵涉到学校里来呢?可见学校的性质,决不是单纯的教育机关了。然则古代所以尊师重道,太学之礼,虽诏于天子,无北面(《礼记·学记》)。养老之礼,天子要袒而割牲,执酱而馈,执爵而酳(《礼记·乐记》)。亦非徒以其为道之所在,齿德俱尊,而因其人本为教中尊宿之故。凡此,均可见古代的太学和宗教关系的密切。贵族的小学教育,出于家庭,平民的小学教育,则仍操诸社团之手。《孟子》说:“夏曰校,殷曰序,周曰庠,学则三代共之。”学指太学言,校、序、庠都是民间的小学。第四十一章所述:平民住居之地,在其中间立一个校室,十月里农功完了,公推有年纪的人,在这里教育未成年的人,就是校的制度。所以《孟子》说“校者教也”。又说“序者射也,庠者养也”,这是行乡射和乡饮酒礼之地。孔子说:“君子无所争,必也射乎?揖让而升,下而饮,其争也君子。”(《论语·八佾篇》)。又说:一看乡饮酒礼,便知道明贵贱,辨隆杀,和乐而不流,弟长而无遗,安燕而不乱等道理。所以说:“吾观于乡,而知王道之易易也。”(《礼记·乡饮酒义》)然则庠序都是行礼之地,使人民看了,受其感化的。正和现在开一个运动会,使人看了,知道武勇、刚毅、仁侠、秩序等的精神,是一样的用意。行礼必作乐,古人称礼乐可以化民,其道即由于此。并非是后世的王礼,天子和百官,行之于庙堂之上,而百姓不闻不见的。汉朝人所谓庠序,还系如此。与现在所谓学校,偏重智识传授的,大不相同。古代平民的教育,是偏重于道德的。所以兴学必在生计问题既解决之后。孟子说庠序之制,必与制民之产并言(见《梁惠王·滕文公上篇》)。《王制》亦说:“食节事时,民咸安其居,乐事劝功,尊君亲上,然后兴学。”生计问题既解决之后,教化问题,却系属必要。所以又说:“饱食暖衣,逸居而无教,则近于禽兽。”(《孟子·滕文公上篇》)。又说:“君子如欲化民成俗,其必由学乎?”(《学记》)\n以上是古代社会,把其传统的所谓做人的道理,传给后辈的途径(贵族有贵族立身的方法,平民有平民立身的方法,其方法虽不同,其为立身之道则一)。至于实际的智识技能,则得之必由于实习。实习即在办理其事的机关里,古称为宦。《礼记·曲礼》说“宦学事师”,《疏》引熊氏云:“宦谓学仕官之事。”官就是机关,仕官,就是在机关里办事。学仕官之事,就是学习在机关里所办的事。这种学习,是即在该机关中行之的,和现在各机关里的实习生一般。《史记·秦始皇本纪》:昌平君发卒攻嫪毐,战咸阳,斩首数百,皆拜爵。及宦者皆在战中,亦拜爵一级。《吕不韦列传》:请客求宦为嫪毐舍人千余人。《汉书·惠帝纪》:即位后,爵五大夫,吏六百石以上,及宦皇帝而知名者,有罪当盗械者,皆颂系。此所谓宦,即系学仕于其家。因为古代卿大夫及皇太子之家,都系一个机关。嫪毐之家,食客求宦者至千余人,自然未必有正经的事情可办,亦未必有正经的事情可以学习。正式的机关则不然。九流之学,必出于王官者以此(参看第五十三章)。子路说:“有民人焉,有社稷焉,何必读书,然后为学?”(《论语·先进篇》)。就是主张人只要在机关里实习,不必再到教会所设的学校里,或者私家教授,而其宗旨与教会教育相同的地方去学习(《史记·孔子世家》说,孔子以诗、书、礼、乐教,可见孔子的教育,与古代学校中传统的教育相近)。并不是说不要学习,就可以办事。\n古代的平民教育,有其优点,亦有其劣点。优点是切于人的生活。劣点则但把传统的见解,传授给后生,而不授以较高的智识。如此,平民就只好照着传统的道理做人,而无从再研究其是非了。太学中的宗教哲学,虽然高深,却又去实际太远。所以必须到东周之世,各机关中的才智之士,将其(一)经验所得的智识,及(二)大学中相传的宗教哲学,合而为一,而学术才能开一新纪元。此时的学术,既非传统的见解所能限,亦非复学校及机关所能容,乃一变而为私家之学。求学问的,亦只得拜私人为师。于是教育之权,亦由官家移于私家,乃有先秦诸子聚徒讲学之事。\n社会上新旧两事物冲突,新的大概都是合理的。因为必旧的摇动了,然后新的会发生,而旧的所以要摇动,即由于其不合理。但此理是不易为昔人所承认的,于是有秦始皇和李斯的办法:“士则学习法令辟禁。”“欲学法令,以吏为师。”这是想恢复到政教合一之旧。所以要恢复政教合一,则因他们认为“人善其所私学,以非上之所建立”,是天下所以不治;而当时的人,所以要善私学以非上所建立,全是出于朋党之私,所谓“饰虚言以乱实”(《史记·秦始皇本纪》三十四年),这固然不无相当的理由。然古代社会,矛盾不深刻,政治所代表的,就是社会的公意,自然没有人出来说什么话。后世社会复杂了,各方面的矛盾,渐渐深刻,政治总只代表得一方面,其(一)反对方面,以及(二)虽非站在反对方面,而意在顾全公益的人,总不免有话说。这正是(一)有心求治者所乐闻,(二)即以手段而论,防民之口,甚于防川,亦是秉政者所应希望其宣泄的。而始皇、李斯不知“天下有道,则庶人不议”(《论语·季氏篇》)。误以为庶人不议,则天下有道;至少庶人不议,天下才可以走上有道的路,这就和时势相反了。人的智识,总不免于落后,这也无怪其然。但社会学的公例,是不因人之不知,而加以宽恕的,该失败的总是要失败,而秦遂因之倾覆(秦朝的灭亡,固非儒生所为,然人心之不平,实为其最大原因之一,而儒生亦是其中的一部分)。\n汉朝的设立学校,事在武帝建元五年。此时并未立学校之名,仅为五经博士置弟子。在内由太常择补。在外由县、道、邑的长官,上所属二千石,二千石察其可者,令与所遣上计之吏,同诣京师。这就是公孙弘所说的“因旧官而兴焉”(不另设新机关),但因博士弟子,都有出身,所以传业者浸盛(以上见《史记》、《汉书·儒林传》)。至后汉,则光武帝下车即营建太学。明、章二代,屡次驾幸。顺帝又增修校舍。至其末年,游学诸生,遂至三万余人,为至今未曾再有的盛况。按赵翼《陔余丛考》有一条,说两汉受学者都诣京师,其实亦不尽然。后汉所立,不过十四博士,而《汉书·儒林传》说:“大师众至千余人。”《汉书·儒林传》,不能证明其有后人增窜之迹,则此语至少当在东汉初年。可见民间传业,亦并非不盛。然汉代国家所设立的太学,较后世为盛;事实上比较的是学问的重心;则是不诬的。此因(一)当时社会,学问不如后世的广布,求学的自有走集学问中心地的必要。(二)则利禄使然,参看第四十三章自明。前汉时,博士弟子,虽有出路,究系平流而进。后汉则党人劫持选举,而太学为私党聚集,声气标榜之地。又此时学术在社会上渐占重要地位。功臣、外戚及官吏等,亦多遣子弟入学。于是纨袴子弟,搀杂其中,不能认真研究,而易与政治接近。就成《后汉书·儒林传》所说的:“章句渐疏,多以浮华相尚”了。汉末丧乱,既不能研究学问,而以朋党劫持选举的作用亦渐失。魏文帝所立的太学,遂成学生专为避役而来,博士并无学问可以教授的现状。详见《三国·魏志·王肃传》《注》引《魏略》。\n魏晋以后,学校仅为粉饰升平之具。所谓粉饰升平,并不是学校能积极的替政治上装饰出什么东西来,而是消极的,因为倘使连学校都没有,未免说不过去。所以苟非丧乱之时,总必有所谓学校。至其制度,则历代又略有不同。晋武帝咸宁二年,始立国子学。按今文经说,只有太学。大司乐合国之子弟,是出于《周官》的,是古文经说。两汉的政治制度,大抵是根据今文学说的。东汉之世,古学渐兴,魏晋以后,今文传授的统绪遂绝,所以此时的政治制度,亦渐采用古文学说了。自此以后,元魏国子、太学并置。周只有太学。齐只有国子学。隋时,始令国子学不隶太常,独立为一监。唐有国子学、太学、四门学、律学、书学、算学,都属国子监。后律学改隶详刑,书学改隶兰台,算学改隶秘阁。律学、书学、算学专研一种学问艺术,系专门学校性质。国子学、太学、四门学,则系普通性质。国子学、太学,都只收官吏子弟,只有四门学收一部分庶人,成为阶级性质了。这都是古文学说的流毒(四门学在历史上,有两种性质:有时以为小学。此时则模仿《礼记·王制》之说:王太子、王子、群后的太子、卿大夫元士的適子,都可以直接入学,庶人则须节级而升,因令其先入四门小学。然古代所谓学校,本非研究学问之地。乡论秀士,升诸司徒,司徒升之于学,大乐正再升诸司马,不过是选举的一途。贵族世袭之世,得此已算开明。后世则用人本无等级,学校为研究学问之地,庶人的学问,未必劣于贵族,而令其节级而升,未免不合于理。将庶人及皇亲、国戚、官吏子弟所入的学校分离,那更是造出等级来了)。又有弘文馆属门下省,是专收皇亲的。崇文馆属东宫,是收皇太后、皇后亲属兼及官吏子孙的。总之,学校只是政治上的一个机关,学生只是选举上的一条出路,和学术无甚关系(学校中未必真研究学术,要研究学术,亦不一定要入学)。\n把学校看作提倡学术,或兴起教化之具,其设立,是不能限于京师的。汉武帝时,虽兴起太学,尚未能注意地方。其时只有贤长官如文翁等,在其所治之地,自行提倡(见《汉书·循吏传》)。到元帝令郡国皆设五经百石卒史,才可算中央政府,以命令设立地方学校的权舆。但汉朝人眼光中,所谓庠序,还不是用以提倡学术,而是用以兴起教化的。所以元帝所为,在当时的人看起来,只能算是提倡经学,并不能算是设立地方学校。这个,只要看《汉书·礼乐志》的议论,便可知道。隋唐时,各州县都有学(隋文帝曾尽裁太学、四门学及州、县学,仅留国子生七十人。炀帝时恢复),然只法令如此。在唐时,大概只有一笔释奠之费,以祭孔子。事见《唐书·刘禹锡传》。按明清之世,亦正是如此。所谓府、州、县学,寻常人是不知其为学校,只知其为孔子庙的。所以有人疑惑:“为什么佛寺、道观,都大开了门,任人进去,独有孔子庙却门禁森严?”当变法维新之初,有人想把孔子抬出来,算做中国的教主,以和基督教相抗,还有主张把文庙开放,和教堂一样的。殊不知中国本无所谓孔子庙。孔子乃是学校里所祭的先圣或先师(《礼记·文王世子》:“凡入学,必释奠于先圣、先师。”先圣是发明家。先师是把发明家的学问,流传下来的人。此项风气,在中国流行颇广。凡百事业,都有其所崇奉的人,如药业崇奉神农,木匠崇奉鲁班,都是把他认作先圣,儒家是传孔子之道的,所以把孔子认作先圣,传经的人,认作先师。古文学说既行,认为孔子所传的,只是古圣王之道,尤其直接模范的是周公。周朝集古代治法的大成,而其治法的制定,皆由于周公。所以周公可以看作发明家的代表。于是以周公为先圣,孔子为先师。然孔子为中国所最尊的人,仅仅乎把他看做传述者,不足以餍足宗教心理。于是仍改奉孔子为先圣。自宋学兴起以后,所谓孔子之道者又一变。认为汉唐传经儒生,都不足以代表孔子之学。宋代诸儒,崛起于千载之后,乃能遥接其道统。于是将宋以后的理学家,认为先师。此即所谓从祀。汉至唐传经诸儒,除品行恶劣者外,亦不废黜。是为历代所谓先圣先师者的变迁)。寺庙可以公开,学校是办不到的。现在的学校,从前的书院、义塾,又何尝能大开其门,任人出入呢?然令流俗之人,有此误会,亦可见学校的有名无实了。\n魏晋以后,看重学校的有两个人:一个是王安石,一个是明太祖。王安石的意思,是人才要由国家养成的。科举只是取人才,不是养人才,不能以此为已足。照安石的意思,改革科举,只是暂时的事,论根本,是要归结到学校养士的。所以于太学立三舍之法。即外舍、内舍、上舍,学生依次而升。到升入上舍,则得免发解及礼部试,而特赐之以进士第。哲宗元符二年,令诸州行三舍法。岁贡其上舍生,附于外舍。徽宗遂特建外学,以受诸州贡士。并令太学内的外舍生,亦出居外学。遂令取士悉由学校升贡,其州郡发解及礼部试并停。后虽旋复,然在这一时期中的立法,亦可谓很重视学校了。按(一)凡事都由国家主持,只有国小而社会情形简单的时代,可以办到。国大而社会复杂,根本是不可能的,因为(甲)国家不但不能胜此繁重的职务,(乙)并不能尽知社会的需要。因(甲)则其所办之事,往往有名无实,甚或至于有弊。因(乙)则其所办之事,多不能与社会趋势相应,甚或顽固守旧,阻碍进步。所以积极的事情,根本是不宜于国家办的。现在政治学上,虽亦有此项主张,然其理论漏洞甚多,至多只可用以应急,施诸特殊的事务,断非可以遍行常行的道理。这话太长了,现在不能详论。然可用以批评宋时的学校,总是无疑的。所以当时的学校,根本不会办得好。(二)而况自亡清以前(学堂奖励章程废止以前),国家把学校、科举,都看作登庸官吏之法,入学者和应科举者一样,都是为利禄而来,又何以善其后呢?(其中固有少数才智之士。然亦如昔人论科举的话,“乃人才得科举,非科举得人才。”此等人在学校中,并不能视为学校所养成。)安石变科举法后,感慨道:“本欲变学究为秀才,不料变秀才为学究。”秀才是科举中最高的一科,学究则是最低的。熙宁贡举法所试,较诸旧法,不能不说是有用些。成绩之所以不良,则由学问的好坏,甚而至于可以说是有无,都判之于其真假,真就是有,假就是无。真假不是判之于其所研究的门类、材料,而是判之于其研究的态度、方法的。态度和方法,判之于其有无诚意。所以以利用为目的,以学习为手段,学到的,至多是技术,决不是学问。此其原理,在学校与科举中,并无二致。以得奖励为目的的学校,其结果,只能与科举一样。\n凡国家办的事,往往只能以社会上已通行的,即大众所公认的理论为根据。而这种理论,往往是已经过时的,至少是比较陈旧的。因为不如此,不会为大众所承认。其较新鲜的,方兴的,则其事必在逐渐萌芽,理论必未甚完全,事实亦不会有什么轰轰烈烈的,提给大众看,国家当然无从依据之以办事,所以政治所办理的事情,往往较社会上自然发生的事情为落后。教育事业,亦是如此。学问是不宜于孤独研究的。因为(一)在物质方面,供给不易完全;(二)在精神方面,亦不免孤陋寡闻之诮。所以研究学问的人,自然会结成一种团体。这个团体,就是学校。学校的起源,本是纯洁的,专为研究学问的;惜乎后来变为国家养成人才之所。国家养成人才,原是很好的事;但因(一)事实上,国家所代表的,总是业经通行、已占势力的理论。所以公家所立的学校,其内容,总要比较陈旧些。社会上新兴的,即在前途有真正需要,而并非在过去占有势力的学科,往往不能尽力提倡。(二)而且其本身,总不免因利禄关系而腐化。于是民间有一种研究学问的组织兴起来,这便是所谓书院。书院是起于唐、五代之间的。宋初,有所谓四大书院者,朝廷咸赐之额(曰白鹿,在庐山白鹿洞,为南唐升元中所建。曰石鼓,唐元和中衡州守李宽所建。曰应天,宋真宗时,府民曹诚所建。曰岳麓,宋开宝中,潭州守朱洞所建。此系据《通考》。《玉海》有嵩阳而无石鼓。嵩阳,在登封县大室山下,五代时所建)。此外赐额、赐田、赐书的还很多。但书院并不靠朝廷的奖励和补助。书院之设,大概由(一)有道德学问者所提倡,(二)或为好学者的集合,(三)或则有力者所兴办。他是无所为而为之的,所以能够真正研究学问,而且真能跟着风气走。在理学盛行时代,则为讲学的中心;在考据之学盛行的时代,亦有许多从事于此的书院,即其确证。新旧两势力,最好是能互相调和。以官办的学校,代表较旧的、传统的学术;以私立的学校,代表较新的、方兴的学术,实在是最好的办法。\n●岳麓书院\n●白鹿洞书院\n宋朝国势虽弱,然在文化上,不能说是没有进步的。文化既进步,自然觉得有多设学校的必要。元朝的立法,就受这风气的影响。元朝的国子监,本是蒙古、色目和汉人,都可以进的(蒙古人试法从宽,授官六品;色目人试法稍密,授官七品;汉人试法最密,授官从七品,则系阶级制度)。然在京师,又有蒙古国子学。诸路有蒙古字学。仁宗延祐元年,又立回回国子学,以肄习其文字。诸路、府、州、县皆有学。世祖至元二十八年,又令江南诸路学及各县学内设小学,选老成之士教之。或自愿招师,或自受家学于父兄者,亦从其便。其他先儒过化之地,名贤经行之所,与好事之家,出钱米赡学者,并立为书院。各省设提举二员,以提举学校之事。官家竭力提倡,而仍承认私家教育的重要,不可谓非较进步的立法。此项法令,能否真正实行,固未可知,然在立法上,总是明朝的前驱。\n明朝的学校,立法是很完密的。在昔时把学校看做培植人才(政治上的人才),登庸官吏的机关,而不视为提高文化,普及教育的工具,其立法不过如此而止,其扩充亦只能到这地步了。然而其法并不能实行,这可见法律的拗不过事实。明朝的太学,名为国子监。太祖看国子监,是极重的。所用的监官,都是名儒。规则极严,待诸生甚厚。又创历事之法,使在各机关中实习,曾于一日之间,擢用国子生六十余人为布、按两司官。其时国子诸生,扬历中外者甚众,可谓极看重学校的了。然一再传后,科举积重,学校积轻,举贡的选用,遂远不能与进士比。而自纳粟入监之例开后,且被视为异途。国子生本是从府、州、县学里来的。府、州、县学学生升入国子监的,谓之贡生。有岁贡(按年依定额升入)、选贡(选拔特优的)、恩贡(国家有庆典时,特许学生升入国学,即以当充岁贡者充之,而以其次一名充岁贡)、纳贡(府、州、县学生纳粟出学)之别。举人亦可入监。后又取副榜若干,令其入监读书。府、州、县学,府有教授,州有学正,县有教谕,其副概称训导。学生各有定额。初设的都由学校供给饮食,后来增广学额,则不能然。于是称初设的为廪膳生员,增广的为增广生员。后又推广其额,谓之附学生员。于是新取入学的,概称附学生员。依考试的成绩,递升为增广、廪膳。廪膳生资格深的,即充岁贡。入学和判定成绩的考试,并非由教谕训导举行,而是另行派员主持的。入学之试,初由巡按御史或布按两司及府、州、县官,后特置提督学政,巡历举行(僻远之地,为巡历所不能至者,或仍由巡按御史及分巡道)。学政任期三年。三年之中,考试所属府、州、县学生两次:一次称岁考,是用以判定成绩优劣的。一次称科考,在举行科场之年,择其优者,许应乡试。国子监生,毕业后可以入官的,府、州、县学生,则无所谓毕业。其出路:只有(一)应科举中式,(二)贡入国子监。如其不然,则始终只是一个学生。要到五十岁之后,方许其不应岁试。未满五十而不应岁试(试时亦可请假,但下届须补。清制,缺至三次者,即须斥革),其学籍,是要取消掉的。非府、州、县学生不能应科举,府、州、县学生除贡入太学外,亦非应科举不能得出路,这是实行宋以来学校科举相辅而行的理想的。在当时,确是较进步的立法。然而法律拗不过事实。事实上,国家所设的学校,一定要人来读书,除非(一)学校中真有学问,为在校外所学不到的。(二)法令严切,不真在校学习,即不能得到出路。但当时的学校,即使认真教授,其程度,亦不会超过民间的教育,而况并不教授?既然并不教授,自无从强迫学生在学。于是除国子监在京师首善之地,且沿明初认真办理之余,不能竟不到监,乃斤斤和监官计较“坐监”的日数外,府、州、县学,皆阒无其人,人家仍只目它为文庙。学校的有名无实,一方面,固表现政治的无力,一方面,也表示社会的进步。因为社会进步了,到处都有指导研究的人,供给研究的器,人家自然无庸到官立的学校里来了。我们现在,如其要读中国的旧书,并不一定要进学校。如其要研究新学问,有时非进学校不可,甚至有非到外国去不可的。就因为此种学术,在社会上还未广布。清朝的学制,是和明朝大同的。所不同的,则明朝国子监中的荫生,分为官生、恩生。官生是限以官品的(学生父兄的官品)。恩生则出自特恩,不拘品级。清制分为难荫及恩荫。恩荫即明代的官生。难荫谓父兄殉难的,其条件较恩荫为优。又清制,除恩副岁贡生外,又有优、拔两贡。优贡三岁一行。每一督学使者,岁科两试俱讫后,就教官所举优行生,加以考试,再择优送礼部考试,许其入国子监读书。拔贡十二年举行一次。合岁科两试优等生,钦命大臣,会同督抚复试。送吏部,再应廷试,一、二等录用,三等入监。但入监都是有名无实的。\n以上所述的,大体都是官办的学校,为政治制度的一部分,和选举制度有关。其非官办的,亦或具有学校的性质,如书院是。至于不具学校形式的。则有(一)私人的从师读书,(二)或延师于家教授。其教授的内容,亦分为两种:(一)是以应科举为目的的,可谓士人所受的教育。(二)又一种,但求粗知文义,为农、工、商家所受。前者既不足以语于学问,后者又不切于实用。这是因为从前对于教育,无人研究,不过模模糊糊,蹈常习故而行之而已。至清末,变法以来,才有所谓新式的教育,就是现行的制度。对于文化的关系,人所共知,不烦深论。学校初兴时,还有所谓奖励。大学毕业视进士,大学预科、高等学堂视举人。中等学校以下,分别视贡生及附生等。这还带有政治的性质。民国时代,把奖励章程废去,才全和科举绝缘。\n第五十二章 语文 # 语言文字的发明,是人类的一个大进步。(一)有语言,然后人类能有明晰的概念。(二)有语言,然后这一个人的意思,能够传达给那一个人。而(甲)不须人人自学,(乙)且可将个人的行为,化作团体的行为。单有语言,还嫌其空间太狭,时间太短,于是又有文字,赋语言以形,以扩充其作用。总之,文字语言,是在空间上和时间上,把人类联结为一的。人类是非团结不能进化的,团结的范围愈广,进化愈速,所以言语文字,实为文化进化中极重要的因素。\n以语言表示意思,以文字表示语言,这是在语言文字,发达到一定阶段之后看起来是如此。在语言文字萌芽之始,则并不是如此的。代表意思,多靠身势。其中最重要的是手势。中国文字中的“看”字,义为以手遮目,最能表示身势语的遗迹。与语言同表一种意象的,则有图画。图画简单化,即成象形文字。图画及初期的象形文字,都不是代表语言的。所以象形文字,最初未必有读音。图画更无论了。到后来,事物繁复,身势不够表示,语言乃被迫而增加。语言是可以增加的,(一)图画及象形文字,则不能为无限的增加,且其所能增加之数极为有限;(二)而凡意思皆用语言表示,业已成为习惯,于是又改用文字代表语言。文字既改为代表语言,自可用表示声音之法造成,而不必专于象形,文字就造的多了。\n中国文字的构造,旧有六书之说。即(一)象形,(二)指事,(三)会意,(四)形声,(五)转注,(六)假借。六者之中,第五种为文字增加的一例,第六种为文字减少的一例,只有前四种是造字之法。许慎《说文解字·序》说:“黄帝之史仓颉,见鸟兽蹄迒之迹,知分理之可相别异也,初造书契。”又说:“仓颉之初作书,盖依类象形,故谓之文。其后形声相益,即谓之字。”按许氏说仓颉造字,又说仓颉是黄帝之史,这话是错的。其余的话,则大概不错。字是用文拼成的,所以文在中国文字中,实具有字母的作用(旧说谓之偏旁)。象形、指事、会意、形声四种中,只有象形一种是文,余三种都是字。象形就是画成一种东西的形状,如此字须横看。,《说文》:“象臂胫之形。”按此所画系人的侧面,而又略去其头未画。,上系头,中系两臂,小孩不能自立,故下肢并而为一。,《说文》:“象人形。”按此系人的正面形,而亦略画其头。只有子字是连头画出的。按画人无不画其头之理,画人而不画其头,则已全失图画之意矣。于此,可悟象形文字和图画的区别)等字是。(一)天下的东西,不都有形可画。(二)有形可画的,其形亦往往相类。画得详细了,到足以表示其异点,就图画也不能如此其繁。于是不得不略之又略,至于仅足以略示其意而止。倘使不加说明,看了它的形状,是万不能知其所指的。即或可以猜测,亦必极其模糊。此为象形文字与图画的异点。象形文字所以能脱离图画而独立者以此。然如此,所造的字,决不能多。指事:旧说是指无形可象的事,如人类的动作等。这话是错的。指,就是指定其所在。事物二字,古代通用。指事,就是指示其物之所在。《说文》所举的例,是上下二字。卫恒《四体书势》说“在上为上,在下为下”,其语殊不可解。我们看《周官》保氏《疏》说“人在一上为上,人在一下为下”,才知道《四体书势》,实有脱文。《说文》中所载古文二字,乃系省略之形。其原形当如篆文作。一画的上下系人字,借人在一画之上,或一画之下,以表示上下的意思(这一画,并非一二的一字,只是一个界画。《说文》中此例甚多)。用此法,所造的字,亦不能多。会意的会训合。会意,就是合两个字的意思,以表示一个字的意思。如《说文》所举人言为信,止戈为武之类。此法所造的字,还是不能多的。只有形声字,原则上是用两个偏旁,一个表示意义,一个表示声音。凡是一句话,总自有其意义,亦自有其声音的。如此,造字的人,就不必多费心思,只要就本语的意义,本语的声音,各找一个偏旁来表示它就够了。造的人既容易,看的人也易于了解。而且其意义,反较象形、指事、会意为确实。所以有形声之法,而“文字之用,遂可以至于无穷”。转注:《说文》所举的例,是考老二字。声音相近,意义亦相近。其根源本是一句话,后来分化为两句的。语言的增加,循此例的很多。文字所以代表语言,自亦当跟着语言的分化而分化。这就是昔人的所谓转注(夥多两字,与考老同例)。假借则因语言之用,以声音为主。文字所以代表语言,亦当以声音为主。语文合一之世,文字不是靠眼睛看了明白的,还要读出声音来。耳朵听了(等于听语言),而明白其意义。如此,意义相异之语,只要声音相同,就可用相同的字形来代表它。于是(一)有些字,根本可以不造。(二)有些字,虽造了,仍废弃不用,而代以同音的字。此为文字之所以减少。若无此例,文字将繁至不可胜识了。六书之说,见于许《序》及《汉书·艺文志》(作象形、象事、象意、象声、转注、假借)、《周官》保氏《注》引郑司农之说(作象形、会意、转注、处事、假借、谐声)。昔人误以为造字之法,固属大谬。即以为保氏教国子之法,亦属不然。教学童以文字,只有使之识其形,明其音义,可以应用,断无涉及文字构造之理。以上所举六书之说,当系汉时研究文字学者之说。其说是至汉世才有的。《周官》保氏,教国子以六书,当与《汉书·艺文志》所说太史以六体试学童的六体是一,乃系字的六种写法,正和现在字的有行、草、篆、隶一样(《汉书·艺文志》说:“古者八岁入小学,故《周官》保氏,掌养国子,教之六书。谓象形、象事、象意、象声、转注、假借,造字之本也。汉兴,萧何草律,亦著其法,曰:太史论学童,能讽书九千字以上,乃得为史。又以六体试之。课最者以为尚书、御史、史书、令史。吏民上书,字或不正,辄举劾。六体者,古文、奇字、篆书、隶书、缪篆、虫书,皆所以通知古今文字,摹印章,书幡信也。”“谓象形、象事、象意、象声、转注、假借,造字之本也”十八字,定系后人窜入。惟保氏六书和太史六体是一,所以说亦著其法,若六书与六体是二,这亦字便不可通了)。以六书说中国文字的构造,其实是粗略的(读拙撰《字例略说》可明。商务印书馆本),然大体亦尚可应用。旧时学者的风气,本来是崇古的;一般人又误以六书为仓颉造字的六法。造字是昔时视为神圣事业的,更无人敢于置议。其说遂流传迄今。《荀子·解蔽篇》说:“故好书者众矣,而仓颉独传者,壹也。”可见仓颉只是一个会写字的人。然将长于某事的人,误认作创造其事的人,古人多有此误(如暴辛公善埙,苏成公善篪,《世本·作篇》即云:暴辛公作埙,苏成公作篪,谯周《古史考》已驳其缪。见《诗·何人斯》《疏》)。因此,生出仓颉造字之说。汉代纬书,皆认仓颉为古代的帝皇(见拙撰《中国文字变迁考》第二章,商务印书馆本)。又有一派,因《易经·系辞传》说“上古结绳而治,后世圣人易之以书契”,蒙上“黄帝、尧、舜,垂衣裳而天下治”,认为上古圣人,即是黄帝。司记事者为史官,因以仓颉为黄帝之史。其实二者都是无稽的。还有《尚书》伪孔安国《传序》,以三坟为三皇之书,五典为五帝之典,而以伏羲、神农、黄帝为三皇,就说文字起于伏羲时,那更是无据之谈了。\n文字有形、音、义三方面,都是有变迁的。形的变迁,又有改变其字的构造,和笔画形状之异两种,但除笔画形状之异一种外,其余都非寻常人所知(字之有古音古义,每为寻常人所不知。至于字形构造之变,则新形既行,旧形旋废,人并不知有此字)。所以世俗所谓文字变迁,大概是指笔画形状之异。其大别为篆书、隶书、真书、草书、行书五种。\n一、篆书是古代的文字,流传到秦汉之世的。其文字,大抵刻在简牍之上,所以谓之篆书(篆就是刻的意思)。又因其字体的不同,而分为(甲)古文,(乙)奇字,(丙)大篆,(丁)小篆四种。大篆,又称为籀文。《汉书·艺文志》,小学家有《史籀》十五篇。自注:“周宣王太史作。”《说文解字·序》:“《史籀》者,周时史官教学童书也。”又说:“《仓颉》七章者,秦丞相李斯所作也。《爰历》六章者,车府令赵高所作也。《博学》七章者,太史令胡毋敬所作也。文字多取《史籀篇》,而篆体复颇异,所谓秦篆者也。”然则大篆和小篆,大同小异。现在《说文》所录籀文二百二十余,该就是其相异的。其余则与小篆同。小篆是秦以后通行的字。大篆该是周以前通行的字。至于古文,则该是在大篆以前的。即自古流传的文字,不见于《史籀》十五篇中的。奇字即古文的一部分。所不同者,古文能说得出他字形构造之由,奇字则否。所谓古文,不过如此。《汉书·艺文志》、《景十三王传》、《楚元王传》载刘歆《移让太常博士书》,都说鲁恭王坏孔子宅,在壁中得到许多古文经传。其说本属可疑。因为(一)秦始皇焚书,事在三十四年。自此至秦亡,止有七年。即下距汉惠帝四年除挟书律,亦只有二十三年。孔壁藏书,规模颇大,度非一二人所为。不应其事遂无人知,而有待于鲁恭王从无意中发现。(二)假使果有此事,则在汉时实为一大事。何以仅见于《汉书》中这三处,而他书及《汉书》中这三处以外,绝无人提及其事(凡历史上较重大之事,总和别的事情有关系的,也总有人提及其事,所以其文很易散见于各处)。此三处:《鲁恭王传》,不将坏孔子宅之事,接叙于其好治宫室之下,而别为数语,缀于传末,其为作传时所无有(传成之后,再行加缀于末),显而易见。《移让太常博士》,本系刘歆所说的话。《艺文志》也是以刘歆所做的《七略》为本的。然则这两篇,根本上还是刘歆一个人的话。所以汉代得古文经一事,极为可疑。然自班固以前,还不过说是得古文经;古文经的本子、字句,有些和今文经不同而已,并没有说古文经的字,为当时的人所不识。到王充作《论衡》,其《正说篇》,才说鲁恭王得百篇《尚书》,武帝使使者取视,莫能读者。《尚书·伪孔安国传序》,则称孔壁中字为蝌蚪书。谓蝌蚪书废已久,时人无能知者。孔安国据伏生所传的《尚书》,考论文义(意谓先就伏生所传各篇,认识其字,然后再用此为根据,以读其余诸篇),才能多通得二十五篇。这纯是以意揣度的野言,古人并无此说。凡文字,总是大众合力,于无形中逐渐创造的,亦总是大众于无形之间,将其逐渐改变的。由一人制定文字,颁诸公众,令其照用,古无此事。亦不会两个时代中,有截然的异同,至于不能相识。\n二、篆书是圆笔,隶书是方笔。隶书的初起,因秦时“官狱多事”(《汉志》语。官指普通行政机关,狱指司法机关),“令隶人佐书”(《四体书势》语),故得此名。徒隶是不会写字的人,画在上面就算,所以笔画形状,因此变异了。然这种字写起来,比篆书简便得多,所以一经通行,遂不能废。初写隶书的人是徒隶,自然画在上面就算,不求美观。既经通行,写的人就不仅徒隶了。又渐求其美观。于是变成一种有挑法(亦谓之波磔)的隶书。当时的人,谓之八分书。带有美术性质的字,十之八九都用它。\n三、其实用的字,不求美观的,则仍无挑法,谓之章程书。就是我们现在所用的正书。所以八分书是隶书的新派,无挑法的系隶书的旧派。现在的正书,系承接旧派的,所以现在的正书,昔人皆称为隶书。王羲之,从来没有看见他写一个八分书,或者八分书以前的隶字,而《晋书》本传,却称其善隶书。\n四、正书,亦作真书,其名系对行草而立。草书的初起,其作用,当同于后来的行书,是供起草之用的。《史记·屈原列传》说:楚怀王使原造宪令,草藁未上,上官大夫见而欲夺之。所谓草藁,就是现在所谓起草。草藁是只求自己认得,不给别人看的,其字,自然可以写得将就些。这是大家都这样做的,本不能算创造一种字体,自更说不上是谁所创造。到后来,写的人,不求其疾速,而务求其美观。于是草书的字体,和真书相去渐远。导致只认得真书的人,不能认得草书。于是草书距实用亦渐远。然自张芝以前,总还是一个一个字分开的。到张芝出,乃“或以上字之下,为下字之上”,其字竟至不可认识了。后人称一个一个字分开的为章草,张芝所创的为狂草。\n五、狂草固不可用,即章草亦嫌其去正书稍远。(甲)学的人,几乎在正书之外,又要认识若干草字。(乙)偶然将草稿给人家看,不识草字的人,亦将无从看起(事务繁忙之后,给人家看的东西,未必一定能誊真的)。草书至此,乃全不适于实用。然起草之事,是决不能没有的。于是另有一种字,起而承其乏,此即所谓行书。行书之名,因“正书如立,行书如行”而起。其写法亦有两种:(子)写正书的人,把它写得潦草些,是为真行。(丑)写草书的人,把它写得凝重些,是为行草(见张怀瓘《书议》)。从实用上说,字是不能没有真草两种,而亦不能多于真草两种的。因为看要求其清楚,写要求其捷速;若多于真草两种,那又是浪费了(孟森说)。中国字现在书写之所以烦难,是由于都写真书。所以要都写正书,则由于草书无一定的体式。草书所以无一定的体式,则因字体的变迁,都因美术而起。美术是求其多变化的,所以字体愈写愈纷歧。这是因向来讲究写字的人,多数是有闲阶级;而但求应用的人,则根本无暇讲究写字之故。这亦是社会状况所规定。今后社会进化,使用文字的地方愈多。在实用上,断不能如昔日仅恃潦草的正书。所以制定草体,实为当务之急。有人说:草体离正书太远了,几乎又要认识一种字,不如用行书。这话,从认字方面论,固有相当的理由。但以书写而论,则行书较正书简便得没有多少。现在人所写潦草的正书,已与行书相去无几。若求书写的便利,至少该用行草。在正书中,无论笔画如何繁多的字,在草书里,很少超过五画的。现在求书写的便利,究竟该用行书,还该用草书,实在是一个有待研究的问题。至于简笔字,则是不值得提倡的。这真是徒使字体纷繁,而书写上仍简便得有限(书写的烦难,亦由于笔画形状的工整与流走,不尽由于笔画的多少)。\n中国现在古字可考的,仍以《说文》一书为大宗。此书所载,百分之九十几,系秦汉时通行的篆书。周以前文字极少。周以前的文字,多存于金石刻中(即昔人刻在金石上的文字),但其物不能全真,而后人的解释,亦不能保其没有错误。亡清光绪二十四五年间,河南安阳县北的小屯,发见龟甲、兽骨,其上有的刻有文字。据后人考证,其地即《史记·项羽本纪》所谓殷墟。认其字为殷代文字。现在收藏研究的人甚多。但自民国十七年中央研究院和河南省合作发掘以前所发现之品,伪造者极多(详见《安阳发掘报告书》第一期所载《民国十七年十月试掘安阳小屯报告书》,及《田野考古报告》第一期所载《安阳侯家庄出土之甲骨文字》。又吴县所出《国学论衡》某册所载章炳麟之言,及《制言杂志》第五十期章炳麟《答金祖同论甲骨文第二书》)。所以在中央研究院发掘所得者外,最好不必信据,以昭谨慎。\n古人多造单字,后世则单音语渐变为复音,所增非复单音的字,而是复音的辞。大抵春秋战国之时,为增造新字最多的时代。《论语·卫灵公篇》:子曰:“吾犹及史之阙文也”,“今亡已夫”!这就是说:从前写字的人,遇见写不出的字,还空着去请教人,现在却没有了,都杜造一个字写进去。依我推想起来,孔子这种见解,实未免失之于旧。因为前此所用的文字少,写来写去,总是这几个字。自己不知道,自然可问之他人。现在所用的字多了,口中的语言,向来没有文字代表它的,亦要写在纸上。既向无此字,问之于人何益?自然不得不杜造了。(一)此等新造的字,既彼此各不相谋。(二)就旧字也有(甲)讹,(乙)变。一时文字,遂颇呈纷歧之观。《说文解字·序》说七国之世,“文字异形”,既由于此。然(子)其字虽异,其造字之法仍同;(丑)而旧有习熟的字,亦决不会有改变,大体还是统一的。所以《中庸》又说:“今天下”,“书同文”。《史记·秦始皇本纪》:二十六年,“书同文字”。此即许《序》所说:“秦始皇帝初兼天下,丞相李斯乃奏同之,罢其不与秦文合者。”此项法令,并无效验。《汉书·艺文志》说:闾里书师,合《苍颉》、《爰历》、《博学》三篇,断六十四字以为一章,凡五十五章,并为《苍颉篇》。这似乎是把三书合而为一,大体上把重复之字除去。假定其全无复字,则秦时通行的字,共得三千三百。然此三书都是韵文,除尽复字,实际上怕不易办到,则尚不及此数。而《说文》成于后汉时,所载之字,共得九千九百一十三。其中固有籀文及古文、奇字,然其数实不多,而音义相同之字,则不胜枚举。可见李斯所奏罢的字,实未曾罢,如此下去,文字势必日形纷歧。这是一个很严重的问题。幸得语言从单音变为复音,把这种祸患,自然救止了。用一个字代表一个音,实在是最为简易之法。因为复音辞可以日增,单音字则只有此数。识字是最难的事,过时即不能学的。单音无甚变迁,单字既无甚增加,亦无甚改变。读古书的,研究高深文学的,所通晓的辞类及文法,虽较常人为多,所识的单字,则根本无甚相异。认识了几千个字,就能读自古至今的书,也就能通并时的各种文学,即由于此。所以以一字代表一音,实在是中国文字的一个进化。至此,文字才真正成了语言的代表。这亦是文字进化到相当程度,然后实现的。最初并非如此。《说文》:犙,三岁牛。马八岁。犙从参声,从八声,笔之于书,则有牛马旁,出之于口,与“三八”何异?听的人焉知道是什么话?然则犙决非读作参,决非读作八;犙两字,决非代表参八两个音,而系代表三岁牛,马八岁两句话。两句话只要写两个字,似乎简便了,然以一字代表一音纯一之例破坏,总是弊余于利的。所以宁忍书写之烦,而把此等字淘汰去。这可见自然的进化,总是合理的。新造的氱氮等字,若读一音,则人闻之而不能解,徒使语言与文字分离,若读两音,则把一字代表一音的条例破坏,得不偿失。这实在是退化的举动。所以私智穿凿,总是无益有损的。\n语言可由分歧而至统一,亦可由统一而至分歧。由分歧而至统一,系由各分立的部族,互相同化。由统一而至分歧,则由交通不便,语音逐渐讹变;新发生的事物,各自创造新名;旧事物也有改用新名的。所以(一)语音,(二)词类,都可以逐渐分歧。只有语法,是不容易变化的。中国语言,即在此等状况下统一,亦即在此等状况下分歧。所以语音、词类,各地方互有不同,语法则无问题。在崇古的时代,古训是不能不研究的。研究古训,须读古书。古书自无所谓不统一。古书读得多的人,下笔的时候,自然可即写古语。虽然古语不能尽达现代人的意思,然(一)大体用古语,而又依照古语的法则,增加一二俗语,(二)或者依据古语的法则,创造笔下有而口中无的语言,自亦不至为人所不能解。遂成文字统一,语言分歧的现象。论者多以此自豪。这在中国民族统一上,亦确曾收到相当的效果。然但能统一于纸上,而不能统一于口中,总是不够用的。因为(一)有些地方,到底不能以笔代口。(二)文字的进化,较语言为迟,总感觉其不够用。(三)文字总只有一部分人能通。于是发生(一)语言统一,(二)文言合一的两个问题。\n语言统一,是随着交通的进步而进步的。即(一)各地方的往来频繁。(二)(甲)大都会,(乙)大集团的逐渐发生。用学校教授的方法,收效必小。因为语言是实用之物,要天天在使用,才能够学得成功,成功了不致于忘掉。假使有一个人,生在穷乡僻壤,和非本地的人,永无交接,单用学校教授的形式,教他学国语,是断不会学得好,学好了,亦终于要忘掉的。所以这一个问题,断不能用人为的方法,希望其在短时间之内,有很大的成功。至于言文合一,则干脆的,只要把口中的语言,写在纸上就够了。这在一千年以来,语体文的逐渐流行,逐渐扩大,早已走上了这一条路。但还觉得其不够。而在近来,又发生一个文字难于认识的问题,于是有主张改用拼音字的。而其议论,遂摇动及于中国文字的本身。\n拼音字是将口中的语言,分析之而求其音素,将音素制成字母,再将字母拼成文字的。这种文字,只要识得字母,懂得拼法,识字是极容易的,自然觉得简便。但文字非自己发生,而学自先进民族的,可以用此法造字。文字由自己创造的民族,是决不会用此法的。因为当其有文字之初,尚非以之代表语言,安能分析语言而求其音素?到后来进化了,知道此理,而文字是前后相衔的,不能舍旧而从新,拼音文字,就无从在此等民族中使用了。印度是使用拼音文字的,中国和印度交通后,只采用其法于切音,而卒不能改造文字,即由于此。使用拼音文字于中国,最早的,当推基督教徒。他们鉴于中国字的不易认识,用拉丁字母,拼成中国语,以教贫民,颇有相当的效果。中国人自己提倡的,起于清末的劳乃宣。后来主张此项议论的,亦不乏人。以传统观念论,自不易废弃旧文字。于是由改用拼音字,变为用注音字注旧文字的读音。遂有教育部所颁布的注音符号。然其成效殊鲜。这是由于统一读音,和统一语音,根本是两件事。因语音统一,而影响到读音,至少是语体文的读音,收效或者快些。想靠读音的统一,以影响到语音,其事的可能性怕极少。因为语言是活物,只能用之于口中。写在纸上再去读,无论其文字如何通俗,总是读不成语调的。而语言之所以不同,并非语音规定语调,倒是语调规定语音。申言之:各地方人的语调不同,并非由其所发的一个个音不同,以至积而成句,积而成篇,成为不同的语调。倒是因其语调不同,一个个音,排在一篇一句之内,而其发音不得不如此。所以用教学的方法,传授一种语言,是可能的。用教学的方法,传授读音,希望其积渐而至于统一语言,则根本不会有这回事。果真要用人为的方法,促进语言的统一,只有将一地方的言语,定为标准语,即以这地方的人,作为教授的人,散布于各地方去教授,才可以有相当的效果。教授之时,宜专于语言,不必涉及读书。语言学会了,自会矫正读音,至于某程度。即使用教学的方法,矫正读音,其影响亦不过如是而止,决不会超过的。甚或两个问题,互相牵制,收效转难。注音符号,意欲据全国人所能发的音,制造成一种语言。这在现在,实际上是无此语言的。所以无论什么地方的话,总不能与国语密合。想靠注音符号等工具,及教学的方法,造成一种新语言,是不容易的。所以现在所谓能说国语的人,百分之九十九,总还夹杂土话。既然总不密合,何不拣一种最近于国语的言语,定为标准语,来得痛快些呢?\n至于把中国文字,改成拼音文字,则我以为在现在状况之下,听凭两种文字,同时并行,是最合理的。旧日的人,视新造的拼音文字,为洪水猛兽,以为将要破坏中国的旧文化,因而使中国人丧失其民族性;新的人,以为旧文字是阻碍中国进化的,也视其为洪水猛兽,都是一偏之见。认识单字,与年龄有极大的关系。超过一定年龄,普通的人,都极难学习。即使勉强学习,其程度,也很难相当的。所以中国的旧文字,决不能施之成人。即年龄未长,而受教育时间很短的人,也是难学的。因为几千个单字,到底不能于短时间之内认识。如平民千字识字课等,硬把文字之数减少,也是不适于用的。怀抱旧见解的人,以为新文字一行,即将把旧文化破坏净尽。且将使中国民族,丧失其统一性。殊不知旧文字本只有少数人通晓。兼用拼音字,这少数通晓旧文字的人,总还是有的。使用新文字的人,则本来都是不通旧文字的,他们所濡染的中国文化,本非从文字中得来,何至因此而破坏中国的旧文化,及民族的统一性?就实际情形,平心而论,中国旧文化,或反因此而得新工具,更容易推广,因之使中国的民族性,更易于统一呢?吴敬恒说:“中国的读书人,每拘于下笔千秋的思想,以为一张纸写出字来,即可以传之永久。”于是设想:用新文字写成的东西,亦将像现在的旧书一般,汗牛充栋,留待后人的研究,而中国的文化,就因之丧失统一性了。殊不知这种用新文字写成的东西,都和现在的传单报纸一般,阅过即弃,至于有永久性的著作,则必是受教育程度稍深的人,然后能为,而此种人,大都能识得旧文字。所以依我推想,即使听新旧文字同时并行,也决不会有多少书籍,堆积起来。而且只能学新文字的人,其生活,和文字本来是无缘的。现在虽然勉强教他以几个文字,他亦算勉强学会了几个文字,对于文字的关系,总还是很浅的。怕连供一时之用的宣传品等,还不会有多少呢。何能因此而破坏中国的文化和民族统一性?准此以谈,则知有等人说:中国现在,语言虽不统一,文字却是统一的。若拼音字不限于拼写国语,而许其拼写各地方的方言,将会有碍于中国语言的统一,也是一样的缪见。因为(一)现在文字虽然统一,决不能以此为工具,进而统一语言的。(二)而只能拼写方言的人,亦即不通国语的人,其语言,亦本来不曾统一。至于说一改用拼音文字,识字即会远较今日为易,因之文化即会突飞猛进,也是痴话。生活是最大的教育。除少数学者外,读书对于其人格的关系,是很少的。即使全国的人,都能读相当的书,亦未必其人的见解,就会有多大改变。何况识得几个字的人,还未必都会去读书呢?拼音文字,认识较旧文字为易是事实,其习熟则并无难易之分。习熟者的读书,是一眼望去便知道的,并不是一个个字拼着音去认识,且识且读的。且识且读,拼音文字,是便利得多了。然这只可偶一为之,岂能常常如此?若常常如此,则其烦苦莫甚,还有什么人肯读书?若一望而知,试问Book与书,有何区别?所以拼音文字在现在,只是供一时一地之用的。其最大的作用,亦即在此。既然如此,注音符号、罗马字母等等杂用,也是无妨的。并不值得争论。主张采用罗马字母的人,说如此,我们就可以采用世界各国的语言,扩大我国的语言,这也是痴话。采用外国的语言,与改变中国的文字何涉?中国和印度交通以来,佛教的语言,输入中国的何限?又何尝改用梵文呢?\n语言和中国不同,而采用中国文字的,共有三法:即(一)径用中国文,如朝鲜是。(二)用中国文字的偏旁,自行造字,如辽是。(三)用中国字而别造音符,如日本是。三法中,自以第三法为最便。第二法最为无谓。所以辽人又别有小字,出于回纥,以便应用。大抵文字非出于自造,而取自他族的,自以用拼音之法为便。所以如辽人造大字之法,毕竟不能通行。又文字所以代表语言,必不能强语言以就文字。所以如朝鲜人,所做华文,虽极纯粹,仍必另造谚文以应用(契丹文字,系用隶书之半,增损为之,见《五代史》。此系指契丹大字而言,据《辽史·太祖本纪》,事在神册五年。小字出于回纥,为迭剌所造,见《皇子表》)。\n满、蒙、回、藏四族,都是使用拼音文字的。回文:或说出于犹太,或说出于天主教徒,或说出于大食,未知孰是(见《元史·译文证补》)。藏文出于印度。是唐初吐蕃英主弃宗弄赞,派人到印度去留学,归国后所创制的(见《蒙古源流考》)。蒙古人初用回文,见《元史·塔塔统阿传》。《脱卜察安》(《元秘史》。元朝人最早自己所写的历史)即系用回文所写。后来世祖命八思巴造字,则是根据藏文的。满文系太祖时额尔德尼所造。太宗时,达海又加以圈点(一种符号),又以蒙文为根据。西南诸族,惟倮有文字,却是本于象形字的。于此,可见文字由于自造者,必始象形,借自他族者,必取拼音之理。\n文字的流传,必资印刷。所以文字的为用,必有印刷而后弘,正和语言之为用,必得文字而后大一样。古人文字,要保存永久的,则刻诸金石。此乃以其物之本身供众览,而非用以印刷,只能认为印刷的前身,不能即认为印刷事业。汉代的石经,还系如此。后来就此等金石刻,加以摹拓。摹拓既广,觉得所摹拓之物,不必以之供众览,只须用摹拓出来的东西供览即可。于是其雕刻,专为供印刷起见,就成为印刷术了。既如此,自然不必刻金石,而只要刻木。刻板之事,现在可考的起于隋。陆深《河汾燕闲录》说,隋文帝开皇十三年,敕废像遗经,悉令雕版。其时为民国纪元前1319年,西历593年。《敦煌石室书录》有《大隋永陀罗尼本经》,足见陆说之确。唐代雕本,宋人已没有著录的,惟江陵杨氏,藏有《开元杂报》七页。日本亦有永徽六年(唐高宗年号。民国纪元前1257年,西历655年)《阿毗达磨大毗婆娑论》。后唐明宗长兴三年(民国纪元前980年,西历932年),宰相冯道、李愚,请令判国子监田敏,校正九经,刻板印卖,是为官刻书之始。历二十七年始成(周太祖广顺三年)。宋代又续刻义疏及诸史。书贾因牟利,私人因爱好文艺而刻的亦日多。仁宗庆历中(民国纪元前871至前864年,西历1041至1048年),毕昇又造活字(系用泥制。元王祯始刻木为之。明无锡华氏始用铜。清武英殿活字亦用铜制)。于是印刷事业,突飞猛进,宋以后书籍,传于后世的,其数量,就远非唐以前所可比了(此节据孙毓修《中国雕板源流考》,其详可参考原书,商务印书馆本)。\n第五十三章 学术 # 学术思想,是一个民族的灵魂。看似虚悬无薄,实则前进的方向,全是受其指导。中国是一个学术发达的国家。几千年来,学术分门别类,各致其精。如欲详述之,将数十百万言而不能尽。现在所讲的,只是思想转变的大略,及其和整个文化的关系。依此讲,则中国的学术思想,可分为三大时期:\n一、自上古至汉魏之际。\n二、自佛学输入至亡清。其中又分为(甲)佛学时期,(乙)理学时期。\n三、自西学输入以后。\n现在研究先秦诸子的人,大都偏重于其哲学方面。这个实在是错误的。先秦诸子的学术,有两个来源:其(一)从古代的宗教哲学中,蜕化而出。其(二)从各个专门的官守中,孕育而成。前都偏重玄学方面,后者偏重政治社会方面。《汉书·艺文志》说诸子之学,其原皆出于王官。《淮南要略》说诸子之学,皆出于救时之弊。一个说其因,一个说其缘,都不及古代的哲学。尤可见先秦诸子之学,实以政治社会方面为重,玄学方面为轻。此意,近人中能见得的,只有章炳麟氏。\n从古代宗教中蜕化而出的哲学思想,大致是如此的:(一)因人有男女,鸟有雌雄,兽有牝牡,自然界又有天地日月等现象,而成立阴阳的概念。(二)古代的工业,或者是分做水、火、木、金、土五类的。实际的生活影响于哲学思想,遂分物质为五行。(三)思想进步,觉得五行之说,不甚合理,乃认万物的原质为一个,而名之曰气。(四)至此,遂并觉阴阳二力,还不是宇宙的根源(因为最后的总是唯一的,也只有唯一的能算最后的)。乃再成立一个唯一的概念,是即所谓太极。(五)又知质与力并非二物,于是所谓有无,只是隐显。(六)隐显由于变动,而宇宙的根源,遂成为一种动力。(七)这种动力,是颇为机械的。一发动之后,其方向即不易改变。所以有谨小、慎始诸义。(八)自然之力,是极其伟大的。只有随顺,不能抵抗。所以要法自然。所以贵因。(九)此种动力,其方向是循环的。所以有祸福倚伏之义。所以贵知白守黑,知雄守雌。(十)既然万物的原质,都是一个,而又变化不已,则万物根本只是一物。天地亦万物之一,所以惠施是提倡泛爱,说天地万物一体,而物论可齐(论同伦,类也)。(十一)因万物即是一物,所以就杂多的现象,仍可推出其总根源。所谓“穷理尽性,以至于命”。此等思想,影响于后来,极为深刻。历代的学术家,几乎都奉此为金科玉律。诚然,此等宽廓的说法,不易发现其误缪。而因其立说的宽廓,可以容受多方面的解释,即存其说为弘纲,似亦无妨。但有等错误的观念,业已不能适用的,亦不得不加以改正。如循环之说,古人大约由观察昼夜寒暑等现象得来。此说施诸自然界,虽未必就是,究竟还可应用。若移用于社会科学,就不免误缪了。明明是进化的,如何说是循环。\n先秦诸子,关于政治社会方面的意见,是各有所本的,而其所本亦分新旧。依我看来:(一)农家之所本最旧,这是隆古时代农业部族的思想。(二)道家次之,是游牧好侵略的社会的反动。(三)墨家又次之,所取法的是夏朝。(四)儒家及阴阳家又次之,这是综合自上古至西周的政治经验所发生的思想。(五)法家最新,是按切东周时的政治形势所发生的思想。以上五家,代表整个的时代变化,其关系最大。其余如名家,专讲高深玄远的理论。纵横家,兵家等,只效一节之用。其关系较轻。\n怎说农家是代表最古的思想的呢?这只要看许行的话,便可明白。许行之说有二:(一)君臣并耕,政府毫无威权。(二)物价论量不论质。如非根据于最古最简陋的社会的习俗,决不能有此思想(见《孟子·滕文公上篇》)。\n怎说道家所代表的,是游牧好侵略的社会的反动思想呢?汉人率以黄、老并称。今《列子》虽系伪书,然亦有其所本(此凡伪书皆然,不独《列子》。故伪书既知其伪之后,在相当条件下,其材料仍可利用)。此书《天瑞篇》有《黄帝书》两条,其一同《老子》。又有黄帝之言一条。《力命篇》有《黄帝书》一条,与《老子》亦极相类。《老子》书(一)多系三四言韵语。(二)所用名词,极为特别(如有雌雄牝牡而无男女字)。(三)又全书之义,女权皆优于男权。足证其时代之古。此必自古口耳相传之说,老子著之竹帛的,决非老子所自作。黄帝是个武功彪炳的人,该是一个好侵略的部族的酋长。侵略民族,大抵以过刚而折。如夷羿、殷纣等,都是其适例。所以思想上发生一种反动,要教之以守柔。《老子》书又主张无为。无为二字的意义,每为后人所误解。为训化。《礼记·杂记》,子曰:“张而不弛,文武不能也。弛而不张,文武不为也。”此系就农业立说。言弛而不张,虽文武亦不能使种子变化而成谷物。贾谊《谏放民私铸疏》:“奸钱日多,五谷不为”(今本作“五谷不为多”,多字系后人妄增),正是此义。野蛮部族往往好慕效文明,而其慕效文明,往往牺牲了多数人的幸福((一)因社会的组织,随之变迁。(二)因在上的人,务于淫侈,因此而刻剥其下)。所以有一种反动的思想,劝在上的人,不要领导着在下的人变化。在下的人,“化而欲作”,还该“镇之以无名之朴”。这正和现今人因噎废食,拒绝物质文明一样。\n怎样说墨家所代表的,是夏代的文化呢?《汉书·艺文志》说墨家之学,“茅屋采椽,是以贵俭(古人的礼,往往在文明既进步之后,仍保存简陋的样子,以资纪念。如既有酒,祭祀仍用水,便是其一例。汉武帝时,公玉带上明堂图,其上犹以茅盖,见《史记·封禅书》。可见《汉志》此说之确)。养三老五更,是以兼爱(三老五更,乃他人的父兄)。选士大射,是以尚贤(平民由此进用。参看第四十三章)。宗祀严父,是以右鬼(人死曰鬼)。顺四时而行,是以非命(命有前定之义。顺四时而行,即《月令》所载的政令。据《月令》说:政令有误,如孟春行夏令等,即有灾异。此乃天降之罚。然则天是有意志,随时监视着人的行动,而加以赏罚的。此为墨子天志之说所由来。他家之所谓命,多含前定之义,则近于机械论了)。以孝视天下(视同示)是以上同。”都显见得是明堂中的职守。所以《汉志》说他出于清庙之官(参看第五十一章)。《吕览·当染篇》说:“鲁惠公使宰让请郊庙之礼于天子。天子使史角往,惠公止之。其后在鲁,墨子学焉。”此为墨学出于清庙之官的确证。清庙中能保存较古之学说,于理是可有的。墨家最讲究实用,而《经》、《经说》、《大小取》等篇,讲高深的哲学,为名家所自出的,反在墨家书中,即由于此。但此非墨子所重。墨子的宗旨,主于兼爱。因为兼爱,所以要非攻。又墨子是取法乎夏的,夏时代较早,又值水灾之后,其生活较之殷、周,自然要简朴些,所以墨子的宗旨,在于贵俭。因为贵俭,所以要节用,要节葬,要非乐。又夏时代较早,迷信较深,所以墨子有天志、明鬼之说。要讲天志、明鬼,即不得不非命。墨家所行的,是凶荒札丧的变礼(参看第四十一章)。其所教导的,是沦落的武士(参看第四十章)。其实行的精神,最为丰富。\n怎样说儒家、阴阳家是西周时代所产生的思想呢?荀子说:“父子相传,以持王公,三代虽亡,治法犹存,官人百吏之所以取禄秩也。”(《荣辱篇》)国虽亡而治法犹存,这是极可能的事。然亦必其时代较近,而后所能保存的才多。又必其时的文化,较为发达,然后足为后人所取法。如此,其足供参考的,自然是夏、殷、周三代。所以儒家有通三统之说(封本朝以前两代之后以大国,使之保存其治法,以便与本朝之治,三者轮流更换。《史记·高祖本纪》赞所谓“三王之道若循环”,即是此义)。这正和阴阳家所谓五德终始一样。五德终始有两说:旧说以所克者相代。如秦以周为火德,自己是水德;汉又自以为土德是。前汉末年,改取相生之说。以周为木德,说秦朝是闰位,不承五行之运,而自以为是火德。后来魏朝又自以为是土德)。《汉书·严安传》:载安上书,引邹子之说,说“政教文质者,所以云救也。当时则用,过则舍之,有易则易之。”可见五德终始,乃系用五种治法,更迭交换。邹衍之学,所以要本所已知的历史,推论未知;本所已见的地理,推所未见,正是要博观众事,以求其公例。治法随时变换,不拘一格,不能不说是一种进步的思想。此非在西周以后,前代的治法,保存的已多,不能发生。阴阳家之说,缺佚已甚,其最高的蕲向如何,已无可考。儒家的理想,颇为高远。看第四十一章所述大同小康之说可见。《春秋》三世之义,据乱而作,进于升平,更进于太平,明是要将乱世逆挽到小康,再逆挽到大同。儒家所传的,多是小康之义。大同世之规模,从升平世进至太平世的方法,其详已不可得闻。几千年来,崇信儒家之学的,只认封建完整时代,即小康之世的治法,为最高之境,实堪惋惜。但儒家学术的规模,是大体尚可考见的。它有一种最高的理想,企图见之于人事。这种理想,是有其哲学上的立足点的。如何次第实行,亦定有一大体的方案。儒家之道,具于六经。六经之中,《诗》、《书》、《礼》、《乐》,乃古代大学的旧教科,说已见第五十一章。《易》、《春秋》则为孔门最高之道所在。《易》言原理,《春秋》言具体的方法,两者互相表里,所以说“《易》本隐以之显,《春秋》推见至隐”。儒家此等高义,既已隐晦。其盛行于世,而大有裨益于中国社会的,乃在个人修养部分。(一)在理智方面,其说最高的是中庸。其要,在审察环境的情形,随时随地,定一至当不易的办法。此项至当不易的办法,是随时随地,必有其一,而亦只能有一的,所以贵择之精而守之坚。(二)人之感情,与理智不能无冲突。放纵感情,固然要撞出大祸,抑压感情,也终于要溃决的,所以又有礼乐,以陶冶其感情。(三)无可如何之事,则劝人以安命。在这一点,儒家亦颇有宗教家的精神。(四)其待人之道,则为絜矩(两字见《大学》)。消极的“己所不欲,勿施于人”。积极的则“所求乎子以事父,所求乎臣以事君,所求乎弟以事兄,所求乎朋友先施之”。我们该怎样待人,只要想一想,我们希望他怎样待我即得,这是何等简而赅。怎样糊涂的人,对这话也可以懂得,而圣人行之,亦终身有所不能尽,这真是一个妙谛。至于(五)性善之说,(六)义利之辨,(七)知言养气之功,则孟子发挥,最为透彻,亦于修养之功,有极大关系。儒家之遗害于后世的,在于大同之义不传,所得的多是小康之义。小康之世的社会组织,较后世为专制。后人不知此为一时的组织,而认为天经地义,无可改变,欲强已进步的社会以就之,这等于以杞柳为杯棬,等于削足以适屦,所以引起纠纷,而儒学盛行,遂成为功罪不相掩之局。这只可说是后来的儒家不克负荷,怪不得创始的人。但亦不能一定怪后来的任何人。因儒学是在这种社会之中,逐渐发达的。凡学术,固有变化社会之功,同时亦必受社会的影响,而其本身自起变化。这亦是无可如何的事。\n怎样说法家之学,是按切东周时代的情形立说的呢?这时候,最要紧的,是(一)裁抑贵族,以铲除封建势力。(二)富国强兵,以统一天下。这两个条件,秦国行之,固未能全合乎理想,然在当时,毕竟是最能实行的,所以卒并天下。致秦国于富强的,前有商鞅,后有李斯,都是治法家之学的。法家之学的法字,是个大名。细别起来,则治民者谓之法,裁抑贵族者谓之术,见《韩非子·定法篇》。其富国强兵之策,则最重要的,是一民于农战。《商君书》发挥此理最透,而《管》、《韩》二子中,亦有其理论。法家是最主张审察现实,以定应付的方法的,所以最主张变法而反对守旧。这确是法家的特色。其学说之能最新,大约即得力于此。\n以上所述五家,是先秦诸子中和中国的学术思想及整个的文化,最有关系的。虽亦有其高远的哲学,然其所想解决的,都是人事问题。而人事问题,则以改良社会的组织为其基本。粗读诸子之书,似乎所注重的,都是政治问题。然古代的政治问题,不像后世单以维持秩序为主,而整个的社会问题,亦包括在内。所以古人说政治,亦就是说社会。\n诸家之学,并起争鸣,经过一个相当时期之后,总是要归于统一的。统一的路线有两条:(一)淘汰其无用,而存留其有用的。(二)将诸家之说,融合为一。在战国时,诸家之说皆不行,只有法家之说,秦用之以并天下,已可说是切于时务的兴,而不切于时务的亡了。但时异势殊,则学问的切于实用与否,亦随之而变。天下统一,则需要与民休息,民生安定,则需要兴起教化。这二者,是大家都会感觉到的。秦始皇坑儒时说:“吾前收天下书不中用者尽去之。悉召文学方术士甚众。欲以兴太平。方士欲练,以求奇药。”兴太平指文学士言。可见改正制度,兴起教化,始皇非无此志,不过天下初定,民心未服,不得不从事于镇压;又始皇对外,颇想立起一个开拓和防御的规模来,所以有所未遑罢了。秦灭汉兴,此等积极的愿望,暂时无从说起。最紧要的,是与民休息。所以道家之学,一时甚盛。然道家所谓无为而治,乃为正常的社会说法。社会本来正常的,所以劝在上的人,不要领导其变化;且须镇压之,使不变化,这在事实上虽不可能,在理论上亦未必尽是,然尚能自成一说。若汉时,则其社会久已变坏,一味因循,必且迁流更甚。所以改正制度,兴起教化,在当时,是无人不以为急务的。看贾谊、董仲舒的议论,便可明白。文帝亦曾听公孙臣的话,有意于兴作。后因新垣平诈觉,牵连作罢。这自是文帝脑筋的糊涂,作事的因循,不能改变当时的事势。到武帝,儒学遂终于兴起了。儒学的兴起,是有其必然之势的,并非偶然之事。因为改正制度,兴起教化,非儒家莫能为。论者多以为武帝一人之功,这就错了。武帝即位时,年仅十六,虽非昏愚之主,亦未闻其天亶夙成,成童未几,安知儒学为何事?所以与其说汉武帝提倡儒学,倒不如说儒学在当时自有兴盛之势,武帝特顺着潮流而行。\n儒学的兴起,虽由社会情势的要求,然其得政治上的助力,确亦不少。其中最紧要的,便是为五经博士置弟子。所谓“设科射策,劝以官禄”,自然来者就多了。儒学最初起的,是《史记·儒林传》所说的八家:言《诗》:于鲁,自申培公;于齐,自辕固生;于燕,自韩太傅。言《书》,自济南伏生。言《礼》,自鲁高堂生。言《易》,自菑川田生。言《春秋》:于齐、鲁,自胡毋生;于赵,自董仲舒。东汉立十四博士:《诗》齐、鲁、韩。《书》欧阳、大小夏侯。《礼》大小戴。《易》施、孟、梁丘、京。《春秋》严、颜(见《后汉书·儒林传》。《诗》齐鲁韩下衍毛字),大体仍是这八家之学(惟京氏《易》最可疑)。但是在当时,另有一种势力,足以促令学术变更。那便是第四十一章所说:在当时,急须改正的,是社会的经济制度。要改正社会经济制度,必须平均地权,节制资本。而在儒家,是只知道前一义的。后者之说,实在法家。当时儒家之学,业已成为一种权威,欲图改革,自以自托于儒家为便,儒家遂不得不广采异家之学以自助,于是有所谓古文之学。读第四十一章所述,已可明白了。但是学术的本身,亦有促令其自起变化的。那便是由专门而趋于通学。\n先秦学术,自其一方面论,可以说是最精的。因为他各专一门,都有很高的见解。自其又一方面说,亦可以说是最粗的。因为他只知道一门,对于他人的立场,全不了解。譬如墨子所主张,乃凶荒札丧的变礼,本不谓平世当然。而荀子力驳他,说天下治好了,财之不足,不足为患,岂非无的放矢?理论可以信口说,事实上,是办不到只顾一方面的。只顾一方面,一定行不通。所以先秦时已有所谓杂家之学。《汉志》说:杂家者流,出于议官。可见国家的施政,不得不兼顾到各方面了。情势如此,学术自然不得不受其影响,而渐趋于会通,古文之学初兴时,实系兼采异家之说,后来且自立新说,实亦受此趋势所驱使。倘使当时的人,痛痛快快,说儒家旧说,不够用了,我所以要兼采异说;儒家旧说,有所未安,我所以要别立新说,岂不直捷?无如当时的思想和风气,不容如此。于是一方面说儒家之学,别有古书,出于博士所传以外(其中最重要的,便是孔壁一案,参看第五十二章),一方面,自己研究所得,硬说是某某所传(如《毛诗》与《小序》为一家言。《小序》明明是卫宏等所作,而毛公之学,偏要自谓子夏所传),纠纷就来得多了。流俗眩于今古文之名,以为今古文经,文字必大有异同,其实不然。今古文经的异字,备见于《仪礼》郑《注》(从今文处,则出古文于注。从古文处,则出今文于注),如古文位作立,仪作义,义作谊之类,于意义毫无关系。他经度亦不过如此。有何关系之可言?今古文经的异同,实不在经文而在经说。其中重要问题,略见于许慎的《五经异义》。自大体言之:今文家说,都系师师相传。古文家说,则自由研究所得,不为古人的成说所囿,而自出心裁,从事研究,其方法似觉进步。但(一)其成绩并不甚佳。又(二)今文家言,有传讹而无臆造。传讹之说,略有其途径可寻,所以其说易于还原。一经还原,即可见古说的真相(其未曾传讹的,自然更不必说)。古文家言,则各人凭臆为说,其根源无可捉摸。所以把经学当做古史的材料看,亦以今文家言价值较高。\n然古学的流弊,亦可说仍自今学开之。一种学术,当其与名利无关时,治其学者,都系无所为而为之,只求有得于己,不欲炫耀于人,其学自无甚流弊。到成为名利之途则不然。治其学者,往往不知大体,而只斤斤计较于一枝一节之间。甚或理不可通,穿凿立说。或则广罗异说,以自炫其博。引人走入旁门,反致抛荒正义。从研究真理的立场上言,实于学术有害。但流俗的人,偏喜其新奇,以为博学。此等方法,遂成为哗世取宠之资。汉代此等风气,由来甚早。《汉书·夏侯胜传》说:“胜从父子建,师事胜及欧阳高,左右采获。又从五经诸儒问与《尚书》相出入者,牵引以次章句,具文饰说。胜非之曰:建所谓章句小儒,破碎大道。建亦非胜为学疏略,难以应敌。”专以应敌为务,真所谓徇外为人。此种风气既开,遂至专求闻见之博,不顾义理之安;甚且不知有事理。如郑玄,遍注群经,在汉朝,号称最博学的人,而其说经,支离灭裂,于理决不可通,以及自相矛盾之处,就不知凡几。此等风气既盛,治经者遂多变为无脑筋之徒。虽有耳目心思,都用诸琐屑无关大体之处。而于此种学问,所研究的,究属宇宙间何种现象?研究之究有何益?以及究应如何研究?一概无所闻见。学术走入此路,自然只成为有闲阶级,消耗日力精力之资,等于消闲遣兴,于国家民族的前途,了无关系了。此等风气,起于西汉中叶,至东汉而大盛,直至南北朝、隋唐而未改。汉代所谓章句,南北朝时所谓义疏,都系如此。读《后汉书》及《南北史》的《儒林传》,最可见得。古学既继今学而起,到汉末,又有所谓伪古文一派。据近代所考证:王肃为其中最重要的一个人。肃好与郑玄立异,而无以相胜。乃伪造《孔子家语》,将己说窜入其中,以折服异己,经学中最大的《伪古文尚书》一案,虽不能断为即肃之所造,然所谓《伪孔安国传》者,必系与肃同一学派之人所为,则无可疑(《伪古文尚书》及《伪孔安国传》之伪,至清阎若璩作《古人尚书疏证》而其论略定。伪之者为哪一派人,至清丁晏作《尚书余论》而其论略定)。此即由当时风气,专喜广搜证据,只要所搜集者博,其不合理,并无人能发觉,所以容得这一班人作伪。儒学至此,再无西汉学者经世致用的气概。然以当时学术界的形势论,儒学业已如日中天。治国安民之责,在政治上、在社会上,都以为惟儒家足以负之。这一班人,如何当得起这个责任?他们所想出来的方案,无非是泥古而不适于时,专事模仿古人的形式。这个如何足以为治?自然要激起有思想的人的反对了。于是魏晋玄学,乘机而起,成为儒佛之间的一个过渡。\n魏晋玄学,人多指为道家之学。其实不然。玄学乃儒道两家的混合,亦可说是儒学中注重原理的一派,与拘泥事迹的一派相对立。先秦诸子的哲学,都出自古代的宗教哲学,大体无甚异同,说已见前。儒家之书,专谈原理的是《易经》。《易》家亦有言理、言数两派。言理的,和先秦诸子的哲学,无甚异同。言数的,则与古代术数之学相出入。《易》之起源,当和术数相近;孔门言易,则当注重于其哲学,这是通观古代学术的全体,而可信其不诬的。今文《易》说,今已不传。古文《易》说,则无一非术数之谈。《汉书·艺文志》:易家有《淮南·道训》两篇。自注云:“淮南王安,聘明《易》者九人,号九师说。”此书,当即今《淮南子》中的《原道训》。今《淮南子》中,引《易》说的还有几条,都言理而不及数,当系今文《易》说之遗。然则儒家的哲学,原与道家无甚出入。不过因今文《易》说失传,其残存的,都被后人误认为道家之说罢了。如此说来,则魏晋玄学的兴起,并非从儒家转变到道家,只是儒家自己的转变。不过此种转变,和道家很为接近,所以其人多兼采道家之学。观魏晋以后的玄学家,史多称其善《易》、《老》可知。儒学的本体,乃以《易》言原理,《春秋》则据此原理,而施之人事。魏晋的玄学家,则专研原理,而于措之人事的方法,不甚讲求。所以实际上无甚功绩可见,并没有具体可见之施行的方案。然经此运动之后,拘泥古人形式之弊遂除。凡言法古的,都是师其意而不是回复其形式。泥古不通之弊,就除去了,这是他们摧陷廓清莫大的功绩(玄学家最重要的观念,为重道而遗迹。道即原理,迹即事物的形式)。\n从新莽改革失败以后,彻底改变社会的组织,业已无人敢谈。解决人生问题的,遂转而求之个人方面。又玄学家探求原理,进而益上,其机,殊与高深玄远的哲学相近。在这一点上,印度的学术,是超过于中国的。佛学遂在这种情势之下兴起。\n佛,最初系以宗教的资格,输入中国的。但到后来,则宗教之外,别有其学术方面。\n佛教,普通分为大小乘。依后来详细的判教,则小乘之下,尚有人天(专对人天说法,不足以语四圣。见下);大乘之中,又分权实。所谓判教,乃因一切经论(佛所说谓之经,菩萨以下所说谓之论。僧、尼、居士等所应守的规条谓之律。经、律、论谓之三藏),立说显有高低,所以加以区别,说佛说之异,乃因其所对待的人不同而然。则教外的人,不能因此而诋佛教的矛盾,教中的人,亦不必因此而起争辩了。依近来的研究:佛教在印度的兴起,并不在其哲学的高深,而实由其能示人以实行的标准。缘印度地处热带,生活宽裕,其人所究心的,实为宇宙究竟,人生归宿等问题,所以自古以来,哲学思想,即极发达。到佛出世时,各家之说(所谓外道),已极高深,而其派别亦极繁多了。群言淆乱,转使人无所适从。释迦牟尼出,乃截断无谓的辩论,而教人以实行修证的方法。从之者乃觉得所依归,而其精神乃觉安定。故佛非究竟真理的发现者(中国信佛的人,视佛如此),而为时代的圣者。佛灭后百年之内,其说无甚异同,近人称为原始佛教。百年之后而小乘兴,五六百年之后而大乘出,则其说已有改变附益,而非复佛说之旧了。然则佛教的输入中国,所以前后互异,亦因其本身的前后,本有不同,并非在彼业已一时具足,因我们接受的程度,先后不同,而彼乃按其深浅,先后输入的了。此等繁碎的考据,今可勿论。但论其与中国文化的关系。\n佛教把一切有情,分为十等:即(一)佛,(二)菩萨,(三)缘觉,(四)声闻,是为四圣。(五)天,(六)人,(七)阿修罗,(八)畜生,(九)饿鬼,(十)地狱,是为六凡。辗转于六凡之中,不得超出,谓之六道轮回。佛不可学,我们所能学的,至菩萨而止。在小乘中,缘觉、声闻,亦可成佛,大乘则非菩萨不能。所谓菩萨,系念念以利他为主,与我们念念都以自己为本位的,恰恰相反。至佛则并利他之念而亦无之,所以不可学了。缘觉、声闻鉴于人生不能离生、老、病、死诸苦,死后又要入轮回;我们幸得为人,尚可努力修持,一旦堕入他途便难了(畜生、饿鬼、地狱亦称三途,不必论了。阿修罗神通广大,易生嗔怒;诸天福德殊胜,亦因其享受优越,转易堕落,所以以修持论,皆不如人)。所以觉得生死事大,无常迅速,实可畏怖,生前不得不努力修持。其修持之功,固然艰苦卓绝,极可佩服,即其所守戒律,亦复极端利他。然根本观念,终不离乎自利,所以大乘斥其不足成佛。此为大小乘重要的异点。亦即大乘教理,较小乘为进化之处。又所谓佛在小乘,即指释迦牟尼其人。大乘则佛有三身:(一)佛陀其人,谓之报身,是他造了为人之因,所以在这世界上成为一个人的。生理心理等作用,一切和我们一样。不吃也要饿,不着也要冷,置诸传染病的环境中,也会害病;饿了,冷了,病重了,也是会死的。(二)至于有是而无非,威权极大。我们动一善念,动一恶念,他都无不知道。善有善报,恶有恶报,丝毫不得差错。是为佛之法身,实即自然力之象征。(三)一心信佛者,临死或在他种环境中,见有佛来接引拯救等事,是为佛之化身。佛在某种环境中,经历一定的时间,即可修成。所以过去已有无数的佛,将来还有无数的佛要成功,并不限于释迦牟尼一人。大乘的说法,当他宗教信,是很能使人感奋的。从哲学上说,其论亦圆满具足,无可非难。宗教的进化,可谓至斯而极。\n佛教的宇宙观,系以识为世界的根本。有眼、耳、鼻、舌、身、意,即有色、声、香、味、触、法。此为前六识,为人人所知。第七识为末那,第八识为阿赖耶,其义均不能译,故昔人惟译其音。七识之义,为“恒审思量,常执有我”。我们念念以自己为本位(一切现象,都以自己为本位而认识。一切利害,都以自己为本位而打算),即七识之作用。至八识则为第七识之所由生,为一切识的根本。必须将它灭尽,才得斩草除根。但所谓灭识,并不是将他铲除掉,至于空无所有。有无,佛教谓之色空。色空相对,只是凡夫之见。佛说则“色即是空,空即是色”(如在昼间,则昼为色,夜为空。然夜之必至,其确实性,并不减于昼之现存。所以当昼时,夜之现象,虽未实现,夜之原理,业已存在。凡原理存在者,即与其现象存在无异。已过去之事,为现在未来诸现象之因。因果原系一事。所以已过去的事,亦并未消灭)。所以所谓灭识,并非将识消灭,而系“转识成智”。善恶同体。佛说的譬喻,是如水与波。水为善,动而为波即成恶。按照现在科学之理,可以有一个更妙的譬喻,即生理与病理。病非别有其物,只是生理作用的异常。去病得健,亦非把病理作用的本体消灭,只是使他回复到生理作用。所以说“真如无明,同体不离”(真如为本体,无明为恶的起点)。行为的好坏,不是判之于其行为的形式的,而是判之于其用意。所以所争的只在迷悟。迷时所做的事,悟了还是可以做的。不过其用意不同,则形式犹是,而其内容却正相反,一为恶业,一为净业了。喻如母亲管束子女,其形式,有时与厂主管理童工是一样的。所以说:“共行只是人间路,得失谁知霄壤分。”佛教为什么如此重视迷悟呢?因为世界纷扰的起因,不外乎(一)怀挟恶意,(二)虽有善意,而失之愚昧。怀挟恶意的,不必论了。失之愚昧的,虽有善意,然所做的事,无一不错,亦必伏下将来的祸根。而愚昧之因,又皆因眼光只限于局部,而不能扩及全体(兼时间空间言)。所以佛说世俗之善,“如以少水而沃冰山,暂得融解,还增其厚。”此悟之所以重要。佛教的人生问题,依以上所说而得解答。至于你要追问宇宙问题的根源,如空间有无界限,时间有无起讫等,则佛教谓之“戏论”,置诸不答(外道以此为问,佛不答,见《金七十论》)。这因为:我们所认识的世界,完全是错误的。其所以错误,即因我们用现在的认识方法去认识之故。要把现在的认识方法放下,换一种方法去认识,自然不待言而可明。若要就现在的认识方法,替你说明,则非我的不肯说,仍系事之不可能。要怎样才能换用别种认识方法呢?非修到佛地位不可。佛所用的认识方法,是怎样的呢?固非我们所能知。要之是和我们现在所用,大不相同的。这个,我们名之曰证。所以佛教中最后的了义,“惟佛能知”;探求的方法,“惟证相应”。这不是用现在的方法,所能提证据给你看的。信不信只好由你。所以佛教说到最后,总还是一种宗教。\n佛教派别很多,然皆小小异同,现在不必一一论述。其中最有关系的,(一)为天台、惟识、华严三宗。惟识宗亦称相宗,乃就我们所认识的相,阐发万法惟识之义。天台亦称性宗,则系就识的本身,加以阐发。实为一说的两面。华严述菩萨行相,即具体的描写一个菩萨的样子给我们看,使我们照着他做。此三宗,都有很深的教理,谓之教下三家。(二)禅宗则不立文字,直指心源,专靠修证,谓之教外别传。(甲)佛教既不用我们的认识,求最后的解决,而要另换一种认识方法(所谓转识成智),则一切教理上的启发、辩论,都不过把人引上修证之路,均系手段而非目的。所以照佛教发达的趋势,终必至于诸宗皆衰,禅宗独盛为止。(乙)而社会上研究学问的风气,亦是时有转变的。佛教教理的探求,极为烦琐,实与儒家的义疏之学,途径异而性质相同。中唐以后,此等风气,渐渐衰息,诸宗就不得不衰,禅宗就不得不独盛了。(三)然(子)禅宗虽不在教义上为精深的探讨,烦琐的辩论,而所谓禅定,理论上也自有其相当的高深的。(丑)而修习禅定,亦非有优闲生活的人不能。所以仍为有闲阶级所专有。然佛教此时的声势,是非发达到普及各阶层不可的。于是适应大众的净土宗复兴。所谓净土宗,系说我们所住的世界,即娑婆世界的西方,另有一个世界,称为净土。诸佛之中,有一个唤做阿弥陀佛的,与娑婆世界特别有缘。曾发誓愿:有一心皈依他的,到临终之时,阿弥陀佛便来接引他,往生净土。往生净土有什么利益呢?原来成佛极难,而修行的人,不到得成佛,又终不免于退转。如此示人以难,未免使人灰心短气。然(A)成佛之难,(B)以及非成佛则不能不退转,又经前此的教义,说得固定了,无可动摇。于是不得不想出一个补救的方法,说我们所以易于退转,乃因环境不良使然。倘使环境优良,居于其中,徐徐修行,虽成佛依旧艰难,然可保证我们不致中途堕落。这不啻给与我们以成佛的保证,而且替我们祛除了沿路的一切危险、困难,实给意志薄弱的人以一个大安慰、大兴奋。而且净土之中,有种种乐,无种种苦,也不啻给与祈求福报的人以一个满足。所以净土宗之说,实在是把佛教中以前的某种说法取消掉了的。不过其取消之法很巧妙,能使人不觉得其立异罢了。其修持之法,亦变艰难而归简易。其法:为(A)观,(B)想,(C)持名,三者并行,谓之念佛。有一佛像当前,而我们一心注视着他,谓之观。没有时,心中仍想象其有,谓之想。口诵南无阿弥陀佛(自然心也要想着他),谓之持名。佛法贵止观双修。止就是心住于其所应住之处,不起妄念。观有种种方法。如(A)我们最怕死,乃设想尖刀直刺吾胸,血肉淋漓;又人谁不爱女人,乃设想其病时的丑恶,死后的腐朽,及现时外观虽美,而躯壳内种种污秽的情形,以克服我们的情意。(B)又世事因缘复杂,常人非茫无所知,即认识错误,必须仔细观察。如两人争斗,粗观之,似由于人有好斗之性。深观之,则知其实由教化的不善;而教化的不善,又由于生计的窘迫;生计的窘迫,又由于社会组织的不良。如此辗转推求,并无止境。要之观察得愈精细,措施愈不至有误。这是所以增长我们的智识的。止观双修,意义诚极该括,然亦断非愚柔者所能行,净土宗代之以念佛,方法简易,自然可普接利钝了。所以在佛教诸宗皆衰之后,禅宗尚存于上流社会中,净土宗则行于下流社会,到现在还有其遗迹。\n佛教教义的高深,是无可否认的事实。在它,亦有种种治国安民的理论,读《华严经》的五十三参可知。又佛学所争,惟在迷悟。既悟了,一切世俗的事情,仍无有不可做的,所以也不一定要出家。然佛教既视世法皆非了义,则终必至于舍弃世事而后止。以消灭社会为解决社会之法,断非社会所能接受。于是经过相当的期间,而反动又起。\n佛教反动,是为宋学。宋学的渊源,昔人多推诸唐之韩愈。然韩愈辟佛,其说甚粗,与宋学实无多大关系。宋学实至周张出而其说始精,二程继之而后光大,朱陆及王阳明又继之,而其义蕴始尽。\n哲学是不能直接应用的,然万事万物,必有其总根源。总根源变,则对于一切事情的观点,及其应付的方法,俱随之而变。所以风气大转变时,哲学必随之而变更。宋儒的哲学,改变佛学之处安在呢?那就是抹杀认识论不谈,而回到中国古代的宇宙论。中国古代的哲学,是很少谈到认识论的。佛学却不然,所注重的全在乎此。既注重于认识论,而又参以宗教上的悲观,则势必至于视世界为空虚而后止。此为佛教入于消极的真原因。宋学的反佛,其最重要的,就在此点。然从认识论上驳掉佛说,是不可能的。乃将认识论抹杀不谈,说佛教的谈认识论便是错。所以宋学反佛的口号,是“释氏本心,吾徒本天”。所谓本心,即是佛家万法惟识之论。所谓本天,则是承认外界的实在性。万事万物,其间都有一个定理,此即所谓天理。所以宋学的反佛,是以唯物论反对唯心论。\n宋学中自创一种宇宙观和人生观的,有周敦颐、张载、邵雍三人。周敦颐之说,具于《太极图说》及《通书》。他依据古说,假定宇宙的本体为太极。太极动而生阳,静而生阴。动极复静,静极复动。如此循环不已,因生水、火、木、金、土五种物质。此五种物质,是各有其性质的。人亦系此五种物质所构成,所以有智(水)、礼(火)、仁(木)、义(金)、信(土)五种性质。及其见诸实施,则不外乎仁义二者(所以配阴阳)。仁义的性质,都是好的,然用之不得其当,则皆可以变而为恶(如寒暑都是好的,不当寒而寒,不当暑而暑则为恶),所以要不离乎中正(所以配太极),不离乎中正谓之静。所以说:“圣人定之以仁义中正而主静,立人极焉。”张载之说,具于《正蒙》。其说:亦如古代,以气为万物的原质。气是动而不已的。因此而有聚散。有聚散则有疏密。密则为吾人所能知觉,疏则否,是为世俗所谓有无。其实则是隐显。隐显即是幽明。所以鬼神之与人物,同是一气。气之运动,自有其一定的法则。在某种情形之下,则两气相迎;在某种情形之下,则两气相距,是为人情好恶之所由来(此说将精神现象的根源,归诸物质,实为极彻底的一元论)。然此等自然的迎距,未必得当。好在人的精神,一方面受制于物质,一方面仍有其不受制于物质者存。所以言性,当分为气质之性(受制于物质的)与义理之性(不受制于物质的)。人之要务,为变化其气质,以求合乎义理。此为张氏修己之说。张氏又本其哲学上的见地,创万物一体之说,见于其所著的《西铭》。与惠施泛爱之说相近。邵雍之说,与周张相异。其说乃中国所谓术数之学。中国学术,是重于社会现象,而略于自然现象的。然亦有一部分人,喜欢研究自然现象。此等人,其视万物,皆为物质所构成。既为物质所构成,则其运动,必有其定律可求。人若能发现此定律,就能知道万物变化的公例了。所以此等人的愿望,亦可说是希冀发现世界的机械性的。世界广大,不可遍求,然他们既承认世界的规律性,则研究其一部分,即可推之于其余。所以此一派的学者,必重视数。他们的意思,原不过借此以资推测,并不敢谓所推之必确,安敢谓据此遂可以应付事物?然(一)既曾尽力于研求,终不免有时想见诸应用。(二)又此学的初兴,与天文历法,关系极密,古人迷信较深,不知世界的规律性,不易发现,竟有谓据此可以逆臆未来的。(三)而流俗之所震惊,亦恒在于逆臆未来,而不在乎推求定理。所以此派中亦多逆臆未来之论,遂被称为术数之学。此派学者,虽系少数,著名的亦有数家,邵雍为其中之最善者。雍之说,见于《观物内外篇》及《皇极经世书》。《观物篇》称天体为阴阳,地体为刚柔,又各分太少二者(日为太阳,月为太阴。星为少阳,辰为少阴。火为太刚,水为太柔。石为少刚,土为少柔。其说曰:阳燧取于日而得火,火与日相应也。方诸取于月而得水,水与月一体也。星陨为石;天无日月星之处为辰,地无山川之处为土;故以星与石,辰与土相配。其余一切物与阴阳刚柔相配,皆准此理),以说明万物的性质及变化。《皇极经世书》以十二万九千六百年为一元(日之数一为元。月之数十二为会。星之数三百六十为运。辰之数四千三百二十为世。一世三十年。以三十乘四千三百二十,得十二万九千六百)。他说:“一元在天地之间,犹一年也。”这和扬雄作《太玄》,想本一年间的变化,以窥测悠久的宇宙一样。邵雍的宗旨,在于以物观物。所谓以物观物,即系除尽主观的见解,以冀发现客观的真理,其立说精湛处甚多。但因术数之学,不为中国所重视,所以在宋学中不被视为正宗。\n经过周、张、邵诸家的推求,新宇宙观和新人生观可谓大致已定。二程以下,乃努力于实行的方法。大程名颢,他主张“识得此理,以诚敬存之”。但何以识得此理呢?其弟小程名颐,乃替他补充,说“涵养须用敬,进学在致知”。致知之功,在于格物。即万事而穷其理,以求一旦豁然贯通。这话骤听似乎不错的。人家驳他,说天下之物多着呢,如何格得尽?这话也是误解。因为宋儒的所求,并非今日物理学家之所谓物理,乃系吾人处事之法。如曾国藩所谓:“冠履不同位,凤皇鸱鸮不同栖,物所自具之分殊也。鲧湮洪水,舜殛之,禹郊之,物与我之分际殊也。”天下之物格不尽,吾人处事的方法,积之久,是可以知识日臻广博,操持日益纯熟的。所以有人以为格物是离开身心,只是一个误解。问题倒在(一)未经修养过的心,是否能够格物?(二)如要修养其心,其方法,是否以格物为最适宜?所以后来陆九渊出,以即物穷理为支离,要教人先发其本心之明,和赞成小程的朱熹,成为双峰并峙之局。王守仁出,而其说又有进。守仁以心之灵明为知。即人所以知善知恶,知是知非。此知非由学得,无论如何昏蔽,亦不能无存,所以谓之良知。知行即是一事。《大学》说“如恶恶息,如好好色”。知恶臭之恶,好色之好,是知一方面事。恶恶臭,好好色,是行一方面事。人们谁非闻恶臭即恶,见好色即好的?谁是闻恶臭之后,别立一心去恶?见好色之后,别立一心去好?然则“知而不行,只是未知”。然因良知无论如何昏蔽,总不能无存,所以我们不怕不能知善知恶,知是知非,只怕明知之而不肯遵照良心去做。如此,便要在良知上用一番洗除障翳的功夫,此即所谓致知。至于处事的方法,则虽圣人亦有所不能尽知。然苟得良知精明,毫无障翳,当学时,他自会指点你去学;当用人时,他自会指点你去求助于人,正不必以此为患。心之灵明谓之知,所知的自然有一物在。不成天下之物都无了,只剩一面镜子,还说这镜子能照。所以即物穷理,功夫亦仍是用在心上。而心当静止不动时,即使之静止不动,亦即是一种功夫。所以“静处体悟,事上磨炼”,两者均无所不可。程朱的涵养须用敬,进学在致知,固然把道德和知识,分成两截。陆九渊要先发人本心之明,亦不过是把用功的次序倒转了,并没有能把两者合而为一。王守仁之说,便大不相同了。所以理学从朱陆到王,实在是一个辩证法的进步。但人之性质,总是偏于一方面的,或喜逐事零碎用功夫,或喜先提挈一个大纲。所以王守仁之说,仍被认为近于陆九渊,并称为陆王。人的性质,有此两种,是一件事实,是一件无可变更的事实。有两种人自然该有两种方法给他们用,而他们亦自然会把事情做成两种样子。所以章学诚说:“朱陆为千古不可无之同异,亦为千古不能无之同异。”(见《文史通义·朱陆篇》),其说最通。\n以一种新文化,替代一种旧文化,此新文化,必已兼摄旧文化之长,此为辩证法的真理。宋学之于佛学,亦系如此。宋学兼摄佛学之长,最显著的有两点:(一)为其律己之严,(二)为其理论的彻底。论治必严王霸之辨,论人必严君子小人之分,都系由此而出。此等精严的理论,以之律己则可,以之论事,则不免多所窒碍。又宋学家虽反对佛教的遗弃世事,然其修养的方法,受佛教的影响太深了。如其说而行之,终不免偏于内心的修养,甚至学问亦被抛荒,事为更不必说,所以在宋代,宋学中的永嘉、永康两派,就对此而起反动(永嘉派以叶适、陈傅良为魁首。反对宋学的疏于事功,疏于实学的考究。永康派以陈亮为魁首,对于朱熹王霸之辨,持异议颇坚,亦是偏于事功的)。到清代,颜元、李塨一派,亦是对此力加攻击的。然永嘉、永康两派和朱陆,根本观念上,实无甚异同,所争的只是程度问题,无关宏指。颜李一派,则专讲实务,而反对在心上用功夫,几乎把宋学根本取消了。近来的人,因反对中国的学者,多尚空言,而不能实行,颇多称道颜李的。然颜、李的理论,实极浅薄,不足以自成一军。因为世界进步了,分工不得不精。一件事,合许多人而分工,或从事于研究,或从事于实行,和一个人幼学壮行,并无二致。研究便是实行的一部。颜、李之说,主张过甚,势必率天下人而闭目妄行。即使主张不甚,亦必变精深为浅薄。所以其说实不能成立。从理论上反对宋儒的,还有戴震。谓宋儒主张天理人欲之辨太过,以致(一)不顾人情。视斯民饮食男女之欲,为人生所不能无的,都以为毫无价值而不足恤。(二)而在上者皆得据理以责其下,下情却不能上达,遂致有名分而无是非,人与人相处之道,日流于残酷。此两端:其前一说,乃宋学末流之弊,并非宋学的本意。后一说则由宋学家承认封建时代的秩序,为社会合理的组织之故。戴氏攻之,似得其当。然戴氏亦不知其病根之所在,而说只要舍理而论情,人与人的相处,就可以无问题,其说更粗浅牵强了。在现在的文化下所表现出来的人情,只要率之而行,天下就可以太平无事么?戴氏不是盲目的,何以毫无所见?\n所以宋学衰敝以后,在主义上,能卓然自立,与宋学代兴的,实无其人。梁启超说:清代的学术,只是方法运动,不是主义运动(见所著《清代学术概论》),可谓知言了。质实言之,清代考证之学,不过是宋学的一支派。宋学中陆王一派,是不讲究读书的,程朱一派本不然。朱子就是一个读书极博的人。其后学如王应麟等,考据尤极精审。清学的先驱,是明末诸大儒。其中顾炎武与清代考证之学,关系尤密,也是程朱一派(其喜言经世,则颇近永嘉)。清代所谓纯汉学,实至乾嘉之世而后形成,前此还是兼采汉宋,择善而从的。其门径,和宋学并无区别。清学的功绩,在其研究之功渐深,而日益趋于客观。因务求古事的真相,觉得我们所根据的材料,很不完全,很不正确;材料的解释,又极困难。乃致力于校勘;致力于辑佚;对于解释古书的工具,即训诂,尤为尽心。其结果,古书已佚而复见的,古义已晦而复明的不少,而其解决问题的方法,亦因经验多了,知道凭臆判断,自以为得事理之平,远不如调查其来路,而凭以判断者之确。于是折衷汉宋,变为分别汉宋,其主意,亦从求是变而为求真了(非不求是,乃以求真为求是)。清学至此,其所至,已非复宋儒所能限,然仍是一种方法的转变,不足以自成一军。\n清学在宗旨上,渐能脱离宋学而自立,要到道咸时今文之学兴起以后。西汉经师之说,传自先秦,其时社会的组织,还和秦汉以后不同。有许多议论,都不是东汉以后人所能了解的。自今文传授绝后,久被搁置不提了。清儒因分别学派,发现汉儒亦自有派别,精心从事于搜剔,而其材料始渐发现,其意义亦渐明白。今学中有一派,专务材料的搜剔,不甚注意于义理。又一派则不然,常州的庄(存与)、刘(逢禄),号称此学的开山,已渐注意于汉儒的非常异义。龚(自珍)、魏(源)两氏继之,其立说弥以恢廓。到廖平、康有为出,就渐渐地引上经世一路,非复经生之业了。\n廖平晚岁的学说,颇涉荒怪。然其援据纬说,要把孔道的规模,扩充到无限大,也仍是受世变的影响的。但廖氏毕竟是个经生。其思想,虽亦受世变的影响,而其所立之说,和现代的情形,隔膜太甚。所以对于学术界,并不能发生多大的影响。康氏就不然了。康氏的经学远不如廖氏的精深。然其思想,较之廖氏,远觉阔大而有条理。他怀抱着一种见解,借古人之说为材料而说明之。以《春秋》三世之义,说明进化的原理,而表明中国现在的当改革,而以孔子托古改制之说辅之。其终极的目的,则为世界大同。恰和中国人向来怀抱的远大的思想相合,又和其目前急须改革的情形相应,所以其说能风靡一时。但康有为的学说,仍只成为现代学术思想转变的一个前驱。这是为什么呢?因为学术是利于通的,物穷则变,宋明以来的文化,现在也几乎走到尽头了。即使没有西学输入,我们的学术思想,也未必不变。但既与西洋学术相值,自然乐得借之以自助。何况现在西洋的科学方法,其精密,确非我们之所及呢?所以近数十年来,中国学术思想的变化,虽然靠几个大思想家做前驱,而一经发动之后,其第二步,即继之以西洋学术的输入。和中国学术思想的转变最有关系的人是梁启超。梁氏的长处:在其(一)对于新科学,多所了解。(二)最能适应社会的程度,从事于介绍。(三)且能引学说以批评事实,使多数人感觉兴味。即此趋势的说明。\n西洋学术输入以来,中国人对之之态度,亦经数变。(一)其初是指采用西法者为用夷变夏,而极力加以排斥的。(二)继则变为中学为体,西学为用。(三)再进一步,就要打倒孔家店,指旧礼教为吃人,欢迎德谟克拉西先生、赛因斯先生,并有主张全盘西化的了。其实都不是这么一回事。欧美近代,最初发达的是自然科学。因此引起的整个学术思想的变化(即对于一切事情的观点及其应付方法),较诸旧说,都不过是程度问题。后来推及社会科学亦然。现在文化前途的改变,乃是整个社会组织的改变,并非一枝一节的问题。这个问题,乃中国与西洋之所同,而非中国之所独。具体言之,即是中国与西洋,以及全世界的各民族,都要携手相将,走上一条新的径路。其间固无一个民族,能够守旧而不变,也断非哪一个民族,尽弃其所固有,而仿效别一个民族的问题。因为照现状,彼此都是坏的,而且坏得极相像。然则各种学术,能指示我们以前途,且成为各学之王,而使他种学术,奔走其下,各尽其一枝一节之用的,必然是社会学。一切现象,都是整个社会的一枝一节,其变化,都是受整个社会的规定的。惟有整个社会,能说明整个社会。亦惟有整个社会,能说明一枝一节的现象的所以然。人们向来不知,只是把一枝一节的现象,互相说明,就错了。这是因为从前的人,不知道整个社会,可成为研究的对象,所以如此。现在便不同了。所以只有最近成立的社会学,为前此之所无。亦只有整个的社会学,能够说明文化之所由来,而评判其得失,而指示我们以当走的路径。即如文明愈进步,则风俗愈薄恶,这是一件众所周知的事实,而亦是向来所视为无可如何的事实。毁弃文明,固不可,亦不能。任社会风俗之迁流,而日趋于薄恶,也不是一回事。提倡道德,改良政治等,则世界上无论哪一个文明国,都已经努力了几千年,而证明其无效的了。人道其将终穷乎?从社会学发明以来,才知道风俗的薄恶,全由于社会组织的不良,和文明进步,毫无关系。我们若能把社会组织,彻底改良,则文明进步,就只有增加人类的福利了。这是社会学指示给我们前途最大的光明。而社会学之所以能发明,则和现代各地方蛮人风俗的被重视,以及史前史的发现,有极大的关系。因此,我们才知道社会的组织,可以有多种。目前的组织,只是特种事实所造成,并非天经地义,必不可变。变的前途,实有无限的可能。变的方法,我们所知道的,亦与前人迥异了。\n以上论中国学术思想转变的大概,以下再略论中国的文学和史学。\n文学的发达,韵文必先于散文,中国古代,亦系如此。现存的先秦古书,都分明包含着两种文字:一种是辞句整齐而有韵的。一种则参差不齐,和我们的口语一样。前者是韵文,后者是散文。散文的发达,大约在东周之世,至西汉而达于极点。散文发达了,我们的意思,才能够尽量倾吐(因为到这时候,文字和语言,才真正一致),所以是文学的一个大进步。西汉末年,做文章的,渐渐求其美化。其所谓美是:(一)句多偶丽,(二)不用过长过短之句,(三)用字务求其足以引起美感。其结果,逐渐成汉魏体的骈文。汉魏体的骈文,只是字句修饰些,声调啴缓些,和散文相去,还不甚远。以后一直向这趋势发达,至齐梁时代,遂浮靡而不能达意了。此时供实用之文,别称为笔。然笔不过参用俗字俗语;用字眼、用典故,不及文来得多;其语调还和当时的文相近,与口语不合,还是不适于用。积重之势,已非大改革不可。改革有三条路可走:(一)径用口语。这在昔日文字为上中流社会所专有的时代,是不行的。(二)以古文为法。如苏绰的拟《大诰》是。这还是不能达意。只有第(三)条路,用古文的义法(即文字尚未浮靡时的语法),以运用今人的言语,是成功的。唐朝从韩、柳以后,才渐渐地走上这条路。散文虽兴,骈文仍自有其用,骈散自此遂分途。宋朝为散文发达的时代。其时的骈文,亦自成一格。谓之宋四六。气韵生动,论者称为骈文中的散文。\n诗歌另是一体。文是导源于语言,诗是导源于歌谣的。所以诗体,当其发生之时,即非口语之调。近人以随意写来的散文,亦称为诗(新诗),这至少要改变向来诗字的定义然后可。古代的诗,大抵可歌。传于后世的,便是《诗经》和《楚辞》。到汉朝,风尚变了。制氏雅乐虽存,不为人之所好。汉武帝立新声乐府,采赵、代、秦、楚之讴,使李延年协其律,司马相如等为之辞,是为汉代可歌的诗。古代的诗,则变为五言诗,成为只可吟诵之物。论者多以此为诗体的退化,这是为尊古之见所误。其实凡事都到愈后来愈分化。吟诵的诗,和合乐的诗的判而为二,正是诗体的进化。歌唱的音调,和听者的好尚的变迁,是无可如何的事。隋唐时,汉代的乐府,又不为人之所好,而其辞亦渐不能合乐了。听者的好尚,移于外国传来的燕乐。按其调而填词,谓之词。极盛于两宋之世。至元以后,又渐成为但可吟诵,不能协律之作,而可歌的限于南北曲。到清朝,按曲谱而填词的,又多可诵而不可歌了。中国的所谓诗,扩而充之,可以连乐府、词、曲,都包括在内。因为其起源,同是出于口中的歌的。一个民族的歌谣,不容易改变。试看现代的山歌,其音调,还与汉代的乐府一样,便可知道。所以现在,非有新音乐输入,诗体是不会变化的。现在万国交通,新音乐输入的机会正多。到我国人的口耳,与之相习,而能利用之以达自己的美感时,新诗体就可产生了。\n文学初兴之时,总是与语言相合的。但到后来,因(一)社会情形的复杂,受教育的程度,各有不同;(二)而时间积久了,人的语言,不能不变,写在纸上的字,却不能再变;言文就渐渐的分离了。合于口语的文字,是历代都有的。如(一)禅宗和宋儒的语录。(二)元代的诏令。(三)寒山、拾得的诗。(四)近代劝人为善的书都是。(五)而其用之,要以平话为最广。这是非此不可的。从前文言、白话,各有其分野,现在却把白话的范围推广了。这因(一)受教育的人渐多,不限于有闲阶级;而所受的教育,亦和从前不同,不能专力于文字。(二)世变既亟,语言跟着扩充、变化,文字来不及相随,乃不得不即用口语。此乃事势的自然,无人提倡,也会逐渐推广的。守旧的人,竭力排斥白话,固然是不达。好新的人,以此沾沾自喜,也是贪天之功,以为己力的。所谓古文,大部分系古代的言语。其中亦有一部分,系后人依据古代的语法所造的,从未宣诸唇吻,只是形之楮墨。然楮墨之用,亦系一种广义的语言。既有其用,自不能废。而况纸上的言语,有时亦可为口语所采用。所以排斥文言,也是偏激之论。\n文以载道,文贵有用之说,极为近人所诋毁。此说固未免于迂,然亦不能谓其全无道理。近人袭西洋文学的理论,贵纯文学而贱杂文学,这话固然不错。然以为说理论事之作,必是杂文学,必写景言情之作,而后可以谓之纯文学,则是皮相之谈。美的原质,论其根柢,实在还是社会性。社会性有从积极方面流露的,如屈原、杜甫的忠爱是。有从消极方面流露的,如王维、孟浩然的闲适是。积极的人人所解,消极的似乎适得其反,其实不然。积极的是想把社会改好,消极的则表示不合作。虽然无所作为,然(一)使人因此而悟现社会之坏,(二)至少亦使社会减少一部分恶势力,其功效也还是一样的。文字上的所谓美,表面上虽若流连风景,其暗中深处,都藏有这一个因素在内。诗词必须有寄托,才觉得有味,真正流连风景的,总觉得浅薄,就是为此。然则文字的美恶,以及其美的程度,即视此种性质之有无多寡以为衡,其借何种材料而表现,倒是没有关系的。忧国忧民,和风花雪月,正是一样。以说理论事,或写景言情,判别文学的为纯为杂,又是皮相之谈了。文以载道,文贵有用等说,固然不免于迂腐。然载道及有用之作,往往是富于社会性的,以此为第一等文字,实亦不为无见,不过抛荒其美的方面,而竟以载道和有用为目的,不免有语病罢了。\n中国的有史籍甚早。《礼记·玉藻》说:“动则左史书之,言则右史书之。”郑《注》说:“其书,《春秋》、《尚书》其存者。”(《汉书·艺文志》说“右史记事,左史记言”是错的。《礼记·祭统》说“史由君右,执策命之”,即右史记言之证)这大约是不错的。《周官》还有小史,记国君及卿大夫的世系,是为《帝系》及《世本》。我国最古的史籍《史记》,其本纪及世家,似系据《春秋》和《系》、《世》编纂而成。列传则源出记言之史。记言之史,称为《尚书》。乃因其为上世之书而得此名,其原名似称为语。语之本体,当系记人君的言语,如现在的训辞讲演之类。后来扩充之,则及于一切嘉言。嘉言的反面是莠言,间亦存之以昭炯戒。记录言语的,本可略述其起因及结果,以备本事。扩充之则及于一切懿行,而其反面即为恶行。此体后来附庸蔚为大国,名卿大夫,及学术界巨子,大抵都有此等记录,甚至帝王亦有之。其分国编纂的,则谓之《国语》。关于一人的言行,分类编纂的,则谓之《论语》。记载一人的大事的,则如《礼记·乐记》,述武王之事,谓之《牧野之语》都是。《史记》的列传,在他篇中提及,多称为语(如《秦本纪》述商鞅说孝公变法事曰:“其事在《商君语》中。”),可见其源出于语,推而广之,则不名为语的,其实亦系语体。如《晏子春秋》及《管子》中的《大》、《中》、《小匡》等是。八书是记典章经制的,其源当亦出于史官,不过不能知其为何官之史罢了。史官以外,还有民间的传述。有出于学士大夫之口的,如魏绛、伍员述少康、羿、浞之事是(见《左传》襄公四年、哀公元年,及《史记·吴太伯世家》)。亦有出于农夫野老之口的,如孟子斥咸丘蒙所述为齐东野人之语是。古史的来源,大略如此。秦始皇烧书,《史记·六国表》说“诸侯史记尤甚”,大约史官所记载,损失极多。流行民间之书,受其影响当较少。日耳相传,未著竹帛的,自然更不必说了。\n有历史的材料是一事,有史学上的见地,又系一事。古代史官,虽各有专职,然大体不过奉行故事。民间传达,或出惊奇之念,或出仰慕之忱(所谓多识前言往行,以蓄其德),亦说不上什么史学上的见地。到司马谈、迁父子出,才网罗当时所有的史料,编纂成一部大书。这时的中国,在当时人的眼光中,实已可谓之天下(因为所知者限于此。在所知的范围中,并没有屏斥异国或异族的史料不载)。所以《太史公书》(这是《史记》的本名。《汉书·艺文志》著录即如此。《史记》乃史籍通名,犹今言历史。《太史公书》,为史部中最早的著述,遂冒其一类的总名),实自国别史进于世界史,为史体一大进步。\n从此以后,国家亦渐知史籍的重要了。后汉以后,乃有诏兰台、东观中人述作之事。魏晋以后,国家遂特设专官。此时作史的,在物力上,已非倚赖国家不行(一因材料的保存及搜辑,一因编纂时之费用)。至于撰述,则因材料不多,还为私人之力所能及。所以自南北朝以前,大率由国家供给材料及助力,而司编撰之事的,则仍系一二人,为私家著述性质。唐以后史料更多,不徒保存、搜辑,即整理、排比,亦非私人之力所及,于是独力的著述,不得不变为集众纂修之局了。私家著述及集众纂修,昔人的议论,多偏袒前者,这亦是一偏之见。姑无论材料既多,运用为私人之力所不及。即舍此勿论,而昔时的正史,包括的门类很多,亦非一人所能兼通。所以即就学术方面论,二者亦各有长短。唐修《新晋书》(即今正史中的《晋书》),其志非前人所能及,即其一证。关于正史的历史,可参看《史通》的《六家》、《二体》、《古今正史》、《史官建置》各篇,及拙撰《史通评》中这几篇的评(商务印书馆本)。\n从前的历史,系偏重于政治方面的。而在政治方面,则所注重的,为理乱兴衰、典章经制两类。正史中的纪传,是所以详前者的,志则所以详后者。已见《绪论》中。编年史偏详前者。《通典》、《通考》一类的书,则偏详后者,都不如纪传表志体的完全。所以后来功令,独取纪传表志体为正史。然编年体和政书(《通典》、《通考》等),在观览上亦各有其便,所以其书仍并为学者所重。这是中国旧日所认为史部的重心的。纪传体以人为单位,编年史以时为系统,欲勾稽一事的始末,均觉不易。自袁枢因《通鉴》作《纪事本末》后,其体亦渐广行。\n中国的史学,在宋时,可谓有一大进步。(一)独力著成一史的,自唐以后,已无其事。宋则《新五代史》出欧阳修一人;《新唐书》虽出修及宋祁两人,亦有私家著述性质,事非易得。(二)编年之史,自三国以后,久已废阙。至宋则有司马光的《资治通鉴》,贯串古今。朱嘉的《通鉴纲目》,叙事虽不如《通鉴》的精,体例却较《通鉴》为善(《通鉴》有目无纲,检阅殊为不便。司马光因此,乃有《目录》之作,又有《举要》之作。《目录》既不与本书相附丽。《举要》则朱子《答潘正叔书》,讥其“详不能备首尾,略不可供检阅”,亦系实情。所以《纲目》之作,确足以改良《通鉴》的体例)。(三)讲典章经制的书,虽起于唐杜佑的《通典》,然宋马端临的《文献通考》,搜集尤备,分类亦愈精。又有会要一体,以存当代的掌故,并推其体例,以整理前代的史实。(四)郑樵《通志》之作,网罗古今。其书虽欠精审,亦见其魄力之大。(五)当代史料,搜辑綦详。如李焘《续资治通鉴长编》、李心传《建炎以来系年要录》、徐梦莘《三朝北盟会编》、王偁《东都纪略》等都是。(六)自周以前的古史,实系别一性质。至宋而研究加详。如刘恕《通鉴外纪》、金履祥《通鉴纲目前编》、苏辙《古史考》、胡宏《皇王大纪》、罗泌《路史》等都是。(七)研究外国史的,宋朝亦加多。如叶隆礼《契丹国志》、孟珙《蒙鞑备录》等是。(八)考古之学,亦起于宋时。如欧阳修的《集古录》、赵明诚的《金石录》等,始渐求材料于书籍之外。(九)倪思的《班马异同评》、吴缜的《新唐书纠谬》等,皆出于宋时。史事的考证,渐见精核。综此九端,可见宋代史学的突飞猛进。元明时代复渐衰。此因其时之学风,渐趋于空疏之故。但关于当代史料,明人尚能留心收拾。到清朝,文字之狱大兴,士不敢言当代的史事;又其时的学风,偏于考古,而略于致用;当代史料,就除官书、碑传之外,几乎一无所有了。但清代考据之学颇精。推其法以治史,能补正前人之处亦颇多。\n研究史法之作,专著颇少。其言之成理,而又有条理系统的,当推刘知幾的《史通》。《史通》是在大体上承认前人的史法为不误,而为之弥缝匡救的。其回到事实上,批评历代的史法,是否得当;以及研究今后作史之法当如何的,则当推章学诚。其识力实远出刘知幾之上。此亦时代为之。因为刘知幾之时,史料尚不甚多,不虑其不可遍览,即用前人的方法撰述已足。章学诚的时代,则情形大不同,所以迫得他不得不另觅新途径了。然章氏的识力,亦殊不易及。他知道史与史材非一物,保存史材,当务求其备,而作史则当加以去取;以及作史当重客观等(见《文史通义·史德篇》),实与现在的新史学,息息相通。不过其时无他种科学,以为辅助。所以其论不如现在新史学的精审罢了。然亦不过未达一间而已,其识力亦很可钦佩了。\n第五十四章 宗教 # 宗教的信仰,是不论哪一个民族都有的。在浅演之时固然,即演进较深之后,亦复如此。这是因为:学问之所研究,只是一部分的问题,而宗教之所欲解决,则为整个的人生问题。宗教的解决人生问题,亦不是全不顾知识方面的。它在感情方面,固然要与人以满足。在知识方面,对于当时的人所提出的疑问,亦要与以一个满意的解答。所以一种宗教,当其兴起之时,总是足以解决整个人生问题的。但既兴起之后,因其植基于信仰,其说往往不易改变;而其态度亦特别不宽容;经过一定时期之后,遂成为进化的障碍,而被人斥为迷信。\n宗教所给与人的,既是当下感情上和知识上的满足,其教义,自然要随时随地而异。一种宗教,名目未变,其教义,亦会因环境而变迁。原始的人,不知道自然界的法则。以为凡事都有一个像人一般的东西,有知识,有感情,有意志,在暗中发动主持着。既不知道自然界的法则,则视外界一切变化,皆属可能。所以其视环境,非常之可畏怖。而其视其所祈求的对象,能力之大,尤属不可思议。有形之物,虽亦为其所崇拜,然其所畏怖而祈求的,大概非其形而为寓于其中的精灵。无形可见之物,怎会令人深信不疑呢?原来古人不知道生物与无生物之别,更不知道动物与植物、人与动物之别,一切都看做和自己一样,而人所最易经验到而难于解释的,为梦与死。明明睡在这里没有动,却有所见,有所闻,有所作为;明明还是这个人,而顷刻之间,有知已变为无知了,安得不相信人身之中,别有一物以为之主?既以为人是如此,就推之一切物,以为都是如此了。这是我们现在,相信人有灵魂;相信天地、日月、山川等,都有神为之主;相信老树、怪石、狐狸、蛇等,都可以成为精怪的由来。虽然我们现在,已知道自然界的法则了;知道生物与无生物、动物与植物、人与其他动物之别了;然此等见解,根株仍未拔尽。\n●摩尼教石刻像 位于福建晋江草庵,该寺建于元顺帝至元五年(1339年)。石佛高1.52米,宽0.83米,佛龛直径1.98米\n人类所崇拜的灵界,其实是虚无缥缈的,都是人所想象造作出来的。所以所谓灵界,其实还是人间世界的反映。人类社会的组织变化了,灵界的组织,也是要跟着变化的。我们现在所看得到的,其第一步,便是从部族时代进于封建时代的变化。部族的神,大抵是保护一个部族的,和别一个部族,则处于敌对的地位。所以《左传》僖公十年说:“神不歆非类,民不祀非族。”孔子也说:“非其鬼而祭之,谄也。”(《论语·为政》)到封建时代,各个神灵之间,就要有一个联系。既要互相联系,其间自然要生出一个尊卑等级来。在此时代,宗教家所要做的工作就是:(一)把神灵分类。(二)确定每一类之中,及各类之间尊卑等级的关系。我们在古书上看得见的,便是《周官》大宗伯所分的(一)天神,(二)地祇,(三)人鬼,(四)物魁四类。四类相互之间,自然天神最尊,地祇次之,人鬼次之,物魁最下。天神包括日月、星辰、风雨等。地祇包括山岳、河海等。但又有一个总天神和总地祇。人鬼:最重要的,是自己的祖宗。其余一切有功劳、有德行的人,也都包括在内。物魁是列举不尽的。天神、地祇、人鬼等,都是善性居多。物魁则善恶无定。这是中国人最普通的思想,沿袭自几千年以前的。宗教发达到这一步,离一般人就渐渐地远了。“天子祭天地,诸侯祭其境内名山大川”(《礼记·王制》),和一般人是没有关系的。季氏旅于泰山,孔子就要讥其非礼了(《论语·八佾》),何况平民?昊天上帝之外,还有主四时化育的五帝:东方青帝灵威仰,主春生。南方赤帝赤熛怒,主夏长。西方白帝白招拒,主秋成。北方黑帝汁光纪,主冬藏。中央黄帝含枢纽,则兼主四时化育。每一朝天子的始祖,据说实在是上帝的儿子。譬如周朝的始祖后稷,他的母亲姜嫄,虽说是帝喾之妃,后稷却不是帝喾的儿子。有一次,姜嫄出去,见一个大的足印。姜嫄一只脚,还不如他一个拇指大。姜嫄见了,觉得奇怪。把自己的脚,在这足印里踏踏看呢。一踏上去,身体就觉得感动。从此有孕了。生了一个儿子,就是后稷。又如商朝的始祖契。他的母亲简狄,也是帝喾之妃,然而契也不是帝窖的儿子。简狄有一次,到河里去洗澡,有一只玄鸟,掉下一个卵来。简狄取来吞下去,因此有孕了。后来就生了契。这个谓之“感生”(见《诗·生民》及《玄鸟》。《史记·殷周本纪》述契、后稷之生,即系《诗说》。《周官》大宗伯,以禋祀祀昊天上帝。小宗伯,兆五帝于四郊。郑玄谓天有六,即五帝和昊天上帝耀魄宝。可看《礼记·祭法疏》,最简单明了。五帝之名,虽出纬候,然其说自系古说。所以《礼记·礼运》:“因名山以升中于天,因吉土以飨帝于郊”已经把天和帝分说了)。契、稷等因系上帝之子,所以其子孙得受命而为天子。按诸“神不歆非类,民不祀非族”之义,自然和平民无涉的,用不着平民去祭。其余如“山林、川谷、丘陵,能出云,为风雨,见怪物”,而且是“民所取材用”的(《礼记·祭法》),虽和人民有关系。然因尊卑等级,不可紊乱之故,也就轮不着人民去祭了。宗教发达到此,神的等级愈多,上级的神,威权愈大,其去一般人却愈远,正和由部族之长,发展到有诸侯,由列国并立的诸侯,进步到一统全国的君主,其地位愈尊,而其和人民相去却愈远一样。\n人,总是实际主义的。所敬畏的,只会是和自己切近而有关系的神。日本田崎仁义所著《中国古代经济思想及制度》说:古代宗教思想,多以生物之功,归之女性;又多视日为女神。中国古代,最隆重的是社祭(《礼记·郊特牲》说:“惟为社事,单出里。惟为社田,国人毕作。惟社,丘乘共粢盛。”单同殚)。而这所谓社,则只是一地方的土神(据《礼记·祭法》,王、诸侯、大夫等,均各自立社),并不是与天神相对的后土。《易经·说卦传》离为日,为中女。《山海经》和《淮南子》,以生日驭日的羲和为女神(《山海经·大荒南经》:“东南海之外,甘水之间,有羲和之国。有女子,名羲和,方浴日于甘渊。羲和者,帝俊之妻,生十日。”《淮南子·天文训》:“至于悲泉,爰止其女,爰息其马,是谓县车。”)而《礼记·郊特牲》说,郊之祭,乃所以迎“长日之至”。可见以郊祭为祭天,乃后起之事,其初只是祭日;而祭日与祭社,则同是所以报其生物之功。后来虽因哲学观念的发达,而有所谓苍苍者天,抟抟者地,然这整个的天神和整个的地神,就和人民关系不切了,虽没有政治上“天子祭天地”的禁令,怕也不会有什么人去祭它的。日月星辰风雨等,太多了,祭不胜祭;亦知道其所关涉者广,用不着一地方去祭它。只有一地方的土神,向来视为于己最亲的,其祭祀还相沿不废。所以历代以来,民间最隆重的典礼是社祭,最热闹的节场是作社。还有所谓八蜡之祭,是农功既毕之后,举凡与农事有关之神,一概祭飨它一次(见《郊特牲》)。又古代视万物皆有神,则有所谓中,有所谓门,有所谓行,有所谓户,有所谓灶(均见《祭法》),此等崇拜,倒也有残留到后世的。又如古代的司命,是主人的生死的(司命亦见《祭法》。《庄子·至乐》云:“庄子之楚,见髑髅而问之。夜半,髑髅见梦。庄子曰:吾使司命复生子形,为子骨肉肌肤”,知古谓人生死,皆司命主之)。后世则说南斗主生,北斗主死,所以南北斗去人虽远,倒也有人崇拜它。诸如此类,悉数难终。总之于人有切近的关系的,则有人崇拜,于人无切近的关系的,则位置虽高,人视之,常在若有若无之间。现在人的议论,都说:一神教比多神教进化,中国人所崇拜的对象太杂,所以其宗教,还是未甚进化的。其实不然。从前俄国在专制时代,人民捐一个钱到教堂里去,名义上也要以俄皇的命令允许的。这和佛教中的阿弥陀佛有一个人皈依他,到临死时,佛都自己来接引他到净土去一样。中国的皇帝,向来是不管小事的,所以反映着人间社会而成的灵界组织,最高的神,亦不亲细务。假使中国宗教上的灵界组织,是以一个大神,躬亲万事的,中国人也何尝不会专崇拜这一个神?然而崇拜北斗,希冀长生,和专念阿弥陀佛,希冀往生净土的,根本上有什么区别呢?若说一神教的所谓一神,只是一种自然力的象征,所以崇拜一神教的,其哲学上的见地,业已达于泛神论了,要比多神教高些。则崇拜一神教的,都是当他自然力的象征崇拜的么?老实说:泛神论与无神论,是一而二,二而一的。真懂得泛神论的,也就懂得无神的意义,不会再有现在宗教家的顽固见解了。\n较神的迷信进一步的,则为术。术数二字,古每连称,其实二者是不同的,已见上章。术之起源,由于因果的误认。如说做一个木人,或者束一个草人,把他当做某人,用箭去射他,就会使这个人受伤。又如把某人贴身之物,加以破坏,就能使这个人受影响之类。苌弘在周朝,把狸首象征不来的诸侯去射它,以致为晋人所杀(见《史记·封禅书》)。豫让为赵襄子所擒,请襄子之衣,拔剑三跃而击之,衣尽出血,襄子回车,车轮未周而亡。就是此等见解。凡厌胜咒诅之术,均自此而出。又有一种,以为此地的某种现象,与彼地的某种现象;现在的某种现象,和将来的某种现象,有连带关系的。因欲依据此时此地的现象,以测知彼时彼地的现象,是为占卜之术所自始。此等都是所谓术。更进一步则为数。《汉书·艺文志》说形法家之学道:“形人及六畜骨法之度数,器物之形容,以求其声气贵贱吉凶,犹律有长短,而各征其声,非有鬼神,数自然也。”全然根据于目可见、身可触的物质,以说明现象的原因,而否认目不可见的神秘之说,卓然是科学家的路径。惜乎这种学派中人,亦渐渐地枉其所信,而和术家混合为一了。《汉志·术数略》,共分六家:曰天文、曰历谱、曰五行、曰蓍龟、曰杂占、曰形法。蓍龟和杂占,纯粹是术家言。天文、历谱、五行、形法都饶有数的意味,和术家混合了,为后世星相之学所自出。\n中国古代所崇拜的对象,到后世,都合并起来,而被收容于道教之中。然所谓道教,除此之外,尚有一个元素,那便是神仙家。当春秋战国时,就有所谓方士者,以不死之说,诳惑人主。《左传》昭公二十年,齐景公问于晏子,说“古而无死,其乐何如”?古代无论哲学、宗教,都没有持不死之说的,可见景公所问,为受神仙家的诳惑了。此后齐威宣王、燕昭王,亦都相信它(见于《史记·封禅书》),而秦始皇、汉武帝信之尤笃,其事为人人所知,无烦赘述了。事必略有征验,然后能使人相信。说人可不死,是最无征验的。齐景公等都系有为之主,何以都为所蛊惑呢?以我推测,因燕齐一带,多有海市。古人明见空中有人物城郭宫室,而不知其理,对于神仙之说,自然深信不疑了。神仙家,《汉志》列于方技,与医经、经方、房中并列。今所传最古的医书《素问》,中亦多载方士之言。可见方士与医药,关系甚密。想藉修炼、服食、房中等术,以求长生,虽然误缪,要不能视为迷信。然此派在汉武时,就渐渐的和古代的宗教混合了。汉武时,所谓方士,实分两派:一派讲炼丹药,求神仙,以求长生。一派则从事祠祭以求福。其事具见于《史记·封禅书》、《汉书·郊祀志》。《郊祀志》所载各地方的山川,各有其当祭之神,即由献其说的方士主持。此乃古代各部族的宗教,遗留到后世的。《山海经》所载,某水某山有某神,当用何物祠祭,疑即此等方士所记载。此派至元帝后,多被废罢;求神仙一派,亦因其太无效验,不复为时主所信,乃转而诳惑人民。其中规模最大的,自然是张角。次之则是张鲁。他们也都讲祠祭。但因人民无求长生的奢望,亦无炼金丹等财力(依《抱朴子》讲,当时方士炼丹,所费甚巨。葛洪即自憾无此资财,未能从事),所以不讲求神仙,而变为以符咒治病了。符咒治病,即是祝由之术,亦古代医术中的一科。其牵合道家之学,则自张鲁使其下诵习《老子》五千言始。张鲁之道,与老子毫无干涉,何以会使人诵习《老子》呢?依我推测,大约因汉时以黄、老并称,神仙家自托于黄帝,而黄帝无书,所以牵率及于老子。张鲁等的宗教,有何理论可讲?不过有一部书,以资牵合附会就够了,管什么实际合不合呢?然未几,玄学大兴,《老子》变为时髦之学,神仙家诳惑上流社会的,亦渐借其哲理以自文。老子和所谓方士,所谓神仙家,就都生出不可分离的关系来了。此等杂多的迷信,旁薄郁积,毕竟要汇合为一的。享其成的,则为北魏时的寇谦之。谦之学张鲁之术,因得崔浩的尊信,言于魏明元帝而迎之,尊之为天师,道教乃成为国家所承认的宗教,俨然与儒释并列了。此事在民国纪元前1489年,公元423年(刘宋少帝景平元年,魏明元帝泰常八年)。后世谈起道教来,均奉张陵为始祖。陵乃鲁之祖父。据《后汉书》说:陵客蜀,学道于鹄鸣山中。受其道者,辄出米五斗,故谓之米贼,陵传子衡,衡传于鲁。然其事并无证据。据《三国志》《注》引《典略》,则为五斗米道的,实系张修。修乃与鲁并受命于刘焉,侵据汉中,后来鲁又袭杀修而并其众的。鲁行五斗米道于汉中,一时颇收小效。疑其本出于修,鲁因其有治效而沿袭之,却又讳其所自出,而自托之于父祖。历史,照例所传的,是成功一方面的人的话,张陵就此成为道教的始祖了。\n从外国输入的宗教,最有权威的,自然是佛教。佛教的输入,旧说都以为在后汉明帝之世。说明帝梦见金人,以问群臣,傅毅对以西方有圣人,乃遣郎中蔡愔、博士弟子秦景等使于天竺。得佛经四十二章,及释迦立像,与沙门摄摩腾、竺法兰,以白马负经而至。因立白马寺于洛城西。此乃因其说见于《魏书·释老志》,以为出于正史之故。梁启超作《佛教之初输入》,考此说出于西晋道士王浮的《老子化胡经》,其意乃欲援释入道,殊为妖妄。然《魏书》实未以金人入梦,为佛教入中国之始。据《魏书》之意,佛教输入,当分三期:(一)匈奴浑邪王降,中国得其金人,为佛教流通之渐。(二)张骞至大夏,知有身毒,行浮屠之教。哀帝元寿元年,博士弟子秦景宪,受大月氏使伊存口授浮屠经。(三)乃及明帝金人之梦。金人实与佛教无涉。大月氏使口授浮屠经,事若确实,当可称为佛教输入之始。元寿元年,为民国纪元前1913年,即西历公元前2年。然则佛教输入中国,实在基督诞生后两年了(基督降生,在纪元前4年。西人因纪年行用已久,遂未改正)。据《后汉书》所载,光武帝子楚王英,业已信佛,可见其输入必不在明帝之世。秦景宪与秦景,当即一人。此等传说中的人物,有无尚不可知,何况确定其名姓年代?但大月氏为佛教盛行之地;汉与西域,交通亦极频繁,佛教自此输入,理有可能。梁启超以南方佛像涂金;《后汉书·陶谦传》,说谦使笮融督广陵、下邳、彭城运粮,融遂断三郡委输,大起浮屠寺,作黄金屠像,疑佛教本自南方输入。然此说太近臆测。即谓其系事实,亦不能断定其输入在北方之先。梁氏此文,破斥旧说之功甚大,其所建立之说,则尚待研究。柳诒徵《梁氏佛教史评》,可以参看。佛教的特色:在于(一)其说轮回,把人的生命延长了,足以救济中国旧说,(甲)限善报于今世及其子孙,及(乙)神仙家飞升尸解等说的太无征验,而满足人的欲望。(二)又其宗旨偏于出世,只想以个人的修养,解脱苦痛,全不参加政治斗争。在此点,佛教与张角、张鲁等,大不相同。所以不为政治势力所摧残,而反为其所扶植。(三)中国是时,尚缺乏统一全国的大宗教。一地方一部族之神,既因其性质褊狭而不适于用,天子所祭的天地等,亦因其和人民相去远了,而在若无若有之间。张角、张鲁等的宗教运动,又因其带有政治斗争性质;且其教义怕太浅,而不足以餍上中流社会之望;并只适于秘密的结合,而不宜于平和的传布,不能通行。只有佛教,既有哲理,又说福报,是对于上中下流社会都适宜的。物我无间,冤亲平等,国界种界尚且不分,何况一国之中,各地方各民族等小小界限?其能风行全国,自然无待于言了。至佛教的哲理方面,及其重要宗派,上章已略言之,今不赘述。\n●敦煌壁画反弹琵琶\n把一个中空的瓶抛在水中,水即滔滔注入,使其中本有水,外面的水就不容易进去了。这是先入为主之理,一人如是,一国亦然。佛教输入时,中国的宗教界,尚觉贫乏,所以佛教能够盛行。佛教输入后,就不然了。所以其他外教,输入中国的虽多,都不能如佛教的风行无阻。其和中国文化的关系亦较浅。\n佛教以外,外国输入的宗教,自以回教为最大。此教缘起,人人知之,无待赘述。其教本名伊思兰,在中国则名清真,其寺称清真寺。其经典名《可兰》。原来为阿剌伯文,非其教中有学问的人不能读;而其译本及教中著述,流布于社会上的很少;所以在中国,除教徒外,罕有了解回教教义的。又回教教规,极为严肃。教徒生活,与普通人不甚相合。所以自元代盛行输入以来,已历七百年,仍不能与中国社会相融化。现在中国信奉回教的人,约有五千万。其中所包含的民族实甚多,然人皆称为回族,俨然因宗教而结合成一个民族了。因宗教而结合成一个民族,在中国,除回教之外,是没有的。\n中国人称伊思兰教为回教,乃因其为回纥人所信奉而然。然回纥在漠北,实本信摩尼教。其信伊思兰教,乃走入天山南路后事。摩尼教原出火教。火教为波斯国教,中国称为胡天。又造祆字,称为祆教(其字从示从天,读他烟切。或误为从夭,读作于兆切,就错了)。火教当南北朝时,传至葱岭以东,因而流入中国。然信奉它的,只有北朝的君主。唐朝时,波斯为大食所灭,中亚细亚亦为所据,火教徒颇有东行入中国的,亦未和中国社会,发生甚么影响。摩尼教则不然。唐朝安史乱后,回纥人多入中国,其教亦随之而入。自长安流行及于江淮。武宗时,回纥败亡,会昌五年(西历845年,民国纪元前1067年),中国乃加以禁断。然其教流行至南宋时仍不绝。其人自称为明教。教外人则谓之吃菜事魔,以其教徒均不肉食之故。按宗教虽似专给人以精神上的慰安,实则仍和现实生活有关系。现实生活,经济问题为大。流行于贫苦社会中的宗教,有教人团结以和现社会相斗争的,如太平天国所创的上帝教,实行均田和共同生活之法是。有教教徒自相救恤,对于现社会的组织,则取放任态度的,如张鲁在汉中,教人作义舍,置米肉其中,以便行人;令有小过者修路;禁酒,春夏禁杀;明教徒戒肉食,崇节俭,互相救恤是。入其教的,生活上既有实益,所以宋时屡加禁断,不能尽绝。然社会秩序未能转变时,与之斗争的,固然不免灭亡;即欲自成一团体,独立于现社会组织之外的,亦必因其和广大的社会秩序不能相容,而终遭覆灭。所以到元朝以后,明教也就默默无闻了。张鲁之治汉中,所以能经历数十年,乃因其政治尚有规模,人民能与之相安,并非由其教义,则明教的流行较久,亦未必和其教义有甚关系了。火教及摩尼教流行中国的历史,详见近人陈垣所撰《火祆教入中国考》。\n基督教入中国,事在民国纪元前1274年(公元638年,唐太宗贞观十二年)。波斯人阿罗本(Olopen),始赉其经典来长安。太宗许其建寺,称为波斯,玄宗因其教本出大秦,改寺名为大秦寺。其教在当时,称为景教。德宗时,寺僧景净,立《景教流行中国碑》,明末出土,可以考见其事的始末。蒙古时,基督教又行输入。其徒谓之也里可温。陈垣亦有考。元时,信奉基督教的,多是蒙古人。所以元亡而复绝。直到明中叶后,才从海路复行输入。近代基督教的输入,和中国冲突颇多。推其源,实出于政治上的误解。基督教的教义,如禁拜天、拜祖宗、拜孔子等,固然和中国的风俗,是冲突的。然前代的外教,教规亦何尝不和中国风俗有异同?况近代基督教初输入时,是并不禁拜天、拜祖宗、拜孔子的。明末相信基督教的,如徐光启、李之藻辈,并非不了解中国文化的人。假使基督教义和中国传统的风俗习惯,实不相容,他们岂肯因崇信科学之故,把民族国家,一齐牺牲了?当时反对西教的,莫如杨光先。试看他所著的《不得已书》。他说:他们“不婚不宦,则志不在小”。又说:“其制器精者,其兵械亦精。”又说:他们著书立说,谓中国人都是异教的子孙。万一他们蠢动起来,中国人和他相敌,岂非以子弟敌父兄?又说:“以数万里不朝不贡之人,来不稽其所从来,去不究其所从去;行不监押,止不关防。十三省山川形势,兵马钱粮,靡不收归图籍。百余年后,将有知余言之不得已者。”因而断言:“宁可使中国无好历法,不可使中国有西洋人。”原来中国历代,军政或者废弛,至于军械,则总是在外国之上的。到近代,西人的船坚炮利,中国才自愧弗如。而中国人迷信宗教,是不甚深的。西洋教士艰苦卓绝的精神,又非其所了解。自然要生出疑忌来了。这也是在当日情势之下,所不能免的,原不足以为怪,然攻击西教士的虽有,而主张优容的,亦不在少数。所以清圣祖初年,虽因光先的攻击,汤若望等一度获罪,然教禁旋复解除。康熙一朝,教士被任用者不少。于中国文化,裨益实非浅鲜。此亦可见基督教和中国文化,无甚冲突了。教禁之起,实由1704年(康熙四十三年),教皇听别派教士的话,以不禁中国教徒拜天、拜祖宗、拜孔子为不然,派多罗(Tourmon)到中国来禁止。此非但教义与中国相隔阂,亦且以在中国传教的教士,而受命于外国的教皇,亦非当时中国的见解,所能容许。于是有康熙五十六年重申教禁之事。世宗即位后,遂将教徒一律安置澳门;各省的天主堂,尽行改为公廨了。自此以后,至五口通商后教禁解除之前,基督教在中国,遂变为秘密传播的宗教。中国人既不知道它的真相,就把向来秘密教中的事情,附会到基督教身上:什么挖取死人的眼睛咧;聚集教堂中的妇女,本师投以药饵,使之雉鸣求牡咧。种种离奇怪诞之说,不一而足,都酿成于此时。五口通商以后,(一)中国人既怀战败之忿,视外国的传教,为藉兵力胁迫而成。(二)教民又恃教士的干涉词讼为护符,鱼肉乡里。(三)就是外国教士,也有倚势妄为,在中国实施其敲诈行为的(见严复译英人宓克所著《中国教案论》)。于是教案迭起,成为交涉上的大难题了。然自庚子事变以后,中国人悟盲目排外之无益,风气翻然一变,各省遂无甚教案。此亦可见中国人对于异教的宽容了。\n基督教原出犹太。犹太教亦曾输入中国。谓之一赐乐业教。实即以色列的异译。中国谓之挑筋教。今存于河南的开封。据其教中典籍所记,其教当五代汉时(民国纪元前965至962,公元947至950年),始离本土,至宋孝宗隆兴元年(民国纪元前749,公元1163年),始在中国建寺。清圣祖康熙四十一年,有教徒二三千人。宣宗道光末,存者止三百余。宣统元年二百余。民国八年,止有一百二十余人。初来时凡十七姓,清初,存者止有七姓了。详见陈垣《一赐乐业教考》。\n社会变乱之际,豪杰之士,想结合徒党,有所作为的,亦往往藉宗教为工具。如前代的张角、孙恩,近代的太平天国等都是。此特其荦荦大者,其较小的,则不胜枚举。此等宗教,大率即系其人所创造,多藉当时流行之说为资料。如张角讹言“苍天已死,黄天当立”(苍,疑当作赤,为汉人所讳改),系利用当时五行生胜之说;白莲教依托佛教;上帝教依托基督教是。然此实不过借为资料(利用其业已流行于社会),其教理,实与其所依附之说,大不相同。其支离灭裂,往往使稍有智识之人,闻之失笑。上帝教和义和团之说,因时代近,传者较多,稍一披览,便可见得。然非此不足以煽动下流社会中人。我们现在的社会,实截然分为两橛。一为上中流知识阶级,一为下流无知识阶级。我们所见,所闻,所想,实全与广大的为社会基础的下层阶级相隔绝。我们的工作,所以全是浮面的,没有真正的功效,不能改良社会,即由于此。不可不猛省。\n●北魏·山西大同市云冈石窟第1窟内景\n中国社会,迷信宗教,是不甚深的。此由孔教盛行,我人之所祈求,都在人间而不在别一世界之故。因此,教会之在中国,不能有很大的威权。因此,我们不以宗教问题和异族异国,起无谓的争执。此实中国文化的一个优点。现今世界文化进步,一日千里。宗教因其性质固定之故,往往成为进化的障碍。若与之争斗,则又招致无谓的牺牲,欧洲的已事,即其殷鉴。这似乎是文化前途一个很大的难题。然实际生活,总是顽强的观念论的强敌。世界上任何宗教,其教义,总有几分禁欲性的,事实上,却从没看见多数的教徒,真能脱离俗生活。文化愈进步,人的生活情形,变更得愈快。宗教阻碍进步之处,怕更不待以干戈口舌争之了。这也是史事无复演,不容以旧眼光推测新变局的一端。\n[1]为了照顾读者阅读习惯,出版者将原书上册改作下编,原书下册改作了上编。本序所引章节均为本书章节——出版者注。\n"},{"id":163,"href":"/zh/docs/culture/%E4%B8%AD%E5%9B%BD%E9%80%9A%E5%8F%B2%E5%90%95%E6%80%9D%E5%8B%89/%E4%B8%8A%E7%AF%87-%E4%B8%AD%E5%9B%BD%E6%94%BF%E6%B2%BB%E5%8F%B2/","title":"上编-中国政治史","section":"中国通史(吕思勉)","content":" 第一章 中国民族的由来 # 社会是整个的,作起文化史来,分门别类,不过是我们分从各方面观察,讲到最后的目的,原是要集合各方面,以说明一个社会的盛衰,即其循着曲线进化的状况的。但是这件事很不容易。史事亡失的多了,我们现在,对于各方面,所知道的多很模糊(不但古代史籍缺乏之时,即至后世,史籍号称完备,然我们所要知道的事,仍很缺乏而多伪误。用现代新史学的眼光看起来,现在人类对于过去的知识,实在是很贫乏的),贸贸然据不完不备的材料,来说明一时代的盛衰,往往易流于武断。而且从中学到大学,永远是以时为经、以事为纬的,将各时代的事情,复述一遍,虽然详略不同,而看法失之单纯,亦难于引起兴趣。所以我这部书,变换一个方法,下册依文化的项目,把历代的情形,加以叙述,这一册依据时代,略述历代的盛衰。读者在读这一册时,对于历代的社会状况,阅读下册就会略有所知,则涉及时措辞可以从略,不至有头绪纷繁之苦;而于历代盛衰的原因,亦更易于明了了。\n叙述历代的盛衰,此即向来所谓政治史。中国从前的历史,所以被人讥诮为帝王的家谱,为相斫书,都由其偏重这一方面之故。然而矫枉过正,以为这一方面,可以视为无足重轻,也是不对的。现在的人民,正和生物在进化的中途需要外骨保护一样。这话怎样说呢?世界尚未臻于大同之境,人类不能免于彼此对立,就不免要靠着武力或者别种力量互相剥削。在一个团体之内,虽然有更高的权力,以评判其是非曲直,而制止其不正当的竞争,在各个团体之间,却至今还没有,到被外力侵犯之时,即不得不以强力自卫,此团体即所谓国家。一个国家之中,总包含着许多目的简单,有意用人力组成的团体,如实业团体、文化团体等都是。此等团体,和别一个国家内性质相同的团体,是用不着分界限的,能合作固好,能合并则更好。无如世界上现在还有用强力压迫人家,掠夺人家的事情,我们没有组织,就要受到人家的压迫、掠夺,而浸至无以自存了。这是现今时代国家所以重要的原因。世界上的人多着呢?为什么有些人能合组一个国家,有些人却要分做两国呢?这个原因,最重要的,就是民族的异同,而民族的根柢,则为文化。世界文化的发达,其无形的目的,总是向着大同之路走的,但非一蹴可几。未能至于大同之时,则文化相同的人民可以结为一体,通力合作,以共御外侮;文化不相同的则不能然,此即民族国家形成的原理。在现今世界上,非民族的国家固多,然总不甚稳固。其内部能平和相处,强大民族承认弱小民族自决权利的还好,其不然的,往往演成极激烈的争斗;而一民族强被分割的,亦必出死力以求其合,这是世界史上数见不鲜的事。所以民族国家,在现今,实在是一个最重要的组织。若干人民,其文化能互相融和而成为一个民族,一个民族而能建立一个强固的国家,都是很不容易的事。苟其能之,则这一个国家,就是这一个民族在今日世界上所以自卫,而对世界的进化尽更大的责任的良好工具了。\n中国是世界上最大的一个民族国家,这是无待于言的。一个大民族,固然总是融合许多小民族而成,然其中亦必有一主体。为中国民族主体的,无疑是汉族了。汉族的由来,在从前是很少有人提及的。这是因为从前人地理知识的浅薄,不知道中国以外还有许多地方之故。至于记载邃古的时代,自然是没有的。后来虽然有了,然距邃古的时代业已很远,又为神话的外衣所蒙蔽。一个民族不能自知其最古的历史,正和一个人不能自知其极小时候的情形一样。如其开化较晚,而其邻近有先进的民族,这一个民族的古史,原可藉那一个民族而流传,中国却又无有。那么,中国民族最古的情形,自然无从知道了。直至最近,中国民族的由来,才有人加以考究,而其初还是西人,到后来,中国人才渐加注意。从前最占势力的是“西来说”,即说中国民族,自西方高地而来。其中尤被人相信的,为中国民族来自黄河上源昆仑山之说。此所谓黄河上源,乃指今新疆的于阗河;所谓昆仑山,即指于阗河上源之山。这是因为:一、中国的开化,起于黄河流域;二、汉武帝时,汉使穷河源,说河源出于于阗。《史记·大宛列传》说,天子案古图书,河源出于昆仑。后人因汉代去古未远,相信武帝所案,必非无据之故。其实黄河上源,明明不出于阗。若说于阗河伏流地下,南出而为黄河上源,则为地势所不容,明明是个曲说。而昆仑的地名,在古书里也是很神秘的,并不能实指其处,这只要看《楚辞》的《招魂》、《淮南子》的《地形训》和《山海经》便知。所以以汉族开化起于黄河流域,而疑其来自黄河上源,因此而信今新疆西南部的山为汉族发祥之地,根据实在很薄弱。这一说,在旧时诸说中,是最有故书雅记做根据的,而犹如此,其他更不必论了。\n●北京猿人复原图\n\\[Peking Man。按此名为安特生所名,协和医学院解剖学教授步达生(Davidson Black)名之为Sinanthropus Pekinensis,叶为耽名之曰震旦人,见所著《震旦人与周口店文化》,商务印书馆本\\]。据考古学家的研究,其时约距今四十万年。其和中国人有无关系,殊不可知,不过因此而知东方亦是很古的人类起源之地罢了。其和历史时代可以连接的,则为民国十年辽宁锦西沙锅屯,河南渑池仰韶村,及十二三年甘肃临夏、宁定、民勤,青海贵德及青海沿岸所发现的彩色陶器,和俄属土耳其斯单所发现的酷相似。考古家安特生(J.G.Andersson)因谓中国民族,实自中亚经新疆、甘肃而来。但彩陶起自巴比仑,事在公元前3500年,传至小亚细亚,约在公元前2500年至前2000年,传至古希腊,则在前2000年至前1000年,俄属土耳其斯单早有铜器,河南、甘肃、青海之初期则无之,其时必在公元2500年之前,何以传播能如是之速?制铜之术,又何以不与制陶并传?斯坦因(Sir Aurel Stein)在新疆考古,所得汉、唐遗物极多,而先秦之物,则绝无所得,可见中国文化在先秦世实尚未行于西北,安特生之说,似不足信了(此说据金兆梓《中国人种及文化由来》,见《东方杂志》第二十六卷第二期)。民国十九年以后,山东历城的城子崖,滕县的安上村,都发现了黑色陶器。江苏武进的奄城,金山的戚家墩,吴县的磨盘山、黄壁山,浙江杭县的古荡、良渚,吴兴的钱山漾,嘉兴的双栖,平湖的乍浦,海盐的澉浦,亦得有新石器时代的石器、陶器,其中杭县的黑陶,颇与山东相类。又河域所得陶器,皆为条纹及席纹;南京、江、浙和山东邹县,福建武平,辽宁金县貔子窝及香港的陶器,则其文理为几何形。又山东、辽宁有有孔石斧,朝鲜、日本有有孔石厨刀,福建厦门、武平有有沟石锛,南洋群岛有有沟石斧,大洋洲木器所刻动物形,有的和中国铜器上的动物相像。北美阿拉斯加的土器,也有和中国相像的。然则中国沿海一带,实自有其文化。据民国十七年以后中央研究院在河南所发掘,安阳的侯家庄,濬县的大赉店,兼有彩色、黑色两种陶器,而安阳县北的小屯村,即1898、1899年发现甲骨文字之处,世人称为殷墟的,亦有几何纹的陶器。又江、浙石器中,有戈、矛及钺,河域惟殷墟有之。鬲为中国所独有,为鼎之前身,辽东最多,仰韶亦有之,甘肃、青海,则至后期才有。然则中国文化,在有史以前,似分东、西两系。东系以黑陶为代表,西系以彩陶为代表,而河南为其交会之地。彩陶为西方文化东渐的,代表中国固有的文化的,实为黑陶。试以古代文化现象证之:一、“国君无故不杀牛,大夫无故不杀羊,士无故不杀犬豕”,而鱼鳖则为常食。二、衣服材料,以麻、丝为主,裁制极其宽博。三、古代的人民,是巢居或湖居的。四、其货币多用贝。五、在宗教上又颇敬畏龙蛇。皆足证其文化起于东南沿海之处;彩陶文化之为外铄,似无疑义了。在古代,亚洲东方的民族,似可分为三系,而其处置头发的方法,恰可为其代表,这是一件极有趣味的事,即北族辫发、南族断发、中原冠带。《尔雅·释言》说:“齐,中也。”《释地》说:“自齐州以南戴日为丹穴,北戴斗极为空同,东至日所出为大平,西至日所入为大蒙。”“齐”即今之“脐”字,本有中央之义。古代的民族,总是以自己所居之地为中心的,齐州为汉族发祥之地,可无疑义了。然则齐州究在何处呢?我们固不敢断言其即后来的齐国,然亦必与之相近。又《尔雅·释地》说“中有岱岳”,而泰山为古代祭天之处,亦必和我民族起源之地有关。文化的发展,总是起于大河下流的,埃及和小亚细亚即其明证。与其说中国文化起于黄河上流,不如说其起于黄河下流的切于事情了。近来有些人,窥见此中消息,却又不知中国和南族之别,甚有以为中国人即是南族的,这个也不对。南族的特征是断发文身,断发即我国古代的髡刑,文身则是古代的黥刑。以南族的装饰为刑,可见其曾与南族相争斗,而以其俘虏为奴隶。近代的考古学,证明长城以北的古物,可分为三类:一、打制石器,其遗迹西起新疆,东至东三省,而限于西辽河、松花江以北,环绕着沙漠。二、细石器,限于兴安岭以西。与之相伴的遗物,有类似北欧及西伯利亚的,亦有类似中欧及西南亚的,两者均系狩猎或畜牧民族所为。三、磨制石器,北至黑龙江昂昂溪,东至朝鲜北境,则系黄河流域的农耕民族所为,其遗物多与有孔石斧及类鬲的土器并存,与山东龙口所得的土器极相似。可见我国民族,自古即介居南北两民族之间,而为东方文化的主干了(步达生言仰韶村、沙锅屯的遗骸,与今华北人同,日本清野谦次亦谓貔子窝遗骸,与仰韶村遗骸极相似)。\n●彩陶盆 仰韶文化半坡类型,公元前5000年—公元前4000年,陕西西安半坡遗址出土,高16.4厘米、口径40厘米,内绘鱼、鱼和人面相结合的花纹,这是仰韶文化早期流行的纹饰。有观点认为,鱼便是仰韶文化半坡类型的图腾\n第二章 中国史的年代 # 讲历史要知道年代,正和讲地理要知道经纬线一般。有了经纬线,才知道某一地方在地球面上的某一点,和其余的地方距离如何,关系如何。有了年代,才知道某一件事发生在悠远年代中的某一时,当时各方面的情形如何,和其前后诸事件的关系如何。不然,就毫无意义了。\n正确的年代,原于(一)正确,(二)不断的记载。中国正确而又不断的记载,起于什么时候呢?那就是周朝厉、宣两王间的共和元年。下距民国纪元2752年,公历纪元841年,在世界各国中,要算是很早的了。但是比之于人类的历史,还如小巫之见大巫。世界之有人类,其正确的年代虽不可知,总得在四五十万年左右。历史确实的纪年,只有二千余年,正像人活了一百岁,只记得一年不到的事情,要做正确的年谱,就很难了。虽然历史无完整的记载,历史学家仍有推求之法。那便是据断片的记载,涉及天地现象的,用历法推算。中国用这方法的也很多。其中较为通行的,一为《汉书·律历志》所载刘歆之所推算,一为宋朝邵雍之所推算。刘歆所推算:周朝867年,殷朝629年,夏朝432年,虞舜在位五十年,唐尧在位七十年。周朝的灭亡,在民国纪元前2167年,公历纪元前256年,则唐尧的元年,在民国纪元前4215年,公历纪元前2305年。据邵雍所推算,则唐尧元年,在民国纪元前4268年,公历纪元前2357年。据历法推算,本是极可信据的,但前人的记载,未必尽确,后人的推算,也不能无误,所以也不可尽信。不过这所谓不可信,仅系不密合,论其大概,还是不误的。《孟子·公孙丑下篇》说:“由周而来,七百有余岁矣。”《尽心下篇》说:“由尧、舜至于汤,五百有余岁;由汤至于文王,五百有余岁;由文王至于孔子,五百有余岁。”乐毅报燕惠王书,称颂昭王破齐之功,说他“收八百岁之蓄积”。《韩非子·显学篇》说:“殷、周七百余岁,虞、夏二千余岁。”(此七百余岁但指周言)都和刘歆、邵雍所推算,相去不远。古人大略的记忆,十口相传,是不会大错的。然则我国历史上可知而不甚确实的年代,大约在四千年以上了。\n自此以上,连断片的记录,也都没有,则只能据发掘所得,推测其大略,是为先史时期。人类学家把人类所用的工具,分别它进化的阶段,最早的为旧石器时期,次之为新石器时期,都在有史以前,更次之为青铜器时期,更次之为铁器时期,就在有史以后了。我国近代发掘所得,据考古学家的推测:周口店的遗迹,约在旧石器前期之末,距今二万五千年至七万年。甘、青、河南遗迹,早的在新石器时期,在公历纪元前2600至3500年之间;\n晚的在青铜器时期,在公历纪元前1700至2600年之间。按古代南方铜器的发明,似较北方为早,则实际上,我国开化的年代,或许还在此以前。\n●旧石器时代·刮削器和尖状器山西省阳高县许家窑遗址出土。最长的5.6厘米,最短的2.7厘米。是10万年前的石器\n中国古书上,有的把古史的年代,说得极远而且极确实的,虽然不足为凭,然因其由来甚远,亦不可不一发其覆。按《续汉书律历志》载蔡邕议历法的话,说《元命苞》、《乾凿度》都以为自开辟至获麟(获麟是《春秋》的末一年,在公元前481年),二百七十六万岁。司马贞《补三皇本纪》,则说《春秋纬》称自开辟至获麟,凡三百二十七万六千岁,分为十纪。据《汉书·律历志》刘歆的三统历法,以十九年为一章,四章为一蔀,二十蔀为一纪,三纪为一元。二百七十五万九千二百八十年,乃是六百十三元之数。《汉书·王莽传》说:莽下三万六千岁历,三万六千被乘于九十一,就是三百二十七万六千年了。这都是乡壁虚造之谈,可谓毫无历史上的根据。\n●新石器时代·白陶山东维坊姚官庄出土。温酒器,高29.7厘米。是山东龙山文化遗存\n第三章 古代的开化 # 中国俗说,最早的帝王是盘古氏。古书有的说他和天地开辟并生,有的说他死后身体变化而成日月、山河、草木等。(徐整《三五历记》说:“天地混沌如鸡子,盘古生其中。万八千岁,天地开辟,阳清为天,阴浊为地,盘古在其中……天日高一丈,地日厚一丈,盘古日长一丈。如此万八千岁,天数极高,地数极深,盘古极长。”《五运历年记》说:“首生盘古,垂死化身:气成风云,声为雷霆,左眼为日,右眼为月,四肢、五体为四极、五岳,血液为江河,筋脉为地理,肌肉为田土,发髭为星辰,皮毛为草木,齿骨为金石,精髓为珠玉,汗流为雨,身之诸虫,因风所感,化为黎虻。”)这自然是附会之辞,不足为据。《后汉书·南蛮传》说:汉时长沙、武陵蛮(长沙、武陵,皆后汉郡名。长沙,治今湖南长沙县。武陵,治今湖南常德县)的祖宗,唤做盘瓠,乃是帝喾高辛氏的畜狗。当时有个犬戎国,为中国之患。高辛氏乃下令,说有能得犬戎吴将军的头的,赏他黄金万镒,还把自己的女儿嫁给他。令下之后,盘瓠衔了吴将军的头来。遂背了高辛氏的公主,走入南山,生了六男六女,自相夫妻,成为长沙、武陵蛮的祖宗。现在广西一带,还有祭祀盘古的。闽、浙的畲民,则奉盘瓠为始祖,其画像仍作狗形。有人说:盘古就是盘瓠,这话似乎很确。但是《后汉书》所记,只是长沙、武陵一支,而据古书所载,则盘古传说,分布之地极广,而且绝无为帝喾畜狗之说(据《路史》:会昌有盘古山,湘乡有盘古堡,雩都有盘古祠,成都、淮安、京兆亦皆有盘古庙。会昌,令江西会昌县。湘乡,今湖南湘乡县。雩都,今江西雩都县。成都,今四川成都县。淮安,今江苏淮安县。京兆,今西京),则盘古、盘瓠,究竟是一是二,还是一个疑问。如其是一,则盘古本非中国民族的始祖;如其是二,除荒渺的传说外,亦无事迹可考,只好置诸不论不议之列了。\n在盘古之后,而习惯上认为很早的帝王的,就是三皇、五帝。三皇、五帝之名,见于《周官》外史氏,并没说他是谁。后来异说甚多(三皇异说:《白虎通》或说,无遂人而有祝融。《礼记·曲礼正义》说:郑玄注《中候敕省图》引《运斗枢》,无遂人而有女娲。按《淮南子·天文训》、《览冥训》,《论衡·谈天》、《顺鼓》两篇,都说共工氏触不周之山,天柱折,地维缺,女娲炼五色石以补天,断鳌足以立四极。而司马贞《补三皇本纪》说系共工氏与祝融战,则女娲、祝融一人。祝融为火神,燧人是发明钻木取火的,可见其仍系一个部族。五帝异说:则汉代的古学家,于黄帝、颛顼之间,增加了一个少昊,于是五帝变成六人。郑玄注《中候敕省图》,乃谓德合五帝坐星,即可称帝,故“实六人而为五”。然总未免牵强。东晋晚出的《伪古文尚书》的《伪孔安国传序》,乃将三皇中的燧人除去,而将黄帝上升为三皇,于是六人为五的不通,给他弥缝过去了。《伪古文尚书》今已判明其为伪,人皆不之信,东汉古学家之说,则尚未显被推翻。但古学家此说,不过欲改五德终始说之相胜为相生,而又顾全汉朝之为火德,其作伪实无以异,而手段且更拙。按五德终始之说,创自邹衍,本依五行相胜的次序。依他的说法,是虞土、夏木、殷金、周火,所以秦始皇自以为水德,而汉初自以为土德。到刘向父子出,改五德的次序为五行相生,又以汉为尧后。而黄帝的称号为黄,黄为土色,其为土德,无可移易。如此,依五帝的旧次,颛顼金德,帝喾水德,尧是木德,与汉不同德了。于其间增一少昊为金德,则颛顼水德,帝喾木德,尧为火德,与汉相同;尧以后则虞土,夏金,殷水,周木,而汉以火德承之,秦人则被视为闰位,不算入五德相承次序。这是从前汉末年发生,至后汉而完成的一套五德终始的新说,其说明见于《后汉书·贾逵传》,其不能据以言古代帝王的统系,是毫无疑义的了),其较古的,还是《风俗通》引《含文嘉》,以燧人、伏羲、神农为三皇,《史记·五帝本纪》以黄帝、颛顼、帝喾、尧、舜为五帝之说。燧人、伏羲、神农,不是“身相接”的,五帝则有世系可考。\n据《史记·五帝本纪》及《大戴礼记·帝系篇》,其统系如下:\n按五帝之说,源于五德终始,五德终始之说,创自邹衍,邹衍是齐人,《周官》所述的制度,多和《管子》相合,疑亦是齐学。古代本没有一个天子是世代相承的;即一国的世系较为连贯的,亦必自夏以后。夏、殷两代,后世的史家,都认为是当时的共主,亦是陷于时代错误的。据《史记·夏本纪》、《史记·殷本纪》所载,明明还是盛则诸侯来朝,衰则诸侯不至,何况唐、虞以上?所以三皇、五帝,只是后人造成的一个古史系统,实际上怕全不是这么一回事。但自夏以后,一国的世系,既略有可考;而自黄帝以后,诸帝王之间,亦略有不很正确的世系,总可藉以推测古史的大略了。\n古代帝王的称号,有所谓德号及地号(服虔说,见《礼记·月令》、《疏》),德号是以其所做的事业为根据的,地号则以其所居之地为根据。按古代国名、地名,往往和部族之名相混,还可以随着部族而迁移,所以虽有地号,其部族究在何处,仍难断言。至于德号,更不过代表社会开化的某阶段;或者某一个部族,特长于某种事业;并其所在之地而不可知,其可考见的真相,就更少了。然既有这些传说,究可略据之以为推测之资。传说中的帝王,较早而可考见社会进化的迹象的,是有巢氏和燧人氏。有巢氏教民构木为巢,燧人氏教民钻木取火,见于《韩非子》的《五蠹篇》。稍后则为伏羲、神农。伏羲氏始画八卦,作结绳而为网罟,以佃以渔;神农氏斫木为耜,揉木为耒,日中为市,见于《易经》的《系辞传》。有巢、燧人、神农都是德号,显而易见。伏羲氏,《易传》作包牺氏,包伏一声之转。据《风俗通》引《含文嘉》,是“下伏而化之”之意,羲化亦是一声。他是始画八卦的,大约在宗教上很有权威,其为德号,亦无疑义。这些都不过代表社会进化的一个阶段,究有其人与否,殊不可知。但各部族的进化,不会同时,某一个部族,对于某一种文化,特别进步得早,是可能有的。如此,我们虽不能说在古代确有发明巢居、取火、佃渔、耕稼的帝王,却不能否认对于这些事业,有一个先进的部族。既然有这部族,其时、地就该设法推考了。伏羲古称为太昊氏,风姓,据《左传》僖公二十一年所载,任、宿、须句、颛臾四国,是其后裔。任在今山东的济宁县,宿和须句都在东平县,颛臾在费县。神农,《礼记·月令》《疏》引《春秋说》,称为大庭氏。《左传》昭公十八年,鲁有大庭氏之库。鲁国的都城,即今山东曲阜县(《帝王世纪》说伏羲都陈,乃因左氏有“陈太昊之墟”之语而附会,不足信,见下文。又说神农氏都陈徙鲁,则因其承伏羲之后而附会的)。然则伏羲、神农,都在今山东东南部,和第一章所推测的汉族古代的根据地,是颇为相合的了。\n神农亦称炎帝,炎帝之后为黄帝,炎、黄之际,是有一次战事可以考见的,古史的情形,就更较明白了。《史记·五帝本纪》说:神农氏世衰,诸侯相侵伐,弗能征,而蚩尤氏最为暴。“黄帝乃征师诸侯,与蚩尤战于涿鹿之野,遂擒杀蚩尤。”又说:“炎帝欲侵陵诸侯,诸侯咸归轩辕。”(《史记·五帝本纪》说黄帝名轩辕,他书亦有称为轩辕氏的。按古书所谓名,兼包一切称谓,不限于名字之名。)轩辕“与炎帝战于阪泉之野,三战,然后得其志”。其说有些矛盾。《史记》的《五帝本纪》,和《大戴礼记》的《五帝德》,是大同小异的,《大戴礼记》此处,却只有和炎帝战于阪泉,而并没有和蚩尤战于涿鹿之事。神农、蚩尤,都是姜姓。《周书·史记篇》说“阪泉氏徙居独鹿”,独鹿之即涿鹿,亦显而易见。然则蚩尤、炎帝,即是一人,涿鹿、阪泉,亦系一地。《太平御览·州郡部》引《帝王世纪》转引《世本》,说涿鹿在彭城南,彭城是今江苏的铜山县(服虔谓涿鹿为汉之涿郡,即今河北涿县。皇甫谧、张晏谓在上谷,则因汉上谷郡有涿鹿县而云然,皆据后世的地名附会,不足信。汉涿鹿县即今察哈尔涿鹿县)。《世本》是古书,是较可信据的,然则汉族是时的发展,仍和鲁东南不远了。黄帝之后是颛顼,颛顼之后是帝喾,这是五帝说的旧次序。后人于其间增一少昊,这是要改五德终始之说相胜的次序为相生,又要顾全汉朝是火德而云然,无足深论。但是有传于后,而被后人认为共主的部族,在古代总是较强大的,其事迹仍旧值得考据,则无疑义。《史记·周本纪正义》引《帝王世纪》说:炎帝、黄帝、少昊,都是都于曲阜的,而黄帝自穷桑登帝位,少昊氏邑于穷桑,颛顼则始都穷桑,后徙帝丘。它说“穷桑在鲁北,或云穷桑即曲阜也”。《帝王世纪》,向来认为不足信之书,但只是病其牵合附会,其中的材料,还是出于古书的,只要不轻信其结论,其材料仍可采用。《左传》定公四年说伯禽封于少昊之墟,昭公二十年说:“少昊氏有四叔,世不失职,遂济穷桑”,则穷桑近鲁,少昊氏都于鲁之说,都非无据。帝丘地在今河北濮阳县,为后来卫国的都城。颛顼徙帝丘之说,乃因《左传》昭公十七年“卫颛顼之虚”而附会,然《左传》此说,与“陈太昊之墟”,“宋大辰之虚”,“郑祝融之虚”并举,大辰,无论如何,不能说为人名或国名(近人或谓即《后汉书》朝鲜半岛的辰国,证据未免太乏),则太昊、祝融、颛顼,亦系天神,颛顼徙都帝丘之说,根本不足信了。《史记·五帝本纪》说:黄帝正妃“嫘祖生二子,其后皆有天下。其一曰玄嚣,是为青阳,青阳降居江水”,此即后人指为少昊的。“其二曰昌意,降居若水,生高阳。”高阳即帝颛顼。后人以今之金沙江释此文的江水,鸦龙江释此文的若水,此乃大误。古代南方之水皆称江。《史记·殷本纪》引《汤诰》,说“东为江,北为济,西为河,南为淮,四渎己修,万民乃有居”,其所说的江,即明明不是长江(淮、泗、汝皆不入江,而《孟子·滕文公上篇》说禹“决汝、汉,排淮、泗,而注之江”,亦由于此)。《吕览·古乐篇》说:“帝颛顼生自若水,实处空桑,乃登为帝。”可见若水实与空桑相近。《山海经·海内经》说:“南海之内,黑水、青水之间,有木焉,名曰若木,若水出焉。”《说文》桑字作,若水之若,实当作,仍系桑字,特加以象根形,后人认为若字实误。《楚辞》的若木,亦当作桑木,即神话中的扶桑,在日出之地(此据王筠说,见《说文释例》)。然则颛顼、帝喾,踪迹仍在东方了。\n继颛顼之后的是尧,继尧之后的是舜,继舜之后的是禹。尧、舜、禹的相继,据儒家的传说,是纯出于公心的,即所谓“禅让”,亦谓之“官天下”。但《庄子·盗跖篇》有尧杀长子之说,《吕览·去私》、《求人》两篇,都说尧有十子,而《孟子·万章上篇》和《淮南子·泰族训》,都说尧只有九子,很像尧的大子是被杀的(俞正燮即因此疑之,见所著《癸巳类稿·奡证》)。后来《竹书纪年》又有舜囚尧,并偃塞丹朱,使不与尧相见之说。刘知幾因之作《疑古篇》,把尧、舜、禹的相继,看作和后世的篡夺一样。其实都不是真相。古代君位与王位不同,在第三十九章中,业经说过。尧、舜、禹的相继,乃王位而非君位,这正和蒙古自成吉思汗以后的汗位一样。成吉思汗以后的大汗,也还是出于公举的(详见第二十七章)。前一个王老了,要指定一人替代,正可见得此时各部族之间,已有较密切的关系,所以共主之位,不容空缺。自夏以后,变为父子相传,古人谓之“家天下”,又可见得被举为王的一个部族,渐次强盛,可以久居王位了。\n尧、舜、禹之间,似乎还有一件大事,那便是汉族的开始西迁。古书中屡次说颛顼、帝喾、尧、舜、禹和共工、三苗的争斗(《淮南子·天文训》、《兵略训》,都说共工与颛顼争,《原道训》说共工与帝喾争。《周书·史记篇》说:共工亡于唐氏。《书经·尧典》说:舜流共工于幽州。《荀子·议兵篇》说:禹伐共工。《书经·尧典》又说:舜迁三苗于三危。《甫刑》说:“皇帝遏绝苗民,无世在下。”皇帝,《疏》引郑注以为颛顼,与《国语》、《楚语》相合。而《战国·魏策》,《墨子》的《兼爱》、《非攻》,《韩非子》的《五蠹》,亦均载禹征三苗之事)。共工、三苗都是姜姓之国,似乎姬、姜之争,历世不绝,而结果是姬姓胜利的。我的看法,却不是如此。《国语·周语》说:“共工欲壅防百川,堕高堙卑,鲧称遂共工之过,禹乃高高下下,疏川导滞。”似乎共工和鲧,治水都是失败的,至禹乃一变其法。然《礼记·祭法德》说“共工氏之霸九州也,其子曰后土,能平九州”,则共工氏治水之功,实与禹不相上下。后人说禹治水的功绩,和唐、虞、夏间的疆域,大抵根据《书经》中的《禹贡》,其实此篇所载,必非禹时实事。《书经》的《皋陶谟》载禹自述治水之功道:“予决九川,距四海,濬畎浍距川。”九川特极言其多。四海的海字,乃晦暗之义。古代交通不便,又各部族之间,多互相敌视,本部族以外的情形,就茫昧不明,所以夷、蛮、戎、狄,谓之四海(见《尔雅·释地》,中国西北两面均无海,而古称四海者以此)。州洲本系一字,亦即今之岛字,说见第五十章。《说文》川部:“州,水中可居者。昔尧遭洪水,民居水中高土,故曰九州。”此系唐、虞、夏间九州的真相,决非如《禹贡》所述,跨今黄河、长江两流域。同一时代的人,知识大抵相类,禹的治水,能否一变共工及鲧之法,实在是一个疑问。堙塞和疏导之法,在一个小区域之内,大约共工、鲧、禹,都不免要并用的。但区域既小,无论堙塞,即疏导,亦决不能挽回水灾的大势,所以我疑心共工、鲧、禹,虽然相继施功,实未能把水患解决,到禹的时代,汉族的一支,便开始西迁了。尧的都城,《汉书·地理志》说在晋阳,即今山西的太原县。郑玄《诗谱》说他后迁平阳,在今山西的临汾县。《帝王世纪》说舜都蒲阪,在今山西的永济县。又说禹都平阳,或于安邑,或于晋阳,安邑是今山西的夏县。这都是因后来的都邑而附会。《太平御览·州郡部》引《世本》说:尧之都后迁涿鹿;《孟子·离娄下篇》说:“舜生于诸冯,迁于负夏,卒于鸣条”。这都是较古之说。涿鹿在彭城说已见前。诸冯、负夏、鸣条皆难确考。然鸣条为后来汤放桀之处,桀当时是自西向东走的,则鸣条亦必在东方。而《周书·度邑解》说:“自洛汭延于伊汭,居易无固,其有夏之居。”这虽不就是禹的都城,然自禹的儿子启以后,就不闻有和共工、三苗争斗之事,则夏朝自禹以后,逐渐西迁,似无可疑。然则自黄帝至禹,对姜姓部族争斗的胜利,怕也只是姬姓部族自己夸张之辞,不过只有姬姓部族的传说,留遗下来,后人就认为事实罢了。为什么只有姬姓部族的传说,留遗于后呢?其中仍有个关键。大约当时东方的水患,是很烈的,而水利亦颇饶。因其水利颇饶,所以成为汉族发祥之地。因其水患很烈,所以共工、鲧、禹,相继施功而无可如何。禹的西迁,大约是为避水患的。当时西边的地方,必较东边为瘠,所以非到水久治无功时,不肯迁徙。然既迁徙之后,因地瘠不能不多用人力,文明程度转而因此进步,而留居故土的部族,反落其后了。这就是自夏以后,西方的历史传者较详,而东方较为茫昧之故。然则夏代的西迁,确是古史上的一个转折,而夏朝亦确是古史上的一个界划了。\n第四章 夏殷西周的事迹 # 夏代事迹,有传于后的,莫如太康失国少康中兴一事。这件事,据《左传》、《周书》、《墨子》、《楚辞》所载(《左传》襄公四年、哀公元年,《周书·尝麦解》,《墨子·非乐》,《楚辞·离骚》),大略是如此的。禹的儿子启,荒于音乐和饮食。死后,他的儿子太康兄弟五人,起而作乱,是为五观。太康因此失国,人民和政权,都入于有穷后羿之手。太康传弟仲康,仲康传子相(夏朝此时,失掉的是王位,并非君位,所以仍旧相传)。羿因荒于游畋,又为其巨寒浞所杀。寒浞占据了羿的妻妾,生了两个儿子:一个唤做浇,一个唤做豷。夏朝这时候,依靠他同姓之国斟灌和斟寻。寒浞使浇把他们都灭掉,又灭掉夏后相。使浇住在唤做过,豷住在唤做戈的地方。夏后相的皇后,是仍国的女儿,相被灭时,正有身孕,逃归母家,生了一个儿子,是为少康。做了仍国的牧正。寒浞听得他有才干,使浇去寻找他。少康逃到虞国。虞国的国君,把两个女儿嫁给他,又把唤做纶的地方封他。有一个唤做靡的,当羿死时,逃到有鬲氏,就从有鬲氏收合斟灌、斟寻的余众,把寒浞灭掉。少康灭掉了浇,少康的儿子杼又灭掉了豷。穷国就此灭亡。这件事,虽然带些神话和传说的性质,然其匡廓尚算明白,颇可据以推求夏代的情形。旧说的释地,是全不足据的。《左传》说“后羿自迁于穷石”,又说羿“因夏民以代夏政”,则穷石即非夏朝的都城,亦必和夏朝的都城相近。《路史》说安丰有穷谷、穷水,就是穷国所在,其地在今安徽霍邱县。《汉书·地理志》《注》引应劭说:有穷是偃姓之国,皋陶之后。据《史记·五帝本纪》,皋陶之后,都是封在安徽六安一带的。过不可考。戈,据《左传》,地在宋、郑之间(见《左传》哀公十二年)。《春秋》桓公五年,天王使仍叔之子来聘,仍,《榖梁》作任,地在今山东的济宁县。虞国当系虞舜之后,旧说在今河南的虞城县。《周书》称太康兄弟五人为“殷之五子”。又说:“皇天哀禹,赐以彭寿,思正夏略。”殷似即后来的亳殷,在今河南的偃师县(即下文所引《春秋繁露》说汤作官邑于下洛之阳的。官宫二字古通用,作官邑就是造房屋和城郭。商朝的都城所在,都称为亳,此地大约本名殷,商朝所以又称殷朝)。彭寿该是立国于彭城的。按《世本》说禹都阳城,地在今河南的登封县,西迁未必能如此之速。综观自太康至少康之事,似乎夏朝的根据地,本在安徽西部,而逐渐迁徙到河南去,入于上章所引《周书》所说的“自洛汭延于伊汭”这一个区城的。都阳城该是夏朝后代的事,而不是禹时的事。从六安到霍邱,地势比较高一些,从苏北鲁南避水患而迁于此,又因战争的激荡而西北走向河南,似乎于情事还合。\n●夏·乳钉纹平底爵高22.5厘米,流至尾长31.5厘米,1975年河南偃师二里头出土\n但在这时候,东方的势力,亦还不弱,所以后来夏朝卒亡于商。商朝的始祖名契,封于商。郑玄说地在大华之阳,即今陕西的商县,未免太远。《史记·殷本纪》说:“自契至于成汤八迁。”《世本》说契居蕃,契的儿子昭明居砥石,昭明的儿子相土居商丘,扬雄《兖州牧箴》说“成汤五徙,卒归于亳”,合之恰得八数。蕃当即汉朝的蕃县,为今山东的滕县。商丘,当即后来宋国的都城,为今河南的商丘县。五迁地难悉考。据《吕览·慎大》、《具备》两篇,则汤尝居郼,郼即韦,为今河南的滑县。《春秋繁露·三代改制质文篇》说“汤受命而王,作官邑于下洛之阳”,此当即亳殷之地。《诗·商颂》说:“韦,顾既伐,昆吾,夏桀。”顾在今山东的范县。昆吾,据《左传》昭公十二年《传》楚灵王说“昔我皇祖伯父昆吾,旧许是宅”,该在今河南的许昌县,而哀公十七年,又说卫国有昆吾之观,卫国这时候,在今河北的濮阳县,则昆吾似自河北迁于河南。《史记·殷本纪》说:“汤自把钺以伐昆吾,遂伐桀。”“桀败于有娀之虚,桀奔于鸣条。”《左传》昭公四年“夏桀为仍之会,有缗叛之”,《韩非子·十过篇》亦有这话,仍作娀,则有娀,即有仍。鸣条为舜卒处,已见上章。合观诸说,商朝似乎兴于今鲁、豫之间,汤先平定了河南的北境,然后向南攻桀,桀败后是反向东南逃走的。观桀之不向西走而向东逃,可见此时伊、洛以西之地,还未开辟。\n据《史记》的《夏本纪》、《殷本纪》,夏朝传国共十七代,商朝则三十代。商朝的世数所以多于夏,大约是因其兼行兄终弟及之制而然。后来的鲁国,自庄公以前,都是一生一及,吴国亦有兄终弟及之法,请见第三十八章,这亦足以证明商朝的起于东方。商朝的事迹,较夏朝传者略多。据《史记》:成汤以后,第四代大甲,第九代大戊,第十三代祖乙,第十九代盘庚,第二十二代武丁,都是贤君,而武丁之时,尤其强盛。商朝的都城,是屡次迁徙的。第十代仲丁迁于隞地,在今河南荥泽县(隞,《书序》作嚣,《书序》不一定可信,所以今从《史记》。隞的所在,亦有异说。但古书皆东周至汉的人所述,尤其大多数是汉朝人写下来的,所以用的大抵多是当时的地名,所以古书的释地,和东周、秦、汉时地名相近的,必较可信。如隞即敖,今之荥泽县,为秦汉间敖仓所在,以此释仲丁所迁之隞,确实性就较大些。这是治古史的通例,不能一一具说,特于此发其凡)。第十二代河亶甲居相,在今河南内黄县。第十三代祖乙迁于邢,在今河北邢台县。到盘庚才迁回成汤的旧居亳殷。第二十七代武乙,复去亳居河北。今河南安阳县北的小屯村,即发现龟甲兽骨之处,据史学家所考证,其地即《史记·项羽本纪》所谓殷墟,不知是否武乙时所都。至共第三十代即最后一个君主纣,则居于朝歌,在今河南淇县。综观商朝历代的都邑,都在今河南省里的黄河两岸,还是汤居郼,营下洛之阳的旧观。周朝的势力,却更深入西北部了。\n周朝的始祖名弃,是舜之时居稷官的,封于邰。历若干代,至不窋,失官,奔于戎狄之间。再传至公刘,居邠,仍从事于农业。又十传至古公亶父,复为狄所逼,徙岐山下。邰,旧说是今陕西的武功县。邠是今陕西的邠县,岐是今陕西的岐山县。近人钱穆说,《左传》昭公元年说金天氏之裔子台骀封于汾川,《周书·度邑篇》说武王升汾之阜以望商邑,汾即邠,邰则因台骀之封而得名,都在今山西境内。亶父逾梁山而至岐,梁山在今陕西韩城县,岐山亦当距梁山不远(见所著《周初地理考》)。据他这说法,则后来文王居丰,武王居镐,在今陕西鄠县界内的,不是东下,乃是西上了。河、汾下流和渭水流域,地味最为肥沃,周朝是农业部族,自此向西拓展,和事势是很合的。古公亶父亦称太王,周至其时始强盛。传幼子季历以及文王,《论语》说他“三分天下有其二”(见《泰伯下篇》)。文王之子武王,遂灭纣。文王时曾打破耆国,而殷人振恐,武王则渡孟津而与纣战,耆国,在今山西的黎城县,自此向朝歌,乃今出天井关南下的隘道,孟津在今河南孟县南,武王大约是出今潼关到此的,这又可以看出周初自西向东发展的方向。然武王虽胜纣,并未能把商朝灭掉,仍以纣地封其子武庚,而使其弟管叔、蔡叔监之。武王崩,子成王幼,武王弟周公摄政,管、蔡和武庚都叛。据《周书·作雒解》,是时叛者,又有徐、奄及熊、盈。徐即后来的徐国,地在泗水流域,奄即后来的鲁国,熊为楚国的氏族,盈即嬴,乃秦国的姓。可见东方诸侯,此时皆服商而不服周。然周朝此时,颇有新兴之气。周公自己东征,平定了武庚和管叔、蔡叔,灭掉奄国。又使其子伯禽平定了淮夷、徐戎。于是封周公于鲁,使伯禽就国,又封太公望于齐,又经营今洛阳之地为东都,东方的旧势力,就给西方的新势力压服了。周公平定东方之后,据说就制礼作乐,摄政共七年,而归政于成王。周公死后,据说又有所谓“雷风之变”。这件事情,见于《书经》的《金縢篇》。据旧说:武王病时,周公曾请以身代,把祝策藏在金縢之匮中。周公死,成王葬以人臣之礼。天大雷雨,又刮起大风,田禾都倒了,大木也拔了出来。成王大惧,开金縢之匮,才知道周公请代武王之事,乃改用王礼葬周公,这一场灾异,才告平息。据郑玄的说法,则武王死后三年,成王服满了,才称自己年纪小,求周公摄政。摄政之后,管叔、蔡叔散布谣言,说周公要不利于成王,周公乃避居东都。成王尽执周公的属党。遇见了雷风之变,才把周公请回来。周公乃重行摄政。此说颇不合情理,然亦不会全属子虚。《左传》昭公七年,昭公要到楚国去,梦见襄公和他送行。子服惠伯说:“先君未尝适楚,故周公祖以道之,襄公适楚矣,而祖以道君。”据此,周公曾到过楚国,而《史记·蒙恬列传》,亦有周公奔楚之说,我颇疑心周公奔楚及其属党被执,乃是归政后之事。后来不知如何,又回到周朝。周公是否是善终,亦颇有可疑,杀害了一个人,因迷信的关系,又去求媚于他,这是野蛮时代常有的事,不足为怪。如此,则两说可通为一。楚国封于丹阳,其地实在丹、淅两水的会口(宋翔凤说,见《过庭录·楚鬻熊居丹阳武王徙郢考》),正当自武关东南出之路,据周公奔楚一事,我们又可见得周初发展的一条路线了。\n成王和他的儿子康王之时,称为西周的盛世。康王的儿子昭王,“南巡守不返,卒于江上”(《史记·周本纪》文)。这一个江字,也是南方之水的通称。其实昭王是伐楚而败,淹死在汉水里的,所以后来齐桓公伐楚,还把这件事情去诘问楚国(见《左传》僖公四年)。周朝对外的威力,开始受挫了。昭王子穆王,西征犬戎。其时徐偃王强,《后汉书·东夷传》谓其“率九夷以伐宗周,西至河上”。《后汉书》此语,未知何据(《博物志》亦载徐偃王之事,但《后汉书》所据,并不就是《博物志》,该是同据某一种古说的)。《礼记·檀弓下篇》载徐国容居的话,说“昔我先君驹王,西讨济于河”。驹王疑即偃王,则《后汉书》之说亦非全属子虚,被压服的东方,又想恢复其旧势了。然穆王使楚伐徐,偃王走死,则仍为西方所压服。穆王是周朝的雄主,在位颇久,当其时,周朝的声势,是颇振起的,穆王死后,就无此盛况了。穆王五传至厉王,因暴虐,为国人所逐,居外十四年。周朝的卿士周公、召公当国行政,谓之共和。厉王死于外,才立其子宣王。宣王号称中兴,然其在位之三十九年,与姜氏之戎战于千,为其所败。千在今山西的介休县,则周朝对于隔河的地方,业经控制不住,西方戎狄的势力,也渐次抬头了。至子幽王,遂为犬戎和南阳地方的申国所灭。幽王灭亡的事情,《史记》所载的,恢诡有类平话,决不是真相。《左传》昭公二十六年,载周朝的王子朝告诸侯的话,说这时候“携王干命,诸侯替之,而建王嗣,用迁郏鄏”(即东都之地,见《左传》宣公三年)。则幽王死后,西畿之地,还有一个携王。周朝当时,似乎是有内忧兼有外患的。携王为诸侯所废,周朝对于西畿之地,就不能控制了。而且介休败了;出武关向丹、淅的路,又已不通,只有对于东畿,还保存着相当的势力。平王于是迁居洛阳,号称东周,其事在公元前770年。\n第五章 春秋战国的竞争和秦国的统一 # 文化是从一个中心点,逐渐向各方面发展的。西周以前所传的,只有后世认为共主之国一个国家的历史,其余各方面的情形,都很茫昧。固然,书阙有间,不能因我们之无所见而断言其无有,然果有文化十分发达的地方,其事实也决不会全然失传的,于此,就可见得当时的文明,还是限于一个小区域之内了。东周以后则不然,斯时所传者,以各强国和文化较发达的地方的事迹为多,所谓天子之国,转若在无足重轻之列。原来古代所谓中原之地,不过自泰岱以西,华岳以东,太行以南,淮、汉以北,为今河南、山东的大部分,河北、山西的小部分。渭水流域的开发,怕还是西周兴起以来数百年间之事。到春秋时代,情形就大不然了。当时号称大国的,有晋、楚、齐、秦,其兴起较晚的,则有吴、越,乃在今山西的西南境,山东的东北境,陕西的中部,甘肃的东部,及江苏、浙江、安徽之境。在向来所称为中原之地的鲁、卫、宋、郑、陈、蔡、曹、许等,反夷为二三等国了。这实在是一个惊人的文化扩张。其原因何在呢?居于边地之国,因为和异族接近,以竞争磨砺而强,而其疆域亦易于拓展,该是其中最主要的。\n“周之东迁,晋、郑焉依。”(见《左传》隐公六年)即此便可见得当时王室的衰弱。古代大国的疆域,大约方百里,至春秋时则夷为三等国,其次等国大约方五百里,一等国则必方千里以上,请见第三十九章。当西周之世,合东西两畿之地,优足当春秋时的一个大国而有余,东迁以后,西畿既不能恢复,东畿地方,又颇受列国的剥削,周朝自然要夷于鲁、卫了。古语说“天无二日,民无二王”,这只是当时的一个希望。事实上,所谓王者,亦不过限于一区域之内,并不是普天之下,都服从他的。当春秋时,大约吴、楚等国称雄的区域,原不在周朝所管辖的范围内,所以各自称王。周天子所管辖的区域,因强国不止一个,没有一国能尽数慑服各国,所以不敢称王,只得以诸侯之长,即所谓霸主自居,这话在第三十九章中,亦已说过。所以春秋时代,大局的变迁,系于几个霸国手里。春秋之世,首起而称霸的是齐桓公。当时异民族杂居内地的颇多,也有相当强盛的,同族中的小国,颇受其压迫。(一)本来古代列国之间,多有同姓或婚姻的关系。(二)其不然的,则大国受了小国的朝贡,亦有加以保护的义务。(三)到这时候,文化相同之国,被文化不同之国所压迫,而互相救援,那更有些甫有萌芽的微茫的民族主义在内了。所以攘夷狄一举,颇为当时之人所称道。在这一点上,齐桓公的功绩是颇大的。他曾却狄以存邢、卫,又尝伐山戎以救燕(这个燕该是南燕,在今河南的封丘县。《史记》说它就是战国时的北燕,在今河北蓟县,怕是弄错了的,因为春秋时单称为燕的,都是南燕。即北燕的初封,我疑其亦距封丘不远,后来才迁徙到今蓟县,但其事无可考)。而他对于列国,征伐所至亦颇广。曾南伐楚,西向干涉晋国内乱,晚年又曾经略东夷。古人说“五霸桓公为盛”,信非虚语了。齐桓公的在位,系自前685至643年。桓公死后,齐国内乱,霸业遽衰。宋襄公欲继之称霸。然宋国较小,实力不足,前638年,为楚人所败,襄公受伤而死,北方遂无霸主。前632年,晋文公败楚于城濮(今山东濮县),楚国的声势才一挫。此时的秦国,亦已尽取西周旧地,东境至河,为西方一强国,然尚未能干涉中原之事。秦穆公初和晋国竞争不胜,前624年,打败了晋国的兵,亦仅称霸于西戎。中原之地,遂成为晋、楚争霸之局。前597年,楚庄王败晋于邲(今河南郑县),称霸。前591年卒。此时齐顷公亦图与晋争霸。前589年,为晋所败。前575年,晋厉公又败楚于鄢陵(今河南鄢县)。然楚仍与晋兵争不息。至前561年,楚国放弃争郑,晋悼公才称复霸。前546年,宋大夫向戌,善于晋、楚的执政,出而合二国之成,为弭兵之会,晋、楚的兵争,至此才告休息。自城濮之战至此,凡八十七年。弭兵盟后,楚灵王强盛,北方诸侯多奔走往与其朝会。然灵王奢侈而好兵争,不顾民力,旋因内乱被弑。此时吴国日渐强盛,而楚国政治腐败,前506年,楚国的都城,为吴阖闾所破,楚昭王藉秦援,仅得复国,楚国一时陷于不振,然越国亦渐强,起而乘吴之后。前496年,阖闾伐越,受伤而死。前494年,阖闾子夫差破越。夫差自此骄侈,北伐齐、鲁,与晋争长于黄池(今河南封丘县),前473年,越勾践灭吴,越遂徙都琅邪,与齐、晋会于徐州(今山东诸城县),称为霸王。然根基因此不固,至前333年而为楚所灭。\n此时已入于战国之世了(春秋时代,始于周平王四十九年,即鲁隐公元年,为公元前722年,终于前481年,共242年。其明年为战国之始,算至前222年秦灭六国的前一年为止,共259年)。春秋之世,诸侯只想争霸,即争得二三等国的服从,一等国之间,直接的兵争较少,有之亦不过疆场细故,不甚剧烈。至战国时,则(一)北方诸侯,亦不复将周天子放在眼里,而先后称王。(二)二三等国,已全然无足重轻,日益削弱,而终至于夷灭,诸一等国间,遂无复缓冲之国。(三)而其土地又日广,人民又日多,兵甲亦益盛,战争遂更烈。始而要凌驾于诸王之上而称帝,再进一步,就要径图并吞,实现统一的欲望了。春秋时的一等国,有发展过速,而其内部的组织,还不甚完密的,至战国时,则臣强于君的,如齐国的田氏,竟废其君而代之;势成分裂的,如晋之赵、韩、魏三家,则索性分晋而独立。看似力分而弱,实则其力量反更充实了。边方诸国,发展的趋势,依旧进行不已,其成功较晚的为北燕。天下遂分为燕、齐、赵、韩、魏、秦、楚七国。六国都为秦所并,读史的人,往往以为一入战国,而秦即最强,这是错误了的。秦国之强,起于献公而成于孝公,献公之立,在公元前385年,是入战国后的九十六年,孝公之立,在公元前361年,是入战国后的一百二十年了。先是魏文侯任用吴起等贤臣,侵夺秦国河西之地。后来楚悼王用吴起,南平百越,北并陈、蔡,却三晋,西伐秦,亦称雄于一时。楚悼王死于公元前381年,恰是入战国后的一百年,于是楚衰而魏惠王起,曾攻拔赵国的邯郸(今河北邯郸县)。后又伐赵,为齐救兵所败,秦人乘机恢复河西,魏遂弃安邑,徙都大梁(今河南开封县)。秦人渡蒲津东出的路,就开通了。然前342年,魏为逢泽之会(在开封)。《战国·秦策》称其“乘夏车,称夏王(此夏字该是大字的意思),朝天子,天下皆从”,则仍处于霸主的地位。其明年,又为齐所败。于是魏衰而齐代起,宣王、湣王两代,俨然称霸东方,而湣王之时为尤盛。相传苏秦约六国,合纵以摈秦,即在湣王之时。战国七雄,韩、魏地都较小,又逼近秦,故其势遂紧急,燕、赵则较偏僻,国势最盛的,自然是齐、秦、楚三国。楚袭春秋以来的声势,其地位又处于中部,似乎声光更在齐、秦之上,所以此时,齐、秦二国似乎是合力以谋楚的。《战国策》说张仪替秦国去骗楚怀王:肯绝齐,则送他商於的地方六百里(即今商县之地)。楚怀王听了他,张仪却悔约,说所送的地方,只有六里。怀王大怒,兴兵伐秦。两次大败,失去汉中。后来秦国又去诱他讲和,前299年,怀王去和秦昭王相会,遂为秦人所诱执。这种类乎平话的传说,是全不足信的,事实上,该是齐、秦合力以谋楚。然而楚怀王入秦的明年,齐人即合韩、魏以伐秦,败其兵于函谷(在今河南灵宝县西南,此为自河南入陕西的隘道的东口,今之潼关为其西口)。前296年,怀王死于秦,齐又合诸侯以攻秦;则齐湣王似是合秦以谋楚,又以此为秦国之罪而伐之的,其手段亦可谓狡黠了。先是前314年,齐国乘燕内乱攻破燕国。宋王偃称强东方,前286年,又为齐、楚、魏所灭。此举名为三国瓜分,实亦是以齐为主的,地亦多入于齐。齐湣王至此时,可谓臻于极盛。然过刚者必折。前284年,燕昭王遂合诸侯,用乐毅为将,攻破齐国,湣王走死,齐仅存聊、莒、即墨三城(聊,今山东聊城县。莒,今山东莒县。即墨,今山东平度县)。后来虽藉田单之力,得以复国,然已失其称霸东方的资格了。东方诸国中,赵武灵王颇有才略。他不与中原诸国争衡,而专心向边地开拓。先灭中山(今河北定县),又向今大同一带发展,意欲自此经河套之地去袭秦。前295年,又因内乱而死。七国遂惟秦独强。秦人遂对诸侯施其猛烈的攻击。前279年,秦白起伐楚,取鄢、邓、西陵。明年,遂破楚都郢,楚东北徙都陈,后又迁居寿春(鄢,即鄢陵。邓,今河南邓县。西陵,今湖北宜昌县。郢,今湖北江陵县西北。吴阖庐所入之郢,尚不在江陵,但其地不可考,至此时之郢,则必在江陵,今人钱穆、童书业说皆如此),直逃到今安徽境内了。对于韩、魏,亦时加攻击。前260年,秦兵伐韩,取野王,上党路绝,降赵,秦大败赵兵于长平,坑降卒四十万(野王,今河南沁阳县。上党,今山西晋城县。长平,今山西长平县),遂取上党,北定太原。进围邯郸,为魏公子无忌合诸国之兵所败。前256年,周朝的末主赧王为秦所灭。前249年,又灭其所分封的东周君。前246年,秦始皇立。《史记·秦本纪》说,这时候,吕不韦为相国,招致宾客游士,欲以并天下。大概并吞之计,和吕不韦是很有关系的。后来吕不韦虽废死于蜀,然秦人仍守其政策不变。前230年,灭韩,前228年,灭赵。燕太子丹使荆轲刺秦王,不中,秦大发兵以攻燕。前226年,燕王喜夺辽东。前225年,秦人灭魏。前223年,灭楚。前222年,发兵攻辽东,灭燕。前221年,即以灭燕之兵南灭齐,而天下遂统一。\n秦朝的统一,决不全是兵力的关系。我们须注意:此时交通的便利,列国内部的发达,小国的被夷灭,郡县的渐次设立,在政治上、经济上、文化上,本有趋于统一之势,而秦人特收其成功。秦人所以能收成功之利,则(一)它地处西垂,开化较晚,风气较为诚朴。(二)三晋地狭人稠,秦地广人稀,秦人因招致三晋之民,使之任耕,而使自己之民任战。(三)又能奉行法家的政策,裁抑贵族的势力,使能尽力于农战的人民,有一个邀赏的机会。该是其最重要的原因。\n●秦始皇\n第六章 古代对于异族的同化 # 中国民族,以同化力的伟大闻于天下,究竟我们对于异族的同化,是怎样一回事呢?说到这一点,就不能不着眼于中国的地理。亚洲的东部,在世界上,是自成其为一个文化区域的。这一个区域,以黄河、长江两流域为其文化的中心。其北为蒙古高原,便于游牧民族的住居。其南的粤江、闽江两流域,则地势崎岖,气候炎热,开化虽甚早,进步却较迟。黄河、长江两流域,也不是没有山地的,但其下流,则包括淮水流域(以古地理言之,则江、河之间,包括淮、济二水。今黄河下流,为古济水入海之道,黄河则在今天津入海),扩展为一大平原,地味腴沃,气候适宜,这便是中国民族的文化最初函毓之处。汉族,很早的就是个农耕民族,惯居于平地。其所遇见的民族,就其所居之地言之,可以分为两种:一种是住在山地的,古代称为山戎,多数似亦以农为业,但其农业不及中国的进步。一种是住在平地,大约是广大的草原上,而以畜牧为业的,古人称为骑寇。春秋以前,我族所遇的,以山戎为多,战国以后,才开始和骑寇接触。\n夷、蛮、戎、狄,是按着方位分别之辞,并不能代表民族,但亦可见得一个大概。在古代,和中国民族争斗较烈的,似乎是戎狄。据《史记·五帝本纪》,黄帝就北逐獯粥,未知确否(如《史记》此说是正确的,则当时的獯粥,决不在后来的獯粥所在之地)。到周朝初年,则和所谓獯粥或称为猃狁,犬戎或称为昆夷、串夷的,争斗甚烈(猃狁亦作狁,犬戎亦作畎戎,戎又作夷。此犬或畎字乃译音,非贱视诋毁之辞,昆夷亦作混夷、绲夷,夷亦可作戎,和串夷亦都是犬字的异译,说见《诗经·皇矣正义》),而后来周朝卒亡于犬戎。犬戎在今陕西的中部,甘肃的东部,泾、渭二水流域间,东周以后,大约逐渐为秦人所征服。在其东方的,《春秋》所载,初但称狄,后分为赤狄、白狄。白狄在今陕西境内,向东蔓延到中山。赤狄在今山西、河北境内,大部为晋所并(据《左传》和杜预《注》,赤狄种类凡六:曰东山皋落氏,在今山西昔阳县。曰廧咎如,在今山西乐平县。曰潞氏,在今山西潞城县。曰甲氏,在今河北鸡泽县。曰留吁,在今山西屯留县。曰铎辰,在今山西长治县。白狄种类凡三:曰鲜虞,即战国时的中山。曰肥,在今河北藁城县。曰鼓,在今河北晋县。又晋国吕相绝秦,说“白狄及君同州”,则白狄亦有在陕西的)。在周朝的西面的,主要的是后世的氐、羌。氐人在今嘉陵江流域,即古所谓巴。羌人,汉时在今黄河、大通河流域(大通河,古湟水)。据《后汉书》所载,其初本在黄河之东,后来为秦人所攘斥,才逃到黄河以西去的。据《书经·牧誓》,羌人曾从武王伐纣。又《尚书大传》说:武王伐纣的兵,前歌后舞,《后汉书》说这就是汉时所谓巴氐的兵。这话大约是对的,因为汉世还有一种出于巴氐的巴渝舞,有事实为证。然则这两族,其初必不在今四川、甘肃境内,大约因汉族的开拓,而向西南方走去的。和巴连称的蜀,则和后世的贝字是一音之转,亦即近世之所谓暹。据《牧誓》,亦曾从武王伐纣。战国时,还在今汉中之境,南跨成都。后因和巴人相攻,为秦国所并。\n在东北方的民族,古称为貉。此族在后世,蔓衍于今朝鲜半岛之地,其文明程度是很高的。但《诗经》已说“王锡韩侯,其追其貉”(《韩奕》。追不可考),《周官》亦有貉隶,可见此族本在内地,箕子所封的朝鲜,决不在今朝鲜半岛境内,怕还在山海关以内呢!在后世,东北之族,还有肃慎,即今满洲人的祖宗。《左传》昭公九年,周朝人对晋国人说:“自武王克商以来,肃慎、燕、亳,吾北土也。”此燕当即南燕,亳疑即汤所居之郼,则肃慎亦在内地,后乃随中国的拓展而东北徙。《国语·晋语》说:成王会诸侯于岐阳,楚与鲜卑守燎,则鲜卑本是南族,后来不知如何,也迁向东北了。据《后汉书》说:鲜卑和乌丸,都是东胡之后。此二族风俗极相像,其本系一个部落,毫无可疑。东胡的风俗,虽少可考,然汉代历史,传者已较详,汉人说它是乌丸、鲜卑所自出,其说该不至误。南族断发,鲜卑婚姻时尚先髠头,即其源出南族之证。然则东胡也是从内地迁徙出去的了。\n在南方的有黎族,此即后世所谓俚。古称三苗为九黎之君,三苗系姜姓之国,九黎则系黎民(见《礼记·缁衣》《疏》引《书经·吕刑》郑《注》)。此即汉时之长沙武陵蛮,为南蛮的正宗。近世所云苗族,乃蛮字的转音,和古代的三苗之国无涉,有人将二者牵合为一,就错了。《史记》说三苗在江、淮、荆州(《史记·五帝本纪》),《战国·魏策》,吴起说三苗之国,在洞庭、彭蠡之间(《史记·吴起列传》同,又见《韩诗外传》)。则古代长江流域之地,主要的是为黎族所占据,楚国达到长江流域后,所开辟的,大约是这一族的居地。在沿海一带的,古称为越,亦作粤。此即现在的马来人,分布在亚洲大陆的沿岸,和南洋群岛,地理学上称为亚洲大陆的真沿边的。此族有断发文身和食人两种风俗,在后世犹然,古代沿海一带,亦到处有这风俗,可知其为同族。吴、越的初期,都是和此族杂居的。即淮水流域的淮夷、徐戎,山东半岛的莱夷,亦必和此族相杂(《礼记·王制》说:“东方曰夷,被发文身”,此被字为髲字之假错字,即断发,可见蛮夷之俗相同。《左传》僖公十九年,“宋公使邾文公用鄫子于次睢之社,欲以属东夷”,可见东夷亦有食人之俗。《续汉书·郡国志》:“临沂有丛亭。”《注》引《博物志》曰:“县东界次睢,有大丛社,民谓之食人社,即次睢之社。”临沂,今山东临沂县)。随着吴、越等国的进步,此族亦渐进于文明了。西南的大族为濮,此即现在的倮。其居地,本在今河南、湖北两省间(《国语·郑语》韦《注》:濮为南阳之国)。楚国从河南的西南部,发展向今湖北省的西部,所开辟的,大约是此族的居地。此族又从今湖北的西南境,向贵州、云南分布。战国时,楚国的庄,循牂牁江而上,直达滇国(今云南昆明县),所经的,也是这一族之地。庄到滇国之后,楚国的巴、黔中郡(巴郡,今四川江北县。黔中郡,今湖南沅陵县),为秦国所夺,庄不能来,就在滇国做了一个王。其地虽未正式收人中国的版图,亦已戴汉人为君了,和现在西南土司,以汉人为酋长的一样了。\n《礼记·王制》说:古代的疆域,“北不尽恒山”,此所谓恒山,当在今河北正定县附近,即汉朝恒山郡之地(后避文帝讳改常山)。自此以南的平地,为汉族所居,这一带山地,则山戎所处,必得把它开拓了,才会和北方骑寇相接,所以汉族和骑寇的接触,必在太原、中山和战国时北燕之地开辟以后。做这件事业的,就是燕、赵两国。赵武灵王开辟云中、雁门、代郡,燕国则开辟上谷、渔阳、右北平、辽西、辽东五郡(云中,今山西大同县。雁门,今山西右玉县。代郡,今山西代县。上谷,今察哈尔怀来县。渔阳,今河北密云县。右北平,今河北卢龙县。辽西,今河北抚宁县。辽东,今辽宁辽阳县),把现在热、察、绥、辽宁四省,一举而收入版图。\n综观以上所述,汉族恃其文化之高,把附近的民族,逐渐同化,而汉族的疆域,亦即随之拓展。和汉族接近的民族,当汉族开拓时,自然也有散向四方,即汉族的版图以外去的,然亦多少带了些中原的文化以俱去,这又是中国的文化扩展的路径。这便是在古代中国同化异民族的真相。\n第七章 古代社会的综述 # 周和秦,是从前读史的人看作古今的界线的。我们任意翻阅旧书,总可见到“三代以上”,“秦、汉以下”等辞句。前人的见解,固然不甚确实,也不会全属虚诬;而且既有这个见解,也总有一个来历。然则所谓三代以上,到底是怎样一个世界呢?\n●清源山老君造像刻于宋代,位于福建泉州,高5.63米,厚6.85米,宽8.01米,席地面积55平方米\n人,总是要维持其生命的;不但要维持生命,还要追求幸福,以扩大其生命的意义;这是人类的本性如此,无可怀疑。人类在生物史上,其互相团结,以谋生存,已不知其若干年了。所以其相亲相爱,看得他人的苦乐,和自己的苦乐一般;喜欢受到同类的嘉奖,而不愿意受到其批评;到人己利害不相容时,宁可牺牲自己,以保全他人;即古人之所谓仁心者,和其爱自己的心,一样的深刻。专指七尺之躯为我,或者专指一个极小的团体为我,实在是没有这回事的。人类为要维持生命,追求幸福,必得和自然斗争。和自然斗争,一个人的力量,自然是不够的,于是乎要合力;合力之道,必须分工,这都是自然的趋势。分工合力,自然是范围愈大,利益愈多,所以团体的范围,总是在日扩而大。但是人类的能力是有限的,在进行中,却不能不形成敌对的状态,这是为什么呢?皇古之世,因环境的限制,把人类分做许多小团体。在一个团体之中,个个人的利害,都是相同的,在团体以外却不然;又因物质的欲求,不能够都给足;团体和团体间就开始有争斗,有争斗就有胜败,有胜败就有征服者和被征服者之分。“人不可以害人的,害人的必自害。”这句话,看似迂腐,其实却是真理。你把迷信者流因果报应之说去解释这句话,自然是诬罔的,若肯博观事实,而平心推求其因果,那正见得其丝毫不爽。对内竞争和对外竞争,虽竞争的对象不同,其为竞争则一。既然把对物的争斗,移而用之于对人,自可将对外的争斗,移而用之于对内。一个团体之中,有征服者和被征服者之分,不必说了。即使无之,而当其争斗之时,基于分工的关系,自然有一部分人,专以战争为事,这一部分人,自将处于特殊的地位。前此团体之中,个个人利害相同的,至此则形成对立。前此公众的事情,是由公众决定的,至此,则当权的一个人或少数人,渐渐不容公众过问,渐渐要做违背公众利益的措置,公众自然不服,乃不得不用强力镇压,或者用手段对付。于是团体之中有了阶级,而形成现代的所谓国家。以上所述,是从政治上立论的。其变迁的根源,实由于团体和团体的互相争斗,而团体和团体的互相争斗,则由于有些团体,迫于环境,以掠夺为生产的手段。所以其真正的根源,还是在于经济上。经济的根柢是生产方法。在古代,主要的生业是农业,农业的生产方法,是由粗而趋于精,亦即由合而趋于分的,于是形成了井田制度,因而固定了五口、八口的小家族,使一个团体之中,再分为无数利害对立的小团体。从前在一个团体之内,利害即不再对立的氏族制度,因此而趋于崩溃了。氏族既已崩溃,则专门从事于制造,而以服务性质,无条件供给大众使用的工业制度,亦随之而崩溃。人,本来是非分工合力不能生存的,至此时,因生活程度的增高,其不能不互相倚赖愈甚,分配之法既废,交易之法乃起而代之,本行于团体与团体之间的商业,乃一变而行于团体之内人与人之间,使人人的利害,都处于对立的地位。于是乎人心大变。在从前,团体与团体之间,是互相嫉视的,在一个团体之内,是互视为一体的。至此时,团体之内,其互相嫉视日深。在团体与团体之间,却因生活的互相倚赖而往来日密,其互相了解的程度,即随之而日深,同情心亦即随之而扩大。又因其彼此互相仿效,以及受了外部的影响,而内部的组织,不得不随之而起变化,各地方的风俗亦日趋于统一。民族的同化作用,即缘此而进行。政治上的统一,不过是顺着这种趋势推进。再彻底些说,政治上的统一,只是在当时情况之下,完成统一的一个方法。并不是政治的本身,真有多大的力量。随着世运的进展,井田制度破坏了。连公用的山泽,亦为私人所占。工商业愈活跃,其剥削消费者愈深。在上的君主和贵族,亦因其日趋于腐败、奢侈,而其剥削人民愈甚。习久于战争就养成一种特别阶级,视战斗为壮快、征服为荣誉的心理,认为与其出汗,毋宁出血。此即孔子和其余的先秦诸子所身逢的乱世。追想前一个时期,列国之间,战争还不十分剧烈。一国之内,虽然已有阶级的对立,然前此利害共同时的旧组织,还有存留,而未至于破坏净尽。秩序还不算十分恶劣,人生其间的,也还不至于十分痛苦,好像带病延年的人,虽不能算健康,还可算一个准健康体,此即孔子所谓小康。再前一个时期,内部毫无矛盾,对外毫无竞争,则即所谓大同了。在大同之世,物质上的享受,或者远不如后来,然而人类最亲切的苦乐,其实不在于物质,而在于人与人间的关系,所以大同时代的境界,永存于人类记忆之中。不但孔子,即先秦诸子,亦无不如此(道家无论已,即最切实际的法家亦然。如《管子》亦将皇、帝、王、霸分别治法的高下;《史记·商君列传》亦载商君初说秦孝公以帝王之道,秦孝公不能用,乃说之以富国强兵之术都是)。这不是少数人的理想高尚,乃是受了大多数人的暗示而然的。人类生当此际,实应把其所以致此之由,彻底地加以检讨,明白其所以然之故,然后将现社会的组织,摧毁之而加以改造。这亦非古人所没有想到,先秦诸子,如儒、墨、道、法诸家,就同抱着这个志愿的,但其所主张的改革的方法,都不甚适合。道家空存想望,并没有具体实行的方案的,不必说了。墨家不讲平均分配,而专讲节制消费,也是不能行的。儒家希望恢复井田,法家希望制止大工商业的跋扈;把大事业收归官营;救济事业亦由国家办理,以制止富豪的重利盘剥,进步些了。然单讲平均地权,本不能解决社会的经济问题,兼讲节制资本,又苦于没有推行的机关。在政治上,因为民主政治,废坠的久了,诸家虽都以民为重,却想不出一个使人民参与政治的办法,而只希望在上者用温情主义来抚恤人民,尊重舆论,用督责手段,以制止臣下的虐民。在国与国之间,儒家则希望有一个明王出来,能够处理列国间的纷争,而监督其内政;法家因为兴起较后,渐抱统一的思想,然秦朝的统一,和贵族的被裁抑,都只是事势的迁流,并不能实行法家的理想,所以要自此再进一步,就没有办法了。在伦理上,诸家所希望的,同是使下级服从上级,臣民该服从君主,儿子要服从父亲,妇女要服从男子,少年该服从老人。他们以为上级和下级的人,各安其分,各尽其职,则天下自然太平,而不知道上级的人受不到制裁,决不会安其分而尽其职。总而言之:小康之世,所以向乱世发展,是有其深刻的原因的。世运只能向前进,要想改革,只能顺其前进的趋势而加以指导。先秦诸子中,只有法家最看得出社会前进的趋势,然其指导亦未能全然得法。他家则都是想把世运逆挽之,使其回到小康以前的时代的,所以都不能行。\n●孔子像\n虽然如此,人类生来是避苦求乐的,身受的苦痛,是不能使人不感觉的,既然感觉了,自然要求摆脱。求摆脱,总得有个办法,而人类凭空是想不出办法来的。世运只有日新,今天之后,只会有明天,而人所知道的,最新亦只是今日以前之事,于是乎想出来的办法,总不免失之于旧,这个在今日尚然,何况古代?最好的时代是过去了,但永存于人类想望记忆之中。虽回忆之,而并不知其真相如何,乃各以其所谓最好者当之。合众人的所谓最好者,而调和折衷,造成一个大略为众所共认的偶像,此即昔人所谓三代以前的世界。这个三代以前的世界,其不合实际,自然是无待于言的。这似乎只是一个历史上的误解,无甚关系,然奉此开倒车的办法为偶像而思实践之,就不徒不能达到希望,而且还要引起纠纷。\n第八章 秦朝治天下的政策 # 秦始皇尽灭六国,事在公元前221年,自此至公元189年,董卓行废立,东方州郡,起兵讨卓,海内扰乱分裂,共四百年,称为中国的盛世。在这一时期之中,中国的历史,情形是怎样呢?“英雄造时势”,只是一句夸大的话。事实上,英雄之所以成为英雄,正因其能顺着时势,进行之故。“时势造英雄”这句话倒是真的,因为他能决定英雄的趋向。然则在这一个时期之内,时势的要求,是怎样呢?依我们所见到的,可以分为对内对外两方面:对内方面,在列国竞争之时,不能注全力于内治;即使注意到,亦只是局部的问题,而不能概括全体,只是一时的应付,而不能策划永久。统一之后,就不然了。阻碍之力既去,有志于治平的,就可以行其理想。对外方面,当时的人看中国,已经是天下的一大部分了。未入版图的地方,较强悍的部落,虑其为中国之患,该有一个对策;较弱小的,虽然不足为患,然亦是平天下的一个遗憾,先知先觉的中国人,在力所能及的范围内,亦有其应尽的责任。所以在当日,我们所需要的是:一、对内建立一个久安长治的规模。二、对外把力所能及的地方,都收入中国版图之内,其未能的,则确立起一条防线来。\n秦始皇所行的,正顺着这种趋势。\n在古代,阻碍平天下最大的力量,自然是列国的纷争。所以秦并吞六国之后,决计不再行封建,“父兄有天下,而子弟为匹夫”。郡的设立,本来是军事上控扼之点,第三十九章中业经说过。六国新灭,遗民未曾心服,自然有在各地方设立据点的必要。所以秦灭六国,多以其地设郡。至六国尽灭之后,则更合全国的情形,加以调整,分天下为三十六郡。当时的郡守,就是一个不世袭的大国之君,自亦有防其专擅的必要。所以每郡又都派一个御史去监察他(当时还每郡都设立一个尉,但其权远在郡守之下,倒是不足重视的)。\n要人民不能反抗,第一步办法,自然是解除其武装。好在当时,金属铸成的兵器,为数有限,正和今日的枪械一般,大略可以收尽的。于是收天下之兵,聚之咸阳,铸以为金人和锺、(秦都咸阳,今陕西咸阳县)。\n最根本的,莫过于统一人民的心思了。原来古代社会,内部没有矛盾,在下者的意见,常和在上者一致,此即所谓“天下有道,则庶人不议”(《论语·季氏》)。后世阶级分化,内部的矛盾多了,有利于这方面的就不利于那方面。自然人民的意见,不能统一。处置之法,最好的,是使其利害相一致;次之则当求各方面的协调,使其都有发表意见的机会,此即今日社会主义和民主政治的原理。但当时的人,不知此理。他们不知道各方面的利害冲突了,所以有不同的见解,误以为许多方面,各有其不同的主张,以致人各有心,代表全国公益的在上者的政策,不能顺利进行。如此,自有统一全国人的心思的必要。所以在《管子·法禁》、《韩非子·问辨》两篇中,早有焚书的主张。秦始皇及李斯就把它实行了。把关涉到社会、政治问题的“诗、书、百家语”都烧掉,只留下关系技术作用的医药、卜筮、种树之书。涉及社会、政治问题的,所许学的,只有当代的法令;有权教授的人,即是当时的官吏。若认为始皇、李斯此举,不合时代潮流,他们是百口无以自解的,若认为有背于古,则实在冤枉。他们所想回复的,正是古代“政教合一,官师不分”之旧。古代的情形是如此,清朝的章学诚是发挥得十分透彻的(坑儒一举,乃因有人诽谤始皇而起,意非欲尽灭儒生,并不能与焚书之事并论)。\n以上是秦始皇对内的政策。至于对外,则北自阴山以南,南自五岭以南至海,秦始皇都认为应当收入版图。于是使蒙恬北逐匈奴,取河南之地(今之河套),把战国时秦、赵、燕三国北边的长城连接起来,东起现在朝鲜境内(秦长城起自乐浪郡遂城县,见《汉书·地理志》),西至现在甘肃的岷县,成立了一道新防线。南则略取现在广东、广西和越南之地,设立了桂林、南海、象三郡(大略桂林是今广西之地,南海是今广东之地,象郡是今越南之地),取今福建之地,设立了闽中郡。楚国庄所开辟的地方,虽未曾正式收入版图,亦有一部分曾和秦朝交通,秦于其地置吏。\n秦始皇,向来都说他是暴君,把他的好处一笔抹杀了,其实这是冤枉的。看以上所述,他的政治实在是抱有一种伟大的理想的。这亦非他一人所能为,大约是法家所定的政策,而他据以实行的。这只要看他用李斯为宰相,言听计从,焚诗书、废封建之议,都出于李斯而可知。政治是不能专凭理想,而要顾及实际的情形的,即不论实际的情形能行与否,亦还要顾到行之之手腕。秦始皇的政策虽好,行之却似过于急进。北筑长城,南收两越,除当时的征战外,还要发兵戍守;既然有兵戍守,就得运粮饷去供给;这样,人民业已不堪赋役的负担。他还沿着战国以前的旧习惯,虐民以自奉。造阿房宫,在骊山起坟茔(骊山,在今陕西临潼县),都穷极奢侈;还要到处去巡游。统一虽然是势所必至,然而人的见解,总是落后的,在当时的人,怕并不认为合理之举,甚而至于认为反常之态。况且不必论理,六国夷灭,总有一班失其地位的人,心上是不服的,满怀着报仇的愤恨,和复旧的希望;加以大多数人民的困于无告而易于煽动,一有机会,就要乘机而起了。\n第九章 秦汉间封建政体的反动 # 秦始皇帝以前210年,东巡死于沙丘(今河北邢台县)。他大的儿子,名唤扶苏,先已谪罚到上郡去(今陕西绥德县),做蒙恬军队中的监军了。从前政治上的惯例,太子是不出京城,不做军队中的事务的,苟其如此,就是表示不拟立他的意思。所以秦始皇的不立扶苏,是预定了的。《史记》说秦始皇的少子胡亥,宠幸宦者赵高,始皇死后,赵高替胡亥运动李斯,假造诏书,杀掉扶苏、蒙恬而立胡亥,这话是不足信的(《史记·李斯列传》所载的全是当时的传说,并非事实。秦汉间的史实,如此者甚多)。胡亥既立,是为二世皇帝。他诛戮群公子,又杀掉蒙恬的兄弟蒙毅。最后,连劳苦功高、资格很老的李斯都被杀掉。于是秦朝的政府,失其重心,再不能钳制天下了。皇帝的家庭之中,明争暗斗,向来是很多的,而于继承之际为尤甚。这个并不起于秦朝,但在天下统一之后,皇室所管辖的地方大了,因其内部有问题而牵动大局,使人民皆受其祸,其所牵涉的范围,也就更广大了。秦始皇之死,距其尽灭六国,不过十二年,而此祸遂作。\n秦始皇死的明年,戍卒陈胜、吴广起兵于蕲(今安徽宿县),北取陈。胜自立为王,号张楚。分兵四出徇地,郡县多杀其守令以应。六国之后,遂乘机并起。秦朝政治虽乱,兵力尚强;诸侯之兵,多是乌合之众;加以心力不齐,不肯互相救援;所以秦将章邯,倒也所向无敌。先镇压了陈胜、吴广,又打死了新立的魏王。战国时楚国的名将,即最后支持楚国而战死的项燕的儿子项梁,和其兄子项籍,起兵于吴,引兵渡江而西(今江苏之江南,古称江东。古所谓江南,指今之湖南)。以居巢人范增的游说,立楚怀王的后裔于盱眙(居巢,今安徽巢县。盱眙,今安徽盱眙县),仍称为楚怀王(以祖谥为生号)。项梁引兵而北,兵锋颇锐,连战皆胜,后亦为章邯所袭杀。章邯以为楚地兵不足忧,乃北围赵王于巨鹿(今河北平乡县)。北强南弱,乃是东晋以后逐渐转变成功的形势。自此以前,都是北方的军队,以节制胜,南方的军队,以剽悍胜的。尤其是吴、越之士,《汉书·地理志》上,还称其“轻死好用剑”。项梁既死,楚怀王分遣项籍北救赵,起兵于沛的刘邦即汉高祖西入关(沛,今江苏沛县)。项籍大破秦兵于巨鹿。汉高祖亦自武关而入。此时二世和赵高,不知如何又翻了脸,赵高弑二世,立其兄子婴,婴又刺杀高,正当纷乱之际,汉高祖的兵已到霸上(在今陕西长安县东),子婴只得投降,秦朝就此灭亡。此事在前206年。\n既称秦之灭六国为无道,斥为强虎狼,灭秦之后,自无一人专据称尊之理,自然要分封。但是分封之权,出于何人呢?读史的人,都以为是项籍。这是错了的。项籍纵使在实际上有支配之权,形式上决不能专断,况且实际上也未必能全由项籍一个人支配?项籍既破章邯之后,亦引兵西人关。汉高祖先已入关了,即遣将守关。项羽怒,把他攻破。进兵至鸿门(在今陕西临潼县),和高祖几乎开战。幸而有人居间调解,汉高祖自己去见项籍,解释了一番,战事得以未成。此时即议定了分封之事。这一件事,《史记》的《自序》称为“诸侯之相王”,可见形式上是取决于公议的。其所封的:为(一)六国之后,(二)亡秦有功之人,(三)而楚怀王则以空名尊为义帝,(四)实权则在称为西楚霸王的项籍(都彭城,当时称其地为西楚。江陵为南楚,吴为东楚)。这是摹仿东周以后,天子仅拥虚名,而实权在于霸主的。分封的办法,我们看《史记》所载,并不能说它不公平。汉朝人说:楚怀王遣诸将入关时,与之约:先入关者王之,所以汉高祖当王关中,项籍把他改封在巴、蜀、汉中为背约。姑无论这话的真假,即使是真的,楚怀王的命令,安能约束楚国以外的人呢?这且不必论它。前文业经说过:人的思想,总是落后的,观于秦、汉之间而益信。封建政体,既已不能维持,于是分封甫定,而叛乱即起于东方。项籍因为是霸王,有征讨的责任,用兵于齐。汉高祖乘机北定关中。又出关,合诸侯之兵,攻破彭城。项籍虽然还兵把他打败,然汉高祖坚守荥阳、成皋(荥阳,今河南荥泽县。成皋,今河南汜水县),得萧何镇守关中,继续供给兵员和粮饷。遣韩信渡河,北定赵、代,东破齐。彭越又直接扰乱项籍的后方。至前202年,项籍遂因兵少食尽,为汉所灭。从秦亡至此,不过五年。\n事实上,天下又已趋于统一了。然而当时的人,怕不是这样看法。当楚、汉相持之时,有一策士,名唤蒯彻,曾劝韩信以三分天下之计。汉高祖最后攻击项籍时,和韩信、彭越相约合力,而信、越的兵都不会,到后来,约定把齐地尽给韩信,梁地尽给彭越,二人才都引兵而来,这不是以君的资格分封其臣,乃是以对等的资格立分地之约。所以汉高祖的灭楚,以实在情形论,与其说是汉灭楚,毋宁说是许多诸侯,亦即许多支新崛起的军队,联合以灭楚,汉高祖不过是联军中的首领罢了。楚既灭,这联军中的首领,自然有享受一个较众为尊的名号的资格,于是共尊汉高祖为皇帝。然虽有此称号,在实际上,未必含有沿袭秦朝皇帝职权的意义。做了皇帝之后,就可以任意诛灭废置诸王侯,怕是当时的人所不能想象的,这是韩信等在当时所以肯尊汉高祖为皇帝之故。不然,怕就没有这么容易了。汉初异姓之王,有楚王韩信、梁王彭越、赵王张敖、韩王信、淮南王英布、燕王臧荼、长沙王吴芮。这都是事实上先已存在,不得不封的,并非是皇帝的意思所设置。汉高祖灭楚之后,即从娄敬、张良之说,西都关中,当时的理由,是关中地势险固,且面积较大,资源丰富,易于据守及用以临制诸侯,可见他原只想做列国中最强的一国。但是事势所趋,人自然会做出不被思想所拘束的事情来的。不数年间,而韩信、彭越,都以汉朝的诡谋被灭。张敖以罪见废。韩王信、英布、臧荼,都以反而败。臧荼之后,立了一个卢绾,是汉高祖生平第一个亲信人,亦因被谗而亡入匈奴。到前195年汉高祖死时,只剩得一个地小而且偏僻的长沙国了。天下至此,才真正可以算是姓刘的天下。其成功之速,可以说和汉高祖的灭楚,同是一个奇迹。这亦并不是汉高祖所能为,不过封建政体,到这时候业已自趋于没落罢了。\n以一个政府之力统治全国,秦始皇是有此魄力的,或亦可以说是有此公心,替天下废除封建,汉高祖却无有了。既猜忌异姓,就要大封同姓以自辅,于是随着异姓诸侯的灭亡,而同姓诸国次第建立。其中尤以高祖的长子齐王肥,封地既大,人民又多,且居东方形胜之地,为当时所重视(又有淮南王长,燕王建,赵王如意,梁王恢,代王恒,淮阳王友,皆高帝子。楚王交,高帝弟。吴王濞,高帝兄子)。宗法社会中,所信任的,不是同姓,便是外戚。汉初功臣韩信、彭越等,不过因其封地大,所以特别被猜忌,其余无封地,或者仅有小封土的,亦安能“与官同心”?汉高祖东征西讨,频年在外,中央政府所委任的,却是何人呢?幸而他的皇后吕氏是很有能力的。她的母家,大约亦是当时所谓豪杰之流;她的哥哥吕泽和吕释之,都跟随高祖带兵;妹夫樊哙,尤其是功臣中的佼佼者;所以在当时,亦自成为一种势力。高祖频年在外,京城里的事情,把持着的便是她,这只要看韩信、彭越都死在她手里,便可知道。所以高祖死后,嗣子惠帝,虽然懦弱,倒也安安稳稳地做了七年皇帝。惠帝死后,嗣子少帝,又做了四年。不知何故(吕后女鲁元公主,下嫁张敖,敖女为惠帝后。《史记》说他无子,佯为有身,取美人子,杀其母,名为己子。惠帝崩,立,既长,闻其事,口出怨言,为吕后所废。此非事实。张皇后之立,据《汉书》本纪,事在惠帝四年十月,至少帝四年仅七年,少帝至多不过七岁,安有知怨吕后之理),为吕后所废而立其弟。吕后临朝称制。又四年而死。吕后活着的时候,虽然封了几个母家的人为王,却都没有到国。吕后,其实并无推翻刘氏、重用吕氏的意思,所任用的,还是汉初的几个功臣,这班人究竟未免有些可怕,所以临死的时候,吩咐带北军的吕禄、南军的吕产(禄,释之之子。产,泽子),“据兵卫宫”,不要出去送丧,以防有人在京城里乘虚作乱。此时齐王肥已经死了,子襄继为齐王。其弟朱虚侯章在京城里,暗中派人去叫他起兵。汉朝派功臣灌婴去打他。灌婴到荥阳,和齐王连和,于是前敌形成了僵局。丞相陈平、太尉周勃等,乃派人运动吕禄,交出兵权。吕禄犹豫未决,周勃用诈术突入北军,运动军人,反对吕氏。把吕禄、吕产和其余吕氏的人都杀掉。于是阴谋说惠帝的儿子都不是惠帝所生的,就高帝现存的儿子中,择其最长的,迎立了代王恒,是为文帝。齐王一支人,自然是不服的。文帝乃运用手腕,即分齐地,封朱虚侯为城阳王,朱虚侯之弟东牟侯兴居为济北王(城阳治莒,今山东莒县。济北治卢,今山东长清县)。城阳王不久就死了。济北王以反被诛。汉初宗室、外戚、功臣的三角斗争,至此才告结束。\n●西汉·阳陵兵马俑 陕西咸阳市张家湾咸阳原出土。西汉景帝阳陵陪葬俑,相当于真人的二分之一大小\n当时的功臣,所以不敢推翻刘氏,和汉朝同姓分封之多,确实是有关系的,所以封建不能说没有一时之用。然而异姓功臣都灭亡后,所患的,却又在于同姓了。要铲除同姓诸侯尾大不掉之患,自不外乎贾谊“众建诸侯而少其力”一语。这话,当文帝时,其实是已经实行了的,齐王襄传子则,则死后没有儿子,文帝就把他的地方,分为齐、济北、济南、菑川、胶西、胶东六国(济南治东平陵,今山东历城县。菑川治剧,今山东寿光县。胶西治高苑,今山东桓台县。胶东治即墨,今山东即墨县),立了齐王肥的庶子六人。又把淮南之地,分成三国。但吴、楚仍是大国,吴王濞尤积有反心。晁错力劝文帝以法绳诸侯,文帝是个因循的人,没有能彻底实行。前157年,文帝死,子景帝立。晁错做了御史大夫,即实行其所主张。前154年,吴王联合楚、赵、胶西、胶东、菑川、济南造反,声势很盛。幸而吴王不懂得兵谋,“屯聚而西,无他奇道”,为周亚夫所败。于是景帝改定制度,诸侯王不得治民,令相代治其国。到武帝,又用主父偃之计,令诸侯得以其地,分封自己的子弟,在平和的手腕中,把“众建诸侯而少其力”一语,彻底实行了。封建政体反动的余波,至此才算解决。从秦二世元年六国复立起,到吴、楚之乱平定,共五十六年。\n第十章 汉武帝的内政外交 # 在第八章里所提出的对内对外两个问题,乃是统一以后自然存在着的问题,前文业经说明了。这个问题,自前206年秦灭汉兴,至前141年景帝之死,共六十六年,久被搁置着不提了。这是因为高帝、吕后时,忙于应付异姓功臣,文帝、景帝时,又存在着一个同姓诸王的问题;高帝本是无赖子,文、景二帝亦只是个寻常人,凡事都只会蹈常习故之故。当这时候,天下新离兵革之患,再没有像战国以前年年打仗的事情了。郡县长官,比起世袭的诸侯来,自然权力要小了许多,不敢虐民。诸侯王虽有荒淫昏暴的,比之战国以前,自然也差得远了。这时候的中央政府,又一事不办,和秦始皇的多所作为,要加重人民负担的,大不相同。在私有财产制度之下,人人都急于自谋,你只要不去扰累他,他自然会休养生息,日臻富厚。所以据《史记·平准书》说:在武帝的初年,海内是很为富庶的。但是如此就算了么?须知社会,并不是有了钱就没有问题的。况且当时所谓有钱,只是总算起来,富力有所增加,并不是人人都有饭吃,富的人富了,穷的人还是一样的穷,而且因贫富相形,使人心更感觉不平,感觉不足。而对外的问题,时势亦逼着我们不能闭关自守。汉武帝并不是真有什么本领的人,但是他的志愿,却较文、景二帝为大,不肯蹈常习故,一事不办,于是久经搁置的问题,又要重被提起了。\n●汉武帝像\n当时对内的问题,因海内已无反侧,用不到像秦始皇一般,注意于镇压,而可以谋一个长治久安之策。这个问题,在当时的人看起来,重要的有两方面:一个是生计,一个是教化,这是理论上当然的结果。衣食足而知荣辱,生计问题,自然在教化之先;而要解决生计问题,又不过平均地权、节制资本两途,这亦是理论上当然的结果。最能解决这两个问题的,是哪一家的学术呢?那么,言平均地权和教化者,莫如儒家,言节制资本者,莫如法家。汉武帝,大家称他是崇儒的人,其实他并不是真懂得儒家之道的。他所以崇儒,大约因为他的性质是夸大的,要做些表面上的事情,如改正朔、易服色等,而此等事情,只有儒家最为擅长之故。所以当时一个真正的儒家董仲舒,提出了限民名田的主张,他并不能行。他的功绩,最大的,只是替《五经》博士置弟子,设科射策,劝以官禄,使儒家之学,得国家的提倡而地位提高。但是照儒家之学,生计问题,本在教化问题之先;即以教化问题而论,地方上的庠序,亦重于京城里的大学,这只要看《汉书·礼志》上的议论,便可以知道。武帝当日,对于庠序,亦未能注意,即因其专做表面上的事情之故。至于法家,他用到了一个桑弘羊,行了些榷盐铁、酒酤、均输等政策。据《盐铁论》看来,桑弘羊是确有节制资本之意,并非专为筹款的。但是节制资本而藉官僚以行之,很难望其有利无弊,所以其结果,只达到了筹款的目的,节制资本,则徒成虚语,且因行政的腐败,转不免有使人民受累的地方。其余急不暇择的筹款方法,如算缗钱、舟车,令民生子三岁即出口钱,及令民入羊为郎、入谷补官等,更不必说了。因所行不顺民心,不得不用严切的手段,乃招致张汤、赵禹等,立了许多严切的法令,以压迫人民。秦以来的狱吏,本来是偏于残酷的,加以此等法律,其遗害自然更深了。他用此等方法,搜括了许多钱来,做些什么事呢?除对外的武功,有一部分,可以算是替国家开拓疆土、防御外患外,其余如封禅、巡幸、信用方士、大营宫室等,可以说全部是浪费。山东是当时诛求剥削的中心,以致末年民愁盗起,几至酿成大乱。\n武帝对外的武功,却是怎样呢?当时还威胁着中国边境的,自然还是匈奴。此外秦朝所开辟的桂林、南海、象三郡和闽中郡,秦末汉初,又已分离为南越、闽越、东瓯三国了。现在的西康、云、贵和四川、甘肃的边境,即汉人所谓西南夷,则秦时尚未正式开辟。东北境,虽然自战国以来,燕国人业已开辟了辽东,当时的辽东,且到现在朝鲜境内(汉初守燕国的旧疆,以水为界,则秦界尚在水以西。水,今大同江),然汉族的移殖,还不以此为限,自可更向外开拓。而从甘肃向西北入新疆,向西南到青海,也正随着国力的扩张,而可有互相交通之势。在这种情势之下,推动雄才大略之主,向外开拓的,有两种动机:其一,可说是代表国家和民族向外拓展的趋势,又其一则为君主个人的野心。匈奴,自秦末乘中国内乱、戍边者皆去,复入居河南。汉初,其雄主冒顿,把今蒙古东部的东胡,甘肃西北境的月氏,都征服了。到汉文帝时,他又征服了西域,西域,即今新疆之地(西域二字,义有广狭。《汉书·西域传》说西域之地,“南北有大山,中央有河,东则接汉,阨以玉门、阳关,西则限以葱岭。”北方的大山,即今天山,南方的大山,即沙漠以南的山脉,略为新疆与西藏之界。河系今塔里木河。玉门、阳关,都在今甘肃敦煌县西。此乃今天山南路之地。其后自此西出,凡交通所及之地,亦概称为西域,则其界限并无一定,就连欧洲也都包括在内)。汉时分为三十六国(后分至五十余)。其种有塞,有氐羌。塞人属于高加索种,都是居国,其文明程度,远在匈奴、氐、羌等游牧民族之上。匈奴设官以收其赋税。汉高祖曾出兵征伐匈奴,被围于平城(今山西大同县),七日乃解。此时中国初定,对内的问题还多,不能对外用兵,乃用娄敬之策,名家人子为长公主,嫁给冒顿,同他讲和,是为中国以公主下嫁外国君主结和亲之始。文、景两代,匈奴时有叛服,文、景不过发兵防之而已,并没建立一定的对策。到武帝,才大出兵以征匈奴,前127年,恢复河南之地,匈奴自此移于漠北。前119年,又派卫青、霍去病绝漠攻击,匈奴损折颇多。此外较小的战斗,还有多次,兵事连亘,前后共二十余年,匈奴因此又渐移向西北。汉武帝的用兵,是很不得法的,他不用功臣宿将,而专用卫青、霍去病等椒房之亲。纪律既不严明,对于军需,又不爱惜,以致士卒死伤很多,物质亦极浪费(如霍去病,《史记》称其少而侍中,贵不省士。其用兵,“既还,重车余弃粱肉,而士有饥者。在塞外,卒乏粮,或不能自振,而去病尚穿域蹋鞠,事多类此。”卫青、霍去病大出塞的一役,汉马死者至十余万匹,从此以马少则不能大举兵事。李广利再征大宛时,兵出敦煌的六万人,私人自愿从军的,还不在其内,马三万匹,回来时,进玉门关的只有一万多人,马一千多匹。史家说这一次并不乏食,战死的也不多,所以死亡如此,全由将吏不爱士卒之故。可见用人不守成法之害)。只因中国和匈奴,国力相去悬绝,所以终能得到胜利。然此乃国力的胜利,并非战略的胜利。至于其通西域,则更是动于侈心。他的初意,是听说月氏为匈奴所破,逃到今阿母河滨,要想报匈奴的仇,苦于无人和他合力,乃派张骞出使。张骞回来后,知道月氏已得沃土,无报仇之心,其目的已不能达到了。但武帝因此而知西域的广大,以为招致了他们来朝贡,实为自古所未有,于是动于侈心,要想招致西域各国。张骞在大夏时,看见邛竹杖、蜀布,问他从哪里来的?他们说从身毒买来(今印度)。于是臆想,从四川、云南,可通西域。派人前去寻求道路,都不能通(当时蜀物入印度,所走的路,当系今自四川经西康、云南入缅甸的路。自西南夷求通西域的使者,“传闻其西可千余里,有乘象国,名曰滇越,而蜀贾奸出物者或至焉”,即当今缅甸之地)。后来匈奴的浑邪王降汉,今甘肃西北部之地,收入中国版图,通西域的路,才正式开通。前104年,李广利伐大宛(大宛都贵山城,乃今之霍阐),不克。武帝又续发大兵,前101年,到底把它打下。大宛是离中国很远的国。西域诸国,因此慑于中国兵威,相率来朝。还有一个乌孙,也是游牧民族,当月氏在甘肃西北境时,乌孙为其所破,依匈奴以居。月氏为匈奴所破,是先逃到伊犁河流域的。乌孙借匈奴的助力,把它打败,月氏才逃到阿母河流域,乌孙即占据伊犁之地。浑邪王降汉时,汉朝尚无意开其地为郡县,张骞建议,招乌孙来居之。乌孙不肯来,而匈奴因其和中国交通,颇责怪它。乌孙恐惧,愿“婿汉氏以自亲”。于是汉朝把一个宗室女儿嫁给它。从此以后,乌孙和匈奴之间有问题,汉朝就不能置之不问,《汉书·西域传》说“汉用忧劳无宁岁”,很有怨怼的意思。按西域都是些小国,汉攻匈奴,并不能得它的助力,而因此劳费殊甚,所以当时人的议论,大都是反对的。但是史事复杂,利害很难就一时一地之事论断。(一)西域是西洋文明传布之地。西洋文明的中心古希腊、罗马等,距离中国很远,在古代只有海道的交通,交流不甚密切,西域则与中国陆地相接,自近代西力东渐以前,中西的文明,实在是恃此而交流的。(二)而且西域之地,设或为游牧民族所据,亦将成为中国之患,汉通西域之后,对于天山南北路,就有相当的防备,后来匈奴败亡后,未能侵入,这也未始非中国之福。所以汉通西域,不是没有益处的。但这只是史事自然的推迁,并非当时所能豫烛。当时的朝鲜:汉初燕人卫满走出塞,把箕子之后袭灭了,自王朝鲜。传子至孙,于前108年,为汉武帝所灭。将其地设置乐浪、临屯、真番、玄菟四郡(乐浪,今朝鲜平安南道及黄海、京畿两道之地。临屯为江原道地。玄菟为咸镜南道。真番跨鸭绿江上流。至前82年,罢真番、临屯,以并乐浪、玄菟)。朝鲜半岛的主要民族是貉族,自古即渐染汉族的文化,经此长时期的保育,其汉化的程度愈深,且因此而输入半岛南部的三韩(马韩,今忠清、全罗两道。弁韩、辰韩,今庆尚道)和海东的日本,实为中国文化在亚洲东北部最大的根据地。南方的东瓯,因为闽越所攻击,前138年,徙居江、淮间。南越和闽越,均于111年,为中国所灭。当时的西南夷:在今金沙江和黔江流域的,是夜郎、滇、邛都,在岷江和嘉陵江上源的,是徙、筰都、冉、白马。在今横断山脉和澜沧、金沙两江间的,是巂昆明(夜郎,今贵州桐梓县。滇,今云南昆明县。邛都,今西康西昌县。徙,今四川天全县。筰都,今西康汉源县。冉,今四川茂县。白马,今甘肃成县。巂昆明,在今昆明、大理之间,乃行国)。两越既平,亦即开辟为郡县,确立了中国西南部的疆域。今青海首府附近,即汉人称为河湟之地的,为羌人所据。这一支羌人,系属游牧民族,颇为中国之患。前112年,汉武帝把它打破,设护羌校尉管理它,开辟了今青海的东境。\n第十一章 前汉的衰亡 # 汉武帝死后,汉朝是经过一次政变的,这件事情的真相,未曾有传于后。武帝因迷信之故,方士神巫,多聚集京师,至其末年,遂有巫蛊之祸,皇后自杀。太子据发兵,把诬陷他和皇后的江充杀掉。武帝认为造反,亦发兵剿办。太子兵败出亡,后被发觉,自缢而死。当太子死时,武帝儿子存在的,还有燕王旦、广陵王胥、昌邑王髆,武帝迄未再立太子。前87年,武帝死,立赵婕妤所生幼子弗陵,是为昭帝。霍光、上官桀、桑弘羊、金日同受遗诏辅政。赵婕妤先以谴死。褚先生《补外戚世家》说:是武帝怕身死之后,嗣君年少,母后专权,先行把她除去的。《汉书·霍光传》又说:武帝看中了霍光,使画工画了一幅周公负成王朝诸侯的图赏给他。武帝临死时,霍光问当立谁?武帝说:“立少子,君行周公之事。”这话全出捏造。武帝生平溺于女色;他大约是个多血质的人,一生行事,全凭一时感情冲动,安能有深谋远虑,预割嬖爱?霍光乃左右近习之流,仅可以供驱使。上官桀是养马的。金日系匈奴休屠王之子,休屠王与浑邪王同守西边,因不肯降汉,为浑邪王所杀,乃系一个外国人,与中国又有杀父之仇。朝臣中即使无人,安得托孤于这几个人?当他们三个人以武帝遗诏封侯时,有一个侍卫,名唤王莽,他的儿子唤做王忽,扬言道:皇帝病时,我常在左右,哪里有这道诏书?霍光闻之,切责王莽,王莽只得把王忽杀掉。然则昭帝之立,究竟是怎样一回事,就可想而知了。昭帝既立,燕王谋反,不成而死。桑弘羊、上官桀都以同谋被杀。霍光的女儿,是上官桀的儿媳妇,其女即昭帝的皇后。上官桀大约因是霍光的亲戚而被引用,又因争权而翻脸的,殊不足论,桑弘羊却可惜了(金日于昭帝元年即死,故不与此次政变)。前74年,昭帝死,无子。霍光迎立昌邑王的儿子贺,旋又为光所废,而迎太子据之孙病已于民间,是为宣帝。昌邑王之废,表面上是无道,然当昌邑群臣二百余人被杀时,在市中号呼道“当断不断,反受其乱”,则昌邑王因何被废,又可想而知了。太子据败时,妻妾子女悉被害,只有一个宣帝系狱,此事在前91年。到前87年,即武帝死的一年,据说,有望气者说:“长安狱中有天子气。”武帝就派使者,“分条中都官狱系者,轻重皆杀之。”幸而有个丙吉,“拒闭使者”,宣帝才得保全,因而遇赦。按太子死后,武帝不久即自悔。凡和杀太子有关的人,都遭诛戮。太子系闭门自缢,脚蹋开门和解去他自缢的绳索的人都封侯。上书讼太子冤的田千秋,无德无能,竟用为丞相。武帝的举动如此,宣帝安得系狱五年不释?把各监狱中的罪人,不问罪名轻重,尽行杀掉,在中国历史上,是从来没有这回事的,这是和中国,至少是有史以来的中国的文化不相容的,武帝再老病昏乱些,也发不出这道命令。如其发出了,拒绝不肯执行的,又岂止一个丙吉?然则宣帝是否武帝的曾孙,又很有可疑了。今即舍此勿论,而昌邑王以有在国时的群臣,为其谋主,当断不断而败,宣帝起自民间,这一层自然无足为虑,这怕总是霍光所以迎立他的真原因了罢。宣帝即立,自然委政于光,立六年而光死,事权仍在霍氏手里。宣帝不动声色地,逐渐把他们的权柄夺去,任用自己的亲信。至前66年,而霍氏被诛灭。\n●东汉·绿釉陶楼通高130.2厘米。山东高唐县东固河出土\n霍光的事情,真相如此。因为汉时史料缺乏,后人遂认为他的废立是出于公心的,把他和向来崇拜的偶像伊尹联系在一起,称为伊、霍,史家的易欺,真堪惊叹了。当时朝廷之上,虽有这种争斗,影响却未及于民间。武帝在时,内行奢侈,外事四夷,实已民不堪命。霍光秉政,颇能轻徭薄赋,与民休息。宣帝起自民间,又能留意于吏治和刑狱。所以昭、宣二帝之世,即自前86至前49年凡三十八年之间,政治反较武帝时为清明,其时汉朝对于西域的声威,益形振起。前60年,设立西域都护,兼管南北两道。匈奴内乱,五单于并立,后并于呼韩邪。又有一个郅支单于,把呼韩邪打败。前51年,呼韩邪入朝于汉。郅支因汉拥护呼韩邪,遁走西域。前49年,宣帝崩,子元帝立。前36年,西域副都护陈汤矫诏发诸国兵袭杀郅支。汉朝国威之盛,至此亦达于极点。然有一事,系汉朝政治败坏的根源,其端实开自霍光秉政之时的,那便是宰相之权,移于尚书。汉朝的宰相,是颇有实权的。全国的政治,都以相府为总汇,皇帝的秘书御史,不过是他的助手,尚书乃皇帝手下的管卷,更其说不着了。自霍光秉政,自领尚书,宰相都用年老无气和自己的私人,政事悉由宫中而出,遂不能有正色立朝之臣。宣帝虽诛灭霍氏,于此却未能矫正。宦者弘恭、石显,当宣、元之世,相继在内用事。元帝时,士大夫如萧望之、刘向等,竭力和他们争斗,终不能胜。朝无重臣,遂至嬖得干相位,外戚得移朝祚,西汉的灭亡,相权的丧失,实在是一个重要的原因。而且其事不但关涉汉朝,历代的政治,实都受其影响,参看第四十二章自明。\n●过居庸关图内蒙古和林格尔县汉墓壁画,长132.4厘米,宽67.5厘米。绘有墓主路经居庸关情景,居庸关用山谷中的桥梁表示\n元帝以前33年死,子成帝立。成帝是个荒淫无度的人,喜欢了一个歌者赵飞燕,立为皇后,又立其女弟合德为婕妤。性又优柔寡断,事权遂入于外家王氏之手。前7年,成帝崩,哀帝立,颇想效法武、宣,振起威权。然宠爱嬖人董贤,用为宰相,朝政愈乱。此时王氏虽一时退避,然其势力仍在。哀帝任用其外家丁氏,祖母族傅氏,其中却并无人才,实力远非王氏之敌。前1年,哀帝崩,无子,王莽乘机复出,迎立平帝。诛灭丁、傅、董贤,旋弑平帝而立孺子婴(哀、平二帝皆元帝孙,孺子为宣帝曾孙)。王莽从居摄改称假皇帝,又从假皇帝变做真皇帝,改国号为新,而前汉遂亡。此事在公元9年。\n第十二章 新室的兴亡 # 前后汉之间,是中国历史的一个转变。在前汉之世,政治家的眼光,看了天下,认为不该就这么苟安下去的。后世的政治家,奉为金科玉律的思想,所谓“治天下不如安天下,安天下不如与天下安”,是这时候的人所没有的。他们看了社会,还是可用人力控制的,一切不合理的事,都该用人力去改变,此即所谓“拨乱世,反之正”。出来负这个责任的,当然是贤明的君主和一班贤明的政治家。当汉昭帝时,有一个儒者,唤做眭弘,因灾异,使其朋友上书,劝汉帝“求索贤人,禅以帝位,而退自封百里”。宣帝时,有个盖宽饶,上封事亦说:“五帝官天下,三王家天下,家以传子,官以传贤,四序之运,成功者退,不得其人,则不居其位。”这两个人,虽然都得罪而死,但眭弘,大约因霍光专政,怕人疑心他要篡位,所以牺牲了他,以资辨白的。况且霍光是个不学无术的人,根本不懂得什么改革大计。盖宽饶则因其刚直之性,既触犯君主,又为有权势的人所忌,以致遭祸,都不是反对这种理论,视为大逆不道。至于不关涉政体,而要在政务上举行较根本的改革的,则在宣帝时有王吉,因为宣帝是个实际的政治家,不能听他的话。元帝即位,却征用了王吉及和他志同道合的朋友贡禹。王吉年老,在路上死了。贡禹征至,官至御史大夫。听了他的话,改正了许多奢侈的制度,又行了许多宽恤民力的政事。其时又有个翼奉,劝元帝徙都成周。他说:长安的制度,已经坏了,因袭了这种制度,政治必不能改良,所以要迁都正本,与天下更始,则其规模更为阔大了。哀帝多病,而且无子,又有个李寻,保荐了一个贺良,陈说“汉历中衰,当更受命”,劝他改号为陈圣刘皇帝。陈字和田字同音,田地二字,古人通用,地就是土,陈圣刘皇帝,大约是说皇帝虽然姓刘,所行的却是土德。西汉人五德终始之说,还不是像后世专讲一些无关实际,有类迷信的空话的,既然要改变“行序”,同时就有一大套实际的政务,要跟着改变。这只要看贾谊说汉朝应当改革,虽然要“改正朔,易服色”,也要“法制度,定官名”,而他所草拟的具体方案,“为官名,悉更秦之故”,便可知道。五德终始,本来不是什么迷信,而是一套有系统的政治方案,这在第四十三章中,业经说过了。这种根本的大改革,要遭到不了解的人无意识的反对,和实际于他权利有损的人出死力的抵抗,自是当然之事。所以贺良再进一步,要想改革实际的政务,就遭遇反对而失败了。但改革的气势,既然如此其旁薄郁积,自然终必有起而行之之人,而这个人就是王莽。所以王莽是根本无所谓篡窃的。他只是代表时代潮流,出来实行改革的人。要实行改革,自然要取得政权;要取得政权,自然要推翻前朝的皇帝;而因实行改革而推翻前朝的皇帝,在当时的人看起来,毋宁是天理人情上当然的事。所以应天顺人(《易·鼎卦彖辞》:“汤武革命,顺乎天而应乎人”),在当时也并不是一句门面话。\n要大改革,第一步自然还是生计问题,王莽所实行的是:一、改名天下的田为王田,这即是现在的宣布土地国有,和附着于土地的奴隶,都不准卖买,而举当时所有的土田,按照新章,举行公平的分配。二、立六筦之法,将大事业收归官营。三、立司市、泉府,以平衡物价,使消费者、生产者、交换者,都不吃亏。收有职业的人的税,以供要生利而无资本的人,及有正当消费而一时周转不灵的人的借贷。其详见第四十一章。他的办法,颇能综合儒法两家,兼顾到平均地权和节制资本两方面,其规模可称阔大,思虑亦可谓周详。但是徒法不能自行,要举行这种大改革,必须民众有相当的觉悟,且能作出相当的行动,专靠在上者的操刀代斫,是不行的。因为真正为国为民的人,总只有少数,官僚阶级中的大多数人,其利害总是和人民相反的,非靠督责不行。以中国之大,古代交通的不便,一个中央政府,督责之力本来有所不及;而况当大改革之际,普通官吏,对于法令,也未必能了解,而作弊的机会却特多;所以推行不易,而监督更难。王莽当日所定的法令,有关实际的,怕没有一件能够真正推行,而达到目的,因此而生的流弊,则无一事不有,且无一事不厉害。其余无关实际,徒资纷扰的,更不必说了。王莽是个偏重立法的人,他又“锐思于制作”,而把眼前的政务搁起。尤其无谓的,是他的改革货币,麻烦而屡次改变,势不可行,把商业先破坏了。新分配之法,未曾成立,旧交易之法,先已破坏,遂使生计界的秩序大乱,全国的人,无一个不受到影响。王莽又是个拘泥理论、好求形式上的整齐的人。他要把全国的政治区划,依据地理,重行厘定,以制定封建和郡县制度。这固然是一种根本之图,然岂旦夕可致?遂至改革纷纭,名称屡变,吏弗能纪。他又要大改官制,一时亦不能成功,而官吏因制度未定,皆不得禄,自然贪求更甚了。对于域外,也是这么一套。如更改封号及印章等,无关实际、徒失交涉的圆滑,加以措置失宜,匈奴、西域、西南夷,遂至背叛。王莽对于西域,未曾用兵。西南夷则连年征讨,骚扰殊甚。对于匈奴,他更有一个分立许多小单于,而发大兵深入穷追,把其不服的赶到丁令地方去的一个大计划(此乃欲将匈奴驱入今西伯利亚之地,而将漠北空出)。这个计划,倒也是值得称赞的,然亦谈何容易?当时调兵运饷,牵动尤广,屯守连年,兵始终没有能够出,而内乱却已蔓延了。\n●帛画《升天图》\n莽末的内乱,是起于公元17年的。今山东地方,先行吃紧。湖北地方,亦有饥民屯聚。剿办连年弗能定。公元22年,藏匿在今当阳县绿林山中的兵,分出南阳和南郡(汉南阳郡,治宛,今河南南阳县。南郡,治江陵,今湖北江陵县)。入南阳的谓之新市兵,入南郡的谓之下江兵。又有起于今随县的平林乡的,谓之平林兵。汉朝的宗室刘玄,在平林兵中。刘、刘秀则起兵舂陵(今湖北枣阳县),和新市、平林兵合。刘玄初称更始将军,后遂被立为帝。入据宛。明年,王莽派大兵四十万去剿办,多而不整,大败于昆阳(今河南叶县)。莽遂失其控制之力,各地方叛者并起。更始分兵两支:一攻洛阳,一入武关。长安中叛者亦起。莽遂被杀。更始移居长安,然为新市、平林诸将所制,不能有为。此时海内大乱,而今河南、河北、山东一带更甚。刘为新市、平林诸将所杀。刘秀别为一军,出定河北。即帝位于鄗(改名高邑县),是为后汉光武皇帝。先打平了许多小股的流寇。其大股赤眉,因食尽西上,另立了一个汉朝的宗室刘盆子,攻入长安。更始兵败出降,旋被杀。光武初以河内为根据地(汉河内郡,治怀,在令河南武陟县),派兵留守,和服从更始的洛阳对峙。至此遂取得了洛阳,定都其地。派兵去攻关中,未能遽定,而赤眉又因食尽东走,光武自勒大兵,降之宜阳(今河南宜阳县)。此时东方还有汉朝的宗室刘永割据睢阳(今河南商丘县)。东方诸将,多与之合。又有秦丰、田戎等,割据今湖北沿江一带,亦被他次第打平。只有陇西的隗嚣,四川的公孙述,较有规模,到最后才平定。保据河西的窦融,则不烦兵力而自下。到公元36年,天下又算平定了。从公元17年东方及荆州兵起,算到这一年,其时间实四倍于秦末之乱;其破坏的程度,怕还不止这一个比例。光武平定天下之后,自然只好暂顾目前,说不上什么远大的计划了。而自王莽举行这样的大改革而失败后,政治家的眼光,亦为之一变。根本之计,再也没有人敢提及。社会渐被视为不可以人力控制之物,只能听其迁流所至。“治天下不如安天下,安天下不如与天下安”,遂被视为政治上的金科玉律了。所以说:这是中国历史上的一个大转变。\n●马踏飞燕\n第十三章 后汉的盛衰 # 后汉自公元25年光武帝即位起,至公元220年为魏所篡止,共计192年;若算到公元189年董卓行废立,东方起兵讨卓,实际分裂之时为止,则共得175年;其运祚略与前汉相等,然其国力的充实,则远不如前汉了。这是因为后汉移都洛阳,对于西、北两面的控制,不如前汉之便;又承大乱之后,海内凋敝已极,休养未几,而羌乱即起,其富力亦不如前汉之盛之故。两汉四百年,同称中国的盛世,实际上,后汉已渐露中衰之机了。光武帝是一个实际的政治家。他知道大乱之后,急于要休养生息,所以一味的减官省事。退功臣,进文吏。位高望重的三公,亦只崇其礼貌,而自己以严切之法,行督责之术,虽然有时不免失之过严,然颇得专制政治,“严以察吏,宽以驭民”的秘诀,所以其时的政治,颇为清明。公元57年,光武帝崩,子明帝立。亦能守其遗法。公元75年,明帝崩,子章帝立,政治虽渐见宽弛,然尚能蒙业而安。章帝以公元88年崩。自公元36年公孙述平定至此,共计52年,为东汉治平之世。匈奴呼韩邪单于约诸子以次继立。六传至呼都而尸单于,背约而杀其弟。前单于之子比,时领南边,不服。公元48年,自立为呼韩邪单于,来降。中国人处之于今绥远境内。匈奴自此分为南、北。北匈奴日益衰乱。公元89年,南单于上书求并北庭。时和帝新立,年幼,太后窦氏临朝。后见窦宪犯法,欲令其立功自赎,乃以宪为大将军,出兵击破匈奴。后年,又大破之于金微山(大约系今蒙古西北的阿尔泰山)。北匈奴自此远遁,不能为中国之患了。西域的东北部,是易受匈奴控制的。其西南部,则自脱离汉朝都护的管辖后,强国如莎车、于阗等,出而攻击诸国,意图并吞。后汉初兴,诸国多愿遣子入侍,请派都护。光武不许。明帝时,才遣班超出使。班超智勇足备,带了少数的人,留居西域,调发诸国的兵,征讨不服,至公元91年而西域平定。汉朝复设都护,以超为之。后汉之于域外,并没有出力经营,其成功,倒亦和前汉相仿佛,只可谓之适值天幸而已。\n●东汉·持戟青铜骑士 仪仗队武士高30厘米,马高40~42.5厘米,身长33~35厘米。甘肃武威市雷台汉墓出土\n后汉的乱源,共有好几个,其中最重要的,就是外戚和宦官。从前的皇室,其前身,本来是一个强大的氏族。氏族自有氏族的继承法。当族长逝世,合法继承人年幼时,从族中推出一个人来,暂操治理之权,谓之摄政。如由前族长之妻,现族长之母代理,则即所谓母后临朝。宗室分封于外,而中朝以外戚辅政,本来是前汉的一个政治习惯。虽然前汉系为外戚所篡,然当一种制度未至崩溃时,即有弊窦,人们总认为是人的不好,而不会归咎于制度的。如此,后汉屡有冲幼之君,自然产生不出皇族摄政的制度来,而只会由母后临朝;母后临朝,自然要任用外戚。君主之始,本来是和一个乡长或县长差不多的。他和人民是很为接近的。到后来,国家愈扩愈大,和原始的国家不知相差若干倍了,而君主的制度依然如故。他和人民,和比较低级的官吏,遂至因层次之多,而自然隔绝。又因其地位之高,而自成养尊处优之势,关系之重,而不得不深居简出。遂至和当朝的大臣,都不接近,而只是和些宦官宫妾习狎。这是历代的嬖近习,易于得志的原因,而也是政治败坏的一个原因。后汉外戚之祸,起于章帝时。章帝的皇后窦氏是没有儿子的。宋贵人生子庆,立为太子。梁贵人生子肇,窦后养为己子。后诬杀宋贵人,废庆为清河王,而立肇为太子。章帝崩,肇立,是为和帝。后兄窦宪专权。和帝既长,与宦者郑众谋诛之,是为后汉皇帝和宦官合谋以诛外戚之始。105年,和帝崩。据说和帝的皇子,屡次夭殇,所以生才百余日的殇帝,是寄养于民间的。皇后邓氏迎而立之。明年,复死。乃迎立清河王的儿子,是为安帝。邓太后临朝,凡十五年。太后崩后,安帝亲政,任用皇后的哥哥阎显,又宠信宦官和乳母王圣,政治甚为紊乱。阎皇后无子,后宫李氏生子保,立为太子。后谮杀李氏而废保。125年,安帝如宛,道崩。皇后秘丧驰归,迎立章帝之孙北乡侯懿。当年即死。宦者孙程等迎立废太子保,是为顺帝。程等十九人皆封列侯。然未久即多遭谴斥。顺帝任用皇后的父亲梁商,梁商为人还算谨慎。商死后,子冀继之,其骄淫纵恣,为前此所未有。144年,顺帝崩,子冲帝立。明年崩。梁冀迎立章帝的玄孙质帝。因年小聪明,为冀所弑。又迎立章帝的曾孙桓帝。桓帝立十三年后,才和宦者单超等五人合谋把梁冀诛戮,自此宦官又得势了。\n因宦官的得势,遂激成所谓党锢之祸。宦官和阉人,本来是两件事。宦字的初义,是在机关中学习,后来则变为在贵人家中专事伺候人的意思,第四十一章中,业经说过了。皇室的规模,自然较卿大夫更大,自亦有在宫中服事他的人,此即所谓宦官(据《汉书·本纪》,惠帝即位后,曾施恩于宦皇帝的人,此即是惠帝为太子时,在“太子家”中伺候他的人)。本不专用阉人,而且其初,宦官的等级远较阉人为高,怕是绝对不能用阉人的。但到后来,刑罚滥了,士大夫亦有受到宫刑的(如司马迁受宫刑后为中书谒者令,即其好例);又有生来天阉的人;又有贪慕权势,自宫以进的,不都是俘虏或罪人。于是其人的能力和品格,都渐渐提高,而可以用为宦官了。后汉邓太后临朝后,宫中有好几种官,如中常侍等,都改用阉人,宦官遂成为阉人所做的官的代名词。虽然阉人的地位实已提高,然其初既是俘虏和罪人,社会上自然总还将他当作另一种人看待,士大夫更瞧他不起。此时的士大夫和贵族,都是好名的,都是好交结的。这一者出于战国之世,贵族好养士,士人好奔走的习惯,一则出于此时选举上的需要,在第四十三章中,业经说过了。当时的宦官,多有子弟亲戚,或在外面做官暴虐,或则居乡恃势骄横。用法律裁制,或者激动舆论反对他,正是立名的好机会。士大夫和宦官,遂势成水火。这一班好名誉好交结的士大夫,自然也不免互相标榜,互相结托。京城里的太学,游学者众多,而且和政治接近,便自然成为他们聚集的中心。结党以营谋进身,牵引同类,淆乱是非,那是政治上的一个大忌。当时的士大夫,自不免有此嫌疑。而且用了这一个罪名,则一网可以打尽,这是多么便利,多么痛快的事!宦官遂指当时反对他们的名士为党人,劝桓帝加以禁锢,后因后父窦武进言,方才把他们赦免。167年,桓帝崩,无子,窦后和武定策禁中,迎立了章帝的玄孙灵帝。太后临朝。窦武是和名士接近的,有恩于窦氏的陈蕃,做了太傅,则其本身就是名士中人。谋诛弄权的宦官,反为所害。太后亦被迁抑郁而死。灵帝年长,不徒不知整顿,反更崇信宦官,听其把持朝政,浊乱四海。而又一味聚敛奢侈。此时乱源本已潜伏,再天天给他制造爆发的机会,遂成为不可收拾之局了。\n大伤后汉的元气的是羌乱。中国和外夷,其间本来总有边塞隔绝着的。论民族主义的真谛,先进民族本来有诱掖后进民族的责任,不该以隔绝为事。但是同化须行之以渐。在同化的进行未达相当程度时,彼此的界限是不能遽行撤废的。因为文化的不同就是生活的相异,不能使其生活从同,顾欲强使生活不同的人共同生活,自不免引起纠纷。这是五胡乱华的一个重要原因,而后汉时的羌乱,业已导其先路了。今青海省的东北境,在汉时本是羌人之地。王莽摄政时,讽羌人献地,设立了一个西海郡。既无实力开拓,边塞反因之撤废,羌人就侵入内地。后汉初年,屡有反叛,给中国征服了,又都把他们迁徙到内地来。于是降羌散居今甘肃之地者日多。安帝时,遂酿成大规模的叛乱。这时候,政治腐败,地方官无心守土,都把郡县迁徙到内地。人民不乐迁徙,则加以强迫驱遣,流离死亡,不可胜数。派兵剿办,将帅又腐败,历时十余年,用费达二百四十亿,才算勉强结束。顺帝时又叛,兵费又至八十余亿,桓帝任用段颎,大加诛戮,才算镇定下来。然而西北一方,凋敝已甚,将帅又渐形骄横,隐伏着一个很大的乱源了。\n遇事都诉之理性,这只是受过优良教育的人,在一定的范围中能够。其余大多数人,和这一部分人出于一定范围以外的行为,还是受习惯和传统思想的支配的。此种习惯和传统的思想,是没有理由可以解说的,若要仔细追究起来,往往和我们别一方面的知识冲突,所以人们都置诸不问,而无条件加以承认,此即所谓迷信。给迷信以一种力量的则为宗教。宗教鼓动人的力量是颇大的。当部族林立之世,宗教的教义,亦只限于一部族,而不足以吸引别部族人。到统一之后就不然了。各种小宗教,渐渐混合而产生大宗教的运动,在第五十四章中说过。在汉时,上下流社会,是各别进行的。在上流社会中,孔子渐被视为一个神人,看当时内学家(东汉时称纬为内学)尊崇孔子的话,便可见得。但在上流社会中,到底是受过良好教育,理性较为发达的,不容此等迷信之论控制,所以不久就被反对迷信的玄学打倒。在下流社会,则各种迷信,逐渐结合,而形成后世的道教。在汉时是其初步。其中最主要的是张角的太平道和张修的五斗米道。道教到北魏时的寇谦之,才全然和政府妥协,前此,则是很激烈地反对政府的。他们以符咒治病等,为煽动和结合的工具。张修造反,旋即平定。张鲁后来虽割据汉中,只是设立鬼卒等,闭关自守,实行其神权政治而已,于大局亦无甚关系。张角却声势浩大。以公元184年起事。他的徒党,遍于青、徐、幽、冀、荆、扬、兖、豫八州,即今江苏、安徽、浙江、江西、湖北、湖南、山东、河南、河北各省之地。但张角似是一个只会煽惑而并没有什么政治能力的人,所以不久即败。然此时的小乱事,则已到处蔓延,不易遏止了,而黄巾的余党亦难于肃清。于是改刺史为州牧,将两级制变成了三级制,便宜了一部分的野心家,即仍称刺史的人以及手中亦有兵权的郡守。分裂之势渐次形成,静待着一个机会爆发。\n●地动仪\n第十四章 后汉的分裂和三国 # 公元189年,灵帝崩。灵帝皇后何氏,生子辩。美人王氏,生子协。灵帝属意于协,未及定而崩,属协于宦者蹇硕。这蹇硕,大约是有些武略的。当黄巾贼起时,汉朝在京城里练兵,共设立八个校尉,蹇硕便是上军校尉,所以灵帝把废嫡立庶的事情付托他。然而这本是不合法的事,皇帝自己办起来,还不免遭人反对,何况在其死后?这自然不能用法律手段解决。蹇硕乃想伏兵把何皇后的哥哥大将军何进杀掉,然后举事。事机不密,被何进知道了,就拥兵不朝。蹇硕无可如何,而辩乃得即位,是为废帝。何进把蹇硕杀掉,因想尽诛宦官。而何氏家本寒微,向来是尊敬宦官的。何太后的母亲和何进的兄弟,又受了宦官的贿赂,替他们在太后面前说好话。太后因此坚持不肯。何进无奈,乃召外兵进京,欲以胁迫太后。宦官见事急,诱进入宫,把他杀掉。何进的官属,举兵尽诛宦官。京城大乱,而凉州将董卓适至,拥兵入京,大权遂尽入其手。董卓只是个强盗的材料。他把废帝废掉,而立协为皇帝,是为献帝。山东州郡起兵反对他,他乃移献帝于长安,接近自己的老家,以便负隅抵抗。东方州郡实在是人各有心的,都各占地盘,无意于进兵追讨。后来司徒王允,和董卓亲信的将官吕布相结,把董卓杀掉。董卓的将校李傕、郭汜,又回兵替董卓报仇。吕布出奔,王允被杀。李傕、郭汜又互相攻击,汉朝的中央政府就从此解纽,不再能号令全国了。\n●《历代帝王图》刘备像,阎立本绘\n各地方割据的:幽州有公孙瓒。冀州有袁绍。兖州有曹操。徐州始而是陶谦,后来成为刘备和吕布争夺之场。扬州,今寿县一带,为袁术所据,江东则入于孙策。荆州有刘表。益州有刘焉。这是较大而在中原之地的,其较小较偏僻的,则汉中有张鲁,凉州有马腾、韩遂,辽东有公孙度。当时政治的重心,是在山东的(古书所谓山东,系指华山以东,今之河南、山东,都包括在内)。袁绍击灭了公孙瓒,又占据了并州,地盘最大,而曹操最有雄才大略。献帝因不堪李傕、郭汜的压迫,逃归洛阳,贫弱不能自立,召曹操入卫,操移献帝于许昌,遂成挟天子以令诸侯之势。刘备为吕布所破,逃归曹操,曹操和他合力,击杀了吕布。袁术因荒淫无度,不能自立,想走归袁绍,曹操又使刘备邀击,术退走,旋死。刘备叛操,操又击破之。河南略定。公元200年,袁绍举大兵南下,与操相持于官渡(城名,在令河南中牟县北),为操所败。绍气愤死。公元205年,绍二子并为操所灭。于是北方无与操抗者。208年,操南征荆州。刘表适死,其幼子琮,以襄阳降(今湖北襄阳县,当时荆州治此)。刘备时在荆州,走江陵。操追败之。备奔刘表的长子琦于江夏(汉郡,后汉时,郡治在今湖北黄冈县境),和孙权合力,败操于赤壁(山名,在今湖北嘉鱼县)。于是刘备屯兵荆州,而孙权亦觊觎其地。后备乘刘焉的儿子刘璋暗弱,夺取益州。孙权想攻荆州,刘备同他讲和,把荆州之地平分了。时马腾的儿子马超和韩遂反叛,曹操击破之。又降伏了张鲁。刘备北取汉中。曹操自争之,不能克,只得退回。天下渐成三分之势。刘备初见诸葛亮时,诸葛亮替他计划,就是据有荆、益两州,天下有变,命将将荆州之兵以向宛、洛,而自率益州之众以出秦川的。这时的形势,颇合乎这个条件。备乃命关羽自荆州北伐,取襄阳,北方颇为震动,而孙权遣兵袭取江陵,羽还救,为权所杀。刘备忿怒,自将大兵攻权,又大败于猇亭(在今湖北宜都县西)。于是荆州全入于吴。备旋以惭愤而死,此事在公元223年。先是220年,曹操死,子丕篡汉自立,是为魏文帝。其明年,刘备称帝于蜀,是为蜀汉昭烈帝。孙权是到229年才称帝的,是为吴大帝。天下正式成为三分之局。蜀的地方最小,只有今四川一省,其云南、贵州,全是未开发之地。吴虽自江陵而下,全据长江以南,然其时江南的开化,亦远在北方之后。所以三国以魏为最强,吴、蜀二国,常合力以与之抗。\n三国的分裂,可以说是两种心理造成的。其一是封建的余习。人心是不能骤变的。在封建时代,本有各忠其君的心理,秦汉以后,虽然统一了,然此等见解,还未能全行破除。试看汉代的士大夫,仕于州郡的,都奉其长官为君,称其机关为本朝,有事为之尽忠,死则为之持服,便可知道。又其一则为南方风气的强悍。赤壁战时,孙权实在没有联合刘备抵抗曹操的必要。所以当时文人持重而顾大局的,如张昭等,都主张迎降。只有周瑜和鲁肃,主张抵抗,和孙权的意见相合。《三国志》载周瑜的话,说曹操名为汉相,实系汉贼,这是劫持众人的门面话,甚或竟是事后附会之谈。东吴的君臣,自始至终,所作所为,何曾有一件事有汉朝在心目之中?说这话要想欺谁?在当时东吴朝廷的空气中,这话何能发生效力?孙权一生,最赏识的是周瑜,次之则是鲁肃。孙权当称帝时,说鲁子敬早有此议,鲁肃如此,周瑜可知。为什么要拥戴孙权做皇帝?这个绝无理由,不过是一种倔犟之气,不甘为人下,孙权的自始便要想做皇帝,则更不过是一种不知分量的野心而已。赤壁之战,是天下三分的关键,其事在公元208年,至280年晋灭吴,天下才见统一,因这一种蛮悍的心理,使战祸延长了七十二年。\n●诸葛亮像\n刘备的嗣子愚弱,所以托孤于诸葛亮。诸葛亮是有志于恢复中原的;而且蜀之国势,非以攻为守,亦无以自立;所以自先主死后,诸葛亮即与吴弃衅言和,连年出兵伐魏。吴则除诸葛恪辅政之时外,多系疆场小战。曹操自赤壁败后,即改从今安徽方面经略东南。三国时,吴、魏用兵,亦都在这一带,彼此均无大成功。魏文帝本来无甚才略。死后,儿子明帝继立,荒淫奢侈,朝政更坏。其时司马懿屡次带兵在关中和诸葛亮相持,又平定了辽东。明帝死后,子齐王芳年幼,司马懿和曹爽同受遗诏辅政。其初大权为曹爽所专。司马懿托病不出,而暗中运用诡谋,到底把曹爽推翻,大权遂尽入其手。司马懿死后,他的儿子司马师、司马昭相继把持朝局。扬州方面,三次起兵反对司马氏,都无成。蜀自诸葛亮死后,蒋琬、费袆继之,不复能出兵北伐。费袆死后,姜维继之,频年出兵北伐而无功,民力颇为疲敝。后主又信任宦官,政局渐坏。司马昭乘此机会,于263年发兵灭蜀。司马昭死后,他的儿子司马炎继之,于265年篡魏,是为晋武帝。至280年而灭吴统一中国。\n第十五章 晋初的形势 # 吴、蜀灭亡,天下复归于统一了,然而乱源正潜伏着。这乱源是什么呢?\n自后汉以来,政治的纲纪久经废弛,试看第十三章所述可知。政治上的纲纪若要挽回,最紧要的是以严明之法行督责之术。魏武帝和诸葛亮都是以此而收暂时的效果的。然而一两个严明的政治家,挽不回社会上江河日下的风气,到魏、晋之世,纲纪又复颓败了。试看清谈之风,起于正始(魏齐王芳年号,自公元240年至248年),至晋初而更甚,直绵延至南朝之末可知。所谓清谈,所谈的就是玄学。玄学的内容,请见第五十三章。谈玄本不是坏事,以思想论,玄学要比汉代的儒学高明得多。不过学问是学问,事实是事实。因学问而忽视现实问题,在常人尚且不可,何况当时因谈玄而蔑视现实的,有许多是国家的官吏,所抛弃的是政治上的职务?\n汉朝人讲道家之学的所崇奉的是黄、老,所讲的是清静不扰,使人民得以各安其生的法术。魏晋以后的人所崇奉的是老、庄,其宗旨为委心任运。狡黠的讲求趋避之术,养成不负责任之风。懦弱的则逃避现实,以求解除痛苦。颓废的则索性蔑视精神,专求物质上的快乐。到底人是现实主义的多,物质容易使人沉溺,于是奢侈之风大盛。当曹爽执政时,曾引用一班名士。虽因政争失败,未能有所作为,然从零碎的材料看来,他们是有一种改革的计划,而其计划且颇为远大的(如夏侯玄有废郡之议,他指出郡已经是供镇压之用,而不是治民事的,从来讲官制的人,没有这么彻底注重民治的)。曹爽等的失败,我们固然很难知其原因所在,然而奢侈无疑的总是其原因之一。代曹爽而起的是司马氏,司马氏是武人,武人是不知义理,亦不知有法度的,一奢侈就可以毫无规范。何曾、石崇等人正是这一个时代的代表。\n●兰亭序。唐代冯承素摹\n封建时代用人本来是看重等级的。东周以后,世变日亟,游士渐起而夺贵族之席。秦国在七国中是最能任用游士的,读李斯《谏逐客书》可见。秦始皇灭六国后,仍保持这个政治习惯,所以李斯能做到宰相,得始皇的信任。汉高起自徒步,一时将相大臣,亦多刀笔吏或家贫无行者流,就更不必说了。汉武帝听了董仲舒的话,改革选法,博士、博士弟子、郡国上计之吏,和州郡所察举的秀才、孝廉,都从广大的地方和各种不同的阶层中来。其他擢用上书言事的人,以及朝廷和各机关的征辟,亦都是以人才为主的。虽或不免采取虚誉,及引用善于奔走运动的人,究与一阶级中人世据高位者不同。魏晋以降,门阀制度渐次形成,影响及于选举,高位多为贵族所盘踞,起自中下阶层中较有活气的人,参与政治的机会较少,政治自然不免腐败。如上章所述,三国时代,南方士大夫的风气,还是颇为剽悍的。自东晋之初,追溯后汉之末,不过百余年,周瑜、鲁肃、吕蒙、陆逊等人物,未必无有(晋初的周处,即系南人,还很有武烈之风)。倘使元帝东渡以后,晋朝能多引用这一班人,则除为国家戡乱以外,更加以民族的敌忾心,必有功效可见。然而大权始终为自北南迁的贵族所把持,使宋武帝一类的人物,直到晋末,才得出现于政治舞台之上,这也是一笔很大的损失。\n两汉时儒学盛行。儒学是封建时代的产物,颇笃于君臣之义的。两汉时,此项运动,亦颇收到相当的效果。汉末政治腐败,有兵权的将帅,始终不敢背叛朝廷(说本《后汉书·儒林传论》)。以魏武帝的功盖天下,亦始终只敢做周文王(参看《三国志·魏武帝纪》建安十五年《注》引是年十二月己亥令,这句句都是真话),就是为此。司马氏的成功是狡黠而不知义理的军阀得势(《晋书·宣帝纪》说:“明帝时,王导侍坐,帝问前世所以得天下,导乃陈帝创业之始,及文帝末高贵乡公事。明帝以面覆床曰:‘若如公言,晋祚复安得长远?’”司马氏之说可见),自此风气急变。宋、齐、梁、陈之君亦多是如此。加以运祚短促,自不足以致人忠诚之心。门阀用人之习既成,贵游子弟,出身便做好官,富贵吾所自有,朝代变换,这班人却并不更动,遂至“忠君之念已亡,保家之念弥切”(说本《南史·禇渊传论》)。中国人自视其国为天下,国家观念,本不甚发达;五胡乱华,虽然稍稍激起民族主义,尚未能发扬光大;政治上的纲纪,还要靠忠君之义维持,而其颓败又如此,政治自更奄奄无生气了。\n秦汉时虽有所谓都尉,调兵和统率之权,是属于太守的。其时所行的是民兵之制,平时并无军队屯聚;一郡的地方大小,亦不足以背叛中央,所以柳宗元说“有叛国而无叛郡”(见其所著《封建论》)。自刺史变为州牧,而地盘始大;即仍称刺史的,其实权亦与州牧无异;郡守亦有执掌兵权的,遂成尾大不掉之势。晋武帝深知其弊,平吴之后,就下令去刺史的兵权,回复其监察之职。然沿袭既久,人心一时难于骤变。平吴之后,不久内乱即起,中央政府,顾不到各地方,仍藉各州郡自行镇压,外重之势遂成,迄南朝不能尽革。\n自秦汉统一之后,国内的兵争既息,用不到人人当兵。若说外征,则因路途窎远,费时失业,人民在经济上的损失太大,于是多用谪发及谪戍。至后汉光武时,省郡国都尉,而民兵之制遂废。第四十五章中,业经说过了。国家的强弱,固不尽系乎兵,然若多数人民,都受过相当的军事训练,到缓急之际,所表现出来的抵抗力,是不可轻侮的。后汉以来,此条件业经丧失,反因贪一时便利之故,多用降伏的异族为兵,兵权倒持在异族手里,遂成为五胡扰乱的直接原因。\n●西晋·青瓷羊尊 长26厘米,江苏南京西岗果木农场墓葬出土\n晋初五胡的形势,是如此的:一、匈奴。散布在并州即今山西省境内。二、羯。史籍上说是匈奴的别种,以居于上党武乡的羯室而得名的(在今山西辽县)。按古书上的种字,不是现在所谓种族之义。古书所谓种或种姓,其意义,与姓氏或氏族相当。羯人有火葬之俗,与氐、羌同,疑系氐、羌与匈奴的混种,其成分且以氐、羌为多。羯室正以羯人居此得名,并非匈奴的一支,因居羯室之地而称羯。三、鲜卑。《后汉书》说东胡为匈奴所破,余众分保乌丸、鲜卑二山,因以为名。事实上,怕亦是山以部族名的。此二山,当在今蒙古东部苏克苏鲁、索岳尔济一带。乌桓在南,鲜卑在北。汉朝招致乌桓,居于上谷、渔阳、右北平、辽西、辽东塞上,以捍御匈奴。后汉时,北匈奴败亡,鲜卑徙居其地。其酋长檀石槐,曾一时控制今蒙古之地,东接夫余(与高句丽同属貉族。其都城,即今吉林的长春县),西至西域。所以乌丸和中国,较为接近,而鲜卑则据地较广。曹操和袁绍相争时,乌丸多附袁绍。袁氏既灭,曹操袭破之于柳城(汉县,今热河凌源县)。乌桓自此式微,而鲜卑则东起辽东,西至今甘肃境内,部族历历散布,成为五胡中人数最多、分布最广的一族。四、氐。氐人本来是居于武都的(即白马氐之地,今甘肃成县),魏武帝怕被蜀人所利用,把他迁徙到关中。五、羌。即后汉时叛乱之余。氐、羌都在泾、渭二水流域。当时的五胡大部分是居于塞内的,间或有在塞外的,亦和边塞很为接近。其人亦多散处民间,从事耕织,然犷悍之气未消,而其部族首领,又有野心勃勃,想乘时恢复故业的。一旦啸聚起来,“掩不备之人,收散野之积”(江统《徙戎论》语),其情势,自又非从塞外侵入之比。所以郭钦、江统等要想乘天下初定,用兵力将他们迁回故地。这虽不是民族问题根本解决之方,亦不失为政治上一时措置之策,而晋武帝因循不能用。\n●北燕·铜虎子 通长38厘米,高24厘米,辽宁北票县西官营子冯素弗墓出土\n第十六章 五胡之乱(上) # 五胡之乱,已经蓄势等待着了,而又有一个八王之乱(八王,谓汝南王亮、楚王玮、赵王伦、齐王冏、长沙王乂、成都王颖、河间王颙、东海王越),做它的导火线。封建亲戚以为屏藩之梦,此时尚未能醒。我们试看:魏武帝于建安十五年十二月己亥下令,说从前朝廷恩封我的几个儿子,我辞而不受,现在想起来,却又要受了,因为执掌政权年久,怕要谋害我的人多,想借此自全之故,就可见得这时候人的思想。魏虽亦有分封之制,但文帝当未做魏世子时,曾和他的兄弟争立,所以猜忌宗室诸王特甚,名为分藩,实同囚禁,绝不能牵掣晋朝的篡弑。晋人有鉴于此,所以得国之后,就大封同姓,体制颇为崇隆,而且各国都有卫兵。晋武帝是文帝的儿子,景帝之后,自然不甘退让。在武帝时,齐王攸颇有觊觎储位之意,似乎也有党附于他的人。然未能有成,惠帝卒立。惠帝是很昏愚的,其初太后父杨骏执政,皇后贾氏和楚王玮合谋,把他杀掉,而用汝南王亮,又把他杀掉,后又杀楚王,旋弑杨太后。太子遹非后所生,后亦把他废杀。赵王伦时总宿卫,因人心不服,弑后,遂废惠帝而自立。时齐王冏镇许昌,成都王颖镇邺(今河南临漳县),河间王颙镇关中,连兵攻杀伦。惠帝复位,齐王入洛专政。河间王颙和长沙王乂合谋攻杀之,又和成都王颖合谋,攻杀乂。东海王越合幽、并两州的兵,把河间、成都两王打败,遂弑惠帝而立怀帝。此等扰乱之事,在公元291至306的十六年间。\n匈奴单于,自后汉之末失位,入居中国。单于死后,中国分其部众为五,各立酋帅。其中左部最强,中国将其酋帅羁留在邺,以资驾驭,至晋初仍未释放。东海王之兵既起,刘渊说成都王回去合五部之众,来帮他的忙,成都王才释放了他。刘渊至并州,遂自立,是为十六国中的前赵。此时中原之地,盗贼蜂起,刘渊如能力征经营,很可以有所成就。然刘渊是个无甚才略的人,自立之后,遂安居不出。羯人石勒,才略却比较优长。东方群盗,尽为所并。名虽服从前赵,实则形同独立。东海王既定京师,出兵征讨,死于军中,其兵为石勒所追败。晋朝遂成坐困之势。310年,刘渊的族子刘曜攻破洛阳,怀帝被虏。明年,被弑。愍帝立于长安。316年,又被虏。明年,被弑。元帝时督扬州,从下邳迁徙到建业(下邳,今江苏邳县。建业,今南京。东晋后避愍帝讳,改曰建康),自立,是为东晋元帝。此时,在北方,只有幽州刺史王浚、并州刺史刘琨,崎岖和戎狄相持。南方则豫州刺史祖逖,从淮北经略今之豫东,颇有成绩。然王浚本是个狂妄的人,刘琨则窘困太甚,终于不能支持,为石勒所破灭。祖逖因中央和荆州互相猜忌,知道功不能成,愤慨而死,就无能抗拒石勒的人。328年,勒灭前赵。除割据凉州的前凉,辽东、西的前燕外,北方几尽入其手。\n●南齐·齐宣帝萧承之永安陵天禄 长2.95米,高2.75米,位于江苏丹阳胡桥乡狮子湾\n南方的情势,是荆州强于扬州。元帝即位之后,要想统一上流的事权,乃派王敦去都督荆州。王敦颇有才能,能把荆州的实权收归掌握,却又和中央互相猜忌。322年,终于决裂。王敦的兵,入据京城。元帝忧愤而死。子明帝立,颇有才略。乘王敦病死,把其余党讨平。然明帝在位仅三年。明帝崩,子成帝立,年幼,太后庾氏临朝,后兄庾亮执政,和历阳内史苏峻不协(今安徽和县)。苏峻举兵造反,亮奔温峤于寻阳(今江西九江县)。温峤是很公忠体国的,邀约荆州刺史陶侃,把苏峻打平。陶侃时已年老,故无跋扈之心。侃死后,庾亮出镇荆州。庾亮死后,其弟庾翼、庾冰继之。此时内外的大权,都在庾氏手里,所以成帝、康帝之世,相安无事。344年,康帝崩,子穆帝立。明年,庾翼死,表请以其子继任,宰相何充不听,而用了桓温。于是上下流之间,又成对立之势了。\n石勒死于333年。明年,勒从子虎杀勒子而自立。石虎是个淫暴无人理的,然兵力尚强。庾翼于342年出兵北伐,未能有功。349年,石虎死,诸子争立。汉人冉闵为虎养子,性颇勇悍,把石虎诸子尽行诛灭。闵下令道:“与官同心者住,不同心者各任所之。”于是“赵人百里内悉入城”,而“胡、羯去者填门”。闵知胡之不为己用,遂下令大诛胡、羯。单是一个邺中,死者就有二十多万。四方亦都承令执行。胡、羯经此打击,就不能再振了。先是鲜卑慕容廆,兴于辽西,兼并辽东。至其子皝,迁都龙城(今热河朝阳县)。慕容氏是远较前、后赵为文明的,地盘既广,兵力亦强。石虎死的前一年,慕容皝死,子儁立,乘北方丧乱,侵入中原。冉闵与战,为其所杀。于是河北之地,尽入于慕容氏。羌酋姚弋仲、氐酋苻洪,其初为后赵所压服的,至此亦乘机自立。苻洪死,子苻健入关。姚弋仲死,其子姚襄降晋,想借晋力以自图发展。晋朝因和桓温互相猜忌,引用了名士殷浩做宰相,想从下流去经略中原。殷浩亦不是没有才能的人,但扬州势成积弱,殷浩出而任事,又没有一个相当的时间以资准备,自然只得就固有的力量加以利用。于是即用姚襄为前锋,反为其所邀击,大败,军资丧失甚众。此事在354年。先是桓温已灭前蜀,至此,遂迫胁朝廷,废掉殷浩,他却出兵北伐,击破了姚襄,恢复洛阳,然亦未能再进。慕容儁死后,子慕容继之,虽年幼无知,然有慕容恪辅政,慕容垂带兵,仍有相当的力量。姚襄败后入关,为秦人所杀,弟苌以众降秦。秦苻健死后,子生无道,为苻坚所弑,自立,能任用王猛以修国政,其势尤张。此时的北方,已较难图,所以当后赵、冉闵纷纭争夺之时,晋朝实在坐失了一个恢复中原的机会。此时燕人频年出兵,以经略河南,洛阳又为所陷。369年,桓温出兵伐燕,大败于枋头(城名,今河南濬县)。桓温之意,本来要立些功业,再图篡夺的。至此,自顾北伐己无成功之望,乃于371年入朝,行废立之事(康帝崩,子穆帝立。崩,成帝子哀帝立。崩,弟海西公立。至是为桓温所废,而立元帝子简文帝)。温以禅让之意,讽示朝臣。谢安、王坦之当国,持之以静。373年,桓温死。他的兄弟桓冲,是个没有野心的人,把荆州让出,政局乃获暂安。\n第十七章 五胡之乱(下) # 东晋时的五胡十六国,实在并不成其为一个国家,所以其根基并不稳固。看似声势雄张,只是没有遇见强敌,一战而败,遂可以至于覆亡。枋头战后,慕容垂因被猜忌出奔。前秦乘机举兵,其明年,前燕竟为所灭。前秦又灭掉前凉,又有统一北方之势,然其根基亦并不是稳固的。此时北方的汉族,因为没有政府的领导,虽有强宗巨室和较有才力的人,能保据一隅,或者潜伏山泽,终产生不出一个强大的政权来,少数的五胡,遂得横行无忌。然他们亦是人各有心,而且野蛮成习,颇难于统驭的。五胡中苟有英明的酋长出来,亦只得希望汉族拥戴他,和他一心,要联合许多异族以制汉族,根本上是没有这回事的。若要专恃本族,而把汉族以外的异族铲除,则(一)因限于实力,(二)则汉族此时,并不肯替此等异族出死力,而此等异族,性本蛮悍,加以志在掠夺,用之为兵,似乎颇为适宜,所以习惯上都是靠它们做主力的军队,尽数剪除,未免削弱兵力,所以其势又办不到。苻坚的政策,是把氐人散布四方,行驻防政策,而将其余被征服的异族置之肘腋之下,以便监制。倘使他的威力,能够始终维持,原亦未为非计。然若一朝失足,则氐人散处四方,不能聚集,无复基本队伍,就糟了。所以当时,苻坚要想伐晋以图混一,他手下的稳健派,如王猛,如其兄弟苻融等,都是反对的。而苻坚志得意满,违众举兵,遂以383年大败于淝水。北方异族,乘机纷纷而起。而慕容垂据河北为后燕,姚苌据关中为后秦。苻坚于385年为姚苌所杀。子丕,族子登,相继自立,至394年,卒为姚苌之子姚兴所灭。此时侵入中原的五胡,已成强弩之末。因为频年攻战,死亡甚多,人口减少,而汉族的同化作用,仍在逐渐进行,战斗力也日益衰弱。其仍居塞外的,却比较气完力厚。此等情势,自公元四世纪末,夏及拓跋魏之兴,至六世纪前半尔朱氏、宇文氏等侵入中原,迄未曾变。自遭冉闵的大屠戮后,胡、羯之势,业已不能复振。只有匈奴铁弗氏,根据地在新兴(今山西忻县),还是一个比较完整的部落。拓跋氏自托于黄帝之后,说其初建国北荒,后来南迁大泽,因其地“昏冥沮洳”,乃再南迁至匈奴故地。自托于黄帝之后,自不足信,其起源发迹之地,该不是骗人的。它大约自西伯利亚迁徙到外蒙古,又逐渐迁徙到内蒙古的。晋初,其根据地在上谷之北,今滦河上源之西。刘琨藉其兵力以御匈奴,畀以雁门关以北之地。拓跋氏就据有平城,东至今察哈尔的西部。这时候,自辽东至今热河东部,都是慕容氏的势力范围。其西为宇文氏,再西就是拓跋氏。慕容氏盛时,宇文氏受其压迫,未能自强,拓跋氏却不然。拓跋氏和匈奴铁弗氏是世仇。苻坚时,拓跋氏内乱,铁弗的酋长刘卫辰引秦兵把他打破。苻坚即使刘卫辰和其族人刘库仁分管其部落。刘库仁是拓跋氏的女婿,反保护其遗裔拓跋珪。其时塞外,从阴山至贺兰山,零星部落极多,拓跋珪年长后,逐渐加以征服,势力复张。刘卫辰为其所灭,其子勃勃奔后秦,姚兴使其守御北边,勃勃遂叛后秦自立。后秦屡为所败,国势益衰。395年,慕容垂之子宝伐后魏,大败于参合陂(今山西阳高县)。明年,慕容垂自将伐魏。魏人退出平城,以避其锋。慕容垂入平城,而实无所得。还至参合陂,见前此战败时的尸骸,堆积如山,军中哭声振天,惭愤而死。慕容宝继立。拓跋珪大举来攻,势如排山倒海。慕容宝弃其都城中山,逃到龙城,被弑。少子盛定乱自立,旋亦被弑。弟熙立,因淫虐,为其将冯跋所篡,是为北燕。其宗族慕容德南走广固(今山东益都县西),自立,是为南燕。拓跋珪服寒食散,散发不能治事,不复出兵。北方形势,又暂告安静。\n●北魏·元邵墓陶俑 河南省洛阳市出土。前为镇墓兽,后为陶武士俑、陶骑马乐俑等。陶俑形体修长,挺拔劲健,眉目端丽,表现出特有的时代风貌\n南方当这时候,却产生出一种新势力来。晋朝从东渡以后,长江上流的形势,迄较下流为强,以致内外相持,坐视北方的丧乱而不能乘。当淝水战前六年,谢玄镇广陵(今江苏江都县),才创立一支北府兵,精锐无匹,而刘牢之为这一支军队的领袖。淝水之战,就是倚以制胜的。下流的形势,至此实已较上流为强。东晋孝武帝,是一个昏聩糊涂的人。始而信任琅邪王道子,后来又猜忌他,使王恭镇京口(今江苏镇江),殷仲堪镇江陵以防之。慕容垂死的一年,孝武帝也死了,子安帝立。398年,王恭、殷仲堪同时举兵。道子嗜酒昏愚,而其世子元显,年少有些才气。使人勾结刘牢之倒戈,王恭被杀。而上流之兵已逼,牢之不肯再战。殷仲堪并不会用兵,军事都是委任南郡相杨佺期的(南郡,治江陵)。而桓温的小儿子桓玄在荆州,仍有势力,此时亦在军中。晋朝乃以杨佺期刺雍州,桓玄刺江州,各给了一个地盘,上流之兵才退。后来殷仲堪和杨佺期,都给桓玄所并。402年,元显乘荆州饥馑,举兵伐玄,刘牢之又倒戈,桓玄入京城,元显和道子都被杀。桓玄是个狂妄的人,得志之后,夺掉了刘牢之的兵权,牢之谋反抗,而手下的人,不满他的屡次倒戈,不肯服从,牢之自缢而死。桓玄以为天下无事了,就废安帝自立。然刘牢之虽死,北府兵中人物尚多。404年,刘裕等起兵讨玄,玄遂败死。安帝复位。刘裕入居中央,掌握政权,一时的功臣,都分布州郡,南方的形势一变。\n●北齐·公牛与神兽图 山西省太原市王郭村北齐娄睿墓甬道壁画。公牛身躯壮健,作昂首前进状,造型比例准确,线条简练,前后各有二神兽围护\n409年,刘裕出兵灭南燕。想要停镇下邳,经营河、洛,而后方又有变故。先是399年,孙恩起兵会稽(今浙江绍兴),剽掠沿海。后为刘牢之及刘裕所破,入海岛而死。其党卢循袭据广州。桓玄不能讨,用为刺史。卢循又以其妹夫徐道覆为始兴相(今广东曲江县)。刘裕北伐时,卢循、徐道覆乘机北出,沿江而下,直逼京城。此时情势确甚危急。刘裕速回兵,以疲敝之众,守住京城。卢循、徐道覆不能克,退回上流,为裕所袭败。裕又遣兵从海道袭据广州,把他们打平。刘裕于是剪除异己。至417年,复大举以灭后秦。此时后魏正值中衰;凉州一隅,自前秦亡后,复四分五裂,然其中并无强大之国(氐酋吕光,为苻坚将,定西域。苻坚败后,据姑臧自立,是为后凉。后匈奴酋沮渠蒙逊据张掖叛之,为北凉。汉族李暠据敦煌,为西凉。鲜卑秃发乌孤据乐都为南凉。后凉之地遂分裂。又有鲜卑乞伏国仁,据陇右,为西秦。后凉为后秦所灭。西凉为北凉所灭。南凉为西秦所灭。西秦为夏所灭。北凉为后魏所灭。姑臧,今甘肃武威县。张掖、敦煌,今县皆同名。乐都,今碾伯县。西秦初居勇士川,在今甘肃金县后徙苑川,在今甘肃靖远县);夏虽有剽悍之气,究系偏隅小国;倘使刘裕能在关中驻扎几年,扩清扫荡之效,是可以预期的,则当南北朝分立之初,海内即可有统一之望,以后一百七十年的分裂之祸,可以免除了。然旧时的英雄,大抵未尝学问。个人权势意气之争,重于为国为民之念。以致同时并起,资望相等的人物,往往不能相容,而要互相剪灭,这个实在使人才受到一个很大的损失。刘裕亦是如此,到灭秦时,同起义兵诸人,都已被剪除尽了。手下虽有几个勇将,资格都是相等的,谁亦不能统率谁。而刘裕后方的机要事务,全是交给一个心腹刘穆之的,这时候,刘穆之忽然死了,刘裕放心不下,只得弃关中而归,留一个小儿子义真,以镇守长安。诸将心力不齐,长安遂为夏所陷。刘裕登城北望,流涕而已。内部的矛盾,影响到对外,真可谓深刻极了。420年,刘裕篡晋,是为宋武帝。三年而崩。子少帝立,为宰相徐羡之等所废,迎立其弟文帝。文帝亦是个中主,然无武略,而功臣宿将,亦垂垂向尽。自北府兵创立至此,不足五十年,南方新兴的一种中心势力,复见衰颓。北魏拓跋珪自立,是为道武帝。道武帝末年,势颇不振。子明元帝,亦仅谨守河北。明元帝死,子太武帝立,复强。公元431年,灭夏。436年,灭燕。凉州之地,亦皆为其所吞并。天下遂分为南北朝。\n第十八章 南北朝的始末 # 南北朝的对立,起于公元420年宋之代晋,终于公元589年隋之灭陈,共一百七十年。其间南北的强弱,以宋文帝的北伐失败及侯景的乱梁为两个重要关键。南朝的治世,只有宋文帝和梁武帝在位时,历时较久。北方的文野,以孝文的南迁为界限,其治乱则以尔朱氏的侵入为关键。自尔朱氏、宇文氏等相继失败后,五胡之族,都力尽而衰,中国就复见盛运了。\n宋文帝即位后,把参与废立之谋的徐羡之、傅亮、谢晦等都诛灭。初与其谋而后来反正的檀道济,后亦被杀。于是武帝手里的谋臣勇将,几于靡有孑遗了。历代开国之主,能够戡定大乱、抵御外患的,大抵在政治上、军事上,都有卓绝的天才,此即所谓文武兼资。而其所值的时局,难易各有不同。倘使大难能够及身戡定,则继世者但得守成之主,即可以蒙业而安。如其不然,则非更有文武兼资的人物不可。此等人固不易多得,然人之才力,相去不远,亦不能谓并时必无其人;尤其做一番大事业的人,必有与之相辅之士。倘使政治上无家天下的习惯,开国之主,正可就其中择贤而授,此即儒家禅让的理想,国事实受其益了。无如在政治上,为国为民之义,未能彻底明了,而自封建时代相沿下来的自私其子孙,以及徒效忠于豢养自己的主人的观念,未能打破,而君主时代所谓继承之法,遂因之而立。而权利和意气,都是人所不能不争的,尤其以英雄为甚。同干一番事业的人,遂至不能互相辅助,反要互相残杀,其成功的一个人,传之于其子孙,则都是生长于富贵之中的,好者仅得中主,坏的并不免荒淫昏暴,或者懦弱无用。前人的功业,遂至付诸流水,而国与民亦受其弊。这亦不能不说是文化上的一个病态了。宋初虽失关中,然现在的河南、山东,还是中国之地。宋武帝死后,魏人乘丧南伐,取青、兖、司、豫四州(时青州治广固,兖州治滑台,司州治虎牢,豫州治睢阳。滑台,今河南滑县。虎牢,今河南汜水县。睢阳,今河南商丘县)。此时的魏人,还是游牧民族性质,其文化殊不足观,然其新兴的剽悍之气,却亦未可轻视,而文帝失之于轻敌。430年,遣将北伐,魏人敛兵河北以避之,宋朝得了虎牢、滑台而不能继续进取,兵力并不足坚守。至冬,魏人大举南下,所得之地复失。文帝经营累年,至450年,又大举北伐。然兵皆白丁,将非材勇,甫进即退。魏太武帝反乘机南伐,至于瓜步(镇名,今江苏六合县),所过之处,赤地无余,至于燕归巢于林木,元嘉之世,本来称为南朝富庶的时代的,经此一役,就元气大伤了,而北强南弱之势,亦于是乎形成。\n公元453年,宋文帝为其子劭所弑。劭弟孝武帝,定乱自立。死后,子前废帝无道,为孝武弟明帝所废。孝武帝和明帝都很猜忌,专以屠戮宗室为务。明帝死后,大权遂为萧道成所窃。荆州的沈攸之,和宰相袁粲,先后谋诛之,都不克。明帝子后废帝及顺帝,都为其所废。479年,道成遂篡宋自立,是为齐高帝。在位四年。子武帝,在位十一年。高、武二帝,都很节俭,政治较称清明。武帝太子早卒,立大孙郁林王,为武帝兄子明帝所废。明帝大杀高、武二帝子孙。明帝死后,子东昏侯立。时梁武帝萧衍刺雍州,其兄萧懿刺豫州。梁武帝兄弟,本与齐明帝同党。其时江州刺史陈显达造反,东昏侯使宿将崔慧景讨平之。慧景还兵攻帝,势甚危急,萧懿发兵入援,把他打平。东昏侯反把萧懿杀掉,又想削掉萧衍。东昏侯之弟宝融,时镇荆州,东昏侯使就其长史萧颖胄图之。颖胄奉宝融举兵,以梁武帝为前锋。兵至京城,东昏侯为其下所弑。宝融立,是为和帝。旋传位于梁,此事在502年。\n梁武帝在位四十八年,其早年政治颇清明。自宋明帝时,和北魏交兵,尽失淮北之地。齐明帝时又失沔北。东昏侯时,因豫州刺史裴叔业降魏,并失淮南(时豫州治寿阳,今安徽寿县)。梁武帝时,大破魏兵于锺离(在今安徽凤阳县),恢复了豫州之地。对外的形势,也总算稳定。然梁武性好佛法,晚年刑政殊废弛。又因太子统早卒,不立嫡孙而立次子简文帝为太子,心不自安,使统诸子出刺大郡,又使自己的儿子出刺诸郡,以与之相参。彼此乖离,已经酝酿着一个不安的形势。而北方侯景之乱,又适于此时发作。\n北魏太武帝,虽因割据诸国的不振,南朝的无力恢复,侥幸占据了北方,然其根本之地,实在平城,其视中国,不过一片可以榨取利益之地而已。他还不能自视为和中国一体,所以也不再图南侵。因为其所有的,业已不易消化了。反之,平城附近,为其立国根本之地,却不可不严加维护。所以魏太武帝要出兵征伐柔然、高车,且于北边设立六镇(武川,今绥远武川县。抚冥,在武川东。怀朔,在今绥远五原县。怀荒,在今大同东北察哈尔境内。柔玄,在今察哈尔兴和县。御夷,在今察哈尔沽源县),盛简亲贤,配以高门子弟,以厚其兵力。孝文帝是后魏一个杰出人物。他仰慕中国的文化,一意要改革旧俗。但在平城,终觉得环境不甚适宜。乃于公元493年,迁都洛阳。断北语,改姓氏,禁胡服,奖励鲜卑人和汉人通婚,自此以后,鲜卑人就渐和汉人同化了。然其根本上的毛病,即以征服民族自居,视榨取被征服民族以供享用为当然之事,因而日入于骄奢淫逸,这是不能因文明程度的增进而改变的,而且因为环境的不同,其流于骄奢淫逸更易。论者因见历来的游牧民族同化于汉族之后,即要流于骄奢淫逸,以致失其战斗之力,以为这是中国的文明害了它,摹仿了中国的文明,同时亦传染了中国的文明病。其实它们骄奢淫逸的物质条件,是中国人供给它的,骄奢淫逸的意志,却是它们所自有;而这种意志,又是与其侵略事业,同时并存的,因为它们的侵略,就是它们的生产事业。如此,所以像金世宗等,要禁止他的本族人华化,根本是不可能的。因为不华化,就是要一切生活都照旧,那等于只生产而不消费,经济学上最后的目的安在呢?所以以骄奢淫逸而灭亡,殆为野蛮的侵略民族必然的命运。后魏当日,便是如此。孝文帝传子宣武帝至孝明帝。年幼,太后胡氏临朝。荒淫纵恣,把野蛮民族的病态,悉数现出。中原之民,苦于横征暴敛,群起叛乱。而六镇将士,因南迁以后,待遇不如旧时,魏朝又怕兵力衰颓,禁其浮游在外,亦激而生变。有一个部落酋长,唤作尔朱荣,起而加以镇定。尔朱氏是不曾侵入中原的部族,还保持着犷悍之风。胡太后初为其亲信元义等所囚,后和明帝合谋,把他们诛灭。又和明帝不协。明帝召尔朱荣入清君侧,已而又止之。胡太后惧,弑明帝。尔朱荣举兵入洛,杀胡太后而立孝庄帝。其部众既劲健,而其用兵亦颇有天才。中原的叛乱,都给他镇定了。然其人起于塞外,缺乏政治手腕,以为只要靠兵力屠杀,就可以把人压服。当其入洛之日,就想做皇帝,乃纵兵士围杀朝士二千余人。居民惊惧,逃入山中,洛阳只剩得一座空城。尔朱荣无可如何,只得退居晋阳,遥执朝权。然其篡谋仍不息。孝庄帝无拳无勇,乃利用宣传为防御的工具。当尔朱荣篡谋急时,孝庄帝就散布他要进京的消息,百姓就逃走一空,尔朱荣只得自止。到后来,看看终非此等手段所能有济了。530年,乃索性召他入朝。孝庄帝自藏兵器于衣内,把他刺死。其侄儿尔朱兆,举兵弑帝,别立一君。此时尔朱氏的宗族,分居重镇,其势力如日中天。然尔朱兆是个鲁莽之夫,其宗族中人,亦与之不协。532年,其将高欢起兵和尔朱氏相抗。两军相遇于韩陵(山名,在今河南安阳县),论兵力,尔朱氏是远过于高欢,然因其暴虐过甚,高欢手下的人都齐心死战,而尔朱氏却心力不齐,遂至大败。晋阳失陷,尔朱兆逃至秀容川(在今山西朔县),为高欢所掩杀。其余尔朱氏诸人亦都被扑灭。高欢入洛,废尔朱氏所立,而别立孝武帝。高欢身居晋阳,继承了尔朱荣的地位。孝武帝用贺拔岳为关中大行台,图与高欢相抗。高欢使其党秦州刺史侯莫陈悦杀岳(秦州,今甘肃天水县)。夏州刺史宇文泰攻杀悦(夏州,今陕西横山县),孝武帝即以泰继岳之任。534年,孝武帝举兵讨欢,高欢亦自晋阳南下,夹河而军,孝武帝不敢战,奔关中,为宇文泰所弑。于是高欢、宇文泰,各立一君,魏遂分为东、西。至550年,而东魏为高欢子洋所篡,是为北齐文宣帝。557年,西魏为宇文泰之子觉所篡,是为北周孝闵帝。\n●麦积山石窟佛像。高1.635米\n当东、西魏分裂后,高欢、字文泰曾剧战十余年,彼此都不能逞志,而其患顾中于梁。这时候,北方承剧战之后,兵力颇强,而南方武备久废弛,欲谋恢复,实非其时,而梁武帝年老昏耄,却想乘机侥幸,其祸就不可免了。高欢以547年死。其将侯景,是专管河南的,虽然野蛮粗鲁,在是时北方诸将中,已经算是狡黠的了。高欢死后,其子高澄,嗣为魏相。侯景不服,遂举其所管之地来降。梁武帝使子渊明往援,为魏所败,渊明被擒。侯景逃入梁境,袭据寿阳,梁朝不能制。旋又中魏人反间之计,想牺牲侯景,与魏言和。侯景遂反,进陷台城(南朝之宫城),梁武帝忧愤而崩,时为549年。子简文帝立,551年,为侯景所弑。武帝子湘东王绎即位于江陵,是为元帝。时陈武帝陈霸先自岭南起兵勤王。元帝使其与王僧辩分道东下,把侯景诛灭。先是元帝与诸王,互相攻击。郢州的邵陵王纶(郢州,今湖北武昌县。纶,武帝子),湘州的河东王誉(誉、詧皆昭明太子统之子),皆为所并。襄阳的岳阳王詧,则因求救于西魏而得免。至元帝即位后,武陵王纪亦称帝于成都(纪,武帝子),举兵东下。元帝亦求救于西魏,西魏袭陷成都。武陵王前后受敌,遂败死。而元帝又与西魏失和。554年,西魏陷江陵,元帝被害。魏人徙岳阳王詧于江陵,使之称帝,而对魏则称臣,是为西梁。王僧辩、陈霸先立元帝之子方智于建康,是为敬帝。而北齐又送渊明回国,王僧辩战败,遂迎立之。陈霸先讨杀僧辩,奉敬帝复位。557年,遂禅位于陈。这时候,梁朝骨肉相残,各引异族为助,南朝几至不国。幸得陈武帝智勇足备,卓然不屈,才得替汉族保存了江南之地。\n陈武帝即位后三年而崩。无子,传兄子文帝。文帝死后,弟宣帝,废其子废帝而代之。文、宣二帝,亦可称中主,但南方当丧乱之余,内部又多反侧,所以不能自振。北方则北齐文宣、武成二帝,均极荒淫。武成帝之子纬,尤为奢纵。而北周武帝,颇能励精图治。至577年,齐遂为周所灭。明年,武帝死,子宣帝立,又荒淫。传位于子静帝,大权遂入后父杨坚之手。581年,坚废静帝自立,是为隋文帝。高齐虽自称是汉族,然其性质实在是胡化了的。隋文帝则勤政恤民,俭于自奉,的确是代表了汉族的文化。自西晋覆亡以来,北方至此才复建立汉人统一的政权。此时南方的陈后主,亦极荒淫。589年,为隋所灭。西梁则前两年已被灭。天下复见统一。\n两晋、南北朝之世,是向来被看做黑暗时代的,其实亦不尽然。这一时代,只政治上稍形黑暗,社会的文化,还是依然如故。而且正因时局的动荡,而文化乃得为更大的发展。其中关系最大的,便是黄河流域文明程度最高的地方的民族,分向各方面迁移。《汉书·地理志》叙述楚地的生活情形,还说江南之俗,火耕水耨,果蓏蜯蛤,饮食还足,故呰窳婾生,而无积聚,而《宋书·孔季恭传》叙述荆、扬二州的富力,却是“膏腴上地,亩直一金,鄠、杜之间不能比”(鄠,今陕西鄠县;杜,在今陕西长安县南,汉时农业盛地价高之处);又说:“鱼、盐、杞、梓之利,充仞八方,丝棉、布帛之饶,覆衣天下”,成为全国富力的中心了。三国之世,南方的风气,还是很剽悍的,读第十四章所述可见。而自东晋以来,此种风气,亦潜移默化。谈玄学佛,成为全国文化的重心,这是最彰明较著的。其他东北至辽东,西南至交阯,莫不有中原民族的足迹,其有裨于增进当地的文化,亦决非浅鲜,不过不如长江流域的显著罢了。还有一层,陶潜的《桃花源诗》,大家当他是预言,其实这怕是实事。自东汉之末,至于南北朝之世,北方有所谓山胡,南方有所谓山越。听了胡、越之名,似乎是异族蛰居山地的,其实不然。试看他们一旦出山,便可和齐民杂居,服兵役,输赋税,绝无隔阂,便可知其实非异族,而系汉族避乱入山的。此等避乱入山的异族,为数既众,历时又久,山地为所开辟,异族为所同化的,不知凡几,真是拓殖史上的无名英雄了。以五胡论:固然有荒淫暴虐,如石虎,齐文宣、武成之流的,实亦以能服从汉族文化的居其多数。石勒在兵戈之际,已颇能引用士人,改良政治。苻坚更不必说。慕容氏兴于边徼,亦是能慕效中国的文明的。至北魏孝文帝,则已举其族而自化于汉族。北周用卢辩、苏绰,创立法制,且有为隋、唐所沿袭的。这时候的异族,除血统之外,几乎已经说不出其和汉族的异点了。一到隋唐时代,而所谓五胡,便已泯然无迹,良非偶然。\n第十九章 南北朝隋唐间塞外的形势 # 葱岭以东,西伯利亚以南,后印度半岛以东北,在历史上实自成其为一个区域。这一个区域中,以中国的产业和文化最为发达,自然成为史事的重心。自秦汉至南北朝,我们可以把它看做一个段落,隋唐以后,却又是一个新段落了。这一个新段落中,初期的形势,乃是从五胡侵入中原以后逐渐酝酿而成的,在隋唐兴起以前,实有加以一番检讨的必要。\n漠南北之地,对于中国是一个最大的威胁。继匈奴而居其地的为鲜卑。自五胡乱华以来,鲜卑纷纷侵入中国。依旧保持完整的只有一个拓跋氏,然亦不过在平城附近。自此以东,则有宇文氏的遗落奚、契丹,此时部落尚小。其余的地方都空虚了,铁勒乃乘机入据。铁勒,异译亦作敕勒,即汉时的丁令。其根据地,东起贝加尔湖,西沿西域之北,直抵里海。鲜卑侵入中原后,铁勒踵之而入漠北。后魏道武帝之兴,自阴山以西,漠南零星的部落,几于尽被吞并。只有一个柔然不服,为魏太武帝所破,逃至漠北,臣服铁勒,藉其众以抗魏。魏太武帝又出兵把它打破。将降伏的铁勒迁徙到漠南。这一支,历史上特称为高车,其余则仍称铁勒。南北朝末年,柔然又强了。东、西魏和周、齐都竭力敷衍它。后来阿尔泰山附近的突厥强盛。公元552年,柔然为其所破。突厥遂征服漠南北,继承了柔然的地位,依旧受着周、齐的敷衍。\n西域对中国,是无甚政治关系的,因为它不能侵略中国,而中国当丧乱之时,亦无暇经营域外之故。两晋、南北朝之世,只有苻坚,曾遣吕光去征伐过一次西域,其余都在平和的状态中。但彼此交通仍不绝。河西一带,商业亦盛,这只要看这一带兼用西域的金银钱可知。西域在这时期,脱离了中国和匈奴的干涉,所以所谓三十六国者,得以互相吞并。到隋唐时,只剩得高昌、焉耆、龟兹、于阗等几个大国。\n东北的文明,大略以辽东、西和汉平朝鲜后所设立的四郡为界线。自此以南,为饱受中国文明的貉族。自此以北,则为未开化的满族,汉时称为挹娄,南北朝、隋、唐时称为勿吉,亦作靺鞨。貉族的势力,在前汉时,曾发展到今吉林省的长春附近,建立一个夫余国。后汉时,屡通朝贡。晋初,为鲜卑慕容氏所破。自此渐归澌灭,而辽东、西以北,乃全入鲜卑和靺鞨之手。貉族则专向朝鲜半岛发展,其中一个部落,唤做高句丽的,自中国对东北实力渐衰,遂形成一个独立国。慕容氏侵入中原后,高句丽尽并辽东之地,侵略且及于辽西。其支族又于其南建立一个百济国。半岛南部的三韩,自秦时即有汉人杂居,谓之秦韩。后亦自立为国,谓之新罗。高句丽最强大,其初新罗、百济,尝联合以御之,后百济转附高句丽,新罗势孤,乃不得不乞援于中国,为隋唐时中国和高句丽、百济构衅的一个原因。\n南方海路的交通,益形发达。前后印度及南洋群岛,入贡于中国的很多。中国是时,方热心于佛学,高僧往印度求法,和彼土高僧来中国的亦不少。高句丽、百济,亦自海道通南朝。日本当后汉时,其大酋始自通于中国。至东晋以后,亦时向南朝通贡,传受了许多文明。侯景乱后,百济贡使到建康来,见城阙荒毁,至于号恸涕泣,可见东北诸国,对我感情的深厚了。据阿剌伯人所著的古旅行记,说公元1世纪后半,西亚的海船,才达到交阯。公元1世纪后半,为后汉光武帝至和帝之时。其后桓帝延熹九年,当公元166年,而大秦王安敦(Marcus Aurelius Antoninus,生于公元121年,即后汉安帝建光六年,没于180年,即后汉灵帝光和三年),遣使自日南徼外通中国,可见这记载的不诬。他又说:公元3世纪中叶,中国商船开始西向,从广州到槟榔屿,4世纪至锡兰,5世纪至亚丁,终至在波斯及美索不达米亚独占商权。到7世纪之末,阿剌伯人才与之代兴。3世纪中叶,当三国之末,7世纪之末,则当唐武后时。这四百五十年之中,可以说是中国人握有东西洋航权的时代了。至于偶尔的交通所及,则还不止此。据《梁书·诸夷传》:倭东北七千余里有文身国,文身国东五千余里有大汉国,大汉国东二万余里有扶桑国。这扶桑国或说它是现在的库页岛,或说它是美洲的墨西哥,以道里方向核之,似乎后说为近。据《梁书》所载:公元499年,其国有沙门慧深来至荆州,又晋时法显著《佛国记》,载其到印度求法之后,自锡兰东归,行三日而遇大风,十三日到一岛,又九十余日而到耶婆提,自耶婆提东北行,一月余,遇黑风暴雨,凡七十余日,折西北行,十二日而抵长广郡(今山东即墨县)。章炳麟作《法显发现西半球说》,说他九十余日的东行,实陷入太平洋中。耶婆提当在南美。自此向东,又被黑风吹入大西洋中,超过了中国海岸,折向西北,才得归来。衡以里程及时日,说亦可信。法显的东归,在公元416年,比哥伦布的发现美洲要早1077年了。此等偶然的漂泊,和史事是没有多大关系的,除非将来再有发现,知道美洲的开化,中国文化确占其中重要的成分。此时代的关系:在精神方面,自以印度的佛教为最大;在物质方面,则西南洋一带,香药、宝货和棉布等,输入中国的亦颇多。\n第二十章 隋朝和唐朝的盛世 # 北朝的君主,有荒淫暴虐的,也有能励精图治的,前一种代表了胡风,后一种代表了汉化。隋文帝是十足的后一种的典型。他勤于政事,又能躬行节俭。在位时,把北朝的苛捐杂税都除掉,而府库充实,仓储到处丰盈,国计的宽余,实为历代所未有。突厥狃于南北朝末年的积习,求索无厌。中国不能满其欲,则拥护高齐的遗族,和中国为难。文帝决然定计征伐,大破其兵。又离间其西方的达头可汗和其大可汗沙钵略构衅,突厥由是分为东、西。文帝又以宗女妻其东方的突利可汗。其大可汗都蓝怒,攻突利。突利逃奔中国,中国处之夏、胜二州之间(夏州,在今陕西横山县北。胜州,在今绥远鄂尔多斯左翼后旗黄河西岸),赐号为启民可汗。都蓝死,启民因隋援,尽有其众,臣服于隋。从南北朝末期以来畏服北狄的心理,至此一变。\n隋文帝时代,中国政局,确是好转了的。但是文化不能一时急转,所以还不能没有一些曲折。隋文帝的太子勇,是具有胡化的性质的。其次子炀帝,却又具有南朝君主荒淫猜忌的性质。太子因失欢于文帝后独孤氏被废。炀帝立,以洛阳为东都。开通济渠,使其连接邗沟及江南河。帝乘龙舟,往来于洛阳、江都之间。又使裴矩招致西域诸胡,所过之地,都要大营供帐。又诱西突厥献地,设立西海、河源、鄯善、且末四郡(西海郡,当系青海附近之地。河源郡该在其西南。鄯善、且末,皆汉时西域国名,郡当设于其故地。鄯善国在今罗布泊之南。且末国在车尔成河上),谪罪人以实之。又于611、613、614年,三次发兵伐高句丽,天下骚动,乱者四起。炀帝见中原已乱,无心北归,滞留江都,618年,为其下所弑。其时北方的群雄,以河北的窦建德、河南的李密为最大。而唐高祖李渊,以太原留守,于617年起兵,西据关中,又平定河西、陇右,形势最为完固。炀帝死后,其将王世充拥众北归,据洛阳。李密为其所败,降唐。又出关谋叛,为唐将所击斩。唐兵围洛阳,窦建德来救,唐兵大败擒之,世充亦降。南方割据的,以江陵的萧铣为最大,亦为唐所灭。江、淮之间,有陈稜、李子通、沈法兴、杜伏威等,纷纷而起,后皆并于杜伏威,伏威降唐。北边群雄,依附突厥的,亦次第破灭。隋亡后约十年,而天下复定。\n唐朝自称为西凉李暠之后,近人亦有疑其为胡族的,信否可不必论,民族的特征,乃文化而非血统。唐朝除太宗太子承乾具有胡化的性质,因和此时的文化不相容而被废外,其余指不出一些胡化的性质来,其当认为汉民族无疑了。唐朝开国之君虽为高祖,然其事业,实在大部分是太宗做的。天下既定之后,其哥哥太子建成,和兄弟齐王元吉,要想谋害他,为太宗所杀。高祖传位于太宗,遂开出公元627至649的二十三年间的“贞观之治”。历史上记载他的治绩,至于行千里者不赍粮,断死刑岁仅三十九人,这固然是粉饰之谈,然其时天下有丰乐之实,则必不诬的了。隋唐时的制度,如官制、选举、赋税、兵、刑等,亦都能将前代的制度加以整理,参看第四十二至第四十六章可明。\n对外的情势,此时亦开一新纪元。突厥因隋末之乱,复强盛,控弦之士至百万。北边崛起的群雄,都尊奉它,唐高祖初起时亦然,突厥益骄。天下既定,赠遗不能满其欲,就连年入寇,甚至一年三四入,北边几千里,无处不被其患。太宗因其饥馑和属部的离叛,于630年,发兵袭击,擒其颉利可汗。突厥的强盛,本来是靠铁勒归附的。此时铁勒诸部,以薛延陀、回纥为最强。突厥既亡,薛延陀继居其地。644年,太宗又乘其内乱加以剪灭。回纥徙居其地,事中国颇谨。在西域,则太宗曾用兵于高昌及焉耆、龟兹,以龟兹、于阗、焉耆、疏勒之地为四镇。在西南,则绥服了今青海地方的吐谷浑。西藏之地,隋时始有女国和中国往来。唐时,有一个部落,其先该是从印度迁徙到雅鲁藏布江流域的,是为吐蕃。其英主弃宗弄赞,太宗时始和中国交通,尚宗女文成公主,开西藏佛化的先声。太宗又通使于印度。适直其内乱,使者王玄策调吐蕃和泥婆罗的兵,把它打败。而南方海路交通,所至亦甚广。只有高句丽,太宗自将大兵去伐它,仍未能有功。此乃因自晋以来,东北过于空虚,劳师远攻不易之故。直至663、668两年,高宗才乘其内乱,把百济和高句丽先后灭掉。突厥西方的疆域,本来是很广的。其最西的可萨部,已和东罗马相接了。高宗亦因其内乱,把它戡定。分置两个都督府。其所辖的羁縻府、州,西至波斯。唐朝对外的声威,至此可谓达于最高峰了。因国威之遐畅,而我国的文化,和别国的文化,就起了交流互织的作用。东北一隅,自高句丽、百济平后,新罗即大注意于增进文化。日本亦屡遣通唐使,带了许多僧侣和留学生来。朝鲜半岛南部和日本的举国华化,实在此时。其余波且及于满族。公元7世纪末年,遂有渤海国的建立,一切制度,都以中国为模范。南方虽是佛化盛行之地,然安南在此时,仍为中国的郡县,替中国在南方留了一个文化的据点。西方则大食帝国勃兴于此时,其疆域东至葱岭。大食在文化上实在是继承古希腊,而为欧洲近世的再兴导其先路的。中国和大食,政治上无甚接触,而在文化上则彼此颇有关系。回教的经典和历数等知识,都早经输入中国。就是末尼教和基督教,也是受了回教的压迫,才传播到东方来的。而称为欧洲近世文明之源的印刷术、罗盘针、火药,亦都经中国人直接传入回教国,再经回教国人之手,传入欧洲。\n第二十一章 唐朝的中衰 # ●唐太宗像\n唐朝对外的威力,以高宗时为极盛,然其衰机亦肇于是时。高宗的性质,是失之于柔懦的。他即位之初,还能遵守太宗的成规,所以永徽之政,史称其比美贞观。公元655年,高宗惑于才人武氏,废皇后王氏而立之。武后本有政治上的才能,高宗又因风眩之故,委任于她,政权遂渐入其手。高句丽、百济及西突厥,虽于此时平定,而吐蕃渐强。吐谷浑为其所破,西域四镇,亦被其攻陷,唐朝的外患,于是开始。683年,高宗崩,子中宗立。明年,即为武后所废,徙之房州(今湖北竹山县),立其弟豫王旦(即后来的睿宗)。690年,又废之,改国号为周,自称则天皇帝。后以宰相狄仁杰之言,召回中宗,立为太子。705年,宰相张柬之等乘武后卧病,结宿卫将,奉中宗复位。自武后废中宗执掌政权至此,凡二十二年,若并其为皇后时计之,则达五十五年之久。武后虽有才能,可是宅心不正。她是一种只计维持自己的权势地位,而不顾大局的政治家。当其握有政权之时,滥用禄位,以收买人心;又任用酷吏,严刑峻法,以威吓异己的人,而防其反动;骄奢淫逸的事情,更不知凡几,以致政治大乱。突厥余众复强,其默啜可汗公然雄踞漠南北,和中国对抗。甚至大举入河北,残数十州县。契丹酋长李尽忠,亦一度入犯河北,中国不能讨,幸其为默啜所袭杀,乱乃定。因契丹的反叛,居于营州的靺鞨(营州,今热河朝阳县,为唐时管理东北异族的机关),就逃到东北,建立了一个渤海国。此为满族开化之始,中国对东北的声威,却因此失坠了。设在今朝鲜平壤地方的安东都护府,后亦因此不能维持,而移于辽东。高句丽、百济旧地,遂全入新罗之手。西南方面,西域四镇,虽经恢复,青海方面对吐蕃的战事,却屡次失利。中宗是个昏庸之主,他在房州,虽备尝艰苦,复位之后,却毫无觉悟,并不能铲除武后时的恶势力。皇后韦氏专权,和武后的侄儿子武三思私通,武氏因此复盛。张柬之等反遭贬谪而死。韦后的女儿安乐公主,中宗的婕妤上官婉儿,亦都干乱政治。政界情形的混浊,更甚于武后之时。710年,中宗为韦后所弑。相王旦之子临淄王隆基定乱而立相王,是为睿宗。立隆基为太子。武后的女儿太平公主仍干政,惮太子英明,要想摇动他。幸而未能有成,太平公主被谪,睿宗亦传位于太子,是为玄宗。玄宗用姚崇为相,廓清从武后以来的积弊。又用宋璟及张九龄,亦都称为能持正。自713至741年,史家称为开元之治。末年,突厥复衰乱,744年,乘机灭之;连年和吐蕃苦战,把中宗时所失的河西九曲之地亦收复,国威似乎复振。然自武后已来,荒淫奢侈之习,渐染已深。玄宗初年,虽能在政治上略加整顿,实亦堕入其中而不能自拔。中岁以后,遂渐即怠荒。宠爱杨贵妃,把政事都交给一个奸佞的李林甫。李林甫死后,又用一个善于夤缘的杨国忠。天宝之乱,就无可遏止了。一个团体,积弊深的,往往无可挽回,这大约是历时已久的皇室,必要被推翻的一个原因罢。\n唐朝的盛衰,以安史之乱为关键。安史之乱,皇室的腐败只是一个诱因,其根源是别有所在的。一、唐朝的武功从表面看,虽和汉朝相等,其声威所至,或且超过汉朝,但此乃世运进步使然,以经营域外的实力论,唐朝实非汉朝之比。汉武帝时,攻击匈奴,前后凡数十次;以至征伐大宛,救护乌孙,都是仗自己的实力去摧破强敌。唐朝的征服突厥、薛延陀等,则多因利乘便,且对外多用番兵。玄宗时,府兵制度业已废坏,而吐蕃、突厥都强,契丹势亦渐盛。欲图控制、守御,都不得不加重边兵,所谓藩镇,遂兴起于此时,天下势成偏重。二、胡字本是匈奴的专称,后渐移于一切北族。再后,又因文化的异同易泯,种族的外观难改,遂移为西域白种人的专称(详见拙著《胡考》,在《燕石札记》中,商务印书馆本)。西域人的文明程度,远较北族为高。他们和中国,没有直接的政治关系,所以不受注意。然虽无直接的政治关系,间接的政治关系却是有的,而且其作用颇大。从来北族的盛衰,往往和西胡有关涉。冉闵大诛胡、羯时,史称高鼻多须,颇有滥死,可见此时之胡,已非尽匈奴人。拓跋魏占据北方后,有一个盖吴,起而与之相抗,一时声势很盛,盖吴实在是个胡人(事在公元446年,即宋文帝元嘉二十三年,魏太武帝太平真君七年。见《魏书·本纪》和《宋书·索虏传》)。唐玄宗时,北边有康待宾、康愿子相继造反,牵动颇广(事在公元721、722年,即玄宗开元九年、十年),康亦是西域姓。突厥颉利的衰亡,史称其信任诸胡,疏远宗族,后来回纥的灭亡亦然,可见他们的沉溺于物质的享受,以致渐失其武健之风,还不尽由于中国的渐染。从反面看,就知道他们的进于盛强,如物质文明的进步,政治、军事组织的改良等,亦必有受教于西胡的了。唐朝对待被征服的异族,亦和汉朝不同。汉朝多使之入居塞内,唐朝则仍留之于塞外,而设立都护府或都督府去管理它。所以唐朝所征服的异族虽多,未曾引起像五胡乱华一般的杂居内地的异族之患。然环伺塞外的异族既多,当其种类昌炽,而中国政治力量减退时,就不免有被其侵入的危险了。唐末的沙陀,五代时的契丹,其侵入中国,实在都是这一种性质,而安史之乱,就是一个先期的警告。安禄山,《唐书》说他是营州柳城胡。他本姓康,随母嫁虏将安延偃,因冒姓安。安、康都是西域姓。史思明,《唐书》虽说他是突厥种,然其状貌,“鸢肩伛背,目侧鼻”,怕亦是一个混血儿。安禄山和史思明,都能通六番译,为互市郎,可见其兼具西胡和北族两种性质。任用番将,本是唐朝的习惯,安禄山遂以一身而兼做了范阳、平卢两镇的节度使(平卢军,治营州。范阳军,治幽州,今北平)。此时安禄山的主要任务,为镇压奚、契丹,他就收用其壮士,名之曰曳落河。其军队在当时藩镇之中,大约最为剽悍。目睹玄宗晚年政治腐败,内地守备空虚,遂起觊觎之念。并又求为河东节度使。755年,自范阳举兵反。不一月而河北失陷,河南继之,潼关亦不守,玄宗逃向成都。于路留太子讨贼,太子西北走向灵武(灵州,治今宁夏灵武县),即位,是为肃宗。安禄山虽有强兵,却无政治方略,诸将亦都有勇无谋,既得长安之后,不能再行进取。朔方节度使郭子仪(朔方军,治灵州),乃得先平河东,就借回纥的兵力,收复两京(长安、洛阳)。安禄山为其子庆绪所杀。九节度之师围庆绪于邺。因号令不一,久而无功。史思明既降复叛,自范阳来救,九节度之师大溃。思明杀庆绪,复陷东京。李光弼与之相持。思明又为其子朝义所杀。唐朝乃得再借回纥之力,将其打平。此事在762年。其时肃宗已死,是代宗的元年了。安史之乱首尾不过八年,然对外的威力自此大衰,内治亦陷于紊乱,唐朝就日入于衰运了。\n第二十二章 唐朝的衰亡和沙陀的侵入 # 自从公元755年安史之乱起,直到公元907年朱全忠篡位为止,唐朝一共还有了一百五十二年的天下。在这一个时期中,表面上还维持着统一,对外的威风亦未至于全然失坠,然而自大体言之,则终于日入于衰乱而不能够复振了。\n因安史之乱而直接引起的,是藩镇的跋扈。唐朝此时,兵力不足,平定安史,颇藉回纥的助力。铁勒仆骨部人仆固怀恩,于引用回纥颇有功劳,亦有相当的战功。军事是要威克厥爱的,一个战将,没有人能够使之畏服,便不免要流于骄横,何况他还是一个番将呢?他要养寇自重,于是昭义、成德、天雄、卢龙诸镇(昭义军,治相州,今河南安阳县。成德军,治恒州,今河北正定县。天雄军,治魏州,今河北大名县。卢龙军,即范阳军),均为安、史遗孽所据,名义上虽投降朝廷,实则不奉朝廷的命令。唐朝自己所设的节度使,也有想学他们的样子,而且有和他们互相结托的。次之则为外患的复兴。自玄宗再灭突厥后,回纥占据其地。因有助平安、史之功,骄横不堪。而吐蕃亦乘中国守备空虚,尽陷河西、陇右,患遂中于京畿。又云南的南诏(诏为蛮语王之称,当时,今云南、西康境有六诏:曰蒙巂诏,在今西康西昌县。曰越析诏,亦称磨些诏,在今云南丽江县。曰浪穹诏,在今云南洱源县。曰邆睒诏,在今云南邓川县。曰施浪诏,在洱源县之东。曰蒙舍诏,在今云南蒙化县。地居最南,亦称南诏。余五诏皆为所并),天宝时,杨国忠与之构兵,南诏遂投降吐蕃,共为边患,患又中于西川。\n公元779年,代宗崩,子德宗立,颇思振作。此时昭义已为天雄所并,卢龙亦因易帅恭顺朝廷,德宗遂因成德的不肯受代,发兵攻讨。成德和天雄、平卢连兵拒命。山南东道(治襄州,今湖北襄阳县)亦叛与相应,德宗命淮西军讨平之(淮西军,治蔡州,今河南汝南县)。攻三镇未克,而淮西、卢龙复叛,再发泾原兵东讨(泾原军,治泾州,今甘肃泾川县),过京师,因赏赐菲薄作乱。德宗出奔奉天(唐县,今山西武功县)。乱军奉朱泚为主,大举进攻。幸得浑瑊力战,河中李怀光入援(河中军,治蒲州,今山西永济县),奉天才未被攻破。而李怀光因和宰相卢杞不合,又反。德宗再逃到梁州(今陕西南郑县),听了陆贽的话,赦诸镇的罪,专讨朱泚,才得将京城收复。旋又打平了河中。然其余的事,就只好置诸不问了。德宗因屡遭叛变,不敢相信臣下。回京之后,使宦官带领神策军。这时候,神策军饷糈优厚,诸将多自愿隶属,兵数骤增至十五万,宦官就从此握权。805年,德宗崩,子顺宗立。顺宗在东宫时,即深知宦官之弊。即位后,用东宫旧臣王叔文等,想要除去宦官。然顺宗在位仅八个月,即传位于子宪宗,王叔文等都遭斥逐,其系为宦官所逼,不言而喻了。宪宗任用裴度,削平了淮西,河北三镇亦惧而听命,实为中央挽回威信的一个良机。然宪宗死后,穆宗即位,宰相以为河北已无问题,对善后事宜,失于措置,河北三镇,遂至复叛,终唐之世,不能削平了。穆宗崩,敬宗立,为宦官刘克明所弑。宦官王守澄讨贼而立文宗。文宗初用宋申锡为宰相,与之谋诛宦官,不克。后又不次擢用李训、郑注,把王守澄毒死。郑注出镇凤翔(凤翔军,治凤翔府,今陕西凤翔县),想选精兵进京送王守澄葬,因此把宦官尽数杀掉。不知何故,李训在京城里,又诈称某处有甘露降,想派宦官往看,因而杀掉他们。事机不密,反为宦官所杀。郑注在凤翔,亦被监军杀掉。文宗自此受制于宦官,几同傀儡。相传这时候,有一个翰林学士,唤做崔慎由,曾缘夜被召入宫,有一班宦官,以仇士良为首,诈传皇太后的意旨,要他拟废掉文宗的诏书。崔慎由誓死不肯,宦官默然良久,乃开了后门,把崔慎由引到一个小殿里。文宗正在殿上,宦官就当面数说他,文宗低头不敢开口。宦官道:“不是为了学士,你就不能再坐这宝位了。”于是放崔慎由出宫,叮嘱他不许泄漏,泄漏了是要祸及宗族的。崔慎由虽然不敢泄漏,却把这件事情密记下来,临死时交给他的儿子。他的儿子便是唐末的宰相崔胤。文宗死后,弟武宗靠着仇士良之力,杀太子而自立。武宗能任用李德裕,政治尚称清明。宣宗立,尤能勤于政事,人称之为小太宗。然于宦官,亦都无可如何。宣宗死后,子懿宗立。886年,徐、泗卒戍桂州者作乱(徐州,今江苏铜山县。泗州,今安徽泗县。桂州,今广西桂林县),用沙陀兵讨平之,沙陀入据中原之祸,遂于是乎开始。\n唐朝中叶后的外患,最严重的是回纥、吐蕃,次之则南诏。南诏的归服吐蕃,本出于不得已,吐蕃待之亦甚酷。9世纪初,韦皋为西川节度使,乃与之言和,共击吐蕃,西南的边患,才算解除(西川军,治成都,今四川成都县。后来南诏仍有犯西川之事,并曾侵犯安南,但其性质,不如和吐蕃结合时严重)。840年,回纥为黠戛斯所破,遽尔崩溃。吐蕃旋亦内乱。849年,中国遂克复河、湟,河西之地亦来归。三垂的外患,都算靠天幸解除了。然自身的纲纪不振,沙陀突厥遂至能以一个残破的部落而横行中国。\n沙陀是西突厥的别部,名为处月(朱邪,即处月之异译)。西突厥亡后,依北庭都护府以居(今新疆迪化县)。其地有大碛名沙陀,故称为沙陀突厥。河西、陇右既陷,安西、北庭(安西都护府,治龟兹),朝贡路绝,假道回纥,才得通到长安。回纥因此需索无厌。沙陀苦之,密引吐蕃陷北庭。久之,吐蕃又疑其暗通回纥,想把它迁到河外。沙陀乃又投奔中国。吐蕃追之,且战且走。三万部落之众,只剩得两千到灵州。节度使范希朝以闻,诏处其众于盐州(今宁夏盐池县北)。后来范希朝移镇河东(治太原府,今山西太原县),沙陀又随往,居于现在山阴县北的黄瓜堆。希朝简其精锐的为沙陀军。沙陀虽号称突厥,其形状,据史籍所载,亦是属于白种人的。既定徐、泗之乱,其酋长朱邪赤心,赐姓名为李国昌,镇守大同(治云州,令山西大同县),就有了一个地盘了。873年,懿宗崩,子僖宗立。年幼,信任宦官田令孜。时山东连年荒歉。875年,王仙芝起兵作乱,黄巢聚众应之。后来仙芝被杀,而黄巢到处流窜。从现在的河南打到湖北,沿江东下,经浙东入福建,到广东。再从湖南、江西、安徽打回河南,攻破潼关。田令孜挟僖宗走西川。黄巢遂入长安,时为880年。当黄巢横行时,藩镇都坐视不肯出兵剿讨。京城失陷之后,各路的援兵又不肯进攻。不得已,就只好再借重沙陀。先是李国昌移镇振武(治单于都护府。今绥远和林格尔县),其子李克用叛据大同,为幽州兵所败,父子都逃入鞑靼(居阴山)。这时候,国昌已死,朝廷乃赦李克用的罪,召他回来。打败黄巢,收复长安。李克用镇守河东,沙陀的根据地更深入腹地了。\n黄巢既败,东走攻蔡州。蔡州节度使秦宗权降之。后来黄巢被李克用追击,为其下所杀,而宗权转横,其残虐较黄巢为更甚。河南、山东被其剽掠之处,几于无复人烟。朝廷之上,宦官依然专横。关内一道,亦均为军人所盘踞。其中华州的韩建,邠州的王行瑜(镇国军,治华州,今陕西华县。邠宁军,治邠州,今陕西邠县),凤翔的李茂贞,尤为跋扈,动辄违抗命令,胁迫朝廷,遂更授沙陀以干涉的机会。\n在此情势之下,汉民族有一个英雄,能够和沙陀抵抗的,那便是朱全忠。全忠本名温,是黄巢的将,巢败后降唐,为宣武节度使(治汴州,今河南开封县)。初年兵力甚弱,而全忠智勇足备,先扑灭了秦宗权,渐并今河南,山东之地,又南取徐州。北服河北三镇。西并河中,取义武(义武军,治定州,今河北定县),又取泽、潞(泽州,今山西晋城县。潞州,今山西长子县)及邢、洺、磁诸州(邢州,今河北邢台县。洺州,今河北永年县。磁州,今河北磁县)。河东的形势,就处于其包围之中了。僖宗死于888年,弟昭宗立,颇为英武。然其时的事势,业已不能有为。此时朝廷为关内诸镇所逼,大都靠河东解围。然李克用是个无谋略的人,想不到挟天子以令诸侯。虽然击杀了一个王行瑜,关内的问题,还是不能解决。朱全忠其初是不问中央的事务,一味扩充自己的实力的。到10世纪初年,全忠的势力已经远超出乎李克用之上了。唐朝的宰相崔胤,乃结合了他,以谋宦官。宦官见事急,挟昭宗走凤翔。全忠围凤翔经年,李茂贞不能抗,只得把皇帝送出,同朱全忠讲和。昭宗回到京城,就把宦官悉行诛灭。唐朝中叶后的痼疾,不是藩镇,实在是宦官。因为唐朝的藩镇,并没有敢公然背叛,或者互相攻击,不过据土自专,更代之际,不听命令而已。而且始终如此的,还不过河北三镇。倘使朝廷能够振作,实在未尝不可削平。而唐朝中叶后的君主,如顺宗、文宗、武宗、宣宗、昭宗等,又都未尝不可与有为。其始终不能有为,则全是因被宦官把持之故。事势至此,已非用兵力铲除,不能有别的路走了。一个阶级,当其恶贯满盈,走向灭亡之路时,在它自己,亦是无法拔出泥淖的。\n宦官既亡,唐朝亦与之同尽。公元903年,朱全忠迁帝于洛阳,弑之而立其子昭宣帝。至907年,遂废之而自立,是为梁太祖。此时海内割据的:淮南有杨行密,是为吴。两浙有钱镠,是为吴越。湖南有马殷,是为楚。福建有王审知,是为闽。岭南有刘岩,是为南汉。剑南有王建,是为前蜀。遂入于五代十国之世。\n第二十三章 五代十国的兴亡和契丹的侵入 # 凡内争,是无有不引起外患的,沙陀的侵入,就是一个例。但沙陀是整个部族侵入中国的,正和五胡一样。过了几代之后,和汉族同化了,它的命运也就完了。若在中国境外,立有一国,以国家的资格侵入,侵入之后,其本国依然存在的,则其情形自又不同。自公元840年顷回纥崩溃后,漠南北遂无强部,约历七十年而契丹兴。契丹,大约是宇文氏的遗落。其居中国塞外,实已甚久。但当6世纪初,曾遭到北齐的一次袭击,休养生息,到隋时元气才渐复。7世纪末,又因李尽忠的反叛而大遭破坏。其后又和安禄山相斗争,虽然契丹也曾打过一二次胜仗,然其不得安息,总是实在的。唐朝管理东北方最重要的机关,是营州都督府,中叶后业已不能维持其威力,但契丹仍时时受到幽州的干涉,所以它要到唐末才能够兴起。契丹之众,是分为八部的。每部有一个大人。八个大人之中,公推一人司旗鼓。到年久了,或者国有疾疫而畜牧衰,则另推一个大人替代。它亦有一个共主,始而是大贺氏,后来是遥辇氏,似乎仅有一个虚名。它各部落间的连结,大概是很薄弱的,要遇到战斗的事情,才能互相结合,这或者也是它兴起较晚的一个原因。内乱是招引外族侵入中国的,又是驱逐本国人流移到外国去的。这种事情,在历史上已经不知有过若干次。大抵(一)外国的文明程度低而人数少,而我们移植的人数相当多时,可以把它们完全同化。(二)在人数上我们比较很少,而文明程度相去悬绝时,移殖的人民,就可在它们的部落中做蛮夷大长。(三)若它们亦有相当的程度,智识技术上,虽然要请教于我,政治和社会的组织,却决不容以客族侵入而握有权柄的,则我们移殖的人民,只能供它们之用,甚至造成了它们的强盛,而我们传授给它的智识技术,适成为其反噬之用。时间是进步的良友。一样的正史四裔传中的部族,名称未变,或者名称虽异而统系可寻,在后一代,总要比前一代进步些。所以在前代,中国人的移殖属于前两型的居多,到近世,就多属于后一种了,这是不可以不懔然的,而契丹就是一个适例。契丹太祖耶律阿保机,据《五代史》说,亦是八部大人之一。当公元十世纪之初,幽州刘守光暴虐,中国人逃出塞的很多。契丹太祖都把他招致了去,好好的抚慰他们,因而跟他们学得了许多知识,经济上和政治组织上,都有进步了。就以计诱杀八部大人,不再受代。公元916年,并废遥辇氏而自立。这时候,漠南北绝无强部,他遂得纵横如意。东北灭渤海,服室韦,西南服党项、吐谷浑,直至河西回纥。《辽史》中所列,他的属国,有四五十部之多。\n梁太祖的私德,是有些缺点的,所以从前的史家,对他的批评,多不大好。然而私德只是私德,社会的情形复杂了,论人的标准,自亦随之而复杂,政治和道德、伦理,岂能并为一谈?就篡弑,也是历代英雄的公罪,岂能偏责一人?老实说:当大局阽危之际,只要能保护国家、抗御外族、拯救人民的,就是有功的政治家。当一个政治家要尽他为国为民的责任,而前代的皇室成为其障碍物时,岂能守小信而忘大义?在唐、五代之际,梁太祖确是能定乱和恤民的,而历来论者,多视为罪大恶极,甚有反偏袒后唐的,那就未免不知民族的大义了。惜乎天不假年,梁太祖篡位后仅六年而遇弑。末帝定乱自立,柔懦无能,而李克用死后,其子存勖袭位,颇有英锐之气。梁、晋战争,梁多不利。河北三镇及义武,复入于晋。923年,两军相持于郓州(今山东东平县),晋人乘梁重兵都在河外,以奇兵径袭大梁,末帝自杀,梁亡。存勖是时已改国号为唐,于是定都洛阳,是为后唐庄宗。中原之地,遂为沙陀所占据。后唐庄宗,本来是个野蛮人,灭梁之后,自然志得意满。于是纵情声色,宠爱伶人,听信宦官,政治大乱。925年,使宰相郭崇韬傅其子魏王继岌伐前蜀,把前蜀灭掉。而刘皇后听了宦官的话,疑心郭崇韬要不利于魏王,自己下命令给魏王,叫他把郭崇韬杀掉。于是人心惶骇,谣言四起。天雄军据邺都作乱。庄宗派李克用的养子李嗣源去征伐。李嗣源的军队也反了,胁迫李嗣源进了邺城。嗣源用计,得以脱身而出。旋又听了女婿石敬瑭的话,举兵造反。庄宗为伶人所弑。嗣源立,是为明宗。明宗年事较长,经验亦较多,所以较为安静。933年,明宗死,养子从厚立,是为闵帝。时石敬瑭镇河东,明宗养子从珂镇凤翔,闵帝要把他们调动,从珂举兵反。闵帝派出去的兵,都倒戈投降。闵帝出奔被杀。从珂立,是为废帝。又要调动石敬瑭,敬瑭又反。废帝鉴于闵帝的失败,是预备了一个不倒戈的张敬达,然后发动的,就把石敬瑭围困起来。敬瑭乃派人到契丹去求救,许割燕、云十六州之地(幽州、云州已见前。蓟州,今河北蓟县。瀛洲,今河北河间县。莫州,今河北肃宁县。涿州,今河北涿县。檀州,今河北密云县。顺州,今河北顺义县。新州,今察哈尔涿鹿县。妫州,今察哈尔怀来县。儒州,今察哈尔延庆县。武州,今察哈尔宣化县。应州,今山西应县。寰州,今山西马邑县。朔州,今山西朔县。蔚州,今察哈尔蔚县)。他手下的刘知远劝他:只要赂以金帛,就可如愿,不可许割土地,以遗后患。敬瑭不听。此时契丹太祖已死,次子太宗在位,举兵南下,反把张敬达围困起来,废帝不能救。契丹太宗和石敬瑭南下,废帝自焚死。敬瑭定都于大梁,是为晋高祖,称臣割地于契丹。942年,晋高祖死,兄子重贵立,是为出帝。听了侍卫景延广的话,对契丹不复称臣,交涉亦改强硬态度。此时契丹已改国号为辽。辽兵南下,战事亦互有胜负。但石晋国力疲敝,而勾通外敌,觊觎大位之例已开,即不能禁人的不效尤。于是晋将杜重威降辽,辽人入大梁,执出帝而去,时在946年。辽太宗是个粗人,不懂得政治的。既入大梁,便派人到各地方搜括财帛,又多派他的亲信到各地方去做刺史,汉奸附之以虐民。辽人的行军,本来是不带粮饷的,大军中另有一支军队,随处剽掠以自给,谓之打草谷军,入中国后还是如此。于是反抗者四起。辽太宗无如之何,只得弃汴梁而去,未出中国境而死。太宗本太祖次子,因皇后述律氏的偏爱而立。其兄突欲(汉名倍),定渤海后封于其地,谓之东丹王。东丹王奔后唐,辽太宗入中国时,为晋人所杀,述律后第三子李胡,较太宗更为粗暴,辽人怕述律后要立他,就军中拥戴了东丹王的儿子,是为世宗。李胡兴兵拒战,败绩。世宗在位仅四年,太宗之子穆宗继立,沉湎于酒,政治大乱,北边的风云,遂暂告宁静。此时侵入中国的,幸而是辽太宗,倘使是辽太祖,怕就没有这么容易退出去了。\n契丹虽然退出,中原的政权,却仍落沙陀人之手。刘知远入大梁称帝,是为后汉高祖。未几而死,子隐帝立。950年,为郭威所篡,是为后周太祖。中原的政权,始复归于汉人。后汉高祖之弟旻,自立于太原,称侄于辽,是为北汉,亦称东汉。后周太祖立四年而死,养子世宗立。北汉乘丧来伐,世宗大败之于高平(今山西高平县)。先是吴杨行密之后,为其臣李昪所篡,改国号为唐,是为南唐。并有江西之地,疆域颇广。而后唐庄宗死后,西川节度使孟知祥攻并东川而自立,是为后蜀。李昪之子璟,乘闽、楚之衰,将其吞并,意颇自负;孟知祥之子昶,则是一个昏愚狂妄之人,都想交结契丹,以图中原,世宗要想恢复燕、云,就不得不先膺惩这两国。唐代藩镇之弊,总括起来,是“地擅于将,将擅于兵”八个字。一地方的兵甲、财赋,固为节度使所专,中央不能过问。节度使更代之际,也至少无全权过问,或竟全不能过问。然节度使对于其境内之事,亦未必能全权措置,至少是要顾到其将校的意见,或遵循其军中的习惯的。尤其当更代之际,无论是亲子弟,或是资格相当的人,也必须要得到军中的拥戴,否则就有被杀或被逐的危险。节度使如失众心,亦会为其下所杀。又有野心的人,煽动军队,饵以重赏,推翻节度使而代之的。此等军队,真乃所谓骄兵。凡兵骄,则对外必不能作战,而内部则被其把持,一事不可为,甚且纲纪全无,变乱时作。唐中叶以后的藩镇,所以坐视寇盗的纵横而不能出击;明知强邻的见逼,也只得束手坐待其吞并;一遇强敌,其军队即土崩瓦解,其最大的原因,实在于此。这是非加以彻底的整顿,不足以有为的。周世宗本就深知其弊,到高平之战,军队又有兵刃未接,而望风解甲的,乃益知其情势的危险。于是将禁军大加裁汰,又令诸州募兵,将精强的送至京师,其军队乃焕然改观,而其政治的清明,亦足以与之相配合,于是国势骤张。先伐败后蜀,又伐南唐,尽取江北之地。959年,遂举兵伐辽,恢复了瀛、莫、易三州,直逼幽州。此时正直契丹中衰之际,倘使周世宗不死,燕、云十六州,是很有恢复的希望的,以后的历史,就全然改观了。惜乎世宗在途中遇疾,只得还军,未几就死了。嗣子幼弱,明年,遂为宋太祖所篡。\n宋太祖的才略,亦和周世宗不相上下,或者还要稳健些。他大约知道契丹是大敌,燕、云一时不易取,即使取到了,也非有很重的兵力不能守的,而这时候割据诸国,非弱即乱,取之颇易,所以要先平定了国内,然后厚集其力以对外。从梁亡后,其将高季兴据荆、归、峡三州自立(荆州,今湖北江陵县。归州,今湖北秭归县。峡州,今湖北西陵县),是为南平。而楚虽为唐所灭,朗州亦旋即独立(朗州,今湖南常德县)。962年宋太祖因朗州和衡州相攻击(衡州,今湖南衡山县),遣人来求救,遣兵假道南平前往,把南平和朗州都破掉(衡州先已为朗州所破)。956年,遣兵灭后蜀。971年,遣兵灭南汉。975年,遣兵灭南唐。是年,太祖崩,弟太宗立。976年,吴越纳土归降。明年,太宗遂大举灭北汉。于是中国复见统一。自907年朱梁篡唐至此,共计72年。若从880年僖宗奔蜀,唐朝的中央政权实际崩溃算起,则适得一百年。\n●韩熙载夜宴图局部\n第二十四章 唐宋时代中国文化的转变 # 两个民族的竞争,不单是政治上的事。虽然前代的竞争,不像现代要动员全国的人力和物力,然一国政治上的趋向,无形中总是受整个社会文化的指导的。所以某一民族,在某一时代中,适宜于竞争与否,就要看这一个民族,在这一个时代中文化的趋向。\n在历史上,最威胁中国的是北族。它们和中国人的接触,始于公元前4世纪秦、赵、燕诸国与北方的骑寇相遇,至6世纪之末五胡全被中国同化而告终结,历时约一千年。其第二批和中国的交涉,起于4世纪后半铁勒侵入漠南北,至10世纪前半沙陀失却在中国的政权为止,历时约六百年。从此以后,塞外开发的气运,暂向东北,辽、金、元、清相继而兴。其事起于10世纪初契丹的盛强,终于1911年中国的革命。将来的史家,亦许要把它算到现在的东北问题实际解决时为止,然为期亦必不远了。这一期总算起来,为时亦历千余年。这三大批北族,其逐渐移入中国,而为中国人所同化,前后相同。惟第一二期,是以被征服的形式移入的,至第三期,则系以征服的形式侵入。\n经过五胡和沙陀之乱,中国也可谓受到相当的创痛了。但是以中国之大,安能不把这个看做很大的问题?在当时中国人的眼光里,北族的侵入,还只是治化的缺陷,只要从根本上把中国整顿好了,所谓夷狄,自然不成问题。这时代先知先觉者的眼光,还是全副注重于内部,民族的利害冲突,虽不能说没有感觉,民族主义却未能因此而发皇。\n●颜真卿《多宝塔碑》局部\n●柳公权《玄秘塔碑》局部\n虽然如此,在唐、宋之间,中国的文化,也确是有一个转变的。这个转变是怎样呢?\n中国的文化,截至近世受西洋文化的影响以前,可以分做三个时期:第一期为先秦、两汉时代的诸子之学。第二期为魏、晋、南北朝、隋、唐时代的玄学和佛学。第三期为宋、元、明时代的理学。这三期,恰是一个正、反、合。\n怎样说这三期的文化,是一个辩证法的进化呢?原来先秦时代的学术,是注重于矫正社会的病态的,所谓“拨乱世,反之正”,实不仅儒家,而为各家通有的思想。参看第四十一、第五十三两章自明。王莽变法失败以后,大家认为此路不通,而此等议论,渐趋消沉。魏晋以后,文化乃渐转向,不向整体而向分子方面求解决。他们所讨论的,不是社会的组织如何,使人生于其间,能够获得乐利,可以做个好人,而是人性究竟如何?是好的?是坏的?用何法,把坏人改做好人,使许多好人聚集,而好的社会得以实现?这种动机,确和佛教相契。在这一千年中,传统的儒家,仅仅从事于笺疏,较有思想的人,都走入玄学和佛学一路,就是其明证。但其结果却是怎样呢?显然的,从个人方面着想,所能改良的,只有极小一部分,合全体而观之,依然无济于事。而其改善个人之法,推求到深刻之处,就不能不偏重于内心。工夫用在内心上的多,用在外务上的,自然少了。他们既把社会看做各个分子所构成,社会的好坏,原因在于个人的好坏,而个人的好坏,则源于其内心的好坏;如此,社会上一切问题,自然都不是根本。而他们的所谓好,则实和此世界上的生活不相容,所以他们最彻底的思想,是要消灭这一个世界。明知此路不通,则又一转变而认为现在的世界就是佛国;只要心上觉悟,一切行为虽和俗人一样,也就是圣人。这么一来,社会已经是好的了,根本用不着改良。这两种见解,都是和常识不相容的,都是和生活不相合的。凡是和生活不相合的,凭你说得如何天花乱坠,总只是他们所谓“戏论”,总要给大多数在常识中生活的人所反对的,而事情一到和大多数人的生活相矛盾,就是它的致命伤。物极必反,到唐朝佛学极盛时,此项矛盾,业经开始发展了,于是有韩愈的辟佛。他的议论很粗浅,不过在常识范围中批评佛说而已,到宋儒,才在哲学上取得一个立足点。这话在第五十三章中,亦经说过。宋学从第十一世纪的中叶起,到第十七世纪的中叶止,支配中国的思想界,约六百年。他们仍把社会看做是各分子所构成的,仍以改良个人为改良社会之本;要改良个人,还是注重在内心上,这些和佛学并无疑异。所不同的,则佛家认世界的现状,根本是坏的,若其所谓好的世界而获实现,则现社会的组织,必彻底被破坏,宋学则认现社会的组织,根本是合理的,只因为人不能在此组织中,各处于其所当处的地位,各尽其所应尽的责任,以致不好。而其所认为合理的组织,则是一套封建社会和农业社会中的道德、伦理和政治制度。在商业兴起,广大的分工合作,日日在扩充,每一个地方自给自足的规模,业已破坏净尽,含有自给自足性质的大家族,亦不复存在之时,早已不复适宜了。宋儒还要根据这一个时代的道德、伦理和政治制度,略加修改,制成一种方案,而强人以实行,岂非削足适履?岂非等人性于杞柳,而欲以为杯?所以宋儒治心的方法,是有很大的价值的,而其治世的方法,则根本不可用。不过在当时,中国的思想界,只能在先秦诸子和玄学、佛学两种思想中抉择去取、融化改造,是只能有这个结果的,而文化进化的趋向,亦就不得不受其指导。在君主专制政体下,政治上的纲纪所恃以维持的,就是所谓君臣之义。这种纲纪,是要秩序安定,人心也随着安定,才能够维持的。到兵荒马乱,人人习惯于裂冠毁裳之日,就不免要动摇了。南北朝之世,因其君不足以为君,而有“殉国之感无因,保家之念宜切”的贵族,第十五章中,业经说过。到晚唐、五代之世,此种风气,又盛行了。于是既有历事五朝,而自称长乐老以鸣其得意的冯道,又有许多想借重异族,以自便私图的杜重威。由今之道,无变今之俗,如何可以一朝居?所以宋儒要竭力提倡气节。经宋儒提倡之后,士大夫的气节,确实是远胜于前代。但宋儒(一)因其修养的工夫,偏于内心,而处事多疏。(二)其持躬过于严整,而即欲以是律人,因此,其取人过于严格,而有才能之士,皆为其所排斥。(三)又其持论过高,往往不切于实际。(四)意气过甚,则易陷于党争。党争最易使人动于感情,失却理性,就使宅心公正,也不免有流弊,何况党争既启,哪有个个人都宅心公正之理?自然有一班好名好利、多方掩饰的伪君子,不恤决裂的真小人混进去。到争端扩大而无可收拾,是非淆乱而无从辨别时,就真有宅心公正、顾全大局的人,也苦于无从措手了。所以宋儒根本是不适宜于做政治事业的。若说在社会上做些自治事业,宋儒似乎很为相宜。宋儒有一个优点,他们是知道社会上要百废俱举,尽其相生相养之道,才能够养生送死无憾,使人人各得其所的。他们否认“治天下不如安天下,安天下不如与天下安”的苟简心理,这一点,的确是他们的长处。但他们所以能如此,乃是读了经书而然。而经书所述的,乃是古代自给自足,有互助而无矛盾的社会所留遗,到封建势力逐渐发展时,此等组织,就逐渐破坏了。宋儒不知其所主张的道德、伦理、政治制度,正和这一种规制相反,却要藉其所主张的道德、伦理和政治制度之力,以达到这一个目的。其极端的,遂至要恢复井田封建。平易一些的,亦视智愚贤不肖为自然不可泯的阶级。一切繁密的社会制度,还是要以士大夫去指导着实行,而其所谓组织,亦仍脱不了阶级的对立。所以其结果,还是打不倒土豪劣绅,而宋学家,特如其中关学一派,所草拟的极详密的计划,以极大的热心去推行,终于实现的寥若晨星,而且还是昙花一现。这时候,外有强敌的压迫,最主要的事务,就是富国强兵,而宋儒却不能以全力贯注于此。最需要的,是严肃的官僚政治,而宋学家好作诛心之论,而忽略形迹;又因党争而淆乱是非,则适与之相反。宋学是不适宜于竞争的,而从第十一世纪以来,中国的文化,却受其指导,那无怪其要迭招外侮了。\n●唐三藏西行求法图\n第二十五章 北宋的积弱 # 五代末年,偏方割据诸国,多微弱不振。契丹则是新兴之国,气完力厚的,颇不容易对付,所以宋太祖要厚集其力以对付它。契丹的立国,是合部族、州县、属国三部分而成的。属国仅有事时量借兵粮,州县亦仅有益于财赋(辽朝的汉兵,名为五京乡丁,只守卫地方,不出戍),只有部族,是契丹立国的根本,这才可以真正算是契丹的国民。它们都在指定的地方,从事于畜牧。举族皆兵,一闻令下,立刻聚集,而且一切战具,都系自备。马既多,而其行军又不带粮饷,到处剽掠自资(此即所谓“打草谷”),所以其兵多而行动极速。周世宗时,正是契丹中衰之会,此时却又兴盛了(辽惟穆宗最昏乱。969年,被弑,景宗立,即复安。983年,景宗死,圣宗立。年幼,太后萧氏同听政。圣宗至1030年乃死,子兴宗立,1054年死。圣宗时为辽全盛之世。兴宗时尚可蒙业而安,兴宗死,子道宗立,乃衰)。宋朝若要以力服契丹,非有几十万大兵,能够连年出征,攻下了城能够守,对于契丹地方,还要能加以破坏扰乱不可。这不是容易的事,所以宋太祖不肯轻举。而太宗失之轻敌,灭北汉后,不顾兵力的疲敝,立刻进攻。于是有高梁河之败(在北平西)。至公元985年,太宗又命将分道北伐,亦不利。而契丹反频岁南侵。自燕、云割弃后,山西方面,还有雁门关可守,河北方面,徒恃塘泺以限戎马,是可以御小敌,而不足以御大军的。契丹大举深入,便可直达汴梁对岸的大名,宋朝受威胁殊甚。1004年,辽圣宗奉其母入寇,至澶州(今河北濮阳县)。真宗听了宰相寇準的话,御驾亲征,才算把契丹吓退。然毕竟以岁币成和(银十万两,绢二十万匹)。宋朝开国未几,国势业已陷于不振了。\n假使言和之后,宋朝能够秣马厉兵,以伺其隙,契丹是个浅演之国,它的强盛必不能持久,亦未必无隙可乘。宋朝却怕契丹启衅,伪造天书,要想愚弄敌人(宋朝伪造天书之真意在此,见《宋史·真宗本纪论》)。敌人未必被愚弄,工于献媚和趁风打劫、经手侵渔的官僚,却因此活跃了。斋醮、宫观,因此大兴,财政反陷于竭蹶。而西夏之乱又起。唐朝的政策,虽和汉朝不同,不肯招致异族,入居塞内,然被征服的民族多了,乘机侵入,总是不免的。尤其西北一带,自一度沦陷后,尤为控制之力所不及。党项酋长拓跋氏(拓跋是鲜卑的民族,党项却系羌族,大约是鲜卑人入于羌部族而为其酋长的),于唐太宗时归化。其后裔拓跋思敬,以平黄巢有功,赐姓李氏。做了定难节度使,据有夏、银、绥、宥、静五州(夏州,今陕西怀远县。银州,今陕西米脂县。绥州,今陕西绥德县。宥州,今鄂尔多斯右翼后旗。静州,在米脂县西),传八世至继捧,于宋太宗的时候来降,而其弟继迁叛去。袭据银州和灵州,降于辽,宋朝未能平定。继迁传子德明,三十年未曾窥边,却征服了河西,拓地愈广。1022年,真宗崩,仁宗立。1034年,德明之子元昊反,兵锋颇锐。宋朝屯大兵数十万于陕西,还不能戢其侵寇。到1044年,才以岁赐成和(银、绢、茶、彩,共二十五万五千)。此时辽圣宗已死,兴宗在位,年少气盛,先两年,遣使来求关南之地(瓦桥关,在雄县。周世宗复瀛、莫后,与辽以此为界),宋朝亦增加了岁币(增银十万两,绢十万匹),然后和议得以维持。给付岁币的名义,《宋史》说是纳字,《辽史》却说是贡字,未知谁真谁假。然即使用纳字,亦已经不甚光荣了。仁宗在位岁久,政颇宽仁,然亦极因循腐败。兵多而不能战,财用竭蹶而不易支持,已成不能振作之势。1063年,仁崇崩,英宗立,在位仅四年。神宗继之,乃有用王安石变法之事。\n王安石的变法,旧史痛加诋毁,近来的史家,又有曲为辩护的,其实都未免有偏。王安石所行的政事,都是不错的。但行政有一要义,即所行之事,必须要达到目的,因此所引起的弊窦,必须减至极少。若弊窦在所不免,而目的仍不能达,就不免徒滋纷扰了。安石所行的政事,不能说他全无功效,然因此而引起的弊端极大,则亦不容为讳。他所行的政事,免役最是利余于弊的,青苗就未必能然。方田均税,在他手里推行得有限,后人踵而行之,则全是徒有其名。学校、贡举则并未能收作育人才之效。参看四十一、四十三、四十四三章自明。宋朝当日,相须最急的,是富国强兵。王安石改革的规模颇大,旧日史家的议论,则说他是专注意于富强的(尤其说王安石偏于理财。此因关于改革社会的行政,不为从前的政治家所了解之故)。他改革的规模,固不止此,于此确亦有相当的注意。其结果:裁汰冗兵,确是收到很大的效果的,所置的将兵,则未必精强,保甲尤有名无实,而且所引起的骚扰极大,参看第四十五章自明。安石为相仅七年,然终神宗之世,守其法未变。1085年,神宗崩,子哲宗立。神宗之母高氏临朝,起用旧臣,尽废新法。其死后,哲宗亲政,复行新法,谓之“绍述”。1100年,哲宗崩,徽宗立,太后向氏权同听政,想调和新旧之见,特改元为建中靖国。徽宗亲政后,仍倾向于新法。而其所用的蔡京,则是反复于新旧两党间的巧宦。徽宗性极奢侈,蔡京则搜括了各方面的钱,去供给他浪用,政治情形一落千丈。恢复燕、云和西北,可说是神宗和王安石一个很大的抱负,但因事势的不容许,只得先从事于其易。王安石为相时,曾用王韶征服自唐中叶以后杂居于今甘、青境内的蕃族,开其地为熙河路。这可说是进取西夏的一个预备。然神宗用兵于西夏却不利。哲宗时,继续筑寨,进占其地。夏人力不能支,请辽人居间讲和。宋因对辽有所顾忌,只得许之。徽宗时,宦者童贯,继续用兵西北,则徒招劳费而已。总之:宋朝此时的情势,业已岌岌难支,幸辽、夏亦已就衰,暂得无事,而塞外有一个新兴民族崛起,就要大祸临头了。\n金朝的先世,便是古代的所谓肃慎,南北朝、隋、唐时的靺鞨。宋以后则称为女真(女真二字,似即肃慎的异译。清人自称为满洲,据明人的书,实作满住,乃大酋之称,非部族之名。愚按靺鞨酋长之称为大莫弗瞒咄,瞒咄似即满住,而靺鞨二字,似亦仍系瞒咄的异译。至汉时又称为挹娄,据旧说:系今叶鲁二字的转音。而现在的索伦二字,又系女真的异译,此推测而确,则女真民族之名,自古迄今,实未曾变)。其主要的部落,在今松花江流域。在江南的系辽籍,称为熟女真,江北的不系籍,谓之生女真。女真的文明程度,是很低的,到渤海时代,才一度开化。金朝的始祖,名唤函普,是从高句丽旧地,入居生女真的完颜部,而为其酋长的。部众受其教导,渐次开化。其子孙又以渐征服诸部族,势力渐强。而辽自兴宗后,子道宗立,政治渐乱。道宗死,子天祚帝立,荒于游畋,竟把国事全然置诸不顾。女真本厌辽人的羁轭,天祚帝遣使到女真部族中去求名鹰,骚扰尤甚,遂致激起女真的叛变。金太祖完颜阿骨打,于1114年,起兵与辽相抗。契丹控制女真的要地黄龙府、咸州、宁江州(黄龙府,今吉林农安县。咸州,令辽宁铁岭县。宁江州,在吉林省城北),次第失陷。天祚帝自将大兵东征,因有内乱西归。旋和金人讲和,又迁延不定。东京先陷,上京及中、西两京继之(上京临潢府,在今热河开鲁县南。中京大定府,在今热河建昌县。东京辽阳府,今辽宁辽阳县。南京析津府,即幽州。西京大同府,即云州)。南京别立一君,意图自保,而宋人约金攻辽之事又起。先是童贯当权,闻金人攻辽屡胜,意图侥幸。遣使于金,求其破辽之后,将石晋所割之地,还给中国。金人约以彼此夹攻,得即有之。而童贯进兵屡败,乃又求助于金。金太祖自居庸关入,把南京攻下。太祖旋死,弟太宗立。天祚帝展转漠南,至1125年为金人所获,辽亡。\n●宋徽宗《听琴图》\n宋朝本约金夹攻的,此时南京之下,仍藉金人之力,自无坐享其成之理,乃输燕京代税钱一百万缗,并许给岁币,金人遂以石晋所割之地来归。女真本系小部族,此时吞并全辽,已觉消化不下,焉有余力经营中国的土地?这是其肯将石晋所割之地还给中国的理由。但女真此时,虽不以地狭为忧,却不免以土满为患。文明国民,生产能力高强的,自然尤为其所欢迎。于是军行所至,颇以掳掠人口为务。而汉奸亦已有献媚异族,进不可割地之议的。于是燕京的归还,仅系一个空城,尽掳其人民以去。而营、平、滦三州(平州,今河北卢龙县。滦州,今河北滦县),本非石晋所割让,宋朝向金要求时,又漏未提及,则不肯归还,且将平州建为南京,命辽降将张觉守之。燕京被掳的人民,流离道路,不胜其苦,过平州时,求张觉做主。张觉就据地来降。这是一件很重大的交涉。宋朝当时,应该抚恤其人民,而对于金朝,则另提出某种条件,以足其欲而平其愤。金朝此时,虽已有汉奸相辅,究未脱野蛮之习,且值草创之际,其交涉是并不十分难办的。如其处置得宜,不但无启衅之忧,营、平、滦三州,也未尝不可乘机收复。而宋朝贸然受之,一无措置。到金人来诘责,则又手忙脚乱,把张觉杀掉,函首以畀之。无益于金朝的责言,而反使降将解体,其手段真可谓拙劣极了。\n辽朝灭亡之年,金朝便举兵南下。宗翰自云州至太原,为张孝纯所阻,而宗望自平州直抵汴京。时徽宗已传位于钦宗。初任李纲守御,然救兵来的都不能解围。不得已,许割太原、中山、河间三镇(中山,今河北定县。河间,今河北河间县);宋主称金主为伯父;并输金五百万两,银五千万两,牛、马万头,表缎百万匹讲和。宗望的兵才退去。金朝此时,是不知什么国际的礼法的,宗翰听闻宗望得了赂,也使人来求赂。宋人不许。宗翰怒,攻破威胜军和隆德府(威胜军,今山西沁县。隆德府,今山西长治县)。宋人认为背盟,下诏三镇坚守。契丹遗臣萧仲恭来使,又给以蜡书,使招降契丹降将耶律余睹。于是宗翰、宗望再分道南下,两路都抵汴京。徽、钦二宗,遂于1127年北狩。金朝这时候,是断没有力量,再占据中国的土地的,所希望的,只是有一个傀儡,供其驱使而已。乃立宋臣张邦昌为楚帝,退兵而去。张邦昌自然是要靠金朝的兵力保护,然后能安其位的。金兵既去,只得自行退位。而宋朝是时,太子、后妃、宗室多已被掳,只得请哲宗的废后孟氏出来垂帘。“虽举族有北辕之衅,而敷天同左袒之心”(孟后立高宗诏语),这时候的民族主义,自然还要联系在忠君思想上,于是孟后下诏,命高宗在归德正位(今河南商丘县)。\n第二十六章 南宋恢复的无成 # 语云:“败军之气,累世而不复”,这话亦不尽然。“困兽犹斗”,反败为胜的事情,决不是没有的,只看奋斗的精神如何罢了。宋朝当南渡时,并没有什么完整的军队,而且群盗如毛,境内的治安,且岌岌不可保,似乎一时间决谈不到恢复之计。然以中国的广大,金朝人能有多大的兵力去占据?为宋朝计,是时理宜退守一个可守的据点,练兵筹饷,抚恤人民。被敌兵蹂躏之区,则奖励、指导其人民,使之团结自守,而用相当的正式军队,为之声援。如此相持,历时稍久,金人的气焰必渐折,恢复之谋,就可从此开展了。苦于当时并没有这种眼光远大的战略家。而且当此情势,做首领的,必须是一个文武兼资之才,既有作战的策略,又能统驭诸将,使其不敢骄横,遇敌不敢退缩,对内不敢干政,才能够悉力对外。而这时候,又没有这样一个长于统率的人物。金兵既退,宗泽招降群盗,以守汴京。高宗既不能听他的话还跸,又不能驻守关中或南阳,而南走扬州。公元1129年,金宗翰、宗望会师濮州(在今山东濮县),分遣娄室入陕西。其正兵南下,前锋直打到扬州。高宗奔杭州(今浙江杭县)。明年,金宗弼渡江,自独松关入(在今安徽广德县东),高宗奔明州(今浙江鄞县)。金兵再进迫,高宗逃入海。金兵亦入海追之,不及乃还。自此以后,金人亦以“士马疲敝,粮储未丰”(宗弼语),不能再行进取了。其西北一路,则宋朝任张浚为宣抚使,以拒娄室,而宗弼自江南还,亦往助娄室。浚战败于富平(今陕西兴平县),陕西遂陷。但浚能任赵开以理财,用刘子羽、吴玠、吴璘等为将,卒能保守全蜀。\n●北宋·李成、王晓《读碑窠石图》设色绢本立轴,126.3×104.9厘米,日本大阪市美术馆收藏\n利用傀儡,以图缓冲,使自己得少休息,这种希冀,金人在此时,还没有变。其时宗泽已死,汴京失陷,金人乃立宋降臣刘豫于汴,畀以河南、陕西之地。刘豫却想靠着异族的力量反噬,几次发兵入寇,却又都败北。在金人中,宗弼是公忠体国的,挞懒却骄恣腐败(金朝并无一定之继承法,故宗室中多有觊觎之心。其时握兵权者,宗望、宗弼皆太祖子,宗翰为太祖从子,挞懒则太祖从弟。宗翰即有不臣之心。挞懒最老寿,在熙宗时为尊属,故其觊觎尤甚。熙宗、海陵庶人、世宗,皆太祖孙)。秦桧是当金人立张邦昌时,率领朝官,力争立赵氏之后,被金人捉去的。后来以赐挞懒。秦桧从海路逃归。秦桧的意思,是偏重于对内的。因为当时,宋朝的将帅,颇为骄横。“廪稍惟其所赋,功勋惟其所奏。”“朝廷以转运使主馈饷,随意诛求,无复顾惜。”“使其浸成疽赘,则非特北方未易取,而南方亦未易定。”(叶适《论四大屯兵》语,详见《文献通考·兵考》)所以要对外言和,得一个整理内部的机会。当其南还之时,就说要“南人归南,北人归北”。高宗既无进取的雄才,自然意见与之相合,于是用为宰相。1137年,刘豫为宗弼所废。秦桧乘机,使人向挞懒要求,把河南、陕西之地,还给宋朝。挞懒允许了。明年,遂以其地来归。而金朝突起政变。1139年,宗弼回上京(今吉林阿城县)。挞懒南走。至燕京,为金人所追及,被杀。和议遂废。宗弼再向河南,娄室再向陕西。宋朝此时,兵力已较南渡之初稍强。宗弼前锋至顺昌(今安徽阜阳县),为刘锜所败。岳飞从湖北进兵,亦有郾城之捷(今河南偃城县)。吴璘亦出兵收复了陕西若干州郡。倘使内部没有矛盾,自可和金兵相持。而高宗、秦桧执意言和,把诸将召还,和金人成立和约:东以淮水,西以大散关为界(在陕西宝鸡县南);岁奉银、绢各二十五万两、匹;宋高宗称臣于金,可谓屈辱极了。于是罢三宣抚司,改其兵为某州驻扎御前诸军,而设总领以司其财赋,见第四十五章。\n金太宗死后,太祖之孙熙宗立,以嗜酒昏乱,为其从弟海陵庶人所弑,此事在1149年。海陵更为狂妄,迁都于燕,后又迁都于汴。1160年,遂大举南侵。以其暴虐过甚,兵甫动,就有人到辽阳去拥立世宗。海陵闻之,欲尽驱其众渡江,然后北还。至采石矶,为宋虞允文所败。改趋扬州,为其下所弑,金兵遂北还。1162年,高宗传位于孝宗。孝宗颇有志于恢复,任张浚以图进取。浚使李显忠进兵,至符离(集名,在今安徽宿县)大败。进取遂成画饼。1165年,以岁币各减五万,宋主称金主为伯父的条件成和。金世宗算是金朝的令主。他的民族成见,是最深的。他曾对其种人,屡称上京风俗之美,教他们保存旧风,不要汉化。臣下有说女真、汉人,已为一家的,他就板起脸说:“女真、汉人,其实是二。”这种尖锐的语调,决非前此的北族,所肯出之于口的,其存之于心的,自亦不至如世宗之甚了。然世宗的见解虽如此,而既不能放弃中国之地,就只得定都燕京。并因是时叛者蜂起,不得不将猛安、谋克户移入中原,以资镇压。夺民地以给之,替汉人和女真之间,留下了深刻的仇恨。而诸猛安谋克人,则惟酒是务,竟有一家百口,垅无一苗的,征服者的气质,丧失净尽了。自太祖崛起至此,不过六十年。\n公元1194年,孝宗传位于光宗。此时金世宗亦死,子章宗立,北边颇有叛乱,河南、山东,亦有荒歉之处,金朝的国势渐衰。宋光宗多病,皇后李氏又和太上皇不睦。1194年,孝宗崩,光宗不能出而持丧,人心颇为疑惑。宰相赵汝愚,因合门使韩侂胄,请于高宗后吴氏,扶嘉王扩内禅,是为宁宗。韩侂胄排去赵汝愚,代为宰相,颇为士流所攻击,想立恢复之功,以间执众口。1206年,遂贸然北伐。谁想金兵虽弱,宋兵亦不强。兵交之后,襄阳和淮东西州郡,次第失陷。韩侂胄又想谋和,而金人复书,要斩侂胄之首,和议复绝。皇后杨氏,本和韩侂胄有隙,使其兄次山,勾结侍郎史弥远,把韩侂胄杀掉,函首以畀金。1208年,以增加岁币为三十万两、匹的条件成和。韩侂胄固然是妄人,宋朝此举,也太不成话了。和议成后两年,金章宗死,世宗子卫绍王立。其明年,蒙古侵金,金人就一败涂地。可见金朝是时,业已势成弩末,宋朝并没有急于讲和的必要了。\n蒙古本室韦部落,但其后来和鞑靼混合,所以蒙人亦自称为鞑靼。其居地初在望建河,即今黑龙江上游之南,而后徙于不而罕山,即今外蒙古车臣、土谢图两部界上的布尔罕哈勒那都岭。自回纥灭亡以后,漠北久无强部,算到1167年成吉思汗做蒙古的酋长的时候,已经三百六十多年了,淘汰,酝酿,自然该有一个强部出来。成吉思汗少时,漠南北诸部错列,蒙古并不见得怎样强大。且其内部分裂,成吉思汗备受同族的龁。但他有雄才大略,收合部众,又与诸部落合纵连横,至1206年,而漠南北诸部,悉为所征服。这一年,诸部大会于斡难河源(今译作鄂诺,又作敖嫩),上他以成吉思汗的尊号。成吉思汗在此时,已非蒙古的汗,而为许多部族的大汗了。1210年,成吉思汗伐夏,夏人降。其明年,遂伐金。金人对于北方,所采取的,是一种防守政策。从河套斜向东北,直达女真旧地,筑有一道长城。汪古部居今归绥县之北,守其冲要之点。此时汪古通于蒙古,故蒙古得以安行而入长城。会河堡一战(会河堡,在察哈尔万全县西),金兵大败,蒙古遂入居庸关。留兵围燕京,分兵蹂躏山东、山西,东至辽西。金人弑卫绍王,立宣宗,与蒙古言和,而迁都于汴。蒙古又以为口实,发兵攻陷燕京。金人此时,尽迁河北的猛安、谋克户于河南,又夺汉人之地以给之。其民既不能耕,又不能战。势已旦夕待亡。幸1218年,成吉思汗用兵于西域,金人乃得少宽。这时候,宋朝亦罢金岁币。避强凌弱,国际上总是在所不免的;而此时金人,财政困难,对于岁币,亦不肯放弃,或者还希冀战胜了可以向宋人多胁取些,于是两国开了兵衅。又因疆场细故,与夏人失和,兵力益分而弱。1224年,宣宗死,哀宗立,才和夏人以兄弟之国成和(前此夏人称臣),而宋朝卒不许。其时成吉思汗亦已东归,蒙古人的兵锋,又转向中原了。1227年,成吉思汗围夏,未克而死。遗命秘不发丧,把夏人灭掉。1229年,太宗立。明年,复伐金。时金人已放弃河北,以精兵三十万,守邳县到潼关的一线。太宗使其弟拖雷假道于宋,宋人不许。拖雷就强行通过,自汉中、襄、郧而北,大败金人于三峰山(在河南禹县)。太宗亦自白坡渡河(在河南孟津县),使速不台围汴。十六昼夜不能克,乃退兵议和。旋金兵杀蒙古使者,和议复绝。金哀宗逃到蔡州。宋、元复联合以攻金。宋使孟珙、江海帅师会蒙古兵围蔡。1234年,金亡。\n约金攻辽,还为金灭,这是北宋的覆辙,宋人此时,似乎又不知鉴而蹈之了。所以读史的人,多以宋约元攻金为失策,这亦未必尽然。宋朝和金朝,是不共戴天之仇,不能不报的。若说保存金朝以为障蔽,则金人此时,岂能终御蒙古?不急进而与蒙古联合,恢复一些失地,坐视金人为蒙古所灭,岂不更糟?要知约金攻辽,亦并不算失策,其失策乃在灭辽之后,不能发愤自强,而又轻率启衅。约元灭金之后,弊亦仍在于此。金亡之前十年,宋宁宗崩,无子。史弥远援立理宗,仍专政。金亡前一年,史弥远死,贾似道继之。贾似道是表面上似有才气,而不能切实办事的人,如何当得这艰难的局面?金亡之后,宋朝人倡议收复三京(宋东京即大梁,南京即宋州,西京为洛阳,北京为大名),入汴、洛而不能守。蒙古反因此南侵,江、淮之地多陷。1241年,蒙古太宗死。1246年,定宗立。三年而死。1251年,宪宗方立。蒙古当此时,所致力的还是西域,而国内又有汗位继承之争,所以未能专力攻宋。至1258年,各方粗定,宪宗乃大举入蜀。忽必烈已平吐蕃、大理,亦东北上至鄂州(今湖北武昌县)。宋将王坚守合州(今四川合川县),宪宗受伤,死于城下。贾似道督大军援鄂,不敢战,使人求和,许称臣,划江为界。忽必烈亦急图自立,乃许之而北归。贾似道掩其事,以大捷闻于朝。自此蒙古使者来皆拘之,而借和议以图自强,而待敌人之弊的机会遂绝。忽必烈北还后,自立,是为元世祖。世祖在宪宗时,本来是分治漠南的,他手下又多西域人和中国人,于是以1264年定都燕京。蒙古的根据地,就移到中国来了。明年,理宗崩,子度宗立。宋将刘整叛降元,劝元人攻襄阳。自1268年至1273年,被围凡五年,宋人不能救,襄阳遂陷。明年,度宗崩,子恭帝立。伯颜自两湖长驱南下。1276年,临安不守,谢太后和恭帝都北狩。故相陈宜中立其弟益王于福州(今福建闽侯县),后来转徙,崩于州(在今广东吴川县海中)。其弟卫王昺立,迁于崖山(在今广东新会县海中)。1279年,汉奸张弘范来攻,宰相陆秀夫负帝赴海殉国。张世杰收兵图再举,到海陵山(在今广东海阳县海中),舟覆而死。宋亡。中国遂整个为北族所征服。\n宋朝的灭亡,可以说是我国民族的文化,一时未能急剧转变,以适应于竞争之故。原来游牧民族,以掠夺为生产,而其生活又极适宜于战斗,所以其势甚强,文明民族,往往为其所乘,罗马的见轭于蛮族,和中国的见轭于五胡和辽、金、元、清,正是一个道理。两国国力的强弱,不是以其所有的人力物力的多少而定,而是看其能利用于竞争的共有多少而定。旧时的政治组织,是不适宜于动员全民众的。其所恃以和异族抵抗的一部分,或者正是腐化分子的一个集团。试看宋朝南渡以后,军政的腐败,人民的困苦,而一部分士大夫反溺于晏安鸩毒、歌舞湖山可知。虽其一部分分子的腐化,招致了异族的压迫,却又因异族的压迫,而引起了全民族的觉醒,替民族主义,建立了一个深厚的根基,这也是祸福倚伏的道理。北宋时代,可以说是中国民族主义的萌蘖时期。南宋一代,则是其逐渐成长的时期。试读当时的主战派,如胡铨等一辈人的议论,至今犹觉其凛凛有生气可知(见《宋史》卷三七四)。固然,只论是非,不论利害,是无济于事的。然而事有一时的成功,有将来的成功。主张正义的议论,一时虽看似迂阔,隔若干年代后,往往收到很大的效果。民族主义的形成,即其一例。论是非是宗旨,论利害是手段。手段固不能不择,却不该因此牺牲了宗旨。历来外敌压迫时,总有一班唱高调的人,议论似属正大,居心实不可问,然不能因此而并没其真。所以自宋至明,一班好发议论的士大夫,也是要分别观之的。固不该盲从附和,也不该一笔抹杀。其要,在能分别真伪,看谁是有诚意的,谁是唱高调的,这就是大多数国民,在危急存亡之时,所当拭目辨别清楚的了。民族主义,不但在上流社会中,植下了根基,在下流社会中,亦立下了一个组织,看后文所述便知。\n第二十七章 蒙古大帝国的盛衰 # 蒙古是野蛮的侵略民族所建立的最大的帝国,它是适值幸运而成功的。\n\\[木剌夷(Mulahids),为天方教中之一派,在里海南岸\\],西域至此略定。东北一带,自高句丽、百济灭亡后,新罗亦渐衰。唐末,复分为高丽、后百济及新罗三国。石晋初,尽并于高丽王氏。北宋之世,高丽曾和契丹构兵,颇受其侵略,然尚无大关系。自高句丽灭亡后,朝鲜半岛的北部,新罗控制之力,不甚完全,高丽亦未能尽力经营,女真逐渐侵入其地,是为近世满族发达的一个原因,金朝即以此兴起。完颜部本曾朝贡于高丽,至后来,则高丽反为所胁服,称臣奉贡。金末,契丹遗族和女真人在今辽、吉境内扰乱,蒙古兵追击,始和高丽相遇,因此引起冲突,至太宗时乃成和。此后高丽内政,随时受蒙古人的干涉。有时甚至废其国号,而于其地立征东行省。元世祖时,中国既定,又要介高丽以招致日本。日本不听,世祖遂于1274、1281两年遣兵渡海东征。前一次损失还小,后一次因飓风将作,其将择坚舰先走,余众二十余万,尽为日本所虏,杀蒙古人、高丽人、汉人,而以南人为奴隶,其败绩可谓残酷了。世祖欲图再举,因有事于安南,遂不果。蒙古西南的侵略,是开始于宪宗时的。世祖自今青海之地入西藏,遂入云南,灭大理(即南诏)。自将北还,而留兵续向南方侵略。此时后印度半岛之地,安南已独立为国。其南,今柬埔寨之地为占城,蒲甘河附近则有缅国。元兵侵入安南和占城,其人都不服,1284、1285、1287三年,三次发兵南征,因天时地利的不宜,始终不甚得利。其在南洋,则曾一度用兵于爪哇。此外被招致来朝的共有十国,都是今南洋群岛和印度沿岸之地(《元史》云:当时海外诸国,以俱蓝、马八儿为纲维,这两国,该是诸国中最大的。马八儿,即今印度的马拉巴尔。俱蓝为其后障,当在马拉巴尔附近)。自成吉思汗崛起至世祖灭宋,共历一百一十二年,而蒙古的武功,臻于极盛。其人的勇于战斗,征服各地方后,亦颇长于统治(如不干涉各国的信教自由,即其一端),自有足称。但其大部分成功的原因,则仍在此时别些大国,都适值衰颓,而乏抵抗的能力,其中尤其主要的,就是中国和大食帝国;又有一部分人,反为其所用,如蒙古西征时附从的诸部族便是,所以我说它是适值天幸。\n中国和亚、欧、非三洲之交的地中海沿岸,是世界上两个重要的文明起源之地。这两个区域的文明,被亚洲中部和南部的山岭,和北方的荒凉阻隔住了。欧洲文明的东渐,大约以古希腊人的东迁为最早。汉通西域时所接触的西方文化,就都是古希腊人所传播、所留遗。其后罗马兴,东边的境界,仍为东西文化接触之地。至罗马之北境为蛮族所据而中衰。大食兴,在地理上,拥有超过罗马的大版图,在文化上,亦能继承希腊的遗绪。西方的文化,因此而东渐,东方的文化,因此而西行者不少。但主要的是由于海路。至蒙古兴,而欧西和东方的陆路才开通。其时西方的商人,有经中央亚细亚、天山南路到蒙古来的,亦有从西伯利亚南部经天山北路而来的。基督教国,亦派有使节东来。而意大利人马可波罗(Marco polo)居中国凡三十年,归而以其所见,著成游记,给与西方人以东方地理上较确实的知识,且引起其好奇心,亦为近世西力东侵的一个张本。\n\\[阿阔台之后称Km.of Ogotai,亦称Naiman(乃蛮)。察合台之后称Km.Of Tchagatai。拔都之后称Km.of Kiptchac,亦称Golden Horde。旭烈兀之后称Km.of Iran\\],而分裂即起于其间。蒙古的汗,本来是由诸部族公推的,到后来还是如此。每当大汗逝世之后,即由宗王、驸马和管兵的官,开一个大会(蒙古语为“忽力而台”),议定应继承汗位的人。太祖之妻孛儿帖,曾给蔑儿乞人掳去,后太祖联合与部,把她抢回,就生了朮赤。他的兄弟,心疑他是蔑儿乞种,有些歧视他,所以他西征之后,一去不归,实可称为蒙古的泰伯。太祖死时,曾有命太宗承继之说,所以大会未有异议。太宗死后,其后人和拖雷的后人,就有争夺之意。定宗幸获继立,而身弱多病,未久即死。拖雷之子宪宗被推戴。太宗后人,另谋拥戴失烈门,为宪宗所杀,并夺去太宗后王的兵柄。蒙古的内争,于是开始。宪宗死后,争夺复起于拖雷后人之间。宪宗时,曾命阿里不哥统治漠北,世祖统治漠南。宪宗死后,世祖不待大会的推戴而自立,阿里不哥亦自立于漠北,为世祖所败,而大宗之子海都自立于西北,察合台、钦察两汗国都附和他。伊儿汗国虽附世祖,却在地势上被隔绝了,终世祖之世不能定,直到1310年,海都之子才来归降。然自海都之叛,蒙古大汗的号令,就不能行于全帝国,此时亦不能恢复了。所以蒙古可说是至世祖时而臻于极盛,亦可说自世祖时而开始衰颓。\n第二十八章 汉族的光复事业 # ●成吉思汗\n辽、金、元三朝,立国的情形,各有不同。契丹虽然占据了中国的一部分,然其立国之本,始终寄于部族,和汉人并未发生深切的关系。金朝所侵占的重要之地,唯有中国。它的故土和它固有的部族、文化尚未发展,虽可藉其贫瘠而好掠夺的欲望,及因其进化之浅,社会组织简单,内部矛盾较少,因而以诚朴之气、勇敢之风,而崛起于一时,然究不能据女真之地,用女真之人,以建立一个大国。所以从海陵迁都以后,其国家的生命,已经寄托在它所侵占的中国的土地上了。所以它压迫汉人较甚,而其了解汉人,却亦较深。至蒙古,则所征服之地极广,中国不过是其一部分。虽然从元世祖以后,大帝国业已瓦解,所谓元朝者,其生命亦已寄托于中国,然自以为是一个极大的帝国,看了中国,不过是其所占据的地方的一部分的观念,始终未能改变。所以对于中国,并不能十分了解,试看元朝诸帝,多不通汉文及汉语可知。元朝诸帝,惟世祖较为聪明,所用的汉人和西域人较多,亦颇能厘定治法。此后则惟仁宗在位较久,政治亦较清明。其余诸帝,大抵荒淫愚昧。这个和其继嗣之争,亦颇有关系。因为元朝在世祖之时,北边尚颇紧急。成宗和武宗,都是统兵在北边防御,因而得立的。武宗即位之前,曾由仁宗摄位,所以即位之后,不得不立仁宗为太子。因此引起英宗之后泰定、天顺二帝间的争乱。文宗死后,又引起燕帖木儿的专权(时海都之乱未定,成宗和武宗都是统兵以防北边的。世祖之死,伯颜以宿将重臣,归附成宗,所以未有争议。成宗之死,皇后伯岳吾氏想立安西王。右丞相哈剌哈孙使迎仁宗监国,以待武宗之至。武宗至,弑伯岳吾后,杀安西王而自立。以仁宗为太子。仁宗既立,立英宗为太子,而出明宗于云南。其臣奉之奔阿尔泰山。英宗传子泰定帝,死于上都。子天顺帝,即在上都即位。签书枢密院事燕帖木儿,为武宗旧臣,胁大都百官,迎立武宗之子。因明宗在远,先迎文宗监国。发兵陷上都,天顺帝不知所终。明宗至漠南,即位。文宗入见,明宗暴死。文宗后来心上觉得不安,遗令必立明宗之子。而燕帖木儿不肯。文宗皇后翁吉剌氏,坚持文宗的遗命。于是迎立宁宗,数月而死。再迎顺帝。顺帝的年纪却比宁宗大些了,燕帖木儿又坚持,顺帝虽至,不得即位。会燕帖木儿死,问题乃得解决。顺帝既立,追治明宗死事,翁吉剌后和其子燕帖古思都被流放到高丽,死在路上。元入中国后的继嗣之争,大略如此)。中央的变乱频仍,自然说不到求治,而最后又得一个荒淫的顺帝,胡无百年之运,客星据坐,自然不能持久了。元世祖所创立的治法,是专以防制汉人为务的。试看其设立行省及行御史台;将边徼襟喉之地,分封诸王;遣蒙古军及探马赤军分守河、洛、山东;分派世袭的万户府,屯驻各处;及因重用蒙古、色目人而轻视汉人可知。这是从立法方面说。从行政方面说:则厚敛人民,以奉宗王、妃、主。纵容诸将,使其掠人为奴婢。选法混乱,贪黩公行。而且迷信喇嘛教,佛事所费,既已不赀,还要听其在民间骚扰。可谓无一善政(参看第三十九、第四十、第四十二、第四十三、第四十五各章),所以仍能占据中国数十年,则因中国社会,自有其深根宁极之理,并非政治现象,所能彻底扰乱,所以其以异族入据中原,虽为人心所不服,亦不得不隐忍以待时。到顺帝时,政治既乱,而又时有水旱偏灾,草泽的英雄,就要乘机而起了。\n“举世无人识,终年独自行。海中擎日出,天外唤风生。”(郑所南先生诗语。所南先生名思肖。工画兰。宋亡后,画兰皆不画土。人或问之。则曰:“土为番人夺去,汝不知耶?”著有《心史》,藏之铁函,明季乃于吴中承天寺井中得之。其书语语沉痛,为民族主义放出万丈的光焰。清朝的士大夫读之,不知自愧,反诬为伪造,真可谓全无心肝了。)表面上的平静,是靠不住的,爆发的种子,正潜伏在不见不闻之处。这不见不闻之处是哪里呢?这便在各人的心上。昔人说:“雪大耻,复大仇,皆以心之力。”(龚自珍文中语)。文官投降了,武官解甲了,大多数的人民,虽然不服,苦于不问政治久了,一时团结不起来。时乎时乎?七年之病,求三年之艾,乃将一颗革命的种子,广播潜藏于人民的唯一组织,即所谓江湖豪侠的社会之中,这是近世史上的一件大事。明亡以后之事,为众所周知,然其事实不始于明亡以后,不过年深月久,事迹已陈,这种社会中,又没有记载,其事遂在若存若亡之间罢了。元朝到顺帝之世,反抗政府的,就纷纷而起。其中较大的是:台州的方国珍(今浙江临海县),徐州的李二,湖北的徐寿辉,濠州的郭子兴(今安徽凤阳县),高邮的张士诚(后迁平江,今江苏吴县),而刘福通以白莲教徒,起于安丰(今安徽寿县),奉其教主之子韩林儿为主。白莲教是被近代的人看作邪教的,然其起始决非邪教,试看其在当时,首举北伐的义旗可知。元朝当日,政治紊乱。宰相脱脱之弟也先帖木儿,当征讨之任,连年无功,后来反大溃于沙河(今河南遂平、确山、泌阳境上的沙河店),军资丧失殆尽。脱脱觉得不好,自将大军出征,打破了李二,围张士诚,未克,而为异党排挤以去。南方群雄争持,元朝就不能过问。1358年,刘福通分兵三道:一军入山、陕,一军入山东,自奉韩林儿复开封。此时元朝方面,亦有两个人出来替其挣扎,那便是察罕帖木儿和李思齐。他们是在河南起兵帮助元朝的。此时因陕西行省的求援,先入陕解围。又移兵山东,把刘福通所派的兵,围困起来。刘福通的将遣人把察罕刺死。其子库库帖木儿代总其兵,才把刘福通军打败,刘福通和韩林儿,走回安丰,后为张士诚所灭。然其打山西的一支兵,还从上都直打到辽东(今多伦县,元世祖自立于此,建为上都,而称今北平为大都),然后被消灭。军行数千里,如入无人之境,亦可谓虽败犹荣了。\n首事的虽终于无成,然继起的则业已养成气力。明太祖初起时,本来是附随郭子兴的。后来别为一军,渡江取集庆(今南京,元集庆路)。时徐寿辉为其将陈友谅所杀,陈友谅据江西、湖北,势颇强盛(寿辉将明玉珍据四川自立,传子昇,为明太祖所灭),后为太祖所灭。太祖又降方国珍、破张士诚,几乎全据了长江流域。而元朝是时,复起内乱。其时库库帖木儿据冀宁(元冀宁路,治今山西阳曲县),孛罗帖木儿据大同,孛罗想兼据晋冀,以裕军食,二人因此相争。顺帝次后奇氏,高丽人,生子爱猷识理达腊,立为太子。太子和奇后,阴谋内禅。是时高丽人自宫到元朝来充当内监的很多,奇后宫中,自更不乏,而朴不花最得信任,宰相搠思监就是走朴不花的门路得位的。他和御史大夫老的沙不协,因太子言于顺帝,免其职。老的沙逃奔大同,托庇于孛罗。搠思监诬孛罗谋反。孛罗就真个反叛,举兵犯阙,把搠思监和朴不花都杀掉。太子投奔库库。库库兴兵送太子还京,孛罗已被顺帝遣人刺死。太子欲使库库以兵力胁迫顺帝内禅,库库不肯。时顺帝封库库为河南王,使其总统诸军,平定南方。李思齐因与察罕同起兵,不愿受库库节制,陕西参政张良弼,亦和库库不协,二人连兵攻库库。太子乘机叫顺帝下诏,削掉库库的官爵,使太子统兵讨之。北方大乱。“天道好还,中国有必伸之理,人心效顺,匹夫无不报之仇。”(太祖时讨胡檄中语)1368年,明太祖命徐达、常遇春两道北伐。徐达平河南,常遇春下山东,会师德州(今山东德县),北扼直沽。顺帝走上都。太祖使徐达下太原,乘胜定秦、陇,库库帖木儿奔和林(和林城,太宗所建,今之额尔德尼招,是其遗址)。常遇春攻上都,顺帝再奔应昌(城名,在达里泊傍,为元外戚翁吉剌氏之地)。1387年,顺帝死,明兵再出,爱猷识理达腊亦奔和林。不久便死,子脱古思帖木儿嗣。1387年,太祖使蓝玉平辽东,乘胜袭破脱古思帖木儿于捕鱼海(今达里泊)。脱古思帖木儿北走,为其下所杀。其后五传皆被弑,蒙古大汗的统系遂绝。元宗室分封在内地的亦多降,惟梁王把匝剌瓦尔密据云南不服。1381年,亦为太祖所灭。中原之地,就无元人的遗孽了。自1279年元朝灭宋,至1368年顺帝北走,凡八十九年。\n第二十九章 明朝的盛衰 # 明太祖起于草泽,而能铲除胡元,定群雄,其才不可谓不雄。他虽然起于草泽,亦颇能了解政治,所定的学校、科举、赋役之法,皆为清代所沿袭,行之凡六百年。卫所之制,后来虽不能无弊,然推原其立法之始,亦确是一种很完整的制度,能不烦民力而造成多而且强的军队。所以明朝开国的规模,并不能算不弘远。只可惜他私心太重。废宰相,使朝无重臣,至后世,权遂入于阉宦之手。重任公侯伯的子孙,开军政腐败之端。他用刑本来严酷,又立锦衣卫,使司侦缉事务,至后世,东厂、西厂、内厂,遂纷纷而起(东厂为成祖所设,西厂设于宪宗时,内厂设于武宗时,皆以内监领其事)。这都不能不归咎于诒谋之不臧。其封建诸子于各地,则直接引起了靖难之变。\n明初的边防,规模亦是颇为弘远的。俯瞰蒙古的开平卫,即设于元之上都。其后大宁路来降,又就其地设泰宁、朵颜、福余三卫。泰宁在今热河东部,朵颜在吉林之北,福余则在农安附近。所以明初对东北,威远瞻。其极盛时的奴儿干都司,设于黑龙江口,现在的库页岛,亦受管辖(《明会典》卷一〇九:永乐七年,设奴儿干都司于黑龙江口。清曹廷杰《西伯利亚东偏纪要》说,庙尔以上二百五十余里,混同江东岸特林地方,有两座碑:一刻《敕建永宁寺记》,一刻《宣德六年重建永宁寺记》,均系太监亦失哈述征服奴儿干和海中苦夷之事。苦夷即库页。宣德为宣宗年号,宣德六年为公元1431年)。但太祖建都南京,对于北边的控制,是不甚便利的。成祖既篡建文帝,即移都北京。对于北方的控制,本可更形便利。确实,他亦曾屡次出征,打破鞑靼和瓦剌。但当他初起兵时,怕节制三卫的宁王权要袭其后,把他诱执,而将大宁都司,自今平泉县境迁徙到保定。于是三卫之地,入于兀良哈,开平卫势孤。成祖死后,子仁宗立,仅一年而死。子宣宗继之。遂徙开平卫于独石口。从此以后,宣、大就成为极边了。距离明初的攻克开平,逐去元顺帝,不过六十年。明初的经略,还不仅对于北方。安南从五代时离中国独立,成祖于1406年,因其内乱,将其征服,于其地设立交趾布政使司,同于内地。他又遣中官郑和下南洋,前后凡七次。其事在1405至1433年之间,早于欧人的东航,有好几十年。据近人的考究:郑和当日的航路,实自南海入印度洋,达波斯湾及红海,且拂非洲的东北岸,其所至亦可谓远了。史家或说:成祖此举,是疑心建文帝亡匿海外,所以派人去寻求的。这话臆度而不中情实。建文帝即使亡匿海外,在当日的情势下,又何能为?试读《明史》的外国传,则见当太祖时,对于西域,使节所至即颇远。可见明初的外交,是有意沿袭元代的规模的。但是明朝立国的规模,和元朝不同。所以元亡明兴,西域人来者即渐少。又好勤远略,是和从前政治上的情势不相容的,所以虽有好大喜功之主,其事亦不能持久。从仁宗以后,就没有这种举动了。南方距中国远,该地方的货物,到中原即成为异物,价值很贵;又距离既远,为政府管束所不及,所以宦其地者率多贪污,这是历代如此的。明朝取安南后,还是如此。其时中官奉使的多,横暴太甚,安南屡次背叛。宣宗立,即弃之。此事在1427年,安南重隶中国的版图,不过二十二年而已。自郑和下南洋之后,中国对于南方的航行,更为熟悉,华人移殖海外的渐多。近代的南洋,华人实成为其地的主要民族,其发端实在此时。然此亦是社会自然的发展,得政治的助力很小。\n●朱元璋像 据传朱元璋长相极丑,又忌讳画师如实描绘,因此画师的画像各不相同,大都是似而非\n明代政治的败坏,实始于成祖时。其一为用刑的残酷,其二为宦官的专权,而两事亦互相依倚。太祖定制,内侍本不许读书。成祖反叛时,得内监为内应,始选官入内教习。又使在京营为监军,随诸将出镇。又设立东厂,使司侦缉之事。宦官之势骤盛。宣宗崩,英宗立,年幼,宠太监王振。其时瓦剌强,杀鞑靼酋长,又胁服兀良哈。1449年,其酋长也先入寇。王振贸然怂恿英宗亲征。至大同,知兵势不敌,还师。为敌军追及于土木堡,英宗北狩。朝臣徐有贞等主张迁都,于谦力主守御,奉英宗之弟景帝监国,旋即位。也先入寇,谦任总兵石亨等力战御之。也先攻京城,不能克,后屡寇边,又不得利,乃奉英宗归。大凡敌兵入寇,京城危急之时,迁都与否,要看情势而定。敌兵强,非坚守所能捍御,而中央政府,为一国政治的中心,失陷了,则全国的政治,一时要陷于混乱,则宜退守一可据的据点,徐图整顿。在这情势之下,误执古代国君死社稷之义,不肯迁都,是要误事的,崇祯的已事,是其殷鉴。若敌兵实不甚强,则坚守京城,可以振人心而作士气。一移动,一部分的国土,就要受敌兵蹂躏,损失多而事势亦扩大了。瓦剌在当日,形势实不甚强,所以于谦的主守,不能不谓之得计。然徐有贞因此内惭,石亨又以赏薄怨望,遂结内监曹吉祥等,乘景帝卧病,闯入宫中,迎英宗复辟,是为“夺门”之变。于谦被杀。英宗复辟后,亦无善政。传子宪宗,宠太监汪直。宪宗传孝宗,政治较称清明。孝宗传武宗,又宠太监刘瑾。这不能不说是成祖恶政的流毒了。明自中叶以后,又出了三个昏君。其一是武宗的荒淫,其二是世宗的昏聩,其三是神宗的怠荒,明事遂陷于不可收拾之局。武宗初宠刘瑾,后瑾伏诛,又宠大同游击江彬,导之出游北边。封于南昌的宁王宸濠,乘机作乱,为南赣巡抚王守仁所讨平,武宗又借以为名,出游江南而还。其时山东、畿南群盗大起,后来幸获敉平,只可算得侥幸。武宗无子,世宗以外藩入继。驭宦官颇严,内监的不敢恣肆,是无过于世宗时的。但其性质严而不明,中年又好神仙,日事斋醮,不问政事。严嵩因之,故激其怒,以入人罪,而窃握大权,政事遂至大坏。其时倭寇大起,沿海七省,无一不被其患,甚至沿江深入,直抵南京。北边自也先死后,瓦剌复衰,鞑靼部落,入据河套,谓之“套寇”。明朝迄无善策。至世宗时,成吉思汗后裔达延汗复兴,击败套寇,统一蒙古。达延汗四子,长子早死。达延汗自与其嫡孙卜赤徙牧近长城,称为插汉儿部,就是现在的察哈尔部。次子为套寇所杀。三子系征服套寇的。其有二子:一为今鄂尔多斯部之祖,亦早死。一为阿勒坦汗,《明史》称为俺荅,为土默特部之祖。第四子留居漠北,则为喀尔喀三部之祖(车臣,上谢图,札萨克图。其三音诺颜系清时增设)。自达延汗以后,蒙古遂成今日的形势了,所以达延汗亦可称为中兴蒙古的伟人。俺荅为边患,是最深的。世宗时,曾三次入犯京畿。有一次,京城外火光烛天,严嵩竟骗世宗,说是民家失火,其蒙蔽,亦可谓骇人听闻了。世宗崩,穆宗立,未久而死。神宗立,年幼,张居正为相。此为明朝中兴的一个好机会。当穆宗时,俺荅因其孙为中国所得,来降,受封为顺义王,不复为边患。插汉儿部强盛时,高拱为相,任李成梁守辽东,戚继光守蓟镇以敌之。成梁善战,继光善守,张居正相神宗,益推心任用此二人,东北边亦获安静。明朝政治,久苦因循。张居正则能行严肃的官僚政治。下一纸书,万里之外,无敢不奉行惟谨者,所以吏治大有起色。百孔千疮的财政,整理后亦见充实。惜乎居正为相,不过十年,死后神宗亲政,又复昏乱。他不视朝至于二十余年,群臣都结党相攻。其时无锡顾宪成,居东林书院讲学,喜欢议论时政,于是朝廷上的私党,和民间的清议,渐至纠结而不可分。神宗信任中官,使其到各省去开矿,名为开矿,实则藉此索诈。又在穷乡僻壤,设立税使,骚扰无所不至。日本丰臣秀吉犯朝鲜,明朝发大兵数十万以援之,相持凡七年,并不能却敌,到秀吉死,日本兵才自退。神宗死后,熹宗继之。信任宦官魏忠贤,其专横又为前此所未有。统计明朝之事,自武宗以后,即已大坏,而其中世宗、神宗,均在位甚久。武宗即位,在1506年,熹宗之死,在1627年,此一百二十二年之中,内忧外患,迭起交乘,明事已成不可收拾之局。思宗立,虽有志于振作,而已无能为力了。\n第三十章 明清的兴亡 # 文化是有传播的性质的,而其传播的路线,往往甚为纡曲。辽东、西自公元前4世纪,即成为中国的郡县,因其距中原较远,长驾远驭之力,有所不及,所以中国的政治势力,未能充分向北展拓,自吉林以东北,历代皆仅等诸羁縻。其地地质虽极肥沃,而稍苦寒;又北方扰攘时多,自河北经热河东北出之道,又往往为游牧民族所阻隔,所以中国民族,亦未能盛向东北拓殖。在这一个区域中,以松花江流域为最肥沃,其地距朝鲜甚近,中国的文化,乃从朝鲜绕了一个圈儿,以间接开化其地的女真民族。渤海、金、清的勃兴,都是如此。\n清朝的祖先,据其自己说,是什么天女所生的,这一望而知其为有意造作的神话。据近人所考证,明时女真之地,凡分三卫:曰海西卫,自今辽宁的西北境,延及吉林的西部。曰野人卫,地在吉、黑的东偏。曰建州卫,则在长白山附近。海西卫为清人所谓扈伦部,野人卫清人谓之东海部,建州卫则包括满洲长白山西部。清朝真正的祖先,所谓肇祖都督孟特穆,就是1412年受职为建州卫指挥使的猛哥帖木儿(明人所授指挥使,清人则称为都督。孟特穆为孟哥帖木儿异译),其初曾入贡受职于朝鲜的李朝的。后为七姓野人所杀。其时的建州卫,还在朝鲜会宁府河谷。弟凡察立,迁居佟家江。后猛哥帖木儿之子董山,出而与凡察争袭。明朝乃分建州为左右两卫,以董山为左卫指挥使,凡察为右卫指挥使。董山渐跋扈,明朝檄致广宁诛之。部下拥其子脱罗扰边(《清实录》作妥罗,为肇祖之孙。其弟曰锡宝斋篇古。锡宝斋篇古之子曰兴祖都督福满,即景祖之父),声称报仇,但未久即寂然。自此左卫衰而右卫盛。右卫酋长王杲,居宽甸附近。为李成梁所破,奔扈伦部的哈达(叶赫在吉林西南,明人称为北关。哈达在开原北,明人称为南关)。哈达执送成梁,成梁杀之。其子阿台,助叶赫攻哈达。满洲苏克苏浒部长尼堪外兰,为李成梁做向导,攻杀阿台。满洲酋长叫场,即清朝所谓景祖觉昌安,其子他失,则清朝所谓显祖塔克世,塔克世的儿子驽尔哈赤,就是清朝的太祖了。阿台系景祖孙婿,阿台败时,清景、显二祖亦死。清太祖仍受封于明,后来起兵攻破尼堪外兰。尼堪外兰逃奔明边。明朝非但不能保护,反把他执付清太祖。且开抚顺、清河、宽甸、叆阳四关,和它互市。自此满洲人得以沐浴中国的文化,且藉互市以润泽其经济,其势渐强。先服满洲诸部。扈伦、长白山诸部联合蒙古的科尔沁部来攻,清太祖败之,威声且达蒙古东部。又合叶赫灭哈达。至1616年,遂叛明。\n时值明神宗之世。以杨镐为经略,发大兵二十万,分四路东征,三路皆败。满洲遂陷铁岭,灭叶赫。明以熊廷弼为经略。延弼颇有才能,明顾旋罢之,代以袁应泰。应泰有吏才,无将略,辽、沈遂陷。清太祖初自今之长白县(清之兴京,其地本名赫图阿拉),迁居辽阳,后又迁居沈阳。明朝再起熊廷弼,又为广宁巡抚王化贞所掣肘。化贞兵败,辽西地多陷。明朝逮二人俱论死。旋得袁崇焕力守宁远。1626年,清太祖攻之,受伤而死。子太宗立,因朝鲜归心于明,屡犄满洲之后,太宗乃先把朝鲜征服了,还兵攻宁远、锦州,又大败。清人是时,正值方兴之势,自非一日可以削平,然其力亦并不能进取辽西。倘使明朝能任用如袁崇焕等人物,与之持久,辽东必可徐图恢复的,辽西更不必说了,若说要打进山海关,那简直是梦想。\n●西域图册·土尔扈特风情册(清)明福绘。纸本设色,每半开纵36.8厘米,横43.9厘米\n所谓流寇,是无一定的根据地,流窜到哪里,裹胁到哪里的。中国疆域广大,一部分的天灾人祸,影响不到全国,局部的动乱,势亦不能牵动全国,只有当社会极度不安时,才会酿成如火燎原之势,而明季便是其时了。明末的流寇,是以1628年起于陕西的,正值思宗的元年。旋流入山酉,又流入河北,蔓衍于四川、湖广之境。以李自成和张献忠为两个最大的首领。献忠系粗才,一味好杀,自成则颇有大略。清太宗既不得志于辽西,乃自喜峰口入长城,犯畿甸。袁崇焕闻之,亦兼程入援。两军相持,未分胜负。明思宗之为人,严而不明,果于诛杀。先是袁崇焕因皮岛守将毛文龙跋扈,将其诛戮(皮岛,今图作海洋岛),思宗疑之而未发。及是,遂信清人反间之计,把崇焕下狱杀掉,于是长城自坏。此事在1629年。至1640年,清人大举攻锦州。蓟辽总督洪承畴往援,战败,入松山固守。明年,松山陷,承畴降清。先是毛文龙死后,其将孔有德、耿仲明降清,引清兵攻陷广鹿岛(今图或作光禄岛),守将尚可喜亦降。清当太祖时,尚无意于入据中原,专发挥其仇视汉人的观念,得儒士辄杀,得平民则给满洲人为奴。太宗始变计抚用汉人,尤其优待一班降将。洪承畴等遂不恤背弃祖国,为之效力。于是政治军事的形势,又渐变了。但明兵坚守了山海关,清兵还无力攻陷。虽然屡次绕道长城各口,蹂躏畿甸,南及山东,毕竟不敢久留,不过明朝剿流寇的兵,时被其牵制而已。1643年,李自成陷西安。明年,在其地称帝。东陷太原,分兵出真定(今河北正定县),自陷大同、宣府,入居庸关。北京不守,思宗殉国于煤山。山海关守将吴三桂入援,至丰润,京城已陷。自成招三桂降,三桂业经允许了。旋闻爱妾陈沅被掠,大怒,遂走关外降清。“痛哭六军皆缟素,冲冠一怒为红颜”,民族战争时唯一重要的据点,竟因此兵不血刃而失陷,武人不知礼义的危险,真令人言之而色变了。\n时清太宗已死,子世祖继立,年幼,叔父睿亲王多尔衮摄政,正在关外略地,闻三桂来降,大喜,疾趋受之。李自成战败,奔回陕西,清人遂移都北京。明人立神宗之孙福王由崧于南京,是为弘光帝。清人这时候,原只望占据北京,并不敢想全吞中国,所以五月三日入京,四日下令强迫人民剃发,到二十四日,即又将此令取消。而其传檄南方,亦说“明朝嫡胤无遗,用移大清,宅此北土,其不忘明室,辅立贤藩,戮力同心,共保江左,理亦宜然,予所不禁”。但弘光帝之立,是靠着凤阳总督马士英的兵力做背景的。士英遂引阉党阮大铖入阁,排去史可法。弘光帝又荒淫无度。清朝乃先定河南、山东。又分兵两道入关,李自成走死湖北。清人即移兵以攻江南。明朝诸将,心力不齐,史可法殉国于扬州,南京不守,弘光帝遂北狩,时在1645年。清朝既定江南,乃下令强迫人民剃发。当时有“留头不留发,留发不留头”之谚,其执行的严厉可想。此举是所以摧挫中国的民气的,其用意极为深刻酷毒。缘中国地大而人众,政治向主放任,人民和当地的政府,关系已浅,和中央政府,则几于毫无直接关系,所以朝代的移易,往往刺激不动人民的感情。至于衣服装饰,虽然看似无关紧要,然而习俗相沿,就是一种文化的表征,用兵力侵略的异族,强使故有的民族,弃其旧有的服饰而仿效自己,就不啻摧毁其文化,而且强替其加上一种屈服的标识。这无怪当日的人民,要奋起而反抗了。但是人民无组织已久了,临时的集合,如何能敌得久经征战的军队?所以当日的江南民兵,大都不久即败。南都亡后,明之遗臣,或奉鲁王以海监国绍兴,或奉唐王聿键正位福州,是为隆武帝。清人遣吴三桂陷四川,张献忠败死。别一军下江南,鲁王败走舟山。清兵遂入福建,隆武帝亦殉国,时为1647年。\n西南之地,向来和大局是无甚关系的,龙拏虎攫,总在黄河、长江两流域。到明季,情形却又不同了。长江以南,以湘江流域开辟为最早。汉时杂居诸异族,即已大略同化。其资、沅、澧三水流域,则是隋、唐、北宋之世,逐渐开辟的。1413年,当明成祖之世,贵州之地,始列为布政司。其后水西的安氏,水东的宋氏,播州的杨氏(水西、水东,系分辖贵阳附近新土司的。播州,今遵义县),亦屡烦兵力,然后戡定。而广西桂林的古田,平乐的府江,浔州的大藤峡,梧州的岑溪,明朝亦费掉很大的兵力。云南地方,自唐时,大理独立为国,到元朝才把它灭掉。其时云南的学校,还不知崇祀孔子,而崇祀晋朝的王羲之,货币则所用的是海。全省大都用土官,就正印是流官的,亦必以土官为之副。但自元朝创立土司制度以来,而我族所以管理西南诸族的,又进一步。其制:异族酋长归顺的,我都授以某某司的名目,如宣慰司、招讨司之类,此之谓土司。有反叛、虐民,或自相攻击的,则用政治手腕或兵力戡定,改派中国人治理其地,此之谓改土归流。明朝一朝,西南诸省,逐渐改流的不少,政治势力和人民的拓殖,都大有进步。所以到明末,已可用为抗敌的根据地。隆武帝亡后,明人立其弟聿于广州,旋为叛将李成栋所破。神宗之孙桂王由榔即位肇庆,是为永历帝,亦为成栋所迫,退至桂林。清又使降将孔有德、尚可喜、耿仲明下湖南,金声桓下江西。声桓、成栋旋反正。明兵乘机复湖南,川南、川东亦来归附。桂王一时曾有两广、云、贵、江西、湖南、四川七省之地,然声桓、成栋都系反复之徒,并无能力,不久即败,湖南亦复失。清兵且进陷桂林,永历帝逃到南宁,遣使封张献忠的余党孙可望为秦王。可望虽不过流寇,然其军队久经战阵,战斗力毕竟要强些。可望乃使其党刘文秀攻四川,吴三桂败走汉中。李定国攻桂林,孔有德伏诛。清朝乃派洪承畴守长沙,尚可喜守广东,又派兵驻扎保宁,以守川北,无意于进取了。而永历帝因可望跋扈,密召李定国,可望攻定国,大败,复降清。洪承畴因之请大举。1658年,清兵分三道入滇。定国扼北盘江力战,不能敌,乃奉永历帝走腾越,而伏精兵,大败清之追兵于高黎贡山。清兵乃还。定国旋奉永历帝入缅甸。1661年,吴三桂发大兵十万出边,缅甸人乃奉永历帝入三桂军。明年,被弑,明亡。当永历帝入缅时,刘文秀已前卒。定国和其党白文选崎岖缅甸,欲图恢复,卒皆赍志以终。定国等虽初为寇盗,而其晚节能效忠于国家、民族如此,真可使洪承畴、吴三桂等一班人愧死了。\n汉族在大陆上虽已无根据地,然天南片土,还有保存着上国的衣冠的,是为郑成功。郑成功为郑芝龙的儿子。芝龙本系海盗,受明招安的。清兵入闽时,芝龙阴行通款,以致隆武帝败亡。成功却不肯叛国,退据厦门,练兵造船为兴复之计。鲁王被清兵所袭,失去舟山,也是到厦门去依靠他的。清兵入滇时,成功曾大举入江,直迫江宁。后从荷兰人之手,夺取台湾,务农、训兵、定法律、设学校,俨然独立国的规模。清朝平定西南,本来全靠降将之力,所以事定之后,清朝并不能直接统治,乃封尚可喜于广东,耿仲明之子继茂于福建,吴三桂于云南,是为三藩。三藩中,吴三桂功最高,兵亦最强。1673年,尚可喜因年老,将兵事交给其儿子之信,反为所制,请求撤藩,清人许之。三桂和耿继茂的儿子耿精忠不自安,亦请撤藩,以觇朝意。时清世祖已死,子圣祖在位,年少气盛,独断许之,三桂遂叛清。耿、尚二藩亦相继举兵。清朝在西南,本无实力,三桂一举兵,而贵州、湖南、四川、广西俱下。但三桂暮气不振,既不能弃滇北上,想自出应援陕西响应的兵,又不及,徒据湖南,和清兵相持;耿、尚二藩,本来是反复无常的,此时苦三桂征饷,又叛降清,三桂兵势遂日蹙。1678年,三桂称帝于衡州。旋死,诸将乖离,其孙世璠,遂于1681年为清人所灭。清平定西南,已经出于意外了,如何再有余力,觊觎东南海外之地?所以清朝是时,已有和郑氏言和,听其不剃发,不易衣冠之意。但又有降将作祟。先是郑成功以1662年卒,子经袭,初和耿氏相攻,曾略得漳、泉之地。后并失厦门,退归台湾。其将施琅降清,清人用为提督。1681年,郑经卒,内部乖离。1683年,施琅渡海入台湾,郑氏亡。\n第三十一章 清代的盛衰 # 清朝的猾夏,是远较辽、金、元为甚的。这是因为女真民族,在渤海和金朝时,业已经过两度的开化,所以清朝初兴时,较诸辽、金、元,其程度已觉稍高了。当太宗时,已能任用汉人,且能译读《金世宗本纪》,戒谕臣下,勿得沾染华风。入关之后,圈占民地,给旗人住居,这也和金朝将猛安谋克户迁入中原,是一样的政策。他又命旗兵驻防各省,但多和汉人分城而居,一以免其倚势欺凌,挑起汉人的恶感,一亦防其与汉人同化。其尤较金人为刻毒的,则为把关东三省都封锁起来,禁止汉人移殖。他又和蒙古人结婚姻,而且表面上装作信奉喇嘛教,以联络蒙古的感情,而把蒙古也封锁起来,不许汉人移殖,这可称之为“联蒙制汉”政策。他的对待汉人,为前代异族所不敢行的,则为明目张胆,摧折汉人的民族性。从来开国的君主,对于前代的叛臣,投降自己的,虽明知其为不忠不义之徒,然大抵把这一层抹杀不提,甚且还用些能知天命,志在救民等好看的话头,替他掩饰,这个可说是替降顺自己的人,留些面子。清朝则不然,对于投顺它的人,特立贰臣的名目,把他的假面具都剥光了。康、雍、乾三朝,大兴文字之狱,以摧挫士气。乾隆时开四库馆,编辑四库全书,却借此大烧其书。从公元1763到1782年二十年之中,共烧书二十四次,被烧掉的书有五百三十八种,一万三千八百六十二部之多。不但关涉清朝的,即和辽、金、元等有关涉的,亦莫不加以毁灭。其不能毁灭的,则加以改窜。他岂不知一手不能掩尽天下目?他所造作的东西,并不能使人相信?此等行为,更不能使人心服?不过肆其狠毒之气,一意孤行罢了。他又开博学鸿词科,设明史馆,以冀网罗明季的遗民。然被其招致的,全是二等以下的人物,真正有志节的,并没有入他彀中的啊!\n从前的人民,对于政权,实在疏隔得太厉害了。所以当异族侵入的时候,民心虽然不服,也只得隐忍以待时,清初又是这时候了。从1683年台湾郑氏灭亡起,到1793年白莲教徒起兵和清朝反抗为止,凡一百一十年,海内可说无大兵革。清圣祖的为人,颇为聪明,也颇能勤于政治;就世宗也还精明。他们是一个新兴的野蛮民族,其骄奢淫逸,比之历年已久的皇室,自然要好些。一切弊政,以明末为鉴,自然也有相当的改良。所以康、雍之世,政治还算清明,财政亦颇有余蓄。到乾隆时,虽然政治业已腐败,社会的元气,亦已暗中凋耗了,然表面上却还维持着一个盛况。\n武功是时会之适然。中国的国情,是不适宜于向外侵略的。所以自统一以后,除秦、汉两朝,袭战国之余风,君主有好大喜功的性质,社会上亦有一部分人,喜欢立功绝域外,其余都是守御之师。不过因为国力的充裕,所以只要(一)在我的政治相当清明,(二)在外又无方张的强敌,即足以因利乘便,威行万里。历代的武功,多是此种性质,而清朝亦又逢着这种幸运了。蒙古和西藏的民族,其先都是喜欢侵略的。自唐中叶后,喇嘛教输入吐蕃,而西藏人的性质遂渐变。明末,俺荅的两个儿子侵入青海,其结果,转为青海地方的喇嘛教所感化,喇嘛教因此推行于蒙古,连蒙古人的性质,也渐趋向平和,这可说是近数百年来塞外情形的一个大转变。在清代,塞外的侵略民族,只剩得一个卫拉特了。而其部落较小,侵略的力量不足,卒为清人所摧破。这是清朝人的武功,所以能够煊赫一时的大原因。卫拉特即明代的瓦剌。当土木之变时,其根据地,本在东方。自蒙古复强,它即渐徙而西北。到清时,共分为四部:曰和硕特,居乌鲁木齐。曰准噶尔,居伊犁。曰杜尔伯特,居额尔齐斯河。曰土尔扈特,居塔尔巴哈台。西藏黄教的僧侣,是不许娶妻的。所以其高僧,世世以“呼毕勒罕”主持教务。因西藏人信之甚笃,教权在名义上,遂出于政权之上。然所谓迷信,其实不过是这么一句话。从古以来,所谓神权政府,都是建立在大多数被麻醉的人信仰之上的,然教中的首领,其实并不迷信,试看其争权夺利,一切都和非神权的政府无异可知。达赖喇嘛,是黄教之主宗喀巴的第一个大弟子,他在喇嘛教里,位置算是最高,然并不能亲理政务,政务都在一个称为“第巴”的官的手里。清圣祖时,第巴桑结,招和硕特的固始汗入藏,击杀了红教的护法藏巴汗,而奉宗喀巴的第二大弟子班禅入居札什伦布,是为达赖、班禅分主前后藏之始。和硕特自此徙牧青海,干涉西藏政权,桑结又恶之,招致准噶尔噶尔丹入藏,击杀了固始汗的儿子达颜汗。准噶尔先已慑服杜尔伯特,逐去土尔扈特,至此其势大张。1688年,越阿尔泰山攻击喀尔喀,三汗部众数十万,同时溃走漠南。清圣祖为之出兵击破噶尔丹。噶尔丹因伊犁旧地,为其兄子策妄阿布坦所据无所归,自杀。阿尔泰山以东平。固始汗的曾孙拉藏汗杀掉桑结。策妄阿布坦派兵入藏,袭杀拉藏汗。圣祖又派兵将其击破。1722年,圣祖死,世宗立。固始汗之孙罗卜藏丹津煽动青海的喇嘛反叛,亦为清兵所破。此时卫拉特的乱势,可谓蔓延甚广,幸皆未获逞志,然清朝亦未能犁庭扫穴。直至1754年,策妄阿布坦之子噶尔丹策凌死,其部落内乱,清高宗才于1757年将其荡平。至于天山南路,则本系元朝察哈尔后王之地,为回教区域。元衰后,回教教主的后裔,有入居喀什噶尔的,后遂握有南路政教之权。准部既平,教主的后裔大小和卓木(大和卓木名布罗尼特,小和卓木名霍集占)和清朝反抗,亦于1759年为清所破灭。清朝的武功,以此时为极盛。天山南北路既定,葱岭以西之国,敖罕、哈萨克、布鲁特、乾竺特、博罗尔、巴达克山、布哈尔、阿富汗等,都朝贡于清,仿佛唐朝盛时的规模。1792年,清朝又用兵于廓尔喀,将其征服,则其兵力又为唐时所未至。对于西南一隅,则清朝的武功,是掩耳盗铃的。当明初,中国西南的疆域,实还包括今伊洛瓦底江流域和萨尔温、眉公两江上游(看《明史·西南土司传》可知)。但中国对于西南,实力并不充足,所以安南暂合而复离,而缅甸亦卒独立为国。中国实力所及,西不过腾冲,南不越普洱,遂成为今日的境界了。1767年,清高宗因缅甸犯边,发兵征之败没。1769年,又派大兵再举,亦仅因其请和,许之而还。这时候,暹罗为缅甸所灭。后其遗臣中国人郑昭,起兵复国,传其养子郑华,以1786年受封于中国,缅甸怕中国和暹罗夹攻它,对中国才渐恭顺。\n安南之王黎氏,明中叶后为其臣莫氏所篡。清初复国。颇得其臣阮氏之力,而其臣郑氏,以国戚执政,阮氏与之不协,乃南据顺化,形同独立。后为西贡豪族阮氏所灭,是为新阮,而顺化之阮氏,则称旧阮。新阮既灭旧阮,又入东京灭郑氏,并废黎氏。黎氏遗臣告难中国,高宗于1788年为之出兵,击破新阮,复立黎氏。然旋为新阮所袭败,乃因新阮的请降,封之为王。总而言之,中国用兵于后印度,天时地利,是不甚相宜的,所以历代都无大功,到清朝还是如此。清朝用兵域外,虽不得利,然其在湘西、云、贵、四川各省,则颇能竟前代所未竟之功。在今湖南、贵州间,则开辟永顺、乾州、凤皇、永绥、松桃各府、厅,在云南,则将乌蒙、乌撒、东川、镇雄各土官改流(乌蒙,今云南昭通县。乌撒,今贵州威宁县),在贵州,则平定以古州为中心的大苗疆(古州,今榕江县),这都是明朝未竟的余绪。四川西北的大小金川(大金川,今理番县的绥靖屯。小金川,今懋功县),用兵凡五年,糜饷至七千万,可谓劳费已甚,然综合全局看起来,则于西南的开拓,仍有裨益。\n●乾隆像\n清朝的衰机,可说是起于乾隆之世的。高宗性本奢侈,在位时六次南巡,耗费无艺。中岁后又任用和珅,贪渎为古今所无。官吏都不得不剥民以奉之,上司诛求于下属,下属虐取于人民,于是吏治大坏。清朝历代的皇帝,都是颇能自握魁柄,不肯授权于臣下的。它以异族入主中原,汉族真有大志的人,本来未必帮它的忙。加以其予智自雄,折辱大臣,摧挫言路,抑压士气,自然愈形孤立了。所以到乾、嘉之间,而局面遂一变。\n第三十二章 中西初期的交涉 # 世界是无一息不变的,人,因其感觉迟钝,或虽有感觉而行为濡滞之故,非到外界变动,积微成著,使其感觉困难时,不肯加以理会,设法应付,正和我们住的屋子,非到除夕不肯加以扫除,以致尘埃堆积,扫除时不得不大费其力一样。这话,在绪论中,业已说过了。中国自有信史以来,环境可说未曾大变。北方的游牧民族,凭恃武力,侵入我国的疆域之内是有的,但因其文化较落后,并不能改变我们的生活方式,而且它还不得不弃其生活方式而从我,所以经过若干年之后,即为我们所同化。当其未被同化之时,因其人数甚少,其暴横和掠夺,也是有一个限度的,而且为时不能甚久。所以我们未曾认为是极大的问题,而根本改变我们的生活方式以应之。至于外国的文明,输入中国的,亦非无有。其中最亲切的,自然是印度的宗教。次之则是古希腊文明,播布于东方的,从中国陆路和西域交通,海路和西南洋交通以后,即有输入。其后大食的文明,输入中国的亦不少。但宗教究竟是上层建筑,生活的基础不变,说一种宗教,对于全社会真会有什么大影响,是不确的。所以佛教输入中国之后,并未能使中国人的生活印度化,反而佛教的本身,倒起了变化,以适应我们的生活了。读第五十四章所述可见。其余的文明,无论其为物质的、精神的,对社会上所生的影响,更其“其细已甚”。所以中国虽然不断和外界接触,而其所受的外来的影响甚微。至近代欧西的文明,乃能改变生活的基础,而使我们的生活方式,不得不彻底起一个变化,我们应付的困难,就从此开始了。但前途放大光明、得大幸福的希望,亦即寄托在这个大变化上。\n西人的东来,有海、陆两路。而海路又分两路:一、自大西洋向东行,于公元1516年绕过好望角,自此而至南洋、印度及中国。二、自大西洋向西行,于1492年发现美洲,1519年环绕地球,其事都在明武宗之世。初期在海上占势力的是西、葡,后来英、荷继起,势力反驾乎其上。但其在中国,因葡萄牙人独占了澳门之故,势力仍能凌驾各国,这是明末的情形。清初,因与荷兰人有夹攻台湾郑氏之约,许其商船八年一到广东,然其势力,亦远非葡萄牙之敌。我们试将较旧的书翻阅,说及当时所谓洋务时,总是把“通商传教”四字并举的。的确,我们初期和西洋人的接触,不外乎这两件事。通商本两利之道,但这时候的输出入品,还带有奢侈性质,并非全国人所必需,而近世西人的东来,我们却自始对他存着畏忌的心理。这是为什么呢?其一、中国在军事上,是畏恶海盗的。因为从前的航海之术不精,对海盗不易倾覆其根据地,甚而至于不能发现其根据地。二、中国虽发明火药,却未能制成近世的枪炮。近世的枪炮,实在是西人制成的,而其船舶亦较我们的船舶为高大,军事上有不敌之势。三、西人东来的,自然都是些冒险家,不免有暴横的行为。而因传教,更增加了中国畏忌的心理。近代基督教的传布于东方,是由耶稣会(Jesuit)开始的。其教徒利玛窦(Matteo Ricci),以1581年始至澳门,时为明神宗万历五年。后入北京朝献,神宗许其建立天主堂。当时基督教士的传教,是以科学为先驱;而且顺从中国的风俗,不禁华人祭天、祭祖、崇拜孔子的。于是在中国的反应,发生两派:其一如徐光启、李之藻等,服膺其科学,因而亦信仰其宗教。其二则如清初的杨光先等,正因其人学艺之精,传教的热烈,而格外引起其猜忌之心。在当时,科学的价值,不易为一般人所认识,后一派的见解,自然容易得势。但是输入外国的文明,在中国亦由来已久了。在当时,即以历法疏舛,旧有的回回历法,不如西洋历法之精,已足使中国人引用教士,何况和满洲人战争甚烈,需要教士制造枪炮呢?所以1616年,基督教一度被禁止传播后,到1621年,即因命教士制造枪炮而复解禁。后更引用其人于历局。清初,汤若望(Joannes Adams Schall Von Bell)亦因历法而被任用。圣祖初年,为杨光先所攻击,一时失势。其后卒因旧法的疏舛,而南怀仁(Ferdinandus Verbiest)复见任用。圣祖是颇有科学上的兴趣的,在位时引用教士颇多,然他对于西洋人,根本上仍存着一种畏恶的心理。所以在他御制的文集里,曾说“西洋各国,千百年后,中国必受其累”。这在当时的情势下,亦是无怪其然的。在中国一方面,本有这种心理潜伏着,而在西方,适又有别一派教士,攻击利玛窦一派于教皇,说他们卖教求荣,容许中国的教徒崇拜偶像。于是教皇派多罗(Tourmon)到中国来禁止。这在当时的中国,如何能说得明白?于是圣祖大怒,将多罗押还澳门,令葡萄牙人看管,而令教士不守利玛窦遗法的都退出(教皇仍不变其主张,且处不从令的教士以破门之罚。教士传教中国者,遂不复能顺从中国人的习惯,此亦为中西隔阂之一因)。至1717年,碣石镇总兵陈昂说:“天主教在各省开堂聚众,广州城内外尤多,恐滋事端,请严旧例严禁”,许之。1723年,闽浙总督满保请除送京效力人员外,概行安置澳门;各省天主堂,一律改为公廨,亦许之。基督教自此遂被禁止传布,然其徒之秘密传布如故。中国社会上,本有一种所谓邪教,其内容仅得之于传说,是十分离奇的(以此观之,知历来所谓邪教者的传说,亦必多诬蔑之辞),至此,遂将其都附会到基督教身上去;再加以后来战败的耻辱,因战败而准许传教,有以兵力强迫传布的嫌疑,遂伏下了几十年教案之根。至于通商,在当时从政治上看起来,并没有维持的必要。既有畏恶外人的心理,就禁绝了,也未为不可的。但这是从推理上立说,事实上,一件事情的措置,总是受有实力的人的意见支配的。当时的通商,虽于国计民生无大关系,而在官和商,则都是大利之所在,如何肯禁止?既以其为私利所在而保存之,自然对于外人,不肯不剥削,就伏下了后来五口通商的祸根。海路的交通,在初期,不过是通商传教的关系,至陆路则自始即有政治关系。北方的侵略者,乃蒙古高原的民族,而非西伯利亚的民族,这是几千年以来,历史上持续不变的形势。但到近代欧洲的势力向外发展时,其情形也就变了。15世纪末叶,俄人脱离蒙古的羁绊而自立。其时可萨克族又附俄(Kazak,即哥萨克),为之东略。于是西伯利亚的广土,次第被占。至明末,遂达鄂霍次克海,骚扰且及于黑龙江。清初因国内未平,无暇顾及外攘,至三藩既平,圣祖乃对外用兵。其结果,乃有1688年的《尼布楚条约》,订定西以额尔古讷河,东自格尔必齐河以东,以外兴安岭为界,俄商得三年一至京师。此约中国得地极广,然俄人认为系用兵力迫胁而成,心怀不服,而中国对边陲,又不能实力经营,遂伏下咸丰时戊午、庚申两约的祸根。当《尼布楚条约》签订时,中、俄的边界问题,还只限于东北方面。其后外蒙古归降中国(前此外蒙古对清,虽曾通商,实仅羁縻而已),于是俄、蒙的界务,亦成为中、俄的界务。乃有1727年的《恰克图条约》,规定额尔古讷河以西的边界,至沙宾达巴哈为止。自此以西,仍属未定之界。至1755、1759两年,中国次第平定准部、回部,西北和俄国接界处尤多,其界线问题,亦延至咸丰时方才解决。\n●鸦片战争过程图\n近代欧人的到广东来求通商,事在1516年,下距五口通商时,业经三百余年了。但在五口通商以前,中国讫未觉得其处于另一个不同的世界中,还是一守其闭关独立之旧。清开海禁,事在1685年,于澳门、漳州、宁波、云台山设关四处。其后宁波的通商,移于定海,而贸易最盛于广东。当时在中国方面,贸易之权,操于公行之手,剥削外人颇深。外人心抱不平,乃舍粤而趋浙。1758年,清高宗又命把浙海关封闭,驱归广东,于是外人之不平更甚。英国曾于1792、1810年两次派遣使臣到中国,要求改良通商办法,均未获结果。其时中国官吏并不能管理外人,把其事都交给公行。官吏和外人的交涉,一切都系间接。自1781年以后,英国在中国的贸易,为东印度公司所专。其代理人,中国谓之大班,一切交涉,都是和他办的。1834年,公司的专利权被废止。中国说散商不便制驭,传令其再派大班。英人先后派商务监督和领事前来,中国都仍认为是大班,官厅不肯和他平等交涉。适会鸦片输入太甚,因输出入不相抵,银之输出甚多。银在清朝是用为货币的,银荒既甚,财政首受其影响。遂有1839年林则徐的烧烟,中、英因此酿成战衅,其结果,于1842年在南京订立条约。中国割香港,开广州、厦门、福州、宁波、上海五口通商。废除行商。中、英两国官员,规定了交际礼节。于是前此以天朝自居,英国人在陆上无根据地,及贸易上的制限都除去了。英约定后,法、美、瑞典,遂亦相继和中国立约。惟俄人仍不许在海口通商。中西积久的隔阂,自非用兵力迫胁,可以解除于一时。于是又有1857年的冲突。广州失陷,延及京、津。清文宗为之出奔热河。其结果,乃有1858年和1860年《天津》、《北京》两条约。此即所谓咸丰戊午、庚申之役。此两次的英、法条约,系将五口通商以后外人所得的权利,作一个总结束的。领事裁判,关税协定,内地通商及游历、传教,外国派遣使臣,都在此两约中规定。美国的《天津条约》,虽在平和中交换,然因各约都有最惠国条款,所以英、法所享的权利,美国亦不烦一兵而得享之。至于俄国,则自19世纪以还,渐以实力经营东方。至1850年顷,黑龙江北之地,实际殆已尽为所据。至1858年,遂迫胁黑龙江将军奕山,订立《瑷珲条约》,尽割黑龙江以北,而将乌苏里江以东之地,作为两国共管。1860年,又藉口调停英、法战事,再立《北京条约》,并割乌苏里江以东。而西北边界,应当如何分划,亦在此约中规定了一个大概。先是伊犁和塔尔巴哈台方面,已许俄国通商,至是再开喀什噶尔,而海口通商及传教之权,亦与各国一律。而且规定俄人得由恰克图经库伦、张家口进京。京城和恰克图间的公文,得由台站行走。于是蒙古、新疆的门户,亦洞开了。总而言之:自1838年林则徐被派到广东查办海口事件起,至1860年各国订立《北京条约》为止,中国初期与外国交涉的问题,告一结束。其所涉及的,为:(一)西人得在海口通商,(二)赴内地通商、游历、传教,(三)税则,(四)审判,(五)沿海航行,(六)中俄陆路通商,及(七)边界等问题。\n第三十三章 汉族的光复运动 # 一个民族,进步到达于某一程度之后,就决不会自忘其为一个独立的民族了。虽然进化的路径,是曲线的,有时不免暂为它族所压服。公元1729,即清世宗的雍正七年,曾有过这样一道上谕。他说:“从前康熙年间,各处奸徒窃发,辄以朱三太子为名,如一念和尚、朱一贵者,指不胜屈。近日尚有山东人张玉,假称朱姓,托于明之后裔,遇星士推算有帝王之命,以此希冀蛊惑愚民,现被步军统领拿获究问。从来异姓先后继统,前朝之宗姓,臣服于后代者甚多,否则隐匿姓名,伏处草野,从未有如本朝奸民,假称朱姓,摇惑人心,若此之众者。似此蔓延不息,则中国人君之子孙,遇继统之君,必至于无噍类而后已,岂非奸民迫之使然乎?”这一道上谕,是因曾静之事而发的。曾静是湖南人,读浙江吕留良之书,受着感动,使其徒张熙往说岳钟琪叛清,钟琪将其事举发。吕留良其时已死,因此遭到了剖棺戮尸之祸。曾静、张熙暂时免死拘禁,后亦被杀。这件事,向来被列为清朝的文字狱之一,其实乃是汉族图谋光复的实际行动,非徒文字狱而已。1729年,为亡清入关后之八十六年,表面上业已太平,而据清世宗上谕所说,则革命行动的连续不绝如此,可见一部分怀抱民族主义的人,始终未曾屈服了。怀抱民族主义的人,是中下流社会中都有的。中流社会中人的长处,在其知识较高,行动较有方策,且能把正确的历史知识,留传到后代,但直接行动的力量较弱。下流社会中人,直接行动的力量较强,但其人智识缺乏,行动起来,往往没有适当的方策,所以有时易陷于失败,甚至连正确的历史,都弄得缪悠了。清朝最大的会党,在北为哥老会,在南为天地会,其传说大致相同。天地会亦称三合会,有人说就是三点会,南方的清水、匕首、双刀等会,皆其支派。据它们的传说:福建莆田县九连山中,有一个少林寺。僧徒都有武艺,曾为清征服西鲁国,后为奸臣所谗,清主派兵去把他们剿灭。四面密布火种,缘夜举火,想把他们尽行烧死。有一位神道,唤做达尊,使其使者朱开、朱光,把十八个和尚引导出来。这十八个和尚,且战且走,十三个战死了。剩下来的五个,就是所谓前五祖。又得五勇士和后五祖为辅,矢志反复汨。就是清字,汨就是明字,乃会中所用的秘密符号。他们自称为洪家。把洪字拆开来则是三八二十一,他们亦即用为符号。洪字大约是用的明太祖开国的年号洪武;或者洪与红同音,红与朱同色,寓的明朝国姓的意思,亦未可知。据他们的传说:他们会的成立,在1674年。曾奉明思宗之裔举兵而无成,乃散而广结徒党,以图后举。此事见于日本平山周所著的《中国秘密社会史》(平山周为中山先生的革命同志,曾身入秘密社会,加以调查)。据他说:“后来三合会党的举事,连续不绝。其最著者,如1787,即清高宗乾隆五十二年台湾林爽文之变便是。1832,即宣宗道光十二年,两广、湖南的瑶乱,亦有三合会党在内。鸦片战争既起,三合会党尚有和海峡殖民地的政府接洽,图谋颠覆清朝的。”其反清复明之志,可谓终始不渝了。而北方的白莲教徒的反清,起于1793年,即乾隆五十八年,蔓延四川、湖北、河南、陕西四省,至1804年,即仁宗嘉庆九年而后平定,此即向来的史家称为川楚教匪,为清朝最大的内乱之始的,其所奉的王发生,亦诈称明朝后裔,可见北方的会党,反清复明之志,亦未尝变。后来到1813年,即嘉庆十八年,又有天理教首林清,图谋在京城中举事,至于内监亦为其内应,可见其势力之大。天理教亦白莲教的支派余裔,又可见反清复明之志,各党各派,殊途同归了。而其明目张胆,首传讨胡之檄的则为太平天国。\n太平天国天王洪秀全,系广东花县人,生于1812年,恰在民国纪元之前百年。结合下流社会,有时是不能不利用宗教做工具的。广东和外人交通早,所以天王所创的宗教,亦含有西教的意味。他称耶和华为天父,基督为天兄,而己为其弟。乘广西年饥盗起,地方上有身家的人所办的团练,和贫苦的客民冲突,以1850年,起事于桂平的金田村。明年,下永安,始建国号。又明年,自湖南出湖北,沿江东下。1853年,遂破江宁,建都其地,称为天京。当天国在永安时,有人劝其北出汉中,以图关中;及抵武、汉时,又有人劝其全军北上,天王都未能用。既据江宁,耽于声色货利,不免渐流于腐败。天王之为人,似只长于布教,而短于政治和军事。委政于东王杨秀清,尤骄恣非大器。始起诸王,遂至互相残杀。其北上之军,既因孤行无援,而为清人所消灭。溯江西上之兵,虽再据武、汉,然较有才能的石达开,亦因天京的政治混乱,而和中央脱离了关系。清朝却得曾国藩,训练湘军,以为新兴武力的中坚。后又得李鸿章,招募淮军,以为之辅。天国徒恃一后起之秀的李秀成,只身支柱其间,而其余的政治军事,一切都不能和他配合。虽然兵锋所至达十七省(内地十八省中,惟甘肃未到),前后共历十五年,也不得不陷于灭亡的悲运了。太平天国的失败,其责实不在于军事而在于政治。它的兵力,是够剽悍的。其扎实垒、打死仗的精神,似较之湘、淮军少逊,此乃政治不能与之配合之故,而不能悉归咎于军事。若再推究得深些,则其失败,亦可以说是在文化上。一、社会革命和政治革命,很不容易同时并行,而社会革命,尤其对社会组织,前因后果,要有深切的认识,断非头脑简单,手段灭裂的均贫富主义所能有济。中国的下流社会中人,是向来有均贫富的思想的,其宗旨虽然不错,其方策则决不能行。今观太平天国所定的把天下田亩,按口均分;二十五家立一国库,婚丧等费用,都取给国库,私用有余,亦须缴入国库等,全是极简单的思想,极灭裂的手段。知识浅陋如此,安能应付一切复杂的问题?其政治的不免于紊乱,自是势所必然了。二、满洲人入据中原,固然是中国人所反对,而是时西人对中国,开始用兵力压迫,亦为中国人所深恶的,尤其是传教一端,太平天国初起时,即发布讨胡之檄。“忍令上国衣冠,沦于夷狄?相率中原豪杰,还我河山”,读之亦使人气足神王。倘使他们有知识,知道外力的压迫,由于满清的失政,郑重提出这一点,固能得大多数人的赞成;即使专提讨胡,亦必能得一部分人的拥护。而他们后来,对此也模糊了,反而到处传播其不中不西的上帝教,使反对西教的士大夫,认他为文化上的大敌,反而走集于清朝的旗帜之下。这是太平天国替清朝做了掩蔽,而反以革命的对象自居,其不能成事,实无怪其然了。湘、淮军诸将,亦是一时人杰。并无一定要效忠于满清的理由,他们的甘为异族作伥,实在是太平天国的举动,不能招致豪杰,而反为渊殴鱼。所以我说它政治上的失败,还是文化上的落后。\n和太平天国同时的,北方又有捻党,本蔓延于苏、皖、鲁、豫四省之间。1864年,天国亡,余众多合于捻,而其声势乃大盛。分为东西两股。清朝任左宗棠、李鸿章以攻之。至1867、1868两年,然后先后平定。天国兵锋,侧重南方,到捻党起,则黄河流域各省,亦无不大被兵灾了,而回乱又起于西南,而延及西北。云南的回乱,起于1855年,至1872年而始平,前后共历十八年。西北回乱,则起于1862年,自陕西延及甘肃,并延及新疆。浩罕人借兵给和卓木的后裔,入据喀什喀尔。后浩罕之将阿古柏帕夏杀和卓木后裔而自立,意图在英、俄之间,建立一个独立国。英、俄都和他订结通商条约,且曾通使土耳其。英使且力为之请,欲清人以天山南北路之地封之。清人亦有以用兵劳费,持是议者。幸左宗棠力持不可。西捻既平之后,即出兵以攻叛回。自1875至1878,前后共历四年,而南北两路都平定。阿古柏帕夏自杀。当回乱时,俄人虽乘机占据伊犁,然事定之后,亦获返还。虽然划界时受损不少,西北疆域,大体总算得以保全。\n清朝的衰机,是潜伏于高宗,暴露于仁宗,而大溃于宣宗、文宗之世的。当是时,外有五口通商和咸丰戊午、庚申之役,内则有太平天国和捻、回的反抗,几于不可收拾了。其所以能奠定海宇,号称中兴,全是一班汉人,即所谓中兴诸将,替它效力的。清朝从道光以前,总督用汉人的很少,兵权全在满族手里。至太平天国兵起,则当重任的全是汉人。文宗避英法联军,逃奔热河,1861年,遂死于其地。其时清宗室中,载垣、端华、肃顺三人握权。载垣、端华亦是妄庸之徒,肃顺则颇有才具,力赞文宗任用汉人,当时内乱的得以削平,其根基实定于此。文宗死,子穆宗立。载垣、端华、肃顺等均受遗诏,为赞襄政务大臣。文宗之弟恭亲王奕,时留守京师,至热河,肃顺等隔绝之,不许其和文宗的皇后钮钴禄氏和穆宗的生母叶赫那拉氏相见。后来不知如何,奕终得和她们相见了,密定回銮之计。到京,就把载垣、端华、肃顺都杀掉。于是钮钴禄氏和叶赫那拉氏同时垂帘听政(钮钴禄氏称母后皇太后,谥孝贞。叶赫那拉氏称圣母皇太后,死谥孝钦。世称孝贞为东宫太后,孝钦为西宫太后),钮钴禄氏是不懂得什么的,大权都在叶赫那拉氏手里。叶赫那拉氏和肃顺虽系政敌,对于任用汉人一点,却亦守其政策不变,所以终能削平大难。然自此以后,清朝的中央政府即无能为,一切内政、外交的大任,多是湘、淮军中人物,以疆臣的资格决策或身当其冲。军机及内阁中,汉人的势力亦渐扩张。所以在这个时候,满洲的政权,在实际上已经覆亡了,只因汉人一方面,一时未有便利把它推倒,所以名义又维持了好几十年。\n第三十四章 清朝的衰乱 # 太平天国既亡,捻、回之乱复定,清朝一时号称中兴。的确,遭遇如此大难,而一个皇室,还能维持其政权于不敝的,在历史上亦很少见。然清室的气运,并不能自此好转,仍陵夷衰微以至于覆亡,这又是何故呢?这是世变为之。从西力东侵以后,中国人所遭遇到的,是一个旷古未有的局面,决非任何旧方法所能对付。孝钦皇后,自亦有其相当的才具,然她的思想是很陈旧的。试看她晚年的言论,还时时流露出道、咸时代人的思想来可知。大约她自入宫以后,就和外边隔绝了,时局的真相如何,她是不得而知的。她的思想,比较所谓中兴名臣,还要落后许多。当时应付太平天国,应付捻、回,所用的都是旧手段,她是足以应付的。内乱既定之后,要进而发愤自强,以御外患,就非她所能及了。不但如此,即当时所谓中兴名臣,要应付这时候的时局,也远觉不够。他们不过任事久了,经验丰富些,知道当时的一种迂阔之论不足用,他们亦觉得中国所遭遇的,非复历史上所有的旧局面,但他们所感觉到的,只是军事。因军事而牵及于制造,因制造而牵及于学术,如此而已。后来的人所说的“西人自有其立国之本,非仅在械器之末”,断非这时候的人所能见得到的,这亦无怪其然。不但如此,在当时中兴诸将中,如其有一个首领,像晋末的宋武帝一般,入据中央,大权在握,而清朝的皇帝,仅保存一个名义,这一个中央政府,又要有生气些。而无如中兴诸将,地丑德齐,没有这样的一个人物。而且他们多数是读书人,既有些顾虑君臣的名义,又有些顾虑到身家、名誉,不敢不急流勇退。清朝对于汉人,自然也不敢任之过重。所以当时主持中枢的,都是些智识不足、软弱无力,甚至毫无所知之人。士大夫的风气,在清时本是近于阘茸而好利的。湘军的中坚人物,一时曾以坚贞任事的精神为倡。然少数人的提倡,挽回不过积重的风气来,所以大乱平定未久,而此种精神,即已迅速堕落。官方士习,败坏如故。在同、光之世,曾产生一批所谓清流,喜唱高调,而于事实茫无所知,几于又蹈宋、明人的覆辙。幸而当时的情势,不容这一种人物发荣滋长,法越之役,其人有身当其冲而失败的,遂亦销声匿迹了,而士大夫仍成为一奄奄无气的社会。政府和士大夫阶级,其不振既如此,而宫廷之间,又发生了变故。清穆宗虽系孝钦后所生,顾与孝钦不协。立后之时,孝贞、孝钦,各有所主。穆宗顺从了孝贞的意思。孝钦大怒,禁其与后同居。穆宗郁郁,遂为微行,致疾而死。醇亲王奕之妻,为孝钦后之妹,孝钦因违众议立其子载湉,是为德宗。年方四岁,两宫再临朝。后孝贞后忽无故而死,孝钦后益无忌惮。宠任宦官,骄淫奢侈,卖官鬻爵,无所不为。德宗亲政之后,颇有意于振作,而为孝钦所扼,母子之间,嫌隙日深,就伏下戊戌政变的根源了。\n内政的陵夷如此,外交的情势顾日急。中国历代所谓藩属,本来不过是一个空名,实际上得不到什么利益的。所以论政之家,多以疲民力、勤远略为戒。但到西力东侵以来,情形却不同了。所谓藩属,都是屏蔽于国境之外的,倘使能够保存,敌国的疆域,即不和我国直接,自然无所肆其侵略。所以历来仅有空名的藩属,到这时候,倒确有藩卫的作用了。但以中国外交上的习惯和国家的实力,这时候,如何说得上保存藩属?于是到19世纪,而朝贡于中国之国,遂悉为列强所吞噬。我们现在先从西面说起:哈萨克和布鲁特,都于公元1840年顷,降伏于俄。布哈尔、基华,以1873年,沦为俄国的保护国。浩罕以1876年为俄所灭。巴达克山以1877年受英保护。乾竺特名为两属,实际上我亦无权过问。于是自葱岭以西朝贡之国尽了。其西南,则哲孟雄,当英法联军入北京之年,英人即在其境内获得铁路敷设权。缅甸更早在1826和1851年,和英人启衅战败,先后割让阿萨密、阿剌干、地那悉林及白古,沿海菁华之地都尽。安南旧阮失国后,曾介教士乞援于法。后来乘新阮之衰,借暹罗之助复国,仍受封于中国,改号为越南。当越南复国时,法国其实并没给与多大的助力。然法人的势力,却自此而侵入,交涉屡有葛藤。至1874年,法人遂和越南立约,认其为自主之国。我国虽不承认,法国亦置诸不理。甚至新兴的日本,亦于1879年将自明清以来受册封于中国的琉球灭掉。重大的交涉,在西北,则有1881年的《伊犁条约》。当回乱时,伊犁为俄国所据,中国向其交涉,俄人说:不过代中国保守,事定即行交还的。及是,中国派了一个昏聩糊涂的崇厚去,只收回了一个伊犁城,土地割弃既多,别种权利,丧失尤巨。中国将崇厚治罪,改派了曾纪泽,才算把地界多收回了些,别种条件,亦略有改正。然新疆全境,都准无税通商;肃州、吐鲁番,亦准设立领事;西北的门户,自此洞开了。在西南,则英国屡求派员自印度经云南入西藏探测,中国不能拒,许之。1857年,英人自印度实行派员入滇,其公使又遣其参赞,自上海至云南迎接。至腾越,为野人所杀。其从印度来的人员,亦被人持械击阻。这件事,云贵总督岑毓英,实有指使的嫌疑,几至酿成重大的交涉。次年,乃在芝罘订立条约:允许滇、缅通商,并开宜昌、芜湖、温州、北海为商埠。许英国派员驻扎重庆,察看商务情形,俟轮船能开抵时,再议开埠事宜。此为西人势力侵入西南之始。至1882年,而法、越的战事起。我兵初自云南、广西入越的都不利,海军亦败于福州。然后来冯子材有镇南关之捷,乘势恢复谅山。法人是时的情形,亦未能以全力作战,实为我国在外交上可以坚持的一个机会,但亦未能充分利用。其结果,于1885年,订立条约,承认法国并越,并许在边界上开放两处通商(后订开龙州、蒙自、蛮耗。1895年之约,又订以河口代蛮耗,增开思茅)。英人乘机,于1885年灭缅甸,中国亦只得于其明年立约承认。先是《芝罘条约》中,仍有许英人派员入藏的条款,至是,中国乘机于《缅约》中将此款取消。然及1888年,英、藏又在哲孟雄境内冲突,至1890年,中国和英人订立《藏印条约》,遂承认哲孟雄归英保护。1893年,续议条约,复订开亚东关为商埠,而藏人不肯履行,又伏下将来的祸根。\n对外交涉的历次失败,至1894年中日之战而达于极点。中、日两国,同立国于东方,在历史上的关系,极为深切,当西力东侵之际,本有合作御侮的可能。但这时候,中国人对外情太觉隔阂,一切都不免以猜疑的态度出之,而日方则褊狭性成,专务侵略,自始即不希望和中国合作。中、日的订立条约,事在1871年。领判权彼此皆有。进口货物,按照海关税则完纳,税则未定的,则值百抽五,亦彼此所同。内地通商,则明定禁止。在中国当日,未始不想借此为基本,树立一改良条约之基,然未能将此意开诚布公,和日本说明。日本则本不想和中国合作,而自始即打侵略的主意,于是心怀不忿。至1874年,因台湾生番杀害日本漂流的人民,径自派兵前往攻击。1879年,又灭琉球。交涉屡有葛藤,而衰微不振的朝鲜,适为日本踏上大陆的第一步,遂成为中、日两国权利冲突的焦点。1894年,日人预备充足,蓄意挑衅,卒至以兵戎相见。我国战败之后,于其明年,订立《马关条约》。除承认朝鲜自主外,又割台湾和辽东半岛,赔款至二万万两。改订通商条约,悉以中国和泰西各国所定的约章为准,而开辟沙市、重庆、苏州、杭州为商埠,日人得在通商口岸从事于制造,则又是泰西各国所求之历年,而中国不肯允许的。此约既定之后,俄国联合德、法,加以干涉,日人乃加索赔款三千万两,而将辽东还我。因此而引起1896年的《中俄密约》,中国许俄国将西伯利亚铁路经过黑、吉两省而达到海参崴。当时传闻,俄国还有租借胶州湾的密约,于是引起德国的强占胶州湾,而迫我立九十九年租借之约,并获得建造胶济铁路之权。俄人因此而租借旅、大,并许其将东省铁路,展筑一支线。英人则租借威海卫,法人又租借广州湾。我国沿海业经经营的军港,就都被占据了。其在西南:则法国因干涉还辽之事,向我要索报酬。于1895年订立《续议界务商务专条》,云南、两广开矿时,许先和法人商办。越南已成或拟设的铁路,得接至中国境内。并将前此允许英国不割让他国的孟连、江洪的土地,割去一部分。于是英国再向我国要求,于1897年,订立《中缅条约附款》。云南铁路,允与缅甸连接,而开放三水、梧州和江根墟。外人的势力,侵入西南益深了。又自俄、德两国,在我国获得铁路敷设权以来,各国亦遂互相争夺。俄人初借比国人出面,获得芦汉铁路的敷设权。英人因此要求津镇、河南到山东、九广、浦信、苏杭甬诸路。俄国则要求山海关以北铁路,由其承造。英国又捷足先得,和中国订定了承造牛庄至北京铁路的合同。英、俄旋自相协议,英认长城以北的铁路,归俄承造,俄人则承认长江流域的铁路,归英承造。英、德又自行商议,英认山西及自山西展筑一路至江域外,黄河流域的铁路归德,德认长江流域的铁路归英。凡铁路所至之处,开矿之权利亦随之。各国遂沿用分割非洲时的手段,指我国之某处,为属于某国的势力范围,而要求我以条约或宣言,承认其地不得割让给别国。于是瓜分之论,盛极一时,而我国人亦于其时警醒了。\n第三十五章 清朝的覆亡 # 自西力东侵,而中国人遭遇到旷古未有的变局。值旷古未有的变局,自必有非常的手段,然后足以应付之,此等手段,自非本来执掌政权的阶级所有,然则新机从何处发生呢?其一起自中等阶级,以旧有的文化为根柢的,是为戊戌维新。其二以流传于下级社会中固有的革命思想为渊源,采取西洋文化,而建立成一种方案的,则为辛亥革命。戊戌变法,康有为是其原动力。康有为的学问,是承袭清代经学家今文之学的余绪,而又融合佛学及宋、明理学而成的。一、因为他能承受今文之学的“非常异义”,所以能和西洋的民主主义接近。二、因为他能承受宋学家彻底改革的精神,所以他的论治,主于彻底改革,主张设治详密,反对向来“治天下不如安天下,安天下不如与天下安”的苟简放任政策。三、主张以中坚阶级为政治的重心,则士大夫本该有以天下为己任的大志,有互相团结的精神。宋、明人的讲学,颇有此种风概。入清以来,内鉴于讲学的流弊,外慑于异族的淫威,此等风气,久成过去了。康有为生当清代威力已衰,政令不复有力之时,到处都以讲学为事。他的门下,亦确有一班英多磊落之才。所以康有为的学问及行为,可以说是中国旧文化的复活。他当甲午战前,即已上书言事。到乙未之岁,中、日议和的时候,他又联合入京会试的举人,上书主张迁都续战,因陈变法自强之计。书未得达。和议成后,他立强学会于北京,想联合士大夫,共谋救国。会被封禁,其弟子梁启超走上海,主持《时务报》旬刊,畅论变法自强之义。此报一出,风行海内,而变法维新,遂成为一时的舆论。康有为又上书两次。德占胶州湾时,又入京陈救急之计。于是康有为共上书五次,只一次得达。德宗阅之,颇以为然。岁戊戌,即1898年,遂擢用有为等以谋变法。康有为的宗旨,在于大变和速变。大变所以谋全盘的改革,速变则所以应事机而振精神。他以为变法的阻力,都是由于有权力的大臣,欲固其禄位之私,于是劝德宗勿去旧衙门,但设新差使。他以为如此即可减少阻力。但阻碍变法的,固非尽出于保存禄位之私;即以保存禄位论,权已去,利亦终不可保,此固不足以安其心。何况德宗和孝钦后素有嫌隙,德宗又向来无权?于是有戊戌的政变。政变以后,德宗被幽,有为走海外,立保皇党,以推翻孝钦后,扶德宗亲政相号召。然无拳无勇,复何能为?而孝钦后以欲捕康、梁不得;欲废德宗,又为公使所反对,迁怒及于外人。其时孝钦后立端郡王载漪之子溥儁为大阿哥,载漪因急欲其子正位,宗戚中亦有附和其事,冀立拥戴之功的。而极陈旧的,“只要中国人齐心,即可将外国人尽行逐去,回复到闭关时代之旧”的思想,尚未尽去。加以下层社会中人,身受教案切肤之痛,益以洋人之强惟在枪炮,而神力可以御枪炮之说,遂至酿成1900年间义和团之乱。亲贵及顽固大臣,因欲加以利用,乃有纵容其在京、津间杀教士,焚教堂,拆铁路,倒电杆,见新物则毁,见用洋货的人则杀的怪剧。并伪造外人的要求条件,以恐吓孝钦后,而迫其与各国同时宣战。意欲于乱中取利,废德宗而立溥儁。其结果,八国联军入京城,德宗及孝钦后走西安。1901年的和约,赔款至四百五十兆。京城通至海口路上的炮台,尽行拆去。且许各国于其通路上驻兵。又划定使馆区域,许其自行治理、防守。权利之丧失既多,体面亦可谓丧失净尽了。是时东南诸督抚,和上海各领事订立互保之约,不奉北京的伪令。虽得将战祸范围缩小,然中央的命令,自此更不行于地方了。而黑龙江将军又贸然向俄人启衅,致东三省尽为俄人所占。各国与中国议和时,俄人说东三省系特别事件,不肯并入和约之中讨论,幸保完整的土地,仍有不免于破碎之势。庚子一役闯出的大祸如此。而孝钦后自回銮以后,排外变而为媚外;前此之力阻变革者,至此则变为貌行新政,以敷衍国民。宫廷之中,骄奢淫逸,朝廷之上,昏庸泄沓如故。满清政府至此,遂无可维持,而中国国民,乃不得不自起而谋政治的解决。\n19世纪之末,瓜分之论,盛极一时,已见上章。1899年,美国国务卿海约翰氏(John Hay)乃通牒英、俄、法、德、意、日六国,提出门户开放主义。其内容为:一、各国对于中国所获得的利益范围或租借地域,或他项既得权利,彼此不相干涉。二、各国范围内各港,对他国入港商品,都遵守中国现行海关税率,课税由中国征收。三、各国范围内各港,对他国船舶所课入口税,不得较其本国船舶为高,铁路运费亦然。这无非要保全其在条约上既得的权利。既要保全条约上的权利,自然要连带而及于领土保全,因为领土设或变更,既成的条约,在该被变更的领土上,自然无效了。六国都覆牒承认。然在此时,俄国实为侵略者,逮东三省被占而均势之局寝破。此时英国方有事于南非,无暇顾及东方,乃和德国订约,申明门户开放、领土保全之旨。各国都无异议。惟俄人主张其适用限于英、德的势力范围。英国力持反对。德国和东方,关系究竟较浅,就承认俄国人的主张了。于是英国觉得在东方,要和俄国相抗,非有更强力的外援不可,乃有1902年的英、日同盟。俄国亦联合法国,发表宣言,说如因第三国的侵略或中国的扰乱,两国利益受到侵害时,应当协力防卫。这时候,日本对于我国东北的利害,自然最为关切,然尚未敢贸然与俄国开战,乃有满、韩交换之论。大体上,日本承认俄国在东三省的权利,而俄人承认日本在韩国的权利。而俄人此时甚骄,并此尚不肯承认,其结果,乃有1904年的日俄战争。俄国战败,在美国的朴资茅斯,订立和约。俄人放弃在韩国的权利,割库页岛北纬五十度以南之地与日。除租借地外,两国在东三省的军队都撤退,将其地交还中国。在中国承认的条件之下,将旅顺、大连湾转租与日,并将东省铁路支线,自长春以下,让给日本。清廷如何能不承认?乃和日本订立《会议东三省事宜协议》,除承认《朴资茅斯条约》中有关中国的款项外,并在三省开放商埠多处。军用的安奉铁路,许日人改为商用铁路。且许合资开采鸭绿江左岸材木。于是东北交涉的葛藤,纷纷继起,侵略者的资格,在此而不在彼了。当日俄战争时,英国乘机派兵入藏,达赖出奔。英人和班禅立约,开江孜、噶大克为商埠。非经英国许可,西藏的土地不得租、卖给外国人。铁路、道路、电线、矿产,不得许给外国或外国人。一切入款、银钱、货物,不得抵押给外国或外国人。一切事情,都不受外国干涉,亦不许外国派官驻扎和驻兵。中国得报大惊,然与英人交涉无效,不得已,乃于1906年,订立《英藏续约》,承认《英藏条约》为附约,但声明所谓外国或外国人者,不包括中国或中国人在内而止。在东北方面,中国拟借英款敷设新法铁路,日人指为南满铁路的平行线(东省铁路支线,俄人让给日本的,日人改其名为南满路),中国不得已,作罢,但要求建造锦齐铁路时,日不反对。中国因欲借英、美的款项,将锦齐铁路延长至瑷珲。日人又唆使俄人出而反抗,于是美国人有满洲铁路中立的提议。其办法:系由各国共同借款给中国,由中国将东三省铁路赎回。在借款未还清前,由各国共同管理,禁止政治上、军事上的使用。议既出,日、俄两国均提出抗议。这时候,因英、美两国欲伸张势力于东北而无所成,其结果反促成日、俄的联合。两国因此订立协约,声明维持满洲现状,现状被迫时,彼此互相商议。据说此约别有密约,俄国承认日本并韩,而日本承认俄国在蒙、新方面的行动。此约立于1910年。果然,日本于其年即并韩,而俄人对蒙、新方面,亦于其明年提出强硬的要求,且用哀的美敦书迫胁中国承认了。\n外力的冯凌,实为清季国民最关心的事项。清朝对于疆土的侵削,权利的丧失,既皆熟视而无可如何,且有许多自作孽的事情,以引进外力的深入。国民对于清政府,遂更无希望,且觉难于容忍。在庚子以前,还希冀清朝变法图强的,至庚子以后,则更无此念,激烈的主张革命,平和的也主张立宪,所要改革的,不是政务而是政体了。革命的领导者孙中山先生,是生于中国的南部,能承袭明季以来的民族革命思想,且能接受西方的民治主义的。他当1885年,即已决定颠覆清朝,创建民国。1892年在澳门立兴中会。其后漫游欧、美,复决定兼采民生主义,而三民主义,于是完成。自1892年以来,孙中山屡举革命之帜。其时所利用的武力,主要的为会党,次之则想运动防军。然防军思想多腐败,会党的思想和组织力,亦嫌其不足用,是以屡举而无成。自戊戌政变以后,新机大启,中国人士赴外国留学者渐多,以地近费省之故,到日本去的尤多。以对朝政的失望,革命、立宪之论,盛极一时。1905年,中山先生乃赴日本,将兴中会改组为同盟会。革命团体至此,始有中流以上的人士参加。中山先生说:“我至此,才希望革命之事,可以及身见其有成。”中流以上的人士,直接行动的能力,虽似不如下流社会,然因其素居领导的地位,在宣传方面的力量,却和下流社会中人,相去不可以道里计,革命的思潮,不久就弥漫全国了。素主保皇的康有为,在此时,则仍主张君主立宪。其弟子梁启超,是历年办报,在言论界最有权威的。初主革命,后亦改从其师的主张,在所办的《新民丛报》内,发挥其意见,和同盟会所出的《民报》,互相辩论,于是立宪、革命成为政治上的两大潮流。因对于清朝的失望,即内外臣工中,亦有主张立宪的。日俄战争而后,利用日以立宪而胜,俄以专制而败为口实,其议论一时尤盛。清朝这时候,自己是并无主张的。于是于1906年,下诏预备立宪。俟数年后,察看情形,以定实行的期限。人民仍不满足。1908年,下诏定实行立宪之期为九年。这一年冬天,德宗和孝钦后相继而死。德宗弟醇亲王载沣之子溥仪立。年幼,载沣摄政,性甚昏庸。其弟载洵、载涛则恣意妄为。居政府首席的庆亲王奕劻,则老耄而好贿,政局更形黑暗。人民屡请即行立宪,不许。1910年,号称为国会预备的资政院,亦以为请,乃勉许缩短期限,于三年后设立国会。然以当时的政局,眼见得即使召集国会,亦无改善的希望,人民仍觉得灰心短气。而又因铁路国有问题,和人民大起冲突。此时的新军,其知识,已非旧时军队之比;其纪律和战斗力,自亦远较会党为强。因革命党人的热心运动,多有赞成革命的。1911年10月10日,即旧历辛亥八月十九日,革命军起事于武昌。清朝本无与立,在无事时,亲贵虽欲专权,至危急时,仍不得不起用袁世凯。袁世凯亦非有诚意扶持清朝的,清人力尽势穷,遂不得不于其明年即中华民国元年二月十二日退位。沦陷了二百六十八年的中华,至此光复;且将数千年来的君主专制政体,一举而加以颠覆。自五口通商,我国民感觉时局的严重,奋起而图改革,至此不过七十年,而有如此的大成就,其成功,亦不可谓之不速了。\n●袁世凯像\n第三十六章 革命途中的中国 # 语云:“大器晚成”,一人尚然,而况一国?中华民国的建立,虽已三十年,然至今仍在革命的途中,亦无怪其然了。策励将来,端在检讨以往,我现在,且把这三十年来的经过,述其大略如下:\n●孙中山像\n民国的成立,虽说是由于人心的效顺,然以数千年来专制的积重,说真能一朝涤除净尽,自然是无此理的。大约当时最易为大众所了解的,是民族革命,所以清朝立见颠覆。而袁世凯则仍有运用阴谋,图遂其个人野心的余地。民党当日亦知道袁世凯之不足信,但为避免战祸,且急图推翻清朝起见,遂亦暂时加以利用。孙中山先生辞临时大总统之职,推荐袁世凯于参议院。于是袁世凯被举为临时大总统。民党因南方的空气,较为清新,要其到南京来就职。袁世凯自然不肯来,乃唆使京、津、保定的兵哗变。民党乃只得听其在北京就职。此时同盟会已改组为国民党,自秘密的革命团体变成公开的政党。孙中山先生知道政局暂难措手,主张国民党退居在野的地位,而自己则专办实业。然是时的国民党员,不能服从党纪,不听。二年四月八日,国会既开,国民党议员,乃欲藉国会和内阁的权力,从法律上来限制袁氏。这如何会有效?酝酿复酝酿,到底有二年七月间的二次革命。二次革命失败后,孙中山先生在海外组织中华革命党。鉴于前此以纪律不严而败,所以此次以服从党魁为重要的条件。然一时亦未能为有效的活动。而袁氏在国内,则从解散国民党,进而停止国会议员的职务,又解散省议会,停办地方自治,召开约法会议,擅将宪法未成以前的根本大法《临时约法》修改为《中华民国约法》,世称为《新约法》,而称《临时约法》为《旧约法》。又立参议院,令其代行立法权。共和政体,不绝如缕。至四年底,卒有伪造民意帝制自为之举。于是护国军起于云南。贵州、两广、浙江、四川、湖南,先后响应。山东、陕西境内,亦有反对帝制的军队。袁氏派兵攻击,因人心不顺,无效,而外交方面,又不顺利,乃于五年三月间下令将帝制取消,商请南方停战。南方要求袁氏退位,奉副总统黎元洪为大总统。事势陷于僵持。未久,袁氏逝世,黎氏代行职权,恢复《临时约法》和国会,问题乃得自然解决。然为大局之梗者,实并非袁氏一人。袁氏虽非忠贞,然当其未至溃败决裂时,北洋系军人,究尚有一个首领。到袁氏身败名裂之后,野心军人,就更想乘机弄权。当南方要求袁氏退位而袁氏不肯时,江苏将军就主张联合未独立各省,公议办法。通电说:“四省若违众论,固当视同公敌;政府若有异议,亦当一致争持”,这已经不成话了。后来他们又组织了一个各省区联合会,更形成了一种非法的势力。六年二月,因德国宣布无限制潜艇战争,我国与德绝交。国务总理段祺瑞进而谋对德参战。议案被国会搁置。各省、区督军、都统,遂分呈总统和国务总理,藉口反对宪法草案,要求解散国会。黎总统旋免段祺瑞之职。安徽遂首先宣告和中央脱离关系。直隶、山东、山西、河南、陕西、奉天、黑龙江、浙江、福建等省继之。在天津设立军务总参谋处。通电说:“出兵各省,意在另订根本大法,设立临时政府和临时议会”,这更显然是谋叛了。黎总统无可如何,召安徽督军张勋进京共商国是。张勋至天津,迫胁黎总统解散国会而后入。七月一日,竟挟废帝溥仪在京复辟。黎总统走使馆,令副总统冯国璋代行职权,以段祺瑞为国务总理。祺瑞誓师马厂,十二日,克复京城。张勋所扶翼的清朝亡。流了无量数有名无名的先烈的血,然后造成的中华民国,因军人、政客私意的交争,而几至于倾覆,论理,同为中华民国的人民,应该可以悔过了。然而社会现象,哪有如此简单?北方的军人、政客,仍不能和南方合作。乃藉口于复辟之时,中华民国业经中断,可仿民国元年之例,重行召集参议院,不知当复辟的十一天中,所谓溥仪者,号令只行于一城;我们即使退一百步,承认当时督军团中的督军,可以受令于溥仪,而西南诸省固自在,中华民国,何尝一日中断来?然而这句话何从向当时的政客说起?于是云南、两广,当国会解散时,宣布径行禀承元首,不受非法内阁干涉的,仍不能和北方合作。国会开非常会议于广州,议决《军政府组织大纲》,在《临时约法》未恢复前,以大元帅任行政权,对外代表中华民国,举孙中山为大元帅。后又改为总裁制,以政务总裁七人,组织政务会议,由各部长所组织之政务院赞襄之,以行使军政府之行政权。北方则召集参政院,修改选举法,另行召集新国会,举徐世昌为总统,于七年十月十日就职。中华民国遂成南北分裂之局。而南北的内部,尚不免于战争。九年七月,北方的吴佩孚,自衡阳撤防回直隶,和段祺瑞所统率的定国军作战。定国军败,段氏退职,是为皖、直之战。南方亦因心力不齐,总裁中如孙中山等均离粤。是年十月,以粤军而驻扎于福建漳州的陈炯明回粤,政务总裁岑春煊等宣布取消自主。徐世昌据之,下令接收,孙中山等通电否认,回粤再开政务会议。十年四月,国会再开非常会议,选举孙中山为大总统,于五月五日就职,陈炯明遂进兵平定广西。\n是时北方:曹锟为直、鲁、豫巡阅使,吴佩孚为副。王占元为两湖巡阅使,张作霖为东三省巡阅使,兼蒙疆经略使。湖南军队进攻湖北,王占元败走。旋为吴佩孚所败,进陷岳州,川军东下,亦为佩孚所败。十一年四五月间,奉军在近畿和直军冲突,奉军败退出关。孙中山本在广西筹备北伐。是年四月间,将大本营移至韶关。陈炯明走惠州。五月,北伐军入江西。六月,徐世昌辞职。曹锟等电黎元洪请复位。元洪复电,要求各巡阅使、督军先释兵柄,旋复许先行入都。撤消六年六月解散国会之令,国会再开。孙中山宣言:直系诸将,应将所部半数,先行改为工兵,余则留待与全国军队同时以次改编,方能饬所部罢兵。未几,广西粤军回粤,围攻总统府。孙中山走上海。岁杪,滇、桂军在粤的及粤军的一部分讨陈,陈炯明再走惠州。十二年二月,孙中山乃再回广州,以大元帅的名义主持政务。然滇、桂军并不肯进取东江,在广东方面的军事,遂成相持之局。此时北方各督军中,惟浙江卢永祥通电说,冯国璋代理的期限既满,就是黎元洪法定的任期告终,不肯承认黎元洪之复职为合法。东三省则自奉、直战后,即由三省省议会公举张作霖为联省自治保安总司令,而以吉、黑两省督军为之副,不奉北京的命令。其余则悉集于直系旗帜之下。南方如陈炯明及四川省内的军人,亦多与之相结的。直系的势力,可谓如日中天,而祸患即起于其本身。十二年六月间,北京军、警围总统府索饷,黎元洪走天津,旋走南方。至十月,曹锟遂以贿选为大总统,于十月就职,同时公布宪法。浙江宣布与北京断绝关系,云南及东三省皆通电讨曹,然亦未能出兵。十三年九月,江、浙战起,奉、直之战继之,直系孙传芳自福建入浙,卢永祥败走。北方则冯玉祥自古北口回军,自称国民第一军。胡景翼、孙岳应之,称国民第二、第三军。吴佩孚方与张作霖相持于山海关,因后路被截,浮海溯江,南走湖北。奉军入关,张作霖与冯玉祥相会,共推段祺瑞为临时执政,段祺瑞邀孙中山入京,共商国是。孙中山主开国民会议,解决国是。段祺瑞不能用。段祺瑞亦主开善后会议,先解决时局纠纷,再开国民代表会议,解决根本问题。孙中山以其所谓会议者,人民团体无一得与,诫国民党员勿得加入。于是会商仍无结果。是年三月十二日,孙中山先生卒于北京。\n是时北方:张作霖为东北边防督办,冯玉祥为西北边防督办。胡景翼督办河南军务善后事宜,孙岳为省长。后胡景翼卒,孙岳改为督办陕西军务事宜。卢永祥为苏、皖、赣宣抚使,以奉军南下,齐燮元走海外。直隶李景林、山东张宗昌、江苏杨宇霆、安徽姜登选,均属奉系人物。直系残余势力,惟萧耀南仍踞湖北,孙传芳仍据浙江,吴佩孚亦仍居鸡公山。十四年十月,孙传芳入江苏。杨宇霆、姜登选皆退走。孙军北上至徐州。十一月,吴佩孚亦起于汉口。奉军驻扎关内的郭松龄出关攻张作霖,以为日本人所阻,败死。冯玉祥攻李景林,李景林走济南,与张宗昌合。吴佩孚初称讨奉,后又与张作霖合攻冯玉祥,冯军撤退西北。段祺瑞出走。北方遂无复首领。大局的奠定,不得不有望于南方的北伐。\n先是孙中山以八年十月,改中华革命党为中国国民党。十二年十一月,又加改组。十三年一月十二日,始开全国代表大会于广州,将大元帅府改组为国民政府。十四年四月,国民政府平东江。还军平定滇、桂军之叛。广西亦来附。改组政府为委员制。十五年一月,开全国代表第二次大会。六月,中央执行委员会召集临时会,通过迅速北伐案。七月,克长沙。九月,下汉阳、汉口,围武昌,至十月而下。十一月,平江西。冯玉祥之国民军,亦以是月入陕,十二月,达潼关。东江之国民军,先以十月入福建。明年,国民军之在湖南者北入河南,与冯玉祥之军合。在福建者入浙江。在江西者分江左、江右两军,沿江而下,合浙江之兵克南京。旋因清党事起,宁、汉分裂,至七月间乃复合作。明年,一月,再北伐。至五月入济南,而五三惨案作。国民军绕道德州北伐。张作霖于六月三日出关,四日,至皇姑屯,遇炸死。其子张学良继任。至十二月九日,通电服从国民政府,而统一之业告成。\n中国革命前途重要的问题,毕竟不在对内而在对外。军人的跋扈,看似扰乱了中国好几十年,然这一班并无大略,至少是思想落伍,不识现代潮流的人,在今日的情势之下,复何能为?他们的难于措置,至少是有些外交上的因素牵涉在内的。而在今日,国内既无问题之后,对外的难关,仍成为我们生死存亡的大问题。所以中国既处于今日之世界,非努力打退侵略的恶势力,决无可以自存之理。外交上最大的压力,来自东北方。当前清末年,曾向英、美、德、法四国借款,以改革币制及振兴东三省的实业,以新课盐税和东三省的烟酒、生产、消费税为抵。因革命军起,事未有成。至民国时代,四国银行团加入日、俄,变为六国,旋美国又退出,变为五国,所借的款项,则变为善后大借款,这是最可痛心的事。至欧战起,乃有日本和英国合兵攻下胶州湾之举。日本因此而提出五号二十一条的要求。其后复因胶济沿路的撤兵,和青岛及潍县、济南日人所施民政的撤废,而有《济顺高徐借款预备契约》及胶济铁路日方归中、日合办的照会,由于复文有“欣然同意”字样,致伏巴黎和会失败之根。其后虽有华盛顿会议的《九国公约》,列举四原则,其第一条,即为尊重中国的主权独立和领土及行政的完整,然迄今未获实现。自欧战以后,与德、奥、俄所订的条约,均为平等。国民政府成立以来,努力于外交的改进。废除不平等条约,已定有办法。关税业已自主。取消领事裁判权,亦已有实行之期,租借地威海卫已交还。租界亦有交还的。然在今日情势之下,此等又都成为微末的问题。我们当前的大问题,若能得到解决,则这些都不成问题;在大问题还没解决之前,这些又都无从说起了。在经济上,我们非解除外力的压迫,更无生息的余地,资源虽富,怕我们更无余沥可沾。在文化上,我们非解除外力的压迫,亦断无自由发展的余地,甚至当前的意见,亦非此无以调和。总之:我们今日一切问题,都在于对外而不在于对内。\n我们现在,所处的境界,诚极沉闷,却不可无一百二十分的自信心。岂有数万万的大族,数千年的大国、古国,而没有前途之理?悲观主义者流:“君歌且休听我歌,我歌今与君殊科。”我请诵近代大史学家梁任公先生所译英国大文豪拜伦的诗,以结吾书。\n希腊啊!你本是平和时代的爱娇,你本是战争时代的天骄。撒芷波,歌声高,女诗人,热情好。更有那德罗士、菲波士荣光常照。此地是艺文旧垒,技术中潮。祇今在否?算除却太阳光线,万般没了。\n马拉顿前啊!山容缥缈。马拉顿后啊!海门环绕。如此好河山,也应有自由回照。我向那波斯军墓门凭眺。难道我为奴为隶,今生便了?不信我为奴为隶,今生便了。\n卅·九·一八于孤岛\n"},{"id":164,"href":"/zh/docs/culture/%E4%B8%AD%E5%9B%BD%E9%80%9A%E5%8F%B2%E5%90%95%E6%80%9D%E5%8B%89/%E5%B0%81%E9%9D%A2-%E7%89%88%E6%9D%83-%E8%AF%BB%E5%90%8E-%E8%87%AA%E5%BA%8F/","title":"封面-版权-读后-自序","section":"中国通史(吕思勉)","content":" 版权信息 # 中国通史\n作 者:吕思勉\n责任编辑:周 宏\n特约编辑:邱承辉\n装帧设计:利 锐\n本书由天津博集新媒科技有限公司授权亚马逊全球范围发行\n**目录 **\n版权信息\n吕思勉先生的史识与史德——《中国通史》读后\n自序 绪论\n上编 中国政治史 第一章 中国民族的由来\n第二章 中国史的年代\n第三章 古代的开化\n第四章 夏殷西周的事迹\n第五章 春秋战国的竞争和秦国的统一\n第六章 古代对于异族的同化\n第七章 古代社会的综述\n第八章 秦朝治天下的政策\n第九章 秦汉间封建政体的反动\n第十章 汉武帝的内政外交\n第十一章 前汉的衰亡\n第十二章 新室的兴亡\n第十三章 后汉的盛衰\n第十四章 后汉的分裂和三国\n第十五章 晋初的形势\n第十六章 五胡之乱(上)\n第十七章 五胡之乱(下)\n第十八章 南北朝的始末\n第十九章 南北朝隋唐间塞外的形势\n第二十章 隋朝和唐朝的盛世\n第二十一章 唐朝的中衰\n第二十二章 唐朝的衰亡和沙陀的侵入\n第二十三章 五代十国的兴亡和契丹的侵入\n第二十四章 唐宋时代中国文化的转变\n第二十五章 北宋的积弱\n第二十六章 南宋恢复的无成\n第二十七章 蒙古大帝国的盛衰\n第二十八章 汉族的光复事业\n第二十九章 明朝的盛衰\n第三十章 明清的兴亡\n第三十一章 清代的盛衰\n第三十二章 中西初期的交涉\n第三十三章 汉族的光复运动\n第三十四章 清朝的衰乱\n第三十五章 清朝的覆亡\n第三十六章 革命途中的中国\n下编 中国文化史 第三十七章 婚姻\n第三十八章 族制\n第三十九章 政体\n第四十章 阶级\n第四十一章 财产\n第四十二章 官制\n第四十三章 选举\n第四十四章 赋税\n第四十五章 兵制\n第四十六章 刑法\n第四十七章 实业\n第四十八章 货币\n第四十九章 衣食\n第五十章 住行\n第五十一章 教育\n第五十二章 语文\n第五十三章 学术\n第五十四章 宗教\n吕思勉先生的史识与史德——《中国通史》读后 # 章立凡\n身处浮躁的互联网时代,阅读讲求“吃快餐”,太长的文字没人看。记忆学原理中有一条:付费的知识不易遗忘,价格越高越记得住。上网浏览毕竟有别于捧书阅读,好书还得买来读。在书号成为稀缺资源的国度,出版者不得不计算成本和利润,总喜欢出厚一点的畅销书。上世纪90年代以前那种要言不烦的小册子,如今已日见稀少,盖因其性价比往往仅适于读者而非商家。\n不时有朋友问我:最便捷地阅读了解五千年来的中国史,读哪种通史好?就我的阅读经验而言,排除掉《史记》、《资治通鉴》、《纲鉴易知录》那样的文言大部头,读过的新式中国通史中,范文澜、蔡美彪先生主编的有10册,白寿彝先生主编的有22册,翦伯赞先生的《中国史纲》是两册,均为1949年后的版本,或多或少都有“以论带史”的特色;相形之下,吕思勉、钱穆、黄现璠先生的通史类著作,比较简约精要,且鲜有政治烙印。\n一、广博的视野和独特的视角\n吕思勉先生(1884—1957),字诚之,江苏常州人,出身书香之家。他幼承家学,次第入塾入县学,旧学根基深厚,基本上是自学成才,未接受新式大学教育。1926年起,任上海光华大学国文系教授,后任历史系教授兼系主任。若按当今只重学历不重才识的官式教育制度,他连执教资格都不具备。民国时代学术重镇在北京大学,以他的学术地位和影响力,前往任教不成问题,他却选择了留在私立光华大学(上世纪50年代并入华东师范大学),直到逝世。吕先生的学历、学术旨趣,与当时西方教育背景的学术精英不甚合拍,而坚守“私学”传统,不愿涉足官办的公立大学,恐怕也是原因之一。\n吕先生是一位通博之才,一生著有两部中国通史、四部断代史、五部专门史以及大量史学札记,共有八九百万字。这部《中国通史》原书分为上下两册,“上册以文化现象为题目,下册乃依时代加以连结”。1在上册中,他将《史记》“八书”体例加以细化,分解为十八个门类,分别加以论述。下册从民族起源、古代社会始,按时序叙述历朝历代史事直至民国开创。以人文史为纬,以政治史为经,表述分明,议论风发,浓缩中国五千年以上的历史于一书,仅用了三十八万字,其功力非同一般。\n应该用怎样的视角和立场,去回顾和审视历史?我认为至少应做到两点:一、先有宏观视野,后有微观视角,随时穿越时空,不断调整焦距;二、保持平常心,不预设立场,审视距离放在目标时段的一百年至五百年之后。通史写作需要具备穿越时空的视野和高屋建瓴的史识,否则很难驾驭海量的史料。吕著《中国通史》不仅继承了司马迁以来的史学传统,同时采用了清末梁启超“新史学”所开辟的学术视野,将中国史作为一个民族国家的历史,放到世界史的时空中观察研究,并对梁先生的酷锐视角有所调整,与政治保持了适当距离。\n二、对儒、法两家经济思想的评述\n我在阅读中最感兴趣的部分,是吕先生建立在旧学底蕴和新学高度上的历史观。原书由私域扩展到公域,自初民的社会生活始,从婚姻、族制、政体、阶级、财产而及官制、选举、赋税、兵制、刑法,从实业、货币到衣食、住行,从教育、语文到学术、宗教,解析社会制度、经济、文化的演变。各章节的排序及内容的表述丝丝入扣,具有内在的历史逻辑关系。\n吕先生治学的严谨,不仅在于具有宏观的视野,同时也关注到历史的细节。他从经济制度上把中国的历史分为三大时期:“有史以前为第一期。有史以后,讫于新室之末,为第二期。自新室亡后至现在,为第三期。自今以后,则将为第四期的开始。”\n他注意到:“在东周之世,社会上即已发生两种思潮:一是儒家,主张平均地权,其具体办法,是恢复井田制度。一是法家,主张节制资本,其具体办法,是(甲)大事业官营;(乙)大商业和民间的借贷,亦由公家加以干涉。”法家在统治技术(治术)方面,懂得“创设新税,自当用间接之法,避免直接取之于农民”。在与百姓日用相关的盐铁上“加些微之价,国家所得,已不少了”。汉代法家桑弘羊的盐铁官卖及均输政策,“筹款的目的是达到了,矫正社会经济的目的,则并未达到。汉朝所实行的政策,如减轻田租、重农抑商等,更其无实效可见了。直到汉末,王莽出来,才综合儒法两家的主张行一断然的大改革”。(第四十一章财产)\n吕先生认为:“王莽的失败,不是王莽一个人的失败,乃是先秦以来言社会改革者公共的失败。”王莽失败后,中国的社会改革运动长期停顿,仅出现过“平和的、不彻底的平均地权运动”,如晋朝的户调式、北魏的均田令、唐朝的租庸调法,至唐德宗朝改为两税制后,“国家遂无复平均地权的政策”。宋朝王安石变法,关注点已转移到粮价,推行青苗法用意虽良,但在商品交换及市民社会尚未充分发育的年代,权力无法监督,改革最终沦为秕政。他总结说:\n中国历代,社会上的思想,都是主张均贫富的,这是其在近代所以易于接受社会主义的一个原因。然其宗旨虽善,而其所主张的方法,则有未善。这因历代学者,受传统思想的影响太深,而对于现实的观察太浅之故。在中国,思想界的权威,无疑是儒家。儒家对于社会经济的发展,认识本不如法家的深刻,所以只主张平均地权,而忽略了资本的作用。(第四十一章财产)\n这段论述是相当公允的,肯定了改革者的历史地位,而较之“文革”中为政治需要生造出的“儒法斗争史”,又不知高明凡几。\n三、对文化与制度的思考\n吕先生在解析财产制度由公有制向私有制演变时,提出“人类的联合,有两种方法:一种是无分彼此,通力合作,一种则分出彼此的界限来。既分出彼此的界限,而又要享受他人劳动的结果,那就非于(甲)交易、(乙)掠夺两者之中择行其一不可了。而在古代,掠夺的方法,且较交易为通行。在古代各种社会中,论文化,自以农业社会为最高;论富力,亦以农业社会为较厚,然却很容易被人征服”。而征服者在建立统治之后,就得考虑统治(或曰剥削)的可持续性,不随意干涉原有的社会组织,甚至同化于比自身更先进的社会文化:\n(一)剥削者对于被剥削者,亦必须留有余地,乃能长保其剥削的资源。(二)剥削的宗旨,是在于享乐的,因而是懒惰的,能够达到剥削的目的就够了,何必干涉人家内部的事情?(三)而剥削者的权力,事实上亦或有所制限,被剥削者内部的事情,未必容其任意干涉。(四)况且两个社会相遇,武力或以进化较浅的社会为优强,组织必以进化较深的社会为坚凝。所以在军事上,或者进化较深的社会,反为进化较浅的社会所征服;在文化上,则总是进化较浅的社会,为进化较深的社会所同化的。(第四十一章财产)\n对于从封建时代到资本主义时代的文明嬗替,吕先生认为:\n封建社会的根源,是以武力互相掠夺。人人都靠武力互相掠夺,则人人的生命财产,俱不可保。这未免太危险。所以社会逐渐进步,武力掠夺之事,总不能不悬为厉禁。到这时代,有钱的人,拿出钱来,就要看他愿否。于是有钱就是有权力。豪爽的武士,不能不俯首于狡猾悭吝的守财奴之前了。这是封建社会和资本主义社会转变的根源。平心而论:资本主义的惨酷,乃是积重以后的事。当其初兴之时,较之武力主义,公平多了,温和多了,自然是人所欢迎的。(第四十章阶级)\n在工业文明东渐之前,中国的农业文明曾是一种强势文明。吕先生指出,游牧民族入侵后,被中国文化所同化;同为农业文明的佛教文化输入中国后,“并未能使中国人的生活印度化,反而佛教的本身,倒起了变化,以适应我们的生活了”;“中国虽然不断和外界接触,而其所受的外来的影响甚微”;“至近代欧西的文明,乃能改变生活的基础,而使我们的生活方式,不得不彻底起一个变化,我们应付的困难,就从此开始了。但前途放大光明、得大幸福的希望,亦即寄托在这个大变化上”。(第三十二章中西初期的交涉)生活方式的改变是最彻底的改变,这些表述,揭示了工业文明取代农业文明,成为主流文明的历史必然。\n文化与制度的关系,是一个争执已久的话题。对于改造西方宗教为本土教门的太平天国革命,吕先生分析其失败之原因“实不在于军事而在于政治”,“若再推究得深些,则其失败,亦可以说是在文化上”。他指出:“社会革命和政治革命,很不容易同时并行,而社会革命,尤其对社会组织,前因后果,要有深切的认识,断非简单、手段灭裂的均贫富主义所能有济。”(第三十三章汉族的光复运动)吕先生这一分析十分精到,近代以来中国人对革命的误解,恰恰在于混淆了政治革命与社会革命的区别,将两者同时并行。\n离现实越近的历史越难评判,吕先生在分析清朝的覆亡时,除缕陈戊戌维新失败的权力斗争背景外,也指出文化上的守旧愚昧:“只要中国人齐心,即可将外国人尽行逐去,回复到闭关时代之旧”的思想,是酿成蒙昧主义排外运动的重要原因。而革命超越改良的原因则在于:“孝钦后自回銮以后,排外变而为媚外;前此之力阻变革者,至此则变为貌行新政,以敷衍国民。宫廷之中,骄奢淫逸,朝廷之上,昏庸泄沓如故。满清政府至此,遂无可维持,而中国国民,乃不得不自起而谋政治的解决”。(第三十五章清朝的覆亡)腐朽的政治、滞后的改革和媚外的外交,最终导致了革命爆发和王朝倾覆。\n四、余论\n上述种种,仅系阅读中的一点心得体会,无法尽述吕先生的博大精深。\n严耕望先生将陈寅恪、钱穆、陈垣、吕思勉并称为前辈史学“四大家”,其他三家都令名远扬,惟吕先生相形落寞,直到近年“国学热”兴起,才重新“被出土”。1949年鼎革之际,钱先生出走香江,不与新政权合作;陈(寅恪)先生走到半途滞留羊城,成为非主流代表人物;陈(垣)先生留京痛悔前非,为新主流所接纳。如此看来,功名可“正取”也可“逆取”,有心无心的“政治正确”或“不正确”,皆足以扬名立万。\n“学成文武艺,货与帝王家”,中国士大夫历来有当“帝王师”的冲动,统治者想干点好事或坏事,往往摆出“以史为鉴”的身段向史家求教。其实在主子心目中,这些人大多是备用的“两脚书橱”或歌功颂德的词臣。治学如不能与政治保持距离,学者很容易失身入彀沦为政客,吕先生毕生潜心治学不求闻达,坚持做学界隐者,尤为难能可贵。\n清人章学诚在其名著《文史通义》中指出:“能具史识者,必具史德。”正史出于胜利者,而信史出于旁观者,从这部叙事心平气和、解析鞭辟入里的中国通史中,不仅能窥见作者的史德与史识,也可洞悉中国历代王朝兴替的周期律,令后来者鉴之,祈勿使后人复哀后人也。\n2010年3月14日风雨读书楼\n自序 # 我在上海光华大学,讲过十几年的本国史。其初系讲通史。后来文学院长钱子泉先生说:讲通史易与中学以下的本国史重复,不如讲文化史。于是改讲文化史。民国二十七年,教育部颁行大学课程;其初以中国文化史为各院系一年级必修科,后改为通史,而注明须注重于文化。大约因政治方面,亦不可缺,怕定名为文化史,则此方面太被忽略之故。用意诚甚周详。然通史讲授,共止一百二十小时,若编制仍与中学以下之书相同,恐终不免于犯复。所以我现在讲授,把它分为两部分:上册以文化现象为题目,下册乃依时代加以联结,以便两面兼顾(今本书已将政治史移作上册,文化史改作下册——出版者注)。此意在本书绪论中,业经述及了。此册系居孤岛上所编,参考书籍,十不备一;而时间甚为匆促。其不能完善,自无待言。但就文化的各方面加以探讨,以说明其变迁之故,而推求现状之所由来;此等书籍,现在似尚不多,或亦足供参考。故上册写成,即付排印,以代抄写。不完不备之处,当于将来大加订补。此书之意,欲求中国人于现状之所由来,多所了解。故叙述力求扼要,行文亦力求浅显。又多引各种社会科学成说,以资说明。亦颇可作一般读物;单取上册,又可供文化史教科或参考之用。其浅陋误缪之处,务望当代通人,加以教正。\n民国二十八年九月二十八日,吕思勉识。\n绪论 # 历史,究竟是怎样一种学问?研究了它,究竟有什么用处呢?\n这个问题,在略知学问的人,都会毫不迟疑地作答道:历史是前车之鉴。什么叫做前车之鉴呢?他们又会毫不迟疑地回答道:昔人所为而得,我可以奉为模范;如其失策,便当设法避免,这就是所谓“法戒”。这话骤听似是,细想就知道不然。世界上哪有真正相同的事情?所谓相同,都是察之不精,误以不同之事为同罢了。远者且勿论。欧人东来以后,我们应付他的方法,何尝不本于历史上的经验?其结果却是如何呢?然则历史是无用了么?而不知往事,一意孤行的人,又未尝不败。然则究竟如何是好呢?\n历史虽是记事之书,我们之所探求,则为理而非事。理是概括众事的,事则只是一事。天下事既没有两件真正相同的,执应付此事的方法,以应付彼事,自然要失败。根据于包含众事之理,以应付事实,就不至于此了。然而理是因事而见的,舍事而求理,无有是处。所以我们求学,不能不顾事实,又不该死记事实。\n要应付一件事情,必须明白它的性质。明白之后,应付之术,就不求而自得了。而要明白一件事情的性质,又非先知其既往不可。一个人,为什么会成为这样子的一个人?譬如久于官场的人,就有些官僚气;世代经商的人,就有些市侩气;向来读书的人,就有些迂腐气。难道他是生来如此的么?无疑,是数十年的做官、经商、读书养成的。然则一个国家,一个社会,亦是如此了。中国的社会,为什么不同于欧洲?欧洲的社会,为什么不同于日本?习焉不察,则不以为意,细加推考,自然知其原因极为深远复杂了。然则往事如何好不研究呢?然而以往的事情多着呢,安能尽记?社会上每天所发生的事情,报纸所记载的,奚啻亿兆京垓分之一。一天的报纸,业已不可遍览,何况积而至于十年、百年、千年、万年呢?然则如何是好?\n须知我们要知道一个人,并不要把他以往的事情,通统都知道了,记牢了。我,为什么成为这样一个我?反躬自省,总是容易明白的,又何尝能把自己以往的事,通统记牢呢?然则要明白社会的所以然,也正不必把以往的事,全数记得,只要知道“使现社会成为现社会的事”就够了。然而这又难了。\n任何一事一物,要询问它的起源,我们现在,不知所对的很多。其所能对答的,又十有八九靠不住。然则我们安能本于既往,以说明现在呢?\n这正是我们所以愚昧的原因,而史学之所求,亦即在此。史学之所求,不外乎(一)搜求既往的事实,(二)加以解释,(三)用以说明现社会,(四)因以推测未来,而指示我们以进行的途径。\n往昔的历史,是否能肩起这种任务呢?观于借鉴于历史以应付事实导致失败者之多,无疑的是不能的。其失败的原因安在呢?列举起来,也可以有多端,其中最重要的,自然是偏重于政治的。翻开二十五史来一看(从前都说二十四史,这是清朝时候,功令上所定为正史的。民国时代,柯劭忞所著的《新元史》,业经奉徐世昌总统令,加入正史之中,所以现在该称二十五史了),所记的,全是些战争攻伐,在庙堂上的人所发的政令,以及这些人的传记世系。昔人称《左传》为相斫书;近代的人称二十四史为帝王的家谱,说虽过当,也不能谓其全无理由了。单看了这些事,能明白社会的所以然么?从前的历史,为什么会有这种毛病呢?这是由于历史是文明时代之物,而在文明时代,国家业已出现,并成为活动的中心,常人只从表面上看,就认为政治可以概括一切,至少是社会现象中最重要的一项了。其实政治只是表面上的事情。政治的活动,全靠社会做根底。社会,实在政治的背后,做了无数更广大更根本的事情。不明白社会,是断不能明白政治的。所以现在讲历史的人,都不但着重于政治,而要着重于文化。\n●史记 司马迁著,文笔优美,生动翔实,是我国正史的开山之作\n何谓文化?向来狭义的解释,只指学术技艺而言,其为不当,自无待论。说得广的,又把一切人为的事,都包括于文化之中,然则动物何以没有文化呢?须知文化,正是人之所以异于动物的。其异点安在呢?凡动物,多能对外界的刺激而起反应,亦多能与外界相调适。然其与外界相调适,大抵出于本能,其力量极有限,而且永远不过如此。人则不然。所以人所处的世界,与动物所处的世界,大不相同。人之所以能如此,(一)由其有特异的脑筋,能想出种种法子;(二)而其手和足的全然分开,能制造种种工具,以遂行其计划;(三)又有语言以互相交通,而其扩大的即为文字。此人之所知,所能,可以传之于彼;前人之所知,所能,并可以传之于后。因而人的工作,不是个个从头做起的,乃是互相接续着做的。不像赛跑的人,从同一地点出发,却像驿站上的驿夫,一个个连接着,向目的地进行。其所走的路线自然长,而后人所达到的,自非前人所能知了。然则文化,是因人有特异的禀赋,良好的交通工具,所成就的控制环境的共业。动物也有进化的,但它的进化,除非改变其机体,以求与外界相适应,这是要靠遗传上变异淘汰等作用,才能达到其目的的,自然非常迟慢。人则只须改变其所用的工具,和其对付事物的方法。我们身体的构造,绝无以异于野蛮人,而其控制环境的成绩,却大不相同,即由其一为生物进化,一为文化进化之故。人类学上,证明自冰期以后,人的体质,无大变化。埃及的尸体解剖,亦证明其身体构造,与现今的人相同。可见人类的进化,全是文化进化。恒人每以文化状况,与民族能力,并为一谈,实在是一个重大的错误。遗传学家,论社会的进化,过于重视个体的先天能力,也不免为此等俗见所累。至于有意夸张种族能力的,那更不啻自承其所谓进化,将返于生物进化了。从理论上说,人的行为,也有许多来自机体,和动物无以异的,然亦无不披上文化的色彩。如饮食男女之事,即其最显明之例。所以在理论上,虽不能将人类一切行为,都称为文化行为,在事实上,则人类一切行为,几无不与文化有关系。可见文化范围的广大。能了解文化,自然就能了解社会了(人类的行为,源于机体的,只是能力。其如何发挥此能力,则全因文化而定其形式)。\n全世界的文化,到底是一元的?还是多元的?这个问题,还非今日所能解决。研究历史的人,即暂把这问题置诸不论不议之列亦得。因为目前分明放着多种不同的文化,有待于我们的各别研究。话虽如此说,研究一种文化的人,专埋头于这一种文化,而于其余的文化,概无所见,也是不对的。因为(一)各别的文化,其中仍有共同的原理存;(二)而世界上各种文化,交流互织,彼此互有关系,也确是事实。文化本是人类控制环境的工具,环境不同,文化自因之而异。及其兴起以后,因其能改造环境之故,愈使环境不同。人类遂在更不相同的环境中进化。其文化,自然也更不相同了。文化有传播的性质,这是毫无疑义的。此其原理,实因人类生而有求善之性(智)与相爱之情(仁),所以文化优的,常思推行其文化于文化相异之群,以冀改良其生活,共谋人类的幸福(其中固有自以为善而实不然的,强力推行,反致引起纠纷,甚或酿成大祸,宗教之传布,即其一例。但此自误于愚昧,不害其本意之善)。而其劣的,亦恒欣然接受(其深闭固拒的,皆别有原因,当视为例外)。这是世界上的文化所以交流互织的原因。而人类的本性,原是相同的。所以在相类的环境中,能有相类的文化。即使环境不同,亦只能改变其形式,而不能改变其原理(正因原理之同,形式不能不异;即因形式之异,可见原理之同,昔人夏葛冬裘之喻最妙)。此又不同的文化,所以有共同原理的原因。以理言之如此。以事实言,则自塞趋通,殆为进化无疑的轨辙。试观我国,自古代林立的部族,进而为较大的国家;再进而为更大的国家;再进而臻于统一;更进而与域外交通,开疆拓土,同化异民族,无非受这原理的支配。转观外国的历史,亦系如此。今者世界大通,前此各别的文化,当合流而生一新文化,更是毫无疑义的了。然则一提起文化,就该是世界的文化,而世界各国的历史,亦将可融合为一。为什么又有所谓国别史,以研究各别的文化呢?这是因为研究的方法,要合之而见其大,必先分之而致其精。况且研究的人,各有其立场。居中国而言中国,欲策将来的进步,自必先了解既往的情形。即以迎受外来的文化而论,亦必有其预备条件。不先明白自己的情形,是无从定其迎拒的方针的。所以我们在今日,欲了解中国史,固非兼通外国史不行,而中国史亦自有其特殊研究的必要。\n人类以往的社会,似乎是一动一静的。我们试看,任何一个社会,在以往,大都有个突飞猛进的时期。隔着一个时期,就停滞不进了。再阅若干时,又可以突飞猛进起来。已而复归于停滞。如此更互不已。这是什么理由?解释的人,说节奏是人生的定律。个人如此,社会亦然。只能在遇见困难时,奋起而图功,到认为满足时,就要停滞下来了。社会在这时期就会本身无所发明;对于外来的,亦非消极的不肯接受,即积极地加以抗拒。世界是无一息不变的(不论自然的和人为的,都系如此)。人,因其感觉迟钝,或虽有感觉,而行为濡滞之故,非到外界变动,积微成著,使其感觉困难时,不肯加以理会,设法应付。正和我们住的屋子,非到除夕,不肯加以扫除,以致尘埃堆积,扫除时不得不大费其力一样。这是世界所以一治一乱的真原因。倘使当其渐变之时,随时加以审察,加以修正,自然不至于此了。人之所以不能如此,昔时的人,都以为这是限于一动一静的定律,无可如何的。我则以为不然。这种说法,是由于把机体所生的现象,和超机现象,并为一谈,致有此误。须知就一个人而论,劳动之后,需要休息若干时;少年好动,老年好静,都是无可如何之事。社会则不然。个体有老少之殊,而社会无之。个体活动之后,必继之以休息,社会则可以这一部分动,那一部分静。然则人因限于机体之故,对于外界,不能自强不息地为不断的应付,正可藉社会的协力,以弥补其缺憾。然则从前感觉的迟钝,行为的濡滞,只是社会的病态(如因教育制度不良,致社会中人,不知远虑,不能预烛祸患;又如因阶级对立尖锐,致寄生阶级不顾大局的利害,不愿改革等,都只可说是社会的病态)。我们能矫正其病态,一治一乱的现象,自然可以不复存,而世界遂臻于郅治了。这是我们研究历史的人最大的希望。\n马端临的《文献通考·序》,把历史上的事实分为两大类:一为理乱兴亡,一为典章经制。这种说法,颇可代表从前史学家的见解。一部二十五史,拆开来,所谓纪传,大部分是记载理乱兴亡一类的事实的,志则以记载典章经制为主(表二者都有)理乱兴亡一类的事实,是随时发生的,今天不能逆料明天。典章经制,则为人预设之以待将来的,其性质较为持久。所以前者可称为动的史实,后者可称为静的史实。史实确乎不外这两类,但限其范围于政治以内,则未免太狭了。须知文化的范围,广大无边。两间的现象,除(一)属于自然的;(二)或虽出于生物,而纯导原于机体的,一切都当包括在内。它综合有形无形的事物,不但限制人的行为,而且陶铸人的思想。在一种文化中的人,其所作所为,断不能出于这个文化模式以外,所以要讲文化史,非把昔时的史料,大加扩充不可。教育部所定大学课程草案,各学院共同必修科,本有文化史而无通史。后又改为通史,而注明当注重于文化。大约因为政治的现象,亦不可略,怕改为文化史之后,讲授的人全忽略了政治事项之故,用意固甚周详。然大学的中国通史,讲授的时间,实在不多。若其编制仍与中学以下同,所讲授者,势必不免于重复。所以我现在,换一个体例。先就文化现象,分篇叙述,然后按时代加以综合。我这一部书,取材颇经拣择,说明亦力求显豁。颇希望读了的人,对于中国历史上重要的文化现象,略有所知,因而略知现状的所以然;对于前途,可以预加推测;因而对于我们的行为,可以有所启示。以我之浅学,而所希望者如此,自不免操豚蹄而祸篝车之诮,但总是我的一个希望罢了。\n"},{"id":165,"href":"/zh/docs/technology/Markdown/_SuperTutorial_/","title":"Markdown超级教程","section":"技术","content":" 全篇转载自 https://forum-zh.obsidian.md/t/topic/435\n作者obsidian论坛名-yikelee 成雙醬,感谢作者无私分享!\n"},{"id":166,"href":"/zh/docs/test/mytest/","title":"test","section":"测试","content":"askdfjkasjdf asdfkajskdf asdfjaskdfj sdk 哈哈哈这是要给测试 as jd afk\nsdafkjasjdfkjk\nfdasfdft.\n"},{"id":167,"href":"/zh/docs/technology/Linux/_TheLinuxCommandsHandbook_/","title":"_TheLinuxCommandsHandbook_","section":"Linux","content":" 《TheLinuxCommandsHandbook》结合视频 https://www.youtube.com/watch?v=ZtqBQ68cfJc 看的\nCommand意义 # 更快,自动化,在任何Linux上工作,有些工作的基本需求\n系统-Unix和Windows # 绿色-开源\n红色-闭源\n黄色-混合\n图片中的Linux只是类Unix,而不是真正的Unix\nFreeSoftware,开源 # GNU与Linux\nLinux只是一个操作系统内核而已,而GNU提供了大量的自由软件来丰富在其之上各种应用程序。\n绝大多数基于Linux内核的操作系统使用了大量的GNU软件,包括了一个shell程序、工具、程序库、编译器及工具,还有许多其他程序 我们常说的Linux,准确地来讲,应该是叫“GNU/Linux”。虽然,我们没有为GNU和Linux的开发做出什么贡献,但是我们可以为GNU和Linux的宣传和应用做出微薄的努力,至少我们能够准确地去向其他人解释清楚GNU、Linux以及GNU/Linux之间的区别。让我们一起为GNU/Linux的推广贡献出自己的力量!\n内核,用来连接硬件和软件的\nTrueUNIX # Unix一开始是收费的,后面出现Unix-like(类Unix),和Unix标准兼容。\nLinux不是真正的Unix,而是类Unix。\nLinux本身只是一个内核,连接硬件和软件\nLinuxDistributions,Linux发行版(1000多种)\nLinux内核是一些GUN工具,文档,包管理器,桌面环境窗口管理,和系统一些其他东西组成的一个系统\n有开源的和不开源的,Linux(LinuxGUN)完全开源\nshell # windows(powershell)\n把命令交给系统\nterminal(最古老时是一个硬件)\u0026ndash;屏幕+带键盘的物理设备,如今是一个软件\n默认情况下,Ubuntu和大多数Linux发行版是bashshell,还有zsh\nsetup and installing # 如果有Mac或者其他Linux发行版,则不需要额外操作。(作者在Mac里装了Ubuntu虚拟机)\nWindowsSubsystem # wsl --install\n默认是Ubuntu\nThe Linux Handbook(电子书内容) # Linux 手册\nPreface # 前言\nThe Linux Handbook follows the 80/20 rule: learn in 20% of the time the 80% of a topic.\nLinux 手册遵循 80/20 规则:用 20% 的时间学习某个主题的 80%。\nIn particular, the goal is to get you up to speed quickly with Linux.\n具体来说,我们的目标是让您快速熟悉 Linux。\nThis book is written by Flavio. I publish programming tutorials on my blog flaviocopes.com and I organize a yearly bootcamp at bootcamp.dev.\n这本书的作者是弗拉维奥。我在博客 flaviocopes.com上发布编程教程,并在 bootcamp.dev组织年度训练营。\nYou can reach me on Twitter @flaviocopes.\n您可以通过 Twitter @flaviocopes联系我。\nEnjoy!\n享受!\nIntroduction to Linux # Linux简介\nLinux is an operating system, like macOS or Windows.\nLinux 是一个操作系统,就像 macOS 或 Windows 一样。\nIt is also the most popular Open Source and free, as in freedom, operating system.\n它也是最流行的开源和免费操作系统。\nIt powers the vast majority of the servers that compose the Internet. It\u0026rsquo;s the base upon which everything is built upon. But not just that. Android is based on amodifiedversionofa modified version of Linux.\n它为组成互联网的绝大多数服务器提供动力。它是一切事物建立的基础。但不仅如此。 Android 基于 Linux(的修改版本)。\nThe Linux \u0026ldquo;core\u0026rdquo; called∗kernel∗called *kernel* was born in 1991 in Finland, and it went a really long way from its humble beginnings. It went on to be the kernel of the GNU Operating System, creating the duo GNU/Linux.\nLinux“核心”(称为kernel )于 1991 年在芬兰诞生,从最初的卑微开始,它已经走过了漫长的道路。它后来成为 GNU 操作系统的内核,创造了 GNU/Linux 双核。\nThere\u0026rsquo;s one thing about Linux that corporations like Microsoft and Apple, or Google, will never be able to offer: the freedom to do whatever you want with your computer.\nLinux 有一点是 Microsoft、Apple 或 Google 等公司永远无法提供的:用计算机做任何你想做的事情的自由。\nThey\u0026rsquo;re actually going in the opposite direction, building walled gardens, especially on the mobile side.\n他们实际上正在朝相反的方向前进,建造围墙花园,尤其是在移动端。\nLinux is the ultimate freedom.\nLinux 是终极的自由。\nIt is developed by volunteers, some paid by companies that rely on it, some independently, but there\u0026rsquo;s no single commercial company that can dictate what goes into Linux, or the project priorities.\n它是由志愿者开发的,有些是由依赖它的公司付费的,有些是独立开发的,但没有任何一家商业公司可以决定 Linux 的内容或项目的优先级。\nLinux can also be used as your day to day computer. I use macOS because I really enjoy the applications, the design and I also used to be an iOS and Mac apps developer, but before using it I used Linux as my main computer Operating System.\nLinux 也可以用作您的日常计算机。我使用 macOS 是因为我真的很喜欢它的应用程序和设计,而且我也曾经是一名 iOS 和 Mac 应用程序开发人员,但在使用它之前我使用 Linux 作为我的主要计算机操作系统。\nNo one can dictate which apps you can run, or \u0026ldquo;call home\u0026rdquo; with apps that track you, your position, and more.\n没有人可以规定您可以运行哪些应用程序,或者使用跟踪您、您的位置等的应用程序“打电话回家”。\nLinux is also special because there\u0026rsquo;s not just \u0026ldquo;one Linux\u0026rdquo;, like it happens on Windows or macOS. Instead, we have distributions.\nLinux 也很特别,因为它不像 Windows 或 macOS 那样只有“一个 Linux”。相反,我们有发行版。\nA \u0026ldquo;distro\u0026rdquo; is made by a company or organization and packages the Linux core with additional programs and tooling.\n“发行版”由公司或组织制作,并将 Linux 核心与附加程序和工具打包在一起。\nFor example you have Debian, Red Hat, and Ubuntu, probably the most popular.\n例如,您有 Debian、Red Hat 和 Ubuntu,它们可能是最受欢迎的。\nMany, many more exist. You can create your own distribution, too. But most likely you\u0026rsquo;ll use a popular one, one that has lots of users and a community of people around it, so you can do what you need to do without losing too much time reinventing the wheel and figuring out answers to common problems.\n还存在很多很多。您也可以创建自己的发行版。但您很可能会使用一种流行的产品,它拥有大量用户和周围的人员社区,因此您可以做您需要做的事情,而不会浪费太多时间重新发明轮子并找出常见问题的答案。\nSome desktop computers and laptops ship with Linux preinstalled. Or you can install it on your Windows-based computer, or on a Mac.\n一些台式计算机和笔记本电脑预装了 Linux。或者您可以将其安装在基于 Windows 的计算机或 Mac 上。\nBut you don\u0026rsquo;t need to disrupt your existing computer just to get an idea of how Linux works.\n但您不需要仅仅为了了解 Linux 的工作原理而破坏现有的计算机。\nI don\u0026rsquo;t have a Linux computer.\n我没有 Linux 计算机。\nIf you use a Mac you need to know that under the hood macOS is a UNIX Operating System, and it shares a lot of the same ideas and software that a GNU/Linux system uses, because GNU/Linux is a free alternative to UNIX.\n如果您使用 Mac,您需要知道 macOS 在本质上是一个 UNIX 操作系统,它与 GNU/Linux 系统使用许多相同的想法和软件,因为 GNU/Linux 是 UNIX 的免费替代品。\nUNIX is an umbrella term that groups many operating systems used in big corporations and institutions, starting from the 70\u0026rsquo;s\nUNIX是一个涵盖性术语,涵盖了从 70 年代开始在大公司和机构中使用的许多操作系统\nThe macOS terminal gives you access to the same exact commands I\u0026rsquo;ll describe in the rest of this handbook.\nmacOS 终端使您可以访问我将在本手册的其余部分中描述的相同命令。\nMicrosoft has an official Windows Subsystem for Linux which you can andshould ⁣and should\\! install on Windows. This will give you the ability to run Linux in a very easy way on your PC.\nMicrosoft 有一个 适用于 Linux 的官方 Windows 子系统,您可以(并且应该!)将其安装在 Windows 上。这将使您能够在 PC 上以非常简单的方式运行 Linux。\nBut the vast majority of the time you will run a Linux computer in the cloud via a VPS VirtualPrivateServerVirtual Private Server like DigitalOcean.\n但绝大多数时候,您将通过 DigitalOcean 等 VPS(虚拟专用服务器)在云中运行 Linux 计算机。\nA shell is a command interpreter that exposes to the user an interface to work with the underlying operating system.\nshell 是一个命令解释器,它向用户公开一个与底层操作系统一起工作的界面。\nIt allows you to execute operations using text and commands, and it provides users advanced features like being able to create scripts.\n它允许您使用文本和命令执行操作,并为用户提供高级功能,例如能够创建脚本。\nThis is important: shells let you perform things in a more optimized way than a GUI GraphicalUserInterfaceGraphical User Interface could ever possibly let you do. Command line tools can offer many different configuration options without being too complex to use.\n这很重要:shell 可以让您以比 GUI(图形用户界面)更优化的方式执行操作。命令行工具可以提供许多不同的配置选项,而且不会太复杂而无法使用。\nThere are many different kind of shells. This post focuses on Unix shells, the ones that you will find commonly on Linux and macOS computers.\n有许多不同种类的贝壳。本文重点介绍 Unix shell,即 Linux 和 macOS 计算机上常见的 shell。\nMany different kind of shells were created for those systems over time, and a few of them dominate the space: Bash, Csh, Zsh, Fish and many more!\n随着时间的推移,为这些系统创建了许多不同类型的 shell,其中一些占据了主导地位:Bash、Csh、Zsh、Fish 等等!\nAll shells originate from the Bourne Shell, called sh. \u0026ldquo;Bourne\u0026rdquo; because its creator was Steve Bourne.\n所有 shell 都源自 Bourne Shell,称为sh 。 “伯恩”是因为它的创造者是史蒂夫·伯恩。\nBash means Bourne-again shell. sh was proprietary and not open source, and Bash was created in 1989 to create a free alternative for the GNU project and the Free Software Foundation. Since projects had to pay to use the Bourne shell, Bash became very popular.\nBash 的意思是Bourne-again shell 。 sh是专有的而不是开源的,Bash 于 1989 年创建,旨在为 GNU 项目和自由软件基金会创建免费的替代方案。由于项目必须付费才能使用 Bourne shell,因此 Bash 变得非常流行。\nIf you use a Mac, try opening your Mac terminal. That by default is running ZSH. or,pre−Catalina,Bashor, pre-Catalina, Bash 如果您使用 Mac,请尝试打开 Mac 终端。默认情况下运行的是 ZSH。 (或者,Catalina 之前的 Bash)\nYou can set up your system to run any kind of shell, for example I use the Fish shell.\n您可以将系统设置为运行任何类型的 shell,例如我使用 Fish shell。\nEach single shell has its own unique features and advanced usage, but they all share a common functionality: they can let you execute programs, and they can be programmed.\n每个 shell 都有自己独特的功能和高级用法,但它们都有一个共同的功能:它们可以让您执行程序,并且可以进行编程。\nIn the rest of this handbook we\u0026rsquo;ll see in detail the most common commands you will use.\n在本手册的其余部分,我们将详细介绍您将使用的最常用命令。\nman # The first command I want to introduce is a command that will help you understand all the other commands.\n我想介绍的第一个命令将帮助您理解所有其他命令。\nEvery time I don\u0026rsquo;t know how to use a command, I type man \u0026lt;command\u0026gt; to get the manual:\n每次我不知道如何使用命令时,我都会输入man \u0026lt;command\u0026gt;来获取手册:\nThis is a man from∗manual∗from *manual* page. Man pages are an essential tool to learn, as a developer. They contain so much information that sometimes it\u0026rsquo;s almost too much.\n这是一个 man(来自手册)页面。手册页是开发人员学习的重要工具。它们包含太多信息,有时几乎太多了。\nThe above screenshot is just 1 of 14 screens of explanation for the ls command.\n上面的屏幕截图只是ls命令解释的 14 个屏幕之一。\nMost of the times when I\u0026rsquo;m in need to learn a command quickly I use this site called tldr pages: https://tldr.sh/. It\u0026rsquo;s a command you can install, then you run it like this: tldr \u0026lt;command\u0026gt;, which gives you a very quick overview of a command, with some handy examples of common usage scenarios:\n大多数时候,当我需要快速学习命令时,我会使用这个名为tldr 页面的网站: https://tldr.sh/ 。这是一个可以安装的命令,然后像这样运行它: tldr \u0026lt;command\u0026gt; ,它可以让您快速了解命令,并提供一些常见使用场景的方便示例:\nThis is not a substitute for man, but a handy tool to avoid losing yourself in the huge amount of information present in a man page. Then you can use the man page to explore all the different options and parameters you can use on a command.\n这并不是man的替代品,而是一个方便的工具,可以避免在手册页中的大量信息中迷失方向。然后,您可以使用手册页来探索可在命令上使用的所有不同选项和参数。\nls # Inside a folder you can list all the files that the folder contains using the ls command:\n在文件夹内,您可以使用ls命令列出该文件夹包含的所有文件:\nls If you add a folder name or path, it will print that folder contents:\n如果您添加文件夹名称或路径,它将打印该文件夹内容:\nls /bin ls accepts a lot of options. One of my favorite options combinations is -al. Try it:\nls接受很多选项。我最喜欢的选项组合之一是-al 。尝试一下:\nls -al /bin compared to the plain ls, this returns much more information.\n与普通的ls相比,这会返回更多信息。\nYou have, from left to right:\n你有,从左到右:\nthe file permissions andifyoursystemsupportsACLs,yougetanACLflagaswelland if your system supports ACLs, you get an ACL flag as well 文件权限(如果您的系统支持 ACL,您也会获得 ACL 标志) the number of links to that file\n该文件的链接数 the owner of the file\n文件的所有者 the group of the file\n文件组 the file size in bytes\n文件大小(以字节为单位) the file modified datetime\n文件修改日期时间 the file name\n文件名 This set of data is generated by the l option. The a option instead also shows the hidden files.\n这组数据是由l选项生成的。 a选项还显示隐藏文件。\nHidden files are files that start with a dot ‘.‘`.` .\n隐藏文件是以点 ‘.‘ `.` 开头的文件。\ncd # Once you have a folder, you can move into it using the cd command. cd means change directory. You invoke it specifying a folder to move into. You can specify a folder name, or an entire path.\n有了文件夹后,您可以使用cd命令进入该文件夹。 cd表示更改****目录。您可以调用它并指定要移入的文件夹。您可以指定文件夹名称或整个路径。\nExample:\n例子:\nmkdir fruits cd fruits Now you are into the fruits folder.\n现在您已进入fruits文件夹。\nYou can use the .. special path to indicate the parent folder:\n您可以使用..特殊路径来指示父文件夹:\ncd .. #back to the home folder The # character indicates the start of the comment, which lasts for the entire line after it\u0026rsquo;s found.\n# 字符表示注释的开始,它在找到后持续整行。\nYou can use it to form a path:\n您可以使用它来形成路径:\nmkdir fruits mkdir cars cd fruits cd ../cars There is another special path indicator which is ., and indicates the current folder.\n还有另一个特殊的路径指示器是. ,并表示当前文件夹。\nYou can also use absolute paths, which start from the root folder /:\n您还可以使用绝对路径,从根文件夹/开始:\ncd /etc This command works on Linux, macOS, WSL, and anywhere you have a UNIX environment\n此命令适用于 Linux、macOS、WSL 以及任何拥有 UNIX 环境的地方\npwd # Whenever you feel lost in the filesystem, call the pwd command to know where you are:\n每当您在文件系统中感到迷失时,请调用pwd命令来了解您所在的位置:\npwd It will print the current folder path.\n它将打印当前文件夹路径。\nmkdir # You create folders using the mkdir command:\n您可以使用mkdir命令创建文件夹:\nmkdir fruits You can create multiple folders with one command:\n您可以使用一个命令创建多个文件夹:\nmkdir dogs cars You can also create multiple nested folders by adding the -p option:\n您还可以通过添加-p选项来创建多个嵌套文件夹:\nmkdir -p fruits/apples Options in UNIX commands commonly take this form. You add them right after the command name, and they change how the command behaves. You can often combine multiple options, too.\nUNIX 命令中的选项通常采用这种形式。您可以将它们添加到命令名称之后,它们会更改命令的行为方式。您通常也可以组合多个选项。\nYou can find which options a command supports by typing man \u0026lt;commandname\u0026gt;. Try now with man mkdir for example pressthe‘q‘keytoescthemanpagepress the `q` key to esc the man page . Man pages are the amazing built-in help for UNIX.\n您可以通过键入man \u0026lt;commandname\u0026gt;来查找命令支持哪些选项。例如,现在尝试使用man mkdir (按q键退出手册页)。手册页是 UNIX 令人惊叹的内置帮助。\nrmdir # Just as you can create a folder using mkdir, you can delete a folder using rmdir:\n正如您可以使用mkdir创建文件夹一样,您可以使用rmdir删除文件夹:\nmkdir fruits rmdir fruits You can also delete multiple folders at once:\n您还可以一次删除多个文件夹:\nmkdir fruits cars rmdir fruits cars The folder you delete must be empty.\n您删除的文件夹必须是空的。\nTo delete folders with files in them, we\u0026rsquo;ll use the more generic rm command which deletes files and folders, using the -rf options:\n要删除其中包含文件的文件夹,我们将使用更通用的rm命令来删除文件和文件夹,并使用-rf选项:\nrm -rf fruits cars Be careful as this command does not ask for confirmation and it will immediately remove anything you ask it to remove.\n请小心,因为此命令不会要求确认,并且它会立即删除您要求其删除的任何内容。\nThere is no bin when removing files from the command line, and recovering lost files can be hard.\n从命令行删除文件时没有bin ,并且恢复丢失的文件可能很困难。\nmv # Once you have a file, you can move it around using the mv command. You specify the file current path, and its new path:\n一旦有了文件,就可以使用mv命令移动它。您指定文件的当前路径及其新路径:\ntouch pear mv pear new_pear The pear file is now moved to new_pear. This is how you rename files and folders.\npear文件现在已移至new_pear 。这就是重命名文件和文件夹的方法。\nIf the last parameter is a folder, the file located at the first parameter path is going to be moved into that folder. In this case, you can specify a list of files and they will all be moved in the folder path identified by the last parameter:\n如果最后一个参数是文件夹,则位于第一个参数路径的文件将被移动到该文件夹​​中。在这种情况下,您可以指定文件列表,它们将全部移动到最后一个参数标识的文件夹路径中:\ntouch pear touch apple mkdir fruits mv pear apple fruits #pear and apple moved to the fruits folder cp # You can copy a file using the cp command:\n您可以使用cp命令复制文件:\ntouch test cp apple another_apple To copy folders you need to add the -r option to recursively copy the whole folder contents:\n要复制文件夹,您需要添加-r选项以递归复制整个文件夹内容:\nmkdir fruits cp -r fruits cars open # The open command lets you open a file using this syntax:\nopen命令允许您使用以下语法打开文件:\nopen \u0026lt;filename\u0026gt; You can also open a directory, which on macOS opens the Finder app with the current directory open:\n您还可以打开一个目录,在 macOS 上,该目录会打开 Finder 应用程序并打开当前目录:\nopen \u0026lt;directory name\u0026gt; I use it all the time to open the current directory:\n我一直用它来打开当前目录:\nopen . The special . symbol points to the current directory, as .. points to the parent directory\n特别的.符号指向当前目录,as ..指向父目录\nThe same command can also be be used to run an application:\n相同的命令也可用于运行应用程序:\nopen \u0026lt;application name\u0026gt; touch # You can create an empty file using the touch command:\n您可以使用touch命令创建一个空文件:\ntouch apple If the file already exists, it opens the file in write mode, and the timestamp of the file is updated.\n如果文件已存在,则以写入模式打开文件,并更新文件的时间戳。\nfind # The find command can be used to find files or folders matching a particular search pattern. It searches recursively.\nfind命令可用于查找与特定搜索模式匹配的文件或文件夹。它递归地搜索。\nLet\u0026rsquo;s learn it by example.\n让我们通过例子来学习一下。\nFind all the files under the current tree that have the .js extension and print the relative path of each file matching:\n查找当前树下所有具有.js扩展名的文件,并打印每个匹配文件的相对路径:\nfind . -name '*.js' It\u0026rsquo;s important to use quotes around special characters like * to avoid the shell interpreting them.\n在*等特殊字符周围使用引号很重要,以避免 shell 解释它们。\nFind directories under the current tree matching the name \u0026ldquo;src\u0026rdquo;:\n在当前树下查找与名称“src”匹配的目录:\nfind . - type d -name src Use -type f to search only files, or -type l to only search symbolic links.\n使用-type f仅搜索文件,或使用-type l仅搜索符号链接。\n-name is case sensitive. use -iname to perform a case-insensitive search.\n-name区分大小写。使用-iname执行不区分大小写的搜索。\nYou can search under multiple root trees:\n您可以在多个根树下搜索:\nfind folder1 folder2 -name filename.txt Find directories under the current tree matching the name \u0026ldquo;node_modules\u0026rdquo; or \u0026lsquo;public\u0026rsquo;:\n在当前树下查找与名称“node_modules”或“public”匹配的目录:\nfind . - type d -name node_modules -or -name public You can also exclude a path, using -not -path:\n您还可以使用-not -path排除路径:\nfind . - type d -name '*.md' -not -path 'node_modules/*' You can search files that have more than 100 characters bytesbytes in them:\n您可以搜索包含超过 100 个字符(字节)的文件:\nfind . - type f -size +100c Search files bigger than 100KB but smaller than 1MB:\n搜索大于 100KB 但小于 1MB 的文件:\nfind . - type f -size +100k -size -1M Search files edited more than 3 days ago\n搜索 3 天前编辑的文件\nfind . - type f -mtime +3 Search files edited in the last 24 hours\n搜索过去 24 小时内编辑的文件\nfind . - type f -mtime -1 You can delete all the files matching a search by adding the -delete option. This deletes all the files edited in the last 24 hours:\n您可以通过添加-delete选项来删除与搜索匹配的所有文件。这将删除过去 24 小时内编辑的所有文件:\nfind . - type f -mtime -1 -delete You can execute a command on each result of the search. In this example we run cat to print the file content:\n您可以对每个搜索结果执行命令。在此示例中,我们运行cat来打印文件内容:\nfind . - type f - exec cat {} \\; notice the terminating \\;. {} is filled with the file name at execution time.\n注意终止\\; 。 {}填充执行时的文件名。\nln # The ln command is part of the Linux file system commands.\nln命令是 Linux 文件系统命令的一部分。\nIt\u0026rsquo;s used to create links. What is a link? It\u0026rsquo;s like a pointer to another file. A file that points to another file. You might be familiar with Windows shortcuts. They\u0026rsquo;re similar.\n它用于创建链接。什么是链接?它就像一个指向另一个文件的指针。一个文件指向另一个文件。您可能熟悉 Windows 快捷方式。他们很相似。\nWe have 2 types of links: hard links and soft links.\n我们有两种类型的链接:硬链接和软链接。\nHard links are rarely used. They have a few limitations: you can\u0026rsquo;t link to directories, and you can\u0026rsquo;t link to external filesystems disksdisks .\n硬链接很少使用。它们有一些限制:您不能链接到目录,也不能链接到外部文件系统(磁盘)。\nA hard link is created using\n使用以下命令创建硬链接\nln \u0026lt;original\u0026gt; \u0026lt;link\u0026gt; For example, say you have a file called recipes.txt. You can create a hard link to it using:\n例如,假设您有一个名为recipes.txt 的文件。您可以使用以下方法创建指向它的硬链接:\nln recipes.txt newrecipes.txt The new hard link you created is indistinguishable from a regular file:\n您创建的新硬链接与常规文件没有区别:\nNow any time you edit any of those files, the content will be updated for both.\n现在,每当您编辑这些文件中的任何一个时,这两个文件的内容都会更新。\nIf you delete the original file, the link will still contain the original file content, as that\u0026rsquo;s not removed until there is one hard link pointing to it.\n如果您删除原始文件,该链接仍将包含原始文件内容,因为只有在有一个硬链接指向它时,该链接才会被删除。\nSoft links are different. They are more powerful as you can link to other filesystems and to directories, but when the original is removed, the link will be broken.\n软链接则不同。它们更强大,因为您可以链接到其他文件系统和目录,但是当删除原始文件系统和目录时,链接将被破坏。\nYou create soft links using the -s option of ln:\n您可以使用ln的-s选项创建软链接:\nln -s \u0026lt;original\u0026gt; \u0026lt;link\u0026gt; For example, say you have a file called recipes.txt. You can create a soft link to it using:\n例如,假设您有一个名为recipes.txt 的文件。您可以使用以下方法创建指向它的软链接:\nln -s recipes.txt newrecipes.txt In this case you can see there\u0026rsquo;s a special l flag when you list the file using ls -al, and the file name has a @ at the end, and it\u0026rsquo;s colored differently if you have colors enabled:\n在这种情况下,当您使用ls -al列出文件时,您可以看到有一个特殊的l标志,并且文件名末尾有一个@ ,如果启用了颜色,则其颜色会有所不同:\nNow if you delete the original file, the links will be broken, and the shell will tell you \u0026ldquo;No such file or directory\u0026rdquo; if you try to access it:\n现在,如果您删除原始文件,链接将被破坏,并且如果您尝试访问它,shell 会告诉您“没有这样的文件或目录”:\ngzip # You can compress a file using the gzip compression protocol named LZ77 using the gzip command.\n您可以使用gzip命令使用名为 LZ77的 gzip 压缩协议来压缩文件。\nHere\u0026rsquo;s the simplest usage:\n这是最简单的用法:\ngzip filename This will compress the file, and append a .gz extension to it. The original file is deleted. To prevent this, you can use the -c option and use output redirection to write the output to the filename.gz file:\n这将压缩该文件,并为其附加.gz扩展名。原始文件被删除。为了防止这种情况,您可以使用-c选项并使用输出重定向将输出写入filename.gz文件:\ngzip -c filename \u0026gt; filename.gz The -c option specifies that output will go to the standard output stream, leaving the original file intact\n-c选项指定输出将转到标准输出流,保持原始文件不变\nOr you can use the -k option:\n或者您可以使用-k选项:\ngzip -k filename There are various levels of compression. The more the compression, the longer it will take to compress anddecompressand decompress . Levels range from 1 fastest,worstcompressionfastest, worst compression to 9 slowest,bettercompressionslowest, better compression , and the default is 6.\n压缩有多种级别。压缩越多,压缩(和解压缩)所需的时间就越长。级别范围从 1(最快、最差压缩)到 9(最慢、更好压缩),默认值为 6。\nYou can choose a specific level with the -\u0026lt;NUMBER\u0026gt; option:\n您可以使用-\u0026lt;NUMBER\u0026gt;选项选择特定级别:\ngzip -1 filename You can compress multiple files by listing them:\n您可以通过列出多个文件来压缩它们:\ngzip filename1 filename2 You can compress all the files in a directory, recursively, using the -r option:\n您可以使用-r选项递归地压缩目录中的所有文件:\ngzip -r a_folder The -v option prints the compression percentage information. Here\u0026rsquo;s an example of it being used along with the -k keepkeep option:\n-v选项打印压缩百分比信息。下面是它与-k (保留)选项一起使用的示例:\ngzip can also be used to decompress a file, using the -d option:\ngzip还可以用于解压缩文件,使用-d选项:\ngzip -d filename.gz gunzip # The gunzip command is basically equivalent to the gzip command, except the -d option is always enabled by default.\ngunzip命令基本上等同于gzip命令,只是默认情况下始终启用-d选项。\nThe command can be invoked in this way:\n该命令可以通过以下方式调用:\ngunzip filename.gz This will gunzip and will remove the .gz extension, putting the result in the filename file. If that file exists, it will overwrite that.\n这将进行gunzip并删除.gz扩展名,将结果放入filename文件中。如果该文件存在,它将覆盖该文件。\nYou can extract to a different filename using output redirection using the -c option:\n您可以使用-c选项使用输出重定向来提取到不同的文件名:\ngunzip -c filename.gz \u0026gt; anotherfilename tar # The tar command is used to create an archive, grouping multiple files in a single file.\ntar命令用于创建存档,将多个文件分组到一个文件中。\nIts name comes from the past and means tape archive. Back when archives were stored on tapes.\n它的名字来源于过去,意思是磁带存档。回到档案存储在磁带上的时代。\nThis command creates an archive named archive.tar with the content of file1 and file2:\n此命令创建一个名为archive.tar的存档,其中包含file1和file2的内容:\ntar -cf archive.tar file1 file2 The c option stands for create. The f option is used to write to file the archive.\nc选项代表create 。 f选项用于写入存档。\nTo extract files from an archive in the current folder, use:\n要从当前文件夹中的存档中提取文件,请使用:\ntar -xf archive.tar the x option stands for extract\nx选项代表提取\nand to extract them to a specific directory, use:\n并将它们提取到特定目录,请使用:\ntar -xf archive.tar -C directory You can also just list the files contained in an archive:\n您还可以只列出存档中包含的文件:\ntar is often used to create a compressed archive, gzipping the archive.\ntar通常用于创建压缩档案,对档案进行 gzip 压缩。\nThis is done using the z option:\n这是使用z选项完成的:\ntar -czf archive.tar.gz file1 file2 This is just like creating a tar archive, and then running gzip on it.\n这就像创建一个 tar 存档,然后在其上运行gzip一样。\nTo unarchive a gzipped archive, you can use gunzip, or gzip -d, and then unarchive it, but tar -xf will recognize it\u0026rsquo;s a gzipped archive, and do it for you:\n要取消归档 gzip 压缩档案,您可以使用gunzip或gzip -d ,然后将其取消归档,但tar -xf会识别出它是 gzip 压缩档案,并为您执行此操作:\ntar -xf archive.tar.gz alias # It\u0026rsquo;s common to always run a program with a set of options you like using.\n总是使用一组您喜欢使用的选项来运行程序是很常见的。\nFor example, take the ls command. By default it prints very little information:\n例如,使用ls命令。默认情况下它打印很少的信息:\nwhile using the -al option it will print something more useful, including the file modification date, the size, the owner, and the permissions, also listing hidden files (files starting with a .:\n使用-al选项时,它将打印更有用的内容,包括文件修改日期、大小、所有者和权限,还列出隐藏文件(以.开头的文件:\nYou can create a new command, for example I like to call it ll, that is an alias to ls -al.\n您可以创建一个新命令,例如我喜欢将其称为ll ,这是ls -al的别名。\nYou do it in this way:\n你可以这样做:\nalias ll= 'ls -al' Once you do, you can call ll just like it was a regular UNIX command:\n完成后,您可以像调用常规 UNIX 命令一样调用ll :\nNow calling alias without any option will list the aliases defined:\n现在不带任何选项调用alias将列出定义的别名:\nThe alias will work until the terminal session is closed.\n该别名将一直有效,直到终端会话关闭。\nTo make it permanent, you need to add it to the shell configuration, which could be ~/.bashrc or ~/.profile or ~/.bash_profile if you use the Bash shell, depending on the use case.\n为了使其永久化,您需要将其添加到 shell 配置中,如果您使用 Bash shell,则可以是~/.bashrc或~/.profile或~/.bash_profile ,具体取决于用例。\nBe careful with quotes if you have variables in the command: using double quotes the variable is resolved at definition time, using single quotes it\u0026rsquo;s resolved at invocation time. Those 2 are different:\n如果命令中有变量,请小心使用引号:使用双引号,变量在定义时解析,使用单引号,变量在调用时解析。这两个是不同的:\nalias lsthis= \u0026quot;ls $PWD \u0026quot; alias lscurrent= 'ls $PWD' $PWD refers to the current folder the shell is into. If you now navigate away to a new folder, lscurrent lists the files in the new folder, lsthis still lists the files in the folder you were when you defined the alias.\n$PWD 指 shell 所在的当前文件夹。如果您现在导航到新文件夹, lscurrent会列出新文件夹中的文件, lsthis仍会列出您定义别名时所在文件夹中的文件。\ncat # Similar to tail in some way, we have cat. Except cat can also add content to a file, and this makes it super powerful.\n在某种程度上与tail类似,我们有cat 。除了cat还可以向文件添加内容,这使得它超级强大。\nIn its simplest usage, cat prints a file\u0026rsquo;s content to the standard output:\n在最简单的用法中, cat将文件的内容打印到标准输出:\ncat file You can print the content of multiple files:\n您可以打印多个文件的内容:\ncat file1 file2 and using the output redirection operator \u0026gt; you can concatenate the content of multiple files into a new file:\n并使用输出重定向运算符\u0026gt;您可以将多个文件的内容连接到一个新文件中:\ncat file1 file2 \u0026gt; file3 Using \u0026gt;\u0026gt; you can append the content of multiple files into a new file, creating it if it does not exist:\n使用\u0026gt;\u0026gt;您可以将多个文件的内容附加到一个新文件中,如果它不存在则创建它:\ncat file1 file2 \u0026gt;\u0026gt; file3 When watching source code files it\u0026rsquo;s great to see the line numbers, and you can have cat print them using the -n option:\n当观看源代码文件时,很高兴看到行号,并且您可以使用-n选项让cat打印它们:\ncat -n file1 You can only add a number to non-blank lines using -b, or you can also remove all the multiple empty lines using -s.\n您只能使用-b将数字添加到非空行,也可以使用-s删除所有多个空行。\ncat is often used in combination with the pipe operator | to feed a file content as input to another command: cat file1 | anothercommand.\ncat通常与管道运算符|结合使用将文件内容作为另一个命令的输入: cat file1 | anothercommand 。\nless # The less command is one I use a lot. It shows you the content stored inside a file, in a nice and interactive UI.\nless命令是我经常使用的命令。它以漂亮的交互式用户界面向您显示文件中存储的内容。\nUsage: less \u0026lt;filename\u0026gt;.\n用法: less \u0026lt;filename\u0026gt; 。\nOnce you are inside a less session, you can quit by pressing q.\n一旦进入less会话,您可以按q退出。\nYou can navigate the file contents using the up and down keys, or using the space bar and b to navigate page by page. You can also jump to the end of the file pressing G and jump back to the start pressing g.\n您可以使用up和down键导航文件内容,或使用space bar和b逐页导航。您还可以按G跳转到文件末尾,然后按g跳回开头。\nYou can search contents inside the file by pressing / and typing a word to search. This searches forward. You can search backwards using the ? symbol and typing a word.\n您可以通过按/并键入要搜索的单词来搜索文件内的内容。这向前搜索。您可以使用?向后搜索符号并输入一个单词。\nThis command just visualises the file\u0026rsquo;s content. You can directly open an editor by pressing v. It will use the system editor, which in most cases is vim.\n该命令只是可视化文件的内容。您可以通过按v直接打开编辑器。它将使用系统编辑器,在大多数情况下是vim 。\nPressing the F key enters follow mode, or watch mode. When the file is changed by someone else, like from another program, you get to see the changes live. By default this is not happening, and you only see the file version at the time you opened it. You need to press ctrl-C to quit this mode. In this case the behaviour is similar to running the tail -f \u0026lt;filename\u0026gt; command.\n按F键进入跟随模式或监视模式。当其他人(例如从另一个程序)更改文件时,您可以实时看到更改。默认情况下,这种情况不会发生,您只能看到打开文件时的文件版本。您需要按ctrl-C退出此模式。在这种情况下,行为类似于运行tail -f \u0026lt;filename\u0026gt;命令。\nYou can open multiple files, and navigate through them using :n togotothenextfileto go to the next file and :p togotothepreviousto go to the previous .\n您可以打开多个文件,并使用:n (转到下一个文件)和:p (转到上一个文件)浏览它们。\ntail # The best use case of tail in my opinion is when called with the -f option. It opens the file at the end, and watches for file changes. Any time there is new content in the file, it is printed in the window. This is great for watching log files, for example:\n我认为 tail 的最佳用例是使用-f选项调用时。它在末尾打开文件,并监视文件更改。每当文件中有新内容时,都会将其打印在窗口中。这对于查看日志文件非常有用,例如:\ntail -f /var/ log /system.log To exit, press ctrl-C.\n要退出,请按ctrl-C 。\nYou can print the last 10 lines in a file:\n您可以打印文件中的最后 10 行:\ntail -n 10 \u0026lt;filename\u0026gt; You can print the whole file content starting from a specific line using + before the line number:\n您可以在行号之前使用+打印从特定行开始的整个文件内容:\ntail -n +10 \u0026lt;filename\u0026gt; tail can do much more and as always my advice is to check man tail.\ntail可以做更多事情,一如既往,我的建议是检查man tail 。\nwc # The wc command gives us useful information about a file or input it receives via pipes.\nwc命令为我们提供有关文件或通过管道接收的输入的有用信息。\necho test \u0026gt;\u0026gt; test.txt wc test.txt 1 1 5 test.txt Example via pipes, we can count the output of running the ls -al command:\n通过管道示例,我们可以计算运行ls -al命令的输出:\nls -al | wc 6 47 284 The first column returned is the number of lines. The second is the number of words. The third is the number of bytes.\n返回的第一列是行数。第二是字数。第三个是字节数。\nWe can tell it to just count the lines:\n我们可以告诉它只计算行数:\nwc -l test.txt or just the words:\n或者只是这样的话:\nwc -w test.txt or just the bytes:\n或者只是字节:\nwc -c test.txt Bytes in ASCII charsets equate to characters, but with non-ASCII charsets, the number of characters might differ because some characters might take multiple bytes, for example this happens in Unicode.\nASCII 字符集中的字节等同于字符,但对于非 ASCII 字符集,字符数可能会有所不同,因为某些字符可能占用多个字节,例如在 Unicode 中就会发生这种情况。\nIn this case the -m flag will help getting the correct value:\n在这种情况下, -m标志将有助于获取正确的值:\nwc -m test.txt grep # The grep command is a very useful tool, that when you master will help you tremendously in your day to day.\ngrep命令是一个非常有用的工具,当你掌握它时,它将对你的日常工作有很大帮助。\nIf you\u0026rsquo;re wondering, grep stands for global regular expression print\n如果您想知道, grep代表全局正则表达式打印\nYou can use grep to search in files, or combine it with pipes to filter the output of another command.\n您可以使用grep在文件中搜索,或将其与管道结合起来过滤另一个命令的输出。\nFor example here\u0026rsquo;s how we can find the occurences of the document.getElementById line in the index.md file:\n例如,我们如何在index.md文件中查找document.getElementById行的出现:\ngrep document.getElementById index.md Using the -n option it will show the line numbers:\n使用-n选项它将显示行号:\ngrep -n document.getElementById index.md One very useful thing is to tell grep to print 2 lines before, and 2 lines after the matched line, to give us more context. That\u0026rsquo;s done using the -C option, which accepts a number of lines:\n一件非常有用的事情是告诉 grep 在匹配行之前打印 2 行,在匹配行之后打印 2 行,以便为我们提供更多上下文。这是使用-C选项完成的,它接受多行:\ngrep -nC 2 document.getElementById index.md Search is case sensitive by default. Use the -i flag to make it insensitive.\n默认情况下,搜索区分大小写。使用-i标志使其不敏感。\nAs mentioned, you can use grep to filter the output of another command. We can replicate the same functionality as above using:\n如前所述,您可以使用 grep 来过滤另一个命令的输出。我们可以使用以下方法复制与上面相同的功能:\nless index.md | grep -n document.getElementById The search string can be a regular expression, and this makes grep very powerful.\n搜索字符串可以是正则表达式,这使得grep非常强大。\nAnother thing you might find very useful is to invert the result, excluding the lines that match a particular string, using the -v option:\n您可能会发现非常有用的另一件事是使用-v选项反转结果,排除与特定字符串匹配的行:\nsort # Suppose you have a text file which contains the names of dogs:\n假设您有一个包含狗的名字的文本文件:\nThis list is unordered.\n该列表是无序的。\nThe sort command helps us sorting them by name:\nsort命令帮助我们按名称对它们进行排序:\nUse the r option to reverse the order:\n使用r选项反转顺序:\nSorting by default is case sensitive, and alphabetic. Use the --ignore-case option to sort case insensitive, and the -n option to sort using a numeric order.\n默认情况下排序区分大小写并按字母顺序。使用--ignore-case选项不区分大小写进行排序,使用-n选项按数字顺序排序。\nIf the file contains duplicate lines:\n如果文件包含重复行:\nYou can use the -u option to remove them:\n您可以使用-u选项来删除它们:\nsort does not just works on files, as many UNIX commands it also works with pipes, so you can use on the output of another command, for example you can order the files returned by ls with:\nsort不仅仅适用于文件,因为许多 UNIX 命令也适用于管道,因此您可以在另一个命令的输出上使用,例如您可以使用以下命令对ls返回的文件进行排序:\nls | sort sort is very powerful and has lots more options, which you can explore calling man sort.\nsort非常强大,并且有更多选项,您可以调用man sort来探索。\nuniq # uniq is a command useful to sort lines of text.\nuniq是一个用于对文本行进行排序的命令。\nYou can get those lines from a file, or using pipes from the output of another command:\n您可以从文件中获取这些行,或使用管道从另一个命令的输出中获取这些行:\nuniq dogs.txt ls | uniq You need to consider this key thing: uniq will only detect adjacent duplicate lines.\n您需要考虑这一关键事项: uniq只会检测相邻的重复行。\nThis implies that you will most likely use it along with sort:\n这意味着您很可能将它与sort一起使用:\nsort dogs.txt | uniq The sort command has its own way to remove duplicates with the -u ∗unique∗*unique* option. But uniq has more power.\nsort命令有自己的方法来使用-u ∗unique∗ *unique* 选项删除重复项。但uniq的力量更大。\nBy default it removes duplicate lines:\n默认情况下它会删除重复的行:\nYou can tell it to only display duplicate lines, for example, with the -d option:\n您可以告诉它只显示重复的行,例如,使用-d选项:\nsort dogs.txt | uniq -d You can use the -u option to only display non-duplicate lines:\n您可以使用-u选项仅显示非重复行:\nYou can count the occurrences of each line with the -c option:\n您可以使用-c选项计算每行的出现次数:\nUse the special combination:\n使用特殊组合:\nsort dogs.txt | uniq -c | sort -nr to then sort those lines by most frequent:\n然后按最常见的顺序对这些行进行排序:\ndiff # diff is a handy command. Suppose you have 2 files, which contain almost the same information, but you can\u0026rsquo;t find the difference between the two.\ndiff是一个方便的命令。假设你有2个文件,它们包含几乎相同的信息,但你找不到两者之间的差异。\ndiff will process the files and will tell you what\u0026rsquo;s the difference.\ndiff将处理文件并告诉您有什么区别。\nSuppose you have 2 files: dogs.txt and moredogs.txt. The difference is that moredogs.txt contains one more dog name:\n假设您有 2 个文件: dogs.txt和moredogs.txt 。不同之处在于moredogs.txt多了一个狗的名字:\ndiff dogs.txt moredogs.txt will tell you the second file has one more line, line 3 with the line Vanille:\ndiff dogs.txt moredogs.txt会告诉你第二个文件还有一行,第 3 行带有Vanille行:\nIf you invert the order of the files, it will tell you that the second file is missing line 3, whose content is Vanille:\n如果你颠倒文件的顺序,它会告诉你第二个文件缺少第3行,其内容是Vanille :\nUsing the -y option will compare the 2 files line by line:\n使用-y选项将逐行比较两个文件:\nThe -u option however will be more familiar to you, because that\u0026rsquo;s the same used by the Git version control system to display differences between versions:\n不过,您会更熟悉-u选项,因为 Git 版本控制系统使用该选项来显示版本之间的差异:\nComparing directories works in the same way. You must use the -r option to compare recursively goingintosubdirectoriesgoing into subdirectories :\n比较目录的工作方式相同。您必须使用-r选项进行递归比较(进入子目录):\nIn case you\u0026rsquo;re interested in which files differ, rather than the content, use the r and q options:\n如果您对哪些文件不同而不是内容感兴趣,请使用r和q选项:\nThere are many more options you can explore in the man page running man diff:\n您可以在运行man diff手册页中探索更多选项:\necho # The echo command does one simple job: it prints to the output the argument passed to it.\necho命令执行一项简单的工作:它将传递给它的参数打印到输出。\nThis example:\n这个例子:\necho \u0026quot;hello\u0026quot; will print hello to the terminal.\n将向终端打印hello 。\nWe can append the output to a file:\n我们可以将输出附加到文件中:\necho \u0026quot;hello\u0026quot; \u0026gt;\u0026gt; output.txt We can interpolate environment variables:\n我们可以插入环境变量:\necho \u0026quot;The path variable is $PATH \u0026quot; Beware that special characters need to be escaped with a backslash \\. $ for example:\n请注意,特殊字符需要使用反斜杠\\进行转义。 $例如:\nThis is just the start. We can do some nice things when it comes to interacting with the shell features.\n这只是开始。在与 shell 功能交互时,我们可以做一些不错的事情。\nWe can echo the files in the current folder:\n我们可以回显当前文件夹中的文件:\necho * We can echo the files in the current folder that start with the letter o:\n我们可以回显当前文件夹中以字母o开头的文件:\necho o* Any valid Bash oranyshellyouareusingor any shell you are using command and feature can be used here.\n任何有效的 Bash(或您正在使用的任何 shell)命令和功能都可以在此处使用。\nYou can print your home folder path:\n您可以打印您的主文件夹路径:\necho ~ You can also execute commands, and print the result to the standard output ortofile,asyousawor to file, as you saw :\n您还可以执行命令,并将结果打印到标准输出(或打印到文件,如您所见):\necho $(ls -al) Note that whitespace is not preserved by default. You need to wrap the command in double quotes to do so:\n请注意,默认情况下不保留空格。您需要将命令用双引号括起来才能执行此操作:\nYou can generate a list of strings, for example ranges:\n您可以生成字符串列表,例如范围:\necho {1..5} chown # Every file/directory in an Operating System like Linux or macOS andeveryUNIXsystemsingeneraland every UNIX systems in general has an owner.\nLinux 或 macOS(以及一般的每个 UNIX 系统)等操作系统中的每个文件/目录都有一个所有者。\nThe owner of a file can do everything with it. It can decide the fate of that file.\n文件的所有者可以用它做任何事情。它可以决定该文件的命运。\nThe owner andthe‘root‘userand the `root` user can change the owner to another user, too, using the chown command:\n所有者(和root用户)也可以使用chown命令将所有者更改为其他用户:\nchown \u0026lt;owner\u0026gt; \u0026lt;file\u0026gt; Like this:\n像这样:\nchown flavio test.txt For example if you have a file that\u0026rsquo;s owned by root, you can\u0026rsquo;t write to it as another user:\n例如,如果您有一个归root所有的文件,则无法以其他用户身份写入该文件:\nYou can use chown to transfer the ownership to you:\n您可以使用chown将所有权转移给您:\nIt\u0026rsquo;s rather common to have the need to change the ownership of a directory, and recursively all the files contained, plus all the subdirectories and the files contained in them, too.\n需要更改目录的所有权以及递归地更改其中包含的所有文件以及所有子目录和其中包含的文件的所有权是相当常见的。\nYou can do so using the -R flag:\n您可以使用-R标志来执行此操作:\nchown -R \u0026lt;owner\u0026gt; \u0026lt;file\u0026gt; Files/directories don\u0026rsquo;t just have an owner, they also have a group. Through this command you can change that simultaneously while you change the owner:\n文件/目录不仅有一个所有者,它们还有一个组。通过此命令,您可以在更改所有者的同时更改它:\nchown \u0026lt;owner\u0026gt;:\u0026lt;group\u0026gt; \u0026lt;file\u0026gt; Example:\n例子:\nchown flavio:users test.txt You can also just change the group of a file using the chgrp command:\n您还可以使用chgrp命令更改文件组:\nchgrp \u0026lt;group\u0026gt; \u0026lt;filename\u0026gt; chmod # Every file in the Linux / macOS Operating Systems andUNIXsystemsingeneraland UNIX systems in general has 3 permissions: Read, write, execute.\nLinux / macOS 操作系统(以及一般的 UNIX 系统)中的每个文件都有 3 个权限:读、写、执行。\nGo into a folder, and run the ls -al command.\n进入文件夹,然后运行ls -al命令。\nThe weird strings you see on each file line, like drwxr-xr-x, define the permissions of the file or folder.\n您在每个文件行上看到的奇怪字符串(例如drwxr-xr-x )定义了文件或文件夹的权限。\nLet\u0026rsquo;s dissect it.\n我们来剖析一下。\nThe first letter indicates the type of file:\n第一个字母表示文件类型:\n- means it\u0026rsquo;s a normal file\n-表示这是一个普通文件 d means it\u0026rsquo;s a directory\nd表示它是一个目录 l means it\u0026rsquo;s a link\nl表示这是一个链接 Then you have 3 sets of values:\n然后你有 3 组值:\nThe first set represents the permissions of the owner of the file\n第一组代表文件所有者的权限 The second set represents the permissions of the members of the group the file is associated to\n第二组表示文件关联的组成员的权限 The third set represents the permissions of the everyone else\n第三组代表其他人的权限 Those sets are composed by 3 values. rwx means that specific persona has read, write and execution access. Anything that is removed is swapped with a -, which lets you form various combinations of values and relative permissions: rw-, r--, r-x, and so on.\n这些集合由 3 个值组成。 rwx表示特定角色具有读、写和执行访问权限。任何删除的内容都会与-交换,这使您可以形成值和相对权限的各种组合: rw- 、 r-- 、 rx等。\nYou can change the permissions given to a file using the chmod command.\n您可以使用chmod命令更改赋予文件的权限。\nchmod can be used in 2 ways. The first is using symbolic arguments, the second is using numeric arguments. Let\u0026rsquo;s start with symbols first, which is more intuitive.\nchmod有两种使用方式。第一个是使用符号参数,第二个是使用数字参数。我们先从符号开始,这样更直观。\nYou type chmod followed by a space, and a letter:\n您输入chmod后跟一个空格和一个字母:\na stands for all\na代表全部 u stands for user\nu代表用户 g stands for group\ng代表组 o stands for others\no代表其他 Then you type either + or - to add a permission, or to remove it. Then you enter one or more permissions symbols ‘r‘,‘w‘,‘x‘`r`, `w`, `x` .\n然后输入+或-来添加或删除权限。然后输入一个或多个权限符号( r 、 w 、 x )。\nAll followed by the file or folder name.\n全部后跟文件或文件夹名称。\nHere are some examples:\n以下是一些示例:\nchmod a+r filename #everyone can now read chmod a+rw filename #everyone can now read and write chmod o-rwx filename #others (not the owner, not in the same group of the file) cannot read, write or execute the file You can apply the same permissions to multiple personas by adding multiple letters before the +/-:\n您可以通过- +之前添加多个字母来将相同的权限应用于多个角色:\nchmod og-r filename #other and group can't read any more In case you are editing a folder, you can apply the permissions to every file contained in that folder using the -r recursiverecursive flag.\n如果您正在编辑文件夹,则可以使用-r (递归)标志将权限应用于该文件夹中包含的每个文件。\nNumeric arguments are faster but I find them hard to remember when you are not using them day to day. You use a digit that represents the permissions of the persona. This number value can be a maximum of 7, and it\u0026rsquo;s calculated in this way:\n数字参数速度更快,但我发现当你不每天使用它们时很难记住它们。您使用代表角色权限的数字。该数值最大可为 7,计算方法如下:\n1 if has execution permission\n1是否有执行权限 2 if has write permission\n2是否有写权限 4 if has read permission\n4是否有读权限 This gives us 4 combinations:\n这给了我们 4 种组合:\n0 no permissions\n0无权限 1 can execute\n1可以执行 2 can write\n2可以写 3 can write, execute\n3可以写入、执行 4 can read\n4可以阅读 5 can read, execute\n5可以读取、执行 6 can read, write\n6可以读、写 7 can read, write and execute\n7可以读、写、执行 We use them in pairs of 3, to set the permissions of all the 3 groups altogether:\n我们将它们 3 个成对使用,以总共设置所有 3 个组的权限:\nchmod 777 filename chmod 755 filename chmod 644 filename umask # When you create a file, you don\u0026rsquo;t have to decide permissions up front. Permissions have defaults.\n创建文件时,您不必预先决定权限。权限有默认值。\nThose defaults can be controlled and modified using the umask command.\n可以使用umask命令控制和修改这些默认值。\nTyping umask with no arguments will show you the current umask, in this case 0022:\n不带参数输入umask将显示当前的 umask,在本例中为0022 :\nWhat does 0022 mean? That\u0026rsquo;s an octal value that represent the permissions.\n0022是什么意思?这是代表权限的八进制值。\nAnother common value is 0002.\n另一个常见的值是0002 。\nUse umask -S to see a human-readable notation:\n使用umask -S查看人类可读的符号:\nIn this case, the user ‘u‘`u` , owner of the file, has read, write and execution permissions on files.\n在这种情况下,文件的所有者用户 ‘u‘ `u` 拥有文件的读、写和执行权限。\nOther users belonging to the same group ‘g‘`g` have read and execution permission, same as all the other users ‘o‘`o` .\n属于同一组的其他用户 ‘g‘ `g` 具有读取和执行权限,与所有其他用户 ‘o‘ `o` 相同。\nIn the numeric notation, we typically change the last 3 digits.\n在数字表示法中,我们通常更改最后 3 位数字。\nHere\u0026rsquo;s a list that gives a meaning to the number:\n这是一个给出数字含义的列表:\n0 read, write, execute\n0读、写、执行 1 read and write\n1读写 2 read and execute\n2读取并执行 3 read only\n3只读 4 write and execute\n4写入并执行 5 write only\n5只写 6 execute only\n6只执行 7 no permissions\n7无权限 Note that this numeric notation differs from the one we use in chmod.\n请注意,此数字表示法与我们在chmod中使用的数字表示法不同。\nWe can set a new value for the mask setting the value in numeric format:\n我们可以为掩码设置一个新值,以数字格式设置该值:\numask 002 or you can change a specific role\u0026rsquo;s permission:\n或者您可以更改特定角色的权限:\numask g+r du # The du command will calculate the size of a directory as a whole:\ndu命令将计算整个目录的大小:\ndu The 32 number here is a value expressed in bytes.\n这里的32数字是一个以字节表示的值。\nRunning du * will calculate the size of each file individually:\n运行du *将单独计算每个文件的大小:\nYou can set du to display values in MegaBytes using du -m, and GigaBytes using du -g.\n您可以使用du -m将du设置为以兆字节为单位显示值,并使用du -g将 du 设置为以千兆字节为单位显示值。\nThe -h option will show a human-readable notation for sizes, adapting to the size:\n-h选项将显示人类可读的大小符号,以适应大小:\nAdding the -a option will print the size of each file in the directories, too:\n添加-a选项也会打印目录中每个文件的大小:\nA handy thing is to sort the directories by size:\n一个方便的事情是按大小对目录进行排序:\ndu -h \u0026lt;directory\u0026gt; | sort -nr and then piping to head to only get the first 10 results:\n然后通过管道连接到head只获取前 10 个结果:\ndf # The df command is used to get disk usage information.\ndf命令用于获取磁盘使用信息。\nIts basic form will print information about the volumes mounted:\n其基本形式将打印有关已安装卷的信息:\nUsing the -h option ‘df−h‘`df -h` will show those values in a human-readable format:\n使用-h选项 ‘df−h‘ `df -h` 将以人类可读的格式显示这些值:\nYou can also specify a file or directory name to get information about the specific volume it lives on:\n您还可以指定文件或目录名称来获取有关其所在特定卷的信息:\nbasename # Suppose you have a path to a file, for example /Users/flavio/test.txt.\n假设您有一个文件的路径,例如/Users/flavio/test.txt 。\nRunning\n跑步\nbasename /Users/flavio/test.txt will return the test.txt string:\n将返回test.txt字符串:\nIf you run basename on a path string that points to a directory, you will get the last segment of the path. In this example, /Users/flavio is a directory:\n如果您在指向目录的路径字符串上运行basename ,您将获得路径的最后一段。在此示例中, /Users/flavio是一个目录:\ndirname # Suppose you have a path to a file, for example /Users/flavio/test.txt.\n假设您有一个文件的路径,例如/Users/flavio/test.txt 。\nRunning\n跑步\ndirname /Users/flavio/test.txt will return the /Users/flavio string:\n将返回/Users/flavio字符串:\nps # Your computer is running, at all times, tons of different processes.\n您的计算机始终运行着大量不同的进程。\nYou can inspect them all using the ps command:\n您可以使用ps命令检查它们:\nThis is the list of user-initiated processes currently running in the current session.\n这是当前会话中当前运行的用户启动进程的列表。\nHere I have a few fish shell instances, mostly opened by VS Code inside the editor, and an instances of Hugo running the development preview of a site.\n这里我有一些fish shell 实例,大部分是通过编辑器内的 VS Code 打开的,还有一个运行网站开发预览的 Hugo 实例。\nThose are just the commands assigned to the current user. To list all processes we need to pass some options to ps.\n这些只是分配给当前用户的命令。要列出所有进程,我们需要将一些选项传递给ps 。\nThe most common I use is ps ax:\n我最常用的是ps ax :\nThe a option is used to also list other users processes, not just our own. x shows processes not linked to any terminal notinitiatedbyusersthroughaterminalnot initiated by users through a terminal .\na选项还用于列出其他用户进程,而不仅仅是我们自己的进程。 x显示未链接到任何终端的进程(不是由用户通过终端启动的)。\nAs you can see, the longer commands are cut. Use the command ps axww to continue the command listing on a new line instead of cutting it:\n正如您所看到的,较长的命令被删除了。使用命令ps axww在新行上继续列出命令而不是剪切它:\nWe need to specify w 2 times to apply this setting, it\u0026rsquo;s not a typo.\n我们需要指定w 2 次才能应用此设置,这不是拼写错误。\nYou can search for a specific process combining grep with a pipe, like this:\n您可以将grep与管道结合起来搜索特定进程,如下所示:\nps axww | grep \u0026quot;Visual Studio Code\u0026quot; The columns returned by ps represent some key information.\nps返回的列代表一些关键信息。\nThe first information is PID, the process ID. This is key when you want to reference this process in another command, for example to kill it.\n第一个信息是PID ,即进程 ID。当您想在另一个命令中引用此进程(例如杀死它)时,这是关键。\nThen we have TT that tells us the terminal id used.\n然后我们有TT告诉我们所使用的终端 ID。\nThen STAT tells us the state of the process:\n然后STAT告诉我们进程的状态:\nI a process that is idle sleepingforlongerthanabout20secondssleeping for longer than about 20 seconds R a runnable process S a process that is sleeping for less than about 20 seconds T a stopped process U a process in uninterruptible wait Z a dead process a∗zombie∗a *zombie* I一个空闲进程(休眠时间超过约 20 秒) R一个可运行的进程 S睡眠时间少于 20 秒的进程 T已停止的进程 U进程处于不间断等待状态 Z死进程(僵尸)\nIf you have more than one letter, the second represents further information, which can be very technical.\n如果您有多个字母,第二个字母代表更多信息,这可能非常技术性。\nIt\u0026rsquo;s common to have + which indicates the process is in the foreground in its terminal. s means the process is a session leader.\n通常有+表示该进程位于终端的前台。 s表示该进程是 会话领导者。\nTIME tells us how long the process has been running.\nTIME告诉我们该进程已经运行了多长时间。\ntop # A quick guide to the top command, used to list the processes running in real time\ntop命令快速指南,用于列出实时运行的进程\nThe top command is used to display dynamic real-time information about running processes in the system.\ntop命令用于显示系统中正在运行的进程的动态实时信息。\nIt\u0026rsquo;s really handy to understand what is going on.\n了解正在发生的事情真的很方便。\nIts usage is simple, you just type top, and the terminal will be fully immersed in this new view:\n它的用法很简单,你只需输入top ,终端就会完全沉浸在这个新视图中:\nThe process is long-running. To quit, you can type the q letter or ctrl-C.\n该过程是长期运行的。要退出,您可以输入q字母或ctrl-C 。\nThere\u0026rsquo;s a lot of information being given to us: the number of processes, how many are running or sleeping, the system load, the CPU usage, and a lot more.\n我们获得了很多信息:进程数、正在运行或休眠的进程数、系统负载、CPU 使用率等等。\nBelow, the list of processes taking the most memory and CPU is constantly updated.\n下面,占用最多内存和 CPU 的进程列表不断更新。\nBy default, as you can see from the %CPU column highlighted, they are sorted by the CPU used.\n默认情况下,正如您从突出显示的%CPU列中看到的那样,它们按使用的 CPU 排序。\nYou can add a flag to sort processes by memory utilized:\n您可以添加一个标志来按内存使用情况对进程进行排序:\ntop -o mem kill # Linux processes can receive signals and react to them.\nLinux 进程可以接收信号并对信号做出反应。\nThat\u0026rsquo;s one way we can interact with running programs.\n这是我们与正在运行的程序交互的一种方式。\nThe kill program can send a variety of signals to a program.\nkill程序可以向程序发送各种信号。\nIt\u0026rsquo;s not just used to terminate a program, like the name would suggest, but that\u0026rsquo;s its main job.\n正如其名称所暗示的那样,它不仅仅用于终止程序,但这才是它的主要工作。\nWe use it in this way:\n我们这样使用它:\nkill \u0026lt;PID\u0026gt; By default, this sends the TERM signal to the process id specified.\n默认情况下,这会将TERM信号发送到指定的进程 ID。\nWe can use flags to send other signals, including:\n我们可以使用标志来发送其他信号,包括:\nkill -HUP \u0026lt;PID\u0026gt; kill -INT \u0026lt;PID\u0026gt; kill -KILL \u0026lt;PID\u0026gt; kill -TERM \u0026lt;PID\u0026gt; kill -CONT \u0026lt;PID\u0026gt; kill -STOP \u0026lt;PID\u0026gt; HUP means hang up. It\u0026rsquo;s sent automatically when a terminal window that started a process is closed before terminating the process.\nHUP意思是挂断。当启动进程的终端窗口在终止进程之前关闭时,它会自动发送。\nINT means interrupt, and it sends the same signal used when we press ctrl-C in the terminal, which usually terminates the process.\nINT表示中断,它发送的信号与我们在终端中按ctrl-C时使用的信号相同,这通常会终止进程。\nKILL is not sent to the process, but to the operating system kernel, which immediately stops and terminates the process.\nKILL不是发送给进程,而是发送给操作系统内核,操作系统内核会立即停止并终止进程。\nTERM means terminate. The process will receive it and terminate itself. It\u0026rsquo;s the default signal sent by kill.\nTERM意思是终止。该进程将收到它并自行终止。这是kill发送的默认信号。\nCONT means continue. It can be used to resume a stopped process.\nCONT表示继续。它可用于恢复停止的进程。\nSTOP is not sent to the process, but to the operating system kernel, which immediately stops butdoesnotterminatebut does not terminate the process.\nSTOP不会发送到进程,而是发送到操作系统内核,操作系统内核会立即停止(但不会终止)进程。\nYou might see numbers used instead, like kill -1 \u0026lt;PID\u0026gt;. In this case,\n您可能会看到使用数字,例如kill -1 \u0026lt;PID\u0026gt; 。在这种情况下,\n1 corresponds to HUP. 2 corresponds to INT. 9 corresponds to KILL. 15 corresponds to TERM. 18 corresponds to CONT. 15 corresponds to STOP.\n1对应于HUP 。 2对应于INT 。 9对应于KILL 。 15对应于TERM 。 18对应于CONT 。 15对应于STOP 。\nkillall # Similar to the kill command, killall instead of sending a signal to a specific process id will send the signal to multiple processes at once.\n与kill命令类似, killall不是向特定进程id发送信号,而是一次向多个进程发送信号。\nThis is the syntax:\n这是语法:\nkillall \u0026lt;name\u0026gt; where name is the name of a program. For example you can have multiple instances of the top program running, and killall top will terminate them all.\n其中name是程序的名称。例如,您可以运行多个top程序实例, killall top将终止它们。\nYou can specify the signal, like with kill andcheckthe‘kill‘tutorialtoreadmoreaboutthespecifickindsofsignalswecansendand check the `kill` tutorial to read more about the specific kinds of signals we can send , for example:\n您可以指定信号,就像使用kill一样(并查看kill教程以了解有关我们可以发送的特定类型信号的更多信息),例如:\nkillall -HUP top jobs # When we run a command in Linux / macOS, we can set it to run in the background using the \u0026amp; symbol after the command. For example we can run top in the background:\n当我们在Linux / macOS中运行命令时,我们可以使用命令后面的\u0026amp;符号将其设置为在后台运行。例如我们可以在后台运行top :\ntop \u0026amp; This is very handy for long-running programs.\n这对于长时间运行的程序非常方便。\nWe can get back to that program using the fg command. This works fine if we just have one job in the background, otherwise we need to use the job number: fg 1, fg 2 and so on. To get the job number, we use the jobs command.\n我们可以使用fg命令返回该程序。如果我们在后台只有一项作业,那么这很好用,否则我们需要使用作业编号: fg 1 、 fg 2等等。要获取作业编号,我们使用jobs命令。\nSay we run top \u0026amp; and then top -o mem \u0026amp;, so we have 2 top instances running. jobs will tell us this:\n假设我们运行top \u0026amp;然后运行top -o mem \u0026amp; ,所以我们有 2 个 top 实例正在运行。 jobs会告诉我们这一点:\nNow we can switch back to one of those using fg \u0026lt;jobid\u0026gt;. To stop the program again we can hit cmd-Z.\n现在我们可以切换回使用fg \u0026lt;jobid\u0026gt;的其中之一。要再次停止程序,我们可以点击cmd-Z 。\nRunning jobs -l will also print the process id of each job.\n运行jobs -l还将打印每个作业的进程 ID。\nbg # When a command is running you can suspend it using ctrl-Z.\n当命令正在运行时,您可以使用ctrl-Z暂停它。\nThe command will immediately stop, and you get back to the shell terminal.\n该命令将立即停止,您将返回到 shell 终端。\nYou can resume the execution of the command in the background, so it will keep running but it will not prevent you from doing other work in the terminal.\n您可以在后台恢复该命令的执行,因此它将继续运行,但不会阻止您在终端中执行其他工作。\nIn this example I have 2 commands stopped:\n在此示例中,我停止了 2 个命令:\nI can run bg 1 to resume in the background the execution of the job #1.\n我可以运行bg 1在后台恢复作业 #1 的执行。\nI could have also said bg without any option, as the default is to pick the job #1 in the list.\n我也可以说bg而不带任何选项,因为默认是选择列表中的作业#1。\nfg # When a command is running in the background, because you started it with \u0026amp; at the end (example: top \u0026amp; or because you put it in the background with the bg command, you can put it to the foreground using fg.\n当命令在后台运行时,因为您以\u0026amp;结尾(例如: top \u0026amp;或因为您使用bg命令将其置于后台,所以可以使用fg将其置于前台。\nRunning\n跑步\nfg will resume to the foreground the last job that was suspended.\n将恢复到前台上次暂停的作业。\nYou can also specify which job you want to resume to the foreground passing the job number, which you can get using the jobs command.\n您还可以通过作业编号指定要恢复到前台的作业,可以使用jobs命令获取作业编号。\nRunning fg 2 will resume job #2:\n运行fg 2将恢复作业#2:\ntype # A command can be one of those 4 types:\n命令可以是以下 4 种类型之一:\nan executable\n一个可执行文件 a shell built-in program\nshell 内置程序 a shell function\n一个外壳函数 an alias\n别名 The type command can help figure out this, in case we want to know or we\u0026rsquo;re just curious. It will tell you how the command will be interpreted.\ntype命令可以帮助弄清楚这一点,以防我们想知道或者只是好奇。它会告诉您如何解释该命令。\nThe output will depend on the shell used. This is Bash:\n输出将取决于所使用的 shell。这是巴什:\nThis is Zsh:\n这是 Zsh:\nThis is Fish:\n这是鱼:\nOne of the most interesting things here is that for aliases it will tell you what is aliasing to. You can see the ll alias, in the case of Bash and Zsh, but Fish provides it by default, so it will tell you it\u0026rsquo;s a built-in shell function.\n这里最有趣的事情之一是,对于别名,它会告诉您别名是什么。在 Bash 和 Zsh 中,您可以看到ll别名,但 Fish 默认提供它,因此它会告诉您这是一个内置的 shell 函数。\nwhich # Suppose you have a command you can execute, because it\u0026rsquo;s in the shell path, but you want to know where it is located.\n假设您有一个可以执行的命令,因为它位于 shell 路径中,但您想知道它所在的位置。\nYou can do so using which. The command will return the path to the command specified:\n您可以使用which来执行此操作。该命令将返回指定命令的路径:\nwhich will only work for executables stored on disk, not aliases or built-in shell functions.\nwhich适用于存储在磁盘上的可执行文件,不适用于别名或内置 shell 函数。\nnohup # Sometimes you have to run a long-lived process on a remote machine, and then you need to disconnect.\n有时您必须在远程计算机上运行长期进程,然后需要断开连接。\nOr you simply want to prevent the command to be halted if there\u0026rsquo;s any network issue between you and the server.\n或者您只是想防止命令在您和服务器之间出现任何网络问题时停止。\nThe way to make a command run even after you log out or close the session to a server is to use the nohup command.\n即使在注销或关闭服务器会话后仍运行命令的方法是使用nohup命令。\nUse nohup \u0026lt;command\u0026gt; to let the process continue working even after you log out.\n使用nohup \u0026lt;command\u0026gt;让进程在您注销后继续工作。\nxargs # The xargs command is used in a UNIX shell to convert input from standard input into arguments to a command.\nxargs命令在 UNIX shell 中用于将输入从标准输入转换为命令的参数。\nIn other words, through the use of xargs the output of a command is used as the input of another command.\n换句话说,通过使用xargs一个命令的输出被用作另一个命令的输入。\nHere\u0026rsquo;s the syntax you will use:\n这是您将使用的语法:\ncommand1 | xargs command2 We use a pipe ‘∣‘`|` to pass the output to xargs. That will take care of running the command2 command, using the output of command1 as its argument ss .\n我们使用管道 ‘∣‘ `|` 将输出传递给xargs 。这将负责运行command2命令,并使用command1的输出作为其参数。\nLet\u0026rsquo;s do a simple example. You want to remove some specific files from a directory. Those files are listed inside a text file.\n我们来做一个简单的例子。您想要从目录中删除某些特定文件。这些文件列在文本文件中。\nWe have 3 files: file1, file2, file3.\n我们有 3 个文件: file1 、 file2 、 file3 。\nIn todelete.txt we have a list of files we want to delete, in this example file1 and file3:\n在todelete.txt中,我们有一个要删除的文件列表,在本例中为file1和file3 :\nWe will channel the output of cat todelete.txt to the rm command, through xargs.\n我们将通过xargs将cat todelete.txt的输出引导到rm命令。\nIn this way:\n这样:\ncat todelete.txt | xargs rm That\u0026rsquo;s the result, the files we listed are now deleted:\n这就是结果,我们列出的文件现在已被删除:\nThe way it works is that xargs will run rm 2 times, one for each line returned by cat.\n它的工作方式是xargs将运行rm 2 次, cat返回的每一行运行一次。\nThis is the simplest usage of xargs. There are several options we can use.\n这是xargs最简单的用法。我们可以使用多种选项。\nOne of the most useful in my opinion, especially when starting to learn xargs, is -p. Using this option will make xargs print a confirmation prompt with the action it\u0026rsquo;s going to take:\n我认为最有用的之一是-p ,尤其是在开始学习xargs时。使用此选项将使xargs打印一条确认提示,其中包含将要采取的操作:\nThe -n option lets you tell xargs to perform one iteration at a time, so you can individually confirm them with -p. Here we tell xargs to perform one iteration at a time with -n1:\n-n选项允许您告诉xargs一次执行一次迭代,因此您可以使用-p单独确认它们。在这里,我们告诉xargs使用-n1一次执行一次迭代:\nThe -I option is another widely used one. It allows you to get the output into a placeholder, and then you can do various things.\n-I选项是另一个广泛使用的选项。它允许您将输出放入占位符中,然后您可以执行各种操作。\nOne of them is to run multiple commands:\n其中之一是运行多个命令:\ncommand1 | xargs -I % /bin/bash -c 'command2 %; command3 %' You can swap the % symbol I used above with anything else, it\u0026rsquo;s a variable\n您可以将我上面使用的%符号替换为其他符号,它是一个变量\nvim # vim is a very popular file editor, especially among programmers. It\u0026rsquo;s actively developed and frequently updated, and there\u0026rsquo;s a very big community around it. There\u0026rsquo;s even a Vim conference!\nvim是一种非常流行的文件编辑器,尤其是在程序员中。它得到了积极的开发和频繁的更新,并且有一个非常大的社区。甚至还有 Vim 会议!\nvi in modern systems is just an alias to vim, which means vi improved.\n现代系统中的vi只是vim的别名,这意味着vi i m被证明。\nYou start it by running vi on the command line.\n您可以通过在命令行上运行vi来启动它。\nYou can specify a filename at invocation time to edit that specific file:\n您可以在调用时指定文件名来编辑该特定文件:\nvi test.txt You have to know that Vim has 2 main modes:\n你要知道 Vim 有 2 个主要模式:\ncommand or∗normal∗or *normal* mode\n命令(或正常)模式 insert mode\n插入模式 When you start the editor, you are in command mode. You can\u0026rsquo;t enter text like you expect from a GUI-based editor. You have to enter insert mode. You can do this by pressing the i key. Once you do so, the -- INSERT -- word appear at the bottom of the editor:\n当您启动编辑器时,您处于命令模式。您无法像在基于 GUI 的编辑器中那样输入文本。您必须进入插入模式。您可以通过按i键来执行此操作。一旦你这样做了, -- INSERT --这个词就会出现在编辑器的底部:\nNow you can start typing and filling the screen with the file contents:\n现在您可以开始输入文件内容并在屏幕上填充:\nYou can move around the file with the arrow keys, or using the h - j - k - l keys. h-l for left-right, j-k for down-up.\n您可以使用箭头键或使用h - j - k - l键在文件中移动。 hl代表左-右, jk代表下-上。\nOnce you are done editing you can press the esc key to exit insert mode, and go back to command mode.\n完成编辑后,您可以按esc键退出插入模式,然后返回命令模式。\nAt this point you can navigate the file, but you can\u0026rsquo;t add content to it andbecarefulwhichkeysyoupressastheymightbecommandsand be careful which keys you press as they might be commands .\n此时,您可以导航该文件,但无法向其中添加内容(并且要小心按下的键,因为它们可能是命令)。\nOne thing you might want to do now is saving the file. You can do so by pressing : coloncolon , then w.\n您现在可能想做的一件事是保存文件。您可以通过按:冒号),然后w来执行此操作。\nYou can save and quit pressing : then w and q: :wq\n您可以**保存并退出,**按:然后按w和q : :wq\nYou can quit without saving, pressing : then q and !: :q!\n您可以退出而不保存,按:然后按q和! :: :q!\nYou can undo and edit by going to command mode and pressing u. You can redo cancelanundocancel an undo by pressing ctrl-r.\n您可以通过进入命令模式并按u来撤消和编辑。您可以按ctrl-r重做(取消撤消)。\nThose are the basics of working with Vim. From here starts a rabbit hole we can\u0026rsquo;t go into in this little introduction.\n这些是使用 Vim 的基础知识。从这里开始,我们无法在这个小介绍中进入一个兔子洞。\nI will only mention those commands that will get you started editing with Vim:\n我只会提到那些可以让你开始使用 Vim 进行编辑的命令:\npressing the x key deletes the character currently highlighted\n按x键删除当前突出显示的字符 pressing A goes at the end of the currently selected line\n按A转到当前所选行的末尾 press 0 to go to the start of the line\n按0转到行首 go to the first character of a word and press d followed by w to delete that word. If you follow it with e instead of w, the white space before the next word is preserved\n转到单词的第一个字符,然后按d然后按w删除该单词。如果您使用e而不是w ,则保留下一个单词之前的空格 use a number between d and w to delete more than 1 word, for example use d3w to delete 3 words forward\n使用d和w之间的数字删除 1 个以上的单词,例如使用d3w向前删除 3 个单词 press d followed by d to delete a whole entire line. Press d followed by $ to delete the entire line from where the cursor is, until the end\n按d然后按d可删除整行。按d后按$可删除从光标所在位置开始的整行,直到末尾 To find out more about Vim I can recommend the Vim FAQ and especially running the vimtutor command, which should already be installed in your system and will greatly help you start your vim explorations.\n要了解有关 Vim 的更多信息,我可以推荐 Vim FAQ ,特别是运行vimtutor命令,该命令应该已经安装在您的系统中,并将极大地帮助您开始您的vim探索。\nemacs # emacs is an awesome editor and it\u0026rsquo;s historically regarded as the editor for UNIX systems. Famously vi vs emacs flame wars and heated discussions caused many unproductive hours for developers around the world.\nemacs是一个很棒的编辑器,历来被认为是 UNIX 系统的编辑器。众所周知, vi与emacs的激烈争论和激烈的讨论导致世界各地的开发人员花费了很多时间,毫无成效。\nemacs is very powerful. Some people use it all day long as a kind of operating system [https://news.ycombinator.com/item?id=19127258](https://news.ycombinator.com/item?id=19127258)[https://news.ycombinator.com/item?id=19127258](https://news.ycombinator.com/item?id=19127258) . We\u0026rsquo;ll just talk about the basics here.\nemacs非常强大。有些人整天把它当作一种操作系统来使用( https://news.ycombinator.com/item?id=19127258 )。我们在这里只讨论基础知识。\nYou can open a new emacs session simply by invoking emacs:\n您只需调用emacs即可打开新的 emacs 会话:\nmacOS users, stop a second now. If you are on Linux there are no problems, but macOS does not ship applications using GPLv3, and every built-in UNIX command that has been updated to GPLv3 has not been updated. While there is a little problem with the commands I listed up to now, in this case using an emacs version from 2007 is not exactly the same as using a version with 12 years of improvements and change. This is not a problem with Vim, which is up to date. To fix this, run brew install emacs and running emacs will use the new version from Homebrew makesureyouhaveHomebrewinstalledmake sure you have Homebrew installed macOS 用户,请停下来。如果您使用的是 Linux,则没有问题,但 macOS 不提供使用 GPLv3 的应用程序,并且已更新到 GPLv3 的每个内置 UNIX 命令尚未更新。虽然我到目前为止列出的命令存在一些问题,但在这种情况下,使用 2007 年的 emacs 版本与使用经过 12 年改进和更改的版本并不完全相同。这对于 Vim 来说不是问题,它是最新的。要解决此问题,请运行brew install emacs ,并且运行emacs将使用Homebrew的新版本(确保您已安装Homebrew )\nYou can also edit an existing file calling emacs \u0026lt;filename\u0026gt;:\n您还可以编辑调用emacs \u0026lt;filename\u0026gt;的现有文件:\nYou can start editing and once you are done, press ctrl-x followed by ctrl-w. You confirm the folder:\n您可以开始编辑,完成后,按ctrl-x ,然后按ctrl-w 。您确认该文件夹:\nand Emacs tell you the file exists, asking you if it should overwrite it:\nEmacs 会告诉您该文件存在,并询问您是否应该覆盖它:\nAnswer y, and you get a confirmation of success:\n回答y ,您将收到成功确认信息:\nYou can exit Emacs pressing ctrl-x followed by ctrl-c. Or ctrl-x followed by c keep‘ctrl‘pressedkeep `ctrl` pressed .\n您可以按ctrl-x然后按ctrl-c退出 Emacs。或者按ctrl-x后跟c (按住ctrl不放)。\nThere is a lot to know about Emacs. More than I am able to write in this little introduction. I encourage you to open Emacs and press ctrl-h r to open the built-in manual and ctrl-h t to open the official tutorial.\n关于 Emacs 有很多东西需要了解。我在这个小小的介绍中无法写出更多内容。我鼓励你打开 Emacs 并按ctrl-h r打开内置手册,按ctrl-h t打开官方教程。\nnano # nano is a beginner friendly editor.\nnano是一个适合初学者的编辑器。\nRun it using nano \u0026lt;filename\u0026gt;.\n使用nano \u0026lt;filename\u0026gt;运行它。\nYou can directly type characters into the file without worrying about modes.\n您可以直接在文件中键入字符,而不必担心模式。\nYou can quit without editing using ctrl-X. If you edited the file buffer, the editor will ask you for confirmation and you can save the edits, or discard them. The help at the bottom shows you the keyboard commands that let you work with the file:\n您可以使用ctrl-X退出而不进行编辑。如果您编辑了文件缓冲区,编辑器将要求您确认,您可以保存编辑或放弃它们。底部的帮助显示了可让您使用该文件的键盘命令:\npico is more or less the same, although nano is the GNU version of pico which at some point in history was not open source and the nano clone was made to satisfy the GNU operating system license requirements.\npico或多或少是相同的,尽管nano是pico的 GNU 版本,在历史上的某个时刻它不是开源的,并且nano克隆是为了满足 GNU 操作系统许可要求而制作的。\nwhoami # Type whoami to print the user name currently logged in to the terminal session:\n输入whoami以打印当前登录到终端会话的用户名:\nNote: this is different from the who am i command, which prints more information\n注意:这与who am i命令不同,后者打印更多信息\nwho # The who command displays the users logged in to the system.\nwho命令显示登录到系统的用户。\nUnless you\u0026rsquo;re using a server multiple people have access to, chances are you will be the only user logged in, multiple times:\n除非您使用的服务器可供多人访问,否则您很可能是唯一多次登录的用户:\nWhy multiple times? Because each shell opened will count as an access.\n为什么要多次?因为每次打开的shell都会算作一次访问。\nYou can see the name of the terminal used, and the time/day the session was started.\n您可以看到所使用的终端的名称以及会话开始的时间/日期。\nThe -aH flags will tell who to display more information, including the idle time and the process ID of the terminal:\n-aH标志将告诉who显示更多信息,包括空闲时间和终端的进程 ID:\nThe special who am i command will list the current terminal session details:\n特殊的who am i命令将列出当前终端会话详细信息:\nsu # While you\u0026rsquo;re logged in to the terminal shell with one user, you might have the need to switch to another user.\n当您使用一个用户登录到终端 shell 时,您可能需要切换到另一用户。\nFor example you\u0026rsquo;re logged in as root to perform some maintenance, but then you want to switch to a user account.\n例如,您以 root 身份登录来执行一些维护,但随后您想要切换到用户帐户。\nYou can do so with the su command:\n您可以使用su命令来执行此操作:\nsu \u0026lt;username\u0026gt; For example: su flavio.\n例如: su flavio 。\nIf you\u0026rsquo;re logged in as a user, running su without anything else will prompt to enter the root user password, as that\u0026rsquo;s the default behavior.\n如果您以用户身份登录,则运行su而不执行任何其他操作将提示输入root用户密码,因为这是默认行为。\nsu will start a new shell as another user.\nsu将以另一个用户身份启动一个新的 shell。\nWhen you\u0026rsquo;re done, typing exit in the shell will close that shell, and will return back to the current user\u0026rsquo;s shell.\n完成后,在 shell 中键入exit将关闭该 shell,并返回到当前用户的 shell。\nsudo # sudo is commonly used to run a command as root.\nsudo通常用于以 root 身份运行命令。\nYou must be enabled to use sudo, and once you do, you can run commands as root by entering your user\u0026rsquo;s password ∗not∗therootuserpassword*not* the root user password .\n您必须能够使用sudo ,一旦启用,您就可以通过输入用户密码(而不是root 用户密码)以 root 身份运行命令。\nThe permissions are highly configurable, which is great especially in a multi-user server environment, and some users can be granted access to running specific commands through sudo.\n权限是高度可配置的,这在多用户服务器环境中尤其有用,并且可以通过sudo授予某些用户运行特定命令的访问权限。\nFor example you can edit a system configuration file:\n例如,您可以编辑系统配置文件:\nsudo nano /etc/hosts which would otherwise fail to save since you don\u0026rsquo;t have the permissions for it.\n否则将无法保存,因为您没有权限。\nYou can run sudo -i to start a shell as root:\n您可以运行sudo -i以 root 身份启动 shell:\nYou can use sudo to run commands as any user. root is the default, but use the -u option to specify another user:\n您可以使用sudo以任何用户身份运行命令。 root是默认用户,但使用-u选项指定另一个用户:\nsudo -u flavio ls /Users/flavio passwd # Users in Linux have a password assigned. You can change the password using the passwd command.\nLinux 中的用户已分配一个密码。您可以使用passwd命令更改密码。\nThere are two situations here.\n这里有两种情况。\nThe first is when you want to change your password. In this case you type:\n第一个是当您想要更改密码时。在这种情况下,您输入:\npasswd and an interactive prompt will ask you for the old password, then it will ask you for the new one:\n交互式提示会要求您输入旧密码,然后会要求您输入新密码:\nWhen you\u0026rsquo;re root orhavesuperuserprivilegesor have superuser privileges you can set the username of which you want to change the password:\n当您是root (或具有超级用户权限)时,您可以设置要更改密码的用户名:\npasswd \u0026lt;username\u0026gt; \u0026lt;new password\u0026gt; In this case you don\u0026rsquo;t need to enter the old one.\n在这种情况下,您无需输入旧的。\nping # The ping command pings a specific network host, on the local network or on the Internet.\nping命令对本地网络或 Internet 上的特定网络主机执行 ping 操作。\nYou use it with the syntax ping \u0026lt;host\u0026gt; where \u0026lt;host\u0026gt; could be a domain name, or an IP address.\n您可以使用语法ping \u0026lt;host\u0026gt;来使用它,其中\u0026lt;host\u0026gt;可以是域名或 IP 地址。\nHere\u0026rsquo;s an example pinging google.com:\n以下是 ping google.com示例:\nThe commands sends a request to the server, and the server returns a response.\n命令向服务器发送请求,服务器返回响应。\nping keep sending the request every second, by default, and will keep running until you stop it with ctrl-C, unless you pass the number of times you want to try with the -c option: ping -c 2 google.com.\n默认情况下, ping每秒都会发送请求,并且将继续运行,直到您使用ctrl-C停止它为止,除非您通过-c选项传递了您想要尝试的次数: ping -c 2 google.com 。\nOnce ping is stopped, it will print some statistics about the results: the percentage of packages lost, and statistics about the network performance.\n一旦ping停止,它将打印一些有关结果的统计信息:丢失包的百分比以及有关网络性能的统计信息。\nAs you can see the screen prints the host IP address, and the time that it took to get the response back.\n正如您所看到的,屏幕打印了主机 IP 地址以及获取响应所需的时间。\nNot all servers support pinging, in case the requests times out:\n并非所有服务器都支持 ping,以防请求超时:\nSometimes this is done on purpose, to \u0026ldquo;hide\u0026rdquo; the server, or just to reduce the load. The ping packets can also be filtered by firewalls.\n有时这是故意这样做的,以“隐藏”服务器,或者只是为了减少负载。 ping 数据包也可以被防火墙过滤。\nping works using the ICMP protocol ∗InternetControlMessageProtocol∗*Internet Control Message Protocol* , a network layer protocol just like TCP or UDP.\nping使用ICMP 协议(​​互联网控制消息协议)进行工作,这是一种网络层协议,就像 TCP 或 UDP 一样。\nThe request sends a packet to the server with the ECHO_REQUEST message, and the server returns a ECHO_REPLY message. I won\u0026rsquo;t go into details, but this is the basic concept.\n请求向服务器发送带有ECHO_REQUEST消息的数据包,服务器返回ECHO_REPLY消息。我不会详细说明,但这是基本概念。\nPinging a host is useful to know if the host is reachable supposingitimplementspingsupposing it implements ping , and how distant it is in terms of how long it takes to get back to you. Usually the nearest the server is geographically, the less time it will take to return back to you, for simple physical laws that cause a longer distance to introduce more delay in the cables.\n对主机执行 Ping 操作有助于了解该主机是否可达(假设它实现了 ping),以及与您联系所需的时间有多远。通常,服务器在地理位置上越近,返回给您所需的时间就越短,因为简单的物理定律会导致距离较长,从而在电缆中引入更多延迟。\ntraceroute # When you try to reach a host on the Internet, you go through your home router, then you reach your ISP network, which in turn goes through its own upstream network router, and so on, until you finally reach the host.\n当您尝试访问互联网上的主机时,您将通过家庭路由器,然后到达 ISP 网络,ISP 网络又通过其自己的上游网络路由器,依此类推,直到您最终到达主机。\nHave you ever wanted to know what are the steps that your packets go through to do that?\n您是否想知道您的数据包要经过哪些步骤才能做到这一点?\nThe traceroute command is made for this.\ntraceroute命令就是为此而设计的。\nYou invoke\n你调用\ntraceroute \u0026lt;host\u0026gt; and it will slowlyslowly gather all the information while the packet travels.\n它会在数据包传输过程中(慢慢地)收集所有信息。\nIn this example I tried reaching for my blog with traceroute flaviocopes.com:\n在此示例中,我尝试使用traceroute flaviocopes.com访问我的博客:\nNot every router travelled returns us information. In this case, traceroute prints * * *. Otherwise, we can see the hostname, the IP address, and some performance indicator.\n并非每个经过的路由器都会向我们返回信息。在这种情况下, traceroute会打印* * * 。否则,我们可以看到主机名、IP 地址和一些性能指标。\nFor every router we can see 3 samples, which means traceroute tries by default 3 times to get you a good indication of the time needed to reach it. This is why it takes this long to execute traceroute compared to simply doing a ping to that host.\n对于每个路由器,我们可以看到 3 个样本,这意味着默认情况下,traceroute 会尝试 3 次,以便您很好地了解到达该路由器所需的时间。这就是为什么与简单地对该主机执行ping相比,执行traceroute需要这么长时间。\nYou can customize this number with the -q option:\n您可以使用-q选项自定义此数字:\ntraceroute -q 1 flaviocopes.com clear # Type clear to clear all the previous commands that were ran in the current terminal.\n键入clear以清除当前终端中运行的所有先前命令。\nThe screen will clear and you will just see the prompt at the top:\n屏幕将清除,您只会在顶部看到提示:\nNote: this command has a handy shortcut: ctrl-L\n注意:此命令有一个方便的快捷键: ctrl-L\nOnce you do that, you will lose access to scrolling to see the output of the previous commands entered.\n一旦执行此操作,您将无法滚动查看先前输入的命令的输出。\nSo you might want to use clear -x instead, which still clears the screen, but lets you go back to see the previous work by scrolling up.\n因此,您可能想使用clear -x ,它仍然会清除屏幕,但可以让您通过向上滚动返回查看之前的工作。\nhistory # Every time we run a command, that\u0026rsquo;s memorized in the history.\n每次我们运行命令时,它都会被记录在历史记录中。\nYou can display all the history using:\n您可以使用以下命令显示所有历史记录:\nhistory This shows the history with numbers:\n这用数字显示了历史:\nYou can use the syntax !\u0026lt;command number\u0026gt; to repeat a command stored in the history, in the above example typing !121 will repeat the ls -al | wc -l command.\n您可以使用语法!\u0026lt;command number\u0026gt;来重复存储在历史记录中的命令,在上面的示例中键入!121将重复ls -al | wc -l命令。\nTypically the last 500 commands are stored in the history.\n通常,最后 500 个命令存储在历史记录中。\nYou can combine this with grep to find a command you ran:\n您可以将其与grep结合起来查找您运行的命令:\nhistory | grep docker To clear the history, run history -c\n要清除历史记录,请运行history -c\nexport # The export command is used to export variables to child processes.\nexport命令用于将变量导出到子进程。\nWhat does this mean?\n这意味着什么?\nSuppose you have a variable TEST defined in this way:\n假设您有一个这样定义的变量 TEST:\nTEST= \u0026quot;test\u0026quot; You can print its value using echo $TEST:\n您可以使用echo $TEST打印其值:\nBut if you try defining a Bash script in a file script.sh with the above command:\n但是,如果您尝试使用上述命令在script.sh文件中定义 Bash 脚本:\nThen you set chmod u+x script.sh and you execute this script with ./script.sh, the echo $TEST line will print nothing!\n然后设置chmod u+x script.sh并使用./script.sh执行此脚本, echo $TEST行将不打印任何内容!\nThis is because in Bash the TEST variable was defined local to the shell. When executing a shell script or another command, a subshell is launched to execute it, which does not contain the current shell local variables.\n这是因为在 Bash 中, TEST变量是在 shell 本地定义的。当执行 shell 脚本或其他命令时,会启动一个子 shell 来执行它,该子 shell 不包含当前 shell 局部变量。\nTo make the variable available there we need to define TEST not in this way:\n为了使变量在那里可用,我们需要不以这种方式定义TEST :\nTEST= \u0026quot;test\u0026quot; but in this way:\n但这样:\nexport TEST= \u0026quot;test\u0026quot; Try that, and running ./script.sh now should print \u0026ldquo;test\u0026rdquo;:\n尝试一下,现在运行./script.sh应该打印“test”:\nSometimes you need to append something to a variable. It\u0026rsquo;s often done with the PATH variable. You use this syntax:\n有时您需要向变量附加一些内容。通常通过PATH变量来完成。您使用以下语法:\nexport PATH= $PATH :/new/path It\u0026rsquo;s common to use export when you create new variables in this way, but also when you create variables in the .bash_profile or .bashrc configuration files with Bash, or in .zshenv with Zsh.\n当您以这种方式创建新变量时,以及当您使用 Bash 在.bash_profile或.bashrc配置文件中创建变量,或者使用 Zsh 在.zshenv中创建变量时,通常会使用export 。\nTo remove a variable, use the -n option:\n要删除变量,请使用-n选项:\nexport -n TEST Calling export without any option will list all the exported variables.\n不带任何选项调用export将列出所有导出的变量。\ncrontab # Cron jobs are jobs that are scheduled to run at specific intervals. You might have a command perform something every hour, or every day, or every 2 weeks. Or on weekends. They are very powerful, especially on servers to perform maintenance and automations.\nCron 作业是计划以特定时间间隔运行的作业。您可能有一个命令每小时、每天或每两周执行一些操作。或者在周末。它们非常强大,特别是在服务器上执行维护和自动化。\nThe crontab command is the entry point to work with cron jobs.\ncrontab命令是使用 cron 作业的入口点。\nThe first thing you can do is to explore which cron jobs are defined by you:\n您可以做的第一件事是探索您定义了哪些 cron 作业:\ncrontab -l You might have none, like me:\n你可能没有,就像我一样:\nRun\n跑步\ncrontab -e to edit the cron jobs, and add new ones.\n编辑 cron 作业并添加新作业。\nBy default this opens with the default editor, which is usually vim. I like nano more, you can use this line to use a different editor:\n默认情况下,它会使用默认编辑器打开,通常是vim 。我更喜欢nano ,你可以使用这一行来使用不同的编辑器:\nEDITOR=nano crontab -e Now you can add one line for each cron job.\n现在您可以为每个 cron 作业添加一行。\nThe syntax to define cron jobs is kind of scary. This is why I usually use a website to help me generate it without errors: https://crontab-generator.org/\n定义 cron 作业的语法有点可怕。这就是为什么我通常使用一个网站来帮助我生成它而不会出现错误: https ://crontab-generator.org/\nYou pick a time interval for the cron job, and you type the command to execute.\n您为 cron 作业选择一个时间间隔,然后键入要执行的命令。\nI chose to run a script located in /Users/flavio/test.sh every 12 hours. This is the crontab line I need to run:\n我选择每 12 小时运行一次位于/Users/flavio/test.sh中的脚本。这是我需要运行的 crontab 行:\n* */12 * * * /Users/flavio/test.sh \u0026gt;/dev/null 2\u0026gt;\u0026amp;1 I run crontab -e:\n我运行crontab -e :\nEDITOR=nano crontab -e and I add that line, then I press ctrl-X and press y to save.\n我添加该行,然后按ctrl-X并按y保存。\nIf all goes well, the cron job is set up:\n如果一切顺利,则 cron 作业已设置:\nOnce this is done, you can see the list of active cron jobs by running:\n完成此操作后,您可以通过运行以下命令查看活动 cron 作业的列表:\ncrontab -l You can remove a cron job running crontab -e again, removing the line and exiting the editor:\n您可以删除再次运行crontab -e cron 作业,删除该行并退出编辑器:\nuname # Calling uname without any options will return the Operating System codename:\n不带任何选项调用uname将返回操作系统代号:\nThe m option shows the hardware name ‘x8664‘inthisexample`x86_64` in this example and the p option prints the processor architecture name ‘i386‘inthisexample`i386` in this example :\nm选项显示硬件名称(本例中为x86_64 ), p选项打印处理器架构名称(本例中为i386 ):\nThe s option prints the Operating System name. r prints the release, v prints the version:\ns选项打印操作系统名称。 r打印版本, v打印版本:\nThe n option prints the node network name:\nn选项打印节点网络名称:\nThe a option prints all the information available:\na选项打印所有可用的信息:\nOn macOS you can also use the sw_vers command to print more information about the macOS Operating System. Note that this differs from the Darwin theKernelthe Kernel version, which above is 19.6.0.\n在 macOS 上,您还可以使用sw_vers命令打印有关 macOS 操作系统的更多信息。请注意,这与 Darwin(内核)版本不同,上面是19.6.0 。\nDarwin is the name of the kernel of macOS. The kernel is the \u0026ldquo;core\u0026rdquo; of the Operating System, while the Operating System as a whole is called macOS. In Linux, Linux is the kernel, GNU/Linux would be the Operating System name, although we all refer to it as \u0026ldquo;Linux\u0026rdquo;\nDarwin 是 macOS 内核的名称。内核是操作系统的“核心”,而整个操作系统称为 macOS。在 Linux 中,Linux 是内核,GNU/Linux 是操作系统名称,尽管我们都将其称为“Linux”\nenv # The env command can be used to pass environment variables without setting them on the outer environment thecurrentshellthe current shell .\nenv命令可用于传递环境变量,而无需在外部环境(当前 shell)上设置它们。\nSuppose you want to run a Node.js app and set the USER variable to it.\n假设您要运行 Node.js 应用程序并将USER变量设置为其。\nYou can run\n你可以运行\nenv USER=flavio node app.js and the USER environment variable will be accessible from the Node.js app via the Node process.env interface.\n并且可以通过 Node.js 应用程序通过 Node process.env接口访问USER环境变量。\nYou can also run the command clearing all the environment variables already set, using the -i option:\n您还可以使用-i选项运行命令清除已设置的所有环境变量:\nenv -i node app.js In this case you will get an error saying env: node: No such file or directory because the node command is not reachable, as the PATH variable used by the shell to look up commands in the common paths is unset.\n在这种情况下,您会收到一条错误消息 env: node: No such file or directory 因为node命令不可访问,因为 shell 用于在公共路径中查找命令的PATH变量未设置。\nSo you need to pass the full path to the node program:\n因此需要将完整路径传递给node程序:\nenv -i /usr/ local /bin/node app.js Try with a simple app.js file with this content:\n尝试使用包含以下内容的简单app.js文件:\nconsole .log(process.env.NAME) console .log(process.env.PATH) You will see the output being\n你会看到输出是\nundefined undefined You can pass an env variable:\n您可以传递一个环境变量:\nenv -i NAME=flavio node app.js and the output will be\n输出将是\nflavio undefined Removing the -i option will make PATH available again inside the program:\n删除-i选项将使PATH在程序内再次可用:\nThe env command can also be used to print out all the environment variables, if ran with no options:\n如果运行时没有选项, env命令还可以用于打印所有环境变量:\nenv it will return a list of the environment variables set, for example:\n它将返回环境变量集的列表,例如:\nHOME=/Users/flavio LOGNAME=flavio PATH=/usr/local/bin:/usr/bin:/bin:/usr/sbin:/sbin:/Library/Apple/usr/bin PWD=/Users/flavio SHELL=/usr/local/bin/fish You can also make a variable inaccessible inside the program you run, using the -u option, for example this code removes the HOME variable from the command environment:\n您还可以使用-u选项使变量在运行的程序内不可访问,例如以下代码从命令环境中删除HOME变量:\nenv -u HOME node app.js printenv # A quick guide to the printenv command, used to print the values of environment variables\nprintenv命令快速指南,用于打印环境变量的值\nIn any shell there are a good number of environment variables, set either by the system, or by your own shell scripts and configuration.\n在任何 shell 中都有大量的环境变量,这些变量可以由系统设置,也可以由您自己的 shell 脚本和配置设置。\nYou can print them all to the terminal using the printenv command. The output will be something like this:\n您可以使用printenv命令将它们全部打印到终端。输出将是这样的:\nHOME=/Users/flavio LOGNAME=flavio PATH=/usr/local/bin:/usr/bin:/bin:/usr/sbin:/sbin:/Library/Apple/usr/bin PWD=/Users/flavio SHELL=/usr/local/bin/fish with a few more lines, usually.\n通常还有几行。\nYou can append a variable name as a parameter, to only show that variable value:\n您可以附加变量名称作为参数,以仅显示该变量值:\nprintenv PATH Conclusion # 结论\nThanks a lot for reading this book.\n非常感谢您阅读这本书。\nFor more, head over to flaviocopes.com.\n如需了解更多信息,请访问 flaviocopes.com 。\nSend any feedback, errata or opinions at flavio@flaviocopes.com\n请将任何反馈、勘误表或意见发送至flavio@flaviocopes.com\n"},{"id":168,"href":"/zh/docs/culture/%E8%AE%BA%E8%AF%AD/%E8%AE%BA%E8%AF%AD%E7%9A%84%E7%94%9F%E6%B4%BB%E6%99%BA%E6%85%A7/01%E5%AD%A6%E5%A4%A9%E7%AC%AC%E4%B8%80/","title":"01学天第一","section":"论语的生活智慧","content":" # # "},{"id":169,"href":"/zh/docs/culture/%E8%AE%BA%E8%AF%AD/%E8%AE%BA%E8%AF%AD%E7%9A%84%E7%94%9F%E6%B4%BB%E6%99%BA%E6%85%A7/02%E4%B8%BA%E6%94%BF%E7%AC%AC%E4%BA%8C/","title":"02为政第二","section":"论语的生活智慧","content":"\n"},{"id":170,"href":"/zh/docs/culture/%E8%AE%BA%E8%AF%AD/%E8%AE%BA%E8%AF%AD%E7%9A%84%E7%94%9F%E6%B4%BB%E6%99%BA%E6%85%A7/03%E5%85%AB%E4%BD%BE%E7%AC%AC%E4%B8%89/","title":"03八佾第三","section":"论语的生活智慧","content":"\n"},{"id":171,"href":"/zh/docs/culture/%E8%AE%BA%E8%AF%AD/%E8%AE%BA%E8%AF%AD%E7%9A%84%E7%94%9F%E6%B4%BB%E6%99%BA%E6%85%A7/04%E9%87%8C%E4%BB%81%E7%AC%AC%E5%9B%9B/","title":"04里仁第四","section":"论语的生活智慧","content":"\n"},{"id":172,"href":"/zh/docs/culture/%E8%AE%BA%E8%AF%AD/%E8%AE%BA%E8%AF%AD%E7%9A%84%E7%94%9F%E6%B4%BB%E6%99%BA%E6%85%A7/05%E5%85%AC%E5%86%B6%E9%95%BF%E7%AC%AC%E4%BA%94/","title":"05公治长第五","section":"论语的生活智慧","content":"\n"},{"id":173,"href":"/zh/docs/culture/%E8%AE%BA%E8%AF%AD/%E8%AE%BA%E8%AF%AD%E7%9A%84%E7%94%9F%E6%B4%BB%E6%99%BA%E6%85%A7/06%E9%9B%8D%E4%B9%9F%E7%AC%AC%E5%85%AD/","title":"06雍也第六","section":"论语的生活智慧","content":"\n"},{"id":174,"href":"/zh/docs/culture/%E8%AE%BA%E8%AF%AD/%E8%AE%BA%E8%AF%AD%E7%9A%84%E7%94%9F%E6%B4%BB%E6%99%BA%E6%85%A7/07%E8%BF%B0%E8%80%8C%E7%AC%AC%E4%B8%83/","title":"07述而第七","section":"论语的生活智慧","content":"\n"},{"id":175,"href":"/zh/docs/culture/%E8%AE%BA%E8%AF%AD/%E8%AE%BA%E8%AF%AD%E7%9A%84%E7%94%9F%E6%B4%BB%E6%99%BA%E6%85%A7/08%E6%B3%B0%E4%BC%AF%E7%AC%AC%E5%85%AB/","title":"08泰伯第八","section":"论语的生活智慧","content":"\n"},{"id":176,"href":"/zh/docs/culture/%E8%AE%BA%E8%AF%AD/%E8%AE%BA%E8%AF%AD%E7%9A%84%E7%94%9F%E6%B4%BB%E6%99%BA%E6%85%A7/09%E5%AD%90%E7%BD%95%E7%AC%AC%E4%B9%9D/","title":"09子罕第九","section":"论语的生活智慧","content":"\n"},{"id":177,"href":"/zh/docs/culture/%E8%AE%BA%E8%AF%AD/%E8%AE%BA%E8%AF%AD%E7%9A%84%E7%94%9F%E6%B4%BB%E6%99%BA%E6%85%A7/10%E4%B9%A1%E5%85%9A%E7%AC%AC%E5%8D%81/","title":"10乡党第十","section":"论语的生活智慧","content":"\n"},{"id":178,"href":"/zh/docs/culture/%E8%AE%BA%E8%AF%AD/%E8%AE%BA%E8%AF%AD%E7%9A%84%E7%94%9F%E6%B4%BB%E6%99%BA%E6%85%A7/11%E5%85%88%E8%BF%9B%E7%AC%AC%E5%8D%81%E4%B8%80/","title":"11先进第十一","section":"论语的生活智慧","content":"\n"},{"id":179,"href":"/zh/docs/culture/%E8%AE%BA%E8%AF%AD/%E8%AE%BA%E8%AF%AD%E7%9A%84%E7%94%9F%E6%B4%BB%E6%99%BA%E6%85%A7/12%E9%A2%9C%E6%B8%8A%E7%AC%AC%E5%8D%81%E4%BA%8C/","title":"12颜渊第十二","section":"论语的生活智慧","content":"\n"},{"id":180,"href":"/zh/docs/culture/%E8%AE%BA%E8%AF%AD/%E8%AE%BA%E8%AF%AD%E7%9A%84%E7%94%9F%E6%B4%BB%E6%99%BA%E6%85%A7/13%E5%AD%90%E8%B7%AF%E7%AC%AC%E5%8D%81%E4%B8%89/","title":"13子路第十三","section":"论语的生活智慧","content":"\n"},{"id":181,"href":"/zh/docs/culture/%E8%AE%BA%E8%AF%AD/%E8%AE%BA%E8%AF%AD%E7%9A%84%E7%94%9F%E6%B4%BB%E6%99%BA%E6%85%A7/14%E5%AE%AA%E9%97%AE%E7%AC%AC%E5%8D%81%E5%9B%9B/","title":"14宪问第十四","section":"论语的生活智慧","content":"\n"},{"id":182,"href":"/zh/docs/culture/%E8%AE%BA%E8%AF%AD/%E8%AE%BA%E8%AF%AD%E7%9A%84%E7%94%9F%E6%B4%BB%E6%99%BA%E6%85%A7/15%E5%8D%AB%E7%81%B5%E5%85%AC%E7%AC%AC%E5%8D%81%E4%BA%94/","title":"15卫灵公第十五","section":"论语的生活智慧","content":"\n"},{"id":183,"href":"/zh/docs/culture/%E8%AE%BA%E8%AF%AD/%E8%AE%BA%E8%AF%AD%E7%9A%84%E7%94%9F%E6%B4%BB%E6%99%BA%E6%85%A7/16%E5%AD%A3%E6%B0%8F%E7%AC%AC%E5%8D%81%E5%85%AD/","title":"16季氏第十六","section":"论语的生活智慧","content":"\n"},{"id":184,"href":"/zh/docs/culture/%E8%AE%BA%E8%AF%AD/%E8%AE%BA%E8%AF%AD%E7%9A%84%E7%94%9F%E6%B4%BB%E6%99%BA%E6%85%A7/17%E9%98%B3%E8%B4%A7%E7%AC%AC%E5%8D%81%E4%B8%83/","title":"17阳货第十七","section":"论语的生活智慧","content":"\n"},{"id":185,"href":"/zh/docs/culture/%E8%AE%BA%E8%AF%AD/%E8%AE%BA%E8%AF%AD%E7%9A%84%E7%94%9F%E6%B4%BB%E6%99%BA%E6%85%A7/18%E5%BE%AE%E5%AD%90%E7%AC%AC%E5%8D%81%E5%85%AB/","title":"18微子第十八","section":"论语的生活智慧","content":"\n"},{"id":186,"href":"/zh/docs/culture/%E8%AE%BA%E8%AF%AD/%E8%AE%BA%E8%AF%AD%E7%9A%84%E7%94%9F%E6%B4%BB%E6%99%BA%E6%85%A7/19%E5%AD%90%E5%BC%A0%E7%AC%AC%E5%8D%81%E4%B9%9D/","title":"19子张第十九","section":"论语的生活智慧","content":"\n"},{"id":187,"href":"/zh/docs/culture/%E8%AE%BA%E8%AF%AD/%E8%AE%BA%E8%AF%AD%E7%9A%84%E7%94%9F%E6%B4%BB%E6%99%BA%E6%85%A7/20%E5%B0%A7%E6%97%A5%E7%AC%AC%E4%BA%8C%E5%8D%81/","title":"20尧日第二十","section":"论语的生活智慧","content":"\n"},{"id":188,"href":"/zh/docs/culture/%E8%B5%84%E6%B2%BB%E9%80%9A%E9%89%B4/%E5%91%A8%E7%BA%AA/%E5%9F%BA%E6%9C%AC%E7%9F%A5%E8%AF%86/","title":"基本知识","section":"周纪","content":" 周朝君主世系图 # 资治通鉴中 # 周威烈王始\n完整世系图 # 《资治通鉴》从周威烈王(图里是32,这个第几任好像各家的算法不一致,不必深究)开始\n"},{"id":189,"href":"/zh/docs/culture/%E8%B5%84%E6%B2%BB%E9%80%9A%E9%89%B4/%E5%91%A8%E7%BA%AA/001%E5%91%A8%E7%BA%AA%E4%B8%80/","title":"001周纪一","section":"周纪","content":"資治通鑑卷第一\n朝散大夫右諫議大夫權御史中丞充理檢使上護軍賜紫金魚袋臣\n司马光 奉敕编集\n後 學 天 台\n胡三省 音 註\n周紀一 起著雍攝提格(戊寅),盡玄黓困敦(壬子),凡三十五年。\n爾雅:太歲在甲曰閼逢,在乙曰旃zhān蒙,在丙曰柔兆,在丁曰強圉,在戊曰著雍,在己曰屠維,在庚曰上章,在辛曰重光,在壬曰玄黓yì,在癸曰昭陽,是為歲陽。在寅曰攝提格,在卯曰單閼,在辰曰執徐,在巳曰大荒落,在午曰敦牂,在未曰協洽,在申曰涒tūn灘,在酉曰作噩,在戌曰掩茂,在亥曰大淵獻,在子曰困敦,在丑曰赤奮若,是為歲名。周紀分註「起著雍攝提格」,起戊寅也。「盡玄黓困敦」,盡壬子也。閼,讀如字;史記作「焉」,於乾翻。著,陳如翻。雍,于容翻。黓,逸職翻。單閼,上音丹,又特連翻;下烏葛翻,又于連翻。牂,作郎翻。涒,吐魂翻。灘,吐丹翻。困敦,音頓。杜預 世族譜曰:周,黃帝之苗裔,姬姓。后稷之後,封于邰;及夏衰,稷子不窋zhú竄於西戎。至十二代孫太王,避狄遷岐;至孫文王受命,武王克商而有天下。自武王至平王凡十三世,自平王至威烈王又十八世,自威烈王至赧王又五世。張守節曰:因太王居周原,國號曰周。地理志云:右扶風美陽縣岐山西北中水鄉,周太王所邑。括地志云:故周城一名美陽城,在雍州武功縣西北二十五里。紀,理也,統理眾事而系之年月。溫公系年用春秋之法,因史、漢本紀而謂之紀。邰,湯來翻。夏,戶雅翻。窋,竹律翻。在雍,於用翻。\n威烈王 # 名午,考王之子。諡法:猛以剛果曰威;有功安民曰烈。沈約曰:諸複諡,有諡人,無諡法。\n二十三年(戊寅,前四零三) # 上距春秋獲麟七十八年,距左傳趙襄子惎jì智伯事七十一年。惎,毒也,音其冀翻。\n1初命晉‹府新田,山西侯马›大夫魏 ‹府安邑,山西夏县›斯、趙‹府晋阳,山西太原›籍、韓‹府平阳,山西临汾›虔為諸侯。此溫公書法所由始也。魏之先,畢公高後,與周同姓;其苗裔曰畢萬,始封于魏,至魏舒,始為晉正卿;三世至斯。趙之先,造父後;至叔帶,始自周適晉;至趙夙,始封于耿。至趙盾,始為晉正卿,六世至籍。韓之先,出於周武王,至韓武子事晉,封于韓原。至韓厥,為晉正卿;六世至虔。三家者,世為晉大夫,於周則陪臣也。周室既衰,晉主夏盟,以尊王室,故命之為伯。三卿竊晉之權,暴蔑其君,剖分其國,此王法所必誅也。威烈王不惟不能誅之,又命之為諸侯,是崇獎奸名犯分之臣也,通鑑始於此,其所以謹名分歟!\n〖译文〗 [1]周威烈王姬午初次分封晋国大夫魏斯、赵籍、韩虔为诸侯国君。\n臣光曰:臣聞天子之職莫大於禮,禮莫大於分,分莫大於名。 分,扶問翻;下同。何謂禮?紀綱是也。何謂分?君、臣是也。何謂名?公、侯、卿、大夫是也。\n〖译文〗 臣司马光曰:我知道天子的职责中最重要的是维护礼教,礼教中最重要的是区分地位,区分地位中最重要的是匡正名分。什么是礼教?就是法纪。什么是区分地位?就是君臣有别。什么是名分?就是公、侯、卿、大夫等官爵。\n夫以四海之廣, 夫以,音扶。兆民之眾,受制於一人,雖有絕倫之力,高世之智,莫【章︰十二行本「莫」下有「敢」字;乙十一行本同;孔本同。】不奔走而服役者,豈非以禮為之紀綱【章︰十二行本,二字互乙;乙十一行本同;孔本同。】哉!是故天子統三公, 統,他綜翻。三公率諸侯,諸侯制卿大夫,卿大夫治士庶人。 治,直之翻。貴以臨賤,賤以承貴。上之使下猶心腹之運手足,根本之制支葉,下之事上猶手足之衛心腹,支葉之庇本根,然後能上下相保而國家治安。 治,直吏翻。故曰天子之職莫大於禮也。\n〖译文〗 四海之广,亿民之众,都受制于天子一人。尽管是才能超群、智慧绝伦的人,也不能不在天子足下为他奔走服务,这难道不是以礼作为礼纪朝纲的作用吗!所以,天子统率三公,三公督率诸侯国君,诸侯国君节制卿、大夫官员,卿、大夫官员又统治士人百姓。权贵支配贱民,贱民服从权贵。上层指挥下层就好像人的心腹控制四肢行动,树木的根和干支配枝和叶;下层服侍上层就好像人的四肢卫护心腹,树木的枝和叶遮护根和干,这样才能上下层互相保护,从而使国家得到长治久安。所以说,天子的职责没有比维护礼制更重要的了。\n文王序易,以乾、坤為首。孔子系之曰:「天尊地卑,乾坤定矣。 卑高以陳,貴賤位矣。」 系,戶計翻。言君臣之位猶天地之不可易也。春秋抑諸侯,尊王【章︰十二行本「王」作「周」;乙十一行本同;孔本同;退齋校同。】室,王人雖微,序於諸侯之上,以是見聖人於君臣之際未嘗不惓惓也。惓,逵員翻。漢劉向傳:忠臣畎quǎn畝,猶不忘君惓惓之義也。惓惓,猶言勤勤也。非有桀 ‹姒履癸›、紂‹子受辛›之暴,湯‹子天乙›、武‹姬发›之仁,人歸之,天命之,君臣之分當守節伏死而已矣。是故以微子 ‹子启›而代紂‹子受辛›則成湯‹子天乙›配天矣,史記:商帝乙生三子:長曰微子啟,次曰中衍,季曰紂。紂之母為后。帝乙欲立啟為太子,太史據法爭之曰:「有妻之子,不可立妾之子。」乃立紂。紂卒以暴虐亡殷國。孔鄭玄義曰:物之大者莫若於天;推父比天,與之相配,行孝之大,莫大於此;所謂「嚴父莫大於配天」也。又孔氏曰:禮記稱萬物本乎天,人本乎祖。俱為其本,可以相配,故王者皆以祖配天。諡法:除殘去虐曰湯。然諡法起于周;蓋殷人先有此號,周人遂引以為諡法。分,扶問翻。長,知兩翻。卒,子恤翻。以季札而君吳則太伯血食矣, 吳王壽夢有子四人:長曰諸樊,次曰餘祭,次曰餘昩,次曰季札。季札賢,壽夢欲立之,季札讓不可,於是立諸樊。諸樊卒,以授餘祭,欲兄弟以次相傳,必致國于季札;季札終讓而逃之。其後諸樊之子光與餘昩之子僚爭國,至於夫差,吳遂以亡。宗廟之祭用牲,故曰血食。太伯,吳立國之君。范寧曰:太者,善大之稱;伯者,長也。周太王之元子,故曰太伯。陸德明曰:壽夢,莫公翻。餘祭,側介翻。餘昩,音末。然二子寧亡國而不為者,誠以禮之大節不可亂也。故曰禮莫大於分也。\n〖译文〗 周文王演绎排列《易经》,以乾、坤为首位。孔子解释说:“天尊贵,地卑微,阳阴于是确定。由低至高排列有序,贵贱也就各得其位。”这是说君主和臣子之间的上下关系就像天和地一样不能互易。《春秋》一书贬低诸侯,尊崇周王室,尽管周王室的官吏地位不高,在书中排列顺序仍在诸侯国君之上,由此可见孔圣人对于君臣关系的关注。如果不是夏桀、商纣那样的暴虐昏君,对手又遇上商汤、周武王这样的仁德明主,使人民归心、上天赐命的话,君臣之间的名分只能是作臣子的恪守臣节,矢死不渝。所以如果商朝立贤明的微子为国君来取代纣王,成汤创立的商朝就可以永配上天;而吴国如果以仁德的季札做君主,开国之君太伯也可以永享祭祀。然而微子、季札二人宁肯国家灭亡也不愿做君主,实在是因为礼教的大节绝不可因此破坏。所以说,礼教中最重要的就是地位高下的区分。\n夫禮,辨貴賤,序親疏,裁群物,制庶事,非名不著,非器不形;名以命之,器以別之, 夫,音扶。別,彼列翻。然後上下粲然有倫,此禮之大經也。名器既亡,則禮安得獨在哉!昔仲叔于奚有功于衛,辭邑而請繁纓,孔子以為不如多與之邑。惟名與器,不可以假人,君之所司也;政亡則國家從之。 左傳:衛孫桓子帥師與齊師戰於新築‹河北大名南›,衛師敗績。新築人仲叔于奚救孫桓子,桓子是以免。既而衛人賞之邑,辭;請曲縣、繁纓以朝,許之。孔子聞之曰:「不如多與之邑,惟名與器不可以假人。」繁纓,馬飾也。繁,馬鬣上飾;纓,馬膺前飾。晉志註曰:纓在馬膺如索帬。繁,音蒲官翻。纓,伊盈翻。索,昔各翻。 衛君待孔子而為政,孔子欲先正名,以為名不正則民無所措手足。 見論語。夫繁纓,小物也,而孔子惜之;正名,細務也,而孔子先之: 先,悉薦翻。誠以名器既亂則上下無以相保故也。夫事未有不生於微而成于著,聖人之慮遠,故能謹其微而治之。 治,直之翻;下同。眾人之識近,故必待其著而後救之;治其微則用力寡而功多,救其著則竭力而不能及也。易曰:「履霜堅冰至,」坤初六爻辭。象曰:「履霜堅冰,陰始凝也。馴致其道,至堅冰也。」書曰:「一日二日萬幾,」皋陶謨之辭。孔安國註曰:幾,微也。言當戒懼萬事之微。幾,居依翻。謂此類也。故曰分莫大於名也。 分,扶問翻。\n〖译文〗 所谓礼教,在于分辨贵贱,排比亲疏,裁决万物,处理日常事物。没有一定的名位,就不能显扬;没有器物,就不能表现。只有用名位来分别称呼,用器物来分别标志,然后上下才能井然有序。这就是礼教的根本所在。如果名位、器物都没有了,那么礼教又怎么能单独存在呢!当年仲叔于奚为卫国建立了大功,他谢绝了赏赐的封地,却请求允许他享用贵族才应有的马饰。孔子认为不如多赏赐他一些封地,惟独名位和器物,绝不能假与他人,这是君王的职权象征;处理政事不坚持原则,国家也就会随着走向危亡。卫国国君期待孔子为他崐处理政事,孔子却先要确立名位,认为名位不正则百姓无所是从。马饰,是一种小器物,而孔子却珍惜它的价值;正名位,是一件小事情,而孔子却要先从它做起,就是因为名位、器物一紊乱,国家上下就无法相安互保。没有一件事情不是从微小之处产生而逐渐发展显著的,圣贤考虑久远,所以能够谨慎对待微小的变故及时予以处理;常人见识短浅,所以必等弊端闹大才来设法挽救。矫正初起的小错,用力小而收效大;挽救已明显的大害,往往是竭尽了全力 也不能成功。《易经》说:“行于霜上而知严寒冰冻将至。”《尚书》说:“先王每天都要兢兢业业地处理成千上万件事情。”就是指这类防微杜渐的例子。所以说,区分地位高下最重要的是匡正各个等级的名分。\n嗚呼!幽‹姬宫湦shēng›、厲‹姬胡›失德,周道日衰,綱紀散壞,下陵上替,諸侯專征, 謂齊桓公,晉文公至悼公以及楚莊王、吳夫差之類。大夫擅政,謂晉六卿、魯三家、齊田氏之類。禮之大體什喪七八矣,喪,息浪翻。然文‹姬昌›、武‹姬发›之祀猶綿綿相屬者,屬,聯屬也,音之欲翻。凡聯屬之屬皆同音。蓋以周之子孫尚能守其名分故也。何以言之?昔晉文公‹姬重耳›有大功於王室,請隧於襄王‹姬郑›,襄王不許,曰:「王章也。未有代德而有二王,亦叔父之所惡也。不然,叔父有地而隧,又何請焉!」文公於是懼而不敢違。太叔帶之難,襄王出居於氾fàn‹河南襄城›。晉文公帥師納王,殺太叔帶。既定襄王於郟jiá,王勞之以地,辭;請隧焉,王弗許云云。杜預曰:闕地通路曰隧,此乃王者葬禮也。諸侯皆縣柩而下。王章者,章顯王者異於諸侯。古者天子謂同姓諸侯為伯父、叔父。隧,音遂。惡,烏路翻。難,乃旦翻。氾,音泛。勞,力到翻。闕,其月翻。縣,音玄。柩,其久翻。是故以周之地則不大於曹‹山東定陶›、滕‹山東滕州›,以周之民則不眾於邾‹山东邹县›、莒‹山東莒縣›,曹、滕、邾、莒,春秋時小國。莒,居許翻。然歷數百年,宗主天下,雖以晉、楚、齊、秦之強不敢加者,何哉?徒以名分尚存故也。至於季氏之於魯,田常之于齊,白公之于楚,智伯之于晉,魯大夫季氏,自季友以來,世執魯國之政。季平子逐昭公,季康子逐哀公,然終身北面,不敢篡國。田常,即陳恒。田氏本陳氏;溫公避國諱,改「恒」曰「常」。陳成子得齊國之政,殺闞止,弑簡公,而亦不敢自立。史記世家以陳敬仲完為田敬仲完,陳成子恒為田常,故通鑑因以為據。白公勝殺楚令尹子西、司馬子期,石乞曰:「焚庫弑王,不然不濟!」白公曰:「弑王不祥,焚庫無聚。」智伯當晉之衰,專其國政,侵伐鄰國,于晉大夫為最強;攻晉出公,出公道死。智伯欲并晉而不敢,乃奉哀公驕立之。其勢皆足以逐君而自為,然而卒不敢者,卒,子恤翻,終也。豈其力不足而心不忍哉,乃畏奸名犯分而天下共誅之也。奸,居寒翻,亦犯也。分,扶問翻。今晉大夫暴蔑其君,剖分晉國,史記六國年表:定王十六年,趙、魏、韓滅智伯,遂三分晉國。天子既不能討,又寵秩之,使列於諸侯,是區區之名分復不能守而并棄之也。陸德明經典釋文:凡復字,其義訓又者,并音扶又翻。先王之禮於斯盡矣!\n〖译文〗 呜呼!周幽王、周厉王丧失君德,周朝的气数每况愈下。礼纪朝纲土崩瓦解;下欺凌、上衰败;诸侯国君恣意征讨他人;士大夫擅自干预朝政;礼教从总体上已经有十之七八沦丧了。然而周文王、周武王开创的政权还能绵绵不断地延续下来,就是因为周王朝的子孙后裔尚能守定名位。为什么这样说呢?当年晋文公为周朝建立了大功,于是向周襄王请求允许他死后享用王室的隧葬礼制,周襄王没有准许,说:“周王制度明显。没有改朝换代而有两个天子,这也是作为叔父辈的晋文公您所反对的。不然的话,叔父您有地,愿意隧葬,又何必请示我呢?”晋文公于是感到畏惧而没有敢违反礼制。因此,周王室的地盘并不比曹国、滕国大,管辖的臣民也不比邾国、莒国多,然而经过几百年,仍然是天下的宗主,即使是晋、楚、齐、秦那样的强国也还不敢凌驾于其上,这是为什么呢?只是由于周王还保有天子的名分。再看看鲁国的大夫季氏、齐国的田常、楚国的白公胜、晋国的智伯,他们的势力都大得足以驱逐国君而自立,然而他们到底不敢这样做,难道是他们力量不足或是于心不忍吗?只不过是害怕奸夺名位僭犯身分而招致天下的讨伐罢了。现在晋国的三家大夫欺凌蔑视国君,瓜分了晋国,作为天子的周王不能派兵征讨,反而对他们加封赐爵,让他们列位于诸侯国君之中,这样做就使周王朝仅有的一点名分不能再守定而全部放弃了。周朝先王的礼教到此丧失干净!\n或者以為當是之時,周室微弱,三晉強盛,三家分晉國,時因謂之「三晉」,猶後之三秦、三齊也。雖欲勿許,其可得乎!是大不然。夫三晉雖強,苟不顧天下之誅而犯義侵禮,則不請於天子而自立矣。不請於天子而自立,則為悖逆之臣,夫,音扶。悖,蒲內翻,又蒲沒翻。天下苟有桓、文之君,必奉禮義而征之。今請於天子而天子許之,是受天子之命而為諸侯也,誰得而討之!故三晉之列于諸侯,非三晉之壞禮,乃天子自壞之也。壞,音怪,人毀之也。\n〖译文〗 有人认为当时周王室已经衰微,而晋国三家力量强盛,就算周王不想承认他们,又怎么能做得到呢!这种说法是完全错误的。晋国三家虽然强悍,但他们如果打算不顾天下的指责而公然侵犯礼义的话,就不会来请求周天子的批准,而是去自立为君了。不向天子请封而自立为国君,那就是叛逆之臣,天下如果有像齐桓公、晋文公那样的贤德诸侯,一定会尊奉礼义对他们进行征讨。现在晋国三家向天子请封,天子又批准了。他们就是奉天子命令而成为诸侯的,谁又能对他们加以讨伐呢!所以晋国三家大夫成为诸侯,并不是晋国三家破坏了礼教,正是周天子自已破坏了周朝的礼教啊!\n烏呼!君臣之禮既壞矣,此壞,其義為成壞之壞,讀如字。則天下以智力相雄長,長,知兩翻。遂使聖賢之後為諸侯者,社稷無不泯絕,謂齊、宋亡于田氏,魯、陳、越亡于楚,鄭亡于韓也。泯,彌忍翻,盡也,又彌鄰翻。毛晃曰:沒也,滅也。生民之類糜滅幾盡,說文曰:糜,糝sǎn也;取糜爛之義,音忙皮翻。幾,居依翻,又渠希翻,近也。豈不哀哉!\n〖译文〗 呜呼!君臣之间的礼纪既然崩坏,于是天下便开始以智慧、武力互相争雄,使当年受周先王分封而成为诸侯国君的圣贤后裔,江山相继沦亡,周朝先民的子孙灭亡殆尽,岂不哀伤!\n2初,智宣子將以瑤為後,智果曰:「不如宵也。韋昭曰:智宣子,晉卿荀躒之子申也,瑤,宣子之子智伯也,諡曰襄子。智果,智氏之族也。宵,宣子之庶子也。按諡法:聖善周聞曰宣。智氏溢美也。瑤之賢於人者五,其不逮者一也。韋昭曰:不仁也。美鬢長大則賢,通鑑俗傳寫者多作「美鬚」,非也。國語作「美鬢」,今從之。【章︰十二行本正作「鬢」;孔本同。乙十一行本作「鬚」。】射御足力則賢,伎藝畢給則賢,巧文辯惠則賢,韋昭曰:給,足也。巧文,巧於文辭。伎,渠綺翻。強毅果敢則賢;如是而甚不仁。夫以其五賢陵人而以不仁行之,其誰能待之?韋昭曰:待,猶假也。若果立瑤也,智宗必滅。」弗聽。智果別族於太史,為輔氏。此事見國語。按左傳哀公二十三年,晉荀瑤伐齊,始見於傳。哀二十三年,史記元王五年也。荀躒,智文子也。定十四年,智文子猶見於傳。智宣子之事,傳無所考。立瑤之議,當在元王五年之前。韋昭曰:太史掌氏姓,周禮春官之屬;小史掌定世系,辨昭穆。鄭司農註云:史官主書,故韓宣子聘魯,觀書于太史。世系謂帝系、世本之屬是也;小史主定之。賈公彥疏曰,註引太史證之者,太史史官之長,共其事故也。蓋周之制,小史定姓氏,其書則太史掌之。智果欲避智氏之禍,故於太史別族。宋祁國語補音:別,彼列翻;又如字。\n〖译文〗 [2]当初,晋国的智宣子想以智瑶为继承人,族人智果说:“他不如智宵。智瑶有超越他人的五项长处,只有一项短处。美发高大是长处,精于骑射是长处,才艺双全是长处,能写善辩是长处,坚毅果敢是长处。虽然如此却很不仁厚。如果他以五项长处来制服别人而做不仁不义的恶事,谁能和他和睦相处?要是真的立智瑶为继承人,那么智氏宗族一定灭亡。”智宣子置之不理。智果便向太史请求脱离智族姓氏,另立为辅氏。\n趙簡子之子,長曰伯魯,幼曰無恤。趙簡子,文子之孫鞅也。諡法:一德不懈曰簡。白虎通曰:子,孳zī也,孳孳無已也。趙岐曰:子者,男子之通稱也。長,知兩翻。將置後,不知所立,乃書訓戒之辭于二簡,孔穎達曰:書者,舒也。書緯璇璣鈐qián云:書者,如也。則書者,寫其言如其意,得展舒也。世本曰:沮誦、蒼頡作書。釋文名曰:書,庶也,紀庶物也;亦言著也,著之簡紙,求不滅也。簡,竹策也。以授二子曰:「謹識之!」識,職吏翻,記也。三年而問之,伯魯不能舉其辭;求其簡,已失之矣。問無恤,誦其辭甚習;習,熟也。求其簡,出諸袖中而奏之。毛晃曰:奏,進上也。於是簡子以無恤為賢,立以為後。\n〖译文〗 赵国的大夫赵简子的儿子,长子叫伯鲁,幼子叫无恤。赵简子想确定继承人,不知立哪位好,于是把他的日常训诫言词写在两块竹简上,分别交给两个儿子,嘱咐说:“好好记住!”过了三年,赵简子问起两个儿子,大儿子伯鲁说不出竹简上的话;再问他的竹简,已丢失了。又问小儿子无恤,竟然背诵竹简训词很熟习;追问竹简,他便从袖子中取出献上。于是,赵简子认为无恤十分贤德,便立他为继承人。\n簡子使尹鐸為晉陽‹山西太原›,姓譜:尹,少昊之子,封于尹城,子孫因為氏。韋昭曰:晉陽,趙氏邑。為,治也。班志曰:晉陽,故詩唐國。周成王滅唐,封弟叔虞。龍山在西,晉水所出,東入汾。臣瓚曰:所謂唐,今河東永安縣是也,去晉四百里。括地志曰:晉陽故城,今名晉城,在蒲州虞鄉縣西。今按水經註:晉水出晉陽縣西龍山。昔智伯遏晉水以灌晉陽,其水分為二流,北瀆即智氏故渠也。同過水出沾縣北山,西過榆次縣南,又西到晉陽縣南。榆次縣南水側有鑿臺,戰國策所謂「智伯死于鑿臺之下」,即此處也。參而考之,晉陽故城恐不在蒲州。水經註又云:叔虞封于唐,縣有晉水,故改名為晉。子夏序詩,「此晉也而謂之唐」,是也,與班志合。瓚說及括地志未知何據。請曰:「以為繭絲乎?抑為保障乎?」簡子曰:「保障哉!」繭絲,謂浚民之膏澤,如抽繭之緒,不盡則不止。保障,謂厚民之生,如築堡以自障,愈培則愈厚。宋祁曰:障,之亮翻,又音章。尹鐸損其戶數。韋昭曰:損其戶,則民優而稅少。簡子謂無恤曰:「晉國有難,而無以尹鐸為少,而,汝也。難,乃旦翻,患也,厄也。少,音多少之少。重之為多,輕之為少。無以晉陽為遠,必以為歸。」\n〖译文〗 赵简子派尹铎去晋阳,临行前尹铎请示说:“您是打算让我去抽丝剥茧般地搜刮财富呢,还是作为保障之地?”赵简子说:“作为保障。”尹铎便少算居民户数,减轻赋税。赵简子又对儿子赵无恤说:“一旦晋国发生危难,你不要嫌尹铎地位不高,不要怕晋阳路途遥远,一定要以那里作为归宿。”\n及智宣子卒,卒,子恤翻。智襄子為政,諡法:有勞定國曰襄。為政,為晉國之政。與韓康子、魏桓子宴于藍台。韓康子,韓宣子之曾孫莊子之子虔虎也。魏桓子,魏獻子之子曼多之孫駒也。諡法:溫柔好樂曰康;辟土服遠曰桓。爾雅:四方而高曰台。智伯戲康子而侮段規。姓譜:段,鄭共叔段之後。智國聞之,諫曰:「主不備難,【章︰十二行本無「難」字;乙十一行本同。】難必至矣!」春秋以來,大夫之家臣謂大夫曰主。難,乃旦翻;下同。智伯曰:「難將由我。我不為難,誰敢興之!」對曰:「不然。夏書有之:『一人三失,怨豈在明,不見是圖。』書五子之歌之辭。夏,戶雅翻。見,賢遍翻,發見也,著也,形也。夫君子能勤小物,故無大患。今主一宴而恥人之君相,夫,音扶。段規,韓康子之相也。相,息醬翻;下同。又弗備:曰『不敢興難』,無乃不可乎!蜹ruì、蟻、蜂、蠆,皆能害人,宋祁曰:蜹,如銳翻;又字林:人劣翻。秦人謂蚊為蜹。今按:蜹,小蟲,日中群集人之肌膚而嘬zuō其血,蚊之類也。蜂,細腰而能螫人。蠆亦毒蟲,長尾,音丑邁翻。況君相乎!」弗聽。\n〖译文〗 等到智宣子去世,智襄子智瑶当政,他与韩康子、魏桓子在蓝台饮宴,席间智瑶戏弄韩康子,又侮辱他的家相段规。智瑶的家臣智国听说此事,就告诫说:“主公您不提防招来灾祸,灾祸就一定会来了!”智瑶说:“人的生死灾祸都取决于我。我不给他们降临灾祸,谁还敢兴风作浪!”智国又说:“这话可不妥。《夏书》中说:‘一个人屡次三番犯错误,结下的仇怨岂能在明处,应该在它没有表现时就提防。’贤德的人能够谨慎地处理小事,所以不会招致大祸。现在主公一次宴会就开罪了人家的主君和臣相,又不戒备,说:‘不敢兴风作浪。’这种态度恐怕不行吧。蚊子、蚂蚁、蜜蜂、蝎子,都能害人,何况是国君、国相呢!”智瑶不听。\n智伯請地于韓康子,康子欲弗與。段規曰:「智伯好利而愎,不與,將伐我;不如與之。彼狃niǔ於得地,好,呼到翻。愎,弼力翻,狠也。狃,女九翻,驕忲tài也,又相狎也。必請於他人;他人不與,必向之以兵,然後【章︰十二行本「後」作「則」;乙十一行本同。】我得免於患而待事之變矣。」康子曰:「善。」使使者致萬家之邑于智伯。毛晃曰:邑,都邑。四井為邑,四邑為丘;邑方二里,丘方四里。載師以公邑之田任甸地,以家邑之田任稍地。註:公邑,謂六遂餘地。家邑,大夫之采地。此又與四井之邑不同。又都,國都;邑,縣也。左傳:凡邑有先君宗廟之主曰都,無曰邑。邑曰築,都曰城。此謂大縣邑也。杜預引周禮「四縣為都,四井為邑」,恐誤。四井之邑方二里,豈能容宗廟城郭!如論語「十室之邑」,西都賦「都都相望,邑邑相屬」,則是四縣四井之都邑也。若千室之邑、萬家之邑,則非井邑矣。項安世曰:小司徒井牧田野,以四井為邑,凡三十六家;除公田四夫,凡三十二家;遂大夫會為邑者之政,以里為邑,凡二十五家。遂大夫蓋論里井之制,二十五家共一里門,即六鄉之二十五家為一閭也;小司徒蓋論溝洫之制,四井為邑,共用一溝,即匠人所謂「井間廣四尺深四尺謂之溝」也。居則度人之眾寡,溝則度水之眾寡,此其所以異歟!毛、項二說皆明周制,參而考之,戰國之所謂邑非周制矣。致,送至也。智伯悅。又求地于魏桓子,桓子欲弗與。任章曰:「何故弗與?」任章,魏桓子之相也。姓譜:黃帝二十五子,十二人各以德為姓,第一曰任氏。又任為風姓之國,實太昊之後,主濟祀,今濟州任城即其地。任,市林翻。桓子曰:「無故索地,故弗與。」任章曰:「無故索地,諸大夫必懼;索,山客翻,求也,吾與之地,智伯必驕。彼驕而輕敵,此懼而相親。以相親之兵待輕敵之人,智氏之命必不長矣。周書曰:『將欲敗之,必姑輔之。將欲取之,必姑與之。』逸書也。敗,補邁翻。主不如與之,以驕智伯,然後可以擇交而圖智氏矣,奈何獨以吾為智氏質乎!」質,脂利翻,物相綴當也。又質讀如字,亦通。質,謂椹質也,質的也。椹質受斧,質的受矢。言智伯怒魏桓子,必加兵于魏,如椹質之受斧,質的之受矢也。桓子曰:「善。」復與之萬家之邑一。復,扶又翻。\n〖译文〗 智瑶向韩康子要地,韩康子想不给。段规说:“智瑶贪财好利,又刚愎自用,如果不给,一定讨伐我们,不如姑且给他。他拿到地更加狂妄,一定又会向别人索要;别人不给,他必定向人动武用兵,这样我们就可以免于祸患而伺机行动了。”韩康子说:“好主意。”便派了使臣去送上有万户居民的领地。智瑶大喜,果然又向魏桓子提出索地要求,魏桓子想不给。家相任章问:“为崐什么不给呢?”魏桓子说:“无缘无故来要地,所以不给。”任章说:“智瑶无缘无故强索他人领地,一定会引起其他大夫官员的警惧;我们给智瑶地,他一定会骄傲。他骄傲而轻敌,我们警惧而互相亲善;用精诚团结之兵来对付狂妄轻敌的智瑶,智家的命运一定不会长久了。《周书》说:‘要打败敌人,必须暂时听从他;要夺取敌人利益,必须先给他一些好处。’主公不如先答应智瑶的要求,让他骄傲自大,然后我们可以选择盟友共同图谋,又何必单独以我们作智瑶的靶子呢!”魏桓子说:“对。”也交给智瑶一个有万户的封地。\n智伯又求蔡‹藺,山西离石西›、皋狼‹山西離石縣西北›之地于趙襄子,康曰:皋,姑勞切;狼,盧當切;春秋蔡地,後為趙邑。余據春秋之時,晉、楚爭盟,晉不能越鄭而服蔡。三家分晉,韓得成皋,因以并鄭,時蔡已為楚所滅,鄭之南境亦入于楚,就使皋狼為蔡地,趙襄子安得而有之!漢書地理志西河郡有皋狼縣,又有藺縣。漢之西河,春秋以來皆為晉境,而古文「藺」字與「蔡」字近,或者「蔡」字其「藺」字之訛也。襄子弗與。智伯怒,帥韓、魏之甲以攻趙氏。帥,讀曰率。襄子將出,曰:「吾何走乎?」走,則豆翻,疾趨之也。趨,七喻翻。從者曰:「長子‹山西长子›近,且城厚完。」從,才用翻。長子縣,周史辛伯所封邑。班志屬上黨郡。陸德明曰:長子之長,丁丈翻。顏師古曰:長,讀為短長之長;今讀為長幼之長,非也。崔豹古今註曰:城,盛也,所以盛受民物也。淮南子曰:鯀作城。盛,時征翻。襄子曰:「民罷力以完之,罷,讀曰疲。又斃死以守之,其誰與我!」韋昭曰:謂誰與我同力也。從者曰:「邯鄲‹河北邯鄲›之倉庫實。」邯鄲,即春秋邯鄲午之邑也。班志,邯鄲縣屬趙國。張晏曰:邯鄲山在東城下。單,盡也。城郭從邑,故旁加邑。宋白曰:邯鄲本衛地,後屬晉;七國時為趙都,趙敬侯自晉陽始都邯鄲。余按史記六國年表,周安王之十六年,趙敬侯之元年;烈王之二年,趙成侯之元年。成侯二十二年,魏克邯鄲,是年顯王之十六年也。二十四年,魏歸邯鄲。若敬侯已都邯鄲,魏克其國都而趙不亡,何也?至顯王二十二年,公子范襲邯鄲,不勝而死,是年肅侯之三年也。意此時趙方都邯鄲,蓋肅侯徙都,非敬侯也。邯,音寒。鄲,音丹,康多寒切。襄子曰:「浚民之膏澤以實之,韋昭曰:浚,煎也,讀曰醮jiào。宋祁曰:浚,蘇俊翻;醮,子召翻;余謂浚讀當如宋音。浚者,疏瀹yuè也,淘也,深也。又因而殺之,其誰與我!其晉陽‹山西太原›乎,先主之所屬也,古者諸侯之大夫,其家之臣子皆稱之曰主,死則曰先主,考左傳可見已。屬,陟玉翻。尹鐸之所寬也,民必和矣。」乃走晉陽。\n〖译文〗 智瑶又向赵襄子要蔡和皋狼的地方。赵襄子拒绝不给。智瑶勃然大怒,率领韩、魏两家甲兵前去攻打赵家。赵襄子准备出逃。问:“我到哪里去呢?”随从说:“长子城最近,而且城墙坚厚又完整。”赵襄子说:“百姓精疲力尽地修完城墙,又要他们舍生入死地为我守城,谁能和我同心?”随从又说:“邯郸城里仓库充实。”赵襄子说:“搜刮民脂民膏才使仓库充实,现在又因战争让他们送命,谁会和我同心。还是投奔晋阳吧,那是先主的地盘,尹铎又待百姓宽厚,人民一定能同我们和衷共济。”于是前往晋阳。\n三家以國人圍而灌之,城不浸者三版;高二尺為一版;三版,六尺。沈竈產鼃wā,民無叛意。沈,持林翻。顏師古漢書音義曰,鼃,黽měng也,似蝦蟆而長腳,其色青。史遊急就章曰:蛙,蝦蟆。陸佃埤雅曰;鼃,似蝦蟆而長踦,瞋目如怒。鼃,與蛙同,音下媧翻。智伯行水,據經典釋文,凡巡行之行,音下孟翻;後仿此。魏桓子御,韓康子驂乘。兵車,尊者居左,執弓矢;御者居中;有力者居右,持矛以備傾側,所謂車右是也。韓、魏畏智氏之強,一為之御,一為之右。驂,與參同,參者,三也。三人同車則曰驂乘,四人同車則曰駟乘。左傳:齊伐晉,燭庸之越駟乘。杜預註曰:四人共乘者殿車。乘,石證翻。智伯曰:「吾乃今知水可以亡人國也。」桓子肘康子:康子履桓子之跗,以汾水可以灌安邑‹山西夏縣›,絳水可以灌平陽‹山西臨汾›也。跗,音夫,足趾也。班志:汾水出汾陽北山。汾陽縣屬太原郡,安邑縣屬河東郡。史記正義曰:安邑故城在絳州夏縣東北十五里。應劭shào曰:絳水出河東絳縣西南。平陽縣亦屬河東郡。安邑,魏絳始居邑。平陽,韓武子玄孫貞子始居之。桓、康二子之肘足接,蓋各為都邑慮也。水經註曰:絳水出絳縣西南,蓋以故絳為言,其水出絳山東,西北流而合於澮huì,猶在絳縣界中。智伯所謂「汾水可以灌安邑」,或亦有之;「絳水可以灌平陽」,未識所由。余謂自春秋之季至於元魏,歷年滋多,郡縣之離合,川谷之遷改,有不可以一時所睹為據者。史記正義曰:韓初都平陽,今晉州也。括地志曰:絳水一名白,今名沸泉,源出絳山,飛泉奮湧,揚波注縣,積壑三十餘丈,望之極為奇觀,可接引北灌平陽城。酈道元父范,歷仕三齊,少長齊地,熟其山川,後入關死於道,未嘗至河東也。此蓋因耳學而致疑。括地志成于唐之魏王泰,泰者,太宗之愛子,羅致天下一時名儒以作此書,其考據宜詳,當取以為據。絺chī疵謂智伯曰:「韓、魏必反矣。」智伯曰:「子何以知之?」絺疵曰:「以人事知之。夫從韓、魏之兵以攻趙,趙亡,難必及韓、魏矣。夫,音扶。難,乃旦翻。今約勝趙而三分其地,城不沒者三版,人馬相食,城降有日,而二子無喜志,有憂色,是非反而何?」明日,智伯以絺疵之言告二子,二子曰:「此夫讒人欲為趙氏遊說,使主疑於二家而懈于攻趙氏也。不然,夫二家豈不利朝夕分趙氏之田,而欲為危難不可成之事乎!」二子出,絺疵入曰:「主何以臣之言告二子也?」智伯曰:「子何以知之?」對曰:「臣見其視臣端而趨疾,知臣得其情故也。」智伯不悛quān。絺疵請使于齊。夫,音扶;餘并同。難,乃旦翻。降,戶江翻,下也,服也。說,輸芮翻。懈,居隘翻,怠也。危難,如字。悛,丑緣翻,改也,止也。絺,抽遲翻,姓也。康曰:「絺」當作「郗」,姓譜諸書未有從絲者,疑借字。余按姓譜:絺姓,周蘇忿生支子,封於絺,因氏焉。為趙之為,音於偽翻。使,疏吏翻。疵請出使以避禍也。\n〖译文〗 智瑶、韩康子、魏桓子三家围住晋阳,引水灌城。城墙头只差三版的地方没有被淹没,锅灶都被泡塌,青蛙孳生,人民仍是没有背叛之意。智瑶巡视水势,魏桓子为他驾车,韩康子站在右边护卫。智瑶说:“我今天才知道水可以让人亡国。”魏桓子用胳膊肘碰了一下韩康子,韩康子也踩了一下魏桓子脚。因为汾水可以灌魏国都城安邑,绛水也可以灌韩国都城平阳。智家的谋士疵对智瑶说:“韩、魏两家肯定会反叛。”智瑶问:“你何以知道?”疵说:“以人之常情而论。我们调集韩、魏两家的军队来围攻赵家,赵家覆亡,下次灾难一定是连及韩、魏两家了。现在我们约定灭掉赵家后三家分割其地,晋阳城仅差三版就被水淹没,城内宰马为食,破城已是指日可待。然而韩康子、魏桓子两人没有高兴的心情,反倒面有忧色,这不是必反又是什么?”第二天,智瑶把疵的话告诉了韩、魏二人,二人说:“这一定是离间小人想为赵家游说,让主公您怀疑我们韩、魏两家而放松对赵家的进攻。不然的话,我们两家岂不是放着早晚就分到手的赵家田土不要,而要去干那危险必不可成的事吗?”两人出去,疵进来说:“主公为什么把臣下我的话告诉他们两人呢?”智瑶惊奇地反问:“你怎么知道的?”回答说:“我见他们认真看我而匆忙离去,因为他们知道我看穿了他们的心思。”智瑶不改。于是疵请求让他出使齐国。\n趙襄子使張孟談潛出見二子,曰:「臣聞唇亡則齒寒。今智伯帥韓、魏以攻趙,趙亡則韓、魏為之次矣。」帥,讀曰率。二子曰:「我心知其然也;恐事未遂而謀泄,則禍立至矣。」張孟談曰:「謀出二主之口,入臣之耳,何傷也!」二子乃潛與張孟談約,為之期日而遣之。姓譜:張氏本自軒轅第五子揮,始造弦,寔shí張網羅,世掌其職,後因氏焉。風俗傳云:張、王、李、趙,黃帝所賜姓也。又晉有解張,字張侯,自此晉國有張氏。唐姓氏譜:張氏出自姬姓,黃帝子少昊青陽氏第五子揮正始制弓矢,子孫賜姓張。周宣王卿士張仲,其後裔事晉為大夫。襄子夜使人殺守堤之吏,而決水灌智伯軍。智伯軍救水而亂,韓、魏翼而擊之,襄子將卒犯其前,將,即亮翻,又音如字。將,領也。卒,臧沒翻。說文:吏人給事者衣為卒,卒衣有題識;其字從「衣」從「十」。大敗智伯之眾,以此敗彼曰敗。敗,比邁翻。遂殺智伯,盡滅智氏之族。史記六國年表,三晉滅智氏在周定王十六年,上距獲麟二十七年。皇甫謐曰:元王十一年癸未,三晉滅智伯。唯輔果在。以別族也。\n〖译文〗 赵襄子派张孟谈秘密出城来见韩、魏二人,说:“我听说唇亡齿寒。现在智瑶率领韩、魏两家来围攻赵家,赵家灭亡就该轮到韩、魏了。”韩康子、魏崐桓子也说:“我们心里也知道会这样,只怕事情还未办好而计谋先泄露出去,就会马上大祸临头。”张孟谈又说:“计谋出自二位主公之口,进入我一人耳朵,有何伤害呢?”于是两人秘密地与张孟谈商议,约好起事日期后送他回城了。夜里,赵襄子派人杀掉智军守堤官吏,使大水决口反灌智瑶军营。智瑶军队为救水淹而大乱,韩、魏两家军队乘机从两翼夹击,赵襄子率士兵从正面迎头痛击,大败智家军,于是杀死智瑶,又将智家族人尽行诛灭。只有辅果得以幸免。\n臣光曰:智伯之亡也,才勝德也。夫才與德異,而世俗莫之能辨,夫,音扶。通謂之賢,此其所以失人也。夫聰察強毅之謂才,正直中和之謂德。才者,德之資也,德者,才之帥也。夫,音扶。帥,所類翻。雲夢‹湖北安陆南›之竹,天下之勁也;書禹貢:雲土夢作乂yì。孔安國註云:雲夢之澤在江南。左傳:楚王以鄭伯田江南之夢。杜預註云:楚之雲夢跨江南北。班志:雲夢澤在南郡華容縣南。祝穆曰:據左傳鄖夫人棄子文於夢中,言夢而不言雲,楚子避吳入於雲中,言雲而不言夢,則知雲、夢二澤也。漢陽志:雲在江之北,夢在江之南。又安陸有雲夢澤,枝江有雲夢城。蓋古之雲夢澤甚廣,而後世悉為邑居聚落,故地之以雲夢得名者非一處。竹箭之產,荊楚為良;雲夢,楚之地也。夢,如字,又莫公翻。然而不矯揉,不羽括,則不能以入堅。矯,舉夭翻。揉,如久翻。康曰:揉曲為矯,揉所以橈曲而使之直也。羽者,箭翎。括者,箭窟受弦處。括,音聒,通作「筈」。kuò棠溪‹河南西平西北›之金,天下之利也;左傳:楚封吳夫概王於棠溪。戰國之時,其地屬韓,出金甚精利。劉昭郡國志:汝南郡吳房縣有棠溪亭。杜佑通典曰:棠溪在今汝州郾城縣界。九域志:蔡州有冶爐城,韓國鑄劍之地。然而不鎔范,不砥礪,則不能以擊強。毛晃曰:鎔,銷也,鑄也;說文:鑄器法也。董仲舒傳:猶金在鎔。註:鎔,謂鑄器之模范。范,法也,式也。禮運:范金合土。砥,軫氏翻,柔石也。礪,力制翻,䃺也。是故才德全盡謂之「聖人」,才德兼亡謂之「愚人」;德勝才謂之「君子」,才勝德謂之「小人」。凡取人之術,苟不得聖人、君子而與之,與其得小人,不若得愚人。何則?君子挾才以為善,小人挾才以為惡。挾才以為善者,善無不至矣;挾才以為惡者,惡亦無不至矣。挾,檄頰翻。愚者雖欲為不善,智不能周,力不能勝,譬如乳狗搏人,人得而制之。挾,戶頰翻。朱元晦曰:挾者,兼有而恃之之稱。勝,音升。乳,儒遇翻,乳育也。乳狗,育子之狗也。搏,伯各翻。小人智足以遂其奸,勇足以決其暴,是虎而翼者也,其為害豈不多哉!虎而傅翼,其為害也愈甚。夫德者人之所嚴,嚴,敬也。而才者人之所愛;愛者易親,嚴者易疏,易,以豉翻。是以察者多蔽於才而遺於德。自古昔以來,國之亂臣,家之敗子,才有餘而德不足,以至於顛覆者多矣,豈特智伯哉!故為國為家者苟能審於才德之分而知所先後,先,悉薦翻。後,戶遘翻。又何失人之足患哉!\n〖译文〗 臣司马光曰:智瑶的灭亡,在于才胜过德。才与德是不同的两回事,而世俗之人往往分不清,一概而论之曰贤明,于是就看错了人。所谓才,是指聪明、明察、坚强、果毅;所谓德,是指正直、公道、平和待人。才,是德的辅助;德,是才的统帅。云梦地方的竹子,天下都称为刚劲,然而如果不矫正其曲,不配上羽毛,就不能作为利箭穿透坚物。棠地方出产的铜材,天下都称为精利,然而如果不经熔烧铸造,不锻打出锋,就不能作为兵器击穿硬甲。所以,德才兼备称之为圣人;无德无才称之为愚人;德胜过才称之为君子;才胜过德称之为小人。挑选人才的方法,如果找不到圣人、君子而委任,与其得到小人,不如得到愚人。原因何在?因为君子持有才干把它用到善事上;而小人持有才干用来作恶。持有才干作善事,能处处行善;而凭借才干作恶,就无恶不作了。愚人尽管想作恶,因为智慧不济,气力不胜任,好像小狗扑人,人还能制服它。而小人既有足够的阴谋诡计来发挥邪恶,又有足够的力量来逞凶施暴,就如恶虎生翼,他的危害难道不大吗!有德的人令人尊敬,有才的人使人喜爱;对喜爱的人容易宠信专任,对尊敬的人容易疏远,所以察选人才者经常被人的才干所蒙蔽而忘记了考察他的品德。自古至今,国家的乱臣奸佞,家族的败家浪子,因为才有余而德不足,导致家国覆亡的多了,又何止智瑶呢!所以治国治家者如果能审察才与德两种不同的标准,知道选择的先后,又何必担心失去人才呢!\n3三家分智氏之田。趙襄子漆智伯之頭,以為飲器。說文:桼qī,木汁可以䰍qī物,下從水,象桼如水滴而下也。漢書張騫傳:匈奴破月氏王,以其頭為飲器。韋昭註曰:飲器,椑pí榼kē也。晉灼曰:飲器,虎子屬也。或曰,飲酒之器也。師古曰:匈奴嘗以月氏王頭與漢使歃血盟,然則飲酒之器是也。韋云椑榼,晉云虎子,皆非也。椑榼,即今之偏榼,所以盛酒耳,非用飲者也。虎子,褻器,所以溲便者。椑,音鼙。榼,克合翻。氏,音支。使,疏吏翻。歃,色甲翻。盛,時征翻。褻,息列翻。溲,疏鳩翻。便,毗連翻。智伯之臣豫讓欲為之報仇,豫,姓也。讓,名也。戰國之時又有豫且,不知其同時否也。為,音於偽翻;下同。乃詐為刑人,挾匕首,入襄子宮中塗廁。挾,持也。劉向曰:匕首,短劍。鹽鐵論曰:匕首長尺八寸;頭類匕,故云匕首。匕,音比。廁,初吏翻,圊qīng也。長,直亮翻。襄子如廁心動,索之,獲豫讓。索,山客翻。左右欲殺之,襄子曰:「智伯死無後,而此人欲為報仇,真義士也,吾謹避之耳。」乃舍之。舍,讀曰捨。豫讓又漆身為癩,吞炭為啞。癩,落蓋翻,惡疾也。啞,倚下翻,瘖yīn也。行乞於市,神農日中為市,致天下之民,聚天下之貨,交易而退,此立市之始也,鄭氏周禮註曰:市,雜聚之處。其妻不識也。行見其友,其友識之,為之泣曰:「以子之才,臣事趙孟,必得近幸,自春秋之時,趙宣子謂之宣孟,趙文子謂之趙孟,其後遂襲而呼為趙孟。孟,長也。子乃為所欲為,顧不易邪?易,以豉翻。何乃自苦如此?求以報仇,不亦難乎!」豫讓曰:【章︰十二行本「曰」下有「不可」二字;乙十一行本同;孔本同;張校同;退齋校同。】「既已委質為臣,經典釋文曰:質,職日翻。委質,委其體以事君也。後漢書註:委質,屈膝。而又求殺之,是二心也。凡吾所為者,極難耳。然所以為此者,將以愧天下後世之為人臣懷二心者也。」襄子出,豫讓伏於橋下。襄子至橋,馬驚;索之,得豫讓,遂殺之。自智宣子立瑤,至豫讓報仇,其事皆在威烈王二十三年之前,故先以「初」字發之。溫公之意,蓋以天下莫大于名分,觀命三大夫為諸侯之事,則知周之所以益微,七雄之所以益盛;莫重于宗社,觀智、趙立後之事,則知智宣子之所以失,趙簡子之所以得;君臣之義當守節伏死而已,觀豫讓之事,則知策名委質者必有霣yǔn而無貳。其為後世之鑒,豈不昭昭也哉!\n〖译文〗 [3]赵、韩、魏三家瓜分智家的田土,赵襄子把智瑶的头骨涂上漆,作为饮具。智瑶的家臣豫让想为主公报仇,就化装为罪人,怀揣匕首,混到赵襄子的宫室中打扫厕所。赵襄子上厕所时,忽然心动不安,令人搜索,抓获了豫让。左右随从要将他杀死,赵襄子说:“智瑶已死无后人,而此人还要为他报仇,真是一个义士,我小心躲避他好了。”于是释放豫让。豫让用漆涂身,弄成一个癞疮病人,又吞下火炭,弄哑嗓音。在街市上乞讨,连结发妻子见面也认不出来。路上遇到朋友,朋友认出他,为他垂泪道:“以你的才干,如果投靠赵家,一定会成为亲信,那时你就为所欲为,不是易如反掌吗?何苦自残形体崐以至于此?这样来图谋报仇,不是太困难了吗!”豫让说:“我要是委身于赵家为臣,再去刺杀他,就是怀有二心。我现在这种做法,是极困难的。然而之所以还要这样做,就是为了让天下与后世做人臣子而怀有二心的人感到羞愧。”赵襄子乘车出行,豫让潜伏在桥下。赵襄子到了桥前,马突然受惊,进行搜索,捕获豫让,于是杀死他。\n襄子為伯魯之不立也,有子五人,不肯置後。封伯魯之子于代‹河北蔚縣›,代國在夏屋句注之北,趙襄子滅之。班志有代郡代縣。為,於偽翻。夏,戶雅翻。曰代成君,早卒;成,諡也。諡法:安民立政曰成。立其子浣為趙氏後。浣,戶管翻。襄子卒,弟桓子逐浣而自立;史記六國表,威烈王元年,襄子卒;二年,趙桓子元年,卒;明年,國人立獻侯浣。「浣」,索隱作「晚」。卒,子恤翻;下同。一年卒。趙氏之人曰:「桓子立非襄主意。」乃共殺其子,復迎浣而立之,是為獻子。復,扶又翻;又音如字。獻子,即獻侯。六國表:威烈王三年,獻侯之元年。蓋分晉之後,三晉僭jiàn侯久矣。諡法:知質有聖曰獻。獻子生籍,是為烈侯。諡法:有功安民曰烈,秉德尊業曰烈。魏斯者,魏桓子之孫也,是為文侯。諡法:學勤好問曰文;慈惠安民曰文。韓康子生武子;武子生虔,是為景侯。諡法;克定禍亂曰武;布義行剛曰景。六國表:威烈王二年,魏文侯斯元年;十八年,韓景侯虔元年。蓋其在國僭爵已久,不敢以通王室;威烈王遂因而命之,識者重為周惜。通鑑於此序三家之世也。\n〖译文〗 赵襄子因为赵简子没有立哥哥伯鲁为继承人,自己虽然有五个儿子,也不肯立为继承人。他封赵伯鲁的儿子于代国,称代成君,早逝;又立其子赵浣为赵家的继承人。赵襄子死后,弟弟赵桓子就驱逐赵浣,自立为国君,继位一年也死了。赵家的族人说:“赵桓子做国君本来就不是赵襄子的主意。”大家一起杀死了赵桓子的儿子,再迎回赵浣,拥立为国君,这就是赵献子。赵献子生子名赵籍,就是赵烈侯。魏斯,是魏桓子的孙子,就是魏文侯。韩康子生子名韩武子,武子又生韩虔,被封为韩景侯。\n魏文侯以卜子夏、田子方為師。卜,以官為氏。田本出於陳,陳敬仲以陳為田氏。徐廣曰:始食埰地,由是改姓田氏。索隱曰:陳、田二聲相近,遂為田氏。夏,戶雅翻。每過段干木之廬必式。過,工禾翻。唐人志氏族曰:李耳,字伯陽,一字聃;其後有李宗,魏封于段,為干木大夫,是以段為氏也。余按:通鑑赧王四十二年,魏有段干子,則段干,複姓也。書:武王式商容閭。註云:式其閭巷,以禮賢。記曲禮:國君撫式,士下之。註云:升車必正立,據式小俛fǔ,崇敬也。師古曰:式,車前橫木。古者立乘;凡言式車者,謂俛首撫式,以禮敬人。孔穎達曰:式,謂俯下頭也。古者車箱長四尺四寸而三分,前一後二,橫一木,下去車牀三尺三寸,謂之為式;又於式上二尺二寸橫一木,謂之較,較去車牀凡五尺五寸。於時立乘,若平常則憑較,故詩云「倚重較兮」是也。若應為敬,則落隱下式,而頭得俯俛,故記云「式視馬尾」是也。較,訖嶽翻。四方賢士多歸之。\n〖译文〗 魏文侯魏斯以卜子夏、田子方为国师,他每次经过名士段干木的住宅,都要在车上俯首行礼。四方贤才德士很多前来归附他。\n文侯與群臣飲酒,樂,而天雨,命駕將適野。左右曰:「今日飲酒樂,天又雨,君將安之?」文侯曰:「吾與虞人期獵,雖樂,豈可無一會期哉?」乃往,身自罷之。周禮有山虞、澤虞,以掌山澤。註云:虞,度也,度知山林之大小及其所生。身自罷之者,身往告之,以雨而罷獵也。樂,音洛。\n〖译文〗 魏文侯与群臣饮酒,奏乐间,下起了大雨,魏文侯却下令备车前往山野之中。左右侍臣问:“今天饮酒正乐,外面又下着大雨,国君打算到哪里去呢?”魏文侯说:“我与山野村长约好了去打猎,虽然这里很快乐,也不能不遵守约定!”于是前去,亲自告诉停猎。\n韓借師于魏以伐趙,文侯曰:「寡人與趙,兄弟也,不敢聞命。」趙借師于魏以伐韓,文侯應之亦然。二國皆怒而去。已而知文侯以講於己也,講,和也。皆朝于魏。朝,直遙翻。魏於是始大於三晉,諸侯莫能與之爭。\n〖译文〗 韩国邀请魏国出兵攻打赵国。魏文侯说:“我与赵国,是兄弟之邦,不敢从命。”赵国也来向魏国借兵讨伐韩国,魏文侯仍然用同样的理由拒绝了。两国使者都怒气冲冲地离去。后来两国得知魏文侯对自己的和睦态度,都前来朝拜魏国。魏国于是开始成为魏、赵、韩三国之首,各诸侯国都不能和它争雄。\n使樂羊伐中山‹都顾城,河北定州›,克之;樂,姓也。本自有殷微子之後。宋戴公四世孫樂呂為大司寇。中山,春秋之鮮虞也,漢為中山郡。宋白曰:唐定州,春秋白狄鮮虞之地。隋圖經曰:中山城在今唐昌縣東北三十一里,中山故城是也。杜佑曰:城中有山,故曰中山。以封其子擊。文侯問於群臣曰:「我何如主?」皆曰:「仁君。」任座曰:「君得中山,不以封君之弟而以封君之子,何謂仁君!」文侯怒,任座趨出。任座亦習見當時鄰國之事而為是言耳。任音壬,「座」一作「痤」,音才戈翻。次問翟璜,翟,姓也,音直格翻,又音狄。姓譜:翟為晉所滅,子孫以國為氏。今人多讀從上音。璜,戶光翻。對曰:「仁君。」文侯曰:「何以知之?」對曰:「臣聞君仁則臣直。嚮者任座之言直,臣是以知之。」文侯悅,使翟璜召任座而反之,親下堂迎之,以為上客。\n〖译文〗 魏文侯派乐羊攻打中山国,予以攻克,封给自己的儿子魏击。魏文侯问群臣:“我是什么样的君主?”大家都说:“您是仁德的君主!”只有任座说:“国君您得了中山国,不用来封您的弟弟,却封给自己的儿子,这算什么仁德君主!”魏文侯勃然大怒,任座快步离开。魏文侯又问翟璜,翟璜回答说:“您是仁德君主。”魏文侯问:“你何以知道?”回答说:“臣下我听说国君仁德,他的臣子就敢直言。刚才任座的话很耿直,于是我知道您是仁德君主。”魏文侯大喜,派翟璜去追任座回来,还亲自下殿堂去迎接,奉为上客。\n文侯與田子方飲,文侯曰:「鐘聲不比乎?比,音毗。不比,言不和也。左高。」此蓋編鐘之懸,左高,故其聲不和。田子方笑。文侯曰:「何笑?」子方曰:「臣聞之,君明樂官,不明樂音,今君審於音,臣恐其聾於官也。」明樂官,知其才不才;明樂音,知其和不和。五聲合和,然後成音。詩大序曰:聲成文,謂之音。文侯曰:「善。」\n〖译文〗 魏文侯与田子方饮酒,文侯说:“编钟的乐声不协调吗?左边高。”田子方笑了,魏文侯问:“你笑什么?”田子方说:“臣下我听说,国君懂得任用乐官,不必懂得乐音。现在国君您精通音乐,我担心您会疏忽了任用官员的职责。”魏文侯说:“对。”\n子擊出,遭田子方於道,下車伏謁。古文史考曰:黃帝作車,引重致遠;少昊氏加牛;禹時奚仲加馬。釋名曰:車,居也。韋昭曰:古唯尺遮翻,自漢以來,始有「居」音。蕭子顯曰:三皇氏乘祇zhī車出谷口,車之始也。祇,翹移翻。子方不為禮。子擊怒,謂子方曰:「富貴者驕人乎?貧賤者驕人乎?」子方曰:「亦貧賤者驕人耳,富貴者安敢驕人!國君而驕人則失其國,大夫而驕人則失其家。失其國者未聞有以國待之者也,失其家者未聞有以家待之者也。夫士貧賤者,言不用,行不合則納履而去耳,安往而不得貧賤哉!」子擊乃謝之。夫,音扶。行,下孟翻。\n〖译文〗 魏文侯的公子魏击出行,途中遇见国师田子方,下车伏拜行礼。田子方却不作回礼。魏击怒气冲冲地对田子方说:“富贵的人能对人骄傲呢,还是贫贱的人能对人骄傲?”田子方说:“当然是贫贱的人能对人骄傲啦,富贵的人哪里敢对人骄傲呢!国君对人骄傲就将亡国,大夫对人骄傲就将失去采地。失去国家的人,没有听说有以国主对待他的;失去采地的人,也没有听说有以家主对待他的。贫贱的游士呢,话不听,行为不合意,就穿上鞋子告辞了,到哪里得不到贫贱呢!”魏击于是谢罪。\n文侯謂李克曰:「先生嘗有言曰:『家貧思良妻,國亂思良相。』今所置非成則璜,二子何如?」李氏出自顓頊曾孫皋陶,為堯大理,以官命族為理氏。商紂時,裔孫利貞逃難,食木子得全,改為李氏。置,言置相也。相,息亮翻。難,乃旦翻。對曰:「卑不謀尊,疏不謀戚。臣在闕門之外,不敢當命。」在闕門之外,謂疏遠也。文侯曰:「先生臨事勿讓!」克曰:「君弗察故也。居視其所親,富視其所與,達視其所舉,窮視其所不為,貧視其所不取,五者足以定之矣,何待克哉!」文侯曰:「先生就舍,吾之相定矣。」相,息亮翻。李克出,見翟璜。翟璜曰:「今者聞君召先生而卜相,果誰為之?」克曰:「魏成。」翟璜忿然作色曰:「西河‹黃河西岸,陝西東部›守吳起,臣所進也。班志;魏地,其界自高陵以東,盡河東、河內。高陵縣,漢屬馮翊,其地在河西,所謂「西河之外」者也。魏初使吳起守之,秦兵不敢東向。至惠王時,秦使衛鞅擊虜其將公子卬,遂獻西河之外于秦。吳,以國為姓。相,息亮翻,守,式又翻。君內以鄴‹河北临漳西南邺镇›為憂,臣進西門豹。班志,鄴縣屬魏郡。西門豹為鄴令,鑿渠以利民。王符潛夫論姓氏篇曰:如有東門、西郭、南宮、北郭,皆因居以為姓。西門蓋亦此類。鄴,魚怯翻。君欲伐中山,臣進樂羊。中山已拔,無使守之,臣進先生。君之子無傅,臣進屈侯鮒。傅者,傅之以德義,因以為官名。傅,芳遇翻。屈,九勿翻,姓也。余按屈,晉地,時屬魏;鮒蓋魏封屈侯也。鮒,音符遇翻。以耳目之所睹記,臣何負于魏成!」不勝為負。李克曰:「子【章︰十二行本「子」下有「之」字;乙十一行本同;孔本同。】言克於子之君者,豈將比周以求大官哉?比,毗至翻。阿黨為比。君問相於克,克之對如是。李克自敍其答魏文侯之言也。所以知君之必相魏成者,魏成食祿千鐘,孔穎達曰:祿者,穀也。故鄭註司祿云:祿也言穀,年穀豐然後制祿。援神契云:祿者,錄也。白虎通曰:上以收錄接下,下以名錄謹以事上是也。六斛四斗為一鐘。什九在外,什一在內;是以東得卜子夏、田子方、段干木。夏,戶雅翻。此三人者,君皆師之:子所進五人者,君皆臣之。子惡得與魏成比也!」惡,讀曰烏,何也。翟璜逡巡再拜曰:「璜,鄙人也,失對,願卒為弟子!」逡,七倫翻。逡巡,卻退貌。卒,子恤翻,終也。孔穎達曰:先生,師也。謂師為先生者,言彼先己而生,其德多厚也。自稱為弟子者,言己自處如弟子,則尊其師如父兄也。\n〖译文〗 魏文侯问李克:“先生曾经说过:‘家贫思良妻,国乱思良相。’现在我选相不是魏成就是翟璜,这两人怎么样?”李克回答说:“下属不参与尊长的事,外人不过问亲戚的事。臣子我在朝外任职,不敢接受命令。”魏文侯说:“先生不要临事推让!”李克说道:“国君您没有仔细观察呀!看人,平时看他所亲近的,富贵时看他所交往的,显赫时看他所推荐的,穷困时看他所不做的,贫贱时看他所不取的。仅此五条,就足以去断定人,又何必要等我指明呢!”魏文侯说:“先生请回府吧,我的国相已经选定了。”李克离去,遇到翟璜。翟璜问:“听说今天国君召您去征求宰相人选,到底定了谁呢?”李克说:“魏成。”翟璜立刻忿忿不平地变了脸色,说:“西河守令吴起,是我推荐的。国君担心内地的邺县,我推荐西门豹。国君想征伐中山国,我推荐乐羊。中山国攻克之后,没有人去镇守,我推荐了先生您。国君的公子没有老师,我推荐了屈侯鲋。凭耳闻目睹的这些事实,我哪点儿比魏成差!”李克说:“你把我介绍给你的国君,难道是为了结党以谋求高官吗?国君问我宰相的人选,我说了刚才那一番话。我所以推断国君肯定会选中魏成为相,是因为魏成享有千钟的傣禄,十分之九都用在外面,只有十分之一留作家用,所以向东得到了卜子夏、田子方、段干木。这三个人,国君都奉他们为老师;而你所举荐的五人,国君都任用为臣属。你怎么能和魏成比呢!”翟璜听罢徘徊不敢进前,一再行礼说:“我翟璜,真是个粗人,失礼了,愿终身为您的弟子!”\n吳起者,衛‹府濮阳,河南濮阳›人,仕于魯‹府曲阜,山东曲阜›。齊人伐魯,魯人欲以為將,起取齊‹府临淄,山东淄博东临淄镇›女為妻,將,即亮翻;下同。取,讀曰娶。孔穎達曰:妻之為言齊也;以禮見問,得與夫敵體也。魯人疑之,起殺妻以求將,大破齊師。或譖之魯侯曰:「起始事曾參,世本曰:曾姓出自鄫國。陸德明曰:參,所金翻,一音七南翻。母死不奔喪,曾參絕之;今又殺妻以求為君將。起,殘忍薄行人也!行,下孟翻。且以魯國區區而有勝敵之名,則諸侯圖魯矣。」起恐得罪,聞魏文侯賢,乃往歸之。文侯問諸李克,李克曰:「起貪而好色;好,呼到翻。然用兵,司馬穰苴弗能過也。」司馬,官名。穰苴本齊田姓,仕齊為是官,故以稱之;齊景公之賢將也。穰,如羊翻。苴,子餘翻。於是文侯以為將,擊秦,拔五城。\n〖译文〗 吴起,卫国人,在鲁国任官。齐国攻打鲁国,鲁国想任用吴起为将,但吴起娶的妻子是齐国人,鲁国猜疑吴起。于是,吴起杀死了自己的妻子,求得大将,大破齐国军队。有人在鲁国国君面前攻击他说:“吴起当初曾师事曾参,母亲死了也不回去治丧,曾参与他断绝关系。现在他又杀死妻子来求得您的大将职位。吴起,真是一个残忍缺德的人!况且,以我们小小的鲁国能有战胜齐国的名气,各个国家都要来算计鲁国了。”吴起恐怕鲁国治他的罪,又听说魏文侯贤明,于是就前去投奔。魏文侯征求李克的意见,李克说:“吴起为人贪婪而好色,然而他的用兵之道,连齐国的名将司马穰苴也超不过他。”于是魏文侯崐任命吴起为大将,攻击秦国,攻占五座城。\n起之為將,與士卒最下者同衣食,臥不設席,行不騎乘,騎馬為騎,乘車為乘,言起與士卒同其勞苦,行不用車馬也。親裹贏糧,師古曰:贏,擔也。此言起親裹士卒所齎jī擔之糧。贏,怡成翻。與士卒分勞苦。卒有病疽者,起為吮之。疽,七餘翻,癰也。吮,徐兗翻;說文:嗽也,康所角切。卒母聞而哭之。人曰:「子,卒也,而將軍自吮其疽,何哭為?」母曰:「非然也。往年吳公吮其父疽,【章︰十二行本無「疽」字;乙十一行本同;孔本同。】其父戰不旋踵,遂死於敵。吳公今又吮其子,妾不知其死所矣,是以哭之。」\n〖译文〗 吴起做大将,与最下等的士兵同样穿衣吃饭,睡觉不铺席子,行军也不骑马,亲自挑上士兵的粮食,与士兵们分担疾苦。有个士兵患了毒疮,吴起为他吸吮毒汁。士兵的母亲听说后却痛哭。有人奇怪地问:“你的儿子是个士兵,而吴起将军亲自为他吸吮毒疮,你为什么哭?”士兵母亲答道:“不是这样啊!当年吴将军为孩子的父亲吸过毒疮,他父亲作战从不后退,就战死在敌阵中了。吴将军现在又为我儿子吸毒疮,我不知道他该死在哪里了,所以哭他。”\n4燕‹府蓟城,北京›湣公薨,子僖公立。燕自召公奭受封于北燕,其地則唐幽州薊縣故城是也。自召公至湣公三十二世。燕,因肩翻。湣,讀與閔同。諡法:使民悲傷曰閔;小心畏忌曰僖。\n〖译文〗 [4]燕国燕公去世,其子燕僖公即位。\n二十四年(己卯,前四零二) # 1王‹姬午›崩,子安王驕立。\n〖译文〗 [1]周威烈王驾崩,其子姬骄即位,是为周安王。\n2盜殺楚‹都郢都,湖北江陵›聲王‹芈当›,國人立其子悼王‹芈疑›。周成王封熊繹于楚,姓羋氏,居丹陽,今枝江縣故丹陽城是也。括地志曰:歸州秭歸縣丹陽城,熊繹之始國。其後強大,北封畛zhěn于汝,南并吳、越,地方五千里。自熊繹至聲王三十世。索隱曰:聲王,名當。悼王,名疑。諡法:不生其國曰聲。註云:生於外家。年中早夭曰悼。註云:年不稱志。又云:恐懼從處曰悼。註云:從處,言險圮pǐ也。\n〖译文〗 [2]盗匪杀死楚国楚声王,国中贵族拥立其子楚悼王即位。\n安王諡法,好和不爭曰安。 # 元年(庚辰,前四零一) # 1秦‹府雍县,陕西凤翔›伐魏‹府安邑,山西夏县›,至陽孤‹山西垣曲东南›。周孝王邑非子于秦。徐廣曰:今隴西縣秦亭是也。括地志曰:秦州清水縣本名秦。十三州志曰:秦亭,秦谷是也。至襄公取周地,穆公霸西戎,日以強大。是年,秦簡公之十四年也。自非子至簡公二十八世。「陽孤」,史記作「陽狐」。【章︰乙十一行本正作「狐」。】正義引括地志曰:陽狐郭在魏州元城縣東北三十里。余按此時西河之外皆為魏境,若秦兵至元城,則是越魏都安邑而東矣。水經註:河東垣縣有陽壺城。九域志:絳州有陽壺城。姑識之以廣異聞,且俟知者。\n〖译文〗 [1]秦国攻打魏国,直至阳孤。\n二年(辛巳,前四零零) # 1魏‹府安邑,山西夏县›、韓‹府平阳,山西临汾›、趙‹府晋阳,山西太原›伐楚‹都郢都,湖北江陵›,至桑丘。水經註:澺yì水自葛陂東南逕新蔡縣故城東,而東南流注于汝水;又東南逕下桑里,左迤為橫塘陂。史記作「乘丘」‹山东兖州西北›。正義:地理志,乘丘故城在兗州瑕丘縣西北三十五里。當從之。\n〖译文〗 [1]韩国、魏国、赵国联合攻打楚国,直至桑丘。\n2鄭‹府新郑,河南新郑›圍韓陽翟‹河南禹州›。周宣王封其弟友于鄭。杜預世族譜曰:封于咸林,今京兆鄭邑是也。幽王無道,友徙其人於虢、鄶kuài之間,遂有其地,今河南新鄭是也。友,諡桓公。是年,鄭繻公駘tái之二十三年。自桓公至繻公二十二世。班志,陽翟縣屬潁川郡。索隱曰:翟,音狄,溫公類篇音萇伯切。繻,詢趨翻。駘,堂來翻。\n〖译文〗 [2]郑国围攻韩国阳翟城。\n3韓景侯‹虔›薨,子烈侯取立。\n〖译文〗 [3]韩国韩景侯去世,其子韩取即位,是为韩烈侯。\n4趙烈侯‹籍›薨,國人立其弟武侯。\n〖译文〗 [4]赵国赵烈侯去世,国中贵族拥立其弟即位,是为赵武侯。\n5秦‹府雍县,陕西凤翔›簡公‹悼子›薨,子惠公立。諡法:愛民好與曰惠。\n〖译文〗 [5]秦国秦简公去世,其子即位,是为秦惠公。\n三年(壬午,前三九九) # 1王子定奔晉‹府新田,山西侯马›。\n〖译文〗 [1]周朝王子姬定出奔晋国。\n2虢山‹河南三门峡西›崩,壅河。徐廣曰:虢山在陝。裴駰曰:弘農陝縣,故虢國。北虢在大陽,東虢在滎陽。括地志曰:虢山在陜州陝縣,西臨黃河;今臨河有岡阜,似是頹山之餘。水經註曰:陜城西北帶河,水湧起方數十丈。父老云:石虎載銅翁仲至此沈沒,水所以湧。洪河巨瀆,宜不為金狄梗流,蓋魏文侯時虢山崩壅河所致耳。陝,失冉翻。\n〖译文〗 [2]虢山崩塌,泥石壅塞黄河。\n四年(癸未,前三九八) # 1楚‹都郢都,湖北江陵›圍鄭‹河南新郑›。鄭人殺其相駟子陽。鄭穆公之子騑,字子駟;古者以王父之字為氏,子陽其後也。相,息亮翻。騑,芳菲翻。\n〖译文〗 [1]楚国围攻郑国。郑国人杀死国相驷子阳。\n五年(甲申,前三九七) # 1日有食之。杜預曰:日行遲,一歲一周天。月行速,一月一周天;一歲凡十二交會。然日、月,動物,雖行度有大量,不能不小有贏縮,故有雖交會而不食者,或有頻交而食者。孔穎達曰:日月交會,謂朔也。周天三百六十五度四分度之一。日月皆右行於天,一晝一夜,日行一度,月行十三度十九分度之七,二十九日日有餘,而月行天一周,追及於日而與之會。交會而日月同道則食;月或在日道表,或在日道里,則不食矣。又曆家為交食之法,大率以一百七十有三日有奇為限。然月先在里,則依限而食者多;若月在表,則依限而食者少。杜預見其參差,乃云「雖行度有大量,不能不小有贏縮,故有雖交會而不食者,或有頻交而食者」,此得之矣。蘇氏曰:交當朔則日食,然亦有交而不食者。交而食,陽微而陰乘之也;交而不食,陽盛而陰不能揜yǎn也。朱元晦曰:此則系乎人事之感。蓋臣子背君父,妾婦乘其夫,小人陵君子,夷狄侵中國,所感如是,則陰盛陽微而日為之食矣。是以聖人于春秋,每食必書,而詩人亦以為醜也。今此書年而不書月與晦、朔,史失之也。釋名曰:日、月虧曰食;稍小侵虧,如蟲食草木之葉也。亦作「蝕」。\n〖译文〗 [1]出现日食。\n2三月,盜殺韓‹府平阳,山西临汾›相俠累。俠累與濮陽‹河南濮陽›嚴仲子有惡。仲子聞軹zhǐ‹河南濟源東南›人聶政之勇,以黃金百溢為政母壽,欲因以報仇。相,息亮翻。俠,戶頰翻。累,力追翻。濮陽,春秋之帝丘,漢為濮陽縣,屬東郡。應劭曰:濮水南入鉅野。水北為陽。濮,博木翻。惡,如字,不善也;康烏故切,非。軹,春秋原邑,晉文公所圍者;漢為軹縣,屬河內郡;音只。姓譜曰:楚大夫食采于聶,因以為氏。聶,尼輒翻。溢,夷質翻。二十四兩為溢。政不受,曰:「老母在,政身未敢以許人也!」及母卒,仲子乃使政刺俠累。卒,子恤翻。刺,七亦翻,又如字。俠累方坐府上,兵衛甚眾,聶政直入上階,上,時掌翻。刺殺俠累,因自皮面決【章︰乙十一行本作「抉」。】眼,自屠出腸。韓人暴其尸於市,暴,步木翻,又音如字,露也。購問,莫能識。其姊嫈聞而往,哭之曰:「是軹深井里‹河南济源东南十五公里›聶政也!史記正義曰:深井里在懷州濟源縣南三十里。以妾尚在之故,重自刑以絕從。妾奈何畏歿身之誅,終滅賢弟之名!」遂死於政尸之旁。皮面,以刀剺lí面而去其皮。懸賞以募告者曰購。購,古侯翻。嫈,烏莖翻。絕從之從,讀曰蹤,謂自絕其蹤跡。或曰:從,讀如字,謂絕其從坐之罪也。\n〖译文〗 [2]三月,盗匪杀死韩国国相侠累。侠累与濮阳人严仲子有仇,严仲子听说轵地人聂政很勇敢,便拿出一百镒黄金为聂政母亲祝寿,想让聂政为他报仇。聂政却不接受,说:“我的老母亲还健在,我不敢为别人去献身!”等到他的母亲去世,严仲子便派聂政去行刺侠累。侠累正端坐府中,有许多护卫兵丁,聂政一直冲上厅阶,把侠累刺死。然后划破自己的面皮,挖出双眼,割出肚肠而死。韩国人把聂政的尸体放在集市中暴尸。并悬赏查找,但无人知晓。聂政的姐姐聂听说此事前往,哭着说:“这是轵地深井里的聂政啊!他因为我还在,就自毁面容不使连累。我怎么能怕杀身之祸,最终埋没我弟弟的英名呢!”于是自尽死在聂政的尸体旁边。\n六年(乙酉,前三九六) # 1鄭‹府新郑,河南新郑›駟子陽之黨弑繻公‹贻›,繻者,諡法所不載。史記註:「繻」,或作「繚」。繻,詢趨翻。而立其弟乙,白虎通曰:弟,悌也,心順、行篤也。行,下孟翻。是為康公。\n〖译文〗 [1]郑国宰相驷子阳的余党杀死国君郑公,改立他的弟弟姬乙,是为郑康公。\n2宋‹府睢阳,河南商丘›悼公薨,子休公田立。武王封微子啟于宋,唐宋州之睢陽縣是也。自微子二十七世至悼公,名購由。休,亦諡法所不載。\n〖译文〗 [2]宋国宋悼公去世,其子宋田即位,是为宋休公。\n八年(丁亥,前三九四) # 1齊‹府临淄,山东淄博东临淄镇›伐魯‹府曲阜,山东曲阜›,取最‹山東曲阜東南›。【章︰十二行本「最」下有「韓救魯」三字;乙十一行本同;孔本同;張校同;退齋校同。】武王封太公于齊,唐青州之臨淄是也。括地志曰:天齊水在臨淄東南十五里。封禪書曰:齊之所以為齊者,以天齊。是年,康公貸之十一年。自太公至康公二十九世。成王封伯禽于魯,唐兗州之曲阜是也。是年,穆公之十六年。自伯禽至穆公凡二十八世。\n〖译文〗 [1]齐国攻打鲁国,攻占最地。\n2鄭‹府新郑,河南新郑›負黍‹河南登封西南›叛,復歸韓‹府平阳,山西临汾›。據史記,繻公之十六年,敗韓於負黍,蓋以此時取之,而今復叛歸韓也。劉昭郡國志:潁川郡陽城縣有負黍聚。古今地名云;負黍山在陽城縣西南二十七里,或云在西南三十五里。\n〖译文〗 [2]郑国的负黍地方反叛,复归顺韩国。\n九年(戊子,前三九三年) # 1魏‹府安邑,山西夏县›伐鄭‹府新郑,河南新郑›。\n〖译文〗 [1]魏国攻打郑国。\n2晉‹府新田,山西侯马›烈公‹止›薨,子孝公傾立。周成王封弟叔虞于唐。括地志曰:故唐城在并州晉陽縣北二里,堯所築也。都城記曰:唐叔虞之子燮xiè父徙居晉水旁,今并州理故唐城,即燮父初徙之處;其城南半入州城中。毛詩譜曰:燮父以堯墟南有晉水,改曰晉侯。自唐叔至烈公三十七世。烈公,名止。諡法:慈惠愛親曰孝。\n〖译文〗 [2]晋国晋烈公去世,其子姬倾即位,是为晋孝公。\n十一年(庚寅,前三九一年) # 1秦‹府雍县,陕西凤翔›伐韓‹府平阳,山西临汾›宜陽‹河南宜陽›,取六邑。班志,宜陽縣屬弘農郡。史記正義曰:宜陽縣故城,在河南府福昌縣東十四里,故韓城是也。此邑即周禮「四井為邑」之邑。\n〖译文〗 [1]秦国攻打韩国宜阳地方,夺取六个村邑。\n2初,田常生襄子盤,盤生莊子白,白生太公和。此序齊田氏之世也。田常,即左傳陳成子恒也。溫公避仁廟諱,改「恒」曰「常」。自陳公子完奔齊,五世至常得政。諡法:勝敵志強曰莊。是歲,齊‹府临淄,山东淄博东临淄镇›田和遷齊康公於海上,使食一城,以奉其先祀。\n〖译文〗 [2]起初,齐国田常生襄子田盘,田盘生庄子田白,田白再生太公田和。这年,田和把国君齐康公流放到海边,让他保有一个城的赋税收入,以承继祖先祭祀。\n十二年(辛卯,前三九零年) # 1秦‹府雍县,陕西凤翔›、晉‹府新田,山西侯马›戰于武城‹陝西华县东›。此非魯之武城。左傳:晉陰飴甥會秦伯,盟于王城。杜預曰:馮翊臨晉縣東有王城,今名武鄉。括地志:故武城,一名武平城,在華州鄭縣東北十三里。\n〖译文〗 [1]秦国与晋国大战于武城。\n2齊伐魏,取襄陽‹襄陵,河南睢县›。「陽」,當作「陵」。徐廣曰:今之南平陽也。余據晉志,南平陽縣屬山陽郡。班志,陳留郡有襄邑縣。師古曰:圈稱云:襄邑,宋地,本承匡襄陵鄉也,宋襄公所葬,故曰襄陵。秦始皇以承匡卑濕,徙縣襄陵,因曰襄邑。\n〖译文〗 [2]齐国攻打魏国,夺取襄阳。\n3魯‹府曲阜,山东曲阜›敗齊師于平陸‹山東汶上縣西北›。班志,東平國有東平陸縣,戰國時之平陸也。史記正義曰:平陸,兗州縣,即古厥國。宋白曰:鄆yùn州中都縣,漢為平陸縣,史記「魯敗齊師于平陸」是也。敗,補邁翻。\n〖译文〗 [3]鲁国在平陆击败齐国军队。\n十三年(壬辰,前三八九年) # 1秦‹府雍县,陕西凤翔›侵晉‹府新田,山西侯马›。\n〖译文〗 [1]秦国入侵晋国。\n2齊‹府临淄,山东淄博东临淄镇›田和會魏文侯、楚人、衛‹府濮阳,河南濮阳›人於濁澤‹河南新郑西南›,康曰,濁,水名;漢志:濁水出齊郡廣縣媯山。余謂康說誤矣。徐廣史記註曰:長社有濁澤。水經註曰:皇陂水出胡城西北。胡城,潁陰之狐人亭也。皇陂,古長社之濁澤也。記:諸侯相見于郤xì地曰會。孔穎達曰:諸侯未及期而相見曰遇。會者,謂及期之禮,既及期,又至所期之地。求為諸侯。魏文侯為之請于王及諸侯,王許之。為之之為,於偽翻。\n〖译文〗 [2]齐国田和在浊泽约会魏文侯及楚国、卫国贵族,要求作诸侯。魏文侯替他向周安王及各国诸侯申请,周安王准许。\n十五年(甲午,前三八七年) # 1秦‹府雍县,陕西凤翔›伐蜀‹府成都,四川成都›,取南鄭‹陝西汉中›。譜記普疑衍云:蜀之先,肇自人皇之際。黃帝子昌意娶蜀山氏女,生帝俈kù。既立,封其支庶於蜀,歷虞、夏、商、周。周衰,先稱王者蠶叢。余據武王伐紂,庸、蜀諸國皆會於牧野。孔安國曰:蜀,叟也,春秋之時不與中國通。班志,南鄭縣屬漢中郡,唐為梁州治所。「俈」,通作「嚳」,音括沃翻。\n〖译文〗 [1]秦国攻打蜀地,夺取南郑。\n2魏‹府安邑,山西夏县›文侯‹斯›薨,太子擊立,王者以嫡長子為太子,謂之國儲副君。諸侯曰世子。周衰,率上僭。孔穎達曰:太者,大中之大也。上,時掌翻。長,知兩翻。是為武侯。\n〖译文〗 [2]魏国魏文侯去世,太子魏击即位,是为魏武侯。\n武侯浮西河而下,西河,即禹貢之「龍門西河」。中流顧謂吳起曰:「美哉山河之固,此魏國之寶也!」對曰:「在德不在險。昔三苗氏,左洞庭,右彭蠡‹鄱阳湖›,德義不修,禹滅之。武陵、長沙、零、桂之水,匯為洞庭,周七百里。彭蠡澤在漢豫章郡彭澤縣西。書:有苗弗率,汝徂cú征。三苗所居,蓋今江南西道之地。蠡,里弟翻。夏桀之居,左河、濟,右泰華‹陕西华阴南华山›,伊闕‹洛阳›在其南,羊腸‹山西平顺东南太行山中›在其北;修政不仁,湯放之。濟水出河東垣縣王屋山,南流貫河而南,合於滎瀆。禹貢所謂「導沇水,東流為濟,溢為滎」者也。自漢築滎陽石門,而濟與河合流而注於海,不入滎瀆。禹貢所謂「導沇水,東流為濟,入於河」。桀都安邑‹山西夏縣›,蓋恃以為險。泰華山在京兆華陰縣南。水經:伊水出南陽縣西荀渠山,東北流至河南新城縣,又東南過伊闕中,大禹所鑿也。兩山相對,望之若闕。左傳「女寬守闕塞」,即其地。括地志:伊闕山在洛州南十九里。班志,上党壺關縣有羊腸阪‹山西平顺东南太行山中›。此安邑四履所憑,山河之固也。書曰:成湯放桀于南巢。濟,子禮翻。華,戶化翻。商紂之國,左孟門‹山西吉县西›,右太行,常山‹恒山,河北曲阳北›在其北,大河經其南;修政不德,武王殺之。水經註:孟門在河東北屈縣西,即龍門上口也。淮南子曰:龍門未辟,呂梁未鑿,河出孟門之上,溢而逆流,無有丘陵,名曰洪水。太行山在河內野王縣西北。常山在常山郡上曲陽縣西北。河水自孟門南抵華陰,屈而東流;紂都朝歌,河經其南。北屈之孟門在朝歌西北,恐不可言「左」。索隱曰:孟門別一山,在朝歌東邊。此特左、右二字之差而誤耳。春秋說題辭:河之為言荷也;荷精分佈,懷陰引度也。釋名:河,下也,隨地下處而通流也。書曰:武王勝殷,殺紂。太行之行,戶剛翻。北屈,陸求忽翻,顏居勿翻。由此觀之,在德不在險。若君不修德,舟中之人皆敵國也!」武侯曰:「善。」\n〖译文〗 魏武侯顺黄河而下,在中游对吴起说:“稳固的山河真美啊!这是魏国的宝啊!”吴起回答说:“国宝在于德政而不在于地势险要。当初三苗氏部落,左面有洞庭湖,右面有彭蠡湖,但他们不修德义,被禹消灭了。夏朝君王桀的居住之地,左边是黄河、济水,右边是泰华山,伊阙山在其南面,羊肠阪在其北面,但因朝政不仁,也被商朝汤王驱逐了。商朝纣王的都城,左边是孟门,右边是太行山,常山在其北面,黄河经过其南面,因他施政不德,被周武王杀了。由此可见,国宝在于德政而不在于地势险要。如果君主您不修德政,恐怕就是这条船上的人,也要成为您的敌人。”魏武侯听罢说道:“对。”\n魏置相,相田文。相,息亮翻。此田文非齊之田文。吳起不悅,謂田文曰:「請與子論功可乎?」田文曰:「可。」起曰:「將三軍,使士卒樂死,敵國不敢謀,子孰與起?」文曰:「不如子。」將,即亮翻。樂,音洛。起曰:「治百官,親萬民,實府庫,子孰與起?」文曰:「不如子。」治,直之翻。起曰:「守西河,秦兵不敢東鄉,韓‹府平阳,山西临汾›、趙‹府晋阳,山西太原›賓從,子孰與起?」文曰:「不如子。」鄉,讀曰嚮。賓從,猶言賓服也。起曰:「此三者子皆出吾下,而位居吾上,何也?」文曰:「主少國疑,大臣未附,百姓不信,方是之時,屬之子乎,屬之我乎?」少,詩照翻。屬,子之欲翻。起默然良久曰:「屬之子矣!」\n〖译文〗 魏国设置国相,任命田文为相。吴起不高兴,对田文说:“我和你比较功劳如何?”田文说:“可以。”吴起便说:“统率三军,使士兵乐于战死,敌国不敢谋算,你比我吴起如何?”田文说:“我不如你。”吴起又问:“整治百官,亲善百姓,使仓库充实,你比我吴起如何?”田文说:“我不如你。”吴起再问:“镇守西河,使秦兵不敢向东侵犯,韩国、赵国依附听命,你比我吴起如何?”田文还是说:“我不如你。”吴起质问道:“这三条你都在我之下,而职位却在我之上,是什么道理?”田文说:“如今国君年幼,国多疑难,大臣们不能齐心归附,老百姓不能信服,在这个时候,是嘱托给你呢,还是嘱托给我呢?”吴起默默不语想了一会儿,说:“嘱托给你啊!”\n久之,魏相公叔尚【章︰十二行本「尚」下有「魏公」二字;乙十一行本同;孔本同;張校同;退齋校同。】主而害吳起。如淳曰:天子嫁女于諸侯,必使諸侯同姓者主之,故謂之公主。帝姊妹曰長公主,諸王女曰翁主。師古曰:如說得之。天子不親主婚,故謂之公主;諸王則自主婚,故其女曰翁主。翁者,父也,言父主其婚也;亦曰王主,言王自主其婚也。揚雄方言云:周、晉、秦、隴謂父曰翁。而臣瓚,王楙,或云「公者比於上爵」,或云「主者婦人尊稱」,皆失之。劉貢父曰:予謂公主之稱本出秦舊,男為公子,女為公主。古者大夫妻稱主,故以公配之。若謂同姓主之,故謂之公主,則周之事,秦不知用也。古之嫁女,禮當如周使大夫主之,何不謂之夫主乎?然則謂之王主者,猶言王子也;謂之公主者,緣公而生耳。毛晃曰:尚,崇也,高也,貴也,飾也,加也,尊也。娶公主謂之尚,言帝王之女尊而尚之,不敢言娶也。相,息亮翻。公叔之僕曰:「起易去也。起為人剛勁自喜。易,以豉翻。去,起呂翻。師古曰:喜,許吏翻。子先言於君曰:『吳起,賢人也,而君之國小,臣恐起之無留心也。君盍試延以女,起無留心,則必辭矣。』子因與起歸而使公主辱子,起見公主之賤子也,必辭,則子之計中矣。」中,竹仲翻。公叔從之,吳起果辭公主。魏武侯疑之而未信,起懼誅,遂奔楚。\n〖译文〗 过了很久,魏国国相公叔娶公主为妻而以吴起为忌。他的仆人献计说:“吴起容易去掉,吴起为人刚劲而沾沾自喜。您可以先对国君说:‘吴起是个杰出人才,但君主您的国家小,我担心他没有长留的心思。国君您何不试着要把女儿嫁给他,如果吴起没有久留之心,一定会辞谢的。’主人您再与吴起一起回去,让公主羞辱您,吴起看到公主如此轻视您,一定会辞谢国君的婚事,这样您的计谋就实现了。”公叔照此去做,吴起果然辞谢了与公主的婚事。魏武侯疑忌他,不敢信任,吴起害怕被诛杀,于是投奔了楚国。\n楚悼王素聞其賢,至則任之為相。起明灋審令,相,息亮翻。灋,古法字。捐不急之官,廢公族疏遠者,以撫養戰斗之士,要在強兵,破遊說之言從橫者。捐,餘專翻,棄也,除去也。漢書音義曰:以利合曰從,以威力相脅曰橫。或曰:南北曰從,從者,連南北為一,西鄉以擯秦。東西曰橫,橫者,離山東之交,使之西鄉以事秦。說,式芮翻。從,即容翻。「橫」,亦作「衡」,音同。於是南平百越‹东南沿海的浙江、福建、广东›,韋昭曰:越有百邑。北卻三晉,西伐秦,諸侯皆患楚之強;而楚之貴戚大臣多怨吳起者。\n〖译文〗 楚悼王平素听说吴起是个人才,到了便任命他为国相。吴起严明法纪号令,裁减一些不重要的闲官,废除了王族中远亲疏戚,用来安抚奖励征战之士,大力增强军队、破除合纵连横游说言论。于是楚国向南平定百越,向北抵挡住韩、魏、赵三国的扩张,向西征讨秦国,各诸侯国都害怕楚国的强大,而楚国的王亲贵戚、权臣显要中却有很多人怨恨吴起。\n3秦惠公薨,子出公立。出,非諡也;以其失國出死,故曰出公。\n〖译文〗 [3]秦国秦惠公去世,其子即位,是为秦出公。\n4趙武侯薨,國人復立烈侯之太子章,是為敬侯。諡法:夙夜警戒曰敬。\n〖译文〗 [4]赵国赵武侯去世,国中贵族又拥立赵烈侯的太子赵章即位,是为赵敬侯。\n5韓烈侯薨,子文侯立。\n〖译文〗 [5]韩国韩烈侯去世,其子即位,是为韩文侯。\n十六年(乙未,前三八六年) # 1初命齊‹府临淄,山东淄博东临淄镇›大夫田和為諸侯。田氏自此遂有齊國。田和是為太公。\n〖译文〗 [1]周王朝开始任命齐国大夫田和为诸侯国君。\n2趙‹府晋阳,山西太原›公子朝作亂,【章︰乙十一行本「亂」下有「出」字;孔本同;退齋校同;此處百衲本缺。】奔魏‹府安邑,山西夏县›;與魏襲邯鄲‹河北邯郸›,不克。邯,音寒。鄲,音丹。\n〖译文〗 [2]赵国公子赵朝作乱,出奔魏国,与魏国军队一起进袭赵国邯郸,未能攻克。\n十七年(丙申,前三八五年) # 1秦‹府雍县,陕西凤翔›庶長改逆獻公於河西而立之;殺出子及其母,沈之淵旁。後秦制爵,一級曰公士,二上造,三簪zān裊niǎo,四不更,五大夫,六官大夫,七公大夫,八公乘,九五大夫,十左庶長,十一右庶長,十二左更,十三中更,十四右更,十五少上造,十六大上造,十七駟車庶長,十八大庶長,十九關內侯,二十徹侯。師古曰:庶長,言眾列之長。註又詳見下卷顯王十年前。據史記:威烈王十一年秦靈公卒,子獻公師隰不得立,立靈公季父悼子,是為簡公。出子,簡公之孫也。今庶長改迎獻公而殺出子。正義曰:西者,秦州西縣,秦之舊地。時獻公在西縣,故迎立之。余謂此言河西,非西縣也。靈公之卒,獻公不得立,出居河西;河西者,黃河之西,蓋漢涼州之地。「裊」,當作「褭」,乃了翻。更,工衡翻。乘,繩證翻。長,知丈翻。\n〖译文〗 [1]秦国名叫改的庶长在河西迎接秦献公,立为国君;把秦出公和他的母亲杀死,沉在河里。\n2齊‹府临淄,山东淄博东临淄镇›伐魯‹府曲阜,山东曲阜›。\n〖译文〗 [2]齐国攻打鲁国。\n3韓‹府平阳,山西临汾›伐鄭‹府新郑,河南新郑›,取陽城‹河南登封告城镇›;漢陽城縣屬潁川郡;是為地中,成周於此以土圭測日景。伐宋‹府睢阳,河南商丘›,執宋公。\n〖译文〗 [3]韩国攻打郑国,夺取阳城。又攻打宋国,捉住宋国国君。\n4齊太公薨,子桓公午立。\n〖译文〗 [4]齐国太公田和去世,其子田午即位,是为齐桓公。\n十九年(戊戌,前三八三年) # 1魏‹府安邑,山西夏县›敗趙‹府晋阳,山西太原›師於兔台。史記趙世家曰:魏敗我兔台,築剛平。正義曰:兔台、剛平,并在河北。敗,補邁翻。\n〖译文〗 [1]魏国在兔台击败赵国军队。\n二十年(己亥,前三八二年) # 1日有食之,既。既,盡也\n〖译文〗 [1]出现日全食。\n二十一年(庚子,前三八一年) # 1楚‹都郢都,湖北江陵›悼王薨。貴戚大臣作亂,攻吳起;起走之王尸而伏之。之,往也,往赴王尸而伏其側。擊起之徒因射刺起,并中王尸。射,而亦翻。刺,七亦翻。中,竹仲翻。既葬,肅王即位,諡法:剛德克就曰肅;執心決斷曰肅。使令尹盡誅為亂者;令尹,楚相也。坐起夷宗者七十餘家。夷,殺也;夷宗者,殺其同宗也。\n〖译文〗 [1]楚悼王去世。贵族国戚和大臣作乱,攻打吴起,吴起逃到悼王尸体边,伏在上面。攻击吴起的暴徒用箭射吴起,并射中了悼王的尸体。办完葬事,楚肃王即位,命令楚相全数翦灭作乱者,因射吴起之事而被灭族的多达七十余家。\n二十二年(辛丑,前三八零年) # 1齊伐燕‹府蓟城,北京›,取桑丘‹河北徐水縣›。魏、韓、趙伐齊,至桑丘。此桑丘,非二年所書楚之桑丘。括地志曰:桑丘故城,俗名敬城,在易州遂城縣,蓋燕之南界也。\n〖译文〗 [1]齐国攻打燕国,夺取桑兵。魏、韩、赵三国攻打齐国,兵至桑丘。\n二十三年(壬寅,前三七九年) # 1趙‹府邯郸,河北邯郸›襲衛‹府濮阳,河南濮阳›,不克。成王封康叔于衛,居河、淇之間,故殷墟也。至懿公為狄所滅,東徙度河。文公徙居楚丘,遂國于濮陽。是年,慎公頹之三十五年。自康叔至慎公凡三十二世。\n〖译文〗 [1]赵国袭击卫国,未能攻克。\n2齊康公‹贷›薨,無子,田氏遂并齊而有之。姜氏至此滅矣。\n〖译文〗 [2]流放的齐康公去世,没有儿子。田氏家族于是把姜氏的齐国全部兼并了。\n是歲,齊桓公亦薨,子威王因齊立。諡法:強毅訅qiú正曰威,訅,渠留翻。齊桓公,田午。訅,謀也。\n〖译文〗 当年,齐桓公也去世,其子田因齐即位,是为齐威王。\n二十四年(癸卯,前三七八年) # 1狄‹山西北部›敗魏‹府安邑,山西夏县›師於澮kuài‹山西翼城南浍河›。漢之中山、上黨、西河、上郡,自春秋以來,狄皆居之,此亦其種也。水經:澮水出河東絳縣東澮山,西過絳縣南,又西南過虒sī祁宮南,又西南至王橋,入汾水。括地志:澮山在絳州翼城縣東北。敗,補邁翻。澮,古外翻。\n〖译文〗 [1]北方狄族在浍山击败魏国军队。\n2魏、韓‹府平阳,山西临汾›、趙‹府邯郸,河北邯郸›伐齊‹府临淄,山东淄博东临淄镇›,至靈丘‹山東茌平›。史記正義曰:靈丘,河東蔚州縣。余按蔚州之靈丘,即漢代郡之靈丘,此時齊境安能至代北邪!此即孟子謂蚳chí鼃wā辭靈丘請士師之地。班志曰:齊地北有千乘、清河以南。漢清河郡有靈縣,清河北接趙、魏之境,此為近之。蚳,音遲。鼃,烏花翻。\n〖译文〗 [2]魏、韩、赵三国攻打齐国,兵至灵丘。\n3晉‹府新田,山西侯马›孝公薨,子靖公俱酒立。諡法:柔眾安民曰靖;又,恭己鮮言曰靖。\n〖译文〗 [3]晋国晋孝公去世,其子姬俱酒即位,是为晋靖公。\n二十五年(甲辰,前三七七年) # 1蜀‹府成都,四川成都›伐楚‹都郢都,湖北江陵›,取茲方‹四川奉節›。據史記:蜀伐楚,取茲方,楚為扞關以拒之。則茲方之地在扞關之西。劉昭志:巴郡魚復縣有扞關。\n〖译文〗 [1]蜀人攻打楚国,夺取兹方。\n2子思言苟變于衛侯曰:「其才可將五百乘。」古者兵車一乘,甲士三人,步卒七十二人;五百乘,三萬七千五百人。國語曰:苟本自黃帝之子。將,即亮翻;下同。乘,繩證翻。公曰:「吾知其可將;然變也嘗為吏,賦於民而食人二雞子,故弗用也。」子思曰:「夫聖人之官人,猶匠之用木也,夫,音扶。取其所長,棄其所短;故杞梓連抱而有數尺之朽,良工不棄。今君處戰國之世,處,昌呂翻。選爪牙之士,而以二卵棄干城之將,詩:赳赳武夫,公侯干城。毛氏傳曰:干,捍也;音戶旦翻。鄭氏箋曰:干也,城也,皆所以禦難也。干,讀如字。此不可使聞於鄰國也。」公再拜曰:「謹受教矣!」\n〖译文〗 [2]孔,字子思,向卫国国君提起苟变说:“他的才能可统领五百辆车。”卫侯说:“我知道他是个将才,然而苟变做官吏的时候,有次征税吃了老百姓两个鸡蛋,所以我不用他。”孔说:“圣人选人任官,就好比木匠使用木料,取其所长,弃其所短;因此一根合抱的良木,只有几尺朽烂处,高明的工匠是不会扔掉它的。现在国君您处在战国纷争之世,正要收罗锋爪利牙的人才,却因为两个鸡蛋而舍弃了一员可守一城的大将,这事可不能让邻国知道啊!”卫侯一再拜谢说:“我接受你的指教。”\n衛侯言計非是,而群臣和者如出一口。和,戶臥翻。子思曰:「以吾觀衛,所謂『君不君,臣不臣』者也!」「君不君,臣不臣,」論語載齊景公之言。公丘懿子曰:「何乃若是?」公丘,複姓。諡法:溫柔賢善曰懿。子思曰:「人主自臧,則眾謀不進。臧,善也。事是而臧之,猶卻眾謀,況和非以長惡乎!和,戶臥翻。長,知丈翻。夫不察事之是非而悅人贊己,暗莫甚焉;不度理之所在而阿諛求容,諂莫甚焉。度,徒洛翻。君暗臣諂,以居百姓之上,民不與也。若此不已,國無類矣!」\n〖译文〗 卫侯提出了一项不正确的计划,而大臣们却附和如出一口。孔说:“我看卫国,真是‘君不像君,臣不像臣’呀!”公丘懿子问道:“为什么竟会这样?”孔说:“君主自以为是,大家便不提出自己的意见。即使事情处理对了没有听取众议,也是排斥了众人的意见,更何况现在众人都附和错误见解而助长邪恶之风呢!不考察事情的是非而乐于让别人赞扬,是无比的昏暗;不判断事情是否有道理而一味阿谀奉承,是无比的谄媚。君主昏暗而臣下谄媚,这样居于百姓之上,老百姓是不会同意的。长期这样不改,国家就不象国家了。”\n子思言于衛侯曰:「君之國事將日非矣!」公曰:「何故?」對曰:「有由然焉。君出言自以為是,而卿大夫莫敢矯其非;卿大夫出言亦自以為是,而士庶人莫敢矯其非。君臣既自賢矣,白虎通曰:君,群也,群下之所歸心也。臣,堅也,厲志自堅也。而群下同聲賢之,賢之則順而有福,矯之則逆而有禍,如此則善安從生!詩曰:『具曰予聖,誰知烏之雌雄?』詩正月之辭。毛氏傳曰:君臣俱自謂聖也。鄭氏箋曰:時君臣賢愚適同,如烏之雌雄相似,誰能別異之乎?又曰:烏鳥之雌雄不可別者,以翼知之,右掩左,雄,左掩右,雌,陰陽相下之義也。抑亦似君之君臣乎!」\n〖译文〗 孔对卫侯说:“你的国家将要一天不如一天了。”卫侯问:“为什么?”回答说:“事出有因。国君你说话自以为是,卿大夫等官员没有人敢改正你的错误;于是他们也说话自以为是,士人百姓也不敢改正其误。君臣都自以为贤能,下属又同声称贤,称赞贤能则和顺而有福,指出错误则忤逆而有祸,这样,怎么会有好的结果!《诗经》说:‘都称道自己是圣贤,乌鸦雌雄谁能辨?’不也像你们这些君臣吗?”\n3魯‹府曲阜,山东曲阜›穆公薨,子共公奮立。諡法:布德就義曰穆;中情見貌曰穆;尊賢敬讓曰共;既過能改曰共;執事堅固曰共。共,讀曰恭。考異曰:司馬遷史記六國表:周威烈王十九年甲戌,魯穆公元年。烈王元年丙午,共公元年。顯王十七年己巳,康公元年。二十六年戊寅,景公元年。赧王元年丁未,平公元年。二十年丙寅,文公元年。四十三年己丑,頃公元年。五十九年乙巳,周亡。秦莊襄王元年壬子,楚滅魯。按魯世家,穆公三十三年卒,若元甲戌,終乙巳,則是三十二年也。共公二十二年卒,若元丙午,終戊辰,則是二十三年也。康公九年卒,景公二十五年卒,平公二十二年卒,若元丁未,終乙丑,則是十九年也。文公二十三年卒,頃公二十四年楚滅魯。班固漢書律曆志「文公」作「緡公」;其在位之年與世家異者,惟平公二十年耳。本志自魯僖公五年正月辛亥朔旦冬至推之,至成公十二年正月庚寅朔旦冬至,定公七年正月己巳朔旦冬至,元公四年正月戊申朔旦冬至,康公四年正月丁亥朔旦冬至,緡公二十二年正月丙寅朔旦冬至,漢高祖八年十一月乙巳朔旦冬至,武帝元朔六年十一月甲申朔旦冬至,元帝初元二年十一月癸亥朔旦冬至,其間相距皆七十六年,此最為得實,又與魯世家註、皇甫謐所紀歲次皆合,今從之。六國表差謬,難可盡據也。余按考異「自魯僖公五年至漢元帝初元二年六百餘年間,十二月朔旦冬至,相距皆七十六年,此最為得實,又與魯世家註、皇甫謐所紀歲次皆合」,蓋謂劉彝叟長曆也。且言「史記六國表差謬,難可盡據」。又按通鑑目錄編年用劉彝叟長曆。漢武帝太初元年,初用夏正定曆,史記曆書是年書閼逢攝提格,目錄書強圉赤奮若。閼逢攝提格,甲寅也,強圉赤奮若,丁丑也,有二十四年之差,溫公用彝叟曆,邵康節皇極經世書亦用彝叟曆。康節少自雄其才,既學,力慕高遠,一見李之才,遂從而受學,廬於共城百源,冬不爐,夏不扇,夜不就席者數年,覃思於易經也。皇極經世書不能違彝叟曆。及其來居於洛,而溫公亦奉祠以書局在洛,相過從稔,又夙所敬者也。余意其講明之間必嘗及此,而決於用彝叟曆。讀考異此一段,辭意可見。\n〖译文〗 [3]鲁国鲁穆公去世,其子姬奋即位,是为鲁共公。\n4韓文侯薨,子哀侯立。\n〖译文〗 [4]韩国韩文侯去世,其子即位,是为韩哀侯。\n二十六年(乙巳,前三七六年) # 1‹周,都洛阳,河南洛阳东白马寺东›王‹姬骄›崩,子烈王喜立。\n〖译文〗 [1]周安王去世,其子姬喜即位,是为周烈王。\n2魏‹府安邑,山西夏县›、韓‹府平阳,山西临汾›、趙‹府邯郸,河北邯郸›共廢晉靖公為家人而分其地‹新田,山西侯马›。唐叔不祀矣。\n〖译文〗 [2]魏、韩、赵三国一同把晋靖公废黜为平民,瓜分了他的残余领地。\n烈王名喜,安王之子。 # 元年(丙午,前三七五年) # 1日有食之。\n〖译文〗 [1]出现日食。\n2韓滅鄭,因徙都之。韓本都平陽‹山西臨汾›,其地屬漢之河東郡;中間徙都陽翟‹河南禹州›。鄭都新鄭,其地屬漢之河南郡。鄭桓公始封于鄭,其地屬漢之京兆;後滅虢、鄶而國於溱、洧之間,故曰新鄭,左傳鄭莊公所謂「吾先君新邑於此」是也。今韓既滅鄭,自陽翟徙都之。韓既都鄭,故時人亦謂韓王為鄭王,考之戰國策、韓非子可見。\n〖译文〗 [2]韩国灭掉郑国,于是把国都迁到新郑。\n3趙敬侯薨,子成侯種立。種,章勇翻。\n〖译文〗 [3]赵国赵敬侯去世,其子赵种即位,是为赵成侯。\n三年(戊申,前三七三年) # 1燕‹府蓟城,北京›敗齊師于林狐。敗,補邁翻。\n〖译文〗 [1]燕国在林狐击败齐国军队。\n魯伐齊,入陽關‹山東泰安东南›。徐廣曰:陽關在鉅平。班志,鉅平縣屬泰山郡。括地志:陽關故城在兗州博城縣南二十九里,其城之西臨汶水。汶,音問。\n〖译文〗 鲁国攻打齐国,进入阳关。\n魏伐齊,至博陵‹山東茌平西北›。史記正義曰:博陵在濟州西界。宋白曰:史記,齊威王伐晉至博陵。徐廣曰:東郡之博平,漢為縣。\n〖译文〗 魏国攻打齐国,抵达博陵。\n2燕僖公薨,子桓公立。\n〖译文〗 [2]燕国燕僖公去世,其子即位,是为燕桓公。\n3宋‹府睢阳,河南商丘›休公薨,子辟公立。辟亦諡法之所不載。\n〖译文〗 [3]宋国宋休公去世,其子即位,是为宋辟公。\n4衛‹府濮阳,河南濮阳›慎公薨,子聲公訓立。諡法:敏以敬曰慎。戴記:思慮深遠曰慎。\n〖译文〗 [4]卫国卫慎公去世,其子卫训即位,是为卫声公。\n四年(己酉,前三七二年) # 1趙‹府邯郸,河北邯郸›伐衛,取都鄙七十三。周禮:太宰以八則治都鄙。註云:都之所居曰鄙。都鄙,卿大夫之采邑。蓋周之制,四縣為都,方四十里,一千六百井,積一萬四千四百夫;五酇zàn為鄙,鄙五百家也。此時衛國褊小,若都鄙七十三,以成周之制率之,其地廣矣,盡衛之提封,未必能及此數也。更俟博考。\n〖译文〗 [1]赵国攻打卫国,夺取七十三个村镇。\n2魏‹府安邑,山西夏县›敗趙師于北藺‹山西離石›。班志,西河郡有藺縣。史記正義曰:在石州。其地于趙為西北,故曰北藺。藺,離進翻。\n〖译文〗 [2]魏国在北蔺击败赵国军队。\n五年(庚戌,前三七一年) # 1魏伐楚,取魯陽‹河南魯山縣›。左傳所謂「劉累遷于魯縣」,即魯陽也。班志,魯陽縣屬南陽郡。史記正義曰:今汝州魯山縣。\n〖译文〗 [1]魏国攻打楚国,夺取鲁阳。\n2韓‹府新郑,河南新郑›嚴遂弑哀侯,國人立其子懿侯。初,哀侯以韓廆wěi為相而愛嚴遂,二人甚相害也。嚴遂令人刺韓廆於朝,廆走哀侯,哀侯抱之;人刺韓廆,兼及哀侯。戰國策以聶政刺韓相事及并中哀侯為一事;此從史記。蜀本註曰:按太史公年表及韓世家,于韓烈侯三年皆書「聶政殺韓相俠累」,于哀侯六年又皆書「嚴遂弑哀侯」。以刺客傳考之,聶政殺俠累事在哀侯時;以戰國策考之亦然。從傳與戰國策,則是年表,世家于烈侯三年書「盜殺俠累」誤矣。通鑑於烈侯三年載聶政殺俠累事,又于哀侯六年載嚴遂殺其君哀侯,是從年表、世家所書。蓋刺客傳初不言并殺哀侯,止戰國策言之,通鑑豈以此疑之歟!故載并刺哀侯,不書聶政,止曰「使人」。以此求之,則通鑑之意不以嚴仲子為嚴遂,亦不以俠累為韓廆,止從年表、世家而不信其傳也。余按溫公與劉道原書,亦疑此事。廆,戶賄翻。相,息亮翻。刺,七亦翻。朝,直遙翻。走,音奏。\n〖译文〗 [2]韩国严遂杀死韩哀侯,国中贵族立哀侯之子,是为韩懿侯。当初,韩哀侯曾任命韩为国相却宠爱严遂,两人互相仇恨至深。严遂派人在朝廷行刺韩,韩逃到韩哀侯身边,韩哀侯抱住他,刺客刺韩,连带韩哀侯也被刺死。\n3魏武侯薨,不立太子,子罃與公中緩爭立,國內亂。罃,於耕翻。中,讀曰仲。\n〖译文〗 [3]魏国魏武侯去世,没有立太子,他的儿子魏与公中缓争位,国内大乱。\n六年(辛亥,前三七零年) # 1齊‹府临淄,山东淄博东临淄镇›威王來朝。是時周室微弱,諸侯莫朝,而齊獨朝之,天下以此益賢威王。朝,直遙翻。\n〖译文〗 [1]齐威王朝拜周烈王。当时周王室已十分衰弱,各诸侯国都不来朝拜,唯独齐王仍来朝拜,因此天下人愈发称赞齐威王贤德。\n2趙伐齊,至鄄‹山东鄄城›。班志,濟陰郡有鄄城縣。鄄,工掾翻。\n〖译文〗 [2]赵国攻打齐国,直至鄄地。\n3魏敗趙師于懷‹河南武陟西南›。班志,河內郡有懷縣。魏收地形志,懷州武德郡有懷縣,縣管內有懷城。敗,補邁翻。\n〖译文〗 [3]魏国在怀地击败赵国军队。\n4齊威王召即墨‹山東平度东南›大夫,語之曰:「自子之居即墨也,毀言日至。班志,即墨縣屬膠東國。括地志:即墨故城,在萊州膠水縣南六十里。宋白曰:城臨墨水,故曰即墨。語,牛倨翻,下同。然吾使人視即墨,田野辟,辟,讀曰闢;下同。人民給,官無事,東方以寧;是子不事吾左右以求助也!」封之萬家。召阿‹山東东阿›大夫,語之曰:「自子守阿,譽言日至。阿,即東阿縣;班志屬東郡。譽,音余,稱其美也。吾使人視阿,田野不辟,人民貧餒。昔日趙攻鄄,子不救;鄄,工掾翻。衛取薛陵‹山東陽谷東北›,子不知;薛陵,春秋薛國之墟也。班志,薛縣屬魯國,而衛國在漢東郡陳留界。薛陵屬齊而近于衛,故為所取。齊後封田嬰於此。是子厚幣事吾左右以求譽也!」是日,烹阿大夫及左右嘗譽者。於是群臣聳懼,莫敢飾詐,務盡其情,齊國大治,強於天下。譽,音余。治,直吏翻。\n〖译文〗 [4]齐威王召见即墨大夫,对他说:“自从你到即墨任官,每天都有指责你的话传来。然而我派人去即墨察看,却是田土开辟整治,百姓丰足,官府无事,东方因而十分安定。于是我知道这是你不巴结我的左右内臣谋求内援的缘故。”便封赐即墨大夫享用一万户的俸禄。齐威王又召见阿地大夫,对他说:“自从你到阿地镇守,每天都有称赞你的好话传来。但我派人前去察看阿地,只见田地荒芜,百姓贫困饥饿。当初赵国攻打鄄地,你不救;卫国夺取薛陵,你不知道;于是我知道你用重金来买通我的左右近臣以求替你说好话!”当天,齐威王下令烹死阿地大夫及替他说好话的左右近臣。于是臣僚们毛骨耸然,不敢再弄虚假,都尽力做实事,齐国因此大治,成为天下最强盛的国家。\n5楚肅王薨,無子,立其弟良夫,是為宣王。\n〖译文〗 [5]楚国楚肃王去世,他没有儿子,弟弟良夫即位,是为楚宣王。\n6宋辟公薨,子剔成立。剔,他曆翻。\n〖译文〗 [6]宋国宋辟公去世,其子宋剔成即位。\n七年(壬子,前三六九年) # 1日有食之。\n〖译文〗 [1]出现日食。\n2王崩,弟扁立,據班書古今人表師古註:扁,音篇。是為顯王。\n〖译文〗 [2]周烈王去世,弟弟姬扁即位,是为周显王。\n3魏大夫王錯出奔韓。姓譜:王氏之所自出非一。出太原、琅邪者,周靈王太子晉之後。北海、陳留,齊王田和之後。東海出自姬姓。高平、京兆,魏信陵君之後。天水、東平、新蔡、新野、山陽、中山、章武、東萊、河東者,殷王子比干為紂所害,子孫以王者之後,號曰王氏。余謂此皆後世以諸郡著姓言之耳。春秋之時自有王姓,莫能審其所自出。公孫頎qí謂韓懿侯曰:「魏亂,可取也。」公孫,姓也。黃帝,公孫氏。頎,渠希翻。懿侯乃與趙成侯合兵伐魏,戰於濁澤‹山西永济西›,大破之,遂圍魏。史記正義曰:徐廣以為長社濁澤,非也。括地志云:濁水源出蒲州解縣東北平地,爾時魏都安邑,韓、趙伐魏,豈至河南長社邪!解縣濁水近於魏都,當是也。成侯曰:「殺罃,立公中緩,割地而退,我二國之利也。」懿侯曰:「不可。殺魏君,暴也;割地而退,貪也。不如兩分之。魏分為兩,不強于宋、衛,則我終無魏患矣。」趙人不聽。懿侯不悅,以其兵夜去。趙成侯亦去。罃遂殺公中緩而立,中,讀曰仲。是為惠王。\n〖译文〗 [3]魏国大夫王错逃奔韩国。公孙颀对韩懿侯说:“魏国内乱,可以乘机攻取。”韩懿侯于是与赵成侯联合出兵攻打魏国,在浊泽地方交战,大败魏军,包围了魏国都城。赵成侯说:“杀掉魏,立公中缓为魏国国君,然后割地退兵,这对我们两国是有利的作法。”韩懿侯说:“不妥。杀死魏国国君,是强暴;割地后才退兵,是贪婪。不如让两人分别治理魏国,魏国分为两半,比宋国、卫国还不如,我们就再也不用担心魏国的威胁了。”赵成侯不同意。韩懿侯不高兴,率领他的军队乘夜离去。赵成侯也只好退兵归国。魏于是杀死公中缓即位,是为魏惠王。\n太史公曰:魏惠王所以身不死、國不分者,二國之謀不和也。若從一家之謀,魏必分矣。故曰:「君終,無適子,其國可破也。」索隱曰:蓋古人之言及俗說,故云「故曰」。適,讀曰嫡。\n〖译文〗 太史公司马迁曰:魏惠王之所以能自身不死,国家不被瓜分,是由于韩、赵两国意见不和。如果按照其中一家的办法去做,魏国一定会被瓜分。所以说:“国君死时,无继承人,国家就会被击破。”\n"},{"id":190,"href":"/zh/docs/technology/Linux/SHELLlearnLinuxTV_/16-18/","title":"16-18","section":"SHELL编程(learnLinuxTV)_","content":" 向bash脚本添加参数 # basic # ─ ~/shellTest ly@vmmin 10:37:24 ╰─❯ cat ./16myscript_cls.sh #!/bin/bash echo \u0026#34;You entered the argument: $1,$2,$3, and $4.\u0026#34; 结果\n╭─ ~/shellTest 16s ly@vmmin 10:37:18 ╰─❯ ./16myscript_cls.sh Linux1 Linux2 You entered the argument: Linux1,Linux2,, and . 示例1 # ╭─ ~/shellTest ly@vmmin 10:41:45 ╰─❯ cat ./16myscript_cls.sh #!/bin/bash ls -lh $1 #echo \u0026#34;You entered the argument: $1,$2,$3, and $4.\u0026#34; ╭─ ~/shellTest ly@vmmin 10:41:28 ╰─❯ ./16myscript_cls.sh /etc total 792K -rw-r--r-- 1 root root 3.0K May 25 2023 adduser.conf -rw-r--r-- 1 root root 44 Dec 17 15:26 adjtime -rw-r--r-- 1 root root 194 Dec 23 22:38 aliases drwxr-xr-x 2 root root 4.0K Dec 23 22:38 alternatives drwxr-xr-x 2 root root 4.0K Dec 17 15:24 apparmor drwxr-xr-x 8 root root 4.0K Dec 17 15:25 apparmor.d drwxr-xr-x 9 root root 4.0K Dec 17 15:30 apt -rw-r----- 1 root daemon 144 Oct 16 2022 at.deny -rw-r--r-- 1 root root 2.0K Mar 30 2024 bash.bashrc 示例2 # #!/bin/bash lines=$(ls -lh $1 | wc -l) #行计数 echo \u0026#34;You hava $(($lines-1)) objects in the $1 directory.\u0026#34; #$(($lines-1))这里用到了子shell #echo \u0026#34;You entered the argument: $1,$2,$3, and $4.\u0026#34; ╭─ ~/shellTest ly@vmmin 10:48:06 ╰─❯ ls -lh logfiles total 12K -rw-r--r-- 1 ly ly 0 Dec 22 23:07 a.log -rw-r--r-- 1 ly ly 120 Dec 22 23:17 a.log.tar.gz -rw-r--r-- 1 ly ly 0 Dec 22 23:07 b.log -rw-r--r-- 1 ly ly 121 Dec 22 23:17 b.log.tar.gz -rw-r--r-- 1 ly ly 0 Dec 22 23:07 c.log -rw-r--r-- 1 ly ly 121 Dec 22 23:17 c.log.tar.gz -rw-r--r-- 1 ly ly 0 Dec 22 23:15 xx.txt -rw-r--r-- 1 ly ly 0 Dec 22 23:15 y.txt ╭─ ~/shellTest ly@vmmin 10:48:10 ╰─❯ ./16myscript_cls.sh logfiles You hava 8 objects in the logfiles directory. head,表示前十行,可以看出total这些被算作一行了,所以上面的shell中-1\n─ ~/shellTest ly@vmmin 10:57:19 ╰─❯ ls -l /etc | head total 792 -rw-r--r-- 1 root root 3040 May 25 2023 adduser.conf -rw-r--r-- 1 root root 44 Dec 17 15:26 adjtime -rw-r--r-- 1 root root 194 Dec 23 22:38 aliases drwxr-xr-x 2 root root 4096 Dec 23 22:38 alternatives drwxr-xr-x 2 root root 4096 Dec 17 15:24 apparmor drwxr-xr-x 8 root root 4096 Dec 17 15:25 apparmor.d drwxr-xr-x 9 root root 4096 Dec 17 15:30 apt -rw-r----- 1 root daemon 144 Oct 16 2022 at.deny -rw-r--r-- 1 root root 1994 Mar 30 2024 bash.bashrc 不输入参数的情形 # 可以看出,其实就是应用到当前文件夹了\n╭─ ~/shellTest ly@vmmin 11:02:35 ╰─❯ ./16myscript_cls.sh logfiles You hava 8 objects in the logfiles directory. ╭─ ~/shellTest ly@vmmin 11:02:39 ╰─❯ ./16myscript_cls.sh You hava 29 objects in the directory. ╭─ ~/shellTest ly@vmmin 11:02:44 ╰─❯ ls -l | wc -l 30 参数判断 # #!/bin/bash # $#表示用户传到脚本中的参数数量 if [ $# -ne 1 ] #[]左右两边都一定要有空格 then echo \u0026#34;This script requires xxxxone directory path passed to it.\u0026#34; echo \u0026#34;Please try again.\u0026#34; exit 1 fi lines=$(ls -lh $1 | wc -l) echo \u0026#34;You hava $(($lines-1)) objects in the $1 directory.\u0026#34; #echo \u0026#34;You entered the argument: $1,$2,$3, and $4.\u0026#34; 执行\n╭─ ~/shellTest 4m 3s ly@vmmin 11:09:41 ╰─❯ ./16myscript_cls.sh This script requires xxxxone directory path passed to it. Please try again. ╭─ ~/shellTest ly@vmmin 11:09:45 ╰─❯ ./16myscript_cls.sh logfiles You hava 8 objects in the logfiles directory. ╭─ ~/shellTest ly@vmmin 11:09:55 ╰─❯ ./16myscript_cls.sh logfiles x b This script requires xxxxone directory path passed to it. Please try again. 创建备份脚本 # ╭─ ~/shellTest ly@vmmin 12:41:11 ╰─❯ cat ./17myscript_cls.sh #!/bin/bash #如果参数个数不是2则退出,并指定exitCode为1 if [ $# -ne 2 ] then echo \u0026#34;Usage: backup.sh \u0026lt;source_directory\u0026gt; \u0026lt;target_directory\u0026gt;\u0026#34; echo \u0026#34;Please try again.\u0026#34; exit 1 fi # check rsync installed #发送标准错误和标准输出到/dev/null #command -v rsync \u0026gt; /dev/null 2\u0026gt;\u0026amp;1 这条命令,若rsync存在则返回零(真),否则返回非零(假) if ! command -v rsync \u0026gt; /dev/null 2\u0026gt;\u0026amp;1 then echo \u0026#34;This script requires rsync to be installed.\u0026#34; echo \u0026#34;Please use your distribution\u0026#39;s package manager to install it and try again.\u0026#34; #指定exitCode exit 2 fi #格式化date输出,即YYYY-MM-DD current_date=$(date +%Y-%m-%d) # -a 保留所有元数据,权限等 # -v 详细显示输出 #-b、--backup参数指定在删除或更新目标目录已经存在的文件时,将该文件更名后进行备份,默认行为是删除。更名规则是添加由--suffix参数指定的文件后缀名,默认是~。 #--backup-dir参数指定文件备份时存放的目录,比如--backup-dir=/path/to/backups # --delete 确保目标目录是源目录的克隆(完全克隆,不多不少) # --dry-run 尝试执行操作 rsync_options=\u0026#34;-avb --backup-dir $2/$current_date --delete --dry-run\u0026#34; #rsync_options=\u0026#34;-avb --backup-dir $2/$current_date --delete \u0026#34; # $1是源目录 $(which rsync) $rsync_options $1 $2/current \u0026gt;\u0026gt; backup_$current_date.log 运行脚本 # 会提示rsync还没有安装,需要安装\n╭─ ~/shellTest ly@vmmin 14:41:04 ╰─❯ nano 17myscript_cls.sh ╭─ ~/shellTest 27s ly@vmmin 14:41:35 ╰─❯ ./17myscript_cls.sh logfiles backup ╭─ ~/shellTest ly@vmmin 14:41:45 ╰─❯ ./17myscript_cls.sh logfiles/ backup ╭─ ~/shellTest ly@vmmin 14:41:49 ╰─❯ cat backup_2024-12-24.log sending incremental file list created directory backup/current logfiles/ logfiles/a.log logfiles/a.log.tar.gz logfiles/b.log logfiles/b.log.tar.gz logfiles/c.log logfiles/c.log.tar.gz logfiles/xx.txt logfiles/y.txt sent 279 bytes received 81 bytes 720.00 bytes/sec total size is 362 speedup is 1.01 (DRY RUN) sending incremental file list created directory backup/current ./ a.log a.log.tar.gz b.log b.log.tar.gz c.log c.log.tar.gz xx.txt y.txt sent 254 bytes received 80 bytes 668.00 bytes/sec total size is 362 speedup is 1.08 (DRY RUN) backup是空白,因为这只是试运行\n./17myscript_cls.sh logfiles backup和./17myscript_cls.sh logfiles/ backup的区别,后者备份文件夹logfiles下所有文件,而前者备份logfiles(包括文件夹自身)整个文件夹\n把 --dry-run去掉后运行 # 文件查看\n╭─ ~/shellTest ly@vmmin 14:42:53 ╰─❯ ls backup ╭─ ~/shellTest ly@vmmin 14:43:03 ╰─❯ nano 17myscript_cls.sh #第一次备份 ╭─ ~/shellTest 8s ly@vmmin 14:43:17 ╰─❯ ./17myscript_cls.sh logfiles/ backup ╭─ ~/shellTest ly@vmmin 14:43:28 ╰─❯ ls backup current ╭─ ~/shellTest ly@vmmin 14:43:42 ╰─❯ ls backup/current a.log a.log.tar.gz b.log b.log.tar.gz c.log c.log.tar.gz xx.txt y.txt 日志查看\n╭─ ~/shellTest ly@vmmin 14:43:46 ╰─❯ cat backup_2024-12-24.log sending incremental file list created directory backup/current logfiles/ logfiles/a.log logfiles/a.log.tar.gz logfiles/b.log logfiles/b.log.tar.gz logfiles/c.log logfiles/c.log.tar.gz logfiles/xx.txt logfiles/y.txt sent 279 bytes received 81 bytes 720.00 bytes/sec total size is 362 speedup is 1.01 (DRY RUN) sending incremental file list created directory backup/current ./ a.log a.log.tar.gz b.log b.log.tar.gz c.log c.log.tar.gz xx.txt y.txt sent 254 bytes received 80 bytes 668.00 bytes/sec total size is 362 speedup is 1.08 (DRY RUN) sending incremental file list created directory backup/current ./ a.log a.log.tar.gz b.log b.log.tar.gz c.log c.log.tar.gz xx.txt y.txt sent 916 bytes received 208 bytes 2,248.00 bytes/sec total size is 362 speedup is 0.32 此时在logfiles里新建一个文件以及更新一个文件\n╭─ ~/shellTest ly@vmmin 14:43:50 ╰─❯ touch logfiles/testfile.txt ╭─ ~/shellTest ly@vmmin 14:46:48 ╰─❯ touch logfiles/a.log ╭─ ~/shellTest ly@vmmin 14:47:20 ╰─❯ rm backup_2024-12-24.log #第二次备份 ╭─ ~/shellTest ly@vmmin 14:48:22 ╰─❯ ./17myscript_cls.sh logfiles/ backup ╭─ ~/shellTest ly@vmmin 14:49:16 ╰─❯ cat backup_2024-12-24.log sending incremental file list ./ a.log testfile.txt sent 339 bytes received 57 bytes 792.00 bytes/sec total size is 362 speedup is 0.91 查看此时真实目录\n╭─ ~/shellTest ly@vmmin 14:54:58 ╰─❯ ls 10_1myscript_cls.sh 17myscript_cls.sh 62myscript_cls.sh 91myscript_cls.sh 11_1myscript_cls.sh 2myscript_cls.sh 63myscript_cls.sh 92myscript_cls.sh 11_2myscript_cls.sh 31myscript_cls.sh 64myscript_cls.sh backup 12myscript_cls.sh 32myscript_cls.sh 65myscript_cls.sh backup_2024-12-24.log 13myscript_cls.sh 51myscript_cls.sh 71myscript_cls.sh logfiles 14myscript_cls.sh 52myscript_cls.sh 72myscript_cls.sh package_install_results.log 15myscript_cls.sh 53myscript_cls.sh 81myscript_cls.sh package_isntall_failure.log 16myscript_cls.sh 61myscript_cls.sh 82myscript_cls.sh ╭─ ~/shellTest ly@vmmin 14:55:37 ╰─❯ ls backup/current a.log backup b.log.tar.gz c.log.tar.gz xx.txt a.log.tar.gz b.log c.log testfile.txt y.txt #这里会发现,他在替换成新文件前,把旧的文件拷贝到备份文件夹中了 ╭─ ~/shellTest ly@vmmin 14:55:42 ╰─❯ ls backup/current/backup/2024-12-24 a.log 我又修改了一次a.log,变成了这样(深层次)\n╭─ ~/shellTest ly@vmmin 14:55:53 ╰─❯ touch logfiles/a.log ╭─ ~/shellTest ly@vmmin 14:57:52 ╰─❯ ./17myscript_cls.sh logfiles/ backup ╭─ ~/shellTest ly@vmmin 14:57:57 ╰─❯ cat backup_2024-12-24.log sending incremental file list ./ a.log testfile.txt sent 336 bytes received 57 bytes 786.00 bytes/sec total size is 362 speedup is 0.92 sending incremental file list deleting backup/2024-12-24/a.log cannot delete non-empty directory: backup/2024-12-24 cannot delete non-empty directory: backup a.log sent 295 bytes received 165 bytes 920.00 bytes/sec total size is 362 speedup is 0.79 ╭─ ~/shellTest ly@vmmin 14:58:00 ╰─❯ ls backup/current/backup/2024-12-24 a.log backup #注意,这里对a.log又进行了backup备份 ╭─ ~/shellTest ly@vmmin 14:58:17 ╰─❯ ls backup/current/backup/2024-12-24/backup 2024-12-24 ╭─ ~/shellTest ly@vmmin 14:58:23 ╰─❯ ls backup/current/backup/2024-12-24/backup/2024-12-24 a.log 继续Linux的学习 # https://ubuntuserverbook.com/ 作者写的书 https://www.youtube.com/c/LearnLinuxTV 作者的y2b https://learnlinux.tv 作者的网站 "},{"id":191,"href":"/zh/docs/technology/Linux/SHELLlearnLinuxTV_/12-15/","title":"12-15","section":"SHELL编程(learnLinuxTV)_","content":" functions 函数 # 以update这个脚本为基础编改\n作用\n减少重复代码 #!/bin/bash release_file=/etc/os-release logfile=/var/log/updater.log errorlog=/var/log/updater_errors.log check_exit_status(){ if [ $? -ne 0 ] then echo \u0026#34;An error occured,please check the $errorlog file.\u0026#34; fi } if grep -q \u0026#34;Arch\u0026#34; $release_file then sudo pacman -Syu 1\u0026gt;\u0026gt;$logfile 2\u0026gt;\u0026gt;$errorlog check_exit_status fi if grep -q \u0026#34;Ubuntu\u0026#34; $release_file || grep -q \u0026#34;Debian\u0026#34; $release_file then sudo apt update 1\u0026gt;\u0026gt;$logfile 2\u0026gt;\u0026gt;$errorlog check_exit_status #默认yes sudo apt dist-upgrade -y 1\u0026gt;\u0026gt;$logfile 2\u0026gt;\u0026gt;$errorlog check_exit_status fi CaseStatements # 脚本 # ╭─ ~/shellTest ly@vmmin 22:32:52 ╰─❯ cat ./13myscript_cls.sh #!/bin/bash finished=0 while [ $finished -ne 1 ] do echo \u0026#34;What is your favorite Linux distribution?\u0026#34; echo \u0026#34;1 - Arch\u0026#34; echo \u0026#34;2 - CentOS\u0026#34; echo \u0026#34;3 - Debian\u0026#34; echo \u0026#34;4 - Mint\u0026#34; echo \u0026#34;5 - Something else..\u0026#34; echo \u0026#34;6 - exit\u0026#34; read distro; case $distro in 1) echo \u0026#34;Arch is xxx\u0026#34;;; 2) echo \u0026#34;CentOS is xbxxx\u0026#34;;; 3) echo \u0026#34;Debian is bbbxx\u0026#34;;; 4) echo \u0026#34;Mint is xxxxsss\u0026#34;;; 5) echo \u0026#34;Something els.xxxxx\u0026#34;;; 6) finished=1 echo \u0026#34;now will exit\u0026#34; ;; *) echo \u0026#34;you didn\u0026#39;t enter an xxxx choice.\u0026#34; esac done echo \u0026#34;Thanks for using this script.\u0026#34; 脚本执行 # ╭─ ~/shellTest ly@vmmin 22:32:11 ╰─❯ ./13myscript_cls.sh What is your favorite Linux distribution? 1 - Arch 2 - CentOS 3 - Debian 4 - Mint 5 - Something else.. 6 - exit 3 Debian is bbbxx What is your favorite Linux distribution? 1 - Arch 2 - CentOS 3 - Debian 4 - Mint 5 - Something else.. 6 - exit u you didn\u0026#39;t enter an xxxx choice. What is your favorite Linux distribution? 1 - Arch 2 - CentOS 3 - Debian 4 - Mint 5 - Something else.. 6 - exit 6 now will exit Thanks for using this script. ScheduleJobs # 作用 # 脚本在特定时间运行\n安装 # ─ ~/shellTest ly@vmmin 22:37:20 ╰─❯ which at at not found ╭─ ~/shellTest ly@vmmin 22:37:23 ╰─❯ sudo apt install at 查看 # ─ ~/shellTest 28s ly@vmmin 22:38:59 ╰─❯ which at /usr/bin/at 示例 # ╰─❯ cat 14myscript_cls.sh #!/bin/bash logfile=job_results.log echo \u0026#34;The script ran at the following time: $(date)\u0026#34; \u0026gt; $logfile ╭─ ~/shellTest ly@vmmin 23:06:05 ╰─❯ date Mon Dec 23 11:06:06 PM CST 2024 ╭─ ~/shellTest ly@vmmin 23:06:06 ╰─❯ at 23:07 -f /home/ly/shellTest/14myscript_cls.sh warning: commands will be executed using /bin/sh job 1 at Mon Dec 23 23:07:00 2024 ╭─ ~/shellTest ly@vmmin 23:06:34 ╰─❯ cat job_results.log The script ran at the following time: Mon Dec 23 11:01:23 PM CST 2024 ╭─ ~/shellTest ly@vmmin 23:06:46 ╰─❯ cat job_results.log The script ran at the following time: Mon Dec 23 11:07:00 PM CST 2024 ╭─ ~/shellTest ly@vmmin 23:07:03 ╰─❯ date Mon Dec 23 11:07:10 PM CST 2024 解释 # at 23:07 -f /home/ly/shellTest/14myscript_cls.sh 23:07没给日期说明是今天,-f表示运行的是一个文件\n查看待运行任务 # ╭─ ~/shellTest ly@vmmin 23:10:53 ╰─❯ at 23:12 -f ./14myscript_cls.sh warning: commands will be executed using /bin/sh job 2 at Mon Dec 23 23:12:00 2024 ╭─ ~/shellTest ly@vmmin 23:11:02 ╰─❯ atq 2\tMon Dec 23 23:12:00 2024 a ly ╭─ ~/shellTest ly@vmmin 23:11:04 ╰─❯ at 23:13 -f ./14myscript_cls.sh warning: commands will be executed using /bin/sh job 3 at Mon Dec 23 23:13:00 2024 ╭─ ~/shellTest ly@vmmin 23:11:12 ╰─❯ atq 3\tMon Dec 23 23:13:00 2024 a ly 2\tMon Dec 23 23:12:00 2024 a ly 删除作业 # ╭─ ~/shellTest ly@vmmin 23:13:09 ╰─❯ at 23:15 -f ./14myscript_cls.sh warning: commands will be executed using /bin/sh job 4 at Mon Dec 23 23:15:00 2024 ╭─ ~/shellTest ly@vmmin 23:13:14 ╰─❯ at 23:16 -f ./14myscript_cls.sh warning: commands will be executed using /bin/sh job 5 at Mon Dec 23 23:16:00 2024 ╭─ ~/shellTest ly@vmmin 23:13:20 ╰─❯ atq 5\tMon Dec 23 23:16:00 2024 a ly 4\tMon Dec 23 23:15:00 2024 a ly ╭─ ~/shellTest ly@vmmin 23:13:24 ╰─❯ atrm 4 ╭─ ~/shellTest ly@vmmin 23:13:31 ╰─❯ atq 5\tMon Dec 23 23:16:00 2024 a ly 日期 # ╭─ ~/shellTest ly@vmmin 23:13:33 ╰─❯ atq 5\tMon Dec 23 23:16:00 2024 a ly ╭─ ~/shellTest ly@vmmin 23:14:52 ╰─❯ at 23:16 122424 -f ./14myscript_cls.sh warning: commands will be executed using /bin/sh job 6 at Tue Dec 24 23:16:00 2024 ╭─ ~/shellTest ly@vmmin 23:15:08 ╰─❯ atq 5\tMon Dec 23 23:16:00 2024 a ly 6\tTue Dec 24 23:16:00 2024 a ly CronJobs # 命令的完整路径 # 安排你的bash脚本,在将来的某个时间执行\n下面使用完全限定名\n╭─ ~/shellTest 19s ly@vmmin 09:08:02 ╰─❯ cat 15myscript_cls.sh #!/bin/bash logfile=job_results.log /usr/bin/echo \u0026#34;The script ran at the following time: $(/uar/bin/date)\u0026#34; \u0026gt; $logfile ╭─ ~/shellTest ly@vmmin 09:08:06 ╰─❯ which查看命令\nly@vmmin:~/shellTest$ which echo /usr/bin/echo ly@vmmin:~/shellTest$ which date /usr/bin/date 能够保持安全性,还有涉及到路径变量(没法找到,或者找错)\n编辑任务 # ╭─ ~/shellTest ly@vmmin 09:17:35 ╰─❯ crontab -e no crontab for ly - using an empty one Select an editor. To change later, run \u0026#39;select-editor\u0026#39;. 1. /bin/nano \u0026lt;---- easiest 2. /usr/bin/vim.basic 3. /usr/bin/vim.tiny Choose 1-3 [1]: 1 #之后会进入nano编辑器并编辑/tmp/crontab.I0AOMk/crontab这个文件(用户自己的,不会干扰其他用户)(crontab.I0AOMk这个文件夹每次都不确定,每次运行crontab -e都是不同文件夹,不过crontab文件内容会跟上次的一样) 编辑内容\n# For more information see the manual pages of cro\u0026gt; # # m h dom mon dow command 30 1 * * 5 /home/ly/shellTest/15myscript_cls.sh 30分钟时执行(每一个小时,即0:30,1:30,2:30),后面两个星号,表示一个月中几号,每年的哪个月,最后这个5表示每星期几(这里是星期五,0跟7都代表星期日)。 这个脚本的意思,每周五凌晨1点30分运行\n30 1 10 7 4 /home/ly/shellTest/15myscript_cls.sh,如果改成这样,则运行的概率极低。即 每年7月10号且星期四,那天里每一个小时到达30分时执行 #用root用户为某个用户创建任务,不存在则创建新文件并编辑任务,存在则继续编辑之前的任务文件 sudo crontab -u ly -e "},{"id":192,"href":"/zh/docs/technology/Linux/SHELLlearnLinuxTV_/11DataStreams/","title":"11DataStreams","section":"SHELL编程(learnLinuxTV)_","content":" 下面的输出中,涉及到标准输出的,有十几行的那些,只列举了其中四五行\n概念 # 标准输入,标准输出,标准错误 标准输出:打印到屏幕上的输出 ╭─ ~ ly@vmmin 12:31:44 ╰─❯ ls content.zh index.html myfile dufs.log install.sh shellTest ╭─ ~ ly@vmmin 12:32:21 ╰─❯ echo $? 0 标准错误 ╭─ ~ ly@vmmin 12:30:27 ╰─❯ ls /notexist ls: cannot access \u0026#39;/notexist\u0026#39;: No such file or directory ╭─ ~ ly@vmmin 12:30:59 ╰─❯ echo $? 2 标准输出和标准错误 # 部分重定向 # 标准错误重定向 2\u0026gt; # find,文件系统\nfind /etc -type f\n## 下面是附加知识,最后没用到 #新建一个用户,-m 让用户具有默认主目录,-d指定目录,-s指定用户登入后所使用的shell sudo useradd -d /home/ly1 -s /bin/bash -m ly1 #设置密码 sudo passwd ly1 为了演示错误,先创建几个文件\nroot@vmmin:/home/ly# mkdir a \u0026amp;\u0026amp; touch a/a1.txt a/a2.txt root@vmmin:/home/ly# mkdir b \u0026amp;\u0026amp; touch b/b1.txt b/b2.txt #去除所在组和其他人的所有权限 root@vmmin:/home/ly# chmod 700 a root@vmmin:/home/ly# chmod 700 b ╭─ ~ ly@vmmin 17:02:36 ╰─❯ ls -l total 80 drwx------ 2 root root 4096 Dec 23 16:13 a -rw-r--r-- 1 ly ly 17006 Dec 23 16:04 abc.txt drwx------ 2 root root 4096 Dec 23 16:13 b drwxr-xr-x 3 ly ly 4096 Dec 18 17:33 content.zh -rw-r--r-- 1 ly ly 90 Dec 20 11:20 dufs.log -rw-r--r-- 1 ly ly 19786 Dec 17 23:54 index.html -rw-r--r-- 1 ly ly 18369 Dec 19 15:01 install.sh -rw-r--r-- 1 ly ly 0 Dec 20 22:27 myfile drwxr-xr-x 3 ly ly 4096 Dec 23 10:34 shellTest 用root账号,在/home/ly下面创建了a,b文件夹,以及a1.txt,a2.txt,b1.txt,b2.txt\n,a,b文件夹的权限均为700\n使用find查找,会出现Permission错误\n这里使用-not -path \u0026quot;/home/ly/.**\u0026quot;忽略点开头的文件\n╭─ ~ ly@vmmin 17:02:38 ╰─❯ find ~ -type f -not -path \u0026#34;/home/ly/.**\u0026#34; find: ‘/home/ly/a’: Permission denied /home/ly/dufs.log find: ‘/home/ly/b’: Permission denied /home/ly/index.html /home/ly/abc.txt /home/ly/shellTest/2myscript_cls.sh /home/ly/shellTest/82myscript_cls.sh /home/ly/shellTest/62myscript_cls.sh /home/ly/shellTest/31myscript_cls.sh /home/ly/shellTest/92myscript_cls.sh 忽略显示错误的信息\n╭─ ~ ly@vmmin 17:05:37 ╰─❯ find ~ -type f -not -path \u0026#34;/home/ly/.**\u0026#34; 2\u0026gt; /dev/null /home/ly/dufs.log /home/ly/index.html /home/ly/abc.txt /home/ly/shellTest/2myscript_cls.sh /home/ly/shellTest/82myscript_cls.sh /home/ly/shellTest/62myscript_cls.sh /home/ly/shellTest/31myscript_cls.sh /home/ly/shellTest/92myscript_cls.sh /home/ly/shellTest/61myscript_cls.sh /home/ly/shellTest/32myscript_cls.sh /home/ly/shellTest/64myscript_cls.sh /home/ly/shellTest/65myscript_cls.sh /home/ly/shellTest/53myscript_cls.sh /home/ly/shellTest/72myscript_cls.sh /home/ly/shellTest/52myscript_cls.sh /home/ly/shellTest/81myscript_cls.sh ╭─ ~ ly@vmmin 17:05:47 ╰─❯ echo $? 1 echo $?结果为1说明其实出错了,但是没有显示。\n\u0026gt;号用来重定向 /dev/null dev null\n构成错误-标准错误的每一行将被发送到Dev null而不是屏幕\n标准输出重定向1\u0026gt;或\u0026gt; # ╭─ ~ ly@vmmin 17:12:43 ╰─❯ find ~ -type f -not -path \u0026#34;/home/ly/.**\u0026#34; \u0026gt; /dev/null find: ‘/home/ly/a’: Permission denied find: ‘/home/ly/b’: Permission denied 1\u0026gt; 和 \u0026gt; 是一样的结果,都是重定向标准输出\n╭─ ~ ly@vmmin 17:12:43 ╰─❯ find ~ -type f -not -path \u0026#34;/home/ly/.**\u0026#34; 1\u0026gt; /dev/null find: ‘/home/ly/a’: Permission denied find: ‘/home/ly/b’: Permission denied 重定向到文件 # ╭─ ~ ly@vmmin 17:15:07 ╰─❯ find ~ -type f -not -path \u0026#34;/home/ly/.**\u0026#34; 1\u0026gt; file.txt find: ‘/home/ly/a’: Permission denied find: ‘/home/ly/b’: Permission denied ╭─ ~ ly@vmmin 17:16:35 ╰─❯ cat file.txt /home/ly/dufs.log /home/ly/index.html /home/ly/file.txt /home/ly/abc.txt /home/ly/shellTest/2myscript_cls.sh /home/ly/shellTest/82myscript_cls.sh /home/ly/shellTest/62myscript_cls.sh /home/ly/shellTest/31myscript_cls.sh /home/ly/shellTest/92myscript_cls.sh /home/ly/shellTest/61myscript_cls.sh /home/ly/shellTest/32myscript_cls.sh /home/ly/shellTest/64myscript_cls.sh /home/ly/shellTest/65myscript_cls.sh /home/ly/shellTest/53myscript_cls.sh 同时重定向标准输出和标准错误 # 同时重定向到一个 # 在Shell中,标准错误写法为 2\u0026gt;, 标准输出为 1\u0026gt; 或者 \u0026gt;。如要要将标准输出和标准错误合二为一,都重定向到同一个文件,可以使用下面两种方式:\n\u0026amp;\u0026gt; # ╭─ ~ ly@vmmin 17:22:07 ╰─❯ find ~ -type f -not -path \u0026#34;/home/ly/.**\u0026#34; \u0026amp;\u0026gt; file.txt ╭─ ~ ly@vmmin 17:22:14 ╰─❯ cat file.txt find: ‘/home/ly/a’: Permission denied /home/ly/dufs.log find: ‘/home/ly/b’: Permission denied /home/ly/index.html /home/ly/file.txt /home/ly/abc.txt /home/ly/shellTest/2myscript_cls.sh /home/ly/shellTest/82myscript_cls.sh /home/ly/shellTest/62myscript_cls.sh /home/ly/shellTest/31myscript_cls.sh /home/ly/shellTest/92myscript_cls.sh /home/ly/shellTest/61myscript_cls.sh 2\u0026gt;\u0026amp;1 # ╭─ ~ ly@vmmin 17:24:41 ╰─❯ find ~ -type f -not -path \u0026#34;/home/ly/.**\u0026#34; \u0026gt; file.txt 2\u0026gt;\u0026amp;1 ╭─ ~ ly@vmmin 17:24:57 ╰─❯ cat file.txt find: ‘/home/ly/a’: Permission denied /home/ly/dufs.log find: ‘/home/ly/b’: Permission denied /home/ly/index.html /home/ly/file.txt /home/ly/abc.txt /home/ly/shellTest/2myscript_cls.sh /home/ly/shellTest/82myscript_cls.sh /home/ly/shellTest/62myscript_cls.sh /home/ly/shellTest/31myscript_cls.sh /home/ly/shellTest/92myscript_cls.sh 一条语句分别重定向到多个 # ╭─ ~ ly@vmmin 17:30:48 ╰─❯ find ~ -type f -not -path \u0026#34;/home/ly/.**\u0026#34; 1\u0026gt;find_results.txt 2\u0026gt;find_errors.txt ╭─ ~ ly@vmmin 17:31:06 ╰─❯ cat find_results.txt /home/ly/dufs.log /home/ly/index.html /home/ly/file.txt /home/ly/abc.txt /home/ly/shellTest/2myscript_cls.sh /home/ly/shellTest/82myscript_cls.sh /home/ly/shellTest/62myscript_cls.sh /home/ly/shellTest/31myscript_cls.sh /home/ly/shellTest/92myscript_cls.sh /home/ly/shellTest/61myscript_cls.sh /home/ly/shellTest/32myscript_cls.sh /home/ly/shellTest/64myscript_cls.sh ╭─ ~ ly@vmmin 17:31:24 ╰─❯ cat find_errors.txt find: ‘/home/ly/a’: Permission denied find: ‘/home/ly/b’: Permission denied 也可以使用find ~ -type f -not -path \u0026quot;/home/ly/.**\u0026quot; \u0026gt;find_results.txt 2\u0026gt;find_errors.txt\nupdate脚本(之前的) # ╭─ ~ ly@vmmin 17:36:08 ╰─❯ cat /usr/local/bin/update #!/bin/bash release_file=/etc/os-release if grep -q \u0026#34;Arch\u0026#34; $release_file then sudo pacman -Syu fi if grep -q \u0026#34;Ubuntu\u0026#34; $release_file || grep -q \u0026#34;Debian\u0026#34; $release_file then sudo apt update sudo apt dist-upgrade fi 修改\n╭─ ~/shellTest ly@vmmin 17:46:55 ╰─❯ cat 11_1_1myscript_cls.sh #!/bin/bash release_file=/etc/os-release logfile=/var/log/updater.log errorlog=/var/log/updater_errors.log if grep -q \u0026#34;Arch\u0026#34; $release_file then sudo pacman -Syu 1\u0026gt;\u0026gt;$logfile 2\u0026gt;\u0026gt;$errorlog if [ $? -ne 0 ] then echo \u0026#34;An error occured,please check the $errorlog file.\u0026#34; fi fi if grep -q \u0026#34;Ubuntu\u0026#34; $release_file || grep -q \u0026#34;Debian\u0026#34; $release_file then sudo apt update 1\u0026gt;\u0026gt;$logfile 2\u0026gt;\u0026gt;$errorlog if [ $? -ne 0 ] then echo \u0026#34;An error occured,please check the $errorlog file.\u0026#34; fi sudo apt dist-upgrade -y 1\u0026gt;\u0026gt;$logfile 2\u0026gt;\u0026gt;$errorlog if [ $? -ne 0 ] then echo \u0026#34;An error occured,please check the $errorlog file.\u0026#34; fi fi 切换到root用户并执行\n╭─ ~/shellTest ly@vmmin 17:50:25 ╰─❯ su root - Password: root@vmmin:/home/ly/shellTest# ./11_1_1myscript_cls.sh 查看\n#root用户下 root@vmmin:/home/ly/shellTest# cat /var/log/updater.log Hit:1 https://mirrors.tuna.tsinghua.edu.cn/debian bookworm InRelease Hit:2 https://mirrors.tuna.tsinghua.edu.cn/debian bookworm-updates InRelease Hit:3 https://mirrors.tuna.tsinghua.edu.cn/debian bookworm-backports InRelease Hit:4 https://mirrors.tuna.tsinghua.edu.cn/debian-security bookworm-security InRelease Hit:5 https://security.debian.org/debian-security bookworm-security InRelease Reading package lists... Building dependency tree... Reading state information... All packages are up to date. Reading package lists... Building dependency tree... Reading state information... Calculating upgrade... 0 upgraded, 0 newly installed, 0 to remove and 0 not upgraded. root@vmmin:/home/ly/shellTest# cat var/log/updater_errors.log cat: var/log/updater_errors.log: No such file or directory #这里没有出错,所以甚至连错误文件都没有 附加知识,监听文本文件\n╭─ ~ ly@vmmin 18:00:17 ╰─❯ sudo tail -f /var/log/updater.log Hit:5 https://security.debian.org/debian-security bookworm-security InRelease Reading package lists... Building dependency tree... Reading state information... All packages are up to date. Reading package lists... Building dependency tree... Reading state information... Calculating upgrade... 0 upgraded, 0 newly installed, 0 to remove and 0 not upgraded. 标准输入 # ╭─ ~/shellTest 5s ly@vmmin 18:07:53 ╰─❯ cat 11_2myscript_cls.sh #!/bin/bash echo \u0026#34;Please enter your name:\u0026#34; read myname echo \u0026#34;Your name is: $myname\u0026#34; ╭─ ~/shellTest 1m 6s ly@vmmin 18:07:42 ╰─❯ ./11_2myscript_cls.sh Please enter your name: JayH Your name is: JayH "},{"id":193,"href":"/zh/docs/technology/Linux/SHELLlearnLinuxTV_/07-10/","title":"07-10","section":"SHELL编程(learnLinuxTV)_","content":" WhileLoops # 范例 # #!/bin/bash myvar=1 #小于或者等于10 while [ $myvar -le 10 ] do echo $myvar myvar=$(( $myvar + 1 )) sleep 0.5 done 运行\n╭─ ~/shellTest ≡ ly@vmmin 12:10:33 ╰─❯ ./71myscript_cls.sh 1 2 3 4 5 6 7 8 9 10 数字会每隔0.5s就输出一次\n对于myvar=$(( $myvar + 1 )) ,$((expression))形式表示算数运算,而且其中的空格是可以省略的\n范例2 # #!/bin/bash while [ -f ~/testfile ] do echo \u0026#34;As of $(date),the test file exists.\u0026#34; sleep 5 done echo \u0026#34;As of $(date), the test ....has gone missing.\u0026#34; 用来测试文件是否存在,运行前先新建一下文件touch ~/testfile 运行一会后把文件删除,如图\ndate命令包含在子shell中,因此date命令将在后台运行并将该命令的输出替换$(date)这部分\n更新相关的脚本 # 基本概念 # upgrade:系统将现有的Package升级,如果有相依性的问题,而此相依性需要安装其它新的Package或影响到其它Package的相依性时,此Package就不会被升级,会保留下来。\ndist-upgrade:可以聪明的解决相依性的问题,如果有相依性问题,需要安装/移除新的Package,就会试着去安装/移除它。\ngrep -q,安静模式,不打印任何标准输出。如果有匹配的内容则立即返回状态值0\nshell中,零为真,非零为假\n#!/bin/bash release_file=/etc/os-release #这里没有使用[]测试命令,而是使用Linux命令 # #号用来注释,除了第一行shebang比较特殊 if grep -q \u0026#34;Arch\u0026#34; $release_file then sudo pacman -Syu fi # ||或者,\u0026amp;\u0026amp; 与, if grep -q \u0026#34;Ubuntu\u0026#34; $release_file || grep -q \u0026#34;Debian\u0026#34; $release_file then sudo apt update sudo apt dist-upgrade fi for语句 # #!/bin/bash for current_number in 1 2 3 4 5 6 7 8 9 10 do echo $current_number sleep 1 done echo \u0026#34;This is outside of the for loop.\u0026#34; for语句进入do语句前,current_number指向1,1的do结束后current_number指向2\n─ ~/shellTest ly@vmmin 21:55:34 ╰─❯ ./9myscript_cls.sh 1 2 3 4 5 6 7 8 9 10 This is outside of the for loop. 简化\n#!/bin/bash for current_number in {1..10} #for current_number in {a..z} #字母也行 do echo $current_number sleep 1 done echo \u0026#34;This is outside of the for loop.\u0026#34; #!/bin/bash for n in {1..10} #for n in {a..z} #字母也行 do echo $n sleep 1 done echo \u0026#34;This is outside of the for loop.\u0026#34; 文件遍历 # ─ ~/shellTest ly@vmmin 23:15:41 ╰─❯ ls logfiles a.log b.log c.log xx.txt y.txt 脚本:\n#!/bin/bash for file in logfiles/*.log do tar -czvf $file.tar.gz $file done tar命令,tar -czvf c : create,z : zip,v: view,f: file\n结果:\n╭─ ~/shellTest ly@vmmin 23:26:15 ╰─❯ ls logfiles a.log b.log c.log xx.txt a.log.tar.gz b.log.tar.gz c.log.tar.gz y.txt 可以用来循环发送日志文件(提到,没例子) # 脚本保存位置 # 主要讨论脚本应该放在哪个公共位置才可以让所有人都可以访问\n为需要的人提供脚本\nfile system hierarchy standard,文件系统层次结构标准,简称FHS 这个东西存在的目的,\u0026ldquo;所有Linux发行版上都可以找到的每个典型目录\u0026rdquo;。\nFHS指出了与本地安装的程序一起使用的用户本地目录(给系统管理员使用),bin目录也位于用户本地,我们将在其中放置脚本\n─ ~/shellTest ly@vmmin 10:34:13 ╰─❯ sudo mv 10_1myscript_cls.sh /usr/local/bin/update ╭─ ~/shellTest 3s ly@vmmin 10:28:43 ╰─❯ ls -l /usr/local/bin total 77876 -rwxr-xr-x 1 root root 4488672 Dec 17 16:28 dufs -rwxr-xr-x 1 root root 75247968 Dec 17 16:44 hugo -rwxr-xr-x 1 root root 231 Dec 23 10:28 update ╭─ ~/shellTest ly@vmmin 10:34:58 ╰─❯ ls -l /usr/local/bin total 77876 -rwxr-xr-x 1 root root 4488672 Dec 17 16:28 dufs -rwxr-xr-x 1 root root 75247968 Dec 17 16:44 hugo -rwxr-xr-x 1 ly ly 231 Dec 23 10:33 update 现在需要让这个脚本由root拥有,以确保有人需要pseudo privileges 伪权限或者root permissions root权限才能修改该脚本,不能让(普通)用户修改\n╭─ ~/shellTest ly@vmmin 10:35:05 ╰─❯ sudo chown root:root /usr/local/bin/update ╭─ ~/shellTest ly@vmmin 10:40:32 ╰─❯ ls -l /usr/local/bin total 77876 -rwxr-xr-x 1 root root 4488672 Dec 17 16:28 dufs -rwxr-xr-x 1 root root 75247968 Dec 17 16:44 hugo -rwxr-xr-x 1 root root 231 Dec 23 10:33 update Linux中任何脚本其实都不需要后缀的,所以这里删除了 .sh 。\n因为第一行shebang已经指明了需要使用到什么解释器\n使用 # ╭─ ~ ly@vmmin 11:39:04 ╰─❯ ls content.zh dufs.log index.html install.sh myfile shellTest ╭─ ~ ly@vmmin 11:39:05 ╰─❯ update Hit:1 https://mirrors.tuna.tsinghua.edu.cn/debian bookworm InRelease Hit:2 https://mirrors.tuna.tsinghua.edu.cn/debian bookworm-updates InRelease Hit:3 https://mirrors.tuna.tsinghua.edu.cn/debian bookworm-backports InRelease Hit:4 https://mirrors.tuna.tsinghua.edu.cn/debian-security bookworm-security InRelease Hit:5 https://security.debian.org/debian-security bookworm-security InRelease Reading package lists... Done Building dependency tree... Done Reading state information... Done All packages are up to date. Reading package lists... Done Building dependency tree... Done Reading state information... Done Calculating upgrade... Done 0 upgraded, 0 newly installed, 0 to remove and 0 not upgraded. ╭─ ~ 13s ly@vmmin 11:39:19 ╰─❯ which update /usr/local/bin/update 运行update命令的时候,是需要sudo权限的\n且不需要指定具体完整路径,就可以使用update文件\n有一个系统变量,告诉shell将在其中查找所有的目录\n全大写表示系统变量\n─ ~ ly@vmmin 11:44:21 ╰─❯ echo $PATH /usr/local/bin:/usr/bin:/bin:/usr/games 系统变量查看\n╭─ ~ ly@vmmin 11:45:48 ╰─❯ env USER=ly LOGNAME=ly HOME=/home/ly PATH=/usr/local/bin:/usr/bin:/bin:/usr/games SHELL=/usr/bin/zsh TERM=xterm DISPLAY=localhost:11.0 XDG_SESSION_ID=99 XDG_RUNTIME_DIR=/run/user/1000 DBUS_SESSION_BUS_ADDRESS=unix:path=/run/user/1000/bus XDG_SESSION_TYPE=tty XDG_SESSION_CLASS=user MOTD_SHOWN=pam LANG=en_US.UTF-8 SSH_CLIENT=192.168.1.201 52599 22 SSH_CONNECTION=192.168.1.201 52599 192.168.1.206 22 SSH_TTY=/dev/pts/2 SHLVL=1 PWD=/home/ly OLDPWD=/home/ly/shellTest P9K_TTY=old _P9K_TTY=/dev/pts/2 ZSH=/home/ly/.oh-my-zsh PAGER=less LESS=-R LSCOLORS=Gxfxcxdxbxegedabagacad LS_COLORS=rs=0:di=01;34:ln=01;36:mh=00:pi=40;33:so=01;35:do=01;35:bd=40;33;01:cd=40;33;01:or=40;31;01:mi=00:su=37;41:sg=30;43:ca=00:tw=30;42:ow=34;42:st=37;44:ex=01;32:*.tar=01;31:*.tgz=01;31:*.arc=01;31:*.arj=01;31:*.taz=01;31:*.lha=01;31:*.lz4=01;31:*.lzh=01;31:*.lzma=01;31:*.tlz=01;31:*.txz=01;31:*.tzo=01;31:*.t7z=01;31:*.zip=01;31:*.z=01;31:*.dz=01;31:*.gz=01;31:*.lrz=01;31:*.lz=01;31:*.lzo=01;31:*.xz=01;31:*.zst=01;31:*.tzst=01;31:*.bz2=01;31:*.bz=01;31:*.tbz=01;31:*.tbz2=01;31:*.tz=01;31:*.deb=01;31:*.rpm=01;31:*.jar=01;31:*.war=01;31:*.ear=01;31:*.sar=01;31:*.rar=01;31:*.alz=01;31:*.ace=01;31:*.zoo=01;31:*.cpio=01;31:*.7z=01;31:*.rz=01;31:*.cab=01;31:*.wim=01;31:*.swm=01;31:*.dwm=01;31:*.esd=01;31:*.avif=01;35:*.jpg=01;35:*.jpeg=01;35:*.mjpg=01;35:*.mjpeg=01;35:*.gif=01;35:*.bmp=01;35:*.pbm=01;35:*.pgm=01;35:*.ppm=01;35:*.tga=01;35:*.xbm=01;35:*.xpm=01;35:*.tif=01;35:*.tiff=01;35:*.png=01;35:*.svg=01;35:*.svgz=01;35:*.mng=01;35:*.pcx=01;35:*.mov=01;35:*.mpg=01;35:*.mpeg=01;35:*.m2v=01;35:*.mkv=01;35:*.webm=01;35:*.webp=01;35:*.ogm=01;35:*.mp4=01;35:*.m4v=01;35:*.mp4v=01;35:*.vob=01;35:*.qt=01;35:*.nuv=01;35:*.wmv=01;35:*.asf=01;35:*.rm=01;35:*.rmvb=01;35:*.flc=01;35:*.avi=01;35:*.fli=01;35:*.flv=01;35:*.gl=01;35:*.dl=01;35:*.xcf=01;35:*.xwd=01;35:*.yuv=01;35:*.cgm=01;35:*.emf=01;35:*.ogv=01;35:*.ogx=01;35:*.aac=00;36:*.au=00;36:*.flac=00;36:*.m4a=00;36:*.mid=00;36:*.midi=00;36:*.mka=00;36:*.mp3=00;36:*.mpc=00;36:*.ogg=00;36:*.ra=00;36:*.wav=00;36:*.oga=00;36:*.opus=00;36:*.spx=00;36:*.xspf=00;36:*~=00;90:*#=00;90:*.bak=00;90:*.old=00;90:*.orig=00;90:*.part=00;90:*.rej=00;90:*.swp=00;90:*.tmp=00;90:*.dpkg-dist=00;90:*.dpkg-old=00;90:*.ucf-dist=00;90:*.ucf-new=00;90:*.ucf-old=00;90:*.rpmnew=00;90:*.rpmorig=00;90:*.rpmsave=00;90: P9K_SSH=1 _P9K_SSH_TTY=/dev/pts/2 _=/usr/bin/env 如果想要修改与路径变量PATH不同的目录\n#如果/usr/bin/local/bin默认没有添加到path里面的情况 export PATH=/usr/bin/local/bin:$PATH "},{"id":194,"href":"/zh/docs/life/dailyExcerpt/","title":"每日摘抄","section":"生活","content":" 欲买桂花同载酒,终不似,少年游。 君埋泉下泥销骨,我寄人间雪满头。 吾不识青天高,黄地厚。唯见月寒日暖,来煎人寿。 \u0026ldquo;老妈看不到你变老的样子了\u0026rdquo; 我也曾闪亮如星,而非没入尘埃。 我并非一直无人问津,也曾有人对我寄予厚望。 \u0026ldquo;你要好好读书,将来让他们都有工作。\u0026rdquo; 人道洛阳花似锦,偏我来时不逢春。 "},{"id":195,"href":"/zh/docs/technology/Linux/SHELLlearnLinuxTV_/06ExitCode/","title":"06ExitCode","section":"SHELL编程(learnLinuxTV)_","content":" 意义 # 用来确定代码是否执行成功\n例子 # ls -l /misc echo $? #输出2 ls -l ~ echo $? #输出0 $?用来显示最近一个命令的状态,零表示成功,非零表示失败\n#!/bin/bash #这个例子之前,作者用 sudo apt remove htop 命令把htop删除了 package=htop sudo apt install $package echo \u0026#34;The exit code for ....is $?\u0026#34; 安装完毕后显示返回0\n另一个示例\n#!/bin/bash package=notexist sudo apt install $package echo \u0026#34;The exit code for ....is $?\u0026#34; #执行后显示 #Reading package lists... Done #Building dependency tree... Done #Reading state information... Done #E: Unable to locate package notexist #The exit code for ....is 100 配合if语句 # 基本功能 # #!/bin/bash package=htop sudo apt install $package if [ $? -eq 0 ] then echo \u0026#34;The installation of $package success...\u0026#34; echo \u0026#34;new comman here:\u0026#34; which $package else echo \u0026#34;$package failed ...\u0026#34; fi 之前前作者用sudo apt remove htop又把htop删除了,不过其实不删除也是走的 echo \u0026quot;The installation of .....\u0026quot;这个分支\n结果:\nxxxxxx.... kB] Fetched 152 kB in 1s (292 kB/s) Selecting previously unselected package htop. (Reading database ... 38811 files and directories currently installed.) Preparing to unpack .../htop_3.2.2-2_amd64.deb ... Unpacking htop (3.2.2-2) ... Setting up htop (3.2.2-2) ... Processing triggers for mailcap (3.70+nmu1) ... Processing triggers for man-db (2.11.2-2) ... The installation of htop success... new comman here: /usr/bin/htop 修改后:\n#!/bin/bash package=notexit sudo apt install $package if [ $? -eq 0 ] then echo \u0026#34;The installation of $package success...\u0026#34; echo \u0026#34;new comman here:\u0026#34; which $package else echo \u0026#34;$package failed ...\u0026#34; fi 再次运行的结果:\nReading package lists... Done Building dependency tree... Done Reading state information... Done E: Unable to locate package notexit notexit failed ... 重定向到文件 # 先把htop再次提前卸载sudo apt remove htop 成功 # #!/bin/bash package=htop sudo apt install $package \u0026gt;\u0026gt; package_install_results.log if [ $? -eq 0 ] then echo \u0026#34;The installation of $package success...\u0026#34; echo \u0026#34;new comman here:\u0026#34; which $package else echo \u0026#34;$package failed ...\u0026#34; \u0026gt;\u0026gt; package_isntall_failure.log fi 结果:\n╭─ ~/shellTest ≡ ly@vmmin 11:14:08 ╰─❯ ./63myscript_cls.sh WARNING: apt does not have a stable CLI interface. Use with caution in scripts. The installation of htop success... new comman here: /usr/bin/htop 查看文件:\n─ ~/shellTest ≡ ly@vmmin 11:15:06 ╰─❯ cat package_install_results.log Reading package lists... Building dependency tree... Reading state information... Suggested packages: lm-sensors strace The following NEW packages will be installed: htop 0 upgraded, 1 newly installed, 0 to remove and 0 not upgraded. Need to get 152 kB of archives. After this operation, 387 kB of additional disk space will be used. Get:1 https://mirrors.tuna.tsinghua.edu.cn/debian bookworm/main amd64 htop amd64 3.2.2-2 [152 kB] Fetched 152 kB in 1s (202 kB/s) Selecting previously unselected package htop. (Reading database ... 38811 files and directories currently installed.) Preparing to unpack .../htop_3.2.2-2_amd64.deb ... Unpacking htop (3.2.2-2) ... Setting up htop (3.2.2-2) ... Processing triggers for mailcap (3.70+nmu1) ... Processing triggers for man-db (2.11.2-2) ... 失败 # #!/bin/bash package=notexit sudo apt install $package \u0026gt;\u0026gt; package_install_results.log if [ $? -eq 0 ] then echo \u0026#34;The installation of $package success...\u0026#34; echo \u0026#34;new comman here:\u0026#34; which $package else echo \u0026#34;$package failed ...\u0026#34; \u0026gt;\u0026gt; package_isntall_failure.log fi 结果:\n─ ~/shellTest ≡ ly@vmmin 11:17:23 ╰─❯ ./63myscript_cls.sh WARNING: apt does not have a stable CLI interface. Use with caution in scripts. E: Unable to locate package notexit 查看文件\n╭─ ~/shellTest ≡ ly@vmmin 11:17:26 ╰─❯ cat *fail* notexit failed ... 退出代码的测试 # 代码\n#!/bin/bash directory=/notexist if [ -d $directory ] then echo $? #测试失败,是非0 echo \u0026#34;The directory $directory exists.\u0026#34; else echo $? #测试失败,是非0 echo \u0026#34;The directory $directory doesn\u0026#39;t exist.\u0026#34; fi #最近一个命令是echo,echo确实正确执行并输出了,所以上一个指令执行成功,返回0 echo \u0026#34;The exit code ....is $?\u0026#34; 结果\n╭─ ~/shellTest ≡ ly@vmmin 11:43:24 ╰─❯ ./64myscript_cls.sh 1 The directory /notexist doesn\u0026#39;t exist. The exit code ....is 0 控制退出代码的结果 # ─ ~/shellTest ≡ ly@vmmin 11:50:29 ╰─❯ cat ./65myscript_cls.sh #!/bin/bash echo \u0026#34;Hello world\u0026#34; exit 199 #这行代码永远都不会执行 echo \u0026#34;never exec\u0026#34; 结果:\n╭─ ~/shellTest ✘ STOP 1m 16s ≡ ly@vmmin 11:50:16 ╰─❯ ./65myscript_cls.sh Hello world ╭─ ~/shellTest ✘ 199 ≡ ly@vmmin 11:50:23 ╰─❯ echo $? 199 执行失败 # 以最后一次exit返回的code为最终结果\n虽然执行失败了,但是返回值还是以我们给出的为结果\n╭─ ~/shellTest ✘ INT ≡ ly@vmmin 11:53:24 ╰─❯ cat 65myscript_cls.sh #!/bin/bash sudo apt install notexist exit 0 #这行代码永远都不会执行 echo \u0026#34;never exec\u0026#34; 结果:\n╭─ ~/shellTest ✘ STOP 8s ≡ ly@vmmin 11:55:04 ╰─❯ ./65myscript_cls.sh Reading package lists... Done Building dependency tree... Done Reading state information... Done E: Unable to locate package notexist ╭─ ~/shellTest ≡ ly@vmmin 11:55:07 ╰─❯ echo $? 0 exit最本质含义 # !/bin/bash directory=/notexist if [ -d $directory ] then echo \u0026#34;The directory $directory exists.\u0026#34; exit 0 else echo \u0026#34;The directory $directory doesn\u0026#39;t exist.\u0026#34; exit 1 fi #下面这三行永远不会执行,因为上面的任何一个分支都会导致退出程序执行 echo \u0026#34;The exit code for this ....is: $?\u0026#34; echo \u0026#34;You didn\u0026#39;t ..see this..\u0026#34; echo \u0026#34;You won\u0026#39;t see ...\u0026#34; "},{"id":196,"href":"/zh/docs/technology/Linux/SHELLlearnLinuxTV_/05If/","title":"05If","section":"SHELL编程(learnLinuxTV)_","content":" 在shell中,零为真,非零为假。\nif then fi # mynum=200 #[和]前后都要有空格 if [ $mynum -eq 200 ] then echo \u0026#34;The condition is true.\u0026#34; fi 编辑之后,按ctrl + O 保存文件\nctrl + T + Z 保持在后台,fg+回车 恢复\n#!/bin/bash mynum=200 #[和]前后都要有空格 if [ $mynum -eq 200 ] then echo \u0026#34;The condition is true.\u0026#34; fi if [ $mynum -eq 300 ] then echo \u0026#34;The variable does not equal 200.\u0026#34; fi else if # #!/bin/bash mynum=300 #[和]前后都要有空格 if [ $mynum -eq 200 ] then echo \u0026#34;The condition is true.\u0026#34; else echo \u0026#34;The variable does not equal\u0026gt; fi ! # #!/bin/bash mynum=300 #[和]前后都要有空格 #!用来反转条件 if [ ! $mynum -eq 200 ] then echo \u0026#34;The condition is true.\u0026#34; else echo \u0026#34;The variable does not equal 200.\u0026#34; fi ne # #!/bin/bash mynum=300 #[和]前后都要有空格 if [ $mynum -ne 200 ] then echo \u0026#34;The condition is true.\u0026#34; else echo \u0026#34;The variable does not equal 200.\u0026#34; fi 其他 # -gt 大于\n-f 文件是否存在 # #!/bin/bash if [ -f ~/myfile ] then echo \u0026#34;The file exists.\u0026#34; else echo \u0026#34;The file does not exists.\u0026#34; fi touch # 文件不存在则创建文件;存在则更新修改时间\n-d查看是否存在某个目录\n配合install # which查看是否存在应用程序\n先是which htop,结果是空 说明暂时没有安装该程序\n编辑程序\n#!/bin/bash command=/usr/bin/htop #查看程序文件是否存在 if [ -f $command ] then echo \u0026#34;$command is available,let\u0026#39;s run it ...\u0026#34; else #不存在则进行安装 echo \u0026#34;$command is NOT available, installing it...\u0026#34; sudo apt update \u0026amp;\u0026amp; sudo apt install -y htop fi $command 首先apt update只是用来更新软件包列表,与镜像存储库同步,找出实际可用的软件包,并不会实际更新软件。这就是为什么上面要先更新列表之后再安装。 其次,经常有时候要apt update之后apt upgrade(这个命令才实际更新了软件) \u0026amp;\u0026amp;用来命令链接,如果第一个命令成功,将立即运行第二个命令。失败则不运行。-y表示不要确认提示,只需继续运行即可(-y:当安装过程提示选择全部为\u0026quot;yes\u0026quot; ) 还有一点,在此之前我已经将我该用户ly添加进了sudoer组,即使用root用户运行 sudo usermod -aG sudo ly 命令(sudo deluser ly sudo 移出sudo组)。解释:-a 参数表示附加,只和 -G 参数一同使用,表示将用户增加到组中;即将ly添加到sudo组中。 简化\n#!/bin/bash command=htop #这里删除了[],因为command本身就是一个测试命令 if command -v $command then echo \u0026#34;$command is available,let\u0026#39;s run it ...\u0026#34; else echo \u0026#34;$command is NOT available, installing it...\u0026#34; sudo apt update \u0026amp;\u0026amp; sudo apt install -y $command fi $command man # man test 补充 # 我经常用的是[[ ]] 这个命令,感觉比较直观,很多运算符都能用上。[]这个命令有些运算符没法用\n"},{"id":197,"href":"/zh/docs/technology/Linux/SHELLlearnLinuxTV_/01-04/","title":"01-04","section":"SHELL编程(learnLinuxTV)_","content":" 意义 # 执行一系列命令\n视频框架 # 介绍,欢迎 HelloWorld 变量 数学函数 if语句 退出代码 while循环 更新脚本,保持服务器最新状态 for循环 脚本应该存储在文件系统哪个位置 数据流,标准输入、标准输出、标准错误输出 函数 case语句 调度作业(SchedulingJobs)Part1 调度作业(SchedulingJobs)Part2 传递参数 备份脚本 准备 # 需要一台运行Linux系统的计算机(或虚拟机)\n一些基本操作 # 新建或编辑脚本 # nano myscript.sh 内容 # ctrl + o 保存,ctrl + x 退出\n如何执行脚本 # 权限 # #给脚本赋予执行的权限 sudo chmod +x myscript.sh 执行 # 执行前查看权限 # 运行 # ./myscript.sh 查看脚本 # cat myscript.sh 更多语句的脚本 # ls pwd 输出\nshebang # 告诉系统哪个解释器准备运行脚本(不特别指定的情况),比如bash ./myscript.sh就特别指明了用bash运行脚本,所以这里指的是./myscript.sh这种情况使用的哪个默认解释器\n#!/bin/bash echo \u0026#34;Hello World!\u0026#34; echo \u0026#34;My current working directory is:\u0026#34; #结果中pwd会另取一行跟这里的显式换行没关系, 我猜是echo在最末尾加了\\n换行符 pwd 关于echo行末换行符 # echo -n abc;echo c 这里使用-n禁止输出默认换行符,所以两个c连接上了\n变量 # 变量左右两侧都不允许有空格!! # nano快捷键 # ctrl + k ,删除当前行\n基本使用 # #!/bin/bash myname=\u0026#34;Jay\u0026#34; #myage=\u0026#34;40\u0026#34; my=\u0026#34;xxx\u0026#34; myage=\u0026#34;40\u0026#34; #\u0026#34;\u0026#34;和\u0026#39;\u0026#39;的区别 echo \u0026#39;Hello, my name is $myname.\u0026#39; echo \u0026#34;Hello, my name is $myname.\u0026#34; #注意下面这句,不会去找变量m,my或者mya(以word字符为界,即字母或下划线为开头,直到字母或数字或下划线终止) echo \u0026#34;I\u0026#39;m $myage years old.\u0026#34; #下面这句,将单引号进行了转义 #视频中的方法有点问题,这里貌似只能通过 #下面这种分段的方法 echo \u0026#39;I\u0026#39;\\\u0026#39;\u0026#39;m $myage years old.\u0026#39; 减少重复操作 # # myscript.sh #!/bin/bash word=\u0026#34;fun\u0026#34; echo \u0026#34;Linux is $word\u0026#34; echo \u0026#34;Vediogames are $word\u0026#34; echo \u0026#34;Sunny days are $word\u0026#34; 存储临时值 # now=$(date) echo \u0026#34;The system time and date is:\u0026#34; echo $now 系统环境变量(默认变量) # 视频中的 # 输出\n自己测试 # 系统变量字母全是大写英文 # #查看系统变量 env 数学函数 # 运算符左右两边都要有空格!! # shell中执行算术运算 # expr 3 + 3 expr 30 - 10 expr 30 / 10 乘法*号是通配符 # 反斜杠转义星号\nexpr 100 \\* 4 变量运算 # "},{"id":198,"href":"/zh/docs/technology/RegExp/baseCoreySchafer_/base/","title":"基础","section":"基础(CoreySchafer)_","content":" 环境 # 使用视频作者给出的示例,https://github.com/CoreyMSchafer/code_snippets/tree/master/Regular-Expressions 使用sublimeText打开的文件,ctrl+f时要确认勾选正则及区分大小写\nsimple.txt-基础操作 # 直接搜索 # 任意字符 # 这里默认不会显示所有,点击findAll才会出来\n有些字符需要加反斜杠转义,比如 . (点)以及 \\ (斜杠本身) # /////,从左到右,和书写方向一致的叫做(正)斜杠。\n反之,叫做反斜杠 \\\n一些元字符 # . - Any Character Except New Line 除了换行符的任意字符 \\d - Digit (0-9) 数字 \\D - Not a Digit (0-9) 非数字 \\w - Word Character (a-z, A-Z, 0-9, _) 单词字符,大小写字母+数字+下划线 \\W - Not a Word Character 非单词字符 \\s - Whitespace (space, tab, newline) 空白字符,空格+tab+换行符 \\S - Not Whitespace (space, tab, newline) 非空白字符 \\b - Word Boundary 边界字符-单词边界 \\B - Not a Word Boundary 非单词边界(没有单词边界) ^ - Beginning of a String $ - End of a String [] - Matches Characters in brackets [^ ] - Matches Characters NOT in brackets | - Either Or ( ) - Group Quantifiers: * - 0 or More + - 1 or More ? - 0 or One {3} - Exact Number {3,4} - Range of Numbers (Minimum, Maximum) #### Sample Regexs #### [a-zA-Z0-9_.+-]+@[a-zA-Z0-9-]+\\.[a-zA-Z0-9-.]+ 边界字符 # 非边界字符 # 事例 # 数字 # 方括号,或者 # 破折号是有特殊意义的(表示范围),比如1-9,a-z,但是处于方括号中的开头或者结尾,就是普通的破折号\n范围 # 任意的小写字母或者大写字母\n尖叫符号表示非,排除,否定 # 匹配多次(大括号,数字) # (|)组 或者关系,?出现或不出现 ,* 出现几次都行 # 综合事例 # 邮箱1 # 邮箱2 # URL # 匹配 # 分组并且反向引用 # 这里有个没展示,$0 表示匹配的内容,这里指的是从http一直到结束\n"},{"id":199,"href":"/zh/docs/test/hello2/","title":"pdfTest","section":"测试","content":"sd44sdf\ns2345df\nsssdfadf\n111\n"},{"id":200,"href":"/zh/docs/technology/Hugo/themes/PaperMod/01/","title":"使用PaperMode","section":"主题","content":" 地址 # 官方: https://github.com/adityatelange/hugo-PaperMod/wiki/Installation (有些东西没有同hugo官方同步) 非官方: https://github.com/vanitysys28/hugo-papermod-wiki/blob/master/Home.md (与hugo官方更同步)\n安装 # hugo new site blog.source --format yaml cd blog.source git init git submodule add --depth=1 https://github.com/adityatelange/hugo-PaperMod.git themes/PaperMod git submodule update --init --recursive # needed when you reclone your repo (submodules may not get cloned automatically) git submodule update --remote --merge "},{"id":201,"href":"/zh/docs/technology/Hugo/GiraffeAcademy_/advanced20-23/","title":"hugo进阶学习20-23","section":"基础(Giraffe学院)_","content":"\nDateFiles # {% raw %} { \u0026#34;classA\u0026#34;:\u0026#34;json位置: data\\\\classes.json\u0026#34;, \u0026#34;classA\u0026#34;:{ \u0026#34;master\u0026#34;:\u0026#34;xiaoLi\u0026#34;, \u0026#34;number\u0026#34;:\u0026#34;05\u0026#34; }, \u0026#34;classB\u0026#34;:{ \u0026#34;master\u0026#34;:\u0026#34;aXiang\u0026#34;, \u0026#34;number\u0026#34;:\u0026#34;15\u0026#34; }, \u0026#34;classC\u0026#34;:{ \u0026#34;master\u0026#34;:\u0026#34;BaoCeng\u0026#34;, \u0026#34;number\u0026#34;:\u0026#34;20\u0026#34; } } {% endraw %} 模板代码\n{% raw %} {{/* layouts\\_default\\single.html */}} {{ define \u0026#34;main\u0026#34; }} {{ range .Site.Data.classes }} master:{{.master}}==number:{{.number}}\u0026lt;br\u0026gt; {{end}} {{end}} {% endraw %} PartialTemplates # 传递全局范围 # {% raw %} {{/*layouts\\partials\\header.html*/}} \u0026lt;h1\u0026gt;{{.Title}}\u0026lt;/h1\u0026gt; \u0026lt;p\u0026gt;{{.Date}}\u0026lt;/p\u0026gt; {% endraw %} {% raw %} {{/*layouts\\_default\\single.html*/}} {{ define \u0026#34;main\u0026#34; }} {{ partial \u0026#34;header\u0026#34; . }} {{/*点.传递了当前文件的范围,代表了所有的范围,所有可以访问的变量*/}} \u0026lt;hr\u0026gt; {{end}} {% endraw %} 预览:\n传递字典 # {% raw %} {{/* layouts\\partials\\header.html */}} {{ partial \u0026#34;header\u0026#34; (dict \u0026#34;myTitle\u0026#34; \u0026#34;myCustomTitle\u0026#34; \u0026#34;myDate\u0026#34; \u0026#34;myCustomDate\u0026#34; ) }} {{/* partial \u0026#34;header\u0026#34; . 同一个partial只能在一个地方出现一次?这里会报错,不知道为啥*/}} \u0026lt;hr\u0026gt; {% endraw %} 使用:\n{% raw %} {{/*layouts\\partials\\header.html*/}} \u0026lt;h1\u0026gt;{{.myTitle}}\u0026lt;/h1\u0026gt; \u0026lt;p\u0026gt;{{.myDate}}\u0026lt;/p\u0026gt; {% endraw %} 效果:\nShortCodeTemplate # 效果图 # 记得先在a相关的template把 .Content 补上 # 代码片段的使用 # {% raw %} --- title: \u0026#34;This is A\u0026#39;s title\u0026#34; date: 2004-12-04T12:42:49+08:00 draft: true author: \u0026#34;Mike\u0026#34; color: \u0026#34;blue\u0026#34; --- This is A. {{\u0026lt; myshortcode color=\u0026#34;blue\u0026#34; \u0026gt;}} {{\u0026lt; myshortcode2 red \u0026gt;}} {{\u0026lt; myshortcode-p \u0026gt;}} This is the test inside the shortcode tags.. sdf d---end {{\u0026lt; /myshortcode-p \u0026gt;}} 下面没有被渲染: {{\u0026lt; myshortcode-p \u0026gt;}} **bold text** {{\u0026lt; /myshortcode-p \u0026gt;}} 下面被渲染了,但是没有被片段处理: {{% myshortcode-p %}} **bold text**xxx {{% /myshortcode-p %}} {%/* endraw */%} 代码片段的编写 # 等号键值对 # {% raw %} \u0026lt;!--layouts\\shortcodes\\myshortcode.html--\u0026gt; \u0026lt;p style=\u0026#34;color:{{.Get `color`}}\u0026#34;\u0026gt;This is my shortcode text\u0026lt;/p\u0026gt; {% endraw %} 直接写值 # {% raw %} \u0026lt;!--layouts\\shortcodes\\myshortcode2.html--\u0026gt; \u0026lt;p style=\u0026#34;color:{{.Get 0}}\u0026#34;\u0026gt;This is my shortcode text\u0026lt;/p\u0026gt; {% endraw %} 获取多行大量文字 # {% raw %} \u0026lt;!--layouts\\shortcodes\\myshortcode-p.html--\u0026gt; \u0026lt;p style=\u0026#34;background-color: yellow;\u0026#34;\u0026gt;{{.Inner}}\u0026lt;/p\u0026gt; {% endraw %} 如何构建网站及托管 # 使用hugo server运行并打开网站(平常测试) 使用hugo生成静态网页文件夹/public/ 把上面的/public/下的所有文件传到网络服务器即可 进行第三步之前,得先把原先传到网络服务器上的/public/的内容清空 "},{"id":202,"href":"/zh/docs/technology/Hugo/GiraffeAcademy_/advanced17-19/","title":"hugo进阶学习17-19","section":"基础(Giraffe学院)_","content":"\nVariable # 文件结构 # 实战 # {% raw %} {{/*layouts\\_default\\single.html*/}} {{ define \u0026#34;main\u0026#34; }} This is the single template\u0026lt;br\u0026gt; {{/* 常见变量 */}} title: {{ .Params.title }}\u0026lt;br\u0026gt; title: {{ .Title }}\u0026lt;br\u0026gt; date: {{ .Date }}\u0026lt;br\u0026gt; url: {{ .URL }}\u0026lt;br\u0026gt; myvar: {{ .Params.myVar }}\u0026lt;br\u0026gt; {{/* 定义变量 */}} {{ $myVarname := \u0026#34;aString\u0026#34; }} myVarname:{{ $myVarname }}\u0026lt;br\u0026gt; \u0026lt;h1 style=\u0026#34;color: {{ .Params.color }} ;\u0026#34; \u0026gt;Single Template\u0026lt;/h1\u0026gt; {{ end }} {% endraw %} {% raw %} --- title: \u0026#34;E-title\u0026#34; date: 2024-12-07T12:43:21+08:00 draft: true myVar: \u0026#34;myvalue\u0026#34; color: \u0026#34;red\u0026#34; --- This is dir3/e.md {% endraw %} 其他两个文件效果\n{% raw %} --- title: \u0026#34;F\u0026#34; date: 2024-12-07T12:43:21+08:00 draft: true color: \u0026#34;green\u0026#34; --- This is dir3/f.md {% endraw %} {% raw %} --- title: \u0026#34;This is A\u0026#39;s title\u0026#34; date: 2004-12-04T12:42:49+08:00 draft: true author: \u0026#34;Mike\u0026#34; color: \u0026#34;blue\u0026#34; --- This is A,/a. {% endraw %} 效果:\n官网详细默认变量 # hugo variables。\nFunctions函数 # 文件结构 # 代码(模板) # 注意,下面全是dir1下的模板,只对/dir1/及其下文件有效\nbaseof.html\n{% raw %} {{/*layouts\\_default\\baseof.html*/}} \u0026lt;html lang=\u0026#34;en\u0026#34;\u0026gt; \u0026lt;head\u0026gt; \u0026lt;meta charset=\u0026#34;UTF-8\u0026#34;\u0026gt; \u0026lt;meta name=\u0026#34;viewport\u0026#34; content=\u0026#34;width=device-width, initial-scale=1.0\u0026#34;\u0026gt; \u0026lt;title\u0026gt;Document\u0026lt;/title\u0026gt; \u0026lt;/head\u0026gt; \u0026lt;body\u0026gt; {{ block \u0026#34;main\u0026#34; . }} 33 {{ end }} \u0026lt;/body\u0026gt; \u0026lt;/html\u0026gt; {% endraw %} single.html\n{% raw %} {{/*layouts\\dir1\\single.html*/}} \u0026lt;html\u0026gt; \u0026lt;head\u0026gt; \u0026lt;meta charset=\u0026#34;UTF-8\u0026#34;\u0026gt; \u0026lt;meta name=\u0026#34;viewport\u0026#34; content=\u0026#34;width=device-width, initial-scale=1.0\u0026#34;\u0026gt; \u0026lt;meta http-equiv=\u0026#34;X-UA-Compatible\u0026#34; content=\u0026#34;ie=edge\u0026#34;\u0026gt; \u0026lt;title\u0026gt;Document111\u0026lt;/title\u0026gt; \u0026lt;/head\u0026gt; \u0026lt;body\u0026gt; Dir1Template,see! \u0026lt;h1\u0026gt;Header\u0026lt;/h1\u0026gt; \u0026lt;h3\u0026gt;{{.Title}}\u0026lt;/h3\u0026gt; \u0026lt;h4\u0026gt;{{.Date}}\u0026lt;/h4\u0026gt; \u0026lt;!--特殊项--\u0026gt; {{.Content}} \u0026lt;h1\u0026gt;Footer\u0026lt;/h1\u0026gt; \u0026lt;hr\u0026gt; {{ truncate 10 \u0026#34;This is a really long string\u0026#34;}}\u0026lt;br\u0026gt; {{ add 1 5 }}\u0026lt;br\u0026gt; {{ sub 1 5 }}\u0026lt;br\u0026gt; {{ singularize \u0026#34;dogs\u0026#34; }} \u0026lt;br\u0026gt; {{/*下面完全没有输出,因为不是list page*/}} {{ range .Pages }} {{ .Title }}\u0026lt;br\u0026gt; {{ end }} \u0026lt;/body\u0026gt; \u0026lt;/html\u0026gt; {% endraw %} 对于上面的single.html生成的html源码:\n{% raw %} \u0026lt;html\u0026gt; \u0026lt;head\u0026gt; \u0026lt;meta charset=\u0026#34;UTF-8\u0026#34;\u0026gt; \u0026lt;meta name=\u0026#34;viewport\u0026#34; content=\u0026#34;width=device-width, initial-scale=1.0\u0026#34;\u0026gt; \u0026lt;meta http-equiv=\u0026#34;X-UA-Compatible\u0026#34; content=\u0026#34;ie=edge\u0026#34;\u0026gt; \u0026lt;title\u0026gt;Document111\u0026lt;/title\u0026gt; \u0026lt;!--注意这里,说明完全使用layouts\\dir1\\single.html作为模板,跟baseof.html无关--\u0026gt; \u0026lt;/head\u0026gt; \u0026lt;body\u0026gt; Dir1Template,see! \u0026lt;h1\u0026gt;Header\u0026lt;/h1\u0026gt; \u0026lt;h3\u0026gt;B-title\u0026lt;/h3\u0026gt; \u0026lt;h4\u0026gt;2024-12-07 12:43:21 \u0026amp;#43;0800 CST\u0026lt;/h4\u0026gt; \u0026lt;p\u0026gt;This is dir1/b.md\u0026lt;/p\u0026gt; \u0026lt;h1\u0026gt;Footer\u0026lt;/h1\u0026gt; \u0026lt;hr\u0026gt; This is a …\u0026lt;br\u0026gt; 6\u0026lt;br\u0026gt; -4\u0026lt;br\u0026gt; dog \u0026lt;br\u0026gt; \u0026lt;script data-no-instant\u0026gt;document.write(\u0026#39;\u0026lt;script src=\u0026#34;/livereload.js?port=1313\u0026amp;mindelay=10\u0026#34;\u0026gt;\u0026lt;/\u0026#39; + \u0026#39;script\u0026gt;\u0026#39;)\u0026lt;/script\u0026gt;\u0026lt;/body\u0026gt; \u0026lt;/html\u0026gt; {% endraw %} list.html\n{% raw %} {{/* layouts\\dir1\\list.html */}} {{ define \u0026#34;main\u0026#34; }} This is the listTemplate for dir1;\u0026lt;br\u0026gt; {{/*下面只输出了dir1下的所有文件(包括子文件夹)*/}} {{ range .Pages }} {{ .Title }}\u0026lt;br\u0026gt; {{ end }} {{ end }} {% endraw %} IfStatements # 文件结构 # if代码演示 # {% raw %} {{/* layouts\\_default\\single.html */}} {{ define \u0026#34;main\u0026#34; }} This is the listTemplate\u0026lt;br\u0026gt; {{ $var1 := \u0026#34;dog\u0026#34; }} {{ $var2 := \u0026#34;cat\u0026#34; }} {{ if ge $var1 $var2 }} True {{ else }} False {{ end }} \u0026lt;br\u0026gt; {{ $var3 := 6 }} {{ $var4 := 4 }} {{ $var5 := 1 }} {{ if and (le $var3 $var4) (lt $var3 $var5) }} var3 is minist {{ else if and (le $var4 $var3) (lt $var4 $var5)}} var4 is minist {{ else }} var5 is minist {{ end }} \u0026lt;br\u0026gt; {{ end }} {% endraw %} 其他代码展示 # {% raw %} \u0026lt;!--layouts\\dir1\\single.html--\u0026gt; \u0026lt;html\u0026gt; \u0026lt;head\u0026gt; \u0026lt;meta charset=\u0026#34;UTF-8\u0026#34;\u0026gt; \u0026lt;meta name=\u0026#34;viewport\u0026#34; content=\u0026#34;width=device-width, initial-scale=1.0\u0026#34;\u0026gt; \u0026lt;meta http-equiv=\u0026#34;X-UA-Compatible\u0026#34; content=\u0026#34;ie=edge\u0026#34;\u0026gt; \u0026lt;title\u0026gt;Document111\u0026lt;/title\u0026gt; \u0026lt;/head\u0026gt; \u0026lt;body\u0026gt; {{ $title := .Title }} {{/* 注意,这里遍历的是整个网站(.Site)的文件 */}} {{ range .Site.Pages }} \u0026lt;a href=\u0026#34;{{.URL}}\u0026#34; style=\u0026#34; {{ if eq .Title $title }} background-color: red; {{ end }} \u0026#34;\u0026gt;{{.Title}}\u0026lt;/a\u0026gt; {{ end }} \u0026lt;hr\u0026gt; \u0026lt;/body\u0026gt; \u0026lt;/html\u0026gt; {% endraw %} "},{"id":203,"href":"/zh/docs/technology/Hugo/GiraffeAcademy_/advanced11-16/","title":"hugo进阶学习11-15","section":"基础(Giraffe学院)_","content":" 这里使用的版本是v0.26(很久之前的版本)\ntemplate basic # 模板分为list template和single template\n文件夹结构 # content目录结构\nlist template (列表模板) # single template (单页模板) # 特点 # 所有的列表之间都是长一样的(页眉,页脚,及内容(都是列表))\n所有的单页之间都是长一样的(一样的页眉页脚,一样的内容布局)\n部分代码解释 # 单页探索 # list page templates # 文件夹结构 # 文件内容 # #content/_index --- title: \u0026#34;_Index\u0026#34; --- This is the home page #content/dir1/_index --- title: \u0026#34;_Index\u0026#34; --- This is the landing page for dir1 当前效果 # 原因 # {% raw %} \u0026lt;!--themes\\ga-hugo-theme\\layouts\\_default\\list.html--\u0026gt; \u0026lt;html\u0026gt; \u0026lt;head\u0026gt; \u0026lt;meta charset=\u0026#34;UTF-8\u0026#34;\u0026gt; \u0026lt;meta name=\u0026#34;viewport\u0026#34; content=\u0026#34;width=device-width, initial-scale=1.0\u0026#34;\u0026gt; \u0026lt;meta http-equiv=\u0026#34;X-UA-Compatible\u0026#34; content=\u0026#34;ie=edge\u0026#34;\u0026gt; \u0026lt;title\u0026gt;Document\u0026lt;/title\u0026gt; \u0026lt;/head\u0026gt; \u0026lt;body\u0026gt; {{ partial \u0026#34;header\u0026#34; (dict \u0026#34;Kind\u0026#34; .Kind \u0026#34;Template\u0026#34; \u0026#34;List\u0026#34;) }} {{.Content}} {{ range .Pages }} \u0026lt;div style=\u0026#34;border: 1px solid black; margin:10px; padding:10px; \u0026#34;\u0026gt; \u0026lt;div style=\u0026#34;font-size:20px;\u0026#34;\u0026gt; \u0026lt;a href=\u0026#34;{{.URL}}\u0026#34;\u0026gt;{{.Title}}\u0026lt;/a\u0026gt; \u0026lt;/div\u0026gt; \u0026lt;div style=\u0026#34;color:grey; font-size:16px;\u0026#34;\u0026gt;{{ dateFormat \u0026#34;Monday, Jan 2, 2006\u0026#34; .Date }}\u0026lt;/div\u0026gt; \u0026lt;div style=\u0026#34;color:grey; font-size:16px;\u0026#34;\u0026gt;{{ if .Params.tags }}\u0026lt;strong\u0026gt;Tags:\u0026lt;/strong\u0026gt; {{range .Params.tags}}\u0026lt;a href=\u0026#34;{{ \u0026#34;/tags/\u0026#34; | relLangURL }}{{ . | urlize }}\u0026#34;\u0026gt;{{ . }}\u0026lt;/a\u0026gt; {{end}}{{end}}\u0026lt;/div\u0026gt; \u0026lt;div style=\u0026#34;color:grey; font-size:16px;\u0026#34;\u0026gt;{{ if .Params.categories }}\u0026lt;strong\u0026gt;Categories:\u0026lt;/strong\u0026gt; {{range .Params.categories}}\u0026lt;a href=\u0026#34;{{ \u0026#34;/categories/\u0026#34; | relLangURL }}{{ . | urlize }}\u0026#34;\u0026gt;{{ . }}\u0026lt;/a\u0026gt; {{end}}{{end}}\u0026lt;/div\u0026gt; \u0026lt;div style=\u0026#34;color:grey; font-size:16px;\u0026#34;\u0026gt;{{ if .Params.moods }}\u0026lt;strong\u0026gt;Moods:\u0026lt;/strong\u0026gt; {{range .Params.moods}}\u0026lt;a href=\u0026#34;{{ \u0026#34;/moods/\u0026#34; | relLangURL }}{{ . | urlize }}\u0026#34;\u0026gt;{{ . }}\u0026lt;/a\u0026gt; {{end}}{{end}}\u0026lt;/div\u0026gt; \u0026lt;p style=\u0026#34;font-size:18px;\u0026#34;\u0026gt;{{.Summary}}\u0026lt;/p\u0026gt; \u0026lt;/div\u0026gt; {{ end }} {{ partial \u0026#34;footer\u0026#34; . }} \u0026lt;/body\u0026gt; \u0026lt;/html\u0026gt; {% endraw %} 覆盖默认的list template # 编辑文件并保存\n{% raw %} \u0026lt;!--layouts\\_default\\list.html--\u0026gt; \u0026lt;html\u0026gt; \u0026lt;head\u0026gt; \u0026lt;meta charset=\u0026#34;UTF-8\u0026#34;\u0026gt; \u0026lt;meta name=\u0026#34;viewport\u0026#34; content=\u0026#34;width=device-width, initial-scale=1.0\u0026#34;\u0026gt; \u0026lt;meta http-equiv=\u0026#34;X-UA-Compatible\u0026#34; content=\u0026#34;ie=edge\u0026#34;\u0026gt; \u0026lt;title\u0026gt;Document\u0026lt;/title\u0026gt; \u0026lt;/head\u0026gt; \u0026lt;body\u0026gt; {{.Content}} \u0026lt;!--显示对应的目录下的_index.md内容--\u0026gt; {{ range .Pages }} \u0026lt;!--枚举对应目录下所有页面(.md)--\u0026gt; \u0026lt;ul\u0026gt; \u0026lt;!--.URL 文件路径,类似 /a或者/dir1/b--\u0026gt; \u0026lt;!--.Title md中的前言-title字段--\u0026gt; \u0026lt;li\u0026gt;\u0026lt;a href=\u0026#34;{{.URL}}\u0026#34;\u0026gt;{{.Title}}\u0026lt;/a\u0026gt;\u0026lt;/li\u0026gt; \u0026lt;/ul\u0026gt; {{end}} \u0026lt;/body\u0026gt; \u0026lt;/html\u0026gt; {% endraw %} 效果 # list template简易版\nsingle template # 当前效果 # 主题默认代码 # {% raw %} \u0026lt;!-- themes\\ga-hugo-theme\\layouts\\_default\\single.html --\u0026gt; \u0026lt;html\u0026gt; \u0026lt;head\u0026gt; \u0026lt;meta charset=\u0026#34;UTF-8\u0026#34;\u0026gt; \u0026lt;meta name=\u0026#34;viewport\u0026#34; content=\u0026#34;width=device-width, initial-scale=1.0\u0026#34;\u0026gt; \u0026lt;meta http-equiv=\u0026#34;X-UA-Compatible\u0026#34; content=\u0026#34;ie=edge\u0026#34;\u0026gt; \u0026lt;title\u0026gt;Document\u0026lt;/title\u0026gt; \u0026lt;/head\u0026gt; \u0026lt;body\u0026gt; {{ partial \u0026#34;header\u0026#34; (dict \u0026#34;Kind\u0026#34; .Kind \u0026#34;Template\u0026#34; \u0026#34;Single\u0026#34;) }} \u0026lt;p\u0026gt;Test test\u0026lt;/p\u0026gt; \u0026lt;div style=\u0026#34;margin:25px;\u0026#34;\u0026gt; \u0026lt;h1\u0026gt;{{.Title}}\u0026lt;/h1\u0026gt; \u0026lt;div style=\u0026#34;color:grey; font-size:16px;\u0026#34;\u0026gt;{{ dateFormat \u0026#34;Monday, Jan 2, 2006\u0026#34; .Date }}\u0026lt;/div\u0026gt; \u0026lt;div style=\u0026#34;color:grey; font-size:16px;\u0026#34;\u0026gt;{{if .Params.author}}Author: {{.Params.Author}}{{end}}\u0026lt;/div\u0026gt; \u0026lt;div style=\u0026#34;font-size:18px;\u0026#34;\u0026gt;{{.Content}}\u0026lt;/div\u0026gt; \u0026lt;/div\u0026gt; {{ partial \u0026#34;footer\u0026#34; . }} \u0026lt;/body\u0026gt; \u0026lt;/html\u0026gt; {% endraw %} 改编 # {% raw %} \u0026lt;!--layouts\\_default\\single.html--\u0026gt; \u0026lt;html\u0026gt; \u0026lt;head\u0026gt; \u0026lt;meta charset=\u0026#34;UTF-8\u0026#34;\u0026gt; \u0026lt;meta name=\u0026#34;viewport\u0026#34; content=\u0026#34;width=device-width, initial-scale=1.0\u0026#34;\u0026gt; \u0026lt;meta http-equiv=\u0026#34;X-UA-Compatible\u0026#34; content=\u0026#34;ie=edge\u0026#34;\u0026gt; \u0026lt;title\u0026gt;Document\u0026lt;/title\u0026gt; \u0026lt;/head\u0026gt; \u0026lt;body\u0026gt; \u0026lt;h1\u0026gt;Header\u0026lt;/h1\u0026gt; \u0026lt;h3\u0026gt;{{.Title}}\u0026lt;/h3\u0026gt; \u0026lt;h4\u0026gt;{{.Date}}\u0026lt;/h4\u0026gt; \u0026lt;!--特殊项--\u0026gt; {{.Content}} \u0026lt;h1\u0026gt;Footer\u0026lt;/h1\u0026gt; \u0026lt;/body\u0026gt; \u0026lt;/html\u0026gt; {% endraw %} 效果\nhome template # 是什么 # 前面学到,页面分为“列表页面list page”和“单页页面”。其实再细分还有一种“主页页面home page”。 主页,即 localhost:1313 是先使用homepage,找不到的情况,才会使用list page 目录结构 # 当前效果 # 修改文件代码 # {% raw %} \u0026lt;!--layouts\\index.html--\u0026gt; Home Page Template {% endraw %} 效果 # SectionTemplate # 当前目录结构 # 目的 # 不用理会a.md使用哪个当single template。而dir1文件夹下的所有md,都是用同一个single template。\n目前content下所有md文件详情:a.md使用layouts/index.html当模板(没有的话则找layouts/_default/index.html当模板)。b.md和c.md、e.md、d.md、f.md均使用layouts/_default/index.html当模板\n代码\n{% raw %} \u0026lt;!--layouts\\dir1\\single.html--\u0026gt; \u0026lt;html\u0026gt; \u0026lt;head\u0026gt; \u0026lt;meta charset=\u0026#34;UTF-8\u0026#34;\u0026gt; \u0026lt;meta name=\u0026#34;viewport\u0026#34; content=\u0026#34;width=device-width, initial-scale=1.0\u0026#34;\u0026gt; \u0026lt;meta http-equiv=\u0026#34;X-UA-Compatible\u0026#34; content=\u0026#34;ie=edge\u0026#34;\u0026gt; \u0026lt;title\u0026gt;Document\u0026lt;/title\u0026gt; \u0026lt;/head\u0026gt; \u0026lt;body\u0026gt; Dir1Template,see! \u0026lt;h1\u0026gt;Header\u0026lt;/h1\u0026gt; \u0026lt;h3\u0026gt;{{.Title}}\u0026lt;/h3\u0026gt; \u0026lt;h4\u0026gt;{{.Date}}\u0026lt;/h4\u0026gt; \u0026lt;!--特殊项--\u0026gt; {{.Content}} \u0026lt;h1\u0026gt;Footer\u0026lt;/h1\u0026gt; \u0026lt;/body\u0026gt; \u0026lt;/html\u0026gt; {% endraw %} 结果 # 其他的走默认模板 layouts\\_default\\single.html\nBase Templates \u0026amp;\u0026amp; Blocks Hugo # 是什么 # BaseTemplate就是这个网站的总体模板\n案例 # 目录结构 # 编辑文件 # baseof.html\n{% raw %} \u0026lt;!--layouts\\_default\\baseof.html--\u0026gt; \u0026lt;html lang=\u0026#34;en\u0026#34;\u0026gt; \u0026lt;head\u0026gt; \u0026lt;meta charset=\u0026#34;UTF-8\u0026#34;\u0026gt; \u0026lt;meta name=\u0026#34;viewport\u0026#34; content=\u0026#34;width=device-width, initial-scale=1.0\u0026#34;\u0026gt; \u0026lt;title\u0026gt;Document\u0026lt;/title\u0026gt; \u0026lt;/head\u0026gt; \u0026lt;body\u0026gt; \u0026lt;!--Hugo实体快,Block--\u0026gt; {{ block \u0026#34;main\u0026#34; . }} {{end}} \u0026lt;/body\u0026gt; \u0026lt;/html\u0026gt; {% endraw %} single.html\n不要用html5的\u0026lt;!----!\u0026gt;注释,会出问题\n{% raw %} {{ define \u0026#34;main\u0026#34; }} This is the single template {{ end }} {% endraw %} list.html\n{% raw %} {{/* layouts\\_default\\single.html */}} {{ define \u0026#34;main\u0026#34; }} This is the listTemplate {{ end }} {% endraw %} 效果\n"},{"id":204,"href":"/zh/docs/technology/Hugo/GiraffeAcademy_/advanced01-10/","title":"hugo进阶学习01-10","section":"基础(Giraffe学院)_","content":" 系列视频地址介绍\nhttps://www.youtube.com/watch?v=qtIqKaDlqXo\u0026list=PLLAZ4kZ9dFpOnyRlyS-liKL5ReHDcj4G3\n介绍 # hugo是用来构建静态网站的 但是也可以稍微做点动态生成的事 这里使用的版本是v0.26(很久之前的版本) 备注:标题短代码之前(不包括短代码这篇)的笔记是回溯的,所以没有复制源代码下来,直接在视频再次截图的\n在Windows上安装hugo # 到github release下载,然后放到某个文件夹中\n设置环境变量\n验证环境变量\n最后验证hugo版本 hugo version 创建一个新的网站 # 使用代码生成 hugo new site 文件夹结构\n使用主题 # 这里是https://themes.gohugo.io\n这里使用的是ga-hugo-theme(github中查找),并放到themes文件夹中\n之后在config.toml中使用主题\nbaseURL = \u0026#34;http://example.org/\u0026#34; languageCode = \u0026#34;en-us\u0026#34; title = \u0026#34;My New Hugo Site\u0026#34; theme = \u0026#34;ga-hugo-theme\u0026#34; #添加这句话 启动博客\nhugo serve 地址\nlocalhost:1313 创建md文件 # 使用hugo new a.md把文件创建在content/a.md或者hugo new dir2/d.md把文件创建在content/dir2.md下,这讲创建后的结构目录为\n总共5个文件,可以使用localhost:1313访问博客(默认列举所有(包括子文件夹)文件 可以使用 localhost:1313/dir3访问dir3下所有文件列表(list),localhost:1313/dir1访问dir1下所有文件列表 (都是content的直接子文件夹) 如果没有dir1/dir2/_index.md这个文件 ,则不能直接使用localhost:1313/dir1/dir2访问dir1/dir2下所有文件 查看dir1/dir2/index.md文件及效果\nfrontmatter (前言) # 可以使用YAML,TOML,或者JSON md编码及效果\narchetypes(原型) # 默认的原型文件 # archetypes/default.md\n{% raw %} --- title: \u0026#34;{{ replace .TranslationBaseName \u0026#34;-\u0026#34; \u0026#34; \u0026#34; | title }}\u0026#34; date: {{ .Date }} draft: true author: \u0026#34;Mike\u0026#34; --- {% endraw %} 使用命令行hugo new b.md结果\n和文件夹结构相关的原型文件 # 使用命令行hugo new dir1/c.md结果\n如果hugo new dir1/c.md时archetypes/dir1.md不存在,则才会去找archetypes/default.md当模板创建文件\nshortcodes 短代码 # 代码 # 放到markdown文件中(这个youtube是官方支持的内嵌的)\n{% raw %} {{/*\u0026lt; youtube w7Ft2ymGmfc \u0026gt;*/}} {% endraw %} 效果 # taxonomies(分类法) # 默认的两个分类 # 比如修改了总共三个文件 (隐去其他前言数据)\n{% raw %} --- # a.md title: \u0026#34;A\u0026#34; tags: [\u0026#34;tag1\u0026#34;,\u0026#34;tag2\u0026#34;,\u0026#34;tag3\u0026#34;] categories: [\u0026#34;cat1\u0026#34;] --- # b.md --- title: \u0026#34;B\u0026#34; tags: [\u0026#34;tag2\u0026#34; ] categories: [\u0026#34;cat2\u0026#34;] --- # c.md --- title: \u0026#34;C\u0026#34; tags: [\u0026#34;tag3\u0026#34;] categories: [\u0026#34;cat2\u0026#34;] --- {% endraw %} 效果:\n点击tag2时效果\n点击cat1时的效果\n自定义分类 # {% raw %} # a.md添加最后一行,最后代码(忽略其他属性) --- title: \u0026#34;A tags: [\u0026#34;tag1\u0026#34;,\u0026#34;tag2\u0026#34;,\u0026#34;tag3\u0026#34;] categories: [\u0026#34;cat1\u0026#34;] moods: [\u0026#34;Happy\u0026#34;,\u0026#34;Upbeat\u0026#34;] --- {% endraw %} 以及修改config.toml文件\n{% raw %} baseURL = \u0026#34;http://example.org/\u0026#34; languageCode = \u0026#34;en-us\u0026#34; title = \u0026#34;My New Hugo Site\u0026#34; theme = \u0026#34;ga-hugo-theme\u0026#34; [taxonomies] #添加这行及以下三行 tag = \u0026#34;tags\u0026#34; category = \u0026#34;categories\u0026#34; mood = \u0026#34;moods\u0026#34; {% endraw %} 效果:\n"},{"id":205,"href":"/zh/docs/technology/Obsidian/border-theme/","title":"border-theme背景图片问题","section":"Obsidian","content":" svg格式作为背景图片(简单图片可行) # 以下面这张图片为例\n最简单的方式,用记事本/文本编辑器,打开svg图片,全选,复制,即\n\u0026lt;svg xmlns=\u0026#34;http://www.w3.org/2000/svg\u0026#34; viewBox=\u0026#34;0 0 1920 1080\u0026#34;\u0026gt;\u0026lt;g transform=\u0026#34; rotate(0 960 540) translate(-0 -0) scale(1) \u0026#34;\u0026gt;\u0026lt;rect width=\u0026#34;1920\u0026#34; height=\u0026#34;1080\u0026#34; fill=\u0026#34;rgb(184, 171, 255)\u0026#34;\u0026gt;\u0026lt;/rect\u0026gt;\u0026lt;g transform=\u0026#34;translate(0, 0)\u0026#34;\u0026gt;\u0026lt;path fill=\u0026#34;rgb(131, 114, 218)\u0026#34; fill-opacity=\u0026#34;1\u0026#34; d=\u0026#34;M0,352.943L45.714,350.075C91.429,347.207,182.857,341.471,274.286,340.581C365.714,339.692,457.143,343.65,548.571,344.095C640,344.54,731.429,341.472,822.857,303.183C914.286,264.894,1005.714,191.383,1097.143,185.175C1188.571,178.967,1280,240.06,1371.429,221.336C1462.857,202.612,1554.286,104.069,1645.714,98.48C1737.143,92.892,1828.571,180.258,1874.286,223.941L1920,267.624L1920,1080L1874.286,1080C1828.571,1080,1737.143,1080,1645.714,1080C1554.286,1080,1462.857,1080,1371.429,1080C1280,1080,1188.571,1080,1097.143,1080C1005.714,1080,914.286,1080,822.857,1080C731.429,1080,640,1080,548.571,1080C457.143,1080,365.714,1080,274.286,1080C182.857,1080,91.429,1080,45.714,1080L0,1080Z\u0026#34;\u0026gt;\u0026lt;/path\u0026gt;\u0026lt;/g\u0026gt;\u0026lt;g transform=\u0026#34;translate(0, 360)\u0026#34;\u0026gt;\u0026lt;path fill=\u0026#34;rgb(79, 57, 180)\u0026#34; fill-opacity=\u0026#34;1\u0026#34; d=\u0026#34;M0,136.093L45.714,117.434C91.429,98.774,182.857,61.455,274.286,80.719C365.714,99.983,457.143,175.829,548.571,189.505C640,203.181,731.429,154.687,822.857,130.414C914.286,106.141,1005.714,106.09,1097.143,141.274C1188.571,176.458,1280,246.877,1371.429,284.697C1462.857,322.517,1554.286,327.739,1645.714,284.675C1737.143,241.611,1828.571,150.263,1874.286,104.589L1920,58.914L1920,720L1874.286,720C1828.571,720,1737.143,720,1645.714,720C1554.286,720,1462.857,720,1371.429,720C1280,720,1188.571,720,1097.143,720C1005.714,720,914.286,720,822.857,720C731.429,720,640,720,548.571,720C457.143,720,365.714,720,274.286,720C182.857,720,91.429,720,45.714,720L0,720Z\u0026#34;\u0026gt;\u0026lt;/path\u0026gt;\u0026lt;/g\u0026gt;\u0026lt;g transform=\u0026#34;translate(0, 720)\u0026#34;\u0026gt;\u0026lt;path fill=\u0026#34;rgb(26, 0, 143)\u0026#34; fill-opacity=\u0026#34;1\u0026#34; d=\u0026#34;M0,107.121L45.714,134.307C91.429,161.493,182.857,215.866,274.286,254.33C365.714,292.794,457.143,315.35,548.571,300.514C640,285.679,731.429,233.452,822.857,180.313C914.286,127.174,1005.714,73.123,1097.143,43.365C1188.571,13.606,1280,8.141,1371.429,41.079C1462.857,74.017,1554.286,145.358,1645.714,167.782C1737.143,190.206,1828.571,163.713,1874.286,150.467L1920,137.221L1920,360L1874.286,360C1828.571,360,1737.143,360,1645.714,360C1554.286,360,1462.857,360,1371.429,360C1280,360,1188.571,360,1097.143,360C1005.714,360,914.286,360,822.857,360C731.429,360,640,360,548.571,360C457.143,360,365.714,360,274.286,360C182.857,360,91.429,360,45.714,360L0,360Z\u0026#34;\u0026gt;\u0026lt;/path\u0026gt;\u0026lt;/g\u0026gt;\u0026lt;/g\u0026gt;\u0026lt;/svg\u0026gt; 之后打开https://codepen.io/yoksel/details/MWKeKK 网站,在 Insert your SVG中粘贴,得到\n最后把url(\u0026quot;\u0026quot;) 这块复制【没有分号】,即\nurl(\u0026#39;data:image/svg+xml,%3Csvg xmlns=\u0026#34;http://www.w3.org/2000/svg\u0026#34; viewBox=\u0026#34;0 0 1920 1080\u0026#34;%3E%3Cg transform=\u0026#34; rotate(0 960 540) translate(-0 -0) scale(1) \u0026#34;%3E%3Crect width=\u0026#34;1920\u0026#34; height=\u0026#34;1080\u0026#34; fill=\u0026#34;rgb(184, 171, 255)\u0026#34;%3E%3C/rect%3E%3Cg transform=\u0026#34;translate(0, 0)\u0026#34;%3E%3Cpath fill=\u0026#34;rgb(131, 114, 218)\u0026#34; fill-opacity=\u0026#34;1\u0026#34; d=\u0026#34;M0,352.943L45.714,350.075C91.429,347.207,182.857,341.471,274.286,340.581C365.714,339.692,457.143,343.65,548.571,344.095C640,344.54,731.429,341.472,822.857,303.183C914.286,264.894,1005.714,191.383,1097.143,185.175C1188.571,178.967,1280,240.06,1371.429,221.336C1462.857,202.612,1554.286,104.069,1645.714,98.48C1737.143,92.892,1828.571,180.258,1874.286,223.941L1920,267.624L1920,1080L1874.286,1080C1828.571,1080,1737.143,1080,1645.714,1080C1554.286,1080,1462.857,1080,1371.429,1080C1280,1080,1188.571,1080,1097.143,1080C1005.714,1080,914.286,1080,822.857,1080C731.429,1080,640,1080,548.571,1080C457.143,1080,365.714,1080,274.286,1080C182.857,1080,91.429,1080,45.714,1080L0,1080Z\u0026#34;%3E%3C/path%3E%3C/g%3E%3Cg transform=\u0026#34;translate(0, 360)\u0026#34;%3E%3Cpath fill=\u0026#34;rgb(79, 57, 180)\u0026#34; fill-opacity=\u0026#34;1\u0026#34; d=\u0026#34;M0,136.093L45.714,117.434C91.429,98.774,182.857,61.455,274.286,80.719C365.714,99.983,457.143,175.829,548.571,189.505C640,203.181,731.429,154.687,822.857,130.414C914.286,106.141,1005.714,106.09,1097.143,141.274C1188.571,176.458,1280,246.877,1371.429,284.697C1462.857,322.517,1554.286,327.739,1645.714,284.675C1737.143,241.611,1828.571,150.263,1874.286,104.589L1920,58.914L1920,720L1874.286,720C1828.571,720,1737.143,720,1645.714,720C1554.286,720,1462.857,720,1371.429,720C1280,720,1188.571,720,1097.143,720C1005.714,720,914.286,720,822.857,720C731.429,720,640,720,548.571,720C457.143,720,365.714,720,274.286,720C182.857,720,91.429,720,45.714,720L0,720Z\u0026#34;%3E%3C/path%3E%3C/g%3E%3Cg transform=\u0026#34;translate(0, 720)\u0026#34;%3E%3Cpath fill=\u0026#34;rgb(26, 0, 143)\u0026#34; fill-opacity=\u0026#34;1\u0026#34; d=\u0026#34;M0,107.121L45.714,134.307C91.429,161.493,182.857,215.866,274.286,254.33C365.714,292.794,457.143,315.35,548.571,300.514C640,285.679,731.429,233.452,822.857,180.313C914.286,127.174,1005.714,73.123,1097.143,43.365C1188.571,13.606,1280,8.141,1371.429,41.079C1462.857,74.017,1554.286,145.358,1645.714,167.782C1737.143,190.206,1828.571,163.713,1874.286,150.467L1920,137.221L1920,360L1874.286,360C1828.571,360,1737.143,360,1645.714,360C1554.286,360,1462.857,360,1371.429,360C1280,360,1188.571,360,1097.143,360C1005.714,360,914.286,360,822.857,360C731.429,360,640,360,548.571,360C457.143,360,365.714,360,274.286,360C182.857,360,91.429,360,45.714,360L0,360Z\u0026#34;%3E%3C/path%3E%3C/g%3E%3C/g%3E%3C/svg%3E\u0026#39;) 加上\ntop/cover no-repeat fixed 即\nurl(\u0026#39;data:image/svg+xml,%3Csvg xmlns=\u0026#34;http://www.w3.org/2000/svg\u0026#34; viewBox=\u0026#34;0 0 1920 1080\u0026#34;%3E%3Cg transform=\u0026#34; rotate(0 960 540) translate(-0 -0) scale(1) \u0026#34;%3E%3Crect width=\u0026#34;1920\u0026#34; height=\u0026#34;1080\u0026#34; fill=\u0026#34;rgb(184, 171, 255)\u0026#34;%3E%3C/rect%3E%3Cg transform=\u0026#34;translate(0, 0)\u0026#34;%3E%3Cpath fill=\u0026#34;rgb(131, 114, 218)\u0026#34; fill-opacity=\u0026#34;1\u0026#34; d=\u0026#34;M0,352.943L45.714,350.075C91.429,347.207,182.857,341.471,274.286,340.581C365.714,339.692,457.143,343.65,548.571,344.095C640,344.54,731.429,341.472,822.857,303.183C914.286,264.894,1005.714,191.383,1097.143,185.175C1188.571,178.967,1280,240.06,1371.429,221.336C1462.857,202.612,1554.286,104.069,1645.714,98.48C1737.143,92.892,1828.571,180.258,1874.286,223.941L1920,267.624L1920,1080L1874.286,1080C1828.571,1080,1737.143,1080,1645.714,1080C1554.286,1080,1462.857,1080,1371.429,1080C1280,1080,1188.571,1080,1097.143,1080C1005.714,1080,914.286,1080,822.857,1080C731.429,1080,640,1080,548.571,1080C457.143,1080,365.714,1080,274.286,1080C182.857,1080,91.429,1080,45.714,1080L0,1080Z\u0026#34;%3E%3C/path%3E%3C/g%3E%3Cg transform=\u0026#34;translate(0, 360)\u0026#34;%3E%3Cpath fill=\u0026#34;rgb(79, 57, 180)\u0026#34; fill-opacity=\u0026#34;1\u0026#34; d=\u0026#34;M0,136.093L45.714,117.434C91.429,98.774,182.857,61.455,274.286,80.719C365.714,99.983,457.143,175.829,548.571,189.505C640,203.181,731.429,154.687,822.857,130.414C914.286,106.141,1005.714,106.09,1097.143,141.274C1188.571,176.458,1280,246.877,1371.429,284.697C1462.857,322.517,1554.286,327.739,1645.714,284.675C1737.143,241.611,1828.571,150.263,1874.286,104.589L1920,58.914L1920,720L1874.286,720C1828.571,720,1737.143,720,1645.714,720C1554.286,720,1462.857,720,1371.429,720C1280,720,1188.571,720,1097.143,720C1005.714,720,914.286,720,822.857,720C731.429,720,640,720,548.571,720C457.143,720,365.714,720,274.286,720C182.857,720,91.429,720,45.714,720L0,720Z\u0026#34;%3E%3C/path%3E%3C/g%3E%3Cg transform=\u0026#34;translate(0, 720)\u0026#34;%3E%3Cpath fill=\u0026#34;rgb(26, 0, 143)\u0026#34; fill-opacity=\u0026#34;1\u0026#34; d=\u0026#34;M0,107.121L45.714,134.307C91.429,161.493,182.857,215.866,274.286,254.33C365.714,292.794,457.143,315.35,548.571,300.514C640,285.679,731.429,233.452,822.857,180.313C914.286,127.174,1005.714,73.123,1097.143,43.365C1188.571,13.606,1280,8.141,1371.429,41.079C1462.857,74.017,1554.286,145.358,1645.714,167.782C1737.143,190.206,1828.571,163.713,1874.286,150.467L1920,137.221L1920,360L1874.286,360C1828.571,360,1737.143,360,1645.714,360C1554.286,360,1462.857,360,1371.429,360C1280,360,1188.571,360,1097.143,360C1005.714,360,914.286,360,822.857,360C731.429,360,640,360,548.571,360C457.143,360,365.714,360,274.286,360C182.857,360,91.429,360,45.714,360L0,360Z\u0026#34;%3E%3C/path%3E%3C/g%3E%3C/g%3E%3C/svg%3E\u0026#39;) top/cover no-repeat fixed 之后粘贴即可\n通过对比发现:\n就是把下面的内容\nurl(\u0026#39;data:image/svg+xml,\u0026lt;svg xmlns=\u0026#34;http://www.w3.org/2000/svg\u0026#34; viewBox=\u0026#34;0 0 1920 1080\u0026#34;\u0026gt;\u0026lt;g transform=\u0026#34; rotate(0 960 540) translate(-0 -0) scale(1) \u0026#34;\u0026gt;\u0026lt;rect width=\u0026#34;1920\u0026#34; height=\u0026#34;1080\u0026#34; fill=\u0026#34;rgb(184, 171, 255)\u0026#34;\u0026gt;\u0026lt;/rect\u0026gt;\u0026lt;g transform=\u0026#34;translate(0, 0)\u0026#34;\u0026gt;\u0026lt;path fill=\u0026#34;rgb(131, 114, 218)\u0026#34; fill-opacity=\u0026#34;1\u0026#34; d=\u0026#34;M0,352.943L45.714,350.075C91.429,347.207,182.857,341.471,274.286,340.581C365.714,339.692,457.143,343.65,548.571,344.095C640,344.54,731.429,341.472,822.857,303.183C914.286,264.894,1005.714,191.383,1097.143,185.175C1188.571,178.967,1280,240.06,1371.429,221.336C1462.857,202.612,1554.286,104.069,1645.714,98.48C1737.143,92.892,1828.571,180.258,1874.286,223.941L1920,267.624L1920,1080L1874.286,1080C1828.571,1080,1737.143,1080,1645.714,1080C1554.286,1080,1462.857,1080,1371.429,1080C1280,1080,1188.571,1080,1097.143,1080C1005.714,1080,914.286,1080,822.857,1080C731.429,1080,640,1080,548.571,1080C457.143,1080,365.714,1080,274.286,1080C182.857,1080,91.429,1080,45.714,1080L0,1080Z\u0026#34;\u0026gt;\u0026lt;/path\u0026gt;\u0026lt;/g\u0026gt;\u0026lt;g transform=\u0026#34;translate(0, 360)\u0026#34;\u0026gt;\u0026lt;path fill=\u0026#34;rgb(79, 57, 180)\u0026#34; fill-opacity=\u0026#34;1\u0026#34; d=\u0026#34;M0,136.093L45.714,117.434C91.429,98.774,182.857,61.455,274.286,80.719C365.714,99.983,457.143,175.829,548.571,189.505C640,203.181,731.429,154.687,822.857,130.414C914.286,106.141,1005.714,106.09,1097.143,141.274C1188.571,176.458,1280,246.877,1371.429,284.697C1462.857,322.517,1554.286,327.739,1645.714,284.675C1737.143,241.611,1828.571,150.263,1874.286,104.589L1920,58.914L1920,720L1874.286,720C1828.571,720,1737.143,720,1645.714,720C1554.286,720,1462.857,720,1371.429,720C1280,720,1188.571,720,1097.143,720C1005.714,720,914.286,720,822.857,720C731.429,720,640,720,548.571,720C457.143,720,365.714,720,274.286,720C182.857,720,91.429,720,45.714,720L0,720Z\u0026#34;\u0026gt;\u0026lt;/path\u0026gt;\u0026lt;/g\u0026gt;\u0026lt;g transform=\u0026#34;translate(0, 720)\u0026#34;\u0026gt;\u0026lt;path fill=\u0026#34;rgb(26, 0, 143)\u0026#34; fill-opacity=\u0026#34;1\u0026#34; d=\u0026#34;M0,107.121L45.714,134.307C91.429,161.493,182.857,215.866,274.286,254.33C365.714,292.794,457.143,315.35,548.571,300.514C640,285.679,731.429,233.452,822.857,180.313C914.286,127.174,1005.714,73.123,1097.143,43.365C1188.571,13.606,1280,8.141,1371.429,41.079C1462.857,74.017,1554.286,145.358,1645.714,167.782C1737.143,190.206,1828.571,163.713,1874.286,150.467L1920,137.221L1920,360L1874.286,360C1828.571,360,1737.143,360,1645.714,360C1554.286,360,1462.857,360,1371.429,360C1280,360,1188.571,360,1097.143,360C1005.714,360,914.286,360,822.857,360C731.429,360,640,360,548.571,360C457.143,360,365.714,360,274.286,360C182.857,360,91.429,360,45.714,360L0,360Z\u0026#34;\u0026gt;\u0026lt;/path\u0026gt;\u0026lt;/g\u0026gt;\u0026lt;/g\u0026gt;\u0026lt;/svg\u0026gt;\u0026#39;) top/cover no-repeat fixed ,左尖括号 \u0026lt; 替换成 %3C,把 \u0026lt; 替换成 %3E (补充下,如果是其他图片可能不止这几个,但是这张图只替换了这几个。不太了解前端到底需要转哪些特殊字符,因为我发现有些空格 / \u0026quot; 也都没有转),即\nurl(\u0026#39;data:image/svg+xml,%3Csvg xmlns=\u0026#34;http://www.w3.org/2000/svg\u0026#34; viewBox=\u0026#34;0 0 1920 1080\u0026#34;%3E%3Cg transform=\u0026#34; rotate(0 960 540) translate(-0 -0) scale(1) \u0026#34;%3E%3Crect width=\u0026#34;1920\u0026#34; height=\u0026#34;1080\u0026#34; fill=\u0026#34;rgb(184, 171, 255)\u0026#34;%3E%3C/rect%3E%3Cg transform=\u0026#34;translate(0, 0)\u0026#34;%3E%3Cpath fill=\u0026#34;rgb(131, 114, 218)\u0026#34; fill-opacity=\u0026#34;1\u0026#34; d=\u0026#34;M0,352.943L45.714,350.075C91.429,347.207,182.857,341.471,274.286,340.581C365.714,339.692,457.143,343.65,548.571,344.095C640,344.54,731.429,341.472,822.857,303.183C914.286,264.894,1005.714,191.383,1097.143,185.175C1188.571,178.967,1280,240.06,1371.429,221.336C1462.857,202.612,1554.286,104.069,1645.714,98.48C1737.143,92.892,1828.571,180.258,1874.286,223.941L1920,267.624L1920,1080L1874.286,1080C1828.571,1080,1737.143,1080,1645.714,1080C1554.286,1080,1462.857,1080,1371.429,1080C1280,1080,1188.571,1080,1097.143,1080C1005.714,1080,914.286,1080,822.857,1080C731.429,1080,640,1080,548.571,1080C457.143,1080,365.714,1080,274.286,1080C182.857,1080,91.429,1080,45.714,1080L0,1080Z\u0026#34;%3E%3C/path%3E%3C/g%3E%3Cg transform=\u0026#34;translate(0, 360)\u0026#34;%3E%3Cpath fill=\u0026#34;rgb(79, 57, 180)\u0026#34; fill-opacity=\u0026#34;1\u0026#34; d=\u0026#34;M0,136.093L45.714,117.434C91.429,98.774,182.857,61.455,274.286,80.719C365.714,99.983,457.143,175.829,548.571,189.505C640,203.181,731.429,154.687,822.857,130.414C914.286,106.141,1005.714,106.09,1097.143,141.274C1188.571,176.458,1280,246.877,1371.429,284.697C1462.857,322.517,1554.286,327.739,1645.714,284.675C1737.143,241.611,1828.571,150.263,1874.286,104.589L1920,58.914L1920,720L1874.286,720C1828.571,720,1737.143,720,1645.714,720C1554.286,720,1462.857,720,1371.429,720C1280,720,1188.571,720,1097.143,720C1005.714,720,914.286,720,822.857,720C731.429,720,640,720,548.571,720C457.143,720,365.714,720,274.286,720C182.857,720,91.429,720,45.714,720L0,720Z\u0026#34;%3E%3C/path%3E%3C/g%3E%3Cg transform=\u0026#34;translate(0, 720)\u0026#34;%3E%3Cpath fill=\u0026#34;rgb(26, 0, 143)\u0026#34; fill-opacity=\u0026#34;1\u0026#34; d=\u0026#34;M0,107.121L45.714,134.307C91.429,161.493,182.857,215.866,274.286,254.33C365.714,292.794,457.143,315.35,548.571,300.514C640,285.679,731.429,233.452,822.857,180.313C914.286,127.174,1005.714,73.123,1097.143,43.365C1188.571,13.606,1280,8.141,1371.429,41.079C1462.857,74.017,1554.286,145.358,1645.714,167.782C1737.143,190.206,1828.571,163.713,1874.286,150.467L1920,137.221L1920,360L1874.286,360C1828.571,360,1737.143,360,1645.714,360C1554.286,360,1462.857,360,1371.429,360C1280,360,1188.571,360,1097.143,360C1005.714,360,914.286,360,822.857,360C731.429,360,640,360,548.571,360C457.143,360,365.714,360,274.286,360C182.857,360,91.429,360,45.714,360L0,360Z\u0026#34;%3E%3C/path%3E%3C/g%3E%3C/g%3E%3C/svg%3E\u0026#39;) top/cover no-repeat fixed PNG格式作为背景图片(不可行) # 试着将这张图片用base64编码\n这里之所以不放字符了,是因为我放了之后obsidian卡死了(30多万个字符),所以突然联想到一个问题,这就是为什么我把这个base64放到StyleSettings里面的时候,没效果而且卡死的原因把,哈哈,之前一直没注意\n试着把png先转为svg,之后再按照svg作为背景的方法做一遍。\n转换后的svg图片(不知道为什么图片方向变了,我第一次下载这张图【png】确实是这个方向,后面我调整正了,现在又歪了)\n有两百多万多个字符。。。照样没效果,不过这次没卡死了 用了个纯色png转svg,有效果了,但是图片变了一些。我觉得可能是转换的问题?不过可以确定的一点就是,png没效果的一个原因是字符太多\n最后尝试用一张纯色png,转base64,并用url(\u0026quot;\u0026quot;)的形式放入设置,照样没效果。不知道是不支持png,还是我的设置方法错了,这次只有9000个字符,但是卡顿了,tab界面花屏,如图\n文章中用到的(可能用到的)url如下\nhttps://codepen.io/yoksel/details/MWKeKK svg-\u0026gt;encode-\u0026gt;css https://stackoverflow.com/questions/41405884/svg-data-image-as-css-background 关于svg转base64并嵌入css的做法(可能可行,没试过,本文中采用稍微简单的办法) https://meyerweb.com/eric/tools/dencoder/ url encode(没用上,因为发现他把所有字符都转码了,实际上只转了 \u0026lt; 和 \u0026gt; https://www.base64-image.de/ 图片转base64\nhttps://tool.chinaz.com/tools/urlencode.aspx url编码解码\nhttps://www.asciim.cn/m/tools/convert_ascii_to_string.html ascaii与字符串的转换\nhttps://forever-z-133.github.io/demos/single/svg-to-base64.html svg转base64\nhttps://products.aspose.app/pdf/zh/conversion/png-to-svg png转svg\nhttps://github.com/Akifyss/obsidian-border/issues/251 obsidian-border主题,issue中作者的相关回复\n"},{"id":206,"href":"/zh/docs/technology/Obsidian/obsidian-theme/","title":"obsidian-theme","section":"Obsidian","content":" 主题推荐 # Neumorphism-dark.json\nSunset-base64.json ✔ Obsidian-default-dark-alt ✔ 4. Obsidian-default-light-alt Neumorphism.json eyefriendly ✔ boundy ✔ flexoki-light Borderless-light 关于obsidian主题border的背景图片设置 # 配合StyleSettings,在StyleSettings的这里设置\n暂不明确 # background中貌似存在转换规则,不是直接用url(\u0026quot;\u0026quot;)这个形式把图片base64放进来就可以了,目前觉得可能的转换规则\n%3c 48+12=60 \u0026lt; %3e 48+14=62 \u0026gt; %23 32+3=35 # #下面的好像没用到,也不确定 %2b 32+11=43 + %3b ; %2c , 后续见另一篇文章\nborder-theme {% post_link \u0026lsquo;study/obsidian/border-theme\u0026rsquo; \u0026lsquo;helo\u0026rsquo; %}\n"},{"id":207,"href":"/zh/docs/technology/Obsidian/plugin/","title":"plugin","section":"Obsidian","content":" obsidian-custom-attachment-location v.28.1文件批量重命名有效,再往上都是无效的\n"},{"id":208,"href":"/zh/docs/technology/Redis/rsync-use/","title":"rsync使用","section":"Redis","content":"其实就是linux的cp功能(带增量复制) #推送到rsync-k40 rsync -avz \u0026ndash;progress \u0026ndash;delete source-rsync -e \u0026lsquo;ssh -p 8022\u0026rsquo; ly@192.168.1.101:/storage/emulated/0/000Ly/git/blog.source/source/attachments/rsync-k40 #推送到rsync-tabs8 rsync -avz \u0026ndash;progress \u0026ndash;delete source-rsync -e \u0026lsquo;ssh -p 8022\u0026rsquo; ly@192.168.1.106:/storage/emulated/0/000Ly/git/blog.source/source/attachments/rsync-tabs8 #推送到rsync-pc rsync -avz \u0026ndash;progress \u0026ndash;delete source-rsync -e \u0026lsquo;ssh -p 22\u0026rsquo; ly@192.168.1.206:/mnt/hgfs/gitRepo/blog.source/source/attachments/rsync-pc\n#从手机上拉取 rsync -avz \u0026ndash;progress \u0026ndash;delete -e \u0026lsquo;ssh -p 8022\u0026rsquo; ly@192.168.1.101:/storage/emulated/0/000Ly/git/blog.source/source/attachments/rsync-k40 source-rsync\n"},{"id":209,"href":"/zh/docs/life/20240626/","title":"知命不惧 日日自新","section":"生活","content":"视频分享\n"},{"id":210,"href":"/zh/docs/problem/Other/01/","title":"如何搜索","section":"Other","content":" 原则 # 搜索的时候,要简约,且尽量把关键词分散,不要有\u0026quot;的\u0026quot;,\u0026ldquo;地\u0026rdquo;,或者其他动词什么的,尽量是名词。 关键词之间用空格隔开 比如想要看一部电影,那么关键词有\u0026quot;电影名\u0026quot;,\u0026ldquo;bt\u0026rdquo;,\u0026ldquo;迅雷\u0026rdquo;,\u0026ldquo;阿里云盘\u0026rdquo;,\u0026ldquo;百度网盘\u0026rdquo;\n解释一下,这里来源(迅雷|阿里云盘|百度网盘),资源类型(bt)也是关键词\n所以搜索就是**\u0026ldquo;孤注一掷 bt\u0026rdquo;,\u0026ldquo;孤注一掷 阿里云盘\u0026rdquo;,\u0026ldquo;孤注一掷 百度网盘\u0026rdquo;,\u0026ldquo;孤注一掷 迅雷\u0026rdquo;,注意,中间都有空格**\n例子 # bt种子形式 # 点进来之后,滑到最下面(一般链接都是以浅蓝色标识,点过一次后就变成暗红色)\nbt文件一般要用\u0026quot;迅雷\u0026quot;这个软件下载,上面随便点击一个,迅雷这个软件就会跳出来\n然后选择好目录点击确认就可以下载了\n阿里云盘形式 # 第一个链接有人提出质疑了,我们点下面那个,这是进入之后的画面:\n再点击\u0026quot;阿里xxxxxxxxxx\u0026quot;这个链接,进入阿里云盘:\n点进来看视频文件还在不在,在的话,保存就可以了\n之后到自己的阿里云盘下载就行了\n百度云盘形式 # 百度云盘被限速了,不得已的情况下,不要用百度云盘,基本上前面两种形式的资源没找到的话,百度云盘大概率也不会有\n"},{"id":211,"href":"/zh/docs/life/20231227/","title":"起床临感","section":"生活","content":"所谓贵人,并不是封建迷信,而是指对你成长有帮助的人,不单单是直观的好。\n在你蒸蒸日上的时候打压你,让你有所收敛;在你颓废堕落的时候鼓励你,使你积极向上。他们都是贵人,一阴一阳之谓道,如是而已。\n"},{"id":212,"href":"/zh/docs/life/20231101/","title":"20231101","section":"生活","content":" 附(20231102)\n融入我中华文化的,才是自己人。想消灭我中华文化的,即使占有了这片土地,也不能称之“功臣”。\n当一个民族的文化被摧毁的时候,那个民族就是彻底灭亡了。 不过,我觉得,只要是在中国这片土地上(地理),无论谁来,都会产生这样的文化,无例外。(地理决定论) "},{"id":213,"href":"/zh/docs/life/archive/20231026/","title":"成就","section":"往日归档","content":" 任何事情的成功,都没有什么可骄傲的,不过是一物降一物,无他尔。 人生最大的问题,是不想,而不是不能。 "},{"id":214,"href":"/zh/docs/life/archive/20231013/","title":"沉没","section":"往日归档","content":" 努力不一定有用,但是虚度光阴难道就是对的吗? 即使你一时找不到正确的路,但是你应该能一眼看出哪些是错的,及时避开。 "},{"id":215,"href":"/zh/docs/problem/Linux/20230919/","title":"Linux操作符问题","section":"Linux","content":" 函数退出 # 函数退出状态:0(成功),非零(非正常,失败)\n引号 # 双引号中使用转义字符可以防止展开\n这意味着单词分割(空格制表换行分割单词)、路径名展开(*星号)、波浪线展开和花括号展开都将失效,然而参数展开、 算术展开和命令替换仍然执行\necho \u0026#34;text ~/*.txt {a,b} $(echo foo) $((2+2)) $USER\u0026#34; #禁止部分 text ~/*.txt {a,b} foo 4 me echo \u0026#39;text ~/*.txt {a,b} $(echo foo) $((2+2)) $USER\u0026#39; #全部禁止 text ~/*.txt {a,b} $(echo foo) $((2+2)) $USER 各种操作符 # [ expression ] / test [[ expression ]] $(( expression )) $var $( termi ) 文件表达式 -e file,字符串表达式 -n string,整数表达式 integer1 -eq integer2 test增强,增加 [ str =~ regex ],增加 == [[ $FILE == foo.* ]] 整数加减乘除取余 取变量 执行命令/函数 termi取变量$必加,里面被看作命令参数,\u0026lt; \u0026gt; ( ) 必须转义 否则 小于号 \u0026lt; 大于号\u0026gt;被认为重定向 与[ ] 一致 取变量$可加可不加 termi取变量$必加 if [ -x \u0026#34;$FILE\u0026#34; ] #引号可以防止空参数,空值。而\u0026#34;\u0026#34;被解释成空字符串 "},{"id":216,"href":"/zh/docs/life/archive/20230913/","title":"鲇鱼后思","section":"往日归档","content":" 鲇鱼事件其实出来很久了,一直没有太大关注,这几天突发兴致(某乎评论提到),就去了解了下。可能我对“官”这种东西,从小到大就定了性,所以如果查出个大清官,省吃俭用破衣烂衫,倒可算得上新闻。 对于其言论,确实听了未尝不免义愤填膺。于是我就花了大半个小时义愤填膺\u0026hellip; 一代人只能做一代人的事\u0026mdash;《走向共和》,官如此,民亦如此。如果作为普通老百姓,不能够跻身仕途,那就是先老老实实做好自己的本分\u0026ndash;照顾父母,照顾自己,照顾妻子,照顾儿女。总有人会替天行道,如果不是你,那就做好自己,教育好自己的子女,足以。不要三心二意,事物发展有其必然规律,有盛必有衰,自古皆如此。穷则独善其身,达则兼济天下。 没有必要把自己带入高高在上的角色。也许自己到了那个地位,贪得更凶。真小人好过伪君子,伪君子往往会迷失自己,既做不了君子,又成不了小人。 "},{"id":217,"href":"/zh/docs/life/archive/20230912/","title":"病愈 有感","section":"往日归档","content":" 一个人只有真正意识到事情的发展,是自己的错误导致,才会真正改过自新。否则就会怨天尤人,甚至掩耳盗铃。 世界万事万物,有优有劣。劣并不代表罪恶,不过是事物发展的某个过程,如同生病的头疼脑热,“现象”,不过是提醒世人罢了。切勿以过程盖棺定论,自暴自弃,及时止损即可。 "},{"id":218,"href":"/zh/docs/problem/Linux/20230819/","title":"Debian问题处理3","section":"Linux","content":" fcitx配合各种软件出现的问题 # 本文章中出现的引号都是英文状态下的引号,切记!\n安装完毕后环境变量设置 # /etc/profile 和/etc/enviroment 均可,profile针对用户,environment针对系统。一般都是放profile里面\n不行的话 # 如果修改profile无效,则在/etc/enviroment添加修改\n#/etc/enviroment 末尾添加 fcitx \u0026amp; #这行要添加 export XIM_PROGRAM=fcitx export XIM=fcitx export GTK_IM_MODULE=fcitx export QT_IM_MODULE=fcitx export XMODIFIERS=\u0026#34;@im=fcitx\u0026#34; export LANG=zh_CN.UTF-8 source后再重启一下哦\n装了zsh后(从终端打开)idea等各种软件不出现fcitx输入法的问题 # 在/.zshrc最后添加\nexport XIM_PROGRAM=fcitx export XIM=fcitx export GTK_IM_MODULE=fcitx export QT_IM_MODULE=fcitx export XMODIFIERS=\u0026#34;@im=fcitx\u0026#34; export LANG=zh_CN.UTF-8 export LC_MESSAGES=en_US.UTF-8 #让终端报错时,显示英文 而不是中文 也可以不在/.zshrc中追加这些,而是直接追加 source /etc/profile或者/etc/enviroment即可\n如果还有问题,就要在idea的配置文件idea.vmoptions添加\n-Drecreate.x11.input.method=true 如果使用系统默认终端的情况下出的问题 # 可以在 ~/.bashrc最后添加这段话,重启试试\nexport XIM_PROGRAM=fcitx export XIM=fcitx export GTK_IM_MODULE=fcitx export QT_IM_MODULE=fcitx export XMODIFIERS=\u0026#34;@im=fcitx\u0026#34; export LANG=zh_CN.UTF-8 各个文件的解释 # /etc/profile //用户级,所有用户登陆时才会执行 对于fcitx没效果(firefox无效)\n/etc/enviroment //系统级,一般不修改 这里有效果\n~/.bashrc //系统默认终端打开时执行 ~/.zshrc //zsh使用前执行\nsource命令是一个内置的shell命令,用于从当前shell会话中的文件读取和执行命令。source命令通常用于保留、更改当前shell中的环境变量。简而言之,source一个脚本,将会在当前shell中运行execute命令。 source命令可用于:\n刷新当前的shell环境 在当前环境使用source执行Shell脚本 从脚本中导入环境中一个Shell函数 从另一个Shell脚本中读取变量\nzsh卸载后账号无法登录 # 参考https://lwmfjc.github.io/2023/05/23/problem/linux/20230523/ 这篇文章\n如果不是root用户就简单多了,直接\nvim /etc/passwd # xx(账户名)......zsh,中/bin/zsh,改为/bin/bash 即可 xfce4的安装及gnome卸载 # gnome完全卸载\naptitude purge `dpkg --get-selections | grep gnome | cut -f 1` aptitude -f install aptitude purge `dpkg --get-selections | grep deinstall | cut -f 1` aptitude -f install xfce4安装\nsudo apt install task-xfce-desktop 蓝牙问题 # 最后是装了blueman 连接蓝牙耳机出现这个问题,为了连接装了这个。之后想用扬声器发现用不了,拔了耳机可以了却发现破音了\u0026hellip;.\n最后解决方案是把这两个删了,而且此时蓝牙耳机也可以连上了\u0026hellip;原因不明\n备份 # 如果是vm下学习linux,要多利用vmware,养成习惯,每进行一次大操作之前,都要进行vmware的快照备份。避免大操作导致出问题\n"},{"id":219,"href":"/zh/docs/problem/Linux/20230817/","title":"Debian问题处理2","section":"Linux","content":" 代理 # Vmware里面的debian,连接外面物理机的v2ray。\n对于浏览器 # 无论是firefox还是chromium,都可以直接通过v2ray允许局域网,然后使用ProxySwitchOmege代理访问\n对于命令 # 可以使用proxychains,直接用apt-get 安装即可,注意事项\n作用范围 # 对tcp生效,ping是不生效的,不要白费力气\n需要修改两个地方 # libproxychains.so.3 提示不存在 ly\nwhereis libproxychains.so.3 #libproxychains.so.3: /usr/lib/x86_64-linux-gnu/libproxychains.so.3 #修改/usr/bin/proxychains #export LD_PRELOAD = libproxychains.so.3 修改为: export LD_PRELOAD = /usr/lib/x86_64-linux-gnu/libproxychains.so.3 l\u0026rsquo;y配置修改\n#修改文件/etc/proxychains.conf,在最后一行添加 socks5 192.168.1.201 1082 使用\nproxychains git pull #直接在命令最前面输入proxychains即可 直接网络(gui)配置代理 # 这个对于终端不生效\nzsh安装 # proxychains wget https://raw.githubusercontent.com/ohmyzsh/ohmyzsh/master/tools/install.sh proxychains sh install.sh zsh主题安装 # proxychains git clone --depth=1 https://github.com/romkatv/powerlevel10k.git ${ZSH_CUSTOM:-$HOME/.oh-my-zsh/custom}/themes/powerlevel10k #修改 vim ~/.zshrc ZSH_THEME=\u0026#34;powerlevel10k/powerlevel10k\u0026#34; 重新配置 p10k configure\n程序环境设置 # 环境变量\n#java环境变量 export JAVA_HOME=/usr/local/jdk1.8.0_281 export CLASSPATH=$:CLASSPATH:$JAVA_HOME/lib/ export PATH=$PATH:$JAVA_HOME/bin #maven环境变量 export MAVEN_HOME=/usr/local/apache-maven-3.3.9 export PATH=$PATH:$MAVEN_HOME/bin typora破解 # 声明:破解可耻,尊重正版。这里仅以学习为目的\n来自文章 https://ccalt.cn/2023/04/07/%5BWindows%7CLinux%5DTypora%E6%9C%80%E6%96%B0%E7%89%88%E9%80%9A%E7%94%A8%E7%A0%B4%E8%A7%A3-%E8%87%B3%E4%BB%8A%E5%8F%AF%E7%94%A8/\n程序 # https://github.com/DiamondHunters/NodeInject_Hook_example/actions/runs/4180836116\n我自己fork了一份,不知道哪天就没了\nhttps://github.com/lwmfjc/NodeInject_Hook_example/actions/runs/5888943386\n步骤 # 将linux版本的文件,按下面的结构解压放入Typora文件夹中\n这里盗(借)用文章图片说明,不想截图了\n之后先运行node_inject,后运行license-gen ,即可得到序列号\n字体 # 很多程序都偏小,系统字体基本正常。\nfirefox中,要设置最小字体。 Typor没找到。\npicgo问题 # 没有什么特别注意的,基本问题搜索引擎都有。对了,把windows下的picgo卸载了,只留下了picgo-core,还安装了 super-prefix,自定义上传路径及文件名\n参考文章 https://connor-sun.github.io/posts/38835.html\n#自定义文件夹及文件名 picgo install super-prefix #super-prefix地址 https://github.com/gclove/picgo-plugin-super-prefix picgo-core 配置手册 https://picgo.github.io/PicGo-Core-Doc/zh/guide/config.html\n#插件配置 ~/.picgo/config.json ,在根结构里面添加 \u0026#34;picgoPlugins\u0026#34;: { \u0026#34;picgo-plugin-super-prefix\u0026#34;: true }, \u0026#34;picgo-plugin-super-prefix\u0026#34;: { \u0026#34;prefixFormat\u0026#34;: \u0026#34;YYYY/MM/DD/\u0026#34;, \u0026#34;fileFormat\u0026#34;: \u0026#34;YYYYMMDD-HHmmss\u0026#34; } Typora中文件上传对picgo-core的设置 # 自定义命令格式:picgo upload,windows中可以使用绝对路径(加双引号)\n截图工具 # apt install flameshot 使用flameshot gui 启动\n"},{"id":220,"href":"/zh/docs/problem/Linux/20230815/","title":"Debian问题处理1","section":"Linux","content":" 清华源设置 # vim /etc/apt/sources.list #注释掉原来的,并添加 # 默认注释了源码镜像以提高 apt update 速度,如有需要可自行取消注释 deb https://mirrors.tuna.tsinghua.edu.cn/debian/ bookworm main contrib non-free non-free-firmware # deb-src https://mirrors.tuna.tsinghua.edu.cn/debian/ bookworm main contrib non-free non-free-firmware deb https://mirrors.tuna.tsinghua.edu.cn/debian/ bookworm-updates main contrib non-free non-free-firmware # deb-src https://mirrors.tuna.tsinghua.edu.cn/debian/ bookworm-updates main contrib non-free non-free-firmware deb https://mirrors.tuna.tsinghua.edu.cn/debian/ bookworm-backports main contrib non-free non-free-firmware # deb-src https://mirrors.tuna.tsinghua.edu.cn/debian/ bookworm-backports main contrib non-free non-free-firmware deb https://mirrors.tuna.tsinghua.edu.cn/debian-security bookworm-security main contrib non-free non-free-firmware # deb-src https://mirrors.tuna.tsinghua.edu.cn/debian-security bookworm-security main contrib non-free non-free-firmware deb https://security.debian.org/debian-security bookworm-security main contrib non-free non-free-firmware # deb-src https://security.debian.org/debian-security bookworm-security main contrib non-free non-free-firmware 中文环境 # su sudo apt-get install locales #配置中文环境 1.选择zh开头的 2 后面选择en(cn也行,不影响输入法) sudo dpkg-reconfigure locales #设置上海时区 sudo timedatectl set-timezone Asia/Shanghai 中文输入法 # #清除旧的环境 apt-get remove ibus #不兼容问题 apt-get remove fcitx5 fcitx5-chinese-addons apt-get autoremove ly # gnome-shell-extension-kimpanel sudo apt install fcitx5 fcitx5-chinese-addons fcitx5-frontend-gtk4 fcitx5-frontend-gtk3 fcitx5-frontend-gtk2 fcitx5-frontend-qt5 im-config #配置使用fcitx5 #环境变量添加 export XMODIFIERS=@im=fcitx export GTK_IM_MODULE=fcitx export QT_IM_MODULE=fcitx #退出root用户权限,使用普通用户权限再终端 fcitx5-configtool #配置中文输入法即可 #附加组件-经典用户界面--这里可以修改字体及大小 其他 # 应用程序-优化 修改默认字体大小\n桌面任务栏\u0026ndash; https://extensions.gnome.org/extension/1160/dash-to-panel\n参考文章 https://itsfoss.com/gnome-shell-extensions/\n之后设置一下任务栏的位置即可\n参考文章 # https://zhuanlan.zhihu.com/p/508797663\n"},{"id":221,"href":"/zh/docs/problem/Linux/20230803/","title":"安卓手机及平板安装linuxDeploy的问题简记","section":"Linux","content":" 为什么是简记呢,因为这几天折腾这些太累了,等以后回过头来重新操作再详细记载\n前言 # 初衷 # 一开始的初衷是为了在平板上使用idea,之前看了一篇docker使用idea的文章,心血来潮。所以想直接在平板的termux安装docker然后使用,结果一堆问题。后面妥协了,在手机上装,然后开远程吧\n这年头机在人在,所以装手机还是平板,还真没有很大的问题。后面使用情况证明:手机不需要开热点的情况(开热点是为了保证网络联通,在同一局域网),其实不怎么发热也不怎么耗电的。\n平板上 # 本来想在tab s8平板上通过termux安装linux(无root权限),但是总会遇到一堆问题\u0026ndash;连系统都装不上。因为root会有两个问题,所以一开始没有考虑使用linuxDeploy(需要root)\n保修失效 无法通过系统直接更新(需要线刷) 手机上(root) # 配置 # 后面尝试在root过的手机上安装linuxDeploy,照样有一堆问题,这里配上能使用的配置(能进系统):\n我用的时候ssh端口改了一下,不过不影响,第一次用的22端口也是能连上的。初始用户写的root,这里也是设置的root。\n最好挂载一下\n问题 # 用其他桌面环境,可能会导致图标没有(应该是就没有那个应用,比如浏览器),不过我这个配置完也没有浏览器,不过好在图标也没,不用自己再去移除了。\n装完之后vns有出错过一次,突然就蹦了,死活连不上。后面我直接重装系统了(linux deploy),没有再出现问题。装完之后需要在etc/rc.local添加:\n#删除vns临时文件,保证每次启动都是使用端口:5901 #(linux上显示:1,连接使用时要+5900,即使用5901端口) rm -rf /tmp/.X[1-9]-lock rm -rf /tmp/.X11-unix/X[1-9] #保证系统每次启动后都自动启动vncserver vncserver 电脑上随便找了个VNCServer 绿色免安装程序可以连上\n平板上使用AVNC,电脑不方便截图,就不截了.. 类似长这样\n#常用命令(也不常,这两天用的最多的) vncserver -kill :1 #强制关闭端口1 vncserver #启动 安装idea,也不用安装,就是去官网下载解压即可。问题:需要jdk11以上才能打开(疑惑,貌似之前在windows安装的时候没这要求,反正后面我妥协了,装了11,之后就是配置环境变量什么的)\n一开始linuxDeploy的Ubuntu,然后..发现openjdk11装完之后,java -version显示的10,一脸蒙圈,搞得后面又重装了Debian(中途还试了centos)\n装完没有中文输入法,系统装完就是要用的,如果随便打打命令倒是不需要中文输入法,但是如果打点代码写点注解,那蹩脚英语就\u0026hellip;总不能句句good good study,day day up..真是one day day de\u0026hellip;\n问题处理 # 其实解决方案前面好像都说了,输入法单独开一块吧,比较恶心,主要是让我意识到了自己水平有多菜\u0026hellip;\n某些机器(平板)省电模式下,默认的那个用户会断网,原因不明 # 所以那个用户就别用了,再创建一个新的\nuseradd -d /home/ly -s /bin/bash -m ly 然后设置下密码\npasswd ly 配置中文环境 # sudo dpkg-reconfigure locales #前面选英文和中文,后面选英文 #设置时区 sudo timedatectl set-timezone Asia/Shanghai 中文字体安装 # apt-get install ttf-wqy-zenhei apt-get install xfonts-intl-chinese wqy* 输入法相关安装 # #fcitx安装 apt install fcitx -y #输入法安装 apt install fcitx-googlepinyin fcitx-sunpinyin fcitx-pinyin #中文字体包,简体繁体 apt install fonts-arphic-bsmi00lp fonts-arphic-gbsn00lp fonts-arphic-gkai00mp apt install fcitx-table* 输入法bug # 网上一堆教程,找了很多,最后的解决方案 fcitx+googlepinyin\n没用fcitx5和sougou输入法,因为尝试了很多次实在装不上,不知道是arm64的架构问题还是什么,装完老是输入法状态栏闪啊闪\u0026hellip;bug?玄学?\n这个网上都有教程(抄袭),就不写(抄)了。写下重要的问题\n输入法在terminal终端、idea中不能切换出来、切换后打字不能上去 # tabs8没有这个bug,手机的miui系统有这个bug,不知道为啥\n这个问题其实在wiki里面有说到,不过我是google之后才定位到这里的\nhttps://wiki.archlinux.org/title/fcitx\n需要修改 ~/.xinitrc文件\nfcitx \u0026amp; #add export GTK_IM_MODULE=fcitx #add export QT_IM_MODULE=fcitx #add export XMODIFIERS=@im=fcitx #add XAUTHORITY=$HOME/.Xauthority export XAUTHORITY LANG_MESSAGE=zh_CN.UTF-8 #add LANG=en_US.UTF-8 #add export LANG #add export LANG_MESSAGE #add echo $$ \u0026gt; /tmp/xsession.pid . $HOME/.xsession 其他的不要改,安装系统原来怎么样就怎么样就行\u0026hellip;\n在这之前什么中文字体、还有区域设置都要先搞定,前面的设置选择en_US.UTF-8+zh_CN所有(有四五个),后面的设置选择系统默认语言(中英文都可,我选的英文,方便我这样的菜鸟报错时google查解决方案,只能选一个)\n突然想起来souhupinyin闪啊闪可能跟这里设置了fcitx有关系?以后有空再研究\n还有这个地方要改\nvim /etc/locale.conf #LANG=zh_CN.UTF-8 #LC_MESSAGES=en_US.UTF-8 LANG=en_US.UTF-8 LC_MESSAGES=zh_CN.UTF-8 之前我还改了idea.*vmoptions的配置,不过可能跟这个没有太大关系\nidea中输入法位置会出现在左下角 # 看了fcitx官方的issue,说是不关它的事\u0026hellip;.\n博客 # Obsidian # 到官方github下载即可\nhttps://github.com/obsidianmd/obsidian-releases/releases\n下载其中一个就行,不过都要以 \u0026ndash;no-sandbox方式才能运行\n这期间会有很多问题,需要安装下列软件\napt-get install zlib1g-dev apt-get install fuse libfuse2 apt install libnss3 ./obsidian --no-sandbox #以这种方式运行 Typora # linux 的arm64只存在于1.0以上版本,众所周知\u0026hellip;\n以下链接,仅供学习\nhttps://www.cnblogs.com/youngyajun/p/16661980.html \u0026ndash;1.0.3有效,不过图片上传有点问题,直接拖曳进去是能上传的,复制后粘贴不行(1.6可以,不过未授权,不太好弄)\npython安装过程也挺曲折,不要装python2,直接装3。pip也是直接装版本3即可。记得使用清华源/或者阿里源,要不得下载半天\npicgo # 这里配合的是picgo-core,用官方教程安装即可\n直接picgo不行,没有找到对应平台的安装包\nPicHoro # 最后放弃在linux上写博客的想法了,勉强能用,不过实在是勉强。\n后面采用直接在平板安装obsidian+pichoro的办法\nhttps://github.com/Kuingsmile/PicHoro\n还行。不过2.1.2报毒,不知道为啥,下完被自动删除了。使用的是2.1.1\n浏览器 # chromium # 直接apt安装就好了,装完之后ui那个图标是打不开的,同样的问题,在terminal终端那里,使用命令chromium --no-sandbox即可打开。有一些问题,比如没声音啦、一些pdf插件(博客的)显示不出来之类。目前没需求,暂不处理了\nps: 还有q的问题,目前也没需求。暂不处理\n参考链接 # https://cloud.tencent.com/developer/article/1159972\nhttps://blog.csdn.net/qysh123/article/details/117288055\nhttps://blog.csdn.net/weixin_42122432/article/details/116703457\nhttps://www.cnblogs.com/jtianlin/p/4230527.html\nhttps://blog.csdn.net/sinat_42483341/article/details/104104441\nhttps://blog.csdn.net/ma726518972/article/details/121034994?ydreferer=aHR0cHM6Ly93d3cuZ29vZ2xlLmNvbS8%3D\nhttps://www.cnblogs.com/shanhubei/p/17517381.html\nhttps://blog.csdn.net/sandonz/article/details/106877555\nhttps://archlinuxarm.org/packages/aarch64/vim\nhttps://bbs.archlinuxcn.org/viewtopic.php?id=12685\nhttps://soft.zol.com.cn/126/1262460.html\nhttps://bbs.archlinuxcn.org/viewtopic.php?id=10498\nhttps://wiki.archlinux.org/title/fcitx\nhttps://www.reddit.com/r/archlinux/comments/rl1ncw/fcitx_input_in_terminal_window_doesnt_work/\nhttps://stackoverflow.com/questions/20705089/why-can-not-i-use-fcitx-input-method-in-gnome-terminal\nhttps://blog.csdn.net/u011166277/article/details/106287587/\nhttps://www.reddit.com/r/swaywm/comments/t09udp/anyone_using_the_input_method_fcitx5rime_cant_get/\nhttps://github.com/fcitx/fcitx5/issues/79\n其实不止这些,不过有用的可能就这些\n成果 # 疑惑 # 其实科学这种东西,在你不精通的情况下,也会出现玄而又玄的事。那传统意义上所谓的玄学,是否是因为自己不精通/失传导致的呢\u0026hellip;人类是退化还是进化..不过有一点是肯定的,所有的事情都是人为的 \u0026ndash; 自作自受。\n用window系统打完了这篇博客真的爽,没有各种莫名其妙的问题。果然,跌到谷底的时候,怎么走都是向上。\n"},{"id":222,"href":"/zh/docs/problem/JVM/20230526/","title":"JDK代理和CGLIB代理","section":"Jvm","content":" 完全转载自https://juejin.cn/post/7011357346018361375 ,以防丢失故作备份 。\n一、什么是代理模式 # 代理模式(Proxy Pattern)给某一个对象提供一个代理,并由代理对象控制原对象的引用。代理对象在客户端和目标对象之间起到中介作用。\n代理模式是常用的结构型设计模式之一,当直接访问某些对象存在问题时可以通过一个代理对象来间接访问,为了保证客户端使用的透明性,所访问的真实对象与代理对象需要实现相同的接口。代理模式属于结构型设计模式,属于GOF23种设计模式之一。\n代理模式可以分为静态代理和动态代理两种类型,而动态代理中又分为JDK动态代理和CGLIB代理两种。 代理模式包含如下角色:\nSubject (抽象主题角色) 抽象主题角色声明了真实主题和代理主题的共同接口,这样一来在任何使用真实主题 的地方都可以使用代理主题。客户端需要针对抽象主题角色进行编程。 Proxy (代理主题角色) 代理主题角色内部包含对真实主题的引用,从而可以在任何时候操作真实主题对象。 在代理主题角色中提供一个与真实主题角色相同的接口,以便在任何时候都可以替代真实 主体。代理主题角色还可以控制对真实主题的使用,负责在需要的时候创建和删除真实主 题对象,并对真实主题对象的使用加以约束。代理角色通常在客户端调用所引用的真实主 题操作之前或之后还需要执行其他操作,而不仅仅是单纯的调用真实主题对象中的操作。 RealSubject (真实主题 角色) 真实主题角色定义了代理角色所代表的真实对象,在真实主题角色中实现了真实的业 务操作,客户端可以通过代理主题角色间接调用真实主题角色中定义的方法。 代理模式的优点 # 代理模式能将代理对象与真实被调用的目标对象分离。 一定程度上降低了系统的耦合度,扩展性好。 可以起到保护目标对象的作用。 可以对目标对象的功能增强。 代理模式的缺点 # 代理模式会造成系统设计中类的数量增加。 在客户端和目标对象增加一个代理对象,会造成请求处理速度变慢。 二、JDK动态代理 # 在java的动态代理机制中,有两个重要的类或接口,一个是 InvocationHandler(Interface)、另一个则是 Proxy(Class),这一个类和接口是实现我们动态代理所必须用到的。\nInvocationHandler # 每一个动态代理类都必须要实现InvocationHandler这个接口,并且每个代理类的实例都关联了一个handler,当我们通过代理对象调用一个方法的时候,这个方法的调用就会被转发为由InvocationHandler这个接口的 invoke 方法来进行调用。\nInvocationHandler这个接口的唯一一个方法 invoke 方法:\njava 复制代码Object invoke(Object proxy, Method method, Object[] args) throws Throwable 这个方法一共接受三个参数,那么这三个参数分别代表如下:\nproxy:指代JDK动态生成的最终代理对象 method:指代的是我们所要调用真实对象的某个方法的Method对象 args:指代的是调用真实对象某个方法时接受的参数 Proxy # Proxy这个类的作用就是用来动态创建一个代理对象的类,它提供了许多的方法,但是我们用的最多的就是newProxyInstance 这个方法:\njava 复制代码public static Object newProxyInstance(ClassLoader loader, Class\u0026lt;?\u0026gt;[] interfaces, InvocationHandler handler) throws IllegalArgumentException 这个方法的作用就是得到一个动态的代理对象,其接收三个参数,我们来看看这三个参数所代表的含义:\nloader:ClassLoader对象,定义了由哪个ClassLoader来对生成的代理对象进行加载,即代理类的类加载器。 interfaces:Interface对象的数组,表示的是我将要给我需要代理的对象提供一组什么接口,如果我提供了一组接口给它,那么这个代理对象就宣称实现了该接口(多态),这样我就能调用这组接口中的方法了。 Handler:InvocationHandler对象,表示的是当我这个动态代理对象在调用方法的时候,会关联到哪一个InvocationHandler对象上。 所以我们所说的DynamicProxy(动态代理类)是这样一种class:它是在运行时生成的class,在生成它时你必须提供一组interface给它,然后该class就宣称它实现了这些 interface。这个DynamicProxy其实就是一个Proxy,它不会做实质性的工作,在生成它的实例时你必须提供一个handler,由它接管实际的工作。\nJDK动态代理实例 # 创建接口类\njava复制代码public interface HelloInterface { void sayHello(); } 创建被代理类,实现接口\njava复制代码/** * 被代理类 */ public class HelloImpl implements HelloInterface{ @Override public void sayHello() { System.out.println(\u0026#34;hello\u0026#34;); } } 创建InvocationHandler实现类\njava复制代码/** * 每次生成动态代理类对象时都需要指定一个实现了InvocationHandler接口的调用处理器对象 */ public class ProxyHandler implements InvocationHandler{ private Object subject; // 这个就是我们要代理的真实对象,也就是真正执行业务逻辑的类 public ProxyHandler(Object subject) {// 通过构造方法传入这个被代理对象 this.subject = subject; } /** *当代理对象调用真实对象的方法时,其会自动的跳转到代理对象关联的handler对象的invoke方法来进行调用 */ @Override public Object invoke(Object obj, Method method, Object[] objs) throws Throwable { Object result = null; System.out.println(\u0026#34;可以在调用实际方法前做一些事情\u0026#34;); System.out.println(\u0026#34;当前调用的方法是\u0026#34; + method.getName()); result = method.invoke(subject, objs);// 需要指定被代理对象和传入参数 System.out.println(method.getName() + \u0026#34;方法的返回值是\u0026#34; + result); System.out.println(\u0026#34;可以在调用实际方法后做一些事情\u0026#34;); System.out.println(\u0026#34;------------------------\u0026#34;); return result;// 返回method方法执行后的返回值 } } 测试\njava复制代码public class Mytest { public static void main(String[] args) { //第一步:创建被代理对象 HelloImpl hello = new HelloImpl(); //第二步:创建handler,传入真实对象 ProxyHandler handler = new ProxyHandler(hello); //第三步:创建代理对象,传入类加载器、接口、handler HelloInterface helloProxy = (HelloInterface) Proxy.newProxyInstance( HelloInterface.class.getClassLoader(), new Class[]{HelloInterface.class}, handler); //第四步:调用方法 helloProxy.sayHello(); } } 结果\nmarkdown复制代码可以在调用实际方法前做一些事情 当前调用的方法是sayHello hello sayHello方法的返回值是null 可以在调用实际方法后做一些事情 ------------------------ JDK动态代理步骤 # JDK动态代理分为以下几步:\n拿到被代理对象的引用,并且通过反射获取到它的所有的接口。 通过JDK Proxy类重新生成一个新的类,同时新的类要实现被代理类所实现的所有的接口。 动态生成 Java 代码,把新加的业务逻辑方法由一定的逻辑代码去调用。 编译新生成的 Java 代码.class。 将新生成的Class文件重新加载到 JVM 中运行。 所以说JDK动态代理的核心是通过重写被代理对象所实现的接口中的方法来重新生成代理类来实现的,那么假如被代理对象没有实现接口呢?那么这时候就需要CGLIB动态代理了。\n三、CGLIB动态代理 # JDK动态代理是通过重写被代理对象实现的接口中的方法来实现,而CGLIB是通过继承被代理对象来实现,和JDK动态代理需要实现指定接口一样,CGLIB也要求代理对象必须要实现MethodInterceptor接口,并重写其唯一的方法intercept。\nCGLib采用了非常底层的字节码技术,其原理是通过字节码技术为一个类创建子类,并在子类中采用方法拦截的技术拦截所有父类方法的调用,顺势织入横切逻辑。(利用ASM开源包,对代理对象类的class文件加载进来,通过修改其字节码生成子类来处理)\n注意:因为CGLIB是通过继承目标类来重写其方法来实现的,故而如果是final和private方法则无法被重写,也就是无法被代理。\nxml复制代码\u0026lt;dependency\u0026gt; \u0026lt;groupId\u0026gt;cglib\u0026lt;/groupId\u0026gt; \u0026lt;artifactId\u0026gt;cglib-nodep\u0026lt;/artifactId\u0026gt; \u0026lt;version\u0026gt;2.2\u0026lt;/version\u0026gt; \u0026lt;/dependency\u0026gt; CGLib核心类 # 1、 net.sf.cglib.proxy.Enhancer:主要增强类,通过字节码技术动态创建委托类的子类实例;\nEnhancer可能是CGLIB中最常用的一个类,和Java1.3动态代理中引入的Proxy类差不多。和Proxy不同的是,Enhancer既能够代理普通的class,也能够代理接口。Enhancer创建一个被代理对象的子类并且拦截所有的方法调用(包括从Object中继承的toString和hashCode方法)。Enhancer不能够拦截final方法,例如Object.getClass()方法,这是由于Java final方法语义决定的。基于同样的道理,Enhancer也不能对fianl类进行代理操作。这也是Hibernate为什么不能持久化final class的原因。\n2、net.sf.cglib.proxy.MethodInterceptor:常用的方法拦截器接口,需要实现intercept方法,实现具体拦截处理;\njava复制代码 public java.lang.Object intercept(java.lang.Object obj, java.lang.reflect.Method method, java.lang.Object[] args, MethodProxy proxy) throws java.lang.Throwable{} obj:动态生成的代理对象 method:实际调用的方法 args:调用方法入参 net.sf.cglib.proxy.MethodProxy:java Method类的代理类,可以实现委托类对象的方法的调用;常用方法:methodProxy.invokeSuper(proxy, args);在拦截方法内可以调用多次。 CGLib代理实例 # 创建被代理类\njava复制代码public class SayHello { public void say(){ System.out.println(\u0026#34;hello\u0026#34;); } } 创建代理类\njava复制代码/** *代理类 */ public class ProxyCglib implements MethodInterceptor{ private Enhancer enhancer = new Enhancer(); public Object getProxy(Class clazz){ //设置需要创建子类的类 enhancer.setSuperclass(clazz); enhancer.setCallback(this); //通过字节码技术动态创建子类实例 return enhancer.create(); } //实现MethodInterceptor接口方法 public Object intercept(Object obj, Method method, Object[] args, MethodProxy proxy) throws Throwable { System.out.println(\u0026#34;可以在调用实际方法前做一些事情\u0026#34;); //通过代理类调用父类中的方法 Object result = proxy.invokeSuper(obj, args); System.out.println(\u0026#34;可以在调用实际方法后做一些事情\u0026#34;); return result; } } 测试\njava复制代码public class Mytest { public static void main(String[] args) { ProxyCglib proxy = new ProxyCglib(); //通过生成子类的方式创建代理类 SayHello proxyImp = (SayHello)proxy.getProxy(SayHello.class); proxyImp.say(); } } 结果\n复制代码可以在调用实际方法前做一些事情 hello 可以在调用实际方法后做一些事情 CGLIB动态代理实现分析 # CGLib动态代理采用了FastClass机制,其分别为代理类和被代理类各生成一个FastClass,这个FastClass类会为代理类或被代理类的方法分配一个 index(int类型)。这个index当做一个入参,FastClass 就可以直接定位要调用的方法直接进行调用,这样省去了反射调用,所以调用效率比 JDK 动态代理通过反射调用更高。\n但是我们看上面的源码也可以明显看到,JDK动态代理只生成一个文件,而CGLIB生成了三个文件,所以生成代理对象的过程会更复杂。\n四、JDK和CGLib动态代理对比 # JDK 动态代理是实现了被代理对象所实现的接口,CGLib是继承了被代理对象。 JDK和CGLib 都是在运行期生成字节码,JDK是直接写Class字节码,CGLib 使用 ASM 框架写Class字节码,Cglib代理实现更复杂,生成代理类的效率比JDK代理低。\nJDK 调用代理方法,是通过反射机制调用,CGLib 是通过FastClass机制直接调用方法,CGLib 执行效率更高。\n原理区别: # java动态代理是利用反射机制生成一个实现代理接口的匿名类,在调用具体方法前调用InvokeHandler来处理。核心是实现InvocationHandler接口,使用invoke()方法进行面向切面的处理,调用相应的通知。\n而cglib动态代理是利用asm开源包,对代理对象类的class文件加载进来,通过修改其字节码生成子类来处理。核心是实现MethodInterceptor接口,使用intercept()方法进行面向切面的处理,调用相应的通知。\n1、如果目标对象实现了接口,默认情况下会采用JDK的动态代理实现AOP\n2、如果目标对象实现了接口,可以强制使用CGLIB实现AOP\n3、如果目标对象没有实现了接口,必须采用CGLIB库,spring会自动在JDK动态代理和CGLIB之间转换\n性能区别: # 1、CGLib底层采用ASM字节码生成框架,使用字节码技术生成代理类,在jdk6之前比使用Java反射效率要高。唯一需要注意的是,CGLib不能对声明为final的方法进行代理,因为CGLib原理是动态生成被代理类的子类。\n2、在jdk6、jdk7、jdk8逐步对JDK动态代理优化之后,在调用次数较少的情况下,JDK代理效率高于CGLIB代理效率,只有当进行大量调用的时候,jdk6和jdk7比CGLIB代理效率低一点,但是到jdk8的时候,jdk代理效率高于CGLIB代理。\n各自局限: # 1、JDK的动态代理机制只能代理实现了接口的类,而不能实现接口的类就不能实现JDK的动态代理。\n2、cglib是针对类来实现代理的,他的原理是对指定的目标类生成一个子类,并覆盖其中方法实现增强,但因为采用的是继承,所以不能对final修饰的类进行代理。\n类型 机制 回调方式 适用场景 效率 JDK动态代理 委托机制,代理类和目标类都实现了同样的接口,InvocationHandler持有目标类,代理类委托InvocationHandler去调用目标类的原始方法 反射 目标类是接口类 效率瓶颈在反射调用稍慢 CGLIB动态代理 继承机制,代理类继承了目标类并重写了目标方法,通过回调函数MethodInterceptor调用父类方法执行原始逻辑 通过FastClass方法索引调用 非接口类、非final类,非final方法 第一次调用因为要生成多个Class对象,比JDK方式慢。多次调用因为有方法索引比反射快,如果方法过多,switch case过多其效率还需测试 五、静态代理和动态的本质区别 # 静态代理只能通过手动完成代理操作,如果被代理类增加新的方法,代理类需要同步新增,违背开闭原则。 动态代理采用在运行时动态生成代码的方式,取消了对被代理类的扩展限制,遵循开闭原则。 若动态代理要对目标类的增强逻辑扩展,结合策略模式,只需要新增策略类便可完成,无需修改代理类的代码。 作者:ycf 链接:https://juejin.cn/post/7011357346018361375 来源:稀土掘金 著作权归作者所有。商业转载请联系作者获得授权,非商业转载请注明出处。\n"},{"id":223,"href":"/zh/docs/technology/JVM/_understanding_the_jvm_/03/","title":"03垃圾收集器与内存分配策略","section":"_深入理解Java虚拟机_","content":" 学习《深入理解Java虚拟机》,感谢作者!\n代码清单3-9 -XX:MaxTenuringThreshod=1说明 # Eden[8M] Survivor1[1M] Survivor2[1M] Old {10M} 初始 allocation1[0.25M],allocation2[4MB] 3执行时gc导致的变化 +allocation1[0.25M] +allocation2[4MB] 3执行后 +allocation3[4MB] +allocation1[0.25M] +allocation2[4MB] 5执行时gc导致的变化 allocation2[4MB],+allocation1[0.25M] 5执行后 +allocation3[4MB] allocation2[4MB],+allocation1[0.25M] 代码清单3-9 -XX:MaxTenuringThreshod=15说明 # Eden[8M] Survivor1[1M] Survivor2[1M] Old {10M} 初始 allocation1[0.25M],allocation2[4MB] 3执行时gc导致的变化 +allocation1[0.25M] +allocation2[4MB] 3执行后 +allocation3[4MB] +allocation1[0.25M] +allocation2[4MB] 5执行时gc导致的变化 +allocation1[0.25M] allocation2[4MB] 5执行后 +allocation3[4MB] +allocation1[0.25M] allocation2[4MB],+allocation1[0.25M] 代码清单3-10 说明 # Eden[8M] Survivor1[1M] Survivor2[1M] Old {10M} 初始 allocation1[0.25M],allocation2[[0.25M],allocation3[4M] 4执行时gc导致的变化 +allocation1[0.25M],+allocation2[[0.25M], +allocation3[4MB] 4执行后 +allocation4[4MB] +allocation1[0.25M],+allocation2[[0.25M], +allocation3[4MB] 6执行时gc导致的变化 allocation3[4MB],+allocation1[0.25M],+allocation2[[0.25M], 6执行后 +allocation4[4MB] allocation3[4MB],+allocation1[0.25M],+allocation2[[0.25M], 代码清单3-11 说明 # -XX:-HandlePromotionFailure 关 # Eden[8M] Survivor1[1M] Survivor2[1M] Old {10M} 初始 allocation1[2M],allocation2[2M],allocation3[2M]allocation1[null],allocation4[2M] 5执行时gc导致的变化 +allocation2[2M],+allocation3[2M] //总共4M 5执行后 +allocation4[2M] +allocation2[2M],+allocation3[2M] //总共4M 6-\u0026gt;11 allocation4[2M]+allocation5[2M],+allocation6[2M] allocation2[2M],allocation3[2M] //总共4M,此时老年代连续可用空间在6M(或者说小于6M) 11执行时gc导致的变化 allocation3[4MB],+allocation1[0.25M],+allocation2[[0.25M], 11执行后 +allocation7[2MB] allocation3[4MB],+allocation1[0.25M],+allocation2[[0.25M], 说明 # 书籍版权归著者和出版社所有\n本PDF来自于各个广泛的信息平台,经过整理而成\n本PDF仅限用于非商业用途或者个人交流研究学习使用\n本PDF获得者不得在互联网上以任何目的进行传播,违规者造成的法律责任和后果,违规者自负\n如果觉得书籍内容很赞,请一定购买正版实体书,多多支持编写高质量的图书的作者和相应的出版社!当然,如果图书内容不堪入目,质量低下,你也可以选择狠狠滴撕裂本PDF\n技术类书籍是拿来获取知识的,不是拿来收藏的,你得到了书籍不意味着你得到了知识,所以请不要得到书籍后就觉得沾沾自喜,要经常翻阅!!经常翻阅\n请于下载PDF后24小时内研究使用并删掉本PDF\n"},{"id":224,"href":"/zh/docs/problem/JVM/2023052302/","title":"linux中调试open jdk","section":"Jvm","content":" 完全转载自https://lin1997.github.io/2020/07/19/debug-openjdk-on-ubuntu.html ,以防丢失故作备份,目前还没看懂。\n在Ubuntu中编译和调试OpenJDK # OpenJDK\nUbuntu\nCLion\n2020年 07月19日\n构建编译环境 # 安装GCC编译器:\nsudo apt install build-essential 安装OpenJDK依赖库:\n工具 库名称 安装命令 FreeType The FreeType Project sudo apt install libfreetype6-dev CUPS Common UNIX Printing System sudo apt install libcups2-dev X11 X Window System sudo apt install libx11-dev libxext-dev libxrender-dev libxrandr-dev libxtst-dev libxt-dev ALSA Advanced Linux Sound Architecture sudo apt install libasound2-dev libffi Portable Foreign Function Interface sudo apt install libffi-dev Autoconf Extensible Package of M4 Macros sudo apt install autoconf zip/unzip unzip sudo apt install zip unzip fontconfig fontconfig sudo apt install libfontconfig1-dev 假设要编译大版本号为N的JDK,我们还要安装一个大版本号至少为N-1的、已经编译好的JDK作为“Bootstrap JDK”:\nsudo apt install openjdk-11-jdk 获取源码 # 可以直接访问准备下载的JDK版本的仓库页面(譬如本例中OpenJDK 11的页面为https://hg.openjdk.java.net/jdk-updates/jdk11u/),然后点击左边菜单中的“Browse”,再点击左边的“zip”链接即可下载当前版本打包好的源码,到本地直接解压即可。\n也可以从Github的镜像Repositories中获取(https://github.com/openjdk),进入所需版本的JDK的页面,点击Clone按钮下的Download ZIP按钮下载打包好的源码,到本地直接解压即可。\n进行编译 # 首先进入解压后的源代码目录,本例解压到的目录为~/openjdk/:\ncd ~/openjdk 要想带着调试、定制化的目的去编译,就要使用OpenJDK提供的编译参数,可以使用bash configure --help查看. 本例要编译SlowDebug版、仅含Server模式的HotSpot虚拟机,同时我们还可以禁止压缩生成的调试符号信息,方便gdb调试获取当前正在执行的源代码和行号等调试信息. 对应命令如下:\nbash configure --with-debug-level=slowdebug --with-jvm-variants=server --disable-zip-debug-info 对于版本较低的OpenJDK,编译过程中可能会出现了源码deprecated的错误,这是因为\u0026gt;=2.24版本的glibc中 ,readdir_r等方法被标记为deprecated。若读者也出现了该问题,请在configure命令加上--disable-warnings-as-errors参数,如下:\nbash configure --with-debug-level=slowdebug --with-jvm-variants=server --disable-zip-debug-info --disable-warnings-as-errors 此外,若要重新编译,请先执行make dist-clean\n执行make命令进行编译:\nmake 生成的JDK在build/配置名称/jdk中,测试一下,如:\ncd build/linux-x86_64-normal-server-slowdebug/jdk/bin ./java -version 生成Compilation Database # CLion可以通过Compilation Database来导入项目. 在OpenJDK 11u及之后版本中,OpenJDK官方提供了对于IDE的支持,可以使用make compile-commands命令生成Compilation Database:\nmake compile-commands 对于版本较低的OpenJDK,可以使用一些工具来生成Compilation Database,比如:\nBear scan-build compiled 然后检查一下build/配置名称/下是否生成了compile_commands.json.\ncd build/linux-x86_64-normal-server-slowdebug ls -l 导入项目至CLion # 优化CLion索引速度 # 提高Inotify监视文件句柄上限,以优化CLion索引速度:\n在/etc/sysctl.conf中或 /etc/sysctl.d/目录下新建一个*.conf文件,添加以下内容:\nfs.inotify.max_user_watches = 524288 应用更改:\nsudo sysctl -p --system 重新启动CLion\n导入项目 # 打开CLion,选择Open Or Import,选择上文生成的build/配置名称/compile_commands.json文件,弹出框选择Open as Project,等待文件索引完成.\n接着,修改项目的根目录,通过Tools -\u0026gt; Compilation Database -\u0026gt; Change Project Root功能,选中你的源码目录.\n为了减少CLion索引文件数,提高CLion效率,建议将非必要的文件夹排除:Mark Directory as -\u0026gt; Excluded. 大部分情况下,我们只需要索引以下文件夹下的源码:\nsrc/hotspot src/java.base 配置调试选项 # 创建自定义Build Target # 点击File菜单栏,Settings -\u0026gt; Build, Execution, Deployment -\u0026gt; Custom Build Targets,点击+新建一个Target,配置如下:\nName:Target的名字,之后在创建Run/Debug配置的时候会看到这个名字\n点击Build或者Clean右边的三点,弹出框中点击+新建两个External Tool配置如下:\n# 第一个配置如下,用来指定构建指令 # Program 和 Arguments 共同构成了所要执行的命令 \u0026#34;make\u0026#34; Name: make Program: make Arguments: Working directory: {项目的根目录} # 第二个配置如下,用来清理构建输出 # Program 和 Arguments 共同构成了所要执行的命令 \u0026#34;make clean\u0026#34; Name: make clean Program: make Arguments: clean Working directory: {项目的根目录} ToolChain选择Default;Build选择make(上面创建的第一个External Tool);Clean选择make clean(上面创建的第二个External Tool)\n创建自定义的Run/Debug configuration # 点击Run菜单栏,Edit Configurations, 点击+,选择Custom Build Application,配置如下:\n# Executable 和 Program arguments 可以根据需要调试的信息自行选择 # Name:Configure 的名称 Name: OpenJDK # Target:选择上一步创建的 “Custom Build Target” Target: {上一步创建的 “Custom Build Target”} # Executable:程序执行入口,也就是需要调试的程序 Executable: 这里我们调试`java`,选择`{source_root}/build/{build_name}/jdk/bin/java`。 # Program arguments: 与 “Executable” 配合使用,指定其参数 Program arguments: 这里我们选择`-version`,简单打印一下`java`版本。 # Before luanch:这个下面的Build可去可不去,去掉就不会每次执行都去Build,节省时间,但其实OpenJDK增量编译的方式,每次Build都很快,所以就看个人选择了。 配置GDB # 由于HotSpot JVM内部使用了SEGV等信号来实现一些功能(如NullPointerException、safepoints等),所以调试过程中,GDB可能会误报Signal: SIGSEGV (Segmentation fault). 解决办法是,在用户目录下创建.gdbinit,让GDB捕获SEGV等信号:\nvi ~/.gdbinit 将以下内容追加到文件中并保存:\nhandle SIGSEGV nostop noprint pass 开始调试 # 使用CLion调试C++层面的代码 # 完成以上配置之后,一个可修改、编译、调试的HotSpot工程就完全建立起来了。HotSpot虚拟机启动器的执行入口是${source_root}/src/java.base/share/native/libjli/java.c的JavaMain()方法,读者可以设置断点后点击Debug可开始调试.\n使用GDB调试汇编层面的代码 # 这里提供两个方法,一个是使用-XX:StopInterpreterAt=\u0026lt;n\u0026gt;虚拟机参数来实现中断,缺点是需要找到你所感兴趣的字节码在程序中的序号;第二个方法是直接去寻找记录生成的机器指令的入口(EntryPoint)的表,即Interpreter::_normal_table,在对应的字节码入口地址打断点,但是这需要读者对模板解释器有一定了解。\n使用虚拟机参数进行中断 # 对于汇编级别的调试,我们可以手动使用GDB进行调试:\ngdb build/linux-x86_64-normal-server-slowdebug/jdk/bin/java 由于目前HotSpot在主流的操作系统上,都采用模板解释器来执行字节码,它与即时编译器一样,最终执行的汇编代码都是运行期间产生的,无法直接设置断点,所以HotSpot增加了一些参数来方便开发人员调试解释器。\n我们可以先使用参数-XX:+TraceBytecodes,打印并找出你所感兴趣的字节码位置,中途可以使用Ctrl + C退出:\nset args -XX:+TraceBytecodes run 然后,再使用参数-XX:StopInterpreterAt=\u0026lt;n\u0026gt;,当遇到程序的第n条字节码指令时,便会进入${source_root}/src/os/linux/vm/os_linux.cpp中的空函数breakpoint():\nset args -XX:+TraceBytecodes -XX:StopInterpreterAt=\u0026lt;n\u0026gt; 再通过GDB在${source_root}/src/hotspot/os/linux/os_linux.cpp中的breakpoint()函数上打上断点:\nbreak breakpoint 为什么要将断点打在这里?\n去看${source_root}/src/hotspot/share/interpreter/templateInterpreterGenerator.cpp里,函数TemplateInterpreterGenerator::generate_and_dispatch中对stop_interpreter_at()的调用就知道了.\n接着我们开始运行hotspot:\nrun 当命中断点时,我们再跳出breakpoint()函数:\nfinish 这样就会返回到真正的字节码的执行了。\n不过,我们还要跳过函数TemplateInterpreterGenerator::generate_and_dispatch中插入到字节码真正逻辑前的一些用于debug的逻辑:\nif (PrintBytecodeHistogram) histogram_bytecode(t); // debugging code if (CountBytecodes || TraceBytecodes || StopInterpreterAt \u0026gt; 0) count_bytecode(); if (PrintBytecodePairHistogram) histogram_bytecode_pair(t); if (TraceBytecodes) trace_bytecode(t); if (StopInterpreterAt \u0026gt; 0) stop_interpreter_at(); 比如开启了参数-XX:+TraceBytecodes和-XX:StopInterpreterAt=\u0026lt;n\u0026gt;,应该跳过的指令如下:\n# count_bytecode()对应指令: 0x7fffe07e8261:\tincl 0x16901039(%rip) # 0x7ffff70e92a0 \u0026lt;BytecodeCounter::_counter_value\u0026gt; # trace_bytecode(t)对应指令: 0x7fffe07e8267:\tmov %rsp,%r12 0x7fffe07e826a:\tand $0xfffffffffffffff0,%rsp 0x7fffe07e826e:\tcallq 0x7fffe07c5edf 0x7fffe07e8273:\tmov %r12,%rsp 0x7fffe07e8276:\txor %r12,%r12 # stop_interpreter_at()对应指令: 0x7fffe07e8279:\tcmpl $0x66,0x1690101d(%rip) # 0x7ffff70e92a0 \u0026lt;BytecodeCounter::_counter_value\u0026gt; 0x7fffe07e8283:\tjne 0x7fffe07e828e 0x7fffe07e8289:\tcallq 0x7ffff606281a \u0026lt;os::breakpoint()\u0026gt; #\t......................... #\t......真正的字节码逻辑...... #\t......................... # dispatch_epilog(tos_out, step)对应指令,用来取下一条指令执行... 进入真正的字节码逻辑后,我们就可以使用指令级别的stepi, nexti命令来进行跟踪调试了。(由于汇编代码都是运行期产生的,GDB中没有与源代码的对应符号信息,所以不能用C++源码行级命令step以及next)\n寻找字节码机器指令的入口手动打断点 # 关于模板解释器相关知识,可以阅读: JVM之模板解释器.\n还是一样,运行GDB:\ngdb build/linux-x86_64-normal-server-slowdebug/jdk/bin/java start break JavaMain continue 我们先在${source_root}/src/hotspot/share/interpreter/templateInterpreter.cpp的DispatchTable::set_entry(...)函数上打条件断点,条件是函数实参i == \u0026lt;字节码对应十六进制\u0026gt;,字节码对应的十六进制见:${source_root}/src/hotspot/share/interpreter/bytecodes.hpp的Bytecodes::Code.\nbreak DispatchTable::set_entry if i==\u0026lt;字节码对应十六进制\u0026gt; 然后继续运行\ncontinue 命中断点后,查看函数实参entry所指向的内存地址\nprint entry 在这个地址上打断点。\nbreak *\u0026lt;内存地址\u0026gt; 然后继续运行\ncontinue 命中断点后,就跟前一个方法一样可以直接使用指令级别的stepi, nexti命令来进行跟踪调试了。\n配置IDEA # 为项目的绑定JDK源码路径 # 打开IDEA,新建一个项目。然后选择File -\u0026gt; Project Structure,选到SDKs选项,新添加上自己刚刚编译生成的JDK,JDK home path为${source_root}/build/配置名称/jdk. 然后在Sourcepath下移除原本的源码路径(如果有),并添加为前面的源代码,如${source_root}/src/java.base/share/classes等. 这样以来,我们就可以在IDEA中编辑JDK的JAVA代码,添加自己的注释了。\n重新编译JDK的JAVA代码 # 在添加中文注释后,再编译JDK时会报错:\nerror: unmappable character (0x??) for encoding ascii\n我们可以在${source_root}/make/common/SetupJavaCompilers.gmk中,修改两处编码方式的设置,替换原内容:\n-encoding ascii 为:\n-encoding utf-8 这样编译就不会报错了。\n而且,如果我们只修改了JAVA代码,无需使用make命令重新编译整个OpenJDK,而只需要使用以下命令仅编译JAVA模块:\nmake java 使用IDEA的Step Into跟踪调试源码 # 我们发现,在IDEA调试JDK源码时,无法使用Step Into(F7)跟进JDK中的相关函数,这是因为IDEA默认设置不步入这些内置的源码。可以在File -\u0026gt; Settings -\u0026gt; Build, Execution, Deployment -\u0026gt; Debugger -\u0026gt; Stepping中,取消勾选Do not step into the classes来取消限制。\n参考文章 # Tips \u0026amp; Tricks: Develop OpenJDK in CLion with Pleasure OpenJDK 编译调试指南(Ubuntu 16.04 + MacOS 10.15) JVM-在MacOS系统上使用CLion编译并调试OpenJDK12 深入理解Java虚拟机:JVM高级特性与最佳实践(第3版) 编译JDK源码踩坑纪实 How to to debug the HotSpot interpreter JVM之模板解释器 "},{"id":225,"href":"/zh/docs/problem/Linux/20230523/","title":"zsh卸载后root无法登录及vm扩容centos7报错处理","section":"Linux","content":" zsh卸载后root无法登录 # 主要参考文档 https://blog.csdn.net/Scoful/article/details/119746150\n重启,开机引导进入下面的那个,按e进入编辑模式 移动光标,找到ro crashkernel=auto,修改为 rw init=sysroot/bin/sh\n按ctrl+x进入单用户模式界面\n输入chroot /sysroot 获取权限 vim /etc/passwd 第一行 ,root \u0026hellip;\u0026hellip;zsh,中/bin/zsh,改为/bin/bash 用touch /.autorelabel更新SELinux信息 两次exit 推出chroot reboot 重启:需要一定时间,耐心等待 vm扩容centos7 # 这里是因为我在vm手动扩容后,进入centos7系统\u0026mdash;用了 可视化界面中的disk软件直接扩容,发生错误(具体错误我没注意,一闪而过了),后面呢我再使用命令resize2fs /dev/sda3的时候,发现总是提示 busy\n解决办法 # 按照上面的办法,进入到第3步结束之后(按ctrl+x进入单用户模式界面 要做)\n输入 umount /dev/sda3 进行卸载\n然后输入下面进行修复(极为重要),然后出现问题是否修复一直按\u0026rsquo;y\u0026rsquo;即可\nxfs_repair /dev/sda4 注:如果你当前文件系统是ext4,可以执行fsck.ext4 /dev/sda4 然后输入 mount /dev/sda3 / 进行挂载(这步可能不需要)\n最后 reboot 重启 重启之后,再执行 resize2fs /dev/sda3 即可\n"},{"id":226,"href":"/zh/docs/problem/Hexo/01/","title":"hexo在线查看pdf","section":"Hexo","content":" 场景 # 由于在看《mysql是如何运行的》,做md文件笔记时,发现好多都是按pdf一字不漏打出来。所以想着能不能直接本地编辑pdf,然后博客上支持在线查看。\n事后觉得这个方式有待斟酌,电脑上/平板上查看没啥问题,手机上查看字有点小,但也还能接受。==\u0026gt;待斟酌\n不过下面的方案是可行的。\n准备 # 需要到官网下载 pdf.js\nhttps://github.com/mozilla/pdf.js/releases ,这里选择 v3.4.120中的 pdfjs-3.4.120-dist.zip ,最新版本好像有问题\n操作 # pdfjs处理 # 在source/下创建myjs/pdfjs文件夹,并解压到这个文件夹下\n修改pdfjs/web/viewer.js\nif (fileOrigin !== viewerOrigin) {//1563行左右 throw new Error(\u0026#34;file origin does not match viewer\u0026#39;s\u0026#34;); } //注释掉,为了处理跨域问题,注释掉后允许在线访问其他网站的pdf // if (fileOrigin !== viewerOrigin) { //\tthrow new Error(\u0026#34;file origin does not match viewer\u0026#39;s\u0026#34;); //} hexo配置修改 # # 找到# Directory下的skip_render项,添加忽略渲染的文件夹 skip_render: [\u0026#39;myjs/pdfjs/**/*\u0026#39;] 清理hexo中public及其他缓存文件 # hexo clean \u0026amp; hexo g 文件预览测试 # 本地文件 # 我们在hexo的source文件夹下,放置这样一个文件: source/pdf/my.pdf\nMD文件修改 # \u0026lt;iframe src=\u0026#39;/myjs/pdfjs/web/viewer.html?file=/pdf/my.pdf\u0026#39; style=\u0026#34;padding: 0;width:100%;\u0026#34; marginwidth=\u0026#34;0\u0026#34; frameborder=\u0026#34;no\u0026#34; scrolling=\u0026#34;no\u0026#34; height=\u0026#34;2000px\u0026#34;\u0026gt;\u0026lt;/iframe\u0026gt; 操作并查看 # hexo g \u0026amp; hexo s 远程文件 # ‘\n也就是在我的账号(lwmfjc)下,创建一个仓库(仓库名 pdfs),然后创建一个文件夹及文件 temp/01.pdf ,这个地址是 https://raw.githubusercontent.com/lwmfjc/pdfs/main/temp/01.pdf\n注意修改账号名及仓库名 :lwmfjc/pdfs/\n文件夹及文件:temp/01.pdf\nMD文件修改 # \u0026lt;iframe src=\u0026#39;/myjs/pdfjs/web/viewer.html?file=https://raw.githubusercontent.com/lwmfjc/pdfs/main/mysql/01.pdf\u0026#39; style=\u0026#34;padding: 0;width:100%;\u0026#34; marginwidth=\u0026#34;0\u0026#34; frameborder=\u0026#34;no\u0026#34; scrolling=\u0026#34;no\u0026#34; height=\u0026#34;2000px\u0026#34;\u0026gt;\u0026lt;/iframe\u0026gt; 操作并查看 # hexo g \u0026amp; hexo s "},{"id":227,"href":"/zh/docs/technology/MySQL/_how_mysql_run_/07/","title":"07B+数索引的使用","section":"_MySQL是怎样运行的_","content":" 学习《MySQL是怎样运行的》,感谢作者!\nInnoDB存储引擎的B+树索引:结论 # 每个索引对应一颗B+树。B+树有好多层,最下边一层是叶子节点,其余是内节点。所有用户记录都存在B+树的叶子节点,所有目录项记录都存在内节点 InnoDB 存储引擎会自动为主键建立聚簇索引(如果没有显式指定主键或者没有声明不允许存储NULL的UNIQUE 键,它会自动添加主键) , 聚簇索引的叶子节点包含完整的用户记录 我们可以为感兴趣的列建立二级索引,二级索引的叶子节点包含的用户记录由索引列 和主键组成。如果想通过二级索引查找完整的用户记录,需要执行回表操作, 也就是在通过二级索引找到主键值之后,再到聚簇索引中查找完整的用户记录 B+ 树中的每层节点都按照索引列的值从小到大的顺序排序组成了双向链表,而且每个页内的记录(无论是用户记录还是目录项记录)都按照索引列的值从小到大的顺序形成了一个单向链表。如果是联合索引, 则页面和记录 先按照索引列中前面的列的值排序:如果该列的值相同,再按照索引列中后面的列的值排序。比如, 我们对列c2 和c3建立了联合索引 idx_c2_c3(c2, c3),那么该索引中的页面和记录就先按照c2 列的值进行排序;如果c2 列的值相同, 再按照c3 列的值排序 通过索引查找记录时,是从B+ 树的根节点开始一层一层向下搜索的。由于每个页面(无论是内节点页面还是叶子节点页面〉中的记录都划分成了若干个组, 每个组中索引列值最大的记录在页内的偏移量会被当作槽依次存放在页目录中(当然, 规定Supremum 记录比任何用户记录都大) ,因此可以在页目录中通过二分法快速定位到索引列等于某个值的记录 如果大家在阅读上述结论时哪怕有点疑惑, 那么下面的内容就不适合你,请回过头去反复阅读前面的章节\nB+树索引示意图的简化 # #创建新表 mysql\u0026gt; CREATE TABLE single_table( id INT NOT NULL AUTO_INCREMENT, key1 VARCHAR(100), key2 INT, key3 VARCHAR(100), key_part1 VARCHAR(100), key_part2 VARCHAR(100), key_part3 VARCHAR(100), common_field VARCHAR(100), PRIMARY KEY (id), KEY idx_key1(key1), UNIQUE KEY uk_key2(key2), KEY idx_key3(key3), KEY idx_key_part(key_part1,key_part2,key_part3) ) Engine=InnoDB CHARSET = utf8; 如上,建立了1个聚簇索引,4个二级索引\n为id列建立的聚簇索引 为key1列建立的idx_key1二级索引 为key2列建立的uk_key2二级索引,而且该索引是唯一二级索引 为key3列建立的idx_key3二级索引 为key_part1、key_part2、key_part3列建立的idx_key_part二级索引,是一个联合索引 接下来为这个表插入10,000行记录\n除了id,其余的列取随机值:该表后面会频繁用到\n需要用程序写,这里暂时跳过(不会\u0026hellip;,书上也没写)\n回顾:B+树包括内节点和叶子节点,以及各个节点中的记录。B+树其实是一个矮矮的大胖子,能够利用B+树快速地定位记录,下面简化一下B+树的示意图:\n忽略页结构,直接把所有叶子节点中的记录放一起 为了方便,把聚簇索引叶子节点的记录称为聚簇索引记录,把二级索引叶子节点称为二级索引记录 回顾一下:\n核心要点:把下一层每一页的最小值,放到上一级的目录项记录,以key值+页号这样的组合存在\n精简:\n如上,聚簇索引记录是按照主键值由小到大的顺序排列的 如下图,通过B+树定位到id值为1438的记录\n二级索引idx_key1对应的B+树中保留了叶子结点的记录。以key1排序,如果key1相同,则按照id列排序\n为了方便,把聚簇索引叶子节点的记录称为聚簇索引记录,把二级索引叶子节点称为二级索引记录\n如果要查找key1值等于某个值的二级索引记录,通过idx_key1对应的B+树,可以很容易定位到第一条key1列的值等于某个值的二级索引记录,然后沿着单向链表向后扫描即可。\n索引的代价 # 空间上的代价 # 每建立一个索引,都要为他建立一颗B+树。每一颗B+树的每一个节点都是一个数据页(一个数据页默认占用16KB),而一颗很大的B+树由许多数据页组成,这将占用很大的片存储空间\n时间上的代价 # 每当对表中数据进行增上改查时,都要修改各个B+树索引 执行查询语句前,都要生成一个执行计划。一般情况下,一条查询语句在执行过程中最多用到一个二级索引(有例外,10章),在生成执行计划时需要计算使用不同索引执行查询时所需要的成本,最后选取成本最低的那个索引执行查询(12章:如何计算查询成本)==\u0026gt; 索引太多导致分析时间过长 总结 # 索引越多,存储空间越多,增删改记录或者生成执行计划时性能越差\n为了建立又好又少的索引,得先了解索引在查询执行期间到底是如何发挥作用的\n应用B+树索引 # 对于某个查询来说,最简单粗暴的执行方案就是扫描表中的所有记录。判断每一条记录是否符合搜索条件。如果符合,就将其发送到客户端,否则就跳过该记录。这种执行方案也称为全表扫描。\n对于使用 InnoDB 存储引擎的表来说,全表扫描意味着从聚簇索引第一个叶子节点的第一条记录开始,沿着记录所在的单向链表向后扫描 直到最后一个叶子节点的最后一条记录(叶子节点:页,16KB;即页内最后一条)。虽然全表扫描是一种很笨的执行方案,但却是一种万能的执行方案,所有的查询都可以使用这种方案来执行。\n扫描区间和边界条件 # 可以利用B+树查找索引值等于某个值的记录=\u0026gt;减少需要扫描的记录数量。由于*B+树叶子节点中的记录是按照索引列值由小到大的顺序排序的,所以只扫描某个区间或者某些区间中的记录也可以明显减少需要扫描的记录数量。\n简单例子 # 例子1(聚簇索引) # 例子:SELECT * FROM single_table WHERE id\u0026gt;=2 AND id \u0026lt;=100\n这个语句其实是要找id值在**[2,100]区间中的所有聚簇索引**记录。\n可以通过聚簇索引对应的B+树快速地定位到id值为2的那条聚簇索引记录,然后沿着记录所在的单向链表向后扫描,直到某条聚簇索引记录的id值不在[2,100]区间中为止(即id不再符合id\u0026lt;=100条件) 与扫描全部的聚簇索引记录相比,扫描id 值在**[2,100]** 区间中的记录已经很大程度地减少了需要扫描的记录数量, 所以提升了查询效率。简便起见,我们把这个例子中待扫描记录的id 值所在的区间称为扫描区间,把形成这个扫描区间的搜索条件(也就是id \u0026gt;= 2AND \u0026gt; id \u0026lt;= 100 ) 称为形成这个扫描区间的边界条件. 对于全表扫描来说,相当于扫描id在**(-∞,+∞)** 区间中的记录,也就是说全表扫描对应的扫描区间是**(-∞,+∞)**\n例子2(二级索引) # SELECT * FROM single_table WHERE key2 IN (1438,6328 ) OR (key2 \u0026gt;=38 AND key2 \u0026lt;=79) 可以直接使用全表扫描的方式执行该查询。\n但是我们发现该查询的搜索条件涉及key2列,而我们又正好为key2列建立了uk_key2索引。如果使用uk_key2索引执行这个查询,则相当于从下面的3个扫描区间中获取二级索引记录:\n[1438,1438] :对应的边界条件就是key2 IN (1438) [6328,6328]:对应的边界条件就是key2 IN (6328) [38,79]:对应的边界条件就是key2 \u0026gt;= 38 AND key2 \u0026lt;= 79 这些扫描区间对应到数轴上时,如图\n方便起见,我们把像[1438,1438]、[6328, 6328] 这样只包含一个值的扫描区间称为单点扫描区间, 把[38, 79] 这样包含多个值的扫描区间称为范围扫描区间。另外,由于我们的查询列表是 * ,也就是需要读取完整的用户记录,所以从上述扫描区间中每获取一条二级索引记录, 就需要根据该二级索引记录id列的值执行回表操作,也就是到聚簇索引中找到相应的聚簇索引记录。\n其实我们不仅仅可以使用uk_key2 执行上述查询, 还可以使用idx_key1、idx_key3 、idx_key_part 执行上述查询。以idx_key_1 为例,很显然无法通过搜索条件形成合适的扫描区间来减少需要扫描的idx_key1 二级索引记录的数量,只能扫描idx_keyl 的全部二级索引记录。针对获取到的每一条二级索引记录,都需要执行回表操作来获取完整的用户记录.。我们也可以说,使用idx_key1 执行查询时对应的扫描区间就是**(-∞,+∞)** 这样虽然行得通,但我们图啥呢,最简单粗暴的全表扫描方式已经需要扫描全部的聚簇索引记录, 这里除了需要访问全部的聚簇索引记录,还要扫描全部的idx_key1二级索 引记录,这不是费力不讨好么。可见, 在这个过程中并没有减少需要扫描的记录数量,效 率反而比全表扫描差。所以如果想使用某个索引来执行查询,但是又无法通过搜索条件 形成合适的扫描区间来减少需要扫描的记录数量时, 则不考虑使用这个索引执行查询 例3 不是索引的搜索条件都可以成为边界条件 # SELECT * FROM single_table WHERE key1 \u0026lt; \u0026#39;a\u0026#39; AND key3 \u0026gt; \u0026#39;z\u0026#39; AND common_field = \u0026#39;abc\u0026#39; 如果使用idx_key1 执行查询,那么相应的扫描区间就是(-∞,\u0026lsquo;a\u0026rsquo;),形成该扫描区间的边界条件就是key1 \u0026lt; \u0026lsquo;a\u0026rsquo;。而 key3 \u0026gt; \u0026lsquo;z\u0026rsquo; AND common_field = \u0026lsquo;abc\u0026rsquo;就是普通的搜索条件,这些普通的搜索条件需要在获取到idx_key1的二级索引记录后,再执行回表操作,在获取到完整的用户记录后才能去判断它们是否成立 而如果使用idx_key3 执行查询,那么相应的扫描区间就是\u0026rsquo;z\u0026rsquo;,形成该扫描区间的边界条件就是key3\u0026gt;\u0026lsquo;z\u0026rsquo;。而key1\u0026lt;\u0026lsquo;a\u0026rsquo; AND common_field=\u0026lsquo;abc\u0026rsquo;就是普通的搜索条件,这些普通的搜索条件需要在获取到idx_key3的二级索引记录后,再执行回表操作,在获取到完整的用户记录后才能去判断它们是否成立 总结 # 在使用某个索引执行查询时,关键的问题就是通过搜索条件找出合适的扫描区间,然后再到对应的B+ 树中扫描索引列值在这些扫描区间的记录。对于每个扫描区间来说,仅需要通过B+ 树定位到该扫描区间中的第一条记录,然后就可以沿着记录所在的单向链表向后扫描,直到某条记录不符合形成该扫描区间的边界条件为止。其实对于B+ 树索引来说,只要索引列和常数使用**=、\u0026lt;=\u0026gt;、lN、NOT IN、IS NULL、IS NOT NULL、\u0026gt; 、\u0026lt;、=、\u0026lt;=、BETWEEN 、! = (也可以写成\u0026lt; \u0026gt;)或者LIKE 操作符连接起来,就可以产生所谓的扫描区间**。不过有下面几点需要注意:\nlN操作符的语义与若干个等值匹配操作符( =)之间用OR 连接起来的语义是一样的,都会产生多个单点扫描区间。比如下面这两个语句的语义效果是一样的:\nSELECT * FROM single_table WHERE key2 IN (1438,6328); #与上面的语义效果一样 SELECT * FROM single_table WHERE key2 = 1438 OR key2 = 6328 != 产生的扫描区间比较有趣,如:\nSELECT * FROM single_table key1 != \u0026#39;a\u0026#39;; 此时idx_key1执行查询时对应的扫描区间就是(-∞,\u0026lsquo;a\u0026rsquo;) 和(\u0026lsquo;a\u0026rsquo;,+∞)\nLIKE操作符比较特殊,只有在匹配完整的字符串或者匹配字符串前缀时才产生合适的扫描区间\n比较字符串的大小,其实就相当于一次比较每个字符的大小。字符串的比较过程如下所示:\n先比较字符串的第一个字符:第一个字符小的那个字符串就比较小 如果两个字符串的第一个字符相同,再比较第二个字符;第二个字符比较小的那个字符串就比较小 如果两个字符串的前两个字符都相同,那么就接着比较第三个字符:依此类推 对于某个索引列来说,字符串前缀相同的记录在由记录组成的单向链表中肯定是相邻的。\n比如我们有一个搜索条件是key1 LIKE \u0026lsquo;a%\u0026rsquo;。 对于二级索引 idx_key1 来说,所有字符串前缀为\u0026rsquo;a\u0026rsquo;的二级索引记录肯定是相邻的。这也就意味着我们只要定位 key1 值的字符串前缀为\u0026rsquo;a\u0026rsquo; 的第一条记录,就可以沿着记录所在的单向链表向后扫描, 直到某条二级索引记录的字符串前缀不为\u0026rsquo;a\u0026rsquo; 为止,如图7-7 所示。很显然 key1 LIKE \u0026lsquo;a%\u0026rsquo; 形成的扫描区间相当于**[\u0026lsquo;a\u0026rsquo;,\u0026lsquo;b\u0026rsquo;)** 稍复杂例子 # 日常工作中,一个查询语句中的WHERE子句可能有多个小的搜索条件,这些搜索条件使用AND 或者OR 操作符连接起来。虽然大家都知道这两个操作符的作用,但这里还是要再强调一遍:\ncond1 AND cond2 只有当cond1和cond2都为TRUE 时,整个表达式才为TRUE cond1 OR cond2 , 只要cond1 或者cond2 中有一个为TRUE, 整个表达式就为TRUE 在我们执行一个查询语句时,首先需要找出所有可用的索引以及使用它们时对应的扫描区间。下面我们来看一下怎么从包含若干个AND 或OR 的复杂搜索条件中提取出正确的扫描区间:\n所有搜索条件都可以生成合适的扫描区间的情况 # AND结合 # SELECT * FROM single_table WHERE key2 \u0026gt; 100 AND key2 \u0026gt; 200; 其中,每个小的搜索条件都可以生成一个合适的扫描区间来减少需要扫描的记录数量,最终的扫描区间就是对这两个小的搜索条件形成的扫描区间取交集后的结果,取交集的过程:\n上面查询语句使用uk_key2索引执行查询时对应的扫描区间就是**(200,+∞),形成该扫描区间的边界条件就是key2 \u0026gt; 200**\nOR结合 # 使用OR操作符将多个搜索条件连接在一起:\nSELECT * FROM single_table WHERE key2 \u0026gt; 100 OR key2 \u0026gt; 200 OR意味着需要取各个扫描区间的并集,取并集的过程如图所示:\n即,上面的查询语句在使用uk_key2索引执行查询时,对应的扫描区间就是**(100,+∞),形成扫描区间的边界条件就是key2 \u0026gt; 100**\n有的搜索条件不能生成合适的扫描区间的情况 # AND情况 # 有的搜索条件不能生成合适的扫描区间来减少需要扫描的记录数量\nSELECT * FROM single_table WHERE key2 \u0026gt; 100 AND common_field = \u0026#39;abc\u0026#39; 分析:使用uk_key2执行查询时,搜索条件key2\u0026gt;100可以形成扫描区间(100,+∞)。但是由于uk_key2的二级索引并不按照common_field列进行排序(uk_key2二级索引记录中压根儿不包含common_field列),所以仅凭搜索条件common_field = \u0026lsquo;abc\u0026rsquo;并不能减少需要扫描的二级索引记录数量。即该搜索条件生成的扫描区间其实是**(-∞,+∞)。由于这两个小的搜索条件是使用AND操作符连接,所以对(100,+∞)** 和 (-∞,+∞)这两个搜索区间取交集后得到的结果自然是**(100,+∞)。即使用uk_key2执行上述查询,最终对应的扫描区间就是(100,+∞),形成该扫描区间的条件就是key2\u0026gt;100\n简化:使用uk_key2执行查询时,在寻找对应的扫描区间**的过程中,搜索条件 common_field = \u0026lsquo;abc\u0026rsquo;没起到任何作用,我们可以直接把 common_field = \u0026lsquo;abc\u0026rsquo; 搜索条件替换为TRUE,(TRUE对应的扫描区间也是(-∞,+∞)),如下:\nSELECT * FROM single_table WHERE key2 \u0026gt; 100 AND TRUE # 简化之后 SELECT * FROM single_table WHERE key2 \u0026gt; 100 即上面的查询语句使用uk_key2执行查询时对应的扫描区间是**(100,+∞)**\nOR情况 # SELECT * FROM single_table WHERE key2 \u0026gt; 100 OR common_field = \u0026#39;abc\u0026#39; #替换之后 SELECT * FROM single_table WHERE key2 \u0026gt; 100 OR TRUE 所以,如果强制使用uk_key2执行查询,由于这两个小的搜索条件是使用OR操作符连接,所以对**(100,+∞)** 和 (-∞,+∞)这两个搜索区间取并集后得到的结果自然是**(-∞,+∞)。也就是需要扫描uk_key2的全部二级索引记录**,并且对于获取到的每一条二级索引记录,都需要执行回表操作。这个代价比执行全表扫描的代价都大。这种情况下,不考虑使用uk_key2来执行查询\n从复杂的搜索条件中找出扫描区间 # SELECT * FROM single_table WHERE (key1 \u0026gt; \u0026#39;xyz\u0026#39; AND key2 =748) OR (key1\u0026lt;\u0026#39;abc\u0026#39; AND key1 \u0026gt; \u0026#39;lmn\u0026#39;) OR (key1 LIKE \u0026#39;%suf\u0026#39; AND key1 \u0026gt; \u0026#39;zzz\u0026#39; AND (key2 \u0026lt; 8000 OR common_field = \u0026#39;abc\u0026#39;)) 分析:\n涉及到的列,以及为哪些列建立了索引\n设计key1,key2,common_field这三个列,其中key1列有普通二级索引idx_key1,key2列有唯一二级索引uk_key2 对于可能用到的索引,分析它们的扫描区间 假设使用idx_key1执行查询 # 把不能形成合适扫描区间的搜索条件暂时移除掉:直接替换为TRUE\n除了有关key2和common_field列的搜索条件不能形成合适的扫描区间,还有key1 LIKE \u0026lsquo;%suf\u0026rsquo;形成的扫描区间是(-∞,+∞),所以也需要替换成TRUE,这些不能形成合适扫描区间的搜索条件替换成TRUE之后,搜索条件如下所示:\nSELECT * FROM single_table WHERE (key1 \u0026gt; \u0026#39;xyz\u0026#39; AND TRUE) OR (key1\u0026lt;\u0026#39;abc\u0026#39; AND key1 \u0026gt; \u0026#39;lmn\u0026#39;) OR (TRUE AND key1 \u0026gt; \u0026#39;zzz\u0026#39; AND (TRUE OR TRUE)) #简化 SELECT * FROM single_table WHERE (key1 \u0026gt; \u0026#39;xyz\u0026#39; ) OR (key1\u0026lt;\u0026#39;abc\u0026#39; AND key1 \u0026gt; \u0026#39;lmn\u0026#39;) OR (key1 \u0026gt; \u0026#39;zzz\u0026#39; AND (TRUE OR TRUE) ) #进一步简化 SELECT * FROM single_table WHERE (key1 \u0026gt; \u0026#39;xyz\u0026#39; ) OR (key1\u0026lt;\u0026#39;abc\u0026#39; AND key1 \u0026gt; \u0026#39;lmn\u0026#39;) OR (key1 \u0026gt; \u0026#39;zzz\u0026#39;) #由于key1\u0026lt;\u0026#39;abc\u0026#39; AND key1 \u0026gt;\u0026#39;lmn\u0026#39; 永远为FALSE,所以进一步简化 SELECT * FROM single_table WHERE (key1 \u0026gt; \u0026#39;xyz\u0026#39; ) OR (key1 \u0026gt; \u0026#39;zzz\u0026#39;) #继续简化(取范围大的,即并集) SELECT * FROM single_table WHERE key1 \u0026gt; \u0026#39;xyz\u0026#39; 即如果使用idx_key1索引执行查询,则对应扫描区间为(\u0026lsquo;xyz\u0026rsquo;,+∞)。\n也就是需要把所有满足key1\u0026gt;\u0026lsquo;xyz\u0026rsquo;条件的所有二级索引记录都取出来,针对获取到的每一条二级索引记录,都要用它的主键值再执行回表操作,在得到完整的用户记录之后再使用其他的搜索条件进行过滤\n使用idx_key1执行上述查询时,搜索条件key1 LIKE %suf比较特殊,虽然不能作为形成扫描区间的边界条件,但是idx_key1的二级索引记录是包括key1列的,因此可以*先判断获取到的二级索引记录是否符合这个条件,如果符合再执行回表操作,如果不符合则不执行回表操作。这可减少因回表操作带来的性能损耗,这种优化方式称为索引条件下推\n假设使用idx_key2执行查询 # 对于:\nSELECT * FROM single_table WHERE (key1 \u0026gt; \u0026#39;xyz\u0026#39; AND key2 =748) OR (key1\u0026lt;\u0026#39;abc\u0026#39; AND key1 \u0026gt; \u0026#39;lmn\u0026#39;) OR (key1 LIKE \u0026#39;%suf\u0026#39; AND key1 \u0026gt; \u0026#39;zzz\u0026#39; AND (key2 \u0026lt; 8000 OR common_field = \u0026#39;abc\u0026#39;)) 简化\nSELECT * FROM single_table WHERE (TRUE AND key2 =748) OR (TRUE AND TRUE) OR (TRUE AND TRUE AND (key2 \u0026lt; 8000 OR TRUE)) #简化 key2 = 748 OR TRUE #进一步简化 TRUE 意味着如果要使用uk_key2索引执行查询,则对应的扫描区间就是**(-∞,+∞),即需要扫描uk_key2的全部二级索引记录,针对每一条二级索引记录还需要回表**,所以这种情况下不会使用uk_key2索引\n使用联合索引执行查询时对应的扫描区间 # 联合索引的索引包含多个列,B+树中的每一层页面以及每个页面中采用的排序规则较为复杂,以single_table表的idx_key_part联合索引为例,采用的排序规则如下所示:\n先按照key_part1列的值进行排序 在key_part1列的值相同的情况下,再按照key_part2列的值进行排序 在key_part1列和key_part2列值都相同的情况下,再按照key_part3列的值进行排序,画一下idx_key_part索引的示意图,如图所示:\n对于查询语句Q1(单条件) # SELECT * FROM single_table WHERE key_part1 = \u0026#39;a\u0026#39;; 由于二级索引记录是先按照key_part1列排序的,所以符合key_part1=\u0026lsquo;a\u0026rsquo;条件的所有记录肯定是相邻的。我们可以定位到符合key_part1=\u0026lsquo;a\u0026rsquo;条件的第一条记录,然后沿着记录所在的单向链表向后扫描(如果本页面中的记录扫描完了,就根据叶子节点的双向链表找到下一个页面中的第一条记录,继续沿着记录所在的单向链表向后扫描。我们之后就不强调叶子节点的双向链表了),直到某条记录不符合key_part=\u0026lsquo;a\u0026rsquo;条件为止(当然,对于获取到的每一条二级索引记录都要执行回表操作)。过程如图所示\n也就是说,如果使用idx_key_part索引执行查询语句Q1,对应的扫描区间是**[\u0026lsquo;a\u0026rsquo;,\u0026lsquo;a\u0026rsquo;],形成这个扫描区间的边界条件**就是key_part=\u0026lsquo;a\u0026rsquo;\n对于查询条件Q2(顺序2条件) # SELECT * FROM single_table WHERE key_part1=\u0026#39;a\u0026#39; AND key_part2=\u0026#39;b\u0026#39;; 由于二级索引记录是先按照key_part1列的值排序的, 在key_part1列的值相等的情况下再按照key_part2列进行排序,所以符合key_part1=\u0026lsquo;a\u0026rsquo; AND key_part2=\u0026lsquo;b\u0026rsquo;条件的二级索引记录肯定是相邻的。 我们可以定位到符合key_part1=\u0026lsquo;a\u0026rsquo; AND key_part2=\u0026lsquo;b\u0026rsquo;条件的第一条记录,然后沿着记录所在的单向链表向后扫描,直到某条记录不符合key_part1=\u0026lsquo;a\u0026rsquo;条件或者key_part2=\u0026lsquo;b\u0026rsquo;条件为止(当然,对于获取到的每一条二级索引记录都要执行回表操作,这里就不展示了) ,如图7-12 所示。也就是说,如果使用idx_key_part索引执行查询语句Q2 ,可以形成扫描区间**[(\u0026lsquo;a\u0026rsquo;,\u0026lsquo;b\u0026rsquo;),(\u0026lsquo;a\u0026rsquo;,\u0026lsquo;b\u0026rsquo;)]**,形成这个扫描区间的边界条件就是 key_part1=\u0026lsquo;a\u0026rsquo; AND key_part2=\u0026lsquo;b\u0026rsquo;\n[(\u0026lsquo;a\u0026rsquo;,\u0026lsquo;b\u0026rsquo;),(\u0026lsquo;a\u0026rsquo;,\u0026lsquo;b\u0026rsquo;)] 代表在idx_key_part索引中,从第一条符合key_part1=\u0026lsquo;a\u0026rsquo; AND key_part2=\u0026lsquo;b\u0026rsquo; 条件的记录开始,到最后一条符合key_part1=\u0026lsquo;a\u0026rsquo; AND key_part2=\u0026lsquo;b\u0026rsquo;条件的记录为止的所有二级索引记录。\n对于查询条件Q3(顺序3条件) # SELECT * FROM single_table WHERE key_part1=\u0026#39;a\u0026#39; AND key_part2=\u0026#39;b\u0026#39; AND key_part3=\u0026#39;c\u0026#39;; 由于二级索引记录是先按照 key_part1列的值排序的,在key_part1列的值相等的情况下再按照key_part2列进行排序:在key_part1和key_part2列的值都相等的情况下, 再按照key_part3列进行排序,所以符合key_part1=\u0026lsquo;a\u0026rsquo; AND key_part2=\u0026lsquo;b\u0026rsquo; AND key_part3=\u0026lsquo;c\u0026rsquo;条件的二级索引记录肯定是相邻的。\n我们可以定位到符合key_part1=\u0026lsquo;a\u0026rsquo; AND key_part2 = \u0026lsquo;b\u0026rsquo; AND key_part3=\u0026lsquo;c\u0026rsquo;条件的第一条记录,然后沿着记录所在的单向链表向后扫描,直到某条记录不符合key_part1=\u0026lsquo;a\u0026rsquo;条件或者key_part2=\u0026lsquo;b\u0026rsquo;条件或者key_part3条件为止(当然,对于获取到的每一条二级索引记录都要执行回表操作)。这里就不再画示意图了。\n如果使用idx_key_part索引执行查询语句Q3 ,可以形成扫描区间**[(\u0026lsquo;a\u0026rsquo;,\u0026lsquo;b\u0026rsquo;,\u0026lsquo;c\u0026rsquo;),(\u0026lsquo;a\u0026rsquo;,\u0026lsquo;b\u0026rsquo;,\u0026lsquo;c\u0026rsquo;)]** ,形成这个扫描区间的边界条件就是key_part1=\u0026lsquo;a\u0026rsquo; AND key_part2 = \u0026lsquo;b\u0026rsquo; AND key_part3=\u0026lsquo;c\u0026rsquo;\n对于查询语句Q4(单条件范围) # SELECT * FROM single_table WHERE key_part1 \u0026lt; \u0026#39;a\u0026#39;; 由于二级索引记录是先按照key_part1列的值进行排序的,所以符合key_part1\u0026lt;\u0026lsquo;a\u0026rsquo;条件的所有记录肯定是相邻的。我们可以定位到符合key_part1\u0026lt;\u0026lsquo;a\u0026rsquo;条件的第一条记录(其实就是idx_key_part 索引第一个叶子节点的第一条记录) ,然后沿着记录所在的单向链表向后扫描,直到某条记录不符合key_part1\u0026lt; \u0026lsquo;a\u0026rsquo; 条件为止(当然,对于获取到的每一条二级索引记录都要执行回表操作,这里就不展示了) ,如图7- 13 所示\n也就是说,如果使用idx_key_part索引执行查询语句Q4,可以形成扫描区间(-∞,\u0026lsquo;a\u0026rsquo;),形成这个扫描区间的边界条件就是key_part1\u0026lt;\u0026lsquo;a\u0026rsquo;\n查询语句Q5(条件1等值,条件2范围) # SELECT * FROM single_table WHERE key_part1=\u0026#39;a\u0026#39; AND key_part2 \u0026gt; \u0026#39;a\u0026#39; AND key_part2 \u0026lt; \u0026#39;d\u0026#39;; 由于二级索引记录是先按照key_part1列的值进行排序的,在key_part1列的值相等的情况下再按照key_part2列进行排序。也就是说,在符合key_part1=\u0026lsquo;a\u0026rsquo;条件的二级索引记录中,这些记录是按照key_part2 列的值排序的, 那么此时符合key_part1=\u0026lsquo;a\u0026rsquo; AND key_part2\u0026gt;\u0026lsquo;a\u0026rsquo; AND key_part2 \u0026lt; \u0026rsquo;d\u0026rsquo;条件的二级索引记录肯定是相邻的。我们可以定位到符合 key_part1=\u0026lsquo;a\u0026rsquo; AND key_part2\u0026gt;\u0026lsquo;a\u0026rsquo; AND key_part2 \u0026lt; \u0026rsquo;d\u0026rsquo;条件的第-条记录(其实第一条就是key_part1=\u0026lsquo;a\u0026rsquo; AND key_part2 = \u0026lsquo;a\u0026rsquo; ), 然后沿着记录所在的单向链表向后扫描, 直到某条记录不符合key_part1=\u0026lsquo;a\u0026rsquo; 或者key_part2\u0026gt;\u0026lsquo;a\u0026rsquo; 或者**key_part2 \u0026lt; \u0026rsquo;d\u0026rsquo;**条件为止(当然,对于获取到的每一条二级索引记录都要执行回表操作, 这里就不展示了) ,如图7- 1 4 所示\n也就是说,如果使用idx_key_part索引执行查询语句Q5,可以形成扫描区间**((\u0026lsquo;a\u0026rsquo;,\u0026lsquo;a\u0026rsquo;),(\u0026lsquo;a\u0026rsquo;,\u0026rsquo;d\u0026rsquo;)),形成这个扫描区间的边界条件就是key_part1=\u0026lsquo;a\u0026rsquo; AND key_part2\u0026gt;\u0026lsquo;a\u0026rsquo; AND key_part2\u0026lt;\u0026rsquo;d\u0026rsquo;**。\n查询语句Q6(条件2等值=\u0026gt;用不上索引) # 由于二级索引记录不是直接按照key_part2列的值排序的,所以符合key_part2列的二级索引记录可能并不相邻,也就意味着我们不能通过这个key_part2=\u0026lsquo;a\u0026rsquo; 搜索条件来减少需要扫描的记录数量。在这种情况下,我们是不会使用idx_key_part 索引执行查询的\n查询语句Q7(条件1等值,条件3等值=\u0026gt;只有前面的条件是边界条件) # SELECT * FROM single_table WHERE key_part=\u0026#39;a\u0026#39; AND key_part3=\u0026#39;c\u0026#39; 由于二级索引记录是先按照key_part1列的值排序的,所以符合key_part1=\u0026lsquo;a\u0026rsquo;条件的二级索引记录肯定是相邻的。但是对于符合key_part3 =\u0026lsquo;c\u0026rsquo;条件的二级索引记录来说,并不是直接按照key_part3列进行排序的,也就是说我们不能根据搜索条件key_part3=\u0026lsquo;c\u0026rsquo;来进一步减少需要扫描的记录数量。那么,如果使用idx_key_part 索引执行查询,可以定位到符合key_part1 = \u0026lsquo;a\u0026rsquo;条件的第一条记录,然后沿着记录所在的单向链表向后扫描,直到某条记录不符合key_part1 = \u0026lsquo;a\u0026rsquo;条件为止。所以在使用idx_key_part索引执行查询语句Q7 的过程中,对应的扫描区间其实是**[\u0026lsquo;a\u0026rsquo;,\u0026lsquo;a\u0026rsquo;],形成该扫描区间的边界条件是 key_part1=\u0026lsquo;a\u0026rsquo;,与key_part3=\u0026lsquo;c\u0026rsquo;**无关。\n索引条件下推特性,MySQL5.6中引入,默认开启\n针对获取到的每一条二级索引记录,如果没有开启索引条件下推特性,则必须先执行回表操作,在获取到完整的用户记录后再判断key_part3=\u0026lsquo;c\u0026rsquo;条件是否成立。如呆开启了索引条件下推特性,可以立即判断该二级索引记录是否符合key_part3=\u0026lsquo;c\u0026rsquo;条件。如果符合该条件,则再执行回表操作;如果不符合则不执行回农操作,直接跳到下一条二级索引记录。\n查询语句Q8(条件1范围,条件2等值=\u0026gt;只有前面的条件是边界条件) # SELECT * FROM single_table WHERE key_part \u0026lt; \u0026#39;b\u0026#39; AND key_part2=\u0026#39;a\u0026#39; 由于二级索引记录是先按照key_part1列的值排序的,所以符合key_part1\u0026lt;\u0026lsquo;b\u0026rsquo;条件的二级索引记录肯定是相邻的。但是对于符合key_part2 =\u0026lsquo;a\u0026rsquo;条件的二级索引记录来说,并不是直接按照key_part2列进行排序的,也就是说我们不能根据搜索条件key_part2=\u0026lsquo;a\u0026rsquo;来进一步减少需要扫描的记录数量。那么,如果使用idx_key_part 索引执行查询,可以定位到符合key_part1 \u0026lt; \u0026lsquo;b\u0026rsquo;\u0026lsquo;条件的第一条记录(其实就是idx_key_part索引的第一个叶子节点的第一条记录),然后沿着记录所在的单向链表向后扫描,直到某条记录不符合key_part1\u0026lt;\u0026lsquo;b\u0026rsquo;条件为止。如图:\n所以在使用idx_key_part索引执行查询语句Q8 的过程中,对应的扫锚区间其实是[- ∞,\u0026lsquo;b\u0026rsquo;],形成该扫描区间的边界条件是key_part1 \u0026lt; \u0026lsquo;b\u0026rsquo; , 与 key_part2=\u0026lsquo;a\u0026rsquo;无关\n查询语句Q9(条件1范围(包括等号),条件2等值) # SELECT * FROM single_table WHERE key_part1 \u0026lt;= \u0026#39;b\u0026#39; AND key_part2=\u0026#39;a\u0026#39; Q8和Q9很像,但是在涉及key_part1条件时,Q8中的条件是key_part1\u0026lt;\u0026lsquo;b\u0026rsquo;,Q9中的条件是key_part1\u0026lt;=\u0026lsquo;b\u0026rsquo;。很显然,符合key_part1=\u0026lsquo;b\u0026rsquo;条件的二级索引记录是相邻的。但是对于符合key_part1\u0026lt;=\u0026lsquo;b\u0026rsquo;条件的二级索引记录来说,并不是直接按照key_part2列排序的。但是,对于符合key_part1=\u0026lsquo;b\u0026rsquo;的二级索引记录来说,是按照key_part2列的值排序的。那么在确定需要扫描的二级索引记录的范围时,当二级索引记录的key_part1列值为\u0026rsquo;b\u0026rsquo; 时,也可以通过key_part2=\u0026lsquo;b\u0026rsquo; 条件减少需要扫描的二级索引记录范围。也就是说, 当扫描到不符合key_part1=\u0026lsquo;b\u0026rsquo; AND key_part2=\u0026lsquo;a\u0026rsquo; 条件的第一条记录时,就可以结束扫描,而不需要将所有key_part1列值为\u0026rsquo;b\u0026rsquo;的记录扫描完。\n注意,当扫描到记录的列key_part1值为b时,不能直接定位到**key_part2=\u0026lsquo;a\u0026rsquo;的数据了,但是可以扫描到key_part2=\u0026lsquo;a\u0026rsquo;**停止\n也就是说,如果使用idx_key_part索引执行查询语句Q9,可以形成扫描区间((-∞,-∞),(\u0026lsquo;b\u0026rsquo;,\u0026lsquo;a\u0026rsquo;)),形成这个扫描区间的边界条件就是key_part1\u0026lt;=\u0026lsquo;b\u0026rsquo; AND key_part2=\u0026lsquo;a\u0026rsquo;。而在执行查询语句Q8时,我们必须将所有符合**key_part1\u0026lt;\u0026lsquo;b\u0026rsquo;**的记录都扫描完,**key_part2=\u0026lsquo;a\u0026rsquo;**条件在查询语句Q8中并不能起到减少需要扫描的二级索引范围的作用\n注意,对于Q9,key_part1\u0026lt;\u0026lsquo;b\u0026rsquo;的记录也是要扫描完的。这里仅仅对key_part1=\u0026lsquo;b\u0026rsquo;起了减少扫描二级索引范围的作用。\n索引用于排序 # 我们在编写查询语句时,经常需要使用ORDERBY子句对查询出来的记录按照某种规则进行排序。在一般情况下,我们只能把记录加载到内存中,然后再用一些排序算法在内存中对这些记录进行排序。有时查询的结果集可能太大以至于无法在内存中进行排序,此时就需要暂时借助磁盘的空间来存放中间结果,在排序操作完成后再把排好序的结果集返回客户端。在MySQL 中,这种在内存或者磁盘中进行排序的方式统称为文件排序(fìlesort)。但是,如果ORDERBY子句中使用了索引列,就有可能省去在内存或磁盘中排序的步骤。\n举例:\nSELECT * FROM single_table ORDER BY key_part1,key_part2,key_part3 LIMIT 10; 这个查询语句的结果集需要先按照key_part1 值排序。如果记录的key_part1 值相同,再按照key_part2值排序,如果记录的key_part1 和key_part2值都相同,再按照key_part3 值排序。大家可以回过头去看看图7-10。\n该二级索引的记录本身就是按照上述规则排好序的,所以我们可 以从第一条idx_key_part二级索引记录开始,沿着记录所在的单向链表向后扫描,取10 条二级索引记录即可。当然,针对获取到的每一条二级索引记录都执行一次回表操作,在获取到完整的用户记录之后发送给客户端就好了。这样是不是就变得简单多了,还省去了我们给10000条记录排序的时间\u0026ndash;索引就是这么厉害!\n关于回表操作: 请注意,本例的查询语句中加了LIMIT 子句,这是因为如果不限制需要获取的记录数量,会导致为大量二级索引记录执行回表操作,这样会影响整体的查询性能。关于回表操作造成的影响,我们稍后再详细唠叨\n使用联合索引进行排序时的注意事项 # ORDER BY子句后面的列顺序也必须按照索引列的顺序给出\n如果给出ORDER BY key_part3,key_part2,key_part1的顺序,则无法使用B+树索引。\n如果是ORDER BY key_part1 DESC,key_part2 DESC,key_part3 DESC ,那么应该是可以的,也就是ORDER BY key_part1,key_part2,key_part3的全反序\n之所以颠倒的排序列顺序不能使用索引,原因还是联合索引中页面和记录的排序规则是固定的,也就是先按照key_part1值排序,如果key_part1值相同,再按照key_part2值排序;如果key_part1和key_part2值都相同,再按照key_part2值排序。\n如果ORDER BY子句的内容是ORDER BY key_part3 , key_part2 , key_part,那就要求先要key_part3值排序(升序),如果key_part3相同,再按key_part2升序,如果key_part3和key_part3都相同,再按照key_part1升序\n同理,这些仅对联合索引的索引列中左边连续的列进行排序的形式(如ORDER BY key_part1和ORDER BY key_part1,key_part2),也是可以利用B+树索引的。另外,当连续索引的索引列左边连续的列为常量时,也可以使用联合索引对右边的列进行排序\nSELECT * FROM single_table WHERE key_part1=\u0026#39;a\u0026#39; AND key_part2=\u0026#39;b\u0026#39; ORDER BY key_part3 LIMIT 10 能使用联合索引排序,原因是key_part1值为\u0026rsquo;a\u0026rsquo;、key_part2值为\u0026rsquo;b\u0026rsquo;的二级索引记录本身就是按照key_part3列的值进行排序的\n不能使用索引进行排序的几种情况 # ASC、DESC混用 # 我们要求各个排序列的排序顺序规则是一致的,要么各个列都是按照ASC(升序),要么都是按照DESC(降序)规则排序\n为什么呢:\nidx_key_part联合索引中的二级索引记录的排序规则:先key_part1升序,key_part1相同则key_part2升序,如果都相同则key_part3升序\n如果ORDER BY key_part1,key_part2 LIMIT10,那么直接从联合索引最左边的二级索引记录开始,向右读取10条即可\n如果ORDER BY key_part1 DESC,key_part2 DESC LIMIT 10,可以从联合索引最右边的那条二级索引记录开始,向左读10条\n注意,这里没有key_part3,也可以的。可以理解成,key_part3不要求排序。而按照key_part1 DESC,key_part2DESC顺序的记录一定是连续的\n如果是先key_part1列升序,再key_part2列降序,如:\nSELECT * FROM single_table ORDER BY key_part1,key_part2 DESC LIMIT 10; 此时联合索引的查询过程如下,算法较为复杂,不能高效地使用索引,所以这种情况下是不会使用联合索引执行排序操作的\nMySQL8.0引入了称为Descending Index的特性,支持ORDER BY 子句中ASC、DESC混用的情况\n排序列包含非同一个索引的列,这种情况也不能使用索引进行排序 # SELECT * FROM single_table ORDER BY key1,key2 LIMIT 10 对于idx_key1的二级索引来说,只按照key1列排序。且key1值相同的情况下是不按照key2列的值进行排序的,所以不能使用idx_key1索引执行上述查询\n排序列是某个联合索引的索引列,但是这些排序列再联合索引中并不连续 # SELECT * FROM single_table ORDER BY key_part1,key_part3 LIMIT 10; key_part1值相同的记录并不按照key_part3排序,所以不能使用idx_key_part执行上述查询\n用来形成扫描区间的索引列与排序列不同 # SELECT * FROM single_table WHERE key1=\u0026#39;a\u0026#39; ORDER BY key2 LIMIT 10; 如果使用key1=\u0026lsquo;1\u0026rsquo;作为边界条件来形成扫描区间,也就是再使用idx_key1执行该查询,仅需要扫描key1值为\u0026rsquo;a\u0026rsquo;的二级索引记录。此时无法使用uk_key2执行上述查询\n5:排序列不是以单独列名的形式出现在ORDER BY 子句中\n要想使用索引排序,必须保证索引列是以单独列名的形式(而不是修饰过):\nSELECT * FROM single_table ORDER BY UPPER(key1) LIMIT 10; 因为key1列以UPPER(key1)函数调用的形式出现在ORDER BY子句,所以不能使用idx_key1执行上述查询\n索引用于分组 # 为了方便统计,会把表中记录按照某些列进行分组,如:\nSELECT key_part1,key_part2,key_part3,COUNT(*) FROM single_table GROUP BY key_part1,key_part2,key_part3; 对这些小分组进行统计,上面的查询,即统计每个小小分组包含的记录条数。\n如果没有idx_key_part索引,就得建立一个用于统计的临时表,在扫描聚簇索引的记录时将统计的中间结果填入这个临时表。当扫描完记录后, 再把临时表中的结果作为结果集发送给客户端。 如果有了索引idx_key_part ,恰巧这个分组顺序又与idx_key_part 的索引列的顺序是一致的,而idx_key_part 的二级索引记录又是按照索引列的值排好序的,这就正好了。所以可以直接使用idx_key_part 索引进行分组,而不用再建立临时表了 与使用B+ 树索引进行排序差不多, 分组列的顺序也需要与索引列的顺序一致,也可以只使用索引列中左边连续的列迸行分组\n如上,就是统计 (\u0026lsquo;0\u0026rsquo;,\u0026lsquo;0\u0026rsquo;,\u0026lsquo;0\u0026rsquo;)的有几条, (\u0026lsquo;0\u0026rsquo;,\u0026lsquo;a\u0026rsquo;,\u0026lsquo;a\u0026rsquo;)的有几条, (\u0026lsquo;0\u0026rsquo;,\u0026lsquo;a\u0026rsquo;,\u0026lsquo;b\u0026rsquo;)的有几条等\n回表的代价 # SELECT * FROM single_table WHERE key1 \u0026gt; 'a' AND key1 \u0026lt; 'c'\n有两种方式来执行上面语句\n以全表扫描的方式 # 直接扫描全部的聚簇索引记录, 针对每一条聚簇索引记录,都判断搜索条件是否成立, 如果成立则发送到客户端, 否则跳过该记录.\n使用idx_key1执行该查询 # 可以根据搜索条件key1 \u0026gt; \u0026lsquo;a\u0026rsquo; AND key1 \u0026lt; \u0026lsquo;c\u0026rsquo; 得到对应的扫描区间( \u0026lsquo;a\u0026rsquo;,\u0026lsquo;c\u0026rsquo; ),然后扫描该扫描区间中的二级索引记录。由于idx_key1索引的叶子节点存储的是不完整的用户记录,仅包含key1 、id 这两个列,而查询列表是*, 这意味着我们需要获取每条二级索引记录对应的聚簇索引记录, 也就是执行回表操作,在获取到完整的用户记录后再发送到客户端。\n分析 # 对于使用InnoDB 存储引擎的表来说, 索引中的数据页都必须存放在磁盘中, 等到需要时再加载到内存中使用。这些数据页会被存放到磁盘中的一个或者多个文件中, 页面的页号对应着该页在磁盘文件中的偏移量。以16KB大小的页面为例,页号为0 的页面对应着这些文件中偏移量为0 的位置,页号为1的页面对应着这些文件中偏移量为16KB 的位置。前面章节讲过, B+ 树的每层节点会使用双向链表连接起来, 上一个节点和下一个节点的页号可以不必相邻。\n不过在实际实现中, 设计Inno DB 的大叔还是尽量让同一个索引的叶子节点的页号按照顺序排列,这一点会在稍后讨论表空间时再详细嘴叨\n也就是说,idx_key1在扫描区间( \u0026lsquo;a\u0026rsquo;, \u0026lsquo;c\u0026rsquo; )中的二级索引记录所在的页面的页号会尽可能相邻\n即使这些页面的页号不相邻, 但起码一个页可以存放很多记录,也就是说在执行完一次页面I/O 后,就可以把很多二级索引记录从磁盘加载到内存中。 总而言之,就是读取在扫描区间( \u0026lsquo;a\u0026rsquo;, \u0026lsquo;c\u0026rsquo; ) 中 的二级索引记录时,所付出的代价还是较小的。不过扫描区间( \u0026lsquo;a\u0026rsquo;, \u0026lsquo;c\u0026rsquo; )中的二级索引记录对应 的id 值的大小是毫无规律的, 我们每读取一条二级索引记录,就需要根据该二级索引记录的id 值到聚簇索引中执行回表操作。如果对应的聚簇索引记录所在的页面不在内存中,就需要将该 页面从磁盘加载到内存中.。由于要读取很多id 值并不连续的聚簇索引记录,而且这些聚簇索引 记录分布在不同的数据页中, 这些数据页的页号也毫无规律,因此会造成大量的随机I/O . 需要执行回表操作的记录越多, 使用二级索引进行查询的性能也就越低,某些查询宁愿使 用全表扫描也不使用二级索引。比如, 假设key1值在\u0026rsquo;a\u0026rsquo;~\u0026lsquo;c\u0026rsquo; 之间的用户记录数量****占全部记录** 数量的99%** 以上,如果使用idx_key1索引,则会有99% 以上的id 值需要执行回表操作。这 不是吃力不讨好么, 还不如直接执行全表扫描\n什么时候采用全表扫描, 什么时候使用二级索引+回表的方式 # 这是查询优化器应该做的工作:\n查询优化器会事先针对表中的记录计算一些统计数据,然后再利用这些统计数据或者访问表中的少量记录来计算需要执行回表操作的记录数,如果需要执行回表操作的记录数越多,就越倾向于使用全表扫描, 反之则倾向于使用二级索引+回表的方式。当然,查询优化器所做的分析工作没有这么简单, 但大致上是这样一个过程。\n一般情况下,可以给查询语句指定LIMIT 子句来限制查询返回的记录数, 这可能会让查 询优化器倾向于选择使用二级索引+回表的方式进行查询, 原因是回表的记录越少, 性能提升 就越高。比如,上面的查询语句可以改写成下面这样\nSELECT * FROM single_table WHERE key1 \u0026gt; 'a' AND key1\u0026lt;'c' LIMIT 10\n添加了LIMlT10 子句后的查询语句更容易让查询优化器采用二级索引+回表的方式来执行。 对于需要对结果进行排序的查询,如果在采用二级索引执行查询时需要执行回表操作的记 录特别多,也倾向于使用全表扫描+文件排序的方式执行查询。比如下面这个查询语句 SELECT * FROM single_table ORDER BY key1 由于查询列表是 *,如果使用二级索引进行排序,则需要对所有二级索引记录执行回表操作. 这样操作的成本还不如直接遍历聚簇索引然后再进行文件排序低, 所以查询优化器会倾向于使 用全表扫描的方式执行查询。如果添加了LIMIT子句,比如下面这个查询语句:\nSELECT * FROM single_table ORDER BY key1 LIMIT 10; 这个查询语句需要执行回表操作的记录特别少,查询优化器就会倾向于使用二级索引+回表的 方式来执行\n更好地创建和使用索引 # 只为用于搜索、排序或分组的列创建索引 # SELECT common_field,key_part3 FROM single_table WHERE key1= \u0026#39;a\u0026#39;; 没必要为common_field,key_part3创建索引\n考虑索引列中不重复值的个数 # 前文在唠叨回表的知识时提提到, 在通过二级索引+回表的方式执行查询时,某个扫描区间中包含的二级索引记录数量越多, 就会导致回表操作的代价越大。我们在为某个列创建索引时,需要考虑该列中不重复值的个数占全部记录条数的比例。如果比例太低,则说明该列包含 过多重复值,那么在通过二级索引+回表的方式执行查询时,就有可能执行太多次回表操作\n索引列的类型尽量小 # 在定义表结构时,要显式地指定列的类型 以整数类型为例, 有 TINIINT、MEDIUMINT、INT、BIGINT这几种,它们占用的存储空间的大小依次递增。下面所说的类型大小指的就是该类型占用的存储空间的大小。刚才提到的这几个整数类型,它们能表示的整数范围当然也是依次递增。如果想要对某个整数类型的列建立索引,在表示的整数范围允许的情况下,尽量让索引列使用较小的类型,比如能使用INT就不要使用BIGINT。 能使用MEDIUMINT 就不要使用的INT。 因为数据类型越小, 索引占用的存储空间就越少,在一个数据页内就可以存放更多的记录,磁盘1/0 带来的性能损耗也就越小(一次页面I/O 可以将更多的记录加载到内存中) 读写效率也就越高 这个建议对于表的主键来说更加适用,因为不仅聚簇索引会存储主键值,其他所有的二级索引的节点都会存储一份记录的主键值。如果主键使用更小的数据类型,也就意味着能节省更多的存储空间\n为列前缀建立索引 # 我们知道,一个字符串其实是由若干个字符组成的。如果在MySQL 中使用utf8 字符集存储字符串,则需要1 - 3 字节来编码一个字符。假如字符串很长,那么在存储这个字符串时就需要占用很大的存储空间。在需要为这个字符串所在的列建立索引时,就意味着在对应的B+ 树中的记录中, 需要把该列的完整字符串存储起来。字符串越长,在索引中占用的存储空间越大。 前文说过, 索引列的字符串前缀其实也是排好序的,所以索引的设计人员提出了一个方案。 只将字符串的前几个字符存放到索引中,也就是说在二级索引的记录中只保留字符串的前几个字符。比如我们可以这样修改idx_key1索引,让索引中只保留字符串的前10个字符:\nALTER TABLE single_table DROP INDEX idx_key1; ALTER TABLE single_table ADD INDEX idx_key1(key1(10)); 再执行下面的语句\nSELECT * FROM single_table WHERE key1= \u0026#39;abcdefghijklmn\u0026#39; 由于在idx_key1 的二级索引记录中只保留字符串的前10 个字符,所以我们只能定位到前缀为\u0026rsquo;abcdefghij\u0026rsquo; 的二级索引记录,在扫描这些二级索引记录时再判断它们是否满足key1=\u0026lsquo;abcdefghijklmn\u0026rsquo; 条件。当列中存储的字符串包含的字符较多时,这种为列前缀建立索引的方式可以明显减少索引大小。\n注意,上面说的是扫描这些二级索引记录,是“些”。 可以减少索引大小,但不一样减少索引数量。如果有重复的照样会在索引中出现,因为不是UNIQUE约束。二级索引值大小相同时,会按照聚簇索引大小排列 不过,在只对列前缀建立索引的情况下, 下面这个查询语句就不能使用索引来完成排序需求了: SELECT * FROM single_table ORDER BY key1 LIMIT 10;\n因为二级索引idx_key1中不包含完整的key1列信息,所以在仅使用idx_key1索引执行查询时,无法对key1 列前10 个字符相同但其余字符不同的记录进行排序。也就是说,只为列前缀建立索引的方式无法支持使用索引进行排序的需求。上述查询语句只好乖乖地使用全表扫描+文件排序的方式来执行了。\n只为列前缀创建索引的过程我们就介绍完了,还是将idx_key1 改回原来的样式:\nALTER TABLE single_table DROP INDEX idx_key1; ALTER TABLE single_table ADD INDEX idx_key1(key1); 覆盖索引 # 为了彻底告别回表操作带来的性能损耗,建议最好在查询列表中只包含索引列,比如这个查询语句:\nSELECT key1,id FROM single_table WHERE key1 \u0026gt; 'a' AND key1 \u0026lt; 'c'\n由于只查询key1列和id列的值,这里使用idx_key1索引来扫描(\u0026lsquo;a\u0026rsquo;,\u0026lsquo;c\u0026rsquo;)区间的二级索引记录时,可以直接从获取到的二级索引记录中读出key1列和id列的值,不需要通过id值到聚簇索引执行回表,省去回表操作带来的性能损耗。\n把这种已经包含所有需要读取的列的查询方式称为覆盖索引\n排序操作也优先使用覆盖索引进行查询:\nSELECT * FROM single_table ORDER BY key1 虽然这个查询语句中没有LIMIT子旬,但是由于可以采用覆盖索引,所以查询优化器会直接使用idx_key1索引进行排序 而不需要执行回表操作。 当然,如果业务需要查询索引列以外的列,那还是以保证业务需求为重。如无必要, 最好仅把业务中需要的列放在查询列表中,而不是简单地以*替代\n让索引列以列名的形式在搜索条件中单独出现 # 注意,是单独\n如下面两个语义一样的搜索条件\nSELECT * FROM single_table WHERE key2 * 2 \u0026lt; 4; SELECT * FROM single_table WHERE key2 \u0026lt; 4/2; 在第一个查询语句的搜索条件中, key2列并不是以单独列名的形式出现的,而是以key2 * 2这样的表达式的形式出现的。 MySQL 并不会尝试简化key2*2\u0026lt;4 表达式,而是直接认为这个搜索条件不能形成合适的扫描区间来减少需要扫描的记录数量,所以该查询语句只能以全表扫锚的方式来执行。 在第二个查询语句的搜索条件中, key2 列是以单独列名的形式出现的, MySQL 可以分析出如果使用uk_key2 执行查询,对应的扫描区间就是(-∞,2) ,这可以减少需要扫描的记录数量。 所以MySQL 可能使用uk_key2 来执行查询。 所以,如果想让某个查询使用索引来执行,请让索引列以列名的形式单独出现在搜索条件中 新插入记录时主键大小对效率的影响 # 我们知道,对于一个使用lnnoDB 存储引擎的表来说,在没有显式创建索引时, 表中的数据实际上存储在聚簇索引的叶子节点中,而且B+ 树的每一层数据页以及页面中的记录都是按照主键值从小到大的顺序排序的。如果新插入记录的主键值是依次增大的话,则每插满一个数据页就换到下一个数据页继续插入。如果新插入记录的主键值忽大忽小,就比较麻烦了\n假设某个数据页存储的聚簇索引记录已经满了, 它存储的主键值在1 - 100之间,如图:\n此时,如果再插入一条主键值为8的记录,则它插入的位置如图:\n可这个数据页已经满了啊, 新记录该插入到哪里呢?我们需要把当前页面分裂成两个页面, 把本页中的一些记录移动到新创建的页中。页面分裂意味着什么?意味着性能损耗!所以, 如果想尽量避免这种无谓的性能损耗,最好让插入记录的主键值依次递增。就像single_table的主键id 列具有AUTO_INCREMENT 属性那样。 MySQL 会自动为新插入的记录生成递增的主键值\n冗余和重复索引 # 针对single_table 表, 可以单独针对key_part1列建立一个idx_key_part1索引\nALTER TABLE single_table ADD INDEX idx_key_part(key_part1); 其实现在我们已经有了一个针对key_part1、key_part2 、key_part3列建立的联合索引idx_key_part。idx_key_part索引的二级索引记录本身就是按照key_part1 列的值排序的, 此时再单独为key_part1列建立一个索引其实是没有必要的。我们可以把这个新建的idx_key_part1索引看作是一个冗余索引, 该冗余索引是没有必要的\n有时,我们可能会对同一个列创建多个索引,比如这两个添加索引的语句:\nALTER TABLE single_table ADD UNIQUE KEY uk_id(id); ALTER TABLE single_table ADD INDEX idx_id(id); 我们针对id 列又建立了一个唯一二级索引uk_id,. 还建立了一个普通二级索引idx_id。 可是id 列本身就是single_table 表的主键, InnoDB 自动为该列建立了聚簇索引, 此时uk_id 和idx_id 就是重复的,这种重复索引应该避免\n"},{"id":228,"href":"/zh/docs/technology/MySQL/_how_mysql_run_/06/","title":"06B+树索引","section":"_MySQL是怎样运行的_","content":" 学习《MySQL是怎样运行的》,感谢作者!\n概述 # 数据页由7个组成部分,各个数据页可以组成一个双向链表,每个数据页中的记录会按照主键值从小到大的顺序组成一个单向链表。每个数据页都会为它里面的记录生成一个页目录,在通过主键查找某条记录的时候可以在页目录中使用二分法快速定位到对应的槽,然后再遍历该槽对应分组中的记录即可快速找到指定的记录。页和记录的关系\n页a,页b 可以不在物理结构上相连,只要通过双向链表相关联即可\n没有索引时进行查找 # 假设我们要搜索某个列等于某个常数的情况:\nSELECT [查询列表] FROM 表名 WHERE 列名 = xxx\n在一个页中查找 # 假设记录极少,所有记录可以存放到一个页中\n以主键位搜索条件:页目录中使用二分法快速定位到对应的槽,然后在遍历槽对应分组中的记录,即可快速找到指定记录 其他列作为搜索条件:对于非主键,数据页没有为非主键列建立所谓的页目录,所以无法通过二分法快速定位相应的槽。只能从Infimum依次遍历单向链表中的每条记录,然后对比,效率极低 在很多页中查找 # 两个步骤:\n定位到记录所在的页 从所在页内查找相应的记录 没有索引情况下,不能快速定位到所在页,只能从第一页沿着双向链表一直往下找,而如果是主键,每一页则可以在页目录二分查找。\n不过由于要遍历所有页,所以超级耗时\n索引 # #例子 mysql\u0026gt; CREATE TABLE index_demo( c1 INT, c2 INT, c3 CHAR(1), PRIMARY KEY(c1) ) ROW_FORMAT=COMPACT; 完整的行格式\n简化的行格式\nrecord_type:记录头信息的一项属性,表示记录的类型。0:普通记录,2:Infimum记录,3:Supremum记录,1还没用过等会再说 next_record:记录头信息的一项属性,表示从当前记录的真实数据到下一条记录真实数据的距离 各个列的值:这里只展示在index_demo表中的3个列,分别是c1、c2、c3 其他信息:包括隐藏列及记录的额外信息 改为竖着查看:\n上面图6-4的箭头其实有一点点出入,应该是指向z真实数据第1列那个位置,如下 一个简单的索引方案 # 思考:在根据某个条件查找一些记录,为什么要遍历所有的数据页呢?因为各个页中的记录没有规律,不知道搜索条件会匹配哪些页\n思路:为快速定位记录所在的数据页而建立一个别的目录\n有序 # 下一个数据页中用户记录的主键值必须大于上一页用户记录的主键值\n假设一页只能存放3条记录\n#插入3条记录 mysql\u0026gt; INSERT INTO index_demo VALUES(1,4,\u0026#39;u\u0026#39;),(3,9,\u0026#39;d\u0026#39;),(5,3,\u0026#39;y\u0026#39;); Query OK, 3 rows affected (0.02 sec) Records: 3 Duplicates: 0 Warnings: 0 此时页的情况\n记录组成了单链表\n再插入一条记录\nmysql\u0026gt; INSERT INTO index_demo VALUES(4,4,\u0026#39;a\u0026#39;); Query OK, 1 row affected (0.01 sec) (注意,页之间可能不是连续的)\n由于页10中最大记录是5,而页28中有一条记录是4,因为5\u0026gt;4,不符合下一个数据页中用户记录的主键值必须大于上一页中用户记录的主键值,所以在插入主键值为4的记录时需要伴随着一次记录移动,也就是把5的记录移动到页28中,再把主键值4的记录插入到页10中\n这个过程表明,在对页中的记录进行增删改操作的过程中,我们必须通过一些诸如记录移动的操作来始终保证这个状态一直成立:下一个数据页中用户记录的主键值必须大于上一个页中用户记录的主键值。这个过程也可以称为页分裂。\n给所有的页建立一个目录项 # 前提:index_demo表中有多条记录的效果\n1页有16KB,这些页在磁盘上可能并不连续,要想从这么多页中根据主键值快速定位某些记录所在的页,需要给他们编制一个目录,每个页对应一个目录项,每个目录项包括两部分:\n页的用户记录中最小的主键值,用key表示 页号,page_no表示 假设我们此时把目录项在物理存储器上连续存储,比如放到数组中,此时就可以根据主键值快速查找某条记录\n先用二分法快速定位主键20的记录在目录项3中(因为12\u0026lt;20\u0026lt;209),对应页是9\n根据前面说的方式在页9中定位具体记录\n先在页目录中二分查找,找到对应的组后沿链表遍历\n这个目录项的别名:索引\nInnoDB中的索引方案 # 上述方案的问题 # InnoDB使用页作为管理存储空间的基本单位,即只能保证16KB的连续存储空间,如果记录非常多,则需要的连续存储空间就非常大 增删改是很频繁的,如果页28的记录全部移除,那么目录项2就没有出现的必要,即要删除目录项2,那么所有的目录项都需要左移/或者不移动,作为冗余放到目录项列表中,浪费空间 方案 # 复用之前存储用户记录的数据页来存储目录项,用了和用户记录进行区分,把这些用来表示目录项的记录称为目录项记录。如何区分一条记录是普通用户记录,还是目录项记录:使用记录头信息中的record_type属性\n0:普通用户记录 1:目录项记录 2:Infimum记录 3:Supremum记录 将目录项放到数据页中\n新分配了一个编号为30的页来专门存储目录项记录\n目录项记录和普通的用户记录的不同点\n目录项记录的record_type值为1,普通用户记录record_type值为0\n目录项记录只有主键值和页的编号这两个列,而普通用户记录的列是用户自己定义的,可能包含许多列,另外还有InnoDB自己添加的隐藏列\n记录头信息中有一个名为min_rec_flag的属性,只有目录项记录的min_rec_flag属性才可能为1,普通记录的min_rec_flag属性都是0\nB+ 树中每层非叶子节点中的最小的目录项记录都会添加该标记\n其他:\n它们用的是一样的数据页(页面类型都是Ox45BF ,这个属性在File Header 中);页的组成结构也是一样的〈就是我们前面介绍过的7 个部分);都会为主键值生成Page Directory(页目录)从而在按照主键值进行查找时可以使用 二分法来加快查询速度。\n举例 # 单个 目录项记录页 # 其中,页30中存储的主键值分别为1,5,12,209\n假设我们现在要查找主键值为20的记录:\n先到存储目录项记录的页(这里是页30)中,(由于有页目录)通过二分法快速定位到对应的目录项记录,因为12\u0026lt;20\u0026lt;209,所以定位到对应的用户记录所在的页就是页9 再到存储用户记录的页9中根据二分法(由于有页目录))快速定位到主键值为20的用户记录 目录项记录中只存储主键值和对应的页号,存储空间极小,但一个页只有16KB,存放的目录项记录有限。如果表中数据太多(页太多),以至于一个数据页不足以存放所有的目录项记录\n多个 目录项记录的页 # 解决方案:新增一个存储目录项记录的页\n此时再进行查找\n确定存储目录项记录的页\n现在存储目录项记录的页有2个,即页30和页32。又因为页30表示的目录项记录主键值范围是**[1,320),页32表示的目录项记录主键值范围 \u0026gt; 320。所以确定主键值为20的记录对应的目录项记录**在页30中 按照单个 目录项记录页的方案查找 多个目录项记录页 # 如果数据再增加,则再生成存储更高级目录项记录的数据页\n无论是存放用户记录的数据页,还是存放目录项记录的数据页,都放到B+树数据结构中,我们也将这些数据页称为B+树的节点\n如图,我们真正的用户记录其实都存放在B+树最底层的节点上,这些节点也称为叶子节点或页节点,其余用来存放目录项记录的节点称为非叶子节点或者内节点,其中B+树最上边的那个节点也称为根节点 这里我们规定,最下面那层(存放用户记录的那层)为0层,之后层级依次往上加。\n这里我们假设所有存放用户记录的叶子节点所代表的数据页可以存放100条用户记录(16KB=16 * 1024 ≈10000 字节,差不多一条记录100字节),假设所有存放目录项记录的内节点所代表的数据页可以存放1000条目录项记录(10000字节,假设1个目录项10字节),那么如果\n如果B+树有1层,那么只有一个用于存放用户记录的节点,那么能存放100条用户记录(1百) 如果B+树有2层,那么能存放 1000 * 100=100,000条用户记录(10万) 如果B+树有3层,那么能存放 1000 * 1000 * 100=100,000,000条用户记录(1亿) 如果B+树有4层,那么能存放 1000 * 1000 * 1000 * 100=100,000,000,000条用户记录 (1000亿) 所以一般情况下,我们用到的B+树不会超过4层。\n当我们要通过主键值查找**某条记录 **\n最多只需要进行4个页面内的查找(查找3个存储目录项记录的页和1个存储用户记录的页) 每个页面内存在PageDirectory(页目录),所以在页面内也可以通过二分法快速定位记录 PageHeader中,有一个名为PAGE_LEVEL的属性,代表着这个数据页作为节点在B+树中的层级\n聚簇索引 # 前面介绍的B+树本身就是一个记录,或者说本身就是一个索引,有以下两个特点\n使用记录主键值的大小进行记录和页的排序 页(包括叶子节点和内节点)内的记录,按照主键大小顺序排成一个单向链表,页内的记录被划分成若干个组,每个组中主键值最大的记录在页内的偏移量会被当作槽一次存放在页目录中(Supremum记录比任何用户记录都大)之后可以在页目录中通过二分法快速定位到主键列等于某个值的记录 各个存放用户记录的页也是根据页中用户记录的主键大小顺序排成一个双向链表 存放目录项记录的页分为不同的层级,在同一层级中也是根据页目录项记录的主键大小顺序排成一个双向链表 B+树的叶子节点存储的是完整的用户记录(指的是这个记录中存储了所有列的值(包括隐藏列)) 具有上面两个特点的B+树称为聚簇索引。所有完整的用户记录都存放在这个聚簇索引的叶子节点处。这种聚簇索引,不需要我们在MySQL语句中显示使用INDEX语句去创建,InnoDB会自动为我们创建聚簇索引\nInnoDB存储引擎中,聚簇索引就是数据的存储方式(所有的用户记录都存储在了叶子节点)。索引即数据,数据即索引\n二级索引 # 聚簇索引只能在搜索条件是主键值时才发挥作用,如果要以别的列作为搜索条件,可以多建几颗B+树,而且不同B+树中的数据,采用不同的排序规则\n比如用c2列的大小作为数据页、页中记录的排序规则,再建一颗B+树\n\u0026ldquo;前言:c1已经是主键了\u0026rdquo;\n#例子 mysql\u0026gt; CREATE TABLE index_demo( c1 INT, c2 INT, c3 CHAR(1), PRIMARY KEY(c1) ) ROW_FORMAT=COMPACT; 下面是聚簇索引特点:\n二级索引说明:\n使用记录c2列的大小进行记录和页的排序 页(包括叶子节点和内节点)内的记录,按照c2列大小顺序排成一个单向链表,页内的记录被划分成若干个组,每个组中c2列值最大的记录在页内的偏移量会被当作槽一次存放在页目录中(Supremum记录比任何用户记录都大)之后可以在页目录中通过二分法快速定位到c2列值等于某个值的记录 各个存放用户记录的页也是根据页中用户记录的c2列大小顺序排成一个双向链表 存放目录项记录的页分为不同的层级,在同一层级中也是根据页目录项记录的c2列大小顺序排成一个双向链表 B+树的叶子节点存储的是并不是完整的用户记录,而只是c2列+主键这两个列的值 目录项记录中不再是主键+页号的匹配,而变成了c2列+页号的搭配 B+树如下\n举例 # 假设要查找c2=4的记录,可以使用上面的B+树。由于c2没有唯一性约束,所以可能会有很多条:我们只需要在该B+树的叶子节点处定位到第一条满足搜索条件c2=4的那条,然后由记录组成的单向链表一直向后扫描即可。另外,各个叶子节点组成了双向链表,搜索完了本页面的记录后可以顺利跳到下一个页面中的第一条记录,然后沿着记录组成的单向链表向后扫描\n查找过程 # 确定第一条符合c2=4条件的目录项记录所在的页\n根据**根页面(44)**可以快速定位到第一条符合c2=4条件的目录项记录所在页为页42(因为2\u0026lt;4\u0026lt;9)\n通过第一条符合c2=4条件的目录项记录所在的页面确定第一条符合c2=4条件的用户记录所在的页\n根据页42可以快速定位(通过页目录)到第一条符合条件的用户记录所在页为34或35,因为2\u0026lt;4\u0026lt;=4\n在真正存储第一条符合c2=4条件的用户记录的页中定位到具体的记录\n页34和页35中定位到具体的用户记录(如果页34使用页目录定位到第一条符合条件的用户记录,就不需要再到35中去再定位,因为直接一直往后查找到不等的记录即可)\n由于这个B+树的叶子节点的记录只存储了c2和c1(即主键)两个列。在叶子节点定位到第一条符合条件的那条用户记录之后,我们需要根据该纪录中的主键信息,到聚簇索引中查找到完整的用户记录,这个通过携带主键信息到聚簇索引中重新定位完整的用户记录的过程也称为回表 。\n然后再返回到这棵B+ 树的叶子节点处,找到刚才定位到的符合条件的那条用户记录,并沿着记录组成的单向链表向后继续搜索其他也满足c2=4的记录**,每找到一条的话就继续进行回表操作。重复这个过程,直到下一条记录不满足c2 =4**的这个条件为止.\n如果把完整的用户记录放到叶子节点是可以不用回表,但是太占地方了\n因为这种以非主键列的大小为排序规则而建立的B+ 树需要执行回表操作才可以定位到完整的用户记录,所以这种B+ 树也称为二级索引(Secondary Index) 或辅助索引。由于我们是以c2 列的大小作为B+ 树的排序规则,所以我们也称这棵B+ 树为为c2 列建立的索引,把c2列称为索引列。二级索引记录和聚簇索引记录使用的是一样的记录行格式,只不过二级索引记录存储的列不像聚簇索引记录那么完整。\n把聚簇索引或者二级索引的叶子节点中的记录称为用户记录。为了区分,也把聚簇索引叶子节点中的记录称为完整的用户记录,把二级索引叶子节点中的记录称为不完整的用户记录\n如果为一个存储字符串的列建立索引,别忘了前面说的字符集和比较规则,字符串也是可以比较大小的\n联合索引 # 同时以多个列的大小作为排序规则,也就是同时为多个列建立索引,含义:\n先把各个记录和页按照c2列进行排序 记录的c2列相同的情况下,再采用c3进行排序 每条目录项记录都由c2列、c3列、页号这3个部分组成。各条记录先按照c2列的值进行排序。如果记录的c2列相同,则按照c3列的值进行排序\n这里说的是极特殊的情况,也就是c2列相同的记录有很多很多条,导致好几个页都有c2 = x的记录,而且其中c3列的值还不同,那么就会出现目录项记录页中的目录项c2相同而c3不相同\nB+树叶子节点处的用户记录由c2列、c3列、和主键c1列组成\n以c2 和c3 列的大小为排序规则建立的B+ 树称为联合索引,也称为复合索 引或多列索引。它本质上也是一个二级索引,它的索引列包括c2、c3.需要注意的是\u0026quot;以c2和c3列的大小为排序规则建立联合索引\u0026ldquo;和\u0026rdquo;分别为c2和d 列建立索引\u0026quot; 的表述是不同的, 不同点如下:\n建立联合索引只会建立如图6-15 所示的一棵B+ 树 为c2 和c3 列分别建立索引时,则会分别以c2 和c3 列的大小为排序规则建立两棵B+ 树 Inno中B+树索引的注意事项 # 根页面万年不动窝 # 前面为了理解方便,我们先把存储用户记录的叶子节点都画出来,然后再画出存储目录项记录的内节点。而实际上是这样的:\n每当为某个表创建一个B+树索引(聚簇索引不是人为创建的,默认就存在)时,都会为这个索引创建一个根节点页面。\n一开始表中没有数据的时候,每个B+树索引对应的根节点中既没有用户记录,也没有目录项记录 随后向表中插入用户记录时,先把用户记录存储到这个根节点中 当根节点可用空间用完时,继续插入记录,此时会将根节点中的所有记录复制到一个新分配的页(比如页a)中,然后对这个新页进行页分裂操作,得到另一个新页(比如页b)[因为一个页放不下,所以还要这个新页]。这时新插入的记录会根据键值(也就是聚簇索引中的主键值,或者二级索引中对应的索引列的值)的大小分配到页a或者页b。根节点此时,便升级为存储目录项记录的页,也就需要把页a和页b对应的目录项记录插入到根节点中 在这个过程中,需要特别注意的是, 一个B+ 树索引的根节点自创建之日起便不会再移动(也就是页号不再改变)。\n由于这个特性,只要我们对某个表建立一个索引,那么它的根节点的页号便会被记录到某个地方,后续凡是InnoDB引擎需要用到这个索引,会从那个固定的地方取出根节点的页号,从而访问这个索引\n\u0026ldquo;存储某个索引的根节点在哪个页面中\u0026rdquo;,就是传说中的数据字典中的一项信息\n这里还有一个问题,书上没说,就是根节点作为a,b页的存储目录项记录的页,一旦后面页越来越多,根节点放不下了,接下来\n我猜是这样的,也是再新分配一个页X,然后对页X页分裂,得到页Y。把根节点此时的所有目录项全复制到页X,然后新插入的目录项记录根据键值分配到页X,或Y,然后根节点又变为存储目录项记录的页\n内节点中目录项记录的唯一性 # 目前为止,我们说B+树索引的内节点中,目录项记录的内容是索引列加页号的搭配,但是这个搭配对二级索引来说有点儿不太严谨。以下面这个表为例(c1是主键,c2是二级索引)\nc1 c2 c3 1 1 \u0026lsquo;u\u0026rsquo; 3 1 \u0026rsquo;d' 5 1 \u0026lsquo;y\u0026rsquo; 7 1 \u0026lsquo;a\u0026rsquo; 如果二级索引中,目录项记录的内容只是索引列+页号的匹配,那么为c2列建立索引后的B+树如下图6-16\n如果此时再插入一条记录 c1=9,c2=1,c3=\u0026lsquo;c\u0026rsquo;,那么在修改为c2列建立的二级索引对应的B+树时:由于页3中存储的目录项记录由c2列+页号构成,页3中两条目录项记录对应的c2列都是1,而新插入的这条记录中,c2列也是1,那么这条新插入的记录应该放在页4还是页5?\n为了保证B+树同一层内节点的目录项记录除了页号这个字段以外是唯一,所以二级索引的内节点的目录项记录内容实际上由3部分构成: 索引列的值,主键值,页号 ,如上图6-17\n插入记录(9,1,’c\u0026rsquo;)时,由于页3 中存储的目录项记录是由c2 列+ 主键+页号构成的, 因此可以先把新记录的c2 列的值和页3 中各目录项记录的c2 列的值进行比较, 如果c2 列的值相同,可以接着比较主键值。因为B+ 树同一层中不同目录项记录的c2 列+主键的值肯定是不一样的,所以最后肯定能定位到唯一的一条目录项记录。 在本例中, 最后确定新记录应该插入到页5 中\n对于二级索引,先按照二级索引列的值进行排序,如果相同,再按照主键值进行排序。所以,为c2列建立索引,相当于为(c2,c1)列建立了一个联合索引。另外,对于唯一二级索引来说(当我们为某个列或列组合声明UNIQUE属性时,便会为这个列或组合建立唯一索引),也可能出现多条记录键值相同的情况(1. 声明为UNIQUE的列可能存储多个NULL 2. 后面要讲的MVCC服务),唯一二级索引的内节点的目录项记录也会包含记录的主键值\n注意,书上没有讲到删除的情况,也就是假设有一种情形:索引值1的行被删了,后面又重新添加了。我的理解是不会出现两条索引值一样的记录在树上(根据前面记录行的delete_flag,有可能重复,但是我猜会覆盖掉,所以这里没讲到那个情况,暂时没找到资料证明)\n一个页面至少容纳2条记录 # 如果一个大的目录中只存放一个子目录,那么目录层级会非常多,而且最后那个存放真正数据的目录中只能存放一条记录\n如果让B+树的叶子节点只存储一条记录,让内节点存储多条记录,也还是可以发挥B+树作用的。为了避免B+树的层级增长过高,要求所有数据页都至少可以容纳2条记录(也就是说,会极力避免因为列值过大、或者过多导致容纳不了2条记录)\nInnoDB对列的数量有所限制,而如果在最大限制下,结合04章的结论:\n如果一条记录的某个列中存储的数据占用字节数非常多,导致一个页没有办法存储两条记录,该列就可能会成为溢出列\nMyISAM中的索引方案简介 # 为了内容完整性,介绍一下MyISAM存储引擎中的索引方案\nInnoDB中,索引即数据,也就是聚簇索引的那颗B+树的叶子节点中包含了完整的用户记录。MyISAM虽然也是树形,但是索引和数据是分开的\n数据文件 # 表中的记录按照记录的插入顺序单独存储在一个文件中(称之为数据文件)\n该文件不划分若干个数据页,有多少记录就往文件中塞多少。通过行号快速访问到一条记录\nMyISAM记录需要记录头信息来存储额外数据,以index_demo表为例,看一下这个表在使用MyISAM作为存储引擎时,它的记录如何在存储空间表示\n由于是按插入顺序,没有按主键大小排序,所以不能在这些数据上使用二分法\n索引 # MyISAM存储引擎会把索引信息单独存储到另一个文件中(即索引文件)\nMyISAM会为表的主键单独创建一个索引,只不过在索引的叶子节点中存储的不是完整用户记录,而是主键值与行号的结合。即先通过索引找到对应的行号,再通过行号去找对应的记录\n与InnoDB不同,InnoDB存储隐情中,只需根据主键值对聚簇索引进行依次查找就能找到对应记录,而MyISAM中却需要进行一次回表操作。意味着MyISAM中建立的索引相当于都是二级索引\n其他索引 # 可以为其他列分别建立索引或者建立联合索引,原理与InnoDB差不多,只不过叶子节点存储的是相应的列+行号(InnoDB中存储的则是主键)。这些索引也都是二级索引。\nMyISAM行格式有定长记录格式Static、变长记录格式Dynamic、压缩记录格式 Compressed。图6-18就是定长记录格式,即一条记录占用的存储空间是固定的,这样就可以使用行号轻松算出某条记录在数据文件中的地址偏移量,但是变长记录格式就不行乐,MyISAM会直接在索引叶子节点处存储该记录在数据文件中的偏移量。==\u0026gt; MyISAM回表快速,因为是拿着地址偏移量直接到文件中取数据。而InnoDB则是获取主键后,再去从聚簇索引中查找。\n总结 # InnoDB:索引即数据\nMyISAM:索引是索引,数据是数据\nMySQL中创建和删除索引的语句 # InnoDB会自动为主键或者**带有UNIQUE **属性的列建立索引\nInnoDB不会自动为每个列创建索引,因为每建立一个索引都会建立一颗B+树,且增删改都要维护各个记录、数据页的排序关系,费性能和存储空间\n#语法 CREATE TABLE 表名( 各个列的信息..., (KEY|INDEX) 索引名 (需要被索引的单个列或多个列); ) #修改表结构 ALTER TABLE 表名 ADD (INDEX|KEY) 索引名 (需要被索引的单个列或多个列); #修改表结构的时候删除索引 ALTER TABLE 表名 DROP (INDEX|KEY) 索引名; 实例:\nmysql\u0026gt; CREATE TABLE index_demo( c1 INT, c2 INT, c3 CHAR(1), PRIMARY KEY (c1), INDEX idx_c2_c3 (c2,c3) ); #查看建表语句 mysql\u0026gt; SHOW CREATE TABLE index_demo; +------------+---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------+ | Table | Create Table | +------------+---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------+ | index_demo | CREATE TABLE `index_demo` ( `c1` int(11) NOT NULL, `c2` int(11) DEFAULT NULL, `c3` char(1) DEFAULT NULL, PRIMARY KEY (`c1`), KEY `idx_c2_c3` (`c2`,`c3`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8 | +------------+---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------+ 1 row in set (0.00 sec) "},{"id":229,"href":"/zh/docs/technology/MySQL/_how_mysql_run_/05/","title":"05InnoDB数据页结构","section":"_MySQL是怎样运行的_","content":" 学习《MySQL是怎样运行的》,感谢作者!\n不同类型的页简介 # 页是InnoDB管理存储空间的基本单位,1个页的大小一般是16KB\nInnoDB为了不同目的设计多种不同类型的页,包括存放表空间头部信息 的页、存放Change Buffer 信息的页、存放INODE信息的页、存放undo 日志信息的页\n这里说的是存放表中记录的那种类型的页,这种存放记录的页称为索引页(INDEX页)\n暂时称之为数据页\n数据页结构快览 # 1个页有16KB,这部分存储空间被划分为了多个部分(7部分),不同部分有不同的功能\n名称 中文名 占用空间 大小 File Header 文件头部 38 字节 页的一些通用信息 Page Header 页面头部 56 字节 数据页专有的一些信息 Infimum + Supremum 页面中的最小记录和最大记录 26 字节 两个虚拟的记录 User Records 用户记录 不确定 用户存储的记录内容 Free Space 空闲空间 不确定 页中尚未使用的空间 Page Directory 页目录 不确定 某些记录的相对位置 File Trailer 文件尾部 8 字节 校验页是否完整 记录在页中的存储 # 每插入一条记录,从Free Space申请一个记录大小的空间,并将这个空间划分到UserRecords部分。当FreeSpace部分的空间全部被UserRecords部分替代掉后,意味着该页用完。如果再插入,就需要申请新的页\n记录头信息的秘密 # mysql\u0026gt; CREATE TABLE page_demo( c1 INT, c2 INT, c3 VARCHAR(10000), PRIMARY KEY(c1) ) CHARSET=ascii ROW_FORMAT=COMPACT; Query OK, 0 rows affected (0.03 sec) 名称 大小(比特) 描述 预留位1 1 没有使用 预留位2 1 没有使用 deleted_flag 1 标志该记录是否被删除 min_rec_flag 1 B+ 树中每层非叶子节点中的最小的目录项记录都会添加该标记 n_owned 4 一个页面中的记录会被分成若干个组,每个组中有一个记录是\u0026quot;带头大哥“,其余的记录都是\u0026quot;小弟\u0026quot;。带头大哥\u0026quot;记录的n_owned值代表该组中所有的记录条数,\u0026ldquo;小弟\u0026quot;记录的n_owned值都为0 heap_no 13 表示当前记录在页面堆中的相对位置 record_type 3 表示当前记录的类型,0表示普通记录. 1 表示B+ 树非叶节点的目录项记录. 2 表示Infimum 记录. 3 表示Supremum 记录 next_record 16 表示下一条记录的相对位置 简化一下(忽略其他非讲解的部分信息)\n#插入4条记录 mysql\u0026gt; INSERT INTO page_demo VALUES(1,100,\u0026#39;aaaa\u0026#39;),(2,200,\u0026#39;bbbb\u0026#39;),(3,300,\u0026#39;cccc\u0026#39;),(4,400,\u0026#39;dddd\u0026#39;); Query OK, 4 rows affected (0.01 sec) Records: 4 Duplicates: 0 Warnings: 0 UserRecords部分的存储结构\ndeleted_flag # 标记当前记录是否删除:0表示没有被删除,1表示记录被删除\n被删除的记录不从磁盘溢出,因为移除后还需要在磁盘上重新排列其他的记录,带来性能消耗\n被删除掉的记录会组成一个垃圾链表,记录在这个链表中占用的空间称为可重用空间,如果之后有新纪录插入到表中,就可能覆盖掉被删除的记录所占用的存储空间\ndelete_flag设置为1和将被删除的记录加入到垃圾链表其实是两个阶段,后面介绍undo日志会详细讲解删除操作的详细执行过程\nmin_rec_flag # B+树每层非叶子节点中的最小的目录项记录都会添加该标记\nn_onwed # heap_no # 记录一条一条亲密无间排列的结构称之为堆(heap)。把一条记录在堆中的相对位置称之为heap_no\n为了管理这个堆,每一条记录在堆中的相对位置称为heap_no。\n页面前面的记录heap_no比后面的小,且每新申请一条记录的存储空间,该条记录比物理位置在它前面的那条记录的heap_no大1\n由上可知,4条记录的heap_no为2,3,4,5\nInnoDB的设计者自动给每个页添加了两条记录(称之伪记录或虚拟记录)。一条代表页面中的最小记录(也称Infimum记录美 [ɪn'faɪməm]),一条代表页面中的最大记录(也称Supremumsu'pri: m en)。这两条伪记录也算作堆的一部分\n比较完整记录的大小就是比较主键的大小\n规定,用户的任何记录都比Infimum记录大,比supremum记录小\nInfimum和Supremum记录 # 单独放在一个称为Infimum和Supremum的部分\n堆中记录的heap_no值在分配之后就不会发生改动了(即使删除了堆中某条记录)\nrecord_type # 表示当前记录的类型,0表示普通记录(上面自己插入的记录是),1表示B+树非叶节点的目录项记录(后面索引会讲到),2表示Infimum记录,3表示Supremum记录\nnext_record # 表示从当前记录的真实数据到下一条记录的真实数据的距离\n如果该属性值为正数, 说明当前记录的下一条记录在当前记录的后面: 如果该属性值为负数,说明当前记录的下一条记录在当前记录的前面\n下一条记录,指的是按照主键值由小到大的顺序排列的下一条记录\nInfimum的下一条记录是本页中主键值最小的用户记录,本页中主键值最大的用户记录的下一条记录就是Supremum记录\n如上,记录按照主键从小到大的顺序形成了一个单向链表\nSupremum记录的next_record值为0,即没有下一条记录了,如果删除其中一条记录\nSupremum记录的n_owned由5变成了4\nInnoDB始终维护记录的一个单向链表,链表中的各个节点是按照主键值由小到大的顺序链接起来的\n为啥next_record是指向记录头信息和真实数据之间的位置,而不是整条记录的开头。\n这个位置刚好,向左是记录头信息,向右是真实数据 由于变长字段长度列表、NULL值列表中的信息都是逆序存放,这样可以使记录中靠前的字段和他们对应的字段长度信息在内存中的距离更近,提高高速缓存命中率 如果第2条记录被重新插入\nPageDirectory(页目录) # 解释 # 直接遍历的话,时间复杂度太高\n说明: 将所有记录(包括Infimum和Supremum记录,不包括已经移除到垃圾链表的记录划分为几个组\n每个组的最后一条记录(组内最大的那条记录)相当于带头大哥,其余记录相当于小弟。\n带头大哥记录的头信息中的n_owned属性表示改组内共有几条记录\n操作:\n将每个组中最后一条记录(组内最大记录)在页面中的地址偏移量(该记录的真实数据与页面中第0个字节之间的距离)单独提取出来,按顺序存储倒靠近页尾部的地方(这个地方就是PageDirectory)\n页目录的偏移地址称为槽(Slot),每个槽占用2字节,页目录由多个槽组成\n1页有16KB,即16384字节,而2字节可以表示的地址偏移量为2^16-1=65535 \u0026gt;16384,所以用2字节表示一个槽足够了\n举例 # 假设page_demo表中有6条记录(包括Infimum和Supremum)\n注意,Infimum记录的n_owned值为1,Supremum记录的n_owned值为5\n且槽对应的记录(值)越小,越靠近FileTrailer\n用指针形式表示\n划分依据\n规定:对于Infimum记录所在的分组只能有1条记录,Supremum记录所在分组记录数在18条之间,剩下的分组中记录的条数范围只能是48条 简化:\n步骤:\n初始情况,数据页中只有Infimum和Supremum两条记录,分属两个分组\n页目录也只有两个槽:分别代表Infimum记录和Supremum记录在页中的偏移量\n之后每插入一条记录,都会从页目录中找到对应记录的主键值比待插入记录的主键值大并且差值最小的槽(从本质上看,槽是一个组内最大那条记录在页面中的地址偏移量,通过槽可以快速找到对应的记录的主键值)。然后把该槽对应的记录的n_owned值加1,表示本组内又添加了一条记录,直到该组中的记录数等于8\n当一个组中的记录数等于8后,再插入一条记录,会将组中的记录拆分成两个组,其中一个组中4条记录,另一个5条记录。且会在页目录中新增一个槽,记录这个新增分组中最大的那条记录的偏移量\n为了演示快速查找,再添加12条记录 ,总共16条\n一个槽占用2个字节,且槽之间是挨着的,每个槽代表的主键值都是从小到大排序的,所以可以使用二分法快速查找\n这里给槽编号:0,1,2,3,4。最低的槽就是low=0,最高的槽就是high=4\n假设我们要查找主键值为6的记录\n(0+4)/2=2,槽2代表的主键值8\u0026gt;6,所以high=2,low不变=0 (0+2)/2=1,槽1代表的主键值4\u0026lt;6,所以low=1,high不变=2 high-low=1,又因为槽记录的是最大值,所以不在槽1中,而是在槽2中\n沿着单项列表遍历槽2中的记录:如何遍历,先找到槽1的地址,然后它的下一条记录就是槽2中的最小记录 值为5,从值5的记录出发遍历即可(由于一个组中包含的记录条数最多是8,所以代价极小 总结\n通过二分法确定槽,找到槽所在分组中主键值最小的那条记录\n然后通过记录的next_record属性遍历该槽所在记录的各个记录\nPageHeader(页面头部) # 页结构的第2部分,占用固定的56字节,专门存储各种状态信息\nPageHeader的结构及描述\n状态名称 占用空间大小 描述 PAGE_N_DlR SLOTS 2字节 在页目录中的槽数量 PAGE_HEAP_TOP 2字节 还未使用的空间最小地址, 也就是说从该地址之后就是FreeSpace PAGE_N_HEAP 2字节 第1位表示本记录是否为紧凑型的记录, 剩余的15 位表示本页的堆中记录的数量(包括lnfimum 和Supremum 记录以及标记为\u0026quot;己删除\u0026quot;的记录) PAGE_FREE 2字节 各个己删除的记录通过next_record 组成一个单向链表,这个单向链表中的记录所占用的存储空间可以被重新利用;PAGE FREE 表示该链表头节点对应记录在页面中的偏移量 PAGE_GARBAGE 2字节 己删除记录占用的字节数 PAGE_LAST_INSERT 2字节 最后插入记录的位置 PAGE_DIRECTION 2字节 最后一条记录插入的方向 PAGE_N_DIRECTION 2字节 一个方向连续插入的记录数量 PAGE_N_RECS 2字节 该页中用户记录的数量〈不包括Infimum 和Supremum记录以及被删除的记录) PAGE_MAX_TRX_ID 8字节 修改当前页的最大事务id. 该值仅在二级索引页面中定义 PAGE_LEVEL 2字节 当前页在B+ 树中所处的层级 PAGE_INDEX_ID 8字节 索引ID, 表示当前页属于哪个索引 PAGE_BTR_SEG_LEAF 10字节 B+ 树叶子节点段的头部信息,仅在B+ 树的根页面中定义 PAGE_BTR_SEG_TOP 10字节 B+ 树非叶子节点段的头部信息,仅在B+ 树的根页面中定义 PAGE_N_DlR SLOTS - PAGE_N_RECS 的作用应该是清除的,这里有两个解释一下:\nPAGE_DIRECTION:加入新插入的一条记录的主键值比上一条记录的主键值大,我们说这条记录的插入方向是右边,反之则是左边。用来表示最后一条记录插入方向的状态就是PAGE_DIRECTION\nPAGE_N_DIRECTION:假设连续插入新记录的方向都是一致,InnoDB会把沿着同一个方向插入记录的条数记下来,用PAGE_N_DIRECTION表示。如果最后一条记录的插入方向发生了改变,这个状态的值会被清零后重新统计\n其他的暂时不讨论\nFileHeader(文件头部) # PageHeader专门针对的是数据页记录的各种状态信息,比如页有多少条记录,多少个槽。\nFileHeader通用于各种类型的页,描述了一些通用于各种页的信息,比如这个页的编号是多少,它的上一个页和下一个页是谁,固定占用38字节\n校验和(checksum):对于很长的字节串,通过某种算法计算出比较短的值来代编这个字节串,比较之前先比较这个字节串。省去了直接比较这两个长字节串的时间损耗\nInnoDB通过页号来唯一定位一个页\n页号(第n个号),4字节,2^(4*8)=2^32次方位 =4294967296 个页\n4294967296 * (16KB/页) =64T,这也是InnoDB 单表限制的大小\n页有好几种类型,前面介绍的是存储记录的数据页,还有其他类型的页\n存放记录的数据页的类型其实是FIL_PAGE_INDEX,也就是索引页\n前面说记录的存储结构时,所说的溢出页是FIL_PAGE_TYPE_BLOB\n对于FIL_PAGE_PREV和FIL_PAGE_NEXT:当占用空间非常大时,无法一次性为这么多数据分配一个非常大的存储空间,如果分散到多个不连续的页中存储,则需要把这些页关联起来。FIL_PAGE_PREV和FIL_PAGE_NEXT就分别代表本数据页的上一个页和下一个页的页号。不是所有类型的页都有上一个页和下一个页属性的,不过数据页(FIL_PAGE_INDEX的页)有这两个属性,所以存储记录的数据页其实可以组成一个双向链表\nFileTrailer(文件尾部) # InnoDB存储引擎会把数据存储倒磁盘,但磁盘速度太慢,需要以页为单位把数据加载到内存中处理\n如果在该页中的数据在内存中修改了,在修改后某个时间还需要把数据刷新到磁盘中,但在刷新还没结束的时候断电了怎么办。为了检测一个页是否完整(判断刷新时有没有之刷新了一部分),为每个页尾部添加一个FileTriler部分,由8个字节组成,又分两小部分\n前4 字节代表页的校验和。这个部分与File Header 中的校验和相对应。每当一个页面在内存中发生修改时,在刷新之前就要把页面的校验和算出来。因为File Header 在页面的前边,所以File Header 中的校验和会被首先刷新到磁盘,当完全写完后,校验和也会被写到页的尾部。如果页面刷新成功,则页首和页尾的校验和应该是一致的。如果刷新了一部分后断电了,那么File Header 中的校验和就代表着己经修改过的页,而File Trailer 中的校验和代表着原先的页(因为断电了,所以没有完全写完),二者不同则意味着刷新期间发生了错误. 后4 字节代表页面被最后修改时对应的LSN 的后4 字节,正常情况下应该与FileHeader 部分的FIL_PAGE_LSN的后4 字节相同。这个部分也是用于校验页的完整性,不过我们目前还没说LSN 是什么意思,所以大家可以先不用管这个属性。 这个File Trailer 与File Header 类似,都通用于所有类型的页\n"},{"id":230,"href":"/zh/docs/technology/MySQL/_how_mysql_run_/04/","title":"04InnoDB记录存储结构","section":"_MySQL是怎样运行的_","content":" 学习《MySQL是怎样运行的》,感谢作者!\n问题 # 表数据存在哪,以什么格式存放,MySQL以什么方式来访问\n存储引擎:对表中数据进行存储和写入\nInnoDB是MySQL默认的存储引擎,这章主要讲InnoDB存储引擎的记录存储结构\nInnoDB页简介 # 注意,是简介\nInnoDB:将表中的数据存储到磁盘上\n真正处理数据的过程:内存中。所以需要把磁盘中数据加载到内存中,如果是写入或修改请求,还需要把内存中的内容刷新到磁盘上\n获取记录:不是一条条从磁盘读,InnoDB将数据划分为若干个页,以页作为磁盘和内存之间交互的基本单位。页大小-\u0026gt; 一般是16KB\n一般情况:一次最少从磁盘读取16KB的内容到内存中,一次最少把内存中的16KB内容刷新到磁盘中\nmysql\u0026gt; SHOW VARIABLES LIKE \u0026#39;innodb_page_size\u0026#39;; +------------------+-------+ | Variable_name | Value | +------------------+-------+ | innodb_page_size | 16384 | +------------------+-------+ 1 row in set (0.00 sec) 只能在第一次初始化MySQL数据目录时指定,之后再也不能更改(通过mysqld \u0026ndash;initialize初始化数据目录[旧版本])\nInnoDB行格式 # 以记录为单位向表中插入数据,而这些记录在磁盘上的存放形式也被称为行格式或者记录格式\n目前有4中不同类型的行格式:COMPACT、REDUNDANT、DYNAMIC和COMPRESSED\ncompact [kəmˈpækt]契约\nredundant[rɪˈdʌndənt] 冗余的\ndynamic[daɪˈnæmɪk]动态的\ncompressed [kəmˈprest] 压缩的\n指定行格式的语法 # CREATE TABLE 表名(列的信息) ROW_FORMAT=行格式名称\nALTER TABLE 表名 ROW_FORMATE=行格式名称\n如下,在数据库xiaohaizi下创建一个表\nCREATE TABLE record_format_demo( c1 VARCHAR(10), c2 VARCHAR(10) NOT NULL, c3 CHAR(10), c4 VARCHAR(10) ) CHARSET=ascii ROW_FORMAT=COMPACT; #回顾:ascii每个字符1字节即可表示,且只有空格标点数字字母不可见字符 #插入两条数据 INSERT INTO record_format_demo(c1,c2,c3,c4) VALUES(\u0026#39;aaaa\u0026#39;,\u0026#39;bbb\u0026#39;,\u0026#39;cc\u0026#39;,\u0026#39;d\u0026#39;),(\u0026#39;eeee\u0026#39;,\u0026#39;fff\u0026#39;,NULL,NULL); 查询\n#查询 mysql\u0026gt; SELECT * FROM record_format_demo; +------+-----+------+------+ | c1 | c2 | c3 | c4 | +------+-----+------+------+ | aaaa | bbb | cc | d | | eeee | fff | NULL | NULL | +------+-----+------+------+ 2 rows in set (0.01 sec) COMPACT行格式 # [kəmˈpækt]契约\n额外信息 # 包括变长字段长度列表、NULL值列表、记录头信息\n记录的真实数据 # REDUNDANT行格式 # [rɪˈdʌndənt] 冗余的 MySQL5.0之前使用的一种行格式(古老)\n如图\n下面主要和COMPACT行格式做比较\n字段长度偏移列表 # 记录了所有列 偏移,即不是直接记录,而是通过加减 同样是逆序,如第一条记录\n06 0C 13 17 1A 24 25,则\n第1列(RD_ROW_ID):6字节\n第2列(DB_TRX_ID):6字节 0C-06=6 第3列(DB_ROLL_POINTER):7字节 13-0C=7 第4列(c1):4字节\n第5列(c2):3字节\n第6列(c3):10字节\n第7列(c4):1字节\n记录头信息 # 相比COMPACT行格式,多出了2个,少了一个\n没有了record_type这个属性 多了n_field和1byte_offs_flag这两个属性:\n#查询 mysql\u0026gt; SELECT * FROM record_format_demo; +------+-----+------+------+ | c1 | c2 | c3 | c4 | +------+-----+------+------+ | aaaa | bbb | cc | d | | eeee | fff | NULL | NULL | +------+-----+------+------+ 2 rows in set (0.01 sec) 第一条记录的头信息为:00 00 10 0F 00 BC\n即:00000000 00000000 00010000 00001111 00000000 1011 1100\n前面2字节都是0,即预留位1,预留位2,deleted_flag,min_rec_flag,n_owned都是0\nheap_no前面8位是0,再取5位:即 00000000 0001 0,即0x02\nn_field:000 0000111,即0x07\n1byte_offs_flag:0x01\nnext_record:00000000 1011 1100,即0xBC\n记录头信息中的1byte_offs_flag的值是怎么选择的 # 字段长度偏移列表存储的偏移量指的是每个列的值占用的空间在记录的真 实数据处结束的位置\n如上,0x06代表第一列(DB_ROW_ID)在真实数据的第6字节处结束;0x0C 代表第二列(DB_TRX_ID)在真实数据的第12字节处结束\u0026hellip;.\n讨论:每个列对应的偏移量可以使用1字节或2字节来存储,那么什么时候1什么时候2\n根据REDUNDANT行格式记录的真实数据占用的总大小来判断\n如果真实数据占用的字节数不大于127时,每个列对应的偏移量占用1字节**[注意,这里只用到了1字节的7位,即max=01111111]**\n如果大于127但不大于32767 (2^15-1,也就是15位的最大表示)时,使用2字节。\n如果超过32767,则本页中只保留前768字节和20字节的溢出页面地址(20字节还有别的信息)。这种情况下只是用2字节存储每个列对应的偏移量即可(127\u0026lt;768\u0026lt;=32767)\n在头信息中放置了一个1byte_offs_flag属性,值为1时表明使用1字节存储偏移量;值为0时表明使用2字节存储偏移量\nREDUNDANT行格式中NULL值的处理 # REDUNDANT行格式并没有NULL值列表\n将列对应的偏移量值的第一个比特位,作为是否为NULL的依据,也称之为NULL比特位 不论是1字节还是2字节,都要使用第1个比特位来标记该列值是否为NULL\n对于NULL列来说,该列的类型是否为变长类型决定了该列在记录的真实数据处的存储方式。\n分析第2条数据\n字段长度偏移列表-\u0026gt;按照列的顺序排放:06 0C 13 17 1A A4 A4\nc3=NULL,且c3类型-\u0026gt;CHAR(10) ==\u0026gt;真实数据部分占用10字节,0x00\nc3 原偏移量为36=32+4 = 00100100-\u0026gt;0x24,由于为NULL,所以首位(比特)为1,所以(真实)偏移量为10100100,0xA4\nc2偏移量为0x1A,则c2字节数为0x24-0x1A=36-26=10\n如果存储NULL值的字段为变长数据类型,则不在记录的真实数据部分占用任何存储空间\n所以c4的偏移量应该和c3相同,都是00100100,且由于是NULL,所以首位为1-\u0026gt;10100100,0xA4\n从结果往回推理,c4也是0xA4,和c3相同,说明c4和c3一样都是NULL\nCOMPACT行格式的记录占用的空间更少\nCHAR(m)列的存储格式 # COMPACT中,当定长类型CHAR(M)的字符集的每个字符占用字节不固定时,才会记录CHAR列的长度;而REDUNDANT行格式中,该列真实数据占用的存储空间大小,就是该字符集表示一个字符最多需要的字节数和M的乘积:utf8的CHAR(10)类型的列,真实数据占用存储空间大小始终为30字节;使用gbk字符集的CHAR(10),始终20字节\n溢出列 # 溢出列 # #举例 mysql\u0026gt; CREATE TABLE off_page_demo( c VARCHAR(65532) ) CHARSET=ascii ROW_FORMAT=COMPACT; #插入一条数据 mysql\u0026gt; INSERT INTO off_page_demo(c) VALUES(REPEAT(\u0026#39;a\u0026#39;,65532)); Query OK, 1 row affected (0.06 sec) ascii字符集中1字符占用1字节,REPEAT(\u0026lsquo;a\u0026rsquo;,65532)生成一个把字符\u0026rsquo;a\u0026rsquo;重复65532次数的字符串\n1页有16kb=1024*16=16384字节,65532字节远超1页大小\nCOMPACT和REDUNDANT行格式中,对于存储空间占用特别多的列,真实数据处只会存储该列一部分数据,剩余数据存储在几个其他的页中,在记录的真实数据处用20字节存储指向这些页的地址(当然,这20字节还包括分散在其他页面中的数据所占用的字节数)\n原书加了括号里的话,不是很理解,我的理解是:这20字节指向的页中,包括了溢出的那部分数据\n如上,如果列数据非常大,只会存储该列前768字节的数据以及一个指向其他页的地址(存疑,应该不止一个,有时候1个是放不下所有溢出页数据的吧?)\n简化:\n例子中列c的数据需要使用溢出页来存储,我们把这个列称为溢出列,不止VARCHAR(M),TEXT和BLOB也可能成为溢出列\n产生溢出列的临界点 # MySQL中规定一个页至少存放2条记录\n16KB=16384字节\n每个页除了记录,还有额外信息,这些额外信息需要132字节。\n每个记录需要27字节,包括\n针对下面的表\nmysql\u0026gt; CREATE TABLE off_page_demo( c VARCHAR(65532) ) CHARSET=ascii ROW_FORMAT=COMPACT;\n注意,是COMPACT行格式\n对于每一行记录 存储真实数据长度(2字节)\n存储列是否为NULL值(1字节)\n5字节大小的头信息\n6字节的row_id列\n6字节的row_id列\n7字节的roll_pointer列\n132+2*(27+n) \u0026lt;16384\n至于为社么不能等于,这是MySQL设计时规定的,未知。\n正常记录的页和溢出页是两种不同的页,没有规定一个溢出页页面中至少存放两条记录\n对于该表,得出的解是n\u0026lt;8099,也就是如果一个列存储的数据小于8099,就不会成为溢出页\n结论 # 如果一条记录的某个列中存储的数据占用字节数非常多,导致一个页没有办法存储两条记录,该列就可能会成为溢出列\nDYNAMIC行格式和COMPRESSED行格式 # 这两个与COMPACT行记录挺像,对于处理溢出列的数据有分歧:\n他们不会在记录真实处存储真实数据的前768字节,而是把该列所有真实数据都存储到溢出页,只在真实记录处存储20字节(指向溢出页的地址)。COMPRESSED行格式不同于DYNAMIC行格式的一点:COMPRESSED行格式会采用压缩算法对页面进行压缩\nMySQL5.7默认使用DYNAMIC行记录\n总结 # REDUNDANT是一个比较原始的行格式,较为紧凑;而COMPACT、DYNAMIC以及COMPRESSED行格式是较新的行格式,它们是紧凑的(占用存储空间更少)\n"},{"id":231,"href":"/zh/docs/technology/MySQL/_how_mysql_run_/03/","title":"03字符集和比较规则","section":"_MySQL是怎样运行的_","content":" 学习《MySQL是怎样运行的》,感谢作者!\n字符集 # 把哪些字符映射成二进制数据:字符范围\n怎么映射:字符-\u0026gt;二进制数据,编码;二进制-\u0026gt;字符,解码\n字符集:某个字符范围的编码规则\n同一种字符集可以有多种比较规则\n重要的字符集 # ASCAII字符集:128个,包括空格标点数字大小写及不可见字符,使用一个字节编码\nISO 8859-1字符集:256个,ASCAII基础扩充128个西欧常用字符(包括德法),使用1个字节,别名Latin1\nGB2312字符集:收录部分汉字,兼容ASCAII字符集,如果字符在ASCAII字符集中则采用1字节,否则两字节。即变长编码方式\n区分某个字节,代表一个单独字符,还是某个字符的一部分。\n比如0xB0AE75,由于是16进制,所有两个代表1个字节。所以这里有三个字节,其中最后那个字节为7*16+5=117 \u0026lt; 127 所以代表一个单独字符。而AE=10 * 16 +15=175 \u0026gt;127 ,所以是某个字符的一部分\nGBK字符集:对GB2312字符集扩充,编码方式兼容GB2312\nUTF-8字符集:几乎收录所有字符,且不断扩充,兼容ASCAII字符集。变长:采用14字节\nL-\u0026gt;0x4C 1字节,啊-\u0026gt;0xE5958A,两字节\nUTF-8是Unicode字符集的一种编码方案,Unicode字符集有三种方案:UTF-8(14字节编码一个字符),UTF-16(2或4字节编码一个字符),UTF-32(4字节编码一个字符)\n对于**“我”**,ASCLL中没有,UTF-8中采用3字节编码,GB22312采用2字节编码\nMySQL中支持的字符集和比较规则 # MySQL中,区分utf8mb3和utf8mb4,前者只是用13字节表示字符;后者使用14字节表示字符。MySQL中,utf8代表utf8mb3。\n#查看当前MySQL支持的字符集(注意,是字符集,名称都是小写) #Default collation 默认比较规则 mysql\u0026gt; SHOW CHARSET; +----------+---------------------------------+---------------------+--------+ | Charset | Description | Default collation | Maxlen | +----------+---------------------------------+---------------------+--------+ | big5 | Big5 Traditional Chinese | big5_chinese_ci | 2 | | dec8 | DEC West European | dec8_swedish_ci | 1 | | cp850 | DOS West European | cp850_general_ci | 1 | | hp8 | HP West European | hp8_english_ci | 1 | | koi8r | KOI8-R Relcom Russian | koi8r_general_ci | 1 | | latin1 | cp1252 West European | latin1_swedish_ci | 1 | \u0026lt;--- | latin2 | ISO 8859-2 Central European | latin2_general_ci | 1 | \u0026lt;--- | swe7 | 7bit Swedish | swe7_swedish_ci | 1 | | ascii | US ASCII | ascii_general_ci | 1 | \u0026lt;--- | ujis | EUC-JP Japanese | ujis_japanese_ci | 3 | | sjis | Shift-JIS Japanese | sjis_japanese_ci | 2 | | hebrew | ISO 8859-8 Hebrew | hebrew_general_ci | 1 | | tis620 | TIS620 Thai | tis620_thai_ci | 1 | | euckr | EUC-KR Korean | euckr_korean_ci | 2 | | koi8u | KOI8-U Ukrainian | koi8u_general_ci | 1 | | gb2312 | GB2312 Simplified Chinese | gb2312_chinese_ci | 2 | \u0026lt;--- | greek | ISO 8859-7 Greek | greek_general_ci | 1 | | cp1250 | Windows Central European | cp1250_general_ci | 1 | | gbk | GBK Simplified Chinese | gbk_chinese_ci | 2 | \u0026lt;--- | latin5 | ISO 8859-9 Turkish | latin5_turkish_ci | 1 | | armscii8 | ARMSCII-8 Armenian | armscii8_general_ci | 1 | | utf8 | UTF-8 Unicode | utf8_general_ci | 3 | \u0026lt;--- | ucs2 | UCS-2 Unicode | ucs2_general_ci | 2 | | cp866 | DOS Russian | cp866_general_ci | 1 | | keybcs2 | DOS Kamenicky Czech-Slovak | keybcs2_general_ci | 1 | | macce | Mac Central European | macce_general_ci | 1 | | macroman | Mac West European | macroman_general_ci | 1 | | cp852 | DOS Central European | cp852_general_ci | 1 | | latin7 | ISO 8859-13 Baltic | latin7_general_ci | 1 | | utf8mb4 | UTF-8 Unicode | utf8mb4_general_ci | 4 | \u0026lt;--- | cp1251 | Windows Cyrillic | cp1251_general_ci | 1 | | utf16 | UTF-16 Unicode | utf16_general_ci | 4 | \u0026lt;--- | utf16le | UTF-16LE Unicode | utf16le_general_ci | 4 | | cp1256 | Windows Arabic | cp1256_general_ci | 1 | | cp1257 | Windows Baltic | cp1257_general_ci | 1 | | utf32 | UTF-32 Unicode | utf32_general_ci | 4 | \u0026lt;--- | binary | Binary pseudo charset | binary | 1 | | geostd8 | GEOSTD8 Georgian | geostd8_general_ci | 1 | | cp932 | SJIS for Windows Japanese | cp932_japanese_ci | 2 | | eucjpms | UJIS for Windows Japanese | eucjpms_japanese_ci | 3 | | gb18030 | China National Standard GB18030 | gb18030_chinese_ci | 4 | +----------+---------------------------------+---------------------+--------+ 41 rows in set (0.00 sec) 字符集的比较规则(这里先看utf8的)\nmysql\u0026gt; SHOW COLLATION LIKE \u0026#39;utf8\\_%\u0026#39;; +--------------------------+---------+-----+---------+----------+---------+ | Collation | Charset | Id | Default | Compiled | Sortlen | +--------------------------+---------+-----+---------+----------+---------+ | utf8_general_ci | utf8 | 33 | Yes | Yes | 1 | | utf8_bin | utf8 | 83 | | Yes | 1 | | utf8_unicode_ci | utf8 | 192 | | Yes | 8 | | utf8_icelandic_ci | utf8 | 193 | | Yes | 8 | | utf8_latvian_ci | utf8 | 194 | | Yes | 8 | | utf8_romanian_ci | utf8 | 195 | | Yes | 8 | | utf8_slovenian_ci | utf8 | 196 | | Yes | 8 | | utf8_polish_ci | utf8 | 197 | | Yes | 8 | | utf8_estonian_ci | utf8 | 198 | | Yes | 8 | | utf8_spanish_ci | utf8 | 199 | | Yes | 8 | | utf8_swedish_ci | utf8 | 200 | | Yes | 8 | | utf8_turkish_ci | utf8 | 201 | | Yes | 8 | | utf8_czech_ci | utf8 | 202 | | Yes | 8 | | utf8_danish_ci | utf8 | 203 | | Yes | 8 | | utf8_lithuanian_ci | utf8 | 204 | | Yes | 8 | | utf8_slovak_ci | utf8 | 205 | | Yes | 8 | | utf8_spanish2_ci | utf8 | 206 | | Yes | 8 | | utf8_roman_ci | utf8 | 207 | | Yes | 8 | | utf8_persian_ci | utf8 | 208 | | Yes | 8 | | utf8_esperanto_ci | utf8 | 209 | | Yes | 8 | | utf8_hungarian_ci | utf8 | 210 | | Yes | 8 | | utf8_sinhala_ci | utf8 | 211 | | Yes | 8 | | utf8_german2_ci | utf8 | 212 | | Yes | 8 | | utf8_croatian_ci | utf8 | 213 | | Yes | 8 | | utf8_unicode_520_ci | utf8 | 214 | | Yes | 8 | | utf8_vietnamese_ci | utf8 | 215 | | Yes | 8 | | utf8_general_mysql500_ci | utf8 | 223 | | Yes | 1 | +--------------------------+---------+-----+---------+----------+---------+ 27 rows in set (0.00 sec) utf8_polish_ci 波兰语比较规则; utf8_spanish_ci班牙语的比较规则;utf8_general_ci一种通用的比较规则 (utf8的默认比较规则)\n一些后缀的解释:\n字符集和比较规则的应用 # MySQL有4个级别的字符集和比较规则:服务器级别、数据库级别、表级别、列级别\n服务器级别 # mysql\u0026gt; SHOW VARIABLES LIKE \u0026#39;character_set_server\u0026#39;; +----------------------+--------+ | Variable_name | Value | +----------------------+--------+ | character_set_server | latin1 | +----------------------+--------+ 1 row in set (0.00 sec) mysql\u0026gt; SHOW VARIABLES LIKE \u0026#39;collation_server\u0026#39;; +------------------+-------------------+ | Variable_name | Value | +------------------+-------------------+ | collation_server | latin1_swedish_ci | +------------------+------------------- 1 row in set (0.00 sec) #centos7(英语语言)默认情况下如上 #所以比较时,是不区分大小写的 mysql\u0026gt; select * from test; +-------+ | name | +-------+ | hello | | Hello | +-------+ 2 rows in set (0.00 sec) mysql\u0026gt; select * from test where name = \u0026#39;hello\u0026#39;; +-------+ | name | +-------+ | hello | | Hello | +-------+ 修改为utf8\nvim /etc/my.cnf #新增 [server] character_set_server=utf8 collation_server=utf8_general_ci #重启并查看 systemctl restart mysqld; mysql\u0026gt; SHOW VARIABLES LIKE \u0026#39;character_set_server\u0026#39;; +----------------------+-------+ | Variable_name | Value | +----------------------+-------+ | character_set_server | utf8 | +----------------------+-------+ 1 row in set (0.00 sec) mysql\u0026gt; SHOW VARIABLES LIKE \u0026#39;character_set_server\u0026#39;;^C mysql\u0026gt; SHOW VARIABLES LIKE \u0026#39;collation_server\u0026#39;; +------------------+-----------------+ | Variable_name | Value | +------------------+-----------------+ | collation_server | utf8_general_ci | +------------------+-----------------+ 1 row in set (0.00 sec) 数据库级别 # #创建数据库并指定字符集及比较规则 #如果不设置,则使用上级(服务器级别)的字符集和比较规则作为数据库的字符集和比较规则 CREATE DATABASE db_test CHARACTER SET gb2312 COLLATE gb2312_chinese_ci; #此时切换到db_test数据库 #再查看,发现变了 mysql\u0026gt; use db_test; Database changed mysql\u0026gt; SHOW VARIABLES LIKE \u0026#39;character_set_database\u0026#39;; +------------------------+--------+ | Variable_name | Value | +------------------------+--------+ | character_set_database | gb2312 | +------------------------+--------+ 1 row in set (0.00 sec) mysql\u0026gt; SHOW VARIABLES LIKE \u0026#39;collation_database\u0026#39;; +--------------------+-------------------+ | Variable_name | Value | +--------------------+-------------------+ | collation_database | gb2312_chinese_ci | +--------------------+-------------------+ 1 row in set (0.00 sec) 表级别 # mysql\u0026gt; CREATE TABLE t(col VARCHAR(10)) CHARACTER SET utf8 COLLATE utf8_general_ci; Query OK, 0 rows affected (0.06 sec) mysql\u0026gt; SHOW CREATE TABLE t; +-------+------------------------------------------------------------------------------------------+ | Table | Create Table | +-------+------------------------------------------------------------------------------------------+ | t | CREATE TABLE `t` ( `col` varchar(10) DEFAULT NULL ) ENGINE=InnoDB DEFAULT CHARSET=utf8 | +-------+------------------------------------------------------------------------------------------+ 1 row in set (0.00 sec) 如果不指定,那么将继承所在数据库的字符集和比较规则\n列级别 # mysql\u0026gt; ALTER TABLE t MODIFY col VARCHAR(10) CHARACTER SET gbk COLLATE gbk_chinese_ci; ## 修改为字符集gbk和对应的排序规则,如果不指定,则使用表的字符集及表的排序规则 其他 # 如果仅修改字符集,不修改比较规则,则比较规则会设置为默认该字符集的比较规则;\n如果仅修改比较规则,则字符集会设置为该比较规则对应的字符集\n#查看当前服务器对应规则 mysql\u0026gt; SHOW VARIABLES LIKE \u0026#39;character_set_server\u0026#39;; +----------------------+-------+ | Variable_name | Value | +----------------------+-------+ | character_set_server | utf8 | +----------------------+-------+ 1 row in set (0.00 sec) mysql\u0026gt; SHOW VARIABLES LIKE \u0026#39;collation_server\u0026#39;; +------------------+-----------------+ | Variable_name | Value | +------------------+-----------------+ | collation_server | utf8_general_ci | +------------------+-----------------+ 1 row in set (0.00 sec) #只修改字符集 mysql\u0026gt; SET character_set_server = gb2312; Query OK, 0 rows affected (0.00 sec) mysql\u0026gt; SHOW VARIABLES LIKE \u0026#39;collation_server\u0026#39;; +------------------+-------------------+ | Variable_name | Value | +------------------+-------------------+ | collation_server | gb2312_chinese_ci | +------------------+-------------------+ 1 row in set (0.00 sec) #仅修改比较规则 mysql\u0026gt; SET collation_server = utf8_general_ci; Query OK, 0 rows affected (0.00 sec) mysql\u0026gt; SHOW VARIABLES LIKE \u0026#39;character_set_server\u0026#39;; +----------------------+-------+ | Variable_name | Value | +----------------------+-------+ | character_set_server | utf8 | +----------------------+-------+ 1 row in set (0.00 sec) 根据各个列的字符集和比较规则是什么,从而根据这个列的类型来确认每个列存储的实际数据所占用的存储空间大小\n#例子 mysql\u0026gt; describe t; +-------+-------------+------+-----+---------+-------+ | Field | Type | Null | Key | Default | Extra | +-------+-------------+------+-----+---------+-------+ | col | varchar(10) | YES | | NULL | | +-------+-------------+------+-----+---------+-------+ 1 row in set (0.01 sec) mysql\u0026gt; INSERT INTO t(col) VALUES(\u0026#39;我我\u0026#39;); Query OK, 1 row affected (0.01 sec) ## 如果列col使用的字符集是gbk,则每个字符占用2字节,两个字符占用4字节;如果列col使用字符集为utf8,则两个字符实际占用的存储空间为6字节 客户端和服务端通信过程中使用的字符集 # 编码和解码使用的字符集不一样 # 字符集转换的概念 # “我\u0026quot; utf8\u0026ndash;\u0026gt; 编码成0xE68891\n接收到0xE68891后,对它解码,然后又按照GBK字符集编码,编码后是0xCED2。过程称为**”字符集的转换“**\nMySQL中字符集转换过程 # 从用户角度,客户发送请求和服务器返回响应都是字符串;从机器角度,客户端发送的请求和服务端返回的响应本质上就是一个字节序列,这个过程经历了多次的字符集转换\n客户端发送请求 # #查看当前系统使用的字符集 #linux #以第一个优先,没有依次往下取 ▶ echo $LC_ALL root@centos7101:~ ▶ echo $LC_CTYPE root@centos7101:~ ▶ echo $LANG zh_CN.UTF-8 #window C:\\Users\\ly\u0026gt;chcp 活动代码页: 936 -\u0026gt; GBK windows中,使用mysql客户端时,可以使用mysql --default-character-set=utf8,客户端将以UTF-8字符集对请求的字符串进行编码\n服务端接收请求 # 服务端接收到的应该是一个字节序列,是系统变量character_set_client代表的字符集进行编码后的字节序列\n每个客户端与服务端建立连接后,服务器会为该客户端维护一个独立的character_set_client变量,是SESSION级别的 客户端在编码请求字符串时实际使用的字符集,与服务器在收到一个字节序列后认为该序列采用的编码字符集,是两个独立的字符集。要尽量保证这两个字符集是一致的 例子:如果客户端用的UTF8编码\u0026quot;我\u0026quot;为0xE68891,并发给服务端,而服务端的character_set_client=latin1,则服务端会理解为\n如果character_set_client对应的字符集不能解释请求的字节序列,那么服务器发生警告\nmysql\u0026gt; SET character_set_client =ascii; Query OK, 0 rows affected (0.00 sec) mysql\u0026gt; select \u0026#39;我\u0026#39;; #utf8将它编码成了0xE68891发给服务端,而服务端以ASCII字符集解码 +-----+ | ??? | +-----+ | ??? | +-----+ 1 row in set, 1 warning (0.00 sec) mysql\u0026gt; SHOW WARNINGS\\G *************************** 1. row *************************** Level: Warning Code: 1300 Message: Invalid ascii character string: \u0026#39;\\xE6\\x88\\x91\u0026#39; 1 row in set (0.00 sec) 服务器处理请求 # 服务端会将请求的字节序列当作采用character_set_client对应的字符集进行编码,不过真正处理请求时会将其转换为SESSION级别的系统变量character_set_connection对应的字符集进行编码的字符序列\n假设character_set_client=utf8,character_set_connection=gbk,则对于\u0026quot;我\u0026quot;\u0026ndash;\u0026gt;0xE68891\u0026ndash;\u0026gt;0xCED2\n情形1 # 例子: SELECT 'a' = 'A'\n#默认情况 mysql\u0026gt; SHOW VARIABLES LIKE \u0026#39;character_connection\u0026#39;; Empty set (0.00 sec) mysql\u0026gt; SHOW VARIABLES LIKE \u0026#39;collation_connection\u0026#39;; +----------------------+-----------------+ | Variable_name | Value | +----------------------+-----------------+ | collation_connection | utf8_general_ci | +----------------------+-----------------+ 1 row in set (0.00 sec) mysql\u0026gt; SELECT \u0026#39;A\u0026#39;=\u0026#39;a\u0026#39;; +---------+ | \u0026#39;A\u0026#39;=\u0026#39;a\u0026#39; | +---------+ | 1 | +---------+ 1 row in set (0.00 sec) 其他情况:\nmysql\u0026gt; SET character_set_connection = gbk; Query OK, 0 rows affected (0.00 sec) mysql\u0026gt; SET collation_connection=gbk_chinese_ci; Query OK, 0 rows affected (0.00 sec) mysql\u0026gt; SELECT \u0026#39;a\u0026#39;=\u0026#39;A\u0026#39;; +---------+ | \u0026#39;a\u0026#39;=\u0026#39;A\u0026#39; | +---------+ | 1 | +---------+ 1 row in set (0.00 sec) 另一种情况:\nmysql\u0026gt; SET character_set_connection = gbk; Query OK, 0 rows affected (0.00 sec) mysql\u0026gt; SET collation_connection=gbk_bin; Query OK, 0 rows affected (0.00 sec) mysql\u0026gt; SELECT \u0026#39;a\u0026#39;=\u0026#39;A\u0026#39;; +---------+ | \u0026#39;a\u0026#39;=\u0026#39;A\u0026#39; | +---------+ | 0 | +---------+ 1 row in set (0.00 sec) gbk_bin 简体中文, 二进制 gbk_chinese_ci 简体中文, 不区分大小写\n情形2 # #创建一个表 CREATE TABLE tt(c VARCHAR(100)) ENGINE=INNODB CHARSET=utf8; #先改回来 mysql\u0026gt; SET character_set_connection = utf8; Query OK, 0 rows affected (0.00 sec) mysql\u0026gt; SHOW VARIABLES LIKE \u0026#39;collation_connection\u0026#39;; +----------------------+-----------------+ | Variable_name | Value | +----------------------+-----------------+ | collation_connection | utf8_general_ci | +----------------------+-----------------+ 1 row in set (0.00 sec) #插入一条记录 INSERT INTO tt(c) VALUES(\u0026#39;我\u0026#39;); 当前数据库、表、列使用的是utf8,现在把collection改为gbk:\nmysql\u0026gt; SET character_set_connection = gbk; Query OK, 0 rows affected (0.00 sec) mysql\u0026gt; SET collation_connection=gbk_chinese_ci; Query OK, 0 rows affected (0.00 sec) mysql\u0026gt; SELECT * FROM tt WHERE c=\u0026#39;我\u0026#39;; +------+ | c | +------+ | 我 | +------+ 1 row in set (0.00 sec) 此时SELECT * FROM tt WHERE c='我';中,\u0026lsquo;我\u0026rsquo;是使用gbk字符集进行编码的,比较规则是gbk_chinese_ci;而列c使用utf8字符集编码,比较规则为utf8_general_ci。这种情况下列的字符集和排序规则的优先级更高,会将gbk字符集转换成utf8字符集,然后使用列c的比较规则utf8_general_ci进行比较\n服务器生成响应 # 继续以最近的上面例子为例SELECT * FROM tt,是否是直接将0xE68891读出来发送到客户端呢?不是的,取决于SESSION级别的系统变量character_set_result的值\nSET character_set_results=gbk; 服务器会将字符串\u0026rsquo;我\u0026rsquo;从utf8字符集编码的0xE68891转换为character_set_results系统变量对应的字符集编码后的字节序列,再发给客户端\n此时传给客户端的响应中,字符串\u0026rsquo;我\u0026rsquo;对应的就是字节序列0xCED2(\u0026lsquo;我\u0026rsquo;的gbk编码字节序列)\n总结 # 每个客户端在服务器建立连接后,服务器都会为这个连接维护这3个变量\n每个MySQL客户端都维护着一个客户端默认字符集,客户端在启动时会自动检测所在系统(是客户端所在系统)当前使用的字符集,并按照一定规则映射成(最接近)MySQL支持的字符集,然后将该字符集作为客户端默认的字符集。\n如果启动MySQL客户端时设置了default-character-set 则忽略操作系统当前使用的字符集,直接将default-character-set启动选项中指定的值作为客户端默认字符集\n连接服务器时,客户端将默认的字符集信息与用户密码等发送给服务端,服务端在收到后会将character_set_client、character_set_connection、character_set_results这3个系统变量的值初始化为客户端的默认字符集\n客户端连接到服务器之后,可以使用SET分别修改character_set_client、character_set_connection、character_set_results这3个系统变量的值(或者使用SET NAMES charset_name一次性修改)\n不会改变客户端在编码请求字符串时使用的字符集,也不会修改客户端的默认字符集\n客户端接收到请求 # 客户端收到的响应其实也是一个字节序列\n对于类UNIX系统,收到的字节序列相当于写到黑框框中,再由黑框框将这个字节序列解释为人类能看懂的字符(用操作系统当前使用的字符集来解释);对于Windows,客户端使用客户端的默认字符集来解释\n也就是说,如果在linux下指定的default-character-set和系统不一致,就会导致乱码\n整个过程,五件事:\n客户端发送的请求字节序列是采用哪种字符集进行编码的 [由客户端启动选项\u0026ndash;default-character-set/其次是系统默认的(linux可能是utf-8/windows可能是gbk)] 服务端接收到请求字节序列后会认为它是采用哪种字符集进行编码的[由character_set_client决定,而character_set_client[还有character_set_connection、character_set_results这2个系统变量]的值初始化为客户端的默认字符集,也就是上面1中的] 服务器在运行过程中会把请求的字符序列转换为以哪种字符集编码的字节序列[character_set_connection] 服务器在向客户端返回字节序列时,是采用哪种字符集进行编码的[character_set_results] 上面的character_set_clien、character_set_connection、character_set_results这3个系统变量]的值在客户端连接到服务端之后,都是可以修改的,且都是SESSION级别的 客户端在接收到响应字节序列后,是怎么把他们写到黑框框中的[windows中由default-character-set/其次是系统默认] 比较规则的应用 # 通常用来比较大小和排序\n例子:\nmysql\u0026gt; CREATE TABLE t(col VARCHAR(100)) ENGINE=INNODB CHARSET=gbk; Query OK, 0 rows affected (0.02 sec) mysql\u0026gt; INSERT INTO t(col) VALUES(\u0026#39;a\u0026#39;),(\u0026#39;b\u0026#39;),(\u0026#39;A\u0026#39;),(\u0026#39;B\u0026#39;); Query OK, 4 rows affected (0.01 sec) Records: 4 Duplicates: 0 Warnings: 0 mysql\u0026gt; SELECT * FROM t ORDER BY col; +------+ | col | +------+ | a | | A | | b | | B | | 我 | +------+ 5 rows in set (0.00 sec) # 如上,排序规则gbk_chinese_ci是不区分大小写的 # 修改为gbk_bin; mysql\u0026gt; ALTER TABLE t MODIFY col VARCHAR(10) COLLATE gbk_bin; Query OK, 5 rows affected (0.06 sec) Records: 5 Duplicates: 0 Warnings: 0 mysql\u0026gt; SELECT * FROM t ORDER BY col; +------+ | col | +------+ | A | | B | | a | | b | | 我 | +------+ 5 rows in set (0.00 sec) 解释:\n列col各个字符在使用gbk字符集编码后对应的数字如下:\n\u0026lsquo;A\u0026rsquo; -\u0026gt; 65\n\u0026lsquo;B\u0026rsquo;-\u0026gt;66\n\u0026lsquo;a\u0026rsquo;-\u0026gt;97\n\u0026lsquo;b\u0026rsquo;-\u0026gt;98\n\u0026lsquo;我\u0026rsquo;-\u0026gt;52946\n"},{"id":232,"href":"/zh/docs/technology/MySQL/_how_mysql_run_/02/","title":"02启动选项和系统变量","section":"_MySQL是怎样运行的_","content":" 学习《MySQL是怎样运行的》,感谢作者!\n启动选项和配置文件 # 在程序启动时指定的设置项,也称之为启动选项startup option(可以在命令行中/配置文件中 指定)\n由于在centos7中使用systemctl start mysqld启动mysql,所以好像没法用命令行指定启动选项了\n程序(可能有些程序新版本已经没有了)的对应类别和能读取的组:\n这里讲配置文件的方式设置启动选项:\n#添加配置 vim /etc/my.cnf [server] skip-networking #禁止tcp网络连接 default-storage-engine=MyISAM #建表默认使用M有ISAM存储引擎 #效果 ▶ mysql -h127.0.0.1 -uroot -p Enter password: ERROR 2003 (HY000): Can\u0026#39;t connect to MySQL server on \u0026#39;127.0.0.1\u0026#39; (111) #去除tcp网络连接限制后新建一个表 ▶ mysql -h127.0.0.1 -uroot -p #可以连接上 mysql\u0026gt; create table default_storage_engine_demo(i int); Query OK, 0 rows affected (0.01 sec) mysql\u0026gt; show create table default_storage_engine_demo; +-----------------------------+----------------------------------------------------------------------------------------------------------------+ | Table | Create Table | +-----------------------------+----------------------------------------------------------------------------------------------------------------+ | default_storage_engine_demo | CREATE TABLE `default_storage_engine_demo` ( `i` int(11) DEFAULT NULL ) ENGINE=MyISAM DEFAULT CHARSET=latin1 | 如果多个配置文件都配置了某个选项,如/etc/my.cnf /etc/mysql/my.cnf /usr/etc/my.cnf ~/.my.cnf都配置了,则以最后一个配置的为主\n如果同一个配置文件,比如[server]组和[mysqld]组都出现了default-storage-engine配置,则以后出现的组中的配置为准\n如果一个启动选项既在命令行中出现,又在配置文件中配置,则以命令行中的为准\n系统变量 # 查看系统变量\nmysql\u0026gt; SHOW VARIABLES LIKE \u0026#39;default_storage_engine\u0026#39;; +------------------------+--------+ | Variable_name | Value | +------------------------+--------+ | default_storage_engine | InnoDB | +------------------------+--------+ 1 row in set (0.00 sec) mysql\u0026gt; SHOW VARIABLES LIKE \u0026#39;default%\u0026#39;; +-------------------------------+-----------------------+ | Variable_name | Value | +-------------------------------+-----------------------+ | default_authentication_plugin | mysql_native_password | | default_password_lifetime | 0 | | default_storage_engine | InnoDB | | default_tmp_storage_engine | InnoDB | | default_week_format | 0 | +-------------------------------+-----------------------+ 5 rows in set (0.00 sec) mysql\u0026gt; SHOW VARIABLES LIKE \u0026#39;max_connections\u0026#39;; +-----------------+-------+ | Variable_name | Value | +-----------------+-------+ | max_connections | 151 | +-----------------+-------+ 1 row in set (0.00 sec) 大部分系统变量,可以在服务器程序运行过程中动态修改而无须停止并重启服务器\n不同作用范围的系统变量\nGLOBAL(全局范围):影响服务器的整体操作\nSESSION(会话范围):影响某个客户端连接的操作\n让之后新连接到服务器的客户端都用MyISAM作为默认的存储引擎\n#不会对这之前已连接的客户端产生影响 SET GLOBAL default_storage_engine=MyISAM; #systemctl restart mysqld时候,该配置就失效了 只对本客户端使用\nSET SESSION default_storage_engine=MyISAM;#或 SET default_storage_engine=MyISAM; 查看系统变量\nSHOW [GLOBAL|SESSION] VARIABLES [LIKE \u0026#39;匹配的模式\u0026#39;]; 状态变量 # 状态变量用来显示服务器程序运行状态\n状态变量也分GLOBAL|SESSION\nSHOW [GLOBAL|SESSION] STATUS [LIKE 匹配的模式];\nmysql\u0026gt; SHOW STATUS LIKE \u0026#39;thread%\u0026#39;; +-------------------+-------+ | Variable_name | Value | +-------------------+-------+ | Threads_cached | 0 | | Threads_connected | 2 | | Threads_created | 2 | | Threads_running | 1 | +-------------------+-------+ "},{"id":233,"href":"/zh/docs/technology/MySQL/_how_mysql_run_/01/","title":"01初识MySQL","section":"_MySQL是怎样运行的_","content":" 学习《MySQL是怎样运行的》,感谢作者!\n原文 # 下载与安装 # 环境Centos7\n添加MySQL5.7仓库\nsudo rpm -ivh https://dev.mysql.com/get/mysql57-community-release-el7-11.noarch.rpm 解决证书问题\nrpm --import https://repo.mysql.com/RPM-GPG-KEY-mysql-2022 查看是否添加成功\nsudo yum repolist all | grep mysql | grep 启用 mysql-connectors-community/x86_64 MySQL Connectors Community 启用: 213 mysql-tools-community/x86_64 MySQL Tools Community 启用: 96 mysql57-community/x86_64 MySQL 5.7 Community Server 启用: 642 MySQL安装\nsudo yum -y install mysql-community-server 运行与密码修改 # Centos7中安装目录查看,在/usr/bin中,与Max有所不同\nwhereis mysql mysql: /usr/bin/mysql /usr/lib64/mysql /usr/share/mysql /usr/share/man/man1/mysql.1.gz ls /usr/bin |grep mysql mysql mysqladmin mysqlbinlog mysqlcheck mysql_config_editor mysqld_pre_systemd mysqldump mysqldumpslow mysqlimport mysql_install_db mysql_plugin mysqlpump mysql_secure_installation mysqlshow mysqlslap mysql_ssl_rsa_setup mysql_tzinfo_to_sql mysql_upgrade 添加mysqld目录到环境变量中(这里可省略,因为mysqld默认在/usr/bin中了\n启动MySQL(和书上说的启动方式有点不一样,查资料得知,从5.7.6起,不再支持mysql_safe的启动方式)\n# 启动MySQL root@centos7101:~ ▶ systemctl start mysqld # 查看MySQL状态 root@centos7101:~ ▶ systemctl status mysqld ● mysqld.service - MySQL Server Loaded: loaded (/usr/lib/systemd/system/mysqld.service; enabled; vendor preset: disabled) Active: active (running) since 一 2023-04-17 11:43:42 CST; 19s ago Docs: man:mysqld(8) http://dev.mysql.com/doc/refman/en/using-systemd.html Main PID: 2182 (mysqld) CGroup: /system.slice/mysqld.service └─2182 /usr/sbin/mysqld --daemonize --pid-file=/var/run/mysqld/mysqld.pid 4月 17 11:43:37 centos7101 systemd[1]: Starting MySQL Server... 4月 17 11:43:42 centos7101 systemd[1]: Started MySQL Server. # 设置为开机启动 root@centos7101:~ ▶ systemctl enable mysqld 查看MySQL默认密码\ncat /var/log/mysqld.log |grep -i \u0026#39;temporary password\u0026#39; 2023-04-17T03:43:38.995935Z 1 [Note] A temporary password is generated for root@localhost: ampddi9+fpyQ 连接\nmysql -uroot -p123456 #或者 mysql -uroot -p #或者 mysql -hlocalhost -uroot -p123456 为了方便起见,修改密码为123456\n# 修改密码强度 set global validate_password_policy=LOW; #修改密码长度 set global validate_password_length=6; #修改密码 ALTER USER \u0026#39;root\u0026#39;@\u0026#39;localhost\u0026#39; IDENTIFIED BY \u0026#39;123456\u0026#39;; #刷新权限 flush privileges; 退出\nquit #或者 exit #或者 \\q 客户端与服务端连接过程 # 采用TCP作为服务端和客户端之间的网络通信协议\n远程连接前提\n#添加一个远程用户 CREATE USER \u0026#39;root\u0026#39;@\u0026#39;%\u0026#39; IDENTIFIED BY \u0026#39;123456.\u0026#39;; grant all on *.* to \u0026#39;root\u0026#39;@\u0026#39;%\u0026#39; identified by \u0026#34;123456.\u0026#34; with grant option; #修改用户密码 SET PASSWORD FOR \u0026#39;root\u0026#39;@\u0026#39;host\u0026#39; = password(\u0026#39;123456.\u0026#39;); 端口号修改与远程连接\n#修改MySQL启动的端口 vim /etc/my.cnf [mysqld] port=33062 #新增该行即可 #重启 systemctl restart mysqld #查看状态 systemctl status mysqld #查看服务是否启动 netstat -lntup |grep mysql tcp6 0 0 :::33062 :::* LISTEN 4612/mysqld #远程连接 mysql -hnode2 -uroot -P33062 -p 处理客户端请求\n常用存储引擎:Innodb和MyISAM\n查看当前服务器支持的存储引擎\n只有InnoDB是支持事务的且支持分布式事务、部分回滚\n存储引擎是负责对表中数据进行读取和写入的\n-- 创建表时指定存储引擎 CREATE TABLE engine_demo_table(i int) ENGINE = MyISAM -- 查看建表语句 mysql\u0026gt; SHOW CREATE TABLE engine_demo_table \\G *************************** 1. row *************************** Table: engine_demo_table Create Table: CREATE TABLE `engine_demo_table` ( `i` int(11) DEFAULT NULL ) ENGINE=MyISAM DEFAULT CHARSET=latin1 1 row in set (0.00 sec) -- 修改建表时指定的存储引擎 ALTER TABLE engine_demo_table ENGINE=InnoDB -- 修改编码 ALTER TABLE engine_demo_table CHARSET=UTF8 "},{"id":234,"href":"/zh/docs/technology/Redis/redis-cluster/","title":"redis集群搭建","section":"Redis","content":" 转载自https://www.cnblogs.com/Yunya-Cnblogs/p/14608937.html(添加小部分笔记)感谢作者!\n部分参考自 https://www.cnblogs.com/ysocean/p/12328088.html\n基本准备 # 架构 # 采用Centos7,Redis版本为6.2,架构如下:\nhosts修改 # vim /etc/hosts #添加 192.168.1.101 node1 192.168.1.102 node2 192.168.1.103 node3 集群准备 # 对每个节点 # 下载redis并解压到 /usr/local/redis-cluster中\ncd /usr/local mkdir redis-cluster tar -zxvf redis* -C /usr/local/redis* 进入redis根目录\nmake make install 安装完毕\nhosts修改\nvim /etc/hosts #添加 192.168.1.101 node1 192.168.1.102 node2 192.168.1.103 node3 配置文件修改(6个节点中的每一个) # 创建多级目录\nmkdir -p /usr/local/redis_cluster/redis_63{79,80}/{conf,pid,logs} 编写配置文件\nvim /usr/local/redis_cluster/redis_6379/conf/redis.conf # 命令行状态下输入 :%d 回车,清空文件 # 再输入 :set paste 处理多出的行带#的问题 # 再输入i ####内容##### # 快速修改::%s/6379/6380/g # 守护进行模式启动 daemonize yes # 设置数据库数量,默认数据库为0 databases 16 # 绑定地址,需要修改 # bind 192.168.1.101 bind node1 # 绑定端口,需要修改 port 6379 # pid文件存储位置,文件名需要修改 pidfile /usr/local/redis_cluster/redis_6379/pid/redis_6379.pid # log文件存储位置,文件名需要修改 logfile /usr/local/redis_cluster/redis_6379/logs/redis_6379.log # RDB快照备份文件名,文件名需要修改 dbfilename redis_6379.rdb # 本地数据库存储目录,需要修改 dir /usr/local/redis_cluster/redis_6379 # 集群相关配置 # 是否以集群模式启动 cluster-enabled yes # 集群节点回应最长时间,超过该时间被认为下线 cluster-node-timeout 15000 # 生成的集群节点配置文件名,文件名需要修改 cluster-config-file nodes_6379.conf 复制粘贴配置文件\ncp /usr/local/redis_cluster/redis_6379/conf/redis.conf /usr/local/redis_cluster/redis_6380/conf/redis.conf vim /usr/local/redis_cluster/redis_6380/conf/redis.conf #命令行模式下 :%s/6379/6380/g 查看文件夹当前情况\n运行 # 查看端口是否运行\nnetstat -lntup | grep 6379 运行\nredis-server /usr/local/redis_cluster/redis_6379/conf/redis.conf \u0026amp; redis-server /usr/local/redis_cluster/redis_6380/conf/redis.conf \u0026amp; 结果\nnetstat -lntup |grep 6379 tcp 0 0 192.168.1.101:6379 0.0.0.0:* LISTEN 6538/redis-server 1 tcp 0 0 192.168.1.101:16379 0.0.0.0:* LISTEN 6538/redis-server 1 #+10000端口出现,说明集群各个节点之间可以互相通信 结果\ncat *6379/pid/*pid* 6538 ##就是上面的进程id cat *6379/logs/*log 6538:C 14 Apr 2023 16:37:04.893 # oO0OoO0OoO0Oo Redis is starting oO0OoO0OoO0Oo 6538:C 14 Apr 2023 16:37:04.893 # Redis version=6.2.6, bits=64, commit=00000000, modified=0, pid=6538, just started 6538:C 14 Apr 2023 16:37:04.893 # Configuration loaded 6538:M 14 Apr 2023 16:37:04.895 * Increased maximum number of open files to 10032 (it was originally set to 1024). 6538:M 14 Apr 2023 16:37:04.895 * monotonic clock: POSIX clock_gettime 6538:M 14 Apr 2023 16:37:04.898 * No cluster configuration found, I\u0026#39;m e13c04818944108ee3b0690d836466b4c0eb69fd 6538:M 14 Apr 2023 16:37:04.929 * Running mode=cluster, port=6379. 6538:M 14 Apr 2023 16:37:04.929 # WARNING: The TCP backlog setting of 511 cannot be enforced because /proc/sys/net/core/somaxconn is set to the lower value of 128. 6538:M 14 Apr 2023 16:37:04.929 # Server initialized 6538:M 14 Apr 2023 16:37:04.929 # WARNING overcommit_memory is set to 0! Background save may fail under low memory condition. To fix this issue add \u0026#39;vm.overcommit_memory = 1\u0026#39; to /etc/sysctl.conf and then reboot or run the command \u0026#39;sysctl vm.overcommit_memory=1\u0026#39; for this to take effect. 6538:M 14 Apr 2023 16:37:04.930 * Ready to accept connections 集群节点配置文件,会发现生成了一组集群信息\n# 第一段信息是这个Redis服务作为集群节点的一个身份编码 # 别名为集群的node-id\ncat *6379/*nodes*conf e13c04818944108ee3b0690d836466b4c0eb69fd :0@0 myself,master - 0 0 0 connected vars currentEpoch 0 lastVoteEpoch 0 ## 当后续所有节点都连接上时,内容会变成: ls conf logs nodes_6379.conf pid redis_6379.rdb root@centos7101:local/redis_cluster/redis_6379 cat nodes_6379.conf f6cf3978d3397582c87480f8c335297675d4354a 192.168.1.103:6380@16380 master - 1681470597368 1681470597337 6 connected 0-5461 f1151c2350820b35e117d3c32b59b64917688745 192.168.1.103:6379@16379 master - 1681470597369 1681470597337 5 connected 10923-16383 83bfb30e39e3040397d995a7b1f560e8fb53c6a9 192.168.1.102:6380@16380 slave f1151c2350820b35e117d3c32b59b64917688745 1681470597369 1681470597337 5 connected 4e9452afe7a8f53dc546b0436109bef570e03888 192.168.1.101:6380@16380 slave 95b2dcd681674398d22817728af08c31d4bd4872 1681470597370 1681470597337 4 connected 95b2dcd681674398d22817728af08c31d4bd4872 192.168.1.102:6379@16379 master - 1681470597370 1681470597337 4 connected 5462-10922 fd6acb4af8afa5ddd31cf559ee2c80ffcbea456f 192.168.1.101:6379@16379 myself,slave f6cf3978d3397582c87480f8c335297675d4354a 0 1681470597337 6 connected vars currentEpoch 6 lastVoteEpoch 0 集群搭建 # 手动搭建集群 # 加入集群 # 在node1:6379 查看当前cluster\nredis-cli -h node1 -p 6379 node1:6379\u0026gt; cluster nodes e13c04818944108ee3b0690d836466b4c0eb69fd :6379@16379 myself,master - 0 0 0 connected node1:6379\u0026gt; cluster meet 192.168.1.102 6379 OK node1:6379\u0026gt; cluster nodes e13c04818944108ee3b0690d836466b4c0eb69fd 192.168.1.101:6379@16379 myself,master - 0 0 1 connected fbe66448ee1baefa6e9fbd55e778c1d09054b59a 192.168.1.102:6379@16379 master - 0 1681464479300 0 connected node1:6379\u0026gt; 此时在node2:6379查看当前cluster\nredis-cli -h node2 -p 6379 node2:6379\u0026gt; cluster nodes fbe66448ee1baefa6e9fbd55e778c1d09054b59a 192.168.1.102:6379@16379 myself,master - 0 0 0 connected e13c04818944108ee3b0690d836466b4c0eb69fd 192.168.1.101:6379@16379 master - 0 1681464547007 1 connected 切回node1:6379,将剩下的节点meet上\nnode1:6379\u0026gt; cluster meet 192.168.1.103 6379 OK node1:6379\u0026gt; cluster meet 192.168.1.101 6380 OK node1:6379\u0026gt; cluster meet 192.168.1.102 6380 OK node1:6379\u0026gt; cluster meet 192.168.1.103 6380 OK node1:6379\u0026gt; clear node1:6379\u0026gt; cluster nodes 84384230f256fae73ab5bbaf34b0479b67602d6e 192.168.1.102:6380@16380 master - 0 1681464635860 4 connected e13c04818944108ee3b0690d836466b4c0eb69fd 192.168.1.101:6379@16379 myself,master - 0 1681464633000 1 connected fbe66448ee1baefa6e9fbd55e778c1d09054b59a 192.168.1.102:6379@16379 master - 0 1681464636894 0 connected 43cdb0cd626a0341cf0c9fa31832735c5341a89b 192.168.1.103:6380@16380 master - 0 1681464635000 5 connected a20b6da956145cfa06ed55159456de8259d9f246 192.168.1.103:6379@16379 master - 0 1681464637923 2 connected 4aeeaa0d87b91712576c6e995b355fe4a87b24e0 192.168.1.101:6380@16380 master - 0 1681464637000 3 connected 主从配置 # 上面发现的node-id\nhostname 节点 node-id node1 192.168.1.101:6379 e13c04818944108ee3b0690d836466b4c0eb69fd node2 192.168.1.102:6379 fbe66448ee1baefa6e9fbd55e778c1d09054b59a node3 192.168.1.103:6379 a20b6da956145cfa06ed55159456de8259d9f246 主从配置\n#node1:6380-\u0026gt;node2:6379 node1:6380\u0026gt; cluster replicate 95b2dcd681674398d22817728af08c31d4bd4872 OK #node2:6380-\u0026gt;node3:6379 node2:6380\u0026gt; cluster replicate f1151c2350820b35e117d3c32b59b64917688745 OK #node3:6380-\u0026gt;node1:6379 node3:6380\u0026gt; cluster replicate fd6acb4af8afa5ddd31cf559ee2c80ffcbea456f OK 再一次查看节点信息,出现了master,slave\nnode3:6380\u0026gt; cluster nodes 4aeeaa0d87b91712576c6e995b355fe4a87b24e0 192.168.1.101:6380@16380 slave fbe66448ee1baefa6e9fbd55e778c1d09054b59a 0 1681465221000 0 connected 43cdb0cd626a0341cf0c9fa31832735c5341a89b 192.168.1.103:6380@16380 myself,slave e13c04818944108ee3b0690d836466b4c0eb69fd 0 1681465222000 1 connected fbe66448ee1baefa6e9fbd55e778c1d09054b59a 192.168.1.102:6379@16379 master - 0 1681465223000 0 connected e13c04818944108ee3b0690d836466b4c0eb69fd 192.168.1.101:6379@16379 master - 0 1681465222000 1 connected a20b6da956145cfa06ed55159456de8259d9f246 192.168.1.103:6379@16379 master - 0 1681465221814 2 connected 84384230f256fae73ab5bbaf34b0479b67602d6e 192.168.1.102:6380@16380 slave a20b6da956145cfa06ed55159456de8259d9f246 0 1681465223880 2 connected 分配槽位 # 只对主库分配,从库不进行分配\nexpr 16384 / 3 5461\n下面平均分配到3个master中,其中:\n节点 槽位数量 node1:6379 0 - 5461 【多分配了一个】 node2:6379 5461 - 10922 node3:6379 10922 - 16383 redis-cli -h node1 -p 6379 cluster addslots {0..5461} redis-cli -h node2 -p 6379 cluster addslots {5462..10922} redis-cli -h node3 -p 6379 cluster addslots {10923..16383} redis-cli -h node3 -p 6379 node1:6379\u0026gt; cluster nodes 84384230f256fae73ab5bbaf34b0479b67602d6e 192.168.1.102:6380@16380 slave a20b6da956145cfa06ed55159456de8259d9f246 0 1681467951000 2 connected e13c04818944108ee3b0690d836466b4c0eb69fd 192.168.1.101:6379@16379 myself,master - 0 1681467948000 1 connected 0-5461 fbe66448ee1baefa6e9fbd55e778c1d09054b59a 192.168.1.102:6379@16379 master - 0 1681467949000 0 connected 5462-10922 43cdb0cd626a0341cf0c9fa31832735c5341a89b 192.168.1.103:6380@16380 slave e13c04818944108ee3b0690d836466b4c0eb69fd 0 1681467949690 1 connected a20b6da956145cfa06ed55159456de8259d9f246 192.168.1.103:6379@16379 master - 0 1681467947626 2 connected 10923-16383 4aeeaa0d87b91712576c6e995b355fe4a87b24e0 192.168.1.101:6380@16380 slave fbe66448ee1baefa6e9fbd55e778c1d09054b59a 0 1681467951745 0 connected 检查集群状态是否OK\nnode1:6379\u0026gt; cluster info cluster_state:ok cluster_slots_assigned:16384 cluster_slots_ok:16384 cluster_slots_pfail:0 cluster_slots_fail:0 cluster_known_nodes:6 cluster_size:3 cluster_current_epoch:5 cluster_my_epoch:1 cluster_stats_messages_ping_sent:3461 cluster_stats_messages_pong_sent:3530 cluster_stats_messages_meet_sent:5 cluster_stats_messages_sent:6996 cluster_stats_messages_ping_received:3530 cluster_stats_messages_pong_received:3466 cluster_stats_messages_received:6996 自动集群搭建 # 假设所有的节点都已经重置过,没有主从状态,也未加入任何集群。\nRedis5之前使用redis-trib.rb脚本搭建\nredis-trib.rb脚本使用ruby语言编写,所以想要运行次脚本,我们必须安装Ruby环境。安装命令如下:\nyum -y install centos-release-scl-rh yum -y install rh-ruby23 scl enable rh-ruby23 bash gem install redis 安装完成后,我们可以使用 ruby -v 查看版本信息。\nRuby环境安装完成后。运行如下命令:\nredis-trib.rb create --replicas 1 192.168.14.101:6379 192.168.14.102:6380 192.168.14.103:6381 192.168.14.101:6382 192.168.14.102:6383 192.168.14.103:6384\n前面我们就说过,redis5.0之后已经将redis-trib.rb 脚本的功能全部集成到redis-cli中了,所以我们直接使用如下命令即可:\nredis-cli -h node3 -p 6379 cluster reset hard\n此时所有节点都是master且已经在运行中\n此时运行\n# redis-cli -a ${password} --cluster create 192.168.1.101:6379 192.168.1.102:6379 192.168.1.103:6379 192.168.1.103:6380 192.168.1.101:6380 192.168.1.102:6380 --cluster-replicas 1 # 如果有密码,一般情况下集群下的所有节点使用同样的密码 redis-cli --cluster create 192.168.1.101:6379 192.168.1.102:6379 192.168.1.103:6379 192.168.1.103:6380 192.168.1.101:6380 192.168.1.102:6380 --cluster-replicas 1 \u0026gt;\u0026gt;\u0026gt; Performing hash slots allocation on 6 nodes... Master[0] -\u0026gt; Slots 0 - 5460 Master[1] -\u0026gt; Slots 5461 - 10922 Master[2] -\u0026gt; Slots 10923 - 16383 Adding replica 192.168.1.102:6380 to 192.168.1.101:6379 Adding replica 192.168.1.103:6380 to 192.168.1.102:6379 Adding replica 192.168.1.101:6380 to 192.168.1.103:6379 M: 24ea7569f0a433eb9706d991f21ae49ec21e48cf 192.168.1.101:6379 slots:[0-5460] (5461 slots) master M: 518fc32f556b10d4b8f83bda420d01aaeeb25f51 192.168.1.102:6379 slots:[5461-10922] (5462 slots) master M: c021bdbaf1c3a476616781c25dbc2b3042ed6f10 192.168.1.103:6379 slots:[10923-16383] (5461 slots) master S: 25e44d3ff2d94400b3c53d66993fc99332adffe4 192.168.1.103:6380 replicates 518fc32f556b10d4b8f83bda420d01aaeeb25f51 S: a6159c5dda95017ba5433f597ea4d18780868dfc 192.168.1.101:6380 replicates c021bdbaf1c3a476616781c25dbc2b3042ed6f10 S: a0e986c4cfb914f34efc8f6ea07cb9b72b615593 192.168.1.102:6380 replicates 24ea7569f0a433eb9706d991f21ae49ec21e48cf Can I set the above configuration? (type \u0026#39;yes\u0026#39; to accept): yes \u0026gt;\u0026gt;\u0026gt; Nodes configuration updated \u0026gt;\u0026gt;\u0026gt; Assign a different config epoch to each node \u0026gt;\u0026gt;\u0026gt; Sending CLUSTER MEET messages to join the cluster Waiting for the cluster to join . \u0026gt;\u0026gt;\u0026gt; Performing Cluster Check (using node 192.168.1.101:6379) M: 24ea7569f0a433eb9706d991f21ae49ec21e48cf 192.168.1.101:6379 slots:[0-5460] (5461 slots) master 1 additional replica(s) S: a0e986c4cfb914f34efc8f6ea07cb9b72b615593 192.168.1.102:6380 slots: (0 slots) slave replicates 24ea7569f0a433eb9706d991f21ae49ec21e48cf S: 25e44d3ff2d94400b3c53d66993fc99332adffe4 192.168.1.103:6380 slots: (0 slots) slave replicates 518fc32f556b10d4b8f83bda420d01aaeeb25f51 M: c021bdbaf1c3a476616781c25dbc2b3042ed6f10 192.168.1.103:6379 slots:[10923-16383] (5461 slots) master 1 additional replica(s) S: a6159c5dda95017ba5433f597ea4d18780868dfc 192.168.1.101:6380 slots: (0 slots) slave replicates c021bdbaf1c3a476616781c25dbc2b3042ed6f10 M: 518fc32f556b10d4b8f83bda420d01aaeeb25f51 192.168.1.102:6379 slots:[5461-10922] (5462 slots) master 1 additional replica(s) [OK] All nodes agree about slots configuration. \u0026gt;\u0026gt;\u0026gt; Check for open slots... \u0026gt;\u0026gt;\u0026gt; Check slots coverage... [OK] All 16384 slots covered. #所有槽位都分配成功 随便使用一个节点查询:\nredis-cli -h node1 -p 6380 cluster nodes 25e44d3ff2d94400b3c53d66993fc99332adffe4 192.168.1.103:6380@16380 slave 518fc32f556b10d4b8f83bda420d01aaeeb25f51 0 1681482908718 2 connected 518fc32f556b10d4b8f83bda420d01aaeeb25f51 192.168.1.102:6379@16379 master - 0 1681482906668 2 connected 5461-10922 24ea7569f0a433eb9706d991f21ae49ec21e48cf 192.168.1.101:6379@16379 master - 0 1681482908000 1 connected 0-5460 c021bdbaf1c3a476616781c25dbc2b3042ed6f10 192.168.1.103:6379@16379 master - 0 1681482907000 3 connected 10923-16383 a0e986c4cfb914f34efc8f6ea07cb9b72b615593 192.168.1.102:6380@16380 slave 24ea7569f0a433eb9706d991f21ae49ec21e48cf 0 1681482909743 1 connected a6159c5dda95017ba5433f597ea4d18780868dfc 192.168.1.101:6380@16380 myself,slave c021bdbaf1c3a476616781c25dbc2b3042ed6f10 0 1681482908000 3 connected 如上,槽位都已经平均分配完,且主从关系也配置好了\n弊端:通过该方式创建的带有从节点的机器不能够自己手动指定主节点,所以如果需要指定的话,需要自己手动指定\na: 先使用redis-cli --cluster create 192.168.163.132:6379 192.168.163.132:6380 192.168.163.132:6381\nb:或```redis-cli \u0026ndash;cluster add-node 192.168.163.132:6382 192.168.163.132:6379 说明:b:为一个指定集群添加节点,需要先连到该集群的任意一个节点IP(192.168.163.132:6379),再把新节点加入。该2个参数的顺序有要求:新加入的节点放前面 通过redis-cli --cluster add-node 192.168.163.132:6382 192.168.163.132:6379 --cluster-slave --cluster-master-id 117457eab5071954faab5e81c3170600d5192270来处理。 说明:把6382节点加入到6379节点的集群中,并且当做node_id为 117457eab5071954faab5e81c3170600d5192270 的从节点。如果不指定 \u0026ndash;cluster-master-id 会随机分配到任意一个主节点。 总结:也就是先创建主节点,再创建从节点就是了 MOVED重定向 # redis-cli -h node1 -p 6379 node1:6379\u0026gt; set k1 \u0026#34;v1\u0026#34; (error) MOVED 12706 192.168.1.103:6379 node1:6379\u0026gt; get k1 (error) MOVED 12706 192.168.1.103:6379 //上面没有设置成功,(连接时)使用下面的命令,Redis集群会自动进行MOVED重定向\nredis-cli -c -h node1 -p 6379 node1:6379\u0026gt; get k1 -\u0026gt; Redirected to slot [12706] located at 192.168.1.103:6379 (nil) 192.168.1.103:6379\u0026gt; set k1 \u0026#34;v1\u0026#34; OK 192.168.1.103:6379\u0026gt; get k1 \u0026#34;v1\u0026#34; #如上,会自动给你切换到slot对应的机器上 //在master3的slave3上查找数据\nredis-cli -h node2 -p 6380 -c node2:6380\u0026gt; keys * 1) \u0026#34;k1\u0026#34; node2:6380\u0026gt; get k1 -\u0026gt; Redirected to slot [12706] located at 192.168.1.103:6379 \u0026#34;v1\u0026#34; ## 1 只有master分配了槽位,所以会重定向到master3去取数据 ## 2 同一个槽位不能同时分配给2个节点 ## 3 在redis的官方文档中,对redis-cluster架构上,有这样的说明:在cluster架构下,默认的,一般redis-master用于接收读写,而redis-slave则用于备份,当有请求是在向slave发起时,会直接重定向到对应key所在的master来处理。但如果不介意读取的是redis-cluster中有可能过期的数据并且对写请求不感兴趣时,则亦可通过readonly命令,将slave设置成可读,然后通过slave获取相关的key,达到读写分离。 # readOnly设置 redis-cli -h node2 -p 6380 node2:6380\u0026gt; get k1 (error) MOVED 12706 192.168.1.103:6379 node2:6380\u0026gt; keys * 1) \u0026#34;k1\u0026#34; node2:6380\u0026gt; readonly OK node2:6380\u0026gt; keys * 1) \u0026#34;k1\u0026#34; node2:6380\u0026gt; get k1 \u0026#34;v1\u0026#34; ## 重置Readonly node2:6380\u0026gt; readwrite OK node2:6380\u0026gt; get k1 (error) MOVED 12706 192.168.1.103:6379 故障转移 # 关闭前\nnode2:6379\u0026gt; cluster nodes f6cf3978d3397582c87480f8c335297675d4354a 192.168.1.103:6380@16380 slave fd6acb4af8afa5ddd31cf559ee2c80ffcbea456f 0 1681470413429 0 connected fd6acb4af8afa5ddd31cf559ee2c80ffcbea456f 192.168.1.101:6379@16379 master - 0 1681470414459 0 connected 0-5461 95b2dcd681674398d22817728af08c31d4bd4872 192.168.1.102:6379@16379 myself,master - 0 1681470413000 4 connected 5462-10922 f1151c2350820b35e117d3c32b59b64917688745 192.168.1.103:6379@16379 master - 0 1681470412000 5 connected 10923-16383 83bfb30e39e3040397d995a7b1f560e8fb53c6a9 192.168.1.102:6380@16380 slave f1151c2350820b35e117d3c32b59b64917688745 0 1681470412397 5 connected 4e9452afe7a8f53dc546b0436109bef570e03888 192.168.1.101:6380@16380 slave 95b2dcd681674398d22817728af08c31d4bd4872 0 1681470411371 4 connected 关闭node1 master\nredis-cli -h node1 -p 6379 shutdown 如下,node3的slave变成了master\nredis-cli -h node1 -p 6380 node1:6380\u0026gt; cluster nodes f6cf3978d3397582c87480f8c335297675d4354a 192.168.1.103:6380@16380 master - 0 1681470479000 6 connected 0-5461 ###这里升级成了master,槽位也转移了 83bfb30e39e3040397d995a7b1f560e8fb53c6a9 192.168.1.102:6380@16380 slave f1151c2350820b35e117d3c32b59b64917688745 0 1681470479664 5 connected 4e9452afe7a8f53dc546b0436109bef570e03888 192.168.1.101:6380@16380 myself,slave 95b2dcd681674398d22817728af08c31d4bd4872 0 1681470479000 4 connected fd6acb4af8afa5ddd31cf559ee2c80ffcbea456f 192.168.1.101:6379@16379 master,fail - 1681470463058 1681470459947 0 disconnected 95b2dcd681674398d22817728af08c31d4bd4872 192.168.1.102:6379@16379 master - 0 1681470478616 4 connected 5462-10922 f1151c2350820b35e117d3c32b59b64917688745 192.168.1.103:6379@16379 master - 0 1681470480698 5 connected 10923-16383 此时将6379再次上线\nredis-server /usr/local/redis_cluster/redis_6379/conf/redis.conf ## 此时node1的6379变成了node3的6380的从库 node1:6379\u0026gt; cluster nodes f6cf3978d3397582c87480f8c335297675d4354a 192.168.1.103:6380@16380 master - 0 1681470625000 6 connected 0-5461 f1151c2350820b35e117d3c32b59b64917688745 192.168.1.103:6379@16379 master - 0 1681470628003 5 connected 10923-16383 83bfb30e39e3040397d995a7b1f560e8fb53c6a9 192.168.1.102:6380@16380 slave f1151c2350820b35e117d3c32b59b64917688745 0 1681470626979 5 connected 4e9452afe7a8f53dc546b0436109bef570e03888 192.168.1.101:6380@16380 slave 95b2dcd681674398d22817728af08c31d4bd4872 0 1681470627000 4 connected 95b2dcd681674398d22817728af08c31d4bd4872 192.168.1.102:6379@16379 master - 0 1681470627000 4 connected 5462-10922 fd6acb4af8afa5ddd31cf559ee2c80ffcbea456f 192.168.1.101:6379@16379 myself,slave f6cf3978d3397582c87480f8c335297675d4354a 0 1681470625000 6 connected 集群扩容 # 当前集群状态 # ▶ redis-cli -h node1 -p 6379 cluster nodes f635a8cdaa48e04f2531d28c103bea9dc2d8f48d 192.168.1.102:6380@16380 slave f9d707317348314a7306fdaf91da2d153590140e 0 1681527313557 5 connected f49300c718a7e0baf6d3e8ba4bf7e9915e8051cc 192.168.1.101:6380@16380 slave 9e9613cec2fdd48000509e9c3723d157263edd87 0 1681527313000 4 connected 9ea59136c61207347657503fd7a78349f57e919e 192.168.1.103:6380@16380 slave fff7298fa77799434bc8ef6c74c974c21ebc47b4 0 1681527314000 0 connected fff7298fa77799434bc8ef6c74c974c21ebc47b4 192.168.1.101:6379@16379 myself,master - 0 1681527313000 0 connected 0-5461 9e9613cec2fdd48000509e9c3723d157263edd87 192.168.1.102:6379@16379 master - 0 1681527314579 4 connected 5462-10922 f9d707317348314a7306fdaf91da2d153590140e 192.168.1.103:6379@16379 master - 0 1681527312000 5 connected 10923-16383 新增节点配置并启动 # 准备 # 假设在node3新增两个端口{6390,6391},作为新节点\n且 node3:6391 replicate node3:6390\n步骤:通过mkdir -p /usr/local/redis_cluster/redis_63{91,90}/{conf,pid,logs}创建文件夹,然后再conf目录下配置集群配置文件\n# 守护进行模式启动 daemonize yes # 设置数据库数量,默认数据库为0 databases 16 # 绑定地址,需要修改 bind node3 # 绑定端口,需要修改 port 6390 # pid文件存储位置,文件名需要修改 pidfile /usr/local/redis_cluster/redis_6390/pid/redis_6390.pid # log文件存储位置,文件名需要修改 logfile /usr/local/redis_cluster/redis_6390/logs/redis_6390.log # RDB快照备份文件名,文件名需要修改 dbfilename redis_6390.rdb # 本地数据库存储目录,需要修改 dir /usr/local/redis_cluster/redis_6390 # 集群相关配置 # 是否以集群模式启动 cluster-enabled yes # 集群节点回应最长时间,超过该时间被认为下线 cluster-node-timeout 15000 # 生成的集群节点配置文件名,文件名需要修改 cluster-config-file nodes_6390.conf 目录结构\nroot@centos7103:/usr/local/redis_cluster ▶ ls redis6 redis_6379 redis_6380 redis_6390 redis_6391 ▶ tree *90 redis_6390 ├── conf │ └── redis.conf ├── logs └── pid 3 directories, 1 file 启动节点\n# 两个孤儿节点 root@centos7103:/usr/local/redis_cluster ⍉ ▶ redis-server /usr/local/redis_cluster/redis_6390/conf/redis.conf root@centos7103:/usr/local/redis_cluster ▶ redis-server /usr/local/redis_cluster/redis_6391/conf/redis.conf root@centos7103:/usr/local/redis_cluster ▶ netstat -lntup |grep redis tcp 0 0 192.168.1.103:6379 0.0.0.0:* LISTEN 3484/redis-server n tcp 0 0 192.168.1.103:6380 0.0.0.0:* LISTEN 3507/redis-server n tcp 0 0 192.168.1.103:6390 0.0.0.0:* LISTEN 5590/redis-server n tcp 0 0 192.168.1.103:6391 0.0.0.0:* LISTEN 5616/redis-server n tcp 0 0 192.168.1.103:16379 0.0.0.0:* LISTEN 3484/redis-server n tcp 0 0 192.168.1.103:16380 0.0.0.0:* LISTEN 3507/redis-server n tcp 0 0 192.168.1.103:16390 0.0.0.0:* LISTEN 5590/redis-server n tcp 0 0 192.168.1.103:16391 0.0.0.0:* LISTEN 5616/redis-server n 添加主节点 # 将新节点加入到node1:6379 [0,5460]所在的集群中\n加入前\nredis-cli -h node3 -p 6390 node3:6390\u0026gt; cluster nodes b014cfbeff6f9668ec9592cbc8aa874bda2d8d6b :6390@16390 myself,master - 0 0 0 connected 加入\n# 在node1客户端操作,将103:6390添加到101:6379所在的集群中 redis-cli -h node1 -p 6379 --cluster add-node 192.168.1.103:6390 192.168.1.101:6379 \u0026gt;\u0026gt;\u0026gt; Adding node 192.168.1.103:6390 to cluster 192.168.1.101:6379 \u0026gt;\u0026gt;\u0026gt; Performing Cluster Check (using node 192.168.1.101:6379) M: fff7298fa77799434bc8ef6c74c974c21ebc47b4 192.168.1.101:6379 slots:[0-5461] (5462 slots) master 1 additional replica(s) S: f635a8cdaa48e04f2531d28c103bea9dc2d8f48d 192.168.1.102:6380 slots: (0 slots) slave replicates f9d707317348314a7306fdaf91da2d153590140e S: f49300c718a7e0baf6d3e8ba4bf7e9915e8051cc 192.168.1.101:6380 slots: (0 slots) slave replicates 9e9613cec2fdd48000509e9c3723d157263edd87 S: 9ea59136c61207347657503fd7a78349f57e919e 192.168.1.103:6380 slots: (0 slots) slave replicates fff7298fa77799434bc8ef6c74c974c21ebc47b4 M: 9e9613cec2fdd48000509e9c3723d157263edd87 192.168.1.102:6379 slots:[5462-10922] (5461 slots) master 1 additional replica(s) M: f9d707317348314a7306fdaf91da2d153590140e 192.168.1.103:6379 slots:[10923-16383] (5461 slots) master 1 additional replica(s) [OK] All nodes agree about slots configuration. \u0026gt;\u0026gt;\u0026gt; Check for open slots... \u0026gt;\u0026gt;\u0026gt; Check slots coverage... [OK] All 16384 slots covered. \u0026gt;\u0026gt;\u0026gt; Send CLUSTER MEET to node 192.168.1.103:6390 to make it join the cluster. [OK] New node added correctly. 加入后\n▶ redis-cli -h node1 -p 6379 cluster nodes b014cfbeff6f9668ec9592cbc8aa874bda2d8d6b 192.168.1.103:6390@16390 master - 0 1681527533967 6 connected f635a8cdaa48e04f2531d28c103bea9dc2d8f48d 192.168.1.102:6380@16380 slave f9d707317348314a7306fdaf91da2d153590140e 0 1681527534990 5 connected f49300c718a7e0baf6d3e8ba4bf7e9915e8051cc 192.168.1.101:6380@16380 slave 9e9613cec2fdd48000509e9c3723d157263edd87 0 1681527533000 4 connected 9ea59136c61207347657503fd7a78349f57e919e 192.168.1.103:6380@16380 slave fff7298fa77799434bc8ef6c74c974c21ebc47b4 0 1681527533000 0 connected fff7298fa77799434bc8ef6c74c974c21ebc47b4 192.168.1.101:6379@16379 myself,master - 0 1681527529000 0 connected 0-5461 9e9613cec2fdd48000509e9c3723d157263edd87 192.168.1.102:6379@16379 master - 0 1681527534000 4 connected 5462-10922 f9d707317348314a7306fdaf91da2d153590140e 192.168.1.103:6379@16379 master - 0 1681527533000 5 connected 10923-16383 为他分配槽位\n# 最后一个参数,表示原来集群中任意一个节点,这里会将源节点所在集群的一部分分给新增节点 redis-cli -h node1 -p 6379 --cluster reshard 192.168.1.101:6379 ##过程 #后面的2000表示分配2000个槽位给新增节点 How many slots do you want to move (from 1 to 16384)? 2000 #输入 #表示接受节点的NodeId,填新增节点6390的 What is the receiving node ID? b014cfbeff6f9668ec9592cbc8aa874bda2d8d6b #输入 #这里填槽的来源,要么填all,表示所有master节点都拿出一部分槽位分配给新增节点; #要么填某个原有NodeId,表示这个节点拿出一部分槽位给新增节点 Please enter all the source node IDs. Type \u0026#39;all\u0026#39; to use all the nodes as source nodes for the hash slots. Type \u0026#39;done\u0026#39; once you entered all the source nodes IDs. Source node #1: 7e900adc7f977cfcccef12d48c7a29b64c4344c2 Source node #2: done # 这里把node1:6379 拿出了2000个槽位给新节点 结果:\n? redis-cli -h node1 -p 6380 cluster nodes 259b65d7f3d1eac2716f7ae00cc6c1db27a55b27 192.168.1.103:6379@16379 master - 0 1681530641000 2 connected 10923-16383 429ed631dbf09ba846a5371b707defe17b9f8c8e 192.168.1.101:6380@16380 myself,slave 9355d72df6e9dc2643ac1c819cd2e496fb1aed60 0 1681530643000 4 connected 9355d72df6e9dc2643ac1c819cd2e496fb1aed60 192.168.1.102:6379@16379 master - 0 1681530641000 4 connected 5462-10922 81e1e03230ed7700028fa56155e9531b48791164 192.168.1.103:6390@16390 master - 0 1681530644122 6 connected 0-1999 a04347e1af8930324dab7ae85f912449475a487f 192.168.1.102:6380@16380 slave 259b65d7f3d1eac2716f7ae00cc6c1db27a55b27 0 1681530643093 2 connected 92a9d6b988dcf8a219de0247975d8e341072134d 192.168.1.103:6380@16380 slave 7e900adc7f977cfcccef12d48c7a29b64c4344c2 0 1681530643000 1 connected 7e900adc7f977cfcccef12d48c7a29b64c4344c2 192.168.1.101:6379@16379 master - 0 1681530642063 1 connected 2000-5461 添加从节点 # 将节点添加到集群中\n▶ redis-cli -h node1 -p 6379 --cluster add-node 192.168.1.103:6391 192.168.1.101:6379 \u0026gt;\u0026gt;\u0026gt; Adding node 192.168.1.103:6391 to cluster 192.168.1.101:6379 \u0026gt;\u0026gt;\u0026gt; Performing Cluster Check (using node 192.168.1.101:6379) M: 7e900adc7f977cfcccef12d48c7a29b64c4344c2 192.168.1.101:6379 slots:[2000-5461] (3462 slots) master 1 additional replica(s) M: 9355d72df6e9dc2643ac1c819cd2e496fb1aed60 192.168.1.102:6379 slots:[5462-10922] (5461 slots) master 1 additional replica(s) S: a04347e1af8930324dab7ae85f912449475a487f 192.168.1.102:6380 slots: (0 slots) slave replicates 259b65d7f3d1eac2716f7ae00cc6c1db27a55b27 M: 259b65d7f3d1eac2716f7ae00cc6c1db27a55b27 192.168.1.103:6379 slots:[10923-16383] (5461 slots) master 1 additional replica(s) M: 81e1e03230ed7700028fa56155e9531b48791164 192.168.1.103:6390 slots:[0-1999] (2000 slots) master S: 429ed631dbf09ba846a5371b707defe17b9f8c8e 192.168.1.101:6380 slots: (0 slots) slave replicates 9355d72df6e9dc2643ac1c819cd2e496fb1aed60 S: 92a9d6b988dcf8a219de0247975d8e341072134d 192.168.1.103:6380 slots: (0 slots) slave replicates 7e900adc7f977cfcccef12d48c7a29b64c4344c2 [OK] All nodes agree about slots configuration. \u0026gt;\u0026gt;\u0026gt; Check for open slots... \u0026gt;\u0026gt;\u0026gt; Check slots coverage... [OK] All 16384 slots covered. \u0026gt;\u0026gt;\u0026gt; Send CLUSTER MEET to node 192.168.1.103:6391 to make it join the cluster. [OK] New node added correctly. 建立主从关系\n▶ redis-cli -h node1 -p 6380 cluster nodes 9babc7adc86da25ba501bd5bc007300dc04743a9 192.168.1.103:6391@16391 master - 0 1681530812000 0 connected 259b65d7f3d1eac2716f7ae00cc6c1db27a55b27 192.168.1.103:6379@16379 master - 0 1681530808000 2 connected 10923-16383 429ed631dbf09ba846a5371b707defe17b9f8c8e 192.168.1.101:6380@16380 myself,slave 9355d72df6e9dc2643ac1c819cd2e496fb1aed60 0 1681530811000 4 connected 9355d72df6e9dc2643ac1c819cd2e496fb1aed60 192.168.1.102:6379@16379 master - 0 1681530810000 4 connected 5462-10922 81e1e03230ed7700028fa56155e9531b48791164 192.168.1.103:6390@16390 master - 0 1681530810000 6 connected 0-1999 a04347e1af8930324dab7ae85f912449475a487f 192.168.1.102:6380@16380 slave 259b65d7f3d1eac2716f7ae00cc6c1db27a55b27 0 1681530811246 2 connected 92a9d6b988dcf8a219de0247975d8e341072134d 192.168.1.103:6380@16380 slave 7e900adc7f977cfcccef12d48c7a29b64c4344c2 0 1681530812275 1 connected 7e900adc7f977cfcccef12d48c7a29b64c4344c2 192.168.1.101:6379@16379 master - 0 1681530809182 1 connected 2000-5461 root@centos7101:/usr/local/redis_cluster ▶ redis-cli -h node3 -p 6391 cluster replicate 81e1e03230ed7700028fa56155e9531b48791164 OK root@centos7101:/usr/local/redis_cluster # 验证 ▶ redis-cli -h node1 -p 6380 cluster nodes 9babc7adc86da25ba501bd5bc007300dc04743a9 192.168.1.103:6391@16391 slave 81e1e03230ed7700028fa56155e9531b48791164 0 1681530870000 6 connected 259b65d7f3d1eac2716f7ae00cc6c1db27a55b27 192.168.1.103:6379@16379 master - 0 1681530868642 2 connected 10923-16383 429ed631dbf09ba846a5371b707defe17b9f8c8e 192.168.1.101:6380@16380 myself,slave 9355d72df6e9dc2643ac1c819cd2e496fb1aed60 0 1681530867000 4 connected 9355d72df6e9dc2643ac1c819cd2e496fb1aed60 192.168.1.102:6379@16379 master - 0 1681530871715 4 connected 5462-10922 81e1e03230ed7700028fa56155e9531b48791164 192.168.1.103:6390@16390 master - 0 1681530868000 6 connected 0-1999 a04347e1af8930324dab7ae85f912449475a487f 192.168.1.102:6380@16380 slave 259b65d7f3d1eac2716f7ae00cc6c1db27a55b27 0 1681530869000 2 connected 92a9d6b988dcf8a219de0247975d8e341072134d 192.168.1.103:6380@16380 slave 7e900adc7f977cfcccef12d48c7a29b64c4344c2 0 1681530870000 1 connected 7e900adc7f977cfcccef12d48c7a29b64c4344c2 192.168.1.101:6379@16379 master - 0 1681530870693 1 connected 2000-5461 测试\nnode1:6380\u0026gt; set 18 a -\u0026gt; Redirected to slot [511] located at 192.168.1.103:6390 OK 192.168.1.103:6390\u0026gt; get 18 \u0026#34;a\u0026#34; #在node3:6391上尝试-\u0026gt;说明从机上是有数据的 ▶ redis-cli -h node3 -p 6391 node3:6391\u0026gt; get 18 (error) MOVED 511 192.168.1.103:6390 node3:6391\u0026gt; readonly OK node3:6391\u0026gt; get 18 \u0026#34;a\u0026#34; node3:6391\u0026gt; keys * 1) \u0026#34;18\u0026#34; 集群收缩 # 迁移待移除节点的槽位 # #当前节点信息 429ed631dbf09ba846a5371b707defe17b9f8c8e 192.168.1.101:6380@16380 slave 9355d72df6e9dc2643ac1c819cd2e496fb1aed60 0 1681531264142 4 connected 7e900adc7f977cfcccef12d48c7a29b64c4344c2 192.168.1.101:6379@16379 master - 0 1681531260000 1 connected 2000-5461 9355d72df6e9dc2643ac1c819cd2e496fb1aed60 192.168.1.102:6379@16379 myself,master - 0 1681531261000 4 connected 5462-10922 259b65d7f3d1eac2716f7ae00cc6c1db27a55b27 192.168.1.103:6379@16379 master - 0 1681531262088 2 connected 10923-16383 9babc7adc86da25ba501bd5bc007300dc04743a9 192.168.1.103:6391@16391 slave 81e1e03230ed7700028fa56155e9531b48791164 0 1681531265170 6 connected 81e1e03230ed7700028fa56155e9531b48791164 192.168.1.103:6390@16390 master - 0 1681531264000 6 connected 0-1999 92a9d6b988dcf8a219de0247975d8e341072134d 192.168.1.103:6380@16380 slave 7e900adc7f977cfcccef12d48c7a29b64c4344c2 0 1681531263115 1 connected a04347e1af8930324dab7ae85f912449475a487f 192.168.1.102:6380@16380 slave 259b65d7f3d1eac2716f7ae00cc6c1db27a55b27 0 1681531260038 2 connected 移除并将槽位分配给其他节点\nredis-cli -p 6379 -h node1 --cluster reshard --cluster-from bee9c03b1c4592119695a17472847736128c8603 --cluster-to 644b722eb996aeb392a8190b29cfdbe95536af9a --cluster-slots 2000 192.168.1.101:6379 # 用哪个客户端,最后的ip:host-\u0026gt;对该ip host所在集群的from和to操作,进行转移 # 结果 redis-cli -h node1 -p 6380 cluster nodes bee9c03b1c4592119695a17472847736128c8603 192.168.1.103:6390@16390 master - 0 1681532501000 6 connected f525c38c1a78e997a96315ca982f969c51500e86 192.168.1.102:6379@16379 master - 0 1681532501000 0 connected 5462-10922 2b905b7e2480d80bb7c7aa47940e9636697a7d4c 192.168.1.103:6379@16379 master - 0 1681532503071 2 connected 10923-16383 644b722eb996aeb392a8190b29cfdbe95536af9a 192.168.1.101:6379@16379 master - 0 1681532502000 8 connected 0-5461 180113f8ceeba0b17b4a122caa62d36e99141225 192.168.1.103:6391@16391 slave 644b722eb996aeb392a8190b29cfdbe95536af9a 0 1681532503000 8 connected 576e15ed8ac1f4632e5f0917c43d41f7e26dc1e0 192.168.1.101:6380@16380 myself,slave f525c38c1a78e997a96315ca982f969c51500e86 0 1681532500000 0 connected 7ff6ce4b934027c1cdb8720169873f8e97474885 192.168.1.102:6380@16380 slave 2b905b7e2480d80bb7c7aa47940e9636697a7d4c 0 1681532504083 2 connected 75f8df2756a83c121b5637e3a381fa8ebfb9204d 192.168.1.103:6380@16380 slave 644b722eb996aeb392a8190b29cfdbe95536af9a 0 1681532501053 8 connected ## 查看 ▶ redis-cli -h node1 -p 6379 node1:6379\u0026gt; get 18 \u0026#34;a\u0026#34; node1:6379\u0026gt; keys * 1) \u0026#34;18\u0026#34; node1:6379\u0026gt; exit ## 看看还在不在103:6390上 redis-cli -h node3 -p 6390 node3:6390\u0026gt; keys * (empty array) node3:6390\u0026gt; get 18 (error) MOVED 511 192.168.1.101:6379 槽位调整成功\n注意,node3:6391原本replicate node3:6390,但是node3:6390没有槽位了,所以他就跟到槽位所在的node上了,即:\n644b722eb996aeb392a8190b29cfdbe95536af9a 192.168.1.101:6379@16379 master - 0 1681532502000 8 connected 0-5461 180113f8ceeba0b17b4a122caa62d36e99141225 192.168.1.103:6391@16391 slave 644b722eb996aeb392a8190b29cfdbe95536af9a 0 1681532503000 8 connected 移除待删除的主从节点 # 先移除从节点,再移除主节点,防止触发集群故障转移(如上,这里可能并不会,因为已经没有节点replicate node3:6390了)\nredis-cli -p 6379 -h node1 --cluster del-node 192.168.1.102:6380 180113f8ceeba0b17b4a122caa62d36e99141225 #ip+port :哪个节点所在的集群 #nodeId \u0026gt;\u0026gt;\u0026gt; Removing node 180113f8ceeba0b17b4a122caa62d36e99141225 from cluster 192.168.1.102:6380 \u0026gt;\u0026gt;\u0026gt; Sending CLUSTER FORGET messages to the cluster... \u0026gt;\u0026gt;\u0026gt; Sending CLUSTER RESET SOFT to the deleted node. 移除主节点\nredis-cli -p 6379 -h node1 --cluster del-node 192.168.1.102:6380 bee9c03b1c4592119695a17472847736128c8603 \u0026gt;\u0026gt;\u0026gt; Removing node bee9c03b1c4592119695a17472847736128c8603 from cluster 192.168.1.102:6380 \u0026gt;\u0026gt;\u0026gt; Sending CLUSTER FORGET messages to the cluster... \u0026gt;\u0026gt;\u0026gt; Sending CLUSTER RESET SOFT to the deleted node. 查看状态(移除成功)\n▶ redis-cli -h node1 -p 6379 cluster nodes 644b722eb996aeb392a8190b29cfdbe95536af9a 192.168.1.101:6379@16379 myself,master - 0 1681533116000 8 connected 0-5461 75f8df2756a83c121b5637e3a381fa8ebfb9204d 192.168.1.103:6380@16380 slave 644b722eb996aeb392a8190b29cfdbe95536af9a 0 1681533119000 8 connected f525c38c1a78e997a96315ca982f969c51500e86 192.168.1.102:6379@16379 master - 0 1681533120514 0 connected 5462-10922 2b905b7e2480d80bb7c7aa47940e9636697a7d4c 192.168.1.103:6379@16379 master - 0 1681533119492 2 connected 10923-16383 576e15ed8ac1f4632e5f0917c43d41f7e26dc1e0 192.168.1.101:6380@16380 slave f525c38c1a78e997a96315ca982f969c51500e86 0 1681533118463 0 connected 7ff6ce4b934027c1cdb8720169873f8e97474885 192.168.1.102:6380@16380 slave 2b905b7e2480d80bb7c7aa47940e9636697a7d4c 0 1681533118000 2 connected cluster命令 # 以下是集群中常用的可执行命令,命令执行格式为:\ncluster 下表命令 命令如下,未全,如果想了解更多请执行cluster help操作:\n命令 描述 INFO 返回当前集群信息 MEET \u0026lt;ip\u0026gt; \u0026lt;port\u0026gt; [\u0026lt;bus-port\u0026gt;] 添加一个节点至当前集群 MYID 返回当前节点集群ID NODES 返回当前节点的集群信息 REPLICATE \u0026lt;node-id\u0026gt; 将当前节点作为某一集群节点的从库 FAILOVER [FORCE|TAKEOVER] 将当前从库升级为主库 RESET [HARD|SOFT] 重置当前节点信息 ADDSLOTS \u0026lt;slot\u0026gt; [\u0026lt;slot\u0026gt; ...] 为当前集群节点增加一个或多个插槽位,推荐在bash shell中执行,可通过{int..int}指定多个插槽位 DELSLOTS \u0026lt;slot\u0026gt; [\u0026lt;slot\u0026gt; ...] 为当前集群节点删除一个或多个插槽位,推荐在bash shell中执行,可通过{int..int}指定多个插槽位 FLUSHSLOTS 删除当前节点中所有的插槽信息 FORGET \u0026lt;node-id\u0026gt; 从集群中删除某一节点 COUNT-FAILURE-REPORTS \u0026lt;node-id\u0026gt; 返回当前集群节点的故障报告数量 COUNTKEYSINSLOT \u0026lt;slot\u0026gt; 返回某一插槽中的键的数量 GETKEYSINSLOT \u0026lt;slot\u0026gt; \u0026lt;count\u0026gt; 返回当前节点存储在插槽中的key名称。 KEYSLOT \u0026lt;key\u0026gt; 返回该key的哈希槽位 SAVECONFIG 保存当前集群配置,进行落盘操作 SLOTS 返回该插槽的信息 SpringBoot+RedisCluster # 依赖\n\u0026lt;dependency\u0026gt; \u0026lt;groupId\u0026gt;org.springframework.boot\u0026lt;/groupId\u0026gt; \u0026lt;artifactId\u0026gt;spring-boot-starter-data-redis\u0026lt;/artifactId\u0026gt; \u0026lt;/dependency\u0026gt; 配置文件yml\nspring: redis: # 如果是redis-cluster 不能用这种形式,否则会报错,只适合单机 #Error in execution; nested exception is io.lettuce.core. #RedisCommandExecutionException: MOVED 15307 192.168.1.103:6379 #host: 192.168.1.102 #port: 6380 # 下面的配置,nodes写一个或者多个都行 cluster: nodes: # - 192.168.1.101:6379 # - 192.168.1.102:6379 # - 192.168.1.101:6380 - 192.168.1.102:6380 # - 192.168.1.103:6379 # - 192.168.1.103:6380 序列化处理\n@Configuration public class RedisConfig { @Bean public RedisTemplate\u0026lt;String, Object\u0026gt; redisTemplate(RedisConnectionFactory redisConnectionFactory) { RedisTemplate\u0026lt;String, Object\u0026gt; template = new RedisTemplate\u0026lt;\u0026gt;(); RedisSerializer\u0026lt;String\u0026gt; redisSerializer = new StringRedisSerializer(); template.setConnectionFactory(redisConnectionFactory); //key序列化方式 template.setKeySerializer(redisSerializer); //value序列化 template.setValueSerializer(redisSerializer); //value hashmap序列化 template.setHashValueSerializer(redisSerializer); //key haspmap序列化 template.setHashKeySerializer(redisSerializer); // return template; } } 使用\n@Autowired private RedisTemplate\u0026lt;String, Object\u0026gt; redisTemplate; @RequestMapping(\u0026#34;/redisTest\u0026#34;) public String redisTest(){ redisTemplate.opsForValue().set(\u0026#34;190\u0026#34;,\u0026#34;hello,world\u0026#34;+new Date().getTime()); Object hello = redisTemplate.opsForValue().get(\u0026#34;190\u0026#34;); return hello.toString(); } RedisCluster架构原理分析 # 基础架构(数据分片) # 集群分片原理 # 如果有任意1个槽位没有被分配,则集群创建不成功。\n启动集群原理 # 集群通信原理 # "},{"id":235,"href":"/zh/docs/technology/Other/kaoshi/","title":"科目","section":"其他","content":" 科目 # 1022/9:00-11:30\n00024 普通逻辑 2010 02197 概率论与数理统计(二)2018 02318 计算机组成原理 2016 02324 离散数学 2014 02331 数据结构 2012 03709 马克思主义基本原理概论 2018 04747 Java语言程序设计(一) 2019 1022/14:30-17:00\n00023 高等数学(工本) 2019 00342 高级语言程序设计(一)2017 02326 操作系统 2017 04730 电子技术基础(三) 2006 04735 数据库系统原理 2018 1023/09:00-11:30\n02325 计算机系统结构 2012 03708 中国近现代史纲要 2018 04737 C++程序设计 2019 1023/14:30-17:00\n0015 英语(二)2012 02333 软件工程 2011 04741 计算机网络原理 2018 "},{"id":236,"href":"/zh/docs/technology/Linux/basic/","title":"基本操作","section":"Linux","content":" yum源替换成阿里云 # yum install -y wget ## 备份 mv /etc/yum.repos.d/CentOS-Base.repo /etc/yum.repos.d/CentOS-Base.repo.bak ## 下载 wget -O /etc/yum.repos.d/CentOS-Base.repo http://mirrors.aliyun.com/repo/Centos-7.repo ## 重建缓存 yum clean all yum makecache Java环境搭建 # yum search java | grep jdk yum install -y java-1.8.0-openjdk-devel.x86_64 # java -version 正常 # javac -version 正常 解压相关 # -zxvf\ntar -zxvf redis* -C /usr/local/redis* # z :表示 tar 包是被 gzip 压缩过的 (后缀是.tar.gz),所以解压时需要用 gunzip 解压 (.tar不需要) # x :表示 从 tar 包中把文件提取出来 # v :表示 显示打包过程详细信息 # f :指定被处理的文件是什么 # 适用于参数分开使用的情况,连续无分隔参数不应该再使用(所以上面的命令不标准), # 应该是 tar zxvf redis* -C /usr/local/redis* 主题修改 # oh my zsh\n在线 # Method Command curl sh -c \u0026quot;$(curl -fsSL https://raw.githubusercontent.com/ohmyzsh/ohmyzsh/master/tools/install.sh)\u0026quot; wget sh -c \u0026quot;$(wget -O- https://raw.githubusercontent.com/ohmyzsh/ohmyzsh/master/tools/install.sh)\u0026quot; fetch sh -c \u0026quot;$(fetch -o - https://raw.githubusercontent.com/ohmyzsh/ohmyzsh/master/tools/install.sh)\u0026quot; 手动安装 # yum install -y zsh #一定要先装 sh -c \u0026#34;$(wget https://gitee.com/liu_yi_er/ohmyzsh/raw/master/tools/install.sh -O -)\u0026#34; #自己的gitee目录,从官网下载 sh install.sh 修改主题 # //该主题样式如下\n$ vi ~/.zshrc # 找到这一行,修改为自己喜欢的主题名称 # ZSH_THEME=\u0026#34;ys\u0026#34; ZSH_THEME=\u0026#34;avit\u0026#34; # 修改保存后,使配置生效 $ source ~/.zshrc zsh home和end失效,可以改用ctrl+a / ctrl+e 代替\nVim的使用快捷使用 # # 清空文件--命令模式下输入 :%d 回车 # 处理粘贴时多出的行带#的问题-- 命令模式下输入 :set paste 再输入i进行粘贴 #快速修改-- 命令模式下输入 :%s/6379/6380/g (将文件中所有6379替换成6380) 基本网络工具安装 # yum install -y net-tools 查看端口监听情况\nnetstat -lntup | grep redis 解释\n-a (all)显示所有选项,默认不显示LISTEN相关 -t (tcp)仅显示tcp相关选项 -u (udp)仅显示udp相关选项 -n 拒绝显示别名,能显示数字的全部转化成数字。 -l 仅列出有在 Listen (监听) 的服務状态\n-p 显示建立相关链接的程序名 -r 显示路由信息,路由表 -e 显示扩展信息,例如uid等 -s 按各个协议进行统计 -c 每隔一个固定时间,执行该netstat命令。\n提示:LISTEN和LISTENING的状态只有用-a或者-l才能看到\nps命令 # ps -ef //-e表示全部进程 ,-f表示全部的列\n树形结构查看文件夹 # yum install -y tree\n快捷键 # ctrl+w 快速删除光标前的整个单词\nctrl+a 光标移到行首 [xshell]\nctrl+e 光标移到行尾 [xshell]\n创建多级目录 # mkdir -p /usr/local/redis_cluster/redis_63{79,80}/{conf,pid,logs}\n"},{"id":237,"href":"/zh/docs/technology/Linux/create_clone/","title":"vmware上linux主机的安装和克隆","section":"Linux","content":" 安装 # 虚拟机向导 # 典型\u0026mdash;稍后安装\u0026ndash;linux\u0026ndash;RedhatEnterpriseLinux7 64 虚拟机名称rheCentos700 接下来都默认即可(20G硬盘,2G内存,网络适配器(桥接模式)) 安装界面 # 日期\u0026ndash;亚洲上海,键盘\u0026ndash;汉语,语言支持\u0026ndash;简体中文(中国)\n软件安装\n最小安装\u0026mdash;\u0026gt; 兼容性程序库+开发工具\n其他存储选项\u0026ndash;配置分区\n/boot 1G 标准分区,文件系统ext4 swap 2G 标准分区 ,文件系统swap / 17G 标准分区,文件系统ext4 网络和主机名\n打开网络+设置主机名(rheCentos700)\n完成\u0026mdash;过程中配置密码 默认用户root+其他用户ly\n安装完成后修改ip及网关 # Centos # vi /etc/sysconfig/network-scripts/ifcfg-ens**\n修改部分键值对\nBOOTPROTO=\u0026#34;static\u0026#34; IPADDR=192.168.1.100 NETMASK=255.255.255.0 GATEWAY=192.168.1.1 DNS1=223.5.5.5 DNS2=223.6.6.6 systemctl restart network\nDebian # 查看当前网卡\nip link #1: lo: \u0026lt;LOOPBACK,UP,LOWER_UP\u0026gt; mtu 65536 qdisc noqueue state UNKNOWN mode DEFAULT group default qlen 1000 # link/loopback 00:00:00:00:00:00 brd 00:00:00:00:00:00 #2: ens33: \u0026lt;BROADCAST,MULTICAST,UP,LOWER_UP\u0026gt; mtu 1500 qdisc fq_codel state UP mode DEFAULT group default qlen 1000 # link/ether 00:0c:29:ed:95:f5 brd ff:ff:ff:ff:ff:ff # altname enp2s1 得知网卡名为ens33\nvim /etc/network/interfaces 添加内容,为网卡(ens33)设置静态ip\n#ly-update auto ens33 iface ens33 inet static address 192.168.1.206 netmask 255.255.255.0 gateway 192.168.1.1 dns-nameservers 223.5.5.5 223.6.6.6 重启网络\nsudo service networking restart 查看ip\nip a #---------------------结果显示 1: lo: \u0026lt;LOOPBACK,UP,LOWER_UP\u0026gt; mtu 65536 qdisc noqueue state UNKNOWN group default qlen 1000 link/loopback 00:00:00:00:00:00 brd 00:00:00:00:00:00 inet 127.0.0.1/8 scope host lo valid_lft forever preferred_lft forever inet6 ::1/128 scope host noprefixroute valid_lft forever preferred_lft forever 2: ens33: \u0026lt;BROADCAST,MULTICAST,UP,LOWER_UP\u0026gt; mtu 1500 qdisc fq_codel state UP group default qlen 1000 link/ether 00:0c:xx:xx:23:f5 brd ff:ff:ff:ff:ff:ff altname enp2s1 inet 192.168.1.206/24 brd 192.168.1.255 scope global ens33 valid_lft forever preferred_lft forever inet6 xxxx::20c:29ff:feed:xxxx/64 scope link valid_lft forever preferred_lft forever 克隆虚拟机 # 右键\u0026ndash;管理\u0026ndash;克隆\u0026ndash;创建完整克隆\n修改MAC、主机名、ip、uuid\n右键\u0026ndash;设置\u0026ndash;网络适配器\u0026ndash;高级\u0026ndash;MAC地址-\u0026gt;生成\nvi /etc/hostname修改主机名\nreboot\nvi /etc/sysconfig/network-scripts/ifcfg-ens**修改ip及uuid\nuuid自动生成\n常用操作 # 常用命令安装\nyum install -y wget yum阿里云源切换\n备份mv /etc/yum.repos.d/CentOS-Base.repo /etc/yum.repos.d/CentOS-Base.repo.bak 下载并切换wget -O /etc/yum.repos.d/CentOS-Base.repo http://mirrors.aliyun.com/repo/Centos-7.repo 清理yum clean all 缓存处理yum makecache "},{"id":238,"href":"/zh/docs/technology/Review/java_guide/database/MySQL/ly0606lymysql-query-execution-plan/","title":"mysql执行计划","section":"MySQL","content":" 转载自https://github.com/Snailclimb/JavaGuide(添加小部分笔记)感谢作者!\n本文来自公号 MySQL 技术,JavaGuide 对其做了补充完善。原文地址:https://mp.weixin.qq.com/s/d5OowNLtXBGEAbT31sSH4g\n优化 SQL 的第一步应该是读懂 SQL 的执行计划。本篇文章,我们一起来学习下 MySQL EXPLAIN 执行计划相关知识。\n什么是执行计划? # 执行计划 是指一条 SQL 语句在经过 MySQL 查询优化器 的优化会后,具体的执行方式。\n执行计划通常用于 SQL 性能分析、优化等场景。通过 EXPLAIN 的结果,可以了解到如数据表的查询顺序、数据查询操作的操作类型、哪些索引可以被命中、哪些索引实际会命中、每个数据表有多少行记录被查询等信息。\n如何获取执行计划? # -- 提交准备数据 SET NAMES utf8mb4; SET FOREIGN_KEY_CHECKS = 0; -- ---------------------------- -- Table structure for dept_emp -- ---------------------------- DROP TABLE IF EXISTS `dept_emp`; CREATE TABLE `dept_emp` ( `id` int(0) NOT NULL, `emp_no` varchar(128) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NULL DEFAULT NULL, `other1` varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NULL DEFAULT NULL, `other2` varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NULL DEFAULT NULL, PRIMARY KEY (`id`) USING BTREE, INDEX `index_emp_no`(`emp_no`) USING BTREE ) ENGINE = InnoDB CHARACTER SET = utf8mb4 COLLATE = utf8mb4_0900_ai_ci ROW_FORMAT = Dynamic; -- ---------------------------- -- Records of dept_emp -- ---------------------------- INSERT INTO `dept_emp` VALUES (1, \u0026#39;a1\u0026#39;, \u0026#39;o11\u0026#39;, \u0026#39;012\u0026#39;); INSERT INTO `dept_emp` VALUES (2, \u0026#39;a2\u0026#39;, \u0026#39;o21\u0026#39;, \u0026#39;o22\u0026#39;); INSERT INTO `dept_emp` VALUES (3, \u0026#39;a3\u0026#39;, \u0026#39;o31\u0026#39;, \u0026#39;o32\u0026#39;); INSERT INTO `dept_emp` VALUES (4, \u0026#39;a4\u0026#39;, \u0026#39;o41\u0026#39;, \u0026#39;o42\u0026#39;); INSERT INTO `dept_emp` VALUES (5, \u0026#39;a5\u0026#39;, \u0026#39;o51\u0026#39;, \u0026#39;o52\u0026#39;); SET FOREIGN_KEY_CHECKS = 1; MySQL 为我们提供了 EXPLAIN 命令,来获取执行计划的相关信息。\n需要注意的是,EXPLAIN 语句并不会真的去执行相关的语句,而是通过查询优化器对语句进行分析,找出最优的查询方案,并显示对应的信息。\nEXPLAIN 执行计划支持 SELECT、DELETE、INSERT、REPLACE 以及 UPDATE 语句。我们一般多用于分析 SELECT 查询语句,使用起来非常简单,语法如下:\nEXPLAIN + SELECT 查询语句; 我们简单来看下一条查询语句的执行计划:\nmysql\u0026gt; explain SELECT * FROM dept_emp WHERE emp_no IN (SELECT emp_no FROM dept_emp GROUP BY emp_no HAVING COUNT(emp_no)\u0026gt;1); +----+-------------+----------+------------+-------+-----------------+---------+---------+------+--------+----------+-------------+ | id | select_type | table | partitions | type | possible_keys | key | key_len | ref | rows | filtered | Extra | +----+-------------+----------+------------+-------+-----------------+---------+---------+------+--------+----------+-------------+ | 1 | PRIMARY | dept_emp | NULL | ALL | NULL | NULL | NULL | NULL | 331143 | 100.00 | Using where | | 2 | SUBQUERY | dept_emp | NULL | index | PRIMARY,dept_no | PRIMARY | 16 | NULL | 331143 | 100.00 | Using index | +----+-------------+----------+------------+-------+-----------------+---------+---------+------+--------+----------+-------------+ 可以看到,执行计划结果中共有 12 列,各列代表的含义总结如下表:\n列名 含义 id SELECT查询的序列标识符 select_type SELECT关键字对应的查询类型 table 用到的表名 partitions 匹配的分区,对于未分区的表,值为 NULL type 表的访问方法 possible_keys 可能用到的索引 key 实际用到的索引 key_len 所选索引的长度 ref 当使用索引等值查询时,与索引作比较的列或常量 rows 预计要读取的行数 filtered 按表条件过滤后,留存的记录数的百分比 Extra 附加信息 如何分析 EXPLAIN 结果? # 为了分析 EXPLAIN 语句的执行结果,我们需要搞懂执行计划中的重要字段。\nid # SELECT 标识符,是查询中 SELECT 的序号,用来标识整个查询中 SELELCT 语句的顺序。\nid 如果相同,从上往下依次执行。id 不同,id 值越大,执行优先级越高,如果行引用其他行的并集结果,则该值可以为 NULL。\nselect_type # 查询的类型,主要用于区分普通查询、联合查询、子查询等复杂的查询,常见的值有:\nSIMPLE:简单查询,不包含 UNION 或者子查询。 PRIMARY:查询中如果包含子查询或其他部分,外层的 SELECT 将被标记为 PRIMARY。 SUBQUERY:子查询中的第一个 SELECT。 UNION:在 UNION 语句中,UNION 之后出现的 SELECT。 DERIVED:在 FROM 中出现的子查询将被标记为 DERIVED。 UNION RESULT:UNION 查询的结果。 table # 查询用到的表名,每行都有对应的表名,表名除了正常的表之外,也可能是以下列出的值:\n\u0026lt;unionM,N\u0026gt; : 本行引用了 id 为 M 和 N 的行的 UNION 结果; \u0026lt;derivedN\u0026gt; : 本行引用了 id 为 N 的表所产生的的派生表结果。派生表有可能产生自 FROM 语句中的子查询。 -\u0026lt;subqueryN\u0026gt; : 本行引用了 id 为 N 的表所产生的的物化子查询结果。 type(重要) # 查询执行的类型,描述了查询是如何执行的。所有值的顺序从最优到最差排序为:system \u0026gt; const \u0026gt; eq_ref \u0026gt; ref \u0026gt; fulltext \u0026gt; ref_or_null \u0026gt; index_merge \u0026gt; unique_subquery \u0026gt; index_subquery \u0026gt; range \u0026gt; index \u0026gt; ALL\n常见的几种类型具体含义如下:\nsystem:如果表使用的引擎对于表行数统计是精确的(如:MyISAM),且表中只有一行记录的情况下,访问方法是 system ,是 const 的一种特例。 const:表中最多只有一行匹配的记录,一次查询就可以找到,常用于使用主键或唯一索引的所有字段作为查询条件。 eq_ref:当连表查询时,前一张表的行在当前这张表中只有一行与之对应。是除了 system 与 const 之外最好的 join 方式,常用于使用主键或唯一索引的所有字段作为连表条件。 ref:使用普通索引作为查询条件,查询结果可能找到多个符合条件的行。 index_merge:当查询条件使用了多个索引时,表示开启了 Index Merge 优化,此时执行计划中的 key 列列出了使用到的索引。 range:对索引列进行范围查询,执行计划中的 key 列表示哪个索引被使用了。 index:查询遍历了整棵索引树,与 ALL 类似,只不过扫描的是索引,而索引一般在内存中,速度更快。 ALL:全表扫描。 possible_keys # possible_keys 列表示 MySQL 执行查询时可能用到的索引。如果这一列为 NULL ,则表示没有可能用到的索引;这种情况下,需要检查 WHERE 语句中所使用的的列,看是否可以通过给这些列中某个或多个添加索引的方法来提高查询性能。\nkey(重要) # key 列表示 MySQL 实际使用到的索引。如果为 NULL,则表示未用到索引。\nkey_len # key_len 列表示 MySQL 实际使用的索引的最大长度;当使用到联合索引时,有可能是多个列的长度和。在满足需求的前提下越短越好。如果 key 列显示 NULL ,则 key_len 列也显示 NULL 。\nrows # rows 列表示根据表统计信息及选用情况,大致估算出找到所需的记录或所需读取的行数,数值越小越好。\nExtra(重要) # 这列包含了 MySQL 解析查询的额外信息,通过这些信息,可以更准确的理解 MySQL 到底是如何执行查询的。常见的值如下:\nUsing filesort:在排序时使用了外部的索引排序,没有用到表内索引进行排序。 Using temporary:MySQL 需要创建临时表来存储查询的结果,常见于 ORDER BY 和 GROUP BY。 Using index:表明查询使用了覆盖索引,不用回表,查询效率非常高。 Using index condition:表示查询优化器选择使用了索引条件下推这个特性。 Using where:表明查询使用了 WHERE 子句进行条件过滤。一般在没有使用到索引的时候会出现。 Using join buffer (Block Nested Loop):连表查询的方式,表示当被驱动表的没有使用索引的时候,MySQL 会先将驱动表读出来放到 join buffer 中,再遍历被驱动表与驱动表进行查询。 这里提醒下,当 Extra 列包含 Using filesort 或 Using temporary 时,MySQL 的性能可能会存在问题,需要尽可能避免。\n参考 # https://dev.mysql.com/doc/refman/5.7/en/explain-output.html https://juejin.cn/post/6953444668973514789 "},{"id":239,"href":"/zh/docs/technology/Review/java_guide/database/ly0503lysql-question-01/","title":"sql常见面试题总结01","section":"数据库","content":" 转载自https://github.com/Snailclimb/JavaGuide(添加小部分笔记)感谢作者!\n题目来源于: 牛客题霸 - SQL 必知必会\n检索数据 # select 用于从数据库中查询数据。\n从 Customers 表中检索所有的 ID # 现有表 Customers 如下:\ncust_id A B C 编写 SQL 语句,从 Customers 表中检索所有的 cust_id。\n答案:\nselect cust_id from Customers; 检索并列出已订购产品的清单 # 表 OrderItems 含有非空的列 prod_id 代表商品 id,包含了所有已订购的商品(有些已被订购多次)。\nprod_id a1 a2 a3 a4 a5 a6 a7 编写 SQL 语句,检索并列出所有已订购商品(prod_id)的去重后的清单。\n答案:\nselect distinct prod_id from OrderItems; 知识点:distinct 用于返回列中的唯一不同值。\n检索所有列 # 现在有 Customers 表(表中含有列 cust_id 代表客户 id,cust_name 代表客户姓名)\ncust_id cust_name a1 andy a2 ben a3 tony a4 tom a5 an a6 lee a7 hex 需要编写 SQL 语句,检索所有列。\n答案:\nselect cust_id, cust_name from Customers; 排序检索数据 # order by 用于对结果集按照一个列或者多个列进行排序。默认按照升序对记录进行排序,如果需要按照降序对记录进行排序,可以使用 desc 关键字。\n检索顾客名称并且排序 # 有表 Customers,cust_id 代表客户 id,cust_name 代表客户姓名。\ncust_id cust_name a1 andy a2 ben a3 tony a4 tom a5 an a6 lee a7 hex 从 Customers 中检索所有的顾客名称(cust_name),并按从 Z 到 A 的顺序显示结果。\n答案:\nselect cust_name from Customers order by cust_name desc 对顾客 ID 和日期排序 # 有 Orders 表:\ncust_id order_num order_date andy aaaa 2021-01-01 00:00:00 andy bbbb 2021-01-01 12:00:00 bob cccc 2021-01-10 12:00:00 dick dddd 2021-01-11 00:00:00 编写 SQL 语句,从 Orders 表中检索顾客 ID(cust_id)和订单号(order_num),并先按顾客 ID 对结果进行排序,再按订单日期倒序排列。\n答案:\n# 根据列名排序 # 注意:是 order_date 降序,而不是 order_num select cust_id, order_num from Orders order by cust_id, order_date desc; 知识点:order by 对多列排序的时候,先排序的列放前面,后排序的列放后面。并且,不同的列可以有不同的排序规则。\n按照数量和价格排序 # 假设有一个 OrderItems 表:\nquantity item_price 1 100 10 1003 2 500 编写 SQL 语句,显示 OrderItems 表中的数量(quantity)和价格(item_price),并按数量由多到少、价格由高到低排序。\n答案:\nselect quantity, item_price from OrderItems order by quantity desc, item_price desc; 检查 SQL 语句 # 有 Vendors 表:\nvend_name 海底捞 小龙坎 大龙燚 下面的 SQL 语句有问题吗?尝试将它改正确,使之能够正确运行,并且返回结果根据vend_name 逆序排列。\nSELECT vend_name, FROM Vendors ORDER vend_name DESC; 改正后:\nselect vend_name from Vendors order by vend_name desc; 知识点:\n逗号作用是用来隔开列与列之间的。 order by 是有 by 的,需要撰写完整,且位置正确。 过滤数据 # where 可以过滤返回的数据。\n下面的运算符可以在 where 子句中使用:\n运算符 描述 = 等于 \u0026lt;\u0026gt; 不等于。**注释:**在 SQL 的一些版本中,该操作符可被写成 != \u0026gt; 大于 \u0026lt; 小于 \u0026gt;= 大于等于 \u0026lt;= 小于等于 BETWEEN 在某个范围内 LIKE 搜索某种模式 IN 指定针对某个列的多个可能值 返回固定价格的产品 # 有表 Products :\nprod_id prod_name prod_price a0018 sockets 9.49 a0019 iphone13 600 b0018 gucci t-shirts 1000 【问题】从 Products 表中检索产品 ID(prod_id)和产品名称(prod_name),只返回价格为 9.49 美元的产品。\n答案:\nselect prod_id, prod_name from Products where prod_price = 9.49; 返回更高价格的产品 # 有表 Products :\nprod_id prod_name prod_price a0018 sockets 9.49 a0019 iphone13 600 b0019 gucci t-shirts 1000 【问题】编写 SQL 语句,从 Products 表中检索产品 ID(prod_id)和产品名称(prod_name),只返回价格为 9 美元或更高的产品。\n答案:\nselect prod_id, prod_name from Products where prod_price \u0026gt;= 9; 返回产品并且按照价格排序 # 有表 Products :\nprod_id prod_name prod_price a0011 egg 3 a0019 sockets 4 b0019 coffee 15 【问题】编写 SQL 语句,返回 Products 表中所有价格在 3 美元到 6 美元之间的产品的名称(prod_name)和价格(prod_price),然后按价格对结果进行排序。\n答案:\nselect prod_name, prod_price from Products where prod_price between 3 and 6 order by prod_price; # 或者 select prod_name, prod_price from Products where prod_price \u0026gt;= 3 and prod_price \u0026lt;= 6 order by prod_price; 返回更多的产品 # OrderItems 表含有:订单号 order_num,quantity产品数量\norder_num quantity a1 105 a2 1100 a2 200 a4 1121 a5 10 a2 19 a7 5 【问题】从 OrderItems 表中检索出所有不同且不重复的订单号(order_num),其中每个订单都要包含 100 个或更多的产品。\n答案:\nselect distinct order_num from OrderItems where quantity \u0026gt;= 100; 高级数据过滤 # and 和 or 运算符用于基于一个以上的条件对记录进行过滤,两者可以结合使用。and 必须 2 个条件都成立,or只要 2 个条件中的一个成立即可。\n检索供应商名称 # Vendors 表有字段供应商名称(vend_name)、供应商国家(vend_country)、供应商州(vend_state)\nvend_name vend_country vend_state apple USA CA vivo CNA shenzhen huawei CNA xian 【问题】编写 SQL 语句,从 Vendors 表中检索供应商名称(vend_name),仅返回加利福尼亚州的供应商(这需要按国家[USA]和州[CA]进行过滤,没准其他国家也存在一个 CA)\n答案:\nselect vend_name from Vendors where vend_country = \u0026#39;USA\u0026#39; and vend_state = \u0026#39;CA\u0026#39;; 检索并列出已订购产品的清单 # OrderItems 表包含了所有已订购的产品(有些已被订购多次)。\nprod_id order_num quantity BR01 a1 105 BR02 a2 1100 BR02 a2 200 BR03 a4 1121 BR017 a5 10 BR02 a2 19 BR017 a7 5 【问题】编写 SQL 语句,查找所有订购了数量至少 100 个的 BR01、BR02 或 BR03 的订单。你需要返回 OrderItems 表的订单号(order_num)、产品 ID(prod_id)和数量(quantity),并按产品 ID 和数量进行过滤。\n答案:\nselect order_num, prod_id, quantity from OrderItems where quantity \u0026gt;= 100 and prod_id in(\u0026#39;BR01\u0026#39;, \u0026#39;BR02\u0026#39;, \u0026#39;BR03\u0026#39;); 返回所有价格在 3 美元到 6 美元之间的产品的名称和价格 # 有表 Products:\nprod_id prod_name prod_price a0011 egg 3 a0019 sockets 4 b0019 coffee 15 【问题】编写 SQL 语句,返回所有价格在 3 美元到 6 美元之间的产品的名称(prod_name)和价格(prod_price),使用 AND 操作符,然后按价格对结果进行升序排序。\n答案:\nselect prod_name, prod_price from Products where prod_price between 3 and 6 order by prod_price; 检查 SQL 语句 # 供应商表 Vendors 有字段供应商名称 vend_name、供应商国家 vend_country、供应商省份 vend_state\nvend_name vend_country vend_state apple USA CA vivo CNA shenzhen huawei CNA xian 【问题】修改正确下面 sql,使之正确返回。\nSELECT vend_name FROM Vendors ORDER BY vend_name WHERE vend_country = \u0026#39;USA\u0026#39; AND vend_state = \u0026#39;CA\u0026#39;; 修改后:\nselect vend_name from Vendors where vend_country = \u0026#39;USA\u0026#39; and vend_state = \u0026#39;CA\u0026#39; order by vend_name; order by 语句必须放在 where 之后。\n用通配符进行过滤 # SQL 通配符必须与 LIKE 运算符一起使用\n在 SQL 中,可使用以下通配符:\n通配符 描述 % 代表零个或多个字符 _ 仅替代一个字符 [charlist] 字符列中的任何单一字符 [^charlist] 或者 [!charlist] 不在字符列中的任何单一字符 检索产品名称和描述(一) # Products 表如下:\nprod_name prod_desc a0011 usb a0019 iphone13 b0019 gucci t-shirts c0019 gucci toy d0019 lego toy 【问题】编写 SQL 语句,从 Products 表中检索产品名称(prod_name)和描述(prod_desc),仅返回描述中包含 toy 一词的产品名称。\n答案:\nselect prod_name, prod_desc from Products where prod_desc like \u0026#39;%toy%\u0026#39;; 检索产品名称和描述(二) # Products 表如下:\nprod_name prod_desc a0011 usb a0019 iphone13 b0019 gucci t-shirts c0019 gucci toy d0019 lego toy 【问题】编写 SQL 语句,从 Products 表中检索产品名称(prod_name)和描述(prod_desc),仅返回描述中未出现 toy 一词的产品,最后按”产品名称“对结果进行排序。\n答案:\nselect prod_name, prod_desc from Products where prod_desc not like \u0026#39;%toy%\u0026#39; order by prod_name; 检索产品名称和描述(三) # Products 表如下:\nprod_name prod_desc a0011 usb a0019 iphone13 b0019 gucci t-shirts c0019 gucci toy d0019 lego carrots toy 【问题】编写 SQL 语句,从 Products 表中检索产品名称(prod_name)和描述(prod_desc),仅返回描述中同时出现 toy 和 carrots 的产品。有好几种方法可以执行此操作,但对于这个挑战题,请使用 AND 和两个 LIKE 比较。\n答案:\nselect prod_name, prod_desc from Products where prod_desc like \u0026#39;%toy%\u0026#39; and prod_desc like \u0026#34;%carrots%\u0026#34;; 检索产品名称和描述(四) # Products 表如下:\nprod_name prod_desc a0011 usb a0019 iphone13 b0019 gucci t-shirts c0019 gucci toy d0019 lego toy carrots 【问题】编写 SQL 语句,从 Products 表中检索产品名称(prod_name)和描述(prod_desc),仅返回在描述中以先后顺序同时出现 toy 和 carrots 的产品。提示:只需要用带有三个 % 符号的 LIKE 即可。\n答案:\nselect prod_name, prod_desc from Products where prod_desc like \u0026#39;%toy%carrots%\u0026#39;; 创建计算字段 # 别名 # 别名的常见用法是在检索出的结果中重命名表的列字段(为了符合特定的报表要求或客户需求)。有表 Vendors 代表供应商信息,vend_id 供应商 id、vend_name 供应商名称、vend_address 供应商地址、vend_city 供应商城市。\nvend_id vend_name vend_address vend_city a001 tencent cloud address1 shenzhen a002 huawei cloud address2 dongguan a003 aliyun cloud address3 hangzhou a003 netease cloud address4 guangzhou 【问题】编写 SQL 语句,从 Vendors 表中检索 vend_id、vend_name、vend_address 和 vend_city,将 vend_name 重命名为 vname,将 vend_city 重命名为 vcity,将 vend_address 重命名为 vaddress,按供应商名称对结果进行升序排序。\n答案:\nselect vend_id, vend_name as vname, vend_address as vaddress, vend_city as vcity from Vendors order by vname; # as 可以省略 select vend_id, vend_name vname, vend_address vaddress, vend_city vcity from Vendors order by vname; 打折 # 我们的示例商店正在进行打折促销,所有产品均降价 10%。Products 表包含 prod_id 产品 id、prod_price 产品价格。\n【问题】编写 SQL 语句,从 Products 表中返回 prod_id、prod_price 和 sale_price。sale_price 是一个包含促销价格的计算字段。提示:可以乘以 0.9,得到原价的 90%(即 10%的折扣)。\n答案:\nselect prod_id, prod_price, prod_price * 0.9 as sale_price from Products; 注意:sale_price 是对计算结果的命名,而不是原有的列名。\n使用函数处理数据 # 顾客登录名 # 我们的商店已经上线了,正在创建顾客账户。所有用户都需要登录名,默认登录名是其名称和所在城市的组合。\n给出 Customers 表 如下:\ncust_id cust_name cust_contact cust_city a1 Andy Li Andy Li Oak Park a2 Ben Liu Ben Liu Oak Park a3 Tony Dai Tony Dai Oak Park a4 Tom Chen Tom Chen Oak Park a5 An Li An Li Oak Park a6 Lee Chen Lee Chen Oak Park a7 Hex Liu Hex Liu Oak Park 【问题】编写 SQL 语句,返回顾客 ID(cust_id)、顾客名称(cust_name)和登录名(user_login),其中登录名全部为大写字母,并由顾客联系人的前两个字符(cust_contact)和其所在城市的前三个字符(cust_city)组成。提示:需要使用函数、拼接和别名。\n答案:\nselect cust_id, cust_name, upper(concat(substring(cust_contact, 1, 2), substring(cust_city, 1, 3))) as user_login from Customers; 知识点:\n截取函数substring():截取字符串,substring(str ,n ,m):返回字符串 str 从第 n 个字符截取到第 m 个字符(左闭右闭);\n返回字符串 str 从第 n 个字符截取 m 个字符(左闭右闭)\n拼接函数concat():将两个或多个字符串连接成一个字符串,select concat(A,B) :连接字符串 A 和 B。\n大写函数 upper():将指定字符串转换为大写。\n返回 2020 年 1 月的所有订单的订单号和订单日期 # Orders 订单表如下:\norder_num order_date a0001 2020-01-01 00:00:00 a0002 2020-01-02 00:00:00 a0003 2020-01-01 12:00:00 a0004 2020-02-01 00:00:00 a0005 2020-03-01 00:00:00 【问题】编写 SQL 语句,返回 2020 年 1 月的所有订单的订单号(order_num)和订单日期(order_date),并按订单日期升序排序\n答案:\nselect order_num, order_date from Orders where month(order_date) = \u0026#39;01\u0026#39; and year(order_date) = \u0026#39;2020\u0026#39; order by order_date; 也可以用通配符来做:\nselect order_num, order_date from Orders where order_date like \u0026#39;2020-01%\u0026#39; order by order_date; 知识点:\n日期格式:YYYY-MM-DD 时间格式:HH:MM:SS 日期和时间处理相关的常用函数:\n函 数 说 明 adddate() 增加一个日期(天、周等) addtime() 增加一个时间(时、分等) curdate() 返回当前日期 curtime() 返回当前时间 date() 返回日期时间的日期部分 datediff() 计算两个日期之差 date_format() 返回一个格式化的日期或时间串 day() 返回一个日期的天数部分 dayofweek() 对于一个日期,返回对应的星期几 hour() 返回一个时间的小时部分 minute() 返回一个时间的分钟部分 month() 返回一个日期的月份部分 now() 返回当前日期和时间 second() 返回一个时间的秒部分 time() 返回一个日期时间的时间部分 year() 返回一个日期的年份部分 汇总数据 # 汇总数据相关的函数:\n函 数 说 明 avg() 返回某列的平均值 count() 返回某列的行数 max() 返回某列的最大值 min() 返回某列的最小值 sum() 返回某列值之和 确定已售出产品的总数 # OrderItems 表代表售出的产品,quantity 代表售出商品数量。\nquantity 10 100 1000 10001 2 15 【问题】编写 SQL 语句,确定已售出产品的总数。\n答案:\nselect sum(quantity) as items_ordered from OrderItems; 确定已售出产品项 BR01 的总数 # OrderItems 表代表售出的产品,quantity 代表售出商品数量,产品项为 prod_id。\nquantity prod_id 10 AR01 100 AR10 1000 BR01 10001 BR010 【问题】修改创建的语句,确定已售出产品项(prod_id)为\u0026quot;BR01\u0026quot;的总数。\n答案:\nselect sum(quantity) as items_ordered from OrderItems where prod_id = \u0026#39;BR01\u0026#39;; 确定 Products 表中价格不超过 10 美元的最贵产品的价格 # Products 表如下,prod_price 代表商品的价格。\nprod_price 9.49 600 1000 【问题】编写 SQL 语句,确定 Products 表中价格不超过 10 美元的最贵产品的价格(prod_price)。将计算所得的字段命名为 max_price。\n答案:\nselect max(prod_price) as max_price from Products where prod_price \u0026lt;= 10; 分组数据 # group by :\ngroup by 子句将记录分组到汇总行中。 group by 为每个组返回一个记录。 group by 通常还涉及聚合count,max,sum,avg 等。 group by 可以按一列或多列进行分组。 group by 按分组字段进行排序后,order by 可以以汇总字段来进行排序。 having:\nhaving 用于对汇总的 group by 结果进行过滤。 having 必须要与 group by 连用。 where 和 having 可以在相同的查询中。 having vs where:\nwhere:过滤过滤指定的行,后面不能加聚合函数(分组函数)。 having:过滤分组,必须要与 group by 连用,不能单独使用。 返回每个订单号各有多少行数 # OrderItems 表包含每个订单的每个产品\norder_num a002 a002 a002 a004 a007 【问题】编写 SQL 语句,返回每个订单号(order_num)各有多少行数(order_lines),并按 order_lines 对结果进行升序排序。\n答案:\nselect order_num, count(order_num) as order_lines from OrderItems group by order_num order by order_lines; 知识点:\ncount(*),count(列名)都可以,区别在于,count(列名)是统计非 NULL 的行数; order by 最后执行,所以可以使用列别名; 分组聚合一定不要忘记加上 group by ,不然只会有一行结果。 每个供应商成本最低的产品 # 有 Products 表,含有字段 prod_price 代表产品价格,vend_id 代表供应商 id\nvend_id prod_price a0011 100 a0019 0.1 b0019 1000 b0019 6980 b0019 20 【问题】编写 SQL 语句,返回名为 cheapest_item 的字段,该字段包含每个供应商成本最低的产品(使用 Products 表中的 prod_price),然后从最低成本到最高成本对结果进行升序排序。\n答案:\nselect vend_id, min(prod_price) as cheapest_item from Products group by vend_id order by cheapest_item; 返回订单数量总和不小于 100 的所有订单的订单号 # OrderItems 代表订单商品表,包括:订单号 order_num 和订单数量 quantity。\norder_num quantity a1 105 a2 1100 a2 200 a4 1121 a5 10 a2 19 a7 5 【问题】请编写 SQL 语句,返回订单数量总和不小于 100 的所有订单号,最后结果按照订单号升序排序。\n答案:\n# 直接聚合 select order_num from OrderItems group by order_num having sum(quantity) \u0026gt;= 100 order by order_num; # 子查询 select order_num from (select order_num, sum(quantity) as sum_num from OrderItems group by order_num having sum_num \u0026gt;= 100 ) a order by order_num; 知识点:\nwhere:过滤过滤指定的行,后面不能加聚合函数(分组函数)。 having:过滤分组,与 group by 连用,不能单独使用。 计算总和 # OrderItems 表代表订单信息,包括字段:订单号 order_num 和 item_price 商品售出价格、quantity 商品数量。\norder_num item_price quantity a1 10 105 a2 1 1100 a2 1 200 a4 2 1121 a5 5 10 a2 1 19 a7 7 5 【问题】编写 SQL 语句,根据订单号聚合,返回订单总价不小于 1000 的所有订单号,最后的结果按订单号进行升序排序。\n提示:总价 = item_price 乘以 quantity\n答案:\nselect order_num, sum(item_price * quantity) as total_price from OrderItems group by order_num having total_price \u0026gt;= 1000 order by order_num; 检查 SQL 语句 # OrderItems 表含有 order_num 订单号\norder_num a002 a002 a002 a004 a007 【问题】将下面代码修改正确后执行\nSELECT order_num, COUNT(*) AS items FROM OrderItems GROUP BY items HAVING COUNT(*) \u0026gt;= 3 ORDER BY items, order_num; 修改后:\nSELECT order_num, COUNT(*) AS items FROM OrderItems GROUP BY order_num HAVING items \u0026gt;= 3 ORDER BY items, order_num; 使用子查询 # 子查询是嵌套在较大查询中的 SQL 查询,也称内部查询或内部选择,包含子查询的语句也称为外部查询或外部选择。简单来说,子查询就是指将一个 select 查询(子查询)的结果作为另一个 SQL 语句(主查询)的数据来源或者判断条件。\n子查询可以嵌入 select、insert、update 和 delete 语句中,也可以和 =、\u0026lt;、\u0026gt;、in、between、exists 等运算符一起使用。\n子查询常用在 where 子句和 from 子句后边:\n当用于 where 子句时,根据不同的运算符,子查询可以返回单行单列、多行单列、单行多列数据。子查询就是要返回能够作为 WHERE 子句查询条件的值。 当用于 from 子句时,一般返回多行多列数据,相当于返回一张临时表,这样才符合 from 后面是表的规则。这种做法能够实现多表联合查询。 注意:MySQL 数据库从 4.1 版本才开始支持子查询,早期版本是不支持的。\n用于 where 子句的子查询的基本语法如下:\nselect column_name [, column_name ] from table1 [, table2 ] where column_name operator (select column_name [, column_name ] from table1 [, table2 ] [where]) 子查询需要放在括号( )内。 operator 表示用于 where 子句的运算符。 用于 from 子句的子查询的基本语法如下:\nselect column_name [, column_name ] from (select column_name [, column_name ] from table1 [, table2 ] [where]) as temp_table_name where condition 用于 from 的子查询返回的结果相当于一张临时表,所以需要使用 AS 关键字为该临时表起一个名字。\n返回购买价格为 10 美元或以上产品的顾客列表 # OrderItems` 表示订单商品表,含有字段 订单号:`order_num`、 订单价格:`item_price`; `Orders` 表代表订单信息表,含有 顾客 `id:cust_id` 和 订单号:`order_num OrderItems 表:\norder_num item_price a1 10 a2 1 a2 1 a4 2 a5 5 a2 1 a7 7 Orders 表:\norder_num cust_id a1 cust10 a2 cust1 a2 cust1 a4 cust2 a5 cust5 a2 cust1 a7 cust7 【问题】使用子查询,返回购买价格为 10 美元或以上产品的顾客列表,结果无需排序。\n答案:\nselect cust_id from Orders where order_num in ( select order_num from OrderItems group by order_num having sum(item_price) \u0026gt;= 10 ); 确定哪些订单购买了 prod_id 为 BR01 的产品(一) # 表 OrderItems 代表订单商品信息表,prod_id 为产品 id;Orders 表代表订单表有 cust_id 代表顾客 id 和订单日期 order_date\nOrderItems 表:\nprod_id order_num BR01 a0001 BR01 a0002 BR02 a0003 BR02 a0013 Orders 表:\norder_num cust_id order_date a0001 cust10 2022-01-01 00:00:00 a0002 cust1 2022-01-01 00:01:00 a0003 cust1 2022-01-02 00:00:00 a0013 cust2 2022-01-01 00:20:00 【问题】\n编写 SQL 语句,使用子查询来确定哪些订单(在 OrderItems 中)购买了 prod_id 为 \u0026ldquo;BR01\u0026rdquo; 的产品,然后从 Orders 表中返回每个产品对应的顾客 ID(cust_id)和订单日期(order_date),按订购日期对结果进行升序排序。\n答案:\n# 写法 1:子查询 select cust_id, order_date from Orders where order_num in ( select order_num from OrderItems where prod_id = \u0026#39;BR01\u0026#39; ) order by order_date; # 写法 2: 连接表 select b.cust_id, b.order_date from OrderItems a, Orders b where a.order_num = b.order_num and a.prod_id = \u0026#39;BR01\u0026#39; order by order_date; 返回购买 prod_id 为 BR01 的产品的所有顾客的电子邮件(一) # 你想知道订购 BR01 产品的日期,有表 OrderItems 代表订单商品信息表,prod_id 为产品 id;Orders 表代表订单表有 cust_id 代表顾客 id 和订单日期 order_date;Customers 表含有 cust_email 顾客邮件和 cust_id 顾客 id\nOrderItems 表:\nprod_id order_num BR01 a0001 BR01 a0002 BR02 a0003 BR02 a0013 Orders 表:\norder_num cust_id order_date a0001 cust10 2022-01-01 00:00:00 a0002 cust1 2022-01-01 00:01:00 a0003 cust1 2022-01-02 00:00:00 a0013 cust2 2022-01-01 00:20:00 Customers 表代表顾客信息,cust_id 为顾客 id,cust_email 为顾客 email\ncust_id cust_email cust10 cust10@cust.com cust1 cust1@cust.com cust2 cust2@cust.com 【问题】返回购买 prod_id 为 BR01 的产品的所有顾客的电子邮件(Customers 表中的 cust_email),结果无需排序。\n提示:这涉及 SELECT 语句,最内层的从 OrderItems 表返回 order_num,中间的从 Customers 表返回 cust_id。\n答案:\n# 写法 1:子查询 select cust_email from Customers where cust_id in ( select cust_id from Orders where order_num in ( select order_num from OrderItems where prod_id = \u0026#39;BR01\u0026#39; ) ); # 写法 2: 连接表(inner join) select c.cust_email from OrderItems a, Orders b, Customers c where a.order_num = b.order_num and b.cust_id = c.cust_id and a.prod_id = \u0026#39;BR01\u0026#39;; # 写法 3:连接表(left join) select c.cust_email from Orders a left join OrderItems b on a.order_num = b.order_num left join Customers c on a.cust_id = c.cust_id where b.prod_id = \u0026#39;BR01\u0026#39;; 返回每个顾客不同订单的总金额 # 我们需要一个顾客 ID 列表,其中包含他们已订购的总金额。\nOrderItems 表代表订单信息,OrderItems 表有订单号:order_num 和商品售出价格:item_price、商品数量:quantity。\norder_num item_price quantity a0001 10 105 a0002 1 1100 a0002 1 200 a0013 2 1121 a0003 5 10 a0003 1 19 a0003 7 5 Orders` 表订单号:`order_num`、顾客 id:`cust_id order_num cust_id a0001 cust10 a0002 cust1 a0003 cust1 a0013 cust2 【问题】\n编写 SQL 语句,返回顾客 ID(Orders 表中的 cust_id),并使用子查询返回 total_ordered 以便返回每个顾客的订单总数,将结果按金额从大到小排序。\n答案:\n# 写法 1:子查询 SELECT o.cust_id cust_id, tb.total_ordered total_ordered FROM ( SELECT order_num, SUM(item_price * quantity) total_ordered FROM OrderItems GROUP BY order_num ) as tb, Orders o WHERE tb.order_num = o.order_num ORDER BY total_ordered DESC; # 写法 2:连接表 select b.cust_id, sum(a.quantity * a.item_price) as total_ordered from OrderItems a, Orders b where a.order_num = b.order_num group by cust_id order by total_ordered desc; 从 Products 表中检索所有的产品名称以及对应的销售总数 # Products` 表中检索所有的产品名称:`prod_name`、产品 id:`prod_id prod_id prod_name a0001 egg a0002 sockets a0013 coffee a0003 cola OrderItems` 代表订单商品表,订单产品:`prod_id`、售出数量:`quantity prod_id quantity a0001 105 a0002 1100 a0002 200 a0013 1121 a0003 10 a0003 19 a0003 5 【问题】\n编写 SQL 语句,从 Products 表中检索所有的产品名称(prod_name),以及名为 quant_sold 的计算列,其中包含所售产品的总数(在 OrderItems 表上使用子查询和 SUM(quantity) 检索)。\n答案:\n# 写法 1:子查询 select p.prod_name, tb.quant_sold from ( select prod_id, sum(quantity) as quant_sold from OrderItems group by prod_id ) as tb, Products p where tb.prod_id = p.prod_id; # 写法 2:连接表 select p.prod_name, sum(o.quantity) as quant_sold from Products p, OrderItems o where p.prod_id = o.prod_id group by p.prod_name;(这里不能用 p.prod_id,会报错) 连接表 # JOIN 是“连接”的意思,顾名思义,SQL JOIN 子句用于将两个或者多个表联合起来进行查询。\n连接表时需要在每个表中选择一个字段,并对这些字段的值进行比较,值相同的两条记录将合并为一条。连接表的本质就是将不同表的记录合并起来,形成一张新表。当然,这张新表只是临时的,它仅存在于本次查询期间。\n使用 JOIN 连接两个表的基本语法如下:\nselect table1.column1, table2.column2... from table1 join table2 on table1.common_column1 = table2.common_column2; table1.common_column1 = table2.common_column2 是连接条件,只有满足此条件的记录才会合并为一行。您可以使用多个运算符来连接表,例如 =、\u0026gt;、\u0026lt;、\u0026lt;\u0026gt;、\u0026lt;=、\u0026gt;=、!=、between、like 或者 not,但是最常见的是使用 =。\n当两个表中有同名的字段时,为了帮助数据库引擎区分是哪个表的字段,在书写同名字段名时需要加上表名。当然,如果书写的字段名在两个表中是唯一的,也可以不使用以上格式,只写字段名即可。\n另外,如果两张表的关联字段名相同,也可以使用 USING子句来代替 ON,举个例子:\n# join....on select c.cust_name, o.order_num from Customers c inner join Orders o on c.cust_id = o.cust_id order by c.cust_name; # 如果两张表的关联字段名相同,也可以使用USING子句:join....using() select c.cust_name, o.order_num from Customers c inner join Orders o using(cust_id) order by c.cust_name; ON 和 WHERE 的区别:\n连接表时,SQL 会根据连接条件生成一张新的临时表。ON 就是连接条件,它决定临时表的生成。 WHERE 是在临时表生成以后,再对临时表中的数据进行过滤,生成最终的结果集,这个时候已经没有 JOIN-ON 了。 所以总结来说就是:SQL 先根据 ON 生成一张临时表,然后再根据 WHERE 对临时表进行筛选。\nSQL 允许在 JOIN 左边加上一些修饰性的关键词,从而形成不同类型的连接,如下表所示:\n连接类型 说明 INNER JOIN 内连接 (默认连接方式)只有当两个表都存在满足条件的记录时才会返回行。 LEFT JOIN / LEFT OUTER JOIN 左(外)连接 返回左表中的所有行,即使右表中没有满足条件的行也是如此。 RIGHT JOIN / RIGHT OUTER JOIN 右(外)连接 返回右表中的所有行,即使左表中没有满足条件的行也是如此。 FULL JOIN / FULL OUTER JOIN 全(外)连接 只要其中有一个表存在满足条件的记录,就返回行。 SELF JOIN 将一个表连接到自身,就像该表是两个表一样。为了区分两个表,在 SQL 语句中需要至少重命名一个表。 CROSS JOIN 交叉连接,从两个或者多个连接表中返回记录集的笛卡尔积。 下图展示了 LEFT JOIN、RIGHT JOIN、INNER JOIN、OUTER JOIN 相关的 7 种用法。\n如果不加任何修饰词,只写 JOIN,那么默认为 INNER JOIN\n对于 INNER JOIN 来说,还有一种隐式的写法,称为 “隐式内连接”,也就是没有 INNER JOIN 关键字,使用 WHERE 语句实现内连接的功能\n# 隐式内连接 select c.cust_name, o.order_num from Customers c, Orders o where c.cust_id = o.cust_id order by c.cust_name; # 显式内连接 select c.cust_name, o.order_num from Customers c inner join Orders o using(cust_id) order by c.cust_name; 返回顾客名称和相关订单号 # Customers` 表有字段顾客名称 `cust_name`、顾客 id `cust_id cust_id cust_name cust10 andy cust1 ben cust2 tony cust22 tom cust221 an cust2217 hex Orders 订单信息表,含有字段 order_num 订单号、cust_id 顾客 id\norder_num cust_id a1 cust10 a2 cust1 a3 cust2 a4 cust22 a5 cust221 a7 cust2217 【问题】编写 SQL 语句,返回 Customers 表中的顾客名称(cust_name)和 Orders 表中的相关订单号(order_num),并按顾客名称再按订单号对结果进行升序排序。你可以尝试用两个不同的写法,一个使用简单的等连接语法,另外一个使用 INNER JOIN。\n答案:\n# 隐式内连接 select c.cust_name, o.order_num from Customers c, Orders o where c.cust_id = o.cust_id order by c.cust_name; # 显式内连接 select c.cust_name, o.order_num from Customers c inner join Orders o using(cust_id) order by c.cust_name; 返回顾客名称和相关订单号以及每个订单的总价 # Customers` 表有字段,顾客名称:`cust_name`、顾客 id:`cust_id cust_id cust_name cust10 andy cust1 ben cust2 tony cust22 tom cust221 an cust2217 hex Orders` 订单信息表,含有字段,订单号:`order_num`、顾客 id:`cust_id order_num cust_id a1 cust10 a2 cust1 a3 cust2 a4 cust22 a5 cust221 a7 cust2217 OrderItems` 表有字段,商品订单号:`order_num`、商品数量:`quantity`、商品价格:`item_price order_num quantity item_price a1 1000 10 a2 200 10 a3 10 15 a4 25 50 a5 15 25 a7 7 7 【问题】除了返回顾客名称和订单号,返回 Customers 表中的顾客名称(cust_name)和 Orders 表中的相关订单号(order_num),添加第三列 OrderTotal,其中包含每个订单的总价,并按顾客名称再按订单号对结果进行升序排序。\n# 简单的等连接语法 select c.cust_name, o.order_num, sum(quantity * item_price) as OrderTotal from Customers c, Orders o, OrderItems oi where c.cust_id = o.cust_id and o.order_num = oi.order_num group by c.cust_name, o.order_num order by c.cust_name, o.order_num; 注意,可能有小伙伴会这样写:\nselect c.cust_name, o.order_num, sum(quantity * item_price) as OrderTotal from Customers c, Orders o, OrderItems oi where c.cust_id = o.cust_id and o.order_num = oi.order_num group by c.cust_name order by c.cust_name, o.order_num; 这是错误的!只对 cust_name 进行聚类确实符合题意,但是不符合 group by 的语法。\nselect 语句中,如果没有 group by 语句,那么 cust_name、order_num 会返回若干个值,而 sum(quantity _ item_price) 只返回一个值,通过 group by cust_name 可以让 cust_name 和 sum(quantity _ item_price) 一一对应起来,或者说聚类,所以同样的,也要对 order_num 进行聚类。\n一句话,select 中的字段要么都聚类,要么都不聚类\n确定哪些订单购买了 prod_id 为 BR01 的产品(二) # 表 OrderItems 代表订单商品信息表,prod_id 为产品 id;Orders 表代表订单表有 cust_id 代表顾客 id 和订单日期 order_date\nOrderItems 表:\nprod_id order_num BR01 a0001 BR01 a0002 BR02 a0003 BR02 a0013 Orders 表:\norder_num cust_id order_date a0001 cust10 2022-01-01 00:00:00 a0002 cust1 2022-01-01 00:01:00 a0003 cust1 2022-01-02 00:00:00 a0013 cust2 2022-01-01 00:20:00 【问题】\n编写 SQL 语句,使用子查询来确定哪些订单(在 OrderItems 中)购买了 prod_id 为 \u0026ldquo;BR01\u0026rdquo; 的产品,然后从 Orders 表中返回每个产品对应的顾客 ID(cust_id)和订单日期(order_date),按订购日期对结果进行升序排序。\n提示:这一次使用连接和简单的等连接语法。\n# 写法 1:子查询 select cust_id, order_date from Orders where order_num in ( select order_num from OrderItems where prod_id = \u0026#39;BR01\u0026#39; ) order by order_date; # 写法 2:连接表 inner join select cust_id, order_date from Orders o inner join ( select order_num from OrderItems where prod_id = \u0026#39;BR01\u0026#39; ) tb on o.order_num = tb.order_num order by order_date; # 写法 3:写法 2 的简化版 select cust_id, order_date from Orders inner join OrderItems using(order_num) where OrderItems.prod_id = \u0026#39;BR01\u0026#39; order by order_date; 返回购买 prod_id 为 BR01 的产品的所有顾客的电子邮件(二) # 有表 OrderItems 代表订单商品信息表,prod_id 为产品 id;Orders 表代表订单表有 cust_id 代表顾客 id 和订单日期 order_date;Customers 表含有 cust_email 顾客邮件和 cust_id 顾客 id\nOrderItems 表:\nprod_id order_num BR01 a0001 BR01 a0002 BR02 a0003 BR02 a0013 Orders 表:\norder_num cust_id order_date a0001 cust10 2022-01-01 00:00:00 a0002 cust1 2022-01-01 00:01:00 a0003 cust1 2022-01-02 00:00:00 a0013 cust2 2022-01-01 00:20:00 Customers 表代表顾客信息,cust_id 为顾客 id,cust_email 为顾客 email\ncust_id cust_email cust10 cust10@cust.com cust1 cust1@cust.com cust2 cust2@cust.com 【问题】返回购买 prod_id 为 BR01 的产品的所有顾客的电子邮件(Customers 表中的 cust_email),结果无需排序。\n提示:涉及到 SELECT 语句,最内层的从 OrderItems 表返回 order_num,中间的从 Customers 表返回 cust_id,但是必须使用 INNER JOIN 语法。\nselect cust_email from Customers inner join Orders using(cust_id) inner join OrderItems using(order_num) where OrderItems.prod_id = \u0026#39;BR01\u0026#39;; 确定最佳顾客的另一种方式(二) # OrderItems 表代表订单信息,确定最佳顾客的另一种方式是看他们花了多少钱,OrderItems 表有订单号 order_num 和 item_price 商品售出价格、quantity 商品数量\norder_num item_price quantity a1 10 105 a2 1 1100 a2 1 200 a4 2 1121 a5 5 10 a2 1 19 a7 7 5 Orders 表含有字段 order_num 订单号、cust_id 顾客 id\norder_num cust_id a1 cust10 a2 cust1 a3 cust2 a4 cust22 a5 cust221 a7 cust2217 顾客表 Customers 有字段 cust_id 客户 id、cust_name 客户姓名\ncust_id cust_name cust10 andy cust1 ben cust2 tony cust22 tom cust221 an cust2217 hex 【问题】编写 SQL 语句,返回订单总价不小于 1000 的客户名称和总额(OrderItems 表中的 order_num)。\n提示:需要计算总和(item_price 乘以 quantity)。按总额对结果进行排序,请使用 INNER JOIN 语法。\nselect cust_name, sum(item_price * quantity) as total_price from Customers inner join Orders using(cust_id) inner join OrderItems using(order_num) group by cust_name having total_price \u0026gt;= 1000 order by total_price; 创建高级连接 # 检索每个顾客的名称和所有的订单号(一) # Customers` 表代表顾客信息含有顾客 id `cust_id` 和 顾客名称 `cust_name cust_id cust_name cust10 andy cust1 ben cust2 tony cust22 tom cust221 an cust2217 hex Orders` 表代表订单信息含有订单号 `order_num` 和顾客 id `cust_id order_num cust_id a1 cust10 a2 cust1 a3 cust2 a4 cust22 a5 cust221 a7 cust2217 【问题】使用 INNER JOIN 编写 SQL 语句,检索每个顾客的名称(Customers 表中的 cust_name)和所有的订单号(Orders 表中的 order_num),最后根据顾客姓名 cust_name 升序返回。\nselect cust_name, order_num from Customers inner join Orders using(cust_id) order by cust_name; 检索每个顾客的名称和所有的订单号(二) # Orders` 表代表订单信息含有订单号 `order_num` 和顾客 id `cust_id order_num cust_id a1 cust10 a2 cust1 a3 cust2 a4 cust22 a5 cust221 a7 cust2217 Customers` 表代表顾客信息含有顾客 id `cust_id` 和 顾客名称 `cust_name cust_id cust_name cust10 andy cust1 ben cust2 tony cust22 tom cust221 an cust2217 hex cust40 ace 【问题】检索每个顾客的名称(Customers 表中的 cust_name)和所有的订单号(Orders 表中的 order_num),列出所有的顾客,即使他们没有下过订单。最后根据顾客姓名 cust_name 升序返回。\nselect cust_name, order_num from Customers left join Orders using(cust_id) order by cust_name; 返回产品名称和与之相关的订单号 # Products 表为产品信息表含有字段 prod_id 产品 id、prod_name 产品名称\nprod_id prod_name a0001 egg a0002 sockets a0013 coffee a0003 cola a0023 soda OrderItems` 表为订单信息表含有字段 `order_num` 订单号和产品 id `prod_id prod_id order_num a0001 a105 a0002 a1100 a0002 a200 a0013 a1121 a0003 a10 a0003 a19 a0003 a5 【问题】使用外连接(left join、 right join、full join)联结 Products 表和 OrderItems 表,返回产品名称(prod_name)和与之相关的订单号(order_num)的列表,并按照产品名称升序排序。\nselect prod_name, order_num from Products left join OrderItems using(prod_id) order by prod_name; 返回产品名称和每一项产品的总订单数 # Products 表为产品信息表含有字段 prod_id 产品 id、prod_name 产品名称\nprod_id prod_name a0001 egg a0002 sockets a0013 coffee a0003 cola a0023 soda OrderItems` 表为订单信息表含有字段 `order_num` 订单号和产品 id `prod_id prod_id order_num a0001 a105 a0002 a1100 a0002 a200 a0013 a1121 a0003 a10 a0003 a19 a0003 a5 【问题】\n使用 OUTER JOIN 联结 Products 表和 OrderItems 表,返回产品名称(prod_name)和每一项产品的总订单数(不是订单号),并按产品名称升序排序。\nselect prod_name, count(order_num) as orders from Products left join OrderItems using(prod_id) group by prod_name order by prod_name; 列出供应商及其可供产品的数量 # 有 Vendors 表含有 vend_id (供应商 id)\nvend_id a0002 a0013 a0003 a0010 有 Products 表含有 vend_id(供应商 id)和 prod_id(供应产品 id)\nvend_id prod_id a0001 egg a0002 prod_id_iphone a00113 prod_id_tea a0003 prod_id_vivo phone a0010 prod_id_huawei phone 【问题】列出供应商(Vendors 表中的 vend_id)及其可供产品的数量,包括没有产品的供应商。你需要使用 OUTER JOIN 和 COUNT()聚合函数来计算 Products 表中每种产品的数量,最后根据 vend_id 升序排序。\n注意:vend_id 列会显示在多个表中,因此在每次引用它时都需要完全限定它。\nselect vend_id, count(prod_id) as prod_id from Vendors left join Products using(vend_id) group by vend_id order by vend_id; 组合查询 # UNION 运算符将两个或更多查询的结果组合起来,并生成一个结果集,其中包含来自 UNION 中参与查询的提取行。\nUNION 基本规则:\n所有查询的列数和列顺序必须相同。 每个查询中涉及表的列的数据类型必须相同或兼容。 通常返回的列名取自第一个查询。 默认地,UNION 操作符选取不同的值。如果允许重复的值,请使用 UNION ALL。\nSELECT column_name(s) FROM table1 UNION ALL SELECT column_name(s) FROM table2; UNION 结果集中的列名总是等于 UNION 中第一个 SELECT 语句中的列名。\nJOIN vs UNION:\nJOIN 中连接表的列可能不同,但在 UNION 中,所有查询的列数和列顺序必须相同。 UNION 将查询之后的行放在一起(垂直放置),但 JOIN 将查询之后的列放在一起(水平放置),即它构成一个笛卡尔积。 将两个 SELECT 语句结合起来(一) # 表 OrderItems 包含订单产品信息,字段 prod_id 代表产品 id、quantity 代表产品数量\nprod_id quantity a0001 105 a0002 100 a0002 200 a0013 1121 a0003 10 a0003 19 a0003 5 BNBG 10002 【问题】将两个 SELECT 语句结合起来,以便从 OrderItems 表中检索产品 id(prod_id)和 quantity。其中,一个 SELECT 语句过滤数量为 100 的行,另一个 SELECT 语句过滤 id 以 BNBG 开头的产品,最后按产品 id 对结果进行升序排序。\nselect prod_id, quantity from OrderItems where quantity = 100 union select prod_id, quantity from OrderItems where prod_id like \u0026#39;BNBG%\u0026#39;; 将两个 SELECT 语句结合起来(二) # 表 OrderItems 包含订单产品信息,字段 prod_id 代表产品 id、quantity 代表产品数量。\nprod_id quantity a0001 105 a0002 100 a0002 200 a0013 1121 a0003 10 a0003 19 a0003 5 BNBG 10002 【问题】将两个 SELECT 语句结合起来,以便从 OrderItems 表中检索产品 id(prod_id)和 quantity。其中,一个 SELECT 语句过滤数量为 100 的行,另一个 SELECT 语句过滤 id 以 BNBG 开头的产品,最后按产品 id 对结果进行升序排序。 注意:这次仅使用单个 SELECT 语句。\n答案:\n要求只用一条 select 语句,那就用 or 不用 union 了。\nselect prod_id, quantity from OrderItems where quantity = 100 or prod_id like \u0026#39;BNBG%\u0026#39;; 组合 Products 表中的产品名称和 Customers 表中的顾客名称 # Products 表含有字段 prod_name 代表产品名称\nprod_name flower rice ring umbrella Customers 表代表顾客信息,cust_name 代表顾客名称\ncust_name andy ben tony tom an lee hex 【问题】编写 SQL 语句,组合 Products 表中的产品名称(prod_name)和 Customers 表中的顾客名称(cust_name)并返回,然后按产品名称对结果进行升序排序。\n# UNION 结果集中的列名总是等于 UNION 中第一个 SELECT 语句中的列名。 select prod_name from Products union select cust_name from Customers order by prod_name; 检查 SQL 语句 # 表 Customers 含有字段 cust_name 顾客名、cust_contact 顾客联系方式、cust_state 顾客州、cust_email 顾客 email\ncust_name cust_contact cust_state cust_email cust10 8695192 MI cust10@cust.com cust1 8695193 MI cust1@cust.com cust2 8695194 IL cust2@cust.com 【问题】修正下面错误的 SQL\nSELECT cust_name, cust_contact, cust_email FROM Customers WHERE cust_state = \u0026#39;MI\u0026#39; ORDER BY cust_name; UNION SELECT cust_name, cust_contact, cust_email FROM Customers WHERE cust_state = \u0026#39;IL\u0026#39;ORDER BY cust_name; 修正后:\nSELECT cust_name, cust_contact, cust_email FROM Customers WHERE cust_state = \u0026#39;MI\u0026#39; UNION SELECT cust_name, cust_contact, cust_email FROM Customers WHERE cust_state = \u0026#39;IL\u0026#39; ORDER BY cust_name; 使用 union 组合查询时,只能使用一条 order by 字句,他必须位于最后一条 select 语句之后\n或者直接用 or 来做:\nSELECT cust_name, cust_contact, cust_email FROM Customers WHERE cust_state = \u0026#39;MI\u0026#39; or cust_state = \u0026#39;IL\u0026#39; ORDER BY cust_name; "},{"id":240,"href":"/zh/docs/technology/Review/java_guide/database/ly0504lysql-syntax-summary/","title":"sql语法基础知识总结","section":"数据库","content":" 转载自https://github.com/Snailclimb/JavaGuide(添加小部分笔记)感谢作者!\n本文整理完善自下面这两份资料:\nSQL 语法速成手册 MySQL 超全教程 基本概念 # 数据库术语 # 数据库(database) - 保存有组织的数据的容器(通常是一个文件或一组文件)。 数据表(table) - 某种特定类型数据的结构化清单。 模式(schema) - 关于数据库和表的布局及特性的信息。模式定义了数据在表中如何存储,包含存储什么样的数据,数据如何分解,各部分信息如何命名等信息。数据库和表都有模式。 列(column) - 表中的一个字段。所有表都是由一个或多个列组成的。 行(row) - 表中的一个记录。 主键(primary key) - 一列(或一组列),其值能够唯一标识表中每一行。 SQL 语法 # SQL(Structured Query Language),标准 SQL 由 ANSI 标准委员会管理,从而称为 ANSI SQL。各个 DBMS 都有自己的实现,如 PL/SQL、Transact-SQL 等。\nSQL 语法结构 # SQL 语法结构包括:\n子句 - 是语句和查询的组成成分。(在某些情况下,这些都是可选的。) 表达式 - 可以产生任何标量值,或由列和行的数据库表 谓词 - 给需要评估的 SQL 三值逻辑(3VL)(true/false/unknown)或布尔真值指定条件,并限制语句和查询的效果,或改变程序流程。 查询 - 基于特定条件检索数据。这是 SQL 的一个重要组成部分。 语句 - 可以持久地影响纲要和数据,也可以控制数据库事务、程序流程、连接、会话或诊断。 SQL 语法要点 # SQL 语句不区分大小写,但是数据库表名、列名和值是否区分,依赖于具体的 DBMS 以及配置。例如:SELECT 与 select 、Select 是相同的。 多条 SQL 语句必须以分号(;)分隔。 处理 SQL 语句时,所有空格都被忽略。 SQL 语句可以写成一行,也可以分写为多行。\n-- 一行 SQL 语句 UPDATE user SET username=\u0026#39;robot\u0026#39;, password=\u0026#39;robot\u0026#39; WHERE username = \u0026#39;root\u0026#39;; -- 多行 SQL 语句 UPDATE user SET username=\u0026#39;robot\u0026#39;, password=\u0026#39;robot\u0026#39; WHERE username = \u0026#39;root\u0026#39;; SQL 支持三种注释:\n## 注释1 -- 注释2 /* 注释3 */ SQL 分类 # 数据定义语言(DDL) # 数据定义语言(Data Definition Language,DDL)是 SQL 语言集中负责数据结构定义与数据库对象定义的语言。\nDDL 的主要功能是定义数据库对象。\nDDL 的核心指令是 CREATE、ALTER、DROP。\n数据操纵语言(DML) # 数据操纵语言(Data Manipulation Language, DML)是用于数据库操作,对数据库其中的对象和数据运行访问工作的编程语句。\nDML 的主要功能是 访问数据,因此其语法都是以读写数据库为主。\nDML 的核心指令是 INSERT、UPDATE、DELETE、SELECT。这四个指令合称 CRUD(Create, Read, Update, Delete),即增删改查。\n事务控制语言(TCL) # 事务控制语言 (Transaction Control Language, TCL) 用于管理数据库中的事务。这些用于管理由 DML 语句所做的更改。它还允许将语句分组为逻辑事务。\nTCL 的核心指令是 COMMIT、ROLLBACK。\n数据控制语言(DCL) # 数据控制语言 (Data Control Language, DCL) 是一种可对数据访问权进行控制的指令,它可以控制特定用户账户对数据表、查看表、预存程序、用户自定义函数等数据库对象的控制权。\nDCL 的核心指令是 GRANT、REVOKE。\nDCL 以控制用户的访问权限为主,因此其指令作法并不复杂,可利用 DCL 控制的权限有:CONNECT、SELECT、INSERT、UPDATE、DELETE、EXECUTE、USAGE、REFERENCES。\n根据不同的 DBMS 以及不同的安全性实体,其支持的权限控制也有所不同。\n我们先来介绍 DML 语句用法。 DML 的主要功能是读写数据库实现增删改查。\n增删改查 # 增删改查,又称为 CRUD,数据库基本操作中的基本操作。\n插入数据 # INSERT INTO 语句用于向表中插入新记录。\n插入完整的行\n# 插入一行 INSERT INTO user VALUES (10, \u0026#39;root\u0026#39;, \u0026#39;root\u0026#39;, \u0026#39;xxxx@163.com\u0026#39;); # 插入多行 INSERT INTO user VALUES (10, \u0026#39;root\u0026#39;, \u0026#39;root\u0026#39;, \u0026#39;xxxx@163.com\u0026#39;), (12, \u0026#39;user1\u0026#39;, \u0026#39;user1\u0026#39;, \u0026#39;xxxx@163.com\u0026#39;), (18, \u0026#39;user2\u0026#39;, \u0026#39;user2\u0026#39;, \u0026#39;xxxx@163.com\u0026#39;); 插入行的一部分\nINSERT INTO user(username, password, email) VALUES (\u0026#39;admin\u0026#39;, \u0026#39;admin\u0026#39;, \u0026#39;xxxx@163.com\u0026#39;); 插入查询出来的数据\nINSERT INTO user(username) SELECT name FROM account; 更新数据 # UPDATE 语句用于更新表中的记录。\nUPDATE user SET username=\u0026#39;robot\u0026#39;, password=\u0026#39;robot\u0026#39; WHERE username = \u0026#39;root\u0026#39;; 删除数据 # DELETE 语句用于删除表中的记录。 TRUNCATE TABLE 可以清空表,也就是删除所有行。 删除表中的指定数据\nDELETE FROM user WHERE username = \u0026#39;robot\u0026#39;; 清空表中的数据\nTRUNCATE TABLE user; 查询数据 # SELECT 语句用于从数据库中查询数据。\nDISTINCT 用于返回唯一不同的值。它作用于所有列,也就是说所有列的值都相同才算相同。\nLIMIT 限制返回的行数。可以有两个参数,第一个参数为起始行,从 0 开始;第二个参数为返回的总行数。\nASC :升序(默认) DESC :降序 查询单列\nSELECT prod_name FROM products; 查询多列\nSELECT prod_id, prod_name, prod_price FROM products; 查询所有列\nELECT * FROM products; 查询不同的值\nSELECT DISTINCT vend_id FROM products; 限制查询结果\n-- 返回前 5 行 SELECT * FROM mytable LIMIT 5; SELECT * FROM mytable LIMIT 0, 5; -- 返回第 3 ~ 5 行 SELECT * FROM mytable LIMIT 2, 3; 排序 # order by 用于对结果集按照一个列或者多个列进行排序。默认按照升序对记录进行排序,如果需要按照降序对记录进行排序,可以使用 desc 关键字。\norder by 对多列排序的时候,先排序的列放前面,后排序的列放后面。并且,不同的列可以有不同的排序规则。\nSELECT * FROM products ORDER BY prod_price DESC, prod_name ASC; 分组 # group by :\ngroup by 子句将记录分组到汇总行中。 group by 为每个组返回一个记录。 group by 通常还涉及聚合count,max,sum,avg 等。 group by 可以按一列或多列进行分组。 group by 按分组字段进行排序后,order by 可以以汇总字段来进行排序。 分组\nSELECT cust_name, COUNT(cust_address) AS addr_num FROM Customers GROUP BY cust_name; 分组后排序\nSELECT cust_name, COUNT(cust_address) AS addr_num FROM Customers GROUP BY cust_name ORDER BY cust_name DESC; having:\nhaving 用于对汇总的 group by 结果进行过滤。 having 一般都是和 group by 连用。 where 和 having 可以在相同的查询中。 使用 WHERE 和 HAVING 过滤数据\nSELECT cust_name, COUNT(*) AS num FROM Customers WHERE cust_email IS NOT NULL GROUP BY cust_name HAVING COUNT(*) \u0026gt;= 1; having vs where :\nwhere:过滤过滤指定的行,后面不能加聚合函数(分组函数)。where 在group by 前。 having:过滤分组,一般都是和 group by 连用,不能单独使用。having 在 group by 之后。 子查询 # 子查询是嵌套在较大查询中的 SQL 查询,也称内部查询或内部选择,包含子查询的语句也称为外部查询或外部选择。简单来说,子查询就是指将一个 select 查询(子查询)的结果作为另一个 SQL 语句(主查询)的数据来源或者判断条件。\n子查询可以嵌入 SELECT、INSERT、UPDATE 和 DELETE 语句中,也可以和 =、\u0026lt;、\u0026gt;、IN、BETWEEN、EXISTS 等运算符一起使用。\n子查询常用在 WHERE 子句和 FROM 子句后边:\n当用于 WHERE 子句时,根据不同的运算符,子查询可以返回单行单列、多行单列、单行多列数据。子查询就是要返回能够作为 WHERE 子句查询条件的值。 当用于 FROM 子句时,一般返回多行多列数据,相当于返回一张临时表,这样才符合 FROM 后面是表的规则。这种做法能够实现多表联合查询。 注意:MYSQL 数据库从 4.1 版本才开始支持子查询,早期版本是不支持的。\n用于 WHERE 子句的子查询的基本语法如下:\nselect column_name [, column_name ] from table1 [, table2 ] where column_name operator (select column_name [, column_name ] from table1 [, table2 ] [where]) 子查询需要放在括号( )内。 operator 表示用于 where 子句的运算符。 用于 FROM 子句的子查询的基本语法如下:\nselect column_name [, column_name ] from (select column_name [, column_name ] from table1 [, table2 ] [where]) as temp_table_name where condition 用于 FROM 的子查询返回的结果相当于一张临时表,所以需要使用 AS 关键字为该临时表起一个名字。\n子查询的子查询\nSELECT cust_name, cust_contact FROM customers WHERE cust_id IN (SELECT cust_id FROM orders WHERE order_num IN (SELECT order_num FROM orderitems WHERE prod_id = \u0026#39;RGAN01\u0026#39;)); 内部查询首先在其父查询之前执行,以便可以将内部查询的结果传递给外部查询。执行过程可以参考下图:\nWHERE # WHERE 子句用于过滤记录,即缩小访问数据的范围。 WHERE 后跟一个返回 true 或 false 的条件。 WHERE 可以与 SELECT,UPDATE 和 DELETE 一起使用。 可以在 WHERE 子句中使用的操作符。 运算符 描述 = 等于 \u0026lt;\u0026gt; 不等于。注释:在 SQL 的一些版本中,该操作符可被写成 != \u0026gt; 大于 \u0026lt; 小于 \u0026gt;= 大于等于 \u0026lt;= 小于等于 BETWEEN 在某个范围内 LIKE 搜索某种模式 IN 指定针对某个列的多个可能值 SELECT 语句中的 WHERE 子句\nSELECT * FROM Customers WHERE cust_name = \u0026#39;Kids Place\u0026#39;; UPDATE 语句中的 WHERE 子句\nUPDATE Customers SET cust_name = \u0026#39;Jack Jones\u0026#39; WHERE cust_name = \u0026#39;Kids Place\u0026#39;; DELETE 语句中的 WHERE 子句\nDELETE FROM Customers WHERE cust_name = \u0026#39;Kids Place\u0026#39;; IN 和 BETWEEN # IN 操作符在 WHERE 子句中使用,作用是在指定的几个特定值中任选一个值。 BETWEEN 操作符在 WHERE 子句中使用,作用是选取介于某个范围内的值。 IN 示例\nSELECT * FROM products WHERE vend_id IN (\u0026#39;DLL01\u0026#39;, \u0026#39;BRS01\u0026#39;); BETWEEN 示例\nSELECT * FROM products WHERE prod_price BETWEEN 3 AND 5; AND、OR、NOT # AND、OR、NOT 是用于对过滤条件的逻辑处理指令。 AND 优先级高于 OR,为了明确处理顺序,可以使用 ()。 AND 操作符表示左右条件都要满足。 OR 操作符表示左右条件满足任意一个即可。 NOT 操作符用于否定一个条件。 AND 示例\nSELECT prod_id, prod_name, prod_price FROM products WHERE vend_id = \u0026#39;DLL01\u0026#39; AND prod_price \u0026lt;= 4; OR 示例\nSELECT prod_id, prod_name, prod_price FROM products WHERE vend_id = \u0026#39;DLL01\u0026#39; OR vend_id = \u0026#39;BRS01\u0026#39;; NOT 示例\nSELECT * FROM products WHERE prod_price NOT BETWEEN 3 AND 5; LIKE # LIKE 操作符在 WHERE 子句中使用,作用是确定字符串是否匹配模式。 只有字段是文本值时才使用 LIKE。 LIKE 支持两个通配符匹配选项:% 和 _。 不要滥用通配符,通配符位于开头处匹配会非常慢。 % 表示任何字符出现任意次数。 _ 表示任何字符出现一次。 % 示例\nSELECT prod_id, prod_name, prod_price FROM products WHERE prod_name LIKE \u0026#39;%bean bag%\u0026#39;; _ 示例\nSELECT prod_id, prod_name, prod_price FROM products WHERE prod_name LIKE \u0026#39;__ inch teddy bear\u0026#39;; 连接 # JOIN 是“连接”的意思,顾名思义,SQL JOIN 子句用于将两个或者多个表联合起来进行查询。\n连接表时需要在每个表中选择一个字段,并对这些字段的值进行比较,值相同的两条记录将合并为一条。连接表的本质就是将不同表的记录合并起来,形成一张新表。当然,这张新表只是临时的,它仅存在于本次查询期间。\n使用 JOIN 连接两个表的基本语法如下:\nselect table1.column1, table2.column2... from table1 join table2 on table1.common_column1 = table2.common_column2; table1.common_column1 = table2.common_column2 是连接条件,只有满足此条件的记录才会合并为一行。您可以使用多个运算符来连接表,例如 =、\u0026gt;、\u0026lt;、\u0026lt;\u0026gt;、\u0026lt;=、\u0026gt;=、!=、between、like 或者 not,但是最常见的是使用 =。\n当两个表中有同名的字段时,为了帮助数据库引擎区分是哪个表的字段,在书写同名字段名时需要加上表名。当然,如果书写的字段名在两个表中是唯一的,也可以不使用以上格式,只写字段名即可。\n另外,如果两张表的关联字段名相同,也可以使用 USING子句来代替 ON,举个例子:\n# join....on select c.cust_name, o.order_num from Customers c inner join Orders o on c.cust_id = o.cust_id order by c.cust_name; # 如果两张表的关联字段名相同,也可以使用USING子句:join....using() select c.cust_name, o.order_num from Customers c inner join Orders o using(cust_id) order by c.cust_name; ON 和 WHERE 的区别:\n连接表时,SQL 会根据连接条件生成一张新的临时表。ON 就是连接条件,它决定临时表的生成。 WHERE 是在临时表生成以后,再对临时表中的数据进行过滤,生成最终的结果集,这个时候已经没有 JOIN-ON 了。 所以总结来说就是:SQL 先根据 ON 生成一张临时表,然后再根据 WHERE 对临时表进行筛选。\nSQL 允许在 JOIN 左边加上一些修饰性的关键词,从而形成不同类型的连接,如下表所示:\n连接类型 说明 INNER JOIN 内连接 (默认连接方式)只有当两个表都存在满足条件的记录时才会返回行。 LEFT JOIN / LEFT OUTER JOIN 左(外)连接 返回左表中的所有行,即使右表中没有满足条件的行也是如此。 RIGHT JOIN / RIGHT OUTER JOIN 右(外)连接 返回右表中的所有行,即使左表中没有满足条件的行也是如此。 FULL JOIN / FULL OUTER JOIN 全(外)连接 只要其中有一个表存在满足条件的记录,就返回行。 SELF JOIN 将一个表连接到自身,就像该表是两个表一样。为了区分两个表,在 SQL 语句中需要至少重命名一个表。 CROSS JOIN 交叉连接,从两个或者多个连接表中返回记录集的笛卡尔积。 下图展示了 LEFT JOIN、RIGHT JOIN、INNER JOIN、OUTER JOIN 相关的 7 种用法。\n如果不加任何修饰词,只写 JOIN,那么默认为 INNER JOIIN\n对于 INNER JOIIN 来说,还有一种隐式的写法,称为 “隐式内连接”,也就是没有 INNER JOIIN 关键字,使用 WHERE 语句实现内连接的功能\n# 隐式内连接 select c.cust_name, o.order_num from Customers c, Orders o where c.cust_id = o.cust_id order by c.cust_name; # 显式内连接 select c.cust_name, o.order_num from Customers c inner join Orders o using(cust_id) order by c.cust_name; 组合 # UNION 运算符将两个或更多查询的结果组合起来,并生成一个结果集,其中包含来自 UNION 中参与查询的提取行。\nUNION 基本规则:\n所有查询的列数和列顺序必须相同。 每个查询中涉及表的列的数据类型必须相同或兼容。 通常返回的列名取自第一个查询。 默认地,UNION 操作符选取不同的值。如果允许重复的值,请使用 UNION ALL。\nSELECT column_name(s) FROM table1 UNION ALL SELECT column_name(s) FROM table2; UNION 结果集中的列名总是等于 UNION 中第一个 SELECT 语句中的列名。\nJOIN vs UNION:\nJOIN 中连接表的列可能不同,但在 UNION 中,所有查询的列数和列顺序必须相同。 UNION 将查询之后的行放在一起(垂直放置),但 JOIN 将查询之后的列放在一起(水平放置),即它构成一个笛卡尔积。 函数 # 不同数据库的函数往往各不相同,因此不可移植。本节主要以 MysSQL 的函数为例。\n文本处理 # 函数 说明 LEFT()、RIGHT() 左边或者右边的字符 LOWER()、UPPER() 转换为小写或者大写 LTRIM()、RTIM() 去除左边或者右边的空格 LENGTH() 长度 SOUNDEX() 转换为语音值 其中, SOUNDEX() 可以将一个字符串转换为描述其语音表示的字母数字模式。\nSELECT * FROM mytable WHERE SOUNDEX(col1) = SOUNDEX(\u0026#39;apple\u0026#39;) 日期和时间处理 # 日期格式:YYYY-MM-DD 时间格式:HH:MM:SS 函 数 说 明 AddDate() 增加一个日期(天、周等) AddTime() 增加一个时间(时、分等) CurDate() 返回当前日期 CurTime() 返回当前时间 Date() 返回日期时间的日期部分 DateDiff() 计算两个日期之差 Date_Add() 高度灵活的日期运算函数 Date_Format() 返回一个格式化的日期或时间串 Day() 返回一个日期的天数部分 DayOfWeek() 对于一个日期,返回对应的星期几 Hour() 返回一个时间的小时部分 Minute() 返回一个时间的分钟部分 Month() 返回一个日期的月份部分 Now() 返回当前日期和时间 Second() 返回一个时间的秒部分 Time() 返回一个日期时间的时间部分 Year() 返回一个日期的年份部分 数值处理 # 函数 说明 SIN() 正弦 COS() 余弦 TAN() 正切 ABS() 绝对值 SQRT() 平方根 MOD() 余数 EXP() 指数 PI() 圆周率 RAND() 随机数 汇总 # 函 数 说 明 AVG() 返回某列的平均值 COUNT() 返回某列的行数 MAX() 返回某列的最大值 MIN() 返回某列的最小值 SUM() 返回某列值之和 AVG() 会忽略 NULL 行。\n使用 DISTINCT 可以让汇总函数值汇总不同的值。\nSELECT AVG(DISTINCT col1) AS avg_col FROM mytable 接下来,我们来介绍 DDL 语句用法。DDL 的主要功能是定义数据库对象(如:数据库、数据表、视图、索引等)\n数据定义 # 数据库(DATABASE) # 创建数据库 # CREATE DATABASE test; 删除数据库 # DROP DATABASE test; 选择数据库 # USE test; 数据表(TABLE) # 创建数据表 # 普通创建\nCREATE TABLE user ( id int(10) unsigned NOT NULL COMMENT \u0026#39;Id\u0026#39;, username varchar(64) NOT NULL DEFAULT \u0026#39;default\u0026#39; COMMENT \u0026#39;用户名\u0026#39;, password varchar(64) NOT NULL DEFAULT \u0026#39;default\u0026#39; COMMENT \u0026#39;密码\u0026#39;, email varchar(64) NOT NULL DEFAULT \u0026#39;default\u0026#39; COMMENT \u0026#39;邮箱\u0026#39; ) COMMENT=\u0026#39;用户表\u0026#39;; 根据已有的表创建新表\nCREATE TABLE vip_user AS SELECT * FROM user; 删除数据表 # DROP TABLE user; 修改数据表 # 添加列\nALTER TABLE user ADD age int(3); 删除列\nALTER TABLE user DROP COLUMN age; 修改列\nALTER TABLE `user` MODIFY COLUMN age tinyint; 添加主键\nALTER TABLE user ADD PRIMARY KEY (id); 删除主键\nALTER TABLE user DROP PRIMARY KEY; 视图(VIEW) # 定义:\n视图是基于 SQL 语句的结果集的可视化的表。 视图是虚拟的表,本身不包含数据,也就不能对其进行索引操作。对视图的操作和对普通表的操作一样。 作用:\n简化复杂的 SQL 操作,比如复杂的联结; 只使用实际表的一部分数据; 通过只给用户访问视图的权限,保证数据的安全性; 更改数据格式和表示。 创建视图 # CREATE VIEW top_10_user_view AS SELECT id, username FROM user WHERE id \u0026lt; 10; 删除视图 # DROP VIEW top_10_user_view; 索引(INDEX) # 索引是一种用于快速查询和检索数据的数据结构,其本质可以看成是一种排序好的数据结构。\n索引的作用就相当于书的目录。打个比方: 我们在查字典的时候,如果没有目录,那我们就只能一页一页的去找我们需要查的那个字,速度很慢。如果有目录了,我们只需要先去目录里查找字的位置,然后直接翻到那一页就行了。\n优点 :\n使用索引可以大大加快 数据的检索速度(大大减少检索的数据量), 这也是创建索引的最主要的原因。 通过创建唯一性索引,可以保证数据库表中每一行数据的唯一性。 缺点 :\n创建索引和维护索引需要耗费许多时间。当对表中的数据进行增删改的时候,如果数据有索引,那么索引也需要动态的修改,会降低 SQL 执行效率。 索引需要使用物理文件存储,也会耗费一定空间。 但是,使用索引一定能提高查询性能吗?\n大多数情况下,索引查询都是比全表扫描要快的。但是如果数据库的数据量不大,那么使用索引也不一定能够带来很大提升。\n关于索引的详细介绍,请看我写的 MySQL 索引详解 这篇文章。\n创建索引 # CREATE INDEX user_index ON user (id); 添加索引 # ALTER table user ADD INDEX user_index(id) 创建唯一索引 # CREATE UNIQUE INDEX user_index ON user (id); 删除索引 # ALTER TABLE user DROP INDEX user_index; 约束 # SQL 约束用于规定表中的数据规则。\n如果存在违反约束的数据行为,行为会被约束终止。\n约束可以在创建表时规定(通过 CREATE TABLE 语句),或者在表创建之后规定(通过 ALTER TABLE 语句)。\n约束类型:\nNOT NULL - 指示某列不能存储 NULL 值。 UNIQUE - 保证某列的每行必须有唯一的值。 PRIMARY KEY - NOT NULL 和 UNIQUE 的结合。确保某列(或两个列多个列的结合)有唯一标识,有助于更容易更快速地找到表中的一个特定的记录。 FOREIGN KEY - 保证一个表中的数据匹配另一个表中的值的参照完整性。 CHECK - 保证列中的值符合指定的条件。 DEFAULT - 规定没有给列赋值时的默认值。 创建表时使用约束条件:\nCREATE TABLE Users ( Id INT(10) UNSIGNED NOT NULL AUTO_INCREMENT COMMENT \u0026#39;自增Id\u0026#39;, Username VARCHAR(64) NOT NULL UNIQUE DEFAULT \u0026#39;default\u0026#39; COMMENT \u0026#39;用户名\u0026#39;, Password VARCHAR(64) NOT NULL DEFAULT \u0026#39;default\u0026#39; COMMENT \u0026#39;密码\u0026#39;, Email VARCHAR(64) NOT NULL DEFAULT \u0026#39;default\u0026#39; COMMENT \u0026#39;邮箱地址\u0026#39;, Enabled TINYINT(4) DEFAULT NULL COMMENT \u0026#39;是否有效\u0026#39;, PRIMARY KEY (Id) ) ENGINE=InnoDB AUTO_INCREMENT=2 DEFAULT CHARSET=utf8mb4 COMMENT=\u0026#39;用户表\u0026#39;; 接下来,我们来介绍 TCL 语句用法。TCL 的主要功能是管理数据库中的事务。\n事务处理 # 不能回退 SELECT 语句,回退 SELECT 语句也没意义;也不能回退 CREATE 和 DROP 语句。\nMySQL 默认是隐式提交,每执行一条语句就把这条语句当成一个事务然后进行提交。当出现 START TRANSACTION 语句时,会关闭隐式提交;当 COMMIT 或 ROLLBACK 语句执行后,事务会自动关闭,重新恢复隐式提交。\n通过 set autocommit=0 可以取消自动提交,直到 set autocommit=1 才会提交;autocommit 标记是针对每个连接而不是针对服务器的。\n指令:\nSTART TRANSACTION - 指令用于标记事务的起始点。 SAVEPOINT - 指令用于创建保留点。 ROLLBACK TO - 指令用于回滚到指定的保留点;如果没有设置保留点,则回退到 START TRANSACTION 语句处。 COMMIT - 提交事务。 -- 开始事务 START TRANSACTION; -- 插入操作 A INSERT INTO `user` VALUES (1, \u0026#39;root1\u0026#39;, \u0026#39;root1\u0026#39;, \u0026#39;xxxx@163.com\u0026#39;); -- 创建保留点 updateA SAVEPOINT updateA; -- 插入操作 B INSERT INTO `user` VALUES (2, \u0026#39;root2\u0026#39;, \u0026#39;root2\u0026#39;, \u0026#39;xxxx@163.com\u0026#39;); -- 回滚到保留点 updateA ROLLBACK TO updateA; -- 提交事务,只有操作 A 生效 COMMIT; 接下来,我们来介绍 DCL 语句用法。DCL 的主要功能是控制用户的访问权限。\n权限控制 # 要授予用户帐户权限,可以用GRANT命令。有撤销用户的权限,可以用REVOKE命令。这里以 MySQl 为例,介绍权限控制实际应用。\nGRANT授予权限语法:\nGRANT privilege,[privilege],.. ON privilege_level TO user [IDENTIFIED BY password] [REQUIRE tsl_option] [WITH [GRANT_OPTION | resource_option]]; 简单解释一下:\n在GRANT关键字后指定一个或多个权限。如果授予用户多个权限,则每个权限由逗号分隔。 ON privilege_level 确定权限应用级别。MySQL 支持 global(*.*),database(database.*),table(database.table)和列级别。如果使用列权限级别,则必须在每个权限之后指定一个或逗号分隔列的列表。 user 是要授予权限的用户。如果用户已存在,则GRANT语句将修改其权限。否则,GRANT语句将创建一个新用户。可选子句IDENTIFIED BY允许您为用户设置新的密码。 REQUIRE tsl_option指定用户是否必须通过 SSL,X059 等安全连接连接到数据库服务器。 可选 WITH GRANT OPTION 子句允许您授予其他用户或从其他用户中删除您拥有的权限。此外,您可以使用WITH子句分配 MySQL 数据库服务器的资源,例如,设置用户每小时可以使用的连接数或语句数。这在 MySQL 共享托管等共享环境中非常有用。 REVOKE 撤销权限语法:\nREVOKE privilege_type [(column_list)] [, priv_type [(column_list)]]... ON [object_type] privilege_level FROM user [, user]... 简单解释一下:\n在 REVOKE 关键字后面指定要从用户撤消的权限列表。您需要用逗号分隔权限。 指定在 ON 子句中撤销特权的特权级别。 指定要撤消 FROM 子句中的权限的用户帐户。 GRANT 和 REVOKE 可在几个层次上控制访问权限:\n整个服务器,使用 GRANT ALL 和 REVOKE ALL; 整个数据库,使用 ON database.*; 特定的表,使用 ON database.table; 特定的列; 特定的存储过程。 新创建的账户没有任何权限。账户用 username@host 的形式定义,username@% 使用的是默认主机名。MySQL 的账户信息保存在 mysql 这个数据库中。\nUSE mysql; SELECT user FROM user; 下表说明了可用于GRANT和REVOKE语句的所有允许权限:\n特权 说明 级别 全局 数据库 表 列 程序 代理 ALL [PRIVILEGES] 授予除 GRANT OPTION 之外的指定访问级别的所有权限 ALTER 允许用户使用 ALTER TABLE 语句 X X X ALTER ROUTINE 允许用户更改或删除存储的例程 X X X CREATE 允许用户创建数据库和表 X X X CREATE ROUTINE 允许用户创建存储的例程 X X CREATE TABLESPACE 允许用户创建,更改或删除表空间和日志文件组 X CREATE TEMPORARY TABLES 允许用户使用 CREATE TEMPORARY TABLE 创建临时表 X X CREATE USER 允许用户使用 CREATE USER,DROP USER,RENAME USER 和 REVOKE ALL PRIVILEGES 语句。 X CREATE VIEW 允许用户创建或修改视图。 X X X DELETE 允许用户使用 DELETE X X X DROP 允许用户删除数据库,表和视图 X X X EVENT 启用事件计划程序的事件使用。 X X EXECUTE 允许用户执行存储的例程 X X X FILE 允许用户读取数据库目录中的任何文件。 X GRANT OPTION 允许用户拥有授予或撤消其他帐户权限的权限。 X X X X X INDEX 允许用户创建或删除索引。 X X X INSERT 允许用户使用 INSERT 语句 X X X X LOCK TABLES 允许用户对具有 SELECT 权限的表使用 LOCK TABLES X X PROCESS 允许用户使用 SHOW PROCESSLIST 语句查看所有进程。 X PROXY 启用用户代理。 REFERENCES 允许用户创建外键 X X X X RELOAD 允许用户使用 FLUSH 操作 X REPLICATION CLIENT 允许用户查询以查看主服务器或从属服务器的位置 X REPLICATION SLAVE 允许用户使用复制从属从主服务器读取二进制日志事件。 X SELECT 允许用户使用 SELECT 语句 X X X X SHOW DATABASES 允许用户显示所有数据库 X SHOW VIEW 允许用户使用 SHOW CREATE VIEW 语句 X X X SHUTDOWN 允许用户使用 mysqladmin shutdown 命令 X SUPER 允许用户使用其他管理操作,例如 CHANGE MASTER TO,KILL,PURGE BINARY LOGS,SET GLOBAL 和 mysqladmin 命令 X TRIGGER 允许用户使用 TRIGGER 操作。 X X X UPDATE 允许用户使用 UPDATE 语句 X X X X USAGE 相当于“没有特权” 创建账户 # CREATE USER myuser IDENTIFIED BY \u0026#39;mypassword\u0026#39;; 修改账户名 # UPDATE user SET user=\u0026#39;newuser\u0026#39; WHERE user=\u0026#39;myuser\u0026#39;; FLUSH PRIVILEGES; 删除账户 # DROP USER myuser; 查看权限 # SHOW GRANTS FOR myuser; 授予权限 # GRANT SELECT, INSERT ON *.* TO myuser; 删除权限 # REVOKE SELECT, INSERT ON *.* FROM myuser; 更改密码 # SET PASSWORD FOR myuser = \u0026#39;mypass\u0026#39;; 存储过程 # 存储过程可以看成是对一系列 SQL 操作的批处理。存储过程可以由触发器,其他存储过程以及 Java, Python,PHP 等应用程序调用。\n使用存储过程的好处:\n代码封装,保证了一定的安全性; 代码复用; 由于是预先编译,因此具有很高的性能。 创建存储过程:\n命令行中创建存储过程需要自定义分隔符,因为命令行是以 ; 为结束符,而存储过程中也包含了分号,因此会错误把这部分分号当成是结束符,造成语法错误。 包含 in、out 和 inout 三种参数。 给变量赋值都需要用 select into 语句。 每次只能给一个变量赋值,不支持集合的操作。 需要注意的是:阿里巴巴《Java 开发手册》强制禁止使用存储过程。因为存储过程难以调试和扩展,更没有移植性。\n至于到底要不要在项目中使用,还是要看项目实际需求,权衡好利弊即可!\n创建存储过程 # DROP PROCEDURE IF EXISTS `proc_adder`; DELIMITER ;; CREATE DEFINER=`root`@`localhost` PROCEDURE `proc_adder`(IN a int, IN b int, OUT sum int) BEGIN DECLARE c int; if a is null then set a = 0; end if; if b is null then set b = 0; end if; set sum = a + b; END ;; DELIMITER ; 使用存储过程 # set @b=5; call proc_adder(2,@b,@s); select @s as sum; 游标 # 游标(cursor)是一个存储在 DBMS 服务器上的数据库查询,它不是一条 SELECT 语句,而是被该语句检索出来的结果集。\n在存储过程中使用游标可以对一个结果集进行移动遍历。\n游标主要用于交互式应用,其中用户需要滚动屏幕上的数据,并对数据进行浏览或做出更改。\n使用游标的几个明确步骤:\n在使用游标前,必须声明(定义)它。这个过程实际上没有检索数据, 它只是定义要使用的 SELECT 语句和游标选项。\n一旦声明,就必须打开游标以供使用。这个过程用前面定义的 SELECT 语句把数据实际检索出来。\n对于填有数据的游标,根据需要取出(检索)各行。\n在结束游标使用时,必须关闭游标,可能的话,释放游标(有赖于具\n体的 DBMS)。\nDELIMITER $ CREATE PROCEDURE getTotal() BEGIN DECLARE total INT; -- 创建接收游标数据的变量 DECLARE sid INT; DECLARE sname VARCHAR(10); -- 创建总数变量 DECLARE sage INT; -- 创建结束标志变量 DECLARE done INT DEFAULT false; -- 创建游标 DECLARE cur CURSOR FOR SELECT id,name,age from cursor_table where age\u0026gt;30; -- 指定游标循环结束时的返回值 DECLARE CONTINUE HANDLER FOR NOT FOUND SET done = true; SET total = 0; OPEN cur; FETCH cur INTO sid, sname, sage; WHILE(NOT done) DO SET total = total + 1; FETCH cur INTO sid, sname, sage; END WHILE; CLOSE cur; SELECT total; END $ DELIMITER ; -- 调用存储过程 call getTotal(); 触发器 # 触发器是一种与表操作有关的数据库对象,当触发器所在表上出现指定事件时,将调用该对象,即表的操作事件触发表上的触发器的执行。\n我们可以使用触发器来进行审计跟踪,把修改记录到另外一张表中。\n使用触发器的优点:\nSQL 触发器提供了另一种检查数据完整性的方法。 SQL 触发器可以捕获数据库层中业务逻辑中的错误。 SQL 触发器提供了另一种运行计划任务的方法。通过使用 SQL 触发器,您不必等待运行计划任务,因为在对表中的数据进行更改之前或之后会自动调用触发器。 SQL 触发器对于审计表中数据的更改非常有用。 使用触发器的缺点:\nSQL 触发器只能提供扩展验证,并且不能替换所有验证。必须在应用程序层中完成一些简单的验证。例如,您可以使用 JavaScript 在客户端验证用户的输入,或者使用服务器端脚本语言(如 JSP,PHP,ASP.NET,Perl)在服务器端验证用户的输入。 从客户端应用程序调用和执行 SQL 触发器是不可见的,因此很难弄清楚数据库层中发生了什么。 SQL 触发器可能会增加数据库服务器的开销。 MySQL 不允许在触发器中使用 CALL 语句 ,也就是不能调用存储过程。\n注意:在 MySQL 中,分号 ; 是语句结束的标识符,遇到分号表示该段语句已经结束,MySQL 可以开始执行了。因此,解释器遇到触发器执行动作中的分号后就开始执行,然后会报错,因为没有找到和 BEGIN 匹配的 END。\n这时就会用到 DELIMITER 命令(DELIMITER 是定界符,分隔符的意思)。它是一条命令,不需要语句结束标识,语法为:DELIMITER new_delemiter。new_delemiter 可以设为 1 个或多个长度的符号,默认的是分号 ;,我们可以把它修改为其他符号,如 $ - DELIMITER $ 。在这之后的语句,以分号结束,解释器不会有什么反应,只有遇到了 $,才认为是语句结束。注意,使用完之后,我们还应该记得把它给修改回来。\n在 MySQL 5.7.2 版之前,可以为每个表定义最多六个触发器。\nBEFORE INSERT - 在将数据插入表格之前激活。 AFTER INSERT - 将数据插入表格后激活。 BEFORE UPDATE - 在更新表中的数据之前激活。 AFTER UPDATE - 更新表中的数据后激活。 BEFORE DELETE - 在从表中删除数据之前激活。 AFTER DELETE - 从表中删除数据后激活。 但是,从 MySQL 版本 5.7.2+开始,可以为同一触发事件和操作时间定义多个触发器。\nNEW 和 OLD :\nMySQL 中定义了 NEW 和 OLD 关键字,用来表示触发器的所在表中,触发了触发器的那一行数据。 在 INSERT 型触发器中,NEW 用来表示将要(BEFORE)或已经(AFTER)插入的新数据; 在 UPDATE 型触发器中,OLD 用来表示将要或已经被修改的原数据,NEW 用来表示将要或已经修改为的新数据; 在 DELETE 型触发器中,OLD 用来表示将要或已经被删除的原数据; 使用方法: NEW.columnName (columnName 为相应数据表某一列名) 创建触发器 # 提示:为了理解触发器的要点,有必要先了解一下创建触发器的指令。\nCREATE TRIGGER 指令用于创建触发器。\n语法:\nCREATE TRIGGER trigger_name trigger_time trigger_event ON table_name FOR EACH ROW BEGIN trigger_statements END; 说明:\ntrigger_name :触发器名 trigger_time : 触发器的触发时机。取值为 BEFORE 或 AFTER。 trigger_event : 触发器的监听事件。取值为 INSERT、UPDATE 或 DELETE。 table_name : 触发器的监听目标。指定在哪张表上建立触发器。 FOR EACH ROW: 行级监视,Mysql 固定写法,其他 DBMS 不同。 trigger_statements: 触发器执行动作。是一条或多条 SQL 语句的列表,列表内的每条语句都必须用分号 ; 来结尾。 当触发器的触发条件满足时,将会执行 BEGIN 和 END 之间的触发器执行动作。\n示例:\nDELIMITER $ CREATE TRIGGER `trigger_insert_user` AFTER INSERT ON `user` FOR EACH ROW BEGIN INSERT INTO `user_history`(user_id, operate_type, operate_time) VALUES (NEW.id, \u0026#39;add a user\u0026#39;, now()); END $ DELIMITER ; 查看触发器 # SHOW TRIGGERS; 删除触发器 # DROP TRIGGER IF EXISTS trigger_insert_user; 文章推荐 # 后端程序员必备:SQL高性能优化指南!35+条优化建议立马GET! 后端程序员必备:书写高质量SQL的30条建议 "},{"id":241,"href":"/zh/docs/technology/Review/java_guide/database/Redis/diagram/","title":"redis问题图解","section":"Redis","content":" 转载自https://github.com/Snailclimb/JavaGuide(添加小部分笔记)感谢作者!\n主从复制原理\n哨兵模式(简单)\n哨兵模式详解\n先配置主从模式,再配置哨兵模式\n所有的哨兵 sentinel.conf 都是配置为监听master\u0026ndash;\u0026gt; 192.168.14.101,如果主机宕机,sentinel.conf 中的配置也会自动更改为选举后的\nJava客户端连接原理\n客户端是和Sentinel来进行交互的,通过Sentinel来获取真正的Redis节点信息,然后来操作.实际工作时,Sentinel 内部维护了一个主题队列,用来保存Redis的节点信息,并实时更新,客户端订阅了这个主题,然后实时的去获取这个队列的Redis节点信息.\n/** 代码相对比较简单 **/ //1.设置sentinel 各个节点集合 Set\u0026lt;String\u0026gt; sentinelSet = new HashSet\u0026lt;\u0026gt;(); sentinelSet.add(\u0026#34;192.168.14.101:26379\u0026#34;); sentinelSet.add(\u0026#34;192.168.14.102:26380\u0026#34;); sentinelSet.add(\u0026#34;192.168.14.103:26381\u0026#34;); //2.设置jedispool 连接池配置文件 JedisPoolConfig config = new JedisPoolConfig(); config.setMaxTotal(10); config.setMaxWaitMillis(1000); //3.设置mastername,sentinelNode集合,配置文件,Redis登录密码 JedisSentinelPool jedisSentinelPool = new JedisSentinelPool(\u0026#34;mymaster\u0026#34;,sentinelSet,config,\u0026#34;123\u0026#34;); Jedis jedis = null; try { jedis = jedisSentinelPool.getResource(); //获取Redis中key=hello的值 String value = jedis.get(\u0026#34;hello\u0026#34;); System.out.println(value); } catch (Exception e) { e.printStackTrace(); } finally { if(jedis != null){ jedis.close(); } } 哨兵工作原理\n主观宕机:sentinel自认为redis不可用\n客观宕机:sentinel集群认为redis不可用\n故障转移\n"},{"id":242,"href":"/zh/docs/technology/Review/java_guide/database/Redis/ly0703ly3-commonly-used-cache-read-and-write-strategies/","title":"3种常用的缓存读写策略详解","section":"Redis","content":" 转载自https://github.com/Snailclimb/JavaGuide(添加小部分笔记)感谢作者!\n看到很多小伙伴简历上写了“熟练使用缓存”,但是被我问到“缓存常用的3种读写策略”的时候却一脸懵逼。\n在我看来,造成这个问题的原因是我们在学习 Redis 的时候,可能只是简单了写一些 Demo,并没有去关注缓存的读写策略,或者说压根不知道这回事。\n但是,搞懂3种常见的缓存读写策略对于实际工作中使用缓存以及面试中被问到缓存都是非常有帮助的!\n下面介绍到的三种模式各有优劣,不存在最佳模式,根据具体的业务场景选择适合自己的缓存读写模式。\nCache Aside Pattern(旁路缓存模式) # Cache Aside Pattern 是我们平时使用比较多的一个缓存读写模式,比较适合读请求比较多的场景。\nCache Aside Pattern 中服务端需要同时维系 db 和 cache,并且是以 db 的结果为准。\n下面我们来看一下这个策略模式下的缓存读写步骤。\n写 :\n先更新 db 然后直接删除 cache 。 简单画了一张图帮助大家理解写的步骤。\n读 :\n从 cache 中读取数据,读取到就直接返回 cache 中读取不到的话,就从 db 中读取数据返回 再把数据放到 cache 中。 简单画了一张图帮助大家理解读的步骤。\n你仅仅了解了上面这些内容的话是远远不够的,我们还要搞懂其中的原理。\n比如说面试官很可能会追问:“在写数据的过程中,可以先删除 cache ,后更新 db 么?”\n答案: 那肯定是不行的!因为这样可能会造成 数据库(db)和缓存(Cache)数据不一致的问题。\n举例:请求 1 先写数据 A,请求 2 随后读数据 A 的话,就很有可能产生数据不一致性的问题。\n这个过程可以简单描述为:\n请求 1 先把 cache 中的 A 数据删除 -\u0026gt; 请求 2 从 db 中读取数据【此时请求2把脏数据(对于请求1来说是)更新到缓存去了】-\u0026gt;请求 1 再把 db 中的 A 数据更新,即请求1的操作非原子\n当你这样回答之后,面试官可能会紧接着就追问:“在写数据的过程中,先更新 db,后删除 cache 就没有问题了么?”\n答案: 理论上来说还是可能会出现数据不一致性的问题,不过概率非常小,因为缓存的写入速度是比数据库的写入速度快很多。\n举例:请求 1 先读数据 A,请求 2 随后写数据 A,并且数据 A 在请求 1 请求之前不在缓存中的话,也有可能产生数据不一致性的问题。\n这个过程可以简单描述为:\n请求 1 从 db 读数据 A-\u0026gt; 请求 2 更新 db 中的数据 A(此时缓存中无数据 A ,故不用执行删除缓存操作 ) -\u0026gt; 请求 1 将数据 A 写入 cache\n现在我们再来分析一下 Cache Aside Pattern 的缺陷。\n缺陷 1:首次请求数据一定不在 cache 的问题\n解决办法:可以将热点数据可以提前放入 cache 中。\n缺陷 2:写操作比较频繁的话导致 cache 中的数据会被频繁被删除,这样会影响缓存命中率 。\n解决办法:\n数据库和缓存数据强一致场景 :更新 db 的时候同样更新 cache,不过我们需要加一个锁/分布式锁来保证更新 cache 的时候不存在线程安全问题。 可以短暂地允许数据库和缓存数据不一致的场景 :更新 db 的时候同样更新 cache,但是给缓存加一个比较短的过期时间,这样的话就可以保证即使数据不一致的话影响也比较小。 Read/Write Through Pattern(读写穿透) # Read/Write Through Pattern 中服务端把 cache 视为主要数据存储,从中读取数据并将数据写入其中。cache 服务负责将此数据读取和写入 db,从而减轻了应用程序的职责。\n这种缓存读写策略小伙伴们应该也发现了在平时在开发过程中非常少见。抛去性能方面的影响,大概率是因为我们经常使用的分布式缓存 Redis 并没有提供 cache 将数据写入 db 的功能。\n写(Write Through):\n先查 cache,cache 中不存在,直接更新 db。 cache 中存在,则先更新 cache,然后 cache 服务自己更新 db(同步更新 cache 和 db)。 简单画了一张图帮助大家理解写的步骤。\n读(Read Through):\n从 cache 中读取数据,读取到就直接返回 。 读取不到的话,先从 db 加载,写入到 cache 后返回响应。 简单画了一张图帮助大家理解读的步骤。\nRead-Through Pattern 实际只是在 Cache-Aside Pattern 之上进行了封装。在 Cache-Aside Pattern 下,发生读请求的时候,如果 cache 中不存在对应的数据,是由客户端自己负责把数据写入 cache,而 Read Through Pattern 则是 cache 服务自己来写入缓存的,这对客户端是透明的。\n和 Cache Aside Pattern 一样, Read-Through Pattern 也有首次请求数据一定不再 cache 的问题,对于热点数据可以提前放入缓存中。\nWrite Behind Pattern(异步缓存写入) # Write Behind Pattern 和 Read/Write Through Pattern 很相似,两者都是由 cache 服务来负责 cache 和 db 的读写。\n但是,两个又有很大的不同:Read/Write Through 是同步更新 cache 和 db,而 Write Behind 则是只更新缓存,不直接更新 db,而是改为异步批量的方式来更新 db。\n很明显,这种方式对数据一致性带来了更大的挑战,比如 cache 数据可能还没异步更新 db 的话,cache 服务可能就就挂掉了。\n这种策略在我们平时开发过程中也非常非常少见,但是不代表它的应用场景少,比如消息队列中消息的异步写入磁盘、MySQL 的 Innodb Buffer Pool 机制都用到了这种策略。\nWrite Behind Pattern 下 db 的写性能非常高,非常适合一些数据经常变化又对数据一致性要求没那么高的场景,比如浏览量、点赞量。\n"},{"id":243,"href":"/zh/docs/technology/Review/java_guide/database/Redis/ly0704lyredis-memory-fragmentation/","title":"redis内存碎片","section":"Redis","content":" 转载自https://github.com/Snailclimb/JavaGuide(添加小部分笔记)感谢作者!\n什么是内存碎片? # 你可以将内存碎片简单地理解为那些不可用的空闲内存。\n举个例子:操作系统为你分配了 32 字节的连续内存空间,而你存储数据实际只需要使用 24 字节内存空间,那这多余出来的 8 字节内存空间如果后续没办法再被分配存储其他数据的话,就可以被称为内存碎片。\nRedis 内存碎片虽然不会影响 Redis 性能,但是会增加内存消耗。\n为什么会有 Redis 内存碎片? # Redis 内存碎片产生比较常见的 2 个原因:\n1、Redis 存储存储数据的时候向操作系统申请的内存空间可能会大于数据实际需要的存储空间。\n以下是这段 Redis 官方的原话:\nTo store user keys, Redis allocates at most as much memory as the maxmemory setting enables (however there are small extra allocations possible).\nRedis 使用 zmalloc 方法(Redis 自己实现的内存分配方法)进行内存分配的时候,除了要分配 size 大小的内存之外,还会多分配 PREFIX_SIZE 大小的内存。\nzmalloc 方法源码如下(源码地址:https://github.com/antirez/redis-tools/blob/master/zmalloc.c):\nvoid *zmalloc(size_t size) { // 分配指定大小的内存 void *ptr = malloc(size+PREFIX_SIZE); if (!ptr) zmalloc_oom_handler(size); #ifdef HAVE_MALLOC_SIZE update_zmalloc_stat_alloc(zmalloc_size(ptr)); return ptr; #else *((size_t*)ptr) = size; update_zmalloc_stat_alloc(size+PREFIX_SIZE); return (char*)ptr+PREFIX_SIZE; #endif } 另外,Redis 可以使用多种内存分配器来分配内存( libc、jemalloc、tcmalloc),默认使用 jemalloc,而 jemalloc 按照一系列固定的大小(8 字节、16 字节、32 字节\u0026hellip;\u0026hellip;)来分配内存的。jemalloc 划分的内存单元如下图所示:\n当程序申请的内存最接近某个固定值时,jemalloc 会给它分配相应大小的空间,就比如说程序需要申请 17 字节的内存,jemalloc 会直接给它分配 32 字节的内存,这样会导致有 15 字节内存的浪费。不过,jemalloc 专门针对内存碎片问题做了优化,一般不会存在过度碎片化的问题。\n2、频繁修改 Redis 中的数据也会产生内存碎片。\n当 Redis 中的某个数据删除时,Redis 通常不会轻易释放内存给操作系统。\n这个在 Redis 官方文档中也有对应的原话:\n文档地址:https://redis.io/topics/memory-optimization 。\n如何查看 Redis 内存碎片的信息? # 使用 info memory 命令即可查看 Redis 内存相关的信息。下图中每个参数具体的含义,Redis 官方文档有详细的介绍:https://redis.io/commands/INFO 。\n]\nRedis 内存碎片率的计算公式:mem_fragmentation_ratio (内存碎片率)= used_memory_rss (操作系统实际分配给 Redis 的物理内存空间大小)/ used_memory(Redis 内存分配器为了存储数据实际申请使用的内存空间大小)\n也就是说,mem_fragmentation_ratio (内存碎片率)的值越大代表内存碎片率越严重。\n一定不要误认为used_memory_rss 减去 used_memory值就是内存碎片的大小!!!这不仅包括内存碎片,还包括其他进程开销,以及共享库、堆栈等的开销。\n很多小伙伴可能要问了:“多大的内存碎片率才是需要清理呢?”。\n通常情况下,我们认为 mem_fragmentation_ratio \u0026gt; 1.5 的话才需要清理内存碎片。 mem_fragmentation_ratio \u0026gt; 1.5 意味着你使用 Redis 存储实际大小 2G 的数据需要使用大于 3G 的内存。\n如果想要快速查看内存碎片率的话,你还可以通过下面这个命令:\n\u0026gt; redis-cli -p 6379 info | grep mem_fragmentation_ratio 另外,内存碎片率可能存在小于 1 的情况。这种情况我在日常使用中还没有遇到过,感兴趣的小伙伴可以看看这篇文章 故障分析 | Redis 内存碎片率太低该怎么办?- 爱可生开源社区 。\n如何清理 Redis 内存碎片? # Redis4.0-RC3 版本以后自带了内存整理,可以避免内存碎片率过大的问题。\n直接通过 config set 命令将 activedefrag 配置项设置为 yes 即可。\nconfig set activedefrag yes 具体什么时候清理需要通过下面两个参数控制:\n# 内存碎片占用空间达到 500mb 的时候开始清理 config set active-defrag-ignore-bytes 500mb # 内存碎片率大于 1.5 的时候开始清理 config set active-defrag-threshold-lower 50 通过 Redis 自动内存碎片清理机制可能会对 Redis 的性能产生影响,我们可以通过下面两个参数来减少对 Redis 性能的影响:\n# 内存碎片清理所占用 CPU 时间的比例不低于 20% config set active-defrag-cycle-min 20 # 内存碎片清理所占用 CPU 时间的比例不高于 50% config set active-defrag-cycle-max 50 另外,重启节点可以做到内存碎片重新整理。如果你采用的是高可用架构的 Redis 集群的话,你可以将碎片率过高的主节点转换为从节点,以便进行安全重启。\n参考 # Redis 官方文档:https://redis.io/topics/memory-optimization Redis 核心技术与实战 - 极客时间 - 删除数据后,为什么内存占用率还是很高?:https://time.geekbang.org/column/article/289140 Redis 源码解析——内存分配: https://shinerio.cc/2020/05/17/redis/Redis%E6%BA%90%E7%A0%81%E8%A7%A3%E6%9E%90%E2%80%94%E2%80%94%E5%86%85%E5%AD%98%E7%AE%A1%E7%90%86/ "},{"id":244,"href":"/zh/docs/technology/Review/java_guide/database/Redis/ly0702lyredis-spec-data-structure/","title":"redis特殊数据结构","section":"Redis","content":" 转载自https://github.com/Snailclimb/JavaGuide(添加小部分笔记)感谢作者!\n除了 5 种基本的数据结构之外,Redis 还支持 3 种特殊的数据结构 :Bitmap、HyperLogLog、GEO。\nBitmap # 介绍 # Bitmap 存储的是连续的二进制数字(0 和 1),通过 Bitmap, 只需要一个 bit 位来表示某个元素对应的值或者状态,key 就是对应元素本身 。我们知道 8 个 bit 可以组成一个 byte,所以 Bitmap 本身会极大的节省储存空间。\n你可以将 Bitmap 看作是一个存储二进制数字(0 和 1)的数组,数组中每个元素的下标叫做 offset(偏移量)。\n常用命令 # 命令 介绍 SETBIT key offset value 设置指定 offset 位置的值 GETBIT key offset 获取指定 offset 位置的值 BITCOUNT key start end 获取 start 和 end 之前值为 1 的元素个数 BITOP operation destkey key1 key2 \u0026hellip; 对一个或多个 Bitmap 进行运算,可用运算符有 AND, OR, XOR 以及 NOT Bitmap 基本操作演示 :\n# SETBIT 会返回之前位的值(默认是 0)这里会生成 7 个位 \u0026gt; SETBIT mykey 7 1 (integer) 0 \u0026gt; SETBIT mykey 7 0 (integer) 1 \u0026gt; GETBIT mykey 7 (integer) 0 \u0026gt; SETBIT mykey 6 1 (integer) 0 \u0026gt; SETBIT mykey 8 1 (integer) 0 # 通过 bitcount 统计被被设置为 1 的位的数量。 \u0026gt; BITCOUNT mykey (integer) 2 应用场景 # 需要保存状态信息(0/1 即可表示)的场景\n举例 :用户签到情况、活跃用户情况、用户行为统计(比如是否点赞过某个视频)。 相关命令 :SETBIT、GETBIT、BITCOUNT、BITOP。 HyperLogLog # 介绍 # HyperLogLog 是一种有名的基数计数概率算法 ,基于 LogLog Counting(LLC)优化改进得来,并不是 Redis 特有的,Redis 只是实现了这个算法并提供了一些开箱即用的 API。\nRedis 提供的 HyperLogLog 占用空间非常非常小,只需要 12k 的空间就能存储接近2^64个不同元素。这是真的厉害,这就是数学的魅力么!并且,Redis 对 HyperLogLog 的存储结构做了优化,采用两种方式计数:\n稀疏矩阵 :计数较少的时候,占用空间很小。 稠密矩阵 :计数达到某个阈值的时候,占用 12k 的空间。 Redis 官方文档中有对应的详细说明:\n基数计数概率算法为了节省内存并不会直接存储元数据,而是通过一定的概率统计方法预估基数值(集合中包含元素的个数)。因此, HyperLogLog 的计数结果并不是一个精确值,存在一定的误差(标准误差为 0.81% 。)。\nHyperLogLog 的使用非常简单,但原理非常复杂。HyperLogLog 的原理以及在 Redis 中的实现可以看这篇文章: HyperLogLog 算法的原理讲解以及 Redis 是如何应用它的 。\n再推荐一个可以帮助理解 HyperLogLog 原理的工具: Sketch of the Day: HyperLogLog — Cornerstone of a Big Data Infrastructure 。\n常用命令 # HyperLogLog 相关的命令非常少,最常用的也就 3 个。\n命令 介绍 PFADD key element1 element2 \u0026hellip; 添加一个或多个元素到 HyperLogLog 中 PFCOUNT key1 key2 获取一个或者多个 HyperLogLog 的唯一计数。 PFMERGE destkey sourcekey1 sourcekey2 \u0026hellip; 将多个 HyperLogLog 合并到 destkey 中,destkey 会结合多个源,算出对应的唯一计数。 HyperLogLog 基本操作演示 :\n\u0026gt; PFADD hll foo bar zap (integer) 1 \u0026gt; PFADD hll zap zap zap (integer) 0 \u0026gt; PFADD hll foo bar (integer) 0 \u0026gt; PFCOUNT hll (integer) 3 \u0026gt; PFADD some-other-hll 1 2 3 (integer) 1 \u0026gt; PFCOUNT hll some-other-hll (integer) 6 \u0026gt; PFMERGE desthll hll some-other-hll \u0026#34;OK\u0026#34; \u0026gt; PFCOUNT desthll (integer) 6 应用场景 # 数量量巨大(百万、千万级别以上)的计数场景\n举例 :热门网站每日/每周/每月访问 ip 数统计、热门帖子 uv 统计、 相关命令 :PFADD、PFCOUNT 。 Geospatial index # 介绍 # Geospatial index(地理空间索引,简称 GEO) 主要用于存储地理位置信息,基于 Sorted Set 实现。\n通过 GEO 我们可以轻松实现两个位置距离的计算、获取指定位置附近的元素等功能。\n常用命令 # 命令 介绍 GEOADD key longitude1 latitude1 member1 \u0026hellip; 添加一个或多个元素对应的经纬度信息到 GEO 中 GEOPOS key member1 member2 \u0026hellip; 返回给定元素的经纬度信息 GEODIST key member1 member2 M/KM/FT/MI 返回两个给定元素之间的距离 GEORADIUS key longitude latitude radius distance 获取指定位置附近 distance 范围内的其他元素,支持 ASC(由近到远)、DESC(由远到近)、Count(数量) 等参数 GEORADIUSBYMEMBER key member radius distance 类似于 GEORADIUS 命令,只是参照的中心点是 GEO 中的元素 基本操作 :\n\u0026gt; GEOADD personLocation 116.33 39.89 user1 116.34 39.90 user2 116.35 39.88 user3 3 \u0026gt; GEOPOS personLocation user1 116.3299986720085144 39.89000061669732844 \u0026gt; GEODIST personLocation user1 user2 km 1.4018 通过 Redis 可视化工具查看 personLocation ,果不其然,底层就是 Sorted Set。\nGEO 中存储的地理位置信息的经纬度数据通过 GeoHash 算法转换成了一个整数,这个整数作为 Sorted Set 的 score(权重参数)使用。\n获取指定位置范围内的其他元素 :\n\u0026gt; GEORADIUS personLocation 116.33 39.87 3 km user3 user1 \u0026gt; GEORADIUS personLocation 116.33 39.87 2 km \u0026gt; GEORADIUS personLocation 116.33 39.87 5 km user3 user1 user2 \u0026gt; GEORADIUSBYMEMBER personLocation user1 5 km user3 user1 user2 \u0026gt; GEORADIUSBYMEMBER personLocation user1 2 km user1 user2 GEORADIUS 命令的底层原理解析可以看看阿里的这篇文章: Redis 到底是怎么实现“附近的人”这个功能的呢? 。\n移除元素 :\nGEO 底层是 Sorted Set ,你可以对 GEO 使用 Sorted Set 相关的命令。\n\u0026gt; ZREM personLocation user1 1 \u0026gt; ZRANGE personLocation 0 -1 user3 user2 \u0026gt; ZSCORE personLocation user2 4069879562983946 应用场景 # 需要管理使用地理空间数据的场景\n举例:附近的人。 相关命令: GEOADD、GEORADIUS、GEORADIUSBYMEMBER 。 参考 # Redis Data Structures :https://redis.com/redis-enterprise/data-structures/ 。 《Redis 深度历险:核心原理与应用实践》1.6 四两拨千斤——HyperLogLog 布隆过滤器,位图,HyperLogLog:https://hogwartsrico.github.io/2020/06/08/BloomFilter-HyperLogLog-BitMap/index.html "},{"id":245,"href":"/zh/docs/technology/Review/java_guide/database/Redis/ly0701lyredis-base-data-structures/","title":"redis基本数据结构","section":"Redis","content":" 转载自https://github.com/Snailclimb/JavaGuide(添加小部分笔记)感谢作者!\nRedis 共有 5 种基本数据结构:String(字符串)、List(列表)、Set(集合)、Hash(散列)、Zset(有序集合)。\n这 5 种数据结构是直接提供给用户使用的,是数据的保存形式,其底层实现主要依赖这 8 种数据结构:简单动态字符串(SDS)、LinkedList(双向链表)、Hash Table(哈希表)、SkipList(跳跃表)、Intset(整数集合)、ZipList(压缩列表)、QuickList(快速列表)。\nRedis 基本数据结构的底层数据结构实现如下:\nString List Hash Set Zset SDS LinkedList/ZipList/QuickList Hash Table、ZipList ZipList、Intset ZipList、SkipList Redis 3.2 之前,List 底层实现是 LinkedList 或者 ZipList。 Redis 3.2 之后,引入了 LinkedList 和 ZipList 的结合 QuickList,List 的底层实现变为 QuickList。\n你可以在 Redis 官网上找到 Redis 数据结构非常详细的介绍:\nRedis Data Structures Redis Data types tutorial 未来随着 Redis 新版本的发布,可能会有新的数据结构出现,通过查阅 Redis 官网对应的介绍,你总能获取到最靠谱的信息。\nString(字符串) # 介绍 # String 是 Redis 中最简单同时也是最常用的一个数据结构。\nString 是一种二进制安全的数据结构,可以用来存储任何类型的数据比如字符串、整数、浮点数、图片(图片的 base64 编码或者解码或者图片的路径)、序列化后的对象。\n虽然 Redis 是用 C 语言写的,但是 Redis 并没有使用 C 的字符串表示,而是自己构建了一种 简单动态字符串(Simple Dynamic String,SDS)。相比于 C 的原生字符串,Redis 的 SDS 不光可以保存文本数据还可以保存二进制数据,并且获取字符串长度复杂度为 O(1)(C 字符串为 O(N)),除此之外,Redis 的 SDS API 是安全的,不会造成缓冲区溢出。\n常用命令 # 命令 介绍 SET key value 设置指定 key 的值 SETNX key value 只有在 key 不存在时设置 key 的值 GET key 获取指定 key 的值 MSET key1 value1 key2 value2 … 设置一个或多个指定 key 的值 MGET key1 key2 \u0026hellip; 获取一个或多个指定 key 的值 STRLEN key 返回 key 所储存的字符串值的长度 INCR key 将 key 中储存的数字值增一 DECR key 将 key 中储存的数字值减一 EXISTS key 判断指定 key 是否存在 DEL key(通用) 删除指定的 key EXPIRE key seconds(通用) 给指定 key 设置过期时间 更多 Redis String 命令以及详细使用指南,请查看 Redis 官网对应的介绍:https://redis.io/commands/?group=string 。\n基本操作 :\n\u0026gt; SET key value OK \u0026gt; GET key \u0026#34;value\u0026#34; \u0026gt; EXISTS key (integer) 1 \u0026gt; STRLEN key (integer) 5 \u0026gt; DEL key (integer) 1 \u0026gt; GET key (nil) 批量设置 :\n\u0026gt; MSET key1 value1 key2 value2 OK \u0026gt; MGET key1 key2 # 批量获取多个 key 对应的 value 1) \u0026#34;value1\u0026#34; 2) \u0026#34;value2\u0026#34; 计数器(字符串的内容为整数的时候可以使用):\n\u0026gt; SET number 1 OK \u0026gt; INCR number # 将 key 中储存的数字值增一 (integer) 2 \u0026gt; GET number \u0026#34;2\u0026#34; \u0026gt; DECR number # 将 key 中储存的数字值减一 (integer) 1 \u0026gt; GET number \u0026#34;1\u0026#34; 设置过期时间(默认为永不过期):\n\u0026gt; EXPIRE key 60 (integer) 1 \u0026gt; SETNX key 60 value # 设置值并设置过期时间 OK \u0026gt; TTL key (integer) 56 应用场景 # 需要存储常规数据的场景\n举例 :缓存 session、token、图片地址、序列化后的对象(相比较于 Hash 存储更节省内存)。 相关命令 : SET、GET。 需要计数的场景\n举例 :用户单位时间的请求数(简单限流可以用到)、页面单位时间的访问数。 相关命令 :SET、GET、 INCR、DECR 。 分布式锁\n利用 SETNX key value 命令可以实现一个最简易的分布式锁(存在一些缺陷,通常不建议这样实现分布式锁)。\nList(列表) # 介绍 # Redis 中的 List 其实就是链表数据结构的实现。我在 线性数据结构 :数组、链表、栈、队列 这篇文章中详细介绍了链表这种数据结构,我这里就不多做介绍了。\n许多高级编程语言都内置了链表的实现比如 Java 中的 LinkedList,但是 C 语言并没有实现链表,所以 Redis 实现了自己的链表数据结构。Redis 的 List 的实现为一个 双向链表,即可以支持反向查找和遍历,更方便操作,不过带来了部分额外的内存开销。\n常用命令 # 命令 介绍 RPUSH key value1 value2 \u0026hellip; 在指定列表的尾部(右边)添加一个或多个元素 LPUSH key value1 value2 \u0026hellip; 在指定列表的头部(左边)添加一个或多个元素 LSET key index value 将指定列表索引 index 位置的值设置为 value LPOP key 移除并获取指定列表的第一个元素(最左边) RPOP key 移除并获取指定列表的最后一个元素(最右边) LLEN key 获取列表元素数量 LRANGE key start end 获取列表 start 和 end 之间 的元素 更多 Redis List 命令以及详细使用指南,请查看 Redis 官网对应的介绍:https://redis.io/commands/?group=list 。\n通过 RPUSH/LPOP 或者 LPUSH/RPOP实现队列 :\n\u0026gt; RPUSH myList value1 (integer) 1 \u0026gt; RPUSH myList value2 value3 (integer) 3 \u0026gt; LPOP myList \u0026#34;value1\u0026#34; \u0026gt; LRANGE myList 0 1 1) \u0026#34;value2\u0026#34; 2) \u0026#34;value3\u0026#34; \u0026gt; LRANGE myList 0 -1 1) \u0026#34;value2\u0026#34; 2) \u0026#34;value3\u0026#34; 通过 RPUSH/RPOP或者LPUSH/LPOP 实现栈 :\n\u0026gt; RPUSH myList2 value1 value2 value3 (integer) 3 \u0026gt; RPOP myList2 # 将 list的头部(最右边)元素取出 \u0026#34;value3\u0026#34; 我专门画了一个图方便大家理解 RPUSH , LPOP , lpush , RPOP 命令:\n通过 LRANGE 查看对应下标范围的列表元素 :\n\u0026gt; RPUSH myList value1 value2 value3 (integer) 3 \u0026gt; LRANGE myList 0 1 1) \u0026#34;value1\u0026#34; 2) \u0026#34;value2\u0026#34; \u0026gt; LRANGE myList 0 -1 1) \u0026#34;value1\u0026#34; 2) \u0026#34;value2\u0026#34; 3) \u0026#34;value3\u0026#34; 通过 LRANGE 命令,你可以基于 List 实现分页查询,性能非常高!\n通过 LLEN 查看链表长度 :\n\u0026gt; LLEN myList (integer) 3 应用场景 # 信息流展示\n举例 :最新文章、最新动态。 相关命令 : LPUSH、LRANGE。 消息队列\nRedis List 数据结构可以用来做消息队列,只是功能过于简单且存在很多缺陷,不建议这样做。\n相对来说,Redis 5.0 新增加的一个数据结构 Stream 更适合做消息队列一些,只是功能依然非常简陋。和专业的消息队列相比,还是有很多欠缺的地方比如消息丢失和堆积问题不好解决。\nHash(哈希) # 介绍 # Redis 中的 Hash 是一个 String 类型的 field-value(键值对) 的映射表,特别适合用于存储对象,后续操作的时候,你可以直接修改这个对象中的某些字段的值。\nHash 类似于 JDK1.8 前的 HashMap,内部实现也差不多(数组 + 链表)。不过,Redis 的 Hash 做了更多优化。\n常用命令 # 命令 介绍 HSET key field value 设置指定哈希表中指定字段的值 HSETNX key field value 只有指定字段不存在时设置指定字段的值 HMSET key field1 value1 field2 value2 \u0026hellip; 同时将一个或多个 field-value (域-值)对设置到指定哈希表中 HGET key field 获取指定哈希表中指定字段的值 HMGET key field1 field2 \u0026hellip; 获取指定哈希表中一个或者多个指定字段的值 HGETALL key 获取指定哈希表中所有的键值对 HEXISTS key field 查看指定哈希表中指定的字段是否存在 HDEL key field1 field2 \u0026hellip; 删除一个或多个哈希表字段 HLEN key 获取指定哈希表中字段的数量 HINCRBY key field increment 对指定哈希中的指定字段做运算操作(正数为加,负数为减) 更多 Redis Hash 命令以及详细使用指南,请查看 Redis 官网对应的介绍:https://redis.io/commands/?group=hash 。\n模拟对象数据存储 :\n\u0026gt; HMSET userInfoKey name \u0026#34;guide\u0026#34; description \u0026#34;dev\u0026#34; age 24 OK \u0026gt; HEXISTS userInfoKey name # 查看 key 对应的 value中指定的字段是否存在。 (integer) 1 \u0026gt; HGET userInfoKey name # 获取存储在哈希表中指定字段的值。 \u0026#34;guide\u0026#34; \u0026gt; HGET userInfoKey age \u0026#34;24\u0026#34; \u0026gt; HGETALL userInfoKey # 获取在哈希表中指定 key 的所有字段和值 1) \u0026#34;name\u0026#34; 2) \u0026#34;guide\u0026#34; 3) \u0026#34;description\u0026#34; 4) \u0026#34;dev\u0026#34; 5) \u0026#34;age\u0026#34; 6) \u0026#34;24\u0026#34; \u0026gt; HSET userInfoKey name \u0026#34;GuideGeGe\u0026#34; \u0026gt; HGET userInfoKey name \u0026#34;GuideGeGe\u0026#34; \u0026gt; HINCRBY userInfoKey age 2 (integer) 26 应用场景 # 对象数据存储场景\n举例 :用户信息、商品信息、文章信息、购物车信息。 相关命令 :HSET (设置单个字段的值)、HMSET(设置多个字段的值)、HGET(获取单个字段的值)、HMGET(获取多个字段的值)。 Set(集合) # 介绍 # Redis 中的 Set 类型是一种无序集合,集合中的元素没有先后顺序但都唯一,有点类似于 Java 中的 HashSet 。当你需要存储一个列表数据,又不希望出现重复数据时,Set 是一个很好的选择,并且 Set 提供了判断某个元素是否在一个 Set 集合内的重要接口,这个也是 List 所不能提供的。\n你可以基于 Set 轻易实现交集、并集、差集的操作,比如你可以将一个用户所有的关注人存在一个集合中,将其所有粉丝存在一个集合。这样的话,Set 可以非常方便的实现如共同关注、共同粉丝、共同喜好等功能。这个过程也就是求交集的过程。\n常用命令 # 命令 介绍 SADD key member1 member2 \u0026hellip; 向指定集合添加一个或多个元素 SMEMBERS key 获取指定集合中的所有元素 SCARD key 获取指定集合的元素数量 SISMEMBER key member 判断指定元素是否在指定集合中 SINTER key1 key2 \u0026hellip; 获取给定所有集合的交集 SINTERSTORE destination key1 key2 \u0026hellip; 将给定所有集合的交集存储在 destination 中 SUNION key1 key2 \u0026hellip; 获取给定所有集合的并集 SUNIONSTORE destination key1 key2 \u0026hellip; 将给定所有集合的并集存储在 destination 中 SDIFF key1 key2 \u0026hellip; 获取给定所有集合的差集 SDIFFSTORE destination key1 key2 \u0026hellip; 将给定所有集合的差集存储在 destination 中 SPOP key count 随机移除并获取指定集合中一个或多个元素 SRANDMEMBER key count 随机获取指定集合中指定数量的元素 更多 Redis Set 命令以及详细使用指南,请查看 Redis 官网对应的介绍:https://redis.io/commands/?group=set 。\n基本操作 :\n\u0026gt; SADD mySet value1 value2 (integer) 2 \u0026gt; SADD mySet value1 # 不允许有重复元素,因此添加失败 (integer) 0 \u0026gt; SMEMBERS mySet 1) \u0026#34;value1\u0026#34; 2) \u0026#34;value2\u0026#34; \u0026gt; SCARD mySet (integer) 2 \u0026gt; SISMEMBER mySet value1 (integer) 1 \u0026gt; SADD mySet2 value2 value3 (integer) 2 mySet : value1、value2 。 mySet2 : value2 、value3 。 求交集 :\n\u0026gt; SINTERSTORE mySet3 mySet mySet2 (integer) 1 \u0026gt; SMEMBERS mySet3 1) \u0026#34;value2\u0026#34; 求并集 :\n\u0026gt; SUNION mySet mySet2 1) \u0026#34;value3\u0026#34; 2) \u0026#34;value2\u0026#34; 3) \u0026#34;value1\u0026#34; 求差集 :\n\u0026gt; SDIFF mySet mySet2 # 差集是由所有属于 mySet 但不属于 A 的元素组成的集合 1) \u0026#34;value1\u0026#34; 应用场景 # 需要存放的数据不能重复的场景\n举例:网站 UV 统计(数据量巨大的场景还是 HyperLogLog更适合一些)、文章点赞、动态点赞等场景。 相关命令:SCARD(获取集合数量) 。 需要获取多个数据源交集、并集和差集的场景\n举例 :**共同好友(**交集)、共同粉丝(交集)、共同关注(交集)、好友推荐(差集)、音乐推荐(差集) 、订阅号推荐(差集+交集) 等场景。 相关命令:SINTER(交集)、SINTERSTORE (交集)、SUNION (并集)、SUNIONSTORE(并集)、SDIFF(差集)、SDIFFSTORE (差集)。 需要随机获取数据源中的元素的场景\n举例 :抽奖系统、随机。 相关命令:SPOP(随机获取集合中的元素并移除,适合不允许重复中奖的场景)、SRANDMEMBER(随机获取集合中的元素,适合允许重复中奖的场景)。 Sorted Set(有序集合) # 介绍 # Sorted Set 类似于 Set,但和 Set 相比,Sorted Set 增加了一个权重参数 score,使得集合中的元素能够按 score 进行有序排列,还可以通过 score 的范围来获取元素的列表。有点像是 Java 中 HashMap 和 TreeSet 的结合体。\n常用命令 # 命令 介绍 ZADD key score1 member1 score2 member2 \u0026hellip; 向指定有序集合添加一个或多个元素 ZCARD KEY 获取指定有序集合的元素数量 ZSCORE key member 获取指定有序集合中指定元素的 score 值 ZINTERSTORE destination numkeys key1 key2 \u0026hellip; 将给定所有有序集合的交集存储在 destination 中,对相同元素对应的 score 值进行 SUM 聚合操作,numkeys 为集合数量 ZUNIONSTORE destination numkeys key1 key2 \u0026hellip; 求并集,其它和 ZINTERSTORE 类似 ZDIFF destination numkeys key1 key2 \u0026hellip; 求差集,其它和 ZINTERSTORE 类似 ZRANGE key start end 获取指定有序集合 start 和 end 之间的元素(score 从低到高) ZREVRANGE key start end 获取指定有序集合 start 和 end 之间的元素(score 从高到底) ZREVRANK key member 获取指定有序集合中指定元素的排名(score 从大到小排序) 更多 Redis Sorted Set 命令以及详细使用指南,请查看 Redis 官网对应的介绍:https://redis.io/commands/?group=sorted-set 。\n基本操作 :\n\u0026gt; ZADD myZset 2.0 value1 1.0 value2 (integer) 2 \u0026gt; ZCARD myZset 2 \u0026gt; ZSCORE myZset value1 2.0 \u0026gt; ZRANGE myZset 0 1 1) \u0026#34;value2\u0026#34; 2) \u0026#34;value1\u0026#34; \u0026gt; ZREVRANGE myZset 0 1 1) \u0026#34;value1\u0026#34; 2) \u0026#34;value2\u0026#34; \u0026gt; ZADD myZset2 4.0 value2 3.0 value3 (integer) 2 myZset : value1(2.0)、value2(1.0) 。 myZset2 : value2 (4.0)、value3(3.0) 。 获取指定元素的排名 :\n\u0026gt; ZREVRANK myZset value1 0 \u0026gt; ZREVRANK myZset value2 1 求交集 :\n\u0026gt; ZINTERSTORE myZset3 2 myZset myZset2 1 \u0026gt; ZRANGE myZset3 0 1 WITHSCORES value2 5 求并集 :\n\u0026gt; ZUNIONSTORE myZset4 2 myZset myZset2 3 \u0026gt; ZRANGE myZset4 0 2 WITHSCORES value1 2 value3 3 value2 5 求差集 :\n\u0026gt; ZDIFF 2 myZset myZset2 WITHSCORES value1 2 应用场景 # 需要随机获取数据源中的元素根据某个权重进行排序的场景\n举例 :各种排行榜比如直播间送礼物的排行榜、朋友圈的微信步数排行榜、王者荣耀中的段位排行榜、话题热度排行榜等等。 相关命令 :ZRANGE (从小到大排序) 、 ZREVRANGE (从大到小排序)、ZREVRANK (指定元素排名)。 《Java 面试指北》 的「技术面试题篇」就有一篇文章详细介绍如何使用 Sorted Set 来设计制作一个排行榜。\n需要存储的数据有优先级或者重要程度的场景 比如优先级任务队列。\n举例 :优先级任务队列。 相关命令 :ZRANGE (从小到大排序) 、 ZREVRANGE (从大到小排序)、ZREVRANK (指定元素排名)。 参考 # Redis Data Structures :https://redis.com/redis-enterprise/data-structures/ 。 Redis Commands : https://redis.io/commands/ 。 Redis Data types tutorial:https://redis.io/docs/manual/data-types/data-types-tutorial/ 。 Redis 存储对象信息是用 Hash 还是 String : https://segmentfault.com/a/1190000040032006 "},{"id":246,"href":"/zh/docs/technology/Review/java_guide/database/Redis/ly0706lyredis-questions-02/","title":"redis面试题02","section":"Redis","content":" 转载自https://github.com/Snailclimb/JavaGuide(添加小部分笔记)感谢作者!\nRedis 事务 # 如何使用 Redis 事务? # Redis 可以通过 MULTI,EXEC,DISCARD 和 WATCH 等命令来实现事务(transaction)功能。\n\u0026gt; MULTI OK \u0026gt; SET PROJECT \u0026#34;JavaGuide\u0026#34; QUEUED \u0026gt; GET PROJECT QUEUED \u0026gt; EXEC 1) OK 2) \u0026#34;JavaGuide\u0026#34; MULTI 命令后可以输入多个命令,Redis 不会立即执行这些命令,而是将它们放到队列,当调用了 EXEC 命令后,再执行所有的命令。\n这个过程是这样的:\n开始事务(MULTI); 命令入队(批量操作 Redis 的命令,先进先出(FIFO)的顺序执行); 执行事务(EXEC)。 你也可以通过 DISCARD 命令取消一个事务,它会清空事务队列中保存的所有命令。\n\u0026gt; MULTI OK \u0026gt; SET PROJECT \u0026#34;JavaGuide\u0026#34; QUEUED \u0026gt; GET PROJECT QUEUED \u0026gt; DISCARD OK 你可以通过 WATCH 命令监听指定的 Key,当调用 EXEC 命令执行事务时,如果一个被 WATCH 命令监视的 Key 被 其他客户端/Session 修改的话,整个事务都不会被执行。\n# 客户端 1 \u0026gt; SET PROJECT \u0026#34;RustGuide\u0026#34; OK \u0026gt; WATCH PROJECT OK \u0026gt; MULTI OK \u0026gt; SET PROJECT \u0026#34;JavaGuide\u0026#34; QUEUED # 客户端 2 # 在客户端 1 执行 EXEC 命令提交事务之前修改 PROJECT 的值 \u0026gt; SET PROJECT \u0026#34;GoGuide\u0026#34; # 客户端 1 # 修改失败,因为 PROJECT 的值被客户端2修改了 \u0026gt; EXEC (nil) \u0026gt; GET PROJECT \u0026#34;GoGuide\u0026#34; 不过,如果 WATCH 与 事务 在同一个 Session 里,并且被 WATCH 监视的 Key 被修改的操作发生在事务内部,这个事务是可以被执行成功的(相关 issue : WATCH 命令碰到 MULTI 命令时的不同效果)。\n事务内部修改 WATCH 监视的 Key:\n\u0026gt; SET PROJECT \u0026#34;JavaGuide\u0026#34; OK \u0026gt; WATCH PROJECT OK \u0026gt; MULTI OK \u0026gt; SET PROJECT \u0026#34;JavaGuide1\u0026#34; QUEUED \u0026gt; SET PROJECT \u0026#34;JavaGuide2\u0026#34; QUEUED \u0026gt; SET PROJECT \u0026#34;JavaGuide3\u0026#34; QUEUED \u0026gt; EXEC 1) OK 2) OK 3) OK 127.0.0.1:6379\u0026gt; GET PROJECT \u0026#34;JavaGuide3\u0026#34; 事务外部修改 WATCH 监视的 Key:\n\u0026gt; SET PROJECT \u0026#34;JavaGuide\u0026#34; OK \u0026gt; WATCH PROJECT OK \u0026gt; SET PROJECT \u0026#34;JavaGuide2\u0026#34; OK \u0026gt; MULTI OK \u0026gt; GET USER QUEUED \u0026gt; EXEC (nil) Redis 官网相关介绍 https://redis.io/topics/transactions 如下:\nRedis 支持原子性吗? # Redis 的事务和我们平时理解的关系型数据库的事务不同。我们知道事务具有四大特性: 1. 原子性,2. 隔离性,3. 持久性,4. 一致性。\n原子性(Atomicity): 事务是最小的执行单位,不允许分割。事务的原子性确保动作要么全部完成,要么完全不起作用; 隔离性(Isolation): 并发访问数据库时,一个用户的事务不被其他事务所干扰,各并发事务之间数据库是独立的; 持久性(Durability): 一个事务被提交之后。它对数据库中数据的改变是持久的,即使数据库发生故障也不应该对其有任何影响。 一致性(Consistency): 执行事务前后,数据保持一致,多个事务对同一个数据读取的结果是相同的; Redis 事务在运行错误的情况下,除了执行过程中出现错误的命令外,其他命令都能正常执行。并且,Redis 是不支持回滚(roll back)操作的。因此,Redis 事务其实是不满足原子性的(而且不满足持久性)。\nRedis 官网也解释了自己为啥不支持回滚。简单来说就是 Redis 开发者们觉得没必要支持回滚,这样更简单便捷并且性能更好。Redis 开发者觉得即使命令执行错误也应该在开发过程中就被发现而不是生产过程中。\n你可以将 Redis 中的事务就理解为 :Redis 事务提供了一种将多个命令请求打包的功能。然后,再按顺序执行打包的所有命令,并且不会被中途打断。\n除了不满足原子性之外,事务中的每条命令都会与 Redis 服务器进行网络交互,这是比较浪费资源的行为。明明一次批量执行多个命令就可以了,这种操作实在是看不懂。\n因此,Redis 事务是不建议在日常开发中使用的。\n相关 issue :\nissue452: 关于 Redis 事务不满足原子性的问题 。 Issue491:关于 redis 没有事务回滚? 如何解决 Redis 事务的缺陷? # Redis 从 2.6 版本开始支持执行 Lua 脚本,它的功能和事务非常类似。我们可以利用 Lua 脚本来批量执行多条 Redis 命令,这些 Redis 命令会被提交到 Redis 服务器一次性执行完成,大幅减小了网络开销。\n一段 Lua 脚本可以视作一条命令执行,一段 Lua 脚本执行过程中不会有其他脚本或 Redis 命令同时执行,保证了操作不会被其他指令插入或打扰。\n如果 Lua 脚本运行时出错并中途结束,出错之后的命令是不会被执行的。并且,出错之前执行的命令是无法被撤销的。因此,严格来说,通过 Lua 脚本来批量执行 Redis 命令也是不满足原子性的。\n另外,Redis 7.0 新增了 Redis functions 特性,你可以将 Redis functions 看作是比 Lua 更强大的脚本。\nRedis 性能优化 # Redis bigkey # 什么是 bigkey? # 简单来说,如果一个 key 对应的 value 所占用的内存比较大,那这个 key 就可以看作是 bigkey。具体多大才算大呢?有一个不是特别精确的参考标准:string 类型的 value 超过 10 kb,复合类型的 value 包含的元素超过 5000 个(对于复合类型的 value 来说,不一定包含的元素越多,占用的内存就越多)。\nbigkey 有什么危害? # 除了会消耗更多的内存空间,bigkey 对性能也会有比较大的影响。\n因此,我们应该尽量避免写入 bigkey!\n如何发现 bigkey? # 1、使用 Redis 自带的 --bigkeys 参数来查找。\n# redis-cli -p 6379 --bigkeys # Scanning the entire keyspace to find biggest keys as well as # average sizes per key type. You can use -i 0.1 to sleep 0.1 sec # per 100 SCAN commands (not usually needed). [00.00%] Biggest string found so far \u0026#39;\u0026#34;ballcat:oauth:refresh_auth:f6cdb384-9a9d-4f2f-af01-dc3f28057c20\u0026#34;\u0026#39; with 4437 bytes [00.00%] Biggest list found so far \u0026#39;\u0026#34;my-list\u0026#34;\u0026#39; with 17 items -------- summary ------- Sampled 5 keys in the keyspace! Total key length in bytes is 264 (avg len 52.80) Biggest list found \u0026#39;\u0026#34;my-list\u0026#34;\u0026#39; has 17 items Biggest string found \u0026#39;\u0026#34;ballcat:oauth:refresh_auth:f6cdb384-9a9d-4f2f-af01-dc3f28057c20\u0026#34;\u0026#39; has 4437 bytes 1 lists with 17 items (20.00% of keys, avg size 17.00) 0 hashs with 0 fields (00.00% of keys, avg size 0.00) 4 strings with 4831 bytes (80.00% of keys, avg size 1207.75) 0 streams with 0 entries (00.00% of keys, avg size 0.00) 0 sets with 0 members (00.00% of keys, avg size 0.00) 0 zsets with 0 members (00.00% of keys, avg size 0.00 从这个命令的运行结果,我们可以看出:这个命令会扫描(Scan) Redis 中的所有 key ,会对 Redis 的性能有一点影响。并且,这种方式只能找出每种数据结构 top 1 bigkey(占用内存最大的 string 数据类型,包含元素最多的复合数据类型)。\n2、分析 RDB 文件\n通过分析 RDB 文件来找出 big key。这种方案的前提是你的 Redis 采用的是 RDB 持久化。\n网上有现成的代码/工具可以直接拿来使用:\nredis-rdb-tools :Python 语言写的用来分析 Redis 的 RDB 快照文件用的工具 rdb_bigkeys : Go 语言写的用来分析 Redis 的 RDB 快照文件用的工具,性能更好。 大量 key 集中过期问题 # 我在上面提到过:对于过期 key,Redis 采用的是 定期删除+惰性/懒汉式删除 策略。\n定期删除执行过程中,如果突然遇到大量过期 key 的话,客户端请求必须等待定期清理过期 key 任务线程执行完成,因为这个这个定期任务线程是在 Redis 主线程中执行的。这就导致客户端请求没办法被及时处理,响应速度会比较慢。\n如何解决呢?下面是两种常见的方法:\n给 key 设置随机过期时间。 开启 lazy-free(惰性删除/延迟释放) 。lazy-free 特性是 Redis 4.0 开始引入的,指的是让 Redis 采用异步方式延迟释放 key 使用的内存,将该操作交给单独的子线程处理,避免阻塞主线程。 个人建议不管是否开启 lazy-free,我们都尽量给 key 设置随机过期时间。\nRedis 生产问题 # 缓存穿透 # 什么是缓存穿透? # 缓存穿透说简单点就是大量请求的 key 是不合理的,根本不存在于缓存中,也不存在于数据库中 。这就导致这些请求直接到了数据库上,根本没有经过缓存这一层,对数据库造成了巨大的压力,可能直接就被这么多请求弄宕机了。\n举个例子:某个黑客故意制造一些非法的 key 发起大量请求,导致大量请求落到数据库,结果数据库上也没有查到对应的数据。也就是说这些请求最终都落到了数据库上,对数据库造成了巨大的压力。\n有哪些解决办法? # 最基本的就是首先做好参数校验,一些不合法的参数请求直接抛出异常信息返回给客户端。比如查询的数据库 id 不能小于 0、传入的邮箱格式不对的时候直接返回错误消息给客户端等等。\n1)缓存无效 key\n如果缓存和数据库都查不到某个 key 的数据就写一个到 Redis 中去并设置过期时间,具体命令如下: SET key value EX 10086 。这种方式可以解决请求的 key 变化不频繁的情况,如果黑客恶意攻击,每次构建不同的请求 key,会导致 Redis 中缓存大量无效的 key 。很明显,这种方案并不能从根本上解决此问题。如果非要用这种方式来解决穿透问题的话,尽量将无效的 key 的过期时间设置短一点比如 1 分钟。\n另外,这里多说一嘴,一般情况下我们是这样设计 key 的: 表名:列名:主键名:主键值 。\n如果用 Java 代码展示的话,差不多是下面这样的:\npublic Object getObjectInclNullById(Integer id) { // 从缓存中获取数据 Object cacheValue = cache.get(id); // 缓存为空 if (cacheValue == null) { // 从数据库中获取 Object storageValue = storage.get(key); // 缓存空对象 cache.set(key, storageValue); // 如果存储数据为空,需要设置一个过期时间(300秒) if (storageValue == null) { // 必须设置过期时间,否则有被攻击的风险 cache.expire(key, 60 * 5); } return storageValue; } return cacheValue; } 2)布隆过滤器\n布隆过滤器是一个非常神奇的数据结构,通过它我们可以非常方便地判断一个给定数据是否存在于海量数据中。我们需要的就是判断 key 是否合法,有没有感觉布隆过滤器就是我们想要找的那个“人”。\n具体是这样做的:把所有可能存在的请求的值都存放在布隆过滤器中,当用户请求过来,先判断用户发来的请求的值是否存在于布隆过滤器中。不存在的话,直接返回请求参数错误信息给客户端,存在的话才会走下面的流程。\n加入布隆过滤器之后的缓存处理流程图如下。\n但是,需要注意的是布隆过滤器可能会存在误判的情况。总结来说就是: 布隆过滤器说某个元素存在,小概率会误判。布隆过滤器说某个元素不在,那么这个元素一定不在。\n为什么会出现误判的情况呢? 我们还要从布隆过滤器的原理来说!\n我们先来看一下,当一个元素加入布隆过滤器中的时候,会进行哪些操作:\n使用布隆过滤器中的哈希函数对元素值进行计算,得到哈希值(有几个哈希函数得到几个哈希值)。 根据得到的哈希值,在位数组中把对应下标的值置为 1。 我们再来看一下,当我们需要判断一个元素是否存在于布隆过滤器的时候,会进行哪些操作:\n对给定元素再次进行相同的哈希计算; 得到值之后判断位数组中的每个元素是否都为 1,如果值都为 1,那么说明这个值在布隆过滤器中,如果存在一个值不为 1,说明该元素不在布隆过滤器中。 然后,一定会出现这样一种情况:不同的字符串可能哈希出来的位置相同。 (可以适当增加位数组大小或者调整我们的哈希函数来降低概率)\n更多关于布隆过滤器的内容可以看我的这篇原创: 《不了解布隆过滤器?一文给你整的明明白白!》 ,强烈推荐,个人感觉网上应该找不到总结的这么明明白白的文章了。\n缓存击穿 # 什么是缓存击穿? # 缓存击穿中,请求的 key 对应的是 热点数据 ,该数据 存在于数据库中,但不存在于缓存中(通常是因为缓存中的那份数据已经过期) 。这就可能会导致瞬时大量的请求直接打到了数据库上,对数据库造成了巨大的压力,可能直接就被这么多请求弄宕机了。\n举个例子 :秒杀进行过程中,缓存中的某个秒杀商品的数据突然过期,这就导致瞬时大量对该商品的请求直接落到数据库上,对数据库造成了巨大的压力。\n有哪些解决办法? # 设置热点数据永不过期或者过期时间比较长。 针对热点数据提前预热,将其存入缓存中并设置合理的过期时间比如秒杀场景下的数据在秒杀结束之前不过期。 请求数据库写数据到缓存之前,先获取互斥锁,保证只有一个请求会落到数据库上,减少数据库的压力。 缓存穿透和缓存击穿有什么区别? # 缓存穿透中,请求的 key 既不存在于缓存中,也不存在于数据库中。\n缓存击穿中,请求的 key 对应的是 热点数据 ,该数据 存在于数据库中,但不存在于缓存中(通常是因为缓存中的那份数据已经过期) 。\n缓存雪崩 # 什么是缓存雪崩? # 我发现缓存雪崩这名字起的有点意思,哈哈。\n实际上,缓存雪崩描述的就是这样一个简单的场景:缓存在同一时间大面积的失效,导致大量的请求都直接落到了数据库上,对数据库造成了巨大的压力。 这就好比雪崩一样,摧枯拉朽之势,数据库的压力可想而知,可能直接就被这么多请求弄宕机了。\n另外,缓存服务宕机也会导致缓存雪崩现象,导致所有的请求都落到了数据库上。\n举个例子 :数据库中的大量数据在同一时间过期,这个时候突然有大量的请求需要访问这些过期的数据。这就导致大量的请求直接落到数据库上,对数据库造成了巨大的压力。\n有哪些解决办法? # 针对 Redis 服务不可用的情况:\n采用 Redis 集群,避免单机出现问题整个缓存服务都没办法使用。 限流,避免同时处理大量的请求。 针对热点缓存失效的情况:\n设置不同的失效时间比如随机设置缓存的失效时间。 缓存永不失效(不太推荐,实用性太差)。 设置二级缓存。 缓存雪崩和缓存击穿有什么区别? # 缓存雪崩和缓存击穿比较像,但缓存雪崩导致的原因是缓存中的大量或者所有数据失效,缓存击穿导致的原因主要是某个热点数据不存在于缓存中(通常是因为缓存中的那份数据已经过期)。\n如何保证缓存和数据库数据的一致性? # 细说的话可以扯很多,但是我觉得其实没太大必要(小声 BB:很多解决方案我也没太弄明白)。我个人觉得引入缓存之后,如果为了短时间的不一致性问题,选择让系统设计变得更加复杂的话,完全没必要。\n下面单独对 Cache Aside Pattern(旁路缓存模式) 来聊聊。\nCache Aside Pattern 中遇到写请求是这样的:更新 DB,然后直接删除 cache 。\n如果更新数据库成功,而删除缓存这一步失败的情况的话,简单说两个解决方案:\n缓存失效时间变短(不推荐,治标不治本) :我们让缓存数据的过期时间变短,这样的话缓存就会从数据库中加载数据。另外,这种解决办法对于先操作缓存后操作数据库的场景不适用。 增加 cache 更新重试机制(常用): 如果 cache 服务当前不可用导致缓存删除失败的话,我们就隔一段时间进行重试,重试次数可以自己定。如果多次重试还是失败的话,我们可以把当前更新失败的 key 存入队列中,等缓存服务可用之后,再将缓存中对应的 key 删除即可。 相关文章推荐: 缓存和数据库一致性问题,看这篇就够了 - 水滴与银弹\nRedis 集群 # Redis Sentinel :\n什么是 Sentinel? 有什么用? Sentinel 如何检测节点是否下线?主观下线与客观下线的区别? Sentinel 是如何实现故障转移的? 为什么建议部署多个 sentinel 节点(哨兵集群)? Sentinel 如何选择出新的 master(选举机制)? 如何从 Sentinel 集群中选择出 Leader ? Sentinel 可以防止脑裂吗? Redis Cluster :\n为什么需要 Redis Cluster?解决了什么问题?有什么优势? Redis Cluster 是如何分片的? 为什么 Redis Cluster 的哈希槽是 16384 个? 如何确定给定 key 的应该分布到哪个哈希槽中? Redis Cluster 支持重新分配哈希槽吗? Redis Cluster 扩容缩容期间可以提供服务吗? Redis Cluster 中的节点是怎么进行通信的? 参考答案 : Redis 集群详解(付费)。\n参考 # 《Redis 开发与运维》 《Redis 设计与实现》 Redis Transactions : https://redis.io/docs/manual/transactions/ 。 "},{"id":247,"href":"/zh/docs/technology/Review/java_guide/database/Redis/ly0705lyredis-questions-01/","title":"redis面试题01","section":"Redis","content":" 转载自https://github.com/Snailclimb/JavaGuide(添加小部分笔记)感谢作者!\nRedis 基础 # 什么是 Redis? # Redis 是一个基于 C 语言开发的开源数据库(BSD 许可),与传统数据库不同的是 Redis 的数据是存在内存中的(内存数据库),读写速度非常快,被广泛应用于缓存方向。并且,Redis 存储的是 KV 键值对数据。\n为了满足不同的业务场景,Redis 内置了多种数据类型实现(比如 String、Hash、【List、Set、】Sorted Set、Bitmap)。并且,Redis 还支持事务 、持久化、Lua 脚本、多种开箱即用的集群方案(Redis Sentinel、Redis Cluster)。\nRedis 没有外部依赖,Linux 和 OS X 是 Redis 开发和测试最多的两个操作系统,官方推荐生产环境使用 Linux 部署 Redis。\n个人学习的话,你可以自己本机安装 Redis 或者通过 Redis 官网提供的 在线 Redis 环境来实际体验 Redis。\n全世界有非常多的网站使用到了 Redis , techstacks.io 专门维护了一个 使用 Redis 的热门站点列表 ,感兴趣的话可以看看。\nRedis 为什么这么快? # Redis 内部做了非常多的性能优化,比较重要的主要有下面 3 点:\nRedis 基于内存,内存的访问速度是磁盘的上千倍; Redis 基于 Reactor 模式设计开发了一套高效的事件处理模型,主要是单线程事件循环和 IO 多路复用(Redis 线程模式后面会详细介绍到); Redis 内置了多种优化过后的数据结构实现,性能非常高。 下面这张图片总结的挺不错的,分享一下,出自 Why is Redis so fast? 。\n分布式缓存常见的技术选型方案有哪些? # 分布式缓存的话,比较老牌同时也是使用的比较多的还是 Memcached 和 Redis。不过,现在基本没有看过还有项目使用 Memcached 来做缓存,都是直接用 Redis。\nMemcached 是分布式缓存最开始兴起的那会,比较常用的。后来,随着 Redis 的发展,大家慢慢都转而使用更加强大的 Redis 了。\n另外,腾讯也开源了一款类似于 Redis 的分布式高性能 KV 存储数据库,基于知名的开源项目 RocksDB 作为存储引擎 ,100% 兼容 Redis 协议和 Redis4.0 所有数据模型,名为 Tendis (腾讯的)。\n关于 Redis 和 Tendis 的对比,腾讯官方曾经发过一篇文章: Redis vs Tendis:冷热混合存储版架构揭秘 ,可以简单参考一下。\n从这个项目的 Github 提交记录可以看出,Tendis 开源版几乎已经没有被维护更新了,加上其关注度并不高,使用的公司也比较少。因此,不建议你使用 Tendis 来实现分布式缓存。\n说一下 Redis 和 Memcached 的区别和共同点 # 现在公司一般都是用 Redis 来实现缓存,而且 Redis 自身也越来越强大了!不过,了解 Redis 和 Memcached 的区别和共同点,有助于我们在做相应的技术选型的时候,能够做到有理有据!\n共同点 :\n都是基于内存的数据库,一般都用来当做缓存使用。 都有过期策略。 两者的性能都非常高。 区别 :\nRedis 支持更丰富的数据类型(支持更复杂的应用场景)。Redis 不仅仅支持简单(string)的 k/v 类型的数据,同时还提供 list,set,zset,hash 等数据结构的存储。Memcached 只支持最简单的 k/v 数据类型。\nRedis 支持数据的持久化,可以将内存中的数据保持在磁盘中,重启的时候可以再次加载进行使用,而 Memcached 把数据全部存在内存之中。\nRedis 有灾难恢复机制。 因为可以把缓存中的数据持久化到磁盘上。\nRedis 在服务器内存使用完之后,可以将不用的数据放到磁盘上。但是,Memcached 在服务器内存使用完之后,就会直接报异常。\nMemcached 没有原生的集群模式,需要依靠客户端来实现往集群中分片写入数据;但是 Redis 目前是原生支持 cluster 模式的。\nMemcached 是多线程,非阻塞 IO 复用的网络模型;Redis 使用单线程的多路 IO 复用模型。 (Redis 6.0 引入了多线程 IO )\n非阻塞的 IO复用\n单线程的多路IO复用\nRedis 支持发布订阅模型、Lua 脚本、事务等功能,而 Memcached 不支持。并且,Redis 支持更多的编程语言。\nMemcached 过期数据的删除策略只用了惰性删除,而 Redis 同时使用了惰性删除与定期删除。\n相信看了上面的对比之后,我们已经没有什么理由可以选择使用 Memcached 来作为自己项目的分布式缓存了。\n为什么要用 Redis/为什么要用缓存? # 下面我们主要从“高性能”和“高并发”这两点来回答这个问题。\n高性能\n假如用户第一次访问数据库中的某些数据的话,这个过程是比较慢,毕竟是从硬盘中读取的。但是,如果说,用户访问的数据属于高频数据并且不会经常改变的话,那么我们就可以很放心地将该用户访问的数据存在缓存中。\n这样有什么好处呢? 那就是保证用户下一次再访问这些数据的时候就可以直接从缓存中获取了。操作缓存就是直接操作内存,所以速度相当快。\n高并发\n一般像 MySQL 这类的数据库的 QPS 大概都在 1w 左右(4 核 8g) ,但是使用 Redis 缓存之后很容易达到 10w+,甚至最高能达到 30w+(就单机 Redis 的情况,Redis 集群的话会更高)。\nQPS(Query Per Second):服务器每秒可以执行的查询次数;\n由此可见,直接操作缓存能够承受的数据库请求数量是远远大于直接访问数据库的,所以我们可以考虑把数据库中的部分数据转移到缓存中去,这样用户的一部分请求会直接到缓存这里而不用经过数据库。进而,我们也就提高了系统整体的并发。\nRedis 除了做缓存,还能做什么? # 分布式锁 : 通过 Redis 来做分布式锁是一种比较常见的方式。通常情况下,我们都是基于 Redisson 来实现分布式锁。关于 Redis 实现分布式锁的详细介绍,可以看我写的这篇文章: 分布式锁详解 。 限流 :一般是通过 Redis + Lua 脚本的方式来实现限流。相关阅读: 《我司用了 6 年的 Redis 分布式限流器,可以说是非常厉害了!》。 消息队列 :Redis 自带的 list 数据结构可以作为一个简单的队列使用。Redis 5.0 中增加的 Stream 类型的数据结构更加适合用来做消息队列。它比较类似于 Kafka,有主题和消费组的概念,支持消息持久化以及 ACK 机制。 复杂业务场景 :通过 Redis 以及 Redis 扩展(比如 Redisson)提供的数据结构,我们可以很方便地完成很多复杂的业务场景比如通过 bitmap 统计活跃用户、通过 sorted set 维护排行榜。 \u0026hellip;\u0026hellip; Redis 可以做消息队列么? # Redis 5.0 新增加的一个数据结构 Stream 可以用来做消息队列,Stream 支持:\n发布 / 订阅模式 按照消费者组进行消费 消息持久化( RDB 和 AOF) 不过,和专业的消息队列相比,还是有很多欠缺的地方比如消息丢失和堆积问题不好解决。因此,我们通常建议是不使用 Redis 来做消息队列的,你完全可以选择市面上比较成熟的一些消息队列比如 RocketMQ、Kafka。\n相关文章推荐: Redis 消息队列的三种方案(List、Streams、Pub/Sub)。\n如何基于 Redis 实现分布式锁? # 关于 Redis 实现分布式锁的详细介绍,可以看我写的这篇文章: 分布式锁详解 。\nRedis 数据结构 # Redis 常用的数据结构有哪些? # 5 种基础数据结构 :String(字符串)、List(列表)、Set(集合)、Hash(散列)、Zset(有序集合)。 3 种特殊数据结构 :HyperLogLogs(基数统计)、Bitmap (位存储)、Geospatial (地理位置)。 关于 5 种基础数据结构的详细介绍请看这篇文章: Redis 5 种基本数据结构详解。\n关于 3 种特殊数据结构的详细介绍请看这篇文章: Redis 3 种特殊数据结构详解。\nString 的应用场景有哪些? # 常规数据(比如 session、token、、序列化后的对象)的缓存; 计数比如用户单位时间的请求数(简单限流可以用到)、页面单位时间的访问数; 分布式锁(利用 SETNX key value 命令可以实现一个最简易的分布式锁); \u0026hellip;\u0026hellip; 关于 String 的详细介绍请看这篇文章: Redis 5 种基本数据结构详解。\nString 还是 Hash 存储对象数据更好呢? # String 存储的是序列化后的对象数据,存放的是整个对象。Hash 是对对象的每个字段单独存储,可以获取部分字段的信息,也可以修改或者添加部分字段,节省网络流量。如果对象中某些字段需要经常变动或者经常需要单独查询对象中的个别字段信息,Hash 就非常适合。 String 存储相对来说更加节省内存,缓存相同数量的对象数据,String 消耗的内存约是 Hash 的一半。并且,存储具有多层嵌套的对象时也方便很多。如果系统对性能和资源消耗非常敏感的话,String 就非常适合。 在绝大部分情况,我们建议使用 String 来存储对象数据即可!\nString 的底层实现是什么? # Redis 是基于 C 语言编写的,但 Redis 的 String 类型的底层实现并不是 C 语言中的字符串(即以空字符 \\0 结尾的字符数组),而是自己编写了 SDS(Simple Dynamic String,简单动态字符串) 来作为底层实现。\n[daɪˈnæmɪk] 动态的\nSDS 最早是 Redis 作者为日常 C 语言开发而设计的 C 字符串,后来被应用到了 Redis 上,并经过了大量的修改完善以适合高性能操作。\nRedis7.0 的 SDS 的部分源码如下(https://github.com/redis/redis/blob/7.0/src/sds.h):\n/* Note: sdshdr5 is never used, we just access the flags byte directly. * However is here to document the layout of type 5 SDS strings. */ struct __attribute__ ((__packed__)) sdshdr5 { unsigned char flags; /* 3 lsb of type, and 5 msb of string length */ char buf[]; }; struct __attribute__ ((__packed__)) sdshdr8 { uint8_t len; /* used */ uint8_t alloc; /* excluding the header and null terminator */ unsigned char flags; /* 3 lsb of type, 5 unused bits */ char buf[]; }; struct __attribute__ ((__packed__)) sdshdr16 { uint16_t len; /* used */ uint16_t alloc; /* excluding the header and null terminator */ unsigned char flags; /* 3 lsb of type, 5 unused bits */ char buf[]; }; struct __attribute__ ((__packed__)) sdshdr32 { uint32_t len; /* used */ uint32_t alloc; /* excluding the header and null terminator */ unsigned char flags; /* 3 lsb of type, 5 unused bits */ char buf[]; }; struct __attribute__ ((__packed__)) sdshdr64 { uint64_t len; /* used */ uint64_t alloc; /* excluding the header and null terminator */ unsigned char flags; /* 3 lsb of type, 5 unused bits */ char buf[]; }; 通过源码可以看出,SDS 共有五种实现方式 SDS_TYPE_5(并未用到)、SDS_TYPE_8、SDS_TYPE_16、SDS_TYPE_32、SDS_TYPE_64,其中只有后四种实际用到。Redis 会根据初始化的长度决定使用哪种类型,从而减少内存的使用。\n类型 字节 位 sdshdr5 \u0026lt; 1 \u0026lt;8 sdshdr8 1 8 sdshdr16 2 16 sdshdr32 4 32 sdshdr64 8 64 对于后四种实现都包含了下面这 4 个属性:\nlen :字符串的长度也就是已经使用的字节数 alloc:总共可用的字符空间大小,alloc-len 就是 SDS 剩余的空间大小 buf[] :实际存储字符串的数组 flags :低三位保存类型标志 SDS 相比于 C 语言中的字符串有如下提升:\n可以避免缓冲区溢出 :C 语言中的字符串被修改(比如拼接)时,一旦没有分配足够长度的内存空间,就会造成缓冲区溢出。SDS 被修改时,会先根据 len 属性检查空间大小是否满足要求,如果不满足,则先扩展至所需大小再进行修改操作。 获取字符串长度的复杂度较低 : C 语言中的字符串的长度通常是经过遍历计数来实现的,时间复杂度为 O(n)。SDS 的长度获取直接读取 len 属性即可,时间复杂度为 O(1)。 减少内存分配次数 : 为了避免修改(增加/减少)字符串时,每次都需要重新分配内存(C 语言的字符串是这样的),SDS 实现了空间预分配和惰性空间释放两种优化策略。当 SDS 需要增加字符串时,Redis 会为 SDS 分配好内存,并且根据特定的算法分配多余的内存,这样可以减少连续执行字符串增长操作所需的内存重分配次数。当 SDS 需要减少字符串时,这部分内存不会立即被回收,会被记录下来,等待后续使用(支持手动释放,有对应的 API)。 二进制安全 :C 语言中的字符串以空字符 \\0 作为字符串结束的标识,这存在一些问题,像一些二进制文件(比如图片、视频、音频)就可能包括空字符,C 字符串无法正确保存。SDS 使用 len 属性判断字符串是否结束,不存在这个问题。 多提一嘴,很多文章里 SDS 的定义是下面这样的:\nstruct sdshdr { unsigned int len; unsigned int free; char buf[]; }; 这个也没错,Redis 3.2 之前就是这样定义的。后来,由于这种方式的定义存在问题,len 和 free 的定义用了 4 个字节,造成了浪费。Redis 3.2 之后,Redis 改进了 SDS 的定义,将其划分为了现在的 5 种类型。\n购物车信息用 String 还是 Hash 存储更好呢? # 由于购物车中的商品频繁修改和变动,购物车信息建议使用 Hash 存储:\n用户 id 为 key 商品 id 为 field,商品数量为 value 那用户购物车信息的维护具体应该怎么操作呢?\n用户添加商品就是往 Hash 里面增加新的 field 与 value; 查询购物车信息就是遍历对应的 Hash; 更改商品数量直接修改对应的 value 值(直接 set 或者做运算皆可); 删除商品就是删除 Hash 中对应的 field; 清空购物车直接删除对应的 key 即可。 这里只是以业务比较简单的购物车场景举例,实际电商场景下,field 只保存一个商品 id 是没办法满足需求的。\n使用 Redis 实现一个排行榜怎么做? # Redis 中有一个叫做 sorted set 的数据结构经常被用在各种排行榜的场景,比如直播间送礼物的排行榜、朋友圈的微信步数排行榜、王者荣耀中的段位排行榜、话题热度排行榜等等。\n相关的一些 Redis 命令: ZRANGE (从小到大排序) 、 ZREVRANGE (从大到小排序)、ZREVRANK (指定元素排名)。\n《Java 面试指北》\n使用 Set 实现抽奖系统需要用到什么命令? # SPOP key count : 随机移除并获取指定集合中一个或多个元素,适合不允许重复中奖的场景。\nSRANDMEMBER key count : 随机获取指定集合中指定数量的元素,适合允许重复中奖的场景。\n重复中奖,这里说的是第一次中的是a,第二次可能也是a。而不是说一次中将的人有两个a\n使用 Bitmap 统计活跃用户怎么做? # 使用日期(精确到天)作为 key,然后用户 ID 为 offset,如果当日活跃过就设置为 1。\n初始化数据:\n\u0026gt; SETBIT 20210308 1 1 (integer) 0 \u0026gt; SETBIT 20210308 2 1 (integer) 0 \u0026gt; SETBIT 20210309 1 1 (integer) 0 统计 20210308~20210309 总活跃用户数:\n\u0026gt; BITOP and desk1 20210308 20210309 (integer) 1 \u0026gt; BITCOUNT desk1 (integer) 1 统计 20210308~20210309 在线活跃用户数:\n\u0026gt; BITOP or desk2 20210308 20210309 (integer) 1 \u0026gt; BITCOUNT desk2 (integer) 2 使用 HyperLogLog 统计页面 UV 怎么做? # Unique Visitor,即有多少个用户访问了我们的网站\n1、将访问指定页面的每个用户 ID 添加到 HyperLogLog 中。\nPFADD PAGE_1:UV USER1 USER2 ...... USERn 2、统计指定页面的 UV。\nPFCOUNT PAGE_1:UV #会自动扣除重复的 Redis 线程模型 # 对于读写命令来说,Redis 一直是单线程模型。不过,在 Redis 4.0 版本之后引入了多线程来执行一些大键值对的异步删除操作, Redis 6.0 版本之后引入了多线程来处理网络请求(提高网络 IO 读写性能)。\nRedis 单线程模型了解吗? # Redis 基于 Reactor 模式设计开发了一套高效的事件处理模型 (Netty 的线程模型也基于 Reactor 模式,Reactor 模式不愧是高性能 IO 的基石),这套事件处理模型对应的是 Redis 中的文件事件处理器(file event handler)。由于**文件事件处理器(file event handler)**是单线程方式运行的,所以我们一般都说 Redis 是单线程模型。\n《Redis 设计与实现》有一段话是如是介绍文件事件处理器的,我觉得写得挺不错。\nRedis 基于 Reactor 模式开发了自己的网络事件处理器:这个处理器被称为文件事件处理器(file event handler)。\n文件事件处理器使用 I/O 多路复用(multiplexing)程序来同时监听多个套接字,并根据套接字目前执行的任务来为套接字关联不同的事件处理器。 当被监听的套接字准备好执行连接应答(accept)、读取(read)、写入(write)、关 闭(close)等操作时,与操作相对应的文件事件就会产生,这时文件事件处理器就会调用套接字之前关联好的事件处理器来处理这些事件。 虽然文件事件处理器以单线程方式运行,但通过使用 I/O 多路复用程序来监听多个套接字,文件事件处理器既实现了高性能的网络通信模型,又可以很好地与 Redis 服务器中其他同样以单线程方式运行的模块进行对接,这保持了 Redis 内部单线程设计的简单性。\n既然是单线程,那怎么监听大量的客户端连接呢?\nRedis 通过 IO 多路复用程序 来监听来自客户端的大量连接(或者说是监听多个 socket),它会将感兴趣的事件及类型(读、写)注册到内核中并监听每个事件是否发生。\n这样的好处非常明显: I/O 多路复用技术的使用让 Redis 不需要额外创建多余的线程来监听客户端的大量连接,降低了资源的消耗(和 NIO 中的 Selector 组件很像)。\n文件事件处理器(file event handler)主要是包含 4 个部分:\n多个 socket(客户端连接)\nIO 多路复用程序(支持多个客户端连接的关键)\n文件事件分派器(将 socket 关联到相应的事件处理器)\n事件处理器(连接应答处理器、命令请求处理器、命令回复处理器)\n相关阅读: Redis 事件机制详解 。\nRedis6.0 之前为什么不使用多线程? # 虽然说 Redis 是单线程模型,但是,实际上,Redis 在 4.0 之后的版本中就已经加入了对多线程的支持。\n不过,Redis 4.0 增加的多线程主要是针对一些大键值对的删除操作的命令,使用这些命令就会使用主线程之外的其他线程来“异步处理”。\n为此,Redis 4.0 之后新增了**UNLINK(可以看作是 DEL 的异步版本)、FLUSHALL ASYNC(清空所有数据库的所有 key,不仅仅是当前 SELECT 的数据库)、FLUSHDB ASYNC**(清空当前 SELECT 数据库中的所有 key)等异步命令。\n大体上来说,Redis 6.0 之前主要还是单线程处理。\n那 Redis6.0 之前为什么不使用多线程? 我觉得主要原因有 3 点:\n单线程编程容易并且更容易维护; Redis 的性能瓶颈不在 CPU ,主要在内存和网络; 多线程就会存在死锁、线程上下文切换等问题,甚至会影响性能。 相关阅读: 为什么 Redis 选择单线程模型 。\nRedis6.0 之后为何引入了多线程? # Redis6.0 引入多线程主要是为了提高网络 IO 读写性能,因为这个算是 Redis 中的一个性能瓶颈(Redis 的瓶颈主要受限于内存和网络)。\n虽然,Redis6.0 引入了多线程,但是 Redis 的多线程只是在网络数据的读写这类耗时操作上使用了,执行命令仍然是单线程顺序执行。因此,你也不需要担心线程安全问题。\nRedis6.0 的多线程默认是禁用的,只使用主线程。如需开启需要设置IO线程数 \u0026gt; 1,需要修改 redis 配置文件 redis.conf :\nio-threads 4 #设置1的话只会开启主线程,官网建议4核的机器建议设置为2或3个线程,8核的建议设置为6个线程 另外:\nio-threads的个数一旦设置,不能通过config动态设置 当设置ssl后,io-threads将不工作 开启多线程后,默认只会使用多线程进行IO写入writes,即发送数据给客户端,如果需要开启多线程IO读取reads,同样需要修改 redis 配置文件 redis.conf :\nio-threads-do-reads yes 但是官网描述开启多线程读并不能有太大提升,因此一般情况下并不建议开启\n相关阅读:\nRedis 6.0 新特性-多线程连环 13 问! Redis 多线程网络模型全面揭秘(推荐) Redis 内存管理 # Redis 给缓存数据设置过期时间有啥用? # 一般情况下,我们设置保存的缓存数据的时候都会设置一个过期时间。为什么呢?\n因为内存是有限的,如果缓存中的所有数据都是一直保存的话,分分钟直接 Out of memory。\nRedis 自带了给缓存数据设置过期时间的功能,比如:\n127.0.0.1:6379\u0026gt; expire key 60 # 数据在 60s 后过期 (integer) 1 127.0.0.1:6379\u0026gt; setex key 60 value # 数据在 60s 后过期 (setex:[set] + [ex]pire) OK 127.0.0.1:6379\u0026gt; ttl key # 查看数据还有多久过期 (integer) 56 注意:Redis 中除了字符串类型有自己独有设置过期时间的命令 setex 外,其他方法都需要依靠 expire 命令来设置过期时间 。另外, persist 命令可以移除一个键的过期时间。\n过期时间除了有助于缓解内存的消耗,还有什么其他用么?\n很多时候,我们的业务场景就是需要某个数据只在某一时间段内存在,比如我们的短信验证码可能只在 1 分钟内有效,用户登录的 token 可能只在 1 天内有效。\n如果使用传统的数据库来处理的话,一般都是自己判断过期,这样更麻烦并且性能要差很多。\nRedis 是如何判断数据是否过期的呢? # Redis 通过一个叫做过期字典(可以看作是 hash 表)来保存数据过期的时间。过期字典的键指向 Redis 数据库中的某个 key(键),过期字典的值是一个 long long 类型的整数,这个整数保存了 key 所指向的数据库键的过期时间(毫秒精度的 UNIX 时间戳)。\n过期字典是存储在 redisDb 这个结构里的:\ntypedef struct redisDb { ... dict *dict; //数据库键空间,保存着数据库中所有键值对 dict *expires // 过期字典,保存着键的过期时间 ... } redisDb; 过期的数据的删除策略了解么? # 如果假设你设置了一批 key 只能存活 1 分钟,那么 1 分钟后,Redis 是怎么对这批 key 进行删除的呢?\n常用的过期数据的删除策略就两个(重要!自己造缓存轮子的时候需要格外考虑的东西):\n惰性删除 :只会在取出 key 的时候才对数据进行过期检查。这样对 CPU 最友好,但是可能会造成太多过期 key 没有被删除。 定期删除 : 每隔一段时间抽取一批 key 执行删除过期 key 操作。并且,Redis 底层会通过限制删除操作执行的时长和频率来减少删除操作对 CPU 时间的影响。 定期删除对内存更加友好,惰性删除对 CPU 更加友好。两者各有千秋,所以 Redis 采用的是 定期删除+惰性/懒汉式删除 。\n但是,仅仅通过给 key 设置过期时间还是有问题的。因为还是可能存在定期删除和惰性删除漏掉了很多过期 key 的情况。这样就导致大量过期 key 堆积在内存里,然后就 Out of memory 了。\n怎么解决这个问题呢?答案就是:Redis 内存淘汰机制。\nRedis 内存淘汰机制了解么? # 相关问题:MySQL 里有 2000w 数据,Redis 中只存 20w 的数据,如何保证 Redis 中的数据都是热点数据?\n当缓存数据越来越多,Redis 不可避免的会被写满,这时候就涉及到 Redis 的内存淘汰机制了\nRedis 提供 6 种数据淘汰策略:\nvolatile-lru(least recently used):从已设置过期时间的数据集(server.db[i].expires)中挑选最近最少使用的数据淘汰 volatile-ttl:从已设置过期时间的数据集(server.db[i].expires)中挑选将要过期的数据淘汰 volatile-random:从已设置过期时间的数据集(server.db[i].expires)中任意选择数据淘汰 allkeys-lru(least recently used):当内存不足以容纳新写入数据时,在键空间中,移除最近最少使用的 key(这个是最常用的) allkeys-random:从数据集(server.db[i].dict)中任意选择数据淘汰 no-eviction:禁止驱逐数据,也就是说当内存不足以容纳新写入数据时,新写入操作会报错。这个应该没人使用吧! 4.0 版本后增加以下两种:\nvolatile-lfu(least frequently used):从已设置过期时间的数据集(server.db[i].expires)中挑选最不经常使用的数据淘汰 allkeys-lfu(least frequently used):当内存不足以容纳新写入数据时,在键空间中,移除最不经常使用的 key 关于最近最少使用:\n链表尾部的数据会被丢弃\n长期不被使用的数据,在未来被用到的几率也不大。因此,当数据所占 内存达到一定阈值时,要移除掉最近最少使用的数据。\n关于翻译问题:least,程度最轻的。recently,最近的。其实翻译应该是“非最近的,越远越要淘汰”\njava算法实现\npublic class LRUCache { class DLinkedNode { int key; int value; DLinkedNode prev; DLinkedNode next; public DLinkedNode() {} public DLinkedNode(int _key, int _value) {key = _key; value = _value;} } private Map\u0026lt;Integer, DLinkedNode\u0026gt; cache = new HashMap\u0026lt;Integer, DLinkedNode\u0026gt;(); private int size; private int capacity; private DLinkedNode head, tail; public LRUCache(int capacity) { this.size = 0; this.capacity = capacity; // 使用伪头部和伪尾部节点 head = new DLinkedNode(); tail = new DLinkedNode(); head.next = tail; tail.prev = head; } public int get(int key) { DLinkedNode node = cache.get(key); if (node == null) { return -1; } // 如果 key 存在,先通过哈希表定位,再移到头部 moveToHead(node); return node.value; } public void put(int key, int value) { DLinkedNode node = cache.get(key); if (node == null) { // 如果 key 不存在,创建一个新的节点 DLinkedNode newNode = new DLinkedNode(key, value); // 添加进哈希表 cache.put(key, newNode); // 添加至双向链表的头部 addToHead(newNode); ++size; if (size \u0026gt; capacity) { // 如果超出容量,删除双向链表的尾部节点 DLinkedNode tail = removeTail(); // 删除哈希表中对应的项 cache.remove(tail.key); --size; } } else { // 如果 key 存在,先通过哈希表定位,再修改 value,并移到头部 node.value = value; moveToHead(node); } } private void addToHead(DLinkedNode node) { node.prev = head; node.next = head.next; head.next.prev = node; head.next = node; } private void removeNode(DLinkedNode node) { node.prev.next = node.next; node.next.prev = node.prev; } private void moveToHead(DLinkedNode node) { removeNode(node); addToHead(node); } private DLinkedNode removeTail() { DLinkedNode res = tail.prev; removeNode(res); return res; } } Redis 持久化机制 # 怎么保证 Redis 挂掉之后再重启数据可以进行恢复? # 很多时候我们需要持久化数据也就是将内存中的数据写入到硬盘里面,大部分原因是为了之后重用数据(比如重启机器、机器故障之后恢复数据),或者是为了防止系统故障而将数据备份到一个远程位置。\nRedis 不同于 Memcached 的很重要一点就是,Redis 支持持久化,而且支持两种不同的持久化操作。Redis 的一种持久化方式叫快照(snapshotting,RDB),另一种方式是只追加文件(append-only file, AOF)。这两种方法各有千秋,下面我会详细这两种持久化方法是什么,怎么用,如何选择适合自己的持久化方法。\n什么是 RDB 持久化? # Redis 可以通过创建快照来获得存储在内存里面的数据在某个时间点上的副本。Redis 创建快照之后,可以对快照进行备份,可以将快照复制到其他服务器从而创建具有相同数据的服务器副本(Redis 主从结构,主要用来提高 Redis 性能),还可以将快照留在原地以便重启服务器的时候使用。\n快照持久化是 Redis 默认采用的持久化方式,在 redis.conf 配置文件中默认有此下配置:\nsave 900 1 #在900秒(15分钟)之后,如果至少有1个key发生变化,Redis就会自动触发bgsave命令创建快照。 save 300 10 #在300秒(5分钟)之后,如果至少有10个key发生变化,Redis就会自动触发bgsave命令创建快照。 save 60 10000 #在60秒(1分钟)之后,如果至少有10000个key发生变化,Redis就会自动触发bgsave命令创建快照。 RDB 创建快照时会阻塞主线程吗? # Redis 提供了两个命令来生成 RDB 快照文件:\nsave : 主线程执行,会阻塞主线程; bgsave : 子线程执行,不会阻塞主线程,默认选项。 什么是 AOF 持久化? # 与快照持久化相比,AOF 持久化的实时性更好,因此已成为主流的持久化方案。默认情况下 Redis 没有开启 AOF(append only file)方式的持久化,可以通过 appendonly 参数开启:\nappendonly yes 开启 AOF 持久化后每执行一条会更改 Redis 中的数据的命令,Redis 就会将该命令写入到内存缓存 server.aof_buf 中,然后再根据 appendfsync 配置来决定何时将其同步到硬盘中的 AOF 文件。\nAOF 文件的保存位置和 RDB 文件的位置相同,都是通过 dir 参数设置的,默认的文件名是 appendonly.aof。\n在 Redis 的配置文件中存在三种不同的 AOF 持久化方式,它们分别是:\nappendfsync always #每次有数据修改发生时都会写入AOF文件,这样会严重降低Redis的速度 appendfsync everysec #每秒钟同步一次,显式地将多个写命令同步到硬盘 appendfsync no #让操作系统决定何时进行同步 为了兼顾数据和写入性能,用户可以考虑 appendfsync everysec 选项 ,让 Redis 每秒同步一次 AOF 文件,Redis 性能几乎没受到任何影响。而且这样即使出现系统崩溃,用户最多只会丢失一秒之内产生的数据。当硬盘忙于执行写入操作的时候,Redis 还会优雅的放慢自己的速度以便适应硬盘的最大写入速度。\n相关 issue :\nRedis 的 AOF 方式 #783 Redis AOF 重写描述不准确 #1439 AOF 日志是如何实现的? # 关系型数据库(如 MySQL)通常都是执行命令之前记录日志(方便故障恢复),而 Redis AOF 持久化机制是在执行完命令之后再记录日志。\n为什么是在执行完命令之后记录日志呢?\n避免额外的检查开销,AOF 记录日志不会对命令进行语法检查; 在命令执行完之后再记录,不会阻塞当前的命令执行。 这样也带来了风险(我在前面介绍 AOF 持久化的时候也提到过):\n如果刚执行完命令 Redis 就宕机会导致对应的修改丢失; 可能会阻塞后续其他命令的执行(AOF 记录日志是在 Redis 主线程中进行的)。 AOF 重写了解吗? # 当 AOF 变得太大时,Redis 能够在后台自动重写 AOF 产生一个新的 AOF 文件,这个新的 AOF 文件和原有的 AOF 文件所保存的数据库状态一样,但体积更小。\nAOF 重写是一个有歧义的名字,该功能是通过读取数据库中的键值对来实现的,程序无须对现有 AOF 文件进行任何读入、分析或者写入操作。\n在执行 BGREWRITEAOF 命令时,Redis 服务器会维护一个 AOF 重写缓冲区,该缓冲区会在子进程创建新 AOF 文件期间,记录服务器执行的所有写命令。当子进程完成创建新 AOF 文件的工作之后,服务器会将重写缓冲区中的所有内容追加到新 AOF 文件的末尾,使得新的 AOF 文件保存的数据库状态与现有的数据库状态一致。最后,服务器用新的 AOF 文件替换旧的 AOF 文件,以此来完成 AOF 文件重写操作。\nRedis 7.0 版本之前,如果在重写期间有写入命令,AOF 可能会使用大量内存,重写期间到达的所有写入命令都会写入磁盘两次。\naof 文件重写是将 redis 中的数据转换为 写命令同步更新到 aof 文件的过程。\n重写 aof 后 为什么么可以变小\n清除了一些无效命令 eg. del srem 进程内超时的数据不再写入 aof 文件 多条写命令可以合并为批量写命令 eg. lpush list v1 lpush list v2 lpush list v3 合并为一条写入命令 lpush list v1 v2 v3 如何选择 RDB 和 AOF? # 关于 RDB 和 AOF 的优缺点,官网上面也给了比较详细的说明 Redis persistence,这里结合自己的理解简单总结一下。\nRDB 比 AOF 优秀的地方 :\nRDB 文件存储的内容是经过压缩的二进制数据, 保存着某个时间点的数据集,文件很小,适合做数据的备份,灾难恢复。AOF 文件存储的是每一次写命令,类似于 MySQL 的 binlog 日志,通常会必 RDB 文件大很多。当 AOF 变得太大时,Redis 能够在后台自动重写 AOF。新的 AOF 文件和原有的 AOF 文件所保存的数据库状态一样,但体积更小。不过, Redis 7.0 版本之前,如果在重写期间有写入命令,AOF 可能会使用大量内存,重写期间到达的所有写入命令都会写入磁盘两次。 使用 RDB 文件恢复数据,直接解析还原数据即可,不需要一条一条地执行命令,速度非常快。而 AOF 则需要依次执行每个写命令,速度非常慢。也就是说,与 AOF 相比,恢复大数据集的时候,RDB 速度更快。 AOF 比 RDB 优秀的地方 :\nRDB 的数据安全性不如 AOF,没有办法实时或者秒级持久化数据。生成 RDB 文件的过程是比繁重的, 虽然 BGSAVE 子进程写入 RDB 文件的工作不会阻塞主线程,但会对机器的 CPU 资源和内存资源产生影响,严重的情况下甚至会直接把 Redis 服务干宕机。AOF 支持秒级数据丢失(取决 fsync 策略,如果是 everysec,最多丢失 1 秒的数据),仅仅是追加命令到 AOF 文件,操作轻量。 RDB 文件是以特定的二进制格式保存的,并且在 Redis 版本演进中有多个版本的 RDB,所以存在老版本的 Redis 服务不兼容新版本的 RDB 格式的问题。 AOF 以一种易于理解和解析的格式包含所有操作的日志。你可以轻松地导出 AOF 文件进行分析,你也可以直接操作 AOF 文件来解决一些问题。比如,如果执行FLUSHALL命令意外地刷新了所有内容后,只要 AOF 文件没有被重写,删除最新命令并重启即可恢复之前的状态。 Redis 4.0 对于持久化机制做了什么优化? # 由于 RDB 和 AOF 各有优势,于是,Redis 4.0 开始支持 RDB 和 AOF 的混合持久化(默认关闭,可以通过配置项 aof-use-rdb-preamble 开启)。\n如果把混合持久化打开,AOF 重写的时候就直接把 RDB 的内容写到 AOF 文件开头。这样做的好处是可以结合 RDB 和 AOF 的优点, 快速加载同时避免丢失过多的数据。当然缺点也是有的, AOF 里面的 RDB 部分是压缩格式不再是 AOF 格式,可读性较差。\n官方文档地址:https://redis.io/topics/persistence\n参考 # 《Redis 开发与运维》 《Redis 设计与实现》 Redis 命令手册:https://www.redis.com.cn/commands.html WHY Redis choose single thread (vs multi threads): https://medium.com/@jychen7/sharing-redis-single-thread-vs-multi-threads-5870bd44d153 The difference between AOF and RDB persistence:https://www.sobyte.net/post/2022-04/redis-rdb-and-aof/ "},{"id":248,"href":"/zh/docs/technology/Review/java_guide/lycly_system-design/web-real-time-message-push/","title":"web-real-time-message-push","section":"系统设计","content":" 转载自https://github.com/Snailclimb/JavaGuide(添加小部分笔记)感谢作者!\n原文地址:https://juejin.cn/post/7122014462181113887,JavaGuide 对本文进行了完善总结。\n我有一个朋友做了一个小破站,现在要实现一个站内信 Web 消息推送的功能,对,就是下图这个小红点,一个很常用的功能。\n不过他还没想好用什么方式做,这里我帮他整理了一下几种方案,并简单做了实现。\n# 什么是消息推送? # 推送的场景比较多,比如有人关注我的公众号,这时我就会收到一条推送消息,以此来吸引我点击打开应用。\n消息推送通常是指网站的运营工作等人员,通过某种工具对用户当前网页或移动设备 APP 进行的主动消息推送。\n消息推送一般又分为 Web 端消息推送和移动端消息推送。\n移动端消息推送示例 :\nWeb 端消息推送示例:\n在具体实现之前,咱们再来分析一下前边的需求,其实功能很简单,只要触发某个事件(主动分享了资源或者后台主动推送消息),Web 页面的通知小红点就会实时的 +1 就可以了。\n通常在服务端会有若干张消息推送表,用来记录用户触发不同事件所推送不同类型的消息,前端主动查询(拉)或者被动接收(推)用户所有未读的消息数。\n消息推送无非是推(push)和拉(pull)两种形式,下边我们逐个了解下。\n# 消息推送常见方案 # # 短轮询 # 轮询(polling) 应该是实现消息推送方案中最简单的一种,这里我们暂且将轮询分为短轮询和长轮询。\n短轮询很好理解,指定的时间间隔,由浏览器向服务器发出 HTTP 请求,服务器实时返回未读消息数据给客户端,浏览器再做渲染显示。\n一个简单的 JS 定时器就可以搞定,每秒钟请求一次未读消息数接口,返回的数据展示即可。\nsetInterval(() =\u0026gt; { // 方法请求 messageCount().then((res) =\u0026gt; { if (res.code === 200) { this.messageCount = res.data } }) }, 1000); 效果还是可以的,短轮询实现固然简单,缺点也是显而易见,由于推送数据并不会频繁变更,无论后端此时是否有新的消息产生,客户端都会进行请求,势必会对服务端造成很大压力,浪费带宽和服务器资源。\n# 长轮询 # 长轮询是对上边短轮询的一种改进版本,在尽可能减少对服务器资源浪费的同时,保证消息的相对实时性。长轮询在中间件中应用的很广泛,比如 Nacos 和 Apollo 配置中心,消息队列 Kafka、RocketMQ 中都有用到长轮询。\nNacos 配置中心交互模型是 push 还是 pull?open in new window一文中我详细介绍过 Nacos 长轮询的实现原理,感兴趣的小伙伴可以瞅瞅。\n长轮询其实原理跟轮询差不多,都是采用轮询的方式。不过,如果服务端的数据没有发生变更,会 一直 hold 住请求,直到服务端的数据发生变化,或者等待一定时间超时才会返回。返回后,客户端又会立即再次发起下一次长轮询。\n这次我使用 Apollo 配置中心实现长轮询的方式,应用了一个类DeferredResult,它是在 Servelet3.0 后经过 Spring 封装提供的一种异步请求机制,直意就是延迟结果。\nDeferredResult可以允许容器线程快速释放占用的资源,不阻塞请求线程,以此接受更多的请求提升系统的吞吐量,然后启动异步工作线程处理真正的业务逻辑,处理完成调用DeferredResult.setResult(200)提交响应结果。\n下边我们用长轮询来实现消息推送。\n因为一个 ID 可能会被多个长轮询请求监听,所以我采用了 Guava 包提供的Multimap结构存放长轮询,一个 key 可以对应多个 value。一旦监听到 key 发生变化,对应的所有长轮询都会响应。前端得到非请求超时的状态码,知晓数据变更,主动查询未读消息数接口,更新页面数据。\n@Controller @RequestMapping(\u0026#34;/polling\u0026#34;) public class PollingController { // 存放监听某个Id的长轮询集合 // 线程同步结构 public static Multimap\u0026lt;String, DeferredResult\u0026lt;String\u0026gt;\u0026gt; watchRequests = Multimaps.synchronizedMultimap(HashMultimap.create()); /** * 设置监听 */ @GetMapping(path = \u0026#34;watch/{id}\u0026#34;) @ResponseBody public DeferredResult\u0026lt;String\u0026gt; watch(@PathVariable String id) { // 延迟对象设置超时时间 DeferredResult\u0026lt;String\u0026gt; deferredResult = new DeferredResult\u0026lt;\u0026gt;(TIME_OUT); // 异步请求完成时移除 key,防止内存溢出 deferredResult.onCompletion(() -\u0026gt; { watchRequests.remove(id, deferredResult); }); // 注册长轮询请求 watchRequests.put(id, deferredResult); return deferredResult; } /** * 变更数据 */ @GetMapping(path = \u0026#34;publish/{id}\u0026#34;) @ResponseBody public String publish(@PathVariable String id) { // 数据变更 取出监听ID的所有长轮询请求,并一一响应处理 if (watchRequests.containsKey(id)) { Collection\u0026lt;DeferredResult\u0026lt;String\u0026gt;\u0026gt; deferredResults = watchRequests.get(id); for (DeferredResult\u0026lt;String\u0026gt; deferredResult : deferredResults) { deferredResult.setResult(\u0026#34;我更新了\u0026#34; + new Date()); } } return \u0026#34;success\u0026#34;; } 当请求超过设置的超时时间,会抛出AsyncRequestTimeoutException异常,这里直接用@ControllerAdvice全局捕获统一返回即可,前端获取约定好的状态码后再次发起长轮询请求,如此往复调用。\n@ControllerAdvice public class AsyncRequestTimeoutHandler { @ResponseStatus(HttpStatus.NOT_MODIFIED) @ResponseBody @ExceptionHandler(AsyncRequestTimeoutException.class) public String asyncRequestTimeoutHandler(AsyncRequestTimeoutException e) { System.out.println(\u0026#34;异步请求超时\u0026#34;); return \u0026#34;304\u0026#34;; } } 我们来测试一下,首先页面发起长轮询请求/polling/watch/10086监听消息更变,请求被挂起,不变更数据直至超时,再次发起了长轮询请求;紧接着手动变更数据/polling/publish/10086,长轮询得到响应,前端处理业务逻辑完成后再次发起请求,如此循环往复。\n长轮询相比于短轮询在性能上提升了很多,但依然会产生较多的请求,这是它的一点不完美的地方。\n# iframe 流 # iframe 流就是在页面中插入一个隐藏的\u0026lt;iframe\u0026gt;标签,通过在src中请求消息数量 API 接口,由此在服务端和客户端之间创建一条长连接,服务端持续向iframe传输数据。\n传输的数据通常是 HTML、或是内嵌的JavaScript 脚本,来达到实时更新页面的效果。\n这种方式实现简单,前端只要一个\u0026lt;iframe\u0026gt;标签搞定了\n\u0026lt;iframe src=\u0026#34;/iframe/message\u0026#34; style=\u0026#34;display:none\u0026#34;\u0026gt;\u0026lt;/iframe\u0026gt; 服务端直接组装 HTML、JS 脚本数据向 response 写入就行了\n@Controller @RequestMapping(\u0026#34;/iframe\u0026#34;) public class IframeController { @GetMapping(path = \u0026#34;message\u0026#34;) public void message(HttpServletResponse response) throws IOException, InterruptedException { while (true) { response.setHeader(\u0026#34;Pragma\u0026#34;, \u0026#34;no-cache\u0026#34;); response.setDateHeader(\u0026#34;Expires\u0026#34;, 0); response.setHeader(\u0026#34;Cache-Control\u0026#34;, \u0026#34;no-cache,no-store\u0026#34;); response.setStatus(HttpServletResponse.SC_OK); response.getWriter().print(\u0026#34; \u0026lt;script type=\\\u0026#34;text/javascript\\\u0026#34;\u0026gt;\\n\u0026#34; + \u0026#34;parent.document.getElementById(\u0026#39;clock\u0026#39;).innerHTML = \\\u0026#34;\u0026#34; + count.get() + \u0026#34;\\\u0026#34;;\u0026#34; + \u0026#34;parent.document.getElementById(\u0026#39;count\u0026#39;).innerHTML = \\\u0026#34;\u0026#34; + count.get() + \u0026#34;\\\u0026#34;;\u0026#34; + \u0026#34;\u0026lt;/script\u0026gt;\u0026#34;); } } } iframe 流的服务器开销很大,而且IE、Chrome等浏览器一直会处于 loading 状态,图标会不停旋转,简直是强迫症杀手。\niframe 流非常不友好,强烈不推荐。\n# SSE (我的方式) # 很多人可能不知道,服务端向客户端推送消息,其实除了可以用WebSocket这种耳熟能详的机制外,还有一种服务器发送事件(Server-Sent Events),简称 SSE。这是一种服务器端到客户端(浏览器)的单向消息推送。\nSSE 基于 HTTP 协议的,我们知道一般意义上的 HTTP 协议是无法做到服务端主动向客户端推送消息的,但 SSE 是个例外,它变换了一种思路。\nSSE 在服务器和客户端之间打开一个单向通道,服务端响应的不再是一次性的数据包而是text/event-stream类型的数据流信息,在有数据变更时从服务器流式传输到客户端。\n整体的实现思路有点类似于在线视频播放,视频流会连续不断的推送到浏览器,你也可以理解成,客户端在完成一次用时很长(网络不畅)的下载。\nSSE 与 WebSocket 作用相似,都可以建立服务端与浏览器之间的通信,实现服务端向客户端推送消息,但还是有些许不同:\nSSE 是基于 HTTP 协议的,它们不需要特殊的协议或服务器实现即可工作;WebSocket 需单独服务器来处理协议。 SSE 单向通信,只能由服务端向客户端单向通信;WebSocket 全双工通信,即通信的双方可以同时发送和接受信息。 SSE 实现简单开发成本低,无需引入其他组件;WebSocket 传输数据需做二次解析,开发门槛高一些。 SSE 默认支持断线重连;WebSocket 则需要自己实现。 SSE 只能传送文本消息,二进制数据需要经过编码后传送;WebSocket 默认支持传送二进制数据。 SSE 与 WebSocket 该如何选择?\n技术并没有好坏之分,只有哪个更合适\nSSE 好像一直不被大家所熟知,一部分原因是出现了 WebSocket,这个提供了更丰富的协议来执行双向、全双工通信。对于游戏、即时通信以及需要双向近乎实时更新的场景,拥有双向通道更具吸引力。\n但是,在某些情况下,不需要从客户端发送数据。而你只需要一些服务器操作的更新。比如:站内信、未读消息数、状态更新、股票行情、监控数量等场景,SEE 不管是从实现的难易和成本上都更加有优势。此外,SSE 具有 WebSocket 在设计上缺乏的多种功能,例如:自动重新连接、事件 ID 和发送任意事件的能力。\n前端只需进行一次 HTTP 请求,带上唯一 ID,打开事件流,监听服务端推送的事件就可以了\n\u0026lt;script\u0026gt; let source = null; let userId = 7777 if (window.EventSource) { // 建立连接 source = new EventSource(\u0026#39;http://localhost:7777/sse/sub/\u0026#39;+userId); setMessageInnerHTML(\u0026#34;连接用户=\u0026#34; + userId); /** * 连接一旦建立,就会触发open事件 * 另一种写法:source.onopen = function (event) {} */ source.addEventListener(\u0026#39;open\u0026#39;, function (e) { setMessageInnerHTML(\u0026#34;建立连接。。。\u0026#34;); }, false); /** * 客户端收到服务器发来的数据 * 另一种写法:source.onmessage = function (event) {} */ source.addEventListener(\u0026#39;message\u0026#39;, function (e) { setMessageInnerHTML(e.data); }); } else { setMessageInnerHTML(\u0026#34;你的浏览器不支持SSE\u0026#34;); } \u0026lt;/script\u0026gt; 服务端的实现更简单,创建一个SseEmitter对象放入sseEmitterMap进行管理\nprivate static Map\u0026lt;String, SseEmitter\u0026gt; sseEmitterMap = new ConcurrentHashMap\u0026lt;\u0026gt;(); /** * 创建连接 */ public static SseEmitter connect(String userId) { try { // 设置超时时间,0表示不过期。默认30秒 SseEmitter sseEmitter = new SseEmitter(0L); // 注册回调 sseEmitter.onCompletion(completionCallBack(userId)); sseEmitter.onError(errorCallBack(userId)); sseEmitter.onTimeout(timeoutCallBack(userId)); sseEmitterMap.put(userId, sseEmitter); count.getAndIncrement(); return sseEmitter; } catch (Exception e) { log.info(\u0026#34;创建新的sse连接异常,当前用户:{}\u0026#34;, userId); } return null; } /** * 给指定用户发送消息 */ public static void sendMessage(String userId, String message) { if (sseEmitterMap.containsKey(userId)) { try { sseEmitterMap.get(userId).send(message); } catch (IOException e) { log.error(\u0026#34;用户[{}]推送异常:{}\u0026#34;, userId, e.getMessage()); removeUser(userId); } } } 注意: SSE 不支持 IE 浏览器,对其他主流浏览器兼容性做的还不错。\n# Websocket # Websocket 应该是大家都比较熟悉的一种实现消息推送的方式,上边我们在讲 SSE 的时候也和 Websocket 进行过比较。\n是一种在 TCP 连接上进行全双工通信的协议,建立客户端和服务器之间的通信渠道。浏览器和服务器仅需一次握手,两者之间就直接可以创建持久性的连接,并进行双向数据传输。\nSpringBoot 整合 Websocket,先引入 Websocket 相关的工具包,和 SSE 相比额外的开发成本。\n\u0026lt;!-- 引入websocket --\u0026gt; \u0026lt;dependency\u0026gt; \u0026lt;groupId\u0026gt;org.springframework.boot\u0026lt;/groupId\u0026gt; \u0026lt;artifactId\u0026gt;spring-boot-starter-websocket\u0026lt;/artifactId\u0026gt; \u0026lt;/dependency\u0026gt; \u0026lt;!--可能需要排除springboot自带的tomcat--\u0026gt; 服务端使用@ServerEndpoint注解标注当前类为一个 WebSocket 服务器,客户端可以通过ws://localhost:7777/webSocket/10086来连接到 WebSocket 服务器端。\n@Component @Slf4j @ServerEndpoint(\u0026#34;/websocket/{userId}\u0026#34;) public class MyWebSocket { //与某个客户端的连接会话,需要通过它来给客户端发送数据 private Session session; private static final CopyOnWriteArraySet\u0026lt;MyWebSocket\u0026gt; webSockets = new CopyOnWriteArraySet\u0026lt;\u0026gt;(); // 用来存在线连接数 private static final Map\u0026lt;String, Session\u0026gt; sessionPool = new HashMap\u0026lt;String, Session\u0026gt;(); /** * 链接成功调用的方法 */ @OnOpen public void onOpen(Session session, @PathParam(value = \u0026#34;userId\u0026#34;) String userId) { try { this.session = session; webSockets.add(this); sessionPool.put(userId, session); log.info(\u0026#34;websocket消息: 有新的连接,总数为:\u0026#34; + webSockets.size()); } catch (Exception e) { } } /** * 收到客户端消息后调用的方法 */ @OnMessage public void onMessage(String message) { log.info(\u0026#34;websocket消息: 收到客户端消息:\u0026#34; + message); } /** * 此为单点消息 */ public void sendOneMessage(String userId, String message) { Session session = sessionPool.get(userId); if (session != null \u0026amp;\u0026amp; session.isOpen()) { try { log.info(\u0026#34;websocket消: 单点消息:\u0026#34; + message); session.getAsyncRemote().sendText(message); } catch (Exception e) { e.printStackTrace(); } } } } 前端初始化打开 WebSocket 连接,并监听连接状态,接收服务端数据或向服务端发送数据。\n\u0026lt;html\u0026gt; \u0026lt;head\u0026gt; \u0026lt;title\u0026gt;websocket测试\u0026lt;/title\u0026gt; \u0026lt;meta charset=\u0026#34;utf-8\u0026#34; /\u0026gt; \u0026lt;script src=\u0026#34;https://code.jquery.com/jquery-3.1.1.min.js\u0026#34;\u0026gt;\u0026lt;/script\u0026gt; \u0026lt;/head\u0026gt; \u0026lt;body\u0026gt; \u0026lt;input type=\u0026#34;text\u0026#34; id=\u0026#34;message\u0026#34;\u0026gt; \u0026lt;button onclick=\u0026#34;sendMessage()\u0026#34;\u0026gt;测试\u0026lt;/button\u0026gt; \u0026lt;script\u0026gt; //注意,地址不要填错了 var ws = new WebSocket(\u0026#39;ws://localhost:8089/websocket/10086\u0026#39;); // 获取连接状态 console.log(\u0026#39;ws连接状态:\u0026#39; + ws.readyState); //监听是否连接成功 ws.onopen = function () { console.log(\u0026#39;ws连接状态:\u0026#39; + ws.readyState); //连接成功则发送一个数据 ws.send(\u0026#39;test1\u0026#39;); } // 接听服务器发回的信息并处理展示 ws.onmessage = function (data) { console.log(\u0026#39;接收到来自服务器的消息:\u0026#39;); console.log(data); //完成通信后关闭WebSocket连接(这里不要关闭,让他持续发) //ws.close(); } // 监听连接关闭事件 ws.onclose = function () { // 监听整个过程中websocket的状态 console.log(\u0026#39;ws连接状态:\u0026#39; + ws.readyState); } // 监听并处理error事件 ws.onerror = function (error) { console.log(error); } //如果新开一个窗口,可以手动访问http://192.168.2.26:8089/socket/publish?userId=10086\u0026amp;message=abcde,那么就可以发送消息啦(原窗口可以继续接收消息) function sendMessage() { var content = $(\u0026#34;#message\u0026#34;).val(); //这里需要后台提供/socket/publish接口 $.ajax({ url: \u0026#39;http://192.168.2.26:8089/socket/publish?userId=10086\u0026amp;message=\u0026#39; + content, type: \u0026#39;GET\u0026#39;, data: { \u0026#34;id\u0026#34;: \u0026#34;7777\u0026#34;, \u0026#34;content\u0026#34;: content }, success: function (data) { console.log(data) } }) } \u0026lt;/script\u0026gt; \u0026lt;/body\u0026gt; \u0026lt;/html\u0026gt; 页面初始化建立 WebSocket 连接,之后就可以进行双向通信了,效果还不错。\n# MQTT # 什么是 MQTT 协议?\nMQTT (Message Queue Telemetry Transport)是一种基于发布/订阅(publish/subscribe)模式的轻量级通讯协议,通过订阅相应的主题来获取消息,是物联网(Internet of Thing)中的一个标准传输协议。\n该协议将消息的发布者(publisher)与订阅者(subscriber)进行分离,因此可以在不可靠的网络环境中,为远程连接的设备提供可靠的消息服务,使用方式与传统的 MQ 有点类似。\nTCP 协议位于传输层,MQTT 协议位于应用层,MQTT 协议构建于 TCP/IP 协议上,也就是说只要支持 TCP/IP 协议栈的地方,都可以使用 MQTT 协议。\n为什么要用 MQTT 协议?\nMQTT 协议为什么在物联网(IOT)中如此受偏爱?而不是其它协议,比如我们更为熟悉的 HTTP 协议呢?\n首先 HTTP 协议它是一种同步协议,客户端请求后需要等待服务器的响应。而在物联网(IOT)环境中,设备会很受制于环境的影响,比如带宽低、网络延迟高、网络通信不稳定等,显然异步消息协议更为适合 IOT 应用程序。 HTTP 是单向的,如果要获取消息客户端必须发起连接,而在物联网(IOT)应用程序中,设备或传感器往往都是客户端,这意味着它们无法被动地接收来自网络的命令。 通常需要将一条命令或者消息,发送到网络上的所有设备上。HTTP 要实现这样的功能不但很困难,而且成本极高。 具体的 MQTT 协议介绍和实践,这里我就不再赘述了,大家可以参考我之前的两篇文章,里边写的也都很详细了。\nMQTT 协议的介绍: 我也没想到 SpringBoot + RabbitMQ 做智能家居,会这么简单open in new window MQTT 实现消息推送: 未读消息(小红点),前端 与 RabbitMQ 实时消息推送实践,贼简单~open in new window # 总结 # 以下内容为 JavaGuide 补充\n介绍 优点 缺点 短轮询 客户端定时向服务端发送请求,服务端直接返回响应数据(即使没有数据更新) 简单、易理解、易实现 实时性太差,无效请求太多,频繁建立连接太耗费资源 长轮询 与短轮询不同是,长轮询接收到客户端请求之后等到有数据更新才返回请求 减少了无效请求 挂起请求会导致资源浪费 iframe 流 服务端和客户端之间创建一条长连接,服务端持续向iframe传输数据。 简单、易理解、易实现 维护一个长连接会增加开销,效果太差(图标会不停旋转) SSE 一种服务器端到客户端(浏览器)的单向消息推送。 简单、易实现,功能丰富 不支持双向通信 WebSocket 除了最初建立连接时用 HTTP 协议,其他时候都是直接基于 TCP 协议进行通信的,可以实现客户端和服务端的全双工通信。 性能高、开销小 对开发人员要求更高,实现相对复杂一些 MQTT 基于发布/订阅(publish/subscribe)模式的轻量级通讯协议,通过订阅相应的主题来获取消息。 成熟稳定,轻量级 对开发人员要求更高,实现相对复杂一些 著作权归所有 原文链接:https://javaguide.cn/system-design/web-real-time-message-push.html\n"},{"id":249,"href":"/zh/docs/technology/Review/java_guide/lycly_system-design/schedule-task/","title":"Java定时任务详解","section":"系统设计","content":" 转载自https://github.com/Snailclimb/JavaGuide(添加小部分笔记)感谢作者!\n为什么需要定时任务? # 我们来看一下几个非常常见的业务场景:\n某系统凌晨要进行数据备份。 某电商平台,用户下单半个小时未支付的情况下需要自动取消订单。 某媒体聚合平台,每 10 分钟动态抓取某某网站的数据为自己所用。 某博客平台,支持定时发送文章。 某基金平台,每晚定时计算用户当日收益情况并推送给用户最新的数据。 \u0026hellip;\u0026hellip; 这些场景往往都要求我们在某个特定的时间去做某个事情。\n单机定时任务技术选型 # Timer # java.util.Timer是 JDK 1.3 开始就已经支持的一种定时任务的实现方式。\nTimer 内部使用一个叫做 TaskQueue 的类存放定时任务,它是一个基于最小堆实现的优先级队列。TaskQueue 会按照任务距离下一次执行时间的大小将任务排序,保证在堆顶的任务最先执行。这样在需要执行任务时,每次只需要取出堆顶的任务运行即可!\nTimer 使用起来比较简单,通过下面的方式我们就能创建一个 1s 之后执行的定时任务。\n// 示例代码: TimerTask task = new TimerTask() { public void run() { System.out.println(\u0026#34;当前时间: \u0026#34; + new Date() + \u0026#34;n\u0026#34; + \u0026#34;线程名称: \u0026#34; + Thread.currentThread().getName()); } }; System.out.println(\u0026#34;当前时间: \u0026#34; + new Date() + \u0026#34;n\u0026#34; + \u0026#34;线程名称: \u0026#34; + Thread.currentThread().getName()); Timer timer = new Timer(\u0026#34;Timer\u0026#34;); long delay = 1000L; timer.schedule(task, delay); //输出: 当前时间: Fri May 28 15:18:47 CST 2021n线程名称: main 当前时间: Fri May 28 15:18:48 CST 2021n线程名称: Timer 不过其缺陷较多,比如一个 Timer 一个线程,这就导致 Timer 的任务的执行只能串行执行,一个任务执行时间过长的话会影响其他任务(性能非常差),再比如发生异常时任务直接停止(Timer 只捕获了 InterruptedException )。\nTimer 类上的有一段注释是这样写的:\n* This class does not offer real-time guarantees: it schedules * tasks using the \u0026lt;tt\u0026gt;Object.wait(long)\u0026lt;/tt\u0026gt; method. *Java 5.0 introduced the {@code java.util.concurrent} package and * one of the concurrency utilities therein is the {@link * java.util.concurrent.ScheduledThreadPoolExecutor * ScheduledThreadPoolExecutor} which is a thread pool for repeatedly * executing tasks at a given rate or delay. It is effectively a more * versatile replacement for the {@code Timer}/{@code TimerTask} * combination, as it allows multiple service threads, accepts various * time units, and doesn\u0026#39;t require subclassing {@code TimerTask} (just * implement {@code Runnable}). Configuring {@code * ScheduledThreadPoolExecutor} with one thread makes it equivalent to * {@code Timer}. 大概的意思就是: ScheduledThreadPoolExecutor 支持多线程执行定时任务并且功能更强大,是 Timer 的替代品。\nScheduledExecutorService # ScheduledExecutorService 是一个接口,有多个实现类,比较常用的是 ScheduledThreadPoolExecutor 。\nScheduledThreadPoolExecutor 本身就是一个线程池,支持任务并发执行。并且,其内部使用 DelayedWorkQueue 作为任务队列。\n// 示例代码: TimerTask repeatedTask = new TimerTask() { @SneakyThrows public void run() { System.out.println(\u0026#34;当前时间: \u0026#34; + new Date() + \u0026#34;n\u0026#34; + \u0026#34;线程名称: \u0026#34; + Thread.currentThread().getName()); } }; System.out.println(\u0026#34;当前时间: \u0026#34; + new Date() + \u0026#34;n\u0026#34; + \u0026#34;线程名称: \u0026#34; + Thread.currentThread().getName()); ScheduledExecutorService executor = Executors.newScheduledThreadPool(3); long delay = 1000L; long period = 1000L; executor.scheduleAtFixedRate(repeatedTask, delay, period, TimeUnit.MILLISECONDS); Thread.sleep(delay + period * 5); executor.shutdown(); //输出: 当前时间: Fri May 28 15:40:46 CST 2021n线程名称: main 当前时间: Fri May 28 15:40:47 CST 2021n线程名称: pool-1-thread-1 当前时间: Fri May 28 15:40:48 CST 2021n线程名称: pool-1-thread-1 当前时间: Fri May 28 15:40:49 CST 2021n线程名称: pool-1-thread-2 当前时间: Fri May 28 15:40:50 CST 2021n线程名称: pool-1-thread-2 当前时间: Fri May 28 15:40:51 CST 2021n线程名称: pool-1-thread-2 当前时间: Fri May 28 15:40:52 CST 2021n线程名称: pool-1-thread-2 不论是使用 Timer 还是 ScheduledExecutorService 都无法使用 Cron 表达式指定任务执行的具体时间。\nSpring Task # [krɑn] cron\n我们直接通过 Spring 提供的 @Scheduled 注解即可定义定时任务,非常方便!\n/** * cron:使用Cron表达式。 每分钟的1,2秒运行 */ @Scheduled(cron = \u0026#34;1-2 * * * * ? \u0026#34;) public void reportCurrentTimeWithCronExpression() { log.info(\u0026#34;Cron Expression: The time is now {}\u0026#34;, dateFormat.format(new Date())); } 我在大学那会做的一个 SSM 的企业级项目,就是用的 Spring Task 来做的定时任务。\n并且,Spring Task 还是支持 Cron 表达式 的。Cron 表达式主要用于定时作业(定时任务)系统定义执行时间或执行频率的表达式,非常厉害,你可以通过 Cron 表达式进行设置定时任务每天或者每个月什么时候执行等等操作。咱们要学习定时任务的话,Cron 表达式是一定是要重点关注的。推荐一个在线 Cron 表达式生成器:http://cron.qqe2.com/ 。\n但是,Spring 自带的定时调度只支持单机,并且提供的功能比较单一。之前写过一篇文章: 《5 分钟搞懂如何在 Spring Boot 中 Schedule Tasks》 ,不了解的小伙伴可以参考一下。\nSpring Task 底层是基于 JDK 的 ScheduledThreadPoolExecutor 线程池来实现的。\n优缺点总结:\n优点: 简单,轻量,支持 Cron 表达式 缺点 :功能单一 时间轮 # Kafka、Dubbo、ZooKeeper、Netty 、Caffeine 、Akka 中都有对时间轮的实现。\n时间轮简单来说就是一个环形的队列(底层一般基于数组实现),队列中的每一个元素(时间格)都可以存放一个定时任务列表。\n时间轮中的每个时间格代表了时间轮的基本时间跨度或者说时间精度,加入时间一秒走一个时间格的话,那么这个时间轮的最高精度就是 1 秒(也就是说 3 s 和 3.9s 会在同一个时间格中)。\n下图是一个有 12 个时间格的时间轮,转完一圈需要 12 s。当我们需要新建一个 3s 后执行的定时任务,只需要将定时任务放在下标为 3 的时间格中即可。当我们需要新建一个 9s 后执行的定时任务,只需要将定时任务放在下标为 9 的时间格中即可。\n那当我们需要创建一个 13s 后执行的定时任务怎么办呢?这个时候可以引入一叫做 圈数/轮数 的概念,也就是说这个任务还是放在下标为 3 的时间格中, 不过它的圈数为 2 。\n除了增加圈数这种方法之外,还有一种 多层次时间轮 (类似手表),Kafka 采用的就是这种方案。\n针对下图的时间轮,我来举一个例子便于大家理解。\n上图的时间轮,第 1 层的时间精度为 1 ,第 2 层的时间精度为 20 ,第 3 层的时间精度为 400。假如我们需要添加一个 350s 后执行的任务 A 的话(当前时间是 0s),这个任务会被放在第 2 层(因为第二层的时间跨度为 20*20=400\u0026gt;350)的第 350/20=17 个时间格子。\n当第一层转了 17 圈之后,时间过去了 340s ,第 2 层的指针此时来到第 17 个时间格子。此时,第 2 层第 17 个格子的任务会被移动到第 1 层。\n任务 A 当前是 10s 之后执行,因此它会被移动到第 1 层的第 10 个时间格子。\n这里在层与层之间的移动也叫做时间轮的升降级。参考手表来理解就好!\n时间轮比较适合任务数量比较多的定时任务场景,它的任务写入和执行的时间复杂度都是 0(1)。\n分布式定时任务技术选型 # 上面提到的一些定时任务的解决方案都是在单机下执行的,适用于比较简单的定时任务场景比如每天凌晨备份一次数据。\n如果我们需要一些高级特性比如支持任务在分布式场景下的分片和高可用的话,我们就需要用到分布式任务调度框架了。\n通常情况下,一个定时任务的执行往往涉及到下面这些角色:\n任务 : 首先肯定是要执行的任务,这个任务就是具体的业务逻辑比如定时发送文章。 调度器 :其次是调度中心,调度中心主要负责任务管理,会分配任务给执行器。 执行器 : 最后就是执行器,执行器接收调度器分派的任务并执行。 Quartz # 一个很火的开源任务调度框架,完全由Java写成。Quartz 可以说是 Java 定时任务领域的老大哥或者说参考标准,其他的任务调度框架基本都是基于 Quartz 开发的,比如当当网的elastic-job就是基于quartz二次开发之后的分布式调度解决方案。\n使用 Quartz 可以很方便地与 Spring 集成,并且支持动态添加任务和集群。但是,Quartz 使用起来也比较麻烦,API 繁琐。\n并且,Quzrtz 并没有内置 UI 管理控制台,不过你可以使用 quartzui 这个开源项目来解决这个问题。\n另外,Quartz 虽然也支持分布式任务。但是,它是在数据库层面,通过数据库的锁机制做的,有非常多的弊端比如系统侵入性严重、节点负载不均衡。有点伪分布式的味道。\n优缺点总结:\n优点: 可以与 Spring 集成,并且支持动态添加任务和集群。 缺点 :分布式支持不友好,没有内置 UI 管理控制台、使用麻烦(相比于其他同类型框架来说) Elastic-Job # Elastic-Job 是当当网开源的一个基于Quartz和ZooKeeper的分布式调度解决方案,由两个相互独立的子项目 Elastic-Job-Lite 和 Elastic-Job-Cloud 组成,一般我们只要使用 Elastic-Job-Lite 就好。\nElasticJob 支持任务在分布式场景下的分片和高可用、任务可视化管理等功能。\nElasticJob-Lite 的架构设计如下图所示:\n从上图可以看出,Elastic-Job 没有调度中心这一概念,而是使用 ZooKeeper 作为注册中心,注册中心负责协调分配任务到不同的节点上。\nElastic-Job 中的定时调度都是由执行器自行触发,这种设计也被称为去中心化设计(调度和处理都是执行器单独完成)。\n@Component @ElasticJobConf(name = \u0026#34;dayJob\u0026#34;, cron = \u0026#34;0/10 * * * * ?\u0026#34;, shardingTotalCount = 2, shardingItemParameters = \u0026#34;0=AAAA,1=BBBB\u0026#34;, description = \u0026#34;简单任务\u0026#34;, failover = true) public class TestJob implements SimpleJob { @Override public void execute(ShardingContext shardingContext) { log.info(\u0026#34;TestJob任务名:【{}】, 片数:【{}】, param=【{}】\u0026#34;, shardingContext.getJobName(), shardingContext.getShardingTotalCount(), shardingContext.getShardingParameter()); } } 相关地址:\nGithub 地址:https://github.com/apache/shardingsphere-elasticjob。 官方网站:https://shardingsphere.apache.org/elasticjob/index_zh.html 。 优缺点总结:\n优点 :可以与 Spring 集成、支持分布式、支持集群、性能不错 缺点 :依赖了额外的中间件比如 Zookeeper(复杂度增加,可靠性降低、维护成本变高) XXL-JOB # XXL-JOB 于 2015 年开源,是一款优秀的轻量级分布式任务调度框架,支持任务可视化管理、弹性扩容缩容、任务失败重试和告警、任务分片等功能,\n根据 XXL-JOB 官网介绍,其解决了很多 Quartz 的不足。\nXXL-JOB 的架构设计如下图所示:\n从上图可以看出,XXL-JOB 由 调度中心 和 执行器 两大部分组成。调度中心主要负责任务管理、执行器管理以及日志管理。执行器主要是接收调度信号并处理。另外,调度中心进行任务调度时,是通过自研 RPC 来实现的。\n不同于 Elastic-Job 的去中心化设计, XXL-JOB 的这种设计也被称为中心化设计(调度中心调度多个执行器执行任务)。\n和 Quzrtz 类似 XXL-JOB 也是基于数据库锁调度任务,存在性能瓶颈。不过,一般在任务量不是特别大的情况下,没有什么影响的,可以满足绝大部分公司的要求。\n不要被 XXL-JOB 的架构图给吓着了,实际上,我们要用 XXL-JOB 的话,只需要重写 IJobHandler 自定义任务执行逻辑就可以了,非常易用!\n@JobHandler(value=\u0026#34;myApiJobHandler\u0026#34;) @Component public class MyApiJobHandler extends IJobHandler { @Override public ReturnT\u0026lt;String\u0026gt; execute(String param) throws Exception { //...... return ReturnT.SUCCESS; } } 还可以直接基于注解定义任务。\n@XxlJob(\u0026#34;myAnnotationJobHandler\u0026#34;) public ReturnT\u0026lt;String\u0026gt; myAnnotationJobHandler(String param) throws Exception { //...... return ReturnT.SUCCESS; } 相关地址:\nGithub 地址:https://github.com/xuxueli/xxl-job/。 官方介绍:https://www.xuxueli.com/xxl-job/ 。 优缺点总结:\n优点:开箱即用(学习成本比较低)、与 Spring 集成、支持分布式、支持集群、内置了 UI 管理控制台。 缺点:不支持动态添加任务(如果一定想要动态创建任务也是支持的,参见: xxl-job issue277)。 PowerJob # 非常值得关注的一个分布式任务调度框架,分布式任务调度领域的新星。目前,已经有很多公司接入比如 OPPO、京东、中通、思科。\n这个框架的诞生也挺有意思的,PowerJob 的作者当时在阿里巴巴实习过,阿里巴巴那会使用的是内部自研的 SchedulerX(阿里云付费产品)。实习期满之后,PowerJob 的作者离开了阿里巴巴。想着说自研一个 SchedulerX,防止哪天 SchedulerX 满足不了需求,于是 PowerJob 就诞生了。\n更多关于 PowerJob 的故事,小伙伴们可以去看看 PowerJob 作者的视频 《我和我的任务调度中间件》。简单点概括就是:“游戏没啥意思了,我要扛起了新一代分布式任务调度与计算框架的大旗!”。\n由于 SchedulerX 属于人民币产品,我这里就不过多介绍。PowerJob 官方也对比过其和 QuartZ、XXL-JOB 以及 SchedulerX。\n总结 # 这篇文章中,我主要介绍了:\n定时任务的相关概念 :为什么需要定时任务、定时任务中的核心角色、分布式定时任务。 定时任务的技术选型 : XXL-JOB 2015 年推出,已经经过了很多年的考验。XXL-JOB 轻量级,并且使用起来非常简单。虽然存在性能瓶颈,但是,在绝大多数情况下,对于企业的基本需求来说是没有影响的。PowerJob 属于分布式任务调度领域里的新星,其稳定性还有待继续考察。ElasticJob 由于在架构设计上是基于 Zookeeper ,而 XXL-JOB 是基于数据库,性能方面的话,ElasticJob 略胜一筹。 这篇文章并没有介绍到实际使用,但是,并不代表实际使用不重要。我在写这篇文章之前,已经动手写过相应的 Demo。像 Quartz,我在大学那会就用过。不过,当时用的是 Spring 。为了能够更好地体验,我自己又在 Spring Boot 上实际体验了一下。如果你并没有实际使用某个框架,就直接说它并不好用的话,是站不住脚的。\n最后,这篇文章要感谢艿艿的帮助,写这篇文章的时候向艿艿询问过一些问题。推荐一篇艿艿写的偏实战类型的硬核文章: 《Spring Job?Quartz?XXL-Job?年轻人才做选择,艿艿全莽~》 。\n"},{"id":250,"href":"/zh/docs/technology/Review/java_guide/lycly_system-design/security/ly06ly_sentive-words-filter/","title":"敏感词过滤方案总结","section":"安全","content":" 转载自https://github.com/Snailclimb/JavaGuide(添加小部分笔记)感谢作者!\n系统需要对用户输入的文本进行敏感词过滤如色情、政治、暴力相关的词汇。\n敏感词过滤用的使用比较多的 Trie 树算法 和 DFA 算法。\n算法实现 # Trie 树 # Trie 树 也称为字典树、单词查找树,哈系树(这里是不是写错了,哈希树?)的一种变种,通常被用于字符串匹配,用来解决在一组字符串集合中快速查找某个字符串的问题。像浏览器搜索的关键词提示一般就是基于 Trie 树来做的。\n假如我们的敏感词库中有以下敏感词:\n高清有码 高清 AV 东京冷 东京热 我们构造出来的敏感词 Trie 树就是下面这样的:\n当我们要查找对应的字符串“东京热”的话,我们会把这个字符串切割成单个的字符“东”、“京”、“热”,然后我们从 Trie 树的根节点开始匹配。\n可以看出, Trie 树的核心原理其实很简单,就是通过公共前缀来提高字符串匹配效率。\nApache Commons Collecions 这个库中就有 Trie 树实现:\nTrie\u0026lt;String, String\u0026gt; trie = new PatriciaTrie\u0026lt;\u0026gt;(); trie.put(\u0026#34;Abigail\u0026#34;, \u0026#34;student\u0026#34;); trie.put(\u0026#34;Abi\u0026#34;, \u0026#34;doctor\u0026#34;); trie.put(\u0026#34;Annabel\u0026#34;, \u0026#34;teacher\u0026#34;); trie.put(\u0026#34;Christina\u0026#34;, \u0026#34;student\u0026#34;); trie.put(\u0026#34;Chris\u0026#34;, \u0026#34;doctor\u0026#34;); Assertions.assertTrue(trie.containsKey(\u0026#34;Abigail\u0026#34;)); assertEquals(\u0026#34;{Abi=doctor, Abigail=student}\u0026#34;, trie.prefixMap(\u0026#34;Abi\u0026#34;).toString()); assertEquals(\u0026#34;{Chris=doctor, Christina=student}\u0026#34;, trie.prefixMap(\u0026#34;Chr\u0026#34;).toString()); Aho-Corasick(AC)自动机是一种建立在 Trie 树上的一种改进算法,是一种多模式匹配算法,由贝尔实验室的研究人员 Alfred V. Aho 和 Margaret J.Corasick 发明。\nAC 自动机算法使用 Trie 树来存放模式串的前缀,通过失败匹配指针(失配指针)来处理匹配失败的跳转。\n相关阅读: 地铁十分钟 | AC 自动机\nDFA # DFA(Deterministic Finite Automata)即确定有穷自动机,与之对应的是 NFA(Non-Deterministic Finite Automata,有穷自动机)。\n关于 DFA 的详细介绍可以看这篇文章: 有穷自动机 DFA\u0026amp;NFA (学习笔记) - 小蜗牛的文章 - 知乎 。\nHutool 提供了 DFA 算法的实现:\nWordTree wordTree = new WordTree(); wordTree.addWord(\u0026#34;大\u0026#34;); wordTree.addWord(\u0026#34;大憨憨\u0026#34;); wordTree.addWord(\u0026#34;憨憨\u0026#34;); String text = \u0026#34;那人真是个大憨憨!\u0026#34;; // 获得第一个匹配的关键字 String matchStr = wordTree.match(text); System.out.println(matchStr); // 标准匹配,匹配到最短关键词,并跳过已经匹配的关键词 List\u0026lt;String\u0026gt; matchStrList = wordTree.matchAll(text, -1, false, false); System.out.println(matchStrList); //匹配到最长关键词,跳过已经匹配的关键词 List\u0026lt;String\u0026gt; matchStrList2 = wordTree.matchAll(text, -1, false, true); System.out.println(matchStrList2); 输出:\n大 [大, 憨憨] [大, 大憨憨] 开源项目 # ToolGood.Words :一款高性能敏感词(非法词/脏字)检测过滤组件,附带繁体简体互换,支持全角半角互换,汉字转拼音,模糊搜索等功能。 sensitive-words-filter :敏感词过滤项目,提供 TTMP、DFA、DAT、hash bucket、Tire 算法支持过滤。可以支持文本的高亮、过滤、判词、替换的接口支持。 论文 # 一种敏感词自动过滤管理系统 一种网络游戏中敏感词过滤方法及系统 "},{"id":251,"href":"/zh/docs/technology/Review/java_guide/lycly_system-design/security/ly05ly_design-of-authority-system/","title":"权限系统设计详解","section":"安全","content":" 转载自https://github.com/Snailclimb/JavaGuide(添加小部分笔记)感谢作者!\n作者:转转技术团队\n原文:https://mp.weixin.qq.com/s/ONMuELjdHYa0yQceTj01Iw\nly:比较繁琐,大概看了前面的部分\n老权限系统的问题与现状 # 转转公司在过去并没有一个统一的权限管理系统,权限管理由各业务自行研发或是使用其他业务的权限系统,权限管理的不统一带来了不少问题:\n各业务重复造轮子,维护成本高 各系统只解决部分场景问题,方案不够通用,新项目选型时没有可靠的权限管理方案 缺乏统一的日志管理与审批流程,在授权信息追溯上十分困难 基于上述问题,去年底公司启动建设转转统一权限系统,目标是开发一套灵活、易用、安全的权限管理系统,供各业务使用。\n业界权限系统的设计方式 # 目前业界主流的权限模型有两种,下面分别介绍下:\n基于角色的访问控制(RBAC) 基于属性的访问控制(ABAC) RBAC 模型 # 基于角色的访问控制(Role-Based Access Control,简称 RBAC) 指的是通过用户的角色(Role)授权其相关权限,实现了灵活的访问控制,相比直接授予用户权限,要更加简单、高效、可扩展。\n一个用户可以拥有若干角色,每一个角色又可以被分配若干权限这样,就构造成“用户-角色-权限” 的授权模型。在这种模型中,用户与角色、角色与权限之间构成了多对多的关系。\n用一个图来描述如下:\n当使用 RBAC模型 时,通过分析用户的实际情况,基于共同的职责和需求,授予他们不同角色。这种 用户 -\u0026gt; 角色 -\u0026gt; 权限 间的关系,让我们可以不用再单独管理单个用户权限,用户从授予的角色里面获取所需的权限。\n以一个简单的场景(Gitlab 的权限系统)为例,用户系统中有 Admin、Maintainer、Operator 三种角色,这三种角色分别具备不同的权限,比如只有 Admin 具备创建代码仓库、删除代码仓库的权限,其他的角色都不具备。我们授予某个用户 Admin 这个角色,他就具备了 创建代码仓库 和 删除代码仓库 这两个权限。\n通过 RBAC模型 ,当存在多个用户拥有相同权限时,我们只需要创建好拥有该权限的角色,然后给不同的用户分配不同的角色,后续只需要修改角色的权限,就能自动修改角色内所有用户的权限。\nABAC 模型 # 基于属性的访问控制(Attribute-Based Access Control,简称 ABAC) 是一种比 RBAC模型 更加灵活的授权模型,它的原理是通过各种属性来动态判断一个操作是否可以被允许。这个模型在云系统中使用的比较多,比如 AWS,阿里云等。\n考虑下面这些场景的权限控制:\n授权某个人具体某本书的编辑权限 当一个文档的所属部门跟用户的部门相同时,用户可以访问这个文档 当用户是一个文档的拥有者并且文档的状态是草稿,用户可以编辑这个文档 早上九点前禁止 A 部门的人访问 B 系统 在除了上海以外的地方禁止以管理员身份访问 A 系统 用户对 2022-06-07 之前创建的订单有操作权限 可以发现上述的场景通过 RBAC模型 很难去实现,因为 RBAC模型 仅仅描述了用户可以做什么操作,但是操作的条件,以及操作的数据,RBAC模型 本身是没有这些限制的。但这恰恰是 ABAC模型 的长处,ABAC模型 的思想是基于用户、访问的数据的属性、以及各种环境因素去动态计算用户是否有权限进行操作。\nABAC 模型的原理 # 在 ABAC模型 中,一个操作是否被允许是基于对象、资源、操作和环境信息共同动态计算决定的。\n对象:对象是当前请求访问资源的用户。用户的属性包括 ID,个人资源,角色,部门和组织成员身份等 资源:资源是当前用户要访问的资产或对象,例如文件,数据,服务器,甚至 API 操作:操作是用户试图对资源进行的操作。常见的操作包括“读取”,“写入”,“编辑”,“复制”和“删除” 环境:环境是每个访问请求的上下文。环境属性包含访问的时间和位置,对象的设备,通信协议和加密强度等 在 ABAC模型 的决策语句的执行过程中,决策引擎会根据定义好的决策语句,结合对象、资源、操作、环境等因素动态计算出决策结果。每当发生访问请求时,ABAC模型 决策系统都会分析属性值是否与已建立的策略匹配。如果有匹配的策略,访问请求就会被通过。\n新权限系统的设计思想 # 结合转转的业务现状,RBAC模型 满足了转转绝大部分业务场景,并且开发成本远低于 ABAC模型 的权限系统,所以新权限系统选择了基于 RBAC模型 来实现。对于实在无法满足的业务系统,我们选择了暂时性不支持,这样可以保障新权限系统的快速落地,更快的让业务使用起来。\n标准的 RBAC模型 是完全遵守 用户 -\u0026gt; 角色 -\u0026gt; 权限 这个链路的,也就是用户的权限完全由他所拥有的角色来控制,但是这样会有一个缺点,就是给用户加权限必须新增一个角色,导致实际操作起来效率比较低。所以我们在 RBAC模型 的基础上,新增了给用户直接增加权限的能力,也就是说既可以给用户添加角色,也可以给用户直接添加权限。最终用户的权限是由拥有的角色和权限点组合而成。\n新权限系统的权限模型:用户最终权限 = 用户拥有的角色带来的权限 + 用户独立配置的权限,两者取并集。\n新权限系统方案如下图 :\n首先,将集团所有的用户(包括外部用户),通过 统一登录与注册 功能实现了统一管理,同时与公司的组织架构信息模块打通,实现了同一个人员在所有系统中信息的一致,这也为后续基于组织架构进行权限管理提供了可行性。 其次,因为新权限系统需要服务集团所有业务,所以需要支持多系统权限管理。用户进行权限管理前,需要先选择相应的系统,然后配置该系统的 菜单权限 和 数据权限 信息,建立好系统的各个权限点。PS:菜单权限和数据权限的具体说明,下文会详细介绍。 最后,创建该系统下的不同角色,给不同角色配置好权限点。比如店长角色,拥有店员操作权限、本店数据查看权限等,配置好这个角色后,后续只需要给店长增加这个角色,就可以让他拥有对应的权限。 完成上述配置后,就可以进行用户的权限管理了。有两种方式可以给用户加权限:\n先选用户,然后添加权限。该方式可以给用户添加任意角色或是菜单/数据权限点。 先选择角色,然后关联用户。该方式只可给用户添加角色,不能单独添加菜单/数据权限点。 这两种方式的具体设计方案,后文会详细说明。\n权限系统自身的权限管理 # 对于权限系统来说,首先需要设计好系统自身的权限管理,也就是需要管理好 ”谁可以进入权限系统,谁可以管理其他系统的权限“,对于权限系统自身的用户,会分为三类:\n超级管理员:拥有权限系统的全部操作权限,可以进行系统自身的任何操作,也可以管理接入权限的应用系统的管理操作。 权限操作用户:拥有至少一个已接入的应用系统的超级管理员角色的用户。该用户能进行的操作限定在所拥有的应用系统权限范围内。权限操作用户是一种身份,无需分配,而是根据规则自动获得的。 普通用户:普通用户也可以认为是一种身份,除去上述 2 类人,其余的都为普通用户。他们只能申请接入系统以及访问权限申请页面。 权限类型的定义 # 新权限系统中,我们把权限分为两大类,分别是 :\n菜单功能权限:包括系统的目录导航、菜单的访问权限,以及按钮和 API 操作的权限 数据权限:包括定义数据的查询范围权限,在不同系统中,通常叫做 “组织”、”站点“等,在新权限系统中,统一称作 ”组织“ 来管理数据权限 默认角色的分类 # 每个系统中设计了三个默认角色,用来满足基本的权限管理需求,分别如下:\n超级管理员:该角色拥有该系统的全部权限,可以修改系统的角色权限等配置,可以给其他用户授权。 系统管理员:该角色拥有给其他用户授权以及修改系统的角色权限等配置能力,但角色本身不具有任何权限。 授权管理员:该角色拥有给其他用户授权的能力。但是授权的范围不超出自己所拥有的权限。 举个栗子:授权管理员 A 可以给 B 用户添加权限,但添加的范围 小于等于 A 用户已拥有的权限。\n经过这么区分,把 拥有权限 和 拥有授权能力 ,这两部分给分隔开来,可以满足所有的权限控制的场景。\n新权限系统的核心模块设计 # 上面介绍了新权限系统的整体设计思想,接下来分别介绍下核心模块的设计\n系统/菜单/数据权限管理 # 把一个新系统接入权限系统有下列步骤:\n创建系统 配置菜单功能权限 配置数据权限(可选) 创建系统的角色 其中,1、2、3 的步骤,都是在系统管理模块完成,具体流程如下图:\n用户可以对系统的基本信息进行增删改查的操作,不同系统之间通过 系统编码 作为唯一区分。同时 系统编码 也会用作于菜单和数据权限编码的前缀,通过这样的设计保证权限编码全局唯一性。\n例如系统的编码为 test_online,那么该系统的菜单编码格式便为 test_online:m_xxx。\n系统管理界面设计如下:\n菜单管理 # 新权限系统首先对菜单进行了分类,分别是 目录、菜单 和 操作,示意如下图\n它们分别代表的含义是:\n目录 :指的是应用系统中最顶部的一级目录,通常在系统 Logo 的右边 菜单 :指的是应用系统左侧的多层级菜单,通常在系统 Logo 的下面,也是最常用的菜单结构 操作 :指页面中的按钮、接口等一系列可以定义为操作或页面元素的部分。 菜单管理界面设计如下: 菜单权限数据的使用,也提供两种方式:\n动态菜单模式 :这种模式下,菜单的增删完全由权限系统接管。也就是说在权限系统增加菜单,应用系统会同步增加。这种模式好处是修改菜单无需项目上线。 静态菜单模式 :菜单的增删由应用系统的前端控制,权限系统只控制访问权限。这种模式下,权限系统只能标识出用户是否拥有当前菜单的权限,而具体的显示控制是由前端根据权限数据来决定。 角色与用户管理 # 角色与用户管理都是可以直接改变用户权限的核心模块,整个设计思路如下图:\n这个模块设计重点是需要考虑到批量操作。无论是通过角色关联用户,还是给用户批量增加/删除/重置权限,批量操作的场景都是系统需要设计好的。\n权限申请 # 除了给其他用户添加权限外,新权限系统同时支持了用户自主申请权限。这个模块除了常规的审批流(申请、审批、查看)等,有一个比较特别的功能,就是如何让用户能选对自己要的权限。所以在该模块的设计上,除了直接选择角色外,还支持通过菜单/数据权限点,反向选择角色,如下图:\n操作日志 # 系统操作日志会分为两大类:\n操作流水日志 :用户可看、可查的关键操作日志 服务 Log 日志 :系统服务运行过程中产生的 Log 日志,其中,服务 Log 日志信息量大于操作流水日志,但是不方便搜索查看。所以权限系统需要提供操作流水日志功能。 在新权限系统中,用户所有的操作可以分为三类,分别为新增、更新、删除。所有的模块也可枚举,例如用户管理、角色管理、菜单管理等。明确这些信息后,那么一条日志就可以抽象为:什么人(Who)在什么时间(When)对哪些人(Target)的哪些模块做了哪些操作。 这样把所有的记录都入库,就可以方便的进行日志的查看和筛选了。\n总结与展望 # 至此,新权限系统的核心设计思路与模块都已介绍完成,新系统在转转内部有大量的业务接入使用,权限管理相比以前方便了许多。权限系统作为每家公司的一个基础系统,灵活且完备的设计可以助力日后业务的发展更加高效。\n后续两篇:\n转转统一权限系统的设计与实现(后端实现篇) 转转统一权限系统的设计与实现(前端实现篇) 参考 # 选择合适的权限模型:https://docs.authing.cn/v2/guides/access-control/choose-the-right-access-control-model.html "},{"id":252,"href":"/zh/docs/technology/Review/java_guide/lycly_system-design/security/ly04ly_sso-intro/","title":"sso单点登录","section":"安全","content":" 转载自https://github.com/Snailclimb/JavaGuide(添加小部分笔记)感谢作者!\n本文授权转载自 : https://ken.io/note/sso-design-implement 作者:ken.io\nSSO 介绍 # 什么是 SSO? # SSO 英文全称 Single Sign On,单点登录。SSO 是在多个应用系统中,用户只需要登录一次就可以访问所有相互信任的应用系统。\n例如你登录网易账号中心(https://reg.163.com/ )之后访问以下站点都是登录状态。\n网易直播 https://v.163.com 网易博客 https://blog.163.com 网易花田 https://love.163.com 网易考拉 https://www.kaola.com 网易 Lofter http://www.lofter.com SSO 有什么好处? # 用户角度 :用户能够做到一次登录多次使用,无需记录多套用户名和密码,省心。 系统管理员角度 : 管理员只需维护好一个统一的账号中心就可以了,方便。 新系统开发角度: 新系统开发时只需直接对接统一的账号中心即可,简化开发流程,省时。 SSO 设计与实现 # 本篇文章也主要是为了探讨如何设计\u0026amp;实现一个 SSO 系统\n以下为需要实现的核心功能:\n单点登录 单点登出 支持跨域单点登录 支持跨域单点登出 核心应用与依赖 # 应用/模块/对象 说明 前台站点 需要登录的站点 SSO 站点-登录 提供登录的页面 SSO 站点-登出 提供注销登录的入口 SSO 服务-登录 提供登录服务 SSO 服务-登录状态 提供登录状态校验/登录信息查询的服务 SSO 服务-登出 提供用户注销登录的服务 数据库 存储用户账户信息 缓存 存储用户的登录信息,通常使用 Redis 用户登录状态的存储与校验 # 常见的 Web 框架对于 Session 的实现都是生成一个 SessionId 存储在浏览器 Cookie 中。然后将 Session 内容存储在服务器端内存中,这个 ken.io 在之前 Session 工作原理中也提到过。整体也是借鉴这个思路。\n用户登录成功之后,生成 AuthToken 交给客户端保存。如果是浏览器,就保存在 Cookie 中。如果是手机 App 就保存在 App 本地缓存中。本篇主要探讨基于 Web 站点的 SSO。\n用户在浏览需要登录的页面时,客户端将 AuthToken 提交给 SSO 服务校验登录状态/获取用户登录信息\n对于登录信息的存储,建议采用 Redis,使用 Redis 集群来存储登录信息,既可以保证高可用,又可以线性扩充。同时也可以让 SSO 服务满足负载均衡/可伸缩的需求。\n对象 说明 AuthToken 直接使用 UUID/GUID 即可,如果有验证 AuthToken 合法性需求,可以将 UserName+时间戳加密生成,服务端解密之后验证合法性 登录信息 通常是将 UserId,UserName 缓存起来 用户登录/登录校验 # 登录时序图\n按照上图,用户登录后 AuthToken 保存在 Cookie 中。 domain=test.com 浏览器会将 domain 设置成 .test.com,\n这样访问所有 .test.com 的 web 站点,都会将 AuthToken 携带到服务器端*。 然后通过 SSO 服务,完成对用户状态的校验/用户登录信息的获取\n登录信息获取/登录状态校验\n用户登出 # 用户登出时要做的事情很简单:\n服务端清除缓存(Redis)中的登录状态 客户端清除存储的 AuthToken 登出时序图\n跨域登录、登出 # 前面提到过,核心思路是客户端存储 AuthToken,服务器端通过 Redis 存储登录信息。由于客户端是将 AuthToken 存储在 Cookie 中的。所以跨域要解决的问题,就是如何解决 Cookie 的跨域读写问题。\n解决跨域的核心思路就是:\n登录完成之后通过回调的方式,将 AuthToken 传递给主域名之外的站点,该站点自行将 AuthToken 保存在当前域下的 Cookie 中。 登出完成之后通过回调的方式,调用非主域名站点的登出页面,完成设置 Cookie 中的 AuthToken 过期的操作。(过期:先让主域名过期,再让非主域名过期[token失效]) 跨域登录(主域名已登录) 跨域登录(主域名未登录)\n跨域登出\n说明 # 关于方案 :这次设计方案更多是提供实现思路。如果涉及到 APP 用户登录等情况,在访问 SSO 服务时,增加对 APP 的签名验证就好了。当然,如果有无线网关,验证签名不是问题。 关于时序图:时序图中并没有包含所有场景,只列举了核心/主要场景,另外对于一些不影响理解思路的消息能省就省了。 "},{"id":253,"href":"/zh/docs/technology/Review/java_guide/lycly_system-design/security/ly03ly_jwt-advantages-disadvantages/","title":"jwt身份认证优缺点","section":"安全","content":" 转载自https://github.com/Snailclimb/JavaGuide(添加小部分笔记)感谢作者!\n在 JWT 基本概念详解这篇文章中,我介绍了:\n什么是 JWT? JWT 由哪些部分组成? 如何基于 JWT 进行身份验证? JWT 如何防止 Token 被篡改? 如何加强 JWT 的安全性? 这篇文章,我们一起探讨一下 JWT 身份认证的优缺点以及常见问题的解决办法。\nJWT 的优势 # 相比于 Session 认证的方式来说,使用 JWT 进行身份认证主要有下面 4 个优势。\n无状态 # JWT 自身包含了身份验证所需要的所有信息,因此,我们的服务器不需要存储 Session 信息。这显然增加了系统的可用性和伸缩性,大大减轻了服务端的压力。\n不过,也正是由于 JWT 的无状态,也导致了它最大的缺点:不可控!\n就比如说,我们想要在 JWT 有效期内废弃一个 JWT 或者更改它的权限的话,并不会立即生效,通常需要等到有效期过后才可以。再比如说,当用户 Logout 的话,JWT 也还有效。除非,我们在后端增加额外的处理逻辑比如将失效的 JWT 存储起来,后端先验证 JWT 是否有效再进行处理。具体的解决办法,我们会在后面的内容中详细介绍到,这里只是简单提一下。\n有效避免了 CSRF 攻击 # [ˈfɔːdʒəri] forgery 伪造\nCSRF(Cross Site Request Forgery) 一般被翻译为 跨站请求伪造,属于网络攻击领域范围。相比于 SQL 脚本注入、XSS 等安全攻击方式,CSRF 的知名度并没有它们高。但是,它的确是我们开发系统时必须要考虑的安全隐患。就连业内技术标杆 Google 的产品 Gmail 也曾在 2007 年的时候爆出过 CSRF 漏洞,这给 Gmail 的用户造成了很大的损失。\n那么究竟什么是跨站请求伪造呢? 简单来说就是用你的身份去做一些不好的事情(发送一些对你不友好的请求比如恶意转账)。\n举个简单的例子:小壮登录了某网上银行,他来到了网上银行的帖子区,看到一个帖子下面有一个链接写着“科学理财,年盈利率过万”,小壮好奇的点开了这个链接,结果发现自己的账户少了 10000 元。这是这么回事呢?原来黑客在链接中藏了一个请求,这个请求直接利用小壮的身份给银行发送了一个转账请求,也就是通过你的 Cookie 向银行发出请求。\n\u0026lt;a src=\u0026#34;http://www.mybank.com/Transfer?bankId=11\u0026amp;money=10000\u0026#34;\u0026gt;科学理财,年盈利率过万\u0026lt;/a\u0026gt; CSRF 攻击需要依赖 Cookie ,Session 认证中 Cookie 中的 SessionID 是由浏览器发送到服务端的,只要发出请求,Cookie 就会被携带。借助这个特性,即使黑客无法获取你的 SessionID,只要让你误点攻击链接,就可以达到攻击效果。\n另外,并不是必须点击链接才可以达到攻击效果,很多时候,只要你打开了某个页面,CSRF 攻击就会发生。\n\u0026lt;img src=\u0026#34;http://www.mybank.com/Transfer?bankId=11\u0026amp;money=10000\u0026#34; /\u0026gt; 那为什么 JWT 不会存在这种问题呢?\n一般情况下我们使用 JWT 的话,在我们登录成功获得 JWT 之后,一般会选择存放在 localStorage 中。前端的每一个请求后续都会附带上这个 JWT,整个过程压根不会涉及到 Cookie。因此,即使你点击了非法链接发送了请求到服务端,这个非法请求也是不会携带 JWT 的,所以这个请求将是非法的。\n总结来说就一句话:使用 JWT 进行身份验证不需要依赖 Cookie ,因此可以避免 CSRF 攻击。\n不过,这样也会存在 XSS 攻击的风险。为了避免 XSS 攻击,你可以选择将 JWT 存储在标记为httpOnly 的 Cookie 中。但是,这样又导致了你必须自己提供 CSRF 保护,因此,实际项目中我们通常也不会这么做。\nXSS攻击又称为跨站脚本,XSS的重点不在于跨站点,而是在于脚本的执行。XSS是一种经常出现在Web应用程序中的计算机安全漏洞,是由于Web应用程序对用户的输入过滤不足而产生的,它允许恶意web用户将代码植入到提供给其它用户使用的页面中。\n常见的避免 XSS 攻击的方式是过滤掉请求中存在 XSS 攻击风险的可疑字符串。\n在 Spring 项目中,我们一般是通过创建 XSS 过滤器来实现的。\n@Component @Order(Ordered.HIGHEST_PRECEDENCE) public class XSSFilter implements Filter { @Override public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain) throws IOException, ServletException { XSSRequestWrapper wrappedRequest = new XSSRequestWrapper((HttpServletRequest) request); chain.doFilter(wrappedRequest, response); } // other methods } 适合移动端应用 # 使用 Session 进行身份认证的话,需要保存一份信息在服务器端,而且这种方式会依赖到 Cookie(需要 Cookie 保存 SessionId),所以不适合移动端。\n但是,使用 JWT 进行身份认证就不会存在这种问题,因为只要 JWT 可以被客户端存储就能够使用,而且 JWT 还可以跨语言使用。\n单点登录友好 # 使用 Session 进行身份认证的话,实现单点登录,需要我们把用户的 Session 信息保存在一台电脑上,并且还会遇到常见的 Cookie 跨域的问题。但是,使用 JWT 进行认证的话, JWT 被保存在客户端,不会存在这些问题。\nJWT 身份认证常见问题及解决办法 # 注销登录等场景下 JWT 还有效 # 与之类似的具体相关场景有:\n退出登录; 修改密码; 服务端修改了某个用户具有的权限或者角色; 用户的帐户被封禁/删除; 用户被服务端强制注销; 用户被踢下线; \u0026hellip;\u0026hellip; 这个问题不存在于 Session 认证方式中,因为在 Session 认证方式中,遇到这种情况的话服务端删除对应的 Session 记录即可。但是,使用 JWT 认证的方式就不好解决了。我们也说过了,JWT 一旦派发出去,如果后端不增加其他逻辑的话,它在失效之前都是有效的。\n那我们如何解决这个问题呢?查阅了很多资料,我简单总结了下面 4 种方案:\n1、将 JWT 存入内存数据库\n将 JWT 存入 DB 中,Redis 内存数据库在这里是不错的选择。如果需要让某个 JWT 失效就直接从 Redis 中删除这个 JWT 即可。但是,这样会导致每次使用 JWT 发送请求都要先从 DB 中查询 JWT 是否存在的步骤,而且违背了 JWT 的无状态原则。\n2、黑名单机制\n和上面的方式类似,使用内存数据库比如 Redis 维护一个黑名单,如果想让某个 JWT 失效的话就直接将这个 JWT 加入到 黑名单 即可。然后,每次使用 JWT 进行请求的话都会先判断这个 JWT 是否存在于黑名单中。\n前两种方案的核心在于将有效的 JWT 存储起来或者将指定的 JWT 拉入黑名单。\n虽然这两种方案都违背了 JWT 的无状态原则,但是一般实际项目中我们通常还是会使用这两种方案。\n3、修改密钥 (Secret) :\n我们为每个用户都创建一个专属密钥,如果我们想让某个 JWT 失效,我们直接修改对应用户的密钥即可。但是,这样相比于前两种引入内存数据库带来了危害更大:\n如果服务是分布式的,则每次发出新的 JWT 时都必须在多台机器同步密钥。为此,你需要将密钥存储在数据库或其他外部服务中,这样和 Session 认证就没太大区别了。 如果用户同时在两个浏览器打开系统,或者在手机端也打开了系统,如果它从一个地方将账号退出,那么其他地方都要重新进行登录,这是不可取的。 4、保持令牌的有效期限短并经常轮换\n很简单的一种方式。但是,会导致用户登录状态不会被持久记录,而且需要用户经常登录。\n另外,对于修改密码后 JWT 还有效问题的解决还是比较容易的。说一种我觉得比较好的方式:使用用户的密码的哈希值对 JWT 进行签名。因此,如果密码更改,则任何先前的令牌将自动无法验证。\nJWT 的续签问题 # JWT 有效期一般都建议设置的不太长,那么 JWT 过期后如何认证,如何实现动态刷新 JWT,避免用户经常需要重新登录?\n我们先来看看在 Session 认证中一般的做法:假如 Session 的有效期 30 分钟,如果 30 分钟内用户有访问,就把 Session 有效期延长 30 分钟。\nJWT 认证的话,我们应该如何解决续签问题呢?查阅了很多资料,我简单总结了下面 4 种方案:\n1、类似于 Session 认证中的做法\n这种方案满足于大部分场景。假设服务端给的 JWT 有效期设置为 30 分钟,服务端每次进行校验时,如果发现 JWT 的有效期马上快过期了,服务端就重新生成 JWT 给客户端。客户端每次请求都检查新旧 JWT,如果不一致,则更新本地的 JWT。这种做法的问题是仅仅在快过期的时候请求才会更新 JWT ,对客户端不是很友好。\n2、每次请求都返回新 JWT\n这种方案的的思路很简单,但是,开销会比较大,尤其是在服务端要存储维护 JWT 的情况下。\n3、JWT 有效期设置到半夜\n这种方案是一种折衷的方案,保证了大部分用户白天可以正常登录,适用于对安全性要求不高的系统。\n4、用户登录返回两个 JWT\n第一个是 accessJWT ,它的过期时间 JWT 本身的过期时间比如半个小时,另外一个是 refreshJWT 它的过期时间更长一点比如为 1 天。客户端登录后,将 accessJWT 和 refreshJWT 保存在本地,每次访问将 accessJWT 传给服务端。服务端校验 accessJWT 的有效性,如果过期的话,就将 refreshJWT 传给服务端。如果有效,服务端就生成新的 accessJWT 给客户端。否则,客户端就重新登录即可。\n这种方案的不足是:\n需要客户端来配合;\n用户注销的时候需要同时保证两个 JWT 都无效;\n重新请求获取 JWT 的过程中会有短暂 JWT 不可用的情况(可以通过在客户端设置定时器,当 accessJWT 快过期的时候,提前去通过 refreshJWT 获取新的 accessJWT);\n这里说的短暂,就是accessJWT失效而refreshJWT成功的那种情况\n存在安全问题,只要拿到了未过期的 refreshJWT 就一直可以获取到 accessJWT。\n总结 # JWT 其中一个很重要的优势是无状态,但实际上,我们想要在实际项目中合理使用 JWT 的话,也还是需要保存 JWT 信息。\nJWT 也不是银弹,也有很多缺陷,具体是选择 JWT 还是 Session 方案还是要看项目的具体需求。万万不可尬吹 JWT,而看不起其他身份认证方案。\n另外,不用 JWT 直接使用普通的 Token(随机生成,不包含具体的信息) 结合 Redis 来做身份认证也是可以的。我在 「优质开源项目推荐」的第 8 期推荐过的 Sa-Token 这个项目是一个比较完善的 基于 JWT 的身份认证解决方案,支持自动续签、踢人下线、账号封禁、同端互斥登录等功能,感兴趣的朋友可以看看。\n参考 # JWT 超详细分析:https://learnku.com/articles/17883 How to log out when using JWT:https://medium.com/devgorilla/how-to-log-out-when-using-jwt-a8c7823e8a6 CSRF protection with JSON Web JWTs:https://medium.com/@agungsantoso/csrf-protection-with-json-web-JWTs-83e0f2fcbcc Invalidating JSON Web JWTs:https://stackoverflow.com/questions/21978658/invalidating-json-web-JWTs "},{"id":254,"href":"/zh/docs/technology/Review/java_guide/lycly_system-design/security/ly02ly_jwt-intro/","title":"jwt-intro","section":"安全","content":" 转载自https://github.com/Snailclimb/JavaGuide(添加小部分笔记)感谢作者!\n什么是 JWT? # JWT (JSON Web Token) 是目前最流行的跨域认证解决方案,是一种基于 Token 的认证授权机制。 从 JWT 的全称可以看出,JWT 本身也是 Token,一种规范化之后的 JSON 结构的 Token。\n跨域认证的问题\n互联网服务离不开用户认证。一般流程是下面这样。\n这种模式的问题在于,扩展性(scaling)不好。单机当然没有问题,如果是服务器集群,或者是跨域的服务导向架构,就要求 session 数据共享,每台服务器都能够读取 session。\n举例来说,A 网站和 B 网站是同一家公司的关联服务。现在要求,用户只要在其中一个网站登录,再访问另一个网站就会自动登录,请问怎么实现?\n一种解决方案是 session 数据持久化,写入数据库或别的持久层。各种服务收到请求后,都向持久层请求数据。这种方案的优点是架构清晰,缺点是工程量比较大。另外,持久层万一挂了,就会单点失败。\n另一种方案是服务器索性不保存 session 数据了,所有数据都保存在客户端,每次请求都发回服务器。JWT 就是这种方案的一个代表。\nJWT 自身包含了身份验证所需要的所有信息,因此,我们的服务器不需要存储 Session 信息。这显然增加了系统的可用性和伸缩性,大大减轻了服务端的压力。\nly:我觉得这里的重点就是,服务器不存储Session以维护\u0026quot;用户\u0026quot;和cookie(session id)的关系了\n可以看出,JWT 更符合设计 RESTful API 时的「Stateless(无状态)」原则 。\n并且, 使用 JWT 认证可以有效避免 CSRF 攻击,因为 JWT 一般是存在在 localStorage 中,使用 JWT 进行身份验证的过程中是不会涉及到 Cookie 的。\n我在 JWT 优缺点分析这篇文章中有详细介绍到使用 JWT 做身份认证的优势和劣势。\n下面是 RFC 7519 对 JWT 做的较为正式的定义。\nJSON Web Token (JWT) is a compact, URL-safe means of representing claims to be transferred between two parties. The claims in a JWT are encoded as a JSON object that is used as the payload of a JSON Web Signature (JWS) structure or as the plaintext of a JSON Web Encryption (JWE) structure, enabling the claims to be digitally signed or integrity protected with a Message Authentication Code (MAC) and/or encrypted. —— JSON Web Token (JWT)\nJWT 由哪些部分组成? # JWT 本质上就是一组字串,通过(.)切分成三个为 Base64 编码的部分:\nHeader : 描述 JWT 的元数据,定义了生成签名的算法以及 Token 的类型。 Payload : 用来存放实际需要传递的数据 Signature(签名) :服务器通过 Payload、Header 和**一个密钥(Secret)**使用 Header 里面指定的签名算法(默认是 HMAC SHA256)生成。 JWT 通常是这样的:xxxxx.yyyyy.zzzzz。\n示例:\neyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9. eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiaWF0IjoxNTE2MjM5MDIyfQ. SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c 你可以在 jwt.io 这个网站上对其 JWT 进行解码,解码之后得到的就是 Header、Payload、Signature 这三部分。\nHeader 和 Payload 都是 JSON 格式的数据,Signature 由 Payload、Header 和 **Secret(密钥)**通过特定的计算公式和加密算法得到。\nHeader # Header 通常由两部分组成:\ntyp(Type):令牌类型,也就是 JWT。 alg(Algorithm) :签名算法,比如 HS256。 示例:\n{ \u0026#34;alg\u0026#34;: \u0026#34;HS256\u0026#34;, \u0026#34;typ\u0026#34;: \u0026#34;JWT\u0026#34; } JSON 形式的 Header 被转换成 Base64 编码,成为 JWT 的第一部分。\nPayload # Payload 也是 JSON 格式数据,其中包含了 Claims(声明,包含 JWT 的相关信息)。\nClaims 分为三种类型:\nRegistered Claims(注册声明) :预定义的一些声明,建议使用,但不是强制性的。 Public Claims(公有声明) :JWT 签发方可以自定义的声明,但是为了避免冲突,应该在 IANA JSON Web Token Registry 中定义它们。 Private Claims(私有声明) :JWT 签发方因为项目需要而自定义的声明,更符合实际项目场景使用。 下面是一些常见的注册声明:\niss(issuer):JWT 签发方。 iat(issued at time):JWT 签发时间。 sub(subject):JWT 主题。 aud(audience):JWT 接收方。 exp(expiration time):JWT 的过期时间。 nbf(not before time):JWT 生效时间,早于该定义的时间的 JWT 不能被接受处理。 jti(JWT ID):JWT 唯一标识。 示例:\n{ \u0026#34;uid\u0026#34;: \u0026#34;ff1212f5-d8d1-4496-bf41-d2dda73de19a\u0026#34;, \u0026#34;sub\u0026#34;: \u0026#34;1234567890\u0026#34;, \u0026#34;name\u0026#34;: \u0026#34;John Doe\u0026#34;, \u0026#34;exp\u0026#34;: 15323232, \u0026#34;iat\u0026#34;: 1516239022, \u0026#34;scope\u0026#34;: [\u0026#34;admin\u0026#34;, \u0026#34;user\u0026#34;] } Payload 部分默认是不加密的,一定不要将隐私信息存放在 Payload 当中!!!\nJSON 形式的 Payload 被转换成 Base64 编码,成为 JWT 的第二部分。\nSignature # Signature 部分是对前两部分的签名,作用是防止 JWT(主要是 payload) 被篡改。\n这个签名的生成需要用到:\nHeader + Payload。 存放在服务端的密钥(一定不要泄露出去)。 签名算法。 签名的计算公式如下:\nHMACSHA256( base64UrlEncode(header) + \u0026#34;.\u0026#34; + base64UrlEncode(payload), secret) 算出签名以后,把 Header、Payload、Signature 三个部分拼成一个字符串,每个部分之间用\u0026quot;点\u0026quot;(.)分隔,这个字符串就是 JWT 。\n如何基于 JWT 进行身份验证? # 在基于 JWT 进行身份验证的的应用程序中,服务器通过 Payload、Header 和 Secret(密钥)创建 JWT 并将 JWT 发送给客户端。客户端接收到 JWT 之后,会将其保存在 Cookie 或者 localStorage 里面,以后客户端发出的所有请求都会携带这个令牌。\n简化后的步骤如下:\n用户向服务器发送用户名、密码以及验证码用于登陆系统。\n如果用户用户名、密码以及验证码校验正确的话,服务端会返回已经签名的 Token,也就是 JWT。\n注意,很重要,★★ JWT是服务器生成的!\n用户以后每次向后端发请求都在 Header 中带上这个 JWT 。\n服务端检查 JWT 并从中获取用户相关信息。\n两点建议:\n建议将 JWT 存放在 localStorage 中,放在 Cookie 中会有 CSRF 风险。 请求服务端并携带 JWT 的常见做法是将其放在 HTTP Header 的 Authorization 字段中(Authorization: Bearer Token)。 spring-security-jwt-guide 就是一个基于 JWT 来做身份认证的简单案例,感兴趣的可以看看。\n如何防止 JWT 被篡改? # 有了签名之后,即使 JWT 被泄露或者截获,黑客也没办法同时篡改 Signature 、Header 、Payload。\n这是为什么呢?因为服务端拿到 JWT 之后,会解析出其中包含的 Header、Payload 以及 Signature 。服务端会根据 Header、Payload、密钥再次生成一个 Signature。拿新生成的 Signature 和 JWT 中的 Signature 作对比,如果一样就说明 Header 和 Payload 没有被修改。\n不过,如果服务端的秘钥也被泄露的话,黑客就可以同时篡改 Signature 、Header 、Payload 了。黑客直接修改了 Header 和 Payload 之后,再重新生成一个 Signature 就可以了。\n密钥一定保管好,一定不要泄露出去。JWT 安全的核心在于签名,签名安全的核心在密钥。\n如何加强 JWT 的安全性? # 使用安全系数高的加密算法。 使用成熟的开源库,没必要造轮子。 JWT 存放在 localStorage 中而不是 Cookie 中,避免 CSRF 风险。 一定不要将隐私信息存放在 Payload 当中。 密钥一定保管好,一定不要泄露出去。JWT 安全的核心在于签名,签名安全的核心在密钥。 Payload 要加入 exp (JWT 的过期时间),永久有效的 JWT 不合理。并且,JWT 的过期时间不易过长。 \u0026hellip;\u0026hellip; "},{"id":255,"href":"/zh/docs/technology/Review/java_guide/lycly_system-design/security/ly01ly_basis-of-authority-certification/","title":"认证授权基础概念详解","section":"安全","content":" 转载自https://github.com/Snailclimb/JavaGuide(添加小部分笔记)感谢作者!\n认证 (Authentication) 和授权 (Authorization)的区别是什么? # 这是一个绝大多数人都会混淆的问题。首先先从读音上来认识这两个名词,很多人都会把它俩的读音搞混,所以我建议你先先去查一查这两个单词到底该怎么读,他们的具体含义是什么。\n说简单点就是:\n认证 (Authentication): 你是谁。[ɔːˌθentɪˈkeɪʃn] 身份验证 授权 (Authorization): 你有权限干什么。[ˌɔːθəraɪˈzeɪʃn] 授权 稍微正式点(啰嗦点)的说法就是 :\nAuthentication(认证) 是验证您的身份的凭据(例如用户名/用户 ID 和密码),通过这个凭据,系统得以知道你就是你,也就是说系统存在你这个用户。所以,Authentication 被称为身份/用户验证。 Authorization(授权) 发生在 Authentication(认证) 之后。授权嘛,光看意思大家应该就明白,它主要掌管我们访问系统的权限。比如有些特定资源只能具有特定权限的人才能访问比如 admin,有些对系统资源操作比如删除、添加、更新只能特定人才具有。 认证 :\n授权:\n这两个一般在我们的系统中被结合在一起使用,目的就是为了保护我们系统的安全性。\nRBAC 模型了解吗? # 系统权限控制最常采用的访问控制模型就是 RBAC 模型 。\n什么是 RBAC 呢?\nRBAC 即基于角色的权限访问控制(Role-Based Access Control)。这是一种通过角色关联权限,角色同时又关联用户的授权的方式。\n简单地说:一个用户可以拥有若干角色,每一个角色又可以被分配若干权限,这样就构造成“用户-角色-权限” 的授权模型。在这种模型中,用户与角色、角色与权限之间构成了多对多的关系,如下图\n在 RBAC 中,权限与角色相关联,用户通过成为适当角色的成员而得到这些角色的权限。这就极大地简化了权限的管理。\n本系统的权限设计相关的表如下(一共 5 张表,2 张用户建立表之间的联系):\n通过这个权限模型,我们可以创建不同的角色并为不同的角色分配不同的权限范围(菜单)。\n通常来说,如果系统对于权限控制要求比较严格的话,一般都会选择使用 RBAC 模型来做权限控制。\n什么是 Cookie ? Cookie 的作用是什么? # ly:如上,可以看出 cookie的附属是域名\nCookie 和 Session 都是用来跟踪浏览器用户身份的会话方式,但是两者的应用场景不太一样。\n维基百科是这样定义 Cookie 的:\nCookies 是某些网站为了辨别用户身份而储存在用户本地终端上的数据(通常经过加密)。\n简单来说: Cookie 存放在客户端,一般用来保存用户信息。\n下面是 Cookie 的一些应用案例:\n我们在 Cookie 中保存已经登录过的用户信息,下次访问网站的时候页面可以自动帮你登录的一些基本信息给填了。除此之外,Cookie 还能保存用户首选项,主题和其他设置信息。\n使用 Cookie 保存 SessionId 或者 Token ,向后端发送请求的时候带上 Cookie,这样后端就能取到 Session 或者 Token 了。这样就能记录用户当前的状态了,因为 HTTP 协议是无状态的。\n无状态的意思就是通过http发的多次请求,没有特殊处理的话我们是不知道是否是同一个用户发的,也就是没有用户状态。\nCookie 还可以用来记录和分析用户行为。举个简单的例子你在网上购物的时候,因为 HTTP 协议是没有状态的,如果服务器想要获取你在某个页面的停留状态或者看了哪些商品,一种常用的实现方式就是将这些信息存放在 Cookie\n\u0026hellip;\u0026hellip;\n如何在项目中使用 Cookie 呢? # 我这里以 Spring Boot 项目为例。\n1)设置 Cookie 返回给客户端\n@GetMapping(\u0026#34;/change-username\u0026#34;) public String setCookie(HttpServletResponse response) { // 创建一个 cookie Cookie cookie = new Cookie(\u0026#34;username\u0026#34;, \u0026#34;Jovan\u0026#34;); //设置 cookie过期时间 cookie.setMaxAge(7 * 24 * 60 * 60); // expires in 7 days //添加到 response 中 response.addCookie(cookie); return \u0026#34;Username is changed!\u0026#34;; } 2) 使用 Spring 框架提供的 @CookieValue 注解获取特定的 cookie 的值\n@GetMapping(\u0026#34;/\u0026#34;) public String readCookie(@CookieValue(value = \u0026#34;username\u0026#34;, defaultValue = \u0026#34;Atta\u0026#34;) String username) { return \u0026#34;Hey! My username is \u0026#34; + username; } 3) 读取所有的 Cookie 值\n@GetMapping(\u0026#34;/all-cookies\u0026#34;) public String readAllCookies(HttpServletRequest request) { Cookie[] cookies = request.getCookies(); if (cookies != null) { return Arrays.stream(cookies) .map(c -\u0026gt; c.getName() + \u0026#34;=\u0026#34; + c.getValue()).collect(Collectors.joining(\u0026#34;, \u0026#34;)); } return \u0026#34;No cookies\u0026#34;; } 更多关于如何在 Spring Boot 中使用 Cookie 的内容可以查看这篇文章: How to use cookies in Spring Boot 。\nCookie 和 Session 有什么区别? # Session 的主要作用就是通过服务端记录用户的状态。 典型的场景是购物车,当你要添加商品到购物车的时候,★★重要:系统不知道是哪个用户操作的,因为 HTTP 协议是无状态的。服务端给特定的用户创建特定的 Session 之后就可以标识这个用户并且跟踪这个用户了。\nCookie 数据保存在客户端(浏览器端),Session 数据保存在服务器端。相对来说 Session 安全性更高。如果使用 Cookie 的一些敏感信息不要写入 Cookie 中,最好能将 Cookie 信息加密然后使用到的时候再去服务器端解密。\n那么,如何使用 Session 进行身份验证?\n如何使用 Session-Cookie 方案进行身份验证? # 很多时候我们都是通过 SessionID 来实现特定的用户,SessionID 一般会选择存放在 Redis 中。举个例子:\n用户成功登陆系统,然后返回给客户端具有 SessionID 的 Cookie 。 当用户向后端发起请求的时候会把 SessionID 带上,这样后端就知道你的身份状态了。 关于这种认证方式更详细的过程如下:\n用户向服务器发送用户名、密码、验证码用于登陆系统。 服务器验证通过后,服务器为用户创建一个 Session,并将 Session 信息存储起来(一般是Redis)。 服务器向用户返回一个 SessionID,写入用户的 Cookie。 当用户保持登录状态时,Cookie 将与每个后续请求一起被发送出去。 服务器可以将存储在 Cookie 上的 SessionID 与存储在内存中或者数据库中的 Session 信息进行比较,以验证用户的身份,返回给用户客户端响应信息的时候会附带用户当前的状态。 使用 Session 的时候需要注意下面几个点:\n依赖 Session 的关键业务一定要确保客户端开启了 Cookie。 注意 Session 的过期时间。 另外,Spring Session 提供了一种跨多个应用程序或实例管理用户会话信息的机制。如果想详细了解可以查看下面几篇很不错的文章:\nGetting Started with Spring Session Guide to Spring Session Sticky Sessions with Spring Session \u0026amp; Redis 多服务器节点下 Session-Cookie 方案如何做? # Session-Cookie 方案在单体环境是一个非常好的身份认证方案。但是,当服务器水平拓展成多节点时,Session-Cookie 方案就要面临挑战了。\n举个例子:假如我们部署了两份相同的服务 A,B,用户第一次登陆的时候 ,Nginx 通过负载均衡机制将用户请求转发到 A 服务器,此时用户的 Session 信息保存在 A 服务器。结果,用户第二次访问的时候 Nginx 将请求路由到 B 服务器,由于 B 服务器没有保存 用户的 Session 信息,导致用户需要重新进行登陆。\n我们应该如何避免上面这种情况的出现呢?\n有几个方案可供大家参考:\n某个用户的所有请求都通过特性的哈希策略分配给同一个服务器处理。这样的话,每个服务器都保存了一部分用户的 Session 信息。服务器宕机,其保存的所有 Session 信息就完全丢失了。 每一个服务器保存的 Session 信息都是互相同步的,也就是说每一个服务器都保存了全量的 Session 信息。每当一个服务器的 Session 信息发生变化,我们就将其同步到其他服务器。这种方案成本太大,并且,节点越多时,同步成本也越高。 单独使用一个所有服务器都能访问到的数据节点(比如缓存)来存放 Session 信息。为了保证高可用,数据节点尽量要避免是单点。 如果没有 Cookie 的话 Session 还能用吗? # 这是一道经典的面试题!\n一般是通过 Cookie 来保存 SessionID ,假如你使用了 Cookie 保存 SessionID 的方案的话, 如果客户端禁用了 Cookie,那么 Session 就无法正常工作。\n但是,并不是没有 Cookie 之后就不能用 Session 了,比如你可以将 SessionID 放在请求的 url 里面https://javaguide.cn/?Session_id=xxx 。这种方案的话可行,但是安全性和用户体验感降低。当然,为了你也可以对 SessionID 进行一次加密之后再传入后端。\n个人觉得localstorage也是可以的\n为什么 Cookie 无法防止 CSRF 攻击,而 Token 可以? # CSRF(Cross Site Request Forgery) 一般被翻译为 跨站请求伪造 。那么什么是 跨站请求伪造 呢?说简单用你的身份去发送一些对你不友好的请求。举个简单的例子:\n小壮登录了某网上银行,他来到了网上银行的帖子区,看到一个帖子下面有一个链接写着“科学理财,年盈利率过万”,小壮好奇的点开了这个链接,结果发现自己的账户少了 10000 元。这是这么回事呢?原来黑客在链接中藏了一个请求,这个请求直接利用小壮的身份给银行发送了一个转账请求,也就是通过你的 Cookie 向银行发出请求。\n\u0026lt;a src=http://www.mybank.com/Transfer?bankId=11\u0026amp;money=10000\u0026gt;科学理财,年盈利率过万\u0026lt;/\u0026gt; 上面也提到过,进行 Session 认证的时候,我们一般使用 Cookie 来存储 SessionId,当我们登陆后后端生成一个 SessionId 放在 Cookie 中返回给客户端,服务端通过 Redis 或者其他存储工具记录保存着这个 SessionId,客户端登录以后每次请求都会带上这个 SessionId,服务端通过这个 SessionId 来标示你这个人。如果别人通过 Cookie 拿到了 SessionId 后就可以代替你的身份访问系统了。\nSession 认证中 Cookie 中的 SessionId 是由浏览器发送到服务端的,借助这个特性,攻击者就可以通过让用户误点攻击链接,达到攻击效果。\n但是,我们使用 Token 的话就不会存在这个问题,在我们登录成功获得 Token 之后,一般会选择存放在 localStorage (浏览器本地存储)中。然后我们在前端通过某些方式会给每个发到后端的请求加上这个 Token,这样就不会出现 CSRF 漏洞的问题。因为,即使有个你点击了非法链接发送了请求到服务端,这个非法请求是不会携带 Token 的,所以这个请求将是非法的。\n需要注意的是:不论是 Cookie 还是 Token 都无法避免 跨站脚本攻击(Cross Site Scripting)XSS 。\n跨站脚本攻击(Cross Site Scripting)缩写为 CSS 但这会与层叠样式表(Cascading Style Sheets,CSS)的缩写混淆。因此,有人将跨站脚本攻击缩写为 XSS。\nXSS 中攻击者会用各种方式将恶意代码注入到其他用户的页面中。就可以通过脚本盗用信息比如 Cookie 。\n推荐阅读: 如何防止 CSRF 攻击?—美团技术团队\n什么是 JWT?JWT 由哪些部分组成? # JWT 基础概念详解\n如何基于 JWT 进行身份验证? 如何防止 JWT 被篡改? # JWT 基础概念详解\n什么是 SSO? # SSO(Single Sign On)即单点登录说的是用户登陆多个子系统的其中一个就有权访问与其相关的其他系统。举个例子我们在登陆了京东金融之后,我们同时也成功登陆京东的京东超市、京东国际、京东生鲜等子系统。\nSSO 有什么好处? # 用户角度 :用户能够做到一次登录多次使用,无需记录多套用户名和密码,省心。 系统管理员角度 : 管理员只需维护好一个统一的账号中心就可以了,方便。 新系统开发角度: 新系统开发时只需直接对接统一的账号中心即可,简化开发流程,省时。 如何设计实现一个 SSO 系统? # SSO 单点登录详解\n什么是 OAuth 2.0? # OAuth 是一个行业的标准授权协议,主要用来授权第三方应用获取有限的权限。而 OAuth 2.0 是对 OAuth 1.0 的完全重新设计,OAuth 2.0 更快,更容易实现,OAuth 1.0 已经被废弃。详情请见: rfc6749。\n实际上它就是一种授权机制,它的最终目的是为第三方应用颁发一个有时效性的令牌 Token,使得第三方应用能够通过该令牌获取相关的资源。\nOAuth 2.0 比较常用的场景就是第三方登录,当你的网站接入了第三方登录的时候一般就是使用的 OAuth 2.0 协议。\n另外,现在 OAuth 2.0 也常见于支付场景(微信支付、支付宝支付)和开发平台(微信开放平台、阿里开放平台等等)。\n下图是 Slack OAuth 2.0 第三方登录的示意图:\n推荐阅读:\nOAuth 2.0 的一个简单解释 10 分钟理解什么是 OAuth 2.0 协议 OAuth 2.0 的四种方式 GitHub OAuth 第三方登录示例教程 参考 # 不要用 JWT 替代 session 管理(上):全面了解 Token,JWT,OAuth,SAML,SSO:https://zhuanlan.zhihu.com/p/38942172 Introduction to JSON Web Tokens:https://jwt.io/introduction JSON Web Token Claims:https://auth0.com/docs/secure/tokens/json-web-tokens/json-web-token-claims "},{"id":256,"href":"/zh/docs/technology/Review/java_guide/lycly_system-design/basis/unit-test/","title":"单元测试","section":"基础","content":" 转载自https://github.com/Snailclimb/JavaGuide(添加小部分笔记)感谢作者!\n何谓单元测试? # 维基百科是这样介绍单元测试的:\n在计算机编程中,单元测试(Unit Testing)是针对程序模块(软件设计的最小单位)进行的正确性检验测试工作。\n程序单元是应用的 最小可测试部件 。在过程化编程中,一个单元就是单个程序、函数、过程等;对于面向对象编程,最小单元就是方法,包括基类(超类)、抽象类、或者派生类(子类)中的方法。\n由于每个单元有独立的逻辑,在做单元测试时,为了隔离外部依赖,确保这些依赖不影响验证逻辑,我们经常会用到 Fake、Stub 与 Mock 。\n关于 Fake、Mock 与 Stub 这几个概念的解读,可以看看这篇文章: 测试中 Fakes、Mocks 以及 Stubs 概念明晰 - 王下邀月熊 - 2018 。\n为什么需要单元测试? # 为重构保驾护航 # 我在 重构这篇文章中这样写到:\n单元测试可以为重构提供信心,降低重构的成本。我们要像重视生产代码那样,重视单元测试。\n每个开发者都会经历重构,重构后把代码改坏了的情况并不少见,很可能你只是修改了一个很简单的方法就导致系统出现了一个比较严重的错误。\n如果有了单元测试的话,就不会存在这个隐患了。写完一个类,把单元测试写了,确保这个类逻辑正确;写第二个类,单元测试\u0026hellip;..写 100 个类,道理一样,每个类做到第一点“保证逻辑正确性”,100 个类拼在一起肯定不出问题。你大可以放心一边重构,一边运行 APP;而不是整体重构完,提心吊胆地 run。\n提高代码质量 # 由于每个单元有独立的逻辑,做单元测试时需要隔离外部依赖,确保这些依赖不影响验证逻辑。因为要把各种依赖分离,单元测试会促进工程进行组件拆分,整理工程依赖关系,更大程度减少代码耦合。这样写出来的代码,更好维护,更好扩展,从而提高代码质量。\n减少 bug # 一个机器,由各种细小的零件组成,如果其中某件零件坏了,机器运行故障。必须保证每个零件都按设计图要求的规格,机器才能正常运行。\n一个可单元测试的工程,会把业务、功能分割成规模更小、有独立的逻辑部件,称为单元。单元测试的目标,就是保证各个单元的逻辑正确性。单元测试保障工程各个“零件”按“规格”(需求)执行,从而保证整个“机器”(项目)运行正确,最大限度减少 bug。\n快速定位 bug # 如果程序有 bug,我们运行一次全部单元测试,找到不通过的测试,可以很快地定位对应的执行代码。修复代码后,运行对应的单元测试;如还不通过,继续修改,运行测试\u0026hellip;..直到测试通过。\n持续集成依赖单元测试 # 持续集成需要依赖单元测试,当持续集成服务自动构建新代码之后,会自动运行单元测试来发现代码错误。\n谁逼你写单元测试? # 领导要求 # 有些经验丰富的领导,或多或少都会要求团队写单元测试。对于有一定工作经验的队友,这要求挺合理;对于经验尚浅的、毕业生,恐怕要死要活了,连代码都写不好,还要写单元测试,are you kidding me?\n培训新人单元测试用法,是一项艰巨的任务。新人代码风格未形成,也不知道单元测试多重要,强制单元测试会让他们感到困惑,没办法按自己思路写代码。\n大牛都写单元测试 # 国外很多家喻户晓的开源项目,都有大量单元测试。例如, retrofit、 okhttp、 butterknife\u0026hellip;. 国外大牛都写单元测试,我们也写吧!\n很多读者都有这种想法,一开始满腔热血。当真要对自己项目单元测试时,便困难重重,很大原因是项目对单元测试不友好。最后只能对一些不痛不痒的工具类做单元测试,久而久之,当初美好愿望也不了了之。\n保住面子 # 都是有些许年经验的老鸟,还天天被测试同学追 bug,好意思么?花多一点时间写单元测试,确保没低级 bug,还能彰显大牛风范,何乐而不为?\n心虚 # 笔者也是个不太相信自己代码的人,总觉得哪里会突然冒出莫名其妙的 bug,也怕别人不小心改了自己的代码(被害妄想症),新版本上线提心吊胆\u0026hellip;\u0026hellip;花点时间写单元测试,有事没事跑一下测试,确保原逻辑没问题,至少能睡安稳一点。\nTDD 测试驱动开发 # 何谓 TDD? # TDD 即 Test-Driven Development( 测试驱动开发),这是敏捷开发的一项核心实践和技术,也是一种设计方法论。\nTDD 原理是开发功能代码之前,先编写测试用例代码,然后针对测试用例编写功能代码,使其能够通过。\nTDD 的节奏:“红 - 绿 - 重构”。\n由于 TDD 对开发人员要求非常高,跟传统开发思维不一样,因此实施起来相当困难。\nTDD 在很多人眼中是不实用的,一来他们并不理解测试“驱动”开发的含义,但更重要的是,他们很少会做任务分解。而任务分解是做好 TDD 的关键点。只有把任务分解到可以测试的地步,才能够有针对性地写测试。\nTDD 优缺点分析 # 测试驱动开发有好处也有坏处。因为每个测试用例都是根据需求来的,或者说把一个大需求分解成若干小需求编写测试用例,所以测试用例写出来后,开发者写的执行代码,必须满足测试用例。如果测试不通过,则修改执行代码,直到测试用例通过。\n优点 :\n帮你整理需求,梳理思路; 帮你设计出更合理的接口(空想的话很容易设计出屎); 减小代码出现 bug 的概率; 提高开发效率(前提是正确且熟练使用 TDD)。 缺点 :\n能用好 TDD 的人非常少,看似简单,实则门槛很高; 投入开发资源(时间和精力)通常会更多; 由于测试用例在未进行代码设计前写;很有可能限制开发者对代码整体设计; 可能引起开发人员不满情绪,我觉得这点很严重,毕竟不是人人都喜欢单元测试,尽管单元测试会带给我们相当多的好处。 相关阅读: 如何用正确的姿势打开 TDD? - 陈天 - 2017 。\n总结 # 单元测试确实会带给你相当多的好处,但不是立刻体验出来。正如买重疾保险,交了很多保费,没病没痛,十几年甚至几十年都用不上,最好就是一辈子用不上理赔,身体健康最重要。单元测试也一样,写了可以买个放心,对代码的一种保障,有 bug 尽快测出来,没 bug 就最好,总不能说“写那么多单元测试,结果测不出 bug,浪费时间”吧?\n以下是个人对单元测试一些建议:\n越重要的代码,越要写单元测试; 代码做不到单元测试,多思考如何改进,而不是放弃; 边写业务代码,边写单元测试,而不是完成整个新功能后再写; 多思考如何改进、简化测试代码。 测试代码需要随着生产代码的演进而重构或者修改,如果测试不能保持整洁,只会越来越难修改。 作为一名经验丰富的程序员,写单元测试更多的是对自己的代码负责。有测试用例的代码,别人更容易看懂,以后别人接手你的代码时,也可能放心做改动。\n多敲代码实践,多跟有单元测试经验的工程师交流,你会发现写单元测试获得的收益会更多。\n"},{"id":257,"href":"/zh/docs/technology/Review/java_guide/lycly_system-design/basis/refactoring/","title":"代码重构指南","section":"基础","content":" 转载自https://github.com/Snailclimb/JavaGuide(添加小部分笔记)感谢作者!\n前段时间重读了 《重构:改善代码既有设计》,收货颇多。于是,简单写了一篇文章来聊聊我对重构的看法。\n何谓重构? # 学习重构必看的一本神书《重构:改善代码既有设计》从两个角度给出了重构的定义:\n重构(名词):对软件内部结构的一种调整,目的是在不改变软件可观察行为的前提下,提高其可理解性,降低其修改成本。 重构(动词):使用一系列重构手法,在不改变软件可观察行为的前提下,调整其结构。 用更贴近工程师的语言来说: 重构就是利用设计模式(如组合模式、策略模式、责任链模式)、软件设计原则(如 SOLID 原则、YAGNI 原则、KISS 原则)和重构手段(如封装、继承、构建测试体系)来让代码更容易理解,更易于修改。\n软件设计原则指导着我们组织和规范代码,同时,重构也是为了能够尽量设计出尽量满足软件设计原则的软件。\n正确重构的核心在于 步子一定要小,每一步的重构都不会影响软件的正常运行,可以随时停止重构。\n常见的设计模式如下 :\n更全面的设计模式总结,可以看 java-design-patterns 这个开源项目。\n常见的软件设计原则如下 :\n更全面的设计原则总结,可以看 java-design-patterns 和 hacker-laws-zh 这两个开源项目。\n为什么要重构? # 在上面介绍重构定义的时候,我从比较抽象的角度介绍了重构的好处:重构的主要目的主要是提升代码\u0026amp;架构的灵活性/可扩展性以及复用性。\n如果对应到一个真实的项目,重构具体能为我们带来什么好处呢?\n让代码更容易理解 : 通过添加注释、命名规范、逻辑优化等手段可以让我们的代码更容易被理解; 避免代码腐化 :通过重构干掉坏味道代码; 加深对代码的理解 :重构代码的过程会加深你对某部分代码的理解; 发现潜在 bug :是这样的,很多潜在的 bug ,都是我们在重构的过程中发现的; \u0026hellip;\u0026hellip; 看了上面介绍的关于重构带来的好处之后,你会发现重构的最终目标是 提高软件开发速度和质量 。\n重构并不会减慢软件开发速度,相反,如果代码质量和软件设计较差,当我们想要添加新功能的话,开发速度会越来越慢。到了最后,甚至都有想要重写整个系统的冲动。\n[\n《重构:改善代码既有设计》这本书中这样说:\n重构的唯一目的就是让我们开发更快,用更少的工作量创造更大的价值。\n何时进行重构? # 重构在是开发过程中随时可以进行的,见机行事即可,并不需要单独分配一两天的时间专门用来重构。\n提交代码之前 # 《重构:改善代码既有设计》这本书介绍了一个 营地法则 的概念:\n编程时,需要遵循营地法则:保证你离开时的代码库一定比来时更健康。\n这个概念表达的核心思想其实很简单:在你提交代码的之前,花一会时间想一想,我这次的提交是让项目代码变得更健康了,还是更腐化了,或者说没什么变化?\n项目团队的每一个人只有保证自己的提交没有让项目代码变得更腐化,项目代码才会朝着健康的方向发展。\n当我们离开营地(项目代码)的时候,请不要留下垃圾(代码坏味道)!尽量确保营地变得更干净了!\n开发一个新功能之后\u0026amp;之前 # 在开发一个新功能之后,我们应该回过头看看是不是有可以改进的地方。在添加一个新功能之前,我们可以思考一下自己是否可以重构代码以让新功能的开发更容易。\n一个新功能的开发不应该仅仅只有功能验证通过那么简单,我们还应该尽量保证代码质量。\n有一个两顶帽子的比喻:在我开发新功能之前,我发现重构可以让新功能的开发更容易,于是我戴上了重构的帽子。重构之后,我换回原来的帽子,继续开发新能功能。新功能开发完成之后,我又发现自己的代码难以理解,于是我又戴上了重构帽子。比较好的开发状态就是就是这样在重构和开发新功能之间来回切换。\nCode Review 之后 # Code Review 可以非常有效提高代码的整体质量,它会帮助我们发现代码中的坏味道以及可能存在问题的地方。并且, Code Review 可以帮助项目团队其他程序员理解你负责的业务模块,有效避免人员方面的单点风险。\n经历一次 Code Review ,你的代码可能会收到很多改进建议。\n捡垃圾式重构 # 当我们发现坏味道代码(垃圾)的时候,如果我们不想停下手头自己正在做的工作,但又不想放着垃圾不管,我们可以这样做:\n如果这个垃圾很容易重构的话,我们可以立即重构它。 如果这个垃圾不太容易重构的话,我们可以先记录下来,当****重构它。 阅读理解代码的时候 # 搞开发的小伙伴应该非常有体会:我们经常需要阅读项目团队中其他人写的代码,也经常需要阅读自己过去写的代码。阅读代码的时候,通常要比我们写代码的时间还要多很多。\n我们在阅读理解代码的时候,如果发现一些坏味道的话,我们就可以对其进行重构。\n就比如说你在阅读张三写的某段代码的时候,你发现这段代码逻辑过于复杂难以理解,你有更好的写法,那你就可以对张三的这段代码逻辑进行重构。\n重构有哪些注意事项? # 单元测试是重构的保护网 # 单元测试可以为重构提供信心,降低重构的成本。我们要像重视生产代码那样,重视单元测试。\n另外,多提一句:持续集成也要依赖单元测试,当持续集成服务自动构建新代码之后,会自动运行单元测试来发现代码错误。\n怎样才能算单元测试呢? 网上的定义很多,很抽象,很容易把人给看迷糊了。我觉得对于单元测试的定义主要取决于你的项目,一个函数甚至是一个类都可以看作是一个单元。就比如说我们写了一个计算个人股票收益率的方法,我们为了验证它的正确性专门为它写了一个单元测试。再比如说我们代码有一个类专门负责数据脱敏,我们为了验证脱敏是否符合预期专门为这个类写了一个单元测试。\n单元测试也是需要重构或者修改的。 《代码整洁之道:敏捷软件开发手册》这本书这样写到:\n测试代码需要随着生产代码的演进而修改,如果测试不能保持整洁,只会越来越难修改。\n不要为了重构而重构 # 重构一定是要为项目带来价值的! 某些情况下我们不应该进行重构:\n学习了某个设计模式/工程实践之后,不顾项目实际情况,刻意使用在项目上(避免货物崇拜编程); 项目进展比较急的时候,重构项目调用的某个 API 的底层代码(重构之后对项目调用这个 API 并没有带来什么价值); 重写比重构更容易更省事; \u0026hellip;\u0026hellip; 遵循方法 # 《重构:改善代码既有设计》这本书中列举除了代码常见的一些坏味道(比如重复代码、过长函数)和重构手段(如提炼函数、提炼变量、提炼类)。我们应该花时间去学习这些重构相关的理论知识,并在代码中去实践这些重构理论。\n如何练习重构? # 除了可以在重构项目代码的过程中练习精进重构之外,你还可以有下面这些手段:\n重构实战练习 :通过几个小案例一步一步带你学习重构! 设计模式+重构学习网站 :免费在线学习代码重构、 设计模式、 SOLID 原则 (单一职责、 开闭原则、 里氏替换、 接口隔离以及依赖反转) 。 IDEA 官方文档的代码重构教程 : 教你如何使用 IDEA 进行重构。 参考 # 再读《重构》- ThoughtWorks 洞见 - 2020 :详细介绍了重构的要点比如小步重构、捡垃圾式的重构,主要是重构概念相关的介绍。 常见代码重构技巧 - VectorJin - 2021 :从软件设计原则、设计模式、代码分层、命名规范等角度介绍了如何进行重构,比较偏实战。 "},{"id":258,"href":"/zh/docs/technology/Review/java_guide/lycly_system-design/basis/naming/","title":"代码命名指南","section":"基础","content":" 转载自https://github.com/Snailclimb/JavaGuide(添加小部分笔记)感谢作者!\n我还记得我刚工作那一段时间, 项目 Code Review 的时候,我经常因为变量命名不规范而被 “diss”!\n究其原因还是自己那会经验不足,而且,大学那会写项目的时候不太注意这些问题,想着只要把功能实现出来就行了。\n但是,工作中就不一样,为了代码的可读性、可维护性,项目组对于代码质量的要求还是很高的!\n前段时间,项目组新来的一个实习生也经常在 Code Review 因为变量命名不规范而被 “diss”,这让我想到自己刚到公司写代码那会的日子。\n于是,我就简单写了这篇关于变量命名规范的文章,希望能对同样有此困扰的小伙伴提供一些帮助。\n确实,编程过程中,有太多太多让我们头疼的事情了,比如命名、维护其他人的代码、写测试、与其他人沟通交流等等。\n据说之前在 Quora 网站,由接近 5000 名程序员票选出来的最难的事情就是“命名”。\n大名鼎鼎的《重构》的作者老马(Martin Fowler)曾经在 TwoHardThings这篇文章中提到过CS 领域有两大最难的事情:一是 缓存失效 ,一是 程序命名 。\n这个句话实际上也是老马引用别人的,类似的表达还有很多。比如分布式系统领域有两大最难的事情:一是 保证消息顺序 ,一是 严格一次传递 。\n今天咱们就单独拎出 “命名” 来聊聊!\n这篇文章配合我之前发的 《编码 5 分钟,命名 2 小时?史上最全的 Java 命名规范参考!》 这篇文章阅读效果更佳哦!\n为什么需要重视命名? # 咱们需要先搞懂为什么要重视编程中的命名这一行为,它对于我们的编码工作有着什么意义。\n为什么命名很重要呢? 这是因为 好的命名即是注释,别人一看到你的命名就知道你的变量、方法或者类是做什么的!\n简单来说就是 别人根据你的命名就能知道你的代码要表达的意思 (不过,前提这个人也要有基本的英语知识,对于一些编程中常见的单词比较熟悉)。\n简单举个例子说明一下命名的重要性。\n《Clean Code》这本书明确指出:\n好的代码本身就是注释,我们要尽量规范和美化自己的代码来减少不必要的注释。\n若编程语言足够有表达力,就不需要注释,尽量通过代码来阐述。\n举个例子:\n去掉下面复杂的注释,只需要创建一个与注释所言同一事物的函数即可\n// check to see if the employee is eligible for full benefits if ((employee.flags \u0026amp; HOURLY_FLAG) \u0026amp;\u0026amp; (employee.age \u0026gt; 65)) 应替换为\nif (employee.isEligibleForFullBenefits()) 常见命名规则以及适用场景 # 这里只介绍 3 种最常见的命名规范。\n驼峰命名法(CamelCase) # 驼峰命名法应该我们最常见的一个,这种命名方式使用大小写混合的格式来区别各个单词,并且单词之间不使用空格隔开或者连接字符连接的命名方式\n大驼峰命名法(UpperCamelCase) # 类名需要使用大驼峰命名法(UpperCamelCase)\n正例:\nServiceDiscovery、ServiceInstance、LruCacheFactory 反例:\nserviceDiscovery、Serviceinstance、LRUCacheFactory 小驼峰命名法(lowerCamelCase) # 方法名、参数名、成员变量、局部变量需要使用小驼峰命名法(lowerCamelCase)。\n正例:\ngetUserInfo() createCustomThreadPool() setNameFormat(String nameFormat) Uservice userService; 反例:\nGetUserInfo()、CreateCustomThreadPool()、setNameFormat(String NameFormat) Uservice user_service 蛇形命名法(snake_case) # 测试方法名、常量、枚举名称需要使用蛇形命名法(snake_case)\n在蛇形命名法中,各个单词之间通过**下划线“_”**连接,比如should_get_200_status_code_when_request_is_valid、CLIENT_CONNECT_SERVER_FAILURE。\n蛇形命名法的优势是命名所需要的单词比较多的时候,比如我把上面的命名通过小驼峰命名法给大家看一下:“shouldGet200StatusCodeWhenRequestIsValid”。\n感觉如何? 相比于使用蛇形命名法(snake_case)来说是不是不那么易读?\n正例:\n@Test void should_get_200_status_code_when_request_is_valid() { ...... } 反例:\n@Test void shouldGet200StatusCodeWhenRequestIsValid() { ...... } 串式命名法(kebab-case) # 在串式命名法中,各个单词之间通过连接符“-”连接,比如dubbo-registry。\n建议项目文件夹名称使用串式命名法(kebab-case),比如 dubbo 项目的各个模块的命名是下面这样的。\n常见命名规范 # Java 语言基本命名规范 # 1、类名需要使用大驼峰命名法(UpperCamelCase)风格。方法名、参数名、成员变量、局部变量需要使用小驼峰命名法(lowerCamelCase)。\n2、测试方法名、常量、枚举名称需要使用蛇形命名法(snake_case),比如should_get_200_status_code_when_request_is_valid、CLIENT_CONNECT_SERVER_FAILURE。并且,测试方法名称要求全部小写,常量以及枚举名称需要全部大写。\n3、项目文件夹名称使用串式命名法(kebab-case),比如dubbo-registry。\n4、包名统一使用小写,尽量使用单个名词作为包名,各个单词通过 \u0026ldquo;.\u0026rdquo; 分隔符连接,并且各个单词必须为单数。\n正例: org.apache.dubbo.common.threadlocal\n反例: org.apache_dubbo.Common.threadLocals\n5、抽象类命名使用 Abstract 开头。\n//为远程传输部分抽象出来的一个抽象类(出处:Dubbo源码) public abstract class AbstractClient extends AbstractEndpoint implements Client { } 6、异常类命名使用 Exception 结尾。\n//自定义的 NoSuchMethodException(出处:Dubbo源码) public class NoSuchMethodException extends RuntimeException { private static final long serialVersionUID = -2725364246023268766L; public NoSuchMethodException() { super(); } public NoSuchMethodException(String msg) { super(msg); } } 7、测试类命名以它要测试的类的名称开始,以 Test 结尾。\n//为 AnnotationUtils 类写的测试类(出处:Dubbo源码) public class AnnotationUtilsTest { ...... } POJO 类中布尔类型的变量,都不要加 is 前缀,否则部分框架解析会引起序列化错误。\n如果模块、接口、类、方法使用了设计模式,在命名时需体现出具体模式。\n命名易读性规范 # 1、为了能让命名更加易懂和易读,尽量不要缩写/简写单词,除非这些单词已经被公认可以被这样缩写/简写。比如 CustomThreadFactory 不可以被写成 ~~CustomTF 。\n2、命名不像函数一样要尽量追求短,可读性强的名字优先于简短的名字,虽然可读性强的名字会比较长一点。 这个对应我们上面说的第 1 点。\n3、避免无意义的命名,你起的每一个名字都要能表明意思。\n正例:UserService userService; int userCount;\n反例: UserService service int count\n4、避免命名过长(50 个字符以内最好),过长的命名难以阅读并且丑陋。\n5、不要使用拼音,更不要使用中文。 不过像 alibaba 、wuhan、taobao 这种国际通用名词可以当做英文来看待。\n正例:discount\n反例:dazhe\nCodelf:变量命名神器? # 这是一个由国人开发的网站,网上有很多人称其为变量命名神器, 我在实际使用了几天之后感觉没那么好用。小伙伴们可以自行体验一下,然后再给出自己的判断。\nCodelf 提供了在线网站版本,网址:https://unbug.github.io/codelf/,具体使用情况如下:\n我选择了 Java 编程语言,然后搜索了“序列化”这个关键词,然后它就返回了很多关于序列化的命名。\n并且,Codelf 还提供了 VS code 插件,看这个评价,看来大家还是很喜欢这款命名工具的。\n相关阅读推荐 # 《阿里巴巴 Java 开发手册》 《Clean Code》 Google Java 代码指南:https://google.github.io/styleguide/javaguide.html 告别编码5分钟,命名2小时!史上最全的Java命名规范参考:https://www.cnblogs.com/liqiangchn/p/12000361.html 总结 # 作为一个合格的程序员,小伙伴们应该都知道代码表义的重要性。想要写出高质量代码,好的命名就是第一步!\n好的命名对于其他人(包括你自己)理解你的代码有着很大的帮助!你的代码越容易被理解,可维护性就越强,侧面也就说明你的代码设计的也就越好!\n在日常编码过程中,我们需要谨记常见命名规范比如类名需要使用大驼峰命名法、不要使用拼音,更不要使用中文\u0026hellip;\u0026hellip;。\n另外,国人开发的一个叫做 Codelf 的网站被很多人称为“变量命名神器”,当你为命名而头疼的时候,你可以去参考一下上面提供的一些命名示例。\n最后,祝愿大家都不用再为命名而困扰!\n"},{"id":259,"href":"/zh/docs/technology/Review/java_guide/lycly_system-design/basis/software-engineering/","title":"软件工程简明教程","section":"基础","content":" 转载自https://github.com/Snailclimb/JavaGuide(添加小部分笔记)感谢作者!\n大部分软件开发从业者,都会忽略软件开发中的一些最基础、最底层的一些概念。但是,这些软件开发的概念对于软件开发来说非常重要,就像是软件开发的基石一样。这也是我写这篇文章的原因。\n何为软件工程? # 1968 年 NATO(北大西洋公约组织)提出了软件危机(Software crisis)一词。同年,为了解决软件危机问题,“软件工程”的概念诞生了。一门叫做软件工程的学科也就应运而生。\n随着时间的推移,软件工程这门学科也经历了一轮又一轮的完善,其中的一些核心内容比如软件开发模型越来越丰富实用!\n什么是软件危机呢?\n简单来说,软件危机描述了当时软件开发的一个痛点:我们很难高效地开发出质量高的软件。\nDijkstra(Dijkstra算法的作者) 在 1972年图灵奖获奖感言中也提高过软件危机,他是这样说的:“导致软件危机的主要原因是机器变得功能强大了几个数量级!坦率地说:只要没有机器,编程就完全没有问题。当我们有一些弱小的计算机时,编程成为一个温和的问题,而现在我们有了庞大的计算机,编程也同样成为一个巨大的问题”。\n说了这么多,到底什么是软件工程呢?\n工程是为了解决实际的问题将理论应用于实践。软件工程指的就是将工程思想应用于软件开发。\n上面是我对软件工程的定义,我们再来看看比较权威的定义。IEEE 软件工程汇刊给出的定义是这样的: (1)将系统化的、规范的、可量化的方法应用到软件的开发、运行及维护中,即将工程化方法应用于软件。 (2)在(1)中所述方法的研究。\n总之,软件工程的终极目标就是:在更少资源消耗的情况下,创造出更好、更容易维护的软件。\n软件开发过程 # 维基百科是这样定义软件开发过程的:\n软件开发过程(英语:software development process),或软件过程(英语:software process),是软件开发的开发生命周期(software development life cycle),其各个阶段实现了软件的需求定义与分析、设计、实现、测试、交付和维护。软件过程是在开发与构建系统时应遵循的步骤,是软件开发的路线图。\n需求分析 :分析用户的需求,建立逻辑模型。 软件设计 : 根据需求分析的结果对软件架构进行设计。 编码 :编写程序运行的源代码。 测试 : 确定测试用例,编写测试报告。 交付 :将做好的软件交付给客户。 维护 :对软件进行维护比如解决 bug,完善功能。 软件开发过程只是比较笼统的层面上,一定义了一个软件开发可能涉及到的一些流程。\n软件开发模型更具体地定义了软件开发过程,对开发过程提供了强有力的理论支持。\n软件开发模型 # 软件开发模型有很多种,比如瀑布模型(Waterfall Model)、快速原型模型(Rapid Prototype Model)、V模型(V-model)、W模型(W-model)、敏捷开发模型。其中最具有代表性的还是 瀑布模型 和 敏捷开发 。\n瀑布模型 定义了一套完成的软件开发周期,完整地展示了一个软件的的生命周期。\n敏捷开发模型 是目前使用的最多的一种软件开发模型。 MBA智库百科对敏捷开发的描述是这样的:\n敏捷开发 是一种以人为核心、迭代、循序渐进的开发方法。在敏捷开发中,软件项目的构建被切分成多个子项目,各个子项目的成果都经过测试,具备集成和可运行的特征。换言之,就是把一个大项目分为多个相互联系,但也可独立运行的小项目,并分别完成,在此过程中软件一直处于可使用状态。\n像现在比较常见的一些概念比如 持续集成 、重构 、小版本发布 、低文档 、站会 、结对编程 、测试驱动开发 都是敏捷开发的核心。\n软件开发的基本策略 # 软件复用 # 我们在构建一个新的软件的时候,不需要从零开始,通过复用已有的一些轮子(框架、第三方库等)、设计模式、设计原则等等现成的物料,我们可以更快地构建出一个满足要求的软件。\n像我们平时接触的开源项目就是最好的例子。我想,如果不是开源,我们构建出一个满足要求的软件,耗费的精力和时间要比现在多的多!\n分而治之 # 构建软件的过程中,我们会遇到很多问题。我们可以将一些比较复杂的问题拆解为一些小问题,然后,一一攻克。\n我结合现在比较火的软件设计方法—**领域驱动设计(Domain Driven Design,简称 DDD)**来说说。\n在领域驱动设计中,很重要的一个概念就是领域(Domain),它就是我们要解决的问题。在领域驱动设计中,我们要做的就是把比较大的领域(问题)拆解为若干的小领域(子域)。\n除此之外,分而治之也是一个比较常用的算法思想,对应的就是分治算法。如果你想了解分治算法的话,推荐你看一下北大的 《算法设计与分析 Design and Analysis of Algorithms》。\n逐步演进 # 软件开发是一个逐步演进的过程,我们需要不断进行迭代式增量开发,最终交付符合客户价值的产品。\n这里补充一个在软件开发领域,非常重要的概念:MVP(Minimum Viable Product,最小可行产品)。\n这个最小可行产品,可以理解为刚好能够满足客户需求的产品。下面这张图片把这个思想展示的非常精髓。\n利用最小可行产品,我们可以也可以提早进行市场分析,这对于我们在探索产品不确定性的道路上非常有帮助。可以非常有效地指导我们下一步该往哪里走。\n优化折中 # 软件开发是一个不断优化改进的过程。任何软件都有很多可以优化的点,不可能完美。我们需要不断改进和提升软件的质量。\n但是,也不要陷入这个怪圈。要学会折中,在有限的投入内,以最有效的方式提高现有软件的质量。\n参考 # 软件工程的基本概念-清华大学软件学院 刘强:https://www.xuetangx.com/course/THU08091000367 软件开发过程-维基百科 :https://zh.wikipedia.org/wiki/软件开发过程 "},{"id":260,"href":"/zh/docs/technology/Review/java_guide/lycly_system-design/basis/restful/","title":"restFul","section":"基础","content":" 转载自https://github.com/Snailclimb/JavaGuide(添加小部分笔记)感谢作者!\n这篇文章简单聊聊后端程序员必备的 RESTful API 相关的知识。\n开始正式介绍 RESTful API 之前,我们需要首先搞清 :API 到底是什么?\n# 何为 API? # API(Application Programming Interface) 翻译过来是应用程序编程接口的意思。\n我们在进行后端开发的时候,主要的工作就是为前端或者其他后端服务提供 API 比如查询用户数据的 API 。\n但是, API 不仅仅代表后端系统暴露的接口,像框架中提供的方法也属于 API 的范畴。\n为了方便大家理解,我再列举几个例子 🌰:\n你通过某电商网站搜索某某商品,电商网站的前端就调用了后端提供了搜索商品相关的 API。 你使用 JDK 开发 Java 程序,想要读取用户的输入的话,你就需要使用 JDK 提供的 IO 相关的 API。 \u0026hellip;\u0026hellip; 你可以把 API 理解为程序与程序之间通信的桥梁,其本质就是一个函数而已。另外,API 的使用也不是没有章法的,它的规则由(比如数据输入和输出的格式)API 提供方制定。\n# 何为 RESTful API? # RESTful API 经常也被叫做 REST API,它是基于 REST 构建的 API。这个 REST 到底是什么,我们后文在讲,涉及到的概念比较多。\n如果你看 RESTful API 相关的文章的话一般都比较晦涩难懂,主要是因为 REST 涉及到的一些概念比较难以理解。但是,实际上,我们平时开发用到的 RESTful API 的知识非常简单也很容易概括!\n举个例子,如果我给你下面两个 API 你是不是立马能知道它们是干什么用的!这就是 RESTful API 的强大之处!\nGET /classes:列出所有班级 POST /classes:新建一个班级 RESTful API 可以让你看到 URL+Http Method 就知道这个 URL 是干什么的,让你看到了 HTTP 状态码(status code)就知道请求结果如何。\n像咱们在开发过程中设计 API 的时候也应该至少要满足 RESTful API 的最基本的要求(比如接口中尽量使用名词,使用 POST 请求创建资源,DELETE 请求删除资源等等,示例:GET /notes/id:获取某个指定 id 的笔记的信息)。\n# 解读 REST # REST 是 REpresentational State Transfer 的缩写。这个词组的翻译过来就是“表现层状态转化”。\n这样理解起来甚是晦涩,实际上 REST 的全称是 Resource Representational State Transfer ,直白地翻译过来就是 “资源”在网络传输中以某种“表现形式”进行“状态转移” 。如果还是不能继续理解,请继续往下看,相信下面的讲解一定能让你理解到底啥是 REST 。\n我们分别对上面涉及到的概念进行解读,以便加深理解,实际上你不需要搞懂下面这些概念,也能看懂我下一部分要介绍到的内容。不过,为了更好地能跟别人扯扯 “RESTful API”我建议你还是要好好理解一下!\n资源(Resource) :我们可以把真实的对象数据称为资源。一个资源既可以是一个集合,也可以是单个个体。比如我们的班级 classes 是代表一个集合形式的资源,而特定的 class 代表单个个体资源。每一种资源都有特定的 URI(统一资源标识符)与之对应,如果我们需要获取这个资源,访问这个 URI 就可以了,比如获取特定的班级:/class/12。另外,资源也可以包含子资源,比如 /classes/classId/teachers:列出某个指定班级的所有老师的信息 表现形式(Representational):\u0026ldquo;资源\u0026quot;是一种信息实体,它可以有多种外在表现形式。我们把\u0026quot;资源\u0026quot;具体呈现出来的形式比如 json,xml,image,txt 等等叫做它的**\u0026ldquo;表现层/表现形式\u0026rdquo;**。 状态转移(State Transfer) :大家第一眼看到这个词语一定会很懵逼?内心 BB:这尼玛是啥啊? 大白话来说 REST 中的状态转移更多地描述的服务器端资源的状态,比如你通过增删改查(通过 HTTP 动词实现)引起资源状态的改变。ps:互联网通信协议 HTTP 协议,是一个无状态协议,所有的资源状态都保存在服务器端。 综合上面的解释,我们总结一下什么是 RESTful 架构:\n每一个 URI 代表一种资源; 客户端和服务器之间,传递这种资源的某种表现形式比如 json,xml,image,txt 等等; 客户端通过特定的 HTTP 动词,对服务器端资源进行操作,实现**\u0026ldquo;表现层状态转化\u0026rdquo;**。 # RESTful API 规范 # # 动作 # GET:请求从服务器获取特定资源。举个例子:GET /classes(获取所有班级) POST :在服务器上创建一个新的资源。举个例子:POST /classes(创建班级) PUT :更新服务器上的资源(客户端提供更新后的整个资源)。举个例子:PUT /classes/12(更新编号为 12 的班级) DELETE :从服务器删除特定的资源。举个例子:DELETE /classes/12(删除编号为 12 的班级) PATCH :更新服务器上的资源(客户端提供更改的属性,可以看做作是部分更新),使用的比较少,这里就不举例子了。 # 路径(接口命名) # 路径又称\u0026quot;终点\u0026rdquo;(endpoint),表示 API 的具体网址。实际开发中常见的规范如下:\n网址中不能有动词,只能有名词,API 中的名词也应该使用复数。 因为 REST 中的资源往往和数据库中的表对应,而数据库中的表都是同种记录的\u0026quot;集合\u0026quot;(collection)。如果 API 调用并不涉及资源(如计算,翻译等操作)的话,可以用动词。比如:GET /calculate?param1=11\u0026amp;param2=33 。 不用大写字母,建议用中杠 - 不用下杠 _ 。比如邀请码写成 invitation-code而不是 invitation_code 。 善用版本化 API。当我们的 API 发生了重大改变而不兼容前期版本的时候,我们可以通过 URL 来实现版本化,比如 http://api.example.com/v1、http://apiv1.example.com 。版本不必非要是数字,只是数字用的最多,日期、季节都可以作为版本标识符,项目团队达成共识就可。 接口尽量使用名词,避免使用动词。 RESTful API 操作(HTTP Method)的是资源(名词)而不是动作(动词)。 Talk is cheap!来举个实际的例子来说明一下吧!现在有这样一个 API 提供班级(class)的信息,还包括班级中的学生和教师的信息,则它的路径应该设计成下面这样。\nGET /classes:列出所有班级 POST /classes:新建一个班级 GET /classes/{classId}:获取某个指定班级的信息 PUT /classes/{classId}:更新某个指定班级的信息(一般倾向整体更新) PATCH /classes/{classId}:更新某个指定班级的信息(一般倾向部分更新) DELETE /classes/{classId}:删除某个班级 GET /classes/{classId}/teachers:列出某个指定班级的所有老师的信息 GET /classes/{classId}/students:列出某个指定班级的所有学生的信息 DELETE /classes/{classId}/teachers/{ID}:删除某个指定班级下的指定的老师的信息 反例:\n/getAllclasses /createNewclass /deleteAllActiveclasses 理清资源的层次结构,比如业务针对的范围是学校,那么学校会是一级资源:/schools,老师: /schools/teachers,学生: /schools/students 就是二级资源。\n# 过滤信息(Filtering) # 如果我们在查询的时候需要添加特定条件的话,建议使用 url 参数的形式。比如我们要查询 state 状态为 active 并且 name 为 guidegege 的班级:\nGET /classes?state=active\u0026amp;name=guidegege 比如我们要实现分页查询:\nGET /classes?page=1\u0026amp;size=10 //指定第1页,每页10个数据 # 状态码(Status Codes) # 状态码范围:\n2xx:成功 3xx:重定向 4xx:客户端错误 5xx:服务器错误 200 成功 301 永久重定向 400 错误请求 500 服务器错误 201 创建 304 资源未修改 401 未授权 502 网关错误 403 禁止访问 504 网关超时 404 未找到 405 请求方法不对 # RESTful 的极致 HATEOAS # RESTful 的极致是 hateoas ,但是这个基本不会在实际项目中用到。\n上面是 RESTful API 最基本的东西,也是我们平时开发过程中最容易实践到的。实际上,RESTful API 最好做到 Hypermedia,即返回结果中提供链接,连向其他 API 方法,使得用户不查文档,也知道下一步应该做什么。\n比如,当用户向 api.example.com 的根目录发出请求,会得到这样一个返回结果\n{\u0026#34;link\u0026#34;: { \u0026#34;rel\u0026#34;: \u0026#34;collection https://www.example.com/classes\u0026#34;, \u0026#34;href\u0026#34;: \u0026#34;https://api.example.com/classes\u0026#34;, \u0026#34;title\u0026#34;: \u0026#34;List of classes\u0026#34;, \u0026#34;type\u0026#34;: \u0026#34;application/vnd.yourformat+json\u0026#34; }} 上面代码表示,文档中有一个 link 属性,用户读取这个属性就知道下一步该调用什么 API 了。rel 表示这个 API 与当前网址的关系(collection 关系,并给出该 collection 的网址),href 表示 API 的路径,title 表示 API 的标题,type 表示返回类型 Hypermedia API 的设计被称为 HATEOASopen in new window。\n在 Spring 中有一个叫做 HATEOAS 的 API 库,通过它我们可以更轻松的创建出符合 HATEOAS 设计的 API。相关文章:\n在 Spring Boot 中使用 HATEOASopen in new window Building REST services with Springopen in new window (Spring 官网 ) An Intro to Spring HATEOASopen in new window spring-hateoas-examplesopen in new window Spring HATEOASopen in new window (Spring 官网 ) # 参考 # https://RESTfulapi.net/ https://www.ruanyifeng.com/blog/2014/05/restful_api.html https://juejin.im/entry/59e460c951882542f578f2f0 https://phauer.com/2016/testing-RESTful-services-java-best-practices/ https://www.seobility.net/en/wiki/REST_API https://dev.to/duomly/rest-api-vs-graphql-comparison-3j6g 著作权归所有 原文链接:https://javaguide.cn/system-design/basis/RESTfulAPI.html\n"},{"id":261,"href":"/zh/docs/technology/Review/java_guide/lyfly_high-availability/ly05ly_performance-test/","title":"性能测试入门","section":"高可用","content":" 转载自https://github.com/Snailclimb/JavaGuide(添加小部分笔记)感谢作者!\n性能测试一般情况下都是由测试这个职位去做的,那还需要我们开发学这个干嘛呢?了解性能测试的指标、分类以及工具等知识有助于我们更好地去写出性能更好的程序,另外作为开发这个角色,如果你会性能测试的话,相信也会为你的履历加分不少。\n这篇文章是我会结合自己的实际经历以及在测试这里取的经所得,除此之外,我还借鉴了一些优秀书籍,希望对你有帮助。\n本文思维导图:\n# 一 不同角色看网站性能 # # 1.1 用户 # 当用户打开一个网站的时候,最关注的是什么?当然是网站响应速度的快慢。比如我们点击了淘宝的主页,淘宝需要多久将首页的内容呈现在我的面前,我点击了提交订单按钮需要多久返回结果等等。\n所以,用户在体验我们系统的时候往往根据你的响应速度的快慢来评判你的网站的性能。\n# 1.2 开发人员 # 用户与开发人员都关注速度,这个速度实际上就是我们的系统处理用户请求的速度。\n开发人员一般情况下很难直观的去评判自己网站的性能,我们往往会根据网站当前的架构以及基础设施情况给一个大概的值,比如:\n项目架构是分布式的吗? 用到了缓存和消息队列没有? 高并发的业务有没有特殊处理? 数据库设计是否合理? 系统用到的算法是否还需要优化? 系统是否存在内存泄露的问题? 项目使用的 Redis 缓存多大?服务器性能如何?用的是机械硬盘还是固态硬盘? \u0026hellip;\u0026hellip; # 1.3 测试人员 # 测试人员一般会根据性能测试工具来测试,然后一般会做出一个表格。这个表格可能会涵盖下面这些重要的内容:\n响应时间; 请求成功率; 吞吐量; \u0026hellip;\u0026hellip; # 1.4 运维人员 # 运维人员会倾向于根据基础设施和资源的利用率来判断网站的性能,比如我们的服务器资源使用是否合理、数据库资源是否存在滥用的情况、当然,这是传统的运维人员,现在 Devpos 火起来后,单纯干运维的很少了。我们这里暂且还保留有这个角色。\n# 二 性能测试需要注意的点 # 几乎没有文章在讲性能测试的时候提到这个问题,大家都会讲如何去性能测试,有哪些性能测试指标这些东西。\n# 2.1 了解系统的业务场景 # 性能测试之前更需要你了解当前的系统的业务场景。 对系统业务了解的不够深刻,我们很容易犯测试方向偏执的错误,从而导致我们忽略了对系统某些更需要性能测试的地方进行测试。比如我们的系统可以为用户提供发送邮件的功能,用户配置成功邮箱后只需输入相应的邮箱之后就能发送,系统每天大概能处理上万次发邮件的请求。很多人看到这个可能就直接开始使用相关工具测试邮箱发送接口,但是,发送邮件这个场景可能不是当前系统的性能瓶颈,这么多人用我们的系统发邮件, 还可能有很多人一起发邮件,单单这个场景就这么人用,那用户管理可能才是性能瓶颈吧!\n# 2.2 历史数据非常有用 # 当前系统所留下的历史数据非常重要,一般情况下,我们可以通过相应的些历史数据初步判定这个系统哪些接口调用的比较多、哪些 service 承受的压力最大,这样的话,我们就可以针对这些地方进行更细致的性能测试与分析。\n另外,这些地方也就像这个系统的一个短板一样,优化好了这些地方会为我们的系统带来质的提升。\n# 三 性能测试的指标 # # 3.1 响应时间 # 响应时间就是用户发出请求到用户收到系统处理结果所需要的时间。 重要吗?实在太重要!\n比较出名的 2-5-8 原则是这样描述的:通常来说,2到5秒,页面体验会比较好,5到8秒还可以接受,8秒以上基本就很难接受了。另外,据统计当网站慢一秒就会流失十分之一的客户。\n但是,在某些场景下我们也并不需要太看重 2-5-8 原则 ,比如我觉得系统导出导入大数据量这种就不需要,系统生成系统报告这种也不需要。\n# 3.2 并发数 # 并发数是系统能同时处理请求的数目即同时提交请求的用户数目。\n不得不说,高并发是现在后端架构中非常非常火热的一个词了,这个与当前的互联网环境以及中国整体的互联网用户量都有很大关系。一般情况下,你的系统并发量越大,说明你的产品做的就越大。但是,并不是每个系统都需要达到像淘宝、12306 这种亿级并发量的。\n# 3.3 吞吐量 # 吞吐量指的是系统单位时间内系统处理的请求数量。衡量吞吐量有几个重要的参数:QPS(TPS)、并发数、响应时间。\nQPS(Query Per Second):服务器每秒可以执行的查询次数; TPS(Transaction Per Second):服务器每秒处理的事务数(这里的一个事务可以理解为客户发出请求到收到服务器的过程); 并发数;系统能同时处理请求的数目即同时提交请求的用户数目。 响应时间: 一般取多次请求的平均响应时间 理清他们的概念,就很容易搞清楚他们之间的关系了。\nQPS(TPS) = 并发数/平均响应时间 并发数 = QPS*平均响应时间 书中是这样描述 QPS 和 TPS 的区别的。\nQPS vs TPS:QPS 基本类似于 TPS,但是不同的是,对于一个页面的一次访问,形成一个TPS;但一次页面请求,可能产生多次对服务器的请求,服务器对这些请求,就可计入“QPS”之中。如,访问一个页面会请求服务器2次,一次访问,产生一个“T”,产生2个“Q”。\n# 3.4 性能计数器 # 性能计数器是描述服务器或者操作系统的一些数据指标如内存使用、CPU使用、磁盘与网络I/O等情况。\n# 四 几种常见的性能测试 # # 性能测试 # 性能测试方法是通过测试工具模拟用户请求系统,目的主要是为了测试系统的性能是否满足要求。通俗地说,这种方法就是要在特定的运行条件下验证系统的能力状态。\n性能测试是你在对系统性能已经有了解的前提之后进行的,并且有明确的性能指标。\n# 负载测试 # 对被测试的系统继续加大请求压力,直到服务器的某个资源已经达到饱和了,比如系统的缓存已经不够用了或者系统的响应时间已经不满足要求了。\n负载测试说白点就是测试系统的上限。\n# 压力测试 # 不去管系统资源的使用情况,对系统继续加大请求压力,直到服务器崩溃无法再继续提供服务。\n# 稳定性测试 # 模拟真实场景,给系统一定压力,看看业务是否能稳定运行。\n# 五 常用性能测试工具 # 这里就不多扩展了,有时间的话会单独拎一个熟悉的说一下。\n# 5.1 后端常用 # 没记错的话,除了 LoadRunner 其他几款性能测试工具都是开源免费的。\nJmeter :Apache JMeter 是 JAVA 开发的性能测试工具。 LoadRunner:一款商业的性能测试工具。 Galtling :一款基于Scala 开发的高性能服务器性能测试工具。 ab :全称为 Apache Bench 。Apache 旗下的一款测试工具,非常实用。 # 5.2 前端常用 # Fiddler:抓包工具,它可以修改请求的数据,甚至可以修改服务器返回的数据,功能非常强大,是Web 调试的利器。 HttpWatch: 可用于录制HTTP请求信息的工具。 # 六 常见的性能优化策略 # 性能优化之前我们需要对请求经历的各个环节进行分析,排查出可能出现性能瓶颈的地方,定位问题。\n下面是一些性能优化时,我经常拿来自问的一些问题:\n系统是否需要缓存? 系统架构本身是不是就有问题? 系统是否存在死锁的地方? 系统是否存在内存泄漏?(Java 的自动回收内存虽然很方便,但是,有时候代码写的不好真的会造成内存泄漏) 数据库索引使用是否合理? \u0026hellip;\u0026hellip; 著作权归所有 原文链接:https://javaguide.cn/high-availability/performance-test.html\n"},{"id":262,"href":"/zh/docs/technology/Review/java_guide/lyfly_high-availability/ly04ly_timout-and-retry/","title":"超时\u0026重试详解","section":"高可用","content":" 转载自https://github.com/Snailclimb/JavaGuide(添加小部分笔记)感谢作者!\n由于网络问题、系统或者服务内部的 Bug、服务器宕机、操作系统崩溃等问题的不确定性,我们的系统或者服务永远不可能保证时刻都是可用的状态。\n为了最大限度的减小系统或者服务出现故障之后带来的影响,我们需要用到的 超时(Timeout) 和 重试(Retry) 机制。\n想要把超时和重试机制讲清楚其实很简单,因为它俩本身就不是什么高深的概念。\n虽然超时和重试机制的思想很简单,但是它俩是真的非常实用。你平时接触到的绝大部分涉及到远程调用的系统或者服务都会应用超时和重试机制。尤其是对于微服务系统来说,正确设置超时和重试非常重要。单体服务通常只涉及数据库、缓存、第三方 API、中间件等的网络调用,而微服务系统内部各个服务之间还存在着网络调用。\n# 超时机制 # # 什么是超时机制? # 超时机制说的是当一个请求超过指定的时间(比如 1s)还没有被处理的话,这个请求就会直接被取消并抛出指定的异常或者错误(比如 504 Gateway Timeout)。\n我们平时接触到的超时可以简单分为下面 2 种:\n连接超时(ConnectTimeout) :客户端与服务端建立连接的最长等待时间。 读取超时(ReadTimeout) :客户端和服务端已经建立连接,客户端等待服务端处理完请求的最长时间。实际项目中,我们关注比较多的还是读取超时。 一些连接池客户端框架中可能还会有获取连接超时和空闲连接清理超时**。\n如果没有设置超时的话,就可能会导致服务端连接数爆炸和大量请求堆积的问题。\n这些堆积的连接和请求会消耗系统资源,影响新收到的请求的处理。严重的情况下,甚至会拖垮整个系统或者服务。\n我之前在实际项目就遇到过类似的问题,整个网站无法正常处理请求,服务器负载直接快被拉满。后面发现原因是项目超时设置错误加上客户端请求处理异常,导致服务端连接数直接接近 40w+,这么多堆积的连接直接把系统干趴了。\n# 超时时间应该如何设置? # 超时到底设置多长时间是一个难题!超时值设置太高或者太低都有风险。如果设置太高的话,会降低超时机制的有效性,比如你设置超时为 10s 的话,那设置超时就没啥意义了,系统依然可能会出现大量慢请求堆积的问题。如果设置太低的话,就可能会导致在系统或者服务在某些处理请求速度变慢的情况下(比如请求突然增多),大量请求重试(超时通常会结合重试)继续加重系统或者服务的压力,进而导致整个系统或者服务被拖垮的问题。\n通常情况下,我们建议读取超时设置为 1500ms ,这是一个比较普适的值。如果你的系统或者服务对于延迟比较敏感的话,那读取超时值可以适当在 1500ms 的基础上进行缩短。反之,读取超时值也可以在 1500ms 的基础上进行加长,不过,尽量还是不要超过 1500ms 。连接超时可以适当设置长一些,建议在 1000ms ~ 5000ms 之内。\n没有银弹!超时值具体该设置多大,还是要根据实际项目的需求和情况慢慢调整优化得到。\n更上一层,参考 美团的Java线程池参数动态配置open in new window思想,我们也可以将超时弄成可配置化的参数而不是固定的,比较简单的一种办法就是将超时的值放在配置中心中。这样的话,我们就可以根据系统或者服务的状态动态调整超时值了。\n# 重试机制 # # 什么是重试机制? # 重试机制一般配合超时机制一起使用,指的是多次发送相同的请求来避免瞬态故障和偶然性故障。\n瞬态故障可以简单理解为某一瞬间系统偶然出现的故障,并不会持久。偶然性故障可以理解为哪些在某些情况下偶尔出现的故障,频率通常较低。\n重试的核心思想是通过消耗服务器的资源来尽可能获得请求更大概率被成功处理。由于瞬态故障和偶然性故障是很少发生的,因此,重试对于服务器的资源消耗几乎是可以被忽略的。\n# 重试的次数如何设置? # 重试的次数不宜过多,否则依然会对系统负载造成比较大的压力。\n重试的次数通常建议设为 3 次。并且,我们通常还会设置重试的间隔,比如说我们要重试 3 次的话,第 1 次请求失败后,等待 1 秒再进行重试,第 2 次请求失败后,等待 2 秒再进行重试,第 3 次请求失败后,等待 3 秒再进行重试。\n# 重试幂等 # 超时和重试机制在实际项目中使用的话,需要注意保证同一个请求没有被多次执行。\n这里说的同一个请求,指的是\u0026quot;\u0026ldquo;业务上的概念\u0026rdquo;\u0026quot;\n什么情况下会出现一个请求被多次执行呢?客户端等待服务端完成请求完成超时但此时服务端已经执行了请求,只是由于短暂的网络波动导致响应在发送给客户端的过程中延迟了。\n举个例子:用户支付购买某个课程,结果用户支付的请求由于重试的问题导致用户购买同一门课程支付了两次。对于这种情况,我们在执行用户购买课程的请求的时候需要判断一下用户是否已经购买过。这样的话,就不会因为重试的问题导致重复购买了。\n# 参考 # 微服务之间调用超时的设置治理:https://www.infoq.cn/article/eyrslar53l6hjm5yjgyx 超时、重试和抖动回退:https://aws.amazon.com/cn/builders-library/timeouts-retries-and-backoff-with-jitter/ 著作权归所有 原文链接:https://javaguide.cn/high-availability/timeout-and-retry.html\n"},{"id":263,"href":"/zh/docs/technology/Review/java_guide/lyfly_high-availability/ly03ly_limit-request/","title":"服务限流详解","section":"高可用","content":" 转载自https://github.com/Snailclimb/JavaGuide(添加小部分笔记)感谢作者!\n针对软件系统来说,限流就是对请求的速率进行限制,避免瞬时的大量请求击垮软件系统。毕竟,软件系统的处理能力是有限的。如果说超过了其处理能力的范围,软件系统可能直接就挂掉了。\n限流可能会导致用户的请求无法被正确处理,不过,这往往也是权衡了软件系统的稳定性之后得到的最优解。\n现实生活中,处处都有限流的实际应用,就比如排队买票是为了避免大量用户涌入购票而导致售票员无法处理。\n常见限流算法有哪些? # 简单介绍 4 种非常好理解并且容易实现的限流算法!\n图片来源于 InfoQ 的一篇文章 《分布式服务限流实战,已经为你排好坑了》。\n固定窗口计数器算法 # 固定窗口其实就是时间窗口。固定窗口计数器算法 规定了我们单位时间处理的请求数量。\n假如我们规定系统中某个接口 1 分钟只能访问 33 次的话,使用固定窗口计数器算法的实现思路如下:\n给定一个变量 counter 来记录当前接口处理的请求数量,初始值为 0(代表接口当前 1 分钟内还未处理请求)。 1 分钟之内每处理一个请求之后就将 counter+1 ,当 counter=33 之后(也就是说在这 1 分钟内接口已经被访问 33 次的话),后续的请求就会被全部拒绝。 等到 1 分钟结束后,将 counter 重置 0,重新开始计数。 这种限流算法无法保证限流速率,因而无法保证突然激增的流量。\n就比如说我们限制某个接口 1 分钟只能访问 1000 次,该接口的 QPS 为 500,前 55s 这个接口 1 个请求没有接收,后 1s 突然接收了 1000 个请求。然后,在当前场景下,这 1000 个请求在 1s 内是没办法被处理的,系统直接就被瞬时的大量请求给击垮了。\n滑动窗口计数器算法 # 滑动窗口计数器算法 算的上是固定窗口计数器算法的升级版。\n滑动窗口计数器算法相比于固定窗口计数器算法的优化在于:它把时间以一定比例分片(即分片后应用\u0026quot;固定窗口计数器\u0026quot;算法) 。\n例如我们的接口限流每分钟处理 60 个请求,我们可以把 1 分钟分为 60 个窗口(每个窗口时1秒)。每隔 1 秒移动一次,每个窗口一秒只能处理 不大于 60(请求数)/60(窗口数) 的请求, 如果当前窗口的请求计数总和超过了限制的数量的话就不再处理其他请求。\n感觉上面的例子和图没匹配上,应该是\n例如我们的接口限流每分钟处理 300 个请求,我们可以把 1 分钟分为 60 个窗口(每个窗口时1秒)。每隔 1 秒移动一次,每个窗口一秒只能处理 不大于 300(请求数)/60(窗口数) 的请求, 如果当前窗口的请求计数总和超过了限制的数量的话就不再处理其他请求。\n很显然, 当滑动窗口的格子划分的越多,滑动窗口的滚动就越平滑,限流的统计就会越精确。\n]\n漏桶算法 # 我们可以把发请求的动作比作成注水到桶中,我们处理请求的过程可以比喻为漏桶漏水。我们往桶中以任意速率流入水,以一定速率流出水。当水超过桶流量则丢弃,因为桶容量是不变的,保证了整体的速率。\n如果想要实现这个算法的话也很简单,准备一个队列用来保存请求,然后我们定期从队列中拿请求来执行就好了(和消息队列削峰/限流的思想是一样的)。\n令牌桶算法 # 令牌桶算法也比较简单。和漏桶算法算法一样,我们的主角还是桶(这限流算法和桶过不去啊)。不过现在桶里装的是令牌了,请求在被处理之前需要拿到一个令牌,请求处理完毕之后将这个令牌丢弃(删除)。我们根据限流大小,按照一定的速率往桶里添加令牌。如果桶装满了,就不能继续往里面继续添加令牌了。\n单机限流怎么做? # 单机限流针对的是单体架构应用。\n单机限流可以直接使用 Google Guava 自带的限流工具类 RateLimiter 。 RateLimiter 基于令牌桶算法,可以应对突发流量。\nGuava 地址:https://github.com/google/guava\n除了最基本的令牌桶算法(平滑突发限流)实现之外,Guava 的RateLimiter还提供了 平滑预热限流 的算法实现。\n平滑突发限流就是按照指定的速率放令牌到桶里,而平滑预热限流会有一段预热时间,预热时间之内,速率会逐渐提升到配置的速率。\n我们下面通过两个简单的小例子来详细了解吧!\n我们直接在项目中引入 Guava 相关的依赖即可使用。\n\u0026lt;dependency\u0026gt; \u0026lt;groupId\u0026gt;com.google.guava\u0026lt;/groupId\u0026gt; \u0026lt;artifactId\u0026gt;guava\u0026lt;/artifactId\u0026gt; \u0026lt;version\u0026gt;31.0.1-jre\u0026lt;/version\u0026gt; \u0026lt;/dependency\u0026gt; 下面是一个简单的 Guava 平滑突发限流的 Demo。\nimport com.google.common.util.concurrent.RateLimiter; /** * 微信搜 JavaGuide 回复\u0026#34;面试突击\u0026#34;即可免费领取个人原创的 Java 面试手册 * * @author Guide哥 * @date 2021/10/08 19:12 **/ public class RateLimiterDemo { public static void main(String[] args) { // 1s 放 5 个令牌到桶里也就是 0.2s 放 1个令牌到桶里 //也就是1s放几个 //public static RateLimiter create(double permitsPerSecond) {} RateLimiter rateLimiter = RateLimiter.create(5); for (int i = 0; i \u0026lt; 10; i++) { //阻塞直到得到一个令牌 ,会返回阻塞的时间 double sleepingTime = rateLimiter.acquire(1); System.out.printf(\u0026#34;get 1 tokens: %ss%n\u0026#34;, sleepingTime); } } } //源码 /** * Creates a {@code RateLimiter} with the specified stable throughput, given as \u0026#34;permits per * second\u0026#34; (commonly referred to as \u0026lt;i\u0026gt;QPS\u0026lt;/i\u0026gt;, queries per second). * * \u0026lt;p\u0026gt;The returned {@code RateLimiter} ensures that on average no more than {@code * permitsPerSecond} are issued during any given second, with sustained requests being smoothly * spread over each second. When the incoming request rate exceeds {@code permitsPerSecond} the * rate limiter will release one permit every {@code (1.0 / permitsPerSecond)} seconds. When the * rate limiter is unused, bursts of up to {@code permitsPerSecond} permits will be allowed, with * subsequent requests being smoothly limited at the stable rate of {@code permitsPerSecond}. * * @param permitsPerSecond the rate of the returned {@code RateLimiter}, measured in how many * permits become available per second * @throws IllegalArgumentException if {@code permitsPerSecond} is negative or zero */ // TODO(user): \u0026#34;This is equivalent to // {@code createWithCapacity(permitsPerSecond, 1, TimeUnit.SECONDS)}\u0026#34;. // public static RateLimiter create(double permitsPerSecond) {} //源码 /** * Acquires the given number of permits from this {@code RateLimiter}, blocking until the request * can be granted. Tells the amount of time slept, if any. * * @param permits the number of permits to acquire * @return time spent sleeping to enforce rate, in seconds; 0.0 if not rate-limited * @throws IllegalArgumentException if the requested number of permits is negative or zero * @since 16.0 (present in 13.0 with {@code void} return type}) */ /* @CanIgnoreReturnValue public double acquire(int permits) { long microsToWait = reserve(permits); stopwatch.sleepMicrosUninterruptibly(microsToWait); return 1.0 * microsToWait / SECONDS.toMicros(1L); } */ 输出:\nget 1 tokens: 0.0s get 1 tokens: 0.188413s get 1 tokens: 0.197811s get 1 tokens: 0.198316s get 1 tokens: 0.19864s get 1 tokens: 0.199363s get 1 tokens: 0.193997s get 1 tokens: 0.199623s get 1 tokens: 0.199357s get 1 tokens: 0.195676s 下面是一个简单的 Guava 平滑预热限流的 Demo。\nimport com.google.common.util.concurrent.RateLimiter; import java.util.concurrent.TimeUnit; /** * 微信搜 JavaGuide 回复\u0026#34;面试突击\u0026#34;即可免费领取个人原创的 Java 面试手册 * * @author Guide哥 * @date 2021/10/08 19:12 **/ public class RateLimiterDemo { public static void main(String[] args) { // 1s 放 5 个令牌到桶里也就是 0.2s 放 1个令牌到桶里 // 预热时间为3s,也就说刚开始的 3s 内发牌速率会逐渐提升到 0.2s 放 1 个令牌到桶里(所以前面的速率比较小,获取需要的时间比较长) RateLimiter rateLimiter = RateLimiter.create(5, 3, TimeUnit.SECONDS); for (int i = 0; i \u0026lt; 20; i++) { double sleepingTime = rateLimiter.acquire(1); System.out.printf(\u0026#34;get 1 tokens: %sds%n\u0026#34;, sleepingTime); } } } 输出:\nget 1 tokens: 0.0s get 1 tokens: 0.561919s get 1 tokens: 0.516931s get 1 tokens: 0.463798s get 1 tokens: 0.41286s get 1 tokens: 0.356172s get 1 tokens: 0.300489s get 1 tokens: 0.252545s get 1 tokens: 0.203996s get 1 tokens: 0.198359s 另外,Bucket4j 是一个非常不错的基于令牌/漏桶算法的限流库。\nBucket4j 地址:https://github.com/vladimir-bukhtoyarov/bucket4j\n相对于,Guava 的限流工具类来说,Bucket4j 提供的限流功能更加全面。不仅支持单机限流和分布式限流,还可以集成监控,搭配 Prometheus 和 Grafana 使用。\n不过,毕竟 Guava 也只是一个功能全面的工具类库,其提供的开箱即用的限流功能在很多单机场景下还是比较实用的。\nSpring Cloud Gateway 中自带的单机限流的早期版本就是基于 Bucket4j 实现的。后来,替换成了 Resilience4j。\nResilience4j 是一个轻量级的容错组件,其灵感来自于 Hystrix。自 Netflix 宣布不再积极开发 Hystrix 之后,Spring 官方和 Netflix 都更推荐使用 Resilience4j 来做限流熔断。\nResilience4j 地址: https://github.com/resilience4j/resilience4j\n一般情况下,为了保证系统的高可用,项目的限流和熔断都是要一起做的。\nResilience4j 不仅提供限流,还提供了熔断、负载保护、自动重试等保障系统高可用开箱即用的功能。并且,Resilience4j 的生态也更好,很多网关都使用 Resilience4j 来做限流熔断的。\n因此,在绝大部分场景下 Resilience4j 或许会是更好的选择。如果是一些比较简单的限流场景的话,Guava 或者 Bucket4j 也是不错的选择。\n分布式限流怎么做? # 分布式限流针对的分布式/微服务应用架构应用,在这种架构下,单机限流就不适用了,因为会存在多种服务,并且一种服务也可能会被部署多份。\n分布式限流常见的方案:\n借助中间件架限流 :可以借助 Sentinel 或者使用 Redis 来自己实现对应的限流逻辑。 网关层限流 :比较常用的一种方案,直接在网关层把限流给安排上了。不过,通常网关层限流通常也需要借助到中间件/框架。就比如 Spring Cloud Gateway 的分布式限流实现**RedisRateLimiter**就是基于 Redis+Lua 来实现的,再比如 Spring Cloud Gateway 还可以整合 Sentinel 来做限流。 如果你要基于 Redis 来手动实现限流逻辑的话,建议配合 Lua 脚本来做。\n为什么建议 Redis+Lua 的方式? 主要有两点原因:\n减少了网络开销 :我们可以利用 Lua 脚本来批量执行多条 Redis 命令,这些 Redis 命令会被提交到 Redis 服务器一次性执行完成,大幅减小了网络开销。 原子性 :一段 Lua 脚本可以视作一条命令执行,一段 Lua 脚本执行过程中不会有其他脚本或 Redis 命令同时执行,保证了操作不会被其他指令插入或打扰。 我这里就不放具体的限流脚本代码了,网上也有很多现成的优秀的限流脚本供你参考,就比如 Apache 网关项目 ShenYu 的 RateLimiter 限流插件就基于 Redis + Lua 实现了令牌桶算法/并发令牌桶算法、漏桶算法、滑动窗口算法。\nShenYu 地址: https://github.com/apache/incubator-shenyu\n服务治理之轻量级熔断框架 Resilience4j :https://xie.infoq.cn/article/14786e571c1a4143ad1ef8f19 超详细的 Guava RateLimiter 限流原理解析:https://cloud.tencent.com/developer/article/1408819 实战 Spring Cloud Gateway 之限流篇 👍:https://www.aneasystone.com/archives/2020/08/spring-cloud-gateway-current-limiting.html "},{"id":264,"href":"/zh/docs/technology/Review/java_guide/lyfly_high-availability/ly02ly_redundancy/","title":"冗余设计","section":"高可用","content":" 转载自https://github.com/Snailclimb/JavaGuide(添加小部分笔记)感谢作者!\ntitle category 冗余设计详解 高可用 冗余设计是保证系统和数据高可用的最常的手段。\n对于服务来说,冗余的思想就是相同的服务部署多份,如果正在使用的服务突然挂掉的话,系统可以很快切换到备份服务上,大大减少系统的不可用时间,提高系统的可用性。\n对于数据来说,冗余的思想就是相同的数据备份多份,这样就可以很简单地提高数据的安全性。\n实际上,日常生活中就有非常多的冗余思想的应用。\n拿我自己来说,我对于重要文件的保存方法就是冗余思想的应用。我日常所使用的重要文件都会同步一份在 Github 以及个人云盘上,这样就可以保证即使电脑硬盘损坏,我也可以通过 Github 或者个人云盘找回自己的重要文件。\n高可用集群(High Availability Cluster,简称 HA Cluster)、同城灾备、异地灾备、同城多活和异地多活是冗余思想在高可用系统设计中最典型的应用。\n高可用集群 : 同一份服务部署两份或者多份,当正在使用的服务突然挂掉的话,可以切换到另外一台服务,从而保证服务的高可用。 同城灾备 :一整个集群可以部署在同一个机房,而同城灾备中相同服务部署在同一个城市的不同机房中。并且,备用服务不处理请求。这样可以避免机房出现意外情况比如停电、火灾。 异地灾备 :类似于同城灾备,不同的是,相同服务部署在异地(通常距离较远,甚至是在不同的城市或者国家)的不同机房中 同城多活 :类似于同城灾备,但备用服务可以处理请求,这样可以充分利用系统资源,提高系统的并发。 异地多活 : 将服务部署在异地的不同机房中,并且,它们可以同时对外提供服务。 高可用集群单纯是服务的冗余,并没有强调地域。同城灾备、异地灾备、同城多活和异地多活实现了地域上的冗余。\n同城和异地的主要区别在于机房之间的距离。异地通常距离较远,甚至是在不同的城市或者国家。\n和传统的灾备设计相比,同城多活和异地多活最明显的改变在于**“多活”,即所有站点都是同时在对外提供服务的。异地多活是为了应对突发状况比如火灾**、地震等自然或者人为灾害。\n光做好冗余还不够,必须要配合上 故障转移 才可以! 所谓故障转移,简单来说就是实现不可用服务快速且自动地切换到可用服务,整个过程不需要人为干涉。\n举个例子:哨兵模式的 Redis 集群中,如果 Sentinel(哨兵) 检测到 master 节点出现故障的话, 它就会帮助我们实现故障转移,自动将某一台 slave 升级为 master,确保整个 Redis 系统的可用性。整个过程完全自动,不需要人工介入。我在 《Java 面试指北》的「技术面试题篇」中的数据库部分详细介绍了 Redis 集群相关的知识点\u0026amp;面试题,感兴趣的小伙伴可以看看。\n再举个例子:Nginx 可以结合 Keepalived 来实现高可用。如果 Nginx 主服务器宕机的话,Keepalived 可以自动进行故障转移,备用 Nginx 主服务器升级为主服务。并且,这个切换对外是透明的,因为使用的虚拟 IP,虚拟 IP 不会改变。我在 《Java 面试指北》的「技术面试题篇」中的「服务器」部分详细介绍了 Nginx 相关的知识点\u0026amp;面试题,感兴趣的小伙伴可以看看。\n异地多活架构实施起来非常难,需要考虑的因素非常多。本人不才,实际项目中并没有实践过异地多活架构,我对其了解还停留在书本知识。\n如果你想要深入学习异地多活相关的知识,我这里推荐几篇我觉得还不错的文章:\n搞懂异地多活,看这篇就够了- 水滴与银弹 - 2021 四步构建异地多活 《从零开始学架构》— 28 | 业务高可用的保障:异地多活架构 不过,这些文章大多也都是在介绍概念知识。目前,网上还缺少真正介绍具体要如何去实践落地异地多活架构的资料。\n灾备 = 容灾+备份。\n备份 : 将系统所产生的的所有重要数据多备份几份。 容灾 : 在异地建立两个完全相同的系统。当某个地方的系统突然挂掉,整个应用系统可以切换到另一个,这样系统就可以正常提供服务了。 异地多活 描述的是将服务部署在异地并且服务同时对外提供服务。和传统的灾备设计的最主要区别在于“多活”,即所有站点都是同时在对外提供服务的。异地多活是为了应对突发状况比如火灾、地震等自然或者人为灾害。\n"},{"id":265,"href":"/zh/docs/technology/Review/java_guide/lyfly_high-availability/ly01ly_high-availability-system-design/","title":"高可用系统设计指南","section":"高可用","content":" 转载自https://github.com/Snailclimb/JavaGuide(添加小部分笔记)感谢作者!\n什么是高可用?可用性的判断标准是啥? # 高可用描述的是一个系统在大部分时间都是可用的,可以为我们提供服务的。高可用代表系统即使在发生硬件故障或者系统升级的时候,服务仍然是可用的。\n一般情况下,我们使用多少个 9 来评判一个系统的可用性,比如 99.9999% 就是代表该系统在所有的运行时间中只有 0.0001% 的时间是不可用的,这样的系统就是非常非常高可用的了!当然,也会有系统如果可用性不太好的话,可能连 9 都上不了。\n除此之外,系统的可用性还可以用某功能的失败次数与总的请求次数之比来衡量,比如对网站请求 1000 次,其中有 10 次请求失败,那么可用性就是 99%。\n哪些情况会导致系统不可用? # 黑客攻击; 硬件故障,比如服务器坏掉。 并发量/用户请求量激增导致整个服务宕掉或者部分服务不可用。 代码中的坏味道导致内存泄漏或者其他问题导致程序挂掉。 网站架构某个重要的角色比如 Nginx 或者数据库突然不可用。 自然灾害或者人为破坏。 \u0026hellip;\u0026hellip; 有哪些提高系统可用性的方法? # 注重代码质量,测试严格把关 # 我觉得这个是最最最重要的,代码质量有问题比如比较常见的内存泄漏、循环依赖都是对系统可用性极大的损害。大家都喜欢谈限流、降级、熔断,但是我觉得从代码质量这个源头把关是首先要做好的一件很重要的事情。如何提高代码质量?比较实际可用的就是 CodeReview,不要在乎每天多花的那 1 个小时左右的时间,作用可大着呢!\n另外,安利几个对提高代码质量有实际效果的神器:\nSonarqube; Alibaba 开源的 Java 诊断工具 Arthas; 阿里巴巴 Java 代码规范(Alibaba Java Code Guidelines); IDEA 自带的代码分析等工具。 使用集群,减少单点故障 # 先拿常用的 Redis 举个例子!我们如何保证我们的 Redis 缓存高可用呢?答案就是使用集群,避免单点故障。当我们使用一个 Redis 实例作为缓存的时候,这个 Redis 实例挂了之后,整个缓存服务可能就挂了。使用了集群之后,即使一台 Redis 实例挂了,不到一秒就会有另外一台 Redis 实例顶上。\n限流 # 流量控制(flow control),其原理是监控应用流量的 QPS 或并发线程数等指标,当达到指定的阈值时对流量进行控制,以避免被瞬时的流量高峰冲垮,从而保障应用的高可用性。——来自 alibaba-Sentinel 的 wiki。\n超时和重试机制设置 # 一旦用户请求超过某个时间的得不到响应,就抛出异常。这个是非常重要的,很多线上系统故障都是因为没有进行超时设置或者超时设置的方式不对导致的。我们在读取第三方服务的时候,尤其适合设置超时和重试机制。一般我们使用一些 RPC 框架的时候,这些框架都自带的超时重试的配置。如果不进行超时设置可能会导致请求响应速度慢,甚至导致请求堆积进而让系统无法再处理请求。重试的次数一般设为 3 次,再多次的重试没有好处,反而会加重服务器压力(部分场景使用失败重试机制会不太适合)。\n熔断机制 # 超时和重试机制设置之外,熔断机制也是很重要的。 熔断机制说的是系统自动收集所依赖服务的资源使用情况和性能指标,当所依赖的服务恶化或者调用失败次数达到某个阈值的时候就迅速失败,让当前系统立即切换依赖其他备用服务。 比较常用的流量控制和熔断降级框架是 Netflix 的 Hystrix 和 alibaba 的 Sentinel。\n异步调用 # 异步调用的话我们不需要关心最后的结果,这样我们就可以用户请求完成之后就立即返回结果,具体处理我们可以后续再做,秒杀场景用这个还是蛮多的。但是,使用异步之后我们可能需要 适当修改业务流程进行配合,比如用户在提交订单之后,不能立即返回用户订单提交成功,需要在消息队列的订单消费者进程真正处理完该订单之后,甚至出库后,再通过电子邮件或短信通知用户订单成功。除了可以在程序中实现异步之外,我们常常还使用消息队列,消息队列可以通过异步处理提高系统性能(削峰、减少响应所需时间)并且可以降低系统耦合性。\n使用缓存 # 如果我们的系统属于并发量比较高的话,如果我们单纯使用数据库的话,当大量请求直接落到数据库可能数据库就会直接挂掉。使用缓存缓存热点数据,因为缓存存储在内存中,所以速度相当地快!\n其他 # 核心应用和服务优先使用更好的硬件 监控系统资源使用情况增加报警设置。 注意备份,必要时候回滚。 灰度发布: 将服务器集群分成若干部分,每天只发布一部分机器,观察运行稳定没有故障,第二天继续发布一部分机器,持续几天才把整个集群全部发布完毕,期间如果发现问题,只需要回滚已发布的一部分服务器即可 定期检查/更换硬件: 如果不是购买的云服务的话,定期还是需要对硬件进行一波检查的,对于一些需要更换或者升级的硬件,要及时更换或者升级。 \u0026hellip;.. "},{"id":266,"href":"/zh/docs/technology/Review/java_guide/lyely_high-performance/message-mq/rocketmq-questions/","title":"rocketmq常见面试题","section":"RocketMQ","content":" 转载自https://github.com/Snailclimb/JavaGuide(添加小部分笔记)感谢作者!\n本文来自读者 PR。\n主要是rocket mq的几个问题\n1 单机版消息中心 # 一个消息中心,最基本的需要支持多生产者、多消费者,例如下:\nclass Scratch { public static void main(String[] args) { // 实际中会有 nameserver 服务来找到 broker 具体位置以及 broker 主从信息 Broker broker = new Broker(); Producer producer1 = new Producer(); producer1.connectBroker(broker); Producer producer2 = new Producer(); producer2.connectBroker(broker); Consumer consumer1 = new Consumer(); consumer1.connectBroker(broker); Consumer consumer2 = new Consumer(); consumer2.connectBroker(broker); for (int i = 0; i \u0026lt; 2; i++) { producer1.asyncSendMsg(\u0026#34;producer1 send msg\u0026#34; + i); producer2.asyncSendMsg(\u0026#34;producer2 send msg\u0026#34; + i); } System.out.println(\u0026#34;broker has msg:\u0026#34; + broker.getAllMagByDisk()); for (int i = 0; i \u0026lt; 1; i++) { System.out.println(\u0026#34;consumer1 consume msg:\u0026#34; + consumer1.syncPullMsg()); } for (int i = 0; i \u0026lt; 3; i++) { System.out.println(\u0026#34;consumer2 consume msg:\u0026#34; + consumer2.syncPullMsg()); } } } class Producer { private Broker broker; public void connectBroker(Broker broker) { this.broker = broker; } public void asyncSendMsg(String msg) { if (broker == null) { throw new RuntimeException(\u0026#34;please connect broker first\u0026#34;); } new Thread(() -\u0026gt; { broker.sendMsg(msg); }).start(); } } class Consumer { private Broker broker; public void connectBroker(Broker broker) { this.broker = broker; } public String syncPullMsg() { return broker.getMsg(); } } class Broker { // 对应 RocketMQ 中 MessageQueue,默认情况下 1 个 Topic 包含 4 个 MessageQueue private LinkedBlockingQueue\u0026lt;String\u0026gt; messageQueue = new LinkedBlockingQueue(Integer.MAX_VALUE); // 实际发送消息到 broker 服务器使用 Netty 发送 public void sendMsg(String msg) { try { messageQueue.put(msg); // 实际会同步或异步落盘,异步落盘使用的定时任务定时扫描落盘 } catch (InterruptedException e) { } } public String getMsg() { try { return messageQueue.take(); } catch (InterruptedException e) { } return null; } public String getAllMagByDisk() { StringBuilder sb = new StringBuilder(\u0026#34;\\n\u0026#34;); messageQueue.iterator().forEachRemaining((msg) -\u0026gt; { sb.append(msg + \u0026#34;\\n\u0026#34;); }); return sb.toString(); } } 问题:\n没有实现真正执行消息存储落盘 没有实现 NameServer 去作为注册中心,定位服务 使用 LinkedBlockingQueue 作为消息队列,注意,参数是无限大,在真正 RocketMQ 也是如此是无限大,理论上不会出现对进来的数据进行抛弃,但是会有内存泄漏问题(阿里巴巴开发手册也因为这个问题,建议我们使用自制线程池) 没有使用多个队列(即多个 LinkedBlockingQueue),RocketMQ 的顺序消息是通过生产者和消费者同时使用同一个 MessageQueue 来实现,但是如果我们只有一个 MessageQueue,那我们天然就支持顺序消息 没有使用 MappedByteBuffer 来实现文件映射从而使消息数据落盘非常的快(实际 RocketMQ 使用的是 FileChannel+DirectBuffer) 2 分布式消息中心 # 2.1 问题与解决 # 2.1.1 消息丢失的问题 # 当你系统需要保证百分百消息不丢失,你可以使用生产者每发送一个消息,Broker 同步返回一个消息发送成功的反馈消息 即每发送一个消息,同步落盘后才返回生产者消息发送成功,这样只要生产者得到了消息发送生成的返回,事后除了硬盘损坏,都可以保证不会消息丢失 但是这同时引入了一个问题,同步落盘怎么才能快? 2.1.2 同步落盘怎么才能快 # 使用 FileChannel + DirectBuffer 池,使用堆外内存,加快内存拷贝 使用数据和索引分离,当消息需要写入时,使用 commitlog 文件顺序写,当需要定位某个消息时,查询index 文件来定位,从而减少文件IO随机读写的性能损耗 2.1.3 消息堆积的问题 # 后台定时任务每隔72小时,删除旧的没有使用过的消息信息 根据不同的业务实现不同的丢弃任务,具体参考线程池的 AbortPolicy,例如FIFO/LRU等(RocketMQ没有此策略) 消息定时转移,或者对某些重要的 TAG 型(支付型)消息真正落库 2.1.4 定时消息的实现 # 实际 RocketMQ 没有实现任意精度的定时消息,它只支持某些特定的时间精度的定时消息 实现定时消息的原理是:创建特定时间精度的 MessageQueue,例如生产者需要定时1s之后被消费者消费,你只需要将此消息发送到特定的 Topic,例如:MessageQueue-1 表示这个 MessageQueue 里面的消息都会延迟一秒被消费,然后 Broker 会在 1s 后发送到消费者消费此消息,使用 newSingleThreadScheduledExecutor 实现 2.1.5 顺序消息的实现 # 与定时消息同原理,生产者生产消息时指定特定的 MessageQueue ,消费者消费消息时,消费特定的 MessageQueue,其实单机版的消息中心在一个 MessageQueue 就天然支持了顺序消息 注意:同一个 MessageQueue 保证里面的消息是顺序消费的前提是:消费者是串行的消费该 MessageQueue,因为就算 MessageQueue 是顺序的,但是当并行消费时,还是会有顺序问题,但是串行消费也同时引入了两个问题: 引入锁来实现串行 前一个消费阻塞时后面都会被阻塞 2.1.6 分布式消息的实现 # 需要前置知识:2PC RocketMQ4.3 起支持,原理为2PC,即两阶段提交,prepared-\u0026gt;commit/rollback 生产者发送事务消息,假设该事务消息 Topic 为 Topic1-Trans,Broker 得到后首先更改该消息的 Topic 为 Topic1-Prepared,该 Topic1-Prepared 对消费者不可见。然后定时回调生产者的本地事务A执行状态,根据本地事务A执行状态,来是否将该消息修改为 Topic1-Commit 或 Topic1-Rollback,消费者就可以正常找到该事务消息或者不执行等 注意,就算是事务消息最后回滚了也不会物理删除,只会逻辑删除该消息\n2.1.7 消息的 push 实现 # 注意,RocketMQ 已经说了自己会有低延迟问题,其中就包括这个消息的 push 延迟问题 因为这并不是真正的将消息主动的推送到消费者,而是 Broker 定时任务每5s将消息推送到消费者 pull模式需要我们手动调用consumer拉消息,而push模式则只需要我们提供一个listener即可实现对消息的监听,而实际上,RocketMQ的push模式是基于pull模式实现的,它没有实现真正的push。 push方式里,consumer把轮询过程封装了,并注册MessageListener监听器,取到消息后,唤醒MessageListener的consumeMessage()来消费,对用户而言,感觉消息是被推送过来的。 2.1.8 消息重复发送的避免 # RocketMQ 会出现消息重复发送的问题,因为在网络延迟的情况下,这种问题不可避免的发生,如果非要实现消息不可重复发送,那基本太难,因为网络环境无法预知,还会使程序复杂度加大,因此默认允许消息重复发送 RocketMQ 让使用者在消费者端去解决该问题,即需要消费者端在消费消息时支持幂等性的去消费消息 最简单的解决方案是每条消费记录有个消费状态字段,根据这个消费状态字段来判断是否消费或者使用一个集中式的表,来存储所有消息的消费状态,从而避免重复消费 具体实现可以查询关于消息幂等消费的解决方案 2.1.9 广播消费与集群消费 # 消息消费区别:广播消费,订阅该 Topic 的消息者们都会消费每个消息。集群消费,订阅该 Topic 的消息者们只会有一个去消费某个消息 消息落盘区别:具体表现在消息消费进度的保存上。广播消费,由于每个消费者都独立的去消费每个消息,因此每个消费者各自保存自己的消息消费进度。而集群消费下,订阅了某个 Topic,而旗下又有多个 MessageQueue,每个消费者都可能会去消费不同的 MessageQueue,因此总体的消费进度保存在 Broker 上集中的管理 2.1.10 RocketMQ 不使用 ZooKeeper 作为注册中心的原因,以及自制的 NameServer 优缺点? # ZooKeeper 作为支持顺序一致性的中间件,在某些情况下,它为了满足一致性,会丢失一定时间内的可用性,RocketMQ 需要注册中心只是为了发现组件地址,在某些情况下,RocketMQ 的注册中心可以出现数据不一致性,这同时也是 NameServer 的缺点,因为 NameServer 集群间互不通信,它们之间的注册信息可能会不一致 另外,当有新的服务器加入时,NameServer 并不会立马通知到 Producer,而是由 Producer 定时去请求 NameServer 获取最新的 Broker/Consumer 信息(这种情况是通过 Producer 发送消息时,负载均衡解决) 2.1.11 其它 # 加分项咯\n包括组件通信间使用 Netty 的自定义协议 消息重试负载均衡策略(具体参考 Dubbo 负载均衡策略) 消息过滤器(Producer 发送消息到 Broker,Broker 存储消息信息,Consumer 消费时请求 Broker 端从磁盘文件查询消息文件时,在 Broker 端就使用过滤服务器进行过滤) Broker 同步双写和异步双写中 Master 和 Slave 的交互 Broker 在 4.5.0 版本更新中引入了基于 Raft 协议的多副本选举,之前这是商业版才有的特性 ISSUE-1046 3 参考 # 《RocketMQ技术内幕》:https://blog.csdn.net/prestigeding/article/details/85233529 关于 RocketMQ 对 MappedByteBuffer 的一点优化: https://lishoubo.github.io/2017/09/27/MappedByteBuffer%E7%9A%84%E4%B8%80%E7%82%B9%E4%BC%98%E5%8C%96/ 十分钟入门RocketMQ:https://developer.aliyun.com/article/66101 分布式事务的种类以及 RocketMQ 支持的分布式消息:https://www.infoq.cn/article/2018/08/rocketmq-4.3-release 滴滴出行基于RocketMQ构建企业级消息队列服务的实践:https://yq.aliyun.com/articles/664608 基于《RocketMQ技术内幕》源码注释:https://github.com/LiWenGu/awesome-rocketmq "},{"id":267,"href":"/zh/docs/technology/Review/java_guide/lyely_high-performance/message-mq/rocketmq-intro/","title":"rocketmq介绍","section":"RocketMQ","content":" 转载自https://github.com/Snailclimb/JavaGuide(添加小部分笔记)感谢作者!\n消息队列扫盲 # 消息队列顾名思义就是存放消息的队列,队列我就不解释了,别告诉我你连队列都不知道是啥吧?\n所以问题并不是消息队列是什么,而是 消息队列为什么会出现?消息队列能用来干什么?用它来干这些事会带来什么好处?消息队列会带来副作用吗?\n# 消息队列为什么会出现? # 消息队列算是作为后端程序员的一个必备技能吧,因为分布式应用必定涉及到各个系统之间的通信问题,这个时候消息队列也应运而生了。可以说分布式的产生是消息队列的基础,而分布式怕是一个很古老的概念了吧,所以消息队列也是一个很古老的中间件了。\n# 消息队列能用来干什么? # # 异步 # 你可能会反驳我,应用之间的通信又不是只能由消息队列解决,好好的通信为什么中间非要插一个消息队列呢?我不能直接进行通信吗?\n很好👍,你又提出了一个概念,同步通信。就比如现在业界使用比较多的 Dubbo 就是一个适用于各个系统之间同步通信的 RPC 框架。\n我来举个🌰吧,比如我们有一个购票系统,需求是用户在购买完之后能接收到购买完成的短信。\n我们省略中间的网络通信时间消耗,假如购票系统处理需要 150ms ,短信系统处理需要 200ms ,那么整个处理流程的时间消耗就是 150ms + 200ms = 350ms。\n当然,乍看没什么问题。可是仔细一想你就感觉有点问题,我用户购票在购票系统的时候其实就已经完成了购买,而我现在通过同步调用非要让整个请求拉长时间,而短信系统这玩意又不是很有必要,它仅仅是一个辅助功能增强用户体验感而已。我现在整个调用流程就有点 头重脚轻 的感觉了,购票是一个不太耗时的流程,而我现在因为同步调用,非要等待发送短信这个比较耗时的操作才返回结果。那我如果再加一个发送邮件呢?\n这样整个系统的调用链又变长了,整个时间就变成了550ms。\n当我们在学生时代需要在食堂排队的时候,我们和食堂大妈就是一个同步的模型。\n我们需要告诉食堂大妈:“姐姐,给我加个鸡腿,再加个酸辣土豆丝,帮我浇点汁上去,多打点饭哦😋😋😋” 咦~~~ 为了多吃点,真恶心。\n然后大妈帮我们打饭配菜,我们看着大妈那颤抖的手和掉落的土豆丝不禁咽了咽口水。\n最终我们从大妈手中接过饭菜然后去寻找座位了\u0026hellip;\n回想一下,我们在给大妈发送需要的信息之后我们是 同步等待大妈给我配好饭菜 的,上面我们只是加了鸡腿和土豆丝,万一我再加一个番茄牛腩,韭菜鸡蛋,这样是不是大妈打饭配菜的流程就会变长,我们等待的时间也会相应的变长。\n那后来,我们工作赚钱了有钱去饭店吃饭了,我们告诉服务员来一碗牛肉面加个荷包蛋 (传达一个消息) ,然后我们就可以在饭桌上安心的玩手机了 (干自己其他事情) ,等到我们的牛肉面上了我们就可以吃了。这其中我们也就传达了一个消息,然后我们又转过头干其他事情了。这其中虽然做面的时间没有变短,但是我们只需要传达一个消息就可以干其他事情了,这是一个 异步 的概念。\n所以,为了解决这一个问题,聪明的程序员在中间也加了个类似于服务员的中间件——消息队列。这个时候我们就可以把模型给改造了。\n这样,我们在将消息存入消息队列之后我们就可以直接返回了(我们告诉服务员我们要吃什么然后玩手机),所以整个耗时只是 150ms + 10ms = 160ms。\n但是你需要注意的是,整个流程的时长是没变的,就像你仅仅告诉服务员要吃什么是不会影响到做面的速度的。\n# 解耦 # 回到最初同步调用的过程,我们写个伪代码简单概括一下。\n那么第二步,我们又添加了一个发送邮件,我们就得重新去修改代码,如果我们又加一个需求:用户购买完还需要给他加积分,这个时候我们是不是又得改代码?\n如果你觉得还行,那么我这个时候不要发邮件这个服务了呢,我是不是又得改代码,又得重启应用?\n这样改来改去是不是很麻烦,那么 此时我们就用一个消息队列在中间进行解耦 。你需要注意的是,我们后面的发送短信、发送邮件、添加积分等一些操作都依赖于上面的 result ,这东西抽象出来就是购票的处理结果呀,比如订单号,用户账号等等,也就是说我们后面的一系列服务都是需要同样的消息来进行处理。既然这样,我们是不是可以通过 “广播消息” 来实现。\n我上面所讲的“广播”并不是真正的广播,而是接下来的系统作为消费者去 订阅 特定的主题。比如我们这里的主题就可以叫做 订票 ,我们购买系统作为一个生产者去生产这条消息放入消息队列,然后消费者订阅了这个主题,会从消息队列中拉取消息并消费。就比如我们刚刚画的那张图,你会发现,在生产者这边我们只需要关注 生产消息到指定主题中 ,而 消费者只需要关注从指定主题中拉取消息 就行了。\n如果没有消息队列,每当一个新的业务接入,我们都要在主系统调用新接口、或者当我们取消某些业务,我们也得在主系统删除某些接口调用。有了消息队列,我们只需要关心消息是否送达了队列,至于谁希望订阅,接下来收到消息如何处理,是下游的事情,无疑极大地减少了开发和联调的工作量。\n# 削峰(xue 1) # 我们再次回到一开始我们使用同步调用系统的情况,并且思考一下,如果此时有大量用户请求购票整个系统会变成什么样?\n如果,此时有一万的请求进入购票系统,我们知道运行我们主业务的服务器配置一般会比较好,所以这里我们假设购票系统能承受这一万的用户请求,那么也就意味着我们同时也会出现一万调用发短信服务的请求。而对于短信系统来说并不是我们的主要业务,所以我们配备的硬件资源并不会太高,那么你觉得现在这个短信系统能承受这一万的峰值么,且不说能不能承受,系统会不会 直接崩溃 了?\n短信业务又不是我们的主业务,我们能不能 折中处理 呢?如果我们把购买完成的信息发送到消息队列中,而短信系统 尽自己所能地去消息队列中取消息和消费消息 ,即使处理速度慢一点也无所谓,只要我们的系统没有崩溃就行了。\n留得江山在,还怕没柴烧?你敢说每次发送验证码的时候是一发你就收到了的么?\n# 消息队列能带来什么好处? # 其实上面我已经说了。异步、解耦、削峰。 哪怕你上面的都没看懂也千万要记住这六个字,因为他不仅是消息队列的精华,更是编程和架构的精华。\n# 消息队列会带来副作用吗? # 没有哪一门技术是“银弹”,消息队列也有它的副作用。\n比如,本来好好的两个系统之间的调用,我中间加了个消息队列,如果消息队列挂了怎么办呢?是不是 降低了系统的可用性 ?\n那这样是不是要保证HA(高可用)?是不是要搞集群?那么我 整个系统的复杂度是不是上升了 ?\n抛开上面的问题不讲,万一我发送方发送失败了,然后执行重试,这样就可能产生重复的消息。\n或者我消费端处理失败了,请求重发,这样也会产生重复的消息。\n对于一些微服务来说,消费重复消息会带来更大的麻烦,比如增加积分,这个时候我加了多次是不是对其他用户不公平?\n那么,又 如何解决重复消费消息的问题 呢?\n如果我们此时的消息需要保证严格的顺序性怎么办呢?比如生产者生产了一系列的有序消息(对一个id为1的记录进行删除增加修改),但是我们知道在发布订阅模型中,对于主题是无顺序的,那么这个时候就会导致对于消费者消费消息的时候没有按照生产者的发送顺序消费,比如这个时候我们消费的顺序为修改删除增加,如果该记录涉及到金额的话是不是会出大事情?\n那么,又 如何解决消息的顺序消费问题 呢?\n就拿我们上面所讲的分布式系统来说,用户购票完成之后是不是需要增加账户积分?在同一个系统中我们一般会使用事务来进行解决,如果用 Spring 的话我们在上面伪代码中加入 @Transactional 注解就好了。但是在不同系统中如何保证事务呢?总不能这个系统我扣钱成功了你那积分系统积分没加吧?或者说我这扣钱明明失败了,你那积分系统给我加了积分。\n那么,又如何 解决分布式事务问题 呢?\n我们刚刚说了,消息队列可以进行削峰操作,那如果我的消费者如果消费很慢或者生产者生产消息很快,这样是不是会将消息堆积在消息队列中?\n那么,又如何 解决消息堆积的问题 呢?\n可用性降低,复杂度上升,又带来一系列的重复消费,顺序消费,分布式事务,消息堆积的问题,这消息队列还怎么用啊😵?\n别急,办法总是有的。\n# RocketMQ是什么? # 原理 来源: https://www.bilibili.com/video/BV1GY4y1F7og\n哇,你个混蛋!上面给我抛出那么多问题,你现在又讲 RocketMQ ,还让不让人活了?!🤬\n别急别急,话说你现在清楚 MQ 的构造吗,我还没讲呢,我们先搞明白 MQ 的内部构造,再来看看如何解决上面的一系列问题吧,不过你最好带着问题去阅读和了解喔。\nRocketMQ 是一个 队列模型 的消息中间件,具有高性能、高可靠、高实时、分布式 的特点。它是一个采用 Java 语言开发的分布式的消息系统,由阿里巴巴团队开发,在2016年底贡献给 Apache,成为了 Apache 的一个顶级项目。 在阿里内部,RocketMQ 很好地服务了集团大大小小上千个应用,在每年的双十一当天,更有不可思议的万亿级消息通过 RocketMQ 流转。\n废话不多说,想要了解 RocketMQ 历史的同学可以自己去搜寻资料。听完上面的介绍,你只要知道 RocketMQ 很快、很牛、而且经历过双十一的实践就行了!\n# 队列模型和主题模型 # 在谈 RocketMQ 的技术架构之前,我们先来了解一下两个名词概念——队列模型 和 主题模型 。\n首先我问一个问题,消息队列为什么要叫消息队列?\n你可能觉得很弱智,这玩意不就是存放消息的队列嘛?不叫消息队列叫什么?\n的确,早期的消息中间件是通过 队列 这一模型来实现的,可能是历史原因,我们都习惯把消息中间件称为消息队列。\n但是,如今例如 RocketMQ 、Kafka 这些优秀的消息中间件不仅仅是通过一个 队列 来实现消息存储的。\n# 队列模型 # 就像我们理解队列一样,消息中间件的队列模型就真的只是一个队列。。。我画一张图给大家理解。\n在一开始我跟你提到了一个 “广播” 的概念,也就是说如果我们此时我们需要将一个消息发送给多个消费者(比如此时我需要将信息发送给短信系统和邮件系统),这个时候单个队列即不能满足需求了。\n当然你可以让 Producer 生产消息放入多个队列中,然后每个队列去对应每一个消费者。问题是可以解决,创建多个队列并且复制多份消息是会很影响资源和性能的。而且,这样子就会导致生产者需要知道具体消费者个数然后去复制对应数量的消息队列,这就违背我们消息中间件的 解耦 这一原则。\n# 主题模型 # 那么有没有好的方法去解决这一个问题呢?有,那就是 主题模型 或者可以称为 发布订阅模型 。\n感兴趣的同学可以去了解一下设计模式里面的观察者模式并且手动实现一下,我相信你会有所收获的。\n在主题模型中,消息的生产者称为 发布者(Publisher) ,消息的消费者称为 订阅者(Subscriber) ,存放消息的容器称为 主题(Topic) 。\n其中,发布者将消息发送到指定主题中,订阅者需要 提前订阅主题 才能接受特定主题的消息。\n# RocketMQ中的消息模型 # RocketMQ 中的消息模型就是按照 主题模型 所实现的。你可能会好奇这个 主题 到底是怎么实现的呢?你上面也没有讲到呀!\n其实对于主题模型的实现来说每个消息中间件的底层设计都是不一样的,就比如 Kafka 中的 分区 ,RocketMQ 中的 队列 ,RabbitMQ 中的 Exchange 。我们可以理解为 主题模型/发布订阅模型 就是一个标准,那些中间件只不过照着这个标准去实现而已。\n所以,RocketMQ 中的 主题模型 到底是如何实现的呢?首先我画一张图,大家尝试着去理解一下。\n我们可以看到在整个图中有 Producer Group 、Topic 、Consumer Group 三个角色,我来分别介绍一下他们。\nProducer Group 生产者组: 代表某一类的生产者,比如我们有多个秒杀系统作为生产者,这多个合在一起就是一个 Producer Group 生产者组,它们一般生产相同的消息。 Consumer Group 消费者组: 代表某一类的消费者,比如我们有多个短信系统作为消费者,这多个合在一起就是一个 Consumer Group 消费者组,它们一般消费相同的消息。 Topic 主题: 代表一类消息,比如订单消息,物流消息等等。 你可以看到图中生产者组中的生产者会向主题发送消息,而 主题中存在多个队列,生产者每次生产消息之后是指定主题中的某个队列发送消息的。\nly:我的理解是,一般情况下,同一个生产者组生产的消息,会发到同一个topic中\n每个主题中都有多个队列(这里还不涉及到 Broker),集群消费模式下,一个消费者集群多台机器共同消费一个 topic 的多个队列,一个队列只会被一个消费者消费。如果某个消费者挂掉,分组内其它消费者会接替挂掉的消费者继续消费。就像上图中 Consumer1 和 Consumer2 分别对应着两个队列,而 Consumer3 是没有队列对应的,所以一般来讲要控制 消费者组中的消费者个数和主题中队列个数相同 。\n当然也可以消费者个数小于队列个数,只不过不太建议。如下图。\n每个消费组在每个队列上维护一个消费位置 ,为什么呢?\n因为我们刚刚画的仅仅是一个消费者组,我们知道在发布订阅模式中一般会涉及到多个消费者组,而每个消费者组在每个队列中的消费位置都是不同的。如果此时有多个消费者组,那么消息被一个消费者组消费完之后是不会删除的(因为其它消费者组也需要呀 【注意重点,是消费完之后】),它仅仅是为每个消费者组维护一个 消费位移(offset) ,每次消费者组消费完会返回一个成功的响应,然后队列再把维护的消费位移加一,这样就不会出现刚刚消费过的消息再一次被消费了。\n可能你还有一个问题,为什么一个主题中需要维护多个队列 ?\n答案是 提高并发能力 。的确,每个主题中只存在一个队列也是可行的。你想一下,如果每个主题中只存在一个队列,这个队列中也维护着每个消费者组的消费位置,这样也可以做到 发布订阅模式 。如下图。\n但是,这样我生产者是不是只能向一个队列发送消息?又因为需要维护消费位置所以一个队列只能对应一个消费者组中的消费者,这样是不是其他的 Consumer 就没有用武之地了?从这两个角度来讲,并发度一下子就小了很多。\n所以总结来说,RocketMQ 通过使用在一个 Topic 中配置多个队列并且每个队列维护每个消费者组的消费位置 实现了 主题模式/发布订阅模式 。\n# RocketMQ的架构图 # 讲完了消息模型,我们理解起 RocketMQ 的技术架构起来就容易多了。\nRocketMQ 技术架构中有四大角色 NameServer 、Broker 、Producer 、Consumer 。我来向大家分别解释一下这四个角色是干啥的。\nBroker: 主要负责消息的存储、投递和查询以及服务高可用保证。说白了就是消息队列服务器嘛,生产者生产消息到 Broker ,消费者从 Broker 拉取消息并消费。\n这里,我还得普及一下关于 Broker 、Topic 和 队列的关系。上面我讲解了 Topic 和队列的关系——一个 Topic 中存在多个队列,那么这个 Topic 和队列存放在哪呢?\n一个 Topic 分布在多个 Broker上,一个 Broker 可以配置多个 Topic ,它们是多对多的关系。\n如果某个 Topic 消息量很大,应该给它多配置几个队列(上文中提到了提高并发能力),并且 尽量多分布在不同 Broker 上,以减轻某个 Broker 的压力 。\nTopic 消息量都比较均匀的情况下,如果某个 broker 上的队列越多,则该 broker 压力越大。\n感觉下面这个图有一点点误解,就是Queue 重复(比如多个Queue),我觉得同一个Queue不会分布在多个Topic上面的\n所以说我们需要配置多个Broker。\nNameServer: 不知道你们有没有接触过 ZooKeeper 和 Spring Cloud 中的 Eureka ,它其实也是一个 注册中心 ,主要提供两个功能:Broker管理 和 路由信息管理 。说白了就是 Broker 会将自己的信息注册到 NameServer 中,此时 NameServer 就存放了很多 Broker 的信息(Broker的路由表),消费者和生产者就从 NameServer 中获取路由表然后照着路由表的信息和对应的 Broker 进行通信(生产者和消费者定期会向 NameServer 去查询相关的 Broker 的信息)。\nProducer: 消息发布的角色,支持分布式集群方式部署。说白了就是生产者。\nConsumer: 消息消费的角色,支持分布式集群方式部署。支持以push推,pull拉两种模式对消息进行消费。同时也支持集群方式和广播方式的消费,它提供实时消息订阅机制。说白了就是消费者。\n听完了上面的解释你可能会觉得,这玩意好简单。不就是这样的么?\n嗯?你可能会发现一个问题,这老家伙 NameServer 干啥用的,这不多余吗?直接 Producer 、Consumer 和 Broker 直接进行生产消息,消费消息不就好了么?\n但是,我们上文提到过 Broker 是需要保证高可用的,如果整个系统仅仅靠着一个 Broker 来维持的话,那么这个 Broker 的压力会不会很大?所以我们需要使用多个 Broker 来保证 负载均衡 。\n如果说,我们的消费者和生产者直接和多个 Broker 相连,那么当 Broker 修改的时候必定会牵连着每个生产者和消费者,这样就会产生耦合问题,而 NameServer 注册中心就是用来解决这个问题的。\n如果还不是很理解的话,可以去看我介绍 Spring Cloud 的那篇文章,其中介绍了 Eureka 注册中心。\n当然,RocketMQ 中的技术架构肯定不止前面那么简单,因为上面图中的四个角色都是需要做集群的。我给出一张官网的架构图,大家尝试理解一下。\n其实和我们最开始画的那张乞丐版的架构图也没什么区别,主要是一些细节上的差别。听我细细道来🤨。\n第一、我们的 Broker 做了集群并且还进行了主从部署 ,由于消息分布在各个 Broker 上,一旦某个 Broker 宕机,则该Broker 上的消息读写都会受到影响。所以 Rocketmq 提供了 master/slave 的结构, salve 定时从 master 同步数据(同步刷盘或者异步刷盘),如果 master 宕机,则 slave 提供消费服务,但是不能写入消息 (后面我还会提到哦)。\n第二、为了保证 HA ,我们的 NameServer 也做了集群部署,但是请注意它是 去中心化 的。也就意味着它没有主节点,你可以很明显地看出 NameServer 的所有节点是没有进行 Info Replicate 的,在 RocketMQ 中是通过 单个Broker和所有NameServer保持长连接 ,并且在每隔30秒 Broker 会向所有 Nameserver 发送心跳,心跳包含了自身的 Topic 配置信息,这个步骤就对应这上面的 Routing Info 。\n第三、在生产者需要向 Broker 发送消息的时候,需要先从 NameServer 获取关于 Broker 的路由信息,然后通过 轮询 的方法去向每个队列中生产数据以达到 负载均衡 的效果。\n第四、消费者通过 NameServer 获取所有 Broker 的路由信息后,向 Broker 发送 Pull 请求来获取消息数据。Consumer 可以以两种模式启动—— 广播(Broadcast)和集群(Cluster)。广播模式下,一条消息会发送给 同一个消费组中的所有消费者 ,集群模式下消息只会发送给一个消费者。\n# 如何解决 顺序消费、重复消费 # 其实,这些东西都是我在介绍消息队列带来的一些副作用的时候提到的,也就是说,这些问题不仅仅挂钩于 RocketMQ ,而是应该每个消息中间件都需要去解决的。\n在上面我介绍 RocketMQ 的技术架构的时候我已经向你展示了 它是如何保证高可用的 ,这里不涉及运维方面的搭建,如果你感兴趣可以自己去官网上照着例子搭建属于你自己的 RocketMQ 集群。\n其实 Kafka 的架构基本和 RocketMQ 类似,只是它注册中心使用了 Zookeeper 、它的 分区 就相当于 RocketMQ 中的 队列 。还有一些小细节不同会在后面提到。\n# 顺序消费 # 在上面的技术架构介绍中,我们已经知道了 RocketMQ 在主题上是无序的、它只有在队列层面才是保证有序 的。\n这又扯到两个概念——普通顺序 和 严格顺序 。\n所谓普通顺序是指 消费者通过 同一个消费队列收到的消息是有顺序的 ,不同消息队列收到的消息则可能是无顺序的。普通顺序消息在 Broker 重启情况下不会保证消息顺序性 (短暂时间) 。\n所谓严格顺序是指 消费者收到的 所有消息 均是有顺序的。严格顺序消息 即使在异常情况下也会保证消息的顺序性 。\n但是,严格顺序看起来虽好,实现它可会付出巨大的代价。如果你使用严格顺序模式,Broker 集群中只要有一台机器不可用,则整个集群都不可用。你还用啥?现在主要场景也就在 binlog 同步。\n一般而言,我们的 MQ 都是能容忍短暂的乱序,所以推荐使用普通顺序模式。\n那么,我们现在使用了 普通顺序模式 ,我们从上面学习知道了在 Producer 生产消息的时候会进行轮询(取决你的负载均衡策略)来向同一主题的不同消息队列发送消息。那么如果此时我有几个消息分别是同一个订单的创建、支付、发货,在轮询的策略下这 三个消息会被发送到不同队列 ,因为在不同的队列此时就无法使用 RocketMQ 带来的队列有序特性来保证消息有序性了。\n那么,怎么解决呢?\n其实很简单,我们需要处理的仅仅是将同一语义下的消息放入同一个队列(比如这里是同一个订单),那我们就可以使用 Hash取模法 来保证同一个订单在同一个队列中就行了。\n# 重复消费 # emmm,就两个字—— 幂等 。在编程中一个幂等 操作的特点是其任意多次执行所产生的影响均与一次执行的影响相同。比如说,这个时候我们有一个订单的处理积分的系统,每当来一个消息的时候它就负责为创建这个订单的用户的积分加上相应的数值。可是有一次,消息队列发送给订单系统 FrancisQ 的订单信息,其要求是给 FrancisQ 的积分加上 500。但是积分系统在收到 FrancisQ 的订单信息处理完成之后返回给消息队列处理成功的信息的时候出现了网络波动(当然还有很多种情况,比如Broker意外重启等等),这条回应没有发送成功。\n那么,消息队列没收到积分系统的回应会不会尝试重发这个消息?问题就来了,我再发这个消息,万一它又给 FrancisQ 的账户加上 500 积分怎么办呢?\n所以我们需要给我们的消费者实现 幂等 ,也就是对同一个消息的处理结果,执行多少次都不变。\n那么如何给业务实现幂等呢?这个还是需要结合具体的业务的。你可以使用 写入 Redis 来保证,因为 Redis 的 key 和 value 就是天然支持幂等的(如果mq处理过就给redis设置值,而后每次mq处理之前查询一下redis才知道mq是否已经处理过)。当然还有使用 数据库插入法 ,基于数据库的唯一键来保证重复数据不会被插入多条。\n不过最主要的还是需要 根据特定场景使用特定的解决方案 ,你要知道你的消息消费是否是完全不可重复消费还是可以忍受重复消费的,然后再选择强校验和弱校验的方式。毕竟在 CS 领域还是很少有技术银弹的说法。\n而在整个互联网领域,幂等不仅仅适用于消息队列的重复消费问题,这些实现幂等的方法,也同样适用于,在其他场景中来解决重复请求或者重复调用的问题 。比如将HTTP服务设计成幂等的,解决前端或者APP重复提交表单数据的问题 ,也可以将一个微服务设计成幂等的,解决 RPC 框架自动重试导致的 重复调用问题 。\n# 分布式事务 # 如何解释分布式事务呢?事务大家都知道吧?要么都执行要么都不执行 。在同一个系统中我们可以轻松地实现事务,但是在分布式架构中,我们有很多服务是部署在不同系统之间的,而不同服务之间又需要进行调用。比如此时我下订单然后增加积分,如果保证不了分布式事务的话,就会出现A系统下了订单,但是B系统增加积分失败或者A系统没有下订单,B系统却增加了积分。前者对用户不友好,后者对运营商不利,这是我们都不愿意见到的。\n那么,如何去解决这个问题呢?\n如今比较常见的分布式事务实现有 2PC、TCC 和事务消息(half 半消息机制)。每一种实现都有其特定的使用场景,但是也有各自的问题,都不是完美的解决方案。\n在 RocketMQ 中使用的是 事务消息加上事务反查机制 来解决分布式事务问题的。我画了张图,大家可以对照着图进行理解。\n在第一步发送的 half 消息 ,它的意思是 在事务提交之前,对于消费者来说,这个消息是不可见的 。\n那么,如何做到写入消息但是对用户不可见呢?RocketMQ事务消息的做法是:如果消息是half消息,将备份原消息的主题与消息消费队列,然后 改变主题 为RMQ_SYS_TRANS_HALF_TOPIC。由于消费组未订阅该主题,故消费端无法消费half类型的消息,然后RocketMQ会开启一个定时任务,从Topic为RMQ_SYS_TRANS_HALF_TOPIC中拉取消息进行消费,根据生产者组获取一个服务提供者发送回查事务状态请求,根据事务状态来决定是提交或回滚消息。\n你可以试想一下,如果没有从第5步开始的 事务反查机制 ,如果出现网路波动第4步没有发送成功,这样就会产生 MQ 不知道是不是需要给消费者消费的问题,他就像一个无头苍蝇一样。在 RocketMQ 中就是使用的上述的事务反查来解决的,而在 Kafka 中通常是直接抛出一个异常让用户来自行解决。\n你还需要注意的是,在 MQ Server 指向系统B的操作已经和系统A不相关了,也就是说在消息队列中的分布式事务是——本地事务和存储消息到消息队列才是同一个事务。这样也就产生了事务的最终一致性,因为整个过程是异步的,每个系统只要保证它自己那一部分的事务就行了。 # 消息堆积问题 # 在上面我们提到了消息队列一个很重要的功能——削峰 。那么如果这个峰值太大了导致消息堆积在队列中怎么办呢?\n其实这个问题可以将它广义化,因为产生消息堆积的根源其实就只有两个——生产者生产太快或者消费者消费太慢。\n我们可以从多个角度去思考解决这个问题,当流量到峰值的时候是因为生产者生产太快,我们可以使用一些 限流降级 的方法,当然你也可以增加多个消费者实例去水平扩展增加消费能力来匹配生产的激增。如果消费者消费过慢的话,我们可以先检查 是否是消费者出现了大量的消费错误 ,或者打印一下日志查看是否是哪一个线程卡死,出现了锁资源不释放等等的问题。\n当然,最快速解决消息堆积问题的方法还是增加消费者实例,不过 同时你还需要增加每个主题的队列数量 。\n别忘了在 RocketMQ 中,一个队列只会被一个消费者消费 ,如果你仅仅是增加消费者实例就会出现我一开始给你画架构图的那种情况。\n# 回溯消费 # 回溯消费是指 Consumer 已经消费成功的消息,由于业务上需求需要重新消费,在RocketMQ 中, Broker 在向Consumer 投递成功消息后,消息仍然需要保留 。并且重新消费一般是按照时间维度,例如由于 Consumer 系统故障,恢复后需要重新消费1小时前的数据,那么 Broker 要提供一种机制,可以按照时间维度来回退消费进度。RocketMQ 支持按照时间回溯消费,时间维度精确到毫秒。\n这是官方文档的解释,我直接照搬过来就当科普了😁😁😁。\n# RocketMQ 的刷盘机制 # 上面我讲了那么多的 RocketMQ 的架构和设计原理,你有没有好奇\n在 Topic 中的 队列是以什么样的形式存在的?\n队列中的消息又是如何进行存储持久化的呢?\n我在上文中提到的 同步刷盘 和 异步刷盘 又是什么呢?它们会给持久化带来什么样的影响呢?\n下面我将给你们一一解释。\n# 同步刷盘和异步刷盘 # 如上图所示,在同步刷盘中需要等待一个刷盘成功的 ACK ,同步刷盘对 MQ 消息可靠性来说是一种不错的保障,但是 性能上会有较大影响 ,一般地适用于金融等特定业务场景。\n而异步刷盘往往是开启一个线程去异步地执行刷盘操作。消息刷盘采用后台异步线程提交的方式进行, 降低了读写延迟 ,提高了 MQ 的性能和吞吐量,一般适用于如发验证码等对于消息保证要求不太高的业务场景。\n一般地,异步刷盘只有在 Broker 意外宕机的时候会丢失部分数据,你可以设置 Broker 的参数 FlushDiskType 来调整你的刷盘策略(ASYNC_FLUSH 或者 SYNC_FLUSH)。\n# 同步复制和异步复制 # 上面的同步刷盘和异步刷盘是在单个结点层面的,而同步复制和异步复制主要是指的 Borker 主从模式下,主节点返回消息给客户端的时候是否需要同步从节点。\n同步复制: 也叫 “同步双写”,也就是说,只有消息同步双写到主从节点上时才返回写入成功 。 异步复制: 消息写入主节点之后就直接返回写入成功 。 然而,很多事情是没有完美的方案的,就比如我们进行消息写入的节点越多就更能保证消息的可靠性,但是随之的性能也会下降,所以需要程序员根据特定业务场景去选择适应的主从复制方案。\n那么,异步复制会不会也像异步刷盘那样影响消息的可靠性呢?\n答案是不会的,因为两者就是不同的概念,对于消息可靠性是通过不同的刷盘策略保证的,而像异步同步复制策略仅仅是影响到了 可用性 。为什么呢?其主要原因是 RocketMQ 是不支持自动主从切换的,当主节点挂掉之后,生产者就不能再给这个主节点生产消息了。\n比如这个时候采用异步复制的方式,在主节点还未发送完需要同步的消息的时候主节点挂掉了,这个时候从节点就少了一部分消息。但是此时生产者无法再给主节点生产消息了,消费者可以自动切换到从节点进行消费(仅仅是消费),所以在主节点挂掉的时间只会产生主从结点短暂的消息不一致的情况,降低了可用性,而当主节点重启之后,从节点那部分未来得及复制的消息还会继续复制。\n在单主从架构中,如果一个主节点挂掉了,那么也就意味着整个系统不能再生产了。那么这个可用性的问题能否解决呢?一个主从不行那就多个主从的呗,别忘了在我们最初的架构图中,每个 Topic 是分布在不同 Broker 中的。\n但是这种复制方式同样也会带来一个问题,那就是无法保证 严格顺序 。在上文中我们提到了如何保证的消息顺序性是通过将一个语义的消息发送在同一个队列中,使用 Topic 下的队列来保证顺序性的。如果此时我们主节点A负责的是订单A的一系列语义消息,然后它挂了,这样其他节点是无法代替主节点A的,如果我们任意节点都可以存入任何消息,那就没有顺序性可言了。\n而在 RocketMQ 中采用了 Dledger 解决这个问题。他要求在写入消息的时候,要求至少消息复制到半数以上的节点之后,才给客⼾端返回写⼊成功,并且它是⽀持通过选举来动态切换主节点的。这里我就不展开说明了,读者可以自己去了解。\n也不是说 Dledger 是个完美的方案,至少在 Dledger 选举过程中是无法提供服务的,而且他必须要使用三个节点或以上,如果多数节点同时挂掉他也是无法保证可用性的,而且要求消息复制半数以上节点的效率和直接异步复制还是有一定的差距的。\n# 存储机制 # ly:详细的有点复杂,暂时跳过。大概就是有三个东西,CommitLog(实际存储消息的东西),ConsumeQueue(相当于CommitLog的索引)\n还记得上面我们一开始的三个问题吗?到这里第三个问题已经解决了。\n但是,在 Topic 中的 队列是以什么样的形式存在的?队列中的消息又是如何进行存储持久化的呢? 还未解决,其实这里涉及到了 RocketMQ 是如何设计它的存储结构了。我首先想大家介绍 RocketMQ 消息存储架构中的三大角色——CommitLog 、ConsumeQueue 和 IndexFile 。\nCommitLog: 消息主体以及元数据的存储主体,存储 Producer 端写入的消息主体内容,消息内容不是定长的。单个文件大小默认1G ,文件名长度为20位,左边补零,剩余为起始偏移量,比如00000000000000000000代表了第一个文件,起始偏移量为0,文件大小为1G=1073741824;当第一个文件写满了,第二个文件为00000000001073741824,起始偏移量为1073741824,以此类推。消息主要是顺序写入日志文件,当文件满了,写入下一个文件。\nConsumeQueue: 消息消费队列,引入的目的主要是提高消息消费的性能(我们再前面也讲了),由于RocketMQ 是基于主题 Topic 的订阅模式,消息消费是针对主题进行的,如果要遍历 commitlog 文件中根据 Topic 检索消息是非常低效的。Consumer 即可根据 ConsumeQueue 来查找待消费的消息。其中,ConsumeQueue(逻辑消费队列)作为消费消息的索引,保存了指定 Topic 下的队列消息在 CommitLog 中的起始物理偏移量 offset *,消息大小 size 和消息 Tag 的 HashCode 值。*consumequeue 文件可以看成是基于 topic 的 commitlog 索引文件,故 consumequeue 文件夹的组织方式如下:topic/queue/file三层组织结构,具体存储路径为:$HOME/store/consumequeue/{topic}/{queueId}/{fileName}。同样 consumequeue 文件采取定长设计,每一个条目共20个字节,分别为8字节的 commitlog 物理偏移量、4字节的消息长度、8字节tag hashcode,单个文件由30W个条目组成,可以像数组一样随机访问每一个条目,每个 ConsumeQueue文件大小约5.72M;\n保存了指定 Topic 下的队列消息在 CommitLog 中的**起始物理偏移量 offset **,消息大小 size 和消息 Tag 的 HashCode 值\nIndexFile: IndexFile(索引文件)提供了一种可以通过key或时间区间来查询消息的方法。这里只做科普不做详细介绍。\n总结来说,整个消息存储的结构,最主要的就是 CommitLoq 和 ConsumeQueue 。而 ConsumeQueue 你可以大概理解为 Topic 中的队列。\n我的理解是,通过ConsumeQueue files去查询CommitLog\nRocketMQ 采用的是 混合型的存储结构 ,即为 Broker 单个实例下所有的队列共用一个日志数据文件来存储消息。有意思的是在同样高并发的 Kafka 中会为每个 Topic 分配一个存储文件。这就有点类似于我们有一大堆书需要装上书架,RockeMQ 是不分书的种类直接成批的塞上去的,而 Kafka 是将书本放入指定的分类区域的。\n而 RocketMQ 为什么要这么做呢?原因是 提高数据的写入效率 ,不分 Topic 意味着我们有更大的几率获取 成批 的消息进行数据写入,但也会带来一个麻烦就是读取消息的时候需要遍历整个大文件,这是非常耗时的。\n所以,在 RocketMQ 中又使用了 ConsumeQueue 作为每个队列的索引文件来 提升读取消息的效率。我们可以直接根据队列的消息序号,计算出索引的全局位置(索引序号*索引固定⻓度20),然后直接读取这条索引,再根据索引中记录的消息的全局位置,找到消息。\n讲到这里,你可能对 RockeMQ 的存储架构还有些模糊,没事,我们结合着图来理解一下。\nemmm,是不是有一点复杂🤣,看英文图片和英文文档的时候就不要怂,硬着头皮往下看就行。\n如果上面没看懂的读者一定要认真看下面的流程分析!\n首先,在最上面的那一块就是我刚刚讲的你现在可以直接 把 ConsumerQueue 理解为 Queue。\n在图中最左边说明了红色方块代表被写入的消息,虚线方块代表等待被写入的。左边的生产者发送消息会指定 Topic 、QueueId 和具体消息内容,而在 Broker 中管你是哪门子消息,他直接 全部顺序存储到了 CommitLog。而根据生产者指定的 Topic 和 QueueId 将这条消息本身在 CommitLog 的偏移(offset),消息本身大小,和tag的hash值存入对应的 ConsumeQueue 索引文件中。而在每个队列中都保存了 ConsumeOffset 即每个消费者组的消费位置(我在架构那里提到了,忘了的同学可以回去看一下),而消费者拉取消息进行消费的时候只需要根据 ConsumeOffset 获取下一个未被消费的消息就行了。\n上述就是我对于整个消息存储架构的大概理解(这里不涉及到一些细节讨论,比如稀疏索引等等问题),希望对你有帮助。\n因为有一个知识点因为写嗨了忘讲了,想想在哪里加也不好,所以我留给大家去思考🤔🤔一下吧。\n为什么 CommitLog 文件要设计成固定大小的长度呢?提醒:内存映射机制。\n# 总结 # 总算把这篇博客写完了。我讲的你们还记得吗😅?\n这篇文章中我主要想大家介绍了\n消息队列出现的原因 消息队列的作用(异步,解耦,削峰) 消息队列带来的一系列问题(消息堆积、重复消费、顺序消费、分布式事务等等) 消息队列的两种消息模型——队列和主题模式 分析了 RocketMQ 的技术架构(NameServer 、Broker 、Producer 、Comsumer) 结合 RocketMQ 回答了消息队列副作用的解决方案 介绍了 RocketMQ 的存储机制和刷盘策略。 等等。。。\n如果喜欢可以点赞哟👍👍👍。\n著作权归所有 原文链接:https://javaguide.cn/high-performance/message-queue/rocketmq-intro.html\n"},{"id":268,"href":"/zh/docs/technology/Review/java_guide/lyely_high-performance/message-mq/base/","title":"message-queue","section":"RocketMQ","content":" 转载自https://github.com/Snailclimb/JavaGuide(添加小部分笔记)感谢作者!\n“RabbitMQ?”“Kafka?”“RocketMQ?”\u0026hellip;在日常学习与开发过程中,我们常常听到消息队列这个关键词。我也在我的多篇文章中提到了这个概念。可能你是熟练使用消息队列的老手,又或者你是不懂消息队列的新手,不论你了不了解消息队列,本文都将带你搞懂消息队列的一些基本理论。\n如果你是老手,你可能从本文学到你之前不曾注意的一些关于消息队列的重要概念,如果你是新手,相信本文将是你打开消息队列大门的一板砖。\n什么是消息队列? # 我们可以把消息队列看作是一个存放消息的容器,当我们需要使用消息的时候,直接从容器中取出消息供自己使用即可。由于队列 Queue 是一种先进先出的数据结构,所以消费消息时也是按照顺序来消费的。\n参与消息传递的双方称为生产者和消费者,生产者负责发送消息,消费者负责处理消息。\n我们知道操作系统中的进程通信的一种很重要的方式就是消息队列。我们这里提到的消息队列稍微有点区别,更多指的是各个服务以及系统内部各个组件/模块之前的通信,属于一种中间件。\n随着分布式和微服务系统的发展,消息队列在系统设计中有了更大的发挥空间,使用消息队列可以降低系统耦合性、实现任务异步、有效地进行流量削峰,是分布式和微服务系统中重要的组件之一。\n消息队列有什么用? # 通常来说,使用消息队列能为我们的系统带来下面三点好处:\n通过异步处理提高系统性能(减少响应所需时间)。 削峰/限流 降低系统耦合性。 如果在面试的时候你被面试官问到这个问题的话,一般情况是你在你的简历上涉及到消息队列这方面的内容,这个时候推荐你结合你自己的项目来回答。\n通过异步处理提高系统性能(减少响应所需时间) # 将用户的请求数据存储到消息队列之后就立即返回结果。随后,系统再对消息进行消费。\n因为用户请求数据写入消息队列之后就立即返回给用户了,但是请求数据在后续的业务校验、写数据库等操作中可能失败。因此,使用消息队列进行异步处理之后,需要适当修改业务流程进行配合,比如用户在提交订单之后,订单数据写入消息队列,不能立即返回用户订单提交成功,需要在消息队列的订单消费者进程真正处理完该订单之后,甚至出库后,再通过电子邮件或短信通知用户订单成功,以免交易纠纷。这就类似我们平时手机订火车票和电影票。\n削峰/限流 # 先将短时间高并发产生的事务消息存储在消息队列中,然后后端服务再慢慢根据自己的能力去消费这些消息,这样就避免直接把后端服务打垮掉。\n举例:在电子商务一些秒杀、促销活动中,合理使用消息队列可以有效抵御促销活动刚开始大量订单涌入对系统的冲击。如下图所示:\n降低系统耦合性 # 使用消息队列还可以降低系统耦合性。我们知道如果模块之间不存在直接调用,那么新增模块或者修改模块就对其他模块影响较小,这样系统的可扩展性无疑更好一些。还是直接上图吧:\n生产者(客户端)发送消息到消息队列中去,接受者(服务端)处理消息,需要消费的系统直接去消息队列取消息进行消费即可而不需要和其他系统有耦合,这显然也提高了系统的扩展性。\n消息队列使用发布-订阅模式工作,消息发送者(生产者)发布消息,一个或多个消息接受者(消费者)订阅消息。 从上图可以看到消息发送者(生产者)和消息接受者(消费者)之间没有直接耦合,消息发送者将消息发送至分布式消息队列即结束对消息的处理,消息接受者从分布式消息队列获取该消息后进行后续处理,并不需要知道该消息从何而来。对新增业务,只要对该类消息感兴趣,即可订阅该消息,对原有系统和业务没有任何影响,从而实现网站业务的可扩展性设计。\n消息接受者对消息进行过滤、处理、包装后,构造成一个新的消息类型,将消息继续发送出去,等待其他消息接受者订阅该消息。因此基于事件(消息对象)驱动的业务架构可以是一系列流程。\n另外,为了避免消息队列服务器宕机造成消息丢失,会将成功发送到消息队列的消息存储在消息生产者服务器上,等消息真正被消费者服务器处理后才删除消息。在消息队列服务器宕机后,生产者服务器会选择分布式消息队列服务器集群中的其他服务器发布消息。\n备注: 不要认为消息队列只能利用发布-订阅模式工作,只不过在解耦这个特定业务环境下是使用发布-订阅模式的。除了发布-订阅模式,还有点对点订阅模式(一个消息只有一个消费者),我们比较常用的是发布-订阅模式。另外,这两种消息模型是 JMS 提供的,AMQP 协议还提供了 5 种消息模型。\n使用消息队列哪些问题? # 系统可用性降低: 系统可用性在某种程度上降低,为什么这样说呢?在加入 MQ 之前,你不用考虑消息丢失或者说 MQ 挂掉等等的情况,但是,引入 MQ 之后你就需要去考虑了! 系统复杂性提高: 加入 MQ 之后,你需要保证消息没有被重复消费、处理消息丢失的情况、保证消息传递的顺序性等等问题! 一致性问题: 我上面讲了消息队列可以实现异步,消息队列带来的异步确实可以提高系统响应速度。但是,万一消息的真正消费者并没有正确消费消息怎么办?这样就会导致数据不一致的情况了! JMS 和 AMQP # JMS 是什么? # JMS(JAVA Message Service,java 消息服务)是 java 的消息服务,JMS 的客户端之间可以通过 JMS 服务进行异步的消息传输。JMS(JAVA Message Service,Java 消息服务)API 是一个消息服务的标准或者说是规范,允许应用程序组件基于 JavaEE 平台创建、发送、接收和读取消息。它使分布式通信耦合度更低,消息服务更加可靠以及异步性。\nJMS 定义了五种不同的消息正文格式以及调用的消息类型,允许你发送并接收以一些不同形式的数据:\nStreamMessage:Java 原始值的数据流 MapMessage:一套名称-值对 TextMessage:一个字符串对象 ObjectMessage:一个序列化的 Java 对象 BytesMessage:一个字节的数据流 ActiveMQ(已被淘汰) 就是基于 JMS 规范实现的。\nJMS 两种消息模型 # 点到点(P2P)模型 # 使用**队列(Queue)*作为消息通信载体;满足*生产者与消费者模式,一条消息只能被一个消费者使用,未被消费的消息在队列中保留直到被消费或超时。比如:我们生产者发送 100 条消息的话,两个消费者来消费一般情况下两个消费者会按照消息发送的顺序各自消费一半(也就是你一个我一个的消费。)\n发布/订阅(Pub/Sub)模型 # 发布订阅模型(Pub/Sub) 使用**主题(Topic)*作为消息通信载体,类似于*广播模式;发布者发布一条消息,该消息通过主题传递给所有的订阅者,在一条消息广播之后才订阅的用户则是收不到该条消息的。\nAMQP 是什么? # AMQP,即 Advanced Message Queuing Protocol,一个提供统一消息服务的应用层标准 高级消息队列协议(二进制应用层协议),是应用层协议的一个开放标准,为面向消息的中间件设计,兼容 JMS。基于此协议的客户端与消息中间件可传递消息,并不受客户端/中间件同产品,不同的开发语言等条件的限制。\nRabbitMQ 就是基于 AMQP 协议实现的。\nJMS vs AMQP # 对比方向 JMS AMQP 定义 Java API 协议 跨语言 否 是 跨平台 否 是 支持消息类型 提供两种消息模型:①Peer-2-Peer;②Pub/sub 提供了五种消息模型:①direct exchange;②fanout exchange;③topic change;④headers exchange;⑤system exchange。本质来讲,后四种和 JMS 的 pub/sub 模型没有太大差别,仅是在路由机制上做了更详细的划分; 支持消息类型 支持多种消息类型 ,我们在上面提到过 byte[](二进制) 总结:\nAMQP 为消息定义了线路层(wire-level protocol)的协议,而 JMS 所定义的是 API 规范。在 Java 体系中,多个 client 均可以通过 JMS 进行交互,不需要应用修改代码,但是其对跨平台的支持较差。而 AMQP 天然具有跨平台、跨语言特性。 JMS 支持 TextMessage、MapMessage 等复杂的消息类型;而 AMQP 仅支持 byte[] 消息类型(复杂的类型可序列化后发送)。 由于 Exchange 提供的路由算法,AMQP 可以提供多样化的路由方式来传递消息到消息队列,而 JMS 仅支持 队列 和 主题/订阅 方式两种。 消息队列技术选型 # 常见的消息队列有哪些? # Kafka # Kafka 是 LinkedIn 开源的一个分布式流式处理平台,已经成为 Apache 顶级项目,早期被用来用于处理海量的日志,后面才慢慢发展成了一款功能全面的高性能消息队列。\n流式处理平台具有三个关键功能:\n消息队列:发布和订阅消息流,这个功能类似于消息队列,这也是 Kafka 也被归类为消息队列的原因。 容错的持久方式存储记录消息流: Kafka 会把消息持久化到磁盘,有效避免了消息丢失的风险。 流式处理平台: 在消息发布的时候进行处理,Kafka 提供了一个完整的流式处理类库。 Kafka 是一个分布式系统,由通过高性能 TCP 网络协议进行通信的服务器和客户端组成,可以部署在在本地和云环境中的裸机硬件、虚拟机和容器上。\n在 Kafka 2.8 之前,Kafka 最被大家诟病的就是其重度依赖于 Zookeeper 做元数据管理和集群的高可用。在 Kafka 2.8 之后,引入了基于 Raft 协议的 KRaft 模式,不再依赖 Zookeeper,大大简化了 Kafka 的架构,让你可以以一种轻量级的方式来使用 Kafka。\n不过,要提示一下:如果要使用 KRaft 模式的话,建议选择较高版本的 Kafka,因为这个功能还在持续完善优化中。Kafka 3.3.1 版本是第一个将 KRaft(Kafka Raft)共识协议标记为生产就绪的版本。\nKafka 官网:http://kafka.apache.org/\nKafka 更新记录(可以直观看到项目是否还在维护):https://kafka.apache.org/downloads\nRocketMQ # RocketMQ 是阿里开源的一款云原生“消息、事件、流”实时数据处理平台,借鉴了 Kafka,已经成为 Apache 顶级项目。\nRocketMQ 的核心特性(摘自 RocketMQ 官网):\n云原生:生与云,长与云,无限弹性扩缩,K8s 友好 高吞吐:万亿级吞吐保证,同时满足微服务与大数据场景。 流处理:提供轻量、高扩展、高性能和丰富功能的流计算引擎。 金融级:金融级的稳定性,广泛用于交易核心链路。 架构极简:零外部依赖,Shared-nothing 架构。 生态友好:无缝对接微服务、实时计算、数据湖等周边生态。 根据官网介绍:\nApache RocketMQ 自诞生以来,因其架构简单、业务功能丰富、具备极强可扩展性等特点被众多企业开发者以及云厂商广泛采用。历经十余年的大规模场景打磨,RocketMQ 已经成为业内共识的金融级可靠业务消息首选方案,被广泛应用于互联网、大数据、移动互联网、物联网等领域的业务场景。\nRocketMQ 官网:https://rocketmq.apache.org/ (文档很详细,推荐阅读)\nRocketMQ 更新记录(可以直观看到项目是否还在维护):https://github.com/apache/rocketmq/releases\nRabbitMQ # RabbitMQ 是采用 Erlang 语言实现 AMQP(Advanced Message Queuing Protocol,高级消息队列协议)的消息中间件,它最初起源于金融系统,用于在分布式系统中存储转发消息。\nRabbitMQ 发展到今天,被越来越多的人认可,这和它在易用性、扩展性、可靠性和高可用性等方面的卓著表现是分不开的。RabbitMQ 的具体特点可以概括为以下几点:\n可靠性: RabbitMQ 使用一些机制来保证消息的可靠性,如持久化、传输确认及发布确认等。 灵活的路由: 在消息进入队列之前,通过交换器来路由消息。对于典型的路由功能,RabbitMQ 己经提供了一些内置的交换器来实现。针对更复杂的路由功能,可以将多个交换器绑定在一起,也可以通过插件机制来实现自己的交换器。这个后面会在我们讲 RabbitMQ 核心概念的时候详细介绍到。 扩展性: 多个 RabbitMQ 节点可以组成一个集群,也可以根据实际业务情况动态地扩展集群中节点。 高可用性: 队列可以在集群中的机器上设置镜像,使得在部分节点出现问题的情况下队列仍然可用。 支持多种协议: RabbitMQ 除了原生支持 AMQP 协议,还支持 STOMP、MQTT 等多种消息中间件协议。 多语言客户端: RabbitMQ 几乎支持所有常用语言,比如 Java、Python、Ruby、PHP、C#、JavaScript 等。 易用的管理界面: RabbitMQ 提供了一个易用的用户界面,使得用户可以监控和管理消息、集群中的节点等。在安装 RabbitMQ 的时候会介绍到,安装好 RabbitMQ 就自带管理界面。 插件机制: RabbitMQ 提供了许多插件,以实现从多方面进行扩展,当然也可以编写自己的插件。感觉这个有点类似 Dubbo 的 SPI 机制 RabbitMQ 官网:https://www.rabbitmq.com/ 。\nRabbitMQ 更新记录(可以直观看到项目是否还在维护):https://www.rabbitmq.com/news.html\nPulsar # Pulsar 是下一代云原生分布式消息流平台,最初由 Yahoo 开发 ,已经成为 Apache 顶级项目。\nPulsar 集消息、存储、轻量化函数式计算为一体,采用计算与存储分离架构设计,支持多租户、持久化存储、多机房跨区域数据复制,具有强一致性、高吞吐、低延时及高可扩展性等流数据存储特性,被看作是云原生时代实时消息流传输、存储和计算最佳解决方案。\nPulsar 的关键特性如下(摘自官网):\n是下一代云原生分布式消息流平台。 Pulsar 的单个实例原生支持多个集群,可跨机房在集群间无缝地完成消息复制。 极低的发布延迟和端到端延迟。 可无缝扩展到超过一百万个 topic。 简单的客户端 API,支持 Java、Go、Python 和 C++。 主题的多种订阅模式(独占、共享和故障转移)。 通过 Apache BookKeeper 提供的持久化消息存储机制保证消息传递 。 由轻量级的 serverless 计算框架 Pulsar Functions 实现流原生的数据处理。 基于 Pulsar Functions 的 serverless connector 框架 Pulsar IO 使得数据更易移入、移出 Apache Pulsar。 分层式存储可在数据陈旧时,将数据从热存储卸载到冷/长期存储(如 S3、GCS)中。 Pulsar 官网:https://pulsar.apache.org/\nPulsar 更新记录(可以直观看到项目是否还在维护):https://github.com/apache/pulsar/releases\nActiveMQ # 目前已经被淘汰,不推荐使用,不建议学习。\n如何选择? # 参考《Java 工程师面试突击第 1 季-中华石杉老师》\n对比方向 概要 吞吐量 万级的 ActiveMQ 和 RabbitMQ 的吞吐量(ActiveMQ 的性能最差)要比十万级甚至是百万级的 RocketMQ 和 Kafka 低一个数量级。 可用性 都可以实现高可用。ActiveMQ 和 RabbitMQ 都是基于主从架构实现高可用性。RocketMQ 基于分布式架构。 Kafka 也是分布式的,一个数据多个副本,少数机器宕机,不会丢失数据,不会导致不可用 时效性 RabbitMQ 基于 Erlang 开发,所以并发能力很强,性能极其好,延时很低,达到微秒级,其他几个都是 ms 级。 功能支持 Pulsar 的功能更全面,支持多租户、多种消费模式和持久性模式等功能,是下一代云原生分布式消息流平台。 消息丢失 ActiveMQ 和 RabbitMQ 丢失的可能性非常低, Kafka、RocketMQ 和 Pulsar 理论上可以做到 0 丢失。 总结:\nActiveMQ 的社区算是比较成熟,但是较目前来说,ActiveMQ 的性能比较差,而且版本迭代很慢,不推荐使用,已经被淘汰了。 RabbitMQ 在吞吐量方面虽然稍逊于 Kafka 、RocketMQ 和 Pulsar,但是由于它基于 Erlang 开发,所以并发能力很强,性能极其好,延时很低,达到微秒级。但是也因为 RabbitMQ 基于 Erlang 开发,所以国内很少有公司有实力做 Erlang 源码级别的研究和定制。如果业务场景对并发量要求不是太高(十万级、百万级),那这几种消息队列中,RabbitMQ 或许是你的首选。 RocketMQ 和 Pulsar 支持强一致性,对消息一致性要求比较高的场景可以使用。 RocketMQ 阿里出品,Java 系开源项目,源代码我们可以直接阅读,然后可以定制自己公司的 MQ,并且 RocketMQ 有阿里巴巴的实际业务场景的实战考验。 Kafka 的特点其实很明显,就是仅仅提供较少的核心功能,但是提供超高的吞吐量,ms 级的延迟,极高的可用性以及可靠性,而且分布式可以任意扩展。同时 kafka 最好是支撑较少的 topic 数量即可,保证其超高吞吐量。Kafka 唯一的一点劣势是有可能消息重复消费,那么对数据准确性会造成极其轻微的影响,在大数据领域中以及日志采集中,这点轻微影响可以忽略这个特性天然适合大数据实时计算以及日志收集。如果是大数据领域的实时计算、日志采集等场景,用 Kafka 是业内标准的,绝对没问题,社区活跃度很高,绝对不会黄,何况几乎是全世界这个领域的事实性规范。 参考 # KRaft: Apache Kafka Without ZooKeeper:https://developer.confluent.io/learn/kraft/ "},{"id":269,"href":"/zh/docs/technology/Review/java_guide/lyely_high-performance/ly02ly_cdn/","title":"cdn","section":"高性能","content":" 转载自https://github.com/Snailclimb/JavaGuide(添加小部分笔记)感谢作者!\n什么是 CDN ? # CDN 全称是 Content Delivery Network/Content Distribution Network,翻译过的意思是 内容分发网络 。\n我们可以将内容分发网络拆开来看:\n内容 :指的是静态资源比如图片、视频、文档、JS、CSS、HTML。 分发网络 :指的是将这些静态资源分发到位于多个不同的地理位置机房中的服务器上,这样,就可以实现静态资源的就近访问比如北京的用户直接访问北京机房的数据。 所以,简单来说,CDN 就是将静态资源分发到多个不同的地方以实现就近访问,进而加快静态资源的访问速度,减轻服务器以及带宽的负担。\n类似于京东建立的庞大的仓储运输体系,京东物流在全国拥有非常多的仓库,仓储网络几乎覆盖全国所有区县。这样的话,用户下单的第一时间,商品就从距离用户最近的仓库,直接发往对应的配送站,再由京东小哥送到你家。\n你可以将 CDN 看作是服务上一层的特殊缓存服务,分布在全国各地,主要用来处理静态资源的请求。\n我们经常拿全站加速和内容分发网络做对比,不要把两者搞混了!全站加速(不同云服务商叫法不同,腾讯云叫 ECDN、阿里云叫 DCDN)既可以加速静态资源又可以加速动态资源,**内容分发网络(CDN)**主要针对的是 静态资源 。\n绝大部分公司都会在项目开发中交使用 CDN 服务,但很少会有自建 CDN 服务的公司。基于成本、稳定性和易用性考虑,建议直接选择专业的云厂商(比如阿里云、腾讯云、华为云、青云)或者 CDN 厂商(比如网宿、蓝汛)提供的开箱即用的 CDN 服务。\n很多朋友可能要问了:既然是就近访问,为什么不直接将服务部署在多个不同的地方呢?\n成本太高,需要部署多份相同的服务。 静态资源通常占用空间比较大且经常会被访问到,如果直接使用服务器或者缓存来处理静态资源请求的话,对系统资源消耗非常大,可能会影响到系统其他服务的正常运行。 同一个服务在在多个不同的地方部署多份(比如同城灾备、异地灾备、同城多活、异地多活)是为了实现系统的高可用而不是就近访问。\nCDN 工作原理是什么? # 搞懂下面 3 个问题也就搞懂了 CDN 的工作原理:\n静态资源是如何被缓存到 CDN 节点中的? 如何找到最合适的 CDN 节点? 如何防止静态资源被盗用? 静态资源是如何被缓存到 CDN 节点中的? # 你可以通过预热的方式将源站的资源同步到 CDN 的节点中。这样的话,用户首次请求资源可以直接从 CDN 节点中取,无需回源。这样可以降低源站压力,提升用户体验。\n如果不预热的话,你访问的资源可能不再 CDN 节点中,这个时候 CDN 节点将请求源站获取资源,这个过程是大家经常说的 回源。\n命中率 和 回源率 是衡量 CDN 服务质量两个重要指标。命中率越高越好,回源率越低越好。\n如果资源有更新的话,你也可以对其 刷新 ,删除 CDN 节点上缓存的资源,当用户访问对应的资源时直接回源获取最新的资源,并重新缓存。\n如何找到最合适的 CDN 节点? # **GSLB (Global Server Load Balance,全局负载均衡)**是 CDN 的大脑,负责多个CDN节点之间相互协作,最常用的是基于 DNS 的 GSLB。\nCDN 会通过 GSLB 找到最合适的 CDN 节点,更具体点来说是下面这样的:\n浏览器向 DNS 服务器发送域名请求; DNS 服务器向根据 CNAME( Canonical Name ) 别名记录向 GSLB 发送请求; GSLB 返回性能最好(通常距离请求地址最近)的 CDN 节点(边缘服务器,真正缓存内容的地方)的地址给浏览器; 浏览器直接访问指定的 CDN 节点。 为了方便理解,上图其实做了一点简化。GSLB 内部可以看作是 CDN 专用 DNS 服务器和负载均衡系统组合。CDN 专用 DNS 服务器会返回负载均衡系统 IP 地址给浏览器,浏览器使用 IP 地址请求负载均衡系统进而找到对应的 CDN 节点。\nGSLB 是如何选择出最合适的 CDN 节点呢? GSLB 会根据请求的 IP 地址、CDN 节点状态(比如负载情况、性能、响应时间、带宽)等指标来综合判断具体返回哪一个 CDN 节点的地址。\n如何防止资源被盗刷? # 如果我们的资源被其他用户或者网站非法盗刷的话,将会是一笔不小的开支。\n解决这个问题最常用最简单的办法设置 Referer 防盗链,具体来说就是根据 HTTP 请求的头信息里面的 Referer 字段对请求进行限制。我们可以通过 Referer 字段获取到当前请求页面的来源页面的网站地址,这样我们就能确定请求是否来自合法的网站。\nCDN 服务提供商几乎都提供了这种比较基础的防盗链机制。\n不过,如果站点的防盗链配置允许 Referer 为空的话,通过隐藏 Referer,可以直接绕开防盗链。\n通常情况下,我们会配合其他机制来确保静态资源被盗用,一种常用的机制是 时间戳防盗链 。相比之下,时间戳防盗链 的安全性更强一些。时间戳防盗链加密的 URL 具有时效性,过期之后就无法再被允许访问。\n时间戳防盗链的 URL 通常会有两个参数一个是签名字符串,一个是过期时间。签名字符串一般是通过对用户设定的加密字符串、请求路径、过期时间通过 MD5 哈希算法取哈希的方式获得。\n注意,这个用户设定的加密字符串,是在后台配置的,而签名是后台给前端的\n(1)用户管理员在七牛 CDN 控制台 配置 key ,并将 key配置进业务服务器。 (2)当客户端请求资源时,将原始 url 发送至业务服务器。 (3)业务服务器根据 计算逻辑,将带有时间戳签名的 url 返回至客户端。 (4)客户端使用带有时间戳签名的 url 请求资源。 (5)CDN 检查 url 签名的合法性。\n时间戳防盗链 URL示例:\nhttp://cdn.wangsu.com/4/123.mp3? wsSecret=79aead3bd7b5db4adeffb93a010298b5\u0026amp;wsTime=1601026312 wsSecret :签名字符串。 wsTime: 过期时间。 时间戳防盗链的实现也比较简单,并且可靠性较高,推荐使用。并且,绝大部分 CDN 服务提供商都提供了开箱即用的时间戳防盗链机制。\n除了 Referer 防盗链和时间戳防盗链之外,你还可以 IP 黑白名单配置、IP 访问限频配置等机制来防盗刷。\n总结 # CDN 就是将静态资源分发到多个不同的地方以实现就近访问,进而加快静态资源的访问速度,减轻服务器以及带宽的负担。 基于成本、稳定性和易用性考虑,建议直接选择专业的云厂商(比如阿里云、腾讯云、华为云、青云)或者 CDN 厂商(比如网宿、蓝汛)提供的开箱即用的 CDN 服务。 GSLB (Global Server Load Balance,全局负载均衡)是 CDN 的大脑,负责多个 CDN 节点之间相互协作,最常用的是基于 DNS 的 GSLB。CDN 会通过 GSLB 找到最合适的 CDN 节点。 为了防止静态资源被盗用,我们可以利用 Referer 防盗链 + 时间戳防盗链 。 参考 # 时间戳防盗链 - 七牛云 CDN:https://developer.qiniu.com/fusion/kb/1670/timestamp-hotlinking-prevention CDN是个啥玩意?一文说个明白:https://mp.weixin.qq.com/s/Pp0C8ALUXsmYCUkM5QnkQw 《透视 HTTP 协议》- 37 | CDN:加速我们的网络服务:http://gk.link/a/11yOG "},{"id":270,"href":"/zh/docs/technology/Review/java_guide/lyely_high-performance/ly01ly_read-and-write-separation-and-library-subtable/","title":"数据库读写分离\u0026分库分表详解","section":"高性能","content":" 转载自https://github.com/Snailclimb/JavaGuide(添加小部分笔记)感谢作者!\n读写分离 # 什么是读写分离? # 见名思意,根据读写分离的名字,我们就可以知道:读写分离主要是为了将对数据库的读写操作分散到不同的数据库节点上。 这样的话,就能够小幅提升写性能,大幅提升读性能。\n我简单画了一张图来帮助不太清楚读写分离的小伙伴理解。\n一般情况下,我们都会选择一主多从,也就是一台主数据库负责写,其他的从数据库负责读。主库和从库之间会进行数据同步,以保证从库中数据的准确性。这样的架构实现起来比较简单,并且也符合系统的写少读多的特点。\n读写分离会带来什么问题?如何解决? # 读写分离对于提升数据库的并发非常有效,但是,同时也会引来一个问题:主库和从库的数据存在延迟,比如你写完主库之后,主库的数据同步到从库是需要时间的,这个时间差就导致了主库和从库的数据不一致性问题。这也就是我们经常说的 主从同步延迟 。\n主从同步延迟问题的解决,没有特别好的一种方案(可能是我太菜了,欢迎评论区补充)。你可以根据自己的业务场景,参考一下下面几种解决办法。\n1.强制将读请求路由到主库处理。\n既然你从库的数据过期了,那我就直接从主库读取嘛!这种方案虽然会增加主库的压力,但是,实现起来比较简单,也是我了解到的使用最多的一种方式。\n比如 Sharding-JDBC 就是采用的这种方案。通过使用 Sharding-JDBC 的 HintManager 分片键值管理器,我们可以强制使用主库。\nHintManager hintManager = HintManager.getInstance(); hintManager.setMasterRouteOnly(); // 继续JDBC操作 对于这种方案,你可以将那些必须获取最新数据的读请求都交给主库处理。\n2.延迟读取。\n还有一些朋友肯定会想既然主从同步存在延迟,那我就在延迟之后读取啊,比如主从同步延迟 0.5s,那我就 1s 之后再读取数据。这样多方便啊!方便是方便,但是也很扯淡。\n不过,如果你是这样设计业务流程就会好很多:对于一些对数据比较敏感的场景,你可以在完成写请求之后,避免立即进行请求操作。比如你支付成功之后,跳转到一个支付成功的页面,当你点击返回之后才返回自己的账户。\n另外, 《MySQL 实战 45 讲》这个专栏中的 《读写分离有哪些坑?》这篇文章还介绍了很多其他比较实际的解决办法,感兴趣的小伙伴可以自行研究一下。\n如何实现读写分离? # 不论是使用哪一种读写分离具体的实现方案,想要实现读写分离一般包含如下几步:\n部署多台数据库,选择其中的一台作为主数据库,其他的一台或者多台作为从数据库。 保证主数据库和从数据库之间的数据是实时同步的,这个过程也就是我们常说的主从复制。 系统将写请求交给主数据库处理,读请求交给从数据库处理。[ 使用上 ] 落实到项目本身的话,常用的方式有两种:\n1.代理方式\n我们可以在应用和数据中间加了一个代理层。应用程序所有的数据请求都交给代理层处理,代理层负责分离读写请求,将它们路由到对应的数据库中。\n提供类似功能的中间件有 MySQL Router(官方)、Atlas(基于 MySQL Proxy)、Maxscale、MyCat。\n2.组件方式\n在这种方式中,我们可以通过引入第三方组件来帮助我们读写请求。\n这也是我比较推荐的一种方式。这种方式目前在各种互联网公司中用的最多的,相关的实际的案例也非常多。如果你要采用这种方式的话,推荐使用 sharding-jdbc ,直接引入 jar 包即可使用,非常方便。同时,也节省了很多运维的成本。\n你可以在 shardingsphere 官方找到 sharding-jdbc 关于读写分离的操作。\n主从复制原理是什么? # MySQL binlog(binary log 即二进制日志文件) 主要记录了 MySQL 数据库中数据的所有变化(数据库执行的所有 DDL 和 DML 语句)。因此,我们根据主库的 MySQL binlog 日志就能够将主库的数据同步到从库中。\n更具体和详细的过程是这个样子的(图片来自于: 《MySQL Master-Slave Replication on the Same Machine》):\n主库将数据库中数据的变化写入到 binlog 从库连接主库 从库会创建一个 I/O 线程向主库请求更新的 binlog 主库会创建一个 binlog dump 线程来发送 binlog ,从库中的 I/O 线程负责接收 从库的 I/O 线程将接收的 binlog 写入到 relay log 中。 从库的 SQL 线程读取 relay log 同步数据本地(也就是再执行一遍 SQL )。 怎么样?看了我对主从复制这个过程的讲解,你应该搞明白了吧!\n你一般看到 binlog 就要想到主从复制。当然,除了主从复制之外,binlog 还能帮助我们实现数据恢复。\n🌈 拓展一下:\n不知道大家有没有使用过阿里开源的一个叫做 canal 的工具。这个工具可以帮助我们实现 MySQL 和其他数据源比如 Elasticsearch 或者另外一台 MySQL 数据库之间的数据同步。很显然,这个工具的底层原理肯定也是依赖 binlog。canal 的原理就是模拟 MySQL 主从复制的过程,解析 binlog 将数据同步到其他的数据源。\n另外,像咱们常用的分布式缓存组件 Redis 也是通过主从复制实现的读写分离。\n🌕 简单总结一下:\nMySQL 主从复制是依赖于 binlog 。另外,常见的一些同步 MySQL 数据到其他数据源的工具(比如 canal)的底层一般也是依赖 binlog 。\n分库分表 # 读写分离主要应对的是数据库读并发,没有解决数据库存储问题。试想一下:如果 MySQL 一张表的数据量过大怎么办?\n换言之,我们该如何解决 MySQL 的存储压力呢?\n答案之一就是 分库分表。\n什么是分库? # 分库 就是将数据库中的数据分散到不同的数据库上,可以垂直分库,也可以水平分库。\n垂直分库 就是把单一数据库按照业务进行划分,不同的业务使用不同的数据库,进而将一个数据库的压力分担到多个数据库。\n举个例子:说你将数据库中的用户表、订单表和商品表分别单独拆分为用户数据库、订单数据库和商品数据库。\n水平分库 是把同一个表按一定规则拆分到不同的数据库中,每个库可以位于不同的服务器上,这样就实现了水平扩展,解决了单表的存储和性能瓶颈的问题。\n举个例子:订单表数据量太大,你对订单表进行了水平切分(水平分表),然后将切分后的 2 张订单表分别放在两个不同的数据库。\n什么是分表? # 分表 就是对单表的数据进行拆分,可以是垂直拆分,也可以是水平拆分。\n垂直分表 是对数据表列的拆分,把一张列比较多的表拆分为多张表。\n举个例子:我们可以将用户信息表中的一些列单独抽出来作为一个表。\n水平分表 是对数据表行的拆分,把一张行比较多的表拆分为多张表,可以解决单一表数据量过大的问题。\n举个例子:我们可以将用户信息表拆分成多个用户信息表,这样就可以避免单一表数据量过大对性能造成影响。\n水平拆分只能解决单表数据量大的问题,为了提升性能,我们通常会选择将拆分后的多张表放在不同的数据库中。也就是说,水平分表通常和水平分库同时出现。\n什么情况下需要分库分表? # 遇到下面几种场景可以考虑分库分表:\n单表的数据达到千万级别以上,数据库读写速度比较缓慢。 数据库中的数据占用的空间越来越大,备份时间越来越长。 应用的并发量太大。 常见的分片算法有哪些? # 分片算法主要解决了数据被水平分片之后,数据究竟该存放在哪个表的问题。\n哈希分片 :求指定 key(比如 id) 的哈希,然后根据哈希值确定数据应被放置在哪个表中。哈希分片比较适合随机读写的场景,不太适合经常需要范围查询的场景。 范围分片 :按照特性的范围区间(比如时间区间、ID区间)来分配数据,比如 将 id 为 1~299999 的记录分到第一个库, 300000~599999 的分到第二个库。范围分片适合需要经常进行范围查找的场景,不太适合随机读写的场景(数据未被分散,容易出现热点数据的问题)。 地理位置分片 :很多 NewSQL 数据库都支持地理位置分片算法,也就是根据地理位置(如城市、地域)来分配数据。 融合算法 :灵活组合多种分片算法,比如将哈希分片和范围分片组合。 \u0026hellip;\u0026hellip; 分库分表会带来什么问题呢? # 记住,你在公司做的任何技术决策,不光是要考虑这个技术能不能满足我们的要求,是否适合当前业务场景,还要重点考虑其带来的成本。\n引入分库分表之后,会给系统带来什么挑战呢?\njoin 操作 : 同一个数据库中的表分布在了不同的数据库中,导致无法使用 join 操作。这样就导致我们需要手动进行数据的封装,比如你在一个数据库中查询到一个数据之后,再根据这个数据去另外一个数据库中找对应的数据。 事务问题 :同一个数据库中的表分布在了不同的数据库中,如果单个操作涉及到多个数据库,那么数据库自带的事务就无法满足我们的要求了。 分布式 id :分库之后, 数据遍布在不同服务器上的数据库,数据库的自增主键已经没办法满足生成的主键唯一了。我们如何为不同的数据节点生成全局唯一主键呢?这个时候,我们就需要为我们的系统引入分布式 id 了。 \u0026hellip;\u0026hellip; 另外,引入分库分表之后,一般需要 DBA 的参与,同时还需要更多的数据库服务器,这些都属于成本。\n分库分表有没有什么比较推荐的方案? # ShardingSphere 项目(包括 Sharding-JDBC、Sharding-Proxy 和 Sharding-Sidecar)是当当捐入 Apache 的,目前主要由京东数科的一些巨佬维护。\nShardingSphere 绝对可以说是当前分库分表的首选!ShardingSphere 的功能完善,除了支持读写分离和分库分表,还提供分布式事务、数据库治理等功能。\n另外,ShardingSphere 的生态体系完善,社区活跃,文档完善,更新和发布比较频繁。\n艿艿之前写了一篇分库分表的实战文章,各位朋友可以看看: 《芋道 Spring Boot 分库分表入门》 。\n分库分表后,数据怎么迁移呢? # 分库分表之后,我们如何将老库(单库单表)的数据迁移到新库(分库分表后的数据库系统)呢?\n比较简单同时也是非常常用的方案就是停机迁移,写个脚本老库的数据写到新库中。比如你在凌晨 2 点,系统使用的人数非常少的时候,挂一个公告说系统要维护升级预计 1 小时。然后,你写一个脚本将老库的数据都同步到新库中。\n如果你不想停机迁移数据的话,也可以考虑双写方案。双写方案是针对那种不能停机迁移的场景,实现起来要稍微麻烦一些。具体原理是这样的:\n我们对老库的更新操作(增删改),同时也要写入新库(双写)。如果操作的数据不存在于新库的话,需要插入到新库中。 这样就能保证,咱们新库里的数据是最新的。 在迁移过程,双写只会让被更新操作过的老库中的数据同步到新库,我们还需要自己写脚本将老库中的数据(这里说的就是原来老库中的数据但是没有设计更新操作)和新库的数据做比对。如果新库中没有,那咱们就把数据插入到新库。如果新库有,旧库没有,就把新库对应的数据删除(冗余数据清理)。 重复上一步的操作,直到老库和新库的数据一致为止。 想要在项目中实施双写还是比较麻烦的,很容易会出现问题。我们可以借助上面提到的数据库同步工具 Canal 做增量数据迁移(还是依赖 binlog,开发和维护成本较低)。\n总结 # 读写分离主要是为了将对数据库的读写操作分散到不同的数据库节点上。 这样的话,就能够小幅提升写性能,大幅提升读性能。 读写分离基于主从复制,MySQL 主从复制是依赖于 binlog 。 分库 就是将数据库中的数据分散到不同的数据库上。分表 就是对单表的数据进行拆分,可以是垂直拆分,也可以是水平拆分。 引入分库分表之后,需要系统解决事务、分布式 id、无法 join 操作问题。 ShardingSphere 绝对可以说是当前分库分表的首选!ShardingSphere 的功能完善,除了支持读写分离和分库分表,还提供分布式事务、数据库治理等功能。另外,ShardingSphere 的生态体系完善,社区活跃,文档完善,更新和发布比较频繁。 "},{"id":271,"href":"/zh/docs/technology/Review/java_guide/lydly_distributed_system/ly08ly_zookeeper-in-action/","title":"zookeeper实战","section":"分布式系统","content":" 转载自https://github.com/Snailclimb/JavaGuide(添加小部分笔记)感谢作者!\n1. 前言 # 这篇文章简单给演示一下 ZooKeeper 常见命令的使用以及 ZooKeeper Java客户端 Curator 的基本使用。介绍到的内容都是最基本的操作,能满足日常工作的基本需要。\n如果文章有任何需要改善和完善的地方,欢迎在评论区指出,共同进步!\n2. ZooKeeper 安装和使用 # 2.1. 使用Docker 安装 zookeeper # a.使用 Docker 下载 ZooKeeper\ndocker pull zookeeper:3.5.8 b.运行 ZooKeeper\ndocker run -d --name zookeeper -p 2181:2181 zookeeper:3.5.8 2.2. 连接 ZooKeeper 服务 # a.进入ZooKeeper容器中\n先使用 docker ps 查看 ZooKeeper 的 ContainerID,然后使用 docker exec -it ContainerID /bin/bash 命令进入容器中。\nb.先进入 bin 目录,然后通过 ./zkCli.sh -server 127.0.0.1:2181命令连接ZooKeeper 服务\nroot@eaf70fc620cb:/apache-zookeeper-3.5.8-bin# cd bin 如果你看到控制台成功打印出如下信息的话,说明你已经成功连接 ZooKeeper 服务。\n2.3. 常用命令演示 # 2.3.1. 查看常用命令(help 命令) # 通过 help 命令查看 ZooKeeper 常用命令\n2.3.2. 创建节点(create 命令) # 通过 create 命令在根目录创建了 node1 节点,与它关联的字符串是\u0026quot;node1\u0026quot;\n[zk: 127.0.0.1:2181(CONNECTED) 34] create /node1 “node1” 通过 create 命令在根目录创建了 node1 节点,与它关联的内容是数字 123\n这个是不是写错了,应该是在node1目录下 ,创建了 node1.1节点\n[zk: 127.0.0.1:2181(CONNECTED) 1] create /node1/node1.1 123 Created /node1/node1.1 2.3.3. 更新节点数据内容(set 命令) # [zk: 127.0.0.1:2181(CONNECTED) 11] set /node1 \u0026#34;set node1\u0026#34; 2.3.4. 获取节点的数据(get 命令) # get 命令可以获取指定节点的数据内容和节点的状态,可以看出我们通过 set 命令已经将节点数据内容改为 \u0026ldquo;set node1\u0026rdquo;。\nset node1 cZxid = 0x47 ctime = Sun Jan 20 10:22:59 CST 2019 mZxid = 0x4b mtime = Sun Jan 20 10:41:10 CST 2019 pZxid = 0x4a cversion = 1 dataVersion = 1 aclVersion = 0 ephemeralOwner = 0x0 dataLength = 9 numChildren = 1 2.3.5. 查看某个目录下的子节点(ls 命令) # 通过 ls 命令查看根目录下的节点\n[zk: 127.0.0.1:2181(CONNECTED) 37] ls / [dubbo, ZooKeeper, node1] 通过 ls 命令查看 node1 目录下的节点\n[zk: 127.0.0.1:2181(CONNECTED) 5] ls /node1 [node1.1] ZooKeeper 中的 ls 命令和 linux 命令中的 ls 类似, 这个命令将列出绝对路径 path 下的所有子节点信息(列出 1 级,并不递归)\n2.3.6. 查看节点状态(stat 命令) # 通过 stat 命令查看节点状态\n[zk: 127.0.0.1:2181(CONNECTED) 10] stat /node1 cZxid = 0x47 ctime = Sun Jan 20 10:22:59 CST 2019 mZxid = 0x47 mtime = Sun Jan 20 10:22:59 CST 2019 pZxid = 0x4a cversion = 1 dataVersion = 0 aclVersion = 0 ephemeralOwner = 0x0 dataLength = 11 numChildren = 1 上面显示的一些信息比如 cversion、aclVersion、numChildren 等等,我在上面 “ ZooKeeper 相关概念总结(入门)” 这篇文章中已经介绍到。\n2.3.7. 查看节点信息和状态(ls2 命令) # ls2 命令更像是 ls 命令和 stat 命令的结合。 ls2 命令返回的信息包括 2 部分:\n子节点列表 当前节点的 stat 信息。 [zk: 127.0.0.1:2181(CONNECTED) 7] ls2 /node1 [node1.1] cZxid = 0x47 ctime = Sun Jan 20 10:22:59 CST 2019 mZxid = 0x47 mtime = Sun Jan 20 10:22:59 CST 2019 pZxid = 0x4a cversion = 1 dataVersion = 0 aclVersion = 0 ephemeralOwner = 0x0 dataLength = 11 numChildren = 1 2.3.8. 删除节点(delete 命令) # 这个命令很简单,但是需要注意的一点是如果你要删除某一个节点,那么这个节点必须无子节点才行。\n[zk: 127.0.0.1:2181(CONNECTED) 3] delete /node1/node1.1 在后面我会介绍到 Java 客户端 API 的使用以及开源 ZooKeeper 客户端 ZkClient 和 Curator 的使用。\n3. ZooKeeper Java客户端 Curator简单使用 # Curator 是Netflix公司开源的一套 ZooKeeper Java客户端框架,相比于 Zookeeper 自带的客户端 zookeeper 来说,Curator 的封装更加完善,各种 API 都可以比较方便地使用。\n下面我们就来简单地演示一下 Curator 的使用吧!\nCurator4.0+版本对ZooKeeper 3.5.x支持比较好。开始之前,请先将下面的依赖添加进你的项目。\n\u0026lt;dependency\u0026gt; \u0026lt;groupId\u0026gt;org.apache.curator\u0026lt;/groupId\u0026gt; \u0026lt;artifactId\u0026gt;curator-framework\u0026lt;/artifactId\u0026gt; \u0026lt;version\u0026gt;4.2.0\u0026lt;/version\u0026gt; \u0026lt;/dependency\u0026gt; \u0026lt;dependency\u0026gt; \u0026lt;groupId\u0026gt;org.apache.curator\u0026lt;/groupId\u0026gt; \u0026lt;artifactId\u0026gt;curator-recipes\u0026lt;/artifactId\u0026gt; \u0026lt;version\u0026gt;4.2.0\u0026lt;/version\u0026gt; \u0026lt;/dependency\u0026gt; 3.1. 连接 ZooKeeper 客户端 # 通过 CuratorFrameworkFactory 创建 CuratorFramework 对象,然后再调用 CuratorFramework 对象的 start() 方法即可!\nprivate static final int BASE_SLEEP_TIME = 1000; private static final int MAX_RETRIES = 3; // Retry strategy. Retry 3 times, and will increase the sleep time between retries. RetryPolicy retryPolicy = new ExponentialBackoffRetry(BASE_SLEEP_TIME, MAX_RETRIES); CuratorFramework zkClient = CuratorFrameworkFactory.builder() // the server to connect to (can be a server list) .connectString(\u0026#34;127.0.0.1:2181\u0026#34;). .retryPolicy(retryPolicy) .build(); zkClient.start(); 对于一些基本参数的说明:\nbaseSleepTimeMs:重试之间等待的初始时间 maxRetries :最大重试次数 connectString :要连接的服务器列表 retryPolicy :重试策略 3.2. 数据节点的增删改查 # 3.2.1. 创建节点 # 我们在 ZooKeeper常见概念解读 中介绍到,我们通常是将 znode 分为 4 大类:\n持久(PERSISTENT)节点 :一旦创建就一直存在即使 ZooKeeper 集群宕机,直到将其删除。 临时(EPHEMERAL)节点 :临时节点的生命周期是与 客户端会话(session) 绑定的,会话消失则节点消失 。并且,临时节点 只能做叶子节点 ,不能创建子节点。 持久顺序(PERSISTENT_SEQUENTIAL)节点 :除了具有持久(PERSISTENT)节点的特性之外, 子节点的名称还具有顺序性。比如 /node1/app0000000001 、/node1/app0000000002 。 临时顺序(EPHEMERAL_SEQUENTIAL)节点 :除了具备临时(EPHEMERAL)节点的特性之外,子节点的名称还具有顺序性。 你在使用的ZooKeeper 的时候,会发现 CreateMode 类中实际有 7种 znode 类型 ,但是用的最多的还是上面介绍的 4 种。\na.创建持久化节点\n你可以通过下面两种方式创建持久化的节点。\n//注意:下面的代码会报错,下文说了具体原因 zkClient.create().forPath(\u0026#34;/node1/00001\u0026#34;); zkClient.create().withMode(CreateMode.PERSISTENT).forPath(\u0026#34;/node1/00002\u0026#34;); 但是,你运行上面的代码会报错,这是因为的父节点node1还未创建。\n你可以先创建父节点 node1 ,然后再执行上面的代码就不会报错了。\nzkClient.create().forPath(\u0026#34;/node1\u0026#34;); 更推荐的方式是通过下面这行代码, creatingParentsIfNeeded() 可以保证父节点不存在的时候自动创建父节点,这是非常有用的。\nzkClient.create().creatingParentsIfNeeded().withMode(CreateMode.PERSISTENT).forPath(\u0026#34;/node1/00001\u0026#34;); b.创建临时节点\nzkClient.create().creatingParentsIfNeeded().withMode(CreateMode.EPHEMERAL).forPath(\u0026#34;/node1/00001\u0026#34;); c.创建节点并指定数据内容\nzkClient.create().creatingParentsIfNeeded().withMode(CreateMode.EPHEMERAL).forPath(\u0026#34;/node1/00001\u0026#34;,\u0026#34;java\u0026#34;.getBytes()); zkClient.getData().forPath(\u0026#34;/node1/00001\u0026#34;);//获取节点的数据内容,获取到的是 byte数组 d.检测节点是否创建成功\nzkClient.checkExists().forPath(\u0026#34;/node1/00001\u0026#34;);//不为null的话,说明节点创建成功 3.2.2. 删除节点 # a.删除一个子节点\nzkClient.delete().forPath(\u0026#34;/node1/00001\u0026#34;); b.删除一个节点以及其下的所有子节点\nzkClient.delete().deletingChildrenIfNeeded().forPath(\u0026#34;/node1\u0026#34;); 3.2.3. 获取/更新节点数据内容 # zkClient.create().creatingParentsIfNeeded().withMode(CreateMode.EPHEMERAL).forPath(\u0026#34;/node1/00001\u0026#34;,\u0026#34;java\u0026#34;.getBytes()); zkClient.getData().forPath(\u0026#34;/node1/00001\u0026#34;);//获取节点的数据内容 zkClient.setData().forPath(\u0026#34;/node1/00001\u0026#34;,\u0026#34;c++\u0026#34;.getBytes());//更新节点数据内容 3.2.4. 获取某个节点的所有子节点路径 # List\u0026lt;String\u0026gt; childrenPaths = zkClient.getChildren().forPath(\u0026#34;/node1\u0026#34;); "},{"id":272,"href":"/zh/docs/technology/Review/java_guide/lydly_distributed_system/ly07ly_zookeeper-plus/","title":"zookeeper进阶","section":"分布式系统","content":" 转载自https://github.com/Snailclimb/JavaGuide(添加小部分笔记)感谢作者!\nFrancisQ 投稿。\n1. 好久不见 # 离上一篇文章的发布也快一个月了,想想已经快一个月没写东西了,其中可能有期末考试、课程设计和驾照考试,但这都不是借口!\n一到冬天就懒的不行,望广大掘友督促我🙄🙄✍️✍️。\n文章很长,先赞后看,养成习惯。❤️ 🧡 💛 💚 💙 💜\n2. 什么是ZooKeeper # ZooKeeper 由 Yahoo 开发,后来捐赠给了 Apache ,现已成为 Apache 顶级项目。ZooKeeper 是一个开源的分布式应用程序协调服务器,其为分布式系统提供一致性服务。其一致性是通过基于 Paxos 算法的 ZAB 协议完成的。其主要功能包括:配置维护、分布式同步、集群管理、分布式事务等。\n简单来说, ZooKeeper 是一个 分布式协调服务框架 。分布式?协调服务?这啥玩意?🤔🤔\n其实解释到分布式这个概念的时候,我发现有些同学并不是能把 分布式和集群 这两个概念很好的理解透。前段时间有同学和我探讨起分布式的东西,他说分布式不就是加机器吗?一台机器不够用再加一台抗压呗。当然加机器这种说法也无可厚非,你一个分布式系统必定涉及到多个机器,但是你别忘了,计算机学科中还有一个相似的概念—— Cluster ,集群不也是加机器吗?但是 集群 和 分布式 其实就是两个完全不同的概念。\n比如,我现在有一个秒杀服务,并发量太大单机系统承受不住,那我加几台服务器也 一样 提供秒杀服务,这个时候就是 Cluster 集群 。\n但是,我现在换一种方式,我将一个秒杀服务 拆分成多个子服务 ,比如创建订单服务,增加积分服务,扣优惠券服务等等,然后我将这些子服务都部署在不同的服务器上 ,这个时候就是 Distributed 分布式 。\n而我为什么反驳同学所说的分布式就是加机器呢?因为我认为加机器更加适用于构建集群,因为它真是只有加机器。而对于分布式来说,你首先需要将业务进行拆分,然后再加机器(不仅仅是加机器那么简单),同时你还要去解决分布式带来的一系列问题。\n比如各个分布式组件如何协调起来,如何减少各个系统之间的耦合度,分布式事务的处理,如何去配置整个分布式系统等等。ZooKeeper 主要就是解决这些问题的。\n3. 一致性问题 # 设计一个分布式系统必定会遇到一个问题—— 因为分区容忍性(partition tolerance)的存在,就必定要求我们需要在系统可用性(availability)和数据一致性(consistency)中做出权衡 。这就是著名的 CAP 定理。\n理解起来其实很简单,比如说把一个班级作为整个系统,而学生是系统中的一个个独立的子系统。这个时候班里的小红小明偷偷谈恋爱被班里的大嘴巴小花发现了,小花欣喜若狂告诉了周围的人,然后小红小明谈恋爱的消息在班级里传播起来了。当在消息的传播(散布)过程中,你抓到一个同学问他们的情况,如果回答你不知道(或者说的是他们的前男/女朋友,也就是消息不一致),那么说明整个班级系统出现了数据不一致的问题(因为小花已经知道这个消息了)。而如果他直接不回答你,因为整个班级有消息在进行传播(为了保证一致性,需要所有人都知道才可提供服务),这个时候就出现了系统的可用性问题。\n而上述前者就是 Eureka 的处理方式,它保证了AP(可用性),后者就是我们今天所要讲的 ZooKeeper 的处理方式,它保证了CP(数据一致性(即同步期间不可用))。\n【ly总结】也就是说两台机器同步期间,如果要保证可用性,那么必然会出现一致性问题;而如果要保证一致性,那必然要等到完全同步完(也就是期间会让请求不可用)\n4. 一致性协议和算法 # 而为了解决数据一致性问题,在科学家和程序员的不断探索中,就出现了很多的一致性协议和算法。比如 2PC(两阶段提交),3PC(三阶段提交),Paxos算法等等。\n这时候请你思考一个问题,同学之间如果采用传纸条的方式去传播消息,那么就会出现一个问题——我咋知道我的小纸条有没有传到我想要传递的那个人手中呢?万一被哪个小家伙给劫持篡改了呢,对吧?\n这个时候就引申出一个概念—— 拜占庭将军问题 。它意指 在不可靠信道上试图通过消息传递的方式达到一致性是不可能的, 所以所有的一致性算法的 必要前提 就是安全可靠的消息通道。\n而为什么要去解决数据一致性的问题?你想想,如果一个秒杀系统将服务拆分成了下订单和加积分服务,这两个服务部署在不同的机器上了,万一在消息的传播过程中积分系统宕机了,总不能你这边下了订单却没加积分吧?你总得保证两边的数据需要一致吧?\n4.1. 2PC(两阶段提交) # 两阶段提交是一种保证分布式系统数据一致性的协议,现在很多数据库都是采用的两阶段提交协议来完成 分布式事务 的处理。\n在介绍2PC之前,我们先来想想分布式事务到底有什么问题呢?\n还拿秒杀系统的下订单和加积分两个系统来举例吧(我想你们可能都吐了🤮🤮🤮),我们此时下完订单会发个消息给积分系统告诉它下面该增加积分了。如果我们仅仅是发送一个消息也不收回复,那么我们的订单系统怎么能知道积分系统的收到消息的情况呢?如果我们增加一个收回复的过程,那么当积分系统收到消息后返回给订单系统一个 Response ,但在中间出现了网络波动,那个回复消息没有发送成功,订单系统是不是以为积分系统消息接收失败了?它是不是会回滚事务?但此时(实际情况:)积分系统是成功收到消息的,它就会去处理消息然后给用户增加积分,这个时候就会出现积分加了但是订单没下成功。\n所以我们所需要解决的是在分布式系统中,整个调用链中,我们所有服务的数据处理要么都成功要么都失败,即所有服务的 原子性问题 。\n在两阶段提交中,主要涉及到两个角色,分别是协调者和参与者。\n第一阶段:当要执行一个分布式事务的时候,事务发起者首先向协调者发起事务请求,然后协调者会给所有参与者发送 prepare 请求(其中包括事务内容)告诉参与者你们需要执行事务了,如果能执行我发的事务内容那么就先执行但不提交,执行后请给我回复。然后参与者收到 prepare 消息后,他们会开始执行事务(但不提交),并将 Undo 和 Redo 信息记入事务日志中,之后参与者就向协调者反馈是否准备好了。\n第二阶段:第二阶段主要是协调者根据参与者反馈的情况来决定接下来是否可以进行事务的提交操作,即提交事务或者回滚事务。\n比如这个时候 所有的参与者 都返回了准备好了的消息,这个时候就进行事务的提交,协调者此时会给所有的参与者发送 Commit 请求 ,当参与者收到 Commit 请求的时候会执行前面执行的事务的 提交操作 ,提交完毕之后将给协调者发送提交成功的响应。\n而如果在第一阶段并不是所有参与者都返回了准备好了的消息,那么此时协调者将会给所有参与者发送 回滚事务的 rollback 请求,参与者收到之后将会 回滚它在第一阶段所做的事务处理 ,然后再将处理情况返回给协调者,最终协调者收到响应后便给事务发起者返回处理失败的结果。\n个人觉得 2PC 实现得还是比较鸡肋的,因为事实上它只解决了各个事务的原子性问题,随之也带来了很多的问题。\n单点故障问题,如果协调者挂了那么整个系统都处于不可用的状态了。 阻塞问题,即当协调者发送 prepare 请求,参与者收到之后如果能处理那么它将会进行事务的处理但并不提交,这个时候会一直占用着资源不释放,如果此时协调者挂了,那么这些资源都不会再释放了,这会极大影响性能。 数据不一致问题,比如当第二阶段,协调者只发送了一部分的 commit 请求就挂了,那么也就意味着,收到消息的参与者会进行事务的提交,而后面没收到的则不会进行事务提交,那么这时候就会产生数据不一致性问题。 4.2. 3PC(三阶段提交) # 因为2PC存在的一系列问题,比如单点,容错机制缺陷等等,从而产生了 3PC(三阶段提交) 。那么这三阶段又分别是什么呢?\n千万不要吧PC理解成个人电脑了,其实他们是 phase-commit 的缩写,即阶段提交。\nCanCommit阶段:协调者向所有参与者发送 CanCommit 请求,参与者收到请求后会根据自身情况查看是否能执行事务,如果可以则返回 YES 响应并进入预备状态,否则返回 NO 。 PreCommit阶段:协调者根据参与者返回的响应来决定是否可以进行下面的 PreCommit 操作。如果上面参与者返回的都是 YES,那么协调者将向所有参与者发送 PreCommit 预提交请求,参与者收到预提交请求后,会进行事务的执行操作,并将 Undo 和 Redo 信息写入事务日志中 ,最后如果参与者顺利执行了事务则给协调者返回成功的响应。如果在第一阶段协调者收到了 任何一个 NO 的信息,或者 在一定时间内 并没有收到全部的参与者的响应,那么就会中断事务,它会向所有参与者发送中断请求(abort),参与者收到中断请求之后会立即中断事务,或者在一定时间内没有收到协调者的请求,它也会中断事务。 DoCommit阶段:这个阶段其实和 2PC 的第二阶段差不多,如果协调者收到了所有参与者在 PreCommit 阶段的 YES 响应,那么协调者将会给所有参与者发送 DoCommit 请求,参与者收到 DoCommit 请求后则会进行事务的提交工作,完成后则会给协调者返回响应,协调者收到所有参与者返回的事务提交成功的响应之后则完成事务。若协调者在 PreCommit 阶段 收到了任何一个 NO 或者在一定时间内没有收到所有参与者的响应 ,那么就会进行中断请求的发送,参与者收到中断请求后则会 通过上面记录的回滚日志 来进行事务的回滚操作,并向协调者反馈回滚状况,协调者收到参与者返回的消息后,中断事务。 这里是 3PC 在成功的环境下的流程图,你可以看到 3PC 在很多地方进行了超时中断的处理,比如协调者在指定时间内为收到全部的确认消息则进行事务中断的处理,这样能 减少同步阻塞的时间 。还有需要注意的是,3PC 在 DoCommit 阶段参与者如未收到协调者发送的提交事务的请求,它会在一定时间内进行事务的提交。为什么这么做呢?是因为这个时候我们肯定保证了在第一阶段所有的协调者全部返回了可以执行事务的响应,这个时候我们有理由相信其他系统都能进行事务的执行和提交,所以不管协调者有没有发消息给参与者,进入第三阶段参与者都会进行事务的提交操作。\n总之,3PC 通过一系列的超时机制很好的缓解了阻塞问题,但是最重要的一致性并没有得到根本的解决,比如在 PreCommit 阶段,当一个参与者收到了请求之后其他参与者和协调者挂了或者出现了网络分区,这个时候收到消息的参与者都会进行事务提交,这就会出现数据不一致性问题。\n所以,要解决一致性问题还需要靠 Paxos 算法⭐️ ⭐️ ⭐️ 。\n4.3. Paxos 算法 [ly:看不懂,跳过] # Paxos 算法是基于消息传递且具有高度容错特性的一致性算法,是目前公认的解决分布式一致性问题最有效的算法之一,其解决的问题就是在分布式系统中如何就某个值(决议)达成一致 。\n在 Paxos 中主要有三个角色,分别为 Proposer提案者、Acceptor表决者、Learner学习者。Paxos 算法和 2PC 一样,也有两个阶段,分别为 Prepare 和 accept 阶段。\n4.3.1. prepare 阶段 # Proposer提案者:负责提出 proposal,每个提案者在提出提案时都会首先获取到一个 具有全局唯一性的、递增的提案编号N,即在整个集群中是唯一的编号 N,然后将该编号赋予其要提出的提案,在第一阶段是只将提案编号发送给所有的表决者。 Acceptor表决者:每个表决者在 accept 某提案后,会将该提案编号N记录在本地,这样每个表决者中保存的已经被 accept 的提案中会存在一个编号最大的提案,其编号假设为 maxN。每个表决者仅会 accept 编号大于自己本地 maxN 的提案,在批准提案时表决者会将以前接受过的最大编号的提案作为响应反馈给 Proposer 。 下面是 prepare 阶段的流程图,你可以对照着参考一下。\n4.3.2. accept 阶段 # 当一个提案被 Proposer 提出后,如果 Proposer 收到了超过半数的 Acceptor 的批准(Proposer 本身同意),那么此时 Proposer 会给所有的 Acceptor 发送真正的提案(你可以理解为第一阶段为试探),这个时候 Proposer 就会发送提案的内容和提案编号。\n表决者收到提案请求后会再次比较本身已经批准过的最大提案编号和该提案编号,如果该提案编号 大于等于 已经批准过的最大提案编号,那么就 accept 该提案(此时执行提案内容但不提交),随后将情况返回给 Proposer 。如果不满足则不回应或者返回 NO 。\n当 Proposer 收到超过半数的 accept ,那么它这个时候会向所有的 acceptor 发送提案的提交请求。需要注意的是,因为上述仅仅是超过半数的 acceptor 批准执行了该提案内容,其他没有批准的并没有执行该提案内容,所以这个时候需要向未批准的 acceptor 发送提案内容和提案编号并让它无条件执行和提交,而对于前面已经批准过该提案的 acceptor 来说 仅仅需要发送该提案的编号 ,让 acceptor 执行提交就行了。\n而如果 Proposer 如果没有收到超过半数的 accept 那么它将会将 递增 该 Proposal 的编号,然后 重新进入 Prepare 阶段 。\n对于 Learner 来说如何去学习 Acceptor 批准的提案内容,这有很多方式,读者可以自己去了解一下,这里不做过多解释。\n4.3.3. paxos 算法的死循环问题 # 其实就有点类似于两个人吵架,小明说我是对的,小红说我才是对的,两个人据理力争的谁也不让谁🤬🤬。\n比如说,此时提案者 P1 提出一个方案 M1,完成了 Prepare 阶段的工作,这个时候 acceptor 则批准了 M1,但是此时提案者 P2 同时也提出了一个方案 M2,它也完成了 Prepare 阶段的工作。然后 P1 的方案已经不能在第二阶段被批准了(因为 acceptor 已经批准了比 M1 更大的 M2),所以 P1 自增方案变为 M3 重新进入 Prepare 阶段,然后 acceptor ,又批准了新的 M3 方案,它又不能批准 M2 了,这个时候 M2 又自增进入 Prepare 阶段。。。\n就这样无休无止的永远提案下去,这就是 paxos 算法的死循环问题。\n那么如何解决呢?很简单,人多了容易吵架,我现在 就允许一个能提案 就行了。\n5. 引出 ZAB # 5.1. Zookeeper 架构 # 作为一个优秀高效且可靠的分布式协调框架,ZooKeeper 在解决分布式数据一致性问题时并没有直接使用 Paxos ,而是专门定制了一致性协议叫做 ZAB(ZooKeeper Atomic Broadcast) 原子广播协议,该协议能够很好地支持 崩溃恢复 。\n5.2. ZAB 中的三个角色 # 和介绍 Paxos 一样,在介绍 ZAB 协议之前,我们首先来了解一下在 ZAB 中三个主要的角色,Leader 领导者、Follower跟随者、Observer观察者 。\nLeader :集群中 唯一的写请求处理者 ,能够发起投票(投票也是为了进行写请求)。 Follower:能够接收客户端的请求,如果是读请求则可以自己处理,如果是写请求则要转发给 Leader 。在选举过程中会参与投票,有选举权和被选举权 。 Observer :就是没有选举权和被选举权的 Follower 。 观察者[əbˈzɜːvə(r)] 在 ZAB 协议中对 zkServer(即上面我们说的三个角色的总称) 还有两种模式的定义,分别是 消息广播 和 崩溃恢复 。\n5.3. 消息广播模式 # 说白了就是 ZAB 协议是如何处理写请求的,上面我们不是说只有 Leader 能处理写请求嘛?那么我们的 Follower 和 Observer 是不是也需要 同步更新数据 呢?总不能数据只在 Leader 中更新了,其他角色都没有得到更新吧?\n不就是 在整个集群中保持数据的一致性 嘛?如果是你,你会怎么做呢?\n废话,第一步肯定需要 Leader 将写请求 广播 出去呀,让 Leader 问问 Followers 是否同意更新,如果超过半数以上的同意那么就进行 Follower 和 Observer 的更新(和 Paxos 一样)。当然这么说有点虚,画张图理解一下。\n嗯。。。看起来很简单,貌似懂了🤥🤥🤥。这两个 Queue 哪冒出来的?答案是 ZAB 需要让 Follower 和 Observer 保证顺序性 。何为顺序性,比如我现在有一个写请求A,此时 Leader 将请求A广播出去,因为只需要半数同意就行,所以可能这个时候有一个 Follower F1因为网络原因没有收到,而 Leader 又广播了一个请求B,因为网络原因,F1竟然先收到了请求B然后才收到了请求A,这个时候请求处理的顺序不同就会导致数据的不同,从而 产生数据不一致问题 。\n所以在 Leader 这端,它为每个其他的 zkServer 准备了一个 队列 ,采用先进先出的方式发送消息。由于协议是 通过 TCP 来进行网络通信的,保证了消息的发送顺序性,接受顺序性也得到了保证。\n队列的先进先出+tcp的发送顺序性,保证了接收顺序的一致性\n除此之外,在 ZAB 中还定义了一个 全局单调递增的事务ID ZXID ,它是一个64位long型,其中高32位表示 epoch 年代,低32位表示事务id。epoch 是会根据 Leader 的变化而变化的,当一个 Leader 挂了,新的 Leader 上位的时候,年代(epoch)就变了。而低32位可以简单理解为递增的事务id。\n定义这个的原因也是为了顺序性,每个 proposal 在 Leader 中生成后需要 通过其 ZXID 来进行排序 ,才能得到处理。\n5.4. 崩溃恢复模式 # 说到崩溃恢复我们首先要提到 ZAB 中的 Leader 选举算法,当系统出现崩溃影响最大应该是 Leader 的崩溃,因为我们只有一个 Leader ,所以当 Leader 出现问题的时候我们势必需要重新选举 Leader 。\nLeader 选举可以分为两个不同的阶段,第一个是我们提到的 Leader 宕机需要重新选举,第二则是当 Zookeeper 启动时需要进行系统的 Leader 初始化选举。下面我先来介绍一下 ZAB 是如何进行初始化选举的。\n假设我们集群中有3台机器,那也就意味着我们需要两台以上同意(超过半数)。比如这个时候我们启动了 server1 ,它会首先 投票给自己 ,投票内容为服务器的 myid 和 ZXID ,因为初始化所以 ZXID 都为0,此时 server1 发出的投票为 (1,0)。但此时 server1 的投票仅为1,所以不能作为 Leader ,此时还在选举阶段所以整个集群处于 Looking 状态。\n接着 server2 启动了,它首先也会将投票选给自己(2,0),并将投票信息广播出去(server1也会,只是它那时没有其他的服务器了),server1 在收到 server2 的投票信息后会将投票信息与自己的作比较。首先它会比较 ZXID ,ZXID 大的优先为 Leader,如果相同则比较 myid,myid 大的优先作为 Leader。所以此时server1 发现 server2 更适合做 Leader,它就会将自己的投票信息更改为(2,0)然后再广播出去,之后server2 收到之后发现和自己的一样无需做更改,并且自己的 投票已经超过半数 ,则 确定 server2 为 Leader,server1 也会将自己服务器设置为 Following 变为 Follower。整个服务器就从 Looking 变为了正常状态。\n当 server3 启动发现集群没有处于 Looking 状态时,它会直接以 Follower 的身份加入集群。\n还是前面三个 server 的例子,如果在整个集群运行的过程中 server2 挂了,那么整个集群会如何重新选举 Leader 呢?其实和初始化选举差不多。\n首先毫无疑问的是剩下的两个 Follower 会将自己的状态 从 Following 变为 Looking 状态 ,然后每个 server 会向初始化投票一样首先给自己投票(这不过这里的 zxid 可能不是0了,这里为了方便随便取个数字)。\n假设 server1 给自己投票为(1,99),然后广播给其他 server,server3 首先也会给自己投票(3,95),然后也广播给其他 server。server1 和 server3 此时会收到彼此的投票信息,和一开始选举一样,他们也会比较自己的投票和收到的投票(zxid 大的优先,如果相同那么就 myid 大的优先)。这个时候 server1 收到了 server3 的投票发现没自己的合适故不变,server3 收到 server1 的投票结果后发现比自己的合适于是更改投票为(1,99)然后广播出去,最后 server1 收到了发现自己的投票已经超过半数就把自己设为 Leader,server3 也随之变为 Follower。\n请注意 ZooKeeper 为什么要设置奇数个结点?比如这里我们是三个,挂了一个我们还能正常工作,挂了两个我们就不能正常工作了(已经没有超过半数的节点数了,所以无法进行投票等操作了)。而假设我们现在有四个,挂了一个也能工作,但是挂了两个也不能正常工作了,这是和三个一样的,而三个比四个还少一个,带来的效益是一样的,所以 Zookeeper 推荐奇数个 server 。\n那么说完了 ZAB 中的 Leader 选举方式之后我们再来了解一下 崩溃恢复 是什么玩意?\n其实主要就是 当集群中有机器挂了,我们整个集群如何保证数据一致性?\n如果只是 Follower 挂了,而且挂的(总数)没超过半数的时候,因为我们一开始讲了在 Leader 中会维护队列,所以不用担心后面的数据没接收到导致数据不一致性。\n如果 Leader 挂了那就麻烦了,我们肯定需要先暂停服务变为 Looking 状态然后进行 Leader 的重新选举(上面我讲过了),但这个就要分为两种情况了,分别是 确保已经被Leader提交的提案最终能够被所有的Follower提交 和 跳过那些已经被丢弃的提案 。\n确保已经被Leader提交的提案最终能够被所有的Follower提交是什么意思呢?\n假设 Leader (server2) 发送 commit 请求(忘了请看上面的消息广播模式),他发送给了 server3,然后要发给 server1 的时候突然挂了。这个时候重新选举的时候我们如果把 server1 作为 Leader 的话,那么肯定会产生数据不一致性,因为 server3 肯定会提交刚刚 server2 发送的 commit 请求的提案,而 server1 根本没收到所以会丢弃。\n那怎么解决呢?\n聪明的同学肯定会质疑,这个时候 server1 已经不可能成为 Leader 了,因为 server1 和 server3 进行投票选举的时候会比较 ZXID ,而此时 server3 的 ZXID 肯定比 server1 的大了。(不理解可以看前面的选举算法)\n那么跳过那些已经被丢弃的提案又是什么意思呢?\n假设 Leader (server2) 此时同意了提案N1,自身提交了这个事务并且要发送给所有 Follower 要 commit 的请求,却在这个时候挂了,此时肯定要重新进行 Leader 的选举,比如说此时选 server1 为 Leader (这无所谓)。但是过了一会,这个 挂掉的 Leader 又重新恢复了 ,此时它肯定会作为 Follower 的身份进入集群中,需要注意的是刚刚 server2 已经同意提交了提案N1,但其他 server 并没有收到它的 commit 信息,所以其他 server 不可能再提交这个提案N1了,这样就会出现数据不一致性问题了,所以 该提案N1最终需要被抛弃掉 。\n6. Zookeeper的几个理论知识 # 了解了 ZAB 协议还不够,它仅仅是 Zookeeper 内部实现的一种方式,而我们如何通过 Zookeeper 去做一些典型的应用场景呢?比如说集群管理,分布式锁,Master 选举等等。\n这就涉及到如何使用 Zookeeper 了,但在使用之前我们还需要掌握几个概念。比如 Zookeeper 的 数据模型 、会话机制、ACL、Watcher机制 等等。\n6.1. 数据模型 # zookeeper 数据存储结构与标准的 Unix 文件系统非常相似,都是在根节点下挂很多子节点(树型)。但是 zookeeper 中没有文件系统中目录与文件的概念,而是 使用了 znode 作为数据节点 。znode 是 zookeeper 中的最小数据单元,每个 znode 上都可以保存数据,同时还可以挂载子节点,形成一个树形化命名空间。\n每个 znode 都有自己所属的 节点类型 和 节点状态。\n其中节点类型可以分为 持久节点、持久顺序节点、临时节点 和 临时顺序节点。\n持久节点:一旦创建就一直存在,直到将其删除。 持久顺序节点:一个父节点可以为其子节点 维护一个创建的先后顺序 ,这个顺序体现在 节点名称 上,是节点名称后自动添加一个由 10 位数字组成的数字串,从 0 开始计数。 临时节点:临时节点的生命周期是与 客户端会话 绑定的,会话消失则节点消失 。临时节点 只能做叶子节点 ,不能创建子节点。 临时顺序节点:父节点可以创建一个维持了顺序的临时节点(和前面的持久顺序性节点一样)。 节点状态中包含了很多节点的属性比如 czxid 、mzxid 等等,在 zookeeper 中是使用 Stat 这个类来维护的。下面我列举一些属性解释。\nczxid:Created ZXID,该数据节点被 创建 时的事务ID。 mzxid:Modified ZXID,节点 最后一次被更新时 的事务ID。 ctime:Created Time,该节点被创建的时间。 mtime: Modified Time,该节点最后一次被修改的时间。 version:节点的版本号。 cversion:子节点 的版本号。 aversion:节点的 ACL 版本号。 ephemeralOwner:创建该节点的会话的 sessionID ,如果该节点为持久节点,该值为0。 dataLength:节点数据内容的长度。 numChildre:该节点的子节点个数,如果为临时节点为0。 pzxid:该节点子节点列表最后一次被修改时的事务ID,注意是子节点的 列表 ,不是内容。 6.2. 会话 # 我想这个对于后端开发的朋友肯定不陌生,不就是 session 吗?只不过 zk 客户端和服务端是通过 TCP 长连接 维持的会话机制,其实对于会话来说你可以理解为 保持连接状态 。\n在 zookeeper 中,会话还有对应的事件,比如 CONNECTION_LOSS 连接丢失事件 、SESSION_MOVED 会话转移事件 、SESSION_EXPIRED 会话超时失效事件 。\n6.3. ACL # ACL 为 Access Control Lists ,它是一种权限控制。在 zookeeper 中定义了5种权限,它们分别为:\nCREATE :创建子节点的权限。 READ:获取节点数据和子节点列表的权限。 WRITE:更新节点数据的权限。 DELETE:删除子节点的权限。 ADMIN:设置节点 ACL 的权限。 6.4. Watcher机制 # Watcher 为事件监听器,是 zk 非常重要的一个特性,很多功能都依赖于它,它有点类似于订阅的方式,即客户端向服务端 注册 指定的 watcher ,当服务端符合了 watcher 的某些事件或要求则会 向客户端发送事件通知 ,客户端收到通知后找到自己定义的 Watcher 然后 执行相应的回调方法 。\n7. Zookeeper的几个典型应用场景 # 前面说了这么多的理论知识,你可能听得一头雾水,这些玩意有啥用?能干啥事?别急,听我慢慢道来。\n7.1. 选主 # 还记得上面我们的所说的临时节点吗?因为 Zookeeper 的强一致性,能够很好地在保证 在高并发的情况下保证节点创建的全局唯一性 (即无法重复创建同样的节点)。\n利用这个特性,我们可以 让多个客户端创建一个指定的节点 ,创建成功的就是 master。\n但是,如果这个 master 挂了怎么办???\n你想想为什么我们要创建临时节点?还记得临时节点的生命周期吗?master 挂了是不是代表会话断了?会话断了是不是意味着这个节点没了?还记得 watcher 吗?我们是不是可以 让其他不是 master 的节点监听节点的状态 ,比如说我们监听这个临时节点的父节点,如果子节点个数变了就代表 master 挂了,这个时候我们 触发回调函数进行重新选举 ,或者我们直接监听节点的状态,我们可以通过节点是否已经失去连接来判断 master 是否挂了等等。\n总的来说,我们可以完全 利用 临时节点、节点状态 和 watcher 来实现选主的功能,临时节点主要用来选举,节点状态和**watcher** 可以用来判断 master 的活性和进行重新选举。\n7.2. 分布式锁 # 分布式锁的实现方式有很多种,比如 Redis 、数据库 、zookeeper 等。个人认为 zookeeper 在实现分布式锁这方面是非常非常简单的。\n上面我们已经提到过了 zk在高并发的情况下保证节点创建的全局唯一性,这玩意一看就知道能干啥了。实现互斥锁呗,又因为能在分布式的情况下,所以能实现分布式锁呗。\n如何实现呢?这玩意其实跟选主基本一样,我们也可以利用临时节点的创建来实现。\n首先肯定是如何获取锁,因为创建节点的唯一性,我们可以让多个客户端同时创建一个临时节点,创建成功的就说明获取到了锁 。然后没有获取到锁的客户端也像上面选主的非主节点创建一个 watcher 进行节点状态的监听,如果这个互斥锁被释放了(可能获取锁的客户端宕机了,或者那个客户端主动释放了锁)可以调用回调函数重新获得锁。\nzk 中不需要向 redis 那样考虑锁得不到释放的问题了,因为当客户端挂了,节点也挂了,锁也释放了。是不是很简单?\n那能不能使用 zookeeper 同时实现 共享锁和独占锁 呢?答案是可以的,不过稍微有点复杂而已。\n还记得 有序的节点 吗?\n这个时候我规定所有创建节点必须有序,当你是读请求(要获取共享锁)的话,如果 没有比自己更小的节点,或比自己小的节点都是读请求 ,则可以获取到读锁,然后就可以开始读了。若比自己小的节点中有写请求 ,则当前客户端无法获取到读锁,只能等待前面的写请求完成。\n如果你是写请求(获取独占锁),若 没有比自己更小的节点 ,则表示当前客户端可以直接获取到写锁,对数据进行修改。若发现 有比自己更小的节点,无论是读操作还是写操作,当前客户端都无法获取到写锁 ,等待所有前面的操作完成。\n这就很好地同时实现了共享锁和独占锁,当然还有优化的地方,比如当一个锁得到释放它会通知所有等待的客户端从而造成 羊群效应 。此时你可以通过让等待的节点只监听他们前面的节点。\n具体怎么做呢?其实也很简单,你可以让 读请求监听比自己小的最后一个写请求节点,写请求只监听比自己小的最后一个节点 ,感兴趣的小伙伴可以自己去研究一下。\n7.3. 命名服务 # 如何给一个对象设置ID,大家可能都会想到 UUID,但是 UUID 最大的问题就在于它太长了。。。(太长不一定是好事,嘿嘿嘿)。那么在条件允许的情况下,我们能不能使用 zookeeper 来实现呢?\n我们之前提到过 zookeeper 是通过 树形结构 来存储数据节点的,那也就是说,对于每个节点的 全路径,它必定是唯一的,我们可以使用节点的全路径作为命名方式了。而且更重要的是,路径是我们可以自己定义的,这对于我们对有些有语意的对象的ID设置可以更加便于理解。\n7.4. 集群管理和注册中心 # 看到这里是不是觉得 zookeeper 实在是太强大了,它怎么能这么能干!\n别急,它能干的事情还很多呢。可能我们会有这样的需求,我们需要了解整个集群中有多少机器在工作,我们想对集群中的每台机器的运行时状态进行数据采集,对集群中机器进行上下线操作等等。\n而 zookeeper 天然支持的 watcher 和 临时节点能很好的实现这些需求。我们可以为每条机器创建临时节点,并监控其父节点,如果子节点列表有变动(我们可能创建删除了临时节点),那么我们可以使用在其父节点绑定的 watcher 进行状态监控和回调。\n至于注册中心也很简单,我们同样也是让 服务提供者 在 zookeeper 中创建一个临时节点并且将自己的 ip、port、调用方式 写入节点,当 服务消费者 需要进行调用的时候会 通过注册中心找到相应的服务的地址列表(IP端口什么的) ,并缓存到本地(方便以后调用),当消费者调用服务时,不会再去请求注册中心,而是直接通过负载均衡算法从地址列表中取一个服务提供者的服务器调用服务。\n当服务提供者的某台服务器宕机或下线时,相应的地址会从服务提供者地址列表中移除。同时,注册中心会将新的服务地址列表发送给服务消费者的机器并缓存在消费者本机(当然你可以让消费者进行节点监听,我记得 Eureka 会先试错,然后再更新)。\n8. 总结 # 看到这里的同学实在是太有耐心了👍👍👍,如果觉得我写得不错的话点个赞哈。\n不知道大家是否还记得我讲了什么😒。\n这篇文章中我带大家入门了 zookeeper 这个强大的分布式协调框架。现在我们来简单梳理一下整篇文章的内容。\n分布式与集群的区别\n2PC 、3PC 以及 paxos 算法这些一致性框架的原理和实现。\nzookeeper 专门的一致性算法 ZAB 原子广播协议的内容(Leader 选举、崩溃恢复、消息广播)。\nzookeeper 中的一些基本概念,比如 ACL,数据节点,会话,watcher机制等等。\nzookeeper 的典型应用场景,比如选主,注册中心等等。\n如果忘了可以回去看看再次理解一下,如果有疑问和建议欢迎提出🤝🤝🤝。\n"},{"id":273,"href":"/zh/docs/technology/Review/java_guide/lydly_distributed_system/ly06ly_zookeeper-intro/","title":"zookeeper介绍","section":"分布式系统","content":" 转载自https://github.com/Snailclimb/JavaGuide(添加小部分笔记)感谢作者!\n1. 前言 # 相信大家对 ZooKeeper 应该不算陌生。但是你真的了解 ZooKeeper 到底有啥用不?如果别人/面试官让你给他讲讲对于 ZooKeeper 的认识,你能回答到什么地步呢?\n拿我自己来说吧!我本人曾经使用 Dubbo 来做分布式项目的时候,使用了 ZooKeeper 作为注册中心。为了保证分布式系统能够同步访问某个资源,我还使用 ZooKeeper 做过分布式锁。另外,我在学习 Kafka 的时候,知道 Kafka 很多功能的实现依赖了 ZooKeeper。\n前几天,总结项目经验的时候,我突然问自己 ZooKeeper 到底是个什么东西?想了半天,脑海中只是简单的能浮现出几句话:\nZooKeeper 可以被用作注册中心、分布式锁; ZooKeeper 是 Hadoop 生态系统的一员; 构建 ZooKeeper 集群的时候,使用的服务器最好是奇数台。 由此可见,我对于 ZooKeeper 的理解仅仅是停留在了表面。\n所以,通过本文,希望带大家稍微详细的了解一下 ZooKeeper 。如果没有学过 ZooKeeper ,那么本文将会是你进入 ZooKeeper 大门的垫脚砖。如果你已经接触过 ZooKeeper ,那么本文将带你回顾一下 ZooKeeper 的一些基础概念。\n另外,本文不光会涉及到 ZooKeeper 的一些概念,后面的文章会介绍到 ZooKeeper 常见命令的使用以及使用 Apache Curator 作为 ZooKeeper 的客户端。\n如果文章有任何需要改善和完善的地方,欢迎在评论区指出,共同进步!\n2. ZooKeeper 介绍 # 2.1. ZooKeeper 由来 # 正式介绍 ZooKeeper 之前,我们先来看看 ZooKeeper 的由来,还挺有意思的。\n下面这段内容摘自《从 Paxos 到 ZooKeeper 》第四章第一节,推荐大家阅读一下:\nZooKeeper 最早起源于雅虎研究院的一个研究小组。在当时,研究人员发现,在雅虎内部很多大型系统基本都需要依赖一个类似的系统来进行分布式协调,但是这些系统往往都存在分布式单点问题。所以,雅虎的开发人员就试图开发一个通用的无单点问题的分布式协调框架,以便让开发人员将精力集中在处理业务逻辑上。\n关于“ZooKeeper”这个项目的名字,其实也有一段趣闻。在立项初期,考虑到之前内部很多项目都是使用动物的名字来命名的(例如著名的 Pig 项目),雅虎的工程师希望给这个项目也取一个动物的名字。时任研究院的首席科学家 RaghuRamakrishnan 开玩笑地说:“在这样下去,我们这儿就变成动物园了!”此话一出,大家纷纷表示就叫动物园管理员吧一一一因为各个以动物命名的分布式组件放在一起,雅虎的整个分布式系统看上去就像一个大型的动物园了,而 ZooKeeper 正好要用来进行分布式环境的协调一一于是,ZooKeeper 的名字也就由此诞生了。\n2.2. ZooKeeper 概览 # ZooKeeper 是一个开源的分布式协调服务,它的设计目标是将那些复杂且容易出错的分布式一致性服务封装起来,构成一个高效可靠的原语集,并以一系列简单易用的接口提供给用户使用。\n原语: 操作系统或计算机网络用语范畴。是由若干条指令组成的,用于完成一定功能的一个过程。具有不可分割性·即原语的执行必须是连续的,在执行过程中不允许被中断。\nZooKeeper 为我们提供了高可用、高性能、稳定的分布式数据一致性解决方案,通常被用于实现诸如数据发布/订阅、负载均衡、命名服务、分布式协调/通知、集群管理、Master 选举、分布式锁和分布式队列等功能。\n另外,ZooKeeper 将数据保存在内存中,性能是非常棒的。 在“读”多于“写”的应用程序中尤其地高性能,因为“写”会导致所有的服务器间同步状态。(“读”多于“写”是协调服务的典型场景)。\n2.3. ZooKeeper 特点 # 顺序一致性: 从同一客户端发起的事务请求,最终将会严格地按照顺序被应用到 ZooKeeper 中去。 原子性: 所有事务请求的处理结果在整个集群中所有机器上的应用情况是一致的,也就是说,要么整个集群中所有的机器都成功应用了某一个事务,要么都没有应用。 单一系统映像 : 无论客户端连到哪一个 ZooKeeper 服务器上,其看到的服务端数据模型都是一致的。 可靠性: 一旦一次更改请求被应用,更改的结果就会被持久化,直到被下一次更改覆盖。 2.4. ZooKeeper 典型应用场景 # ZooKeeper 概览中,我们介绍到使用其通常被用于实现诸如数据发布/订阅、负载均衡、命名服务、分布式协调/通知、集群管理、Master 选举、分布式锁和分布式队列等功能。\n下面选 3 个典型的应用场景来专门说说:\n分布式锁 : 通过创建唯一节点获得分布式锁,当获得锁的一方执行完相关代码或者是挂掉之后就释放锁。 命名服务 :可以通过 ZooKeeper 的顺序节点生成全局唯一 ID 数据发布/订阅 :通过 Watcher 机制 可以很方便地实现数据发布/订阅。当你将数据发布到 ZooKeeper 被监听的节点上,其他机器可通过监听 ZooKeeper 上节点的变化来实现配置的动态更新。 实际上,这些功能的实现基本都得益于 ZooKeeper 可以保存数据的功能,但是 ZooKeeper 不适合保存大量数据,这一点需要注意。\n2.5. 有哪些著名的开源项目用到了 ZooKeeper? # Kafka : ZooKeeper 主要为 Kafka 提供 Broker 和 Topic 的注册以及多个 Partition 的负载均衡等功能。 Hbase : ZooKeeper 为 Hbase 提供确保整个集群只有一个 Master 以及保存和提供 regionserver 状态信息(是否在线)等功能。 Hadoop : ZooKeeper 为 Namenode 提供高可用支持。 3. ZooKeeper 重要概念解读 # 破音:拿出小本本,下面的内容非常重要哦!\n3.1. Data model(数据模型) # ZooKeeper 数据模型采用层次化的多叉树形结构,每个节点上都可以存储数据,这些数据可以是数字、字符串或者是二级制序列。并且。每个节点还可以拥有 N 个子节点,最上层是根节点以“/”来代表。每个数据节点在 ZooKeeper 中被称为 znode,它是 ZooKeeper 中数据的最小单元。并且,每个 znode 都一个唯一的路径标识。\n强调一句:ZooKeeper 主要是用来协调服务的,而不是用来存储业务数据的,所以不要放比较大的数据在 znode 上,ZooKeeper 给出的上限是每个结点的数据大小最大是 1M。\n从下图可以更直观地看出:ZooKeeper 节点路径标识方式和 Unix 文件系统路径非常相似,都是由一系列使用斜杠\u0026quot;/\u0026ldquo;进行分割的路径表示,开发人员可以向这个节点中写入数据,也可以在节点下面创建子节点。这些操作我们后面都会介绍到。\n3.2. znode(数据节点) # 介绍了 ZooKeeper 树形数据模型之后,我们知道每个数据节点在 ZooKeeper 中被称为 znode,它是 ZooKeeper 中数据的最小单元。你要存放的数据就放在上面,是你使用 ZooKeeper 过程中经常需要接触到的一个概念。\n3.2.1. znode 4 种类型 # 我们通常是将 znode 分为 4 大类:\n持久(PERSISTENT)节点 :一旦创建就一直存在即使 ZooKeeper 集群宕机,直到将其删除。 临时(EPHEMERAL)节点 :临时节点的生命周期是与 客户端会话(session) 绑定的,会话消失则节点消失 。并且,临时节点只能做叶子节点 ,不能创建子节点。 持久顺序(PERSISTENT_SEQUENTIAL)节点 :除了具有持久(PERSISTENT)节点的特性之外, 子节点的名称还具有顺序性。比如 /node1/app0000000001 、/node1/app0000000002 。 临时顺序(EPHEMERAL_SEQUENTIAL)节点 :除了具备临时(EPHEMERAL)节点的特性之外,子节点的名称还具有顺序性。 3.2.2. znode 数据结构 # 每个 znode 由 2 部分组成:\nstat :状态信息 data : 节点存放的数据的具体内容 如下所示,我通过 get 命令来获取 根目录下的 dubbo 节点的内容。(get 命令在下面会介绍到)。\n[zk: 127.0.0.1:2181(CONNECTED) 6] get /dubbo # 该数据节点关联的数据内容为空 null # 下面是该数据节点的一些状态信息,其实就是 Stat 对象的格式化输出 cZxid = 0x2 ctime = Tue Nov 27 11:05:34 CST 2018 mZxid = 0x2 mtime = Tue Nov 27 11:05:34 CST 2018 pZxid = 0x3 cversion = 1 dataVersion = 0 aclVersion = 0 ephemeralOwner = 0x0 dataLength = 0 numChildren = 1 Stat 类中包含了一个数据节点的所有状态信息的字段,包括事务 ID-cZxid、节点创建时间-ctime 和子节点个数-numChildren 等等。\n下面我们来看一下每个 znode 状态信息究竟代表的是什么吧!(下面的内容来源于《从 Paxos 到 ZooKeeper 分布式一致性原理与实践》,因为 Guide 确实也不是特别清楚,要学会参考资料的嘛! ) :\nznode 状态信息 解释 cZxid create ZXID,即该数据节点被创建时的事务 id ctime create time,即该节点的创建时间 mZxid modified ZXID,即该节点最终一次更新时的事务 id mtime modified time,即该节点最后一次的更新时间 pZxid 该节点的子节点列表最后一次修改时的事务 id,只有子节点列表变更才会更新 pZxid,子节点内容变更不会更新 cversion 子节点版本号,当前节点的子节点每次变化时值增加 1 dataVersion 数据节点内容版本号,节点创建时为 0,每更新一次节点内容(不管内容有无变化)该版本号的值增加 1 aclVersion 节点的 ACL 版本号,表示该节点 ACL 信息变更次数 ephemeralOwner 创建该临时节点的会话的 sessionId;如果当前节点为持久节点,则 ephemeralOwner=0 dataLength 数据节点内容长度 numChildren 当前节点的子节点个数 3.3. 版本(version) # 在前面我们已经提到,对应于每个 znode,ZooKeeper 都会为其维护一个叫作 Stat 的数据结构,Stat 中记录了这个 znode 的三个相关的版本:\ndataVersion :当前 znode 节点的版本号 cversion : 当前 znode 子节点的版本 aclVersion : 当前 znode 的 ACL 的版本。 3.4. ACL(权限控制) # ZooKeeper 采用 ACL(AccessControlLists)策略来进行权限控制,类似于 UNIX 文件系统的权限控制。\n对于 znode 操作的权限,ZooKeeper 提供了以下 5 种:\nCREATE : 能创建子节点 READ :能获取节点数据和列出其子节点 WRITE : 能设置/更新节点数据 DELETE : 能删除子节点 ADMIN : 能设置节点 ACL 的权限 其中尤其需要注意的是,CREATE 和 DELETE 这两种权限都是针对 子节点 的权限控制。\n对于身份认证,提供了以下几种方式:\nworld : 默认方式,所有用户都可无条件访问。 auth :不使用任何 id,代表任何已认证的用户。 digest :用户名:密码认证方式: username:password 。 ip : 对指定 ip 进行限制。 3.5. Watcher(事件监听器) # Watcher(事件监听器),是 ZooKeeper 中的一个很重要的特性。ZooKeeper 允许用户在指定节点上注册一些 Watcher,并且在一些特定事件触发的时候,ZooKeeper 服务端会将事件通知到感兴趣的客户端上去,该机制是 ZooKeeper 实现分布式协调服务的重要特性。\n破音:非常有用的一个特性,都拿出小本本记好了,后面用到 ZooKeeper 基本离不开 Watcher(事件监听器)机制。\n3.6. 会话(Session) # Session 可以看作是 ZooKeeper 服务器与客户端的之间的一个 TCP 长连接,通过这个连接,客户端能够通过心跳检测与服务器保持有效的会话,也能够向 ZooKeeper 服务器发送请求并接受响应,同时还能够通过该连接接收来自服务器的 Watcher 事件通知。\nSession 有一个属性叫做:sessionTimeout ,sessionTimeout 代表会话的超时时间。当由于服务器压力太大、网络故障或是客户端主动断开连接等各种原因导致客户端连接断开时,只要在**sessionTimeout规定的时间内能够重新连接上集群中任意一台服务器,那么之前创建的会话仍然有效**。\n另外,在为客户端创建会话之前,服务端首先会为每个客户端都分配一个 sessionID。由于 sessionID是 ZooKeeper 会话的一个重要标识,许多与会话相关的运行机制都是基于这个 sessionID 的,因此,无论是哪台服务器为客户端分配的 sessionID,都务必保证全局唯一。\n4. ZooKeeper 集群 # 为了保证高可用,最好是以集群形态来部署 ZooKeeper,这样只要集群中大部分机器是可用的(能够容忍一定的机器故障),那么 ZooKeeper 本身仍然是可用的。通常 3 台服务器就可以构成一个 ZooKeeper 集群了。ZooKeeper 官方提供的架构图就是一个 ZooKeeper 集群整体对外提供服务。\n[!\n上图中每一个 Server 代表一个安装 ZooKeeper 服务的服务器。组成 ZooKeeper 服务的服务器都会在内存中维护当前的服务器状态,并且每台服务器之间都互相保持着通信。集群间通过 **ZAB 协议(ZooKeeper Atomic Broadcast)**来保持数据的一致性。\n[ zookeeper中不是使用这个 ]\n最典型集群模式: Master/Slave 模式(主备模式)。在这种模式中,通常 Master 服务器作为主服务器提供写服务,其他的 Slave 服务器从服务器通过异步复制的方式获取 Master 服务器最新的数据提供读服务。\n4.1. ZooKeeper 集群角色 # 但是,在 ZooKeeper 中没有选择传统的 Master/Slave 概念,而是引入了 Leader、Follower 和 Observer 三种角色。如下图所示\n[!\nZooKeeper 集群中的所有机器通过一个 Leader 选举过程 来选定一台称为 “Leader” 的机器,Leader 既可以为客户端提供写服务又能提供读服务。除了 Leader 外,Follower 和 Observer 都只能提供读服务。Follower 和 Observer 唯一的区别在于 Observer 机器不参与 Leader 的选举过程,也不参与写操作的“过半写成功”策略,因此 Observer 机器可以在不影响写性能的情况下提升集群的读性能。\n角色 说明 Leader 为客户端提供读和写的服务,负责投票的发起和决议,更新系统状态。 Follower 为客户端提供读服务,如果是写服务则转发给 Leader。参与选举过程中的投票。 Observer 为客户端提供读服务,如果是写服务则转发给 Leader。不参与选举过程中的投票,也**不参与“过半写成功”**策略。在不影响写性能的情况下提升集群的读性能。此角色于 ZooKeeper3.3 系列新增的角色。 当 Leader 服务器出现网络中断、崩溃退出与重启等异常情况时,就会进入 Leader 选举过程,这个过程会选举产生新的 Leader 服务器。\n这个过程大致是这样的:\nLeader election(选举阶段):节点在一开始都处于选举阶段,只要有一个节点得到超半数节点的票数,它就可以当选准 leader。 Discovery(发现阶段) :在这个阶段,followers 跟准 leader 进行通信,同步 followers 最近接收的事务提议。 Synchronization(同步阶段) :同步阶段主要是利用 leader 前一阶段获得的最新提议历史,同步集群中所有的副本。同步完成之后 准 leader 才会成为真正的 leader。 Broadcast(广播阶段) :到了这个阶段,ZooKeeper 集群才能正式对外提供事务服务,并且 leader 可以进行消息广播。同时如果有新的节点加入,还需要对新节点进行同步。 4.2. ZooKeeper 集群中的服务器状态 # LOOKING :寻找 Leader。 LEADING :Leader 状态,对应的节点为 Leader。 FOLLOWING :Follower 状态,对应的节点为 Follower。 OBSERVING :Observer 状态,对应节点为 Observer,该节点不参与 Leader 选举。 4.3. ZooKeeper 集群为啥最好奇数台? # ZooKeeper 集群在宕掉几个 ZooKeeper 服务器之后,如果剩下的 ZooKeeper 服务器个数大于宕掉的个数的话整个 ZooKeeper 才依然可用。假如我们的集群中有 n 台 ZooKeeper 服务器,那么也就是剩下的服务数必须大于 n/2。\n有点绕,换句话就是说最多的宕机数必须小于一半(等于也不行),那么如果是奇数x,那他只能小于x/2 即为除之后的整数部分,就算再加一台,也最多只能宕机(x+1)/2 -1(等于奇数 (x+1)/2 -1),所以没必要再多一台,并不能增加可宕机数 先说一下结论,2n 和 2n-1 的容忍度是一样的,都是 n-1,大家可以先自己仔细想一想,这应该是一个很简单的数学问题了。 比如假如我们有 3 台,那么最大允许宕掉 1 台 ZooKeeper 服务器,如果我们有 4 台的的时候也同样只允许宕掉 1 台。 假如我们有 5 台,那么最大允许宕掉 2 台 ZooKeeper 服务器,如果我们有 6 台的的时候也同样只允许宕掉 2 台。\n综上,何必增加那一个不必要的 ZooKeeper 呢?\n4.4. ZooKeeper 选举的过半机制防止脑裂 # 何为集群脑裂?\n对于一个集群,通常多台机器会部署在不同机房,来提高这个集群的可用性。保证可用性的同时,会发生一种机房间网络线路故障,导致机房间网络不通,而集群被割裂成几个小集群。这时候子集群各自选主导致“脑裂”的情况。\n举例说明:比如现在有一个由 6 台服务器所组成的一个集群,部署在了 2 个机房,每个机房 3 台。正常情况下只有 1 个 leader,但是当两个机房中间网络断开的时候,每个机房的 3 台服务器都会认为另一个机房的 3 台服务器下线,而选出自己的 leader 并对外提供服务。若没有过半机制,当网络恢复的时候会发现有 2 个 leader。仿佛是 1 个大脑(leader)分散成了 2 个大脑,这就发生了脑裂现象。脑裂期间 2 个大脑都可能对外提供了服务,这将会带来数据一致性等问题。\n过半机制是如何防止脑裂现象产生的?\nZooKeeper 的过半机制导致不可能产生 2 个 leader,因为少于等于一半是不可能产生 leader 的,这就使得不论机房的机器如何分配都不可能发生脑裂。\n5. ZAB 协议和 Paxos 算法 # Paxos 算法应该可以说是 ZooKeeper 的灵魂了。但是,ZooKeeper 并没有完全采用 Paxos 算法 ,而是使用 ZAB 协议作为其保证数据一致性的核心算法。另外,在 ZooKeeper 的官方文档中也指出,ZAB 协议并不像 Paxos 算法那样,是一种通用的分布式一致性算法,它是一种特别为 Zookeeper 设计的崩溃可恢复的原子消息广播算法。\n5.1. ZAB 协议介绍 # ZAB(ZooKeeper Atomic Broadcast 原子广播) 协议是为分布式协调服务 ZooKeeper 专门设计的一种支持崩溃恢复的原子广播协议。 在 ZooKeeper 中,主要依赖 ZAB 协议来实现分布式数据一致性,基于该协议,ZooKeeper 实现了一种主备模式的系统架构来保持集群中各个副本之间的数据一致性。\n5.2. ZAB 协议两种基本的模式:崩溃恢复和消息广播 # ZAB 协议包括两种基本的模式,分别是\n崩溃恢复 :当整个服务框架在启动过程中,或是当 Leader 服务器出现网络中断、崩溃退出与重启等异常情况时,ZAB 协议就会进入恢复模式并选举产生新的 Leader 服务器。当选举产生了新的 Leader 服务器,同时集群中已经有过半的机器与该 Leader 服务器完成了状态同步之后,ZAB 协议就会退出恢复模式。其中,所谓的状态同步是指数据同步,用来保证集群中存在过半的机器能够和 Leader 服务器的数据状态保持一致。 消息广播 :当集群中已经有过半的 Follower 服务器完成了和 Leader 服务器的状态同步,那么整个服务框架就可以进入消息广播模式了。 当一台同样遵守 ZAB 协议的服务器启动后加入到集群中时,如果此时集群中已经存在一个 Leader 服务器在负责进行消息广播,那么新加入的服务器就会自觉地进入数据恢复模式:找到 Leader 所在的服务器,并与其进行数据同步,然后一起参与到消息广播流程中去。 关于 ZAB 协议\u0026amp;Paxos 算法 需要讲和理解的东西太多了,具体可以看下面这两篇文章:\n图解 Paxos 一致性协议 Zookeeper ZAB 协议分析 6. 总结 # ZooKeeper 本身就是一个分布式程序(只要半数以上节点存活,ZooKeeper 就能正常服务)。 为了保证高可用,最好是以集群形态来部署 ZooKeeper,这样只要集群中大部分机器是可用的(能够容忍一定的机器故障),那么 ZooKeeper 本身仍然是可用的。 ZooKeeper 将数据保存在内存中,这也就保证了 高吞吐量和低延迟(但是内存限制了能够存储的容量不太大,此限制也是保持 znode 中存储的数据量较小的进一步原因)。 ZooKeeper 是高性能的。 在“读”多于“写”的应用程序中尤其地明显,因为“写”会导致所有的服务器间同步状态。(“读”多于“写”是协调服务的典型场景。) ZooKeeper 有临时节点的概念。 当创建临时节点的客户端会话一直保持活动,瞬时节点就一直存在。而当会话终结时,瞬时节点被删除。持久节点是指一旦这个 znode 被创建了,除非主动进行 znode 的移除操作,否则这个 znode 将一直保存在 ZooKeeper 上。 ZooKeeper 底层其实只提供了两个功能:① 管理(存储、读取)用户程序提交的数据;② 为用户程序提供数据节点监听服务。 7. 参考 # 《从 Paxos 到 ZooKeeper 分布式一致性原理与实践》 "},{"id":274,"href":"/zh/docs/technology/Review/java_guide/lydly_distributed_system/ly04ly_rpc-http/","title":"rpc_http","section":"分布式系统","content":" 转载自https://github.com/Snailclimb/JavaGuide(添加小部分笔记)感谢作者!\n我正在参与掘金技术社区创作者签约计划招募活动, 点击链接报名投稿。\n我想起了我刚工作的时候,第一次接触RPC协议,当时就很懵,我HTTP协议用的好好的,为什么还要用RPC协议?\n于是就到网上去搜。\n不少解释显得非常官方,我相信大家在各种平台上也都看到过,解释了又好像没解释,都在用一个我们不认识的概念去解释另外一个我们不认识的概念,懂的人不需要看,不懂的人看了还是不懂。\n这种看了,又好像没看的感觉,云里雾里的很难受,我懂。\n为了避免大家有强烈的审丑疲劳,今天我们来尝试重新换个方式讲一讲。\n从TCP聊起 # 作为一个程序员,假设我们需要在A电脑的进程发一段数据到B电脑的进程,我们一般会在代码里使用socket进行编程。\n这时候,我们可选项一般也就TCP和UDP二选一。TCP可靠,UDP不可靠。 除非是马总这种神级程序员(早期QQ大量使用UDP),否则,只要稍微对可靠性有些要求,普通人一般无脑选TCP就对了。\n类似下面这样。\nfd = socket(AF_INET,SOCK_STREAM,0); 复制代码 其中SOCK_STREAM,是指使用字节流传输数据,说白了就是TCP协议。\n在定义了socket之后,我们就可以愉快的对这个socket进行操作,比如用bind()绑定IP端口,用connect()发起建连。\n在连接建立之后,我们就可以使用send()发送数据,recv()接收数据。\n光这样一个纯裸的TCP连接,就可以做到收发数据了,那是不是就够了?\n不行,这么用会有问题。\n使用纯裸TCP会有什么问题 # 八股文常背,TCP是有三个特点,面向连接、可靠、基于字节流。\n这三个特点真的概括的非常精辟,这个八股文我们没白背。\n每个特点展开都能聊一篇文章,而今天我们需要关注的是基于字节流这一点。\n字节流可以理解为一个双向的通道里流淌的数据,这个数据其实就是我们常说的二进制数据,简单来说就是一大堆 01 串。纯裸TCP收发的这些 01 串之间是没有任何边界的,你根本不知道到哪个地方才算一条完整消息。 正因为这个没有任何边界的特点,所以当我们选择使用TCP发送 \u0026ldquo;夏洛\u0026quot;和\u0026quot;特烦恼\u0026rdquo; 的时候,接收端收到的就是 \u0026ldquo;夏洛特烦恼\u0026rdquo; ,这时候接收端没发区分你是想要表达 \u0026ldquo;夏洛\u0026rdquo;+\u0026ldquo;特烦恼\u0026rdquo; 还是 \u0026ldquo;夏洛特\u0026rdquo;+\u0026ldquo;烦恼\u0026rdquo; 。\n这就是所谓的粘包问题,之前也写过一篇专门的 文章聊过这个问题。\n说这个的目的是为了告诉大家,纯裸TCP是不能直接拿来用的,你需要在这个基础上加入一些自定义的规则,用于区分消息边界。\n于是我们会把每条要发送的数据都包装一下,比如加入消息头,消息头里写清楚一个完整的包长度是多少,根据这个长度可以继续接收数据,截取出来后它们就是我们真正要传输的消息体。\n而这里头提到的消息头,还可以放各种东西,比如消息体是否被压缩过和消息体格式之类的,只要上下游都约定好了,互相都认就可以了,这就是所谓的协议。\n每个使用TCP的项目都可能会定义一套类似这样的协议解析标准,他们可能有区别,但原理都类似。\n于是基于TCP,就衍生了非常多的协议,比如HTTP和RPC。\nHTTP和RPC # 我们回过头来看网络的分层图。\nTCP是传输层的协议,而基于TCP造出来的HTTP和各类RPC协议,它们都只是定义了不同消息格式的应用层协议而已。\nHTTP协议(Hyper Text Transfer Protocol),又叫做超文本传输协议。我们用的比较多,平时上网在浏览器上敲个网址就能访问网页,这里用到的就是HTTP协议。\n而RPC(Remote Procedure Call),又叫做远程过程调用。它本身并不是一个具体的协议,而是一种调用方式。\n举个例子,我们平时调用一个本地方法就像下面这样。\nres = localFunc(req) 复制代码 如果现在这不是个本地方法,而是个远端服务器暴露出来的一个方法remoteFunc,如果我们还能像调用本地方法那样去调用它,这样就可以屏蔽掉一些网络细节,用起来更方便,岂不美哉?\nres = remoteFunc(req) 复制代码 基于这个思路,大佬们造出了非常多款式的RPC协议,比如比较有名的gRPC,thrift。\n值得注意的是,虽然大部分RPC协议底层使用TCP,但实际上它们不一定非得使用TCP,改用UDP或者HTTP,其实也可以做到类似的功能。\n到这里,我们回到文章标题的问题。\n既然有HTTP协议,为什么还要有RPC?\n其实,TCP是70年代出来的协议,而HTTP是90年代才开始流行的。而直接使用裸TCP会有问题,可想而知,这中间这么多年有多少自定义的协议,而这里面就有80年代出来的RPC。\n所以我们该问的不是既然有HTTP协议为什么要有RPC,而是为什么有RPC还要有HTTP协议。\n那既然有RPC了,为什么还要有HTTP呢? # 现在电脑上装的各种联网软件,比如xx管家,xx卫士,它们都作为客户端(client) 需要跟服务端(server) 建立连接收发消息,此时都会用到应用层协议,在这种client/server (c/s) 架构下,它们可以使用自家造的RPC协议,因为它只管连自己公司的服务器就ok了。\n但有个软件不同,浏览器(browser) ,不管是chrome还是IE,它们不仅要能访问自家公司的服务器(server) ,还需要访问其他公司的网站服务器,因此它们需要有个统一的标准,不然大家没法交流。于是,HTTP就是那个时代用于统一 browser/server (b/s) 的协议。\n也就是说在多年以前,HTTP主要用于b/s架构,而RPC更多用于c/s架构。但现在其实已经没分那么清了,b/s和c/s在慢慢融合。 很多软件同时支持多端,比如某度云盘,既要支持网页版,还要支持手机端和pc端,如果通信协议都用HTTP的话,那服务器只用同一套就够了。而RPC就开始退居幕后,一般用于公司内部集群里,各个微服务之间的通讯。\n那这么说的话,都用HTTP得了,还用什么RPC?\n仿佛又回到了文章开头的样子,那这就要从它们之间的区别开始说起。\nHTTP和RPC有什么区别 # 我们来看看RPC和HTTP区别比较明显的几个点。\n服务发现 # 首先要向某个服务器发起请求,你得先建立连接,而建立连接的前提是,你得知道IP地址和端口。这个找到服务对应的IP端口的过程,其实就是服务发现。\n在HTTP中,你知道服务的域名,就可以通过DNS服务去解析得到它背后的IP地址,默认80端口。\n而RPC的话,就有些区别,一般会有专门的中间服务去保存服务名和IP信息,比如consul或者etcd,甚至是redis。想要访问某个服务,就去这些中间服务去获得IP和端口信息。由于dns也是服务发现的一种,所以也有基于dns去做服务发现的组件,比如CoreDNS。\n可以看出服务发现这一块,两者是有些区别,但不太能分高低。\n底层连接形式 # 以主流的HTTP1.1协议为例,其默认在建立底层TCP连接之后会一直保持这个连接(keep alive),之后的请求和响应都会复用这条连接。\n而RPC协议,也跟HTTP类似,也是通过建立TCP长链接进行数据交互,但不同的地方在于,RPC协议一般还会再建个连接池,在请求量大的时候,建立多条连接放在池内,要发数据的时候就从池里取一条连接出来,用完放回去,下次再复用,可以说非常环保。\n由于连接池有利于提升网络请求性能,所以不少编程语言的网络库里都会给HTTP加个连接池,比如go就是这么干的。\n可以看出这一块两者也没太大区别,所以也不是关键。\n传输的内容 # 基于TCP传输的消息,说到底,无非都是消息头header和消息体body。\nheader是用于标记一些特殊信息,其中最重要的是消息体长度。\nbody则是放我们真正需要传输的内容,而这些内容只能是二进制01串,毕竟计算机只认识这玩意。所以TCP传字符串和数字都问题不大,因为字符串可以转成编码再变成01串,而数字本身也能直接转为二进制。但结构体呢,我们得想个办法将它也转为二进制01串,这样的方案现在也有很多现成的,比如json,protobuf。\n这个将结构体转为二进制数组的过程就叫序列化,反过来将二进制数组复原成结构体的过程叫反序列化。\n对于主流的HTTP1.1,虽然它现在叫超文本协议,支持音频视频,但HTTP设计初是用于做网页文本展示的,所以它传的内容以字符串为主。header和body都是如此。在body这块,它使用json来序列化结构体数据。\n我们可以随便截个图直观看下。\n可以看到这里面的内容非常多的冗余,显得非常啰嗦。最明显的,像header里的那些信息,其实如果我们约定好头部的第几位是content-type,就不需要每次都真的把\u0026quot;content-type\u0026quot;这个字段都传过来,类似的情况其实在body的json结构里也特别明显。\n而RPC,因为它定制化程度更高,可以采用体积更小的protobuf或其他序列化协议去保存结构体数据,同时也不需要像HTTP那样考虑各种浏览器行为,比如302重定向跳转啥的。因此性能也会更好一些,这也是在公司内部微服务中抛弃HTTP,选择使用RPC的最主要原因。\n====缺一张图片====\n当然上面说的HTTP,其实特指的是现在主流使用的HTTP1.1,HTTP2在前者的基础上做了很多改进,所以性能可能比很多RPC协议还要好,甚至连gRPC底层都直接用的HTTP2。\n那么问题又来了。\n为什么既然有了HTTP2,还要有RPC协议? # 这个是由于HTTP2是2015年出来的。那时候很多公司内部的RPC协议都已经跑了好些年了,基于历史原因,一般也没必要去换了。\n总结 # 纯裸TCP是能收发数据,但它是个无边界的数据流,上层需要定义消息格式用于定义消息边界。于是就有了各种协议,HTTP和各类RPC协议就是在TCP之上定义的应用层协议。 RPC本质上不算是协议,而是一种调用方式,而像gRPC和thrift这样的具体实现,才是协议,它们是实现了RPC调用的协议。目的是希望程序员能像调用本地方法那样去调用远端的服务方法。同时RPC有很多种实现方式,不一定非得基于TCP协议。 从发展历史来说,HTTP主要用于b/s架构,而RPC更多用于c/s架构。但现在其实已经没分那么清了,b/s和c/s在慢慢融合。 很多软件同时支持多端,所以对外一般用HTTP协议,而内部集群的微服务之间则采用RPC协议进行通讯。 RPC其实比HTTP出现的要早,且比目前主流的HTTP1.1性能要更好,所以大部分公司内部都还在使用RPC。 HTTP2.0在HTTP1.1的基础上做了优化,性能可能比很多RPC协议都要好,但由于是这几年才出来的,所以也不太可能取代掉RPC。 最后留个问题吧,大家有没有发现,不管是HTTP还是RPC,它们都有个特点,那就是消息都是客户端请求,服务端响应。客户端没问,服务端肯定就不答,这就有点僵了,但现实中肯定有需要下游主动发送消息给上游的场景,比如打个网页游戏,站在那啥也不操作,怪也会主动攻击我,这种情况该怎么办呢?\n最后 # 按照惯例,我应该在这里唯唯诺诺的求大家叫我两声靓仔的。\n但还是算了。因为我最近一直在想一个问题,希望兄弟们能在评论区告诉我答案。\n最近手机借给别人玩了一下午,现在老是给我推荐练习时长两年半的练习生视频。\n每个视频都在声嘶力竭的告诉我,鸡你太美。\n所以我很想问,兄弟们。\n鸡,到底美不美?\n头疼。\n右下角的点赞和再看还是可以走一波的。\n先这样。\n我是小白,我们下期见。\n别说了,一起在知识的海洋里呛水吧 # "},{"id":275,"href":"/zh/docs/technology/Review/java_guide/lydly_distributed_system/ly05ly_rpc-intro/","title":"rpc基础及面试题","section":"分布式系统","content":" 转载自https://github.com/Snailclimb/JavaGuide(添加小部分笔记)感谢作者!\n简单介绍一下 RPC 相关的基础概念。\n何为 RPC? # RPC(Remote Procedure Call) 即远程过程调用,通过名字我们就能看出 RPC 关注的是远程调用而非本地调用。\n为什么要 RPC ? 因为,两个不同的服务器上的服务提供的方法不在一个内存空间,所以,需要通过网络编程才能传递方法调用所需要的参数。并且,方法调用的结果也需要通过网络编程来接收。但是,如果我们自己手动网络编程来实现这个调用过程的话工作量是非常大的,因为,我们需要考虑底层传输方式(TCP还是UDP)、序列化方式等等方面。\nRPC 能帮助我们做什么呢? 简单来说,通过 RPC 可以帮助我们调用远程计算机上某个服务的方法,这个过程就像调用本地方法一样简单。并且!我们不需要了解底层网络编程的具体细节。\n举个例子:两个不同的服务 A、B 部署在两台不同的机器上,服务 A 如果想要调用服务 B 中的某个方法的话就可以通过 RPC 来做。\n一言蔽之:RPC 的出现就是为了让你调用远程方法像调用本地方法一样简单。\nRPC 的原理是什么? # 为了能够帮助小伙伴们理解 RPC 原理,我们可以将整个 RPC的 核心功能看作是下面👇 5 个部分实现的:\n客户端(服务消费端) :调用远程方法的一端。 客户端 Stub(桩) : 这其实就是一代理类。代理类主要做的事情很简单,就是把你调用方法、类、方法参数等信息传递到服务端。 网络传输 : 网络传输就是你要把你调用的方法的信息比如说参数啊这些东西传输到服务端,然后服务端执行完之后再把返回结果通过网络传输给你传输回来。网络传输的实现方式有很多种比如最近基本的 Socket或者性能以及封装更加优秀的 Netty(推荐)。 服务端 Stub(桩) :这个桩就不是代理类了。我觉得理解为桩实际不太好,大家注意一下就好。这里的服务端 Stub 实际指的就是接收到客户端执行方法的请求后,去指定对应的方法然后返回结果给客户端的类。 服务端(服务提供端) :提供远程方法的一端。 具体原理图如下,后面我会串起来将整个RPC的过程给大家说一下。\n服务消费端(client)以本地调用的方式调用远程服务; 客户端 Stub(client stub) 接收到调用后负责将方法、参数等组装成能够进行网络传输的消息体(序列化):RpcRequest; 客户端 Stub(client stub) 找到远程服务的地址,并将消息发送到服务提供端; 服务端 Stub(桩)收到消息将消息反序列化为Java对象: RpcRequest; 服务端 Stub(桩)根据RpcRequest中的类、方法、方法参数等信息调用本地的方法; 服务端 Stub(桩)得到方法执行结果并将组装成能够进行网络传输的消息体:RpcResponse(序列化)发送至消费方; 客户端 Stub(client stub)接收到消息并将消息反序列化为Java对象:RpcResponse ,这样也就得到了最终结果。over! 相信小伙伴们看完上面的讲解之后,已经了解了 RPC 的原理。\n虽然篇幅不多,但是基本把 RPC 框架的核心原理讲清楚了!另外,对于上面的技术细节,我会在后面的章节介绍到。\n最后,对于 RPC 的原理,希望小伙伴不单单要理解,还要能够自己画出来并且能够给别人讲出来。因为,在面试中这个问题在面试官问到 RPC 相关内容的时候基本都会碰到。\n有哪些常见的 RPC 框架? # 我们这里说的 RPC 框架指的是可以让客户端直接调用服务端方法,就像调用本地方法一样简单的框架,比如我下面介绍的 Dubbo、Motan、gRPC这些。 如果需要和 HTTP 协议打交道,解析和封装 HTTP 请求和响应。这类框架并不能算是“RPC 框架”,比如Feign。\nDubbo # Apache Dubbo 是一款微服务框架,为大规模微服务实践提供高性能 RPC 通信、流量治理、可观测性等解决方案, 涵盖 Java、Golang 等多种语言 SDK 实现。\nDubbo 提供了从服务定义、服务发现、服务通信到流量管控等几乎所有的服务治理能力,支持 Triple 协议(基于 HTTP/2 之上定义的下一代 RPC 通信协议)、应用级服务发现、Dubbo Mesh (Dubbo3 赋予了很多云原生友好的新特性)等特性。\nDubbo 是由阿里开源,后来加入了 Apache 。正是由于 Dubbo 的出现,才使得越来越多的公司开始使用以及接受分布式架构。\nDubbo 算的是比较优秀的国产开源项目了,它的源码也是非常值得学习和阅读的!\nGithub :https://github.com/apache/incubator-dubbo 官网:https://dubbo.apache.org/zh/ Motan # Motan 是新浪微博开源的一款 RPC 框架,据说在新浪微博正支撑着千亿次调用。不过笔者倒是很少看到有公司使用,而且网上的资料也比较少。\n很多人喜欢拿 Motan 和 Dubbo 作比较,毕竟都是国内大公司开源的。笔者在查阅了很多资料,以及简单查看了其源码之后发现:Motan 更像是一个精简版的 Dubbo,可能是借鉴了 Dubbo 的思想,Motan 的设计更加精简,功能更加纯粹。\n不过,我不推荐你在实际项目中使用 Motan。如果你要是公司实际使用的话,还是推荐 Dubbo ,其社区活跃度以及生态都要好很多。\n从 Motan 看 RPC 框架设计:http://kriszhang.com/motan-rpc-impl/ Motan 中文文档:https://github.com/weibocom/motan/wiki/zh_overview gRPC # gRPC 是 Google 开源的一个高性能、通用的开源 RPC 框架。其由主要面向移动应用开发并基于 HTTP/2 协议标准而设计(支持双向流、消息头压缩等功能,更加节省带宽),基于 ProtoBuf 序列化协议开发,并且支持众多开发语言。\n何谓 ProtoBuf? ProtoBuf( Protocol Buffer) 是一种更加灵活、高效的数据格式,可用于通讯协议、数据存储等领域,基本支持所有主流编程语言且与平台无关。不过,通过 ProtoBuf 定义接口和数据类型还挺繁琐的,这是一个小问题。\n不得不说,gRPC 的通信层的设计还是非常优秀的, Dubbo-go 3.0 的通信层改进主要借鉴了 gRPC。\n不过,gRPC 的设计导致其几乎没有服务治理能力。如果你想要解决这个问题的话,就需要依赖其他组件比如腾讯的 PolarisMesh(北极星)了。\nGithub:https://github.com/grpc/grpc 官网:https://grpc.io/ Thrift # Apache Thrift 是 Facebook 开源的跨语言的 RPC 通信框架,目前已经捐献给 Apache 基金会管理,由于其跨语言特性和出色的性能,在很多互联网公司得到应用,有能力的公司甚至会基于 thrift 研发一套分布式服务框架,增加诸如服务注册、服务发现等功能。\nThrift支持多种不同的编程语言,包括C++、Java、Python、PHP、Ruby等(相比于 gRPC 支持的语言更多 )。\n官网:https://thrift.apache.org/ Thrift 简单介绍:https://www.jianshu.com/p/8f25d057a5a9 总结 # gRPC 和 Thrift 虽然支持跨语言的 RPC 调用,但是它们只提供了最基本的 RPC 框架功能,缺乏一系列配套的服务化组件和服务治理功能的支撑。\nDubbo 不论是从功能完善程度、生态系统还是社区活跃度来说都是最优秀的。而且,Dubbo在国内有很多成功的案例比如当当网、滴滴等等,是一款经得起生产考验的成熟稳定的 RPC 框架。最重要的是你还能找到非常多的 Dubbo 参考资料,学习成本相对也较低。\n下图展示了 Dubbo 的生态系统。\nDubbo 也是 Spring Cloud Alibaba 里面的一个组件。\n但是,Dubbo 和 Motan 主要是给 Java 语言使用。虽然,Dubbo 和 Motan 目前也能兼容部分语言,但是不太推荐。如果需要跨多种语言调用的话,可以考虑使用 gRPC。\n综上,如果是 Java 后端技术栈,并且你在纠结选择哪一种 RPC 框架的话,我推荐你考虑一下 Dubbo。\n如何设计并实现一个 RPC 框架? # 《手写 RPC 框架》 是我的 知识星球的一个内部小册,我写了 12 篇文章来讲解如何从零开始基于 Netty+Kyro+Zookeeper 实现一个简易的 RPC 框架。\n麻雀虽小五脏俱全,项目代码注释详细,结构清晰,并且集成了 Check Style 规范代码结构,非常适合阅读和学习。\n内容概览 :\n既然有了 HTTP 协议,为什么还要有 RPC ? # HTTP 和 RPC 详细对比 。\n"},{"id":276,"href":"/zh/docs/technology/Review/java_guide/lydly_distributed_system/ly03ly_distributed-lock/","title":"分布式锁","section":"分布式系统","content":" 转载自https://github.com/Snailclimb/JavaGuide(添加小部分笔记)感谢作者!\n网上有很多分布式锁相关的文章,写了一个相对简洁易懂的版本,针对面试和工作应该够用了。\n什么是分布式锁? # 对于单机多线程来说,在 Java 中,我们通常使用 ReetrantLock 类、synchronized 关键字这类 JDK 自带的 本地锁 来控制一个 JVM 进程内的多个线程对本地共享资源的访问。\n下面是我对本地锁画的一张示意图。\n从图中可以看出,这些线程访问共享资源是互斥的,同一时刻只有一个线程可以获取到本地锁访问共享资源。\n分布式系统下,不同的服务/客户端通常运行在独立的 JVM 进程上。如果多个 JVM 进程共享同一份资源的话,使用本地锁就没办法实现资源的互斥访问了。于是,分布式锁 就诞生了。\n举个例子:系统的订单服务一共部署了 3 份,都对外提供服务。用户下订单之前需要检查库存,为了防止超卖,这里需要加锁以实现对检查库存操作的同步访问。由于订单服务位于不同的 JVM 进程中,本地锁在这种情况下就没办法正常工作了。我们需要用到分布式锁,这样的话,即使多个线程不在同一个 JVM 进程中也能获取到同一把锁,进而实现共享资源的互斥访问。\n下面是我对分布式锁画的一张示意图。\n从图中可以看出,这些独立的进程中的线程访问共享资源是互斥的,同一时刻只有一个线程可以获取到分布式锁访问共享资源。\n一个最基本的分布式锁需要满足:\n互斥 :任意一个时刻,锁只能被一个线程持有; 高可用 :锁服务是高可用的。并且,即使客户端的释放锁的代码逻辑出现问题(这里说的是异常,不是说代码写的有问题),锁最终一定还是会被释放,不会影响其他线程对共享资源的访问。 可重入:(同)一个节点获取了锁之后,还可以再次获取锁。 通常情况下,我们一般会选择基于 Redis 或者 ZooKeeper 实现分布式锁,Redis 用的要更多一点,我这里也以 Redis 为例介绍分布式锁的实现。\n基于 Redis 实现分布式锁 # 如何基于 Redis 实现一个最简易的分布式锁? # 不论是实现锁(本地)还是分布式锁,核心都在于**“互斥”**。\n在 Redis 中, SETNX 命令是可以帮助我们实现互斥。SETNX 即 SET if Not eXists (对应 Java 中的 setIfAbsent 方法),如果 key 不存在的话,才会设置 key 的值。如果 key 已经存在, SETNX 啥也不做。\n\u0026gt; SETNX lockKey uniqueValue (integer) 1 \u0026gt; SETNX lockKey uniqueValue (integer) 0 #如上成功为1,失败为0 释放锁的话,直接通过 DEL 命令删除对应的 key 即可。\n\u0026gt; DEL lockKey (integer) 1 # 成功为1 为了误删到其他的锁,这里我们建议使用 Lua 脚本通过 key 对应的 value(唯一值)来判断。\n选用 Lua 脚本是为了保证解锁操作的原子性。因为 Redis 在执行 Lua 脚本时,可以以原子性的方式执行,从而保证了锁释放操作的原子性。\n// 释放锁时,先比较锁对应的 value 值是否相等,避免锁的误释放 if redis.call(\u0026#34;get\u0026#34;,KEYS[1]) == ARGV[1] then return redis.call(\u0026#34;del\u0026#34;,KEYS[1]) else return 0 end 这是一种最简易的 Redis 分布式锁实现,实现方式比较简单,性能也很高效。不过,这种方式实现分布式锁存在一些问题。就比如应用程序遇到一些问题比如释放锁的逻辑突然挂掉,可能会导致锁无法被释放,进而造成共享资源无法再被其他线程/进程访问。\n为什么要给锁设置一个过期时间? # 为了避免锁无法被释放,我们可以想到的一个解决办法就是: 给这个 key(也就是锁) 设置一个过期时间 。\n127.0.0.1:6379\u0026gt; SET lockKey uniqueValue EX 3 NX OK lockKey :加锁的锁名; uniqueValue :能够唯一标示锁的随机字符串; NX :只有当 lockKey 对应的 key 值不存在的时候才能 SET 成功; EX :过期时间设置(秒为单位)EX 3 标示这个锁有一个 3 秒的自动过期时间。与 EX 对应的是 PX(毫秒为单位),这两个都是过期时间设置。 一定要保证设置指定 key 的值和过期时间是一个原子操作!!! 不然的话,依然可能会出现锁无法被释放的问题。\n这样确实可以解决问题,不过,这种解决办法同样存在漏洞:如果操作共享资源的时间大于过期时间,就会出现锁提前过期的问题,进而导致分布式锁直接失效。如果锁的超时时间设置过长,又会影响到性能。\n你或许在想: 如果操作共享资源的操作还未完成,锁过期时间能够自己续期就好了!\n如何实现锁的优雅续期? # 对于 Java 开发的小伙伴来说,已经有了现成的解决方案: Redisson 。其他语言的解决方案,可以在 Redis 官方文档中找到,地址:https://redis.io/topics/distlock 。\nRedisson 是一个开源的 Java 语言 Redis 客户端,提供了很多开箱即用的功能,不仅仅包括多种分布式锁的实现。并且,Redisson 还支持 Redis 单机、Redis Sentinel 、Redis Cluster 等多种部署架构。\nRedisson 中的分布式锁自带自动续期机制,使用起来非常简单,原理也比较简单,其提供了一个专门用来监控和续期锁的 Watch Dog( 看门狗),如果操作共享资源的线程还未执行完成的话,Watch Dog 会不断地延长锁的过期时间,进而保证锁不会因为超时而被释放。\n如图,续期之前也是要检测是否为持锁线程\n看门狗名字的由来于 getLockWatchdogTimeout() 方法,这个方法返回的是看门狗给锁续期的过期时间,默认为 30 秒( redisson-3.17.6)。\n//默认 30秒,支持修改 private long lockWatchdogTimeout = 30 * 1000; public Config setLockWatchdogTimeout(long lockWatchdogTimeout) { this.lockWatchdogTimeout = lockWatchdogTimeout; return this; } public long getLockWatchdogTimeout() { return lockWatchdogTimeout; } renewExpiration() 方法包含了看门狗的主要逻辑:\nprivate void renewExpiration() { //...... Timeout task = commandExecutor.getConnectionManager().newTimeout(new TimerTask() { @Override public void run(Timeout timeout) throws Exception { //...... // 异步续期,基于 Lua 脚本(ly:我觉得是为了保证原子性所以用了Lua脚本) CompletionStage\u0026lt;Boolean\u0026gt; future = renewExpirationAsync(threadId); future.whenComplete((res, e) -\u0026gt; { if (e != null) { // 无法续期 log.error(\u0026#34;Can\u0026#39;t update lock \u0026#34; + getRawName() + \u0026#34; expiration\u0026#34;, e); EXPIRATION_RENEWAL_MAP.remove(getEntryName()); return; } if (res) { // 递归调用实现续期 renewExpiration(); } else { // 取消续期 cancelExpirationRenewal(null); } }); } // 延迟 internalLockLeaseTime/3(默认 10s,也就是 30/3) 再调用 }, internalLockLeaseTime / 3, TimeUnit.MILLISECONDS); ee.setTimeout(task); } 默认情况下,每过 10 秒,看门狗就会执行续期操作,将锁的超时时间设置为 30 秒。看门狗续期前也会先判断是否需要执行续期操作,需要才会执行续期,否则取消续期操作。\nWatch Dog 通过调用 renewExpirationAsync() 方法实现锁的异步续期:\nprotected CompletionStage\u0026lt;Boolean\u0026gt; renewExpirationAsync(long threadId) { return evalWriteAsync(getRawName(), LongCodec.INSTANCE, RedisCommands.EVAL_BOOLEAN, // 判断是否为持锁线程,如果是就执行续期操作,就锁的过期时间设置为 30s(默认) \u0026#34;if (redis.call(\u0026#39;hexists\u0026#39;, KEYS[1], ARGV[2]) == 1) then \u0026#34; + \u0026#34;redis.call(\u0026#39;pexpire\u0026#39;, KEYS[1], ARGV[1]); \u0026#34; + \u0026#34;return 1; \u0026#34; + \u0026#34;end; \u0026#34; + \u0026#34;return 0;\u0026#34;, Collections.singletonList(getRawName()), internalLockLeaseTime, getLockName(threadId)); } 可以看出, renewExpirationAsync 方法其实是调用 Lua 脚本实现的续期,这样做主要是为了保证续期操作的原子性。\n我这里以 Redisson 的分布式可重入锁 RLock 为例来说明如何使用 Redisson 实现分布式锁:\n// 1.获取指定的分布式锁对象 RLock lock = redisson.getLock(\u0026#34;lock\u0026#34;); // 2.拿锁且不设置锁超时时间,具备 Watch Dog 自动续期机制 lock.lock(); // 3.执行业务 ... // 4.释放锁 lock.unlock(); 只有未指定锁超时时间,才会使用到 Watch Dog 自动续期机制。\n// 手动给锁设置过期时间,不具备 Watch Dog 自动续期机制 lock.lock(10, TimeUnit.SECONDS); 如果使用 Redis 来实现分布式锁的话,还是比较推荐直接基于 Redisson 来做的。\n如何实现可重入锁? # 所谓可重入锁指的是在一个线程中可以多次获取同一把锁,比如一个线程在执行一个带锁的方法,该方法中又调用了另一个需要相同锁的方法,则该线程可以直接执行调用的方法即可重入 ,而无需重新获得锁。像 Java 中的 synchronized 和 ReentrantLock 都属于可重入锁。\n不可重入的分布式锁基本可以满足绝大部分业务场景了,一些特殊的场景可能会需要使用可重入的分布式锁。\n可重入分布式锁的实现核心思路是线程在获取锁的时候判断是否为自己的锁,如果是的话,就不用再重新获取了。为此,我们可以为每个锁关联一个可重入计数器和一个占有它的线程。当可重入计数器大于 0 时,则锁被占有,需要判断占有该锁的线程和请求获取锁的线程是否为同一个。\n实际项目中,我们不需要自己手动实现,推荐使用我们上面提到的 Redisson ,其内置了多种类型的锁比如可重入锁(Reentrant Lock)、自旋锁(Spin Lock)、公平锁(Fair Lock)、多重锁(MultiLock)、 红锁(RedLock)、 读写锁(ReadWriteLock)。\nRedis 如何解决集群情况下分布式锁的可靠性? # 为了避免单点故障(也就是只部署在一台机器,导致一台机器挂了服务就无法运行并提供功能),生产环境下的 Redis 服务通常是集群化部署的。\nRedis 集群下,上面介绍到的分布式锁的实现会存在一些问题。由于 Redis 集群数据同步到各个节点时是异步的,如果在 Redis 主节点获取到锁后,在没有同步到其他节点时,Redis 主节点宕机了,此时新的 Redis 主节点依然可以获取锁,所以多个应用服务就可以同时获取到锁。\n针对这个问题,Redis 之父 antirez 设计了 Redlock 算法 来解决。\nRedlock 算法的思想是让客户端向 Redis 集群中的多个独立的 Redis 实例 依次请求申请加锁,如果客户端能够和半数以上的实例成功地完成加锁操作,那么我们就认为,客户端成功地获得分布式锁,否则加锁失败。\n即使部分 Redis 节点出现问题,只要保证 Redis 集群中有半数以上的 Redis 节点可用,分布式锁服务就是正常的。\nRedlock 是直接操作 Redis 节点的,并不是通过 Redis 集群操作的,这样才可以避免 Redis 集群主从切换导致的锁丢失问题。\n注意,不是通过Redis集群做的哦\nRedlock 实现比较复杂,性能比较差,发生时钟变迁的情况下还存在安全性隐患。《数据密集型应用系统设计》一书的作者 Martin Kleppmann 曾经专门发文( How to do distributed locking - Martin Kleppmann - 2016)怼过 Redlock,他认为这是一个很差的分布式锁实现。感兴趣的朋友可以看看 Redis 锁从面试连环炮聊到神仙打架这篇文章,有详细介绍到 antirez 和 Martin Kleppmann 关于 Redlock 的激烈辩论。\n实际项目中不建议使用 Redlock 算法,成本和收益不成正比。\n如果不是非要实现绝对可靠的分布式锁的话,其实单机版 Redis 就完全够了,实现简单,性能也非常高。如果你必须要实现一个绝对可靠的分布式锁的话,可以基于 Zookeeper 来做,只是性能会差一些。\n"},{"id":277,"href":"/zh/docs/technology/Review/java_guide/lydly_distributed_system/ly02ly_distributed-id/","title":"分布式id","section":"分布式系统","content":" 转载自https://github.com/Snailclimb/JavaGuide(添加小部分笔记)感谢作者!\n分布式 ID 介绍 # 什么是 ID? # 日常开发中,我们需要对系统中的各种数据使用 ID 唯一表示,比如用户 ID 对应且仅对应一个人,商品 ID 对应且仅对应一件商品,订单 ID 对应且仅对应一个订单。\n我们现实生活中也有各种 ID,比如身份证 ID 对应且仅对应一个人、地址 ID 对应且仅对应\n简单来说,ID 就是数据的唯一标识。\n什么是分布式 ID? # 分布式 ID 是分布式系统下的 ID。分布式 ID 不存在与现实生活中(属于技术上的问题,跟业务无关),属于计算机系统中的一个概念。\n我简单举一个分库分表的例子。\n我司的一个项目,使用的是单机 MySQL 。但是,没想到的是,项目上线一个月之后,随着使用人数越来越多,整个系统的数据量将越来越大。单机 MySQL 已经没办法支撑了,需要进行分库分表(推荐 Sharding-JDBC)。\n在分库之后, 数据遍布在不同服务器上的数据库,数据库的自增主键已经没办法满足生成的主键唯一了。我们如何为不同的数据节点生成全局唯一主键呢?\n这个时候就需要生成分布式 ID了。\n分布式 ID 需要满足哪些要求? # 分布式 ID 作为分布式系统中必不可少的一环,很多地方都要用到分布式 ID。\n一个最基本的分布式 ID 需要满足下面这些要求:\n全局唯一 :ID 的全局唯一性肯定是首先要满足的! 高性能 : 分布式 ID 的生成速度要快,对本地资源消耗要小。 高可用 :生成分布式 ID 的服务要保证可用性无限接近于 100%。 方便易用 :拿来即用,使用方便,快速接入! 除了这些之外,一个比较好的分布式 ID 还应保证:\n安全 :ID 中不包含敏感信息。 有序递增 :如果要把 ID 存放在数据库的话,ID 的有序性可以提升数据库写入速度。并且,很多时候 ,我们还很有可能会直接通过 ID 来进行排序。 有具体的业务含义 :生成的 ID 如果能有具体的业务含义,可以让定位问题以及开发更透明化(通过 ID 就能确定是哪个业务)。 独立部署 :也就是分布式系统单独有一个发号器服务,专门用来生成分布式 ID。这样就生成 ID 的服务可以和业务相关的服务解耦。不过,这样同样带来了网络调用消耗增加的问题。总的来说,如果需要用到分布式 ID 的场景比较多的话,独立部署的发号器服务还是很有必要的。 分布式 ID 常见解决方案 # 这里说的是如何获取到一个分布式ID,而不是具体分布式ID的使用\n数据库 # 数据库主键自增 # 这种方式就比较简单直白了,就是通过关系型数据库的自增主键产生来唯一的 ID。\n以 MySQL 举例,我们通过下面的方式即可。\n1.创建一个数据库表。\nCREATE TABLE `sequence_id` ( `id` bigint(20) unsigned NOT NULL AUTO_INCREMENT, `stub` char(10) NOT NULL DEFAULT \u0026#39;\u0026#39;, PRIMARY KEY (`id`), UNIQUE KEY `stub` (`stub`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; stub 字段无意义,只是为了占位,便于我们插入或者修改数据。并且,给 stub 字段创建了唯一索引,保证其唯一性。\n2.通过 replace into 来插入数据。\nBEGIN; REPLACE INTO sequence_id (stub) VALUES (\u0026#39;stub\u0026#39;); SELECT LAST_INSERT_ID(); COMMIT; 插入数据这里,我们没有使用 insert into 而是使用 replace into 来插入数据,具体步骤是这样的:\n1)第一步: 尝试把数据插入到表中。\n2)第二步: 如果主键或唯一索引字段出现重复数据错误而插入失败时,先从表中删除含有重复关键字值的冲突行,然后再次尝试把数据插入到表中。\n使用replace只是用来删除行,没有什么特殊含义\n这种方式的优缺点也比较明显:\n优点 :实现起来比较简单、ID 有序递增、存储消耗空间小 缺点 : 支持的并发量不大、存在数据库单点问题(可以使用数据库集群解决,不过增加了复杂度)、ID 没有具体业务含义、安全问题(比如根据订单 ID 的递增规律就能推算出每天的订单量,商业机密啊! )、每次获取 ID 都要访问一次数据库(增加了对数据库的压力,获取速度也慢) 数据库号段模式 # 数据库主键自增这种模式,每次获取 ID 都要访问一次数据库,ID 需求比较大的时候,肯定是不行的。\n如果我们可以批量获取,然后存在在内存里面,需要用到的时候,直接从内存里面拿就舒服了!这也就是我们说的 基于数据库的号段模式来生成分布式 ID。\n数据库的号段模式也是目前比较主流的一种分布式 ID 生成方式。像滴滴开源的 Tinyid 就是基于这种方式来做的。不过,TinyId 使用了双号段缓存、增加多 db 支持等方式来进一步优化。\n以 MySQL 举例,我们通过下面的方式即可。\n1.创建一个数据库表。\nCREATE TABLE `sequence_id_generator` ( `id` int(10) NOT NULL, `current_max_id` bigint(20) NOT NULL COMMENT \u0026#39;当前最大id\u0026#39;, `step` int(10) NOT NULL COMMENT \u0026#39;号段的长度\u0026#39;, `version` int(20) NOT NULL COMMENT \u0026#39;版本号\u0026#39;, `biz_type` int(20) NOT NULL COMMENT \u0026#39;业务类型\u0026#39;, PRIMARY KEY (`id`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; current_max_id 字段和step字段主要用于获取批量 ID,获取的批量 id 为: current_max_id ~ current_max_id+step。\nversion 字段主要用于解决并发问题(乐观锁),biz_type 主要用于表示业务类型。\n2.先插入一行数据。\nINSERT INTO `sequence_id_generator` (`id`, `current_max_id`, `step`, `version`, `biz_type`) VALUES (1, 0, 100, 0, 101); 3.通过 SELECT 获取指定业务下的批量唯一 ID\nSELECT `current_max_id`, `step`,`version` FROM `sequence_id_generator` where `biz_type` = 101 结果:\nid\tcurrent_max_id\tstep\tversion\tbiz_type 1\t0\t100\t0\t101 4.不够用的话,更新之后重新 SELECT 即可。\nUPDATE sequence_id_generator SET current_max_id = 0+100, version=version+1 WHERE version = 0 AND `biz_type` = 101 SELECT `current_max_id`, `step`,`version` FROM `sequence_id_generator` where `biz_type` = 101 结果:\nid\tcurrent_max_id\tstep\tversion\tbiz_type 1\t100\t100\t1\t101 相比于数据库主键自增的方式,数据库的号段模式对于数据库的访问次数更少,数据库压力更小。\n另外,为了避免单点问题,你可以从使用主从模式来提高可用性。\n数据库号段模式的优缺点:\n优点 :ID 有序递增、存储消耗空间小 缺点 :存在数据库单点问题(可以使用数据库集群解决,不过增加了复杂度)、ID 没有具体业务含义、安全问题(比如根据订单 ID 的递增规律就能推算出每天的订单量,商业机密啊! ) NoSQL # 一般情况下,NoSQL 方案使用 Redis 多一些。我们通过 Redis 的 incr 命令即可实现对 id 原子顺序递增。\n127.0.0.1:6379\u0026gt; set sequence_id_biz_type 1 OK 127.0.0.1:6379\u0026gt; incr sequence_id_biz_type (integer) 2 127.0.0.1:6379\u0026gt; get sequence_id_biz_type \u0026#34;2\u0026#34; 为了提高可用性和并发,我们可以使用 Redis Cluster。Redis Cluster 是 Redis 官方提供的 Redis 集群解决方案(3.0+版本)。\n除了 Redis Cluster 之外,你也可以使用开源的 Redis 集群方案 Codis (大规模集群比如上百个节点的时候比较推荐)。\n除了高可用和并发之外,我们知道 Redis 基于内存,我们需要持久化数据,避免重启机器或者机器故障后数据丢失。Redis 支持两种不同的持久化方式:快照(snapshotting,RDB)、只追加文件(append-only file, AOF)。 并且,Redis 4.0 开始支持 RDB 和 AOF 的混合持久化(默认关闭,可以通过配置项 aof-use-rdb-preamble 开启)。\n关于 Redis 持久化,我这里就不过多介绍。不了解这部分内容的小伙伴,可以看看 JavaGuide 对于 Redis 知识点的总结。\nRedis 方案的优缺点:\n优点 : 性能不错并且生成的 ID 是有序递增的 缺点 : 和数据库主键自增方案的缺点类似 除了 Redis 之外,MongoDB ObjectId 经常也会被拿来当做分布式 ID 的解决方案。\nMongoDB ObjectId 一共需要 12 个字节存储:\n0~3:时间戳 3~6: 代表机器 ID 7~8:机器进程 ID 9~11 :自增值 MongoDB 方案的优缺点:\n优点 : 性能不错并且生成的 ID 是有序递增的 缺点 : 需要解决重复 ID 问题(当机器时间不对的情况下,可能导致会产生重复 ID) 、有安全性问题(ID 生成有规律性) 算法 # UUID # UUID 是 Universally Unique Identifier(通用唯一标识符) 的缩写。UUID 包含 32 个 16 进制数字(8-4-4-4-12)。\nJDK 就提供了现成的生成 UUID 的方法,一行代码就行了。\n//输出示例:cb4a9ede-fa5e-4585-b9bb-d60bce986eaa UUID.randomUUID() RFC 4122 中关于 UUID 的示例是这样的:\n我们这里重点关注一下这个 Version(版本),不同的版本对应的 UUID 的生成规则是不同的。\n5 种不同的 Version(版本)值分别对应的含义(参考 维基百科对于 UUID 的介绍):\n版本 1 : UUID 是根据时间和节点 ID(通常是 MAC 地址)生成; 版本 2 : UUID 是根据标识符(通常是组或用户 ID)、时间和节点 ID 生成; 版本 3、版本 5 : 版本 5 - 确定性 UUID 通过散列(hashing)名字空间(namespace)标识符和名称生成; 版本 4 : UUID 使用 随机性或 伪随机性生成。 下面是 Version 1 版本下生成的 UUID 的示例:\nJDK 中通过 UUID 的 randomUUID() 方法生成的 UUID 的版本默认为 4。\nUUID uuid = UUID.randomUUID(); int version = uuid.version();// 4 另外,Variant(变体)也有 4 种不同的值,这种值分别对应不同的含义。这里就不介绍了,貌似平时也不怎么需要关注。\n需要用到的时候,去看看维基百科对于 UUID 的 Variant(变体) 相关的介绍即可。\n从上面的介绍中可以看出,UUID 可以保证唯一性,因为其生成规则包括 MAC 地址、时间戳、名字空间(Namespace)、随机或伪随机数、时序等元素,计算机基于这些规则生成的 UUID 是肯定不会重复的。\n虽然,UUID 可以做到全局唯一性,但是,我们一般很少会使用它。\n比如使用 UUID 作为 MySQL 数据库主键的时候就非常不合适:\n数据库主键要尽量越短越好,而 UUID 的消耗的存储空间比较大(32 个字符串,128 位)。 UUID 是无顺序的,InnoDB 引擎下,数据库主键的无序性会严重影响数据库性能。 最后,我们再简单分析一下 UUID 的优缺点 (面试的时候可能会被问到的哦!) :\n优点 :生成速度比较快、简单易用 缺点 : 存储消耗空间大(32 个字符串,128 位) 、 不安全(基于 MAC 地址生成 UUID 的算法会造成 MAC 地址泄露)、无序(非自增)、没有具体业务含义、需要解决重复 ID 问题(当机器时间不对的情况下,可能导致会产生重复 ID) Snowflake(雪花算法) # Snowflake 是 Twitter 开源的分布式 ID 生成算法。Snowflake 由 64 bit 的二进制数字组成,这 64bit 的二进制被分成了几部分,每一部分存储的数据都有特定的含义:\n第 0 位: 符号位(标识正负),始终为 0,没有用,不用管。 第 1~41 位 :一共 41 位,用来表示时间戳,单位是毫秒,可以支撑 2 ^41 毫秒(约 69 年) 第 42~52 位 :一共 10 位,一般来说,前 5 位表示机房 ID,后 5 位表示机器 ID(实际项目中可以根据实际情况调整)。这样就可以区分不同集群/机房的节点。 第 53~64 位 :一共 12 位,用来表示序列号。 序列号为自增值,代表单台机器每毫秒能够产生的最大 ID 数(2^12 = 4096),也就是说单台机器每毫秒最多可以生成 4096 个 唯一 ID。 如果你想要使用 Snowflake 算法的话,一般不需要你自己再造轮子。有很多基于 Snowflake 算法的开源实现比如美团 的 Leaf、百度的 UidGenerator,并且这些开源实现对原有的 Snowflake 算法进行了优化。\n另外,在实际项目中,我们一般也会对 Snowflake 算法进行改造,最常见的就是在 Snowflake 算法生成的 ID 中加入业务类型信息。\n我们再来看看 Snowflake 算法的优缺点 :\n优点 :生成速度比较快、生成的 ID 有序递增、比较灵活(可以对 Snowflake 算法进行简单的改造比如加入业务 ID) 缺点 : 需要解决重复 ID 问题(依赖时间,当机器时间不对的情况下,可能导致会产生重复 ID)。 开源框架 # UidGenerator(百度) # UidGenerator 是百度开源的一款基于 Snowflake(雪花算法)的唯一 ID 生成器。\n不过,UidGenerator 对 Snowflake(雪花算法)进行了改进,生成的唯一 ID 组成如下。\n可以看出,和原始 Snowflake(雪花算法)生成的唯一 ID 的组成不太一样。并且,上面这些参数我们都可以自定义。\nUidGenerator 官方文档中的介绍如下:\n自 18 年后,UidGenerator 就基本没有再维护了,我这里也不过多介绍。想要进一步了解的朋友,可以看看 UidGenerator 的官方介绍。\nLeaf(美团) # Leaf 是美团开源的一个分布式 ID 解决方案 。这个项目的名字 Leaf(树叶) 起源于德国哲学家、数学家莱布尼茨的一句话: “There are no two identical leaves in the world”(世界上没有两片相同的树叶) 。这名字起得真心挺不错的,有点文艺青年那味了!\nLeaf 提供了 号段模式 和 Snowflake(雪花算法) 这两种模式来生成分布式 ID。并且,它支持双号段,还解决了雪花 ID 系统时钟回拨问题。不过,时钟问题的解决需要弱依赖于 Zookeeper 。\nLeaf 的诞生主要是为了解决美团各个业务线生成分布式 ID 的方法多种多样以及不可靠的问题。\nLeaf 对原有的号段模式进行改进,比如它这里增加了双号段避免获取 DB 在获取号段的时候阻塞请求获取 ID 的线程。简单来说,就是我一个号段还没用完之前,我自己就主动提前去获取下一个号段(图片来自于美团官方文章: 《Leaf——美团点评分布式 ID 生成系统》)。\n根据项目 README 介绍,在 4C8G VM 基础上,通过公司 RPC 方式调用,QPS 压测结果近 5w/s,TP999 1ms。\nTinyid(滴滴) # Tinyid 是滴滴开源的一款基于数据库号段模式的唯一 ID 生成器。\n数据库号段模式的原理我们在上面已经介绍过了。Tinyid 有哪些亮点呢?\n为了搞清楚这个问题,我们先来看看基于数据库号段模式的简单架构方案。(图片来自于 Tinyid 的官方 wiki: 《Tinyid 原理介绍》)\n在这种架构模式下,我们通过 HTTP 请求向发号器服务申请唯一 ID。负载均衡 router 会把我们的请求送往其中的一台 tinyid-server。\n这种方案有什么问题呢?在我看来(Tinyid 官方 wiki 也有介绍到),主要由下面这 2 个问题:\n获取新号段的情况下,程序获取唯一 ID 的速度比较慢。 需要保证 DB 高可用,这个是比较麻烦且耗费资源的。 除此之外,HTTP 调用也存在网络开销。\nTinyid 的原理比较简单,其架构如下图所示:\n相比于基于数据库号段模式的简单架构方案,Tinyid 方案主要做了下面这些优化:\n双号段缓存 :为了避免在获取新号段的情况下,程序获取唯一 ID 的速度比较慢。 Tinyid 中的号段在用到一定程度的时候,就会去异步加载下一个号段,保证内存中始终有可用号段。 增加多 db 支持 :支持多个 DB,并且,每个 DB 都能生成唯一 ID,提高了可用性。 增加 tinyid-client :纯本地操作,无 HTTP 请求消耗,性能和可用性都有很大提升。 Tinyid 的优缺点这里就不分析了,结合数据库号段模式的优缺点和 Tinyid 的原理就能知道。\n总结 # 通过这篇文章,我基本上已经把最常见的分布式 ID 生成方案都总结了一波。\n除了上面介绍的方式之外,像 ZooKeeper 这类中间件也可以帮助我们生成唯一 ID。没有银弹,一定要结合实际项目来选择最适合自己的方案。\n"},{"id":278,"href":"/zh/docs/technology/Review/java_guide/lydly_distributed_system/ly01ly_api-gateway/","title":"api网关","section":"分布式系统","content":" 转载自https://github.com/Snailclimb/JavaGuide(添加小部分笔记)感谢作者!\n什么是网关?有什么用? # 微服务背景下,一个系统被拆分为多个服务,但是像安全认证,流量控制,日志,监控等功能是每个服务都需要的,没有网关的话,我们就需要在每个服务中单独实现,这使得我们做了很多重复的事情并且没有一个全局的视图来统一管理这些功能。\n一般情况下,网关可以为我们提供请求转发、安全认证(身份/权限认证)、流量控制、负载均衡、降级熔断、日志、监控等功能。\n上面介绍了这么多功能,实际上,网关主要做了一件事情:请求过滤 。\n有哪些常见的网关系统? # Netflix Zuul # Zuul 是 Netflix 开发的一款提供动态路由、监控、弹性、安全的网关服务。\nZuul 主要通过过滤器(类似于 AOP)来过滤请求,从而实现网关必备的各种功能。\n我们可以自定义过滤器来处理请求,并且,Zuul 生态本身就有很多现成的过滤器供我们使用。就比如限流可以直接用国外朋友写的 spring-cloud-zuul-ratelimit (这里只是举例说明,一般是配合 hystrix 来做限流):\n\u0026lt;dependency\u0026gt; \u0026lt;groupId\u0026gt;org.springframework.cloud\u0026lt;/groupId\u0026gt; \u0026lt;artifactId\u0026gt;spring-cloud-starter-netflix-zuul\u0026lt;/artifactId\u0026gt; \u0026lt;/dependency\u0026gt; \u0026lt;dependency\u0026gt; \u0026lt;groupId\u0026gt;com.marcosbarbero.cloud\u0026lt;/groupId\u0026gt; \u0026lt;artifactId\u0026gt;spring-cloud-zuul-ratelimit\u0026lt;/artifactId\u0026gt; \u0026lt;version\u0026gt;2.2.0.RELEASE\u0026lt;/version\u0026gt; \u0026lt;/dependency\u0026gt; Zuul 1.x 基于同步 IO,性能较差。Zuul 2.x 基于 Netty 实现了异步 IO,性能得到了大幅改进。\nGithub 地址 : https://github.com/Netflix/zuul 官方 Wiki : https://github.com/Netflix/zuul/wiki Spring Cloud Gateway # SpringCloud Gateway 属于 Spring Cloud 生态系统中的网关,其诞生的目标是为了替代老牌网关 **Zuul **。准确点来说,应该是 Zuul 1.x。SpringCloud Gateway 起步要比 Zuul 2.x 更早。\n为了提升网关的性能,SpringCloud Gateway 基于 Spring WebFlux 。Spring WebFlux 使用 Reactor 库来实现响应式编程模型,底层基于 Netty 实现异步 IO。\nSpring Cloud Gateway 的目标,不仅提供统一的路由方式,并且基于 Filter 链的方式提供了网关基本的功能,例如:安全,监控/指标,和限流。\nSpring Cloud Gateway 和 Zuul 2.x 的差别不大,也是通过过滤器来处理请求。不过,目前更加推荐使用 Spring Cloud Gateway 而非 Zuul,Spring Cloud 生态对其支持更加友好。\nGithub 地址 : https://github.com/spring-cloud/spring-cloud-gateway 官网 : https://spring.io/projects/spring-cloud-gateway Kong # Kong 是一款基于 OpenResty 的高性能、云原生、可扩展的网关系统。\nOpenResty 是一个基于 Nginx 与 Lua 的高性能 Web 平台,其内部集成了大量精良的 Lua 库、第三方模块以及大多数的依赖项。用于方便地搭建能够处理超高并发、扩展性极高的动态 Web 应用、Web 服务和动态网关。\nKong 提供了插件机制来扩展其功能。比如、在服务上启用 Zipkin 插件\n$ curl -X POST http://kong:8001/services/{service}/plugins \\ --data \u0026#34;name=zipkin\u0026#34; \\ --data \u0026#34;config.http_endpoint=http://your.zipkin.collector:9411/api/v2/spans\u0026#34; \\ --data \u0026#34;config.sample_ratio=0.001\u0026#34; Github 地址: https://github.com/Kong/kong 官网地址 : https://konghq.com/kong APISIX # APISIX 是一款基于 Nginx 和 etcd 的高性能、云原生、可扩展的网关系统。\netcd是使用 Go 语言开发的一个开源的、高可用的分布式 key-value 存储系统,使用 Raft 协议做分布式共识。\n与传统 API 网关相比,APISIX 具有动态路由和插件热加载,特别适合微服务系统下的 API 管理。并且,APISIX 与 SkyWalking(分布式链路追踪系统)、Zipkin(分布式链路追踪系统)、Prometheus(监控系统) 等 DevOps 生态工具对接都十分方便。\n作为 NGINX 和 Kong 的替代项目,APISIX 目前已经是 Apache 顶级开源项目,并且是最快毕业的国产开源项目。国内目前已经有很多知名企业(比如金山、有赞、爱奇艺、腾讯、贝壳)使用 APISIX 处理核心的业务流量。\n根据官网介绍:“APISIX 已经生产可用,功能、性能、架构全面优于 Kong”。\nGithub 地址 :https://github.com/apache/apisix 官网地址: https://apisix.apache.org/zh/ 相关阅读:\n有了 NGINX 和 Kong,为什么还需要 Apache APISIX APISIX 技术博客 APISIX 用户案例 Shenyu # Shenyu 是一款基于 WebFlux 的可扩展、高性能、响应式网关,Apache 顶级开源项目。\nShenyu 通过插件扩展功能,插件是 ShenYu 的灵魂,并且插件也是可扩展和热插拔的。不同的插件实现不同的功能。Shenyu 自带了诸如限流、熔断、转发 、重写、重定向、和路由监控等插件。\nGithub 地址: https://github.com/apache/incubator-shenyu 官网地址 : https://shenyu.apache.org/ "},{"id":279,"href":"/zh/docs/technology/Review/java_guide/lydly_distributed_system/base/raft-algorithm/","title":"raft算法","section":"基础","content":" 转载自https://github.com/Snailclimb/JavaGuide(添加小部分笔记)感谢作者!\n1 背景 # 当今的数据中心和应用程序在高度动态的环境中运行,为了应对高度动态的环境,它们通过额外的服务器进行横向扩展,并且根据需求进行扩展和收缩。同时,服务器和网络故障也很常见。\n因此,系统必须在正常操作期间处理服务器的上下线。它们必须对变故做出反应并在几秒钟内自动适应;对客户来说的话,明显的中断通常是不可接受的。\n幸运的是,分布式共识可以帮助应对这些挑战。\n1.1 拜占庭将军 # 在介绍共识算法之前,先介绍一个简化版拜占庭将军的例子来帮助理解共识算法。\n假设多位拜占庭将军中没有叛军,信使的信息可靠但有可能被暗杀的情况下,将军们如何达成是否要进攻的一致性决定?\n解决方案大致可以理解成:先在所有的将军中选出一个大将军,用来做出所有的决定。\n举例如下:假如现在一共有 3 个将军 A,B 和 C,每个将军都有一个随机时间的倒计时器,倒计时一结束,这个将军就把自己当成大将军候选人,然后派信使传递选举投票的信息给将军 B 和 C,如果将军 B 和 C 还没有把自己当作候选人(自己的倒计时还没有结束),并且没有把选举票投给其他人,它们就会把票投给将军 A,信使回到将军 A 时,将军 A 知道自己收到了足够的票数,成为大将军。在有了大将军之后,是否需要进攻就由大将军 A 决定,然后再去派信使通知另外两个将军,自己已经成为了大将军。如果一段时间还没收到将军 B 和 C 的回复(信使可能会被暗杀),那就再重派一个信使,直到收到回复。\n1.2 共识算法 # 共识是可容错系统中的一个基本问题:即使面对故障,服务器也可以在共享状态上达成一致。\n共识算法允许一组节点像一个整体一样一起工作,即使其中的一些节点出现故障也能够继续工作下去,其正确性主要是源于复制状态机的性质:一组Server的状态机计算相同状态的副本,即使有一部分的Server宕机了它们仍然能够继续运行。\n图-1 复制状态机架构 一般通过使用复制日志来实现复制状态机。每个Server存储着一份包括命令序列的日志文件,状态机会按顺序执行这些命令。因为每个日志包含相同的命令,并且顺序也相同,所以每个状态机处理相同的命令序列。由于状态机是确定性的,所以处理相同的状态,得到相同的输出。\n因此共识算法的工作就是保持复制日志的一致性。服务器上的共识模块从客户端接收命令并将它们添加到日志中。它与其他服务器上的共识模块通信,以确保即使某些服务器发生故障。每个日志最终包含相同顺序的请求。一旦命令被正确地复制,它们就被称为已提交。每个服务器的状态机按照日志顺序处理已提交的命令,并将输出返回给客户端,因此,这些服务器形成了一个单一的、高度可靠的状态机。\n适用于实际系统的共识算法通常具有以下特性:\n安全。确保在非拜占庭条件(也就是上文中提到的简易版拜占庭)下的安全性,包括网络延迟、分区、包丢失、复制和重新排序。 高可用。只要大多数服务器都是可操作的,并且可以相互通信,也可以与客户端进行通信,那么这些服务器就可以看作完全功能可用的。因此,一个典型的由五台服务器组成的集群可以容忍任何两台服务器端故障。假设服务器因停止而发生故障;它们稍后可能会从稳定存储上的状态中恢复并重新加入集群。 一致性不依赖时序。错误的时钟和极端的消息延迟,在最坏的情况下也只会造成可用性问题,而不会产生一致性问题。 在集群中大多数服务器响应,命令就可以完成,不会被少数运行缓慢的服务器来影响整体系统性能。 2 基础 # 2.1 节点类型 # 一个 Raft 集群包括若干服务器,以典型的 5 服务器集群举例。在任意的时间,每个服务器一定会处于以下三个状态中的一个:\nLeader:负责发起心跳,响应客户端,创建日志,同步日志。 Candidate:Leader 选举过程中的临时角色,由 Follower 转化而来,发起投票参与竞选。 Follower:接受 Leader 的心跳和日志同步数据,投票给 Candidate。 在正常的情况下,只有一个服务器是 Leader,剩下的服务器是 Follower。Follower 是被动的,它们不会发送任何请求,只是响应来自 Leader 和 Candidate 的请求。\n图-2:服务器的状态 2.2 任期 # 图-3:任期 如图 3 所示,raft 算法将时间划分为任意长度的任期(term),任期用连续的数字表示,看作当前 term 号。每一个任期的开始都是一次选举,在选举开始时,一个或多个 Candidate 会尝试成为 Leader。如果一个 Candidate 赢得了选举,它就会在该任期内担任 Leader。如果没有选出 Leader,将会开启另一个任期,并立刻开始下一次选举。raft 算法保证在给定的一个任期最少要有一个 Leader。\n每个节点都会存储当前的 term 号,当服务器之间进行通信时会交换当前的 term 号;如果有服务器发现自己的 term 号比其他人小,那么他会更新到较大的 term 值。如果一个 Candidate 或者 Leader 发现自己的 term 过期了,他会立即退回成 Follower。如果一台服务器收到的请求的 term 号是过期的,那么它会拒绝此次请求。\n2.3 日志 # entry:每一个事件成为 entry,只有 Leader 可以创建 entry。entry 的内容为\u0026lt;term,index,cmd\u0026gt;其中 cmd 是可以应用到状态机的操作。 log:由 entry 构成的数组,每一个 entry 都有一个表明自己在 log 中的 index。只有 Leader 才可以改变其他节点的 log。entry 总是先被 Leader 添加到自己的 log 数组中,然后再发起共识请求,获得同意后才会被 Leader 提交给状态机。Follower 只能从 Leader 获取新日志和当前的 commitIndex,然后把对应的 entry 应用到自己的状态机中。 3 领导人选举 # raft 使用心跳机制来触发 Leader 的选举。\n如果一台服务器能够收到来自 Leader 或者 Candidate 的有效信息,那么它会一直保持为 Follower 状态,并且刷新自己的 electionElapsed,重新计时。\nLeader 会向所有的 Follower 周期性发送心跳来保证自己的 Leader 地位。如果一个 Follower 在一个周期内没有收到心跳信息,就叫做选举超时,然后它就会认为此时没有可用的 Leader,并且开始进行一次选举以选出一个新的 Leader。\n为了开始新的选举,Follower 会自增自己的 term 号并且转换状态为 Candidate。然后他会向所有节点发起 RequestVoteRPC 请求, Candidate 的状态会持续到以下情况发生:\n赢得选举 其他节点赢得选举 一轮选举结束,无人胜出 赢得选举的条件是:一个 Candidate 在一个任期内收到了来自集群内的多数选票(N/2+1),就可以成为 Leader。\n在 Candidate 等待选票的时候,它可能收到其他节点声明自己是 Leader 的心跳,此时有两种情况:\n该 Leader 的 term 号大于等于自己的 term 号,说明对方已经成为 Leader,则自己回退为 Follower。 该 Leader 的 term 号小于自己的 term 号,那么会拒绝该请求并让该节点更新 term。 由于可能同一时刻出现多个 Candidate,导致没有 Candidate 获得大多数选票,如果没有其他手段来重新分配选票的话,那么可能会无限重复下去。\nraft 使用了随机的选举超时时间来避免上述情况。每一个 Candidate 在发起选举后,都会随机化一个新的枚举超时时间,这种机制使得各个服务器能够分散开来,在大多数情况下只有一个服务器会率先超时;它会在其他服务器超时之前赢得选举。\n4 日志复制 # 一旦选出了 Leader,它就开始接受客户端的请求。每一个客户端的请求都包含一条需要被复制状态机(Replicated State Mechine)执行的命令。\nLeader 收到客户端请求后,会生成一个 entry,包含\u0026lt;index,term,cmd\u0026gt;,再将这个 entry 添加到自己的日志末尾后,向所有的节点广播该 entry,要求其他服务器复制这条 entry。\n如果 Follower 接受该 entry,则会将 entry 添加到自己的日志后面,同时返回给 Leader 同意。\n如果 Leader 收到了多数的成功响应,Leader 会将这个 entry 应用到自己的状态机中,之后可以成为这个 entry 是 committed 的,并且向客户端返回执行结果。\nraft 保证以下两个性质:\n在两个日志里,有两个 entry 拥有相同的 index 和 term,那么它们一定有相同的 cmd 在两个日志里,有两个 entry 拥有相同的 index 和 term,那么它们前面的 entry 也一定相同 通过“仅有 Leader 可以生存 entry”来保证第一个性质,第二个性质需要一致性检查来进行保证。\n一般情况下,Leader 和 Follower 的日志保持一致,然后,Leader 的崩溃会导致日志不一样,这样一致性检查会产生失败。Leader 通过强制 Follower 复制自己的日志来处理日志的不一致。这就意味着,在 Follower 上的冲突日志会被领导者的日志覆盖。\n为了使得 Follower 的日志和自己的日志一致,Leader 需要找到 Follower 与它日志一致的地方,然后删除 Follower 在该位置之后的日志,接着把这之后的日志发送给 Follower。\nLeader 给每一个Follower 维护了一个 nextIndex,它表示 Leader 将要发送给该追随者的下一条日志条目的索引。当一个 Leader 开始掌权时,它会将 nextIndex 初始化为它的最新的日志条目索引数+1。如果一个 Follower 的日志和 Leader 的不一致,AppendEntries 一致性检查会在下一次 AppendEntries RPC 时返回失败。在失败之后,Leader 会将 nextIndex 递减然后重试 AppendEntries RPC。最终 nextIndex 会达到一个 Leader 和 Follower 日志一致的地方。这时,AppendEntries 会返回成功,Follower 中冲突的日志条目都被移除了,并且添加所缺少的上了 Leader 的日志条目。一旦 AppendEntries 返回成功,Follower 和 Leader 的日志就一致了,这样的状态会保持到该任期结束。\n5 安全性 # 5.1 选举限制 # Leader 需要保证自己存储全部已经提交的日志条目。这样才可以使日志条目只有一个流向:从 Leader 流向 Follower,Leader 永远不会覆盖已经存在的日志条目。\n每个 Candidate 发送 RequestVoteRPC 时,都会带上最后一个 entry 的信息。所有节点收到投票信息时,会对该 entry 进行比较,如果发现自己的更新,则拒绝投票给该 Candidate。\n判断日志新旧的方式:如果两个日志的 term 不同,term 大的更新;如果 term 相同,更长的 index 更新。\n5.2 节点崩溃 # 如果 Leader 崩溃,集群中的节点在 electionTimeout 时间内没有收到 Leader 的心跳信息就会触发新一轮的选主,在选主期间整个集群对外是不可用的。\n如果 Follower 和 Candidate 崩溃,处理方式会简单很多。之后发送给它的 RequestVoteRPC 和 AppendEntriesRPC 会失败。由于 raft 的所有请求都是幂等的,所以失败的话会无限的重试。如果崩溃恢复后,就可以收到新的请求,然后选择追加或者拒绝 entry。\n5.3 时间与可用性 # raft 的要求之一就是安全性不依赖于时间:系统不能仅仅因为一些事件发生的比预想的快一些或者慢一些就产生错误。为了保证上述要求,最好能满足以下的时间条件:\nbroadcastTime \u0026lt;\u0026lt; electionTimeout \u0026lt;\u0026lt; MTBF broadcastTime:向其他节点并发发送消息的平均响应时间; electionTimeout:选举超时时间; MTBF(mean time between failures):单台机器的平均健康时间; broadcastTime应该比electionTimeout小一个数量级,为的是使Leader能够持续发送心跳信息(heartbeat)来阻止Follower开始选举;\nelectionTimeout也要比MTBF小几个数量级,为的是使得系统稳定运行。当Leader崩溃时,大约会在整个electionTimeout的时间内不可用;我们希望这种情况仅占全部时间的很小一部分。\n由于broadcastTime和MTBF是由系统决定的属性,因此需要决定electionTimeout的时间。\n一般来说,broadcastTime 一般为 0.5~20ms,electionTimeout 可以设置为 10~500ms,MTBF 一般为一两个月。\n6 参考 # https://tanxinyu.work/raft/ https://github.com/OneSizeFitsQuorum/raft-thesis-zh_cn/blob/master/raft-thesis-zh_cn.md https://github.com/ongardie/dissertation/blob/master/stanford.pdf https://knowledge-sharing.gitbooks.io/raft/content/chapter5.html "},{"id":280,"href":"/zh/docs/technology/Review/java_guide/lydly_distributed_system/base/paxos-algorithm/","title":"paxos算法","section":"基础","content":" 转载自https://github.com/Snailclimb/JavaGuide(添加小部分笔记)感谢作者!\n背景 # Paxos 算法是 Leslie Lamport( 莱斯利·兰伯特)在 1990 年提出了一种分布式系统 共识 算法。这也是第一个被证明完备的共识算法(前提是不存在拜占庭将军问题,也就是没有恶意节点)。\n为了介绍 Paxos 算法,兰伯特专门写了一篇幽默风趣的论文。在这篇论文中,他虚拟了一个叫做 Paxos 的希腊城邦来更形象化地介绍 Paxos 算法。\n不过,审稿人并不认可这篇论文的幽默。于是,他们就给兰伯特说:“如果你想要成功发表这篇论文的话,必须删除所有 Paxos 相关的故事背景”。兰伯特一听就不开心了:“我凭什么修改啊,你们这些审稿人就是缺乏幽默细胞,发不了就不发了呗!”。\n于是乎,提出 Paxos 算法的那篇论文在当时并没有被成功发表。\n直到 1998 年,系统研究中心 (Systems Research Center,SRC)的两个技术研究员需要找一些合适的分布式算法来服务他们正在构建的分布式系统,Paxos 算法刚好可以解决他们的部分需求。因此,兰伯特就把论文发给了他们。在看了论文之后,这俩大佬觉得论文还是挺不错的。于是,兰伯特在 1998 年重新发表论文 《The Part-Time Parliament》。\n论文发表之后,各路学者直呼看不懂,言语中还略显调侃之意。这谁忍得了,在 2001 年的时候,兰伯特专门又写了一篇 《Paxos Made Simple》 的论文来简化对 Paxos 的介绍,主要讲述两阶段共识协议部分,顺便还不忘嘲讽一下这群学者。\n《Paxos Made Simple》这篇论文就 14 页,相比于 《The Part-Time Parliament》的 33 页精简了不少。最关键的是这篇论文的摘要就一句话:\nThe Paxos algorithm, when presented in plain English, is very simple.\n翻译过来的意思大概就是:当我用无修饰的英文来描述时,Paxos 算法真心简单!\n有没有感觉到来自兰伯特大佬满满地嘲讽的味道?\n介绍 # Paxos 算法是第一个被证明完备的分布式系统共识算法。共识算法的作用是让分布式系统中的多个节点之间对某个提案(Proposal)达成一致的看法。提案的含义在分布式系统中十分宽泛,像哪一个节点是 Leader 节点、多个事件发生的顺序等等都可以是一个提案。\n兰伯特当时提出的 Paxos 算法主要包含 2 个部分:\nBasic Paxos 算法 : 描述的是多节点之间如何就某个值(提案 Value)达成共识。 Multi-Paxos 思想 : 描述的是执行多个 Basic Paxos 实例,就一系列值达成共识。Multi-Paxos 说白了就是执行多次 Basic Paxos ,核心还是 Basic Paxos 。 由于 Paxos 算法在国际上被公认的非常难以理解和实现,因此不断有人尝试简化这一算法。到了 2013 年才诞生了一个比 Paxos 算法更易理解和实现的共识算法— Raft 算法 。更具体点来说,Raft 是 Multi-Paxos 的一个变种,其简化了 Multi-Paxos 的思想,变得更容易被理解以及工程实现。\n针对没有恶意节点的情况,除了 Raft 算法之外,当前最常用的一些共识算法比如 ZAB 协议 、 Fast Paxos 算法都是基于 Paxos 算法改进的。\n针对存在恶意节点的情况,一般使用的是 工作量证明(POW,Proof-of-Work) 、 权益证明(PoS,Proof-of-Stake ) 等共识算法。这类共识算法最典型的应用就是区块链,就比如说前段时间以太坊官方宣布其共识机制正在从工作量证明(PoW)转变为权益证明(PoS)。\n区块链系统使用的共识算法需要解决的核心问题是 拜占庭将军问题 ,这和我们日常接触到的 ZooKeeper、Etcd、Consul 等分布式中间件不太一样。\n下面我们来对 Paxos 算法的定义做一个总结:\nPaxos 算法是兰伯特在 1990 年提出了一种分布式系统共识算法。 兰伯特当时提出的 Paxos 算法主要包含 2 个部分: Basic Paxos 算法和 Multi-Paxos 思想。 Raft 算法、ZAB 协议、 Fast Paxos 算法都是基于 Paxos 算法改进而来。 Basic Paxos 算法 # Basic Paxos 中存在 3 个重要的角色:\n提议者(Proposer):也可以叫做协调者(coordinator),提议者负责接受客户端的请求并发起提案。提案信息通常包括提案编号 (Proposal ID) 和提议的值 (Value)。 接受者(Acceptor):也可以叫做投票员(voter),负责对提议者的提案进行投票,同时需要记住自己的投票历史; 学习者(Learner):如果有超过半数接受者就某个提议达成了共识,那么学习者就需要接受这个提议,并就该提议作出运算,然后将运算结果返回给客户端。 为了减少实现该算法所需的节点数,一个节点可以身兼多个角色。并且,一个提案被选定需要被半数以上的 Acceptor 接受。这样的话,Basic Paxos 算法还具备容错性,在少于一半的节点出现故障时,集群仍能正常工作。\nMulti Paxos 思想 # Basic Paxos 算法的仅能就单个值达成共识,为了能够对一系列的值达成共识,我们需要用到 Basic Paxos 思想。\n⚠️注意 : Multi-Paxos 只是一种思想,这种思想的核心就是通过多个 Basic Paxos 实例就一系列值达成共识。也就是说,Basic Paxos 是 Multi-Paxos 思想的核心,Multi-Paxos 就是多执行几次 Basic Paxos。\n由于兰伯特提到的 Multi-Paxos 思想缺少代码实现的必要细节(比如怎么选举领导者),所以在理解和实现上比较困难。\n不过,也不需要担心,我们并不需要自己实现基于 Multi-Paxos 思想的共识算法,业界已经有了比较出名的实现。像 Raft 算法就是 Multi-Paxos 的一个变种,其简化了 Multi-Paxos 的思想,变得更容易被理解以及工程实现,实际项目中可以优先考虑 Raft 算法。\n参考 # https://zh.wikipedia.org/wiki/Paxos 分布式系统中的一致性与共识算法:http://www.xuyasong.com/?p=1970 "},{"id":281,"href":"/zh/docs/technology/Review/java_guide/lydly_distributed_system/base/cap_base-theorem/","title":"CAP\u0026BASE 理论","section":"基础","content":" 转载自https://github.com/Snailclimb/JavaGuide(添加小部分笔记)感谢作者!\n经历过技术面试的小伙伴想必对 CAP \u0026amp; BASE 这个两个理论已经再熟悉不过了!\n我当年参加面试的时候,不夸张地说,只要问到分布式相关的内容,面试官几乎是必定会问这两个分布式相关的理论。一是因为这两个分布式基础理论是学习分布式知识的必备前置基础,二是因为很多面试官自己比较熟悉这两个理论(方便提问)。\n我们非常有必要将这两个理论搞懂,并且能够用自己的理解给别人讲出来。\nCAP 理论 # CAP 理论/定理起源于 2000 年,由加州大学伯克利分校的 Eric Brewer 教授在分布式计算原理研讨会(PODC)上提出,因此 CAP 定理又被称作 布鲁尔定理(Brewer’s theorem)\n2 年后,麻省理工学院的 Seth Gilbert 和 Nancy Lynch 发表了布鲁尔猜想的证明,CAP 理论正式成为分布式领域的定理。\n简介 # [kənˈsɪstənsi] consistency 一致性\n[əˌveɪlə'bɪləti] availability 可用性 ,\n[pɑːˈtɪʃn] 分割 [ˈtɒlərəns] 容忍, CAP 也就是 Consistency(一致性)、Availability(可用性)、Partition Tolerance(分区容错性) 这三个单词首字母组合。\nCAP 理论的提出者布鲁尔在提出 CAP 猜想的时候,并没有详细定义 Consistency、Availability、Partition Tolerance 三个单词的明确定义。\n因此,对于 CAP 的民间解读有很多,一般比较被大家推荐的是下面 👇 这种版本的解读。\n在理论计算机科学中,CAP 定理(CAP theorem)指出对于一个分布式系统来说,当设计读写操作时,只能同时满足以下三点中的两个:\n一致性(Consistency) : 所有节点访问同一份最新的数据副本 可用性(Availability): 非故障的节点在合理的时间内返回合理的响应(不是错误或者超时的响应)。 分区容错性(Partition Tolerance) : 分布式系统出现网络分区的时候,仍然能够对外提供服务。 什么是网络分区?\n分布式系统中,多个节点之前的网络本来是连通的,但是因为某些故障(比如部分节点网络出了问题)某些节点之间不连通了,整个网络就分成了几块区域,这就叫 网络分区。\n不是所谓的“3 选 2” # 大部分人解释这一定律时,常常简单的表述为:“一致性、可用性、分区容忍性三者你只能同时达到其中两个,不可能同时达到”。实际上这是一个非常具有误导性质的说法,而且在 CAP 理论诞生 12 年之后,CAP 之父也在 2012 年重写了之前的论文。\n当发生网络分区的时候,如果我们要继续服务(也就是P),那么强一致性和可用性只能 2 选 1。也就是说当网络分区之后 P 是前提,决定了 P 之后才有 C 和 A 的选择。也就是说分区容错性(Partition tolerance)我们是必须要实现的。\n简而言之就是:CAP 理论中分区容错性 P 是一定要满足的,在此基础上,只能满足可用性 A 或者一致性 C。\n因此,分布式系统理论上不可能选择 CA 架构,只能选择 CP 或者 AP 架构。 比如 ZooKeeper、HBase 就是 CP 架构,Cassandra、Eureka 就是 AP 架构,Nacos 不仅支持 CP 架构也支持 AP 架构。\nP,分区容错性,就是一定要保证能提供服务\n为啥不可能选择 CA 架构呢? 举个例子:若系统出现“分区”,系统中的某个节点在进行写操作。为了保证 C, 必须要禁止其他节点的读写操作,这就和 A 发生冲突了。如果为了保证 A,其他节点的读写操作正常的话,那就和 C 发生冲突了。\n选择 CP 还是 AP 的关键在于当前的业务场景,没有定论,比如对于需要确保强一致性的场景如银行一般会选择保证 CP 。\n另外,需要补充说明的一点是: 如果网络分区正常的话(系统在绝大部分时候所处的状态),也就说不需要保证 P 的时候,C 和 A 能够同时保证。\nCAP 实际应用案例 # 我这里以注册中心来探讨一下 CAP 的实际应用。考虑到很多小伙伴不知道注册中心是干嘛的,这里简单以 Dubbo 为例说一说。\n下图是 Dubbo 的架构图。注册中心 Registry 在其中扮演了什么角色呢?提供了什么服务呢?\n注册中心负责服务地址的注册与查找,相当于目录服务,服务提供者和消费者只在启动时与注册中心交互,注册中心不转发请求,压力较小。\n]( https://camo.gith\n常见的可以作为注册中心的组件有:ZooKeeper、Eureka、Nacos\u0026hellip;。\nZooKeeper 保证的是 CP。 任何时刻对 ZooKeeper 的读请求都能得到一致性的结果,但是, ZooKeeper 不保证每次请求的可用性比如在 Leader 选举过程中或者半数以上的机器不可用的时候服务就是不可用的。 Eureka 保证的则是 AP。 Eureka 在设计的时候就是优先保证 A (可用性)。在 Eureka 中不存在什么 Leader 节点,每个节点都是一样的、平等的。因此 Eureka 不会像 ZooKeeper 那样出现选举过程中或者半数以上的机器不可用的时候服务就是不可用的情况。 Eureka 保证即使大部分节点挂掉也不会影响正常提供服务,只要有一个节点是可用的就行了。只不过这个节点上的数据可能并不是最新的。 Nacos 不仅支持 CP 也支持 AP。 总结 # 在进行分布式系统设计和开发时,我们不应该仅仅局限在 CAP 问题上,还要关注系统的扩展性、可用性等等\n在系统发生“分区”的情况下,CAP 理论只能满足 CP 或者 AP。要注意的是,这里的前提是系统发生了“分区”\n如果系统没有发生“分区”的话,节点间的网络连接通信正常的话,也就不存在 P 了。这个时候,我们就可以同时保证 C 和 A 了。\n总结:如果系统发生“分区”,我们要考虑选择 CP 还是 AP。如果系统没有发生“分区”的话,我们要思考如何保证 CA 。\n推荐阅读 # CAP 定理简化 (英文,有趣的案例) 神一样的 CAP 理论被应用在何方 (中文,列举了很多实际的例子) 请停止呼叫数据库 CP 或 AP (英文,带给你不一样的思考) BASE 理论 # BASE 理论起源于 2008 年, 由 eBay 的架构师 Dan Pritchett 在 ACM 上发表。\n简介 # BASE 是 Basically Available(基本可用) 、Soft-state(软状态) 和 Eventually Consistent(最终一致性) 三个短语的缩写。BASE 理论是对 CAP 中一致性 C 和可用性 A 权衡的结果,其来源于对大规模互联网系统分布式实践的总结,是基于 CAP 定理逐步演化而来的,它大大降低了我们对系统的要求。\nBASE 理论的核心思想 # 即使无法做到强一致性,但每个应用都可以根据自身业务特点,采用适当的方式来使系统达到最终一致性。\n也就是牺牲数据的一致性来满足系统的高可用性,系统中一部分数据不可用或者不一致时,仍需要保持系统整体“主要可用”。\nBASE 理论本质上是对 CAP 的延伸和补充,更具体地说,是对 CAP 中 AP 方案的一个补充。\n为什么这样说呢?\nCAP 理论这节我们也说过了:\n如果系统没有发生“分区”的话,节点间的网络连接通信正常的话,也就不存在 P 了。这个时候,我们就可以同时保证 C 和 A 了。因此,如果系统发生“分区”,我们要考虑选择 CP 还是 AP。如果系统没有发生“分区”的话,我们要思考如何保证 CA 。\n因此,AP 方案只是在系统发生分区的时候放弃一致性,而不是永远放弃一致性。在分区故障恢复后,系统应该达到最终一致性。这一点其实就是 BASE 理论延伸的地方。\nBASE 理论三要素 # 基本可用 # 基本可用是指分布式系统在出现不可预知故障的时候,允许损失部分可用性。但是,这绝不等价于系统不可用。\n什么叫允许损失部分可用性呢?\n响应时间上的损失: 正常情况下,处理用户请求需要 0.5s 返回结果,但是由于系统出现故障,处理用户请求的时间变为 3 s。 系统功能上的损失:正常情况下,用户可以使用系统的全部功能,但是由于系统访问量突然剧增,系统的部分非核心功能无法使用。 软状态 # 软状态指允许系统中的数据存在中间状态(CAP 理论中的数据不一致),并认为该中间状态的存在不会影响系统的整体可用性,即允许系统在不同节点的数据副本之间进行数据同步的过程存在延时。\n最终一致性 # 最终一致性强调的是系统中所有的数据副本,在经过一段时间的同步后,最终能够达到一个一致的状态。因此,最终一致性的本质是需要系统保证最终数据能够达到一致,而不需要实时保证系统数据的强一致性。\n分布式一致性的 3 种级别:\n强一致性 :系统写入了什么,读出来的就是什么。 弱一致性 :不一定可以读取到最新写入的值,也不保证多少时间之后读取到的数据是最新的,只是会尽量保证某个时刻达到数据一致的状态。 最终一致性 :弱一致性的升级版,系统会保证在一定时间内达到数据一致的状态。 业界比较推崇是最终一致性级别,但是某些对数据一致要求十分严格的场景比如银行转账还是要保证强一致性。\n那实现最终一致性的具体方式是什么呢? 《分布式协议与算法实战》 中是这样介绍:\n读时修复 : 在读取数据时,检测数据的不一致,进行修复。比如 Cassandra 的 Read Repair 实现,具体来说,在向 Cassandra 系统查询数据的时候,如果检测到不同节点的副本数据不一致,系统就自动修复数据。 写时修复 : 在写入数据,检测数据的不一致时,进行修复。比如 Cassandra 的 Hinted Handoff 实现。具体来说,Cassandra 集群的节点之间远程写数据的时候,如果写失败 就将数据缓存下来,然后定时重传,修复数据的不一致性。 异步修复 : 这个是最常用的方式,通过定时对账检测副本数据的一致性,并修复。 比较推荐 写时修复,这种方式对性能消耗比较低。\n总结 # ACID 是数据库事务完整性的理论,CAP 是分布式系统设计理论,BASE 是 CAP 理论中 AP 方案的延伸。\n"},{"id":282,"href":"/zh/docs/technology/Review/java_guide/lybly_framework/MyBatis/principle/mybatis-principle3/","title":"Mybatis原理系列(3)","section":"原理","content":" 转载自https://www.jianshu.com/p/4e268828db48(添加小部分笔记)感谢作者!\n还没看完\n在上篇文章中,我们讲解了MyBatis的启动流程,以及启动过程中涉及到的组件,在本篇文中,我们继续探索SqlSession,SqlSessionFactory,SqlSessionFactoryBuilder的关系。SqlSession作为MyBatis的核心组件,可以说MyBatis的所有操作都是围绕SqlSession来展开的。对SqlSession理解透彻,才能全面掌握MyBatis。\n1. SqlSession初识 # SqlSession在一开始就介绍过是高级接口,类似于JDBC操作的connection对象,它包装了数据库连接,通过这个接口我们可以实现增删改查,提交/回滚事物,关闭连接,获取代理类等操作。SqlSession是个接口,其默认实现是DefaultSqlSession。SqlSession是线程不安全的,每个线程都会有自己唯一的SqlSession,不同线程间调用同一个SqlSession会出现问题,因此在使用完后需要close掉。\nSqlSession的方法\n2. SqlSession的创建 # SqlSessionFactoryBuilder的build()方法使用建造者模式创建了SqlSessionFactory接口对象,SqlSessionFactory接口的默认实现是DefaultSqlSessionFactory。SqlSessionFactory使用实例工厂模式来创建SqlSession对象。SqlSession,SqlSessionFactory,SqlSessionFactoryBuilder的关系如下(图画得有点丑\u0026hellip;):\n类图\nDefaultSqlSessionFactory中openSession是有两种方法一种是openSessionFromDataSource,另一种是openSessionFromConnection。这两种是什么区别呢?从字面意义上将,一种是从数据源中获取SqlSession对象,一种是由已有连接获取SqlSession。SqlSession实际是对数据库连接的一层包装,数据库连接是个珍贵的资源,如果频繁的创建销毁将会影响吞吐量,因此使用数据库连接池化技术就可以复用数据库连接了。因此openSessionFromDataSource会从数据库连接池中获取一个连接,然后包装成一个SqlSession对像。openSessionFromConnection则是直接包装已有的连接并返回SqlSession对像。\nopenSessionFromDataSource 主要经历了以下几步:\n从获取configuration中获取Environment对象,Environment包含了数据库配置 从Environment获取DataSource数据源 从DataSource数据源中获取Connection连接对象 从DataSource数据源中获取TransactionFactory事物工厂 从TransactionFactory中创建事物Transaction对象 创建Executor对象 包装configuration和Executor对象成DefaultSqlSession对象 private SqlSession openSessionFromDataSource(ExecutorType execType, TransactionIsolationLevel level, boolean autoCommit) { Transaction tx = null; try { final Environment environment = configuration.getEnvironment(); final TransactionFactory transactionFactory = getTransactionFactoryFromEnvironment(environment); tx = transactionFactory.newTransaction(environment.getDataSource(), level, autoCommit); final Executor executor = configuration.newExecutor(tx, execType); return new DefaultSqlSession(configuration, executor, autoCommit); } catch (Exception e) { closeTransaction(tx); // may have fetched a connection so lets call close() throw ExceptionFactory.wrapException(\u0026#34;Error opening session. Cause: \u0026#34; + e, e); } finally { ErrorContext.instance().reset(); } } private SqlSession openSessionFromConnection(ExecutorType execType, Connection connection) { try { boolean autoCommit; try { autoCommit = connection.getAutoCommit(); } catch (SQLException e) { // Failover to true, as most poor drivers // or databases won\u0026#39;t support transactions autoCommit = true; } final Environment environment = configuration.getEnvironment(); final TransactionFactory transactionFactory = getTransactionFactoryFromEnvironment(environment); final Transaction tx = transactionFactory.newTransaction(connection); final Executor executor = configuration.newExecutor(tx, execType); return new DefaultSqlSession(configuration, executor, autoCommit); } catch (Exception e) { throw ExceptionFactory.wrapException(\u0026#34;Error opening session. Cause: \u0026#34; + e, e); } finally { ErrorContext.instance().reset(); } } 3. SqlSession的使用 # SqlSession 获取成功后,我们就可以使用其中的方法了,比如直接使用SqlSession发送sql语句,或者通过mapper映射文件的方式来使用,在上两篇文章中我们都是通过mapper映射文件来使用的,接下来就介绍第一种,直接使用SqlSession发送sql语句。\npublic static void main(String[] args){ try { // 1. 读取配置 InputStream inputStream = Resources.getResourceAsStream(\u0026#34;mybatis-config.xml\u0026#34;); // 2. 获取SqlSessionFactory对象 SqlSessionFactory sqlSessionFactory = new SqlSessionFactoryBuilder().build(inputStream); // 3. 获取SqlSession对象 SqlSession sqlSession = sqlSessionFactory.openSession(); // 4. 执行sql TTestUser user = sqlSession.selectOne(\u0026#34;com.example.demo.dao.TTestUserMapper.selectByPrimaryKey\u0026#34;, 13L); log.info(\u0026#34;user = [{}]\u0026#34;, JSONUtil.toJsonStr(user)); // 5. 关闭连接 sqlSession.close(); inputStream.close(); } catch (Exception e){ log.error(\u0026#34;errMsg = [{}]\u0026#34;, e.getMessage(), e); } } 其中com.example.demo.dao.TTestUserMapper.selectByPrimaryKey指定了TTestUserMapper中selectByPrimaryKey这个方法,在对应的mapper/TTestUserMapper.xml我们定义了id一致的sql语句\n\u0026lt;select id=\u0026#34;selectByPrimaryKey\u0026#34; parameterType=\u0026#34;java.lang.Long\u0026#34; resultMap=\u0026#34;BaseResultMap\u0026#34;\u0026gt; select \u0026lt;include refid=\u0026#34;Base_Column_List\u0026#34; /\u0026gt; from t_test_user where id = #{id,jdbcType=BIGINT} \u0026lt;/select\u0026gt; Mybatis会在一开始加载的时候将每个标签中的sql语句包装成MappedStatement对象,并以类全路径名+方法名为key,MappedStatement为value缓存在内存中。在执行对应的方法时,就会根据这个唯一路径找到TTestUserMapper.xml这条sql语句并且执行返回结果。\n4. SqlSession的执行原理 # 4. 1 SqlSession的selectOne的执行原理 # SqlSession的selectOne代码如下,其实是调用selectList()方法获取第一条数据的。其中参数statement就是statement的id,parameter就是参数。\npublic \u0026lt;T\u0026gt; T selectOne(String statement, Object parameter) { List\u0026lt;T\u0026gt; list = this.selectList(statement, parameter); if (list.size() == 1) { return list.get(0); } else if (list.size() \u0026gt; 1) { throw new TooManyResultsException(\u0026#34;Expected one result (or null) to be returned by selectOne(), but found: \u0026#34; + list.size()); } else { return null; } } RowBounds 对象是分页对象,主要拼接sql中的start,limit条件。并且可以看到两个重要步骤:\n从configuration的成员变量mappedStatements中获取MappedStatement对象。mappedStatements是Map\u0026lt;String, MappedStatement\u0026gt;类型的缓存结构,其中key就是mapper接口全类名+方法名,MappedStatement就是对标签中配置的sql一个包装 使用executor成员变量来执行查询并且指定结果处理器,并且返回结果。Executor也是mybatis的一个重要的组件。sql的执行都是由Executor对象来操作的。 public \u0026lt;E\u0026gt; List\u0026lt;E\u0026gt; selectList(String statement, Object parameter, RowBounds rowBounds) { List var5; try { MappedStatement ms = this.configuration.getMappedStatement(statement); var5 = this.executor.query(ms, this.wrapCollection(parameter), rowBounds, Executor.NO_RESULT_HANDLER); } catch (Exception var9) { throw ExceptionFactory.wrapException(\u0026#34;Error querying database. Cause: \u0026#34; + var9, var9); } finally { ErrorContext.instance().reset(); } return var5; } MappedStatement对象的具体内容和Executor对象的类型,我们将在其它文章中详述。\n4. 2 SqlSession的通过mapper对象使用的执行原理 # 在启动流程那篇文章中,我们大致了解了sqlSession.getMapper返回的其实是个代理类MapperProxy,然后调mapper接口的方法其实都是调用MapperProxy的invoke方法,进而调用MapperMethod的execute方法。\npublic static void main(String[] args) { try { // 1. 读取配置 InputStream inputStream = Resources.getResourceAsStream(\u0026#34;mybatis-config.xml\u0026#34;); // 2. 创建SqlSessionFactory工厂 SqlSessionFactory sqlSessionFactory = new SqlSessionFactoryBuilder().build(inputStream); // 3. 获取sqlSession SqlSession sqlSession = sqlSessionFactory.openSession(); // 4. 获取Mapper TTestUserMapper userMapper = sqlSession.getMapper(TTestUserMapper.class); // 5. 执行接口方法 TTestUser userInfo = userMapper.selectByPrimaryKey(16L); System.out.println(\u0026#34;userInfo = \u0026#34; + JSONUtil.toJsonStr(userInfo)); // 6. 提交事物 sqlSession.commit(); // 7. 关闭资源 sqlSession.close(); inputStream.close(); } catch (Exception e){ log.error(e.getMessage(), e); } } MapperMethod的execute方法中使用命令模式进行增删改查操作,其实也是调用了sqlSession的增删改查方法。\npublic Object execute(SqlSession sqlSession, Object[] args) { Object result; switch (command.getType()) { case INSERT: { Object param = method.convertArgsToSqlCommandParam(args); result = rowCountResult(sqlSession.insert(command.getName(), param)); break; } case UPDATE: { Object param = method.convertArgsToSqlCommandParam(args); result = rowCountResult(sqlSession.update(command.getName(), param)); break; } case DELETE: { Object param = method.convertArgsToSqlCommandParam(args); result = rowCountResult(sqlSession.delete(command.getName(), param)); break; } case SELECT: if (method.returnsVoid() \u0026amp;\u0026amp; method.hasResultHandler()) { executeWithResultHandler(sqlSession, args); result = null; } else if (method.returnsMany()) { result = executeForMany(sqlSession, args); } else if (method.returnsMap()) { result = executeForMap(sqlSession, args); } else if (method.returnsCursor()) { result = executeForCursor(sqlSession, args); } else { Object param = method.convertArgsToSqlCommandParam(args); result = sqlSession.selectOne(command.getName(), param); if (method.returnsOptional() \u0026amp;\u0026amp; (result == null || !method.getReturnType().equals(result.getClass()))) { result = Optional.ofNullable(result); } } break; case FLUSH: result = sqlSession.flushStatements(); break; default: throw new BindingException(\u0026#34;Unknown execution method for: \u0026#34; + command.getName()); } if (result == null \u0026amp;\u0026amp; method.getReturnType().isPrimitive() \u0026amp;\u0026amp; !method.returnsVoid()) { throw new BindingException(\u0026#34;Mapper method \u0026#39;\u0026#34; + command.getName() + \u0026#34; attempted to return null from a method with a primitive return type (\u0026#34; + method.getReturnType() + \u0026#34;).\u0026#34;); } return result; } 总结 # 在这篇文章中我们详细介绍了SqlSession的作用,创建过程,使用方法,以及执行原理等,对SqlSession已经有了比较全面的了解。其中涉及到的Executor对象,MappedStatement对象,ResultHandler我们将在其它文章中讲解。欢迎在评论区中讨论指正,一起进步。\n"},{"id":283,"href":"/zh/docs/technology/Review/java_guide/lybly_framework/MyBatis/principle/mybatis-principle2/","title":"Mybatis原理系列(2)","section":"原理","content":" 转载自https://www.jianshu.com/p/7d6b891180a3(添加小部分笔记)感谢作者!\n在上篇文章中,我们举了一个例子如何使用MyBatis,但是对其中dao层,entity层,mapper层间的关系不得而知,从此篇文章开始,笔者将从MyBatis的启动流程着手,真正的开始研究MyBatis源码了。\n1. MyBatis启动代码示例 # 在上篇文章中,介绍了MyBatis的相关配置和各层代码编写,本文将以下代码展开描述和介绍MyBatis的启动流程,并简略的介绍各个模块的作用,各个模块的细节部分将在其它文章中呈现。\n回顾下上文中使用mybatis的部分代码,包括七步。每步虽然都是一行代码,但是隐藏了很多细节。接下来我们将围绕这起步展开了解。\n@Slf4j public class MyBatisBootStrap { public static void main(String[] args) { try { // 1. 读取配置 InputStream inputStream = Resources.getResourceAsStream(\u0026#34;mybatis-config.xml\u0026#34;); // 2. 创建SqlSessionFactory工厂 SqlSessionFactory sqlSessionFactory = new SqlSessionFactoryBuilder().build(inputStream); // 3. 获取sqlSession SqlSession sqlSession = sqlSessionFactory.openSession(); // 4. 获取Mapper TTestUserMapper userMapper = sqlSession.getMapper(TTestUserMapper.class); // 5. 执行接口方法 TTestUser userInfo = userMapper.selectByPrimaryKey(16L); System.out.println(\u0026#34;userInfo = \u0026#34; + JSONUtil.toJsonStr(userInfo)); // 6. 提交事物 sqlSession.commit(); // 7. 关闭资源 sqlSession.close(); inputStream.close(); } catch (Exception e){ log.error(e.getMessage(), e); } } } 2. 读取配置 # // 1. 读取配置 InputStream inputStream = Resources.getResourceAsStream(\u0026#34;mybatis-config.xml\u0026#34;); 在mybatis-config.xml中我们配置了属性,环境,映射文件路径等,其实不仅可以配置以上内容,还可以配置插件,反射工厂,类型处理器等等其它内容。在启动流程中的第一步我们就需要读取这个配置文件,并获取一个输入流为下一步解析配置文件作准备。\nmybatis-config.xml 内容如下\n\u0026lt;?xml version=\u0026#34;1.0\u0026#34; encoding=\u0026#34;UTF-8\u0026#34; ?\u0026gt; \u0026lt;!DOCTYPE configuration PUBLIC \u0026#34;-//mybatis.org//DTD Config 3.0//EN\u0026#34; \u0026#34;http://mybatis.org/dtd/mybatis-3-config.dtd\u0026#34;\u0026gt; \u0026lt;configuration\u0026gt; \u0026lt;!--一些重要的全局配置--\u0026gt; \u0026lt;settings\u0026gt; \u0026lt;setting name=\u0026#34;cacheEnabled\u0026#34; value=\u0026#34;true\u0026#34;/\u0026gt; \u0026lt;!--\u0026lt;setting name=\u0026#34;lazyLoadingEnabled\u0026#34; value=\u0026#34;true\u0026#34;/\u0026gt;--\u0026gt; \u0026lt;!--\u0026lt;setting name=\u0026#34;multipleResultSetsEnabled\u0026#34; value=\u0026#34;true\u0026#34;/\u0026gt;--\u0026gt; \u0026lt;!--\u0026lt;setting name=\u0026#34;useColumnLabel\u0026#34; value=\u0026#34;true\u0026#34;/\u0026gt;--\u0026gt; \u0026lt;!--\u0026lt;setting name=\u0026#34;useGeneratedKeys\u0026#34; value=\u0026#34;false\u0026#34;/\u0026gt;--\u0026gt; \u0026lt;!--\u0026lt;setting name=\u0026#34;autoMappingBehavior\u0026#34; value=\u0026#34;PARTIAL\u0026#34;/\u0026gt;--\u0026gt; \u0026lt;!--\u0026lt;setting name=\u0026#34;autoMappingUnknownColumnBehavior\u0026#34; value=\u0026#34;WARNING\u0026#34;/\u0026gt;--\u0026gt; \u0026lt;!--\u0026lt;setting name=\u0026#34;defaultExecutorType\u0026#34; value=\u0026#34;SIMPLE\u0026#34;/\u0026gt;--\u0026gt; \u0026lt;!--\u0026lt;setting name=\u0026#34;defaultStatementTimeout\u0026#34; value=\u0026#34;25\u0026#34;/\u0026gt;--\u0026gt; \u0026lt;!--\u0026lt;setting name=\u0026#34;defaultFetchSize\u0026#34; value=\u0026#34;100\u0026#34;/\u0026gt;--\u0026gt; \u0026lt;!--\u0026lt;setting name=\u0026#34;safeRowBoundsEnabled\u0026#34; value=\u0026#34;false\u0026#34;/\u0026gt;--\u0026gt; \u0026lt;!--\u0026lt;setting name=\u0026#34;mapUnderscoreToCamelCase\u0026#34; value=\u0026#34;false\u0026#34;/\u0026gt;--\u0026gt; \u0026lt;!--\u0026lt;setting name=\u0026#34;localCacheScope\u0026#34; value=\u0026#34;STATEMENT\u0026#34;/\u0026gt;--\u0026gt; \u0026lt;!--\u0026lt;setting name=\u0026#34;jdbcTypeForNull\u0026#34; value=\u0026#34;OTHER\u0026#34;/\u0026gt;--\u0026gt; \u0026lt;!--\u0026lt;setting name=\u0026#34;lazyLoadTriggerMethods\u0026#34; value=\u0026#34;equals,clone,hashCode,toString\u0026#34;/\u0026gt;--\u0026gt; \u0026lt;!--\u0026lt;setting name=\u0026#34;logImpl\u0026#34; value=\u0026#34;STDOUT_LOGGING\u0026#34; /\u0026gt;--\u0026gt; \u0026lt;/settings\u0026gt; \u0026lt;environments default=\u0026#34;development\u0026#34;\u0026gt; \u0026lt;environment id=\u0026#34;development\u0026#34;\u0026gt; \u0026lt;transactionManager type=\u0026#34;JDBC\u0026#34;/\u0026gt; \u0026lt;dataSource type=\u0026#34;POOLED\u0026#34;\u0026gt; \u0026lt;property name=\u0026#34;driver\u0026#34; value=\u0026#34;com.mysql.cj.jdbc.Driver\u0026#34;/\u0026gt; \u0026lt;property name=\u0026#34;url\u0026#34; value=\u0026#34;jdbc:mysql://10.255.0.50:3306/volvo_bev?useUnicode=true\u0026#34;/\u0026gt; \u0026lt;property name=\u0026#34;username\u0026#34; value=\u0026#34;appdev\u0026#34;/\u0026gt; \u0026lt;property name=\u0026#34;password\u0026#34; value=\u0026#34;FEGwo3EzsdDYS9ooYKGCjRQepkwG\u0026#34;/\u0026gt; \u0026lt;/dataSource\u0026gt; \u0026lt;/environment\u0026gt; \u0026lt;/environments\u0026gt; \u0026lt;mappers\u0026gt; \u0026lt;!--这边可以使用package和resource两种方式加载mapper--\u0026gt; \u0026lt;!--\u0026lt;package name=\u0026#34;包名\u0026#34;/\u0026gt;--\u0026gt; \u0026lt;!--\u0026lt;mapper resource=\u0026#34;./mappers/SysUserMapper.xml\u0026#34;/\u0026gt; \u0026lt;package name=\u0026#34;com.example.demo.dao\u0026#34;/\u0026gt; --\u0026gt; \u0026lt;mapper resource=\u0026#34;./mapper/TTestUserMapper.xml\u0026#34;/\u0026gt; \u0026lt;/mappers\u0026gt; \u0026lt;/configuration\u0026gt; 3. 创建SqlSessionFactory工厂 # SqlSessionFactory sqlSessionFactory = new SqlSessionFactoryBuilder().build(inputStream); 我们在学习Java的设计模式时,会学到工厂模式,工厂模式又分为简单工厂模式,工厂方法模式,抽象工厂模式等等。工厂模式就是为了创建对象提供接口,并将创建对象的具体细节屏蔽起来,从而可以提高灵活性。\npublic interface SqlSessionFactory { SqlSession openSession(); SqlSession openSession(boolean autoCommit); SqlSession openSession(Connection connection); SqlSession openSession(TransactionIsolationLevel level); SqlSession openSession(ExecutorType execType); SqlSession openSession(ExecutorType execType, boolean autoCommit); SqlSession openSession(ExecutorType execType, TransactionIsolationLevel level); SqlSession openSession(ExecutorType execType, Connection connection); Configuration getConfiguration(); } 由此可知SqlSessionFactory工厂是为了创建一个对象而生的,其产出的对象就是SqlSession对象。SqlSession是MyBatis面向数据库的高级接口,其提供了执行查询sql,更新sql,提交事物,回滚事物,**获取映射代理类(也就是Mapper)**等等方法。\n在此笔者列出了主要方法,一些重载的方法就过滤掉了。\npublic interface SqlSession extends Closeable { /** * 查询一个结果对象 **/ \u0026lt;T\u0026gt; T selectOne(String statement, Object parameter); /** * 查询一个结果集合 **/ \u0026lt;E\u0026gt; List\u0026lt;E\u0026gt; selectList(String statement, Object parameter, RowBounds rowBounds); /** * 查询一个map **/ \u0026lt;K, V\u0026gt; Map\u0026lt;K, V\u0026gt; selectMap(String statement, Object parameter, String mapKey, RowBounds rowBounds); /** * 查询游标 **/ \u0026lt;T\u0026gt; Cursor\u0026lt;T\u0026gt; selectCursor(String statement, Object parameter, RowBounds rowBounds); void select(String statement, Object parameter, RowBounds rowBounds, ResultHandler handler); /** * 插入 **/ int insert(String statement, Object parameter); /** * 修改 **/ int update(String statement, Object parameter); /** * 删除 **/ int delete(String statement, Object parameter); /** * 提交事物 **/ void commit(boolean force); /** * 回滚事物 **/ void rollback(boolean force); List\u0026lt;BatchResult\u0026gt; flushStatements(); void close(); void clearCache(); Configuration getConfiguration(); /** * 获取映射代理类 **/ \u0026lt;T\u0026gt; T getMapper(Class\u0026lt;T\u0026gt; type); /** * 获取数据库连接 **/ Connection getConnection(); } 回到开始,SqlSessionFactory工厂是怎么创建的出来的呢?SqlSessionFactoryBuilder就是创建者,以Builder结尾我们很容易想到了Java设计模式中的建造者模式,一个对象的创建是由众多复杂对象组成的,建造者模式就是一个创建复杂对象的选择,它与工厂模式相比,建造者模式更加关注零件装配的顺序。\npublic class SqlSessionFactoryBuilder { public SqlSessionFactory build(InputStream inputStream, String environment, Properties properties) { try { XMLConfigBuilder parser = new XMLConfigBuilder(inputStream, environment, properties); return build(parser.parse()); } catch (Exception e) { throw ExceptionFactory.wrapException(\u0026#34;Error building SqlSession.\u0026#34;, e); } finally { ErrorContext.instance().reset(); try { inputStream.close(); } catch (IOException e) { // Intentionally ignore. Prefer previous error. } } } } 其中XMLConfigBuilder就是解析mybatis-config.xml中每个标签的内容,parse()方法返回的就是一个Configuration对象.Configuration也是MyBatis中一个很重要的组件,包括插件,对象工厂,反射工厂,映射文件,类型解析器等等都存储在Configuration对象中。\npublic Configuration parse() { if (parsed) { throw new BuilderException(\u0026#34;Each XMLConfigBuilder can only be used once.\u0026#34;); } parsed = true; parseConfiguration(parser.evalNode(\u0026#34;/configuration\u0026#34;)); return configuration; } private void parseConfiguration(XNode root) { try { // issue #117 read properties first // 解析properties节点 propertiesElement(root.evalNode(\u0026#34;properties\u0026#34;)); Properties settings = settingsAsProperties(root.evalNode(\u0026#34;settings\u0026#34;)); loadCustomVfs(settings); loadCustomLogImpl(settings); typeAliasesElement(root.evalNode(\u0026#34;typeAliases\u0026#34;)); pluginElement(root.evalNode(\u0026#34;plugins\u0026#34;)); objectFactoryElement(root.evalNode(\u0026#34;objectFactory\u0026#34;)); objectWrapperFactoryElement(root.evalNode(\u0026#34;objectWrapperFactory\u0026#34;)); reflectorFactoryElement(root.evalNode(\u0026#34;reflectorFactory\u0026#34;)); settingsElement(settings); // read it after objectFactory and objectWrapperFactory issue #631 environmentsElement(root.evalNode(\u0026#34;environments\u0026#34;)); databaseIdProviderElement(root.evalNode(\u0026#34;databaseIdProvider\u0026#34;)); typeHandlerElement(root.evalNode(\u0026#34;typeHandlers\u0026#34;)); mapperElement(root.evalNode(\u0026#34;mappers\u0026#34;)); } catch (Exception e) { throw new BuilderException(\u0026#34;Error parsing SQL Mapper Configuration. Cause: \u0026#34; + e, e); } } 在获取到Configuration对象后,SqlSessionFactoryBuilder就会创建一个DefaultSqlSessionFactory对象,DefaultSqlSessionFactory是SqlSessionFactory的一个默认实现,还有一个实现是SqlSessionManager。\npublic SqlSessionFactory build(Configuration config) { return new DefaultSqlSessionFactory(config); } 4. 获取sqlSession # // 3. 获取sqlSession SqlSession sqlSession = sqlSessionFactory.openSession(); 在前面我们讲到,sqlSession是操作数据库的高级接口,我们操作数据库都是通过这个接口操作的。获取sqlSession有两种方式,一种是从数据源中获取的,还有一种是从连接中获取。\n貌似默认是从数据源获取\n获取到的都是DefaultSqlSession对象,也就是sqlSession的默认实现。\n注意,过程中有个Executor\u0026mdash;执行器\nprivate SqlSession openSessionFromDataSource(ExecutorType execType, TransactionIsolationLevel level, boolean autoCommit) { Transaction tx = null; try { final Environment environment = configuration.getEnvironment(); final TransactionFactory transactionFactory = getTransactionFactoryFromEnvironment(environment); tx = transactionFactory.newTransaction(environment.getDataSource(), level, autoCommit); final Executor executor = configuration.newExecutor(tx, execType); return new DefaultSqlSession(configuration, executor, autoCommit); } catch (Exception e) { closeTransaction(tx); // may have fetched a connection so lets call close() throw ExceptionFactory.wrapException(\u0026#34;Error opening session. Cause: \u0026#34; + e, e); } finally { ErrorContext.instance().reset(); } } private SqlSession openSessionFromConnection(ExecutorType execType, Connection connection) { try { boolean autoCommit; try { autoCommit = connection.getAutoCommit(); } catch (SQLException e) { // Failover to true, as most poor drivers // or databases won\u0026#39;t support transactions autoCommit = true; } final Environment environment = configuration.getEnvironment(); final TransactionFactory transactionFactory = getTransactionFactoryFromEnvironment(environment); final Transaction tx = transactionFactory.newTransaction(connection); final Executor executor = configuration.newExecutor(tx, execType); return new DefaultSqlSession(configuration, executor, autoCommit); } catch (Exception e) { throw ExceptionFactory.wrapException(\u0026#34;Error opening session. Cause: \u0026#34; + e, e); } finally { ErrorContext.instance().reset(); } } 获取SqlSession步骤\n5. 获取Mapper代理类 # 在上一步获取到sqlSession后,我们接下来就获取到了mapper代理类。\n// 4. 获取Mapper TTestUserMapper userMapper = sqlSession.getMapper(TTestUserMapper.class); 这个getMapper方法,我们看看DefaultSqlSession是怎么做的\nDefaultSqlSession 的 getMapper 方法\npublic \u0026lt;T\u0026gt; T getMapper(Class\u0026lt;T\u0026gt; type) { return this.configuration.getMapper(type, this); } Configuration 的 getMapper 方法\npublic \u0026lt;T\u0026gt; T getMapper(Class\u0026lt;T\u0026gt; type, SqlSession sqlSession) { return this.mapperRegistry.getMapper(type, sqlSession); } MapperRegistry 中有个getMapper方法,实际上是从成员变量knownMappers中获取的,这个knownMappers是个key-value形式的缓存,key是mapper接口的class对象,value是MapperProxyFactory代理工厂,这个工厂就是用来创建MapperProxy代理类的。\npublic class MapperRegistry { private final Configuration config; private final Map\u0026lt;Class\u0026lt;?\u0026gt;, MapperProxyFactory\u0026lt;?\u0026gt;\u0026gt; knownMappers = new HashMap(); public MapperRegistry(Configuration config) { this.config = config; } public \u0026lt;T\u0026gt; T getMapper(Class\u0026lt;T\u0026gt; type, SqlSession sqlSession) { MapperProxyFactory\u0026lt;T\u0026gt; mapperProxyFactory = (MapperProxyFactory)this.knownMappers.get(type); if (mapperProxyFactory == null) { throw new BindingException(\u0026#34;Type \u0026#34; + type + \u0026#34; is not known to the MapperRegistry.\u0026#34;); } else { try { return mapperProxyFactory.newInstance(sqlSession); } catch (Exception var5) { throw new BindingException(\u0026#34;Error getting mapper instance. Cause: \u0026#34; + var5, var5); } } } } 如果对java动态代理了解的同学就知道,Proxy.newProxyInstance()方法可以创建出一个目标对象一个代理对象。由此可知每次调用getMapper方法都会创建出一个代理类出来。\npublic class MapperProxyFactory\u0026lt;T\u0026gt; { private final Class\u0026lt;T\u0026gt; mapperInterface; private final Map\u0026lt;Method, MapperMethod\u0026gt; methodCache = new ConcurrentHashMap(); public MapperProxyFactory(Class\u0026lt;T\u0026gt; mapperInterface) { this.mapperInterface = mapperInterface; } public Class\u0026lt;T\u0026gt; getMapperInterface() { return this.mapperInterface; } public Map\u0026lt;Method, MapperMethod\u0026gt; getMethodCache() { return this.methodCache; } protected T newInstance(MapperProxy\u0026lt;T\u0026gt; mapperProxy) { return Proxy.newProxyInstance(this.mapperInterface.getClassLoader(), new Class[]{this.mapperInterface}, mapperProxy); } public T newInstance(SqlSession sqlSession) { MapperProxy\u0026lt;T\u0026gt; mapperProxy = new MapperProxy(sqlSession, this.mapperInterface, this.methodCache); return this.newInstance(mapperProxy); } } 回到上面,那这个MapperProxyFactory是怎么加载到MapperRegistry的knownMappers缓存中的呢?\n在上面的Configuration类的parseConfiguration方法中,我们会解析 mappers标签,mapperElement方法就会解析mapper接口。\nprivate void parseConfiguration(XNode root) { try { // issue #117 read properties first // 解析properties节点 propertiesElement(root.evalNode(\u0026#34;properties\u0026#34;)); Properties settings = settingsAsProperties(root.evalNode(\u0026#34;settings\u0026#34;)); loadCustomVfs(settings); loadCustomLogImpl(settings); typeAliasesElement(root.evalNode(\u0026#34;typeAliases\u0026#34;)); pluginElement(root.evalNode(\u0026#34;plugins\u0026#34;)); objectFactoryElement(root.evalNode(\u0026#34;objectFactory\u0026#34;)); objectWrapperFactoryElement(root.evalNode(\u0026#34;objectWrapperFactory\u0026#34;)); reflectorFactoryElement(root.evalNode(\u0026#34;reflectorFactory\u0026#34;)); settingsElement(settings); // read it after objectFactory and objectWrapperFactory issue #631 environmentsElement(root.evalNode(\u0026#34;environments\u0026#34;)); databaseIdProviderElement(root.evalNode(\u0026#34;databaseIdProvider\u0026#34;)); typeHandlerElement(root.evalNode(\u0026#34;typeHandlers\u0026#34;)); mapperElement(root.evalNode(\u0026#34;mappers\u0026#34;)); } catch (Exception e) { throw new BuilderException(\u0026#34;Error parsing SQL Mapper Configuration. Cause: \u0026#34; + e, e); } } private void mapperElement(XNode parent) throws Exception { if (parent != null) { for (XNode child : parent.getChildren()) { if (\u0026#34;package\u0026#34;.equals(child.getName())) { String mapperPackage = child.getStringAttribute(\u0026#34;name\u0026#34;); configuration.addMappers(mapperPackage); } else { String resource = child.getStringAttribute(\u0026#34;resource\u0026#34;); String url = child.getStringAttribute(\u0026#34;url\u0026#34;); String mapperClass = child.getStringAttribute(\u0026#34;class\u0026#34;); if (resource != null \u0026amp;\u0026amp; url == null \u0026amp;\u0026amp; mapperClass == null) { ErrorContext.instance().resource(resource); InputStream inputStream = Resources.getResourceAsStream(resource); XMLMapperBuilder mapperParser = new XMLMapperBuilder(inputStream, configuration, resource, configuration.getSqlFragments()); mapperParser.parse(); } else if (resource == null \u0026amp;\u0026amp; url != null \u0026amp;\u0026amp; mapperClass == null) { ErrorContext.instance().resource(url); InputStream inputStream = Resources.getUrlAsStream(url); XMLMapperBuilder mapperParser = new XMLMapperBuilder(inputStream, configuration, url, configuration.getSqlFragments()); mapperParser.parse(); } else if (resource == null \u0026amp;\u0026amp; url == null \u0026amp;\u0026amp; mapperClass != null) { Class\u0026lt;?\u0026gt; mapperInterface = Resources.classForName(mapperClass); configuration.addMapper(mapperInterface); } else { throw new BuilderException(\u0026#34;A mapper element may only specify a url, resource or class, but not more than one.\u0026#34;); } } } } } 解析完后,就将这个mapper接口加到 mapperRegistry中,\nconfiguration.addMapper(mapperInterface); Configuration的addMapper方法\npublic \u0026lt;T\u0026gt; void addMapper(Class\u0026lt;T\u0026gt; type) { mapperRegistry.addMapper(type); } 最后还是加载到了MapperRegistry的knownMappers中去了\npublic \u0026lt;T\u0026gt; void addMapper(Class\u0026lt;T\u0026gt; type) { if (type.isInterface()) { if (hasMapper(type)) { throw new BindingException(\u0026#34;Type \u0026#34; + type + \u0026#34; is already known to the MapperRegistry.\u0026#34;); } boolean loadCompleted = false; try { knownMappers.put(type, new MapperProxyFactory\u0026lt;\u0026gt;(type)); // It\u0026#39;s important that the type is added before the parser is run // otherwise the binding may automatically be attempted by the // mapper parser. If the type is already known, it won\u0026#39;t try. MapperAnnotationBuilder parser = new MapperAnnotationBuilder(config, type); parser.parse(); loadCompleted = true; } finally { if (!loadCompleted) { knownMappers.remove(type); } } } } 获取mapper代理类过程\n6. 执行mapper接口方法 # // 5. 执行接口方法 TTestUser userInfo = userMapper.selectByPrimaryKey(16L); selectByPrimaryKey是TTestUserMapper接口中定义的一个方法,但是我们没有编写TTestUserMapper接口的的实现类,那么Mybatis是怎么帮我们执行的呢?前面讲到,获取mapper对象时,是会获取到一个MapperProxyFactory工厂类,并创建一个MapperProxy代理类,在执行Mapper接口的方法时,会调用MapperProxy的invoke方法。\n@Override public Object invoke(Object proxy, Method method, Object[] args) throws Throwable { try { if (Object.class.equals(method.getDeclaringClass())) { return method.invoke(this, args); } else { return cachedInvoker(method).invoke(proxy, method, args, sqlSession); } } catch (Throwable t) { throw ExceptionUtil.unwrapThrowable(t); } } 如果是Object的方法就直接执行,否则执行cachedInvoker(method).invoke(proxy, method, args, sqlSession); 这行代码,到这里,想必有部分同学已经头晕了吧。怎么又来了个invoke方法。 cachedInvoker 是返回缓存的MapperMethodInvoker对象,MapperMethodInvoker的invoke方法会执行MapperMethod的execute方法。\npublic class MapperMethod { private final SqlCommand command; private final MethodSignature method; public MapperMethod(Class\u0026lt;?\u0026gt; mapperInterface, Method method, Configuration config) { this.command = new SqlCommand(config, mapperInterface, method); this.method = new MethodSignature(config, mapperInterface, method); } public Object execute(SqlSession sqlSession, Object[] args) { Object result; switch (command.getType()) { case INSERT: { Object param = method.convertArgsToSqlCommandParam(args); result = rowCountResult(sqlSession.insert(command.getName(), param)); break; } case UPDATE: { Object param = method.convertArgsToSqlCommandParam(args); result = rowCountResult(sqlSession.update(command.getName(), param)); break; } case DELETE: { Object param = method.convertArgsToSqlCommandParam(args); result = rowCountResult(sqlSession.delete(command.getName(), param)); break; } case SELECT: if (method.returnsVoid() \u0026amp;\u0026amp; method.hasResultHandler()) { executeWithResultHandler(sqlSession, args); result = null; } else if (method.returnsMany()) { result = executeForMany(sqlSession, args); } else if (method.returnsMap()) { result = executeForMap(sqlSession, args); } else if (method.returnsCursor()) { result = executeForCursor(sqlSession, args); } else { Object param = method.convertArgsToSqlCommandParam(args); result = sqlSession.selectOne(command.getName(), param); if (method.returnsOptional() \u0026amp;\u0026amp; (result == null || !method.getReturnType().equals(result.getClass()))) { result = Optional.ofNullable(result); } } break; case FLUSH: result = sqlSession.flushStatements(); break; default: throw new BindingException(\u0026#34;Unknown execution method for: \u0026#34; + command.getName()); } if (result == null \u0026amp;\u0026amp; method.getReturnType().isPrimitive() \u0026amp;\u0026amp; !method.returnsVoid()) { throw new BindingException(\u0026#34;Mapper method \u0026#39;\u0026#34; + command.getName() + \u0026#34; attempted to return null from a method with a primitive return type (\u0026#34; + method.getReturnType() + \u0026#34;).\u0026#34;); } return result; } } 然后根据执行的接口找到mapper.xml中配置的sql,并处理参数,然后执行返回结果处理结果等步骤。\n7. 提交事务 # // 6. 提交事务 sqlSession.commit(); 事务就是将若干数据库操作看成一个单元,要么全部成功,要么全部失败,如果失败了,则会执行执行回滚操作,恢复到开始执行的数据库状态。\n8. 关闭资源 # // 7. 关闭资源 sqlSession.close(); inputStream.close(); sqlSession是种共用资源,用完了要返回到池子中,以供其它地方使用。\n9. 总结 # 至此我们已经大致了解了Mybatis启动时的大致流程,很多细节都还没有详细介绍,这是因为涉及到的层面又深又广,如果在一篇文章中介绍,反而会让读者如置云里雾里,不知所云。因此,在接下来我将每个模块的详细介绍。如果文章有什么错误或者需要改进的,希望同学们指出来,希望对大家有帮助。\n"},{"id":284,"href":"/zh/docs/technology/Review/java_guide/lybly_framework/MyBatis/principle/mybatis-principle1/","title":"Mybatis原理系列(1)","section":"原理","content":" 转载自https://www.jianshu.com/p/ada025f97a07(添加小部分笔记)感谢作者!\n作为Java码农,无论在面试中,还是在工作中都会遇到MyBatis的相关问题。笔者从大学开始就接触MyBatis,到现在为止都是会用,知道怎么配置,怎么编写xml,但是不知道Mybatis核心原理,一遇到问题就复制错误信息百度解决。为了改变这种境地,鼓起勇气开始下定决心阅读MyBatis源码,并开始记录阅读过程,希望和大家分享。\n1. 初识MyBatis # 还记得当初接触MyBatis时,觉得要配置很多,而且sql要单独写在xml中,相比Hibernate来说简直不太友好,直到后来出现了复杂的业务需求,需要编写相应的复杂的sql,此时用Hibernate反而更加麻烦了,用MyBatis是真香了。因此笔者对MyBatis的第一印象就是将业务关注的sql和java代码进行了解耦,在业务复杂变化的时候,相应的数据库操作需要相应进行修改,如果通过java代码构建操作数据逻辑,这不断变动的需求对程序员的耐心是极大的考验。如果将sql统一的维护在一个文件里,java代码用接口定义,在需求变动时,只用改相应的sql,从而减少了修改量,提高开发效率。以上也是经常在面试中经常问到的Hibernate和MyBatis间的区别一点。\n切到正题,Mybatis是什么呢?\nMybatis SQL 映射框架使得一个面向对象构建的应用程序去访问一个关系型数据库变得更容易。MyBatis使用XML描述符或注解将对象与存储过程或SQL语句耦合。与对象关系映射工具相比,简单性是MyBatis数据映射器的最大优势。\n以上是Mybatis的官方解释,其中“映射”,“面向对象”,“关系型”,“xml”等等都是Mybatis的关键词,也是我们了解了Mybatis原理后,会恍然大悟的地方。笔者现在不详述这些概念,在最后总结的时候再进行详述。我们只要知道Mybatis为我们操作数据库提供了很大的便捷。\n2. 源码下载 # 这里建议使用maven即可,在pom.xml添加以下依赖\n\u0026lt;dependencies\u0026gt; \u0026lt;!-- https://mvnrepository.com/artifact/mysql/mysql-connector-java --\u0026gt; \u0026lt;dependency\u0026gt; \u0026lt;groupId\u0026gt;mysql\u0026lt;/groupId\u0026gt; \u0026lt;artifactId\u0026gt;mysql-connector-java\u0026lt;/artifactId\u0026gt; \u0026lt;version\u0026gt;8.0.32\u0026lt;/version\u0026gt; \u0026lt;/dependency\u0026gt; \u0026lt;dependency\u0026gt; \u0026lt;groupId\u0026gt;org.mybatis\u0026lt;/groupId\u0026gt; \u0026lt;artifactId\u0026gt;mybatis\u0026lt;/artifactId\u0026gt; \u0026lt;version\u0026gt;3.5.6\u0026lt;/version\u0026gt; \u0026lt;/dependency\u0026gt; \u0026lt;!--这里还添加了一些辅助的依赖--\u0026gt; \u0026lt;!--lombok--\u0026gt; \u0026lt;dependency\u0026gt; \u0026lt;groupId\u0026gt;org.projectlombok\u0026lt;/groupId\u0026gt; \u0026lt;artifactId\u0026gt;lombok\u0026lt;/artifactId\u0026gt; \u0026lt;version\u0026gt;1.18.8\u0026lt;/version\u0026gt; \u0026lt;/dependency\u0026gt; \u0026lt;!--日志模块--\u0026gt; \u0026lt;dependency\u0026gt; \u0026lt;groupId\u0026gt;org.apache.logging.log4j\u0026lt;/groupId\u0026gt; \u0026lt;artifactId\u0026gt;log4j-api\u0026lt;/artifactId\u0026gt; \u0026lt;version\u0026gt;2.17.1\u0026lt;/version\u0026gt; \u0026lt;/dependency\u0026gt; \u0026lt;/dependencies\u0026gt; 然后在ExternalLibraries 的mybatis:3.5.6里找到,就能看到目录结构 ,随便找一个进去 idea右上角会出现DownloadSource之类的字样 ,点击即可\n我们首先要从github上下载源码, 仓库地址,然后在IDEA中clone代码\n在打开中的IDEA中,选择vsc -\u0026gt; get from version control -\u0026gt; 复制刚才的地址\nimage.png\n点击clone即可\nimage.png\n经过漫长的等待后,代码会全部下载下来,项目结果如下,框起来的就是我们要关注的核心代码了。\nimage.png\n每个包就是MyBatis的一个模块,每个包的作用如下:\n3. 一个简单的栗子 # 不知道现在还有没有同学知道怎么使用原生的JDBC进行数据库操作,现在框架太方便了,为我们考虑了很多,也隐藏了很多细节,因此会让我们处于一个云里雾里的境地,为什么这么设计,这样设计解决了什么问题,我们是不得而知的,为了了解其中奥秘,还是需要我们从头开始了解。\n接下来笔者将以两个栗子来分别讲讲如何用原生的JDBC操作数据库,以及如何使用MyBatis框架来实现相同的功能,并比较两者的区别。\n首先创建数据库 test\n3.1 创建表 # 在此我们建了两张表,一张是t_test_user用户信息主表,一张是t_test_user_info用户信息副表,两张表通过member_id进行关联。\nDROP TABLE IF EXISTS `t_test_user`; CREATE TABLE `t_test_user` ( `id` bigint(20) NOT NULL AUTO_INCREMENT COMMENT \u0026#39;id\u0026#39;, `member_id` bigint(20) NOT NULL COMMENT \u0026#39;会员id\u0026#39;, `real_name` varchar(255) CHARACTER SET utf8 DEFAULT NULL COMMENT \u0026#39;真实姓名\u0026#39;, `nickname` varchar(255) CHARACTER SET utf8mb4 DEFAULT NULL COMMENT \u0026#39;会员昵称\u0026#39;, `date_create` datetime DEFAULT CURRENT_TIMESTAMP COMMENT \u0026#39;创建时间\u0026#39;, `date_update` datetime DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT \u0026#39;更新时间\u0026#39;, `deleted` bigint(20) DEFAULT \u0026#39;0\u0026#39; COMMENT \u0026#39;删除标识,0未删除,时间戳-删除时间\u0026#39;, PRIMARY KEY (`id`) ) ENGINE=InnoDB AUTO_INCREMENT=42013 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT=\u0026#39;测试表\u0026#39;; DROP TABLE IF EXISTS `t_test_user_info`; CREATE TABLE `t_test_user_info` ( `id` bigint(20) NOT NULL AUTO_INCREMENT COMMENT \u0026#39;id\u0026#39;, `member_id` bigint(20) NOT NULL COMMENT \u0026#39;会员id\u0026#39;, `member_phone` varchar(50) CHARACTER SET utf8mb4 DEFAULT NULL COMMENT \u0026#39;电话\u0026#39;, `member_province` varchar(50) CHARACTER SET utf8mb4 DEFAULT NULL COMMENT \u0026#39;省\u0026#39;, `member_city` varchar(50) CHARACTER SET utf8mb4 DEFAULT NULL COMMENT \u0026#39;市\u0026#39;, `member_county` varchar(50) CHARACTER SET utf8mb4 DEFAULT NULL COMMENT \u0026#39;区\u0026#39;, `date_create` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT \u0026#39;创建时间\u0026#39;, `date_update` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT \u0026#39;更新时间\u0026#39;, `deleted` bigint(20) NOT NULL DEFAULT \u0026#39;0\u0026#39; COMMENT \u0026#39;删除标识,0未删除,时间戳-删除时间\u0026#39;, PRIMARY KEY (`id`) ) ENGINE=InnoDB AUTO_INCREMENT=17 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT=\u0026#39;用户信息测试表\u0026#39;; 3.2 使用Java JDBC进行操作数据库 # JDBC(Java Database Connectivity,简称JDBC)是Java中用来规范客户端程序如何来访问数据库的应用程序接口,提供了诸如查询和更新数据库中数据的方法。使用JDBC操作数据库,一般包含7步,代码如下。\npublic class JDBCTest { /** * 数据库地址 替换成本地的地址 */ private static final String url = \u0026#34;jdbc:mysql://localhost:3306/test?useUnicode=true\u0026#34;; /** * 数据库用户名 */ private static final String username = \u0026#34;test\u0026#34;; /** * 密码 */ private static final String password = \u0026#34;test\u0026#34;; public static void main(String[] args) { try { // 1. 加载数据库驱动 Class.forName(\u0026#34;com.mysql.jdbc.Driver\u0026#34;); // 2. 获得连接 Connection connection = DriverManager.getConnection(url, username, password); // 3. 创建sql语句 String sql = \u0026#34;select * from t_test_user\u0026#34;; Statement statement = connection.createStatement(); // 4. 执行sql ResultSet result = statement.executeQuery(sql); // 5. 处理结果 while(result.next()){ System.out.println(\u0026#34;result = \u0026#34; + result.getString(1)); } // 6. 关闭连接 result.close(); connection.close(); } catch (Exception e){ System.out.println(e); } } } 3.3 使用Mybatis进行操作数据库 # 3.3.1 新增mybatis-config.xml配置 # 在路径src/main/resources/mybatis-config.xml新增配置,配置内容如下\n\u0026lt;?xml version=\u0026#34;1.0\u0026#34; encoding=\u0026#34;UTF-8\u0026#34; ?\u0026gt; \u0026lt;!DOCTYPE configuration PUBLIC \u0026#34;-//mybatis.org//DTD Config 3.0//EN\u0026#34; \u0026#34;http://mybatis.org/dtd/mybatis-3-config.dtd\u0026#34;\u0026gt; \u0026lt;configuration\u0026gt; \u0026lt;!--一些重要的全局配置--\u0026gt; \u0026lt;settings\u0026gt; \u0026lt;setting name=\u0026#34;cacheEnabled\u0026#34; value=\u0026#34;true\u0026#34;/\u0026gt; \u0026lt;!--\u0026lt;setting name=\u0026#34;lazyLoadingEnabled\u0026#34; value=\u0026#34;true\u0026#34;/\u0026gt;--\u0026gt; \u0026lt;!--\u0026lt;setting name=\u0026#34;multipleResultSetsEnabled\u0026#34; value=\u0026#34;true\u0026#34;/\u0026gt;--\u0026gt; \u0026lt;!--\u0026lt;setting name=\u0026#34;useColumnLabel\u0026#34; value=\u0026#34;true\u0026#34;/\u0026gt;--\u0026gt; \u0026lt;!--\u0026lt;setting name=\u0026#34;useGeneratedKeys\u0026#34; value=\u0026#34;false\u0026#34;/\u0026gt;--\u0026gt; \u0026lt;!--\u0026lt;setting name=\u0026#34;autoMappingBehavior\u0026#34; value=\u0026#34;PARTIAL\u0026#34;/\u0026gt;--\u0026gt; \u0026lt;!--\u0026lt;setting name=\u0026#34;autoMappingUnknownColumnBehavior\u0026#34; value=\u0026#34;WARNING\u0026#34;/\u0026gt;--\u0026gt; \u0026lt;!--\u0026lt;setting name=\u0026#34;defaultExecutorType\u0026#34; value=\u0026#34;SIMPLE\u0026#34;/\u0026gt;--\u0026gt; \u0026lt;!--\u0026lt;setting name=\u0026#34;defaultStatementTimeout\u0026#34; value=\u0026#34;25\u0026#34;/\u0026gt;--\u0026gt; \u0026lt;!--\u0026lt;setting name=\u0026#34;defaultFetchSize\u0026#34; value=\u0026#34;100\u0026#34;/\u0026gt;--\u0026gt; \u0026lt;!--\u0026lt;setting name=\u0026#34;safeRowBoundsEnabled\u0026#34; value=\u0026#34;false\u0026#34;/\u0026gt;--\u0026gt; \u0026lt;!--\u0026lt;setting name=\u0026#34;mapUnderscoreToCamelCase\u0026#34; value=\u0026#34;false\u0026#34;/\u0026gt;--\u0026gt; \u0026lt;!--\u0026lt;setting name=\u0026#34;localCacheScope\u0026#34; value=\u0026#34;STATEMENT\u0026#34;/\u0026gt;--\u0026gt; \u0026lt;!--\u0026lt;setting name=\u0026#34;jdbcTypeForNull\u0026#34; value=\u0026#34;OTHER\u0026#34;/\u0026gt;--\u0026gt; \u0026lt;!--\u0026lt;setting name=\u0026#34;lazyLoadTriggerMethods\u0026#34; value=\u0026#34;equals,clone,hashCode,toString\u0026#34;/\u0026gt;--\u0026gt; \u0026lt;!--\u0026lt;setting name=\u0026#34;logImpl\u0026#34; value=\u0026#34;STDOUT_LOGGING\u0026#34; /\u0026gt;--\u0026gt; \u0026lt;/settings\u0026gt; \u0026lt;environments default=\u0026#34;development\u0026#34;\u0026gt; \u0026lt;environment id=\u0026#34;development\u0026#34;\u0026gt; \u0026lt;transactionManager type=\u0026#34;JDBC\u0026#34;/\u0026gt; \u0026lt;dataSource type=\u0026#34;POOLED\u0026#34;\u0026gt; \u0026lt;property name=\u0026#34;driver\u0026#34; value=\u0026#34;com.mysql.cj.jdbc.Driver\u0026#34;/\u0026gt; \u0026lt;property name=\u0026#34;url\u0026#34; value=\u0026#34;jdbc:mysql://localhost:3306/test?useUnicode=true\u0026#34;/\u0026gt; \u0026lt;property name=\u0026#34;username\u0026#34; value=\u0026#34;root\u0026#34;/\u0026gt; \u0026lt;property name=\u0026#34;password\u0026#34; value=\u0026#34;123456\u0026#34;/\u0026gt; \u0026lt;/dataSource\u0026gt; \u0026lt;/environment\u0026gt; \u0026lt;/environments\u0026gt; \u0026lt;mappers\u0026gt; \u0026lt;!--这边可以使用package或resource两种方式加载mapper--\u0026gt; \u0026lt;!--\u0026lt;package name=\u0026#34;包名\u0026#34;/\u0026gt;--\u0026gt; \u0026lt;!--如果这里使用了包名, 那么resource下 的Mapper.xml文件的层级,一定要和Mapper类的全类名一样,即com/example/demo/dao/TTestUserMapper.xml--\u0026gt; \u0026lt;!--\u0026lt;mapper resource=\u0026#34;具体的Mapper.xml地址\u0026#34; /\u0026gt;--\u0026gt; \u0026lt;mapper resource=\u0026#34;mapper/TTestUserMapper.xml\u0026#34; /\u0026gt; \u0026lt;!--\u0026lt;package name=\u0026#34;com.example.demo.dao\u0026#34;/\u0026gt;--\u0026gt; \u0026lt;/mappers\u0026gt; \u0026lt;/configuration\u0026gt; 3.3.2 新增mapper接口 # 新增src/main/java/com/example/demo/dao/TTestUserMapper.java 接口\npackage com.example.demo.dao; import com.example.demo.entity.TTestUser; import org.apache.ibatis.annotations.Mapper; import java.util.List; @Mapper public interface TTestUserMapper { /** * This method was generated by MyBatis Generator. * This method corresponds to the database table t_test_user * * @mbggenerated */ int deleteByPrimaryKey(Long id); /** * This method was generated by MyBatis Generator. * This method corresponds to the database table t_test_user * * @mbggenerated */ int insert(TTestUser record); /** * This method was generated by MyBatis Generator. * This method corresponds to the database table t_test_user * * @mbggenerated */ int insertSelective(TTestUser record); int batchInsert(List\u0026lt;TTestUser\u0026gt; records); /** * This method was generated by MyBatis Generator. * This method corresponds to the database table t_test_user * * @mbggenerated */ TTestUser selectByPrimaryKey(Long id); /** * This method was generated by MyBatis Generator. * This method corresponds to the database table t_test_user * * @mbggenerated */ int updateByPrimaryKeySelective(TTestUser record); /** * This method was generated by MyBatis Generator. * This method corresponds to the database table t_test_user * * @mbggenerated */ int updateByPrimaryKey(TTestUser record); } 3.3.3 新增映射配置文件 # src/main/resources/mapper/TTestUserMapper.xml 新增映射配置文件\n\u0026lt;?xml version=\u0026#34;1.0\u0026#34; encoding=\u0026#34;UTF-8\u0026#34;?\u0026gt; \u0026lt;!DOCTYPE mapper PUBLIC \u0026#34;-//mybatis.org//DTD Mapper 3.0//EN\u0026#34; \u0026#34;http://mybatis.org/dtd/mybatis-3-mapper.dtd\u0026#34;\u0026gt; \u0026lt;mapper namespace=\u0026#34;com.example.demo.dao.TTestUserMapper\u0026#34;\u0026gt; \u0026lt;resultMap id=\u0026#34;BaseResultMap\u0026#34; type=\u0026#34;com.example.demo.entity.TTestUser\u0026#34;\u0026gt; \u0026lt;!-- WARNING - @mbggenerated This element is automatically generated by MyBatis Generator, do not modify. --\u0026gt; \u0026lt;id column=\u0026#34;id\u0026#34; jdbcType=\u0026#34;BIGINT\u0026#34; property=\u0026#34;id\u0026#34; /\u0026gt; \u0026lt;result column=\u0026#34;member_id\u0026#34; jdbcType=\u0026#34;BIGINT\u0026#34; property=\u0026#34;memberId\u0026#34; /\u0026gt; \u0026lt;result column=\u0026#34;real_name\u0026#34; jdbcType=\u0026#34;VARCHAR\u0026#34; property=\u0026#34;realName\u0026#34; /\u0026gt; \u0026lt;result column=\u0026#34;nickname\u0026#34; jdbcType=\u0026#34;VARCHAR\u0026#34; property=\u0026#34;nickname\u0026#34; /\u0026gt; \u0026lt;result column=\u0026#34;date_create\u0026#34; jdbcType=\u0026#34;TIMESTAMP\u0026#34; property=\u0026#34;dateCreate\u0026#34; /\u0026gt; \u0026lt;result column=\u0026#34;date_update\u0026#34; jdbcType=\u0026#34;TIMESTAMP\u0026#34; property=\u0026#34;dateUpdate\u0026#34; /\u0026gt; \u0026lt;result column=\u0026#34;deleted\u0026#34; jdbcType=\u0026#34;BIGINT\u0026#34; property=\u0026#34;deleted\u0026#34; /\u0026gt; \u0026lt;/resultMap\u0026gt; \u0026lt;sql id=\u0026#34;Base_Column_List\u0026#34;\u0026gt; \u0026lt;!-- WARNING - @mbggenerated This element is automatically generated by MyBatis Generator, do not modify. --\u0026gt; id, member_id, real_name, nickname, date_create, date_update, deleted \u0026lt;/sql\u0026gt; \u0026lt;select id=\u0026#34;selectByPrimaryKey\u0026#34; parameterType=\u0026#34;java.lang.Long\u0026#34; resultMap=\u0026#34;BaseResultMap\u0026#34;\u0026gt; \u0026lt;!-- WARNING - @mbggenerated This element is automatically generated by MyBatis Generator, do not modify. --\u0026gt; select \u0026lt;include refid=\u0026#34;Base_Column_List\u0026#34; /\u0026gt; from t_test_user where id = #{id,jdbcType=BIGINT} \u0026lt;/select\u0026gt; \u0026lt;delete id=\u0026#34;deleteByPrimaryKey\u0026#34; parameterType=\u0026#34;java.lang.Long\u0026#34;\u0026gt; \u0026lt;!-- WARNING - @mbggenerated This element is automatically generated by MyBatis Generator, do not modify. --\u0026gt; delete from t_test_user where id = #{id,jdbcType=BIGINT} \u0026lt;/delete\u0026gt; \u0026lt;insert id=\u0026#34;insert\u0026#34; parameterType=\u0026#34;com.example.demo.entity.TTestUser\u0026#34;\u0026gt; \u0026lt;!-- WARNING - @mbggenerated This element is automatically generated by MyBatis Generator, do not modify. --\u0026gt; insert into t_test_user (id, member_id, real_name, nickname, date_create, date_update, deleted) values (#{id,jdbcType=BIGINT}, #{memberId,jdbcType=BIGINT}, #{realName,jdbcType=VARCHAR}, #{nickname,jdbcType=VARCHAR}, #{dateCreate,jdbcType=TIMESTAMP}, #{dateUpdate,jdbcType=TIMESTAMP}, #{deleted,jdbcType=BIGINT}) \u0026lt;/insert\u0026gt; \u0026lt;insert id=\u0026#34;insertSelective\u0026#34; parameterType=\u0026#34;com.example.demo.entity.TTestUser\u0026#34;\u0026gt; \u0026lt;!-- WARNING - @mbggenerated This element is automatically generated by MyBatis Generator, do not modify. --\u0026gt; insert into t_test_user \u0026lt;trim prefix=\u0026#34;(\u0026#34; suffix=\u0026#34;)\u0026#34; suffixOverrides=\u0026#34;,\u0026#34;\u0026gt; \u0026lt;if test=\u0026#34;id != null\u0026#34;\u0026gt; id, \u0026lt;/if\u0026gt; \u0026lt;if test=\u0026#34;memberId != null\u0026#34;\u0026gt; member_id, \u0026lt;/if\u0026gt; \u0026lt;if test=\u0026#34;realName != null\u0026#34;\u0026gt; real_name, \u0026lt;/if\u0026gt; \u0026lt;if test=\u0026#34;nickname != null\u0026#34;\u0026gt; nickname, \u0026lt;/if\u0026gt; \u0026lt;if test=\u0026#34;dateCreate != null\u0026#34;\u0026gt; date_create, \u0026lt;/if\u0026gt; \u0026lt;if test=\u0026#34;dateUpdate != null\u0026#34;\u0026gt; date_update, \u0026lt;/if\u0026gt; \u0026lt;if test=\u0026#34;deleted != null\u0026#34;\u0026gt; deleted, \u0026lt;/if\u0026gt; \u0026lt;/trim\u0026gt; \u0026lt;trim prefix=\u0026#34;values (\u0026#34; suffix=\u0026#34;)\u0026#34; suffixOverrides=\u0026#34;,\u0026#34;\u0026gt; \u0026lt;if test=\u0026#34;id != null\u0026#34;\u0026gt; #{id,jdbcType=BIGINT}, \u0026lt;/if\u0026gt; \u0026lt;if test=\u0026#34;memberId != null\u0026#34;\u0026gt; #{memberId,jdbcType=BIGINT}, \u0026lt;/if\u0026gt; \u0026lt;if test=\u0026#34;realName != null\u0026#34;\u0026gt; #{realName,jdbcType=VARCHAR}, \u0026lt;/if\u0026gt; \u0026lt;if test=\u0026#34;nickname != null\u0026#34;\u0026gt; #{nickname,jdbcType=VARCHAR}, \u0026lt;/if\u0026gt; \u0026lt;if test=\u0026#34;dateCreate != null\u0026#34;\u0026gt; #{dateCreate,jdbcType=TIMESTAMP}, \u0026lt;/if\u0026gt; \u0026lt;if test=\u0026#34;dateUpdate != null\u0026#34;\u0026gt; #{dateUpdate,jdbcType=TIMESTAMP}, \u0026lt;/if\u0026gt; \u0026lt;if test=\u0026#34;deleted != null\u0026#34;\u0026gt; #{deleted,jdbcType=BIGINT}, \u0026lt;/if\u0026gt; \u0026lt;/trim\u0026gt; \u0026lt;/insert\u0026gt; \u0026lt;update id=\u0026#34;updateByPrimaryKeySelective\u0026#34; parameterType=\u0026#34;com.example.demo.entity.TTestUser\u0026#34;\u0026gt; \u0026lt;!-- WARNING - @mbggenerated This element is automatically generated by MyBatis Generator, do not modify. --\u0026gt; update t_test_user \u0026lt;set\u0026gt; \u0026lt;if test=\u0026#34;memberId != null\u0026#34;\u0026gt; member_id = #{memberId,jdbcType=BIGINT}, \u0026lt;/if\u0026gt; \u0026lt;if test=\u0026#34;realName != null\u0026#34;\u0026gt; real_name = #{realName,jdbcType=VARCHAR}, \u0026lt;/if\u0026gt; \u0026lt;if test=\u0026#34;nickname != null\u0026#34;\u0026gt; nickname = #{nickname,jdbcType=VARCHAR}, \u0026lt;/if\u0026gt; \u0026lt;if test=\u0026#34;dateCreate != null\u0026#34;\u0026gt; date_create = #{dateCreate,jdbcType=TIMESTAMP}, \u0026lt;/if\u0026gt; \u0026lt;if test=\u0026#34;dateUpdate != null\u0026#34;\u0026gt; date_update = #{dateUpdate,jdbcType=TIMESTAMP}, \u0026lt;/if\u0026gt; \u0026lt;if test=\u0026#34;deleted != null\u0026#34;\u0026gt; deleted = #{deleted,jdbcType=BIGINT}, \u0026lt;/if\u0026gt; \u0026lt;/set\u0026gt; where id = #{id,jdbcType=BIGINT} \u0026lt;/update\u0026gt; \u0026lt;update id=\u0026#34;updateByPrimaryKey\u0026#34; parameterType=\u0026#34;com.example.demo.entity.TTestUser\u0026#34;\u0026gt; \u0026lt;!-- WARNING - @mbggenerated This element is automatically generated by MyBatis Generator, do not modify. --\u0026gt; update t_test_user set member_id = #{memberId,jdbcType=BIGINT}, real_name = #{realName,jdbcType=VARCHAR}, nickname = #{nickname,jdbcType=VARCHAR}, date_create = #{dateCreate,jdbcType=TIMESTAMP}, date_update = #{dateUpdate,jdbcType=TIMESTAMP}, deleted = #{deleted,jdbcType=BIGINT} where id = #{id,jdbcType=BIGINT} \u0026lt;/update\u0026gt; \u0026lt;/mapper\u0026gt; 3.3.5 新增实体类 # package com.example.demo.entity; import java.io.Serializable; import java.util.Date; public class TTestUser implements Serializable { /** * This field was generated by MyBatis Generator. * This field corresponds to the database column t_test_user.id * * @mbggenerated */ private Long id; /** * This field was generated by MyBatis Generator. * This field corresponds to the database column t_test_user.member_id * * @mbggenerated */ private Long memberId; /** * This field was generated by MyBatis Generator. * This field corresponds to the database column t_test_user.real_name * * @mbggenerated */ private String realName; /** * This field was generated by MyBatis Generator. * This field corresponds to the database column t_test_user.nickname * * @mbggenerated */ private String nickname; /** * This field was generated by MyBatis Generator. * This field corresponds to the database column t_test_user.date_create * * @mbggenerated */ private Date dateCreate; /** * This field was generated by MyBatis Generator. * This field corresponds to the database column t_test_user.date_update * * @mbggenerated */ private Date dateUpdate; /** * This field was generated by MyBatis Generator. * This field corresponds to the database column t_test_user.deleted * * @mbggenerated */ private Long deleted; /** * This field was generated by MyBatis Generator. * This field corresponds to the database table t_test_user * * @mbggenerated */ private static final long serialVersionUID = 1L; /** * This method was generated by MyBatis Generator. * This method returns the value of the database column t_test_user.id * * @return the value of t_test_user.id * * @mbggenerated */ public Long getId() { return id; } /** * This method was generated by MyBatis Generator. * This method sets the value of the database column t_test_user.id * * @param id the value for t_test_user.id * * @mbggenerated */ public void setId(Long id) { this.id = id; } /** * This method was generated by MyBatis Generator. * This method returns the value of the database column t_test_user.member_id * * @return the value of t_test_user.member_id * * @mbggenerated */ public Long getMemberId() { return memberId; } /** * This method was generated by MyBatis Generator. * This method sets the value of the database column t_test_user.member_id * * @param memberId the value for t_test_user.member_id * * @mbggenerated */ public void setMemberId(Long memberId) { this.memberId = memberId; } /** * This method was generated by MyBatis Generator. * This method returns the value of the database column t_test_user.real_name * * @return the value of t_test_user.real_name * * @mbggenerated */ public String getRealName() { return realName; } /** * This method was generated by MyBatis Generator. * This method sets the value of the database column t_test_user.real_name * * @param realName the value for t_test_user.real_name * * @mbggenerated */ public void setRealName(String realName) { this.realName = realName == null ? null : realName.trim(); } /** * This method was generated by MyBatis Generator. * This method returns the value of the database column t_test_user.nickname * * @return the value of t_test_user.nickname * * @mbggenerated */ public String getNickname() { return nickname; } /** * This method was generated by MyBatis Generator. * This method sets the value of the database column t_test_user.nickname * * @param nickname the value for t_test_user.nickname * * @mbggenerated */ public void setNickname(String nickname) { this.nickname = nickname == null ? null : nickname.trim(); } /** * This method was generated by MyBatis Generator. * This method returns the value of the database column t_test_user.date_create * * @return the value of t_test_user.date_create * * @mbggenerated */ public Date getDateCreate() { return dateCreate; } /** * This method was generated by MyBatis Generator. * This method sets the value of the database column t_test_user.date_create * * @param dateCreate the value for t_test_user.date_create * * @mbggenerated */ public void setDateCreate(Date dateCreate) { this.dateCreate = dateCreate; } /** * This method was generated by MyBatis Generator. * This method returns the value of the database column t_test_user.date_update * * @return the value of t_test_user.date_update * * @mbggenerated */ public Date getDateUpdate() { return dateUpdate; } /** * This method was generated by MyBatis Generator. * This method sets the value of the database column t_test_user.date_update * * @param dateUpdate the value for t_test_user.date_update * * @mbggenerated */ public void setDateUpdate(Date dateUpdate) { this.dateUpdate = dateUpdate; } /** * This method was generated by MyBatis Generator. * This method returns the value of the database column t_test_user.deleted * * @return the value of t_test_user.deleted * * @mbggenerated */ public Long getDeleted() { return deleted; } /** * This method was generated by MyBatis Generator. * This method sets the value of the database column t_test_user.deleted * * @param deleted the value for t_test_user.deleted * * @mbggenerated */ public void setDeleted(Long deleted) { this.deleted = deleted; } /** * This method was generated by MyBatis Generator. * This method corresponds to the database table t_test_user * * @mbggenerated */ @Override public String toString() { StringBuilder sb = new StringBuilder(); sb.append(getClass().getSimpleName()); sb.append(\u0026#34; [\u0026#34;); sb.append(\u0026#34;Hash = \u0026#34;).append(hashCode()); sb.append(\u0026#34;, id=\u0026#34;).append(id); sb.append(\u0026#34;, memberId=\u0026#34;).append(memberId); sb.append(\u0026#34;, realName=\u0026#34;).append(realName); sb.append(\u0026#34;, nickname=\u0026#34;).append(nickname); sb.append(\u0026#34;, dateCreate=\u0026#34;).append(dateCreate); sb.append(\u0026#34;, dateUpdate=\u0026#34;).append(dateUpdate); sb.append(\u0026#34;, deleted=\u0026#34;).append(deleted); sb.append(\u0026#34;, serialVersionUID=\u0026#34;).append(serialVersionUID); sb.append(\u0026#34;]\u0026#34;); return sb.toString(); } } 3.3.6 执行查询 # @Slf4j public class MyBatisBootStrap { public static void main(String[] args) { try { // 1. 读取配置 InputStream inputStream = Resources.getResourceAsStream(\u0026#34;mybatis-config.xml\u0026#34;); // 2. 创建SqlSessionFactory工厂 SqlSessionFactory sqlSessionFactory = new SqlSessionFactoryBuilder().build(inputStream); // 3. 获取sqlSession SqlSession sqlSession = sqlSessionFactory.openSession(); // 4. 获取Mapper TTestUserMapper userMapper = sqlSession.getMapper(TTestUserMapper.class); // 5. 执行接口方法 TTestUser userInfo = userMapper.selectByPrimaryKey(16L); System.out.println(\u0026#34;userInfo = \u0026#34; + JSONUtil.toJsonStr(userInfo)); // 6. 提交事务 sqlSession.commit(); // 7. 关闭资源 sqlSession.close(); inputStream.close(); } catch (Exception e){ log.error(e.getMessage(), e); } } } 3.4 区别 # 发现没有在写MyBatis的时候,新增了dao, mapper.xml, entity, mybatis-config.xml等很多东西,工作量反而增大了。但是dao, mapper.xml, entity都是可以根据插件mybatis-generator生成的,我们也不用一一去创建,而且我们没有涉及到原生JDBC中加载驱动,创建连接,处理结果集,关闭连接等等这些操作,这些都是MyBatis帮我们做了,我们只用关心提供的查询接口和sql编写即可。\n如果使用原生的JDBC进行数据库操作,我们需要关心如何加载驱动,如何获取连接关闭连接,如何获取结果集等等与业务无关的地方,而MyBatis通过**“映射”这个核心概念将sql和java接口关联起来,我们调用java接口就相当于可以直接执行sql**,并且将结果映射为java pojo对象,这也是我们开头说的**“映射”,“面向对象的”**的原因了。\n4. 总结 # 这篇文章简单的介绍了下MyBatis的基本概念,并提供了简单的栗子,接下来几篇文章打算写下Mybatis的启动流程,让我们更好的了解下mybatis的各模块协作。\n"},{"id":285,"href":"/zh/docs/technology/Review/java_guide/lybly_framework/MyBatis/MyBatis-interview/","title":"Mybatis面试","section":"MyBatis","content":" 转载自https://github.com/Snailclimb/JavaGuide(添加小部分笔记)感谢作者!\n部分疑问参考自 https://blog.csdn.net/Gherbirthday0916 感谢作者!\n#{} 和 ${} 的区别是什么? # 注:这道题是面试官面试我同事的。\n答:\n${}是 Properties 文件中的变量占位符,它可以用于标签属性值和 sql 内部,属于静态文本替换,比如${driver}会被静态替换为com.mysql.jdbc. Driver。 #{}是 sql 的参数占位符,MyBatis 会将 sql 中的#{}替换为? 号,在 sql 执行前会使用 PreparedStatement 的参数设置方法,按序给 sql 的? 号占位符设置参数值,比如 ps.setInt(0, parameterValue),#{item.name} 的取值方式为使用反射从参数对象中获取 item 对象的 name 属性值,相当于 param.getItem().getName()。 [这里用到了反射] 在底层构造完整SQL语句时,MyBatis的两种传参方式所采取的方式不同。#{Parameter}采用预编译的方式构造SQL,避免了 SQL注入 的产生。而**${Parameter}采用拼接的方式构造SQL,在对用户输入过滤不严格**的前提下,此处很可能存在SQL注入\nxml 映射文件中,除了常见的 select、insert、update、delete 标签之外,还有哪些标签? # 注:这道题是京东面试官面试我时问的。\n答:还有很多其他的标签, \u0026lt;resultMap\u0026gt; 、 \u0026lt;parameterMap\u0026gt; 、 \u0026lt;sql\u0026gt; 、 \u0026lt;include\u0026gt; 、 \u0026lt;selectKey\u0026gt; ,加上动态 sql 的 9 个标签, trim|where|set|foreach|if|choose|when|otherwise|bind 等,其中 \u0026lt;sql\u0026gt; 为 sql 片段标签,通过 \u0026lt;include\u0026gt; 标签引入 sql 片段, \u0026lt;selectKey\u0026gt; 为不支持自增的主键生成策略标签。\nset标签,是update用的\n\u0026lt;update id=\u0026#34;updateUserById\u0026#34; parameterType=\u0026#34;user\u0026#34;\u0026gt; update user \u0026lt;set\u0026gt; \u0026lt;if test=\u0026#34;uid!=null\u0026#34;\u0026gt; uid=#{uid} \u0026lt;/if\u0026gt; \u0026lt;/set\u0026gt; \u0026lt;/update\u0026gt; ResultMap(结果集映射):\n假设我们的数据库字段和映射pojo类的属性字段不一致,那么查询结果,不一致的字段值会为null\n这时可以使用Mybatis ResultMap 结果集映射\nparameterMap(参数类型映射):\n很少使用,基本都用parameterType替代\nparameterMap标签可以用来定义参数组,可以为参数组指定ID、参数类型\n例如有一个bean是这样的:\npublic class ArgBean { private String name; private int age; // 忽略 getter 和 setter } 下面使用 \u0026lt;parameterMap\u0026gt; 将参数 ArgBean 对象进行映射\n\u0026lt;parameterMap id=\u0026#34;PARAM_MAP\u0026#34; type=\u0026#34;com.hxstrive.mybatis.parameter.demo2.ArgBean\u0026#34;\u0026gt; \u0026lt;parameter property=\u0026#34;age\u0026#34; javaType=\u0026#34;integer\u0026#34; /\u0026gt; \u0026lt;parameter property=\u0026#34;name\u0026#34; javaType=\u0026#34;String\u0026#34; /\u0026gt; \u0026lt;/parameterMap\u0026gt; sql(sql片段标签)/include(片段插入标签): 重复的SQL预计永远不可避免,\u0026lt;sql\u0026gt;标签就是用来解决这个问题的\n其中 \u0026lt;sql\u0026gt; 为 sql 片段标签,通过 \u0026lt;include\u0026gt; 标签引入 sql 片段\n例如:\n\u0026lt;mapper namespace=\u0026#34;com.klza.dao.UserMapper\u0026#34;\u0026gt; \u0026lt;sql id=\u0026#34;sqlUserParameter\u0026#34;\u0026gt;id,username,password\u0026lt;/sql\u0026gt; \u0026lt;select id=\u0026#34;getUserList\u0026#34; resultType=\u0026#34;user\u0026#34;\u0026gt; select \u0026lt;include refid=\u0026#34;sqlUserParameter\u0026#34;/\u0026gt; from test.user \u0026lt;/select\u0026gt; \u0026lt;/mapper\u0026gt; \u0026lt;selectKey\u0026gt; 为不支持自增的主键生成策略标签\n\u0026lt;insert id=\u0026#34;insert\u0026#34; parameterType=\u0026#34;com.pinyougou.pojo.TbGoods\u0026#34; \u0026gt; \u0026lt;selectKey resultType=\u0026#34;java.lang.Long\u0026#34; order=\u0026#34;AFTER\u0026#34; keyProperty=\u0026#34;id\u0026#34;\u0026gt; SELECT LAST_INSERT_ID() AS id \u0026lt;/selectKey\u0026gt; insert into tb_goods (id, seller_id ) values (#{id,jdbcType=BIGINT}, #{sellerId,jdbcType=VARCHAR} \u0026lt;/insert\u0026gt; Dao 接口的工作原理是什么?Dao 接口里的方法,参数不同时,方法能重载吗? # 注:这道题也是京东面试官面试我被问的。\n答:最佳实践中,通常一个 xml 映射文件,都会写一个 Dao 接口与之对应。Dao 接口就是人们常说的 Mapper 接口,接口的全限名,就是映射文件中的 namespace 的值,接口的方法名,就是映射文件中 MappedStatement 的 id 值,接口方法内的参数,就是传递给 sql 的参数。 Mapper 接口是没有实现类的,当调用接口方法时,接口全限名+方法名拼接字符串作为 key 值,可唯一定位一个 MappedStatement ,举例: com.mybatis3.mappers. StudentDao.findStudentById ,可以唯一找到 namespace 为 com.mybatis3.mappers. StudentDao 下面 id = findStudentById 的 MappedStatement 。在 MyBatis 中,每一个 \u0026lt;select\u0026gt; 、 \u0026lt;insert\u0026gt; 、 \u0026lt;update\u0026gt; 、 \u0026lt;delete\u0026gt; 标签,都会被解析为一个 MappedStatement 对象。\nDao 接口里的方法,是不能重载的,因为是全限名+方法名的保存和寻找策略。\nDao 接口里的方法可以重载,但是 Mybatis 的 xml 里面的 ID 不允许重复。\nMybatis 版本 3.3.0,亲测如下:\n/** * Mapper接口里面方法重载 */ public interface StuMapper { List\u0026lt;Student\u0026gt; getAllStu(); List\u0026lt;Student\u0026gt; getAllStu(@Param(\u0026#34;id\u0026#34;) Integer id); } 然后在 StuMapper.xml 中利用 Mybatis 的动态 sql 就可以实现。\n\u0026lt;select id=\u0026#34;getAllStu\u0026#34; resultType=\u0026#34;com.pojo.Student\u0026#34;\u0026gt; select * from student \u0026lt;where\u0026gt; \u0026lt;if test=\u0026#34;id != null\u0026#34;\u0026gt; id = #{id} \u0026lt;/if\u0026gt; \u0026lt;/where\u0026gt; \u0026lt;/select\u0026gt; 能正常运行,并能得到相应的结果,这样就实现了在 Dao 接口中写重载方法。\nMybatis 的 Dao 接口可以有多个重载方法,但是多个接口对应的映射必须只有一个,否则启动会报错。\n相关 issue : 更正:Dao 接口里的方法可以重载,但是 Mybatis 的 xml 里面的 ID 不允许重复!。\nDao 接口的工作原理是 JDK 动态代理,MyBatis 运行时会使用 JDK 动态代理为 Dao 接口生成代理 proxy 对象,代理对象 proxy 会拦截接口方法,转而执行 MappedStatement 所代表的 sql,然后将 sql 执行结果返回。\n补充 :\nDao 接口方法可以重载,但是需要满足以下条件:\n仅有一个无参方法和一个有参方法 (多个参数)的方法中,参数数量必须(和xml中的)一致。且使用相同的 @Param ,或者使用 param1 这种 测试如下 :\nPersonDao.java Person queryById(); Person queryById(@Param(\u0026#34;id\u0026#34;) Long id); Person queryById(@Param(\u0026#34;id\u0026#34;) Long id, @Param(\u0026#34;name\u0026#34;) String name); PersonMapper.xml \u0026lt;select id=\u0026#34;queryById\u0026#34; resultMap=\u0026#34;PersonMap\u0026#34;\u0026gt; select id, name, age, address from person \u0026lt;where\u0026gt; \u0026lt;if test=\u0026#34;id != null\u0026#34;\u0026gt; id = #{id} \u0026lt;/if\u0026gt; \u0026lt;if test=\u0026#34;name != null and name != \u0026#39;\u0026#39;\u0026#34;\u0026gt; and name = #{name} \u0026lt;/if\u0026gt; \u0026lt;/where\u0026gt; limit 1 \u0026lt;/select\u0026gt; org.apache.ibatis.scripting.xmltags. DynamicContext. ContextAccessor#getProperty 方法用于获取 \u0026lt;if\u0026gt; 标签中的条件值\nContextAccessor 这个修饰符为默认(同一个包内)\npublic Object getProperty(Map context, Object target, Object name) { Map map = (Map) target; Object result = map.get(name); if (map.containsKey(name) || result != null) { return result; } Object parameterObject = map.get(PARAMETER_OBJECT_KEY); if (parameterObject instanceof Map) { return ((Map)parameterObject).get(name); } return null; } parameterObject 为 map,存放的是 Dao 接口中参数相关信息。\n((Map)parameterObject).get(name) 方法如下\npublic V get(Object key) { if (!super.containsKey(key)) { throw new BindingException(\u0026#34;Parameter \u0026#39;\u0026#34; + key + \u0026#34;\u0026#39; not found. Available parameters are \u0026#34; + keySet()); } return super.get(key); } queryById()方法执行时,parameterObject为 null,getProperty方法返回 null 值,\u0026lt;if\u0026gt;标签获取的所有条件值都为 null,所有条件不成立,动态 sql 可以正常执行。 queryById(1L)方法执行时,parameterObject为 map,包含了**id和param1**两个 key 值。当获取\u0026lt;if\u0026gt;标签中name的属性值时,进入((Map)parameterObject).get(name)方法中,map 中 key 不包含name,所以抛出异常。 queryById(1L,\u0026quot;1\u0026quot;)方法执行时,parameterObject中包含id,param1,name,param2四个 key 值,id和name属性都可以获取到,动态 sql 正常执行。 也就是说,if的test一定是会进行判断的(除非整个parameterObject为null)。但是如果这里面的param 不存在,那么就会抛异常 (BindingException)\nMyBatis 是如何进行分页的?分页插件的原理是什么? # 注:我出的。\n答:(1) MyBatis 使用 RowBounds 对象进行分页,它是针对 ResultSet 结果集执行的内存分页,而非物理分页;\n//使用 //Mapper中 List\u0026lt;User\u0026gt; getUserListLimit(RowBounds rowBounds); //Mapper.xml定义 (不变) \u0026lt;select id=\u0026#34;getUserListLimit\u0026#34; resultType=\u0026#34;user\u0026#34;\u0026gt; select * from test.user \u0026lt;/select\u0026gt; //使用, 将会从index为0的记录开始,取两条记录 List\u0026lt;User\u0026gt; userListLimit = userMapper.getUserListLimit(new RowBounds(0, 2)); for (User user : userListLimit) { System.out.println(user); } 想要使用mybatis日志,只要加上日志模块的依赖即可\n\u0026lt;dependency\u0026gt; \u0026lt;groupId\u0026gt;org.slf4j\u0026lt;/groupId\u0026gt; \u0026lt;artifactId\u0026gt;slf4j-api\u0026lt;/artifactId\u0026gt; \u0026lt;version\u0026gt;1.7.30\u0026lt;/version\u0026gt; \u0026lt;/dependency\u0026gt; \u0026lt;!-- https://mvnrepository.com/artifact/ch.qos.logback/logback-classic --\u0026gt; \u0026lt;dependency\u0026gt; \u0026lt;groupId\u0026gt;ch.qos.logback\u0026lt;/groupId\u0026gt; \u0026lt;artifactId\u0026gt;logback-classic\u0026lt;/artifactId\u0026gt; \u0026lt;version\u0026gt;1.2.3\u0026lt;/version\u0026gt; \u0026lt;/dependency\u0026gt; 查看上面的日志可以发现,实际查找的是全部的数据(没有使用物理分页)\n14:28:14.938 [main] DEBUG org.mybatis.example.BlogMapper.selectBlog - ==\u0026gt; Preparing: select * from Blog 14:28:14.996 [main] DEBUG org.mybatis.example.BlogMapper.selectBlog - ==\u0026gt; Parameters: [Blog{id=2, name=\u0026#39;n2\u0026#39;, age=20}, Blog{id=3, name=\u0026#39;n3\u0026#39;, age=30}] (2) 可以在 sql 内直接书写带有物理分页的参数来完成物理分页功能\n(3) 也可以使用分页插件来完成物理分页\n分页插件的基本原理是使用 MyBatis 提供的插件接口,实现自定义插件,在插件的拦截方法内拦截待执行的 sql,然后重写 sql,根据 dialect 方言,添加对应的物理分页语句和物理分页参数。\n举例: select _ from student ,拦截 sql 后重写为: select t._ from (select \\* from student)t limit 0,10\n分页插件的使用\n接下来介绍PageHelper插件的使用:\n第一步,引入依赖:\n\u0026lt;!-- https://mvnrepository.com/artifact/com.github.pagehelper/pagehelper --\u0026gt; \u0026lt;dependency\u0026gt; \u0026lt;groupId\u0026gt;com.github.pagehelper\u0026lt;/groupId\u0026gt; \u0026lt;artifactId\u0026gt;pagehelper\u0026lt;/artifactId\u0026gt; \u0026lt;version\u0026gt;5.3.2\u0026lt;/version\u0026gt; \u0026lt;/dependency\u0026gt; 第二步,mybatis-config.xml配置拦截器:\n\u0026lt;!-- 配置pageHelper拦截器 --\u0026gt; \u0026lt;plugins\u0026gt; \u0026lt;plugin interceptor=\u0026#34;com.github.pagehelper.PageInterceptor\u0026#34;/\u0026gt; \u0026lt;/plugins\u0026gt; 第三步,代码编写:\nMapper接口类:\nList\u0026lt;User\u0026gt; getUserListByPageHelper(); Mapper.xml:\n\u0026lt;select id=\u0026#34;getUserListByPageHelper\u0026#34; resultType=\u0026#34;user\u0026#34;\u0026gt; select * from test.user \u0026lt;/select\u0026gt; 测试程序:\n// 开启分页功能 int pageNum = 1; // 当前页码 int pageSize = 2; // 每页的记录数 PageHelper.startPage(pageNum, pageSize); List\u0026lt;User\u0026gt; userListByPageHelper = userMapper.getUserListByPageHelper(); userListByPageHelper.forEach(System.out::println); 第四步:获取pageInfo信息:\npageHelper真正强大的地方在于它的pageInfo功能,它可以为我们提供详细的分页数据:\n例如:\n// 开启分页功能 int pageNum = 2; // 当前页码 int pageSize = 5; // 每页的记录数 PageHelper.startPage(pageNum, pageSize); List\u0026lt;User\u0026gt; userListByPageHelper = userMapper.getUserListByPageHelper(); // 设置导航的卡片数为3 PageInfo\u0026lt;User\u0026gt; userPageInfo = new PageInfo\u0026lt;\u0026gt;(userListByPageHelper, 3); System.out.println(userPageInfo); /* * PageInfo{pageNum=2, pageSize=5, size=5, startRow=6, endRow=10, total=1004, pages=201, * list=Page{count=true, pageNum=2, pageSize=5, startRow=5, endRow=10, total=1004, pages=201, reasonable=false, pageSizeZero=false} * [User(id=6, username=Cheng Zhennan, password=Jx3SLGXeS4), User(id=7, username=Thelma Hernandez, password=VxVO6dEgym), User(id=8, username=Emma Wood, password=XljUnUrnFZ), User(id=9, username=Kikuchi Akina, password=IgditeatR7), User(id=10, username=Miura Kenta, password=2CbmTGczZv)], * prePage=1, nextPage=3, isFirstPage=false, isLastPage=false, hasPreviousPage=true, hasNextPage=true, navigatePages=3, navigateFirstPage=1, navigateLastPage=3, navigatepageNums=[1, 2, 3]} 简述 MyBatis 的插件运行原理,以及如何编写一个插件。 # 注:我出的。\n答:MyBatis 仅可以编写针对 ParameterHandler 、 ResultSetHandler 、 StatementHandler 、 Executor 这 4 种接口的插件,MyBatis 使用 JDK 的动态代理,为需要拦截的接口生成代理对象以实现接口方法拦截功能,每当执行这 4 种接口对象的方法时,就会进入拦截方法,具体就是 InvocationHandler 的 invoke() 方法,当然,只会拦截那些你指定需要拦截的方法。\n实现 MyBatis 的 Interceptor 接口并复写 intercept() 方法,然后在给插件编写注解,指定要拦截哪一个接口的哪些方法即可,记住,别忘了在配置文件中配置你编写的插件。\nMyBatis 执行批量插入,能返回数据库主键列表吗? # 注:我出的。\n答:能,JDBC 都能,MyBatis 当然也能。\nMyBatis 动态 sql 是做什么的?都有哪些动态 sql?能简述一下动态 sql 的执行原理不? # 注:我出的。\n答:MyBatis 动态 sql 可以让我们在 xml 映射文件内,以标签的形式编写动态 sql,完成逻辑判断和动态拼接 sql 的功能。其执行原理为,使用 OGNL 从 sql 参数对象中计算表达式的值,根据表达式的值动态拼接 sql,以此来完成动态 sql 的功能。\nMyBatis 提供了 9 种动态 sql 标签:\n\u0026lt;if\u0026gt;\u0026lt;/if\u0026gt; \u0026lt;where\u0026gt;\u0026lt;/where\u0026gt;(trim,set) \u0026lt;choose\u0026gt;\u0026lt;/choose\u0026gt;(when, otherwise) \u0026lt;foreach\u0026gt;\u0026lt;/foreach\u0026gt; \u0026lt;bind/\u0026gt; 关于 MyBatis 动态 SQL 的详细介绍,请看这篇文章: Mybatis 系列全解(八):Mybatis 的 9 大动态 SQL 标签你知道几个? 。\n关于这些动态 SQL 的具体使用方法,请看这篇文章: Mybatis【13】\u0026ndash; Mybatis 动态 sql 标签怎么使用?\nMyBatis 是如何将 sql 执行结果封装为目标对象并返回的?都有哪些映射形式? # 注:我出的。\n答:第一种是使用 \u0026lt;resultMap\u0026gt; 标签,逐一定义列名和对象属性名之间的映射关系。第二种是使用 sql 列的别名功能,将列别名书写为对象属性名,比如 T_NAME AS NAME,对象属性名一般是 name,小写,但是列名不区分大小写,MyBatis 会忽略列名大小写,智能找到与之对应对象属性名,你甚至可以写成 T_NAME AS NaMe,MyBatis 一样可以正常工作。\n有了列名与属性名的映射关系后,MyBatis 通过反射创建对象,同时使用反射 给对象的属性逐一赋值并返回,那些找不到映射关系的属性,是无法完成赋值的。\nMyBatis 能执行一对一、一对多的关联查询吗?都有哪些实现方式,以及它们之间的区别。【实际没有用过】 # 注:我出的。\n答:能,MyBatis 不仅可以执行一对一、一对多的关联查询,还可以执行多对一,多对多的关联查询,多对一查询,其实就是一对一查询,只需要把 selectOne() 修改为 selectList() 即可;多对多查询,其实就是一对多查询,只需要把 selectOne() 修改为 selectList() 即可。\n关联对象查询,有两种实现方式,一种是单独发送一个 sql 去查询关联对象,赋给主对象,然后返回主对象。另一种是使用嵌套查询,嵌套查询的含义为使用 join 查询,一部分列是 A 对象的属性值,另外一部分列是关联对象 B 的属性值,好处是只发一个 sql 查询,就可以把主对象和其关联对象查出来。\n那么问题来了,join 查询出来 100 条记录,如何确定主对象是 5 个,而不是 100 个?其去重复的原理是 \u0026lt;resultMap\u0026gt; 标签内的 \u0026lt;id\u0026gt; 子标签,指定了唯一确定一条记录的 id 列,MyBatis 根据 \u0026lt;id\u0026gt; 列值来完成 100 条记录的去重复功能, \u0026lt;id\u0026gt; 可以有多个,代表了联合主键的语意。\n同样主对象的关联对象,也是根据这个原理去重复的,尽管一般情况下,只有主对象会有重复记录,关联对象一般不会重复。\n举例:下面 join 查询出来 6 条记录,一、二列是 Teacher 对象列,第三列为 Student 对象列,MyBatis 去重复处理后,结果为 1 个老师 6 个学生,而不是 6 个老师 6 个学生。\nt_id t_name s_id 1 teacher 38 1 teacher 39 1 teacher 40 1 teacher 41 1 teacher 42 1 teacher 43 MyBatis 是否支持延迟加载?如果支持,它的实现原理是什么? # 注:我出的。\n答:MyBatis 仅支持 association 关联对象和 collection 关联集合对象的延迟加载,association 指的就是一对一,collection 指的就是一对多查询。在 MyBatis 配置文件中,可以配置是否启用延迟加载 lazyLoadingEnabled=true|false。\n它的原理是,使用 CGLIB 创建目标对象的代理对象,当调用目标方法时,进入拦截器方法,比如调用 a.getB().getName() ,拦截器 invoke() 方法发现 a.getB() 是 null 值,那么就会单独发送事先保存好的查询关联 B 对象的 sql,把 B 查询上来,然后调用 a.setB(b),于是 a 的对象 b 属性就有值了,接着完成 a.getB().getName() 方法的调用。这就是延迟加载的基本原理。\n当然了,不光是 MyBatis,几乎所有的包括 Hibernate,支持延迟加载的原理都是一样的。\nMyBatis 的 xml 映射文件中,不同的 xml 映射文件,id 是否可以重复? # 注:我出的。\n答:不同的 xml 映射文件,如果配置了 namespace,那么 id 可以重复;如果没有配置 namespace,那么 id 不能重复;毕竟 namespace 不是必须的,只是最佳实践而已。\n原因就是 namespace+id 是作为 Map\u0026lt;String, MappedStatement\u0026gt; 的 key 使用的,如果没有 namespace,就剩下 id,那么,id 重复会导致数据互相覆盖。有了 namespace,自然 id 就可以重复,namespace 不同,namespace+id 自然也就不同。\nMyBatis 中如何执行批处理? # 注:我出的。\n答:使用 BatchExecutor 完成批处理。\nMyBatis 都有哪些 Executor 执行器?它们之间的区别是什么? # 注:我出的\n答:MyBatis 有三种基本的 Executor 执行器:\nSimpleExecutor: 每执行一次 update 或 select,就开启一个 Statement 对象,用完立刻关闭 Statement 对象。 ReuseExecutor: 执行 update 或 select,以 sql 作为 key 查找 Statement 对象,存在就使用,不存在就创建,用完后,不关闭 Statement 对象,而是放置于 Map\u0026lt;String, Statement\u0026gt;内,供下一次使用。简言之,就是重复使用 Statement 对象。 BatchExecutor :执行 update(没有 select,JDBC 批处理不支持 select),将所有 sql 都添加到批处理中(addBatch()),等待统一执行(executeBatch()),它缓存了多个 Statement 对象,每个 Statement 对象都是 addBatch()完毕后,等待逐一执行 executeBatch()批处理。与 JDBC 批处理相同。 作用范围:Executor 的这些特点,都严格限制在 SqlSession 生命周期范围内。\nMyBatis 中如何指定使用哪一种 Executor 执行器? # 注:我出的\n答:在 MyBatis 配置文件中,可以指定默认的 ExecutorType 执行器类型,也可以手动给 DefaultSqlSessionFactory 的创建 SqlSession 的方法传递 ExecutorType 类型参数。\nMyBatis 是否可以映射 Enum 枚举类? # 注:我出的\n答:MyBatis 可以映射枚举类,不单可以映射枚举类,MyBatis 可以映射任何对象到表的一列上。映射方式为自定义一个 TypeHandler ,实现 TypeHandler 的 setParameter() 和 getResult() 接口方法。 TypeHandler 有两个作用:\n一是完成从 javaType 至 jdbcType 的转换; 二是完成 jdbcType 至 javaType 的转换,体现为 setParameter() 和 getResult() 两个方法,分别代表设置 sql 问号占位符参数和获取列查询结果。 MyBatis 映射文件中,如果 A 标签通过 include 引用了 B 标签的内容,请问,B 标签能否定义在 A 标签的后面,还是说必须定义在 A 标签的前面? # 注:我出的\n答:虽然 MyBatis 解析 xml 映射文件是按照顺序解析的,但是,被引用的 B 标签依然可以定义在任何地方,MyBatis 都可以正确识别。\n原理是,MyBatis 解析 A 标签,发现 A 标签引用了 B 标签,但是 B 标签尚未解析到,尚不存在,此时,MyBatis 会将 A 标签标记为未解析状态,然后继续解析余下的标签,包含 B 标签,待所有标签解析完毕,MyBatis 会重新解析那些被标记为未解析的标签,此时再解析 A 标签时,B 标签已经存在,A 标签也就可以正常解析完成了。\n简述 MyBatis 的 xml 映射文件和 MyBatis 内部数据结构之间的映射关系?[不懂] # 注:我出的\n答:MyBatis 将所有 xml 配置信息都封装到 All-In-One 重量级对象 Configuration 内部。在 xml 映射文件中, \u0026lt;parameterMap\u0026gt; 标签会被解析为 ParameterMap 对象,其每个子元素会被解析为 ParameterMapping 对象。 \u0026lt;resultMap\u0026gt; 标签会被解析为 ResultMap 对象,其每个子元素会被解析为 ResultMapping 对象。每一个 \u0026lt;select\u0026gt;、\u0026lt;insert\u0026gt;、\u0026lt;update\u0026gt;、\u0026lt;delete\u0026gt; 标签均会被解析为 MappedStatement 对象,标签内的 sql 会被解析为 BoundSql 对象。\n为什么说 MyBatis 是半自动 ORM 映射工具?它与全自动的区别在哪里? # 注:我出的\n答:Hibernate 属于全自动 ORM 映射工具,使用 Hibernate 查询关联对象或者关联集合对象时,可以根据对象关系模型直接获取,所以它是全自动的。而 MyBatis 在查询关联对象或关联集合对象时,需要手动编写 sql 来完成,所以,称之为半自动 ORM 映射工具。\n面试题看似都很简单,但是想要能正确回答上来,必定是研究过源码且深入的人,而不是仅会使用的人或者用的很熟的人,以上所有面试题及其答案所涉及的内容,在我的 MyBatis 系列博客中都有详细讲解和原理分析。\n"},{"id":286,"href":"/zh/docs/technology/Review/java_guide/lybly_framework/conditional_on_class/","title":"ConditionalOnClass实践","section":"框架","content":" 两个测试方向 # 方向1:两个maven项目 # 详见git上的 conditional_on_class_main 项目以及 conditional_on_class2 项目\n基础maven项目 conditional_on_class2\npom文件\n\u0026lt;?xml version=\u0026#34;1.0\u0026#34; encoding=\u0026#34;UTF-8\u0026#34;?\u0026gt; \u0026lt;project xmlns=\u0026#34;http://maven.apache.org/POM/4.0.0\u0026#34; xmlns:xsi=\u0026#34;http://www.w3.org/2001/XMLSchema-instance\u0026#34; xsi:schemaLocation=\u0026#34;http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd\u0026#34;\u0026gt; \u0026lt;modelVersion\u0026gt;4.0.0\u0026lt;/modelVersion\u0026gt; \u0026lt;groupId\u0026gt;org.example\u0026lt;/groupId\u0026gt; \u0026lt;artifactId\u0026gt;conditional_on_class_2\u0026lt;/artifactId\u0026gt; \u0026lt;version\u0026gt;1.0-SNAPSHOT\u0026lt;/version\u0026gt; \u0026lt;/project\u0026gt; java类\npackage com; public class LyReferenceImpl { public String sayWord() { return \u0026#34;hello one\u0026#34;; } } 简单的SpringBoot项目 conditional_on_class_main\n\u0026lt;!--pom文件--\u0026gt; \u0026lt;?xml version=\u0026#34;1.0\u0026#34; encoding=\u0026#34;UTF-8\u0026#34;?\u0026gt; \u0026lt;project xmlns=\u0026#34;http://maven.apache.org/POM/4.0.0\u0026#34; xmlns:xsi=\u0026#34;http://www.w3.org/2001/XMLSchema-instance\u0026#34; xsi:schemaLocation=\u0026#34;http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd\u0026#34;\u0026gt; \u0026lt;modelVersion\u0026gt;4.0.0\u0026lt;/modelVersion\u0026gt; \u0026lt;groupId\u0026gt;org.example\u0026lt;/groupId\u0026gt; \u0026lt;artifactId\u0026gt;conditional_on_class_main\u0026lt;/artifactId\u0026gt; \u0026lt;version\u0026gt;1.0-SNAPSHOT\u0026lt;/version\u0026gt; \u0026lt;parent\u0026gt; \u0026lt;groupId\u0026gt;org.springframework.boot\u0026lt;/groupId\u0026gt; \u0026lt;artifactId\u0026gt;spring-boot-starter-parent\u0026lt;/artifactId\u0026gt; \u0026lt;version\u0026gt;2.7.8\u0026lt;/version\u0026gt; \u0026lt;/parent\u0026gt; \u0026lt;dependencies\u0026gt; \u0026lt;!--把1配置的bean引用进来--\u0026gt; \u0026lt;dependency\u0026gt; \u0026lt;groupId\u0026gt;org.example\u0026lt;/groupId\u0026gt; \u0026lt;artifactId\u0026gt;conditional_on_class_2\u0026lt;/artifactId\u0026gt; \u0026lt;version\u0026gt;1.0-SNAPSHOT\u0026lt;/version\u0026gt; \u0026lt;scope\u0026gt;provided\u0026lt;/scope\u0026gt; \u0026lt;optional\u0026gt;true\u0026lt;/optional\u0026gt; \u0026lt;/dependency\u0026gt; \u0026lt;dependency\u0026gt; \u0026lt;groupId\u0026gt;org.springframework.boot\u0026lt;/groupId\u0026gt; \u0026lt;artifactId\u0026gt;spring-boot-starter-web\u0026lt;/artifactId\u0026gt; \u0026lt;/dependency\u0026gt; \u0026lt;/dependencies\u0026gt; \u0026lt;build\u0026gt; \u0026lt;plugins\u0026gt; \u0026lt;plugin\u0026gt; \u0026lt;groupId\u0026gt;org.springframework.boot\u0026lt;/groupId\u0026gt; \u0026lt;artifactId\u0026gt;spring-boot-maven-plugin\u0026lt;/artifactId\u0026gt; \u0026lt;configuration\u0026gt; \u0026lt;excludes\u0026gt; \u0026lt;!-- 默认会将conditional_on_class_2 打包进去,现在会配置SayExist 如果放开注释,那么会配置SayNotExist--\u0026gt; \u0026lt;!--\u0026lt;dependency\u0026gt; \u0026lt;groupId\u0026gt;org.example\u0026lt;/groupId\u0026gt; \u0026lt;artifactId\u0026gt;conditional_on_class_2\u0026lt;/artifactId\u0026gt; \u0026lt;/dependency\u0026gt;--\u0026gt; \u0026lt;/excludes\u0026gt; \u0026lt;jvmArguments\u0026gt;-Dfile.encoding=UTF-8\u0026lt;/jvmArguments\u0026gt; \u0026lt;/configuration\u0026gt; \u0026lt;executions\u0026gt; \u0026lt;execution\u0026gt; \u0026lt;goals\u0026gt; \u0026lt;goal\u0026gt;repackage\u0026lt;/goal\u0026gt;\u0026lt;!--可以把依赖的包都打包到生成的Jar包中 --\u0026gt; \u0026lt;/goals\u0026gt; \u0026lt;/execution\u0026gt; \u0026lt;/executions\u0026gt; \u0026lt;/plugin\u0026gt; \u0026lt;/plugins\u0026gt; \u0026lt;/build\u0026gt; \u0026lt;/project\u0026gt; //两个配置类 //配置类1 package com.config; import com.service.ISay; import com.service.SayExist; import org.springframework.boot.autoconfigure.condition.ConditionalOnClass; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; @Configuration //不要放在方法里面,否则会报错\u0026#34;java.lang.ArrayStoreException: sun.reflect.annotation.TypeNotPresentExceptionProxy\u0026#34; @ConditionalOnClass(value = com.LyReferenceImpl.class) public class ExistConfiguration { @Bean public ISay getISay1(){ return new SayExist(); } } //配置类2 package com.config; import com.service.ISay; import com.service.SayNotExist; import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingClass; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; @Configuration @ConditionalOnMissingClass(\u0026#34;com.LyReferenceImpl\u0026#34;) public class NotExistConfiguration { @Bean public ISay getISay1(){ return new SayNotExist(); } } 方向2:3个maven项目(建议用这个理解) # 注意,这里可能还漏了一个问题,那就是 这个conditional_on_class1 的configuration之所以能够被自动装配,是因为和 conditional_on_class_main1的Application类是同一个包,所以不用特殊处理。如果是其他包名的话,那么是需要用到spring boot的自动装配机制的:在conditional_on_class1 工程的 resources 包下创建META-INF/spring.factories,并写上Config类的全类名\n详见 git上的 conditional_on_class_main1, conditional_on_class1 项目以及 conditional_on_class2 项目\n基础 conditional_on_class2\npom文件\n\u0026lt;?xml version=\u0026#34;1.0\u0026#34; encoding=\u0026#34;UTF-8\u0026#34;?\u0026gt; \u0026lt;project xmlns=\u0026#34;http://maven.apache.org/POM/4.0.0\u0026#34; xmlns:xsi=\u0026#34;http://www.w3.org/2001/XMLSchema-instance\u0026#34; xsi:schemaLocation=\u0026#34;http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd\u0026#34;\u0026gt; \u0026lt;modelVersion\u0026gt;4.0.0\u0026lt;/modelVersion\u0026gt; \u0026lt;groupId\u0026gt;org.example\u0026lt;/groupId\u0026gt; \u0026lt;artifactId\u0026gt;conditional_on_class_2\u0026lt;/artifactId\u0026gt; \u0026lt;version\u0026gt;1.0-SNAPSHOT\u0026lt;/version\u0026gt; \u0026lt;/project\u0026gt; java类\npackage com; public class LyReferenceImpl { public String sayWord() { return \u0026#34;hello one\u0026#34;; } } 以LyReferenceImpl.class存不存在,决定创建哪个bean\nconditional_on_class_1 pom.xml\n\u0026lt;?xml version=\u0026#34;1.0\u0026#34; encoding=\u0026#34;UTF-8\u0026#34;?\u0026gt; \u0026lt;project xmlns=\u0026#34;http://maven.apache.org/POM/4.0.0\u0026#34; xmlns:xsi=\u0026#34;http://www.w3.org/2001/XMLSchema-instance\u0026#34; xsi:schemaLocation=\u0026#34;http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd\u0026#34;\u0026gt; \u0026lt;modelVersion\u0026gt;4.0.0\u0026lt;/modelVersion\u0026gt; \u0026lt;groupId\u0026gt;org.example\u0026lt;/groupId\u0026gt; \u0026lt;artifactId\u0026gt;conditional_on_class_1\u0026lt;/artifactId\u0026gt; \u0026lt;version\u0026gt;1.0-SNAPSHOT\u0026lt;/version\u0026gt; \u0026lt;dependencies\u0026gt; \u0026lt;!--引入被引用的类,只在编译期存在--\u0026gt; \u0026lt;dependency\u0026gt; \u0026lt;groupId\u0026gt;org.example\u0026lt;/groupId\u0026gt; \u0026lt;artifactId\u0026gt;conditional_on_class_2\u0026lt;/artifactId\u0026gt; \u0026lt;version\u0026gt;1.0-SNAPSHOT\u0026lt;/version\u0026gt; \u0026lt;scope\u0026gt;provided\u0026lt;/scope\u0026gt; \u0026lt;optional\u0026gt;true\u0026lt;/optional\u0026gt; \u0026lt;/dependency\u0026gt; \u0026lt;!-- https://mvnrepository.com/artifact/org.springframework.boot/spring-boot-autoconfigure --\u0026gt; \u0026lt;dependency\u0026gt; \u0026lt;groupId\u0026gt;org.springframework.boot\u0026lt;/groupId\u0026gt; \u0026lt;artifactId\u0026gt;spring-boot-autoconfigure\u0026lt;/artifactId\u0026gt; \u0026lt;version\u0026gt;2.7.8\u0026lt;/version\u0026gt; \u0026lt;/dependency\u0026gt; \u0026lt;/dependencies\u0026gt; \u0026lt;/project\u0026gt; //根据是否存在class_2中的类,进行自动装配 @Configuration //不要放在方法里面,否则会报错\u0026#34;java.lang.ArrayStoreException: sun.reflect.annotation.TypeNotPresentExceptionProxy\u0026#34; @ConditionalOnClass(value = com.LyReferenceImpl.class) public class ExistConfiguration { @Bean public LyEntity lyEntity1(){ return new LyEntity(\u0026#34;存在\u0026#34;); } } @Configuration @ConditionalOnMissingClass(\u0026#34;com.LyReferenceImpl\u0026#34;) public class NotExistConfiguration { @Bean public LyEntity lyEntity1(){ return new LyEntity(\u0026#34;不存在\u0026#34;); } } //基础类 public class LyEntity { private String name; private Integer age; public LyEntity(String name) { this.name = name; } public String getName() { return name; } public void setName(String name) { this.name = name; } public Integer getAge() { return age; } public void setAge(Integer age) { this.age = age; } } 使用 在_main项目中 pom.xml\n\u0026lt;parent\u0026gt; \u0026lt;groupId\u0026gt;org.springframework.boot\u0026lt;/groupId\u0026gt; \u0026lt;artifactId\u0026gt;spring-boot-starter-parent\u0026lt;/artifactId\u0026gt; \u0026lt;version\u0026gt;2.7.8\u0026lt;/version\u0026gt; \u0026lt;/parent\u0026gt; \u0026lt;dependencies\u0026gt; \u0026lt;!--把1配置的bean引用进来--\u0026gt; \u0026lt;dependency\u0026gt; \u0026lt;groupId\u0026gt;org.example\u0026lt;/groupId\u0026gt; \u0026lt;artifactId\u0026gt;conditional_on_class_1\u0026lt;/artifactId\u0026gt; \u0026lt;version\u0026gt;1.0-SNAPSHOT\u0026lt;/version\u0026gt; \u0026lt;/dependency\u0026gt; \u0026lt;!--加上这个则会提示存在--\u0026gt; \u0026lt;!-- \u0026lt;dependency\u0026gt; \u0026lt;groupId\u0026gt;org.example\u0026lt;/groupId\u0026gt; \u0026lt;artifactId\u0026gt;conditional_on_class_2\u0026lt;/artifactId\u0026gt; \u0026lt;version\u0026gt;1.0-SNAPSHOT\u0026lt;/version\u0026gt; \u0026lt;/dependency\u0026gt; --\u0026gt; \u0026lt;dependency\u0026gt; \u0026lt;groupId\u0026gt;org.springframework.boot\u0026lt;/groupId\u0026gt; \u0026lt;artifactId\u0026gt;spring-boot-starter-web\u0026lt;/artifactId\u0026gt; \u0026lt;/dependency\u0026gt; \u0026lt;/dependencies\u0026gt; 如果不存在class_2中的类,则提示不存在;如果存在则提示存在\n@SpringBootApplication @RestController public class MyApplication { @Autowired private LyEntity lyEntity; @RequestMapping(\u0026#34;hello\u0026#34;) public String hello(){ return lyEntity.getName(); } public static void main(String[] args) { SpringApplication.run(MyApplication.class,args); } } "},{"id":287,"href":"/zh/docs/technology/Review/java_guide/lybly_framework/ly05ly_springboot-auto-assembly/","title":"SpringBoot自动装配原理","section":"框架","content":" 转载自https://github.com/Snailclimb/JavaGuide(添加小部分笔记)感谢作者!\n每次问到 Spring Boot, 面试官非常喜欢问这个问题:“讲述一下 SpringBoot 自动装配原理?”。\n我觉得我们可以从以下几个方面回答:\n什么是 SpringBoot 自动装配? SpringBoot 是如何实现自动装配的?如何实现按需加载? 如何实现一个 Starter? 篇幅问题,这篇文章并没有深入,小伙伴们也可以直接使用 debug 的方式去看看 SpringBoot 自动装配部分的源代码。\n前言 # 使用过 Spring 的小伙伴,一定有被 XML 配置统治的恐惧。即使 Spring 后面引入了基于注解的配置,我们在开启某些 Spring 特性或者引入第三方依赖的时候,还是需要用 XML 或 Java 进行显式配置。\n举个例子。没有 Spring Boot 的时候,我们写一个 RestFul Web 服务,还首先需要进行如下配置。\n@Configuration public class RESTConfiguration { @Bean public View jsonTemplate() { MappingJackson2JsonView view = new MappingJackson2JsonView(); view.setPrettyPrint(true); return view; } @Bean public ViewResolver viewResolver() { return new BeanNameViewResolver(); } } spring-servlet.xml \u0026lt;beans xmlns=\u0026#34;http://www.springframework.org/schema/beans\u0026#34; xmlns:xsi=\u0026#34;http://www.w3.org/2001/XMLSchema-instance\u0026#34; xmlns:context=\u0026#34;http://www.springframework.org/schema/context\u0026#34; xmlns:mvc=\u0026#34;http://www.springframework.org/schema/mvc\u0026#34; xsi:schemaLocation=\u0026#34;http://www.springframework.org/schema/beans http://www.springframework.org/schema/beans/spring-beans.xsd http://www.springframework.org/schema/context/ http://www.springframework.org/schema/context/spring-context.xsd http://www.springframework.org/schema/mvc/ http://www.springframework.org/schema/mvc/spring-mvc.xsd\u0026#34;\u0026gt; \u0026lt;context:component-scan base-package=\u0026#34;com.howtodoinjava.demo\u0026#34; /\u0026gt; \u0026lt;mvc:annotation-driven /\u0026gt; \u0026lt;!-- JSON Support --\u0026gt; \u0026lt;bean name=\u0026#34;viewResolver\u0026#34; class=\u0026#34;org.springframework.web.servlet.view.BeanNameViewResolver\u0026#34;/\u0026gt; \u0026lt;bean name=\u0026#34;jsonTemplate\u0026#34; class=\u0026#34;org.springframework.web.servlet.view.json.MappingJackson2JsonView\u0026#34;/\u0026gt; \u0026lt;/beans\u0026gt; 但是,Spring Boot 项目,我们只需要添加相关依赖,无需配置,通过启动下面的 main 方法即可。\n@SpringBootApplication public class DemoApplication { public static void main(String[] args) { SpringApplication.run(DemoApplication.class, args); } } 并且,我们通过 Spring Boot 的全局配置文件 application.properties或application.yml即可对项目进行设置比如更换端口号,配置 JPA 属性等等。\n为什么 Spring Boot 使用起来这么酸爽呢? 这得益于其自动装配。自动装配可以说是 Spring Boot 的核心,那究竟什么是自动装配呢?\n什么是 SpringBoot 自动装配? # 我们现在提到自动装配的时候,一般会和 Spring Boot 联系在一起。但是,实际上 Spring Framework 早就实现了这个功能。Spring Boot 只是在其基础上,通过 SPI 的方式,做了进一步优化。\nSpringBoot 定义了一套接口规范,这套规范规定:SpringBoot 在启动时会扫描外部引用 jar 包中的META-INF/spring.factories文件,将文件中配置的类型信息加载到 Spring 容器(此处涉及到 JVM 类加载机制与 Spring 的容器知识),并执行类中定义的各种操作。对于外部 jar 来说,只需要按照 SpringBoot 定义的标准,就能将自己的功能装置进 SpringBoot。\n没有 Spring Boot 的情况下,如果我们需要引入第三方依赖,需要手动配置,非常麻烦。但是,Spring Boot 中,我们直接引入一个 starter 即可。比如你想要在项目中使用 redis 的话,直接在项目中引入对应的 starter 即可。\n\u0026lt;dependency\u0026gt; \u0026lt;groupId\u0026gt;org.springframework.boot\u0026lt;/groupId\u0026gt; \u0026lt;artifactId\u0026gt;spring-boot-starter-data-redis\u0026lt;/artifactId\u0026gt; \u0026lt;/dependency\u0026gt; 引入 starter 之后,我们通过少量注解和一些简单的配置就能使用第三方组件提供的功能了。\n在我看来,自动装配可以简单理解为:通过注解或者一些简单的配置就能在 Spring Boot 的帮助下实现某块功能。\nSpringBoot 是如何实现自动装配的? # 我们先看一下 SpringBoot 的核心注解 SpringBootApplication 。\n@Target({ElementType.TYPE}) @Retention(RetentionPolicy.RUNTIME) @Documented @Inherited \u0026lt;1.\u0026gt;@SpringBootConfiguration \u0026lt;2.\u0026gt;@ComponentScan \u0026lt;3.\u0026gt;@EnableAutoConfiguration public @interface SpringBootApplication { } @Target({ElementType.TYPE}) @Retention(RetentionPolicy.RUNTIME) @Documented @Configuration //实际上它也是一个配置类 public @interface SpringBootConfiguration { } 大概可以把 @SpringBootApplication看作是 @Configuration、@EnableAutoConfiguration、@ComponentScan 注解的集合。根据 SpringBoot 官网,这三个注解的作用分别是:\n@EnableAutoConfiguration:启用 SpringBoot 的自动配置机制\n@Configuration:允许在上下文中注册额外的 bean 或导入其他配置类\n@ComponentScan: 扫描被@Component (@Service,@Controller)注解的 bean,注解默认会扫描启动类所在的包下所有的类 ,可以自定义不扫描某些 bean。如下图所示,容器中将排除TypeExcludeFilter和AutoConfigurationExcludeFilter。\n@EnableAutoConfiguration 是实现自动装配的重要注解,我们以这个注解入手。\n@EnableAutoConfiguration:实现自动装配的核心注解 # EnableAutoConfiguration 只是一个简单地注解,自动装配核心功能的实现实际是通过 **AutoConfigurationImportSelector**类。\n@Target({ElementType.TYPE}) @Retention(RetentionPolicy.RUNTIME) @Documented @Inherited @AutoConfigurationPackage //作用:将main包下的所有组件注册到容器中 @Import({AutoConfigurationImportSelector.class}) //加载自动装配类 xxxAutoconfiguration public @interface EnableAutoConfiguration { String ENABLED_OVERRIDE_PROPERTY = \u0026#34;spring.boot.enableautoconfiguration\u0026#34;; Class\u0026lt;?\u0026gt;[] exclude() default {}; String[] excludeName() default {}; } 我们现在重点分析下**AutoConfigurationImportSelector** 类到底做了什么?\nAutoConfigurationImportSelector:加载自动装配类 # AutoConfigurationImportSelector类的继承体系如下:\npublic class AutoConfigurationImportSelector implements DeferredImportSelector, BeanClassLoaderAware, ResourceLoaderAware, BeanFactoryAware, EnvironmentAware, Ordered { } public interface DeferredImportSelector extends ImportSelector { } public interface ImportSelector { String[] selectImports(AnnotationMetadata var1); } 可以看出,AutoConfigurationImportSelector 类实现了 ImportSelector接口,也就实现了这个接口中的 selectImports方法,该方法主要用于获取所有符合条件的类的全限定类名,这些类需要被加载到 IoC 容器中。\nprivate static final String[] NO_IMPORTS = new String[0]; public String[] selectImports(AnnotationMetadata annotationMetadata) { // \u0026lt;1\u0026gt;.判断自动装配开关是否打开 if (!this.isEnabled(annotationMetadata)) { return NO_IMPORTS; } else { //\u0026lt;2\u0026gt;.获取所有需要装配的bean AutoConfigurationMetadata autoConfigurationMetadata = AutoConfigurationMetadataLoader.loadMetadata(this.beanClassLoader); AutoConfigurationImportSelector.AutoConfigurationEntry autoConfigurationEntry = this.getAutoConfigurationEntry(autoConfigurationMetadata, annotationMetadata); return StringUtils.toStringArray(autoConfigurationEntry.getConfigurations()); } } 这里我们需要重点关注一下getAutoConfigurationEntry()方法,这个方法主要负责加载自动配置类的。\n该方法调用链如下:\n现在我们结合getAutoConfigurationEntry()的源码来详细分析一下:\nprivate static final AutoConfigurationEntry EMPTY_ENTRY = new AutoConfigurationEntry(); AutoConfigurationEntry getAutoConfigurationEntry(AutoConfigurationMetadata autoConfigurationMetadata, AnnotationMetadata annotationMetadata) { //\u0026lt;1\u0026gt;. if (!this.isEnabled(annotationMetadata)) { return EMPTY_ENTRY; } else { //\u0026lt;2\u0026gt;. AnnotationAttributes attributes = this.getAttributes(annotationMetadata); //\u0026lt;3\u0026gt;. List\u0026lt;String\u0026gt; configurations = this.getCandidateConfigurations(annotationMetadata, attributes); //\u0026lt;4\u0026gt;. configurations = this.removeDuplicates(configurations); Set\u0026lt;String\u0026gt; exclusions = this.getExclusions(annotationMetadata, attributes); this.checkExcludedClasses(configurations, exclusions); configurations.removeAll(exclusions); configurations = this.filter(configurations, autoConfigurationMetadata); this.fireAutoConfigurationImportEvents(configurations, exclusions); return new AutoConfigurationImportSelector.AutoConfigurationEntry(configurations, exclusions); } } 第 1 步:\n判断自动装配开关是否打开。默认spring.boot.enableautoconfiguration=true,可在 application.properties 或 application.yml 中设置\n第 2 步 :\n用于获取EnableAutoConfiguration注解中的 exclude 和 excludeName。\n第 3 步\n获取需要自动装配的所有配置类,读取META-INF/spring.factories\nspring-boot/spring-boot-project/spring-boot-autoconfigure/src/main/resources/META-INF/spring.factories 从下图可以看到这个文件的配置内容都被我们读取到了。XXXAutoConfiguration的作用就是按需加载组件。\n[\n不光是这个依赖下的META-INF/spring.factories被读取到,所有 Spring Boot Starter 下的META-INF/spring.factories都会被读取到。\n所以,你可以清楚滴看到, druid 数据库连接池的 Spring Boot Starter 就创建了META-INF/spring.factories文件。\n如果,我们自己要创建一个 Spring Boot Starter,这一步是必不可少的。\n第 4 步 :\n到这里可能面试官会问你:“spring.factories中这么多配置,每次启动都要全部加载么?”。\n很明显,这是不现实的。我们 debug 到后面你会发现,configurations 的值变小了。\n因为,这一步有经历了一遍筛选,@ConditionalOnXXX 中的所有条件都满足,该类才会生效。\n@Configuration // 检查相关的类:RabbitTemplate 和 Channel是否存在 // 存在才会加载 @ConditionalOnClass({ RabbitTemplate.class, Channel.class }) @EnableConfigurationProperties(RabbitProperties.class) @Import(RabbitAnnotationDrivenConfiguration.class) public class RabbitAutoConfiguration { } 有兴趣的童鞋可以详细了解下 Spring Boot 提供的条件注解\n@ConditionalOnBean:当容器里有指定 Bean 的条件下 @ConditionalOnMissingBean:当容器里没有指定 Bean 的情况下 @ConditionalOnSingleCandidate:当指定 Bean 在容器中只有一个,或者虽然有多个但是指定首选 Bean @ConditionalOnClass:当类路径下有指定类的条件下(这个极其重要) @ConditionalOnMissingClass:当类路径下没有指定类的条件下 @ConditionalOnProperty:指定的属性是否有指定的值 @ConditionalOnResource:类路径是否有指定的值 @ConditionalOnExpression:基于 SpEL 表达式作为判断条件 @ConditionalOnJava:基于 Java 版本作为判断条件 @ConditionalOnJndi:在 JNDI 存在的条件下差在指定的位置 @ConditionalOnNotWebApplication:当前项目不是 Web 项目的条件下 @ConditionalOnWebApplication:当前项目是 Web 项 目的条件下 如何实现一个 Starter # 光说不练假把式,现在就来撸一个 starter,实现自定义线程池\n第一步,创建threadpool-spring-boot-starter工程\n第二步,引入 Spring Boot 相关依赖\n[\n第三步,创建ThreadPoolAutoConfiguration\n第四步,在threadpool-spring-boot-starter工程的 resources 包下创建META-INF/spring.factories文件\n最后新建工程引入threadpool-spring-boot-starter\n测试通过!!!\n[\n总结 # Spring Boot 通过**@EnableAutoConfiguration开启自动装配,通过 SpringFactoriesLoader 最终加载META-INF/spring.factories中的自动配置类实现自动装配**,自动配置类其实就是通过@Conditional按需加载的配置类,想要其生效必须引入**spring-boot-starter-xxx包实现起步依赖**\n"},{"id":288,"href":"/zh/docs/technology/Review/java_guide/lybly_framework/ly04ly_spring-design-patterns/","title":"spring 设计模式","section":"框架","content":" 转载自https://github.com/Snailclimb/JavaGuide(添加小部分笔记)感谢作者!\n“JDK 中用到了哪些设计模式? Spring 中用到了哪些设计模式? ”这两个问题,在面试中比较常见。\n我在网上搜索了一下关于 Spring 中设计模式的讲解几乎都是千篇一律,而且大部分都年代久远。所以,花了几天时间自己总结了一下。\n由于我的个人能力有限,文中如有任何错误各位都可以指出。另外,文章篇幅有限,对于设计模式以及一些源码的解读我只是一笔带过,这篇文章的主要目的是回顾一下 Spring 中的设计模式。\n控制反转(IoC)和依赖注入(DI) # IoC(Inversion of Control,控制反转) 是 Spring 中一个非常非常重要的概念,它不是什么技术,而是一种解耦的设计思想。IoC 的主要目的是借助于“第三方”(Spring 中的 IoC 容器) 实现具有依赖关系的对象之间的解耦(IOC 容器管理对象,你只管使用即可),从而降低代码之间的耦合度。\nIoC 是一个原则,而不是一个模式,以下模式(但不限于)实现了 IoC 原则。\nSpring IoC 容器就像是一个工厂一样,当我们需要创建一个对象的时候,只需要配置好配置文件/注解即可,完全不用考虑对象是如何被创建出来的。 IoC 容器负责创建对象,将对象连接在一起,配置这些对象,并从创建中处理这些对象的整个生命周期,直到它们被完全销毁。\n在实际项目中一个 Service 类如果有几百甚至上千个类作为它的底层,我们需要实例化这个 Service,你可能要每次都要搞清这个 Service 所有底层类的构造函数,这可能会把人逼疯。如果利用 IOC 的话,你只需要配置好,然后在需要的地方引用就行了,这大大增加了项目的可维护性且降低了开发难度。\n关于 Spring IOC 的理解,推荐看这一下知乎的一个回答:https://www.zhihu.com/question/23277575/answer/169698662 ,非常不错。\n控制反转怎么理解呢? 举个例子:\u0026quot;对象 a 依赖了对象 b,当对象 a 需要使用 对象 b 的时候必须自己去创建。但是当系统引入了 IOC 容器后, 对象 a 和对象 b 之前就失去了直接的联系。这个时候,当对象 a 需要使用 对象 b 的时候, 我们可以指定 IOC 容器去创建一个对象 b 注入到对象 a 中\u0026quot;。 对象 a 获得依赖对象 b 的过程,由主动行为变为了被动行为,控制权反转,这就是控制反转名字的由来。\nDI(Dependecy Inject,依赖注入)是实现控制反转的一种设计模式,依赖注入就是将实例变量传入到一个对象中去。\n工厂设计模式 # //方式一 spring3.1后,XmlBeanFactory被弃用 XmlBeanFactory factory = new XmlBeanFactory (new ClassPathResource(\u0026#34;beans.xml\u0026#34;)); User bean = factory.getBean(User.class); System.out.println(bean); /* //方式二 ApplicationContext context=new ClassPathXmlApplicationContext(\u0026#34;beans.xml\u0026#34;); User bean = context.getBean(User.class); System.out.println(bean);*/ Spring 使用工厂模式可以通过 BeanFactory 或 ApplicationContext 创建 bean 对象。\nApplicationContext继承了ListableBeanFactory,ListableBeanFactory继承了BeanFactory\n两者对比:\nBeanFactory :延迟注入(使用到某个 bean 的时候才会注入),相比于ApplicationContext 来说会占用更少的内存,程序启动速度更快。 ApplicationContext :容器启动的时候,不管你用没用到,一次性创建所有 bean 。BeanFactory 仅提供了最基本的依赖注入支持,ApplicationContext 扩展了 BeanFactory ,除了有BeanFactory的功能还有额外更多功能,所以一般开发人员使用ApplicationContext会更多。 ApplicationContext 的三个实现类:\nClassPathXmlApplication:把上下文文件当成类路径资源。 FileSystemXmlApplication:从文件系统中的 XML 文件载入上下文定义信息。 XmlWebApplicationContext:从 Web 系统中的 XML 文件载入上下文定义信息。 Example:\nimport org.springframework.context.ApplicationContext; import org.springframework.context.support.FileSystemXmlApplicationContext; public class App { public static void main(String[] args) { ApplicationContext context = new FileSystemXmlApplicationContext( \u0026#34;C:/work/IOC Containers/springframework.applicationcontext/src/main/resources/bean-factory-config.xml\u0026#34;); HelloApplicationContext obj = (HelloApplicationContext) context.getBean(\u0026#34;helloApplicationContext\u0026#34;); obj.getMsg(); } } 单例设计模式 # 在我们的系统中,有一些对象其实我们只需要一个,比如说:线程池、缓存、对话框、注册表、日志对象、充当打印机、显卡等设备驱动程序的对象。事实上,这一类对象只能有一个实例,如果制造出多个实例就可能会导致一些问题的产生,比如:程序的行为异常、资源使用过量、或者不一致性的结果。\n使用单例模式的好处 :\n对于频繁使用的对象,可以省略创建对象所花费的时间,这对于那些重量级对象而言,是非常可观的一笔系统开销; 由于 new 操作的次数减少,因而对系统内存的使用频率也会降低,这将减轻 GC 压力,缩短 GC 停顿时间。 Spring 中 bean 的默认作用域就是 singleton(单例)的。 除了 singleton 作用域,Spring 中 bean 还有下面几种作用域:\nprototype : 每次获取都会创建一个新的 bean 实例。也就是说,连续 getBean() 两次,得到的是不同的 Bean 实例。 request (仅 Web 应用可用): 每一次 HTTP 请求都会产生一个新的 bean(请求 bean),该 bean 仅在当前 HTTP request 内有效。 session (仅 Web 应用可用) : 每一次来自新 session 的 HTTP 请求都会产生一个新的 bean(会话 bean),该 bean 仅在当前 HTTP session 内有效。 application/global-session (仅 Web 应用可用): 每个 Web 应用在启动时创建一个 Bean(应用 Bean),,该 bean 仅在当前应用启动时间内有效。 websocket (仅 Web 应用可用):每一次 WebSocket 会话产生一个新的 bean。 Spring 通过 ConcurrentHashMap 实现单例注册表的特殊方式实现单例模式。\nSpring 实现单例的核心代码如下:\n// 通过 ConcurrentHashMap(线程安全) 实现单例注册表 private final Map\u0026lt;String, Object\u0026gt; singletonObjects = new ConcurrentHashMap\u0026lt;String, Object\u0026gt;(64); public Object getSingleton(String beanName, ObjectFactory\u0026lt;?\u0026gt; singletonFactory) { Assert.notNull(beanName, \u0026#34;\u0026#39;beanName\u0026#39; must not be null\u0026#34;); synchronized (this.singletonObjects) { // 检查缓存中是否存在实例 Object singletonObject = this.singletonObjects.get(beanName); if (singletonObject == null) { //...省略了很多代码 try { singletonObject = singletonFactory.getObject(); } //...省略了很多代码 // 如果实例对象在不存在,我们注册到单例注册表中。 addSingleton(beanName, singletonObject); } return (singletonObject != NULL_OBJECT ? singletonObject : null); } } //将对象添加到单例注册表 protected void addSingleton(String beanName, Object singletonObject) { synchronized (this.singletonObjects) { this.singletonObjects.put(beanName, (singletonObject != null ? singletonObject : NULL_OBJECT)); } } } 单例 Bean 存在线程安全问题吗?\n大部分时候我们并没有在项目中使用多线程,所以很少有人会关注这个问题。单例 Bean 存在线程问题,主要是因为当多个线程操作同一个对象的时候是存在资源竞争的。\n常见的有两种解决办法:\n在 Bean 中尽量避免定义可变的成员变量。 在类中定义一个 ThreadLocal 成员变量,将需要的可变成员变量保存在 ThreadLocal 中(推荐的一种方式)。 不过,大部分 Bean 实际都是无状态(没有实例变量)的(比如 Dao、Service),这种情况下, Bean 是线程安全的。\n代理设计模式 # 代理模式在 AOP 中的应用 # AOP(Aspect-Oriented Programming,面向切面编程) 能够将那些与业务无关,却为业务模块所共同调用的逻辑或责任(例如事务处理、日志管理、权限控制等)封装起来,便于减少系统的重复代码,降低模块间的耦合度,并有利于未来的可拓展性和可维护性。\nSpring AOP 就是基于动态代理的,如果要代理的对象,实现了某个接口,那么 Spring AOP 会使用 JDK Proxy 去创建代理对象,而对于没有实现接口的对象,就无法使用 JDK Proxy 去进行代理了,这时候 Spring AOP 会使用 Cglib 生成一个被代理对象的子类来作为代理,如下图所示:\n当然,你也可以使用 AspectJ ,Spring AOP 已经集成了 AspectJ ,AspectJ 应该算的上是 Java 生态系统中最完整的 AOP 框架了。\n使用 AOP 之后我们可以把一些通用功能抽象出来,在需要用到的地方直接使用即可,这样大大简化了代码量。我们需要增加新功能时也方便,这样也提高了系统扩展性。日志功能、事务管理等等场景都用到了 AOP 。\nSpring AOP 和 AspectJ AOP 有什么区别? # Spring AOP 属于运行时增强,而 AspectJ 是编译时增强。 Spring AOP 基于代理(Proxying),而 AspectJ 基于字节码操作(Bytecode Manipulation)。\nSpring AOP 已经集成了 AspectJ ,AspectJ 应该算的上是 Java 生态系统中最完整的 AOP 框架了。AspectJ 相比于 Spring AOP 功能更加强大,但是 Spring AOP 相对来说更简单,\n如果我们的切面比较少,那么两者性能差异不大。但是,当切面太多的话,最好选择 AspectJ ,它比 Spring AOP 快很多。\n模板方法 # 模板方法模式是一种行为设计模式,它定义一个操作中的算法的骨架,而将一些步骤延迟到子类中。 模板方法使得子类可以不改变一个算法的结构即可重定义该算法的某些特定步骤的实现方式。\npublic abstract class Template { //这是我们的模板方法 public final void TemplateMethod(){ PrimitiveOperation1(); PrimitiveOperation2(); PrimitiveOperation3(); } protected void PrimitiveOperation1(){ //当前类实现 } //被子类实现的方法 protected abstract void PrimitiveOperation2(); protected abstract void PrimitiveOperation3(); } public class TemplateImpl extends Template { @Override public void PrimitiveOperation2() { //当前类实现 } @Override public void PrimitiveOperation3() { //当前类实现 } } Spring 中 JdbcTemplate、HibernateTemplate 等以 Template 结尾的对数据库操作的类,它们就使用到了模板模式。一般情况下,我们都是使用继承的方式来实现模板模式,但是 Spring 并没有使用这种方式,而是使用 Callback 模式与模板方法模式配合,既达到了代码复用的效果,同时增加了灵活性。\n什么是Callback模式\n//纯模板方法模式 public abstract class JdbcTemplate { public final Object execute(String sql){ Connection con=null; Statement stmt=null; try { con=getConnection(); stmt=con.createStatement(); Object retValue=executeWithStatement(stmt,sql); return retValue; } catch(SQLException e){ ... } finally { closeStatement(stmt); releaseConnection(con); } } protected abstract Object executeWithStatement(Statement stmt, String sql); } Callback类\npublic interface StatementCallback{ Object doWithStatement(Statement stmt); } 使用\n//结合Callback模式 public class JdbcTemplate { //主要是传入了一个类 public final Object execute(StatementCallback callback){ Connection con=null; Statement stmt=null; try { con=getConnection(); stmt=con.createStatement(); Object retValue=callback.doWithStatement(stmt); return retValue; } catch(SQLException e){ ... } finally { closeStatement(stmt); releaseConnection(con); } } ...//其它方法定义 } JdbcTemplate jdbcTemplate=...; final String sql=...; StatementCallback callback=new StatementCallback(){ public Object=doWithStatement(Statement stmt){ return ...; } } jdbcTemplate.execute(callback); 观察者模式 # 观察者模式是一种对象行为型模式。它表示的是一种对象与对象之间具有依赖关系,当一个对象发生改变的时候,这个对象所依赖的对象也会做出反应。Spring 事件驱动模型就是观察者模式很经典的一个应用。Spring 事件驱动模型非常有用,在很多场景都可以解耦我们的代码。比如我们每次添加商品的时候都需要重新更新商品索引,这个时候就可以利用观察者模式来解决这个问题。\nSpring 事件驱动模型中的三种角色 # 事件角色:是一种属性(物品)\n事件监听者:(注册到事件发布者上)\n事件发布者:当发布的时候,通知指定的监听者(观察者)\n事件角色 # ApplicationEvent (org.springframework.context包下)充当事件的角色,这是一个抽象类,它继承了java.util.EventObject并实现了 java.io.Serializable接口。\nSpring 中默认存在以下事件,他们都是对 ApplicationContextEvent 的实现(继承自ApplicationContextEvent):\nContextStartedEvent:ApplicationContext 启动后触发的事件; ContextStoppedEvent:ApplicationContext 停止后触发的事件; ContextRefreshedEvent:ApplicationContext 初始化或刷新完成后触发的事件; ContextClosedEvent:ApplicationContext 关闭后触发的事件。 事件监听者角色 # ApplicationListener 充当了事件监听者角色,它是一个接口,里面只定义了一个 onApplicationEvent()方法来处理ApplicationEvent。ApplicationListener接口类源码如下,可以看出接口定义看出接口中的事件只要实现了 ApplicationEvent就可以了。所以,在 Spring 中我们只要实现 ApplicationListener 接口的 onApplicationEvent() 方法即可完成监听事件\n注意代码,E extends ApplicationEvent , 对某类事件进行监听\npackage org.springframework.context; import java.util.EventListener; @FunctionalInterface public interface ApplicationListener\u0026lt;E extends ApplicationEvent\u0026gt; extends EventListener { void onApplicationEvent(E var1); } 事件发布者角色 # ApplicationEventPublisher 充当了事件的发布者,它也是一个接口。\n@FunctionalInterface public interface ApplicationEventPublisher { default void publishEvent(ApplicationEvent event) { this.publishEvent((Object)event); } void publishEvent(Object var1); } ApplicationEventPublisher 接口的publishEvent()这个方法在AbstractApplicationContext类中被实现(这个类继承了ApplicationEventPublisher),阅读这个方法的实现,你会发现实际上事件真正是通过ApplicationEventMulticaster来广播出去的。具体内容过多,就不在这里分析了,后面可能会单独写一篇文章提到。\nSpring 的事件流程总结 # 定义一个事件: 实现一个继承自 ApplicationEvent,并且写相应的构造函数; 定义一个事件监听者:实现 ApplicationListener 接口,重写 onApplicationEvent() 方法; 使用事件发布者发布消息: 可以通过 ApplicationEventPublisher 的 publishEvent() 方法发布消息。 Example:\n// 定义一个事件,继承自ApplicationEvent并且写相应的构造函数 public class DemoEvent extends ApplicationEvent{ private static final long serialVersionUID = 1L; private String message; public DemoEvent(Object source,String message){ super(source); this.message = message; } public String getMessage() { return message; } } // 定义一个事件监听者,实现ApplicationListener接口,重写 onApplicationEvent() 方法; @Component public class DemoListener implements ApplicationListener\u0026lt;DemoEvent\u0026gt;{ //使用onApplicationEvent接收消息 @Override public void onApplicationEvent(DemoEvent event) { String msg = event.getMessage(); System.out.println(\u0026#34;接收到的信息是:\u0026#34;+msg); } } // 发布事件,可以通过ApplicationEventPublisher 的 publishEvent() 方法发布消息。 @Component public class DemoPublisher { @Autowired ApplicationContext applicationContext; public void publish(String message){ //发布事件 applicationContext.publishEvent(new DemoEvent(this, message)); } } 当调用 DemoPublisher 的 publish() 方法的时候,比如 demoPublisher.publish(\u0026quot;你好\u0026quot;) ,控制台就会打印出:接收到的信息是:你好 。\n适配器模式 # 适配器模式(Adapter Pattern) 将一个接口转换成客户希望的另一个接口,适配器模式使接口不兼容的那些类可以一起工作。\nSpring AOP 中的适配器模式 # 我们知道 Spring AOP 的实现是基于代理模式,但是 Spring AOP 的增强或通知(Advice)使用到了适配器模式,与之相关的接口是AdvisorAdapter 。\nAdvice 常用的类型有:BeforeAdvice(目标方法调用前,前置通知)、AfterAdvice(目标方法调用后,后置通知)、AfterReturningAdvice(目标方法执行结束后,return 之前)等等。每个类型 Advice(通知)都有对应的拦截器:MethodBeforeAdviceInterceptor、AfterReturningAdviceInterceptor、ThrowsAdviceInterceptor 等等。\nSpring 预定义的通知要通过对应的适配器,适配成 MethodInterceptor 接口(方法拦截器)类型的对象(如:MethodBeforeAdviceAdapter 通过调用 getInterceptor 方法,将 MethodBeforeAdvice 适配成 MethodBeforeAdviceInterceptor )。\nSpring MVC 中的适配器模式 # 在 Spring MVC 中,DispatcherServlet 根据请求信息调用 HandlerMapping,解析请求对应的 Handler。解析到对应的 Handler(也就是我们平常说的 Controller 控制器)后,开始由HandlerAdapter 适配器处理。HandlerAdapter 作为期望接口,具体的适配器实现类用于对目标类进行适配,Controller 作为需要适配的类。\n为什么要在 Spring MVC 中使用适配器模式?\nSpring MVC 中的 Controller 种类众多,不同类型的 Controller 通过不同的方法来对请求进行处理。如果不利用适配器模式的话,DispatcherServlet 直接获取对应类型的 Controller,需要的自行来判断,像下面这段代码一样:\nif(mappedHandler.getHandler() instanceof MultiActionController){ ((MultiActionController)mappedHandler.getHandler()).xxx }else if(mappedHandler.getHandler() instanceof XXX){ ... }else if(...){ ... } 假如我们再增加一个 Controller类型就要在上面代码中再加入一行 判断语句,这种形式就使得程序难以维护,也违反了设计模式中的开闭原则 – 对扩展开放,对修改关闭。\n装饰者模式 # 装饰者模式可以动态地给对象添加一些额外的属性或行为。相比于使用继承,装饰者模式更加灵活。简单点儿说就是当我们需要修改原有的功能,但我们又不愿直接去修改原有的代码时,设计一个 Decorator 套在原有代码外面。其实在 JDK 中就有很多地方用到了装饰者模式,比如 InputStream家族,InputStream 类下有 FileInputStream (读取文件)、BufferedInputStream (增加缓存,使读取文件速度大大提升)等子类都在不修改InputStream 代码的情况下扩展了它的功能。\nSpring 中配置 DataSource 的时候,DataSource 可能是不同的数据库和数据源。我们能否根据客户的需求在少修改原有类的代码下动态切换不同的数据源?这个时候就要用到装饰者模式(这一点我自己还没太理解具体原理)。Spring 中用到的包装器模式在类名上含有 Wrapper或者 Decorator。这些类基本上都是动态地给一个对象添加一些额外的职责\n总结 # Spring 框架中用到了哪些设计模式?\n工厂设计模式 : Spring 使用工厂模式通过 BeanFactory、ApplicationContext 创建 bean 对象。 代理设计模式 : Spring AOP 功能的实现。 单例设计模式 : Spring 中的 Bean 默认都是单例的。 模板方法模式 : Spring 中 jdbcTemplate、hibernateTemplate 等以 Template 结尾的对数据库操作的类,它们就使用到了模板模式。 包装器设计模式 : 我们的项目需要连接多个数据库,而且不同的客户在每次访问中根据需要会去访问不同的数据库。这种模式让我们可以根据客户的需求能够动态切换不同的数据源。 观察者模式: Spring 事件驱动模型就是观察者模式很经典的一个应用。 适配器模式 :Spring AOP 的增强或通知(Advice)使用到了适配器模式、spring MVC 中也是用到了适配器模式适配Controller。 \u0026hellip;\u0026hellip; 参考 # 《Spring 技术内幕》 https://blog.eduonix.com/java-programming-2/learn-design-patterns-used-spring-framework/ http://blog.yeamin.top/2018/03/27/单例模式-Spring单例实现原理分析/ https://www.tutorialsteacher.com/ioc/inversion-of-control https://design-patterns.readthedocs.io/zh_CN/latest/behavioral_patterns/observer.html https://juejin.im/post/5a8eb261f265da4e9e307230 https://juejin.im/post/5ba28986f265da0abc2b6084 "},{"id":289,"href":"/zh/docs/technology/Review/java_guide/lybly_framework/ly03ly_spring-transaction/","title":"Spring事务详情","section":"框架","content":" 转载自https://github.com/Snailclimb/JavaGuide(添加小部分笔记)感谢作者!\n前段时间答应读者的 Spring 事务 分析总结终于来了。这部分内容比较重要,不论是对于工作还是面试,但是网上比较好的参考资料比较少。\n什么是事务? # 事务是逻辑上的一组操作,要么都执行,要么都不执行。\n相信大家应该都能背上面这句话了,下面我结合我们日常的真实开发来谈一谈。\n我们系统的每个业务方法可能包括了多个原子性的数据库操作,比如下面的 savePerson() 方法中就有两个原子性的数据库操作。这些原子性的数据库操作是有依赖的,它们要么都执行,要不就都不执行。\npublic void savePerson() { personDao.save(person); personDetailDao.save(personDetail); } 另外,需要格外注意的是:事务能否生效数据库引擎是否支持事务是关键。比如常用的 MySQL 数据库默认使用支持事务的 innodb引擎。但是,如果把数据库引擎变为 myisam,那么程序也就不再支持事务了!\n事务最经典也经常被拿出来说例子就是转账了。假如小明要给小红转账 1000 元,这个转账会涉及到两个关键操作就是:\n将小明的余额减少 1000 元。 将小红的余额增加 1000 元。 万一在这两个操作之间突然出现错误比如银行系统崩溃或者网络故障,导致小明余额减少而小红的余额没有增加,这样就不对了。事务就是保证这两个关键操作要么都成功,要么都要失败。\npublic class OrdersService { private AccountDao accountDao; public void setOrdersDao(AccountDao accountDao) { this.accountDao = accountDao; } @Transactional(propagation = Propagation.REQUIRED, isolation = Isolation.DEFAULT, readOnly = false, timeout = -1) public void accountMoney() { //小红账户多1000 accountDao.addMoney(1000,xiaohong); //模拟突然出现的异常,比如银行中可能为突然停电等等 //如果没有配置事务管理的话会造成,小红账户多了1000而小明账户没有少钱 int i = 10 / 0; //小王账户少1000 accountDao.reduceMoney(1000,xiaoming); } } 另外,数据库事务的 ACID 四大特性是事务的基础,下面简单来了解一下。\n事务的特性(ACID)了解么? AID -\u0026gt; C # 原子性(Atomicity): 一个事务(transaction)中的所有操作,或者全部完成,或者全部不完成,不会结束在中间某个环节。事务在执行过程中发生错误,会被回滚(Rollback)到事务开始前的状态,就像这个事务从来没有执行过一样。即,事务不可分割、不可约简。 一致性(Consistency): 在事务开始之前和事务结束以后,数据库的完整性没有被破坏。这表示写入的资料必须完全符合所有的预设约束、触发器、级联回滚等。 隔离性(Isolation): 数据库允许多个并发事务同时对其数据进行读写和修改的能力,隔离性可以防止多个事务并发执行时由于交叉执行而导致数据的不一致。事务隔离分为不同级别,包括未提交读(Read uncommitted)、提交读(read committed)、可重复读(repeatable read)和串行化(Serializable)。 持久性(Durability): 事务处理结束后,对数据的修改就是永久的,即便系统故障也不会丢失。 参考 :https://zh.wikipedia.org/wiki/ACID 。\n详谈 Spring 对事务的支持 # ⚠️ 再提醒一次:你的程序是否支持事务首先取决于数据库 ,比如使用 MySQL 的话,如果你选择的是 innodb 引擎,那么恭喜你,是可以支持事务的。但是,如果你的 MySQL 数据库使用的是 myisam 引擎的话,那不好意思,从根上就是不支持事务的。\n这里再多提一下一个非常重要的知识点: MySQL 怎么保证原子性的?\n我们知道如果想要保证事务的原子性,就需要在异常发生时,对已经执行的操作进行回滚,在 MySQL 中,恢复机制是通过 回滚日志(undo log) 实现的,所有事务进行的修改都会先记录到这个回滚日志中,然后再执行相关的操作。如果执行过程中遇到异常的话,我们直接利用 回滚日志 中的信息将数据回滚到修改之前的样子即可!并且,回滚日志会先于数据持久化到磁盘上。这样就保证了即使遇到数据库突然宕机等情况,当用户再次启动数据库的时候,数据库还能够通过查询回滚日志来回滚之前未完成的事务。\nSpring 支持两种方式的事务管理 # 编程式事务管理 # 通过 **TransactionTemplate或者TransactionManager**手动管理事务,实际应用中很少使用,但是对于你理解 Spring 事务管理原理有帮助。\n使用TransactionTemplate 进行编程式事务管理的示例代码如下:\n@Autowired private TransactionTemplate transactionTemplate; public void testTransaction() { transactionTemplate.execute(new TransactionCallbackWithoutResult() { @Override protected void doInTransactionWithoutResult(TransactionStatus transactionStatus) { try { // .... 业务代码 } catch (Exception e){ //回滚 transactionStatus.setRollbackOnly(); } } }); } 使用 TransactionManager 进行编程式事务管理的示例代码如下:\n@Autowired private PlatformTransactionManager transactionManager; public void testTransaction() { TransactionStatus status = transactionManager.getTransaction(new DefaultTransactionDefinition()); try { // .... 业务代码 transactionManager.commit(status); } catch (Exception e) { transactionManager.rollback(status); } } 声明式事务管理 # 推荐使用(代码侵入性最小),实际是通过 AOP 实现(基于@Transactional 的全注解方式使用最多)。\n使用 @Transactional注解进行事务管理的示例代码如下:\n@Transactional(propagation = Propagation.REQUIRED) public void aMethod { //do something B b = new B(); C c = new C(); b.bMethod(); c.cMethod(); } Spring 事务管理接口介绍 # Spring 框架中,事务管理相关最重要的 3 个接口如下:\nPlatformTransactionManager: (平台)事务管理器,Spring 事务策略的核心。 TransactionDefinition: 事务定义信息(事务隔离级别、传播行为、超时、只读、回滚规则)。 TransactionStatus: 事务运行状态。 我们可以把 PlatformTransactionManager 接口可以被看作是事务上层的管理者,而 TransactionDefinition 和 TransactionStatus 这两个接口可以看作是事务的描述。\nPlatformTransactionManager 会根据 TransactionDefinition 的定义比如事务超时时间、隔离级别、传播行为等来进行事务管理 ,而 TransactionStatus 接口则提供了一些方法来获取事务相应的状态比如是否新事务、是否可以回滚等等。\nPlatformTransactionManager:事务管理接口 # Spring 并不直接管理事务,而是提供了多种事务管理器 。Spring 事务管理器的接口是: PlatformTransactionManager 。\n通过这个接口,Spring 为各个平台如:JDBC(DataSourceTransactionManager)、Hibernate(HibernateTransactionManager)、JPA(JpaTransactionManager)等都提供了对应的事务管理器,但是具体的实现就是各个平台自己的事情了。\nPlatformTransactionManager 接口的具体实现如下:\n![img](img/ly-20241212142010912.png\nPlatformTransactionManager接口中定义了三个方法:\npackage org.springframework.transaction; import org.springframework.lang.Nullable; public interface PlatformTransactionManager { //获得事务 TransactionStatus getTransaction(@Nullable TransactionDefinition var1) throws TransactionException; //提交事务 void commit(TransactionStatus var1) throws TransactionException; //回滚事务 void rollback(TransactionStatus var1) throws TransactionException; } 这里多插一嘴。为什么要定义或者说抽象出来PlatformTransactionManager这个接口呢?\n主要是因为要将事务管理行为抽象出来,然后不同的平台去实现它,这样我们可以保证提供给外部的行为(提供给程序员)不变,方便我们扩展。\n我前段时间在我的 知识星球分享过:“为什么我们要用接口?” 。\n《设计模式》(GOF 那本)这本书在很多年前都提到过说要基于接口而非实现编程,你真的知道为什么要基于接口编程么?\n纵观开源框架和项目的源码,接口是它们不可或缺的重要组成部分。要理解为什么要用接口,首先要搞懂接口提供了什么功能。我们可以把接口理解为提供了一系列功能列表的约定,接口本身不提供功能,它只定义行为。但是谁要用,就要先实现我,遵守我的约定,然后再自己去实现我定义的要实现的功能。\n举个例子,我上个项目有发送短信的需求,为此,我们定了一个接口,接口只有两个方法:\n1.发送短信 2.处理发送结果的方法。\n刚开始我们用的是阿里云短信服务,然后我们实现这个接口完成了一个阿里云短信的服务。后来,我们突然又换到了别的短信服务平台,我们这个时候只需要再实现这个接口即可。这样保证了我们提供给外部的行为不变。几乎不需要改变什么代码,我们就轻松完成了需求的转变,提高了代码的灵活性和可扩展性。\n什么时候用接口?当你要实现的功能模块设计抽象行为的时候,比如发送短信的服务,图床的存储服务等等。\nTransactionDefinition:事务属性 # 事务管理器接口 PlatformTransactionManager 通过 getTransaction(TransactionDefinition definition) 方法来得到一个事务,这个方法里面的参数是 TransactionDefinition 类 ,这个类就定义了一些基本的事务属性。\n什么是事务属性呢? 事务属性可以理解成事务的一些基本配置,描述了事务策略如何应用到方法上。\n事务属性包含了 5 个方面:\n隔离级别 传播行为 回滚规则 是否只读 事务超时 TransactionDefinition 接口中定义了 5 个方法以及一些表示事务属性的常量比如隔离级别、传播行为等等。\npackage org.springframework.transaction; import org.springframework.lang.Nullable; public interface TransactionDefinition { int PROPAGATION_REQUIRED = 0; int PROPAGATION_SUPPORTS = 1; int PROPAGATION_MANDATORY = 2; int PROPAGATION_REQUIRES_NEW = 3; int PROPAGATION_NOT_SUPPORTED = 4; int PROPAGATION_NEVER = 5; int PROPAGATION_NESTED = 6; int ISOLATION_DEFAULT = -1; int ISOLATION_READ_UNCOMMITTED = 1; int ISOLATION_READ_COMMITTED = 2; int ISOLATION_REPEATABLE_READ = 4; int ISOLATION_SERIALIZABLE = 8; int TIMEOUT_DEFAULT = -1; // 返回事务的传播行为,默认值为 REQUIRED。 int getPropagationBehavior(); //返回事务的隔离级别,默认值是 DEFAULT int getIsolationLevel(); // 返回事务的超时时间,默认值为-1。如果超过该时间限制但事务还没有完成,则自动回滚事务。 int getTimeout(); // 返回是否为只读事务,默认值为 false boolean isReadOnly(); @Nullable String getName(); } TransactionStatus:事务状态 # TransactionStatus接口用来记录事务的状态 该接口定义了一组方法,用来获取或判断 事务的相应状态信息。\nPlatformTransactionManager.getTransaction(…)方法返回一个 TransactionStatus 对象。\nTransactionStatus 接口内容如下:\npublic interface TransactionStatus{ boolean isNewTransaction(); // 是否是新的事务 boolean hasSavepoint(); // 是否有恢复点 void setRollbackOnly(); // 设置为只回滚 boolean isRollbackOnly(); // 是否为只回滚 boolean isCompleted; // 是否已完成 } 事务属性详解 # 实际业务开发中,大家一般都是使用 @Transactional 注解来开启事务,但很多人并不清楚这个注解里面的参数是什么意思,有什么用。为了更好的在项目中使用事务管理,强烈推荐好好阅读一下下面的内容。\n事务传播行为 # 事务传播行为是为了解决业务层方法之间互相调用的事务问题。\n当事务方法被另一个事务方法调用时,必须指定事务应该如何传播。例如:方法可能继续在现有事务中运行,也可能开启一个新事务,并在自己的事务中运行。\n举个例子:我们在 A 类的aMethod()方法中调用了 B 类的 bMethod() 方法。这个时候就涉及到业务层方法之间互相调用的事务问题。如果我们的 bMethod()如果发生异常需要回滚,如何配置事务传播行为才能让 aMethod()也跟着回滚呢?这个时候就需要事务传播行为的知识了,如果你不知道的话一定要好好看一下。\n@Service Class A { @Autowired B b; @Transactional(propagation = Propagation.xxx) public void aMethod { //do something b.bMethod(); } } @Service Class B { @Transactional(propagation = Propagation.xxx) public void bMethod { //do something } } 在TransactionDefinition定义中包括了如下几个表示传播行为的常量:\npublic interface TransactionDefinition { int PROPAGATION_REQUIRED = 0; int PROPAGATION_SUPPORTS = 1; int PROPAGATION_MANDATORY = 2; int PROPAGATION_REQUIRES_NEW = 3; int PROPAGATION_NOT_SUPPORTED = 4; int PROPAGATION_NEVER = 5; int PROPAGATION_NESTED = 6; ...... } 不过,为了方便使用,Spring 相应地定义了一个枚举类:Propagation\npackage org.springframework.transaction.annotation; import org.springframework.transaction.TransactionDefinition; public enum Propagation { REQUIRED(TransactionDefinition.PROPAGATION_REQUIRED), SUPPORTS(TransactionDefinition.PROPAGATION_SUPPORTS), MANDATORY(TransactionDefinition.PROPAGATION_MANDATORY), REQUIRES_NEW(TransactionDefinition.PROPAGATION_REQUIRES_NEW), NOT_SUPPORTED(TransactionDefinition.PROPAGATION_NOT_SUPPORTED), NEVER(TransactionDefinition.PROPAGATION_NEVER), NESTED(TransactionDefinition.PROPAGATION_NESTED); private final int value; Propagation(int value) { this.value = value; } public int value() { return this.value; } } 正确的事务传播行为可能的值如下 :\n1.TransactionDefinition.PROPAGATION_REQUIRED\n使用的最多的一个事务传播行为,我们平时经常使用的@Transactional注解默认使用就是这个事务传播行为。如果当前存在事务,则加入该事务;如果当前没有事务,则创建一个新的事务。也就是说:\n如果外部方法没有开启事务的话,Propagation.REQUIRED修饰的内部方法会新开启自己的事务,且开启的事务相互独立,互不干扰。 如果外部方法开启事务并且被Propagation.REQUIRED的话,所有Propagation.REQUIRED修饰的内部方法和外部方法均属于同一事务 ,只要一个方法回滚,整个事务均回滚。 举个例子:如果我们上面的aMethod()和bMethod()使用的都是PROPAGATION_REQUIRED传播行为的话,两者使用的就是同一个事务,只要其中一个方法回滚,整个事务均回滚。\n@Service Class A { @Autowired B b; @Transactional(propagation = Propagation.REQUIRED) public void aMethod { //do something b.bMethod(); } } @Service Class B { @Transactional(propagation = Propagation.REQUIRED) public void bMethod { //do something } } 2.TransactionDefinition.PROPAGATION_REQUIRES_NEW\n创建一个新的事务,如果当前存在事务,则把当前事务挂起。也就是说不管外部方法是否开启事务,Propagation.REQUIRES_NEW修饰的内部方法会新开启自己的事务,且开启的事务相互独立,互不干扰。\n举个例子:如果我们上面的bMethod()使用PROPAGATION_REQUIRES_NEW事务传播行为修饰,aMethod还是用PROPAGATION_REQUIRED修饰的话。如果aMethod()发生异常回滚,bMethod()不会跟着回滚,因为 bMethod()开启了独立的事务。但是,如果 bMethod()抛出了未被捕获的异常并且这个异常满足事务回滚规则的话,aMethod()同样也会回滚,因为这个异常被 aMethod()的事务管理机制检测到了。\n@Service Class A { @Autowired B b; @Transactional(propagation = Propagation.REQUIRED) public void aMethod { //do something b.bMethod(); } } @Service Class B { @Transactional(propagation = Propagation.REQUIRES_NEW) public void bMethod { //do something } } 3.TransactionDefinition.PROPAGATION_NESTED:\n如果当前存在事务,就在嵌套事务内执行;如果当前没有事务,就执行与TransactionDefinition.PROPAGATION_REQUIRED类似的操作。也就是说:\n在外部方法开启事务的情况下,在内部开启一个新的事务,作为嵌套事务存在。 如果外部方法无事务,则单独开启一个事务,与 PROPAGATION_REQUIRED 类似。 这里还是简单举个例子:如果 bMethod() 回滚的话,aMethod()也会回滚。\n如果aMethod()回滚的话,bMethod()也会回滚\n@Service Class A { @Autowired B b; @Transactional(propagation = Propagation.REQUIRED) public void aMethod { //do something b.bMethod(); } } @Service Class B { @Transactional(propagation = Propagation.NESTED) public void bMethod { //do something } } 4.TransactionDefinition.PROPAGATION_MANDATORY\n如果当前存在事务,则加入该事务;如果当前没有事务,则抛出异常。(mandatory:强制性)\n这个使用的很少,就不举例子来说了。\n若是错误的配置以下 3 种事务传播行为,事务将不会发生回滚,这里不对照案例讲解了,使用的很少。\nTransactionDefinition.PROPAGATION_SUPPORTS: 如果当前存在事务,则加入该事务;如果当前没有事务,则以非事务的方式继续运行。 TransactionDefinition.PROPAGATION_NOT_SUPPORTED: 以非事务方式运行,如果当前存在事务,则把当前事务挂起。 TransactionDefinition.PROPAGATION_NEVER: 以非事务方式运行,如果当前存在事务,则抛出异常。 更多关于事务传播行为的内容请看这篇文章: 《太难了~面试官让我结合案例讲讲自己对 Spring 事务传播行为的理解。》\n事务隔离级别 # TransactionDefinition 接口中定义了五个表示隔离级别的常量:\npublic interface TransactionDefinition { ...... int ISOLATION_DEFAULT = -1; int ISOLATION_READ_UNCOMMITTED = 1; int ISOLATION_READ_COMMITTED = 2; int ISOLATION_REPEATABLE_READ = 4; int ISOLATION_SERIALIZABLE = 8; ...... } 和事务传播行为那块一样,为了方便使用,Spring 也相应地定义了一个枚举类:Isolation\npublic enum Isolation { DEFAULT(TransactionDefinition.ISOLATION_DEFAULT), READ_UNCOMMITTED(TransactionDefinition.ISOLATION_READ_UNCOMMITTED), READ_COMMITTED(TransactionDefinition.ISOLATION_READ_COMMITTED), REPEATABLE_READ(TransactionDefinition.ISOLATION_REPEATABLE_READ), SERIALIZABLE(TransactionDefinition.ISOLATION_SERIALIZABLE); private final int value; Isolation(int value) { this.value = value; } public int value() { return this.value; } } 下面我依次对每一种事务隔离级别进行介绍:\nTransactionDefinition.ISOLATION_DEFAULT :使用后端数据库默认的隔离级别,MySQL 默认采用的 REPEATABLE_READ 隔离级别 Oracle 默认采用的 READ_COMMITTED 隔离级别. TransactionDefinition.ISOLATION_READ_UNCOMMITTED :最低的隔离级别,使用这个隔离级别很少,因为它允许读取尚未提交的数据变更,可能会导致脏读、幻读或不可重复读 TransactionDefinition.ISOLATION_READ_COMMITTED : 允许读取并发事务已经提交的数据,可以阻止脏读,但是幻读或不可重复读仍有可能发生 TransactionDefinition.ISOLATION_REPEATABLE_READ : 对同一字段的多次读取结果都是一致的,除非数据是被本身事务自己所修改,可以阻止脏读和不可重复读,但幻读仍有可能发生。 TransactionDefinition.ISOLATION_SERIALIZABLE : 最高的隔离级别,完全服从 ACID 的隔离级别。所有的事务依次逐个执行,这样事务之间就完全不可能产生干扰,也就是说,该级别可以防止脏读、不可重复读以及幻读。但是这将严重影响程序的性能。通常情况下也不会用到该级别。 相关阅读: MySQL事务隔离级别详解。\n事务超时属性 # 所谓事务超时,就是指一个事务所允许执行的最长时间,如果超过该时间限制但事务还没有完成,则自动回滚事务。在 TransactionDefinition 中以 int 的值来表示超时时间,其单位是秒,默认值为-1,这表示事务的超时时间取决于底层事务系统或者没有超时时间。\n事务只读属性 # package org.springframework.transaction; import org.springframework.lang.Nullable; public interface TransactionDefinition { ...... // 返回是否为只读事务,默认值为 false boolean isReadOnly(); } 对于只有读取数据查询的事务,可以指定事务类型为 readonly,即只读事务。只读事务不涉及数据的修改,数据库会提供一些优化手段,适合用在有多条数据库查询操作的方法中。\n很多人就会疑问了,为什么我一个数据查询操作还要启用事务支持呢?\n拿 MySQL 的 innodb 举例子,根据官网 https://dev.mysql.com/doc/refman/5.7/en/innodb-autocommit-commit-rollback.html 描述:\nMySQL 默认对每一个新建立的连接都启用了autocommit模式。在该模式下,每一个发送到 MySQL 服务器的sql语句都会在一个单独的事务中进行处理,执行结束后会自动提交事务,并开启一个新的事务(才会开启一个新的事务)。\n但是,如果你给方法加上了Transactional注解的话,这个方法执行的所有sql会被放在一个事务中。如果声明了只读事务的话,数据库就会去优化它的执行,并不会带来其他的什么收益。\n如果不加Transactional,每条sql会开启一个单独的事务,中间被其它事务改了数据,都会实时读取到最新值。\n分享一下关于事务只读属性,其他人的解答:\n如果你一次执行单条查询语句,则没有必要启用事务支持,数据库默认支持 SQL 执行期间的读一致性; 如果你一次执行多条查询语句,例如统计查询,报表查询,在这种场景下,多条查询 SQL 必须保证整体的读一致性,否则,在前条 SQL 查询之后,后条 SQL 查询之前,数据被其他用户改变,则该次整体的统计查询将会出现读数据不一致的状态,此时,应该启用事务支持(避免多次查询结果不一致) 事务回滚规则 # 这些规则定义了哪些异常会导致事务回滚而哪些不会。默认情况下,事务只有遇到运行期异常(RuntimeException 的子类)时才会回滚,Error 也会导致事务回滚,但是,在遇到检查型(Checked)异常时不会回滚。[ **这里说的是默认情况下 **]\n![img](img/ly-20241212142011181.png\n如果你想要回滚你定义的特定的异常类型的话,可以这样:\n@Transactional(rollbackFor= MyException.class) @Transactional 注解使用详解 # @Transactional 的作用范围 # 方法 :推荐将注解使用于方法上,不过需要注意的是:该注解只能应用到 public 方法上,否则不生效。 类 :如果这个注解使用在类上的话,表明该注解对该类中所有的 public 方法都生效。 接口 :不推荐在接口上使用。 @Transactional 的常用配置参数 # @Transactional注解源码如下,里面包含了基本事务属性的配置:\n@Target({ElementType.TYPE, ElementType.METHOD}) @Retention(RetentionPolicy.RUNTIME) @Inherited @Documented public @interface Transactional { @AliasFor(\u0026#34;transactionManager\u0026#34;) String value() default \u0026#34;\u0026#34;; @AliasFor(\u0026#34;value\u0026#34;) String transactionManager() default \u0026#34;\u0026#34;; Propagation propagation() default Propagation.REQUIRED; Isolation isolation() default Isolation.DEFAULT; int timeout() default TransactionDefinition.TIMEOUT_DEFAULT; boolean readOnly() default false; Class\u0026lt;? extends Throwable\u0026gt;[] rollbackFor() default {}; String[] rollbackForClassName() default {}; Class\u0026lt;? extends Throwable\u0026gt;[] noRollbackFor() default {}; String[] noRollbackForClassName() default {}; } @Transactional 的常用配置参数总结(只列出了 5 个我平时比较常用的):\n属性名 说明 propagation 事务的传播行为,默认值为 REQUIRED,可选的值在上面介绍过 isolation 事务的隔离级别,默认值采用 DEFAULT,可选的值在上面介绍过 timeout 事务的超时时间,默认值为-1(不会超时)。如果超过该时间限制但事务还没有完成,则自动回滚事务。 readOnly 指定事务是否为只读事务,默认值为 false。 rollbackFor 用于指定能够触发事务回滚的异常类型,并且可以指定多个异常类型。 @Transactional 事务注解原理 # 面试中在问 AOP 的时候可能会被问到的一个问题。简单说下吧!\n我们知道,@Transactional 的工作机制是基于 AOP 实现的,AOP 又是使用动态代理实现的。如果目标对象实现了接口,默认情况下会采用 JDK 的动态代理,如果目标对象没有实现了接口,会使用 CGLIB 动态代理。\n多提一嘴:createAopProxy() 方法 决定了是使用 JDK 还是 Cglib 来做动态代理,源码如下:\npublic class DefaultAopProxyFactory implements AopProxyFactory, Serializable { @Override public AopProxy createAopProxy(AdvisedSupport config) throws AopConfigException { if (config.isOptimize() || config.isProxyTargetClass() || hasNoUserSuppliedProxyInterfaces(config)) { Class\u0026lt;?\u0026gt; targetClass = config.getTargetClass(); if (targetClass == null) { throw new AopConfigException(\u0026#34;TargetSource cannot determine target class: \u0026#34; + \u0026#34;Either an interface or a target is required for proxy creation.\u0026#34;); } if (targetClass.isInterface() || Proxy.isProxyClass(targetClass)) { return new JdkDynamicAopProxy(config); } return new ObjenesisCglibAopProxy(config); } else { return new JdkDynamicAopProxy(config); } } ....... } 如果一个类或者一个类中的 public 方法上被标注@Transactional 注解的话,Spring 容器就会在启动的时候为其创建一个代理类,在调用被@Transactional 注解的 public 方法的时候,实际调用的是,TransactionInterceptor 类中的 invoke()方法。这个方法的作用就是在目标方法之前开启事务,方法执行过程中如果遇到异常的时候回滚事务,方法调用完成之后提交事务。\nTransactionInterceptor 类中的 invoke()方法内部实际调用的是 TransactionAspectSupport 类的 invokeWithinTransaction()方法。由于新版本的 Spring 对这部分重写很大,而且用到了很多响应式编程的知识,这里就不列源码了。\nSpring AOP 自调用问题 # 若同一类中的其他没有 @Transactional 注解的方法内部调用有 @Transactional 注解的方法,有@Transactional 注解的方法的事务会失效。\n这是由于Spring AOP代理的原因造成的,因为只有当 @Transactional 注解的方法在类以外被调用的时候,Spring 事务管理才生效。\n因为这里使用了this,而内部调用(使用this)第二个方法其实是使用了原始类,而非代理类\nMyService 类中的method1()调用method2()就会导致method2()的事务失效。\n@Service public class MyService { private void method1() { method2(); //...... } @Transactional public void method2() { //...... } } 解决办法就是避免同一类中自调用或者使用 AspectJ 取代 Spring AOP 代理。\n@Transactional 的使用注意事项总结 # @Transactional 注解只有作用到 public 方法上事务才生效,不推荐在接口上使用; 避免同一个类中调用 @Transactional 注解的方法,这样会导致事务失效; 正确的设置 @Transactional 的 rollbackFor 和 propagation 属性,否则事务可能会回滚失败; 被 @Transactional 注解的方法所在的类必须被 Spring 管理,否则不生效; 底层使用的数据库必须支持事务机制,否则不生效; \u0026hellip;\u0026hellip; 参考 # [总结]Spring 事务管理中@Transactional 的参数: http://www.mobabel.net/spring 事务管理中 transactional 的参数/ Spring 官方文档:https://docs.spring.io/spring/docs/4.2.x/spring-framework-reference/html/transaction.html 《Spring5 高级编程》 透彻的掌握 Spring 中@transactional 的使用: https://www.ibm.com/developerworks/cn/java/j-master-spring-transactional-use/index.html Spring 事务的传播特性: https://github.com/love-somnus/Spring/wiki/Spring 事务的传播特性 Spring 事务传播行为详解 :https://segmentfault.com/a/1190000013341344 全面分析 Spring 的编程式事务管理及声明式事务管理:https://www.ibm.com/developerworks/cn/education/opensource/os-cn-spring-trans/index.html "},{"id":290,"href":"/zh/docs/technology/Review/java_guide/lybly_framework/ly02ly_spring-annotations/","title":"Spring/SpringBoot常用注解","section":"框架","content":" 转载自https://github.com/Snailclimb/JavaGuide(添加小部分笔记)感谢作者!\n0.前言 # 可以毫不夸张地说,这篇文章介绍的 Spring/SpringBoot 常用注解基本已经涵盖你工作中遇到的大部分常用的场景。对于每一个注解我都说了具体用法,掌握搞懂,使用 SpringBoot 来开发项目基本没啥大问题了!\n为什么要写这篇文章?\n最近看到网上有一篇关于 SpringBoot 常用注解的文章被转载的比较多,我看了文章内容之后属实觉得质量有点低,并且有点会误导没有太多实际使用经验的人(这些人又占据了大多数)。所以,自己索性花了大概 两天时间简单总结一下了。\n因为我个人的能力和精力有限,如果有任何不对或者需要完善的地方,请帮忙指出!Guide 哥感激不尽!\n1. @SpringBootApplication # 这里先单独拎出@SpringBootApplication 注解说一下,虽然我们一般不会主动去使用它。\nGuide 哥:这个注解是 Spring Boot 项目的基石,创建 SpringBoot 项目之后会默认在主类加上。\n@SpringBootApplication public class SpringSecurityJwtGuideApplication { public static void main(java.lang.String[] args) { SpringApplication.run(SpringSecurityJwtGuideApplication.class, args); } } 我们可以把 @SpringBootApplication看作是 @Configuration、@EnableAutoConfiguration、@ComponentScan 注解的集合。\npackage org.springframework.boot.autoconfigure; @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 { ...... } package org.springframework.boot; @Target(ElementType.TYPE) @Retention(RetentionPolicy.RUNTIME) @Documented @Configuration public @interface SpringBootConfiguration { } 根据 SpringBoot 官网,这三个注解的作用分别是:\n@EnableAutoConfiguration:启用 SpringBoot 的自动配置机制 (这个是必须的,另外两个可以不要) @ComponentScan: 扫描被@Component (@Repository,@Service,@Controller)注解的 bean,注解默认会扫描该类所在的包下所有的类。 @Configuration:允许在 Spring 上下文中注册额外的 bean 或导入其他配置类 2. Spring Bean 相关 # 2.1. @Autowired # 自动导入对象到类中,被注入进的类同样要被 Spring 容器管理比如:Service 类注入到 Controller 类中。\n@Service public class UserService { ...... } @RestController @RequestMapping(\u0026#34;/users\u0026#34;) public class UserController { @Autowired private UserService userService; ...... } 2.2. @Component,@Repository,@Service, @Controller # 我们一般使用 @Autowired 注解让 Spring 容器帮我们自动装配 bean。要想把类标识成可用于 @Autowired 注解自动装配的 bean 的类,可以采用以下注解实现:\n@Component :通用的注解,可标注任意类为 Spring 组件。如果一个 Bean 不知道属于哪个层,可以使用@Component 注解标注。 @Repository : 对应持久层即 Dao 层,主要用于数据库相关操作。 @Service : 对应服务层,主要涉及一些复杂的逻辑,需要用到 Dao 层。 @Controller : 对应 Spring MVC 控制层,主要用于接受用户请求并调用 Service 层返回数据给前端页面。 2.3. @RestController # @RestController注解是@Controller和@ResponseBody的合集,表示这是个控制器 bean,并且是将函数的返回值直接填入 HTTP 响应体中,是 REST 风格的控制器。\nGuide 哥:现在都是前后端分离,说实话我已经很久没有用过@Controller。如果你的项目太老了的话,就当我没说。\n单独使用 @Controller 不加 @ResponseBody的话一般是用在要返回一个视图的情况,这种情况属于比较传统的 Spring MVC 的应用,对应于前后端不分离的情况。@Controller +@ResponseBody 返回 JSON 或 XML 形式数据\n关于@RestController 和 @Controller的对比,请看这篇文章: @RestController vs @Controller。\n2.4. @Scope # 声明 Spring Bean 的作用域,使用方法:\n@Bean @Scope(\u0026#34;singleton\u0026#34;) public Person personSingleton() { return new Person(); } 四种常见的 Spring Bean 的作用域:\nsingleton : 唯一 bean 实例,Spring 中的 bean 默认都是单例的。 prototype : 每次请求都会创建一个新的 bean 实例。 request : 每一次 HTTP 请求都会产生一个新的 bean,该 bean 仅在当前 HTTP request 内有效。 session : 每一个 HTTP Session 会产生一个新的 bean,该 bean 仅在当前 HTTP session 内有效。 2.5. @Configuration # 一般用来声明配置类,可以使用 @Component注解替代,不过使用@Configuration注解声明配置类更加语义化。(还有就是配置第三方库)\n@Configuration public class AppConfig { @Bean public TransferService transferService() { return new TransferServiceImpl(); } } 3. 处理常见的 HTTP 请求类型 # 5 种常见的请求类型:\nGET :请求从服务器获取特定资源。举个例子:GET /users(获取所有学生) POST :在服务器上创建一个新的资源。举个例子:POST /users(创建学生) PUT :更新服务器上的资源(客户端提供更新后的整个资源)。举个例子:PUT /users/12(更新编号为 12 的学生) DELETE :从服务器删除特定的资源。举个例子:DELETE /users/12(删除编号为 12 的学生) PATCH :更新服务器上的资源(客户端提供更改的属性,可以看做作是部分更新),使用的比较少,这里就不举例子了。 3.1. GET 请求 # @GetMapping(\u0026#34;users\u0026#34;)` 等价于`@RequestMapping(value=\u0026#34;/users\u0026#34;,method=RequestMethod.GET) @GetMapping(\u0026#34;/users\u0026#34;) public ResponseEntity\u0026lt;List\u0026lt;User\u0026gt;\u0026gt; getAllUsers() { return userRepository.findAll(); } 3.2. POST 请求 # @PostMapping(\u0026#34;users\u0026#34;)` 等价于`@RequestMapping(value=\u0026#34;/users\u0026#34;,method=RequestMethod.POST) 关于@RequestBody注解的使用,在下面的“前后端传值”这块会讲到。\n@PostMapping(\u0026#34;/users\u0026#34;) public ResponseEntity\u0026lt;User\u0026gt; createUser(@Valid @RequestBody UserCreateRequest userCreateRequest) { return userRespository.save(userCreateRequest); } 3.3. PUT 请求 # @PutMapping(\u0026#34;/users/{userId}\u0026#34;)` 等价于`@RequestMapping(value=\u0026#34;/users/{userId}\u0026#34;,method=RequestMethod.PUT) @PutMapping(\u0026#34;/users/{userId}\u0026#34;) public ResponseEntity\u0026lt;User\u0026gt; updateUser(@PathVariable(value = \u0026#34;userId\u0026#34;) Long userId, @Valid @RequestBody UserUpdateRequest userUpdateRequest) { ...... } 3.4. DELETE 请求 # @DeleteMapping(\u0026#34;/users/{userId}\u0026#34;)`等价于`@RequestMapping(value=\u0026#34;/users/{userId}\u0026#34;,method=RequestMethod.DELETE) @DeleteMapping(\u0026#34;/users/{userId}\u0026#34;) public ResponseEntity deleteUser(@PathVariable(value = \u0026#34;userId\u0026#34;) Long userId){ ...... } 3.5. PATCH 请求 # 一般实际项目中,我们都是 PUT 不够用了之后才用 PATCH 请求去更新数据。\n@PatchMapping(\u0026#34;/profile\u0026#34;) public ResponseEntity updateStudent(@RequestBody StudentUpdateRequest studentUpdateRequest) { studentRepository.updateDetail(studentUpdateRequest); return ResponseEntity.ok().build(); } 4. 前后端传值 # 掌握前后端传值的正确姿势,是你开始 CRUD 的第一步!\n4.1. @PathVariable 和 @RequestParam # @PathVariable用于获取路径参数,@RequestParam用于获取查询参数。\n举个简单的例子:\n@GetMapping(\u0026#34;/klasses/{klassId}/teachers\u0026#34;) public List\u0026lt;Teacher\u0026gt; getKlassRelatedTeachers( @PathVariable(\u0026#34;klassId\u0026#34;) Long klassId, @RequestParam(value = \u0026#34;type\u0026#34;, required = false) String type ) { ... } 如果我们请求的 url 是:/klasses/123456/teachers?type=web\n那么我们服务获取到的数据就是:klassId=123456,type=web。\n4.2. @RequestBody # 用于读取 Request 请求(可能是 POST,PUT,DELETE,GET 请求)的 body 部分并且Content-Type 为 application/json 格式的数据,接收到数据之后会自动将数据绑定到 Java 对象上去。系统会使用HttpMessageConverter或者自定义的HttpMessageConverter将请求的 body 中的 json 字符串转换为 java 对象。\n我用一个简单的例子来给演示一下基本使用!\n我们有一个注册的接口:\n@PostMapping(\u0026#34;/sign-up\u0026#34;) public ResponseEntity signUp(@RequestBody @Valid UserRegisterRequest userRegisterRequest) { userService.save(userRegisterRequest); return ResponseEntity.ok().build(); } UserRegisterRequest对象:\n@Data @AllArgsConstructor @NoArgsConstructor public class UserRegisterRequest { @NotBlank private String userName; @NotBlank private String password; @NotBlank private String fullName; } 我们发送 post 请求到这个接口,并且 body 携带 JSON 数据:\n{\u0026#34;userName\u0026#34;:\u0026#34;coder\u0026#34;,\u0026#34;fullName\u0026#34;:\u0026#34;shuangkou\u0026#34;,\u0026#34;password\u0026#34;:\u0026#34;123456\u0026#34;} 这样我们的后端就可以直接把 json 格式的数据映射到我们的 UserRegisterRequest 类上。\n👉 需要注意的是:一个请求方法只可以有一个@RequestBody,但是可以有多个@RequestParam和@PathVariable。 如果你的方法必须要用两个 @RequestBody来接受数据的话,大概率是你的数据库设计或者系统设计出问题了!\n5. 读取配置信息 # 很多时候我们需要将一些常用的配置信息比如阿里云 oss、发送短信、微信认证的相关配置信息等等放到配置文件中。\n下面我们来看一下 Spring 为我们提供了哪些方式帮助我们从配置文件中读取这些配置信息。\n我们的数据源application.yml内容如下:\nwuhan2020: 2020年初武汉爆发了新型冠状病毒,疫情严重,但是,我相信一切都会过去!武汉加油!中国加油! my-profile: name: Guide哥 email: koushuangbwcx@163.com library: location: 湖北武汉加油中国加油 books: - name: 天才基本法 description: 二十二岁的林朝夕在父亲确诊阿尔茨海默病这天,得知自己暗恋多年的校园男神裴之即将出国深造的消息——对方考取的学校,恰是父亲当年为她放弃的那所。 - name: 时间的秩序 description: 为什么我们记得过去,而非未来?时间“流逝”意味着什么?是我们存在于时间之内,还是时间存在于我们之中?卡洛·罗韦利用诗意的文字,邀请我们思考这一亘古难题——时间的本质。 - name: 了不起的我 description: 如何养成一个新习惯?如何让心智变得更成熟?如何拥有高质量的关系? 如何走出人生的艰难时刻? 5.1. @Value(常用) # 使用 @Value(\u0026quot;${property}\u0026quot;) 读取比较简单的配置信息:\n@Value(\u0026#34;${wuhan2020}\u0026#34;) String wuhan2020; 5.2. @ConfigurationProperties(常用) # 通过@ConfigurationProperties读取配置信息并与 bean 绑定。\n@Component @ConfigurationProperties(prefix = \u0026#34;library\u0026#34;) class LibraryProperties { @NotEmpty private String location; private List\u0026lt;Book\u0026gt; books; @Setter @Getter @ToString static class Book { String name; String description; } 省略getter/setter ...... } 你可以像使用普通的 Spring bean 一样,将其注入到类中使用。\n5.3. @PropertySource(不常用) # @PropertySource读取指定 properties 文件\n@Component @PropertySource(\u0026#34;classpath:website.properties\u0026#34;) class WebSite { @Value(\u0026#34;${url}\u0026#34;) private String url; 省略getter/setter ...... } 更多内容请查看我的这篇文章: 《10 分钟搞定 SpringBoot 如何优雅读取配置文件?》 。\n6. 参数校验 # 数据的校验的重要性就不用说了,即使在前端对数据进行校验的情况下,我们还是要对传入后端的数据再进行一遍校验,避免用户绕过浏览器直接通过一些 HTTP 工具直接向后端请求一些违法数据。\nJSR(Java Specification Requests) 是一套 JavaBean 参数校验的标准,它定义了很多常用的校验注解,我们可以直接将这些注解加在我们 JavaBean 的属性上面,这样就可以在需要校验的时候进行校验了,非常方便!\n校验的时候我们实际用的是 Hibernate Validator 框架。Hibernate Validator 是 Hibernate 团队最初的数据校验框架,Hibernate Validator 4.x 是 Bean Validation 1.0(JSR 303)的参考实现,Hibernate Validator 5.x 是 Bean Validation 1.1(JSR 349)的参考实现,目前最新版的 Hibernate Validator 6.x 是 Bean Validation 2.0(JSR 380)的参考实现。\nSpringBoot 项目的 spring-boot-starter-web 依赖中已经有 hibernate-validator 包,不需要引用相关依赖。如下图所示(通过 idea 插件—Maven Helper 生成):\n注:更新版本的 spring-boot-starter-web 依赖中不再有 hibernate-validator 包(如2.3.11.RELEASE),需要自己引入 spring-boot-starter-validation 依赖。\n非 SpringBoot 项目需要自行引入相关依赖包,这里不多做讲解,具体可以查看我的这篇文章:《 如何在 Spring/Spring Boot 中做参数校验?你需要了解的都在这里!》。\n👉 需要注意的是: 所有的注解,推荐使用 JSR 注解,即javax.validation.constraints,而不是org.hibernate.validator.constraints\n6.1. 一些常用的字段验证的注解 # @NotEmpty 被注释的字符串的不能为 null 也不能为空 @NotBlank 被注释的字符串非 null,并且必须包含一个非空白字符 @Null 被注释的元素必须为 null @NotNull 被注释的元素必须不为 null @AssertTrue 被注释的元素必须为 true @AssertFalse 被注释的元素必须为 false @Pattern(regex=,flag=)被注释的元素必须符合指定的正则表达式 @Email 被注释的元素必须是 Email 格式。 @Min(value)被注释的元素必须是一个数字,其值必须大于等于指定的最小值 @Max(value)被注释的元素必须是一个数字,其值必须小于等于指定的最大值 @DecimalMin(value)被注释的元素必须是一个数字,其值必须大于等于指定的最小值 @DecimalMax(value) 被注释的元素必须是一个数字,其值必须小于等于指定的最大值 @Size(max=, min=)被注释的元素的大小必须在指定的范围内 @Digits(integer, fraction)被注释的元素必须是一个数字,其值必须在可接受的范围内 @Past被注释的元素必须是一个过去的日期 @Future 被注释的元素必须是一个将来的日期 \u0026hellip;\u0026hellip; 6.2. 验证请求体(RequestBody) # @Data @AllArgsConstructor @NoArgsConstructor public class Person { @NotNull(message = \u0026#34;classId 不能为空\u0026#34;) private String classId; @Size(max = 33) @NotNull(message = \u0026#34;name 不能为空\u0026#34;) private String name; @Pattern(regexp = \u0026#34;((^Man$|^Woman$|^UGM$))\u0026#34;, message = \u0026#34;sex 值不在可选范围\u0026#34;) @NotNull(message = \u0026#34;sex 不能为空\u0026#34;) private String sex; @Email(message = \u0026#34;email 格式不正确\u0026#34;) @NotNull(message = \u0026#34;email 不能为空\u0026#34;) private String email; } 我们在需要验证的参数上加上了@Valid注解,如果验证失败,它将抛出MethodArgumentNotValidException。\n@RestController @RequestMapping(\u0026#34;/api\u0026#34;) public class PersonController { @PostMapping(\u0026#34;/person\u0026#34;) public ResponseEntity\u0026lt;Person\u0026gt; getPerson(@RequestBody @Valid Person person) { return ResponseEntity.ok().body(person); } } 6.3. 验证请求参数(Path Variables 和 Request Parameters) # 一定一定不要忘记在类上加上 @Validated 注解了,这个参数可以告诉 Spring 去校验方法参数。\n@RestController @RequestMapping(\u0026#34;/api\u0026#34;) @Validated public class PersonController { @GetMapping(\u0026#34;/person/{id}\u0026#34;) public ResponseEntity\u0026lt;Integer\u0026gt; getPersonByID(@Valid @PathVariable(\u0026#34;id\u0026#34;) @Max(value = 5,message = \u0026#34;超过 id 的范围了\u0026#34;) Integer id) { return ResponseEntity.ok().body(id); } } 更多关于如何在 Spring 项目中进行参数校验的内容,请看《 如何在 Spring/Spring Boot 中做参数校验?你需要了解的都在这里!》这篇文章。\n7. 全局处理 Controller 层异常 # 介绍一下我们 Spring 项目必备的全局处理 Controller 层异常。\n相关注解:\n@ControllerAdvice :注解定义全局异常处理类 @ExceptionHandler :注解声明异常处理方法 如何使用呢?拿我们在第 5 节参数校验这块来举例子。如果方法参数不对的话就会抛出MethodArgumentNotValidException,我们来处理这个异常。\n@ControllerAdvice @ResponseBody public class GlobalExceptionHandler { /** * 请求参数异常处理 */ @ExceptionHandler(MethodArgumentNotValidException.class) public ResponseEntity\u0026lt;?\u0026gt; handleMethodArgumentNotValidException(MethodArgumentNotValidException ex, HttpServletRequest request) { ...... } } 更多关于 Spring Boot 异常处理的内容,请看我的这两篇文章:\nSpringBoot 处理异常的几种常见姿势 使用枚举简单封装一个优雅的 Spring Boot 全局异常处理! 8. JPA 相关 # 8.1. 创建表 # @Entity声明一个类对应一个数据库实体。\n@Table 设置表名\n@Entity @Table(name = \u0026#34;role\u0026#34;) public class Role { @Id @GeneratedValue(strategy = GenerationType.IDENTITY) private Long id; private String name; private String description; 省略getter/setter...... } 8.2. 创建主键 # @Id :声明一个字段为主键。\n使用@Id声明之后,我们还需要定义主键的生成策略。我们可以使用 @GeneratedValue 指定主键生成策略。\n1.通过 @GeneratedValue直接使用 JPA 内置提供的四种主键生成策略来指定主键生成策略。\n@Id @GeneratedValue(strategy = GenerationType.IDENTITY) private Long id; JPA 使用枚举定义了 4 种常见的主键生成策略,如下:\nGuide 哥:枚举替代常量的一种用法\npublic enum GenerationType { /** * 使用一个特定的数据库表格来保存主键 * 持久化引擎通过关系数据库的一张特定的表格来生成主键, */ TABLE, /** *在某些数据库中,不支持主键自增长,比如Oracle、PostgreSQL其提供了一种叫做\u0026#34;序列(sequence)\u0026#34;的机制生成主键 */ SEQUENCE, /** * 主键自增长 */ IDENTITY, /** *把主键生成策略交给持久化引擎(persistence engine), *持久化引擎会根据数据库在以上三种主键生成 策略中选择其中一种 */ AUTO } @GeneratedValue`注解默认使用的策略是`GenerationType.AUTO public @interface GeneratedValue { GenerationType strategy() default AUTO; String generator() default \u0026#34;\u0026#34;; } 一般使用 MySQL 数据库的话,使用GenerationType.IDENTITY策略比较普遍一点(分布式系统的话需要另外考虑使用分布式 ID)。\n2.通过 @GenericGenerator声明一个主键策略,然后 @GeneratedValue使用这个策略\n@Id @GeneratedValue(generator = \u0026#34;IdentityIdGenerator\u0026#34;) @GenericGenerator(name = \u0026#34;IdentityIdGenerator\u0026#34;, strategy = \u0026#34;identity\u0026#34;) private Long id; 等价于:\n@Id @GeneratedValue(strategy = GenerationType.IDENTITY) private Long id; jpa 提供的主键生成策略有如下几种:\npublic class DefaultIdentifierGeneratorFactory implements MutableIdentifierGeneratorFactory, Serializable, ServiceRegistryAwareService { @SuppressWarnings(\u0026#34;deprecation\u0026#34;) public DefaultIdentifierGeneratorFactory() { register( \u0026#34;uuid2\u0026#34;, UUIDGenerator.class ); register( \u0026#34;guid\u0026#34;, GUIDGenerator.class );\t// can be done with UUIDGenerator + strategy register( \u0026#34;uuid\u0026#34;, UUIDHexGenerator.class );\t// \u0026#34;deprecated\u0026#34; for new use register( \u0026#34;uuid.hex\u0026#34;, UUIDHexGenerator.class ); // uuid.hex is deprecated register( \u0026#34;assigned\u0026#34;, Assigned.class ); register( \u0026#34;identity\u0026#34;, IdentityGenerator.class ); register( \u0026#34;select\u0026#34;, SelectGenerator.class ); register( \u0026#34;sequence\u0026#34;, SequenceStyleGenerator.class ); register( \u0026#34;seqhilo\u0026#34;, SequenceHiLoGenerator.class ); register( \u0026#34;increment\u0026#34;, IncrementGenerator.class ); register( \u0026#34;foreign\u0026#34;, ForeignGenerator.class ); register( \u0026#34;sequence-identity\u0026#34;, SequenceIdentityGenerator.class ); register( \u0026#34;enhanced-sequence\u0026#34;, SequenceStyleGenerator.class ); register( \u0026#34;enhanced-table\u0026#34;, TableGenerator.class ); } public void register(String strategy, Class generatorClass) { LOG.debugf( \u0026#34;Registering IdentifierGenerator strategy [%s] -\u0026gt; [%s]\u0026#34;, strategy, generatorClass.getName() ); final Class previous = generatorStrategyToClassNameMap.put( strategy, generatorClass ); if ( previous != null ) { LOG.debugf( \u0026#34; - overriding [%s]\u0026#34;, previous.getName() ); } } } 8.3. 设置字段类型 # @Column 声明字段。\n示例:\n设置属性 userName 对应的数据库字段名为 user_name,长度为 32,非空\n@Column(name = \u0026#34;user_name\u0026#34;, nullable = false, length=32) private String userName; 设置字段类型并且加默认值,这个还是挺常用的。\n@Column(columnDefinition = \u0026#34;tinyint(1) default 1\u0026#34;) private Boolean enabled; 8.4. 指定不持久化特定字段 # @Transient :声明不需要与数据库映射的字段,在保存的时候不需要保存进数据库 。\n如果我们想让secrect 这个字段不被持久化,可以使用 @Transient关键字声明。\n@Entity(name=\u0026#34;USER\u0026#34;) public class User { ...... @Transient private String secrect; // not persistent because of @Transient } 除了 @Transient关键字声明, 还可以采用下面几种方法:\nstatic String secrect; // not persistent because of static final String secrect = \u0026#34;Satish\u0026#34;; // not persistent because of final transient String secrect; // not persistent because of transient 一般使用注解的方式比较多。\n8.5. 声明大字段 # @Lob:声明某个字段为大字段。\n@Lob private String content; 更详细的声明:\n@Lob //指定 Lob 类型数据的获取策略, FetchType.EAGER 表示非延迟加载,而 FetchType.LAZY 表示延迟加载 ; @Basic(fetch = FetchType.EAGER) //columnDefinition 属性指定数据表对应的 Lob 字段类型 @Column(name = \u0026#34;content\u0026#34;, columnDefinition = \u0026#34;LONGTEXT NOT NULL\u0026#34;) private String content; 8.6. 创建枚举类型的字段 # 可以使用枚举类型的字段,不过枚举字段要用@Enumerated注解修饰。\npublic enum Gender { MALE(\u0026#34;男性\u0026#34;), FEMALE(\u0026#34;女性\u0026#34;); private String value; Gender(String str){ value=str; } } @Entity @Table(name = \u0026#34;role\u0026#34;) public class Role { @Id @GeneratedValue(strategy = GenerationType.IDENTITY) private Long id; private String name; private String description; @Enumerated(EnumType.STRING) private Gender gender; 省略getter/setter...... } 数据库里面对应存储的是 MALE/FEMALE。\n8.7. 增加审计功能 # 只要继承了 AbstractAuditBase的类都会默认加上下面四个字段。\n@Data @AllArgsConstructor @NoArgsConstructor @MappedSuperclass @EntityListeners(value = AuditingEntityListener.class) public abstract class AbstractAuditBase { @CreatedDate @Column(updatable = false) @JsonIgnore private Instant createdAt; @LastModifiedDate @JsonIgnore private Instant updatedAt; @CreatedBy @Column(updatable = false) @JsonIgnore private String createdBy; @LastModifiedBy @JsonIgnore private String updatedBy; } 我们对应的审计功能对应地配置类可能是下面这样的(Spring Security 项目):\n@Configuration @EnableJpaAuditing public class AuditSecurityConfiguration { @Bean AuditorAware\u0026lt;String\u0026gt; auditorAware() { return () -\u0026gt; Optional.ofNullable(SecurityContextHolder.getContext()) .map(SecurityContext::getAuthentication) .filter(Authentication::isAuthenticated) .map(Authentication::getName); } } 简单介绍一下上面涉及到的一些注解:\n@CreatedDate: 表示该字段为创建时间字段,在这个实体被 insert 的时候,会设置值\n@CreatedBy :表示该字段为创建人,在这个实体被 insert 的时候,会设置值\n@LastModifiedDate、@LastModifiedBy同理。\n@EnableJpaAuditing:开启 JPA 审计功能。\n8.8. 删除/修改数据 # @Modifying 注解提示 JPA 该操作是修改操作,注意还要配合@Transactional注解使用。\n@Repository public interface UserRepository extends JpaRepository\u0026lt;User, Integer\u0026gt; { @Modifying @Transactional(rollbackFor = Exception.class) void deleteByUserName(String userName); } 8.9. 关联关系 # @OneToOne 声明一对一关系 @OneToMany 声明一对多关系 @ManyToOne 声明多对一关系 @ManyToMany 声明多对多关系 更多关于 Spring Boot JPA 的文章请看我的这篇文章: 一文搞懂如何在 Spring Boot 正确中使用 JPA 。\n9. 事务 @Transactional # 在要开启事务的方法上使用@Transactional注解即可!\n@Transactional(rollbackFor = Exception.class) public void save() { ...... } 我们知道 Exception 分为运行时异常 RuntimeException 和非运行时异常。在@Transactional注解中如果不配置rollbackFor属性,那么事务只会在遇到RuntimeException的时候才会回滚,加上rollbackFor=Exception.class,可以让事务在遇到非运行时异常时也回滚。\n@Transactional 注解一般可以作用在类或者方法上。\n作用于类:当把@Transactional 注解放在类上时,表示所有该类的 public 方法都配置相同的事务属性信息。 作用于方法:当类配置了@Transactional,方法也配置了@Transactional,方法的事务会覆盖 类的事务配置信息。 更多关于 Spring 事务的内容请查看我的这篇文章: 可能是最漂亮的 Spring 事务管理详解 。\n10. json 数据处理 # 10.1. 过滤 json 数据 # @JsonIgnoreProperties 作用在类上用于过滤掉特定字段不返回或者不解析。\n//生成json时将userRoles属性过滤 @JsonIgnoreProperties({\u0026#34;userRoles\u0026#34;}) public class User { private String userName; private String fullName; private String password; private List\u0026lt;UserRole\u0026gt; userRoles = new ArrayList\u0026lt;\u0026gt;(); } @JsonIgnore一般用于类的属性上,作用和上面的@JsonIgnoreProperties 一样。\npublic class User { private String userName; private String fullName; private String password; //生成json时将userRoles属性过滤 @JsonIgnore private List\u0026lt;UserRole\u0026gt; userRoles = new ArrayList\u0026lt;\u0026gt;(); } 10.2. 格式化 json 数据 # @JsonFormat一般用来格式化 json 数据。\n比如:\n@JsonFormat(shape=JsonFormat.Shape.STRING, pattern=\u0026#34;yyyy-MM-dd\u0026#39;T\u0026#39;HH:mm:ss.SSS\u0026#39;Z\u0026#39;\u0026#34;, timezone=\u0026#34;GMT\u0026#34;) private Date date; 10.3. 扁平化对象 # @Getter @Setter @ToString public class Account { private Location location; private PersonInfo personInfo; @Getter @Setter @ToString public static class Location { private String provinceName; private String countyName; } @Getter @Setter @ToString public static class PersonInfo { private String userName; private String fullName; } } 未扁平化之前:\n{ \u0026#34;location\u0026#34;: { \u0026#34;provinceName\u0026#34;:\u0026#34;湖北\u0026#34;, \u0026#34;countyName\u0026#34;:\u0026#34;武汉\u0026#34; }, \u0026#34;personInfo\u0026#34;: { \u0026#34;userName\u0026#34;: \u0026#34;coder1234\u0026#34;, \u0026#34;fullName\u0026#34;: \u0026#34;shaungkou\u0026#34; } } 使用@JsonUnwrapped **扁平对象(外面那层没了)**之后:\n@Getter @Setter @ToString public class Account { @JsonUnwrapped private Location location; @JsonUnwrapped private PersonInfo personInfo; ...... } { \u0026#34;provinceName\u0026#34;:\u0026#34;湖北\u0026#34;, \u0026#34;countyName\u0026#34;:\u0026#34;武汉\u0026#34;, \u0026#34;userName\u0026#34;: \u0026#34;coder1234\u0026#34;, \u0026#34;fullName\u0026#34;: \u0026#34;shaungkou\u0026#34; } 11. 测试相关 # @ActiveProfiles一般作用于测试类上, 用于声明生效的 Spring 配置文件。\n@SpringBootTest(webEnvironment = RANDOM_PORT) @ActiveProfiles(\u0026#34;test\u0026#34;) @Slf4j public abstract class TestBase { ...... } @Test声明一个方法为测试方法\n@Transactional被声明的测试方法的数据会回滚,避免污染测试数据。\n@WithMockUser Spring Security 提供的,用来模拟一个真实用户,并且可以赋予权限。\n@Test @Transactional @WithMockUser(username = \u0026#34;user-id-18163138155\u0026#34;, authorities = \u0026#34;ROLE_TEACHER\u0026#34;) void should_import_student_success() throws Exception { ...... } 暂时总结到这里吧!虽然花了挺长时间才写完,不过可能还是会一些常用的注解的被漏掉,所以,我将文章也同步到了 Github 上去,Github 地址: 欢迎完善!\n本文已经收录进我的 75K Star 的 Java 开源项目 JavaGuide:https://github.com/Snailclimb/JavaGuide。\n"},{"id":291,"href":"/zh/docs/technology/Review/java_guide/lybly_framework/ly01ly_spring-knowledge-and-questions-summary/","title":"spring 常见面试题总结","section":"框架","content":" 转载自https://github.com/Snailclimb/JavaGuide(添加小部分笔记)感谢作者!\n这篇文章主要是想通过一些问题,加深大家对于 Spring 的理解,所以不会涉及太多的代码!\n下面的很多问题我自己在使用 Spring 的过程中也并没有注意,自己也是临时查阅了很多资料和书籍补上的。网上也有一些很多关于 Spring 常见问题/面试题整理的文章,我感觉大部分都是互相 copy,而且很多问题也不是很好,有些回答也存在问题。所以,自己花了一周的业余时间整理了一下,希望对大家有帮助。\nSpring 基础 # 什么是 Spring 框架? # Spring 是一款开源的轻量级 Java 开发框架,旨在提高开发人员的开发效率以及系统的可维护性。\n我们一般说 Spring 框架指的都是 Spring Framework,它是很多模块的集合,使用这些模块可以很方便地协助我们进行开发,比如说 Spring 支持 IoC(Inversion of Control:控制反转) 和 AOP(Aspect-Oriented Programming:面向切面编程)、可以很方便地对数据库进行访问、可以很方便地集成第三方组件(电子邮件,任务,调度,缓存等等)、对单元测试支持比较好、支持 RESTful Java 应用程序的开发。\n[\nSpring 最核心的思想就是不重新造轮子,开箱即用,提高开发效率。\nSpring 翻译过来就是春天的意思,可见其目标和使命就是为 Java 程序员带来春天啊!感动!\n🤐 多提一嘴 : 语言的流行通常需要一个杀手级的应用,Spring 就是 Java 生态的一个杀手级的应用框架。\nSpring 提供的核心功能主要是 IoC 和 AOP。学习 Spring ,一定要把 IoC 和 AOP 的核心思想搞懂!\nSpring 官网:https://spring.io/ Github 地址: https://github.com/spring-projects/spring-framework Spring 包含的模块有哪些? # Spring4.x 版本 :\nSpring5.x 版本 :\nSpring5.x 版本中 Web 模块的 Sertlet (应该是Servlet 吧)组件已经被废弃掉,同时增加了用于异步响应式处理的 WebFlux 组件。\nSpring 各个模块的依赖关系如下: Core Container # Spring 框架的核心模块,也可以说是基础模块,主要提供 IoC 依赖注入功能的支持。Spring 其他所有的功能基本都需要依赖于该模块,我们从上面那张 Spring 各个模块的依赖关系图就可以看出来。\nspring-core :Spring 框架基本的核心工具类。 spring-beans :提供对 bean 的创建、配置和管理等功能的支持。 spring-context :提供对国际化、事件传播、资源加载等功能的支持。 spring-expression :提供对表达式语言(Spring Expression Language) SpEL 的支持,只依赖于 core 模块,不依赖于其他模块,可以单独使用。 AOP # spring-aspects :该模块为与 AspectJ 的集成提供支持。 spring-aop :提供了面向切面的编程实现。 spring-instrument :提供了为 JVM 添加代理(agent)的功能。 具体来讲,它为 Tomcat 提供了一个织入代理,能够为 Tomcat 传递类文 件,就像这些文件是被类加载器加载的一样。没有理解也没关系,这个模块的使用场景非常有限。 Data Access/Integration # spring-jdbc :提供了对数据库访问的抽象 JDBC。不同的数据库都有自己独立的 API 用于操作数据库,而 Java 程序只需要和 JDBC API 交互,这样就屏蔽了数据库的影响。 spring-tx :提供对事务的支持。 spring-orm : 提供对 Hibernate、JPA 、iBatis 等 ORM 框架的支持。 spring-oxm :提供一个抽象层支撑 OXM(Object-to-XML-Mapping),例如:JAXB、Castor、XMLBeans、JiBX 和 XStream 等。 spring-jms : 消息服务。自 Spring Framework 4.1 以后,它还提供了对 spring-messaging 模块的继承。 Spring Web # spring-web :对 Web 功能的实现提供一些最基础的支持。 spring-webmvc : 提供对 Spring MVC 的实现。 spring-websocket : 提供了对 WebSocket 的支持,WebSocket 可以让客户端和服务端进行双向通信。 spring-webflux :提供对 WebFlux 的支持。WebFlux 是 Spring Framework 5.0 中引入的新的响应式框架。与 Spring MVC 不同,它不需要 Servlet API,是完全异步。 Messaging # spring-messaging 是从 Spring4.0 开始新加入的一个模块,主要职责是为 Spring 框架集成一些基础的报文传送应用。\nSpring Test # Spring 团队提倡测试驱动开发(TDD)。有了控制反转 (IoC)的帮助,单元测试和集成测试变得更简单。\nSpring 的测试模块对 JUnit(单元测试框架)、TestNG(类似 JUnit)、Mockito(主要用来 Mock 对象)、PowerMock(解决 Mockito 的问题比如无法模拟 final, static, private 方法)等等常用的测试框架支持的都比较好。\nSpring,Spring MVC,Spring Boot 之间什么关系? # 很多人对 Spring,Spring MVC,Spring Boot 这三者傻傻分不清楚!这里简单介绍一下这三者,其实很简单,没有什么高深的东西。\nSpring 包含了多个功能模块(上面刚刚提到过),其中最重要的是 Spring-Core(主要提供 IoC 依赖注入功能的支持) 模块, Spring 中的其他模块(比如 Spring MVC)的功能实现基本都需要依赖于该模块。\n下图对应的是 Spring4.x 版本。目前最新的 5.x 版本中 Web 模块的 Portlet 组件已经被废弃掉,同时增加了用于异步响应式处理的 WebFlux 组件。\nSpring MVC 是 Spring 中的一个很重要的模块,主要赋予 Spring 快速构建 MVC 架构的 Web 程序的能力。MVC 是模型(Model)、视图(View)、控制器(Controller)的简写,其核心思想是通过将业务逻辑、数据、显示分离来组织代码。\n使用 Spring 进行开发各种配置过于麻烦比如开启某些 Spring 特性时,需要用 XML 或 Java 进行显式配置。于是,Spring Boot 诞生了!\nSpring 旨在简化 J2EE 企业应用程序开发。Spring Boot 旨在简化 Spring 开发(减少配置文件,开箱即用!)。\nSpring Boot 只是简化了配置,如果你需要构建 MVC 架构的 Web 程序,你还是需要使用 Spring MVC 作为 MVC 框架,只是说 Spring Boot 帮你简化了 Spring MVC 的很多配置,真正做到开箱即用!\nSpring IoC # 谈谈自己对于 Spring IoC 的了解 # IoC(Inversion of Control:控制反转) 是一种设计思想,而不是一个具体的技术实现。IoC 的思想就是将原本在程序中手动创建对象的控制权,交由 Spring 框架来管理。不过, IoC 并非 Spring 特有,在其他语言中也有应用。\n为什么叫控制反转?\n控制 :指的是对象创建(实例化、管理)的权力 反转 :控制权交给外部环境(Spring 框架、IoC 容器) 将对象之间的相互依赖关系交给 IoC 容器来管理,并由 IoC 容器完成对象的注入。这样可以很大程度上简化应用的开发,把应用从复杂的依赖关系中解放出来。 IoC 容器就像是一个工厂一样,当我们需要创建一个对象的时候,只需要配置好配置文件/注解即可,完全不用考虑对象是如何被创建出来的。\n在实际项目中一个 Service 类可能依赖了很多其他的类,假如我们需要实例化这个 Service,你可能要每次都要搞清这个 Service 所有底层类的构造函数,这可能会把人逼疯。如果利用 IoC 的话,你只需要配置好,然后在需要的地方引用就行了,这大大增加了项目的可维护性且降低了开发难度。\n在 Spring 中, IoC 容器是 Spring 用来实现 IoC 的载体, IoC 容器实际上就是个 Map(key,value),Map 中存放的是各种对象。\nSpring 时代我们一般通过 XML 文件来配置 Bean,后来开发人员觉得 XML 文件来配置不太好,于是 SpringBoot 注解配置就慢慢开始流行起来。\n相关阅读:\nIoC 源码阅读 面试被问了几百遍的 IoC 和 AOP ,还在傻傻搞不清楚? 什么是 Spring Bean? # 简单来说,Bean 代指的就是那些被 IoC 容器所管理的对象。\n我们需要告诉 IoC 容器帮助我们管理哪些对象,这个是通过配置元数据来定义的。配置元数据可以是 XML 文件、注解或者 Java 配置类。\n\u0026lt;!-- Constructor-arg with \u0026#39;value\u0026#39; attribute --\u0026gt; \u0026lt;bean id=\u0026#34;...\u0026#34; class=\u0026#34;...\u0026#34;\u0026gt; \u0026lt;constructor-arg value=\u0026#34;...\u0026#34;/\u0026gt; \u0026lt;/bean\u0026gt; 下图简单地展示了 IoC 容器如何使用配置元数据来管理对象。\norg.springframework.beans和 org.springframework.context 这两个包是 IoC 实现的基础,如果想要研究 IoC 相关的源码的话,可以去看看\n将一个类声明为 Bean 的注解有哪些? # @Component :通用的注解,可标注任意类为 Spring 组件。如果一个 Bean 不知道属于哪个层,可以使用@Component 注解标注。 @Repository : 对应持久层即 Dao 层,主要用于数据库相关操作。 @Service : 对应服务层,主要涉及一些复杂的逻辑,需要用到 Dao 层。 @Controller : 对应 Spring MVC 控制层,主要用户接受用户请求并调用 Service 层返回数据给前端页面。 @Component 和 @Bean 的区别是什么? # @Component 注解作用于类,而@Bean注解作用于方法。 @Component通常是通过类路径扫描来自动侦测以及自动装配到 Spring 容器中(我们可以使用 @ComponentScan 注解定义要扫描的路径从中找出标识了需要装配的类自动装配到 Spring 的 bean 容器中)。@Bean 注解通常是我们在标有该注解的方法中定义产生这个 bean,@Bean告诉了 Spring 这是某个类的实例,当我需要用它的时候还给我。 @Bean 注解比 @Component 注解的自定义性更强,而且很多地方我们只能通过 @Bean 注解来注册 bean。比如当我们引用第三方库中的类需要装配到 Spring容器时,则只能通过 @Bean来实现。 @Bean注解使用示例:\n@Configuration public class AppConfig { @Bean public TransferService transferService() { return new TransferServiceImpl(); } } 上面的代码相当于下面的 xml 配置\n\u0026lt;beans\u0026gt; \u0026lt;bean id=\u0026#34;transferService\u0026#34; class=\u0026#34;com.acme.TransferServiceImpl\u0026#34;/\u0026gt; \u0026lt;/beans\u0026gt; 下面这个例子是通过 @Component 无法实现的。(带有逻辑)\n@Bean public OneService getService(status) { case (status) { when 1: return new serviceImpl1(); when 2: return new serviceImpl2(); when 3: return new serviceImpl3(); } } 注入 Bean 的注解有哪些? # Spring 内置的 @Autowired 以及 JDK 内置的 @Resource 和 @Inject 都可以用于注入 Bean。\nAnnotaion Package Source @Autowired org.springframework.bean.factory Spring 2.5+ @Resource javax.annotation Java JSR-250 @Inject javax.inject Java JSR-330 @Autowired 和@Resource使用的比较多一些。\n@Autowired 和 @Resource 的区别是什么? # Autowired 属于 Spring 内置的注解,默认的注入方式为byType(根据类型进行匹配),也就是说会优先根据接口类型去匹配并注入 Bean (接口的实现类)。\n这会有什么问题呢? 当一个接口存在多个实现类的话,byType这种方式就无法正确注入对象了,因为这个时候 Spring 会同时找到多个满足条件的选择,默认情况下它自己不知道选择哪一个。\n这种情况下,注入方式会变为 byName(根据名称进行匹配),这个名称通常就是类名(首字母小写)。就比如说下面代码中的 smsService 就是我这里所说的名称,这样应该比较好理解了吧。\n// smsService 就是我们上面所说的名称 @Autowired private SmsService smsService; 举个例子,SmsService 接口有两个实现类: SmsServiceImpl1和 SmsServiceImpl2,且它们都已经被 Spring 容器所管理。\n// 报错,byName 和 byType 都无法匹配到 bean @Autowired private SmsService smsService; // 正确注入 SmsServiceImpl1 对象对应的 bean @Autowired private SmsService smsServiceImpl1; // 正确注入 SmsServiceImpl1 对象对应的 bean // smsServiceImpl1 就是我们上面所说的名称 @Autowired @Qualifier(value = \u0026#34;smsServiceImpl1\u0026#34;) private SmsService smsService; 我们还是建议通过 @Qualifier 注解来显式指定名称而不是依赖变量的名称。\n@Resource属于 JDK 提供的注解,默认注入方式为 byName。如果无法通过名称匹配到对应的 Bean 的话,注入方式会变为byType。\n@Resource 有两个比较重要且日常开发常用的属性:name(名称)、type(类型)。\npublic @interface Resource { String name() default \u0026#34;\u0026#34;; Class\u0026lt;?\u0026gt; type() default Object.class; } 如果仅指定 name 属性则注入方式为byName,如果仅指定type属性则注入方式为byType,如果同时指定name 和type属性(不建议这么做)则注入方式为byType+byName。\n// 报错,byName 和 byType 都无法匹配到 bean @Resource private SmsService smsService; // 正确注入 SmsServiceImpl1 对象对应的 bean @Resource private SmsService smsServiceImpl1; // 正确注入 SmsServiceImpl1 对象对应的 bean(比较推荐这种方式) @Resource(name = \u0026#34;smsServiceImpl1\u0026#34;) private SmsService smsService; 简单总结一下:\n@Autowired 是 Spring 提供的注解,@Resource 是 JDK 提供的注解。 Autowired 默认的注入方式为**byType(根据类型进行匹配)**,@Resource默认注入方式为 byName(根据名称进行匹配)。 当一个接口存在多个实现类的情况下,@Autowired 和@Resource都需要通过名称才能正确匹配到对应的 Bean。Autowired 可以通过 @Qualifier 注解来显式指定名称,@Resource可以通过 name 属性来显式指定名称。 Bean 的作用域有哪些? # Spring 中 Bean 的作用域通常有下面几种:\nsingleton : IoC 容器中只有唯一的 bean 实例。Spring 中的 bean 默认都是单例的,是对单例设计模式的应用。 prototype : 每次获取都会创建一个新的 bean 实例。也就是说,连续 getBean() 两次,得到的是不同的 Bean 实例。 request (仅 Web 应用可用): 每一次 HTTP 请求都会产生一个新的 bean(请求 bean),该 bean 仅在当前 HTTP request 内有效。 session (仅 Web 应用可用) : 每一次来自新 session 的 HTTP 请求都会产生一个新的 bean(会话 bean),该 bean 仅在当前 HTTP session 内有效。 application/global-session (仅 Web 应用可用): 每个 Web 应用在启动时创建一个 Bean(应用 Bean),该 bean 仅在当前应用启动时间内有效。 websocket (仅 Web 应用可用):每一次 WebSocket 会话产生一个新的 bean。 如何配置 bean 的作用域呢?\nxml 方式:\n\u0026lt;bean id=\u0026#34;...\u0026#34; class=\u0026#34;...\u0026#34; scope=\u0026#34;singleton\u0026#34;\u0026gt;\u0026lt;/bean\u0026gt; 注解方式:\n@Bean @Scope(value = ConfigurableBeanFactory.SCOPE_PROTOTYPE) public Person personPrototype() { return new Person(); } 单例 Bean 的线程安全问题了解吗? # 大部分时候我们并没有在项目中使用多线程,所以很少有人会关注这个问题。单例 Bean 存在线程问题,主要是因为当多个线程操作同一个对象的时候是存在资源竞争的。\n常见的有两种解决办法:\n在 Bean 中尽量避免定义可变的成员变量。 在类中定义一个 ThreadLocal 成员变量,将需要的可变成员变量保存在 ThreadLocal 中(推荐的一种方式)。 不过,大部分 Bean 实际都是无状态(没有实例变量)的(比如 Dao、Service),这种情况下, Bean 是线程安全的。\nBean 的生命周期了解么? # 下面的内容整理自:https://yemengying.com/2016/07/14/spring-bean-life-cycle/ ,除了这篇文章,再推荐一篇很不错的文章 :https://www.cnblogs.com/zrtqsk/p/3735273.html 。\nBean 容器找到配置文件中 Spring Bean 的定义。 Bean 容器利用 Java Reflection API 创建一个 Bean 的实例。【反射】 如果涉及到一些属性值 利用 set()方法设置一些属性值。 aware 英[əˈweə(r)] adj. 意识到的,发觉,发现`\n如果 Bean 实现了 BeanNameAware 接口,调用 setBeanName()方法,传入 Bean 的名字。 如果 Bean 实现了 BeanClassLoaderAware 接口,调用 setBeanClassLoader()方法,传入 ClassLoader对象的实例。 如果 Bean 实现了 BeanFactoryAware 接口,调用 setBeanFactory()方法,传入 BeanFactory对象的实例。 与上面的类似,如果实现了其他 *.Aware接口,就调用相应的方法。 如果有和加载这个 Bean 的 Spring 容器相关的 BeanPostProcessor 对象,执行postProcessBeforeInitialization() 方法 如果 Bean 实现了**InitializingBean接口,执行afterPropertiesSet()**方法。 如果 Bean 在配置文件中的定义包含 init-method 属性,执行指定的方法。 如果有和加载这个 Bean 的 Spring 容器相关的 BeanPostProcessor 对象,执行postProcessAfterInitialization() 方法 当要销毁 Bean 的时候,如果 Bean 实现了 DisposableBean 接口,执行 destroy() 方法。 当要销毁 Bean 的时候,如果 Bean 在配置文件中的定义包含 destroy-method 属性,执行指定的方法。 图示:\n与之比较类似的中文版本:\nSpring AoP # 谈谈自己对于 AOP 的了解 # aspect 英[ˈæspekt] 方位 n.\noriented 英[ˈɔːrientɪd] 朝向 v.\nAOP(Aspect-Oriented Programming:面向切面编程)能够将那些与业务无关,却为业务模块所共同调用的逻辑或责任(例如事务处理、日志管理、权限控制等)封装起来,便于减少系统的重复代码,降低模块间的耦合度,并有利于未来的可拓展性和可维护性。\nSpring AOP 就是基于动态代理的,如果要代理的对象,实现了某个接口,那么 Spring AOP 会使用 JDK Proxy,去创建代理对象,而对于没有实现接口的对象,就无法使用 JDK Proxy 去进行代理了,这时候 Spring AOP 会使用 Cglib 生成一个被代理对象的子类来作为代理,如下图所示:\n当然你也可以使用 AspectJ !Spring AOP 已经集成了 AspectJ ,AspectJ 应该算的上是 Java 生态系统中最完整的 AOP 框架了。\nAOP 切面编程设计到的一些专业术语:\n术语 含义 目标(Target) 被通知的对象 代理(Proxy) 向目标对象应用通知之后创建的代理对象 连接点(JoinPoint) 目标对象的所属类中,定义的所有方法均为连接点 切入点(Pointcut) 被切面拦截 / 增强的连接点(切入点一定是连接点,连接点不一定是切入点) 通知(Advice) 增强的逻辑 / 代码,也即拦截到目标对象的连接点之后要做的事情 切面(Aspect) 切入点(Pointcut)+通知(Advice) Weaving(织入) 将通知应用到目标对象,进而生成代理对象的过程动作 Spring AOP 和 AspectJ AOP 有什么区别? # Spring AOP 属于运行时增强,而 AspectJ 是编译时增强。 Spring AOP 基于代理(Proxying),而 AspectJ 基于字节码操作(Bytecode Manipulation)。\nSpring AOP 已经集成了 AspectJ ,AspectJ 应该算的上是 Java 生态系统中最完整的 AOP 框架了。AspectJ 相比于 Spring AOP 功能更加强大,但是 Spring AOP 相对来说更简单,\n如果我们的切面比较少,那么两者性能差异不大。但是,当切面太多的话,最好选择 AspectJ ,它比 Spring AOP 快很多。\nAspectJ 定义的通知类型有哪些? # Before(前置通知):目标对象的方法调用之前触发 After (后置通知):目标对象的方法调用之后触发 AfterReturning(返回通知):目标对象的方法调用完成,在返回结果值之后触发 AfterThrowing(异常通知) :目标对象的方法运行中抛出 / 触发异常后触发。AfterReturning 和 AfterThrowing 两者互斥。如果方法调用成功无异常,则会有返回值;如果方法抛出了异常,则不会有返回值。 Around (环绕通知):编程式控制目标对象的方法调用。环绕通知是所有通知类型中可操作范围最大的一种,因为它可以直接拿到目标对象,以及要执行的方法,所以环绕通知可以任意的在目标对象的方法调用前后搞事,甚至不调用目标对象的方法 多个切面的执行顺序如何控制? # 1、通常使用**@Order 注解**直接定义切面顺序\n// 值越小优先级越高 @Order(3) @Component @Aspect public class LoggingAspect implements Ordered { 2、实现Ordered 接口重写 getOrder 方法。\n@Component @Aspect public class LoggingAspect implements Ordered { // .... @Override public int getOrder() { // 返回值越小优先级越高 return 1; } } Spring MVC # 说说自己对于 Spring MVC 了解? # MVC 是模型(Model)、视图(View)、控制器(Controller)的简写,其核心思想是通过将业务逻辑、数据、显示分离来组织代码。\n网上有很多人说 MVC 不是设计模式,只是软件设计规范,我个人更倾向于 MVC 同样是众多设计模式中的一种。 java-design-patterns 项目中就有关于 MVC 的相关介绍。\n想要真正理解 Spring MVC,我们先来看看 Model 1 和 Model 2 这两个没有 Spring MVC 的时代。\nModel 1 时代\n很多学 Java 后端比较晚的朋友可能并没有接触过 Model 1 时代下的 JavaWeb 应用开发。在 Model1 模式下,整个 Web 应用几乎全部用 JSP 页面组成,只用少量的 JavaBean 来处理数据库连接、访问等操作。\n这个模式下 JSP 即是控制层(Controller)又是表现层(View)。显而易见,这种模式存在很多问题。比如控制逻辑和表现逻辑混杂在一起,导致代码重用率极低;再比如前端和后端相互依赖,难以进行测试维护并且开发效率极低。\nModel 2 时代\n学过 Servlet 并做过相关 Demo 的朋友应该了解“Java Bean(Model)+ JSP(View)+Servlet(Controller) ”这种开发模式,这就是早期的 JavaWeb MVC 开发模式。\nModel:系统涉及的数据,也就是 dao 和 bean。 View:展示模型中的数据,只是用来展示。 Controller:处理用户请求都发送给 Servlet,返回数据给 JSP 并展示给用户。 Model2 模式下还存在很多问题,Model2 的抽象和封装程度还远远不够,使用 Model2 进行开发时不可避免地会重复造轮子,这就大大降低了程序的可维护性和复用性。\n于是,很多 JavaWeb 开发相关的 MVC 框架应运而生比如 Struts2,但是 Struts2 比较笨重。\nSpring MVC 时代\n随着 Spring 轻量级开发框架的流行,Spring 生态圈出现了 Spring MVC 框架, Spring MVC 是当前最优秀的 MVC 框架。相比于 Struts2 , Spring MVC 使用更加简单和方便,开发效率更高,并且 Spring MVC 运行速度更快。\nMVC 是一种设计模式,Spring MVC 是一款很优秀的 MVC 框架。Spring MVC 可以帮助我们进行更简洁的 Web 层的开发,并且它天生与 Spring 框架集成。Spring MVC 下我们一般把后端项目分为 Service 层(处理业务)、Dao 层(数据库操作)、Entity 层(实体类)、Controller 层(控制层,返回数据给前台页面)。\nSpring MVC 的核心组件有哪些? # 记住了下面这些组件,也就记住了 SpringMVC 的工作原理。\nDispatcherServlet :核心的中央处理器,负责接收请求、分发,并给予客户端响应。 HandlerMapping :处理器映射器,根据 uri 去匹配查找能处理的 Handler ,并会将请求涉及到的拦截器和 Handler 一起封装。 HandlerAdapter :处理器适配器,根据 HandlerMapping 找到的 Handler ,适配执行对应的 Handler; Handler :请求处理器,处理实际请求的处理器。 ViewResolver :视图解析器,根据 Handler 返回的逻辑视图 / 视图,解析并渲染真正的视图,并传递给 DispatcherServlet 响应客户端 SpringMVC 工作原理了解吗? # Spring MVC 原理如下图所示:\nSpringMVC 工作原理的图解我没有自己画,直接图省事在网上找了一个非常清晰直观的,原出处不明。\n流程说明(重要):\n客户端(浏览器)发送请求, DispatcherServlet拦截请求。 DispatcherServlet 根据请求信息调用 HandlerMapping 。HandlerMapping 根据 uri 去匹配查找能处理的 Handler(也就是我们平常说的 Controller 控制器) ,并会将请求涉及到的拦截器和 Handler 一起封装。 DispatcherServlet 调用 **HandlerAdapter**适配执行 Handler 。 Handler 完成对用户请求的处理后,会返回一个 ModelAndView 对象给DispatcherServlet,ModelAndView 顾名思义,包含了数据模型以及相应的视图的信息。Model 是返回的数据对象,View 是个逻辑上的 View。 ViewResolver 会根据逻辑 View 查找实际的 View。 DispaterServlet 把返回的 Model 传给 View(视图渲染)。 把 View 返回给请求者(浏览器)\n统一异常处理怎么做? # 推荐使用注解的方式统一异常处理,具体会使用到 @ControllerAdvice + @ExceptionHandler 这两个注解 。\n@ControllerAdvice @ResponseBody public class GlobalExceptionHandler { @ExceptionHandler(BaseException.class) public ResponseEntity\u0026lt;?\u0026gt; handleAppException(BaseException ex, HttpServletRequest request) { //...... } @ExceptionHandler(value = ResourceNotFoundException.class) public ResponseEntity\u0026lt;ErrorReponse\u0026gt; handleResourceNotFoundException(ResourceNotFoundException ex, HttpServletRequest request) { //...... } } 这种异常处理方式下,会给所有或者指定的 Controller 织入异常处理的逻辑(AOP),当 Controller 中的方法抛出异常的时候,由被@ExceptionHandler 注解修饰的方法进行处理。\nExceptionHandlerMethodResolver 中 getMappedMethod 方法决定了异常具体被哪个被 @ExceptionHandler 注解修饰的方法处理异常。【这个是框架里的源码,不是自己写的】\n@Nullable private Method getMappedMethod(Class\u0026lt;? extends Throwable\u0026gt; exceptionType) { List\u0026lt;Class\u0026lt;? extends Throwable\u0026gt;\u0026gt; matches = new ArrayList\u0026lt;\u0026gt;(); //找到可以处理的所有异常信息。mappedMethods 中存放了异常和处理异常的方法的对应关系 for (Class\u0026lt;? extends Throwable\u0026gt; mappedException : this.mappedMethods.keySet()) { if (mappedException.isAssignableFrom(exceptionType)) { matches.add(mappedException); } } // 不为空说明有方法处理异常 if (!matches.isEmpty()) { // 按照匹配程度从小到大排序 matches.sort(new ExceptionDepthComparator(exceptionType)); // 返回处理异常的方法 return this.mappedMethods.get(matches.get(0)); } else { return null; } } 从源代码看出: getMappedMethod()会首先找到可以匹配处理异常的所有方法信息,然后对其进行从小到大的排序,最后取最小的那一个匹配的方法(即匹配度最高的那个)。\nSpring 框架中用到了哪些设计模式? # 关于下面这些设计模式的详细介绍,可以看我写的 Spring 中的设计模式详解 这篇文章。\n工厂设计模式 : Spring 使用工厂模式通过 BeanFactory、ApplicationContext 创建 bean 对象。 代理设计模式 : Spring AOP 功能的实现。 单例设计模式 : Spring 中的 Bean 默认都是单例的。 模板方法模式 : Spring 中 jdbcTemplate、hibernateTemplate 等以 Template 结尾的对数据库操作的类,它们就使用到了模板模式。 包装器设计模式 : 我们的项目需要连接多个数据库,而且不同的客户在每次访问中根据需要会去访问不同的数据库。这种模式让我们可以根据客户的需求能够动态切换不同的数据源。 观察者模式: Spring 事件驱动模型就是观察者模式很经典的一个应用。 适配器模式 : Spring AOP 的增强或通知(Advice)使用到了适配器模式、spring MVC 中也是用到了适配器模式适配Controller。 \u0026hellip;\u0026hellip; Spring 事务 # 关于 Spring 事务的详细介绍,可以看我写的 Spring 事务详解 这篇文章。\nSpring 管理事务的方式有几种? # 编程式事务 : 在代码中硬编码(不推荐使用) : 通过 **TransactionTemplate**或者 TransactionManager 手动管理事务,实际应用中很少使用,但是对于你理解 Spring 事务管理原理有帮助。 声明式事务 : 在 XML 配置文件中配置或者直接基于注解(推荐使用) : 实际是通过 AOP 实现(基于@**Transactional** 的全注解方式使用最多) Spring事务失效的几种情况(非javaguide) # 1.spring事务实现方式及原理 # Spring 事务的本质其实就是数据库对事务的支持,没有数据库的事务支持,spring 是无法提供事务功能的。真正的数据库层的事务提交和回滚是在binlog提交之后进行提交的 通过 redo log 来重做, undo log来回滚。\n一般我们在程序里面使用的都是在方法上面加@Transactional 注解,这种属于声明式事务。\n声明式事务本质是通过 AOP 功能,对方法前后进行拦截,将事务处理的功能编织到拦截的方法中,也就是在目标方法开始之前加入一个事务,在执行完目标方法之后根据执行情况提交或者回滚事务。\n2.数据库本身不支持事务 # 这里以 MySQL 为例,其 MyISAM 引擎是不支持事务操作的,InnoDB 才是支持事务的引擎,一般要支持事务都会使用 InnoDB\n3.当前类的调用 # @Service public class UserServiceImpl implements UserService { public void update(User user) { updateUser(user); } @Transactional(rollbackFor = Exception.class) public void updateUser(User user) { // update user } } 复制代码 上面的这种情况下是不会有事务管理操作的。\n通过看声明式事务的原理可知,spring使用的是AOP切面的方式,本质上使用的是动态代理来达到事务管理的目的,当前类调用的方法上面加@Transactional 这个是没有任何作用的,因为调用这个方法的是this.\nOK, 我们在看下面的一种例子。\n@Service public class UserServiceImpl implements UserService { @Transactional(rollbackFor = Exception.class) public void update(User user) { updateUser(user); } @Transactional(propagation = Propagation.REQUIRES_NEW) public void updateUser(User user) { // update user } } 复制代码 这次在 update 方法上加了 @Transactional,updateUser 加了 REQUIRES_NEW 新开启一个事务,那么新开的事务管用么?\n答案是:不管用!\n因为它们发生了自身调用,就调该类自己的方法,而没有经过 Spring 的代理类,默认只有在外部调用事务才会生效,这也是老生常谈的经典问题了。\n4.方法不是public的 # @Service public class UserServiceImpl implements UserService { @Transactional(rollbackFor = Exception.class) private void updateUser(User user) { // update user } } 复制代码 private 方法是不会被spring代理的,因此是不会有事务产生的,这种做法是无效的。\n5.没有被spring管理 # //@Service public class UserServiceImpl implements UserService { @Transactional(rollbackFor = Exception.class) public void updateUser(User user) { // update user } } 复制代码 没有被spring管理的bean, spring连代理对象都无法生成,当然无效咯。\n6.配置的事务传播性有问题 # @Service public class UserServiceImpl implements UserService { @Transactional(propagation = Propagation.NOT_SUPPORTED) public void update(User user) { // update user } } 复制代码 回顾一下spring的事务传播行为\nSpring 事务的传播行为说的是,当多个事务同时存在的时候, Spring 如何处理这些事务的行为。\nPROPAGATION_REQUIRED:如果当前没有事务,就创建一个新事务,如果当前存在事务,就加入该事务,该设置是最常用的设置。 PROPAGATION_SUPPORTS:支持当前事务,如果当前存在事务,就加入该事务,如果当前不存在事务,就以非事务执行 PROPAGATION_MANDATORY:支持当前事务,如果当前存在事务,就加入该事务,如果当前不存在事务,就抛出异常。 PROPAGATION_REQUIRES_NEW:创建新事务,无论当前存不存在事务,都创建新事务。 PROPAGATION_NOT_SUPPORTED:以非事务方式执行操作,如果当前存在事务,就把当前事务挂起。 PROPAGATION_NEVER: 以非事务方式执行,如果当前存在事务,则抛出异常。 PROPAGATION_NESTED:如果当前存在事务,则在嵌套事务内执行。如果当前没有事务,则按 REQUIRED 属性执行 当传播行为设置了PROPAGATION_NOT_SUPPORTED,PROPAGATION_NEVER,PROPAGATION_SUPPORTS这三种时,就有可能存在事务不生效\n7.异常被你 \u0026ldquo;抓住\u0026quot;了 # @Service public class UserServiceImpl implements UserService { @Transactional(rollbackFor = Exception.class) public void update(User user) { try{ // update user }catch(Execption e){ log.error(\u0026#34;异常\u0026#34;,e) } } } 复制代码 异常被抓了,这样子代理类就没办法知道你到底有没有错误,需不需要回滚,所以这种情况也是没办法回滚的哦。\n8.接口层声明式事务使用cglib代理 # 注意,这是个前后关系,说的是:如果在接口层使用了声明式事务,结果用的是cglib代理,那么事务就不会生效\npublic interface UserService { @Transactional(rollbackFor = Exception.class) public void update(User user) } 复制代码 @Service public class UserServiceImpl implements UserService { public void update(User user) { // update user } } 复制代码 通过元素的 \u0026ldquo;proxy-target-class\u0026rdquo; 属性值来控制是基于接口的还是基于类的代理被创建。如果 \u0026ldquo;proxy-target-class\u0026rdquo; 属值被设置为 \u0026ldquo;true\u0026rdquo;,那么基于类的代理将起作用(这时需要CGLIB库cglib.jar在CLASSPATH中)。如果 \u0026ldquo;proxy-target-class\u0026rdquo; 属值被设置为 \u0026ldquo;false\u0026rdquo; 或者这个属性被省略,那么标准的JDK基于接口的代理将起作用\n注解@Transactional cglib与java动态代理最大区别是代理目标对象不用实现接口,那么注解要是写到接口方法上,要是使用cglib代理,这时注解事务就失效了,为了保持兼容注解最好都写到实现类方法上。\n9.rollbackFor异常指定错误 # @Service public class UserServiceImpl implements UserService { @Transactional public void update(User user) { // update user } } 复制代码 上面这种没有指定回滚异常,这个时候默认的回滚异常是RuntimeException ,如果出现其他异常那么就不会回滚事务\nSpring 事务中哪几种事务传播行为? # 事务传播行为是为了解决业务层方法之间互相调用的事务问题。\n当事务方法被另一个事务方法(也可能非事务)调用时,必须指定事务应该如何传播。例如:方法可能继续在现有事务中运行,也可能开启一个新事务,并在自己的事务中运行。\n注意几点,下面这个值都是内方法上的注解的值,且两个方法必须属于不同类\n@Service public class MyClassServiceImpl extends ServiceImpl\u0026lt;MyClassMapper, MyClass\u0026gt; implements MyClassService { @Autowired private UserService userService; //外方法 @Override public void methodOuter() throws Exception { //新增一条记录 MyClass myClass=new MyClass(); myClass.setName(\u0026#34;class_name\u0026#34;); this.saveOrUpdate(myClass); //调用内方法 userService.methodInner(); //抛出异常 //throw new Exception(\u0026#34;hello\u0026#34;); } } @Service public class UserServiceImpl extends ServiceImpl\u0026lt;UserMapper, User\u0026gt; implements UserService { //内方法 @Transactional( rollbackFor = Exception.class ,propagation = Propagation.REQUIRED ) @Override public void methodInner() throws Exception { //新增一条记录 User user = new User(); user.setName(\u0026#34;outer_name\u0026#34;); this.saveOrUpdate(user); //抛出异常 //throw new Exception(\u0026#34;hello\u0026#34;); } } 正确的事务传播行为可能的值如下:\n注:如果外方法不存在事务,则内外方法完全独立,自己(方法内)抛异常不影响另一方法\n1.TransactionDefinition.PROPAGATION_REQUIRED\n使用的最多的一个事务传播行为,我们平时经常使用的@Transactional注解默认使用就是这个事务传播行为。如果当前存在事务,则加入该事务;如果当前没有事务,则创建一个新的事务。\n如果外方法存在事务,则不论 外方法或内方法抛出异常,都会导致外内所在事务(同一个)回滚\n2.TransactionDefinition.PROPAGATION_REQUIRES_NEW\n创建一个新的事务,如果当前存在事务,则把当前事务挂起。也就是说不管外部方法是否开启事务,Propagation.REQUIRES_NEW修饰的内部方法会新开启自己的事务,且开启的事务相互独立,互不干扰。\n如果外方法存在事务,如果仅内方法抛异常,会导致外方法回滚;如果仅外方法抛异常,则不会回滚内方法\n3.TransactionDefinition.PROPAGATION_NESTED\n如果当前存在事务,则创建一个事务作为当前事务的嵌套事务来运行;如果当前没有事务,则该取值等价于TransactionDefinition.PROPAGATION_REQUIRED。\n如果外方法存在事务,(效果和1一样), 不论 外方法或内方法抛出异常,都会导致外内所在事务(和1唯一不同的是,他们是不同事务)回滚\n4.TransactionDefinition.PROPAGATION_MANDATORY\n如果当前存在事务,则加入该事务;如果当前没有事务,则抛出异常。(mandatory:强制性)\n这个使用的很少。\n如果外方法存在事务,(效果和1一样), 不论 外方法或内方法抛出异常,都会导致外内所在事务(和1唯一不同的是,如果外方法不存在事务,调用该方法前就直接抛异常)回滚\n若是错误的配置以下 3 种事务传播行为,事务将不会发生回滚:\nTransactionDefinition.PROPAGATION_SUPPORTS: 如果当前存在事务,则加入该事务;如果当前没有事务,则以非事务的方式继续运行。 TransactionDefinition.PROPAGATION_NOT_SUPPORTED: 以非事务方式运行,如果当前存在事务,则把当前事务挂起。 TransactionDefinition.PROPAGATION_NEVER: 以非事务方式运行,如果当前存在事务,则抛出异常。 Spring 事务中的隔离级别有哪几种? # //这个注解应该是用来修改session级别的隔离级别\n和事务传播行为这块一样,为了方便使用,Spring 也相应地定义了一个枚举类:Isolation\npublic enum Isolation { DEFAULT(TransactionDefinition.ISOLATION_DEFAULT), READ_UNCOMMITTED(TransactionDefinition.ISOLATION_READ_UNCOMMITTED), READ_COMMITTED(TransactionDefinition.ISOLATION_READ_COMMITTED), REPEATABLE_READ(TransactionDefinition.ISOLATION_REPEATABLE_READ), SERIALIZABLE(TransactionDefinition.ISOLATION_SERIALIZABLE); private final int value; Isolation(int value) { this.value = value; } public int value() { return this.value; } } 下面我依次对每一种事务隔离级别进行介绍:\nTransactionDefinition.ISOLATION_DEFAULT :使用后端数据库默认的隔离级别,MySQL 默认采用的 REPEATABLE_READ 隔离级别 Oracle 默认采用的 READ_COMMITTED 隔离级别. TransactionDefinition.ISOLATION_READ_UNCOMMITTED :最低的隔离级别,使用这个隔离级别很少,因为它允许读取尚未提交的数据变更,可能会导致脏读、幻读或不可重复读 TransactionDefinition.ISOLATION_READ_COMMITTED : 允许读取并发事务已经提交的数据,可以阻止脏读,但是幻读或不可重复读仍有可能发生 TransactionDefinition.ISOLATION_REPEATABLE_READ : 对同一字段的多次读取结果都是一致的,除非数据是被本身事务自己所修改,可以阻止脏读和不可重复读,但幻读仍有可能发生。 TransactionDefinition.ISOLATION_SERIALIZABLE : 最高的隔离级别,完全服从 ACID 的隔离级别。所有的事务依次逐个执行,这样事务之间就完全不可能产生干扰,也就是说,该级别可以防止脏读、不可重复读以及幻读。但是这将严重影响程序的性能。通常情况下也不会用到该级别。 注意,这个注解的使用方法,下面写了两个方法分别模拟两个不同的线程操作(供不同的controller使用)\n@Transactional( isolation = Isolation.READ_COMMITTED ) public User isolation1() { //读取userid=1的值 User byId = this.getById(1L); return byId; } @Transactional public User isolation2() throws InterruptedException { //10s后修改 TimeUnit.SECONDS.sleep(10); User user=new User(); user.setId(1L); user.setName(\u0026#34;1被修改了\u0026#34;); this.saveOrUpdate(user); //10s后提交 TimeUnit.SECONDS.sleep(10); return null; } 当isolation1为读已提交时,只要isolation2方法没有执行完毕(没有提交),那么isolation1只会读取到未修改的值; 当isolation1为读为提交时,即使isolation2方法没有执行完毕(没有提交),那么isolation1也会立马读取到最新的值; @Transactional(rollbackFor = Exception.class)注解了解吗? # Exception 分为运行时异常 RuntimeException 和非运行时异常。事务管理对于企业应用来说是至关重要的,即使出现异常情况,它也可以保证数据的一致性。\n当 @Transactional 注解作用于类上时,该类的所有 public 方法将都具有该类型的事务属性,同时,我们也可以在方法级别使用该标注来覆盖类级别的定义。如果类或者方法加了这个注解,那么这个类里面的方法抛出异常,就会回滚,数据库里面的数据也会回滚。\n在 @Transactional 注解中如果不配置rollbackFor属性,那么事务只会在遇到**RuntimeException的时候才会回滚,加上 rollbackFor=Exception.class,可以让事务在遇到非运行时异常时**也回滚。\nSpring Data JPA # JPA 重要的是实战,这里仅对小部分知识点进行总结。\n如何使用 JPA 在数据库中非持久化一个字段? # 假如我们有下面一个类:\n@Entity(name=\u0026#34;USER\u0026#34;) public class User { @Id @GeneratedValue(strategy = GenerationType.AUTO) @Column(name = \u0026#34;ID\u0026#34;) private Long id; @Column(name=\u0026#34;USER_NAME\u0026#34;) private String userName; @Column(name=\u0026#34;PASSWORD\u0026#34;) private String password; private String secrect; } 如果我们想让secrect 这个字段不被持久化,也就是不被数据库存储怎么办?我们可以采用下面几种方法:\nstatic String transient1; // not persistent because of static final String transient2 = \u0026#34;Satish\u0026#34;; // not persistent because of final transient String transient3; // not persistent because of transient @Transient String transient4; // not persistent because of @Transient 一般使用后面两种方式比较多,我个人使用注解的方式比较多。\nJPA 的审计功能是做什么的?有什么用? # 审计功能主要是帮助我们记录数据库操作的具体行为比如某条记录是谁创建的、什么时间创建的、最后修改人是谁、最后修改时间是什么时候。\n@Data @AllArgsConstructor @NoArgsConstructor @MappedSuperclass @EntityListeners(value = AuditingEntityListener.class) public abstract class AbstractAuditBase { @CreatedDate @Column(updatable = false) @JsonIgnore private Instant createdAt; @LastModifiedDate @JsonIgnore private Instant updatedAt; @CreatedBy @Column(updatable = false) @JsonIgnore private String createdBy; @LastModifiedBy @JsonIgnore private String updatedBy; } @CreatedDate: 表示该字段为创建时间字段,在这个实体被 insert 的时候,会设置值\n@CreatedBy :表示该字段为创建人,在这个实体被 insert 的时候,会设置值\n@LastModifiedDate、@LastModifiedBy同理。\n实体之间的关联关系注解有哪些? # @OneToOne : 一对一。 @ManyToMany :多对多。 @OneToMany : 一对多。 @ManyToOne :多对一。 利用 @ManyToOne 和 @OneToMany 也可以表达多对多的关联关系。\nSpring Security # Spring Security 重要的是实战,这里仅对小部分知识点进行总结。\n有哪些控制请求访问权限的方法? # permitAll() :无条件允许任何形式访问,不管你登录还是没有登录。 anonymous() :允许匿名访问,也就是没有登录才可以访问。 denyAll() :无条件决绝任何形式的访问。 authenticated():只允许已认证的用户访问。 fullyAuthenticated() :只允许已经登录或者通过 remember-me 登录的用户访问。 hasRole(String) : 只允许指定的角色访问。 hasAnyRole(String)\t: 指定一个或者多个角色,满足其一的用户即可访问。 hasAuthority(String) :只允许具有指定权限的用户访问 hasAnyAuthority(String) :指定一个或者多个权限,满足其一的用户即可访问。 hasIpAddress(String) : 只允许指定 ip 的用户访问。 hasRole 和 hasAuthority 有区别吗? # 可以看看松哥的这篇文章: Spring Security 中的 hasRole 和 hasAuthority 有区别吗?,介绍的比较详细。\n如何对密码进行加密? # 如果我们需要保存密码这类敏感数据到数据库的话,需要先加密再保存。\nSpring Security 提供了多种加密算法的实现,开箱即用,非常方便。这些加密算法实现类的父类是 PasswordEncoder ,如果你想要自己实现一个加密算法的话,也需要继承 PasswordEncoder。\nPasswordEncoder 接口一共也就 3 个必须实现的方法。\npublic interface PasswordEncoder { // 加密也就是对原始密码进行编码 String encode(CharSequence var1); // 比对原始密码和数据库中保存的密码 boolean matches(CharSequence var1, String var2); // 判断加密密码是否需要再次进行加密,默认返回 false default boolean upgradeEncoding(String encodedPassword) { return false; } } 官方推荐使用基于 bcrypt 强哈希函数的加密算法实现类。\n如何优雅更换系统使用的加密算法? # 如果我们在开发过程中,突然发现现有的加密算法无法满足我们的需求,需要更换成另外一个加密算法,这个时候应该怎么办呢?\n推荐的做法是通过 DelegatingPasswordEncoder 兼容多种不同的密码加密方案,以适应不同的业务需求。\n从名字也能看出来,DelegatingPasswordEncoder 其实就是一个代理类,并非是一种全新的加密算法,它做的事情就是代理上面提到的加密算法实现类。在 Spring Security 5.0之后,默认就是基于 DelegatingPasswordEncoder 进行密码加密的。\n参考 # 《Spring 技术内幕》 《从零开始深入学习 Spring》:https://juejin.cn/book/6857911863016390663 http://www.cnblogs.com/wmyskxz/p/8820371.html https://www.journaldev.com/2696/spring-interview-questions-and-answers https://www.edureka.co/blog/interview-questions/spring-interview-questions/ https://www.cnblogs.com/clwydjgs/p/9317849.html https://howtodoinjava.com/interview-questions/top-spring-interview-questions-with-answers/ http://www.tomaszezula.com/2014/02/09/spring-series-part-5-component-vs-bean/ https://stackoverflow.com/questions/34172888/difference-between-bean-and-autowired "},{"id":292,"href":"/zh/docs/technology/Review/java_guide/lyaly_dev_tools/git/","title":"git","section":"开发工具","content":" 转载自https://github.com/Snailclimb/JavaGuide(添加小部分笔记)感谢作者!\n版本控制 # 什么是版本控制 # 版本控制是一种记录一个或若干文件内容变化,以便将来查阅特定版本修订情况的系统。 除了项目源代码,你还可以对任何类型的文件进行版本控制。\n为什么要版本控制 # 有了它你就可以将某个文件回溯到之前的状态,甚至将整个项目都回退到过去某个时间点的状态,你可以比较文件的变化细节,查出最后是谁修改了哪个地方,从而找出导致怪异问题出现的原因,又是谁在何时报告了某个功能缺陷等等。\n本地版本控制系统 # 许多人习惯用复制整个项目目录的方式来保存不同的版本,或许还会改名加上备份时间以示区别。 这么做唯一的好处就是简单,但是特别容易犯错。 有时候会混淆所在的工作目录,一不小心会写错文件或者覆盖意想外的文件。\n为了解决这个问题,人们很久以前就开发了许多种本地版本控制系统,大多都是采用某种简单的数据库来记录文件的历次更新差异。\n集中化的版本控制系统 # 接下来人们又遇到一个问题,如何让在不同系统上的开发者协同工作? 于是,集中化的版本控制系统(Centralized Version Control Systems,简称 CVCS)应运而生。\n集中化的版本控制系统都有一个单一的集中管理的服务器,保存所有文件的修订版本,而协同工作的人们都通过客户端连到这台服务器,取出最新的文件或者提交更新。\n这么做虽然解决了本地版本控制系统无法让在不同系统上的开发者协同工作的诟病,但也还是存在下面的问题:\n单点故障: 中央服务器宕机,则其他人无法使用;如果中心数据库磁盘损坏又没有进行备份,你将丢失所有数据。本地版本控制系统也存在类似问题,只要整个项目的历史记录被保存在单一位置,就有丢失所有历史更新记录的风险。 必须联网才能工作: 受网络状况、带宽影响。 分布式版本控制系统 # 于是分布式版本控制系统(Distributed Version Control System,简称 DVCS)面世了。 Git 就是一个典型的分布式版本控制系统。\n这类系统,客户端并不只提取最新版本的文件快照,而是把代码仓库完整地镜像下来。 这么一来,任何一处协同工作用的服务器发生故障,事后都可以用任何一个镜像出来的本地仓库恢复。 因为每一次的克隆操作,实际上都是一次对代码仓库的完整备份。\n分布式版本控制系统可以不用联网就可以工作,因为每个人的电脑上都是完整的版本库,当你修改了某个文件后,你只需要将自己的修改推送给别人就可以了。但是,在实际使用分布式版本控制系统的时候,很少会直接进行推送修改,而是使用一台充当“中央服务器”的东西。这个服务器的作用仅仅是用来方便“交换”大家的修改,没有它大家也一样干活,只是交换修改不方便而已。\n分布式版本控制系统的优势不单是不必联网这么简单,后面我们还会看到 Git 极其强大的分支管理等功能。\n认识 Git # Git 简史 # Linux 内核项目组当时使用分布式版本控制系统 BitKeeper 来管理和维护代码。但是,后来开发 BitKeeper 的商业公司同 Linux 内核开源社区的合作关系结束,他们收回了 Linux 内核社区免费使用 BitKeeper 的权力。 Linux 开源社区(特别是 Linux 的缔造者 Linus Torvalds)基于使用 BitKeeper 时的经验教训,开发出自己的版本系统,而且对新的版本控制系统做了很多改进。\nGit 与其他版本管理系统的主要区别 # Git 在保存和对待各种信息的时候与其它版本控制系统有很大差异,尽管操作起来的命令形式非常相近,理解这些差异将有助于防止你使用中的困惑。\n下面我们主要说一个关于 Git 与其他版本管理系统的主要差别:对待数据的方式。\nGit采用的是直接记录快照的方式,而非差异比较。我后面会详细介绍这两种方式的差别。\n大部分版本控制系统(CVS、Subversion、Perforce、Bazaar 等等)都是以文件变更列表的方式存储信息,这类系统将它们保存的信息看作是一组基本文件和每个文件随时间逐步累积的差异。\n具体原理如下图所示,理解起来其实很简单,每当我们提交更新一个文件之后,系统都会记录这个文件做了哪些更新,以增量符号Δ(Delta)表示。\n我们怎样才能得到一个文件的最终版本呢?\n很简单,高中数学的基本知识,我们只需要将这些原文件和这些增加进行相加就行了。\n这种方式有什么问题呢?\n比如我们的增量特别特别多的话,如果我们要得到最终的文件是不是会耗费时间和性能。\nGit 不按照以上方式对待或保存数据。 反之,Git 更像是把数据看作是对小型文件系统的一组快照。 每次你提交更新,或在 Git 中保存项目状态时,它主要对当时的全部文件制作一个快照并保存这个快照的索引。 为了高效,如果文件没有修改,Git 不再重新存储该文件,而是只保留一个链接指向之前存储的文件。 Git 对待数据更像是一个 快照流。\nGit 的三种状态 # Git 有三种状态,你的文件可能处于其中之一:\n已提交(committed):数据已经安全的保存在本地数据库中。 已修改(modified):已修改表示修改了文件,但还没保存到数据库中。 已暂存(staged):表示对一个已修改文件的当前版本做了标记,使之包含在下次提交的快照中。 由此引入 Git 项目的三个工作区域的概念:Git 仓库(.git directory)、工作目录(Working Directory) 以及 暂存区域(Staging Area) 。\n基本的 Git 工作流程如下:\n在工作目录中修改文件。 暂存文件,将文件的快照放入暂存区域。 提交更新,找到暂存区域的文件,将快照永久性存储到 Git 仓库目录。 Git 使用快速入门 # 获取 Git 仓库 # 有两种取得 Git 项目仓库的方法。\n在现有目录中初始化仓库: 进入项目目录运行 git init 命令,该命令将创建一个名为 .git 的子目录。 从一个服务器克隆一个现有的 Git 仓库: git clone [url] 自定义本地仓库的名字: git clone [url] directoryname 记录每次更新到仓库 # 检测当前文件状态 : git status 提出更改(把它们添加到暂存区):git add filename (针对特定文件)、git add *(所有文件)、git add *.txt(支持通配符,所有 .txt 文件) 忽略文件:.gitignore 文件 提交更新: git commit -m \u0026quot;代码提交信息\u0026quot; (每次准备提交前,先用 git status 看下,是不是都已暂存起来了, 然后再运行提交命令 git commit) 跳过使用暂存区域更新的方式 : git commit -a -m \u0026quot;代码提交信息\u0026quot;。 git commit 加上 -a 选项,Git 就会自动把所有已经跟踪过的文件暂存起来一并提交,从而跳过 git add 步骤。 移除文件 :git rm filename (从暂存区域移除,然后提交。) 对文件重命名 :git mv README.md README(这个命令相当于mv README.md README、git rm README.md、git add README 这三条命令的集合) 一个好的 Git 提交消息 # 一个好的 Git 提交消息如下:\n标题行:用这一行来描述和解释你的这次提交 主体部分可以是很少的几行,来加入更多的细节来解释提交,最好是能给出一些相关的背景或者解释这个提交能修复和解决什么问题。 主体部分当然也可以有几段,但是一定要注意换行和句子不要太长。因为这样在使用 \u0026#34;git log\u0026#34; 的时候会有缩进比较好看。 提交的标题行描述应该尽量的清晰和尽量的一句话概括。这样就方便相关的 Git 日志查看工具显示和其他人的阅读。\n推送改动到远程仓库 # 如果你还没有克隆现有仓库,并欲将你的仓库连接到某个远程服务器,你可以使用如下命令添加:git remote add origin \u0026lt;server\u0026gt; ,比如我们要让本地的一个仓库和 Github 上创建的一个仓库关联可以这样**git remote add origin https://github.com/Snailclimb/test.git**\n将这些改动提交到远端仓库:git push origin master (可以把 master 换成你想要推送的任何分支)\n如此你就能够将你的改动推送到所添加的服务器上去了。\n远程仓库的移除与重命名 # 将 test 重命名为 test1:git remote rename test test1 移除远程仓库 test1:git remote rm test1 查看提交历史 # 在提交了若干更新,又或者克隆了某个项目之后,你也许想回顾下提交历史。 完成这个任务最简单而又有效的工具是 git log 命令。git log 会按提交时间列出所有的更新,最近的更新排在最上面。\n可以添加一些参数来查看自己希望看到的内容:\n只看某个人的提交记录:\ngit log --author=bob 撤销操作 # 有时候我们提交完了才发现漏掉了几个文件没有添加,或者提交信息写错了。 此时,可以运行带有 --amend 选项的提交命令尝试重新提交:\ngit commit --amend 取消暂存的文件\ngit reset filename 撤消对文件的修改:\ngit checkout -- filename 假如你想丢弃你在本地的所有改动与提交,可以到服务器上获取最新的版本历史,并将你本地主分支指向它:\ngit fetch origin git reset --hard origin/master 分支 # 分支是用来将特性开发绝缘开来的。在你创建仓库的时候,master 是“默认”的分支。在其他分支上进行开发,完成后再将它们合并到主分支上。\n我们通常在开发新功能、修复一个紧急 bug 等等时候会选择创建分支。单分支开发好还是多分支开发好,还是要看具体场景来说。\n创建一个名字叫做 test 的分支\ngit branch test 切换当前分支到 test(当你切换分支的时候,Git 会重置你的工作目录,使其看起来像回到了你在那个分支上最后一次提交的样子。 Git 会自动添加、删除、修改文件以确保此时你的工作目录和这个分支最后一次提交时的样子一模一样)\ngit checkout test 你也可以直接这样创建分支并切换过去(上面两条命令的合写)\ngit checkout -b feature_x 切换到主分支\ngit checkout master 合并分支(可能会有冲突)\ngit merge test 把新建的分支删掉\ngit branch -d feature_x 将分支推送到远端仓库(推送成功后其他人可见):\ngit push origin 推荐 # 在线演示学习工具:\n「补充,来自 issue729」Learn Git Branching https://oschina.gitee.io/learn-git-branching/ 。该网站可以方便的演示基本的git操作,讲解得明明白白。每一个基本命令的作用和结果。\n推荐阅读:\nGit - 简明指南 图解Git 猴子都能懂得Git入门 https://git-scm.com/book/en/v2 Generating a new SSH key and adding it to the ssh-agent 一个好的 Git 提交消息,出自 Linus 之手 "},{"id":293,"href":"/zh/docs/technology/Review/java_guide/lyaly_dev_tools/maven/","title":"maven","section":"开发工具","content":" 转载自https://github.com/Snailclimb/JavaGuide(添加小部分笔记)感谢作者!\n这部分内容主要根据 Maven 官方文档整理,做了对应的删减,主要保留比较重要的部分,不涉及实战,主要是一些重要概念的介绍。\nMaven 介绍 # Maven 官方文档是这样介绍的 Maven 的:\nApache Maven is a software project management and comprehension tool. Based on the concept of a project object model (POM), Maven can manage a project\u0026rsquo;s build, reporting and documentation from a central piece of information.\nApache Maven 的本质是一个软件项目管理和理解工具。基于项目对象模型 (Project Object Model,POM) 的概念,Maven 可以从一条中心信息 管理项目的构建、报告和文档。\n什么是 POM? 每一个 Maven 工程都有一个 pom.xml 文件,位于根目录中,包含项目构建生命周期的详细信息。通过 pom.xml 文件,我们可以定义项目的坐标、项目依赖、项目信息、插件信息等等配置。\n对于开发者来说,Maven 的主要作用主要有 3 个:\n项目构建 :提供标准的、跨平台的自动化项目构建方式。 依赖管理 :方便快捷的管理项目依赖的资源(jar 包),避免资源间的版本冲突问题。 统一开发结构 :提供标准的、统一的项目结构。 关于 Maven 的基本使用这里就不介绍了,建议看看官网的 5 分钟上手 Maven 的教程: Maven in 5 Minutes 。\nMaven 坐标 # 项目中依赖的第三方库以及插件可统称为构件。每一个构件都可以使用 Maven 坐标 唯一标识,坐标元素包括:\ngoupId(必须): 定义了当前 Maven 项目隶属的组织或公司。groupId 一般分为多段,通常情况下,第一段为域,第二段为公司名称。域又分为 org、com、cn 等,其中 org 为非营利组织,com 为商业组织,cn 表示中国。以 apache 开源社区的 tomcat 项目为例,这个项目的 groupId 是 org.apache,它的域是 org(因为 tomcat 是非营利项目),公司名称是 apache,artifactId 是 tomcat。 artifactId(必须):定义了当前 Maven 项目的名称,项目的唯一的标识符,对应项目根目录的名称。 version(必须): 定义了 Maven 项目当前所处版本。 packaging(可选):定义了 Maven 项目的打包方式(比如 jar,war\u0026hellip;),默认使用 jar。 classifier(可选):常用于区分从同一 POM 构建的具有不同内容的构件,可以是任意的字符串,附加在版本号之后。 只要你提供正确的坐标,就能从 Maven 仓库中找到相应的构件供我们使用。\n举个例子(引入阿里巴巴开源的 EasyExcel) :\n\u0026lt;dependency\u0026gt; \u0026lt;groupId\u0026gt;com.alibaba\u0026lt;/groupId\u0026gt; \u0026lt;artifactId\u0026gt;easyexcel\u0026lt;/artifactId\u0026gt; \u0026lt;version\u0026gt;3.1.1\u0026lt;/version\u0026gt; \u0026lt;/dependency\u0026gt; 你可以在 https://mvnrepository.com/ 这个网站上找到几乎所有可用的构件,如果你的项目使用的是 Maven 作为构建工具,那这个网站你一定会经常接触。\nMaven 依赖 # 如果使用 Maven 构建产生的构件(例如 Jar 文件)被其他的项目引用,那么该构件就是其他项目的依赖。\n依赖配置 # 配置信息示例 :\n\u0026lt;project\u0026gt; \u0026lt;dependencies\u0026gt; \u0026lt;dependency\u0026gt; \u0026lt;groupId\u0026gt;\u0026lt;/groupId\u0026gt; \u0026lt;artifactId\u0026gt;\u0026lt;/artifactId\u0026gt; \u0026lt;version\u0026gt;\u0026lt;/version\u0026gt; \u0026lt;type\u0026gt;...\u0026lt;/type\u0026gt; \u0026lt;scope\u0026gt;...\u0026lt;/scope\u0026gt; \u0026lt;optional\u0026gt;...\u0026lt;/optional\u0026gt; \u0026lt;exclusions\u0026gt; \u0026lt;exclusion\u0026gt; \u0026lt;groupId\u0026gt;...\u0026lt;/groupId\u0026gt; \u0026lt;artifactId\u0026gt;...\u0026lt;/artifactId\u0026gt; \u0026lt;/exclusion\u0026gt; \u0026lt;/exclusions\u0026gt; \u0026lt;/dependency\u0026gt; \u0026lt;/dependencies\u0026gt; \u0026lt;/project\u0026gt; 配置说明 :\ndependencies: 一个 pom.xml 文件中只能存在一个这样的标签,是用来管理依赖的总标签。 dependency:包含在 dependencies 标签中,可以有多个,每一个表示项目的一个依赖。 groupId,artifactId,version(必要):依赖的基本坐标,对于任何一个依赖来说,基本坐标是最重要的,Maven 根据坐标才能找到需要的依赖。我们在上面解释过这些元素的具体意思,这里就不重复提了。 type(可选):依赖的类型,对应于项目坐标定义的 packaging。大部分情况下,该元素不必声明,其默认值是 jar。 scope(可选):依赖的范围,默认值是 compile。 optional(可选): 标记依赖是否可选 exclusions(可选):用来排除传递性依赖,例如 jar 包冲突 依赖范围 # classpath 用于指定 .class 文件存放的位置,类加载器会从该路径中加载所需的 .class 文件到内存中。\nMaven 在编译、执行测试、实际运行有着三套不同的 classpath:\n编译 classpath :编译主代码有效 测试 classpath :编译、运行测试代码有效 运行 classpath :项目运行时有效 Maven 的依赖范围如下:\ncompile:编译依赖范围(默认),使用此依赖范围对于编译、测试、运行三种都有效,即在编译、测试和运行的时候都要使用该依赖 Jar 包。 test:测试依赖范围,从字面意思就可以知道此依赖范围只能用于测试,而在编译和运行项目时无法使用此类依赖,典型的是 JUnit,它只用于编译 测试代码和运行测试代码的时候才需要。 provided :此依赖范围,对于编译和测试有效,而对运行时无效。比如 servlet-api.jar 在 Tomcat 中已经提供了,我们只需要的是编译期提供而已。 runtime:运行时依赖范围,对于测试和运行有效,但是在编译主代码时无效,典型的就是 JDBC 驱动实现。 system:系统依赖范围,使用 system 范围的依赖时必须通过 systemPath 元素显示地指定依赖文件的路径,不依赖 Maven 仓库解析,所以可能会造成建构的不可移植。 传递依赖性 # 依赖冲突 # 1、对于 Maven 而言,同一个 groupId 同一个 artifactId 下,只能使用一个 version。\n\u0026lt;dependency\u0026gt; \u0026lt;groupId\u0026gt;in.hocg.boot\u0026lt;/groupId\u0026gt; \u0026lt;artifactId\u0026gt;mybatis-plus-spring-boot-starter\u0026lt;/artifactId\u0026gt; \u0026lt;version\u0026gt;1.0.48\u0026lt;/version\u0026gt; \u0026lt;/dependency\u0026gt; \u0026lt;!-- 只会使用 1.0.49 这个版本的依赖 --\u0026gt; \u0026lt;dependency\u0026gt; \u0026lt;groupId\u0026gt;in.hocg.boot\u0026lt;/groupId\u0026gt; \u0026lt;artifactId\u0026gt;mybatis-plus-spring-boot-starter\u0026lt;/artifactId\u0026gt; \u0026lt;version\u0026gt;1.0.49\u0026lt;/version\u0026gt; \u0026lt;/dependency\u0026gt; 若相同类型但版本不同的依赖存在于同一个 pom 文件,只会引入后一个声明的依赖。\n2、项目的两个依赖同时引入了某个依赖。\n举个例子,项目存在下面这样的依赖关系:\n依赖链路一:A -\u0026gt; B -\u0026gt; C -\u0026gt; X(1.0) 依赖链路二:A -\u0026gt; D -\u0026gt; X(2.0) 这两条依赖路径上有两个版本的 X,为了避免依赖重复,Maven 只会选择其中的一个进行解析。\n哪个版本的 X 会被 Maven 解析使用呢?\nMaven 在遇到这种问题的时候,会遵循 路径最短优先 和 声明顺序优先 两大原则。解决这个问题的过程也被称为 Maven 依赖调解 。\n路径最短优先\n依赖链路一:A -\u0026gt; B -\u0026gt; C -\u0026gt; X(1.0) // dist = 3 依赖链路二:A -\u0026gt; D -\u0026gt; X(2.0) // dist = 2 依赖链路二的路径最短,因此,X(2.0)会被解析使用。\n不过,你也可以发现。路径最短优先原则并不是通用的,像下面这种路径长度相等的情况就不能单单通过其解决了:\n依赖链路一:A -\u0026gt; B -\u0026gt; X(1.0) // dist = 3 依赖链路二:A -\u0026gt; D -\u0026gt; X(2.0) // dist = 2 因此,Maven 又定义了声明顺序优先原则。\n依赖调解第一原则不能解决所有问题,比如这样的依赖关系:A-\u0026gt;B-\u0026gt;Y(1.0)、A-\u0026gt; C-\u0026gt;Y(2.0),Y(1.0)和 Y(2.0)的依赖路径长度是一样的,都为 2。Maven 定义了依赖调解的第二原则:\n声明顺序优先\n在依赖路径长度相等的前提下,在 pom.xml 中依赖声明的顺序决定了谁会被解析使用,顺序最前的那个依赖优胜。该例中,如果 B 的依赖声明在 D 之前,那么 X (1.0)就会被解析使用。\n\u0026lt;!-- A pom.xml --\u0026gt; \u0026lt;dependencies\u0026gt; ... dependency B ... dependency D \u0026lt;/dependencies\u0026gt; 排除依赖 # 单纯依赖 Maven 来进行依赖调解,在很多情况下是不适用的,需要我们手动排除依赖。\n举个例子,当前项目存在下面这样的依赖关系:\n依赖链路一:A -\u0026gt; B -\u0026gt; C -\u0026gt; X(1.5) // dist = 3 依赖链路二:A -\u0026gt; D -\u0026gt; X(1.0) // dist = 2 根据路径最短优先原则,X(1.0) 会被解析使用,也就是说实际用的是 1.0 版本的 X。\n但是!!!这会一些问题:如果 D 依赖用到了 1.5 版本的 X 中才有的一个类,运行项目就会报NoClassDefFoundError错误。如果 D 依赖用到了 1.5 版本的 X 中才有的一个方法,运行项目就会报NoSuchMethodError错误。\n现在知道为什么你的 Maven 项目总是会报**NoClassDefFoundError和NoSuchMethodError**错误了吧?\n如何解决呢? 我们可以通过exclusive标签手动将 X(1.0) 给排除。\n\u0026lt;dependencyB\u0026gt; ...... \u0026lt;exclusions\u0026gt; \u0026lt;exclusion\u0026gt; \u0026lt;artifactId\u0026gt;x\u0026lt;/artifactId\u0026gt; \u0026lt;groupId\u0026gt;org.apache.x\u0026lt;/groupId\u0026gt; \u0026lt;/exclusion\u0026gt; \u0026lt;/exclusions\u0026gt; \u0026lt;/dependency\u0026gt; 一般我们在解决依赖冲突的时候,都会优先保留版本较高的。这是因为大部分 jar 在升级的时候都会做到向下兼容。\n如果高版本修改了低版本的一些类或者方法的话,这个时候就能直接保留高版本了,而是应该考虑优化上层依赖,比如升级上层依赖的版本。\n还是上面的例子:\n依赖链路一:A -\u0026gt; B -\u0026gt; C -\u0026gt; X(1.5) // dist = 3 依赖链路二:A -\u0026gt; D -\u0026gt; X(1.0) // dist = 2 我们保留了 1.5 版本的 X,但是这个版本的 X 删除了 1.0 版本中的某些类。这个时候,我们可以考虑升级 D 的版本到一个 X 兼容的版本。\nMaven 仓库 # 在 Maven 世界中,任何一个依赖、插件或者项目构建的输出,都可以称为 构件 。\n坐标和依赖是构件在 Maven 世界中的逻辑表示方式,构件的物理表示方式是文件,Maven 通过仓库来统一管理这些文件。 任何一个构件都有一组坐标唯一标识。有了仓库之后,无需手动引入构件,我们直接给定构件的坐标即可在 Maven 仓库中找到该构件。\nMaven 仓库分为:\n本地仓库 :运行 Maven 的计算机上的一个目录,它缓存远程下载的构件并包含尚未发布的临时构件。settings.xml 文件中可以看到 Maven 的本地仓库路径配置,默认本地仓库路径是在 ${user.home}/.m2/repository。 远程仓库 :官方或者其他组织维护的 Maven 仓库。 Maven 远程仓库可以分为:\n中央仓库 :这个仓库是由 Maven 社区来维护的,里面存放了绝大多数开源软件的包,并且是作为 Maven 的默认配置,不需要开发者额外配置。另外为了方便查询,还提供了一个 查询地址,开发者可以通过这个地址更快的搜索需要构件的坐标。 私服 :私服是一种特殊的远程 Maven 仓库,它是架设在局域网内的仓库服务,私服一般被配置为互联网远程仓库的镜像,供局域网内的 Maven 用户使用。 其他的公共仓库 :有一些公共仓库是未来加速访问(比如阿里云 Maven 镜像仓库)或者部分构件不存在于中央仓库中。 Maven 依赖包寻找顺序:\n先去本地仓库找寻,有的话,直接使用。 本地仓库没有找到的话,会去远程仓库找寻,下载包到本地仓库。 远程仓库没有找到的话,会报错。 Maven 生命周期 # Maven 的生命周期就是为了对所有的构建过程进行抽象和统一,包含了项目的清理、初始化、编译、测试、打包、集成测试、验证、部署和站点生成等几乎所有构建步骤。\nMaven 定义了 3 个生命周期META-INF/plexus/components.xml:\ndefault 生命周期 clean生命周期 site生命周期 这些生命周期是相互独立的,每个生命周期包含多个阶段(phase)。并且,这些阶段是有序的,也就是说,后面的阶段依赖于前面的阶段。当执行某个阶段的时候,会先执行它前面的阶段。\n执行 Maven 生命周期的命令格式如下:\nmvn 阶段 [阶段2] ...[阶段n] default 生命周期 # default生命周期是在没有任何关联插件的情况下定义的,是 Maven 的主要生命周期,用于构建应用程序,共包含 23 个阶段。\n\u0026lt;phases\u0026gt; \u0026lt;!-- 验证项目是否正确,并且所有必要的信息可用于完成构建过程 --\u0026gt; \u0026lt;phase\u0026gt;validate\u0026lt;/phase\u0026gt; \u0026lt;!-- 建立初始化状态,例如设置属性 --\u0026gt; \u0026lt;phase\u0026gt;initialize\u0026lt;/phase\u0026gt; \u0026lt;!-- 生成要包含在编译阶段的源代码 --\u0026gt; \u0026lt;phase\u0026gt;generate-sources\u0026lt;/phase\u0026gt; \u0026lt;!-- 处理源代码 --\u0026gt; \u0026lt;phase\u0026gt;process-sources\u0026lt;/phase\u0026gt; \u0026lt;!-- 生成要包含在包中的资源 --\u0026gt; \u0026lt;phase\u0026gt;generate-resources\u0026lt;/phase\u0026gt; \u0026lt;!-- 将资源复制并处理到目标目录中,为打包阶段做好准备。 --\u0026gt; \u0026lt;phase\u0026gt;process-resources\u0026lt;/phase\u0026gt; \u0026lt;!-- 编译项目的源代码 --\u0026gt; \u0026lt;phase\u0026gt;compile\u0026lt;/phase\u0026gt; \u0026lt;!-- 对编译生成的文件进行后处理,例如对 Java 类进行字节码增强/优化 --\u0026gt; \u0026lt;phase\u0026gt;process-classes\u0026lt;/phase\u0026gt; \u0026lt;!-- 生成要包含在编译阶段的任何测试源代码 --\u0026gt; \u0026lt;phase\u0026gt;generate-test-sources\u0026lt;/phase\u0026gt; \u0026lt;!-- 处理测试源代码 --\u0026gt; \u0026lt;phase\u0026gt;process-test-sources\u0026lt;/phase\u0026gt; \u0026lt;!-- 生成要包含在编译阶段的测试源代码 --\u0026gt; \u0026lt;phase\u0026gt;generate-test-resources\u0026lt;/phase\u0026gt; \u0026lt;!-- 处理从测试代码文件编译生成的文件 --\u0026gt; \u0026lt;phase\u0026gt;process-test-resources\u0026lt;/phase\u0026gt; \u0026lt;!-- 编译测试源代码 --\u0026gt; \u0026lt;phase\u0026gt;test-compile\u0026lt;/phase\u0026gt; \u0026lt;!-- 处理从测试代码文件编译生成的文件 --\u0026gt; \u0026lt;phase\u0026gt;process-test-classes\u0026lt;/phase\u0026gt; \u0026lt;!-- 使用合适的单元测试框架(Junit 就是其中之一)运行测试 --\u0026gt; \u0026lt;phase\u0026gt;test\u0026lt;/phase\u0026gt; \u0026lt;!-- 在实际打包之前,执行任何的必要的操作为打包做准备 --\u0026gt; \u0026lt;phase\u0026gt;prepare-package\u0026lt;/phase\u0026gt; \u0026lt;!-- 获取已编译的代码并将其打包成可分发的格式,例如 JAR、WAR 或 EAR 文件 --\u0026gt; \u0026lt;phase\u0026gt;package\u0026lt;/phase\u0026gt; \u0026lt;!-- 在执行集成测试之前执行所需的操作。 例如,设置所需的环境 --\u0026gt; \u0026lt;phase\u0026gt;pre-integration-test\u0026lt;/phase\u0026gt; \u0026lt;!-- 处理并在必要时部署软件包到集成测试可以运行的环境 --\u0026gt; \u0026lt;phase\u0026gt;integration-test\u0026lt;/phase\u0026gt; \u0026lt;!-- 执行集成测试后执行所需的操作。 例如,清理环境 --\u0026gt; \u0026lt;phase\u0026gt;post-integration-test\u0026lt;/phase\u0026gt; \u0026lt;!-- 运行任何检查以验证打的包是否有效并符合质量标准。 --\u0026gt; \u0026lt;phase\u0026gt;verify\u0026lt;/phase\u0026gt; \u0026lt;!-- 将包安装到本地仓库中,可以作为本地其他项目的依赖 --\u0026gt; \u0026lt;phase\u0026gt;install\u0026lt;/phase\u0026gt; \u0026lt;!-- 将最终的项目包复制到远程仓库中与其他开发者和项目共享 --\u0026gt; \u0026lt;phase\u0026gt;deploy\u0026lt;/phase\u0026gt; \u0026lt;/phases\u0026gt; 根据前面提到的阶段间依赖关系理论,当我们执行 mvn test命令的时候,会执行从 validate 到 test 的所有阶段,这也就解释了为什么执行测试的时候,项目的代码能够自动编译。\nclean 生命周期 # clean 生命周期的目的是清理项目,共包含 3 个阶段:\npre-clean clean post-clean \u0026lt;phases\u0026gt; \u0026lt;!-- 执行一些需要在clean之前完成的工作 --\u0026gt; \u0026lt;phase\u0026gt;pre-clean\u0026lt;/phase\u0026gt; \u0026lt;!-- 移除所有上一次构建生成的文件 --\u0026gt; \u0026lt;phase\u0026gt;clean\u0026lt;/phase\u0026gt; \u0026lt;!-- 执行一些需要在clean之后立刻完成的工作 --\u0026gt; \u0026lt;phase\u0026gt;post-clean\u0026lt;/phase\u0026gt; \u0026lt;/phases\u0026gt; \u0026lt;default-phases\u0026gt; \u0026lt;clean\u0026gt; org.apache.maven.plugins:maven-clean-plugin:2.5:clean \u0026lt;/clean\u0026gt; \u0026lt;/default-phases\u0026gt; 根据前面提到的阶段间依赖关系理论,当我们执行 mvn clean 的时候,会执行 clean 生命周期中的 pre-clean 和 clean 阶段。\nsite 生命周期 # site 生命周期的目的是建立和发布项目站点,共包含 4 个阶段:\npre-site site post-site site-deploy \u0026lt;phases\u0026gt; \u0026lt;!-- 执行一些需要在生成站点文档之前完成的工作 --\u0026gt; \u0026lt;phase\u0026gt;pre-site\u0026lt;/phase\u0026gt; \u0026lt;!-- 生成项目的站点文档作 --\u0026gt; \u0026lt;phase\u0026gt;site\u0026lt;/phase\u0026gt; \u0026lt;!-- 执行一些需要在生成站点文档之后完成的工作,并且为部署做准备 --\u0026gt; \u0026lt;phase\u0026gt;post-site\u0026lt;/phase\u0026gt; \u0026lt;!-- 将生成的站点文档部署到特定的服务器上 --\u0026gt; \u0026lt;phase\u0026gt;site-deploy\u0026lt;/phase\u0026gt; \u0026lt;/phases\u0026gt; \u0026lt;default-phases\u0026gt; \u0026lt;site\u0026gt; org.apache.maven.plugins:maven-site-plugin:3.3:site \u0026lt;/site\u0026gt; \u0026lt;site-deploy\u0026gt; org.apache.maven.plugins:maven-site-plugin:3.3:deploy \u0026lt;/site-deploy\u0026gt; \u0026lt;/default-phases\u0026gt; Maven 能够基于 pom.xml 所包含的信息,自动生成一个友好的站点,方便团队交流和发布项目信息。\nMaven 插件 # Maven 本质上是一个插件执行框架,所有的执行过程,都是由一个一个插件独立完成的。像咱们日常使用到的 install、clean、deploy 等命令,其实底层都是一个一个的 Maven 插件。关于 Maven 的核心插件可以参考官方的这篇文档:https://maven.apache.org/plugins/index.html 。\n除了 Maven 自带的插件之外,还有一些三方提供的插件比如单测覆盖率插件 jacoco-maven-plugin、帮助开发检测代码中不合规范的地方的插件 maven-checkstyle-plugin、分析代码质量的 sonar-maven-plugin。并且,我们还可以自定义插件来满足自己的需求。\njacoco-maven-plugin 使用示例:\n\u0026lt;build\u0026gt; \u0026lt;plugins\u0026gt; \u0026lt;plugin\u0026gt; \u0026lt;groupId\u0026gt;org.jacoco\u0026lt;/groupId\u0026gt; \u0026lt;artifactId\u0026gt;jacoco-maven-plugin\u0026lt;/artifactId\u0026gt; \u0026lt;version\u0026gt;0.8.8\u0026lt;/version\u0026gt; \u0026lt;executions\u0026gt; \u0026lt;execution\u0026gt; \u0026lt;goals\u0026gt; \u0026lt;goal\u0026gt;prepare-agent\u0026lt;/goal\u0026gt; \u0026lt;/goals\u0026gt; \u0026lt;/execution\u0026gt; \u0026lt;execution\u0026gt; \u0026lt;id\u0026gt;generate-code-coverage-report\u0026lt;/id\u0026gt; \u0026lt;phase\u0026gt;test\u0026lt;/phase\u0026gt; \u0026lt;goals\u0026gt; \u0026lt;goal\u0026gt;report\u0026lt;/goal\u0026gt; \u0026lt;/goals\u0026gt; \u0026lt;/execution\u0026gt; \u0026lt;/executions\u0026gt; \u0026lt;/plugin\u0026gt; \u0026lt;/plugins\u0026gt; \u0026lt;/build\u0026gt; 你可以将 Maven 插件理解为一组任务的集合,用户可以通过命令行直接运行指定插件的任务,也可以将插件任务挂载到构建生命周期,随着生命周期运行。\nMaven 插件被分为下面两种类型:\nBuild plugins :在构建时执行。 Reporting plugins:在网站生成过程中执行。 Maven 多模块管理 # 多模块管理简单地来说就是将一个项目分为多个模块,每个模块只负责单一的功能实现。直观的表现就是一个 Maven 项目中不止有一个 pom.xml 文件,会在不同的目录中有多个 pom.xml 文件,进而实现多模块管理。\n多模块管理除了可以更加便于项目开发和管理,还有如下好处:\n降低代码之间的耦合性(从类级别的耦合提升到 jar 包级别的耦合); 减少重复,提升复用性; 每个模块都可以是自解释的(通过模块名或者模块文档); 模块还规范了代码边界的划分,开发者很容易通过模块确定自己所负责的内容。 多模块管理下,会有一个父模块,其他的都是子模块。父模块通常只有一个 pom.xml,没有其他内容。父模块的 pom.xml 一般只定义了各个依赖的版本号、包含哪些子模块以及插件有哪些。不过,要注意的是,如果依赖只在某个子项目中使用,则可以在子项目的 pom.xml 中直接引入,防止父 pom 的过于臃肿。\n如下图所示,Dubbo 项目就被分成了多个子模块比如 dubbo-common(公共逻辑模块)、dubbo-remoting(远程通讯模块)、dubbo-rpc(远程调用模块)。\n文章推荐 # 安全同学讲 Maven 间接依赖场景的仲裁机制 - 阿里开发者 - 2022 高效使用 Java 构建工具| Maven 篇 - 阿里开发者 - 2022 安全同学讲 Maven 重打包的故事 - 阿里开发者 - 2022 参考 # 《Maven 实战》 Introduction to Repositories - Maven 官方文档:https://maven.apache.org/guides/introduction/introduction-to-repositories.html Introduction to the Build Lifecycle - Maven 官方文档:https://maven.apache.org/guides/introduction/introduction-to-the-lifecycle.html#Lifecycle_Reference Maven 依赖范围:http://www.mvnbook.com/maven-dependency.html 解决 maven 依赖冲突,这篇就够了!:https://www.cnblogs.com/qdhxhz/p/16363532.html Multi-Module Project with Maven:https://www.baeldung.com/maven-multi-module "},{"id":294,"href":"/zh/docs/technology/Review/java_guide/java/Concurrent/ly030301lyatomicpre/","title":"Atomic预备知识","section":"并发","content":" Java实现CAS的原理[非javaguide] # i是非线程安全的,因为**i不是原子操作;可以使用synchronized和CAS实现加锁**\nsynchronized是悲观锁,一旦获得锁,其他线程进入后就会阻塞等待锁;而CAS是乐观锁,执行时不会加锁,假设没有冲突,如果因为冲突失败了就重试,直到成功\n乐观锁和悲观锁\n这是一种分类方式 悲观锁,总是认为每次访问共享资源会发生冲突,所以必须对每次数据操作加锁,以保证临界区的程序同一时间只能有一个线程在执行 乐观锁,又称**“无锁”**,假设对共享资源访问没有冲突,线程可以不停的执行,无需加锁无需等待;一旦发生冲突,通常是使用一种称为CAS的技术保证线程执行安全 无锁没有锁的存在,因此不可能发生死锁,即乐观锁天生免疫死锁 乐观锁用于**“读多写少”的环境,避免加锁频繁影响性能;悲观锁用于“写多读少”,避免频繁失败及重试**影响性能 CAS概念,即CompareAndSwap ,比较和交换,CAS中,有三个值(概念上)\nV:要更新的变量(var);E:期望值(expected);N:新值(new) 判断V是否等于E,如果等于,将V的值设置为N;如果不等,说明已经有其它线程更新了V,则当前线程放弃更新,什么都不做。 一般来说,预期值E本质上指的是“旧值”(判断是否修改了)\n如果有一个多个线程共享的变量i原本等于5,我现在在线程A中,想把它设置为新的值6; 我们使用CAS来做这个事情; (首先要把原来的值5在线程中保存起来) 接下来是原子操作:首先我们用(现在的i)去与5对比,发现它等于5,说明没有被其它线程改过,那我就把它设置为新的值6,此次CAS成功,i的值被设置成了6; 如果不等于5,说明i被其它线程改过了(比如现在i的值为2),那么我就什么也不做,此次CAS失败,i的值仍然为2。 其中i为V,5为E,6为N\nCAS是一种原子操作,它是一种系统原语,是一条CPU原子指令,从CPU层面保证它的原子性(不可能出现说,判断了对比了i为5之后,正准备更新它的值,此时该值被其他线程改了)\n当多个线程同时使用CAS操作一个变量时,只有一个会胜出,并成功更新,其余均会失败,但失败的线程并不会被挂起,仅是被告知失败,并且允许再次尝试,当然也允许失败的线程放弃操作。\nJava实现CAS的原理 - Unsafe类\n在Java中,如果一个方法是native的,那Java就不负责具体实现它,而是交给底层的JVM使用c或者c++去实现\nJava中有一个Unsafe类,在sun.misc包中,里面有一些native方法,其中包括:\nboolean compareAndSwapObject(Object o, long offset,Object expected, Object x); boolean compareAndSwapInt(Object o, long offset,int expected,int x); boolean compareAndSwapLong(Object o, long offset,long expected,long x); //------\u0026gt;AtomicInteger.class public class AtomicInteger extends Number implements java.io.Serializable { private static final long serialVersionUID = 6214790243416807050L; // setup to use Unsafe.compareAndSwapInt for updates private static final Unsafe unsafe = Unsafe.getUnsafe(); private static final long valueOffset; static { try { valueOffset = unsafe.objectFieldOffset (AtomicInteger.class.getDeclaredField(\u0026#34;value\u0026#34;)); } catch (Exception ex) { throw new Error(ex); } } private volatile int value; public final int getAndIncrement() { return unsafe.getAndAddInt(this, valueOffset, 1); } }\nUnsafe中对CAS的实现是C++写的,它的具体实现和操作系统、CPU都有关系。Linux的X86中主要通过cmpxchgl这个指令在CPU级完成CAS操作,如果是多处理器则必须使用lock指令加锁\nUnsafe类中还有park(线程挂起)和unpark(线程恢复),LockSupport底层则调用了该方法;还有支持反射操作的allocateInstance()\n原子操作- AtomicInteger类源码简析 JDK提供了一些原子操作的类,在java.util.concurrent.atomic包下面,JDK11中有如下17个类 包括 原子更新基本类型,原子更新数组,原子更新引用,原子更新字段(属性)\n其中,AtomicInteger类的getAndAdd(int data)\npublic final int getAndAdd(int delta) { return unsafe.getAndAddInt(this, valueOffset, delta); } //unsafe字段 private static final jdk.internal.misc.Unsafe U = jdk.internal.misc.Unsafe.getUnsafe(); //上面方法实际调用 @HotSpotIntrinsicCandidate public final int getAndAddInt(Object o, long offset, int delta) { int v; do { v = getIntVolatile(o, offset); } while (!weakCompareAndSetInt(o, offset, v, v + delta)); return v; } //对于offset,这是一个对象偏移量,用于获取某个字段相对Java对象的起始地址的偏移量 /* 一个java对象可以看成是一段内存,各个字段都得按照一定的顺序放在这段内存里,同时考虑到对齐要求,可能这些字段不是连续放置的, 用这个方法能准确地告诉你某个字段相对于对象的起始内存地址的字节偏移量,因为是相对偏移量,所以它其实跟某个具体对象又没什么太大关系,跟class的定义和虚拟机的内存模型的实现细节更相关。 */ public class AtomicInteger extends Number implements java.io.Serializable { private static final long serialVersionUID = 6214790243416807050L; // setup to use Unsafe.compareAndSwapInt for updates private static final Unsafe unsafe = Unsafe.getUnsafe(); private static final long valueOffset; static { try { valueOffset = unsafe.objectFieldOffset (AtomicInteger.class.getDeclaredField(\u0026#34;value\u0026#34;)); } catch (Exception ex) { throw new Error(ex); } } private volatile int value; public final int getAndIncrement() { return unsafe.getAndAddInt(this, valueOffset, 1); } } 再重新看这段代码\n@HotSpotIntrinsicCandidate public final int getAndAddInt(Object o, long offset, int delta) { int v; do { v = getIntVolatile(o, offset); } while (!weakCompareAndSetInt(o, offset, v, v + delta)); return v; } 这里声明了v,即要返回的值,即不论如何都会返回原来的值(更新成功前的值),然后新的值为v+delta\n使用do-while保证所有循环至少执行一遍\n循环体的条件是一个CAS方法:\npublic final boolean weakCompareAndSetInt(Object o, long offset, int expected, int x) { return compareAndSetInt(o, offset, expected, x); } public final native boolean compareAndSetInt(Object o, long offset, int expected, int x); 最终调用了native方法:compareAndSetInt方法\n为甚么要经过一层weakCompareAndSetInt,在JDK 8及之前的版本,这两个方法是一样的。\n而在JDK 9开始,这两个方法上面增加了@HotSpotIntrinsicCandidate注解。这个注解允许HotSpot VM自己来写汇编或IR编译器来实现该方法以提供性能。也就是说虽然外面看到的在JDK9中weakCompareAndSet和compareAndSet底层依旧是调用了一样的代码,但是不排除HotSpot VM会手动来实现weakCompareAndSet真正含义的功能的可能性。\n简单来说,weakCompareAndSet操作仅保留了volatile自身变量的特性,而除去了happens-before规则带来的内存语义。也就是说,weakCompareAndSet**无法保证处理操作目标的volatile变量外的其他变量的执行顺序( 编译器和处理器为了优化程序性能而对指令序列进行重新排序 ),同时也无法保证这些变量的可见性。**这在一定程度上可以提高性能。(没看懂)\nCAS如果旧值V不等于预期值E,它就会更新失败。说明旧的值发生了变化。那我们当然需要返回的是被其他线程改变之后的旧值了,因此放在了do循环体内\nCAS实现原子操作的三大问题\nABA问题\n就是一个值原来是A,变成了B,又变回了A。这个时候使用CAS是检查不出变化的,但实际上却被更新了两次\n在变量前面追加上版本号或者时间戳。从JDK 1.5开始,JDK的atomic包里提供了一个类AtomicStampedReference类来解决ABA问题\nAtomicStampedReference类的compareAndSet方法的作用是首先检查当前引用是否等于预期引用,并且检查当前标志是否等于预期标志,如果二者都相等,才使用CAS设置为新的值和标志。\npublic boolean compareAndSet(V expectedReference, V newReference, int expectedStamp, int newStamp) { Pair\u0026lt;V\u0026gt; current = pair; return expectedReference == current.reference \u0026amp;\u0026amp; expectedStamp == current.stamp \u0026amp;\u0026amp; ((newReference == current.reference \u0026amp;\u0026amp; newStamp == current.stamp) || casPair(current, Pair.of(newReference, newStamp))); } 循环时间长开销大\nCAS多与自旋结合,如果自旋CAS长时间不成功,则会占用大量CPU资源,解决思路是让JVM支持处理器提供的pause指令\npause指令能让自旋失败时cpu睡眠一小段时间再继续自旋,从而使得读操作的频率低很多,为解决内存顺序冲突而导致的CPU流水线重排的代价也会小很多。\n限制次数(如果可以放弃操作的话)\n只能保证一个共享变量的原子操作\n使用JDK 1.5开始就提供的AtomicReference类保证对象之间的原子性,把多个变量放到一个对象里面进行CAS操作; 使用锁。锁内的临界区代码可以保证只有当前线程能操作。 AtomicInteger的使用[非javaguide] # //AtomicInteger类常用方法(下面的自增,都使用了CAS,是同步安全的) ublic final int get() //获取当前的值 public final int getAndSet(int newValue)//获取当前的值,并设置新的值 public final int getAndIncrement()//获取当前的值,并自增 public final int getAndDecrement() //获取当前的值,并自减 public final int getAndAdd(int delta) //获取当前的值,并加上预期的值 boolean compareAndSet(int expect, int update) //如果输入的数值等于预期值,则以原子方式将该值设置为输入值(update) public final void lazySet(int newValue)//最终设置为newValue,使用 lazySet 设置之后可能导致其他线程在之后的一小段时间内还是可以读到旧的值。 ------ //使用如下 class AtomicIntegerTest { private AtomicInteger count = new AtomicInteger(); //使用AtomicInteger之后,不需要对该方法加锁,也可以实现线程安全。 public void increment() { count.incrementAndGet(); } public int getCount() { return count.get(); } } 浅谈AtomicInteger实现原理[非javaguide] # 位于Java.util.concurrent.atomic包下,对int封装,提供原子性的访问和更新操作,其原子性操作的实现基于CAS(CompareAndSet)\nCAS,比较并交换,Java并发中lock-free机制的基础,调用Sun的Unsafe的CompareAndSwapInt完成,为native方法,基于CPU的CAS指令来实现的,即无阻塞;且为CAS原语\nCAS:三个参数,1. 当前内存值V 2.旧的预期值 3.即将更新的值,当且仅当预期值A和内存值相同时,将内存值改为 8 并返回true;否则返回false 在JAVA中,CAS通过调用C++库实现,由C++库再去调用CPU指令集。\nCAS确定\nABA 问题 如果期间发生了 A -\u0026gt; B -\u0026gt; A 的更新,仅仅判断数值是 A,可能导致不合理的修改操作;为此,提供了AtomicStampedReference 工具类,为引用建立类似版本号stamp的方式\n循环时间长,开销大。CAS适用于竞争情况短暂的情况,有需要的时候要限制自旋次数,以免过度消耗CPU\n只能保证一个共享变量的原子操作 对多个共享变量操作时,循环CAS就无法保证操作的原子性,这个时候就可以用锁;或者取巧一下,比如 i = 2 , j = a ,合并后为 ij = 2a ,然后cas操作2a\nJava1.5开始JDK提供了AtomicReference类来保证引用对象之间的原子性,你可以把多个变量放在一个对象里来进行CAS操作,例子如下: 如图,它是同时更新了两个变量,而这两个变量都在新的对象上,所以就能解决多个共享变量的问题,即“将问题转换成,如果变量更新了,则更换一个对象”\nAtomicInteger原理浅析\n一些公共属性:\npublic class AtomicInteger extends Number implements java.io.Serializable { private static final long serialVersionUID = 6214790243416807050L; // setup to use Unsafe.compareAndSwapInt for updates private static final Unsafe unsafe = Unsafe.getUnsafe(); private static final long valueOffset; static { try { valueOffset = unsafe.objectFieldOffset (AtomicInteger.class.getDeclaredField(\u0026#34;value\u0026#34;)); } catch (Exception ex) { throw new Error(ex); } } private volatile int value; } AtomicInteger,根据valueOffset代表的该变量值,在内存中的偏移地址,从而获取数据;且value用volatile修饰,保证多线程之间的可见性\npublic final int getAndIncrement() { return unsafe.getAndAddInt(this, valueOffset, 1); } //unsafe.getAndAddInt public final int getAndAddInt(Object var1, long var2, int var4) { int var5; do { var5 = this.getIntVolatile(var1, var2); } while(!this.compareAndSwapInt(var1, var2, var5, var5 + var4));//先获取var1对象的偏移量为var2的内存地址上的值【现在的实际值】 //如果此刻还是var5,+1并赋值,否则重新获取 return var5; } 假设线程1和线程2通过getIntVolatile拿到value的值都为1,线程1被挂起,线程2继续执行 (这里是非原子的哦) 线程2在compareAndSwapInt操作中由于预期值和内存值都为1,因此成功将内存值更新为2 线程1继续执行,在compareAndSwapInt操作中,预期值是1,而当前的内存值为2,CAS操作失败,什么都不做,返回false 线程1重新通过getIntVolatile拿到最新的value为2,再进行一次compareAndSwapInt操作,这次操作成功,内存值更新为3 原子操作的实现原理\nJava中的CAS操作正是利用了处理器提供的CMPXCHG指令实现的。自旋CAS实现的基本思路就是循环进行CAS操作直到操作成功为止。 在CAS中有三个操作数:分别是内存地址(在Java中可以简单理解为变量的内存地址,用V表示,要获取实时值)、旧的预期值(用A表示,[操作之前保存的])和新值(用B表示)。CAS指令执行时,当且仅当V符合旧的预期值A时,处理器才会用新值B更新V的值,否则他就不执行更新,但无论是否更新了V的值,都会返回V的旧值。(这里说的三个值,指的是逻辑概念,而不是实际概念) "},{"id":295,"href":"/zh/docs/technology/Review/java_guide/database/MySQL/ly0611lymysql-high-performance-optimization-specification-recommendations/","title":"MySQL高性能优化规范建议总结","section":"MySQL","content":" 转载自https://github.com/Snailclimb/JavaGuide(添加小部分笔记)感谢作者!\n索引优化相关\nin 代替 or not exist 代替 not in 数据库命名规范 # 所有数据库对象名称必须使用小写字母并用下划线分割 所有数据库对象名称禁止使用 MySQL 保留关键字(如果表名中包含关键字查询时,需要将其用单引号括起来) 数据库对象的命名要能做到见名识意,并且最好不要超过 32 个字符 临时库表必须以 tmp_ 为前缀并以日期为后缀,备份表必须以 bak_ 为前缀并以日期 (时间戳) 为后缀 所有存储相同数据的列名和列类型必须一致(一般作为关联列,如果查询时关联列类型不一致会自动进行数据类型隐式转换,会造成列上的索引失效,导致查询效率降低) 数据库基本设计规范 # 所有表必须使用InnoDB存储引擎 # 没有特殊要求(即 InnoDB 无法满足的功能如:列存储,存储空间数据等)的情况下,所有表必须使用 InnoDB 存储引擎(MySQL5.5 之前默认使用 Myisam,5.6 以后默认的为 InnoDB)。 InnoDB 支持事务,支持行级锁,更好的恢复性,高并发下性能更好 数据库和表的字符集统一使用UTF-8 # 兼容性更好,统一字符集可以避免由于字符集转换产生的乱码,不同的字符集进行比较前需要进行转换会造成索引失效,如果数据库中有存储 emoji 表情的需要,字符集需要采用 utf8mb4 字符集。\n参考文章:\nMySQL 字符集不一致导致索引失效的一个真实案例open in new window [MySQL 字符集详解 所有表和字段都需要添加注释 # 使用 comment 从句添加表和列的备注,从一开始就进行数据字典的维护\n尽量控制单表数据量的大小,建议控制在500万以内 # 500 万并不是 MySQL 数据库的限制,过大会造成修改表结构,备份,恢复都会有很大的问题。\n可以用历史数据归档(应用于日志数据),**分库分表(应用于业务数据)**等手段来控制数据量大小\n谨慎使用MySQL分区表 # 分区表在物理上表现为多个文件,在逻辑上表现为一个表;\n谨慎选择分区键,跨分区查询效率可能更低;\n建议采用物理分表的方式管理大数据。\n经常一起使用的列放到一个表中 # 避免更多的关联操作。\n禁止在表中建立预留字段 # 预留字段的命名很难做到见名识义。 预留字段无法确认存储的数据类型,所以无法选择合适的类型。 对预留字段类型的修改,会对表进行锁定 禁止在数据库中存储文本(比如图片)这类大的二进制数据 # 在数据库中存储文件会严重影响数据库性能,消耗过多存储空间。\n文件(比如图片)这类大的二进制数据通常存储于文件服务器,数据库只存储文件地址信息。\n不要被数据库范式所束缚 # 一般来说,设计关系数据库时需要满足第三范式,但为了满足第三范式,我们可能会拆分出多张表。而在进行查询时需要对多张表进行关联查询,有时为了提高查询效率,会降低范式的要求,在表中保存一定的冗余信息,也叫做反范式。但要注意反范式一定要适度。\n禁止在线上做数据库压力测试 # 禁止从开发环境、测试环境,直接连接生产环境数据库 # 安全隐患极大,要对生产环境抱有敬畏之心!\n数据库字段设计规范 # 优先选择符合存储需要的最小数据类型 # Byte:字节\n存储字节越小,占用也就空间越小,性能也越好。\na.某些字符串可以转换成数字类型存储比如可以将 IP 地址转换成整型数据。\n数字是连续的,性能更好,占用空间也更小。\nMySQL 提供了两个方法来处理 ip 地址\nINET_ATON() : 把 ip 转为无符号整型 (4-8 位) INET_NTOA() :把整型的 ip 转为地址 插入数据前,先用 INET_ATON() 把 ip 地址转为整型,显示数据时,使用 INET_NTOA() 把整型的 ip 地址转为地址显示即可。\nb.对于非负型的数据 (如自增 ID,整型 IP,年龄) 来说,要优先使用无符号整型来存储。\n无符号相对于有符号可以多出一倍的存储空间\nSIGNED INT -2147483648~2147483647 UNSIGNED INT 0~4294967295 c.小数值类型(比如年龄、状态表示如 0/1)优先使用 TINYINT 类型。\n避免使用TEXT、BLOB数据类型,最常见的TEXT类型可以存储64k的数据 # a. 建议把 BLOB 或是 TEXT 列分离到单独的扩展表中。\nMySQL 内存临时表不支持 TEXT、BLOB 这样的大数据类型,如果查询中包含这样的数据,在排序等操作时,就不能使用内存临时表,必须使用磁盘临时表进行。而且对于这种数据,MySQL 还是要进行二次查询,会使 sql 性能变得很差,但是不是说一定不能使用这样的数据类型。\n如果一定要使用,建议把 BLOB 或是 TEXT 列分离到单独的扩展表中,查询时一定不要使用 select *而只需要取出必要的列,不需要 TEXT 列的数据时不要对该列进行查询。\n2、TEXT 或 BLOB 类型只能使用前缀索引\n因为 MySQL 对索引字段长度是有限制的,所以 TEXT 类型只能使用前缀索引,并且 TEXT 列上是不能有默认值的\n避免使用ENUM类型 # 原因:\n修改 ENUM 值需要使用 ALTER 语句; ENUM 类型的 ORDER BY 操作效率低,需要额外操作; ENUM 数据类型存在一些限制比如建议不要使用数值作为 ENUM 的枚举值。 相关阅读: 是否推荐使用 MySQL 的 enum 类型? - 架构文摘 - 知乎open in new window\n尽可能把所有的列定义为NOT NULL # 除非有特别的原因使用 NULL 值,应该总是让字段保持 NOT NULL。\n索引 NULL 列需要额外的空间来保存,所以要占用更多的空间;\n该位指示了该行数据中是否有NULL值,有则使用1来表示,该部分占的字节大小大概为1字节,比如当NULL标志位为06时,06转换为二进制为110,表示第二列和第三列为NULL。\n进行比较和计算时要对 NULL 值做特别的处理。\n相关阅读: 技术分享 | MySQL 默认值选型(是空,还是 NULL)open in new window 。\n使用TIMESTAMP(4个字节)或DATETIME类型(8个字节)存储时间 # TIMESTAMP 存储的时间范围 1970-01-01 00:00:01 ~ 2038-01-19-03:14:07\nTIMESTAMP 占用 4 字节和 INT 相同,但比 INT 可读性高\n超出 TIMESTAMP 取值范围的使用 DATETIME 类型存储\n反之, 经常会有人用字符串存储日期型的数据**(不正确的做法)**\n缺点 1:无法用日期函数进行计算和比较 缺点 2:用字符串存储日期要占用更多的空间 同财务相关的金额类数据必须使用decimal类型 # 非精准浮点 :float,double 精准浮点 :decimal decimal 类型为精准浮点数,在计算时不会丢失精度。占用空间由定义的宽度决定,每 4 个字节可以存储 9 位数字,并且小数点要占用一个字节。并且,decimal 可用于存储比 bigint 更大的整型数据\n不过, 由于 decimal 需要额外的空间和计算开销,应该尽量只在需要对数据进行精确计算时才使用 decimal 。\n单表不要包含过多字段 # 如果一个表包含过多字段的话,可以考虑将其分解成多个表,必要时增加中间表进行关联。\n索引设计规范 # 限制每张表上的索引数量,建议单张表索引不超过5个 # 索引并不是越多越好!索引可以提高效率同样可以降低效率。\n索引可以增加查询效率,但同样也会降低插入和更新的效率,甚至有些情况下会降低查询效率。\n因为 MySQL 优化器在选择如何优化查询时,会根据统一信息,对每一个可以用到的索引来进行评估,以生成出一个最好的执行计划,如果同时有很多个索引都可以用于查询,就会增加 MySQL 优化器生成执行计划的时间,同样会降低查询性能。\n禁止使用全文索引 # 全文索引不适用于 OLTP 场景。\nOn-Line Transaction Processing联机事务处理过程(OLTP),也称为面向交易的处理过程\n禁止给表中的每一列都建立单独的索引 # 5.6 版本之前,一个 sql 只能使用到一个表中的一个索引,5.6 以后,虽然有了合并索引的优化方式,但是还是远远没有使用一个联合索引的查询方式好\n尽量使用联合索引\n每个InnoDB表必须有个主键 # InnoDB 是一种索引组织表:数据的存储的逻辑顺序和索引的顺序是相同的。每个表都可以有多个索引,但是表的存储顺序只能有一种。 InnoDB 是按照主键索引的顺序来组织表的 不要使用更新频繁的列作为主键,不适用(使用)多列主键(相当于联合索引) 不要使用 UUID,MD5,HASH,字符串列作为主键(无法保证数据的顺序增长) 主键建议使用自增 ID 值 常见索引列建议 # 出现在 SELECT、UPDATE、DELETE 语句的 WHERE 从句中的列\n包含在 ORDER BY、GROUP BY、DISTINCT 中的字段\n并不要将符合 1 和 2 中的字段的列都建立一个索引, 通常将 1、2 中的字段建立联合索引效果更好\n多表 join 的关联列\n如何选择索引列的顺序 # 建立索引的目的是:希望通过索引进行数据查找,减少随机 IO,增加查询性能 ,索引能过滤出越少的数据,则从磁盘中读入的数据也就越少。\n区分度最高的放在联合索引的最左侧(区分度=列中不同值的数量/列的总行数) 尽量把字段长度小的列放在联合索引的最左侧(因为字段长度越小,一页能存储的数据量越大,IO 性能也就越好) 使用最频繁的列放到联合索引的左侧(这样可以比较少的建立一些索引) 避免建立冗余索引和重复索引(增加了查询优化器生成执行计划的时间) # 重复索引示例:primary key(id)、index(id)、unique index(id) 冗余索引示例:index(a,b,c)、index(a,b)、index(a) 对于频繁的查询有优先考虑使用覆盖索引 # 覆盖索引:就是包含了所有查询字段 (where,select,order by,group by 包含的字段) 的索引\n覆盖索引的好处:\n避免 InnoDB 表进行索引的二次查询: InnoDB 是以聚集索引的顺序来存储的,对于 InnoDB 来说,二级索引在叶子节点中所保存的是行的主键信息,如果是用二级索引查询数据的话,在查找到相应的键值后,还要通过主键进行二次查询才能获取我们真实所需要的数据。而在覆盖索引中,二级索引的键值中可以获取所有的数据,避免了对主键的二次查询 ,减少了 IO 操作,提升了查询效率。 可以把随机 IO 变成顺序 IO 加快查询效率: 由于覆盖索引是按键值的顺序存储的,对于 IO 密集型的范围查找来说,对比随机从磁盘读取每一行的数据 IO 要少的多,因此利用覆盖索引在访问时也可以把磁盘的随机读取的 IO 转变成索引查找的顺序 IO。 索引SET规范 # 尽量避免使用外键约束\n不建议使用外键约束(foreign key),但一定要在表与表之间的关联键上建立索引 外键可用于保证数据的参照完整性,但建议在业务端实现 外键会影响父表和子表的写操作从而降低性能 数据库SQL开发规范 # 优化对性能影响较大的SQL语句 # 要找到最需要优化的 SQL 语句。要么是使用最频繁的语句,要么是优化后提高最明显的语句,可以通过查询 MySQL 的慢查询日志来发现需要进行优化的 SQL 语句;\n充分利用表上已经存在的索引 # 避免使用双%号的查询条件。如:a like '%123%',(如果无前置%,只有后置%,是可以用到列上的索引的)\n一个 SQL 只能利用到复合索引中的一列进行范围查询。如:有 a,b,c 列的联合索引,在查询条件中有 a 列的范围查询,则在 b,c 列上的索引将不会被用到。\nhttps://blog.csdn.net/qq_33589510/article/details/123038988\n(a=1 b=1 c=1) (a=1 b=2 c=1) (a=1 b=2 c=3)\n(a=2 b=2 c=3) (a=2 b=2 c=5) (a=2 b=5 c=1) (a=2 b=5 c=2)\n(a=3 b=0 c=1) (a=3 b=3 c=5) (a=3 b=8 c=6)\n假设有一条SQL为select a,b,c from table where a = 2 and b \u0026gt;1 and c = 2,那么索引c就用不到了,因为有可能b查找后c是无序的了\n在定义联合索引时,如果 a 列要用到范围查找的话,就要把 a 列放到联合索引的右侧,使用 left join 或 not exists 来优化 not in 操作,因为 not in 也通常会使用索引失效。\n这个的意思是,如果有两个列,b,a都是索引\nSELECT * FROM table WHERE a \u0026gt; 1 and b = 2;\n对于上面这句,如果建立(a,b),那么只有a会用得到。而如果建立(b,a),则都能用上 (如果没有b= 2,那么(b,a)索引就用不上了)\n禁止使用SELECT * 必须使用SELECT \u0026lt;字段列表\u0026gt; 查询 # SELECT * 消耗更多的 CPU 和 IO 以网络带宽资源 SELECT * 无法使用覆盖索引 SELECT \u0026lt;字段列表\u0026gt; 可减少表结构变更带来的影响 禁止使用不含字段列表的INSERT语句 # 如:\ninsert into t values (\u0026#39;a\u0026#39;,\u0026#39;b\u0026#39;,\u0026#39;c\u0026#39;); 应使用:\ninsert into t(c1,c2,c3) values (\u0026#39;a\u0026#39;,\u0026#39;b\u0026#39;,\u0026#39;c\u0026#39;); 建议使用预编译语句进行数据库操作 # (这里应该是针对jdbc,不是指mybatis的情况)\n例子:\nMySQL执行预编译分为如三步:\n执行预编译语句,例如:prepare myfun from \u0026lsquo;select * from t_book where bid=?\u0026rsquo;\n设置变量,例如:set @str=\u0026lsquo;b1\u0026rsquo;\n执行语句,例如:execute myfun using @str\n如果需要再次执行myfun,那么就不再需要第一步,即不需要再编译语句了:\n设置变量,例如:set @str=\u0026lsquo;b2\u0026rsquo; 执行语句,例如:execute myfun using @str 通过查看MySQL日志可以看到执行的过程:\n使用Statement执行预编译\n**使用Statement执行预编译就是把上面的SQL语句执行一次。 **\nConnection con = JdbcUtils.getConnection(); Statement stmt = con.createStatement(); stmt.executeUpdate(\u0026#34;prepare myfun from \u0026#39;select * from t_book where bid=?\u0026#39;\u0026#34;); stmt.executeUpdate(\u0026#34;set @str=\u0026#39;b1\u0026#39;\u0026#34;); ResultSet rs = stmt.executeQuery(\u0026#34;execute myfun using @str\u0026#34;); while(rs.next()) { System.out.print(rs.getString(1) + \u0026#34;, \u0026#34;); System.out.print(rs.getString(2) + \u0026#34;, \u0026#34;); System.out.print(rs.getString(3) + \u0026#34;, \u0026#34;); System.out.println(rs.getString(4)); } stmt.executeUpdate(\u0026#34;set @str=\u0026#39;b2\u0026#39;\u0026#34;); rs = stmt.executeQuery(\u0026#34;execute myfun using @str\u0026#34;); while(rs.next()) { System.out.print(rs.getString(1) + \u0026#34;, \u0026#34;); System.out.print(rs.getString(2) + \u0026#34;, \u0026#34;); System.out.print(rs.getString(3) + \u0026#34;, \u0026#34;); System.out.println(rs.getString(4)); } rs.close(); stmt.close(); con.close(); useServerPrepStmts参数\n默认使用PreparedStatement是不能执行预编译的,这需要在url中给出useServerPrepStmts=true参数(MySQL Server 4.1之前的版本是不支持预编译的,而Connector/J在5.0.5以后的版本,默认是没有开启预编译功能的)。\n例如:jdbc:mysql://localhost:3306/test?useServerPrepStmts=true\n预编译语句可以重复使用这些计划,减少 SQL 编译所需要的时间,还可以解决动态 SQL 所带来的 SQL 注入的问题。\n只传参数,比传递 SQL 语句更高效。\n相同语句可以一次解析,多次使用,提高处理效率。\n避免数据类型的隐式转换 # 隐式转换会导致索引失效如: 这里id应该不是字符型(但是这个好像是例外,如果字段是数字,而查询的是字符,索引还是有效的)\nselect name,phone from customer where id = \u0026#39;111\u0026#39;; 详细解读可以看: MySQL 中的隐式转换造成的索引失效 这篇文章\n避免使用子查询,可以把子查询优化为join操作 # 通常子查询在 in 子句中,且子查询中为简单 SQL(不包含 union、group by、order by、limit 从句) 时,才可以把子查询转化为关联查询进行优化。\n子查询性能差的原因: 子查询的结果集无法使用索引,通常子查询的结果集会被存储到临时表中,不论是内存临时表还是磁盘临时表都不会存在索引,所以查询性能会受到一定的影响。特别是对于返回结果集比较大的子查询,其对查询性能的影响也就越大。由于子查询会产生大量的临时表也没有索引,所以会消耗过多的 CPU 和 IO 资源,产生大量的慢查询。\n避免JOIN关联太多的表 # 对于 MySQL 来说,是存在关联缓存的,缓存的大小可以由 join_buffer_size 参数进行设置。\n在 MySQL 中,对于同一个 SQL 多关联(join)一个表,就会多分配一个关联缓存,如果在一个 SQL 中关联的表越多,所占用的内存也就越大。\n如果程序中大量的使用了多表关联的操作,同时 join_buffer_size 设置的也不合理的情况下,就容易造成服务器内存溢出的情况,就会影响到服务器数据库性能的稳定性。\n同时对于关联操作来说,会产生临时表操作,影响查询效率,MySQL 最多允许关联 61 个表,建议不超过 5 个。\n减少同数据库的交互次数 # 数据库更适合处理批量操作,合并多个相同的操作到一起,可以提高处理效率。\n对应同一列进行or判断时,使用in代替or # in 的值不要超过 500 个,in 操作可以更有效的利用索引,or 大多数情况下很少能利用到索引。\n禁止使用order by rand() 进行随机排序 # order by rand() 会把表中所有符合条件的数据装载到内存中,然后在内存中对所有数据根据随机生成的值进行排序,并且可能会对每一行都生成一个随机值,如果满足条件的数据集非常大,就会消耗大量的 CPU 和 IO 及内存资源。\n推荐在程序中获取一个随机值,然后从数据库中获取数据的方式。\nWHERE从句中禁止对列进行函数转换和计算 # 对列进行函数转换或计算时会导致无法使用索引\n不推荐:\nwhere date(create_time)=\u0026#39;20190101\u0026#39; 推荐:\nwhere create_time \u0026gt;= \u0026#39;20190101\u0026#39; and create_time \u0026lt; \u0026#39;20190102\u0026#39; 在明显不会有重复值时使用UNION ALL 而不是 UNION # UNION 会把两个结果集的所有数据放到临时表中后再进行去重操作 UNION ALL 不会再对结果集进行去重操作 拆分复杂的大SQL为多个小SQL # 大 SQL 逻辑上比较复杂,需要占用大量 CPU 进行计算的 SQL MySQL 中,一个 SQL 只能使用一个 CPU 进行计算 SQL 拆分后可以通过并行执行来提高处理效率 程序连接不同的数据库使用不同的账号,禁止跨库查询 # 为数据库迁移和分库分表留出余地 降低业务耦合度 避免权限过大而产生的安全风险 数据库操作行为规范 # 超 100 万行的批量写 (UPDATE,DELETE,INSERT) 操作,要分批多次进行操作 # 大批量操作可能会造成严重的主从延迟\n主从环境中,大批量操作可能会造成严重的主从延迟,大批量的写操作一般都需要执行一定长的时间,而只有当主库上执行完成后,才会在其他从库上执行,所以会造成主库与从库长时间的延迟情况\nbinlog 日志为 row 格式时会产生大量的日志\n大批量写操作会产生大量日志,特别是对于 row 格式二进制数据而言,由于在 row 格式中会记录每一行数据的修改,我们一次修改的数据越多,产生的日志量也就会越多,日志的传输和恢复所需要的时间也就越长,这也是造成主从延迟的一个原因\n避免产生大事务操作\n大批量修改数据,一定是在一个事务中进行的,这就会造成表中大批量数据进行锁定,从而导致大量的阻塞,阻塞会对 MySQL 的性能产生非常大的影响。\n特别是长时间的阻塞会占满所有数据库的可用连接,这会使生产环境中的其他应用无法连接到数据库,因此一定要注意大批量写操作要进行分批\n对于大表使用 pt-online-schema-change 修改表结构 # 避免大表修改产生的主从延迟 避免在对表字段进行修改时进行锁表 对大表数据结构的修改一定要谨慎,会造成严重的锁表操作,尤其是生产环境,是不能容忍的。\npt-online-schema-change 它会首先建立一个与原表结构相同的新表,并且在新表上进行表结构的修改,然后再把原表中的数据复制到新表中,并在原表中增加一些触发器。把原表中新增的数据也复制到新表中,在行所有数据复制完成之后,把新表命名成原表,并把原来的表删除掉。\n把原来一个 DDL 操作,分解成多个小的批次进行。\n禁止为程序使用的账号赋予 super 权限 # 当达到最大连接数限制时,还运行 1 个有 super 权限的用户连接 super 权限只能留给 DBA 处理问题的账号使用 对于程序连接数据库账号,遵循权限最小原则 # 程序使用数据库账号只能在一个 DB 下使用,不准跨库 程序使用的账号原则上不准有 drop 权限 "},{"id":296,"href":"/zh/docs/technology/Review/java_guide/database/MySQL/ly0610lymysql-questions-01/","title":"MySQL常见面试题总结","section":"MySQL","content":" 转载自https://github.com/Snailclimb/JavaGuide(添加小部分笔记)感谢作者!====\nMySQL基础 # 关系型数据库介绍 # 关系型数据库,建立在关系模型的基础上的数据库。表明数据库中所存储的数据之间的联系(一对一、一对多、多对多) 关系型数据库中,我们的数据都被存放在各种表中(比如用户表),表中的每一行存放着一条数据(比如一个用户的信息) 大部分关系型数据库都使用SQL来操作数据库中的数据,并且大部分关系型数据库都支持事务的四大特性(ACID) 常见的关系型数据库\nMySQL、PostgreSQL、Oracle、SQL Server、SQLite(微信本地的聊天记录的存储就是用的 SQLite) \u0026hellip;\u0026hellip;\nMySQL介绍 # MySQL是一种关系型数据库,主要用于持久化存储我们系统中的一些数据比如用户信息\n由于 MySQL 是开源免费并且比较成熟的数据库,因此,MySQL 被大量使用在各种系统中。任何人都可以在 GPL(General Public License 通用性公开许可证) 的许可下下载并根据个性化的需要对其进行修改。MySQL 的默认端口号是3306。\nMySQL基础架构 # MySQL的一个简要机构图,客户端的一条SQL语句在MySQL内部如何执行 MySQL主要由几部分构成 连接器:身份认证和权限相关(登录MySQL的时候) 查询缓存:执行查询语句的时候,会先查询缓存(MySQL8.0版本后移除,因为这个功能不太实用) 分析器:没有命中缓存的话,SQL语句就会经过分析器,分析器说白了就是要先看你的SQL语句要干嘛,再检查你的SQL语句语法是否正确 优化器:按照MySQL认为最优的方案去执行 执行器:执行语句,然后从存储引擎返回数据。执行语句之前会先判断是否有权限,如果没有权限,就会报错 插件式存储引擎:主要负责数据的存储和读取,采用的是插件式架构,支持InnoDB、MyISAM、Memory等多种存储引擎 MySQL存储引擎 # MySQL核心在于存储引擎\nMySQL支持哪些存储引擎?默认使用哪个? # MySQL支持多种存储引擎,可以通过show engines命令来查看MySQL支持的所有存储引擎 默认存储引擎为InnoDB,并且,所有存储引擎中只有InnoDB是事务性存储引擎,也就是说只有InnoDB支持事务\n这里使用MySQL 8.x MySQL 5.5.5之前,MyISAM是MySQL的默认存储引擎;5.5.5之后,InnoDB是MySQL的默认存储引擎,可以通过select version()命令查看你的MySQL版本\nmysql\u0026gt; select version(); +-----------+ | version() | +-----------+ | 8.0.27 | +-----------+ 1 row in set (0.00 sec) 使用show variables like %storage_engine%命令直接查看MySQL当前默认的存储引擎 如果只想查看数据库中某个表使用的存储引擎的话,可以使用show table status from db_name where name = 'table_name'命令\n如果你想要深入了解每个存储引擎以及它们之间的区别,推荐你去阅读以下 MySQL 官方文档对应的介绍(面试不会问这么细,了解即可):\nInnoDB 存储引擎详细介绍:https://dev.mysql.com/doc/refman/8.0/en/innodb-storage-engine.html 。 其他存储引擎详细介绍:https://dev.mysql.com/doc/refman/8.0/en/storage-engines.html 。 MySQL存储引擎架构了解吗? # MySQL 存储引擎采用的是插件式架构,支持多种存储引擎,我们甚至可以为不同的数据库表设置不同的存储引擎以适应不同场景的需要。存储引擎是基于表的,而不是数据库 可以根据 MySQL 定义的存储引擎实现标准接口来编写一个属于自己的存储引擎。这些非官方提供的存储引擎可以称为第三方存储引擎,区别于官方存储引擎 像目前最常用的 InnoDB 其实刚开始就是一个第三方存储引擎,后面由于过于优秀,其被 Oracle 直接收购了。\nMySQL 官方文档也有介绍到如何编写一个自定义存储引擎,地址:https://dev.mysql.com/doc/internals/en/custom-engine.html\nMyISAM和InnoDB的区别是什么? # ISAM全称:Indexed Sequential Access Method(索引 顺序 访问 方法) 虽然,MyISAM 的性能还行,各种特性也还不错(比如全文索引、压缩、空间函数等)。但是,MyISAM 不支持事务和行级锁,而且最大的缺陷就是崩溃后无法安全恢复 是否支持行级锁 MyISAM 只有表级锁(table-level locking),而 InnoDB 支持行级锁(row-level locking)和表级锁,默认为行级锁。\nMyISAM 一锁就是锁住了整张表,这在并发写的情况下是多么滴憨憨啊!这也是为什么 InnoDB 在并发写的时候,性能更牛皮了!\n是否支持事务\nMyISAM不支持事务,InnoDB提供事务支持\nInnoDB实现了SQL标准,定义了四个隔离级别,具有提交(commit)和回滚(rollback)事务的能力 InnoDB默认使用的REPEATABLE-READ(可重复读)隔离级别是可以解决幻读问题发生的(部分幻读),基于MVCC和Next-Key Lock(间隙锁) 详细可以查看MySQL 事务隔离级别详解\n是否支持外键\nMyISAM不支持,而InnoDB支持\n外键对于维护数据一致性非常有帮助,但是对性能有一定的损耗。因此,通常情况下,我们是不建议在实际生产项目中使用外键的,在业务代码中进行约束即可!\n阿里的《Java 开发手册》也是明确规定禁止使用外键的。\n不过,在代码中进行约束的话,对程序员的能力要求更高,具体是否要采用外键还是要根据你的项目实际情况而定\n一般我们也是不建议在数据库层面使用外键的,应用层面可以解决。不过,这样会对数据的一致性造成威胁。具体要不要使用外键还是要根据你的项目来决定 是否支持数据库异常崩溃后的安全恢复 MyISAM 不支持,而 InnoDB 支持。\n使用 InnoDB 的数据库在异常崩溃后,数据库重新启动的时候会保证数据库恢复到崩溃前的状态。这个恢复的过程依赖于 redo log 是否支持MVCC MyISAM 不支持,而 InnoDB 支持。\nMyISAM 连行级锁都不支持。MVCC 可以看作是行级锁的一个升级,可以有效减少加锁操作,提高性能。 索引实现不一样\n虽然 MyISAM 引擎和 InnoDB 引擎都是使用 B+Tree 作为索引结构,但是两者的实现方式不太一样。 InnoDB 引擎中,其数据文件本身就是索引文件。而 MyISAM中,索引文件和数据文件是分离的 InnoDB引擎中,表数据文件本身就是按 B+Tree 组织的一个索引结构,树的叶节点 data 域保存了完整的数据记录。 详细区别,推荐 : MySQL 索引详解\nMyISAM和InnoDB 如何选择 # 大多数时候我们使用的都是 InnoDB 存储引擎,在某些读密集的情况下,使用 MyISAM 也是合适的。不过,前提是你的项目不介意 MyISAM 不支持事务、崩溃恢复等缺点(可是~我们一般都会介意啊!)\n《MySQL 高性能》上面有一句话这样写到:\n不要轻易相信“MyISAM 比 InnoDB 快”之类的经验之谈,这个结论往往不是绝对的。在很多我们已知场景中,InnoDB 的速度都可以让 MyISAM 望尘莫及,尤其是用到了聚簇索引,或者需要访问的数据都可以放入内存的应用。\n一般情况下我们选择 InnoDB 都是没有问题的,但是某些情况下你并不在乎可扩展能力和并发能力,也不需要事务支持,也不在乎崩溃后的安全恢复问题的话,选择 MyISAM 也是一个不错的选择。但是一般情况下,我们都是需要考虑到这些问题的。\n对于咱们日常开发的业务系统来说,你几乎找不到什么理由再使用 MyISAM 作为自己的 MySQL 数据库的存储引擎\nMySQL 索引 # MySQL 索引相关的问题比较多,对于面试和工作都比较重要,于是,我单独抽了一篇文章专门来总结 MySQL 索引相关的知识点和问题: MySQL 索引详解]\nMySQL查询缓存 # 执行查询语句的时候,会先查询缓存。不过**,MySQL 8.0 版本后移除**,因为这个功能不太实用\nmy.cnf 加入以下配置,重启 MySQL 开启查询缓存\nquery_cache_type=1 query_cache_size=600000 执行以下命令也可以开启查询缓存\nset global query_cache_type=1; set global query_cache_size=600000; 开启查询缓存后在同样的查询条件以及数据情况下,会直接在缓存中返回结果:\n查询条件包括查询本身、当前要查询的数据库、客户端协议版本号等一些可能影响结果的信息\n查询缓存不命中的情况:\n任何两个查询在任何字符上的不同都会导致缓存不命中 如果查询中包含任何用户自定义函数、存储函数、用户变量、临时表、MySQL 库中的系统表,其查询结果也不会被缓存 缓存建立之后,MySQL 的查询缓存系统会跟踪查询中涉及的每张表,如果这些表(数据或结构)发生变化,那么和这张表相关的所有缓存数据都将失效 缓存虽然能够提升数据库的查询性能,但是缓存同时也带来了额外的开销,每次查询后都要做一次缓存操作,失效后还要销毁。 因此,开启查询缓存要谨慎,尤其对于写密集的应用来说更是如此。如果开启,要注意合理控制缓存空间大小,一般来说其大小设置为几十 MB 比较合适。此外,**还可以通过 sql_cache 和 sql_no_cache 来控制某个查询语句是否需要缓存\nselect sql_no_cache count(*) from usr; MySQL事务 # 何谓事务 # 我们设想一个场景,这个场景中我们需要插入多条相关联的数据到数据库,不幸的是,这个过程可能会遇到下面这些问题:\n数据库中途突然因为某些原因挂掉了。 客户端突然因为网络原因连接不上数据库了。 并发访问数据库时,多个线程同时写入数据库,覆盖了彼此的更改。 \u0026hellip;\u0026hellip; 上面的任何一个问题都可能会导致数据的不一致性。为了保证数据的一致性,系统必须能够处理这些问题。事务就是我们抽象出来简化这些问题的首选机制。事务的概念起源于数据库,目前,已经成为一个比较广泛的概念\n事务是逻辑上的一组操作,要么都执行,要么都不执行\n最经典的就是转账,假如小明要给小红转账1000元,这个转账涉及到两个关键操作,这两个操作必须都成功或者都失败\n将小明的余额减少1000元 将小红的余额增加1000元 事务会把两个操作看成逻辑上的一个整体,这个整体包含的操作要么都成功,要么都失败。这样就不会出现小明余额减少而小红余额却没有增加的情况\n何谓数据库事务 # 多数情况下,我们谈论事务的时候,如果没有特指分布式事务,往往指的是数据库事务\n数据库事务在日常开发中接触最多,如果项目属于单体架构,接触的往往就是数据库事务\n数据库事务的作用\n可以保证多个对数据库的操作(也就是SQL语句)构成一个逻辑上的整体,构成这个逻辑上整体的这些数据库操作遵循:要么全部执行成功,要么全部不执行\n# 开启一个事务 START TRANSACTION; # 多条 SQL 语句 SQL1,SQL2... ## 提交事务 COMMIT; 关系型数据库(比如MySQL、SQLServer、Oracle等)事务都有ACID特性\n原子性(Atomicity) : 事务是最小的执行单位,不允许分割。事务的原子性确保动作要么全部完成,要么完全不起作用; 一致性(Consistency): 执行事务前后,数据保持一致,例如转账业务中,无论事务是否成功,转账者和收款人的总额应该是不变的;(其实一致性是结果) 隔离性(Isolation): 并发访问数据库时,一个用户的事务不被其他事务所干扰,各并发事务之间数据库是独立的; 持久性(Durability): 一个事务被提交之后。它对数据库中数据的改变是持久的,即使数据库发生故障也不应该对其有任何影响。 只有保证了事务的持久性、原子性、隔离性之后,一致性才能得到保障。也就是说 A、I、D 是手段,C 是目的! 想必大家也和我一样,被 ACID 这个概念被误导了很久! 我也是看周志明老师的公开课 《周志明的软件架构课》open in new window才搞清楚的(多看好书!!)\n另外,DDIA 也就是 《Designing Data-Intensive Application(数据密集型应用系统设计)》open in new window 的作者在他的这本书中如是说:\nAtomicity, isolation, and durability are properties of the database, whereas consis‐ tency (in the ACID sense) is a property of the application. The application may rely on the database’s atomicity and isolation properties in order to achieve consistency, but it’s not up to the database alone.\n翻译过来的意思是:原子性,隔离性和持久性是数据库的属性,而一致性(在 ACID 意义上)是应用程序的属性。应用可能依赖数据库的原子性和隔离属性来实现一致性,但这并不仅取决于数据库。因此,字母 C 不属于 ACID 《Designing Data-Intensive Application(数据密集型应用系统设计)》这本书强推一波,值得读很多遍!豆瓣有接近 90% 的人看了这本书之后给了五星好评。另外,中文翻译版本已经在 Github 开源,地址: https://github.com/Vonng/ddiaopen in new window\n并发事务带来了哪些问题 # 典型应用程序中,多个事务并发运行,经常会操作相同数据来完成各自任务(多个用户对统一数据进行操作)。并发虽然是必须的,但是会导致一下的问题\n脏读(Dirty read) **\n一个事务读取数据并且对数据进行了修改,这个修改对其他事务来说是可见的(其实就是读未提交),即使当前事务没有提交。这时另外一个事务读取了这个还未提交的数据,但第一个事务突然回滚,导致数据并没有被提交到数据库,那第二个事务读取到的就是脏数据**,这也就是脏读的由来。\n例如:事务 1 读取某表中的数据 A=20,事务 1 修改 A=A-1,事务 2 读取到 A = 19,事务 1 回滚导致对 A 的修改并为提交到数据库, A 的值还是 20\n丢失修改(Lost to modify) 在一个事务读取一个数据时,另外一个事务也访问了该数据,那么在第一个事务中修改了这个数据后,第二个事务也修改了这个数据。这样第一个事务内的修改结果就被丢失,因此称为丢失修改。\n例如:事务 1 读取某表中的数据 A=20,事务 2 也读取 A=20,事务 1 先修改 A=A-1,事务 2 后来也修改 A=A-1,最终结果 A=19,事务 1 的修改被丢失。 (这里例子举得不好,用事务2进行了A = A - 2 操作会比较明显) 不可重复读(Unrepeatable read)\n指在一个事务内多次读同一数据。在这个事务还没有结束时,另一个事务也访问该数据。那么,在第一个事务中的两次读数据之间,由于第二个事务的修改导致第一个事务两次读取的数据可能不太一样。这就发生了在一个事务内两次读到的数据是不一样的情况,因此称为不可重复读。\n例如:事务 1 读取某表中的数据 A=20,事务 2 也读取 A=20,事务 1 修改 A=A-1,事务 2 再次读取 A =19,此时读取的结果和第一次读取的结果不同。\n幻读\n幻读与不可重复读类似。它发生在一个事务读取了几行数据,接着另一个并发事务插入了一些数据时。在随后的查询中,第一个事务就会发现多了一些原本不存在的记录,就好像发生了幻觉一样,所以称为幻读。\n例如:事务 2 读取某个范围的数据,事务 1 在这个范围插入了新的数据,事务 1 再次读取这个范围的数据发现相比于第一次读取的结果多了新的数据。\n不可重复读和幻读有什么区别 # 不可重复读的重点是内容修改或者记录减少。比如多次读取一条记录发现其中某些记录的值被修改;\n幻读的重点在于记录新增比如多次执行同一条查询语句(DQL)时,发现查到的记录增加了。\n幻读其实可以看作是不可重复读的一种特殊情况,单独把区分幻读的原因主要是解决幻读和不可重复读的方案不一样。\n举个例子:执行 delete 和 update 操作的时候,可以直接对记录加锁,保证事务安全。而执行 insert 操作的时候,由于记录锁(Record Lock)只能锁住已经存在的记录,为了避免插入新记录,需要依赖间隙锁(Gap Lock)。也就是说执行 insert 操作的时候需要依赖 Next-Key Lock(Record Lock+Gap Lock) 进行加锁来保证不出现幻读。 (这里说的是完全解决幻读,其实也可以依靠MVCC部分解决幻读) 使用MVCC机制(只在事务第一次select的时候生成ReadView解决不可重复读的问题) SQL标准定义了哪些事务隔离级别 # SQL标准定义了**四个隔离级别 **\nREAD-UNCOMMITTED(读取未提交) : 最低的隔离级别,允许读取尚未提交的数据变更,可能会导致脏读、幻读或不可重复读。 READ-COMMITTED(读取已提交) : 允许读取并发事务已经提交的数据,可以阻止脏读,但是幻读或不可重复读仍有可能发生。 REPEATABLE-READ(可重复读) : 对同一字段的多次读取结果都是一致的,除非数据是被本身事务自己所修改,可以阻止脏读和不可重复读,但幻读仍有可能发生。 SERIALIZABLE(可串行化) : 最高的隔离级别,完全服从 ACID 的隔离级别。所有的事务依次逐个执行,这样事务之间就完全不可能产生干扰,也就是说,该级别可以防止脏读、不可重复读以及幻读。 隔离级别 脏读 不可重复读 幻读 READ-UNCOMMITTED √ √ √ READ-COMMITTED × √ √ REPEATABLE-READ × × √ SERIALIZABLE × × × MySQL的隔离级别是基于锁实现的吗 # MySQL 的隔离级别基于锁和 MVCC 机制共同实现的。\nSERIALIZABLE 隔离级别,是通过锁来实现的。除了 SERIALIZABLE 隔离级别,其他的隔离级别都是基于 MVCC 实现。 不过, SERIALIZABLE 之外的其他隔离级别可能也需要用到锁机制,就比如 REPEATABLE-READ 在当前读情况下需要使用加锁读来保证不会出现幻读(这就是MVCC不能解决幻读的例外之一)。 上述总结 # MySQL的默认隔离级别是什么 # MySQL InnoDB 存储引擎的默认支持的隔离级别是 REPEATABLE-READ(可重读)。我们可以通过**SELECT @@tx_isolation;**命令来查看,MySQL 8.0 该命令改为SELECT @@transaction_isolation;\nmysql\u0026gt; SELECT @@tx_isolation; +-----------------+ | @@tx_isolation | +-----------------+ | REPEATABLE-READ | +-----------------+ ------ 关于 MySQL 事务隔离级别的详细介绍,可以看看我写的这篇文章: MySQL 事务隔离级别详解\nMySQL锁 # 表级锁和行级锁了解吗?有什么区别 # MyISAM 仅仅支持表级锁(table-level locking),一锁就锁整张表,这在并发写的情况下性非常差。 InnoDB 不光支持表级锁(table-level locking),还支持行级锁(row-level locking),默认为行级锁。行级锁的粒度更小,仅对相关的记录上锁即可(对一行或者多行记录加锁),所以对于并发写入操作来说, InnoDB 的性能更高。 表级锁和行级锁对比 :\n表级锁: MySQL 中锁定粒度最大的一种锁(全局锁除外),是针对非索引字段加的锁,对当前操作的整张表加锁,实现简单,资源消耗也比较少,加锁快,不会出现死锁。其锁定粒度最大,触发锁冲突的概率最高,并发度最低,MyISAM 和 InnoDB 引擎都支持表级锁。\n行级锁: MySQL 中锁定粒度最小的一种锁,是针对索引字段加的锁,只针对当前操作的行记录进行加锁。 行级锁能大大减少数据库操作的冲突。其加锁粒度最小,并发度高,但加锁的开销也最大,加锁慢,会出现死锁。\n行级锁的使用有什么注意事项 # InnoDB 的行锁是针对索引字段加的锁,表级锁是针对非索引字段加的锁。\n当我们执行 UPDATE、DELETE 语句时,如果 WHERE条件中字段没有命中唯一索引或者索引失效的话,就会导致扫描全表对表中的所有行记录进行加锁。这个在我们日常工作开发中经常会遇到,一定要多多注意!!!\n不过,很多时候即使用了索引也有可能会走全表扫描,这是因为 MySQL 优化器的原因。\n共享锁和排他锁 # 不论是表级锁还是行级锁,都存在**共享锁(Share Lock,S 锁)和排他锁(Exclusive Lock,X 锁)**这两类\n共享锁(S 锁) :又称读锁,事务在读取记录的时候获取共享锁,允许多个事务同时获取(锁兼容)。 排他锁(X 锁) :又称写锁/独占锁,事务在修改记录的时候获取排他锁,不允许多个事务同时获取。如果一个记录已经被加了排他锁,那其他事务不能再对这条事务加任何类型的锁**(锁不兼容)**。 排他锁与任何的锁都不兼容,共享锁仅和共享锁兼容。\nS 锁 X 锁 S 锁 不冲突 冲突 X 锁 冲突 冲突 由于 MVCC 的存在,对于一般的 SELECT 语句,InnoDB 不会加任何锁。不过, 你可以通过以下语句显式加共享锁或排他锁:\n# 共享锁 SELECT ... LOCK IN SHARE MODE; # 排他锁 SELECT ... FOR UPDATE; 意向锁有什么作用 # ★★ 重点 :如果需要用到表锁的话,如何判断表中的记录没有行锁呢?一行一行遍历肯定是不行,性能太差。我们需要用到一个叫做意向锁的东东来快速判断是否可以对某个表使用表锁。\n意向锁是表级锁(这句话很重要,意向锁是描述某个表的某个属性(这个表是否有记录加了共享锁/或者排他锁)),共有两种:\n意向共享锁(Intention Shared Lock,IS 锁):事务有意向对表中的某些记录加共享锁(S 锁),加共享锁前必须先取得该表的 IS 锁。\n意向排他锁(Intention Exclusive Lock,IX 锁):事务有意向对表中的某些记录加排他锁(X 锁),加排他锁之前必须先取得该表的 IX 锁。\n意向锁是有数据引擎自己维护的,用户无法手动操作意向锁,在为数据行加共享 / 排他锁之前,InooDB 会先获取(如果获取到了,其实就是“加了锁”)该数据行所在在数据表的对应意向锁。\n意向锁之间是互相兼容的 :\n理由很简单,表里某一条记录加了排他锁(即这个表加了意向排他锁),不代表不能操作其他记录\nIS 锁 IX 锁 IS 锁 兼容 兼容 IX 锁 兼容 兼容 意向锁和共享锁和排它锁互斥(这里指的是表级别的共享锁和排他锁,意向锁不会与行级的共享锁和排他锁互斥,★★括号里这句话极其重要,要不然就看不懂下面的表了)。\nIS 锁 IX 锁 S 锁 兼容 互斥 X 锁 互斥 互斥 《MySQL 技术内幕 InnoDB 存储引擎》这本书对应的描述应该是笔误了。\nInnoDB 有哪几类行锁 # MySQL InnoDB 支持三种行锁定方式:\n记录锁(Record Lock) :也被称为记录锁,属于单个行记录上的锁。 间隙锁(Gap Lock) :锁定一个范围,不包括记录本身。 临键锁(Next-key Lock) :Record Lock+Gap Lock,锁定一个范围,包含记录本身。记录锁只能锁住已经存在的记录,为了避免插入新记录,需要依赖间隙锁。 InnoDB 的默认隔离级别 RR(可重读)是可以解决幻读问题发生的,主要有下面两种情况:\n快照读(一致性非锁定读) :由 MVCC 机制来保证不出现幻读。 当前读 (一致性锁定读): 使用 Next-Key Lock 进行加锁来保证不出现幻读。 当前读和快照读有什么区别 # 快照读(一致性非锁定读)就是单纯的 SELECT 语句,但不包括下面这两类 SELECT 语句:\nSELECT ... FOR UPDATE SELECT ... LOCK IN SHARE MODE 快照即记录的历史版本,每行记录可能存在多个历史版本(多版本技术)。\n快照读的情况下,如果读取的记录正在执行 UPDATE/DELETE 操作,读取操作不会因此去等待记录上 X 锁的释放,而是会去读取行的一个快照。\n只有在事务隔离级别 RC(读取已提交,ReadCommit) 和 **RR(可重读,RepeatableCommit)**下,InnoDB 才会使用一致性非锁定读:\n在 RC 级别下,对于快照数据,一致性非锁定读总是读取被锁定行的最新一份(可见)快照数据。 在 RR 级别下,对于快照数据,一致性非锁定读总是读取本事务开始时的行数据版本。 快照读比较适合对于数据一致性要求不是特别高且追求极致性能的业务场景。\n当前读 (一致性锁定读)就是给行记录加 X 锁或 S 锁。(使用当前读的话在RR级别下就无法解决幻读)\n当前读的一些常见 SQL 语句类型如下:\n# 对读的记录加一个X锁 SELECT...FOR UPDATE # 对读的记录加一个S锁 SELECT...LOCK IN SHARE MODE # 对修改的记录加一个X锁 INSERT... UPDATE... DELETE... MySQL 性能优化 # 关于 MySQL 性能优化的建议总结,请看这篇文章: MySQL 高性能优化规范建议总结\n能用MySQL直接存储文件(比如图片)吗 # 可以是可以,直接存储文件对应的二进制数据即可。不过,还是建议不要在数据库中存储文件,会严重影响数据库性能,消耗过多存储空间。\n数据库只存储文件地址信息,文件由文件存储服务负责存储。\n可以选择使用云服务厂商提供的开箱即用的文件存储服务,成熟稳定,价格也比较低。 也可以选择自建文件存储服务,实现起来也不难,基于 FastDFS、MinIO(推荐) 等开源项目就可以实现分布式文件服务。\n相关阅读: Spring Boot 整合 MinIO 实现分布式文件服务\nMySQL如何存储IP 地址 # 可以将 IP 地址转换成整形数据存储,性能更好,占用空间也更小。\nMySQL 提供了两个方法来处理 ip 地址\nINET_ATON() : 把 ip 转为无符号整型 (4-8 位) INET_NTOA() :把整型的 ip 转为地址 插入数据前,先用 INET_ATON() 把 ip 地址转为整型,显示数据时,使用 INET_NTOA() 把整型的 ip 地址转为地址显示即可\n有哪些常见的SQL优化手段吗 # 《Java 面试指北》open in new window 的「技术面试题篇」有一篇文章详细介绍了常见的 SQL 优化手段,非常全面,清晰易懂!\n"},{"id":297,"href":"/zh/docs/technology/Review/java_guide/database/MySQL/ly0609lyindex-invalidation-caused-by-implicit-conversion/","title":"MySQL中的隐式转换造成的索引失效","section":"MySQL","content":" 转载自https://github.com/Snailclimb/JavaGuide(添加小部分笔记)感谢作者!\n本篇文章基于MySQL 5.7.26,原文:https://www.guitu18.com/post/2019/11/24/61.html\n前言 # 关于数据库优化,最常见的莫过于索引失效,数据量多的时候比较明显,处理不及时会造成雪球效应,最终导致数据库卡死甚至瘫痪。 这里说的是隐式转换造成的索引失效 数据准备 # -- 创建测试数据表 DROP TABLE IF EXISTS test1; CREATE TABLE `test1` ( `id` int(11) NOT NULL, `num1` int(11) NOT NULL DEFAULT \u0026#39;0\u0026#39;, `num2` varchar(11) NOT NULL DEFAULT \u0026#39;\u0026#39;, `type1` int(4) NOT NULL DEFAULT \u0026#39;0\u0026#39;, `type2` int(4) NOT NULL DEFAULT \u0026#39;0\u0026#39;, `str1` varchar(100) NOT NULL DEFAULT \u0026#39;\u0026#39;, `str2` varchar(100) DEFAULT NULL, PRIMARY KEY (`id`), KEY `num1` (`num1`), KEY `num2` (`num2`), KEY `type1` (`type1`), KEY `str1` (`str1`), KEY `str2` (`str2`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8; -- 创建存储过程 DROP PROCEDURE IF EXISTS pre_test1; DELIMITER // CREATE PROCEDURE `pre_test1`() BEGIN DECLARE i INT DEFAULT 0; SET autocommit = 0; WHILE i \u0026lt; 10000000 DO SET i = i + 1; SET @str1 = SUBSTRING(MD5(RAND()),1,20); -- 每100条数据str2产生一个null值 IF i % 100 = 0 THEN SET @str2 = NULL; ELSE SET @str2 = @str1; END IF; INSERT INTO test1 (`id`, `num1`, `num2`, `type1`, `type2`, `str1`, `str2`) VALUES (CONCAT(\u0026#39;\u0026#39;, i), CONCAT(\u0026#39;\u0026#39;, i), CONCAT(\u0026#39;\u0026#39;, i), i%5, i%5, @str1, @str2); -- 事务优化,每一万条数据提交一次事务 IF i % 10000 = 0 THEN COMMIT; END IF; END WHILE; END; // DELIMITER ; -- 执行存储过程 CALL pre_test1(); 其中,七个字段,首先使用存储过程生成 1000 万条测试数据, 测试表一共建立了 7 个字段(包括主键),num1和num2保存的是和ID一样的顺序数字,其中num2是字符串类型。 type1和type2保存的都是主键对 5 的取模,目的是模拟实际应用中常用类似 type 类型的数据,但是**type2是没有建立索引的。 str1和str2都是保存了一个 20 位长度的随机字符串,str1不能为NULL,str2允许为NULL,相应的生成测试数据的时候我也会在str2字段生产少量NULL值**(每 100 条数据产生一个NULL值)。\n数据量比较大,还涉及使用MD5生成随机字符串,所以速度有点慢,稍安勿躁,耐心等待即可。\n1000 万条数据,我用了 33 分钟才跑完(实际时间跟你电脑硬件配置有关)。这里贴几条生成的数据,大致长这样。 数据如下所示:\nSQL测试 # 注:num1是int类型,num2是varchar类型。\n1: SELECT * FROM `test1` WHERE num1 = 10000; 2: SELECT * FROM `test1` WHERE num1 = \u0026#39;10000\u0026#39;; 3: SELECT * FROM `test1` WHERE num2 = 10000; 4: SELECT * FROM `test1` WHERE num2 = \u0026#39;10000\u0026#39;; 这四条 SQL 都是有针对性写的,12 查询的字段是 int 类型,34 查询的字段是varchar类型。12 或 34 查询的字段虽然都相同,但是一个条件是数字,一个条件是用引号引起来的字符串。这样做有什么区别呢?先不看下边的测试结果你能猜出这四条 SQL 的效率顺序吗?\n经测试这四条 SQL 最后的执行结果却相差很大,其中 124 三条 SQL 基本都是瞬间出结果,大概在 0.0010.005 秒,在千万级的数据量下这样的结果可以判定这三条 SQL 性能基本没差别了。但是第三条 SQL,多次测试耗时基本在 4.54.8 秒之间\n也就是说 左int 右字符不影响效率;而左字符右int则影响效率,后面会解释\n下面看1234的执行计划\n可以看到,124 三条 SQL 都能使用到索引,连接类型都为ref,扫描行数都为 1,所以效率非常高。再看看第三条 SQL,没有用上索引,所以为全表扫描,rows直接到达 1000 万了,所以性能差别才那么大\n34 两条 SQL 查询的字段num2是varchar类型的,查询条件等号右边加引号的第 4 条 SQL 是用到索引的,那么是查询的数据类型和字段数据类型不一致造成的吗?如果是这样那 12 两条 SQL 查询的字段num1是int类型,但是第 2 条 SQL 查询条件右边加了引号为什么还能用上索引呢。 官方文档: 12.2 Type Conversion in Expression Evaluationopen in new window\n当操作符与不同类型的操作数一起使用时,会发生类型转换以使操作数兼容。某些转换是隐式发生的。例如,MySQL 会根据需要自动将字符串转换为数字,反之亦然。以下规则描述了比较操作的转换方式:\n两个参数至少有一个是NULL时,比较的结果也是NULL,特殊的情况是使用\u0026lt;=\u0026gt;对两个NULL做比较时会返回1,这两种情况都不需要做类型转换 两个参数都是字符串,会按照字符串来比较,不做类型转换 两个参数都是整数,按照整数来比较,不做类型转换 十六进制的值和非数字做比较时,会被当做二进制串 有一个参数是TIMESTAMP或DATETIME,并且另外一个参数是常量,常量会被转换为timestamp 有一个参数是decimal类型,如果另外一个参数是decimal或者整数,会将整数转换为decimal后进行比较,如果另外一个参数是浮点数,则会把decimal转换为浮点数进行比较 所有其他情况下,两个参数都会被转换为浮点数再进行比较 根据官方文档的描述,我们的第 23 两条 SQL 都发生了隐式转换,第 2 条 SQL 的查询条件num1 = '10000',左边是int类型右边是字符串,第 3 条 SQL 相反,那么根据官方转换规则第 7 条,左右两边都会转换为浮点数再进行比较\n★★\n先看第 2 条 SQL:SELECT * FROMtest1WHERE num1 = '10000';左边为 int 类型10000,转换为浮点数还是10000,右边字符串类型'10000',转换为浮点数也是10000。两边的转换结果都是唯一确定的,所以不影响使用索引\n也就是说,这个sql是要找到索引num1的值为浮点数10000的行,所以能用上索引\n第 3 条 SQL:SELECT * FROMtest1WHERE num2 = 10000;左边是字符串类型'10000',转浮点数为 10000 是唯一的,右边int类型10000转换结果也是唯一的。但是,因为左边是检索条件,'10000'转到10000虽然是唯一,但是其他字符串也可以转换为10000,比如'10000a','010000','10000'等等都能转为浮点数10000,这样的情况下,是不能用到索引的。\n也就是说,如果我把10000当作索引去查,是不行的。因为正确结果应该是把 \u0026lsquo;10000a\u0026rsquo;,\u0026lsquo;10000-\u0026lsquo;这种都查出来。而如果使用索引,也只能查出'10000\u0026rsquo;,结果不对。所以肯定会用上全表扫描\n也就是说,这个sql是要找到索引num2的值(字符串)转换后是'10000\u0026rsquo;的行,因为10000a,10000b转换后也都是10000,所以用不上索引\n对第二点的后半部分再做解释\n关于这个隐式转换我们可以通过查询测试验证一下,先插入几条数据,其中num2='10000a'、'010000'和'10000':\nINSERT INTO `test1` (`id`, `num1`, `num2`, `type1`, `type2`, `str1`, `str2`) VALUES (\u0026#39;10000001\u0026#39;, \u0026#39;10000\u0026#39;, \u0026#39;10000a\u0026#39;, \u0026#39;0\u0026#39;, \u0026#39;0\u0026#39;, \u0026#39;2df3d9465ty2e4hd523\u0026#39;, \u0026#39;2df3d9465ty2e4hd523\u0026#39;); INSERT INTO `test1` (`id`, `num1`, `num2`, `type1`, `type2`, `str1`, `str2`) VALUES (\u0026#39;10000002\u0026#39;, \u0026#39;10000\u0026#39;, \u0026#39;010000\u0026#39;, \u0026#39;0\u0026#39;, \u0026#39;0\u0026#39;, \u0026#39;2df3d9465ty2e4hd523\u0026#39;, \u0026#39;2df3d9465ty2e4hd523\u0026#39;); INSERT INTO `test1` (`id`, `num1`, `num2`, `type1`, `type2`, `str1`, `str2`) VALUES (\u0026#39;10000003\u0026#39;, \u0026#39;10000\u0026#39;, \u0026#39; 10000\u0026#39;, \u0026#39;0\u0026#39;, \u0026#39;0\u0026#39;, \u0026#39;2df3d9465ty2e4hd523\u0026#39;, \u0026#39;2df3d9465ty2e4hd523\u0026#39;); 然后使用第三条 SQL 语句SELECT * FROMtest1WHERE num2 = 10000;进行查询:\n从结果可以看到,后面插入的三条数据也都匹配上了。那么这个字符串隐式转换的规则是什么呢?为什么num2='10000a'、'010000'和'10000'这三种情形都能匹配上呢?查阅相关资料发现规则如下:\n不以数字开头的字符串都将转换为0。如'abc'、'a123bc'、'abc123'都会转化为0; 以数字开头的字符串转换时会进行截取,从第一个字符截取到第一个非数字内容为止。比如'123abc'会转换为123,'012abc'会转换为012也就是12,'5.3a66b78c'会转换为5.3,其他同理。 现对以上规则做如下测试验证:\n如此也就印证了之前的查询结果了\n再次写一条 SQL 查询 str1 字段:SELECT * FROMtest1WHERE str1 = 1234;\n分析和总结 # 通过上面的测试我们发现 MySQL 使用操作符的一些特性:\n当操作符左右两边的数据类型不一致时,会发生隐式转换。 当 where 查询操作符左边为数值类型时发生了隐式转换,那么对效率影响不大,但还是不推荐这么做。 当 where 查询操作符左边为字符类型时发生了隐式转换,那么会导致索引失效,造成全表扫描效率极低。 字符串转换为数值类型时,非数字开头的字符串会转化为0,以数字开头的字符串会截取从第一个字符到第一个非数字内容为止的值为转化结果。 所以,我们在写 SQL 时一定要养成良好的习惯,查询的字段是什么类型,等号右边的条件就写成对应的类型。特别当查询的字段是字符串时,等号右边的条件一定要用引号引起来标明这是一个字符串,否则会造成索引失效触发全表扫描\n"},{"id":298,"href":"/zh/docs/technology/Review/java_guide/database/MySQL/ly0608lysome-thoughts-on-database-storage-time/","title":"MySQL数据库时间类型数据存储建议","section":"MySQL","content":" 转载自https://github.com/Snailclimb/JavaGuide(添加小部分笔记)感谢作者!\n不要用字符串存储日期 # 优点:简单直白 缺点 字符串占有的空间更大 字符串存储的日期效率比较低(逐个字符进行比较),无法用日期相关的API进行计算和比较 Datetime和Timestamp之间抉择 # Datetime 和 Timestamp 是 MySQL 提供的两种比较相似的保存时间的数据类型。他们两者究竟该如何选择呢?\n通常我们都会首选 Timestamp\nDatetime类型没有时区信息 # DateTime 类型是没有时区信息的(时区无关) ,DateTime 类型保存的时间都是当前会话所设置的时区对应的时间。这样就会有什么问题呢?当你的时区更换之后,比如你的服务器更换地址或者更换客户端连接时区设置的话,就会导致你从数据库中读出的时间错误。不要小看这个问题,很多系统就是因为这个问题闹出了很多笑话。 Timestamp 和时区有关。Timestamp 类型字段的值会随着服务器时区的变化而变化,自动换算成相应的时间,说简单点就是在不同时区,查询到同一个条记录此字段的值会不一样 案例\n-- 建表 CREATE TABLE `time_zone_test` ( `id` bigint(20) NOT NULL AUTO_INCREMENT, `date_time` datetime DEFAULT NULL, `time_stamp` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, PRIMARY KEY (`id`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8; -- 插入数据 INSERT INTO time_zone_test(date_time,time_stamp) VALUES(NOW(),NOW()); -- 查看数据 select date_time,time_stamp from time_zone_test; -- 结果 /* +---------------------+---------------------+ | date_time | time_stamp | +---------------------+---------------------+ | 2020-01-11 09:53:32 | 2020-01-11 09:53:32 | +---------------------+---------------------+ ------ */ 修改时区并查看数据\nset time_zone=\u0026#39;+8:00\u0026#39;; /* +---------------------+---------------------+ | date_time | time_stamp | +---------------------+---------------------+ | 2020-01-11 09:53:32 | 2020-01-11 17:53:32 | +---------------------+---------------------+ ------ */ 关于MySQL时区设置的一个常用sql命令\n# 查看当前会话时区 SELECT @@session.time_zone; # 设置当前会话时区 SET time_zone = \u0026#39;Europe/Helsinki\u0026#39;; SET time_zone = \u0026#34;+00:00\u0026#34;; # 数据库全局时区设置 SELECT @@global.time_zone; # 设置全局时区 SET GLOBAL time_zone = \u0026#39;+8:00\u0026#39;; SET GLOBAL time_zone = \u0026#39;Europe/Helsinki\u0026#39;; DateTime类型耗费空间更大 # Timestamp 只需要使用 4 个字节的存储空间,但是 DateTime 需要耗费 8 个字节的存储空间。但是,这样同样造成了一个问题,Timestamp 表示的时间范围更小。\nDateTime :1000-01-01 00:00:00 ~ 9999-12-31 23:59:59 Timestamp: 1970-01-01 00:00:01 ~ 2037-12-31 23:59:59 Timestamp 在不同版本的 MySQL 中有细微差别。\n再看MySQL日期类型存储空间 # MySQL 5.6 版本中日期类型所占的存储空间 可以看出 5.6.4 之后的 MySQL 多出了一个需要 0 ~ 3 字节的小数位。DateTime 和 Timestamp 会有几种不同的存储空间占用。 为了方便,本文我们还是默认 Timestamp 只需要使用 4 个字节的存储空间,但是 DateTime 需要耗费 8 个字节的存储空间 数值型时间戳是更好的选择吗 # 使用int或者bigint类型数值,即时间戳来表示时间\n优点:使用它进行日期排序以及对比等操作效率更高,跨系统也方便 缺点:可读性差 时间戳的定义\n时间戳的定义是从一个基准时间开始算起,这个基准时间是「1970-1-1 00:00:00 +0:00」,从这个时间开始,用整数表示,以秒计时,随着时间的流逝这个时间整数不断增加。这样一来,我只需要一个数值,就可以完美地表示时间了,而且这个数值是一个绝对数值,即无论的身处地球的任何角落,这个表示时间的时间戳,都是一样的,生成的数值都是一样的,并且没有时区的概念,所以在系统的中时间的传输中,都不需要进行额外的转换了,只有在显示给用户的时候,才转换为字符串格式的本地时间\n实际操作\nmysql\u0026gt; select UNIX_TIMESTAMP(\u0026#39;2020-01-11 09:53:32\u0026#39;); +---------------------------------------+ | UNIX_TIMESTAMP(\u0026#39;2020-01-11 09:53:32\u0026#39;) | +---------------------------------------+ | 1578707612 | +---------------------------------------+ 1 row in set (0.00 sec) mysql\u0026gt; select FROM_UNIXTIME(1578707612); +---------------------------+ | FROM_UNIXTIME(1578707612) | +---------------------------+ | 2020-01-11 09:53:32 | +---------------------------+ 1 row in set (0.01 sec) 总结 # 推荐使用《高性能MySQL》\n对比\n"},{"id":299,"href":"/zh/docs/technology/Review/java_guide/database/MySQL/ly0605lyhow-sql-executed-in-mysql/","title":"SQL语句在MySQL中的执行过程","section":"MySQL","content":" 转载自https://github.com/Snailclimb/JavaGuide(添加小部分笔记)感谢作者!\n原文 https://github.com/kinglaw1204 感谢作者\n本篇文章会分析一个SQL语句在MySQL的执行流程,包括SQL的查询在MySQL内部会怎么流转,SQL语句的更新是怎么完成的 分析之前先看看MySQL的基础架构,知道了MySQL由哪些组件组成以及这些组件的作用是什么,可以帮助我们理解和解决这些问题 MySQL基础架构分析 # MySQL基本架构概览 # 下图是MySQL的简要架构图,从下图可以看到用户的SQL语句在MySQL内部是如何执行的 先简单介绍一个下图涉及的一些组件的基本作用 连接器: 身份认证和权限相关(登录MySQL的时候) 查询缓存:执行查询语句的时候,会先查询缓存(MySQL8.0版本后移除,因为这个功能不太实用) 分析器:没有命中缓存的话,SQL语句就会经过分析器,分析器说白了就是要先看你的SQL语句干嘛,再检查你的SQL语句语法是否正确 优化器:按照MySQL认为最优的方案去执行 执行器:执行语句,然后从存储引擎返回数据 简单来说 MySQL 主要分为 Server 层和存储引擎层: Server 层:主要包括连接器、查询缓存、分析器、优化器、执行器等,所有跨存储引擎的功能都在这一层实现,比如存储过程、触发器、视图,函数等,还有一个通用的日志模块 binlog 日志模块。 存储引擎: 主要负责数据的存储和读取,采用可以替换的插件式架构,支持 InnoDB、MyISAM、Memory 等多个存储引擎,其中 InnoDB 引擎有自有的日志模块 redolog 模块。现在最常用的存储引擎是 InnoDB,它从 MySQL 5.5 版本开始就被当做默认存储引擎了 Server层基本组件介绍 # 连接器 连接器主要和身份认证和权限相关的功能相关,就好比一个级别很高的门卫一样\n主要负责用户登录数据库,进行用户的身份认证,包括校验账户密码,权限等操作,如果用户账户密码已通过,连接器会到权限表中查询该用户的所有权限,之后在这个连接里的权限逻辑判断都是会依赖此时读取到的权限数据,也就是说,后续只要这个连接不断开,即使管理员修改了该用户的权限,该用户也是不受影响的。\n查询缓存(MySQL8.0 版本后移除)\n查询缓存主要用来缓存我们所执行的 SELECT 语句以及该语句的结果集。\n连接建立后,执行查询语句的时候,会先查询缓存,MySQL 会先校验这个 SQL 是否执行过,以 Key-Value 的形式缓存在内存中,Key 是查询预计,Value 是结果集。如果缓存 key 被命中,就会直接返回给客户端,如果没有命中,就会执行后续的操作,完成后也会把结果缓存起来,方便下一次调用。当然在真正执行缓存查询的时候还是会校验用户的权限,是否有该表的查询条件。\nMySQL 查询不建议使用缓存,因为查询缓存失效在实际业务场景中可能会非常频繁,假如你对一个表更新的话,这个表上的所有的查询缓存都会被清空。对于不经常更新的数据来说,使用缓存还是可以的。所以,一般在大多数情况下我们都是不推荐去使用查询缓存的。\nMySQL 8.0 版本后删除了缓存的功能,官方也是认为该功能在实际的应用场景比较少,所以干脆直接删掉了\n分析器MySQL 没有命中缓存,那么就会进入分析器,分析器主要是用来分析 SQL 语句是来干嘛的,分析器也会分为几步:\n第一步,词法分析,一条 SQL 语句有多个字符串组成,首先要提取关键字,比如 select,提出查询的表,提出字段名,提出查询条件等等。做完这些操作后,就会进入第二步。\n第二步,语法分析,主要就是判断你输入的 SQL 是否正确,是否符合 MySQL 的语法。\n完成这 2 步之后,MySQL 就准备开始执行了,但是如何执行,怎么执行是最好的结果呢?这个时候就需要优化器上场了。\n优化器\n优化器的作用就是它认为的最优的执行方案去执行(有时候可能也不是最优,这篇文章涉及对这部分知识的深入讲解),比如多个索引的时候该如何选择索引,多表查询的时候如何选择关联顺序等。\n可以说,经过了优化器之后可以说这个语句具体该如何执行就已经定下来\n执行器\n当选择了执行方案后,MySQL 就准备开始执行了,首先执行前会校验该用户有没有权限,如果没有权限,就会返回错误信息,如果有权限,就会去调用引擎的接口,返回接口执行的结果\n语句分析 # SQL分为两种,一种是查询,一种是更新(增加、修改、删除)\n查询语句 # select * from tb_student A where A.age='18' and A.name=' 张三 ';\n结合上面说明,分析下面这个语句的执行流程:\n先检查该语句是否有权限,如果没有权限,直接返回错误信息,如果有权限,在 MySQL8.0 版本以前,会先查询缓存,以这条 SQL 语句为 key 在内存中查询是否有结果,如果有直接缓存,如果没有,执行下一步。\n通过分析器进行词法分析,提取 SQL 语句的关键元素,比如提取上面这个语句是查询 select,提取需要查询的表名为 tb_student,需要查询所有的列,查询条件是这个表的 id=\u0026lsquo;1\u0026rsquo;。然后判断这个 SQL 语句是否有语法错误,比如关键词是否正确等等,如果检查没问题就执行下一步。\n接下来就是优化器进行确定执行方案,上面的 SQL 语句,可以有两种执行方案:\na.先查询学生表中姓名为“张三”的学生,然后判断是否年龄是 18。 b.先找出学生中年龄 18 岁的学生,然后再查询姓名为“张三”的学生。 那么优化器根据自己的优化算法进行选择执行效率最好的一个方案(优化器认为,有时候不一定最好)。那么确认了执行计划后就准备开始执行了\n进行权限校验,如果没有权限就会返回错误信息,如果有权限就会调用数据库引擎接口,返回引擎的执行结果\n更新语句 # 以上就是一条查询 SQL 的执行流程,那么接下来我们看看一条更新语句如何执行的呢?SQL 语句如下:\nupdate tb_student A set A.age='19' where A.name=' 张三 ';\n我们来给张三修改下年龄,在实际数据库肯定不会设置年龄这个字段的,不然要被技术负责人打的 (因为可能有生日,年龄是不可人为手动修改) 其实这条语句也基本上会沿着上一个查询的流程走,只不过执行更新的时候肯定要记录日志啦,这就会引入日志模块了,MySQL 自带的日志模块是 binlog(归档日志) ,所有的存储引擎都可以使用,我们常用的 InnoDB 引擎还自带了一个日志模块 redo log(重做日志),我们就以 InnoDB 模式下来探讨这个语句的执行流程 先查询到张三这一条数据,如果有缓存,也是会用到缓存。 然后拿到查询的语句,把 age 改为 19,然后调用引擎 API 接口,写入这一行数据,InnoDB 引擎把数据保存在内存中,同时记录 redo log,此时 redo log 进入 prepare 状态,然后告诉执行器,执行完成了,随时可以提交。 执行器收到通知后记录 binlog,然后调用引擎接口,提交 redo log 为提交状态。 更新完成 这里肯定有同学会问,为什么要用两个日志模块,用一个日志模块不行吗?\n这是因为最开始 MySQL 并没有 InnoDB 引擎(InnoDB 引擎是其他公司以插件形式插入 MySQL 的),MySQL 自带的引擎是 MyISAM,但是我们知道 redo log 是 InnoDB 引擎特有的,其他存储引擎都没有,这就导致会没有 crash-safe 的能力(crash-safe 的能力即使数据库发生异常重启,之前提交的记录都不会丢失),binlog 日志只能用来归档。\n并不是说只用一个日志模块不可以,只是 InnoDB 引擎就是通过 redo log 来支持事务的。那么,又会有同学问,我用两个日志模块,但是不要这么复杂行不行,为什么 redo log 要引入 prepare 预提交状态?这里我们用反证法来说明下为什么要这么做?\n先写 redo log 直接提交,然后写 binlog,假设写完 redo log 后,机器挂了,binlog 日志没有被写入,那么机器重启后,这台机器会通过 redo log 恢复数据,但是这个时候 binlog 并没有记录该数据,**后续进行机器备份(从机)**的时候,就会丢失这一条数据,同时主从同步也会丢失这一条数据。 先写 binlog,然后写 redo log,假设写完了 binlog,机器异常重启了,由于没有 redo log,本机是无法恢复这一条记录的,但是 binlog 又有记录,那么和上面同样的道理,就会产生数据不一致的情况。 如果采用 redo log 两阶段提交的方式就不一样了,写完 binlog 后,然后再提交 redo log 就会防止出现上述的问题,从而保证了数据的一致性。那么问题来了,有没有一个极端的情况呢?假设 redo log 处于预提交状态,binlog 也已经写完了,这个时候发生了异常重启会怎么样呢? 这个就要依赖于 MySQL 的处理机制了,MySQL 的处理过程如下:\n判断 redo log 是否完整,如果判断是完整的,就立即提交。 如果 redo log 只是预提交但不是 commit 状态,这个时候就会去判断 binlog 是否完整,如果完整就提交 redo log, 不完整就回滚事务。 这样就解决了数据一致性的问题\n总结 # MySQL 主要分为 Server 层和引擎层,Server 层主要包括连接器、查询缓存、分析器、优化器、执行器,同时还有一个日志模块(binlog),这个日志模块所有执行引擎都可以共用,redolog 只有 InnoDB 有。\n引擎层是插件式的,目前主要包括,MyISAM,InnoDB,Memory 等。\n查询语句的执行流程如下:权限校验(如果命中缓存)\u0026mdash;\u0026gt;查询缓存\u0026mdash;\u0026gt;分析器\u0026mdash;\u0026gt;优化器\u0026mdash;\u0026gt;权限校验\u0026mdash;\u0026gt;执行器\u0026mdash;\u0026gt;引擎\n更新语句执行流程如下:分析器\u0026mdash;-\u0026gt;权限校验\u0026mdash;-\u0026gt;执行器\u0026mdash;\u0026gt;引擎\u0026mdash;redo log(prepare 状态)\u0026mdash;\u0026gt;binlog\u0026mdash;\u0026gt;redo log(commit状态\n"},{"id":300,"href":"/zh/docs/technology/Review/java_guide/database/MySQL/ly0604lyinnodb-implementation-of-mvcc/","title":"innodb引擎对MVCC的实现","section":"MySQL","content":" 转载自https://github.com/Snailclimb/JavaGuide(添加小部分笔记)感谢作者!\n一致性非锁定读和锁定读 # 一致性非锁定读 # ★★非锁定★★\n对于一致性非锁定读(Consistent Nonlocking Reads)的实现,通常做法是加一个版本号或者时间戳字段,在更新数据的同时版本号+1或者更新时间戳。查询时,将当前可见的版本号与对应记录的版本号进行比对,如果记录的版本小于可见版本,则表示该记录可见 InnoDB存储引擎中,多版本控制(multi versioning)即是非锁定读的实现。如果读取的行正在执行DELETE或UPDATE操作,这时读取操作不会去等待行上 锁的释放.相反地,Inn哦DB存储引擎会去读取行的一个快照数据,对于这种读取历史数据的方式,我们叫它快照读(snapshot read)。 在 Repeatable Read 和 Read Committed 两个隔离级别下,如果是执行普通的 select 语句(不包括 select ... lock in share mode ,select ... for update)则会使用 一致性非锁定读(MVCC)。并且在 Repeatable Read 下 MVCC 实现了可重复读和防止部分幻读 锁定读 # 如果执行的是下列语句,就是锁定读(Locking Reads)\nselect ... lock in share\nselect ... for update\ninsert 、upate、delete\n锁定读下,读取的是数据的最新版本,这种读也被称为当前读current read。锁定读会对读取到的记录加锁\nselect ... lock in share mode :对(读取到的)记录加S锁,其他事务也可以加S锁,如果加X锁则会被阻塞\nselect ... for update、insert、update、delete:对记录加X锁,且其他事务不能加任何锁\n在一致性非锁定读下,即使读取的记录已被其他事务加上X锁,这时记录也是可以被读取的,即读取的快照数据。\n在RepeatableRead下MVCC防止了部分幻读,这边的“部分”是指在一致性非锁定读情况下,只能读取到第一次查询之前所插入的数据(根据ReadView判断数据可见性,ReadView在第一次查询时生成),但如果是当前读,每次读取的都是最新数据,这时如果两次查询中间有其他事务插入数据,就会产生幻读 所以,InnoDB在实现RepeatableRead时,如果执行的是当前读,则会对读取的记录使用Next-key Lock,来防止其他事务在间隙间插入数据。 RR产生幻读的另一个场景\n假设有这样一张表\n事务 A 执行查询 id = 5 的记录,此时表中是没有该记录的,所以查询不出来。\n# 事务 A mysql\u0026gt; begin; Query OK, 0 rows affected (0.00 sec) mysql\u0026gt; select * from t_stu where id = 5; Empty set (0.01 sec) 然后事务 B 插入一条 id = 5 的记录,并且提交了事务。\n# 事务 B mysql\u0026gt; begin; Query OK, 0 rows affected (0.00 sec) mysql\u0026gt; insert into t_stu values(5, \u0026#39;小美\u0026#39;, 18); Query OK, 1 row affected (0.00 sec) mysql\u0026gt; commit; Query OK, 0 rows affected (0.00 sec) 此时,事务 A 更新 id = 5 这条记录,对没错,事务 A 看不到 id = 5 这条记录,但是他去更新了这条记录,这场景确实很违和,然后再次查询 id = 5 的记录,事务 A 就能看到事务 B 插入的纪录了,幻读就是发生在这种违和的场景。\n# 事务 A mysql\u0026gt; update t_stu set name = \u0026#39;小林coding\u0026#39; where id = 5; Query OK, 1 row affected (0.01 sec) Rows matched: 1 Changed: 1 Warnings: 0 mysql\u0026gt; select * from t_stu where id = 5; +----+--------------+------+ | id | name | age | +----+--------------+------+ | 5 | 小林coding | 18 | +----+--------------+------+ 1 row in set (0.00 sec) 时序图如下\n在可重复读隔离级别下,事务 A 第一次执行普通的 select 语句时生成了一个 ReadView,之后事务 B 向表中新插入了一条 id = 5 的记录并提交。接着,事务 A 对 id = 5 这条记录进行了更新操作,在这个时刻,这条新记录的 trx_id 隐藏列的值就变成了事务 A 的事务 id,之后事务 A 再使用普通 select 语句去查询这条记录时就可以看到这条记录了,于是就发生了幻读。\n因为这种特殊现象的存在,所以我们认为 MySQL Innodb 中的 MVCC 并不能完全避免幻读现象。\nInnoDB对MVCC的实现 # MVCC的实现依赖于:隐藏字段(每条记录的)、ReadView(当前事务生成的)、undo log(当前事务执行时,为每个操作(记录)生成的) 内部实现中,InnoDB通过数据行的DB_TRX_ID和Read View来判断数据的可见性,如不可见,则通过数据行的DB_ROLL_PTR找到undo log中的历史版本。因此,每个事务读到的数据版本可能是不一样的,在同一个事务中,用户只能看到该事务创建ReadView之前**(其实这个说法不太准确,m_up_limit_id不一定大于当前事务id)已经提交的修改和该事务本身做的修改** 隐藏字段 # 内部,InnoDB存储引擎为每行数据添加了三个隐藏字段: DB_TRX_ID(6字节):表示最后一次插入或更新该行的事务id。此外,delete操作在内部被视为更新,只不过会在记录头Record header中的deleted_flag字段将其标记为已删除 DB_ROLL_PTR(7字节):回滚指针,指向该行的undo log。如果该行未被更新,则为空 DB_ROW_ID(6字节):如果没有设置主键且该表没有唯一非空索引时,InnoDB会使用该id来生成聚簇索引 ReadView # class ReadView { /* ... */ private: trx_id_t m_low_limit_id; /* 大于等于这个 ID 的事务均不可见 */ trx_id_t m_up_limit_id; /* 小于这个 ID 的事务均可见 */ trx_id_t m_creator_trx_id; /* 创建该 Read View 的事务ID */ trx_id_t m_low_limit_no; /* 事务 Number, 小于该 Number 的 Undo Logs 均可以被 Purge */ ids_t m_ids; /* 创建 Read View 时的活跃事务列表 */ m_closed; /* 标记 Read View 是否 close */ } Read View 主要是用来做可见性判断,里面保存了 “当前对本事务不可见的其他活跃事务”\nReadView主要有以下字段\nm_low_limit_id:目前出现过的最大的事务 ID+1,即下一个将被分配的事务 ID。大于等于这个 ID 的数据版本均不可见 m_up_limit_id:活跃事务列表 m_ids 中最小的事务 ID,如果 m_ids 为空,则 m_up_limit_id 为 m_low_limit_id。小于这个 ID 的数据版本均可见 m_ids:Read View 创建时其他未提交的活跃事务 ID 列表。创建 Read View时,将当前未提交事务 ID 记录下来,后续即使它们修改了记录行的值,对于当前事务也是不可见的。m_ids 不包括当前事务自己和已提交的事务(正在内存中) m_creator_trx_id:创建该 Read View 的事务 ID 事务可见性示意图(这个图容易理解):\n为什么不是分大于m_low_limit_id和在小于m_low_limit_id里过滤存在于活跃事务列表,应该和算法有关吧\nundo-log # undo log主要有两个作用\n当事务回滚时用于将数据恢复到修改前的样子 用于MVCC,读取记录时,若该记录被其他事务占用或当前版本对该事务不可见,则可以通过undo log 读取之前的版本数据,以此实现非锁定读 InnoDB存储引擎中undo log分为两种:insert undo log和update undo log\ninsert undo log:指在insert操作中产生的undo log,因为insert操作的记录只对事务本身可见,对其他事务不可见,故该undo log可以在事务提交后直接删除。不需要进行purge操作(purge:清洗)\ninsert时的数据初始状态:(DB_ROLL_PTR为空)\nupdate undo log:undate或delete操作产生的undo log。该undo log 可能需要提供给MVCC机制,因此不能在事务提交时就进行删除。提交时放入undo log链表,等待purge线程进行最后的删除\n数据第一次修改时\n数据第二次被修改时\n不同事务或者相同事务的对同一记录行的修改,会使该记录行的 undo log 成为一条链表,链首就是最新的记录,链尾就是最早的旧记录。\n数据可见性算法 # 在 InnoDB 存储引擎中,创建一个新事务后,执行每个 select 语句前(RC下是),都会创建一个快照(Read View),快照中保存了当前数据库系统中正处于活跃(没有 commit)的事务的 ID 号。其实简单的说保存的是系统中当前不应该被本事务看到的其他事务 ID 列表(即 m_ids)。当用户在这个事务中要读取某个记录行的时候,InnoDB 会将该记录行的 DB_TRX_ID 与 Read View 中的一些变量及当前事务 ID 进行比较,判断是否满足可见性条件\n具体的比较算法\n如果记录 DB_TRX_ID \u0026lt; m_up_limit_id,那么表明最新修改该行的事务(DB_TRX_ID)在当前事务创建快照之前就提交了,所以该记录行的值对当前事务是可见的 如果 DB_TRX_ID \u0026gt;= m_low_limit_id,那么表明最新修改该行的事务(DB_TRX_ID)在当前事务创建快照之后才修改该行,所以该记录行的值对当前事务不可见。跳到步骤 5 m_ids 为空[那就不用排除啦,只要小于m_low_limit_id都可见](且DB_TRX_ID \u0026lt; m_low_limit_id),则表明在当前事务创建快照之前,修改该行的事务就已经提交了,所以该记录行的值对当前事务是可见的 如果 m_up_limit_id \u0026lt;= DB_TRX_ID \u0026lt; m_low_limit_id,表明最新修改该行的事务(DB_TRX_ID)在当前事务创建快照的时候可能处于“活动状态”或者“已提交状态”;所以就要对活跃事务列表 m_ids 进行查找(源码中是用的二分查找,因为是有序的) 如果在活跃事务列表 m_ids 中能找到 DB_TRX_ID,表明:① 在当前事务创建快照前,该记录行的值被事务 ID 为 DB_TRX_ID 的事务修改了,但没有提交;或者 ② 在当前事务创建快照后,该记录行的值被事务 ID 为 DB_TRX_ID 的事务修改了且提交了(可重复读)。这些情况下,这个记录行的值对当前事务都是不可见的。跳到步骤 5 在活跃事务列表中找不到,则表明“id 为 trx_id 的事务”在修改“该记录行的值”后,在**“当前事务”创建快照前就已经提交**了,所以记录行对当前事务可见 在该记录行的 DB_ROLL_PTR 指针所指向的 undo log 取出快照记录,用快照记录的 DB_TRX_ID 跳到步骤 1 重新开始判断,直到找到满足的快照版本或返回空 RC 和 RR 隔离级别下 MVCC 的差异 # 在事务隔离级别 RC 和 RR (InnoDB 存储引擎的默认事务隔离级别)下,InnoDB 存储引擎使用 MVCC(非锁定一致性读),但它们生成 Read View 的时机却不同 【RC:Read Commit 读已提交,RR:Repeatable Read 可重复读】\n在 RC 隔离级别下的 每次select 查询前都生成一个Read View (m_ids 列表) 在 RR 隔离级别下只在事务开始后 第一次select 数据前生成一个Read View(m_ids 列表) MVCC解决不可重复读问题 # 虽然 RC 和 RR 都通过 MVCC 来读取快照数据,但由于 生成 Read View 时机不同,从而在 RR 级别下实现可重复读\n举例: (Tn 表示时间线)\n在RC下ReadView生成情况 # 1. 假设时间线来到 T4 ,那么此时数据行 id = 1 的版本链为:\n由于 RC 级别下每次查询都会生成Read View ,并且事务 101、102 并未提交,此时 103 事务生成的 Read View 中活跃的事务 m_ids 为:[101,102] ,m_low_limit_id为:104,m_up_limit_id为:101,m_creator_trx_id 为:103\n此时最新记录的 DB_TRX_ID 为 101,m_up_limit_id \u0026lt;= 101 \u0026lt; m_low_limit_id,所以要在 m_ids 列表中查找,发现 DB_TRX_ID 存在列表中,那么这个记录不可见 根据 DB_ROLL_PTR 找到 undo log 中的上一版本记录,上一条记录的 DB_TRX_ID 还是 101,不可见 继续找上一条 DB_TRX_ID为 1,满足 1 \u0026lt; m_up_limit_id,可见,所以事务 103 查询到数据为 name = 菜花 2. 时间线来到 T6 ,数据的版本链为:\n因为在 RC 级别下,重新生成 Read View,这时事务 101 已经提交,102 并未提交,所以此时 Read View 中活跃的事务 m_ids:[102] ,m_low_limit_id为:104,m_up_limit_id为:102,m_creator_trx_id为:103\n此时最新记录的 DB_TRX_ID 为 102,m_up_limit_id \u0026lt;= 102 \u0026lt; m_low_limit_id,所以要在 m_ids 列表中查找,发现 DB_TRX_ID 存在列表中,那么这个记录不可见 根据 DB_ROLL_PTR 找到 undo log 中的上一版本记录,上一条记录的 DB_TRX_ID 为 101,满足 101 \u0026lt; m_up_limit_id,记录可见,所以在 T6 时间点查询到数据为 name = 李四,与时间 T4 查询到的结果不一致,不可重复读! 3. 时间线来到 T9 ,数据的版本链为:\n重新生成 Read View, 这时事务 101 和 102 都已经提交,所以 m_ids 为空,则 m_up_limit_id = m_low_limit_id = 104,最新版本事务 ID 为 102,满足 102 \u0026lt; m_low_limit_id,可见,查询结果为 name = 赵六\n总结: 在 RC 隔离级别下,事务在每次查询开始时都会生成并设置新的 Read View,所以导致不可重复读\n在RR下ReadView生成情况 # 在可重复读级别下,只会在事务开始后第一次读取数据时生成一个 Read View(m_ids 列表)\n1. 在 T4 情况下的版本链为:\n在当前执行 select 语句时生成一个 Read View,此时 m_ids:[101,102] ,m_low_limit_id为:104,m_up_limit_id为:101,m_creator_trx_id 为:103\n此时和 RC 级别下一样:\n最新记录的 DB_TRX_ID 为 101,m_up_limit_id \u0026lt;= 101 \u0026lt; m_low_limit_id,所以要在 m_ids 列表中查找,发现 DB_TRX_ID 存在列表中,那么这个记录不可见 根据 DB_ROLL_PTR 找到 undo log 中的上一版本记录,上一条记录的 DB_TRX_ID 还是 101,不可见 继续找上一条 DB_TRX_ID为 1,满足 1 \u0026lt; m_up_limit_id,可见,所以事务 103 查询到数据为 name = 菜花 2. 时间点 T6 情况下:\n在 RR 级别下只会生成一次Read View,所以此时依然沿用 m_ids :[101,102] ,m_low_limit_id为:104,m_up_limit_id为:101,m_creator_trx_id 为:103\n最新记录的 DB_TRX_ID 为 102,m_up_limit_id \u0026lt;= 102 \u0026lt; m_low_limit_id,所以要在 m_ids 列表中查找,发现 DB_TRX_ID 存在列表中,那么这个记录不可见 根据 DB_ROLL_PTR 找到 undo log 中的上一版本记录,上一条记录的 DB_TRX_ID 为 101,不可见 【从这步开始就跟T4一样了】 继续根据 DB_ROLL_PTR 找到 undo log 中的上一版本记录,上一条记录的 DB_TRX_ID 还是 101,不可见 继续找上一条 DB_TRX_ID为 1,满足 1 \u0026lt; m_up_limit_id,可见,所以事务 103 查询到数据为 name = 菜花 3. 时间点 T9 情况下:\n此时情况跟 T6 完全一样,由于已经生成了 Read View,此时依然沿用 m_ids :[101,102] ,所以查询结果依然是 name = 菜花\nMVCC+Next-key -Lock防止幻读 # InnoDB存储引擎在 RR 级别下通过 MVCC和 Next-key Lock 来解决幻读问题:\n1、执行普通 select,此时会以 MVCC 快照读的方式读取数据\n在快照读的情况下,RR 隔离级别只会在事务开启后的第一次查询生成 Read View ,并使用至事务提交。所以在生成 Read View 之后其它事务所做的更新、插入记录版本对当前事务并不可见,实现了可重复读和防止快照读下的 “幻读”\n2、执行 select\u0026hellip;for update/lock in share mode、insert、update、delete 等当前读\n在当前读下,读取的都是最新的数据,如果其它事务有插入新的记录,并且刚好在当前事务查询范围内,就会产生幻读!\nInnoDB 使用 Next-key Lockopen in new window 来防止这种情况。当执行当前读时,会锁定读取到的记录的同时,锁定它们的间隙,防止其它事务在查询范围内插入数据。只要我不让你插入,就不会发生幻读\nNext-Key* Lock(临键锁) 是Record Lock(记录锁) 和Gap Lock(间隙锁) 的结合 间隙锁是(左,右] ,即左开右闭。 "},{"id":301,"href":"/zh/docs/technology/Review/java_guide/database/MySQL/ly0603lytransaction-isolation-level/","title":"MySQL事务隔离级别详解","section":"MySQL","content":" 转载自https://github.com/Snailclimb/JavaGuide(添加小部分笔记)感谢作者!\n事务隔离级别总结 # SQL标准定义了四个隔离级别\nREAD-UNCOMMITTED(读取未提交):最低的隔离级别,允许读取尚未提交的数据变更,可能会导致脏读、幻读或不可重复读 READ-COMMITED(读取已提交):允许读取并发事务 已经提交的数据,可以阻止脏读,但是幻读或不可重复读仍有可能发生 REPEATABLE-READ(可重复读):对同一字段的多次读取结果都是一致的,除非数据是被本身事务自己所修改,可以阻止脏读和不可重复读,但幻读仍有可能发生 SERIALIZABLE(可串行化):最高的隔离级别,完全服从ACID的隔离级别。所有的事务依次逐个执行,这样事务之间就完全不可能产生干扰,也就是说,该级别可以防止脏读、不可重复读以及幻读。 隔离级别 脏读 不可重复读 幻读 READ-UNCOMMITTED √ √ √ READ-COMMITTED × √ √ REPEATABLE-READ × × √ SERIALIZABLE × × × MySQL InnoDB 存储引擎的默认支持的隔离级别是 REPEATABLE-READ(可重读)\n使用命令查看,通过SELECT @@tx_isolation;。\nMySQL 8.0 该命令改为SELECT @@transaction_isolation;\nMySQL\u0026gt; SELECT @@tx_isolation; +-----------------+ | @@tx_isolation | +-----------------+ | REPEATABLE-READ | +-----------------+ 从上面对SQL标准定义了四个隔离级别的介绍可以看出,标准的SQL隔离级别里,REPEATABLE-READ(可重复读)是不可以防止幻读的。但是,InnoDB实现的REPEATABLE-READ 隔离级别其实是可以解决幻读问题发生的,分两种情况\n快照读:由MVCC机制来保证不出现幻读 当前读:使用Next-Key Lock进行加锁来保证不出现幻读,Next-Key Lock是行锁(Record Lock )和间隙锁(Gap Lock)的结合,行锁只能锁住已经存在的行,为了避免插入新行,需要依赖间隙锁 (只用间隙锁不行,因为间隙锁是 \u0026gt; 或 \u0026lt; ,不包括等于,所以再可重复读下原记录可能会被删掉) 因为隔离级别越低,事务请求的锁越少,所以大部分数据库系统的隔离级别都是 READ-COMMITTED ,但是你要知道的是 InnoDB 存储引擎默认使用 REPEATABLE-READ 并不会有任何性能损失。\nInnoDB 存储引擎在分布式事务的情况下一般会用到 SERIALIZABLE 隔离级别\nInnoDB 存储引擎提供了对 XA 事务的支持,并通过 XA 事务来支持分布式事务的实现。 分布式事务指的是允许多个独立的事务资源(transactional resources)参与到一个全局的事务中。事务资源通常是关系型数据库系统,但也可以是其他类型的资源。全局事务要求在其中的所有参与的事务要么都提交,要么都回滚,这对于事务原有的 ACID 要求又有了提高。 在使用分布式事务时,InnoDB 存储引擎的事务隔离级别必须设置为 SERIALIZABLE。 实际情况演示 # 下面会使用2个命令行MySQL,模拟多线程(多事务)对同一份数据的(脏读等)问题\nMySQL 命令行的默认配置中事务都是自动提交的,即执行 SQL 语句后就会马上执行 COMMIT 操作。如果要显式地开启一个事务需要使用命令:START TRANSACTION\n通过下面的命令来设置隔离级别 session :更改只有本次会话有效;global:更改在所有会话都有效,且不会影响已开启的session\nSET [SESSION|GLOBAL] TRANSACTION ISOLATION LEVEL [READ UNCOMMITTED|READ COMMITTED|REPEATABLE READ|SERIALIZABLE] 实际操作中使用到的一些并发控制的语句\nSTART TRANSACTION | BEGIN:显示地开启一个事务 (begin也能开启一个事务) COMMIT:提交事务,使得对数据库做的所有修改成为永久性 ROLLBACK:回滚,会结束用户的事务,并撤销正在进行的所有未提交的修改 脏读(读未提交) # 事务1 设置为读未提交级别 SET SESSION TRANSACTION ISOLATION LEVEL READ UNCOMMITTED;\n事务1开启事务并查看数据\nSTART TRANSACTION; SELECT salary FROM employ WHERE id = 1; # 结果 +--------+ | salary | +--------+ | 5000 | +--------+ 开启新连接,事务2 开启事务并更新数据\nSTART TRANSACTION; UPDATE employ SET salary = 4500 ; 事务1查看 SELECT salary FROM employ WHERE id = 1;\n+--------+ | salary | +--------+ | 4500 | +--------+ 此时事务2 进行回滚 ROLLBACK; 使用事务1再次查看 SELECT salary FROM employ WHERE id = 1;\n+--------+ | salary | +--------+ | 5000 | +--------+ 事务二进行了回滚,但是之前事务1却读取到了4500(是个脏数据)\n避免脏读(读已提交) # 不要在上面的连接里继续\n事务1 设置为读已提交SET SESSION TRANSACTION ISOLATION LEVEL READ COMMITTED;\n事务1 开启事务并查询数据\nSTART TRANSACTION; SELECT salary FROM employ WHERE id = 1; # 结果 +--------+ | salary | +--------+ | 5000 | +--------+ 事务2 开启并修改数据(未提交)\nSTART TRANSACTION; UPDATE employ SET salary = 4500 ; 事务1查看数据 SELECT salary FROM employ WHERE id = 1; 因为事务隔离级别为读已提交,所以不会发生脏读\n# 结果 +--------+ | salary | +--------+ | 5000 | +--------+ 事务2提交 COMMIT;后,事务1再次读取数据\nSELECT salary FROM employ WHERE id = 1; +--------+ | salary | +--------+ | 4500 | +--------+ 不可重复读 # 还是刚才读已提交的那些步骤,重复操作可以知道 虽然避免了读未提交,但是出现了,一个事务还没结束,就发生了不可重复读问题\n同一个数据,在同一事务内读取多次但值不一样\n可重复读 # 断开连接后重新连接MySQL,默认就是REPEATABLE-READ 可重复读\n事务1查看当前事务隔离级别 select @@tx_isolation;\n+-----------------+ | @@tx_isolation | +-----------------+ | REPEATABLE-READ | +-----------------+ 事务1 开启事务并查询数据\nSTART TRANSACTION; SELECT salary FROM employ WHERE id = 1; # 结果 +--------+ | salary | +--------+ | 5000 | +--------+ 事务2 开启事务并更新数据\nSTART TRANSACTION; UPDATE employ SET salary = 4500 WHERE id = 1; 事务1 读取数据(结果仍不变,避免了读未提交的问题)\nSELECT salary FROM employ WHERE id = 1; +--------+ | salary | +--------+ | 5000 | +--------+ 事务2提交事务 COMMIT ;\n提交后事务1再次读取\nSELECT salary FROM employ WHERE id = 1; +--------+ | salary | +--------+ | 5000 | +--------+ 与MySQL建立新连接并查询数据(发现数据确实是已经更新了的)\nSELECT salary FROM employ WHERE id = 1; +--------+ | salary | +--------+ | 4500 | +--------+ 幻读 # 接下来测试一下该隔离策略下是否幻读 这里是在可重复读下\n先查看一下当前数据库表的数据\nSELECT * FROM test; +----+--------+ | id | salary | +----+--------+ | 1 | 8000 | | 6 | 500 | +----+--------+ use lydb; \u0026mdash;\u0026gt; 事务1和事务2都开启事务 START TRANSACTION;\n事务2插入一条薪资为500的数据并提交\nINSERT INTO test(salary) values (500); COMMIT; #此时数据库已经有两条500的数据了(事务2) select * from test; +----+--------+ | id | salary | +----+--------+ | 1 | 8000 | | 6 | 500 | | 10 | 500 | +----+--------+ 事务1查询500的数据(★★如果在事务2提交之前查询 SELECT * FROM test WHERE salary = 500; 或者 SELECT * FROM test; 那么这里[快照读]就只会查出一条,但是不管怎么样 [当前读]都会查出两条)\n#---------------- # 快照读------------------ SELECT * FROM test WHERE salary = 500; +----+--------+ | id | salary | +----+--------+ | 6 | 500 | +----+--------+ #----------------# 当前读------------------ SELECT * FROM test WHERE salary = 500 FOR UPDATE; +----+--------+ | id | salary | +----+--------+ | 6 | 500 | | 11 | 500 | +----+--------+ SQL 事务1 在第一次查询工资为 500 的记录时只有一条,SQL 事务2 插入了一条工资为 500 的记录,提交之后;SQL 事务1 在同一个事务中再次使用当前读查询发现出现了两条工资为 500 的记录这种就是幻读。\n这里说明一下当前读和快照读:\nMySQL 里除了普通查询是快照读,其他都是当前读,比如 update、insert、delete,这些语句执行前都会查询最新版本的数据,然后再做进一步的操作 【为什么上面要先进行查询的原因】可重复读隔离级是由 MVCC(多版本并发控制)实现的,实现的方式是开始事务后(执行 begin 语句后),在执行第一个查询语句后,会创建一个 Read View,后续的查询语句利用这个 Read View,通过这个 Read View 就可以在 undo log 版本链找到事务开始时的数据,所以事务过程中每次查询的数据都是一样的,即使中途有其他事务插入了新纪录,是查询不出来这条数据的,所以就很好了避免幻读问题。 解决幻读的方法 # 解决幻读的方式有很多,但是它们的核心思想就是一个事务在操作某张表数据的时候,另外一个事务不允许新增或者删除这张表中的数据了。解决幻读的方式主要有以下几种:(由重到轻)\n将事务隔离级别调整为 SERIALIZABLE 。 在可重复读的事务级别下,给事务操作的这张表添加表锁。 在可重复读的事务级别下,给事务操作的这张表添加 Next-key Lock(Record Lock+Gap Lock)。 "},{"id":302,"href":"/zh/docs/technology/Review/java_guide/database/MySQL/ly0602lymysql-logs/","title":"日志","section":"MySQL","content":" 转载自https://github.com/Snailclimb/JavaGuide(添加小部分笔记)感谢作者!\n前言 # 首先要了解一个东西 :WAL,全称 Write-Ahead Logging,它的关键点就是先写日志,再写磁盘\n在概念上,innodb通过***force log at commit***机制实现事务的持久性,即在事务提交的时候,必须先将该事务的所有事务日志写入到磁盘上的redo log file和undo log file中进行持久化\nWAL 机制的原理也很简单:修改并不直接写入到数据库文件中,而是写入到另外一个称为 WAL 的文件中;如果事务失败,WAL 中的记录会被忽略,撤销修改;如果事务成功,它将在随后的某个时间被写回到数据库文件中,提交修改\n使用 WAL 的数据库系统不会再每新增一条 WAL 日志就将其刷入数据库文件中,一般积累一定的量然后批量写入,通常使用页为单位,这是磁盘的写入单位。 同步 WAL 文件和数据库文件的行为被称为 checkpoint(检查点),一般在 WAL 文件积累到一定页数修改的时候;当然,有些系统也可以手动执行 checkpoint。执行 checkpoint 之后,WAL 文件可以被清空,这样可以保证 WAL 文件不会因为太大而性能下降。\n有些数据库系统读取请求也可以使用 WAL,通过读取 WAL 最新日志就可以获取到数据的最新状态\n关于checkpoint:https://www.cnblogs.com/chenpingzhao/p/5107480.html思考一下这个场景:如果重做日志可以无限地增大,同时缓冲池也足够大 ,那么是不需要将缓冲池中页的新版本刷新回磁盘。因为当发生宕机时,完全可以通过重做日志来恢复整个数据库系统中的数据到宕机发生的时刻。但是这需要两个前提条件:1、缓冲池可以缓存数据库中所有的数据;2、重做日志可以无限增大\n因此Checkpoint(检查点)技术就诞生了,目的是解决以下几个问题:1、缩短数据库的恢复时间;2、缓冲池不够用时,将脏页刷新到磁盘;3、重做日志不可用时,刷新脏页。\n当数据库发生宕机时,数据库不需要重做所有的日志,因为Checkpoint之前的页都已经刷新回磁盘。数据库只需对Checkpoint后的重做日志进行恢复,这样就大大缩短了恢复的时间。 当缓冲池不够用时,根据LRU算法会溢出最近最少使用的页,若此页为脏页,那么需要强制执行Checkpoint,将脏页也就是页的新版本刷回磁盘。 当重做日志出现不可用时,因为当前事务数据库系统对重做日志的设计都是循环使用的,并不是让其无限增大的,重做日志可以被重用的部分是指这些重做日志已经不再需要,当数据库发生宕机时,数据库恢复操作不需要这部分的重做日志,因此这部分就可以被覆盖重用。如果重做日志还需要使用,那么必须强制Checkpoint,将缓冲池中的页至少刷新到当前重做日志的位置。 mysql 的 WAL,大家可能都比较熟悉。mysql 通过 redo、undo 日志实现 WAL。redo log 称为重做日志,每当有操作时,在数据变更之前将操作写入 redo log,这样当发生掉电之类的情况时系统可以在重启后继续操作。undo log 称为撤销日志,当一些变更执行到一半无法完成时,可以根据撤销日志恢复到变更之间的状态。mysql 中用 redo log 来在系统 Crash 重启之类的情况时修复数据(事务的持久性),而 undo log 来保证事务的原子性。\nMySQL 日志 主要包括错误日志、查询日志、慢查询日志、事务日志、二进制日志几大类\nmysql执行\n总结\n比较重要的\n二进制日志: binlog(归档日志)【server层】 事务日志:redo log(重做日志)和undo log(回滚日志) 【引擎层】 redo log是记录物理上的改变;\nundo log是从逻辑上恢复,产生时机:事务开始之前 MySQL InnoDB 引擎使用 redo log(重做日志) 保证事务的持久性,使用 undo log(回滚日志) 来保证事务的原子性。 redo log # redo log(重做日志)是InnoDB存储引擎独有的,它让MySQL拥有了崩溃恢复的能力\n比如 MySQL 实例挂了或宕机了,重启时,InnoDB存储引擎会使用redo log恢复数据,保证数据的持久性与完整性。\n再具体点:防止在发生故障的时间点,尚有脏页未写入磁盘,在重启mysql服务的时候,根据redo log进行重做,从而达到事务的持久性这一特性\nMySQL中数据是以页(这个很重要,重点是针对页)为单位,你查询一条记录,会从硬盘把一页的数据加载出来,加载出来的数据叫数据页,会放到Buffer Pool中 (这个时候 如果更新,buffer pool 中的数据页就与磁盘上的数据页内容不一致,我们称 buffer pool 的数据页为 dirty page 脏数据)\n以页为单位:\n页是InnoDB 管理存储空间的基本单位,一个页的大小一般是16KB 。可以理解为创建一个表时,会创建一个大小为16KB大小的空间,也就是数据页。新增数据时会往该页中User Records中添加数据,如果页的大小不够使用了继续创建新的页。也就是说一般情况下一次最少从磁盘读取16kb的内容到内存,一次最少把16kb的内容刷新到磁盘中,其作用有点缓存行的意思 原文链接:https://blog.csdn.net/qq_31142237/article/details/125447413\n后续的查询都是先从 Buffer Pool 中找,没有命中再去硬盘加载,减少硬盘 IO 开销,提升性能。\n更新表数据的时候,也是如此,发现 Buffer Pool 里存在要更新的数据,就直接在 Buffer Pool 里更新\n把“在某个数据页上做了什么修改”记录到重做日志缓存(redo log buffer)里,接着刷盘到 redo log 文件里\n即 从 硬盘上db数据文件 \u0026ndash;\u0026gt; BufferPool \u0026ndash;\u0026gt; redo log buffer \u0026ndash;\u0026gt; redo log 理想情况,事务一提交就会进行刷盘操作,但实际上,刷盘的时机是根据策略\n每条redo记录由**”表空间号+数据页号+偏移量+修改数据长度+具体修改的数据“**组成\n刷盘时机 # InnoDB 存储引擎为 redo log 的刷盘策略提供了 innodb_flush_log_at_trx_commit 参数,它支持三种策略\n0:设置为0时,表示每次事务提交时不进行刷盘操作\n1:设置为1时,表示每次事务提交时都将进行刷盘操作(默认值)\n2:设置为2时,表示每次事务提交时都只把redo log buffer内容写入page cache(系统缓存)\ninnodb_flush_log_at_trx_commit 参数默认为 1 ,也就是说当事务提交时会调用 fsync 对 redo log 进行刷盘\nInnoDB 存储引擎有一个后台线程,每隔1 秒,就会把 redo log buffer 中的内容写到文件系统缓存(page cache),然后调用 fsync 刷盘。(★★重要★★即使没有提交事务的redo log记录,也有可能会刷盘,因为在事务执行过程 redo log 记录是会写入redo log buffer 中,这些 redo log 记录会被后台线程刷盘。)\n除了后台线程每秒1次的轮询操作,还有一种情况,当 redo log buffer 占用的空间即将达到 innodb_log_buffer_size 一半的时候,后台线程会主动刷盘\n不同刷盘策略的流程图\ninnodb_flush_log_at_trx_commit=0(不对是否刷盘做出处理) # 为0时,如果MySQL挂了或宕机可能会有1秒数据的丢失。\n(由于事务提交成功也不会主动写入page cache,所以即使只有MySQL 挂了,没有宕机,也会丢失。)\ninnodb_flush_log_at_trx_commit=1 # 为1时, 只要事务提交成功,redo log记录就一定在硬盘里,不会有任何数据丢失。如果事务执行期间MySQL挂了或宕机,这部分日志丢了,但是事务并没有提交,所以日志丢了也不会有损失。\ninnodb_flush_log_at_trx_commit=2 # 为2时, 只要事务提交成功,redo log buffer中的内容只写入文件系统缓存(page cache)。\n如果仅仅只是MySQL挂了不会有任何数据丢失,但是宕机可能会有1秒数据的丢失。\n日志文件组 # 硬盘上存储的 redo log 日志文件不只一个,而是以一个日志文件组的形式出现的,每个的redo日志文件大小都是一样的\n比如可以配置为一组**4个文件**,每个文件的大小是 1GB,整个 redo log 日志文件组可以记录**4G**的内容\n它采用的是环形数组形式,从头开始写,写到末尾又回到头循环写,如下图所示\n在一个日志文件组中还有两个重要的属性,分别是 write pos、checkpoint\nwrite pos 是当前记录的位置,一边写一边后移 checkpoint 是当前要擦除的位置,也是往后推移 write pos 和 checkpoint 之间的还空着的部分可以用来写入新的 redo log 记录。 ly: 我的理解是有个缓冲带\n如果 write pos 追上 checkpoint (ly: 没有可以擦除的地方了),表示日志文件组满了,这时候不能再写入新的 redo log 记录,MySQL 得停下来,清空一些记录,把 checkpoint 推进一下。\nredo log 小结 # ★★这里有个很重要的问题,就是为什么允许擦除★★\n因为redo log记录的是数据页上的修改,如果Buffer Pool中数据页已经刷磁盘(这里说的磁盘是数据库数据吧)后,那这些记录就失效了,新日志会将这些失效的记录进行覆盖擦除。 redo log日志满了,在擦除之前,需要确保这些要被擦除记录对应在内存中的数据页都已经刷到磁盘中了。擦除旧记录腾出新空间这段期间,是不能再接收新的更新请求的,此刻MySQL的性能会下降。所以在并发量大的情况下,合理调整redo log的文件大小非常重要。 那为什么要绕这么一圈呢,只要每次把修改后的数据页直接刷盘不就好了,还有 redo log 什么事?\n1 Byte = 8bit 1 KB = 1024 Byte 1 MB = 1024 KB 1 GB = 1024 MB 1 TB = 1024 GB 实际上,数据页是16KB,刷盘比较耗时,有时候可能就修改了数据页里的几Byte数据,有必要把完整的数据页刷盘吗\n数据页刷盘是随机写,因为一个数据页对应的位置可能在硬盘文件的随机位置,所以性能是很差\n一个数据页对应的位置可能在硬盘文件的随机位置,即1页是16KB,这16KB,可能是在某个硬盘文件的某个偏移量到某个偏移量之间\n如果是写 redo log,一行记录可能就占几十 Byte,只包含表空间号、数据页号、磁盘文件偏移 量、更新值,再加上是顺序写,所以刷盘速度很快。\n其实内存的数据页在一定时机也会刷盘,我们把这称为页合并,讲 Buffer Pool的时候会对这块细说\nbinlog # redo log是物理日志,记录内容是**“在某个数据页上做了什么修改”,属于InnoDB 存储引擎**;而bin log是逻辑日志,记录内容是语句的原始逻辑,类似于 “给ID = 2 这一行的 c 字段加1”,属于MYSQL Server层\n无论用什么存储引擎,只要发生了表数据更新,都会产生于binlog 日志\nMySQL的数据库的数据备份、主备、主主、主从都离不开binlog,需要依靠binlog来同步数据,保证数据一致性。 binlog会记录所有涉及更新数据的逻辑操作,而且是顺序写\n记录格式 # binlog 日志有三种格式,可以通过**binlog_format**参数指定。 statement row mixed 指定**statement,记录的内容是SQL语句原文**,比如执行一条update T set update_time=now() where id=1,记录的内容如下 同步数据时会执行记录的SQL语句,但有个问题,update_time = now() 会获取当前系统时间,直接执行会导致与原库的数据不一致\n为了解决上面问题,需要指定row,记录的不是简单的SQL语句,还包括操作的具体数据,记录内容如下\nrow格式的记录内容看不到详细信息,需要用mysqlbinlog工具解析出来 update_time=now()变成了具体的时间update_time=1627112756247,条件后面的@1、@2、@3 都是该行数据第 1 个~3 个字段的原始值(假设这张表只有 3 个字段) 这样就能保证同步数据的一致性,通常情况下都是指定row,可以为数据库的恢复与同步带来更好的可靠性\n但是由于row需要更大的容量来记录,比较占用空间,恢复与同步更消耗IO资源,影响执行速度。 折中方案,指定为mixed,记录内容为两者混合:MySQL会判断这条SQL语句是否引起数据不一致,如果是就用row格式,否则就使用statement格式\n写入机制 # binlog的写入时机:事务执行过程中,先把日志写到binlog cache,事务提交的时候(这个很重要,他不像redo log,binlog只有提交的时候才会刷盘),再把binlog cache写到binlog文件中\n因为一个事务的**binlog不能被拆开**,无论这个事务多大,也要确保一次性写入,所以系统会给每个线程分配一个块内存作为binlog cache\n我们可以通过binlog_cache_size参数控制单个线程 binlog cache 大小,如果存储内容超过了这个参数,就要暂存到磁盘(Swap):\nbinlog日志刷盘流程如下\n上图的 write,是指把日志写入到文件系统的 page cache,并没有把数据持久化到磁盘,所以速度比较快 上图的 fsync,才是将数据持久化到磁盘的操作 write和fsync的时机,由sync_binlog控制,默认为0\n为0时,表示每次提交的事务都只write,由系统自行判断什么时候执行fsync 虽然性能会提升,但是如果机器宕机,page cache里面的binlog会丢失\n设置为1,表示每次提交事务都会fsync ,就如同redo log日志刷盘流程 一样\n折中,可以设置为N\n在出现IO瓶颈的场景里,将sync_binlog设置成一个较大的值,可以提升性能\n同理,如果机器宕机,会丢失最近N个事务的binlog日志\n两阶段提交 # redo log(重做日志)让InnoDB存储引擎拥有了崩溃恢复的能力 binlog(归档日志)保证了MySQL集群架构的数据一致性 两者都属于持久性的保证,但侧重点不同\n更新语句过程,会记录redo log和binlog两块日志,以基本的事务为单位\nredo log在事务执行过程中可以不断地写入,而binlog只有在提交事务时才写入,所以redo log和binlog写入时机不一样\nredo log与binlog 两份日志之间的逻辑不一样,会出现什么问题?\n以update语句为例,假设id=2的记录,字段c值是0,把字段c值更新成1,SQL语句为update T set c=1 where id= 2\n假设执行过程中写完redo log日志后,binlog日志写期间发生了异常,会出现什么情况 由于binlog没写完就异常,这时候**binlog里面没有对应的修改记录**。因此,之后用**binlog日志恢复(备库)数据时,就会少这一次更新,恢复出来的这一行c值是0,而原库因为redo log日志恢复,这一行c值是1,最终数据不一致**。\n为了解决两份日志之间的逻辑一致问题,InnoDB存储引擎使用两阶段提交方案 即将redo log的写入拆成了两个步骤prepare和commit,这就是两阶段提交(其实就是等binlog正式写入后redo log才正式提交) 使用两阶段提交后,写入binlog时发生异常也不会有影响,因为**MySQL根据redo log日志恢复数据时,发现redo log还处于prepare阶段(也就是下图的非commit阶段),并且没有对应binlog日志**,就会回滚该事务。\n其实下图中,是否存在对应的binlog,就是想知道binlog是否是完整的,如果完整的话 redolog就可以提交 (箭头前面是否commit阶段,是的话就表示binlog写入期间没有出错,即binlog完整) 还有个问题,redo log设置commit阶段发生异常,那会不会回滚事务呢? 并不会回滚事务,它会执行上图框住的逻辑,虽然redo log是处于prepare阶段,但是能通过事务id找到对应的binlog日志,所以**MySQL认为(binlog)是完整的**,就会提交事务恢复数据。\nundo log # 如果想要保证事务的原子性,就需要在异常发生时,对已经执行的操作进行回滚,在 MySQL 中,恢复机制是通过 回滚日志(undo log) 实现的,所有事务进行的修改都会先记录到这个回滚日志中,然后再执行相关的操作 如果执行过程中遇到异常的话,我们直接利用 回滚日志 中的信息将数据回滚到修改之前的样子即可! 回滚日志会先于数据(数据库数据)持久化到磁盘上。这样就保证了即使遇到数据库突然宕机等情况,当用户再次启动数据库的时候,数据库还能够通过查询回滚日志来回滚将之前未完成的事务。 关于undo log:\n参考https://blog.csdn.net/Weixiaohuai/article/details/117867353\nundo log是逻辑日志,而且记录的是相反的语句\nundo log日志里面不仅存放着数据更新前的记录,还记录着RowID、事务ID、回滚指针。其中事务ID每次递增,回滚指针第一次如果是insert语句的话,回滚指针为NULL**,第二次update之后的undo log的回滚指针就会指向刚刚那一条undo log日志**,依次类推,就会形成一条undo log的回滚链,方便找到该条记录的历史版本\n更新数据之前,MySQL会提前生成undo log日志,当事务提交的时候,并不会立即删除undo log,因为后面可能需要进行回滚操作,要执行回滚(rollback)操作时,从缓存中读取数据。undo log日志的删除是通过通过后台purge线程进行回收处理的。\n举例\n假设有A、B两个数据,值分别为1,2。\nA. 事务开始\nB. 记录A=1到undo log中\nC. 修改A=3\nD. 记录B=2到undo log中\nE. 修改B=4\nF. 将undo log写到磁盘 \u0026mdash;\u0026mdash;-undo log持久化\nG. 将数据写到磁盘 \u0026mdash;\u0026mdash;-数据持久化\nH. 事务提交 \u0026mdash;\u0026mdash;-提交事务\n由于以下特点,所以能保证原子性和持久化\n更新数据前记录undo log。 为了保证持久性,必须将数据在事务提交前写到磁盘,只要事务成功提交,数据必然已经持久化到磁盘。 undo log必须先于数据持久化到磁盘。如果在G,H之间发生系统崩溃,undo log是完整的,可以用来回滚。 如果在A - F之间发生系统崩溃,因为数据没有持久化到磁盘,所以磁盘上的数据还是保持在事务开始前的状态。 参考https://developer.aliyun.com/article/1009683\nhttps://www.cnblogs.com/defectfixer/p/15835714.html\nMySQL 的 InnoDB 存储引擎使用“Write-Ahead Log”日志方案实现本地事务的原子性、持久性。\n“提前写入”(Write-Ahead),就是在事务提交之前,允许将变动数据写入磁盘。与“提前写入”相反的就是,在事务提交之前,不允许将变动数据写入磁盘,而是等到事务提交之后再写入。\n“提前写入”的好处是:有利于利用空闲 I/O 资源。但“提前写入”同时也引入了新的问题:在事务提交之前就有部分变动数据被写入磁盘,那么如果事务要回滚,或者发生了崩溃,这些提前写入的变动数据就都成了错误。“Write-Ahead Log”日志方案给出的解决办法是:增加了一种被称为 Undo Log 的日志,用于进行事务回滚。\n变动数据写入磁盘前,必须先记录 Undo Log,Undo Log 中存储了回滚需要的数据。在事务回滚或者崩溃恢复时,根据 Undo Log 中的信息对提前写入的数据变动进行擦除。\n更新一条语句的执行过程(ly:根据多方资料验证,这个是对的,事务提交前并不会持久化到db磁盘数据库文件中)\n回答题主的问题,对MySQL数据库来说,事务提交之前,操作的数据存储在数据库在内存区域中的缓冲池中,即写的是内存缓冲池中的页(page cache),同时会在缓冲池中写undolog(用于回滚)和redolog、binlog(用于故障恢复,保证数据持久化的一致性),事务提交后,有数据变更的页,即脏页,会被持久化到物理磁盘。\n作者:王同学 链接:https://www.zhihu.com/question/278643174/answer/1998207141 来源:知乎 著作权归作者所有。商业转载请联系作者获得授权,非商业转载请注明出处。\n执行后的几个步骤\n事务开始 申请加锁:表锁、MDL 锁、行锁、索引区间锁(看情况加哪几种锁) 执行器找存储引擎取数据。 如果记录所在的数据页本来就在内存(innodb_buffer_cache)中,存储引擎就直接返回给执行器; 否则,存储引擎需要先将该数据页从磁盘读取到内存,然后再返回给执行器。 执行器拿到存储引擎给的行数据,进行更新操作后,再调用存储引擎接口写入这行新数据(6 - 9)。 存储引擎将回滚需要的数据记录到 Undo Log,并将这个更新操作记录到 Redo Log,此时 Redo Log 处于 prepare 状态。并将这行新数据更新到内存(innodb_buffer_cache)中。同时,然后告知执行器执行完成了,随时可以提交事务。 手动事务 commit:执行器生成这个操作的 Binary Log,并把 Binary Log 写入磁盘。 执行器调用存储引擎的提交事务接口,存储引擎把刚刚写入的 Redo Log 改成 commit 状态。 事务结束 MVCC # MVCC 的实现依赖于:隐藏字段、Read View、undo log。\n内部实现中,InnoDB 通过数据行的 DB_TRX_ID 和 Read View 来判断数据的可见性,如不可见,则通过数据行的 DB_ROLL_PTR 找到 undo log 中的历史版本。\n每个事务读到的数据版本可能是不一样的,在同一个事务中,用户只能看到该事务创建 Read View 之前已经提交的修改和该事务本身做的修改\n总结 # MySQL InnoDB 引擎使用 redo log(重做日志) 保证事务的持久性,使用 undo log(回滚日志) 来保证事务的原子性。 MySQL数据库的数据备份、主备、主主、主从都离不开binlog,需要依靠binlog来同步数据,保证数据一致性。 三大日志大概的流程 "},{"id":303,"href":"/zh/docs/technology/Review/java_guide/database/MySQL/ly0601lymysql-index/","title":"索引","section":"MySQL","content":" 转载自https://github.com/Snailclimb/JavaGuide(添加小部分笔记)感谢作者!\n补充索引基础知识(引自b站sgg视频) # 存储引擎,数据的基本单位是页,如果数据很少,只有一页,那就简单,是直接二分查找(不涉及磁盘IO);如果数据很多,有好几个页,那么需要对页建立一种数据结构,能够最快定位到哪一页,然后减少磁盘IO 索引介绍 # 索引是一种用于快速查询和检索数据的数据结构,其本质可以看成是一种排序好的数据结构\n索引的作用就相当于书的目录。打个比方: 我们在查字典的时候,如果没有目录,那我们就只能一页一页的去找我们需要查的那个字,速度很慢。如果有目录了,我们只需要先去目录里查找字的位置,然后直接翻到那一页就行了\n索引底层数据结构存在很多种类型,常见的索引结构有:B树,B+树和Hash、红黑树。在MySQL中,无论是Innodb还是MyIsam,都使用了B+树作为索引结构\n索引的优缺点 # 优点:\n使用索引可以大大加快 数据的检索速度(大大减少检索的数据量), 这也是创建索引的最主要的原因。 通过创建唯一性索引,可以保证数据库表中每一行数据的唯一性。 缺点:\n创建索引和维护索引需要耗费许多时间。当对表中的数据进行增删改的时候,如果数据有索引,那么索引也需要动态的修改,会降低 SQL 执行效率。 索引需要使用物理文件存储,也会耗费一定空间 索引一定会提高查询性能吗\n多数情况下,索引查询都是比全表扫描要快的。但是如果数据库的数据量不大,那么使用索引也不一定能够带来很大提升 索引的底层数据结构 # Hash表 # 哈希表是键值对的集合,通过键(key)即可快速取出对应的值(value),因此哈希表可以快速检索数据(接近O(1))\n为何能够通过key快速取出value呢?原因在于哈希算法(也叫散列算法)。通过哈希算法,我们可以快速找到key对应的index,找到了index也就找到了对应的value\nhash = hashfunc(key) index = hash % array_size 注意,图中keys[天蓝色]是字符串,不是什么莫名其妙的人 哈希算法有个 Hash 冲突 问题,也就是说多个不同的 key 最后得到的 index 相同。通常情况下,我们常用的解决办法是 链地址法。链地址法就是将哈希冲突数据存放在链表中。就比如 JDK1.8 之前 HashMap 就是通过链地址法来解决哈希冲突的。不过,JDK1.8 以后HashMap为了减少链表过长的时候搜索时间过长引入了红黑树。\n为了减少 Hash 冲突的发生,一个好的哈希函数应该**“均匀地”将数据分布**在整个可能的哈希值集合中\n由于Hash索引不支持顺序和范围查询,假如要对表中的数据进行排序或者进行范围查询,那Hash索引就不行了,并且,每次IO只能取一个\n例如: SELECT * FROM tb1 WHERE id \u0026lt; 500 ; 这种范围查询中,B+树 优势非常大 直接遍历比500小的叶子节点即可 如果使用Hash索引,由于Hash索引是根据hash算法来定位的,难不成把1 ~499 (小于500)的数据都进行一次hash计算来定位吗?这就是Hash最大的缺点 这里其实说的是已经找到了索引,但是索引没有数据的情形。要么通过hash一个个取数据,要么利用B+树的特性(叶子节点有完整数据)\nB树\u0026amp; B+ 树 # B树也称B-树,全称为多路平衡查找树,B+树是B树的一种变体\nB树和B+树中的B是Balanced(平衡)的意思\n目前大部分数据库以及文件系统都采用B-Tree或者其变种B+Tree作为索引结构\nB树\u0026amp;B+树两者有何异同呢\nB树的所有结点既存放键(key)也存放数据(data),而B+树只有叶子结点存放key和data,其他内节点只存放key B树的叶子节点都是独立的;B+树的叶子节点有一条引用链指向与它相邻的叶子节点 B树的检索的过程相当于对范围内的每个结点的关键字做二分查找,可能还没有到达叶子节点,检索就结束了。而B+树的检索效率比较稳定,任何查找都是从根节点到叶子节点的过程,叶子结点的顺序检索很明显 B树中某个子节点,他都包括了父节点的某个节点 如图 在MySQL中,MyISAM引擎和InnoDB引擎都是使用B+Tree作为索引结构,但是,两者的实现方式有点不太一样\nMyISAM 引擎中,B+Tree 叶节点的 data 域存放的是数据记录的地址。在索引检索的时候,首先按照 B+Tree 搜索算法搜索索引,如果指定的 Key 存在,则取出其 data 域的值,然后以 data 域的值为地址读取相应的数据记录。这被称为“非聚簇索引(非聚集索引)”。【反例,B+ 树非叶子节点没有存储数据记录的地址/数据记录本身】 InnoDB 引擎中,其数据文件本身就是索引文件。 MyISAM 的 索引文件和数据文件是分离的,而InnoDB引擎中其表数据文件本身就是按 B+Tree 组织的一个索引结构,树的叶节点 data 域保存了完整的数据记录。这个索引的 key 是数据表的主键(而非地址),因此 InnoDB 表数据文件本身就是主索引。这被称为“聚簇索引(聚集索引)”,而其余的索引都作为 辅助索引 ,辅助索引的 data 域存储相应记录主键的值而不是地址,这也是和 MyISAM 不同的地方。在根据主索引搜索时,直接找到 key 所在的节点即可取出数据;在根据辅助索引查找时,则需要先取出主键的值,再走一遍主索引。 在设计表的时候,不建议使用过长的字段作为主键,也不建议使用非单调的字段作为主键,这样会造成主索引频繁分裂。\n原因: InnoDB的辅助索引data域存储相应记录主键的值而不是地址。所以不建议使用过长的字段作为主键,因为所有辅助索引都引用主索引,过长的主索引会令辅助索引变得过大 (不建议使用过长的字段作为主键) InnoDB数据文件本身是一颗B+Tree,非单调的主键会造成在插入新记录时数据文件为了维持B+Tree的特性而频繁的分裂调整,十分低效。(不建议使用非单调的字段作为主键) MySQL底层数据结构总结 # 索引类型总结 # 按照数据结构维度划分:\nBTree 索引:MySQL 里默认和最常用的索引类型。只有叶子节点存储 value,非叶子节点只有指针和 key。存储引擎 MyISAM 和 InnoDB 实现 BTree 索引都是使用 B+Tree,但二者实现方式不一样(前面已经介绍了)。 哈希索引:类似键值对的形式,一次即可定位。 RTree 索引:一般不会使用,仅支持 geometry 数据类型,优势在于范围查找,效率较低,通常使用搜索引擎如 ElasticSearch 代替。 全文索引:对文本的内容进行分词,进行搜索。目前只有 CHAR、VARCHAR ,TEXT 列上可以创建全文索引。一般不会使用,效率较低,通常使用搜索引擎如 ElasticSearch 代替。 按照底层存储方式角度划分:\n聚簇索引(聚集索引):索引结构和数据一起存放的索引,InnoDB 中的主键索引就属于聚簇索引。 非聚簇索引(非聚集索引):索引结构和数据分开存放的索引,**二级索引(辅助索引)**就属于非聚簇索引。MySQL 的 MyISAM 引擎,不管主键还是非主键,使用的都是非聚簇索引。 按照应用维度划分:\n主键索引:加速查询 + 列值唯一(不可以有 NULL)+ 表中只有一个。 普通索引:仅加速查询。 唯一索引:加速查询 + 列值唯一(可以有 NULL)。 覆盖索引:一个索引包含(或者说覆盖)所有需要查询的字段的值。 联合索引:多列值组成一个索引,专门用于组合搜索,其效率大于索引合并。 全文索引:对文本的内容进行分词,进行搜索。目前只有 CHAR、VARCHAR ,TEXT 列上可以创建全文索引。一般不会使用,效率较低,通常使用搜索引擎如 ElasticSearch 代替。 MySQL 8.x 中实现的索引新特性:\n隐藏索引:也称为不可见索引,不会被优化器使用,但是仍然需要维护,通常会软删除和灰度发布的场景中使用。主键不能设置为隐藏(包括显式设置或隐式设置)。 降序索引:之前的版本就支持通过 desc 来指定索引为降序,但实际上创建的仍然是常规的升序索引。直到 MySQL 8.x 版本才开始真正支持降序索引。另外,在 MySQL 8.x 版本中,不再对 GROUP BY 语句进行隐式排序。 函数索引:从 MySQL 8.0.13 版本开始支持在索引中使用函数或者表达式的值,也就是在索引中可以包含函数或者表达式。 索引类型 # 主键索引(Primary Key) # 数据表的主键列,使用的就是主键索引 一张数据表只能有一个主键,并且主键不能为null,不能重复 在 MySQL 的 InnoDB 的表中,当没有显示的指定表的主键时,InnoDB 会自动先检查表中是否有唯一索引且不允许存在 null 值的字段,如果有,则选择该字段为默认的主键,否则 InnoDB 将会自动创建一个 6Byte 的自增主键 如图 二级索引(辅助索引) # 二级索引又称为辅助索引,是因为二级索引的叶子结点存储的数据是主键。也就是说,通过二级索引,可以定位主键的位置(还有值)\n唯一索引、普通索引、前缀索引等索引都属于二级索引\n唯一索引 Unique Key:是一种约束,该索引的属性列不能出现重复的数据,但是允许数据为NULL,一张表允许创建多个唯一索引。建立唯一索引的目的多是为了该属性列的数据的唯一性,而不是为了查询效率\n普通索引 Index:普通索引的唯一作用就是为了快速查询数据,一张表允许创建多个普通索引,并允许数据重复和NULL\n前缀索引 Prefix:前缀索引只适用于字符串类型的数据。前缀索引是对文本的前几个字符创建索引,相比普通索引建立的数据更小,因为只取前几个字符\n全文索引Full Text:全文索引主要是为了检索大文本数据中的关键字的信息,是目前搜索引擎数据库使用的一种技术。\nMysql5.6 之前只有 MYISAM 引擎支持全文索引,5.6 之后 InnoDB 也支持了全文索引。\n二级索引: 聚簇索引与非聚簇索引 # 聚簇索引(聚集索引) # 聚簇索引介绍 聚簇索引即索引结构和数据一起存放的索引,并不是一种单独的索引类型。InnoDB中的主键索引就属于聚簇索引 MySQL中InnoDB引擎的表的**.ibd 文件就包含了该表的索引和数据**,对于InnoDB引擎表来说,该表的索引(B+树)的每个非叶子节点存储索引(和页地址),叶子结点存储索引和索引对应的数据 聚簇索引的优缺点 优点 查询速度非常快:聚簇索引的查询速度非常的快,因为整个B+树本身就是一颗多差平衡树,叶子节点也都是有序的,定位到索引的节点,就相当于定位到了数据。相比于非聚簇索引,聚簇索引少了一次读取数据的IO操作 对排序查找和范围查找优化:聚簇索引对于逐渐的排序查找和范围查找速度非常快 缺点 依赖于有序的数据:因为B+树是多路平衡树,如果索引的数据不是有序的,那么就需要在插入时排序。如果数据是整型还好,否则类似于字符串或UUID这种又长有难比较的数据,插入或查找的速度较慢 更新代价大:如果对索引列的数据被修改时,那么对应的索引也将会被修改,而且聚簇索引的叶子节点还存放着数据,修改代价肯定是较大的,所以对于主键索引来说,主键一般都是不可被修改的 非聚簇索引(非聚集索引) # 优点:\n更新代价比聚簇索引要小。因为非聚簇索引的叶子节点是不存放数据的\n缺点:\n依赖于有序数据:跟聚簇索引一样,非聚簇索引也依赖于有序数据 可能会二次查询(回表):这应该是非聚簇索引最大的缺点了。 当查到索引对应的指针或主键后,可能还需要根据指针或主键再到数据文件或表中查询 MySQL的表的文件截图: 聚簇索引和非聚簇索引:\n聚簇索引一定回表查询吗(覆盖索引)\n非聚簇索引不一定回表查询\n试想一种情况,用户准备使用 SQL 查询用户名,而用户名字段正好建立了索引。\nSELECT name FROM table WHERE name=\u0026#39;guang19\u0026#39;; 那么这个索引的 key 本身就是 name,查到对应的 name 直接返回就行了,无需回表查询。\n即使是 MYISAM 也是这样,虽然 MYISAM 的主键索引确实需要回表,因为它的主键索引的叶子节点存放的是指针。但是!如果 SQL 查的就是主键(本身)呢?\nSELECT id FROM table WHERE id=1; 主键索引本身的 key 就是主键,查到返回就行了。这种情况就称之为覆盖索引了\n覆盖索引和联合索引 # 覆盖索引 # 如果一个索引包含(或者说覆盖)所有需要查询的字段的值,我们就称之为“覆盖索引”。(也就是不用回表)\n我们知道在 InnoDB 存储引擎中,如果不是主键索引(叶子节点存储的是主键+列值),最终还是要“回表”,也就是要通过主键再查找一次,这样就会比较慢。覆盖索引就是把要查询出的列和索引是对应的,不做回表操作!\n覆盖索引即需要查询的字段正好事索引的字段,那么直接根据该索引,就可以查到数据了,而无需回表查询\n如主键索引,如果一条 SQL 需要查询主键,那么正好根据主键索引就可以查到主键。\n再如普通索引,如果一条 SQL 需要查询 name,name 字段正好有索引, 那么直接根据这个索引就可以查到数据,也无需回表。\n我觉得覆盖索引要在联合索引上体现的话功能会比较突出\n联合索引 # 使用表中的多个字段创建索引,也就是联合索引,也叫组合索引,或复合索引\n最左前缀匹配原则 # 最左前缀匹配原则指的是,在使用联合索引时,MySQL 会根据联合索引中的字段顺序,从左到右依次到查询条件中去匹配,如果查询条件中存在与联合索引中最左侧字段相匹配的字段,则就会使用该字段过滤一批数据,直至联合索引中全部字段匹配完成,或者在执行过程中遇到范围查询,如 \u0026gt;、\u0026lt;、between 和 以%开头的like查询 等条件,才会停止匹配。 所以,我们在使用联合索引时,可以将区分度高的字段放在最左边,这也可以过滤更多数据 索引下推 # 索引下推(Index Condition Pushdown) 是 MySQL 5.6 版本中提供的一项索引优化功能,可以在非聚簇索引遍历过程中,对(即能用索引先用索引)索引中包含的字段先做判断,过滤掉不符合条件的记录,减少回表次数。\n例子:\n对于SELECT * from user where name like '陈%' and age=20这条语句 其中主要几个字段有:id、name、age、address。建立联合索引(name,age)\n最关键的一点: 组合索引满足最左匹配,但是遇到非等值判断时匹配停止。 name like \u0026lsquo;陈%\u0026rsquo; 不是等值匹配,所以 age = 20 这里就用不上 (name,age) 组合索引了。如果没有索引下推,组合索引只能用到 name,age 的判定就需要回表才能做了。5.6之后有了索引下推,age = 20 可以直接在组合索引里判定。\n5.6之前的版本是没有索引下推这个优化的,会忽略age这个字段,直接通过name进行查询,在(name,age)这课树上查找到了两个结果,id分别为2,1,然后拿着取到的id值一次次的回表查询,因此这个过程需要回表两次 5.6版本添加了索引下推这个优化 InnoDB并没有忽略age这个字段,而是在索引内部就判断了age是否等于20,对于不等于20的记录直接跳过,因此在(name,age)这棵索引树中只匹配到了一个记录,此时拿着这个id去主键索引树中回表查询全部数据,这个过程只需要回表一次 争取使用索引的一些建议 # 选择合适的字段创建索引 # 不为 NULL 的字段 :索引字段的数据应该尽量不为 NULL,因为对于数据为 NULL 的字段,数据库较难优化。如果字段频繁被查询,但又避免不了为 NULL,建议使用 0,1,true,false 这样语义较为清晰的短值或短字符作为替代。 被频繁查询的字段 :我们创建索引的字段应该是查询操作非常频繁的字段。 被作为条件查询的字段 :被作为 WHERE 条件查询的字段,应该被考虑建立索引。 频繁需要排序的字段 :索引已经排序,这样查询可以利用索引的排序,加快排序查询时间。 被经常频繁用于连接的字段 :经常用于连接的字段可能是一些外键列,对于外键列并不一定要建立外键,只是说该列涉及到表与表的关系。对于频繁被连接查询的字段,可以考虑建立索引,提高多表连接查询的效率 被频繁更新的字段应该慎重建索引 # 虽然索引能带来查询上的效率,但是维护索引的成本也是不小的。 如果一个字段不被经常查询,反而被经常修改,那么就更不应该在这种字段上建立索引了。\n尽可能地考虑建立联合索引而不是单列索引 # 因为索引是需要占用磁盘空间的,可以简单理解为每个索引都对应着一颗 B+树。如果一个表的字段过多,索引过多,那么当这个表的数据达到一个体量后,索引占用的空间也是很多的,且修改索引时,耗费的时间也是较多的。如果是联合索引,多个字段在一个索引上,那么将会节约很大磁盘空间,且修改数据的操作效率也会提升。\n注意避免冗余索引 # 冗余索引指的是索引的功能相同,能够命中索引(a, b)就肯定能命中索引(a) ,那么索引(a)就是冗余索引\n(name,city )和(name )这两个索引就是冗余索引,能够命中前者的查询肯定是能够命中后者的 在大多数情况下,都应该尽量扩展已有的索引而不是创建新索引\n考虑在字符串类型的字段上使用前缀索引代替普通索引 # 前缀索引仅限于字符串类型,较普通索引会占用更小的空间,所以可以考虑使用前缀索引带替普通索引。\n避免索引失效 # 使用 SELECT * 进行查询;\n创建了组合索引,但查询条件未准守最左匹配原则;\n在索引列上进行计算、函数、类型转换等操作;\n以 % 开头的 LIKE 查询比如 like '%abc';\n%在左边,即使有索引,也会失效 只有当%在右边时,才会生效 查询条件中使用 or,且 or 的前后条件中有一个列没有索引,涉及的索引都不会被使用到(也就是说,反正都是要全表扫描,所以就不用索引了)\n删除长期未使用的索引 # 删除长期未使用的索引,不用的索引的存在会造成不必要的性能损耗\nMySQL 5.7 可以通过查询 sys 库的 schema_unused_indexes 视图来查询哪些索引从未被使用\n"},{"id":304,"href":"/zh/docs/technology/Review/java_guide/database/ly0502lycharactor-set/","title":"字符集详解","section":"数据库","content":" 转载自https://github.com/Snailclimb/JavaGuide(添加小部分笔记)感谢作者!\n图示总结\nMySQL字符编码集有两套UTF-8编码实现:utf-8 和 utf8mb4\n而其中,utf-8 不支持存储emoji符号和一些比较复杂的汉字、繁体字,会出错 何为字符集 # 字符是各种文字和符号的统称,包括各个国家文字、标点符号、表情、数字等等\n字符集就是一系列字符的集合,字符集的种类较多,每个字符集可以表示的字符范围通常不同,就比如说有些字符集无法表示汉字 计算机只能存储二进制的数据,那英文、汉字、表情等字符应该如何存储呢\n我们要将这些字符和二进制的数据一一对应起来,比如说字符“a”对应“01100001”,反之,“01100001”对应 “a”。我们将字符对应二进制数据的过程称为\u0026quot;字符编码\u0026quot;,反之,二进制数据解析成字符的过程称为“字符解码”。\n有哪些常见的字符集 # 常见的字符集有ASCLL、GB2312、GBK、UTF-8 不同的字符集的主要区别在于 可以表示的字符范围 编码方式 ASCLL # ASCII (American Standard Code for Information Interchange,美国信息交换标准代码) 是一套主要用于现代美国英语的字符集(这也是 ASCII 字符集的局限性所在)\n为什么 ASCII 字符集没有考虑到中文等其他字符呢? 因为计算机是美国人发明的,当时,计算机的发展还处于比较雏形的时代,还未在其他国家大规模使用。因此,美国发布 ASCII 字符集的时候没有考虑兼容其他国家的语言\nASCII 字符集至今为止共定义了 128 个字符,其中有 33 个控制字符(比如回车、删除)无法显示\n一个 ASCII 码长度是一个字节也就是 8 个 bit,比如“a”对应的 ASCII 码是“01100001”。不过,最高位是 0 仅仅作为校验位,其余 7 位使用 0 和 1 进行组合,所以,ASCII 字符集可以定义 128(2^7)个字符\n由于,ASCII 码可以表示的字符实在是太少了。后来,人们对其进行了扩展得到了 ASCII 扩展字符集 。ASCII 扩展字符集使用 8 位(bits)表示一个字符,所以,ASCII 扩展字符集可以定义 256(2^8)个字符\n总共128个,下面少了33个无法显示的控制字符 GB2312 # 我们上面说了,ASCII 字符集是一种现代美国英语适用的字符集。因此,很多国家都捣鼓了一个适合自己国家语言的字符集。\nGB2312 字符集是一种对汉字比较友好的字符集,共收录 6700 多个汉字,基本涵盖了绝大部分常用汉字。不过,GB2312 字符集不支持绝大部分的生僻字和繁体字 (对于中英文字符,使用的字节数不一样 ( 1和2 ) )对于英语字符,GB2312 编码和 ASCII 码是相同的,1 字节编码即可。对于非英字符,需要 2 字节编码。 GBK # GBK 字符集可以看作是 GB2312 字符集的扩展,兼容 GB2312 字符集,共收录了 20000 多个汉字。\nGBK 中 K 是汉语拼音 Kuo Zhan(扩展)中的“Kuo”的首字母\nGB18030 # GB18030 完全兼容 GB2312 和 GBK 字符集,纳入中国国内少数民族的文字,且收录了日韩汉字,是目前为止最全面的汉字字符集,共收录汉字 70000 多个\nBIG5 # BIG5 主要针对的是繁体中文,收录了 13000 多个汉字。\nUnicode \u0026amp; UTF-8编码 # 了更加适合本国语言,诞生了很多种字符集。\n我们上面也说了不同的字符集可以表示的字符范围以及编码规则存在差异。这就导致了一个非常严重的问题:使用错误的编码方式查看一个包含字符的文件就会产生乱码现象。 就比如说你使用 UTF-8 编码方式打开 GB2312 编码格式的文件就会出现乱码。示例:“牛”这个汉字 GB2312 编码后的十六进制数值为 “C5A3”,而 “C5A3” 用 UTF-8 解码之后得到的却是 “ţ”。\n你可以通过这个网站在线进行编码和解码:https://www.haomeili.net/HanZi/ZiFuBianMaZhuanHuan 乱码的本质:编码和解码时用了不同或者不兼容的字符集\n如果我们能够有一种字符集将世界上所有的字符都纳入其中就好了,于是Unicode带着这个使命诞生了。\nUnicode 字符集中包含了世界上几乎所有已知的字符。不过,Unicode 字符集并没有规定如何存储这些字符(也就是如何使用二进制数据表示这些字符) 于是有了 UTF-8(8-bit Unicode Transformation Format)。类似的还有 UTF-16、 UTF-32\n其中,UTF-8 使用1-4个字节为每个字符编码,UTF-16使用2或4个字节为每个字符编码,UTF-32固定使用4个字节为每个字符编码\nUTF-8 可以根据不同的符号自动选择编码的长短,像英文字符只需要 1 个字节就够了,这一点 ASCII 字符集一样 。因此,对于英语字符,UTF-8 编码和 ASCII 码是相同的\nUTF-32 的规则最简单,不过缺陷也比较明显,对于英文字母这类字符消耗的空间是 UTF-8 的 4 倍之多。\nUTF-8 是目前使用最广的一种字符编码 MySQL字符集 # MySQL支持很多字符编码的方式,比如UTF-8,GB2312,GBK,BIG5\n使用SHOW CHARSET命令查看 通常情况下,我们建议使用UTF-8作为默认的字符编码方式\n然而,MySQL字符编码中有两套UTF-8编码实现\nutf-8:utf8编码只支持1-3个字节 。 在 utf8 编码中,中文是占 3 个字节,其他数字、英文、符号占一个字节。但 emoji 符号占 4 个字节,一些较复杂的文字、繁体字也是 4 个字节 utf8mb4 : UTF-8 的完整实现,正版!最多支持使用 4 个字节表示字符,因此,可以用来存储 emoji 符号 为何会有两套UTF-8编码实现,原因如下 因此,如果你需要存储emoji类型的数据或者一些比较复杂的文字、繁体字到 MySQL 数据库的话,数据库的编码一定要指定为utf8mb4 而不是utf8 ,要不然存储的时候就会报错了。 测试:\n环境,MySQL 5.7 + 建表语句: ,这里指定数据库CHARSET为utf8\nCREATE TABLE `user` ( `id` varchar(66) NOT NULL, `name` varchar(33) NOT NULL, `phone` varchar(33) DEFAULT NULL, `password` varchar(100) DEFAULT NULL ) ENGINE=InnoDB DEFAULT CHARSET=utf8; CREATE TABLE `user` ( `id` varchar(66) CHARACTER SET utf8mb4 NOT NULL, `name` varchar(33) CHARACTER SET utf8mb4 NOT NULL, `phone` varchar(33) CHARACTER SET utf8mb4 DEFAULT NULL, `password` varchar(100) CHARACTER SET utf8mb4 DEFAULT NULL ) ENGINE=InnoDB DEFAULT CHARSET=utf8; ------ 这边应该是写错了,如果是这个sql,是可以插入成功的 著作权归所有 原文链接:https://javaguide.cn/database/character-set.html 插入\nINSERT INTO `user` (`id`, `name`, `phone`, `password`) VALUES (\u0026#39;A00003\u0026#39;, \u0026#39;guide哥😘😘😘\u0026#39;, \u0026#39;181631312312\u0026#39;, \u0026#39;123456\u0026#39;); -- 报错 Incorrect string value: \u0026#39;\\xF0\\x9F\\x98\\x98\\xF0\\x9F...\u0026#39; for column \u0026#39;name\u0026#39; at row 1 "},{"id":305,"href":"/zh/docs/technology/Review/java_guide/cs_basics/data-structure/tree/","title":"树","section":"数据结构","content":" 转载自https://github.com/Snailclimb/JavaGuide(添加小部分笔记)感谢作者!\n树是一种类似现实生活中的树的数据结构(倒置的树)\n任何一颗非空树只有一个根节点\n一棵树具有以下特点:\n一棵树中的任何两个节点有且仅有唯一的一条路相通 (因为每个结点只会有一个父节点) 一棵树如果有n个节点,那么它一定恰好有n-1条边 一棵树不包括回路 下面是一颗二叉树 深度和高度是对应的;根节点所在层为1层\n常用概念\n节点:树中每个元素都可以统称为节点\n根节点:顶层节点,或者说没有父节点的节点。上图中A节点就是根节点\n父节点:若一个节点含有子节点,则这个节点称为其子节点的父节点。上图中的 B 节点是 D 节点、E 节点的父节点\n兄弟节点:具有相同父节点的节点互称为兄弟节点。上图中 D 节点、E 节点的共同父节点是 B 节点,故 D 和 E 为兄弟节点。\n叶子节点:没有子节点的节点。上图中的 D、F、H、I 都是叶子节点\n节点的高度**(跟叶子节点有关,同一层不一定一样):该节点到叶子节点的最长路径所包含的边数。\n节点的深度**(跟根节点有关,同一层是一样的):根节点到该节点的路径所包含的边数**\n节点的层数:节点的深度+1\n树的高度:根节点的高度\n二叉树的分类 # **二叉树(Binary tree)**是每个节点最多只有两个分支(即不存在分支度大于2的节点)的树结构 二叉树的分支,通常被称为左子树或右子树,并且,二叉树的分支具有左右次序,不能随意颠倒 二叉树的第i层至多拥有2^(i-1) 个节点\n深度为k的二叉树至多总共有 2^(k+1) -1 个节点 (深度为k,最多k + 1 层,最多为满二叉树的情况)\n至少有2^(k) 个节点,即 深度为k-1的二叉树的最多的节点再加1 (关于节点的深度的定义国内争议比较多,我个人比较认可维基百科对 节点深度的定义open in new window)。 满二叉树 # 一个二叉树,如果每一个层的结点数都达到最大值,则这个二叉树就是 满二叉树。也就是说,如果一个二叉树的层数为 K,且结点总数是(2^k) -1 ,则它就是 满二叉树。 完全二叉树 # 定义:除最后一层外,若其余层都是满的,并且最后一层或者是满的,或者是在右边缺少连续若干节点,则这个二叉树就是 完全二叉树 。\n大家可以想象为一棵树从根结点开始扩展,扩展完左子节点才能开始扩展右子节点,每扩展完一层,才能继续扩展下一层。如下图所示:\n从左到右,从上到下:\n完全二叉树的性质:父结点和子节点的序号有着对应关系\n细心的小伙伴可能发现了,当根节点的值为 1 的情况下,若父结点的序号是 i,那么左子节点的序号就是 2i,右子节点的序号是 2i+1。这个性质使得完全二叉树利用数组存储时可以极大地节省空间,以及利用序号找到某个节点的父结点和子节点,后续二叉树的存储会详细介绍。\n平衡二叉树 # 平衡二叉树是一颗二叉排序树,且具有以下性质\n可以是一棵空树 如果不是空树,那么左右两个子树的高度差的绝对值不超过1,并且左右两个子树都是一棵平衡二叉树 平衡二叉树的常用实现方法有 红黑树、AVL 树、替罪羊树、加权平衡树、伸展树 等。\n下面看一颗不太正常的树 这玩意儿还真叫树,只不过这棵树已经退化为一个链表了,我们管它叫 斜树。\n二叉树相比于链表,由于父子节点以及兄弟节点之间往往具有某种特殊的关系,这种关系使得我们在树中对数据进行搜索和修改时,相对于链表更加快捷便利。 如果二叉树退化为一个链表了,那么那么树所具有的优秀性质就难以表现出来,效率也会大打折,为了避免这样的情况,我们希望每个做 “家长”(父结点) 的,都 一碗水端平,分给左儿子和分给右儿子的尽可能一样多,相差最多不超过一层,如下图所示: 二叉树的存储 # 二叉树的存储主要分为链式存储和顺序存储 链式存储 # 和链表类似,二叉树的链式存储依靠指针将各个结点串联起来,不需要连续的存储空间 每个节点包括三个属性 数据data data不一定是单一的数据,根据情况不同,可以是多个具有不同类型的数据 左节点指针 left 右节点指针 right Java没有指针,而是直接引用对象 顺序存储 # 就是利用数组进行存储,数组中每一个位置仅存储结点的data,不存储左右子节点的指针,子节点的索引通过数组下标完成(类似堆) 根节点的序号为1,对于每个节点 Node,假设它存储在数组中下标为 i 的位置,那么它的左子节点就存储在 2i 的位置,它的右子节点存储在下标为 2i+1 的位置。 如图 存储如下数组,会发现问题:如果要存储的二叉树不是完全二叉树,在数组中就会出现空隙,导致内存利用率降低 二叉树的遍历 # 先序遍历 # 定义:先输出根节点,再遍历左子树,最后遍历右子树。\u0026lt;遍历左子树和右子树的时候,同样遵循先序遍历的规则\u0026gt;。也就是说,可以使用递归实现先序遍历\npublic void preOrder(TreeNode root){ if(root == null){ return; } system.out.println(root.data); preOrder(root.left); preOrder(root.right); } 中序遍历 # 定义:先递归中序遍历左子树,再输出根结点的值,再递归中序遍历右子树,大家可以想象成一巴掌把树压扁,父结点被拍到了左子节点和右子节点的中间(倒影、映射)\npublic void inOrder(TreeNode root){ if(root == null){ return; } inOrder(root.left); system.out.println(root.data); inOrder(root.right); } 如图所示 后续遍历 # 定义:先递归后序遍历左子树,再递归后序遍历右子树,最后输出根结点的值\n代码\npublic void postOrder(TreeNode root){ if(root == null){ return; } postOrder(root.left); postOrder(root.right); system.out.println(root.data); } 如图\n"},{"id":306,"href":"/zh/docs/technology/Review/java_guide/cs_basics/data-structure/heap/","title":"堆","section":"数据结构","content":" 转载自https://github.com/Snailclimb/JavaGuide(添加小部分笔记)感谢作者!\n什么是堆 # 堆是满足以下条件的树 堆中每一个节点值都大于等于(或小于等于)子树中所有节点。或者说,任意一个节点的值**都大于等于(或小于等于)**所有子节点的值\n大家可以把堆(最大堆)理解为一个公司,这个公司很公平,谁能力强谁就当老大,不存在弱的人当老大,老大手底下的人一定不会比他强。这样有助于理解后续堆的操作。\n堆不一定是完全二叉树,为了方便存储和索引,我们通常用完全二叉树的形式来表示堆\n广为人知的斐波那契堆和二项堆就不是完全二叉树,它们甚至都不是二叉树 (二叉)堆是一个数组,它可以被看成是一个近似的完全二叉树 下面给出的图是否是堆(通过定义)\n1,2是。 3不是。 堆的用途 # 当我们只关心所有数据中的最大值或者最小值,存在多次获取最大值或者最小值,多次插入或删除数据时,就可以使用堆。\n有小伙伴可能会想到用有序数组,初始化一个有序数组时间复杂度是 O(nlog(n))[也就是将一堆数字乱序排序,最快是O(nlog(n))],查找最大值或者最小值时间复杂度都是 O(1),但是,涉及到更新(插入或删除)数据时,时间复杂度为 O(n),即使是使用复杂度为 O(log(n)) 的二分法找到要插入或者删除的数据,在移动数据时也需要 O(n) 的时间复杂度。\n相对于有序数组而言,堆的主要优势在于更新数据效率较高\n堆的初始化时间复杂度为O(nlog(n)),堆可以做到O(1)的时间复杂度取出最大值或者最小值,O(log(n))的时间复杂度插入或者删除数据 堆的分类 # 堆分为最大堆和最小堆,二者的区别在于节点的排序方式 最大堆:堆中的每一个节点的值都大于子树中所有节点的值 最小堆:堆中的每一个节点的值都小于子树中所有节点的值 如图,图1是最大堆,图2是最小堆 堆的存储 # 由于完全二叉树的优秀性质,利用数组存储二叉树即节省空间,又方便索引(若根结点的序号为1,那么对于树中任意节点i,其左子节点序号为 2*i,右子节点序号为 2*i+1)。 为了方便存储和索引,(二叉)堆可以用完全二叉树的形式进行存储。存储的方式如下图所示 堆的操作 # 堆的更新操作主要包括两种:插入元素和删除堆顶元素\n堆是一个公平的公司,有能力的人自然会走到与他能力所匹配的位置\n插入元素 # 将要插入的元素放到最后 从底向上,如果父节点比该元素小,则该节点和父节点交换(其实就是一棵树有3个(最多)节点,与树上最大的节点比较) 直到无法交换(已经与根节点比较过) 删除堆顶元素 # 根据堆的性质可知,最大堆的堆盯元素为所有元素中最大的,最小堆的堆顶元素是所有元素中最小的\n当我们需要多次查找最大元素或者最小元素的时候,可以利用堆来实现\n删除堆顶元素后,为了保持堆的性质,需要对堆的结构进行调整,我们可以将这个过程称之为堆化\n自底向上的堆化,上述的插入元素所使用的,就是自顶向上的堆化,元素从最底部向上移动 自顶向下的堆化,元素由顶部向下移动。在讲解删除堆顶元素的方法时,我将阐述这两种操作的过程 自底向上堆化\n在堆这个公司中,会出现老大离职的现象,老大离职之后,它的位置就空出来了\n首先删除堆顶元素,使得数组中下标为1的位置空出 那么他的位置由谁来接替呢,当然是他的直接下属了,谁能力强就让谁上\n比较根节点(当前节点)的左子节点和右子节点,也就是下标为 2 ,3 的数组元素,将较大的元素填充到**根节点(下标为1)(当前遍历节点)**的位置 此时又空出一个位置了,老规矩,谁有能力谁上\n一直循环比较空出位置的左右子节点,并将较大者移至空位,直到堆的最底部 此时已经完成自顶向上的堆化,没有元素可以填补空缺。但会发现数组中出现了”气泡”,导致存户空间的浪费。\n解决办法:自顶向下堆化\n自顶向下堆化 自顶向下的堆化用一个词形容就是“石沉大海”\n第一件事情,就是把石头抬起来,从海面扔下去。这个石头就是堆的最后一个元素,我们将最后一个元素移动到堆顶。 将这个石头沉入海底,不停的与左右子节点的值进行比较,和较大的子节点交换位置,直到无法交换位置 结果 堆的操作总结 # 插入元素:先将元素放置数组末尾,再自底向上堆化,将末尾元素上浮\n删除堆顶元素:删除堆顶元素,将末尾元素放置堆顶,再自顶向下堆化,将堆顶元素下沉。\n也可以自底向上堆化,但是会产生气泡,浪费存储空间。不建议\n堆排序 # 堆排序的过程分两步\n建堆,将一个无序的数组,建立成堆 排序,[ 将堆顶元素取出,然后对剩下的元素堆化 ]。 反复迭代,直到所有元素被取出 建堆 # 也就是对所有非叶子结点进行自顶向下\n如图,红色区域分别是堆的情况下。对于T,如果只自顶向下到P、L这层,被换到了这层的那个元素是不一定就比其他树大的,所以还是要依次自顶向下\n这个构建堆操作的时间复杂度为O(n) 首先要了解哪些是非叶节点,最后一个结点的父节点及它(这个父节点)之前的元素,都是非叶节点。也就是说,如果节点个数为n,那么我们需要对n/2到1的节点进行自顶向下(沉底)堆化\n如图 首先将初始的无序数组抽象为一棵树,图中的节点个数为6,所以4,5,6是叶子节点,1,2,3节点为非叶节点 对1,2,3节点进行**自顶向下(沉底)**堆化,注意,顺序是从后往前堆化,从3号开始,一直到1号节点。 3号节点堆化结果\n2号节点堆化结果 1号节点堆化结果 排序 # 方法:由于堆顶元素是所有元素中最大的,所以我们重复取出堆顶元素,将这个最大的堆顶元素放至数组末尾,并对剩下的元素进行堆化即可 现在思考两个问题: 删除堆顶元素后需要执行**自顶向下(沉底)堆化还是自底向上(上浮)**堆化? 取出的堆顶元素存在哪,新建一个数组存? 答案 需要使用自顶向下(沉底)堆化,这个堆化一开始要将末尾元素移动至堆顶。由于这个时候末尾的位置已经空出来了由于堆中元素已经减小,这个位置不会再被使用,所以我们可以将取出的元素放在末尾。 其实是做了一次交换操作,将堆顶和末尾元素调换位置,从而将取出堆顶元素和**堆化的第一步(将末尾元素放至根结点位置)**进行合并 步骤 取出第一个元素并堆化 取出第2个元素并堆化 取出第3个元素并堆化 取出第4个元素并堆化 取出第5个元素并堆化 取出第6个元素并堆化 排序完成 "},{"id":307,"href":"/zh/docs/technology/Review/java_guide/cs_basics/data-structure/graph/","title":"图","section":"数据结构","content":" 转载自https://github.com/Snailclimb/JavaGuide(添加小部分笔记)感谢作者!\n图是一种较为复杂的非线性结构 线性数据结构的元素满足唯一的线性关系,每个元素(除第一个和最后一个外)只有一个直接前驱和一个直接后继 树形数据结构的元素之间有着明显的层级关系 图形结构的元素之间的关系是任意的 图就是由顶点的有穷非空集合和顶点之间的边组成的集合,通常表示为:G(V,E),其中,G表示一个图,V表示顶点的集合,E表示边的集合 下面显示的即图这种数据结构,而且还是一张有向图 图的基本概念 # 顶点 # 图中的数据元素,我们称之为顶点,图至少有一个顶点(有穷非空集合) 对应到好友关系图,每一个用户就代表一个顶点 边 # 顶点之间的关系用边表示 对应到好友关系图,两个用户是好友的话,那两者之间就存在一条边 度 # 度表示一个顶点包含多少条边 有向图中,分为出度和入度,出度表示从该顶点出去的边的条数,入度表示从进入该顶点的边的条数 对应到好友关系图,度就代表了某个人的好友数量 无向图和有向图 # 边表示的是顶点之间的关系,有的关系是双向的,比如同学关系,A是B的同学,那么B也肯定是A的同学,那么在表示A和B的关系时,就不用关注方向,用不带箭头的边表示,这样的图就是无向图。\n有的关系是有方向的,比如父子关系,师生关系,微博的关注关系,A是B的爸爸,但B肯定不是A的爸爸,A关注B,B不一定关注A。在这种情况下,我们就用带箭头的边表示二者的关系,这样的图就是有向图。\n无权图和带权图 # 对于一个关系,如果我们只关心关系的有无,而不关心关系有多强,那么就可以用无权图表示二者的关系。\n对于一个关系,如果我们既关心关系的有无,也关心关系的强度,比如描述地图上两个城市的关系,需要用到距离,那么就用带权图来表示,带权图中的每一条边一个数值表示权值,代表关系的强度。\n下图就是一个带权有向图。\n图的存储 # 邻接矩阵存储 # 邻接矩阵将图用二维矩阵存储,是一种比较直观的表示方式 如果第i个顶点和第j个顶点有关系,且关系权值为n,则A[i] [j] = n 在无向图中,我们只关心关系的有无,所以当顶点i和顶点j有关系时,A[i] [j]=1 ; 当顶点i和顶点j没有关系时,A[i] [j] = 0 ,如下图所示\n无向图的邻接矩阵是一个对称矩阵,因为在无向图中,顶点i和顶点j有关系,则顶点j和顶点i必有关系 有向图的邻接矩阵存储 邻接矩阵存储的方式优点是简单直接(直接使用一个二维数组即可),并且在获取两个顶点之间的关系的时候也非常高效*直接获取指定位置的数组元素。但是这种存储方式的确定啊也比较明显即 比较浪费空间 邻接表存储 # 针对上面邻接矩阵比较浪费内存空间的问题,诞生了图的另一种存储方法\u0026ndash;邻接表\n邻接链表使用一个链表来存储某个顶点的所有后继相邻顶点。对于图中每个顶点Vi ,把所有邻接于Vi 的顶点Vj 链接成一个单链表\n无向图的邻接表存储 有向图的邻接表存储 邻接表中存储的元素的个数(顶点数)以及图中边的条数\n无向图中,邻接表的元素个数等于边的条数的两倍,如下图 7条边,邻接表存储的元素个数为14 (即每条边存储了两次)\n有向图中,邻接表元素个数等于边的条数,如图所示的有向图中,边的条数为8,邻接表 图的搜索 # 广度优先搜索 # 广度优先搜索:像水面上的波纹一样,一层一层向外扩展,如图 具体实现方式,用到了队列,过程如下\n初始状态:将要搜索的源顶点放入队列 取出队首节点,输出0,将0的后继顶点(全部)(未访问过的)放入队列 取出队首节点,输出1,将1的后继顶点(所有)(未访问过的)放入队列 截止到第3步就很清楚了,就是输出最近的一个结点的全部关系节点\n取出队首节点,输出4,将4的后继顶点(未访问过的)放入队列 取出队首节点,输出2,将2的后继顶点(未访问过的)放入队列 取出队首节点,输出3,将3的后继顶点(未访问过的)放入队列,队列为空,结束 总结 先初始化首结点,之后不断从队列取出并将这个结点的有关系的结点 依次放入队列\n深度优先搜索 # 深度优先,即一条路走到黑。从源顶点开始,一直走到后继节点,才回溯到上一顶点,然后继续一条路走到黑 和广度优先搜索类似,深度优先搜索的具体实现,用到了另一种线性数据结构\u0026mdash;栈 初始状态,将要搜索的源顶点放入栈中 取出栈顶元素,输出0,将0的后继顶点(未访问过的)放入栈中 取出栈顶元素,输出4(因为后进先出),将4的后继顶点(未访问过的)放入栈中 取出栈顶元素,输出3,将3的后继顶点(未访问过的)放入栈中 其实到这部就非常明显了,即 前面元素的关系元素,大多都是被一直压在栈底的,会一直走走到 源顶点的直系关系顶点没有了,再往回走\n取出栈顶元素,输出2,将2的后继顶点(为访问过的)放入栈中 取出栈顶元素,输出1,将1的后继顶点(未访问过的)放入栈中,栈为空,结束 "},{"id":308,"href":"/zh/docs/technology/Review/java_guide/cs_basics/data-structure/linear-data-structure/","title":"线性数据结构","section":"数据结构","content":" 转载自https://github.com/Snailclimb/JavaGuide(添加小部分笔记)感谢作者!\n数组 # 数组(Array)是一种常见数据结构,由相同类型的元素(element)组成,并且是使用一块连续的内存来存储 直接可以利用元素的**索引(index)**可以计算出该元素对应的存储地址 数组的特点是:提供随机访问并且容量有限 假设数组长度为n:\n访问:O(1) //访问特定位置的元素\n插入:O(n) //最坏的情况插入在数组的首部并需要移动所有元素时\n删除:O(n) //最坏的情况发生在删除数组的开头并需要移动第一元素后面所有的元素时\n链表 # 链表简介 # 链表(LinkedList)虽然是一种线性表,但是并不会按线性的顺序存储数据,使用的不是连续的内存空间来存储数据\n链表的插入和删除操作的复杂度为O(1),只需要直到目标位置元素的上一个元素即可。但是,在查找一个节点或者访问特定位置的节点的时候复杂度为O(n)\n使用链表结构可以克服数组需要预先知道数据大小的缺点,链表结构可以充分利用计算机内存空间,实现灵活的内存动态管理\n但链表不会节省空间,相比于数组会占用更多空间,因为链表中每个节点存放的还有指向其他节点的指针。除此之外,链表不具有数组随机读取的优点\n链表分类 # 单链表、双向链表、循环链表、双向循环链表\n假设链表中有n个元素\n访问:O(n) //访问特地给位置的元素\n插入删除:O(1) //必须要知道插入元素的位置\n单链表 # 单链表只有一个方向,结点只有一个后继指针next指向后面的节点。因此,链表这种数据结构通常在物理内存上是不连续的 我们习惯性地把第一个结点叫做头结点,链表通常有一个不保存任何值的head节点(头结点),通过头结点我们可以遍历整个链表,尾结点通常指向null 如下图 循环链表 # 循环链表是一种特殊的单链表,和单链表不同的是循环链表的尾结点不是指向null,而是指向链表的头结点 如图 双向链表 # 双向链表包含两个指针,一个prev指向前一个节点,另一个next指向 如图 双向循环链表 # 双向循环链表的最后一个节点的next指向head,而head的prev指向最后一个节点,构成一个环\n应用场景 # 如果需要支持随机访问的话,链表无法做到 如果需要存储的数据元素个数不确定,并且需要经常添加和删除数据的话,使用链表比较合适 如果需要存储的数据元素的个数确定,并且不需要经常添加和删除数据的话,使用数组比较合适 数组 vs 链表 # 数组支持随机访问,链表不支持 数组使用的是连续内存空间 对CPU缓存机制友好,链表则相反 数组的大小固定,而链表则天然支持动态扩容。如果生命的数组过小,需要另外申请一个更大的内存空间存放数组元素,然后将原数组拷贝进去,这个操作比较耗时 栈 # 栈简介 # 栈(stack)只允许在有序的线性数据集合的一端(称为栈顶top)进行加入数据(push)和移除数据(pop)。因而按照**后进先出(LIFO,Last In First Out)**的原理运作。 栈中,push和pop的操作都发生在栈顶 栈常用一维数组或链表来实现,用数组实现的叫顺序栈,用链表实现的叫做链式栈 假设堆栈中有n个元素。 访问:O(n)//最坏情况 插入删除:O(1)//顶端插入和删除元素\n如图:\n栈的常见应用场景 # 当我们要处理的数据,只涉及在一端插入和删除数据,并且满足后进先出(LIFO,LastInFirstOut)的特性时,我们就可以使用栈这个数据结构。\n实现浏览器的回退和前进功能 # 我们只需要使用两个栈(Stack1 和 Stack2)和就能实现这个功能。比如你按顺序查看了 1,2,3,4 这四个页面,我们依次把 1,2,3,4 这四个页面压入 Stack1 中。当你想回头看 2 这个页面的时候,你点击回退按钮,我们依次把 4,3 这两个页面从 Stack1 弹出,然后压入 Stack2 中。假如你又想回到页面 3,你点击前进按钮,我们将 3 页面从 Stack2 弹出,然后压入到 Stack1 中。示例图如下\n检查符号是否承兑出现 # 给定一个只包括 '(',')','{','}','[',']' 的字符串,判断该字符串是否有效。\n有效字符串需满足:\n左括号必须用相同类型的右括号闭合。 左括号必须以正确的顺序闭合。 比如 \u0026ldquo;()\u0026quot;、\u0026rdquo;()[]{}\u0026quot;、\u0026quot;{[]}\u0026quot; 都是有效字符串,而 \u0026ldquo;(]\u0026rdquo; 、\u0026quot;([)]\u0026quot; 则不是。\n这个问题实际是 Leetcode 的一道题目,我们可以利用栈 Stack 来解决这个问题。\n首先我们将括号间的对应规则存放在 Map 中,这一点应该毋容置疑; 创建一个栈。遍历字符串,如果字符是左括号就直接加入stack中,否则将stack 的栈顶元素与这个括号做比较,如果不相等就直接返回 false。遍历结束,如果stack为空,返回 true。 public boolean isValid(String s){ // 括号之间的对应规则 HashMap\u0026lt;Character, Character\u0026gt; mappings = new HashMap\u0026lt;Character, Character\u0026gt;(); mappings.put(\u0026#39;)\u0026#39;, \u0026#39;(\u0026#39;); mappings.put(\u0026#39;}\u0026#39;, \u0026#39;{\u0026#39;); mappings.put(\u0026#39;]\u0026#39;, \u0026#39;[\u0026#39;); Stack\u0026lt;Character\u0026gt; stack = new Stack\u0026lt;Character\u0026gt;(); char[] chars = s.toCharArray(); for (int i = 0; i \u0026lt; chars.length; i++) { if (mappings.containsKey(chars[i])) { char topElement = stack.empty() ? \u0026#39;#\u0026#39; : stack.pop(); if (topElement != mappings.get(chars[i])) { return false; } } else { stack.push(chars[i]); } } return stack.isEmpty(); } 反转字符串 # 将字符串中的每个字符先入栈再出栈就可以了。\n维护函数调用 # 最后一个被调用的函数必须先完成执行,符合栈的 后进先出(LIFO, Last In First Out) 特性。\n栈的实现 # 栈既可以通过数组实现,也可以通过链表实现。两种情况下,入栈、出栈的时间复杂度均为O(1)\n下面使用数组下实现栈,具有push()、pop() (返回栈顶元素并出栈)、peek() (返回栈顶元素不出栈)、isEmpty() 、size() 这些基本的方法\n每次入栈前先判断栈容量是否够用,如果不够用就用Arrays.copyOf() 进行扩容\npublic class MyStack { private int[] storage;//存放栈中元素的数组 private int capacity;//栈的容量 private int count;//栈中元素数量 private static final int GROW_FACTOR = 2; //不带初始容量的构造方法。默认容量为8 public MyStack() { this.capacity = 8; this.storage=new int[8]; this.count = 0; } //带初始容量的构造方法 public MyStack(int initialCapacity) { if (initialCapacity \u0026lt; 1) throw new IllegalArgumentException(\u0026#34;Capacity too small.\u0026#34;); this.capacity = initialCapacity; this.storage = new int[initialCapacity]; this.count = 0; } //入栈 public void push(int value) { if (count == capacity) { ensureCapacity(); } storage[count++] = value; } //确保容量大小 private void ensureCapacity() { int newCapacity = capacity * GROW_FACTOR; storage = Arrays.copyOf(storage, newCapacity); capacity = newCapacity; } //返回栈顶元素并出栈 private int pop() { if (count == 0) throw new IllegalArgumentException(\u0026#34;Stack is empty.\u0026#34;); count--; return storage[count]; } //返回栈顶元素不出栈 private int peek() { if (count == 0){ throw new IllegalArgumentException(\u0026#34;Stack is empty.\u0026#34;); }else { return storage[count-1]; } } //判断栈是否为空 private boolean isEmpty() { return count == 0; } //返回栈中元素的个数 private int size() { return count; } } /*---- MyStack myStack = new MyStack(3); myStack.push(1); myStack.push(2); myStack.push(3); myStack.push(4); myStack.push(5); myStack.push(6); myStack.push(7); myStack.push(8); System.out.println(myStack.peek());//8 System.out.println(myStack.size());//8 for (int i = 0; i \u0026lt; 8; i++) { System.out.println(myStack.pop()); } System.out.println(myStack.isEmpty());//true myStack.pop();//报错:java.lang.IllegalArgumentException: Stack is empty. */ 队列 # 队列简介 # 队列是**先进先出(FIFO,First In,First Out)**的线性表\n通常用链表或数组来实现,用数组实现的队列叫做顺序队列,用链表实现的队列叫做链式队列。\n队列只允许在后端(rear)进行插入操作也就是入队enqueue,在前端(front)进行删除操作也就是出队 dequeue\n队列的操作方式和堆栈类似,唯一的区别在于队列只允许新数据在后端进行添加(不允许在后端删除)\n假设队列中有n个元素。 访问:O(n)//最坏情况 插入删除:O(1)//后端插入前端删除元素\n队列分类 # 单队列 # 这是常见的队列,每次添加元素时,都是添加到队尾。单队列又分为顺序队列(数组实现)和链式队列(链表实现)\n顺序队列存在假溢出:即明明有位置却不能添加\n假设下图是一个顺序队列,我们将前两个元素 1,2 出队,并入队两个元素 7,8。当进行入队、出队操作的时候,front 和 rear 都会持续往后移动,当 rear 移动到最后的时候,我们无法再往队列中添加数据,即使数组中还有空余空间,这种现象就是 ”假溢出“ 。除了假溢出问题之外,如下图所示,当添加元素 8 的时候,rear 指针移动到数组之外(越界)\n为了避免当只有一个元素的时候,队头和队尾重合使处理变得麻烦,所以引入两个指针,front 指针指向对头元素(不是头结点),rear 指针指向队列最后一个元素的下一个位置,这样当 front 等于 rear 时,此队列不是还剩一个元素,而是空队列。——From 《大话数据结构》\n(当只有一个元素时,front 指向0,rear指向1)\n循环队列 # 循环队列可以解决顺序队列的假溢出和越界问题。解决办法就是:从头开始,这样也就会形成头尾相接的循环,这也就是循环队列名字的由来。 (超出的时候,将rear指向0下标)。之后再添加时,向后移动即可\n顺序队列中,我们说 front==rear 的时候队列为空,循环队列中则不一样,也可能为满,如上图所示。解决办法有两种: 可以设置一个标志变量 flag,当 front==rear 并且 flag=0 的时候队列为空,当front==rear 并且 flag=1 的时候队列为满。 队列为空的时候就是 front==rear ,队列满的时候,我们保证数组还有一个空闲的位置,rear 就指向这个空闲位置,如下图所示,那么现在判断队列是否为满的条件就是: (rear+1) % QueueSize= front 。 其实也就是换一个定义罢了 常见应用场景 # 当我们需要按照一定顺序来处理数据的时候可以考虑使用队列这个数据结构\n阻塞队列: 阻塞队列可以看成在队列基础上加了阻塞操作的队列。当队列为空的时候,出队操作阻塞,当队列满的时候,入队操作阻塞。使用阻塞队列我们可以很容易**实现“生产者 - 消费者“**模型 线程池中的请求/任务队列: 线程池中没有空闲线程时,新的任务请求线程资源时,线程池该如何处理呢?答案是将这些请求放在队列中,当有空闲线程的时候,会循环中反复从队列中获取任务来执行。队列分为无界队列(基于链表)和有界队列(基于数组)。无界队列的特点就是可以一直入列,除非系统资源耗尽,比如 :FixedThreadPool 使用无界队列 LinkedBlockingQueue。但是有界队列就不一样了,当队列满的话后面再有任务/请求就会拒绝,在 Java 中的体现就是会抛出**java.util.concurrent.RejectedExecutionException** 异常。 Linux 内核进程队列(按优先级排队) 现实生活中的派对,播放器上的播放列表; 消息队列 等等\u0026hellip;\u0026hellip; "},{"id":309,"href":"/zh/docs/technology/Review/java_guide/database/ly0501lybasis/","title":"数据库基础","section":"数据库","content":" 转载自https://github.com/Snailclimb/JavaGuide(添加小部分笔记)感谢作者!\n这部分内容由于涉及太多概念性内容,所以参考了维基百科和百度百科相应的介绍。\n什么是数据库,数据库管理系统,数据库系统,数据库管理员 # 数据库:数据库(DataBase 简称DB)就是信息的集合或者说数据库管理系统管理的数据的集合。 数据库管理系统:数据库管理系统(Database Management System 简称DBMS)是一种操纵和管理数据库的大型软件,通常用于建立、使用和维护 数据库。 数据库系统(范围最大):数据库系统(Data Base System,简称DBS)通常由**软件、数据和数据管理员(DBA)**组成。 数据库管理员:数据库管理员(Database Adminitrator,简称DBA)负责全面管理和控制数据库系统 (是一个人) 数据库系统基本构成如下图所示\n什么是元组,码,候选码,主码,外码,主属性,非主属性 # 元组:元组(tuple)是关系数据库中的基本概念,关系是一张表,表中的每行(即数据库中的每条记录)就是一个元组,每列就是一个属性。在二维表里,元组也成为行 码:码就是能唯一标识实体的属性,对应表中的列 候选码:若关系中的某一属性或属性组的值能唯一的标识一个元组,而其任何、子集都不能再标识,则称该属性组为候选码。例如:在学生实体中,“学号”是能唯一的区分学生实体的,同时又假设“姓名”、“班级”的属性组合足以区分学生实体,那么**{学号}和{姓名,班级}都是候选码**。 主码:主码也叫主键,主码是从候选码中选出来的。一个实体集中只能有一个主码,但可以有多个候选码 外码:外码也叫外键。如果一个关系中的一个属性是另外一个关系中的主码则这个属性为外码。 主属性 : 候选码中出现过的属性称为主属性(这里强调单个)。比如关系 工人(工号,身份证号,姓名,性别,部门). 显然工号和身份证号都能够唯一标示这个关系,所以都是候选码。工号、身份证号这两个属性就是主属性。如果主码是一个属性组,那么属性组中的属性都是主属性。 非主属性: 不包含在任何一个候选码中的属性称为非主属性。比如在关系——学生(学号,姓名,年龄,性别,班级)中,主码是“学号”,那么其他的“姓名”、“年龄”、“性别”、“班级”就都可以称为非主属性。 主键和外键有什么区别 # 主键(主码) :主键用于唯一标识一个元组,不能有重复,不允许为空。一个表只能有一个主键。 外键(外码) :外键用来和其他表建立联系用,外键是另一表的主键,外键是可以有重复的,可以是空值。一个表可以有多个外键 为什么不推荐使用外键与级联 # 对于外键和级联,阿里巴巴开发手册这样说道\n【强制】不得使用外键与级联,一切外键概念必须在应用层解决。\n说明: 以学生和成绩的关系为例,学生表中的 student_id 是主键,那么成绩表中的 student_id 则为外键。如果更新学生表中的 student_id,同时触发成绩表中的 student_id 更新,即为级联更新。\n缺点: 外键与级联更新适用于单机低并发,不适合分布式、高并发集群; 级联更新是强阻塞,存在数据库更新风暴的风 险; 外键影响数据库的插入速度\n为什么不要使用外键\n增加了复杂性\na. 每次做DELETE 或者UPDATE都必须考虑外键约束,会导致开发的时候很痛苦, 测试数据极为不方便; b. 外键的主从关系是定的,假如那天需求有变化,数据库中的这个字段根本不需要和其他表有关联的话就会增加很多麻烦。\n增加了额外操作\n数据库需要增加维护外键的工作,比如当我们做一些涉及外键字段的增,删,更新操作之后,需要触发相关操作去检查,保证数据的的一致性和正确性,这样会不得不消耗资源;(个人觉得这个不是不用外键的原因,因为即使你不使用外键,你在应用层面也还是要保证的。所以,我觉得这个影响可以忽略不计。)\n对分库分表很不友好:因为分库分表下外键无法生效\n\u0026hellip;\n外键的一些好处\n保证了数据库数据的一致性和完整性; 级联操作方便,减轻了程序代码量; \u0026hellip;\u0026hellip; 如果系统不涉及分库分表,并发量不是很高的情况还是可以考虑使用外键的\n什么是ER图 # 做一个项目的时候一定要试着画 ER 图来捋清数据库设计,这个也是面试官问你项目的时候经常会被问道的。\nE-R图,也称 实体-联系图(Entity Relationship Diagram),提供表示实体类型、属性和关系,用来描述现实世界的概念模型。它是描述现实世界关系概念模型的有效方法,是表示概念关系模型的一种方式 下图是一个学生选课的 ER 图,每个学生可以选若干门课程,同一门课程也可以被若干人选择,所以它们之间的关系是多对多(M: N)。另外,还有其他两种关系是:1 对 1(1:1)、1 对多(1: N) 将ER图转换成数据库实际的关系模型(实际设计中,我们通常会将任课教师也作为一个实体来处理)\n数据库范式了解吗 # 1NF(第一范式) 属性(对应于表中的字段)不能再被分割,也就是这个字段只能是一个值,不能再分为多个其他的字段了。1NF 是所有关系型数据库的最基本要求 ,也就是说关系型数据库中创建的表一定满足第一范式。\n2NF(第二范式) 2NF 在 1NF 的基础之上,消除了非主属性对于码的部分函数依赖。如下图所示,展示了第一范式到第二范式的过渡。第二范式在第一范式的基础上增加了一个列,这个列称为主键,非主属性都依赖于主键。\n第二范式要求,在满足第二范式的基础上,还要满足数据表里得每一条数据记录,都是可唯一标识的。而且所有非主键字段,都必须完全依赖主键,不能只依赖主键的一部分,如下,主键为商品名称、供应商名称,是主码是属性组。而供应商电话只依赖于供应商id,商品价格只依赖于价格。所以不满足第二范式\n3NF(第三范式)3NF 在 2NF 的基础之上,消除了非主属性对于码的传递函数依赖 。符合 3NF 要求的数据库设计,基本上解决了数据冗余过大,插入异常,修改异常,删除异常的问题。比如在关系 R(学号 , 姓名, 系名,系主任)中,学号 → 系名,系名 → 系主任,所以存在非主属性系主任对于学号的传递函数依赖,所以该表的设计,不符合 3NF 的要求。\n确保数据表中的每一个非主键字段都和主键字段相关,也就是说,要求数据表中的所有非主键字段不能依赖于其他非主键字段。(即,不能存在非主属性A依赖于非主属性B,非主属性B依赖于主键C的情况,即存在A \u0026ndash;\u0026gt; B \u0026ndash;\u0026gt; C 的决定关系),规则的意思是所有非主键属性之间不能有依赖关系,必须互相独立\n简单举例:\n部门信息表:每个部门有部门编号(dept_id)、部门名称、部门简介等消息\n员工信息表:每个员工有员工编号、姓名、部门编号。(注意,列出部门编号就不能再将部门名称、部门简介等部门相关的信息再加入员工信息表中,否则将不满足第3范式(但其实是满足第二范式的))\n总结\n1NF:属性不可再分。 2NF:1NF 的基础之上,消除了非主属性对于码的部分函数依赖。 3NF:3NF 在 2NF 的基础之上,消除了非主属性对于码的传递函数依赖 。 一些概念:\n函数依赖(functional dependency) :若在一张表中,在属性(或属性组)X 的值确定的情况下,必定能确定属性 Y 的值,那么就可以说 Y 函数依赖于 X,写作 X → Y。 部分函数依赖(partial functional dependency) :如果 X→Y,并且存在 X 的一个真子集 X0,使得 X0→Y,则称 Y 对 X 部分函数依赖。比如学生基本信息表 R 中(学号,身份证号,姓名)当然学号属性取值是唯一的,在 R 关系中,(学号,身份证号)-\u0026gt;(姓名),(学号)-\u0026gt;(姓名),(身份证号)-\u0026gt;(姓名);所以姓名部分函数依赖与(学号,身份证号);(感觉这个例子虽然是对的,但是不利于理解第二范式) 完全函数依赖(Full functional dependency) :在一个关系中,若某个非主属性数据项依赖于全部关键字称之为完全函数依赖。比如学生基本信息表 R(学号,班级,姓名)假设不同的班级学号有相同的,班级内学号不能相同,在 R 关系中,(学号,班级)-\u0026gt;(姓名),但是(学号)-\u0026gt;(姓名)不成立,(班级)-\u0026gt;(姓名)不成立,所以姓名完全函数依赖与(学号,班级); 传递函数依赖 : 在关系模式 R(U)中,设 X,Y,Z 是 U 的不同的属性子集,如果 X 确定 Y、Y 确定 Z,且有 X 不包含 Y,Y 不确定 X,(X∪Y)∩Z=空集合,则称 Z 传递函数依赖(transitive functional dependency) 于 X。传递函数依赖会导致数据冗余和异常。传递函数依赖的 Y 和 Z 子集往往同属于某一个事物,因此可将其合并放到一个表中。比如在关系 R(学号 , 姓名, 系名,系主任)中,学号 → 系名,系名 → 系主任,所以存在非主属性系主任对于学号的传递函数依赖。。 什么是存储过程 # 作用:我们可以把存储过程看成是一些 SQL 语句的集合,中间加了点逻辑控制语句。存储过程在业务比较复杂的时候是非常实用的,比如很多时候我们完成一个操作可能需要写一大串 SQL 语句,这时候我们就可以写有一个存储过程,这样也方便了我们下一次的调用。存储过程一旦调试完成通过后就能稳定运行,另外,使用存储过程比单纯 SQL 语句执行要快,因为存储过程是预编译过的。\n存储过程在互联网公司应用不多,因为存储过程难以调试和扩展,而且没有移植性,还会消耗数据库资源\n阿里巴巴Java开发手册要求禁止使用存储过程 drop、delete与truncate区别 # 用法不同 # drop(丢弃数据): drop table 表名 ,直接将表都删除掉,在删除表的时候使用。 truncate (清空数据) : truncate table 表名 ,只删除表中的数据,再插入数据的时候自增长 id 又从 1 开始,在清空表中数据的时候使用。 delete(删除数据) : delete from 表名 where 列名=值,删除某一行的数据,如果不加 where 子句和truncate table 表名作用类似。 truncate 和不带 where 子句的 delete、以及 drop 都会删除表内的数据,但是 truncate 和 delete 只删除数据不删除表的结构(定义),执行 drop 语句,此表的结构也会删除,也就是执行 drop 之后对应的表不复存在。\n属于不同的数据库语言 # truncate 和 drop 属于 DDL(数据定义语言)语句,操作立即生效,原数据不放到 rollback segment 中,不能回滚,操作不触发 trigger。而 **delete 语句是 DML (数据库操作语言)**语句,这个操作会放到 rollback segement 中,事务提交之后才生效。 DML语句和DDL语句区别 DML 是**数据库操作语言(Data Manipulation Language)**的缩写,是指对数据库中表记录的操作,主要包括表记录的插入(insert)、更新(update)、删除(delete)和查询(select),是开发人员日常使用最频繁的操作。 **DDL (Data Definition Language)**是数据定义语言的缩写,简单来说,就是对数据库内部的对象进行创建、删除、修改的操作语言。它和 DML 语言的最大区别是 DML 只是对表内部数据的操作,而不涉及到表的定义、结构的修改,更不会涉及到其他对象。DDL 语句更多的被数据库管理员(DBA)所使用,一般的开发人员很少使用。 由于select不会对表进行破坏,所以有的地方也会把select单独区分开叫做数据库查询语言DQL(Data Query Language) 执行速度不同 # 一般来说:drop \u0026gt; truncate \u0026gt; delete(这个我没有设计测试过)\ndelete命令执行的时候会产生数据库的binlog日志,而日志记录是需要消耗时间的,但是也有个好处方便数据回滚恢复。\ntruncate命令执行的时候不会产生数据库日志,因此比delete要快。除此之外,还会把表的自增值重置和索引恢复到初始大小等。\ndrop命令会把表占用的空间全部释放掉。\nTips:你应该更多地关注在使用场景上,而不是执行效率。\n数据库设计通常分为哪几步 # 需求分析 : 分析用户的需求,包括数据、功能和性能需求。 概念结构设计 : 主要采用 E-R 模型进行设计,包括画 E-R 图。 逻辑结构设计 : 通过将 E-R 图转换成表,实现从 E-R 模型到关系模型的转换。 物理结构设计 : 主要是为所设计的数据库选择合适的存储结构和存取路径。 数据库实施 : 包括编程、测试和试运行 数据库的运行和维护 : 系统的运行与数据库的日常维护。 "},{"id":310,"href":"/zh/docs/technology/Review/java_guide/java/JVM/ly0408lyjdk-monitoring-and-troubleshooting-tools/","title":"jvm监控和故障处理工具 总结","section":"Java基础","content":" 转载自https://github.com/Snailclimb/JavaGuide(添加小部分笔记)感谢作者!\nJDK 命令行工具 # 这些命令在 JDK 安装目录下的 bin 目录下:\njps (JVM Process Status): 类似 UNIX 的 ps 命令。用于查看所有 Java 进程的启动类、传入参数和 Java 虚拟机参数等信息; jstat(JVM Statistics Monitoring Tool): 用于收集 HotSpot 虚拟机各方面的运行数据; jinfo (Configuration Info for Java) : Configuration Info for Java,显示虚拟机配置信息; jmap (Memory Map for Java) : 生成堆转储快照; jhat (JVM Heap Dump Browser) : 用于分析 heapdump 文件,它会建立一个 HTTP/HTML 服务器,让用户可以在浏览器上查看分析结果; jstack (Stack Trace for Java) : 生成虚拟机当前时刻的线程快照,线程快照就是当前虚拟机内每一条线程正在执行的方法堆栈的集合。 jps: 查看所有 Java 进程 # jps(JVM Process Status) 命令类似 UNIX 的 ps 命令。\njps:显示虚拟机执行主类名称以及这些进程的本地虚拟机唯一 ID(Local Virtual Machine Identifier,LVMID)。jps -q :只输出进程的本地虚拟机唯一 ID。\nC:\\Users\\SnailClimb\u0026gt;jps 7360 NettyClient2 17396 7972 Launcher 16504 Jps 17340 NettyServer jps -l:输出主类的全名,如果进程执行的是 Jar 包,输出 Jar 路径。\nC:\\Users\\SnailClimb\u0026gt;jps -l 7360 firstNettyDemo.NettyClient2 17396 7972 org.jetbrains.jps.cmdline.Launcher 16492 sun.tools.jps.Jps 17340 firstNettyDemo.NettyServer jps -v:输出虚拟机进程启动时 JVM 参数。\njps -m:输出传递给 Java 进程 main() 函数的参数。\njstat:监视虚拟机各种运行状态信息 # jstat ( JVM Statistics Monitoring Tool ) 使用于监视虚拟机各种运行状态信息的命令行工具。\n可以显示本地或者远程(需要远程主机提供RMI支持)虚拟机进程中的类信息、内存、垃圾收集、JIT编译等运行数据,在没有GUI,只提供了纯文本控制台环境的服务器上,它将是运行期间定位虚拟机性能问题的首选工具\njstat 命令使用格式\njstat -\u0026lt;option\u0026gt; [-t] [-h\u0026lt;lines\u0026gt;] \u0026lt;vmid\u0026gt; [\u0026lt;interval\u0026gt; [\u0026lt;count\u0026gt;]] 比如 jstat -gc -h3 31736 1000 10表示分析进程 id 为 31736 的 gc 情况,每隔 1000ms 打印一次记录,打印 10 次停止,每 3 行后打印指标头部。\nλ jstat -gc -h3 12224 1000 10 常见的option如下 , 下面的vmid,即vm的id (id值)\njstat -class vmid :显示 ClassLoader 的相关信息; jstat -compiler vmid :显示 JIT 编译的相关信息; jstat -gc vmid :显示与 GC 相关的堆信息; jstat -gccapacity vmid :显示各个代的容量及使用情况; jstat -gcnew vmid :显示新生代信息; jstat -gcnewcapcacity vmid :显示新生代大小与使用情况; jstat -gcold vmid :显示老年代和永久代的行为统计,从jdk1.8开始,该选项仅表示老年代,因为永久代被移除了; jstat -gcoldcapacity vmid :显示老年代的大小; jstat -gcpermcapacity vmid :显示永久代大小,从jdk1.8开始,该选项不存在了,因为永久代被移除了; jstat -gcutil vmid :显示垃圾收集信息 使用jstat -gcutil -h3 12224 1000 10\n另外,加上 -t参数可以在输出信息上加一个 Timestamp 列,显示程序的运行时间。 各个参数的含义\njinfo:实时地查看和调整虚拟机各项参数 # jinfo vmid :输出当前 jvm 进程的全部参数和系统属性 (第一部分是系统的属性,第二部分是 JVM 的参数)。 如下图: jinfo -flag name vmid :输出对应名称的参数的具体值。比如输出 MaxHeapSize、查看当前 jvm 进程是否开启打印 GC 日志 ( -XX:PrintGCDetails :详细 GC 日志模式,这两个都是默认关闭的)。\nC:\\Users\\SnailClimb\u0026gt;jinfo -flag MaxHeapSize 17340 -XX:MaxHeapSize=2124414976 C:\\Users\\SnailClimb\u0026gt;jinfo -flag PrintGC 17340 -XX:-PrintGC 使用 jinfo 可以在不重启虚拟机的情况下,可以动态的修改 jvm 的参数。尤其在线上的环境特别有用,请看下面的例子: 使用```jinfo -flag [+|-]name vmid 开启或者关闭对应名称的参数:\nC:\\Users\\SnailClimb\u0026gt;jinfo -flag PrintGC 17340 -XX:-PrintGC C:\\Users\\SnailClimb\u0026gt;jinfo -flag +PrintGC 17340 C:\\Users\\SnailClimb\u0026gt;jinfo -flag PrintGC 17340 -XX:+PrintGC jmap:生成堆转储快照 # jmap(Memory Map for Java )命令用于生成堆转储快照。如果不使用jmap命令,要想获取java堆转储,可以使用-XX:+HeapDumpOutOfMemoryError参数,可以让虚拟机在OOM异常出现之后,自动生成dump文件,Linux命令下通过kill -3发送进程推出信号也能拿到dump文件\njmap 的作用并不仅仅是为了获取 dump 文件,它还可以查询 finalizer 执行队列、Java 堆和永久代的详细信息,如空间使用率、当前使用的是哪种收集器等。和jinfo一样,jmap有不少功能在 Windows 平台下也是受限制的。\n将指定应用程序的堆 快照输出到桌面,后面可以通过jhat、Visual VM等工具分析该堆文件\nC:\\Users\\SnailClimb\u0026gt;jmap -dump:format=b,file=C:\\Users\\SnailClimb\\Desktop\\heap.hprof 17340 Dumping heap to C:\\Users\\SnailClimb\\Desktop\\heap.hprof ... Heap dump file created jhat:分析heapdump文件 # jhat 用于分析 heapdump 文件,它会建立一个 HTTP/HTML 服务器,让用户可以在浏览器上查看分析结果。\nC:\\Users\\SnailClimb\u0026gt;jhat C:\\Users\\SnailClimb\\Desktop\\heap.hprof Reading from C:\\Users\\SnailClimb\\Desktop\\heap.hprof... Dump file created Sat May 04 12:30:31 CST 2019 Snapshot read, resolving... Resolving 131419 objects... Chasing references, expect 26 dots.......................... Eliminating duplicate references.......................... Snapshot resolved. Started HTTP server on port 7000 Server is ready. 之后访问 http://localhost:7000/ 即可,如下: 进入/histo 会发现,有这个东西 这个对象创建了9次,因为我是在第9次循环后dump堆快照的\n//测试代码如下 public class MyMain { private byte[] x = new byte[10 * 1024 * 1024];//10M public static void main(String[] args) throws InterruptedException { System.out.println(\u0026#34;开始循环--\u0026#34;); int i=0; while (++i\u0026gt;0) { String a=new Date().toString(); MyMain myMain = new MyMain(); System.out.println(i+\u0026#34;循环中---\u0026#34; + new Date()); TimeUnit.SECONDS.sleep(10); } } } jstack: 生成虚拟机当前时刻的线程快照 # jstack (Stack Trace for Java ) 命令用于生成虚拟机当前时刻的线程快照。线程快照就是当前虚拟机内每一条线程正在执行的方法堆栈的集合\n生成线程快照的目的主要是定位线程长时间出现停顿的原因,如线程间死锁、死循环、请求外部资源导致的长时间等待等都是导致线程长时间停顿的原因。线程出现停顿的时候通过jstack来查看各个线程的调用堆栈,就可以知道没有响应的线程到底在后台做些什么事情,或者在等待些什么资源。\n线程死锁的代码,通过jstack 命令进行死锁检查,输出死锁信息,找到发生死锁的线程\npackage com.jvm; public class DeadLockDemo { private static Object resource1 = new Object();//资源 1 private static Object resource2 = new Object();//资源 2 public static void main(String[] args) { new Thread(() -\u0026gt; { synchronized (resource1) { System.out.println(Thread.currentThread() + \u0026#34;get resource1\u0026#34;); try { Thread.sleep(1000); } catch (InterruptedException e) { e.printStackTrace(); } System.out.println(Thread.currentThread() + \u0026#34;waiting get resource2\u0026#34;); synchronized (resource2) { System.out.println(Thread.currentThread() + \u0026#34;get resource2\u0026#34;); } } }, \u0026#34;线程 1\u0026#34;).start(); new Thread(() -\u0026gt; { synchronized (resource2) { System.out.println(Thread.currentThread() + \u0026#34;get resource2\u0026#34;); try { Thread.sleep(1000); } catch (InterruptedException e) { e.printStackTrace(); } System.out.println(Thread.currentThread() + \u0026#34;waiting get resource1\u0026#34;); synchronized (resource1) { System.out.println(Thread.currentThread() + \u0026#34;get resource1\u0026#34;); } } }, \u0026#34;线程 2\u0026#34;).start(); } } /*------ Thread[线程 1,5,main]get resource1 Thread[线程 2,5,main]get resource2 Thread[线程 2,5,main]waiting get resource1 Thread[线程 1,5,main]waiting get resource2 */ 分析 线程 A 通过 synchronized (resource1) 获得 resource1 的监视器锁,然后通过 Thread.sleep(1000);让线程 A 休眠 1s 为的是让线程 B 得到执行然后获取到 resource2 的监视器锁。线程 A 和线程 B 休眠结束了都开始企图请求获取对方的资源,然后这两个线程就会陷入互相等待的状态,这也就产生了死锁。\n通过jstack 命令分析\n# 先使用jps 找到思索地那个类 C:\\Users\\SnailClimb\u0026gt;jps 13792 KotlinCompileDaemon 7360 NettyClient2 17396 7972 Launcher 8932 Launcher 9256 DeadLockDemo 10764 Jps 17340 NettyServer ## 然后使用jstack命令分析 C:\\Users\\SnailClimb\u0026gt;jstack 9256 输出的部分如下\nFound one Java-level deadlock: ============================= \u0026#34;线程 2\u0026#34;: waiting to lock monitor 0x000000000333e668 (object 0x00000000d5efe1c0, a java.lang.Object), which is held by \u0026#34;线程 1\u0026#34; \u0026#34;线程 1\u0026#34;: waiting to lock monitor 0x000000000333be88 (object 0x00000000d5efe1d0, a java.lang.Object), which is held by \u0026#34;线程 2\u0026#34; Java stack information for the threads listed above: =================================================== \u0026#34;线程 2\u0026#34;: at DeadLockDemo.lambda$main$1(DeadLockDemo.java:31) - waiting to lock \u0026lt;0x00000000d5efe1c0\u0026gt; (a java.lang.Object) - locked \u0026lt;0x00000000d5efe1d0\u0026gt; (a java.lang.Object) at DeadLockDemo$$Lambda$2/1078694789.run(Unknown Source) at java.lang.Thread.run(Thread.java:748) \u0026#34;线程 1\u0026#34;: at DeadLockDemo.lambda$main$0(DeadLockDemo.java:16) - waiting to lock \u0026lt;0x00000000d5efe1d0\u0026gt; (a java.lang.Object) - locked \u0026lt;0x00000000d5efe1c0\u0026gt; (a java.lang.Object) at DeadLockDemo$$Lambda$1/1324119927.run(Unknown Source) at java.lang.Thread.run(Thread.java:748) Found 1 deadlock. 找到了发生死锁的线程的具体信息\nJDK可视化分析工具 # JConsole:Java监视与管理控制台 # JConsole 是基于 JMX 的可视化监视、管理工具。可以很方便的监视本地及远程服务器的 java 进程的内存使用情况。你可以在控制台输出**console命令启动或者在 JDK 目录下的 bin 目录找到jconsole.exe然后双击启动**. 对于远程连接\n在启动方\n-Djava.rmi.server.hostname=外网访问 ip 地址 -Dcom.sun.management.jmxremote.port=60001 //监控的端口号 -Dcom.sun.management.jmxremote.authenticate=false //关闭认证 -Dcom.sun.management.jmxremote.ssl=false 实例:\njava -Djava.rmi.server.hostname=192.168.200.200 -Dcom.sun.management.jmxremote -Dcom.sun.management.jmxremote.port=60001 -Dcom.sun.management.jmxremote.ssl=false -Dcom.sun.management.jmxremote.authenticate=false com.jvm.DeadLockDemo # 其中 192.168.200.200 为启动该类的机器的ip,而不是谁要连接 在使用 JConsole 连接时,远程进程地址如下:\n外网访问 ip 地址:60001 注意,虚拟机中(这里ip xxx.200是虚拟机ip),需要开放的端口不只是60001,还要通过 netstat -nltp开放另外两个端口 centos中使用\nfirewall-cmd --zone=public --add-port=45443/tcp --permanent firewall-cmd --zone=public --add-port=36521/tcp --permanent firewall-cmd --zone=public --add-port=60001/tcp --permanent firewall-cmd --reload #重启firewall 之后才能连接上\n内存监控 # JConsole 可以显示当前内存的详细信息。不仅包括堆内存/非堆内存的整体信息,还可以细化到 eden 区、survivor 区等的使用情况,如下图所示。\n点击右边的“执行 GC(G)”按钮可以强制应用程序执行一个 Full GC。\n新生代 GC(Minor GC):指发生新生代的的垃圾收集动作,Minor GC 非常频繁,回收速度一般也比较快。\n老年代 GC(Major GC/Full GC):指发生在老年代的 GC,出现了 Major GC 经常会伴随至少一次的 Minor GC(并非绝对),Major GC 的速度一般会比 Minor GC 的慢 10 倍以上。\n线程监控 # 类似我们前面讲的 jstack 命令,不过这个是可视化的。\n最下面有一个\u0026quot;检测死锁 (D)\u0026ldquo;按钮,点击这个按钮可以自动为你找到发生死锁的线程以及它们的详细信息 。 VisualVM: 多合一故障处理工具 # VisualVM 提供在 Java 虚拟机 (Java Virutal Machine, JVM) 上运行的 Java 应用程序的详细信息。在 VisualVM 的图形用户界面中,您可以方便、快捷地查看多个 Java 应用程序的相关信息。Visual VM 官网: https://visualvm.github.io/open in new window 。Visual VM 中文文档: https://visualvm.github.io/documentation.htmlopen in new window。\n下面这段话摘自《深入理解 Java 虚拟机》。\nVisualVM(All-in-One Java Troubleshooting Tool)是到目前为止随 JDK 发布的功能最强大的运行监视和故障处理程序,官方在 VisualVM 的软件说明中写上了“All-in-One”的描述字样,预示着他除了运行监视、故障处理外,还提供了很多其他方面的功能,如性能分析(Profiling)。VisualVM 的性能分析功能甚至比起 JProfiler、YourKit 等专业且收费的 Profiling 工具都不会逊色多少,而且 VisualVM 还有一个很大的优点:不需要被监视的程序基于特殊 Agent 运行,因此他对应用程序的实际性能的影响很小,使得他可以直接应用在生产环境中。这个优点是 JProfiler、YourKit 等工具无法与之媲美的。\nVisualVM 基于 NetBeans 平台开发,因此他一开始就具备了插件扩展功能的特性,通过插件扩展支持,VisualVM 可以做到:\n显示虚拟机进程以及进程的配置、环境信息(jps、jinfo)。 监视应用程序的 CPU、GC、堆、方法区以及线程的信息(jstat、jstack)。 dump 以及分析堆转储快照(jmap、jhat)。 方法级的程序运行性能分析,找到被调用最多、运行时间最长的方法。 离线程序快照:收集程序的运行时配置、线程 dump、内存 dump 等信息建立一个快照,可以将快照发送开发者处进行 Bug 反馈。 其他 plugins 的无限的可能性\u0026hellip;\u0026hellip; 这里就不具体介绍 VisualVM 的使用,如果想了解的话可以看:\nhttps://visualvm.github.io/documentation.htmlopen in new window https://www.ibm.com/developerworks/cn/java/j-lo-visualvm/index.html "},{"id":311,"href":"/zh/docs/technology/Review/java_guide/java/JVM/ly0406lyjvm-params/","title":"jvm参数","section":"Java基础","content":" 转载自https://github.com/Snailclimb/JavaGuide(添加小部分笔记)感谢作者!\n本文由 JavaGuide 翻译自 https://www.baeldung.com/jvm-parametersopen in new window,并对文章进行了大量的完善补充。翻译不易,如需转载请注明出处,作者: baeldungopen in new window 。\n概述 # 本篇文章中,将掌握最常用的JVM参数配置。下面提到了一些概念,堆、方法区、垃圾回收等。\n堆内存相关 # Java 虚拟机所管理的内存中最大的一块,Java 堆是所有线程共享的一块内存区域,在虚拟机启动时创建。此内存区域的唯一目的就是存放对象实例,几乎 所有的对象实例以及数组都在这里分配内存。\n显式指定堆内存-Xms和-Xmx # 与性能相关的最常见实践之一是根据应用程序要求初始化堆内存。\n如果我们需要指定最小和最大堆大小(推荐显示指定大小):\n-Xms\u0026lt;heap size\u0026gt;[unit] -Xmx\u0026lt;heap size\u0026gt;[unit] heap size 表示要初始化内存的具体大小。 unit 表示要初始化内存的单位。单位为***“ g”*** (GB) 、“ m”(MB)、“ k”(KB)。 举例,为JVM分配最小2GB和最大5GB的堆内存大小\n-Xms2G -Xmx5G 显示新生代内存(Young Generation) # 在堆总可用内存配置完成之后,第二大影响因素是为 Young Generation 在堆内存所占的比例。默认情况下,YG 的最小大小为 1310 MB,最大大小为无限制。\n两种指定 新生代内存(Young Generation) 大小的方法\n通过 -XX:NewSize 和 -XX:MaxNewSize -XX:NewSize=\u0026lt;young size\u0026gt;[unit] -XX:MaxNewSize=\u0026lt;young size\u0026gt;[unit] 如,为新生代分配最小256m的内存,最大1024m的内存我们的参数为:\n-XX:NewSize=256m -XX:MaxNewSize=1024m 通过-Xmn\u0026lt;young size\u0026gt;[unit] 指定 举例,为新生代分配256m的内存(NewSize与MaxNewSize设为一致) -Xmn256m 将新对象预留在新生代,由于 Full GC 的成本远高于 Minor GC,因此尽可能将对象分配在新生代是明智的做法,实际项目中根据 GC 日志分析新生代空间大小分配是否合理,适当通过“-Xmn”命令调节新生代大小,最大限度降低新对象直接进入老年代的情况。\n另外,你还可以通过 -XX:NewRatio=\u0026lt;int\u0026gt; 来设置老年代与新生代内存的比值。\n下面的参数,设置老年代与新生代内存的比例为1,即 老年代:新生代 = 1:1,新生代占整个堆栈的1/2 -XX:NewRadio=1\n显示指定永久代/元空间的大小 # 从Java 8开始,如果我们没有指定 Metaspace(元空间) 的大小,随着更多类的创建,虚拟机会耗尽所有可用的系统内存(永久代并不会出现这种情况)\nJDK 1.8 之前永久代还没被彻底移除的时候通常通过下面这些参数来调节方法区大小\n-XX:PermSize=N //方法区 (永久代) 初始大小 -XX:MaxPermSize=N //方法区 (永久代) 最大大小,超过这个值将会抛出 OutOfMemoryError 异常:java.lang.OutOfMemoryError: PermGen 相对而言,垃圾收集行为在这个区域是比较少出现的,但**并非数据进入方法区后就“永久存在”**了。\nJDK 1.8 的时候,方法区(HotSpot 的永久代)被彻底移除了(JDK1.7 就已经开始了),取而代之是元空间,元空间使用的是本地内存\n-XX:MetaspaceSize=N //设置 Metaspace 的初始(和最小大小) -XX:MaxMetaspaceSize=N //设置 Metaspace 的最大大小,如果不指定大小的话,随着更多类的创建,虚拟机会耗尽所有可用的系统内存。 垃圾收集相关 # 垃圾回收器 # 为了提高应用程序的稳定性,选择正确的垃圾收集算法至关重要\nJVM具有四种类型的GC实现:\n串行垃圾收集器 并行垃圾收集器 CMS垃圾收集器(并发) G1垃圾收集器(并发) 使用下列参数实现:\n-XX:+UseSerialGC -XX:+UseParallelGC -XX:+UseParNewGC -XX:+UseG1GC GC记录 # 为了严格监控应用程序的运行状况,应该始终检查JVM的垃圾回收性能。最简单的方法是以人类可读的格式记录GC活动\n通过以下参数\n-XX:+UseGCLogFileRotation -XX:NumberOfGCLogFiles=\u0026lt; number of log files \u0026gt; -XX:GCLogFileSize=\u0026lt; file size \u0026gt;[ unit ] -Xloggc:/path/to/gc.log "},{"id":312,"href":"/zh/docs/technology/Review/java_guide/java/JVM/ly0403lyclass-structure/","title":"类文件结构","section":"Java基础","content":" 转载自https://github.com/Snailclimb/JavaGuide(添加小部分笔记)感谢作者!\n概述 # Java中,JVM可以理解的代码就叫做字节码(即扩展名为.class的文件),它不面向任何特定的处理器,只面向虚拟机 Java语言通过字节码的方式,在一定程度上解决了传统解释型语言执行效率低的问题,同时又保留了解释型语言可移植的特点。所以Java程序运行时效率极高,且由于字节码并不针对一种特定的机器。因此,Java程序无需重新编译便可在多种不通操作系统的计算机运行 Clojure(Lisp 语言的一种方言)、Groovy、Scala 等语言都是运行在 Java 虚拟机之上。下图展示了不同的语言被不同的编译器编译成.class文件最终运行在 Java 虚拟机之上。.class文件的二进制格式可以使用 WinHexopen in new window 查看。 .class文件是不同语言在Java虚拟机之间的重要桥梁,同时也是支持Java跨平台很重要的一个原因\nClass文件结构总结 # 根据Java虚拟机规范,Class文件通过ClassFile定义,有点类似C语言的结构体\nClassFile的结构如下:\nClassFile { u4 magic; //Class 文件的标志 u2 minor_version;//Class 的小版本号 u2 major_version;//Class 的大版本号 u2 constant_pool_count;//常量池的数量 cp_info constant_pool[constant_pool_count-1];//常量池 u2 access_flags;//Class 的访问标记 u2 this_class;//当前类 u2 super_class;//父类 u2 interfaces_count;//接口 u2 interfaces[interfaces_count];//一个类可以实现多个接口 u2 fields_count;//Class 文件的字段属性 field_info fields[fields_count];//一个类可以有多个字段 u2 methods_count;//Class 文件的方法数量 method_info methods[methods_count];//一个类可以有个多个方法 u2 attributes_count;//此类的属性表中的属性数 attribute_info attributes[attributes_count];//属性表集合 } 通过IDEA插件jclasslib查看,可以直观看到Class 文件结构\n使用jclasslib不光能直观地查看某个类对应的字节码文件,还可以查看类的基本信息、常量池、接口、属性、函数等信息\n下面介绍一下Class文件结构涉及到的一些组件\n魔数(Magic Number) # u4 magic; //Class 文件的标志 每个Class文件的头4个字节称为魔数(Magic Number),它的唯一作用是确定这个文件是否为一个能被虚拟机接收的Class文件\n程序设计者很多时候都喜欢用一些特殊的数字表示固定的文件类型或者其它特殊的含义。\n这里前两个字节是cafe 英[ˈkæfeɪ],后两个字节 babe 英[beɪb]\nJAVA为 CA FE BA BE,十六进制(一个英文字母[这里说的是字母,不是英文中文之分]代表4位,即2个英文字母为1字节)\nClass文件版本号(Minor\u0026amp;Major Version) # u2 minor_version;//Class 的小版本号 u2 major_version;//Class 的大版本号 前4个字节存储Class 文件的版本号:第5位和第6位是次版本号,第7位和第8位是主版本号。 比如Java1.8 为00 00 00 34 JDK1.8 = 52 JDK1.7 = 51 JDK1.6 = 50 JDK1.5 = 49 JDK1.4 = 48 如图,下图是在java8中编译的,使用javap -v 查看 每当Java发布大版本(比如Java8 ,Java9 )的时候,主版本号都会+1\n注:高版本的 Java 虚拟机可以执行低版本编译器生成的 Class 文件,但是低版本的 Java 虚拟机不能执行高版本编译器生成的 Class 文件。所以,我们在实际开发的时候要确保开发的的 JDK 版本和生产环境的 JDK 版本保持一致\n常量池(Constant Pool) # u2 constant_pool_count;//常量池的数量 cp_info constant_pool[constant_pool_count-1];//常量池 主次版本号之后的是常量池,常量池实际数量为constant_pool_count -1 (常量池计数器是从 1 开始计数的,将第 0 项常量空出来是有特殊考虑的,索引值为 0 代表“不引用任何一个常量池项”)\n常量池主要包括两大常量:字面量和符号引用。\n字面量比较接近于Java语言层面的常量概念,如文本字符串、声明为final的常量值等\n注意,非常量是不会在这里的, 没有找到3\n符号引用则属于编译原理方面的概念,包括下面三类常量\n类和接口的全限定名 字段的名称和描述符 方法的名称和描述符 常量池中的每一项常量都是一个表,这14种表有一个共同特点:开始第一位是一个u1类型的标志位 -tag 来标识常量的类型,代表当前这个常量属于哪种常量类型\n.class 文件可以通过javap -v class类名 指令来看一下其常量池中的信息(javap -v class类名-\u0026gt; temp.txt :将结果输出到 temp.txt 文件)。\n访问标志(Access Flag) # 常量池结束后,紧接着两个字节代表访问标志,这个标志用于识别一些类或者接口 层次的访问信息,包括\n这个Class是类还是接口,是否为public或者abstract类型,如果是类的话是否声明为final等等\n类访问和属性修饰符\n【这里好像漏了一个0x0002 ,private 】\n上图转自: https://www.cnblogs.com/qdhxhz/p/10676337.html\n其实是所有值相加,所以对于 public interface A ,是0x601 ,即 0x200 + 0x400 + 0x001\n对于 public final class MyEntity extends MyInterface即0x31:0x0001 + 0x0010 + 0x0020\n再举个例子:\npackage top.snailclimb.bean; public class Employee { ... } 通过 javap -v class类名指令来看一下类的访问标志\n当前类(This Class)、父类(Super Class)、接口(Interfaces)索引集合 # u2 this_class;//当前类 u2 super_class;//父类 u2 interfaces_count;//接口 u2 interfaces[interfaces_count];//一个类可以实现多个接口 类索引用于确定这个类的全限定名,父类索引用于确定这个类的父类的全限定名,由于 Java 语言的单继承,所以父类索引只有一个,除了 java.lang.Object 之外,所有的 java 类都有父类,因此除了 java.lang.Object 外,所有 Java 类的父类索引都不为 0。 接口索引集合用来描述这个类实现了那些接口,这些被实现的接口将按 implements (如果这个类本身是接口的话则是extends) 后的接口顺序从左到右排列在接口索引集合中。 字段表集合 (Fields) # u2 fields_count;//Class 文件的字段的个数 field_info fields[fields_count];//一个类可以有多个字段 字段表(filed info)用于描述接口或类中声明的变量\n字段包括类级变量以及实例变量,但不包括在方法内部声明的局部变量 filed info(字段表)的结构:\naccess_flag:字段的作用域(public、private、protected修饰符)、是实例变量还是类变量(static修饰符)、可否被序列化(transient修饰符)、可变性(final)、可见性(volatile修饰符,是否强制从主内存读写) name_index:对常量池的引用,表示的字段的名称 descriptor_index:对常量池的引用,表示字段和方法的描述符 attributes_count:一个字段还会拥有额外的属性,attributes_count 存放属性的个数 attributes[attriutes_count]: 存放具体属性具体内容 上述这些信息中,各个修饰符都是布尔值,要么有某个修饰符,要么没有,很适合使用标志位来表示。而字段叫什么名字、字段被定义为什么数据类型这些都是无法固定的,只能引用常量池中常量来描述。\n方法表集合(Methods) # u2 methods_count;//Class 文件的方法的数量 method_info methods[methods_count];//一个类可以有个多个方法 methods_count 表示方法的数量,而 method_info 表示方法表。-\nClass 文件存储格式中对方法的描述与对字段的描述几乎采用了完全一致的方式。方法表的结构如同字段表一样,依次包括了访问标志、名称索引、描述符索引、属性表集合几项。\nmethod_info(方法表的)结构\n方法表的 access_flag 取值: 注意:因为volatile修饰符和transient修饰符不可以修饰方法,所以方法表的访问标志中没有这两个对应的标志,但是增加了synchronized、native、abstract等关键字修饰方法,所以也就多了这些关键字对应的标志。\n属性表集合(Attributes) # 如上,字段和方法都拥有属性 属性大概就是这种 u2 attributes_count;//此类的属性表中的属性数 attribute_info attributes[attributes_count];//属性表集合 在 Class 文件,字段表,方法表中都可以携带自己的属性表集合,以用于描述某些场景专有的信息 与 Class 文件中其它的数据项目要求的顺序、长度和内容不同,属性表集合的限制稍微宽松一些,不再要求各个属性表具有严格的顺序,并且只要不与已有的属性名重复,任何人实现的编译器都可以向属性表中写 入自己定义的属性信息,Java 虚拟机运行时会忽略掉它不认识的属性 "},{"id":313,"href":"/zh/docs/technology/Review/java_guide/java/JVM/ly0405lyclassloader-detail/","title":"类加载器详解","section":"Java基础","content":" 转载自https://github.com/Snailclimb/JavaGuide(添加小部分笔记)感谢作者!\n回顾一下类加载过程 # 开始介绍类加载器和双亲委派模型之前,简单回顾一下类加载过程。\n类加载过程:加载-\u0026gt;连接-\u0026gt;初始化。 连接过程又可分为三步:验证-\u0026gt;准备-\u0026gt;解析。 [\n加载是类加载过程的第一步,主要完成下面 3 件事情:\n通过全类名获取定义此类的二进制字节流 将字节流所代表的静态存储结构转换为方法区的运行时数据结构 在内存中生成一个代表该类的 Class 对象,作为方法区这些数据的访问入口 类加载器 # 类加载器介绍 # 类加载器从 JDK 1.0 就出现了,最初只是为了满足 Java Applet(已经被淘汰) 的需要。后来,慢慢成为 Java 程序中的一个重要组成部分,赋予了 Java 类可以被动态加载到 JVM 中并执行的能力。\n根据官方 API 文档的介绍:\nA class loader is an object that is responsible for loading classes. The class ClassLoader is an abstract class. Given the binary name of a class, a class loader should attempt to locate or generate data that constitutes a definition for the class. A typical strategy is to transform the name into a file name and then read a \u0026ldquo;class file\u0026rdquo; of that name from a file system.\nEvery Class object contains a reference to the ClassLoader that defined it.\nClass objects for array classes are not created by class loaders, but are created automatically as required by the Java runtime. The class loader for an array class, as returned by Class.getClassLoader() is the same as the class loader for its element type; if the element type is a primitive type, then the array class has no class loader.\n翻译过来大概的意思是:\n类加载器是一个负责加载类的对象。ClassLoader 是一个抽象类。给定类的二进制名称,类加载器应尝试定位或生成构成类定义的数据。典型的策略是将名称转换为文件名,然后从文件系统中读取该名称的“类文件”。\n每个 Java 类都有一个引用指向加载它的 ClassLoader。不过,数组类不是通过 ClassLoader 创建的,而是 JVM 在需要的时候自动创建的,数组类通过getClassLoader()方法获取 ClassLoader 的时候和该数组的元素类型的 ClassLoader 是一致的。\n从上面的介绍可以看出:\n类加载器是一个负责加载类的对象,用于实现类加载过程中的加载这一步。 每个 Java 类都有一个引用指向加载它的 ClassLoader。 数组类不是通过 ClassLoader 创建的(数组类没有对应的二进制字节流),是由 JVM 直接生成的。 class Class\u0026lt;T\u0026gt; { ... private final ClassLoader classLoader; @CallerSensitive public ClassLoader getClassLoader() { //... } ... } 简单来说,类加载器的主要作用就是加载 Java 类的字节码( .class 文件)到 JVM 中(在内存中生成一个代表该类的 Class 对象)。 字节码可以是 Java 源程序(.java文件)经过 javac 编译得来,也可以是通过工具动态生成或者通过网络下载得来。\n其实除了加载类之外,类加载器还可以加载 Java 应用所需的资源如文本、图像、配置文件、视频等等文件资源。本文只讨论其核心功能:加载类。\n类加载器加载规则 # JVM 启动的时候,并不会一次性加载所有的类,而是根据需要去动态加载。也就是说,大部分类在具体用到的时候才会去加载,这样对内存更加友好。\n对于已经加载的类会被放在 ClassLoader 中。在类加载的时候,系统会首先判断当前类是否被加载过。已经被加载的类会直接返回,否则才会尝试加载。也就是说,对于一个类加载器来说,相同二进制名称的类只会被加载一次。\npublic abstract class ClassLoader { ... private final ClassLoader parent; // 由这个类加载器加载的类。 private final Vector\u0026lt;Class\u0026lt;?\u0026gt;\u0026gt; classes = new Vector\u0026lt;\u0026gt;(); // 由VM调用,用此类加载器记录每个已加载类。 void addClass(Class\u0026lt;?\u0026gt; c) { classes.addElement(c); } ... } 类加载器总结 # JVM 中内置了三个重要的 ClassLoader:\nBootstrapClassLoader(启动类加载器) :最顶层的加载类,由 C++实现,通常表示为 null(意思是如果用代码来get,会得到null),并且没有父级,主要用来加载 JDK 内部的核心类库( **%JAVA_HOME%/lib**目录下的 rt.jar 、resources.jar 、charsets.jar等 jar 包和类)以及被 -Xbootclasspath参数指定的路径下的所有类。 ExtensionClassLoader(扩展类加载器) :主要负责加载 %JRE_HOME%/lib/ext 目录下的 jar 包和类以及被 java.ext.dirs 系统变量所指定的路径下的所有类。 AppClassLoader(应用程序类加载器) :面向我们用户的加载器,负责加载当前应用 classpath 下的所有 jar 包和类。 🌈 拓展一下:\nrt.jar : rt 代表“RunTime”,rt.jar是Java基础类库,包含Java doc里面看到的所有的类的类文件。也就是说,我们常用内置库 java.xxx.* 都在里面,比如java.util.*、java.io.*、java.nio.*、java.lang.*、java.sql.*、java.math.*。 Java 9 引入了模块系统,并且略微更改了上述的类加载器。扩展类加载器被改名为平台类加载器(platform class loader)。Java SE 中除了少数几个关键模块,比如说 java.base 是由启动类加载器加载之外,其他的模块均由平台类加载器所加载。 除了这三种类加载器之外,用户还可以加入自定义的类加载器来进行拓展,以满足自己的特殊需求。就比如说,我们可以对 Java 类的字节码( .class 文件)进行加密,加载时再利用自定义的类加载器对其解密。\n除了 BootstrapClassLoader 是 JVM 自身的一部分之外,其他所有的类加载器都是在 JVM 外部实现的,并且全都继承自 ClassLoader抽象类。这样做的好处是用户可以自定义类加载器,以便让应用程序自己决定如何去获取所需的类。\n每个 ClassLoader 可以通过**getParent()获取其父 ClassLoader,如果获取到 ClassLoader 为null**的话,那么该类是通过 BootstrapClassLoader 加载的。\npublic abstract class ClassLoader { ... // 父加载器 private final ClassLoader parent; @CallerSensitive public final ClassLoader getParent() { //... } ... } 为什么 获取到 ClassLoader 为null就是 BootstrapClassLoader 加载的呢? 这是因为BootstrapClassLoader 由 C++ 实现,由于这个 C++ 实现的类加载器在 Java 中是没有与之对应的类的,所以拿到的结果是 null。\n下面我们来看一个获取 ClassLoader 的小案例:\npublic class PrintClassLoaderTree { public static void main(String[] args) { ClassLoader classLoader = PrintClassLoaderTree.class.getClassLoader(); StringBuilder split = new StringBuilder(\u0026#34;|--\u0026#34;); boolean needContinue = true; while (needContinue){ System.out.println(split.toString() + classLoader); if(classLoader == null){ needContinue = false; }else{ classLoader = classLoader.getParent(); split.insert(0, \u0026#34;\\t\u0026#34;); } } } } 输出结果(JDK 8 ):\n|--sun.misc.Launcher$AppClassLoader@18b4aac2 |--sun.misc.Launcher$ExtClassLoader@53bd815b |--null 从输出结果可以看出:\n我们编写的 Java 类 PrintClassLoaderTree 的 ClassLoader 是AppClassLoader; AppClassLoader的父 ClassLoader 是ExtClassLoader; ExtClassLoader的父ClassLoader是Bootstrap ClassLoader,因此输出结果为 null。 自定义类加载器 # 我们前面也说说了,除了 BootstrapClassLoader 其他类加载器均由 Java 实现且全部继承自java.lang.ClassLoader。如果我们要自定义自己的类加载器,很明显需要继承 ClassLoader抽象类。\nClassLoader 类有两个关键的方法:\nprotected Class loadClass(String name, boolean resolve):加载指定二进制名称的类**,实现了双亲委派机制** 。name 为类的二进制名称,resove 如果为 true,在加载时调用 resolveClass(Class\u0026lt;?\u0026gt; c) 方法解析该类。 protected Class findClass(String name):根据类的二进制名称来查找类,默认实现是空方法。 官方 API 文档中写到:\nSubclasses of ClassLoader are encouraged to override findClass(String name), rather than this method.\n建议 ClassLoader的子类重写 findClass(String name)方法而不是loadClass(String name, boolean resolve) 方法。\n如果我们不想打破双亲委派模型,就重写 ClassLoader 类中的 findClass() 方法即可,无法被父类加载器加载的类最终会通过这个方法被加载。但是,如果想打破双亲委派模型则需要重写 loadClass() 方法。\n双亲委派模型 # 双亲委派模型介绍 # 类加载器有很多种,当我们想要加载一个类的时候,具体是哪个类加载器加载呢?这就需要提到双亲委派模型了。\n根据官网介绍:\nThe ClassLoader class uses a delegation model to search for classes and resources. Each instance of ClassLoader has an associated parent class loader. When requested to find a class or resource, a ClassLoader instance will delegate the search for the class or resource to its parent class loader before attempting to find the class or resource itself. The virtual machine\u0026rsquo;s built-in class loader, called the \u0026ldquo;bootstrap class loader\u0026rdquo;, does not itself have a parent but may serve as the parent of a ClassLoader instance.\n翻译过来大概的意思是:\nClassLoader 类使用委托模型来搜索类和资源。每个 ClassLoader 实例都有一个相关的父类加载器。需要查找类或资源时,ClassLoader 实例会在试图亲自查找类或资源之前,将搜索类或资源的任务委托给其父类加载器。 虚拟机中被称为 \u0026ldquo;bootstrap class loader\u0026quot;的内置类加载器本身没有父类加载器,但是可以作为 ClassLoader 实例的父类加载器。\n从上面的介绍可以看出:\nClassLoader 类使用委托模型来搜索类和资源。 双亲委派模型要求除了顶层的启动类加载器外,其余的类加载器都应有自己的父类加载器。 ClassLoader 实例会在试图亲自查找类或资源之前,将搜索类或资源的任务委托给其父类加载器。 下图展示的各种类加载器之间的层次关系被称为类加载器的“双亲委派模型(Parents Delegation Model)”。\n注意⚠️:双亲委派模型并不是一种强制性的约束,只是 JDK 官方推荐的一种方式。如果我们因为某些特殊需求想要打破双亲委派模型,也是可以的,后文会介绍具体的方法。\n其实这个双亲翻译的容易让别人误解,我们一般理解的双亲都是父母,这里的双亲更多地表达的是“父母这一辈”的人而已,并不是说真的有一个 MotherClassLoader 和一个FatherClassLoader 。个人觉得翻译成单亲委派模型更好一些,不过,国内既然翻译成了双亲委派模型并流传了,按照这个来也没问题,不要被误解了就好。\n另外,类加载器之间的父子关系一般不是以继承的关系来实现的,而是通常使用组合关系来复用父加载器的代码。\npublic abstract class ClassLoader { ... // 组合 private final ClassLoader parent; protected ClassLoader(ClassLoader parent) { this(checkCreateClassLoader(), parent); } ... } 在面向对象编程中,有一条非常经典的设计原则: 组合优于继承,多用组合少用继承。\n双亲委派模型的执行流程 # 双亲委派模型的实现代码非常简单,逻辑非常清晰,都集中在 java.lang.ClassLoader 的 loadClass() 中,相关代码如下所示。\nprivate final ClassLoader parent; protected Class\u0026lt;?\u0026gt; loadClass(String name, boolean resolve) throws ClassNotFoundException { synchronized (getClassLoadingLock(name)) { // 首先,检查请求的类是否已经被加载过 Class\u0026lt;?\u0026gt; c = findLoadedClass(name); if (c == null) { //如果 c 为 null,则说明该类没有被加载过 long t0 = System.nanoTime(); try { if (parent != null) {//父加载器不为空,调用父加载器loadClass()方法处理 //注意,这里是一层层抛上去,有点类似把方法放进栈,然后如果BootstrapClassLoader加载不了,就会抛异常,由自己加载(如果自己加载不了,还是会抛异常,然后再次加载权回到子类) c = parent.loadClass(name, false); } else {//父加载器为空,使用启动类加载器 BootstrapClassLoader 加载 c = findBootstrapClassOrNull(name); } } catch (ClassNotFoundException e) { //抛出异常说明父类加载器无法完成加载请求 } if (c == null) { //当父类加载器无法加载时,则调用findClass方法来加载该类 //用户可通过覆写该方法,来自定义类加载器 long t1 = System.nanoTime(); //自己尝试加载 c = findClass(name); //用于统计类加载器相关的信息 // this is the defining class loader; record the stats sun.misc.PerfCounter.getParentDelegationTime().addTime(t1 - t0); sun.misc.PerfCounter.getFindClassTime().addElapsedTimeFrom(t1); sun.misc.PerfCounter.getFindClasses().increment(); } } if (resolve) { //对类进行link操作 resolveClass(c); } return c; } } 每当一个类加载器接收到加载请求时,它会先将请求转发给父类加载器。在父类加载器没有找到所请求的类的情况下,该类加载器才会尝试去加载。\n结合上面的源码,简单总结一下双亲委派模型的执行流程:\n在类加载的时候,系统会首先判断当前类是否被加载过。已经被加载的类会直接返回,否则才会尝试加载(每个父类加载器都会走一遍这个流程)。 类加载器在进行类加载的时候,它首先不会自己去尝试加载这个类,而是把这个请求委派给父类加载器去完成(调用父加载器 loadClass()方法来加载类)。这样的话,所有的请求最终都会传送到顶层的启动类加载器 BootstrapClassLoader 中。 只有当父加载器反馈自己无法完成这个加载请求(它的搜索范围中没有找到所需的类)时,子加载器才会尝试自己去加载(调用自己的 findClass() 方法来加载类)。 🌈 拓展一下:\nJVM 判定两个 Java 类是否相同的具体规则 :JVM 不仅要看类的全名是否相同,还要看加载此类的类加载器是否一样。只有两者都相同的情况,才认为两个类是相同的。即使两个类来源于同一个 Class 文件,被同一个虚拟机加载,只要加载它们的类加载器不同,那这两个类就必定不相同。\n双亲委派模型的好处 # 双亲委派模型保证了 Java 程序的稳定运行,可以避免类的重复加载(JVM 区分不同类的方式不仅仅根据类名,相同的类文件被不同的类加载器加载产生的是两个不同的类),也保证了 Java 的核心 API 不被篡改。\n如果没有使用双亲委派模型,而是每个类加载器加载自己的话就会出现一些问题,比如我们编写一个称为 java.lang.Object 类的话,那么程序运行的时候,系统就会出现两个不同的 Object 类。双亲委派模型可以保证加载的是 JRE 里的那个 Object 类,而不是你写的 Object 类。这是因为 AppClassLoader 在加载你的 Object 类时,会委托给 ExtClassLoader 去加载,而 ExtClassLoader 又会委托给 BootstrapClassLoader,BootstrapClassLoader 发现自己已经加载过了 Object 类,会直接返回,不会去加载你写的 Object 类。\n打破双亲委派模型方法 # 为了避免双亲委托机制,我们可以自己定义一个类加载器,然后重写 loadClass() 即可。\n🐛 修正(参见: issue871 ) :自定义加载器的话,需要继承 ClassLoader 。如果我们不想打破双亲委派模型,就重写 ClassLoader 类中的 findClass() 方法即可,无法被父类加载器加载的类最终会通过这个方法被加载。但是,如果想打破双亲委派模型则需要重写 loadClass() 方法。\n为什么是重写 loadClass() 方法打破双亲委派模型呢?双亲委派模型的执行流程已经解释了:\n类加载器在进行类加载的时候,它首先不会自己去尝试加载这个类,而是把这个请求委派给父类加载器去完成(调用父加载器 loadClass()方法来加载类)。\n我们比较熟悉的 Tomcat 服务器为了能够优先加载 Web 应用目录下的类,然后再加载其他目录下的类,就自定义了类加载器 WebAppClassLoader 来打破双亲委托机制。这也是 Tomcat 下 Web 应用之间的类实现隔离的具体原理。\nTomcat 的类加载器的层次结构如下:\n感兴趣的小伙伴可以自行研究一下 Tomcat 类加载器的层次结构,这有助于我们搞懂 Tomcat 隔离 Web 应用的原理,推荐资料是 《深入拆解 Tomcat \u0026amp; Jetty》。\n推荐阅读 # 《深入拆解 Java 虚拟机》 深入分析 Java ClassLoader 原理:https://blog.csdn.net/xyang81/article/details/7292380 Java 类加载器(ClassLoader):http://gityuan.com/2016/01/24/java-classloader/ Class Loaders in Java:https://www.baeldung.com/java-classloaders Class ClassLoader - Oracle 官方文档:https://docs.oracle.com/javase/8/docs/api/java/lang/ClassLoader.html 老大难的 Java ClassLoader 再不理解就老了:https://zhuanlan.zhihu.com/p/51374915 "},{"id":314,"href":"/zh/docs/technology/Review/java_guide/java/JVM/ly0404lyclassloader-process/","title":"类加载过程","section":"Java基础","content":" 转载自https://github.com/Snailclimb/JavaGuide(添加小部分笔记)感谢作者!\n类的声明周期 # 类加载过程 # Class文件,需要加载到虚拟机中之后才能运行和使用,那么虚拟机是如何加载这些Class文件呢 系统加载Class类文件需要三步:加载-\u0026gt;连接-\u0026gt;初始化。连接过程又分为三步:验证-\u0026gt;准备-\u0026gt;解析\n加载 # 类加载的第一步,主要完成3件事情\n构造与类相关联的方法表\n通过全类名获取定义此类的二进制字节流 将字节流所代表的静态存储结构,转换为方法区的运行时数据结构 在内存中生成一个该类的Class对象,作为方法区这些数据的访问入口 虚拟机规范对上面3点不具体,比较灵活\n对于1 没有具体指明从哪里获取、怎样获取。可以从ZIP包读取 (JAR/EAR/WAR格式的基础)、其他文件生成(JSP)等 非数组类的加载阶段(加载阶段获取类的二进制字节流的动作)是可控性最强的阶段,这一步我们可以去完成还可以自定义类加载器去控制字节流的获取方式(重写一个类加载器的**loadClass()**方法 数组类型不通过类加载器创建,它由Java虚拟机直接创建 加载阶段和连接阶段的部分内容是交叉执行的,即加载阶段尚未结束,连接阶段就可能已经开始了\n验证 # 验证是连接阶段的第一步,这一阶段的目的是确保 Class 文件的字节流中包含的信息符合《Java 虚拟机规范》的全部约束要求,保证这些信息被当作代码运行后不会危害虚拟机自身的安全。\n验证阶段主要由四个检验阶段组成:\n文件格式验证(Class 文件格式检查) 元数据验证(字节码语义检查) 字节码验证(程序语义检查) 符号引用验证(类的正确性检查) 准备 # 准备阶段是正式为类变量分配内存并设置类变量初始值的阶段,这些内存都将在方法区中分配,注意:\n这时候进行内存分配的仅包括类变量(ClassVariables,即静态变量:被static关键字修饰的变量,只与类相关,因此被称为类变量),而不包括实例变量。\n实例变量会在对象实例化时,随着对象一块分配到Java堆中\n从概念上讲,类变量所使用的内存都应当在 方法区 中进行分配。不过有一点需要注意的是:JDK 7 之前,HotSpot 使用永久代来实现方法区的时候,实现是完全符合这种逻辑概念的。 而在 JDK 7 及之后,HotSpot 已经把原本放在永久代的字符串常量池、静态变量等移动到堆中,这个时候类变量则会随着 **Class 对象(上面有提到,内存区生成Class对象)**一起存放在 Java 堆中\n这里所设置的初始值**\u0026ldquo;通常情况\u0026rdquo;下是数据类型默认的零值(如 0、0L、null、false 等**),比如我们定义了**public static int value=111** ,那么 value 变量在准备阶段的初始值就是 0 而不是 111(初始化阶段才会赋值)。特殊情况:比如给 value 变量加上了 final 关键字public static final int value=111 ,那么准备阶段 value 的值就被赋值为 111\n基本数据类型的零值 解析 # 解析阶段是虚拟机将常量池内的符号引用替换为直接引用的过程\n解析动作主要针对类或接口、字段、类方法、接口方法、方法类型、方法句柄和调用限定符7类符号引用进行\n符号引用就是一组符号来描述目标,可以是任何字面量。直接引用就是直接指向目标的指针、相对偏移量或一个间接定位到目标的句柄\n程序实际运行时,只有符号引用是不够的。 在程序执行方法时,系统需要明确知道这个方法所在的位置 Java虚拟机为每个类都准备了一张方法表来存放类中所有的方法。当需要调用一个类的方法的时候,只要知道这个方法在方法表中的偏移量就可以直接调用该方法了(针对其他类X或者当前类的方法)\n通过解析操作符号引用就可以直接转变为目标方法在类中方法表的位置,从而使得方法可以被调用。(将当前类中代码转为 上面说的类的偏移量)\n对下面的内容简化一下就是,编译后的class文件中,以 [类数组] 的方式,保存了类中的方法表的位置(偏移量)(通过得到每个数组元素可以得到方法的信息)。而这里我们只能知道偏移量,但是当正式加载到方法区之后,我们就能根据偏移量,计算出具体的 [内存地址] 了。\n具体详情https://blog.csdn.net/luanlouis/article/details/41113695 ,这里涉及到几个概念,一个是方法表。通过 javap -v xxx查看反编译的信息(class文件的信息)\nclass文件是这样的结构,里面有个方法表的概念\n如下,可能会有好几个方法,所以方法表,其实是一个类数组结构,而每个方法信息(method_info)呢,\n进一步,对于每个method_info结构体的定义\n方法表的结构体由:访问标志(*access_flags*)、名称索引(*name_index*)、描述索引(*descriptor_index*)、属性表(*attribute_info*)集合组成。\n而对于属性表,(其中:属性表集合\u0026ndash;用来记录方法的机器指令和抛出异常等信息)\nJava之所以能够运行,就是从Code属性中,取出的机器码\n解析阶段是虚拟机将常量池内的符号引用替换为直接引用的过程,也就是得到类或者字段、方法在内存中的指针或者偏移量。(因为此时那些class文件已经早就加载到方法区之中了,所以可以改成指向方法区的某个内存地址\n如下,我的理解是,把下面的 com/test/Student.a ()V 修改成了直接的内存地址 类似的意思\n初始化 # 初始化阶段,是执行初始化方法clinit()方法的过程,是类加载的最后一步,这一步JVM才开始真正执行类中定义的Java程序代码(字节码)\nclinit()方法是编译之后自动生成的\n对于clinit () 方法的调用,虚拟机会自己确保其在多线程环境中的安全性。因为 clinit () 方法是带锁线程安全,所以在多线程环境下进行类初始化的话可能会引起多个线程阻塞,并且这种阻塞很难被发现。\n对于初始化阶段,虚拟机严格规范了有且只有 5 种情况下,必须对类进行初始化(只有主动去使用类才会初始化类):\n当遇到 new 、 getstatic、putstatic 或 invokestatic 这 4 条直接码指令时,比如 new 一个类,读取一个静态字段(未被 final 修饰)、或调用一个类的静态方法时。\n当 jvm 执行 new 指令时会初始化类。即当程序创建一个类的实例对象。 当 jvm 执行 getstatic 指令时会初始化类。即程序访问类的静态变量(不是静态常量,常量会被加载到运行时常量池)。 当 jvm 执行 putstatic 指令时会初始化类。即程序给类的静态变量赋值。 当 jvm 执行 invokestatic 指令时会初始化类。即程序调用类的静态方法。 使用 java.lang.reflect 包的方法对类进行反射调用时如 Class.forname(\u0026quot;...\u0026quot;), newInstance() 等等。如果类没初始化,需要触发其初始化。\n初始化一个类,如果其父类还未初始化,则先触发该父类的初始化。\n当虚拟机启动时,用户需要定义一个要执行的主类 (包含 main 方法的那个类),虚拟机会先初始化这个类。\nMethodHandle 和 VarHandle 可以看作是轻量级的反射调用机制,而要想使用这 2 个调用, 就必须先使用 findStaticVarHandle 来初始化要调用的类。\n「补充,来自 issue745open in new window」 当一个接口中定义了 JDK8 新加入的默认方法(被 default 关键字修饰的接口方法)时,如果有这个接口的实现类发生了初始化,那该接口要在其之前被初始化。\n卸载 # 卸载类即该类的 Class 对象被 GC。\n卸载类需要满足 3 个要求:\n该类的所有的实例对象都已被 GC,也就是说堆不存在该类的实例对象。 该类没有在其他任何地方被引用 该类的类加载器的实例已被 GC JVM的生命周期内,由jvm自带的类加载器的类是不会被卸载的,而由我们自定义的类加载器加载的类是可能被卸载的\n只要想通一点就好了,jdk 自带的 BootstrapClassLoader, ExtClassLoader, AppClassLoader 负责加载 jdk 提供的类,所以它们(类加载器的实例)肯定不会被回收。而我们自定义的类加载器的实例是可以被回收的,所以使用我们自定义加载器加载的类是可以被卸载掉的。\n"},{"id":315,"href":"/zh/docs/technology/Review/java_guide/java/JVM/ly0402lygarbage-collection/","title":"java垃圾回收","section":"Java基础","content":" 转载自https://github.com/Snailclimb/JavaGuide(添加小部分笔记)感谢作者!\n前言 # 当需要排查各种内存溢出问题、当垃圾收集成为系统达到更高并发的瓶颈时,我们就需要对这些**“自动化”的技术实施必要的监控和调节**\n堆空间的基本结构 # Java的自动内存管理主要是针对对象内存的回收和对象内存的分配。且Java自动内存管理最核心的功能是堆内存中的对象分配和回收\nJava堆是垃圾收集器管理的主要区域,因此也被称作GC堆(Garbage Collected Heap)\n从垃圾回收的角度来说,由于现在收集器基本都采用分代垃圾收集算法,所以Java堆被划分为了几个不同的区域,这样我们就可以根据各个区域的特点选择合适的垃圾收集算法\nJDK7版本及JDK7版本之前,堆内存被通常分为下面三部分:\n新生代内存(Young Generation) 老生代(Old Generation) 永久代(Permanent Generation) JDK8版本之后PermGen(永久)已被Metaspace(元空间)取代,且已经不在堆里面了,元空间使用的是直接内存。\n内存分配和回收原则 # 对象优先在Eden区分配 # 多数情况下,对象在新生代中Eden区分配。当Eden区没有足够空间进行分配时,会触发一次MinorGC 首先,先添加一下参数打印GC详情:-XX:+PrintGCDetails\npublic class GCTest { public static void main(String[] args) { byte[] allocation1, allocation2; allocation1 = new byte[30900*1024];//会用掉3万多K } } 运行后的结果(这里应该是配过xms和xmx了,即堆内存大小) 如上,Eden区内存几乎被分配完全(即使程序什么都不做,新生代也会使用2000多K)\n注: PSYoungGen 为 38400K ,= 33280K + 5120K (Survivor区总会有一个是空的,所以只加了一个5120K )\n假如我们再为allocation2分配内存会怎么样(不处理的话,年轻代会溢出)\nallocation2 = new byte[900 * 1024]; 在给allocation2分配内存之前,Eden区内存几乎已经被分配完。所以当Eden区没有足够空间进行分配时,虚拟机将发起一次MinorGC。GC期间虚拟机又发现allocation1无法存入空间,所以只好通过分配担保机制,把新生代的对象,提前转移到老年代去,老年代的空间足够存放allocation1,所以不会出现Full GC(这里可能是之前的说法,可能只是要表达老年代的GC,而不是Full GC(整堆GC) ) 执行MinorGC后,后面分配的对象如果能够存在Eden区的话,还是会在Eden区分配内存\n执行如下代码验证:\npublic class GCTest { public static void main(String[] args) { byte[] allocation1, allocation2,allocation3,allocation4,allocation5; allocation1 = new byte[32000*1024]; allocation2 = new byte[1000*1024]; allocation3 = new byte[1000*1024]; allocation4 = new byte[1000*1024]; allocation5 = new byte[1000*1024]; } } 大对象直接进入老年代 # 大对象就是需要连续空间的对象(字符串、数组等) 大对象直接进入老年代,主要是为了避免为大对象分配内存时,由于分配担保机制(这好像跟分配担保机制没有太大关系)带来的复制而降低效率。 假设大对象最后会晋升老年代,而新生代是基于复制算法来回收垃圾的,由两个Survivor区域配合完成复制算法,如果新生代中出现大对象且能屡次躲过GC,那这个对象就会在两个Survivor区域中来回复制,直至最后升入老年代,而大对象在内存里来回复制移动,就会消耗更多的时间。\n假设大对象最后不会晋升老年代,新生代空间是有限的,在新生代里的对象大部分都是朝生夕死的,如果让一个大对象占据了新生代空间,那么相比起正常的对象被分配在新生代,大对象无疑会让新生代GC提早发生,因为内存空间会更快不够用,如果这个大对象因为业务原因,并不会马上被GC回收,那么这个对象就会进入到Survivor区域,默认情况下,Survivor区域本来就不会被分配的很大,那此时被大对象占据了大部分空间,很可能会导致之后的新生代GC后,存活下来的对象,Survivor区域空间不够放不下,导致大部分对象进入老年代,这就加快了老年代GC发生的时间,而老年代GC对系统性能的负面影响则远远大于新生代GC了。\n长期存活的对象进入老年代 # 内存回收时必须能够识别,哪些对象放在新生代,哪些对象放在老年代\u0026mdash;\u0026gt; 因此,虚拟机给每个对象一个**对象年龄(Age)**计数器\n\u0026lt;流程\u0026gt; : 大部分情况下,对象都会首先在Eden区域分配。如果对象在Eden出生并经过第一次MinorGC后仍然能够存活,并且能被Survivor容纳的话,将被移动到Survivor空间(S0或S1)中,并将对象年龄设为1(Eden区 \u0026ndash;\u0026gt; Survivor区后对象初始年龄变为1 )\n后续,对象在Survivor区中每熬过一次MinorGC,年龄就增加1岁,当年龄增加到一定程序(默认为15岁),就会被晋升到老年代中。对象晋升到老年代的年龄阈值,可以通过参数**-XX:MaxTenuringThreshold**来设置 ★★修正: “Hotspot 遍历所有对象时,按照年龄从小到大对其所占用的大小进行累积,当累积的某个年龄大小超过了 survivor 区的 50% 时(默认值是 50%,可以通过 -XX:TargetSurvivorRatio=percent 来设置,参见 issue1199open in new window ),取这个年龄和 MaxTenuringThreshold 中更小的一个值,作为新的晋升年龄阈值”。 动态年龄计算的代码:\nuint ageTable::compute_tenuring_threshold(size_t survivor_capacity) { //survivor_capacity是survivor空间的大小 size_t desired_survivor_size = (size_t)((((double)survivor_capacity)*TargetSurvivorRatio)/100); size_t total = 0; uint age = 1; while (age \u0026lt; table_size) { //sizes数组是每个年龄段对象大小 total += sizes[age]; if (total \u0026gt; desired_survivor_size) { break; } age++; //注意这里,age是递增的,最终是去某个值,而不是区间的值计算 } uint result = age \u0026lt; MaxTenuringThreshold ? age : MaxTenuringThreshold; ... } 例子: 如**对象年龄5的占30%,年龄6的占36%,年龄7的占34%,加入某个年龄段(如例子中的年龄6)**后,总占用超过Survivor空间*TargetSurvivorRatio的时候,从该年龄段开始及大于的年龄对象就要进入老年代(即例子中的年龄6对象,就是年龄6和年龄7晋升到老年代),这时候无需等到MaxTenuringThreshold中要求的15\n关于默认的晋升年龄是 15,这个说法的来源大部分都是《深入理解 Java 虚拟机》这本书。 如果你去 Oracle 的官网阅读 相关的虚拟机参数open in new window,你会发现-XX:MaxTenuringThreshold=threshold这里有个说明\nSets the maximum tenuring threshold for use in adaptive GC sizing. The largest value is 15. The default value is 15 for the parallel (throughput) collector, and 6 for the CMS collector.默认晋升年龄并不都是 15,这个是要区分垃圾收集器的,CMS 就是 6.\n主要进行gc的区域 # 如图:(太长跳过了,直接看下面的总结)\n总结:\n针对HotSpotVM的实现,它里面的GC准确分类只有两大种:\n部分收集(Partial GC) 新生代收集(Minor GC/ Young GC ):只对新生代进行垃圾收集 老年代(Major GC / Old GC ):只对老年代进行垃圾收集。★★:注意,MajorGC在有的语境中也用于指代整堆收集 混合收集(Mixed GC):对整个新生代和部分老年代进行垃圾收集 整堆收集(Full GC):收集整个Java堆和方法区 空间分配担保 # 为了确保在MinorGC之前老年代本身还有容纳新生代所有对象的剩余空间\n《深入理解Java虚拟机》第三章对于空间分配担保的描述如下:\nJDK 6 Update 24 之前,在发生 Minor GC 之前,虚拟机必须先检查老年代最大可用的连续空间是否大于新生代所有对象总空间,如果这个条件成立,那这一次 Minor GC 可以确保是安全的。如果不成立,则虚拟机会先查看 -XX:HandlePromotionFailure 参数的设置值是否允许担保失败(Handle Promotion Failure);如果允许,那会继续检查老年代最大可用的连续空间是否大于历次晋升到老年代对象的平均大小,如果大于,将尝试进行一次 Minor GC,尽管这次 Minor GC 是有风险的;如果小于,或者 -XX: HandlePromotionFailure 设置不允许冒险,那这时就要改为进行一次 Full GC。\nJDK6 Update24之后,规则变为只要老年代的连续空间大于新生代对象总大小,或者历次晋升的平均大小,就会进行MinorGC,否则将进行Full GC\n死亡对象判断方法 # 堆中几乎放着所有的对象实例,对堆垃圾回收前的第一步就是要判断哪些对象已经死亡(即不能再被任何途径使用的对象)\n引用计数法 # 给对象中添加一个引用计数器\n每当有一个地方引用它,计数器就加1 当引用失效,计数器就减1 任何时候计数器为0的对象就是不可能再被使用的 这个方法实现简单,效率高,但是目前主流的虚拟机中并没有选择这个算法来管理内存,其最主要的原因是它很难解决对象之间相互循环引用的问题。\n除了对象 objA 和 objB 相互引用着对方之外,这两个对象之间再无任何引用。但是他们因为互相引用对方,导致它们的引用计数器都不为 0,于是引用计数算法无法通知 GC 回收器回收他们\n★其实我觉得只跟相互有关,跟是不是循环关系不会太大\nly 改:相互在语言逻辑上也可以理解成**“循环”**\npublic class ReferenceCountingGc { Object instance = null; public static void main(String[] args) { ReferenceCountingGc objA = new ReferenceCountingGc(); ReferenceCountingGc objB = new ReferenceCountingGc(); objA.instance = objB; objB.instance = objA; objA = null; objB = null; } } 可达性分析算法 # 该算法的基本思想就是通过一系列称为**“GC Roots\u0026quot;的对象作为起点,从这些节点开始向下搜索**,节点所走过的路径 称为引用链,当一个对象到GC Roots没有任何引用链相连的话,证明该对象不可用,需要被回收 下图中由于Object 6 ~ Object 10之间有引用关系,但它们到GC不可达,所以需要被回收 哪些对象可以作为GC Roots呢\n虚拟机栈(栈帧中的本地变量表)中引用的对象 本地方法栈(Native方法)中引用的对象 方法区中类静态属性引用的对象 (Class 的static变量) 方法区中常量引用的变量(Class 的final static变量) 所有被同步锁持有的对象 (synchronized(obj)) 对象可以被回收,就代码一定会被回收吗 即使在可达性分析法中不可达的对象,也并非是“非死不可”的,这时候它们暂时处于“缓刑阶段”,要真正宣告一个对象死亡,至少要经历两次标记过程:\n可达性分析中不可达的对象被第一次标记并且进行一次筛选:筛选的条件是此对象是否有必要执行finalize方法(有必要则放入)\n当对象没有覆盖finalize方法,或finalize方法已经被虚拟机调用过,则虚拟机将两种情况视为没有必要执行,该对象会被直接回收\n如果这个对象被判定为有必要执行finalize()方法,那么这个对象将会被放置在一个叫做F-Queue的队列中,然后由Finalizer线程去执行。GC将会对F-Queue中的对象进行第二次标记,如果对象在finalize()方法中重新与引用链上的任何一个对象建立关联,那么在第二次标记时将会被移除“即将回收”的集合,否则该对象将会被回收。\n(比如:把自己(this关键字)赋值给某个类变量(static修饰)或者对象的成员变量(在finalize方法中) )\nObject 类中的 finalize 方法一直被认为是一个糟糕的设计,成为了 Java 语言的负担,影响了 Java 语言的安全和 GC 的性能。JDK9 版本及后续版本中各个类中的 finalize 方法会被逐渐弃用移除。忘掉它的存在吧!\n引用类型总结 # 不论是通过引用计数法判断对象引用数量,还是通过可达性分析法判断对象的引用链是否可达,判定对象的存活都与**”引用“**有关 JDK1.2 之前,Java中引用的定义很传统:如果reference类型的数据存储的数值代表的是另一块内存的起始地址,就称这块内存代表一个引用 JDK1.2 之后,Java对引用的概念进行了扩充,将引用(具体)分为强引用、软引用、弱引用、虚引用四种(引用强度逐渐减弱) 强引用(Strong Reference)\n大部分引用实际上是强引用。如果对象具有强引用,那么类似于生活中必不可少,垃圾回收器绝不会回收它 内存空间不足时,宁愿抛出OutOfMemoryErro错误,使程序异常终止,也不会回收强引用对象解决对象内存不足 软引用(SoftReference)\n如果对象只具有软引用,那就类似可有可无的生活用品。 内存够则不会回收;内存不足则回收这些对象。只要垃圾回收器没有回收,那么对象就可以被程序使用。 软引用可用来实现内存敏感的高速缓存 软引用可以和一个引用队列(ReferenceQueue)联合使用,如果软引用所引用的对象被垃圾回收,Java虚拟机就会把这个软引用加入到与之关联的引用队列中 弱引用(WeakReference)\n如果对象只具有弱引用,则类似于可有可无的生活用品 弱引用和软引用的区别:只具有弱引用的对象拥有更短暂的生命周期 垃圾回收器线程扫描它所管辖的内存区域的过程中,一旦发现只具有弱引用的对象,不管当前内存足够与否,都会回收它的内存。不过垃圾回收器是一个优先级很低的线程,因此不一定会很快发现那些只具有弱引用的对象 弱引用可以和一个引用队列(ReferenceQueue)联合使用,如果弱引用所引用的对象被垃圾回收,Java虚拟机就会把这个弱引用加入到与之关联的引用队列中 虚引用(PhantomReference) [ˈfæntəm] 英\n与其他引用不同,虚引用并不会决定对象声明周期。如果一个仅持有虚拟引用,那么它就跟没有任何引用一样,在任何时候都可能被垃圾回收\n虚引用主要用来跟踪对象被垃圾回收的活动\n虚引用、软引用和弱引用的区别:虚引用必须和引用队列(ReferenceQueue)联合使用。\n当垃圾回收器准备回收一个对象时,如果发现它还有虚引用,就会在回收对象的内存之前,把这个虚引用加入到与之关联的引用队列。 程序可以通过判断引用队列是否加入虚引用,来了解被引用的对象是否将被垃圾回收 如果发现某个虚引用已经被加入到引用队列,那么就可以在所引用的对象被回收之前采取必要的行动 在程序设计中一般很少使用弱引用与虚引用,使用软引用的情况较多,这是因为软引用可以加速 JVM 对垃圾内存的回收速度,可以维护系统的运行安全,防止内存溢出(OutOfMemory)等问题的产生\nThreadLocal中的key用到了弱引用\n如何判断一个常量是废弃常量 # 运行时常量池主要回收的是废弃的常量\nJDK1.7 之前,运行时常量池逻辑,包括字符串常量池,存放在方法区,此时hotspot虚拟机对方法区的实现为永久代 JDK1.7字符串常量池(以及静态变量)被从方法区拿到了堆中,这里没有提到运行时常量池,也就是说字符串常量池被单独拿到堆,运行时常量池剩下的东西,还在方法区。即hotspot中的永久代 JDK1.8 hotspot移除了永久代,用元空间Metaspace取代之,这时候字符串常量池还在堆,运行时常量池还在方法区,只不过方法区的实现从永久代变成了元空间Metaspace ★★ 假如字符串常量池存在字符串“abc”,如果当前没有任何String对象引用该字符串常量的话,就说明常量“abc”是废弃常量。如果这时发生内存回收并且有必要的话,“abc”就会被系统清理出常量池\n如何判断一个类是无用类 # 方法区主要回收的是无用的类,判断一个类是否是无用的类相对苛刻,需要同时满足下面条件\n该类所有实例都已经被回收,即Java堆中不存在该类的任何实例 加载该类的ClassLoader已经被回收 该类对应的java.lang.Class对象没有在任何地方被引用,无法在任何地方通过反射访问该类方法 Java虚拟机可以对满足上述3个条件的无用类进行回收,是**“可以”,而不是必然**\n垃圾收集算法 # 标记-清除算法 # 该算法分为**“标记”和“清除”阶段:\n标记出所有不需要回收的对象**,在标记完成后统一回收掉所有没有被标记的对象\n这是最基础的收集算法,后续的算法都是对其不足进行改进得到,有两个明显问题:\n效率问题 空间问题(标记清除后会产生大量不连续碎片) 标记-复制算法 # 将内存分为大小相同的两块,每次使用其中一块 当这块内存使用完后,就将还存活的对象复制到另一块去,然后再把使用的空间一次清理掉 这样每次内存回收都是对内存区间的一半进行回收 标记-整理算法 # 根据老年代特点提出的一种标记算法,标记过程仍然与**“标记-清除”算法一样,但后续不是直接对可回收对象回收,而是让所有存活对象向一端移动**,然后直接清理掉端边界以外的内存\n分代收集算法 # 当前虚拟机的垃圾收集都采用分代收集算法,没有新的思想,只是根据对象存活周期的不同将内存分为几块。\n对象存活周期,也就是有些对象活的时间短,有些对象活的时间长。\n一般将Java堆分为新生代和老年代,这样就可以根据各个年代的特点,选择合适的垃圾收集算法\n新生代中,每次收集都会有大量对象死去,所以可以选择**“标记-复制”算法,只需要付出少量对象的复制成本就可以完成每次垃圾收集** 老年代对象存活几率是比较高的,而且没有额外的空间对它进行分配担保,所以必须选择标记-清除或者**“标记-整理”**算法进行垃圾收集 垃圾收集器 # 收集算法是内存回收的方法论,而垃圾收集器则是内存回收的具体实现 没有最好的垃圾收集器,也没有万能的,应该根据具体应用场景,选择适合自己的垃圾收集器 汇总 # 新生代的垃圾回收器:Serial(串行\u0026ndash;标记复制),ParNew(并行\u0026ndash;标记复制),ParallelScavenge(并行\u0026ndash;标记复制) 老年代的垃圾回收器:SerialOld(串行\u0026ndash;标记整理),ParallelOld(并行\u0026ndash;标记整理),CMS(并发\u0026ndash;标记清除) 只有CMS和G1是并发,且CMS只作用于老年代,而G1都有 JDK8为止,默认垃圾回收器是Parallel Scavenge和Parallel Old【并行\u0026ndash;复制和并行\u0026ndash;标记整理】 JDK9开始,G1收集器成为默认的垃圾收集器,目前来看,G1回收期停顿时间最短且没有明显缺点,偏适合Web应用 jdk8中测试Web应用,堆内存6G中新生代4.5G的情况下\nParallelScavenge回收新生代停顿长达1.5秒。 G1回收器回收同样大小的新生代只停顿0.2秒 Serial 收集器 # Serial 串行 收集器是最基本、历史最悠久的垃圾收集器\n这是一个单线程收集器,它的单线程意义不仅意味着它只会使用一条垃圾收集线程去完成垃圾收集工作,更重要的是它在进行垃圾收集工作时必须暂停其他所有的工作线程**(”Stop The World“),直到它收集结束**。\n新生代采用标记-复制算法,老年代采用标记-整理算法 StopTheWorld会带来不良用户体验,所以在后续垃圾收集器设计中停顿时间不断缩短。(仍然有停顿,垃圾收集器的过程仍然在继续) 优点:简单而高效(与其他收集器的单线程相比) 且由于其没有线程交互的开销,自然可以获得很高的单线程收集效率 Serial收集器对于运行在Client模式下的虚拟机来说是个不错的选择 -XX:+UseSerialGC #虚拟机运行在Client模式下的默认值,Serial+Serial Old。 ParNew 收集器 # ParNew收集器其实就是Serial收集器的多线程版本,除了使用多线程进行垃圾收集外,其余行为(控制参数、收集算法、回收策略等等)和Serial收集器完全一样\n新生代采用标记-复制算法,老年代采用标记-整理算法\n★★★ 这是许多运行在Server模式下的虚拟机的首要选择,除了Serial收集器外,只有它能与CMS收集器(真正意义上的并发收集器)配合工作(ParNew是并行)\n并行和并发概念补充\n并行(Parallel):指多条垃圾收集线程并行工作,但此时用户线程仍然处于等待状态 并发(Concurrent):指用户线程与垃圾收集线程 同时执行(不一定并行,可能会交替执行),用户程序在继续执行,而收集收集器运行在另一个CPU上 -XX:+UseParNewGC #ParNew+Serial Old,在JDK1.8被废弃,在JDK1.7还可以使用。 ParallelScavenge 收集器 # 它也是标记-复制算法的多线程收集器,看上去几乎和ParNew一样,区别\n部分参数 (有点争议,先以下面为准)\n-XX:+UseParallelGC # 虚拟机运行在Server模式下的默认值(1.8) 新生代使用ParallelGC,老年代使用回收器 ; ★★ JDK1.7之后,能达到UseParallelOldGC 的效果 ## 参考自 https://zhuanlan.zhihu.com/p/353458348 -XX:+UseParallelOldGC # 新生代使用ParallelGC,老年代使用ParallelOldGC Parallel Scavenge收集器关注点是吞吐量(高效率利用CPU),CMS等垃圾收集器关注点是用户的停顿时间(提高用户体验)\n所谓吞吐量就是CPU中用于运行用户代码的时间与CPU总消耗时间的比值 (也就是希望消耗少量CPU就能运行更多代码)\nParallel Scavenge 收集器提供了很多参数供用户找到最合适的停顿时间或最大吞吐量,如果对于收集器运作不太了解,手工优化存在困难的时候,使用 Parallel Scavenge 收集器配合自适应调节策略,把内存管理优化交给虚拟机去完成也是一个不错的选择。\n新生代采用标记-复制,老年代采用标记-整理算法 这是JDK1.8 的默认收集器 使用 java -XX:+PrintCommandLineFlags -version 命令查看 如下,两种情况:\n#默认 λ java -XX:+PrintCommandLineFlags -version -XX:InitialHeapSize=531924800 -XX:MaxHeapSize=8510796800 -XX:+PrintCommandLineFlags -XX:+UseCompressedClassPointers -XX:+UseCompressedOops -XX:-UseLargePagesIndividualAllocation -XX:+UseParallelGC java version \u0026#34;1.8.0_202\u0026#34; Java(TM) SE Runtime Environment (build 1.8.0_202-b08) Java HotSpot(TM) 64-Bit Server VM (build 25.202-b08, mixed mode) 第二种情况:(注意:-XX:-UseParallelOldGC)\nλ java -XX:-UseParallelOldGC -XX:+PrintCommandLineFlags -version -XX:InitialHeapSize=531924800 -XX:MaxHeapSize=8510796800 -XX:+PrintCommandLineFlags -XX:+UseCompressedClassPointers -XX:+UseCompressedOops -XX:-UseLargePagesIndividualAllocation -XX:+UseParallelGC -XX:-UseParallelOldGC java version \u0026#34;1.8.0_202\u0026#34; Java(TM) SE Runtime Environment (build 1.8.0_202-b08) Java HotSpot(TM) 64-Bit Server VM (build 25.202-b08, mixed mode) SerialOld 收集器 # Serial收集器的老年代版本,是一个单线程收集器 在JDK1.5以及以前的版本中,与Parallel Scavenge收集器搭配时候 作为CMS收集器的后备方案 ParallelOld 收集器 # Parallel Scavenge收集器的老年代版本,使用多线程和标记-整理算法 在注重吞吐量以及CPU资源的场合,都可以考虑ParallelScavenge和ParallelOld收集器 CMS 收集器 # CMS,Concurrent Mark Sweep,是一种以获取最短回收停顿时间为目标的收集器,非常符合注重用户体验的引用上使用\nCMS收集器是HotSpot虚拟机上第一款真正意义上的并发收集器,第一次实现了让垃圾收集线程与用户线程(基本上)同时工作\nMark-Sweep,是一种“标记-清除”算法,运作过程相比前面几种垃圾收集器来说更加复杂,步骤:\n初始标记:暂停所有其他线程,记录直接与root相连的对象,速度很快\n并发标记:同时 开启GC和用户线程 ,用一个闭包结构记录可达对象。但这个阶段结束,这个闭包结构并不能保证包含当前所有的可达对象。\n因为用户线程会不断更新引用域,所以GC线程无法保证可达性分析的实时性\n所以这个算法里会跟踪记录这些发生引用更新的地方\n重新标记:目的是修正并发标记期间因为用户程序继续运行而导致标记产生变动的那一部分对象的标记记录。\n这个阶段停顿时间一般会被初始标记阶段时间稍长,远远比并发标记阶段时间短\n并发清除:开启用户线程,同时GC线程开始对未扫描的区域做清扫\n从名字可以看出这是一款优秀的收集器:并发收集、低停顿。但有三个明显缺点\n对CPU资源敏感\n无法处理浮动垃圾\n浮动垃圾的解释:就是之前被gc 标记为 可达对象,也就是 存活对象,在两次gc线程之间被业务线程删除了引用,那么颜色不会更改,还是之前的颜色(黑色or灰色),但是其实是白色,所以这一次gc 无法对其回收,需要等下一次gc初始标记启动才会被刷成白色 作者:Yellowtail 链接:https://www.jianshu.com/p/6590aaad82f7 来源:简书\n它使用的收集算法**“标记-清除”算法会导致收集结束时会有大量空间碎片产生**\nG1 收集器 # G1(Garbage-First),是一款面向服务器的垃圾收集器,主要针对配备多颗处理器以及大容量内存的极其,以极高概率满足GC停顿时间要求的同时,还具备高吞吐量性能特征\nJDK1.7中HotSpot虚拟机的一个重要进化特征,具备特点:\n并行与并发:\nG1 能充分利用 CPU、多核环境下的硬件优势,使用多个 CPU(CPU 或者 CPU 核心)来缩短 Stop-The-World 停顿时间。部分其他收集器原本需要停顿 Java 线程执行的 GC 动作,G1 收集器仍然可以通过并发的方式让 java 程序继续执行\n分代收集:\n虽然 G1 可以不需要其他收集器配合就能独立管理整个 GC 堆,但是还是保留了分代的概念。\n空间整合:\n与 CMS 的“标记-清理”算法不同,G1 从整体来看是基于**“标记-整理”算法实现的收集器;从局部上来看是基于“标记-复制”**算法实现的。\n可预测的停顿:\n这是 G1 相对于 CMS 的另一个大优势,降低停顿时间是 G1 和 CMS 共同的关注点,但 G1 除了追求低停顿外,还能建立可预测的停顿时间模型,能让使用者明确指定在一个长度为 M 毫秒的时间片段内。\nG1 收集器的运作大致分为以下几个步骤\n初始标记 并发标记 最终标记 筛选回收 G1 收集器在后台维护了一个优先列表,每次根据允许的收集时间,优先选择回收价值最大的 Region(这也就是它的名字 Garbage-First 的由来) 。这种使用 Region 划分内存空间以及有优先级的区域回收方式,,保证了 G1 收集器在有限时间内可以尽可能高的收集效率(把内存化整为零)\nZGC 收集器 # The Z Garbage Collector\n与 CMS 中的 ParNew 和 G1 类似,ZGC 也采用标记-复制算法,不过 ZGC 对该算法做了重大改进。\n在 ZGC 中出现 Stop The World 的情况会更少!\nJDK11,相关文章 https://tech.meituan.com/2020/08/06/new-zgc-practice-in-meituan.html\n"},{"id":316,"href":"/zh/docs/technology/Review/java_guide/java/JVM/ly0407lyjvm-intro/","title":"jvm-intro","section":"Java基础","content":" 转载自https://github.com/Snailclimb/JavaGuide(添加小部分笔记)感谢作者!\n原文地址: https://juejin.im/post/5e1505d0f265da5d5d744050#heading-28 感谢原作者分享!!\nJVM的基本介绍 # JVM,JavaVirtualMachine的缩写,虚拟出来的计算机,通过在实际的计算机上仿真模拟各类计算机功能实现 JVM类似一台小电脑,运行在windows或者linux这些真实操作系统环境下,直接和操作系统交互,与硬件不直接交互,操作系统帮我们完成和硬件交互的工作 Java文件是如何运行的 # 场景假设:我们写了一个HelloWorld.java,这是一个文本文件。JVM不认识文本文件,所以需要一个编译,让其(xxx.java)成为一个JVM会读的二进制文件\u0026mdash;\u0026gt; HelloWorld.class\n类加载器 如果JVM想要执行这个.class文件,需要将其**(这里应该指的二进制文件)装进类加载器**中,它就像一个搬运工一样,会把所有的.class文件全部搬进JVM里面 方法区\n类加载器将.class文件搬过来,就是先丢到这一块上\n方法区是用于存放类似于元数据信息方面的数据的,比如类信息、常量、静态变量、编译后代码\u0026hellip;等\n堆 堆主要放一些存储的数据,比如对象实例、数组\u0026hellip;等,它和方法区都同属于线程共享区域,即它们都是线程不安全的\n栈\n线程独享\n栈是我们代码运行空间,我们编写的每一个方法都会放到栈里面运行。\n名词:本地方法栈或本地方法接口,不过我们基本不会涉及这两块内容,这两底层使用C进行工作,和Java没有太大关系\n程序计数器 主要就是完成一个加载工作,类似于一个指针一样的,指向下一行我们需要执行的代码。和栈一样,都是线程独享的,就是每一个线程都会自己对应的一块区域而不会存在并发和多线程问题。\n小总结 Java文件经过编译后编程.class字节码文件 字节码文件通过类加载器被搬运到 JVM虚拟机中 虚拟机主要的5大块:方法区、堆 都为线程共享区域,有线程安全问题;栈和本地方法栈和计数器都是独享区域,不存在线程安全问题,而JVM的调优主要就是围绕堆、栈两大块进行 简单的代码例子 # 一个简单的学生类及main方法:\npublic class Student { public String name; public Student(String name) { this.name = name; } public void sayName() { System.out.println(\u0026#34;student\u0026#39;s name is : \u0026#34; + name); } } main方法:\npublic class App { public static void main(String[] args) { Student student = new Student(\u0026#34;tellUrDream\u0026#34;); student.sayName(); } } ★★ 执行main方法的步骤如下\n编译好App.java后得到App.class后,执行APP.class,系统会启动一个JVM进程,从classpath类路径中找到一个名为APP.class的二进制文件,将APP的类信息加载到运行时数据区的方法区内,这个过程叫做APP类的加载 JVM找到APP的主程序入口,执行main方法 这个main的第一条语句**(指令)**为 Student student = new Student(\u0026quot;tellUrDream\u0026quot;),就是让JVM创建一个Student对象,但是这个时候方法区是没有Student类的信息的,所以JVM马上加载Student类,把Student类的信息放到方法区中 加载完Student类后,JVM在堆中为一个新的Student实例分配内存,然后调用构造函数初始化Student实例,这个Student实例**(对象)持有指向方法区中的Student类的类型信息**的引用 执行student.sayName;时,JVM根据student的引用找到student对象,然后根据student对象持有的引用定位到方法区中student类的类型信息的方法表,获得sayName()的字节码地址。 执行sayName() 其实也不用管太多,只需要知道对象实例初始化时,会去方法区中找到类信息(没有的话先加载),完成后再到栈那里去运行方法\n类加载器的介绍 # 类加载器负责加载.class文件,.class文件的开头会有特定的文件标识,将class文件字节码内容加载到内存中,并将这些内容转换成方法区中的运行时数据结构,并且ClassLoader只负责class文件的加载,而能否运行则由Execution Engine来决定\n类加载器的流程 # 从类被加载到虚拟机内存中开始,到释放内存总共有7个步骤:\n加载,验证,准备,解析,初始化,使用,卸载。\n其中验证,准备,解析三个部分统称为链接\n加载 # 将class文件加载到内存 将静态数据结构转化成方法区中运行的数据结构 在堆中生成一个代表这个类的java.lang.Class对象作为数据访问的入口 链接 # 验证:确保加载的类符合JVM规范和安全,保证被校验类的方法在运行时不会做出危害虚拟机的事件,其实就是一个安全检查 准备:为static变量在方法区分配内存空间,设置变量的初始值,例如static int = 3 (注意:准备阶段只设置类中的静态变量(方法区中),不包括实例变量(堆内存中),实例变量是对象初始化时赋值的) 解析:虚拟机将常量池内的符号引用,替换为直接引用的过程(符号引用比如我现在 import java.util.ArrayList 这就算符号引用,直接引用就是指针或者对象地址,注意引用对象一定是在内存进行) 初始化 # 初始化就是执行类构造器方法的clinit()的过程,而且要保证执行前父类的clinit()方法已经执行完毕。 这个方法由编译器收集(也就是编译时产生),顺序执行所有类变量(static 修饰的成员变量) 显示初始化和静态代码块中语句 此时准备阶段时的那个static int a 由默认初始化的0变成了显示初始化的3。由于执行顺序缘故,初始化阶段类变量如果在静态代码中又进行更改,则会覆盖类变量的显式初始化,最终值会为静态代码块中的赋值 字节码文件中初始化方法有两种,非静态资源初始化的init和静态资源初始化的clinit 类构造器方法clinit() 不同于类的构造器,这些方法都是字节码文件中只能给JVM识别的特殊方法 卸载 # GC将无用对象从内存中卸载\n类加载器的加载顺序 # 加载一个Class类的顺序也是有优先级的**(加载,也可以称\u0026quot;查找\u0026quot;)** ,类加载器 从最底层开始往上的顺序:\nBootStrap ClassLoader: rt.jar (lib/rt.jar) Extension ClassLoader: 加载扩展的jar包 (lib/ext/xxx.jar) APP ClassLoader: 指定的classpath下面的jar包 Custom ClassLoader: 自定义的类加载器 双亲委派机制 # 当一个类收到了加载请求时,它是不会先自己去尝试加载的,而是委派给父类去完成,比如我现在要 new 一个 Person,这个 Person 是我们自定义的类,如果我们要加载它,就会先委派 App ClassLoader ,只有当父类加载器都反馈自己无法完成这个请求(也就是父类加载器都没有找到加载所需的 Class)时,子类加载器才会自行尝试加载。\n好处:加载位于 rt.jar 包中的类时不管是哪个加载器加载,最终都会委托到 BootStrap ClassLoader 进行加载,这样保证了使用不同的类加载器得到的都是同一个结果。\n其实这起了一个隔离的作用,避免自己写的代码影响JDK的代码\npackage java.lang; public class String { public static void main(String[] args) { System.out.println(); } } 尝试运行当前类的 main 函数的时候,我们的代码肯定会报错。这是因为在加载的时候其实是找到了 rt.jar 中的java.lang.String,然而发现这个里面并没有 main 方法。\n运行时数据区 # 本地方法栈和程序计数器 # 比如说我们现在点开Thread类的源码,会看到它的start0方法带有一个native关键字修饰,而且不存在方法体,这种用native修饰的方法就是本地方法,这是使用C来实现的,然后一般这些方法都会放到一个叫做本地方法栈的区域。 程序计数器其实就是一个指针,它指向了我们程序中下一句需要执行的指令,它也是内存区域中唯一一个不会出现OutOfMemoryError的区域,而且占用内存空间小到基本可以忽略不计。这个内存仅代表当前线程所执行的字节码的行号指示器,字节码解析器通过改变这个计数器的值选取下一条需要执行的字节码指令。 如果执行的是native方法,那这个指针就不工作了 方法区 # 主要存放类的元数据信息、常量和静态变量\u0026hellip;等。 存储过大时,会在无法满足内存分配时报错 虚拟机栈和虚拟机堆 # 栈管运行,堆管存储 虚拟机栈负责运行代码,虚拟机堆负责存储数据 虚拟机栈的概念 # 虚拟机栈是Java方法执行的内存模型 对局部变量、动态链表、方法出口、栈的操作(入栈和出栈)进行存储,且线程独享。 如果我们听到局部变量表,就是在说虚拟机栈 public class Person{ int a = 1; public void doSomething(){ int b = 2; } } 虚拟机栈存在的异常 # 如果线程请求的栈的深度,大于虚拟机栈的最大深度,就会报StackOverflowError(比如递归) Java虚拟机也可以动态扩展,但随着扩展会不断地申请内存,当无法申请足够内存时就会报错 OutOfMemoryError 虚拟机栈的生命周期 # 栈不存在垃圾回收,只要程序运行结束,栈的空间自然释放 栈的生命周期和所处的线程一致 8种基本类型的变量+对象的引用变量+实例方法,都是在栈里面分配内存 虚拟机栈的执行 # 栈帧数据,在JVM中叫栈帧,Java中叫方法,它也是放在栈中 栈中的数据以栈帧的格式存在,它是一个关于方法和运行期数据的数据集 比如我们执行一个方法a,就会对应产生一个栈帧A1,然后A1会被压入栈中。同理方法b会有一个B1,方法c会有一个C1,等到这个线程执行完毕后,栈会先弹出C1,后B1,A1。它是一个先进后出,后进先出原则。\n局部变量的复用 # 用于存放方法参数和方法内部所定义的局部变量\n容量以Slot为最小单位,一个slot可以存放32以内的数据类型。\n在局部变量表里,32位以内的类型只占用一个slot(包括returnAddress类型),64位的类型(long和double)占两个slot。\n虚拟机通过索引方式使用局部变量表,范围为 [ 0 , 局部变量表的slot的数量 ]。方法中的参数就会按一定顺序排列在这个局部变量表中\n为了节省栈帧空间,这些slot是可以复用的。当方法执行位置超过了某个变量(这里意思应该是用过了这个变量),那么这个变量的slot可以被其它变量复用。当然如果需要复用,那我们的垃圾回收自然就不会去动这些内存\n虚拟机堆的概念 # JVM内存会划分为堆内存和非堆内存,堆内存也会划分为年轻代和老年代,而非堆内存则为永久代。\n年轻代又分为Eden和Survivor区,Survivor还分为FromPlace和ToPlace,toPlace的survivor区域是空的\nEden:FromPlace:ToPlace的默认占比是8:1:1,当然这个东西也可以通过一个-XX:+UsePSAdaptiveSurvivorSizePolicy参数来根据生成对象的速率动态调整\n(因为存活的对象相对较少)\n堆内存中存放的是对象,垃圾收集就是收集这些对象然后交给GC算法进行回收。非堆内存其实我们已经说过了,就是方法区。在1.8中已经移除永久代,替代品是一个元空间(MetaSpace),最大区别是metaSpace是不存在于JVM中的,它使用的是本地内存。并有两个参数:\nMetaspaceSize:初始化元空间大小,控制发生GC MaxMetaspaceSize:限制元空间大小上限,防止占用过多物理内存。 移除的原因\n融合HotSpot JVM和JRockit VM而做出的改变,因为JRockit是没有永久代的,不过这也间接性地解决了永久代的OOM问题。\nEden年轻代的介绍 # 当new一个对象后,会放到Eden划分出来的一块作为存储空间的内存,由于堆内存共享,所以可能出现两个对象共用一个内存的情况。\nJVM的处理:为每个内存都预先申请好一块连续的内存空间并规定对象存放的位置,如果空间不足会再申请多块内存空间。这个操作称为TLAB\nEden空间满了之后,会触发MinorGC(发生在年轻代的GC)操作,存活下来的对象移动到Survivor0区。Survivor0满后会触发MInorGC,将存活对象(这里应该包括Eden的存活对象?)移动到Survivor1区,此时还会把from和to两个指针交换,这样保证一段时间内总有一个survivor区为空且所指向的survivor区为空。\n经过多次的MinorGC后仍然存活的对象(这里存活判断是15次,对应的虚拟机参数为-XX:MaxTenuringThreshold 。HotSpot会在对象中的标记字段里记录年龄,分配到的空间仅有4位,所以最多记录到15)会移动到老年代。\n老年代是存储长期存活对象的,占满时就会触发我们常说的FullGC,期间会停止所有线程等待GC的完成。所以对于响应要求高的应用,应该尽量去减少发生FullGC从而避免响应超时的问题\n当老年区执行full gc周仍然无法进行对象保存操作,就会产生OOM。这时候就是虚拟机中堆内存不足,原因可能会是堆内存设置大小过小,可以通过参数**-Xms、-Xmx来调整。也可能是代码中创建对象大且多**,而且它们一直在被引用从而长时间垃圾收集无法收集它们\n关于-XX:TargetSurvivorRatio参数的问题。其实也不一定是要满足-XX:MaxTenuringThreshold才移动到老年代。可以举个例子:如**对象年龄5的占30%,年龄6的占36%,年龄7的占34%,加入某个年龄段(如例子中的年龄6)**后,总占用超过Survivor空间*TargetSurvivorRatio的时候,从该年龄段开始及大于的年龄对象就要进入老年代(即例子中的年龄6对象,就是年龄6和年龄7晋升到老年代),这时候无需等到MaxTenuringThreshold中要求的15\n如何判断一个对象需要被干掉 # 首先看一下对象的虚拟机的一些流程\n图例有点问题,橙色是线程共享,青绿色是线程独享 图中程序计数器、虚拟机栈、本地方法栈,3个区域随着线程生存而生存。内存分配和回收都是确定的,随着线程的结束内存自然就被回收了,因此不需要考虑垃圾回收问题。\nJava堆和方法区则不一样,各线程共享,内存的分配和回收都是动态的,垃圾收集器所关注的就是堆和方法区这部分内存\n垃圾回收前,判断哪些对象还存活,哪些已经死去。下面介绍连个基础计算方法:\n引用计数器计算:给对象添加一个引用计数器,每次引用这个对象时计数器加一,引用失效时减一,计数器等于0就是不会再次使用的。不过有一种情况,就是 出现对象的循环引用时GC没法回收(我觉得不是非得循环,如果一个对象a中有属性引用另一个对象b,而a指向null,那么按这种方式,b就没有办法被回收)。\n可达性分析计算:一种类似二叉树的实现,将一系列的GC ROOTS作为起始的存活对象集,从这个结点往下搜索,搜索所走过的路径成为引用链,把能被该集合引用到的对象加入该集合中。\n当一个对象到GC Roots没有使用任何引用链时,则说明该对象是不可用的。Java,C#都是用这个方法判断对象是否存活\nJava语言汇总作为GCRoots的对象分为以下几种:\n虚拟机栈(栈帧中的本地方法表)中引用的对象(局部变量)\n方法区中静态变量所引用的对象(静态变量)\n方法区中常量引用的变量\n本地方法栈(即native修饰的方法)中JNI引用的对象\n(JNI是Java虚拟机调用对应的C函数的方式,通过JNI函数也可以创建新的Java对象。且JNI对于对象的局部引用或者全局引用都会把它们指向的对象都标记为不可回收)\n已启动的且未终止的Java线程【这个描述好像是有问题的(不全),应该是用作同步监视器的对象】\n这种方法的优点是,能够解决循环引用的问题,可它的实现耗费大量资源和时间,也需要GC(分析过程引用关系不能发生变化,所以需要停止所有进程)\n如何宣告一个对象的真正死亡 # 首先,需要提到finalize()方法,是Object类的一个方法,一个对象的finalize()方法只会被系统自动调用一次,经过finalize()方法逃脱死亡的对象(比如在方法中,其他变量又一次引用了该对象),第二次不会再被调用\n并不提倡在程序中调用finalize()来进行自救。建议忘掉Java程序中该方法的存在。因为它执行的时间不确定,甚至是否被执行也不确定(Java程序的不正常退出),而且运行代价高昂,无法保证各个对象的调用顺序(甚至有不同线程中调用)。在Java9中已经被标记为 deprecated ,且 java.lang.ref.Cleaner(也就是强、软、弱、幻象引用的那一套)中已经逐步替换掉它,会比 finalize 来\ndeprecated英[ˈdeprəkeɪtɪd]美[ˈdeprəkeɪtɪd]\n判断一个对象的死亡至少需要两次标记\n如果对象可达性分析之后没发现与GC Roots相连的引用链,那它将会被第一次标记并且进行一次筛选,判断条件是是决定**这个对象是否有必要执行finalize()**方法。如果对象有必要执行finalize(),则被放入F-Queue队列 GC堆F-Queue队列中的对象进行二次标记。如果对象在finalize()方法中重新与引用链上的任何一个对象建立了关联,那么二次标记时则会将它移出“即将回收”集合。如果此时对象还没成功逃脱,那么只能被回收了。 垃圾回收算法 # 确定对象已经死亡,此刻需要回收这些垃圾。常用的有标记清除、复制、标记整理、和分代收集算法。\n标记清除算法 # 标记清除算法就是分为**”标记“和”清除“**两个阶段。标记出所有需要回收的对象,标记结束后统一回收。后续算法都根据这个基础来加以改进 即:把已死亡的对象标记为空闲内存,然后记录在空闲列表中,当我们需要new一个对象时,内存管理模块会从空闲列表中寻找空闲的内存来分给新的对象 不足方面:标记和清除效率比较低,且这种做法让内存中碎片非常多 。导致如果我们需要使用较大内存卡时,无法分配到足够的连续内存 如图,可使用的内存都是零零散散的,导致大内存对象问题 复制算法 # 为了解决效率问题,出现了复制算法。将内存按容量划分成两等份,每次只使用其中的一块,和survivor一样用from和to两个指针。fromPlace存满了,就把存活对象copy到另一块toPlace上,然后交换指针内容,就解决了碎片问题\n代价:内存缩水,即堆内存的使用效率变低了 默认情况Eden和Survivor 为 8: 2 (Eden : S0 : S1 = 8:1:1)\n标记整理 # 复制算法在对象存活率高的时候,仍然有效率问题(要复制的多)。 标记整理\u0026ndash;\u0026gt; 标记过程与标记-清除一样,但后续不是直接对可回收对象进行清理,而是让所有存活对象都向一端移动,然后直接清理掉边界以外内存 分代收集算法 # 这种算法并没有什么新的思想,只是根据对象存活周期的不同将内存划分为几块 一般是将Java堆分为新生代和老年代,即可根据各个年代特点采用最适当的收集算法 新生代中,每次垃圾收集时会有大批对象死去,只有少量存活,就采用复制算法,只需要付出少量存活对象的复制成本即可完成收集 老年代中,因为存活对象存活率高,也没有额外空间对它进行分配担保(新生代如果不够可以放老年代,而老年代清理失败就会OutOfMemory,不像新生代可以移动到老年代),所以必须使用**“标记-清理”或者“标记-整理”**来进行回收 即:具体问题具体分析 (了解)各种各样的垃圾回收器 # 新生代的垃圾回收器:Serial(串行\u0026ndash;复制),ParNew(并行\u0026ndash;复制),ParallelScavenge(并行\u0026ndash;复制)\n老年代的垃圾回收器:SerialOld(串行\u0026ndash;标记整理),ParallelOld(并行\u0026ndash;标记整理),CMS(并发\u0026ndash;标记清除)\n只有CMS和G1是并发,且CMS只作用于老年代,而G1都有\nJDK8为止,默认垃圾回收器是Parallel Scavenge和Parallel Old【并行\u0026ndash;复制和并行\u0026ndash;标记整理】\nJDK9开始,G1收集器成为默认的垃圾收集器,目前来看,G1回收期停顿时间最短且没有明显缺点,偏适合Web应用\njdk8中测试Web应用,堆内存6G中新生代4.5G的情况下\nParallelScavenge回收新生代停顿长达1.5秒。 G1回收器回收同样大小的新生代只停顿0.2秒 (了解) JVM的常用参数 # JVM的参数非常之多,这里只列举比较重要的几个,通过各种各样的搜索引擎也可以得知这些信息。\n参数名称 含义 默认值 说明 -Xms 初始堆大小 物理内存的1/64(\u0026lt;1GB) 默认(MinHeapFreeRatio参数可以调整)空余堆内存小于40%时,JVM就会增大堆直到-Xmx的最大限制. -Xmx 最大堆大小 物理内存的1/4(\u0026lt;1GB) 默认(MaxHeapFreeRatio参数可以调整)空余堆内存大于70%时,JVM会减少堆直到 -Xms的最小限制 -Xmn 年轻代大小(1.4or later) 注意:此处的大小是(eden+ 2 survivor space).与jmap -heap中显示的New gen是不同的。整个堆大小=年轻代大小 + 老年代大小 + 持久代(永久代)大小.增大年轻代后,将会减小年老代大小.此值对系统性能影响较大,Sun官方推荐配置为整个堆的3/8 -XX:NewSize 设置年轻代大小(for 1.3/1.4) -XX:MaxNewSize 年轻代最大值(for 1.3/1.4) -XX:PermSize 设置持久代(perm gen)初始值 物理内存的1/64 -XX:MaxPermSize 设置持久代最大值 物理内存的1/4 -Xss 每个线程的堆栈大小 JDK5.0以后每个线程堆栈大小为1M,以前每个线程堆栈大小为256K.根据应用的线程所需内存大小进行 调整.在相同物理内存下,减小这个值能生成更多的线程.但是操作系统对一个进程内的线程数还是有限制的,不能无限生成,经验值在3000~5000左右一般小的应用, 如果栈不是很深, 应该是128k够用的 大的应用建议使用256k。这个选项对性能影响比较大,需要严格的测试。(校长)和threadstacksize选项解释很类似,官方文档似乎没有解释,在论坛中有这样一句话:-Xss is translated in a VM flag named ThreadStackSize”一般设置这个值就可以了 -XX:NewRatio 年轻代(包括Eden和两个Survivor区)与年老代的比值(除去持久代) -XX:NewRatio=4表示年轻代与年老代所占比值为1:4,年轻代占整个堆栈的1/5Xms=Xmx并且设置了Xmn的情况下,该参数不需要进行设置。 -XX:SurvivorRatio Eden区与Survivor区的大小比值 设置为8,则两个Survivor区与一个Eden区的比值为2:8,一个Survivor区占整个年轻代的1/10 -XX:+DisableExplicitGC 关闭System.gc() 这个参数需要严格的测试 -XX:PretenureSizeThreshold 对象超过多大是直接在旧生代分配 0 单位字节 新生代采用Parallel ScavengeGC时无效另一种直接在旧生代分配的情况是大的数组对象,且数组中无外部引用对象. -XX:ParallelGCThreads 并行收集器的线程数 此值最好配置与处理器数目相等 同样适用于CMS -XX:MaxGCPauseMillis 每次年轻代垃圾回收的最长时间(最大暂停时间) 如果无法满足此时间,JVM会自动调整年轻代大小,以满足此值. 其实还有一些打印及CMS方面的参数,这里就不以一一列举了\n关于JVM调优的一些方面 # 默认\n年轻代:老年代 = 1: 2 年轻代中 Eden : S0 : S 1 = 8 : 1 :1 根据刚刚涉及的jvm知识点,可以尝试对JVM进行调优,主要是堆内存那块\n所有线程共享数据区大小=新生代大小+老年代大小+持久代大小 (即 堆 + 方法区)\n持久代一般固定大小为64m,\njava堆中增大年轻代后,会减少老年代大小(因为老年代的清理使用fullgc,所以老年代过小的话反而会增多fullgc)。 年轻代 -Xmn的值推荐配置为java堆的3/8\n调整最大堆内存和最小堆内存 # -Xmx -Xms:指定java堆最大值(默认 物理内存的1/4 (\u0026lt;1 GB ) ) 和 初始java堆最小值(默认值是物理内存的1/64 (\u0026lt;1GB) )\n默认(MinHeapFreeRatio参数可以调整)空余堆内存小于40%时,JVM就会增大堆直到-Xmx的最大限制.,默认(MaxHeapFreeRatio参数可以调整)空余堆内存大于70%时,JVM会减少堆直到 -Xms的最小限制。\n简单点来说,你不停地往堆内存里面丢数据,等它剩余大小小于40%了,JVM就会动态申请内存空间不过会小于-Xmx,如果剩余大小大于70%,又会动态缩小不过不会小于–Xms。就这么简单\n开发过程中,通常会将 -Xms 与 Xmx 两个参数设置成相同的值\n为的是能够在java垃圾回收机制清理完堆区后,不需要重新分隔计算堆区的大小而浪费资源(向系统请求/释放内存资源)\n代码\npublic class App { public static void main(String[] args) { System.out.println(\u0026#34;Xmx=\u0026#34; + Runtime.getRuntime().maxMemory() / 1024.0 + \u0026#34;KB\u0026#34;); //系统的最大空间-Xmx--运行几次都不变 System.out.println(\u0026#34;free mem=\u0026#34; + Runtime.getRuntime().freeMemory() / 1024.0 + \u0026#34;KB\u0026#34;); //系统的空闲空间--每次运行都变 System.out.println(\u0026#34;total mem=\u0026#34; + Runtime.getRuntime().totalMemory() / 1024.0 + \u0026#34;KB\u0026#34;); //当前可用的总空间 与Xms有关--运行几次都不变 } } /* ----- Xmx=7389184.0KB free mem=493486.0546875KB total mem=498688.0KB */ maxMemory()这个方法返回的是java虚拟机(这个进程)能构从操纵系统那里挖到的最大的内存 freeMemory:挖过来而又没有用上的内存,实际上就是 freeMemory(),所以freeMemory()的值一般情况下都是很小的(totalMemory一般比需要用得多一点,剩下的一点就是freeMemory) totalMemory:程序运行的过程中,内存总是慢慢的从操纵系统那里挖的,基本上是用多少挖多少,直 挖到maxMemory()为止,所以totalMemory()是慢慢增大的 原文链接:https://blog.csdn.net/weixin_35671171/article/details/114189796 编辑VM options参数后再看效果:\n-Xmx20m -Xms5m -XX:+PrintGCDetails,堆最大以及堆初始值 20m和5m\n/* 效果 [GC (Allocation Failure) [PSYoungGen: 1024K-\u0026gt;488K(1536K)] 1024K-\u0026gt;608K(5632K), 0.0007606 secs] [Times: user=0.00 sys=0.00, real=0.00 secs] Xmx=18432.0KB free mem=4249.90625KB total mem=5632.0KB Heap PSYoungGen total 1536K, used 1326K [0x00000000ff980000, 0x00000000ffb80000, 0x0000000100000000) eden space 1024K, 81% used [0x00000000ff980000,0x00000000ffa51ad0,0x00000000ffa80000) from space 512K, 95% used [0x00000000ffa80000,0x00000000ffafa020,0x00000000ffb00000) to space 512K, 0% used [0x00000000ffb00000,0x00000000ffb00000,0x00000000ffb80000) ParOldGen total 4096K, used 120K [0x00000000fec00000, 0x00000000ff000000, 0x00000000ff980000) object space 4096K, 2% used [0x00000000fec00000,0x00000000fec1e010,0x00000000ff000000) Metaspace used 3164K, capacity 4496K, committed 4864K, reserved 1056768K class space used 344K, capacity 388K, committed 512K, reserved 1048576K */ 如上, Allocation Failure 因为分配失败导致YoungGen total mem (此时申请到的总内存):\nPSYoungGen + ParOldGen = 1536 + 4096 = 5632 KB freeMemory (申请后没有使用的内存)\n1324 + 120 = 1444 KB 5632 - 4249 = 1383 KB 差不多 使用1M后\npublic class App { public static void main(String[] args) { System.out.println(\u0026#34;Xmx=\u0026#34; + Runtime.getRuntime().maxMemory() / 1024.0 + \u0026#34;KB\u0026#34;); //系统的最大空间-Xmx--运行几次都不变 System.out.println(\u0026#34;free mem=\u0026#34; + Runtime.getRuntime().freeMemory() / 1024.0 + \u0026#34;KB\u0026#34;); //系统的空闲空间--每次运行都变 System.out.println(\u0026#34;total mem=\u0026#34; + Runtime.getRuntime().totalMemory() / 1024.0 + \u0026#34;KB\u0026#34;); //当前可用的总空间 与Xms有关--运行几次都不变 byte[] b = new byte[1 * 1024 * 1024]; System.out.println(\u0026#34;分配了1M空间给数组\u0026#34;); System.out.println(\u0026#34;Xmx=\u0026#34; + Runtime.getRuntime().maxMemory() / 1024.0 / 1024 + \u0026#34;M\u0026#34;); //系统的最大空间 System.out.println(\u0026#34;free mem=\u0026#34; + Runtime.getRuntime().freeMemory() / 1024.0 / 1024 + \u0026#34;M\u0026#34;); //系统的空闲空间 System.out.println(\u0026#34;total mem=\u0026#34; + Runtime.getRuntime().totalMemory() / 1024.0 / 1024 + \u0026#34;M\u0026#34;); } } /** [GC (Allocation Failure) [PSYoungGen: 1024K-\u0026gt;488K(1536K)] 1024K-\u0026gt;608K(5632K), 0.0007069 secs] [Times: user=0.00 sys=0.00, real=0.00 secs] Xmx=18432.0KB free mem=4270.15625KB total mem=5632.0KB 分配了1M空间给数组 Xmx=18.0M free mem=3.1700592041015625M //少了1M total mem=5.5M Heap PSYoungGen total 1536K, used 1270K [0x00000000ff980000, 0x00000000ffb80000, 0x0000000100000000) eden space 1024K, 76% used [0x00000000ff980000,0x00000000ffa43aa0,0x00000000ffa80000) from space 512K, 95% used [0x00000000ffa80000,0x00000000ffafa020,0x00000000ffb00000) to space 512K, 0% used [0x00000000ffb00000,0x00000000ffb00000,0x00000000ffb80000) ParOldGen total 4096K, used 1144K [0x00000000fec00000, 0x00000000ff000000, 0x00000000ff980000) object space 4096K, 27% used [0x00000000fec00000,0x00000000fed1e020,0x00000000ff000000) Metaspace used 3155K, capacity 4496K, committed 4864K, reserved 1056768K class space used 344K, capacity 388K, committed 512K, reserved 1048576K */ 此时free memory就又缩水了,不过total memory是没有变化的。Java会尽可能将total mem的值维持在最小堆内存大小\n这时候我们创建了一个10M的字节数据,这时候最小堆内存是顶不住的。我们会发现现在的total memory已经变成了15M,这就是已经申请了一次内存的结果。\npublic class App { public static void main(String[] args) { System.out.println(\u0026#34;Xmx=\u0026#34; + Runtime.getRuntime().maxMemory() / 1024.0 + \u0026#34;KB\u0026#34;); //系统的最大空间-Xmx--运行几次都不变 System.out.println(\u0026#34;free mem=\u0026#34; + Runtime.getRuntime().freeMemory() / 1024.0 + \u0026#34;KB\u0026#34;); //系统的空闲空间--每次运行都变 System.out.println(\u0026#34;total mem=\u0026#34; + Runtime.getRuntime().totalMemory() / 1024.0 + \u0026#34;KB\u0026#34;); //当前可用的总空间 与Xms有关--运行几次都不变 byte[] b = new byte[1 * 1024 * 1024]; System.out.println(\u0026#34;分配了1M空间给数组\u0026#34;); System.out.println(\u0026#34;Xmx=\u0026#34; + Runtime.getRuntime().maxMemory() / 1024.0 / 1024 + \u0026#34;M\u0026#34;); //系统的最大空间 System.out.println(\u0026#34;free mem=\u0026#34; + Runtime.getRuntime().freeMemory() / 1024.0 / 1024 + \u0026#34;M\u0026#34;); //系统的空闲空间 System.out.println(\u0026#34;total mem=\u0026#34; + Runtime.getRuntime().totalMemory() / 1024.0 / 1024 + \u0026#34;M\u0026#34;); byte[] c = new byte[10 * 1024 * 1024]; System.out.println(\u0026#34;分配了10M空间给数组\u0026#34;); System.out.println(\u0026#34;Xmx=\u0026#34; + Runtime.getRuntime().maxMemory() / 1024.0 / 1024 + \u0026#34;M\u0026#34;); //系统的最大空间 System.out.println(\u0026#34;free mem=\u0026#34; + Runtime.getRuntime().freeMemory() / 1024.0 / 1024 + \u0026#34;M\u0026#34;); //系统的空闲空间 System.out.println(\u0026#34;total mem=\u0026#34; + Runtime.getRuntime().totalMemory() / 1024.0 / 1024 + \u0026#34;M\u0026#34;); //当前可用的总空间 } } /** ---- [GC (Allocation Failure) [PSYoungGen: 1024K-\u0026gt;488K(1536K)] 1024K-\u0026gt;600K(5632K), 0.0006681 secs] [Times: user=0.00 sys=0.00, real=0.00 secs] Xmx=18432.0KB free mem=4257.953125KB total mem=5632.0KB 分配了1M空间给数组 Xmx=18.0M free mem=3.1153564453125M total mem=5.5M 分配了10M空间给数组 Xmx=18.0M free mem=2.579681396484375M total mem=15.0M Heap PSYoungGen total 1536K, used 1363K [0x00000000ff980000, 0x00000000ffb80000, 0x0000000100000000) eden space 1024K, 85% used [0x00000000ff980000,0x00000000ffa5acc0,0x00000000ffa80000) from space 512K, 95% used [0x00000000ffa80000,0x00000000ffafa020,0x00000000ffb00000) to space 512K, 0% used [0x00000000ffb00000,0x00000000ffb00000,0x00000000ffb80000) ParOldGen total 13824K, used 11376K [0x00000000fec00000, 0x00000000ff980000, 0x00000000ff980000) object space 13824K, 82% used [0x00000000fec00000,0x00000000ff71c020,0x00000000ff980000) Metaspace used 3242K, capacity 4500K, committed 4864K, reserved 1056768K class space used 351K, capacity 388K, committed 512K, reserved 1048576K */ 此时我们再跑一下这个代码\n此时要调整垃圾收集器(-XX:+UseG1GC)且b、c要指向null,才能让系统回收这部分内存,即-Xmx20m -Xms5m -XX:+PrintGCDetails -XX:+UseG1GC 注:使用-XX: +UseSerialGC或者-XX:+UseParallelGC都是不能达到效果的\n此时我们手动执行了一次fullgc,此时total memory的内存空间又变回6.0M了,此时又是把申请的内存释放掉的结果。\npublic class App { public static void main(String[] args) throws InterruptedException { System.out.println(\u0026#34;Xmx=\u0026#34; + Runtime.getRuntime().maxMemory() / 1024.0 + \u0026#34;KB\u0026#34;); //系统的最大空间-Xmx--运行几次都不变 System.out.println(\u0026#34;free mem=\u0026#34; + Runtime.getRuntime().freeMemory() / 1024.0 + \u0026#34;KB\u0026#34;); //系统的空闲空间--每次运行都变 System.out.println(\u0026#34;total mem=\u0026#34; + Runtime.getRuntime().totalMemory() / 1024.0 + \u0026#34;KB\u0026#34;); //当前可用的总空间 与Xms有关--运行几次都不变 byte[] b = new byte[1 * 1024 * 1024]; System.out.println(\u0026#34;分配了1M空间给数组\u0026#34;); System.out.println(\u0026#34;Xmx=\u0026#34; + Runtime.getRuntime().maxMemory() / 1024.0 / 1024 + \u0026#34;M\u0026#34;); //系统的最大空间 System.out.println(\u0026#34;free mem=\u0026#34; + Runtime.getRuntime().freeMemory() / 1024.0 / 1024 + \u0026#34;M\u0026#34;); //系统的空闲空间 System.out.println(\u0026#34;total mem=\u0026#34; + Runtime.getRuntime().totalMemory() / 1024.0 / 1024 + \u0026#34;M\u0026#34;); byte[] c = new byte[10 * 1024 * 1024]; System.out.println(\u0026#34;分配了10M空间给数组\u0026#34;); System.out.println(\u0026#34;Xmx=\u0026#34; + Runtime.getRuntime().maxMemory() / 1024.0 / 1024 + \u0026#34;M\u0026#34;); //系统的最大空间 System.out.println(\u0026#34;free mem=\u0026#34; + Runtime.getRuntime().freeMemory() / 1024.0 / 1024 + \u0026#34;M\u0026#34;); //系统的空闲空间 System.out.println(\u0026#34;total mem=\u0026#34; + Runtime.getRuntime().totalMemory() / 1024.0 / 1024 + \u0026#34;M\u0026#34;); //当前可用的总空间 b=null; c=null; System.gc(); System.out.println(\u0026#34;进行了gc\u0026#34;); System.out.println(\u0026#34;Xmx=\u0026#34; + Runtime.getRuntime().maxMemory() / 1024.0 / 1024 + \u0026#34;M\u0026#34;); //系统的最大空间 System.out.println(\u0026#34;free mem=\u0026#34; + Runtime.getRuntime().freeMemory() / 1024.0 / 1024 + \u0026#34;M\u0026#34;); //系统的空闲空间 System.out.println(\u0026#34;total mem=\u0026#34; + Runtime.getRuntime().totalMemory() / 1024.0 / 1024 + \u0026#34;M\u0026#34;); //当前可用的总空间 } } /*-------- Xmx=20480.0KB free mem=4290.3671875KB total mem=6144.0KB 分配了1M空间给数组 Xmx=20.0M free mem=3.1897964477539062M total mem=6.0M [GC pause (G1 Humongous Allocation) (young) (initial-mark), 0.0014754 secs] [Parallel Time: 1.1 ms, GC Workers: 8] [GC Worker Start (ms): Min: 105.0, Avg: 105.1, Max: 105.3, Diff: 0.3] [Ext Root Scanning (ms): Min: 0.5, Avg: 0.5, Max: 0.8, Diff: 0.4, Sum: 4.4] [Update RS (ms): Min: 0.0, Avg: 0.0, Max: 0.0, Diff: 0.0, Sum: 0.0] [Processed Buffers: Min: 0, Avg: 0.0, Max: 0, Diff: 0, Sum: 0] [Scan RS (ms): Min: 0.0, Avg: 0.0, Max: 0.0, Diff: 0.0, Sum: 0.0] [Code Root Scanning (ms): Min: 0.0, Avg: 0.0, Max: 0.0, Diff: 0.0, Sum: 0.0] [Object Copy (ms): Min: 0.1, Avg: 0.3, Max: 0.4, Diff: 0.2, Sum: 2.5] [Termination (ms): Min: 0.0, Avg: 0.0, Max: 0.0, Diff: 0.0, Sum: 0.3] [Termination Attempts: Min: 1, Avg: 6.0, Max: 9, Diff: 8, Sum: 48] [GC Worker Other (ms): Min: 0.0, Avg: 0.0, Max: 0.1, Diff: 0.1, Sum: 0.2] [GC Worker Total (ms): Min: 0.8, Avg: 0.9, Max: 1.0, Diff: 0.3, Sum: 7.4] [GC Worker End (ms): Min: 106.0, Avg: 106.1, Max: 106.1, Diff: 0.0] [Code Root Fixup: 0.0 ms] [Code Root Purge: 0.0 ms] [Clear CT: 0.1 ms] [Other: 0.3 ms] [Choose CSet: 0.0 ms] [Ref Proc: 0.1 ms] [Ref Enq: 0.0 ms] [Redirty Cards: 0.1 ms] [Humongous Register: 0.0 ms] [Humongous Reclaim: 0.0 ms] [Free CSet: 0.0 ms] [Eden: 2048.0K(3072.0K)-\u0026gt;0.0B(1024.0K) Survivors: 0.0B-\u0026gt;1024.0K Heap: 2877.6K(6144.0K)-\u0026gt;1955.9K(6144.0K)] [Times: user=0.00 sys=0.00, real=0.00 secs] [GC concurrent-root-region-scan-start] [GC concurrent-root-region-scan-end, 0.0005373 secs] [GC concurrent-mark-start] [GC concurrent-mark-end, 0.0000714 secs] [GC remark [Finalize Marking, 0.0001034 secs] [GC ref-proc, 0.0000654 secs] [Unloading, 0.0005193 secs], 0.0007843 secs] [Times: user=0.00 sys=0.00, real=0.00 secs] [GC cleanup 11M-\u0026gt;11M(17M), 0.0003613 secs] [Times: user=0.00 sys=0.00, real=0.00 secs] 分配了10M空间给数组 Xmx=20.0M free mem=5.059120178222656M total mem=17.0M [Full GC (System.gc()) 11M-\u0026gt;654K(6144K), 0.0031959 secs] [Eden: 1024.0K(1024.0K)-\u0026gt;0.0B(2048.0K) Survivors: 1024.0K-\u0026gt;0.0B Heap: 11.9M(17.0M)-\u0026gt;654.4K(6144.0K)], [Metaspace: 3152K-\u0026gt;3152K(1056768K)] [Times: user=0.00 sys=0.00, real=0.00 secs] 进行了gc Xmx=20.0M free mem=5.2661590576171875M total mem=6.0M Heap garbage-first heap total 6144K, used 654K [0x00000000fec00000, 0x00000000fed00030, 0x0000000100000000) region size 1024K, 1 young (1024K), 0 survivors (0K) Metaspace used 3243K, capacity 4500K, committed 4864K, reserved 1056768K class space used 351K, capacity 388K, committed 512K, reserved 1048576K */ 调整新生代和老年代的比值 # -XX:NewRatio \u0026mdash; 新生代(eden+2*Survivor)和老年代(不包含永久区)的比值\n例如:-XX:NewRatio=4,表示新生代:老年代=1:4,即新生代占整个堆的1/5。在Xms=Xmx并且设置了Xmn的情况下,该参数不需要进行设置。 注:Xmn为直接设置大小,如-Xmn2G\n调整Survivor区和Eden区的比值 # -XX:SurvivorRatio(幸存代)\u0026mdash; 设置两个Survivor区和eden的比值\n例如:8,表示两个Survivor:eden=2:8,即一个Survivor占年轻代的1/10\n设置年轻代和老年代的大小 # -XX:NewSize \u0026mdash; 设置年轻代大小\n-XX:MaxNewSize \u0026mdash; 设置年轻代最大值\n可以通过设置不同参数来测试不同的情况,反正最优解当然就是官方的Eden和Survivor的占比为8:1:1,然后在刚刚介绍这些参数的时候都已经附带了一些说明,感兴趣的也可以看看。反正最大堆内存和最小堆内存如果数值不同会导致多次的gc,需要注意。\n我的理解是会经常调整totalMemory而导致多次gc,避免临界条件下的 垃圾回收和内存申请和分配\n注: 最大堆内存和最小堆内存设置成一样,为的是能够在java垃圾回收机制清理完堆区后,不需要重新分隔计算堆区的大小而浪费资源(向系统请求/释放内存资源)\n小总结 # 根据实际事情调整新生代和幸存代的大小,官方推荐新生代占java堆的3/8,幸存代占新生代的1/10\nJava堆:新生代 (3/8),老年代\n新生代:SO (1/10) ,S1 ,Eden\n在OOM时,记得Dump出堆,确保可以排查现场问题,通过下面命令可以输出一个.dump 文件,该文件用VisualVM或Java自带的JavaVisualVM 工具\n-Xmx20m -Xms5m -XX:+HeapDumpOnOutOfMemoryError -XX:HeapDumpPath=你要输出的日志路径 一般我们也可以通过编写脚本的方式来让OOM出现时给我们报个信,可以通过发送邮件或者重启程序等来解决\n永久区的设置 # -XX:PermSize -XX:MaxPermSize,应该说的是永久代\n初始空间(默认为物理内存的1/64)和最大空间(默认为物理内存的1/4)。也就是说,jvm启动时,永久区一开始就占用了PermSize大小的空间,如果空间还不够,可以继续扩展,但是不能超过MaxPermSize,否则会OOM。\n如果堆空间没有用完也抛出了OOM,有可能是永久区导致的。堆空间实际占用非常少,但是永久区溢出 一样抛出OOM\nJVM的栈参数调优 # 调整每个线程栈空间的大小 # 可以通过**-Xss**:调整每个线程栈空间的大小\nJDK5.0以后每个线程堆栈大小为1M,以前每个线程堆栈大小为256K。在相同物理内存下,减小这个值能生成更多的线程。但是操作系统对一个进程内的线程数还是有限制的,不能无限生成,经验值在3000~5000左右\n设置线程栈的大小 # -XXThreadStackSize: #设置线程栈的大小(0 means use default stack size) 补充:\n-Xss是OpenJDK和Oracle JDK的-XX:ThreadStackSize的别名。\n尽管他们对参数的解析不同: -Xss可以接受带K,M或G后缀的数字; -XX:ThreadStackSize=需要一个整数(无后缀)-堆栈大小(以千字节为单位)\n(可以直接跳过了)JVM其他参数介绍 # 形形色色的参数很多,就不会说把所有都扯个遍了,因为大家其实也不会说一定要去深究到底。\n设置内存页的大小 # -XXThreadStackSize: 设置内存页的大小,不可设置过大,会影响Perm的大小 设置原始类型的快速优化 # -XX:+UseFastAccessorMethods: 设置原始类型的快速优化 设置关闭手动GC # -XX:+DisableExplicitGC: 设置关闭System.gc()(这个参数需要严格的测试) 设置垃圾最大年龄 # -XX:MaxTenuringThreshold 设置垃圾最大年龄。如果设置为0的话,则年轻代对象不经过Survivor区,直接进入年老代. 对于年老代比较多的应用,可以提高效率。如果将此值设置为一个较大值, 则年轻代对象会在Survivor区进行多次复制,这样可以增加对象再年轻代的存活时间, 增加在年轻代即被回收的概率。该参数只有在串行GC时才有效. 加快编译速度 # -XX:+AggressiveOpts 加快编译速度\n改善锁机制性能 # -XX:+UseBiasedLocking 禁用垃圾回收 # -Xnoclassgc 设置堆空间存活时间 # -XX:SoftRefLRUPolicyMSPerMB 设置每兆堆空闲空间中SoftReference的存活时间,默认值是1s。 设置对象直接分配在老年代 # -XX:PretenureSizeThreshold 设置对象超过多大时直接在老年代分配,默认值是0。 设置TLAB占eden区的比例 # -XX:TLABWasteTargetPercent 设置TLAB占eden区的百分比,默认值是1% 。 设置是否优先YGC # -XX:+CollectGen0First 设置FullGC时是否先YGC,默认值是false。 finally # 附录:\n真的扯了很久这东西,参考了多方的资料,有极客时间的《深入拆解虚拟机》和《Java核心技术面试精讲》,也有百度,也有自己在学习的一些线上课程的总结。希望对你有所帮助,谢谢。\n"},{"id":317,"href":"/zh/docs/technology/Review/java_guide/java/JVM/ly0401lymemory-area/","title":"memory-area","section":"Java基础","content":" 转载自https://github.com/Snailclimb/JavaGuide (添加小部分笔记)感谢作者!\n如果没有特殊说明,针对的都是HotSpot虚拟机\n前言 # 对于Java程序员,虚拟机自动管理机制,不需要像C/C++程序员为每一个new 操作去写对应的delete/free 操作,不容易出现内存泄漏 和 内存溢出问题 但由于内存控制权交给Java虚拟机,一旦出现内存泄漏和溢出方面问题,如果不了解虚拟机是怎么样使用内存,那么很难排查任务 运行时数据区域 # Java虚拟机在执行Java程序的过程中,会把它管理的内存,划分成若干个不同的数据区域\nJDK1.8之前:\n线程共享 堆,方法区【永久代】(包括运行时常量池) 线程私有 虚拟机栈、本地方法栈、程序计数器 本地内存(包括直接内存) JDK1.8之后:\n1.8之后整个永久代改名叫\u0026quot;元空间\u0026quot;,且移到了本地内存中\n规范(概括):\n线程私有:程序计数器,虚拟机栈,本地方法栈\n线程共享:堆,方法区,直接内存(非运行时数据区的一部分)\nJava虚拟机规范对于运行时数据区域的规定是相当宽松的,以堆为例:\n堆可以是连续,也可以不连续 大小可以固定,也可以运行时按需扩展 虚拟机实现者可以使用任何垃圾回收算法管理堆,设置不进行垃圾收集 程序计数器 # 是一块较小内存空间,看作是当前线程所执行的字节码的行号指示器\njava程序流程\n字节码解释器,工作时通过改变这个计数器的值来选取下一条需要执行的字节码指令\n分支、循环、跳转、异常处理、线程恢复等功能都需要依赖这个计数器\n而且,为了线程切换后恢复到正确执行位置,每条线程需要一个独立程序计数器,各线程计数器互不影响,独立存储,我们称这类内存区域为**\u0026ldquo;线程私有\u0026rdquo;**的内存\n总结,程序计数器的作用\n字节码解释器通过改变程序计数器来依次读取指令,从而实现代码的流程控制 多线程情况下,程序计数器用于记录当前线程执行的位置,从而当线程被切回来的时候能够知道该线程上次运行到哪 程序计数器是唯一一个不会出现OutOfMemoryError的内存区域,它的生命周期随线程创建而创建,线程结束而死亡\nJava虚拟机栈 # Java虚拟机栈,简称\u0026quot;栈\u0026quot;,也是线程私有的,生命周期和线程相同,随线程创建而创建,线程死亡而死亡 除了Native方法调用的是通过本地方法栈实现的,其他所有的Java方法调用都是通过栈来实现的(需要和其他运行时数据区域比如程序计数器配合) 方法调用的数据需要通过栈进行传递,每一次方法调用都会有一个对应的栈帧被压入栈,每一个方法调用结束后,都会有一个栈帧被弹出。 栈由一个个栈帧组成,每个栈帧包括局部变量表、操作数栈、动态链接、方法返回地址。 栈为先进后出,且只支持出栈和入栈 局部变量表:存放编译器可知的各种数据类型(boolean、byte、char、short、int、float、long、double)、对象引用(reference 类型,不同于对象本身,可能是一个指向对象起始地址的引用指针,也可能是一个指向一个代表对象的句柄或其他与此对象相关的位置) 操作数栈 作为方法调用的中转站使用,用于存放方法执行过程中产生的中间计算结果。计算过程中产生的临时变量也放在操作数栈中\n动态链接 主要服务一个方法需要调用其他方法的场景。\n在 Java 源文件被编译成字节码文件时,所有的变量和方法引用都作为符号引用(Symbilic Reference)保存在 Class 文件的常量池里。当一个方法要调用其他方法,需要将常量池中指向方法的符号引用转化为其在内存地址中的直接引用。动态链接的作用就是为了将符号引用转换为调用方法的直接引用。\n如果函数调用陷入无限循环,会导致栈中被压入太多栈帧而占用太多空间,导致栈空间过深。当线程请求栈的深度超过当前Java虚拟机栈的最大深度时,就会抛出StackOverFlowError错误\nJava 方法有两种返回方式,一种是 return 语句正常返回,一种是抛出异常。不管哪种返回方式,都会导致栈帧被弹出。也就是说, 栈帧随着方法调用而创建,随着方法结束而销毁。无论方法正常完成还是异常完成都算作方法结束。\n除了 StackOverFlowError 错误之外,栈还可能会出现OutOfMemoryError错误,这是因为如果栈的内存大小可以动态扩展, 如果虚拟机在动态扩展栈时无法申请到足够的内存空间,则抛出**OutOfMemoryError**异常。\n总结,程序运行中栈可能出现的两种错误\nStackOverFlowError:若栈的内存大小不允许动态扩展,那么当线程请求栈的深度超过当前 Java 虚拟机栈的最大深度的时候,就抛出 StackOverFlowError 错误。 OutOfMemoryError: 如果栈的内存大小可以动态扩展, 如果虚拟机在动态扩展栈时无法申请到足够的内存空间,则抛出OutOfMemoryError异常。 本地方法栈 # 和虚拟机栈作用相似,区别:虚拟机栈为虚拟机执行Java方法(字节码)服务,本地方法栈则为虚拟机使用到的Native方法服务。HotSpot虚拟机中和Java虚拟机栈合二为一\n同上,本地方法被执行时,本地方法栈会创建一个栈帧,用于存放本地方法的局部变量表、操作数栈、动态链接、出口信息\n方法执行完毕后相应的栈帧也会出栈并释放内存空间,也会出现StackOverFlowError和OutOfMemoryError两种错误\n堆 # Java虚拟机所管理的内存中最大的一块,Java堆是所有线程共享的一块区域,在虚拟机启动时创建\n此内存区域唯一目的是存放对象实例,几乎所有的对象实例及数组,都在这里分配内存\n“几乎”,因为随着JIT编译器的发展与逃逸分析技术逐渐成熟,栈上分配、标量替换导致微妙变化。从JDK1.7开始已经默认逃逸分析,如果某些方法的对象引用没有被返回或者未被外面使用(未逃逸出去),那么对象可以直接在栈上分配内存。\nJava堆是垃圾收集器管理的主要区域,因此也称GC堆(Garbage Collected Heap)\n现在收集器基本都采用分代垃圾收集算法,从垃圾回收的角度,Java堆还细分为:新生代和老年代。再细致:Eden,Survivor,Old等空间。\u0026gt; 目的是更好的回收内存,或更快地分配内存\nJDK7及JDK7之前,堆内存被分为三部分\n新生代内存(Young Generation),包括Eden区、两个Survivor区S0和S1【8:1:1】 老生代(Old Generation) 【新生代 : 老年代= 1: 2】 永久代(Permanent Generation) JDK8之后PermGen(永久)已被Metaspace(元空间)取代,且元空间使用直接内存\n大部分情况,对象都会首先在 Eden 区域分配,在一次新生代垃圾回收后,如果对象还存活,则会进入 S0 或者 S1,并且对象的年龄还会加 1(Eden 区-\u0026gt;Survivor 区后对象的初始年龄变为 1),当它的年龄增加到一定程度(默认为 15 岁),就会被晋升到老年代中。\n对象晋升到老年代的年龄阈值,可以通过参数 -XX:MaxTenuringThreshold 来设置。\n修正(参见: issue552open in new window) :“Hotspot 遍历所有对象时,按照年龄从小到大对其所占用的大小进行累积,当累积的某个年龄大小超过了 survivor 区的一半时,取这个年龄和 MaxTenuringThreshold 中更小的一个值,作为新的晋升年龄阈值”。图解:\n代码如下:\nuint ageTable::compute_tenuring_threshold(size_t survivor_capacity) { //survivor_capacity是survivor空间的大小 size_t desired_survivor_size = (size_t)((((double) survivor_capacity)*TargetSurvivorRatio)/100); size_t total = 0; uint age = 1; while (age \u0026lt; table_size) { total += sizes[age];//sizes数组是每个年龄段对象大小 if (total \u0026gt; desired_survivor_size) break; age++; } uint result = age \u0026lt; MaxTenuringThreshold ? age : MaxTenuringThreshold; ... } 堆里最容易出现OutOfMemoryError错误,出现这个错误之后的表现形式:\njava.lang.OutOfMemoryError: GC Overhead Limit Exceeded : 当 JVM 花太多时间执行垃圾回收并且只能回收很少的堆空间时,就会发生此错误。\njava.lang.OutOfMemoryError: Java heap space :假如在创建新的对象时, 堆内存中的空间不足以存放新创建的对象, 就会引发此错误。\n(和配置的最大堆内存有关,且受制于物理内存大小。最大堆内存可通过-Xmx参数配置,若没有特别配置,将会使用默认值,详见: Default Java 8 max heap sizeopen in new window)\n\u0026hellip;\n方法区 # 方法区属于JVM运行时数据区域的一块逻辑区域,是各线程共享的内存区域\n“逻辑”,《Java虚拟机规范》规定了有方法区这么个概念和它的作用,方法区如何实现是虚拟机的事。即,不同虚拟机实现上,方法区的实现是不同的\n当虚拟机要使用一个类时,它需要读取并解析Class文件获取相关信息,再将信息存入方法区。方法区会存储已被虚拟机加载的类信息、字段信息、方法信息、常量、静态变量、即时编译器编译后的代码缓存等数据\n方法区和永久代以及元空间有什么关系呢?\n方法区和永久代以及元空间的关系很像Java中接口和类的关系,类实现了接口,这里的类就可以看作是永久代和元空间,接口则看作是方法区 永久代及元空间,是HotSpot虚拟机对虚拟机规范中方法区的两种实现方式 永久代是JDK1.8之前的方法区实现,元空间是JDK1.8及之后方法区的实现 为什么将永久代(PermGen)替换成元空间(MetaSpace)呢\n下图来自《深入理解Java虚拟机》第3版\n整个永久代有一个JVM本身设置的固定大小上限(也就是参数指定),无法进行调整,而元空间使用的是直接内存,受本机可用内存的限制。虽然元空间仍旧可能溢出,但比原来出现的机率会更小\n元空间溢出将得到错误: java.lang.OutOfMemoryError: MetaSpace\n-XX: MaxMetaspaceSize设置最大元空间大小,默认为unlimited,即只受系统内存限制 -XX: MetaspaceSize调整标志定义元空间的初始大小,如果未指定此标志,则Metaspace将根据运行时应用程序需求,动态地重新调整大小。 元空间里存放的是类的元数据,这样加载多少类的元数据就不由MaxPermSize控制了,而由系统的实际可用空间控制,这样加载的类就更多了\n在 JDK8,合并 HotSpot 和 JRockit 的代码时, JRockit 从来没有一个叫永久代的东西, 合并之后就没有必要额外的设置这么一个永久代的地方了\n方法区常用参数有哪些 JDK1.8之前永久代还没有被彻底移除时通过下面参数调节方法区大小\n-XX:PermSize=N//方法区 (永久代) 初始大小 -XX:MaxPermSize=N//方法区 (永久代) 最大大小,超过这个值将会抛出 OutOfMemoryError 异常:java.lang.OutOfMemoryError: PermGen 相对而言,垃圾收集行为在这个区域是比较少出现的,但**并非数据进入方法区后就“永久存在”**了。\nJDK1.7方法区(HotSpot的永久代)被移除一部分,JDK1.8时方法区被彻底移除,取而代之的是元空间,元空间使用直接内存,下面是常用参数\n-XX:MetaspaceSize=N //设置 Metaspace 的初始(和最小大小) -XX:MaxMetaspaceSize=N //设置 Metaspace 的最大大小 与永久代不同,如果不指定大小,随着更多类的创建,虚拟机会耗尽所有可用的系统内存。\n运行时常量池 # Class文件中除了有类的版本、字段、方法、接口等描述信息外,还有用于存放编译器期生成的各种字面量(Literal)和符号引用(Symbolic Reference)的常量池表(注意,这里的常量池表,说的是刚刚编译后的那个class文件字节码代表的含义)\n字面量是源代码中的固定值表示法,即通过字面我们就知道其值的含义。字面量包括整数、浮点数和字符串字面量;符号引用包括类符号引用、字段符号引用、方法符号引用和接口方法符号引用。\n常量池表会在类加载后存放到方法区的运行时常量池中\n运行时常量池的功能类似于传统编程语言的符号表(但是包含了比典型符号表更广泛的数据)\n运行时常量池是方法区的一部分,所以受到方法区内存的限制,当常量池无法再申请到内存时会抛出OutOfMemoryError的错误\n字符串常量池 # 字符串常量池是JVM为了提升性能和减少内存消耗针对字符串(String类)专门开辟的一块区域,主要目的是为了避免字符串得重复创建\n// 在堆中创建字符串对象”ab“ // 将字符串对象”ab“的引用保存在字符串常量池中 String aa = \u0026#34;ab\u0026#34;; // 直接返回字符串常量池中字符串对象”ab“的引用 String bb = \u0026#34;ab\u0026#34;; System.out.println(aa==bb);// true HotSpot 虚拟机中字符串常量池的实现是 src/hotspot/share/classfile/stringTable.cpp ,StringTable 本质上就是一个HashSet\u0026lt;String\u0026gt; ,容量为 StringTableSize(可以通过 -XX:StringTableSize 参数来设置)。\nStringTable 中保存的是字符串对象的引用,字符串对象的引用指向堆中的字符串对象。\nJDK1.7之前, (字符串常量池、静态变量)存放在永久代。JDK1.7字符串常量池和静态变量从永久代移动到了Java堆中 JDK1.7为什么要将字符串常量池移动到堆中\n因为永久代(方法区实现)的GC回收效率太低,只有在整堆收集(Full GC)的时候才会被执行GC。Java程序中通常有大量的被创建的字符串等待回收,将字符串常量池放到堆中,能够高效及时地回收字符串内存。\nJVM常量池中存储的是对象还是引用\n如果您说的确实是runtime constant pool(而不是interned string pool / StringTable之类的其他东西)的话,其中的引用类型常量(例如CONSTANT_String、CONSTANT_Class、CONSTANT_MethodHandle、CONSTANT_MethodType之类)都存的是引用,实际的对象还是存在Java heap上的。【运行时常量池】\n运行时常量池、方法区、字符串常量池这些都是不随虚拟机实现而改变的逻辑概念,是公共且抽象的,Metaspace、Heap 是与具体某种虚拟机实现相关的物理概念,是私有且具体的。\n总结 # 直接内存 # 直接内存并不是虚拟机运行时数据区的一部分,也不是虚拟机规范中定义的内存区域,但是这部分内存也被频繁使用,也可能导致OutOfMemoryError错误出现 JDK1.4中新加入的NIO(New Input/Output)类,引入一种基于通道(Channel)与缓冲区(Buffer)的I/O方式,它可以直接使用Native函数库直接分配堆外内存,然后通过一个存储在Java堆中的DirectByteBuffer对象作为这块内存的引用进行操作。这样就能在一些场景中显著提高性能,因为避免了在Java堆和Native堆之间来回复制数据 本机直接内存的分配不会受到Java堆的限制,但是,既然是内存就会受到本机总内存大小以及处理器寻址空间的限制 HotSpot虚拟机对象探秘 # 了解一下HotSport虚拟机在Java堆中对象分配、布局和访问的全过程\n对象的创建 # 默认:\n类加载检查 虚拟机遇到一条new指令时,首先将去检查这个指令的参数(也就是后面说的符号引用),看是否能在常量池中定位到这个类的符号引用,并且检查这个符号引用代表的类是否已被加载过、解析和初始化过。如果没有,那必须先执行相应的类加载过程\n分配内存 类加载检查通过后,接下来虚拟机将为新生对象分配内存。对象所需内存大小在类加载后便可确定,为对象分配空间的任务,等同于把一块确定大小的内存从Java堆中划分出来**。 分配方式有**”指针碰撞“和”空闲列表“两种,选择哪种分配方式由Java堆是否规整决定**,而Java堆是否规整又由所采用的垃圾收集器是否带有压缩整理功能决定 内存分配的两种方式:\n指针碰撞\n适用场合:**堆内存规整(即没有内存碎片)**的情况下 原理:用过的内存全部整合到一边,没有用过的内存放在另一边,中间有一个分界指针只需要向着没用过的内存方向,将该指针移动对象内存的大小的位置即可 使用该分配方式的GC收集器:Serial,PartNew 空闲列表\n适合场合:堆内存不规整的情况下 原理:虚拟机会维护一个列表,该列表中会记录哪些内存块是可用的,在分配的时候,找一块足够大的内存块来划分给对象实例,最后更新列表记录 使用该分配方式的GC收集器:CMS 选择以上两种方式中的哪一种,取决于 Java 堆内存是否规整。而 Java 堆内存是否规整,取决于 GC 收集器的算法是**\u0026ldquo;标记-清除\u0026rdquo;,还是\u0026ldquo;标记-整理\u0026rdquo;(也称作\u0026ldquo;标记-压缩\u0026rdquo;**),值得注意的是,复制算法内存也是规整的\n内存分配并发问题:\n创建对象时的重要问题\u0026mdash;线程安全,因为在实际开发过程中,创建对象是很频繁的事,作为虚拟机来说,必须要保证线程安全的,虚拟机采用两种方式保证线程安全:\nCAS+失败重试:\nCAS 是乐观锁的一种实现方式。所谓乐观锁就是,每次不加锁而是假设没有冲突而去完成某项操作,如果因为冲突失败就重试,直到成功为止\n虚拟机采用CAS+失败重试的方式保证更新操作的原子性\nTLAB:为每一个线程 预先 在Eden区分配一块内存,JVM在给线程中的对象分配内存时,首先在TLAB(该内存区域)分配,当对象大于TLAB中的剩余内存或TLAB的内存已用尽时,再采用上述的CAS进行内存分配\n在预留这个操作发生的时候,需要进行加锁或者采用CAS等操作进行保护,避免多个线程预留同一个区域\n初始化零值 内存分配完成后,虚拟机需要将分配到的内存空间都初始化为零值(不包括对象头),这一步操作保证了对象的实例字段在 Java 代码中可以不赋初始值就直接使用,程序能访问到这些字段的数据类型所对应的零值。\n设置对象头\n初始化零值完成之后,虚拟机要对对象进行必要的设置,例如这个对象是哪个类的实例、如何才能找到类的元数据信息、对象的哈希码、对象的 GC 分代年龄等信息。 这些信息存放在对象头中。 另外,根据虚拟机当前运行状态的不同,如是否启用偏向锁等,对象头会有不同的设置方式。\n执行init方法\n在上面工作都完成之后,从虚拟机的视角来看,一个新的对象已经产生了,但从 Java 程序的视角来看,对象创建才刚开始,\u0026lt;init\u0026gt; 方法还没有执行,所有的字段都还为零。所以一般来说,执行 new 指令之后会接着执行 \u0026lt;init\u0026gt; 方法,把对象按照程序员的意愿进行初始化,这样一个真正可用的对象才算完全产生出来。\n附: Java 在编译之后会在字节码文件中生成 init 方法,称之为实例构造器,该实例构造器会将语句块,变量初始化,调用父类的构造器等操作收敛到 init 方法中,收敛顺序为: 变量---\u0026gt; 语句块 ---\u0026gt; 构造函数\n父类变量初始化 父类语句块 父类构造函数 子类变量初始化 子类语句块 子类构造函数 收敛到 init 方法的意思是:将这些操作放入到 init 中去执行。 转自: https://juejin.cn/post/6844903957836333063\n对象的内存布局 # Hotspot虚拟机中,对象在内存中的布局分为3块区域:对象头、实例数据和对齐填充 对象头又包括两部分信息,第一部分用于存储对象自身的运行时数据(哈希码、GC分代年龄、锁状态标志等等),即markword;第二部分是类型指针,即对象指向它的类元数据的指针,虚拟机通过这个指针来确定这个对象是哪个类的实例。 实例数据部分是对象真正存储的有效信息,也是在程序中所定义的各种类型的字段内容 对齐填充部分不是必然存在的,没特别含义,只起占位作用。因为Hotspot虚拟机的自动内存管理系统要求对象起始地址必须是8字节的整数倍,即对象的大小必须是8字节的整数倍。而对象头部分正好是8字节的倍数(1倍或2倍),因此当对象实例数据部分没有对齐时,就需要通过对齐填充来补全 对象的访问定位 # 建立对象就是为了使用对象,我们的 Java 程序通过栈上的 reference 数据来操作堆上的具体对象。对象的访问方式由虚拟机实现而定,目前主流的访问方式有:使用句柄、直接指针。\n句柄 如果使用句柄的话,那么 Java 堆中将会划分出一块内存来作为句柄池,reference 中存储的就是对象的句柄地址,而句柄中包含了对象实例数据与对象类型数据各自的具体地址信息。\n【也就是多了一层】\n直接指针 如果使用直接指针访问,reference 中存储的直接就是对象的地址。 这两种对象访问方式各有优势。使用句柄来访问的最大好处是 reference 中存储的是稳定的句柄地址,在对象被移动时只会改变句柄中的实例数据指针,而 reference 本身不需要修改。使用直接指针访问方式最大的好处就是速度快,它节省了一次指针定位的时间开销。\nHotSpot 虚拟机主要使用的就是这种方式**(直接指针**)来进行对象访问。\n"},{"id":318,"href":"/zh/docs/technology/Review/java_guide/java/Concurrent/ly0311lycompletablefuture-intro/","title":"completablefuture-intro","section":"并发","content":" 转载自https://github.com/Snailclimb/JavaGuide (添加小部分笔记)感谢作者!\nJava8被引入的一个非常有用的用于异步编程的类【没看】\n简单介绍 # CompletableFuture同时实现了Future和CompletionStage接口\npublic class CompletableFuture\u0026lt;T\u0026gt; implements Future\u0026lt;T\u0026gt;, CompletionStage\u0026lt;T\u0026gt; { } CompletableFuture 除了提供了更为好用和强大的 Future 特性之外,还提供了函数式编程的能力。\nFuture接口有5个方法:\nboolean cancel(boolean mayInterruptIfRunning) :尝试取消执行任务。 boolean isCancelled() :判断任务是否被取消。 boolean isDone() : 判断任务是否已经被执行完成。 get() :等待任务执行完成并获取运算结果。 get(long timeout, TimeUnit unit) :多了一个超时时间。 CompletionStage\u0026lt;T\u0026gt; 接口中的方法比较多,CompoletableFuture的函数式能力就是这个接口赋予的,大量使用Java8引入的函数式编程\n常见操作 # 创建CompletableFuture # 两种方法:new关键字或 CompletableFuture自带的静态工厂方法 runAysnc()或supplyAsync()\n通过new关键字 这个方式,可以看作是将CompletableFuture当作Future来使用,如下:\n我们通过创建了一个结果值类型为 RpcResponse\u0026lt;Object\u0026gt; 的 CompletableFuture,你可以把 resultFuture 看作是异步运算结果的载体\nCompletableFuture\u0026lt;RpcResponse\u0026lt;Object\u0026gt;\u0026gt; resultFuture = new CompletableFuture\u0026lt;\u0026gt;(); 如果后面某个时刻,得到了最终结果,可以调用complete()方法传入结果,表示resultFuture已经被完成:\n// complete() 方法只能调用一次,后续调用将被忽略。 resultFuture.complete(rpcResponse); 通过isDone()检查是否完成:\npublic boolean isDone() { return result != null; } 获取异步结果,使用get() ,调用get()方法的线程会阻塞 直到CompletableFuture完成运算: rpcResponse = completableFuture.get();\npublic class CompletableFutureTest { public static void main(String[] args) throws ExecutionException, InterruptedException { /*CompletableFuture\u0026lt;Object\u0026gt; resultFuture=new CompletableFuture\u0026lt;\u0026gt;(); resultFuture.complete(\u0026#34;hello world\u0026#34;); System.out.println(resultFuture.get());*/ CompletableFuture\u0026lt;String\u0026gt; stringCompletableFuture = CompletableFuture.supplyAsync(() -\u0026gt; { try { TimeUnit.SECONDS.sleep(3); } catch (InterruptedException e) { e.printStackTrace(); } return \u0026#34;hello,world!\u0026#34;; }); System.out.println(\u0026#34;被阻塞啦----\u0026#34;); String s = stringCompletableFuture.get(); System.out.println(\u0026#34;结果---\u0026#34;+s); } } 如果已经知道结果:\nCompletableFuture\u0026lt;String\u0026gt; future = CompletableFuture.completedFuture(\u0026#34;hello!\u0026#34;); assertEquals(\u0026#34;hello!\u0026#34;, future.get()); //completedFuture() 方法底层调用的是带参数的 new 方法,只不过,这个方法不对外暴露。 public static CompletableFuture completedFuture(U value) { return new CompletableFuture((value == null) ? NIL : value); } 基于CompletableFuture自带的静态工厂方法:runAsync()、supplyAsync() Supplier 供应商; 供货商; 供应者; 供货方; 这两个方法可以帮助我们封装计算逻辑\nstatic CompletableFuture supplyAsync(Supplier supplier); // 使用自定义线程池(推荐) static CompletableFuture supplyAsync(Supplier supplier, Executor executor); static CompletableFuture\u0026lt;Void\u0026gt; runAsync(Runnable runnable); // 使用自定义线程池(推荐) static CompletableFuture\u0026lt;Void\u0026gt; runAsync(Runnable runnable, Executor executor); //简单使用 public class CompletableFutureTest { public static void main(String[] args) throws ExecutionException, InterruptedException { CompletableFuture\u0026lt;String\u0026gt; completableFuture = CompletableFuture.supplyAsync(() -\u0026gt; { //3s后返回结果 try { TimeUnit.SECONDS.sleep(3); } catch (InterruptedException e) { e.printStackTrace(); } return \u0026#34;abc\u0026#34;; }); //这里会被阻塞 String s = completableFuture.get(); System.out.println(s); } } //例子2 import java.util.concurrent.CompletableFuture; import java.util.concurrent.CountDownLatch; import java.util.concurrent.ExecutionException; import java.util.concurrent.TimeUnit; public class CompletableFutureTest { public static void main(String[] args) throws ExecutionException, InterruptedException { CountDownLatch countDownLatch=new CountDownLatch(2); //相当于使用了一个线程池,开启线程,提交了任务 CompletableFuture\u0026lt;Void\u0026gt; a = CompletableFuture.runAsync(() -\u0026gt; { System.out.println(\u0026#34;a\u0026#34;); //执行了3s的任务 try { TimeUnit.SECONDS.sleep(3); } catch (InterruptedException e) { e.printStackTrace(); } countDownLatch.countDown(); }); CompletableFuture\u0026lt;Void\u0026gt; b = CompletableFuture.runAsync(() -\u0026gt; { System.out.println(\u0026#34;b\u0026#34;); //执行了3s的任务 try { TimeUnit.SECONDS.sleep(2); } catch (InterruptedException e) { e.printStackTrace(); } countDownLatch.countDown(); }); countDownLatch.await(); System.out.println(\u0026#34;执行完毕\u0026#34;);//3s后会执行 } } 备注,自定义线程池使用:\nThreadPoolExecutor executor = new ThreadPoolExecutor( CORE_POOL_SIZE, //5 MAX_POOL_SIZE, //10 KEEP_ALIVE_TIME, //1L TimeUnit.SECONDS, //单位 new ArrayBlockingQueue\u0026lt;\u0026gt;(QUEUE_CAPACITY),//100 new ThreadPoolExecutor.CallerRunsPolicy()); //主线程中运行 runAsync() 方法接受的参数是 Runnable ,这是一个函数式接口,不允许返回值。当你需要异步操作且不关心返回结果的时候可以使用 runAsync() 方法。\n@FunctionalInterface public interface Runnable { public abstract void run(); } supplyAsync() 方法接受的参数是 Supplier ,这也是一个函数式接口,U 是返回结果值的类型。\n@FunctionalInterface public interface Supplier\u0026lt;T\u0026gt; { /** * Gets a result. * * @return a result */ T get(); } 当需要异步操作且关心返回的结果时,可以使用supplyAsync()方法\n```java CompletableFuture\u0026lt;Void\u0026gt; future = CompletableFuture.runAsync(() -\u0026gt; System.out.println(\u0026quot;hello!\u0026quot;)); future.get();// 输出 \u0026quot;hello!\u0026quot; **注意,不是get()返回的** CompletableFuture\u0026lt;String\u0026gt; future2 = CompletableFuture.supplyAsync(() -\u0026gt; \u0026quot;hello!\u0026quot;); assertEquals(\u0026quot;hello!\u0026quot;, future2.get()); ``` 处理异步结算的结果 # 可以对异步计算的结果,进行进一步的处理,常用的方法有:\nthenApply() 接收结果 产生结果 ``thenAccept()` 接受结果不产生结果\nthenRun 不接受结果不产生结果 whenComplete() 结束时处理结果\n例子:\npublic class CompletableFutureTest { public static void main(String[] args) throws ExecutionException, InterruptedException { CompletableFuture\u0026lt;String\u0026gt; stringCompletableFuture = CompletableFuture.supplyAsync(() -\u0026gt; { try { TimeUnit.SECONDS.sleep(3); } catch (InterruptedException e) { e.printStackTrace(); } return \u0026#34;hello,world!\u0026#34;; }); System.out.println(\u0026#34;被阻塞啦----\u0026#34;); stringCompletableFuture .whenComplete((s,e)-\u0026gt;{ System.out.println(\u0026#34;complete1----\u0026#34;+s); }) .whenComplete((s,e)-\u0026gt;{ System.out.println(\u0026#34;complete2----\u0026#34;+s); }) .thenAccept(s-\u0026gt;{ System.out.println(\u0026#34;打印结果\u0026#34;+s); }) .thenRun(()-\u0026gt;{ System.out.println(\u0026#34;阻塞结束啦\u0026#34;); }); while (true){ } } } /*------------- 2022-12-07 10:16:44 上午 [Thread: main] INFO:被阻塞啦---- 2022-12-07 10:16:47 上午 [Thread: ForkJoinPool.commonPool-worker-1] INFO:complete1----hello,world! 2022-12-07 10:16:47 上午 [Thread: ForkJoinPool.commonPool-worker-1] INFO:complete2----hello,world! 2022-12-07 10:16:47 上午 [Thread: ForkJoinPool.commonPool-worker-1] INFO:打印结果hello,world! 2022-12-07 10:16:47 上午 [Thread: ForkJoinPool.commonPool-worker-1] INFO:阻塞结束啦 */ thenApply()方法接受Function实例,用它来处理结果 // 沿用上一个任务的线程池 public CompletableFuture thenApply( Function\u0026lt;? super T,? extends U\u0026gt; fn) { return uniApplyStage(null, fn); } //使用默认的 ForkJoinPool 线程池(不推荐) public CompletableFuture thenApplyAsync( Function\u0026lt;? super T,? extends U\u0026gt; fn) { return uniApplyStage(defaultExecutor(), fn); } // 使用自定义线程池(推荐) public CompletableFuture thenApplyAsync( Function\u0026lt;? super T,? extends U\u0026gt; fn, Executor executor) { return uniApplyStage(screenExecutor(executor), fn); } 使用示例:\nCompletableFuture\u0026lt;String\u0026gt; future = CompletableFuture.completedFuture(\u0026#34;hello!\u0026#34;) .thenApply(s -\u0026gt; s + \u0026#34;world!\u0026#34;); assertEquals(\u0026#34;hello!world!\u0026#34;, future.get()); // 这次调用将被忽略。 //**我猜是因为只能get()一次** future.thenApply(s -\u0026gt; s + \u0026#34;nice!\u0026#34;); assertEquals(\u0026#34;hello!world!\u0026#34;, future.get()); 流式调用:\nCompletableFuture\u0026lt;String\u0026gt; future = CompletableFuture.completedFuture(\u0026#34;hello!\u0026#34;) .thenApply(s -\u0026gt; s + \u0026#34;world!\u0026#34;).thenApply(s -\u0026gt; s + \u0026#34;nice!\u0026#34;); assertEquals(\u0026#34;hello!world!nice!\u0026#34;, future.get()); 如果不需要从回调函数中返回结果,可以使用thenAccept()或者thenRun() ,两个方法区别在于thenRun()不能访问异步计算的结果(因为thenAccept方法的参数为 Consumer\u0026lt;? super T\u0026gt; ) public CompletableFuture\u0026lt;Void\u0026gt; thenAccept(Consumer\u0026lt;? super T\u0026gt; action) { return uniAcceptStage(null, action); } public CompletableFuture\u0026lt;Void\u0026gt; thenAcceptAsync(Consumer\u0026lt;? super T\u0026gt; action) { return uniAcceptStage(defaultExecutor(), action); } public CompletableFuture\u0026lt;Void\u0026gt; thenAcceptAsync(Consumer\u0026lt;? super T\u0026gt; action, Executor executor) { return uniAcceptStage(screenExecutor(executor), action); } 顾名思义,Consumer 属于消费型接口,它可以接收 1 个输入对象然后进行“消费”。\n@FunctionalInterface public interface Consumer\u0026lt;T\u0026gt; { void accept(T t); default Consumer\u0026lt;T\u0026gt; andThen(Consumer\u0026lt;? super T\u0026gt; after) { Objects.requireNonNull(after); return (T t) -\u0026gt; { accept(t); after.accept(t); }; } } thenRun() 的方法是的参数是 Runnable\npublic CompletableFuture\u0026lt;Void\u0026gt; thenRun(Runnable action) { return uniRunStage(null, action); } public CompletableFuture\u0026lt;Void\u0026gt; thenRunAsync(Runnable action) { return uniRunStage(defaultExecutor(), action); } public CompletableFuture\u0026lt;Void\u0026gt; thenRunAsync(Runnable action, Executor executor) { return uniRunStage(screenExecutor(executor), action); } 使用如下:\nCompletableFuture.completedFuture(\u0026#34;hello!\u0026#34;) .thenApply(s -\u0026gt; s + \u0026#34;world!\u0026#34;).thenApply(s -\u0026gt; s + \u0026#34;nice!\u0026#34;).thenAccept(System.out::println);//hello!world!nice! //可以接收参数 CompletableFuture.completedFuture(\u0026#34;hello!\u0026#34;) .thenApply(s -\u0026gt; s + \u0026#34;world!\u0026#34;).thenApply(s -\u0026gt; s + \u0026#34;nice!\u0026#34;).thenRun(() -\u0026gt; System.out.println(\u0026#34;hello!\u0026#34;));//hello! whenComplete()的方法参数是BiConsumer\u0026lt;? super T , ? super Throwable \u0026gt;\npublic CompletableFuture\u0026lt;T\u0026gt; whenComplete( BiConsumer\u0026lt;? super T, ? super Throwable\u0026gt; action) { return uniWhenCompleteStage(null, action); } public CompletableFuture\u0026lt;T\u0026gt; whenCompleteAsync( BiConsumer\u0026lt;? super T, ? super Throwable\u0026gt; action) { return uniWhenCompleteStage(defaultExecutor(), action); } // 使用自定义线程池(推荐) public CompletableFuture\u0026lt;T\u0026gt; whenCompleteAsync( BiConsumer\u0026lt;? super T, ? super Throwable\u0026gt; action, Executor executor) { return uniWhenCompleteStage(screenExecutor(executor), action); } 相比Consumer,BiConsumer可以接收2个输入对象然后进行\u0026quot;消费\u0026quot;\n@FunctionalInterface public interface BiConsumer\u0026lt;T, U\u0026gt; { void accept(T t, U u); default BiConsumer\u0026lt;T, U\u0026gt; andThen(BiConsumer\u0026lt;? super T, ? super U\u0026gt; after) { Objects.requireNonNull(after); return (l, r) -\u0026gt; { accept(l, r); after.accept(l, r); }; } } 使用:\nCompletableFuture\u0026lt;String\u0026gt; future = CompletableFuture.supplyAsync(() -\u0026gt; \u0026#34;hello!\u0026#34;) .whenComplete((res, ex) -\u0026gt; { // res 代表返回的结果 // ex 的类型为 Throwable ,代表抛出的异常 System.out.println(res); // 这里没有抛出异常所有为 null assertNull(ex); }); assertEquals(\u0026#34;hello!\u0026#34;, future.get()); 其他区别暂时不知道\n异常处理 # 使用handle() 方法来处理任务执行过程中可能出现的抛出异常的情况\npublic CompletableFuture handle( BiFunction\u0026lt;? super T, Throwable, ? extends U\u0026gt; fn) { return uniHandleStage(null, fn); } public CompletableFuture handleAsync( BiFunction\u0026lt;? super T, Throwable, ? extends U\u0026gt; fn) { return uniHandleStage(defaultExecutor(), fn); } public CompletableFuture handleAsync( BiFunction\u0026lt;? super T, Throwable, ? extends U\u0026gt; fn, Executor executor) { return uniHandleStage(screenExecutor(executor), fn); } 代码:\npublic static void test() throws ExecutionException, InterruptedException { CompletableFuture\u0026lt;String\u0026gt; future = CompletableFuture.supplyAsync(() -\u0026gt; { if (true) { throw new RuntimeException(\u0026#34;Computation error!\u0026#34;); } return \u0026#34;hello!\u0026#34;; }).handle((res, ex) -\u0026gt; { // res 代表返回的结果 // ex 的类型为 Throwable ,代表抛出的异常 return res != null ? res : ex.toString()+\u0026#34;world!\u0026#34;; }); String s = future.get(); log.info(s); } /** 2022-12-07 11:14:44 上午 [Thread: main] INFO:java.util.concurrent.CompletionException: java.lang.RuntimeException: Computation error!world! */ 通过exceptionally处理异常\nCompletableFuture\u0026lt;String\u0026gt; future = CompletableFuture.supplyAsync(() -\u0026gt; { if (true) { throw new RuntimeException(\u0026#34;Computation error!\u0026#34;); } return \u0026#34;hello!\u0026#34;; }).exceptionally(ex -\u0026gt; { System.out.println(ex.toString());// CompletionException return \u0026#34;world!\u0026#34;; }); assertEquals(\u0026#34;world!\u0026#34;, future.get()); 让异步的结果直接就抛异常\nCompletableFuture\u0026lt;String\u0026gt; completableFuture = new CompletableFuture\u0026lt;\u0026gt;(); // ... completableFuture.completeExceptionally( new RuntimeException(\u0026#34;Calculation failed!\u0026#34;)); // ... completableFuture.get(); // ExecutionException 组合CompletableFuture # 使用thenCompose() 按顺序连接两个CompletableFuture对象\npublic CompletableFuture thenCompose( Function\u0026lt;? super T, ? extends CompletionStage\u0026gt; fn) { return uniComposeStage(null, fn); } public CompletableFuture thenComposeAsync( Function\u0026lt;? super T, ? extends CompletionStage\u0026gt; fn) { return uniComposeStage(defaultExecutor(), fn); } public CompletableFuture thenComposeAsync( Function\u0026lt;? super T, ? extends CompletionStage\u0026gt; fn, Executor executor) { return uniComposeStage(screenExecutor(executor), fn); } 使用示例:\nCompletableFuture\u0026lt;String\u0026gt; future = CompletableFuture.supplyAsync(() -\u0026gt; \u0026#34;hello!\u0026#34;) .thenCompose(s -\u0026gt; CompletableFuture.supplyAsync(() -\u0026gt; s + \u0026#34;world!\u0026#34;)); assertEquals(\u0026#34;hello!world!\u0026#34;, future.get()); 在实际开发中,这个方法还是非常有用的。比如说,我们先要获取用户信息然后再用用户信息去做其他事情。\n和thenCompose()方法类似的还有thenCombine()方法,thenCombine()同样可以组合两个CompletableFuture对象\nCompletableFuture\u0026lt;String\u0026gt; completableFuture = CompletableFuture.supplyAsync(() -\u0026gt; \u0026#34;hello!\u0026#34;) .thenCombine(CompletableFuture.supplyAsync( () -\u0026gt; \u0026#34;world!\u0026#34;), (s1, s2) -\u0026gt; s1 + s2) .thenCompose(s -\u0026gt; CompletableFuture.supplyAsync(() -\u0026gt; s + \u0026#34;nice!\u0026#34;)); assertEquals(\u0026#34;hello!world!nice!\u0026#34;, completableFuture.get()); ★★ thenCompose() 和 thenCombine()有什么区别呢\nthenCompose() 可以两个 CompletableFuture 对象,并将前一个任务的返回结果作为下一个任务的参数,它们之间存在着先后顺序。 thenCombine() 会在两个任务都执行完成后,把两个任务的结果合并。两个任务是并行执行的,它们之间并没有先后依赖顺序。 /* 结果是有顺序的,但是执行的过程是无序的 */ CompletableFuture\u0026lt;String\u0026gt; completableFuture = CompletableFuture.supplyAsync(() -\u0026gt; { System.out.println(\u0026#34;执行了第1个\u0026#34;); try { TimeUnit.SECONDS.sleep(3); } catch (InterruptedException e) { e.printStackTrace(); } System.out.println(\u0026#34;第1个执行结束啦\u0026#34;); return \u0026#34;hello!\u0026#34;; }) .thenCombine(CompletableFuture.supplyAsync( () -\u0026gt; { System.out.println(\u0026#34;执行了第2个\u0026#34;); System.out.println(\u0026#34;第2个执行结束啦\u0026#34;); return \u0026#34;world!\u0026#34;; }), (s1, s2) -\u0026gt; s1 + s2); System.out.println(completableFuture.get()); /* 执行了第1个 执行了第2个 第2个执行结束啦 第1个执行结束啦 hello!world! */ 并行运行多个CompletableFuture # 通过CompletableFuture的allOf()这个静态方法并行运行多个CompletableFuture\n实际项目中,我们经常需要并行运行多个互不相关的任务,这些任务没有依赖关系\n比如读取处理6个文件,没有顺序依赖关系 但我们需要返回给用户的时候将这几个文件的处理结果统计整理,示例:\nCompletableFuture\u0026lt;Void\u0026gt; task1 = CompletableFuture.supplyAsync(()-\u0026gt;{ //自定义业务操作 }); ...... CompletableFuture\u0026lt;Void\u0026gt; task6 = CompletableFuture.supplyAsync(()-\u0026gt;{ //自定义业务操作 }); ...... CompletableFuture\u0026lt;Void\u0026gt; headerFuture=CompletableFuture.allOf(task1,.....,task6); try { headerFuture.join(); } catch (Exception ex) { ...... } System.out.println(\u0026#34;all done. \u0026#34;); 调用join()可以让程序等future1和future2都运行完后继续执行\nCompletableFuture\u0026lt;Void\u0026gt; completableFuture = CompletableFuture.allOf(future1, future2); completableFuture.join(); assertTrue(completableFuture.isDone()); System.out.println(\u0026#34;all futures done...\u0026#34;); /**--- future1 done... future2 done... all futures done... */ anyOf则其中一个执行完就立马返回\nCompletableFuture\u0026lt;Object\u0026gt; f = CompletableFuture.anyOf(future1, future2); System.out.println(f.get()); /* future2 done... efg */ //或 /* future1 done... abc */ 例子2\nCompletableFuture\u0026lt;Object\u0026gt; a = CompletableFuture.supplyAsync(() -\u0026gt; { System.out.println(\u0026#34;a\u0026#34;); //执行了3s的任务 try { TimeUnit.SECONDS.sleep(3); } catch (InterruptedException e) { e.printStackTrace(); } return \u0026#34;a-hello\u0026#34;; }); CompletableFuture\u0026lt;Object\u0026gt; b = CompletableFuture.supplyAsync(() -\u0026gt; { System.out.println(\u0026#34;b\u0026#34;); //执行了3s的任务 try { TimeUnit.SECONDS.sleep(10); } catch (InterruptedException e) { e.printStackTrace(); } return \u0026#34;b-hello\u0026#34;; }); /* //会等两个任务都执行完才继续 CompletableFuture\u0026lt;Void\u0026gt; voidCompletableFuture = CompletableFuture.allOf(a, b); voidCompletableFuture.join(); //停顿10s System.out.println(\u0026#34;主线程继续执行\u0026#34;);*/ //任何一个任务执行完就会继续执行 CompletableFuture\u0026lt;Object\u0026gt; objectCompletableFuture = CompletableFuture.anyOf(a, b); objectCompletableFuture.join(); //会得到最快返回值的那个CompletableFuture的值 System.out.println(objectCompletableFuture.get()); //停顿3s System.out.println(\u0026#34;主线程继续执行\u0026#34;); 后记 # 京东的aysncTool框架\nhttps://gitee.com/jd-platform-opensource/asyncTool#%E5%B9%B6%E8%A1%8C%E5%9C%BA%E6%99%AF%E4%B9%8B%E6%A0%B8%E5%BF%83%E4%BB%BB%E6%84%8F%E7%BC%96%E6%8E%92\n"},{"id":319,"href":"/zh/docs/technology/Review/java_guide/java/Concurrent/ly0310lythreadlocal/","title":"ThreadLocal详解","section":"并发","content":" 转载自https://github.com/Snailclimb/JavaGuide (添加小部分笔记)感谢作者!\n本文来自一枝花算不算浪漫投稿, 原文地址: https://juejin.cn/post/6844904151567040519open in new window。 感谢作者!\n思维导图\n目录 # ThreadLocal代码演示 # 简单使用\npublic class ThreadLocalTest { private List\u0026lt;String\u0026gt; messages = Lists.newArrayList(); public static final ThreadLocal\u0026lt;ThreadLocalTest\u0026gt; holder = ThreadLocal.withInitial(ThreadLocalTest::new); public static void add(String message) { holder.get().messages.add(message); } public static List\u0026lt;String\u0026gt; clear() { List\u0026lt;String\u0026gt; messages = holder.get().messages; holder.remove(); System.out.println(\u0026#34;size: \u0026#34; + holder.get().messages.size()); return messages; } public static void main(String[] args) { ThreadLocalTest.add(\u0026#34;一枝花算不算浪漫\u0026#34;); System.out.println(holder.get().messages); ThreadLocalTest.clear(); } } /* 结果 [一枝花算不算浪漫] size: 0 */ 简单使用2\n@Data class LyTest{ private ThreadLocal\u0026lt;String\u0026gt; threadLocal=ThreadLocal.withInitial(()-\u0026gt;{ return \u0026#34;hello\u0026#34;; }); } public class ThreadLocalTest { public static void main(String[] args) throws InterruptedException { CountDownLatch countDownLatch=new CountDownLatch(2); LyTest lyTest=new LyTest(); ThreadLocal\u0026lt;String\u0026gt; threadLocal = lyTest.getThreadLocal(); new Thread(()-\u0026gt;{ String name = Thread.currentThread().getName(); threadLocal.set(name+ \u0026#34;-ly\u0026#34;); System.out.println(name+\u0026#34;:threadLocal当前值\u0026#34;+threadLocal.get()); countDownLatch.countDown(); },\u0026#34;线程1\u0026#34;).start(); new Thread(()-\u0026gt;{ String name = Thread.currentThread().getName(); threadLocal.set(name+ \u0026#34;-ly\u0026#34;); System.out.println(name+\u0026#34;:threadLocal当前值\u0026#34;+threadLocal.get()); countDownLatch.countDown(); },\u0026#34;线程2\u0026#34;).start(); /*while (true){}*/ countDownLatch.await(); System.out.println(Thread.currentThread().getName()+\u0026#34;:threadLocal当前值\u0026#34;+threadLocal.get()); } } /* 线程1:threadLocal当前值线程1-ly 线程2:threadLocal当前值线程2-ly main:threadLocal当前值hello */ ThreadLocal对象可以提供线程局部变量,每个线程Thread拥有一份自己的副本变量,多个线程互不干扰。\n回顾之前的知识点\npublic void set(T value) { //获取当前请求的线程 Thread t = Thread.currentThread(); //取出 Thread 类内部的 threadLocals 变量(哈希表结构) ThreadLocalMap map = getMap(t); if (map != null) // 将需要存储的值放入到这个哈希表中 //★★实际使用的方法 map.set(this, value); else //★★实际使用的方法 createMap(t, value); } ThreadLocalMap getMap(Thread t) { return t.threadLocals; } 如上,实际存取都是从Thread的threadLocals (ThreadLocalMap类)中,并不是存在ThreadLocal上,ThreadLocal用来传递了变量值,只是ThreadLocalMap的封装 ThreadLocal类中通过Thread.currentThread()获取到当前线程对象后,直接通过getMap(Thread t) 可以访问到该线程的ThreadLocalMap对象 每个Thread中具备一个ThreadLocalMap,而ThreadLocalMap可以存储以ThreadLocal为key,Object对象为value的键值对 ThreadLocal的数据结构 # 由上面回顾的知识点可知,value实际上都是保存在**线程类(Thread类)中的某个属性(ThreadLocalMap类)**中\nThreadLocalMap的底层是一个数组(map的底层是数组)\nThread类有一个类型为**ThreadLocal.ThreadLocalMap**的实例变量threadLocals,也就是说每个线程有一个自己的ThreadLocalMap。 ThreadLocalMap是一个静态内部类\n没有修饰符,为包可见。比如父类有一个protected修饰的方法f(),不同包下存在子类A和其他类X,在子类中可以访问方法f(),即使在其他类X创建子类A实例a1,也不能调用a1.f()\u0026ndash;\u0026gt; 其他包不可见\nThreadLocalMap有自己独立实现,简单地将它的key视作ThreadLocal,value为代码中放入的值,(看底层代码可知,实际key不是ThreadLocal本身,而是它的一个弱引用)\n★每个线程在往ThreadLocal里放值的时候,都会往自己的ThreadLocalMap里存,读也是以ThreadLocal作为引用,在自己的map里找对应的key,从而实现了线程隔离。\nThreadLocalMap有点类似HashMap的结构,只是HashMap是由数组+链表实现的,而ThreadLocalMap中并没有链表结构。其中,还要注意Entry类, 它的key是ThreadLocal\u0026lt;?\u0026gt; k ,(Entry类)继承自WeakReference, 也就是我们常说的弱引用类型。\n如下,有个数组存放Entry(弱引用类,且有属性value),且\nstatic class ThreadLocalMap { static class Entry extends WeakReference\u0026lt;ThreadLocal\u0026lt;?\u0026gt;\u0026gt; { /** The value associated with this ThreadLocal. */ Object value; Entry(ThreadLocal\u0026lt;?\u0026gt; k, Object v) { super(k); value = v; } } //..... } 为上面的知识点总结一张图 # GC之后key是否为null # WeakReference的使用\nWeakReference\u0026lt;Car\u0026gt; weakCar = new WeakReference(Car)(car); weakCar.get(); //如果值为null表示已经被回收了 问题: ThreadLocal的key为弱引用,那么在ThreadLocal.get()的时候,发生GC之后,key是否为null\nJava的四种引用类型 强引用:通常情况new出来的为强引用,只要强引用存在,垃圾回收器永远不会回收被引用的对象(即使内存不足) 软引用:使用SoftReference修饰的对象称软引用,软引用指向的对象在内存要溢出的时候被回收 弱引用:使用WeakReference修饰的对象称为弱引用,只要发生垃圾回收,如果这个对象只被弱引用指向,那么就会被回收 虚引用:虚引用是最弱的引用,用PhantomReference定义。唯一的作用就是用队列接收对象即将死亡的通知 使用反射方式查看GC后ThreadLocal中的数据情况\nimport java.lang.reflect.Field; /* t.join()方法阻塞调用此方法的线程(calling thread)进入 TIMED_WAITING 状态,直到线程t完成,此线程再继续 */ public class ThreadLocalDemo { public static void main(String[] args) throws NoSuchFieldException, IllegalAccessException, InterruptedException { Thread t = new Thread(()-\u0026gt;test(\u0026#34;abc\u0026#34;,false)); t.start(); t.join(); System.out.println(\u0026#34;--gc后--\u0026#34;); Thread t2 = new Thread(() -\u0026gt; test(\u0026#34;def\u0026#34;, true)); t2.start(); t2.join(); } private static void test(String s,boolean isGC) { try { //注意这一行,这个ThreadLocal对象是不存在任何强引用的 new ThreadLocal\u0026lt;\u0026gt;().set(s);//当前线程设置了一个值 s if (isGC) { System.gc(); } Thread t = Thread.currentThread(); Class\u0026lt;? extends Thread\u0026gt; clz = t.getClass(); Field field = clz.getDeclaredField(\u0026#34;threadLocals\u0026#34;); field.setAccessible(true); Object threadLocalMap = field.get(t);//得到当前线程的ThreadLocalMap Class\u0026lt;?\u0026gt; tlmClass = threadLocalMap.getClass(); Field tableField = tlmClass.getDeclaredField(\u0026#34;table\u0026#34;); tableField.setAccessible(true); //注意:这里获取的是threadLocalMap内部的(维护)数组 private Entry[] table; Object[] arr = (Object[]) tableField.get(threadLocalMap); for (Object o : arr) { if (o != null) { Class\u0026lt;?\u0026gt; entryClass = o.getClass(); /* Entry结构 static class Entry extends WeakReference\u0026lt;ThreadLocal\u0026lt;?\u0026gt;\u0026gt; { //The value associated with this ThreadLocal. Object value; Entry(ThreadLocal\u0026lt;?\u0026gt; k, Object v) { super(k); value = v; } } */ //获取Entry中的值(键值对的“值”) Field valueField = entryClass.getDeclaredField(\u0026#34;value\u0026#34;); //Entry extends WeakReference //WeakReference\u0026lt;T\u0026gt; extends Reference\u0026lt;T\u0026gt; //Reference 里面有一个属性 referent ,指向实际的对象,即key实际的对象 Field referenceField = entryClass.getSuperclass().getSuperclass().getDeclaredField(\u0026#34;referent\u0026#34;); valueField.setAccessible(true); referenceField.setAccessible(true); System.out.println(String.format(\u0026#34;弱引用key:%s,值:%s\u0026#34;, referenceField.get(o), valueField.get(o))); } } } catch (Exception e) { e.printStackTrace(); } } } /* 结果如下 弱引用key:java.lang.ThreadLocal@433619b6,值:abc 弱引用key:java.lang.ThreadLocal@418a15e3,值:java.lang.ref.SoftReference@bf97a12 --gc后-- 弱引用key:null,值:def */ gc之后的图:\nnew ThreadLocal\u0026lt;\u0026gt;().set(s); GC之后,key就会被回收,我们看到上面的debug中referent=null\n如果这里修改代码,\nThreadLocal\u0026lt;Object\u0026gt; threadLocal=new ThreadLocal\u0026lt;\u0026gt;(); threadLocal.set(s); 使用弱引用+垃圾回收\n如上,垃圾回收前,ThreadLoal是存在强引用的,因此如果如上修改代码,则key不为null\n当不存在强引用时,key会被回收,即出现value没被回收,key被回收,导致key永远存在,内存泄漏\nThreadLocal.set()方法源码详解 # 如图所示\nThreadLocal中的set()方法原理如上,先取出线程Thread中的threadLocals,判断是否存在,然后使用ThreadLocal中的set方法进行数据处理\npublic void set(T value) { Thread t = Thread.currentThread(); ThreadLocalMap map = getMap(t); if (map != null) map.set(this, value); else createMap(t, value); } void createMap(Thread t, T firstValue) { t.threadLocals = new ThreadLocalMap(this, firstValue); } ThreadLocalMap Hash算法 # ThreadLocalMap实现了自己的hash算法来解决散列表数组冲突问题:\n//i为当前key在散列表中对应的数组下标位置 //即(len-1)和和斐波那契数做 与运算 int i = key.threadLocalHashCode \u0026amp; (len-1); threadLocalHashCode值的计算,ThreadLocal中有一个属性为HASH_INCREMENT = 0x61c88647\n0x61c88647,又称为斐波那契数也叫黄金分割数,hash增量为这个数,好处是hash 分布非常均匀\npublic class ThreadLocal\u0026lt;T\u0026gt; { private final int threadLocalHashCode = nextHashCode(); private static AtomicInteger nextHashCode = new AtomicInteger(); private static final int HASH_INCREMENT = 0x61c88647; //hashCode增加 private static int nextHashCode() { return nextHashCode.getAndAdd(HASH_INCREMENT); } static class ThreadLocalMap { ThreadLocalMap(ThreadLocal\u0026lt;?\u0026gt; firstKey, Object firstValue) { table = new Entry[INITIAL_CAPACITY]; int i = firstKey.threadLocalHashCode \u0026amp; (INITIAL_CAPACITY - 1); table[i] = new Entry(firstKey, firstValue); size = 1; setThreshold(INITIAL_CAPACITY); } } } 例子如下,产生的哈希码分布十分均匀\n★★ 说明,下面的所有示例图中,绿色块Entry代表为正常数据,灰色块代表Entry的key为null,已被垃圾回收。白色块代表Entry为null(或者说数组那个位置为null(没有指向))\nThreadLocalMap Hash冲突 # ThreadLocalMap 中使用黄金分割数作为hash计算因子,大大减少Hash冲突的概率 HashMap中解决冲突的方法,是在数组上构造一个链表结构,冲突的数据挂载到链表上,如果链表长度超过一定数量则会转化为红黑树 ThreadLocalMap中没有链表结构(使用线性向后查找) 如图 假设需要插入value = 27 的数据,hash后应该落入槽位4,而槽位已经有了Entry数据 此时线性向后查找,一直找到Entry为null的操作才会停止查找,将当前元素放入该槽位中 线性向后查找迭代中,会遇到Entry不为null且key值相等,以及**Entry中的key为null(图中Entry 为 2)**的情况,处理方式不同 set过程中如果遇到了key过期(key为null)的Entry数据,实际上会进行一轮探测式清理操作 ThreadLocalMap.set() 详解 # ThreadLocalMap.set() 原理图解\n往ThreadLocalMap中set数据(新增或更新数据)分为好几种\n通过hash计算后的槽位对应的Entry数据为空 直接将数据放到该槽位即可\n槽位数据不为空,key值与当前ThreadLocal通过hash计算获取的key值一致 直接更新该槽位的数据\n槽位数据不为空,往后遍历过程中,在找到Entry为null的槽位之前,没有遇到过期的Entry 遍历散列数组的过程中,线性往后查找,如果找到Entry为null的槽位则将数据放入槽位中;或者往后遍历过程中遇到key值相等的数据则更新\n槽位数据不为空,在找到Entry为null的槽位之前,遇到了过期的Entry,如下图 此时会执行replaceStableEntry()方法,该方法含义是替换过期数据的逻辑\n\u0026hellip; 以下省略,太复杂\n替换完成后也是进行过期元素清理工作,清理工作主要是有两个方法:expungeStaleEntry()和cleanSomeSlots()\n经过迭代处理后,有过Hash冲突数据的Entry位置会更靠近正确位置,这样的话,查询的时候 效率才会更高。\nThreadLocalMap过期 key 的探测式清理流程(略过) # ThreadLocalMap扩容机制 # 在ThreadLocalMap.set()方法的最后,如果执行完启发式清理工作后,未清理到任何数据,且当前散列数组中Entry的数量已经达到了列表的扩容阈值(len*2/3),就开始执行rehash()逻辑:\nif (!cleanSomeSlots(i, sz) \u0026amp;\u0026amp; sz \u0026gt;= threshold) rehash(); rehash()的具体实现\nprivate void rehash() { expungeStaleEntries(); if (size \u0026gt;= threshold - threshold / 4) resize(); } private void expungeStaleEntries() { Entry[] tab = table; int len = tab.length; for (int j = 0; j \u0026lt; len; j++) { Entry e = tab[j]; if (e != null \u0026amp;\u0026amp; e.get() == null) expungeStaleEntry(j); } } 注意:\nthreshold [ˈθreʃhəʊld], 门槛 = length * 2/3\nrehash之前进行一次容量判断( 是否 \u0026gt; threshold , 是则rehash)\nrehash时先进行expungeStaleEntries() (探索式清理,从table起始为止)\n这里首先是会进行探测式清理工作,从table的起始位置往后清理,上面有分析清理的详细流程。清理完成之后,table中可能有一些key为null的Entry数据被清理掉,所以此时通过判断size \u0026gt;= threshold - threshold / 4 也就是size \u0026gt;= threshold * 3/4 来决定是否扩容。\n清理后如果大于 threshold 的3/4 ,则进行扩容 具体的resize()方法 以oldTab .len = 8\n容后的tab的大小为oldLen * 2 =16\n遍历老的散列表,重新计算hash位置,然后放到新的tab数组中,如果出现hash冲突则往后寻找最近的entry为null的槽位\n遍历完成之后,oldTab中所有的entry数据都已经放入到新的tab中了。重新计算tab下次扩容的阈值 代码如下\nprivate void resize() { Entry[] oldTab = table; int oldLen = oldTab.length; int newLen = oldLen * 2; Entry[] newTab = new Entry[newLen]; int count = 0; for (int j = 0; j \u0026lt; oldLen; ++j) { Entry e = oldTab[j]; if (e != null) { ThreadLocal\u0026lt;?\u0026gt; k = e.get(); if (k == null) { e.value = null; } else { int h = k.threadLocalHashCode \u0026amp; (newLen - 1); while (newTab[h] != null) h = nextIndex(h, newLen); newTab[h] = e; count++; } } } setThreshold(newLen); size = count; table = newTab; } ThreadLocalMap.get() 详解 # 通过查找key值计算出散列表中slot位置,然后该slot位置中的Entry.key和查找的key一致,则直接返回 slot位置中的Entry.key和要查找的key不一致,之后清理+遍历 我们以get(ThreadLocal1)为例,通过hash计算后,正确的slot位置应该是 4,而index=4的槽位已经有了数据,且key值不等于ThreadLocal1,所以需要继续往后迭代查找。\n迭代到index=5的数据时,此时Entry.key=null,触发一次探测式数据回收操作,执行expungeStaleEntry()方法,执行完后,index 5,8的数据都会被回收,而index 6,7的数据都会前移。index 6,7前移之后,继续从 index=5 往后迭代,于是就在 index=5 找到了key值相等的Entry数据,如下图所示: ThreadLocalMap.get()源码详解\nprivate Entry getEntry(ThreadLocal\u0026lt;?\u0026gt; key) { int i = key.threadLocalHashCode \u0026amp; (table.length - 1); Entry e = table[i]; if (e != null \u0026amp;\u0026amp; e.get() == key) return e; else return getEntryAfterMiss(key, i, e); } private Entry getEntryAfterMiss(ThreadLocal\u0026lt;?\u0026gt; key, int i, Entry e) { Entry[] tab = table; int len = tab.length; while (e != null) { ThreadLocal\u0026lt;?\u0026gt; k = e.get(); if (k == key) return e; if (k == null) expungeStaleEntry(i); else i = nextIndex(i, len); e = tab[i]; } return null; } ThreadLocalMap过期key的启发式清理流程(略过,跟移位运算符有关) # 上面多次提及到ThreadLocalMap过期key的两种清理方式:探测式清理(expungeStaleEntry())、启发式清理(cleanSomeSlots())\n探测式清理是以当前Entry 往后清理,遇到值为null则结束清理,属于线性探测清理。\n而启发式清理被作者定义为:Heuristically scan some cells looking for stale entries.\nInheritable ThreadLocal # 使用ThreadLocal的时候,在异步场景下是无法给子线程共享父线程中创建的线程副本数据的。JDK中存在InheritableThreadLocal类可以解决处理这个问题\n原理: 子线程是通过在父线程中通过new Thread()方法创建子线程,Thread#init 方法在Thread的构造方法中被调用,init方法中拷贝父线程数据到子线程中\nprivate void init(ThreadGroup g, Runnable target, String name, long stackSize, AccessControlContext acc, boolean inheritThreadLocals) { if (name == null) { throw new NullPointerException(\u0026#34;name cannot be null\u0026#34;); } if (inheritThreadLocals \u0026amp;\u0026amp; parent.inheritableThreadLocals != null) this.inheritableThreadLocals = ThreadLocal.createInheritedMap(parent.inheritableThreadLocals); this.stackSize = stackSize; tid = nextThreadID(); } public class InheritableThreadLocalDemo { public static void main(String[] args) { ThreadLocal\u0026lt;String\u0026gt; ThreadLocal = new ThreadLocal\u0026lt;\u0026gt;(); ThreadLocal\u0026lt;String\u0026gt; inheritableThreadLocal = new InheritableThreadLocal\u0026lt;\u0026gt;(); ThreadLocal.set(\u0026#34;父类数据:threadLocal\u0026#34;); inheritableThreadLocal.set(\u0026#34;父类数据:inheritableThreadLocal\u0026#34;); new Thread(new Runnable() { @Override public void run() { System.out.println(\u0026#34;子线程获取父类ThreadLocal数据:\u0026#34; + ThreadLocal.get()); System.out.println(\u0026#34;子线程获取父类inheritableThreadLocal数据:\u0026#34; + inheritableThreadLocal.get()); } }).start(); } } /*结果 子线程获取父类ThreadLocal数据:null 子线程获取父类inheritableThreadLocal数据:父类数据:inheritableThreadLocal */ 但是如果不是直接new(),也就是实际中我们都是通过使用线程池来获取新线程的,那么可以使用阿里开源的一个组件解决这个问题 TransmittableThreadLocal\nThreadLocal项目中使用实战 # 这里涉及到requestId,没用过,不是很懂,略过\nThreadLocal使用场景 # Feign远程调用解决方案 # 线程池异步调用,requestId 传递 # 使用MQ发送消息给第三方系统 # "},{"id":320,"href":"/zh/docs/technology/Review/java_guide/java/Concurrent/ly0309lyatomic-classes/","title":"Atomic原子类介绍","section":"并发","content":" 转载自https://github.com/Snailclimb/JavaGuide (添加小部分笔记)感谢作者!\n文章开头先用例子介绍几种类型的api使用\npackage com.aqs; import lombok.*; import java.util.concurrent.atomic.*; @Data @Getter @Setter @AllArgsConstructor @ToString class User { private String name; //如果要为atomicReferenceFieldUpdater服务,必须加上volatile修饰 public volatile Integer age; } public class AtomicTest { public static void main(String[] args) { System.out.println(\u0026#34;原子更新数值---------------\u0026#34;); AtomicInteger atomicInteger = new AtomicInteger(); int i1 = atomicInteger.incrementAndGet(); System.out.println(\u0026#34;原子增加后为\u0026#34; + i1); System.out.println(\u0026#34;原子更新数组---------------\u0026#34;); int[] a = new int[3]; AtomicIntegerArray atomicIntegerArray = new AtomicIntegerArray(a); int i = atomicIntegerArray.addAndGet(1, 3); System.out.println(\u0026#34;数组元素[\u0026#34; + 1 + \u0026#34;]增加后为\u0026#34; + i); System.out.println(\u0026#34;数组为\u0026#34; + atomicIntegerArray); System.out.println(\u0026#34;原子更新对象---------------\u0026#34;); User user1 = new User(\u0026#34;ly1\u0026#34;, 10); User user2 = new User(\u0026#34;ly2\u0026#34;, 20); User user3 = new User(\u0026#34;ly3\u0026#34;, 30); AtomicReference\u0026lt;User\u0026gt; atomicReference = new AtomicReference\u0026lt;\u0026gt;(user1); boolean b = atomicReference.compareAndSet(user2, user3); System.out.println(\u0026#34;更新\u0026#34; + (b ? \u0026#34;成功\u0026#34; : \u0026#34;失败\u0026#34;)); System.out.println(\u0026#34;引用里值为\u0026#34;+atomicReference.get()); boolean b1 = atomicReference.compareAndSet(user1, user3); System.out.println(\u0026#34;更新\u0026#34; + (b1 ? \u0026#34;成功\u0026#34; : \u0026#34;失败\u0026#34;)); System.out.println(\u0026#34;引用里值为\u0026#34;+atomicReference.get()); System.out.println(\u0026#34;原子更新对象属性---------------\u0026#34;); User user4=new User(\u0026#34;ly4\u0026#34;,40); AtomicReferenceFieldUpdater\u0026lt;User, Integer\u0026gt; atomicReferenceFieldUpdater = AtomicReferenceFieldUpdater.newUpdater(User.class, Integer.class, \u0026#34;age\u0026#34;); boolean b2 = atomicReferenceFieldUpdater.compareAndSet(user4, 41, 400); System.out.println(\u0026#34;更新\u0026#34;+(b2?\u0026#34;成功\u0026#34;:\u0026#34;失败\u0026#34;)); System.out.println(\u0026#34;引用里user4值为\u0026#34;+atomicReferenceFieldUpdater.get(user4)); boolean b3 = atomicReferenceFieldUpdater.compareAndSet(user4, 40, 400); System.out.println(\u0026#34;更新\u0026#34;+(b3?\u0026#34;成功\u0026#34;:\u0026#34;失败\u0026#34;)); System.out.println(\u0026#34;引用里user4值为\u0026#34;+atomicReferenceFieldUpdater.get(user4)); System.out.println(\u0026#34;其他使用---------------\u0026#34;); User user5=new User(\u0026#34;ly5\u0026#34;,50); User user6=new User(\u0026#34;ly6\u0026#34;,60); User user7=new User(\u0026#34;ly7\u0026#34;,70); AtomicMarkableReference\u0026lt;User\u0026gt; userAtomicMarkableReference=new AtomicMarkableReference\u0026lt;\u0026gt;(user5,true); boolean b4 = userAtomicMarkableReference.weakCompareAndSet(user6, user7, true, false); System.out.println(\u0026#34;更新\u0026#34;+(b4?\u0026#34;成功\u0026#34;:\u0026#34;失败\u0026#34;)); System.out.println(\u0026#34;引用里值为\u0026#34;+userAtomicMarkableReference.getReference()); boolean b5 = userAtomicMarkableReference.weakCompareAndSet(user5, user7, false, true); System.out.println(\u0026#34;更新\u0026#34;+(b5?\u0026#34;成功\u0026#34;:\u0026#34;失败\u0026#34;)); System.out.println(\u0026#34;引用里值为\u0026#34;+userAtomicMarkableReference.getReference()); boolean b6 = userAtomicMarkableReference.weakCompareAndSet(user5, user7, true, false); System.out.println(\u0026#34;更新\u0026#34;+(b6?\u0026#34;成功\u0026#34;:\u0026#34;失败\u0026#34;)); System.out.println(\u0026#34;引用里值为\u0026#34;+userAtomicMarkableReference.getReference()); System.out.println(\u0026#34;AtomicStampedReference使用---------------\u0026#34;); User user80=new User(\u0026#34;ly8\u0026#34;,80); User user90=new User(\u0026#34;ly9\u0026#34;,90); User user100=new User(\u0026#34;ly10\u0026#34;,100); AtomicStampedReference\u0026lt;User\u0026gt; userAtomicStampedReference=new AtomicStampedReference\u0026lt;\u0026gt;(user80,80);//版本80 //...每次更改stamp都加1 //这里假设中途被改成81了 boolean b7 = userAtomicStampedReference.compareAndSet(user80, user100,81,90); System.out.println(\u0026#34;更新\u0026#34;+(b7?\u0026#34;成功\u0026#34;:\u0026#34;失败\u0026#34;)); System.out.println(\u0026#34;引用里值为\u0026#34;+userAtomicStampedReference.getReference()); boolean b8 = userAtomicStampedReference.compareAndSet(user80, user100,80,90); System.out.println(\u0026#34;更新\u0026#34;+(b8?\u0026#34;成功\u0026#34;:\u0026#34;失败\u0026#34;)); System.out.println(\u0026#34;引用里值为\u0026#34;+userAtomicStampedReference.getReference()); } } /* 原子更新数值--------------- 原子增加后为1 原子更新数组--------------- 数组元素[1]增加后为3 数组为[0, 3, 0] 原子更新对象--------------- 更新失败 引用里值为User(name=ly1, age=10) 更新成功 引用里值为User(name=ly3, age=30) 原子更新对象属性--------------- 更新失败 引用里user4值为40 更新成功 引用里user4值为400 其他使用--------------- 更新失败 引用里值为User(name=ly5, age=50) 更新失败 引用里值为User(name=ly5, age=50) 更新成功 引用里值为User(name=ly7, age=70) AtomicStampedReference使用--------------- 更新失败 引用里值为User(name=ly8, age=80) 更新成功 引用里值为User(name=ly10, age=100) Process finished with exit code 0 */ 原子类介绍 # 在化学上,原子是构成一般物质的最小单位,化学反应中是不可分割的,Atomic指一个操作是不可中断的,即使在多个线程一起执行时,一个操作一旦开始就不会被其他线程干扰 原子类\u0026ndash;\u0026gt;具有原子/原子操作特征的类 并发包java.util.concurrent 的原子类都放着java.util.concurrent.atomic中 根据操作的数据类型,可以将JUC包中的原子类分为4类(基本类型、数组类型、引用类型、对象的属性修改类型) 基本类型 使用原子方式更新基本类型,包括AtomicInteger 整型原子类,AtomicLong 长整型原子类,AtomicBoolean 布尔型原子类\n数组类型 使用原子方式更新数组里某个元素,包括AtomicIntegerArray 整型数组原子类,AtomicLongArray 长整型数组原子类,AtomicReferenceArray 引用类型数组原子类\n引用类型 AtomicReference 引用类型原子类,AtomicMarkableReference 原子更新带有标记的引用类型,该类将boolean标记与引用关联(不可解决CAS进行原子操作出现的ABA问题),AtomicStampedReference 原子更新带有版本号的引用类型 该类将整数值与引用关联,可用于解决原子更新数据和数据的版本号(解决使用CAS进行原子更新时可能出现的ABA问题)\n对象的属性修改类型 AtomicIntegerFieldUpdater 原子更新整型字段的更新器,AtomicLongFieldUpdater 原子更新长整型字段的更新器, AtomicReferenceFieldUpdater 原子更新引用类型里的字段\nAtomicMarkableReference 不能解决 ABA 问题\npublic class SolveABAByAtomicMarkableReference { private static AtomicMarkableReference atomicMarkableReference = new AtomicMarkableReference(100, false); public static void main(String[] args) { Thread refT1 = new Thread(() -\u0026gt; { try { TimeUnit.SECONDS.sleep(1); } catch (InterruptedException e) { e.printStackTrace(); } atomicMarkableReference.compareAndSet(100, 101, atomicMarkableReference.isMarked(), !atomicMarkableReference.isMarked());//根据期望值100和false 修改为101和true atomicMarkableReference.compareAndSet(101, 100, atomicMarkableReference.isMarked(), !atomicMarkableReference.isMarked());//根据期望值101和true 修改为100和false }); Thread refT2 = new Thread(() -\u0026gt; { //获取原来的marked标记(false) boolean marked = atomicMarkableReference.isMarked(); //2s之后进行替换,不应该替换成功 try { TimeUnit.SECONDS.sleep(2); } catch (InterruptedException e) { e.printStackTrace(); } boolean c3 = atomicMarkableReference.compareAndSet(100, 101, marked, !marked); System.out.println(c3); // 返回true,实际应该返回false }); //导致了ABA问题 refT1.start(); refT2.start(); } } CAS ABA问题\n描述: 第一个线程取到了变量 x 的值 A,然后巴拉巴拉干别的事,总之就是只拿到了变量 x 的值 A。这段时间内第二个线程也取到了变量 x 的值 A,然后把变量 x 的值改为 B,然后巴拉巴拉干别的事,最后又把变量 x 的值变为 A (相当于还原了)。在这之后第一个线程终于进行了变量 x 的操作,但是此时变量 x 的值还是 A,所以 compareAndSet 操作是成功。\n也就是说,线程一无法保证自己操作期间,该值被修改了\n例子描述(可能不太合适,但好理解): 年初,现金为零,然后通过正常劳动赚了三百万,之后正常消费了(比如买房子)三百万。年末,虽然现金零收入(可能变成其他形式了),但是赚了钱是事实,还是得交税的!\n代码描述\nimport java.util.concurrent.atomic.AtomicInteger; public class AtomicIntegerDefectDemo { public static void main(String[] args) { defectOfABA(); } static void defectOfABA() { final AtomicInteger atomicInteger = new AtomicInteger(1); Thread coreThread = new Thread( () -\u0026gt; { final int currentValue = atomicInteger.get(); System.out.println(Thread.currentThread().getName() + \u0026#34; ------ currentValue=\u0026#34; + currentValue); // 这段目的:模拟处理其他业务花费的时间 //也就是说,在差值300-100=200ms内,值被操作了两次(但又改回去了),然后线程coreThread并没有感知到,当作没有修改过来处理 try { Thread.sleep(300); } catch (InterruptedException e) { e.printStackTrace(); } boolean casResult = atomicInteger.compareAndSet(1, 2); System.out.println(Thread.currentThread().getName() + \u0026#34; ------ currentValue=\u0026#34; + currentValue + \u0026#34;, finalValue=\u0026#34; + atomicInteger.get() + \u0026#34;, compareAndSet Result=\u0026#34; + casResult); } ); coreThread.start(); // 这段目的:为了让 coreThread 线程先跑起来 try { Thread.sleep(100); } catch (InterruptedException e) { e.printStackTrace(); } Thread amateurThread = new Thread( () -\u0026gt; { int currentValue = atomicInteger.get(); boolean casResult = atomicInteger.compareAndSet(1, 2); System.out.println(Thread.currentThread().getName() + \u0026#34; ------ currentValue=\u0026#34; + currentValue + \u0026#34;, finalValue=\u0026#34; + atomicInteger.get() + \u0026#34;, compareAndSet Result=\u0026#34; + casResult); currentValue = atomicInteger.get(); casResult = atomicInteger.compareAndSet(2, 1); System.out.println(Thread.currentThread().getName() + \u0026#34; ------ currentValue=\u0026#34; + currentValue + \u0026#34;, finalValue=\u0026#34; + atomicInteger.get() + \u0026#34;, compareAndSet Result=\u0026#34; + casResult); } ); amateurThread.start(); } } /*输出内容 Thread-0 ------ currentValue=1 Thread-1 ------ currentValue=1, finalValue=2, compareAndSet Result=true Thread-1 ------ currentValue=2, finalValue=1, compareAndSet Result=true Thread-0 ------ currentValue=1, finalValue=2, compareAndSet Result=true */ 基本类型原子类 # 使用原子方式更新基本类型:AtomicInteger 整型原子类,AtomicLong 长整型原子类 ,AtomicBoolean 布尔型原子类,下文以AtomicInteger为例子来介绍 常用方法:\npublic final int get() //获取当前的值 public final int getAndSet(int newValue)//获取当前的值,并设置新的值 public final int getAndIncrement()//获取当前的值,并自增 public final int getAndDecrement() //获取当前的值,并自减 public final int getAndAdd(int delta) //获取当前的值,并加上预期的值 boolean compareAndSet(int expect, int update) //如果输入的数值等于预期值,则以原子方式将该值设置为输入值(update) public final void lazySet(int newValue)//最终设置为newValue,使用 lazySet 设置之后可能导致其他线程在之后的一小段时间内还是可以读到旧的值。 常见方法使用\nimport java.util.concurrent.atomic.AtomicInteger; public class AtomicIntegerTest { public static void main(String[] args) { // TODO Auto-generated method stub int temvalue = 0; AtomicInteger i = new AtomicInteger(0); temvalue = i.getAndSet(3); System.out.println(\u0026#34;temvalue:\u0026#34; + temvalue + \u0026#34;; i:\u0026#34; + i);//temvalue:0; i:3 temvalue = i.getAndIncrement(); System.out.println(\u0026#34;temvalue:\u0026#34; + temvalue + \u0026#34;; i:\u0026#34; + i);//temvalue:3; i:4 temvalue = i.getAndAdd(5); System.out.println(\u0026#34;temvalue:\u0026#34; + temvalue + \u0026#34;; i:\u0026#34; + i);//temvalue:4; i:9 } } 基本数据类型原子类的优势 # 多线程环境不使用原子类保证线程安全(基本数据类型)\nclass Test { private volatile int count = 0; //若要线程安全执行执行count++,需要加锁 public synchronized void increment() { count++; } public int getCount() { return count; } } 多线程环境使用原子类保证线程安全(基本数据类型)\nclass Test2 { private AtomicInteger count = new AtomicInteger(); //使用AtomicInteger之后,不需要加锁,也可以实现线程安全。 public void increment() { count.incrementAndGet(); } public int getCount() { return count.get(); } } AtomicInteger线程安全原理简单分析 # 部分源码:\n/ setup to use Unsafe.compareAndSwapInt for updates(更新操作时提供“比较并替换”的作用) private static final Unsafe unsafe = Unsafe.getUnsafe(); private static final long valueOffset; static { try { valueOffset = unsafe.objectFieldOffset (AtomicInteger.class.getDeclaredField(\u0026#34;value\u0026#34;)); } catch (Exception ex) { throw new Error(ex); } } private volatile int value; AtomicInteger类主要利用CAS(compare and swap) + volatile 和 native方法来保证原子操作,从而避免synchronized高开销,提高执行效率 CAS的原理是拿期望的值和原本的值做比较,如果相同则更新成新值 UnSafe类的objectFieldOffset()方法是一个本地方法,这个方法用来拿到**\u0026ldquo;原来的值\u0026quot;的内存地址** value是一个volatile变量,在内存中可见,因此JVM可以保证任何时刻任何线程总能拿到该变量的最新值 数组类型原子类 # 使用原子的方式更新数组里的某个元素\nAtomicIntegerArray 整型数组原子类,AtomicLongArray 长整型数组原子类,AtomicReferenceArray 引用类型数组原子类\n常用方法:\npublic final int get(int i) //获取 index=i 位置元素的值 public final int getAndSet(int i, int newValue)//返回 index=i 位置的当前的值,并将其设置为新值:newValue public final int getAndIncrement(int i)//获取 index=i 位置元素的值,并让该位置的元素自增 public final int getAndDecrement(int i) //获取 index=i 位置元素的值,并让该位置的元素自减 public final int getAndAdd(int i, int delta) //获取 index=i 位置元素的值,并加上预期的值 boolean compareAndSet(int i, int expect, int update) //如果输入的数值等于预期值,则以原子方式将 index=i 位置的元素值设置为输入值(update) public final void lazySet(int i, int newValue)//最终 将index=i 位置的元素设置为newValue,使用 lazySet 设置之后可能导致其他线程在之后的一小段时间内还是可以读到旧的值。 常见方法使用\nimport java.util.concurrent.atomic.AtomicIntegerArray; public class AtomicIntegerArrayTest { public static void main(String[] args) { // TODO Auto-generated method stub int temvalue = 0; int[] nums = { 1, 2, 3, 4, 5, 6 }; AtomicIntegerArray i = new AtomicIntegerArray(nums); for (int j = 0; j \u0026lt; nums.length; j++) { System.out.println(i.get(j)); } temvalue = i.getAndSet(0, 2); System.out.println(\u0026#34;temvalue:\u0026#34; + temvalue + \u0026#34;; i:\u0026#34; + i); temvalue = i.getAndIncrement(0); System.out.println(\u0026#34;temvalue:\u0026#34; + temvalue + \u0026#34;; i:\u0026#34; + i); temvalue = i.getAndAdd(0, 5); System.out.println(\u0026#34;temvalue:\u0026#34; + temvalue + \u0026#34;; i:\u0026#34; + i); } } 引用类型原子类 # 基本类型原子类只能更新一个变量,如果需要原子更新多个变量,则需要使用引用类型原子类\nAtomicReference 引用类型原子类;\nAtomicStampedReference 原子更新带有版本号的引用类型,该类将整数值与引用关联起来,可用于解决原子的更新数据和数据的版本号,可以解决使用CAS进行原子更新时可能出现的ABA问题;\nAtomicMarkableReference:原子更新带有标记的引用类型。该类将boolean标记与引用关联**(注:无法解决ABA问题)**\n下面以AtomicReference为例介绍\nimport java.util.concurrent.atomic.AtomicReference; public class AtomicReferenceTest { public static void main(String[] args) { AtomicReference\u0026lt;Person\u0026gt; ar = new AtomicReference\u0026lt;Person\u0026gt;(); Person person = new Person(\u0026#34;SnailClimb\u0026#34;, 22); ar.set(person); Person updatePerson = new Person(\u0026#34;Daisy\u0026#34;, 20); ar.compareAndSet(person, updatePerson);//如果期望值为person,则替换成updatePerson System.out.println(ar.get().getName()); System.out.println(ar.get().getAge()); } } class Person { private String name; private int age; public Person(String name, int age) { super(); this.name = name; this.age = age; } public String getName() { return name; } public void setName(String name) { this.name = name; } public int getAge() { return age; } public void setAge(int age) { this.age = age; } } 上述代码首先创建了一个 Person 对象,然后把 Person 对象设置进 AtomicReference 对象中,然后调用 compareAndSet 方法,该方法就是通过 CAS 操作设置 ar。如果 ar 的值为 person 的话,则将其设置为 updatePerson。实现原理与 AtomicInteger 类中的 compareAndSet 方法相同。运行上面的代码后的输出结果如下\nDaisy 20 AtomicStampedReference类使用示例\nimport java.util.concurrent.atomic.AtomicStampedReference; public class AtomicStampedReferenceDemo { public static void main(String[] args) { // 实例化、取当前值和 stamp 值 final Integer initialRef = 0, initialStamp = 0; final AtomicStampedReference\u0026lt;Integer\u0026gt; asr = new AtomicStampedReference\u0026lt;\u0026gt;(initialRef, initialStamp); System.out.println(\u0026#34;currentValue=\u0026#34; + asr.getReference() + \u0026#34;, currentStamp=\u0026#34; + asr.getStamp()); // compare and set final Integer newReference = 666, newStamp = 999; final boolean casResult = asr.compareAndSet(initialRef, newReference, initialStamp, newStamp); System.out.println(\u0026#34;currentValue=\u0026#34; + asr.getReference() + \u0026#34;, currentStamp=\u0026#34; + asr.getStamp() + \u0026#34;, casResult=\u0026#34; + casResult); // 获取当前的值和当前的 stamp 值 int[] arr = new int[1]; final Integer currentValue = asr.get(arr); final int currentStamp = arr[0]; System.out.println(\u0026#34;currentValue=\u0026#34; + currentValue + \u0026#34;, currentStamp=\u0026#34; + currentStamp); // 单独设置 stamp 值 final boolean attemptStampResult = asr.attemptStamp(newReference, 88); System.out.println(\u0026#34;currentValue=\u0026#34; + asr.getReference() + \u0026#34;, currentStamp=\u0026#34; + asr.getStamp() + \u0026#34;, attemptStampResult=\u0026#34; + attemptStampResult); // 重新设置当前值和 stamp 值 asr.set(initialRef, initialStamp); System.out.println(\u0026#34;currentValue=\u0026#34; + asr.getReference() + \u0026#34;, currentStamp=\u0026#34; + asr.getStamp()); // [不推荐使用,除非搞清楚注释的意思了] weak compare and set // 困惑!weakCompareAndSet 这个方法最终还是调用 compareAndSet 方法。[版本: jdk-8u191] // 但是注释上写着 \u0026#34;May fail spuriously and does not provide ordering guarantees, // so is only rarely an appropriate alternative to compareAndSet.\u0026#34; // todo 感觉有可能是 jvm 通过方法名在 native 方法里面做了转发 final boolean wCasResult = asr.weakCompareAndSet(initialRef, newReference, initialStamp, newStamp); System.out.println(\u0026#34;currentValue=\u0026#34; + asr.getReference() + \u0026#34;, currentStamp=\u0026#34; + asr.getStamp() + \u0026#34;, wCasResult=\u0026#34; + wCasResult); } } /* 结果 currentValue=0, currentStamp=0 currentValue=666, currentStamp=999, casResult=true currentValue=666, currentStamp=999 currentValue=666, currentStamp=88, attemptStampResult=true currentValue=0, currentStamp=0 currentValue=666, currentStamp=999, wCasResult=true */ AtomicMarkableReference 类使用示例\nimport java.util.concurrent.atomic.AtomicMarkableReference; public class AtomicMarkableReferenceDemo { public static void main(String[] args) { // 实例化、取当前值和 mark 值 final Boolean initialRef = null, initialMark = false; final AtomicMarkableReference\u0026lt;Boolean\u0026gt; amr = new AtomicMarkableReference\u0026lt;\u0026gt;(initialRef, initialMark); System.out.println(\u0026#34;currentValue=\u0026#34; + amr.getReference() + \u0026#34;, currentMark=\u0026#34; + amr.isMarked()); // compare and set final Boolean newReference1 = true, newMark1 = true; final boolean casResult = amr.compareAndSet(initialRef, newReference1, initialMark, newMark1); System.out.println(\u0026#34;currentValue=\u0026#34; + amr.getReference() + \u0026#34;, currentMark=\u0026#34; + amr.isMarked() + \u0026#34;, casResult=\u0026#34; + casResult); // 获取当前的值和当前的 mark 值 boolean[] arr = new boolean[1]; final Boolean currentValue = amr.get(arr); final boolean currentMark = arr[0]; System.out.println(\u0026#34;currentValue=\u0026#34; + currentValue + \u0026#34;, currentMark=\u0026#34; + currentMark); // 单独设置 mark 值 final boolean attemptMarkResult = amr.attemptMark(newReference1, false); System.out.println(\u0026#34;currentValue=\u0026#34; + amr.getReference() + \u0026#34;, currentMark=\u0026#34; + amr.isMarked() + \u0026#34;, attemptMarkResult=\u0026#34; + attemptMarkResult); // 重新设置当前值和 mark 值 amr.set(initialRef, initialMark); System.out.println(\u0026#34;currentValue=\u0026#34; + amr.getReference() + \u0026#34;, currentMark=\u0026#34; + amr.isMarked()); // [不推荐使用,除非搞清楚注释的意思了] weak compare and set // 困惑!weakCompareAndSet 这个方法最终还是调用 compareAndSet 方法。[版本: jdk-8u191] // 但是注释上写着 \u0026#34;May fail spuriously and does not provide ordering guarantees, // so is only rarely an appropriate alternative to compareAndSet.\u0026#34; // todo 感觉有可能是 jvm 通过方法名在 native 方法里面做了转发 final boolean wCasResult = amr.weakCompareAndSet(initialRef, newReference1, initialMark, newMark1); System.out.println(\u0026#34;currentValue=\u0026#34; + amr.getReference() + \u0026#34;, currentMark=\u0026#34; + amr.isMarked() + \u0026#34;, wCasResult=\u0026#34; + wCasResult); } } /* 结果 currentValue=null, currentMark=false currentValue=true, currentMark=true, casResult=true currentValue=true, currentMark=true currentValue=true, currentMark=false, attemptMarkResult=true currentValue=null, currentMark=false currentValue=true, currentMark=true, wCasResult=true */ 对象的属性修改类型原子类 # 对象的属性修改类型原子类,用来原子更新某个类里的某个字段\n包括: AtomicIntegerFieldUpdater 原子更新整型字段的更新器,AtomicLongFieldUpdater 原子更新长整型字段的更新器,AtomicReferenceFieldUpdater 原子更新引用类型里的字段的更新器\n原子地更新对象属性需要两步骤:\n对象的属性修改类型原子类都是抽象类,所以每次使用都必须使用静态方法newUpdater()创建一个更新器,且设置想要更新的类和属性 更新的对象属性必须使用public volatile修饰符 下面以AtomicIntegerFieldUpdater为例子来介绍\nimport java.util.concurrent.atomic.AtomicIntegerFieldUpdater; public class AtomicIntegerFieldUpdaterTest { public static void main(String[] args) { AtomicIntegerFieldUpdater\u0026lt;User\u0026gt; a = AtomicIntegerFieldUpdater.newUpdater(User.class, \u0026#34;age\u0026#34;); User user = new User(\u0026#34;Java\u0026#34;, 22); System.out.println(a.getAndIncrement(user));// 22 System.out.println(a.get(user));// 23 } } class User { private String name; public volatile int age; public User(String name, int age) { super(); this.name = name; this.age = age; } public String getName() { return name; } public void setName(String name) { this.name = name; } public int getAge() { return age; } public void setAge(int age) { this.age = age; } } /* 结果 22 33 */ "},{"id":321,"href":"/zh/docs/technology/Review/java_guide/java/Concurrent/ly0308lyaqs-details/","title":"aqs详解","section":"并发","content":" 转载自https://github.com/Snailclimb/JavaGuide (添加小部分笔记)感谢作者!\nSemaphore [ˈseməfɔː(r)]\n何为 AQS?AQS 原理了解吗? CountDownLatch 和 CyclicBarrier 了解吗?两者的区别是什么? 用过 Semaphore 吗?应用场景了解吗? \u0026hellip;\u0026hellip; AQS简单介绍 # AQS,AbstractQueueSyschronizer,即抽象队列同步器,这个类在java.util.concurrent.locks包下面\nAQS是一个抽象类,主要用来构建锁和同步器\npublic abstract class AbstractQueuedSynchronizer extends AbstractOwnableSynchronizer implements java.io.Serializable { } AQS 为构建锁和同步器提供了一些通用功能的实现,因此,使用 AQS 能简单且高效地构造出应用广泛的大量的同步器,比如我们提到的 ReentrantLock,Semaphore,其他的诸如 ReentrantReadWriteLock,SynchronousQueue,FutureTask(jdk1.7) 等等皆是基于 AQS 的。\nAQS原理 # AQS核心思想 # 面试不是背题,大家一定要加入自己的思想,即使加入不了自己的思想也要保证自己能够通俗的讲出来而不是背出来\nAQS 核心思想是,如果被请求的共享资源(AQS内部)空闲,则将当前请求资源的线程设置为有效的工作线程,并且将共享资源设置为锁定状态。如果被请求的共享资源被占用,那么就需要一套线程阻塞等待以及被唤醒时锁分配的机制,这个机制 AQS 是用 CLH 队列锁实现的,即将暂时获取不到锁的线程加入到队列中。\nCLH(Craig,Landin and Hagersten)队列是一个虚拟的双向队列(虚拟的双向队列即不存在队列实例,仅存在结点之间的关联关系)。AQS 是将每条请求共享资源的线程封装成一个 CLH 锁队列的一个结点(Node)来实现锁的分配。\n[ 搜索了一下,CLH好像是人名 ] 在 CLH 同步队列中,一个节点表示一个线程,它保存着线程的引用(thread)、 当前节点在队列中的状态(waitStatus)、前驱节点(prev)、后继节点(next)。\nCLH队列结构\nAQS(AbstractQueuedSynchronized)原理图\nAQS使用一个int成员变量来表示同步状态,通过内置的线程等待队列来获取资源线程的排队工作。\nstate 变量由 volatile 修饰,用于展示当前临界资源的获锁情况。\nprivate volatile int state;//共享变量,使用volatile修饰保证线程可见性 状态信息的操作\n通过 protected 类型的getState()、setState()和compareAndSetState() 进行操作。并且,这几个方法都是 final 修饰的,在子类中无法被重写。\n//返回同步状态的当前值 protected final int getState() { return state; } //设置同步状态的值 protected final void setState(int newState) { state = newState; } //原子地(CAS操作)将同步状态值设置为给定值update如果当前同步状态的值等于expect(期望值) protected final boolean compareAndSetState(int expect, int update) { return unsafe.compareAndSwapInt(this, stateOffset, expect, update); } 以 ReentrantLock 为例,state 初始值为 0,表示未锁定状态。A 线程 lock() 时,会调用 tryAcquire() 独占该锁并将 state+1 。此后,其他线程再 tryAcquire() 时就会失败,直到 A 线程 unlock() 到 state=0(即释放锁)为止,其它线程才有机会获取该锁。当然,释放锁之前,A 线程自己是可以重复获取此锁的(state 会累加),这就是可重入的概念。但要注意,获取多少次就要释放多少次,这样才能保证 state 是能回到零态的。\n再以 CountDownLatch 以例,任务分为 N 个子线程去执行,state 也初始化为 N(注意 N 要与线程个数一致)。这 N 个子线程是并行执行的,每个子线程执行完后countDown() 一次,state 会 CAS(Compare and Swap) 减 1。等到所有子线程都执行完后(即 state=0 ),会 unpark() 主调用线程,然后主调用线程就会从 await() 函数返回,继续后余动作。\n​\nAQS资源共享方式 # 包括Exclusive(独占,只有一个线程能执行,如ReentrantLock)和Share(共享,多个线程可同时执行,如Semaphore/CountDownLatch)\n从另一个角度讲,就是只有一个线程能操作state变量以及有n个线程能操作state变量的区别\n一般来说,自定义同步器的共享方式要么是独占,要么是共享,他们也只需实现**tryAcquire-tryRelease、tryAcquireShared-tryReleaseShared中的一种即可。但 AQS 也支持自定义同步器同时实现独占和共享两种方式,如ReentrantReadWriteLock**。\n自定义同步器 # 同步器的设计是基于模板方法模式的,如果需要自定义同步器一般的方式是这样(模板方法模式很经典的一个应用):\n使用者继承 AbstractQueuedSynchronizer 并重写指定的方法。【使用者】 将 AQS 组合在自定义同步组件的实现中,并调用其模板方法,而这些模板方法会调用使用者重写的方法。【AQS内部】 这和我们以往通过实现接口的方式有很大区别,这是模板方法模式很经典的一个运用。\n//独占方式。尝试获取资源,成功则返回true,失败则返回false。 protected boolean tryAcquire(int) //独占方式。尝试释放资源,成功则返回true,失败则返回false。 protected boolean tryRelease(int) //共享方式。尝试获取资源。负数表示失败;0表示成功,但没有剩余可用资源;正数表示成功,且有剩余资源。 protected int tryAcquireShared(int) //共享方式。尝试释放资源,成功则返回true,失败则返回false。 protected boolean tryReleaseShared(int) //该线程是否正在独占资源。只有用到condition才需要去实现它。 protected boolean isHeldExclusively() 什么是钩子方法呢? 钩子方法是一种被声明在抽象类中的方法,一般使用 protected 关键字修饰,它可以是空方法(由子类实现),也可以是默认实现的方法。模板设计模式通过钩子方法控制固定步骤的实现。\n篇幅问题,这里就不详细介绍模板方法模式了,不太了解的小伙伴可以看看这篇文章: 用 Java8 改造后的模板方法模式真的是 yyds!open in new window。\n除了上面提到的钩子方法之外,AQS 类中的其他方法都是 final ,所以无法被其他类重写。\n常见同步类 # Semaphore # Semaphore(信号量)可以指定多个线程同时访问某个资源\n/** * * @author Snailclimb * @date 2018年9月30日 * @Description: 需要一次性拿一个许可的情况 */ public class SemaphoreExample1 { // 请求的数量 private static final int threadCount = 550; public static void main(String[] args) throws InterruptedException { // 创建一个具有固定线程数量的线程池对象(如果这里线程池的线程数量给太少的话你会发现执行的很慢) ExecutorService threadPool = Executors.newFixedThreadPool(300); // 一次只能允许执行的线程数量。 final Semaphore semaphore = new Semaphore(20); for (int i = 0; i \u0026lt; threadCount; i++) { final int threadnum = i; threadPool.execute(() -\u0026gt; {// Lambda 表达式的运用 try { //通行证发了20个之后,就不能再发放了 semaphore.acquire();// 获取一个许可,所以可运行线程数量为20/1=20 test(threadnum); semaphore.release();// 释放一个许可 } catch (InterruptedException e) { // TODO Auto-generated catch block e.printStackTrace(); } }); } threadPool.shutdown(); System.out.println(\u0026#34;finish\u0026#34;); } //拿了通行证之后,处理2s钟后才释放 public static void test(int threadnum) throws InterruptedException { Thread.sleep(1000);// 模拟请求的耗时操作 System.out.println(\u0026#34;threadnum:\u0026#34; + threadnum); Thread.sleep(1000);// 模拟请求的耗时操作 } } //另一个例子\npublic static void main(String[] args) throws InterruptedException{ AtomicInteger atomicInteger=new AtomicInteger(); ExecutorService executorService = Executors.newCachedThreadPool(); Semaphore semaphore=new Semaphore(3); for(int i=0;i\u0026lt;8;i++) { int finalI = i; executorService.submit(()-\u0026gt;{ try { semaphore.acquire(); int i1 = atomicInteger.incrementAndGet(); log.info(\u0026#34;获取一个通行证\u0026#34;+ finalI); TimeUnit.SECONDS.sleep(finalI+1); } catch (InterruptedException e) { e.printStackTrace(); }finally { log.info(\u0026#34;通行证\u0026#34;+ finalI +\u0026#34;释放完毕\u0026#34;); semaphore.release(); } }); } log.info(\u0026#34;全部获取完毕\u0026#34;); //这个方法不会导致线程立即结束 executorService.shutdown(); log.info(\u0026#34;线程池shutdown\u0026#34;); } /* 结果 2022-12-01 14:21:31 下午 [Thread: pool-1-thread-3] INFO:获取一个通行证2 2022-12-01 14:21:31 下午 [Thread: main] INFO:全部获取完毕 2022-12-01 14:21:31 下午 [Thread: main] INFO:线程池shutdown 2022-12-01 14:21:31 下午 [Thread: pool-1-thread-2] INFO:获取一个通行证1 2022-12-01 14:21:31 下午 [Thread: pool-1-thread-1] INFO:获取一个通行证0 2022-12-01 14:21:32 下午 [Thread: pool-1-thread-1] INFO:通行证0释放完毕 2022-12-01 14:21:32 下午 [Thread: pool-1-thread-4] INFO:获取一个通行证3 2022-12-01 14:21:33 下午 [Thread: pool-1-thread-2] INFO:通行证1释放完毕 2022-12-01 14:21:33 下午 [Thread: pool-1-thread-5] INFO:获取一个通行证4 2022-12-01 14:21:34 下午 [Thread: pool-1-thread-3] INFO:通行证2释放完毕 2022-12-01 14:21:34 下午 [Thread: pool-1-thread-6] INFO:获取一个通行证5 2022-12-01 14:21:36 下午 [Thread: pool-1-thread-4] INFO:通行证3释放完毕 2022-12-01 14:21:36 下午 [Thread: pool-1-thread-7] INFO:获取一个通行证6 2022-12-01 14:21:38 下午 [Thread: pool-1-thread-5] INFO:通行证4释放完毕 2022-12-01 14:21:38 下午 [Thread: pool-1-thread-8] INFO:获取一个通行证7 2022-12-01 14:21:40 下午 [Thread: pool-1-thread-6] INFO:通行证5释放完毕 2022-12-01 14:21:43 下午 [Thread: pool-1-thread-7] INFO:通行证6释放完毕 2022-12-01 14:21:46 下午 [Thread: pool-1-thread-8] INFO:通行证7释放完毕 Process finished with exit code 0 如上所示,先是获取了210,之后释放一个获取一个(最多获取3个), 3+n*2 =10 ,之后陆续释放0获取3,释放1获取4,释放2获取5 之后 释放3获取6,释放4获取7; 这是还有5,7,6拿着通行证 之后随机将5,7,6释放掉即可。 */ //如上,shutdown不会立即停止,而是:\n线程池shutdown之后不再接收新任务\nsutdown只是将线程池的状态设置为SHUTWDOWN状态,正在执行的任务会继续执行下去,没有被执行的则中断。而shutdownNow则是将线程池的状态设置为STOP,正在执行的任务则被停止,没被执行任务的则返回。如果是shutdownNow,则会报这个问题\njava.lang.InterruptedException: sleep interrupted at java.lang.Thread.sleep(Native Method) at java.lang.Thread.sleep(Thread.java:340) at java.util.concurrent.TimeUnit.sleep(TimeUnit.java:386) at com.ly.SemaphoreExample2.lambda$main$0(SemaphoreExample2.java:45) at java.util.concurrent.Executors$RunnableAdapter.call(Executors.java:511) at java.util.concurrent.FutureTask.run(FutureTask.java:266) at java.util.concurrent.ThreadPoolExecutor.runWorker(ThreadPoolExecutor.java:1149) at java.util.concurrent.ThreadPoolExecutor$Worker.run(ThreadPoolExecutor.java:624) at java.lang.Thread.run(Thread.java:748) 解释最上面的例子:\n执行acquire()方法会导致阻塞,直到有一个许可证可以获得然后拿走一个许可证 每个release()方法增加一个许可证,这**可能会释放一个阻塞的acquire()**方法 Semaphore只是维持了一个可以获得许可证的数量,没有实际的许可证这个对象 Semaphore经常用于限制获取某种资源的线程数量 可以一次性获取或释放多个许可,不过没必要\nsemaphore.acquire(5);// 获取5个许可,所以可运行线程数量为20/5=4 test(threadnum); semaphore.release(5);// 释放5个许可 除了 acquire() 方法之外,另一个比较常用的与之对应的方法是 tryAcquire() 方法,该方法如果获取不到许可就立即返回 false\n介绍\nsynchronized 和 ReentrantLock 都是一次只允许一个线程访问某个资源,而Semaphore(信号量)可以用来控制同时访问特定资源的线程数量。\nSemaphore 的使用简单,我们这里假设有 N(N\u0026gt;5) 个线程来获取 Semaphore 中的共享资源,下面的代码表示同一时刻 N 个线程中只有 5 个线程能获取到共享资源,其他线程都会阻塞,只有获取到共享资源的线程才能执行。等到有线程释放了共享资源,其他阻塞的线程才能获取到。\n// 初始共享资源数量 final Semaphore semaphore = new Semaphore(5); // 获取1个许可 semaphore.acquire(); // 释放1个许可 semaphore.release(); /* 当初始的资源个数为 1 的时候,Semaphore 退化为排他锁。 */ Semaphore有两种模式,公平模式和非公平模式\n公平模式:调用acquire()方法的顺序,就是获取许可证的顺序,遵循FIFO 非公平模式:抢占式的 两个构造函数,必须提供许可数量,第二个构造方法可以指定是公平模式还是非公平模式,默认非公平模式\npublic Semaphore(int permits) { sync = new NonfairSync(permits); } public Semaphore(int permits, boolean fair) { sync = fair ? new FairSync(permits) : new NonfairSync(permits); } Semaphore 通常用于那些资源有明确访问数量限制的场景比如限流(仅限于单机模式,实际项目中推荐使用 Redis +Lua 来做限流)\n原理\nSemaphore 是共享锁的一种实现,它默认构造 AQS 的 state 值为 permits,你可以将 permits 的值理解为许可证的数量,只有拿到许可证的线程才能执行。\n调用semaphore.acquire() ,线程尝试获取许可证,如果 state \u0026gt;= 0 的话,则表示可以获取成功。如果获取成功的话,使用 CAS 操作去修改 state 的值 state=state-1。如果 state\u0026lt;0 的话,则表示许可证数量不足。此时会创建一个 Node 节点加入阻塞队列,挂起当前线程。\n/** * 获取1个许可证 */ public void acquire() throws InterruptedException { sync.acquireSharedInterruptibly(1); } /** * 共享模式下获取许可证,获取成功则返回,失败则加入阻塞队列,挂起线程 */ public final void acquireSharedInterruptibly(int arg) throws InterruptedException { if (Thread.interrupted()) throw new InterruptedException(); // 尝试获取许可证,arg为获取许可证个数,当可用许可证数减当前获取的许可证数结果小于0,则创建一个节点加入阻塞队列,挂起当前线程。 if (tryAcquireShared(arg) \u0026lt; 0) doAcquireSharedInterruptibly(arg); } 调用semaphore.release(); ,线程尝试释放许可证,并使用 CAS 操作去修改 state 的值 state=state+1。释放许可证成功之后,同时会唤醒同步队列中的一个线程。被唤醒的线程会重新尝试去修改 state 的值 state=state-1 ,如果 state\u0026gt;=0 则获取令牌成功,否则重新进入阻塞队列,挂起线程。\n// 释放一个许可证 public void release() { sync.releaseShared(1); } // 释放共享锁,同时会唤醒同步队列中的一个线程。 public final boolean releaseShared(int arg) { //释放共享锁 if (tryReleaseShared(arg)) { //唤醒同步队列中的一个线程 doReleaseShared(); return true; } return false; } 补充\nSemaphore 与 CountDownLatch 一样,也是共享锁的一种实现。它默认构造 AQS 的 state 为 permits。当执行任务的线程数量超出 permits,那么多余的线程将会被放入阻塞队列 Park,并自旋判断 state 是否大于 0。只有当 state 大于 0 的时候,阻塞的线程才能继续执行,此时先前执行任务的线程继续执行 release() 方法,release() 方法使得 state 的变量会加 1,那么自旋的线程便会判断成功。 如此,每次只有最多不超过 permits 数量的线程能自旋成功,便限制了执行任务线程的数量。\nCountDownLatch(倒计时) # CountDown 倒计时器;Latch 门闩 允许count个线程阻塞在一个地方,直至所有线程的任务都执行完毕 CountDownLatch 是一次性的,计数器的值只能在构造方法中初始化一次,之后没有任何机制再次对其设置值,当 CountDownLatch 使用完毕后,它不能再次被使用。 原理 CountDownLatch是共享锁的一种实现(我的理解是 本质上是说AQS内部的state变量可以被多个线程同时修改,所以是\u0026quot;共享\u0026quot;),默认构造AQS的state值为count。当线程使用countDown()方法时,其实是使用了tryReleaseShared方法以CAS操作来减少state,直至state为0 当调用await()方法时,如果state不为0,那就证明任务还没有执行完毕,await()方法会一直阻塞,即await()方法之后的语句不会被执行。之后CountDownLatch会自旋CAS判断state==0,如果state == 0就会释放所有等待线程,await()方法之后的语句得到执行 CountDownLatch的两种典型用法 # 其实就是n个线程等待其他m个线程执行完毕后唤醒,只有n为1时是第一种情况,只有m为1时是第二种情况\n某线程在开始运行前等待n个线程执行完毕\n将 CountDownLatch 的计数器初始化为 n (new CountDownLatch(n)),每当一个任务线程执行完毕,就将计数器减 1 (countdownlatch.countDown()),当计数器的值变为 0 时,在 CountDownLatch 上 await() 的线程就会被唤醒。一个典型应用场景就是启动一个服务时,主线程需要等待多个组件加载完毕,之后再继续执行。\n实现多个线程开始执行任务的最大并行性\n注意是并行性,不是并发,强调的是多个线程在某一时刻同时开始执行。类似于赛跑,将多个线程放到起点,等待发令枪响,然后同时开跑。\n做法是初始化一个共享的 CountDownLatch 对象,将其计数器初始化为 1 (new CountDownLatch(1)),多个线程在开始执行任务前首先 coundownlatch.await(),当主线程调用 countDown() 时,计数器变为 0,多个线程同时被唤醒。\nCountDownLatch使用示例\n300个线程(说的是线程池有300个核心线程,而不是CountDown300次),550个请求(及count = 550)。启动线程后,主线程阻塞。当所有请求都countDown,主线程恢复运行\n/** * * @author SnailClimb * @date 2018年10月1日 * @Description: CountDownLatch 使用方法示例 */ public class CountDownLatchExample1 { // 请求的数量 private static final int threadCount = 550; public static void main(String[] args) throws InterruptedException { // 创建一个具有固定线程数量的线程池对象(如果这里线程池的线程数量给太少的话你会发现执行的很慢) ExecutorService threadPool = Executors.newFixedThreadPool(300); final CountDownLatch countDownLatch = new CountDownLatch(threadCount); for (int i = 0; i \u0026lt; threadCount; i++) { final int threadnum = i; threadPool.execute(() -\u0026gt; {// Lambda 表达式的运用 try { test(threadnum); } catch (InterruptedException e) { // TODO Auto-generated catch block e.printStackTrace(); } finally { countDownLatch.countDown();// 表示一个请求已经被完成 } }); } countDownLatch.await(); threadPool.shutdown(); System.out.println(\u0026#34;finish\u0026#34;); } public static void test(int threadnum) throws InterruptedException { Thread.sleep(1000);// 模拟请求的耗时操作 System.out.println(\u0026#34;threadnum:\u0026#34; + threadnum); Thread.sleep(1000);// 模拟请求的耗时操作 } } 与CountDownLatch的第一次交互是主线程等待其他线程\n主线程必须在启动其他线程后立即调用CountDownLatch.await()方法,这样主线程的操作就会在这个方法阻塞,直到其他线程完成各自任务\n其他 N 个线程必须引用闭锁对象(说的是CountDownLoatch对象),因为他们需要通知 CountDownLatch 对象,他们已经完成了各自的任务。这种通知机制是通过 CountDownLatch.countDown()方法来完成的;每调用一次这个方法,在构造函数中初始化的 count 值就减 1。所以当 N 个线程都调 用了这个方法,count 的值等于 0,然后主线程就能通过 await()方法,恢复执行自己的任务。\nCountDownLatch 的 await() 方法使用不当很容易产生死锁,比如我们上面代码中的 for 循环改为:\nfor (int i = 0; i \u0026lt; threadCount-1; i++) { ....... } //这样就导致 count 的值没办法等于 0(最终为1),然后就会导致一直等待。 CountDownLatch 的不足 # CountDownLatch 是一次性的,计数器的值只能在构造方法中初始化一次,之后没有任何机制再次对其设置值,当 CountDownLatch 使用完毕后,它不能再次被使用。\nCountDownLatch 相常见面试题(改版后没了) # CountDownLatch 怎么用?应用场景是什么? CountDownLatch 和 CyclicBarrier 的不同之处? CountDownLatch 类中主要的方法? CyclicBarrier # CyclicBarrier和CountDownLatch类似,可以实现线程间的技术等待,主要应用场景和CountDownLatch类似,但更复杂强大 主要应用场景和 CountDownLatch 类似。\nCountDownLatch基于AQS,而CycliBarrier基于ReentrantLock(ReentrantLock属于AQS同步器)和Condition\nCyclicBarrier 的字面意思是可循环使用(Cyclic)的屏障(Barrier)。它要做的事情是:让一组线程(中的一个)到达一个屏障(也可以叫同步点)时被阻塞,直到最后一个线程到达屏障时,屏障才会开门,所有被屏障拦截的线程才会继续干活。\n原理 # CyclicBarrier 内部通过一个 count 变量作为计数器,count 的初始值为 parties 属性的初始化值,每当一个线程到了栅栏这里了,那么就将计数器减 1。如果 count 值为 0 了,表示这是这一代最后一个线程到达栅栏,就尝试执行我们构造方法中输入的任务(之后再释放所有阻塞的线程)。\n//每次拦截的线程数 private final int parties; //计数器 private int count; CyclicBarrier 默认的构造方法是 CyclicBarrier(int parties),其参数表示屏障拦截的线程数量,每个线程调用 await() 方法告诉 CyclicBarrier 我已经到达了屏障,然后当前线程被阻塞。\npublic CyclicBarrier(int parties) { this(parties, null); } public CyclicBarrier(int parties, Runnable barrierAction) { if (parties \u0026lt;= 0) throw new IllegalArgumentException(); this.parties = parties; this.count = parties; this.barrierCommand = barrierAction; } 先看一个例子\n/** * * @author Snailclimb * @date 2018年10月1日 * @Description: 测试 CyclicBarrier 类中带参数的 await() 方法 */ public class CyclicBarrierExample2 { // 请求的数量 private static final int threadCount = 550; // 需要同步的线程数量 private static final CyclicBarrier cyclicBarrier = new CyclicBarrier(5, () -\u0026gt; { System.out.println(\u0026#34;------当线程数达到之后,优先执行------\u0026#34;); }); public static void main(String[] args) throws InterruptedException { // 创建线程池 ExecutorService threadPool = Executors.newFixedThreadPool(10); for (int i = 0; i \u0026lt; threadCount; i++) { final int threadNum = i; Thread.sleep(1000); ///注意这行 threadPool.execute(() -\u0026gt; { try { test(threadNum); } catch (InterruptedException e) { // TODO Auto-generated catch block e.printStackTrace(); } catch (BrokenBarrierException e) { // TODO Auto-generated catch block e.printStackTrace(); } }); } threadPool.shutdown(); } public static void test(int threadnum) throws InterruptedException, BrokenBarrierException { System.out.println(\u0026#34;threadnum:\u0026#34; + threadnum + \u0026#34;is ready\u0026#34;); try { /**等待60秒,保证子线程完全执行结束*/ //如果等待的时间,超过了60秒,那么就会抛出异常,而且还会进行重置(变为0个线程再等待) cyclicBarrier.await(60, TimeUnit.SECONDS); //最后一个(第5个到达后,count会重置为0) } catch (Exception e) { System.out.println(\u0026#34;-----CyclicBarrierException------\u0026#34;); } System.out.println(\u0026#34;threadnum:\u0026#34; + threadnum + \u0026#34;is finish\u0026#34;); } } /* 结果 threadnum:0is ready threadnum:1is ready threadnum:2is ready threadnum:3is ready threadnum:4is ready threadnum:4is finish threadnum:0is finish threadnum:1is finish threadnum:2is finish threadnum:3is finish threadnum:5is ready threadnum:6is ready threadnum:7is ready threadnum:8is ready threadnum:9is ready threadnum:9is finish threadnum:5is finish threadnum:8is finish threadnum:7is finish threadnum:6is finish ...... */ /* 1.可以看到当线程数量也就是请求数量达到我们定义的 5 个的时候, await() 方法之后的方法才被执行。 2.另外,CyclicBarrier 还提供一个更高级的构造函数 CyclicBarrier(int parties, Runnable barrierAction),用于在线程到达屏障时,优先执行 barrierAction,方便处理更复杂的业务场景。 */ //注意这里,如果把Thread.sleep(1000)去掉,顺序(情况之一)为: //也就是说,上面的代码,导致的现象:所有的ready都挤在一起了(而且不分先后,随时执行,而某5个的finish,会等待那5个的ready执行完才会执行,且finish没有顺序的) //★如上,ready也是没有顺序的 /*threadnum:0is ready threadnum:5is ready threadnum:9is ready threadnum:7is ready threadnum:3is ready threadnum:8is ready threadnum:4is ready threadnum:2is ready threadnum:1is ready threadnum:6is ready ------当线程数达到之后,优先执行------ 当ready数量为5的倍数时(栅栏是5个,就会执行这个) threadnum:3is finish threadnum:10is ready ------当线程数达到之后,优先执行------ threadnum:10is finish threadnum:11is ready threadnum:0is finish threadnum:5is finish threadnum:4is finish threadnum:1is finish threadnum:8is finish threadnum:12is ready threadnum:9is finish threadnum:7is finish threadnum:16is ready threadnum:15is ready ------当线程数达到之后,优先执行------ threadnum:14is ready threadnum:6is finish threadnum:13is ready threadnum:2is finish threadnum:19is ready threadnum:16is finish threadnum:12is finish threadnum:18is ready threadnum:11is finish threadnum:23is ready ------当线程数达到之后,优先执行------ threadnum:17is ready threadnum:19is finish threadnum:15is finish threadnum:25is ready threadnum:24is ready threadnum:18is finish threadnum:26is ready threadnum:13is finish threadnum:14is finish threadnum:23is finish threadnum:22is ready threadnum:21is ready threadnum:20is ready ------当线程数达到之后,优先执行------ threadnum:29is ready threadnum:28is ready threadnum:27is ready threadnum:22is finish threadnum:24is finish threadnum:25is finish threadnum:32is ready ..... */ 在看一个例子:\npublic class BarrierTest1 { public static void main(String[] args) throws InterruptedException, TimeoutException, BrokenBarrierException { CyclicBarrier cyclicBarrier = new CyclicBarrier(3); ExecutorService executorService = Executors.newFixedThreadPool(10); executorService.submit(() -\u0026gt; { try { TimeUnit.SECONDS.sleep(1); } catch (InterruptedException e) { e.printStackTrace(); } try { cyclicBarrier.await( ); System.out.println(\u0026#34;数量11====\u0026#34;+cyclicBarrier.getNumberWaiting()); System.out.println(\u0026#34;111\u0026#34;); } catch (Exception e) { System.out.println(\u0026#34;数量异常1111===\u0026#34;+cyclicBarrier.getNumberWaiting()); // e.printStackTrace(); System.out.println(\u0026#34;报错1\u0026#34;); } }); executorService.submit(() -\u0026gt; { try { TimeUnit.SECONDS.sleep(3); } catch (InterruptedException e) { e.printStackTrace(); } try { System.out.println(\u0026#34;数量2222====\u0026#34;+cyclicBarrier.getNumberWaiting()); cyclicBarrier.await(111,TimeUnit.SECONDS); System.out.println(\u0026#34;222\u0026#34;); } catch (Exception e) { System.out.println(\u0026#34;数量异常2222====\u0026#34;+cyclicBarrier.getNumberWaiting()); System.out.println(\u0026#34;报错2\u0026#34;); } }); executorService.submit(() -\u0026gt; { try { TimeUnit.SECONDS.sleep(10); } catch (InterruptedException e) { e.printStackTrace(); } try { System.out.println(\u0026#34;数量33 await前====\u0026#34;+cyclicBarrier.getNumberWaiting()); cyclicBarrier.await(); System.out.println(\u0026#34;数量33 await后====\u0026#34;+cyclicBarrier.getNumberWaiting()); System.out.println(\u0026#34;333\u0026#34;); } catch (Exception e) { System.out.println(\u0026#34;数量异常333====\u0026#34;+cyclicBarrier.getNumberWaiting()); System.out.println(\u0026#34;报错3\u0026#34;); } }); } } /* 数量2222====1 数量33 await前====2 (第1、2个处于wait状态) 数量33 await后====0 (得到栅栏数量3,wait线程数重置为0) 333 数量11====0 (此时第1、2个线程都会释放,且数量重置为0) 111 222 */ CyclicBarrier源码分析 # 当调用CyclicBarrier对象调用await() 方法时,实际上调用的是dowait(false,0L )方法【主要用到false】\nawait() 方法就像树立起一个栅栏的行为一样,将线程挡住了,当拦住的线程数量达到 parties 的值时,栅栏才会打开,线程才得以通过执行。\npublic int await() throws InterruptedException, BrokenBarrierException { try { return dowait(false, 0L); } catch (TimeoutException toe) { throw new Error(toe); // cannot happen } } dowait(false,0L)方法\n// 当线程数量或者请求数量达到 count 时 await 之后的方法才会被执行。上面的示例中 count 的值就为 5。 private int count; /** * Main barrier code, covering the various policies. */ private int dowait(boolean timed, long nanos) throws InterruptedException, BrokenBarrierException, TimeoutException { final ReentrantLock lock = this.lock; // 锁住 lock.lock(); try { final Generation g = generation; if (g.broken) throw new BrokenBarrierException(); // 如果线程中断了,抛出异常 if (Thread.interrupted()) { breakBarrier(); throw new InterruptedException(); } // cout减1 //★前面锁住了,所以不需要CAS int index = --count; //★★ 当 count 数量减为 0 之后说明最后一个线程已经到达栅栏了,也就是达到了可以执行await 方法之后的条件 if (index == 0) { // tripped boolean ranAction = false; try { final Runnable command = barrierCommand; if (command != null) command.run(); ranAction = true; // 将 count 重置为 parties 属性的初始化值 // 唤醒之前等待的线程 // 下一波执行开始 nextGeneration(); return 0; } finally { if (!ranAction) breakBarrier(); } } // loop until tripped, broken, interrupted, or timed out for (;;) { try { if (!timed) trip.await(); else if (nanos \u0026gt; 0L) nanos = trip.awaitNanos(nanos); } catch (InterruptedException ie) { if (g == generation \u0026amp;\u0026amp; ! g.broken) { breakBarrier(); throw ie; } else { // We\u0026#39;re about to finish waiting even if we had not // been interrupted, so this interrupt is deemed to // \u0026#34;belong\u0026#34; to subsequent execution. Thread.currentThread().interrupt(); } } if (g.broken) throw new BrokenBarrierException(); if (g != generation) return index; if (timed \u0026amp;\u0026amp; nanos \u0026lt;= 0L) { breakBarrier(); throw new TimeoutException(); } } } finally { lock.unlock(); } } 总结:CyclicBarrier 内部通过一个 count 变量作为计数器,count 的初始值为 parties 属性的初始化值,每当一个线程到了栅栏这里了,那么就将计数器减一。如果 count 值为 0 了,表示这是这一代最后一个线程到达栅栏,就尝试执行我们构造方法中输入的任务\nCyclicBarrier和CountDownLatch区别 # CountDownLatch 是计数器,只能使用一次,而 CyclicBarrier 的计数器提供 reset 功能,可以多次使用。\n从jdk作者设计的目的来看,javadoc是这么描述他们的\nCountDownLatch: A synchronization aid that allows one or more threads to wait until a set of operations being performed in other threads completes.(CountDownLatch: 一个或者多个线程,等待其他多个线程完成某件事情之后才能执行;) CyclicBarrier : A synchronization aid that allows a set of threads to all wait for each other to reach a common barrier point.(CyclicBarrier : 多个线程互相等待,直到到达同一个同步点,再继续一起执行。)\n需要结合上面的代码示例,CyclicBarrier示例是这个意思\n对于 CountDownLatch 来说,重点是“一个线程(多个线程)等待”,而其他的 N 个线程在完成“某件事情”之后,可以终止,也可以等待。【强调的是某个(组)等另一组线程完成】\n而对于 CyclicBarrier,重点是多个线程,在任意一个线程没有完成,所有的线程都必须等待。【强调的是互相】\nCountDownLatch 是计数器,线程完成一个记录一个,只不过计数不是递增而是递减,而 CyclicBarrier 更像是一个阀门,需要所有线程都到达,阀门才能打开,然后继续执行。\nReentrantLock和ReentrantReadWriteLock # 读写锁 ReentrantReadWriteLock 可以保证多个线程可以同时读,所以在读操作远大于写操作的时候,读写锁就非常有用了。\n"},{"id":322,"href":"/zh/docs/technology/Review/java_guide/java/Concurrent/ly0307lyconcurrent-collections/","title":"java常见并发容器","section":"并发","content":" 转载自https://github.com/Snailclimb/JavaGuide (添加小部分笔记)感谢作者!\nJDK提供的容器,大部分在java.util.concurrent包中\nConcurrentHashMap:线程安全的HashMap CopyOnWriteArrayList:线程安全的List,在读多写少的场合性能非常好,远好于Vector ConcurrentLinkedQueue:高效的并发队列,使用链表实现,可以看作一个线程安全的LinkedList,是一个非阻塞队列 BlockingQueue:这是一个接口,JDK内部通过链表、数组等方式实现了该接口。表示阻塞队列,非常适合用于作为数据共享的通道 ConcorrentSkipListMap:跳表的实现,是一个Map,使用跳表的数据结构进行快速查找 ConcurrentHashMap # HashMap是线程不安全的,并发场景下要保证线程安全,可以使用Collections.synchronizedMap()方法来包装HashMap,但这是通过使用一个全局的锁来同步不同线程间的并发访问,因此会带来性能问题 建议使用ConcurrentHashMap,不论是读操作还是写操作都能保证高性能:读操作(几乎)不需要加锁,而写操作时通过锁分段(这里说的是JDK1.7?)技术,只对所操作的段加锁而不影响客户端对其他段的访问 CopyOnWriteArrayList # //源码 public class CopyOnWriteArrayList\u0026lt;E\u0026gt; extends Object implements List\u0026lt;E\u0026gt;, RandomAccess, Cloneable, Serializable 在很多应用场景中,读操作可能会远远大于写操作 我们应该允许多个线程同时访问List内部数据(针对读) 与ReentrantReadWriteLock读写锁思想非常类似,即读读共享、写写互斥、读写互斥、写读互斥 不一样的是,CopyOnWriteArrayList读取时完全不需要加锁,且写入也不会阻塞读取操作,只有写入和写入之间需要同步等待。 CopyOnWriteArrayList是如何做到的 # CopyOnWriteArrayList 类的所有可变操作(add,set 等等)都是通过创建底层数组的新副本来实现的。当 List 需要被修改的时候,并不修改原有内容,而是对原有数据进行一次复制,将修改的内容写入副本。写完之后,再将修改完的副本替换原来的数据,这样就可以保证写操作不会影响读操作了。 从 CopyOnWriteArrayList 的名字就能看出 CopyOnWriteArrayList 是满足 CopyOnWrite 的 在计算机,如果你想要对一块内存进行修改时,我们不在原有内存块中进行写操作,而是将内存拷贝一份,在新的内存中进行写操作,写完之后呢,就将指向原来内存指针指向新的内存(注意,是指向,而不是重新拷贝★重要★),原来的内存就可以被回收掉了 CopyOnWriteArrayList 读取和写入源码简单分析 # CopyOnWriteArrayList读取操作的实现 读取操作没有任何同步控制和锁操作,理由就是内部数组array不会发生修改,只会被另一个array替换,因此可以保证数据安全\n/** The array, accessed only via getArray/setArray. */ private transient volatile Object[] array; public E get(int index) { return get(getArray(), index); } @SuppressWarnings(\u0026#34;unchecked\u0026#34;) private E get(Object[] a, int index) { return (E) a[index]; } final Object[] getArray() { return array; } CopyOnWriteArrayList写入操作的实现 在添加集合的时候加了锁,保证同步,避免多线程写的时候会copy出多个副本\n/** * Appends the specified element to the end of this list. * * @param e element to be appended to this list * @return {@code true} (as specified by {@link Collection#add}) */ 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();//释放锁 } } ConcurrentLinkedQueue # Java提供的线程安全的Queue分为阻塞队列和非阻塞队列 阻塞队列的典型例子是BlockingQueue,非阻塞队列的典型例子是ConcurrentLinkedQueue 阻塞队列通过锁来实现,非阻塞队列通过CAS实现 ConcurrentLinkedQueue使用链表作为数据结构,是高并发环境中性能最好的队列 ConcurrentLinkedQueue 适合在对性能要求相对较高,同时对队列的读写存在多个线程同时进行的场景,即如果对队列加锁的成本较高则适合使用无锁的 ConcurrentLinkedQueue,即CAS 来替代 BlockingQueue # 阻塞队列(BlockingQueue)被广泛使用在“生产者-消费者”问题中,其原因是 BlockingQueue 提供了可阻塞的插入和移除的方法。当队列容器已满,生产者线程会被阻塞,直到队列未满;当队列容器为空时,消费者线程会被阻塞,直至队列非空时为止\nBlockingQueue是一个接口,继承自Queue,而Queue又继承自Collection接口,下面是BlockingQueue的相关实现类:\n代码例子(主要是**put()和take()**两个方法):\npublic class TestBlockingQueue { public static void main(String[] args) { BlockingQueue\u0026lt;String\u0026gt; blockingQueue = new ArrayBlockingQueue\u0026lt;\u0026gt;(2); for (int i = 10; i \u0026lt; 20; i++) { int finalI = i; new Thread(() -\u0026gt; { try { TimeUnit.SECONDS.sleep(finalI); } catch (InterruptedException e) { e.printStackTrace(); } try { blockingQueue.put(finalI + \u0026#34;\u0026#34;); } catch (InterruptedException e) { e.printStackTrace(); } System.out.println(Thread.currentThread() .getName() + \u0026#34;放入了元素[\u0026#34; + finalI + \u0026#34;\u0026#34;); }, \u0026#34;线程\u0026#34; + i).start(); } for (int i = 20; i \u0026lt; 30; i++) { int finalI = i; new Thread(() -\u0026gt; { try { TimeUnit.SECONDS.sleep(finalI); } catch (InterruptedException e) { e.printStackTrace(); } String remove = null; try { remove = blockingQueue.take(); } catch (InterruptedException e) { e.printStackTrace(); } System.out.println(Thread.currentThread() .getName() + \u0026#34;取出了元素[\u0026#34; + remove + \u0026#34;\u0026#34;); }, \u0026#34;线程\u0026#34; + i).start(); } } } /* 由下可以知道,放入了两个元素之后,需要等待取出后,才能继续放入 线程10放入了元素[10 线程11放入了元素[11 ----\u0026gt; 之后这里发生了停顿 线程20取出了元素[10 线程12放入了元素[12 线程21取出了元素[11 线程13放入了元素[13 线程22取出了元素[12 线程14放入了元素[14 线程23取出了元素[13 线程15放入了元素[15 线程24取出了元素[14 线程16放入了元素[16 线程25取出了元素[15 线程17放入了元素[17 线程26取出了元素[16 线程18放入了元素[18 线程27取出了元素[17 线程19放入了元素[19 线程28取出了元素[18 线程29取出了元素[19 Process finished with exit code 0 */ ArrayBockingQueue # ArrayBlockingQueue是BlockingQueue接口的有界队列实现类,底层采用数组来实现\npublic class ArrayBlockingQueue\u0026lt;E\u0026gt; extends AbstractQueue\u0026lt;E\u0026gt; implements BlockingQueue\u0026lt;E\u0026gt;, Serializable{} ArrayBlockingQueue 一旦创建,容量不能改变。其并发控制采用可重入锁 ReentrantLock ,不管是插入操作还是读取操作,都需要获取到锁才能进行操作。当队列容量满时,尝试将元素放入队列将导致操作阻塞;尝试从一个空队列中取一个元素也会同样阻塞。\nArrayBlockingQueue 默认情况下不能保证线程访问队列的公平性,所谓公平性是指严格按照线程等待的绝对时间顺序,即最先等待的线程能够最先访问到 ArrayBlockingQueue。而非公平性则是指访问 ArrayBlockingQueue 的顺序不是遵守严格的时间顺序,有可能存在,当 ArrayBlockingQueue 可以被访问时,长时间阻塞的线程依然无法访问到 ArrayBlockingQueue。如果保证公平性,通常会降低吞吐量。如果需要获得公平性的 ArrayBlockingQueue,可采用如下代码(主要是第二个参数)\nprivate static ArrayBlockingQueue\u0026lt;Integer\u0026gt; blockingQueue = new ArrayBlockingQueue\u0026lt;Integer\u0026gt;(10,true); LinkedBlockingQueue # 底层基于单向链表实现阻塞队列,可以当作无界队列也可以当作有界队列\n满足FIFO特性,与ArrayBlockingQueue相比有更高吞吐量,为防止LinkedBlockingQueue容量迅速增加,损耗大量内存,一般创建LinkedBlockingQueue对象时会指定大小****;如果未指定则容量等于Integer.MAX_VALUE\n相关构造方法\n/** *某种意义上的无界队列 * Creates a {@code LinkedBlockingQueue} with a capacity of * {@link Integer#MAX_VALUE}. */ public LinkedBlockingQueue() { this(Integer.MAX_VALUE); } /** *有界队列 * Creates a {@code LinkedBlockingQueue} with the given (fixed) capacity. * * @param capacity the capacity of this queue * @throws IllegalArgumentException if {@code capacity} is not greater * than zero */ public LinkedBlockingQueue(int capacity) { if (capacity \u0026lt;= 0) throw new IllegalArgumentException(); this.capacity = capacity; last = head = new Node\u0026lt;E\u0026gt;(null); } PriorityBlockingQueue # 支持优先级的无界阻塞队列,默认情况元素采用自然顺序进行排序,或通过自定义类实现compareTo()方法指定元素排序,或初始化时通过构造器参数Comparator来指定排序规则 PriorityBlockingQueue 并发控制采用的是可重入锁 ReentrantLock,队列为无界队列(ArrayBlockingQueue 是有界队列,LinkedBlockingQueue 也可以通过在构造函数中传入 capacity 指定队列最大的容量,但是 PriorityBlockingQueue 只能指定初始的队列大小,后面插入元素的时候,如果空间不够的话会自动扩容) 它就是 PriorityQueue 的线程安全版本。不可以插入 null 值,同时,插入队列的对象必须是可比较大小的(comparable),否则报 ClassCastException 异常。它的插入操作 put 方法不会 block(是block 阻塞,不是lock 锁),因为它是无界队列(take 方法在队列为空的时候会阻塞) ConcurrentSkipListMap # 对于一个单链表,即使链表是有序的,如果我们想要在其中查找某个数据,也只能从头到尾遍历链表,这样效率自然就会很低,跳表就不一样了。跳表是一种可以用来快速查找的数据结构,有点类似于平衡树。它们都可以对元素进行快速的查找。但一个重要的区别是:对平衡树的插入和删除往往很可能导致平衡树进行一次全局的调整。而对跳表的插入和删除只需要对整个数据结构的局部进行操作即可。这样带来的好处是:在高并发的情况下,你会需要一个全局锁来保证整个平衡树的线程安全。而对于跳表,你只需要部分锁即可。这样,在高并发环境下,你就可以拥有更好的性能。而就查询的性能而言,跳表的时间复杂度也是 O(logn) 所以在并发数据结构中,JDK 使用跳表来实现一个 Map。\n跳表的本质是维护多个链表,且链表是分层的 最低层的链表维护跳表内所有元素,每上面一层链表都是下面一层的子集 跳表内所有链表的元素都是排序的 查找时,可以从顶级链表开始找。一旦发现被查找的元素大于当前链表中的取值(这里应该加上一句,小于前一个节点,比如下面如果是查找3,那么就从1跳下去),就会转入下一层链表继续找。这也就是说在查找过程中,搜索是跳跃式的。如上图所示,在跳表中查找元素 18。 查找 18 的时候原来需要遍历 18 次,现在只需要 7 次即可。针对链表长度比较大的时候,构建索引查找效率的提升就会非常明显 (这里好像不太对,原来也不需要遍历18次,反正大概率是说效率高就是了)\n使用跳表实现 Map 和使用哈希算法实现 Map 的另外一个不同之处是:哈希并不会保存元素的顺序,而跳表内所有的元素都是排序的。因此在对跳表进行遍历时,你会得到一个有序的结果。所以,如果你的应用需要有序性,那么跳表就是你不二的选择。JDK 中实现这一数据结构的类是 ConcurrentSkipListMap。\n"},{"id":323,"href":"/zh/docs/technology/Review/java_guide/java/Concurrent/ly0306lythread-pool-best/","title":"线程池最佳实践","section":"并发","content":" 转载自https://github.com/Snailclimb/JavaGuide (添加小部分笔记)感谢作者!\n线程池知识回顾 # 1. 为什么要使用线程池 # 池化技术的思想,主要是为了减少每次获取资源(线程资源)的消耗,提高对资源的利用率 线程池提供了一种限制和管理资源(包括执行一个任务)的方法,每个线程池还维护一些基本统计信息,例如已完成任务的数量 好处:\n降低资源消耗 提高响应速度 提高线程的可管理性 2. 线程池在实际项目的使用场景 # 线程池一般用于执行多个不相关联的耗时任务,没有多线程的情况下,任务顺序执行,使用了线程池的话可让多个不相关联的任务同时执行。\n3. 如何使用线程池 # 一般是通过 ThreadPoolExecutor 的构造函数来创建线程池,然后提交任务给线程池执行就可以了。构造函数如下:\n/** * 用给定的初始参数创建一个新的ThreadPoolExecutor。 */ public ThreadPoolExecutor(int corePoolSize,//线程池的核心线程数量 int maximumPoolSize,//线程池的最大线程数 long keepAliveTime,//当线程数大于核心线程数时,多余的空闲线程存活的最长时间 TimeUnit unit,//时间单位 BlockingQueue\u0026lt;Runnable\u0026gt; workQueue,//任务队列,用来储存等待执行任务的队列 ThreadFactory threadFactory,//线程工厂,用来创建线程,一般默认即可 RejectedExecutionHandler handler//拒绝策略,当提交的任务过多而不能及时处理时,我们可以定制策略来处理任务 ) { if (corePoolSize \u0026lt; 0 || maximumPoolSize \u0026lt;= 0 || maximumPoolSize \u0026lt; corePoolSize || keepAliveTime \u0026lt; 0) throw new IllegalArgumentException(); if (workQueue == null || threadFactory == null || handler == null) throw new NullPointerException(); this.corePoolSize = corePoolSize; this.maximumPoolSize = maximumPoolSize; this.workQueue = workQueue; this.keepAliveTime = unit.toNanos(keepAliveTime); this.threadFactory = threadFactory; this.handler = handler; } 使用代码:\nprivate static final int CORE_POOL_SIZE = 5; private static final int MAX_POOL_SIZE = 10; private static final int QUEUE_CAPACITY = 100; private static final Long KEEP_ALIVE_TIME = 1L; public static void main(String[] args) { //使用阿里巴巴推荐的创建线程池的方式 //通过ThreadPoolExecutor构造函数自定义参数创建 ThreadPoolExecutor executor = new ThreadPoolExecutor( CORE_POOL_SIZE, MAX_POOL_SIZE, KEEP_ALIVE_TIME, TimeUnit.SECONDS, new ArrayBlockingQueue\u0026lt;\u0026gt;(QUEUE_CAPACITY), new ThreadPoolExecutor.CallerRunsPolicy()); for (int i = 0; i \u0026lt; 10; i++) { executor.execute(() -\u0026gt; { try { Thread.sleep(2000); } catch (InterruptedException e) { e.printStackTrace(); } System.out.println(\u0026#34;CurrentThread name:\u0026#34; + Thread.currentThread().getName() + \u0026#34;date:\u0026#34; + Instant.now()); }); } //终止线程池 executor.shutdown(); try { /* awaitTermination()方法的作用: 当前线程阻塞,直到 1. 等所有已提交的任务(包括正在跑的和队列中等待的)执行完 2. 或者等超时时间到 3. 或者线程被中断,抛出InterruptedException 然后返回true(shutdown请求后所有任务执行完毕)或false(已超时) */ executor.awaitTermination(5, TimeUnit.SECONDS); } catch (InterruptedException e) { e.printStackTrace(); } System.out.println(\u0026#34;Finished all threads\u0026#34;); } /*输出 CurrentThread name:pool-1-thread-5date:2020-06-06T11:45:31.639Z CurrentThread name:pool-1-thread-3date:2020-06-06T11:45:31.639Z CurrentThread name:pool-1-thread-1date:2020-06-06T11:45:31.636Z CurrentThread name:pool-1-thread-4date:2020-06-06T11:45:31.639Z CurrentThread name:pool-1-thread-2date:2020-06-06T11:45:31.639Z CurrentThread name:pool-1-thread-2date:2020-06-06T11:45:33.656Z CurrentThread name:pool-1-thread-4date:2020-06-06T11:45:33.656Z CurrentThread name:pool-1-thread-1date:2020-06-06T11:45:33.656Z CurrentThread name:pool-1-thread-3date:2020-06-06T11:45:33.656Z CurrentThread name:pool-1-thread-5date:2020-06-06T11:45:33.656Z Finished all threads */ 线程池最佳实践 # 1. 使用ThreadPoolExecutor的构造函数声明线程池 # 线程池必须手动通过 ThreadPoolExecutor 的构造函数来声明,避免使用Executors 类的 newFixedThreadPool 和 newCachedThreadPool ,因为可能会有 OOM 的风险。\nFixedThreadPool 和 SingleThreadExecutor : 允许请求的队列长度为 Integer.MAX_VALUE,可能堆积大量的请求,从而导致 OOM。\nCachedThreadPool 和 ScheduledThreadPool : 允许创建的线程数量为 Integer.MAX_VALUE ,可能会创建大量线程,从而导致 OOM。\n总结:使用有界队列,控制线程创建数量\n其他原因:\n实际中要根据自己机器的性能、业务场景来手动配置线程池参数,比如核心线程数、使用的任务队列、饱和策略 给线程池命名,方便定位问题 2. 监测线程池运行状态 # 可以通过一些手段检测线程池运行状态,比如SpringBoot中的Actuator组件\n或者利用ThreadPoolExecutor相关的API做简陋监控,ThreadPoolExecutor提供了获取线程池当前的线程数和活跃线程数、已经执行完成的任务数,正在排队中的任务数等\n简单的demo,使用ScheduleExecutorService定时打印线程池信息\n/** * 打印线程池的状态 * * @param threadPool 线程池对象 */ public static void printThreadPoolStatus(ThreadPoolExecutor threadPool) { ScheduledExecutorService scheduledExecutorService = new ScheduledThreadPoolExecutor(1, createThreadFactory(\u0026#34;print-images/thread-pool-status\u0026#34;, false)); scheduledExecutorService.scheduleAtFixedRate(() -\u0026gt; { log.info(\u0026#34;=========================\u0026#34;); log.info(\u0026#34;ThreadPool Size: [{}]\u0026#34;, threadPool.getPoolSize()); log.info(\u0026#34;Active Threads: {}\u0026#34;, threadPool.getActiveCount()); log.info(\u0026#34;Number of Tasks : {}\u0026#34;, threadPool.getCompletedTaskCount()); log.info(\u0026#34;Number of Tasks in Queue: {}\u0026#34;, threadPool.getQueue().size()); log.info(\u0026#34;=========================\u0026#34;); }, 0, 1, TimeUnit.SECONDS); } 3. 建议不同类别的业务用不同的线程池 # 建议是不同的业务使用不同的线程池,配置线程池的时候根据当前业务的情况对当前线程池进行配置,因为不同的业务的并发以及对资源的使用情况都不同,重心优化系统性能瓶颈相关的业务\n极端情况导致死锁:\n假如我们线程池的核心线程数为 n,父任务(扣费任务)数量为 n,父任务下面有两个子任务(扣费任务下的子任务),其中一个已经执行完成,另外一个被放在了任务队列中。由于父任务把线程池核心线程资源用完,所以子任务因为无法获取到线程资源无法正常执行,一直被阻塞在队列中。父任务等待子任务执行完成,而子任务等待父任务释放线程池资源,这也就造成了 \u0026ldquo;死锁\u0026rdquo;。\n4. 别忘记给线程池命名 # 初始化线程池时显示命名(设置线程池名称前缀),有利于定位问题\n利用guava的ThreadFactoryBuilder\nThreadFactory threadFactory = new ThreadFactoryBuilder() .setNameFormat(threadNamePrefix + \u0026#34;-%d\u0026#34;) .setDaemon(true).build(); ExecutorService threadPool = new ThreadPoolExecutor(corePoolSize, maximumPoolSize, keepAliveTime, TimeUnit.MINUTES, workQueue, threadFactory) 自己实现ThreadFactor\nimport java.util.concurrent.Executors; import java.util.concurrent.ThreadFactory; import java.util.concurrent.atomic.AtomicInteger; /** * 线程工厂,它设置线程名称,有利于我们定位问题。 */ public final class NamingThreadFactory implements ThreadFactory { private final AtomicInteger threadNum = new AtomicInteger(); private final ThreadFactory delegate; private final String name; /** * 创建一个带名字的线程池生产工厂 */ public NamingThreadFactory(ThreadFactory delegate, String name) { this.delegate = delegate; this.name = name; // TODO consider uniquifying this } @Override public Thread newThread(Runnable r) { Thread t = delegate.newThread(r); t.setName(name + \u0026#34; [#\u0026#34; + threadNum.incrementAndGet() + \u0026#34;]\u0026#34;); return t; } } 5. 正确配置线程池参数 # 如果线程池中的线程太多,就会增加上下文切换的成本\n多线程编程中一般线程的个数都大于 CPU 核心的个数,而一个 CPU 核心在任意时刻只能被一个线程使用,为了让这些线程都能得到有效执行,CPU 采取的策略是为每个线程分配时间片并轮转的形式。当一个线程的时间片用完的时候就会重新处于就绪状态让给其他线程使用,这个过程就属于一次上下文切换。概括来说就是:当前任务在执行完 CPU 时间片切换到另一个任务之前会先保存自己的状态,以便下次再切换回这个任务时,可以再加载这个任务的状态。任务从保存到再加载的过程就是一次上下文切换。\n上下文切换通常是计算密集型的。也就是说,它需要相当可观的处理器时间,在每秒几十上百次的切换中,每次切换都需要纳秒量级的时间。所以,上下文切换对系统来说意味着消耗大量的 CPU 时间,事实上,可能是操作系统中时间消耗最大的操作。\nLinux 相比与其他操作系统(包括其他类 Unix 系统)有很多的优点,其中有一项就是,其上下文切换和模式切换的时间消耗非常少。\n过大跟过小都不行\n如果我们设置的线程池数量太小的话,如果同一时间有大量任务/请求需要处理,可能会导致大量的请求/任务在任务队列中排队等待执行,甚至会出现任务队列满了之后任务/请求无法处理的情况,或者大量任务堆积在任务队列导致 OOM 设置线程数量太大,大量线程可能会同时在争取 CPU 资源,这样会导致大量的上下文切换,从而增加线程的执行时间,影响了整体执行效率 简单且适用面较广的公式\nCPU 密集型任务(N+1): 这种任务消耗的主要是 CPU 资源,可以将线程数设置为 N(CPU 核心数)+1,比 CPU 核心数多出来的一个线程是为了防止线程偶发的缺页中断,或者其它原因导致的任务暂停而带来的影响。一旦任务暂停,CPU 就会处于空闲状态,而在这种情况下多出来的一个线程就可以充分利用 CPU 的空闲时间。\nI/O 密集型任务(2N): 这种任务应用起来,系统会用大部分的时间来处理 I/O 交互,而线程在处理 I/O 的时间段内不会占用 CPU 来处理,这时就可以将 CPU 交出给其它线程使用。因此在 I/O 密集型任务的应用中,我们可以多配置一些线程,具体的计算方法是 2N。\n如何判断是CPU密集任务还是IO密集任务\nCPU 密集型简单理解就是利用 CPU 计算能力的任务比如你在内存中对大量数据进行排序。但凡涉及到网络读取,文件读取这类都是 IO 密集型,这类任务的特点是 CPU 计算耗费时间相比于等待 IO 操作完成的时间来说很少,大部分时间都花在了等待 IO 操作完成上。\n美团线程池的处理 主要对线程池的核心参数实现自定义可配置\ncorePoolSize : 核心线程数线程数定义了最小可以同时运行的线程数量。 maximumPoolSize : 当队列中存放的任务达到队列容量的时候,当前可以同时运行的线程数量变为最大线程数。 workQueue: 当新任务来的时候会先判断当前运行的线程数量是否达到核心线程数,如果达到的话,新任务就会被存放在队列中 参数动态配置 格外需要注意的是corePoolSize, 程序运行期间的时候,我们调用 setCorePoolSize()这个方法的话,线程池会首先判断当前工作线程数是否大于corePoolSize,如果大于的话就会回收工作线程。【ThreadPoolExecutor里面的】 另外,你也看到了上面并没有动态指定队列长度的方法,美团的方式是自定义了一个叫做 ResizableCapacityLinkedBlockIngQueue 的队列(主要就是把LinkedBlockingQueue的 capacity 字段的 final 关键字修饰给去掉了,让它变为可变的) "},{"id":324,"href":"/zh/docs/technology/Review/java_guide/java/Concurrent/ly0305lyjava-thread-pool/","title":"java线程池详解","section":"并发","content":" 转载自https://github.com/Snailclimb/JavaGuide (添加小部分笔记)感谢作者!\n一 使用线程池的好处 # 池化技术:减少每次获取资源的消耗,提高对资源的利用率 线程池提供一种限制和管理资源(包括执行一个任务)的方式,每个线程池还维护一些基本统计信息,例如已完成任务的数量 线程池的好处 降低资源消耗(重复利用,降低线程创建和销毁造成的消耗) 提高响应速度(任务到达直接执行,无需等待线程创建) 提高线程可管理性(避免无休止创建,使用线程池统一分配、调优、监控) 二 Executor框架 # Java5之后,通过Executor启动线程,比使用Thread的start方法更好,更易于管理,效率高,还能有助于避免this逃逸的问题\nthis逃逸,指的是构造函数返回之前,其他线程就持有该对象的引用,会导致调用尚未构造完全的对象\n例子:\npublic class ThisEscape { public ThisEscape() { new Thread(new EscapeRunnable()).start(); // ... } private class EscapeRunnable implements Runnable { @Override public void run() { // 通过ThisEscape.this就可以引用外围类对象, 但是此时外围类对象可能还没有构造完成, 即发生了外围类的this引用的逃逸 } } } 处理办法 //不要在构造函数中运行线程\npublic class ThisEscape { private Thread t; public ThisEscape() { t = new Thread(new EscapeRunnable()); // ... } public void init() { //也就是说对象没有构造完成前,不要调用ThisEscape.this即可 t.start(); } private class EscapeRunnable implements Runnable { @Override public void run() { // 通过ThisEscape.this就可以引用外围类对象, 此时可以保证外围类对象已经构造完成 } } } Executor框架不仅包括线程池的管理,提供线程工厂、队列以及拒绝策略。\nExecutor框架结构 # 主要是三大部分:任务(Runnable/Callable),任务的执行(Executor),异步计算的结果Future\n任务 执行的任务需要的Runnable/Callable接口,他们的实现类,都可以被ThreadPoolExecutor或ScheduleThreadPoolExecutor执行\n任务的执行 我们更多关注的,是ThreadPoolExecutor类。另外,ScheduledThreadPoolExecutor类,继承了ThreadPoolExecutor类,并实现了ScheduledExecutorService接口\n//ThreadPoolExecutor类描述 //AbstractExecutorService实现了ExecutorService接口 public class ThreadPoolExecutor extends AbstractExecutorService{} //ScheduledThreadPoolExecutor类描述 //ScheduledExecutorService继承ExecutorService接口 public class ScheduledThreadPoolExecutor extends ThreadPoolExecutor implements ScheduledExecutorService {} 异步计算的结果 Future接口以及其实现类FutueTask类都可以代表异步计算的结果(下面就是Future接口) 当我们把Runnable接口(结果为null)或Callable接口的实现类提交给ThreadPoolExecutor或ScheduledThreadPoolExecutor执行()\nExecutorService executorService = Executors.newCachedThreadPool(); Callable\u0026lt;MyClass\u0026gt; myClassCallable = new Callable\u0026lt;MyClass\u0026gt;() { public MyClass call() throws Exception { MyClass myClass1 = new MyClass(); myClass1.setName(\u0026#34;ly-callable-测试\u0026#34;); TimeUnit.SECONDS.sleep(2); return myClass1; } }; Future\u0026lt;?\u0026gt; submit = executorService.submit(myClassCallable); //这里会阻塞 Object o = submit.get(); log.info(\u0026#34;ly-callable-打印结果1:\u0026#34; + o); FutureTask\u0026lt;MyClass\u0026gt; futureTask = new FutureTask\u0026lt;\u0026gt;(() -\u0026gt; { MyClass myClass1 = new MyClass(); myClass1.setName(\u0026#34;ly-FutureTask-测试\u0026#34;); TimeUnit.SECONDS.sleep(2); return myClass1; }); Future\u0026lt;?\u0026gt; submit2 = executorService.submit(futureTask); //这里会阻塞 Object o2 = submit2.get(); log.info(\u0026#34;ly-callable-打印结果2:\u0026#34; + o2); executorService.shutdown(); /*结果 2022-11-09 10:19:10 上午 [Thread: main] INFO:ly-callable-打印结果1:MyClass(name=ly-callable-测试) 2022-11-09 10:19:12 上午 [Thread: main] INFO:ly-callable-打印结果2:null */ Executor框架的使用示意图 # 主线程首先要创建实现 Runnable 或者 Callable 接口的任务对象。\n把创建完成的实现 Runnable/Callable接口的 对象直接交给 ExecutorService 执行: ExecutorService.execute(Runnable command))或者也可以把 Runnable 对象或Callable 对象提交给 ExecutorService 执行(ExecutorService.submit(Runnable task)或 ExecutorService.submit(Callable \u0026lt;T\u0026gt; task))。\n如果执行 ExecutorService.submit(…),ExecutorService 将返回一个实现Future接口的对象(我们刚刚也提到过了执行 execute()方法和 submit()方法的区别,submit()会返回一个 FutureTask 对象)。由于 FutureTask 实现了 Runnable,我们也可以创建 FutureTask,然后直接交给 ExecutorService 执行(FutureTask实现了Runnable,不是一个Callable 所以直接使用future.get()获取的是null)。\npublic class MyMain { private byte[] x = new byte[10 * 1024 * 1024];//10M public static void main(String[] args) throws Exception { Callable\u0026lt;Object\u0026gt; abc = Executors.callable(() -\u0026gt; { try { TimeUnit.SECONDS.sleep(2); } catch (InterruptedException e) { e.printStackTrace(); } System.out.println(\u0026#34;aaa\u0026#34;);//输出aaa }, \u0026#34;abcccc\u0026#34;);//如果没有\u0026#34;abcccc\u0026#34;,则下面输出null FutureTask\u0026lt;Object\u0026gt; futureTask = new FutureTask\u0026lt;\u0026gt;(abc); /*new Thread(futureTask).start(); Object o = futureTask.get(); System.out.println(\u0026#34;获取值:\u0026#34;+o); //输出abc */ ExecutorService executorService = Executors.newSingleThreadExecutor(); Future\u0026lt;?\u0026gt; future = executorService.submit(futureTask); Future\u0026lt;?\u0026gt; future1 = executorService.submit(new Callable\u0026lt;String\u0026gt;() { @Override public String call() throws Exception { try { TimeUnit.SECONDS.sleep(2); } catch (InterruptedException e) { e.printStackTrace(); } return \u0026#34;hello\u0026#34;; } }); /*System.out.println(future.get());//输出null*/ System.out.println(future1.get()); //输出hello //System.out.println(futureTask.get());//输出abcccc System.out.println(\u0026#34;阻塞结束\u0026#34;); executorService.shutdown(); } } 最后,主线程可以执行 FutureTask.get()方法来等待任务执行完成。主线程也可以执行 FutureTask.cancel(boolean mayInterruptIfRunning)来取消此任务的执行。\n三 (重要)ThreadPoolExecutor类简单介绍 # 线程池实现类 ThreadPoolExecutor 是 Executor 框架最核心的类。\nThreadPoolExecutor类分析 # 这里看最长的那个,其余三个都是在该构造方法的基础上产生,即给定某些默认参数的构造方法,比如默认的拒绝策略\n/** * 用给定的初始参数创建一个新的ThreadPoolExecutor。 */ public ThreadPoolExecutor(int corePoolSize,//线程池的核心线程数量 int maximumPoolSize,//线程池的最大线程数 long keepAliveTime,//当线程数大于核心线程数时,多余的空闲线程存活的最长时间 TimeUnit unit,//时间单位 BlockingQueue\u0026lt;Runnable\u0026gt; workQueue,//任务队列,用来储存等待执行任务的队列 ThreadFactory threadFactory,//线程工厂,用来创建线程,一般默认即可 RejectedExecutionHandler handler//拒绝策略,当提交的任务过多而不能及时处理时,我们可以定制策略来处理任务 ) { if (corePoolSize \u0026lt; 0 || maximumPoolSize \u0026lt;= 0 || maximumPoolSize \u0026lt; corePoolSize || keepAliveTime \u0026lt; 0) throw new IllegalArgumentException(); if (workQueue == null || threadFactory == null || handler == null) throw new NullPointerException(); this.corePoolSize = corePoolSize; this.maximumPoolSize = maximumPoolSize; this.workQueue = workQueue; this.keepAliveTime = unit.toNanos(keepAliveTime); this.threadFactory = threadFactory; this.handler = handler; } ThreadPoolExecutor中,3个最重要的参数\ncorePoolSize:核心线程数,定义了最小可以同时运行的线程数量 maximumPoolSize:当队列中存放的任务达到队列容量时,当前可以同时运行的线程数量变为最大线程数 workQueue:当新任务来的时候,会先判断当前运行的线程数量是否达到核心线程数,如果达到的话,新任务就会被存放在队列中 ThreadPoolExecutor其他常见参数\nkeepAliveTime:当线程池中的线程数量大于corePoolSize时,如果此时没有新任务提交,核心线程外的线程不会立即销毁,而是会等待,直到等待时间超过了keepAliveTime才会被回收销毁 unit:keepAliveTime参数的时间单位 threadFactory:executor创建新线程的时候会用到 handler:饱和策略 线程池各个参数的相互关系的理解\nThreadPoolExecutor饱和策略定义 如果当前同时运行的线程数量达到最大线程数量并且队列也已经被放满了任务时,ThreadPoolTaskExecutor定义了一些策略:\nThreadPoolExecutor.AbortPolicy :抛出 RejectedExecutionException来拒绝新任务的处理。 ThreadPoolExecutor.CallerRunsPolicy :调用执行自己的线程运行任务,也就是直接在调用execute方法的线程中运行(run)被拒绝的任务,如果执行程序已关闭,则会丢弃该任务。因此这种策略会降低对于新任务提交速度,影响程序的整体性能。如果您的应用程序可以承受此延迟并且你要求任何一个任务请求都要被执行的话,你可以选择这个策略。 ThreadPoolExecutor.DiscardPolicy :不处理新任务,直接丢弃掉。 ThreadPoolExecutor.DiscardOldestPolicy : 此策略将丢弃最早的未处理的任务请求。 举例: Spring 通过 ThreadPoolTaskExecutor 或者我们直接通过 ThreadPoolExecutor 的构造函数创建线程池的时候,当我们不指定 RejectedExecutionHandler 饱和策略的话来配置线程池的时候默认使用的是 ThreadPoolExecutor.AbortPolicy。在默认情况下,ThreadPoolExecutor 将抛出 RejectedExecutionException 来拒绝新来的任务 ,这代表你将丢失对这个任务的处理。 对于可伸缩的应用程序,建议使用 ThreadPoolExecutor.CallerRunsPolicy。当最大池被填满时,此策略为我们提供可伸缩队列。(这个直接查看 ThreadPoolExecutor 的构造函数源码就可以看出,比较简单的原因,这里就不贴代码了。\n推荐使用 ThreadPoolExecutor 构造函数创建线程池 # 阿里巴巴Java开发手册\u0026quot;并发处理\u0026quot;这一章节,明确指出,线程资源必须通过线程池提供,不允许在应用中自行显示创建线程\n原因:使用线程池的好处是减少在创建和销毁线程上所消耗的时间以及系统资源开销,解决资源不足的问题。如果不使用线程池,有可能会造成系统创建大量同类线程而导致消耗完内存或者“过度切换”的问题。也不允许使用Executors去创建,而是通过ThreadPoolExecutor构造方式\nExecutors返回线程池对象的弊端:\nFixedThreadPool和SingleThreadExecutor:允许请求的队列长度为Integer.MAV_VALUE 可能堆积大量请求,导致OOM CachedThreadPool和ScheduledThreadPool,允许创建的线程数量为Integer.MAX_VALUE 可能创建大量线程,从而导致OOM 创建线程的几种方法\n通过ThreadPoolExecutor构造函数实现(推荐) 通过Executors框架的工具类Executors来实现,我们可以创建三红类型的ThreadPoolExecutor FixedThreadPool、SingleThreadExecutor、CachedThreadPool 四 ThreadPoolExecutor使用+原理分析 # 示例代码:Runnable+ThreadPoolExecutor # 先创建一个Runnable接口的实现类\n//MyRunnable.java import java.util.Date; /** * 这是一个简单的Runnable类,需要大约5秒钟来执行其任务。 * @author shuang.kou */ public class MyRunnable implements Runnable { private String command; public MyRunnable(String s) { this.command = s; } @Override public void run() { System.out.println(Thread.currentThread().getName() + \u0026#34; Start. Time = \u0026#34; + new Date()); processCommand(); System.out.println(Thread.currentThread().getName() + \u0026#34; End. Time = \u0026#34; + new Date()); } private void processCommand() { try { Thread.sleep(5000); } catch (InterruptedException e) { e.printStackTrace(); } } @Override public String toString() { return this.command; } } 使用自定义的线程池\nimport java.util.concurrent.ArrayBlockingQueue; import java.util.concurrent.ThreadPoolExecutor; import java.util.concurrent.TimeUnit; public class ThreadPoolExecutorDemo { private static final int CORE_POOL_SIZE = 5; private static final int MAX_POOL_SIZE = 10; private static final int QUEUE_CAPACITY = 100; private static final Long KEEP_ALIVE_TIME = 1L; public static void main(String[] args) { //使用阿里巴巴推荐的创建线程池的方式 //通过ThreadPoolExecutor构造函数自定义参数创建 ThreadPoolExecutor executor = new ThreadPoolExecutor( CORE_POOL_SIZE, //5 MAX_POOL_SIZE, //10 KEEP_ALIVE_TIME, //1L TimeUnit.SECONDS, //单位 new ArrayBlockingQueue\u0026lt;\u0026gt;(QUEUE_CAPACITY),//100 new ThreadPoolExecutor.CallerRunsPolicy()); //主线程中运行 for (int i = 0; i \u0026lt; 10; i++) { //创建WorkerThread对象(WorkerThread类实现了Runnable 接口) Runnable worker = new MyRunnable(\u0026#34;\u0026#34; + i); //执行Runnable executor.execute(worker); } //终止线程池 executor.shutdown(); // isTerminated 判断所有提交的任务是否完成(保证之前调用过shutdown方法) while (!executor.isTerminated()) { } System.out.println(\u0026#34;Finished all threads\u0026#34;); } } //结果: /* corePoolSize: 核心线程数为 5。 maximumPoolSize :最大线程数 10 keepAliveTime : 等待时间为 1L。 unit: 等待时间的单位为 TimeUnit.SECONDS。 workQueue:任务队列为 ArrayBlockingQueue,并且容量为 100; handler:饱和策略为 CallerRunsPolicy ---output--- pool-1-thread-3 Start. Time = Sun Apr 12 11:14:37 CST 2020 pool-1-thread-5 Start. Time = Sun Apr 12 11:14:37 CST 2020 pool-1-thread-2 Start. Time = Sun Apr 12 11:14:37 CST 2020 pool-1-thread-1 Start. Time = Sun Apr 12 11:14:37 CST 2020 pool-1-thread-4 Start. Time = Sun Apr 12 11:14:37 CST 2020 pool-1-thread-3 End. Time = Sun Apr 12 11:14:42 CST 2020 pool-1-thread-4 End. Time = Sun Apr 12 11:14:42 CST 2020 pool-1-thread-1 End. Time = Sun Apr 12 11:14:42 CST 2020 pool-1-thread-5 End. Time = Sun Apr 12 11:14:42 CST 2020 pool-1-thread-1 Start. Time = Sun Apr 12 11:14:42 CST 2020 pool-1-thread-2 End. Time = Sun Apr 12 11:14:42 CST 2020 pool-1-thread-5 Start. Time = Sun Apr 12 11:14:42 CST 2020 pool-1-thread-4 Start. Time = Sun Apr 12 11:14:42 CST 2020 pool-1-thread-3 Start. Time = Sun Apr 12 11:14:42 CST 2020 pool-1-thread-2 Start. Time = Sun Apr 12 11:14:42 CST 2020 pool-1-thread-1 End. Time = Sun Apr 12 11:14:47 CST 2020 pool-1-thread-4 End. Time = Sun Apr 12 11:14:47 CST 2020 pool-1-thread-5 End. Time = Sun Apr 12 11:14:47 CST 2020 pool-1-thread-3 End. Time = Sun Apr 12 11:14:47 CST 2020 pool-1-thread-2 End. Time = Sun Apr 12 11:14:47 CST 2020 ------ */ 线程池原理分析 # 如上,线程池首先会先执行 5 个任务,然后这些任务有任务被执行完的话,就会去拿新的任务执行\nexecute方法源码\n// 存放线程池的运行状态 (runState) 和线程池内有效线程的数量 (workerCount) private final AtomicInteger ctl = new AtomicInteger(ctlOf(RUNNING, 0)); private static int workerCountOf(int c) { return c \u0026amp; CAPACITY; } //任务队列 private final BlockingQueue\u0026lt;Runnable\u0026gt; workQueue; public void execute(Runnable command) { // 如果任务为null,则抛出异常。 if (command == null) throw new NullPointerException(); // ctl 中保存的线程池当前的一些状态信息 int c = ctl.get(); // 下面会涉及到 3 步 操作 // 1.首先判断当前线程池中执行的任务数量是否小于 corePoolSize // 如果小于的话,通过addWorker(command, true)新建一个线程,并将任务(command)添加到该线程中;然后,启动该线程从而执行任务。 if (workerCountOf(c) \u0026lt; corePoolSize) { if (addWorker(command, true)) return; c = ctl.get(); } // 2.如果当前执行的任务数量大于等于 corePoolSize 的时候就会走到这里 // 通过 isRunning 方法判断线程池状态,线程池处于 RUNNING 状态并且队列可以加入任务,该任务才会被加入进去 if (isRunning(c) \u0026amp;\u0026amp; workQueue.offer(command)) { int recheck = ctl.get(); // 再次获取线程池状态,如果线程池状态不是 RUNNING 状态就需要从任务队列中移除任务,并尝试判断线程是否全部执行完毕。同时执行拒绝策略。 if (!isRunning(recheck) \u0026amp;\u0026amp; remove(command)) reject(command); // 如果当前线程池为空就新创建一个线程并执行。 else if (workerCountOf(recheck) == 0) addWorker(null, false); } //3. 通过addWorker(command, false)新建一个线程,并将任务(command)添加到该线程中;然后,启动该线程从而执行任务。 //如果addWorker(command, false)执行失败,则通过reject()执行相应的拒绝策略的内容。 else if (!addWorker(command, false)) reject(command); } ------ 图示:\n源码\n// 全局锁,并发操作必备 private final ReentrantLock mainLock = new ReentrantLock(); // 跟踪线程池的最大大小,只有在持有全局锁mainLock的前提下才能访问此集合 private int largestPoolSize; // 工作线程集合,存放线程池中所有的(活跃的)工作线程,只有在持有全局锁mainLock的前提下才能访问此集合 private final HashSet\u0026lt;Worker\u0026gt; workers = new HashSet\u0026lt;\u0026gt;(); //获取线程池状态 private static int runStateOf(int c) { return c \u0026amp; ~CAPACITY; } //判断线程池的状态是否为 Running private static boolean isRunning(int c) { return c \u0026lt; SHUTDOWN; } /** * 添加新的工作线程到线程池 * @param firstTask 要执行 * @param core参数为true的话表示使用线程池的基本大小,为false使用线程池最大大小 * @return 添加成功就返回true否则返回false */ private boolean addWorker(Runnable firstTask, boolean core) { retry: for (;;) { //这两句用来获取线程池的状态 int c = ctl.get(); int rs = runStateOf(c); // Check if queue empty only if necessary. if (rs \u0026gt;= SHUTDOWN \u0026amp;\u0026amp; ! (rs == SHUTDOWN \u0026amp;\u0026amp; firstTask == null \u0026amp;\u0026amp; ! workQueue.isEmpty())) return false; for (;;) { //获取线程池中工作的线程的数量 int wc = workerCountOf(c); // core参数为false的话表明队列也满了,线程池大小变为 maximumPoolSize if (wc \u0026gt;= CAPACITY || wc \u0026gt;= (core ? corePoolSize : maximumPoolSize)) return false; //原子操作将workcount的数量加1 if (compareAndIncrementWorkerCount(c)) break retry; // 如果线程的状态改变了就再次执行上述操作 c = ctl.get(); if (runStateOf(c) != rs) continue retry; // else CAS failed due to workerCount change; retry inner loop } } // 标记工作线程是否启动成功 boolean workerStarted = false; // 标记工作线程是否创建成功 boolean workerAdded = false; Worker w = null; try { w = new Worker(firstTask); final Thread t = w.thread; if (t != null) { // 加锁 final ReentrantLock mainLock = this.mainLock; mainLock.lock(); try { //获取线程池状态 int rs = runStateOf(ctl.get()); //rs \u0026lt; SHUTDOWN 如果线程池状态依然为RUNNING,并且线程的状态是存活的话,就会将工作线程添加到工作线程集合中 //(rs=SHUTDOWN \u0026amp;\u0026amp; firstTask == null)如果线程池状态小于STOP,也就是RUNNING或者SHUTDOWN状态下,同时传入的任务实例firstTask为null,则需要添加到工作线程集合和启动新的Worker // firstTask == null证明只新建线程而不执行任务 if (rs \u0026lt; SHUTDOWN || (rs == SHUTDOWN \u0026amp;\u0026amp; firstTask == null)) { if (t.isAlive()) // precheck that t is startable throw new IllegalThreadStateException(); workers.add(w); //更新当前工作线程的最大容量 int s = workers.size(); if (s \u0026gt; largestPoolSize) largestPoolSize = s; // 工作线程是否启动成功 workerAdded = true; } } finally { // 释放锁 mainLock.unlock(); } //// 如果成功添加工作线程,则调用Worker内部的线程实例t的Thread#start()方法启动真实的线程实例 if (workerAdded) { t.start(); /// 标记线程启动成功 workerStarted = true; } } } finally { // 线程启动失败,需要从工作线程中移除对应的Worker if (! workerStarted) addWorkerFailed(w); } return workerStarted; } ------ 完整源码分析 https://www.throwx.cn/2020/08/23/java-concurrency-thread-pool-executor/\n对于代码中,进行分析:\n我们在代码中模拟了 10 个任务,我们配置的核心线程数为 5 、等待队列容量为 100 ,所以每次只可能存在 5 个任务同时执行,剩下的 5 个任务会被放到等待队列中去。当前的 5 个任务中如果有任务被执行完了,线程池就会去拿新的任务执行。\n几个常见的对比 # Runnable VS Callable Runnable Java 1.0,不会返回结果或抛出检查异常\nCallable Java 1.5 可以\n工具类Executors可以实现,将Runnable对象转换成Callable对象( Executors.callable(Runnable task)或Executors.callable(Runnable task, Object result) )\n//Runnable @FunctionalInterface public interface Runnable { /** * 被线程执行,没有返回值也无法抛出异常 */ public abstract void run(); } ------ //Callable @FunctionalInterface public interface Callable\u0026lt;V\u0026gt; { /** * 计算结果,或在无法这样做时抛出异常。 * @return 计算得出的结果 * @throws 如果无法计算结果,则抛出异常 */ V call() throws Exception; } execute() VS submit()\nexecute()方法用于提交不需要返回值的任务,所以无法判断任务是否被线程池执行成功与否; submit()方法用于提交需要返回值的任务。线程池会返回一个 Future 类型的对象,通过这个 Future 对象可以判断任务是否执行成功,并且可以通过 Future 的 get()方法来获取返回值,get()方法会阻塞当前线程直到任务完成,而使用 get(long timeout,TimeUnit unit)方法的话,如果在 timeout 时间内任务还没有执行完,就会抛出 java.util.concurrent.TimeoutException。 //真实使用,建议使用ThreadPoolExecutor构造方法 ExecutorService executorService = Executors.newFixedThreadPool(3); Future\u0026lt;String\u0026gt; submit = executorService.submit(() -\u0026gt; { try { Thread.sleep(5000L); } catch (InterruptedException e) { e.printStackTrace(); } return \u0026#34;abc\u0026#34;; }); String s = submit.get(); System.out.println(s); executorService.shutdown(); /* abc */ 使用抛异常的方法\nExecutorService executorService = Executors.newFixedThreadPool(3); Future\u0026lt;String\u0026gt; submit = executorService.submit(() -\u0026gt; { try { Thread.sleep(5000L); } catch (InterruptedException e) { e.printStackTrace(); } return \u0026#34;abc\u0026#34;; }); String s = submit.get(3, TimeUnit.SECONDS); System.out.println(s); executorService.shutdown(); /* 控制台输出 Exception in thread \u0026#34;main\u0026#34; java.util.concurrent.TimeoutException at java.util.concurrent.FutureTask.get(FutureTask.java:205) */ shutdown() VS shutdownNow() shutdown() :关闭线程池,线程池的状态变为 SHUTDOWN。线程池不再接受新任务了,但是队列里的任务得执行完毕。 shutdownNow() :关闭线程池,线程的状态变为 STOP。线程池会终止当前正在运行的任务,并停止处理排队的任务并返回正在等待执行的 List。 isTerminated() VS isshutdown()\nisShutDown 当调用 shutdown() 方法后返回为 true。 isTerminated 当调用 shutdown() 方法后,并且所有提交的任务完成后返回为 true callable+ThreadPoolExecutor示例代码 源代码 //MyCallable.java\nimport java.util.ArrayList; import java.util.Date; import java.util.List; import java.util.concurrent.ArrayBlockingQueue; import java.util.concurrent.Callable; import java.util.concurrent.ExecutionException; import java.util.concurrent.Future; import java.util.concurrent.ThreadPoolExecutor; import java.util.concurrent.TimeUnit; public class CallableDemo { private static final int CORE_POOL_SIZE = 5; private static final int MAX_POOL_SIZE = 10; private static final int QUEUE_CAPACITY = 100; private static final Long KEEP_ALIVE_TIME = 1L; public static void main(String[] args) { //使用阿里巴巴推荐的创建线程池的方式 //通过ThreadPoolExecutor构造函数自定义参数创建 ThreadPoolExecutor executor = new ThreadPoolExecutor( CORE_POOL_SIZE, MAX_POOL_SIZE, KEEP_ALIVE_TIME, TimeUnit.SECONDS, new ArrayBlockingQueue\u0026lt;\u0026gt;(QUEUE_CAPACITY), new ThreadPoolExecutor.CallerRunsPolicy()); List\u0026lt;Future\u0026lt;String\u0026gt;\u0026gt; futureList = new ArrayList\u0026lt;\u0026gt;(); Callable\u0026lt;String\u0026gt; callable = new MyCallable(); for (int i = 0; i \u0026lt; 10; i++) { //提交任务到线程池 Future\u0026lt;String\u0026gt; future = executor.submit(callable); //将返回值 future 添加到 list,我们可以通过 future 获得 执行 Callable 得到的返回值 futureList.add(future); } for (Future\u0026lt;String\u0026gt; fut : futureList) { try { System.out.println(new Date() + \u0026#34;::\u0026#34; + fut.get()); } catch (InterruptedException | ExecutionException e) { e.printStackTrace(); } } //关闭线程池 executor.shutdown(); } } /* 运行结果 Wed Nov 13 13:40:41 CST 2019::pool-1-thread-1 Wed Nov 13 13:40:42 CST 2019::pool-1-thread-2 Wed Nov 13 13:40:42 CST 2019::pool-1-thread-3 Wed Nov 13 13:40:42 CST 2019::pool-1-thread-4 Wed Nov 13 13:40:42 CST 2019::pool-1-thread-5 Wed Nov 13 13:40:42 CST 2019::pool-1-thread-3 Wed Nov 13 13:40:43 CST 2019::pool-1-thread-2 Wed Nov 13 13:40:43 CST 2019::pool-1-thread-1 Wed Nov 13 13:40:43 CST 2019::pool-1-thread-4 Wed Nov 13 13:40:43 CST 2019::pool-1-thread-5 ------ */ 几种常见的线程池详解 # FixedThreadPool 称之为可重用固定线程数的线程池,Executors类中源码:\n/** * 创建一个可重用固定数量线程的线程池 */ public static ExecutorService newFixedThreadPool(int nThreads, ThreadFactory threadFactory) { return new ThreadPoolExecutor(nThreads, nThreads, 0L, TimeUnit.MILLISECONDS, new LinkedBlockingQueue\u0026lt;Runnable\u0026gt;(), threadFactory); } //================或================ public static ExecutorService newFixedThreadPool(int nThreads) { return new ThreadPoolExecutor(nThreads, nThreads, 0L, TimeUnit.MILLISECONDS, new LinkedBlockingQueue\u0026lt;Runnable\u0026gt;()); } 如上得知,新创建的FixedThreadPool的corePoolSize和maximumPoolSize都被设置为nThreads\n执行任务过程介绍 FixedThreadPool的execute()方法运行示意图\n上图分析 如果当前运行的线程数小于 corePoolSize, 如果再来新任务的话,就创建新的线程来执行任务; 当前运行的线程数等于 corePoolSize 后, 如果再来新任务的话,会将任务加入 LinkedBlockingQueue; 线程池中的线程执行完 手头的任务后,会在循环中反复从 LinkedBlockingQueue 中获取任务来执行; 为什么不推荐使用FixedThreadPool 主要原因,FixedThreadPool使用无界队列LinkedBlockingQueue(队列容量为Integer.MAX_VALUE)作为线程池的工作队列 线程池的线程数达到corePoolSize后,新任务在无界队列中等待,因此线程池中线程数不超过corePoolSize 由于使用无界队列时 maximumPoolSize 将是一个无效参数,因为不可能存在任务队列满的情况。所以,【不需要空闲线程,因为corePool,然后Queue,最后才是空闲线程】通过创建 FixedThreadPool的源码可以看出创建的 FixedThreadPool 的 corePoolSize 和 maximumPoolSize 被设置为同一个值。 又由于1、2原因,使用无界队列时,keepAliveTime将是无效参数 运行中的FixedThreadPool(如果未执行shutdown()或shutdownNow())则不会拒绝任务,因此在任务较多时会导致OOM(内存溢出,Out Of Memory) SingleThreadExecutor\nSingleThreadExecutor是只有一个线程的线程池,源码:\n/** *返回只有一个线程的线程池 */ public static ExecutorService newSingleThreadExecutor(ThreadFactory threadFactory) { return new FinalizableDelegatedExecutorService (new ThreadPoolExecutor(1, 1, 0L, TimeUnit.MILLISECONDS, new LinkedBlockingQueue\u0026lt;Runnable\u0026gt;(), threadFactory)); } //另一种构造函数 public static ExecutorService newSingleThreadExecutor() { return new FinalizableDelegatedExecutorService (new ThreadPoolExecutor(1, 1, 0L, TimeUnit.MILLISECONDS, new LinkedBlockingQueue\u0026lt;Runnable\u0026gt;())); } 新创建的 SingleThreadExecutor 的 corePoolSize 和 maximumPoolSize 都被设置为 1.其他参数和 FixedThreadPool 相同\n执行过程 如果当前运行线程数少于corePoolSize(1),则创建一个新的线程执行任务;当前线程池有一个运行的线程后,将任务加入LinkedBlockingQueue;线程执行完当前的任务后,会在循环中反复从LinkedBlockingQueue中获取任务执行\n为什么不推荐使用SingleThreadExecutor SingleThreadExecutor使用无界队列LinkedBlockingQueue作为线程池的工作队列(容量为Integer.MAX_VALUE) 。SingleThreadExecutor使用无界队列作为线程池的工作队列会对线程池带来的影响与FixedThreadPoll相同,即导致OOM\nCachedThreadPool CachedThreadPool是一个会根据需要创建新线程的线程池,源码:\n/** * 创建一个线程池,根据需要创建新线程,但会在先前构建的线程可用时重用它。 */ public static ExecutorService newCachedThreadPool(ThreadFactory threadFactory) { return new ThreadPoolExecutor(0, Integer.MAX_VALUE, 60L, TimeUnit.SECONDS, new SynchronousQueue\u0026lt;Runnable\u0026gt;(), threadFactory); } //其他构造函数 public static ExecutorService newCachedThreadPool() { return new ThreadPoolExecutor(0, Integer.MAX_VALUE, 60L, TimeUnit.SECONDS, new SynchronousQueue\u0026lt;Runnable\u0026gt;()); } CachedThreadPool 的**corePoolSize 被设置为空(0),maximumPoolSize被设置为 Integer.MAX.VALUE,即它是无界的,这也就意味着如果主线程提交任务的速度高于 maximumPool 中线程处理任务的速度时,CachedThreadPool 会不断创建新的线程**。极端情况下,这样会导致耗尽 cpu 和内存资源\n★:SynchronousQueue队列只能容纳零个元素 执行过程(execute()示意图)\n上图说明:\n首先执行 SynchronousQueue.offer(Runnable task) 提交任务到任务队列。如果当前 maximumPool 中有闲线程正在执行 SynchronousQueue.poll(keepAliveTime,TimeUnit.NANOSECONDS),那么主线程执行 offer 操作与空闲线程执行的 poll 操作配对成功,主线程把任务交给空闲线程执行,execute()方法执行完成,否则执行下面的步骤 2; 当初始 maximumPool 为空,或者 maximumPool 中没有空闲线程时,将没有线程执行 SynchronousQueue.poll(keepAliveTime,TimeUnit.NANOSECONDS)。这种情况下,步骤 1 将失败,此时 CachedThreadPool 会创建新线程执行任务,execute 方法执行完成; 不推荐使用CachedThreadPool? 因为它允许创建的线程数量为Integer.MAX_VALUE,可能创建大量线程,从而导致OOM\nScheduledThreadPoolExecutor详解 # 项目中基本不会用到,主要用来在给定的延迟后运行任务,或者定期执行任务 它使用的任务队列DelayQueue封装了一个PriorityQueue,PriorityQueue会对队列中的任务进行排序,执行所需时间(第一次执行的时间)短的放在前面先被执行(ScheduledFutureTask的time变量小的先执行),如果一致则先提交的先执行(ScheduleFutureTask的sequenceNumber变量)\nScheduleFutureTask\n/** * 其中, triggerTime(initialDelay, unit) 的结果即上面说的time,说的应该是第一次执行的时间,而不是整个任务的执行时间 * @throws RejectedExecutionException {@inheritDoc} * @throws NullPointerException {@inheritDoc} * @throws IllegalArgumentException {@inheritDoc} */ public ScheduledFuture\u0026lt;?\u0026gt; scheduleAtFixedRate(Runnable command, long initialDelay, long period, TimeUnit unit) { if (command == null || unit == null) throw new NullPointerException(); if (period \u0026lt;= 0) throw new IllegalArgumentException(); ScheduledFutureTask\u0026lt;Void\u0026gt; sft = new ScheduledFutureTask\u0026lt;Void\u0026gt;(command, null, triggerTime(initialDelay, unit), unit.toNanos(period)); RunnableScheduledFuture\u0026lt;Void\u0026gt; t = decorateTask(command, sft); sft.outerTask = t; delayedExecute(t); return t; } 代码,TimerTask\n@Slf4j class MyTimerTask extends TimerTask{ @Override public void run() { log.info(\u0026#34;hello\u0026#34;); } } public class TimerTaskTest { public static void main(String[] args) { Timer timer = new Timer(); Calendar calendar = Calendar.getInstance(); calendar.set(Calendar.HOUR_OF_DAY, 17);//控制小时 calendar.set(Calendar.MINUTE, 1);//控制分钟 calendar.set(Calendar.SECOND, 0);//控制秒 Date time = calendar.getTime();//执行任务时间为17:01:00 //每天定时17:02执行操作,每5秒执行一次 timer.schedule(new MyTimerTask(), time, 5000 ); } } 代码,ScheduleThreadPoolExecutor\n@Slf4j public class ScheduleTask { public static void main(String[] args) throws InterruptedException { ScheduledExecutorService scheduledExecutorService = Executors.newScheduledThreadPool(3); scheduledExecutorService.scheduleAtFixedRate(new Runnable() { @Override public void run() { log.info(\u0026#34;hello world!\u0026#34;); } }, 3, 5, TimeUnit.SECONDS);//10表示首次执行任务的延迟时间,5表示每次执行任务的间隔时间,Thread.sleep(10000); System.out.println(\u0026#34;Shutting down executor...\u0026#34;); TimeUnit.SECONDS.sleep(4); //线程池一关闭,定时器就不会再执行 scheduledExecutorService.shutdown(); while (true){} } } /*结果 Shutting down executor... 2022-11-28 17:25:06 下午 [Thread: pool-1-thread-1] INFO:hello world! 不会再执行定时任务,因为线程池已经关了*/ ScheduleThreadPoolExecutor和Timer的比较 Timer对系统时钟变化敏感,ScheduledThreadPoolExecutor不是\nTimer使用的是System.currentTime(),而ScheduledThreadPoolExecutor使用的是System.nanoTime()\nTimer只有一个线程(导致长时间运行的任务延迟其他任务),ScheduleThreadPoolExecutor可以配置任意数量线程\nTimerTask中抛出运行时异常会杀死一个线程,从而导致Timer死机(即计划任务将不在运行);而ScheduleThreadExecutor不仅捕获运行时异常,还允许需要时处理(afterExecute方法),抛出异常的任务会被取消而其他任务将继续运行\nJDK1.5 之后,没有理由再使用Timer进行任务调度\n运行机制 //下面这块内容后面更新后原作者删除了 ScheduledThreadPoolExecutor的执行分为:\n当调用scheduleAtFixedRate()或scheduleWithFixedDelay()方法时,会向ScheduleThreadPoolExector的DelayQueue添加一个实现了RunnableScheduleFuture接口的ScheduleFutureTask(私有内部类) 线程池中的线程从DelayQueue中获取ScheduleFutureTask,然后执行任务 为了执行周期性任务,对ThreadPoolExecutor做了如下修改:\n使用DelayQueue作为任务队列 获取任务的方式不同 获取周期任务后做了额外处理 获取任务,执行任务,修改任务(time),回放(添加)任务\n线程 1 从 DelayQueue 中获取已到期的 ScheduledFutureTask(DelayQueue.take())。到期任务是指 ScheduledFutureTask的 time 大于等于当前系统的时间; 线程 1 执行这个 ScheduledFutureTask; 线程 1 修改 ScheduledFutureTask 的 time 变量为下次将要被执行的时间; 线程 1 把这个修改 time 之后的 ScheduledFutureTask 放回 DelayQueue 中(DelayQueue.add())。 线程池大小确定 # 如果线程池中的线程太多,就会增加上下文切换的成本\n多线程编程中一般线程的个数都大于 CPU 核心的个数,而一个 CPU 核心在任意时刻只能被一个线程使用,为了让这些线程都能得到有效执行,CPU 采取的策略是为每个线程分配时间片并轮转的形式。当一个线程的时间片用完的时候就会重新处于就绪状态让给其他线程使用,这个过程就属于一次上下文切换。概括来说就是:当前任务在执行完 CPU 时间片切换到另一个任务之前会先保存自己的状态,以便下次再切换回这个任务时,可以再加载这个任务的状态。任务从保存到再加载的过程就是一次上下文切换。\n上下文切换通常是计算密集型的。也就是说,它需要相当可观的处理器时间,在每秒几十上百次的切换中,每次切换都需要纳秒量级的时间。所以,上下文切换对系统来说意味着消耗大量的 CPU 时间,事实上,可能是操作系统中时间消耗最大的操作。\nLinux 相比与其他操作系统(包括其他类 Unix 系统)有很多的优点,其中有一项就是,其上下文切换和模式切换的时间消耗非常少。\n过大跟过小都不行\n如果我们设置的线程池数量太小的话,如果同一时间有大量任务/请求需要处理,可能会导致大量的请求/任务在任务队列中排队等待执行,甚至会出现任务队列满了之后任务/请求无法处理的情况,或者大量任务堆积在任务队列导致 OOM 设置线程数量太大,大量线程可能会同时在争取 CPU 资源,这样会导致大量的上下文切换,从而增加线程的执行时间,影响了整体执行效率 简单且适用面较广的公式\nCPU 密集型任务(N+1): 这种任务消耗的主要是 CPU 资源,可以将线程数设置为 N(CPU 核心数)+1,比 CPU 核心数多出来的一个线程是为了防止线程偶发的缺页中断,或者其它原因导致的任务暂停而带来的影响。一旦任务暂停,CPU 就会处于空闲状态,而在这种情况下多出来的一个线程就可以充分利用 CPU 的空闲时间。\nI/O 密集型任务(2N): 这种任务应用起来,系统会用大部分的时间来处理 I/O 交互,而线程在处理 I/O 的时间段内不会占用 CPU 来处理,这时就可以将 CPU 交出给其它线程使用。因此在 I/O 密集型任务的应用中,我们可以多配置一些线程,具体的计算方法是 2N。\n如何判断是CPU密集任务还是IO密集任务\nCPU 密集型简单理解就是利用 CPU 计算能力的任务比如你在内存中对大量数据进行排序。但凡涉及到网络读取,文件读取这类都是 IO 密集型,这类任务的特点是 CPU 计算耗费时间相比于等待 IO 操作完成的时间来说很少,大部分时间都花在了等待 IO 操作完成上。\n"},{"id":325,"href":"/zh/docs/technology/Review/java_guide/java/Concurrent/ly0304lyjmm/","title":"java内存模型","section":"并发","content":" 引用自https://github.com/Snailclimb/JavaGuide\n从CPU缓存模型说起 # redis是为了解决程序处理速度和访问常规关系型数据库速度不对等的问题,CPU缓存则是为了解决CPU处理速度和内存处理速度不对等的问题\n我们把内存看作外存的高速缓存,程序运行时把外存的数据复制到内存,由于内存的处理速度远高于外存,这样提高了处理速度\n总结,CPU Cache缓存的是内存数据,用于解决CPU处理速度和内存不匹配的问题,内存缓存的是硬盘数据用于解决硬盘访问速度过慢的问题 CPU Cache示意图:\nCPU Cache通常分为三层,分别叫L1,L2,L3 Cache 工作方式: 先复制一份数据到CPUCache中,当CPU需要用的时候就可以从CPUCache中读取数据,运算完成后,将运算得到的数据,写回MainMemory中,此时,会出现内存缓存不一致的问题,例子:执行了i++,如果两个线程同时执行,假设两个线程从CPUCach中读取的i=1,两个线程做了1++运算完之后再写回MainMemory,此时i=2 而正确结果为3\nCPU为了解决内存缓存不一致问题,可以通过制定缓存一致协议(比如MESI协议)或其他手段。这个缓存一致协议,指的是在 CPU 高速缓存与主内存交互的时候需要遵守的原则和规范 操作系统,通过内存模型MemoryModel定义一系列规范来解决这个问题\nJava内存模型 # 指令重排序 # 什么是指令重排序? 简单来说就是系统在执行代码的时候并不一定是按照你写的代码的顺序依次执行\n指令重排有下面2种\n编译器优化重排:编译器(包括 JVM、JIT 编译器等)在不改变单线程程序语义的前提下,重新安排语句的执行顺序。 指令并行重排:现代处理器采用了指令级并行技术(Instruction-Level Parallelism,ILP)来将多条指令重叠执行。如果不存在数据依赖性,处理器可以改变语句对应机器指令的执行顺序 另外,内存系统也会有“重排序”,但又不是真正意义上的重排序。在 JMM 里表现为主存和本地内存的内容可能不一致,进而导致程序在多线程下执行可能出现问题。\n即Java源代码会经历 编译器优化重排\u0026mdash;\u0026gt;指令并行重排\u0026mdash;\u0026gt;内存系统重排,最终编程操作系统可执行的指令序列\n极其重要★:指令重排序可以保证串行语义一致,但是没有义务保证多线程间的语义也一致,所以在多线程下指令重排可能导致一些问题\n编译器和处理器的指令重排序的处理方式不一样。对于编译器,通过禁止特定类型的编译器重排序的方式来禁止重排序。对于处理器,通过插入内存屏障(Memory Barrier,或有时叫做内存栅栏,Memory Fence)的方式来禁止特定类型的处理器重排序。指令并行重排和内存系统重排都属于是处理器级别的指令重排序。\n内存屏障(Memory Barrier,或有时叫做内存栅栏,Memory Fence)是一种 CPU 指令,用来禁止处理器指令发生重排序(像屏障一样),从而保障指令执行的有序性。另外,为了达到屏障的效果,它也会使处理器写入、读取值之前,将主内存的值写入高速缓存,清空无效队列,从而保障变量的可见性。\nJMM(JavaMemoryMode) # 什么是 JMM?为什么需要 JMM? # 一般来说,编程语言也可以直接复用操作系统层面的内存模型。不过,不同的操作系统内存模型不同。如果直接复用操作系统层面的内存模型,就可能会导致同样一套代码换了一个操作系统就无法执行了。Java 语言是跨平台的,它需要自己提供一套内存模型以屏蔽系统差异。\n实际上,对于Java来说,可以把JMM看作是Java定义的并发编程相关的一组规范,除了抽象了线程和主内存之间的关系之外,还规定了从Java源代码到CPU可执行指令的转化过程要遵守哪些和并发相关的原则和规范,主要目的是为了简化多线程编程,增强程序可移植性。\n为什么要遵守这些并发相关的原则和规范呢?因为在并发编程下,CPU多级缓存和指令重排这类设计会导致程序运行出问题,比如指令重排,为此JMM抽象了happens-before原则\nJMM 说白了就是定义了一些规范来解决这些问题,开发者可以利用这些规范更方便地开发多线程程序。对于 Java 开发者说,你不需要了解底层原理,直接使用并发相关的一些关键字和类(比如 volatile、synchronized、各种 Lock)即可开发出并发安全的程序。\nJMM 是如何抽象线程和主内存之间的关系? # Java内存模型(JMM),抽象了线程和主内存之间的关系,比如线程之间的共享变量必须存储在主内存中\nJDK1.2之前,Java内存模型总是从主存(共享内存)读取变量;而当前的Java内存模型下,线程可以把变量保存本地内存(机器的寄存器)中,而不直接在主存中读写。这可能造成,一个线程在主存中修改了一个变量的值,而在另一个线程继续使用它在寄存器中的变量值的拷贝,造成数据不一致\n上面所述跟CPU缓存模型非常相似\n什么是主内存?什么是本地内存?\n主内存:★重要!!★所有线程创建的实例对象都存放在主内存中(感觉这里说的是堆?),不管该实例对象是成员变量还是方法中的本地变量(也称局部变量)\n本地内存 :每个线程都有一个私有的本地内存来存储共享变量的副本,并且,每个线程只能访问自己的本地内存,无法访问其他线程的本地内存。本地内存是 JMM 抽象出来的一个概念,存储了主内存中的共享变量副本\nJava 内存模型的抽象示意图如下:\n如上,若线程1和线程2之间要通信,则\n线程1把本地内存中修改过的共享变量副本的值,同步到主内存中 线程2到主存中,读取对应的共享变量的值 即,JMM为共享变量提供了可见性的保障\n多线程下,主内存中一个共享变量进行操作引发的线程安全问题:\n线程1、2分别对同一个共享变量操作,一个执行修改,一个执行读取 线程2读取到的是线程1修改之前的还是修改之后的值,不确定 关于主内存和工作内存直接的具体交互协议,即一个变量,如何从主内存拷贝到工作内存,如何从工作内存同步到主内存,JMM定义八种同步操作:\n锁定(lock): 作用于主内存中的变量,将他标记为一个线程独享变量。\n解锁(unlock): 作用于主内存中的变量,解除变量的锁定状态,被解除锁定状态的变量才能被其他线程锁定。\nread(读取):作用于主内存的变量,它把一个变量的值从主内存传输到线程的工作内存中,以便随后的 load 动作使用。\nload(载入):把 read 操作从主内存中得到的变量值放入工作内存的变量的副本中。\nuse(使用):把工作内存中的一个变量的值传给执行引擎,每当虚拟机遇到一个使用到变量的指令时都会使用该指令。\nassign(赋值):作用于工作内存的变量,它把一个从执行引擎接收到的值赋给工作内存的变量,每当虚拟机遇到一个给变量赋值的字节码指令时执行这个操作。\nstore(存储):作用于工作内存的变量,它把工作内存中一个变量的值传送到主内存中,以便随后的 write 操作使用。\nwrite(写入):作用于主内存的变量,它把 store 操作从工作内存中得到的变量的值放入主内存的变量中\n下面的同步规则,保证这些同步操作的正确执行: (没看懂)\n不允许一个线程无原因地(没有发生过任何 assign 操作)把数据从线程的工作内存同步回主内存中。\n一个新的变量只能在主内存中 “诞生”,不允许在工作内存中直接使用一个未被初始化(load 或 assign)的变量,换句话说就是对一个变量实施 use 和 store 操作之前,必须先执行过了 assign 和 load 操作。\n一个变量在同一个时刻只允许一条线程对其进行 lock 操作,但 lock 操作可以被同一条线程重复执行多次,多次执行 lock 后,只有执行相同次数的 unlock 操作,变量才会被解锁。\n如果对一个变量执行 lock 操作,将会清空工作内存中此变量的值,在执行引擎使用这个变量前,需要重新执行 load 或 assign 操作初始化变量的值。\n如果一个变量事先没有被 lock 操作锁定,则不允许对它执行 unlock 操作,也不允许去 unlock 一个被其他线程锁定住的变量。\n\u0026hellip;\u0026hellip;\nJava 内存区域和 JMM 有何区别? # JVM 内存结构和 Java 虚拟机的运行时区域相关,定义了 JVM 在运行时如何分区存储程序数据,就比如说堆主要用于存放对象实例。 Java 内存模型和 Java 的并发编程相关,抽象了线程和主内存之间的关系就比如说线程之间的共享变量必须存储在主内存中,规定了从 Java 源代码到 CPU 可执行指令的这个转化过程要遵守哪些和并发相关的原则和规范,其主要目的是为了简化多线程编程,增强程序可移植性的。 happens-before 原则是什么? # 通过逻辑时钟能对分布式系统中的事件的先后关系进行判断\n逻辑时钟并不度量时间本身,仅区分事件发生的前后顺序,其本质就是定义了一种 happens-before 关系。\nJSR 133引入happens-before这个概念来描述两个操作之间的内存可见性\n为什么需要 happens-before 原则? happens-before 原则的诞生是为了程序员和编译器、处理器之间的平衡。程序员追求的是易于理解和编程的强内存模型,遵守既定规则编码即可。编译器和处理器追求的是较少约束的弱内存模型,让它们尽己所能地去优化性能,让性能最大化。\nhappens-before原则的设计思想\n为了对编译器和处理器的约束尽可能少,只要不改变程序的执行结果(单线程程序和正确执行的多线程程序),编译器和处理器怎么进行重排序优化都行。 对于会改变程序执行结果的重排序,JMM 要求编译器和处理器必须禁止这种重排序。 JSR-133对happens-before原则的定义:\n如果一个操作happens-before另一个操作,那么第一个操作的执行结果将对第二个操作可见,并且第一个操作的执行顺序排在第二个操作之前 这是 JMM 对程序员强内存模型的承诺。从程序员的角度来说,可以这样理解 Happens-before 关系:如果 A Happens-before B,那么 JMM 将向程序员保证 — A 操作的结果将对 B 可见,且 A 的执行顺序排在 B 之前。注意,这只是 Java内存模型向程序员做出的保证,即Happens-before提供跨线程的内存可见性保证\n对于这条定义,举个例子(不代表代码就是这样的,这是一个概括性的假设情况)\n// 以下操作在线程 A 中执行 i = 1; // a // 以下操作在线程 B 中执行 j = i; // b // 以下操作在线程 C 中执行 i = 2; // c 假设线程 A 中的操作 a Happens-before 线程 B 的操作 b,那我们就可以确定操作 b 执行后,变量 j 的值一定是等于 1。\n得出这个结论的依据有两个:一是根据 Happens-before 原则,a 操作的结果对 b 可见,即 “i=1” 的结果可以被观察到;二是线程 C 还没运行,线程 A 操作结束之后没有其他线程会修改变量 i 的值。\n现在再来考虑线程 C,我们依然保持 a Happens-before b ,而 c 出现在 a 和 b 的操作之间,但是 c 与 b 没有 Happens-before 关系,也就是说 b 并不一定能看到 c 的操作结果。那么 b 操作的结果也就是 j 的值就不确定了,可能是 1 也可能是 2,那这段代码就是线程不安全的。\n两个操作之间存在 happens-before 关系,并不意味着 Java 平台的具体实现必须要按照 happens-before 关系指定的顺序来执行。如果重排序之后的执行结果,与按 happens-before 关系来执行的结果一致,那么 JMM 也允许这样的重排序 例子:\nint userNum = getUserNum(); // 1 int teacherNum = getTeacherNum();\t// 2 int totalNum = userNum + teacherNum;\t// 3 如上,1 happens-before 2,2 happens-before 3,1 happens-before 3\n虽然 1 happens-before 2,但对 1 和 2 进行重排序不会影响代码的执行结果,所以 JMM 是允许编译器和处理器执行这种重排序的。但 1 和 2 必须是在 3 执行之前,也就是说 1,2 happens-before 3 。\nhappens-before 原则表达的意义其实并不是一个操作发生在另外一个操作的前面,虽然这从程序员的角度上来说也并无大碍。更准确地来说,它更想表达的意义是前一个操作的结果对于后一个操作是可见的,无论这两个操作是否在同一个线程里。\n举个例子:操作 1 happens-before 操作 2,即使操作 1 和操作 2 不在同一个线程内,JMM 也会保证操作 1 的结果对操作 2 是可见的。\nhappens-before 常见规则有哪些?谈谈你的理解? # 主要的5条规则:\n程序顺序规则:一个线程内,按照代码顺序,书写在前面的操作happens-before于书写在后面的操作 解锁规则:解锁happens-before于加锁 volatile变量规则:对一个 volatile 变量的写操作 happens-before 于后面对这个 volatile 变量的读操作。说白了就是对 volatile 变量的写操作的结果对于发生于其后的任何操作都是可见的。 传递规则:如果A happens-before B,且B happens-before C ,那么A happens-before C 线程启动规则:Thread对象的start() 方法 happens-before 于此线程的每一个操作 如果两个操作,不满足于上述任何一个happens-before规则,那么这两个操作就没有顺序的保障,JVM可以对这两个操作进行重排序\nhappens-before 和JMM什么关系 # 根据happens-before规则,告诉程序员,有哪些happens-before规则(哪些情况不会被重排序)\n为了避免 Java 程序员为了理解 JMM 提供的内存可见性保证而去学习复杂的重排序规则以及这些规则的具体实现方法,JMM 就出了这么一个简单易懂的 Happens-before 原则,一个 Happens-before 规则就对应于一个或多个编译器和处理器的重排序规则\nas-if-serial 语义保证单线程内程序的执行结果不被改变,Happens-before 关系保证正确同步的多线程程序的执行结果不被改变。 as-if-serial 语义给编写单线程程序的程序员创造了一个幻境:单线程程序是按程序的顺序来执行的。Happens-before 关系给编写正确同步的多线程程序的程序员创造了一个幻境:正确同步的多线程程序是按 Happens-before 指定的顺序来执行的。 JMM定义的\n再看并发编程三个重要特性 # 原子性,可见性,有序性\n原子性 一次操作或多次操作,要么所有的操作,全部都得到执行并且不会受到任何因素的干扰而中断,要么都不执行\nJava中,使用synchronized、各种Lock以及各种原子类实现原子性(AtomicInteger等)\nsynchronized 和各种 Lock 可以保证任一时刻只有一个线程访问该代码块,因此可以保障原子性。各种原子类是利用 CAS (compare and swap) 操作(可能也会用到 volatile或者final关键字)来保证原子操作。\n可见性 当一个线程对共享变量进行了修改,那么另外的线程都是立即可以看到修改后的最新值。\n在 Java 中,可以借助synchronized 、volatile 以及各种 Lock 实现可见性。\n如果我们将变量声明为 volatile ,这就指示 JVM,这个变量是共享且不稳定的,每次使用它都到主存中进行读取。\n有序性 由于指令重排序问题,代码的执行顺序未必就是编写代码时候的顺序\n指令重排序可以保证串行语义一致,但是没有义务保证多线程间的语义也一致 ,所以在多线程下,指令重排序可能会导致一些问题。\nJava中,volatile关键字可以禁止指令进行重排序优化(注意,synchronized也可以)\n总结 # 补充:线程join()方法,导致调用线程暂停,直到xx.join()中的xx线程执行完,调用join方法的线程才继续执行\nThread thread1 = new Thread(new Runnable() { @Override public void run() { log.info(\u0026#34;暂停5s\u0026#34;); try { TimeUnit.SECONDS.sleep(5); } catch (InterruptedException e) { e.printStackTrace(); } } }); thread1.start(); Thread thread2 = new Thread(new Runnable() { @Override public void run() { log.info(\u0026#34;暂停3s\u0026#34;); try { TimeUnit.SECONDS.sleep(3); } catch (InterruptedException e) { e.printStackTrace(); } } }); thread2.start(); thread1.join(); thread2.join(); log.info(\u0026#34;主线程执行\u0026#34;); /*结果 2022-11-23 13:57:06 下午 [Thread: Thread-1] INFO:暂停5s 2022-11-23 13:57:06 下午 [Thread: Thread-2] INFO:暂停3s 2022-11-23 13:57:11 下午 [Thread: main] INFO:主线程执行 */ 指令重排的影响,举例:【★很重要★】\npublic class Test { private static int x = 0, y = 0; private static int a = 0, b = 0; public static void main(String[] args) throws InterruptedException { for (int i = 0; ; i++) { x = 0; y = 0; a = 0; b = 0; Thread one = new Thread(() -\u0026gt; { a = 1; x = b; }); Thread other = new Thread(() -\u0026gt; { b = 1; y = a; }); one.start(); other.start();; one.join(); other.join(); if (x == 0 \u0026amp;\u0026amp; y == 0) { String result = \u0026#34;第\u0026#34; + i + \u0026#34;次(\u0026#34; + x + \u0026#34;, \u0026#34; + y + \u0026#34;)\u0026#34;; System.out.println(result); } } } } /* 因为线程one中,a和x并不存在依赖关系,因此可能会先执行x=b;而这个时候,b=0。因此x会被赋值为0,而a=1这条语句还没有被执行的时候,线程other先执行了y=a这条语句,这个时候a还是a=0;因此y被赋值为了0。所以存在情况x=0;y=0。这就是指令重排导致的多线程问题。 原文链接:https://blog.csdn.net/qq_45948401/article/details/124973903 */ Java 是最早尝试提供内存模型的语言,其主要目的是为了简化多线程编程,增强程序可移植性的。 CPU 可以通过制定缓存一致协议(比如 MESI 协议open in new window)来解决内存缓存不一致性问题。 为了提升执行速度/性能,计算机在执行程序代码的时候,会对指令进行重排序。 简单来说就是系统在执行代码的时候并不一定是按照你写的代码的顺序依次执行。指令重排序可以保证串行语义一致,但是没有义务保证多线程间的语义也一致 ,所以在多线程下,指令重排序可能会导致一些问题。 你可以把 JMM 看作是 Java 定义的并发编程相关的一组规范,除了抽象了线程和主内存之间的关系之外,其还规定了从 Java 源代码到 CPU 可执行指令的这个转化过程要遵守哪些和并发相关的原则和规范,其主要目的是为了简化多线程编程,增强程序可移植性的。 JSR 133 引入了 happens-before 这个概念来**(极其重要又精简的话)描述两个操作之间的内存可见性**。 "},{"id":326,"href":"/zh/docs/technology/Review/java_guide/java/Concurrent/ly0303lyconcurrent-03/","title":"并发03","section":"并发","content":" 转载自https://github.com/Snailclimb/JavaGuide (添加小部分笔记)感谢作者!\n线程池 # 为什么要使用线程池\n池化技术:线程池、数据库连接池、Http连接池 池化技术思想意义:为了减少每次获取资源的消耗,提高对资源的利用率 线程池提供了限制和管理 资源(包括执行一个任务)的方式 每个线程池还维护基本统计信息,例如已完成任务的数量 好处: 降低资源消耗 重复利用已创建线程降低线程创建和销毁造成的消耗 提高响应速度 任务到达时,任务可以不需等到线程创建就能继续执行 提高线程的可管理性 线程是稀缺资源,如果无限制创建,不仅消耗系统资源,还会降低系统的稳定性,使用线程池统一管理分配、调优和监控。 实现Runnable接口和Callable接口的区别\n//Callable的用法 public class TestLy { //如果加上volatile,就能保证可见性,线程1 才能停止 boolean stop = false;//对象属性 public static void main(String[] args) throws InterruptedException, ExecutionException { FutureTask\u0026lt;String\u0026gt; futureTask=new FutureTask\u0026lt;\u0026gt;(new Callable\u0026lt;String\u0026gt;() { @Override public String call() throws Exception { System.out.println(\u0026#34;等3s再把结果给你\u0026#34;); TimeUnit.SECONDS.sleep(3); return \u0026#34;hello world\u0026#34;; } }); new Thread(futureTask).start(); String s = futureTask.get(); System.out.println(\u0026#34;3s后获取到了结果\u0026#34;+s); new Thread(new Runnable() { @Override public void run() { System.out.println(\u0026#34;abc\u0026#34;); } }).start(); } } /* 等3s再把结果给你 3s后获取到了结果hello world abc */ Runnable接口不会返回结果或抛出检查异常,Callable接口可以\nExecutors可以实现将Runnable对象转换成Callable对象\nExecutors.callable(Runnable task)或Executors.callable(Runnable task, Object result) //则两个方法,运行的结果是 Callable\u0026lt;Object\u0026gt;\n//一个不指定结果,另一个指定结果 public static void main(String[] args) throws Exception { Callable\u0026lt;Object\u0026gt; abc = Executors.callable(() -\u0026gt; { try { TimeUnit.SECONDS.sleep(2); } catch (InterruptedException e) { e.printStackTrace(); } System.out.println(\u0026#34;abc\u0026#34;); },\u0026#34;abcccc\u0026#34;);//如果没有\u0026#34;abcccc\u0026#34;,则下面输出null FutureTask\u0026lt;Object\u0026gt; futureTask = new FutureTask\u0026lt;\u0026gt;(abc); new Thread(futureTask).start(); Object o = futureTask.get(); System.out.println(\u0026#34;获取值:\u0026#34;+o); } Runnable和Callable:\n//Runnable.java @FunctionalInterface public interface Runnable { /** * 被线程执行,没有返回值也无法抛出异常 */ public abstract void run(); } //========================================= //Callable.java @FunctionalInterface public interface Callable\u0026lt;V\u0026gt; { /** * 计算结果,或在无法这样做时抛出异常。 * @return 计算得出的结果 * @throws 如果无法计算结果,则抛出异常 */ V call() throws Exception; } 执行execute()和submit()方法的区别是什么\nexecute() 方法用于提交不需要返回值的任务,所以无法判断任务是否被线程池执行成功\nsubmit()方法用于提交需要返回值的任务,线程池会返回一个Future类型的对象,通过这个Future对象可以判断任务是否返回成功\n这个Future的get()方法来获取返回值,get()方法会阻塞当前线程直到任务完成; 使用get(long timeout,TimeUnit unit) 方法会在阻塞当前线程一段时间后立即返回(此时任务不一定已经执行完) 注意: 这里的get()不一定会有返回值的,例子如下\nExecutorService executorService = Executors.newCachedThreadPool(); Callable\u0026lt;MyClass\u0026gt; myClassCallable = new Callable\u0026lt;MyClass\u0026gt;() { @Override public MyClass call() throws Exception { MyClass myClass1 = new MyClass(); myClass1.setName(\u0026#34;ly-callable-测试\u0026#34;); TimeUnit.SECONDS.sleep(2); return myClass1; } }; Future\u0026lt;?\u0026gt; submit = executorService.submit(myClassCallable); //这里会阻塞 Object o = submit.get(); log.info(\u0026#34;ly-callable-打印结果1:\u0026#34; + o); FutureTask\u0026lt;MyClass\u0026gt; futureTask = new FutureTask\u0026lt;\u0026gt;(() -\u0026gt; { MyClass myClass1 = new MyClass(); myClass1.setName(\u0026#34;ly-FutureTask-测试\u0026#34;); TimeUnit.SECONDS.sleep(2); return myClass1; }); Future\u0026lt;?\u0026gt; submit2 = executorService.submit(futureTask); //这里会阻塞 Object o2 = submit2.get(); log.info(\u0026#34;ly-callable-打印结果2:\u0026#34; + o2); executorService.shutdown(); /*结果 2022-11-09 10:19:10 上午 [Thread: main] INFO:ly-callable-打印结果1:MyClass(name=ly-callable-测试) 2022-11-09 10:19:12 上午 [Thread: main] INFO:ly-callable-打印结果2:null */ 当submit一个Callable对象的时候,能从submit返回的Future.get到返回值;当submit一个FutureTask对象(FutureTask有参构造函数包含Callable对象,但它本身不是Callable)时,没法获取返回值,因为会被当作Runnable对象submit进来\n虚线是实现,实线是继承。\n而入参为Runnable时返回值里是get不到结果的\n下面这段源码,解释了为什么当传入的类型是Runnable对象时,结果为null\n只要是submit(Runnable ),就会返回null\n//源码AbstractExecutorService 接口中的一个submit方法 public Future\u0026lt;?\u0026gt; submit(Runnable task) { if (task == null) throw new NullPointerException(); RunnableFuture\u0026lt;Void\u0026gt; ftask = newTaskFor(task, null); execute(ftask); return ftask; } //其中的newTaskFor方法 protected \u0026lt;T\u0026gt; RunnableFuture\u0026lt;T\u0026gt; newTaskFor(Runnable runnable, T value) { return new FutureTask\u0026lt;T\u0026gt;(runnable, value); } //execute()方法 public void execute(Runnable command) { ... } FutureTask、Thread、Callable、Executors\n如何创建线程池\nexecutor [ɪɡˈzekjətə(r)] 遗嘱执行人(或银行等)\n关于SynchronousQueue(具有0个元素的阻塞队列):\nSynchronousQueue\u0026lt;String\u0026gt; synchronousQueue =new SynchronousQueue\u0026lt;\u0026gt;(); new Thread(()-\u0026gt;{ try { log.info(\u0026#34;放入数据A\u0026#34;); synchronousQueue.put(\u0026#34;A\u0026#34;); } catch (InterruptedException e) { e.printStackTrace(); } log.info(\u0026#34;继续执行\u0026#34;); },\u0026#34;子线程1\u0026#34;).start(); new Thread(()-\u0026gt;{ try { TimeUnit.SECONDS.sleep(3); } catch (InterruptedException e) { e.printStackTrace(); } String poll = null; try { poll = synchronousQueue.take(); } catch (InterruptedException e) { e.printStackTrace(); } log.info(poll); },\u0026#34;子线程2\u0026#34;).start(); /**输出 2023-03-07 15:20:17 下午 [Thread: 子线程1] INFO:放入数据A ---这里会等待3s(等子线程2 task()消费掉) 2023-03-07 15:20:20 下午 [Thread: 子线程2] INFO:A 2023-03-07 15:20:20 下午 [Thread: 子线程1] INFO:继续执行 */ 不允许使用Executors去创建,而是通过new ThreadPoolExecutor的方式:能让写的同学明确线程池运行规则,规避资源耗尽\n/* 工具的方式创建线程池 */ void test(){ ExecutorService executorService = Executors.newCachedThreadPool(); Callable\u0026lt;MyClass\u0026gt; myClassCallable = new Callable\u0026lt;MyClass\u0026gt;() { @Override public MyClass call() throws Exception { MyClass myClass1 = new MyClass(); myClass1.setName(\u0026#34;ly-callable-测试\u0026#34;); TimeUnit.SECONDS.sleep(2); return myClass1; } }; Future\u0026lt;?\u0026gt; submit = executorService.submit(myClassCallable); //这里会阻塞 Object o = submit.get(); log.info(\u0026#34;ly-callable-打印结果1:\u0026#34; + o); } 使用Executors返回线程池对象的弊端:\nThreadPoolExecutor(int corePoolSize, int maximumPoolSize, long keepAliveTime, TimeUnit unit, BlockingQueue\u0026lt;Runnable\u0026gt; workQueue, ThreadFactory threadFactory, RejectedExecutionHandler handler){} //#####时间表示keepAliveTime##### //########线程数量固定,队列长度为Integer.MAX################ Executors.newFixedThreadPool(3); public static ExecutorService newFixedThreadPool(int nThreads) { return new ThreadPoolExecutor(nThreads, nThreads, 0L, TimeUnit.MILLISECONDS, new LinkedBlockingQueue\u0026lt;Runnable\u0026gt;()); } //############线程数量固定,队列长度为Integer.MAX############## Executors.newSingleThreadExecutor(Executors.defaultThreadFactory()); public static ExecutorService newSingleThreadExecutor(ThreadFactory threadFactory) { return new FinalizableDelegatedExecutorService (new ThreadPoolExecutor(1, 1, 0L, TimeUnit.MILLISECONDS, new LinkedBlockingQueue\u0026lt;Runnable\u0026gt;(), threadFactory)); //############线程数量为Integer.MAX############# Executors.newCachedThreadPool(Executors.defaultThreadFactory()); public static ExecutorService newCachedThreadPool(ThreadFactory threadFactory) { return new ThreadPoolExecutor(0, Integer.MAX_VALUE, 60L, TimeUnit.SECONDS, new SynchronousQueue\u0026lt;Runnable\u0026gt;(), threadFactory); } //#############线程数量为Integer.MAX############# Executors.newScheduledThreadPool(3, Executors.defaultThreadFactory()); public static ScheduledExecutorService newScheduledThreadPool( int corePoolSize, ThreadFactory threadFactory) { return new ScheduledThreadPoolExecutor(corePoolSize, threadFactory); } ====================\u0026gt; public ScheduledThreadPoolExecutor(int corePoolSize, ThreadFactory threadFactory) { super(corePoolSize, Integer.MAX_VALUE, 0, NANOSECONDS, new DelayedWorkQueue(), threadFactory); } FixedThreadPool和SingleThreadExecutor:这两个方案允许请求的队列长度为Integer.MAX_VALUE,可能堆积大量请求,导致OOM CachedThreadPool和ScheduledThreadPool:允许创建的线程数量为Integer.MAX_VALUE,可能创建大量线程,导致OOM 通过构造方法实现 通过Executor框架的工具类Executors来实现 以下三个方法,返回的类型都是ThreadPoolExecutor\nFixedThreadPool : 该方法返回固定线程数量的线程池,线程数量始终不变。当有新任务提交时,线程池中若有空闲线程则立即执行;若没有,则新任务被暂存到任务队列中,待有线程空闲时,则处理在任务队列中的任务 SingleThreadExecutor:方法返回一个只有一个线程的线程池。若多余一个任务被提交到该线程池,任务被保存到一个任务队列中,待线程空闲,按先进先出的顺序执行队列中任务 CachedThreadPool:该方法返回一个根据实际情况调整线程数量的线程池。 数量不固定,若有空闲线程可以复用则优先使用可复用线程。若所有线程均工作,此时又有新任务提交,则创建新线程处理任务。所有线程在当前任务执行完毕后返回线程池进行复用 Executors工具类中的方法\n核心线程数和最大线程数有什么区别? 该类提供四个构造方法,看最长那个,其余的都是(给定默认值后)调用这个方法\n/** * 用给定的初始参数创建一个新的ThreadPoolExecutor。 */ public ThreadPoolExecutor(int corePoolSize,//线程池的核心线程数量 int maximumPoolSize,//线程池的最大线程数 long keepAliveTime,//当线程数大于核心线程数时,多余的空闲线程存活的最长时间 TimeUnit unit,//时间单位 BlockingQueue\u0026lt;Runnable\u0026gt; workQueue,//任务队列,用来储存等待执行任务的队列 ThreadFactory threadFactory,//线程工厂,用来创建线程,一般默认即可 RejectedExecutionHandler handler//拒绝策略,当提交的任务过多而不能及时处理时,我们可以定制策略来处理任务 ) { if (corePoolSize \u0026lt; 0 || maximumPoolSize \u0026lt;= 0 || maximumPoolSize \u0026lt; corePoolSize || keepAliveTime \u0026lt; 0) throw new IllegalArgumentException(); if (workQueue == null || threadFactory == null || handler == null) throw new NullPointerException(); this.corePoolSize = corePoolSize; this.maximumPoolSize = maximumPoolSize; this.workQueue = workQueue; this.keepAliveTime = unit.toNanos(keepAliveTime); this.threadFactory = threadFactory; this.handler = handler; } 构造函数重要参数分析\ncorePoolSize : 核心线程数定义最小可以运行的线程数量\nmaximumPoolSize: 当队列中存放的任务达到队列容量时,当前可以同时运行的线程数量变为最大线程数\nworkQueue:当新线程来的时候先判断当前运行线程数量是否达到核心线程数,如果达到的话,新任务就会被存放在队列中 ThreadPoolExecutor其他常见参数:\nkeepAliveTime:如果线程池中的线程数量大于corePoolSize时,如果这时没有新任务提交,核心线程外的线程不会立即销毁,而是等待,等待的时间超过了keepAliveTime就会被回收\nunit: keepAliveTime参数的时间单位\nthreadFactory: executor创建新线程的时候会用到\nhandle: 饱和策略\n如果同时运行的线程数量达到最大线程数,且队列已经被放满任务,ThreadPoolTaskExecutor定义该情况下的策略:\nThreadPoolExecutor.AbortPolicy: 抛出 RejectedExecutionException来拒绝新任务的处理。 ThreadPoolExecutor.CallerRunsPolicy: 调用执行自己的线程(如果在main方法中,那就是main线程)运行任务,也就是直接在调用execute方法的线程中运行(run)被拒绝的任务,如果执行程序已关闭,则会丢弃该任务。因此这种策略会降低对于新任务提交速度,影响程序的整体性能。如果您的应用程序可以承受此延迟并且你要求任何一个任务请求都要被执行的话,你可以选择这个策略。 ThreadPoolExecutor.DiscardPolicy: 不处理新任务,直接丢弃掉。 ThreadPoolExecutor.DiscardOldestPolicy: 此策略将丢弃最早的未处理的任务请求。 举个例子:如果在Spring中通过ThreadPoolTaskExecutor或直接通过ThreadPoolExecutor构造函数创建线程池时,若不指定RejectExcecutorHandler饱和策略则默认使用ThreadPoolExecutor.AbortPolicy,即抛出RejectedExecution来拒绝新来的任务;对于可伸缩程序,建议使用ThreadPoolExecutor.CallerRunsPolicy,\n一个简单的线程池Demo\n//定义一个Runnable接口实现类 import java.util.Date; /** * 这是一个简单的Runnable类,需要大约5秒钟来执行其任务。 * @author shuang.kou */ public class MyRunnable implements Runnable { private String command; public MyRunnable(String s) { this.command = s; } @Override public void run() { System.out.println(Thread.currentThread().getName() + \u0026#34; Start. Time = \u0026#34; + new Date()); processCommand(); System.out.println(Thread.currentThread().getName() + \u0026#34; End. Time = \u0026#34; + new Date()); } private void processCommand() { try { Thread.sleep(5000); } catch (InterruptedException e) { e.printStackTrace(); } } @Override public String toString() { return this.command; } } //实际执行 import java.util.concurrent.ArrayBlockingQueue; import java.util.concurrent.ThreadPoolExecutor; import java.util.concurrent.TimeUnit; public class ThreadPoolExecutorDemo { private static final int CORE_POOL_SIZE = 5;//核心线程数5 private static final int MAX_POOL_SIZE = 10;//最大线程数10 private static final int QUEUE_CAPACITY = 100;//队列容量100 private static final Long KEEP_ALIVE_TIME = 1L;//等待时间 public static void main(String[] args) { //使用阿里巴巴推荐的创建线程池的方式 //通过ThreadPoolExecutor构造函数自定义参数创建 ThreadPoolExecutor executor = new ThreadPoolExecutor( CORE_POOL_SIZE, MAX_POOL_SIZE, KEEP_ALIVE_TIME,8 TimeUnit.SECONDS, new ArrayBlockingQueue\u0026lt;\u0026gt;(QUEUE_CAPACITY), new ThreadPoolExecutor.CallerRunsPolicy()); for (int i = 0; i \u0026lt; 10; i++) { //创建 MyRunnable 对象(MyRunnable 类实现了Runnable 接口) Runnable worker = new MyRunnable(\u0026#34;\u0026#34; + i); //执行Runnable executor.execute(worker); } //终止线程池 executor.shutdown(); while (!executor.isTerminated()) { } System.out.println(\u0026#34;Finished all threads\u0026#34;); } } /*------输出 pool-1-thread-3 Start. Time = Sun Apr 12 11:14:37 CST 2020 pool-1-thread-5 Start. Time = Sun Apr 12 11:14:37 CST 2020 pool-1-thread-2 Start. Time = Sun Apr 12 11:14:37 CST 2020 pool-1-thread-1 Start. Time = Sun Apr 12 11:14:37 CST 2020 pool-1-thread-4 Start. Time = Sun Apr 12 11:14:37 CST 2020 pool-1-thread-3 End. Time = Sun Apr 12 11:14:42 CST 2020 pool-1-thread-4 End. Time = Sun Apr 12 11:14:42 CST 2020 pool-1-thread-1 End. Time = Sun Apr 12 11:14:42 CST 2020 pool-1-thread-5 End. Time = Sun Apr 12 11:14:42 CST 2020 pool-1-thread-1 Start. Time = Sun Apr 12 11:14:42 CST 2020 pool-1-thread-2 End. Time = Sun Apr 12 11:14:42 CST 2020 pool-1-thread-5 Start. Time = Sun Apr 12 11:14:42 CST 2020 pool-1-thread-4 Start. Time = Sun Apr 12 11:14:42 CST 2020 pool-1-thread-3 Start. Time = Sun Apr 12 11:14:42 CST 2020 pool-1-thread-2 Start. Time = Sun Apr 12 11:14:42 CST 2020 pool-1-thread-1 End. Time = Sun Apr 12 11:14:47 CST 2020 pool-1-thread-4 End. Time = Sun Apr 12 11:14:47 CST 2020 pool-1-thread-5 End. Time = Sun Apr 12 11:14:47 CST 2020 pool-1-thread-3 End. Time = Sun Apr 12 11:14:47 CST 2020 pool-1-thread-2 End. Time = Sun Apr 12 11:14:47 CST 2020 ------ */ 线程池原理是什么?\n由结果可以看出,线程池先执行5个任务,此时多出的任务会放到队列,那5个任务中有任务执行完的话,会拿新的任务执行\n为了搞懂线程池的原理,我们需要首先分析一下 execute方法。\n我们可以使用 executor.execute(worker)来提交一个任务到线程池中去,这个方法非常重要,下面我们来看看它的源码:\n//源码分析 // 存放线程池的运行状态 (runState) 和线程池内有效线程的数量 (workerCount) private final AtomicInteger ctl = new AtomicInteger(ctlOf(RUNNING, 0)); private static int workerCountOf(int c) { return c \u0026amp; CAPACITY; } private final BlockingQueue\u0026lt;Runnable\u0026gt; workQueue; public void execute(Runnable command) { // 如果任务为null,则抛出异常。 if (command == null) throw new NullPointerException(); // ctl 中保存的线程池当前的一些状态信息 int c = ctl.get(); // 下面会涉及到 3 步 操作 // 1.首先判断当前线程池中执行的任务数量是否小于 corePoolSize // 如果小于的话,通过addWorker(command, true)新建一个线程,并将任务(command)添加到该线程中;然后,启动该线程从而执行任务。 if (workerCountOf(c) \u0026lt; corePoolSize) { if (addWorker(command, true)) return; c = ctl.get(); } // 2.如果当前执行的任务数量大于等于 corePoolSize 的时候就会走到这里 // 通过 isRunning 方法判断线程池状态,线程池处于 RUNNING 状态并且队列可以加入任务,该任务才会被加入进去 if (isRunning(c) \u0026amp;\u0026amp; workQueue.offer(command)) { int recheck = ctl.get(); // 再次获取线程池状态,如果线程池状态不是 RUNNING 状态就需要从任务队列中移除任务,并尝试判断线程是否全部执行完毕。同时执行拒绝策略。 if (!isRunning(recheck) \u0026amp;\u0026amp; remove(command)) reject(command); // 如果当前线程池为空就新创建一个线程并执行。 else if (workerCountOf(recheck) == 0) addWorker(null, false); } //3. 通过addWorker(command, false)新建一个线程,并将任务(command)添加到该线程中;然后,启动该线程从而执行任务。 //如果addWorker(command, false)执行失败,则通过reject()执行相应的拒绝策略的内容。 else if (!addWorker(command, false)) reject(command); } ------ 如图 分析上面的例子,\n我们在代码中模拟了 10 个任务,我们配置的核心线程数为 5 、等待队列容量为 100 ,所以每次只可能存在 5 个任务同时执行,剩下的 5 个任务会被放到等待队列中去。当前的5个任务中如果有任务被执行完了,线程池就会去拿新的任务执行。\naddWorker 这个方法主要用来创建新的工作线程,如果返回 true 说明创建和启动工作线程成功,否则的话返回的就是 false。\n// 全局锁,并发操作必备 private final ReentrantLock mainLock = new ReentrantLock(); // 跟踪线程池的最大大小,只有在持有全局锁mainLock的前提下才能访问此集合 private int largestPoolSize; // 工作线程集合,存放线程池中所有的(活跃的)工作线程,只有在持有全局锁mainLock的前提下才能访问此集合 private final HashSet\u0026lt;Worker\u0026gt; workers = new HashSet\u0026lt;\u0026gt;(); //获取线程池状态 private static int runStateOf(int c) { return c \u0026amp; ~CAPACITY; } //判断线程池的状态是否为 Running private static boolean isRunning(int c) { return c \u0026lt; SHUTDOWN; } /** * 添加新的工作线程到线程池 * @param firstTask 要执行 * @param core参数为true的话表示使用线程池的基本大小,为false使用线程池最大大小 * @return 添加成功就返回true否则返回false */ private boolean addWorker(Runnable firstTask, boolean core) { retry: for (;;) { //这两句用来获取线程池的状态 int c = ctl.get(); int rs = runStateOf(c); // Check if queue empty only if necessary. if (rs \u0026gt;= SHUTDOWN \u0026amp;\u0026amp; ! (rs == SHUTDOWN \u0026amp;\u0026amp; firstTask == null \u0026amp;\u0026amp; ! workQueue.isEmpty())) return false; for (;;) { //获取线程池中工作的线程的数量 int wc = workerCountOf(c); // core参数为false的话表明队列也满了,线程池大小变为 maximumPoolSize if (wc \u0026gt;= CAPACITY || wc \u0026gt;= (core ? corePoolSize : maximumPoolSize)) return false; //原子操作将workcount的数量加1 if (compareAndIncrementWorkerCount(c)) break retry; // 如果线程的状态改变了就再次执行上述操作 c = ctl.get(); if (runStateOf(c) != rs) continue retry; // else CAS failed due to workerCount change; retry inner loop } } // 标记工作线程是否启动成功 boolean workerStarted = false; // 标记工作线程是否创建成功 boolean workerAdded = false; Worker w = null; try { w = new Worker(firstTask); final Thread t = w.thread; if (t != null) { // 加锁 final ReentrantLock mainLock = this.mainLock; mainLock.lock(); try { //获取线程池状态 int rs = runStateOf(ctl.get()); //rs \u0026lt; SHUTDOWN 如果线程池状态依然为RUNNING,并且线程的状态是存活的话,就会将工作线程添加到工作线程集合中 //(rs=SHUTDOWN \u0026amp;\u0026amp; firstTask == null)如果线程池状态小于STOP,也就是RUNNING或者SHUTDOWN状态下,同时传入的任务实例firstTask为null,则需要添加到工作线程集合和启动新的Worker // firstTask == null证明只新建线程而不执行任务 if (rs \u0026lt; SHUTDOWN || (rs == SHUTDOWN \u0026amp;\u0026amp; firstTask == null)) { if (t.isAlive()) // precheck that t is startable throw new IllegalThreadStateException(); workers.add(w); //更新当前工作线程的最大容量 int s = workers.size(); if (s \u0026gt; largestPoolSize) largestPoolSize = s; // 工作线程是否启动成功 workerAdded = true; } } finally { // 释放锁 mainLock.unlock(); } //// 如果成功添加工作线程,则调用Worker内部的线程实例t的Thread#start()方法启动真实的线程实例 if (workerAdded) { t.start(); /// 标记线程启动成功 workerStarted = true; } } } finally { // 线程启动失败,需要从工作线程中移除对应的Worker if (! workerStarted) addWorkerFailed(w); } return workerStarted; } 如何设定线程池的大小\n如果线程池中的线程太多,就会增加上下文切换的成本\n多线程编程中一般线程的个数都大于 CPU 核心的个数,而一个 CPU 核心在任意时刻只能被一个线程使用,为了让这些线程都能得到有效执行,CPU 采取的策略是为每个线程分配时间片并轮转的形式。当一个线程的时间片用完的时候就会重新处于就绪状态让给其他线程使用,这个过程就属于一次上下文切换。概括来说就是:当前任务在执行完 CPU 时间片切换到另一个任务之前会先保存自己的状态,以便下次再切换回这个任务时,可以再加载这个任务的状态。任务从保存到再加载的过程就是一次上下文切换。\n上下文切换通常是计算密集型的。也就是说,它需要相当可观的处理器时间,在每秒几十上百次的切换中,每次切换都需要纳秒量级的时间。所以,上下文切换对系统来说意味着消耗大量的 CPU 时间,事实上,可能是操作系统中时间消耗最大的操作。\nLinux 相比与其他操作系统(包括其他类 Unix 系统)有很多的优点,其中有一项就是,其上下文切换和模式切换的时间消耗非常少。\n过大跟过小都不行\n如果我们设置的线程池数量太小的话,如果同一时间有大量任务/请求需要处理,可能会导致大量的请求/任务在任务队列中排队等待执行,甚至会出现任务队列满了之后任务/请求无法处理的情况,或者大量任务堆积在任务队列导致 OOM 设置线程数量太大,大量线程可能会同时在争取 CPU 资源,这样会导致大量的上下文切换,从而增加线程的执行时间,影响了整体执行效率(对于CPU密集型任务不能使用这个,因为本来CPU资源就紧张,需要设置小一点,减小上下文切换) 简单且适用面较广的公式\nCPU 密集型任务(N+1): 这种任务消耗的主要是 CPU 资源,可以将线程数设置为 N(CPU 核心数)+1,比 CPU 核心数多出来的一个线程是为了防止线程偶发的缺页中断,或者其它原因导致的任务暂停而带来的影响。一旦任务暂停,CPU 就会处于空闲状态,而在这种情况下多出来的一个线程就可以充分利用 CPU 的空闲时间。\nI/O 密集型任务(2N): 这种任务应用起来,系统会用大部分的时间来处理 I/O 交互,而线程在处理 I/O 的时间段内不会占用 CPU 来处理,这时就可以将 CPU 交出给其它线程使用。因此在 I/O 密集型任务的应用中,我们可以多配置一些线程,具体的计算方法是 2N。\n如何判断是CPU密集任务还是IO密集任务\nCPU 密集型简单理解就是利用 CPU 计算能力的任务比如你在内存中对大量数据进行排序。但凡涉及到网络读取,文件读取这类都是 IO 密集型,这类任务的特点是 CPU 计算耗费时间相比于等待 IO 操作完成的时间来说很少,大部分时间都花在了等待 IO 操作完成上。\nAtomic原子类 # Atomic 英[əˈtɒmɪk]原子,即不可分割\n线程中,Atomic,指一个操作是不可中断的,即使在多线程一起执行时,一个操作一旦开始,就不会被其他线程干扰\n原子类,即具有原子/原子操作特性的类。并发包java.util.concurrent原子类都放在java.util.concurrent.atomit Java中存在四种原子类(基本、数组、引用、对象属性)\n基本类型:AtomicInteger,AtomicLong,AtomicBoolean 数组类型:AtomicIntegerArray,AtomicLongArray,AtomicReferenceArray 引用类型:AtomicReference,AtomicStampedReference([原子更新] 带有版本号的引用类型。该类将整数值与引用关联,解决原子的更新数据和数据的版本号,解决使用CAS进行原子更新可能出现的ABA问题),AtomicMarkableReference(原子更新带有标记位的引用类型) 对象属性修改类型:AtomicIntegerFiledUpdater原子更新整型字段的更新器;AtomicLongFiledUpdater;AtomicReferenceFieldUpdater 详见\nAQS # AQS介绍 全程,AbstractQueuedSynchronizer抽象队列同步器,在java.util.concurrent.locks包下 AQS是一个抽象类,主要用来构建锁和同步器\npublic abstract class AbstractQueuedSynchronizer extends AbstractOwnableSynchronizer implements java.io.Serializable { } AQS 为构建锁和同步器提供了一些通用功能的实现,能简单且高效地构造出大量应用广泛的同步器,例如ReentrantLock,Semaphore[ˈseməfɔː(r)]以及ReentrantReadWriteLock,SynchronousQueue 等等都基于AQS\nAQS原理分析\n面试不是背题,大家一定要加入自己的思想,即使加入不了自己的思想也要保证自己能够通俗的讲出来而不是背出来\nAQS 核心思想是,如果被请求的共享资源空闲,则将当前请求资源的线程设置为有效的工作线程,并且将共享资源设置为锁定状态。如果被请求的共享资源被占用,那么就需要一套线程阻塞等待以及被唤醒时锁分配的机制,这个机制 AQS 是用 CLH 队列锁实现的,即将暂时获取不到锁的线程加入到队列中。\nCLH(Craig,Landin and Hagersten)队列是一个虚拟的双向队列(虚拟的双向队列即不存在队列实例,仅存在结点之间的关联关系)。AQS 是将每条请求共享资源的线程封装成一个 CLH 锁队列的一个结点(Node)来实现锁的分配。 搜索了一下,CLH好像是人名\nCLH队列结构如下图所示\nAQS(AbstractQueuedSynchronized)原理图\nAQS 使用 int 成员变量 state 表示同步状态,通过内置的 线程等待队列 来完成获取资源线程的排队工作。\n//state 变量由 volatile 修饰,用于展示当前临界资源的获锁情况。 private volatile int state;//共享变量,使用volatile修饰保证线程可见性 状态信息的操作\n//返回同步状态的当前值 protected final int getState() { return state; } //设置同步状态的值 protected final void setState(int newState) { state = newState; } //原子地(CAS操作)将同步状态值设置为给定值update如果当前同步状态的值等于expect(期望值) protected final boolean compareAndSetState(int expect, int update) { return unsafe.compareAndSwapInt(this, stateOffset, expect, update); } 以 ReentrantLock 为例,state 初始值为 0,表示未锁定状态。A 线程 lock() 时,会调用 tryAcquire() 独占该锁并将 state+1 。此后,其他线程再 tryAcquire() 时就会失败,直到 A 线程 unlock() 到 state=0(即释放锁)为止,其它线程才有机会获取该锁。当然,释放锁之前,A 线程自己是可以重复获取此锁的(state 会累加),这就是可重入的概念。但要注意,获取多少次就要释放多少次,这样才能保证 state 是能回到零态的。\n以 CountDownLatch 为例,任务分为 N 个子线程去执行,state 也初始化为 N(注意 N 要与线程个数一致)。这 N 个子线程是并行执行的,每个子线程执行完后countDown() 一次,state 会 CAS(Compare and Swap) 减 1。等到所有子线程都执行完后(即 state=0 ),会 unpark() 主调用线程,然后主调用线程就会从 await() 函数返回,继续后余动作\n//例子 public class TestCountDownLatch { public static void main(String[] args) { CountDownLatch countDownLatch=new CountDownLatch(3); new Thread(()-\u0026gt;{ try { TimeUnit.SECONDS.sleep(1); } catch (InterruptedException e) { e.printStackTrace(); } System.out.println(Thread.currentThread().getName()+\u0026#34;执行完毕\u0026#34;); countDownLatch.countDown(); },\u0026#34;线程1\u0026#34;).start(); new Thread(()-\u0026gt;{ try { TimeUnit.SECONDS.sleep(2); } catch (InterruptedException e) { e.printStackTrace(); } System.out.println(Thread.currentThread().getName()+\u0026#34;执行完毕\u0026#34;); countDownLatch.countDown(); },\u0026#34;线程2\u0026#34;).start(); new Thread(()-\u0026gt;{ try { TimeUnit.SECONDS.sleep(3); } catch (InterruptedException e) { e.printStackTrace(); } System.out.println(Thread.currentThread().getName()+\u0026#34;执行完毕\u0026#34;); countDownLatch.countDown(); },\u0026#34;线程3\u0026#34;).start(); try { System.out.println(Thread.currentThread().getName()+\u0026#34;等待中....\u0026#34;); countDownLatch.await();//阻塞 System.out.println(Thread.currentThread().getName()+\u0026#34;等待完毕,继续执行\u0026#34;); } catch (InterruptedException e) { e.printStackTrace(); } } } /* main等待中.... 线程1执行完毕 线程2执行完毕 线程3执行完毕 main等待完毕,继续执行 */ Semaphore # Semaphore 有什么用? synchronized 和 ReentrantLock 都是一次只允许一个线程访问某个资源,而Semaphore(信号量)可以用来控制同时访问特定资源的线程数量。\n//使用 public class TestSemaphore { public static void main(String[] args) { Semaphore semaphore = new Semaphore(3);//能同时运行3个 for (int i = 0; i \u0026lt; 15; i++) { int finalI = i; new Thread(() -\u0026gt; { try { semaphore.acquire();//获取通行证 System.out.println(Thread.currentThread().getName() + \u0026#34;执行中...\u0026#34;); TimeUnit.SECONDS.sleep(finalI); System.out.println(Thread.currentThread().getName() + \u0026#34;释放了通行证\u0026#34;); semaphore.release(); } catch (InterruptedException e) { e.printStackTrace(); } },\u0026#34;线程\u0026#34;+finalI).start(); } } } /*结果 线程0执行中... 线程2执行中... 线程1执行中... 线程0释放了通行证 线程3执行中... 线程1释放了通行证 线程4执行中... 线程2释放了通行证 线程5执行中... 线程3释放了通行证 线程6执行中... 线程4释放了通行证 线程7执行中... 线程5释放了通行证 线程8执行中... 线程6释放了通行证 线程10执行中... 线程7释放了通行证 线程11执行中... 线程8释放了通行证 线程9执行中... 线程10释放了通行证 线程12执行中... 线程11释放了通行证 线程13执行中... 线程9释放了通行证 线程14执行中... 线程12释放了通行证 线程13释放了通行证 线程14释放了通行证 */ Semaphore 的使用简单,我们这里假设有 N(N\u0026gt;5) 个线程来获取 Semaphore 中的共享资源,下面的代码表示同一时刻 N 个线程中只有 5 个线程能获取到共享资源,其他线程都会阻塞,只有获取到共享资源的线程才能执行。等到有线程释放了共享资源,其他阻塞的线程才能获取到\n// 初始共享资源数量 final Semaphore semaphore = new Semaphore(5); // 获取1个许可 semaphore.acquire(); // 释放1个许可 semaphore.release(); 当初始的资源个数为 1 的时候,Semaphore 退化为排他锁。\nSemaphore对应的两个构造方法\npublic Semaphore(int permits) { sync = new NonfairSync(permits); } public Semaphore(int permits, boolean fair) { sync = fair ? new FairSync(permits) : new NonfairSync(permits); } 这两个构造方法,都必须提供许可的数量,第二个构造方法可以指定是公平模式还是非公平模式,默认非公平模式。\nSemaphore 通常用于那些资源有明确访问数量限制的场景比如限流(仅限于单机模式,实际项目中推荐使用 Redis +Lua 来做限流)。\nSemaphore 的原理是什么?\nSemaphore 是共享锁的一种实现,它默认构造 AQS 的 state 值为 permits,你可以将 permits 的值理解为许可证的数量,只有拿到许可证的线程才能执行。\n调用semaphore.acquire() ,线程尝试获取许可证,如果 state \u0026gt;= 0 的话,则表示可以获取成功。如果获取成功的话,使用 CAS 操作去修改 state 的值 state=state-1。如果 state\u0026lt;0 的话,则表示许可证数量不足。此时会创建一个 Node 节点加入阻塞队列,挂起当前线程。\n/** * 获取1个许可证 */ public void acquire() throws InterruptedException { sync.acquireSharedInterruptibly(1); } /** * 共享模式下获取许可证,获取成功则返回,失败则加入阻塞队列,挂起线程 */ public final void acquireSharedInterruptibly(int arg) throws InterruptedException { if (Thread.interrupted()) throw new InterruptedException(); // 尝试获取许可证,arg为获取许可证个数,当可用许可证数减当前获取的许可证数结果小于0,则创建一个节点加入阻塞队列,挂起当前线程。 if (tryAcquireShared(arg) \u0026lt; 0) doAcquireSharedInterruptibly(arg); } 调用semaphore.release(); ,线程尝试释放许可证,并使用 CAS 操作去修改 state 的值 state=state+1。释放许可证成功之后,同时会唤醒同步队列中的一个线程。被唤醒的线程会重新尝试去修改 state 的值 state=state-1 ,如果 state\u0026gt;=0 则获取令牌成功,否则重新进入阻塞队列,挂起线程。\n// 释放一个许可证 public void release() { sync.releaseShared(1); } // 释放共享锁,同时会唤醒同步队列中的一个线程。 public final boolean releaseShared(int arg) { //释放共享锁 if (tryReleaseShared(arg)) { //唤醒同步队列中的一个线程 doReleaseShared(); return true; } return false; } CountDownLatch # CountDownLatch有什么用\nCountDownLatch 允许 count 个线程阻塞在一个地方(一般例子是阻塞在主线程中 countDownLatch.await()),直至所有线程的任务都执行完毕**(再从阻塞的地方继续执行)**。 CountDownLatch 是一次性的,计数器的值只能在构造方法中初始化一次,之后没有任何机制再次对其设置值,当 CountDownLatch 使用完毕后,它不能再次被使用。 CountDownLatch的原理是什么 CountDownLatch 是共享锁的一种实现,它默认构造 AQS 的 state 值为 count。当线程使用 countDown() 方法时,其实使用了**tryReleaseShared方法以 CAS 的操作来减少 state,直至 state 为 0 。当调用 await() 方法的时候,如果 state 不为 0,那就证明任务还没有执行完毕,await() 方法就会一直阻塞**,也就是说 await() 方法之后的语句不会被执行。然后,CountDownLatch 会自旋 CAS 判断 state == 0,如果 state == 0 的话,就会释放所有等待的线程,await() 方法之后的语句得到执行。\n用过 CountDownLatch 么?什么场景下用的?\nCountDownLatch 的作用就是 允许 count 个线程阻塞在一个地方,直至所有线程的任务都执行完毕。之前在项目中,有一个使用多线程读取多个文件处理的场景,我用到了 CountDownLatch 。具体场景是下面这样的:\n我们要读取处理 6 个文件,这 6 个任务都是没有执行顺序依赖的任务,但是我们需要返回给用户的时候将这几个文件的处理的结果进行统计整理。\n为此我们定义了一个线程池和 count 为 6 的CountDownLatch对象 。使用线程池处理读取任务,每一个线程处理完之后就将 count-1,调用CountDownLatch对象的 await()方法,直到所有文件读取完之后,才会接着执行后面的逻辑。\n//伪代码 public class CountDownLatchExample1 { // 处理文件的数量 private static final int threadCount = 6; public static void main(String[] args) throws InterruptedException { // 创建一个具有固定线程数量的线程池对象(推荐使用构造方法创建) ExecutorService threadPool = Executors.newFixedThreadPool(10); final CountDownLatch countDownLatch = new CountDownLatch(threadCount); for (int i = 0; i \u0026lt; threadCount; i++) { final int threadnum = i; threadPool.execute(() -\u0026gt; { try { //处理文件的业务操作 //...... } catch (InterruptedException e) { e.printStackTrace(); } finally { //表示一个文件已经被完成 countDownLatch.countDown(); } }); } countDownLatch.await(); //这里应该是要对threadCound个线程的结果,进行汇总 threadPool.shutdown(); System.out.println(\u0026#34;finish\u0026#34;); } } 上面的例子,也可以用CompletableFuture进行改进\nJava8 的 CompletableFuture 提供了很多对多线程友好的方法,使用它可以很方便地为我们编写多线程程序,什么异步、串行、并行或者等待所有线程执行完任务什么的都非常方便。\nCompletableFuture\u0026lt;Void\u0026gt; task1 = CompletableFuture.supplyAsync(()-\u0026gt;{ //自定义业务操作 }); ...... CompletableFuture\u0026lt;Void\u0026gt; task6 = CompletableFuture.supplyAsync(()-\u0026gt;{ //自定义业务操作 }); ...... CompletableFuture\u0026lt;Void\u0026gt; headerFuture=CompletableFuture.allOf(task1,.....,task6); try { headerFuture.join(); } catch (Exception ex) { //...... } System.out.println(\u0026#34;all done. \u0026#34;); 通过循环添加任务\n//文件夹位置 List\u0026lt;String\u0026gt; filePaths = Arrays.asList(...) // 异步处理所有文件 List\u0026lt;CompletableFuture\u0026lt;String\u0026gt;\u0026gt; fileFutures = filePaths.stream() .map(filePath -\u0026gt; doSomeThing(filePath)) .collect(Collectors.toList()); // 将他们合并起来 CompletableFuture\u0026lt;Void\u0026gt; allFutures = CompletableFuture.allOf( fileFutures.toArray(new CompletableFuture[fileFutures.size()]) ); CyclicBarrier # //使用场景,不太一样的是,它一般是让子任务阻塞后,到时候一起执行 public class TestCyclicBarrier { public static void main(String[] args) { CyclicBarrier cyclicBarrier = new CyclicBarrier(3, () -\u0026gt; { try { TimeUnit.SECONDS.sleep(3); } catch (InterruptedException e) { e.printStackTrace(); } System.out.println(Thread.currentThread().getName() + \u0026#34;执行咯\u0026#34;); }); for (int n = 0; n \u0026lt; 15; n++) { int finalN = n; new Thread(() -\u0026gt; { try { TimeUnit.SECONDS.sleep(finalN); System.out.println(Thread.currentThread().getName() + \u0026#34;数据都准备好了,等待中....\u0026#34;); cyclicBarrier.await(); } catch (InterruptedException | BrokenBarrierException e) { e.printStackTrace(); } System.out.println(Thread.currentThread().getName() + \u0026#34;出发咯!\u0026#34;); }, \u0026#34;线程\u0026#34; + n).start(); } } } /* 线程0数据都准备好了,等待中.... 线程1数据都准备好了,等待中.... 线程2数据都准备好了,等待中.... 线程3数据都准备好了,等待中.... 线程4数据都准备好了,等待中.... 线程5数据都准备好了,等待中.... 线程2执行咯 线程2出发咯! 线程6数据都准备好了,等待中.... 线程7数据都准备好了,等待中.... 线程8数据都准备好了,等待中.... 线程5执行咯 线程5出发咯! 线程0出发咯! 线程1出发咯! 线程9数据都准备好了,等待中.... 线程10数据都准备好了,等待中.... 线程11数据都准备好了,等待中.... 线程8执行咯 线程3出发咯! 线程8出发咯! 线程4出发咯! 线程12数据都准备好了,等待中.... 线程13数据都准备好了,等待中.... 线程14数据都准备好了,等待中.... 线程11执行咯 线程11出发咯! 线程6出发咯! 线程7出发咯! 线程14执行咯 线程14出发咯! 线程12出发咯! 线程10出发咯! 线程9出发咯! 线程13出发咯! Process finished with exit code 0 */ CyclicBarrier 有什么用?\nCyclicBarrier 和 CountDownLatch 非常类似,它也可以实现线程间的技术等待,但是它的功能比 CountDownLatch 更加复杂和强大。主要应用场景和 CountDownLatch 类似。\nCountDownLatch 的实现是基于 AQS 的,而 CycliBarrier 是基于 **ReentrantLock(ReentrantLock 也属于 AQS 同步器)**和 Condition 的。\nCyclicBarrier 的字面意思是可循环使用(Cyclic)的屏障(Barrier)。它要做的事情是:让一组线程到达一个屏障(也可以叫同步点)时被阻塞,直到最后一个线程到达屏障时,屏障才会开门,所有被屏障拦截的线程才会继续干活。\nCyclicBarrier的原理\nCyclicBarrier 内部通过一个 count 变量作为计数器,count 的初始值为 parties 属性的初始化值,每当一个线程到了栅栏这里了,那么就将计数器减 1。如果 count 值为 0 了,表示这是这一代最后一个线程到达栅栏,就尝试执行我们构造方法中输入的任务,之后再从线程阻塞的位置继续执行。\n//每次拦截的线程数, 注意:这个是不可变的哦 private final int parties; //计数器 private int count; 结合源码\nCyclicBarrier 默认的构造方法是 CyclicBarrier(int parties),其参数表示屏障拦截的线程数量,每个线程调用 await() 方法告诉 CyclicBarrier 我已经到达了屏障,然后当前线程被阻塞。\npublic CyclicBarrier(int parties) { this(parties, null); } public CyclicBarrier(int parties, Runnable barrierAction) { if (parties \u0026lt;= 0) throw new IllegalArgumentException(); this.parties = parties; this.count = parties; this.barrierCommand = barrierAction; } 其中,parties 就代表了有(需要)拦截的线程的数量,当拦截的线程数量达到这个值的时候就打开栅栏,让所有线程通过。\n当调用 CyclicBarrier 对象调用 await() 方法时,实际上调用的是 dowait(false, 0L)方法。 await() 方法就像树立起一个栅栏的行为一样,将线程挡住了,当拦住的线程数量达到 parties 的值时,栅栏才会打开,线程才得以通过执行\nublic int await() throws InterruptedException, BrokenBarrierException { try { return dowait(false, 0L); } catch (TimeoutException toe) { throw new Error(toe); // cannot happen } } dowait(false,0L)方法源码如下\n// 当线程数量或者请求数量达到 count 时 await 之后的方法才会被执行。上面的示例中 count 的值就为 5。 private int count; /** * Main barrier code, covering the various policies. */ private int dowait(boolean timed, long nanos) throws InterruptedException, BrokenBarrierException, TimeoutException { final ReentrantLock lock = this.lock; // 锁住 lock.lock(); try { final Generation g = generation; if (g.broken) throw new BrokenBarrierException(); // 如果线程中断了,抛出异常 if (Thread.interrupted()) { breakBarrier(); throw new InterruptedException(); } // cout减1 int index = --count; // 当 count 数量减为 0 之后说明最后一个线程已经到达栅栏了,也就是达到了可以执行await 方法之后的条件 if (index == 0) { // tripped boolean ranAction = false; try { final Runnable command = barrierCommand; if (command != null) command.run(); ranAction = true; // 将 count 重置为 parties 属性的初始化值 // 唤醒之前等待的线程 // 下一波执行开始 nextGeneration(); return 0; } finally { if (!ranAction) breakBarrier(); } } // loop until tripped, broken, interrupted, or timed out for (;;) { try { if (!timed) trip.await(); else if (nanos \u0026gt; 0L) nanos = trip.awaitNanos(nanos); } catch (InterruptedException ie) { if (g == generation \u0026amp;\u0026amp; ! g.broken) { breakBarrier(); throw ie; } else { // We\u0026#39;re about to finish waiting even if we had not // been interrupted, so this interrupt is deemed to // \u0026#34;belong\u0026#34; to subsequent execution. Thread.currentThread().interrupt(); } } if (g.broken) throw new BrokenBarrierException(); if (g != generation) return index; if (timed \u0026amp;\u0026amp; nanos \u0026lt;= 0L) { breakBarrier(); throw new TimeoutException(); } } } finally { lock.unlock(); } } 三者区别 # CountDownLatch也能实现CyclicBarrier类似功能,不过它的栅栏被推到后就不会再重新存在了(CyclicBarrier会重新建立栅栏)\nCountDownLatch countDownLatch=new CountDownLatch(5); new Thread(()-\u0026gt;{ try { countDownLatch.await(); log.info(\u0026#34;栅栏被推开了\u0026#34;); } catch (InterruptedException e) { e.printStackTrace(); } },\u0026#34;线程A\u0026#34;).start(); new Thread(()-\u0026gt;{ try { countDownLatch.await(); log.info(\u0026#34;栅栏被推开了\u0026#34;); } catch (InterruptedException e) { e.printStackTrace(); } },\u0026#34;线程B\u0026#34;).start(); new Thread(()-\u0026gt;{ try { countDownLatch.await(); log.info(\u0026#34;栅栏被推开了\u0026#34;); } catch (InterruptedException e) { e.printStackTrace(); } },\u0026#34;线程C\u0026#34;).start(); new Thread(()-\u0026gt;{ try { countDownLatch.await(); log.info(\u0026#34;栅栏被推开了\u0026#34;); } catch (InterruptedException e) { e.printStackTrace(); } },\u0026#34;线程D\u0026#34;).start(); new Thread(()-\u0026gt;{ try { countDownLatch.await(); log.info(\u0026#34;栅栏被推开了\u0026#34;); } catch (InterruptedException e) { e.printStackTrace(); } },\u0026#34;线程E\u0026#34;).start(); new Thread(()-\u0026gt;{ //countDownLatch.await(); for(int i=0;i\u0026lt;5;i++) { //每隔一秒钟推开一个栅栏 try { TimeUnit.SECONDS.sleep(1); } catch (InterruptedException e) { e.printStackTrace(); } log.info(\u0026#34;推开一个栅栏\u0026#34;); countDownLatch.countDown(); } },\u0026#34;线程F\u0026#34;).start(); while (true){} /**输出 2023-03-07 23:23:19 下午 [Thread: 线程F] INFO:推开一个栅栏 2023-03-07 23:23:20 下午 [Thread: 线程F] INFO:推开一个栅栏 2023-03-07 23:23:21 下午 [Thread: 线程F] INFO:推开一个栅栏 2023-03-07 23:23:22 下午 [Thread: 线程F] INFO:推开一个栅栏 2023-03-07 23:23:24 下午 [Thread: 线程F] INFO:推开一个栅栏 ///////////////////////////////////////////推开5个栅栏后(这里是一个线程推开五个,也可以5个线程-\u0026gt;每个各推开一个),5个被阻塞的线程一起执行了 2023-03-07 23:23:24 下午 [Thread: 线程A] INFO:栅栏被推开了 2023-03-07 23:23:24 下午 [Thread: 线程E] INFO:栅栏被推开了 2023-03-07 23:23:24 下午 [Thread: 线程C] INFO:栅栏被推开了 2023-03-07 23:23:24 下午 [Thread: 线程D] INFO:栅栏被推开了 2023-03-07 23:23:24 下午 [Thread: 线程B] INFO:栅栏被推开了 */ "},{"id":327,"href":"/zh/docs/technology/Review/java_guide/java/Concurrent/ly03122lylock_escalation/","title":"锁升级","section":"并发","content":" 以下内容均转自 https://www.cnblogs.com/wuqinglong/p/9945618.html,部分疑惑参考自另一作者 https://github.com/farmerjohngit/myblog/issues/12 ,感谢原作者。\n【目前还是存有部分疑虑(轻量级锁那块),可能需要详细看源码才能释疑】\n概述 # 传统的synchronized为重量级锁(使用操作系统互斥量(mutex)来实现的传统锁),但是随着JavaSE1.6对synchronized优化后,部分情况下他就没有那么重了。本文介绍了JavaSE1.6为了减少获得锁和释放锁带来的性能消耗而引入的偏向锁和轻量级锁,以及锁结构、及锁升级过程\n实现同步的基础 # Java中每个对象都可以作为锁,具体变现形式\n对于普通同步方法,锁是当前实例对象 对于静态同步方法,锁是当前类的Class对象 对于同步方法块,锁是synchronized括号里配置的对象 一个线程试图访问同步代码块时,必须获取锁;在退出或者抛出异常时,必须释放锁\n实现方式 # JVM 基于进入和退出 Monitor 对象来实现方法同步和代码块同步,但是两者的实现细节不一样\n代码块同步:通过使用 monitorenter 和 monitorexit 指令实现的 同步方法:ACC_SYNCHRONIZED 修饰 monitorenter 指令是在编译后插入到同步代码块的开始位置,而 monitorexit 指令是在编译后插入到同步代码块的结束处或异常处\n对于同步方法,进入方法前添加一个 monitorenter 指令,退出方法后添加一个 monitorexit 指令。\ndemo:\npublic class Demo { public void f1() { synchronized (Demo.class) { System.out.println(\u0026#34;Hello World.\u0026#34;); } } public synchronized void f2() { System.out.println(\u0026#34;Hello World.\u0026#34;); } } 编译之后的字节码(使用 javap )\npublic void f1(); descriptor: ()V flags: ACC_PUBLIC Code: stack=2, locals=3, args_size=1 0: ldc #2 // class me/snail/base/Demo 2: dup 3: astore_1 4: monitorenter 5: getstatic #3 // Field java/lang/System.out:Ljava/io/PrintStream; 8: ldc #4 // String Hello World. 10: invokevirtual #5 // Method java/io/PrintStream.println:(Ljava/lang/String;)V 13: aload_1 14: monitorexit 15: goto 23 18: astore_2 19: aload_1 20: monitorexit 21: aload_2 22: athrow 23: return Exception table: from to target type 5 15 18 any 18 21 18 any LineNumberTable: line 6: 0 line 7: 5 line 8: 13 line 9: 23 StackMapTable: number_of_entries = 2 frame_type = 255 /* full_frame */ offset_delta = 18 locals = [ class me/snail/base/Demo, class java/lang/Object ] stack = [ class java/lang/Throwable ] frame_type = 250 /* chop */ offset_delta = 4 public synchronized void f2(); descriptor: ()V flags: ACC_PUBLIC, ACC_SYNCHRONIZED Code: stack=2, locals=1, args_size=1 0: getstatic #3 // Field java/lang/System.out:Ljava/io/PrintStream; 3: ldc #4 // String Hello World. 5: invokevirtual #5 // Method java/io/PrintStream.println:(Ljava/lang/String;)V 8: return LineNumberTable: line 12: 0 line 13: 8 先说 f1() 方法,发现其中一个 monitorenter 对应了两个 monitorexit,这是不对的。但是仔细看 #15: goto 语句,直接跳转到了 #23: return 处,再看 #22: athrow 语句发现,原来第二个 monitorexit 是保证同步代码块抛出异常时锁能得到正确的释放而存在的,这就理解了。\nJava对象头(存储锁类型) # HotSpot虚拟机中,对象在内存中的布局分为三块区域:对象头、实例数据、对齐填充\n对象头又包括两部分:MarkWord和类型指针,对于数组对象,对象头中还有一部分时存储数组的长度\n多线程下synchronized的加锁,就是对同一个对象的对象头中的MarkWord中的变量进行CAS操作\nMarkWord\n类型指针 虚拟机通过这个指针确定该对象是哪个类的实例\n对象头的长度\n长度 内容 说明 32/64bit MarkWord 存储对象的hashCode或锁信息等 32/64bit Class Metadada Address 存储对象类型数据的指针 32/64bit Array Length 数组的长度(如果当前对象是数组) 如果是数组对象的话,虚拟机用3个字宽(32/64bit + 32/64bit + 32/64bit)存储对象头,如果是普通对象的话,虚拟机用2字宽存储对象头(32/64bit + 32/64bit)。\n32位的字宽为32bit,64位的字宽位64bit\n优化后synchronized锁的分类 # 级别从低到高依次是:无锁状态 -\u0026gt; 偏向锁状态 -\u0026gt; 轻量级锁状态 -\u0026gt; 重量级锁状态\n锁可以升级,但不能降级,即顺序为单向\n下面以32位系统为例,每个锁状态下,每个字宽中的内容\n无锁状态\n25bit 4bit 1bit(是否是偏向锁) 2bit(锁标志位) 对象的hashCode 对象分代年龄 0 01 这里的 hashCode 是 Object#hashCode 或者 System#identityHashCode 计算出来的值,不是用户覆盖产生的 hashCode。\n偏向锁状态\n25bit 4bit 1bit(是否是偏向锁) 2bit(锁标志位) 线程ID epoch 1 01 这里 线程ID 和 epoch 占用了 hashCode 的位置,所以,如果对象如果计算过 identityHashCode 后,便无法进入偏向锁状态,反过来,如果对象处于偏向锁状态,并且需要计算其 identityHashCode 的话,则偏向锁会被撤销,升级为重量级锁。 对于偏向锁,如果线程ID=0 表示为加锁\n什么时候会计算 HashCode 呢?比如:将对象作为 Map 的 Key 时会自动触发计算,List 就不会计算,日常创建一个对象,持久化到库里,进行 json 序列化,或者作为临时对象等,这些情况下,并不会触发计算 hashCode,所以大部分情况不会触发计算 hashCode。\nIdentity hash code是未被覆写的 java.lang.Object.hashCode() 或者 java.lang.System.identityHashCode(Object) 所返回的值。\n轻量级锁状态\n30bit 2bit 指向 线程栈 锁记录的指针 00 这里指向栈帧中的LockRecord记录,里面当然可以记录对象的identityHashCode\n重量级锁状态\n30bit 2bit 指向锁监视器的指针 10 这里指向了内存中对象的 ObjectMonitor 对象,而 ObectMontitor 对象可以存储对象的 identityHashCode 的值。\n锁的升级 # 偏向锁 # 偏向锁是针对于一个线程而言的,线程获得锁之后就不会再有解锁等操作了,这样可以省略很多开销。假如有两个线程来竞争该锁话,那么偏向锁就失效了,进而升级成轻量级锁了【注意这段解释,网上很多都错了,没有什么CAS失败才升级,只要有线程来抢,就直接升级为轻量级锁】\n为什么要这样做呢?因为经验表明,其实大部分情况下,都会是同一个线程进入同一块同步代码块的。这也是为什么会有偏向锁出现的原因。\n如果支持偏向锁(没有计算 hashCode),那么在分配(创建)对象时,分配一个可偏向而未偏向的对象(MarkWord的最后 3 位为 101,并且 Thread Id 字段的值为 0)\n1. 偏向锁的加锁 # 偏向锁标志是未偏向状态,使用 CAS 将 MarkWord 中的线程ID设置为自己的线程ID\n如果成功,则获取偏向锁成功 如果失败,则进行锁升级(也就是被别人抢了,没抢过) 偏向锁状态是已偏向状态\nMarkWord中的线程ID是自己的线程ID,则成功获取锁\nMarkWord中的线程ID不是自己的线程ID,则需要进行锁升级\n注意,这里说的锁升级,需要进行偏向锁的撤销\n2. 偏向锁的撤销 # 前提:撤销偏向的操作需要在全局检查点执行 。我们假设线程A曾经拥有锁(不确定是否释放锁), 线程B来竞争锁对象,如果当线程A不再拥有锁时或者死亡时,线程B直接去尝试获得锁(根据是否 允许重偏向(rebiasing),获得偏向锁或者轻量级锁);如果线程A仍然拥有锁,那么锁 升级为轻量级锁,线程B自旋请求获得锁。\n对象是不可偏向状态 不需要撤销\n对象是可偏向状态\n如果MarkWord中指向的线程不存活 (这里说的是拥有偏向锁的线程正常执行完毕后释放锁,不存活那一定要释放锁咯) 如果允许重偏向(rebiasing),则退回到可偏向但未偏向的状态;如果不允许重偏向,则变为无锁状态 如果MarkWord中的线程仍然存活(注意,关注的是存活,不是是否拥有锁) (这里说的是拥有偏向锁的线程未执行完毕但进行了锁撤销:(包括释放锁及未释放锁(有线程来抢)两种情形)) 如果线程ID指向的线程仍然拥有锁,则**★★升级为轻量级锁,MarkWord复制到线程栈中(很重要)★★;如果线程ID不再拥有锁**(那个线程已经释放了锁),则同样是退回到可偏向(如果允许)但未偏向的状态(即线程ID未空),如果不允许重偏向,则变为无锁状态 偏向锁的撤销流程如图:\n轻量级锁 # 之所以称为轻量级,是因为它仅仅使用CAS进行操作,实现获取锁\n1. 加锁流程 # 如果线程发现对象头中Mark Word已经存在指向自己栈帧的指针,即线程已经获得轻量级锁,那么只需要将0存储在自己的栈帧中(此过程称为递归加锁);在解锁的时候,如果发现锁记录的内容为0, 那么只需要移除栈帧中的锁记录即可,而不需要更新Mark Word。\n线程尝试使用 CAS 将对象头中的 Mark Word 替换为指向锁记录(Lock Record)的指针(★如果当前锁的状态不是无锁状态,则CAS失败★很重要,不然后面有一堆疑问),如果成功当前线程获得轻量级锁, 如上图所示。(我觉得**★这里的CAS,原值为原来的markword,而不是指向其他线程的线程栈地址,否则这样意义就不对了,会导致别的线程执行到一半失去锁【注意:要结合下面的撤销流程看,锁是不会降级的,但是会撤销。撤销后对象头就变为加锁前了(但不是01哦,轻量级锁是00)】★**)\n如果成功,当前线程获得轻量级锁 如果失败,虚拟机先检查当前对象头的 Mark Word 是否指向当前线程的栈帧 如果指向,则说明当前线程已经拥有这个对象的锁,则可以直接进入同步块 执行操作 否则表示其他线程竞争锁,当前线程便尝试使用自旋来获取锁。当竞争线程的自旋次数 达到界限值(threshold),轻量级锁将会膨胀为重量级锁。 2. 撤销流程 # 轻量级锁解锁时,如果对象的Mark Word仍然指向着线程的锁记录(LockRecord),会使用CAS操作, 将Dispalced Mark Word替换到对象头,如果成功,则表示没有竞争发生。如果失败, 表示当前锁存在锁竞争,锁就会膨胀为重量级锁。\n重量级锁 # 重量级锁(heavy weight lock),是使用操作系统互斥量(mutex)来实现的传统锁。 当所有对锁的优化都失效时,将退回到重量级锁。它与轻量级锁不同竞争的线程不再通过自旋来竞争线程, 而是直接进入堵塞状态,此时不消耗CPU,然后等拥有锁的线程释放锁后,唤醒堵塞的线程, 然后线程再次竞争锁。但是注意,当锁膨胀(inflate)为重量锁时,就不能再退回到轻量级锁。\n总结 # 首先要明确一点是引入这些锁是为了提高获取锁的效率, 要明白每种锁的使用场景, 比如偏向锁适合一个线程对一个锁的多次获取的情况; 轻量级锁适合锁执行体比较简单(即减少锁粒度或时间), 自旋一会儿就可以成功获取锁的情况.\n要明白MarkWord中的内容表示的含义.\n"},{"id":328,"href":"/zh/docs/technology/Review/java_guide/java/Concurrent/lock_escalation_deprecated2/","title":"(该文弃用)锁升级","section":"并发","content":"本文主要讲解synchronized原理和偏向锁、轻量级锁、重量级锁的升级过程,基本都转自\nhttps://blog.csdn.net/MariaOzawa/article/details/107665689 原作者: MariaOzawa\n简介 # 为什么需要锁\n并发编程中,多个线程访问同一共享资源时,必须考虑如何维护数据的原子性 历史 JDK1.5之前,Java依靠Synchronized关键字实现锁功能,Synchronized是Jvm实现的内置锁,锁的获取与释放由JVM隐式实现 JDK1.5,并发包新增Lock接口实现锁功能,提供同步功能,使用时显式获取和释放锁 区别 Lock同步锁基于Java实现,Synchronized基于底层操作系统的MutexLock实现 /ˈmjuːtɛks/ ,每次获取和释放锁都会带来用户态和内核态的切换,从而增加系统性能开销,性能糟糕,又称重量级锁 JDK1.6之后,对Synchronized同步锁做了充分优化 Synchronized同步锁实现原理 # Synchronized实现同步锁的两种方式:修饰方法;修饰方法块\n// 关键字在实例方法上,锁为当前实例 public synchronized void method1() { // code } // 关键字在代码块上,锁为括号里面的对象 public void method2() { Object o = new Object(); synchronized (o) { // code } } 这里使用编译\u0026ndash;及javap 打印字节文件\njavac -encoding UTF-8 SyncTest.java //先运行编译class文件命令 javap -v SyncTest.class //再通过javap打印出字节文件 结果如下,Synchronized修饰代码块时,由monitorenter和monitorexist指令实现同步。进入monitorenter指令后线程持有Monitor对象;退出monitorenter指令后,线程释放该Monitor对象\npublic void method2(); descriptor: ()V flags: ACC_PUBLIC Code: stack=2, locals=4, args_size=1 0: new #2 3: dup 4: invokespecial #1 7: astore_1 8: aload_1 9: dup 10: astore_2 11: monitorenter //monitorenter 指令 12: aload_2 13: monitorexit //monitorexit 指令 14: goto 22 17: astore_3 18: aload_2 19: monitorexit 20: aload_3 21: athrow 22: return Exception table: from to target type 12 14 17 any 17 20 17 any LineNumberTable: line 18: 0 line 19: 8 line 21: 12 line 22: 22 StackMapTable: number_of_entries = 2 frame_type = 255 /* full_frame */ offset_delta = 17 locals = [ class com/demo/io/SyncTest, class java/lang/Object, class java/lang/Object ] stack = [ class java/lang/Throwable ] frame_type = 250 /* chop */ offset_delta = 4 如果Synchronized修饰同步方法,代替monitorenter和monitorexit的是 ACC_SYNCHRONIZED标志,即:JVM使用该访问标志区分方法是否为同步方法。方法调用时,调用指令检查是否设置ACC_SYNCHRONIZED标志,如有,则执行线程先持有该Monitor对象,再执行该方法;运行期间,其他线程无法获取到该Monitor对象;方法执行完成后,释放该Monitor对象 javap -v xx.class 字节文件查看\npublic synchronized void method1(); descriptor: ()V flags: ACC_PUBLIC, ACC_SYNCHRONIZED // ACC_SYNCHRONIZED 标志 Code: stack=0, locals=1, args_size=1 0: return LineNumberTable: line 8: 0 Monitor:JVM中的同步是基于进入和退出管程(Monitor)对象实现的。每个对象实例都会有一个Monitor,Monitor可以和对象一起创建、销毁。Monitor由ObjectMonitor实现,而ObjectMonitor由C++的ObjectMonitor.hpp文件实现,如下:\nObjectMonitor() { _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状态的线程,会被加入到该列表(Contention List中那些有资格成为候选资源的线程被移动到Entry List中;) _SpinFreq = 0 ; _SpinClock = 0 ; OwnerIsThread = 0 ; } //Contention List:竞争队列,所有请求锁的线程首先被放在这个竞争队列中 如上,多个线程同时访问一段同步代码时,多个线程会先被存放在ContentionList和**_EntryList**集合中,处于block状态的线程都会加入该列表。 当线程获取到对象的Monitor时,Monitor依靠底层操作系统的MutexLock实现互斥,线程申请Mutex成功,则持有该Mutex,其他线程无法获取;竞争失败的线程再次进入ContentionList被挂起 如果线程调用wait()方法,则会释放当前持有的Mutex,并且该线程进入WaitSet集合中,等待下一次被唤醒(或者顺利执行完方法也会释放Mutex) 锁升级 # 为了提升性能,Java1.6,引入了偏向锁、轻量级锁、重量级锁,来减少锁竞争带来的上下文切换,由新增的Java对象头实现了锁升级。锁只能升级不能降级,目的是提高获得锁和释放锁的效率 当Java对象被Synchronized关键字修饰为同步锁后,围绕这个锁的一系列升级操作都和Java对象头有关 JDK1.6 JVM中,对象实例在堆内存中被分为三个部分:对象头、实例数据和对齐填充。其中对象头由MarkWord、指向类的指针以及数组长度三部分组成 MarkWord记录了对象和锁相关的信息,它在64为JVM的长度是64bit,下图为64位JVM的存储结构: 32位如下 锁标志位是两位,无锁和偏向锁的锁标志位实际为01,轻量级锁的锁标志位为00 锁升级功能,主要依赖于MarkWord中的锁标志位和释放偏向锁标志位,Synchronized同步锁,是从偏向锁开始的,随着竞争越来越激烈,偏向锁升级到轻量级锁,最终升级到重量级锁 =================================从这之后往下,是有误的的============================= # 偏向锁 # JVM会为每个当前线程的栈帧中,创建用于存储锁记录的空间,官方称为Displaced Mark Word(轻量级锁会用到)\n为什么引入偏向锁\n多数情况,锁不仅不存在多线程竞争,且经常由同一线程获得,为了在这种情况让线程获得锁的代价更低而引入了偏向锁。例如:线程操作一个线程安全集合时,同一线程每次都需要获取和释放锁,则每次操作都会发生用户态和内核态的切换(重量级锁)\n解决方案(偏向锁的作用)\n当一个线程再次访问这个同步代码或方法时,该线程只需去对象头的MarkWord中,判断一下是否有偏向锁指向该线程的ID,而无需再进入Monitor去竞争对象 当对象被当作同步锁并有一个线程抢到了锁,锁标志位还是01,是否偏向锁标志位为1,并且记录抢到锁的线程ID,表示进入偏向锁状态 偏向锁的撤销 一旦出现其他线程竞争锁资源(竞争且CAS失败)时,偏向锁就会被撤销。偏向锁的撤销需要等待全局安全点,暂停持有该锁的线程,同时检查该线程是否还在执行该方法,如果是升级锁,反之(该锁)被其他线程抢占\n注:对于“CAS操作替换线程ID”这个解释,我的理解是:\n偏向锁是不会被主动释放的 偏向锁默认开启(JDK15默认关闭),如果应用程序里所有的锁通常情况下处于竞争状态,此时可以添加JVM参数关闭偏向锁来调优系统性能\n-XX:-UseBiasedLocking //关闭偏向锁(默认打开) 轻量级锁 # 何时升级为轻量级锁 当有另外一个线程获取这个锁,由于该锁已经是偏向锁,当发现对象头MarkWord中的线程ID不是自己的线程ID,就会进行CAS操作获取锁 如果获取成功,直接替换MarkWord中的线程ID为自己ID,该锁把持偏向锁状态 如果获取失败,代表当前锁有一定的竞争,偏向锁将升级为轻量级锁 适用场景 **”绝大部分的锁,在整个同步周期内都不存在长时间的竞争“**的场景 "},{"id":329,"href":"/zh/docs/problem/Linux/20221101/","title":"post","section":"Linux","content":" 在安装可视化的时候,出现需要libmysqlclient.so.18()(64bit)解决方案\n将mysql卸载即可 http://wenfeifei.com/art/detail/yGM1BG4\n"},{"id":330,"href":"/zh/docs/technology/Review/java_guide/java/Concurrent/lock_escalation_deprecated/","title":"(该文弃用)锁升级","section":"并发","content":" 简介 # 无锁 =\u0026gt; 偏向锁 =\u0026gt; 轻量锁 =\u0026gt; 重量锁\n复习Class类锁和实例对象锁,说明Class类锁和实例对象锁不是同一把锁,互相不影响\npublic static void main(String[] args) throws InterruptedException { Object object=new Object(); new Thread(()-\u0026gt;{ synchronized (Customer.class){ System.out.println(Thread.currentThread().getName()+\u0026#34;Object.class类锁\u0026#34;); try { TimeUnit.SECONDS.sleep(5); } catch (InterruptedException e) { e.printStackTrace(); } } System.out.println(Thread.currentThread().getName()+\u0026#34;结束并释放锁\u0026#34;); },\u0026#34;线程1\u0026#34;).start(); //保证线程1已经获得类锁 try { TimeUnit.SECONDS.sleep(2); } catch (InterruptedException e) { e.printStackTrace(); } new Thread(()-\u0026gt;{ synchronized (object){ System.out.println(Thread.currentThread().getName()+\u0026#34;获得object实例对象锁\u0026#34;); } System.out.println(Thread.currentThread().getName()+\u0026#34;结束并释放锁\u0026#34;); },\u0026#34;线程2\u0026#34;).start(); } /* 输出 线程1Object.class类锁 线程2获得object实例对象锁 线程2结束并释放锁 线程1结束并释放锁 */ 总结图 , 00 , 01 , 10 ,没有11\n001(无锁)和101(偏向锁),00(轻量级锁),10(重量级锁)\n背景 # 下面这部分,其实在io模块有提到过\n为了保证系统稳定性和安全性,一个进程的地址空间划分为用户空间User space和内核空间Kernel space 平常运行的应用程序都运行在用户空间,只有内核空间才能进行系统态级别的资源有关操作\u0026mdash;文件管理、进程通信、内存管理 如果直接synchronized加锁,会有下面图的流程出现,频繁进行用户态和内核态的切换(阻塞和唤醒线程[线程通信],需要频繁切换cpu的状态)\n为什么每一个对象都可以成为一个锁 markOop.hpp (对应对象标识) 每一个java对象里面,有一个Monitor对象(ObjectMonitor.cpp)关联 如图,_owner指向持有ObjectMonitor对象的线程 Monitor本质依赖于底层操作系统的MutexLock实现,操作系统实现线程之间的切换,需要从用户态到内核态的切换,成本极高 ★★ 重点:Monitor与Java对象以及线程是如何关联 如果一个java对象被某个线程锁住,则该对象的MarkWord字段中,LockWord指向monitor的起始地址(这里说的应该是重量级锁) Monitor的Owner字段会存放拥有相关联对象锁的线程id 图 锁升级 # synchronized用的锁,存在Java对象头里的MarkWord中,锁升级功能主要依赖MarkWord中锁标志位(后2位)和释放偏向锁标志位(无锁和偏向锁,倒数第3位)\n对于锁的指向\n无锁情况:(放hashcode(调用了Object.hashcode才有)) 偏向锁:MarkWord存储的是偏向的线程ID 轻量锁:MarkWord存储的是指向线程栈中LockRecord的指针 重量锁:MarkWord存储的是指向堆中的monitor对象的指针 =================================从这之后往下,是有误的的============================= # 无锁状态 初始状态,一个对象被实例化后,如果还没有任何线程竞争锁,那么它就为无锁状态(001)\npublic static void main(String[] args) { Object o = new Object(); System.out.println(ClassLayout.parseInstance(o).toPrintable()); //16字节 } /* 输出( 这里的mark,VALUE为0x0000000000000001,没有hashCode的值): java.lang.Object object internals: OFF SZ TYPE DESCRIPTION VALUE 0 8 (object header: mark) 0x0000000000000001 (non-biasable; age: 0) 8 4 (object header: class) 0xf80001e5 12 4 (object alignment gap) Instance size: 16 bytes Space losses: 0 bytes internal + 4 bytes external = 4 bytes total */ 下面是调用了hashCode()这个方法的情形:\npublic static void main(String[] args) { Object o = new Object(); System.out.println(Integer.toHexString(o.hashCode())); System.out.println(ClassLayout.parseInstance(o).toPrintable()); //16字节 } /**输出: 74a14482 java.lang.Object object internals: OFF SZ TYPE DESCRIPTION VALUE 0 8 (object header: mark) 0x00000074a1448201 (hash: 0x74a14482; age: 0) 8 4 (object header: class) 0xf80001e5 12 4 (object alignment gap) Instance size: 16 bytes Space losses: 0 bytes internal + 4 bytes external = 4 bytes total */ 偏向锁:单线程竞争\n当线程A第一次竞争到锁时,通过操作修改MarkWord中的偏向线程ID、偏向模式。如果不存在其他线程竞争,那么持有偏向锁的线程将永远不需要同步\n如果没有偏向锁,那么就会频繁出现用户态到内核态的切换\n意义:当一段同步代码,一直被同一个线程多次访问,由于只有一个线程那么该线程在后续访问时便会自动获得锁 锁在第一次被拥有的时候,记录下偏向线程ID(后续这个线程进入和退出这段加了同步锁的代码块时,不需要再次加锁和释放锁,只需要直接检查锁的MarkWord是不是放的自己的线程ID)\n如果相等,表示偏向锁是偏向于当前线程的,不需要再尝试获得锁,直到竞争才会释放锁;以后每次同步,检查锁的偏向线程ID与当前线程ID是否一致,若一致则进入同步,无需每次都加锁解锁去CAS更新对象头;如果自始至终使用锁的线程只有一个,很明显偏向锁几乎没有额外开销 如果不等,表示发生了竞争,锁已经不偏向于同一个线程,此时会尝试使用CAS来替换MarkWord里面的线程ID为新线程的ID 竞争成功,说明之前线程不存在了,MarkWord里的线程ID为新线程ID,所不会升级,仍然为偏向锁 竞争失败,需要升级为轻量级锁,才能保证线程间公平竞争锁 偏向锁只有遇到其他线程尝试竞争偏向锁时,持有偏向锁的线程才会释放锁,线程是不会主动释放锁的(尽量不会涉及用户到内核态转换)\n一个synchronized方法被一个线程抢到锁时,这个方法所在的对象,就会在其所在的MarkWord中**将偏向锁修改状态位\n如图\nJVM不用和操作系统协商设置Mutex(争取内核),不需要操作系统介入\n偏向锁相关参数\njava -XX:+PrintFlagsInitial | grep BiasedLock* intx BiasedLockingBulkRebiasThreshold = 20 {product} intx BiasedLockingBulkRevokeThreshold = 40 {product} intx BiasedLockingDecayTime = 25000 {product} intx BiasedLockingStartupDelay = 4000 #偏向锁启动延迟 4s {product} bool TraceBiasedLocking = false {product} bool UseBiasedLocking = true #默认开启偏向锁 {product} # 使用-XX:UseBiasedLocking 关闭偏向锁 例子:\npublic static void main(String[] args) throws InterruptedException { TimeUnit.SECONDS.sleep(5); //1 如果1跟下面的2兑换,则就不是偏向锁,是否是偏向锁,在创建对象的时候,就已经确认了 Object o = new Object(); //2 //System.out.println(Integer.toHexString(o.hashCode())); synchronized (o){ } System.out.println(ClassLayout.parseInstance(o).toPrintable()); //16字节 } //延迟5秒(\u0026gt;4)后,就会看到偏向锁 /* 打印,005,即二进制101 java.lang.Object object internals: OFF SZ TYPE DESCRIPTION VALUE 0 8 (object header: mark) 0x0000000002f93005 (biased: 0x000000000000be4c; epoch: 0; age: 0) 8 4 (object header: class) 0xf80001e5 12 4 (object alignment gap) Instance size: 16 bytes Space losses: 0 bytes internal + 4 bytes external = 4 bytes total */ 偏向锁的升级\n是一种等到竞争出现才释放锁的机制,只有当其他线程竞争锁时,持有偏向锁的原来线程才会被撤销;撤销需要等待全局安全点(该时间点没有字节码在执行),同时检查持有偏向锁的线程是否还在执行 如果此时第一个线程正在执行synchronized方法(处于同步块),还没执行完其他线程来抢,该偏向锁被取消并出现锁升级;此时轻量级锁由原持有偏向锁的线程持有,继续执行其同步代码,而正在竞争的线程会进入自旋等待获得该轻量级锁 如果第一个线程执行完成synchronized方法(退出同步块),而将对象头设置成无锁状态并撤销偏向锁,重新偏向 Java15之后,HotSpot不再默认开启偏向锁,使用+XX:UseBiasedLocking手动开启\n偏向锁流程总结 (转自https://blog.csdn.net/MariaOzawa/article/details/107665689) 轻量级锁 主要是为了在线程近乎交替执行同步块时提高性能 升级时机,当关闭偏向锁或多线程竞争偏向锁会导致偏向锁升级为轻量级锁 标志位为00\n"},{"id":331,"href":"/zh/docs/technology/Review/java_guide/java/Concurrent/ly03121lyobject-concurrent/","title":"对象内存布局和对象头","section":"并发","content":" 对象布局 # heap (where): new (eden ,s0 ,s1) ,old, metaspace\n对象的构成元素(what) HotSpot虚拟机里,对象在堆内存中的存储布局分为三个部分 对象头(Header) 对象标记 MarkWord 类元信息(类型指针 Class Pointer,指向方法区的地址) 对象头多大 length(数组才有) 实例数据(Instance Data) 对其填充(Padding,保证整个对象大小,是8个字节的倍数) 对象头 # 对象标记\nObject o= new Object(); //new一个对象,占内存多少 o.hashCode() //hashCode存在对象哪个地方 synchronized(o){ } //对象被锁了多少次(可重入锁) System.gc(); //躲过了几次gc(次数) 上面这些,哈希码、gc标记、gc次数、同步锁标记、偏向锁持有者,都保存在对象标记里面 如果在64位系统中,对象头中,**mark word(对象标记)**占用8个字节(64位);**class pointer(类元信息)**占用8个字节,总共16字节(忽略压缩指针) 无锁的时候, 类型指针 注意下图,指向方法区中(模板)的地址 实例数据和对齐填充 # 实例数据\n用来存放类的属性(Filed)数据信息,包括父类的属性信息\n对齐填充\n填充到长度为8字节,因为虚拟机要求对象起始地址必须是8字节的整数倍(对齐填充不一定存在)\n示例\nclass Customer{ int id;//4字节 boolean flag=false; //1字节 } //Customer customer=new Customer(); //该对象大小:对象头(对象标记8+类型指针8)+实例数据(4+1)=21字节 ===\u0026gt; 为了对齐填充,则为24字节 源码查看 # 具体的(64位虚拟机为主) # 无锁和偏向锁的锁标志位(最后2位)都是01 无锁的倒数第3位,为0,表示非偏向锁 偏向锁的倒数第3位,为1,表示偏向锁 轻量级锁的锁标志位(最后2位)是00 重量级锁的锁标志位(最后2位)是10 GC标志(最后2位)是11 如上所示,对象分代年龄4位,即最大值为15(十进制)\n源码中\n使用代码演示上述理论(JOL) # \u0026lt;!--引入依赖,用来分析对象在JVM中的大小和分布--\u0026gt; \u0026lt;dependency\u0026gt; \u0026lt;groupId\u0026gt;org.openjdk.jol\u0026lt;/groupId\u0026gt; \u0026lt;artifactId\u0026gt;jol-core\u0026lt;/artifactId\u0026gt; \u0026lt;version\u0026gt;0.16\u0026lt;/version\u0026gt; \u0026lt;/dependency\u0026gt; //使用\n//VM的细节详细情况 System.out.println(VM.current().details()); //所有对象分配字节都是8的整数倍 System.out.println(VM.current().objectAlignment()); /* 输出: # Running 64-bit HotSpot VM. # Using compressed oop with 3-bit shift. # Using compressed klass with 3-bit shift. # Objects are 8 bytes aligned. # Field sizes by type: 4, 1, 1, 2, 2, 4, 4, 8, 8 [bytes] # Array element sizes: 4, 1, 1, 2, 2, 4, 4, 8, 8 [bytes] 8 */ 简单的情形 注意,下面的8 4 (object header: class) 0xf80001e5,由于开启了类型指针压缩,只用了4个字节\npublic class Hello4 { public static void main(String[] args) throws InterruptedException { Object o = new Object(); System.out.println(ClassLayout.parseInstance(o).toPrintable()); //16字节 Customer customer = new Customer(); System.out.println(ClassLayout.parseInstance(customer).toPrintable()); //16字节 } } class Customer{ } /*输出 java.lang.Object object internals: OFF SZ TYPE DESCRIPTION VALUE 0 8 (object header: mark) 0x0000000000000001 (non-biasable; age: 0) 8 4 (object header: class) 0xf80001e5 12 4 (object alignment gap) Instance size: 16 bytes Space losses: 0 bytes internal + 4 bytes external = 4 bytes total com.ly.Customer object internals: OFF SZ TYPE DESCRIPTION VALUE 0 8 (object header: mark) 0x0000000000000001 (non-biasable; age: 0) 8 4 (object header: class) 0xf800cc94 12 4 (object alignment gap) Instance size: 16 bytes Space losses: 0 bytes internal + 4 bytes external = 4 bytes total Process finished with exit code 0 */ 带有实例数据\npublic class Hello4 { public static void main(String[] args) throws InterruptedException { Customer customer = new Customer(); System.out.println(ClassLayout.parseInstance(customer).toPrintable()); //16字节 } } class Customer{ private int a; private boolean b; } /*输出 com.ly.Customer object internals: OFF SZ TYPE DESCRIPTION VALUE 0 8 (object header: mark) 0x0000000000000001 (non-biasable; age: 0) 8 4 (object header: class) 0xf800cc94 12 4 int Customer.a 0 16 1 boolean Customer.b false 17 7 (object alignment gap) Instance size: 24 bytes Space losses: 0 bytes internal + 7 bytes external = 7 bytes total */ java 运行中添加参数 -XX:MaxTenuringThreshold = 16 ,则会出现下面错误,即分代gc最大年龄为15 压缩指针的相关说明\n使用 java -XX:+PrintComandLineFlags -version ,打印参数\n其中有一个, -XX:+UseCompressedClassPointers ,即开启了类型指针压缩,只需要4字节\n当使用了类型指针压缩(默认)时,一个无任何属性对象是 8字节(markWord) + 4字节(classPointer) + 4字节(对齐填充) = 16字节\n下面代码,使用了 -XX:-UseCompressedClassPointers进行关闭压缩指针 一个无任何属性对象是 8字节(markWord) + 8字节(classPointer) = 16字节\npublic class Hello4 { public static void main(String[] args) throws InterruptedException { Object o = new Object(); System.out.println(ClassLayout.parseInstance(o).toPrintable()); //16字节 //16字节 } } /*输出 java.lang.Object object internals: OFF SZ TYPE DESCRIPTION VALUE 0 8 (object header: mark) 0x0000000000000001 (non-biasable; age: 0) 8 8 (object header: class) 0x000000001dab1c00 Instance size: 16 bytes Space losses: 0 bytes internal + 0 bytes external = 0 bytes total */ "},{"id":332,"href":"/zh/docs/technology/Review/java_guide/java/Concurrent/ly0302lyconcurrent-02/","title":"并发02","section":"并发","content":" 转载自https://github.com/Snailclimb/JavaGuide (添加小部分笔记)感谢作者!\nJMM(JavaMemoryModel) # 详见-知识点 volatile关键字 # 保证变量可见性\n使用volatile关键字保证变量可见性,如果将变量声明为volatile则指示JVM该变量是共享且不稳定的,每次使用它都到主存中读取\nvolatile关键字并非Java语言特有,在C语言里也有,它最原始的意义就是禁用CPU缓存。\nvolatile关键字只能保证数据可见性,不能保证数据原子性。synchronized关键字两者都能保证\n不可见的例子\npackage com.concurrent; import java.util.concurrent.TimeUnit; public class TestLy { //如果加上volatile,就能保证可见性,线程1 才能停止 boolean stop = false;//对象属性 public static void main(String[] args) throws InterruptedException { TestLy atomicTest = new TestLy(); new Thread(() -\u0026gt; { while (!atomicTest.stop) { //这里不能加System.out.println ,因为这个方法内部用了synchronized修饰,会导致获取主内存的值, //就没法展示效果了 /*System.out.println(\u0026#34;1还没有停止\u0026#34;);*/ } System.out.println(Thread.currentThread().getName()+\u0026#34;停止了\u0026#34;); },\u0026#34;线程1\u0026#34;).start(); new Thread(() -\u0026gt; { try { TimeUnit.SECONDS.sleep(1); } catch (InterruptedException e) { e.printStackTrace(); } atomicTest.stop= true; System.out.println(Thread.currentThread().getName()+\u0026#34;让线程1停止\u0026#34;); },\u0026#34;线程2\u0026#34;).start(); while (true){} } } 如何禁止指令重排 使用volatile关键字,除了可以保证变量的可见性,还能防止JVM指令重排。当我们对这个变量进行读写操作的时候,-会通过插入特定的内存屏障来禁止指令重排\nJava中,Unsafe类提供了三个开箱即用关于内存屏障相关的方法,屏蔽了操作系统底层的差异\n可以用来实现和volatile禁止重排序的效果\npublic native void loadFence(); //读指令屏障 public native void storeFence(); //写指令屏障 public native void fullFence(); //读写指令屏障 例子(通过双重校验锁实现对象单例),保证线程安全\npublic class Singleton { private volatile static Singleton uniqueInstance; private Singleton() { } public static Singleton getUniqueInstance() { //先判断对象是否已经实例过,没有实例化过才进入加锁代码(第3、4次 //就不需要再进来(synchronized了)) //避免了不论如何都进行加锁的情况 if (uniqueInstance == null) { //...一些其他代码 //加锁,并判断如果未初始化则进行初始化 synchronized (Singleton.class) { //别晕了,这个是一定要判断的【判断是否已经初始化, //如果还未初始化才进行new对象】 if (uniqueInstance == null) { uniqueInstance = new Singleton(); } } } return uniqueInstance; } } 这里,uniqueInstance采用volatile的必要性:主要分析``` uniqueInstance = new Singleton(); ```分三步(正常情况) 1. 为uniqueInstance**分配内存空间** 2. **初始化** uniqueInstance 3. 将uniqueInstance**指向**被分配的空间 由于指令重排的关系,可能会编程1-\u0026gt;3-\u0026gt;2 ,指令重排在单线程情况下不会出现问题,而多线程, - 就会导致可能指针非空的时候,实际该指针所指向的对象(实例)并还没有初始化 - 例如,线程 T1 执行了 1 和 3,此时 T2 调用 `getUniqueInstance`() 后发现 `uniqueInstance` 不为空,因此返回 `uniqueInstance`,但此时 `uniqueInstance` 还未被初始化**(就会造成一些问题)** - 即可能存在1,3已经完成,2还未完成 volatile不能保证原子性\n下面的代码,输出结果小于2500\npublic class VolatoleAtomicityDemo { public volatile static int inc = 0; public void increase() { inc++; } public static void main(String[] args) throws InterruptedException { ExecutorService threadPool = Executors.newFixedThreadPool(5); VolatoleAtomicityDemo volatoleAtomicityDemo = new VolatoleAtomicityDemo(); for (int i = 0; i \u0026lt; 5; i++) { threadPool.execute(() -\u0026gt; { for (int j = 0; j \u0026lt; 500; j++) { volatoleAtomicityDemo.increase(); } }); } // 等待1.5秒,保证上面程序执行完成 Thread.sleep(1500); System.out.println(inc); threadPool.shutdown(); } } 对于上面例子, 很多人会误以为inc++ 是原子性的,实际上inc ++ 是一个复合操作,即\n读取inc的值**(到线程内存)** 对inc加1 将加1后的值写回内存(主内存) 这三部操作并不是原子性的,有可能出现:\n线程1对inc读取后,尚未修改 线程2又读取了,并对他进行+1,然后将+1后的值写回主存 此时线程2操作完毕后,线程1在之前读取的基础上进行一次自增,这将覆盖第2步操作的值,导致inc只增加了1(实际两个线程处理了,应该加2才对) 如果要保证上面代码运行正确,可以使用synchronized、Lock或者AtomicInteger,如\n//synchronized public synchronized void increase() { inc++; } //或者AtomicInteger public AtomicInteger inc = new AtomicInteger(); public void increase() { inc.getAndIncrement(); } //或者ReentrantLock改进 Lock lock = new ReentrantLock(); public void increase() { lock.lock(); try { inc++; } finally { lock.unlock(); } } synchronized关键字 # 说一说自己对synchronized的理解\n翻译成中文是同步的意思,主要解决的是多个线程之间访问资源的同步性,保证被它修饰的方法/代码块,在任一时刻只有一个线程执行 Java早期版本中,synchronized属于重量级锁;监视器锁(monitor)依赖底层操作系统的Mutex Lock来实现,Java线程映射到操作系统的原生线程上 挂起或唤醒线程,都需要操作系统帮忙完成,即操作系统实现线程之间切换,需要从用户态转换到内核态,这个转换时间成本高 Java 6 之后,Java官方对synchronized较大优化,引入了大量优化:自旋锁、适应性自旋锁、锁消除、锁粗化、偏向锁、轻量级锁等减少所操作的开销 如何使用synchronized关键字\n修饰实例方法 修饰静态方法 修饰代码块 修饰实例方法(锁当前对象实例) 给当前对象实例加锁,进入同步代码前要获得当前对象实例的锁\nsynchronized void method() { //业务代码 } 修饰静态方法(锁当前类) 给当前类枷锁,会作用于类的所有对象实例,进入同步代码前要获得当前class的锁; 这是因为静态成员归整个类所有,而不属于任何一个实例对象,不依赖于类的特定实例,被类所有实例共享\nsynchronized static void method() { //业务代码 } 静态synchronized方法和非静态synchronized方法之间的调用互斥吗:不互斥\n如果线程A调用实例对象的非静态方法,而线程B调用这个实例所属类的静态synchronized方法,是允许的,不会发生互斥;因为访问静态synchronized方法占用的锁是当前类的锁;非静态synchronized方法占用的是当前实例对象的锁\n修饰代码块(锁指定对象/类)\nsynchronized(object) 表示进入同步代码库前要获得 给定对象的锁。 synchronized(类.class) 表示进入同步代码前要获得 给定 Class 的锁 synchronized(this) { //业务代码 } 总结\nsynchronized 关键字加到 static 静态方法和 synchronized(class) 代码块上都是是给 Class 类上锁; synchronized 关键字加到实例方法上是给对象实例上锁; 尽量不要使用 synchronized(String a) 因为 JVM 中,字符串常量池具有缓存功能。(所以就会导致,容易**和其他地方的代码(同样的值的字符串)**互斥,因为是缓冲池的同一个对象) 讲一下synchronized关键字的底层原理 synchronized底层原理是属于JVM层面的\nsynchronized + 代码块 例子:\npublic class SynchronizedDemo { public void method() { synchronized (this) { System.out.println(\u0026#34;synchronized 代码块\u0026#34;); } } } 使用javap命令查看SynchronizedDemo类相关字节码信息:对编译后的SynchronizedDemo.class文件,使用javap -c -s -v -l SynchronizedDemo.class\n同步代码块的实现,使用的是monitorenter和monitorexit指令,其中monitorenter指令指向同步代码块开始的地方,monitorexit指向同步代码块结束的结束位置 执行monitorenter指令就是获取对象监视器monitor的持有权\n在HotSport虚拟机中,Monitor基于C++实现,由ObjectMonitor实现:每个对象内置了ObjectMonitor对象。wait/notify等方法也基于monitor对象,所以只有在同步块或者方法中(获得锁)才能调用wait/notify方法,否则会抛出java.lang.IllegalMonitorStateException异常的原因\nnotify()仅仅是通知,并不会释放锁;wait()会立即释放锁,例子:\nObject obj = new Object(); new Thread(() -\u0026gt; { synchronized (obj) { try { log.info(\u0026#34;运行中\u0026#34;); TimeUnit.SECONDS.sleep(3); log.info(\u0026#34;3s后释放锁\u0026#34;); obj.wait();//会释放锁 log.info(\u0026#34;完成执行\u0026#34;); } catch (InterruptedException e) { e.printStackTrace(); } } }, \u0026#34;线程1\u0026#34;).start(); //保证线程2在线程1之后启动 TimeUnit.SECONDS.sleep(1); new Thread(() -\u0026gt; { synchronized (obj) { log.info(\u0026#34;获得锁\u0026#34;); try { TimeUnit.SECONDS.sleep(5); } catch (InterruptedException e) { e.printStackTrace(); } log.info(\u0026#34;5s后唤醒线程1\u0026#34;); obj.notify(); try { TimeUnit.SECONDS.sleep(3); } catch (InterruptedException e) { e.printStackTrace(); } log.info(\u0026#34;完成执行\u0026#34;); } }, \u0026#34;线程2\u0026#34;).start(); /**打印 2023-03-07 11:33:00 上午 [Thread: 线程1] INFO:运行中 2023-03-07 11:33:03 上午 [Thread: 线程1] INFO:3s后释放锁 2023-03-07 11:33:03 上午 [Thread: 线程2] INFO:获得锁 2023-03-07 11:33:08 上午 [Thread: 线程2] INFO:5s后唤醒线程1 2023-03-07 11:33:21 上午 [Thread: 线程2] INFO:完成执行 2023-03-07 11:33:21 上午 [Thread: 线程1] //这段输出永远会在最后(线程2释放锁才会输出) INFO:完成执行 Process finished with exit code 0 */ 执行monitorenter时,**尝试获取**对象的锁,如果锁计数器为0则表示所可以被获取,获取后锁计数器设为1,简单的流程 只有拥有者线程才能执行monitorexit来释放锁,执行monitorexit指令后,锁计数器设为0(应该是减一,与可重入锁有关),当计数器为0时,表明锁被释放,其他线程可以尝试获得锁(如果某个线程获取锁失败,那么该线程就会阻塞等待,直到锁被(另一个线程)释放) synchronized修饰方法\npublic class SynchronizedDemo2 { public synchronized void method() { System.out.println(\u0026#34;synchronized 方法\u0026#34;); } } 如图 : 对比(下面是对synchronized代码块):\nsynchronized修饰的方法没有monitorenter和monitorexit指令,而是ACC_SYNCHRONIZED标识(flags),该标识指明方法是一个同步方法(JVM通过访问标志判断方法是否声明为同步方法),从而执行同步调用 如果是实例方法,JVM 会尝试获取实例对象的锁。如果是静态方法,JVM 会尝试获取当前 class 的锁。\n总结\nsynchronized 同步语句块的实现使用的是 monitorenter 和 monitorexit 指令,其中 monitorenter 指令指向同步代码块的开始位置,monitorexit 指令则指明同步代码块的结束位置。\nsynchronized 修饰的方法并没有 monitorenter 指令和 monitorexit 指令,取得代之的确实是 ACC_SYNCHRONIZED 标识,该标识指明了该方法是一个同步方法。\n不过两者的本质都是对对象监视器 monitor 的获取。\nJava1.6之后的synchronized关键字底层做了哪些优化 这是一个链接 详情见另一个文章\nJDK1.6对锁的实现,引入了大量的优化,如偏向锁、轻量级锁、自旋锁、适应性自旋锁、锁消除、锁粗化等技术来减少操作的开销 锁主要存在四种状态,依次是:无锁状态、偏向锁状态、轻量级锁状态、重量级锁状态,他们会随着竞争的激烈而逐渐升级。锁可以升级但不可以降级,这种策略是为了提高获得锁和释放锁的效率 synchronized和volatile的区别 synchronized和volatile是互补的存在,而非对立\nvolatile关键字是线程同步的轻量级实现,所以volatile性能肯定比synchronized关键字好,但volatile用于变量而synchronized关键字修饰方法及代码块 volatile关键字能保证数据的可见性、有序性,但无法保证原子性;synchronized三者都能保证 volatile主要还是用于解决变量在线程之间的可见性,而synchronized关键字解决的是多个线程之间访问资源的同步性 synchronized 和 ReentrantLock 的区别\n两者都是可重入锁 ”可重入锁“指的是,自己可以再次获取自己的内部锁。比如一个线程获得了某个对象的锁,此时这个对象锁还没有释放,当其再次想要获取这个对象的锁的时候还是可以获取的\n反之,如果是不可重入锁的话,就会造成死锁。 同一个线程,每次获取锁,锁的计数器都自增1,所以要等到锁的计数器下降为0时才能释放锁 synchronized依赖于JVM,而ReentrantLock依赖于API synchronized为虚拟机在JDK1.6进行的优化,但这些优化是在虚拟机层面实现的;ReentrantLock是JDK层面实现的,使用时,使用lock()和unlock()并配合try/finally语句块来完成 (Java代码) ReentrantLock 比 synchronized 增加了一些高级功能 ReentrantLock增加了一些高级功能,主要有\n等待可中断,提供了能够中断等待锁的线程的机制,通过lock.lockInterruptibly()来实现该机制。即正在等待的线程可以放弃等待,改为处理其他事情\n可实现公平锁:可以指定是公平锁还是非公平锁,而synchronized只能是非公平锁。 所谓公平锁就是先等待的线程先获得锁。ReentrantLock默认是非公平的,可以通过构造方法指定是否公平\n可实现选择性的通知(锁可以绑定多个条件) synchronized关键字与wait()和notify()/notifyAll()方法相结合可以实现等待/通知机制。**ReentrantLock类当然也可以实现,但是需要借助于Condition接口与newCondition()**方法。\nReentrantLock reentrantLock=new ReentrantLock(); Condition condition = reentrantLock.newCondition(); condition.await(); condition.signal(); Condition是 JDK1.5 之后才有的,它具有很好的灵活性,比如可以实现多路通知功能也就是在一个Lock对象中可以创建多个Condition实例(即对象监视器),**线程对象可以注册在指定的Condition中,从而可以有选择性的进行线程通知,在调度线程上更加灵活。 ** 在使用notify()/notifyAll()方法进行通知时,被通知的线程是由 JVM 选择的,用ReentrantLock类结合Condition实例可以实现“选择性通知” ,这个功能非常重要,而且是 Condition 接口默认提供的。 synchronized关键字就相当于整个 Lock 对象中只有一个Condition实例,所有的线程都注册在它一个身上。如果执行notifyAll()方法的话就会通知所有处于等待状态的线程这样会造成很大的效率问题, Condition实例的signalAll()方法 只会唤醒注册在该Condition实例中的所有等待线程。 ThreadLocal # ThreadLocal有什么用\n通常情况下,创建的变量是可以被任何一个线程访问并修改的 JDK自带的ThreadLocal类,该类主要解决的就是让每个线程绑定自己的值,可以将ThreadLocal类形象的比喻成存放数据的盒子,盒子中可以存储每个线程的私有数据 对于ThreadLocal变量,访问这个变量的每个线程都会有这个变量的本地副本。使用get()和set()来获取默认值或将其值更改为当前线程所存的副本的值 如图\n如何使用ThreadLocal Demo演示实际中如何使用ThreadLocal\nimport java.text.SimpleDateFormat; import java.util.Random; public class ThreadLocalExample implements Runnable{ // SimpleDateFormat 不是线程安全的,所以每个线程都要有自己独立的副本 private static final ThreadLocal\u0026lt;SimpleDateFormat\u0026gt; formatter = ThreadLocal.withInitial(() -\u0026gt; new SimpleDateFormat(\u0026#34;yyyyMMdd HHmm\u0026#34;)); /* 非lambda写法 private static final ThreadLocal\u0026lt;SimpleDateFormat\u0026gt; formatter = new ThreadLocal\u0026lt;SimpleDateFormat\u0026gt;(){ @Override protected SimpleDateFormat initialValue(){ return new SimpleDateFormat(\u0026#34;yyyyMMdd HHmm\u0026#34;); } }; */ public static void main(String[] args) throws InterruptedException { ThreadLocalExample obj = new ThreadLocalExample(); for(int i=0 ; i\u0026lt;10; i++){ Thread t = new Thread(obj, \u0026#34;\u0026#34;+i); Thread.sleep(new Random().nextInt(1000)); t.start(); } } //formatter.get().toPattern() 同一个对象的线程变量formatter(里面封装了一个simpleDateFormate对象,具有初始值) //每个线程访问时,先打印它的初始值,然后休眠1s(1s内的随机数),反正每个线程随机数不同,然后修改它 //结果:虽然前面执行的线程,修改值,但是后面执行的线程打印的值还是一样的 没有修改 @Override public void run() { System.out.println(\u0026#34;Thread Name= \u0026#34;+Thread.currentThread().getName()+\u0026#34; default Formatter = \u0026#34;+formatter.get().toPattern()); try { Thread.sleep(new Random().nextInt(1000)); } catch (InterruptedException e) { e.printStackTrace(); } //formatter pattern is changed here by thread, but it won\u0026#39;t reflect to other threads formatter.set(new SimpleDateFormat());//new SimpleDateFormat().toPattern()默认值为\u0026#34;yy-M-d ah:mm\u0026#34; System.out.println(\u0026#34;Thread Name= \u0026#34;+Thread.currentThread().getName()+\u0026#34; formatter = \u0026#34;+formatter.get().toPattern()); } } /*虽然前面执行的线程,修改值,但是后面执行的线程打印的值还是一样的 没有修改 , 结果如下: Thread Name= 0 default Formatter = yyyyMMdd HHmm Thread Name= 0 formatter = yy-M-d ah:mm Thread Name= 1 default Formatter = yyyyMMdd HHmm Thread Name= 2 default Formatter = yyyyMMdd HHmm Thread Name= 1 formatter = yy-M-d ah:mm Thread Name= 3 default Formatter = yyyyMMdd HHmm Thread Name= 2 formatter = yy-M-d ah:mm Thread Name= 4 default Formatter = yyyyMMdd HHmm Thread Name= 3 formatter = yy-M-d ah:mm Thread Name= 4 formatter = yy-M-d ah:mm Thread Name= 5 default Formatter = yyyyMMdd HHmm Thread Name= 5 formatter = yy-M-d ah:mm Thread Name= 6 default Formatter = yyyyMMdd HHmm Thread Name= 6 formatter = yy-M-d ah:mm Thread Name= 7 default Formatter = yyyyMMdd HHmm Thread Name= 7 formatter = yy-M-d ah:mm Thread Name= 8 default Formatter = yyyyMMdd HHmm Thread Name= 9 default Formatter = yyyyMMdd HHmm Thread Name= 8 formatter = yy-M-d ah:mm Thread Name= 9 formatter = yy-M-d ah:mm */ ThreadLocal原理了解吗\n从Thread类源代码入手\npublic class Thread implements Runnable { //...... //与此线程有关的ThreadLocal值。由ThreadLocal类维护 ThreadLocal.ThreadLocalMap threadLocals = null; //与此线程有关的InheritableThreadLocal值。由InheritableThreadLocal类维护 ThreadLocal.ThreadLocalMap inheritableThreadLocals = null; //...... } Thread类中有一个threadLocals和一个inheritableThreadLocals变量,它们都是ThreadLocalMap类型的变量,ThreadLocalMap可以理解为ThreadLocal类实现的定制化HashMap ( key为threadLocal , value 为值) 默认两个变量都是null,当调用set或get时会创建,实际调用的是ThreadLocalMap类对应的get()、set()方法\n//★★ThreadLocal类的set() 方法 public void set(T value) { //获取当前请求的线程 Thread t = Thread.currentThread(); //取出 Thread 类内部的 threadLocals 变量(哈希表结构) ThreadLocalMap map = getMap(t); if (map != null) // 将需要存储的值放入到这个哈希表中 //★★实际使用的方法 map.set(this, value); else //★★实际使用的方法 createMap(t, value); } ThreadLocalMap getMap(Thread t) { return t.threadLocals; } void createMap(Thread t, T firstValue) { t.threadLocals = new ThreadLocalMap(this, firstValue); } public T get() { Thread t = Thread.currentThread(); ThreadLocalMap map = getMap(t); if (map != null) { ThreadLocalMap.Entry e = map.getEntry(this); if (e != null) { @SuppressWarnings(\u0026#34;unchecked\u0026#34;) T result = (T)e.value; return result; } } return setInitialValue(); } /** * Set the value associated with key. * * @param key the thread local object * @param value the value to be set */ private void set(ThreadLocal\u0026lt;?\u0026gt; key, Object value) { // We don\u0026#39;t use a fast path as with get() because it is at // least as common to use set() to create new entries as // it is to replace existing ones, in which case, a fast // path would fail more often than not. Entry[] tab = table; int len = tab.length; int i = key.threadLocalHashCode \u0026amp; (len-1); for (Entry e = tab[i]; e != null; e = tab[i = nextIndex(i, len)]) { ThreadLocal\u0026lt;?\u0026gt; k = e.get(); if (k == key) { e.value = value; return; } if (k == null) { replaceStaleEntry(key, value, i); return; } } tab[i] = new Entry(key, value); int sz = ++size; if (!cleanSomeSlots(i, sz) \u0026amp;\u0026amp; sz \u0026gt;= threshold) rehash(); } 如上,实际存取都是从Thread的threadLocals (ThreadLocalMap类)中,并不是存在ThreadLocal上,ThreadLocal用来传递了变量值,只是ThreadLocalMap的封装\nThreadLocal类中通过Thread.currentThread()获取到当前线程对象后,直接通过getMap(Thread t) 可以访问到该线程的ThreadLocalMap对象\n【★★最重要★★】每个Thread中具备一个ThreadLocalMap,而ThreadLocalMap可以存储以ThreadLocal为key,Object对象为value的键值对\nThreadLocalMap(ThreadLocal\u0026lt;?\u0026gt; firstKey, Object firstValue) { //...... } 比如我们在同一个线程中声明了两个 ThreadLocal 对象的话, Thread内部都是使用仅有的那个ThreadLocalMap 存放数据的,ThreadLocalMap的 key 就是 ThreadLocal对象,value 就是 ThreadLocal 对象调用set方法设置的值\nThreadLocal数据结构如下图所示 ThreadLocalMap是ThreadLocal的静态内部类。 ThreadLocal内存泄露问题时怎么导致的\n前提知识:强引用、软引用、弱引用和虚引用的区别\n强引用StrongReference\n是最普遍的一种引用方式,只要强引用存在,则垃圾回收器就不会回收这个对象\n软引用 SoftReference\n如果内存足够不回收,如果内存不足则回收\n弱引用WeakReference 如果一个对象只具有弱引用,那就类似于可有可无的生活用品。弱引用与软引用的区别在于:只具有弱引用的对象拥有更短暂的生命周期。在垃圾回收器线程扫描它 所管辖的内存区域的过程中,一旦发现了只具有弱引用的对象,不管当前内存空间足够与否,都会回收它的内存。不过,由于垃圾回收器是一个优先级很低的线程, 因此不一定会很快发现那些只具有弱引用的对象。\n弱引用可以和一个引用队列(ReferenceQueue)联合使用,如果弱引用所引用的对象被垃圾回收,Java 虚拟机就会把这个弱引用加入到与之关联的引用队列中。\n虚引用PhantomReference [ˈfæntəm] 幻影\n如果一个对象仅持有虚引用,那么它就和没有任何引用一样,在任何时候都可能被垃圾回收器回收。虚引用主要用来跟踪对象被垃圾回收器回收的活动 虚引用与软引用和弱引用的一个区别在于:虚引用必须和引用队列 (ReferenceQueue)联合使用。当垃圾回收器准备回收一个对象时,如果发现它还有虚引,就会在回收对象的内存之前,把这个虚引用加入到与之关联的引用队列中。 ThreadLocalMap中,使用的key为ThreadLocal的弱引用(源码中,即Entry),而value是强引用\n//注意看ThreadLocal的set()方法 /** * Set the value associated with key. * * @param key the thread local object * @param value the value to be set */ private void set(ThreadLocal\u0026lt;?\u0026gt; key, Object value) { // We don\u0026#39;t use a fast path as with get() because it is at // least as common to use set() to create new entries as // it is to replace existing ones, in which case, a fast // path would fail more often than not. Entry[] tab = table; int len = tab.length; int i = key.threadLocalHashCode \u0026amp; (len-1); for (Entry e = tab[i]; e != null; e = tab[i = nextIndex(i, len)]) { ThreadLocal\u0026lt;?\u0026gt; k = e.get(); if (k == key) { e.value = value; return; } if (k == null) { replaceStaleEntry(key, value, i); return; } } //★★注意看这行,结合下面 tab[i] = new Entry(key, value); int sz = ++size; if (!cleanSomeSlots(i, sz) \u0026amp;\u0026amp; sz \u0026gt;= threshold) rehash(); } 所以,ThreadLocal没有被外部强引用的情况下,垃圾回收的时候 key会被清理掉,而value不会 ```java static class Entry extends WeakReference\u0026lt;ThreadLocal\u0026lt;?\u0026gt;\u0026gt; { /** The value associated with this ThreadLocal. */ Object value; Entry(ThreadLocal\u0026lt;?\u0026gt; k, Object v) { super(k); value = v; } } ``` 此时,ThreadLocalMap中就会出现key为null的Entry,如果不做任何措施,value永远无法被GC回收,此时会产生内存泄漏。ThreadLocaMap实现中已经考虑了这种情况,在调用set()、get()、**remove()**方法时,清理掉key为null的记录 所以使用完ThreadLocal的方法后,最好手动调用remove()方法\nset()方法中的cleanSomeSlots() 已经清除了部分key为null的记录。但是还不完整,还要依赖 expungeStaleEntry() 方法(在remove中)\n//remove()方法 public void remove() { ThreadLocalMap m = getMap(Thread.currentThread()); if (m != null) m.remove(this); } /** * Remove the entry for key. */ private void remove(ThreadLocal\u0026lt;?\u0026gt; key) { Entry[] tab = table; int len = tab.length; int i = key.threadLocalHashCode \u0026amp; (len-1); for (Entry e = tab[i]; e != null; e = tab[i = nextIndex(i, len)]) { if (e.get() == key) { e.clear(); expungeStaleEntry(i); return; } } } /** * Expunge a stale entry by rehashing any possibly colliding entries * lying between staleSlot and the next null slot. This also expunges * any other stale entries encountered before the trailing null. See * Knuth, Section 6.4 * * @param staleSlot index of slot known to have null key * @return the index of the next null slot after staleSlot * (all between staleSlot and this slot will have been checked * for expunging). */ private int expungeStaleEntry(int staleSlot) { Entry[] tab = table; int len = tab.length; // expunge entry at staleSlot tab[staleSlot].value = null; tab[staleSlot] = null; size--; // Rehash until we encounter null Entry e; int i; for (i = nextIndex(staleSlot, len); (e = tab[i]) != null; i = nextIndex(i, len)) { ThreadLocal\u0026lt;?\u0026gt; k = e.get(); if (k == null) { e.value = null; tab[i] = null; size--; } else { int h = k.threadLocalHashCode \u0026amp; (len - 1); if (h != i) { tab[i] = null; // Unlike Knuth 6.4 Algorithm R, we must scan until // null because multiple entries could have been stale. while (tab[h] != null) h = nextIndex(h, len); tab[h] = e; } } } return i; } "},{"id":333,"href":"/zh/docs/technology/springCloud/bl_zhouyang_/base/","title":"基础","section":"基础(尚硅谷)_","content":" springCloud涉及到的技术有哪些 约定 \u0026gt; 配置 \u0026gt; 编码 "},{"id":334,"href":"/zh/docs/technology/Review/java_guide/java/Concurrent/ly0301lyconcurrent-01/","title":"并发01","section":"并发","content":" 转载自https://github.com/Snailclimb/JavaGuide (添加小部分笔记)感谢作者!\n什么是进程和线程\n进程:是程序的一次执行过程,是系统运行程序的基本单位 系统运行一个程序,即一个进程从创建、运行到消亡的过程\n启动main函数则启动了一个JVM进程,main函数所在线程为进程中的一个线程,也称主线程\n以下为一个个的进程\n查看java进程\njps -l 32 org.jetbrains.jps.cmdline.Launcher 10084 16244 com.Test 17400 sun.tools.jps.Jps 杀死进程\ntaskkill /f /pid 16244 何为线程\n线程,比进程更小的执行单位\n同类的多个线程共享进程的堆和方法区资源,但每个线程有自己的程序计数器、虚拟机栈、本地方法栈,又被称为轻量级进程\nJava天生就是多线程程序,如:\npublic class MultiThread { public static void main(String[] args) { // 获取 Java 线程管理 MXBean ThreadMXBean threadMXBean = ManagementFactory.getThreadMXBean(); // 不需要获取同步的 monitor 和 synchronizer 信息,仅获取线程和线程堆栈信息 ThreadInfo[] threadInfos = threadMXBean.dumpAllThreads(false, false); // 遍历线程信息,仅打印线程 ID 和线程名称信息 for (ThreadInfo threadInfo : threadInfos) { System.out.println(\u0026#34;[\u0026#34; + threadInfo.getThreadId() + \u0026#34;] \u0026#34; + threadInfo.getThreadName()); } } } //输出 [5] Attach Listener //添加事件 [4] Signal Dispatcher // 分发处理给 JVM 信号的线程 [3] Finalizer //调用对象 finalize 方法的线程 [2] Reference Handler //清除 reference 线程 [1] main //main 线程,程序入口 也就是说,一个Java程序的运行,是main线程和多个其他线程同时运行\n请简要描述线程与进程的关系,区别及优缺点\n从JVM角度说明 Java内存区域 一个进程拥有多个线程,多个线程共享进程的堆和方法区(JDK1.8: 元空间),每个线程拥有自己的程序计数器、虚拟机栈、本地方法栈 总结\n线程是进程划分成的更小运行单位 线程和进程最大不同在于各进程基本独立,而各线程极有可能互相影响 线程开销小,但不利于资源保护;进程反之 程序计数器为什么是私有\n程序计数器的作用\n单线程情况下,字节码解释器通过改变程序计数器来依次读取指令,从而实现代码的流程控制,如:顺序执行、选择、循环、异常处理。 在多线程的情况下,程序计数器用于记录当前线程执行的位置,从而当线程被切换回来的时候能够知道该线程上次运行到哪儿了。 如果执行的是native方法,则程序计数器记录的是undefined地址;执行Java方法则记录的是下一条指令的地址\n私有,是为了线程切换后能恢复到正确的执行位置\n虚拟机栈和本地方法栈为什么私有\n虚拟机栈:每个Java方法执行时同时会创建一个栈帧用于存储局部变量表、操作数栈、常量池引用等信息 本地方法栈:和虚拟机栈类似,区别是虚拟机栈为虚拟机执行Java方法 (字节码)服务,本地方法栈则为虚拟机使用到的Native方法服务。HotSpot虚拟机中和Java虚拟机栈合二为一 为了保证线程中局部变量不被别的线程访问到,虚拟机栈和本地方法栈是私有的 堆和方法区是所有线程共享的资源,堆是进程中最大一块内存,用于存放新创建的对象(几乎所有对象都在这分配内存); 方法区则存放**已被加载的 ** 类信息、常量、静态变量、即时编译器编译后的代码等数据\n并发与并行的区别\n并发:两个及两个以上的作业在同一时间段内执行(线程,同一个代码同一秒只能由一个线程访问) 并行:两个及两个以上的作业同一时刻执行 关键点:是否同时执行,只有并行才能同时执行 同步和异步\n同步:发出调用后,没有得到结果前,该调用不能返回,一直等待 异步:发出调用后,不用等返回结果,该调用直接返回 为什么要使用多线程\n从计算机底层来说:线程是轻量级进程,程序执行最小单位,线程间切换和调度 成本远小于进程。多核CPU时代意味着多个线程可以同时运行,减少线程上下文切换 从当代互联网发展趋势:如今系统并发量大,利用多线程机制可以大大提高系统整体并发能力及性能 深入计算机底层 单核时代:提高单进程利用CPU和IO系统的效率。当请求IO的时候,如果Java进程中只有一个线程,此线程被IO阻塞则整个进程被阻塞,CPU和IO设备只有一个运行,系统整体效率50%;而多线程时,如果一个线程被IO阻塞,其他线程还可以继续使用CPU 多核时代:多核时代多线程主要是提高进程利用多核CPU的能力,如果要计算复杂任务,只有一个线程的话,不论系统几个CPU核心,都只有一个CPU核心被利用;而创建多个线程,这些线程可以被映射到底层多个CPU上执行,如果任务中的多个线程没有资源竞争,那么执行效率会显著提高 多线程带来的问题:内存泄漏(对象,没有释放)、死锁、线程不安全等\n说说线程的声明周期和状态 Java线程在运行的生命周期中的指定时刻,只可能处于下面6种不同状态中的一个\nNEW:初始状态,线程被创建出来但没有调用start()\nRUNNABLE:运行状态,线程被调用了start() 等待运行的状态\nBLOCKED:阻塞状态,需要等待锁释放\nWAITING:等待状态,表示该线程需要等待其他线程做出一些特定动作(通知或中断)\nTIME_WAITING:超时等待状态,在指定的时间后自行返回而不是像WAITING一直等待\nTERMINATED:终止状态,表示该线程已经运行完毕 如图\n对于该图有以下几点要注意:\n线程创建后处于NEW状态,之后调用start()方法运行,此时线程处于READY,可运行的线程获得CPU时间片(timeslice)后处于RUNNING状态\n操作系统中有READY和RUNNING两个状态,而JVM中只有RUNNABLE状态 现在的操作系统通常都是**“时间分片“方法进行抢占式 轮转调度**“,一个线程最多只能在CPU上运行10-20ms的时间(此时处于RUNNING)状态,时间过短,时间片之后放入调度队列末尾等待再次调度(回到READY状态),太快所以不区分两种状态 线程执行wait()方法后,进入WAITING(等待 )状态,进入等待状态的线程需要依靠其他线程通知才能回到运行状态\nTIMED_WAITING(超时等待)状态,在等待状态的基础上增加超时限制,通过sleep(long millis)或wait(long millis) 方法可以将线程置于TIMED_WAITING状态,超时结束后返回到RUNNABLE状态(注意,不是RUNNING)\n当线程进入synchronized方法/块或者调用wait后(被notify)重新进入synchronized方法/块,但是锁被其他线程占有,这个时候线程就会进入BLOCKED(阻塞)状态\n线程在执行完了**run()方法之后就会进入到TERMINATED(终止)**状态\n注意上述,阻塞和等待的区别\n什么是上下文切换\n线程在执行过程中会有自己的运行条件和状态(也称上下文),比如上文提到的程序计数器,栈信息等。当出现下面情况时,线程从占用CPU状态中退出:\n主动让出CPU,如sleep(),wait()等 时间片用完了 调用了阻塞类型的系统中断(请求IO,线程被阻塞) 被终止或结束运行 前3种会发生线程切换:需要保存当前线程上下文,留待线程下次占用CPU的时候恢复,并加载下一个将要占用CPU的线程上下文,即所谓的上下文切换\n是现代系统基本功能,每次都要保存信息恢复信息,将会占用CPU,内存等系统资源,即效率有一定损耗,频繁切换会造成整体效率低下\n线程死锁是什么?如何避免?\n多个线程同时被阻塞,它们中的一个或者全部,都在等待某个资源被释放。由于线程被无限期地阻塞,因此程序不可能正常终止\n前提:线程A持有资源2,线程B持有资源1。现象:线程A在等待申请资源1,线程B在等待申请资源2,所以这两个线程就会互相等待而进入死锁状态 使用代码描述上述问题\npublic class DeadLockDemo { private static Object resource1 = new Object();//资源 1 private static Object resource2 = new Object();//资源 2 public static void main(String[] args) { new Thread(() -\u0026gt; { synchronized (resource1) { System.out.println(Thread.currentThread() + \u0026#34;get resource1\u0026#34;); try { Thread.sleep(1000); } catch (InterruptedException e) { e.printStackTrace(); } System.out.println(Thread.currentThread() + \u0026#34;waiting get resource2\u0026#34;); synchronized (resource2) { System.out.println(Thread.currentThread() + \u0026#34;get resource2\u0026#34;); } } }, \u0026#34;线程 1\u0026#34;).start(); new Thread(() -\u0026gt; { synchronized (resource2) { System.out.println(Thread.currentThread() + \u0026#34;get resource2\u0026#34;); try { Thread.sleep(1000); } catch (InterruptedException e) { e.printStackTrace(); } System.out.println(Thread.currentThread() + \u0026#34;waiting get resource1\u0026#34;); synchronized (resource1) { System.out.println(Thread.currentThread() + \u0026#34;get resource1\u0026#34;); } } }, \u0026#34;线程 2\u0026#34;).start(); } } /* - 线程A通过synchronized(resource1)获得resource1的监视器锁,然后休眠1s(是为了保证线程B获得执行然后拿到resource2监视器锁) - 休眠结束了两线程都企图请求获得对方的资源,陷入互相等待的状态,于是产生了死锁 */ 死锁产生条件\n互斥:该资源任意一个时刻只由一个线程占有 请求与保持:一线程因请求资源而阻塞时,对已获得的资源保持不放 不剥夺条件:线程已获得的资源未使用完之前不能被其他线程强行剥夺,只有自己使用完才释放(资源) 循环等待:若干线程之间形成头尾相接的循环等待资源关系 如何预防死锁\u0026mdash;\u0026gt;破坏死锁的必要条件\n破坏请求与保持条件:一次性申请所有资源 破坏不剥夺条件:占用部分资源的线程进一步申请其他资源时,如果申请不到,可以主动释放它占有的资源 破坏循环等待条件:靠按需申请资源来预防(按某顺序申请资源,释放资源时反序) 如何将避免死锁\n在资源分配时,借助于算法(银行家算法)对资源分配计算评估,使其进入安全状态\n安全状态 指的是系统能够按照某种线程推进顺序(P1、P2、P3\u0026hellip;..Pn)来为每个线程分配所需资源,直到满足每个线程对资源的最大需求,使每个线程都可顺利完成。称 \u0026lt;P1、P2、P3.....Pn\u0026gt; 序列为安全序列\n修改线程2的代码 原线程1代码不变\nnew Thread(() -\u0026gt; { synchronized (resource1) { System.out.println(Thread.currentThread() + \u0026#34;get resource1\u0026#34;); try { Thread.sleep(1000); } catch (InterruptedException e) { e.printStackTrace(); } System.out.println(Thread.currentThread() + \u0026#34;waiting get resource2\u0026#34;); synchronized (resource2) { System.out.println(Thread.currentThread() + \u0026#34;get resource2\u0026#34;); } } }, \u0026#34;线程 1\u0026#34;).start(); 线程2代码修改:\nnew Thread(() -\u0026gt; { synchronized (resource1) { System.out.println(Thread.currentThread() + \u0026#34;get resource1\u0026#34;); try { Thread.sleep(1000); } catch (InterruptedException e) { e.printStackTrace(); } System.out.println(Thread.currentThread() + \u0026#34;waiting get resource2\u0026#34;); synchronized (resource2) { System.out.println(Thread.currentThread() + \u0026#34;get resource2\u0026#34;); } } }, \u0026#34;线程 2\u0026#34;).start(); /* 输出 Thread[线程 1,5,main]get resource1 Thread[线程 1,5,main]waiting get resource2 Thread[线程 1,5,main]get resource2 Thread[线程 2,5,main]get resource1 Thread[线程 2,5,main]waiting get resource2 Thread[线程 2,5,main]get resource2\nProcess finished with exit code 0 */\n分析 \u0026gt; 线程 1 首先获得到 resource1 的监视器锁,这时候线程 2 就获取不到了。然后线程 1 再去获取 resource2 的监视器锁,可以获取到。然后**线程 1 释放了对 resource1、resource2 的监视器锁的占用,线程 2 获取到(resource1)就可以执行了**。这样就破坏了破坏循环等待条件,因此避免了死锁。 sleep()方法和wait()方法对比\n共同点: 两者都可暂停线程执行 区别 seep() 方法没有释放锁,wait() 方法释放了锁 wait() 通常用于线程间交互/通信,sleep()用于暂停执行 wait()方法被调用后,线程不会自动苏醒,需要别的线程调用同一对象(监视器monitor)的notify()或者notifyAll()方法;sleep()方法执行完成后/或者wait(long timeout)超时后,线程会自动苏醒 sleep时Thread类的静态本地方法,wait()则是Object类的本地方法 为什么wait()方法不定义在Thread中\nwait() 目的是让获得对象锁的线程实现等待,会自动释放当前线程占有的对象锁 每个对象(Object)都拥有对象锁,既然是让获得对象锁的线程等待,所以方法应该出现在对象Object上 sleep()是让当前线程暂停执行,不涉及对象类,也不需要获得对象锁 可以直接调用Thread类的run方法吗\nnew一个Thread之后,线程进入新建状态 调用start(),会启动线程并使他进入就绪状态(Runable,可运行状态,又分为Ready和Running),分配到时间片后就开始运行 start()执行线程相应准备工作,之后**自动执行run()**方法的内容 如果直接执行run()方法,则会把run()方法当作main线程下普通方法去执行,并不会在某个线程中执行它 只有调用start()方法才可以启动新的线程使他进入就绪状态,等待获取时间片后运行 "},{"id":335,"href":"/zh/docs/technology/Review/java_guide/java/IO/ly0203lyio-model/","title":"io模型","section":"IO","content":" 转载自https://github.com/Snailclimb/JavaGuide (添加小部分笔记)感谢作者!\nhttps://zhuanlan.zhihu.com/p/360878783 IO多路复用讲解,这是一个与系统底层有关的知识点,需要一些操作系统调用代码才知道IO多路复用省的时间。\nI/O # 何为I/O # I/O(Input/Output),即输入/输出 从计算机结构的角度来解读一下I/O,根据冯诺依曼结构,计算机结构分为5大部分:运算器、控制器、存储器、输入设备、输出设备 其中,输入设备:键盘;输出设备:显示器 网卡、硬盘既属于输入设备也属于输出设备 输入设备向计算机输入(内存)数据,输出设备接收计算机(内存)输出的数据,即I/O描述了计算机系统与外部设备之间通信的过程 从应用程序的角度解读I/O 为了保证系统稳定性和安全性,一个进程的地址空间划分为用户空间User space和内核空间Kernel space kernel\t英[ˈkɜːnl] 平常运行的应用程序都运行在用户空间,只有内核空间才能进行系统态级别的资源有关操作\u0026mdash;文件管理、进程通信、内存管理 如果要进行IO操作,就得依赖内核空间的能力,用户空间的程序不能直接访问内核空间 用户进程要想执行IO操作,必须通过系统调用来间接访问内核空间 对于磁盘IO(读写文件)和网络IO(网络请求和响应),从应用程序视角来看,应用程序对操作系统的内核发起IO调用(系统调用),操作系统负责的内核执行具体IO操作 应用程序只是发起了IO操作调用,而具体的IO执行则由操作系统内核完成 应用程序发起I/O后,经历两个步骤 内核等待I/O设备准备好数据 内核将数据从内核空间拷贝到用户空间 有哪些常见的IO模型 # UNIX系统下,包括5种:同步阻塞I/O,同步非阻塞I/O,I/O多路复用、信号驱动I/O和异步I/O\nJava中3中常见I/O模型 # BIO (Blocking I/O ) # 应用程序发起read调用后,会一直阻塞,直到内核把数据拷贝到用户空间 NIO (Non-blocking/New I/O) # 对于java.nio包,提供了Channel、Selector、Buffer等抽象概念,对于高负载高并发,应使用NIO NIO是I/O多路复用模型,属于同步非阻塞IO模型 一般的同步非阻塞 IO 模型中,应用程序会一直发起 read 调用。\n等待数据从内核空间拷贝到用户空间的这段时间里,线程依然是阻塞的**,**直到在内核把数据拷贝到用户空间。\n相比于同步阻塞 IO 模型,同步非阻塞 IO 模型确实有了很大改进。通过轮询操作,避免了一直阻塞。\n但是,这种 IO 模型同样存在问题:应用程序不断进行 I/O 系统调用轮询数据是否已经准备好的过程是十分消耗 CPU 资源的。\n★★ 也就是说,【准备数据,数据就绪】是不阻塞的。而【拷贝数据】是阻塞的 I/O多路复用 线程首先发起select调用,询问内核数据是否准备就绪,等准备好了,用户线程再发起read调用,r**ead调用的过程(数据从内核空间\u0026ndash;\u0026gt;用户空间)**还是阻塞的\nIO 多路复用模型,通过减少无效的系统调用,减少了对 CPU 资源的消耗。\nJava 中的 NIO ,有一个非常重要的选择器 ( Selector ) 的概念,也可以被称为 多路复用器。通过它,只需要一个线程便可以管理多个客户端连接。当客户端数据到了之后,才会为其服务。\nSelector,即多路复用器,一个线程管理多个客户端连接 AIO(Asynchronous I/O ) # 异步 IO 是基于事件和回调机制实现的,也就是应用操作之后会直接返回,不会堵塞在那里,当后台处理完成,操作系统会通知相应的线程进行后续的操作 如图\n三者区别 # "},{"id":336,"href":"/zh/docs/technology/Review/java_guide/java/IO/ly0202lyio-design-patterns/","title":"io设计模式","section":"IO","content":" 转载自https://github.com/Snailclimb/JavaGuide (添加小部分笔记)感谢作者!\n装饰器模式 # ​\t类图:\n​\t装饰器,Decorator,装饰器模式可以在不改变原有对象的情况下拓展其功能\n★装饰器模式,通过组合替代继承来扩展原始类功能,在一些继承关系较复杂的场景(IO这一场景各种类的继承关系就比较复杂)下更加实用\n对于字节流,FilterInputStream(对应输入流)和FilterOutputStream(对应输出流)是装饰器模式的核心,分别用于增强(继承了)InputStream和OutputStream子类对象的功能 Filter (过滤的意思),中间(Closeable)下面这两条虚线代表实现;最下面的实线代表继承 其中BufferedInputStream(字节缓冲输入流)、DataInputStream等等都是FilterInputStream的子类,对应的BufferedOutputStream和DataOutputStream都是FilterOutputStream的子类\n例子,使用BufferedInputStream(字节缓冲输入流)来增强FileInputStream功能\nBufferedInputStream源码(构造函数)\nprivate static int DEFAULT_BUFFER_SIZE = 8192; public BufferedInputStream(InputStream in) { this(in, DEFAULT_BUFFER_SIZE); } public BufferedInputStream(InputStream in, int size) { super(in); if (size \u0026lt;= 0) { throw new IllegalArgumentException(\u0026#34;Buffer size \u0026lt;= 0\u0026#34;); } buf = new byte[size]; } 使用\ntry (BufferedInputStream bis = new BufferedInputStream(new FileInputStream(\u0026#34;input.txt\u0026#34;))) { int content; long skip = bis.skip(2); while ((content = bis.read()) != -1) { System.out.print((char) content); } } catch (IOException e) { e.printStackTrace(); } ZipInputStream和ZipOutputStream还可以用来增强BufferedInputStream和BufferedOutputStream的能力\n//使用 BufferedInputStream bis = new BufferedInputStream(new FileInputStream(fileName)); ZipInputStream zis = new ZipInputStream(bis); BufferedOutputStream bos = new BufferedOutputStream(new FileOutputStream(fileName)); ZipOutputStream zipOut = new ZipOutputStream(bos); 装饰器模式重要的一点,就是可以对原始类嵌套使用多个装饰器,所以装饰器需要跟原始类继承相同的抽象类或实现相同接口,上面介绍的IO相关装饰器和原始类共同父类都是InputStream和OutputStream 而对于字符流来说,BufferedReader用来增强Reader(字符输入流)子类功能,BufferWriter用来增加Writer(字符输出流)子类功能\nBufferedWriter bw = new BufferedWriter(new OutputStreamWriter(new FileOutputStream(fileName), \u0026#34;UTF-8\u0026#34;)); IO流中大量使用了装饰器模式,不需要特意记忆\n适配器模式 # 适配器(Adapter Pattern)模式:主要用于接口互不兼容的类的协调工作,你可以将其联想到我们日常使用的电源适配器\n其中被适配的对象/类称为适配者(Adaptee),作用于适配者的对象或者类称为适配器(Adapter)。对象适配器使用组合关系实现,类适配器使用继承关系实现 IO中字符流和字节流接口不同,而他们能协调工作就是基于适配器模式来做的,具体的,是对象适配器:将字节流对象适配成字符流对象,然后通过字节流对象,读取/写入字符数据\nInputStreamReader和OutputStreamWriter为两个适配器,也是字节流和字符流之间的桥梁\nInputStreamReader使用StreamDecode(流解码器)对字节进行解码,实现字节流到字符流的转换\nOutputStreamWriter使用StreamEncoder(流编码器)对字符进行编码,实现字符到字节流的转换\nInputStream和OutputStream的子类是被适配者,InputStreamReader和OutputStreamWriter是适配器 使用:\n// InputStreamReader 是适配器,FileInputStream 是被适配的类 InputStreamReader isr = new InputStreamReader(new FileInputStream(fileName), \u0026#34;UTF-8\u0026#34;); // BufferedReader 增强 InputStreamReader 的功能(装饰器模式) BufferedReader bufferedReader = new BufferedReader(isr); fileReader的源码:\npublic class FileReader extends InputStreamReader { public FileReader(String fileName) throws FileNotFoundException { super(new FileInputStream(fileName)); } } //其父类InputStreamReader public class InputStreamReader extends Reader { //用于解码的对象 private final StreamDecoder sd; public InputStreamReader(InputStream in) { super(in); try { // 获取 StreamDecoder 对象 sd = StreamDecoder.forInputStreamReader(in, this, (String)null); } catch (UnsupportedEncodingException e) { throw new Error(e); } } // 使用 StreamDecoder 对象做具体的读取工作 public int read() throws IOException { return sd.read(); } } 同理,java.io.OutputStreamWriter部分源码:\npublic class OutputStreamWriter extends Writer { // 用于编码的对象 private final StreamEncoder se; public OutputStreamWriter(OutputStream out) { super(out); try { // 获取 StreamEncoder 对象 se = StreamEncoder.forOutputStreamWriter(out, this, (String)null); } catch (UnsupportedEncodingException e) { throw new Error(e); } } // 使用 StreamEncoder 对象做具体的写入工作 public void write(int c) throws IOException { se.write(c); } } 适配器模式和装饰器模式区别\n装饰器模式更侧重于动态增强原始类的功能,(为了嵌套)装饰器类需要跟原始类继承相同抽象类/或实现相同接口。装饰器模式支持对原始类嵌套\n适配器模式侧重于让接口不兼容而不能交互的类一起工作,当调用适配器方法时,适配器内部会调用适配者类或者和适配者类相关类的方法,这个过程透明的。就比如说 StreamDecoder (流解码器)和StreamEncoder(流编码器)就是分别基于 InputStream 和 OutputStream 来获取 FileChannel对象并调用对应的 read 方法和 write 方法进行字节数据的读取和写入。\nStreamDecoder(InputStream in, Object lock, CharsetDecoder dec) { // 省略大部分代码 // 根据 InputStream 对象获取 FileChannel 对象 ch = getChannel((FileInputStream)in); } 适配器和适配者(注意,这里说的都是适配器模式)两者不需要继承相同抽象类/不需要实现相同接口 FutureTask使用了适配器模式 直接调用(构造器)\npublic FutureTask(Runnable runnable, V result) { // 调用 Executors 类的 callable 方法 this.callable = Executors.callable(runnable, result); this.state = NEW; } 间接:\n// 实际调用的是 Executors 的内部类 RunnableAdapter 的构造方法 public static \u0026lt;T\u0026gt; Callable\u0026lt;T\u0026gt; callable(Runnable task, T result) { if (task == null) throw new NullPointerException(); return new RunnableAdapter\u0026lt;T\u0026gt;(task, result); } // 适配器 static final class RunnableAdapter\u0026lt;T\u0026gt; implements Callable\u0026lt;T\u0026gt; { final Runnable task; final T result; RunnableAdapter(Runnable task, T result) { this.task = task; this.result = result; } public T call() { task.run(); return result; } } 工厂模式 # NIO中大量出现,例如Files类的newInputStream,Paths类中的get方法,ZipFileSystem类中的getPath\nInputStream is = Files.newInputStream(Paths.get(generatorLogoPath)) 观察者模式 # 比如NIO中的文件目录监听服务 该服务基于WatchService接口(观察者)和Watchable接口(被观察者)\nWatchable接口其中有一个register方法,用于将对象注册到WatchService(监控服务)并绑定监听事件的方法\n例子\n// 创建 WatchService 对象 WatchService watchService = FileSystems.getDefault().newWatchService(); // 初始化一个被监控文件夹的 Path 类: Path path = Paths.get(\u0026#34;workingDirectory\u0026#34;); // 将这个 path 对象注册到 WatchService(监控服务) 中去 WatchKey watchKey = path.register( watchService, StandardWatchEventKinds...); 可以通过WatchKey对象获取事件具体信息\nWatchKey key; while ((key = watchService.take()) != null) { for (WatchEvent\u0026lt;?\u0026gt; event : key.pollEvents()) { // 可以调用 WatchEvent 对象的方法做一些事情比如输出事件的具体上下文信息 } key.reset(); } 完整的代码应该是如下\n@Test public void myTest() throws IOException, InterruptedException { // 创建 WatchService 对象 WatchService watchService = FileSystems.getDefault().newWatchService(); // 初始化一个被监控文件夹的 Path 类: Path path = Paths.get(\u0026#34;F:\\\\java_test\\\\git\\\\hexo\\\\review_demo\\\\src\\\\com\\\\hp\u0026#34;); // 将这个 path 对象注册到 WatchService(监控服务) 中去 WatchKey key = path.register( watchService, StandardWatchEventKinds.ENTRY_CREATE,StandardWatchEventKinds.ENTRY_DELETE ,StandardWatchEventKinds.ENTRY_MODIFY); while ((key = watchService.take()) != null) { System.out.println(\u0026#34;检测到了事件--start--\u0026#34;); for (WatchEvent\u0026lt;?\u0026gt; event : key.pollEvents()) { // 可以调用 WatchEvent 对象的方法做一些事情比如输出事件的具体上下文信息 System.out.println(\u0026#34;event.kind().name()\u0026#34;+event.kind().name()); } key.reset(); System.out.println(\u0026#34;检测到了事件--end--\u0026#34;); } } public interface Path extends Comparable\u0026lt;Path\u0026gt;, Iterable\u0026lt;Path\u0026gt;, Watchable{ } public interface Watchable { WatchKey register(WatchService watcher, WatchEvent.Kind\u0026lt;?\u0026gt;[] events, WatchEvent.Modifier... modifiers) throws IOException; //events,需要监听的事件,包括创建、删除、修改。 @Override WatchKey register(WatchService watcher, WatchEvent.Kind\u0026lt;?\u0026gt;... events) throws IOException; } 其中events包括下面3种:\nStandardWatchEventKinds.ENTRY_CREATE :文件创建。\nStandardWatchEventKinds.ENTRY_DELETE : 文件删除。\nStandardWatchEventKinds.ENTRY_MODIFY : 文件修改。\nWatchService内部通过一个daemon thread (守护线程),采用定期轮询的方式检测文件变化\nclass PollingWatchService extends AbstractWatchService { // 定义一个 daemon thread(守护线程)轮询检测文件变化 private final ScheduledExecutorService scheduledExecutor; PollingWatchService() { scheduledExecutor = Executors .newSingleThreadScheduledExecutor(new ThreadFactory() { @Override public Thread newThread(Runnable r) { Thread t = new Thread(r); t.setDaemon(true); return t; }}); } void enable(Set\u0026lt;? extends WatchEvent.Kind\u0026lt;?\u0026gt;\u0026gt; events, long period) { synchronized (this) { // 更新监听事件 this.events = events; // 开启定期轮询 Runnable thunk = new Runnable() { public void run() { poll(); }}; this.poller = scheduledExecutor .scheduleAtFixedRate(thunk, period, period, TimeUnit.SECONDS); } } } "},{"id":337,"href":"/zh/docs/technology/Review/java_guide/java/IO/ly0201lyio/","title":"io基础","section":"IO","content":" 转载自https://github.com/Snailclimb/JavaGuide (添加小部分笔记)感谢作者!\n简介 # IO,即Input/Output,输入和输出,输入就是数据输入到计算机内存;输出则是输出到外部存储(如数据库、文件、远程主机)\n根据数据处理方式,又分为字节流和字符流\n基类\n字节输入流 InputStream,字符输入流 Reader 字节输出流 OutputStream, 字符输出流 Writer 字节流 # 字节输入流 InputStream InputStream用于从源头(通常是文件)读取数据(字节信息)到内存中,java.io.InputStream抽象类是所有字节输入流的父类\n常用方法\nread() :返回输入流中下一个字节的数据。返回的值介于 0 到 255 之间。如果未读取任何字节,则代码返回 -1 ,表示文件结束。 read(byte b[ ]) : 从输入流中读取一些字节存储到数组 b 中。如果数组 b 的长度为零,则不读取。如果没有可用字节读取,返回 -1。如果有可用字节读取,则最多读取的字节数最多等于 b.length , 返回读取的字节数。这个方法等价于 read(b, 0, b.length)。 read(byte b[], int off, int len) :在read(byte b[ ]) 方法的基础上增加了 off 参数(偏移量)和 len 参数(要读取的最大字节数)。 skip(long n) :忽略输入流中的 n 个字节 ,返回实际忽略的字节数。 available() :返回输入流中可以读取的字节数。 close() :关闭输入流释放相关的系统资源。 Java9 新增了多个实用方法\nreadAllBytes() :读取输入流中的所有字节,返回字节数组。 readNBytes(byte[] b, int off, int len) :阻塞直到读取 len 个字节。 transferTo(OutputStream out) : 将所有字节从一个输入流传递到一个输出流。 FileInputStream \u0026ndash;\u0026gt; 字节输入流对象,可直接指定文件路径:用来读取单字节数据/或读取至字节数组中,示例如下:\ninput.txt中的字符为LLJavaGuide\ntry (InputStream fis = new FileInputStream(\u0026#34;input.txt\u0026#34;)) { System.out.println(\u0026#34;Number of remaining bytes:\u0026#34; + fis.available()); int content; long skip = fis.skip(2); System.out.println(\u0026#34;The actual number of bytes skipped:\u0026#34; + skip); System.out.print(\u0026#34;The content read from file:\u0026#34;); while ((content = fis.read()) != -1) { System.out.print((char) content); } } catch (IOException e) { e.printStackTrace(); } //输出 /**Number of remaining bytes:11 The actual number of bytes skipped:2 The content read from file:JavaGuide **/ 一般不会单独使用FileInputStream,而是配合BufferdInputStream(字节缓冲输入流),下面代码转为String 较为常见:\n// 新建一个 BufferedInputStream 对象 BufferedInputStream bufferedInputStream = new BufferedInputStream(new FileInputStream(\u0026#34;input.txt\u0026#34;)); // 读取文件的内容并复制到 String 对象中 String result = new String(bufferedInputStream.readAllBytes()); System.out.println(result); DataInputStream 用于读取指定类型数据,不能单独使用,必须结合FileInputStream\nFileInputStream fileInputStream = new FileInputStream(\u0026#34;input.txt\u0026#34;); //必须将fileInputStream作为构造参数才能使用 DataInputStream dataInputStream = new DataInputStream(fileInputStream); //可以读取任意具体的类型数据 dataInputStream.readBoolean(); dataInputStream.readInt(); dataInputStream.readUTF(); ObjectInputStream 用于从输入流读取Java对象(一般是被反序列化到文件中,或者其他介质的数据),ObjectOutputStream用于将对象写入到输出流([将对象]序列化)\nObjectInputStream input = new ObjectInputStream(new FileInputStream(\u0026#34;object.data\u0026#34;)); MyClass object = (MyClass) input.readObject(); input.close(); 用于序列化和反序列化的类必须实现Serializable接口,不想被序列化的属性用**transizent**修饰\n字节输出流 OutputStream\nOutputStream用于将字节数据(字节信息)写入到目的地(通常是文件),java.io.OutputStream抽象类是所有字节输出流的父类\n//常用方法\nwrite(int b) :将特定字节写入输出流。 write(byte b[ ]) : 将数组b 写入到输出流,等价于 write(b, 0, b.length) 。 write(byte[] b, int off, int len) : 在write(byte b[ ]) 方法的基础上增加了 off 参数(偏移量)和 len 参数(要读取的最大字节数)。 flush() :刷新此输出流并强制写出所有缓冲的输出字节。 //相比输入流多出的方法 close() :关闭输出流释放相关的系统资源。 示例代码:\ntry (FileOutputStream output = new FileOutputStream(\u0026#34;output.txt\u0026#34;)) { byte[] array = \u0026#34;JavaGuide\u0026#34;.getBytes(); output.write(array); } catch (IOException e) { e.printStackTrace(); } //结果 /**output.txt文件中内容为: JavaGuide **/ FileOutputStream一般也是配合BufferedOutputStream (字节缓冲输出流): ```java FileOutputStream fileOutputStream = new FileOutputStream(\u0026#34;output.txt\u0026#34;); BufferedOutputStream bos = new BufferedOutputStream(fileOutputStream) DataOutputStream用于写入指定类型数据,不能单独使用,必须结合FileOutputStream\n// 输出流 FileOutputStream fileOutputStream = new FileOutputStream(\u0026#34;out.txt\u0026#34;); DataOutputStream dataOutputStream = new DataOutputStream(fileOutputStream); // 输出任意数据类型 dataOutputStream.writeBoolean(true); dataOutputStream.writeByte(1); ObjectInputStream用于从输入流中读取Java对象(ObjectInputStream,反序列化);ObjectOutputStream用于将对象写入到输出流(ObjectOutputStream,序列化)\nObjectOutputStream output = new ObjectOutputStream(new FileOutputStream(\u0026#34;file.txt\u0026#34;) Person person = new Person(\u0026#34;Guide哥\u0026#34;, \u0026#34;JavaGuide作者\u0026#34;); output.writeObject(person); 字符流 # 简介 文件读写或者网络发送接收,信息的最小存储单元都是字节,为什么I/O流操作要分为字节流操作和字符流操作呢\n字符流是由Java虚拟机将字节转换得到的,过程相对耗时\n如果不知道编码类型,容易出现乱码 如上面的代码,将文件内容改为 : 你好,我是Guide\ntry (InputStream fis = new FileInputStream(\u0026#34;input.txt\u0026#34;)) { System.out.println(\u0026#34;Number of remaining bytes:\u0026#34; + fis.available()); int content; long skip = fis.skip(2); System.out.println(\u0026#34;The actual number of bytes skipped:\u0026#34; + skip); System.out.print(\u0026#34;The content read from file:\u0026#34;); while ((content = fis.read()) != -1) { System.out.print((char) content); } } catch (IOException e) { e.printStackTrace(); } //输出 /**Number of remaining bytes:9 The actual number of bytes skipped:2 The content read from file:§å®¶å¥½ **/ 为了解决乱码问题,I/O流提供了一个直接操作字符的接口,方便对字符进行流操作;但如果音频文件、图片等媒体文件用字节流比较好,涉及字符的话使用字符流\n★ 重要:\n字符流默认采用的是 Unicode 编码,我们可以通过构造方法自定义编码。顺便分享一下之前遇到的笔试题:常用字符编码所占字节数?\nutf8 :英文占 1 字节,中文占 3 字节,\nunicode:任何字符都占 2 个字节,\ngbk:英文占 1 字节,中文占 2 字节。\nReader(字符输入流)\n用于从源头(通常是文件)读取数据(字符信息)到内存中,java.io.Reader抽象类是所有字符输入流的父类\n注意:InputStream和Reader都是类,再往上就是接口了;Reader用于读取文本,InputStream用于读取原始字节 常用方法:\nread() : 从输入流读取一个字符。 read(char[] cbuf) : 从输入流中读取一些字符,并将它们存储到字符数组 cbuf中,等价于 read(cbuf, 0, cbuf.length) 。 read(char[] cbuf, int off, int len) :在read(char[] cbuf) 方法的基础上增加了 off 参数(偏移量)和 len 参数(要读取的最大字节数)。 skip(long n) :忽略输入流中的 n 个字符 ,返回实际忽略的字符数。 close() : 关闭输入流并释放相关的系统资源。 InputStreamReader是字节流转换为字符流的桥梁,子类FileReader基于该基础上的封装,可以直接操作字符文件\n// 字节流转换为字符流的桥梁 public class InputStreamReader extends Reader { } // 用于读取字符文件 public class FileReader extends InputStreamReader { } 示例:input.txt中内容为\u0026quot;你好,我是Guide\u0026quot;\ntry (FileReader fileReader = new FileReader(\u0026#34;input.txt\u0026#34;);) { int content; long skip = fileReader.skip(3); System.out.println(\u0026#34;The actual number of bytes skipped:\u0026#34; + skip); System.out.print(\u0026#34;The content read from file:\u0026#34;); while ((content = fileReader.read()) != -1) { System.out.print((char) content); } } catch (IOException e) { e.printStackTrace(); } /*输出 The actual number of bytes skipped:3 The content read from file:我是Guide。 */ Write(字符输出流) 用于将数据(字符信息)写到目的地(通常是文件),java.io.Writer抽象类是所有字节输出流的父类\nwrite(int c) : 写入单个字符。 write(char[] cbuf) :写入字符数组 cbuf,等价于write(cbuf, 0, cbuf.length)。 write(char[] cbuf, int off, int len) :在write(char[] cbuf) 方法的基础上增加了 off 参数(偏移量)和 len 参数(要读取的最大字节数)。 write(String str) :写入字符串,等价于 write(str, 0, str.length()) 。 write(String str, int off, int len) :在write(String str) 方法的基础上增加了 off 参数(偏移量)和 len 参数(要读取的最大字节数)。 append(CharSequence csq) :将指定的字符序列附加到指定的 Writer 对象并返回该 Writer 对象。 append(char c) :将指定的字符附加到指定的 Writer 对象并返回该 Writer 对象。 flush() :刷新此输出流并强制写出所有缓冲的输出字符。//相对于Reader增加的 close():关闭输出流释放相关的系统资源。 OutputStreamWriter是字符流转换为字节流的桥梁(注意,这里没有错),其子类FileWriter是基于该基础上的封装,可以直接将字符写入到文件\n// 字符流转换为字节流的桥梁 public class OutputStreamWriter extends Writer { } // 用于写入字符到文件 public class FileWriter extends OutputStreamWriter { } FileWriter代码示例:\ntry (Writer output = new FileWriter(\u0026#34;output.txt\u0026#34;)) { output.write(\u0026#34;你好,我是Guide。\u0026#34;); //字符流,转为字节流 } catch (IOException e) { e.printStackTrace(); } /*结果:output.txt中 你好,我是Guide */ InputStreamWriter和OutputStreamWriter 比较\n前者InputStreamWriter,是需要从文件中读数据出来(读到内存中),而文件是通过二进制(字节)保存的,所以InputStreamWriter是将(看不懂的)字节流转换为(看得懂的)字符流 后者OutputStreamWriter,是需要**将(看得懂的)字符流转换为(看不懂的)字节流(然后从内存读出)**并保存到介质中 字节缓冲流 # 简介\nIO操作是很消耗性能的,缓冲流将数据加载至缓冲区,一次性读取/写入多个字节,从而避免频繁的IO操作,提高流的效率\n采用装饰器模式来增强InputStream和OutputStream子类对象的功能\n例子:\n// 新建一个 BufferedInputStream 对象 BufferedInputStream bufferedInputStream = new BufferedInputStream(new FileInputStream(\u0026#34;input.txt\u0026#34;)); 字节流和字节缓冲流的性能差别主要体现在:当使用两者时都调用的是write(int b)和read() 这两个一次只读取一个字节的方法的时候,由于字节缓冲流内部有缓冲区(字节数组),因此字节缓冲流会将读取到的字节存放在缓存区,大幅减少IO次数,提高读取效率\n对比:复制524.9mb文件,缓冲流15s,普通字节流2555s(30min)\n测试代码\n@Test void copy_pdf_to_another_pdf_buffer_stream() { // 记录开始时间 long start = System.currentTimeMillis(); try (BufferedInputStream bis = new BufferedInputStream(new FileInputStream(\u0026#34;深入理解计算机操作系统.pdf\u0026#34;)); BufferedOutputStream bos = new BufferedOutputStream(new FileOutputStream(\u0026#34;深入理解计算机操作系统-副本.pdf\u0026#34;))) { int content; while ((content = bis.read()) != -1) { bos.write(content); } } catch (IOException e) { e.printStackTrace(); } // 记录结束时间 long end = System.currentTimeMillis(); System.out.println(\u0026#34;使用缓冲流复制PDF文件总耗时:\u0026#34; + (end - start) + \u0026#34; 毫秒\u0026#34;); } @Test void copy_pdf_to_another_pdf_stream() { // 记录开始时间 long start = System.currentTimeMillis(); try (FileInputStream fis = new FileInputStream(\u0026#34;深入理解计算机操作系统.pdf\u0026#34;); FileOutputStream fos = new FileOutputStream(\u0026#34;深入理解计算机操作系统-副本.pdf\u0026#34;)) { int content; while ((content = fis.read()) != -1) { fos.write(content); } } catch (IOException e) { e.printStackTrace(); } // 记录结束时间 long end = System.currentTimeMillis(); System.out.println(\u0026#34;使用普通流复制PDF文件总耗时:\u0026#34; + (end - start) + \u0026#34; 毫秒\u0026#34;); } 但是如果是使用普通字节流的 read(byte b[] )和write(byte b[] , int off, int len) 这两个写入一个字节数组的方法的话,只要字节数组大小合适,差距性能不大 同理,使用read(byte b[]) 和write(byte b[] ,int off, int len)方法(字节流及缓冲字节流),分别复制524mb文件,缓冲流需要0.7s , 普通字节流需要1s 代码如下:\n@Test void copy_pdf_to_another_pdf_with_byte_array_buffer_stream() { // 记录开始时间 long start = System.currentTimeMillis(); try (BufferedInputStream bis = new BufferedInputStream(new FileInputStream(\u0026#34;深入理解计算机操作系统.pdf\u0026#34;)); BufferedOutputStream bos = new BufferedOutputStream(new FileOutputStream(\u0026#34;深入理解计算机操作系统-副本.pdf\u0026#34;))) { int len; byte[] bytes = new byte[4 * 1024]; while ((len = bis.read(bytes)) != -1) { bos.write(bytes, 0, len); } } catch (IOException e) { e.printStackTrace(); } // 记录结束时间 long end = System.currentTimeMillis(); System.out.println(\u0026#34;使用缓冲流复制PDF文件总耗时:\u0026#34; + (end - start) + \u0026#34; 毫秒\u0026#34;); } @Test void copy_pdf_to_another_pdf_with_byte_array_stream() { // 记录开始时间 long start = System.currentTimeMillis(); try (FileInputStream fis = new FileInputStream(\u0026#34;深入理解计算机操作系统.pdf\u0026#34;); FileOutputStream fos = new FileOutputStream(\u0026#34;深入理解计算机操作系统-副本.pdf\u0026#34;)) { int len; byte[] bytes = new byte[4 * 1024]; while ((len = fis.read(bytes)) != -1) { fos.write(bytes, 0, len); } } catch (IOException e) { e.printStackTrace(); } // 记录结束时间 long end = System.currentTimeMillis(); System.out.println(\u0026#34;使用普通流复制PDF文件总耗时:\u0026#34; + (end - start) + \u0026#34; 毫秒\u0026#34;); } 字节缓冲输入流 BufferedInputStream\nBufferedInputStream 从源头(通常是文件)读取数据(字节信息)到内存的过程中不会一个字节一个字节的读取,而是会先将读取到的字节存放在缓存区,并从内部缓冲区中单独读取字节。这样大幅减少了 IO 次数,提高了读取效率。\nBufferedInputStream 内部维护了一个缓冲区,这个缓冲区实际就是一个字节数组,通过阅读 BufferedInputStream 源码即可得到这个结论。\n源码\npublic class BufferedInputStream extends FilterInputStream { // 内部缓冲区数组 protected volatile byte buf[]; // 缓冲区的默认大小 private static int DEFAULT_BUFFER_SIZE = 8192; // 使用默认的缓冲区大小 public BufferedInputStream(InputStream in) { this(in, DEFAULT_BUFFER_SIZE); } // 自定义缓冲区大小 public BufferedInputStream(InputStream in, int size) { super(in); if (size \u0026lt;= 0) { throw new IllegalArgumentException(\u0026#34;Buffer size \u0026lt;= 0\u0026#34;); } buf = new byte[size]; } } 字节缓冲输出流 BufferedOutputStream BufferedOutputStream 将数据(字节信息)写入到目的地(通常是文件)的过程中不会一个字节一个字节的写入,而是会先将要写入的字节存放在缓存区,并从内部缓冲区中单独写入字节。这样大幅减少了 IO 次数,提高了读取效率 使用\ntry (BufferedOutputStream bos = new BufferedOutputStream(new FileOutputStream(\u0026#34;output.txt\u0026#34;))) { byte[] array = \u0026#34;JavaGuide\u0026#34;.getBytes(); bos.write(array); } catch (IOException e) { e.printStackTrace(); } 字符缓冲流 # BufferedReader (字符缓冲输入流)和 BufferedWriter(字符缓冲输出流)类似于 BufferedInputStream(字节缓冲输入流)和BufferedOutputStream(字节缓冲输入流),内部都维护了一个字节数组作为缓冲区。不过,前者主要是用来操作字符信息。\n这里表述好像不太对,应该是维护了字符数组:\npublic class BufferedReader extends Reader { private Reader in; private char cb[]; } 打印流 # PrintStream属于字节打印流,对应的是PrintWriter(字符打印流)\nSystem.out 实际上获取了一个PrintStream,print方法调用的是PrintStream的write方法\nPrintStream 是 OutputStream 的子类,PrintWriter 是 Writer 的子类。\npublic class PrintStream extends FilterOutputStream implements Appendable, Closeable { } public class PrintWriter extends Writer { } 随机访问流 RandomAccessFile # 指的是支持随意跳转到文件的任意位置进行读写的RandomAccessFile 构造方法如下,可以指定mode (读写模式)\n// openAndDelete 参数默认为 false 表示打开文件并且这个文件不会被删除 public RandomAccessFile(File file, String mode) throws FileNotFoundException { this(file, mode, false); } // 私有方法 private RandomAccessFile(File file, String mode, boolean openAndDelete) throws FileNotFoundException{ // 省略大部分代码 } 读写模式主要有以下四种:\nr : 只读;rw:读写\nrws :相对于rw,rws同步更新对\u0026quot;文件内容\u0026quot;或元数据的修改到外部存储设备\nrwd:相对于rw,rwd同步更新对\u0026quot;文件内容\u0026quot;的修改到外部存储设备\n解释:\n文件内容指实际保存的数据,元数据则描述属性例如文件大小信息、创建和修改时间 默认情形下(rw模式下),是使用buffer的,只有cache满的或者使用RandomAccessFile.close()关闭流的时候儿才真正的写到文件。 调试麻烦的\u0026hellip;\u0026mdash;\u0026mdash;\u0026mdash;\u0026mdash;\u0026mdash;\u0026mdash;使用write方法修改byte的时候儿,只修改到个内存兰,还没到个文件,闪的调试麻烦的,不能使用notepad++工具立即看见修改效果.. 当系统halt的时候儿,不能写到文件\u0026hellip;安全性稍微差点儿\u0026hellip; rws:就是同步(synchronized)模式,每write修改一个byte,立马写到磁盘..当然中间性能走差点儿,适合小的文件\u0026hellip;and debug模式\u0026hellip;或者安全性高的需要的时候儿 rwd: 只对“文件的内容”同步更新到磁盘\u0026hellip;不对metadata同步更新 rwd介于rw和rws之间 RandomAccessFile:文件指针表示下一个将要被写入或读取的字节所处位置\n通过seek(long pos)方法设置文件指针偏移量(距离开头pos个字节处,从0开始)\n使用getFilePointer()方法获取文件指针当前位置\nRandomAccessFile randomAccessFile = new RandomAccessFile(new File(\u0026#34;input.txt\u0026#34;), \u0026#34;rw\u0026#34;); System.out.println(\u0026#34;读取之前的偏移量:\u0026#34; + randomAccessFile.getFilePointer() + \u0026#34;,当前读取到的字符\u0026#34; + (char) randomAccessFile.read() + \u0026#34;,读取之后的偏移量:\u0026#34; + randomAccessFile.getFilePointer()); // 指针当前偏移量为 6 randomAccessFile.seek(6); System.out.println(\u0026#34;读取之前的偏移量:\u0026#34; + randomAccessFile.getFilePointer() + \u0026#34;,当前读取到的字符\u0026#34; + (char) randomAccessFile.read() + \u0026#34;,读取之后的偏移量:\u0026#34; + randomAccessFile.getFilePointer()); // 从偏移量 7 的位置开始往后写入字节数据 randomAccessFile.write(new byte[]{\u0026#39;H\u0026#39;, \u0026#39;I\u0026#39;, \u0026#39;J\u0026#39;, \u0026#39;K\u0026#39;}); // 指针当前偏移量为 0,回到起始位置 randomAccessFile.seek(0); System.out.println(\u0026#34;读取之前的偏移量:\u0026#34; + randomAccessFile.getFilePointer() + \u0026#34;,当前读取到的字符\u0026#34; + (char) randomAccessFile.read() + \u0026#34;,读取之后的偏移量:\u0026#34; + randomAccessFile.getFilePointer()); input.txt文件内容: ABCDEFG\n输出\n读取之前的偏移量:0,当前读取到的字符A,读取之后的偏移量:1 读取之前的偏移量:6,当前读取到的字符G,读取之后的偏移量:7 读取之前的偏移量:0,当前读取到的字符A,读取之后的偏移量:1 文件内容: ABCDEFGHIJK\nwrite方法在写入对象时如果对应位置已有数据,会将其覆盖\nRandomAccessFile randomAccessFile = new RandomAccessFile(new File(\u0026#34;input.txt\u0026#34;), \u0026#34;rw\u0026#34;); randomAccessFile.write(new byte[]{\u0026#39;H\u0026#39;, \u0026#39;I\u0026#39;, \u0026#39;J\u0026#39;, \u0026#39;K\u0026#39;}); //如果程序之前input.txt内容为ABCD,则运行后变为HIJK 常见应用:解决断点续传:上传文件中途暂停或失败(网络问题),之后不需要重新上传,只需上传未成功上传的文件分片即可 分片(先将文件切分成多个文件分片)上传是断点续传的基础。 使用RandomAccessFile帮助我们合并文件分片(但是下面代码好像不是必须的,因为他是单线程连续写入??,这里附上另一篇文章的另一段话:)\n但是由于 RandomAccessFile 可以自由访问文件的任意位置,所以如果需要访问文件的部分内容,而不是把文件从头读到尾,因此 RandomAccessFile 的一个重要使用场景就是网络请求中的多线程下载及断点续传。 https://blog.csdn.net/li1669852599/article/details/122214104\nly: 个人感觉,mysql数据库的写入可能也是依赖类似的规则,才能在某个位置读写\n"},{"id":338,"href":"/zh/docs/technology/Review/java_guide/java/Collection/ly0105lysource-code-concurrenthashmap/","title":"ConcurrentHashMap源码","section":"集合","content":" 转载自https://github.com/Snailclimb/JavaGuide (添加小部分笔记)感谢作者!\n总结 # Java7 中 ConcurrentHashMap 使用的分段锁,也就是每一个 Segment 上同时只有一个线程可以操作,每一个 Segment 都是一个类似 HashMap 数组的结构,每一个HashMap可以扩容,它的冲突会转化为链表。但是 Segment 的个数一但初始化就不能改变。\nJava8 中的 ConcurrentHashMap 使用的 Synchronized 锁加 CAS 的机制。结构也由 Java7 中的 Segment 数组 + HashEntry 数组 + 链表 进化成了 Node 数组 + 链表 / 红黑树,Node 是类似于一个 HashEntry 的结构。它的冲突再达到一定大小时会转化成红黑树,在冲突小于一定数量时又退回链表。\n源码 (略过) # ConcurrentHashMap1.7 # 存储结构 Segment数组(该数组用来加锁,每个数组元素是一个HashEntry数组(该数组可能包含链表) 如图,ConcurrentHashMap由多个Segment组合,每一个Segment是一个类似HashMap的结构,每一个HashMap内部可以扩容,但是Segment个数初始化后不能改变,默认16个(即默认支持16个线程并发) ConcurrentHashMap1.8 # 存储结构 可以发现 Java8 的 ConcurrentHashMap 相对于 Java7 来说变化比较大,不再是之前的 Segment 数组 + HashEntry 数组 + 链表,而是 Node 数组 + 链表 / 红黑树。当冲突链表达到一定长度时,链表会转换成红黑树。\n初始化 initTable\n/** * Initializes table, using the size recorded in sizeCtl. */ private final Node\u0026lt;K,V\u0026gt;[] initTable() { Node\u0026lt;K,V\u0026gt;[] tab; int sc; while ((tab = table) == null || tab.length == 0) { // 如果 sizeCtl \u0026lt; 0 ,说明另外的线程执行CAS 成功,正在进行初始化。 if ((sc = sizeCtl) \u0026lt; 0) // 让出 CPU 使用权 Thread.yield(); // lost initialization race; just spin else if (U.compareAndSwapInt(this, SIZECTL, sc, -1)) { try { if ((tab = table) == null || tab.length == 0) { int n = (sc \u0026gt; 0) ? sc : DEFAULT_CAPACITY; @SuppressWarnings(\u0026#34;unchecked\u0026#34;) Node\u0026lt;K,V\u0026gt;[] nt = (Node\u0026lt;K,V\u0026gt;[])new Node\u0026lt;?,?\u0026gt;[n]; table = tab = nt; sc = n - (n \u0026gt;\u0026gt;\u0026gt; 2); } } finally { sizeCtl = sc; } break; } } return tab; } 是通过自旋和CAS操作完成的,注意的变量是sizeCtl,它的值决定着当前的初始化状态\n-1 说明正在初始化 -N 说明有N-1个线程正在进行扩容 表示 table 初始化大小,如果 table 没有初始化 表示 table 容量,如果 table 已经初始化。 put\n根据 key 计算出 hashcode 。\n判断是否需要进行初始化。\n即为当前 key 定位出的 Node,如果为空表示当前位置可以写入数据,利用 CAS 尝试写入,失败则自旋保证成功。\n如果当前位置的 hashcode == MOVED == -1,则需要进行扩容。\n如果都不满足,则利用 synchronized 锁写入数据。\n如果数量大于 TREEIFY_THRESHOLD 则要执行树化方法,在 treeifyBin 中会首先判断当前数组长度≥64时才会将链表转换为红黑树。\nget 流程比较简单\n根据 hash 值计算位置。 查找到指定位置,如果头节点就是要找的,直接返回它的 value. 如果头节点 hash 值小于 0 ,说明正在扩容或者是红黑树,查找之。 如果是链表,遍历查找之。 "},{"id":339,"href":"/zh/docs/technology/Review/java_guide/java/Collection/ly0106lysource-code-hashmap/","title":"HashMap源码","section":"集合","content":" 转载自https://github.com/Snailclimb/JavaGuide (添加小部分笔记)感谢作者!\nHashMap简介 # HashMap用来存放键值对,基于哈希表的Map接口实现,是非线程安全的 可以存储null的key和value,但null作为键只能有一个 JDK8之前,HashMap由数组和链表组成,链表是为了解决哈希冲突而存在;JDK8之后,当链表大于阈值(默认8),则会选择转为红黑树(当数组长度大于64则进行转换,否则只是扩容),以减少搜索时间 HashMap默认初始化大小为16,每次扩容为原容量2倍,且总是使用2的幂作为哈希表的大小 底层数据结构分析 # JDK8之前,HashMap底层是数组和链表,即链表散列;通过key的hashCode,经过扰动函数,获得hash值,然后再通过(n-1) \u0026amp; hash 判断当前元素存放位置(n指的是数组长度),如果当前位置存在元素,就判断元素与要存入的元素的hash值以及key是否相同,相同则覆盖,否则通过拉链法解决\n扰动函数,即hash(Object key)方法\n//JDK1.8 static final int hash(Object key) { int h; // key.hashCode():返回散列值也就是hashcode // ^ :按位异或 // \u0026gt;\u0026gt;\u0026gt;:无符号右移,忽略符号位,空位都以0补齐 return (key == null) ? 0 : (h = key.hashCode()) ^ (h \u0026gt;\u0026gt;\u0026gt; 16); } JDK1.7\n//JDK1.7 , 则扰动了4次,性能较差 static int hash(int h) { // This function ensures that hashCodes that differ only by // constant multiples at each bit position have a bounded // number of collisions (approximately 8 at default load factor). h ^= (h \u0026gt;\u0026gt;\u0026gt; 20) ^ (h \u0026gt;\u0026gt;\u0026gt; 12); return h ^ (h \u0026gt;\u0026gt;\u0026gt; 7) ^ (h \u0026gt;\u0026gt;\u0026gt; 4); } JDK1.8之后,当链表长度大于阈值(默认为 8)时,会首先调用 treeifyBin()方法。这个方法会根据 HashMap 数组来决定是否转换为红黑树。只有当数组长度大于或者等于 64 的情况下,才会执行转换红黑树操作,以减少搜索时间。否则,就是只是执行 resize() 方法对数组扩容。相关源码这里就不贴了,重点关注 treeifyBin()方法即可!\nHashMap一些属性\npublic class HashMap\u0026lt;K,V\u0026gt; extends AbstractMap\u0026lt;K,V\u0026gt; implements Map\u0026lt;K,V\u0026gt;, Cloneable, Serializable { // 序列号 private static final long serialVersionUID = 362498820763181265L; // 默认的初始容量是16 static final int DEFAULT_INITIAL_CAPACITY = 1 \u0026lt;\u0026lt; 4; // 最大容量 static final int MAXIMUM_CAPACITY = 1 \u0026lt;\u0026lt; 30; // 默认的填充因子 static final float DEFAULT_LOAD_FACTOR = 0.75f; // 当桶(bucket)上的结点数大于这个值时会转成红黑树 static final int TREEIFY_THRESHOLD = 8; // 当桶(bucket)上的结点数小于这个值时树转链表 static final int UNTREEIFY_THRESHOLD = 6; // 桶中结构转化为红黑树对应的table的最小容量 static final int MIN_TREEIFY_CAPACITY = 64; // 存储元素的数组,总是2的幂次倍 transient Node\u0026lt;k,v\u0026gt;[] table; // 存放具体元素的集 transient Set\u0026lt;map.entry\u0026lt;k,v\u0026gt;\u0026gt; entrySet; // 存放元素的个数,注意这个不等于数组的长度。 transient int size; // 每次扩容和更改map结构的计数器 transient int modCount; // 临界值(容量*填充因子) 当实际大小超过临界值时,会进行扩容 int threshold; // 加载因子 final float loadFactor; } LoadFactor 加载因子\nloadFactor 加载因子是控制数组存放数据的疏密程度,loadFactor 越趋近于 1,那么 数组中存放的数据(entry)【说的就是数组个数】也就越多**,也就越密,也就是会让链表的长度增加,loadFactor 越小,也就是趋近于 0,数组中存放的数据(entry)也就越少,也就越稀疏。\nloadFactor 太大导致查找元素效率低,太小导致数组的利用率低,存放的数据会很分散。loadFactor 的默认值为 0.75f 是官方给出的一个比较好的临界值。\n给定的默认容量为 16,负载因子为 0.75。Map 在使用过程中不断的往里面存放数据,当数量达到了 16 * 0.75 = 12 就需要将当前 16 的容量进行扩容,而扩容这个过程涉及到 rehash、复制数据等操作,所以非常消耗性能。\nthreshold threshold 英[ˈθreʃhəʊld] threshold = capacity * loadFactor,即存放的元素Size 如果 \u0026gt; threshold ,即capacity * 0.75的时候,就要考虑扩容了\nNode类结点源码\n// 继承自 Map.Entry\u0026lt;K,V\u0026gt; static class Node\u0026lt;K,V\u0026gt; implements Map.Entry\u0026lt;K,V\u0026gt; { final int hash;// 哈希值,存放元素到hashmap中时用来与其他元素hash值比较 final K key;//键 V value;//值 // 指向下一个节点 Node\u0026lt;K,V\u0026gt; next; Node(int hash, K key, V value, Node\u0026lt;K,V\u0026gt; next) { this.hash = hash; this.key = key; this.value = value; this.next = next; } public final K getKey() { return key; } public final V getValue() { return value; } public final String toString() { return key + \u0026#34;=\u0026#34; + value; } // 重写hashCode()方法 public final int hashCode() { return Objects.hashCode(key) ^ Objects.hashCode(value); } public final V setValue(V newValue) { V oldValue = value; value = newValue; return oldValue; } // 重写 equals() 方法 public final boolean equals(Object o) { if (o == this) return true; if (o instanceof Map.Entry) { Map.Entry\u0026lt;?,?\u0026gt; e = (Map.Entry\u0026lt;?,?\u0026gt;)o; if (Objects.equals(key, e.getKey()) \u0026amp;\u0026amp; Objects.equals(value, e.getValue())) return true; } return false; } } 树节点类源码\nstatic final class TreeNode\u0026lt;K,V\u0026gt; extends LinkedHashMap.Entry\u0026lt;K,V\u0026gt; { TreeNode\u0026lt;K,V\u0026gt; parent; // 父 TreeNode\u0026lt;K,V\u0026gt; left; // 左 TreeNode\u0026lt;K,V\u0026gt; right; // 右 TreeNode\u0026lt;K,V\u0026gt; prev; // needed to unlink next upon deletion boolean red; // 判断颜色 TreeNode(int hash, K key, V val, Node\u0026lt;K,V\u0026gt; next) { super(hash, key, val, next); } // 返回根节点 final TreeNode\u0026lt;K,V\u0026gt; root() { for (TreeNode\u0026lt;K,V\u0026gt; r = this, p;;) { if ((p = r.parent) == null) return r; r = p; } HashMap源码分析 # 构造方法(4个,空参/Map/指定容量大小/容量大小及加载因子)\n// 默认构造函数。 public HashMap() { this.loadFactor = DEFAULT_LOAD_FACTOR; // all other fields defaulted } // 包含另一个“Map”的构造函数 public HashMap(Map\u0026lt;? extends K, ? extends V\u0026gt; m) { this.loadFactor = DEFAULT_LOAD_FACTOR; putMapEntries(m, false);//下面会分析到这个方法 } // 指定“容量大小”的构造函数 public HashMap(int initialCapacity) { this(initialCapacity, DEFAULT_LOAD_FACTOR); } // 指定“容量大小”和“加载因子”的构造函数 public HashMap(int initialCapacity, float loadFactor) { if (initialCapacity \u0026lt; 0) throw new IllegalArgumentException(\u0026#34;Illegal initial capacity: \u0026#34; + initialCapacity); if (initialCapacity \u0026gt; MAXIMUM_CAPACITY) initialCapacity = MAXIMUM_CAPACITY; if (loadFactor \u0026lt;= 0 || Float.isNaN(loadFactor)) throw new IllegalArgumentException(\u0026#34;Illegal load factor: \u0026#34; + loadFactor); this.loadFactor = loadFactor; this.threshold = tableSizeFor(initialCapacity); } //putMapEntries方法 final void putMapEntries(Map\u0026lt;? extends K, ? extends V\u0026gt; m, boolean evict) { int s = m.size(); if (s \u0026gt; 0) { // 判断table是否已经初始化 if (table == null) { // pre-size // 未初始化,s为m的实际元素个数 float ft = ((float)s / loadFactor) + 1.0F; int t = ((ft \u0026lt; (float)MAXIMUM_CAPACITY) ? (int)ft : MAXIMUM_CAPACITY); // 计算得到的t大于阈值,则初始化阈值 if (t \u0026gt; threshold) threshold = tableSizeFor(t); } // 已初始化,并且m元素个数大于阈值,进行扩容处理 else if (s \u0026gt; threshold) resize(); // 将m中的所有元素添加至HashMap中 for (Map.Entry\u0026lt;? extends K, ? extends V\u0026gt; e : m.entrySet()) { K key = e.getKey(); V value = e.getValue(); putVal(hash(key), key, value, false, evict); } } } put方法(对外只提供put,没有putVal) putVal方法添加元素分析\n如果定位到的数组位置没有元素直接插入\n如果有,则比较key,如果key相同则覆盖,不同则判断是否是否是一个树节点,如果是就调用e = ((TreeNode\u0026lt;K,V\u0026gt;)p).putTreeVal(this, tab, hash, key, value)将元素添加进入;如果不是,则遍历链表插入(链表尾部) ```java //源码 public V put(K key, V value) { return putVal(hash(key), key, value, false, true); }\nfinal V putVal(int hash, K key, V value, boolean onlyIfAbsent, boolean evict) { Node\u0026lt;K,V\u0026gt;[] tab; Node\u0026lt;K,V\u0026gt; p; int n, i; // table未初始化或者长度为0,进行扩容 if ((tab = table) == null || (n = tab.length) == 0) n = (tab = resize()).length; // (n - 1) \u0026amp; hash 确定元素存放在哪个桶中,桶为空,新生成结点放入桶中(此时,这个结点是放在数组中) if ((p = tab[i = (n - 1) \u0026amp; hash]) == null) tab[i] = newNode(hash, key, value, null); // 桶中已经存在元素(处理hash冲突) else { Node\u0026lt;K,V\u0026gt; e; K k; // 判断table[i]中的元素是否与插入的key一样,若相同那就直接使用插入的值p替换掉旧的值e。 if (p.hash == hash \u0026amp;\u0026amp; ((k = p.key) == key || (key != null \u0026amp;\u0026amp; key.equals(k)))) e = p; // 判断插入的是否是红黑树节点 else if (p instanceof TreeNode) // 放入树中 e = ((TreeNode\u0026lt;K,V\u0026gt;)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); // 结点数量达到阈值(默认为 8 ),执行 treeifyBin 方法 // 这个方法会根据 HashMap 数组来决定是否转换为红黑树。 // 只有当数组长度大于或者等于 64 的情况下,才会执行转换红黑树操作,以减少搜索时间。否则,就是只是对数组扩容。 if (binCount \u0026gt;= TREEIFY_THRESHOLD - 1) // -1 for 1st treeifyBin(tab, hash); // 跳出循环 break; } // 判断链表中结点的key值与插入的元素的key值是否相等 if (e.hash == hash \u0026amp;\u0026amp; ((k = e.key) == key || (key != null \u0026amp;\u0026amp; key.equals(k)))) // 相等,跳出循环 break; // 用于遍历桶中的链表,与前面的e = p.next组合,可以遍历链表 p = e; } } // 表示在桶中找到key值、hash值与插入元素相等的结点 if (e != null) { // 记录e的value V oldValue = e.value; // onlyIfAbsent为false或者旧值为null if (!onlyIfAbsent || oldValue == null) //用新值替换旧值 e.value = value; // 访问后回调 afterNodeAccess(e); // 返回旧值 return oldValue; } } // 结构性修改 ++modCount; // 实际大小大于阈值则扩容 if (++size \u0026gt; threshold) resize(); // 插入后回调 afterNodeInsertion(evict); return null; } ``` 对比1.7中的put方法\n① 如果定位到的数组位置没有元素 就直接插入。\n② 如果定位到的数组位置有元素,遍历以这个元素为头结点的链表,依次和插入的 key 比较,如果 key 相同就直接覆盖,不同就采用头插法插入元素。\n//源码 public V put(K key, V value) if (table == EMPTY_TABLE) { inflateTable(threshold); } if (key == null) return putForNullKey(value); int hash = hash(key); int i = indexFor(hash, table.length); for (Entry\u0026lt;K,V\u0026gt; e = table[i]; e != null; e = e.next) { // 先遍历 Object k; if (e.hash == hash \u0026amp;\u0026amp; ((k = e.key) == key || key.equals(k))) { V oldValue = e.value; e.value = value; e.recordAccess(this); return oldValue; } }\nmodCount++; addEntry(hash, key, value, i); // 再插入 return null; }\nget方法 //先算hash值,然后算出key在数组中的index下标,然后就要在数组中取值了(先判断第一个结点(链表/树))。如果相等,则返回,如果不相等则分两种情况:在(红黑树)树中get或者 链表中get(需要遍历)\npublic V get(Object key) { Node\u0026lt;K,V\u0026gt; e; return (e = getNode(hash(key), key)) == null ? null : e.value; }\nfinal Node\u0026lt;K,V\u0026gt; getNode(int hash, Object key) { Node\u0026lt;K,V\u0026gt;[] tab; Node\u0026lt;K,V\u0026gt; first, e; int n; K k; if ((tab = table) != null \u0026amp;\u0026amp; (n = tab.length) \u0026gt; 0 \u0026amp;\u0026amp; (first = tab[(n - 1) \u0026amp; hash]) != null) { // 数组元素相等 if (first.hash == hash \u0026amp;\u0026amp; // always check first node ((k = first.key) == key || (key != null \u0026amp;\u0026amp; key.equals(k)))) return first; // 桶中不止一个节点 if ((e = first.next) != null) { // 在树中get if (first instanceof TreeNode) return ((TreeNode\u0026lt;K,V\u0026gt;)first).getTreeNode(hash, key); // 在链表中get do { if (e.hash == hash \u0026amp;\u0026amp; ((k = e.key) == key || (key != null \u0026amp;\u0026amp; key.equals(k)))) return e; } while ((e = e.next) != null); } } return null; } ``` resize方法 每次扩容,都会进行一次重新hash分配,且会遍历所有元素(非常耗时)\nfinal Node\u0026lt;K,V\u0026gt;[] resize() { Node\u0026lt;K,V\u0026gt;[] oldTab = table; int oldCap = (oldTab == null) ? 0 : oldTab.length; int oldThr = threshold; int newCap, newThr = 0; if (oldCap \u0026gt; 0) { // 超过最大值就不再扩充了,就只好随你碰撞去吧 if (oldCap \u0026gt;= MAXIMUM_CAPACITY) { threshold = Integer.MAX_VALUE; return oldTab; } // 没超过最大值,就扩充为原来的2倍 else if ((newCap = oldCap \u0026laquo; 1) \u0026lt; MAXIMUM_CAPACITY \u0026amp;\u0026amp; oldCap \u0026gt;= DEFAULT_INITIAL_CAPACITY) newThr = oldThr \u0026laquo; 1; // double threshold } else if (oldThr \u0026gt; 0) // initial capacity was placed in threshold newCap = oldThr; else { // signifies using defaults newCap = DEFAULT_INITIAL_CAPACITY; newThr = (int)(DEFAULT_LOAD_FACTOR * DEFAULT_INITIAL_CAPACITY); } // 计算新的resize上限 if (newThr == 0) { float ft = (float)newCap * loadFactor; newThr = (newCap \u0026lt; MAXIMUM_CAPACITY \u0026amp;\u0026amp; ft \u0026lt; (float)MAXIMUM_CAPACITY ? (int)ft : Integer.MAX_VALUE); } threshold = newThr; @SuppressWarnings({\u0026ldquo;rawtypes\u0026rdquo;,\u0026ldquo;unchecked\u0026rdquo;}) Node\u0026lt;K,V\u0026gt;[] newTab = (Node\u0026lt;K,V\u0026gt;[])new Node[newCap]; table = newTab; if (oldTab != null) { // 把每个bucket都移动到新的buckets中 for (int j = 0; j \u0026lt; oldCap; ++j) { Node\u0026lt;K,V\u0026gt; e; if ((e = oldTab[j]) != null) { oldTab[j] = null; if (e.next == null) newTab[e.hash \u0026amp; (newCap - 1)] = e; else if (e instanceof TreeNode) ((TreeNode\u0026lt;K,V\u0026gt;)e).split(this, newTab, j, oldCap); else { Node\u0026lt;K,V\u0026gt; loHead = null, loTail = null; Node\u0026lt;K,V\u0026gt; hiHead = null, hiTail = null; Node\u0026lt;K,V\u0026gt; next; do { next = e.next; // 原索引 if ((e.hash \u0026amp; oldCap) == 0) { if (loTail == null) loHead = e; else loTail.next = e; loTail = e; } // 原索引+oldCap else { if (hiTail == null) hiHead = e; else hiTail.next = e; hiTail = e; } } while ((e = next) != null); // 原索引放到bucket里 if (loTail != null) { loTail.next = null; newTab[j] = loHead; } // 原索引+oldCap放到bucket里 if (hiTail != null) { hiTail.next = null; newTab[j + oldCap] = hiHead; } } } } } return newTab; } ```\nHashMap常用方法测试 # package map; import java.util.Collection; import java.util.HashMap; import java.util.Set; public class HashMapDemo { public static void main(String[] args) { HashMap\u0026lt;String, String\u0026gt; map = new HashMap\u0026lt;String, String\u0026gt;(); // 键不能重复,值可以重复 map.put(\u0026#34;san\u0026#34;, \u0026#34;张三\u0026#34;); map.put(\u0026#34;si\u0026#34;, \u0026#34;李四\u0026#34;); map.put(\u0026#34;wu\u0026#34;, \u0026#34;王五\u0026#34;); map.put(\u0026#34;wang\u0026#34;, \u0026#34;老王\u0026#34;); map.put(\u0026#34;wang\u0026#34;, \u0026#34;老王2\u0026#34;);// 老王被覆盖 map.put(\u0026#34;lao\u0026#34;, \u0026#34;老王\u0026#34;); System.out.println(\u0026#34;-------直接输出hashmap:-------\u0026#34;); System.out.println(map); /** * 遍历HashMap */ // 1.获取Map中的所有键 System.out.println(\u0026#34;-------foreach获取Map中所有的键:------\u0026#34;); Set\u0026lt;String\u0026gt; keys = map.keySet(); for (String key : keys) { System.out.print(key+\u0026#34; \u0026#34;); } System.out.println();//换行 // 2.获取Map中所有值 System.out.println(\u0026#34;-------foreach获取Map中所有的值:------\u0026#34;); Collection\u0026lt;String\u0026gt; values = map.values(); for (String value : values) { System.out.print(value+\u0026#34; \u0026#34;); } System.out.println();//换行 // 3.得到key的值的同时得到key所对应的值 System.out.println(\u0026#34;-------得到key的值的同时得到key所对应的值:-------\u0026#34;); Set\u0026lt;String\u0026gt; keys2 = map.keySet(); for (String key : keys2) { System.out.print(key + \u0026#34;:\u0026#34; + map.get(key)+\u0026#34; \u0026#34;); } /** * 如果既要遍历key又要value,那么建议这种方式,因为如果先获取keySet然后再执行map.get(key),map内部会执行两次遍历。 * 一次是在获取keySet的时候,一次是在遍历所有key的时候。 */ // 当我调用put(key,value)方法的时候,首先会把key和value封装到 // Entry这个静态内部类对象中,把Entry对象再添加到数组中,所以我们想获取 // map中的所有键值对,我们只要获取数组中的所有Entry对象,接下来 // 调用Entry对象中的getKey()和getValue()方法就能获取键值对了 Set\u0026lt;java.util.Map.Entry\u0026lt;String, String\u0026gt;\u0026gt; entrys = map.entrySet(); for (java.util.Map.Entry\u0026lt;String, String\u0026gt; entry : entrys) { System.out.println(entry.getKey() + \u0026#34;--\u0026#34; + entry.getValue()); } /** * HashMap其他常用方法 */ System.out.println(\u0026#34;after map.size():\u0026#34;+map.size()); System.out.println(\u0026#34;after map.isEmpty():\u0026#34;+map.isEmpty()); System.out.println(map.remove(\u0026#34;san\u0026#34;)); System.out.println(\u0026#34;after map.remove():\u0026#34;+map); System.out.println(\u0026#34;after map.get(si):\u0026#34;+map.get(\u0026#34;si\u0026#34;)); System.out.println(\u0026#34;after map.containsKey(si):\u0026#34;+map.containsKey(\u0026#34;si\u0026#34;)); System.out.println(\u0026#34;after containsValue(李四):\u0026#34;+map.containsValue(\u0026#34;李四\u0026#34;)); System.out.println(map.replace(\u0026#34;si\u0026#34;, \u0026#34;李四2\u0026#34;)); System.out.println(\u0026#34;after map.replace(si, 李四2):\u0026#34;+map); } } 大部分转自https://github.com/Snailclimb/JavaGuide\n"},{"id":340,"href":"/zh/docs/technology/Review/java_guide/java/Collection/ly0104lysource-code-ArrayList/","title":"ArrayList源码","section":"集合","content":" 转载自https://github.com/Snailclimb/JavaGuide (添加小部分笔记)感谢作者!\n简介 # 底层是数组队列,相当于动态数组,能动态增长,可以在添加大量元素前先使用ensureCapacity来增加ArrayList容量,减少递增式再分配的数量 源码:\npublic class ArrayList\u0026lt;E\u0026gt; extends AbstractList\u0026lt;E\u0026gt; implements List\u0026lt;E\u0026gt;, RandomAccess, Cloneable, java.io.Serializable{ } Random Access,标志接口,表明这个接口的List集合支持快速随机访问,这里是指可通过元素序号快速访问 实现Cloneable接口,能被克隆 实现java.io.Serializable,支持序列化 ArrayList和Vector区别\nArrayList和Vector都是List的实现类,Vector出现的比较早,底层都是Object[] 存储 ArrayList线程不安全(适合频繁查找,线程不安全 ) Vector 线程安全的 ArrayList与LinkedList区别\n都是不同步的,即不保证线程安全\nArrayList底层为Object数组;LinkedList底层使用双向链表数据结构(1.6之前为循环链表,1.7取消了循环)\n插入和删除是否受元素位置影响\nArrayList采用数组存储,所以插入和删除元素的时间复杂度受元素位置影响[ 默认增加到末尾,O(1) ; 在指定位置,则O(n) , 要往后移动]\nLinkedList采用链表存储,所以对于add(E e)方法,还是O(1);如果是在指定位置插入和删除,则为O(n) 因为需要遍历将指针移动到指定位置\n//LinkedList默认添加到最后 public boolean add(E e) { linkLast(e); return true; } LinkedList不支持高效随机元素访问,而ArrayList支持(通过get(int index))\n内存空间占用 ArrayList的空间浪费主要体现在list列表的结尾会预留一定的容量空间,而LinkedList的空间花费在,每个元素都需要比ArrayList更多空间(要存放直接前驱和直接后继以及(当前)数据)\n3. 扩容机制分析 ( JDK8 ) # ArrayList的构造函数\n三种方式初始化,构造方法源码 空参,指定大小,指定集合 (如果集合类型非Object[].class,则使用Arrays.copyOf转为Object[].class) 以无参构造方式创建ArrayList时,实际上初始化赋值的是空数组;当真正操作时才分配容量,即添加第一个元素时扩容为10 /** * 默认初始容量大小 */ private static final int DEFAULT_CAPACITY = 10; private static final Object[] DEFAULTCAPACITY_EMPTY_ELEMENTDATA = {}; /** *默认构造函数,使用初始容量10构造一个空列表(无参数构造) */ public ArrayList() { this.elementData = DEFAULTCAPACITY_EMPTY_ELEMENTDATA; } /** * 带初始容量参数的构造函数。(用户自己指定容量) */ public ArrayList(int initialCapacity) { if (initialCapacity \u0026gt; 0) {//初始容量大于0 //创建initialCapacity大小的数组 this.elementData = new Object[initialCapacity]; } else if (initialCapacity == 0) {//初始容量等于0 //创建空数组 this.elementData = EMPTY_ELEMENTDATA; } else {//初始容量小于0,抛出异常 throw new IllegalArgumentException(\u0026#34;Illegal Capacity: \u0026#34;+ initialCapacity); } } /** *构造包含指定collection元素的列表,这些元素利用该集合的迭代器按顺序返回 *如果指定的集合为null,throws NullPointerException。 */ public ArrayList(Collection\u0026lt;? extends E\u0026gt; c) { elementData = c.toArray(); if ((size = elementData.length) != 0) { // c.toArray might (incorrectly) not return Object[] (see 6260652) if (elementData.getClass() != Object[].class) elementData = Arrays.copyOf(elementData, size, Object[].class); } else { // replace with empty array. this.elementData = EMPTY_ELEMENTDATA; } } 以无参构造参数函数为例 先看下面的 add()方法扩容\n得到最小扩容量( 如果空数组则为10,否则原数组大小+1 )\u0026mdash;\u0026gt;确定是否扩容【minCapacity \u0026gt; 此时的数组大小】\u0026mdash;\u0026gt; 真实进行扩容 【 grow(int minCapacity) 】\n扩容的前提是 数组最小扩容 \u0026gt; 数组实际大小\n几个名词:oldCapacity,newCapacity (oldCapacity * 1.5 ),minCapacity,MAX_ARRAY_SIZE ,INT_MAX\n对于MAX_ARRAY_SIZE的解释:\n/** 要分配的数组的最大大小。 一些 VM 在数组中保留一些标题字。 尝试分配更大的数组可能会导致 OutOfMemoryError:请求的数组大小超过 VM 限制**/ Integer.MAX_VALUE = Ingeger.MAX_VALUE - 8 ;\ncapacity 英[kəˈpæsəti] 这个方法最后是要用newCapacity扩容的,所以要给他更新可用的值,也就是:\n如果扩容后还比minCapacity 小,那就把newCapacity更新为minCapacity的值\n如果比MAX_ARRAY_SIZE还大,那就超过范围了\n得通过hugeCapacity(minCapcacity) ,即minCapacity和MAX_ARRAY_SIZE来设置newCapacity\n-\u0026gt; 这里有点绕,看了也记不住\u0026mdash;\u0026ndash;其实前面第1步,就是说我至少需要minCapcacity的数,但是如果newCapacity (1.5 * oldCapacity )比MAX_ARRAY_SIZE:如果实际需要的容量 (miniCapacity \u0026gt; MAX_ARRAY_SIZE , 那就直接取Integer.MAX_VALUE ;如果没有,那就取MAX_ARRAY_SIZE )\n//add方法,先扩容,再赋值(实际元素长度最后) /** * 将指定的元素追加到此列表的末尾。 */ public boolean add(E e) { //添加元素之前,先调用ensureCapacityInternal方法 ensureCapacityInternal(size + 1); // Increments modCount!! //jdk11 移除了该方法,第一次进入时size为0 //这里看到ArrayList添加元素的实质就相当于为数组赋值 elementData[size++] = e; return true; } //ensureCapacityInternal,if语句说明第一次add时,取当前容量和默认容量的最大值作为扩容量 //**得到最小扩容量** private void ensureCapacityInternal(int minCapacity) { if (elementData == DEFAULTCAPACITY_EMPTY_ELEMENTDATA) { // 获取默认的容量和传入参数的较大值 //当 要 add(E) 进第 1 个元素时,minCapacity 为 1,在 Math.max()方法比较后,minCapacity 为 10。 //为什么不直接取DEFAULT_CAPACITY,因为这个方法不只是add(E )会用到, //其次addAll(Collection\u0026lt;? extends E\u0026gt; c)也用到了 minCapacity = Math.max(DEFAULT_CAPACITY, minCapacity); } ensureExplicitCapacity(minCapacity); } //ensureExplicitCapacity 判断是否扩容 //判断是否需要扩容 private void ensureExplicitCapacity(int minCapacity) { modCount++; // overflow-conscious code if (minCapacity - elementData.length \u0026gt; 0) //调用grow方法进行扩容,调用此方法代表已经开始扩容了 grow(minCapacity); } /* if语句表示,当minCapacity(数组实际*需要*容量的大小)大于实际容量则进行扩容 添加第1个元素的时候,会进入grow方法,直到添加第10个元素 都不会再进入grow()方法 当添加第11个元素时,minCapacity(11)比elementData.length(10)大,进入扩容 */ // grow()方法 /** * 要分配的最大数组大小 */ private static final int MAX_ARRAY_SIZE = Integer.MAX_VALUE - 8; /** * ArrayList扩容的核心方法。 */ private void grow(int minCapacity) { // oldCapacity为旧容量,newCapacity为新容量 int oldCapacity = elementData.length; //将oldCapacity 右移一位,其效果相当于oldCapacity /2, //我们知道位运算的速度远远快于整除运算,整句运算式的结果就是将新容量更新为旧容量的1.5倍, int newCapacity = oldCapacity + (oldCapacity \u0026gt;\u0026gt; 1); //然后检查新容量是否大于最小需要容量,若还是小于最小需要容量,那么就把最小需要容量当作数组的新容量[1.5倍扩容后还小于,说明一次添加的大于1.5倍扩容后的大小] if (newCapacity - minCapacity \u0026lt; 0) newCapacity = minCapacity; // 如果新容量大于 MAX_ARRAY_SIZE,进入(执行) `hugeCapacity()` 方法来比较 minCapacity 和 MAX_ARRAY_SIZE, //如果minCapacity大于最大容量,则新容量则为`Integer.MAX_VALUE`,否则,新容量大小则为 MAX_ARRAY_SIZE 即为 `Integer.MAX_VALUE - 8`。 if (newCapacity - MAX_ARRAY_SIZE \u0026gt; 0) newCapacity = hugeCapacity(minCapacity); // minCapacity is usually close to size, so this is a win: elementData = Arrays.copyOf(elementData, newCapacity); } /* 进入真正的扩容 int newCapacity = oldCapacity + (oldCapacity \u0026gt;\u0026gt; 1),所以 ArrayList 每次扩容之后容量都会变为原来的 1.5 倍左右(oldCapacity 为偶数就是 1.5 倍,否则是 1.5 倍左右)! 奇偶不同,比如 :10+10/2 = 15, 33+33/2=49。如果是奇数的话会丢掉小数;右移运算会比普通运算符快很多 */ 扩展\njava 中的 length属性是针对数组说的,比如说你声明了一个数组,想知道这个数组的长度则用到了 length 这个属性. java 中的 length() 方法是针对字符串说的,如果想看这个字符串的长度则用到 length() 这个方法. java 中的 size() 方法是针对泛型集合说的,如果想看这个泛型有多少个元素,就调用此方法来查看! hugeCapacity 当新容量超过MAX_ARRAY_SIZE时,if (newCapacity - MAX_ARRAY_SIZE \u0026gt; 0) 进入该方法\nprivate static int hugeCapacity(int minCapacity) { if (minCapacity \u0026lt; 0) // overflow throw new OutOfMemoryError(); //对minCapacity和MAX_ARRAY_SIZE进行比较 //若minCapacity大,将Integer.MAX_VALUE作为新数组的大小 //若MAX_ARRAY_SIZE大,将MAX_ARRAY_SIZE作为新数组的大小 //MAX_ARRAY_SIZE = Integer.MAX_VALUE - 8; return (minCapacity \u0026gt; MAX_ARRAY_SIZE) ? Integer.MAX_VALUE : MAX_ARRAY_SIZE; } System.arraycopy() 和 Arrays.copyOf()\n//System.arraycopy() 是一个native方法 // 我们发现 arraycopy 是一个 native 方法,接下来我们解释一下各个参数的具体意义 /** * 复制数组 * @param src 源数组 * @param srcPos 源数组中的起始位置 * @param dest 目标数组 * @param destPos 目标数组中的起始位置 * @param length 要复制的数组元素的数量 */ public static native void arraycopy(Object src, int srcPos, Object dest, int destPos, int length); 例子:\npublic class ArraycopyTest { public static void main(String[] args) { // TODO Auto-generated method stub int[] a = new int[10]; a[0] = 0; a[1] = 1; a[2] = 2; a[3] = 3; System.arraycopy(a, 2, a, 3, 3); a[2]=99; for (int i = 0; i \u0026lt; a.length; i++) { System.out.print(a[i] + \u0026#34; \u0026#34;); } } } //结果 0 1 99 2 3 0 0 0 0 0 Arrays.copyOf() 方法\npublic static int[] copyOf(int[] original, int newLength) { // 申请一个新的数组 int[] copy = new int[newLength]; // 调用System.arraycopy,将源数组中的数据进行拷贝,并返回新的数组 System.arraycopy(original, 0, copy, 0, Math.min(original.length, newLength)); return copy; } //场景 /** 以正确的顺序返回一个包含此列表中所有元素的数组(从第一个到最后一个元素); 返回的数组的运行时类型是指定数组的运行时类型。 */ public Object[] toArray() { //elementData:要复制的数组;size:要复制的长度 return Arrays.copyOf(elementData, size); } Arrays.copypf() : 用来扩容,或者缩短\npublic class ArrayscopyOfTest { public static void main(String[] args) { int[] a = new int[3]; a[0] = 0; a[1] = 1; a[2] = 2; int[] b = Arrays.copyOf(a, 10); System.out.println(\u0026#34;b.length\u0026#34;+b.length); } } //结果: 10 联系及区别\n看两者源代码可以发现 copyOf()内部实际调用了 System.arraycopy() 方法 arraycopy 更能实现自定义 ensureCapacity 方法 最好在向 ArrayList 添加大量元素之前用 ensureCapacity 方法,以减少增量重新分配的次数 向 ArrayList 添加大量元素之前使用ensureCapacity 方法可以提升性能。不过,这个性能差距几乎可以忽略不计。而且,实际项目根本也不可能往 ArrayList 里面添加这么多元素 2. 核心源码解读 # package java.util; import java.util.function.Consumer; import java.util.function.Predicate; import java.util.function.UnaryOperator; public class ArrayList\u0026lt;E\u0026gt; extends AbstractList\u0026lt;E\u0026gt; implements List\u0026lt;E\u0026gt;, RandomAccess, Cloneable, java.io.Serializable { private static final long serialVersionUID = 8683452581122892189L; /** * 默认初始容量大小 */ private static final int DEFAULT_CAPACITY = 10; /** * 空数组(用于空实例)。 */ private static final Object[] EMPTY_ELEMENTDATA = {}; //用于默认大小空实例的共享空数组实例。 //我们把它从EMPTY_ELEMENTDATA数组中区分出来,以知道在添加第一个元素时容量需要增加多少。 private static final Object[] DEFAULTCAPACITY_EMPTY_ELEMENTDATA = {}; /** * 保存ArrayList数据的数组 */ transient Object[] elementData; // non-private to simplify nested class access /** * ArrayList 所包含的元素个数 */ private int size; /** * 带初始容量参数的构造函数(用户可以在创建ArrayList对象时自己指定集合的初始大小) */ public ArrayList(int initialCapacity) { if (initialCapacity \u0026gt; 0) { //如果传入的参数大于0,创建initialCapacity大小的数组 this.elementData = new Object[initialCapacity]; } else if (initialCapacity == 0) { //如果传入的参数等于0,创建空数组 this.elementData = EMPTY_ELEMENTDATA; } else { //其他情况,抛出异常 throw new IllegalArgumentException(\u0026#34;Illegal Capacity: \u0026#34;+ initialCapacity); } } /** *默认无参构造函数 *DEFAULTCAPACITY_EMPTY_ELEMENTDATA 为0.初始化为10,也就是说初始其实是空数组 当添加第一个元素的时候数组容量才变成10 */ public ArrayList() { this.elementData = DEFAULTCAPACITY_EMPTY_ELEMENTDATA; } /** * 构造一个包含指定集合的元素的列表,按照它们由集合的迭代器返回的顺序。 */ public ArrayList(Collection\u0026lt;? extends E\u0026gt; c) { //将指定集合转换为数组 elementData = c.toArray(); //如果elementData数组的长度不为0 if ((size = elementData.length) != 0) { // 如果elementData不是Object类型数据(c.toArray可能返回的不是Object类型的数组所以加上下面的语句用于判断) if (elementData.getClass() != Object[].class) //将原来不是Object类型的elementData数组的内容,赋值给新的Object类型的elementData数组 elementData = Arrays.copyOf(elementData, size, Object[].class); } else { // 其他情况,用空数组代替 this.elementData = EMPTY_ELEMENTDATA; } } /** * 修改这个ArrayList实例的容量是列表的当前大小。 应用程序可以使用此操作来最小化ArrayList实例的存储。 */ public void trimToSize() { modCount++; if (size \u0026lt; elementData.length) { elementData = (size == 0) ? EMPTY_ELEMENTDATA : Arrays.copyOf(elementData, size); } } //下面是ArrayList的扩容机制 //ArrayList的扩容机制提高了性能,如果每次只扩充一个, //那么频繁的插入会导致频繁的拷贝,降低性能,而ArrayList的扩容机制避免了这种情况。 /** * 如有必要,增加此ArrayList实例的容量,以确保它至少能容纳元素的数量 * @param minCapacity 所需的最小容量 */ public void ensureCapacity(int minCapacity) { //如果是true,minExpand的值为0,如果是false,minExpand的值为10 int minExpand = (elementData != DEFAULTCAPACITY_EMPTY_ELEMENTDATA) // any size if not default element table ? 0 // larger than default for default empty table. It\u0026#39;s already // supposed to be at default size. : DEFAULT_CAPACITY; //如果最小容量大于已有的最大容量 if (minCapacity \u0026gt; minExpand) { ensureExplicitCapacity(minCapacity); } } //1.得到最小扩容量 //2.通过最小容量扩容 private void ensureCapacityInternal(int minCapacity) { if (elementData == DEFAULTCAPACITY_EMPTY_ELEMENTDATA) { // 获取“默认的容量”和“传入参数”两者之间的最大值 minCapacity = Math.max(DEFAULT_CAPACITY, minCapacity); } ensureExplicitCapacity(minCapacity); } //判断是否需要扩容 private void ensureExplicitCapacity(int minCapacity) { modCount++; // overflow-conscious code if (minCapacity - elementData.length \u0026gt; 0) //调用grow方法进行扩容,调用此方法代表已经开始扩容了 grow(minCapacity); } /** * 要分配的最大数组大小 */ private static final int MAX_ARRAY_SIZE = Integer.MAX_VALUE - 8; /** * ArrayList扩容的核心方法。 */ private void grow(int minCapacity) { // oldCapacity为旧容量,newCapacity为新容量 int oldCapacity = elementData.length; //将oldCapacity 右移一位,其效果相当于oldCapacity /2, //我们知道位运算的速度远远快于整除运算,整句运算式的结果就是将新容量更新为旧容量的1.5倍, int newCapacity = oldCapacity + (oldCapacity \u0026gt;\u0026gt; 1); //然后检查新容量是否大于最小需要容量,若还是小于最小需要容量,那么就把最小需要容量当作数组的新容量, if (newCapacity - minCapacity \u0026lt; 0) newCapacity = minCapacity; //再检查新容量是否超出了ArrayList所定义的最大容量, //若超出了,则调用hugeCapacity()来比较minCapacity和 MAX_ARRAY_SIZE, //如果minCapacity大于MAX_ARRAY_SIZE,则新容量则为Interger.MAX_VALUE,否则,新容量大小则为 MAX_ARRAY_SIZE。 if (newCapacity - MAX_ARRAY_SIZE \u0026gt; 0) newCapacity = hugeCapacity(minCapacity); // minCapacity is usually close to size, so this is a win: elementData = Arrays.copyOf(elementData, newCapacity); } //比较minCapacity和 MAX_ARRAY_SIZE private static int hugeCapacity(int minCapacity) { if (minCapacity \u0026lt; 0) // overflow throw new OutOfMemoryError(); return (minCapacity \u0026gt; MAX_ARRAY_SIZE) ? Integer.MAX_VALUE : MAX_ARRAY_SIZE; } /** *返回此列表中的元素数。 */ public int size() { return size; } /** * 如果此列表不包含元素,则返回 true 。 */ public boolean isEmpty() { //注意=和==的区别 return size == 0; } /** * 如果此列表包含指定的元素,则返回true 。 */ public boolean contains(Object o) { //indexOf()方法:返回此列表中指定元素的首次出现的索引,如果此列表不包含此元素,则为-1 return indexOf(o) \u0026gt;= 0; } /** *返回此列表中指定元素的首次出现的索引,如果此列表不包含此元素,则为-1 */ public int indexOf(Object o) { if (o == null) { for (int i = 0; i \u0026lt; size; i++) if (elementData[i]==null) return i; } else { for (int i = 0; i \u0026lt; size; i++) //equals()方法比较 if (o.equals(elementData[i])) return i; } return -1; } /** * 返回此列表中指定元素的最后一次出现的索引,如果此列表不包含元素,则返回-1。. */ public int lastIndexOf(Object o) { if (o == null) { for (int i = size-1; i \u0026gt;= 0; i--) if (elementData[i]==null) return i; } else { for (int i = size-1; i \u0026gt;= 0; i--) if (o.equals(elementData[i])) return i; } return -1; } /** * 返回此ArrayList实例的浅拷贝。 (元素本身不被复制。) */ public Object clone() { try { ArrayList\u0026lt;?\u0026gt; v = (ArrayList\u0026lt;?\u0026gt;) super.clone(); //Arrays.copyOf功能是实现数组的复制,返回复制后的数组。参数是被复制的数组和复制的长度 v.elementData = Arrays.copyOf(elementData, size); v.modCount = 0; return v; } catch (CloneNotSupportedException e) { // 这不应该发生,因为我们是可以克隆的 throw new InternalError(e); } } /** *以正确的顺序(从第一个到最后一个元素)返回一个包含此列表中所有元素的数组。 *返回的数组将是“安全的”,因为该列表不保留对它的引用。 (换句话说,这个方法必须分配一个新的数组)。 *因此,调用者可以自由地修改返回的数组。 此方法充当基于阵列和基于集合的API之间的桥梁。 */ public Object[] toArray() { return Arrays.copyOf(elementData, size); } /** * 以正确的顺序返回一个包含此列表中所有元素的数组(从第一个到最后一个元素); *返回的数组的运行时类型是指定数组的运行时类型。 如果列表适合指定的数组,则返回其中。 *否则,将为指定数组的运行时类型和此列表的大小分配一个新数组。 *如果列表适用于指定的数组,其余空间(即数组的列表数量多于此元素),则紧跟在集合结束后的数组中的元素设置为null 。 *(这仅在调用者知道列表不包含任何空元素的情况下才能确定列表的长度。) */ @SuppressWarnings(\u0026#34;unchecked\u0026#34;) public \u0026lt;T\u0026gt; T[] toArray(T[] a) { if (a.length \u0026lt; size) // 新建一个运行时类型的数组,但是ArrayList数组的内容 return (T[]) Arrays.copyOf(elementData, size, a.getClass()); //调用System提供的arraycopy()方法实现数组之间的复制 System.arraycopy(elementData, 0, a, 0, size); if (a.length \u0026gt; size) a[size] = null; return a; } // Positional Access Operations @SuppressWarnings(\u0026#34;unchecked\u0026#34;) E elementData(int index) { return (E) elementData[index]; } /** * 返回此列表中指定位置的元素。 */ public E get(int index) { rangeCheck(index); return elementData(index); } /** * 用指定的元素替换此列表中指定位置的元素。 */ public E set(int index, E element) { //对index进行界限检查 rangeCheck(index); E oldValue = elementData(index); elementData[index] = element; //返回原来在这个位置的元素 return oldValue; } /** * 将指定的元素追加到此列表的末尾。 */ public boolean add(E e) { ensureCapacityInternal(size + 1); // Increments modCount!! //这里看到ArrayList添加元素的实质就相当于为数组赋值 elementData[size++] = e; return true; } /** * 在此列表中的指定位置插入指定的元素。 *先调用 rangeCheckForAdd 对index进行界限检查;然后调用 ensureCapacityInternal 方法保证capacity足够大; *再将从index开始之后的所有成员后移一个位置;将element插入index位置;最后size加1。 */ public void add(int index, E element) { rangeCheckForAdd(index); ensureCapacityInternal(size + 1); // Increments modCount!! //arraycopy()这个实现数组之间复制的方法一定要看一下,下面就用到了arraycopy()方法实现数组自己复制自己 System.arraycopy(elementData, index, elementData, index + 1, size - index); elementData[index] = element; size++; } /** * 删除该列表中指定位置的元素。 将任何后续元素移动到左侧(从其索引中减去一个元素)。 */ public E remove(int index) { rangeCheck(index); modCount++; E oldValue = elementData(index); int numMoved = size - index - 1; if (numMoved \u0026gt; 0) System.arraycopy(elementData, index+1, elementData, index, numMoved); elementData[--size] = null; // clear to let GC do its work //从列表中删除的元素 return oldValue; } /** * 从列表中删除指定元素的第一个出现(如果存在)。 如果列表不包含该元素,则它不会更改。 *返回true,如果此列表包含指定的元素 */ public boolean remove(Object o) { if (o == null) { for (int index = 0; index \u0026lt; size; index++) if (elementData[index] == null) { fastRemove(index); return true; } } else { for (int index = 0; index \u0026lt; size; index++) if (o.equals(elementData[index])) { fastRemove(index); return true; } } return false; } /* * Private remove method that skips bounds checking and does not * return the value removed. */ private void fastRemove(int index) { modCount++; int numMoved = size - index - 1; if (numMoved \u0026gt; 0) System.arraycopy(elementData, index+1, elementData, index, numMoved); elementData[--size] = null; // clear to let GC do its work } /** * 从列表中删除所有元素。 */ public void clear() { modCount++; // 把数组中所有的元素的值设为null for (int i = 0; i \u0026lt; size; i++) elementData[i] = null; size = 0; } /** * 按指定集合的Iterator返回的顺序将指定集合中的所有元素追加到此列表的末尾。 */ public boolean addAll(Collection\u0026lt;? extends E\u0026gt; c) { Object[] a = c.toArray(); int numNew = a.length; ensureCapacityInternal(size + numNew); // Increments modCount System.arraycopy(a, 0, elementData, size, numNew); size += numNew; return numNew != 0; } /** * 将指定集合中的所有元素插入到此列表中,从指定的位置开始。 */ public boolean addAll(int index, Collection\u0026lt;? extends E\u0026gt; c) { rangeCheckForAdd(index); Object[] a = c.toArray(); int numNew = a.length; ensureCapacityInternal(size + numNew); // Increments modCount int numMoved = size - index; if (numMoved \u0026gt; 0) System.arraycopy(elementData, index, elementData, index + numNew, numMoved); System.arraycopy(a, 0, elementData, index, numNew); size += numNew; return numNew != 0; } /** * 从此列表中删除所有索引为fromIndex (含)和toIndex之间的元素。 *将任何后续元素移动到左侧(减少其索引)。 */ protected void removeRange(int fromIndex, int toIndex) { modCount++; int numMoved = size - toIndex; System.arraycopy(elementData, toIndex, elementData, fromIndex, numMoved); // clear to let GC do its work int newSize = size - (toIndex-fromIndex); for (int i = newSize; i \u0026lt; size; i++) { elementData[i] = null; } size = newSize; } /** * 检查给定的索引是否在范围内。 */ private void rangeCheck(int index) { if (index \u0026gt;= size) throw new IndexOutOfBoundsException(outOfBoundsMsg(index)); } /** * add和addAll使用的rangeCheck的一个版本 */ private void rangeCheckForAdd(int index) { if (index \u0026gt; size || index \u0026lt; 0) throw new IndexOutOfBoundsException(outOfBoundsMsg(index)); } /** * 返回IndexOutOfBoundsException细节信息 */ private String outOfBoundsMsg(int index) { return \u0026#34;Index: \u0026#34;+index+\u0026#34;, Size: \u0026#34;+size; } /** * 从此列表中删除指定集合中包含的所有元素。 */ public boolean removeAll(Collection\u0026lt;?\u0026gt; c) { Objects.requireNonNull(c); //如果此列表被修改则返回true return batchRemove(c, false); } /** * 仅保留此列表中包含在指定集合中的元素。 *换句话说,从此列表中删除其中不包含在指定集合中的所有元素。 */ public boolean retainAll(Collection\u0026lt;?\u0026gt; c) { Objects.requireNonNull(c); return batchRemove(c, true); } /** * 从列表中的指定位置开始,返回列表中的元素(按正确顺序)的列表迭代器。 *指定的索引表示初始调用将返回的第一个元素为next 。 初始调用previous将返回指定索引减1的元素。 *返回的列表迭代器是fail-fast 。 */ public ListIterator\u0026lt;E\u0026gt; listIterator(int index) { if (index \u0026lt; 0 || index \u0026gt; size) throw new IndexOutOfBoundsException(\u0026#34;Index: \u0026#34;+index); return new ListItr(index); } /** *返回列表中的列表迭代器(按适当的顺序)。 *返回的列表迭代器是fail-fast 。 */ public ListIterator\u0026lt;E\u0026gt; listIterator() { return new ListItr(0); } /** *以正确的顺序返回该列表中的元素的迭代器。 *返回的迭代器是fail-fast 。 */ public Iterator\u0026lt;E\u0026gt; iterator() { return new Itr(); } "},{"id":341,"href":"/zh/docs/technology/Review/java_guide/java/Collection/ly0103lycollections-precautions-for-use/","title":"集合使用注意事项","section":"集合","content":" 转载自https://github.com/Snailclimb/JavaGuide (添加小部分笔记)感谢作者!\n集合判空 # //阿里巴巴开发手册\n判断所有集合内部的元素是否为空,使用 isEmpty() 方法,而不是 size()==0 的方式。\nisEmpty()可读性更好,且绝大部分情况下时间复杂度为O(1)\n有例外:ConcurrentHashMap的size()和isEmpty() 时间复杂度均不是O(1)\npublic int size() { long n = sumCount(); return ((n \u0026lt; 0L) ? 0 : (n \u0026gt; (long)Integer.MAX_VALUE) ? Integer.MAX_VALUE : (int)n); } final long sumCount() { CounterCell[] as = counterCells; CounterCell a; long sum = baseCount; if (as != null) { for (int i = 0; i \u0026lt; as.length; ++i) { if ((a = as[i]) != null) sum += a.value; } } return sum; } public boolean isEmpty() { return sumCount() \u0026lt;= 0L; // ignore transient negative values } 集合转Map # //阿里巴巴开发手册\n在使用 java.util.stream.Collectors 类的 toMap() 方法转为 Map 集合时,一定要注意当 value 为 null 时会抛 NPE 异常。\nclass Person { private String name; private String phoneNumber; // getters and setters } List\u0026lt;Person\u0026gt; bookList = new ArrayList\u0026lt;\u0026gt;(); bookList.add(new Person(\u0026#34;jack\u0026#34;,\u0026#34;18163138123\u0026#34;)); bookList.add(new Person(\u0026#34;martin\u0026#34;,null)); // 空指针异常 bookList.stream().collect(Collectors.toMap(Person::getName, Person::getPhoneNumber)); java.util.stream.Collections类的toMap() ,里面使用到了Map接口的merge()方法, 调用了Objects.requireNonNull()方法判断value是否为空\n集合遍历 # //阿里巴巴开发手册\n不要在 foreach 循环里进行元素的 remove/add 操作。remove 元素请使用 Iterator 方式,如果并发操作,需要对 Iterator 对象加锁。\nforeach语法底层依赖于Iterator (foreach是语法糖),不过remove/add 则是直接调用集合的方法,而不是Iterator的; 所以此时Iterator莫名发现自己元素被remove/add,就会抛出一个ConcurrentModificationException来提示用户发生了并发修改异常,即单线程状态下产生的fail-fast机制\njava8开始,可以使用Collection#**removeIf()**方法删除满足特定条件的元素,例子\nList\u0026lt;Integer\u0026gt; list = new ArrayList\u0026lt;\u0026gt;(); for (int i = 1; i \u0026lt;= 10; ++i) { list.add(i); } list.removeIf(filter -\u0026gt; filter % 2 == 0); /* 删除list中的所有偶数 */ System.out.println(list); /* [1, 3, 5, 7, 9] */ 其他的遍历数组的方法(注意是遍历,不是增加/删除)\n使用普通for循环\n使用fail-safe集合类,java.util包下面的所有集合类都是fail-fast,而java.util.concurrent包下面的所有类是fail-safe\n//ConcurrentHashMap源码 package java.util.concurrent; public class ConcurrentHashMap\u0026lt;K,V\u0026gt; extends AbstractMap\u0026lt;K,V\u0026gt; implements ConcurrentMap\u0026lt;K,V\u0026gt;, Serializable {} //List类源码 package java.util; public class HashMap\u0026lt;K,V\u0026gt; extends AbstractMap\u0026lt;K,V\u0026gt; implements Map\u0026lt;K,V\u0026gt;, Cloneable, Serializable { } 集合去重 # //阿里巴巴开发手册\n可以利用 Set 元素唯一的特性,可以快速对一个集合进行去重操作,避免使用 List 的 contains() 进行遍历去重或者判断包含操作。\n// Set 去重代码示例 public static \u0026lt;T\u0026gt; Set\u0026lt;T\u0026gt; removeDuplicateBySet(List\u0026lt;T\u0026gt; data) { if (CollectionUtils.isEmpty(data)) { return new HashSet\u0026lt;\u0026gt;(); } return new HashSet\u0026lt;\u0026gt;(data); } // List 去重代码示例 public static \u0026lt;T\u0026gt; List\u0026lt;T\u0026gt; removeDuplicateByList(List\u0026lt;T\u0026gt; data) { if (CollectionUtils.isEmpty(data)) { return new ArrayList\u0026lt;\u0026gt;(); } List\u0026lt;T\u0026gt; result = new ArrayList\u0026lt;\u0026gt;(data.size()); for (T current : data) { if (!result.contains(current)) { result.add(current); } } return result; } Set时间复杂度为 1 * n ,而List时间复杂度为 n * n\n//Set的Contains,底层依赖于HashMap,时间复杂度为 1 private transient HashMap\u0026lt;E,Object\u0026gt; map; public boolean contains(Object o) { return map.containsKey(o); } //ArrayList的Contains,底层则是遍历,时间复杂度为O(n) public boolean contains(Object o) { return indexOf(o) \u0026gt;= 0; } public int indexOf(Object o) { if (o == null) { for (int i = 0; i \u0026lt; size; i++) if (elementData[i]==null) return i; } else { for (int i = 0; i \u0026lt; size; i++) if (o.equals(elementData[i])) return i; } return -1; } 集合转数组 # //阿里巴巴开发手册\n使用集合转数组的方法,必须使用集合的 toArray(T[] array),传入的是类型完全一致、长度为 0 的空数组。\n例子:\nString [] s= new String[]{ \u0026#34;dog\u0026#34;, \u0026#34;lazy\u0026#34;, \u0026#34;a\u0026#34;, \u0026#34;over\u0026#34;, \u0026#34;jumps\u0026#34;, \u0026#34;fox\u0026#34;, \u0026#34;brown\u0026#34;, \u0026#34;quick\u0026#34;, \u0026#34;A\u0026#34; }; List\u0026lt;String\u0026gt; list = Arrays.asList(s); Collections.reverse(list); //没有指定类型的话会报错 s=list.toArray(new String[0]); 对于 JVM 优化,new String[0]作为Collection.toArray()方法的参数现在使用更好,new String[0]就是起一个模板的作用,指定了返回数组的类型,0 是为了节省空间,因为它只是为了说明返回的类型\n数组转集合 # //阿里巴巴开发手册\n使用工具类 Arrays.asList() 把数组转换成集合时,不能使用其修改集合相关的方法, 它的 add/remove/clear 方法会抛出 UnsupportedOperationException 异常。\n例子及源码:\nString[] myArray = {\u0026#34;Apple\u0026#34;, \u0026#34;Banana\u0026#34;, \u0026#34;Orange\u0026#34;}; List\u0026lt;String\u0026gt; myList = Arrays.asList(myArray); //上面两个语句等价于下面一条语句 List\u0026lt;String\u0026gt; myList = Arrays.asList(\u0026#34;Apple\u0026#34;,\u0026#34;Banana\u0026#34;, \u0026#34;Orange\u0026#34;); //JDK源码说明[返回由指定数组支持的固定大小的列表] /** *返回由指定数组支持的固定大小的列表。此方法作为基于数组和基于集合的API之间的桥梁, * 与 Collection.toArray()结合使用。返回的List是可序列化并实现RandomAccess接口。 */ public static \u0026lt;T\u0026gt; List\u0026lt;T\u0026gt; asList(T... a) { return new ArrayList\u0026lt;\u0026gt;(a); } 注意事项:\n1、Arrays.asList()是泛型方法,传递的数组必须是对象数组,而不是基本类型。 如果把原生数据类型数组传入,则传入的不是数组的元素,而是数组对象本身,可以使用包装类数组解决这个问题\nint[] myArray = {1, 2, 3}; List myList = Arrays.asList(myArray); System.out.println(myList.size());//1 System.out.println(myList.get(0));//数组地址值 System.out.println(myList.get(1));//报错:ArrayIndexOutOfBoundsException int[] array = (int[]) myList.get(0); System.out.println(array[0]);//1 2、使用集合的修改方法add(),remove(),clear()会抛出异常UnsupportedOperationException java.util.Arrays$ArrayList (Arrays里面有一个ArrayList类,该类继承了AbstractList)\n源码:\npublic E remove(int index) { throw new UnsupportedOperationException(); } public boolean add(E e) { add(size(), e); return true; } public void add(int index, E element) { throw new UnsupportedOperationException(); } public void clear() { removeRange(0, size()); } protected void removeRange(int fromIndex, int toIndex) { ListIterator\u0026lt;E\u0026gt; it = listIterator(fromIndex); for (int i=0, n=toIndex-fromIndex; i\u0026lt;n; i++) { it.next(); it.remove(); } } 如何转换成正常的ArraysList呢\n手动实现工具类\n//使用泛型 //JDK1.5+ static \u0026lt;T\u0026gt; List\u0026lt;T\u0026gt; arrayToList(final T[] array) { final List\u0026lt;T\u0026gt; l = new ArrayList\u0026lt;T\u0026gt;(array.length); for (final T s : array) { l.add(s); } return l; } Integer [] myArray = { 1, 2, 3 }; System.out.println(arrayToList(myArray).getClass());//class java.util.ArrayList 便捷的方法\n//再转一次 List list = new ArrayList\u0026lt;\u0026gt;(Arrays.asList(\u0026#34;a\u0026#34;, \u0026#34;b\u0026#34;, \u0026#34;c\u0026#34;)) 使用Java8的Stream(推荐),包括基本类型\nInteger [] myArray = { 1, 2, 3 }; List myList = Arrays.stream(myArray).collect(Collectors.toList()); //基本类型也可以实现转换(依赖boxed的装箱操作) int [] myArray2 = { 1, 2, 3 }; List myList = Arrays.stream(myArray2).boxed().collect(Collectors.toList()); 使用Apache Commons Colletions\nList\u0026lt;String\u0026gt; list = new ArrayList\u0026lt;String\u0026gt;(); CollectionUtils.addAll(list, str); 使用Java9的List.of()\nInteger[] array = {1, 2, 3}; List\u0026lt;Integer\u0026gt; list = List.of(array); 大部分转自https://github.com/Snailclimb/JavaGuide\n"},{"id":342,"href":"/zh/docs/technology/Review/java_guide/java/Collection/ly0102lycollection_2/","title":"集合_2","section":"集合","content":" 转载自https://github.com/Snailclimb/JavaGuide (添加小部分笔记)感谢作者!\nMap # HashMap和Hashtable的区别\nHashMap是非线程安全的,Hashtable是线程安全的,因为Hashtable内部方法都经过synchronized修饰(不过要保证线程安全一般用ConcurrentHashMap)\n由于加了synchronized修饰,HashTable效率没有HashMap高\nHashMap可以存储null的key和value,但null作为键只能有一个**;HashTable不允许有null键和null值**\n初始容量及每次扩容\nHashtable默认初始大小11,之后扩容为2n+1;HashMap初始大小16,之后扩容变为原来的2倍 如果指定初始大小,HashTable直接使用初始大小\n而HashMap 会将其扩充为 2 的幂次方大小(HashMap 中的**tableSizeFor()**方法保证,下面给出了源代码)。也就是说 HashMap 总是使用 2 的幂作为哈希表的大小,后面会介绍到为什么是 2 的幂次方 底层数据结构\nJDK1.8之后HashMap解决哈希冲突时,当链表大于阈值(默认8)时,将链表转为红黑树(转换前判断,如果当前数组长度小于64,则先进行数组扩容,而不转成红黑树),以减少搜索时间。 Hashtable没有上面的机制 /** HashMap 中带有初始容量的构造函数: */ public HashMap(int initialCapacity, float loadFactor) { if (initialCapacity \u0026lt; 0) throw new IllegalArgumentException(\u0026#34;Illegal initial capacity: \u0026#34; + initialCapacity); if (initialCapacity \u0026gt; MAXIMUM_CAPACITY) initialCapacity = MAXIMUM_CAPACITY; if (loadFactor \u0026lt;= 0 || Float.isNaN(loadFactor)) throw new IllegalArgumentException(\u0026#34;Illegal load factor: \u0026#34; + loadFactor); this.loadFactor = loadFactor; this.threshold = tableSizeFor(initialCapacity); } public HashMap(int initialCapacity) { this(initialCapacity, DEFAULT_LOAD_FACTOR); } /*下面这个方法保证了 HashMap 总是使用 2 的幂作为哈希表的大小。*/ /** * Returns a power of two size for the given target capacity. */ static final int tableSizeFor(int cap) { int n = cap - 1; n |= n \u0026gt;\u0026gt;\u0026gt; 1; n |= n \u0026gt;\u0026gt;\u0026gt; 2; n |= n \u0026gt;\u0026gt;\u0026gt; 4; n |= n \u0026gt;\u0026gt;\u0026gt; 8; n |= n \u0026gt;\u0026gt;\u0026gt; 16; return (n \u0026lt; 0) ? 1 : (n \u0026gt;= MAXIMUM_CAPACITY) ? MAXIMUM_CAPACITY : n + 1; } HashMap和hashSet区别\n如果你看过 HashSet 源码的话就应该知道:HashSet 底层就是基于 HashMap 实现的。(HashSet 的源码非常非常少,因为除了 clone()、writeObject()、readObject()是 HashSet 自己不得不实现之外,其他方法都是直接调用 HashMap 中的方法\nHashSet底层就HashMap 会将其扩充为 2 的幂次方大小(HashMap 中的tableSizeFor()方法保证,下面给出了源代码)。也就是说 HashMap 总是使用 2 的幂作为哈希表的大小,后面会介绍到为什么是 2 的幂次方是HashMap实现的 HashMap:实现了Map接口;存储键值对;调用put()向map中添加元素;HashMap使用键(key)计算hashcode HashSet:实现Set接口;仅存储对象;调用add()方法向Set中添加元素;HashSet使用成员对象计算hashCode,对于不相等两个对象来说 hashcode也可能相同,所以**还要再借助equals()**方法判断对象相等性 HashMap和TreeMap navigable 英[ˈnævɪɡəbl] 通航的,可航行的\nHashMap和TreeMap都继承自AbstractMap\nTreeMap还实现了NavigableMap (对集合内元素搜索)和SortedMap(对集合内元素根据键排序,默认key升序,可指定排序的比较器)接口\n实现 NavigableMap 接口让 TreeMap 有了对集合内元素的搜索的能力。\n实现SortedMap接口让 TreeMap 有了对集合中的元素根据键排序的能力。默认是按 key 的升序排序,不过我们也可以指定排序的比较器。示例代码如下:\n/** * @author shuang.kou * @createTime 2020年06月15日 17:02:00 */ public class Person { private Integer age; public Person(Integer age) { this.age = age; } public Integer getAge() { return age; } public static void main(String[] args) { TreeMap\u0026lt;Person, String\u0026gt; treeMap = new TreeMap\u0026lt;\u0026gt;(new Comparator\u0026lt;Person\u0026gt;() { @Override public int compare(Person person1, Person person2) { int num = person1.getAge() - person2.getAge(); return Integer.compare(num, 0); } }); treeMap.put(new Person(3), \u0026#34;person1\u0026#34;); treeMap.put(new Person(18), \u0026#34;person2\u0026#34;); treeMap.put(new Person(35), \u0026#34;person3\u0026#34;); treeMap.put(new Person(16), \u0026#34;person4\u0026#34;); treeMap.entrySet().stream().forEach(personStringEntry -\u0026gt; { System.out.println(personStringEntry.getValue()); }); } } //输出 /**person1 person4 person2 person3 **/ HashSet如何检查重复\n当在HashSet加入对象时,先计算对象hashcode值判断加入位置,同时与其他加入对象的hashcode值比较,如果没有相同的,会假设对象没有重复出现;如果发现有相同的hashcode值的对象,则调用equals()方法检查hashcode相等的对象是否真的相等,如果相等则不会加入\nJDK1.8中,HashSet的add()方法调用了HashMap的put()方法,并判断是否有重复元素(返回值是否null)\n// Returns: true if this set did not already contain the specified element // 返回值:当 set 中没有包含 add 的元素时返回真 public boolean add(E e) { return map.put(e, PRESENT)==null; } //下面为HashMap的源代码 // Returns : previous value, or null if none // 返回值:如果插入位置没有元素返回null,否则返回上一个元素 final V putVal(int hash, K key, V value, boolean onlyIfAbsent, boolean evict) { ... } HashMap底层实现\nJDK1.8之前,底层是数组和链表结合在一起使用,即链表散列。通过key的hashcode 经过扰动函数处理后得到hash值,并通过 (n-1) \u0026amp; hash 判断当前元素存放的位置 (n为数组长度),如果当前位置存在元素的话,就判断该元素与要存入的元素的hash值以及key是否相同,如果相同则覆盖,不同则通过拉链法解决冲突 扰动函数指的是HashMap的hash方法,是为了防止一些实现比较差的hashCode方法,减少碰撞 JDK1.8的hash:如果key为null则返回空,否则使用 (key的hash值) 与 (hash值右移16位) 做异或操作\nstatic final int hash(Object key) { int h; // key.hashCode():返回散列值也就是hashcode // ^ :按位异或 // \u0026gt;\u0026gt;\u0026gt;:无符号右移,忽略符号位,空位都以0补齐 return (key == null) ? 0 : (h = key.hashCode()) ^ (h \u0026gt;\u0026gt;\u0026gt; 16); } JDK1.7扰动次数更多\nstatic int hash(int h) { // This function ensures that hashCodes that differ only by // constant multiples at each bit position have a bounded // number of collisions (approximately 8 at default load factor). h ^= (h \u0026gt;\u0026gt;\u0026gt; 20) ^ (h \u0026gt;\u0026gt;\u0026gt; 12); return h ^ (h \u0026gt;\u0026gt;\u0026gt; 7) ^ (h \u0026gt;\u0026gt;\u0026gt; 4); } 拉链法即链表和数组结合,也就是创建一个链表数组,数组每一格为一个链表,如果发生哈希冲突,就将冲突的值添加到链表中即可\nJDK8之后,解决冲突发生了较大变化,当链表长度大于阈值(默认是8)(如果数组小于64,则只会进行扩容;如果不是,才转成红黑树)时,将链表转换成红黑树,以减少搜索时间 二叉查找树,在某些情况下会退化成线性结构,时间复杂度为n ,而红黑树趋于log n 。TreeMap、TreeSet以及1.8之后的HashMap都用到了红黑树\n代码\n//当链表长度大于8时,执行treeifyBin(转换红黑树) // 遍历链表 for (int binCount = 0; ; ++binCount) { // 遍历到链表最后一个节点 if ((e = p.next) == null) { p.next = newNode(hash, key, value, null); // 如果链表元素个数大于等于TREEIFY_THRESHOLD(8) if (binCount \u0026gt;= TREEIFY_THRESHOLD - 1) // -1 for 1st // 红黑树转换(并不会直接转换成红黑树) treeifyBin(tab, hash); break; } if (e.hash == hash \u0026amp;\u0026amp; ((k = e.key) == key || (key != null \u0026amp;\u0026amp; key.equals(k)))) break; p = e; } //判断是否会转成红黑树 final void treeifyBin(Node\u0026lt;K,V\u0026gt;[] tab, int hash) { int n, index; Node\u0026lt;K,V\u0026gt; e; // 判断当前数组的长度是否小于 64 if (tab == null || (n = tab.length) \u0026lt; MIN_TREEIFY_CAPACITY) // 如果当前数组的长度小于 64,那么会选择先进行数组扩容 resize(); else if ((e = tab[index = (n - 1) \u0026amp; hash]) != null) { // 否则才将列表转换为红黑树 TreeNode\u0026lt;K,V\u0026gt; hd = null, tl = null; do { TreeNode\u0026lt;K,V\u0026gt; p = replacementTreeNode(e, null); if (tl == null) hd = p; else { p.prev = tl; tl.next = p; } tl = p; } while ((e = e.next) != null); if ((tab[index] = hd) != null) hd.treeify(tab); } } HashMap的长度为为什么是2的幂次方\n为了能让 HashMap 存取高效,尽量较少碰撞,也就是要尽量把数据分配均匀。我们上面也讲到了过了,Hash 值的范围值-2147483648 到 2147483647,前后加起来大概 40 亿的映射空间,只要哈希函数映射得比较均匀松散,一般应用是很难出现碰撞的。但问题是一个 40 亿长度的数组,内存是放不下的。所以这个散列值是不能直接拿来用的(也就是这个数组不能直接拿来用)。\n用之前还要先做对数组的长度取模运算,得到的余数才能用来要存放的位置也就是对应的数组下标。这个数组下标的计算方法是“ (n - 1) \u0026amp; hash”。(n 代表数组长度)。这也就解释了 HashMap 的长度为什么是 2 的幂次方。\n这个算法应该如何设计呢?\n我们首先可能会想到采用%取余的操作来实现。但是,重点来了:“取余(%)操作中如果除数是 2 的幂次则等价于与其除数减一的与(\u0026amp;)操作(也就是说 hash%length==hash\u0026amp;(length-1)的前提是 length 是 2 的 n 次方;)。” 并且 采用二进制位操作 \u0026amp;,相对于%能够提高运算效率,这就解释了 HashMap 的长度为什么是 2 的幂次方。\nHashMap多线程操作导致死循环问题 多线程下不建议使用HashMap,1.8之前并发下进行Rehash会造成元素之间形成循环链表,但是1.8之后还有其他问题(数据丢失),建议使用concurrentHashMap\nHashMap有哪几种常见的遍历方式\nhttps://mp.weixin.qq.com/s/zQBN3UvJDhRTKP6SzcZFKw\nConcurrentHashMap和Hashtable\n主要体现在,实现线程安全的方式上不同\n底层数据结构\nConcurrentHashMap:JDK1.7 底层采用分段数组+链表,JDK1.8 则是数组+链表/红黑二叉树(红黑树是1.8之后才出现的) HashTable采用 数组 (应该不是分段数组) + 链表 实现线程安全的方式\nConcurrentHashMap JDK1.7 时对整个桶数进行分割分段(Segment,分段锁),每一把锁只锁容器其中一部分数据,当访问不同数据段的数据就不会存在锁竞争 ConcurrentHashMap JDK1.8摒弃Segment概念,直接用Node数组+链表+红黑树,并发控制使用synchronized和CAS操作 而Hashtable则是同一把锁,使用synchronized保证线程安全,效率低下。问题:当一个线程访问同步方式时,其他线程也访问同步方法,则可能进入阻塞/轮询状态,即如使用put添加元素另一个线程不能使用put和get 底层数据结构图\nHashTable:数组+链表 JDK1.7 的 ConcurrentHashMap(Segment数组,HashEntry数组,链表)\nSegment是用来加锁的 JDK1.8 的ConcurrentHashMap则是Node数组+链表/红黑树,不过红黑树时,不用Node,而是用TreeNode\nTreeNode,存储红黑树节点,被TreeBin包装\n/** root 维护红黑树根节点;waiter维护当前使用这颗红黑树的线程,防止其他线程进入 **/ static final class TreeBin\u0026lt;K,V\u0026gt; extends Node\u0026lt;K,V\u0026gt; { TreeNode\u0026lt;K,V\u0026gt; root; volatile TreeNode\u0026lt;K,V\u0026gt; first; volatile Thread waiter; volatile int lockState; // values for lockState static final int WRITER = 1; // set while holding write lock static final int WAITER = 2; // set when waiting for write lock static final int READER = 4; // increment value for setting read lock ... } ConcurrentHashMap线程安全的具体实现方式/底层具体实现\nJDK1.8之前的ConcurrentHashMap\nSegment 继承了 ReentrantLock,所以 Segment 是一种可重入锁,扮演锁的角色。HashEntry 用于存储键值对数据。\nstatic class Segment\u0026lt;K,V\u0026gt; extends ReentrantLock implements Serializable { } 一个 ConcurrentHashMap 里包含一个 Segment 数组,Segment 的个数一旦初始化就不能改变。 Segment 数组的大小默认是 16,也就是说默认可以同时支持 16 个线程并发写。 Segment 的结构和 HashMap 类似,是一种数组和链表结构,一个 Segment 包含一个 HashEntry 数组,每个 HashEntry 是一个链表结构的元素,每个 Segment 守护着一个 HashEntry 数组里的元素,当对 HashEntry 数组的数据进行修改时,必须首先获得对应的 Segment 的锁。也就是说,对同一 Segment 的并发写入会被阻塞,不同 Segment 的写入是可以并发执行的。\nJDK 1.8 之后 使用Node数组+链表/红黑树,几乎重写了ConcurrentHashMap,使用Node+CAS+Synchronized保证并发安全,数据结构跟HashMap1.8类似,超过一定阈值(默认8)将链表【O(N)】转成红黑树【O(log (N) )】 JDK8中,只锁定当前链表/红黑二叉树的首节点,这样只要hash不冲突就不会产生并发,不影响其他Node的读写,提高效率\nCollections工具类(不重要) # 包括 排序/查找/替换\n排序\nvoid reverse(List list)//反转 void shuffle(List list)//随机排序 void sort(List list)//按自然排序的升序排序 void sort(List list, Comparator c)//定制排序,由Comparator控制排序逻辑 void swap(List list, int i , int j)//交换两个索引位置的元素 void rotate(List list, int distance)//旋转。当distance为正数时,将list后distance个元素整体移到前面。当distance为负数时,将 list的前distance个元素整体移到后面 查找/替换\nint binarySearch(List list, Object key)//对List进行二分查找,返回索引,注意List必须是有序的 int max(Collection coll)//根据元素的自然顺序,返回最大的元素。 类比int min(Collection coll) int max(Collection coll, Comparator c)//根据定制排序,返回最大元素,排序规则由Comparatator类控制。类比int min(Collection coll, Comparator c) void fill(List list, Object obj)//用指定的元素代替指定list中的所有元素 int frequency(Collection c, Object o)//统计元素出现次数 int indexOfSubList(List list, List target)//统计target在list中第一次出现的索引,找不到则返回-1,类比int lastIndexOfSubList(List source, list target) boolean replaceAll(List list, Object oldVal, Object newVal)//用新元素替换旧元素 同步控制,Collections提供了多个synchronizedXxx()方法,该方法可以将指定集合包装成线程同步的集合,从而解决并发问题。 其中,HashSet、TreeSet、ArrayList、LinkedList、HashMap、TreeMap都是线程不安全的\n//不推荐,因为效率极低 建议使用JUC包下的并发集合 synchronizedCollection(Collection\u0026lt;T\u0026gt; c) //返回指定 collection 支持 的同步(线程安全的)collection。 synchronizedList(List\u0026lt;T\u0026gt; list)//返回指定列表支持的同步(线程安全的)List。 synchronizedMap(Map\u0026lt;K,V\u0026gt; m) //返回由指定映射支持的同步(线程安全的)Map。 synchronizedSet(Set\u0026lt;T\u0026gt; s) //返回指定 set 支持的同步(线程安全的)set。 "},{"id":343,"href":"/zh/docs/technology/Review/java_guide/java/Collection/ly0101lycollection_1/","title":"集合_1","section":"集合","content":" 转载自https://github.com/Snailclimb/JavaGuide (添加小部分笔记)感谢作者!\n集合包括Collection和Map,Collection 存放单一元素。Map 存放键值对 # List,Set,Queue,Map区别 # List(对付顺序的好帮手): 存储的元素是有序的、可重复的。 Set(注重独一无二的性质): 存储的元素是无序的、不可重复的。 Queue(实现排队功能的叫号机): 按特定的排队规则来确定先后顺序,存储的元素是有序的、可重复的。 Map(用 key 来搜索的专家): 使用键值对(key-value)存储,类似于数学上的函数 y=f(x),\u0026ldquo;x\u0026rdquo; 代表 key,\u0026ldquo;y\u0026rdquo; 代表 value,key 是无序的、不可重复的,value 是无序的、可重复的,每个键最多映射到一个值。 各种集合框架\u0026ndash;底层数据结构 # List ArrayList、Vector \u0026mdash;-\u0026gt; Object[] 数组 LinkedList 双向链表 (jdk 1.6 之前为循环链表, 1.7 取消了循环) Set HashSet (无序,唯一),且基于HashMap LinkedHashSet 是HashSet的子类,基于LinkedHashMap (LinkedHashMap内部基于HashMap实现) TreeSet(有序,唯一) :红黑树(自平衡的排序二叉树) Queue (队列) PriorityQueue:Object[] 数组来实现二叉堆 ArrayQueue:Object[] 数组+ 双指针 Map HashMap: JDK1.8 之前 HashMap 由数组+链表组成的,数组是 HashMap 的主体,链表则是主要为了解决哈希冲突而存在的(“拉链法”解决冲突)。JDK1.8 以后在解决哈希冲突时有了较大的变化,当链表长度大于阈值(默认为 8)(将链表转换成红黑树前会判断,如果当前数组的长度小于 64,那么会选择先进行数组扩容,而不是转换为红黑树)时,将链表转化为红黑树,以减少搜索时间\nLinkedHashMap: LinkedHashMap 继承自 HashMap,所以它的底层仍然是基于拉链式散列结构 即由数组和链表或红黑树组成。另外,LinkedHashMap 在上面结构的基础上,增加了一条双向链表,使得上面的结构可以保持键值对的插入顺序。同时通过对链表进行相应的操作,实现了访问顺序相关逻辑。\n上图中,淡蓝色的箭头表示前驱引用,红色箭头表示后继引用。每当有新键值对节点插入,新节点最终会接在 tail 引用指向的节点后面(感觉这句话有问题,应该是head引用指向旧结点上)。而 tail 引用则会移动到新的节点上,这样一个双向链表就建立起来了。 作者:田小波 链接:https://www.imooc.com/article/22931\nHashtable: 数组+链表组成的,数组是 Hashtable 的主体,链表则是主要为了解决哈希冲突而存在的\nTreeMap: 红黑树(自平衡的排序二叉树)\n如何选用集合 # 当我们只需要存放元素值时,就选择实现Collection 接口的集合,需要保证元素唯一时选择实现 Set 接口的集合比如 TreeSet 或 HashSet,不需要就选择实现 List 接口的比如 ArrayList 或 LinkedList,然后再根据实现这些接口的集合的特点来选用 需要根据键值获取到元素值时就选用 Map 接口下的集合,需要排序时选择 TreeMap,不需要排序时就选择 HashMap,需要保证线程安全就选用 ConcurrentHashMap。 为什么需要集合 # 当需要保存一组类型相同的数据时,需要容器来保存,即数组,但实际中存储的类型多样, 而数组一旦声明则不可变长,同时数组数据类型也确定、数组有序可重复 集合可以存储不同类型不同数量的对象,还可以保存具有映射关系的数据 Collection 子接口 # List # ArrayList和Vector区别:ArrayList是List主要实现类,底层使用Object[]存储线程不安全;Vector是List古老实现类,底层使用Object[]存储,线程安全 (synchronized关键字)\nArrayList与LinkedList:\n都是线程不安全 ArrayList底层使用Object数组,LinkedList底层使用双向链表结构(JDK7以后非循环链表) ArrayList采用数组存储,所以插入和删除元素的时间复杂度受位置影响;LinkedList采用链表,所以在头尾插入或者删除元素不受元素位置影响,而如果需要插入或者删除中间指定位置,则时间复杂度为O(n) [主要是因为要遍历] LinkedList不支持高效的随机元素访问,而ArrayList支持(即通过元素的序号快速获取元素对象) 内存空间占用:ArrayList的空间浪费主要体现在List结尾会预留一定的容量空间(不是申请的所有容量都会用上),而LinkedList的空间花费则体现在它的每一个元素都需要消耗比ArrayList更多的空间(存放直接后继、直接前驱及数据) 实际项目中不怎么使用LinkedList,因为ArrayList性能通常会更好,LinkedList仅仅在头尾插入或者删除元素的时间复杂度近似O(1)\n双向链表与双向循环链表\n双向链表,包含两个指针,一个 prev 指向前一个节点,一个 next 指向后一个节点。 双向循环链表,首尾相连(头节点的前驱=尾结点,尾结点的后继=头节点) 补充:RandomAccess接口,这个接口只是用来标识:实现这个接口的类,具有随机访问功能,但并不是说因为实现了该接口才具有的快速随机访问机制\nCollections里面有这样一段代码\n在 binarySearch() 方法中,它要判断传入的 list 是否 RandomAccess 的实例,如果是,调用indexedBinarySearch()方法,如果不是,那么调用iteratorBinarySearch()方法\npublic static \u0026lt;T\u0026gt; int binarySearch(List\u0026lt;? extends Comparable\u0026lt;? super T\u0026gt;\u0026gt; list, T key) { if (list instanceof RandomAccess || list.size()\u0026lt;BINARYSEARCH_THRESHOLD) return Collections.indexedBinarySearch(list, key); else return Collections.iteratorBinarySearch(list, key); } ArrayList实现了RandomAccess方法,而LinkedList没有。是由于ArrayList底层是数组,支持快速随机访问,时间复杂度为O(1),而LinkedList底层是链表,不支持快速随机访问,时间复杂度为O(n)\nSet # Coparable和Comparator的区别\nComparable实际出自java.lang包,有一个compareTo(Object obj)方法用来排序 Comparator实际出自java.util包,有一个compare(Object obj1,Object obj2)方法用来排序 Collections.sort(List\u0026lt;T\u0026gt; list, Comparator\u0026lt;? super T\u0026gt; c)默认是正序,T必须实现了Comparable,且Arrays.sort()方法中的部分代码如下:\n//使用插入排序 if (length \u0026lt; INSERTIONSORT_THRESHOLD) { for (int i=low; i\u0026lt;high; i++) for (int j=i; j\u0026gt;low \u0026amp;\u0026amp; c.compare(dest[j-1], dest[j])\u0026gt;0; j--) //如果前一个数跟后面的数相比大于零,则进行交换,即大的排后面 swap(dest, j, j-1); return; } //当比较结果\u0026gt;0时,调换数组前后两个元素的值,也就是后面的一定要比前面的大,即 public int compareTo(Person o) { if (this.age \u0026gt; o.getAge()) { return 1; } if (this.age \u0026lt; o.getAge()) { return -1; } return 0; } //下面这段代码,按照年龄降序(默认是升序) Collections.sort(arrayList, new Comparator\u0026lt;Integer\u0026gt;() { @Override public int compare(Integer o1, Integer o2) { //如果结果大于0,则两个数对调 //如果返回o2.compareTo(o1),就是当o2\u0026gt;01时,两个结果对换,也就是降序 //如果返回o1.compareTo(o2),就是当o1\u0026gt;o2时,两个结果对换,也就是升序 也就是当和参数顺序一致时,是升序;反之,则是降序 return o2.compareTo(o1); } }); //上面这段代码,标识 无序性和不可重复性\n无序性,指存储的数据,在底层数据结构中,并非按照数组索引的顺序添加(而是根据数据的哈希值决定) 不可重复性:指添加的元素按照equals()判断时,返回false。需同时重写equals()方法和hashCode() 方法 比较HashSet、LinkedHashSet和TreeSet三者异同\n都是Set实现类,保证元素唯一,且非线程安全 三者底层数据结构不同,HashSet底层为哈希表(HashMap); LinkedHashSet底层为链表+哈希表 ,元素的插入和取出顺序满足FIFO。TreeSet底层为红黑树,元素有序,排序方式有自然排序和定制排序 Queue # Queue和Deque的区别 # Queue Queue为单端队列,只能从一端插入元素,另一端删除元素,实现上一般遵循先进先出(FIFO)规则【Dequeue为双端队列,在队列两端均可插入或删除元素】 Queue扩展了Collection接口,根据因容量问题而导致操作失败后的处理方式不同分两类,操作失败后抛异常或返回特殊值 Dequeue,双端队列,在队列两端均可插入或删除元素,也会根据失败后处理方式分两类 Deque还有push()和pop()等其他方法,可用于模拟栈 ArrayDeque与LinkedList区别 # ArrayDeque和LinkedList都实现了Deque接口,两者都具有队列功能 ArrayDeque基于可变长的数组和双指针来实现,而LinkedList则通过链表来实现 ArrayDeque不支持存储NULL数据,但LinkedList支持 ArrayDeque是后面(JDK1.6)引入的,而LinkedList在JDK1.2就存在 ArrayDeque 插入时可能存在扩容过程, 不过均摊后的插入操作依然为 O(1)。虽然 LinkedList 不需要扩容,但是每次插入数据时均需要申请新的堆空间,均摊性能相比更慢。 总的来说,ArrayDeque来实现队列要比LinkedList更好,此外,ArrayDeque也可以用于实现栈\n说一说PriorityQueue # PriorityQueue 是在 JDK1.5 中被引入的, 其与 Queue 的区别在于元素出队顺序是与优先级相关的,即总是优先级最高的元素先出队。\n这里列举其相关的一些要点:\nPriorityQueue 利用了二叉堆的数据结构来实现的,底层使用可变长的数组来存储数据 PriorityQueue 通过堆元素的上浮和下沉,实现了在 O(logn) 的时间复杂度内插入元素和删除堆顶元素。 PriorityQueue 是非线程安全的,且不支持存储 NULL 和 non-comparable 的对象。 PriorityQueue 默认是小顶堆,但可以接收一个 Comparator 作为构造参数,从而来自定义元素优先级的先后。 "},{"id":344,"href":"/zh/docs/technology/Review/java_guide/java/Basic/ly0011lysyntactic_sugar/","title":"语法糖","section":"基础","content":" 转载自https://github.com/Snailclimb/JavaGuide (添加小部分笔记)感谢作者!\n简介 # 语法糖(Syntactic Sugar)也称糖衣语法,指的是在计算机语言中添加的某种语法,这种语法对语言的功能并没有影响,但是更方便程序员使用,简而言之,让程序更加简洁,有更高的可读性\nJava中有哪些语法糖 # Java虚拟机并不支持这些语法糖,这些语法糖在编译阶段就会被还原成简单的基础语法结构,这个过程就是解语法糖\njavac命令可以将后缀为.java的源文件编译为后缀名为.class的可以运行于Java虚拟机的字节码。其中,com.sun.tools.javac.main.JavaCompiler的源码中,compile()中有一个步骤就是调用desugar(),这个方法就是负责解语法糖的实现的 Java中的语法糖,包括 泛型、变长参数、条件编译、自动拆装箱、内部类等 switch支持String与枚举 # switch本身原本只支持基本类型,如int、char\nint是比较数值,而char则是比较其ascii码,所以其实对于编译器来说,都是int类型(整型),比如byte。short,char(ackii 码是整型)以及int。 而对于enum类型,\n对于switch中使用String,则:\npublic class switchDemoString { public static void main(String[] args) { String str = \u0026#34;world\u0026#34;; switch (str) { case \u0026#34;hello\u0026#34;: System.out.println(\u0026#34;hello\u0026#34;); break; case \u0026#34;world\u0026#34;: System.out.println(\u0026#34;world\u0026#34;); break; default: break; } } } //反编译之后 public class switchDemoString { public switchDemoString() { } public static void main(String args[]) { String str = \u0026#34;world\u0026#34;; String s; switch((s = str).hashCode()) { default: break; case 99162322: if(s.equals(\u0026#34;hello\u0026#34;)) System.out.println(\u0026#34;hello\u0026#34;); break; case 113318802: if(s.equals(\u0026#34;world\u0026#34;)) System.out.println(\u0026#34;world\u0026#34;); break; } } } 即switch判断是通过**equals()和hashCode()**方法来实现的\nequals()检查是必要的,因为有可能发生碰撞,所以性能没有直接使用枚举进行switch或纯整数常量性能高\n泛型 # 编译器处理泛型有两种方式:Code specialization和Code sharing。C++和 C#是使用Code specialization的处理机制,而 Java 使用的是Code sharing的机制\nCode sharing 方式为每个泛型类型创建唯一的字节码表示,并且将该泛型类型的实例都映射到这个唯一的字节码表示上。将多种泛型类形实例映射到唯一的字节码表示是通过**类型擦除(type erasue)**实现的。\n对于 Java 虚拟机来说,他根本不认识Map\u0026lt;String, String\u0026gt; map 这样的语法。需要在编译阶段通过类型擦除的方式进行解语法糖 类型擦除的主要过程如下: 1.将所有的泛型参数用其最左边界(最顶级的父类型)类型替换。 2.移除所有的类型参数。 两个例子\nMap擦除\nMap\u0026lt;String, String\u0026gt; map = new HashMap\u0026lt;String, String\u0026gt;(); map.put(\u0026#34;name\u0026#34;, \u0026#34;hollis\u0026#34;); map.put(\u0026#34;wechat\u0026#34;, \u0026#34;Hollis\u0026#34;); map.put(\u0026#34;blog\u0026#34;, \u0026#34;www.hollischuang.com\u0026#34;); //解语法糖之后 Map map = new HashMap(); map.put(\u0026#34;name\u0026#34;, \u0026#34;hollis\u0026#34;); map.put(\u0026#34;wechat\u0026#34;, \u0026#34;Hollis\u0026#34;); map.put(\u0026#34;blog\u0026#34;, \u0026#34;www.hollischuang.com\u0026#34;); 其他擦除\npublic static \u0026lt;A extends Comparable\u0026lt;A\u0026gt;\u0026gt; A max(Collection\u0026lt;A\u0026gt; xs) { Iterator\u0026lt;A\u0026gt; xi = xs.iterator(); A w = xi.next(); while (xi.hasNext()) { A x = xi.next(); if (w.compareTo(x) \u0026lt; 0) w = x; } return w; } //擦除后变成 public static Comparable max(Collection xs){ Iterator xi = xs.iterator(); Comparable w = (Comparable)xi.next(); while(xi.hasNext()) { Comparable x = (Comparable)xi.next(); if(w.compareTo(x) \u0026lt; 0) w = x; } return w; } 小结\n虚拟机中并不存在泛型,泛型类没有自己独有的Class类对象,即不存在List\u0026lt;String\u0026gt;.class 或是 List\u0026lt;Integer\u0026gt;.class ,而只有List.class 虚拟机中,只有普通类和普通方法,所有泛型类的类型参数,在编译时都会被擦除 自动装箱与拆箱 # 装箱过程,通过调用包装器的valueOf方法实现的,而拆箱过程,则是通过调用包装器的xxxValue方法实现的\n自动装箱\npublic static void main(String[] args) { int i = 10; Integer n = i; } //反编译后的代码 public static void main(String args[]) { int i = 10; Integer n = Integer.valueOf(i); } 自动拆箱\npublic static void main(String[] args) { Integer i = 10; int n = i; } //反编译后的代码 public static void main(String args[]) { Integer i = Integer.valueOf(10); int n = i.intValue(); //注意,是intValue,不是initValue } 可变长参数 # variable arguments,是在Java 1.5中引入的一个特性,允许一个方法把任意数量的值作为参数,代码:\npublic static void main(String[] args) { print(\u0026#34;Holis\u0026#34;, \u0026#34;公众号:Hollis\u0026#34;, \u0026#34;博客:www.hollischuang.com\u0026#34;, \u0026#34;QQ:907607222\u0026#34;); } public static void print(String... strs) { for (int i = 0; i \u0026lt; strs.length; i++) { System.out.println(strs[i]); } } //反编译后代码 public static void main(String args[]) { print(new String[] { \u0026#34;Holis\u0026#34;, \u0026#34;\\u516C\\u4F17\\u53F7:Hollis\u0026#34;, \u0026#34;\\u535A\\u5BA2\\uFF1Awww.hollischuang.com\u0026#34;, \u0026#34;QQ\\uFF1A907607222\u0026#34; }); } public static transient void print(String strs[]) { for(int i = 0; i \u0026lt; strs.length; i++) System.out.println(strs[i]); } 如上,可变参数在被使用的时候,会创建一个数组,数组的长度,就是调用该方法的传递的实参的个数,然后再把参数值全部放到这个数组当中,最后把这个数组作为参数传递到被调用的方法中\n枚举 # 关键字enum可以将一组具名的值的有限集合创建为一种新的类型,而这些具名的值可以作为常规的程序组件使用,这是一种非常有用的功能\n写一个enum类进行测试\npublic enum T { SPRING,SUMMER; } //反编译之后 // Decompiled by Jad v1.5.8g. Copyright 2001 Pavel Kouznetsov. // Jad home page: http://www.kpdus.com/jad.html // Decompiler options: packimports(3) // Source File Name: T.java package com.ly.review.base; public final class T extends Enum { /** 下面这个和博客不太一样,博客里面是这样的 // ENUM$VALUES是博客编译后的数组名 public static T[] values() { T at[]; int i; T at1[]; System.arraycopy(at = ENUM$VALUES, 0, at1 = new T[i = at.length], 0, i); return at1; } */ public static T[] values() { return (T[])$VALUES.clone(); } public static T valueOf(String s) { return (T)Enum.valueOf(com/ly/review/base/T, s); } private T(String s, int i) { super(s, i); } public static final T Spring; public static final T SUMMER; private static final T $VALUES[]; static { Spring = new T(\u0026#34;Spring\u0026#34;, 0); SUMMER = new T(\u0026#34;SUMMER\u0026#34;, 1); $VALUES = (new T[] { Spring, SUMMER }); } } 重要代码:\npublic final class T extends Enum 说明该类不可继承\npublic static final T Spring; public static final T SUMMER; 说明枚举类型不可修改\n内部类 # 内部类又称为嵌套类,可以把内部类理解成外部类的一个普通成员 内部类之所以也是语法糖,是因为它仅仅是一个编译时的概念,outer.java里面定义了一个内部类inner,一旦编译成功,就会生成两个完全不同的.class文件了,分别是outer.class和outer$inner.class。所以内部类的名字完全可以和它的外部类名字相同。\n代码如下:\npublic class OutterClass { private String userName; public String getUserName() { return userName; } public void setUserName(String userName) { this.userName = userName; } public static void main(String[] args) { } class InnerClass{ private String name; public String getName() { return name; } public void setName(String name) { this.name = name; } } } 编译之后,会生成两个class文件OutterClass.class和OutterClass$InnerClass.class。所以内部类是可以跟外部类完全一样的名字的 如果要对OutterClass.class进行反编译,那么他会把OutterClass$InnerClass.class也一起进行反编译\npublic class OutterClass { class InnerClass { public String getName() { return name; } public void setName(String name) { this.name = name; } private String name; final OutterClass this$0; InnerClass() { this.this$0 = OutterClass.this; super(); } } public OutterClass() { } public String getUserName() { return userName; } public void setUserName(String userName){ this.userName = userName; } public static void main(String args1[]) { } private String userName; } 条件编译 # —般情况下,程序中的每一行代码都要参加编译。但有时候出于对程序代码优化的考虑,希望只对其中一部分内容进行编译,此时就需要在程序中加上条件,让编译器只对满足条件的代码进行编译,将不满足条件的代码舍弃,这就是条件编译。\npublic class ConditionalCompilation { public static void main(String[] args) { final boolean DEBUG = true; if(DEBUG) { System.out.println(\u0026#34;Hello, DEBUG!\u0026#34;); } final boolean ONLINE = false; if(ONLINE){ System.out.println(\u0026#34;Hello, ONLINE!\u0026#34;); } } } //反编译之后如下 public class ConditionalCompilation { public ConditionalCompilation() { } public static void main(String args[]) { boolean DEBUG = true; System.out.println(\u0026#34;Hello, DEBUG!\u0026#34;); boolean ONLINE = false; } } Java 语法的条件编译,是通过判断条件为常量的 if 语句实现的。其原理也是 Java 语言的语法糖。根据 if 判断条件的真假,编译器直接把分支为 false 的代码块消除。通过该方式实现的条件编译,必须在方法体内实现,而无法在正整个 Java 类的结构或者类的属性上进行条件编译\n断言 # Java 在执行的时候默认是不启动断言检查的(这个时候,所有的断言语句都将忽略!),如果要开启断言检查,则需要用开关-enableassertions或-ea来开启\n代码如下:\npublic class AssertTest { public static void main(String args[]) { int a = 1; int b = 1; assert a == b; System.out.println(\u0026#34;公众号:Hollis\u0026#34;); assert a != b : \u0026#34;Hollis\u0026#34;; System.out.println(\u0026#34;博客:www.hollischuang.com\u0026#34;); } } //反编译之后代码如下 public class AssertTest { public AssertTest() { } public static void main(String args[]) { int a = 1; int b = 1; if(!$assertionsDisabled \u0026amp;\u0026amp; a != b) throw new AssertionError(); System.out.println(\u0026#34;\\u516C\\u4F17\\u53F7\\uFF1AHollis\u0026#34;); if(!$assertionsDisabled \u0026amp;\u0026amp; a == b) { throw new AssertionError(\u0026#34;Hollis\u0026#34;); } else { System.out.println(\u0026#34;\\u535A\\u5BA2\\uFF1Awww.hollischuang.com\u0026#34;); return; } } static final boolean $assertionsDisabled = !com/hollis/suguar/AssertTest.desiredAssertionStatus(); } 断言的底层是if语言,如果断言为true,则什么都不做;如果断言为false,则程序抛出AssertError来打断程序执行 -enableassertions会设置$assertionsDisabled字段的值 数值字面量 # java7中,字面量允许在数字之间插入任意多个下划线,不会对字面值产生影响,可以方便阅读\n源代码:\npublic class Test { public static void main(String... args) { int i = 10_000; System.out.println(i); } } //反编译后 public class Test { public static void main(String[] args) { int i = 10000; System.out.println(i); } } for-each # 源代码:\npublic static void main(String... args) { String[] strs = {\u0026#34;Hollis\u0026#34;, \u0026#34;公众号:Hollis\u0026#34;, \u0026#34;博客:www.hollischuang.com\u0026#34;}; for (String s : strs) { System.out.println(s); } List\u0026lt;String\u0026gt; strList = ImmutableList.of(\u0026#34;Hollis\u0026#34;, \u0026#34;公众号:Hollis\u0026#34;, \u0026#34;博客:www.hollischuang.com\u0026#34;); for (String s : strList) { System.out.println(s); } } //反编译之后 public static transient void main(String args[]) { String strs[] = { \u0026#34;Hollis\u0026#34;, \u0026#34;\\u516C\\u4F17\\u53F7\\uFF1AHollis\u0026#34;, \u0026#34;\\u535A\\u5BA2\\uFF1Awww.hollischuang.com\u0026#34; }; String args1[] = strs; int i = args1.length; for(int j = 0; j \u0026lt; i; j++) { String s = args1[j]; System.out.println(s); } List strList = ImmutableList.of(\u0026#34;Hollis\u0026#34;, \u0026#34;\\u516C\\u4F17\\u53F7\\uFF1AHollis\u0026#34;, \u0026#34;\\u535A\\u5BA2\\uFF1Awww.hollischuang.com\u0026#34;); String s; for(Iterator iterator = strList.iterator(); iterator.hasNext(); System.out.println(s)) s = (String)iterator.next(); } 会改成普通的for语句循环,或者使用迭代器\ntry-with-resource # 关闭资源的方式,就是再finally块里释放,即调用close方法\n//正常使用 public static void main(String[] args) { BufferedReader br = null; try { String line; br = new BufferedReader(new FileReader(\u0026#34;d:\\\\hollischuang.xml\u0026#34;)); while ((line = br.readLine()) != null) { System.out.println(line); } } catch (IOException e) { // handle exception } finally { try { if (br != null) { br.close(); } } catch (IOException ex) { // handle exception } } } JDK7之后提供的关闭资源的方式:\npublic static void main(String... args) { try (BufferedReader br = new BufferedReader(new FileReader(\u0026#34;d:\\\\ hollischuang.xml\u0026#34;))) { String line; while ((line = br.readLine()) != null) { System.out.println(line); } } catch (IOException e) { // handle exception } } 编译后:\npublic static transient void main(String args[]) { BufferedReader br; Throwable throwable; br = new BufferedReader(new FileReader(\u0026#34;d:\\\\ hollischuang.xml\u0026#34;)); throwable = null; String line; try { while((line = br.readLine()) != null) System.out.println(line); } catch(Throwable throwable2) { throwable = throwable2; throw throwable2; } if(br != null) if(throwable != null) try { br.close(); } catch(Throwable throwable1) { throwable.addSuppressed(throwable1); } else br.close(); break MISSING_BLOCK_LABEL_113; Exception exception; exception; if(br != null) if(throwable != null) try { br.close(); } catch(Throwable throwable3) { throwable.addSuppressed(throwable3); } else br.close(); throw exception; IOException ioexception; ioexception; } } 也就是我们没有做关闭的操作,编译器都帮我们做了\nLambda表达 # 使用lambda表达式便利list\npublic static void main(String... args) { List\u0026lt;String\u0026gt; strList = ImmutableList.of(\u0026#34;Hollis\u0026#34;, \u0026#34;公众号:Hollis\u0026#34;, \u0026#34;博客:www.hollischuang.com\u0026#34;); strList.forEach( s -\u0026gt; { System.out.println(s); } ); } 反编译之后\npublic static /* varargs */ void main(String ... args) { ImmutableList strList = ImmutableList.of((Object)\u0026#34;Hollis\u0026#34;, (Object)\u0026#34;\\u516c\\u4f17\\u53f7\\uff1aHollis\u0026#34;, (Object)\u0026#34;\\u535a\\u5ba2\\uff1awww.hollischuang.com\u0026#34;); strList.forEach((Consumer\u0026lt;String\u0026gt;)LambdaMetafactory.metafactory(null, null, null, (Ljava/lang/Object;)V, lambda$main$0(java.lang.String ), (Ljava/lang/String;)V)()); } private static /* synthetic */ void lambda$main$0(String s) { System.out.println(s); } lambda表达式的实现其实是依赖了一些底层的api,在编译阶段,会把lambda表达式进行解糖,转换成调用内部api的方式\n可能遇到的坑 # 泛型 # 泛型遇到重载\npublic class GenericTypes { public static void method(List\u0026lt;String\u0026gt; list) { System.out.println(\u0026#34;invoke method(List\u0026lt;String\u0026gt; list)\u0026#34;); } public static void method(List\u0026lt;Integer\u0026gt; list) { System.out.println(\u0026#34;invoke method(List\u0026lt;Integer\u0026gt; list)\u0026#34;); } } 这种方法是编译不过去的,因为参数List\u0026lt;Integer\u0026gt; 和List\u0026lt;String\u0026gt;编译之后都被擦出了,变成了一样的原生类型List,擦除动作导致这两个方法的特征签名变得一模一样。\n泛型的类型参数不能用在 Java 异常处理的 catch 语句中。因为异常处理是由 JVM 在运行时刻来进行的。由于类型信息被擦除,JVM 是无法区分两个异常类型MyException\u0026lt;String\u0026gt;和MyException\u0026lt;Integer\u0026gt;的\n泛型类的所有静态变量是共享的\npublic class StaticTest{ public static void main(String[] args){ GT\u0026lt;Integer\u0026gt; gti = new GT\u0026lt;Integer\u0026gt;(); gti.var=1; GT\u0026lt;String\u0026gt; gts = new GT\u0026lt;String\u0026gt;(); gts.var=2; System.out.println(gti.var); } } class GT\u0026lt;T\u0026gt;{ public static int var=0; public void nothing(T x){} } 以上代码输出结果为:2!\n由于经过类型擦除,所有的泛型类实例都关联到同一份字节码上,泛型类的所有静态变量是共享的。\n自动装箱与拆箱 # 对于自动装箱,整形对象通过使用相同的缓存和重用,适用于整数值区间 [ -128,+127 ]\npublic static void main(String[] args) { Integer a = 1000; Integer b = 1000; Integer c = 100; Integer d = 100; System.out.println(\u0026#34;a == b is \u0026#34; + (a == b)); System.out.println((\u0026#34;c == d is \u0026#34; + (c == d))); } //结果 a == b is false c == d is true 增强for循环 # 遍历时不要使用list的remove方法:\nfor (Student stu : students) { if (stu.getId() == 2) students.remove(stu); } //会报ConcurrentModificationException异常,Iterator在工作的时候不允许被迭代的对象被改变,但可以使用Iterator本身的remove()来删除对象,会在删除当前对象的同时,维护索引的一致性 "},{"id":345,"href":"/zh/docs/technology/Review/java_guide/java/Basic/ly0010lyjava_spi/","title":"java_spi","section":"基础","content":" 转载自https://github.com/Snailclimb/JavaGuide (添加小部分笔记)感谢作者!\n简介 # 为了实现在模块装配的时候不用再程序里面动态指明,这就需要一种服务发现机制。JavaSPI就是提供了这样的一个机制:为某个接口寻找服务实现的机制。有点类似IoC的思想,将装配的控制权交到了程序之外\nSPI介绍 # SPI,ServiceProviderInterface 使用SPI:Spring框架、数据库加载驱动、日志接口、以及Dubbo的扩展实现\n感觉下面这个图不太对,被调用方应该 一般模块之间都是通过接口进行通讯,\n当实现方提供了接口和实现,我们可以通过调用实现方的接口从而拥有实现方给我们提供的能力,这就是 API ,这种接口和实现都是放在实现方的。\n当接口存在于调用方这边时,就是 SPI ,由接口调用方确定接口规则,然后由不同的厂商去根据这个规则对这个接口进行实现,从而提供服务。[可以理解成业务方,或者说使用方。它使用了这个接口,而且制定了接口规范,但是具体实现,由被调用方实现]\n我的理解:被调用方(提供接口的人),调用方(使用接口的人),但是其实这里只把调用方\u0026ndash;\u0026gt;使用接口的人 这个关系是对的。\n也就是说,正常情况下由被调用方自己提供接口和实现,即API。而现在,由调用方(这里的调用方其实可以理解成上面的被调用方),提供了接口还使用了接口,而由被调用方进行接口实现\n实战演示 # SLF4J只是一个日志门面(接口),但是SLF4J的具体实现可以有多种,如:Logback/Log4j/Log4j2等等\n简易版本 # ServiceProviderInterface\n目录结构\n│ service-provider-interface.iml │ ├─.idea │ │ .gitignore │ │ misc.xml │ │ modules.xml │ └─ workspace.xml │ └─src └─edu └─jiangxuan └─up └─spi Logger.java LoggerService.java Main.class Logger接口,即SPI 服务提供者接口,后面的服务提供者要针对这个接口进行实现\npackage edu.jiangxuan.up.spi; public interface Logger { void info(String msg); void debug(String msg); } LoggerService类,主要是为服务使用者(调用方)提供特定功能,这个类是实现JavaSPI机制的关键所在\npackage edu.jiangxuan.up.spi; import java.util.ArrayList; import java.util.List; import java.util.ServiceLoader; public class LoggerService { private static final LoggerService SERVICE = new LoggerService(); private final Logger logger; private final List\u0026lt;Logger\u0026gt; loggerList; private LoggerService() { ServiceLoader\u0026lt;Logger\u0026gt; loader = ServiceLoader.load(Logger.class); List\u0026lt;Logger\u0026gt; list = new ArrayList\u0026lt;\u0026gt;(); for (Logger log : loader) { list.add(log); } // LoggerList 是所有 ServiceProvider loggerList = list; if (!list.isEmpty()) { // Logger 只取一个 logger = list.get(0); } else { logger = null; } } //简单单例 public static LoggerService getService() { return SERVICE; } public void info(String msg) { if (logger == null) { System.out.println(\u0026#34;info 中没有发现 Logger 服务提供者\u0026#34;); } else { logger.info(msg); } } public void debug(String msg) { if (loggerList.isEmpty()) { System.out.println(\u0026#34;debug 中没有发现 Logger 服务提供者\u0026#34;); } loggerList.forEach(log -\u0026gt; log.debug(msg)); } } Main类(服务使用者,调用方)\npackage org.spi.service; public class Main { public static void main(String[] args) { LoggerService service = LoggerService.getService(); service.info(\u0026#34;Hello SPI\u0026#34;); service.debug(\u0026#34;Hello SPI\u0026#34;); } } /** 结果 info 中没有发现 Logger 服务提供者 debug 中没有发现 Logger 服务提供者 */ 新的项目,来实现Logger接口\n项目结构\n│ service-provider.iml │ ├─.idea │ │ .gitignore │ │ misc.xml │ │ modules.xml │ └─ workspace.xml │ ├─lib │ service-provider-interface.jar | └─src ├─edu │ └─jiangxuan │ └─up │ └─spi │ └─service │ Logback.java │ └─META-INF └─services edu.jiangxuan.up.spi.Logger 首先需要有一个实现类\npackage edu.jiangxuan.up.spi.service; import edu.jiangxuan.up.spi.Logger; public class Logback implements Logger { @Override public void info(String s) { System.out.println(\u0026#34;Logback info 打印日志:\u0026#34; + s); } @Override public void debug(String s) { System.out.println(\u0026#34;Logback debug 打印日志:\u0026#34; + s); } } 将之前项目打包的jar导入项目中\n之后要src 目录下新建 META-INF/services 文件夹,然后新建文件 edu.jiangxuan.up.spi.Logger (SPI 的全类名,接口名),文件里面的内容是:edu.jiangxuan.up.spi.service.Logback (Logback 的全类名,即 SPI 的实现类的包名 + 类名)\n这是 JDK SPI 机制 ServiceLoader 约定好的标准。\nJava 中的 SPI 机制就是在每次类加载的时候会先去找到 class 相对目录下的 META-INF 文件夹下的 services 文件夹下的文件,将这个文件夹下面的所有文件先加载到内存中,然后根据这些文件的文件名和里面的文件内容找到相应接口的具体实现类,找到实现类后就可以通过反射去生成对应的对象,保存在一个 list 列表里面,所以可以通过迭代或者遍历的方式拿到对应的实例对象,生成不同的实现。\n即:文件名一定要是接口的全类名,然后里面的内容一定要是实现类的全类名,实现类可以有多个,直接换行就好了,多个实现类的时候,会一个一个的迭代加载。\n接下来同样将 service-provider 项目打包成 jar 包,这个 jar 包就是服务提供方的实现。通常我们导入 maven 的 pom 依赖就有点类似这种,只不过我们现在没有将这个 jar 包发布到 maven 公共仓库中,所以在需要使用的地方只能手动的添加到项目中 效果展示 package edu.jiangxuan.up.service; import edu.jiangxuan.up.spi.LoggerService; public class TestJavaSPI { public static void main(String[] args) { LoggerService loggerService = LoggerService.getService(); loggerService.info(\u0026#34;你好\u0026#34;); loggerService.debug(\u0026#34;测试Java SPI 机制\u0026#34;); } } 通过使用 SPI 机制,可以看出服务(LoggerService)和 服务提供者两者之间的耦合度非常低,如果说我们想要换一种实现,那么其实只需要修改 service-provider 项目中针对 Logger 接口的具体实现就可以了,只需要换一个 jar 包即可,也可以有在一个项目里面有多个实现,这不就是 SLF4J 原理吗?\nServiceLoader # JDK 官方给的注释:一种加载服务实现的工具。\n具体实现 # 自己实现 # //个人简易版\npackage edu.jiangxuan.up.service; import java.io.BufferedReader; import java.io.InputStream; import java.io.InputStreamReader; import java.lang.reflect.Constructor; import java.net.URL; import java.net.URLConnection; import java.util.ArrayList; import java.util.Enumeration; import java.util.List; public class MyServiceLoader\u0026lt;S\u0026gt; { // 对应的接口 Class 模板 private final Class\u0026lt;S\u0026gt; service; // 对应实现类的 可以有多个,用 List 进行封装 private final List\u0026lt;S\u0026gt; providers = new ArrayList\u0026lt;\u0026gt;(); // 类加载器 private final ClassLoader classLoader; // 暴露给外部使用的方法,通过调用这个方法可以开始加载自己定制的实现流程。 public static \u0026lt;S\u0026gt; MyServiceLoader\u0026lt;S\u0026gt; load(Class\u0026lt;S\u0026gt; service) { return new MyServiceLoader\u0026lt;\u0026gt;(service); } // 构造方法私有化 private MyServiceLoader(Class\u0026lt;S\u0026gt; service) { this.service = service; this.classLoader = Thread.currentThread().getContextClassLoader(); doLoad(); } // 关键方法,加载具体实现类的逻辑 private void doLoad() { try { // 读取所有 jar 包里面 META-INF/services 包下面的文件,这个文件名就是接口名,然后文件里面的内容就是具体的实现类的路径加全类名 Enumeration\u0026lt;URL\u0026gt; urls = classLoader.getResources(\u0026#34;META-INF/services/\u0026#34; + service.getName()); // 挨个遍历取到的文件 while (urls.hasMoreElements()) { // 取出当前的文件 URL url = urls.nextElement(); System.out.println(\u0026#34;File = \u0026#34; + url.getPath()); // 建立链接 URLConnection urlConnection = url.openConnection(); urlConnection.setUseCaches(false); // 获取文件输入流 InputStream inputStream = urlConnection.getInputStream(); // 从文件输入流获取缓存 BufferedReader bufferedReader = new BufferedReader(new InputStreamReader(inputStream)); // 从文件内容里面得到实现类的全类名 String className = bufferedReader.readLine(); while (className != null) { // ★★【重点】 通过反射拿到实现类的实例 Class\u0026lt;?\u0026gt; clazz = Class.forName(className, false, classLoader); // 如果声明的接口跟这个具体的实现类是属于同一类型,(可以理解为Java的一种多态,接口跟实现类、父类和子类等等这种关系。)则构造实例 if (service.isAssignableFrom(clazz)) { Constructor\u0026lt;? extends S\u0026gt; constructor = (Constructor\u0026lt;? extends S\u0026gt;) clazz.getConstructor(); S instance = constructor.newInstance(); // 把当前构造的实例对象添加到 Provider的列表里面 providers.add(instance); } // 继续读取下一行的实现类,可以有多个实现类,只需要换行就可以了。 className = bufferedReader.readLine(); } } } catch (Exception e) { System.out.println(\u0026#34;读取文件异常。。。\u0026#34;); } } // 返回spi接口对应的具体实现类列表 public List\u0026lt;S\u0026gt; getProviders() { return providers; } } 基本流程:\n通过 URL 工具类从 jar 包的 /META-INF/services 目录下面找到对应的文件, 读取这个文件的名称找到对应的 spi 接口, 通过 InputStream 流将文件里面的具体实现类的全类名读取出来, 根据获取到的全类名,先判断跟 spi 接口是否为同一类型,如果是的,那么就通过反射的机制构造对应的实例对象, 将构造出来的实例对象添加到 Providers 的列表中。 "},{"id":346,"href":"/zh/docs/technology/Review/java_guide/java/Basic/ly0009lyunsafe_class/","title":"unsafe类","section":"基础","content":" 转载自https://github.com/Snailclimb/JavaGuide (添加小部分笔记)感谢作者!\nsun.misc.Unsafe\n提供执行低级别、不安全操作的方法,如直接访问系统内存资源、自主管理内存资源等,效率快,但由于有了操作内存空间的能力,会增加指针问题风险。且这些功能的实现依赖于本地方法,Java代码中只是声明方法头,具体实现规则交给本地代码 为什么要使用本地方法 # 需要用到Java中不具备的依赖于操作系统的特性,跨平台的同时要实现对底层控制 对于其他语言已经完成的现成功能,可以使用Java调用 对时间敏感/性能要求非常高,有必要使用更为底层的语言 对于同一本地方法,不同的操作系统可能通过不同的方式来实现的\nUnsafe创建 # sun.misc.Unsafe部分源码\npublic final class Unsafe { // 单例对象 private static final Unsafe theUnsafe; ...... private Unsafe() { } //Sensitive : 敏感的 英[ˈsensətɪv] @CallerSensitive public static Unsafe getUnsafe() { Class var0 = Reflection.getCallerClass(); // 仅在引导类加载器`BootstrapClassLoader`加载时才合法 if(!VM.isSystemDomainLoader(var0.getClassLoader())) { throw new SecurityException(\u0026#34;Unsafe\u0026#34;); } else { return theUnsafe; } } } 会先判断当前类是否由Bootstrap classloader加载。即只有启动类加载器加载的类才能够调用Unsafe类中的方法\n如何使用Unsafe这个类\n利用反射获得Unsafe类中已经实例化完成的单例对象theUnsafe\nprivate static Unsafe reflectGetUnsafe() { try { Field field = Unsafe.class.getDeclaredField(\u0026#34;theUnsafe\u0026#34;); field.setAccessible(true); return (Unsafe) field.get(null); } catch (Exception e) { log.error(e.getMessage(), e); return null; } } 通过Java命令行命令-Xbootclasspath/a把调用Unsafe相关方法的类A所在jar包路径追加到默认的bootstrap路径中,使得A被引导类加载器加载\njava -Xbootclasspath/a:${path} // 其中path为调用Unsafe相关方法的类所在jar包路径 Unsafe功能 # 内存操作、内存屏障、对象操作、数据操作、CAS操作、线程调度、Class操作、系统信息\n内存操作 # 相关方法:\n//分配新的本地空间 public native long allocateMemory(long bytes); //重新调整内存空间的大小 public native long reallocateMemory(long address, long bytes); //将内存设置为指定值 public native void setMemory(Object o, long offset, long bytes, byte value); //内存拷贝 public native void copyMemory(Object srcBase, long srcOffset,Object destBase, long destOffset,long bytes); //清除内存 public native void freeMemory(long address); 测试:\npackage com.unsafe; import lombok.extern.slf4j.Slf4j; import sun.misc.Unsafe; import java.lang.reflect.Field; import java.util.concurrent.TimeUnit; @Slf4j public class UnsafeGet { private Unsafe unsafe; public UnsafeGet() { this.unsafe = UnsafeGet.reflectGetUnsafe(); ; } private static Unsafe reflectGetUnsafe() { try { Field field = Unsafe.class.getDeclaredField(\u0026#34;theUnsafe\u0026#34;); field.setAccessible(true); return (Unsafe) field.get(null); } catch (Exception e) { log.error(e.getMessage(), e); return null; } } public void example() throws InterruptedException { int size = 4; //使用allocateMemory方法申请 4 字节长度的内存空间 long addr = unsafe.allocateMemory(size); //setMemory(Object var1, long var2, long var4, byte var6) //从var1的偏移量var2处开始,每个字节都设置为var6,设置var4个字节 unsafe.setMemory(null, addr, size, (byte) 1); //找到一个新的size*2大小的内存块,并且拷贝原来addr的值过来 long addr3 = unsafe.reallocateMemory(addr, size * 2); //实际操作中这个地址可能等于addr(有概率,没找到原因,这里先假设重新分配了一块) System.out.println(\u0026#34;addr: \u0026#34; + addr); System.out.println(\u0026#34;addr3: \u0026#34; + addr3); System.out.println(\u0026#34;addr值: \u0026#34; + unsafe.getInt(addr)); System.out.println(\u0026#34;addr3值: \u0026#34; + unsafe.getLong(addr3)); try { for (int i = 0; i \u0026lt; 2; i++) { // copyMemory(Object var1, long var2, Object var4, long var5, long var7); // 从var1的偏移量var2处开始,拷贝数据到var4的偏移量var5上,每次拷贝var7个字节 //所以i = 0时,拷贝到了addr3的前4个字节;i = 1 时,拷贝到了addr3的后4个字节 unsafe.copyMemory(null, addr, null, addr3 + size * i, 4); } System.out.println(unsafe.getInt(addr)); System.out.println(unsafe.getLong(addr3)); } finally { log.info(\u0026#34;start-------\u0026#34;); unsafe.freeMemory(addr); log.info(\u0026#34;end-------\u0026#34;); unsafe.freeMemory(addr3); //实际操作中这句话没执行,不知道原因 } } public static void main(String[] args) throws InterruptedException { long l = Long.parseLong(\u0026#34;0000000100000001000000010000000100000001000000010000000100000001\u0026#34;, 2); System.out.println(l); new UnsafeGet().example(); /** 输出 72340172838076673 addr: 46927104 addr3: 680731776 addr值: 16843009 addr3值: 16843009 16843009 72340172838076673 2023-01-31 14:19:28 下午 [Thread: main] INFO:start------- */ } } 对于setMemory的解释 来源\n/** 将给定内存块中的所有字节设置为固定值(通常是0)。 内存块的地址由对象引用o和偏移地址共同决定,如果对象引用o为null,offset就是绝对地址。第三个参数就是内存块的大小,如果使用allocateMemory进行内存开辟的话,这里的值应该和allocateMemory的参数一致。 value就是设置的固定值,一般为0(这里可以参考netty的DirectByteBuffer)。一般而言,o为null 所有有个重载方法是public native void setMemory(long offset, long bytes, byte value); 等效于setMemory(null, long offset, long bytes, byte value);。 */ public native void setMemory(Object o, long offset, long bytes, byte value); 分析:\n分析一下运行结果,首先使用allocateMemory方法申请 4 字节长度的内存空间,在循环中调用setMemory方法向每个字节写入内容为byte类型的 1,当使用 Unsafe 调用getInt方法时,因为一个int型变量占 4 个字节,会一次性读取 4 个字节,组成一个int的值,对应的十进制结果为 16843009。\n对于reallocateMemory方法:\n在代码中调用reallocateMemory方法重新分配了一块 8 字节长度的内存空间,通过比较addr和addr3可以看到和之前申请的内存地址是不同的。在代码中的第二个 for 循环里,调用copyMemory方法进行了两次内存的拷贝,每次拷贝内存地址addr开始的 4 个字节,分别拷贝到以addr3和addr3+4开始的内存空间上:\n拷贝完成后,使用getLong方法一次性读取8个字节,得到long类型的值\n这种分配属于堆外内存,无法进行垃圾回收,需要我们把这些内存当作资源去手动调用freeMemory方法进行释放,否则会产生内存泄漏。通常是try-finally进行内存释放\n为什么使用堆外内存\n对垃圾回收停顿的改善,堆外内存直接受操作系统管理而不是JVM 提升程序I/O操作的性能。通常I/O通信过程中,存在堆内内存到堆外内存的数据拷贝操作,对于需要频繁进行内存间的数据拷贝且生命周期较短的暂存数据,建议都存储到堆外内存 典型应用 DirectByteBuffer,Java用于实现堆外内存的重要类,对于堆外内存的创建、使用、销毁等逻辑均由Unsafe提供的堆外内存API来实现\n//DirectByteBuffer类源 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 { // 分配内存并返回基地址 base = unsafe.allocateMemory(size); } catch (OutOfMemoryError x) { Bits.unreserveMemory(size, cap); throw x; } // 内存初始化 unsafe.setMemory(base, size, (byte) 0); if (pa \u0026amp;\u0026amp; (base % ps != 0)) { // Round up to page boundary address = base + ps - (base \u0026amp; (ps - 1)); } else { address = base; } // 跟踪 DirectByteBuffer 对象的垃圾回收,以实现堆外内存释放 cleaner = Cleaner.create(this, new Deallocator(base, size, cap)); att = null; } 内存屏障 # 介绍\n编译器和 CPU 会在保证程序输出结果一致的情况下,会对代码进行重排序,从指令优化角度提升性能 后果是,导致 CPU 的高速缓存和内存中数据的不一致 内存屏障(Memory Barrier)就是通过阻止屏障两边的指令重排序从而避免编译器和硬件的不正确优化情况 Unsafe提供了三个内存屏障相关方法\n//内存屏障,禁止load操作重排序。屏障前的load操作不能被重排序到屏障后,屏障后的load操作不能被重排序到屏障前 public native void loadFence(); //内存屏障,禁止store操作重排序。屏障前的store操作不能被重排序到屏障后,屏障后的store操作不能被重排序到屏障前 public native void storeFence(); //内存屏障,禁止load、store操作重排序 public native void fullFence(); 以loadFence方法为例,会禁止读操作重排序,保证在这个屏障之前的所有读操作都已经完成,并且将缓存数据设为无效,重新从主存中进行加载 在某个线程修改Runnable中的flag\n@Getter class ChangeThread implements Runnable{ /**volatile**/ boolean flag=false; @Override public void run() { try { Thread.sleep(3000); } catch (InterruptedException e) { e.printStackTrace(); } System.out.println(\u0026#34;subThread change flag to:\u0026#34; + flag); flag = true; } } 在主线程的while循环中,加入内存屏障,测试是否能感知到flag的修改变化\npublic static void main(String[] args){ ChangeThread changeThread = new ChangeThread(); new Thread(changeThread).start(); while (true) { boolean flag = changeThread.isFlag(); unsafe.loadFence(); //加入读内存屏障 //假设上面这句unsafe.loadFence()去掉,那么 /* 流程:1. 这里的flag为主线程读取到的flag,且此时子线程还没有修改 2. 3秒后子线程进行了修改,由于没有内存屏障,主线程(注意,不是主 存储,是主线程)还是原来的值,值没有刷新,导致不一致 */ if (flag){ System.out.println(\u0026#34;detected flag changed\u0026#34;); break; } //这里不能有System.out.println语句,不然会导致同步 /* synchronized的规定 线程解锁前,必须把共享变量刷新到主内存 线程加锁前将清空工作内存共享变量的值,需要从主存中获取共享变量的值。 */ /** public void println(String x) { synchronized (this) { print(x); newLine(); } } */ } System.out.println(\u0026#34;main thread end\u0026#34;); } //运行结果 subThread change flag to:false detected flag changed main thread end 如果删除上面的loadFence()方法,就会出现下面的情况,主线程无法感知flag发生的变化,会一直在while中循环 典型应用 Java8新引入的锁\u0026mdash;StampedLock,乐观锁,类似于无锁的操作,完全不会阻塞写线程获取写锁,从而缓解读多写少的”饥饿“现象。由于StampedLock提供的乐观读锁不阻塞写线程获取读锁,当线程共享变量从主内存load到线程工作内存时,存在数据不一致的问题\n/** StampedLock 的 validate 方法会通过 Unsafe 的 loadFence 方法加入一个 load 内存屏障 */ public boolean validate(long stamp) { U.loadFence(); return (stamp \u0026amp; SBITS) == (state \u0026amp; SBITS); } 对象操作 # 对象属性\n//在对象的指定偏移地址获取一个对象引用 public native Object getObject(Object o, long offset); //在对象指定偏移地址写入一个对象引用 public native void putObject(Object o, long offset, Object x); 对象实例化 类:\n@Data public class A { private int b; public A(){ this.b =1; } } 对象实例化\n允许我们使用非常规的方式进行对象的实例化\npublic void objTest() throws Exception{ A a1=new A(); System.out.println(a1.getB()); A a2 = A.class.newInstance(); System.out.println(a2.getB()); A a3= (A) unsafe.allocateInstance(A.class); System.out.println(a3.getB()); } //结果 1 1 0\n\u0026gt; 打印结果分别为 1、1、0,说明通过**`allocateInstance`方法创建对象**过程中,**不会调用类的构造方法**。使用这种方式创建对象时,只用到了`Class`对象,所以说如果想要**跳过对象的初始化阶段**或者**跳过构造器的安全检查**,就可以使用这种方法。在上面的例子中,如果将 A 类的**构造函数改为`private`**类型,将无法通过构造函数和反射创建对象,但**`allocateInstance`方法仍然有效**。 - 典型应用 - 常规对象实例化方式,从本质上来说,都是通过new机制来实现对象的创建 - 非常规的实例化方式:Unsafe中提供allocateInstance方法,**仅通过Class对象**就可以创建此类的实例对象 #### 数组操作 - 介绍 ```java //下面两个方法配置使用,即可定位数组中每个元素在内存中的位置 //返回数组中第一个元素的偏移地址 public native int arrayBaseOffset(Class\u0026lt;?\u0026gt; arrayClass); //返回数组中一个元素占用的大小 public native int arrayIndexScale(Class\u0026lt;?\u0026gt; arrayClass); 典型应用\n这两个与数据操作相关的方法,在 java.util.concurrent.atomic 包下的 AtomicIntegerArray(可以实现对 Integer 数组中每个元素的原子性操作)中有典型的应用,如下图 AtomicIntegerArray 源码所示,通过 Unsafe 的 arrayBaseOffset 、arrayIndexScale 分别获取数组首元素的偏移地址 base 及单个元素大小因子 scale 。后续相关原子性操作,均依赖于这两个值进行数组中元素的定位,如下图二所示的 getAndAdd 方法即通过 checkedByteOffset 方法获取某数组元素的偏移地址,而后通过 CAS 实现原子性操作。\nCAS操作 # 相关操作\n/** * CAS * @param o 包含要修改field的对象 * @param offset 对象中某field的偏移量 * @param expected 期望值 * @param update 更新值 * @return true | false */ public final native boolean compareAndSwapObject(Object o, long offset, Object expected, Object update); public final native boolean compareAndSwapInt(Object o, long offset, int expected,int update); public final native boolean compareAndSwapLong(Object o, long offset, long expected, long update); CAS,AS 即比较并替换(Compare And Swap),是实现并发算法时常用到的一种技术。CAS 操作包含三个操作数——内存位置、预期原值及新值。执行 CAS 操作的时候,将内存位置的值与预期原值比较,如果相匹配,那么处理器会自动将该位置值更新为新值,否则,处理器不做任何操作。我们都知道,CAS 是一条 CPU 的原子指令(cmpxchg 指令),不会造成所谓的数据不一致问题,Unsafe 提供的 CAS 方法(如 compareAndSwapXXX)底层实现即为 CPU 指令 cmpxchg\n输出\nprivate volatile int a; public static void main(String[] args){ CasTest casTest=new CasTest(); new Thread(()-\u0026gt;{ /* 一开始a=0的时候,i=1,所以a + 1;之后 a = 1的时候,i = 2 ,所以a 又加1 ;而如果是不等于的话,就会一直原子获取a的值,直到等于 i -1 */ for (int i = 1; i \u0026lt; 5; i++) { casTest.increment(i); System.out.print(casTest.a+\u0026#34; \u0026#34;); } }).start(); new Thread(()-\u0026gt;{ for (int i = 5 ; i \u0026lt;10 ; i++) { casTest.increment(i); System.out.print(casTest.a+\u0026#34; \u0026#34;); } }).start(); } private void increment(int x){ while (true){ try { long fieldOffset = unsafe.objectFieldOffset(CasTest.class.getDeclaredField(\u0026#34;a\u0026#34;)); if (unsafe.compareAndSwapInt(this,fieldOffset,x-1,x)) break; } catch (NoSuchFieldException e) { e.printStackTrace(); } } } //结果 1 2 3 4 5 6 7 8 9 使用两个线程去修改int型属性a的值,并且只有在a的值等于传入的参数x减一时,才会将a的值变为x,也就是实现对a的加一的操作 线程调度(多线程问题) # //Unsafe类提供的相关方法 //取消阻塞线程 public native void unpark(Object thread); //阻塞线程 public native void park(boolean isAbsolute, long time); //获得对象锁(可重入锁) @Deprecated public native void monitorEnter(Object o); //释放对象锁 @Deprecated public native void monitorExit(Object o); //尝试获取对象锁 @Deprecated public native boolean tryMonitorEnter(Object o); 方法 park、unpark 即可实现线程的挂起与恢复,将一个线程进行挂起是通过 park 方法实现的,调用 park 方法后,线程将一直阻塞直到超时或者中断等条件出现;unpark 可以终止一个挂起的线程,使其恢复正常。\n此外,Unsafe 源码中monitor相关的三个方法已经被标记为deprecated,不建议被使用:\n//获得对象锁 @Deprecated public native void monitorEnter(Object var1); //释放对象锁 @Deprecated public native void monitorExit(Object var1); //尝试获得对象锁 @Deprecated public native boolean tryMonitorEnter(Object var1); monitorEnter方法用于获得对象锁,monitorExit用于释放对象锁,如果对一个没有被monitorEnter加锁的对象执行此方法,会抛出IllegalMonitorStateException异常。tryMonitorEnter方法尝试获取对象锁,如果成功则返回true,反之返回false。\n典型操作\nJava 锁和同步器框架的核心类 AbstractQueuedSynchronizer (AQS),就是通过调用**LockSupport.park()和LockSupport.unpark()实现线程的阻塞和唤醒**的,而 LockSupport 的 park 、unpark 方法实际是调用 Unsafe 的 park 、unpark 方式实现的。\npublic static void park(Object blocker) { Thread t = Thread.currentThread(); setBlocker(t, blocker); UNSAFE.park(false, 0L); setBlocker(t, null); } public static void unpark(Thread thread) { if (thread != null) UNSAFE.unpark(thread); } LockSupport 的park方法调用了 Unsafe 的park方法来阻塞当前线程,此方法将线程阻塞后就不会继续往后执行,直到有其他线程调用unpark方法唤醒当前线程。下面的例子对 Unsafe 的这两个方法进行测试:\npublic static void main(String[] args) { Thread mainThread = Thread.currentThread(); new Thread(()-\u0026gt;{ try { TimeUnit.SECONDS.sleep(5); //5s后唤醒main线程 System.out.println(\u0026#34;subThread try to unpark mainThread\u0026#34;); unsafe.unpark(mainThread); } catch (InterruptedException e) { e.printStackTrace(); } }).start(); System.out.println(\u0026#34;park main mainThread\u0026#34;); unsafe.park(false,0L); System.out.println(\u0026#34;unpark mainThread success\u0026#34;); } //输出 park main mainThread subThread try to unpark mainThread unpark mainThread success 流程图如下:\nClass操作 # Unsafe对class的相关操作主要包括类加载和静态变量的操作方法\n静态属性读取相关的方法\n//获取静态属性的偏移量 public native long staticFieldOffset(Field f); //获取静态属性的对象指针---另一说,获取静态变量所属的类在方法区的首地址 public native Object staticFieldBase(Field f); //判断类是否需要实例化(用于获取类的静态属性前进行检测) public native boolean shouldBeInitialized(Class\u0026lt;?\u0026gt; c); 测试\n@Data public class User { public static String name=\u0026#34;Hydra\u0026#34;; int age; } private void staticTest() throws Exception { User user=new User(); System.out.println(unsafe.shouldBeInitialized(User.class)); Field sexField = User.class.getDeclaredField(\u0026#34;name\u0026#34;);//获取到静态属性 long fieldOffset = unsafe.staticFieldOffset(sexField);//获取静态属性的偏移量 Object fieldBase = unsafe.staticFieldBase(sexField); //获取静态属性对应的是哪个类 Object object = unsafe.getObject(fieldBase, fieldOffset);//获取到静态属性 对象 System.out.println(object); } /** 运行结果:falseHydra */ 在 Unsafe 的对象操作中,我们学习了通过objectFieldOffset方法获取对象属性偏移量并基于它对变量的值进行存取,但是它不适用于类中的静态属性,这时候就需要使用staticFieldOffset方法。在上面的代码中,只有在获取Field对象的过程中依赖到了Class,而获取静态变量的属性时不再依赖于Class。\n在上面的代码中首先创建一个User对象,这是因为如果一个类没有被实例化,那么它的静态属性也不会被初始化,最后获取的字段属性将是null(如果直接使用User.name ,那么是会导致类被初始化的)。所以在获取静态属性前,需要调用shouldBeInitialized方法,判断在获取前是否需要初始化这个类。如果删除创建 User 对象的语句,运行结果会变为:truenull\ndefineClass方法允许程序在运行时动态创建一个类\npublic native Class\u0026lt;?\u0026gt; defineClass(String name, byte[] b, int off, int len, ClassLoader loader,ProtectionDomain protectionDomain); 利用class类字节码文件,动态创建一个类\nprivate static void defineTest() { String fileName=\u0026#34;F:\\\\workspace\\\\unsafe-test\\\\target\\\\classes\\\\com\\\\cn\\\\model\\\\User.class\u0026#34;; File file = new File(fileName); try(FileInputStream fis = new FileInputStream(file)) { byte[] content=new byte[(int)file.length()]; fis.read(content); Class clazz = unsafe.defineClass(null, content, 0, content.length, null, null); Object o = clazz.newInstance(); Object age = clazz.getMethod(\u0026#34;getAge\u0026#34;).invoke(o, null); System.out.println(age); } catch (Exception e) { e.printStackTrace(); } } 系统信息 # //获取系统相关信息 //返回系统指针的大小。返回值为4(32位系统)或 8(64位系统)。 【应该是字节数】 public native int addressSize(); //内存页的大小,此值为2的幂次方。 public native int pageSize(); "},{"id":347,"href":"/zh/docs/technology/Review/java_guide/java/Basic/ly0008lybig_decimal/","title":"big_decimal","section":"基础","content":" 转载自https://github.com/Snailclimb/JavaGuide (添加小部分笔记)感谢作者!\n精度的丢失 # float a = 2.0f - 1.9f; float b = 1.8f - 1.7f; System.out.println(a);// 0.100000024 System.out.println(b);// 0.099999905 System.out.println(a == b);// false 为什么会有精度丢失的风险\n这个和计算机保存浮点数的机制有很大关系。我们知道计算机是二进制的,而且计算机在表示一个数字时,宽度是有限的,无限循环的小数存储在计算机时,只能被截断,所以就会导致小数精度发生损失的情况。这也就是解释了为什么浮点数没有办法用二进制精确表示\n使用BigDecimal来定义浮点数的值,然后再进行浮点数的运算操作即可\nBigDecimal常见方法 # 我们在使用 BigDecimal 时,为了防止精度丢失,推荐使用它的BigDecimal(String val)构造方法或者 BigDecimal.valueOf(double val) 静态方法来创建对象\n加减乘除\nBigDecimal a = new BigDecimal(\u0026#34;1.0\u0026#34;); BigDecimal b = new BigDecimal(\u0026#34;0.9\u0026#34;); System.out.println(a.add(b));// 1.9 System.out.println(a.subtract(b));// 0.1 System.out.println(a.multiply(b));// 0.90 System.out.println(a.divide(b));// 无法除尽,抛出 ArithmeticException 异常 System.out.println(a.divide(b, 2, RoundingMode.HALF_UP));// 1.11 使用divide方法的时候,尽量使用3个参数版本(roundingMode.oldMode)\n保留规则\npublic enum RoundingMode { // 2.5 -\u0026gt; 3 , 1.6 -\u0026gt; 2 // -1.6 -\u0026gt; -2 , -2.5 -\u0026gt; -3 UP(BigDecimal.ROUND_UP), //数轴上靠近哪个取哪个 // 2.5 -\u0026gt; 2 , 1.6 -\u0026gt; 1 // -1.6 -\u0026gt; -1 , -2.5 -\u0026gt; -2 DOWN(BigDecimal.ROUND_DOWN), //数轴上离哪个远取哪个 // 2.5 -\u0026gt; 3 , 1.6 -\u0026gt; 2 // -1.6 -\u0026gt; -1 , -2.5 -\u0026gt; -2 CEILING(BigDecimal.ROUND_CEILING), // 2.5 -\u0026gt; 2 , 1.6 -\u0026gt; 1 // -1.6 -\u0026gt; -2 , -2.5 -\u0026gt; -3 FLOOR(BigDecimal.ROUND_FLOOR), ////数轴上 正数:远离哪个取哪个 负数:靠近哪个取哪个 // 2.5 -\u0026gt; 3 , 1.6 -\u0026gt; 2 // -1.6 -\u0026gt; -2 , -2.5 -\u0026gt; -3 HALF_UP(BigDecimal.ROUND_HALF_UP),// 数轴上 正数:靠近哪个取哪个 负数:远离哪个取哪个 //...... } 大小比较\n使用compareTo\nBigDecimal a = new BigDecimal(\u0026#34;1.0\u0026#34;); BigDecimal b = new BigDecimal(\u0026#34;0.9\u0026#34;); System.out.println(a.compareTo(b));// 1 保留几位小数\nBigDecimal m = new BigDecimal(\u0026#34;1.255433\u0026#34;); BigDecimal n = m.setScale(3,RoundingMode.HALF_DOWN); System.out.println(n);// 1.255 使用compareTo替换equals方法,equals不止会比较直,还会比较精度 BigDecimal工具类分享 (用来操作double算术)\nimport java.math.BigDecimal; import java.math.RoundingMode; /** * 简化BigDecimal计算的小工具类 */ public class BigDecimalUtil { /** * 默认除法运算精度 */ private static final int DEF_DIV_SCALE = 10; private BigDecimalUtil() { } /** * 提供精确的加法运算。 * * @param v1 被加数 * @param v2 加数 * @return 两个参数的和 */ public static double add(double v1, double v2) { BigDecimal b1 = BigDecimal.valueOf(v1); BigDecimal b2 = BigDecimal.valueOf(v2); return b1.add(b2).doubleValue(); } /** * 提供精确的减法运算。 * * @param v1 被减数 * @param v2 减数 * @return 两个参数的差 */ public static double subtract(double v1, double v2) { BigDecimal b1 = BigDecimal.valueOf(v1); BigDecimal b2 = BigDecimal.valueOf(v2); return b1.subtract(b2).doubleValue(); } /** * 提供精确的乘法运算。 * * @param v1 被乘数 * @param v2 乘数 * @return 两个参数的积 */ public static double multiply(double v1, double v2) { BigDecimal b1 = BigDecimal.valueOf(v1); BigDecimal b2 = BigDecimal.valueOf(v2); return b1.multiply(b2).doubleValue(); } /** * 提供(相对)精确的除法运算,当发生除不尽的情况时,精确到 * 小数点以后10位,以后的数字四舍五入。 * * @param v1 被除数 * @param v2 除数 * @return 两个参数的商 */ public static double divide(double v1, double v2) { return divide(v1, v2, DEF_DIV_SCALE); } /** * 提供(相对)精确的除法运算。当发生除不尽的情况时,由scale参数指 * 定精度,以后的数字四舍五入。 * * @param v1 被除数 * @param v2 除数 * @param scale 表示表示需要精确到小数点以后几位。 * @return 两个参数的商 */ public static double divide(double v1, double v2, int scale) { if (scale \u0026lt; 0) { throw new IllegalArgumentException( \u0026#34;The scale must be a positive integer or zero\u0026#34;); } BigDecimal b1 = BigDecimal.valueOf(v1); BigDecimal b2 = BigDecimal.valueOf(v2); return b1.divide(b2, scale, RoundingMode.HALF_UP).doubleValue(); } /** * 提供精确的小数位四舍五入处理。 * * @param v 需要四舍五入的数字 * @param scale 小数点后保留几位 * @return 四舍五入后的结果 */ public static double round(double v, int scale) { if (scale \u0026lt; 0) { throw new IllegalArgumentException( \u0026#34;The scale must be a positive integer or zero\u0026#34;); } BigDecimal b = BigDecimal.valueOf(v); BigDecimal one = new BigDecimal(\u0026#34;1\u0026#34;); return b.divide(one, scale, RoundingMode.HALF_UP).doubleValue(); } /** * 提供精确的类型转换(Float) * * @param v 需要被转换的数字 * @return 返回转换结果 */ public static float convertToFloat(double v) { BigDecimal b = new BigDecimal(v); return b.floatValue(); } /** * 提供精确的类型转换(Int)不进行四舍五入 * * @param v 需要被转换的数字 * @return 返回转换结果 */ public static int convertsToInt(double v) { BigDecimal b = new BigDecimal(v); return b.intValue(); } /** * 提供精确的类型转换(Long) * * @param v 需要被转换的数字 * @return 返回转换结果 */ public static long convertsToLong(double v) { BigDecimal b = new BigDecimal(v); return b.longValue(); } /** * 返回两个数中大的一个值 * * @param v1 需要被对比的第一个数 * @param v2 需要被对比的第二个数 * @return 返回两个数中大的一个值 */ public static double returnMax(double v1, double v2) { BigDecimal b1 = new BigDecimal(v1); BigDecimal b2 = new BigDecimal(v2); return b1.max(b2).doubleValue(); } /** * 返回两个数中小的一个值 * * @param v1 需要被对比的第一个数 * @param v2 需要被对比的第二个数 * @return 返回两个数中小的一个值 */ public static double returnMin(double v1, double v2) { BigDecimal b1 = new BigDecimal(v1); BigDecimal b2 = new BigDecimal(v2); return b1.min(b2).doubleValue(); } /** * 精确对比两个数字 * * @param v1 需要被对比的第一个数 * @param v2 需要被对比的第二个数 * @return 如果两个数一样则返回0,如果第一个数比第二个数大则返回1,反之返回-1 */ public static int compareTo(double v1, double v2) { BigDecimal b1 = BigDecimal.valueOf(v1); BigDecimal b2 = BigDecimal.valueOf(v2); return b1.compareTo(b2); } } "},{"id":348,"href":"/zh/docs/technology/Review/java_guide/java/Basic/ly0007lyproxy_pattern/","title":"Java代理模式","section":"基础","content":" 转载自https://github.com/Snailclimb/JavaGuide (添加小部分笔记)感谢作者!\n代理模式 # 使用代理对象来代替对真实对象的访问,就可以在不修改原目标对象的前提下提供额外的功能操作,扩展目标对象的功能,即在目标对象的某个方法执行前后可以增加一些自定义的操作\n静态代理 # 静态代理中,我们对目标对象的每个方法的增强都是手动完成的(*后面会具体演示代码*),非常不灵活(*比如接口一旦新增加方法,目标对象和代理对象都要进行修改*)且麻烦(*需要对每个目标类都单独写一个代理类*)。 实际应用场景非常非常少,日常开发几乎看不到使用静态代理的场景。\n上面我们是从实现和应用角度来说的静态代理,从 JVM 层面来说, 静态代理在编译时就将接口、实现类、代理类这些都变成了一个个实际的 class 文件。\n定义一个接口及其实现类; 创建一个代理类同样实现这个接口 将目标对象注入进代理类,然后在代理类的对应方法调用目标类中的对应方法。这样的话,我们就可以通过代理类屏蔽对目标对象的访问,并且可以在目标方法执行前后做一些自己想做的事情。 代码:\n//定义发送短信的接口 public interface SmsService { String send(String message); } //实现发送短信的接口 public class SmsServiceImpl implements SmsService { public String send(String message) { System.out.println(\u0026#34;send message:\u0026#34; + message); return message; } } //创建代理类并同样实现发送短信的接口 public class SmsProxy implements SmsService { private final SmsService smsService; public SmsProxy(SmsService smsService) { this.smsService = smsService; } @Override public String send(String message) { //调用方法之前,我们可以添加自己的操作 System.out.println(\u0026#34;before method send()\u0026#34;); smsService.send(message); //调用方法之后,我们同样可以添加自己的操作 System.out.println(\u0026#34;after method send()\u0026#34;); return null; } } //实际使用 public class Main { public static void main(String[] args) { SmsService smsService = new SmsServiceImpl(); SmsProxy smsProxy = new SmsProxy(smsService); smsProxy.send(\u0026#34;java\u0026#34;); } } //打印结果 before method send() send message:java after method send() 动态代理 # 从JVM角度来说,动态代理是在运行时动态生成类字节码,并加载到JVM中的。 SpringAOP和RPC等框架都实现了动态代理\nJDK动态代理 # //定义并发送短信的接口 public interface SmsService { String send(String message); } public class SmsServiceImpl implements SmsService { public String send(String message) { System.out.println(\u0026#34;send message:\u0026#34; + message); return message; } } //JDK动态代理类 import java.lang.reflect.InvocationHandler; import java.lang.reflect.InvocationTargetException; import java.lang.reflect.Method; /** * @author shuang.kou * @createTime 2020年05月11日 11:23:00 */ public class DebugInvocationHandler implements InvocationHandler { /** * 代理类中的真实对象 */ private final Object target; public DebugInvocationHandler(Object target) { this.target = target; }、 public Object invoke(Object proxy, Method method, Object[] args) throws InvocationTargetException, IllegalAccessException { //调用方法之前,我们可以添加自己的操作 System.out.println(\u0026#34;before method \u0026#34; + method.getName()); Object result = method.invoke(target, args); //调用方法之后,我们同样可以添加自己的操作 System.out.println(\u0026#34;after method \u0026#34; + method.getName()); return result; } } 当我们的动态代理对象调用原方法时,实际上调用的invoke(),然后invoke代替我们调用了被代理对象的原生方法\n//工厂类及实际使用 public class JdkProxyFactory { public static Object getProxy(Object target) { return Proxy.newProxyInstance( target.getClass().getClassLoader(), // 目标类的类加载器 target.getClass().getInterfaces(), // 代理需要实现的接口,可指定多个 new DebugInvocationHandler(target) // 代理对象对应的自定义 InvocationHandler ); } } //实际使用 SmsService smsService = (SmsService) JdkProxyFactory.getProxy(new SmsServiceImpl()); smsService.send(\u0026#34;java\u0026#34;); //输出 before method send send message:java after method send CGLIB动态代理机制 # JDK动态代理问题:只能代理实现了接口的类Spring 的AOP中,如果使用了接口,则使用JDK动态代理;否则采用CGLB\n继承\n核心是Enhancer类及MethodInterceptor接口\npublic interface MethodInterceptor extends Callback{ // 拦截被代理类中的方法 public Object intercept(Object obj, java.lang.reflect.Method method, Object[] args,MethodProxy proxy) throws Throwable; } 对象,被拦截方法,参数,调用原始方法\n实例\n//定义一个类,及方法拦截器 package github.javaguide.dynamicProxy.cglibDynamicProxy; public class AliSmsSer pvice { public String send(String message) { System.out.println(\u0026#34;send message:\u0026#34; + message); return message; } } //MethodInterceptor (方法拦截器) import net.sf.cglib.proxy.MethodInterceptor; import net.sf.cglib.proxy.MethodProxy; import java.lang.reflect.Method; /** * 自定义MethodInterceptor */ public class DebugMethodInterceptor implements MethodInterceptor { /** * @param o 代理对象(增强的对象) * @param method 被拦截的方法(需要增强的方法) * @param args 方法入参 * @param methodProxy 用于调用原始方法 */ @Override public Object intercept(Object o, Method method, Object[] args, MethodProxy methodProxy) throws Throwable { //调用方法之前,我们可以添加自己的操作 System.out.println(\u0026#34;before method \u0026#34; + method.getName()); Object object = methodProxy.invokeSuper(o, args); //调用方法之后,我们同样可以添加自己的操作 System.out.println(\u0026#34;after method \u0026#34; + method.getName()); return object; } } // 获取代理类 import net.sf.cglib.proxy.Enhancer; public class CglibProxyFactory { public static Object getProxy(Class\u0026lt;?\u0026gt; clazz) { // 创建动态代理增强类 Enhancer enhancer = new Enhancer(); // 设置类加载器 enhancer.setClassLoader(clazz.getClassLoader()); // 设置被代理类 enhancer.setSuperclass(clazz); // 设置方法拦截器 enhancer.setCallback(new DebugMethodInterceptor()); // 创建代理类 return enhancer.create(); } } //实际使用 AliSmsService aliSmsService = (AliSmsService) CglibProxyFactory.getProxy(AliSmsService.class); aliSmsService.send(\u0026#34;java\u0026#34;); 对比 # 灵活性:动态代理更为灵活,且不需要实现接口,可以直接代理实现类,并且不需要针对每个对象都创建代理类;一旦添加方法,动态代理类不需要修改; JVM层面:静态代理在编译时就将接口、实现类变成实际的class文件,而动态代理是在运行时生成动态类字节码,并加载到JVM中 "},{"id":349,"href":"/zh/docs/technology/Review/java_guide/java/Basic/ly0006lyreflex/","title":"java-reflex","section":"基础","content":" 转载自https://github.com/Snailclimb/JavaGuide (添加小部分笔记)感谢作者!\n何为反射 # 赋予了我们在运行时分析类以及执行类中方法的能力;运行中获取任意一个类的所有属性和方法,以及调用这些方法和属性\n应用场景 # Spring/Spring Boot 、MyBatis等框架都用了大量反射机制,以下为\nJDK动态代理\n接口及实现类\npackage proxy; public interface Car { public void run(); } //实现类 package proxy; public class CarImpl implements Car{ public void run() { System.out.println(\u0026#34;car running\u0026#34;); } } 代理类 及main方法使用 [ˌɪnvəˈkeɪʃn] 祈祷\npackage proxy; import java.lang.reflect.InvocationHandler; import java.lang.reflect.Method; //JDK动态代理代理类 public class CarHandler implements InvocationHandler{ //真实类的对象 private Object car; //构造方法赋值给真实的类 public CarHandler(Object obj){ this.car = obj; } //代理类执行方法时,调用的是这个方法 public Object invoke(Object proxy, Method method, Object[] args) throws Throwable { System.out.println(\u0026#34;before\u0026#34;); Object res = method.invoke(car, args); System.out.println(\u0026#34;after\u0026#34;); return res; } } //main方法使用 package proxy; import java.lang.reflect.Proxy; public class main { public static void main(String[] args) { CarImpl carImpl = new CarImpl(); CarHandler carHandler = new CarHandler(carImpl); Car proxy = (Car)Proxy.newProxyInstance( main.class.getClassLoader(), //第一个参数,获取ClassLoader carImpl.getClass().getInterfaces(), //第二个参数,获取被代理类的接口 carHandler);//第三个参数,一个InvocationHandler对象,表示的是当我这个动态代理对象在调用方法的时候,会关联到哪一个InvocationHandler对象上 proxy.run(); } } //输出 before car running after Cglib动态代理(没有实现接口的Car\n类\npackage proxy; public class CarNoInterface { public void run() { System.out.println(\u0026#34;car running\u0026#34;); } } cglib代理类 [ˌɪntəˈseptə(r)] interceptor 拦截\npackage proxy; import java.lang.reflect.Method; import org.springframework.cglib.proxy.Enhancer; import org.springframework.cglib.proxy.MethodInterceptor; import org.springframework.cglib.proxy.MethodProxy; public class CglibProxy implements MethodInterceptor{ private Object car; /** * 创建代理对象 * * @param target * @return */ public Object getInstance(Object object) { this.car = object; Enhancer enhancer = new Enhancer(); enhancer.setSuperclass(this.car.getClass()); // 回调方法 enhancer.setCallback(this); // 创建代理对象 return enhancer.create(); } @Override public Object intercept(Object obj, Method method, Object[] args,MethodProxy proxy) throws Throwable { System.out.println(\u0026#34;事物开始\u0026#34;); proxy.invokeSuper(obj, args); System.out.println(\u0026#34;事物结束\u0026#34;); return null; } } 使用\npackage proxy; import java.lang.reflect.Proxy; public class main { public static void main(String[] args) { CglibProxy cglibProxy = new CglibProxy(); CarNoInterface carNoInterface = (CarNoInterface)cglibProxy.getInstance(new CarNoInterface()); carNoInterface.run(); } } //输出 事物开始 car running 事物结束 我们可以基于反射分析类,然后获取到类/属性/方法/方法参数上的注解,之后做进一步的处理\n反射机制的优缺点\n优点\n让代码更加灵活 确定,增加安全问题,可以无视泛型参数的安全检查(泛型参数的安全检查发生在编译时,且性能较差) 反射实战\n获取Class对象的几种方式\nClass alunbarClass = TargetObject.class;//第一种 Class alunbarClass1 = Class.forName(\u0026#34;cn.javaguide.TargetObject\u0026#34;);//第二种 TargetObject o = new TargetObject(); Class alunbarClass2 = o.getClass(); //第三种 ClassLoader.getSystemClassLoader().loadClass(\u0026#34;cn.javaguide.TargetObject\u0026#34;); //第4种,通过类加载器获取Class对象不会进行初始化,意味着不进行包括初始化等一系列操作,静态代码块和静态对象不会得到执行 反射的基本操作 例子:\npackage cn.javaguide; public class TargetObject { private String value; public TargetObject() { value = \u0026#34;JavaGuide\u0026#34;; } public void publicMethod(String s) { System.out.println(\u0026#34;I love \u0026#34; + s); } private void privateMethod() { System.out.println(\u0026#34;value is \u0026#34; + value); } } 通过反射操作这个类的方法以及参数\npackage cn.javaguide; import java.lang.reflect.Field; import java.lang.reflect.InvocationTargetException; import java.lang.reflect.Method; public class Main { public static void main(String[] args) throws ClassNotFoundException, NoSuchMethodException, IllegalAccessException, InstantiationException, InvocationTargetException, NoSuchFieldException { /** * 获取 TargetObject 类的 Class 对象并且创建 TargetObject 类实例 */ Class\u0026lt;?\u0026gt; targetClass = Class.forName(\u0026#34;cn.javaguide.TargetObject\u0026#34;); TargetObject targetObject = (TargetObject) targetClass.newInstance(); /** (Car)Proxy.newProxyInstance( main.class.getClassLoader(), //第一个参数,获取ClassLoader carImpl.getClass().getInterfaces(), //第二个参数,获取被代理类的接口 carHandler); **/ /** * 获取 TargetObject 类中定义的所有方法 */ Method[] methods = targetClass.getDeclaredMethods(); for (Method method : methods) { System.out.println(method.getName()); } /** * 获取指定方法并调用 */ Method publicMethod = targetClass.getDeclaredMethod(\u0026#34;publicMethod\u0026#34;, String.class); publicMethod.invoke(targetObject, \u0026#34;JavaGuide\u0026#34;); /** * 获取指定参数并对参数进行修改 */ Field field = targetClass.getDeclaredField(\u0026#34;value\u0026#34;); //为了对类中的参数进行修改我们取消安全检查 field.setAccessible(true); field.set(targetObject, \u0026#34;JavaGuide\u0026#34;); /** * 调用 private 方法 */ Method privateMethod = targetClass.getDeclaredMethod(\u0026#34;privateMethod\u0026#34;); //为了调用private方法我们取消安全检查 privateMethod.setAccessible(true); privateMethod.invoke(targetObject); } } //输出 publicMethod privateMethod I love JavaGuide value is JavaGuide\n- "},{"id":350,"href":"/zh/docs/technology/Review/java_guide/java/Basic/ly0005lyserialize/","title":"Java序列化详解","section":"基础","content":" 转载自https://github.com/Snailclimb/JavaGuide (添加小部分笔记)感谢作者!\n什么是序列化?什么是反序列化 # 当需要持久化Java对象,比如将Java对象保存在文件中、或者在网络中传输Java对象,这些场景都需要用到序列化\n即:\n序列化:将数据结构/对象,转换成二进制字节流 反序列化:将在序列化过程中所生成的二进制字节流,转换成数据结构或者对象的过程 对于Java,序列化的是对象(Object),也就是实例化后的类(Class)\n序列化的目的,是通过网络传输对象,或者说是将对象存储到文件系统、数据库、内存中,如图: 实际场景 # 对象在进行网络传输(比如远程方法调用 RPC 的时候)之前需要先被序列化,接收到序列化的对象之后需要再进行反序列化; 将对象存储到文件中的时候需要进行序列化,将对象从文件中读取出来需要进行反序列化。 将对象存储到缓存数据库(如 Redis)时需要用到序列化,将对象从缓存数据库中读取出来需要反序列化 序列化协议对于TCP/IP 4层模型的哪一层 # 4层包括,网络接口层,网络层,传输层,应用层 如下图所示:\nOSI七层协议模型中,表示层就是对应用层的用户数据,进行处理转换成二进制流;反过来的话,就是将二进制流转换成应用层的用户数据,即序列化和反序列化,\n因为,OSI 七层协议模型中的应用层、表示层和会话层对应的都是 TCP/IP 四层模型中的应用层,所以序列化协议属于 TCP/IP 协议应用层的一部分\n常见序列化协议对比 # kryo 英音 [k\u0026rsquo;rɪəʊ] ,除了JDK自带的序列化,还有hessian、kryo、protostuff\nJDK自带的序列化,只需要实现java.io.Serializable接口即可\n@AllArgsConstructor @NoArgsConstructor @Getter @Builder @ToString public class RpcRequest implements Serializable { private static final long serialVersionUID = 1905122041950251207L; private String requestId; private String interfaceName; private String methodName; private Object[] parameters; private Class\u0026lt;?\u0026gt;[] paramTypes; private RpcMessageTypeEnum rpcMessageTypeEnum; } serialVersionUID用于版本控制,会被写入二进制序列,反序列化如果发现和当前类不一致则会抛出InvalidClassException异常。一般不使用JDK自带序列化,1 不支持跨语言调用 2 性能差,序列化之后字节数组体积过大\nKryo 由于变长存储特性并使用了字节码生成机制,拥有较高的运行速度和较小字节码体积,代码:\n/** * Kryo serialization class, Kryo serialization efficiency is very high, but only compatible with Java language * * @author shuang.kou * @createTime 2020年05月13日 19:29:00 */ @Slf4j public class KryoSerializer implements Serializer { /** * Because Kryo is not thread safe. So, use ThreadLocal to store Kryo objects */ private final ThreadLocal\u0026lt;Kryo\u0026gt; kryoThreadLocal = ThreadLocal.withInitial(() -\u0026gt; { Kryo kryo = new Kryo(); kryo.register(RpcResponse.class); kryo.register(RpcRequest.class); return kryo; }); @Override public byte[] serialize(Object obj) { try (ByteArrayOutputStream byteArrayOutputStream = new ByteArrayOutputStream(); Output output = new Output(byteArrayOutputStream)) { Kryo kryo = kryoThreadLocal.get(); // Object-\u0026gt;byte:将对象序列化为byte数组 kryo.writeObject(output, obj); kryoThreadLocal.remove(); return output.toBytes(); } catch (Exception e) { throw new SerializeException(\u0026#34;Serialization failed\u0026#34;); } } @Override public \u0026lt;T\u0026gt; T deserialize(byte[] bytes, Class\u0026lt;T\u0026gt; clazz) { try (ByteArrayInputStream byteArrayInputStream = new ByteArrayInputStream(bytes); Input input = new Input(byteArrayInputStream)) { Kryo kryo = kryoThreadLocal.get(); // byte-\u0026gt;Object:从byte数组中反序列化出对象 Object o = kryo.readObject(input, clazz); kryoThreadLocal.remove(); return clazz.cast(o); } catch (Exception e) { throw new SerializeException(\u0026#34;Deserialization failed\u0026#34;); } } } Protobuf 出自google\nProtoStuff,更为易用\nhessian,轻量级的自定义描述的二进制RPC协议,跨语言,hessian2,为阿里修改过的hessian lite,是dubbo RPC默认启用的序列化方式\n总结\n如果不需要跨语言可以考虑Kryo Protobuf,ProtoStuff,hessian支持跨语言 "},{"id":351,"href":"/zh/docs/technology/Review/java_guide/java/Basic/ly0004lypassbyvalue/","title":"为什么Java中只有值传递","section":"基础","content":" 转载自https://github.com/Snailclimb/JavaGuide (添加小部分笔记)感谢作者!\n形参\u0026amp;\u0026amp;实参\n实参(实际参数,Arguments),用于传递给函数/方法的参数,必须有确定的值\n形参(形式参数,Parameters),用于定义函数/方法,接收实参,不需要有确定的值\nString hello = \u0026#34;Hello!\u0026#34;; // hello 为实参 sayHello(hello); // str 为形参 void sayHello(String str) { System.out.println(str); } 值传递\u0026amp;\u0026amp;引用传递\n程序设计将实参传递给方法的方式分为两种,值传递:方法接收实参值的拷贝,会创建副本;引用传递:方法接受的是实参所引用的对象在堆中的地址,不会创建副本,对形参的修改将影响到实参 Java中只有值传递,原因:\n传递基本类型参数\npublic static void main(String[] args) { int num1 = 10; int num2 = 20; swap(num1, num2); System.out.println(\u0026#34;num1 = \u0026#34; + num1); System.out.println(\u0026#34;num2 = \u0026#34; + num2); } public static void swap(int a, int b) { int temp = a; a = b; b = temp; System.out.println(\u0026#34;a = \u0026#34; + a); System.out.println(\u0026#34;b = \u0026#34; + b); } //输出 a = 20 b = 10 num1 = 10 num2 = 20 传递引用类型参数 1\npublic static void main(String[] args) { int[] arr = { 1, 2, 3, 4, 5 }; System.out.println(arr[0]); //1 change(arr); System.out.println(arr[0]);//0 } public static void change(int[] array) { // 将数组的第一个元素变为0 array[0] = 0; } change方法的参数,拷贝的是arr(实参)的地址,所以array和arr指向的是同一个数组对象 传递引用类型参数2\npublic class Person { private String name; // 省略构造函数、Getter\u0026amp;Setter方法 } public static void main(String[] args) { Person xiaoZhang = new Person(\u0026#34;小张\u0026#34;); Person xiaoLi = new Person(\u0026#34;小李\u0026#34;); swap(xiaoZhang, xiaoLi); System.out.println(\u0026#34;xiaoZhang:\u0026#34; + xiaoZhang.getName()); System.out.println(\u0026#34;xiaoLi:\u0026#34; + xiaoLi.getName()); } public static void swap(Person person1, Person person2) { Person temp = person1; person1 = person2; person2 = temp; System.out.println(\u0026#34;person1:\u0026#34; + person1.getName()); System.out.println(\u0026#34;person2:\u0026#34; + person2.getName()); } //结果 person1:小李 person2:小张 xiaoZhang:小张 xiaoLi:小李 这里并不会交换xiaoZhang和xiaoLi,只会交换swap方法栈里的person1和person2\n小结 Java 中将实参传递给方法(或函数)的方式是 值传递 :\n如果参数是基本类型的话,很简单,传递的就是基本类型的字面量值的拷贝,会创建副本。 如果参数是引用类型,传递的就是实参所引用的对象在堆中地址值的拷贝,同样也会创建副本。 "},{"id":352,"href":"/zh/docs/technology/Review/java_guide/java/Basic/ly0003lyjava_guide_basic_3/","title":"javaGuide基础3","section":"基础","content":" 转载自https://github.com/Snailclimb/JavaGuide (添加小部分笔记)感谢作者!\n异常 # unchecked exceptions (运行时异常)\nchecked exceptions (非运行时异常,编译异常)\nJava异常类层次结构图 Exception和Error有什么区别\n除了RuntimeException及其子类以外,其他的Exception类及其子类都属于受检查异常\nException : 程序本身可以处理的异常(可通过catch捕获)\nChecked Exception ,受检查异常,必须处理(catch 或者 throws ,否则编译器通过不了) IOException,ClassNotFoundException,SQLException,FileNotFoundException\nUnchecked Exception , 不受检查异常 , 可以不处理\n(算数异常,类型转换异常,不合法的线程状态异常,下标超出异常,空指针异常,参数类型异常,数字格式异常,不支持操作异常) ArithmeticException,ClassCastException,IllegalThreadStateException,IndexOutOfBoundsException\nNullPointerException,IllegalArgumentException,NumberFormatException,SecurityException,UnsupportedOperationException ```illegal 英[ɪˈliːɡl] 非法的``` ```Arithmetic 英[əˈrɪθmətɪk] 算术``` Error: 程序无法处理的错误 ,不建议通过catch 捕获,已办错误发生时JVM会选择线程终止\nOutOfMemoryError (堆,Java heap space),VirtualMachineError,StackOverFlowError,AssertionError (断言),IOError\nThrowable类常用方法\nString getMessage() //简要描述 String toString() //详细 String getLocalizedMessage() //本地化信息,如果子类(Throwable的子类)没有覆盖该方法,则与gtMessage() 结果一样 void printStackTrace() //打印Throwable对象封装的异常信息 try-catch-finally如何使用 try后面必须要有catch或者finally;无论是否捕获异常,finally都会执行;当在 try 块或 catch 块中遇到 return 语句时,finally 语句块将在方法返回之前被执行。\n不要在 finally 语句块中使用 return! 当 try 语句和 finally 语句中都有 return 语句时,try 语句块中的 return 语句会被忽略。这是因为 try 语句中的 return 返回值会先被暂存在一个本地变量中,当执行到 finally 语句中的 return 之后,这个本地变量的值就变为了 finally 语句中的 return 返回值。\npublic static void main(String[] args) { System.out.println(f(2)); }\npublic static int f(int value) { try { return value * value; } finally { if (value == 2) { return 0; } } } /*\n0 */\nfinally中的代码不一定执行(如果finally之前虚拟机就已经被终止了)\n另外两种情况,程序所在的线程死亡;关闭CPU;都会导致代码不执行 使用try-with-resources代替try-catch-finally\n适用范围:任何实现java.lang.AutoCloseable或者java.io.Closeable的对象【比如InputStream、OutputStream、Scanner、PrintWriter等需要调用close()方法的资源】\n在try-with-resources中,任何catch或finally块在声明的资源关闭后运行\n例子\n//读取文本文件的内容 Scanner scanner = null; try { scanner = new Scanner(new File(\u0026#34;D://read.txt\u0026#34;)); while (scanner.hasNext()) { System.out.println(scanner.nextLine()); } } catch (FileNotFoundException e) { e.printStackTrace(); } finally { if (scanner != null) { scanner.close(); } } 改造后:\ntry (Scanner scanner = new Scanner(new File(\u0026#34;test.txt\u0026#34;))) { while (scanner.hasNext()) { System.out.println(scanner.nextLine()); } } catch (FileNotFoundException fnfe) { fnfe.printStackTrace(); } 可以使用分隔符来分割\ntry (BufferedInputStream bin = new BufferedInputStream(new FileInputStream(new File(\u0026#34;test.txt\u0026#34;))); BufferedOutputStream bout = new BufferedOutputStream(new FileOutputStream(new File(\u0026#34;out.txt\u0026#34;)))) { int b; while ((b = bin.read()) != -1) { bout.write(b); } } catch (IOException e) { e.printStackTrace(); } 需要注意的地方\n不要把异常定义为静态变量,因为这样会导致异常栈信息错乱。每次手动抛出异常,我们都需要手动 new 一个异常对象抛出。 抛出的异常信息一定要有意义。 建议抛出更加具体的异常比如字符串转换为数字格式错误的时候应该抛出NumberFormatException而不是其父类IllegalArgumentException。 使用日志打印异常之后就不要再抛出异常了(两者不要同时存在一段代码逻辑中)。 泛型 # 什么是泛型?有什么作用 Java泛型(Generics)JDK5中引入的一个新特性,使用泛型参数,可以增强代码的可读性以及稳定性\n编译器可以对泛型参数进行检测,并通过泛型参数可以指定传入的对象类型,比如ArrayList\u0026lt;Person\u0026gt; persons=new ArrayList\u0026lt;Person\u0026gt;()这行代码指明该ArrayList对象只能传入Person对象,若传入其他类型的对象则会报错\n原生List返回类型为Object,需要手动转换类型才能使用,使用泛型后编译器自动转换 泛型使用方式\n泛型类\n//此处T可以随便写为任意标识,常见的如T、E、K、V等形式的参数常用于表示泛型 //在实例化泛型类时,必须指定T的具体类型 public class Generic\u0026lt;T\u0026gt;{ private T key; public Generic(T key) { this.key = key; } public T getKey(){ return key; } } // 使用 Generic\u0026lt;Integer\u0026gt; genericInteger = new Generic\u0026lt;Integer\u0026gt;(123456); 泛型接口\npublic interface Generator\u0026lt;T\u0026gt; { public T method(); } 不指定类型使用\nclass GeneratorImpl\u0026lt;T\u0026gt; implements Generator\u0026lt;T\u0026gt;{ @Override public T method() { return null; } } 指定类型使用\nclass GeneratorImpl\u0026lt;T\u0026gt; implements Generator\u0026lt;String\u0026gt;{ @Override public String method() { return \u0026#34;hello\u0026#34;; } } 泛型方法\npublic static \u0026lt; E \u0026gt; void printArray( E[] inputArray ) { for ( E element : inputArray ){ System.out.printf( \u0026#34;%s \u0026#34;, element ); } System.out.println(); } //使用 // 创建不同类型数组: Integer, Double 和 Character Integer[] intArray = { 1, 2, 3 }; String[] stringArray = { \u0026#34;Hello\u0026#34;, \u0026#34;World\u0026#34; }; printArray( intArray ); printArray( stringArray ); 上面称为静态方法,Java中泛型只是一个占位符,必须在传递类型后才能使用,类在实例化时才能传递类型参数,而类型方法的加载优先于类的实例化,静态泛型方法是**没有办法使用类上声明的泛型(即上面的第二点中类名旁边的T)**的,只能使用自己声明的\u0026lt;E\u0026gt;\n也可以是非静态的\nclass A{ private String name; private int age; public \u0026lt;E\u0026gt; int geA(E e){ System.out.println(e.toString()); return 1; } } //使用,其中 \u0026lt;Object\u0026gt; 可以省略 a.\u0026lt;Object\u0026gt;geA(new Object()); 反射 # 反射赋予了我们在运行时分析类以及执行类中方法的能力,通过反射可以获取任意一个类的所有属性和方法\n反射增加(导致)了安全问题,可以无视泛型参数的安全检查(泛型参数的安全检查发生在编译期),不过其对于框架来说实际是影响不大的\n应用场景\n一般用于框架中,框架中大量使用了动态代理,而动态代理的实现也依赖于反射\n//JDK动态代理 interface ILy { String say(String word); } class LyImpl implements ILy{ @Override public String say(String word) { return \u0026#34;hello ,\u0026#34;+word; } } @Slf4j class MyInvocationHandler implements InvocationHandler { private final Object target; public MyInvocationHandler(Object target) { this.target = target; } @Override public Object invoke(Object proxy, Method method, Object[] args) throws Throwable { log.info(\u0026#34;调用前\u0026#34;); Object result = method.invoke(target, args); log.info(\u0026#34;结果是:\u0026#34;+result); log.info(\u0026#34;调用后\u0026#34;); return result; } } public class Test { String a; public static void main(String[] args) { LyImpl target = new LyImpl(); ILy targetProxy = (ILy)Proxy.newProxyInstance(Test.class.getClassLoader(), target.getClass().getInterfaces(), new MyInvocationHandler(target)); targetProxy.say(\u0026#34;dxs\u0026#34;); } } //cglib动态代理 @Slf4j class MyCglibProxyInterceptor implements MethodInterceptor{ @Override public Object intercept(Object o, Method method, Object[] args, MethodProxy methodProxy) throws Throwable { log.info(\u0026#34;调用前\u0026#34;); //注意,这里是invokeSuper,如果是invoke就会调用自己,导致死循环(递归) Object result = methodProxy.invokeSuper(o, args); //上面这个写法有问题,应该是 //Object result = method.invoke(o, args); log.info(\u0026#34;调用结果\u0026#34;+result); log.info(\u0026#34;调用后\u0026#34;); return result; } } public class Test { String a; public static void main(String[] args) { Enhancer enhancer=new Enhancer(); enhancer.setClassLoader(Test.class.getClassLoader()); enhancer.setSuperclass(LyImpl.class); enhancer.setCallback(new MyCglibProxyInterceptor()); //方法一(通过) ILy o = (ILy)enhancer.create(); //方法二(通过) //LyImpl o = (LyImpl)enhancer.create(); o.say(\u0026#34;lyly\u0026#34;); } } 注解也使用到了反射,比如Spring上的@Component注解。 可以基于反射分析类,然后获取到类/属性/方法/方法的参数上的注解,获取注解后,做进一步的处理\n注解 # 注解,Java5引入,用于修饰类、方法或者变量,提供某些信息供程序在编译或者运行时使用\n@Target(ElementType.METHOD) @Retention(RetentionPolicy.SOURCE) public @interface Override { } //注解本质上是一个继承了Annotation的特殊接口 public interface Override extends Annotation{ } 注解只有被解析后才会生效\n编译期直接扫描 :编译器在编译 Java 代码的时候扫描对应的注解并处理,比如某个方法使用@Override 注解,编译器在编译的时候就会检测当前的方法是否重写了父类对应的方法。 运行期通过反射处理 :像框架中自带的注解(比如 Spring 框架的 @Value 、@Component)都是通过反射来进行处理的。(创建类的时候使用反射分析类,获取注解,对创建的对象进一步处理) SPI # 介绍 Service Provider Interface ,服务提供者的接口 , 专门提供给服务提供者或者扩展框架功能的开发者去使用的一个接口 SPI 将服务接口和具体的服务实现分离开来,将服务调用方和服务实现者解耦,能够提升程序的扩展性、可维护性。修改或者替换服务实现并不需要修改调用方。 很多框架都使用了 Java 的 SPI 机制,比如:Spring 框架、数据库加载驱动、日志接口、以及 Dubbo 的扩展实现等等。 SPI扩展实现 API和SPI区别 模块之间通过接口进行通讯,在服务调用方和服务实现方(服务提供者)之间引入一个“接口” 当接口和实现,都是放在实现方的时候,这就是API\n当接口存在于调用方,由接口调用方确定接口规则,然后由不同的厂商去根据这个规则对这个接口进行实现,从而提供服务,即SPI\n举个通俗易懂的例子:公司 H 是一家科技公司,新设计了一款芯片,然后现在需要量产了,而市面上有好几家芯片制造业公司,这个时候,只要 H 公司指定好了这芯片生产的标准(定义好了接口标准),那么这些合作的芯片公司(服务提供者)就按照标准交付自家特色的芯片(提供不同方案的实现,但是给出来的结果是一样的)\n通过 SPI 机制提供了接口设计的灵活性,缺点: 需要遍历加载所有的实现类,不能做到按需加载,效率较低 当多个ServiceLoader同时load时,会有并发问题 I/O # 序列化和反序列化\n序列化:将数据结构或对象换成二级制字节流的过程 反序列化:将在序列化过程中所生成的二进制字节流转换成数据结构或者对象的过程 对于Java,序列化的都是对象(Object),即实例化后的类(Class) 维基\n序列化(serialization)在计算机科学的数据处理中,是指将数据结构或对象状态转换成可取用格式(例如存成文件,存于缓冲,或经由网络中发送),以留待后续在相同或另一台计算机环境中,能恢复原先状态的过程。依照序列化格式重新获取字节的结果时,可以利用它来产生与原始对象相同语义的副本。对于许多对象,像是使用大量引用的复杂对象,这种序列化重建的过程并不容易。面向对象中的对象序列化,并不概括之前原始对象所关系的函数。这种过程也称为对象编组(marshalling)。从一系列字节提取数据结构的反向操作,是反序列化(也称为解编组、deserialization、unmarshalling)。\n序列化的目的,通过网络传输对象,或者说是将对象存储到文件系统、数据库、内存中 被transient修饰的变量,不进行序列化:即当对象被反序列化时,被transient修饰的变量值不会被持久化和恢复 transient 英[ˈtrænziənt]\ntransient 只能修饰变量,不能修饰类和方法。 transient 修饰的变量,在反序列化后变量值将会被置成类型的默认值。例如,如果是修饰 int 类型,那么反序列后结果就是 0。 static 变量因为不属于任何对象(Object),所以无论有没有 transient 关键字修饰,均不会被序列化。 Java IO流\nIO 即 Input/Output,输入和输出。数据输入到计算机内存的过程即输入,反之输出到外部存储(比如数据库,文件,远程主机)的过程即输出。数据传输过程类似于水流,因此称为 IO 流。IO 流在 Java 中分为输入流和输出流,而根据数据的处理方式又分为字节流和字符流。\nJavaIO流的类都是从如下4个抽象类基类中派生出来的\nInputStream/Reader: 所有的输入流的基类,前者是字节输入流,后者是字符输入流。 OutputStream/Writer: 所有输出流的基类,前者是字节输出流,后者是字符输出流。 不管是文件读写还是网络发送接收,信息的最小存储单元都是字节,那为什么I/O流操作要分为字节流操作和字符流操作\n字符流由Java虚拟机将字节转换得到,过程较为耗时 如果不知道编码类型的过,使用字节流的过程中很容易出现乱码 语法糖 # syntactic 英[sɪnˈtæktɪk] 句法的\n指的是为了方便程序员开发程序而设计的一种特殊语法,对编程语言的功能并没有影响,语法糖写出来的代码往往更简单简洁且容易阅读,比如for-each,原理:基于普通的for循环和迭代器\nString[] strs = {\u0026#34;JavaGuide\u0026#34;, \u0026#34;公众号:JavaGuide\u0026#34;, \u0026#34;博客:https://javaguide.cn/\u0026#34;}; for (String s : strs) { System.out.println(s); } JVM 其实并不能识别语法糖,Java 语法糖要想被正确执行,需要先通过编译器进行解糖,也就是在程序编译阶段将其转换成 JVM 认识的基本语法。这也侧面说明,Java 中真正支持语法糖的是 Java 编译器而不是 JVM。如果你去看com.sun.tools.javac.main.JavaCompiler的源码,你会发现在compile()中有一个步骤就是调用desugar(),这个方法就是负责解语法糖的实现的。\nJava中常见的语法糖:\n泛型、自动拆装箱、变长参数、枚举、内部类、增强 for 循环、try-with-resources 语法、lambda 表达式等\n"},{"id":353,"href":"/zh/docs/technology/Review/java_guide/java/Basic/ly0002lyjava_guide_basic_2/","title":"javaGuide基础2","section":"基础","content":" 转载自https://github.com/Snailclimb/JavaGuide (添加小部分笔记)感谢作者!\n面向对象基础 # 区别\n面向过程把解决问题的过程拆成一个个方法,通过一个个方法的执行解决问题。 面向对象会先抽象出对象,然后用对象执行方法的方式解决问题。 面向对象编程 易维护、易复用、易扩展 对象实例与对象引用的不同\nnew 运算符,new 创建对象实例(对象实例在堆内存中),对象引用指向对象实例(对象引用存放在栈内存中)。\n一个对象引用可以指向 0 个或 1 个对象(一根绳子可以不系气球,也可以系一个气球);一个对象可以有 n 个引用指向它(可以用 n 条绳子系住一个气球)。\n对象的相等一般比较的是内存中存放的内容是否相等;引用相等一般比较的是他们指向的内存地址是否相等\n如果一个类没有声明构造方法,该程序能正确执行吗? 如果我们自己添加了类的构造方法(无论是否有参),Java 就不会再添加默认的无参数的构造方法了\n构造方法特点:名字与类名相同;没有返回值但不能用void声明构造函数;生成类的对象时自动执行 构造方法不能重写(override),但能重载 (overload) 面向对象三大特征\n封装\n把一个对象的状态信息(属性)隐藏在对象内部,不允许直接访问,但提供可以被外界访问的方法来操作属性\npublic class Student { private int id;//id属性私有化 private String name;//name属性私有化 //获取id的方法 public int getId() { return id; } //设置id的方法 public void setId(int id) { this.id = id; } //获取name的方法 public String getName() { return name; } //设置name的方法 public void setName(String name) { this.name = name; } } 继承\n不通类型的对象,相互之间有一定数量的共同点,同时每个对象定义了额外的特性使得他们与众不同。继承是使用已存在的类的定义作为基础建立新类的技术\n父类中的私有属性和方法子类无法访问,只是拥有 子类可以拥有自己的属性、方法,即对父类进行拓展 子类可以用自己的方式实现父类的方法(重写) 多态\n对象类型和引用类型之间具有继承(类)/实现(接口)的关系 引用类型变量发出的方法具体调用哪个类的方法,只有程序运行期间才能确定 多态不能调用“只在子类存在而父类不存在”的方法 如果子类重写了父类的方法,真正执行的是子类覆盖的方法,如果子类没有覆盖父类的方法,执行的是父类的方法 接口和抽象类有什么共同点和区别\n共同:都不能被实例化;都可以包含抽象方法;都可以有默认实现的方法。 区别 接口主要用于对类的行为进行约束;抽象类主要用于代码复用(强调所属) 类只能继承一个类,但能实现多个接口 接口中的成员只能是public static final不能被修改且具有初始值;而抽象类中的成员变量默认为default,也可以被public,protected,private修饰,可以不用赋初值 关于访问权限控制\npublic:Java 访问限制最宽的修饰符,一般称之为“公共的”。被其修饰的类、属性以及方法不仅可以跨类访问,而且可以跨包访问。 protected:介于 public 和 private 之间的一种访问修饰符,一般称之为“保护访问权限”。被其修饰的属性以及方法只能被类本身及其子类(即使子类在不同的包中),以及同包的其他类访问。外包的非子类不可以访问。 default:“默认访问权限“或“包访问权限”,即不加任何访问修饰符。只允许在同包访问,外包的所有类都不能访问。接口例外 private:Java 访问限制最窄的修饰符,一般称之为“私有的”。被其修饰的属性以及方法只能被该类的对象访问,其子类不能访问,更不允许跨包访问。 深拷贝和浅拷贝的区别?什么是引用拷贝\n浅拷贝:浅拷贝会在堆上创建新对象,但是如果原对象内部的属性是引用类型的话,浅拷贝会复制内部对象的引用地址,即拷贝对象和原对象共用一个内部对象\n深拷贝,会完全复制整个对象,包括对象内包含的内部对象\n例子\n浅拷贝\npublic class Address implements Cloneable{ private String name; // 省略构造函数、Getter\u0026amp;Setter方法 @Override public Address clone() { try { return (Address) super.clone(); } catch (CloneNotSupportedException e) { throw new AssertionError(); } } } public class Person implements Cloneable { private Address address; // 省略构造函数、Getter\u0026amp;Setter方法 @Override public Person clone() { try { Person person = (Person) super.clone(); return person; } catch (CloneNotSupportedException e) { throw new AssertionError(); } } } //------------------测试-------------------- Person person1 = new Person(new Address(\u0026#34;武汉\u0026#34;)); Person person1Copy = person1.clone(); // true System.out.println(person1.getAddress() == person1Copy.getAddress()); 深拷贝\n//修改了Person类的clone()方法进行修改 @Override public Person clone() { try { Person person = (Person) super.clone(); person.setAddress(person.getAddress().clone()); return person; } catch (CloneNotSupportedException e) { throw new AssertionError(); } } //--------------测试------- Person person1 = new Person(new Address(\u0026#34;武汉\u0026#34;)); Person person1Copy = person1.clone(); // false System.out.println(person1.getAddress() == person1Copy.getAddress()); 引用拷贝,即两个不同的引用指向同一个对象\n如图\nJava常见类 # Object # 常见方法\n/** * native 方法,用于返回当前运行时对象的 Class 对象,使用了 final 关键字修饰,故不允许子类重写。 */ public final native Class\u0026lt;?\u0026gt; getClass() /** * native 方法,用于返回对象的哈希码,主要使用在哈希表中,比如 JDK 中的HashMap。 */ public native int hashCode() /** * 用于比较 2 个对象的内存地址是否相等,String 类对该方法进行了重写以用于比较字符串的值是否相等。 */ public boolean equals(Object obj) /** * naitive 方法,用于创建并返回当前对象的一份拷贝。 */ protected native Object clone() throws CloneNotSupportedException /** * 返回类的名字实例的哈希码的 16 进制的字符串。建议 Object 所有的子类都重写这个方法。 */ public String toString() /** * native 方法,并且不能重写。唤醒一个在此对象监视器上等待的线程(监视器相当于就是锁的概念)。如果有多个线程在等待只会任意唤醒一个。 */ public final native void notify() /** * native 方法,并且不能重写。跟 notify 一样,唯一的区别就是会唤醒在此对象监视器上等待的所有线程,而不是一个线程。 */ public final native void notifyAll() /** * native方法,并且不能重写。暂停线程的执行。注意:sleep 方法没有释放锁,而 wait 方法释放了锁 ,timeout 是等待时间。 */ public final native void wait(long timeout) throws InterruptedException /** * 多了 nanos 参数,这个参数表示额外时间(以毫微秒为单位,范围是 0-999999)。 所以超时的时间还需要加上 nanos 毫秒。。 */ public final void wait(long timeout, int nanos) throws InterruptedException /** * 跟之前的2个wait方法一样,只不过该方法一直等待,没有超时时间这个概念 */ public final void wait() throws InterruptedException /** * 实例被垃圾回收器回收的时候触发的操作 */ protected void finalize() throws Throwable { } == 和 equals() 区别\n对于基本类型来说,== 比较的是值 对于引用类型,== 比较的是对象的内存地址 Java是值传递,所以本质上比较的都是值,只是引用类型变量存的值是对象地址 equals不能用于判断基本数据类型的变量,且只存在于Object类中,而Object类是所有类的直接或间接父类\nequals默认实现:\npublic boolean equals(Object obj) { return (this == obj); } 如果类没有重写该方法,则如上\n如果重写了,则一般都是重写equals方法来比较对象中的属性是否相等\n关于String 和 new String 的区别: String a = \u0026ldquo;xxx\u0026rdquo; 始终返回的是常量池中的引用;而new String 始终返回的是堆中的引用\n对于String a = \u0026ldquo;xxx\u0026rdquo; ,先到常量池中查找是否存在值为\u0026quot;xxx\u0026quot;的字符串,如果存在,直接将常量池中该值对应的引用返回,如果不存在,则在常量池中创建该对象,并返回引用。\n对于new String(\u0026ldquo;xxx\u0026rdquo;),先到常量池中查找是否存在值为\u0026quot;xxx\u0026quot;的字符串,如果存在,则直接在堆中创建对象,并返回堆中的索引;如果不存在,则先在常量池中创建对象(值为xxx),然后再在堆中创建对象,并返回堆中该对象的引用地址\n来自 https://blog.csdn.net/weixin_44844089/article/details/103648448\n例子:\nString a = new String(\u0026#34;ab\u0026#34;); // a 为一个引用 String b = new String(\u0026#34;ab\u0026#34;); // b为另一个引用,对象的内容一样 String aa = \u0026#34;ab\u0026#34;; // 放在常量池中 String bb = \u0026#34;ab\u0026#34;; // 从常量池中查找 System.out.println(aa == bb);// true System.out.println(a == b);// false System.out.println(a.equals(b));// true System.out.println(42 == 42.0);// true String 类重写了equals()方法\npublic boolean equals(Object anObject) { if (this == anObject) { return true; } if (anObject instanceof String) { String anotherString = (String)anObject; int n = value.length; if (n == anotherString.value.length) { char v1[] = value; char v2[] = anotherString.value; int i = 0; while (n-- != 0) { if (v1[i] != v2[i]) return false; i++; } return true; } } return false; } hashCode()有什么用\nhashCode()的作用是获取哈希码(int整数),也称为散列码,作用是确定该对象在哈希表中的索引位置。函数定义在Object类中,且为本地方法,通常用来将对象的内存地址转换为整数之后返回;散列表存储的是键值对(key-value),根据“键”快速检索出“值”,其中利用了散列码\n为什么需要hashCode\n当你把对象加入 HashSet 时,HashSet 会先计算对象的 hashCode 值来判断对象加入的位置,同时也会与其他已经加入的对象的 hashCode 值作比较,如果没有相符的 hashCode,HashSet 会假设对象没有重复出现。但是如果发现有相同 hashCode 值的对象,这时会调用 equals() 方法来检查 hashCode 相等的对象是否真的相同。如果两者相同,HashSet 就不会让其加入操作成功。如果不同的话,就会重新散列到其他位置【注意,我觉得这里应该是使用拉链法,说成散列到其他位置貌似有点不对】。这样我们就大大减少了 equals 的次数,相应就大大提高了执行速度。\nhashCode()和equals()都用于比较两个对象是否相等,为什么要同时提供两个方法(因为在一些容器中,如HashMap、HashSet中,判断元素是否在容器中效率更高)\n两个对象的hashCode值相等并不代表两个对象就相等 因为hashCode所使用的哈希算法也许会让多个对象传回相同哈希值,取决于哈希算法 总结\n如果两个对象的hashCode 值相等,那这两个对象不一定相等(哈希碰撞)。 如果两个对象的hashCode 值相等并且equals()方法也返回 true,我们才认为这两个对象相等。 如果两个对象的**hashCode 值不相等**,我们就可以直接认为这两个对象不相等。 String # String、StringBuffer,StringBuilder区别 String是不可变的,StringBuffer和StringBuilder都继承自AbstractStringBuilder类,是可变的(提供了修改字符串的方法)\nString中的变量不可变,所以是线程安全的,而StringBuffer对方法加了同步锁,所以是线程安全的;而StringBuilder是线程不安全的\n三者使用建议\n操作少量的数据: 适用 String 单线程操作字符串缓冲区下操作大量数据: 适用 StringBuilder 多线程操作字符串缓冲区下操作大量数据: 适用 StringBuffer String 为什么是不可变的\n代码\npublic final class String implements java.io.Serializable, Comparable\u0026lt;String\u0026gt;, CharSequence { private final char value[]; //... } 如上,保存字符串的数组被final修饰且为私有,并且String类没有提供暴露修改该字符串的方法 String类被修饰为final修饰导致不能被继承,避免子类破坏 Java9\npublic final class String implements java.io.Serializable,Comparable\u0026lt;String\u0026gt;, CharSequence { // @Stable 注解表示变量最多被修改一次,称为“稳定的”。 @Stable private final byte[] value; } abstract class AbstractStringBuilder implements Appendable, CharSequence { byte[] value; } Java9为何String底层实现由char[] 改成了 byte[] 新版的 String 其实支持两个编码方案: Latin-1 和 UTF-16。如果字符串中包含的汉字没有超过 Latin-1 可表示范围内的字符,那就会使用 Latin-1 作为编码方案。Latin-1 编码方案下,byte 占一个字节(8 位),char 占用 2 个字节(16),byte 相较 char 节省一半的内存空间。\nJDK 官方就说了绝大部分字符串对象只包含 Latin-1 可表示的字符。 [ˈlætɪn] 字符串使用“+” 还是 Stringbuilder Java本身不支持运算符重载,但 “ + ” 和 “+=” 是专门为String重载过的运算符,Java中仅有的两个\nString str1 = \u0026#34;he\u0026#34;; String str2 = \u0026#34;llo\u0026#34;; String str3 = \u0026#34;world\u0026#34;; String str4 = str1 + str2 + str3; 对应的字节码:\n字符串对象通过“+”的字符串拼接方式,实际上是通过 StringBuilder 调用 append() 方法实现的,拼接完成之后调用 toString() 得到一个 String 对象。因此这里就会产生问题,如下代码,会产生过多的StringBuilder对象\nString[] arr = {\u0026#34;he\u0026#34;, \u0026#34;llo\u0026#34;, \u0026#34;world\u0026#34;}; String s = \u0026#34;\u0026#34;; for (int i = 0; i \u0026lt; arr.length; i++) { s += arr[i]; } System.out.println(s); 会循环创建StringBuilder对象,建议自己创建一个新的StringBuilder并使用:\nString[] arr = {\u0026#34;he\u0026#34;, \u0026#34;llo\u0026#34;, \u0026#34;world\u0026#34;}; StringBuilder s = new StringBuilder(); for (String value : arr) { s.append(value); } System.out.println(s); String#equals()和Object#equals()有何区别 String的equals被重写过,比较的是字符串的值是否相等,而Object的equals比较的是对象的内存地址\n字符串常量池\n是JVM为了提升性能和减少内存消耗针对字符串(String类)专门开辟的一块区域,主要目的是为了避免字符串的重复创建\n// 在堆中创建字符串对象”ab“ (这里也可以说是在常量池中创建对象) // 将字符串对象”ab“的引用(常量池中的饮用)保存在字符串常量池中 String aa = \u0026#34;ab\u0026#34;; // 直接返回字符串常量池中字符串对象”ab“的引用 String bb = \u0026#34;ab\u0026#34;; System.out.println(aa==bb);// true String s1 = new String(\u0026ldquo;abc\u0026rdquo;);这句话创建了几个字符串对象? # 会创建 1 或 2 个字符串对象。 如果常量池中存在值为\u0026quot;abc\u0026quot;的对象,则直接在堆中创建一个对象,并且返回该对象的引用;如果不存在,则先在常量池中创建该对象,然后再在堆中创建该对象,并且返回该对象(堆中)的引用\n下面这个解释,说明常量池存储的是引用(堆中某一块区域的)\n// 字符串常量池中已存在字符串对象“abc”的引用 String s1 = \u0026#34;abc\u0026#34;; // 下面这段代码只会在堆中创建 1 个字符串对象“abc” String s2 = new String(\u0026#34;abc\u0026#34;); intern方法的作用,是一个native方法,作用是将指定的字符串对象的引用保存在字符串常量池中\n// 在堆中创建字符串对象”Java“ // 将字符串对象”Java“的引用保存在字符串常量池中 String s1 = \u0026#34;Java\u0026#34;; // 直接返回字符串常量池中字符串对象”Java“对应的引用 String s2 = s1.intern(); // 会在堆中在单独创建一个字符串对象 String s3 = new String(\u0026#34;Java\u0026#34;); // 直接返回字符串常量池中字符串对象”Java“对应的引用 String s4 = s3.intern(); // s1 和 s2 指向的是堆中的同一个对象 System.out.println(s1 == s2); // true // s3 和 s4 指向的是堆中不同的对象 System.out.println(s3 == s4); // false // s1 和 s4 指向的是堆中的同一个对象 System.out.println(s1 == s4); //true 问题:String 类型的变量和常量做“+”运算时发生了什么\nString str1 = \u0026#34;str\u0026#34;; String str2 = \u0026#34;ing\u0026#34;; String str3 = \u0026#34;str\u0026#34; + \u0026#34;ing\u0026#34;; String str4 = str1 + str2; String str5 = \u0026#34;string\u0026#34;; System.out.println(str3 == str4);//false System.out.println(str3 == str5);//true System.out.println(str4 == str5);//false 常量折叠\n对于 String str3 = \u0026quot;str\u0026quot; + \u0026quot;ing\u0026quot;; 编译器会给你优化成 String str3 = \u0026quot;string\u0026quot;; 。\n并不是所有的常量都会进行折叠,只有编译器在程序编译期就可以确定值的常量才可以:\n基本数据类型( byte、boolean、short、char、int、float、long、double)以及字符串常量。 final 修饰的基本数据类型和字符串变量 字符串通过 “+”拼接得到的字符串、基本数据类型之间算数运算(加减乘除)、基本数据类型的位运算(\u0026laquo;、\u0026raquo;、\u0026raquo;\u0026gt; ) 引用的值在程序编译期间是无法确认的,无法对其优化\n对象引用和“+”的字符串拼接方式,实际上是通过 StringBuilder 调用 append() 方法实现的,拼接完成之后调用 toString() 得到一个 String 对象 。 如上面代码String str4 = str1 + str2; 但是如果使用了final关键字声明之后,就可以让编译器当作常量来处理\nfinal String str1 = \u0026#34;str\u0026#34;; final String str2 = \u0026#34;ing\u0026#34;; // 下面两个表达式其实是等价的 String c = \u0026#34;str\u0026#34; + \u0026#34;ing\u0026#34;;// 常量池中的对象 String d = str1 + str2; // 常量池中的对象 System.out.println(c == d);// true 但是如果编译器在运行时才能知道其确切值的话,就无法对其优化\nfinal String str1 = \u0026#34;str\u0026#34;; final String str2 = getStr(); //str2只有在运行时才能确定其值 String c = \u0026#34;str\u0026#34; + \u0026#34;ing\u0026#34;;// 常量池中的对象 String d = str1 + str2; // 在堆上创建的新的对象 System.out.println(c == d);// false public static String getStr() { return \u0026#34;ing\u0026#34;; } "},{"id":354,"href":"/zh/docs/technology/Review/java_guide/java/Basic/ly0001lyjava_guide_basic_1/","title":"javaGuide基础1","section":"基础","content":" 转载自https://github.com/Snailclimb/JavaGuide (添加小部分笔记)感谢作者!\n基础概念及常识 # Java语言特点\n面向对象(封装、继承、多态) 平台无关性(Java虚拟机) 等等 JVM并非只有一种,只要满足JVM规范,可以开发自己专属JVM\nJDK与JRE\nJDK,JavaDevelopmentKit,包含JRE,还有编译器(javac)和工具(如javadoc、jdb)。能够创建和编译程序 JRE,Java运行时环境,包括Java虚拟机、Java类库,及Java命令等。但是不能创建新程序 字节码,采用字节码的好处\nJava中,JVM可以理解的代码称为字节码(.class文件),不面向任何处理器,只面向虚拟机 Java程序从源代码到运行的过程 java代码必须先编译为字节码,之后呢,.class\u0026ndash;\u0026gt;机器码,这里JVM类加载器先加载字节码文件,然后通过解释器进行解释执行(也就是字节码需要由Java解释器来解释执行) Java解释器是JVM的一部分 编译与解释并存\n编译型:通过编译器将源代码一次性翻译成可被该平台执行的机器码,执行快、开发效率低 解释型:通过解释器一句一句的将代码解释成机器代码后执行,执行慢,开发效率高 如图 为什么说 Java 语言“编译与解释并存”?\n这是因为 Java 语言既具有编译型语言的特征,也具有解释型语言的特征。因为 Java 程序要经过先编译,后解释两个步骤,由 Java 编写的程序需要先经过编译步骤,生成字节码(.class 文件),这种字节码必须由 Java 解释器来解释执行。\nJava与C++区别\n没学过C++,Java不提供指针直接访问内存 Java为单继承;但是Java支持继承多接口 Java有自动内存管理垃圾回收机制(GC),不需要程序员手动释放无用内存 注释分为 单行注释、多行注释、文档注释 标识符与关键字 标识符即名字,关键字则是被赋予特殊含义的标识符\n自增自减运算符 当 b = ++a 时,先自增(自己增加 1),再赋值(赋值给 b);当 b = a++ 时,先赋值(赋值给 b),再自增(自己增加 1)\ncontinue/break/return\ncontinue :指跳出当前的这一次循环,继续下一次循环。 break :指跳出整个循环体,继续执行循环下面的语句。 return 用于跳出所在方法,结束该方法的运行。 变量\n成员变量和局部变量 成员变量可以被 public,private,static 等修饰符所修饰,而局部变量不能被访问控制修饰符及 static 所修饰;但是,成员变量和局部变量都能被 final 所修饰 从变量在内存中的存储方式来看,如果成员变量是使用 static 修饰的,那么这个成员变量是属于类的,如果没有使用 static 修饰,这个成员变量是属于实例的。而对象存在于堆内存,局部变量则存在于栈内存。 从变量在内存中的生存时间上看,成员变量是对象的一部分,它随着对象的创建而存在,而局部变量随着方法的调用而自动生成,随着方法的调用结束而消亡(即方法栈弹出后消亡)。 final必须显示赋初始值,其他都自动以类型默认值赋值 静态变量:被类所有实例共享 字符型常量与字符串常量区别\n形式 : 字符常量是单引号引起的一个字符,字符串常量是双引号引起的 0 个或若干个字符。 含义 : 字符常量相当于一个整型值( ASCII 值),可以参加表达式运算; 字符串常量代表一个地址值(该字符串在内存中存放位置)。 占内存大小 : 字符常量只占 2 个字节; 字符串常量占若干个字节。 静态方法为什么不能调用非静态成员?\n静态方法是属于类的,在类加载的时候就会分配内存,可以通过类名直接访问。而非静态成员属于实例对象,只有在对象实例化之后才存在,需要通过类的实例对象去访问。 在类的非静态成员不存在的时候静态成员就已经存在了,此时调用在内存中还不存在的非静态成员,属于非法操作。 调用方式\n使用类名.方法名 调用静态方法,或者对象.方法名 (不建议) 调用静态方法可以无需创建对象 重载\n发生在同一个类中(或者父类与子类之间),方法名必须相同,参数类型不同、个数不同、顺序不同、方法返回值和访问修饰符可以不同\n不允许存在(只有返回值不同的两个方法(方法名和参数个数及类型相同)) 重载就是同一个类中多个同名方法根据不同的传参来执行不同的逻辑处理。 重写\n发生在运行期,子类对父类的允许访问的方法实现过程进行重新编写\n方法名、参数列表必须相同,子类方法返回值类型应比父类方法返回值类型更小或相等(也就是更具体),抛出的异常范围小于等于父类,访问修饰符范围大于等于父类(不能说父类可以访问而子类不能访问)。【注意,这里只针对方法,类属性则没有这个限制】\npackage com.javaguide; import java.io.IOException; public class TestParent { private String a; protected AParent x() { return new AParent(); } protected void b() throws Exception { } } class TestChild extends TestParent { public String a; /** * 返回类型有误,没有比父类更具体 * * @return */ /* protected AParentParent x() { return new AChild(); }*/ protected AChild x() { return new AChild(); } /** * 抛异常类型有误 没有比父类更具体 * * @throws Throwable */ /*protected void b() throws Throwable { }*/ protected void b() throws IOException { } } 如果父类方法访问修饰符为 private/final/static 则子类就不能重写该方法,但是被 static 修饰的方法能够被再次声明\nclass TestChild extends TestParent { public static void ab(){} } //父类 public class TestParent { protected static void ab(){} } 构造方法无法被重写\n可变长参数\n代码 可变参数只能作为函数的最后一个参数\npublic static void method2(String arg1, String... args) { //...... } 遇到方法重载的情况怎么办呢?会优先匹配固定参数还是可变参数的方法呢?\n答案是会优先匹配固定参数的方法\nJava 的可变参数编译后实际会被转换成一个数组,我们看编译后生成的 class文件就可以看出来了。\n基本数据类型,8种\n6种数字类型,1种字符类型,1种布尔值 byte,short,int,long ; float,double ; char boolean 1个字节8位,其中 byte 1字节,short 2字节,int 4字节 ,long 8字节 float 4字节,double 8 字节 char 2字节,boolean 1位 基本数据类型和包装类型的区别\n包装类型可用于泛型,而基本类型不可以 对于基本数据类型,局部变量会存放在Java虚拟机栈中的局部变量表中,成员变量(未被static修饰)存放在Java虚拟机堆中。\n包装类型属于对象类型,几乎所有对象实例都存在于堆中 相比对象类型,基本数据类型占用空间非常小 \u0026ldquo;基本数据类型存放在栈中\u0026rdquo; 这句话是错的,基本数据类型的成员变量如果没有被static修饰的话(不建议这么用,应该使用基本数据类型对应的包装类型),就存放在堆中。\n(如果被static修饰了,如果1.7则在方法区,1.7及以上移到了 Java堆中) 包装类型的缓存机制 Byte,Short,Integer,Long这4中包装类默认创建了数值[-128,127]的相应类型的缓存数据,Character创建了数值在[0,127]范围的缓存数据,Boolean直接返回True or False\nInteger缓存代码\npublic static Integer valueOf(int i) { if (i \u0026gt;= IntegerCache.low \u0026amp;\u0026amp; i \u0026lt;= IntegerCache.high) return IntegerCache.cache[i + (-IntegerCache.low)]; return new Integer(i); } private static class IntegerCache { static final int low = -128; static final int high; static { // high value may be configured by property int h = 127; } } Character缓存代码\npublic static Character valueOf(char c) { if (c \u0026lt;= 127) { // must cache return CharacterCache.cache[(int)c]; } return new Character(c); } private static class CharacterCache { private CharacterCache(){} static final Character cache[] = new Character[127 + 1]; static { for (int i = 0; i \u0026lt; cache.length; i++) cache[i] = new Character((char)i); } } Boolean缓存代码\npublic static Boolean valueOf(boolean b) { return (b ? TRUE : FALSE); } 注意Float和Double没有使用缓存机制,且 只有调用valueOf(或者自动装箱)才会使用缓存,当使用new的时候是直接创建新对象\npublic Integer(int value) { this.value = value; } 举例\nBoolean t=new Boolean(true); Boolean f=new Boolean(true); System.out.println(t==f); //false System.out.println(t.equals(f)); //true Boolean t1=Boolean.valueOf(true); Boolean f1=Boolean.valueOf(true); System.out.println(t1==f1); //true System.out.println(Boolean.TRUE==Boolean.TRUE); //true //============================================// Integer i1 = 33; //这里发生了自动装箱,相当于Integer.valueOf(30) Integer i2 = 33; System.out.println(i1 == i2);// 输出 true Float i11 = 333f; Float i22 = 333f; System.out.println(i11 == i22);// 输出 false Double i3 = 1.2; Double i4 = 1.2; System.out.println(i3 == i4);// 输出 false //===========================================// Integer i1 = 40; Integer i2 = new Integer(40); System.out.println(i1==i2); 如上,所有整型包装类对象之间值的比较,应该全部使用equals方法比较 什么是自动装箱和拆箱\n装箱:将基本类型用它们对应的引用类型包装起来; 拆箱:将包装类型转换为基本数据类型; 举例说明\nInteger i = 10 ;//装箱 相当于Integer.valueOf(10) int n = i ;//拆箱 对应的字节码\nL1 LINENUMBER 8 L1 ALOAD 0 BIPUSH 10 INVOKESTATIC java/lang/Integer.valueOf (I)Ljava/lang/Integer; PUTFIELD AutoBoxTest.i : Ljava/lang/Integer; L2 LINENUMBER 9 L2 ALOAD 0 ALOAD 0 GETFIELD AutoBoxTest.i : Ljava/lang/Integer; INVOKEVIRTUAL java/lang/Integer.intValue ()I PUTFIELD AutoBoxTest.n : I RETURN 如图,Integer i = 10 等价于Integer i = Integer.valueOf(10)\nint n= i 等价于 int n= i.intValue();\n频繁拆装箱会严重影响系统性能\n浮点数运算的时候会有精度丢失的风险\n这个和计算机保存浮点数的机制有很大关系。我们知道计算机是二进制的,而且计算机在表示一个数字时,宽度是有限的,无限循环的小数存储在计算机时,只能被截断,所以就会导致小数精度发生损失的情况。这也就是解释了为什么浮点数没有办法用二进制精确表示。\n十进制下的0.2无法精确转换成二进制小数\n// 0.2 转换为二进制数的过程为,不断乘以 2,直到不存在小数为止, // 在这个计算过程中,得到的整数部分从上到下排列就是二进制的结果。 0.2 * 2 = 0.4 -\u0026gt; 0 0.4 * 2 = 0.8 -\u0026gt; 0 0.8 * 2 = 1.6 -\u0026gt; 1 0.6 * 2 = 1.2 -\u0026gt; 1 0.2 * 2 = 0.4 -\u0026gt; 0(发生循环) 使用BigDecimal解决上面的问题\nBigDecimal a = new BigDecimal(\u0026#34;1.0\u0026#34;); BigDecimal b = new BigDecimal(\u0026#34;0.9\u0026#34;); BigDecimal c = new BigDecimal(\u0026#34;0.8\u0026#34;); BigDecimal x = a.subtract(b); BigDecimal y = b.subtract(c); System.out.println(x); /* 0.1 */ System.out.println(y); /* 0.1 */ System.out.println(Objects.equals(x, y)); /* true */ 超过long整形的数据,使用BigInteger\nJava中,64位long整型是最大的整数类型\nlong l = Long.MAX_VALUE; System.out.println(l + 1); // -9223372036854775808 System.out.println(l + 1 == Long.MIN_VALUE); // true //BigInteger内部使用int[] 数组来存储任意大小的整型数据 //对于常规整数类型,使用BigInteger运算的效率会降低 "},{"id":355,"href":"/zh/docs/technology/Review/ssm/scope_transaction/","title":"作用域及事务","section":"Ssm","content":" 四种作用域 # singleton:默认值,当IOC容器一创建就会创建bean实例,而且是单例的,每次得到的是同一个 prototype:原型的,IOC容器创建时不再创建bean实例。每次调用getBean方法时再实例化该bean(每次都会进行实例化) request:每次请求会实例化一个bean session:在一次会话中共享一个bean 事务 # 事务是什么 # 逻辑上的一组操作,要么都执行,要么都不执行\n事务的特性 # ACID\nAtomicity /ˌætəˈmɪsəti/原子性 , 要么全部成功,要么全部失败 Consistency /kənˈsɪstənsi/ 一致性 , 数据库的完整性 Isolation /ˌaɪsəˈleɪʃn/ 隔离性 , 数据库允许多个并发事务同时对其数据进行读写和修改的能力,隔离性可以防止多个事务并发执行时由于交叉执行而导致数据的不一致 , 这里涉及到事务隔离级别 Durability /ˌdjʊərəˈbɪləti/ 持久性 , 事务处理结束后,对数据的修改就是永久的,即便系统故障也不会丢失 Spring支持两种方式的事务管理 # 编程式事务管理 /ˈeksɪkjuːt/ execute\n使用transactionTemplate\n@Autowired private TransactionTemplate transactionTemplate; public void testTransaction() { transactionTemplate.execute(new TransactionCallbackWithoutResult() { @Override protected void doInTransactionWithoutResult(TransactionStatus transactionStatus) { try { // .... 业务代码 } catch (Exception e){ //回滚 transactionStatus.setRollbackOnly(); } } }); } 使用transactionManager\n@Autowired private PlatformTransactionManager transactionManager; public void testTransaction() { TransactionStatus status = transactionManager.getTransaction(new DefaultTransactionDefinition()); try { // .... 业务代码 transactionManager.commit(status); } catch (Exception e) { transactionManager.rollback(status); } } 声明式事务管理\n@Transactional(propagation = Propagation.REQUIRED) public void aMethod { //do something B b = new B(); C c = new C(); b.bMethod(); c.cMethod(); } 事务传播行为 # Definition /ˌdefɪˈnɪʃ(ə)n/ 定义\nPropagation /ˌprɒpəˈɡeɪʃn/ 传播\n假设有代码如下:\n@Service Class A { @Autowired B b; @Transactional(propagation = Propagation.REQUIRED) public void aMethod { //do something b.bMethod(); } } @Service Class B { @Transactional(propagation = Propagation.XXXXXX) public void bMethod { //do something } } 共7种,其中主要有4种如下\nTransactionDefinition.PROPAGATION_REQUIRED 如果外部方法没有开启事务,则内部方法创建一个新的事务,即内外两个方法的事务互相独立;如果外部方法存在事务,则内部方法加入该事务,即内外两个方法使用同一个事务\nTransactionDefinition.PROPAGATION_REQUIRES_NEW 如果外部方法存在事务,则会挂起当前的事务,并且开启一个新事务,当外部方法抛出异常时,内部方法不会回滚;而当内部方法抛出异常时,外部方法会检测到并进行回滚。 如果外部方法不存在事务,则也会开启一个新事务\nTransactionDefinition.PROPAGATION_NESTED: 如果外部方法开启事务,则在内部再开启一个事务,作为嵌套事务存在;如果外部方法无事务,则单独开启一个事务\n在外围方法开启事务的情况下Propagation.NESTED修饰的内部方法属于外部事务的子事务,外围主事务回滚,子事务一定回滚,而内部子事务可以单独回滚而不影响外围主事务和其他子事务,也就是和上面的PROPAGATION_REQUIRES_NEW相反\nTransactionDefinition.PROPAGATION_MANDATORY 如果当前存在事务,则加入该事务;如果当前没有事务,则抛出异常 mandatory /ˈmændətəri/ 强制的\n下面三个比较不常用\nTransactionDefinition.PROPAGATION_SUPPORTS: 如果当前存在事务,则加入该事务;如果当前没有事务,则以非事务的方式继续运行。 TransactionDefinition.PROPAGATION_NOT_SUPPORTED: 以非事务方式运行,如果当前存在事务,则把当前事务挂起。 TransactionDefinition.PROPAGATION_NEVER: 以非事务方式运行,如果当前存在事务,则抛出异常。 事务隔离级别 # TransactionDefinition.ISOLATION_DEFAULT TransactionDefinition.ISOLATION_READ_UNCOMMITTED 读未提交,级别最低,允许读取尚未提交的数据,可能会导致脏读、幻读或不可重复读 TransactionDefinition.ISOLATION_READ_COMMITTED 读已提交,对同一字段的多次读取结果都是一致的。可以阻止脏读,但幻读或不可重复读仍会发生 TransactionDefinition.ISOLATION_SERIALIZABLE 串行化,可以防止脏读、幻读及不可重复读,所有事务依次逐个执行,完全服从ACID,但严重影响性能 "},{"id":356,"href":"/zh/docs/technology/Review/basics/member_variables_and_local_variables/","title":"成员变量与局部变量","section":"Java基础(尚硅谷)_","content":" 代码 # static int s; int i; int j; { int i = 1; i++; j++; s++; } public void test(int j) { j++; i++; s++; } public static void main(String[] args) { Exam5 obj1 = new Exam5(); Exam5 obj2 = new Exam5(); obj1.test(10); obj1.test(20); obj2.test(30); System.out.println(obj1.i + \u0026#34;,\u0026#34; + obj1.j + \u0026#34;,\u0026#34; + obj1.s); System.out.println(obj2.i + \u0026#34;,\u0026#34; + obj2.j + \u0026#34;,\u0026#34; + obj2.s); } 运行结果 # 2,1,5 1,1,5 分析 # 就近原则 # 代码中有很多修改变量的语句,下面是用就近原则+作用域分析的图 局部变量和类变量 # 局部变量包括方法体{},形参,以及代码块\n带static为类变量,不带的为实例变量\n代码中的变量分类 修饰符 \u0026ndash;局部变量只有final \u0026ndash; 实例变量 public , protected , private , final , static , volatile transient\n存储位置\n局部变量:栈\n实例变量:堆\n类变量:方法区(类信息、常量、静态变量)\n作用域 局部变量:从声明处开始,到所属的 } 结束 this 题中的s既可以用成员变量访问,也可以用类名访问\n生命周期\n局部变量:每一个线程,每一次调用执行都是新的生命周期 实例变量:随着对象的创建而初始化,随着对象被回收而消亡(垃圾回收器),每一个对象的实例变量是独立的 类变量:随着类的初始化而初始化,随着类的卸载而消亡,该类的所有对象的类变量是共享的 代码的执行,jvm中 # Exam5 obj1=new Exam5();\nobj1.test(10)\n非静态代码块或者进入方法,都会在栈中开辟空间存储局部变量 注意:静态代码块定义的变量,只会存在于静态代码块中。不是类变量,也不属于成员变量\n"},{"id":357,"href":"/zh/docs/technology/Review/basics/recursion_and_iteration/","title":"递归与迭代","section":"Java基础(尚硅谷)_","content":" 编程题 # 有n步台阶,一次只能上1步或2步,共有多少种走法\n分析 # 分析\nn = 1,1步 f(1) = 1\nn = 2, 两个1步,2步 f(2) = 2\nn = 3, 分两种情况: 最后1步是2级台阶/最后1步是1级台阶, 即 f(3) = f(1)+f(2) n = 4, 分两种情况: 最后1步是2级台阶/最后1步是1级台阶, 即f(4) = f(2)+f(3)\n也就是说,不管有几(n)个台阶,总要分成两种情况:最后1步是2级台阶/最后1步是1级台阶,即 f(n)= f(n-2) + f(n-1)\n递归 # public static int f(int n){ if(n==1 || n==2){ return n; } return f(n-2)+f(n-1); } public static void main(String[] args) { System.out.println(f(1)); //1 System.out.println(f(2)); //2 System.out.println(f(3)); //3 System.out.println(f(4)); //5 System.out.println(f(5)); //8 } debug调试 方法栈 f(4)\u0026mdash;-\u0026gt;分解成f(2)+f(3) f(2)\u0026mdash;返回- f(3)\u0026mdash;f(2)返回\u0026mdash;f(1)返回 【f(3)分解成f(2)和f(1)】 方法栈的个数: 使用循环 # public static int loop(int n){ if (n \u0026lt; 1) { throw new IllegalArgumentException(n + \u0026#34;不能小于1\u0026#34;); } if (n == 1 || n == 2) { return n; } int one=2;//最后只走1步,会有2种走法 int two=1;//最后走2步,会有1种走法 int sum=0; for(int i=3;i\u0026lt;=n;i++){ //最后跨两级台阶+最后跨一级台阶的走法 sum=two+one; two=one; one=sum; } return sum; } 小结 # 方法调用自身称为递归,利用变量的原值推出新值称为迭代(while循环) 递归\n优点:大问题转换为小问题,代码精简\n缺点:浪费空间(栈空间),可能会照成栈的溢出 迭代\n优点:效率高,时间只受循环次数限制,不受出栈入栈时间\n缺点:不如递归精简,可读性稍差 "},{"id":358,"href":"/zh/docs/technology/Review/basics/method_parameter_passing_mechanism/","title":"方法的参数传递机制","section":"Java基础(尚硅谷)_","content":" 代码 # public class Exam4 { public static void main(String[] args) { int i = 1; String str = \u0026#34;hello\u0026#34;; Integer num = 2; int[] arr = {1, 2, 3, 4, 5}; MyData my = new MyData(); change(i, str, num, arr, my); System.out.println(\u0026#34;i = \u0026#34; + i); System.out.println(\u0026#34;str = \u0026#34; + str); System.out.println(\u0026#34;num = \u0026#34; + num); System.out.println(\u0026#34;arr = \u0026#34; + Arrays.toString(arr)); System.out.println(\u0026#34;my.a = \u0026#34; + my.a); } public static void change(int j, String s, Integer n, int[] a, MyData m) { j+=1; s+=\u0026#34;world\u0026#34;; n+=1; a[0]+=1; m.a+=1; } } 结果\ni = 1 str = hello num = 2 arr = [2, 2, 3, 4, 5] my.a = 11 知识点 # 方法的参数传递机制 String、包装类等对象的不可变性 分析 # 对于包装类,如果是使用new,那么一定是开辟新的空间;如果是直接赋值,那么-128-127之间会有缓存池(堆中)\n//当使用new的时候,一定在堆中新开辟的空间 Integer a1= new Integer(12); Integer b1= new Integer(12); System.out.println(a1 == b1);//false Integer a2= -128; Integer b2= -128; System.out.println(a2 == b2);//true Integer a21= -129; Integer b21= -129; System.out.println(a21 == b21);//false Integer a3= 127; Integer b3= 127; System.out.println(a3 == b3);//true Integer a4= 22; Integer b4= 22; System.out.println(a4 == b4);//true Integer a31= 128; Integer b31= 128; System.out.println(a31 == b31);//false 对于String类\n//先查找常量池中是否有\u0026#34;abc\u0026#34;,如果有直接返回在常量池中的引用, //如果没有,则在常量池中创建\u0026#34;abc\u0026#34;,然后返回该引用 String a=\u0026#34;abc\u0026#34;; //先查找常量池中是否有\u0026#34;abc\u0026#34;,如果有则在堆内存中创建对象,然后返回堆内存中的地址 //如果没有,则先在常量池中创建字符串对象,然后再在堆内存中创建对象,最后返回堆内存中的地址 String ab=new String(\u0026#34;abc\u0026#34;); System.out.println(a==ab);//true //intern() //判断常量池中是否有ab对象的字符串,如果存在\u0026#34;abc\u0026#34;则返回\u0026#34;abc\u0026#34;在 //常量池中的引用,如果不存在则在常量池中创建, //并返回\u0026#34;abc\u0026#34;在常量池中的引用 System.out.println(a==ab.intern());//true change方法调用之前,jvm中的结构 方法栈帧中的数据 执行change方法后,实参给形参赋值: 基本数据类型:数据值 引用数据类型:地址值\n当实参是特殊的类型时:比如String、包装类等对象,不可变,即 s+=\u0026quot;world\u0026quot;; 会导致创建两个对象,如图( Integer也是) 数组和对象,则是找到堆内存中的地址,直接更改\n"},{"id":359,"href":"/zh/docs/technology/Review/basics/class_and_instance_initialization/","title":"类、实例初始化","section":"Java基础(尚硅谷)_","content":" 代码 # public class Son extends Father{ private int i=test(); private static int j=method(); static { System.out.print(\u0026#34;(6)\u0026#34;); } Son(){ System.out.print(\u0026#34;(7)\u0026#34;); } { System.out.print(\u0026#34;(8)\u0026#34;); } public int test(){ System.out.print(\u0026#34;(9)\u0026#34;); return 1; } public static int method(){ System.out.print(\u0026#34;(10)\u0026#34;); return 1; } public static void main(String[] args) { Son s1=new Son(); System.out.println(); Son s2=new Son(); } } public class Father { private int i=test(); private static int j=method(); static { System.out.print(\u0026#34;(1)\u0026#34;); } Father(){ System.out.print(\u0026#34;(2)\u0026#34;); } { System.out.print(\u0026#34;(3)\u0026#34;); } public int test() { System.out.print(\u0026#34;(4)\u0026#34;); return 1; } public static int method() { System.out.print(\u0026#34;(5)\u0026#34;); return 1; } } 输出:\n(5)(1)(10)(6)(9)(3)(2)(9)(8)(7) (9)(3)(2)(9)(8)(7) 分析 # 类初始化过程\n当实例化了一个对象/或main所在类会导致类初始化 子类初始化前会先初始化父类 类初始化执行的是clinit 方法,编译查看字节码可得知 clinit 由静态类变量显示赋值语句 以及 静态代码块组成(由上到下顺序),且只执行一次\n如下\n实例初始化过程\n执行的是init方法 由非静态实例变量显示赋值语句 以及 非静态代码块 [从上到下顺序] 以及对应构造器代码[最后执行] 组成 其中,子类构造器一定会调用super() [最前面] 1) super() 【最前】 2)i = test() 3)子类的非静态代码块 【2,3按顺序】 4) 子类的无参构造(最后)\n重写的问题 如上所示,初始化Son对象的时候,会先调用super()方法,即初始化父类,然后会先调用父类的 非静态变量赋值以及非静态代码块,最后才是父类的构造器代码块\n调用父类非静态变量赋值的时候,如果调用了非静态方法,就会涉及到重写问题,比如这里的\npublic class Father{ private int i= test(); } 这里会调用子类(当前正在初始化的对象)的test()方法,而不是父类的test()\n哪些方法不可被重写 final方法、静态方法、父类中的private等修饰使得子类不可见的方法 "},{"id":360,"href":"/zh/docs/technology/Review/basics/singleton_design_pattern/","title":"单例设计模式","section":"Java基础(尚硅谷)_","content":" 特点 # 该类只有一个实例 构造器私有化 该类内部自行创建该实例 使用静态变量保存 能向外部提供这个实例 直接暴露 使用静态变量的get方法获取 几大方法 # 饿汉式 # 随着类的加载进行初始化,不管是否需要都会直接创建实例对象\npublic class Singleton1 { public static final Singleton1 INSTANCE=new Singleton1(); private Singleton1() { } } 枚举 # 枚举类表示该类型的对象是有限的几个\npublic enum Singleton2 { INSTANCE } 使用静态代码块 # 随着类的加载进行初始化\npublic class Singleton2 { public static final Singleton2 INSTANCE; static { INSTANCE = new Singleton2(); } private Singleton2() { } } 如图,当初始化实例时需要进行复杂取值操作时,可以取代第一种方法 懒汉式 # 延迟创建对象\npublic class Singleton4 { //为了防止重排序,需要添加volatile关键字 private static volatile Singleton4 INSTANCE; private Singleton4() { } /** * double check * @return */ public static Singleton4 getInstance() { //2 先判断一次,对于后面的操作(此时已经创建了对象)能减少加锁次数 if (INSTANCE == null) { //如果这里不加锁会导致线程安全问题,可能刚进了判断语句之后,执行权被剥夺了又创建好了对象, //所以判断及创建对象必须是原子操作 synchronized (Singleton4.class) { if (INSTANCE == null) { //用来模拟多线程被剥夺执行权 try { Thread.sleep(1000); } catch (InterruptedException e) { e.printStackTrace(); } //如果这个地方不加volatile,会出现的问题是,指令重排 1,2,3是正常的, //会重排成1,3,2 然后别的线程去拿的时候,判断为非空,但是实际上运行的时候,发现里面的数据是空的 //1 memory = allocate();//分配对象空间 //2 instance(memory); //初始化对象 //3 instance = memory; //设置instance指向刚刚分配的位置 INSTANCE = new Singleton4(); } } } return INSTANCE; } } 使用静态内部类 # public class Singleton6 { private Singleton6(){ } private static class Inner{ private static final Singleton6 INSTANCE=new Singleton6(); } public static Singleton6 getInstance(){ return Inner.INSTANCE; } } 只有当内部类被加载和初始化的时候,才会创建INSTANCE实例对象 静态内部类不会自动随着外部类的加载和初始化而初始化,他需要单独去加载和初始化 又由于他是在内部类加载和初始化时,创建的,属于类加载器处理的,所以是线程安全的 "},{"id":361,"href":"/zh/docs/technology/Review/basics/self_incrementing_variable/","title":"自增变量","section":"Java基础(尚硅谷)_","content":" 题目 # int i=1; i=i++; int j=i++; int k = i+ ++i * i++; System.out.println(\u0026#34;i=\u0026#34;+i); System.out.println(\u0026#34;j=\u0026#34;+j); System.out.println(\u0026#34;k=\u0026#34;+k); 讲解 # 对于操作数栈和局部变量表的理解 # 对于下面的代码\nint i=10; int j=9; j=i; 反编译之后,查看字节码\n0 bipush 10 2 istore_1 3 bipush 9 5 istore_2 6 iload_1 7 istore_2 8 return 如下图,这三行代码,是依次把10,9先放到局部变量表的1,2位置。\n之后呢,再把局部变量表中1位置的值,放入操作数栈中\n最后,将操作数栈弹出一个数(10),将数值赋给局部变量表中的位置2\n如上图,当方法为静态方法时,局部变量表0位置存储的是实参第1个数\n(当方法为非静态方法时,局部变量表0位置存储的是this引用)\n对于下面这段代码\nint i=10; int j=20; i=i++; j=++j; System.out.println(i); System.out.println(j); 编译后的字节码\n0 bipush 10 2 istore_1 3 bipush 20 5 istore_2 6 iload_1 7 iinc 1 by 1 10 istore_1 11 iinc 2 by 1 14 iload_2 15 istore_2 16 getstatic #5 \u0026lt;java/lang/System.out : Ljava/io/PrintStream;\u0026gt; 19 iload_1 20 invokevirtual #6 \u0026lt;java/io/PrintStream.println : (I)V\u0026gt; 23 getstatic #5 \u0026lt;java/lang/System.out : Ljava/io/PrintStream;\u0026gt; 26 iload_2 27 invokevirtual #6 \u0026lt;java/io/PrintStream.println : (I)V\u0026gt; 30 return 如上对于j = ++j ;是\n11 iinc 2 by 1 14 iload_2 15 istore_2 先对局部变量表2中的 值 加1,然后将结果 放入操作数栈中,之后再将操作数栈弹出一个数并赋值给 位置2\n对于题目的解释 # int i=1; i=i++; int j=i++; int k = i+ ++i * i++; System.out.println(\u0026#34;i=\u0026#34;+i); System.out.println(\u0026#34;j=\u0026#34;+j); System.out.println(\u0026#34;k=\u0026#34;+k); 编译后的字节码\n0 iconst_1 1 istore_1 2 iload_1 3 iinc 1 by 1 6 istore_1 7 iload_1 8 iinc 1 by 1 11 istore_2 12 iload_1 13 iinc 1 by 1 16 iload_1 17 iload_1 18 iinc 1 by 1 21 imul 22 iadd 23 istore_3 对于 int j = i++\n7 iload_1 8 iinc 1 by 1 11 istore_2 先将i的值放进栈中,然后将局部变量表中的i + 1,之后将栈中的值赋值给j 到这步骤的时候,i = 2 ,j = 1\n最后一步 int k = i+ i * i\n12 iload_1 13 iinc 1 by 1 16 iload_1 17 iload_1 18 iinc 1 by 1 21 imul 22 iadd 23 istore_3 如字节码所示,先将i load进操作数栈中(2),然后将局部变量表中的i 自增 (3),之后将自增后的结果(3)放入操作数栈中,第二次将局部变量表中的i放入操作数栈中。然后此时操作数栈中存在 3 3 2 (由栈顶到栈底) ,依次进行乘法加法 (3*3+2) =11 ,放入局部变量表3 中。 所以结果为 2, 1,11\n小结 # ​\n"},{"id":362,"href":"/zh/docs/technology/Git/git_sgg_/19-26/","title":"19-26_git_尚硅谷","section":"基础(尚硅谷视频)_","content":" 介绍 # 使用代码托管中心(远程服务器) 团队内写作 push\u0026ndash;clone\u0026ndash;push\u0026mdash; \u0026ndash;pull 跨团队写作 fork(到自己的远程库)\u0026mdash;clone 创建远程库\u0026amp;创建别名 # 官网:https://github.com 现在yuebuqun注册一个账号 创建一个远程库git-demo,创建成功 创建远程库别名 git remote -v (查看别名) 为远程库创建别名 git remote add git-demo https://github.com/lwmfjc/git-demo.git 别名创建成功 fetch和push都可以使用别名 推送本地库到远程库 # 推送master分支 切换git checkout master 推送 git push git-demo master 拉取远程库到本地库 # git pull git-demo master 结果 克隆远程库到本地 # git clone xxxxxxx/git-demo.git clone之后有默认的别名,且已经初始化了本地库 团队内写作 # lhc修改了git-demo下的hello.txt 之后进行git add hello.txt git commit -m \u0026ldquo;lhc-commit \u0026quot; hello.txt 现在进行push git push origin master 出错了 使用ybq,对库进行设置,管理成员 添加成员即可 输入账号名 将邀请函 发送给lhc 现在再次推送,则推送成功 团队外合作 # 先把别人的项目fork下来 之后进行修改并且commit pull request (拉取请求) 请求 东方不败:\n岳不群:看到别人发过来的请求 可以同意 合并申请 SSH免密登录 # ssh免密公钥添加\n添加之前,\ngit config --global user.name \u0026#34;username\u0026#34; git config --global user.email useremail@qq.com 删除~/.ssh 使用\nssh-keygen -t rsa -C xxxx@xx.com # 再次到~/.ssh 查看 cat id_rsa 私钥 把私钥复制到 账号\u0026ndash;设置\u0026ndash;ssh and gpgkeys 测试是否成功 "},{"id":363,"href":"/zh/docs/life/archive/20220724/","title":"人为什么要结婚(找对象)","section":"往日归档","content":"其实这是我在六七年前思考的一个问题,我觉得结婚,并不能单纯的作为一个世俗任务。很多人,是因为年纪到了结婚,因为父母催结婚,因为看到别人结婚而结婚,总之,是为别人而活。但我觉得,结婚的本质,应该是两个人生活的结合,包括了很多,比如生活中的喜怒哀乐互享,这是最基础的,开心了有人替你高兴,生气难过了有人安慰你、心疼你。如果连这个都做不到而各活各的,那我实在想不明白这种婚姻的意义在哪,而现在很多情况正是这样,有为了家庭而工作辛苦而没有交集的,也有单纯的相处腻了、懒了。\n而说到腻,这就在于一点,就是有些婚姻是很仓促的,压根就没看清楚对方的样子(性格、三观),或者是不清楚自己喜欢的是什么样的人,就已经在一起了,之后才发现对方很多问题不是自己能接受的,但是这个时候已经晚了。所以“内在”,才能持久吸引一个人,因为这是不轻易随时光变迁而改变的。\n分享也并非简单的分享,如果分享的东西对方没有啥感觉,那这种关系也是很难持久的。因此,最佳的婚姻,应该是异性知己,你的一些心理,不用向对方解释太多,当然 这里并不是说一开始就是这种状态,更多是通过后面不断了解、不断磨合而达成这种状态,当你被别人误会了有人理解,这是世间最好的良药。理解一个人,就是拯救一个世界,一花一世界,一树一菩提。\n婚姻,就是找个互相理解的爱人,共享世间冷暖,白首不相离。\n"},{"id":364,"href":"/zh/docs/technology/Git/git_sgg_/09-18/","title":"09-18_git_尚硅谷","section":"基础(尚硅谷视频)_","content":" 命令 # 命令-设置用户签名\n查看 git config user.name git config user.email 设置 git config --global user.name ly001 git config --global user.email xxx@xx.com git的配置文件查看 作用:区分不同操作者身份,跟后面登陆的账号没有关系 初始化本地库\ngit init 多出一个文件夹 查看本地库状态\ngit status 默认在master分支 新增一个文件 vim hello.txt 此时查看本地库的状态 untracketd files 未被追踪的文件,也就是这个文件还在工作区 添加暂存区\ngit add hello.txt LF 将会被替换成 CRLF,windows里面是CRLF,也就是说\n这个换行符自动转换会把自动把你代码里 与你当前操作系统不相同的换行的方式 转换成当前系统的换行方式(即LF和CRLF 之间的转换)\n这是因为这个hello.txt是使用vm hello.txt在git bash里面添加的,如果直接在windows文件管理器添加一个文件(hello2.txt),就会发现没有这个警告,因为他已经是CRLF了 (为了和视频保持一致,git rm \u0026ndash;cached hello2.txt 后删除这个文件) 查看当前状态,绿色表示git已经追踪到了这个文件\n文件已经存在于暂存区 使用git rm --cached hello.txt可以将文件从暂存区删除 使用后,文件又出现在工作区了(未添加) 提交本地库\ngit commit -m \u0026quot;first commit\u0026quot; hello.txt 会出现一些警告,以及此时提交的修改和生成的版本号(前七位) git status 使用git reflog查看引用日志信息 git log 查看详细日志信息 修改命令\n前提,修改了文件 git status\n红色表示git还没有追踪到这个修改,如果此时commit ,会提示没有需要commit的 使用git add hello.txt 将文件修改添加到暂存区 之后git status 注意,这里如果提交到暂存区之后,使用git restore是无法恢复文件的\ngit restore --staged \u0026lt;file\u0026gt;...\u0026quot; to unstage 使用这个命令丢弃这个文件的commit操作\n几个命令的区别:\ngit restore file 的命令是丢弃你在工作区修改的内容,(修改的内容会丢失) git restore \u0026ndash;staged file 丢弃你在工作区的修改不被 commit 。但是你的修改依然在工作区。 git rm \u0026ndash;cached file和git restore \u0026ndash;staged file 效果好像一样,这里不做更进一步的分析 回到最初,这里主要是为了看修改,如最上面,将第一行后面添加了22222\ncommit 之后的提示,删除了一行,添加了一行(修改的另一种说法) 如果,HEAD -\u0026gt; master ,指针指向了第二个版本 这里再做第三次修改,并add 及commit 查看工作区,永远只有最后那次修改的文件 版本穿梭\ngit reflog和git log 回顾:hello.txt先是5行,然后第一行加了2,之后第二行加了3\n使用git reset \u0026ndash;hard 版本号进行穿梭,这里多了一行,是因为我复制的时候复制粗了版本号\n使用cat 查看,发现文件已经在另一个版本 查看.git的一些文件 说明目前是在master这个版本上 下面这个文件 .git/refs/heads/master 记录了指向master分支的哪个版本号 这里将文件指向最初的版本 此时查看刚才说的那个记录某个分支当前指向版本的文件,已经做了更新 再穿梭为后面的版本 git reset \u0026ndash;hard file 图片解释 master指针指向first,second,third head永远都是指向master(当前分支,目前只有master,所以不变)\n分支 # 概述和优点 查看\u0026amp;创建\u0026amp;切换\ngit branch 分支名 #创建分支 git branch -v #查看分支 git checkout 分支名 #切换分支 git merge 分支名 #把指定的分支合并到当前分支上 查看分支并显示当前分支指向的版本 git branch -v 创建分支 git branch hot-fix git branch #再次查看 切换分支\ngit branch hot-fix 此时修改一个文件并提交 查看.git/head文件,会发现现在它指向hot-fix分支 合并分支(正常合并)\n切换分支 将某分支xx合并到当前分支 git merge 分支名\n如图,合并成功 以后面那个分支的修改为主\n合并分支(冲突合并)\n前提,现在master分支倒数第二行修改并添加和提交 此时切换到hot-fix分支 修改倒数第一行 将文件从工作区添加到暂存区并提交到本地库 此时再切回master\ngit checkout master git merge hot-fix 提示出错了,而且所有有异常的文件,都以下面的形式标注 按dd进行删除某一行 改完了之后,保存并提交即可 切回之后查看hot-fix分支,发现这里的文件是没有变化的 原理 "},{"id":365,"href":"/zh/docs/technology/Git/git_sgg_/01-08/","title":"01-08_git_尚硅谷","section":"基础(尚硅谷视频)_","content":" 概述 # 课程介绍 # Git - git介绍\u0026ndash;分布式版本控制+集中式版本控制 - git安装\u0026ndash;基于官网,2.31.1 windows - 基于开发案例 详细讲解常用命令 - git分支\u0026mdash;特性、创建、转换、合并、代码合并冲突解决 - idea集成git Github 如何创建远程库 推送 push 拉取 pull 克隆 clone ssh免密登录 idea github集成 Gitee码云 码云创建远程库 Idea集成Gitee Gitlab gitlab服务器的搭建和部署 idea集成gitlab 课程目标:五个小时,熟练掌握git、github、gitee 官网介绍 # git是免费的开源的分布式版本控制系统 廉价的本地库 分支功能 Everything is local 版本控制介绍 # 记录文件内容变化,以便将来查阅特定版本修订记录的系统 如果没有git 为什么需要版本控制(从个人开发过渡到团队合作) 分布式版本控制VS集中式版本控制 # SVN,单一的集中管理的服务器,保存所有文件的修订版本。其他人都先连到这个中央服务器上获取最新处理是否冲突 缺点,单点故障,如果某段时间内故障了,那么就没法提交 Git,每台电脑都是代码库 如果远程库挂了,本地还是可以做版本控制的,只不过不能做代码推送而已 每个客户端保存的都是完整的项目(包括历史记录) 发展历史 # linux系统版本控制历史 1991-2002 手动合并 2002 BitKeeper授权Linux社区免费使用(版本控制系统) 社区将其破解 2005 用C语言开发了一个分布式版本控制系统:Git 两周开发时间 2008年 GitHub上线 工作机制和代码托管中心 # 工作机制\n如果git commit ,会生成对应的历史版本,那么这里的历史版本是删不掉的 如果只是在工作区,或者添加到了暂存区,那么是可以恢复(删掉(操作记录))的 git add (让git知道有这个文件) 如果只有v1,v2,v3,V3版本是删不掉的,如果要恢复成v2,只能再提交一次版本 远程库\u0026ndash; 代码托管中心是基于网络服务器的远程代码仓库,简称为远程库 局域网 GitLab\n互联网 GitHub Gitee 码云\n安装 # git安装、客户端使用(windows)\ngit安装位置 任意 非中文、无空格\n选项配置 编辑器选择 是否修改初始化分支的名字\u0026ndash;默认master 默认第二个,这里选择第一个,只能在git bash里面使用 后台客户端协议 配置行末换行符 windows\u0026ndash;CRLF linux\u0026ndash;LF\n默认,让git根据系统自动转换\n从远程拉取代码时,模式\u0026ndash;用默认 凭据管理器 记录登陆行为,不用每次登录 其他配置 软链接文件 缓存 再git bash里运行第三方程序\n安装成功\u0026mdash;视频里面是2.31 "},{"id":366,"href":"/zh/docs/technology/Maven/advance_dljd_/01-21/","title":"01-21 maven多模块管理_动力节点","section":"进阶(动力节点)_","content":" 场景介绍 # 业务依赖 多模块管理 版本管理 第1种方式 # 创建父工程 # 先创建一个空项目 在这个空项目下,创建一个module当作maven父工程 结构 pom文件\n\u0026lt;?xml version=\u0026#34;1.0\u0026#34; encoding=\u0026#34;UTF-8\u0026#34;?\u0026gt; \u0026lt;project xmlns=\u0026#34;http://maven.apache.org/POM/4.0.0\u0026#34; xmlns:xsi=\u0026#34;http://www.w3.org/2001/XMLSchema-instance\u0026#34; xsi:schemaLocation=\u0026#34;http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd\u0026#34;\u0026gt; \u0026lt;modelVersion\u0026gt;4.0.0\u0026lt;/modelVersion\u0026gt; \u0026lt;groupId\u0026gt;com.bjpowernode.maven\u0026lt;/groupId\u0026gt; \u0026lt;artifactId\u0026gt;001-maven-parent\u0026lt;/artifactId\u0026gt; \u0026lt;version\u0026gt;1.0.0\u0026lt;/version\u0026gt; \u0026lt;!-- packaging 标签指定打包方式,默认为jar --\u0026gt; \u0026lt;!-- maven父工程必须遵守以下两点要求 1、packaging标签的文本内容必须设置为pom 2、把src删除 --\u0026gt; \u0026lt;/project\u0026gt; 介绍pom文件 # pom 项目对象模型,project object model,该文件可以子工程被继承 maven多模块管理,其实就是让它的子模块的pom文件来继承父工程的pom\n创建maven java子工程 # 新建一个module\n注意路径,002在IDEA-maven的目录下 查看pom文件\n\u0026lt;?xml version=\u0026#34;1.0\u0026#34; encoding=\u0026#34;UTF-8\u0026#34;?\u0026gt; \u0026lt;project xmlns=\u0026#34;http://maven.apache.org/POM/4.0.0\u0026#34; xmlns:xsi=\u0026#34;http://www.w3.org/2001/XMLSchema-instance\u0026#34; xsi:schemaLocation=\u0026#34;http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd\u0026#34;\u0026gt; \u0026lt;!--指向父工程的gav坐标--\u0026gt; \u0026lt;parent\u0026gt; \u0026lt;artifactId\u0026gt;001-maven-parent\u0026lt;/artifactId\u0026gt; \u0026lt;groupId\u0026gt;com.bjpowernode.maven\u0026lt;/groupId\u0026gt; \u0026lt;version\u0026gt;1.0.0\u0026lt;/version\u0026gt; \u0026lt;!--相对路径--\u0026gt; \u0026lt;relativePath\u0026gt;../001-maven-parent/pom.xml\u0026lt;/relativePath\u0026gt; \u0026lt;/parent\u0026gt; \u0026lt;modelVersion\u0026gt;4.0.0\u0026lt;/modelVersion\u0026gt; \u0026lt;artifactId\u0026gt;002-maven-java\u0026lt;/artifactId\u0026gt; \u0026lt;/project\u0026gt; 创建maven web子工程 # 创建新模块 查看pom\n\u0026lt;?xml version=\u0026#34;1.0\u0026#34; encoding=\u0026#34;UTF-8\u0026#34;?\u0026gt; \u0026lt;project xmlns=\u0026#34;http://maven.apache.org/POM/4.0.0\u0026#34; xmlns:xsi=\u0026#34;http://www.w3.org/2001/XMLSchema-instance\u0026#34; xsi:schemaLocation=\u0026#34;http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd\u0026#34;\u0026gt; \u0026lt;parent\u0026gt; \u0026lt;artifactId\u0026gt;001-maven-parent\u0026lt;/artifactId\u0026gt; \u0026lt;groupId\u0026gt;com.bjpowernode.maven\u0026lt;/groupId\u0026gt; \u0026lt;version\u0026gt;1.0.0\u0026lt;/version\u0026gt; \u0026lt;relativePath\u0026gt;../001-maven-parent/pom.xml\u0026lt;/relativePath\u0026gt; \u0026lt;/parent\u0026gt; \u0026lt;modelVersion\u0026gt;4.0.0\u0026lt;/modelVersion\u0026gt; \u0026lt;artifactId\u0026gt;003-maven-web\u0026lt;/artifactId\u0026gt; \u0026lt;packaging\u0026gt;war\u0026lt;/packaging\u0026gt; \u0026lt;/project\u0026gt; 修改子工程为父工程 # ​\t1 父工程的pom.xml种的packaging标签的文本内容必须设置pom\n​\t2 删除src目录\n如图,比如这里修改002-maven-java为父工程 添加004为002的子工程 查看pom文件\n\u0026lt;?xml version=\u0026#34;1.0\u0026#34; encoding=\u0026#34;UTF-8\u0026#34;?\u0026gt; \u0026lt;project xmlns=\u0026#34;http://maven.apache.org/POM/4.0.0\u0026#34; xmlns:xsi=\u0026#34;http://www.w3.org/2001/XMLSchema-instance\u0026#34; xsi:schemaLocation=\u0026#34;http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd\u0026#34;\u0026gt; \u0026lt;parent\u0026gt; \u0026lt;artifactId\u0026gt;002-maven-java\u0026lt;/artifactId\u0026gt; \u0026lt;groupId\u0026gt;com.bjpowernode.maven\u0026lt;/groupId\u0026gt; \u0026lt;version\u0026gt;1.0.0\u0026lt;/version\u0026gt; \u0026lt;relativePath\u0026gt;../002-maven-java/pom.xml\u0026lt;/relativePath\u0026gt; \u0026lt;/parent\u0026gt; \u0026lt;modelVersion\u0026gt;4.0.0\u0026lt;/modelVersion\u0026gt; \u0026lt;artifactId\u0026gt;004-maven-java-1\u0026lt;/artifactId\u0026gt; \u0026lt;properties\u0026gt; \u0026lt;maven.compiler.source\u0026gt;8\u0026lt;/maven.compiler.source\u0026gt; \u0026lt;maven.compiler.target\u0026gt;8\u0026lt;/maven.compiler.target\u0026gt; \u0026lt;/properties\u0026gt; \u0026lt;/project\u0026gt; 手动修改Maven工程为子工程(非idea中) # 这里说的是,创建子工程的时候,没有选择父工程 创建完之后的pom.xml\n\u0026lt;?xml version=\u0026#34;1.0\u0026#34; encoding=\u0026#34;UTF-8\u0026#34;?\u0026gt; \u0026lt;project xmlns=\u0026#34;http://maven.apache.org/POM/4.0.0\u0026#34; xmlns:xsi=\u0026#34;http://www.w3.org/2001/XMLSchema-instance\u0026#34; xsi:schemaLocation=\u0026#34;http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd\u0026#34;\u0026gt; \u0026lt;modelVersion\u0026gt;4.0.0\u0026lt;/modelVersion\u0026gt; \u0026lt;groupId\u0026gt;com.bjpowernode.maven\u0026lt;/groupId\u0026gt; \u0026lt;artifactId\u0026gt;005-maven-java\u0026lt;/artifactId\u0026gt; \u0026lt;version\u0026gt;1.0.0\u0026lt;/version\u0026gt; \u0026lt;/project\u0026gt; 修改(添加parent标签即可)\n\u0026lt;?xml version=\u0026#34;1.0\u0026#34; encoding=\u0026#34;UTF-8\u0026#34;?\u0026gt; \u0026lt;project xmlns=\u0026#34;http://maven.apache.org/POM/4.0.0\u0026#34; xmlns:xsi=\u0026#34;http://www.w3.org/2001/XMLSchema-instance\u0026#34; xsi:schemaLocation=\u0026#34;http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd\u0026#34;\u0026gt; \u0026lt;parent\u0026gt; \u0026lt;artifactId\u0026gt;001-maven-parent\u0026lt;/artifactId\u0026gt; \u0026lt;groupId\u0026gt;com.bjpowernode.maven\u0026lt;/groupId\u0026gt; \u0026lt;version\u0026gt;1.0.0\u0026lt;/version\u0026gt; \u0026lt;relativePath\u0026gt;../001-maven-parent/pom.xml\u0026lt;/relativePath\u0026gt; \u0026lt;/parent\u0026gt; \u0026lt;modelVersion\u0026gt;4.0.0\u0026lt;/modelVersion\u0026gt; \u0026lt;artifactId\u0026gt;005-maven-java\u0026lt;/artifactId\u0026gt; \u0026lt;/project\u0026gt; 注意 子模块继承父工程所有依赖 # 比如在父工程添加这块依赖\n\u0026lt;dependencies\u0026gt; \u0026lt;dependency\u0026gt; \u0026lt;groupId\u0026gt;junit\u0026lt;/groupId\u0026gt; \u0026lt;artifactId\u0026gt;junit\u0026lt;/artifactId\u0026gt; \u0026lt;version\u0026gt;4.12\u0026lt;/version\u0026gt; \u0026lt;/dependency\u0026gt; \u0026lt;dependency\u0026gt; \u0026lt;groupId\u0026gt;mysql\u0026lt;/groupId\u0026gt; \u0026lt;artifactId\u0026gt;mysql-connector-java\u0026lt;/artifactId\u0026gt; \u0026lt;version\u0026gt;5.1.46\u0026lt;/version\u0026gt; \u0026lt;/dependency\u0026gt; \u0026lt;dependency\u0026gt; \u0026lt;groupId\u0026gt;com.alibaba\u0026lt;/groupId\u0026gt; \u0026lt;artifactId\u0026gt;dubbo\u0026lt;/artifactId\u0026gt; \u0026lt;version\u0026gt;2.5.3\u0026lt;/version\u0026gt; \u0026lt;/dependency\u0026gt; \u0026lt;/dependencies\u0026gt; 如下 父工程添加的依赖,所有子模块会无条件继承\n父工程管理依赖 # 依赖冗余的问题 加强管理\n\u0026lt;!--加强管理--\u0026gt; \u0026lt;dependencyManagement\u0026gt; \u0026lt;dependencies\u0026gt; \u0026lt;dependency\u0026gt; \u0026lt;groupId\u0026gt;junit\u0026lt;/groupId\u0026gt; \u0026lt;artifactId\u0026gt;junit\u0026lt;/artifactId\u0026gt; \u0026lt;version\u0026gt;4.12\u0026lt;/version\u0026gt; \u0026lt;/dependency\u0026gt; \u0026lt;dependency\u0026gt; \u0026lt;groupId\u0026gt;mysql\u0026lt;/groupId\u0026gt; \u0026lt;artifactId\u0026gt;mysql-connector-java\u0026lt;/artifactId\u0026gt; \u0026lt;version\u0026gt;5.1.46\u0026lt;/version\u0026gt; \u0026lt;/dependency\u0026gt; \u0026lt;dependency\u0026gt; \u0026lt;groupId\u0026gt;com.alibaba\u0026lt;/groupId\u0026gt; \u0026lt;artifactId\u0026gt;dubbo\u0026lt;/artifactId\u0026gt; \u0026lt;version\u0026gt;2.5.3\u0026lt;/version\u0026gt; \u0026lt;/dependency\u0026gt; \u0026lt;/dependencies\u0026gt; \u0026lt;/dependencyManagement\u0026gt; 结果,依赖都没有了 子工程声明式继承父工程依赖 # 比如002-maven-java(子模块,但又是004的父工程)需要mysql\n\u0026lt;dependencies\u0026gt; \u0026lt;dependency\u0026gt; \u0026lt;groupId\u0026gt;mysql\u0026lt;/groupId\u0026gt; \u0026lt;artifactId\u0026gt;mysql-connector-java\u0026lt;/artifactId\u0026gt; \u0026lt;/dependency\u0026gt; \u0026lt;/dependencies\u0026gt; 效果 子模块依赖的版本号继承父工程依赖的版本号 如果子模块指定以来的版本号,那就不会继承父工程依赖的版本号 父工程管理依赖版本号 # 使用properties变量\n\u0026lt;properties\u0026gt; \u0026lt;!--自定义标签名称--\u0026gt; \u0026lt;!--约定:通常管理依赖版本号的标签名:项目名称-字段version, 项目名称.字段version--\u0026gt; \u0026lt;junit-version\u0026gt;4.12\u0026lt;/junit-version\u0026gt; \u0026lt;mysql-connector-java-version\u0026gt;5.1.46\u0026lt;/mysql-connector-java-version\u0026gt; \u0026lt;dubbo-version\u0026gt;2.5.3\u0026lt;/dubbo-version\u0026gt; \u0026lt;/properties\u0026gt; \u0026lt;!--加强管理--\u0026gt; \u0026lt;dependencyManagement\u0026gt; \u0026lt;dependencies\u0026gt; \u0026lt;dependency\u0026gt; \u0026lt;groupId\u0026gt;junit\u0026lt;/groupId\u0026gt; \u0026lt;artifactId\u0026gt;junit\u0026lt;/artifactId\u0026gt; \u0026lt;version\u0026gt;${junit-version}\u0026lt;/version\u0026gt; \u0026lt;/dependency\u0026gt; \u0026lt;dependency\u0026gt; \u0026lt;groupId\u0026gt;mysql\u0026lt;/groupId\u0026gt; \u0026lt;artifactId\u0026gt;mysql-connector-java\u0026lt;/artifactId\u0026gt; \u0026lt;version\u0026gt;${mysql-connector-java-version}\u0026lt;/version\u0026gt; \u0026lt;/dependency\u0026gt; \u0026lt;dependency\u0026gt; \u0026lt;groupId\u0026gt;com.alibaba\u0026lt;/groupId\u0026gt; \u0026lt;artifactId\u0026gt;dubbo\u0026lt;/artifactId\u0026gt; \u0026lt;version\u0026gt;${dubbo-version}\u0026lt;/version\u0026gt; \u0026lt;/dependency\u0026gt; \u0026lt;/dependencies\u0026gt; \u0026lt;/dependencyManagement\u0026gt; 回顾第1种实现方式 # 父工程的要求 子工程的添加 子工程改为父工程 子工程和父工程是平级的 父工程加强管理 \u0026lt;dependencyManagement\u0026gt;\u0026lt;/\u0026lt;dependencyManagement\u0026gt; 注意,第一种方法父工程的pom.xml中,这个也应该是必须的 第2种方式 # 创建父工程 # 最顶层创建一个工程(父工程) pom文件(未处理)\n\u0026lt;?xml version=\u0026#34;1.0\u0026#34; encoding=\u0026#34;UTF-8\u0026#34;?\u0026gt; \u0026lt;project xmlns=\u0026#34;http://maven.apache.org/POM/4.0.0\u0026#34; xmlns:xsi=\u0026#34;http://www.w3.org/2001/XMLSchema-instance\u0026#34; xsi:schemaLocation=\u0026#34;http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd\u0026#34;\u0026gt; \u0026lt;modelVersion\u0026gt;4.0.0\u0026lt;/modelVersion\u0026gt; \u0026lt;groupId\u0026gt;com.bjpowernode.maven\u0026lt;/groupId\u0026gt; \u0026lt;artifactId\u0026gt;maven-parent\u0026lt;/artifactId\u0026gt; \u0026lt;version\u0026gt;1.0.0\u0026lt;/version\u0026gt; \u0026lt;packaging\u0026gt;war\u0026lt;/packaging\u0026gt; \u0026lt;/project\u0026gt; 目录结构(未处理) 处理后 pom.xml\n\u0026lt;?xml version=\u0026#34;1.0\u0026#34; encoding=\u0026#34;UTF-8\u0026#34;?\u0026gt; \u0026lt;project xmlns=\u0026#34;http://maven.apache.org/POM/4.0.0\u0026#34; xmlns:xsi=\u0026#34;http://www.w3.org/2001/XMLSchema-instance\u0026#34; xsi:schemaLocation=\u0026#34;http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd\u0026#34;\u0026gt; \u0026lt;modelVersion\u0026gt;4.0.0\u0026lt;/modelVersion\u0026gt; \u0026lt;groupId\u0026gt;com.bjpowernode.maven\u0026lt;/groupId\u0026gt; \u0026lt;artifactId\u0026gt;maven-parent\u0026lt;/artifactId\u0026gt; \u0026lt;version\u0026gt;1.0.0\u0026lt;/version\u0026gt; \u0026lt;packaging\u0026gt;pom\u0026lt;/packaging\u0026gt; \u0026lt;!-- 1.packaging标签文本内容必须设置为pom 2.删除src目录 --\u0026gt; \u0026lt;/project\u0026gt; 结构 创建子工程 # 子工程的pom.xml\n\u0026lt;?xml version=\u0026#34;1.0\u0026#34; encoding=\u0026#34;UTF-8\u0026#34;?\u0026gt; \u0026lt;project xmlns=\u0026#34;http://maven.apache.org/POM/4.0.0\u0026#34; xmlns:xsi=\u0026#34;http://www.w3.org/2001/XMLSchema-instance\u0026#34; xsi:schemaLocation=\u0026#34;http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd\u0026#34;\u0026gt; \u0026lt;!--指向父工程--\u0026gt; \u0026lt;parent\u0026gt; \u0026lt;artifactId\u0026gt;maven-parent\u0026lt;/artifactId\u0026gt; \u0026lt;groupId\u0026gt;com.bjpowernode.maven\u0026lt;/groupId\u0026gt; \u0026lt;version\u0026gt;1.0.0\u0026lt;/version\u0026gt; \u0026lt;!--注意,这里不需要找pom.xml,因为该子工程和父工程的pom.xml同级--\u0026gt; \u0026lt;/parent\u0026gt; \u0026lt;modelVersion\u0026gt;4.0.0\u0026lt;/modelVersion\u0026gt; \u0026lt;artifactId\u0026gt;maven-java-001\u0026lt;/artifactId\u0026gt; \u0026lt;/project\u0026gt; 父工程的pom.xml\n\u0026lt;?xml version=\u0026#34;1.0\u0026#34; encoding=\u0026#34;UTF-8\u0026#34;?\u0026gt; \u0026lt;project xmlns=\u0026#34;http://maven.apache.org/POM/4.0.0\u0026#34; xmlns:xsi=\u0026#34;http://www.w3.org/2001/XMLSchema-instance\u0026#34; xsi:schemaLocation=\u0026#34;http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd\u0026#34;\u0026gt; \u0026lt;modelVersion\u0026gt;4.0.0\u0026lt;/modelVersion\u0026gt; \u0026lt;groupId\u0026gt;com.bjpowernode.maven\u0026lt;/groupId\u0026gt; \u0026lt;artifactId\u0026gt;maven-parent\u0026lt;/artifactId\u0026gt; \u0026lt;version\u0026gt;1.0.0\u0026lt;/version\u0026gt; \u0026lt;!--父工程包含的所有子模块--\u0026gt; \u0026lt;modules\u0026gt; \u0026lt;module\u0026gt;maven-java-001\u0026lt;/module\u0026gt; \u0026lt;/modules\u0026gt; \u0026lt;packaging\u0026gt;pom\u0026lt;/packaging\u0026gt; \u0026lt;!-- 1.packaging标签文本内容必须设置为pom 2.删除src目录 --\u0026gt; \u0026lt;/project\u0026gt; 第二个子模块\n\u0026lt;?xml version=\u0026#34;1.0\u0026#34; encoding=\u0026#34;UTF-8\u0026#34;?\u0026gt; \u0026lt;project xmlns=\u0026#34;http://maven.apache.org/POM/4.0.0\u0026#34; xmlns:xsi=\u0026#34;http://www.w3.org/2001/XMLSchema-instance\u0026#34; xsi:schemaLocation=\u0026#34;http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd\u0026#34;\u0026gt; \u0026lt;parent\u0026gt; \u0026lt;artifactId\u0026gt;maven-parent\u0026lt;/artifactId\u0026gt; \u0026lt;groupId\u0026gt;com.bjpowernode.maven\u0026lt;/groupId\u0026gt; \u0026lt;version\u0026gt;1.0.0\u0026lt;/version\u0026gt; \u0026lt;/parent\u0026gt; \u0026lt;modelVersion\u0026gt;4.0.0\u0026lt;/modelVersion\u0026gt; \u0026lt;artifactId\u0026gt;maven-web-001\u0026lt;/artifactId\u0026gt; \u0026lt;packaging\u0026gt;war\u0026lt;/packaging\u0026gt; \u0026lt;/project\u0026gt; 父工程pom.xml的变化 创建子工程的子工程 # 父工程必须遵循\npackaging标签文本内容设置为pom 删除src目录 创建子工程 maven-java-001的pom.xml查看\n\u0026lt;?xml version=\u0026#34;1.0\u0026#34; encoding=\u0026#34;UTF-8\u0026#34;?\u0026gt; \u0026lt;project xmlns=\u0026#34;http://maven.apache.org/POM/4.0.0\u0026#34; xmlns:xsi=\u0026#34;http://www.w3.org/2001/XMLSchema-instance\u0026#34; xsi:schemaLocation=\u0026#34;http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd\u0026#34;\u0026gt; \u0026lt;!--指向父工程--\u0026gt; \u0026lt;parent\u0026gt; \u0026lt;artifactId\u0026gt;maven-parent\u0026lt;/artifactId\u0026gt; \u0026lt;groupId\u0026gt;com.bjpowernode.maven\u0026lt;/groupId\u0026gt; \u0026lt;version\u0026gt;1.0.0\u0026lt;/version\u0026gt; \u0026lt;!--注意,这里不需要找pom.xml,因为该子工程和父工程的pom.xml同级--\u0026gt; \u0026lt;/parent\u0026gt; \u0026lt;modelVersion\u0026gt;4.0.0\u0026lt;/modelVersion\u0026gt; \u0026lt;artifactId\u0026gt;maven-java-001\u0026lt;/artifactId\u0026gt; \u0026lt;packaging\u0026gt;pom\u0026lt;/packaging\u0026gt; \u0026lt;modules\u0026gt; \u0026lt;module\u0026gt;maven-java-0101\u0026lt;/module\u0026gt; \u0026lt;/modules\u0026gt; \u0026lt;/project\u0026gt; 子模块的pom.xml\n\u0026lt;?xml version=\u0026#34;1.0\u0026#34; encoding=\u0026#34;UTF-8\u0026#34;?\u0026gt; \u0026lt;project xmlns=\u0026#34;http://maven.apache.org/POM/4.0.0\u0026#34; xmlns:xsi=\u0026#34;http://www.w3.org/2001/XMLSchema-instance\u0026#34; xsi:schemaLocation=\u0026#34;http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd\u0026#34;\u0026gt; \u0026lt;parent\u0026gt; \u0026lt;artifactId\u0026gt;maven-java-001\u0026lt;/artifactId\u0026gt; \u0026lt;groupId\u0026gt;com.bjpowernode.maven\u0026lt;/groupId\u0026gt; \u0026lt;version\u0026gt;1.0.0\u0026lt;/version\u0026gt; \u0026lt;/parent\u0026gt; \u0026lt;modelVersion\u0026gt;4.0.0\u0026lt;/modelVersion\u0026gt; \u0026lt;artifactId\u0026gt;maven-java-0101\u0026lt;/artifactId\u0026gt; \u0026lt;/project\u0026gt; 父工程管理依赖 # 父工程的pom文件 子模块也一起继承了 父工程管理所有依赖 如果子工程需要,则使用声明式依赖 也可以自己指定版本号 父工程管理依赖的版本号 # 使用properties管理版本号,和第一种方式一样 子工程继承父工程编译插件 # 修改之后,这里为了看效果,改成1.6\n\u0026lt;build\u0026gt; \u0026lt;plugins\u0026gt; \u0026lt;plugin\u0026gt; \u0026lt;groupId\u0026gt;org.apache.maven.plugins\u0026lt;/groupId\u0026gt; \u0026lt;artifactId\u0026gt;maven-compiler-plugin\u0026lt;/artifactId\u0026gt; \u0026lt;version\u0026gt;3.8.1\u0026lt;/version\u0026gt; \u0026lt;configuration\u0026gt; \u0026lt;source\u0026gt;1.6\u0026lt;/source\u0026gt; \u0026lt;target\u0026gt;1.6\u0026lt;/target\u0026gt; \u0026lt;/configuration\u0026gt; \u0026lt;/plugin\u0026gt; \u0026lt;/plugins\u0026gt; \u0026lt;/build\u0026gt; 第3种方式 # 前面两种混合使用\n先创建一个空项目 然后假设有三个父工程 然后每个父工程又都有子模块 "},{"id":367,"href":"/zh/docs/technology/Maven/base_dljd_/31-43/","title":"31-43 maven基础_动力节点","section":"基础(动力节点)_","content":" idea中设置maven # 和idea集成maven 创建普通的j2se项目 # 使用idea创建空白项目 新建一个module 使用模板创建普通java项目 输入gav 设置maven信息 标准的maven工程 与创建网站有关,删掉即可\n\u0026lt;?xml version=\u0026#34;1.0\u0026#34; encoding=\u0026#34;UTF-8\u0026#34;?\u0026gt; \u0026lt;project xmlns=\u0026#34;http://maven.apache.org/POM/4.0.0\u0026#34; xmlns:xsi=\u0026#34;http://www.w3.org/2001/XMLSchema-instance\u0026#34; xsi:schemaLocation=\u0026#34;http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd\u0026#34;\u0026gt; \u0026lt;modelVersion\u0026gt;4.0.0\u0026lt;/modelVersion\u0026gt; \u0026lt;groupId\u0026gt;com.bjpowernode\u0026lt;/groupId\u0026gt; \u0026lt;artifactId\u0026gt;ch01-maven-j2se\u0026lt;/artifactId\u0026gt; \u0026lt;version\u0026gt;1.0\u0026lt;/version\u0026gt; \u0026lt;!--设置网站,注释掉即可--\u0026gt; \u0026lt;!-- \u0026lt;name\u0026gt;ch01-maven-j2se\u0026lt;/name\u0026gt; \u0026lt;!– FIXME change it to the project\u0026#39;s website –\u0026gt; \u0026lt;url\u0026gt;http://www.example.com\u0026lt;/url\u0026gt;--\u0026gt; \u0026lt;properties\u0026gt; \u0026lt;!--maven常用设置--\u0026gt; \u0026lt;project.build.sourceEncoding\u0026gt;UTF-8\u0026lt;/project.build.sourceEncoding\u0026gt; \u0026lt;maven.compiler.source\u0026gt;1.8\u0026lt;/maven.compiler.source\u0026gt; \u0026lt;maven.compiler.target\u0026gt;1.8\u0026lt;/maven.compiler.target\u0026gt; \u0026lt;/properties\u0026gt; \u0026lt;dependencies\u0026gt; \u0026lt;dependency\u0026gt;\u0026lt;!--单元测试--\u0026gt; \u0026lt;groupId\u0026gt;junit\u0026lt;/groupId\u0026gt; \u0026lt;artifactId\u0026gt;junit\u0026lt;/artifactId\u0026gt; \u0026lt;version\u0026gt;4.11\u0026lt;/version\u0026gt; \u0026lt;scope\u0026gt;test\u0026lt;/scope\u0026gt; \u0026lt;/dependency\u0026gt; \u0026lt;/dependencies\u0026gt; \u0026lt;build\u0026gt; \u0026lt;!--插件版本的配置,无特殊指定则删除--\u0026gt; \u0026lt;pluginManagement\u0026gt;\u0026lt;!-- lock down plugins versions to avoid using Maven defaults (may be moved to parent pom) --\u0026gt; \u0026lt;plugins\u0026gt; \u0026lt;!-- clean lifecycle, see https://maven.apache.org/ref/current/maven-core/lifecycles.html#clean_Lifecycle --\u0026gt; \u0026lt;plugin\u0026gt; \u0026lt;artifactId\u0026gt;maven-clean-plugin\u0026lt;/artifactId\u0026gt; \u0026lt;version\u0026gt;3.1.0\u0026lt;/version\u0026gt; \u0026lt;/plugin\u0026gt; \u0026lt;!-- default lifecycle, jar packaging: see https://maven.apache.org/ref/current/maven-core/default-bindings.html#Plugin_bindings_for_jar_packaging --\u0026gt; \u0026lt;plugin\u0026gt; \u0026lt;artifactId\u0026gt;maven-resources-plugin\u0026lt;/artifactId\u0026gt; \u0026lt;version\u0026gt;3.0.2\u0026lt;/version\u0026gt; \u0026lt;/plugin\u0026gt; \u0026lt;plugin\u0026gt; \u0026lt;artifactId\u0026gt;maven-compiler-plugin\u0026lt;/artifactId\u0026gt; \u0026lt;version\u0026gt;3.8.0\u0026lt;/version\u0026gt; \u0026lt;/plugin\u0026gt; \u0026lt;plugin\u0026gt; \u0026lt;artifactId\u0026gt;maven-surefire-plugin\u0026lt;/artifactId\u0026gt; \u0026lt;version\u0026gt;2.22.1\u0026lt;/version\u0026gt; \u0026lt;/plugin\u0026gt; \u0026lt;plugin\u0026gt; \u0026lt;artifactId\u0026gt;maven-jar-plugin\u0026lt;/artifactId\u0026gt; \u0026lt;version\u0026gt;3.0.2\u0026lt;/version\u0026gt; \u0026lt;/plugin\u0026gt; \u0026lt;plugin\u0026gt; \u0026lt;artifactId\u0026gt;maven-install-plugin\u0026lt;/artifactId\u0026gt; \u0026lt;version\u0026gt;2.5.2\u0026lt;/version\u0026gt; \u0026lt;/plugin\u0026gt; \u0026lt;plugin\u0026gt; \u0026lt;artifactId\u0026gt;maven-deploy-plugin\u0026lt;/artifactId\u0026gt; \u0026lt;version\u0026gt;2.8.2\u0026lt;/version\u0026gt; \u0026lt;/plugin\u0026gt; \u0026lt;!-- site lifecycle, see https://maven.apache.org/ref/current/maven-core/lifecycles.html#site_Lifecycle --\u0026gt; \u0026lt;plugin\u0026gt; \u0026lt;artifactId\u0026gt;maven-site-plugin\u0026lt;/artifactId\u0026gt; \u0026lt;version\u0026gt;3.7.1\u0026lt;/version\u0026gt; \u0026lt;/plugin\u0026gt; \u0026lt;plugin\u0026gt; \u0026lt;artifactId\u0026gt;maven-project-info-reports-plugin\u0026lt;/artifactId\u0026gt; \u0026lt;version\u0026gt;3.0.0\u0026lt;/version\u0026gt; \u0026lt;/plugin\u0026gt; \u0026lt;/plugins\u0026gt; \u0026lt;/pluginManagement\u0026gt; \u0026lt;/build\u0026gt; \u0026lt;/project\u0026gt; 单元测试 # 关于idea颜色 编写java程序\npackage com.bjpowernode; public class HelloMaven { public int addNumber(int n1,int n2){ return n1+n2; } public static void main(String[] args) { HelloMaven helloMaven=new HelloMaven(); int res=helloMaven.addNumber(10,20); System.out.println(\u0026#34;res = \u0026#34;+res); } } 测试使用 idea中maven工具窗口 # Maven生成的目录 使用mvn clean进行清理\nλ mvn clean [INFO] Scanning for projects... [INFO] [INFO] ------------------\u0026lt; com.bjpowernode:ch01-maven-j2se \u0026gt;------------------- [INFO] Building ch01-maven-j2se 1.0 [INFO] --------------------------------[ jar ]--------------------------------- [INFO] [INFO] --- maven-clean-plugin:2.5:clean (default-clean) @ ch01-maven-j2se --- [INFO] Deleting D:\\Users\\ly\\Documents\\git\\mavenwork\\04-project\\ch01-maven-j2se\\target [INFO] ------------------------------------------------------------------------ [INFO] BUILD SUCCESS [INFO] ------------------------------------------------------------------------ [INFO] Total time: 0.438 s [INFO] Finished at: 2022-07-13T23:39:03+08:00 [INFO] ------------------------------------------------------------------------ 窗口 单元测试 打包 install安装 其他 重新更新依赖项 创建web项目加入servlet依赖 # 结构 创建java文件夹和资源文件夹 pom文件\n\u0026lt;?xml version=\u0026#34;1.0\u0026#34; encoding=\u0026#34;UTF-8\u0026#34;?\u0026gt; \u0026lt;project xmlns=\u0026#34;http://maven.apache.org/POM/4.0.0\u0026#34; xmlns:xsi=\u0026#34;http://www.w3.org/2001/XMLSchema-instance\u0026#34; xsi:schemaLocation=\u0026#34;http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd\u0026#34;\u0026gt; \u0026lt;modelVersion\u0026gt;4.0.0\u0026lt;/modelVersion\u0026gt; \u0026lt;groupId\u0026gt;com.bjpowernode\u0026lt;/groupId\u0026gt; \u0026lt;artifactId\u0026gt;ch02-maven-web\u0026lt;/artifactId\u0026gt; \u0026lt;version\u0026gt;1.0\u0026lt;/version\u0026gt; \u0026lt;packaging\u0026gt;war\u0026lt;/packaging\u0026gt; \u0026lt;name\u0026gt;ch02-maven-web Maven Webapp\u0026lt;/name\u0026gt; \u0026lt;!-- FIXME change it to the project\u0026#39;s website --\u0026gt; \u0026lt;url\u0026gt;http://www.example.com\u0026lt;/url\u0026gt; \u0026lt;properties\u0026gt; \u0026lt;project.build.sourceEncoding\u0026gt;UTF-8\u0026lt;/project.build.sourceEncoding\u0026gt; \u0026lt;maven.compiler.source\u0026gt;1.8\u0026lt;/maven.compiler.source\u0026gt; \u0026lt;maven.compiler.target\u0026gt;1.8\u0026lt;/maven.compiler.target\u0026gt; \u0026lt;/properties\u0026gt; \u0026lt;dependencies\u0026gt; \u0026lt;!--servlet依赖--\u0026gt; \u0026lt;!-- https://mvnrepository.com/artifact/javax.servlet/javax.servlet-api --\u0026gt; \u0026lt;dependency\u0026gt; \u0026lt;groupId\u0026gt;javax.servlet\u0026lt;/groupId\u0026gt; \u0026lt;artifactId\u0026gt;javax.servlet-api\u0026lt;/artifactId\u0026gt; \u0026lt;version\u0026gt;4.0.1\u0026lt;/version\u0026gt; \u0026lt;scope\u0026gt;provided\u0026lt;/scope\u0026gt; \u0026lt;/dependency\u0026gt; \u0026lt;!--jsp依赖--\u0026gt; \u0026lt;!-- https://mvnrepository.com/artifact/javax.servlet.jsp/javax.servlet.jsp-api --\u0026gt; \u0026lt;dependency\u0026gt; \u0026lt;groupId\u0026gt;javax.servlet.jsp\u0026lt;/groupId\u0026gt; \u0026lt;artifactId\u0026gt;javax.servlet.jsp-api\u0026lt;/artifactId\u0026gt; \u0026lt;version\u0026gt;2.3.3\u0026lt;/version\u0026gt; \u0026lt;scope\u0026gt;provided\u0026lt;/scope\u0026gt; \u0026lt;/dependency\u0026gt; \u0026lt;!--junit--\u0026gt; \u0026lt;dependency\u0026gt; \u0026lt;groupId\u0026gt;junit\u0026lt;/groupId\u0026gt; \u0026lt;artifactId\u0026gt;junit\u0026lt;/artifactId\u0026gt; \u0026lt;version\u0026gt;4.11\u0026lt;/version\u0026gt; \u0026lt;scope\u0026gt;test\u0026lt;/scope\u0026gt; \u0026lt;/dependency\u0026gt; \u0026lt;/dependencies\u0026gt; \u0026lt;/project\u0026gt; 和上面进行对比 创建servlet # 创建完之后\n代码\npackage com.bjpowernode.controller; import javax.servlet.*; import javax.servlet.http.*; import java.io.IOException; public class HelloServlet extends HttpServlet { @Override protected void doGet(HttpServletRequest request, HttpServletResponse response) throws ServletException, IOException { } @Override protected void doPost(HttpServletRequest request, HttpServletResponse response) throws ServletException, IOException { } } web.xml\n\u0026lt;!DOCTYPE web-app PUBLIC \u0026#34;-//Sun Microsystems, Inc.//DTD Web Application 2.3//EN\u0026#34; \u0026#34;http://java.sun.com/dtd/web-app_2_3.dtd\u0026#34; \u0026gt; \u0026lt;web-app\u0026gt; \u0026lt;display-name\u0026gt;Archetype Created Web Application\u0026lt;/display-name\u0026gt; \u0026lt;servlet\u0026gt; \u0026lt;servlet-name\u0026gt;HelloServlet\u0026lt;/servlet-name\u0026gt; \u0026lt;servlet-class\u0026gt;com.bjpowernode.controller.HelloServlet\u0026lt;/servlet-class\u0026gt; \u0026lt;/servlet\u0026gt; \u0026lt;/web-app\u0026gt; 添加mapping\n\u0026lt;!DOCTYPE web-app PUBLIC \u0026#34;-//Sun Microsystems, Inc.//DTD Web Application 2.3//EN\u0026#34; \u0026#34;http://java.sun.com/dtd/web-app_2_3.dtd\u0026#34; \u0026gt; \u0026lt;web-app\u0026gt; \u0026lt;display-name\u0026gt;Archetype Created Web Application\u0026lt;/display-name\u0026gt; \u0026lt;servlet\u0026gt; \u0026lt;servlet-name\u0026gt;HelloServlet\u0026lt;/servlet-name\u0026gt; \u0026lt;servlet-class\u0026gt;com.bjpowernode.controller.HelloServlet\u0026lt;/servlet-class\u0026gt; \u0026lt;/servlet\u0026gt; \u0026lt;servlet-mapping\u0026gt; \u0026lt;servlet-name\u0026gt;HelloServlet\u0026lt;/servlet-name\u0026gt; \u0026lt;url-pattern\u0026gt;/hello\u0026lt;/url-pattern\u0026gt; \u0026lt;/servlet-mapping\u0026gt; \u0026lt;/web-app\u0026gt; 添加jsp\n\u0026lt;%-- Created by IntelliJ IDEA. User: ly Date: 2022/7/16 Time: 18:10 To change this template use File | Settings | File Templates. --%\u0026gt; \u0026lt;%@ page contentType=\u0026#34;text/html;charset=UTF-8\u0026#34; language=\u0026#34;java\u0026#34; %\u0026gt; \u0026lt;html\u0026gt; \u0026lt;head\u0026gt; \u0026lt;title\u0026gt;index\u0026lt;/title\u0026gt; \u0026lt;/head\u0026gt; \u0026lt;body\u0026gt; \u0026lt;a href=\u0026#34;hello\u0026#34; \u0026gt;访问\u0026lt;/a\u0026gt; \u0026lt;/body\u0026gt; \u0026lt;/html\u0026gt; 设置转发\npackage com.bjpowernode.controller; import javax.servlet.*; import javax.servlet.http.*; import java.io.IOException; public class HelloServlet extends HttpServlet { @Override protected void doGet(HttpServletRequest request, HttpServletResponse response) throws ServletException, IOException { System.out.println(\u0026#34;收到请求了\u0026#34;); //转发到show request.getRequestDispatcher(\u0026#34;/show.jsp\u0026#34;) .forward(request,response); } @Override protected void doPost(HttpServletRequest request, HttpServletResponse response) throws ServletException, IOException { } } 设置tomcat并发布\nidea出现not found for the web module. 复习核心的概念 # 约定的目录结构 pom 项目对象模型,groupId,artifactId,version gav 仓库 本地仓库 \u0026hellip;../.m2/repository 远程仓库 生命周期,clean,compile,test-compile,test,package,install maven和idea集成 设置maven安装目录和配置文件 设置Runner,创建maven时速度快 使用模板创建 se和web 导入模块到idea # 导入02这个项目 结果 当磁盘中文件夹名字和项目名不一样时 如果导入后颜色不对,则需要右键 mark as scope依赖范围 # scope标签\n依赖范围:scope标签,这个依赖在项目构建的哪个阶段起作用\n值:compile,默认,参与构建项目的所有阶段; test:测试,在测试阶段使用,比如执行mvn test 会使用junit provided:提供者,项目在部署到服务器时,不需要提供这个依赖的jar,而是由服务器提供这个以来的jar包 打包时只有mysql war文件 给服务器,即放到tomcat的webapps中 启动tomcat之后,会自动解压 访问 自定义变量 # properties标签,常用设置 test报告 这种需要将文件夹删除,然后reimport 全局变量,比如依赖版本号 重复的问题 在properties里面定义即可 使用全局变量 ${变量名} 处理文件的默认规则 # 使用资源插件 例子 放置三个文件 进行四个操作,会生成资源文件(src/resources)拷贝到target/classes目录下 如果在java下的包中放资源文件 没有拷贝 即maven只处理src/main/java目录下的.java文件,把这些编译成class,拷贝到target/classes目录中,不处理其他文件 资源插件 # build下\n\u0026lt;build\u0026gt; \u0026lt;!--资源插件--\u0026gt; \u0026lt;resources\u0026gt; \u0026lt;resource\u0026gt; \u0026lt;!--所在的目录--\u0026gt; \u0026lt;directory\u0026gt;src/main/java\u0026lt;/directory\u0026gt; \u0026lt;!--包括properties及xml后缀文件--\u0026gt; \u0026lt;includes\u0026gt; \u0026lt;include\u0026gt;**/*.properties\u0026lt;/include\u0026gt; \u0026lt;include\u0026gt;**/*.xml\u0026lt;/include\u0026gt; \u0026lt;include\u0026gt;**/*.txt\u0026lt;/include\u0026gt; \u0026lt;include\u0026gt;**/*\u0026lt;/include\u0026gt; \u0026lt;/includes\u0026gt; \u0026lt;excludes\u0026gt; \u0026lt;exclude\u0026gt;**/*.java\u0026lt;/exclude\u0026gt; \u0026lt;/excludes\u0026gt; \u0026lt;!--不使用过滤器,*.xml已经起到过滤作用了--\u0026gt; \u0026lt;filtering\u0026gt;false\u0026lt;/filtering\u0026gt; \u0026lt;/resource\u0026gt; \u0026lt;/resources\u0026gt; \u0026lt;/resources\u0026gt; \u0026lt;/build\u0026gt; 结果 "},{"id":368,"href":"/zh/docs/technology/Maven/base_dljd_/17-30/","title":"17-30 maven基础_动力节点","section":"基础(动力节点)_","content":" 本地仓库的设置 # 远程仓库\u0026ndash;\u0026gt;本地仓库\nmaven仓库\n存放maven工具自己的jar包 第三方jar,比如mysql驱动 自己写的程序,可以打包为jar,存放到仓库 分类\n本地仓库(本机):位于自己计算机中,磁盘中某个目录\n默认位置 登录操作系统的账号目录/.m2/repository C:\\Users\\ly.m2\\repository\n可修改 比如放在d盘中\n英[rɪˈpɒzətri] D:\\software\\apache-maven-3.8.6\\repository 备份并编辑 改成左斜杠的方式\n\u0026lt;settings xmlns=\u0026#34;http://maven.apache.org/SETTINGS/1.2.0\u0026#34; xmlns:xsi=\u0026#34;http://www.w3.org/2001/XMLSchema-instance\u0026#34; xsi:schemaLocation=\u0026#34;http://maven.apache.org/SETTINGS/1.2.0 https://maven.apache.org/xsd/settings-1.2.0.xsd\u0026#34;\u0026gt; \u0026lt;!-- localRepository | The path to the local repository maven will use to store artifacts. | | Default: ${user.home}/.m2/repository \u0026lt;localRepository\u0026gt;/path/to/local/repo\u0026lt;/localRepository\u0026gt; --\u0026gt; \u0026lt;localRepository\u0026gt;D:/software/apache-maven-3.8.6/repository\u0026lt;/localRepository\u0026gt; 把之前user下的repository的文件都拷贝到 D:/software/apache-maven-3.8.6/repository 下 然后再对Hello项目进行编译 mvn compile 发现不会下载任何文件,且user下的repository也不会再进行下载\n下面的资源是从maven中下载,或者用maven打包的 pom.xml来说明某个项目需要怎么处理代码、项目结构\n\u0026lt;?xml version=\u0026#34;1.0\u0026#34; encoding=\u0026#34;UTF-8\u0026#34; ?\u0026gt; \u0026lt;project xmlns=\u0026#34;http://maven.apache.org/POM/4.0.0\u0026#34; xmlns:xsi=\u0026#34;http://www.w3.org/2001/XMLSchema-instance\u0026#34; xsi:schemaLocation=\u0026#34;http://maven.apache.org/POM/4.0.0 http://maven.apache.org/maven-v4_0_0.xsd\u0026#34;\u0026gt; \u0026lt;modelVersion\u0026gt;4.0.0\u0026lt;/modelVersion\u0026gt; \u0026lt;groupId\u0026gt;com.bjpowernode\u0026lt;/groupId\u0026gt; \u0026lt;artifactId\u0026gt;ch01-maven\u0026lt;/artifactId\u0026gt; \u0026lt;version\u0026gt;1.0-SNAPSHOT\u0026lt;/version\u0026gt; \u0026lt;packaging\u0026gt;jar\u0026lt;/packaging\u0026gt; \u0026lt;properties\u0026gt; \u0026lt;java.version\u0026gt;1.8\u0026lt;/java.version\u0026gt; \u0026lt;maven.compiler.source\u0026gt;1.8\u0026lt;/maven.compiler.source\u0026gt; \u0026lt;maven.compiler.target\u0026gt;1.8\u0026lt;/maven.compiler.target\u0026gt; \u0026lt;/properties\u0026gt; \u0026lt;dependencies\u0026gt; \u0026lt;dependency\u0026gt; \u0026lt;groupId\u0026gt;mysql\u0026lt;/groupId\u0026gt; \u0026lt;artifactId\u0026gt;mysql-connector-java\u0026lt;/artifactId\u0026gt; \u0026lt;version\u0026gt;5.1.9\u0026lt;/version\u0026gt; \u0026lt;/dependency\u0026gt; \u0026lt;/dependencies\u0026gt; \u0026lt;/project\u0026gt; mvn命令需要在pom.xml所在的目录下执行 仓库的工作方式 # 生命周期插件命令 # 包括 清理(删除target文件,但是不处理已经install的jar)、编译(当前目录生成target目录,放置编译主程序之后生成的字节码)、测试(生成surefire-reports,保存测试结果)、报告、打包(打包主程序[编译、编译测试、测试,并按照pom.xml配置把主程序打包成jar包或war包])、安装(把本工程打包,并按照工程坐标保存到本地仓库中)、部署(打包,保存到本地仓库,并保存到私服中,且自动把项目部署到web容器中) 插件:要完成构建项目的各个阶段,要使用maven的命令,执行命令的功能,是通过插件完成的 插件就是jar,一些类 命令:执行maven功能,通过命令发出,比如mvn compile(编译时由相关的类来操作) junit使用 # 单元测试 junit:单元测试的工具,java中经常使用 单元,java中指的是方法,方法就是一个单元,方法是测试的最小单位\n作用,使用junit去测试方法是否完成了要求,开发人员自测\n使用单元测试\n加入junit的依赖(需要用他的类和方法)\n\u0026lt;dependency\u0026gt; \u0026lt;groupId\u0026gt;junit\u0026lt;/groupId\u0026gt; \u0026lt;artifactId\u0026gt;junit\u0026lt;/artifactId\u0026gt; \u0026lt;version\u0026gt;4.12\u0026gt; \u0026lt;scope\u0026gt;test\u0026lt;/scope\u0026gt; \u0026lt;/dependency\u0026gt; 在src/test/java目录中创建测试类文件,写测试代码\n测试类的定义,名称一般是Test+要测试的类名称 测试它的包名和要测试的类包名一样 在类中定义方法,要测试的代码 方法定义:public方法,没有返回值,名称自定义(建议Test+测试的方法名称) 方法没有参数 测试类中的方法,可以单独执行,测试类也可以单独执行 在该方法上面加入注解@Test 注意:mvn compile的时候,会下载3.8.2的jar包 创建测试类和测试方法 # package com.bjpowernode; //导入包 import org.junit.Assert; import org.junit.Test; public class TestHelloMaven{ //定义多个独立的测试方法,每个方法都是独立的 public void testAddNumber(){ System.out.println(\u0026#34;执行了测试方法testAddNumber\u0026#34;); HelloMaven hello=new HelloMaven(); int res=hello.addNumber(10,20); //把计算结果res交给junit判断 //期望值,实际值 Assert.assertEquals(30,res); } } 相关命令 # mvn clean ,清理,删除以前生成的数据(删除target目录) 插件及版本 maven-clean-plugin:2.5\nd:\\Users\\ly\\Documents\\git\\mavenwork\\Hello\u0026gt;mvn clean [INFO] Scanning for projects... [INFO] [INFO] ---------------------\u0026lt; com.bjpowernode:ch01-maven \u0026gt;--------------------- [INFO] Building ch01-maven 1.0-SNAPSHOT [INFO] --------------------------------[ jar ]--------------------------------- [INFO] [INFO] --- maven-clean-plugin:2.5:clean (default-clean) @ ch01-maven --- [INFO] Deleting d:\\Users\\ly\\Documents\\git\\mavenwork\\Hello\\target [INFO] ------------------------------------------------------------------------ [INFO] BUILD SUCCESS [INFO] ------------------------------------------------------------------------ [INFO] Total time: 0.354 s [INFO] Finished at: 2022-07-09T17:03:46+08:00 [INFO] ------------------------------------------------------------------------ 代码的编译 mvn compile:编译命令,把src/main/java 目录中的java代码编译为class文件 同时把class文件拷贝到target/classes目录,这个目录classes是存放类文件的根目录(也叫做类路径,classpath)\n编译后放到target\\classes中 插件:maven-compiler-plugin:3.1 编译代码 maven-resources-plugin:2.6:resources 资源插件,作用是把src/main/resources目录中的文件拷贝到target/classes 目录中\nλ mvn compile [INFO] Scanning for projects... [INFO] [INFO] ---------------------\u0026lt; com.bjpowernode:ch01-maven \u0026gt;--------------------- [INFO] Building ch01-maven 1.0-SNAPSHOT [INFO] --------------------------------[ jar ]--------------------------------- [INFO] [INFO] --- maven-resources-plugin:2.6:resources (default-resources) @ ch01-maven --- [WARNING] Using platform encoding (GBK actually) to copy filtered resources, i.e. build is platform dependent![INFO] Copying 1 resource [INFO] [INFO] --- maven-compiler-plugin:3.1:compile (default-compile) @ ch01-maven --- [INFO] Changes detected - recompiling the module! [WARNING] File encoding has not been set, using platform encoding GBK, i.e. build is platform dependent! [INFO] Compiling 1 source file to D:\\Users\\ly\\Documents\\git\\mavenwork\\Hello\\target\\classes [INFO] ------------------------------------------------------------------------ [INFO] BUILD SUCCESS [INFO] ------------------------------------------------------------------------ [INFO] Total time: 3.164 s [INFO] Finished at: 2022-07-09T17:20:30+08:00 [INFO] ------------------------------------------------------------------------ 测试resources插件 mvn test-compile:编译命令,编译src/test/java 目录中的源文件,把生成的class拷贝到target/test-classes目录中,同时把src/test/resources目录中的文件拷贝到test-classes目录 命令执行前 执行后 插件 maven-resources-plugin:2.6:resources maven-compiler-plugin:3.1:compile maven-resources-plugin:2.6:testResources maven-compiler-plugin:3.1:testCompile\nλ mvn test-compile [INFO] Scanning for projects... [INFO] [INFO] ---------------------\u0026lt; com.bjpowernode:ch01-maven \u0026gt;--------------------- [INFO] Building ch01-maven 1.0-SNAPSHOT [INFO] --------------------------------[ jar ]--------------------------------- Downloading from central: https://repo.maven.apache.org/maven2/junit/junit/4.12/junit-4.12.jar Downloading from central: https://repo.maven.apache.org/maven2/org/hamcrest/hamcrest-core/1.3/hamcrest-core-1.3.jar Downloaded from central: https://repo.maven.apache.org/maven2/org/hamcrest/hamcrest-core/1.3/hamcrest-core-1.3.jar (45 kB at 24 kB/s) Downloaded from central: https://repo.maven.apache.org/maven2/junit/junit/4.12/junit-4.12.jar (315 kB at 118 kB/s) [INFO] [INFO] --- maven-resources-plugin:2.6:resources (default-resources) @ ch01-maven --- [WARNING] Using platform encoding (GBK actually) to copy filtered resources, i.e. build is platform dependent![INFO] Copying 2 resources [INFO] [INFO] --- maven-compiler-plugin:3.1:compile (default-compile) @ ch01-maven --- [INFO] Nothing to compile - all classes are up to date [INFO] [INFO] --- maven-resources-plugin:2.6:testResources (default-testResources) @ ch01-maven --- [WARNING] Using platform encoding (GBK actually) to copy filtered resources, i.e. build is platform dependent![INFO] Copying 1 resource [INFO] [INFO] --- maven-compiler-plugin:3.1:testCompile (default-testCompile) @ ch01-maven --- [INFO] Changes detected - recompiling the module! [WARNING] File encoding has not been set, using platform encoding GBK, i.e. build is platform dependent! [INFO] Compiling 1 source file to D:\\Users\\ly\\Documents\\git\\mavenwork\\Hello\\target\\test-classes [WARNING] /D:/Users/ly/Documents/git/mavenwork/Hello/src/test/java/com/bjpowernode/TestHelloMaven.java:[2,7] 编码GBK的不可映射字符 [WARNING] /D:/Users/ly/Documents/git/mavenwork/Hello/src/test/java/com/bjpowernode/TestHelloMaven.java:[8,42] 编码GBK的不可映射字符 [WARNING] /D:/Users/ly/Documents/git/mavenwork/Hello/src/test/java/com/bjpowernode/TestHelloMaven.java:[14,29] 编码GBK的不可映射字符 [INFO] ------------------------------------------------------------------------ [INFO] BUILD SUCCESS [INFO] ------------------------------------------------------------------------ [INFO] Total time: 8.085 s [INFO] Finished at: 2022-07-09T17:28:14+08:00 [INFO] ------------------------------------------------------------------------ mvn test 测试命令,执行test-classes目录的程序,测试src/main/java目录中的主程序是否符合要求 注意,这里还是会用到编译插件和资源插件,从 T E S T S 开始测试 结果Results :\nTests run: 1, Failures: 0, Errors: 0, Skipped: 0 测试插件 maven-surefire-plugin:2.12.4\nλ mvn test [INFO] Scanning for projects... [INFO] [INFO] ---------------------\u0026lt; com.bjpowernode:ch01-maven \u0026gt;--------------------- [INFO] Building ch01-maven 1.0-SNAPSHOT [INFO] --------------------------------[ jar ]--------------------------------- [INFO] [INFO] --- maven-resources-plugin:2.6:resources (default-resources) @ ch01-maven --- [WARNING] Using platform encoding (GBK actually) to copy filtered resources, i.e. build is platform dependent![INFO] Copying 2 resources [INFO] [INFO] --- maven-compiler-plugin:3.1:compile (default-compile) @ ch01-maven --- [INFO] Nothing to compile - all classes are up to date [INFO] [INFO] --- maven-resources-plugin:2.6:testResources (default-testResources) @ ch01-maven --- [WARNING] Using platform encoding (GBK actually) to copy filtered resources, i.e. build is platform dependent![INFO] Copying 1 resource [INFO] [INFO] --- maven-compiler-plugin:3.1:testCompile (default-testCompile) @ ch01-maven --- [INFO] Changes detected - recompiling the module! [WARNING] File encoding has not been set, using platform encoding GBK, i.e. build is platform dependent! [INFO] Compiling 1 source file to D:\\Users\\ly\\Documents\\git\\mavenwork\\Hello\\target\\test-classes [INFO] [INFO] --- maven-surefire-plugin:2.12.4:test (default-test) @ ch01-maven --- [INFO] Surefire report directory: D:\\Users\\ly\\Documents\\git\\mavenwork\\Hello\\target\\surefire-reports ------------------------------------------------------- T E S T S ------------------------------------------------------- Running com.bjpowernode.TestHelloMaven 执行了测试方法testAddNumber hello maven -addNumber Tests run: 1, Failures: 0, Errors: 0, Skipped: 0, Time elapsed: 0.131 sec Results : Tests run: 1, Failures: 0, Errors: 0, Skipped: 0 [INFO] ------------------------------------------------------------------------ [INFO] BUILD SUCCESS [INFO] ------------------------------------------------------------------------ [INFO] Total time: 4.630 s [INFO] Finished at: 2022-07-09T17:32:49+08:00 [INFO] ------------------------------------------------------------------------ 测试报告 测试失败的情况 结果\nλ mvn test [INFO] Scanning for projects... [INFO] [INFO] ---------------------\u0026lt; com.bjpowernode:ch01-maven \u0026gt;--------------------- [INFO] Building ch01-maven 1.0-SNAPSHOT [INFO] --------------------------------[ jar ]--------------------------------- [INFO] [INFO] --- maven-resources-plugin:2.6:resources (default-resources) @ ch01-maven --- [WARNING] Using platform encoding (GBK actually) to copy filtered resources, i.e. build is platform dependent![INFO] Copying 2 resources [INFO] [INFO] --- maven-compiler-plugin:3.1:compile (default-compile) @ ch01-maven --- [INFO] Nothing to compile - all classes are up to date [INFO] [INFO] --- maven-resources-plugin:2.6:testResources (default-testResources) @ ch01-maven --- [WARNING] Using platform encoding (GBK actually) to copy filtered resources, i.e. build is platform dependent![INFO] Copying 1 resource [INFO] [INFO] --- maven-compiler-plugin:3.1:testCompile (default-testCompile) @ ch01-maven --- [INFO] Changes detected - recompiling the module! [WARNING] File encoding has not been set, using platform encoding GBK, i.e. build is platform dependent! [INFO] Compiling 1 source file to D:\\Users\\ly\\Documents\\git\\mavenwork\\Hello\\target\\test-classes [INFO] [INFO] --- maven-surefire-plugin:2.12.4:test (default-test) @ ch01-maven --- [INFO] Surefire report directory: D:\\Users\\ly\\Documents\\git\\mavenwork\\Hello\\target\\surefire-reports ------------------------------------------------------- T E S T S ------------------------------------------------------- Running com.bjpowernode.TestHelloMaven 执行了测试方法testAddNumber hello maven -addNumber Tests run: 1, Failures: 1, Errors: 0, Skipped: 0, Time elapsed: 0.217 sec \u0026lt;\u0026lt;\u0026lt; FAILURE! testAddNumber(com.bjpowernode.TestHelloMaven) Time elapsed: 0.043 sec \u0026lt;\u0026lt;\u0026lt; FAILURE! java.lang.AssertionError: expected:\u0026lt;60\u0026gt; but was:\u0026lt;30\u0026gt; at org.junit.Assert.fail(Assert.java:88) at org.junit.Assert.failNotEquals(Assert.java:834) at org.junit.Assert.assertEquals(Assert.java:645) at org.junit.Assert.assertEquals(Assert.java:631) at com.bjpowernode.TestHelloMaven.testAddNumber(TestHelloMaven.java:15) 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.junit.runners.model.FrameworkMethod$1.runReflectiveCall(FrameworkMethod.java:50) at org.junit.internal.runners.model.ReflectiveCallable.run(ReflectiveCallable.java:12) at org.junit.runners.model.FrameworkMethod.invokeExplosively(FrameworkMethod.java:47) at org.junit.internal.runners.statements.InvokeMethod.evaluate(InvokeMethod.java:17) at org.junit.runners.ParentRunner.runLeaf(ParentRunner.java:325) at org.junit.runners.BlockJUnit4ClassRunner.runChild(BlockJUnit4ClassRunner.java:78) at org.junit.runners.BlockJUnit4ClassRunner.runChild(BlockJUnit4ClassRunner.java:57) at org.junit.runners.ParentRunner$3.run(ParentRunner.java:290) at org.junit.runners.ParentRunner$1.schedule(ParentRunner.java:71) at org.junit.runners.ParentRunner.runChildren(ParentRunner.java:288) at org.junit.runners.ParentRunner.access$000(ParentRunner.java:58) at org.junit.runners.ParentRunner$2.evaluate(ParentRunner.java:268) at org.junit.runners.ParentRunner.run(ParentRunner.java:363) at org.apache.maven.surefire.junit4.JUnit4Provider.execute(JUnit4Provider.java:252) at org.apache.maven.surefire.junit4.JUnit4Provider.executeTestSet(JUnit4Provider.java:141) at org.apache.maven.surefire.junit4.JUnit4Provider.invoke(JUnit4Provider.java:112) 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.apache.maven.surefire.util.ReflectionUtils.invokeMethodWithArray(ReflectionUtils.java:189) at org.apache.maven.surefire.booter.ProviderFactory$ProviderProxy.invoke(ProviderFactory.java:165) at org.apache.maven.surefire.booter.ProviderFactory.invokeProvider(ProviderFactory.java:85) at org.apache.maven.surefire.booter.ForkedBooter.runSuitesInProcess(ForkedBooter.java:115) at org.apache.maven.surefire.booter.ForkedBooter.main(ForkedBooter.java:75) Results : Failed tests: testAddNumber(com.bjpowernode.TestHelloMaven): expected:\u0026lt;60\u0026gt; but was:\u0026lt;30\u0026gt; Tests run: 1, Failures: 1, Errors: 0, Skipped: 0 [INFO] ------------------------------------------------------------------------ [INFO] BUILD FAILURE [INFO] ------------------------------------------------------------------------ [INFO] Total time: 8.018 s [INFO] Finished at: 2022-07-09T17:35:38+08:00 [INFO] ------------------------------------------------------------------------ [ERROR] Failed to execute goal org.apache.maven.plugins:maven-surefire-plugin:2.12.4:test (default-test) on project ch01-maven: There are test failures. [ERROR] [ERROR] Please refer to D:\\Users\\ly\\Documents\\git\\mavenwork\\Hello\\target\\surefire-reports for the individual test results. [ERROR] -\u0026gt; [Help 1] [ERROR] [ERROR] To see the full stack trace of the errors, re-run Maven with the -e switch. [ERROR] Re-run Maven using the -X switch to enable full debug logging. [ERROR] [ERROR] For more information about the errors and possible solutions, please read the following articles: [ERROR] [Help 1] http://cwiki.apache.org/confluence/display/MAVEN/MojoFailureException mvn package 打包,作用是把项目中的资源class文件和配置文件,都放到一个压缩包中,默认压缩文件是jar类型,web应用是war类型,扩展名jar/war 这里进行了编译、测试、打包 [INFO] Building jar: D:\\Users\\ly\\Documents\\git\\mavenwork\\Hello\\target\\ch01-maven-1.0-SNAPSHOT.jar 打包插件 maven-jar-plugin:2.4:jar (default-jar) @ ch01-maven maven-jar-plugin:2.4用来执行打包,会生成jar扩展名文件\nλ mvn package [INFO] Scanning for projects... [INFO] [INFO] ---------------------\u0026lt; com.bjpowernode:ch01-maven \u0026gt;--------------------- [INFO] Building ch01-maven 1.0-SNAPSHOT [INFO] --------------------------------[ jar ]--------------------------------- [INFO] [INFO] --- maven-resources-plugin:2.6:resources (default-resources) @ ch01-maven --- [WARNING] Using platform encoding (GBK actually) to copy filtered resources, i.e. build is platform dependent! [INFO] Copying 2 resources [INFO] [INFO] --- maven-compiler-plugin:3.1:compile (default-compile) @ ch01-maven --- [INFO] Changes detected - recompiling the module! [WARNING] File encoding has not been set, using platform encoding GBK, i.e. build is platform dependent! [INFO] Compiling 1 source file to D:\\Users\\ly\\Documents\\git\\mavenwork\\Hello\\target\\classes [INFO] [INFO] --- maven-resources-plugin:2.6:testResources (default-testResources) @ ch01-maven --- [WARNING] Using platform encoding (GBK actually) to copy filtered resources, i.e. build is platform dependent! [INFO] Copying 1 resource [INFO] [INFO] --- maven-compiler-plugin:3.1:testCompile (default-testCompile) @ ch01-maven --- [INFO] Changes detected - recompiling the module! [WARNING] File encoding has not been set, using platform encoding GBK, i.e. build is platform dependent! [INFO] Compiling 1 source file to D:\\Users\\ly\\Documents\\git\\mavenwork\\Hello\\target\\test-classes [INFO] [INFO] --- maven-surefire-plugin:2.12.4:test (default-test) @ ch01-maven --- [INFO] Surefire report directory: D:\\Users\\ly\\Documents\\git\\mavenwork\\Hello\\target\\surefire-reports ------------------------------------------------------- T E S T S ------------------------------------------------------- Running com.bjpowernode.TestHelloMaven 执行了测试方法testAddNumber hello maven -addNumber Tests run: 1, Failures: 0, Errors: 0, Skipped: 0, Time elapsed: 0.135 sec Results : Tests run: 1, Failures: 0, Errors: 0, Skipped: 0 [INFO] [INFO] --- maven-jar-plugin:2.4:jar (default-jar) @ ch01-maven --- [INFO] Building jar: D:\\Users\\ly\\Documents\\git\\mavenwork\\Hello\\target\\ch01-maven-1.0-SNAPSHOT.jar [INFO] ------------------------------------------------------------------------ [INFO] BUILD SUCCESS [INFO] ------------------------------------------------------------------------ [INFO] Total time: 5.624 s [INFO] Finished at: 2022-07-09T17:40:44+08:00 [INFO] ------------------------------------------------------------------------ 生成ch01-maven-1.0-SNAPSHOT.jar 坐标\n\u0026lt;groupId\u0026gt;com.bjpowernode\u0026lt;/groupId\u0026gt; \u0026lt;artifactId\u0026gt;ch01-maven\u0026lt;/artifactId\u0026gt; \u0026lt;version\u0026gt;1.0-SNAPSHOT\u0026lt;/version\u0026gt; \u0026lt;packaging\u0026gt;jar\u0026lt;/packaging\u0026gt; 打包的文件名 artifactId-version.packaging 查看jar 打包的文件中,包括src/main目录中所有的生成的class文件和配置文件(resources下),和测试test无关\nmvn install 把生成的打包文件(jar)安装到maven仓库中 插件:maven-install-plugin-2.4\nλ mvn install [INFO] Scanning for projects... [INFO] [INFO] ---------------------\u0026lt; com.bjpowernode:ch01-maven \u0026gt;--------------------- [INFO] Building ch01-maven 1.0-SNAPSHOT [INFO] --------------------------------[ jar ]--------------------------------- [INFO] [INFO] --- maven-resources-plugin:2.6:resources (default-resources) @ ch01-maven --- [WARNING] Using platform encoding (GBK actually) to copy filtered resources, i.e. build is platform dependent! [INFO] Copying 2 resources [INFO] [INFO] --- maven-compiler-plugin:3.1:compile (default-compile) @ ch01-maven --- [INFO] Nothing to compile - all classes are up to date [INFO] [INFO] --- maven-resources-plugin:2.6:testResources (default-testResources) @ ch01-maven --- [WARNING] Using platform encoding (GBK actually) to copy filtered resources, i.e. build is platform dependent! [INFO] Copying 1 resource [INFO] [INFO] --- maven-compiler-plugin:3.1:testCompile (default-testCompile) @ ch01-maven --- [INFO] Nothing to compile - all classes are up to date [INFO] [INFO] --- maven-surefire-plugin:2.12.4:test (default-test) @ ch01-maven --- [INFO] Surefire report directory: D:\\Users\\ly\\Documents\\git\\mavenwork\\Hello\\target\\surefire-reports ------------------------------------------------------- T E S T S ------------------------------------------------------- Running com.bjpowernode.TestHelloMaven 执行了测试方法testAddNumber hello maven -addNumber Tests run: 1, Failures: 0, Errors: 0, Skipped: 0, Time elapsed: 0.162 sec Results : Tests run: 1, Failures: 0, Errors: 0, Skipped: 0 [INFO] [INFO] --- maven-jar-plugin:2.4:jar (default-jar) @ ch01-maven --- [INFO] [INFO] --- maven-install-plugin:2.4:install (default-install) @ ch01-maven --- [INFO] Installing D:\\Users\\ly\\Documents\\git\\mavenwork\\Hello\\target\\ch01-maven-1.0-SNAPSHOT.jar to D:\\software\\apache-maven-3.8.6\\repository\\com\\bjpowernode\\ch01-maven\\1.0-SNAPSHOT\\ch01-maven-1.0-SNAPSHOT.jar [INFO] Installing D:\\Users\\ly\\Documents\\git\\mavenwork\\Hello\\pom.xml to D:\\software\\apache-maven-3.8.6\\repository\\com\\bjpowernode\\ch01-maven\\1.0-SNAPSHOT\\ch01-maven-1.0-SNAPSHOT.pom [INFO] ------------------------------------------------------------------------ [INFO] BUILD SUCCESS [INFO] ------------------------------------------------------------------------ [INFO] Total time: 5.063 s [INFO] Finished at: 2022-07-09T17:48:43+08:00 [INFO] ------------------------------------------------------------------------ 如上,\nInstalling D:\\Users\\ly\\Documents\\git\\mavenwork\\Hello\\target\\ch01-maven-1.0-SNAPSHOT.jar to D:\\software\\apache-maven-3.8.6\\repository\\com\\bjpowernode\\ch01-maven\\1.0-SNAPSHOT\\ch01-maven-1.0-SNAPSHOT.jar 路径 com\\bjpowernode\\ch01-maven\\1.0-SNAPSHOT ,如下,跟坐标有关\n\u0026lt;groupId\u0026gt;com.bjpowernode\u0026lt;/groupId\u0026gt; \u0026lt;artifactId\u0026gt;ch01-maven\u0026lt;/artifactId\u0026gt; \u0026lt;version\u0026gt;1.0-SNAPSHOT\u0026lt;/version\u0026gt; \u0026lt;packaging\u0026gt;jar\u0026lt;/packaging\u0026gt; \u0026lt;!--groupId出现点,则使用\\(文件夹)分割 artifactId 独立文件夹 version 独立文件夹 --\u0026gt; 结果 部署 mvn deploy 部署主程序(把本工程打包,按照本工程的坐标保存到本地仓库中,并且保存到私服仓库中,还会自动把项目部署到web容器中\n以上命令是可以组合着用的\nλ mvn clean compile [INFO] Scanning for projects... [INFO] [INFO] ---------------------\u0026lt; com.bjpowernode:ch01-maven \u0026gt;--------------------- [INFO] Building ch01-maven 1.0-SNAPSHOT [INFO] --------------------------------[ jar ]--------------------------------- [INFO] [INFO] --- maven-clean-plugin:2.5:clean (default-clean) @ ch01-maven --- [INFO] Deleting D:\\Users\\ly\\Documents\\git\\mavenwork\\Hello\\target [INFO] [INFO] --- maven-resources-plugin:2.6:resources (default-resources) @ ch01-maven --- [WARNING] Using platform encoding (GBK actually) to copy filtered resources, i.e. build is platform dependent! [INFO] Copying 2 resources [INFO] [INFO] --- maven-compiler-plugin:3.1:compile (default-compile) @ ch01-maven --- [INFO] Changes detected - recompiling the module! [WARNING] File encoding has not been set, using platform encoding GBK, i.e. build is platform dependent! [INFO] Compiling 1 source file to D:\\Users\\ly\\Documents\\git\\mavenwork\\Hello\\target\\classes [INFO] ------------------------------------------------------------------------ [INFO] BUILD SUCCESS [INFO] ------------------------------------------------------------------------ [INFO] Total time: 2.725 s [INFO] Finished at: 2022-07-09T17:53:36+08:00 [INFO] ------------------------------------------------------------------------ 配置插件 # 常用插件设置\n\u0026lt;build\u0026gt; \u0026lt;plugins\u0026gt; \u0026lt;plugin\u0026gt; \u0026lt;groupId\u0026gt;org.apache.maven.plugins\u0026lt;/groupId\u0026gt; \u0026lt;artifactId\u0026gt;maven-compiler-plugin\u0026lt;/artifactId\u0026gt; \u0026lt;version\u0026gt;3.8.1\u0026lt;/version\u0026gt; \u0026lt;configuration\u0026gt; \u0026lt;source\u0026gt;1.8\u0026lt;/source\u0026gt; \u0026lt;target\u0026gt;1.8\u0026lt;/target\u0026gt; \u0026lt;/configuration\u0026gt; \u0026lt;/plugin\u0026gt; \u0026lt;/plugins\u0026gt; \u0026lt;/build\u0026gt; 先看一下,目前的版本 maven-compiler-plugin:3.1:compile "},{"id":369,"href":"/zh/docs/technology/Maven/base_dljd_/01-16/","title":"01-16 maven基础_动力节点","section":"基础(动力节点)_","content":" 课程介绍 # maven 自动化构建\u0026ndash;\u0026gt;开发\u0026ndash;编译\u0026ndash;运行-测试\u0026ndash;打包\u0026ndash;部署 (m ei \u0026rsquo; ven) maven的作用 # 软件是一个工程 软件中重复的操作(开发阶段) 需求分析 设计阶段 开发阶段(编码),编译,测试 测试阶段(专业测试),测试报告 项目打包,发布,给客户安装项目 maven 项目自动构建,清理、编译、测试、打包、安装、部署 管理依赖:项目中需要使用的其他资源 Maven中的概念 # 没有使用maven,管理jar,手动处理jar,以及jar之间的依赖 maven是apache 【əˈpætʃi】基金会的开源项目,使用java语法开发 maven是项目的自动化构建工具,管理项目依赖 maven中的概念 POM 约定的目录 坐标 依赖管理 仓库管理 生命周期 插件和目标 继承 (高级内容) 聚合 (高级内容) Maven资源的获取与安装,测试 # https://maven.apache.org/index.html\n各种内容 要求 视频用的3.6.3 ,这里下载3.8.6 (最新的,不要和电脑原配置冲突,方便学习,后续改回3.8.4)\n检查java home 如果没有需要进行配置 将maven的bin目录配置到path环境变量下(这里使用的是下一节的方法,视频中没有用MAVEN_HOME,而是直接将maven的bin目录路径加到path中) maven解压后的目录结构 另一种安装方式 # 确定JAVA_HOME是否有效 创建M2_HOME(MAVEN_HOME),值为maven的安装目录 在path环境中,加入%M2_HOME%\\bin 测试maven安装 mvn -v 约定的目录结构 # 大多数人遵守的目录结构\n一个maven项目对应一个文件夹,比如Hello\nHello \\src \\main\t叫做主程序目录(完成项目功能的代码和配置文件) \\java\t源代码(包和相关的类定义) \\resources 配置文件 \\test\t放置测试程序代码(开发人员自己写的测试代码) \\java\t测试代码(junit) \\resources 测试程序的配置文件 \\pom.xml\tmaven的配置文件 Hello的Maven项目 # maven可以独立使用:创建项目、编译代码、测试程序、打包、部署等\n和idea一起使用,实现编码、测试、打包\npom.xml基本模板\n\u0026lt;?xml version=\u0026#34;1.0\u0026#34; encoding=\u0026#34;UTF-8\u0026#34; ?\u0026gt; \u0026lt;project xmlns=\u0026#34;http://maven.apache.org/POM/4.0.0\u0026#34; xmlns:xsi=\u0026#34;http://www.w3.org/2001/XMLSchema-instance\u0026#34; xsi:schemaLocation=\u0026#34;http://maven.apache.org/POM/4.0.0 http://maven.apache.org/maven-v4_0_0.xsd\u0026#34;\u0026gt; \u0026lt;modelVersion\u0026gt;4.0.0\u0026lt;/modelVersion\u0026gt; \u0026lt;groupId\u0026gt;com.bjpowernode\u0026lt;/groupId\u0026gt; \u0026lt;artifactId\u0026gt;ch01-maven\u0026lt;/artifactId\u0026gt; \u0026lt;version\u0026gt;1.0-SNAPSHOT\u0026lt;/version\u0026gt; \u0026lt;properties\u0026gt; \u0026lt;java.version\u0026gt;1.8\u0026lt;/java.version\u0026gt; \u0026lt;maven.compiler.source\u0026gt;1.8\u0026lt;/maven.compiler.source\u0026gt; \u0026lt;maven.compiler.target\u0026gt;1.8\u0026lt;/maven.compiler.target\u0026gt; \u0026lt;/properties\u0026gt; \u0026lt;/project\u0026gt; 目录创建 在main下创建一个com.bjpowernode的包,以及一个java文件\npackage com.bjpowernode; public class HelloMaven{ public int addNumber(int n1,int n2){ System.out.println(\u0026#34;hello maven -addNumber\u0026#34;); return n1+n2; } public static void main(String args[]){ HelloMaven hello=new HelloMaven(); int res=hello.addNumber(10,20); System.out.println(\u0026#34;在main方法中,执行hello的方法=\u0026#34;+res); } } 在Hello目录下,进行编译 使用mvn compile进行编译 第一次会下载一些东西 查看target文件 进入classes执行java程序\njava com.bjpowernode.HelloMaven pom-modelVersion # pom\u0026ndash;Project Object Model 项目对象模型\nMaven把一个项目的结构和内容抽象成一个模型,在xml文件中进行声明,以方便进行构建和描述\npom文件解释\n\u0026lt;?xml version=\u0026#34;1.0\u0026#34; encoding=\u0026#34;UTF-8\u0026#34; ?\u0026gt; \u0026lt;!--project是根标签,后面的是约束文件 (maven-v4_0_0.xsd)--\u0026gt; \u0026lt;project xmlns=\u0026#34;http://maven.apache.org/POM/4.0.0\u0026#34; xmlns:xsi=\u0026#34;http://www.w3.org/2001/XMLSchema-instance\u0026#34; xsi:schemaLocation=\u0026#34;http://maven.apache.org/POM/4.0.0 http://maven.apache.org/maven-v4_0_0.xsd\u0026#34;\u0026gt; \u0026lt;!--pom模型版本,4.0.0--\u0026gt; \u0026lt;modelVersion\u0026gt;4.0.0\u0026lt;/modelVersion\u0026gt; \u0026lt;!--坐标--\u0026gt; \u0026lt;groupId\u0026gt;com.bjpowernode\u0026lt;/groupId\u0026gt; \u0026lt;artifactId\u0026gt;ch01-maven\u0026lt;/artifactId\u0026gt; \u0026lt;version\u0026gt;1.0-SNAPSHOT\u0026lt;/version\u0026gt; \u0026lt;properties\u0026gt; \u0026lt;java.version\u0026gt;1.8\u0026lt;/java.version\u0026gt; \u0026lt;maven.compiler.source\u0026gt;1.8\u0026lt;/maven.compiler.source\u0026gt; \u0026lt;maven.compiler.target\u0026gt;1.8\u0026lt;/maven.compiler.target\u0026gt; \u0026lt;/properties\u0026gt; \u0026lt;/project\u0026gt; pom-groupId,artifactId,version # 坐标组成,groupid,artifactId,version 作用:资源的唯一标识,maven中每个资源都是坐标,简称gav groupId:组织名称,代码。公司或单位标识,常使用公司域名的倒写 如果规模大,可以是 域名倒写+大项目名称 例如百度无人车项目 : com.baidu.appollo artifactId:项目名称,如果groupId中有项目,此时当前的值就是子项目名,项目名称是唯一的 versionId:项目版本号,使用数字,推荐三位 例如 主版本号.次版本号.小版本号 例如 5.2.5 带快照的版本,以-SNAPSHOT结尾,即非稳定版本 pom-gav作用 # 每个maven项目都有自己的gav 管理依赖,使用其他jar包,也用gav标识 坐标 坐标值的获取 https://mvnrepository.com/ 例如mysql pom-依赖的使用 # 依赖dependency 项目中使用的其他资源(jar) 需要使用maven来表示依赖、管理依赖,通过使用dependencies、dependency和gav完成依赖的使用\n\u0026lt;dependencies\u0026gt; \u0026lt;!-- https://mvnrepository.com/artifact/mysql/mysql-connector-java --\u0026gt; \u0026lt;dependency\u0026gt; \u0026lt;groupId\u0026gt;mysql\u0026lt;/groupId\u0026gt; \u0026lt;artifactId\u0026gt;mysql-connector-java\u0026lt;/artifactId\u0026gt; \u0026lt;version\u0026gt;8.0.29\u0026lt;/version\u0026gt; \u0026lt;/dependency\u0026gt; \u0026lt;/dependencies\u0026gt; \u0026lt;!--maven使用gav标识,从互联网下载依赖的jar,下载到本机中,由maven管理项目使用的这些jar--\u0026gt; 完整\n\u0026lt;?xml version=\u0026#34;1.0\u0026#34; encoding=\u0026#34;UTF-8\u0026#34; ?\u0026gt; \u0026lt;!--project是根标签,后面的是约束文件 (maven-v4_0_0.xsd)--\u0026gt; \u0026lt;project xmlns=\u0026#34;http://maven.apache.org/POM/4.0.0\u0026#34; xmlns:xsi=\u0026#34;http://www.w3.org/2001/XMLSchema-instance\u0026#34; xsi:schemaLocation=\u0026#34;http://maven.apache.org/POM/4.0.0 http://maven.apache.org/maven-v4_0_0.xsd\u0026#34;\u0026gt; \u0026lt;!--pom模型版本,4.0.0--\u0026gt; \u0026lt;modelVersion\u0026gt;4.0.0\u0026lt;/modelVersion\u0026gt; \u0026lt;!--坐标--\u0026gt; \u0026lt;groupId\u0026gt;com.bjpowernode\u0026lt;/groupId\u0026gt; \u0026lt;artifactId\u0026gt;ch01-maven\u0026lt;/artifactId\u0026gt; \u0026lt;version\u0026gt;1.0-SNAPSHOT\u0026lt;/version\u0026gt; \u0026lt;properties\u0026gt; \u0026lt;java.version\u0026gt;1.8\u0026lt;/java.version\u0026gt; \u0026lt;maven.compiler.source\u0026gt;1.8\u0026lt;/maven.compiler.source\u0026gt; \u0026lt;maven.compiler.target\u0026gt;1.8\u0026lt;/maven.compiler.target\u0026gt; \u0026lt;/properties\u0026gt; \u0026lt;dependencies\u0026gt; \u0026lt;!-- https://mvnrepository.com/artifact/mysql/mysql-connector-java --\u0026gt; \u0026lt;dependency\u0026gt; \u0026lt;groupId\u0026gt;mysql\u0026lt;/groupId\u0026gt; \u0026lt;artifactId\u0026gt;mysql-connector-java\u0026lt;/artifactId\u0026gt; \u0026lt;version\u0026gt;8.0.29\u0026lt;/version\u0026gt; \u0026lt;/dependency\u0026gt; \u0026lt;/dependencies\u0026gt; \u0026lt;!--maven使用gav标识,从互联网下载依赖的jar,下载到本机中,由maven管理项目使用的这些jar--\u0026gt; \u0026lt;!--packaging 项目打包类型---\u0026gt; \u0026lt;/project\u0026gt; pom-打包类型 # \u0026lt;packaging\u0026gt; 项目打包类型\n\u0026lt;?xml version=\u0026#34;1.0\u0026#34; encoding=\u0026#34;UTF-8\u0026#34; ?\u0026gt; \u0026lt;!--project是根标签,后面的是约束文件 (maven-v4_0_0.xsd)--\u0026gt; \u0026lt;project xmlns=\u0026#34;http://maven.apache.org/POM/4.0.0\u0026#34; xmlns:xsi=\u0026#34;http://www.w3.org/2001/XMLSchema-instance\u0026#34; xsi:schemaLocation=\u0026#34;http://maven.apache.org/POM/4.0.0 http://maven.apache.org/maven-v4_0_0.xsd\u0026#34;\u0026gt; \u0026lt;!--pom模型版本,4.0.0--\u0026gt; \u0026lt;modelVersion\u0026gt;4.0.0\u0026lt;/modelVersion\u0026gt; \u0026lt;!--坐标--\u0026gt; \u0026lt;groupId\u0026gt;com.bjpowernode\u0026lt;/groupId\u0026gt; \u0026lt;artifactId\u0026gt;ch01-maven\u0026lt;/artifactId\u0026gt; \u0026lt;version\u0026gt;1.0-SNAPSHOT\u0026lt;/version\u0026gt; \u0026lt;packaging\u0026gt;jar\u0026lt;/packaging\u0026gt; \u0026lt;/project\u0026gt; 其他 pom-继承和聚合 # 继承 parent 聚合 modules "},{"id":370,"href":"/zh/docs/technology/Linux/hanshunping_/52-x/","title":"52-X","section":"韩顺平老师_","content":" crond快速入门 # 使用命令 crontab -e 创建一个定时任务\n*/1 * * * * ls -l /etc/ \u0026gt; /tmp/to.txt 特殊符号 ,代表不连续 -破折号 表示连续 其他 定时调用脚本\n编辑脚本 my.sh\ndate \u0026gt;\u0026gt; /home/mycal date \u0026gt;\u0026gt; /home/mycal 给脚本赋予x权限\nchmod u+x my.sh crontab -e\n*/1 * * * * my.sh 数据库备份 crontab -r 删除\ncrontab -l 列出\ncrontab -e 编辑任务\natd 是否在运行 yum install -y atd systemctl start atd\njob队列 at选项 at指定时间 添加任务 at 5pm tomorrow 明天下午5点\nat now + 2 minutes 2分钟后\natrm 5 删除5号\n两分钟后执行某个脚本 磁盘分区 # 分区跟文件系统的关系 (挂载) 将一个分区挂载到某个目录,用户进入到某个目录,就相当于访问到某个分区了 lsblk linux分IDE硬盘和SCSI硬盘 目前基本是SCSI硬盘 sdx~ x代表abcd,~表示数字 lsblk -f 文件类型,唯一标识符 现在挂载一个分区 如图 给虚拟机,添加一个硬盘 重启后,使用lsblk 进行分区 fdisk /dev/sdb 之后输入p, 输入分区数(这里是1) 最后一步,输入w ,写入分区并退出 查看 将分区格式化 mkfs -t ext4 /dev/sdb1 查看 进行挂载 mount /dev/sdb1 /newdisk/ umount /dev/sdb1 卸载 用命令行挂载的指令,重启后挂载关系会消失 永久挂载:修改/etc/fstab # df -h 查看磁盘使用情况 du -h \u0026ndash;max-depth=1 /opt ls -l /opt | grep \u0026ldquo;^-\u0026rdquo; | wc -l 使用正则,并统计数量 ls -lR /opt 注意,这里加了R,将递归显示 使用yum install -y tree 网络配置 # ifconfig 查看ip\n网络的互通 虚拟网络编辑器 使用ping判断主机间是否互通\nvi /etc/sysconfig/network-scripts/ifcfg-ens33 编辑ip\nTYPE=\u0026#34;Ethernet\u0026#34; PROXY_METHOD=\u0026#34;none\u0026#34; BROWSER_ONLY=\u0026#34;no\u0026#34; DEFROUTE=\u0026#34;yes\u0026#34; IPV4_FAILURE_FATAL=\u0026#34;no\u0026#34; IPV6INIT=\u0026#34;yes\u0026#34; IPV6_AUTOCONF=\u0026#34;yes\u0026#34; IPV6_DEFROUTE=\u0026#34;yes\u0026#34; IPV6_FAILURE_FATAL=\u0026#34;no\u0026#34; IPV6_ADDR_GEN_MODE=\u0026#34;stable-privacy\u0026#34; NAME=\u0026#34;ens33\u0026#34; UUID=\u0026#34;8c2741af-382a-44a6-b161-aed16a29875d\u0026#34; DEVICE=\u0026#34;ens33\u0026#34; BOOTPROTO=\u0026#34;static\u0026#34; ONBOOT=\u0026#34;yes\u0026#34; IPADDR=192.168.200.160 GATEWAY=192.168.200.2 DNS1=192.168.200.2 注意最后五行 修改hostname vim /etc/hostname\n进程 # 每一个执行的程序被称为一个进程,每一个进程都分配一个ID号- 每个进程都可以以前台/后台方式运行 一半系统服务以后台进程方式存在的 使用ps显示进程 ps -aux 一些参数解释 使用grep过滤 进程的父进程 ps -ef 由systemd生成启动其他进程 子进程之间关系 进程的终止 kill / killall killall 将子进程一起杀死 kill -9 强制终止 如果把sshd杀死,那就再也连不上了 重新启动sshd /bin/systemctl start sshd.service yum -y install psmisc pstree -u 带上用户 pstree -p 带上进程号 服务管理 # 服务,本质上就是进程 service 服务名 start|stop|restart|reload|status centos7.0之后,主要用systemctl 还使用service的命令 网络连接查看 服务的运行级别 systemctl set-default graphical.target //默认进入图形化界面 rpm管理 # 软件包管理 # "},{"id":371,"href":"/zh/docs/technology/Linux/hanshunping_/40-51/","title":"linux_韩老师_40-51","section":"韩顺平老师_","content":" 组介绍 # 每个用户必定属于某个组 每个文件有几个概念:所有者、所在组、其他组 tom创建了hello.txt,则所有者为tom,默认所在组为tom组 除了所在组,就是其他组 ls -ahl (h更友好,a隐藏,l列表) 所有者 # 使用chown root helo.java 修改,效果如下 所在组修改 # 组的创建 groupadd monster 创建一个用户并让他属于该组 useradd -g monster fox 注意逻辑,此时使用fox创建文件 passwd fox 给fox创建密码 如图,创建一个文件 使用chgrp fruit orange.txt 修改文件的所在组 改变某个用户所在组 usermod -g fruit fox 使用 cat /etc/group 查看所有的组 当一个用户属于多个组的时候,groups会出现多个组名 rwx权限 # rwxrwxrwx 第一列有十位,第0位确认文件类型 -普通文件,l是链接;d是目录;c是字符设备文件、鼠标、键盘;b块设备 1-3表示文件所有者拥有的权限;4-6是文件所在组所拥有的权限,7-9 其他组所拥有的权限\nrwx作用到文件,r代表可读可查看,w代表可修改(如果是删除权限,则必须在该文件所在的目录有写权限,才能删除),x代表可执行 rwx作用到目录,r表示可以读取(ls可查看目录内容),w表示可写(可以在目录内创建、删除、重命名目录),x表示可以进入该目录 rwx分别用数字表示,4,2,1。当拥有所有权限,则为7 最后面的数字,代表连接数(或者子目录数) 1213 文件大小(字节),如果是文件夹则显示4096 最后abc表示文件名,蓝色表示是目录 修改权限 # chmod 修改权限,u:所有者,g:所有组,o:其他人,a 所有(ugo总和) chmod u=rwx,g=rw,o=x 文件/目录名 这里等号表示直接给权限 chmod o+w 文件/目录名 这里加表示+权限 chmod a-x 文件/目录名 chmod u=rwx,g=rx,o=rx abc 给文件添加执行权限(会变成绿色的) 使用数字 将abc.txt文件权限修改成rwxr-xr-x使用数字实现 chmod 755 abc 修改所有者和所在组 # chown tom abc #修改文件所有者为tom chown -R tom abc #修改文件夹及其所有子目录所有者为tom chgrp -R fruit kkk #修改文件夹所在组为fruit 权限管理应用实例 # 警察和土匪的游戏\n前提,有police和bandit两个组,\njack,jerry属于警察组\nxh,xq属于土匪组\ngroupadd police groupadd bandit useradd -g police jack useradd -g police jerry useradd -g bandit xh useradd -g bandit xq chmod 640 jack.txt\nchmod o=r,g=rw jack.txt\n如果要对目录内操作,那么先有改目录相应权限\nchmod 770 jack 放开jack目录权限 题目 对一个目录不能ls(没有读权限),但是是可以直接读写目录中的文件的(有权限的情况下)\n# "},{"id":372,"href":"/zh/docs/technology/MySQL/bl_sgg_/96-00/","title":"mysql高阶_sgg 96-00","section":"进阶(尚硅谷)_","content":" 章节概述 # 架构篇\n1-3 4 5 6 索引及调优篇\n01 02-03\n04-05\n06 事务篇\n01-02 03 04 日志与备份篇\n01 02 03 CentOS环境准备 # 这里主要是做了克隆,并没有讲到CentOS的安装,所以笔记不记录了 MySQL的卸载 # 查找当前系统已经装了哪些 rpm -qa |grep mysql\n查找mysql服务运行状态 systemctl status mysql\n停止mysql服务 systemctl stop mysql\n删除\nyum remove mysql-community-client-plugins-8.0.29-1.el7.x86_64 yum remove mysql-community-common-8.0.29-1.el7.x86_64 查找带mysql名字的文件夹 find / -name mysql\n进行删除\nrm -rf /usr/lib64/mysql rm -rf /usr/share/mysql rm -rf /etc/selinux/targeted/active/modules/100/mysql rm -rf /etc/my.cnf Linux下安装MySQL8.0与5.7版本 # 版本介绍 下载地址 : https://www.mysql.com/downloads/ 进入 即 https://dev.mysql.com/downloads/mysql/ 版本选择 下载最大的那个,离线版 下载后解压,并将下面六个放进linux中\n如果是5.7,则需要进入 https://downloads.mysql.com/archives/community/\n下载后解压 拷贝进linux 安装前,给/tmp临时目录权限\nchmod -R 777 /tmp\n检查依赖\nrpm -qa |grep libaio ##libaio-0.3.109-13.el7.x86_64 rpm -qa |grep net-tools ##net-tools-2.0-0.24.20131004git.el7.x86_64 确保目录下已经存在5(4)个文件并严格按顺序执行\nrpm -ivh mysql-community-common-8.0.29-1.el7.x86_64.rpm rpm -ivh mysql-community-client-plugins-8.0.29-1.el7.x86_64.rpm rpm -ivh mysql-community-libs-8.0.29-1.el7.x86_64.rpm rpm -ivh mysql-community-client-8.0.29-1.el7.x86_64.rpm rpm -ivh mysql-community-icu-data-files-8.0.29-1.el7.x86_64.rpm rpm -ivh mysql-community-server-8.0.29-1.el7.x86_64.rpm 安装libs的时候,会报错\nerror: Failed dependencies: mariadb-libs is obsoleted by mysql-community-libs-8.0.29-1.el7.x86_64 使用下面命令,视频的方法\nyum remove mysql-libs 使用下面命令,卸载mariadb (这是我自己的方法)\nrpm -qa | grep mariadb\n查找到对应的版本 mariadb-libs-5.5.60-1.el7_5.x86_64 # 下面卸载查找出来的版本 # yum remove mariadb-libs-5.5.60-1.el7_5.x86_64 # 再次执行后安装成功\n服务初始化 mysqld --initialize --user=mysql\n查看默认生成的密码 cat /var/log/mysqld.log 判断mysql是否启动 systemctl status mysqld\n启动服务systemctl start mysqld 再次判断,发现已经启动 设置为自动启动\n查看当前是否开机自启动 systemctl list-unit-files|grep mysqld.service 如果是disable,则可以使用下面命令开机自启动 systemctl enable mysqld.service 进行登录\nmysql -u root -p 用刚才的密码\n使用查询,提示需要重置密码 密码更新\nalter user \u0026#39;root\u0026#39;@\u0026#39;localhost\u0026#39; identified by \u0026#39;123456\u0026#39;; quit # 退出重新登录 5.7的安装 赋予权限并检查包,这里发现缺少了libaio,所以yum install libaio\nSQLyog实现MySQL8.0和5.7的远程连接 # sqlyog下载 https://github.com/webyog/sqlyog-community/wiki/Downloads\n默认情况下会有连接出错 先测试ip及端口号 此时linux端口号并没有开放 使用systemctl status firewalld发现防火墙开启 (active) 使用systemctl stop firewalld将防火墙关闭 开机时关闭防火墙systemctl disable firewalld 此时还是报错 这是由于root不允许被远程连接\n查看user表,发现只允许本地登录 修改并更新权限\nupdate user set host = \u0026#39;192.168.1.%\u0026#39; where user= \u0026#39;root\u0026#39;; #或者 update user set host = \u0026#39;%\u0026#39; where user= \u0026#39;root\u0026#39;; #更新权限 flush privileges; 之后如果出现下面的问题(视频中有,我没遇到) ALTER USER \u0026#39;root\u0026#39;@\u0026#39;%\u0026#39; IDENTIFIED WITH mysql_native_password BY \u0026#39;123456\u0026#39;; 然后就可以连接了 命令行进行远程连接 mysql -u root -h 192.168.200.150 -P3306 -p\n字符集的修改与底层原理说明 # 比较规则_请求到响应过程中的编码与解码过程 # SQL大小写规范与sql_model的设置 # "},{"id":373,"href":"/zh/docs/technology/Algorithm/_algorithhms_4th_/3.2.1/","title":"算法红皮书 3.2.1","section":"_算法(第四版)_","content":" 二叉查找树 # 使用每个结点含有两个链接(链表中每个结点只含有一个链接)的二叉查找树来高效地实现符号表\n该数据结构由结点组成,结点包含的链接可以为空(null)或者指向其他结点\n一棵二叉查找树(BST)是一棵二叉树,其中每个结点都含有一个Comparable 的键(以 及相关联的值)且每个结点的键都大于其左子树中的任意结点的键而小于右子树的任意结点的键。\n基本实现 # 数据表示\n每个结点都含有一个键、一个值、一条左链接、一条右链接和一个结点计数器 左链接指向一棵由小于该结点的所有键组成的二叉查找树,右链接指向一棵由大于该节点的所有键组成的二叉查找树,变量N给出了以该结点为根的子树的结点总数 对于任意节点总是成立 size(x)=size(x.left)+size(x.right)+1 多棵二叉查找树表示同一组有序的键来实现构建和使用二叉查找树的高校算法 查找\n在符号表中查找一个键可能得到两种结果:如果含有该键的结点存在表中,我们的查找就命中了,然后返回值;否则查找未命中(返回null) 递归:如果树是空的,则查找未命中;如果被查找的键和根节点的键相等,查找命中,否则在适当的子树中查找:如果被查找的键较小就选择左子树,否则选择右子树 下面的get()方法,第一个参数是一个结点(子树根节点),第二个参数是被查找的键,代码会保证只有该结点所表示的子树才会含有和被查找的键相等的结点 从根结点开始,在每个结点中查找的进程都会递归地在它的一个子结点上展开,因此一次查找也就定义了树的一条路径。对于命中的查找,路径在含有被查找的键的结点处结束。对于未命中的查找,路径的终点是一个空链接 基于二叉查找树的符号表\npublic class BST\u0026lt;Key extends Comparable\u0026lt;Key\u0026gt;, Value\u0026gt; { private Node root; // 二叉查找树的根结点 private class Node { private Key key; // 键 private Value val; // 值 private Node left, right; // 指向子树的链接 private int N; // 以该结点为根的子树中的结点总数 public Node(Key key, Value val, int N) { this.key = key; this.val = val; this.N = N; } } public int size() { return size(root); } private int size(Node x) { if (x == null) return 0; else return x.N; } public Value get(Key key) // 请见算法3.3(续1) public void put(Key key, Value val) // 请见算法3.3(续1) // max()、min()、floor()、ceiling()方法请见算法3.3(续2) // select()、rank()方法请见算法3.3(续3) // delete()、deleteMin()、deleteMax()方法请见算法3.3(续4) // keys()方法请见算法3.3(续5) } 每个Node 对象都是一棵含有N 个结点的子树的根结点,它的左链接指向一棵由小于该结点的所有键组成的二叉查找树,右链接指向一棵由大于该结点的所有键组成的二叉查找 树。root 变量指向二叉查找树的根结点Node 对象(这棵树包含了符号表中的所有键值对) 二叉查找树的查找和排序方法的实现\npublic Value get(Key key) { return get(root, key); } private Value get(Node x, Key key) { // 在以x为根结点的子树中查找并返回key所对应的值; // 如果找不到则返回null if (x == null) return null; int cmp = key.compareTo(x.key); if (cmp \u0026lt; 0) return get(x.left, key); else if (cmp \u0026gt; 0) return get(x.right, key); else return x.val; } public void put(Key key, Value val) { // 查找key,找到则更新它的值,否则为它创建一个新的结点 root = put(root, key, val); } private Node put(Node x, Key key, Value val) { // 如果key存在于以x为根结点的子树中则更新它的值; // 否则将以key和val为键值对的新结点插入到该子树中 if (x == null) return new Node(key, val, 1); int cmp = key.compareTo(x.key); //注意,这里进行比较后,确认新节点应该放在当前节点的左边还是右边 if (cmp \u0026lt; 0) x.left = put(x.left, key, val); else if (cmp \u0026gt; 0) x.right = put(x.right, key, val); else x.val = val; x.N = size(x.left) + size(x.right) + 1; return x; } 插入 put()方法的实现逻辑和递归查找很相似:如果树是空的,就返回一个含有该键值对的新节点;如果被查找的键小于根节点的键,我们就会继续在左子树中插入该键,否则在右子树中插入该键\n递归\n可以将递归调用前的代码想象成沿着树向下走:它会将给定的键和每个结点的键相比较并根据结果向左或者向右移动到下一个结点。然后可以将递归调用后的代码想象成沿着树向上爬 在一棵简单的二叉查找树中,唯一的新链接就是在最底层指向新结点的链接,重置更上层的链接可以通过比较语句来避免。同样,我们只需要将路径上每个结点中的计数器的值加1,但我们使用了更加通用的代码,使之等于结点的所有子结点的计数器之和加1 使用二叉查找树的标准索引用例的轨迹 分析 # 在由N 个随机键构造的二叉查找树中,查找命中平均所需的比较次数为∼ 2lnN\n在由N 个随机键构造的二叉查找树中插入操作和查找未命中平均所需的比较次数为∼ 2lnN(约1.39lgN)\n有序性相关的方法与删除操作 # 最大键和最小键 # 如果根结点的左链接为空,那么一棵二叉查找树中最小的键就是根结点;如果左链接非空,那么 树中的最小键就是左子树中的最小键\n向上取整和向下取整 # 如果给定的键key 小于二叉查找树的根结点的键,那么小于等于key 的最大键floor(key) 一定 在根结点的左子树中;如果给定的键key 大于二叉查找树的根结点,那么只有当根结点右子树中存在小于等于key 的结点时,小于等于key 的最大键才会出现在右子树中,否则根结点就是小于等于key的最大键\n选择操作 # public Key min() { return min(root).key; } private Node min(Node x) { if (x.left == null) return x; return min(x.left); } public Key floor(Key key) { Node x = floor(root, key); if (x == null) return null; return x.key; } private Node floor(Node x, Key key) { if (x == null) return null; int cmp = key.compareTo(x.key); if (cmp == 0) return x; if (cmp \u0026lt; 0) return floor(x.left, key); Node t = floor(x.right, key); if (t != null) return t; else return x; } 排名 # 删除最大键和删除最小键 # 删除操作 # 范围查找 # 性能分析 # "},{"id":374,"href":"/zh/docs/technology/MyBatis-Plus/bl_sgg_/40-57/","title":"mybatis-plus-sgg-40-57","section":"基础(尚硅谷)_","content":" LambdaXxxWrapper # LambdaQueryWrapper主要是为了防止字段名写错\n@Test public void test11(){ String username=\u0026#34;abc\u0026#34;; Integer ageBegin=null; Integer ageEnd=30; LambdaQueryWrapper\u0026lt;User\u0026gt; queryWrapper=new LambdaQueryWrapper\u0026lt;\u0026gt;(); queryWrapper.like(StringUtils.isNotBlank(username),User::getUserName,username) .ge(ageBegin!=null,User::getAge,ageBegin); userMapper.selectList(queryWrapper); } sql日志打印\n==\u0026gt; Preparing: SELECT uid AS id,name AS userName,age,email,is_deleted_ly FROM t_user WHERE is_deleted_ly=0 AND (name LIKE ?) ==\u0026gt; Parameters: %abc%(String) \u0026lt;== Total: 0 LambdaUpdateWrapper\n@Test public void test12() { //(age\u0026gt;23且用户名包含a) 或 (邮箱为null) LambdaUpdateWrapper\u0026lt;User\u0026gt; updateWrapper = new LambdaUpdateWrapper\u0026lt;\u0026gt;(); updateWrapper.like(User::getUserName, \u0026#34;a\u0026#34;) .and(userUpdateWrapper -\u0026gt; userUpdateWrapper.gt(User::getAge, 23).or().isNotNull(User::getEmail)); updateWrapper.set(User::getUserName, \u0026#34;小黑\u0026#34;).set(User::getEmail, \u0026#34;abc@ly.com\u0026#34;); userMapper.update(null, updateWrapper); } sql日志打印\n==\u0026gt; Preparing: UPDATE t_user SET name=?,email=? WHERE is_deleted_ly=0 AND (name LIKE ? AND (age \u0026gt; ? OR email IS NOT NULL)) ==\u0026gt; Parameters: 小黑(String), abc@ly.com(String), %a%(String), 23(Integer) \u0026lt;== Updates: 0 MyBatis分页 # 先使用配置类\n@Configuration @MapperScan(\u0026#34;com.ly.mybatisplus.mapper\u0026#34;) public class MyBatisConfig { @Bean public MybatisPlusInterceptor mybatisPlusInterceptor(){ MybatisPlusInterceptor mybatisPlusInterceptor=new MybatisPlusInterceptor(); mybatisPlusInterceptor.addInnerInterceptor(new PaginationInnerInterceptor(DbType.MYSQL)); return mybatisPlusInterceptor; } } 使用\n@Test public void testPage() { Page\u0026lt;User\u0026gt; page = new Page\u0026lt;\u0026gt;(); page.setCurrent(2);//当前页页码 page.setSize(3);//每页条数 Page\u0026lt;User\u0026gt; userPage = userMapper.selectPage(page, null); System.out.println(userPage.getRecords() + \u0026#34;----\\n\u0026#34; + userPage.getPages() + \u0026#34;----\\n\u0026#34; + userPage.getTotal() + \u0026#34;---\\n\u0026#34;) ; } sql日志打印\n==\u0026gt; Preparing: SELECT uid AS id,name AS userName,age,email,is_deleted_ly FROM t_user WHERE is_deleted_ly=0 LIMIT ?,? ==\u0026gt; Parameters: 3(Long), 3(Long) \u0026lt;== Columns: id, userName, age, email, is_deleted_ly \u0026lt;== Row: 4, 被修改了, 21, test4@baomidou.com, 0 \u0026lt;== Row: 5, 被修改了, 24, email被修改了, 0 \u0026lt;== Row: 6, 张三5, 18, test5@baomidou.com, 0 \u0026lt;== Total: 3 结果Page对象的数据\n[User(id=4, userName=被修改了, age=21, email=test4@baomidou.com, isDeletedLy=0), User(id=5, userName=被修改了, age=24, email=email被修改了, isDeletedLy=0), User(id=6, userName=张三5, age=18, email=test5@baomidou.com, isDeletedLy=0)]---- 3---- 8--- 自定义分页功能\n首先,设置类型别名所在的包\nmybatis-plus: type-aliases-package: com.ly.mybatisplus.pojo 在Mapper类中编写接口方法\n@Repository public interface UserMapper extends BaseMapper\u0026lt;User\u0026gt; { /** * 通过年龄查询并分页 * @param page mybatis-plus提供的,必须存在且在第一个位置 * @param age * @return */ Page\u0026lt;User\u0026gt; selectPageVO(Page\u0026lt;User\u0026gt; page,Integer age); } 注意第一个参数\n在Mapper.xml中编写语句\n\u0026lt;select id=\u0026#34;selectPageVO\u0026#34; resultType=\u0026#34;User\u0026#34;\u0026gt; select uid,name,email from t_user where age \u0026gt; #{age} \u0026lt;/select\u0026gt; 测试方法\n@Test public void testPageCustom() { Page\u0026lt;User\u0026gt; page = new Page\u0026lt;\u0026gt;(); page.setCurrent(3);//当前页页码 page.setSize(5);//每页条数 Page\u0026lt;User\u0026gt; userPage = userMapper.selectPageVO(page, 12); System.out.println(userPage.getRecords() + \u0026#34;----\\n\u0026#34; + userPage.getPages() + \u0026#34;----\\n\u0026#34; + userPage.getTotal() + \u0026#34;---\\n\u0026#34;) ; } sql日志输出\n==\u0026gt; Preparing: SELECT COUNT(*) AS total FROM t_user WHERE age \u0026gt; ? ==\u0026gt; Parameters: 12(Integer) \u0026lt;== Columns: total \u0026lt;== Row: 20 \u0026lt;== Total: 1 //从第10行开始(不包括第10行),取5条记录 ==\u0026gt; Preparing: select uid,name,email from t_user where age \u0026gt; ? LIMIT ?,? ==\u0026gt; Parameters: 12(Integer), 10(Long), 5(Long) \u0026lt;== Columns: uid, name, email \u0026lt;== Row: 11, a, null \u0026lt;== Row: 12, a, null \u0026lt;== Row: 13, a, null \u0026lt;== Row: 14, a, null \u0026lt;== Row: 15, a, null \u0026lt;== Total: 5 Closing non transactional SqlSession [org.apache.ibatis.session.defaults.DefaultSqlSession@706fe5c6] [null, null, null, null, null]---- 4---- 20--- 注意上面那个sql,他会先查询条数,如果条数\u0026lt;=0,那么就不会执行下面的数据搜索了\n悲观锁和乐观锁 # 场景 乐观锁根据版本号使用 version\n乐观锁实现流程 模拟冲突 # 表创建\nCREATE TABLE t_product ( id BIGINT ( 20 ) NOT NULL COMMENT \u0026#39;主键id\u0026#39;, NAME VARCHAR ( 30 ) null DEFAULT NULL COMMENT \u0026#39;商品名称\u0026#39;, price INT ( 11 ) DEFAULT 0 COMMENT \u0026#39;价格\u0026#39;, version INT ( 11 ) DEFAULT 0 COMMENT \u0026#39;乐观锁版本号\u0026#39;, PRIMARY KEY ( id ) ) 创建ProductMapper\n@Repository public interface ProductMapper extends BaseMapper\u0026lt;Product\u0026gt; { } 数据库数据 代码\n@Test public void testModel() { //小李查询商品 Product productLi = productMapper.selectById(1L); //小王查询商品 Product productWang = productMapper.selectById(1L); //小李将商品加50 productLi.setPrice(productLi.getPrice()+50); productMapper.updateById(productLi); //小王将价格降低30 productWang.setPrice(productWang.getPrice()-30); productMapper.updateById(productWang); } sql日志\n==\u0026gt; Preparing: UPDATE t_product SET name=?, price=?, version=? WHERE id=? ==\u0026gt; Parameters: 外星人(String), 150(Integer), 0(Integer), 1(Long) \u0026lt;== Updates: 1 Closing non transactional SqlSession [org.apache.ibatis.session.defaults.DefaultSqlSession@6325f352] Creating a new SqlSession SqlSession [org.apache.ibatis.session.defaults.DefaultSqlSession@70730db] was not registered for synchronization because synchronization is not active JDBC Connection [HikariProxyConnection@91831175 wrapping com.mysql.cj.jdbc.ConnectionImpl@74ea46e2] will not be managed by Spring ==\u0026gt; Preparing: UPDATE t_product SET name=?, price=?, version=? WHERE id=? ==\u0026gt; Parameters: 外星人(String), 70(Integer), 0(Integer), 1(Long) \u0026lt;== Updates: 1 //最终结果为70\n乐观锁插件 # 在实体类中使用@Version注解表示乐观锁版本号\n@Version private Integer version; 配置类\n@Bean public MybatisPlusInterceptor mybatisPlusInterceptor(){ MybatisPlusInterceptor mybatisPlusInterceptor=new MybatisPlusInterceptor(); mybatisPlusInterceptor.addInnerInterceptor(new PaginationInnerInterceptor(DbType.MYSQL)); //添加乐观锁插件 mybatisPlusInterceptor.addInnerInterceptor(new OptimisticLockerInnerInterceptor()); return mybatisPlusInterceptor; } 再次运行代码\n@Test public void testModel() { //小李查询商品 Product productLi = productMapper.selectById(1L); //小王查询商品 Product productWang = productMapper.selectById(1L); //小李将商品加50 productLi.setPrice(productLi.getPrice()+50); productMapper.updateById(productLi); //小王将价格降低30 productWang.setPrice(productWang.getPrice()-30); productMapper.updateById(productWang); } sql日志查看\n==\u0026gt; Preparing: UPDATE t_product SET name=?, price=?, version=? WHERE id=? AND version=? ==\u0026gt; Parameters: 外星人(String), 120(Integer), 1(Integer), 1(Long), 0(Integer) \u0026lt;== Updates: 1 Closing non transactional SqlSession [org.apache.ibatis.session.defaults.DefaultSqlSession@2d64160c] Creating a new SqlSession SqlSession [org.apache.ibatis.session.defaults.DefaultSqlSession@33063f5b] was not registered for synchronization because synchronization is not active JDBC Connection [HikariProxyConnection@356539350 wrapping com.mysql.cj.jdbc.ConnectionImpl@127a7272] will not be managed by Spring ==\u0026gt; Preparing: UPDATE t_product SET name=?, price=?, version=? WHERE id=? AND version=? ==\u0026gt; Parameters: 外星人(String), 40(Integer), 1(Integer), 1(Long), 0(Integer) \u0026lt;== Updates: 0 优化修改流程 # @Test public void testModel() { //小李查询商品 Product productLi = productMapper.selectById(1L); //小王查询商品 Product productWang = productMapper.selectById(1L); //小李将商品加50 productLi.setPrice(productLi.getPrice() + 50); productMapper.updateById(productLi); //小王将价格降低30 productWang.setPrice(productWang.getPrice() - 30); int i = productMapper.updateById(productWang); //如果小王操作失败,再获取一次 if (i == 0) { Product product = productMapper.selectById(1L); product.setPrice(product.getPrice() - 30); productMapper.updateById(product); } } sql日志打印\n==\u0026gt; Preparing: UPDATE t_product SET name=?, price=?, version=? WHERE id=? AND version=? ==\u0026gt; Parameters: 外星人(String), 150(Integer), 6(Integer), 1(Long), 5(Integer) \u0026lt;== Updates: 1 Closing non transactional SqlSession [org.apache.ibatis.session.defaults.DefaultSqlSession@544e8149] Creating a new SqlSession SqlSession [org.apache.ibatis.session.defaults.DefaultSqlSession@48a0c8aa] was not registered for synchronization because synchronization is not active JDBC Connection [HikariProxyConnection@1637000661 wrapping com.mysql.cj.jdbc.ConnectionImpl@5f481b73] will not be managed by Spring ==\u0026gt; Preparing: UPDATE t_product SET name=?, price=?, version=? WHERE id=? AND version=? ==\u0026gt; Parameters: 外星人(String), 70(Integer), 6(Integer), 1(Long), 5(Integer) \u0026lt;== Updates: 0 Closing non transactional SqlSession [org.apache.ibatis.session.defaults.DefaultSqlSession@48a0c8aa] Creating a new SqlSession SqlSession [org.apache.ibatis.session.defaults.DefaultSqlSession@4cbc2e3b] was not registered for synchronization because synchronization is not active JDBC Connection [HikariProxyConnection@43473566 wrapping com.mysql.cj.jdbc.ConnectionImpl@5f481b73] will not be managed by Spring ==\u0026gt; Preparing: SELECT id,name,price,version FROM t_product WHERE id=? ==\u0026gt; Parameters: 1(Long) \u0026lt;== Columns: id, name, price, version \u0026lt;== Row: 1, 外星人, 150, 6 \u0026lt;== Total: 1 Closing non transactional SqlSession [org.apache.ibatis.session.defaults.DefaultSqlSession@4cbc2e3b] Creating a new SqlSession SqlSession [org.apache.ibatis.session.defaults.DefaultSqlSession@57562473] was not registered for synchronization because synchronization is not active JDBC Connection [HikariProxyConnection@2050360660 wrapping com.mysql.cj.jdbc.ConnectionImpl@5f481b73] will not be managed by Spring ==\u0026gt; Preparing: UPDATE t_product SET name=?, price=?, version=? WHERE id=? AND version=? ==\u0026gt; Parameters: 外星人(String), 120(Integer), 7(Integer), 1(Long), 6(Integer) \u0026lt;== Updates: 1 Closing non transactional SqlSession [org.apache.ibatis.session.defaults.DefaultSqlSession@57562473] 通用枚举 # 添加一个enum类\n@Getter public enum SexEnum { MALE(1, \u0026#34;男\u0026#34;), FEMALE(2, \u0026#34;女\u0026#34;); private Integer sex; private String sexName; SexEnum(Integer sex, String sexName) { this.sex = sex; this.sexName = sexName; } } 数据库增加一个sex 字段,实体类增加一个sex属性 实体类\nprivate SexEnum sex; 进行添加\n@Test public void testEnum(){ User user=new User(); user.setUserName(\u0026#34;enum - 测试名字\u0026#34;); user.setSexEnum(SexEnum.MALE); int insert = userMapper.insert(user); System.out.println(insert); } 注意看sql日志,有报错信息\n==\u0026gt; Preparing: INSERT INTO t_user ( name, sex ) VALUES ( ?, ? ) ==\u0026gt; Parameters: enum - 测试名字(String), MALE(String) ### SQL: INSERT INTO t_user ( name, sex ) VALUES ( ?, ? ) ### Cause: java.sql.SQLException: Incorrect integer value: \u0026#39;MALE\u0026#39; for column \u0026#39;sex\u0026#39; at row 1 插入了非数字\n修正,enum类添加注解\n@EnumValue //将注解所标识的属性的值设置到数据库 private Integer sex; 扫描通用枚举的包 application.yml中\nmybatis-plus: type-enums-package: com.ly.mybatisplus.enums 运行测试类并查看日志\n==\u0026gt; Preparing: INSERT INTO t_user ( name, sex ) VALUES ( ?, ? ) ==\u0026gt; Parameters: enum - 测试名字(String), 1(Integer) \u0026lt;== Updates: 1 代码生成器 # {% post_link study/mybatis_plus/official/hello 在28%进度的地方 %}\nmybatis-plus 代码自动生成\nmaven 依赖\n\u0026lt;!-- https://mvnrepository.com/artifact/com.baomidou/mybatis-plus-generator --\u0026gt; \u0026lt;dependency\u0026gt; \u0026lt;groupId\u0026gt;com.baomidou\u0026lt;/groupId\u0026gt; \u0026lt;artifactId\u0026gt;mybatis-plus-generator\u0026lt;/artifactId\u0026gt; \u0026lt;version\u0026gt;3.5.2\u0026lt;/version\u0026gt; \u0026lt;/dependency\u0026gt; \u0026lt;!-- https://mvnrepository.com/artifact/org.apache.velocity/velocity-engine-core --\u0026gt; \u0026lt;dependency\u0026gt; \u0026lt;groupId\u0026gt;org.apache.velocity\u0026lt;/groupId\u0026gt; \u0026lt;artifactId\u0026gt;velocity-engine-core\u0026lt;/artifactId\u0026gt; \u0026lt;version\u0026gt;2.3\u0026lt;/version\u0026gt; \u0026lt;/dependency\u0026gt; 在测试类中编写程序让其自动生成\nimport com.baomidou.mybatisplus.generator.FastAutoGenerator; import com.baomidou.mybatisplus.generator.config.DataSourceConfig; import org.apache.ibatis.jdbc.ScriptRunner; import java.io.InputStream; import java.io.InputStreamReader; import java.sql.Connection; import java.sql.SQLException; /** * \u0026lt;p\u0026gt; * 快速生成 * \u0026lt;/p\u0026gt; * * @author lanjerry * @since 2021-09-16 */ public class FastAutoGeneratorTest { /** * 执行初始化数据库脚本 */ public static void before() throws SQLException { Connection conn = DATA_SOURCE_CONFIG.build().getConn(); InputStream inputStream = FastAutoGeneratorTest.class.getResourceAsStream(\u0026#34;/db/schema-mysql.sql\u0026#34;); ScriptRunner scriptRunner = new ScriptRunner(conn); scriptRunner.setAutoCommit(true); scriptRunner.runScript(new InputStreamReader(inputStream)); conn.close(); } /** * 数据源配置 */ private static final DataSourceConfig.Builder DATA_SOURCE_CONFIG = new DataSourceConfig .Builder(\u0026#34;jdbc:mysql://localhost:3306/mybatis_plus_demo?useUnicode=true\u0026amp;characterEncoding=utf-8\u0026amp;allowMultiQueries=true\u0026amp;nullCatalogMeansCurrent=true\u0026#34;, \u0026#34;root\u0026#34;, \u0026#34;123456\u0026#34;); /** * 执行 run */ public static void main(String[] args) throws SQLException { before(); FastAutoGenerator.create(DATA_SOURCE_CONFIG) // 全局配置 .globalConfig((scanner, builder) -\u0026gt; builder.author(scanner.apply(\u0026#34;请输入作者名称\u0026#34;))) // 包配置 .packageConfig((scanner, builder) -\u0026gt; builder.parent(scanner.apply(\u0026#34;请输入包名\u0026#34;))) // 策略配置 .strategyConfig((scanner, builder) -\u0026gt; builder.addInclude(scanner.apply(\u0026#34;请输入表名,多个表名用,隔开\u0026#34;))) /* 模板引擎配置,默认 Velocity 可选模板引擎 Beetl 或 Freemarker .templateEngine(new BeetlTemplateEngine()) .templateEngine(new FreemarkerTemplateEngine()) */ .execute(); } } shang gui gu 配置 模拟多数据源环境 # 新建一个mybatis-plus数据库和表 maven依赖添加\n\u0026lt;!-- https://mvnrepository.com/artifact/com.baomidou/dynamic-datasource-spring-boot-starter --\u0026gt; \u0026lt;dependency\u0026gt; \u0026lt;groupId\u0026gt;com.baomidou\u0026lt;/groupId\u0026gt; \u0026lt;artifactId\u0026gt;dynamic-datasource-spring-boot-starter\u0026lt;/artifactId\u0026gt; \u0026lt;version\u0026gt;3.5.1\u0026lt;/version\u0026gt; \u0026lt;/dependency\u0026gt; 前提 使用mybatis_plus中的t_product表 及mybatis_plus1中的t_product1表\nyml配置\nspring: datasource: dynamic: primary: master #设置默认的数据源或者数据源组,默认值即为master strict: false #严格匹配数据源,默认false. true未匹配到指定数据源时抛异常,false使用默认数据源 datasource: master: url: jdbc:mysql://localhost:3306/mybatis_plus?characterEncoding=utf-8\u0026amp;\u0026amp;useSSL=false\u0026amp;\u0026amp;allowPublicKeyRetrieval=true username: root password: 123456 driver-class-name: com.mysql.jdbc.Driver # 3.2.0开始支持SPI可省略此配置 slave_1: url: jdbc:mysql://localhost:3306/mybatis_plus_1?characterEncoding=utf-8\u0026amp;\u0026amp;useSSL=false\u0026amp;\u0026amp;allowPublicKeyRetrieval=true username: root password: 123456 driver-class-name: com.mysql.jdbc.Driver #slave_2: # url: ENC(xxxxx) # 内置加密,使用请查看详细文档 # username: ENC(xxxxx) # password: ENC(xxxxx) # driver-class-name: com.mysql.jdbc.Driver #......省略 #以上会配置一个默认库master,一个组slave下有两个子库slave_1,slave_2 代码\n结构 安装MyBatisX插件 # 插件市场 自动定位 MyBatis代码快速生成 # 配置 url及密码配置 使用 自动生成 "},{"id":375,"href":"/zh/docs/technology/MyBatis-Plus/bl_sgg_/19-39/","title":"mybatis-plus-sgg-19-39","section":"基础(尚硅谷)_","content":" 通用Service应用 # 这里会出现 publicKey is now allowed ,在数据库连接语句后面加上这句话即可 allowPublicKeyRetrieval=true\nspring: #配置数据源 datasource: #配置数据源类型 type: com.zaxxer.hikari.HikariDataSource #配置数据源各个信息 driver-class-name: com.mysql.cj.jdbc.Driver url: jdbc:mysql://localhost:3306/mybatis_plus?characterEncoding=utf-8\u0026amp;\u0026amp;useSSL=false\u0026amp;\u0026amp;allowPublicKeyRetrieval=true username: root password: 123456 查询\n@Test public void testList(){ //List\u0026lt;User\u0026gt; list = userService.list(); long count = userService.count(); System.out.println(\u0026#34;总条数:\u0026#34;+count); } SQL执行语句\n==\u0026gt; Preparing: SELECT COUNT( * ) FROM user ==\u0026gt; Parameters: \u0026lt;== Columns: COUNT( * ) \u0026lt;== Row: 5 \u0026lt;== Total: 1 批量添加\n@Test public void batchInsert(){ List\u0026lt;User\u0026gt; users=new ArrayList\u0026lt;\u0026gt;(); for(int i=0;i\u0026lt;10;i++){ User user=new User(); user.setName(\u0026#34;name\u0026#34;+i); user.setEmail(\u0026#34;email\u0026#34;+i); users.add(user); } boolean b = userService.saveBatch(users); System.out.println(\u0026#34;result:\u0026#34;+b); } sql日志输出\n==\u0026gt; Preparing: INSERT INTO user ( id, name, email ) VALUES ( ?, ?, ? ) ==\u0026gt; Parameters: 1532579686881243138(Long), name0(String), email0(String) ==\u0026gt; Parameters: 1532579687124512770(Long), name1(String), email1(String) ==\u0026gt; Parameters: 1532579687128707074(Long), name2(String), email2(String) ==\u0026gt; Parameters: 1532579687128707075(Long), name3(String), email3(String) ==\u0026gt; Parameters: 1532579687132901377(Long), name4(String), email4(String) ==\u0026gt; Parameters: 1532579687137095681(Long), name5(String), email5(String) ==\u0026gt; Parameters: 1532579687137095682(Long), name6(String), email6(String) ==\u0026gt; Parameters: 1532579687141289985(Long), name7(String), email7(String) ==\u0026gt; Parameters: 1532579687145484289(Long), name8(String), email8(String) ==\u0026gt; Parameters: 1532579687145484290(Long), name9(String), email9(String) result:true 注意,这里是一个个的insert into ,而不是一条(单个的sql语句进行循环添加)\nMyBatis-Plus常用注解1 # 现在将mysql数据库表user名改为t_user 会提示下面的报错\nCause: java.sql.BatchUpdateException: Table \u0026#39;mybatis_plus.user\u0026#39; doesn\u0026#39;t exist 说明mybatis plus查询的时候会去找实体类名一样的表\n使用@TableName(\u0026ldquo;t_user\u0026rdquo;) 设置实体类对应的表名\n@Data @TableName(\u0026#34;t_user\u0026#34;) public class User { private Long id; private String name; private Integer age; private String email; } 修改后执行成功 统一添加\nmybatis-plus: configuration: global-config: db-config: table-prefix: t_ 指定主键名 假设现在把数据库列名和bean的属性名id改为uid,此时新增一条记录\nField \u0026#39;uid\u0026#39; doesn\u0026#39;t have a default value ; Field \u0026#39;uid\u0026#39; doesn\u0026#39;t have a default value; nested exception is java.sql.SQLException: Field \u0026#39;uid\u0026#39; doesn\u0026#39;t have a default value 说明此时没有为uid赋值 使用@TableId告诉mybatis-plus那个字段为主键,让mybatis-plus为他赋默认值\n@Data public class User { @TableId private Long uid; private String name; private Integer age; private String email; } sql打印\n==\u0026gt; Preparing: INSERT INTO t_user ( uid, name, age ) VALUES ( ?, ?, ? ) ==\u0026gt; Parameters: 1532582462671618050(Long), 张三(String), 18(Integer) \u0026lt;== Updates: 1 @TableId的value属性 # 用于指定绑定的主键的字段 假设此时将bean的主键属性名为id,数据库主键名是uid\n此时运行,会提示\n### SQL: INSERT INTO t_user ( id, name, age ) VALUES ( ?, ?, ? ) ### Cause: java.sql.SQLSyntaxErrorException: Unknown column \u0026#39;id\u0026#39; in \u0026#39;field list\u0026#39; 他会拿bean的属性来生成sql语句\n加上@TableId(value=\u0026ldquo;uid\u0026rdquo;)后运行正常\n@TableId的value属性 # /** * 生成ID类型枚举类 * * @author hubin * @since 2015-11-10 */ @Getter public enum IdType { /** * 数据库ID自增 * \u0026lt;p\u0026gt;该类型请确保数据库设置了 ID自增 否则无效\u0026lt;/p\u0026gt; */ AUTO(0), /** * 该类型为未设置主键类型(注解里等于跟随全局,全局里约等于 INPUT) */ NONE(1), /** * 用户输入ID * \u0026lt;p\u0026gt;该类型可以通过自己注册自动填充插件进行填充\u0026lt;/p\u0026gt; */ INPUT(2), /* 以下3种类型、只有当插入对象ID 为空,才自动填充。 */ /** * 分配ID (主键类型为number或string), * 默认实现类 {@link com.baomidou.mybatisplus.core.incrementer.DefaultIdentifierGenerator}(雪花算法) * * @since 3.3.0 */ ASSIGN_ID(3), /** * 分配UUID (主键类型为 string) * 默认实现类 {@link com.baomidou.mybatisplus.core.incrementer.DefaultIdentifierGenerator}(UUID.replace(\u0026#34;-\u0026#34;,\u0026#34;\u0026#34;)) */ ASSIGN_UUID(4); private final int key; IdType(int key) { this.key = key; } } //使用自增 @TableId(value=\u0026#34;uid\u0026#34;,type = IdType.AUTO ) private Long id; 然后将数据库主键设置为自动递增\n新增后id为6\n通过全局属性设置主键生成策略 # 全局配置设置\nmybatis-plus: global-config: db-config: id-type: auto 雪花算法 # 数据库扩展方式:主从复制、业务分库、数据库分表 数据库拆分:水平拆分、垂直拆分 水平分表相对垂直分表,会引入更多的复杂性,比如要求唯一的数据id该怎么处理 可以给每个分表都给定一个范围大小,但是这样分段大小不好取 可以取模,但是如果增加了机器,原来的值主键(怎么处理是个问题 雪花算法,由Twitter公布的分布式主键生成算法 能够保证不同表的主键的不重复性,以及相同表的主键的有序性 核心思想 MyBatis-Plus常用注解2 # 此时数据库字段名为name,如果现在实体类的名字改为userName,那么会报错\nINSERT INTO t_user ( user_name, age ) VALUES ( ?, ? ) 又一次证明了MyBatis-plus通过实体类属性猜测数据库表的相关字段\n使用@TableFiled来指定对应的字段名\n@TableField(value = \u0026#34;name\u0026#34;) private String userName; 查询\n代码\n@Test public void selectTest() { User user = userService.getById(5L); System.out.println(\u0026#34;结果:\u0026#34; + user); } sql执行语句\n==\u0026gt; Preparing: SELECT uid AS id,name AS userName,age,email,is_deleted_ly FROM t_user WHERE uid=? AND is_deleted_ly=0 ==\u0026gt; Parameters: 5(Long) \u0026lt;== Columns: id, userName, age, email, is_deleted_ly \u0026lt;== Row: 5, Billie, 24, email被修改了, 0 \u0026lt;== Total: 1 Closing non transactional SqlSession [org.apache.ibatis.session.defaults.DefaultSqlSession@5e048149] 结果:User(id=5, userName=Billie, age=24, email=email被修改了, isDeletedLy=0) 逻辑删除(主要是允许数据的恢复) 这里增加一个isDeletedLy字段(这里为了测试,一般是isDeleted)\n在User类添加下面的字段\n@TableLogic private Integer isDeletedLy; 逻辑删除\n代码\n@Test public void deleteLogic() { boolean save = userService.removeBatchByIds(Arrays.asList(1L,2L,3L)); System.out.println(\u0026#34;结果:\u0026#34; + save); } sql执行语句 注意,这里使用了is_deleted_ly=0是因为在下面的步骤加入了逻辑删除注解\n==\u0026gt; Preparing: UPDATE t_user SET is_deleted_ly=1 WHERE uid=? AND is_deleted_ly=0 ==\u0026gt; Parameters: 1(Long) ==\u0026gt; Parameters: 2(Long) ==\u0026gt; Parameters: 3(Long) 结果 条件构造器 # 结构 解释 查看BaseWrapper源码\n/** * Mapper 继承该接口后,无需编写 mapper.xml 文件,即可获得CRUD功能 * \u0026lt;p\u0026gt;这个 Mapper 支持 id 泛型\u0026lt;/p\u0026gt; * * @author hubin * @since 2016-01-23 */ public interface BaseMapper\u0026lt;T\u0026gt; extends Mapper\u0026lt;T\u0026gt; { /** * 插入一条记录 * * @param entity 实体对象 */ int insert(T entity); /** * 根据 ID 删除 * * @param id 主键ID */ int deleteById(Serializable id); /** * 根据实体(ID)删除 * * @param entity 实体对象 * @since 3.4.4 */ int deleteById(T entity); /** * 根据 columnMap 条件,删除记录 * * @param columnMap 表字段 map 对象 */ int deleteByMap(@Param(Constants.COLUMN_MAP) Map\u0026lt;String, Object\u0026gt; columnMap); /** * 根据 entity 条件,删除记录 * * @param queryWrapper 实体对象封装操作类(可以为 null,里面的 entity 用于生成 where 语句) */ int delete(@Param(Constants.WRAPPER) Wrapper\u0026lt;T\u0026gt; queryWrapper); /** * 删除(根据ID或实体 批量删除) * * @param idList 主键ID列表或实体列表(不能为 null 以及 empty) */ int deleteBatchIds(@Param(Constants.COLLECTION) Collection\u0026lt;?\u0026gt; idList); /** * 根据 ID 修改 * * @param entity 实体对象 */ int updateById(@Param(Constants.ENTITY) T entity); /** * 根据 whereEntity 条件,更新记录 * * @param entity 实体对象 (set 条件值,可以为 null) * @param updateWrapper 实体对象封装操作类(可以为 null,里面的 entity 用于生成 where 语句) */ int update(@Param(Constants.ENTITY) T entity, @Param(Constants.WRAPPER) Wrapper\u0026lt;T\u0026gt; updateWrapper); /** * 根据 ID 查询 * * @param id 主键ID */ T selectById(Serializable id); /** * 查询(根据ID 批量查询) * * @param idList 主键ID列表(不能为 null 以及 empty) */ List\u0026lt;T\u0026gt; selectBatchIds(@Param(Constants.COLLECTION) Collection\u0026lt;? extends Serializable\u0026gt; idList); /** * 查询(根据 columnMap 条件) * * @param columnMap 表字段 map 对象 */ List\u0026lt;T\u0026gt; selectByMap(@Param(Constants.COLUMN_MAP) Map\u0026lt;String, Object\u0026gt; columnMap); /** * 根据 entity 条件,查询一条记录 * \u0026lt;p\u0026gt;查询一条记录,例如 qw.last(\u0026#34;limit 1\u0026#34;) 限制取一条记录, 注意:多条数据会报异常\u0026lt;/p\u0026gt; * * @param queryWrapper 实体对象封装操作类(可以为 null) */ default T selectOne(@Param(Constants.WRAPPER) Wrapper\u0026lt;T\u0026gt; queryWrapper) { List\u0026lt;T\u0026gt; ts = this.selectList(queryWrapper); if (CollectionUtils.isNotEmpty(ts)) { if (ts.size() != 1) { throw ExceptionUtils.mpe(\u0026#34;One record is expected, but the query result is multiple records\u0026#34;); } return ts.get(0); } return null; } /** * 根据 Wrapper 条件,判断是否存在记录 * * @param queryWrapper 实体对象封装操作类 * @return */ default boolean exists(Wrapper\u0026lt;T\u0026gt; queryWrapper) { Long count = this.selectCount(queryWrapper); return null != count \u0026amp;\u0026amp; count \u0026gt; 0; } /** * 根据 Wrapper 条件,查询总记录数 * * @param queryWrapper 实体对象封装操作类(可以为 null) */ Long selectCount(@Param(Constants.WRAPPER) Wrapper\u0026lt;T\u0026gt; queryWrapper); /** * 根据 entity 条件,查询全部记录 * * @param queryWrapper 实体对象封装操作类(可以为 null) */ List\u0026lt;T\u0026gt; selectList(@Param(Constants.WRAPPER) Wrapper\u0026lt;T\u0026gt; queryWrapper); /** * 根据 Wrapper 条件,查询全部记录 * * @param queryWrapper 实体对象封装操作类(可以为 null) */ List\u0026lt;Map\u0026lt;String, Object\u0026gt;\u0026gt; selectMaps(@Param(Constants.WRAPPER) Wrapper\u0026lt;T\u0026gt; queryWrapper); /** * 根据 Wrapper 条件,查询全部记录 * \u0026lt;p\u0026gt;注意: 只返回第一个字段的值\u0026lt;/p\u0026gt; * * @param queryWrapper 实体对象封装操作类(可以为 null) */ List\u0026lt;Object\u0026gt; selectObjs(@Param(Constants.WRAPPER) Wrapper\u0026lt;T\u0026gt; queryWrapper); /** * 根据 entity 条件,查询全部记录(并翻页) * * @param page 分页查询条件(可以为 RowBounds.DEFAULT) * @param queryWrapper 实体对象封装操作类(可以为 null) */ \u0026lt;P extends IPage\u0026lt;T\u0026gt;\u0026gt; P selectPage(P page, @Param(Constants.WRAPPER) Wrapper\u0026lt;T\u0026gt; queryWrapper); /** * 根据 Wrapper 条件,查询全部记录(并翻页) * * @param page 分页查询条件 * @param queryWrapper 实体对象封装操作类 */ \u0026lt;P extends IPage\u0026lt;Map\u0026lt;String, Object\u0026gt;\u0026gt;\u0026gt; P selectMapsPage(P page, @Param(Constants.WRAPPER) Wrapper\u0026lt;T\u0026gt; queryWrapper); } Wrapper条件组装 queryWrapper测试\n@Test public void test01() { QueryWrapper\u0026lt;User\u0026gt; userQueryWrapper = new QueryWrapper\u0026lt;\u0026gt;(); //链式结构调用 userQueryWrapper.like(\u0026#34;name\u0026#34;, \u0026#34;a\u0026#34;) .between(\u0026#34;age\u0026#34;, 10, 30) .isNotNull(\u0026#34;email\u0026#34;); List\u0026lt;User\u0026gt; users = userMapper.selectList(userQueryWrapper); users.forEach(System.out::println); } sql日志打印\n//注意,这里出现了逻辑删除条件 ==\u0026gt; Preparing: SELECT uid AS id,name AS userName,age,email,is_deleted_ly FROM t_user WHERE is_deleted_ly=0 AND (name LIKE ? AND age BETWEEN ? AND ? AND email IS NOT NULL) ==\u0026gt; Parameters: %a%(String), 10(Integer), 30(Integer) \u0026lt;== Columns: id, userName, age, email, is_deleted_ly \u0026lt;== Row: 4, Sandy, 21, test4@baomidou.com, 0 \u0026lt;== Row: 5, Billiea, 24, email被修改了, 0 \u0026lt;== Total: 2 Closing non transactional SqlSession [org.apache.ibatis.session.defaults.DefaultSqlSession@19650aa6] User(id=4, userName=Sandy, age=21, email=test4@baomidou.com, isDeletedLy=0) User(id=5, userName=Billiea, age=24, email=email被修改了, isDeletedLy=0) 使用排序\n@Test public void test02() { QueryWrapper\u0026lt;User\u0026gt; userQueryWrapper = new QueryWrapper\u0026lt;\u0026gt;(); userQueryWrapper.orderByDesc(\u0026#34;age\u0026#34;) .orderByAsc(\u0026#34;uid\u0026#34;); List\u0026lt;User\u0026gt; users = userMapper.selectList(userQueryWrapper); users.forEach(System.out::println); } sql日志打印\n==\u0026gt; Preparing: SELECT uid AS id,name AS userName,age,email,is_deleted_ly FROM t_user WHERE is_deleted_ly=0 ORDER BY age DESC,uid ASC ==\u0026gt; Parameters: \u0026lt;== Columns: id, userName, age, email, is_deleted_ly \u0026lt;== Row: 7, 张三6, 38, test6@baomidou.com, 0 \u0026lt;== Row: 5, Billiea, 24, email被修改了, 0 \u0026lt;== Row: 4, Sandy, 21, test4@baomidou.com, 0 \u0026lt;== Row: 6, 张三5, 18, test5@baomidou.com, 0 \u0026lt;== Row: 8, 张三a, 18, null, 0 \u0026lt;== Total: 5 Closing non transactional SqlSession [org.apache.ibatis.session.defaults.DefaultSqlSession@7158daf2] User(id=7, userName=张三6, age=38, email=test6@baomidou.com, isDeletedLy=0) User(id=5, userName=Billiea, age=24, email=email被修改了, isDeletedLy=0) User(id=4, userName=Sandy, age=21, email=test4@baomidou.com, isDeletedLy=0) User(id=6, userName=张三5, age=18, email=test5@baomidou.com, isDeletedLy=0) User(id=8, userName=张三a, age=18, email=null, isDeletedLy=0) 条件逻辑删除\n代码\n@Test public void test03() { QueryWrapper\u0026lt;User\u0026gt; userQueryWrapper=new QueryWrapper\u0026lt;\u0026gt;(); userQueryWrapper.isNull(\u0026#34;email\u0026#34;); int deleted = userMapper.delete(userQueryWrapper); System.out.println(deleted); } sql日志输出\n==\u0026gt; Preparing: UPDATE t_user SET is_deleted_ly=1 WHERE is_deleted_ly=0 AND (email IS NULL) ==\u0026gt; Parameters: \u0026lt;== Updates: 1 修改\n@Test public void test04() { QueryWrapper\u0026lt;User\u0026gt; userQueryWrapper=new QueryWrapper\u0026lt;\u0026gt;(); //(age\u0026gt;23且用户名包含a) 或 (邮箱为null) userQueryWrapper.gt(\u0026#34;age\u0026#34;,23) .like(\u0026#34;name\u0026#34;,\u0026#34;a\u0026#34;) .or() .isNull(\u0026#34;email\u0026#34;); User user=new User(); user.setUserName(\u0026#34;被修改了\u0026#34;); int deleted = userMapper.update(user,userQueryWrapper); System.out.println(deleted); } sql日志打印\n==\u0026gt; Preparing: UPDATE t_user SET name=? WHERE is_deleted_ly=0 AND (age \u0026gt; ? AND name LIKE ? OR email IS NULL) ==\u0026gt; Parameters: 被修改了(String), 23(Integer), %a%(String) \u0026lt;== Updates: 1 条件优先级\n@Test public void test05() { QueryWrapper\u0026lt;User\u0026gt; userQueryWrapper = new QueryWrapper\u0026lt;\u0026gt;(); //(age\u0026gt;23且用户名包含a) 或 (邮箱为null) userQueryWrapper .like(\u0026#34;name\u0026#34;, \u0026#34;a\u0026#34;) //and里面是一个条件构造器 .and( userQueryWrapper1 -\u0026gt; userQueryWrapper1.gt(\u0026#34;age\u0026#34;, 20) .or() .isNull(\u0026#34;email\u0026#34;) ); User user = new User(); user.setUserName(\u0026#34;被修改了\u0026#34;); int deleted = userMapper.update(user, userQueryWrapper); System.out.println(deleted); } sql日志输出\n==\u0026gt; Preparing: UPDATE t_user SET name=? WHERE is_deleted_ly=0 AND (name LIKE ? AND (age \u0026gt; ? OR email IS NULL)) ==\u0026gt; Parameters: 被修改了(String), %a%(String), 20(Integer) \u0026lt;== Updates: 1 注意 or也有优先级的参数 只查询某些字段\n@Test public void test06() { QueryWrapper\u0026lt;User\u0026gt; userQueryWrapper =new QueryWrapper\u0026lt;\u0026gt;(); userQueryWrapper.select(\u0026#34;uid\u0026#34;,\u0026#34;name\u0026#34;); List\u0026lt;Map\u0026lt;String, Object\u0026gt;\u0026gt; maps = userMapper.selectMaps(userQueryWrapper); System.out.println(maps); } sql输出\n==\u0026gt; Preparing: SELECT uid,name FROM t_user WHERE is_deleted_ly=0 ==\u0026gt; Parameters: \u0026lt;== Columns: uid, name \u0026lt;== Row: 4, 被修改了 \u0026lt;== Row: 5, 被修改了 \u0026lt;== Row: 6, 张三5 \u0026lt;== Row: 7, 张三6 \u0026lt;== Total: 4 子查询 假设需要完整下面的sql查询 代码\n@Test public void test7(){ //查询id小于等于100 QueryWrapper\u0026lt;User\u0026gt; userQueryWrapper=new QueryWrapper\u0026lt;\u0026gt;(); userQueryWrapper.inSql(\u0026#34;uid\u0026#34;, \u0026#34;select uid from t_user where uid \u0026lt;= 100\u0026#34;); List\u0026lt;User\u0026gt; users = userMapper.selectList(userQueryWrapper); users.forEach(System.out::println); } sql输出\n==\u0026gt; Preparing: SELECT uid AS id,name AS userName,age,email,is_deleted_ly FROM t_user WHERE is_deleted_ly=0 AND (uid IN (select uid from t_user where uid \u0026lt;= 100)) ==\u0026gt; Parameters: \u0026lt;== Columns: id, userName, age, email, is_deleted_ly \u0026lt;== Row: 4, 被修改了, 21, test4@baomidou.com, 0 \u0026lt;== Row: 5, 被修改了, 24, email被修改了, 0 \u0026lt;== Row: 6, 张三5, 18, test5@baomidou.com, 0 \u0026lt;== Row: 7, 张三6, 38, test6@baomidou.com, 0 \u0026lt;== Total: 4 UpdateWrapper\n@Test public void test8(){ //(age\u0026gt;23且用户名包含a) 或 (邮箱为null) UpdateWrapper\u0026lt;User\u0026gt; updateWrapper=new UpdateWrapper\u0026lt;\u0026gt;(); updateWrapper.like(\u0026#34;name\u0026#34;,\u0026#34;a\u0026#34;) .and(userUpdateWrapper -\u0026gt; userUpdateWrapper.gt(\u0026#34;age\u0026#34;,23).or().isNotNull(\u0026#34;email\u0026#34;)); updateWrapper.set(\u0026#34;name\u0026#34;,\u0026#34;小黑\u0026#34;).set(\u0026#34;email\u0026#34;,\u0026#34;abc@ly.com\u0026#34;); userMapper.update(null,updateWrapper); } sql日志输出\n==\u0026gt; Preparing: UPDATE t_user SET name=?,email=? WHERE is_deleted_ly=0 AND (name LIKE ? AND (age \u0026gt; ? OR email IS NOT NULL)) ==\u0026gt; Parameters: 小黑(String), abc@ly.com(String), %a%(String), 23(Integer) \u0026lt;== Updates: 0 模拟用户操作组装条件\n@Test public void test9(){ String username=\u0026#34;\u0026#34;; Integer ageBegin=null; Integer ageEnd=30; QueryWrapper\u0026lt;User\u0026gt; queryWrapper=new QueryWrapper\u0026lt;\u0026gt;(); if(StringUtils.isNotBlank(username)){ queryWrapper.like(\u0026#34;user_name\u0026#34;,username); } if( ageBegin!=null){ queryWrapper.gt(\u0026#34;age\u0026#34;,ageBegin); } if( ageEnd!=null){ queryWrapper.le(\u0026#34;age\u0026#34;,ageEnd); } userMapper.selectList(queryWrapper); } sql日志打印\n==\u0026gt; Preparing: SELECT uid AS id,name AS userName,age,email,is_deleted_ly FROM t_user WHERE is_deleted_ly=0 AND (age \u0026lt;= ?) ==\u0026gt; Parameters: 30(Integer) \u0026lt;== Columns: id, userName, age, email, is_deleted_ly \u0026lt;== Row: 4, 被修改了, 21, test4@baomidou.com, 0 \u0026lt;== Row: 5, 被修改了, 24, email被修改了, 0 \u0026lt;== Row: 6, 张三5, 18, test5@baomidou.com, 0 \u0026lt;== Total: 3 使用condition处理条件\n@Test public void test10(){ String username=\u0026#34;abc\u0026#34;; Integer ageBegin=null; Integer ageEnd=30; QueryWrapper\u0026lt;User\u0026gt; queryWrapper=new QueryWrapper\u0026lt;\u0026gt;(); queryWrapper.like(StringUtils.isNotBlank(username),\u0026#34;name\u0026#34;,username) .ge(ageBegin!=null,\u0026#34;age\u0026#34;,ageBegin); userMapper.selectList(queryWrapper); } sql日志输出\n==\u0026gt; Preparing: SELECT uid AS id,name AS userName,age,email,is_deleted_ly FROM t_user WHERE is_deleted_ly=0 AND (name LIKE ?) ==\u0026gt; Parameters: %abc%(String) \u0026lt;== Total: 0 "},{"id":376,"href":"/zh/docs/technology/MyBatis-Plus/bl_sgg_/12-18/","title":"mybatis-plus-sgg-12-18","section":"基础(尚硅谷)_","content":" BaseMapper # 注:使用 mvn dependency:resolve -Dclassifier=sources 来获得mapper源码\n一些接口介绍\n/** * 插入一条记录 * * @param entity 实体对象 */ int insert(T entity); /** * 根据 ID 删除 * * @param id 主键ID */ int deleteById(Serializable id); /** * 根据实体(ID)删除 * * @param entity 实体对象 * @since 3.4.4 */ int deleteById(T entity); /** * 根据 columnMap 条件,删除记录 * * @param columnMap 表字段 map 对象 */ int deleteByMap(@Param(Constants.COLUMN_MAP) Map\u0026lt;String, Object\u0026gt; columnMap); /** * 根据 entity 条件,删除记录 * * @param queryWrapper 实体对象封装操作类(可以为 null,里面的 entity 用于生成 where 语句) */ int delete(@Param(Constants.WRAPPER) Wrapper\u0026lt;T\u0026gt; queryWrapper); /** * 删除(根据ID或实体 批量删除) * * @param idList 主键ID列表或实体列表(不能为 null 以及 empty) */ int deleteBatchIds(@Param(Constants.COLLECTION) Collection\u0026lt;?\u0026gt; idList); /** * 根据 ID 修改 * * @param entity 实体对象 */ int updateById(@Param(Constants.ENTITY) T entity); /** * 根据 whereEntity 条件,更新记录 * * @param entity 实体对象 (set 条件值,可以为 null) * @param updateWrapper 实体对象封装操作类(可以为 null,里面的 entity 用于生成 where 语句) */ int update(@Param(Constants.ENTITY) T entity, @Param(Constants.WRAPPER) Wrapper\u0026lt;T\u0026gt; updateWrapper); /** * 根据 ID 查询 * * @param id 主键ID */ T selectById(Serializable id); /** * 查询(根据ID 批量查询) * * @param idList 主键ID列表(不能为 null 以及 empty) */ List\u0026lt;T\u0026gt; selectBatchIds(@Param(Constants.COLLECTION) Collection\u0026lt;? extends Serializable\u0026gt; idList); /** * 查询(根据 columnMap 条件) * * @param columnMap 表字段 map 对象 */ List\u0026lt;T\u0026gt; selectByMap(@Param(Constants.COLUMN_MAP) Map\u0026lt;String, Object\u0026gt; columnMap); /** * 根据 entity 条件,查询一条记录 * \u0026lt;p\u0026gt;查询一条记录,例如 qw.last(\u0026#34;limit 1\u0026#34;) 限制取一条记录, 注意:多条数据会报异常\u0026lt;/p\u0026gt; * * @param queryWrapper 实体对象封装操作类(可以为 null) */ default T selectOne(@Param(Constants.WRAPPER) Wrapper\u0026lt;T\u0026gt; queryWrapper) { List\u0026lt;T\u0026gt; ts = this.selectList(queryWrapper); if (CollectionUtils.isNotEmpty(ts)) { if (ts.size() != 1) { throw ExceptionUtils.mpe(\u0026#34;One record is expected, but the query result is multiple records\u0026#34;); } return ts.get(0); } return null; } /** * 根据 Wrapper 条件,判断是否存在记录 * * @param queryWrapper 实体对象封装操作类 * @return */ default boolean exists(Wrapper\u0026lt;T\u0026gt; queryWrapper) { Long count = this.selectCount(queryWrapper); return null != count \u0026amp;\u0026amp; count \u0026gt; 0; } /** * 根据 Wrapper 条件,查询总记录数 * * @param queryWrapper 实体对象封装操作类(可以为 null) */ Long selectCount(@Param(Constants.WRAPPER) Wrapper\u0026lt;T\u0026gt; queryWrapper); /** * 根据 entity 条件,查询全部记录 * * @param queryWrapper 实体对象封装操作类(可以为 null) */ List\u0026lt;T\u0026gt; selectList(@Param(Constants.WRAPPER) Wrapper\u0026lt;T\u0026gt; queryWrapper); /** * 根据 Wrapper 条件,查询全部记录 * * @param queryWrapper 实体对象封装操作类(可以为 null) */ List\u0026lt;Map\u0026lt;String, Object\u0026gt;\u0026gt; selectMaps(@Param(Constants.WRAPPER) Wrapper\u0026lt;T\u0026gt; queryWrapper); /** * 根据 Wrapper 条件,查询全部记录 * \u0026lt;p\u0026gt;注意: 只返回第一个字段的值\u0026lt;/p\u0026gt; * * @param queryWrapper 实体对象封装操作类(可以为 null) */ List\u0026lt;Object\u0026gt; selectObjs(@Param(Constants.WRAPPER) Wrapper\u0026lt;T\u0026gt; queryWrapper); /** * 根据 entity 条件,查询全部记录(并翻页) * * @param page 分页查询条件(可以为 RowBounds.DEFAULT) * @param queryWrapper 实体对象封装操作类(可以为 null) */ \u0026lt;P extends IPage\u0026lt;T\u0026gt;\u0026gt; P selectPage(P page, @Param(Constants.WRAPPER) Wrapper\u0026lt;T\u0026gt; queryWrapper); /** * 根据 Wrapper 条件,查询全部记录(并翻页) * * @param page 分页查询条件 * @param queryWrapper 实体对象封装操作类 */ \u0026lt;P extends IPage\u0026lt;Map\u0026lt;String, Object\u0026gt;\u0026gt;\u0026gt; P selectMapsPage(P page, @Param(Constants.WRAPPER) Wrapper\u0026lt;T\u0026gt; queryWrapper); BaseMapper测试\n新增\n@Test public void testInsert(){ User user=new User(); user.setName(\u0026#34;小明\u0026#34;); user.setAge(11); user.setEmail(\u0026#34;xx@163.com\u0026#34;); int insertNum = userMapper.insert(user); System.out.println(\u0026#34;result:\u0026#34;+insertNum); System.out.println(\u0026#34;result:\u0026#34;+user); } sql日志输出\n==\u0026gt; Preparing: INSERT INTO user ( id, name, age, email ) VALUES ( ?, ?, ?, ? ) ==\u0026gt; Parameters: 1532542803866394625(Long), 小明(String), 11(Integer), xx@163.com(String) \u0026lt;== Updates: 1 删除\nid删除\n@Test public void testDelete(){ int result = userMapper.deleteById(1532542803866394625L); System.out.println(result); } sql日志输出\n==\u0026gt; Preparing: DELETE FROM user WHERE id=? ==\u0026gt; Parameters: 1532542803866394625(Long) \u0026lt;== Updates: 1 Map删除\n@Test public void testDeleteByMap(){ Map\u0026lt;String,Object\u0026gt; hash=new HashMap\u0026lt;\u0026gt;(); hash.put(\u0026#34;name\u0026#34;,\u0026#34;Sandy\u0026#34;); hash.put(\u0026#34;age\u0026#34;,\u0026#34;1234\u0026#34;); int result = userMapper.deleteByMap(hash); System.out.println(result); } sql日志输出\n==\u0026gt; Preparing: DELETE FROM user WHERE name = ? AND age = ? ==\u0026gt; Parameters: Sandy(String), 1234(String) \u0026lt;== Updates: 0 批量删除\n@Test public void testDeleteByIds(){ List\u0026lt;Long\u0026gt; ids = Arrays.asList(1L, 2L, 5L); int result = userMapper.deleteBatchIds(ids); System.out.println(result); } sql日志输出\n==\u0026gt; Preparing: DELETE FROM user WHERE id IN ( ? , ? , ? ) ==\u0026gt; Parameters: 1(Long), 2(Long), 5(Long) \u0026lt;== Updates: 3 修改\n根据id修改\n@Test public void testUpdateById (){ User user=new User(); user.setId(5L); user.setEmail(\u0026#34;email被修改了\u0026#34; ); int result = userMapper.updateById(user); System.out.println(result); } sql日志输出\n==\u0026gt; Preparing: UPDATE user SET email=? WHERE id=? ==\u0026gt; Parameters: email被修改了(String), 5(Long) \u0026lt;== Updates: 1 注意,这里不会修改另一个字段name的值\n查询\n通过id查询用户信息\n@Test public void testSelectById (){ User user = userMapper.selectById(3); System.out.println(user); } sql日志输出\n==\u0026gt; Preparing: SELECT id,name,age,email FROM user WHERE id=? ==\u0026gt; Parameters: 3(Integer) \u0026lt;== Columns: id, name, age, email \u0026lt;== Row: 3, Tom, 28, test3@baomidou.com \u0026lt;== Total: 1 通过id集合查询\n@Test public void testSelectByIds() { List\u0026lt;User\u0026gt; users = userMapper.selectBatchIds(Arrays.asList(1L, 2L, 5L)); users.forEach(System.out::println); } sql日志输出\n==\u0026gt; Preparing: SELECT id,name,age,email FROM user WHERE id IN ( ? , ? , ? ) ==\u0026gt; Parameters: 1(Long), 2(Long), 5(Long) \u0026lt;== Columns: id, name, age, email \u0026lt;== Row: 1, Jone, 18, test1@baomidou.com \u0026lt;== Row: 2, Jack, 20, test2@baomidou.com \u0026lt;== Row: 5, Billie, 24, email被修改了 \u0026lt;== Total: 3 通过map查询\n@Test public void testSelectMap() { Map\u0026lt;String, Object\u0026gt; hashMap = new HashMap\u0026lt;\u0026gt;(); hashMap.put(\u0026#34;name\u0026#34;,\u0026#34;Jon\u0026#34;); hashMap.put(\u0026#34;age\u0026#34;,18); List\u0026lt;User\u0026gt; users = userMapper.selectByMap(hashMap); users.forEach(System.out::println); } sql日志输出\n==\u0026gt; Preparing: SELECT id,name,age,email FROM user WHERE name = ? AND age = ? ==\u0026gt; Parameters: Tom(String), 18(Integer) \u0026lt;== Columns: id, name, age, email \u0026lt;== Row: 3, Tom, 18, test3@baomidou.com \u0026lt;== Total: 1 查询所有数据\n@Test public void testSelectAll() { List\u0026lt;User\u0026gt; users = userMapper.selectList(null); users.forEach(System.out::println); } sql日志输出\n==\u0026gt; Preparing: SELECT id,name,age,email FROM user ==\u0026gt; Parameters: \u0026lt;== Columns: id, name, age, email \u0026lt;== Row: 1, Jone, 18, test1@baomidou.com \u0026lt;== Row: 2, Jack, 20, test2@baomidou.com \u0026lt;== Row: 3, Tom, 18, test3@baomidou.com \u0026lt;== Row: 4, Sandy, 21, test4@baomidou.com \u0026lt;== Row: 5, Billie, 24, email被修改了 \u0026lt;== Total: 5 自定义功能 # mapper映射文件默认位置\nmybatis-plus: configuration: log-impl: org.apache.ibatis.logging.stdout.StdOutImpl mapper-locations: - classpath:/mapper/**/*.xml #默认位置 映射文件配置 /mapper/UserMapper.xml\n\u0026lt;?xml version=\u0026#34;1.0\u0026#34; encoding=\u0026#34;utf-8\u0026#34; ?\u0026gt; \u0026lt;!DOCTYPE mapper PUBLIC \u0026#34;-//mybatis.org//DTD Mapper 3.0//EN\u0026#34; \u0026#34;http://mybatis.org/dtd/mybatis-3-mapper.dtd\u0026#34; \u0026gt; \u0026lt;mapper namespace=\u0026#34;com.ly.mybatisplus.mapper.UserMapper\u0026#34;\u0026gt; \u0026lt;select id=\u0026#34;selectMapById\u0026#34; resultType=\u0026#34;map\u0026#34;\u0026gt; select id,name,age,email from user where id = #{id} and 1=1 \u0026lt;/select\u0026gt; \u0026lt;/mapper\u0026gt; 代码执行\n@Test public void testSelectCustom() { Map\u0026lt;String, Object\u0026gt; map = userMapper.selectMapById(2L); System.out.println(map); } sql日志执行\n==\u0026gt; Preparing: select id,name,age,email from user where id = ? and 1=1 ==\u0026gt; Parameters: 2(Long) \u0026lt;== Columns: id, name, age, email \u0026lt;== Row: 2, Jack, 20, test2@baomidou.com \u0026lt;== Total: 1 通用Service接口 # 和通用Mapper的方法名有区分 Service CRUD中\n使用get查询【mapper-select】 remove删除 【mapper-delete】 list查询集合 page分页 IService源码\n/** * 顶级 Service * * @author hubin * @since 2018-06-23 */ public interface IService\u0026lt;T\u0026gt; { /** * 默认批次提交数量 */ int DEFAULT_BATCH_SIZE = 1000; /** * 插入一条记录(选择字段,策略插入) * * @param entity 实体对象 */ default boolean save(T entity) { return SqlHelper.retBool(getBaseMapper().insert(entity)); } /** * 插入(批量) * * @param entityList 实体对象集合 */ @Transactional(rollbackFor = Exception.class) default boolean saveBatch(Collection\u0026lt;T\u0026gt; entityList) { return saveBatch(entityList, DEFAULT_BATCH_SIZE); } /** * 插入(批量) * * @param entityList 实体对象集合 * @param batchSize 插入批次数量 */ boolean saveBatch(Collection\u0026lt;T\u0026gt; entityList, int batchSize); /** * 批量修改插入 * * @param entityList 实体对象集合 */ @Transactional(rollbackFor = Exception.class) default boolean saveOrUpdateBatch(Collection\u0026lt;T\u0026gt; entityList) { return saveOrUpdateBatch(entityList, DEFAULT_BATCH_SIZE); } /** * 批量修改插入 * * @param entityList 实体对象集合 * @param batchSize 每次的数量 */ boolean saveOrUpdateBatch(Collection\u0026lt;T\u0026gt; entityList, int batchSize); /** * 根据 ID 删除 * * @param id 主键ID */ default boolean removeById(Serializable id) { return SqlHelper.retBool(getBaseMapper().deleteById(id)); } /** * 根据 ID 删除 * * @param id 主键(类型必须与实体类型字段保持一致) * @param useFill 是否启用填充(为true的情况,会将入参转换实体进行delete删除) * @return 删除结果 * @since 3.5.0 */ default boolean removeById(Serializable id, boolean useFill) { throw new UnsupportedOperationException(\u0026#34;不支持的方法!\u0026#34;); } /** * 根据实体(ID)删除 * * @param entity 实体 * @since 3.4.4 */ default boolean removeById(T entity) { return SqlHelper.retBool(getBaseMapper().deleteById(entity)); } /** * 根据 columnMap 条件,删除记录 * * @param columnMap 表字段 map 对象 */ default boolean removeByMap(Map\u0026lt;String, Object\u0026gt; columnMap) { Assert.notEmpty(columnMap, \u0026#34;error: columnMap must not be empty\u0026#34;); return SqlHelper.retBool(getBaseMapper().deleteByMap(columnMap)); } /** * 根据 entity 条件,删除记录 * * @param queryWrapper 实体包装类 {@link com.baomidou.mybatisplus.core.conditions.query.QueryWrapper} */ default boolean remove(Wrapper\u0026lt;T\u0026gt; queryWrapper) { return SqlHelper.retBool(getBaseMapper().delete(queryWrapper)); } /** * 删除(根据ID 批量删除) * * @param list 主键ID或实体列表 */ default boolean removeByIds(Collection\u0026lt;?\u0026gt; list) { if (CollectionUtils.isEmpty(list)) { return false; } return SqlHelper.retBool(getBaseMapper().deleteBatchIds(list)); } /** * 批量删除 * * @param list 主键ID或实体列表 * @param useFill 是否填充(为true的情况,会将入参转换实体进行delete删除) * @return 删除结果 * @since 3.5.0 */ @Transactional(rollbackFor = Exception.class) default boolean removeByIds(Collection\u0026lt;?\u0026gt; list, boolean useFill) { if (CollectionUtils.isEmpty(list)) { return false; } if (useFill) { return removeBatchByIds(list, true); } return SqlHelper.retBool(getBaseMapper().deleteBatchIds(list)); } /** * 批量删除(jdbc批量提交) * * @param list 主键ID或实体列表(主键ID类型必须与实体类型字段保持一致) * @return 删除结果 * @since 3.5.0 */ @Transactional(rollbackFor = Exception.class) default boolean removeBatchByIds(Collection\u0026lt;?\u0026gt; list) { return removeBatchByIds(list, DEFAULT_BATCH_SIZE); } /** * 批量删除(jdbc批量提交) * * @param list 主键ID或实体列表(主键ID类型必须与实体类型字段保持一致) * @param useFill 是否启用填充(为true的情况,会将入参转换实体进行delete删除) * @return 删除结果 * @since 3.5.0 */ @Transactional(rollbackFor = Exception.class) default boolean removeBatchByIds(Collection\u0026lt;?\u0026gt; list, boolean useFill) { return removeBatchByIds(list, DEFAULT_BATCH_SIZE, useFill); } /** * 批量删除(jdbc批量提交) * * @param list 主键ID或实体列表 * @param batchSize 批次大小 * @return 删除结果 * @since 3.5.0 */ default boolean removeBatchByIds(Collection\u0026lt;?\u0026gt; list, int batchSize) { throw new UnsupportedOperationException(\u0026#34;不支持的方法!\u0026#34;); } /** * 批量删除(jdbc批量提交) * * @param list 主键ID或实体列表 * @param batchSize 批次大小 * @param useFill 是否启用填充(为true的情况,会将入参转换实体进行delete删除) * @return 删除结果 * @since 3.5.0 */ default boolean removeBatchByIds(Collection\u0026lt;?\u0026gt; list, int batchSize, boolean useFill) { throw new UnsupportedOperationException(\u0026#34;不支持的方法!\u0026#34;); } /** * 根据 ID 选择修改 * * @param entity 实体对象 */ default boolean updateById(T entity) { return SqlHelper.retBool(getBaseMapper().updateById(entity)); } /** * 根据 UpdateWrapper 条件,更新记录 需要设置sqlset * * @param updateWrapper 实体对象封装操作类 {@link com.baomidou.mybatisplus.core.conditions.update.UpdateWrapper} */ default boolean update(Wrapper\u0026lt;T\u0026gt; updateWrapper) { return update(null, updateWrapper); } /** * 根据 whereEntity 条件,更新记录 * * @param entity 实体对象 * @param updateWrapper 实体对象封装操作类 {@link com.baomidou.mybatisplus.core.conditions.update.UpdateWrapper} */ default boolean update(T entity, Wrapper\u0026lt;T\u0026gt; updateWrapper) { return SqlHelper.retBool(getBaseMapper().update(entity, updateWrapper)); } /** * 根据ID 批量更新 * * @param entityList 实体对象集合 */ @Transactional(rollbackFor = Exception.class) default boolean updateBatchById(Collection\u0026lt;T\u0026gt; entityList) { return updateBatchById(entityList, DEFAULT_BATCH_SIZE); } /** * 根据ID 批量更新 * * @param entityList 实体对象集合 * @param batchSize 更新批次数量 */ boolean updateBatchById(Collection\u0026lt;T\u0026gt; entityList, int batchSize); /** * TableId 注解存在更新记录,否插入一条记录 * * @param entity 实体对象 */ boolean saveOrUpdate(T entity); /** * 根据 ID 查询 * * @param id 主键ID */ default T getById(Serializable id) { return getBaseMapper().selectById(id); } /** * 查询(根据ID 批量查询) * * @param idList 主键ID列表 */ default List\u0026lt;T\u0026gt; listByIds(Collection\u0026lt;? extends Serializable\u0026gt; idList) { return getBaseMapper().selectBatchIds(idList); } /** * 查询(根据 columnMap 条件) * * @param columnMap 表字段 map 对象 */ default List\u0026lt;T\u0026gt; listByMap(Map\u0026lt;String, Object\u0026gt; columnMap) { return getBaseMapper().selectByMap(columnMap); } /** * 根据 Wrapper,查询一条记录 \u0026lt;br/\u0026gt; * \u0026lt;p\u0026gt;结果集,如果是多个会抛出异常,随机取一条加上限制条件 wrapper.last(\u0026#34;LIMIT 1\u0026#34;)\u0026lt;/p\u0026gt; * * @param queryWrapper 实体对象封装操作类 {@link com.baomidou.mybatisplus.core.conditions.query.QueryWrapper} */ default T getOne(Wrapper\u0026lt;T\u0026gt; queryWrapper) { return getOne(queryWrapper, true); } /** * 根据 Wrapper,查询一条记录 * * @param queryWrapper 实体对象封装操作类 {@link com.baomidou.mybatisplus.core.conditions.query.QueryWrapper} * @param throwEx 有多个 result 是否抛出异常 */ T getOne(Wrapper\u0026lt;T\u0026gt; queryWrapper, boolean throwEx); /** * 根据 Wrapper,查询一条记录 * * @param queryWrapper 实体对象封装操作类 {@link com.baomidou.mybatisplus.core.conditions.query.QueryWrapper} */ Map\u0026lt;String, Object\u0026gt; getMap(Wrapper\u0026lt;T\u0026gt; queryWrapper); /** * 根据 Wrapper,查询一条记录 * * @param queryWrapper 实体对象封装操作类 {@link com.baomidou.mybatisplus.core.conditions.query.QueryWrapper} * @param mapper 转换函数 */ \u0026lt;V\u0026gt; V getObj(Wrapper\u0026lt;T\u0026gt; queryWrapper, Function\u0026lt;? super Object, V\u0026gt; mapper); /** * 查询总记录数 * * @see Wrappers#emptyWrapper() */ default long count() { return count(Wrappers.emptyWrapper()); } /** * 根据 Wrapper 条件,查询总记录数 * * @param queryWrapper 实体对象封装操作类 {@link com.baomidou.mybatisplus.core.conditions.query.QueryWrapper} */ default long count(Wrapper\u0026lt;T\u0026gt; queryWrapper) { return SqlHelper.retCount(getBaseMapper().selectCount(queryWrapper)); } /** * 查询列表 * * @param queryWrapper 实体对象封装操作类 {@link com.baomidou.mybatisplus.core.conditions.query.QueryWrapper} */ default List\u0026lt;T\u0026gt; list(Wrapper\u0026lt;T\u0026gt; queryWrapper) { return getBaseMapper().selectList(queryWrapper); } /** * 查询所有 * * @see Wrappers#emptyWrapper() */ default List\u0026lt;T\u0026gt; list() { return list(Wrappers.emptyWrapper()); } /** * 翻页查询 * * @param page 翻页对象 * @param queryWrapper 实体对象封装操作类 {@link com.baomidou.mybatisplus.core.conditions.query.QueryWrapper} */ default \u0026lt;E extends IPage\u0026lt;T\u0026gt;\u0026gt; E page(E page, Wrapper\u0026lt;T\u0026gt; queryWrapper) { return getBaseMapper().selectPage(page, queryWrapper); } /** * 无条件翻页查询 * * @param page 翻页对象 * @see Wrappers#emptyWrapper() */ default \u0026lt;E extends IPage\u0026lt;T\u0026gt;\u0026gt; E page(E page) { return page(page, Wrappers.emptyWrapper()); } /** * 查询列表 * * @param queryWrapper 实体对象封装操作类 {@link com.baomidou.mybatisplus.core.conditions.query.QueryWrapper} */ default List\u0026lt;Map\u0026lt;String, Object\u0026gt;\u0026gt; listMaps(Wrapper\u0026lt;T\u0026gt; queryWrapper) { return getBaseMapper().selectMaps(queryWrapper); } /** * 查询所有列表 * * @see Wrappers#emptyWrapper() */ default List\u0026lt;Map\u0026lt;String, Object\u0026gt;\u0026gt; listMaps() { return listMaps(Wrappers.emptyWrapper()); } /** * 查询全部记录 */ default List\u0026lt;Object\u0026gt; listObjs() { return listObjs(Function.identity()); } /** * 查询全部记录 * * @param mapper 转换函数 */ default \u0026lt;V\u0026gt; List\u0026lt;V\u0026gt; listObjs(Function\u0026lt;? super Object, V\u0026gt; mapper) { return listObjs(Wrappers.emptyWrapper(), mapper); } /** * 根据 Wrapper 条件,查询全部记录 * * @param queryWrapper 实体对象封装操作类 {@link com.baomidou.mybatisplus.core.conditions.query.QueryWrapper} */ default List\u0026lt;Object\u0026gt; listObjs(Wrapper\u0026lt;T\u0026gt; queryWrapper) { return listObjs(queryWrapper, Function.identity()); } /** * 根据 Wrapper 条件,查询全部记录 * * @param queryWrapper 实体对象封装操作类 {@link com.baomidou.mybatisplus.core.conditions.query.QueryWrapper} * @param mapper 转换函数 */ default \u0026lt;V\u0026gt; List\u0026lt;V\u0026gt; listObjs(Wrapper\u0026lt;T\u0026gt; queryWrapper, Function\u0026lt;? super Object, V\u0026gt; mapper) { return getBaseMapper().selectObjs(queryWrapper).stream().filter(Objects::nonNull).map(mapper).collect(Collectors.toList()); } /** * 翻页查询 * * @param page 翻页对象 * @param queryWrapper 实体对象封装操作类 {@link com.baomidou.mybatisplus.core.conditions.query.QueryWrapper} */ default \u0026lt;E extends IPage\u0026lt;Map\u0026lt;String, Object\u0026gt;\u0026gt;\u0026gt; E pageMaps(E page, Wrapper\u0026lt;T\u0026gt; queryWrapper) { return getBaseMapper().selectMapsPage(page, queryWrapper); } /** * 无条件翻页查询 * * @param page 翻页对象 * @see Wrappers#emptyWrapper() */ default \u0026lt;E extends IPage\u0026lt;Map\u0026lt;String, Object\u0026gt;\u0026gt;\u0026gt; E pageMaps(E page) { return pageMaps(page, Wrappers.emptyWrapper()); } /** * 获取对应 entity 的 BaseMapper * * @return BaseMapper */ BaseMapper\u0026lt;T\u0026gt; getBaseMapper(); /** * 获取 entity 的 class * * @return {@link Class\u0026lt;T\u0026gt;} */ Class\u0026lt;T\u0026gt; getEntityClass(); /** * 以下的方法使用介绍: * * 一. 名称介绍 * 1. 方法名带有 query 的为对数据的查询操作, 方法名带有 update 的为对数据的修改操作 * 2. 方法名带有 lambda 的为内部方法入参 column 支持函数式的 * 二. 支持介绍 * * 1. 方法名带有 query 的支持以 {@link ChainQuery} 内部的方法名结尾进行数据查询操作 * 2. 方法名带有 update 的支持以 {@link ChainUpdate} 内部的方法名为结尾进行数据修改操作 * * 三. 使用示例,只用不带 lambda 的方法各展示一个例子,其他类推 * 1. 根据条件获取一条数据: `query().eq(\u0026#34;column\u0026#34;, value).one()` * 2. 根据条件删除一条数据: `update().eq(\u0026#34;column\u0026#34;, value).remove()` * */ /** * 链式查询 普通 * * @return QueryWrapper 的包装类 */ default QueryChainWrapper\u0026lt;T\u0026gt; query() { return ChainWrappers.queryChain(getBaseMapper()); } /** * 链式查询 lambda 式 * \u0026lt;p\u0026gt;注意:不支持 Kotlin \u0026lt;/p\u0026gt; * * @return LambdaQueryWrapper 的包装类 */ default LambdaQueryChainWrapper\u0026lt;T\u0026gt; lambdaQuery() { return ChainWrappers.lambdaQueryChain(getBaseMapper()); } /** * 链式查询 lambda 式 * kotlin 使用 * * @return KtQueryWrapper 的包装类 */ default KtQueryChainWrapper\u0026lt;T\u0026gt; ktQuery() { return ChainWrappers.ktQueryChain(getBaseMapper(), getEntityClass()); } /** * 链式查询 lambda 式 * kotlin 使用 * * @return KtQueryWrapper 的包装类 */ default KtUpdateChainWrapper\u0026lt;T\u0026gt; ktUpdate() { return ChainWrappers.ktUpdateChain(getBaseMapper(), getEntityClass()); } /** * 链式更改 普通 * * @return UpdateWrapper 的包装类 */ default UpdateChainWrapper\u0026lt;T\u0026gt; update() { return ChainWrappers.updateChain(getBaseMapper()); } /** * 链式更改 lambda 式 * \u0026lt;p\u0026gt;注意:不支持 Kotlin \u0026lt;/p\u0026gt; * * @return LambdaUpdateWrapper 的包装类 */ default LambdaUpdateChainWrapper\u0026lt;T\u0026gt; lambdaUpdate() { return ChainWrappers.lambdaUpdateChain(getBaseMapper()); } /** * \u0026lt;p\u0026gt; * 根据updateWrapper尝试更新,否继续执行saveOrUpdate(T)方法 * 此次修改主要是减少了此项业务代码的代码量(存在性验证之后的saveOrUpdate操作) * \u0026lt;/p\u0026gt; * * @param entity 实体对象 */ default boolean saveOrUpdate(T entity, Wrapper\u0026lt;T\u0026gt; updateWrapper) { return update(entity, updateWrapper) || saveOrUpdate(entity); } } IService有一个实现类:ServiceImpl\n自定义一个业务Service接口,继承IService\npublic interface UserService extends IService\u0026lt;User\u0026gt;{ } 编写一个实现类,实现UserService接口,并继承ServiceImpl\npublic class UserServiceImpl extends ServiceImpl\u0026lt;UserMapper, User\u0026gt; implements UserService { } 这样既可以使用自定义的功能,也可以使用MybatisPlus提供的功能\n# "},{"id":377,"href":"/zh/docs/technology/MyBatis-Plus/bl_sgg_/01-11/","title":"mybatis-plus-sgg-01-11","section":"基础(尚硅谷)_","content":" 简介 # MyBatis-Plus是一个MyBatis的增强工具,在MyBatis的基础上只做增强不做改变,为简化开发、提高效率而生 这里以MySQL数据库为案例,以Idea作为IDE,使用Maven作为构建工具,使用SpringBoot完成各种功能 课程主要内容 特性 润物无声、效率至上、丰富功能 支持的数据库 框架结构 左边:扫描实体,从实体抽取属性猜测数据库字段 通过默认提供的方法使用sql语句,然后注入mybatis容器 开发环境 # 测试数据库和表 # 这里创建数据库mybatis_plus\n然后创建表user\nDROP TABLE IF EXISTS user; CREATE TABLE user ( id BIGINT(20) NOT NULL COMMENT \u0026#39;主键ID\u0026#39;, name VARCHAR(30) NULL DEFAULT NULL COMMENT \u0026#39;姓名\u0026#39;, age INT(11) NULL DEFAULT NULL COMMENT \u0026#39;年龄\u0026#39;, email VARCHAR(50) NULL DEFAULT NULL COMMENT \u0026#39;邮箱\u0026#39;, PRIMARY KEY (id) ); 插入默认数据\nDELETE FROM user; INSERT INTO user (id, name, age, email) VALUES (1, \u0026#39;Jone\u0026#39;, 18, \u0026#39;test1@baomidou.com\u0026#39;), (2, \u0026#39;Jack\u0026#39;, 20, \u0026#39;test2@baomidou.com\u0026#39;), (3, \u0026#39;Tom\u0026#39;, 28, \u0026#39;test3@baomidou.com\u0026#39;), (4, \u0026#39;Sandy\u0026#39;, 21, \u0026#39;test4@baomidou.com\u0026#39;), (5, \u0026#39;Billie\u0026#39;, 24, \u0026#39;test5@baomidou.com\u0026#39;); Spring Boot工程 # 添加依赖,并install Lombok 插件\n\u0026lt;parent\u0026gt; \u0026lt;groupId\u0026gt;org.springframework.boot\u0026lt;/groupId\u0026gt; \u0026lt;artifactId\u0026gt;spring-boot-starter-parent\u0026lt;/artifactId\u0026gt; \u0026lt;version\u0026gt;2.7.0\u0026lt;/version\u0026gt; \u0026lt;relativePath/\u0026gt; \u0026lt;/parent\u0026gt; \u0026lt;dependencies\u0026gt; \u0026lt;dependency\u0026gt; \u0026lt;groupId\u0026gt;org.springframework.boot\u0026lt;/groupId\u0026gt; \u0026lt;artifactId\u0026gt;spring-boot-starter\u0026lt;/artifactId\u0026gt; \u0026lt;/dependency\u0026gt; \u0026lt;dependency\u0026gt; \u0026lt;groupId\u0026gt;org.springframework.boot\u0026lt;/groupId\u0026gt; \u0026lt;artifactId\u0026gt;spring-boot-starter-test\u0026lt;/artifactId\u0026gt; \u0026lt;scope\u0026gt;test\u0026lt;/scope\u0026gt; \u0026lt;/dependency\u0026gt; \u0026lt;dependency\u0026gt; \u0026lt;groupId\u0026gt;com.baomidou\u0026lt;/groupId\u0026gt; \u0026lt;artifactId\u0026gt;mybatis-plus-boot-starter\u0026lt;/artifactId\u0026gt; \u0026lt;version\u0026gt;3.5.1\u0026lt;/version\u0026gt; \u0026lt;/dependency\u0026gt; \u0026lt;!-- https://mvnrepository.com/artifact/mysql/mysql-connector-java --\u0026gt; \u0026lt;dependency\u0026gt; \u0026lt;groupId\u0026gt;mysql\u0026lt;/groupId\u0026gt; \u0026lt;artifactId\u0026gt;mysql-connector-java\u0026lt;/artifactId\u0026gt; \u0026lt;version\u0026gt;8.0.29\u0026lt;/version\u0026gt; \u0026lt;/dependency\u0026gt; \u0026lt;!-- https://mvnrepository.com/artifact/org.projectlombok/lombok --\u0026gt; \u0026lt;dependency\u0026gt; \u0026lt;groupId\u0026gt;org.projectlombok\u0026lt;/groupId\u0026gt; \u0026lt;artifactId\u0026gt;lombok\u0026lt;/artifactId\u0026gt; \u0026lt;version\u0026gt;1.18.24\u0026lt;/version\u0026gt; \u0026lt;scope\u0026gt;provided\u0026lt;/scope\u0026gt; \u0026lt;/dependency\u0026gt; \u0026lt;!-- https://mvnrepository.com/artifact/com.baomidou/mybatis-plus-generator --\u0026gt; \u0026lt;dependency\u0026gt; \u0026lt;groupId\u0026gt;com.baomidou\u0026lt;/groupId\u0026gt; \u0026lt;artifactId\u0026gt;mybatis-plus-generator\u0026lt;/artifactId\u0026gt; \u0026lt;version\u0026gt;3.5.2\u0026lt;/version\u0026gt; \u0026lt;/dependency\u0026gt; \u0026lt;/dependencies\u0026gt; 基础配置 # 创建spring boot启动类\n@SpringBootApplication public class MybatisPlusApplication { public static void main(String[] args) { SpringApplication.run(MybatisPlusApplication.class, args); } } 配置resources/application.yml文件\nspring: #配置数据源 datasource: #配置数据源类型 type: com.zaxxer.hikari.HikariDataSource #配置数据源各个信息 driver-class-name: com.mysql.cj.jdbc.Driver url: jdbc:mysql://localhost:3306/mybatis_plus?characterEncoding=utf-8\u0026amp;\u0026amp;useSSL=false username: root password: 123456 这个时候启动会直接结束,因为我们没有使用springboot-web 包 实体类的创建\npackage com.ly.mybatisplus.pojo; import lombok.Data; //相当于get set 无参构造器 hashCode()和equals()、toString()方法重写 @Data public class User { private Long id; private String name; private Integer age; private String email; } mapper的创建 mapper/UserMapper\npackage com.ly.mybatisplus.mapper; import com.baomidou.mybatisplus.core.mapper.BaseMapper; import com.ly.mybatisplus.pojo.User; import org.springframework.stereotype.Repository; //将这个类标记成持久层组件 处理测试类中红色下划线的问题 @Repository public interface UserMapper extends BaseMapper\u0026lt;User\u0026gt; { } 设置mapper接口所在的包\npackage com.ly.mybatisplus; import org.mybatis.spring.annotation.MapperScan; import org.springframework.boot.SpringApplication; import org.springframework.boot.autoconfigure.SpringBootApplication; @SpringBootApplication //扫描指定包下的mapper接口 @MapperScan(\u0026#34;com.ly.mybatisplus.mapper\u0026#34;) public class MybatisPlusApplication { public static void main(String[] args) { SpringApplication.run(MybatisPlusApplication.class, args); } } 测试 # 测试类的创建\nimport com.ly.mybatisplus.MybatisPlusApplication; import com.ly.mybatisplus.mapper.UserMapper; import com.ly.mybatisplus.pojo.User; import org.junit.jupiter.api.Test; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.test.context.SpringBootTest; import java.util.List; //可能是由于没有使用web包依赖,这里要加入classes指定启动类 @SpringBootTest(classes = MybatisPlusApplication.class) public class MybatisPlusTest { @Autowired private UserMapper userMapper; @Test public void testSelect(){ //通过条件构造器查询list集合 null表示没有条件 List\u0026lt;User\u0026gt; users = userMapper.selectList(null); users.forEach(System.out::println); } } 加入日志功能 # 配置application.yml加入日志\n#日志 mybatis-plus: configuration: log-impl: org.apache.ibatis.logging.stdout.StdOutImpl 效果 如上图,查询的字段名来自于实体类属性 "},{"id":378,"href":"/zh/docs/technology/Algorithm/_algorithhms_4th_/3.1.1-3.1.7/","title":"算法红皮书 3.1.1-3.1.7","section":"_算法(第四版)_","content":" 查找 # 经典查找算法\n用符号表这个词来描述抽象的表格,将信息(值)存储在其中,然后按照指定的键来获取这些信息\n符号表也被称为字典\n在英语字典里,键就是单词,值就是单词对应的定义、发音和词源 符号表有时又叫索引 在一本书的索引中,键就是术语,而值就是书中该术语出现的所有页码 下面学习三种经典的数据类型:二叉查找树、红黑树和散列表\n符号表 # 符号表最主要的目的是将键和值联系起来\n用例能够将一个键值对插入符号表并希望在之后能够从符号表的所有键值对中按照键直接找到相对应的值\n符号表是一种存储键值对的数据结构,支持两种操作:插入(put),即将一组新的键值对存入表中;查找(get),即根据给定的键得到相应的值\n典型的符号表应用 API # 符号表是一种典型的数据类型 :代表着一组定义清晰的值及相应的操作。使用应用程序编程接口(API)来精确地定义这些操作 一种简单的泛型符号表API ST(Symbol Table) 泛型 对于符号表,我们通过明确地指定查找时键和值的类型来区分它们的不同角色【key和value】\n重复的键\n这里假设每个键只对应着一个值(表中不允许重复值) 当用例代码向表中存入的键值对和表中已有的键(及关联的值)冲突时,新的值会替代旧的值 上述定义了关联数组的抽象形式,可以将符号表想象成数组,键即索引,值即数组中的值 在一个关联数组中,键可以是任意类型,但我们仍然可以用它来快速访问数组的值 非Java使用st[key]来替代st.get(key),用st[key]=val来替代st.put(key,val) 键不能为空\n值不能为空(因为规定当键不存在时get()返回空) 当值为空表示删除\n删除操作\n延时删除,先将键对应的值置空,之后在某个时刻删除所有值为空的键\n即时删除,立即从表中删除指定的键 put实现的开头:\nif(val == null){ delete(key); return; } 便捷方法 迭代 在API第一行加上implements Iterable\u0026lt;Key\u0026gt; ,所有实现都包含iterator()方法来实现hasNext()和next()方法的迭代器;这里采用另一种方式:定义keys返回一个Iterable\u0026lt;Key\u0026gt;对象以方便便利所有的键,且允许遍历一部分\n键的等价性 自定义的键需要重写equals()方法;且最好使用不可变数据类型作为键\n有序符号表 # 一种有序的泛型符号表的API 最大值和最小值、向下取整和向上取整、排名和选择 对于0到size()-1的所有i都有i==rank(select(i)),且所有的键都满足key == select(rank(key)) 范围查找 例外情况 当一个方法需要返回一个键但表中没有合适的键可以返回时,我们约定抛出一个异常 有序符号表中冗余有序性方法的默认实现 所有Comparable类型中compareTo()方法和equals()方法的一致性 ★★成本模型 在学习符号表的实现时,我们会统计比较的次数(等价性测试或是键的相互比较),在内循环**不进行比较(极少)**的情况下,我们会统计数组的访问次数 用例举例 # 如何使用\n行为测试用例 简单的符号表测试用例 测试用例的键、值和输出 性能测试用例 查找频率最高的单词\npublic class FrequencyCounter { public static void main(String[] args) { int minlen = Integer.parseint(args[0]); // 最小键长 ST\u0026lt;String, Integer\u0026gt; st = new ST\u0026lt;String, Integer\u0026gt;(); while (!StdIn.isEmpty()) { // 构造符号表并统计频率 String word = StdIn.readString(); if (word.length() \u0026lt; minlen) continue; // 忽略较短的单词 if (!st.contains(word)) st.put(word, 1); else st.put(word, st.get(word) + 1); } // 找出出现频率最高的单词 String max = \u0026#34; \u0026#34;; st.put(max, 0); for (String word : st.keys()) if (st.get(word) \u0026gt; st.get(max)) max = word; StdOut.println(max + \u0026#34; \u0026#34; + st.get(max)); } } 每个单词都会被作为键进行搜索,因此处理性能和输入文本的单词总量必然有关;其次,输入的每个单词都会被存入符号表(输入中不重复单词的总数也就是所有键都被插入以后符号表的大小),因此输入流中不同的单词的总数也是相关的\n无序链表中的顺序查找 # 顺序查找的定义:使用链表,每个结点存储一个键值对,get()实现即为遍历链表,用equals()方法比较需被查找的键和每个节点中的键。如果匹配成功我们就返回相应的值,否则返回null。put()实现也是遍历链表,用equals()方法比较需被查找的键和每个节点中的键。如果匹配成功我们就用第二个参数指定更新和该键相关联的值,否则我们就用给定的键值对创建一个新的结点并将其插入到链表的开头。这种方法称为顺序查找\n命中表示一次成功的查找,未命中表示一次失败的查找\n使用基于链表的符号表的索引用例的轨迹 顺序查找(基于无序链表)\npublic class SequentialSearchST\u0026lt;Key,Value\u0026gt; { private Node first; //链表首结点 private class Node{ //链表结点的定义 Key key; Value val; Node next; public Node(Key key, Value val, Node next) { this.key = key; this.val = val; this.next = next; } } public Value get(Key key) { // 查找给定的键,返回相关联的值 for (Node x = first; x != null; x = x.next) if (key.equals(x.key)) return x.val; // 命中 return null; // 未名中 } public void put(Key key, Value val) { // 查找给定的键,找到则更新其值,否则在表中新建结点 for (Node x = first; x != null; x = x.next) if (key.equals(x.key)) { x.val = val; return; } // 命中,更新 first = new Node(key, val, first); // 未命中,新建结点 } } 在含有N 对键值的基于(无序)链表的符号表中,未命中的查找和插入操作都需要N 次比较。命中的查找在最坏情况下需要N 次比较。特别地,向一个空表中插入N 个不同的键需要∼ N2/2 次比较\n查找一个已经存在的键并不需要线性级别的时间。一种度量方法是查找表中的每个键,并将总 时间除以N\n有序数组中的二分查找 # 有序符号表API:它使用的数据结构是一对平行的数组,一个存储键一个存储值\n//rank():小于k的键的数量\npublic class BinarySearchST\u0026lt;Key extends Comparable\u0026lt;Key\u0026gt;, Value\u0026gt; { private Key[] keys; private Value[] vals; private int N; public BinarySearchST(int capacity) { // 调整数组大小的标准代码请见算法1.1 keys = (Key[]) new Comparable[capacity]; vals = (Value[]) new Object[capacity]; } public int size() { return N; } public Value get(Key key) { if (isEmpty()) return null; int i = rank(key); //注意,这里i不一定就是刚好是key所在的索引,他表示比key的值小的个数 if (i \u0026lt; N \u0026amp;\u0026amp; keys[i].compareTo(key) == 0) return vals[i]; else return null; } public int rank(Key key) // 请见算法3.2(续1) public void put(Key key, Value val) { // 查找键,找到则更新值,否则创建新的元素 int i = rank(key); if (i \u0026lt; N \u0026amp;\u0026amp; keys[i].compareTo(key) == 0) { vals[i] = val; return; } //根据成本模型,这里不统计 for (int j = N; j \u0026gt; i; j--) { keys[j] = keys[j-1]; vals[j] = vals[j-1]; } keys[i] = key; vals[i] = val; N++; } public void delete(Key key) // 该方法的实现请见练习3.1.16 } 二分查找 我们使用有序数组存储键的原因是,经典二分查找法能够根据数组的索引大大减少每次查找所需的比较次数\n递归的二分查找\npublic int rank(Key key, int lo, int hi) { if (hi \u0026lt; lo) return lo; int mid = lo + (hi - lo) / 2; int cmp = key.compareTo(keys[mid]); if (cmp \u0026lt; 0) return rank(key, lo, mid-1); else if (cmp \u0026gt; 0) return rank(key, mid+1, hi); else return mid; //如果存在,返回key所在位置的索引(也就是key之前的元素的个数 ) } rank()的性质:如果表中存在该键,rank()应该返回该键的位置,也就是表中小于它的键的数量;如果表中不存在该键,ran()还是应该返回表中小于它的键的数量\n好好想想算法3.2(续1)中非递归的rank() 为什么能够做到这些(你可以证明两个版本的等价性,或者直接证明非递归版本中的循环在结束时lo 的值正好等于表中小于被查找的键的键的数量),所有程序员都能从这些思考中有所收获。(提示:lo 的初始值为0,且永远不会变小) 假设有下面这么一组数(key value)\n0 1 2 3 4 1 2 3 5 9 我要查找6,那么轨迹为: low=0,high=4,mid=2 low=2+1=3,high=4,mid=3 low=3+1=4,high=4,mid=4 low=4,high=4-1,此时high\u0026lt;low,返回low【也就是说找到了最接近于要查找的数的下标】\n带图轨迹 基于二分查找的有序符号表的其他操作\npublic Key min() { return keys[0]; } public Key max() { return keys[N-1]; } public Key select(int k) { return keys[k]; } //大于等于key的最小整数 public Key ceiling(Key key) { int i = rank(key); return keys[i]; } //小于等于key的最大整数 public Key floor(Key key) // 请见练习3.1.17 public Key delete(Key key) // 请见练习3.1.16 public Iterable\u0026lt;Key\u0026gt; keys(Key lo, Key hi) { Queue\u0026lt;Key\u0026gt; q = new Queue\u0026lt;Key\u0026gt;(); for (int i = rank(lo); i \u0026lt; rank(hi); i++) q.enqueue(keys[i]); if (contains(hi)) q.enqueue(keys[rank(hi)]); return q; } 对二分查找的分析 # 在N 个键的有序数组中进行二分查找最多需要(lgN+1)次比较(无论是否成功)\n向大小为N 的有序数组中插入一个新的元素在最坏情况下需要访问∼ 2N 次数组,因此向一个空符号表中插入N 个元素在最坏情况下需要访问∼ N2 次数组\n预览 # 简单的符号表实现的成本总结 符号表的各种实现的优缺点 我们有若干种高效的符号表实现,它们能够并且已经被应用于无数程序之中了 "},{"id":379,"href":"/zh/docs/technology/Algorithm/_algorithhms_4th_/2.5/","title":"算法红皮书 2.5","section":"_算法(第四版)_","content":" 排序如此有用的原因是,在有序的数组中查找一个元素,要比在一个无序的数组中查找简单得多 通用排序算法是最重要的 算法思想虽然简单,但是适用领域广泛 将各种数据排序 # Java的约定使得我们能够利用Java的回调机制将任意实现Comparable接口的数据类型排序\n我们的代码直接能够将String、Integer、Double 和一些其他例如File 和URL 类型的数组排序,因为它们都实现了Comparable 接口 交易事务 商业数据的处理,设想一家互联网商业公司为每笔交易记录都保存了所有的相关信息\npublic int compareTo(Transaction that) { return this.when.compareTo(that.when); } 指针排序 我们使用的方法在经典教材中被称为指针排序,因为我们只处理元素的引用而不移动数据本身\n不可变的键 用不可变的数据类型作为键,比如String、Integer、Double和File等\n廉价的交换\n使用引用的另一个好处是不必移动整个元素对于几乎任意大小的元素,使用引用使得在一般情况下交换的成本和比较的成本几乎相同(代价是需要额外的空间存储这些引用)\n研究将数字排序的算法性能的一种方法就是观察其所需的比较和交换总数,因为这里隐式地假设了比较和交换的成本是相同的\n多种排序方法\n根据情况将一组对象按照不同的方式排序。Java 的Comparator 接口允许我们在一个类之中实现多种排序方法 多键数组\n一个元素的多种属性都可能被用作排序的键\n我们可以定义多种比较器,要将Transaction 对象的数组按照时间排序可以调用: Insertion.sort(a, new Transaction.WhenOrder()) 或者这样来按照金额排序: Insertion.sort(a, new Transaction.HowMuchOrder()) 使用Comparator的插入排序\npublic static void sort(Object[] a, Comparator c) { int N = a.length; for (int i = 1; i \u0026lt; N; i++) for (int j = i; j \u0026gt; 0 \u0026amp;\u0026amp; less(Comparator, a[j], a[j-1]); j--) exch(a, j, j-1); } private static Boolean less(Comparator c, Object v, Object w) { return c.compare(v, w) \u0026lt; 0; } private static void exch(Object[] a, int i, int j) { Object t = a[i]; a[i] = a[j]; a[j] = t; } 使用比较器实现优先队列\n扩展优先队列 导入 java.util.Comparator; 为 MaxPQ 添加一个实例变量 comparator 以及一个构造函数,该构造函数接受一个比较器 作为参数并用它将comparator 初始化; 在 less()中检查 comparator属性是否为 null(如果不是的话就用它进行比较)。 //使用了Comparator的插入排序 import java.util.Comparator; public class Transaction { ... private final String who; private final Date when; private final double amount; ... public static class WhoOrder implements Comparator\u0026lt;Transaction\u0026gt; { public int compare(Transaction v, Transaction w) { return v.who.compareTo(w.who); } } public static class WhenOrder implements Comparator\u0026lt;Transaction\u0026gt; { public int compare(Transaction v, Transaction w) { return v.when.compareTo(w.when); } } public static class HowMuchOrder implements Comparator\u0026lt;Transaction\u0026gt; { public int compare(Transaction v, Transaction w) { if (v.amount \u0026lt; w.amount) return -1; if (v.amount \u0026gt; w.amount) return +1; return 0; } } } 稳定性\n如果一个排序算法能够保留数组中重复元素的相对位置则可以被称为是稳定的 例如,考虑一个需要处理大量含有地理位置和时间戳的事件的互联网商业应用程 序。首先,我们在事件发生时将它们挨个存储在一个数组中,这样在数组中它们已经是按照时间顺序排好了的。现在假设在进一步处理前将按照地理位置切分。一种简单的方法是将数组按照位置排序。如果排序算法不是稳定的,排序后的每个城市的交易可能不会再是按照时间顺序排列的了 我们学习过的一部分算法是稳定的(插入排序和归并排序),但很多不是(选择排序、希尔排序、快速排序和堆排序) 有很多办法能够将任意排序算法变成稳定的(请见练习2.5.18),但一般只有在稳定性是必要的情况下稳定的排序算法才有优势 图示 我应该使用哪种排序算法 # 各种排序算法的性能特点 快速排序是最快的通用排序算法 将原始类型数据排序 一些性能优先的应用的重点可能是将数字排序,因此更合理的做法是跳过引用直接将原始数据 类型的数据排序 Java系统库的排序算法 java.util.Arrays.sort() Java 的系统程序员选择对原始数据类型使用(三向切分的)快速排序,对引用类型使用归并排 序。这些选择实际上也暗示着用速度和空间(对于原始数据类型)来换取稳定性(对于引用类型), 如果考虑稳定性,则选择Merge.sort() 归并排序 问题的归约 # 归约指的是为解决某个问题而发明的算法正好可以用来解决另一种问题\n使用解决问题B 的方法来解决问题A 时,你都是在将A 归约为B。\n如果先将数据排序,那么解决剩下的问题就剩下线性级别的时间,归约后的运行时间的增长数量级由平方级别降低到了线性级别\n找出重复元素的个数(先排序,后遍历)\nQuick.sort(a); int count = 1; // 假设a.length \u0026gt; 0. for (int i = 1; i \u0026lt; a.length; i++) if (a[i].compareTo(a[i-1]) != 0) count++; Kendall tau距离\n优先队列\n在2.4 节中我们已经见过两个被归约为优先队列操作的问题的例子。一个是2.4.2.1 节中的TopM,它能够找到输入流中M 个最大的元素;另一个是2.4.4.7 节中的Multiway,它能够将M 个输入流归并为一个有序的输出流。这两个问题都可以轻易用长度为M 的优先队列解决\n中位数与顺序统计 (与快速排序有关)\n排序应用一览 # 商业计算:按照名字或者数字排序的账号、按照日期或者金额排序的交易、按照 邮编或者地址排序的邮件、按照名称或者日期排序的文件等, 处理这些数据必然需要排序算 信息搜索:有序的顺序可以使用经典的二分查找法 运筹学指的是研究数学模型并将其应用于问题解决和决策的领域 事件驱动模拟、数值计算、组合搜索 基于排序算法的算法 Prim算法和Dijkstra算法 Kruskal算法 霍夫曼压缩 字符串处理 "},{"id":380,"href":"/zh/docs/technology/Algorithm/_algorithhms_4th_/2.4/","title":"算法红皮书 2.4","section":"_算法(第四版)_","content":" 优先队列 # 有些情况下,不需要要求处理的元素全部有序,只要求每次都处理键值最大的元素,然后再收集更多的元素,然后再处理键值最大的元素 需要一种数据结构,支持操作:删除最大元素和插入元素,这种数据类型叫做优先队列 优先队列的基本表现形式:其一或两种操作都能在线性时间内完成 基于二叉堆数据结构的优先队列,用数组保存元素并按照一定条件排序,以实现高效的删除最大元素和插入元素 API # 抽象数据类型,最重要的操作是删除最大元素和插入元素 delMax()和insert()\n用“最大元素”代替“最大键值”或是“键值最大的元素”\n泛型优先队列的API 优先队列的调用示例 从N各输入中找到最大的M各元素所需成本 优先队列的用例 pq里面最多放5个,当大于5个的时候,就从中剔除1个\npublic class TopM { public static void main(String[] args) { // 打印输入流中最大的M行 int M = Integer.parseint(args[0]); MinPQ\u0026lt;Transaction\u0026gt; pq = new MinPQ\u0026lt;Transaction\u0026gt;(M+1); while (StdIn.hasNextLine()) { // 为下一行输入创建一个元素并放入优先队列中 pq.insert(new Transaction(StdIn.readLine())); if (pq.size() \u0026gt; M) pq.delMin(); // 如果优先队列中存在M+1个元素则删除其中最小的元素 } // 最大的M个元素都在优先队列中 Stack\u0026lt;Transaction\u0026gt; stack = new Stack\u0026lt;Transaction\u0026gt;(); while (!pq.isEmpty()) stack.push(pq.delMin()); for (Transaction t : stack) StdOut.println(t); } } 应用 初级实现 # 数组实现(无序) insert元素和栈的push()方法完全一样;要删除最大元素,可以添加一段类似选择排序的内循环的代码,将最大元素的边界元素交换,然后删除 数组实现(有序) insert()方法时,始终将较大的元素,向右边移动一格以使数组有序;删除最大元素就是pop() 链表表示法 可以用基于链表的下压栈的代码作为基础,而后可以选择修改pop() 来找到并返回最大元素,或是修改push() 来保证所有元素为逆序并用pop() 来删除并返回链表的首元素(也就是最大的元素) 优先队列的各种实现在最坏情况下运行时间的增长数量级 在一个优先队列上执行的一系列操作如表2.4.4所示 堆的定义 # 当一棵二叉树的每个节点都大于等于他的两个子结点时,它被称为堆有序\n重要性质1\n在堆有序的二叉树中,每个结点都小于等于它的父结点(如果有的话)。从任意结点向上,我们都能得到一列非递减的元素;从任意结点向下,我们都能得到一列非递增的元素\n重要命题 根结点是堆有序的二叉树中的最大结点\n二叉堆表示法\n如果使用指针来表示堆有序的二叉树,需要三个指针来找到它的上下结点 使用数组来表示(前提是使用完全二叉树来表示),那么只要一层一层由上向下从左至右,在每个结点的下方连接两个更小的结点,直至将N个结点全部连接完毕 即将二叉树的结点按照层级顺序放入数组中 二叉堆是一组能够用堆有序的完全二叉树排序的元素,并在数组中按照层级储存(不使 用数组的第一个位置)\n图解 下面将二叉树 简称为堆\n在一个堆中,位置k 的结点的父结点的位置为k/2,而它的两个子结点的位置则分别为2k 和2k+1。这样在不使用指针的情况下(我们在第3 章中讨论二叉树时会用到它们)我们也可以通过计算数组的索引在树中上下移动:从a[k] 向上一层就令k 等于k/2,向下一层则令k 等于2k 或2k+1\n一棵大小为N的完全二叉树的高度为[lgN]\n当N达到2的幂时树的高度为加1 数组不使用位置[0]\n堆的算法 # 堆实现的比较和交换方法\nprivate Boolean less(int i, int j) { return pq[i].compareTo(pq[j]) \u0026lt; 0; } private void exch(int i, int j) { Key t = pq[i]; pq[i] = pq[j]; pq[j] = t; } 堆的操作首先进行一些简单的改动,打破堆的状态,再遍历堆并按照要求将堆的状态回复,这个过程称为堆的有序化\n当某个结点的优先级上升(或是在堆底加入一个新的元素)时,我们需要由下至上恢复堆的顺序。当某个结点的优先级下降(例如,将根结点替换为一个较小的元素)时,我们需要由上至下恢复堆的顺序\n由下至上的堆有序化(上浮)【在最后位置插入一个元素】\n说明 如果堆的有序状态因为某个结点变得比它的父结点更大而被打破,那么我们就需要通过交换它和它的父结点来修复堆。交换后,这个结点比它的两个子结点都大(一个是曾经的父结点,另一个比它更小,因为它是曾经父结点的子结点),但这个结点仍然可能比它现在的父结点更大。我们可以一遍遍地用同样的办法恢复秩序,将这个结点不断向上移动直到我们遇 到了一个更大的父结点。\n代码\nprivate void swim(int k) { while (k \u0026gt; 1 \u0026amp;\u0026amp; less(k/2, k)) { exch(k/2, k); k = k/2; } } 由上至下的堆有序化(下沉)【在根节点插入一个元素】\n如果堆的有序状态因为某个结点变得比它的两个子结点或是其中之一更小了而被打破了,那么我们可以通过将它和它的两个子结点中的较大者交换来恢复堆。交换可能会在子结点处继续打破堆的有序状态,因此我们需要不断地用相同的方式将其修复,将结点向下移动直到它的子结点都比它更小或是到达了堆的底部\n代码\nprivate void sink(int k) { while (2*k \u0026lt;= N) { int j = 2*k; //j\u0026lt;N用来判断j是否存在右兄弟结点,当j==N(即j为树的[从左到右]最末一个结点,那么它没有右兄弟结点) if (j \u0026lt; N \u0026amp;\u0026amp; less(j, j+1)) j++; //当根节点没有小于子节点时,跳出循环 if (!less(k, j)) break; exch(k, j); k = j; } } 对于上面的说明\n插入元素。我们将新元素加到数组末尾,增加堆的大小并让这个新元素上浮到合适的位 置(如图2.4.5 左半部分所示)。 删除最大元素。我们从数组顶端删去最大的元素并将数组的最后一个元素放到顶端,减 小堆的大小并让这个元素下沉到合适的位置(如图2.4.5 右半部分所示) 上面对优先队列API的实现,能够保证插入元素和删除元素这两个操作的用时,和队列的大小仅成对数关系 图解堆的操作 基于堆的优先队列\npublic class MaxPQ\u0026lt;Key extends Comparable\u0026lt;Key\u0026gt;\u0026gt; { private Key[] pq; // 基于堆的完全二叉树 private int N = 0; // 存储于pq[1..N]中,pq[0]没有使用 public MaxPQ(int maxN) { pq = (Key[]) new Comparable[maxN+1]; } public Boolean isEmpty() { return N == 0; } public int size() { return N; } public void insert(Key v) { pq[++N] = v; swim(N); } public Key delMax() { Key max = pq[1]; // 从根结点得到最大元素 exch(1, N--); // 将其和最后一个结点交换 pq[N+1] = null; // 防止对象游离 sink(1); // 恢复堆的有序性 return max; } // 辅助方法的实现请见本节前面的代码框 private Boolean less(int i, int j) private void exch(int i, int j) private void swim(int k) private void sink(int k) } 说明\n优先队列由一个基于堆的完全二叉树表示, 存储于数组pq[1..N] 中,pq[0] 没有使用。在insert() 中,我们将N 加一并把新元素添加在数组最后,然后用swim() 恢复堆的秩序。在delMax() 中,我们从pq[1] 中得到需要返回的元素,然后将pq[N] 移动到pq[1],将N 减一并用sink() 恢复堆的秩序。同时我们还将不再使用的pq[N+1] 设为null,以便系统回收它所占用的空间。和以前一样(请见1.3 节),这里省略了动态调整数组大小的代码\n对于一个含有N个元素的基于堆的优先队列,插入元素操作只需不超过(lgN+1)次比较,删除最大元素的操作需要不超过2lgN 次比较。\n在堆上进行操作 多叉堆 基于用数组表示的完全三叉树构造堆并修改相应的代码并不困难。对于数组中1 至N 的N 个元素,位置k的结点大于等于位于3k-1、3k 和3k+1 的结点,小于等于位于(k+1)/3 的结点\n调整数组大小 添加一个没有参数的构造函数, 在insert() 中添加将数组长度加倍的代码,在delMax()中添加将数组长度减半的代码,就像在1.3 节中的栈那样\n元素的不可变性 优先队列存储了用例创建的对象,但同时假设用例代码不会改变它们\n索引优先队列 注意minIndex(),最小元素的索引不一定是0,这里说的索引不是IndexMinPQ数据结构中的数组的索引。这两个不是一个意思 表2.4.6 含有N 个元素的基于堆的索引优先队列所有操作在最坏情况下的成本 索引优先队列用例 将多个有序的输入流归并成一个有序的输出流 ★注意,这多个输入流本身是有序的\npublic class Multiway { public static void merge(In[] streams) { int N = streams.length; IndexMinPQ\u0026lt;String\u0026gt; pq = new IndexMinPQ\u0026lt;String\u0026gt;(N); for (int i = 0; i \u0026lt; N; i++){ if (!streams[i].isEmpty()){ //初始化,从文件流中读取一个数,放到优先队列中 pq.insert(i, streams[i].readString()); } } while (!pq.isEmpty()) { StdOut.println(pq.min()); //从优先队列中取最小的数出来 int i = pq.delMin(); if (!streams[i].isEmpty()) //取出数的那个位置,再从文件流读一个值放进去 pq.insert(i, streams[i].readString()); } } public static void main(String[] args) { int N = args.length; In[] streams = new In[N]; for (int i = 0; i \u0026lt; N; i++) streams[i] = new In(args[i]); //三个文件地址 merge(streams); } } 堆排序 # 我们可以把任意优先队列变成一种排序方法,将所有元素插入一个查找最小元素的优先队列,然后再重复调用删除最小元素的操作来将他们按顺序删去\n用堆来实现经典而优雅的排序算法\u0026ndash;堆排序 为了与前面代码保持一致,使用面向最大元素的优先队列并重复删除最大元素;为了排序需要,直接使用swim()和sink(),且将需要排序的数组本身作为堆,省去额外空间 堆的构造\n可以从左到右,就像连续向优先队列中插入元素一样\n从右到左,用sink()函数构造子堆\n★ 重要前提:每个子堆都符合优先序列的根节点大于其他两个子节点(也就是我们可以跳过大小为1的子堆) 所以只要对每个子堆的根节点,进行sink()函数操作就可以构造出优先队列结构的数组了\n进行排序 主要是将数组的位置1和N-1进行交换,然后在1位置进行sink()操作 不断循环,即可让整个数组有序\nsort(Comparable[] a) { int N = a.length; for (int k = N/2; k \u0026gt;= 1; k--) sink(a, k, N); while (N \u0026gt; 1) { exch(a, 1, N--); sink(a, 1, N); } } 注意,这里的sink()函数被修改过,主要是指定了要sink的最后一个位置【sink() 被修改过,以a[] 和N 作为参数】 堆排序的轨迹(每次下沉后的数组内容) 堆排序:堆的构造(左)和下沉排序(右) 堆排序的主要工作都是在第二阶段完成的。这里我们将堆中的最大元素删除,然后放入堆缩小 后数组中空出的位置\n将N个元素排序,堆排序只需少于(2N x lgN+2N )次比较(以及一般次数的交换)\n。2N 项来自于堆的构造( 见命题R)。2NlgN 项来自于每次下沉操作最大可能需要2lgN次比较(见命题P 与命题Q)\n我们将该实现和优先队列的API 独立开来是为了突出这个排序算法的简洁性(sort() 方法只需8 行代码,sink() 函数8 行),并使其可以嵌入其他代码之中。\n小结\n在最坏的情况下它也能保证使用~ 2NlgN 次比较和恒定的额外空间。当空间十分紧张的时候(例如在嵌入式系统或低成本的移动设备中)它很流行,因为它只用几行就能实现(甚至机器码也是)较好的性能。但现代系统的许多应用很少使用它,因为它无法利用缓存。数组元素很少和相邻的其他元素进行比较,因此缓存未命中的次数要远远高于大多数比较都在相邻元素间进行的算法,如快速排序、归并排序,甚至是希尔排序 用堆实现的优先队列在现代应用程序中越来越重要,因为它能在插入操作和删除最大元素操作混合的动态场景中保证对数级别的运行时间 "},{"id":381,"href":"/zh/docs/technology/Flowable/zsx_design/01/","title":"zsx_flowable_design01","section":"Flowable","content":" 模型设计完后,下面三个表有变化\nact_cio_model act_cio_model_module_rel act_ge_bytearray 部署之后,四个表有变化 act_cio_deployment 多了39条记录 act_ge_bytearray 多了两条记录 act_re_deployment 多了一条记录 act_re_procdef 多了一条记录 流程开始运行\n下面只写上主要的几个表 送审时这个结点只能选一个 流程运行时变量表 "},{"id":382,"href":"/zh/docs/technology/Linux/hanshunping_/28-39/","title":"linux_韩老师_28-39","section":"韩顺平老师_","content":" 文件目录 # 用来定位绝对路径或相对路径 cd ~ 用来定位家目录 cd .. 返回上一级 cd - 返回上一次目录\nmkdir 用于创建目录 mkdir -p hello/l1/l2 多级目录创建\nrecursion 递归 rm -rf 要删除的目录 #递归删除\n使用cp进行复制,加上 -r 进行递归复制\nrm 删除某个文件(带提示)\nrm -f 删除文件(不带提示) rm -rf 强制删除递归文件(夹) mv 用来重命名(移动到同一目录下)、(或者移动文件)\n注意,下面的命令,是将hello移动到hello2下,并改名为a(而不是hello2下的a目录) mv Hello.java hello2/a\nmv Hello.java hello2/a/ 移动到hello2下的a目录下(最后有一个斜杠) 移动目录\nmv hello2 hello1/AB 或者 mv hello2/ hello1/AB\n或者 mv hello2/ hello1/AB/\n会把整个hello2文件夹(包括hello2)移动到AB下\n同样是上面的指令,如果AB不存在,那么就会将hello2移动到hello1下,并将hello2文件夹,改名为AB\ncat 指令\ncat -p /etc/profile 浏览并显示文件 管道命令 cat -p /etc/profile | more 把前面的结果再交给more处理 (输入enter查看下一行,空格查看下一页) less指令\nless /etc/profile less指令显示的时候,是按需加载内容,效率较高, q退出 echo 输出到控制台\necho $HOSTNAME 输出环境变量 head 文件前几行\nhead -3 /etc/profile #查看文件前三行 tail 文件后几行\n实时监控 tail -f mydate.txt 覆盖 echo \u0026ldquo;hello\u0026rdquo; \u0026gt; mydate.txt 追加 echo \u0026ldquo;hi\u0026rdquo; \u0026raquo; mydate.txt cal \u0026gt; mydate.txt 将日志添加到文件后 ln指令 ln -s /root/ /home/myroot 在home下创建一个软链接,名为myroot,连接到root 此时cd myroot,就会进入root文件夹 使用rm -f 删除软连接 动态链接库 history 查看曾经执行过的命令 ! + 数字,执行曾经执行过的指令 时间日期 # date指令\u0026ndash; 显示当前日期 date date +%Y 年份 date +%m 月份 date +%d 哪一天 date \u0026ldquo;+%Y-%m-%d %H:%M:%S\u0026rdquo; 年月日时分秒 cal 2020 #2020年所有日历 查找指令 # find /home -name hello.txt 在/home目录下,按名字查找hello.txt find /home -user tom 按拥有者查找\nfind / -size -10M | more 查找小于10M的文件 ls -lh (h,以更符合人类查看的的方式显示) locate 搜索文件 (locate之前要使用updatedb指令创建) (先使用yum install -y mlocate 进行安装)\n进行查找 which ls 查看ls在哪个目录下 grep 过滤查找,管道符,\u0026quot;|\u0026quot; 表示将前一个命令的处理结果输出传递给后面的命令处理\ncat /etc/profile | grep 22 -n -i 压缩和解压 # 使用gzip 和 gunzip tar 用来压缩或者解压 压缩后的格式 .tar.gz 选项说明 -c 产生.tar打包文件 -v 显示详情信息 -f 指定压缩后的文件名 -z 打包同时压缩 -x 解包.tar文件 使用 tar -zcvf pc.tar.gz /home/pig.txt /home/cat.txt 解压 tar -zxvf pc.tar.gz 解压到指定的目录 tar -zxvf pc.tar.gz -C tom/ # "},{"id":383,"href":"/zh/docs/technology/Linux/hanshunping_/21-27/","title":"linux_韩老师_21-33","section":"韩顺平老师_","content":" 用户管理 # 使用ssh root@192.168.200.201进行服务器连接 xshell中 ctr+shift+r 用来重新连接\n用户解释图 添加一个用户milan,会自动创建该用户的家目录milan\n当登录该用户时,会自动切换到家目录下 指定家目录 指定密码 用milan登录,自动切换到/home/milan pwd:显示当前用户所在的目录\n用户删除\n删除用户但保留家目录 需要用超级管理员才能删除 使用su -u root切换到超级管理员 先logout然后再删除 删除用户及家目录 userdel -r milan 建议保留家目录 查询root用户信息\n使用id xx 查询 切换用户 su - xx\n从权限高切换到权限低的用户不需要密码;反之需要 使用logout(exit也行),从root用户回到jack 查看当前用户 who am i 即使切换了用户,返回的还是root(第一次登录时的用户) 用户组(角色)\n增加、删除组\ngroupadd wudang groupdel wudang 如果添加用户的时候没有指定组,那么会创建一个跟用户名一样的名字的组 id是1002,组为king\n添加用户zwj,添加组wudang,并将zwj添加到wudang组里面\ngroupadd wudang useradd -g wudang zwj 修改用户所在组\ngroupadd mojiao usermod -g mojiao zwj 关于用户和组相关的文件\n/etc/passwd 每行的含义 shell 解释和翻译指令 一般用bash,还有其他,很多\n/etc/shadow 口令配置文件\n每行的含义 /etc/group 记录组的信息 组名:口令:组标识号:组内用户列表\n运行级别 # 基本介绍\n0 关机 1 单用户(找回密码) 2 多用户状态没有网络服务 3 多用户状态有网络服务 4系统未使用保留给用户 5 图形界面 6 系统重启 在图形界面输入init 3 会直接进入终端界面\n之后输入init 5 会重新进入图形界面 init 0 会直接关机\n指定默认级别 centosOS7之前,在/etc/inittab文件中 之后进行了简化,如下 查看默认级别\nsystemctl get-default # multi-user.target 设置默认级别\nsystemctl set-default multi-user.target 找回root密码 # 这里讲的是centos os7之后\n重启后,立马按e\n然后光标往下滑 在utf-8后面,加入 init=/bin/sh (进入单用户实例,注意 这里不要加入空格)\n然后ctrl+x 表示启动\n然后输入\nmount -o remount,rw / passwd 修改成功 然后再输入\ntouch /.autorelabel exec /sbin/init exec /sbin/init 之后时间比较长,等待一会,密码则生效\n(卡住两三分钟)\nssh root@192.168.200.201 登录成功\n帮助指令 # man ls linux中,隐藏文件以 . 开头(以点开头) 输入q退出man ls选项可以组合使用 ls -l 单列输出(use a long listing format),信息最全 ls -la 单列输出,包括隐藏文件 ls -al /root 显示/root目录下的内容 help 内置命令的帮助信息\n该命令在zsh下不能用,所以使用下面指令切换 chsh -s /bin/bash #zsh切换到bash,重启后生效 chsh -s /bin/zsh #bash切换到zsh,重启后生效 help cd End "},{"id":384,"href":"/zh/docs/technology/MyBatis-Plus/official/hello/","title":"官方的hello-world","section":"My Batis Plus","content":" 简介 # MyBatis-Plus (opens new window)(简称 MP)是一个 MyBatis (opens new window)的增强工具,在 MyBatis 的基础上只做增强不做改变,为简化开发、提高效率而生。 快速开始 # 数据库的Schema脚本 resources/db/schema-mysql.sql\nDROP TABLE IF EXISTS user; CREATE TABLE user ( id BIGINT(20) NOT NULL COMMENT \u0026#39;主键ID\u0026#39;, name VARCHAR(30) NULL DEFAULT NULL COMMENT \u0026#39;姓名\u0026#39;, age INT(11) NULL DEFAULT NULL COMMENT \u0026#39;年龄\u0026#39;, email VARCHAR(50) NULL DEFAULT NULL COMMENT \u0026#39;邮箱\u0026#39;, PRIMARY KEY (id) ); 数据库Data脚本 resources/db/data-mysql.sql\nDELETE FROM user; INSERT INTO user (id, name, age, email) VALUES (1, \u0026#39;Jone\u0026#39;, 18, \u0026#39;test1@baomidou.com\u0026#39;), (2, \u0026#39;Jack\u0026#39;, 20, \u0026#39;test2@baomidou.com\u0026#39;), (3, \u0026#39;Tom\u0026#39;, 28, \u0026#39;test3@baomidou.com\u0026#39;), (4, \u0026#39;Sandy\u0026#39;, 21, \u0026#39;test4@baomidou.com\u0026#39;), (5, \u0026#39;Billie\u0026#39;, 24, \u0026#39;test5@baomidou.com\u0026#39;); 创建一个spring boot工程(使用maven)\n父工程\n\u0026lt;parent\u0026gt; \u0026lt;groupId\u0026gt;org.springframework.boot\u0026lt;/groupId\u0026gt; \u0026lt;artifactId\u0026gt;spring-boot-starter-parent\u0026lt;/artifactId\u0026gt; \u0026lt;version\u0026gt;2.7.0\u0026lt;/version\u0026gt; \u0026lt;relativePath/\u0026gt; \u0026lt;/parent\u0026gt; springboot 相关仓库及mybatis-plus、mysql、Lombok相关仓库引入\n\u0026lt;dependencies\u0026gt; \u0026lt;dependency\u0026gt; \u0026lt;groupId\u0026gt;org.springframework.boot\u0026lt;/groupId\u0026gt; \u0026lt;artifactId\u0026gt;spring-boot-starter\u0026lt;/artifactId\u0026gt; \u0026lt;/dependency\u0026gt; \u0026lt;dependency\u0026gt; \u0026lt;groupId\u0026gt;org.springframework.boot\u0026lt;/groupId\u0026gt; \u0026lt;artifactId\u0026gt;spring-boot-starter-test\u0026lt;/artifactId\u0026gt; \u0026lt;scope\u0026gt;test\u0026lt;/scope\u0026gt; \u0026lt;/dependency\u0026gt; \u0026lt;dependency\u0026gt; \u0026lt;groupId\u0026gt;com.baomidou\u0026lt;/groupId\u0026gt; \u0026lt;artifactId\u0026gt;mybatis-plus-boot-starter\u0026lt;/artifactId\u0026gt; \u0026lt;version\u0026gt;3.5.1\u0026lt;/version\u0026gt; \u0026lt;/dependency\u0026gt; \u0026lt;dependency\u0026gt; \u0026lt;groupId\u0026gt;org.springframework.boot\u0026lt;/groupId\u0026gt; \u0026lt;artifactId\u0026gt;spring-boot-starter-web\u0026lt;/artifactId\u0026gt; \u0026lt;/dependency\u0026gt; \u0026lt;dependency\u0026gt; \u0026lt;groupId\u0026gt;com.h2database\u0026lt;/groupId\u0026gt; \u0026lt;artifactId\u0026gt;h2\u0026lt;/artifactId\u0026gt; \u0026lt;scope\u0026gt;runtime\u0026lt;/scope\u0026gt; \u0026lt;/dependency\u0026gt; \u0026lt;!-- https://mvnrepository.com/artifact/mysql/mysql-connector-java --\u0026gt; \u0026lt;dependency\u0026gt; \u0026lt;groupId\u0026gt;mysql\u0026lt;/groupId\u0026gt; \u0026lt;artifactId\u0026gt;mysql-connector-java\u0026lt;/artifactId\u0026gt; \u0026lt;version\u0026gt;8.0.29\u0026lt;/version\u0026gt; \u0026lt;/dependency\u0026gt; \u0026lt;!-- https://mvnrepository.com/artifact/org.projectlombok/lombok --\u0026gt; \u0026lt;dependency\u0026gt; \u0026lt;groupId\u0026gt;org.projectlombok\u0026lt;/groupId\u0026gt; \u0026lt;artifactId\u0026gt;lombok\u0026lt;/artifactId\u0026gt; \u0026lt;version\u0026gt;1.18.24\u0026lt;/version\u0026gt; \u0026lt;scope\u0026gt;provided\u0026lt;/scope\u0026gt; \u0026lt;/dependency\u0026gt; \u0026lt;/dependencies\u0026gt; 配置resources/application.yml文件\nspring: datasource: url: jdbc:mysql://localhost:3306/mybatis_plus_demo?useUnicode=true\u0026amp;characterEncoding=utf-8\u0026amp;allowMultiQueries=true\u0026amp;nullCatalogMeansCurrent=true username: root password: 123456 driver-class-name: com.mysql.cj.jdbc.Driver sql: init: schema-locations: classpath:db/schema-mysql.sql data-locations: classpath:db/data-mysql.sql mode: always entity类和mapper类的处理\nentity\n@Data public class User { private Long id; private String name; private Integer age; private String email; } mapper\nimport com.baomidou.mybatisplus.core.mapper.BaseMapper; import com.baomidou.mybatisplus.samples.quickstart.entity.User; public interface UserMapper extends BaseMapper\u0026lt;User\u0026gt; { } 测试类\nimport com.baomidou.mybatisplus.samples.quickstart.Application; import com.baomidou.mybatisplus.samples.quickstart.entity.User; import com.baomidou.mybatisplus.samples.quickstart.mapper.UserMapper; import org.junit.jupiter.api.Assertions; import org.junit.jupiter.api.Test; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.test.context.SpringBootTest; import java.util.List; @SpringBootTest(classes = {Application.class}) public class SampleTest { @Autowired private UserMapper userMapper; @Test public void testSelect() { System.out.println((\u0026#34;----- selectAll method test ------\u0026#34;)); List\u0026lt;User\u0026gt; userList = userMapper.selectList(null); Assertions.assertEquals(5, userList.size()); userList.forEach(System.out::println); } } mybatis-plus 代码自动生成 # maven 依赖\n\u0026lt;!-- https://mvnrepository.com/artifact/com.baomidou/mybatis-plus-generator --\u0026gt; \u0026lt;dependency\u0026gt; \u0026lt;groupId\u0026gt;com.baomidou\u0026lt;/groupId\u0026gt; \u0026lt;artifactId\u0026gt;mybatis-plus-generator\u0026lt;/artifactId\u0026gt; \u0026lt;version\u0026gt;3.5.2\u0026lt;/version\u0026gt; \u0026lt;/dependency\u0026gt; \u0026lt;!-- https://mvnrepository.com/artifact/org.apache.velocity/velocity-engine-core --\u0026gt; \u0026lt;dependency\u0026gt; \u0026lt;groupId\u0026gt;org.apache.velocity\u0026lt;/groupId\u0026gt; \u0026lt;artifactId\u0026gt;velocity-engine-core\u0026lt;/artifactId\u0026gt; \u0026lt;version\u0026gt;2.3\u0026lt;/version\u0026gt; \u0026lt;/dependency\u0026gt; 在测试类中编写程序让其自动生成\nimport com.baomidou.mybatisplus.generator.FastAutoGenerator; import com.baomidou.mybatisplus.generator.config.DataSourceConfig; import org.apache.ibatis.jdbc.ScriptRunner; import java.io.InputStream; import java.io.InputStreamReader; import java.sql.Connection; import java.sql.SQLException; /** * \u0026lt;p\u0026gt; * 快速生成 * \u0026lt;/p\u0026gt; * * @author lanjerry * @since 2021-09-16 */ public class FastAutoGeneratorTest { /** * 执行初始化数据库脚本 */ public static void before() throws SQLException { Connection conn = DATA_SOURCE_CONFIG.build().getConn(); InputStream inputStream = FastAutoGeneratorTest.class.getResourceAsStream(\u0026#34;/db/schema-mysql.sql\u0026#34;); ScriptRunner scriptRunner = new ScriptRunner(conn); scriptRunner.setAutoCommit(true); scriptRunner.runScript(new InputStreamReader(inputStream)); conn.close(); } /** * 数据源配置 */ private static final DataSourceConfig.Builder DATA_SOURCE_CONFIG = new DataSourceConfig .Builder(\u0026#34;jdbc:mysql://localhost:3306/mybatis_plus_demo?useUnicode=true\u0026amp;characterEncoding=utf-8\u0026amp;allowMultiQueries=true\u0026amp;nullCatalogMeansCurrent=true\u0026#34;, \u0026#34;root\u0026#34;, \u0026#34;123456\u0026#34;); /** * 执行 run */ public static void main(String[] args) throws SQLException { before(); FastAutoGenerator.create(DATA_SOURCE_CONFIG) // 全局配置 .globalConfig((scanner, builder) -\u0026gt; builder.author(scanner.apply(\u0026#34;请输入作者名称\u0026#34;))) // 包配置 .packageConfig((scanner, builder) -\u0026gt; builder.parent(scanner.apply(\u0026#34;请输入包名\u0026#34;))) // 策略配置 .strategyConfig((scanner, builder) -\u0026gt; builder.addInclude(scanner.apply(\u0026#34;请输入表名,多个表名用,隔开\u0026#34;))) /* 模板引擎配置,默认 Velocity 可选模板引擎 Beetl 或 Freemarker .templateEngine(new BeetlTemplateEngine()) .templateEngine(new FreemarkerTemplateEngine()) */ .execute(); } } 使用mybats-x插件自动生成代码\n操作 编写controller确定\n@RestController @RequestMapping(\u0026#34;user\u0026#34;) public class UserController { @Autowired private UserService userService; @RequestMapping(\u0026#34;findAll\u0026#34;) public List\u0026lt;User\u0026gt; findAll(){ List\u0026lt;User\u0026gt; list = userService.list(); return list; } } xml文件\n\u0026lt;?xml version=\u0026#34;1.0\u0026#34; encoding=\u0026#34;UTF-8\u0026#34;?\u0026gt; \u0026lt;!DOCTYPE mapper PUBLIC \u0026#34;-//mybatis.org//DTD Mapper 3.0//EN\u0026#34; \u0026#34;http://mybatis.org/dtd/mybatis-3-mapper.dtd\u0026#34;\u0026gt; \u0026lt;mapper namespace=\u0026#34;com.baomidou.mybatisplus.samples.quickstart.mapper.UserMapper\u0026#34;\u0026gt; \u0026lt;resultMap id=\u0026#34;BaseResultMap\u0026#34; type=\u0026#34;com.baomidou.mybatisplus.samples.quickstart.entity.User\u0026#34;\u0026gt; \u0026lt;id property=\u0026#34;id\u0026#34; column=\u0026#34;id\u0026#34; jdbcType=\u0026#34;BIGINT\u0026#34;/\u0026gt; \u0026lt;result property=\u0026#34;name\u0026#34; column=\u0026#34;name\u0026#34; jdbcType=\u0026#34;VARCHAR\u0026#34;/\u0026gt; \u0026lt;result property=\u0026#34;age\u0026#34; column=\u0026#34;age\u0026#34; jdbcType=\u0026#34;INTEGER\u0026#34;/\u0026gt; \u0026lt;result property=\u0026#34;email\u0026#34; column=\u0026#34;email\u0026#34; jdbcType=\u0026#34;VARCHAR\u0026#34;/\u0026gt; \u0026lt;/resultMap\u0026gt; \u0026lt;sql id=\u0026#34;Base_Column_List\u0026#34;\u0026gt; id,name,age, email \u0026lt;/sql\u0026gt; \u0026lt;/mapper\u0026gt; entity\n/** * * @TableName user */ @TableName(value =\u0026#34;user\u0026#34;) public class User implements Serializable { /** * 主键ID */ @TableId private Long id; /** * 姓名 */ private String name; /** * 年龄 */ private Integer age; /** * 邮箱 */ private String email; @TableField(exist = false) private static final long serialVersionUID = 1L; /** * 主键ID */ public Long getId() { return id; } /** * 主键ID */ public void setId(Long id) { this.id = id; } /** * 姓名 */ public String getName() { return name; } /** * 姓名 */ public void setName(String name) { this.name = name; } /** * 年龄 */ public Integer getAge() { return age; } /** * 年龄 */ public void setAge(Integer age) { this.age = age; } /** * 邮箱 */ public String getEmail() { return email; } /** * 邮箱 */ public void setEmail(String email) { this.email = email; } @Override public boolean equals(Object that) { if (this == that) { return true; } if (that == null) { return false; } if (getClass() != that.getClass()) { return false; } User other = (User) that; return (this.getId() == null ? other.getId() == null : this.getId().equals(other.getId())) \u0026amp;\u0026amp; (this.getName() == null ? other.getName() == null : this.getName().equals(other.getName())) \u0026amp;\u0026amp; (this.getAge() == null ? other.getAge() == null : this.getAge().equals(other.getAge())) \u0026amp;\u0026amp; (this.getEmail() == null ? other.getEmail() == null : this.getEmail().equals(other.getEmail())); } @Override public int hashCode() { final int prime = 31; int result = 1; result = prime * result + ((getId() == null) ? 0 : getId().hashCode()); result = prime * result + ((getName() == null) ? 0 : getName().hashCode()); result = prime * result + ((getAge() == null) ? 0 : getAge().hashCode()); result = prime * result + ((getEmail() == null) ? 0 : getEmail().hashCode()); return result; } @Override public String toString() { StringBuilder sb = new StringBuilder(); sb.append(getClass().getSimpleName()); sb.append(\u0026#34; [\u0026#34;); sb.append(\u0026#34;Hash = \u0026#34;).append(hashCode()); sb.append(\u0026#34;, id=\u0026#34;).append(id); sb.append(\u0026#34;, name=\u0026#34;).append(name); sb.append(\u0026#34;, age=\u0026#34;).append(age); sb.append(\u0026#34;, email=\u0026#34;).append(email); sb.append(\u0026#34;, serialVersionUID=\u0026#34;).append(serialVersionUID); sb.append(\u0026#34;]\u0026#34;); return sb.toString(); } } service接口类\npublic interface UserService extends IService\u0026lt;User\u0026gt; { } serviceImpl\n@Service public class UserServiceImpl extends ServiceImpl\u0026lt;UserMapper, User\u0026gt; implements UserService{ } mapper\npublic interface UserMapper extends BaseMapper\u0026lt;User\u0026gt; { } controller测试\n@RestController @RequestMapping(\u0026#34;user\u0026#34;) public class UserController { @Autowired private UserService userService; @RequestMapping(\u0026#34;findAll\u0026#34;) public List\u0026lt;User\u0026gt; findAll(){ List\u0026lt;User\u0026gt; list = userService.list(); return list; } } 测试 使用mybatis-x 插件(idea)\n"},{"id":385,"href":"/zh/docs/technology/Flowable/boge_blbl_/03-others/","title":"boge-03-其他","section":"基础(波哥)_","content":" 会签 # 流程图绘制 注意上面几个参数\n多实例类型用来判断串行并行 基数(有几个用户处理) 元素变量 集合(集合变量) 完成条件\u0026ndash;这里填的是 ${nrOfCompletedInstances \u0026gt; 1 } 在任务监听器 package org.flowable.listener; import org.flowable.engine.ProcessEngine; import org.flowable.engine.ProcessEngines; import org.flowable.engine.TaskService; import org.flowable.engine.delegate.TaskListener; import org.flowable.task.api.Task; import org.flowable.task.service.delegate.DelegateTask; public class MultiInstanceTaskListener implements TaskListener { @Override public void notify(DelegateTask delegateTask) { System.out.println(\u0026#34;处理aaaa\u0026#34;); if(delegateTask.getEventName().equals(\u0026#34;create\u0026#34;)) { System.out.println(\u0026#34;任务id\u0026#34; + delegateTask.getId()); System.out.println(\u0026#34;哪些人需要会签\u0026#34; + delegateTask.getVariable(\u0026#34;persons\u0026#34;)); System.out.println(\u0026#34;任务处理人\u0026#34; + delegateTask.getVariable(\u0026#34;person\u0026#34;)); ProcessEngine engine = ProcessEngines.getDefaultProcessEngine(); TaskService taskService = engine.getTaskService(); Task task = taskService.createTaskQuery().taskId(delegateTask.getId()).singleResult(); task.setAssignee(delegateTask.getVariable(\u0026#34;person\u0026#34;).toString()); taskService.saveTask(task); } } } xml\n\u0026lt;?xml version=\u0026#34;1.0\u0026#34; encoding=\u0026#34;UTF-8\u0026#34;?\u0026gt; \u0026lt;definitions xmlns=\u0026#34;http://www.omg.org/spec/BPMN/20100524/MODEL\u0026#34; xmlns:xsi=\u0026#34;http://www.w3.org/2001/XMLSchema-instance\u0026#34; xmlns:xsd=\u0026#34;http://www.w3.org/2001/XMLSchema\u0026#34; xmlns:flowable=\u0026#34;http://flowable.org/bpmn\u0026#34; xmlns:bpmndi=\u0026#34;http://www.omg.org/spec/BPMN/20100524/DI\u0026#34; xmlns:omgdc=\u0026#34;http://www.omg.org/spec/DD/20100524/DC\u0026#34; xmlns:omgdi=\u0026#34;http://www.omg.org/spec/DD/20100524/DI\u0026#34; typeLanguage=\u0026#34;http://www.w3.org/2001/XMLSchema\u0026#34; expressionLanguage=\u0026#34;http://www.w3.org/1999/XPath\u0026#34; targetNamespace=\u0026#34;http://www.flowable.org/processdef\u0026#34; exporter=\u0026#34;Flowable Open Source Modeler\u0026#34; exporterVersion=\u0026#34;6.7.2\u0026#34;\u0026gt; \u0026lt;process id=\u0026#34;join-key\u0026#34; name=\u0026#34;会签测试1\u0026#34; isExecutable=\u0026#34;true\u0026#34;\u0026gt; \u0026lt;documentation\u0026gt;join-desc\u0026lt;/documentation\u0026gt; \u0026lt;startEvent id=\u0026#34;startEvent1\u0026#34; name=\u0026#34;申请人\u0026#34; flowable:formFieldValidation=\u0026#34;true\u0026#34;\u0026gt;\u0026lt;/startEvent\u0026gt; \u0026lt;userTask id=\u0026#34;sid-477F728E-2F63-43BF-A278-76FBCF58B475\u0026#34; name=\u0026#34;会签人员\u0026#34; flowable:formFieldValidation=\u0026#34;true\u0026#34;\u0026gt; \u0026lt;extensionElements\u0026gt; \u0026lt;flowable:taskListener event=\u0026#34;create\u0026#34; class=\u0026#34;org.flowable.listener.MultiInstanceTaskListener\u0026#34;\u0026gt;\u0026lt;/flowable:taskListener\u0026gt; \u0026lt;/extensionElements\u0026gt; \u0026lt;multiInstanceLoopCharacteristics isSequential=\u0026#34;false\u0026#34; flowable:collection=\u0026#34;persons\u0026#34; flowable:elementVariable=\u0026#34;person\u0026#34;\u0026gt; \u0026lt;extensionElements\u0026gt;\u0026lt;/extensionElements\u0026gt; \u0026lt;loopCardinality\u0026gt;3\u0026lt;/loopCardinality\u0026gt; \u0026lt;completionCondition\u0026gt;${nrOfCompletedInstances \u0026gt; 1 }\u0026lt;/completionCondition\u0026gt; \u0026lt;/multiInstanceLoopCharacteristics\u0026gt; \u0026lt;/userTask\u0026gt; \u0026lt;sequenceFlow id=\u0026#34;sid-B5F81E26-E53B-4D10-8328-C5B3C35E0DD5\u0026#34; sourceRef=\u0026#34;startEvent1\u0026#34; targetRef=\u0026#34;sid-477F728E-2F63-43BF-A278-76FBCF58B475\u0026#34;\u0026gt;\u0026lt;/sequenceFlow\u0026gt; \u0026lt;endEvent id=\u0026#34;sid-3448D902-AE89-467D-8945-805BDEDE7BCA\u0026#34;\u0026gt;\u0026lt;/endEvent\u0026gt; \u0026lt;sequenceFlow id=\u0026#34;sid-598B2F86-A13B-48BE-88AF-6B61CDA24EA7\u0026#34; sourceRef=\u0026#34;sid-477F728E-2F63-43BF-A278-76FBCF58B475\u0026#34; targetRef=\u0026#34;sid-3448D902-AE89-467D-8945-805BDEDE7BCA\u0026#34;\u0026gt;\u0026lt;/sequenceFlow\u0026gt; \u0026lt;/process\u0026gt; \u0026lt;bpmndi:BPMNDiagram id=\u0026#34;BPMNDiagram_join-key\u0026#34;\u0026gt; \u0026lt;bpmndi:BPMNPlane bpmnElement=\u0026#34;join-key\u0026#34; id=\u0026#34;BPMNPlane_join-key\u0026#34;\u0026gt; \u0026lt;bpmndi:BPMNShape bpmnElement=\u0026#34;startEvent1\u0026#34; id=\u0026#34;BPMNShape_startEvent1\u0026#34;\u0026gt; \u0026lt;omgdc:Bounds height=\u0026#34;30.0\u0026#34; width=\u0026#34;30.0\u0026#34; x=\u0026#34;105.0\u0026#34; y=\u0026#34;100.0\u0026#34;\u0026gt;\u0026lt;/omgdc:Bounds\u0026gt; \u0026lt;/bpmndi:BPMNShape\u0026gt; \u0026lt;bpmndi:BPMNShape bpmnElement=\u0026#34;sid-477F728E-2F63-43BF-A278-76FBCF58B475\u0026#34; id=\u0026#34;BPMNShape_sid-477F728E-2F63-43BF-A278-76FBCF58B475\u0026#34;\u0026gt; \u0026lt;omgdc:Bounds height=\u0026#34;80.0\u0026#34; width=\u0026#34;100.0\u0026#34; x=\u0026#34;330.0\u0026#34; y=\u0026#34;60.0\u0026#34;\u0026gt;\u0026lt;/omgdc:Bounds\u0026gt; \u0026lt;/bpmndi:BPMNShape\u0026gt; \u0026lt;bpmndi:BPMNShape bpmnElement=\u0026#34;sid-3448D902-AE89-467D-8945-805BDEDE7BCA\u0026#34; id=\u0026#34;BPMNShape_sid-3448D902-AE89-467D-8945-805BDEDE7BCA\u0026#34;\u0026gt; \u0026lt;omgdc:Bounds height=\u0026#34;28.0\u0026#34; width=\u0026#34;28.0\u0026#34; x=\u0026#34;600.0\u0026#34; y=\u0026#34;106.0\u0026#34;\u0026gt;\u0026lt;/omgdc:Bounds\u0026gt; \u0026lt;/bpmndi:BPMNShape\u0026gt; \u0026lt;bpmndi:BPMNEdge bpmnElement=\u0026#34;sid-B5F81E26-E53B-4D10-8328-C5B3C35E0DD5\u0026#34; id=\u0026#34;BPMNEdge_sid-B5F81E26-E53B-4D10-8328-C5B3C35E0DD5\u0026#34; flowable:sourceDockerX=\u0026#34;15.0\u0026#34; flowable:sourceDockerY=\u0026#34;15.0\u0026#34; flowable:targetDockerX=\u0026#34;50.0\u0026#34; flowable:targetDockerY=\u0026#34;40.0\u0026#34;\u0026gt; \u0026lt;omgdi:waypoint x=\u0026#34;134.94999855629513\u0026#34; y=\u0026#34;115.0\u0026#34;\u0026gt;\u0026lt;/omgdi:waypoint\u0026gt; \u0026lt;omgdi:waypoint x=\u0026#34;232.5\u0026#34; y=\u0026#34;115.0\u0026#34;\u0026gt;\u0026lt;/omgdi:waypoint\u0026gt; \u0026lt;omgdi:waypoint x=\u0026#34;232.5\u0026#34; y=\u0026#34;100.0\u0026#34;\u0026gt;\u0026lt;/omgdi:waypoint\u0026gt; \u0026lt;omgdi:waypoint x=\u0026#34;330.0\u0026#34; y=\u0026#34;100.0\u0026#34;\u0026gt;\u0026lt;/omgdi:waypoint\u0026gt; \u0026lt;/bpmndi:BPMNEdge\u0026gt; \u0026lt;bpmndi:BPMNEdge bpmnElement=\u0026#34;sid-598B2F86-A13B-48BE-88AF-6B61CDA24EA7\u0026#34; id=\u0026#34;BPMNEdge_sid-598B2F86-A13B-48BE-88AF-6B61CDA24EA7\u0026#34; flowable:sourceDockerX=\u0026#34;50.0\u0026#34; flowable:sourceDockerY=\u0026#34;40.0\u0026#34; flowable:targetDockerX=\u0026#34;14.0\u0026#34; flowable:targetDockerY=\u0026#34;14.0\u0026#34;\u0026gt; \u0026lt;omgdi:waypoint x=\u0026#34;429.95000000000005\u0026#34; y=\u0026#34;100.0\u0026#34;\u0026gt;\u0026lt;/omgdi:waypoint\u0026gt; \u0026lt;omgdi:waypoint x=\u0026#34;515.0\u0026#34; y=\u0026#34;100.0\u0026#34;\u0026gt;\u0026lt;/omgdi:waypoint\u0026gt; \u0026lt;omgdi:waypoint x=\u0026#34;515.0\u0026#34; y=\u0026#34;120.0\u0026#34;\u0026gt;\u0026lt;/omgdi:waypoint\u0026gt; \u0026lt;omgdi:waypoint x=\u0026#34;600.0\u0026#34; y=\u0026#34;120.0\u0026#34;\u0026gt;\u0026lt;/omgdi:waypoint\u0026gt; \u0026lt;/bpmndi:BPMNEdge\u0026gt; \u0026lt;/bpmndi:BPMNPlane\u0026gt; \u0026lt;/bpmndi:BPMNDiagram\u0026gt; \u0026lt;/definitions\u0026gt; 将流程部署\n@Test public void deploy() { deleteAll(); ProcessEngine engine = ProcessEngines.getDefaultProcessEngine(); RepositoryService repositoryService = engine.getRepositoryService(); Deployment deploy = repositoryService.createDeployment() .addClasspathResource(\u0026#34;会签测试1.bpmn20.xml\u0026#34;) .deploy(); System.out.println(\u0026#34;部署成功:\u0026#34; + deploy.getId()); } 运行流程\n@Test public void run(){ ProcessEngine defaultProcessEngine = ProcessEngines.getDefaultProcessEngine(); RuntimeService runtimeService = defaultProcessEngine.getRuntimeService(); HashMap\u0026lt;String,Object\u0026gt; map=new HashMap\u0026lt;\u0026gt;(); ArrayList\u0026lt;String\u0026gt; persons=new ArrayList\u0026lt;\u0026gt;(); persons.add(\u0026#34;张三\u0026#34;); persons.add(\u0026#34;李四\u0026#34;); persons.add(\u0026#34;王五\u0026#34;); map.put(\u0026#34;persons\u0026#34;,persons); ProcessInstance processInstance = runtimeService.startProcessInstanceById(\u0026#34;join-key:1:17504\u0026#34;,map); } 此时数据库会有三个任务 完成第一个任务\n@Test public void completeTask(){ //15020 ProcessEngine engine=ProcessEngines.getDefaultProcessEngine(); TaskService taskService = engine.getTaskService(); taskService.complete(\u0026#34;20020\u0026#34;); } 再完成一个任务后,流程会直接结束\n@Test public void completeTask(){ //15020 ProcessEngine engine=ProcessEngines.getDefaultProcessEngine(); TaskService taskService = engine.getTaskService(); taskService.complete(\u0026#34;20028\u0026#34;); } 流程结束\n"},{"id":386,"href":"/zh/docs/technology/Flowable/boge_blbl_/02-advance_6/","title":"boge-02-flowable进阶_6","section":"基础(波哥)_","content":" 任务回退-串行回退 # 流程图绘制 xml\n\u0026lt;?xml version=\u0026#34;1.0\u0026#34; encoding=\u0026#34;UTF-8\u0026#34;?\u0026gt; \u0026lt;definitions xmlns=\u0026#34;http://www.omg.org/spec/BPMN/20100524/MODEL\u0026#34; xmlns:xsi=\u0026#34;http://www.w3.org/2001/XMLSchema-instance\u0026#34; xmlns:xsd=\u0026#34;http://www.w3.org/2001/XMLSchema\u0026#34; xmlns:flowable=\u0026#34;http://flowable.org/bpmn\u0026#34; xmlns:bpmndi=\u0026#34;http://www.omg.org/spec/BPMN/20100524/DI\u0026#34; xmlns:omgdc=\u0026#34;http://www.omg.org/spec/DD/20100524/DC\u0026#34; xmlns:omgdi=\u0026#34;http://www.omg.org/spec/DD/20100524/DI\u0026#34; typeLanguage=\u0026#34;http://www.w3.org/2001/XMLSchema\u0026#34; expressionLanguage=\u0026#34;http://www.w3.org/1999/XPath\u0026#34; targetNamespace=\u0026#34;http://www.flowable.org/processdef\u0026#34; exporter=\u0026#34;Flowable Open Source Modeler\u0026#34; exporterVersion=\u0026#34;6.7.2\u0026#34;\u0026gt; \u0026lt;process id=\u0026#34;reback-key\u0026#34; name=\u0026#34;回退处理\u0026#34; isExecutable=\u0026#34;true\u0026#34;\u0026gt; \u0026lt;documentation\u0026gt;reback-desc\u0026lt;/documentation\u0026gt; \u0026lt;startEvent id=\u0026#34;startEvent1\u0026#34; flowable:formFieldValidation=\u0026#34;true\u0026#34;\u0026gt;\u0026lt;/startEvent\u0026gt; \u0026lt;userTask id=\u0026#34;sid-D380E41A-48EE-4C08-AD01-1D509C512543\u0026#34; name=\u0026#34;用户1\u0026#34; flowable:assignee=\u0026#34;user1\u0026#34; flowable:formFieldValidation=\u0026#34;true\u0026#34;\u0026gt; \u0026lt;extensionElements\u0026gt; \u0026lt;modeler:initiator-can-complete xmlns:modeler=\u0026#34;http://flowable.org/modeler\u0026#34;\u0026gt;\u0026lt;![CDATA[false]]\u0026gt;\u0026lt;/modeler:initiator-can-complete\u0026gt; \u0026lt;/extensionElements\u0026gt; \u0026lt;/userTask\u0026gt; \u0026lt;sequenceFlow id=\u0026#34;sid-E2423FC5-F954-43D3-B57C-8460057CB7D6\u0026#34; sourceRef=\u0026#34;startEvent1\u0026#34; targetRef=\u0026#34;sid-D380E41A-48EE-4C08-AD01-1D509C512543\u0026#34;\u0026gt;\u0026lt;/sequenceFlow\u0026gt; \u0026lt;userTask id=\u0026#34;sid-AF50E3D0-2014-4308-A717-D76586837D70\u0026#34; name=\u0026#34;用户2\u0026#34; flowable:assignee=\u0026#34;user2\u0026#34; flowable:formFieldValidation=\u0026#34;true\u0026#34;\u0026gt; \u0026lt;extensionElements\u0026gt; \u0026lt;modeler:initiator-can-complete xmlns:modeler=\u0026#34;http://flowable.org/modeler\u0026#34;\u0026gt;\u0026lt;![CDATA[false]]\u0026gt;\u0026lt;/modeler:initiator-can-complete\u0026gt; \u0026lt;/extensionElements\u0026gt; \u0026lt;/userTask\u0026gt; \u0026lt;sequenceFlow id=\u0026#34;sid-7C8750DC-E1C1-4AB2-B18C-2C103B61A5E5\u0026#34; sourceRef=\u0026#34;sid-D380E41A-48EE-4C08-AD01-1D509C512543\u0026#34; targetRef=\u0026#34;sid-AF50E3D0-2014-4308-A717-D76586837D70\u0026#34;\u0026gt;\u0026lt;/sequenceFlow\u0026gt; \u0026lt;userTask id=\u0026#34;sid-F4CE7565-5977-4B9C-A603-AB3B817B8C8C\u0026#34; name=\u0026#34;用户3\u0026#34; flowable:assignee=\u0026#34;user3\u0026#34; flowable:formFieldValidation=\u0026#34;true\u0026#34;\u0026gt; \u0026lt;extensionElements\u0026gt; \u0026lt;modeler:initiator-can-complete xmlns:modeler=\u0026#34;http://flowable.org/modeler\u0026#34;\u0026gt;\u0026lt;![CDATA[false]]\u0026gt;\u0026lt;/modeler:initiator-can-complete\u0026gt; \u0026lt;/extensionElements\u0026gt; \u0026lt;/userTask\u0026gt; \u0026lt;sequenceFlow id=\u0026#34;sid-F91582FE-D110-48C9-9407-605E503E42B2\u0026#34; sourceRef=\u0026#34;sid-AF50E3D0-2014-4308-A717-D76586837D70\u0026#34; targetRef=\u0026#34;sid-F4CE7565-5977-4B9C-A603-AB3B817B8C8C\u0026#34;\u0026gt;\u0026lt;/sequenceFlow\u0026gt; \u0026lt;userTask id=\u0026#34;sid-727C1235-F9C1-4CC5-BC6C-E56ABCA105B0\u0026#34; name=\u0026#34;用户4\u0026#34; flowable:assignee=\u0026#34;user4\u0026#34; flowable:formFieldValidation=\u0026#34;true\u0026#34;\u0026gt; \u0026lt;extensionElements\u0026gt; \u0026lt;modeler:initiator-can-complete xmlns:modeler=\u0026#34;http://flowable.org/modeler\u0026#34;\u0026gt;\u0026lt;![CDATA[false]]\u0026gt;\u0026lt;/modeler:initiator-can-complete\u0026gt; \u0026lt;/extensionElements\u0026gt; \u0026lt;/userTask\u0026gt; \u0026lt;sequenceFlow id=\u0026#34;sid-6D998C20-2A97-44B5-92D0-118E5CB05795\u0026#34; sourceRef=\u0026#34;sid-F4CE7565-5977-4B9C-A603-AB3B817B8C8C\u0026#34; targetRef=\u0026#34;sid-727C1235-F9C1-4CC5-BC6C-E56ABCA105B0\u0026#34;\u0026gt;\u0026lt;/sequenceFlow\u0026gt; \u0026lt;endEvent id=\u0026#34;sid-6E5F5037-1979-4150-8408-D0BFD0315BCA\u0026#34;\u0026gt;\u0026lt;/endEvent\u0026gt; \u0026lt;sequenceFlow id=\u0026#34;sid-3ECF3E34-6C07-4AE6-997B-583BF8868AC8\u0026#34; sourceRef=\u0026#34;sid-727C1235-F9C1-4CC5-BC6C-E56ABCA105B0\u0026#34; targetRef=\u0026#34;sid-6E5F5037-1979-4150-8408-D0BFD0315BCA\u0026#34;\u0026gt;\u0026lt;/sequenceFlow\u0026gt; \u0026lt;/process\u0026gt; \u0026lt;bpmndi:BPMNDiagram id=\u0026#34;BPMNDiagram_reback-key\u0026#34;\u0026gt; \u0026lt;bpmndi:BPMNPlane bpmnElement=\u0026#34;reback-key\u0026#34; id=\u0026#34;BPMNPlane_reback-key\u0026#34;\u0026gt; \u0026lt;bpmndi:BPMNShape bpmnElement=\u0026#34;startEvent1\u0026#34; id=\u0026#34;BPMNShape_startEvent1\u0026#34;\u0026gt; \u0026lt;omgdc:Bounds height=\u0026#34;30.0\u0026#34; width=\u0026#34;30.0\u0026#34; x=\u0026#34;100.0\u0026#34; y=\u0026#34;163.0\u0026#34;\u0026gt;\u0026lt;/omgdc:Bounds\u0026gt; \u0026lt;/bpmndi:BPMNShape\u0026gt; \u0026lt;bpmndi:BPMNShape bpmnElement=\u0026#34;sid-D380E41A-48EE-4C08-AD01-1D509C512543\u0026#34; id=\u0026#34;BPMNShape_sid-D380E41A-48EE-4C08-AD01-1D509C512543\u0026#34;\u0026gt; \u0026lt;omgdc:Bounds height=\u0026#34;80.0\u0026#34; width=\u0026#34;100.0\u0026#34; x=\u0026#34;165.0\u0026#34; y=\u0026#34;135.0\u0026#34;\u0026gt;\u0026lt;/omgdc:Bounds\u0026gt; \u0026lt;/bpmndi:BPMNShape\u0026gt; \u0026lt;bpmndi:BPMNShape bpmnElement=\u0026#34;sid-AF50E3D0-2014-4308-A717-D76586837D70\u0026#34; id=\u0026#34;BPMNShape_sid-AF50E3D0-2014-4308-A717-D76586837D70\u0026#34;\u0026gt; \u0026lt;omgdc:Bounds height=\u0026#34;80.0\u0026#34; width=\u0026#34;100.0\u0026#34; x=\u0026#34;320.0\u0026#34; y=\u0026#34;138.0\u0026#34;\u0026gt;\u0026lt;/omgdc:Bounds\u0026gt; \u0026lt;/bpmndi:BPMNShape\u0026gt; \u0026lt;bpmndi:BPMNShape bpmnElement=\u0026#34;sid-F4CE7565-5977-4B9C-A603-AB3B817B8C8C\u0026#34; id=\u0026#34;BPMNShape_sid-F4CE7565-5977-4B9C-A603-AB3B817B8C8C\u0026#34;\u0026gt; \u0026lt;omgdc:Bounds height=\u0026#34;80.0\u0026#34; width=\u0026#34;100.0\u0026#34; x=\u0026#34;465.0\u0026#34; y=\u0026#34;138.0\u0026#34;\u0026gt;\u0026lt;/omgdc:Bounds\u0026gt; \u0026lt;/bpmndi:BPMNShape\u0026gt; \u0026lt;bpmndi:BPMNShape bpmnElement=\u0026#34;sid-727C1235-F9C1-4CC5-BC6C-E56ABCA105B0\u0026#34; id=\u0026#34;BPMNShape_sid-727C1235-F9C1-4CC5-BC6C-E56ABCA105B0\u0026#34;\u0026gt; \u0026lt;omgdc:Bounds height=\u0026#34;80.0\u0026#34; width=\u0026#34;100.0\u0026#34; x=\u0026#34;610.0\u0026#34; y=\u0026#34;138.0\u0026#34;\u0026gt;\u0026lt;/omgdc:Bounds\u0026gt; \u0026lt;/bpmndi:BPMNShape\u0026gt; \u0026lt;bpmndi:BPMNShape bpmnElement=\u0026#34;sid-6E5F5037-1979-4150-8408-D0BFD0315BCA\u0026#34; id=\u0026#34;BPMNShape_sid-6E5F5037-1979-4150-8408-D0BFD0315BCA\u0026#34;\u0026gt; \u0026lt;omgdc:Bounds height=\u0026#34;28.0\u0026#34; width=\u0026#34;28.0\u0026#34; x=\u0026#34;755.0\u0026#34; y=\u0026#34;164.0\u0026#34;\u0026gt;\u0026lt;/omgdc:Bounds\u0026gt; \u0026lt;/bpmndi:BPMNShape\u0026gt; \u0026lt;bpmndi:BPMNEdge bpmnElement=\u0026#34;sid-6D998C20-2A97-44B5-92D0-118E5CB05795\u0026#34; id=\u0026#34;BPMNEdge_sid-6D998C20-2A97-44B5-92D0-118E5CB05795\u0026#34; flowable:sourceDockerX=\u0026#34;50.0\u0026#34; flowable:sourceDockerY=\u0026#34;40.0\u0026#34; flowable:targetDockerX=\u0026#34;50.0\u0026#34; flowable:targetDockerY=\u0026#34;40.0\u0026#34;\u0026gt; \u0026lt;omgdi:waypoint x=\u0026#34;564.9499999999907\u0026#34; y=\u0026#34;178.0\u0026#34;\u0026gt;\u0026lt;/omgdi:waypoint\u0026gt; \u0026lt;omgdi:waypoint x=\u0026#34;609.9999999999807\u0026#34; y=\u0026#34;178.0\u0026#34;\u0026gt;\u0026lt;/omgdi:waypoint\u0026gt; \u0026lt;/bpmndi:BPMNEdge\u0026gt; \u0026lt;bpmndi:BPMNEdge bpmnElement=\u0026#34;sid-7C8750DC-E1C1-4AB2-B18C-2C103B61A5E5\u0026#34; id=\u0026#34;BPMNEdge_sid-7C8750DC-E1C1-4AB2-B18C-2C103B61A5E5\u0026#34; flowable:sourceDockerX=\u0026#34;50.0\u0026#34; flowable:sourceDockerY=\u0026#34;40.0\u0026#34; flowable:targetDockerX=\u0026#34;50.0\u0026#34; flowable:targetDockerY=\u0026#34;40.0\u0026#34;\u0026gt; \u0026lt;omgdi:waypoint x=\u0026#34;264.9499999999882\u0026#34; y=\u0026#34;175.0\u0026#34;\u0026gt;\u0026lt;/omgdi:waypoint\u0026gt; \u0026lt;omgdi:waypoint x=\u0026#34;292.5\u0026#34; y=\u0026#34;175.0\u0026#34;\u0026gt;\u0026lt;/omgdi:waypoint\u0026gt; \u0026lt;omgdi:waypoint x=\u0026#34;292.5\u0026#34; y=\u0026#34;178.0\u0026#34;\u0026gt;\u0026lt;/omgdi:waypoint\u0026gt; \u0026lt;omgdi:waypoint x=\u0026#34;319.9999999999603\u0026#34; y=\u0026#34;178.0\u0026#34;\u0026gt;\u0026lt;/omgdi:waypoint\u0026gt; \u0026lt;/bpmndi:BPMNEdge\u0026gt; \u0026lt;bpmndi:BPMNEdge bpmnElement=\u0026#34;sid-3ECF3E34-6C07-4AE6-997B-583BF8868AC8\u0026#34; id=\u0026#34;BPMNEdge_sid-3ECF3E34-6C07-4AE6-997B-583BF8868AC8\u0026#34; flowable:sourceDockerX=\u0026#34;50.0\u0026#34; flowable:sourceDockerY=\u0026#34;40.0\u0026#34; flowable:targetDockerX=\u0026#34;14.0\u0026#34; flowable:targetDockerY=\u0026#34;14.0\u0026#34;\u0026gt; \u0026lt;omgdi:waypoint x=\u0026#34;709.9499999999999\u0026#34; y=\u0026#34;178.0\u0026#34;\u0026gt;\u0026lt;/omgdi:waypoint\u0026gt; \u0026lt;omgdi:waypoint x=\u0026#34;755.0\u0026#34; y=\u0026#34;178.0\u0026#34;\u0026gt;\u0026lt;/omgdi:waypoint\u0026gt; \u0026lt;/bpmndi:BPMNEdge\u0026gt; \u0026lt;bpmndi:BPMNEdge bpmnElement=\u0026#34;sid-E2423FC5-F954-43D3-B57C-8460057CB7D6\u0026#34; id=\u0026#34;BPMNEdge_sid-E2423FC5-F954-43D3-B57C-8460057CB7D6\u0026#34; flowable:sourceDockerX=\u0026#34;15.0\u0026#34; flowable:sourceDockerY=\u0026#34;15.0\u0026#34; flowable:targetDockerX=\u0026#34;50.0\u0026#34; flowable:targetDockerY=\u0026#34;40.0\u0026#34;\u0026gt; \u0026lt;omgdi:waypoint x=\u0026#34;129.94340692927761\u0026#34; y=\u0026#34;177.55019845363262\u0026#34;\u0026gt;\u0026lt;/omgdi:waypoint\u0026gt; \u0026lt;omgdi:waypoint x=\u0026#34;164.99999999999906\u0026#34; y=\u0026#34;176.4985\u0026#34;\u0026gt;\u0026lt;/omgdi:waypoint\u0026gt; \u0026lt;/bpmndi:BPMNEdge\u0026gt; \u0026lt;bpmndi:BPMNEdge bpmnElement=\u0026#34;sid-F91582FE-D110-48C9-9407-605E503E42B2\u0026#34; id=\u0026#34;BPMNEdge_sid-F91582FE-D110-48C9-9407-605E503E42B2\u0026#34; flowable:sourceDockerX=\u0026#34;50.0\u0026#34; flowable:sourceDockerY=\u0026#34;40.0\u0026#34; flowable:targetDockerX=\u0026#34;50.0\u0026#34; flowable:targetDockerY=\u0026#34;40.0\u0026#34;\u0026gt; \u0026lt;omgdi:waypoint x=\u0026#34;419.94999999999067\u0026#34; y=\u0026#34;178.0\u0026#34;\u0026gt;\u0026lt;/omgdi:waypoint\u0026gt; \u0026lt;omgdi:waypoint x=\u0026#34;464.9999999999807\u0026#34; y=\u0026#34;178.0\u0026#34;\u0026gt;\u0026lt;/omgdi:waypoint\u0026gt; \u0026lt;/bpmndi:BPMNEdge\u0026gt; \u0026lt;/bpmndi:BPMNPlane\u0026gt; \u0026lt;/bpmndi:BPMNDiagram\u0026gt; \u0026lt;/definitions\u0026gt; 部署并运行\n依次完成1,2,3\n从任意节点跳转到任意节点\n@Test public void backProcess(){ ProcessEngine engine=ProcessEngines.getDefaultProcessEngine(); RuntimeService runtimeService = engine.getRuntimeService(); //从当前流程跳转到任意节点 runtimeService.createChangeActivityStateBuilder() .processInstanceId(\u0026#34;2501\u0026#34;) //4--\u0026gt;3 ,活动id .moveActivityIdTo(\u0026#34;sid-727C1235-F9C1-4CC5-BC6C-E56ABCA105B0\u0026#34;, \u0026#34;sid-F4CE7565-5977-4B9C-A603-AB3B817B8C8C\u0026#34;) .changeState(); } 可以在这个表里让用户选择回退节点 此时让user3再完成任务\n注:用下面的方法,不关心当前节点,只写明要跳转的结点即可 自定义表单 # 内置表单 # 绘制 xml\n\u0026lt;?xml version=\u0026#34;1.0\u0026#34; encoding=\u0026#34;UTF-8\u0026#34;?\u0026gt; \u0026lt;definitions xmlns=\u0026#34;http://www.omg.org/spec/BPMN/20100524/MODEL\u0026#34; xmlns:xsi=\u0026#34;http://www.w3.org/2001/XMLSchema-instance\u0026#34; xmlns:xsd=\u0026#34;http://www.w3.org/2001/XMLSchema\u0026#34; xmlns:flowable=\u0026#34;http://flowable.org/bpmn\u0026#34; xmlns:bpmndi=\u0026#34;http://www.omg.org/spec/BPMN/20100524/DI\u0026#34; xmlns:omgdc=\u0026#34;http://www.omg.org/spec/DD/20100524/DC\u0026#34; xmlns:omgdi=\u0026#34;http://www.omg.org/spec/DD/20100524/DI\u0026#34; typeLanguage=\u0026#34;http://www.w3.org/2001/XMLSchema\u0026#34; expressionLanguage=\u0026#34;http://www.w3.org/1999/XPath\u0026#34; targetNamespace=\u0026#34;http://www.flowable.org/processdef\u0026#34; exporter=\u0026#34;Flowable Open Source Modeler\u0026#34; exporterVersion=\u0026#34;6.7.2\u0026#34;\u0026gt; \u0026lt;process id=\u0026#34;form1-test-key\u0026#34; name=\u0026#34;form1-test-name\u0026#34; isExecutable=\u0026#34;true\u0026#34;\u0026gt; \u0026lt;documentation\u0026gt;form1-test-desc\u0026lt;/documentation\u0026gt; \u0026lt;startEvent id=\u0026#34;startEvent1\u0026#34; flowable:formFieldValidation=\u0026#34;true\u0026#34;\u0026gt; \u0026lt;extensionElements\u0026gt; \u0026lt;flowable:formProperty id=\u0026#34;days\u0026#34; name=\u0026#34;天数\u0026#34; type=\u0026#34;long\u0026#34; default=\u0026#34;5\u0026#34;\u0026gt;\u0026lt;/flowable:formProperty\u0026gt; \u0026lt;flowable:formProperty id=\u0026#34;start_time\u0026#34; name=\u0026#34;开始时间\u0026#34; type=\u0026#34;date\u0026#34; datePattern=\u0026#34;MM-dd-yyyy\u0026#34;\u0026gt;\u0026lt;/flowable:formProperty\u0026gt; \u0026lt;flowable:formProperty id=\u0026#34;reason\u0026#34; name=\u0026#34;原因\u0026#34; type=\u0026#34;string\u0026#34;\u0026gt;\u0026lt;/flowable:formProperty\u0026gt; \u0026lt;/extensionElements\u0026gt; \u0026lt;/startEvent\u0026gt; \u0026lt;userTask id=\u0026#34;sid-4C9C8571-1423-4137-93FC-6A138D504E24\u0026#34; name=\u0026#34;用户申请\u0026#34; flowable:formFieldValidation=\u0026#34;true\u0026#34;\u0026gt; \u0026lt;extensionElements\u0026gt; \u0026lt;flowable:formProperty id=\u0026#34;days\u0026#34; name=\u0026#34;天数\u0026#34; type=\u0026#34;long\u0026#34;\u0026gt;\u0026lt;/flowable:formProperty\u0026gt; \u0026lt;flowable:formProperty id=\u0026#34;start_time\u0026#34; name=\u0026#34;开始时间\u0026#34; type=\u0026#34;date\u0026#34; datePattern=\u0026#34;MM-dd-yyyy\u0026#34;\u0026gt;\u0026lt;/flowable:formProperty\u0026gt; \u0026lt;flowable:formProperty id=\u0026#34;reason\u0026#34; name=\u0026#34;原因\u0026#34; type=\u0026#34;string\u0026#34;\u0026gt;\u0026lt;/flowable:formProperty\u0026gt; \u0026lt;/extensionElements\u0026gt; \u0026lt;/userTask\u0026gt; \u0026lt;sequenceFlow id=\u0026#34;sid-8944FE04-D27B-435F-A8A8-4E545AB3D6C0\u0026#34; sourceRef=\u0026#34;startEvent1\u0026#34; targetRef=\u0026#34;sid-4C9C8571-1423-4137-93FC-6A138D504E24\u0026#34;\u0026gt;\u0026lt;/sequenceFlow\u0026gt; \u0026lt;exclusiveGateway id=\u0026#34;sid-35DD948A-C095-486E-98E0-4A0EEC4D9FBC\u0026#34;\u0026gt;\u0026lt;/exclusiveGateway\u0026gt; \u0026lt;sequenceFlow id=\u0026#34;sid-0EA36B83-6115-414F-BC7D-9CB338B03F22\u0026#34; sourceRef=\u0026#34;sid-4C9C8571-1423-4137-93FC-6A138D504E24\u0026#34; targetRef=\u0026#34;sid-35DD948A-C095-486E-98E0-4A0EEC4D9FBC\u0026#34;\u0026gt;\u0026lt;/sequenceFlow\u0026gt; \u0026lt;userTask id=\u0026#34;sid-4B6496FE-B5FE-41AC-83F8-4B7224B09FBD\u0026#34; name=\u0026#34;总监审批\u0026#34; flowable:formFieldValidation=\u0026#34;true\u0026#34;\u0026gt;\u0026lt;/userTask\u0026gt; \u0026lt;userTask id=\u0026#34;sid-8DE5EA05-89D5-48B0-9359-F8ABFB3A3500\u0026#34; name=\u0026#34;部门经理审批\u0026#34; flowable:formFieldValidation=\u0026#34;true\u0026#34;\u0026gt;\u0026lt;/userTask\u0026gt; \u0026lt;exclusiveGateway id=\u0026#34;sid-0EC09183-F41B-4785-83E7-423BB86EB013\u0026#34;\u0026gt;\u0026lt;/exclusiveGateway\u0026gt; \u0026lt;sequenceFlow id=\u0026#34;sid-562C26B5-B634-4771-BF54-C311D56A5317\u0026#34; sourceRef=\u0026#34;sid-4B6496FE-B5FE-41AC-83F8-4B7224B09FBD\u0026#34; targetRef=\u0026#34;sid-0EC09183-F41B-4785-83E7-423BB86EB013\u0026#34;\u0026gt;\u0026lt;/sequenceFlow\u0026gt; \u0026lt;sequenceFlow id=\u0026#34;sid-9AC3E009-D4D6-4D8B-883C-701E044715E9\u0026#34; sourceRef=\u0026#34;sid-8DE5EA05-89D5-48B0-9359-F8ABFB3A3500\u0026#34; targetRef=\u0026#34;sid-0EC09183-F41B-4785-83E7-423BB86EB013\u0026#34;\u0026gt;\u0026lt;/sequenceFlow\u0026gt; \u0026lt;endEvent id=\u0026#34;sid-9CD52D35-7874-42F4-B392-466F71316BFE\u0026#34;\u0026gt;\u0026lt;/endEvent\u0026gt; \u0026lt;sequenceFlow id=\u0026#34;sid-FABB64D1-0182-41D8-90FE-53FE7FE3F024\u0026#34; sourceRef=\u0026#34;sid-0EC09183-F41B-4785-83E7-423BB86EB013\u0026#34; targetRef=\u0026#34;sid-9CD52D35-7874-42F4-B392-466F71316BFE\u0026#34;\u0026gt;\u0026lt;/sequenceFlow\u0026gt; \u0026lt;sequenceFlow id=\u0026#34;sid-E4DB6764-3EA3-427B-AD00-4D812E404FD6\u0026#34; sourceRef=\u0026#34;sid-35DD948A-C095-486E-98E0-4A0EEC4D9FBC\u0026#34; targetRef=\u0026#34;sid-4B6496FE-B5FE-41AC-83F8-4B7224B09FBD\u0026#34;\u0026gt; \u0026lt;conditionExpression xsi:type=\u0026#34;tFormalExpression\u0026#34;\u0026gt;\u0026lt;![CDATA[${day \u0026gt; 3}]]\u0026gt;\u0026lt;/conditionExpression\u0026gt; \u0026lt;/sequenceFlow\u0026gt; \u0026lt;sequenceFlow id=\u0026#34;sid-585C37CB-61FE-4518-B3B6-5722A90A854F\u0026#34; sourceRef=\u0026#34;sid-35DD948A-C095-486E-98E0-4A0EEC4D9FBC\u0026#34; targetRef=\u0026#34;sid-8DE5EA05-89D5-48B0-9359-F8ABFB3A3500\u0026#34;\u0026gt; \u0026lt;conditionExpression xsi:type=\u0026#34;tFormalExpression\u0026#34;\u0026gt;\u0026lt;![CDATA[${day \u0026lt;= 3}]]\u0026gt;\u0026lt;/conditionExpression\u0026gt; \u0026lt;/sequenceFlow\u0026gt; \u0026lt;/process\u0026gt; \u0026lt;bpmndi:BPMNDiagram id=\u0026#34;BPMNDiagram_form1-test-key\u0026#34;\u0026gt; \u0026lt;bpmndi:BPMNPlane bpmnElement=\u0026#34;form1-test-key\u0026#34; id=\u0026#34;BPMNPlane_form1-test-key\u0026#34;\u0026gt; \u0026lt;bpmndi:BPMNShape bpmnElement=\u0026#34;startEvent1\u0026#34; id=\u0026#34;BPMNShape_startEvent1\u0026#34;\u0026gt; \u0026lt;omgdc:Bounds height=\u0026#34;30.0\u0026#34; width=\u0026#34;30.0\u0026#34; x=\u0026#34;100.0\u0026#34; y=\u0026#34;163.0\u0026#34;\u0026gt;\u0026lt;/omgdc:Bounds\u0026gt; \u0026lt;/bpmndi:BPMNShape\u0026gt; \u0026lt;bpmndi:BPMNShape bpmnElement=\u0026#34;sid-4C9C8571-1423-4137-93FC-6A138D504E24\u0026#34; id=\u0026#34;BPMNShape_sid-4C9C8571-1423-4137-93FC-6A138D504E24\u0026#34;\u0026gt; \u0026lt;omgdc:Bounds height=\u0026#34;80.0\u0026#34; width=\u0026#34;100.0\u0026#34; x=\u0026#34;175.0\u0026#34; y=\u0026#34;138.0\u0026#34;\u0026gt;\u0026lt;/omgdc:Bounds\u0026gt; \u0026lt;/bpmndi:BPMNShape\u0026gt; \u0026lt;bpmndi:BPMNShape bpmnElement=\u0026#34;sid-35DD948A-C095-486E-98E0-4A0EEC4D9FBC\u0026#34; id=\u0026#34;BPMNShape_sid-35DD948A-C095-486E-98E0-4A0EEC4D9FBC\u0026#34;\u0026gt; \u0026lt;omgdc:Bounds height=\u0026#34;40.0\u0026#34; width=\u0026#34;40.0\u0026#34; x=\u0026#34;315.0\u0026#34; y=\u0026#34;150.0\u0026#34;\u0026gt;\u0026lt;/omgdc:Bounds\u0026gt; \u0026lt;/bpmndi:BPMNShape\u0026gt; \u0026lt;bpmndi:BPMNShape bpmnElement=\u0026#34;sid-4B6496FE-B5FE-41AC-83F8-4B7224B09FBD\u0026#34; id=\u0026#34;BPMNShape_sid-4B6496FE-B5FE-41AC-83F8-4B7224B09FBD\u0026#34;\u0026gt; \u0026lt;omgdc:Bounds height=\u0026#34;80.0\u0026#34; width=\u0026#34;100.0\u0026#34; x=\u0026#34;405.0\u0026#34; y=\u0026#34;30.0\u0026#34;\u0026gt;\u0026lt;/omgdc:Bounds\u0026gt; \u0026lt;/bpmndi:BPMNShape\u0026gt; \u0026lt;bpmndi:BPMNShape bpmnElement=\u0026#34;sid-8DE5EA05-89D5-48B0-9359-F8ABFB3A3500\u0026#34; id=\u0026#34;BPMNShape_sid-8DE5EA05-89D5-48B0-9359-F8ABFB3A3500\u0026#34;\u0026gt; \u0026lt;omgdc:Bounds height=\u0026#34;80.0\u0026#34; width=\u0026#34;100.0\u0026#34; x=\u0026#34;405.0\u0026#34; y=\u0026#34;225.0\u0026#34;\u0026gt;\u0026lt;/omgdc:Bounds\u0026gt; \u0026lt;/bpmndi:BPMNShape\u0026gt; \u0026lt;bpmndi:BPMNShape bpmnElement=\u0026#34;sid-0EC09183-F41B-4785-83E7-423BB86EB013\u0026#34; id=\u0026#34;BPMNShape_sid-0EC09183-F41B-4785-83E7-423BB86EB013\u0026#34;\u0026gt; \u0026lt;omgdc:Bounds height=\u0026#34;40.0\u0026#34; width=\u0026#34;40.0\u0026#34; x=\u0026#34;585.0\u0026#34; y=\u0026#34;165.0\u0026#34;\u0026gt;\u0026lt;/omgdc:Bounds\u0026gt; \u0026lt;/bpmndi:BPMNShape\u0026gt; \u0026lt;bpmndi:BPMNShape bpmnElement=\u0026#34;sid-9CD52D35-7874-42F4-B392-466F71316BFE\u0026#34; id=\u0026#34;BPMNShape_sid-9CD52D35-7874-42F4-B392-466F71316BFE\u0026#34;\u0026gt; \u0026lt;omgdc:Bounds height=\u0026#34;28.0\u0026#34; width=\u0026#34;28.0\u0026#34; x=\u0026#34;670.0\u0026#34; y=\u0026#34;171.0\u0026#34;\u0026gt;\u0026lt;/omgdc:Bounds\u0026gt; \u0026lt;/bpmndi:BPMNShape\u0026gt; \u0026lt;bpmndi:BPMNEdge bpmnElement=\u0026#34;sid-585C37CB-61FE-4518-B3B6-5722A90A854F\u0026#34; id=\u0026#34;BPMNEdge_sid-585C37CB-61FE-4518-B3B6-5722A90A854F\u0026#34; flowable:sourceDockerX=\u0026#34;20.5\u0026#34; flowable:sourceDockerY=\u0026#34;20.5\u0026#34; flowable:targetDockerX=\u0026#34;50.0\u0026#34; flowable:targetDockerY=\u0026#34;40.0\u0026#34;\u0026gt; \u0026lt;omgdi:waypoint x=\u0026#34;335.5\u0026#34; y=\u0026#34;189.43998414376327\u0026#34;\u0026gt;\u0026lt;/omgdi:waypoint\u0026gt; \u0026lt;omgdi:waypoint x=\u0026#34;335.5\u0026#34; y=\u0026#34;265.0\u0026#34;\u0026gt;\u0026lt;/omgdi:waypoint\u0026gt; \u0026lt;omgdi:waypoint x=\u0026#34;405.0\u0026#34; y=\u0026#34;265.0\u0026#34;\u0026gt;\u0026lt;/omgdi:waypoint\u0026gt; \u0026lt;/bpmndi:BPMNEdge\u0026gt; \u0026lt;bpmndi:BPMNEdge bpmnElement=\u0026#34;sid-E4DB6764-3EA3-427B-AD00-4D812E404FD6\u0026#34; id=\u0026#34;BPMNEdge_sid-E4DB6764-3EA3-427B-AD00-4D812E404FD6\u0026#34; flowable:sourceDockerX=\u0026#34;20.5\u0026#34; flowable:sourceDockerY=\u0026#34;20.5\u0026#34; flowable:targetDockerX=\u0026#34;50.0\u0026#34; flowable:targetDockerY=\u0026#34;40.0\u0026#34;\u0026gt; \u0026lt;omgdi:waypoint x=\u0026#34;336.66824324324324\u0026#34; y=\u0026#34;151.67117117117118\u0026#34;\u0026gt;\u0026lt;/omgdi:waypoint\u0026gt; \u0026lt;omgdi:waypoint x=\u0026#34;342.0\u0026#34; y=\u0026#34;66.0\u0026#34;\u0026gt;\u0026lt;/omgdi:waypoint\u0026gt; \u0026lt;omgdi:waypoint x=\u0026#34;404.9999999999999\u0026#34; y=\u0026#34;68.23008849557522\u0026#34;\u0026gt;\u0026lt;/omgdi:waypoint\u0026gt; \u0026lt;/bpmndi:BPMNEdge\u0026gt; \u0026lt;bpmndi:BPMNEdge bpmnElement=\u0026#34;sid-0EA36B83-6115-414F-BC7D-9CB338B03F22\u0026#34; id=\u0026#34;BPMNEdge_sid-0EA36B83-6115-414F-BC7D-9CB338B03F22\u0026#34; flowable:sourceDockerX=\u0026#34;50.0\u0026#34; flowable:sourceDockerY=\u0026#34;40.0\u0026#34; flowable:targetDockerX=\u0026#34;20.5\u0026#34; flowable:targetDockerY=\u0026#34;20.5\u0026#34;\u0026gt; \u0026lt;omgdi:waypoint x=\u0026#34;274.95000000000005\u0026#34; y=\u0026#34;174.60633484162895\u0026#34;\u0026gt;\u0026lt;/omgdi:waypoint\u0026gt; \u0026lt;omgdi:waypoint x=\u0026#34;316.77118644067775\u0026#34; y=\u0026#34;171.76800847457628\u0026#34;\u0026gt;\u0026lt;/omgdi:waypoint\u0026gt; \u0026lt;/bpmndi:BPMNEdge\u0026gt; \u0026lt;bpmndi:BPMNEdge bpmnElement=\u0026#34;sid-8944FE04-D27B-435F-A8A8-4E545AB3D6C0\u0026#34; id=\u0026#34;BPMNEdge_sid-8944FE04-D27B-435F-A8A8-4E545AB3D6C0\u0026#34; flowable:sourceDockerX=\u0026#34;15.0\u0026#34; flowable:sourceDockerY=\u0026#34;15.0\u0026#34; flowable:targetDockerX=\u0026#34;50.0\u0026#34; flowable:targetDockerY=\u0026#34;40.0\u0026#34;\u0026gt; \u0026lt;omgdi:waypoint x=\u0026#34;129.9499984899576\u0026#34; y=\u0026#34;178.0\u0026#34;\u0026gt;\u0026lt;/omgdi:waypoint\u0026gt; \u0026lt;omgdi:waypoint x=\u0026#34;174.9999999999917\u0026#34; y=\u0026#34;178.0\u0026#34;\u0026gt;\u0026lt;/omgdi:waypoint\u0026gt; \u0026lt;/bpmndi:BPMNEdge\u0026gt; \u0026lt;bpmndi:BPMNEdge bpmnElement=\u0026#34;sid-FABB64D1-0182-41D8-90FE-53FE7FE3F024\u0026#34; id=\u0026#34;BPMNEdge_sid-FABB64D1-0182-41D8-90FE-53FE7FE3F024\u0026#34; flowable:sourceDockerX=\u0026#34;20.5\u0026#34; flowable:sourceDockerY=\u0026#34;20.5\u0026#34; flowable:targetDockerX=\u0026#34;14.0\u0026#34; flowable:targetDockerY=\u0026#34;14.0\u0026#34;\u0026gt; \u0026lt;omgdi:waypoint x=\u0026#34;624.5591869398207\u0026#34; y=\u0026#34;185.37820512820514\u0026#34;\u0026gt;\u0026lt;/omgdi:waypoint\u0026gt; \u0026lt;omgdi:waypoint x=\u0026#34;670.0002755524882\u0026#34; y=\u0026#34;185.08885188426405\u0026#34;\u0026gt;\u0026lt;/omgdi:waypoint\u0026gt; \u0026lt;/bpmndi:BPMNEdge\u0026gt; \u0026lt;bpmndi:BPMNEdge bpmnElement=\u0026#34;sid-9AC3E009-D4D6-4D8B-883C-701E044715E9\u0026#34; id=\u0026#34;BPMNEdge_sid-9AC3E009-D4D6-4D8B-883C-701E044715E9\u0026#34; flowable:sourceDockerX=\u0026#34;50.0\u0026#34; flowable:sourceDockerY=\u0026#34;40.0\u0026#34; flowable:targetDockerX=\u0026#34;20.0\u0026#34; flowable:targetDockerY=\u0026#34;20.0\u0026#34;\u0026gt; \u0026lt;omgdi:waypoint x=\u0026#34;504.95000000000005\u0026#34; y=\u0026#34;238.33333333333334\u0026#34;\u0026gt;\u0026lt;/omgdi:waypoint\u0026gt; \u0026lt;omgdi:waypoint x=\u0026#34;591.9565217391304\u0026#34; y=\u0026#34;191.93913043478258\u0026#34;\u0026gt;\u0026lt;/omgdi:waypoint\u0026gt; \u0026lt;/bpmndi:BPMNEdge\u0026gt; \u0026lt;bpmndi:BPMNEdge bpmnElement=\u0026#34;sid-562C26B5-B634-4771-BF54-C311D56A5317\u0026#34; id=\u0026#34;BPMNEdge_sid-562C26B5-B634-4771-BF54-C311D56A5317\u0026#34; flowable:sourceDockerX=\u0026#34;50.0\u0026#34; flowable:sourceDockerY=\u0026#34;40.0\u0026#34; flowable:targetDockerX=\u0026#34;20.5\u0026#34; flowable:targetDockerY=\u0026#34;20.5\u0026#34;\u0026gt; \u0026lt;omgdi:waypoint x=\u0026#34;504.95000000000005\u0026#34; y=\u0026#34;70.0\u0026#34;\u0026gt;\u0026lt;/omgdi:waypoint\u0026gt; \u0026lt;omgdi:waypoint x=\u0026#34;605.5\u0026#34; y=\u0026#34;70.0\u0026#34;\u0026gt;\u0026lt;/omgdi:waypoint\u0026gt; \u0026lt;omgdi:waypoint x=\u0026#34;605.5\u0026#34; y=\u0026#34;165.5\u0026#34;\u0026gt;\u0026lt;/omgdi:waypoint\u0026gt; \u0026lt;/bpmndi:BPMNEdge\u0026gt; \u0026lt;/bpmndi:BPMNPlane\u0026gt; \u0026lt;/bpmndi:BPMNDiagram\u0026gt; \u0026lt;/definitions\u0026gt; 将流程定义部署\n@Test public void deploy() { ProcessEngine engine = ProcessEngines.getDefaultProcessEngine(); RepositoryService repositoryService = engine.getRepositoryService(); Deployment deploy = repositoryService.createDeployment() .addClasspathResource(\u0026#34;form1-test-name.bpmn20.xml\u0026#34;) .deploy(); System.out.println(\u0026#34;部署成功:\u0026#34; + deploy.getId()); } 查看部署的流程内置的表单\n@Test public void getStartForm(){ ProcessEngine engine=ProcessEngines.getDefaultProcessEngine(); FormService formService = engine.getFormService(); StartFormData startFormData = formService.getStartFormData(\u0026#34;form1-test-key:1:17504\u0026#34;); List\u0026lt;FormProperty\u0026gt; formProperties = startFormData.getFormProperties(); for (FormProperty property:formProperties){ System.out.println(\u0026#34;id==\u0026gt;\u0026#34;+property.getId()); System.out.println(\u0026#34;name==\u0026gt;\u0026#34;+property.getName()); System.out.println(\u0026#34;value==\u0026gt;\u0026#34;+property.getValue()); } } 第一种启动方式,通过map 第二种启动方式\n@Test public void startProcess2(){ ProcessEngine engine=ProcessEngines.getDefaultProcessEngine(); FormService formService = engine.getFormService(); Map\u0026lt;String,String\u0026gt; map=new HashMap\u0026lt;\u0026gt;(); map.put(\u0026#34;days\u0026#34;,\u0026#34;2\u0026#34;); map.put(\u0026#34;startTime\u0026#34;,\u0026#34;22020405\u0026#34;); map.put(\u0026#34;reason\u0026#34;,\u0026#34;想玩\u0026#34;); formService.submitStartFormData(\u0026#34;form1-test-key:1:17504\u0026#34;,map); } 注意查看act_ru_variable变量表 查看任务中的表单数据\n/** * 查看对应的表单数据 */ @Test public void getTaskFormData(){ ProcessEngine engine=ProcessEngines.getDefaultProcessEngine(); FormService formService = engine.getFormService(); TaskFormData taskFormData = formService.getTaskFormData(\u0026#34;20012\u0026#34;); List\u0026lt;FormProperty\u0026gt; formProperties = taskFormData.getFormProperties(); for (FormProperty property:formProperties){ System.out.println(\u0026#34;id==\u0026gt;\u0026#34;+property.getId()); System.out.println(\u0026#34;name==\u0026gt;\u0026#34;+property.getName()); System.out.println(\u0026#34;value==\u0026gt;\u0026#34;+property.getValue()); } //这里做一个测试,设置处理人 /*TaskService taskService = engine.getTaskService(); taskService.setAssignee(\u0026#34;20012\u0026#34;,\u0026#34;lalala\u0026#34;);*/ } 查看完成的任务【主要】//有点问题,不管 外置表单 # [flowable-ui中没找到,不知道是不是eclipse独有的]\n"},{"id":387,"href":"/zh/docs/technology/Flowable/boge_blbl_/02-advance_5/","title":"boge-02-flowable进阶_5","section":"基础(波哥)_","content":" 网关 # 排他网关 # 会按照所有出口顺序流定义的顺序对它们进行计算,选择第一个条件计算为true的顺序流(当没有设置条件时,认为顺序流为true)继续流程\n排他网关的绘制 xml文件\n\u0026lt;?xml version=\u0026#34;1.0\u0026#34; encoding=\u0026#34;UTF-8\u0026#34;?\u0026gt; \u0026lt;definitions xmlns=\u0026#34;http://www.omg.org/spec/BPMN/20100524/MODEL\u0026#34; xmlns:xsi=\u0026#34;http://www.w3.org/2001/XMLSchema-instance\u0026#34; xmlns:xsd=\u0026#34;http://www.w3.org/2001/XMLSchema\u0026#34; xmlns:flowable=\u0026#34;http://flowable.org/bpmn\u0026#34; xmlns:bpmndi=\u0026#34;http://www.omg.org/spec/BPMN/20100524/DI\u0026#34; xmlns:omgdc=\u0026#34;http://www.omg.org/spec/DD/20100524/DC\u0026#34; xmlns:omgdi=\u0026#34;http://www.omg.org/spec/DD/20100524/DI\u0026#34; typeLanguage=\u0026#34;http://www.w3.org/2001/XMLSchema\u0026#34; expressionLanguage=\u0026#34;http://www.w3.org/1999/XPath\u0026#34; targetNamespace=\u0026#34;http://www.flowable.org/processdef\u0026#34; exporter=\u0026#34;Flowable Open Source Modeler\u0026#34; exporterVersion=\u0026#34;6.7.2\u0026#34;\u0026gt; \u0026lt;process id=\u0026#34;holiday-exclusive\u0026#34; name=\u0026#34;请假流程-排他网关\u0026#34; isExecutable=\u0026#34;true\u0026#34;\u0026gt; \u0026lt;startEvent id=\u0026#34;startEvent1\u0026#34; flowable:formFieldValidation=\u0026#34;true\u0026#34;\u0026gt;\u0026lt;/startEvent\u0026gt; \u0026lt;userTask id=\u0026#34;sid-3D5ED4D4-97F5-4FFD-B160-F00566ECC55E\u0026#34; name=\u0026#34;创建请假单\u0026#34; flowable:assignee=\u0026#34;zhangsan\u0026#34; flowable:formFieldValidation=\u0026#34;true\u0026#34;\u0026gt; \u0026lt;extensionElements\u0026gt; \u0026lt;modeler:initiator-can-complete xmlns:modeler=\u0026#34;http://flowable.org/modeler\u0026#34;\u0026gt;\u0026lt;![CDATA[false]]\u0026gt;\u0026lt;/modeler:initiator-can-complete\u0026gt; \u0026lt;/extensionElements\u0026gt; \u0026lt;/userTask\u0026gt; \u0026lt;sequenceFlow id=\u0026#34;sid-33A73370-751D-413F-9306-39DEAA674DB6\u0026#34; sourceRef=\u0026#34;startEvent1\u0026#34; targetRef=\u0026#34;sid-3D5ED4D4-97F5-4FFD-B160-F00566ECC55E\u0026#34;\u0026gt;\u0026lt;/sequenceFlow\u0026gt; \u0026lt;exclusiveGateway id=\u0026#34;sid-5B2117E6-D341-49F2-85B2-336CA836C7D8\u0026#34;\u0026gt;\u0026lt;/exclusiveGateway\u0026gt; \u0026lt;sequenceFlow id=\u0026#34;sid-D1B1F6E0-EA7F-4FF7-AD0C-5D43DBCEBFD2\u0026#34; sourceRef=\u0026#34;sid-3D5ED4D4-97F5-4FFD-B160-F00566ECC55E\u0026#34; targetRef=\u0026#34;sid-5B2117E6-D341-49F2-85B2-336CA836C7D8\u0026#34;\u0026gt;\u0026lt;/sequenceFlow\u0026gt; \u0026lt;userTask id=\u0026#34;sid-08A6CB64-C9BB-4342-852D-444A75315BDE\u0026#34; name=\u0026#34;总经理审批\u0026#34; flowable:assignee=\u0026#34;wangwu\u0026#34; flowable:formFieldValidation=\u0026#34;true\u0026#34;\u0026gt; \u0026lt;extensionElements\u0026gt; \u0026lt;modeler:initiator-can-complete xmlns:modeler=\u0026#34;http://flowable.org/modeler\u0026#34;\u0026gt;\u0026lt;![CDATA[false]]\u0026gt;\u0026lt;/modeler:initiator-can-complete\u0026gt; \u0026lt;/extensionElements\u0026gt; \u0026lt;/userTask\u0026gt; \u0026lt;userTask id=\u0026#34;sid-EA98D0C3-E41D-4DEB-8933-91A1B7301ABE\u0026#34; name=\u0026#34;部门经理审批\u0026#34; flowable:assignee=\u0026#34;lisi\u0026#34; flowable:formFieldValidation=\u0026#34;true\u0026#34;\u0026gt; \u0026lt;extensionElements\u0026gt; \u0026lt;modeler:initiator-can-complete xmlns:modeler=\u0026#34;http://flowable.org/modeler\u0026#34;\u0026gt;\u0026lt;![CDATA[false]]\u0026gt;\u0026lt;/modeler:initiator-can-complete\u0026gt; \u0026lt;/extensionElements\u0026gt; \u0026lt;/userTask\u0026gt; \u0026lt;userTask id=\u0026#34;sid-24F73F7F-EB61-484F-A494-686E194D0118\u0026#34; name=\u0026#34;人事审批\u0026#34; flowable:assignee=\u0026#34;zhaoliu\u0026#34; flowable:formFieldValidation=\u0026#34;true\u0026#34;\u0026gt; \u0026lt;extensionElements\u0026gt; \u0026lt;modeler:initiator-can-complete xmlns:modeler=\u0026#34;http://flowable.org/modeler\u0026#34;\u0026gt;\u0026lt;![CDATA[false]]\u0026gt;\u0026lt;/modeler:initiator-can-complete\u0026gt; \u0026lt;/extensionElements\u0026gt; \u0026lt;/userTask\u0026gt; \u0026lt;sequenceFlow id=\u0026#34;sid-8BA0B88C-BA4F-446D-B5E7-6BF0830B1DC8\u0026#34; sourceRef=\u0026#34;sid-EA98D0C3-E41D-4DEB-8933-91A1B7301ABE\u0026#34; targetRef=\u0026#34;sid-24F73F7F-EB61-484F-A494-686E194D0118\u0026#34;\u0026gt;\u0026lt;/sequenceFlow\u0026gt; \u0026lt;sequenceFlow id=\u0026#34;sid-E748F81F-B0B2-4C34-B993-FBAA2BCD0995\u0026#34; sourceRef=\u0026#34;sid-08A6CB64-C9BB-4342-852D-444A75315BDE\u0026#34; targetRef=\u0026#34;sid-24F73F7F-EB61-484F-A494-686E194D0118\u0026#34;\u0026gt;\u0026lt;/sequenceFlow\u0026gt; \u0026lt;sequenceFlow id=\u0026#34;sid-928C6C6F-57F1-40F2-BE0F-1A9FF3E6E9E4\u0026#34; sourceRef=\u0026#34;sid-5B2117E6-D341-49F2-85B2-336CA836C7D8\u0026#34; targetRef=\u0026#34;sid-08A6CB64-C9BB-4342-852D-444A75315BDE\u0026#34;\u0026gt; \u0026lt;conditionExpression xsi:type=\u0026#34;tFormalExpression\u0026#34;\u0026gt;\u0026lt;![CDATA[${num\u0026gt;3}]]\u0026gt;\u0026lt;/conditionExpression\u0026gt; \u0026lt;/sequenceFlow\u0026gt; \u0026lt;sequenceFlow id=\u0026#34;sid-4DB25720-11C8-401E-BB4C-83BB25510B2E\u0026#34; sourceRef=\u0026#34;sid-5B2117E6-D341-49F2-85B2-336CA836C7D8\u0026#34; targetRef=\u0026#34;sid-EA98D0C3-E41D-4DEB-8933-91A1B7301ABE\u0026#34;\u0026gt; \u0026lt;conditionExpression xsi:type=\u0026#34;tFormalExpression\u0026#34;\u0026gt;\u0026lt;![CDATA[${num\u0026lt;3}]]\u0026gt;\u0026lt;/conditionExpression\u0026gt; \u0026lt;/sequenceFlow\u0026gt; \u0026lt;/process\u0026gt; \u0026lt;bpmndi:BPMNDiagram id=\u0026#34;BPMNDiagram_holiday-exclusive\u0026#34;\u0026gt; \u0026lt;bpmndi:BPMNPlane bpmnElement=\u0026#34;holiday-exclusive\u0026#34; id=\u0026#34;BPMNPlane_holiday-exclusive\u0026#34;\u0026gt; \u0026lt;bpmndi:BPMNShape bpmnElement=\u0026#34;startEvent1\u0026#34; id=\u0026#34;BPMNShape_startEvent1\u0026#34;\u0026gt; \u0026lt;omgdc:Bounds height=\u0026#34;30.0\u0026#34; width=\u0026#34;30.0\u0026#34; x=\u0026#34;30.0\u0026#34; y=\u0026#34;163.0\u0026#34;\u0026gt;\u0026lt;/omgdc:Bounds\u0026gt; \u0026lt;/bpmndi:BPMNShape\u0026gt; \u0026lt;bpmndi:BPMNShape bpmnElement=\u0026#34;sid-3D5ED4D4-97F5-4FFD-B160-F00566ECC55E\u0026#34; id=\u0026#34;BPMNShape_sid-3D5ED4D4-97F5-4FFD-B160-F00566ECC55E\u0026#34;\u0026gt; \u0026lt;omgdc:Bounds height=\u0026#34;80.0\u0026#34; width=\u0026#34;100.0\u0026#34; x=\u0026#34;150.0\u0026#34; y=\u0026#34;135.0\u0026#34;\u0026gt;\u0026lt;/omgdc:Bounds\u0026gt; \u0026lt;/bpmndi:BPMNShape\u0026gt; \u0026lt;bpmndi:BPMNShape bpmnElement=\u0026#34;sid-5B2117E6-D341-49F2-85B2-336CA836C7D8\u0026#34; id=\u0026#34;BPMNShape_sid-5B2117E6-D341-49F2-85B2-336CA836C7D8\u0026#34;\u0026gt; \u0026lt;omgdc:Bounds height=\u0026#34;40.0\u0026#34; width=\u0026#34;40.0\u0026#34; x=\u0026#34;315.0\u0026#34; y=\u0026#34;155.0\u0026#34;\u0026gt;\u0026lt;/omgdc:Bounds\u0026gt; \u0026lt;/bpmndi:BPMNShape\u0026gt; \u0026lt;bpmndi:BPMNShape bpmnElement=\u0026#34;sid-08A6CB64-C9BB-4342-852D-444A75315BDE\u0026#34; id=\u0026#34;BPMNShape_sid-08A6CB64-C9BB-4342-852D-444A75315BDE\u0026#34;\u0026gt; \u0026lt;omgdc:Bounds height=\u0026#34;80.0\u0026#34; width=\u0026#34;100.0\u0026#34; x=\u0026#34;420.0\u0026#34; y=\u0026#34;225.0\u0026#34;\u0026gt;\u0026lt;/omgdc:Bounds\u0026gt; \u0026lt;/bpmndi:BPMNShape\u0026gt; \u0026lt;bpmndi:BPMNShape bpmnElement=\u0026#34;sid-EA98D0C3-E41D-4DEB-8933-91A1B7301ABE\u0026#34; id=\u0026#34;BPMNShape_sid-EA98D0C3-E41D-4DEB-8933-91A1B7301ABE\u0026#34;\u0026gt; \u0026lt;omgdc:Bounds height=\u0026#34;80.0\u0026#34; width=\u0026#34;100.0\u0026#34; x=\u0026#34;405.0\u0026#34; y=\u0026#34;30.0\u0026#34;\u0026gt;\u0026lt;/omgdc:Bounds\u0026gt; \u0026lt;/bpmndi:BPMNShape\u0026gt; \u0026lt;bpmndi:BPMNShape bpmnElement=\u0026#34;sid-24F73F7F-EB61-484F-A494-686E194D0118\u0026#34; id=\u0026#34;BPMNShape_sid-24F73F7F-EB61-484F-A494-686E194D0118\u0026#34;\u0026gt; \u0026lt;omgdc:Bounds height=\u0026#34;80.0\u0026#34; width=\u0026#34;100.0\u0026#34; x=\u0026#34;630.0\u0026#34; y=\u0026#34;225.0\u0026#34;\u0026gt;\u0026lt;/omgdc:Bounds\u0026gt; \u0026lt;/bpmndi:BPMNShape\u0026gt; \u0026lt;bpmndi:BPMNEdge bpmnElement=\u0026#34;sid-8BA0B88C-BA4F-446D-B5E7-6BF0830B1DC8\u0026#34; id=\u0026#34;BPMNEdge_sid-8BA0B88C-BA4F-446D-B5E7-6BF0830B1DC8\u0026#34; flowable:sourceDockerX=\u0026#34;50.0\u0026#34; flowable:sourceDockerY=\u0026#34;40.0\u0026#34; flowable:targetDockerX=\u0026#34;50.0\u0026#34; flowable:targetDockerY=\u0026#34;40.0\u0026#34;\u0026gt; \u0026lt;omgdi:waypoint x=\u0026#34;504.95000000000005\u0026#34; y=\u0026#34;70.0\u0026#34;\u0026gt;\u0026lt;/omgdi:waypoint\u0026gt; \u0026lt;omgdi:waypoint x=\u0026#34;680.0\u0026#34; y=\u0026#34;70.0\u0026#34;\u0026gt;\u0026lt;/omgdi:waypoint\u0026gt; \u0026lt;omgdi:waypoint x=\u0026#34;680.0\u0026#34; y=\u0026#34;225.0\u0026#34;\u0026gt;\u0026lt;/omgdi:waypoint\u0026gt; \u0026lt;/bpmndi:BPMNEdge\u0026gt; \u0026lt;bpmndi:BPMNEdge bpmnElement=\u0026#34;sid-4DB25720-11C8-401E-BB4C-83BB25510B2E\u0026#34; id=\u0026#34;BPMNEdge_sid-4DB25720-11C8-401E-BB4C-83BB25510B2E\u0026#34; flowable:sourceDockerX=\u0026#34;20.5\u0026#34; flowable:sourceDockerY=\u0026#34;20.5\u0026#34; flowable:targetDockerX=\u0026#34;50.0\u0026#34; flowable:targetDockerY=\u0026#34;40.0\u0026#34;\u0026gt; \u0026lt;omgdi:waypoint x=\u0026#34;335.5\u0026#34; y=\u0026#34;155.5\u0026#34;\u0026gt;\u0026lt;/omgdi:waypoint\u0026gt; \u0026lt;omgdi:waypoint x=\u0026#34;335.5\u0026#34; y=\u0026#34;70.0\u0026#34;\u0026gt;\u0026lt;/omgdi:waypoint\u0026gt; \u0026lt;omgdi:waypoint x=\u0026#34;404.99999999996083\u0026#34; y=\u0026#34;70.0\u0026#34;\u0026gt;\u0026lt;/omgdi:waypoint\u0026gt; \u0026lt;/bpmndi:BPMNEdge\u0026gt; \u0026lt;bpmndi:BPMNEdge bpmnElement=\u0026#34;sid-33A73370-751D-413F-9306-39DEAA674DB6\u0026#34; id=\u0026#34;BPMNEdge_sid-33A73370-751D-413F-9306-39DEAA674DB6\u0026#34; flowable:sourceDockerX=\u0026#34;15.0\u0026#34; flowable:sourceDockerY=\u0026#34;15.0\u0026#34; flowable:targetDockerX=\u0026#34;50.0\u0026#34; flowable:targetDockerY=\u0026#34;40.0\u0026#34;\u0026gt; \u0026lt;omgdi:waypoint x=\u0026#34;59.94725673598754\u0026#34; y=\u0026#34;177.70973069236373\u0026#34;\u0026gt;\u0026lt;/omgdi:waypoint\u0026gt; \u0026lt;omgdi:waypoint x=\u0026#34;150.0\u0026#34; y=\u0026#34;175.96677419354836\u0026#34;\u0026gt;\u0026lt;/omgdi:waypoint\u0026gt; \u0026lt;/bpmndi:BPMNEdge\u0026gt; \u0026lt;bpmndi:BPMNEdge bpmnElement=\u0026#34;sid-D1B1F6E0-EA7F-4FF7-AD0C-5D43DBCEBFD2\u0026#34; id=\u0026#34;BPMNEdge_sid-D1B1F6E0-EA7F-4FF7-AD0C-5D43DBCEBFD2\u0026#34; flowable:sourceDockerX=\u0026#34;50.0\u0026#34; flowable:sourceDockerY=\u0026#34;40.0\u0026#34; flowable:targetDockerX=\u0026#34;20.5\u0026#34; flowable:targetDockerY=\u0026#34;20.5\u0026#34;\u0026gt; \u0026lt;omgdi:waypoint x=\u0026#34;249.95000000000002\u0026#34; y=\u0026#34;175.18431734317343\u0026#34;\u0026gt;\u0026lt;/omgdi:waypoint\u0026gt; \u0026lt;omgdi:waypoint x=\u0026#34;315.42592592592536\u0026#34; y=\u0026#34;175.42592592592592\u0026#34;\u0026gt;\u0026lt;/omgdi:waypoint\u0026gt; \u0026lt;/bpmndi:BPMNEdge\u0026gt; \u0026lt;bpmndi:BPMNEdge bpmnElement=\u0026#34;sid-E748F81F-B0B2-4C34-B993-FBAA2BCD0995\u0026#34; id=\u0026#34;BPMNEdge_sid-E748F81F-B0B2-4C34-B993-FBAA2BCD0995\u0026#34; flowable:sourceDockerX=\u0026#34;50.0\u0026#34; flowable:sourceDockerY=\u0026#34;40.0\u0026#34; flowable:targetDockerX=\u0026#34;50.0\u0026#34; flowable:targetDockerY=\u0026#34;40.0\u0026#34;\u0026gt; \u0026lt;omgdi:waypoint x=\u0026#34;519.95\u0026#34; y=\u0026#34;265.0\u0026#34;\u0026gt;\u0026lt;/omgdi:waypoint\u0026gt; \u0026lt;omgdi:waypoint x=\u0026#34;629.9999999998776\u0026#34; y=\u0026#34;265.0\u0026#34;\u0026gt;\u0026lt;/omgdi:waypoint\u0026gt; \u0026lt;/bpmndi:BPMNEdge\u0026gt; \u0026lt;bpmndi:BPMNEdge bpmnElement=\u0026#34;sid-928C6C6F-57F1-40F2-BE0F-1A9FF3E6E9E4\u0026#34; id=\u0026#34;BPMNEdge_sid-928C6C6F-57F1-40F2-BE0F-1A9FF3E6E9E4\u0026#34; flowable:sourceDockerX=\u0026#34;20.5\u0026#34; flowable:sourceDockerY=\u0026#34;20.5\u0026#34; flowable:targetDockerX=\u0026#34;50.0\u0026#34; flowable:targetDockerY=\u0026#34;40.0\u0026#34;\u0026gt; \u0026lt;omgdi:waypoint x=\u0026#34;335.5\u0026#34; y=\u0026#34;194.43942522321433\u0026#34;\u0026gt;\u0026lt;/omgdi:waypoint\u0026gt; \u0026lt;omgdi:waypoint x=\u0026#34;335.5\u0026#34; y=\u0026#34;265.0\u0026#34;\u0026gt;\u0026lt;/omgdi:waypoint\u0026gt; \u0026lt;omgdi:waypoint x=\u0026#34;420.0\u0026#34; y=\u0026#34;265.0\u0026#34;\u0026gt;\u0026lt;/omgdi:waypoint\u0026gt; \u0026lt;/bpmndi:BPMNEdge\u0026gt; \u0026lt;/bpmndi:BPMNPlane\u0026gt; \u0026lt;/bpmndi:BPMNDiagram\u0026gt; \u0026lt;/definitions\u0026gt; 部署\n@Test public void deploy(){ ProcessEngine engine= ProcessEngines.getDefaultProcessEngine(); RepositoryService repositoryService = engine.getRepositoryService(); Deployment deploy = repositoryService.createDeployment() .addClasspathResource(\u0026#34;请假流程-排他网关.bpmn20.xml\u0026#34;) .deploy(); System.out.println(\u0026#34;部署成功:\u0026#34;+deploy); } 运行\n@Test public void run() { ProcessEngine engine = ProcessEngines.getDefaultProcessEngine(); RuntimeService runtimeService = engine.getRuntimeService(); Map\u0026lt;String, Object\u0026gt; variables = new HashMap\u0026lt;\u0026gt;(); variables.put(\u0026#34;num\u0026#34;, 2); runtimeService.startProcessInstanceById (\u0026#34;holiday-exclusive:1:4\u0026#34;, variables); } 数据库 张三完成任务\n@Test public void taskComplete(){ ProcessEngine engine=ProcessEngines.getDefaultProcessEngine(); TaskService taskService = engine.getTaskService(); Task task = taskService.createTaskQuery() .taskAssignee(\u0026#34;zhangsan\u0026#34;) .processInstanceId(\u0026#34;2501\u0026#34;) .singleResult(); taskService.complete(task.getId()); } //接下来会走到部门经理审批\n此时再ran一个num为4的实例,然后张三完成,此时会走到总经理审批\n注意,如果这里num设置为3,则会报错 两者区别 如果上面的分支都不满足条件,那么会直接异常结束 //如果使用排他网关,如果条件都不满足,流程和任务都还在,只是代码抛异常 //如果两个都满足,那么会找出先定义的线走\n并行网关 # 绘制流程图 xml文件\n\u0026lt;?xml version=\u0026#34;1.0\u0026#34; encoding=\u0026#34;UTF-8\u0026#34;?\u0026gt; \u0026lt;definitions xmlns=\u0026#34;http://www.omg.org/spec/BPMN/20100524/MODEL\u0026#34; xmlns:xsi=\u0026#34;http://www.w3.org/2001/XMLSchema-instance\u0026#34; xmlns:xsd=\u0026#34;http://www.w3.org/2001/XMLSchema\u0026#34; xmlns:flowable=\u0026#34;http://flowable.org/bpmn\u0026#34; xmlns:bpmndi=\u0026#34;http://www.omg.org/spec/BPMN/20100524/DI\u0026#34; xmlns:omgdc=\u0026#34;http://www.omg.org/spec/DD/20100524/DC\u0026#34; xmlns:omgdi=\u0026#34;http://www.omg.org/spec/DD/20100524/DI\u0026#34; typeLanguage=\u0026#34;http://www.w3.org/2001/XMLSchema\u0026#34; expressionLanguage=\u0026#34;http://www.w3.org/1999/XPath\u0026#34; targetNamespace=\u0026#34;http://www.flowable.org/processdef\u0026#34; exporter=\u0026#34;Flowable Open Source Modeler\u0026#34; exporterVersion=\u0026#34;6.7.2\u0026#34;\u0026gt; \u0026lt;process id=\u0026#34;holiday-parr-key\u0026#34; name=\u0026#34;请假流程-并行网关\u0026#34; isExecutable=\u0026#34;true\u0026#34;\u0026gt; \u0026lt;documentation\u0026gt;holiday-parr-descr\u0026lt;/documentation\u0026gt; \u0026lt;startEvent id=\u0026#34;startEvent1\u0026#34; flowable:formFieldValidation=\u0026#34;true\u0026#34;\u0026gt;\u0026lt;/startEvent\u0026gt; \u0026lt;userTask id=\u0026#34;sid-47EAD72A-932E-4850-9218-08A7335CEEDD\u0026#34; name=\u0026#34;创建请假单\u0026#34; flowable:assignee=\u0026#34;zhangsan\u0026#34; flowable:formFieldValidation=\u0026#34;true\u0026#34;\u0026gt; \u0026lt;extensionElements\u0026gt; \u0026lt;modeler:initiator-can-complete xmlns:modeler=\u0026#34;http://flowable.org/modeler\u0026#34;\u0026gt;\u0026lt;![CDATA[false]]\u0026gt;\u0026lt;/modeler:initiator-can-complete\u0026gt; \u0026lt;/extensionElements\u0026gt; \u0026lt;/userTask\u0026gt; \u0026lt;sequenceFlow id=\u0026#34;sid-8B72154F-6D29-47F8-A81C-A070F82B95F9\u0026#34; sourceRef=\u0026#34;startEvent1\u0026#34; targetRef=\u0026#34;sid-47EAD72A-932E-4850-9218-08A7335CEEDD\u0026#34;\u0026gt;\u0026lt;/sequenceFlow\u0026gt; \u0026lt;parallelGateway id=\u0026#34;sid-8B323A3D-F6DA-4D38-9CAE-D4CDA1031343\u0026#34;\u0026gt;\u0026lt;/parallelGateway\u0026gt; \u0026lt;sequenceFlow id=\u0026#34;sid-5F0BF3BD-BC7C-4AA0-AF87-F679C8EEB40B\u0026#34; sourceRef=\u0026#34;sid-47EAD72A-932E-4850-9218-08A7335CEEDD\u0026#34; targetRef=\u0026#34;sid-8B323A3D-F6DA-4D38-9CAE-D4CDA1031343\u0026#34;\u0026gt;\u0026lt;/sequenceFlow\u0026gt; \u0026lt;userTask id=\u0026#34;sid-AEFBD42F-2A10-4630-8E56-EDBD35CC95B1\u0026#34; name=\u0026#34;技术经理\u0026#34; flowable:assignee=\u0026#34;lisi\u0026#34; flowable:formFieldValidation=\u0026#34;true\u0026#34;\u0026gt; \u0026lt;extensionElements\u0026gt; \u0026lt;modeler:initiator-can-complete xmlns:modeler=\u0026#34;http://flowable.org/modeler\u0026#34;\u0026gt;\u0026lt;![CDATA[false]]\u0026gt;\u0026lt;/modeler:initiator-can-complete\u0026gt; \u0026lt;/extensionElements\u0026gt; \u0026lt;/userTask\u0026gt; \u0026lt;sequenceFlow id=\u0026#34;sid-49DBB929-7488-471A-B79C-6BBFF4C810E0\u0026#34; sourceRef=\u0026#34;sid-8B323A3D-F6DA-4D38-9CAE-D4CDA1031343\u0026#34; targetRef=\u0026#34;sid-AEFBD42F-2A10-4630-8E56-EDBD35CC95B1\u0026#34;\u0026gt;\u0026lt;/sequenceFlow\u0026gt; \u0026lt;userTask id=\u0026#34;sid-8FB84D20-C946-4988-B4C4-16FFD899AF63\u0026#34; name=\u0026#34;项目经理\u0026#34; flowable:assignee=\u0026#34;wangwu\u0026#34; flowable:formFieldValidation=\u0026#34;true\u0026#34;\u0026gt; \u0026lt;extensionElements\u0026gt; \u0026lt;modeler:initiator-can-complete xmlns:modeler=\u0026#34;http://flowable.org/modeler\u0026#34;\u0026gt;\u0026lt;![CDATA[false]]\u0026gt;\u0026lt;/modeler:initiator-can-complete\u0026gt; \u0026lt;/extensionElements\u0026gt; \u0026lt;/userTask\u0026gt; \u0026lt;sequenceFlow id=\u0026#34;sid-DCF940BC-05D4-4260-8C50-A4C6E291DEA3\u0026#34; sourceRef=\u0026#34;sid-8B323A3D-F6DA-4D38-9CAE-D4CDA1031343\u0026#34; targetRef=\u0026#34;sid-8FB84D20-C946-4988-B4C4-16FFD899AF63\u0026#34;\u0026gt;\u0026lt;/sequenceFlow\u0026gt; \u0026lt;parallelGateway id=\u0026#34;sid-B25B9926-873F-46F5-9D62-D155462C1665\u0026#34;\u0026gt;\u0026lt;/parallelGateway\u0026gt; \u0026lt;sequenceFlow id=\u0026#34;sid-18DF81F2-2B7F-4CC7-AD70-8A878FC7B125\u0026#34; sourceRef=\u0026#34;sid-AEFBD42F-2A10-4630-8E56-EDBD35CC95B1\u0026#34; targetRef=\u0026#34;sid-B25B9926-873F-46F5-9D62-D155462C1665\u0026#34;\u0026gt;\u0026lt;/sequenceFlow\u0026gt; \u0026lt;sequenceFlow id=\u0026#34;sid-B00C2DDD-8A30-4BA0-A2F8-69185D8506F5\u0026#34; sourceRef=\u0026#34;sid-8FB84D20-C946-4988-B4C4-16FFD899AF63\u0026#34; targetRef=\u0026#34;sid-B25B9926-873F-46F5-9D62-D155462C1665\u0026#34;\u0026gt;\u0026lt;/sequenceFlow\u0026gt; \u0026lt;userTask id=\u0026#34;sid-143837B7-0687-4268-B381-BA2442E39097\u0026#34; name=\u0026#34;总经理\u0026#34; flowable:assignee=\u0026#34;zjl\u0026#34; flowable:formFieldValidation=\u0026#34;true\u0026#34;\u0026gt; \u0026lt;extensionElements\u0026gt; \u0026lt;modeler:initiator-can-complete xmlns:modeler=\u0026#34;http://flowable.org/modeler\u0026#34;\u0026gt;\u0026lt;![CDATA[false]]\u0026gt;\u0026lt;/modeler:initiator-can-complete\u0026gt; \u0026lt;/extensionElements\u0026gt; \u0026lt;/userTask\u0026gt; \u0026lt;endEvent id=\u0026#34;sid-5ACFE3BE-E094-43A9-85C5-7D438EFE5A97\u0026#34;\u0026gt;\u0026lt;/endEvent\u0026gt; \u0026lt;sequenceFlow id=\u0026#34;sid-4255A9F7-39A1-46D3-AF14-DBEFF17AE911\u0026#34; sourceRef=\u0026#34;sid-143837B7-0687-4268-B381-BA2442E39097\u0026#34; targetRef=\u0026#34;sid-5ACFE3BE-E094-43A9-85C5-7D438EFE5A97\u0026#34;\u0026gt;\u0026lt;/sequenceFlow\u0026gt; \u0026lt;sequenceFlow id=\u0026#34;sid-2F49B59A-6860-4101-8156-84780094E6FE\u0026#34; sourceRef=\u0026#34;sid-B25B9926-873F-46F5-9D62-D155462C1665\u0026#34; targetRef=\u0026#34;sid-5ACFE3BE-E094-43A9-85C5-7D438EFE5A97\u0026#34;\u0026gt; \u0026lt;conditionExpression xsi:type=\u0026#34;tFormalExpression\u0026#34;\u0026gt;\u0026lt;![CDATA[${num \u0026lt;= 3}]]\u0026gt;\u0026lt;/conditionExpression\u0026gt; \u0026lt;/sequenceFlow\u0026gt; \u0026lt;sequenceFlow id=\u0026#34;sid-A5253FCB-3D23-483F-A511-197811F656D6\u0026#34; sourceRef=\u0026#34;sid-B25B9926-873F-46F5-9D62-D155462C1665\u0026#34; targetRef=\u0026#34;sid-143837B7-0687-4268-B381-BA2442E39097\u0026#34;\u0026gt; \u0026lt;conditionExpression xsi:type=\u0026#34;tFormalExpression\u0026#34;\u0026gt;\u0026lt;![CDATA[${num \u0026gt; 3}]]\u0026gt;\u0026lt;/conditionExpression\u0026gt; \u0026lt;/sequenceFlow\u0026gt; \u0026lt;/process\u0026gt; \u0026lt;bpmndi:BPMNDiagram id=\u0026#34;BPMNDiagram_holiday-parr-key\u0026#34;\u0026gt; \u0026lt;bpmndi:BPMNPlane bpmnElement=\u0026#34;holiday-parr-key\u0026#34; id=\u0026#34;BPMNPlane_holiday-parr-key\u0026#34;\u0026gt; \u0026lt;bpmndi:BPMNShape bpmnElement=\u0026#34;startEvent1\u0026#34; id=\u0026#34;BPMNShape_startEvent1\u0026#34;\u0026gt; \u0026lt;omgdc:Bounds height=\u0026#34;30.0\u0026#34; width=\u0026#34;30.0\u0026#34; x=\u0026#34;100.0\u0026#34; y=\u0026#34;163.0\u0026#34;\u0026gt;\u0026lt;/omgdc:Bounds\u0026gt; \u0026lt;/bpmndi:BPMNShape\u0026gt; \u0026lt;bpmndi:BPMNShape bpmnElement=\u0026#34;sid-47EAD72A-932E-4850-9218-08A7335CEEDD\u0026#34; id=\u0026#34;BPMNShape_sid-47EAD72A-932E-4850-9218-08A7335CEEDD\u0026#34;\u0026gt; \u0026lt;omgdc:Bounds height=\u0026#34;80.0\u0026#34; width=\u0026#34;100.0\u0026#34; x=\u0026#34;175.0\u0026#34; y=\u0026#34;138.0\u0026#34;\u0026gt;\u0026lt;/omgdc:Bounds\u0026gt; \u0026lt;/bpmndi:BPMNShape\u0026gt; \u0026lt;bpmndi:BPMNShape bpmnElement=\u0026#34;sid-8B323A3D-F6DA-4D38-9CAE-D4CDA1031343\u0026#34; id=\u0026#34;BPMNShape_sid-8B323A3D-F6DA-4D38-9CAE-D4CDA1031343\u0026#34;\u0026gt; \u0026lt;omgdc:Bounds height=\u0026#34;40.0\u0026#34; width=\u0026#34;40.0\u0026#34; x=\u0026#34;387.0\u0026#34; y=\u0026#34;143.0\u0026#34;\u0026gt;\u0026lt;/omgdc:Bounds\u0026gt; \u0026lt;/bpmndi:BPMNShape\u0026gt; \u0026lt;bpmndi:BPMNShape bpmnElement=\u0026#34;sid-AEFBD42F-2A10-4630-8E56-EDBD35CC95B1\u0026#34; id=\u0026#34;BPMNShape_sid-AEFBD42F-2A10-4630-8E56-EDBD35CC95B1\u0026#34;\u0026gt; \u0026lt;omgdc:Bounds height=\u0026#34;80.0\u0026#34; width=\u0026#34;100.0\u0026#34; x=\u0026#34;495.0\u0026#34; y=\u0026#34;45.0\u0026#34;\u0026gt;\u0026lt;/omgdc:Bounds\u0026gt; \u0026lt;/bpmndi:BPMNShape\u0026gt; \u0026lt;bpmndi:BPMNShape bpmnElement=\u0026#34;sid-8FB84D20-C946-4988-B4C4-16FFD899AF63\u0026#34; id=\u0026#34;BPMNShape_sid-8FB84D20-C946-4988-B4C4-16FFD899AF63\u0026#34;\u0026gt; \u0026lt;omgdc:Bounds height=\u0026#34;80.0\u0026#34; width=\u0026#34;100.0\u0026#34; x=\u0026#34;495.0\u0026#34; y=\u0026#34;225.0\u0026#34;\u0026gt;\u0026lt;/omgdc:Bounds\u0026gt; \u0026lt;/bpmndi:BPMNShape\u0026gt; \u0026lt;bpmndi:BPMNShape bpmnElement=\u0026#34;sid-B25B9926-873F-46F5-9D62-D155462C1665\u0026#34; id=\u0026#34;BPMNShape_sid-B25B9926-873F-46F5-9D62-D155462C1665\u0026#34;\u0026gt; \u0026lt;omgdc:Bounds height=\u0026#34;40.0\u0026#34; width=\u0026#34;40.0\u0026#34; x=\u0026#34;695.0\u0026#34; y=\u0026#34;143.0\u0026#34;\u0026gt;\u0026lt;/omgdc:Bounds\u0026gt; \u0026lt;/bpmndi:BPMNShape\u0026gt; \u0026lt;bpmndi:BPMNShape bpmnElement=\u0026#34;sid-143837B7-0687-4268-B381-BA2442E39097\u0026#34; id=\u0026#34;BPMNShape_sid-143837B7-0687-4268-B381-BA2442E39097\u0026#34;\u0026gt; \u0026lt;omgdc:Bounds height=\u0026#34;80.0\u0026#34; width=\u0026#34;100.0\u0026#34; x=\u0026#34;795.0\u0026#34; y=\u0026#34;60.0\u0026#34;\u0026gt;\u0026lt;/omgdc:Bounds\u0026gt; \u0026lt;/bpmndi:BPMNShape\u0026gt; \u0026lt;bpmndi:BPMNShape bpmnElement=\u0026#34;sid-5ACFE3BE-E094-43A9-85C5-7D438EFE5A97\u0026#34; id=\u0026#34;BPMNShape_sid-5ACFE3BE-E094-43A9-85C5-7D438EFE5A97\u0026#34;\u0026gt; \u0026lt;omgdc:Bounds height=\u0026#34;28.0\u0026#34; width=\u0026#34;28.0\u0026#34; x=\u0026#34;840.0\u0026#34; y=\u0026#34;225.0\u0026#34;\u0026gt;\u0026lt;/omgdc:Bounds\u0026gt; \u0026lt;/bpmndi:BPMNShape\u0026gt; \u0026lt;bpmndi:BPMNEdge bpmnElement=\u0026#34;sid-4255A9F7-39A1-46D3-AF14-DBEFF17AE911\u0026#34; id=\u0026#34;BPMNEdge_sid-4255A9F7-39A1-46D3-AF14-DBEFF17AE911\u0026#34; flowable:sourceDockerX=\u0026#34;50.0\u0026#34; flowable:sourceDockerY=\u0026#34;40.0\u0026#34; flowable:targetDockerX=\u0026#34;14.0\u0026#34; flowable:targetDockerY=\u0026#34;14.0\u0026#34;\u0026gt; \u0026lt;omgdi:waypoint x=\u0026#34;847.586690647482\u0026#34; y=\u0026#34;139.95\u0026#34;\u0026gt;\u0026lt;/omgdi:waypoint\u0026gt; \u0026lt;omgdi:waypoint x=\u0026#34;853.095383523332\u0026#34; y=\u0026#34;225.02614923910227\u0026#34;\u0026gt;\u0026lt;/omgdi:waypoint\u0026gt; \u0026lt;/bpmndi:BPMNEdge\u0026gt; \u0026lt;bpmndi:BPMNEdge bpmnElement=\u0026#34;sid-8B72154F-6D29-47F8-A81C-A070F82B95F9\u0026#34; id=\u0026#34;BPMNEdge_sid-8B72154F-6D29-47F8-A81C-A070F82B95F9\u0026#34; flowable:sourceDockerX=\u0026#34;15.0\u0026#34; flowable:sourceDockerY=\u0026#34;15.0\u0026#34; flowable:targetDockerX=\u0026#34;50.0\u0026#34; flowable:targetDockerY=\u0026#34;40.0\u0026#34;\u0026gt; \u0026lt;omgdi:waypoint x=\u0026#34;129.9499984899576\u0026#34; y=\u0026#34;178.0\u0026#34;\u0026gt;\u0026lt;/omgdi:waypoint\u0026gt; \u0026lt;omgdi:waypoint x=\u0026#34;174.9999999999917\u0026#34; y=\u0026#34;178.0\u0026#34;\u0026gt;\u0026lt;/omgdi:waypoint\u0026gt; \u0026lt;/bpmndi:BPMNEdge\u0026gt; \u0026lt;bpmndi:BPMNEdge bpmnElement=\u0026#34;sid-49DBB929-7488-471A-B79C-6BBFF4C810E0\u0026#34; id=\u0026#34;BPMNEdge_sid-49DBB929-7488-471A-B79C-6BBFF4C810E0\u0026#34; flowable:sourceDockerX=\u0026#34;20.5\u0026#34; flowable:sourceDockerY=\u0026#34;20.5\u0026#34; flowable:targetDockerX=\u0026#34;50.0\u0026#34; flowable:targetDockerY=\u0026#34;40.0\u0026#34;\u0026gt; \u0026lt;omgdi:waypoint x=\u0026#34;404.70744680851067\u0026#34; y=\u0026#34;145.2843450479233\u0026#34;\u0026gt;\u0026lt;/omgdi:waypoint\u0026gt; \u0026lt;omgdi:waypoint x=\u0026#34;395.0\u0026#34; y=\u0026#34;82.0\u0026#34;\u0026gt;\u0026lt;/omgdi:waypoint\u0026gt; \u0026lt;omgdi:waypoint x=\u0026#34;494.9999999999998\u0026#34; y=\u0026#34;84.0\u0026#34;\u0026gt;\u0026lt;/omgdi:waypoint\u0026gt; \u0026lt;/bpmndi:BPMNEdge\u0026gt; \u0026lt;bpmndi:BPMNEdge bpmnElement=\u0026#34;sid-2F49B59A-6860-4101-8156-84780094E6FE\u0026#34; id=\u0026#34;BPMNEdge_sid-2F49B59A-6860-4101-8156-84780094E6FE\u0026#34; flowable:sourceDockerX=\u0026#34;20.5\u0026#34; flowable:sourceDockerY=\u0026#34;20.5\u0026#34; flowable:targetDockerX=\u0026#34;14.0\u0026#34; flowable:targetDockerY=\u0026#34;14.0\u0026#34;\u0026gt; \u0026lt;omgdi:waypoint x=\u0026#34;715.5\u0026#34; y=\u0026#34;182.43746693121696\u0026#34;\u0026gt;\u0026lt;/omgdi:waypoint\u0026gt; \u0026lt;omgdi:waypoint x=\u0026#34;715.5\u0026#34; y=\u0026#34;239.0\u0026#34;\u0026gt;\u0026lt;/omgdi:waypoint\u0026gt; \u0026lt;omgdi:waypoint x=\u0026#34;840.0\u0026#34; y=\u0026#34;239.0\u0026#34;\u0026gt;\u0026lt;/omgdi:waypoint\u0026gt; \u0026lt;/bpmndi:BPMNEdge\u0026gt; \u0026lt;bpmndi:BPMNEdge bpmnElement=\u0026#34;sid-DCF940BC-05D4-4260-8C50-A4C6E291DEA3\u0026#34; id=\u0026#34;BPMNEdge_sid-DCF940BC-05D4-4260-8C50-A4C6E291DEA3\u0026#34; flowable:sourceDockerX=\u0026#34;20.5\u0026#34; flowable:sourceDockerY=\u0026#34;20.5\u0026#34; flowable:targetDockerX=\u0026#34;50.0\u0026#34; flowable:targetDockerY=\u0026#34;40.0\u0026#34;\u0026gt; \u0026lt;omgdi:waypoint x=\u0026#34;407.5\u0026#34; y=\u0026#34;182.44067421259845\u0026#34;\u0026gt;\u0026lt;/omgdi:waypoint\u0026gt; \u0026lt;omgdi:waypoint x=\u0026#34;407.5\u0026#34; y=\u0026#34;265.0\u0026#34;\u0026gt;\u0026lt;/omgdi:waypoint\u0026gt; \u0026lt;omgdi:waypoint x=\u0026#34;494.9999999999674\u0026#34; y=\u0026#34;265.0\u0026#34;\u0026gt;\u0026lt;/omgdi:waypoint\u0026gt; \u0026lt;/bpmndi:BPMNEdge\u0026gt; \u0026lt;bpmndi:BPMNEdge bpmnElement=\u0026#34;sid-A5253FCB-3D23-483F-A511-197811F656D6\u0026#34; id=\u0026#34;BPMNEdge_sid-A5253FCB-3D23-483F-A511-197811F656D6\u0026#34; flowable:sourceDockerX=\u0026#34;20.5\u0026#34; flowable:sourceDockerY=\u0026#34;20.5\u0026#34; flowable:targetDockerX=\u0026#34;50.0\u0026#34; flowable:targetDockerY=\u0026#34;40.0\u0026#34;\u0026gt; \u0026lt;omgdi:waypoint x=\u0026#34;715.5\u0026#34; y=\u0026#34;143.5\u0026#34;\u0026gt;\u0026lt;/omgdi:waypoint\u0026gt; \u0026lt;omgdi:waypoint x=\u0026#34;715.5\u0026#34; y=\u0026#34;90.0\u0026#34;\u0026gt;\u0026lt;/omgdi:waypoint\u0026gt; \u0026lt;omgdi:waypoint x=\u0026#34;795.0\u0026#34; y=\u0026#34;96.13899613899613\u0026#34;\u0026gt;\u0026lt;/omgdi:waypoint\u0026gt; \u0026lt;/bpmndi:BPMNEdge\u0026gt; \u0026lt;bpmndi:BPMNEdge bpmnElement=\u0026#34;sid-5F0BF3BD-BC7C-4AA0-AF87-F679C8EEB40B\u0026#34; id=\u0026#34;BPMNEdge_sid-5F0BF3BD-BC7C-4AA0-AF87-F679C8EEB40B\u0026#34; flowable:sourceDockerX=\u0026#34;50.0\u0026#34; flowable:sourceDockerY=\u0026#34;40.0\u0026#34; flowable:targetDockerX=\u0026#34;20.0\u0026#34; flowable:targetDockerY=\u0026#34;20.0\u0026#34;\u0026gt; \u0026lt;omgdi:waypoint x=\u0026#34;274.9499999999998\u0026#34; y=\u0026#34;173.87912087912088\u0026#34;\u0026gt;\u0026lt;/omgdi:waypoint\u0026gt; \u0026lt;omgdi:waypoint x=\u0026#34;388.52284263959393\u0026#34; y=\u0026#34;164.5190355329949\u0026#34;\u0026gt;\u0026lt;/omgdi:waypoint\u0026gt; \u0026lt;/bpmndi:BPMNEdge\u0026gt; \u0026lt;bpmndi:BPMNEdge bpmnElement=\u0026#34;sid-18DF81F2-2B7F-4CC7-AD70-8A878FC7B125\u0026#34; id=\u0026#34;BPMNEdge_sid-18DF81F2-2B7F-4CC7-AD70-8A878FC7B125\u0026#34; flowable:sourceDockerX=\u0026#34;50.0\u0026#34; flowable:sourceDockerY=\u0026#34;40.0\u0026#34; flowable:targetDockerX=\u0026#34;20.0\u0026#34; flowable:targetDockerY=\u0026#34;20.0\u0026#34;\u0026gt; \u0026lt;omgdi:waypoint x=\u0026#34;594.95\u0026#34; y=\u0026#34;107.91823529411766\u0026#34;\u0026gt;\u0026lt;/omgdi:waypoint\u0026gt; \u0026lt;omgdi:waypoint x=\u0026#34;701.273276904474\u0026#34; y=\u0026#34;156.70967741935485\u0026#34;\u0026gt;\u0026lt;/omgdi:waypoint\u0026gt; \u0026lt;/bpmndi:BPMNEdge\u0026gt; \u0026lt;bpmndi:BPMNEdge bpmnElement=\u0026#34;sid-B00C2DDD-8A30-4BA0-A2F8-69185D8506F5\u0026#34; id=\u0026#34;BPMNEdge_sid-B00C2DDD-8A30-4BA0-A2F8-69185D8506F5\u0026#34; flowable:sourceDockerX=\u0026#34;50.0\u0026#34; flowable:sourceDockerY=\u0026#34;40.0\u0026#34; flowable:targetDockerX=\u0026#34;20.5\u0026#34; flowable:targetDockerY=\u0026#34;20.5\u0026#34;\u0026gt; \u0026lt;omgdi:waypoint x=\u0026#34;594.95\u0026#34; y=\u0026#34;235.23460410557183\u0026#34;\u0026gt;\u0026lt;/omgdi:waypoint\u0026gt; \u0026lt;omgdi:waypoint x=\u0026#34;702.9632352941177\u0026#34; y=\u0026#34;170.94457720588235\u0026#34;\u0026gt;\u0026lt;/omgdi:waypoint\u0026gt; \u0026lt;/bpmndi:BPMNEdge\u0026gt; \u0026lt;/bpmndi:BPMNPlane\u0026gt; \u0026lt;/bpmndi:BPMNDiagram\u0026gt; \u0026lt;/definitions\u0026gt; 并行网关的条件会被忽略 代码测试\n//部署并运行 @Test public void deploy() { ProcessEngine engine = ProcessEngines.getDefaultProcessEngine(); RepositoryService repositoryService = engine.getRepositoryService(); Deployment deploy = repositoryService.createDeployment() .addClasspathResource(\u0026#34;请假流程-并行网关.bpmn20.xml\u0026#34;) .deploy(); System.out.println(\u0026#34;部署成功:\u0026#34; + deploy.getId()); } @Test public void run() { ProcessEngine engine = ProcessEngines.getDefaultProcessEngine(); RuntimeService runtimeService = engine.getRuntimeService(); Map\u0026lt;String, Object\u0026gt; variables = new HashMap\u0026lt;\u0026gt;(); variables.put(\u0026#34;num\u0026#34;, 4); runtimeService.startProcessInstanceById (\u0026#34;holiday-parr-key:1:12504\u0026#34;, variables); } 此时任务停留在zhangsan 让zhangsan完成任务\n@Test public void taskComplete(){ ProcessEngine engine=ProcessEngines.getDefaultProcessEngine(); TaskService taskService = engine.getTaskService(); Task task = taskService.createTaskQuery() .taskAssignee(\u0026#34;zhangsan\u0026#34;) .processInstanceId(\u0026#34;15001\u0026#34;) .singleResult(); taskService.complete(task.getId()); } 查看表数据(一个任务包含多个执行实例) 让王五和李四进行审批 查看数据库,wangwu审批后,act_ru_task就少了一条记录 此时走到总经理节点 图解 包容网关 # 包容网关可以选择多于一条顺序流。即固定几条必走,其他几条走条件\n流程图 xml\n\u0026lt;?xml version=\u0026#34;1.0\u0026#34; encoding=\u0026#34;UTF-8\u0026#34;?\u0026gt; \u0026lt;definitions xmlns=\u0026#34;http://www.omg.org/spec/BPMN/20100524/MODEL\u0026#34; xmlns:xsi=\u0026#34;http://www.w3.org/2001/XMLSchema-instance\u0026#34; xmlns:xsd=\u0026#34;http://www.w3.org/2001/XMLSchema\u0026#34; xmlns:flowable=\u0026#34;http://flowable.org/bpmn\u0026#34; xmlns:bpmndi=\u0026#34;http://www.omg.org/spec/BPMN/20100524/DI\u0026#34; xmlns:omgdc=\u0026#34;http://www.omg.org/spec/DD/20100524/DC\u0026#34; xmlns:omgdi=\u0026#34;http://www.omg.org/spec/DD/20100524/DI\u0026#34; typeLanguage=\u0026#34;http://www.w3.org/2001/XMLSchema\u0026#34; expressionLanguage=\u0026#34;http://www.w3.org/1999/XPath\u0026#34; targetNamespace=\u0026#34;http://www.flowable.org/processdef\u0026#34; exporter=\u0026#34;Flowable Open Source Modeler\u0026#34; exporterVersion=\u0026#34;6.7.2\u0026#34;\u0026gt; \u0026lt;process id=\u0026#34;holiday-inclusive\u0026#34; name=\u0026#34;holiday-inclusive-name\u0026#34; isExecutable=\u0026#34;true\u0026#34;\u0026gt; \u0026lt;documentation\u0026gt;holiday-inclusive-desc\u0026lt;/documentation\u0026gt; \u0026lt;startEvent id=\u0026#34;startEvent1\u0026#34; flowable:formFieldValidation=\u0026#34;true\u0026#34;\u0026gt;\u0026lt;/startEvent\u0026gt; \u0026lt;userTask id=\u0026#34;sid-6C2C29AA-C1D2-4B09-A542-ED194A13F5F2\u0026#34; name=\u0026#34;创建请假单\u0026#34; flowable:assignee=\u0026#34;i0\u0026#34; flowable:formFieldValidation=\u0026#34;true\u0026#34;\u0026gt; \u0026lt;extensionElements\u0026gt; \u0026lt;modeler:initiator-can-complete xmlns:modeler=\u0026#34;http://flowable.org/modeler\u0026#34;\u0026gt;\u0026lt;![CDATA[false]]\u0026gt;\u0026lt;/modeler:initiator-can-complete\u0026gt; \u0026lt;/extensionElements\u0026gt; \u0026lt;/userTask\u0026gt; \u0026lt;sequenceFlow id=\u0026#34;sid-CAD92170-984F-49E0-BB6D-589B11F7FB8B\u0026#34; sourceRef=\u0026#34;startEvent1\u0026#34; targetRef=\u0026#34;sid-6C2C29AA-C1D2-4B09-A542-ED194A13F5F2\u0026#34;\u0026gt;\u0026lt;/sequenceFlow\u0026gt; \u0026lt;inclusiveGateway id=\u0026#34;sid-46FAF12A-7430-4AFA-AABB-99B2D875C9CD\u0026#34;\u0026gt;\u0026lt;/inclusiveGateway\u0026gt; \u0026lt;sequenceFlow id=\u0026#34;sid-CCD38C3B-C06F-4646-B979-F65C0CA26321\u0026#34; sourceRef=\u0026#34;sid-6C2C29AA-C1D2-4B09-A542-ED194A13F5F2\u0026#34; targetRef=\u0026#34;sid-46FAF12A-7430-4AFA-AABB-99B2D875C9CD\u0026#34;\u0026gt;\u0026lt;/sequenceFlow\u0026gt; \u0026lt;userTask id=\u0026#34;sid-9AD9C288-F114-4AC6-9366-A09A786B068E\u0026#34; name=\u0026#34;项目经理\u0026#34; flowable:assignee=\u0026#34;i1\u0026#34; flowable:formFieldValidation=\u0026#34;true\u0026#34;\u0026gt; \u0026lt;extensionElements\u0026gt; \u0026lt;modeler:initiator-can-complete xmlns:modeler=\u0026#34;http://flowable.org/modeler\u0026#34;\u0026gt;\u0026lt;![CDATA[false]]\u0026gt;\u0026lt;/modeler:initiator-can-complete\u0026gt; \u0026lt;/extensionElements\u0026gt; \u0026lt;/userTask\u0026gt; \u0026lt;userTask id=\u0026#34;sid-764DC717-439D-425E-83FF-D81BD08A2562\u0026#34; name=\u0026#34;人事\u0026#34; flowable:assignee=\u0026#34;i2\u0026#34; flowable:formFieldValidation=\u0026#34;true\u0026#34;\u0026gt; \u0026lt;extensionElements\u0026gt; \u0026lt;modeler:initiator-can-complete xmlns:modeler=\u0026#34;http://flowable.org/modeler\u0026#34;\u0026gt;\u0026lt;![CDATA[false]]\u0026gt;\u0026lt;/modeler:initiator-can-complete\u0026gt; \u0026lt;/extensionElements\u0026gt; \u0026lt;/userTask\u0026gt; \u0026lt;sequenceFlow id=\u0026#34;sid-B8DE143C-4636-4F2C-99C9-8949E23B0042\u0026#34; sourceRef=\u0026#34;sid-46FAF12A-7430-4AFA-AABB-99B2D875C9CD\u0026#34; targetRef=\u0026#34;sid-764DC717-439D-425E-83FF-D81BD08A2562\u0026#34;\u0026gt;\u0026lt;/sequenceFlow\u0026gt; \u0026lt;userTask id=\u0026#34;sid-AC8D2717-5BCD-4C5B-81BB-2FF66CFFC615\u0026#34; name=\u0026#34;技术经理\u0026#34; flowable:assignee=\u0026#34;i3\u0026#34; flowable:formFieldValidation=\u0026#34;true\u0026#34;\u0026gt; \u0026lt;extensionElements\u0026gt; \u0026lt;modeler:initiator-can-complete xmlns:modeler=\u0026#34;http://flowable.org/modeler\u0026#34;\u0026gt;\u0026lt;![CDATA[false]]\u0026gt;\u0026lt;/modeler:initiator-can-complete\u0026gt; \u0026lt;/extensionElements\u0026gt; \u0026lt;/userTask\u0026gt; \u0026lt;inclusiveGateway id=\u0026#34;sid-6449A9C8-B7A3-44EE-BEDF-154AF323B1A8\u0026#34;\u0026gt;\u0026lt;/inclusiveGateway\u0026gt; \u0026lt;sequenceFlow id=\u0026#34;sid-A52331B4-3769-46D8-AAC1-C34214C729BD\u0026#34; sourceRef=\u0026#34;sid-9AD9C288-F114-4AC6-9366-A09A786B068E\u0026#34; targetRef=\u0026#34;sid-6449A9C8-B7A3-44EE-BEDF-154AF323B1A8\u0026#34;\u0026gt;\u0026lt;/sequenceFlow\u0026gt; \u0026lt;sequenceFlow id=\u0026#34;sid-681E9C5D-AD4B-45DD-BF12-E2CD5304ADFB\u0026#34; sourceRef=\u0026#34;sid-764DC717-439D-425E-83FF-D81BD08A2562\u0026#34; targetRef=\u0026#34;sid-6449A9C8-B7A3-44EE-BEDF-154AF323B1A8\u0026#34;\u0026gt;\u0026lt;/sequenceFlow\u0026gt; \u0026lt;sequenceFlow id=\u0026#34;sid-78E79754-E64A-4ADE-A9BB-F9B224D3A5A0\u0026#34; sourceRef=\u0026#34;sid-AC8D2717-5BCD-4C5B-81BB-2FF66CFFC615\u0026#34; targetRef=\u0026#34;sid-6449A9C8-B7A3-44EE-BEDF-154AF323B1A8\u0026#34;\u0026gt;\u0026lt;/sequenceFlow\u0026gt; \u0026lt;exclusiveGateway id=\u0026#34;sid-65D4D76B-AD2B-4AE9-8E78-7B8C33BD9E55\u0026#34;\u0026gt;\u0026lt;/exclusiveGateway\u0026gt; \u0026lt;sequenceFlow id=\u0026#34;sid-422FC4A8-B667-4271-9CB3-A1D2CFEFC5E1\u0026#34; sourceRef=\u0026#34;sid-6449A9C8-B7A3-44EE-BEDF-154AF323B1A8\u0026#34; targetRef=\u0026#34;sid-65D4D76B-AD2B-4AE9-8E78-7B8C33BD9E55\u0026#34;\u0026gt;\u0026lt;/sequenceFlow\u0026gt; \u0026lt;userTask id=\u0026#34;sid-4B834200-7995-453B-BC08-AF93C9F29FCF\u0026#34; name=\u0026#34;总经理\u0026#34; flowable:assignee=\u0026#34;wz\u0026#34; flowable:formFieldValidation=\u0026#34;true\u0026#34;\u0026gt; \u0026lt;extensionElements\u0026gt; \u0026lt;modeler:initiator-can-complete xmlns:modeler=\u0026#34;http://flowable.org/modeler\u0026#34;\u0026gt;\u0026lt;![CDATA[false]]\u0026gt;\u0026lt;/modeler:initiator-can-complete\u0026gt; \u0026lt;/extensionElements\u0026gt; \u0026lt;/userTask\u0026gt; \u0026lt;endEvent id=\u0026#34;sid-7296D067-FF72-49F9-B416-2452640A0FBC\u0026#34;\u0026gt;\u0026lt;/endEvent\u0026gt; \u0026lt;sequenceFlow id=\u0026#34;sid-AD0571E9-839D-4F1F-89ED-05BE60F841FD\u0026#34; sourceRef=\u0026#34;sid-4B834200-7995-453B-BC08-AF93C9F29FCF\u0026#34; targetRef=\u0026#34;sid-7296D067-FF72-49F9-B416-2452640A0FBC\u0026#34;\u0026gt;\u0026lt;/sequenceFlow\u0026gt; \u0026lt;sequenceFlow id=\u0026#34;sid-E808AF78-E258-4997-B4FE-C393D8EBA3B9\u0026#34; sourceRef=\u0026#34;sid-46FAF12A-7430-4AFA-AABB-99B2D875C9CD\u0026#34; targetRef=\u0026#34;sid-9AD9C288-F114-4AC6-9366-A09A786B068E\u0026#34;\u0026gt; \u0026lt;conditionExpression xsi:type=\u0026#34;tFormalExpression\u0026#34;\u0026gt;\u0026lt;![CDATA[${num\u0026gt;3}]]\u0026gt;\u0026lt;/conditionExpression\u0026gt; \u0026lt;/sequenceFlow\u0026gt; \u0026lt;sequenceFlow id=\u0026#34;sid-E4AD02E7-A69A-4684-9A00-DE9B11711348\u0026#34; sourceRef=\u0026#34;sid-46FAF12A-7430-4AFA-AABB-99B2D875C9CD\u0026#34; targetRef=\u0026#34;sid-AC8D2717-5BCD-4C5B-81BB-2FF66CFFC615\u0026#34;\u0026gt; \u0026lt;conditionExpression xsi:type=\u0026#34;tFormalExpression\u0026#34;\u0026gt;\u0026lt;![CDATA[${num \u0026lt;= 3}]]\u0026gt;\u0026lt;/conditionExpression\u0026gt; \u0026lt;/sequenceFlow\u0026gt; \u0026lt;sequenceFlow id=\u0026#34;sid-A6760B6A-B74F-4D35-93C2-6653751F8873\u0026#34; sourceRef=\u0026#34;sid-65D4D76B-AD2B-4AE9-8E78-7B8C33BD9E55\u0026#34; targetRef=\u0026#34;sid-4B834200-7995-453B-BC08-AF93C9F29FCF\u0026#34;\u0026gt; \u0026lt;conditionExpression xsi:type=\u0026#34;tFormalExpression\u0026#34;\u0026gt;\u0026lt;![CDATA[${num \u0026gt; 3}]]\u0026gt;\u0026lt;/conditionExpression\u0026gt; \u0026lt;/sequenceFlow\u0026gt; \u0026lt;sequenceFlow id=\u0026#34;sid-97A0DAB9-564D-4A62-92A4-26C7056CD347\u0026#34; sourceRef=\u0026#34;sid-65D4D76B-AD2B-4AE9-8E78-7B8C33BD9E55\u0026#34; targetRef=\u0026#34;sid-7296D067-FF72-49F9-B416-2452640A0FBC\u0026#34;\u0026gt; \u0026lt;conditionExpression xsi:type=\u0026#34;tFormalExpression\u0026#34;\u0026gt;\u0026lt;![CDATA[${num\u0026lt;=3 }]]\u0026gt;\u0026lt;/conditionExpression\u0026gt; \u0026lt;/sequenceFlow\u0026gt; \u0026lt;/process\u0026gt; \u0026lt;bpmndi:BPMNDiagram id=\u0026#34;BPMNDiagram_holiday-inclusive\u0026#34;\u0026gt; \u0026lt;bpmndi:BPMNPlane bpmnElement=\u0026#34;holiday-inclusive\u0026#34; id=\u0026#34;BPMNPlane_holiday-inclusive\u0026#34;\u0026gt; \u0026lt;bpmndi:BPMNShape bpmnElement=\u0026#34;startEvent1\u0026#34; id=\u0026#34;BPMNShape_startEvent1\u0026#34;\u0026gt; \u0026lt;omgdc:Bounds height=\u0026#34;30.0\u0026#34; width=\u0026#34;30.0\u0026#34; x=\u0026#34;100.0\u0026#34; y=\u0026#34;163.0\u0026#34;\u0026gt;\u0026lt;/omgdc:Bounds\u0026gt; \u0026lt;/bpmndi:BPMNShape\u0026gt; \u0026lt;bpmndi:BPMNShape bpmnElement=\u0026#34;sid-6C2C29AA-C1D2-4B09-A542-ED194A13F5F2\u0026#34; id=\u0026#34;BPMNShape_sid-6C2C29AA-C1D2-4B09-A542-ED194A13F5F2\u0026#34;\u0026gt; \u0026lt;omgdc:Bounds height=\u0026#34;80.0\u0026#34; width=\u0026#34;100.0\u0026#34; x=\u0026#34;195.0\u0026#34; y=\u0026#34;135.0\u0026#34;\u0026gt;\u0026lt;/omgdc:Bounds\u0026gt; \u0026lt;/bpmndi:BPMNShape\u0026gt; \u0026lt;bpmndi:BPMNShape bpmnElement=\u0026#34;sid-46FAF12A-7430-4AFA-AABB-99B2D875C9CD\u0026#34; id=\u0026#34;BPMNShape_sid-46FAF12A-7430-4AFA-AABB-99B2D875C9CD\u0026#34;\u0026gt; \u0026lt;omgdc:Bounds height=\u0026#34;40.0\u0026#34; width=\u0026#34;40.0\u0026#34; x=\u0026#34;366.0\u0026#34; y=\u0026#34;145.0\u0026#34;\u0026gt;\u0026lt;/omgdc:Bounds\u0026gt; \u0026lt;/bpmndi:BPMNShape\u0026gt; \u0026lt;bpmndi:BPMNShape bpmnElement=\u0026#34;sid-9AD9C288-F114-4AC6-9366-A09A786B068E\u0026#34; id=\u0026#34;BPMNShape_sid-9AD9C288-F114-4AC6-9366-A09A786B068E\u0026#34;\u0026gt; \u0026lt;omgdc:Bounds height=\u0026#34;80.0\u0026#34; width=\u0026#34;100.0\u0026#34; x=\u0026#34;451.0\u0026#34; y=\u0026#34;30.0\u0026#34;\u0026gt;\u0026lt;/omgdc:Bounds\u0026gt; \u0026lt;/bpmndi:BPMNShape\u0026gt; \u0026lt;bpmndi:BPMNShape bpmnElement=\u0026#34;sid-764DC717-439D-425E-83FF-D81BD08A2562\u0026#34; id=\u0026#34;BPMNShape_sid-764DC717-439D-425E-83FF-D81BD08A2562\u0026#34;\u0026gt; \u0026lt;omgdc:Bounds height=\u0026#34;80.0\u0026#34; width=\u0026#34;100.0\u0026#34; x=\u0026#34;450.0\u0026#34; y=\u0026#34;120.0\u0026#34;\u0026gt;\u0026lt;/omgdc:Bounds\u0026gt; \u0026lt;/bpmndi:BPMNShape\u0026gt; \u0026lt;bpmndi:BPMNShape bpmnElement=\u0026#34;sid-AC8D2717-5BCD-4C5B-81BB-2FF66CFFC615\u0026#34; id=\u0026#34;BPMNShape_sid-AC8D2717-5BCD-4C5B-81BB-2FF66CFFC615\u0026#34;\u0026gt; \u0026lt;omgdc:Bounds height=\u0026#34;80.0\u0026#34; width=\u0026#34;100.0\u0026#34; x=\u0026#34;465.0\u0026#34; y=\u0026#34;255.0\u0026#34;\u0026gt;\u0026lt;/omgdc:Bounds\u0026gt; \u0026lt;/bpmndi:BPMNShape\u0026gt; \u0026lt;bpmndi:BPMNShape bpmnElement=\u0026#34;sid-6449A9C8-B7A3-44EE-BEDF-154AF323B1A8\u0026#34; id=\u0026#34;BPMNShape_sid-6449A9C8-B7A3-44EE-BEDF-154AF323B1A8\u0026#34;\u0026gt; \u0026lt;omgdc:Bounds height=\u0026#34;40.0\u0026#34; width=\u0026#34;40.0\u0026#34; x=\u0026#34;656.0\u0026#34; y=\u0026#34;137.0\u0026#34;\u0026gt;\u0026lt;/omgdc:Bounds\u0026gt; \u0026lt;/bpmndi:BPMNShape\u0026gt; \u0026lt;bpmndi:BPMNShape bpmnElement=\u0026#34;sid-65D4D76B-AD2B-4AE9-8E78-7B8C33BD9E55\u0026#34; id=\u0026#34;BPMNShape_sid-65D4D76B-AD2B-4AE9-8E78-7B8C33BD9E55\u0026#34;\u0026gt; \u0026lt;omgdc:Bounds height=\u0026#34;40.0\u0026#34; width=\u0026#34;40.0\u0026#34; x=\u0026#34;750.0\u0026#34; y=\u0026#34;137.0\u0026#34;\u0026gt;\u0026lt;/omgdc:Bounds\u0026gt; \u0026lt;/bpmndi:BPMNShape\u0026gt; \u0026lt;bpmndi:BPMNShape bpmnElement=\u0026#34;sid-4B834200-7995-453B-BC08-AF93C9F29FCF\u0026#34; id=\u0026#34;BPMNShape_sid-4B834200-7995-453B-BC08-AF93C9F29FCF\u0026#34;\u0026gt; \u0026lt;omgdc:Bounds height=\u0026#34;80.0\u0026#34; width=\u0026#34;100.0\u0026#34; x=\u0026#34;855.0\u0026#34; y=\u0026#34;60.0\u0026#34;\u0026gt;\u0026lt;/omgdc:Bounds\u0026gt; \u0026lt;/bpmndi:BPMNShape\u0026gt; \u0026lt;bpmndi:BPMNShape bpmnElement=\u0026#34;sid-7296D067-FF72-49F9-B416-2452640A0FBC\u0026#34; id=\u0026#34;BPMNShape_sid-7296D067-FF72-49F9-B416-2452640A0FBC\u0026#34;\u0026gt; \u0026lt;omgdc:Bounds height=\u0026#34;28.0\u0026#34; width=\u0026#34;28.0\u0026#34; x=\u0026#34;900.0\u0026#34; y=\u0026#34;240.0\u0026#34;\u0026gt;\u0026lt;/omgdc:Bounds\u0026gt; \u0026lt;/bpmndi:BPMNShape\u0026gt; \u0026lt;bpmndi:BPMNEdge bpmnElement=\u0026#34;sid-681E9C5D-AD4B-45DD-BF12-E2CD5304ADFB\u0026#34; id=\u0026#34;BPMNEdge_sid-681E9C5D-AD4B-45DD-BF12-E2CD5304ADFB\u0026#34; flowable:sourceDockerX=\u0026#34;50.0\u0026#34; flowable:sourceDockerY=\u0026#34;40.0\u0026#34; flowable:targetDockerX=\u0026#34;20.0\u0026#34; flowable:targetDockerY=\u0026#34;20.0\u0026#34;\u0026gt; \u0026lt;omgdi:waypoint x=\u0026#34;549.9499999999988\u0026#34; y=\u0026#34;159.14772727272728\u0026#34;\u0026gt;\u0026lt;/omgdi:waypoint\u0026gt; \u0026lt;omgdi:waypoint x=\u0026#34;656.3351955307262\u0026#34; y=\u0026#34;157.33435754189946\u0026#34;\u0026gt;\u0026lt;/omgdi:waypoint\u0026gt; \u0026lt;/bpmndi:BPMNEdge\u0026gt; \u0026lt;bpmndi:BPMNEdge bpmnElement=\u0026#34;sid-CCD38C3B-C06F-4646-B979-F65C0CA26321\u0026#34; id=\u0026#34;BPMNEdge_sid-CCD38C3B-C06F-4646-B979-F65C0CA26321\u0026#34; flowable:sourceDockerX=\u0026#34;50.0\u0026#34; flowable:sourceDockerY=\u0026#34;40.0\u0026#34; flowable:targetDockerX=\u0026#34;20.0\u0026#34; flowable:targetDockerY=\u0026#34;20.0\u0026#34;\u0026gt; \u0026lt;omgdi:waypoint x=\u0026#34;294.94999999999993\u0026#34; y=\u0026#34;171.45390070921985\u0026#34;\u0026gt;\u0026lt;/omgdi:waypoint\u0026gt; \u0026lt;omgdi:waypoint x=\u0026#34;367.32450331125824\u0026#34; y=\u0026#34;166.32119205298014\u0026#34;\u0026gt;\u0026lt;/omgdi:waypoint\u0026gt; \u0026lt;/bpmndi:BPMNEdge\u0026gt; \u0026lt;bpmndi:BPMNEdge bpmnElement=\u0026#34;sid-AD0571E9-839D-4F1F-89ED-05BE60F841FD\u0026#34; id=\u0026#34;BPMNEdge_sid-AD0571E9-839D-4F1F-89ED-05BE60F841FD\u0026#34; flowable:sourceDockerX=\u0026#34;50.0\u0026#34; flowable:sourceDockerY=\u0026#34;40.0\u0026#34; flowable:targetDockerX=\u0026#34;14.0\u0026#34; flowable:targetDockerY=\u0026#34;14.0\u0026#34;\u0026gt; \u0026lt;omgdi:waypoint x=\u0026#34;907.3347402597402\u0026#34; y=\u0026#34;139.95\u0026#34;\u0026gt;\u0026lt;/omgdi:waypoint\u0026gt; \u0026lt;omgdi:waypoint x=\u0026#34;913.1831773972388\u0026#34; y=\u0026#34;240.02104379436742\u0026#34;\u0026gt;\u0026lt;/omgdi:waypoint\u0026gt; \u0026lt;/bpmndi:BPMNEdge\u0026gt; \u0026lt;bpmndi:BPMNEdge bpmnElement=\u0026#34;sid-A6760B6A-B74F-4D35-93C2-6653751F8873\u0026#34; id=\u0026#34;BPMNEdge_sid-A6760B6A-B74F-4D35-93C2-6653751F8873\u0026#34; flowable:sourceDockerX=\u0026#34;22.5\u0026#34; flowable:sourceDockerY=\u0026#34;7.0\u0026#34; flowable:targetDockerX=\u0026#34;50.0\u0026#34; flowable:targetDockerY=\u0026#34;40.0\u0026#34;\u0026gt; \u0026lt;omgdi:waypoint x=\u0026#34;775.8406515580737\u0026#34; y=\u0026#34;142.87818696883852\u0026#34;\u0026gt;\u0026lt;/omgdi:waypoint\u0026gt; \u0026lt;omgdi:waypoint x=\u0026#34;855.0\u0026#34; y=\u0026#34;116.58716981132078\u0026#34;\u0026gt;\u0026lt;/omgdi:waypoint\u0026gt; \u0026lt;/bpmndi:BPMNEdge\u0026gt; \u0026lt;bpmndi:BPMNEdge bpmnElement=\u0026#34;sid-B8DE143C-4636-4F2C-99C9-8949E23B0042\u0026#34; id=\u0026#34;BPMNEdge_sid-B8DE143C-4636-4F2C-99C9-8949E23B0042\u0026#34; flowable:sourceDockerX=\u0026#34;20.5\u0026#34; flowable:sourceDockerY=\u0026#34;20.5\u0026#34; flowable:targetDockerX=\u0026#34;50.0\u0026#34; flowable:targetDockerY=\u0026#34;40.0\u0026#34;\u0026gt; \u0026lt;omgdi:waypoint x=\u0026#34;405.4272235576724\u0026#34; y=\u0026#34;165.5\u0026#34;\u0026gt;\u0026lt;/omgdi:waypoint\u0026gt; \u0026lt;omgdi:waypoint x=\u0026#34;428.0\u0026#34; y=\u0026#34;165.5\u0026#34;\u0026gt;\u0026lt;/omgdi:waypoint\u0026gt; \u0026lt;omgdi:waypoint x=\u0026#34;428.0\u0026#34; y=\u0026#34;160.0\u0026#34;\u0026gt;\u0026lt;/omgdi:waypoint\u0026gt; \u0026lt;omgdi:waypoint x=\u0026#34;449.99999999999346\u0026#34; y=\u0026#34;160.0\u0026#34;\u0026gt;\u0026lt;/omgdi:waypoint\u0026gt; \u0026lt;/bpmndi:BPMNEdge\u0026gt; \u0026lt;bpmndi:BPMNEdge bpmnElement=\u0026#34;sid-422FC4A8-B667-4271-9CB3-A1D2CFEFC5E1\u0026#34; id=\u0026#34;BPMNEdge_sid-422FC4A8-B667-4271-9CB3-A1D2CFEFC5E1\u0026#34; flowable:sourceDockerX=\u0026#34;20.5\u0026#34; flowable:sourceDockerY=\u0026#34;20.5\u0026#34; flowable:targetDockerX=\u0026#34;20.5\u0026#34; flowable:targetDockerY=\u0026#34;20.5\u0026#34;\u0026gt; \u0026lt;omgdi:waypoint x=\u0026#34;695.4399309245483\u0026#34; y=\u0026#34;157.5\u0026#34;\u0026gt;\u0026lt;/omgdi:waypoint\u0026gt; \u0026lt;omgdi:waypoint x=\u0026#34;750.5\u0026#34; y=\u0026#34;157.5\u0026#34;\u0026gt;\u0026lt;/omgdi:waypoint\u0026gt; \u0026lt;/bpmndi:BPMNEdge\u0026gt; \u0026lt;bpmndi:BPMNEdge bpmnElement=\u0026#34;sid-97A0DAB9-564D-4A62-92A4-26C7056CD347\u0026#34; id=\u0026#34;BPMNEdge_sid-97A0DAB9-564D-4A62-92A4-26C7056CD347\u0026#34; flowable:sourceDockerX=\u0026#34;20.5\u0026#34; flowable:sourceDockerY=\u0026#34;20.5\u0026#34; flowable:targetDockerX=\u0026#34;14.0\u0026#34; flowable:targetDockerY=\u0026#34;14.0\u0026#34;\u0026gt; \u0026lt;omgdi:waypoint x=\u0026#34;770.5\u0026#34; y=\u0026#34;176.44111163227018\u0026#34;\u0026gt;\u0026lt;/omgdi:waypoint\u0026gt; \u0026lt;omgdi:waypoint x=\u0026#34;770.5\u0026#34; y=\u0026#34;264.0\u0026#34;\u0026gt;\u0026lt;/omgdi:waypoint\u0026gt; \u0026lt;omgdi:waypoint x=\u0026#34;900.033302364888\u0026#34; y=\u0026#34;254.96981315483313\u0026#34;\u0026gt;\u0026lt;/omgdi:waypoint\u0026gt; \u0026lt;/bpmndi:BPMNEdge\u0026gt; \u0026lt;bpmndi:BPMNEdge bpmnElement=\u0026#34;sid-A52331B4-3769-46D8-AAC1-C34214C729BD\u0026#34; id=\u0026#34;BPMNEdge_sid-A52331B4-3769-46D8-AAC1-C34214C729BD\u0026#34; flowable:sourceDockerX=\u0026#34;50.0\u0026#34; flowable:sourceDockerY=\u0026#34;40.0\u0026#34; flowable:targetDockerX=\u0026#34;20.0\u0026#34; flowable:targetDockerY=\u0026#34;20.0\u0026#34;\u0026gt; \u0026lt;omgdi:waypoint x=\u0026#34;550.95\u0026#34; y=\u0026#34;94.83228571428573\u0026#34;\u0026gt;\u0026lt;/omgdi:waypoint\u0026gt; \u0026lt;omgdi:waypoint x=\u0026#34;662.6257153758107\u0026#34; y=\u0026#34;150.3587786259542\u0026#34;\u0026gt;\u0026lt;/omgdi:waypoint\u0026gt; \u0026lt;/bpmndi:BPMNEdge\u0026gt; \u0026lt;bpmndi:BPMNEdge bpmnElement=\u0026#34;sid-E808AF78-E258-4997-B4FE-C393D8EBA3B9\u0026#34; id=\u0026#34;BPMNEdge_sid-E808AF78-E258-4997-B4FE-C393D8EBA3B9\u0026#34; flowable:sourceDockerX=\u0026#34;20.5\u0026#34; flowable:sourceDockerY=\u0026#34;20.5\u0026#34; flowable:targetDockerX=\u0026#34;50.0\u0026#34; flowable:targetDockerY=\u0026#34;40.0\u0026#34;\u0026gt; \u0026lt;omgdi:waypoint x=\u0026#34;386.5\u0026#34; y=\u0026#34;145.5\u0026#34;\u0026gt;\u0026lt;/omgdi:waypoint\u0026gt; \u0026lt;omgdi:waypoint x=\u0026#34;386.5\u0026#34; y=\u0026#34;70.0\u0026#34;\u0026gt;\u0026lt;/omgdi:waypoint\u0026gt; \u0026lt;omgdi:waypoint x=\u0026#34;451.0\u0026#34; y=\u0026#34;70.0\u0026#34;\u0026gt;\u0026lt;/omgdi:waypoint\u0026gt; \u0026lt;/bpmndi:BPMNEdge\u0026gt; \u0026lt;bpmndi:BPMNEdge bpmnElement=\u0026#34;sid-CAD92170-984F-49E0-BB6D-589B11F7FB8B\u0026#34; id=\u0026#34;BPMNEdge_sid-CAD92170-984F-49E0-BB6D-589B11F7FB8B\u0026#34; flowable:sourceDockerX=\u0026#34;15.0\u0026#34; flowable:sourceDockerY=\u0026#34;15.0\u0026#34; flowable:targetDockerX=\u0026#34;50.0\u0026#34; flowable:targetDockerY=\u0026#34;40.0\u0026#34;\u0026gt; \u0026lt;omgdi:waypoint x=\u0026#34;129.94999191137833\u0026#34; y=\u0026#34;178.0\u0026#34;\u0026gt;\u0026lt;/omgdi:waypoint\u0026gt; \u0026lt;omgdi:waypoint x=\u0026#34;162.5\u0026#34; y=\u0026#34;178.0\u0026#34;\u0026gt;\u0026lt;/omgdi:waypoint\u0026gt; \u0026lt;omgdi:waypoint x=\u0026#34;162.5\u0026#34; y=\u0026#34;175.0\u0026#34;\u0026gt;\u0026lt;/omgdi:waypoint\u0026gt; \u0026lt;omgdi:waypoint x=\u0026#34;194.99999999998522\u0026#34; y=\u0026#34;175.0\u0026#34;\u0026gt;\u0026lt;/omgdi:waypoint\u0026gt; \u0026lt;/bpmndi:BPMNEdge\u0026gt; \u0026lt;bpmndi:BPMNEdge bpmnElement=\u0026#34;sid-E4AD02E7-A69A-4684-9A00-DE9B11711348\u0026#34; id=\u0026#34;BPMNEdge_sid-E4AD02E7-A69A-4684-9A00-DE9B11711348\u0026#34; flowable:sourceDockerX=\u0026#34;20.5\u0026#34; flowable:sourceDockerY=\u0026#34;20.5\u0026#34; flowable:targetDockerX=\u0026#34;50.0\u0026#34; flowable:targetDockerY=\u0026#34;40.0\u0026#34;\u0026gt; \u0026lt;omgdi:waypoint x=\u0026#34;386.5\u0026#34; y=\u0026#34;184.4426890432099\u0026#34;\u0026gt;\u0026lt;/omgdi:waypoint\u0026gt; \u0026lt;omgdi:waypoint x=\u0026#34;386.5\u0026#34; y=\u0026#34;295.0\u0026#34;\u0026gt;\u0026lt;/omgdi:waypoint\u0026gt; \u0026lt;omgdi:waypoint x=\u0026#34;465.0\u0026#34; y=\u0026#34;295.0\u0026#34;\u0026gt;\u0026lt;/omgdi:waypoint\u0026gt; \u0026lt;/bpmndi:BPMNEdge\u0026gt; \u0026lt;bpmndi:BPMNEdge bpmnElement=\u0026#34;sid-78E79754-E64A-4ADE-A9BB-F9B224D3A5A0\u0026#34; id=\u0026#34;BPMNEdge_sid-78E79754-E64A-4ADE-A9BB-F9B224D3A5A0\u0026#34; flowable:sourceDockerX=\u0026#34;50.0\u0026#34; flowable:sourceDockerY=\u0026#34;40.0\u0026#34; flowable:targetDockerX=\u0026#34;20.0\u0026#34; flowable:targetDockerY=\u0026#34;20.0\u0026#34;\u0026gt; \u0026lt;omgdi:waypoint x=\u0026#34;561.6083333333333\u0026#34; y=\u0026#34;255.0\u0026#34;\u0026gt;\u0026lt;/omgdi:waypoint\u0026gt; \u0026lt;omgdi:waypoint x=\u0026#34;665.2307692307692\u0026#34; y=\u0026#34;166.20769230769233\u0026#34;\u0026gt;\u0026lt;/omgdi:waypoint\u0026gt; \u0026lt;/bpmndi:BPMNEdge\u0026gt; \u0026lt;/bpmndi:BPMNPlane\u0026gt; \u0026lt;/bpmndi:BPMNDiagram\u0026gt; \u0026lt;/definitions\u0026gt; 部署并运行\n@Test public void deploy() { ProcessEngine engine = ProcessEngines.getDefaultProcessEngine(); RepositoryService repositoryService = engine.getRepositoryService(); Deployment deploy = repositoryService.createDeployment() .addClasspathResource(\u0026#34;holiday-inclusive-name.bpmn20.xml\u0026#34;) .deploy(); System.out.println(\u0026#34;部署成功:\u0026#34; + deploy.getId()); } @Test public void run() { ProcessEngine engine = ProcessEngines.getDefaultProcessEngine(); RuntimeService runtimeService = engine.getRuntimeService(); Map\u0026lt;String, Object\u0026gt; variables = new HashMap\u0026lt;\u0026gt;(); variables.put(\u0026#34;num\u0026#34;, 4); runtimeService.startProcessInstanceById (\u0026#34;holiday-inclusive:1:4\u0026#34;, variables); } i0完成任务\n@Test public void taskComplete(){ ProcessEngine engine=ProcessEngines.getDefaultProcessEngine(); TaskService taskService = engine.getTaskService(); Task task = taskService.createTaskQuery() .taskAssignee(\u0026#34;i0\u0026#34;) .processInstanceId(\u0026#34;2501\u0026#34;) .singleResult(); taskService.complete(task.getId()); } 看数据,默认走人事和项目经理 i1,i2所在任务执行完后,会发现走总经理 i1走完之后 i2走的时候,把num设为1,直接结束\n@Test public void taskComplete(){ ProcessEngine engine=ProcessEngines.getDefaultProcessEngine(); TaskService taskService = engine.getTaskService(); Task task = taskService.createTaskQuery() .taskAssignee(\u0026#34;i2\u0026#34;) .processInstanceId(\u0026#34;2501\u0026#34;) .singleResult(); taskService.setVariable(task.getId(), \u0026#34;num\u0026#34;,1); taskService.complete(task.getId()); } 事件网关 # "},{"id":388,"href":"/zh/docs/technology/Flowable/boge_blbl_/02-advance_4/","title":"boge-02-flowable进阶_4","section":"基础(波哥)_","content":" 候选人 # 流程图设计\n总体 具体 部署并启动流程\n@Test public void deploy(){ ProcessEngine processEngine= ProcessEngines.getDefaultProcessEngine(); RepositoryService repositoryService = processEngine.getRepositoryService(); Deployment deploy = repositoryService.createDeployment().name(\u0026#34;ly画的请假流程-候选人\u0026#34;) .addClasspathResource(\u0026#34;请假流程-候选人.bpmn20.xml\u0026#34;) .deploy(); } @Test public void runProcess(){ //设置候选人 Map\u0026lt;String,Object\u0026gt; variables=new HashMap\u0026lt;\u0026gt;(); variables.put(\u0026#34;candidate1\u0026#34;,\u0026#34;张三\u0026#34;); variables.put(\u0026#34;candidate2\u0026#34;,\u0026#34;李四\u0026#34;); variables.put(\u0026#34;candidate3\u0026#34;,\u0026#34;王五\u0026#34;); ProcessEngine engine=ProcessEngines.getDefaultProcessEngine(); //获取流程运行服务 RuntimeService runtimeService = engine.getRuntimeService(); //运行流程 ProcessInstance processInstance = runtimeService.startProcessInstanceById( \u0026#34;holiday-candidate:1:4\u0026#34;,variables); System.out.println(\u0026#34;processInstance--\u0026#34;+processInstance); } 查看数据库表数据\n处理人为空 变量 图解 实际,作为登录用户如果是张三/李四或者王五,那它可以查看它自己是候选人的任务\n/** * 查询候选任务 */ @Test public void queryCandidate(){ ProcessEngine processEngine=ProcessEngines.getDefaultProcessEngine(); TaskService taskService=processEngine.getTaskService(); List\u0026lt;Task\u0026gt; tasks = taskService.createTaskQuery() .processInstanceId(\u0026#34;5001\u0026#34;) .taskCandidateUser(\u0026#34;张三\u0026#34;) .list(); for(Task task:tasks){ System.out.println(\u0026#34;id--\u0026#34;+task.getId()+\u0026#34;--\u0026#34;+task.getName()); } } 拾取任务\n/** * 拾取任务 */ @Test public void claimTaskCandidate(){ ProcessEngine engine=ProcessEngines.getDefaultProcessEngine(); TaskService taskService=engine.getTaskService(); Task task = taskService.createTaskQuery() .processInstanceId(\u0026#34;5001\u0026#34;) .taskCandidateUser(\u0026#34;张三\u0026#34;) .singleResult(); if(task != null ){ //拾取任务 taskService.claim(task.getId(),\u0026#34;张三\u0026#34;); System.out.println(\u0026#34;拾取任务成功\u0026#34;); } } 数据库数据 此时查询李四候选任务,就查询不到了 归还任务\n/** * 拾取任务 */ @Test public void unclaimTaskCandidate(){ ProcessEngine engine=ProcessEngines.getDefaultProcessEngine(); TaskService taskService=engine.getTaskService(); Task task = taskService.createTaskQuery() .processInstanceId(\u0026#34;5001\u0026#34;) .taskAssignee(\u0026#34;张三\u0026#34;) .singleResult(); if(task != null ){ //归还任务 taskService.unclaim(task.getId()); System.out.println(\u0026#34;归还任务成功\u0026#34;); } } 数据库数据 此时用李四,拾取成功 任务交接(委托)\n/** * 任务交接(委托) */ @Test public void taskCandidate(){ ProcessEngine engine=ProcessEngines.getDefaultProcessEngine(); TaskService taskService=engine.getTaskService(); Task task = taskService.createTaskQuery() .processInstanceId(\u0026#34;5001\u0026#34;) .taskAssignee(\u0026#34;李四\u0026#34;) .singleResult(); if(task != null ){ taskService.setAssignee(task.getId(),\u0026#34;赵六\u0026#34;); System.out.println(\u0026#34;任务交接给赵六\u0026#34;); } } 结果 完成任务\n/** * 完成任务 */ @Test public void taskComplete(){ ProcessEngine engine=ProcessEngines.getDefaultProcessEngine(); TaskService taskService = engine.getTaskService(); Task task = taskService.createTaskQuery() .processInstanceId(\u0026#34;5001\u0026#34;) .taskAssignee(\u0026#34;赵六\u0026#34;) .singleResult(); if(task!=null){ taskService.complete(task.getId()); System.out.println(\u0026#34;完成任务\u0026#34;); } } 此时任务给wz了 候选人组 # 当候选人很多的情况下,可以分组。(先创建组,然后将用户放到组中)\n维护用户和组\n/** * 创建用户 */ @Test public void createUser(){ ProcessEngine engine= ProcessEngines.getDefaultProcessEngine(); IdentityService identityService = engine.getIdentityService(); User user1 = identityService.newUser(\u0026#34;李飞\u0026#34;); user1.setFirstName(\u0026#34;li\u0026#34;); user1.setLastName(\u0026#34;fei\u0026#34;); identityService.saveUser(user1); User user2 = identityService.newUser(\u0026#34;灯标\u0026#34;); user2.setFirstName(\u0026#34;deng\u0026#34;); user2.setLastName(\u0026#34;biao\u0026#34;); identityService.saveUser(user2); User user3 = identityService.newUser(\u0026#34;田家\u0026#34;); user3.setFirstName(\u0026#34;tian\u0026#34;); user3.setLastName(\u0026#34;jia\u0026#34;); identityService.saveUser(user3); } /** * 创建组 */ @Test public void createGroup(){ ProcessEngine engine=ProcessEngines.getDefaultProcessEngine(); IdentityService identityService = engine.getIdentityService(); Group group1 = identityService.newGroup(\u0026#34;group1\u0026#34;); group1.setName(\u0026#34;销售部\u0026#34;); group1.setType(\u0026#34;typ1\u0026#34;); identityService.saveGroup(group1); Group group2 = identityService.newGroup(\u0026#34;group2\u0026#34;); group2.setName(\u0026#34;开发部\u0026#34;); group2.setType(\u0026#34;typ2\u0026#34;); identityService.saveGroup(group2); } /** * 分配 */ @Test public void userGroup(){ ProcessEngine engine=ProcessEngines.getDefaultProcessEngine(); IdentityService identityService = engine.getIdentityService(); //找到组 Group group1 = identityService.createGroupQuery().groupId(\u0026#34;group1\u0026#34;) .singleResult(); //找到所有用户 List\u0026lt;User\u0026gt; list = identityService.createUserQuery().list(); for(User user:list){ identityService.createMembership(user.getId(),group1.getId()); System.out.println(user.getId()); } } 表结构\n应用,创建流程图 xml文件\n\u0026lt;?xml version=\u0026#34;1.0\u0026#34; encoding=\u0026#34;UTF-8\u0026#34;?\u0026gt; \u0026lt;definitions xmlns=\u0026#34;http://www.omg.org/spec/BPMN/20100524/MODEL\u0026#34; xmlns:xsi=\u0026#34;http://www.w3.org/2001/XMLSchema-instance\u0026#34; xmlns:xsd=\u0026#34;http://www.w3.org/2001/XMLSchema\u0026#34; xmlns:flowable=\u0026#34;http://flowable.org/bpmn\u0026#34; xmlns:bpmndi=\u0026#34;http://www.omg.org/spec/BPMN/20100524/DI\u0026#34; xmlns:omgdc=\u0026#34;http://www.omg.org/spec/DD/20100524/DC\u0026#34; xmlns:omgdi=\u0026#34;http://www.omg.org/spec/DD/20100524/DI\u0026#34; typeLanguage=\u0026#34;http://www.w3.org/2001/XMLSchema\u0026#34; expressionLanguage=\u0026#34;http://www.w3.org/1999/XPath\u0026#34; targetNamespace=\u0026#34;http://www.flowable.org/processdef\u0026#34; exporter=\u0026#34;Flowable Open Source Modeler\u0026#34; exporterVersion=\u0026#34;6.7.2\u0026#34;\u0026gt; \u0026lt;process id=\u0026#34;holiday-group\u0026#34; name=\u0026#34;请求流程-候选人组\u0026#34; isExecutable=\u0026#34;true\u0026#34;\u0026gt; \u0026lt;startEvent id=\u0026#34;startEvent1\u0026#34; flowable:formFieldValidation=\u0026#34;true\u0026#34;\u0026gt;\u0026lt;/startEvent\u0026gt; \u0026lt;userTask id=\u0026#34;sid-B4CAA6EE-47C0-4C51-AB0F-7A347AA88CF9\u0026#34; name=\u0026#34;创建请假单\u0026#34; flowable:candidateGroups=\u0026#34;${g1}\u0026#34; flowable:formFieldValidation=\u0026#34;true\u0026#34;\u0026gt;\u0026lt;/userTask\u0026gt; \u0026lt;sequenceFlow id=\u0026#34;sid-FAA16FF3-BFC5-49AA-8BB5-7DF1918F67FF\u0026#34; sourceRef=\u0026#34;startEvent1\u0026#34; targetRef=\u0026#34;sid-B4CAA6EE-47C0-4C51-AB0F-7A347AA88CF9\u0026#34;\u0026gt;\u0026lt;/sequenceFlow\u0026gt; \u0026lt;userTask id=\u0026#34;sid-C3C15BE2-2D50-4178-AD36-D6BAC5C47526\u0026#34; name=\u0026#34;总经理审批\u0026#34; flowable:assignee=\u0026#34;wz\u0026#34; flowable:formFieldValidation=\u0026#34;true\u0026#34;\u0026gt; \u0026lt;extensionElements\u0026gt; \u0026lt;modeler:initiator-can-complete xmlns:modeler=\u0026#34;http://flowable.org/modeler\u0026#34;\u0026gt;\u0026lt;![CDATA[false]]\u0026gt;\u0026lt;/modeler:initiator-can-complete\u0026gt; \u0026lt;/extensionElements\u0026gt; \u0026lt;/userTask\u0026gt; \u0026lt;sequenceFlow id=\u0026#34;sid-9821E7E5-DB4A-4BE5-95C7-2721E98D6BD6\u0026#34; sourceRef=\u0026#34;sid-B4CAA6EE-47C0-4C51-AB0F-7A347AA88CF9\u0026#34; targetRef=\u0026#34;sid-C3C15BE2-2D50-4178-AD36-D6BAC5C47526\u0026#34;\u0026gt;\u0026lt;/sequenceFlow\u0026gt; \u0026lt;endEvent id=\u0026#34;sid-BF42EC91-584D-4C19-8EC0-9658CD948CDE\u0026#34;\u0026gt;\u0026lt;/endEvent\u0026gt; \u0026lt;sequenceFlow id=\u0026#34;sid-6F5E54EF-5767-4E22-8AC7-322C7E332B6B\u0026#34; sourceRef=\u0026#34;sid-C3C15BE2-2D50-4178-AD36-D6BAC5C47526\u0026#34; targetRef=\u0026#34;sid-BF42EC91-584D-4C19-8EC0-9658CD948CDE\u0026#34;\u0026gt;\u0026lt;/sequenceFlow\u0026gt; \u0026lt;/process\u0026gt; \u0026lt;bpmndi:BPMNDiagram id=\u0026#34;BPMNDiagram_holiday-group\u0026#34;\u0026gt; \u0026lt;bpmndi:BPMNPlane bpmnElement=\u0026#34;holiday-group\u0026#34; id=\u0026#34;BPMNPlane_holiday-group\u0026#34;\u0026gt; \u0026lt;bpmndi:BPMNShape bpmnElement=\u0026#34;startEvent1\u0026#34; id=\u0026#34;BPMNShape_startEvent1\u0026#34;\u0026gt; \u0026lt;omgdc:Bounds height=\u0026#34;30.0\u0026#34; width=\u0026#34;30.0\u0026#34; x=\u0026#34;100.0\u0026#34; y=\u0026#34;163.0\u0026#34;\u0026gt;\u0026lt;/omgdc:Bounds\u0026gt; \u0026lt;/bpmndi:BPMNShape\u0026gt; \u0026lt;bpmndi:BPMNShape bpmnElement=\u0026#34;sid-B4CAA6EE-47C0-4C51-AB0F-7A347AA88CF9\u0026#34; id=\u0026#34;BPMNShape_sid-B4CAA6EE-47C0-4C51-AB0F-7A347AA88CF9\u0026#34;\u0026gt; \u0026lt;omgdc:Bounds height=\u0026#34;80.0\u0026#34; width=\u0026#34;100.0\u0026#34; x=\u0026#34;165.0\u0026#34; y=\u0026#34;135.0\u0026#34;\u0026gt;\u0026lt;/omgdc:Bounds\u0026gt; \u0026lt;/bpmndi:BPMNShape\u0026gt; \u0026lt;bpmndi:BPMNShape bpmnElement=\u0026#34;sid-C3C15BE2-2D50-4178-AD36-D6BAC5C47526\u0026#34; id=\u0026#34;BPMNShape_sid-C3C15BE2-2D50-4178-AD36-D6BAC5C47526\u0026#34;\u0026gt; \u0026lt;omgdc:Bounds height=\u0026#34;80.0\u0026#34; width=\u0026#34;100.0\u0026#34; x=\u0026#34;330.0\u0026#34; y=\u0026#34;135.0\u0026#34;\u0026gt;\u0026lt;/omgdc:Bounds\u0026gt; \u0026lt;/bpmndi:BPMNShape\u0026gt; \u0026lt;bpmndi:BPMNShape bpmnElement=\u0026#34;sid-BF42EC91-584D-4C19-8EC0-9658CD948CDE\u0026#34; id=\u0026#34;BPMNShape_sid-BF42EC91-584D-4C19-8EC0-9658CD948CDE\u0026#34;\u0026gt; \u0026lt;omgdc:Bounds height=\u0026#34;28.0\u0026#34; width=\u0026#34;28.0\u0026#34; x=\u0026#34;510.0\u0026#34; y=\u0026#34;164.0\u0026#34;\u0026gt;\u0026lt;/omgdc:Bounds\u0026gt; \u0026lt;/bpmndi:BPMNShape\u0026gt; \u0026lt;bpmndi:BPMNEdge bpmnElement=\u0026#34;sid-9821E7E5-DB4A-4BE5-95C7-2721E98D6BD6\u0026#34; id=\u0026#34;BPMNEdge_sid-9821E7E5-DB4A-4BE5-95C7-2721E98D6BD6\u0026#34; flowable:sourceDockerX=\u0026#34;50.0\u0026#34; flowable:sourceDockerY=\u0026#34;40.0\u0026#34; flowable:targetDockerX=\u0026#34;50.0\u0026#34; flowable:targetDockerY=\u0026#34;40.0\u0026#34;\u0026gt; \u0026lt;omgdi:waypoint x=\u0026#34;264.94999999998356\u0026#34; y=\u0026#34;175.0\u0026#34;\u0026gt;\u0026lt;/omgdi:waypoint\u0026gt; \u0026lt;omgdi:waypoint x=\u0026#34;330.0\u0026#34; y=\u0026#34;175.0\u0026#34;\u0026gt;\u0026lt;/omgdi:waypoint\u0026gt; \u0026lt;/bpmndi:BPMNEdge\u0026gt; \u0026lt;bpmndi:BPMNEdge bpmnElement=\u0026#34;sid-FAA16FF3-BFC5-49AA-8BB5-7DF1918F67FF\u0026#34; id=\u0026#34;BPMNEdge_sid-FAA16FF3-BFC5-49AA-8BB5-7DF1918F67FF\u0026#34; flowable:sourceDockerX=\u0026#34;15.0\u0026#34; flowable:sourceDockerY=\u0026#34;15.0\u0026#34; flowable:targetDockerX=\u0026#34;50.0\u0026#34; flowable:targetDockerY=\u0026#34;40.0\u0026#34;\u0026gt; \u0026lt;omgdi:waypoint x=\u0026#34;129.94340692927761\u0026#34; y=\u0026#34;177.55019845363262\u0026#34;\u0026gt;\u0026lt;/omgdi:waypoint\u0026gt; \u0026lt;omgdi:waypoint x=\u0026#34;164.99999999999906\u0026#34; y=\u0026#34;176.4985\u0026#34;\u0026gt;\u0026lt;/omgdi:waypoint\u0026gt; \u0026lt;/bpmndi:BPMNEdge\u0026gt; \u0026lt;bpmndi:BPMNEdge bpmnElement=\u0026#34;sid-6F5E54EF-5767-4E22-8AC7-322C7E332B6B\u0026#34; id=\u0026#34;BPMNEdge_sid-6F5E54EF-5767-4E22-8AC7-322C7E332B6B\u0026#34; flowable:sourceDockerX=\u0026#34;50.0\u0026#34; flowable:sourceDockerY=\u0026#34;40.0\u0026#34; flowable:targetDockerX=\u0026#34;14.0\u0026#34; flowable:targetDockerY=\u0026#34;14.0\u0026#34;\u0026gt; \u0026lt;omgdi:waypoint x=\u0026#34;429.9499999999989\u0026#34; y=\u0026#34;176.04062499999998\u0026#34;\u0026gt;\u0026lt;/omgdi:waypoint\u0026gt; \u0026lt;omgdi:waypoint x=\u0026#34;510.0021426561354\u0026#34; y=\u0026#34;177.70839534661596\u0026#34;\u0026gt;\u0026lt;/omgdi:waypoint\u0026gt; \u0026lt;/bpmndi:BPMNEdge\u0026gt; \u0026lt;/bpmndi:BPMNPlane\u0026gt; \u0026lt;/bpmndi:BPMNDiagram\u0026gt; \u0026lt;/definitions\u0026gt; 部署并启动流程\n@Test public void deploy(){ ProcessEngine processEngine= ProcessEngines.getDefaultProcessEngine(); RepositoryService repositoryService = processEngine.getRepositoryService(); Deployment deploy = repositoryService.createDeployment().name(\u0026#34;ly画的请假流程-候选人\u0026#34;) .addClasspathResource(\u0026#34;请求流程-候选人组.bpmn20.xml\u0026#34;) .deploy(); } @Test public void runProcess(){ ProcessEngine engine=ProcessEngines.getDefaultProcessEngine(); //实际开发,应该按下面代码让用户选 IdentityService identityService = engine.getIdentityService(); List\u0026lt;Group\u0026gt; list = identityService.createGroupQuery().list(); //获取流程运行服务 RuntimeService runtimeService = engine.getRuntimeService(); //设置候选人 Map\u0026lt;String,Object\u0026gt; variables=new HashMap\u0026lt;\u0026gt;(); variables.put(\u0026#34;g1\u0026#34;,\u0026#34;group1\u0026#34;); //运行流程 ProcessInstance processInstance = runtimeService. startProcessInstanceById( \u0026#34;holiday-group:1:25004\u0026#34;,variables); System.out.println(\u0026#34;processInstance--\u0026#34;+processInstance); } 表 variables 查找当前用户所在组的任务,并拾取\n/** * 查询候选组任务 */ @Test public void queryCandidateGroup(){ String userId=\u0026#34;灯标\u0026#34;; ProcessEngine processEngine=ProcessEngines.getDefaultProcessEngine(); IdentityService identityService = processEngine.getIdentityService(); Group group = identityService.createGroupQuery(). groupMember(userId) .singleResult(); System.out.println(\u0026#34;灯标组id\u0026#34;+group.getId()); TaskService taskService=processEngine.getTaskService(); List\u0026lt;Task\u0026gt; tasks = taskService.createTaskQuery() .processInstanceId(\u0026#34;27501\u0026#34;) .taskCandidateGroup(group.getId()) .list(); for(Task task:tasks){ System.out.println(\u0026#34;id--\u0026#34;+task.getId()+\u0026#34;--\u0026#34;+task.getName()); } Task task = taskService.createTaskQuery() .processInstanceId(\u0026#34;27501\u0026#34;) .taskCandidateGroup(group.getId()) .singleResult(); if(task!=null){ System.out.println(\u0026#34;拾取任务--\u0026#34;+task.getId() +\u0026#34;任务名--\u0026#34;+task.getName()); taskService.claim(task.getId(),userId); } } 数据库数据 完成任务\n/** * 完成任务 */ @Test public void taskComplete(){ ProcessEngine engine=ProcessEngines.getDefaultProcessEngine(); TaskService taskService = engine.getTaskService(); Task task = taskService.createTaskQuery() .processInstanceId(\u0026#34;27501\u0026#34;) .taskAssignee(\u0026#34;灯标\u0026#34;) .singleResult(); if(task!=null){ taskService.complete(task.getId()); System.out.println(\u0026#34;完成任务\u0026#34;); } } # "},{"id":389,"href":"/zh/docs/technology/Flowable/boge_blbl_/02-advance_3/","title":"boge-02-flowable进阶_3","section":"基础(波哥)_","content":" 任务分配-uel表达式 # 通过变量指定来进行分配\n首先绘制流程图(定义) 变量处理 之后将xml文件导出\n\u0026lt;?xml version=\u0026#34;1.0\u0026#34; encoding=\u0026#34;UTF-8\u0026#34;?\u0026gt; \u0026lt;definitions xmlns=\u0026#34;http://www.omg.org/spec/BPMN/20100524/MODEL\u0026#34; xmlns:xsi=\u0026#34;http://www.w3.org/2001/XMLSchema-instance\u0026#34; xmlns:xsd=\u0026#34;http://www.w3.org/2001/XMLSchema\u0026#34; xmlns:flowable=\u0026#34;http://flowable.org/bpmn\u0026#34; xmlns:bpmndi=\u0026#34;http://www.omg.org/spec/BPMN/20100524/DI\u0026#34; xmlns:omgdc=\u0026#34;http://www.omg.org/spec/DD/20100524/DC\u0026#34; xmlns:omgdi=\u0026#34;http://www.omg.org/spec/DD/20100524/DI\u0026#34; typeLanguage=\u0026#34;http://www.w3.org/2001/XMLSchema\u0026#34; expressionLanguage=\u0026#34;http://www.w3.org/1999/XPath\u0026#34; targetNamespace=\u0026#34;http://www.flowable.org/processdef\u0026#34; exporter=\u0026#34;Flowable Open Source Modeler\u0026#34; exporterVersion=\u0026#34;6.7.2\u0026#34;\u0026gt; \u0026lt;process id=\u0026#34;holiday-new\u0026#34; name=\u0026#34;新请假流程\u0026#34; isExecutable=\u0026#34;true\u0026#34;\u0026gt; \u0026lt;documentation\u0026gt;new-description\u0026lt;/documentation\u0026gt; \u0026lt;startEvent id=\u0026#34;startEvent1\u0026#34; flowable:formFieldValidation=\u0026#34;true\u0026#34;\u0026gt;\u0026lt;/startEvent\u0026gt; \u0026lt;userTask id=\u0026#34;sid-8D901410-5BD7-4EED-B988-5E40D12298C7\u0026#34; name=\u0026#34;创建请假流程\u0026#34; flowable:assignee=\u0026#34;${assignee0}\u0026#34; flowable:formFieldValidation=\u0026#34;true\u0026#34;\u0026gt; \u0026lt;extensionElements\u0026gt; \u0026lt;modeler:initiator-can-complete xmlns:modeler=\u0026#34;http://flowable.org/modeler\u0026#34;\u0026gt;\u0026lt;![CDATA[false]]\u0026gt;\u0026lt;/modeler:initiator-can-complete\u0026gt; \u0026lt;/extensionElements\u0026gt; \u0026lt;/userTask\u0026gt; \u0026lt;userTask id=\u0026#34;sid-5EB8F68B-7876-42AF-98E1-FCA27F99D8CE\u0026#34; name=\u0026#34;审批请假流程\u0026#34; flowable:assignee=\u0026#34;${assignee1}\u0026#34; flowable:formFieldValidation=\u0026#34;true\u0026#34;\u0026gt; \u0026lt;extensionElements\u0026gt; \u0026lt;modeler:initiator-can-complete xmlns:modeler=\u0026#34;http://flowable.org/modeler\u0026#34;\u0026gt;\u0026lt;![CDATA[false]]\u0026gt;\u0026lt;/modeler:initiator-can-complete\u0026gt; \u0026lt;/extensionElements\u0026gt; \u0026lt;/userTask\u0026gt; \u0026lt;sequenceFlow id=\u0026#34;sid-631EFFB0-795A-4777-B49E-CF7D015BFF15\u0026#34; sourceRef=\u0026#34;sid-8D901410-5BD7-4EED-B988-5E40D12298C7\u0026#34; targetRef=\u0026#34;sid-5EB8F68B-7876-42AF-98E1-FCA27F99D8CE\u0026#34;\u0026gt;\u0026lt;/sequenceFlow\u0026gt; \u0026lt;endEvent id=\u0026#34;sid-15CAD0D3-7F8B-404C-9346-A8D2A456D47B\u0026#34;\u0026gt;\u0026lt;/endEvent\u0026gt; \u0026lt;sequenceFlow id=\u0026#34;sid-001CA567-6169-4F8A-A0E5-010721D52508\u0026#34; sourceRef=\u0026#34;sid-5EB8F68B-7876-42AF-98E1-FCA27F99D8CE\u0026#34; targetRef=\u0026#34;sid-15CAD0D3-7F8B-404C-9346-A8D2A456D47B\u0026#34;\u0026gt;\u0026lt;/sequenceFlow\u0026gt; \u0026lt;sequenceFlow id=\u0026#34;sid-0A4A52F2-ECF6-44B2-AA41-F926AA7F5932\u0026#34; sourceRef=\u0026#34;startEvent1\u0026#34; targetRef=\u0026#34;sid-8D901410-5BD7-4EED-B988-5E40D12298C7\u0026#34;\u0026gt;\u0026lt;/sequenceFlow\u0026gt; \u0026lt;/process\u0026gt; \u0026lt;bpmndi:BPMNDiagram id=\u0026#34;BPMNDiagram_holiday-new\u0026#34;\u0026gt; \u0026lt;bpmndi:BPMNPlane bpmnElement=\u0026#34;holiday-new\u0026#34; id=\u0026#34;BPMNPlane_holiday-new\u0026#34;\u0026gt; \u0026lt;bpmndi:BPMNShape bpmnElement=\u0026#34;startEvent1\u0026#34; id=\u0026#34;BPMNShape_startEvent1\u0026#34;\u0026gt; \u0026lt;omgdc:Bounds height=\u0026#34;30.0\u0026#34; width=\u0026#34;30.0\u0026#34; x=\u0026#34;100.0\u0026#34; y=\u0026#34;145.0\u0026#34;\u0026gt;\u0026lt;/omgdc:Bounds\u0026gt; \u0026lt;/bpmndi:BPMNShape\u0026gt; \u0026lt;bpmndi:BPMNShape bpmnElement=\u0026#34;sid-8D901410-5BD7-4EED-B988-5E40D12298C7\u0026#34; id=\u0026#34;BPMNShape_sid-8D901410-5BD7-4EED-B988-5E40D12298C7\u0026#34;\u0026gt; \u0026lt;omgdc:Bounds height=\u0026#34;80.0\u0026#34; width=\u0026#34;100.0\u0026#34; x=\u0026#34;225.0\u0026#34; y=\u0026#34;120.0\u0026#34;\u0026gt;\u0026lt;/omgdc:Bounds\u0026gt; \u0026lt;/bpmndi:BPMNShape\u0026gt; \u0026lt;bpmndi:BPMNShape bpmnElement=\u0026#34;sid-5EB8F68B-7876-42AF-98E1-FCA27F99D8CE\u0026#34; id=\u0026#34;BPMNShape_sid-5EB8F68B-7876-42AF-98E1-FCA27F99D8CE\u0026#34;\u0026gt; \u0026lt;omgdc:Bounds height=\u0026#34;80.0\u0026#34; width=\u0026#34;100.0\u0026#34; x=\u0026#34;370.0\u0026#34; y=\u0026#34;120.0\u0026#34;\u0026gt;\u0026lt;/omgdc:Bounds\u0026gt; \u0026lt;/bpmndi:BPMNShape\u0026gt; \u0026lt;bpmndi:BPMNShape bpmnElement=\u0026#34;sid-15CAD0D3-7F8B-404C-9346-A8D2A456D47B\u0026#34; id=\u0026#34;BPMNShape_sid-15CAD0D3-7F8B-404C-9346-A8D2A456D47B\u0026#34;\u0026gt; \u0026lt;omgdc:Bounds height=\u0026#34;28.0\u0026#34; width=\u0026#34;28.0\u0026#34; x=\u0026#34;555.0\u0026#34; y=\u0026#34;146.0\u0026#34;\u0026gt;\u0026lt;/omgdc:Bounds\u0026gt; \u0026lt;/bpmndi:BPMNShape\u0026gt; \u0026lt;bpmndi:BPMNEdge bpmnElement=\u0026#34;sid-001CA567-6169-4F8A-A0E5-010721D52508\u0026#34; id=\u0026#34;BPMNEdge_sid-001CA567-6169-4F8A-A0E5-010721D52508\u0026#34; flowable:sourceDockerX=\u0026#34;50.0\u0026#34; flowable:sourceDockerY=\u0026#34;40.0\u0026#34; flowable:targetDockerX=\u0026#34;14.0\u0026#34; flowable:targetDockerY=\u0026#34;14.0\u0026#34;\u0026gt; \u0026lt;omgdi:waypoint x=\u0026#34;469.94999999997356\u0026#34; y=\u0026#34;160.0\u0026#34;\u0026gt;\u0026lt;/omgdi:waypoint\u0026gt; \u0026lt;omgdi:waypoint x=\u0026#34;555.0\u0026#34; y=\u0026#34;160.0\u0026#34;\u0026gt;\u0026lt;/omgdi:waypoint\u0026gt; \u0026lt;/bpmndi:BPMNEdge\u0026gt; \u0026lt;bpmndi:BPMNEdge bpmnElement=\u0026#34;sid-0A4A52F2-ECF6-44B2-AA41-F926AA7F5932\u0026#34; id=\u0026#34;BPMNEdge_sid-0A4A52F2-ECF6-44B2-AA41-F926AA7F5932\u0026#34; flowable:sourceDockerX=\u0026#34;15.0\u0026#34; flowable:sourceDockerY=\u0026#34;15.0\u0026#34; flowable:targetDockerX=\u0026#34;50.0\u0026#34; flowable:targetDockerY=\u0026#34;40.0\u0026#34;\u0026gt; \u0026lt;omgdi:waypoint x=\u0026#34;129.94999928606217\u0026#34; y=\u0026#34;160.0\u0026#34;\u0026gt;\u0026lt;/omgdi:waypoint\u0026gt; \u0026lt;omgdi:waypoint x=\u0026#34;224.99999999995185\u0026#34; y=\u0026#34;160.0\u0026#34;\u0026gt;\u0026lt;/omgdi:waypoint\u0026gt; \u0026lt;/bpmndi:BPMNEdge\u0026gt; \u0026lt;bpmndi:BPMNEdge bpmnElement=\u0026#34;sid-631EFFB0-795A-4777-B49E-CF7D015BFF15\u0026#34; id=\u0026#34;BPMNEdge_sid-631EFFB0-795A-4777-B49E-CF7D015BFF15\u0026#34; flowable:sourceDockerX=\u0026#34;50.0\u0026#34; flowable:sourceDockerY=\u0026#34;40.0\u0026#34; flowable:targetDockerX=\u0026#34;50.0\u0026#34; flowable:targetDockerY=\u0026#34;40.0\u0026#34;\u0026gt; \u0026lt;omgdi:waypoint x=\u0026#34;324.9499999999907\u0026#34; y=\u0026#34;160.0\u0026#34;\u0026gt;\u0026lt;/omgdi:waypoint\u0026gt; \u0026lt;omgdi:waypoint x=\u0026#34;369.9999999999807\u0026#34; y=\u0026#34;160.0\u0026#34;\u0026gt;\u0026lt;/omgdi:waypoint\u0026gt; \u0026lt;/bpmndi:BPMNEdge\u0026gt; \u0026lt;/bpmndi:BPMNPlane\u0026gt; \u0026lt;/bpmndi:BPMNDiagram\u0026gt; \u0026lt;/definitions\u0026gt; 流程定义的部署\n/** * 流程的部署 */ @Test public void testDeploy() { //获取ProcessEngine对象 ProcessEngine processEngine = configuration.buildProcessEngine(); //获取服务(repository,流程定义) RepositoryService repositoryService = processEngine.getRepositoryService(); Deployment deploy = repositoryService.createDeployment() .addClasspathResource(\u0026#34;新请假流程.bpmn20.xml\u0026#34;) .name(\u0026#34;请求流程\u0026#34;) //流程名 .deploy(); System.out.println(\u0026#34;部署id\u0026#34; + deploy.getId()); System.out.println(\u0026#34;部署名\u0026#34; + deploy.getName()); } 流程的启动(在流程启动时就已经处理好了各个节点的处理人)\n/** * 流程实例的启动 */ @Test public void testRunProcess2(){ ProcessEngine engine=ProcessEngines.getDefaultProcessEngine(); RuntimeService runtimeService = engine.getRuntimeService(); //启动流程时,发起人就已经设置好了 Map\u0026lt;String,Object\u0026gt; variables=new HashMap\u0026lt;\u0026gt;(); variables.put(\u0026#34;assignee0\u0026#34;,\u0026#34;张三\u0026#34;); variables.put(\u0026#34;assignee1\u0026#34;,\u0026#34;李四\u0026#34;); ProcessInstance processInstance = runtimeService.startProcessInstanceById(\u0026#34;holiday-new:1:4\u0026#34;,variables); System.out.println(processInstance); } 查看数据库表数据\nact_ru_variable\nact_ru_task 让张三完成处理\n@Test public void testComplete(){ ProcessEngine processEngine=ProcessEngines.getDefaultProcessEngine(); TaskService taskService = processEngine.getTaskService(); Task task = taskService.createTaskQuery().taskAssignee(\u0026#34;张三\u0026#34;) .processInstanceId(\u0026#34;2501\u0026#34;) .singleResult(); taskService.complete(task.getId()); } 此时观察task和identity这两张表\n任务变成了李四,而identity多了张三的记录\n任务分配-监听器分配 # 首先,java代码中,自定义一个监听器 【注意,这里给任务分配assignee是在create中分配才是有用的】\npackage org.flowable.listener; import org.flowable.engine.delegate.TaskListener; import org.flowable.task.service.delegate.DelegateTask; public class MyTaskListener implements TaskListener { /** * 监听器触发的方法 * @param delegateTask */ @Override public void notify(DelegateTask delegateTask) { System.out.println(\u0026#34;MyTaskListener触发:\u0026#34;+delegateTask .getName()); if(\u0026#34;创建请假流程\u0026#34;.equals(delegateTask.getName()) \u0026amp;\u0026amp;\u0026#34;create\u0026#34;.equals(delegateTask.getEventName())){ delegateTask.setAssignee(\u0026#34;小明\u0026#34;); }else { delegateTask.setAssignee(\u0026#34;小李\u0026#34;); } } } 两个节点走的是同一个监听器\nxml定义中任务监听器的配置(两个节点都配置了) \u0026lt;?xml version=\u0026#34;1.0\u0026#34; encoding=\u0026#34;UTF-8\u0026#34;?\u0026gt; \u0026lt;definitions xmlns=\u0026#34;http://www.omg.org/spec/BPMN/20100524/MODEL\u0026#34; xmlns:xsi=\u0026#34;http://www.w3.org/2001/XMLSchema-instance\u0026#34; xmlns:xsd=\u0026#34;http://www.w3.org/2001/XMLSchema\u0026#34; xmlns:flowable=\u0026#34;http://flowable.org/bpmn\u0026#34; xmlns:bpmndi=\u0026#34;http://www.omg.org/spec/BPMN/20100524/DI\u0026#34; xmlns:omgdc=\u0026#34;http://www.omg.org/spec/DD/20100524/DC\u0026#34; xmlns:omgdi=\u0026#34;http://www.omg.org/spec/DD/20100524/DI\u0026#34; typeLanguage=\u0026#34;http://www.w3.org/2001/XMLSchema\u0026#34; expressionLanguage=\u0026#34;http://www.w3.org/1999/XPath\u0026#34; targetNamespace=\u0026#34;http://www.flowable.org/processdef\u0026#34; exporter=\u0026#34;Flowable Open Source Modeler\u0026#34; exporterVersion=\u0026#34;6.7.2\u0026#34;\u0026gt; \u0026lt;process id=\u0026#34;holiday-new\u0026#34; name=\u0026#34;新请假流程\u0026#34; isExecutable=\u0026#34;true\u0026#34;\u0026gt; \u0026lt;documentation\u0026gt;new-description\u0026lt;/documentation\u0026gt; \u0026lt;startEvent id=\u0026#34;startEvent1\u0026#34; flowable:formFieldValidation=\u0026#34;true\u0026#34;\u0026gt;\u0026lt;/startEvent\u0026gt; \u0026lt;userTask id=\u0026#34;sid-8D901410-5BD7-4EED-B988-5E40D12298C7\u0026#34; name=\u0026#34;创建请假流程\u0026#34; flowable:formFieldValidation=\u0026#34;true\u0026#34;\u0026gt; \u0026lt;extensionElements\u0026gt; \u0026lt;flowable:taskListener event=\u0026#34;create\u0026#34; class=\u0026#34;org.flowable.listener.MyTaskListener\u0026#34;\u0026gt;\u0026lt;/flowable:taskListener\u0026gt; \u0026lt;/extensionElements\u0026gt; \u0026lt;/userTask\u0026gt; \u0026lt;userTask id=\u0026#34;sid-5EB8F68B-7876-42AF-98E1-FCA27F99D8CE\u0026#34; name=\u0026#34;审批请假流程\u0026#34; flowable:formFieldValidation=\u0026#34;true\u0026#34;\u0026gt; \u0026lt;extensionElements\u0026gt; \u0026lt;flowable:taskListener event=\u0026#34;create\u0026#34; class=\u0026#34;org.flowable.listener.MyTaskListener\u0026#34;\u0026gt;\u0026lt;/flowable:taskListener\u0026gt; \u0026lt;/extensionElements\u0026gt; \u0026lt;/userTask\u0026gt; \u0026lt;sequenceFlow id=\u0026#34;sid-631EFFB0-795A-4777-B49E-CF7D015BFF15\u0026#34; sourceRef=\u0026#34;sid-8D901410-5BD7-4EED-B988-5E40D12298C7\u0026#34; targetRef=\u0026#34;sid-5EB8F68B-7876-42AF-98E1-FCA27F99D8CE\u0026#34;\u0026gt;\u0026lt;/sequenceFlow\u0026gt; \u0026lt;sequenceFlow id=\u0026#34;sid-001CA567-6169-4F8A-A0E5-010721D52508\u0026#34; sourceRef=\u0026#34;sid-5EB8F68B-7876-42AF-98E1-FCA27F99D8CE\u0026#34; targetRef=\u0026#34;sid-15CAD0D3-7F8B-404C-9346-A8D2A456D47B\u0026#34;\u0026gt;\u0026lt;/sequenceFlow\u0026gt; \u0026lt;sequenceFlow id=\u0026#34;sid-0A4A52F2-ECF6-44B2-AA41-F926AA7F5932\u0026#34; sourceRef=\u0026#34;startEvent1\u0026#34; targetRef=\u0026#34;sid-8D901410-5BD7-4EED-B988-5E40D12298C7\u0026#34;\u0026gt;\u0026lt;/sequenceFlow\u0026gt; \u0026lt;endEvent id=\u0026#34;sid-15CAD0D3-7F8B-404C-9346-A8D2A456D47B\u0026#34;\u0026gt;\u0026lt;/endEvent\u0026gt; \u0026lt;/process\u0026gt; \u0026lt;bpmndi:BPMNDiagram id=\u0026#34;BPMNDiagram_holiday-new\u0026#34;\u0026gt; \u0026lt;bpmndi:BPMNPlane bpmnElement=\u0026#34;holiday-new\u0026#34; id=\u0026#34;BPMNPlane_holiday-new\u0026#34;\u0026gt; \u0026lt;bpmndi:BPMNShape bpmnElement=\u0026#34;startEvent1\u0026#34; id=\u0026#34;BPMNShape_startEvent1\u0026#34;\u0026gt; \u0026lt;omgdc:Bounds height=\u0026#34;30.0\u0026#34; width=\u0026#34;30.0\u0026#34; x=\u0026#34;100.0\u0026#34; y=\u0026#34;115.0\u0026#34;\u0026gt;\u0026lt;/omgdc:Bounds\u0026gt; \u0026lt;/bpmndi:BPMNShape\u0026gt; \u0026lt;bpmndi:BPMNShape bpmnElement=\u0026#34;sid-8D901410-5BD7-4EED-B988-5E40D12298C7\u0026#34; id=\u0026#34;BPMNShape_sid-8D901410-5BD7-4EED-B988-5E40D12298C7\u0026#34;\u0026gt; \u0026lt;omgdc:Bounds height=\u0026#34;80.0\u0026#34; width=\u0026#34;100.0\u0026#34; x=\u0026#34;195.0\u0026#34; y=\u0026#34;90.0\u0026#34;\u0026gt;\u0026lt;/omgdc:Bounds\u0026gt; \u0026lt;/bpmndi:BPMNShape\u0026gt; \u0026lt;bpmndi:BPMNShape bpmnElement=\u0026#34;sid-5EB8F68B-7876-42AF-98E1-FCA27F99D8CE\u0026#34; id=\u0026#34;BPMNShape_sid-5EB8F68B-7876-42AF-98E1-FCA27F99D8CE\u0026#34;\u0026gt; \u0026lt;omgdc:Bounds height=\u0026#34;80.0\u0026#34; width=\u0026#34;100.0\u0026#34; x=\u0026#34;370.0\u0026#34; y=\u0026#34;90.0\u0026#34;\u0026gt;\u0026lt;/omgdc:Bounds\u0026gt; \u0026lt;/bpmndi:BPMNShape\u0026gt; \u0026lt;bpmndi:BPMNShape bpmnElement=\u0026#34;sid-15CAD0D3-7F8B-404C-9346-A8D2A456D47B\u0026#34; id=\u0026#34;BPMNShape_sid-15CAD0D3-7F8B-404C-9346-A8D2A456D47B\u0026#34;\u0026gt; \u0026lt;omgdc:Bounds height=\u0026#34;28.0\u0026#34; width=\u0026#34;28.0\u0026#34; x=\u0026#34;570.0\u0026#34; y=\u0026#34;116.0\u0026#34;\u0026gt;\u0026lt;/omgdc:Bounds\u0026gt; \u0026lt;/bpmndi:BPMNShape\u0026gt; \u0026lt;bpmndi:BPMNEdge bpmnElement=\u0026#34;sid-001CA567-6169-4F8A-A0E5-010721D52508\u0026#34; id=\u0026#34;BPMNEdge_sid-001CA567-6169-4F8A-A0E5-010721D52508\u0026#34; flowable:sourceDockerX=\u0026#34;50.0\u0026#34; flowable:sourceDockerY=\u0026#34;40.0\u0026#34; flowable:targetDockerX=\u0026#34;14.0\u0026#34; flowable:targetDockerY=\u0026#34;14.0\u0026#34;\u0026gt; \u0026lt;omgdi:waypoint x=\u0026#34;469.9499999999809\u0026#34; y=\u0026#34;130.0\u0026#34;\u0026gt;\u0026lt;/omgdi:waypoint\u0026gt; \u0026lt;omgdi:waypoint x=\u0026#34;570.0\u0026#34; y=\u0026#34;130.0\u0026#34;\u0026gt;\u0026lt;/omgdi:waypoint\u0026gt; \u0026lt;/bpmndi:BPMNEdge\u0026gt; \u0026lt;bpmndi:BPMNEdge bpmnElement=\u0026#34;sid-0A4A52F2-ECF6-44B2-AA41-F926AA7F5932\u0026#34; id=\u0026#34;BPMNEdge_sid-0A4A52F2-ECF6-44B2-AA41-F926AA7F5932\u0026#34; flowable:sourceDockerX=\u0026#34;15.0\u0026#34; flowable:sourceDockerY=\u0026#34;15.0\u0026#34; flowable:targetDockerX=\u0026#34;50.0\u0026#34; flowable:targetDockerY=\u0026#34;40.0\u0026#34;\u0026gt; \u0026lt;omgdi:waypoint x=\u0026#34;129.94999891869114\u0026#34; y=\u0026#34;130.0\u0026#34;\u0026gt;\u0026lt;/omgdi:waypoint\u0026gt; \u0026lt;omgdi:waypoint x=\u0026#34;195.0\u0026#34; y=\u0026#34;130.0\u0026#34;\u0026gt;\u0026lt;/omgdi:waypoint\u0026gt; \u0026lt;/bpmndi:BPMNEdge\u0026gt; \u0026lt;bpmndi:BPMNEdge bpmnElement=\u0026#34;sid-631EFFB0-795A-4777-B49E-CF7D015BFF15\u0026#34; id=\u0026#34;BPMNEdge_sid-631EFFB0-795A-4777-B49E-CF7D015BFF15\u0026#34; flowable:sourceDockerX=\u0026#34;50.0\u0026#34; flowable:sourceDockerY=\u0026#34;40.0\u0026#34; flowable:targetDockerX=\u0026#34;50.0\u0026#34; flowable:targetDockerY=\u0026#34;40.0\u0026#34;\u0026gt; \u0026lt;omgdi:waypoint x=\u0026#34;294.95000000000005\u0026#34; y=\u0026#34;130.0\u0026#34;\u0026gt;\u0026lt;/omgdi:waypoint\u0026gt; \u0026lt;omgdi:waypoint x=\u0026#34;369.99999999993753\u0026#34; y=\u0026#34;130.0\u0026#34;\u0026gt;\u0026lt;/omgdi:waypoint\u0026gt; \u0026lt;/bpmndi:BPMNEdge\u0026gt; \u0026lt;/bpmndi:BPMNPlane\u0026gt; \u0026lt;/bpmndi:BPMNDiagram\u0026gt; \u0026lt;/definitions\u0026gt; 之后将流程再重新部署一遍\n/** * 流程的部署 */ @Test public void testDeploy() { //获取ProcessEngine对象 ProcessEngine processEngine = configuration.buildProcessEngine(); //获取服务(repository,流程定义) RepositoryService repositoryService = processEngine.getRepositoryService(); Deployment deploy = repositoryService.createDeployment() .addClasspathResource(\u0026#34;新请假流程.bpmn20.xml\u0026#34;) .name(\u0026#34;请求流程\u0026#34;) //流程名 .deploy(); System.out.println(\u0026#34;部署id\u0026#34; + deploy.getId()); System.out.println(\u0026#34;部署名\u0026#34; + deploy.getName()); } 流程运行\n/** * 流程实例的启动 */ @Test public void testRunProcess3() { ProcessEngine engine = ProcessEngines.getDefaultProcessEngine(); RuntimeService runtimeService = engine.getRuntimeService(); ProcessInstance processInstance = runtimeService.startProcessInstanceById( \u0026#34;holiday-new:1:4\u0026#34;); System.out.println(processInstance); } 控制台查看 数据库查看 让小明处理任务\n@Test public void testComplete(){ ProcessEngine processEngine=ProcessEngines.getDefaultProcessEngine(); TaskService taskService = processEngine.getTaskService(); Task task = taskService.createTaskQuery().taskAssignee(\u0026#34;小明\u0026#34;) .processInstanceId(\u0026#34;2501\u0026#34;) .singleResult(); taskService.complete(task.getId()); } 数据库查看 流程变量 # 全局变量(跟流程有关)和局部变量(跟task有关)\n一个流程定义,可以运行多个流程实例; 当用到子流程时,就会出现一对多的关系 全局变量被重复赋值时后面会覆盖前面\n流程图的创建 这里还设置了条件,详见xm文件 sequenceFlow.conditionExpression 属性\n\u0026lt;?xml version=\u0026#34;1.0\u0026#34; encoding=\u0026#34;UTF-8\u0026#34;?\u0026gt; \u0026lt;definitions xmlns=\u0026#34;http://www.omg.org/spec/BPMN/20100524/MODEL\u0026#34; xmlns:xsi=\u0026#34;http://www.w3.org/2001/XMLSchema-instance\u0026#34; xmlns:xsd=\u0026#34;http://www.w3.org/2001/XMLSchema\u0026#34; xmlns:flowable=\u0026#34;http://flowable.org/bpmn\u0026#34; xmlns:bpmndi=\u0026#34;http://www.omg.org/spec/BPMN/20100524/DI\u0026#34; xmlns:omgdc=\u0026#34;http://www.omg.org/spec/DD/20100524/DC\u0026#34; xmlns:omgdi=\u0026#34;http://www.omg.org/spec/DD/20100524/DI\u0026#34; typeLanguage=\u0026#34;http://www.w3.org/2001/XMLSchema\u0026#34; expressionLanguage=\u0026#34;http://www.w3.org/1999/XPath\u0026#34; targetNamespace=\u0026#34;http://www.flowable.org/processdef\u0026#34; exporter=\u0026#34;Flowable Open Source Modeler\u0026#34; exporterVersion=\u0026#34;6.7.2\u0026#34;\u0026gt; \u0026lt;process id=\u0026#34;evection\u0026#34; name=\u0026#34;出差申请单\u0026#34; isExecutable=\u0026#34;true\u0026#34;\u0026gt; \u0026lt;documentation\u0026gt;出差申请单\u0026lt;/documentation\u0026gt; \u0026lt;startEvent id=\u0026#34;startEvent1\u0026#34; flowable:formFieldValidation=\u0026#34;true\u0026#34;\u0026gt;\u0026lt;/startEvent\u0026gt; \u0026lt;userTask id=\u0026#34;sid-BFB6D699-D3B5-4C6C-A0F2-00584EAAF207\u0026#34; name=\u0026#34;创建出差申请单\u0026#34; flowable:assignee=\u0026#34;${assignee0}\u0026#34; flowable:formFieldValidation=\u0026#34;true\u0026#34;\u0026gt; \u0026lt;extensionElements\u0026gt; \u0026lt;modeler:initiator-can-complete xmlns:modeler=\u0026#34;http://flowable.org/modeler\u0026#34;\u0026gt;\u0026lt;![CDATA[false]]\u0026gt;\u0026lt;/modeler:initiator-can-complete\u0026gt; \u0026lt;/extensionElements\u0026gt; \u0026lt;/userTask\u0026gt; \u0026lt;sequenceFlow id=\u0026#34;sid-EE410204-0433-4FE6-A958-48585A2A7B4B\u0026#34; sourceRef=\u0026#34;startEvent1\u0026#34; targetRef=\u0026#34;sid-BFB6D699-D3B5-4C6C-A0F2-00584EAAF207\u0026#34;\u0026gt;\u0026lt;/sequenceFlow\u0026gt; \u0026lt;userTask id=\u0026#34;sid-D10C4F45-B429-4E24-B474-5354F1661645\u0026#34; name=\u0026#34;部门经理审批\u0026#34; flowable:assignee=\u0026#34;${assignee1}\u0026#34; flowable:formFieldValidation=\u0026#34;true\u0026#34;\u0026gt; \u0026lt;extensionElements\u0026gt; \u0026lt;modeler:initiator-can-complete xmlns:modeler=\u0026#34;http://flowable.org/modeler\u0026#34;\u0026gt;\u0026lt;![CDATA[false]]\u0026gt;\u0026lt;/modeler:initiator-can-complete\u0026gt; \u0026lt;/extensionElements\u0026gt; \u0026lt;/userTask\u0026gt; \u0026lt;sequenceFlow id=\u0026#34;sid-752CE2F2-40EC-4140-AF60-BEACD06D43A7\u0026#34; sourceRef=\u0026#34;sid-BFB6D699-D3B5-4C6C-A0F2-00584EAAF207\u0026#34; targetRef=\u0026#34;sid-D10C4F45-B429-4E24-B474-5354F1661645\u0026#34;\u0026gt;\u0026lt;/sequenceFlow\u0026gt; \u0026lt;userTask id=\u0026#34;sid-35AB278B-E16D-4CEC-98B1-FBB139FB5AC1\u0026#34; name=\u0026#34;总经理审批\u0026#34; flowable:assignee=\u0026#34;${assignee2}\u0026#34; flowable:formFieldValidation=\u0026#34;true\u0026#34;\u0026gt; \u0026lt;extensionElements\u0026gt; \u0026lt;modeler:initiator-can-complete xmlns:modeler=\u0026#34;http://flowable.org/modeler\u0026#34;\u0026gt;\u0026lt;![CDATA[false]]\u0026gt;\u0026lt;/modeler:initiator-can-complete\u0026gt; \u0026lt;/extensionElements\u0026gt; \u0026lt;/userTask\u0026gt; \u0026lt;userTask id=\u0026#34;sid-4C26DA5C-A4CC-48A5-ABA9-853E82FC2413\u0026#34; name=\u0026#34;财务审批 \u0026#34; flowable:assignee=\u0026#34;${assignee3}\u0026#34; flowable:formFieldValidation=\u0026#34;true\u0026#34;\u0026gt; \u0026lt;extensionElements\u0026gt; \u0026lt;modeler:initiator-can-complete xmlns:modeler=\u0026#34;http://flowable.org/modeler\u0026#34;\u0026gt;\u0026lt;![CDATA[false]]\u0026gt;\u0026lt;/modeler:initiator-can-complete\u0026gt; \u0026lt;/extensionElements\u0026gt; \u0026lt;/userTask\u0026gt; \u0026lt;sequenceFlow id=\u0026#34;sid-BE043A23-0F38-4ED9-A0D1-F4C2F7908A50\u0026#34; sourceRef=\u0026#34;sid-35AB278B-E16D-4CEC-98B1-FBB139FB5AC1\u0026#34; targetRef=\u0026#34;sid-4C26DA5C-A4CC-48A5-ABA9-853E82FC2413\u0026#34;\u0026gt;\u0026lt;/sequenceFlow\u0026gt; \u0026lt;endEvent id=\u0026#34;sid-B3A1D5D4-E1FD-4599-A482-762C7C617844\u0026#34;\u0026gt;\u0026lt;/endEvent\u0026gt; \u0026lt;sequenceFlow id=\u0026#34;sid-6C0130A8-E078-486B-9B6E-D8C14BBCD8EF\u0026#34; sourceRef=\u0026#34;sid-4C26DA5C-A4CC-48A5-ABA9-853E82FC2413\u0026#34; targetRef=\u0026#34;sid-B3A1D5D4-E1FD-4599-A482-762C7C617844\u0026#34;\u0026gt;\u0026lt;/sequenceFlow\u0026gt; \u0026lt;sequenceFlow id=\u0026#34;sid-F85B2D44-1B42-4748-AB35-123C7CCD2F75\u0026#34; sourceRef=\u0026#34;sid-D10C4F45-B429-4E24-B474-5354F1661645\u0026#34; targetRef=\u0026#34;sid-35AB278B-E16D-4CEC-98B1-FBB139FB5AC1\u0026#34;\u0026gt; \u0026lt;conditionExpression xsi:type=\u0026#34;tFormalExpression\u0026#34;\u0026gt;\u0026lt;![CDATA[${num \u0026gt;= 3}]]\u0026gt;\u0026lt;/conditionExpression\u0026gt; \u0026lt;/sequenceFlow\u0026gt; \u0026lt;sequenceFlow id=\u0026#34;sid-B12793A8-FC65-408C-81AD-EC81FEEF6E46\u0026#34; sourceRef=\u0026#34;sid-D10C4F45-B429-4E24-B474-5354F1661645\u0026#34; targetRef=\u0026#34;sid-4C26DA5C-A4CC-48A5-ABA9-853E82FC2413\u0026#34;\u0026gt; \u0026lt;conditionExpression xsi:type=\u0026#34;tFormalExpression\u0026#34;\u0026gt;\u0026lt;![CDATA[${num \u0026lt; 3}]]\u0026gt;\u0026lt;/conditionExpression\u0026gt; \u0026lt;/sequenceFlow\u0026gt; \u0026lt;/process\u0026gt; \u0026lt;bpmndi:BPMNDiagram id=\u0026#34;BPMNDiagram_evection\u0026#34;\u0026gt; \u0026lt;bpmndi:BPMNPlane bpmnElement=\u0026#34;evection\u0026#34; id=\u0026#34;BPMNPlane_evection\u0026#34;\u0026gt; \u0026lt;bpmndi:BPMNShape bpmnElement=\u0026#34;startEvent1\u0026#34; id=\u0026#34;BPMNShape_startEvent1\u0026#34;\u0026gt; \u0026lt;omgdc:Bounds height=\u0026#34;30.0\u0026#34; width=\u0026#34;30.0\u0026#34; x=\u0026#34;100.0\u0026#34; y=\u0026#34;75.0\u0026#34;\u0026gt;\u0026lt;/omgdc:Bounds\u0026gt; \u0026lt;/bpmndi:BPMNShape\u0026gt; \u0026lt;bpmndi:BPMNShape bpmnElement=\u0026#34;sid-BFB6D699-D3B5-4C6C-A0F2-00584EAAF207\u0026#34; id=\u0026#34;BPMNShape_sid-BFB6D699-D3B5-4C6C-A0F2-00584EAAF207\u0026#34;\u0026gt; \u0026lt;omgdc:Bounds height=\u0026#34;80.0\u0026#34; width=\u0026#34;100.0\u0026#34; x=\u0026#34;175.0\u0026#34; y=\u0026#34;50.0\u0026#34;\u0026gt;\u0026lt;/omgdc:Bounds\u0026gt; \u0026lt;/bpmndi:BPMNShape\u0026gt; \u0026lt;bpmndi:BPMNShape bpmnElement=\u0026#34;sid-D10C4F45-B429-4E24-B474-5354F1661645\u0026#34; id=\u0026#34;BPMNShape_sid-D10C4F45-B429-4E24-B474-5354F1661645\u0026#34;\u0026gt; \u0026lt;omgdc:Bounds height=\u0026#34;80.0\u0026#34; width=\u0026#34;100.0\u0026#34; x=\u0026#34;320.0\u0026#34; y=\u0026#34;50.0\u0026#34;\u0026gt;\u0026lt;/omgdc:Bounds\u0026gt; \u0026lt;/bpmndi:BPMNShape\u0026gt; \u0026lt;bpmndi:BPMNShape bpmnElement=\u0026#34;sid-35AB278B-E16D-4CEC-98B1-FBB139FB5AC1\u0026#34; id=\u0026#34;BPMNShape_sid-35AB278B-E16D-4CEC-98B1-FBB139FB5AC1\u0026#34;\u0026gt; \u0026lt;omgdc:Bounds height=\u0026#34;80.0\u0026#34; width=\u0026#34;100.0\u0026#34; x=\u0026#34;555.0\u0026#34; y=\u0026#34;50.0\u0026#34;\u0026gt;\u0026lt;/omgdc:Bounds\u0026gt; \u0026lt;/bpmndi:BPMNShape\u0026gt; \u0026lt;bpmndi:BPMNShape bpmnElement=\u0026#34;sid-4C26DA5C-A4CC-48A5-ABA9-853E82FC2413\u0026#34; id=\u0026#34;BPMNShape_sid-4C26DA5C-A4CC-48A5-ABA9-853E82FC2413\u0026#34;\u0026gt; \u0026lt;omgdc:Bounds height=\u0026#34;80.0\u0026#34; width=\u0026#34;100.0\u0026#34; x=\u0026#34;555.0\u0026#34; y=\u0026#34;210.0\u0026#34;\u0026gt;\u0026lt;/omgdc:Bounds\u0026gt; \u0026lt;/bpmndi:BPMNShape\u0026gt; \u0026lt;bpmndi:BPMNShape bpmnElement=\u0026#34;sid-B3A1D5D4-E1FD-4599-A482-762C7C617844\u0026#34; id=\u0026#34;BPMNShape_sid-B3A1D5D4-E1FD-4599-A482-762C7C617844\u0026#34;\u0026gt; \u0026lt;omgdc:Bounds height=\u0026#34;28.0\u0026#34; width=\u0026#34;28.0\u0026#34; x=\u0026#34;750.0\u0026#34; y=\u0026#34;236.0\u0026#34;\u0026gt;\u0026lt;/omgdc:Bounds\u0026gt; \u0026lt;/bpmndi:BPMNShape\u0026gt; \u0026lt;bpmndi:BPMNEdge bpmnElement=\u0026#34;sid-EE410204-0433-4FE6-A958-48585A2A7B4B\u0026#34; id=\u0026#34;BPMNEdge_sid-EE410204-0433-4FE6-A958-48585A2A7B4B\u0026#34; flowable:sourceDockerX=\u0026#34;15.0\u0026#34; flowable:sourceDockerY=\u0026#34;15.0\u0026#34; flowable:targetDockerX=\u0026#34;50.0\u0026#34; flowable:targetDockerY=\u0026#34;40.0\u0026#34;\u0026gt; \u0026lt;omgdi:waypoint x=\u0026#34;129.9499984899576\u0026#34; y=\u0026#34;90.0\u0026#34;\u0026gt;\u0026lt;/omgdi:waypoint\u0026gt; \u0026lt;omgdi:waypoint x=\u0026#34;175.0\u0026#34; y=\u0026#34;90.0\u0026#34;\u0026gt;\u0026lt;/omgdi:waypoint\u0026gt; \u0026lt;/bpmndi:BPMNEdge\u0026gt; \u0026lt;bpmndi:BPMNEdge bpmnElement=\u0026#34;sid-752CE2F2-40EC-4140-AF60-BEACD06D43A7\u0026#34; id=\u0026#34;BPMNEdge_sid-752CE2F2-40EC-4140-AF60-BEACD06D43A7\u0026#34; flowable:sourceDockerX=\u0026#34;50.0\u0026#34; flowable:sourceDockerY=\u0026#34;40.0\u0026#34; flowable:targetDockerX=\u0026#34;50.0\u0026#34; flowable:targetDockerY=\u0026#34;40.0\u0026#34;\u0026gt; \u0026lt;omgdi:waypoint x=\u0026#34;274.95000000000005\u0026#34; y=\u0026#34;90.0\u0026#34;\u0026gt;\u0026lt;/omgdi:waypoint\u0026gt; \u0026lt;omgdi:waypoint x=\u0026#34;320.0\u0026#34; y=\u0026#34;90.0\u0026#34;\u0026gt;\u0026lt;/omgdi:waypoint\u0026gt; \u0026lt;/bpmndi:BPMNEdge\u0026gt; \u0026lt;bpmndi:BPMNEdge bpmnElement=\u0026#34;sid-B12793A8-FC65-408C-81AD-EC81FEEF6E46\u0026#34; id=\u0026#34;BPMNEdge_sid-B12793A8-FC65-408C-81AD-EC81FEEF6E46\u0026#34; flowable:sourceDockerX=\u0026#34;50.0\u0026#34; flowable:sourceDockerY=\u0026#34;40.0\u0026#34; flowable:targetDockerX=\u0026#34;50.0\u0026#34; flowable:targetDockerY=\u0026#34;40.0\u0026#34;\u0026gt; \u0026lt;omgdi:waypoint x=\u0026#34;419.95000000000005\u0026#34; y=\u0026#34;124.0085106382979\u0026#34;\u0026gt;\u0026lt;/omgdi:waypoint\u0026gt; \u0026lt;omgdi:waypoint x=\u0026#34;555.0\u0026#34; y=\u0026#34;215.95744680851067\u0026#34;\u0026gt;\u0026lt;/omgdi:waypoint\u0026gt; \u0026lt;/bpmndi:BPMNEdge\u0026gt; \u0026lt;bpmndi:BPMNEdge bpmnElement=\u0026#34;sid-6C0130A8-E078-486B-9B6E-D8C14BBCD8EF\u0026#34; id=\u0026#34;BPMNEdge_sid-6C0130A8-E078-486B-9B6E-D8C14BBCD8EF\u0026#34; flowable:sourceDockerX=\u0026#34;50.0\u0026#34; flowable:sourceDockerY=\u0026#34;40.0\u0026#34; flowable:targetDockerX=\u0026#34;14.0\u0026#34; flowable:targetDockerY=\u0026#34;14.0\u0026#34;\u0026gt; \u0026lt;omgdi:waypoint x=\u0026#34;654.9499999998701\u0026#34; y=\u0026#34;250.0\u0026#34;\u0026gt;\u0026lt;/omgdi:waypoint\u0026gt; \u0026lt;omgdi:waypoint x=\u0026#34;750.0\u0026#34; y=\u0026#34;250.0\u0026#34;\u0026gt;\u0026lt;/omgdi:waypoint\u0026gt; \u0026lt;/bpmndi:BPMNEdge\u0026gt; \u0026lt;bpmndi:BPMNEdge bpmnElement=\u0026#34;sid-BE043A23-0F38-4ED9-A0D1-F4C2F7908A50\u0026#34; id=\u0026#34;BPMNEdge_sid-BE043A23-0F38-4ED9-A0D1-F4C2F7908A50\u0026#34; flowable:sourceDockerX=\u0026#34;50.0\u0026#34; flowable:sourceDockerY=\u0026#34;40.0\u0026#34; flowable:targetDockerX=\u0026#34;50.0\u0026#34; flowable:targetDockerY=\u0026#34;40.0\u0026#34;\u0026gt; \u0026lt;omgdi:waypoint x=\u0026#34;605.0\u0026#34; y=\u0026#34;129.95\u0026#34;\u0026gt;\u0026lt;/omgdi:waypoint\u0026gt; \u0026lt;omgdi:waypoint x=\u0026#34;605.0\u0026#34; y=\u0026#34;210.0\u0026#34;\u0026gt;\u0026lt;/omgdi:waypoint\u0026gt; \u0026lt;/bpmndi:BPMNEdge\u0026gt; \u0026lt;bpmndi:BPMNEdge bpmnElement=\u0026#34;sid-F85B2D44-1B42-4748-AB35-123C7CCD2F75\u0026#34; id=\u0026#34;BPMNEdge_sid-F85B2D44-1B42-4748-AB35-123C7CCD2F75\u0026#34; flowable:sourceDockerX=\u0026#34;50.0\u0026#34; flowable:sourceDockerY=\u0026#34;40.0\u0026#34; flowable:targetDockerX=\u0026#34;50.0\u0026#34; flowable:targetDockerY=\u0026#34;40.0\u0026#34;\u0026gt; \u0026lt;omgdi:waypoint x=\u0026#34;419.95000000000005\u0026#34; y=\u0026#34;90.0\u0026#34;\u0026gt;\u0026lt;/omgdi:waypoint\u0026gt; \u0026lt;omgdi:waypoint x=\u0026#34;555.0\u0026#34; y=\u0026#34;90.0\u0026#34;\u0026gt;\u0026lt;/omgdi:waypoint\u0026gt; \u0026lt;/bpmndi:BPMNEdge\u0026gt; \u0026lt;/bpmndi:BPMNPlane\u0026gt; \u0026lt;/bpmndi:BPMNDiagram\u0026gt; \u0026lt;/definitions\u0026gt; 流程进行部署\n/** * 流程的部署 */ @Test public void testDeploy() { //获取ProcessEngine对象 ProcessEngine processEngine = ProcessEngines.getDefaultProcessEngine(); //获取服务(repository,流程定义) RepositoryService repositoryService = processEngine.getRepositoryService(); Deployment deploy = repositoryService.createDeployment() .addClasspathResource(\u0026#34;出差申请单.bpmn20.xml\u0026#34;) .name(\u0026#34;请假流程\u0026#34;) //流程名 .deploy(); System.out.println(\u0026#34;部署id\u0026#34; + deploy.getId()); System.out.println(\u0026#34;部署名\u0026#34; + deploy.getName()); } 流程运行\n/** * 流程实例的定义 */ @Test public void runProcess(){ ProcessEngine processEngine=ProcessEngines.getDefaultProcessEngine(); RuntimeService runtimeService = processEngine.getRuntimeService(); Map\u0026lt;String,Object\u0026gt; variables=new HashMap\u0026lt;\u0026gt;(); variables.put(\u0026#34;assignee0\u0026#34;,\u0026#34;张三\u0026#34;); variables.put(\u0026#34;assignee1\u0026#34;,\u0026#34;李四\u0026#34;); variables.put(\u0026#34;assignee2\u0026#34;,\u0026#34;王五\u0026#34;); variables.put(\u0026#34;assignee3\u0026#34;,\u0026#34;赵财务\u0026#34;); ProcessInstance processInstance = runtimeService. startProcessInstanceById(\u0026#34;evection:1:4\u0026#34;, variables); } //这时候节点走到张三了,让张三处理\n/** * 任务完成 */ @Test public void taskComplete(){ ProcessEngine processEngine=ProcessEngines.getDefaultProcessEngine(); TaskService taskService = processEngine.getTaskService(); Task task = taskService.createTaskQuery() .processInstanceId(\u0026#34;2501\u0026#34;) .taskAssignee(\u0026#34;张三\u0026#34;) .singleResult(); Map\u0026lt;String,Object\u0026gt; processVariables=task.getProcessVariables(); processVariables.put(\u0026#34;num\u0026#34;,3); taskService.complete(task.getId(),processVariables); } 下面修改num的值,修改之前 全局变量的查询\n@Test public void getVariables(){ ProcessEngine processEngine=ProcessEngines.getDefaultProcessEngine(); TaskService taskService = processEngine.getTaskService(); Task task = taskService.createTaskQuery() .includeProcessVariables() //注意,这个一定要加的不然获取不到全局变量 .processInstanceId(\u0026#34;2501\u0026#34;) .taskAssignee(\u0026#34;张三\u0026#34;) .singleResult(); //这里只能获取到任务的局部变量 Map\u0026lt;String, Object\u0026gt; processVariables = task.getProcessVariables(); System.out.println(\u0026#34;当前流程变量--start\u0026#34;); Set\u0026lt;String\u0026gt; keySet1 = processVariables.keySet(); for(String key:keySet1){ System.out.println(\u0026#34;key--\u0026#34;+key+\u0026#34;value--\u0026#34;+processVariables.get(key)); } System.out.println(\u0026#34;当前流程变量--end\u0026#34;); } 修改\n@Test public void updateVariables(){ ProcessEngine processEngine=ProcessEngines.getDefaultProcessEngine(); TaskService taskService = processEngine.getTaskService(); Task task = taskService.createTaskQuery() .includeProcessVariables() //注意,这个一定要加的不然获取不到全局变量 .processInstanceId(\u0026#34;2501\u0026#34;) .taskAssignee(\u0026#34;李四\u0026#34;) .singleResult(); Map\u0026lt;String, Object\u0026gt; processVariables = task.getProcessVariables(); System.out.println(\u0026#34;当前流程变量--start\u0026#34;); Set\u0026lt;String\u0026gt; keySet = processVariables.keySet(); for(String key:keySet){ System.out.println(\u0026#34;key--\u0026#34;+key+\u0026#34;value--\u0026#34;+processVariables.get(key)); } System.out.println(\u0026#34;当前流程变量--end\u0026#34;); processVariables.put(\u0026#34;num\u0026#34;,5); taskService.setVariablesLocal(task.getId(),processVariables); } 结果\n按照视频的说法,这里错了,应该是会多了5条记录 局部变量的再次测试\n@Test public void updateVariables(){ ProcessEngine processEngine=ProcessEngines.getDefaultProcessEngine(); TaskService taskService = processEngine.getTaskService(); Task task = taskService.createTaskQuery() .includeProcessVariables() .processInstanceId(\u0026#34;2501\u0026#34;) .taskAssignee(\u0026#34;张三\u0026#34;) .singleResult(); //流程还没开始运行的情况下,取到的是全局变量 Map\u0026lt;String, Object\u0026gt; processVariables = task.getProcessVariables(); System.out.println(\u0026#34;当前流程变量--start\u0026#34;); Set\u0026lt;String\u0026gt; keySet = processVariables.keySet(); for(String key:keySet){ System.out.println(\u0026#34;key--\u0026#34;+key+\u0026#34;value--\u0026#34;+processVariables.get(key)); } System.out.println(\u0026#34;当前流程变量--end\u0026#34;); Map\u0026lt;String,Object\u0026gt; varLocalInsert=new HashMap\u0026lt;\u0026gt;(); varLocalInsert.put(\u0026#34;num\u0026#34;,5); Map\u0026lt;String,Object\u0026gt; varUpdate=new HashMap\u0026lt;\u0026gt;(); varUpdate.put(\u0026#34;a\u0026#34;,\u0026#34;嘿嘿\u0026#34;); //这里测试会不会把全局变量全部覆盖 taskService.setVariables(task.getId(),varUpdate); taskService.setVariablesLocal(task.getId(),varLocalInsert); } 修改前 修改后 结果表明这是批量增加/修改,而不是覆盖 当前数据库的数据 1个局部变量num,5个全局变量 接下来在张三节点设置一个局部变量\n/** * 任务完成 */ @Test public void taskComplete(){ ProcessEngine processEngine=ProcessEngines.getDefaultProcessEngine(); TaskService taskService = processEngine.getTaskService(); Task task = taskService.createTaskQuery() .processInstanceId(\u0026#34;2501\u0026#34;) .taskAssignee(\u0026#34;张三\u0026#34;) .singleResult(); Map\u0026lt;String,Object\u0026gt; processVariables=task.getProcessVariables(); processVariables.put(\u0026#34;num\u0026#34;,2); taskService.complete(task.getId(),processVariables); } 查看数据库表,发现num已经被修改成2 这时李四设置了一个局部变量num=6\n@Test public void updateVariables2(){ ProcessEngine processEngine=ProcessEngines.getDefaultProcessEngine(); TaskService taskService = processEngine.getTaskService(); Task task = taskService.createTaskQuery() .includeProcessVariables() .processInstanceId(\u0026#34;2501\u0026#34;) .taskAssignee(\u0026#34;李四\u0026#34;) .singleResult(); Map\u0026lt;String,Object\u0026gt; varLocalInsert=new HashMap\u0026lt;\u0026gt;(); varLocalInsert.put(\u0026#34;num\u0026#34;,6); Map\u0026lt;String,Object\u0026gt; varUpdate=new HashMap\u0026lt;\u0026gt;(); varUpdate.put(\u0026#34;a\u0026#34;,\u0026#34;嘿嘿\u0026#34;); //这里测试会不会把全局变量全部覆盖 //taskService.setVariables(task.getId(),varUpdate); taskService.setVariablesLocal(task.getId(),varLocalInsert); } 仅仅多了一条记录 修改全局变量\n@Test public void updateVariables3(){ ProcessEngine processEngine=ProcessEngines.getDefaultProcessEngine(); TaskService taskService = processEngine.getTaskService(); Task task = taskService.createTaskQuery() .includeProcessVariables() .processInstanceId(\u0026#34;2501\u0026#34;) .taskAssignee(\u0026#34;李四\u0026#34;) .singleResult(); Map\u0026lt;String,Object\u0026gt; varLocalInsert=new HashMap\u0026lt;\u0026gt;(); varLocalInsert.put(\u0026#34;num\u0026#34;,18); varLocalInsert.put(\u0026#34;a\u0026#34;,\u0026#34;a被修改了\u0026#34;); //这里测试会不会把全局变量全部覆盖 //taskService.setVariables(task.getId(),varUpdate); taskService.setVariables(task.getId(),varLocalInsert); } 结果如下,当局部变量和全局变量的名称一样时,只能修改局部变量 让李四完成审批 这里存在局部变量num=18,且完成时设置了局部变量20\n@Test public void taskComplete4(){ ProcessEngine processEngine=ProcessEngines.getDefaultProcessEngine(); TaskService taskService = processEngine.getTaskService(); Task task = taskService.createTaskQuery() .includeProcessVariables() .processInstanceId(\u0026#34;2501\u0026#34;) .taskAssignee(\u0026#34;李四\u0026#34;) .singleResult(); // System.out.println(\u0026#34;taskId\u0026#34;+task.getId()); Map\u0026lt;String,Object\u0026gt; map=new HashMap\u0026lt;\u0026gt;(); map.put(\u0026#34;num\u0026#34;,20); taskService.complete(task.getId(),map); } 注意,这里全局变量被改成20了,局部变量被删除了 走到了总经理审批\n再测试 将数据清空,重新部署并运行流程\n现在在赵四节点,局部变量为 @Test public void taskComplete4(){ ProcessEngine processEngine=ProcessEngines.getDefaultProcessEngine(); TaskService taskService = processEngine.getTaskService(); Task task = taskService.createTaskQuery() .includeProcessVariables() .processInstanceId(\u0026#34;2501\u0026#34;) .taskAssignee(\u0026#34;李四\u0026#34;) .singleResult(); // System.out.println(\u0026#34;taskId\u0026#34;+task.getId()); Map\u0026lt;String,Object\u0026gt; map=new HashMap\u0026lt;\u0026gt;(); map.put(\u0026#34;num\u0026#34;,20); taskService.setVariablesLocal(task.getId(),map); taskService.complete(task.getId()); } 运行完之后,局部变量变成20了,但是流程走不下去 稍作更改,添加一个全局变量(但是由于存在局部变量a,所以这里全局变量没设置成功)\n@Test public void taskComplete4(){ ProcessEngine processEngine=ProcessEngines.getDefaultProcessEngine(); TaskService taskService = processEngine.getTaskService(); Task task = taskService.createTaskQuery() .includeProcessVariables() .processInstanceId(\u0026#34;2501\u0026#34;) .taskAssignee(\u0026#34;李四\u0026#34;) .singleResult(); // System.out.println(\u0026#34;taskId\u0026#34;+task.getId()); Map\u0026lt;String,Object\u0026gt; map=new HashMap\u0026lt;\u0026gt;(); map.put(\u0026#34;num\u0026#34;,20); taskService.setVariablesLocal(task.getId(),map); Map\u0026lt;String,Object\u0026gt; map1=new HashMap\u0026lt;\u0026gt;(); map1.put(\u0026#34;num\u0026#34;,1); taskService.setVariables(task.getId(),map1); taskService.complete(task.getId()); } 现在只能通过在complete中设置,来使得全局变量生效\n@Test public void taskComplete4(){ ProcessEngine processEngine=ProcessEngines.getDefaultProcessEngine(); TaskService taskService = processEngine.getTaskService(); Task task = taskService.createTaskQuery() .includeProcessVariables() .processInstanceId(\u0026#34;2501\u0026#34;) .taskAssignee(\u0026#34;李四\u0026#34;) .singleResult(); // System.out.println(\u0026#34;taskId\u0026#34;+task.getId()); Map\u0026lt;String,Object\u0026gt; map=new HashMap\u0026lt;\u0026gt;(); map.put(\u0026#34;num\u0026#34;,null); taskService.setVariablesLocal(task.getId(),map); Map\u0026lt;String,Object\u0026gt; map1=new HashMap\u0026lt;\u0026gt;(); map1.put(\u0026#34;num\u0026#34;,1); //taskService.setVariables(task.getId(),map1); taskService.complete(task.getId(),map1); } 结果,全局变量设置成功,且任务流转到了财务那 再测试\n在存在局部变量num=2的情况下执行下面代码\n@Test public void taskComplete5() { ProcessEngine processEngine = ProcessEngines.getDefaultProcessEngine(); TaskService taskService = processEngine.getTaskService(); Task task = taskService.createTaskQuery() .includeProcessVariables() .processInstanceId(\u0026#34;2501\u0026#34;) .taskAssignee(\u0026#34;李四\u0026#34;) .singleResult(); // System.out.println(\u0026#34;taskId\u0026#34; + task.getId()); Map\u0026lt;String, Object\u0026gt; map = new HashMap\u0026lt;\u0026gt;(); map.put(\u0026#34;num\u0026#34;, 15); taskService.setVariables(task.getId(), map); taskService.complete(task.getId()); /*Map\u0026lt;String,Object\u0026gt; map1=new HashMap\u0026lt;\u0026gt;(); map1.put(\u0026#34;num\u0026#34;,1); taskService.complete(task.getId(),map1);*/ } 会提示报错,Unknown property used in expression: ${num \u0026gt;= 3}\n//说明线条中查找的是全局变量\n在不存在局部变量num的情况下执行上面代码,会走总经理审批(num\u0026gt;3)\n在complete中加上map参数,验证明线条查找的是全局变量的值,complete带上variables会设置全局变量\n@Test public void taskComplete5() { ProcessEngine processEngine = ProcessEngines.getDefaultProcessEngine(); TaskService taskService = processEngine.getTaskService(); Task task = taskService.createTaskQuery() .includeProcessVariables() .processInstanceId(\u0026#34;2501\u0026#34;) .taskAssignee(\u0026#34;李四\u0026#34;) .singleResult(); // System.out.println(\u0026#34;taskId\u0026#34; + task.getId()); Map\u0026lt;String, Object\u0026gt; map = new HashMap\u0026lt;\u0026gt;(); map.put(\u0026#34;num\u0026#34;, 15); // taskService.setVariables(task.getId(), map); taskService.complete(task.getId(),map); /*Map\u0026lt;String,Object\u0026gt; map1=new HashMap\u0026lt;\u0026gt;(); map1.put(\u0026#34;num\u0026#34;,1); taskService.complete(task.getId(),map1);*/ } 数据库表 act_hi_varinst 里面看得到局部变量\n"},{"id":390,"href":"/zh/docs/technology/Flowable/boge_blbl_/02-advance_2/","title":"boge-02-flowable进阶_2","section":"基础(波哥)_","content":" Service服务接口 # 各个Service类 RepositoryService 资源管理类,流程定义、部署、文件 RuntimeService 流程运行管理类,运行过程中(执行) TaskService 任务管理类 HistoryService 历史管理类 ManagerService 引擎管理类 Flowable图标 # BPMN2.0定义的一些图标\n时间 活动 网关 流程部署深入解析 # 使用eclipse打包部署(没有eclipse环境,所以这里只有截图) 将两个流程,打包为bar文件,然后放到项目resources文件夹中 这里是为了测试一次部署多个流程(定义,图) 代码如下 部署完成后查看表结构\nact_re_procdef\n部署id一样 act_re_deployment 结论:部署和定义是1对多的关系\n每次部署所涉及到的资源文件 涉及到的三张表\nact_ge_bytearray act_re_procdef category\u0026ndash;\u0026gt;xml中的namespace name\u0026ndash;\u0026gt;定义时起的名称 key_\u0026mdash;\u0026gt;xml中定义的id resource_name\u0026mdash;\u0026gt;xml文件名称 dgrm_resource_name\u0026ndash;\u0026gt;生成图片名称 suspension_state \u0026ndash;\u0026gt; 是否被挂起\ntenant_id \u0026ndash; \u0026gt;谁部署的流程\nact_re_deployment name_部署名\n代码 主要源码 DeployCmd.class DeploymentEntityManagerImpl.java insert()方法 插入并执行资源 点开里面的insert方法 AbstractDataManger.insert() 回到test类,deploy()方法最终就是完成了表结构的数据的操作(通过Mybatis)\n流程的挂起和激活 # xml文件\n\u0026lt;?xml version=\u0026#34;1.0\u0026#34; encoding=\u0026#34;UTF-8\u0026#34;?\u0026gt; \u0026lt;definitions xmlns=\u0026#34;http://www.omg.org/spec/BPMN/20100524/MODEL\u0026#34; xmlns:xsi=\u0026#34;http://www.w3.org/2001/XMLSchema-instance\u0026#34; xmlns:xsd=\u0026#34;http://www.w3.org/2001/XMLSchema\u0026#34; xmlns:bpmndi=\u0026#34;http://www.omg.org/spec/BPMN/20100524/DI\u0026#34; xmlns:omgdc=\u0026#34;http://www.omg.org/spec/DD/20100524/DC\u0026#34; xmlns:omgdi=\u0026#34;http://www.omg.org/spec/DD/20100524/DI\u0026#34; xmlns:flowable=\u0026#34;http://flowable.org/bpmn\u0026#34; typeLanguage=\u0026#34;http://www.w3.org/2001/XMLSchema\u0026#34; expressionLanguage=\u0026#34;http://www.w3.org/1999/XPath\u0026#34; targetNamespace=\u0026#34;http://www.flowable.org/processdef\u0026#34;\u0026gt; \u0026lt;!--id process key--\u0026gt; \u0026lt;process id=\u0026#34;holidayRequest\u0026#34; name=\u0026#34;请假流程\u0026#34; isExecutable=\u0026#34;true\u0026#34;\u0026gt; \u0026lt;startEvent id=\u0026#34;startEvent\u0026#34;/\u0026gt; \u0026lt;!--sequenceFlow表示的是线条箭头--\u0026gt; \u0026lt;sequenceFlow sourceRef=\u0026#34;startEvent\u0026#34; targetRef=\u0026#34;approveTask\u0026#34;/\u0026gt; \u0026lt;userTask id=\u0026#34;approveTask\u0026#34; name=\u0026#34;同意或者拒绝请假\u0026#34; flowable:assignee=\u0026#34;zhangsan\u0026#34;/\u0026gt; \u0026lt;sequenceFlow sourceRef=\u0026#34;approveTask\u0026#34; targetRef=\u0026#34;decision\u0026#34;/\u0026gt; \u0026lt;!--网关--\u0026gt; \u0026lt;exclusiveGateway id=\u0026#34;decision\u0026#34;/\u0026gt; \u0026lt;sequenceFlow sourceRef=\u0026#34;decision\u0026#34; targetRef=\u0026#34;externalSystemCall\u0026#34;\u0026gt; \u0026lt;!--条件--\u0026gt; \u0026lt;conditionExpression xsi:type=\u0026#34;tFormalExpression\u0026#34;\u0026gt; \u0026lt;![CDATA[ ${approved} ]]\u0026gt; \u0026lt;/conditionExpression\u0026gt; \u0026lt;/sequenceFlow\u0026gt; \u0026lt;sequenceFlow sourceRef=\u0026#34;decision\u0026#34; targetRef=\u0026#34;sendRejectionMail\u0026#34;\u0026gt; \u0026lt;!--条件--\u0026gt; \u0026lt;conditionExpression xsi:type=\u0026#34;tFormalExpression\u0026#34;\u0026gt; \u0026lt;![CDATA[ ${!approved} ]]\u0026gt; \u0026lt;/conditionExpression\u0026gt; \u0026lt;/sequenceFlow\u0026gt; \u0026lt;serviceTask id=\u0026#34;externalSystemCall\u0026#34; name=\u0026#34;Enter holidays in external system\u0026#34; flowable:class=\u0026#34;org.flowable.CallExternalSystemDelegate\u0026#34;/\u0026gt; \u0026lt;sequenceFlow sourceRef=\u0026#34;externalSystemCall\u0026#34; targetRef=\u0026#34;holidayApprovedTask\u0026#34;/\u0026gt; \u0026lt;userTask id=\u0026#34;holidayApprovedTask\u0026#34; name=\u0026#34;Holiday approved\u0026#34; flowable:assignee=\u0026#34;lisi\u0026#34;/\u0026gt; \u0026lt;sequenceFlow sourceRef=\u0026#34;holidayApprovedTask\u0026#34; targetRef=\u0026#34;approveEnd\u0026#34;/\u0026gt; \u0026lt;!--发送一个邮件--\u0026gt; \u0026lt;serviceTask id=\u0026#34;sendRejectionMail\u0026#34; name=\u0026#34;Send out rejection email\u0026#34; flowable:class=\u0026#34;org.flowable.SendRejectionMail\u0026#34;/\u0026gt; \u0026lt;sequenceFlow sourceRef=\u0026#34;sendRejectionMail\u0026#34; targetRef=\u0026#34;rejectEnd\u0026#34;/\u0026gt; \u0026lt;endEvent id=\u0026#34;approveEnd\u0026#34;/\u0026gt; \u0026lt;endEvent id=\u0026#34;rejectEnd\u0026#34;/\u0026gt; \u0026lt;/process\u0026gt; \u0026lt;/definitions\u0026gt; 部署的流程默认情况下为激活,如果不想使用该定义的流程,那么可以挂起该流程,当然该流程定义下边所有的流程实例全部暂停。\n流程定义被定义为挂起,该流程定义将不允许启动新的流程实例,且该流程定义下所有的流程实例将被全部挂起暂停执行\n表结构 act_re_procdef表中的SUSPENSION_STATE字段来表示1激活,2挂起\n挂起流程\n@Test public void testSuspend() { ProcessEngine engine = ProcessEngines.getDefaultProcessEngine(); RepositoryService repositoryService = engine.getRepositoryService(); //找到流程定义 ProcessDefinition processDefinition = repositoryService. createProcessDefinitionQuery().processDefinitionId(\u0026#34;holidayRequest:1:7503\u0026#34;) .singleResult(); //当前流程定义的状态 boolean suspended = processDefinition.isSuspended(); if (suspended) { //如果挂起则激活 System.out.println(\u0026#34;激活流程(定义)\u0026#34; + processDefinition.getId() + \u0026#34;name:\u0026#34; + processDefinition .getName()); repositoryService.activateProcessDefinitionById(processDefinition.getId()); } else { //如果激活则挂起 System.out.println(\u0026#34;挂起流程(定义)\u0026#34; + processDefinition.getId() + \u0026#34;name:\u0026#34; + processDefinition .getName()); repositoryService.suspendProcessDefinitionById(processDefinition.getId()); } } 执行后 如果这时启动流程\n/** * 流程运行 */ @Test public void testRunProcess() { ProcessEngine processEngine = ProcessEngines.getDefaultProcessEngine();//configuration.buildProcessEngine(); RuntimeService runtimeService = processEngine.getRuntimeService(); //这边模拟表单数据(表单数据有多种处理方式,这只是其中一种) Map\u0026lt;String, Object\u0026gt; map = new HashMap\u0026lt;\u0026gt;(); map.put(\u0026#34;employee\u0026#34;, \u0026#34;张三\u0026#34;); map.put(\u0026#34;nrOfHolidays\u0026#34;, 3); map.put(\u0026#34;description\u0026#34;, \u0026#34;工作累了想出去玩\u0026#34;); ProcessInstance holidayRequest = runtimeService.startProcessInstanceByKey( \u0026#34;holidayRequest\u0026#34;, map); System.out.println(\u0026#34;流程定义的id:\u0026#34; + holidayRequest.getProcessDefinitionId()); System.out.println(\u0026#34;当前活跃id:\u0026#34; + holidayRequest.getActivityId()); System.out.println(\u0026#34;流程运行id:\u0026#34; + holidayRequest.getId()); } 则会出现异常报错信息\norg.flowable.common.engine.api.FlowableException: Cannot start process instance. Process definition 请假流程 (id = holidayRequest:1:7503) is suspended 此时再运行一次testSuspend(),将流程定义激活,此时数据库act_re_procdef表中的SUSPENSION_STATE字段值为1 再运行testRunProcess(),流程正常启动 启动流程的原理 # 流程启动\n/** * 流程运行 */ @Test public void testRunProcess() { ProcessEngine processEngine = ProcessEngines.getDefaultProcessEngine();//configuration.buildProcessEngine(); RepositoryService repositoryService = processEngine.getRepositoryService(); Deployment deploy = repositoryService.createDeployment() .addClasspathResource(\u0026#34;holiday-request.bpmn20.xml\u0026#34;) .name(\u0026#34;ly05150817部署的请假流程\u0026#34;) .deploy(); //通过部署id查找流程定义 ProcessDefinition processDefinition = repositoryService.createProcessDefinitionQuery(). deploymentId(deploy.getId()) .singleResult(); RuntimeService runtimeService = processEngine.getRuntimeService(); //这边模拟表单数据(表单数据有多种处理方式,这只是其中一种) Map\u0026lt;String, Object\u0026gt; map = new HashMap\u0026lt;\u0026gt;(); map.put(\u0026#34;employee\u0026#34;, \u0026#34;张三\u0026#34;); map.put(\u0026#34;nrOfHolidays\u0026#34;, 3); map.put(\u0026#34;description\u0026#34;, \u0026#34;工作累了想出去玩\u0026#34;); ProcessInstance holidayRequest = runtimeService.startProcessInstanceById( processDefinition.getId(), \u0026#34;order1000\u0026#34;, map); System.out.println(\u0026#34;流程定义的id:\u0026#34; + holidayRequest.getProcessDefinitionId()); System.out.println(\u0026#34;当前活跃id:\u0026#34; + holidayRequest.getActivityId()); System.out.println(\u0026#34;流程运行id:\u0026#34; + holidayRequest.getId()); } 涉及到的表:(HI中也有对应的表)\nACT_RU_EXECUTION 运行时流程执行实例 当启动一个实例的时候,这里会有两个流程执行\nACT_RU_IDENTITYLINK 运行时用户关系信息\n记录流程实例当前所处的节点\n数据库表 有几种任务处理人的类型\npublic class IdentityLinkType { public static final String ASSIGNEE = \u0026#34;assignee\u0026#34;; //指派 public static final String CANDIDATE = \u0026#34;candidate\u0026#34;;//候选 public static final String OWNER = \u0026#34;owner\u0026#34;;//拥有者 public static final String STARTER = \u0026#34;starter\u0026#34;;//启动者 public static final String PARTICIPANT = \u0026#34;participant\u0026#34;;//参与者 public static final String REACTIVATOR = \u0026#34;reactivator\u0026#34;; } ACT_RU_TASK 运行时任务表 ACT_RU_VARIABLE 运行时变量表\n处理流程的原理 # 流程处理\n@Test public void testCompleted(){ ProcessEngine processEngine=ProcessEngines.getDefaultProcessEngine(); TaskService taskService = processEngine.getTaskService(); Task task = taskService.createTaskQuery() .processInstanceId(\u0026#34;4\u0026#34;) .taskAssignee(\u0026#34;zhangsan\u0026#34;) .singleResult(); //获取当前流程实例绑定的流程变量 Map\u0026lt;String, Object\u0026gt; processVariables = task.getProcessVariables(); Set\u0026lt;String\u0026gt; keySet = processVariables.keySet(); for(String key:keySet){ Object o = processVariables.get(key); System.out.println(\u0026#34;key:\u0026#34;+key+\u0026#34;--value:\u0026#34;+o); } processVariables.put(\u0026#34;approved\u0026#34;,true);//同意 processVariables.put(\u0026#34;description\u0026#34;,\u0026#34;我被修改了\u0026#34;); taskService.complete(task.getId(),processVariables); } 这里用的是之前的xml,所以应该给一个服务监听类\npublic class CallExternalSystemDelegate implements JavaDelegate { @Override public void execute(DelegateExecution execution) { System.out.println(\u0026#34;您的请求通过了!\u0026#34;); } } 任务处理后,这里添加了一个变量,且修改了变量description 可以通过流程变量,它可以在整个流程过程中流转的[注意,这里流程结束后流程变量会不存在的,但是act_hi_variinst里面可以看到流程变量实例] //我感觉应该用表单替代 act_ru_task和act_ru_identitylink\n两者区别 ACT _ RU _ IDENTITYLINK:此表存储有关用户或组的数据及其与(流程/案例/等)实例相关的角色。该表也被其他需要身份链接的引擎使用。【显示全部,包括已完成】 ACT _ RU _ TASK:此表包含一个正在运行的实例的每个未完成用户任务的条目。然后在查询用户的任务列表时使用此表。【这里只显示运行中】 act_ru_task 记录当前实例所运行的当前节点的信息 act_ru_identitylink act_ru_execution这个表的数据不会有变动 流程结束的原理 # 流程走完\n@Test public void testCompleted1() { ProcessEngine processEngine = ProcessEngines.getDefaultProcessEngine(); TaskService taskService = processEngine.getTaskService(); Task task = taskService.createTaskQuery() .processInstanceId(\u0026#34;4\u0026#34;) .taskAssignee(\u0026#34;lisi\u0026#34;) .singleResult(); //获取当前流程实例绑定的流程变量 Map\u0026lt;String, Object\u0026gt; processVariables = task.getProcessVariables(); Set\u0026lt;String\u0026gt; keySet = processVariables.keySet(); for (String key : keySet) { Object o = processVariables.get(key); System.out.println(\u0026#34;key:\u0026#34; + key + \u0026#34;--value:\u0026#34; + o); } /* processVariables.put(\u0026#34;approved\u0026#34;,true);//拒绝 processVariables.put(\u0026#34;description\u0026#34;,\u0026#34;我被修改了\u0026#34;);*/ taskService.complete(task.getId(), processVariables); } 此时跟流程相关的数据都会被清空掉 历史数据\n变量 任务流转历史 流程实例 涉及到的用户 流程活动\n"},{"id":391,"href":"/zh/docs/problem/Idea/01/","title":"问题01","section":"Idea","content":" Cannot download sources # 在maven项目(根目录)下执行\nmvn dependency:resolve -Dclassifier=sources 会开始下载,有控制台输出,结束后再点即可\n预留 # "},{"id":392,"href":"/zh/docs/technology/Flowable/boge_blbl_/02-advance_1/","title":"boge-02-flowable进阶_1","section":"基础(波哥)_","content":" 表结构 # 尽量通过API动数据\nACT_RE:repository,包含流程定义和流程静态资源\nACT_RU: runtime,包含流程实例、任务、变量等,流程结束会删除\nACT_HI: history,包含历史数据,比如历史流程实例、变量、任务等\nACT_GE: general,通用数据\nACT_ID: identity,组织机构。包含标识的信息,如用户、用户组等等\n具体的\n流程历史记录\n流程定义表 运行实例表 用户用户组表\n源码中的体现 默认的配置文件加载 # 对于\nProcessEngine defaultProcessEngine = ProcessEngines.getDefaultProcessEngine(); //--\u0026gt; public static ProcessEngine getDefaultProcessEngine() { return getProcessEngine(NAME_DEFAULT); //NAME_DEFAULT = \u0026#34;default\u0026#34; } //--\u0026gt; public static ProcessEngine getProcessEngine(String processEngineName) { if (!isInitialized()) { init(); } return processEngines.get(processEngineName); } //--\u0026gt;部分 /** * Initializes all process engines that can be found on the classpath for resources \u0026lt;code\u0026gt;flowable.cfg.xml\u0026lt;/code\u0026gt; (plain Flowable style configuration) and for resources * \u0026lt;code\u0026gt;flowable-context.xml\u0026lt;/code\u0026gt; (Spring style configuration). */ public static synchronized void init() { if (!isInitialized()) { if (processEngines == null) { // Create new map to store process-engines if current map is null processEngines = new HashMap\u0026lt;\u0026gt;(); } ClassLoader classLoader = ReflectUtil.getClassLoader(); Enumeration\u0026lt;URL\u0026gt; resources = null; try { resources = classLoader.getResources(\u0026#34;flowable.cfg.xml\u0026#34;); } catch (IOException e) { throw new FlowableIllegalArgumentException(\u0026#34;problem retrieving flowable.cfg.xml resources on the classpath: \u0026#34; + System.getProperty(\u0026#34;java.class.path\u0026#34;), e); } //后面还有,每帖出来 } } 注意这行classLoader.getResources(\u0026quot;flowable.cfg.xml\u0026quot;); 需要在resources根目录下放这么一个文件\n\u0026lt;?xml version=\u0026#34;1.0\u0026#34; encoding=\u0026#34;UTF-8\u0026#34;?\u0026gt; \u0026lt;beans xmlns=\u0026#34;http://www.springframework.org/schema/beans\u0026#34; xmlns:xsi=\u0026#34;http://www.w3.org/2001/XMLSchema-instance\u0026#34; xsi:schemaLocation=\u0026#34;http://www.springframework.org/schema/beans http://www.springframework.org/schema/beans/spring-beans.xsd\u0026#34;\u0026gt; \u0026lt;bean id=\u0026#34;processEngineConfiguration\u0026#34; class=\u0026#34;org.flowable.engine.impl.cfg.StandaloneProcessEngineConfiguration\u0026#34;\u0026gt; \u0026lt;property name=\u0026#34;jdbcUrl\u0026#34; value=\u0026#34;jdbc:mysql://localhost:3306/flow1?useUnicode=true\u0026amp;amp;characterEncoding=utf-8\u0026amp;amp;allowMultiQueries=true\u0026amp;amp;nullCatalogMeansCurrent=true\u0026#34;/\u0026gt; \u0026lt;property name=\u0026#34;jdbcDriver\u0026#34; value=\u0026#34;com.mysql.cj.jdbc.Driver\u0026#34;/\u0026gt; \u0026lt;property name=\u0026#34;jdbcUsername\u0026#34; value=\u0026#34;root\u0026#34;/\u0026gt; \u0026lt;property name=\u0026#34;jdbcPassword\u0026#34; value=\u0026#34;123456\u0026#34;/\u0026gt; \u0026lt;property name=\u0026#34;databaseSchemaUpdate\u0026#34; value=\u0026#34;true\u0026#34;/\u0026gt; \u0026lt;!--异步执行器--\u0026gt; \u0026lt;property name=\u0026#34;asyncExecutorActivate\u0026#34; value=\u0026#34;false\u0026#34;/\u0026gt; \u0026lt;/bean\u0026gt; \u0026lt;/beans\u0026gt; 新建数据库flow1,运行测试代码\n@Test public void processEngine2(){ ProcessEngine defaultProcessEngine = ProcessEngines.getDefaultProcessEngine(); System.out.println(defaultProcessEngine); } 此时数据库已经有表\n加载自定义名称的配置文件 # 把刚才的数据库清空,将flowable的配置文件放到目录custom/lycfg.xml中 代码\n@Test public void processEngine03(){ ProcessEngineConfiguration configuration = ProcessEngineConfiguration.createProcessEngineConfigurationFromResource(\u0026#34;custom/lycfg.xml\u0026#34;); System.out.println(configuration); ProcessEngine processEngine = configuration.buildProcessEngine(); System.out.println(processEngine); } ProcessEngine源码查看 # 源码追溯\nconfiguration.buildProcessEngine() //---\u0026gt;ProcessEngineConfigurationImpl.class @Override public ProcessEngine buildProcessEngine() { init(); ProcessEngineImpl processEngine = new ProcessEngineImpl(this); //... } //----\u0026gt;ProcessEngineImpl.class public class ProcessEngineImpl implements ProcessEngine { private static final Logger LOGGER = LoggerFactory.getLogger(ProcessEngineImpl.class); protected String name; protected RepositoryService repositoryService; protected RuntimeService runtimeService; protected HistoryService historicDataService; protected IdentityService identityService; protected TaskService taskService; protected FormService formService; protected ManagementService managementService; protected DynamicBpmnService dynamicBpmnService; protected ProcessMigrationService processInstanceMigrationService; protected AsyncExecutor asyncExecutor; protected AsyncExecutor asyncHistoryExecutor; protected CommandExecutor commandExecutor; protected Map\u0026lt;Class\u0026lt;?\u0026gt;, SessionFactory\u0026gt; sessionFactories; protected TransactionContextFactory transactionContextFactory; protected ProcessEngineConfigurationImpl processEngineConfiguration; //这里通过ProcessEngineConfigurationImpl获取各种对象 public ProcessEngineImpl(ProcessEngineConfigurationImpl processEngineConfiguration) { this.processEngineConfiguration = processEngineConfiguration; this.name = processEngineConfiguration.getEngineName(); this.repositoryService = processEngineConfiguration.getRepositoryService(); this.runtimeService = processEngineConfiguration.getRuntimeService(); this.historicDataService = processEngineConfiguration.getHistoryService(); this.identityService = processEngineConfiguration.getIdentityService(); this.taskService = processEngineConfiguration.getTaskService(); this.formService = processEngineConfiguration.getFormService(); this.managementService = processEngineConfiguration.getManagementService(); this.dynamicBpmnService = processEngineConfiguration.getDynamicBpmnService(); this.processInstanceMigrationService = processEngineConfiguration.getProcessMigrationService(); this.asyncExecutor = processEngineConfiguration.getAsyncExecutor(); this.asyncHistoryExecutor = processEngineConfiguration.getAsyncHistoryExecutor(); this.commandExecutor = processEngineConfiguration.getCommandExecutor(); this.sessionFactories = processEngineConfiguration.getSessionFactories(); this.transactionContextFactory = processEngineConfiguration.getTransactionContextFactory(); } //... } //----\u0026gt;ProcessEngine.class 获取各个service服务 public interface ProcessEngine extends Engine { /** the version of the flowable library */ String VERSION = FlowableVersions.CURRENT_VERSION; /** * Starts the execuctors (async and async history), if they are configured to be auto-activated. */ void startExecutors(); RepositoryService getRepositoryService(); RuntimeService getRuntimeService(); FormService getFormService(); TaskService getTaskService(); HistoryService getHistoryService(); IdentityService getIdentityService(); ManagementService getManagementService(); DynamicBpmnService getDynamicBpmnService(); ProcessMigrationService getProcessMigrationService(); ProcessEngineConfiguration getProcessEngineConfiguration(); } ProcessEngineConfiguration中的init方法 # 源码追溯\nconfiguration.buildProcessEngine() //---\u0026gt;ProcessEngineConfigurationImpl.class @Override public ProcessEngine buildProcessEngine() { init(); ProcessEngineImpl processEngine = new ProcessEngineImpl(this); //... } //---\u0026gt;ProcessEngineConfigurationImpl.init(); public void init() { initEngineConfigurations(); initConfigurators(); configuratorsBeforeInit(); initClock(); initObjectMapper(); initProcessDiagramGenerator(); initCommandContextFactory(); initTransactionContextFactory(); initCommandExecutors(); initIdGenerator(); initHistoryLevel(); initFunctionDelegates(); initAstFunctionCreators(); initDelegateInterceptor(); initBeans(); initExpressionManager(); initAgendaFactory(); //关系型数据库 if (usingRelationalDatabase) { initDataSource();//下面拿这个举例1 } else { initNonRelationalDataSource(); } if (usingRelationalDatabase || usingSchemaMgmt) { initSchemaManager(); initSchemaManagementCommand(); } configureVariableServiceConfiguration(); configureJobServiceConfiguration(); initHelpers(); initVariableTypes(); initFormEngines(); initFormTypes(); initScriptingEngines(); initBusinessCalendarManager(); initServices(); initWsdlImporterFactory(); initBehaviorFactory(); initListenerFactory(); initBpmnParser(); initProcessDefinitionCache(); initProcessDefinitionInfoCache(); initAppResourceCache(); initKnowledgeBaseCache(); initJobHandlers(); initHistoryJobHandlers(); initTransactionFactory(); if (usingRelationalDatabase) { initSqlSessionFactory();//下面拿这个举例2 } initSessionFactories(); //相关表结构操作 initDataManagers(); //下面拿这个举例2 initEntityManagers(); initCandidateManager(); initVariableAggregator(); initHistoryManager(); initChangeTenantIdManager(); initDynamicStateManager(); initProcessInstanceMigrationValidationManager(); initIdentityLinkInterceptor(); initJpa(); initDeployers(); initEventHandlers(); initFailedJobCommandFactory(); initEventDispatcher(); initProcessValidator(); initFormFieldHandler(); initDatabaseEventLogging(); initFlowable5CompatibilityHandler(); initVariableServiceConfiguration(); //流程变量 initIdentityLinkServiceConfiguration(); initEntityLinkServiceConfiguration(); initEventSubscriptionServiceConfiguration(); initTaskServiceConfiguration(); initJobServiceConfiguration(); initBatchServiceConfiguration(); initAsyncExecutor(); initAsyncHistoryExecutor(); configuratorsAfterInit(); afterInitTaskServiceConfiguration(); afterInitEventRegistryEventBusConsumer(); initHistoryCleaningManager(); initLocalizationManagers(); } //---\u0026gt;AbstractEngineConfiguration //----\u0026gt;AbstractEngineConfiguration.initDataSrouce() public static Properties getDefaultDatabaseTypeMappings() { Properties databaseTypeMappings = new Properties(); databaseTypeMappings.setProperty(\u0026#34;H2\u0026#34;, DATABASE_TYPE_H2); databaseTypeMappings.setProperty(\u0026#34;HSQL Database Engine\u0026#34;, DATABASE_TYPE_HSQL); databaseTypeMappings.setProperty(\u0026#34;MySQL\u0026#34;, DATABASE_TYPE_MYSQL); databaseTypeMappings.setProperty(\u0026#34;MariaDB\u0026#34;, DATABASE_TYPE_MYSQL); databaseTypeMappings.setProperty(\u0026#34;Oracle\u0026#34;, DATABASE_TYPE_ORACLE); databaseTypeMappings.setProperty(PRODUCT_NAME_POSTGRES, DATABASE_TYPE_POSTGRES); databaseTypeMappings.setProperty(\u0026#34;Microsoft SQL Server\u0026#34;, DATABASE_TYPE_MSSQL); databaseTypeMappings.setProperty(DATABASE_TYPE_DB2, DATABASE_TYPE_DB2); databaseTypeMappings.setProperty(\u0026#34;DB2\u0026#34;, DATABASE_TYPE_DB2); databaseTypeMappings.setProperty(\u0026#34;DB2/NT\u0026#34;, DATABASE_TYPE_DB2); databaseTypeMappings.setProperty(\u0026#34;DB2/NT64\u0026#34;, DATABASE_TYPE_DB2); databaseTypeMappings.setProperty(\u0026#34;DB2 UDP\u0026#34;, DATABASE_TYPE_DB2); databaseTypeMappings.setProperty(\u0026#34;DB2/LINUX\u0026#34;, DATABASE_TYPE_DB2); databaseTypeMappings.setProperty(\u0026#34;DB2/LINUX390\u0026#34;, DATABASE_TYPE_DB2); databaseTypeMappings.setProperty(\u0026#34;DB2/LINUXX8664\u0026#34;, DATABASE_TYPE_DB2); databaseTypeMappings.setProperty(\u0026#34;DB2/LINUXZ64\u0026#34;, DATABASE_TYPE_DB2); databaseTypeMappings.setProperty(\u0026#34;DB2/LINUXPPC64\u0026#34;, DATABASE_TYPE_DB2); databaseTypeMappings.setProperty(\u0026#34;DB2/LINUXPPC64LE\u0026#34;, DATABASE_TYPE_DB2); databaseTypeMappings.setProperty(\u0026#34;DB2/400 SQL\u0026#34;, DATABASE_TYPE_DB2); databaseTypeMappings.setProperty(\u0026#34;DB2/6000\u0026#34;, DATABASE_TYPE_DB2); databaseTypeMappings.setProperty(\u0026#34;DB2 UDB iSeries\u0026#34;, DATABASE_TYPE_DB2); databaseTypeMappings.setProperty(\u0026#34;DB2/AIX64\u0026#34;, DATABASE_TYPE_DB2); databaseTypeMappings.setProperty(\u0026#34;DB2/HPUX\u0026#34;, DATABASE_TYPE_DB2); databaseTypeMappings.setProperty(\u0026#34;DB2/HP64\u0026#34;, DATABASE_TYPE_DB2); databaseTypeMappings.setProperty(\u0026#34;DB2/SUN\u0026#34;, DATABASE_TYPE_DB2); databaseTypeMappings.setProperty(\u0026#34;DB2/SUN64\u0026#34;, DATABASE_TYPE_DB2); databaseTypeMappings.setProperty(\u0026#34;DB2/PTX\u0026#34;, DATABASE_TYPE_DB2); databaseTypeMappings.setProperty(\u0026#34;DB2/2\u0026#34;, DATABASE_TYPE_DB2); databaseTypeMappings.setProperty(\u0026#34;DB2 UDB AS400\u0026#34;, DATABASE_TYPE_DB2); databaseTypeMappings.setProperty(PRODUCT_NAME_CRDB, DATABASE_TYPE_COCKROACHDB); return databaseTypeMappings; } //initDataSource(); protected void initDataSource() { if (dataSource == null) { if (dataSourceJndiName != null) { try { dataSource = (DataSource) new InitialContext().lookup(dataSourceJndiName); } catch (Exception e) { throw new FlowableException(\u0026#34;couldn\u0026#39;t lookup datasource from \u0026#34; + dataSourceJndiName + \u0026#34;: \u0026#34; + e.getMessage(), e); } } else if (jdbcUrl != null) { if ((jdbcDriver == null) || (jdbcUsername == null)) { throw new FlowableException(\u0026#34;DataSource or JDBC properties have to be specified in a process engine configuration\u0026#34;); } logger.debug(\u0026#34;initializing datasource to db: {}\u0026#34;, jdbcUrl); if (logger.isInfoEnabled()) { logger.info(\u0026#34;Configuring Datasource with following properties (omitted password for security)\u0026#34;); logger.info(\u0026#34;datasource driver : {}\u0026#34;, jdbcDriver); logger.info(\u0026#34;datasource url : {}\u0026#34;, jdbcUrl); logger.info(\u0026#34;datasource user name : {}\u0026#34;, jdbcUsername); } PooledDataSource pooledDataSource = new PooledDataSource(this.getClass().getClassLoader(), jdbcDriver, jdbcUrl, jdbcUsername, jdbcPassword); if (jdbcMaxActiveConnections \u0026gt; 0) { pooledDataSource.setPoolMaximumActiveConnections(jdbcMaxActiveConnections); } if (jdbcMaxIdleConnections \u0026gt; 0) { pooledDataSource.setPoolMaximumIdleConnections(jdbcMaxIdleConnections); } if (jdbcMaxCheckoutTime \u0026gt; 0) { pooledDataSource.setPoolMaximumCheckoutTime(jdbcMaxCheckoutTime); } if (jdbcMaxWaitTime \u0026gt; 0) { pooledDataSource.setPoolTimeToWait(jdbcMaxWaitTime); } if (jdbcPingEnabled) { pooledDataSource.setPoolPingEnabled(true); if (jdbcPingQuery != null) { pooledDataSource.setPoolPingQuery(jdbcPingQuery); } pooledDataSource.setPoolPingConnectionsNotUsedFor(jdbcPingConnectionNotUsedFor); } if (jdbcDefaultTransactionIsolationLevel \u0026gt; 0) { pooledDataSource.setDefaultTransactionIsolationLevel(jdbcDefaultTransactionIsolationLevel); } dataSource = pooledDataSource; } } if (databaseType == null) { initDatabaseType(); } } //initSqlSessionFactory(); public void initSqlSessionFactory() { if (sqlSessionFactory == null) { InputStream inputStream = null; try { //获取MyBatis配置文件信息 inputStream = getMyBatisXmlConfigurationStream(); Environment environment = new Environment(\u0026#34;default\u0026#34;, transactionFactory, dataSource); Reader reader = new InputStreamReader(inputStream); Properties properties = new Properties(); properties.put(\u0026#34;prefix\u0026#34;, databaseTablePrefix); String wildcardEscapeClause = \u0026#34;\u0026#34;; if ((databaseWildcardEscapeCharacter != null) \u0026amp;\u0026amp; (databaseWildcardEscapeCharacter.length() != 0)) { wildcardEscapeClause = \u0026#34; escape \u0026#39;\u0026#34; + databaseWildcardEscapeCharacter + \u0026#34;\u0026#39;\u0026#34;; } properties.put(\u0026#34;wildcardEscapeClause\u0026#34;, wildcardEscapeClause); // set default properties properties.put(\u0026#34;limitBefore\u0026#34;, \u0026#34;\u0026#34;); properties.put(\u0026#34;limitAfter\u0026#34;, \u0026#34;\u0026#34;); properties.put(\u0026#34;limitBetween\u0026#34;, \u0026#34;\u0026#34;); properties.put(\u0026#34;limitBeforeNativeQuery\u0026#34;, \u0026#34;\u0026#34;); properties.put(\u0026#34;limitAfterNativeQuery\u0026#34;, \u0026#34;\u0026#34;); properties.put(\u0026#34;blobType\u0026#34;, \u0026#34;BLOB\u0026#34;); properties.put(\u0026#34;boolValue\u0026#34;, \u0026#34;TRUE\u0026#34;); if (databaseType != null) { properties.load(getResourceAsStream(pathToEngineDbProperties())); } //Mybatis相关的配置 Configuration configuration = initMybatisConfiguration(environment, reader, properties); sqlSessionFactory = new DefaultSqlSessionFactory(configuration); } catch (Exception e) { throw new FlowableException(\u0026#34;Error while building ibatis SqlSessionFactory: \u0026#34; + e.getMessage(), e); } finally { IoUtil.closeSilently(inputStream); } } } //ProcessEngineConfigurationImpl.getMyBatisXmlConfigurationStream(); @Override public InputStream getMyBatisXmlConfigurationStream() { return getResourceAsStream(mybatisMappingFile); } //代码往上翻 //构造器中 public ProcessEngineConfigurationImpl() { mybatisMappingFile = DEFAULT_MYBATIS_MAPPING_FILE; } //其中 public static final String DEFAULT_MYBATIS_MAPPING_FILE = \u0026#34;org/flowable/db/mapping/mappings.xml\u0026#34;; 查找映射文件 mappings.xml\n\u0026lt;?xml version=\u0026#34;1.0\u0026#34; encoding=\u0026#34;UTF-8\u0026#34;?\u0026gt; \u0026lt;!DOCTYPE configuration PUBLIC \u0026#34;-//mybatis.org//DTD Config 3.0//EN\u0026#34; \u0026#34;http://mybatis.org/dtd/mybatis-3-config.dtd\u0026#34;\u0026gt; \u0026lt;configuration\u0026gt; \u0026lt;settings\u0026gt; \u0026lt;setting name=\u0026#34;lazyLoadingEnabled\u0026#34; value=\u0026#34;false\u0026#34; /\u0026gt; \u0026lt;/settings\u0026gt; \u0026lt;typeAliases\u0026gt; \u0026lt;typeAlias type=\u0026#34;org.flowable.common.engine.impl.persistence.entity.ByteArrayRefTypeHandler\u0026#34; alias=\u0026#34;ByteArrayRefTypeHandler\u0026#34; /\u0026gt; \u0026lt;typeAlias type=\u0026#34;org.flowable.common.engine.impl.persistence.entity.ByteArrayRefTypeHandler\u0026#34; alias=\u0026#34;VariableByteArrayRefTypeHandler\u0026#34; /\u0026gt; \u0026lt;typeAlias type=\u0026#34;org.flowable.common.engine.impl.persistence.entity.ByteArrayRefTypeHandler\u0026#34; alias=\u0026#34;JobByteArrayRefTypeHandler\u0026#34; /\u0026gt; \u0026lt;typeAlias type=\u0026#34;org.flowable.common.engine.impl.persistence.entity.ByteArrayRefTypeHandler\u0026#34; alias=\u0026#34;BatchByteArrayRefTypeHandler\u0026#34; /\u0026gt; \u0026lt;/typeAliases\u0026gt; \u0026lt;typeHandlers\u0026gt; \u0026lt;typeHandler handler=\u0026#34;ByteArrayRefTypeHandler\u0026#34; javaType=\u0026#34;org.flowable.common.engine.impl.persistence.entity.ByteArrayRef\u0026#34; jdbcType=\u0026#34;VARCHAR\u0026#34; /\u0026gt; \u0026lt;typeHandler handler=\u0026#34;VariableByteArrayRefTypeHandler\u0026#34; javaType=\u0026#34;org.flowable.common.engine.impl.persistence.entity.ByteArrayRef\u0026#34; jdbcType=\u0026#34;VARCHAR\u0026#34; /\u0026gt; \u0026lt;typeHandler handler=\u0026#34;JobByteArrayRefTypeHandler\u0026#34; javaType=\u0026#34;org.flowable.common.engine.impl.persistence.entity.ByteArrayRef\u0026#34; jdbcType=\u0026#34;VARCHAR\u0026#34; /\u0026gt; \u0026lt;typeHandler handler=\u0026#34;BatchByteArrayRefTypeHandler\u0026#34; javaType=\u0026#34;org.flowable.common.engine.impl.persistence.entity.ByteArrayRef\u0026#34; jdbcType=\u0026#34;VARCHAR\u0026#34; /\u0026gt; \u0026lt;/typeHandlers\u0026gt; \u0026lt;mappers\u0026gt; \u0026lt;mapper resource=\u0026#34;org/flowable/db/mapping/ChangeTenantBpmn.xml\u0026#34; /\u0026gt; \u0026lt;mapper resource=\u0026#34;org/flowable/db/mapping/entity/Attachment.xml\u0026#34; /\u0026gt; \u0026lt;mapper resource=\u0026#34;org/flowable/db/mapping/entity/Comment.xml\u0026#34; /\u0026gt; \u0026lt;mapper resource=\u0026#34;org/flowable/job/service/db/mapping/entity/DeadLetterJob.xml\u0026#34; /\u0026gt; \u0026lt;mapper resource=\u0026#34;org/flowable/db/mapping/entity/Deployment.xml\u0026#34; /\u0026gt; \u0026lt;mapper resource=\u0026#34;org/flowable/db/mapping/entity/Execution.xml\u0026#34; /\u0026gt; \u0026lt;mapper resource=\u0026#34;org/flowable/db/mapping/entity/ActivityInstance.xml\u0026#34; /\u0026gt; \u0026lt;mapper resource=\u0026#34;org/flowable/db/mapping/entity/HistoricActivityInstance.xml\u0026#34; /\u0026gt; \u0026lt;mapper resource=\u0026#34;org/flowable/db/mapping/entity/HistoricDetail.xml\u0026#34; /\u0026gt; \u0026lt;mapper resource=\u0026#34;org/flowable/db/mapping/entity/HistoricProcessInstance.xml\u0026#34; /\u0026gt; \u0026lt;mapper resource=\u0026#34;org/flowable/variable/service/db/mapping/entity/HistoricVariableInstance.xml\u0026#34; /\u0026gt; \u0026lt;mapper resource=\u0026#34;org/flowable/task/service/db/mapping/entity/HistoricTaskInstance.xml\u0026#34; /\u0026gt; \u0026lt;mapper resource=\u0026#34;org/flowable/task/service/db/mapping/entity/HistoricTaskLogEntry.xml\u0026#34; /\u0026gt; \u0026lt;mapper resource=\u0026#34;org/flowable/identitylink/service/db/mapping/entity/HistoricIdentityLink.xml\u0026#34; /\u0026gt; \u0026lt;mapper resource=\u0026#34;org/flowable/entitylink/service/db/mapping/entity/HistoricEntityLink.xml\u0026#34; /\u0026gt; \u0026lt;mapper resource=\u0026#34;org/flowable/job/service/db/mapping/entity/HistoryJob.xml\u0026#34; /\u0026gt; \u0026lt;mapper resource=\u0026#34;org/flowable/identitylink/service/db/mapping/entity/IdentityLink.xml\u0026#34; /\u0026gt; \u0026lt;mapper resource=\u0026#34;org/flowable/entitylink/service/db/mapping/entity/EntityLink.xml\u0026#34; /\u0026gt; \u0026lt;mapper resource=\u0026#34;org/flowable/job/service/db/mapping/entity/Job.xml\u0026#34; /\u0026gt; \u0026lt;mapper resource=\u0026#34;org/flowable/db/mapping/entity/Model.xml\u0026#34; /\u0026gt; \u0026lt;mapper resource=\u0026#34;org/flowable/db/mapping/entity/ProcessDefinition.xml\u0026#34; /\u0026gt; \u0026lt;mapper resource=\u0026#34;org/flowable/db/mapping/entity/ProcessDefinitionInfo.xml\u0026#34; /\u0026gt; \u0026lt;mapper resource=\u0026#34;org/flowable/common/db/mapping/entity/Property.xml\u0026#34; /\u0026gt; \u0026lt;mapper resource=\u0026#34;org/flowable/common/db/mapping/entity/ByteArray.xml\u0026#34; /\u0026gt; \u0026lt;mapper resource=\u0026#34;org/flowable/common/db/mapping/common.xml\u0026#34; /\u0026gt; \u0026lt;mapper resource=\u0026#34;org/flowable/db/mapping/entity/Resource.xml\u0026#34; /\u0026gt; \u0026lt;mapper resource=\u0026#34;org/flowable/job/service/db/mapping/entity/SuspendedJob.xml\u0026#34; /\u0026gt; \u0026lt;mapper resource=\u0026#34;org/flowable/job/service/db/mapping/entity/ExternalWorkerJob.xml\u0026#34; /\u0026gt; \u0026lt;mapper resource=\u0026#34;org/flowable/common/db/mapping/entity/TableData.xml\u0026#34; /\u0026gt; \u0026lt;mapper resource=\u0026#34;org/flowable/task/service/db/mapping/entity/Task.xml\u0026#34; /\u0026gt; \u0026lt;mapper resource=\u0026#34;org/flowable/job/service/db/mapping/entity/TimerJob.xml\u0026#34; /\u0026gt; \u0026lt;mapper resource=\u0026#34;org/flowable/variable/service/db/mapping/entity/VariableInstance.xml\u0026#34; /\u0026gt; \u0026lt;mapper resource=\u0026#34;org/flowable/eventsubscription/service/db/mapping/entity/EventSubscription.xml\u0026#34; /\u0026gt; \u0026lt;mapper resource=\u0026#34;org/flowable/db/mapping/entity/EventLogEntry.xml\u0026#34; /\u0026gt; \u0026lt;mapper resource=\u0026#34;org/flowable/batch/service/db/mapping/entity/Batch.xml\u0026#34; /\u0026gt; \u0026lt;mapper resource=\u0026#34;org/flowable/batch/service/db/mapping/entity/BatchPart.xml\u0026#34; /\u0026gt; \u0026lt;/mappers\u0026gt; \u0026lt;/configuration\u0026gt; 源码\n//ProcessEnginConfigurationImpl.init()中的代码 initDataManagers(); //下面拿这个举例3 //-\u0026gt;\u0026gt;\u0026gt; @Override @SuppressWarnings(\u0026#34;rawtypes\u0026#34;) public void initDataManagers() { super.initDataManagers(); if (attachmentDataManager == null) { attachmentDataManager = new MybatisAttachmentDataManager(this); } if (commentDataManager == null) { commentDataManager = new MybatisCommentDataManager(this); } if (deploymentDataManager == null) { //下面拿这个查看 deploymentDataManager = new MybatisDeploymentDataManager(this); } if (eventLogEntryDataManager == null) { eventLogEntryDataManager = new MybatisEventLogEntryDataManager(this); } if (executionDataManager == null) { executionDataManager = new MybatisExecutionDataManager(this); } if (dbSqlSessionFactory != null \u0026amp;\u0026amp; executionDataManager instanceof AbstractDataManager) { dbSqlSessionFactory.addLogicalEntityClassMapping(\u0026#34;execution\u0026#34;, ((AbstractDataManager) executionDataManager).getManagedEntityClass()); } if (historicActivityInstanceDataManager == null) { historicActivityInstanceDataManager = new MybatisHistoricActivityInstanceDataManager(this); } if (activityInstanceDataManager == null) { activityInstanceDataManager = new MybatisActivityInstanceDataManager(this); } if (historicDetailDataManager == null) { historicDetailDataManager = new MybatisHistoricDetailDataManager(this); } if (historicProcessInstanceDataManager == null) { historicProcessInstanceDataManager = new MybatisHistoricProcessInstanceDataManager(this); } if (modelDataManager == null) { modelDataManager = new MybatisModelDataManager(this); } if (processDefinitionDataManager == null) { processDefinitionDataManager = new MybatisProcessDefinitionDataManager(this); } if (processDefinitionInfoDataManager == null) { processDefinitionInfoDataManager = new MybatisProcessDefinitionInfoDataManager(this); } if (resourceDataManager == null) { resourceDataManager = new MybatisResourceDataManager(this); } } //--\u0026gt;MybatisDeploymentDataManager,这个类相当于mybatis中的mapper /** * @author Joram Barrez */ public class MybatisDeploymentDataManager extends AbstractProcessDataManager\u0026lt;DeploymentEntity\u0026gt; implements DeploymentDataManager { public MybatisDeploymentDataManager(ProcessEngineConfigurationImpl processEngineConfiguration) { super(processEngineConfiguration); } @Override public Class\u0026lt;? extends DeploymentEntity\u0026gt; getManagedEntityClass() { return DeploymentEntityImpl.class; } @Override public DeploymentEntity create() { return new DeploymentEntityImpl(); } @Override public long findDeploymentCountByQueryCriteria(DeploymentQueryImpl deploymentQuery) { return (Long) getDbSqlSession().selectOne(\u0026#34;selectDeploymentCountByQueryCriteria\u0026#34;, deploymentQuery); } @Override @SuppressWarnings(\u0026#34;unchecked\u0026#34;) public List\u0026lt;Deployment\u0026gt; findDeploymentsByQueryCriteria(DeploymentQueryImpl deploymentQuery) { final String query = \u0026#34;selectDeploymentsByQueryCriteria\u0026#34;; return getDbSqlSession().selectList(query, deploymentQuery); } @Override public List\u0026lt;String\u0026gt; getDeploymentResourceNames(String deploymentId) { return getDbSqlSession().getSqlSession().selectList(\u0026#34;selectResourceNamesByDeploymentId\u0026#34;, deploymentId); } @Override @SuppressWarnings(\u0026#34;unchecked\u0026#34;) public List\u0026lt;Deployment\u0026gt; findDeploymentsByNativeQuery(Map\u0026lt;String, Object\u0026gt; parameterMap) { return getDbSqlSession().selectListWithRawParameter(\u0026#34;selectDeploymentByNativeQuery\u0026#34;, parameterMap); } @Override public long findDeploymentCountByNativeQuery(Map\u0026lt;String, Object\u0026gt; parameterMap) { return (Long) getDbSqlSession().selectOne(\u0026#34;selectDeploymentCountByNativeQuery\u0026#34;, parameterMap); } } ProcessEngine各种方法对比 # ProcessEngines.getDefaultProcessEngine();的方式\n/** * Initializes all process engines that can be found on the classpath for resources \u0026lt;code\u0026gt;flowable.cfg.xml\u0026lt;/code\u0026gt; (plain Flowable style configuration) and for resources * \u0026lt;code\u0026gt;flowable-context.xml\u0026lt;/code\u0026gt; (Spring style configuration). */ public static synchronized void init() { if (!isInitialized()) { if (processEngines == null) { // Create new map to store process-engines if current map is null processEngines = new HashMap\u0026lt;\u0026gt;(); } ClassLoader classLoader = ReflectUtil.getClassLoader(); Enumeration\u0026lt;URL\u0026gt; resources = null; try { resources = classLoader.getResources(\u0026#34;flowable.cfg.xml\u0026#34;); } catch (IOException e) { throw new FlowableIllegalArgumentException(\u0026#34;problem retrieving flowable.cfg.xml resources on the classpath: \u0026#34; + System.getProperty(\u0026#34;java.class.path\u0026#34;), e); } // Remove duplicated configuration URL\u0026#39;s using set. Some // classloaders may return identical URL\u0026#39;s twice, causing duplicate // startups Set\u0026lt;URL\u0026gt; configUrls = new HashSet\u0026lt;\u0026gt;(); while (resources.hasMoreElements()) { configUrls.add(resources.nextElement()); } for (URL resource : configUrls) { LOGGER.info(\u0026#34;Initializing process engine using configuration \u0026#39;{}\u0026#39;\u0026#34;, resource); initProcessEngineFromResource(resource); //注意这个 } try { resources = classLoader.getResources(\u0026#34;flowable-context.xml\u0026#34;); } catch (IOException e) { throw new FlowableIllegalArgumentException(\u0026#34;problem retrieving flowable-context.xml resources on the classpath: \u0026#34; + System.getProperty(\u0026#34;java.class.path\u0026#34;), e); } while (resources.hasMoreElements()) { URL resource = resources.nextElement(); LOGGER.info(\u0026#34;Initializing process engine using Spring configuration \u0026#39;{}\u0026#39;\u0026#34;, resource); initProcessEngineFromSpringResource(resource); } setInitialized(true); } else { LOGGER.info(\u0026#34;Process engines already initialized\u0026#34;); } } 可以通过Spring配置文件的方式\ninitProcessEngineFromResource(resource); //注意这个 private static EngineInfo initProcessEngineFromResource(URL resourceUrl) { EngineInfo processEngineInfo = processEngineInfosByResourceUrl.get(resourceUrl.toString()); // if there is an existing process engine info if (processEngineInfo != null) { // remove that process engine from the member fields processEngineInfos.remove(processEngineInfo); if (processEngineInfo.getException() == null) { String processEngineName = processEngineInfo.getName(); processEngines.remove(processEngineName); processEngineInfosByName.remove(processEngineName); } processEngineInfosByResourceUrl.remove(processEngineInfo.getResourceUrl()); } String resourceUrlString = resourceUrl.toString(); try { LOGGER.info(\u0026#34;initializing process engine for resource {}\u0026#34;, resourceUrl); //注意这个 ProcessEngine processEngine = buildProcessEngine(resourceUrl); String processEngineName = processEngine.getName(); LOGGER.info(\u0026#34;initialised process engine {}\u0026#34;, processEngineName); processEngineInfo = new EngineInfo(processEngineName, resourceUrlString, null); processEngines.put(processEngineName, processEngine); processEngineInfosByName.put(processEngineName, processEngineInfo); } catch (Throwable e) { LOGGER.error(\u0026#34;Exception while initializing process engine: {}\u0026#34;, e.getMessage(), e); processEngineInfo = new EngineInfo(null, resourceUrlString, ExceptionUtils.getStackTrace(e)); } processEngineInfosByResourceUrl.put(resourceUrlString, processEngineInfo); processEngineInfos.add(processEngineInfo); return processEngineInfo; } 源码\nbuildProcessEngine(resourceUrl); // private static ProcessEngine buildProcessEngine(URL resource) { InputStream inputStream = null; try { inputStream = resource.openStream(); ProcessEngineConfiguration processEngineConfiguration = ProcessEngineConfiguration.createProcessEngineConfigurationFromInputStream(inputStream); return processEngineConfiguration.buildProcessEngine(); } catch (IOException e) { throw new FlowableIllegalArgumentException(\u0026#34;couldn\u0026#39;t open resource stream: \u0026#34; + e.getMessage(), e); } finally { IoUtil.closeSilently(inputStream); } } "},{"id":393,"href":"/zh/docs/technology/Flowable/boge_blbl_/01-base/","title":"boge-01-flowable基础","section":"基础(波哥)_","content":" Flowable介绍 # flowable的历史\nflowable是BPNM的一个基于java的软件实现,不仅包括BPMN,还有DMN决策表和CMMNCase管理引擎,并且有自己的用户管理、微服务API等\n获取Engine对象 # maven依赖\n\u0026lt;dependencies\u0026gt; \u0026lt;dependency\u0026gt; \u0026lt;groupId\u0026gt;mysql\u0026lt;/groupId\u0026gt; \u0026lt;artifactId\u0026gt;mysql-connector-java\u0026lt;/artifactId\u0026gt; \u0026lt;version\u0026gt;8.0.29\u0026lt;/version\u0026gt; \u0026lt;/dependency\u0026gt; \u0026lt;!-- https://mvnrepository.com/artifact/org.flowable/flowable-engine --\u0026gt; \u0026lt;dependency\u0026gt; \u0026lt;groupId\u0026gt;org.flowable\u0026lt;/groupId\u0026gt; \u0026lt;artifactId\u0026gt;flowable-engine\u0026lt;/artifactId\u0026gt; \u0026lt;version\u0026gt;6.7.2\u0026lt;/version\u0026gt; \u0026lt;/dependency\u0026gt; \u0026lt;!-- https://mvnrepository.com/artifact/junit/junit --\u0026gt; \u0026lt;dependency\u0026gt; \u0026lt;groupId\u0026gt;junit\u0026lt;/groupId\u0026gt; \u0026lt;artifactId\u0026gt;junit\u0026lt;/artifactId\u0026gt; \u0026lt;version\u0026gt;4.13.2\u0026lt;/version\u0026gt; \u0026lt;scope\u0026gt;test\u0026lt;/scope\u0026gt; \u0026lt;/dependency\u0026gt; \u0026lt;/dependencies\u0026gt; 配置并获取ProcessEngine\nProcessEngineConfiguration configuration= new StandaloneProcessEngineConfiguration(); //配置 configuration.setJdbcDriver(\u0026#34;com.mysql.cj.jdbc.Driver\u0026#34;); configuration.setJdbcUsername(\u0026#34;root\u0026#34;); configuration.setJdbcPassword(\u0026#34;123456\u0026#34;); //nullCatalogMeansCurrent=true 设置为只查当前连接的schema库 configuration.setJdbcUrl(\u0026#34;jdbc:mysql://localhost:3306/flowable-learn?\u0026#34; + \u0026#34;useUnicode=true\u0026amp;characterEncoding=utf-8\u0026#34; + \u0026#34;\u0026amp;allowMultiQueries=true\u0026#34; + \u0026#34;\u0026amp;nullCatalogMeansCurrent=true\u0026#34;); //如果数据库中表结构不存在则新建 configuration.setDatabaseSchemaUpdate(ProcessEngineConfiguration.DB_SCHEMA_UPDATE_TRUE); //构建ProcessEngine ProcessEngine processEngine=configuration.buildProcessEngine(); 日志和表结构介绍 # 添加slf4j依赖\n\u0026lt;!-- https://mvnrepository.com/artifact/org.slf4j/slf4j-reload4j --\u0026gt; \u0026lt;dependency\u0026gt; \u0026lt;groupId\u0026gt;org.slf4j\u0026lt;/groupId\u0026gt; \u0026lt;artifactId\u0026gt;slf4j-reload4j\u0026lt;/artifactId\u0026gt; \u0026lt;version\u0026gt;1.7.36\u0026lt;/version\u0026gt; \u0026lt;scope\u0026gt;test\u0026lt;/scope\u0026gt; \u0026lt;/dependency\u0026gt; \u0026lt;!-- https://mvnrepository.com/artifact/org.apache.logging.log4j/log4j-api --\u0026gt; \u0026lt;dependency\u0026gt; \u0026lt;groupId\u0026gt;org.apache.logging.log4j\u0026lt;/groupId\u0026gt; \u0026lt;artifactId\u0026gt;log4j-api\u0026lt;/artifactId\u0026gt; \u0026lt;version\u0026gt;2.17.2\u0026lt;/version\u0026gt; \u0026lt;/dependency\u0026gt; 添加log配置文件\nlog4j.rootLogger = DEBUG, CA log4j.appender.CA = org.apache.log4j.ConsoleAppender log4j.appender.CA.layout = org.apache.log4j.PatternLayout log4j.appender.CA.layout.ConversionPattern = %d{hh:mm:ss,SSS} {%t} %-5p %c %x - %m%n 此时再次启动就会看到一堆日志 表 流程定义文件解析 # 先通过流程绘制器绘制流程\n案例(官网,请假流程) 设计好流程之后,流程数据保存在holiday-request.bpmn20.xml文件中\n\u0026lt;?xml version=\u0026#34;1.0\u0026#34; encoding=\u0026#34;UTF-8\u0026#34;?\u0026gt; \u0026lt;definitions xmlns=\u0026#34;http://www.omg.org/spec/BPMN/20100524/MODEL\u0026#34; xmlns:xsi=\u0026#34;http://www.w3.org/2001/XMLSchema-instance\u0026#34; xmlns:xsd=\u0026#34;http://www.w3.org/2001/XMLSchema\u0026#34; xmlns:bpmndi=\u0026#34;http://www.omg.org/spec/BPMN/20100524/DI\u0026#34; xmlns:omgdc=\u0026#34;http://www.omg.org/spec/DD/20100524/DC\u0026#34; xmlns:omgdi=\u0026#34;http://www.omg.org/spec/DD/20100524/DI\u0026#34; xmlns:flowable=\u0026#34;http://flowable.org/bpmn\u0026#34; typeLanguage=\u0026#34;http://www.w3.org/2001/XMLSchema\u0026#34; expressionLanguage=\u0026#34;http://www.w3.org/1999/XPath\u0026#34; targetNamespace=\u0026#34;http://www.flowable.org/processdef\u0026#34;\u0026gt; \u0026lt;!--id process key--\u0026gt; \u0026lt;process id=\u0026#34;holidayRequest\u0026#34; name=\u0026#34;请假流程\u0026#34; isExecutable=\u0026#34;true\u0026#34;\u0026gt; \u0026lt;startEvent id=\u0026#34;startEvent\u0026#34;/\u0026gt; \u0026lt;!--sequenceFlow表示的是线条箭头--\u0026gt; \u0026lt;sequenceFlow sourceRef=\u0026#34;startEvent\u0026#34; targetRef=\u0026#34;approveTask\u0026#34;/\u0026gt; \u0026lt;userTask id=\u0026#34;approveTask\u0026#34; name=\u0026#34;同意或者拒绝请假\u0026#34;/\u0026gt; \u0026lt;sequenceFlow sourceRef=\u0026#34;approveTask\u0026#34; targetRef=\u0026#34;decision\u0026#34;/\u0026gt; \u0026lt;!--网关--\u0026gt; \u0026lt;exclusiveGateway id=\u0026#34;decision\u0026#34;/\u0026gt; \u0026lt;sequenceFlow sourceRef=\u0026#34;decision\u0026#34; targetRef=\u0026#34;externalSystemCall\u0026#34;\u0026gt; \u0026lt;!--条件--\u0026gt; \u0026lt;conditionExpression xsi:type=\u0026#34;tFormalExpression\u0026#34;\u0026gt; \u0026lt;![CDATA[ ${approved} ]]\u0026gt; \u0026lt;/conditionExpression\u0026gt; \u0026lt;/sequenceFlow\u0026gt; \u0026lt;sequenceFlow sourceRef=\u0026#34;decision\u0026#34; targetRef=\u0026#34;sendRejectionMail\u0026#34;\u0026gt; \u0026lt;!--条件--\u0026gt; \u0026lt;conditionExpression xsi:type=\u0026#34;tFormalExpression\u0026#34;\u0026gt; \u0026lt;![CDATA[ ${!approved} ]]\u0026gt; \u0026lt;/conditionExpression\u0026gt; \u0026lt;/sequenceFlow\u0026gt; \u0026lt;serviceTask id=\u0026#34;externalSystemCall\u0026#34; name=\u0026#34;Enter holidays in external system\u0026#34; flowable:class=\u0026#34;org.flowable.CallExternalSystemDelegate\u0026#34;/\u0026gt; \u0026lt;sequenceFlow sourceRef=\u0026#34;externalSystemCall\u0026#34; targetRef=\u0026#34;holidayApprovedTask\u0026#34;/\u0026gt; \u0026lt;userTask id=\u0026#34;holidayApprovedTask\u0026#34; name=\u0026#34;Holiday approved\u0026#34;/\u0026gt; \u0026lt;sequenceFlow sourceRef=\u0026#34;holidayApprovedTask\u0026#34; targetRef=\u0026#34;approveEnd\u0026#34;/\u0026gt; \u0026lt;!--发送一个邮件--\u0026gt; \u0026lt;serviceTask id=\u0026#34;sendRejectionMail\u0026#34; name=\u0026#34;Send out rejection email\u0026#34; flowable:class=\u0026#34;org.flowable.SendRejectionMail\u0026#34;/\u0026gt; \u0026lt;sequenceFlow sourceRef=\u0026#34;sendRejectionMail\u0026#34; targetRef=\u0026#34;rejectEnd\u0026#34;/\u0026gt; \u0026lt;endEvent id=\u0026#34;approveEnd\u0026#34;/\u0026gt; \u0026lt;endEvent id=\u0026#34;rejectEnd\u0026#34;/\u0026gt; \u0026lt;/process\u0026gt; \u0026lt;/definitions\u0026gt; 部署流程-代码实现 # 使用@bofore 处理测试中繁琐的配置操作\nProcessEngineConfiguration configuration = null; @Before public void before() { configuration = new StandaloneProcessEngineConfiguration(); //配置 configuration.setJdbcDriver(\u0026#34;com.mysql.cj.jdbc.Driver\u0026#34;); configuration.setJdbcUsername(\u0026#34;root\u0026#34;); configuration.setJdbcPassword(\u0026#34;123456\u0026#34;); //nullCatalogMeansCurrent=true 设置为只查当前连接的schema库 configuration.setJdbcUrl(\u0026#34;jdbc:mysql://localhost:3306/flowable-learn?\u0026#34; + \u0026#34;useUnicode=true\u0026amp;characterEncoding=utf-8\u0026#34; + \u0026#34;\u0026amp;allowMultiQueries=true\u0026#34; + \u0026#34;\u0026amp;nullCatalogMeansCurrent=true\u0026#34;); //如果数据库中表结构不存在则新建 configuration.setDatabaseSchemaUpdate(ProcessEngineConfiguration.DB_SCHEMA_UPDATE_TRUE); } ProcessEngine提供的几个服务 流程部署\n/** * 流程的部署 */ @Test public void testDeploy() { //获取ProcessEngine对象 ProcessEngine processEngine = configuration.buildProcessEngine(); //获取服务(repository,流程定义) RepositoryService repositoryService = processEngine.getRepositoryService(); Deployment deploy = repositoryService.createDeployment().addClasspathResource(\u0026#34;holiday-request.bpmn20.xml\u0026#34;) .name(\u0026#34;请求流程\u0026#34;) //流程名 .deploy(); System.out.println(\u0026#34;部署id\u0026#34; + deploy.getId()); System.out.println(\u0026#34;部署名\u0026#34; + deploy.getName()); } 表结构 查询和删除操作 # 查询已经部署的流程定义\n/** * 流程定义及部署的查询 */ @Test public void testDeployQuery(){ ProcessEngine processEngine=configuration.buildProcessEngine(); RepositoryService repositoryService=processEngine.getRepositoryService(); //流程部署查询 //这里只部署了一个流程定义 Deployment deployment = repositoryService.createDeploymentQuery() .deploymentId(\u0026#34;1\u0026#34;).singleResult(); System.out.println(\u0026#34;部署时的名称:\u0026#34;+deployment.getName()); //流程定义查询器 ProcessDefinitionQuery processDefinitionQuery = repositoryService.createProcessDefinitionQuery(); //查询到的流程定义 ProcessDefinition processDefinition = processDefinitionQuery.deploymentId(\u0026#34;1\u0026#34;).singleResult(); System.out.println(\u0026#34;部署id:\u0026#34;+processDefinition.getDeploymentId()); System.out.println(\u0026#34;定义名:\u0026#34;+processDefinition.getName()); System.out.println(\u0026#34;描述:\u0026#34;+processDefinition.getDescription()); System.out.println(\u0026#34;定义id:\u0026#34;+processDefinition.getId()); } 删除流程定义\n代码\n/** * 流程删除 */ @Test public void testDeleteDeploy(){ ProcessEngine processEngine=configuration.buildProcessEngine(); RepositoryService repositoryService=processEngine. getRepositoryService(); //注意:第一个参数时部署id //后面那个参数表示级联删除,如果流程启动了会同时删除任务。 repositoryService.deleteDeployment(\u0026#34;2501\u0026#34;,true); } 下面三个表的数据都会被删除 启动流程实例 # 由于刚才将部署删除了,所以这里再运行testDeploy()重新部署上\n这里通过流程定义key(xml中的id)启动流程\n/** * 流程运行 */ @Test public void testRunProcess(){ ProcessEngine processEngine=configuration.buildProcessEngine(); RuntimeService runtimeService = processEngine.getRuntimeService(); //这边模拟表单数据(表单数据有多种处理方式,这只是其中一种) Map\u0026lt;String,Object\u0026gt; map=new HashMap\u0026lt;\u0026gt;(); map.put(\u0026#34;employee\u0026#34;,\u0026#34;张三\u0026#34;); map.put(\u0026#34;nrOfHolidays\u0026#34;,3); map.put(\u0026#34;description\u0026#34;,\u0026#34;工作累了想出去玩\u0026#34;); ProcessInstance holidayRequest = runtimeService.startProcessInstanceByKey(\u0026#34;holidayRequest\u0026#34;, map); System.out.println(\u0026#34;流程定义的id:\u0026#34;+holidayRequest.getProcessDefinitionId()); System.out.println(\u0026#34;当前活跃id:\u0026#34;+holidayRequest.getActivityId()); System.out.println(\u0026#34;流程运行id:\u0026#34;+holidayRequest.getId()); } 三个表 act_ru_variable act_ru_task arc_ru_execution\n查询任务 # 这里先指定一下每个任务的候选人,修改xml文件中userTask的节点属性\n修改前先删除一下之前部署的流程图(还是上面的代码)\n/** * 流程删除 */ @Test public void testDeleteDeploy(){ ProcessEngine processEngine=configuration.buildProcessEngine(); RepositoryService repositoryService=processEngine. getRepositoryService(); //注意:第一个参数时部署id //后面那个参数表示级联删除,true表示如果流程启动了会同时删除任务。 repositoryService.deleteDeployment(\u0026#34;2501\u0026#34;,false); } 这里用false参数测试,会提示失败,运行中的流程不允许删除。将第二个参数改为true即可级联删除\n删除后可以发现下面几个表数据全部清空了 然后修改xml定义文件并运行testDeploy()重新部署\n定义修改\n\u0026lt;userTask id=\u0026#34;approveTask\u0026#34; name=\u0026#34;同意或者拒绝请假\u0026#34; flowable:assignee=\u0026#34;zhangsan\u0026#34;/\u0026gt; \u0026lt;!--这里增加了assignee属性值--\u0026gt; 运行流程 testRunProcess()\n运行后节点会跳到给zhangsan的那个任务,查看数据库表 流程变量 查询任务\n/** * 测试任务查询 */ @Test public void testQueryTask(){ ProcessEngine processEngine=configuration.buildProcessEngine(); TaskService taskService = processEngine.getTaskService(); //通过流程定义查询任务 List\u0026lt;Task\u0026gt; list = taskService.createTaskQuery().processDefinitionKey(\u0026#34;holidayRequest\u0026#34;) .taskAssignee(\u0026#34;zhangsan\u0026#34;) .list(); for (Task task:list){ System.out.println(\u0026#34;任务对应的流程定义id\u0026#34;+task.getProcessDefinitionId()); System.out.println(\u0026#34;任务名\u0026#34;+task.getName()); System.out.println(\u0026#34;任务处理人\u0026#34;+task.getAssignee()); System.out.println(\u0026#34;任务描述\u0026#34;+task.getDescription()); System.out.println(\u0026#34;任务id\u0026#34;+task.getId()); } } 处理任务 # 流程图定义的分析 任务A处理后,根据处理结果(这里是拒绝),会走向任务D,然后任务D是一个Service,且通过java的委托对象,自动实现操作\n到了D那个节点,这里指定了一个自定义的java类处理 代码配置,注意类名和xml中的一致\npackage org.flowable; import org.flowable.engine.delegate.DelegateExecution; import org.flowable.engine.delegate.JavaDelegate; public class SendRejectionMail implements JavaDelegate { /** * 这是一个flowable中的触发器 * * @param delegateExecution */ @Override public void execute(DelegateExecution delegateExecution) { //触发执行的逻辑 按照我们在流程中的定义给被拒绝的员工发送通知邮件 System.out.println(\u0026#34;不好意思,你的请假申请被拒绝了\u0026#34;); } } 任务的完成\n@Test public void testCompleteTask() { ProcessEngine engine = configuration.buildProcessEngine(); TaskService taskService = engine.getTaskService(); //查找出张三在这个流程定义中的任务 Task task = taskService.createTaskQuery().processDefinitionKey(\u0026#34;holidayRequest\u0026#34;) .taskAssignee(\u0026#34;zhangsan\u0026#34;) .singleResult(); //创建流程变量 HashMap\u0026lt;String, Object\u0026gt; map = new HashMap\u0026lt;\u0026gt;(); map.put(\u0026#34;approved\u0026#34;, false); //完成任务 taskService.complete(task.getId(), map); } 控制台 数据库 下面几个表的数据都被清空了 历史任务的完成 # Flowable流程引擎可以自动存储所有流程实例的审计数据或历史数据\n先查看一下刚才用的流程定义的id 历史信息查询\n@Test public void testHistory(){ ProcessEngine processEngine=configuration.buildProcessEngine(); HistoryService historyService=processEngine.getHistoryService(); List\u0026lt;HistoricActivityInstance\u0026gt; list = historyService.createHistoricActivityInstanceQuery() .processDefinitionId(\u0026#34;holidayRequest:1:7503\u0026#34;) .finished() //查询已经完成的 .orderByHistoricActivityInstanceEndTime().asc() //指定排序字段和升降序 .list(); for(HistoricActivityInstance history:list){ //注意,和视频不一样的地方,history表还记录了流程箭头流向的那个节点 //_flow_ System.out.println( \u0026#34;活动名--\u0026#34;+history.getActivityName()+ \u0026#34;处理人--\u0026#34;+history.getAssignee()+ \u0026#34;活动id--\u0026#34;+history.getActivityId()+ \u0026#34;处理时长--\u0026#34;+history.getDurationInMillis()+\u0026#34;毫秒\u0026#34;); } 不一样的地方,在旧版本时没有的 流程设计器 # 有eclipse流程设计器,和flowable流程设计器\n使用eclipse的设计,会生成一个bar文件,代码稍微有点不同 接收一个ZipInputStream\nFlowableUI # 使用flowable官方提供的包,里面有一个war,直接用命令 java -jar xx.war启动即可 这个应用分成四个模块 流程图的绘制及用户分配 "},{"id":394,"href":"/zh/docs/technology/Linux/hanshunping_/12-20/","title":"linux_韩老师_12-20","section":"韩顺平老师_","content":" 目录结构 # 目录结构很重要\nwindows下 linux下,从根目录开始分支 /,/root (root用户),/home (创建的用户的目录),/bin(常用的指令),/etc(环境配置)\n在linux世界里,一切皆文件\ncpu被映射成文件\n硬盘 具体的目录结构\n/bin 常用,binary的缩写,存放常用的命令 (/usr/bin、/usr/local/bin) /sbin (/usr/sbin、/usr/local/sbin) SuperUser,存放的是系统管理员使用的系统管理程序\n/home 存放普通用户的主目录\nuseradd jack 之后看该目录 删掉 userdel -r jack 目录消失 /root 该目录为系统管理员,也称超级管理员的用户的主目录\n/lib 系统开机所需要的最基本的动态连接共享库,其作用类似于windows里的DLL,几乎所有的应用程序都需要用到这些共享库\nlost+found 一般为空,非法关机后会存放文件\n/etc 系统管理所需要的配置文件和子目录,比如mysql的my.conf\n/usr 用户的应用程序和文件,类似windows的program files\n/boot 启动Linux时使用的核心文件(破坏则无法启动)\n/proc (不能动) 虚拟目录,系统内存的映射,访问这个目录获取系统信息\n/srv (不能动) service的缩写,存放服务启动之后需要提取的数据\n/sys (不能动) 安装了2.6内核中新出现的文件系统 sysfs\n/tmp 这个目录用来存放一些临时文件\n/dev 类似windows设备管理器,将硬件映射成文件\n/media linux系统会自动识别一些设备,u盘、光驱,将识别的设备映射到该目录下\n/mnt 为了让用户挂载别的文件系统,比如将外部的存储挂载到该目录 /opt 给主机额外安装软件所存放的目录\n/usr/local 给主机额外安装软件所安装的目录,一般通过编译源码方式安装的程序\n/var 日志,不断扩充的东西 /selinux [security-enhanced linux] 安全子系统,控制程序只能访问特定文件 (启用之后才能看到)\n远程登陆 # 背景 linux服务器开发小组共享 正式上线项目运行在公网,所以需要远程开发部署 图解 软件 xshell 和xftp https://www.xshell.com/zh/free-for-home-school/ 使用ifconfig 查看ip 先添加网络工具包 yum install net-tools -y 使用 在客户端打开cmd,并使用ping命令 xshell中配置并进行连接 按住ctrl+鼠标滚轴可以放大字体 远程文件传输 # xtfp6 person安装 新建连接配置 文件夹 可以在这里直接复制上传 图解 解决乱码问题 reboot vim快捷键 # vi :linux内置vi文本编辑器 vim是vi的增强版本,有丰富的字体颜色\n常用的三种模式\n正常模式,使用上下左右、复制粘贴 插入模式 正常模式\u0026ndash;\u0026gt;插入模式 按下i I o O a A r R(一般用i) 命令行模式 插入模式\u0026ndash;\u0026gt;命令行 输入输入esc表示退出,然后输入: 输入wq表示保存并退出 编辑,重新vim Hello.java 下面,这时候按tab可以自动补全 命令 快捷键使用\n正常模式下\n输入yy,拷贝当前行。p进行粘贴 4yy,拷贝当前行(包括)往下4行\n输入dd,删除当前行 4dd,删除当前行(包括)往下4行\n定位到首行(gg)或者末行G\n使用u,撤回刚才的输入(lalala将被撤回) 定位到20行 (20+shift+g)【其实是20+G】\n命令模式 :切换到命令行)\n命令行模式下(:下),输入 /搜索内容\n或者(/)下,直接输入搜索内容\n再次输入 / ,就会清空前面的搜索\n设置文件行号(:下) set nu 设置;set nonu 取消 如果修改太多,需要先拷贝到windows下,然后再传上来\nvim/vi 快捷键 关机重启 # 命令 halt 停止\nshutdown -h now #立刻关机 shutdown -h 1 #给出提示并关机 shutdown -r now #现在重启计算机 halt #立刻关机(虚拟机好像只是把cpu关闭?) reboot #立刻重启 sync #将内存的数据同步到磁盘 sync #将内存的数据同步到磁盘 shutdown/reboot/halt等命令都会在执行前执行sync 登录注销 # 尽量不要用root账号登录\n普通用户登陆后,用su - 用户名 切换成系统管理员身份 logout 注销用户(图形页面没效果) 在运行级别3下有效 "},{"id":395,"href":"/zh/docs/technology/Linux/hanshunping_/07-11/","title":"linux_韩老师_07-11","section":"韩顺平老师_","content":" 网络连接 # 网络连接的三种模式 同一个教室的三个主机 此时三个同学可以正常通讯 桥接模式 这是张三的虚拟机和外部互通;但是如果这样设置,ip会不够用; NAT模式 如图,虚拟机可以跟虚拟的网卡(192.168.100.99)互通,且通过这个虚拟网卡,及(192.168.0.50代理),与外界(192.168.0.X)互通 NAT模式,网络地址转换模式,虚拟系统和外部系统通讯,不造成IP冲突 注意,这里外部其他主机(除0.50和100.99)是访问不到100.88的 主机模式:独立的系统 虚拟机克隆 # 方式1,直接拷贝整个文件夹 方式2,使用VMWare 克隆前先把克隆目标关闭 克隆虚拟机当前状态\u0026ndash;创建完整克隆 虚拟机快照 # 为什么需要虚拟机快照 快照a 之后创建了文件夹hello 然后拍摄快照b 之后创建了文件夹hello2 然后拍摄快照c\n目前 回到快照A 之后会重启,效果(两个文件夹都没有了)\n如果恢复到B,然后再创建一个快照,就会变成 虚拟机迁移 # 直接剪切、删除,即可 vmtools工具 # 如下步骤,注意,这里只是在有界面的情况下进行安装 安装完毕后 在vm上面设置 共享文件夹在linux中的路径 /mnt/hgfs/myshare "},{"id":396,"href":"/zh/docs/technology/Flowable/offical/05/","title":"Flowable-05-spring-boot","section":"官方文档","content":" 入门 # 需要两个依赖\n\u0026lt;properties\u0026gt; \u0026lt;flowable.version\u0026gt;6.7.2\u0026lt;/flowable.version\u0026gt; \u0026lt;/properties\u0026gt; \u0026lt;dependencies\u0026gt; \u0026lt;dependency\u0026gt; \u0026lt;groupId\u0026gt;org.flowable\u0026lt;/groupId\u0026gt; \u0026lt;artifactId\u0026gt;flowable-spring-boot-starter\u0026lt;/artifactId\u0026gt; \u0026lt;version\u0026gt;${flowable.version}\u0026lt;/version\u0026gt; \u0026lt;/dependency\u0026gt; \u0026lt;!-- https://mvnrepository.com/artifact/com.h2database/h2 --\u0026gt; \u0026lt;dependency\u0026gt; \u0026lt;groupId\u0026gt;com.h2database\u0026lt;/groupId\u0026gt; \u0026lt;artifactId\u0026gt;h2\u0026lt;/artifactId\u0026gt; \u0026lt;version\u0026gt;2.1.212\u0026lt;/version\u0026gt; \u0026lt;/dependency\u0026gt; \u0026lt;/dependencies\u0026gt; 结合Spring:\n只需将依赖项添加到类路径并使用*@SpringBootApplication*注释,幕后就会发生很多事情:\n自动创建内存数据源(因为 H2 驱动程序位于类路径中)并传递给 Flowable 流程引擎配置\n已创建并公开了 Flowable ProcessEngine、CmmnEngine、DmnEngine、FormEngine、ContentEngine 和 IdmEngine bean\n所有 Flowable 服务都暴露为 Spring bean\nSpring Job Executor 已创建\n将自动部署流程文件夹中的任何 BPMN 2.0 流程定义。创建一个文件夹processes并将一个虚拟进程定义(名为one-task-process.bpmn20.xml)添加到此文件夹。该文件的内容如下所示。\n\u0026lt;?xml version=\u0026#34;1.0\u0026#34; encoding=\u0026#34;UTF-8\u0026#34;?\u0026gt; \u0026lt;definitions xmlns=\u0026#34;http://www.omg.org/spec/BPMN/20100524/MODEL\u0026#34; xmlns:flowable=\u0026#34;http://flowable.org/bpmn\u0026#34; targetNamespace=\u0026#34;Examples\u0026#34;\u0026gt; \u0026lt;process id=\u0026#34;oneTaskProcess\u0026#34; name=\u0026#34;The One Task Process\u0026#34;\u0026gt; \u0026lt;startEvent id=\u0026#34;theStart\u0026#34; /\u0026gt; \u0026lt;sequenceFlow id=\u0026#34;flow1\u0026#34; sourceRef=\u0026#34;theStart\u0026#34; targetRef=\u0026#34;theTask\u0026#34; /\u0026gt; \u0026lt;userTask id=\u0026#34;theTask\u0026#34; name=\u0026#34;my task\u0026#34; flowable:assignee=\u0026#34;kermit\u0026#34; /\u0026gt; \u0026lt;sequenceFlow id=\u0026#34;flow2\u0026#34; sourceRef=\u0026#34;theTask\u0026#34; targetRef=\u0026#34;theEnd\u0026#34; /\u0026gt; \u0026lt;endEvent id=\u0026#34;theEnd\u0026#34; /\u0026gt; \u0026lt;/process\u0026gt; \u0026lt;/definitions\u0026gt; 案例文件夹中的任何 CMMN 1.1 案例定义都将自动部署。\n将自动部署dmn文件夹中的任何 DMN 1.1 dmn 定义。\n表单文件夹中的任何表单定义都将自动部署。\njava代码 在项目服务启动的时候就去加载一些数据\n@SpringBootApplication(proxyBeanMethods = false) public class MyApplication { public static void main(String[] args) { SpringApplication.run(MyApplication.class, args); } @Bean public CommandLineRunner init(final RepositoryService repositoryService, final RuntimeService runtimeService, final TaskService taskService) { //该bean在项目服务启动的时候就去加载一些数据 return new CommandLineRunner() { @Override public void run(String... strings) throws Exception { //有几个流程定义 System.out.println(\u0026#34;Number of process definitions : \u0026#34; + repositoryService.createProcessDefinitionQuery().count()); //有多少个任务 System.out.println(\u0026#34;Number of tasks : \u0026#34; + taskService.createTaskQuery().count()); runtimeService.startProcessInstanceByKey(\u0026#34;oneTaskProcess\u0026#34;); //开启流程后有多少个任务(+1) System.out.println(\u0026#34;Number of tasks after process start: \u0026#34; + taskService.createTaskQuery().count()); } }; } } 更改数据库 # 添加依赖\n\u0026lt;dependency\u0026gt; \u0026lt;groupId\u0026gt;mysql\u0026lt;/groupId\u0026gt; \u0026lt;artifactId\u0026gt;mysql-connector-java\u0026lt;/artifactId\u0026gt; \u0026lt;version\u0026gt;8.0.29\u0026lt;/version\u0026gt; \u0026lt;/dependency\u0026gt; application.yml中添加配置\nspring: datasource: url: jdbc:mysql://localhost:3306/flowable-spring-boot?useUnicode=true\u0026amp;characterEncoding=utf-8\u0026amp;allowMultiQueries=true\u0026amp;nullCatalogMeansCurrent=true username: root password: 123456 driver-class-name: com.mysql.jdbc.Driver Rest支持 # web支持\n\u0026lt;parent\u0026gt; \u0026lt;groupId\u0026gt;org.springframework.boot\u0026lt;/groupId\u0026gt; \u0026lt;artifactId\u0026gt;spring-boot-starter-parent\u0026lt;/artifactId\u0026gt; \u0026lt;version\u0026gt;2.6.7\u0026lt;/version\u0026gt; \u0026lt;/parent\u0026gt; 添加依赖\n\u0026lt;dependency\u0026gt; \u0026lt;groupId\u0026gt;org.springframework.boot\u0026lt;/groupId\u0026gt; \u0026lt;artifactId\u0026gt;spring-boot-starter-web\u0026lt;/artifactId\u0026gt; \u0026lt;/dependency\u0026gt; 使用Service启动流程及获取给定受让人的任务\n@Service public class MyService { @Autowired private RuntimeService runtimeService; @Autowired private TaskService taskService; @Transactional public void startProcess() { runtimeService.startProcessInstanceByKey(\u0026#34;oneTaskProcess\u0026#34;); } @Transactional public List\u0026lt;Task\u0026gt; getTasks(String assignee) { return taskService.createTaskQuery().taskAssignee(assignee).list(); } } 创建REST端点\n@RestController public class MyRestController { @Autowired private MyService myService; @PostMapping(value=\u0026#34;/process\u0026#34;) public void startProcessInstance() { myService.startProcess(); } @RequestMapping(value=\u0026#34;/tasks\u0026#34;, method= RequestMethod.GET, produces=MediaType.APPLICATION_JSON_VALUE) public List\u0026lt;TaskRepresentation\u0026gt; getTasks(@RequestParam String assignee) { List\u0026lt;Task\u0026gt; tasks = myService.getTasks(assignee); List\u0026lt;TaskRepresentation\u0026gt; dtos = new ArrayList\u0026lt;TaskRepresentation\u0026gt;(); for (Task task : tasks) { dtos.add(new TaskRepresentation(task.getId(), task.getName())); } return dtos; } static class TaskRepresentation { private String id; private String name; public TaskRepresentation(String id, String name) { this.id = id; this.name = name; } public String getId() { return id; } public void setId(String id) { this.id = id; } public String getName() { return name; } public void setName(String name) { this.name = name; } } } 使用下面语句进行测试\ncurl http://localhost:8080/tasks?assignee=kermit [] curl -X POST http://localhost:8080/process curl http://localhost:8080/tasks?assignee=kermit [{\u0026#34;id\u0026#34;:\u0026#34;10004\u0026#34;,\u0026#34;name\u0026#34;:\u0026#34;my task\u0026#34;}] JPA支持 # 添加依赖\n\u0026lt;dependency\u0026gt; \u0026lt;groupId\u0026gt;org.springframework.boot\u0026lt;/groupId\u0026gt; \u0026lt;artifactId\u0026gt;spring-boot-starter-data-jpa\u0026lt;/artifactId\u0026gt; \u0026lt;/dependency\u0026gt; 创建一个实体类\n@Entity class Person { @Id @GeneratedValue private Long id; private String username; private String firstName; private String lastName; private Date birthDate; public Person() { } public Person(String username, String firstName, String lastName, Date birthDate) { this.username = username; this.firstName = firstName; this.lastName = lastName; this.birthDate = birthDate; } public Long getId() { return id; } public void setId(Long id) { this.id = id; } public String getUsername() { return username; } public void setUsername(String username) { this.username = username; } public String getFirstName() { return firstName; } public void setFirstName(String firstName) { this.firstName = firstName; } public String getLastName() { return lastName; } public void setLastName(String lastName) { this.lastName = lastName; } public Date getBirthDate() { return birthDate; } public void setBirthDate(Date birthDate) { this.birthDate = birthDate; } } 属性文件添加\nspring.jpa.hibernate.ddl-auto=update 添加Repository类\n@Repository public interface PersonRepository extends JpaRepository\u0026lt;Person, Long\u0026gt; { Person findByUsername(String username); } 代码\n添加事务\nstartProcess现在修改成:获取传入的受理人用户名,查找Person,并将PersonJPA对象作为流程变量放入流程实例中\n在CommandLineRunner中初始化时创建用户\n@Service @Transactional public class MyService { @Autowired private RuntimeService runtimeService; @Autowired private TaskService taskService; @Autowired private PersonRepository personRepository; public void startProcess(String assignee) { Person person = personRepository.findByUsername(assignee); Map\u0026lt;String, Object\u0026gt; variables = new HashMap\u0026lt;String, Object\u0026gt;(); variables.put(\u0026#34;person\u0026#34;, person); runtimeService.startProcessInstanceByKey(\u0026#34;oneTaskProcess\u0026#34;, variables); } public List\u0026lt;Task\u0026gt; getTasks(String assignee) { return taskService.createTaskQuery().taskAssignee(assignee).list(); } public void createDemoUsers() { if (personRepository.findAll().size() == 0) { personRepository.save(new Person(\u0026#34;jbarrez\u0026#34;, \u0026#34;Joram\u0026#34;, \u0026#34;Barrez\u0026#34;, new Date())); personRepository.save(new Person(\u0026#34;trademakers\u0026#34;, \u0026#34;Tijs\u0026#34;, \u0026#34;Rademakers\u0026#34;, new Date())); } } } CommandRunner修改\n@Bean public CommandLineRunner init(final MyService myService) { return new CommandLineRunner() { public void run(String... strings) throws Exception { myService.createDemoUsers(); } }; } RestController修改\n@RestController public class MyRestController { @Autowired private MyService myService; @PostMapping(value=\u0026#34;/process\u0026#34;) public void startProcessInstance(@RequestBody StartProcessRepresentation startProcessRepresentation) { myService.startProcess(startProcessRepresentation.getAssignee()); } ... static class StartProcessRepresentation { private String assignee; public String getAssignee() { return assignee; } public void setAssignee(String assignee) { this.assignee = assignee; } } 修改流程定义\n\u0026lt;userTask id=\u0026#34;theTask\u0026#34; name=\u0026#34;my task\u0026#34; flowable:assignee=\u0026#34;${person.id}\u0026#34;/\u0026gt; 测试\n启动spring boot之后person表会有两条数据\n启动流程实例\n此时会把从数据库查找到的person传入流程图(变量)\ncurl -H \u0026#34;Content-Type: application/json\u0026#34; -d \u0026#39;{\u0026#34;assignee\u0026#34; : \u0026#34;jbarrez\u0026#34;}\u0026#39; http://localhost:8080/process 使用id获取任务列表\ncurl http://localhost:8080/tasks?assignee=1 [{\u0026#34;id\u0026#34;:\u0026#34;12505\u0026#34;,\u0026#34;name\u0026#34;:\u0026#34;my task\u0026#34;}] 可流动的执行器端点 # "},{"id":397,"href":"/zh/docs/technology/Flowable/offical/04/","title":"Flowable-04-spring","section":"官方文档","content":" ProcessEngineFactoryBean # 将ProcessEngine配置为常规的SpringBean\n\u0026lt;bean id=\u0026#34;processEngineConfiguration\u0026#34; class=\u0026#34;org.flowable.spring.SpringProcessEngineConfiguration\u0026#34;\u0026gt; ... \u0026lt;/bean\u0026gt; \u0026lt;bean id=\u0026#34;processEngine\u0026#34; class=\u0026#34;org.flowable.spring.ProcessEngineFactoryBean\u0026#34;\u0026gt; \u0026lt;property name=\u0026#34;processEngineConfiguration\u0026#34; ref=\u0026#34;processEngineConfiguration\u0026#34; /\u0026gt; \u0026lt;/bean\u0026gt; 使用transaction\n\u0026lt;beans xmlns=\u0026#34;http://www.springframework.org/schema/beans\u0026#34; xmlns:context=\u0026#34;http://www.springframework.org/schema/context\u0026#34; xmlns:tx=\u0026#34;http://www.springframework.org/schema/tx\u0026#34; xmlns:xsi=\u0026#34;http://www.w3.org/2001/XMLSchema-instance\u0026#34; xsi:schemaLocation=\u0026#34;http://www.springframework.org/schema/beans http://www.springframework.org/schema/beans/spring-beans.xsd http://www.springframework.org/schema/context http://www.springframework.org/schema/context/spring-context-2.5.xsd http://www.springframework.org/schema/tx http://www.springframework.org/schema/tx/spring-tx-3.0.xsd\u0026#34;\u0026gt; \u0026lt;bean id=\u0026#34;dataSource\u0026#34; class=\u0026#34;org.springframework.jdbc.datasource.SimpleDriverDataSource\u0026#34;\u0026gt; \u0026lt;property name=\u0026#34;driverClass\u0026#34; value=\u0026#34;org.h2.Driver\u0026#34; /\u0026gt; \u0026lt;property name=\u0026#34;url\u0026#34; value=\u0026#34;jdbc:h2:mem:flowable;DB_CLOSE_DELAY=1000\u0026#34; /\u0026gt; \u0026lt;property name=\u0026#34;username\u0026#34; value=\u0026#34;sa\u0026#34; /\u0026gt; \u0026lt;property name=\u0026#34;password\u0026#34; value=\u0026#34;\u0026#34; /\u0026gt; \u0026lt;/bean\u0026gt; \u0026lt;bean id=\u0026#34;transactionManager\u0026#34; class=\u0026#34;org.springframework.jdbc.datasource.DataSourceTransactionManager\u0026#34;\u0026gt; \u0026lt;property name=\u0026#34;dataSource\u0026#34; ref=\u0026#34;dataSource\u0026#34; /\u0026gt; \u0026lt;/bean\u0026gt; \u0026lt;bean id=\u0026#34;processEngineConfiguration\u0026#34; class=\u0026#34;org.flowable.spring.SpringProcessEngineConfiguration\u0026#34;\u0026gt; \u0026lt;property name=\u0026#34;dataSource\u0026#34; ref=\u0026#34;dataSource\u0026#34; /\u0026gt; \u0026lt;property name=\u0026#34;transactionManager\u0026#34; ref=\u0026#34;transactionManager\u0026#34; /\u0026gt; \u0026lt;property name=\u0026#34;databaseSchemaUpdate\u0026#34; value=\u0026#34;true\u0026#34; /\u0026gt; \u0026lt;property name=\u0026#34;asyncExecutorActivate\u0026#34; value=\u0026#34;false\u0026#34; /\u0026gt; \u0026lt;/bean\u0026gt; \u0026lt;bean id=\u0026#34;processEngine\u0026#34; class=\u0026#34;org.flowable.spring.ProcessEngineFactoryBean\u0026#34;\u0026gt; \u0026lt;property name=\u0026#34;processEngineConfiguration\u0026#34; ref=\u0026#34;processEngineConfiguration\u0026#34; /\u0026gt; \u0026lt;/bean\u0026gt; \u0026lt;bean id=\u0026#34;repositoryService\u0026#34; factory-bean=\u0026#34;processEngine\u0026#34; factory-method=\u0026#34;getRepositoryService\u0026#34; /\u0026gt; \u0026lt;bean id=\u0026#34;runtimeService\u0026#34; factory-bean=\u0026#34;processEngine\u0026#34; factory-method=\u0026#34;getRuntimeService\u0026#34; /\u0026gt; \u0026lt;bean id=\u0026#34;taskService\u0026#34; factory-bean=\u0026#34;processEngine\u0026#34; factory-method=\u0026#34;getTaskService\u0026#34; /\u0026gt; \u0026lt;bean id=\u0026#34;historyService\u0026#34; factory-bean=\u0026#34;processEngine\u0026#34; factory-method=\u0026#34;getHistoryService\u0026#34; /\u0026gt; \u0026lt;bean id=\u0026#34;managementService\u0026#34; factory-bean=\u0026#34;processEngine\u0026#34; factory-method=\u0026#34;getManagementService\u0026#34; /\u0026gt; ... 还包括了其他的一些bean\n\u0026lt;beans\u0026gt; ... \u0026lt;tx:annotation-driven transaction-manager=\u0026#34;transactionManager\u0026#34;/\u0026gt; \u0026lt;bean id=\u0026#34;userBean\u0026#34; class=\u0026#34;org.flowable.spring.test.UserBean\u0026#34;\u0026gt; \u0026lt;property name=\u0026#34;runtimeService\u0026#34; ref=\u0026#34;runtimeService\u0026#34; /\u0026gt; \u0026lt;/bean\u0026gt; \u0026lt;bean id=\u0026#34;printer\u0026#34; class=\u0026#34;org.flowable.spring.test.Printer\u0026#34; /\u0026gt; \u0026lt;/beans\u0026gt; 使用\n使用XML资源方式类配置Spring应用程序上下文\nClassPathXmlApplicationContext applicationContext = new ClassPathXmlApplicationContext( \u0026#34;org/flowable/examples/spring/SpringTransactionIntegrationTest-context.xml\u0026#34;); 或者添加注解\n@ContextConfiguration( \u0026#34;classpath:org/flowable/spring/test/transaction/SpringTransactionIntegrationTest-context.xml\u0026#34;) 获取服务bean并进行部署流程\nRepositoryService repositoryService = (RepositoryService) applicationContext.getBean(\u0026#34;repositoryService\u0026#34;); String deploymentId = repositoryService .createDeployment() .addClasspathResource(\u0026#34;org/flowable/spring/test/hello.bpmn20.xml\u0026#34;) .deploy() .getId(); 下面看userBean类,使用了Transaction事务\npublic class UserBean { /** injected by Spring */ private RuntimeService runtimeService; @Transactional public void hello() { // here you can do transactional stuff in your domain model // and it will be combined in the same transaction as // the startProcessInstanceByKey to the Flowable RuntimeService runtimeService.startProcessInstanceByKey(\u0026#34;helloProcess\u0026#34;); } public void setRuntimeService(RuntimeService runtimeService) { this.runtimeService = runtimeService; } } 使用userBean\nUserBean userBean = (UserBean) applicationContext.getBean(\u0026#34;userBean\u0026#34;); userBean.hello(); 表达式 # BPMN 流程中的所有 表达式也将默认“看到”所有 Spring bean\n要完全不暴露任何 bean,只需将一个空列表作为 SpringProcessEngineConfiguration 上的“beans”属性传递。当没有设置 \u0026lsquo;beans\u0026rsquo; 属性时,上下文中的所有 Spring beans 都将可用\n如下,可以设置暴露的bean\n\u0026lt;bean id=\u0026#34;processEngineConfiguration\u0026#34; class=\u0026#34;org.flowable.spring.SpringProcessEngineConfiguration\u0026#34;\u0026gt; ... \u0026lt;property name=\u0026#34;beans\u0026#34;\u0026gt; \u0026lt;map\u0026gt; \u0026lt;entry key=\u0026#34;printer\u0026#34; value-ref=\u0026#34;printer\u0026#34; /\u0026gt; \u0026lt;/map\u0026gt; \u0026lt;/property\u0026gt; \u0026lt;/bean\u0026gt; \u0026lt;bean id=\u0026#34;printer\u0026#34; class=\u0026#34;org.flowable.examples.spring.Printer\u0026#34; /\u0026gt; 现在的bean进行公开了,在.bpmn20.xml中可以使用\n\u0026lt;definitions id=\u0026#34;definitions\u0026#34;\u0026gt; \u0026lt;process id=\u0026#34;helloProcess\u0026#34;\u0026gt; \u0026lt;startEvent id=\u0026#34;start\u0026#34; /\u0026gt; \u0026lt;sequenceFlow id=\u0026#34;flow1\u0026#34; sourceRef=\u0026#34;start\u0026#34; targetRef=\u0026#34;print\u0026#34; /\u0026gt; \u0026lt;serviceTask id=\u0026#34;print\u0026#34; flowable:expression=\u0026#34;#{printer.printMessage()}\u0026#34; /\u0026gt; \u0026lt;sequenceFlow id=\u0026#34;flow2\u0026#34; sourceRef=\u0026#34;print\u0026#34; targetRef=\u0026#34;end\u0026#34; /\u0026gt; \u0026lt;endEvent id=\u0026#34;end\u0026#34; /\u0026gt; \u0026lt;/process\u0026gt; \u0026lt;/definitions\u0026gt; Print类\npublic class Printer { public void printMessage() { System.out.println(\u0026#34;hello world\u0026#34;); } } spring配置bean\n\u0026lt;beans\u0026gt; ... \u0026lt;bean id=\u0026#34;printer\u0026#34; class=\u0026#34;org.flowable.examples.spring.Printer\u0026#34; /\u0026gt; \u0026lt;/beans\u0026gt; 自动资源部署 # \u0026lt;bean id=\u0026#34;processEngineConfiguration\u0026#34; class=\u0026#34;org.flowable.spring.SpringProcessEngineConfiguration\u0026#34;\u0026gt; ... \u0026lt;property name=\u0026#34;deploymentResources\u0026#34; value=\u0026#34;classpath*:/org/flowable/spring/test/autodeployment/autodeploy.*.bpmn20.xml\u0026#34; /\u0026gt; \u0026lt;/bean\u0026gt; \u0026lt;bean id=\u0026#34;processEngine\u0026#34; class=\u0026#34;org.flowable.spring.ProcessEngineFactoryBean\u0026#34;\u0026gt; \u0026lt;property name=\u0026#34;processEngineConfiguration\u0026#34; ref=\u0026#34;processEngineConfiguration\u0026#34; /\u0026gt; \u0026lt;/bean\u0026gt; 单元测试 # @ExtendWith(FlowableSpringExtension.class) @ExtendWith(SpringExtension.class) @ContextConfiguration(classes = SpringJunitJupiterTest.TestConfiguration.class) public class MyBusinessProcessTest { @Autowired private RuntimeService runtimeService; @Autowired private TaskService taskService; @Test @Deployment void simpleProcessTest() { runtimeService.startProcessInstanceByKey(\u0026#34;simpleProcess\u0026#34;); Task task = taskService.createTaskQuery().singleResult(); assertEquals(\u0026#34;My Task\u0026#34;, task.getName()); taskService.complete(task.getId()); assertEquals(0, runtimeService.createProcessInstanceQuery().count()); } } "},{"id":398,"href":"/zh/docs/technology/Flowable/offical/03/","title":"Flowable-03-api","section":"官方文档","content":" 流程引擎API和服务 # 引擎API是与Flowable交互的常见方式,主要起点是ProcessEngine,可以通过配置(Configuration章节)中描述的多种方式创建。\n从ProcessEngine获取包含工作流/BPM方法的各种服务。ProcessEngine和服务对象是线程安全的\n下面是通过processEngine获取各种服务的方法\nProcessEngine processEngine = ProcessEngines.getDefaultProcessEngine(); RuntimeService runtimeService = processEngine.getRuntimeService(); RepositoryService repositoryService = processEngine.getRepositoryService(); TaskService taskService = processEngine.getTaskService(); ManagementService managementService = processEngine.getManagementService(); IdentityService identityService = processEngine.getIdentityService(); HistoryService historyService = processEngine.getHistoryService(); FormService formService = processEngine.getFormService(); DynamicBpmnService dynamicBpmnService = processEngine.getDynamicBpmnService(); ProcessEngines.getDefaultProcessEngine()在第一次调用时初始化并构建流程引擎,然后返回相同的流程引擎\nProcessEngines类将扫描所有flowable.cfg.xml和flowable-context.xml文件。\n对于所有 flowable.cfg.xml 文件,流程引擎将以典型的 Flowable 方式构建:ProcessEngineConfiguration.createProcessEngineConfigurationFromInputStream(inputStream).buildProcessEngine()。\n对于所有 flowable-context.xml 文件,流程引擎将以 Spring 方式构建:首先创建 Spring 应用程序上下文,然后从该应用程序上下文中获取流程引擎。\nThe RepositoryService is probably the first service needed when working with the Flowable engine.\n该服务**(RepositoryService)提供用于管理和操作部署deployments**和流程定义的操作\n查询引擎已知的部署和流程定义 暂停和激活作为一个整体或特定流程定义的部署。挂起意味着不能对它们执行进一步的操作,而激活则相反并再次启用操作 检索各种资源,例如引擎自动生成的部署或流程图中包含的文件 检索流程定义的 POJO 版本,该版本可用于使用 Java 而不是 XML 来内省流程 RepositoryService主要是关于静态信息(不会改变的数据,或者至少不会改变太多),而RuntimeService处理启动流程定义的新流程实例\n流程定义定义了流程中不同步骤的结构和行为,流程实例是此类流程定义的一次执行\n对于每个流程定义,通常有许多实例同时运行\nRuntime也用于检索和存储流程变量\nRuntimeservice还可以用来查询流程实例和执行(executions)\nExecutions are a representation of the \u0026rsquo;token\u0026rsquo; concept of BPMN 2.0. 执行是指向流程实例当前所在位置的指针\n只要流程实例正在等待外部触发器并且流程需要继续,就会使用 RuntimeService\n流程实例可以有各种等待状态,并且该服务包含各种操作以向实例发出“信号”,即接收到外部触发器并且流程实例可以继续\n需要由系统的人类用户执行的任务是BPM引擎(如Floable)的核心,围绕任务的所有内容都在TaskService中进行分组\n查询分配给用户或组的任务 创建新的独立任务(与流程实例无关) 任务被分配给哪个用户或哪些用户,以及让这些用户以某种方式参与该任务 要求并完成一项任务,声明意味着某人决定成为该任务的受让人assignee IdentityService支持组和用户的管理(创建、更新、删除、查询)\nFormService是可选服务,引入了启动表单(start form)和任务表单(a task form)的概念\nHistoryService公开了 Flowable 引擎收集的所有历史数据。在执行流程时,引擎可以保留很多数据(这是可配置的),例如流程实例的启动时间,谁做了哪些任务,完成任务花了多长时间,每个流程实例中遵循的路径,等等。\n使用Flowable 编写自定义应用程序时,通常不需要**ManagementService 。**它允许检索有关数据库表和表元数据的信息。此外,它还公开了作业的查询功能和管理操作\nDynamicBpmnService可用于更改流程定义的一部分,而无需重新部署它。例如,您可以更改流程定义中用户任务的受理人定义,或更改服务任务的类名。\n异常策略 # Flowable 中的基本异常是 org.flowable.engine.FlowableException\nFlowable的一些异常子类\nFlowableWrongDbException:当 Flowable 引擎发现数据库架构版本和引擎版本不匹配时抛出。 FlowableOptimisticLockingException:当并发访问同一数据条目导致数据存储发生乐观锁定时抛出。 FlowableClassLoadingException:当请求加载的类未找到或加载时发生错误时抛出(例如 JavaDelegates、TaskListeners \u0026hellip;\u0026hellip;)。 FlowableObjectNotFoundException:当请求或操作的对象不存在时抛出。 FlowableIllegalArgumentException:异常表明在 Flowable API 调用中提供了非法参数,在引擎配置中配置了非法值,或者提供了非法值,或者在流程定义中使用了非法值。 FlowableTaskAlreadyClaimedException:当任务已被声明时抛出,当 taskService.claim(\u0026hellip;) 被调用时 查询接口 # 引擎查询数据有两种方式:the query API and native queries\nqueryAPi允许使用fluent API编写完全类型安全的查询,例如\nList\u0026lt;Task\u0026gt; tasks = taskService.createTaskQuery() .taskAssignee(\u0026#34;kermit\u0026#34;) .processVariableValueEquals(\u0026#34;orderId\u0026#34;, \u0026#34;0815\u0026#34;) .orderByDueDate().asc() .list(); native queries (返回类型由您使用的查询对象定义,数据映射到正确的对象[比如任务、流程实例、执行等,且您必须使用在数据库中定义的表明和列名])。如下,可以通过api检索表名等,使依赖关系尽可能小\nList\u0026lt;Task\u0026gt; tasks = taskService.createNativeTaskQuery() .sql(\u0026#34;SELECT count(*) FROM \u0026#34; + managementService.getTableName(Task.class) + \u0026#34; T WHERE T.NAME_ = #{taskName}\u0026#34;) .parameter(\u0026#34;taskName\u0026#34;, \u0026#34;gonzoTask\u0026#34;) .list(); long count = taskService.createNativeTaskQuery() .sql(\u0026#34;SELECT count(*) FROM \u0026#34; + managementService.getTableName(Task.class) + \u0026#34; T1, \u0026#34; + managementService.getTableName(VariableInstanceEntity.class) + \u0026#34; V1 WHERE V1.TASK_ID_ = T1.ID_\u0026#34;) .count(); 变量 # 每个流程实例都需要并使用数据来执行其组成的步骤。在 Flowable 中,这些数据称为变量,存储在数据库中\n流程实例可以有变量(称为流程变量),也可以有执行(指向流程处于活动状态的特定指针)。用户任务也可以有变量,变量存储在ACT_RU_VARIABLE数据库表中\n所有startProcessInstanceXXX方法都有一个可选参数,用于在创建和启动流程实例时提供变量\nProcessInstance startProcessInstanceByKey(String processDefinitionKey, Map\u0026lt;String, Object\u0026gt; variables); 可以在流程执行期间添加变量。例如,(RuntimeService)\nvoid setVariable(String executionId, String variableName, Object value); void setVariableLocal(String executionId, String variableName, Object value); void setVariables(String executionId, Map\u0026lt;String, ? extends Object\u0026gt; variables); void setVariablesLocal(String executionId, Map\u0026lt;String, ? extends Object\u0026gt; variables); 检索变量 TaskService上存在类似的方法。这意味着任务(如执行)可以具有仅在任务期间“活动”的局部变量\nMap\u0026lt;String, Object\u0026gt; getVariables(String executionId); Map\u0026lt;String, Object\u0026gt; getVariablesLocal(String executionId); Map\u0026lt;String, Object\u0026gt; getVariables(String executionId, Collection\u0026lt;String\u0026gt; variableNames); Map\u0026lt;String, Object\u0026gt; getVariablesLocal(String executionId, Collection\u0026lt;String\u0026gt; variableNames); Object getVariable(String executionId, String variableName); \u0026lt;T\u0026gt; T getVariable(String executionId, String variableName, Class\u0026lt;T\u0026gt; variableClass); 当前执行或任务对象是可用的,它可以用于变量设置和/或检索\nexecution.getVariables(); execution.getVariables(Collection\u0026lt;String\u0026gt; variableNames); execution.getVariable(String variableName); execution.setVariables(Map\u0026lt;String, object\u0026gt; variables); execution.setVariable(String variableName, Object value); 在执行上述任何调用时,所有变量都会在后台从数据库中获取。这意味着,如果您有 10 个变量,但只能通过*getVariable(\u0026ldquo;myVariable\u0026rdquo;)*获得一个,那么在幕后将获取并缓存其他 9 个\n接上述,可以设置是否缓存所有变量\nMap\u0026lt;String, Object\u0026gt; getVariables(Collection\u0026lt;String\u0026gt; variableNames, boolean fetchAllVariables); Object getVariable(String variableName, boolean fetchAllVariables); void setVariable(String variableName, Object value, boolean fetchAllVariables); 瞬态变量 # 瞬态变量是行为类似于常规变量但不持久的变量。通常,瞬态变量用于高级用例\n对于瞬态变量,根本没有存储历史记录。 与常规变量一样,瞬态变量在设置时放在最高父级。这意味着在执行时设置变量时,瞬态变量实际上存储在流程实例执行中。与常规变量一样,如果在特定执行或任务上设置变量,则存在方法的局部变体。 只能在流程定义中的下一个“等待状态”之前访问瞬态变量。在那之后,他们就走了。在这里,等待状态是指流程实例中它被持久化到数据存储中的点。请注意,在此定义中,异步活动也是“等待状态”! 瞬态变量只能由setTransientVariable(name, value)设置,但调用getVariable(name)时也会返回瞬态变量(也存在一个getTransientVariable(name),它只检查瞬态变量)。这样做的原因是使表达式的编写变得容易,并且使用变量的现有逻辑适用于这两种类型。 瞬态变量会隐藏同名的持久变量。这意味着当在流程实例上同时设置持久变量和瞬态变量并*调用 getVariable(\u0026ldquo;someVariable\u0026rdquo;)*时,将返回瞬态变量值。 可以在大多数地方设置和获取瞬态变量\n关于JavaDelegate实现中的DelegateExecution\n关于ExecutionListener实现中的DelegateExecution和关于TaskListener实现的DelegateTask\n通过执行对象在脚本任务中\n通过运行时服务启动流程实例时\n完成任务时\n调用runtimeService.trigger方法时\n方法\nvoid setTransientVariable(String variableName, Object variableValue); void setTransientVariableLocal(String variableName, Object variableValue); void setTransientVariables(Map\u0026lt;String, Object\u0026gt; transientVariables); void setTransientVariablesLocal(Map\u0026lt;String, Object\u0026gt; transientVariables); Object getTransientVariable(String variableName); Object getTransientVariableLocal(String variableName); Map\u0026lt;String, Object\u0026gt; getTransientVariables(); Map\u0026lt;String, Object\u0026gt; getTransientVariablesLocal(); void removeTransientVariable(String variableName); void removeTransientVariableLocal(String variableName); 典型示例 瞬态变量传递\nProcessInstance processInstance = runtimeService.createProcessInstanceBuilder() .processDefinitionKey(\u0026#34;someKey\u0026#34;) .transientVariable(\u0026#34;configParam01\u0026#34;, \u0026#34;A\u0026#34;) .transientVariable(\u0026#34;configParam02\u0026#34;, \u0026#34;B\u0026#34;) .transientVariable(\u0026#34;configParam03\u0026#34;, \u0026#34;C\u0026#34;) .start(); 获取数据\npublic static class FetchDataServiceTask implements JavaDelegate { public void execute(DelegateExecution execution) { String configParam01 = (String) execution.getVariable(configParam01); // ... RestResponse restResponse = executeRestCall(); execution.setTransientVariable(\u0026#34;response\u0026#34;, restResponse.getBody()); execution.setTransientVariable(\u0026#34;status\u0026#34;, restResponse.getStatus()); } } 离开独占网关的序列流的条件不知道使用的是持久变量还是瞬态变量(在本例中为状态瞬态变量):\n\u0026lt;conditionExpression xsi:type=\u0026#34;tFormalExpression\u0026#34;\u0026gt;${status == 200}\u0026lt;/conditionExpression\u0026gt; 表达式 # Flowable使用UEL进行表达式解析,UEL代表统一表达式语言,是EE6规范的一部分。两种类型的表达式(值表达式和方法表达式),都可以在需要表达式的地方使用\n值表达式,解析为一个值\n${myVar} ${myBean.myProperty} 方法表达式:调用带或不带参数的方法\n${printer.print()} ${myBean.addNewOrder(\u0026#39;orderName\u0026#39;)} ${myBean.doSomething(myVar, execution)} 表达式函数 # 一些开箱即用的函数\nvariables:get(varName):检索变量的值。与直接在表达式中写变量名的主要区别在于,当变量不存在时,使用这个函数不会抛出异常。例如,如果myVariable不存在,*${myVariable == \u0026ldquo;hello\u0026rdquo;}会抛出异常,但${var:get(myVariable) == \u0026lsquo;hello\u0026rsquo;}*会正常工作。 variables:getOrDefault(varName, defaultValue):类似于get,但可以选择提供默认值,当变量未设置或值为null时返回。 variables:exists(varName) :如果变量具有非空值,则返回true 。 variables:isEmpty(varName) (alias :empty ) : 检查变量值是否不为空。根据变量类型,行为如下: 对于字符串变量,如果变量是空字符串,则认为该变量为空。 对于 java.util.Collection 变量,如果集合没有元素,则返回true 。 对于 ArrayNode 变量,如果没有元素则返回true 如果变量为null,则始终返回true variables:isNotEmpty(varName) (alias : notEmpty) : isEmpty的逆运算。 variables:equals(varName, value)(别名*:eq*):检查变量是否等于给定值。这是表达式的简写函数,否则将被写为*${execution.getVariable(\u0026ldquo;varName\u0026rdquo;) != null \u0026amp;\u0026amp; execution.getVariable(\u0026ldquo;varName\u0026rdquo;) == value}*。 如果变量值为 null,则返回 false(除非与 null 比较)。 variables:notEquals(varName, value)(别名*:ne ):* equals的反向比较。 variables:contains(varName, value1, value2, \u0026hellip;):检查提供的所有值是否包含在变量中。根据变量类型,行为如下: 对于字符串变量,传递的值用作需要成为变量一部分的子字符串 对于 java.util.Collection 变量,所有传递的值都需要是集合的一个元素(正则包含语义)。 对于 ArrayNode 变量:支持检查 arraynode 是否包含作为变量类型支持的类型的 JsonNode 当变量值为 null 时,在所有情况下都返回 false。当变量值不为null,且实例类型不是上述类型之一时,会返回false。 variables:containsAny(varName, value1, value2, \u0026hellip;):类似于contains函数,但如果任何(而非全部)传递的值包含在变量中,则将返回true 。 variables:base64(varName):将二进制或字符串变量转换为 Base64 字符串 比较器功能: variables:lowerThan(varName, value) (别名*:lessThan或:lt* ) : ${execution.getVariable(\u0026ldquo;varName\u0026rdquo;) != null \u0026amp;\u0026amp; execution.getVariable(\u0026ldquo;varName\u0026rdquo;) \u0026lt; value}的简写 变量:lowerThanOrEquals(varName, value)(别名*:lessThanOrEquals或:lte*):类似,但现在用于*\u0026lt; =* variables:greaterThan(varName, value) (alias :gt ) : 类似,但现在用于*\u0026gt;* variables:greaterThanOrEquals(varName, value) (alias :gte ) : 类似,但现在用于*\u0026gt; =* 单元测试 # 使用自定义资源进行单元测试\n@FlowableTest public class MyBusinessProcessTest { private ProcessEngine processEngine; private RuntimeService runtimeService; private TaskService taskService; @BeforeEach void setUp(ProcessEngine processEngine) { this.processEngine = processEngine; this.runtimeService = processEngine.getRuntimeService(); this.taskService = processEngine.getTaskService(); } @Test @Deployment(resources = \u0026#34;holiday-request.bpmn20.xml\u0026#34;) void testSimpleProcess() { HashMap\u0026lt;String, Object\u0026gt; employeeInfo = new HashMap\u0026lt;\u0026gt;(); employeeInfo.put(\u0026#34;employee\u0026#34;, \u0026#34;wangwu1028930\u0026#34;); //employeeInfo.put() runtimeService.startProcessInstanceByKey( \u0026#34;holidayRequest\u0026#34;, employeeInfo ); Task task = taskService.createTaskQuery().singleResult(); assertEquals(\u0026#34;Approve or reject request\u0026#34;, task.getName()); HashMap\u0026lt;String, Object\u0026gt; hashMap = new HashMap\u0026lt;\u0026gt;(); hashMap.put(\u0026#34;approved\u0026#34;, true); taskService.complete(task.getId(), hashMap); assertEquals(1, runtimeService .createProcessInstanceQuery().count()); } } 调试单元测试 # Web应用程序中的流程引擎 # 编写一个简单的ServletContextListener来初始化和销毁普通Servlet环境中的流程引擎\npublic class ProcessEnginesServletContextListener implements ServletContextListener { public void contextInitialized(ServletContextEvent servletContextEvent) { ProcessEngines.init(); } public void contextDestroyed(ServletContextEvent servletContextEvent) { ProcessEngines.destroy(); } } 其中,ProcessEngines.init()将在类路径中查找flowable.cfg.xml资源文件,并为给定的配置创建一个ProcessEngine,使用下面两种方式来获取他\nProcessEngines.getDefaultProcessEngine() //或者下面的方式 ProcessEngines.getProcessEngine(\u0026#34;myName\u0026#34;); "},{"id":399,"href":"/zh/docs/technology/Flowable/offical/02/","title":"Flowable-02-Configuration","section":"官方文档","content":" 创建流程引擎 # Flowable 流程引擎通过一个名为 flowable.cfg.xml 的 XML 文件进行配置\n现在类路径下放置floable.cfg.xml文件\n\u0026lt;beans xmlns=\u0026#34;http://www.springframework.org/schema/beans\u0026#34; xmlns:xsi=\u0026#34;http://www.w3.org/2001/XMLSchema-instance\u0026#34; xsi:schemaLocation=\u0026#34;http://www.springframework.org/schema/beans http://www.springframework.org/schema/beans/spring-beans.xsd\u0026#34;\u0026gt; \u0026lt;bean id=\u0026#34;processEngineConfiguration\u0026#34; class=\u0026#34;org.flowable.engine.impl.cfg.StandaloneProcessEngineConfiguration\u0026#34;\u0026gt; \u0026lt;property name=\u0026#34;jdbcUrl\u0026#34; value=\u0026#34;jdbc:h2:mem:flowable;DB_CLOSE_DELAY=1000\u0026#34; /\u0026gt; \u0026lt;property name=\u0026#34;jdbcDriver\u0026#34; value=\u0026#34;org.h2.Driver\u0026#34; /\u0026gt; \u0026lt;property name=\u0026#34;jdbcUsername\u0026#34; value=\u0026#34;sa\u0026#34; /\u0026gt; \u0026lt;property name=\u0026#34;jdbcPassword\u0026#34; value=\u0026#34;\u0026#34; /\u0026gt; \u0026lt;property name=\u0026#34;databaseSchemaUpdate\u0026#34; value=\u0026#34;true\u0026#34; /\u0026gt; \u0026lt;property name=\u0026#34;asyncExecutorActivate\u0026#34; value=\u0026#34;false\u0026#34; /\u0026gt; \u0026lt;property name=\u0026#34;mailServerHost\u0026#34; value=\u0026#34;mail.my-corp.com\u0026#34; /\u0026gt; \u0026lt;property name=\u0026#34;mailServerPort\u0026#34; value=\u0026#34;5025\u0026#34; /\u0026gt; \u0026lt;/bean\u0026gt; \u0026lt;/beans\u0026gt; 然后使用静态方法进行获取ProcessEngine\nProcessEngine processEngine = ProcessEngines.getDefaultProcessEngine(); 还有其他配置,这里不一一列举,详见文档地址 https://www.flowable.com/open-source/docs/bpmn/ch03-Configuration\n大致目录如下 "},{"id":400,"href":"/zh/docs/technology/Flowable/offical/01/","title":"Flowable-01-GettingStarted","section":"官方文档","content":" 入门 # 什么是流动性 # Flowable 是一个用 Java 编写的轻量级业务流程引擎。Flowable 流程引擎允许您部署 BPMN 2.0 流程定义(用于定义流程的行业 XML 标准)、创建这些流程定义的流程实例、运行查询、访问活动或历史流程实例和相关数据等等。\n可以使用 Flowable REST API 通过 HTTP 进行通信。还有几个 Flowable 应用程序(Flowable Modeler、Flowable Admin、Flowable IDM 和 Flowable Task)提供开箱即用的示例 UI,用于处理流程和任务。\nFlowable和Activiti # Flowable是Activiti的一个分支\n构建命令行命令 # 创建流程引擎 # 请假流程如下\n员工要求休假数次 经理批准或拒绝请求 之后将模拟再某个外部系统中注册请求,并向员工发送一封包含结果的邮件 创建一个空的Mave项目,并添加依赖\n\u0026lt;dependencies\u0026gt; \u0026lt;dependency\u0026gt; \u0026lt;groupId\u0026gt;org.flowable\u0026lt;/groupId\u0026gt; \u0026lt;artifactId\u0026gt;flowable-engine\u0026lt;/artifactId\u0026gt; \u0026lt;version\u0026gt;6.6.0\u0026lt;/version\u0026gt; \u0026lt;/dependency\u0026gt; \u0026lt;dependency\u0026gt; \u0026lt;groupId\u0026gt;com.h2database\u0026lt;/groupId\u0026gt; \u0026lt;artifactId\u0026gt;h2\u0026lt;/artifactId\u0026gt; \u0026lt;version\u0026gt;1.3.176\u0026lt;/version\u0026gt; \u0026lt;/dependency\u0026gt; \u0026lt;dependency\u0026gt; \u0026lt;groupId\u0026gt;mysql\u0026lt;/groupId\u0026gt; \u0026lt;artifactId\u0026gt;mysql-connector-java\u0026lt;/artifactId\u0026gt; \u0026lt;version\u0026gt;8.0.29\u0026lt;/version\u0026gt; \u0026lt;!--当版本号\u0026gt;=8.0.22时会报date转字符串的错误--\u0026gt; \u0026lt;/dependency\u0026gt; \u0026lt;/dependencies\u0026gt; 添加一个带有Main方法的类\n这里实例化一个ProcessEngine实例,一般只需要实例化一次,是通过ProcessEngineConfiguration创建的,用来配置和调整流程引擎的配置\nProcessEngineConfiguration也可以使用配置 XML 文件创建 ProcessEngineConfiguration需要的最低配置是与数据库的 JDBC 连接 package org.flowable; import org.flowable.engine.ProcessEngine; import org.flowable.engine.ProcessEngineConfiguration; import org.flowable.engine.impl.cfg.StandaloneProcessEngineConfiguration; public class HolidayRequest { public static void main(String[] args) { //这里改用mysql,注意后面的nullCatalogMeansCurrent=true //注意,pom需要添加mysql驱动依赖 ProcessEngineConfiguration cfg = new StandaloneProcessEngineConfiguration() .setJdbcUrl(\u0026#34;jdbc:mysql://localhost:3306/flowable_official?useUnicode=true\u0026#34; + \u0026#34;\u0026amp;characterEncoding=utf-8\u0026amp;serverTimezone=Asia/Shanghai\u0026amp;allowMultiQueries=true\u0026#34; +\u0026#34;\u0026amp;nullCatalogMeansCurrent=true\u0026#34; ) .setJdbcUsername(\u0026#34;root\u0026#34;) .setJdbcPassword(\u0026#34;123456\u0026#34;) .setJdbcDriver(\u0026#34;com.mysql.cj.jdbc.Driver\u0026#34;) .setDatabaseSchemaUpdate(ProcessEngineConfiguration.DB_SCHEMA_UPDATE_TRUE); /* //这是官网,用的h2 ProcessEngineConfiguration cfg = new StandaloneProcessEngineConfiguration() .setJdbcUrl(\u0026#34;jdbc:h2:mem:flowable;DB_CLOSE_DELAY=-1\u0026#34;) .setJdbcUsername(\u0026#34;sa\u0026#34;) .setJdbcPassword(\u0026#34;\u0026#34;) .setJdbcDriver(\u0026#34;org.h2.Driver\u0026#34;) .setDatabaseSchemaUpdate(ProcessEngineConfiguration.DB_SCHEMA_UPDATE_TRUE);*/ ProcessEngine processEngine = cfg.buildProcessEngine(); } } 运行后会出现slf4j的警告,添加依赖并编写配置文件即可\n\u0026lt;dependency\u0026gt; \u0026lt;groupId\u0026gt;org.slf4j\u0026lt;/groupId\u0026gt; \u0026lt;artifactId\u0026gt;slf4j-api\u0026lt;/artifactId\u0026gt; \u0026lt;version\u0026gt;1.7.30\u0026lt;/version\u0026gt; \u0026lt;/dependency\u0026gt; \u0026lt;dependency\u0026gt; \u0026lt;groupId\u0026gt;org.slf4j\u0026lt;/groupId\u0026gt; \u0026lt;artifactId\u0026gt;slf4j-log4j12\u0026lt;/artifactId\u0026gt; \u0026lt;version\u0026gt;1.7.30\u0026lt;/version\u0026gt; \u0026lt;/dependency\u0026gt; 配置文件\nlog4j.rootLogger=DEBUG, CA log4j.appender.CA=org.apache.log4j.ConsoleAppender log4j.appender.CA.layout=org.apache.log4j.PatternLayout log4j.appender.CA.layout.ConversionPattern=%d{hh:mm:ss,SSS} [%t] %-5p %c %x - %m%n 重运行程序无警告\n会自动往mysql添加一些表及数据\n部署流程定义 # flowable 引擎希望以 BPMN 2.0 格式定义流程,这是一种在行业中被广泛接受的 XML 标准。Flowable术语称之为流程定义 (可以理解成许多执行的蓝图),从流程定义中可以启动许多流程实例\n流程定义了请假假期所涉及的不同步骤,而一个流程实例与一位特定员工的假期请相匹配。\nBPMN 2.0 存储为 XML,但它也有一个可视化部分:它以标准方式定义每个不同的步骤类型(人工任务、自动服务调用等)如何表示,以及如何将这些不同的步骤连接到彼此。通过这种方式,BPMN 2.0 标准允许技术人员和业务人员以双方都理解的方式就业务流程进行交流。\n我们将使用的流程定义\n假设该过程是通过提供一些信息开始的 左边的圆圈称为开始事件 第一个矩形是用户任务(经理必须执行,批准或拒绝) 根据经理决定,专用网关 (带有十字菱形)会将流程实例路由到批准或拒绝路径 如果获得批准,必须在某个外部系统中注册请求,然后再次为原始员工执行用户任务,通知他们该决定 如果被拒绝,则会向员工发送一封电子邮件,通知他们这一点 此类流程定义使用可视化建模工具建模,例如Flowable Designer(Eclipse)或FlowableModeler(Web应用程序)\nBPMN 2.0 及其概念 下面的holiday-request.bmpn20.xm文件放在src/main/resouces中\n\u0026lt;?xml version=\u0026#34;1.0\u0026#34; encoding=\u0026#34;UTF-8\u0026#34;?\u0026gt; \u0026lt;definitions xmlns=\u0026#34;http://www.omg.org/spec/BPMN/20100524/MODEL\u0026#34; xmlns:xsi=\u0026#34;http://www.w3.org/2001/XMLSchema-instance\u0026#34; xmlns:xsd=\u0026#34;http://www.w3.org/2001/XMLSchema\u0026#34; xmlns:bpmndi=\u0026#34;http://www.omg.org/spec/BPMN/20100524/DI\u0026#34; xmlns:omgdc=\u0026#34;http://www.omg.org/spec/DD/20100524/DC\u0026#34; xmlns:omgdi=\u0026#34;http://www.omg.org/spec/DD/20100524/DI\u0026#34; xmlns:flowable=\u0026#34;http://flowable.org/bpmn\u0026#34; typeLanguage=\u0026#34;http://www.w3.org/2001/XMLSchema\u0026#34; expressionLanguage=\u0026#34;http://www.w3.org/1999/XPath\u0026#34; targetNamespace=\u0026#34;http://www.flowable.org/processdef\u0026#34;\u0026gt; \u0026lt;process id=\u0026#34;holidayRequest\u0026#34; name=\u0026#34;Holiday Request\u0026#34; isExecutable=\u0026#34;true\u0026#34;\u0026gt; \u0026lt;startEvent id=\u0026#34;startEvent\u0026#34;/\u0026gt; \u0026lt;!--线条指向--\u0026gt; \u0026lt;sequenceFlow sourceRef=\u0026#34;startEvent\u0026#34; targetRef=\u0026#34;approveTask\u0026#34;/\u0026gt; \u0026lt;userTask id=\u0026#34;approveTask\u0026#34; name=\u0026#34;Approve or reject request\u0026#34;/\u0026gt; \u0026lt;!--线条指向--\u0026gt; \u0026lt;sequenceFlow sourceRef=\u0026#34;approveTask\u0026#34; targetRef=\u0026#34;decision\u0026#34;/\u0026gt; \u0026lt;!--网关--\u0026gt; \u0026lt;exclusiveGateway id=\u0026#34;decision\u0026#34;/\u0026gt; \u0026lt;!--线条指向,下面有两个分支--\u0026gt; \u0026lt;!--线条指向approved--\u0026gt; \u0026lt;sequenceFlow sourceRef=\u0026#34;decision\u0026#34; targetRef=\u0026#34;externalSystemCall\u0026#34;\u0026gt; \u0026lt;conditionExpression xsi:type=\u0026#34;tFormalExpression\u0026#34;\u0026gt; \u0026lt;![CDATA[ ${approved} ]]\u0026gt; \u0026lt;/conditionExpression\u0026gt; \u0026lt;/sequenceFlow\u0026gt; \u0026lt;!--线条指向!approved--\u0026gt; \u0026lt;sequenceFlow sourceRef=\u0026#34;decision\u0026#34; targetRef=\u0026#34;sendRejectionMail\u0026#34;\u0026gt; \u0026lt;conditionExpression xsi:type=\u0026#34;tFormalExpression\u0026#34;\u0026gt; \u0026lt;![CDATA[ ${!approved} ]]\u0026gt; \u0026lt;/conditionExpression\u0026gt; \u0026lt;/sequenceFlow\u0026gt; \u0026lt;!--分支1--\u0026gt; \u0026lt;serviceTask id=\u0026#34;externalSystemCall\u0026#34; name=\u0026#34;Enter holidays in external system\u0026#34; flowable:class=\u0026#34;org.flowable.CallExternalSystemDelegate\u0026#34;/\u0026gt; \u0026lt;!--线条指向--\u0026gt; \u0026lt;sequenceFlow sourceRef=\u0026#34;externalSystemCall\u0026#34; targetRef=\u0026#34;holidayApprovedTask\u0026#34;/\u0026gt; \u0026lt;!--用户任务--\u0026gt; \u0026lt;userTask id=\u0026#34;holidayApprovedTask\u0026#34; name=\u0026#34;Holiday approved\u0026#34;/\u0026gt; \u0026lt;!--线条指向--\u0026gt; \u0026lt;sequenceFlow sourceRef=\u0026#34;holidayApprovedTask\u0026#34; targetRef=\u0026#34;approveEnd\u0026#34;/\u0026gt; \u0026lt;!--服务任务--\u0026gt; \u0026lt;serviceTask id=\u0026#34;sendRejectionMail\u0026#34; name=\u0026#34;Send out rejection email\u0026#34; flowable:class=\u0026#34;org.flowable.SendRejectionMail\u0026#34;/\u0026gt; \u0026lt;!--线条指向--\u0026gt; \u0026lt;sequenceFlow sourceRef=\u0026#34;sendRejectionMail\u0026#34; targetRef=\u0026#34;rejectEnd\u0026#34;/\u0026gt; \u0026lt;!--分支2结束--\u0026gt; \u0026lt;endEvent id=\u0026#34;approveEnd\u0026#34;/\u0026gt; \u0026lt;!--分支2结束--\u0026gt; \u0026lt;endEvent id=\u0026#34;rejectEnd\u0026#34;/\u0026gt; \u0026lt;/process\u0026gt; \u0026lt;/definitions\u0026gt; 解释\n该文件与BPMN2.0标准规范完全兼容 每个步骤(活动 activity),都有一个id属性,在XML中,该属性提供唯一标识符 name属性为可选的名称,增加了可视化图表的可读性 活动通过**顺序流(sequenceFlow)**连接,即可视图中的定向箭头。执行流程实例时,执行将从开始事件流向下一个活动,且遵循顺序流 离开专有网关的序列流(带有 X 的菱形)显然是特殊的:两者都有一个以表达式形式定义的条件(见第 25 和 32 行)。当流程实例执行到达此gateway时,将评估条件并采用第一个解析为true的条件。这就是这里独有的含义:只选择一个。如果需要不同的路由行为,当然也可以使用其他类型的网关 表达式以${approved}的形式,是${approved == true}的简写 approved称为过程变量,他与流程实例一起存储(持久数据为,在流程实例的声明周期内使用),意味着必须在流程实例的某个时间点(提交经理用户任务时,即结点\u0026lt;userTask id=\u0026quot;approveTask\u0026quot; /\u0026gt;[Flowable术语,完成])设置此流程变量) 部署流程 使用RepositoryService,它可以从ProcessEngine对象中检索,通过传递XML文件的位置并调用deploy()方法来执行它来创建一个新的Deployment\nRepositoryService repositoryService = processEngine.getRepositoryService(); //部署流程 Deployment deployment = repositoryService.createDeployment() .addClasspathResource(\u0026#34;holiday-request.bpmn20.xml\u0026#34;) .deploy(); //打印部署id System.out.println(\u0026#34;Found deployment id : \u0026#34; + deployment.getId()); 每次部署的id存在act_re_deployment表中 通过API查询来验证引擎是否知道流程定义\nProcessDefinition processDefinition = repositoryService.createProcessDefinitionQuery() .deploymentId(deployment.getId()) .singleResult(); System.out.println(\u0026#34;Found process definition : \u0026#34; + processDefinition.getName()); 启动流程实例 # 现在已经将流程定义部署到流程引擎中了,所以可以将此流程定义作为“蓝图”来启动流程实例\n启动前提供一些初始流程变量 ,通常,当流程自动触发时,将通过呈现给用户的表单或者通过REST API获得这些信息,本例为保持简单使用java.util.Scanner在命令中简单输入一些数据\nScanner scanner= new Scanner(System.in); System.out.println(\u0026#34;Who are you?\u0026#34;); String employee = scanner.nextLine(); System.out.println(\u0026#34;How many holidays do you want to request?\u0026#34;); Integer nrOfHolidays = Integer.valueOf(scanner.nextLine()); System.out.println(\u0026#34;Why do you need them?\u0026#34;); String description = scanner.nextLine(); 接下来,通过RuntimeService启动一个流程实例,流程实例使用key启动,此键与BPMN2.0 XML文件中设置的id属性匹配\nRuntimeService runtimeService = processEngine.getRuntimeService(); Map\u0026lt;String, Object\u0026gt; variables = new HashMap\u0026lt;String, Object\u0026gt;(); variables.put(\u0026#34;employee\u0026#34;, employee); variables.put(\u0026#34;nrOfHolidays\u0026#34;, nrOfHolidays); variables.put(\u0026#34;description\u0026#34;, description); ProcessInstance processInstance = runtimeService.startProcessInstanceByKey(\u0026#34;holidayRequest\u0026#34;, variables); 流程实例启动时,会创建一个执行(execution)并将其放入start event启动事件中。之后,此执行(execution)遵守user task 用户任务的序列流 sequence flow以供经理批准并执行用户任务user task行为 此行为将在数据库中创建一个任务,稍后可以使用查询找到该任务 用户任务处于等待状态,引擎将停止进一步执行任何操作,返回 API 调用 支线:交易性 (Sidetrack: transactionality) # 当您进行 Flowable API 调用时,默认情况下,一切都是同步synchronous的,并且是同一事务的一部分。这意味着,当方法调用返回时,将启动并提交事务。 当一个流程实例启动时,从流程实例启动到下一个等待状态会有一个数据库事务。在本例中,这是第一个用户任务。当引擎到达这个用户任务时,状态被持久化到数据库中并且事务被提交并且API调用返回 在 Flowable 中,当继续一个流程实例时,总会有一个数据库事务从前一个等待状态转到下一个等待状态。 查询和完成任务 # 为用户任务配置分配\n[第一个任务进入\u0026quot;经理\u0026quot;组]\n\u0026lt;userTask id=\u0026#34;approveTask\u0026#34; name=\u0026#34;Approve or reject request\u0026#34; flowable:candidateGroups=\u0026#34;managers\u0026#34;/\u0026gt; 第二个任务的受让人assignee属性 基于我们在流程实例启动时传递的流程变量的动态分配\n\u0026lt;userTask id=\u0026#34;holidayApprovedTask\u0026#34; name=\u0026#34;Holiday approved\u0026#34; flowable:assignee=\u0026#34;${employee}\u0026#34;/\u0026gt; 查询并返回\u0026quot;managers\u0026quot;组的任务\nTaskService taskService = processEngine.getTaskService(); List\u0026lt;Task\u0026gt; tasks = taskService.createTaskQuery().taskCandidateGroup(\u0026#34;managers\u0026#34;).list(); System.out.println(\u0026#34;You have \u0026#34; + tasks.size() + \u0026#34; tasks:\u0026#34;); for (int i=0; i\u0026lt;tasks.size(); i++) { System.out.println((i+1) + \u0026#34;) \u0026#34; + tasks.get(i).getName());// } 有三个是因为启动了三个实例\n获取特定的流程实例变量,并在屏幕上显示实际请求\nSystem.out.println(\u0026#34;Which task would you like to complete?\u0026#34;); int taskIndex = Integer.valueOf(scanner.nextLine()); Task task = tasks.get(taskIndex - 1); Map\u0026lt;String, Object\u0026gt; processVariables = taskService.getVariables(task.getId()); System.out.println(processVariables.get(\u0026#34;employee\u0026#34;) + \u0026#34; wants \u0026#34; + processVariables.get(\u0026#34;nrOfHolidays\u0026#34;) + \u0026#34; of holidays. Do you approve this?\u0026#34;); 设置variables让经理批准\nboolean approved = scanner.nextLine().toLowerCase().equals(\u0026#34;y\u0026#34;); variables = new HashMap\u0026lt;String, Object\u0026gt;(); variables.put(\u0026#34;approved\u0026#34;, approved); //经理完成任务 taskService.complete(task.getId(), variables); $\\color{red}该任务现已完成,并且基于\u0026quot;approved\u0026quot;流程变量选择离开专用网关的两条路径之一$\n编写JavaDelegate # 实现在请求被批准时将执行的自动逻辑,在BPMN2.0 XML中,这是一个服务任务\n\u0026lt;serviceTask id=\u0026#34;externalSystemCall\u0026#34; name=\u0026#34;Enter holidays in external system\u0026#34; flowable:class=\u0026#34;org.flowable.CallExternalSystemDelegate\u0026#34;/\u0026gt; 这里指定了具体实现类\npackage org.flowable; import org.flowable.engine.delegate.DelegateExecution; import org.flowable.engine.delegate.JavaDelegate; public class CallExternalSystemDelegate implements JavaDelegate { public void execute(DelegateExecution execution) { System.out.println(\u0026#34;Calling the external system for employee \u0026#34; + execution.getVariable(\u0026#34;employee\u0026#34;)); } } 当执行execution到达service tast服务任务时,BPMN 2.0 XML中引用的类被实例化并被调用\n运行,发现自定义逻辑确实已执行\n处理历史数据 # Flowable引擎会自动存储所有流程实例的审计数据audit data 或历史数据historical data\n下面,显示一直在执行的流程实例的持续时间,从ProcessEngine获取HistoryService并创建历史活动查询。这里添加了过滤\u0026ndash;1 仅针对一个特定流程实例的活动 \u0026ndash;2 只有已经完成的活动\nHistoryService historyService = processEngine.getHistoryService(); List\u0026lt;HistoricActivityInstance\u0026gt; activities = historyService.createHistoricActivityInstanceQuery() .processInstanceId(processInstance.getId()) .finished() .orderByHistoricActivityInstanceEndTime().asc() .list(); for (HistoricActivityInstance activity : activities) { System.out.println(activity.getActivityId() + \u0026#34; took \u0026#34; + activity.getDurationInMillis() + \u0026#34; milliseconds\u0026#34;); } 结论 # 本教程介绍了各种 Flowable 和 BPMN 2.0 概念和术语,同时还演示了如何以编程方式使用 Flowable API。\nFlowable REST API入门 # 设置REST应用程序 # 使用flowable-rest.war , java -jar flowable-rest.war\n测试是否运行成功\ncurl --user rest-admin:test http://localhost:8080/flowable-rest/service/management/engine 部署流程定义 # 先切到该文件夹下 使用下面命令启动flowable-rest\njava -jar flowable-rest.war 部署流程定义\ncurl --user rest-admin:test -F \u0026#34;file=@holiday-request.bpmn20.xml\u0026#34; http://localhost:8080/flowable-rest/service/repository/deployments 查看流程是否部署\ncurl --user rest-admin:test http://localhost:8080/flowable-rest/service/repository/process-definitions 将返回一个列表,列表每个元素是当前部署到引擎的所有流程定义 启动流程实例 # 命令\ncurl --user rest-admin:test -H \u0026#34;Content-Type: application/json\u0026#34; -X POST -d \u0026#39;{ \u0026#34;processDefinitionKey\u0026#34;:\u0026#34;holidayRequest\u0026#34;, \u0026#34;variables\u0026#34;: [ { \u0026#34;name\u0026#34;:\u0026#34;employee\u0026#34;, \u0026#34;value\u0026#34;: \u0026#34;John Doe\u0026#34; }, { \u0026#34;name\u0026#34;:\u0026#34;nrOfHolidays\u0026#34;, \u0026#34;value\u0026#34;: 7 }]}\u0026#39; http://localhost:8080/flowable-rest/service/runtime/process-instances windows中会报错\u0026hellip;估计是没转义啥的原因 将返回\n{\u0026#34;id\u0026#34;:\u0026#34;43\u0026#34;,\u0026#34;url\u0026#34;:\u0026#34;http://localhost:8080/flowable-rest/service/runtime/process-instances/43\u0026#34;,\u0026#34;businessKey\u0026#34;:null,\u0026#34;suspended\u0026#34;:false,\u0026#34;ended\u0026#34;:false,\u0026#34;processDefinitionId\u0026#34;:\u0026#34;holidayRequest:1:42\u0026#34;,\u0026#34;processDefinitionUrl\u0026#34;:\u0026#34;http://localhost:8080/flowable-rest/service/repository/process-definitions/holidayRequest:1:42\u0026#34;,\u0026#34;activityId\u0026#34;:null,\u0026#34;variables\u0026#34;:[],\u0026#34;tenantId\u0026#34;:\u0026#34;\u0026#34;,\u0026#34;completed\u0026#34;:false} 任务列表和完成任务 # 获取manager经理组的所有任务\ncurl --user rest-admin:test -H \u0026#34;Content-Type: application/json\u0026#34; -X POST -d \u0026#39;{ \u0026#34;candidateGroup\u0026#34; : \u0026#34;managers\u0026#34; }\u0026#39; http://localhost:8080/flowable-rest/service/query/tasks 使用命令完成一个任务\ncurl --user rest-admin:test -H \u0026#34;Content-Type: application/json\u0026#34; -X POST -d \u0026#39;{ \u0026#34;action\u0026#34; : \u0026#34;complete\u0026#34;, \u0026#34;variables\u0026#34; : [ { \u0026#34;name\u0026#34; : \u0026#34;approved\u0026#34;, \u0026#34;value\u0026#34; : true} ] }\u0026#39; http://localhost:8080/flowable-rest/service/runtime/tasks/25 这里会报下面的错\n{\u0026#34;message\u0026#34;:\u0026#34;Internal server error\u0026#34;,\u0026#34;exception\u0026#34;:\u0026#34;couldn\u0026#39;t instantiate class org.flowable.CallExternalSystemDelegate\u0026#34;} 解决办法\n这意味着引擎找不到服务任务中引用的 CallExternalSystemDelegate 类。为了解决这个问题,需要将该类放在应用程序的类路径中(这将需要重新启动)。按照本节所述创建类,将其打包为JAR,并将其放在Tomcat的webapps文件夹下的flowable-rest文件夹的WEB-INF/lib文件夹中。\n"},{"id":401,"href":"/zh/docs/technology/Algorithm/_algorithhms_4th_/2.1.2-2.1.3/","title":"算法红皮书 2.1.2-2.1.3","section":"_算法(第四版)_","content":" 排序 # 初级排序算法 # 选择排序 # 命题A。对于长度为N 的数组,选择排序需要大约 N^2/2 次比较和N 次交换。\n代码\npublic class Selection { public static void sort(Comparable[] a) { // 将a[]按升序排列 int N = a.length; // 数组长度 for (int i = 0; i \u0026lt; N; i++) { // 将a[i]和a[i+1..N]中最小的元素交换 int min = i; // 最小元素的索引 for (int j = i+1; j \u0026lt; N; j++) if (less(a[j], a[min])) min = j; exch(a, i, min); } } // less()、exch()、isSorted()和main()方法见“排序算法类模板” } 特点\n运行时间与输入无关,即输入数据的初始状态(比如是否已排序好等等)不影响排序时间 数据移动是最少的(只使用了N次交换,交换次数和数组的大小是线性关系 插入排序 # 命题B。对于随机排列的长度为N 且主键不重复的数组,平均情况下插入排序需要~ N2/4 次比较以及~ N2/4 次交换。最坏情况下需要~ N2/2 次比较和~ N2/2 次交换,最好情况下需要N-1次比较和0 次交换。\n代码\npublic static void sort(Comparable[] a) { int N = a.length; //将下表为 n-1的数,依次和n-2,n-3一直到0比较, //所以第二层for只走到1,因为0前面没有值 //如果比前面的值小,就进行交换 for (int i = 1; i \u0026lt; N; i++) { for (int j = i; j \u0026gt; 0 \u0026amp;\u0026amp; less(a[j], a[j - 1]); j--) { exch(a, j, j - 1); } } } 当倒置的数量很小时,插入排序比本章中的其他任何算法都快\n命题C。插入排序需要的交换操作和数组中倒置的数量相同,需要的比较次数大于等于倒置的数量,小于等于倒置的数量加上数组的大小再减一。\n性质D。对于随机排序的无重复主键的数组,插入排序和选择排序的运行时间是平方级别的,两者之比应该是一个较小的常数\n希尔排序 # 希尔排序的思想是使数组中任意间隔为h的元素都是有序的,这样的数组称为h有序数组,一个h有序数组就是h个互相独立的有序数组编制在一起组成的数组\n算法2.3 的实现使用了序列1/2(3k-1),从N/3 开始递减至1。我们把这个序列称为递增序列\n详述\n实现希尔排序的一种方法是对于每个h,用插入排序将h 个子数组独立地排序。但因为子数组是相互独立的,一个更简单的方法是在h- 子数组中将每个元素交换到比它大的元素之前去(将比它大的元素向右移动一格)。只需要在插入排序的代码中将移动元素的距离由1 改为h 即可。这样,希尔排序的实现就转化为了一个类似于插入排序但使用不同增量的过程。\n代码\npublic class Shell { public static void sort(Comparable[] a) { // 将a[]按升序排列 int N = a.length; int h = 1; while (h \u0026lt; N/3) h = 3*h + 1; // 1, 4, 13, 40, 121, 364, 1093, ... while (h \u0026gt;= 1) { // 将数组变为h有序 for (int i = h; i \u0026lt; N; i++) { // 将a[i]插入到a[i-h], a[i-2*h], a[i-3*h]... 之中 for (int j = i; j \u0026gt;= h \u0026amp;\u0026amp; less(a[j], a[j-h]); j -= h) exch(a, j, j-h); } h = h/3; } } // less()、exch()、isSorted()和main()方法见“排序算法类模板” } 通过提升速度来解决其他方式无法解决的问题是研究算法的设计和性能的主要原因之一\n归并排序 # 归并排序最吸引人的性质是它能够保证将任意长度为N的数组排序所需时间和NlogN成正比,主要缺点是他所需的额外空间和N成正比\n归并排序示意图 自顶向下的归并排序 # 原地归并的抽象方法\n/** * 这里有一个前提,就是a[i..mid]是有序的, * a[mid..hi]是有序的 * * @param a * @param lo * @param mid * @param hi */ public static void merge(Comparable[] a, int lo, int mid, int hi) { int i = lo, j = mid + 1; //先在辅助数组赋上需要的值 for (int k = lo; k \u0026lt;= hi; k++) { aux[k] = a[k]; } //最坏情况下这里时需要比较hi-lo+1次的,也就是数组长度 for (int k = lo; k \u0026lt;= hi; k++) { if (i \u0026gt; mid) { //说明i(左边)比较完了,直接拿右边的值放进去 a[k] = aux[j++]; } else if (j \u0026gt; hi) { //说明j(右边)比较完了,直接拿左边的值放进去 a[k] = aux[i++]; } else if (less(aux[j], aux[i])) { //左右都还有值的情况下,取出最小的值放进去 a[k] = aux[j++]; } else { a[k] = aux[i++]; } } } 递归进行归并排序\nprivate static void sort(Comparable[] a, int lo, int hi) { if (hi \u0026lt;= lo) { return; } int mid = lo + (hi - lo) / 2; //保证左边有序 sort(a, lo, mid); //保证右边有序 sort(a, mid + 1, hi); //归并数组有序的两部分 merge(a, lo, mid, hi); } 辅助数组的一次性初始化\nprivate static Comparable[] aux; public static void sort(Comparable[] a) { aux = new Comparable[a.length];//辅助数组,一次性分配空间 sort(a, 0, a.length - 1); } 自顶向下的归并排序的调用轨迹 N=16时归并排序中子数组的依赖树 每个结点都表示一个sort() 方法通过merge() 方法归并而成的子数组。这棵树正好有n 层。对于0 到n-1 之间的任意k,自顶向下的第k 层有2k 个子数组,每个数组的长度为 $2{(n-k)}$,归并最多需要$2^{(n-k)}$次比较。因此每层的比较次数为$ 2k * 2 ^ {( n - 1 )} = 2 ^ n $ ,n层总共为 $n*2n = lg N * (2 ^ { lg N}) = lg N * N$\n命题F。对于长度为N 的任意数组,自顶向下的归并排序需要(1/2)N lgN 至N lgN 次比较。\n注:因为归并所需要的比较次数最少为N/2\n命题G。对于长度为N 的任意数组,自顶向下的归并排序最多需要访问数组6NlgN 次。 证明。每次归并最多需要访问数组6N 次(2N 次用来复制,2N 次用来将排好序的元素移动回去,另外最多比较2N 次),根据命题F 即可得到这个命题的结果。\n自底向上的归并排序 # 递归实现的归并排序时算法设计中分治思想 的典型应用\n自底向上的归并排序的可视轨迹\n源代码\nprivate static Comparable[] aux; private static void sort(Comparable[] a) { int N = a.length; aux = new Comparable[N]; //每次合并的子数组长度翻倍 for (int sz = 1; sz \u0026lt; N; sz = sz + sz) { //lo:子数组索引 //边界问题, 假设是N为2^n,则倒数第二个数组的元素的下标,一定在倒数第一个元素下标(n-sz)之前 for (int lo = 0; lo \u0026lt; N - sz; lo += sz + sz) { //循环合并一个个的小数组 merge(a, lo, lo + sz - 1, Math.min(lo + sz + sz - 1, N - 1)); } } } 子数组的大小sz的初始值为1,每次加倍\n最后一个子数组的大小只有在数组大小是sz的偶数倍的时候才会等于sz(否则比sz小)\n命题H。对于长度为N 的任意数组,自底向上的归并排序需要1/2NlgN 至NlgN 次比较,最多访问数组6NlgN 次。\n自底向上的归并排序比较适合用链表组织的数据。想象一下将链表先按大小为1 的子链表进行排序,然后是大小为2 的子链表,然后是大小为4 的子链表等。这种方法只需要重新组织链表链接就能将链表原地排序(不需要创建任何新的链表结点)\n归并排序告诉我们,当能够用其中一种方法解决一个问题时,都应该试试另一种,可以像Merge.sort()那样化整为零(然后递归地解决)问题,或者像MergeBU.sort()那样循序渐进的解决问题\n命题I。没有任何基于比较的算法能够保证使用少于lg(N!)~ NlgN 次比较将长度为N 的数组排序\n命题J。归并排序是一种渐进最优的基于比较排序的算法。\n快速排序 # 快速排序是应用最广泛的排序算法\n基本算法 # 是一种分治的排序算法,将一个数组分成两个子数组,将两部分独立的排序\n归并排序将数组分成两个子数组分别排序,并将有序的子数组归并以将两个数组排序;快速排序将数组排序的方式是当两个子数组都有序时整个数组也都有序了\n归并排序:递归调用发生在处理数组之前;快速排序:递归调用发生在处理数组之后\n归并排序中数组被分为两半;快速排序中切分取决于数组内容\n快速排序示意图 递归代码\npublic static void sort(Comparable[] a, int lo, int hi) { if (hi \u0026lt;= lo) return; int j = partition(a, lo, hi); //切分 sort(a, lo, j - 1); /// 将左半部分a[lo .. j-1]排序 sort(a, j + 1, hi);//将右半部分a[j+1..hi]排序 } 快速排序递归的将子数组a[lo..hi]排序,先用partition()方法将a[j]放到一个合适的位置,然后再用递归调用将其他位置的元素排序 切分后使得数组满足三个条件\n对于某个j,a[j]已经排定 a[lo]到a[j-1]的所有元素都不大于a[j] a[j+1]的所有元素都不小于a[j] 归纳法证明数组有序:\n如果左子数组和右子数组都是有序的,那么由左子数组(有序且没有任何元素大于切分元素)、切分元素和右子数组(有序且没有任何元素小于切分元素)组成的结果数组也一定是有序的\n一般策略是先随意地取a[lo] 作为切分元素,即那个将会被排定的元素,然后我们从数组的左端开始向右扫描直到找到一个大于等于它的元素,再从数组的右端开始向左扫描直到找到一个小于等于它的元素。这两个元素显然是没有排定的,因此我们交换它们的位置。如此继续,我们就可以保证左指针i 的左侧元素都不大于切分元素,右指针j 的右侧元素都不小于切分元素。当两个指针相遇时,我们只需要将切分元素a[lo] 和左子数组最右侧的元素(a[j])交换然后返回j 即可\n代码如下\nprivate static int partition(Comparable[] a, int lo, int hi) { int i = lo, j = hi + 1; //左右扫描指针 Comparable v = a[lo]; //切分元素 while (true) { //从左往右扫描,如果找到了大于等于v值的数,就退出循环 while (less(a[++i], v)) { if (i == hi) break; } //从右往左扫描,如果找到了小于等于v值得数,就退出循环 while (less(a[--j], v)) { if (j == lo) break; } if (i \u0026gt;= j) break;//如果i,j相遇则退出循环 //将左边大于等于v值的数与右边小于等于v值的数交换 exch(a, i, j); } //上面的遍历结束后,a[lo+1...j]和a[i..hi]都已经分别有序 //且a[j]\u0026lt;=a[i]\u0026lt;=a[lo],所以应该交换a[lo]和a[j](而不是a[i),因为 //a[i]有可能大于a[lo] exch(a, lo, j); //返回a[lo]被交换的位置 return j; } 切分轨迹 性能特点 # 将长度为N的无重复数组排序,快速排序平均需要~2N lnN 次比较(以及1/6的交换)\n算法改进 # 三向切分\n"},{"id":402,"href":"/zh/docs/problem/Git/01/","title":"git使用ssh连不上","section":"Git","content":" 处理方式 在系统的host文件中,添加ip指定\n199.232.69.194 github.global.ssl.fastly.net 140.82.114.4 github.com "},{"id":403,"href":"/zh/docs/life/archive/20220416/","title":"《作酒》有感","section":"往日归档","content":"最近几天吃饭,经常听到一首很嗨的歌。旋律很轻快,其实本来也就一听而过,可能是耳闻目染次数多了,好奇心上来了,查了下歌词。\n听这首歌期间我居然联想了很多,果然是老emo了。不知道怎么回事,我这种与世无争的心态,听完后居然也让我幻想了一下这歌描述的爱情模样。我又突然想到,如今社会上离婚率居高不下,也许与网络信息的传输有密切关联。如果是古代,嫁错人或者娶错人,大家也都都认了,有什么小打小闹都互相包含。而如今,生活压力不断增大,加上网络上爆炸式(至少效果是)的宣传爱情,对比显著,很让人一着魔就陷进去,就摒弃几年甚至十几年的夫妻之情,去追求所谓的真爱、自由。\n每个人对自己的过往,或多或少都会不甘。如果这种不甘自己没有办法化解,那么就会在某一刻爆发。每个人都应该,也必定会为自己曾经的所作所为负责。不要懵懵懂懂地进入(现代)婚姻,这样对自己和它人都极其不负责。 爆炸式的信息接收会激发你所有的冲动与不甘。\n"},{"id":404,"href":"/zh/docs/technology/Algorithm/_algorithhms_4th_/2.1.1/","title":"算法红皮书 2.1.1","section":"_算法(第四版)_","content":" 排序 # 排序就是将一组对象按照某种逻辑顺序重新排序的过程\n对排序算法的分析有助于理解本书中比较算法性能的方法 类似技术能解决其他类型问题 排序算法常常是我们解决其他问题的第一步 初级排序算法 # 熟悉术语及技巧 某些情况下初级算法更有效 有助于改进复杂算法的效率 游戏规则 # 主要关注重新排序数组元素的算法,每个元素都会有一个主键\n排序后索引较大的主键大于索引较小的主键\n一般情况下排序算法通过两个方法操作数据,less()进行比较,exch()进行交换\n排序算法类的模板\npublic class Example { public static void sort(Comparable[] a) { /* 请见算法2.1、算法2.2、算法2.3、算法2.4、算法2.5或算法2.7*/ } private static Boolean less(Comparable v, Comparable w) { return v.compareTo(w) \u0026lt; 0; } private static void exch(Comparable[] a, int i, int j) { Comparable t = a[i]; a[i] = a[j]; a[j] = t; } private static void show(Comparable[] a) { // 在单行中打印数组 for (int i = 0; i \u0026lt; a.length; i++) StdOut.print(a[i] + \u0026#34; \u0026#34;); StdOut.println(); } public static Boolean isSorted(Comparable[] a) { // 测试数组元素是否有序 for (int i = 1; i \u0026lt; a.length; i++) if (less(a[i], a[i-1])) return false; return true; } public static void main(String[] args) { // 从标准输入读取字符串,将它们排序并输出 String[] a = In.readStrings(); sort(a); assert isSorted(a); show(a); } } 使用\n% more tiny.txt S O R T E X A M P L E % java Example \u0026lt; tiny.txt A E E L M O P R S T X % more words3.txt bed bug dad yes zoo ... all bad yet % java Example \u0026lt; words.txt all bad bed bug dad ... yes yet zoo 使用assert验证\n排序成本模型:在研究排序算法时,我们需要计算比较和交换的数量。对于不交换元素的算法,我们会比较访问数组的次数\n额外内存开销和运行时间同等重要,排序算法分为\n除了函数调用需要的栈和固定数目的实例变量之外,无需额外内存的原地排序算法 需要额外内存空间来存储另一份数组副本的其他排序算法 数据类型\n排序模板适用于任何实现了Comparable接口的数据类型\n对于自己的数据类型,实现Comparable接口即可\npublic class Date implements Comparable\u0026lt;Date\u0026gt; { private final int day; private final int month; private final int year; public Date(int d, int m, int y) { day = d; month = m; year = y; } public int day() { return day; } public int month() { return month; } public int year() { return year; } public int compareTo(Date that) { if (this.year \u0026gt; that.year ) return +1; if (this.year \u0026lt; that.year ) return -1; if (this.month \u0026gt; that.month) return +1; if (this.month \u0026lt; that.month) return -1; if (this.day \u0026gt; that.day ) return +1; if (this.day \u0026lt; that.day ) return -1; return 0; } public String toString() { return month + \u0026#34;/\u0026#34; + day + \u0026#34;/\u0026#34; + year; } } compareTo()必须实现全序关系 自反性,反对称性及传递性 经典算法,包括选择排序、插入排序、希尔排序、归并排序、快速排序和堆排序\n"},{"id":405,"href":"/zh/docs/technology/RocketMQ/heima_/05advance/","title":"05高级功能","section":"基础(黑马)_","content":" 学习来源 https://www.bilibili.com/video/BV1L4411y7mn(添加小部分笔记)感谢作者!\n消息存储 # 流程 # 存储介质 # 关系型数据库DB # 适合数据量不够大,比如ActiveMQ可选用JDBC方式作为消息持久化\n文件系统 # 关系型数据库最终也是要存到文件系统中的,不如直接存到文件系统,绕过关系型数据库 常见的RocketMQ/RabbitMQ/Kafka都是采用消息刷盘到计算机的文件系统来做持久化(同步刷盘/异步刷盘) 消息发送 # 顺序写:600MB/s,随机写:100KB/s\n系统运行一段时间后,我们对文件的增删改会导致磁盘上数据无法连续,非常的分散。\n顺序读也只是逻辑上的顺序,也就是按照当前文件的相对偏移量顺序读取,并非磁盘上连续空间读取\n对于磁盘的读写分为两种模式,顺序IO和随机IO。 随机IO存在一个寻址的过程,所以效率比较低。而顺序IO,相当于有一个物理索引,在读取的时候不需要寻找地址,效率很高。\n来源: https://www.cnblogs.com/liuche/p/15455808.html\n数据网络传输\n零拷贝技术MappedByteBuffer,省去了用户态,由内核态直接拷贝到网络驱动内核。 RocketMQ默认设置单个CommitLog日志数据文件为1G\n消息存储 # 三个概念:commitLog、ConsumerQueue、index\nCommitLog # 默认大小1G\n存储消息的元数据,包括了Topic、QueueId、Message 还存储了ConsumerQueue相关信息,所以ConsumerQueue丢了也没事 ConsumerQueue # 存储了消息在CommitLog的索引(几百K,Linux会事先加载到内存中) 包括最小/最大偏移量、已经消费的偏移量 一个Topic多个队列,每个队列对应一个ConsumerQueue\nIndex # 也是索引文件,为消息查询服务,通过key或时间区间查询消息\n总结 # 刷盘机制 # 同步刷盘 异步刷盘 高可用性机制 # 消费高可用及发送高可用 # 消息主从复制 # 负载均衡 # 消息重试 # 下面都是针对消费失败的重试\n顺序消息 # RocketMQ会自动不断重试,且为了保证顺序性,会导致消息消费被阻塞。使用时要及时监控并处理消费失败现象\n无序消息(普通、定时、延时、事务) # 通过设置返回状态达到消息重试的结果 重试只对集群消费方式生效,广播方式不提供重试特性 重试次数 如果16次后还是消费失败,会进入死信队列,不再被消费 配置是否重试 # 重试 # 不重试,认为消费成功 # 修改重试次数 # 在创建消费者的时候,传入Properties即可\n注意事项 # messge.getReconsumeTimes()获取消息已经重试的次数\n死信队列 # 特性 # 针对的是消费者组;不再被正常消费;有过期时间;\n查看 # 通过admin的控制台查看\n可重发;可指定后特殊消费\n可以重发,也可以写一个消费者,指定死信队列里面的消息\n消费幂等 # 同一条消息不论消费多少次,结果应该都是一样的\n发送时发送的消息重复 # "},{"id":406,"href":"/zh/docs/technology/Algorithm/_algorithhms_4th_/1.5.1-1.5.3/","title":"算法红皮书 1.5.1-1.5.3","section":"_算法(第四版)_","content":" 案例研究:union-find 算法 # 设计和分析算法的基本方法 优秀的算法能解决实际问题 高效的算法也可以很简单 理解某个实现的性能特点是一项有趣的挑战 在解决同一个问题的多种算法间选择,科学方法是一种重要工具 迭代式改进能让算法效率越来越高 动态连通性 # 从输入中读取整数对p q,如果已知的所有整数对都不能说明p,q相连,就打印出pq 网络:整个程序能够判定是否需要在pq之间架设一条新的连接才能进行通信 变量名等价性(即指向同一个对象的多个引用) 数学集合:在处理一个整数对pq时,我们是在判断它们是否属于相同的集合 本节中,将对象称为触点,整数对称为连接,等价类称为连通分量或是简称分量 连通性 问题只要求我们的程序能够判别给定的整数对pq是否相连,并没有要求给两者之间的通路上的所有连接 union-find算法的API\n数据结构和算法的设计影响到算法的效率 实现 # public class UF { private int[]\tid; /* 分量id(以触点作为索引) */ private int\tcount; /* 分量数量 */ public UF( int N ) { /* 初始化分量id数组 */ count\t= N; id\t= new int[N]; for ( int i = 0; i \u0026lt; N; i++ ) id[i] = i; } public int count() { return(count); } public Boolean connected( int p, int q ) { return(find( p ) == find( q ) ); } public int find( int p ) public void union( int p, int q ) /* 请见1.5.2.1节用例(quick-find)、1.5.2.3节用例(quick-union)和算法1.5(加权quick-union) */ public static void main( String[] args ) { /* 解决由StdIn得到的动态连通性问题 */ int\tN\t= StdIn.readint(); /* 读取触点数量 */ UF\tuf\t= new UF( N ); /* 初始化N个分量 */ while ( !StdIn.isEmpty() ) { int\tp\t= StdIn.readint(); int\tq\t= StdIn.readint(); /* 读取整数对 */ if ( uf.connected( p, q ) ) continue; /* 如果已经连通则忽略 */ uf.union( p, q ); /* 归并分量 */ StdOut.println( p + \u0026#34; \u0026#34; + q ); /* 打印连接 */ } StdOut.println( uf.count() + \u0026#34;components\u0026#34; ); } } union-find的成本模型:union-find API的各种算法,统计的是数组的访问次数,不论读写\n以下有三种实现\n且仅当id[p] 等于id[q] 时p 和q 是连通的\npublic int find(int p) { return id[p]; } public void union(int p, int q) { // 将p和q归并到相同的分量中 int pID = find(p);mi int qID = find(q); // 如果p和q已经在相同的分量之中则不需要采取任何行动 if (pID == qID) return; // 将p的分量重命名为q的名称 for (int i = 0; i \u0026lt; id.length; i++) if (id[i] == pID) id[i] = qID; count--; } 命题F:在quick-find 算法中,每次find() 调用只需要访问数组一次,而归并两个分量的union() 操作访问数组的次数在(N+3) 到(2N+1) 之间。\n证明:由代码马上可以知道,每次connected() 调用都会检查id[] 数组中的两个元素是否相等,即会调用两次find() 方法。归并两个分量的union() 操作会调用两次find(),检查id[] 数组中的全部N 个元素并改变它们中1 到N-1 个元素的值。\n假设我们使用quick-find 算法来解决动态连通性问题并且最后只得到了一个连通分量,那么这至少需要调用N-1 次union(),即至少(N+3)(N-1) ~ N2 次数组访问——我们马上可以猜想动态连通性的quick-find 算法是平方级别的\n以触点作为索引的id[]数组,每个触点所对应的id[]元素都是同一个分量中的另一个触点的名称 如下图: private int find(int p) { // 找出分量的名称 while (p != id[p]) p = id[p]; return p; } public void union(int p, int q) { // 将p和q的根节点统一 int pRoot = find(p); int qRoot = find(q); if (pRoot == qRoot) return; id[pRoot] = qRoot; count--; } quick-union算法的最坏情况 加权quick-union算法(减少树的高度) 用一个数组来表示各个节点对应的分量的大小\npublic class WeightedQuickUnionUF { private int[] id; // 父链接数组(由触点索引) private int[] sz; // (由触点索引的)各个根节点所对应的分量的大小 private int count; // 连通分量的数量 public WeightedQuickUnionUF(int N) { count = N; id = new int[N]; for (int i = 0; i \u0026lt; N; i++) id[i] = i; sz = new int[N]; for (int i = 0; i \u0026lt; N; i++) sz[i] = 1; } public int count() { return count; } public Boolean connected(int p, int q) { return find(p) == find(q); } public int find(int p) { // 跟随链接找到根节点 while (p != id[p]) p = id[p]; return p; } public void union(int p, int q) { int i = find(p); int j = find(q); if (i == j) return; // 将小树的根节点连接到大树的根节点 if (sz[i] \u0026lt; sz[j]) { id[i] = j; sz[j] += sz[i]; } else { id[j] = i; sz[i] += sz[j]; } count--; } } quick-union 算法与加权quick-union 算法的对比(100 个触点,88 次union() 操作) 所有操作的总成本 展望 # 研究问题的步骤\n完整而详细地定义问题,找出解决问题所必需的基本抽象操作并定义一份 API。 简洁地实现一种初级算法,给出一个精心组织的开发用例并使用实际数据作为输入。 当实现所能解决的问题的最大规模达不到期望时决定改进还是放弃。 逐步改进实现,通过经验性分析或(和)数学分析验证改进后的效果。 用更高层次的抽象表示数据结构或算法来设计更高级的改进版本。 如果可能尽量为最坏情况下的性能提供保证,但在处理普通数据时也要有良好的性能。 在适当的时候将更细致的深入研究留给有经验的研究者并继续解决下一个问题。 "},{"id":407,"href":"/zh/docs/technology/RocketMQ/heima_/04case/","title":"04案例","section":"基础(黑马)_","content":" 学习来源 https://www.bilibili.com/video/BV1L4411y7mn(添加小部分笔记)感谢作者!基本架构\n架构 # 流程图 # 下单流程 # 支付流程 # SpringBoot整合RocketMQ # 依赖包 # \u0026lt;dependency\u0026gt; \u0026lt;groupId\u0026gt;org.apache.rocketmq\u0026lt;/groupId\u0026gt; \u0026lt;artifactId\u0026gt;rocketmq-spring-boot-starter\u0026lt;/artifactId\u0026gt; \u0026lt;/dependency\u0026gt; 生产者 # yaml # rocketmq: name-server: 192.168.1.135:9876;192.168.1.138:9876 producer: group: my-group 使用 # @Autowired private RocketMQTemplate template; @RequestMapping(\u0026#34;rocketmq\u0026#34;) public String rocketmq(){ log.info(\u0026#34;我被调用了-rocketmq\u0026#34;); //主题+内容 template.convertAndSend(\u0026#34;mytopic-ly\u0026#34;,\u0026#34;hello1231\u0026#34;); return \u0026#34;hello world\u0026#34;+serverPort; } 消费者 # yaml # rocketmq: name-server: 192.168.1.135:9876;192.168.1.138:9876 consumer: group: my-group2 使用 # 创建监听器\n@RocketMQMessageListener(topic = \u0026#34;mytopic-ly\u0026#34;, consumeMode = ConsumeMode.CONCURRENTLY,consumerGroup = \u0026#34;${rocketmq.producer.group}\u0026#34;) @Slf4j @Component public class Consumer implements RocketMQListener\u0026lt;String\u0026gt; { @Override public void onMessage(String s) { log.info(\u0026#34;消费了\u0026#34;+s); } } 下单流程利用MQ进行回退处理,保证数据一致性 # 库存回退的消费者,代码如下:\n@Slf4j @Component @RocketMQMessageListener(topic = \u0026#34;${mq.order.topic}\u0026#34;,consumerGroup = \u0026#34;${mq.order.consumer.group.name}\u0026#34;,messageModel = MessageModel.BROADCASTING ) public class CancelMQListener implements RocketMQListener\u0026lt;MessageExt\u0026gt;{ @Value(\u0026#34;${mq.order.consumer.group.name}\u0026#34;) private String groupName; @Autowired private TradeGoodsMapper goodsMapper; @Autowired private TradeMqConsumerLogMapper mqConsumerLogMapper; @Autowired private TradeGoodsNumberLogMapper goodsNumberLogMapper; @Override public void onMessage(MessageExt messageExt) { String msgId=null; String tags=null; String keys=null; String body=null; try { //1. 解析消息内容 msgId = messageExt.getMsgId(); tags= messageExt.getTags(); keys= messageExt.getKeys(); body= new String(messageExt.getBody(),\u0026#34;UTF-8\u0026#34;); log.info(\u0026#34;接受消息成功\u0026#34;); //2. 查询消息消费记录 TradeMqConsumerLogKey primaryKey = new TradeMqConsumerLogKey(); primaryKey.setMsgTag(tags); primaryKey.setMsgKey(keys); primaryKey.setGroupName(groupName); TradeMqConsumerLog mqConsumerLog = mqConsumerLogMapper.selectByPrimaryKey(primaryKey); if(mqConsumerLog!=null){ //3. 判断如果消费过... //3.1 获得消息处理状态 Integer status = mqConsumerLog.getConsumerStatus(); //处理过...返回 if(ShopCode.SHOP_MQ_MESSAGE_STATUS_SUCCESS.getCode().intValue()==status.intValue()){ log.info(\u0026#34;消息:\u0026#34;+msgId+\u0026#34;,已经处理过\u0026#34;); return; } //正在处理...返回 if(ShopCode.SHOP_MQ_MESSAGE_STATUS_PROCESSING.getCode().intValue()==status.intValue()){ log.info(\u0026#34;消息:\u0026#34;+msgId+\u0026#34;,正在处理\u0026#34;); return; } //处理失败 if(ShopCode.SHOP_MQ_MESSAGE_STATUS_FAIL.getCode().intValue()==status.intValue()){ //获得消息处理次数 Integer times = mqConsumerLog.getConsumerTimes(); if(times\u0026gt;3){ log.info(\u0026#34;消息:\u0026#34;+msgId+\u0026#34;,消息处理超过3次,不能再进行处理了\u0026#34;); return; } mqConsumerLog.setConsumerStatus(ShopCode.SHOP_MQ_MESSAGE_STATUS_PROCESSING.getCode()); //使用数据库乐观锁更新 TradeMqConsumerLogExample example = new TradeMqConsumerLogExample(); TradeMqConsumerLogExample.Criteria criteria = example.createCriteria(); criteria.andMsgTagEqualTo(mqConsumerLog.getMsgTag()); criteria.andMsgKeyEqualTo(mqConsumerLog.getMsgKey()); criteria.andGroupNameEqualTo(groupName); criteria.andConsumerTimesEqualTo(mqConsumerLog.getConsumerTimes()); int r = mqConsumerLogMapper.updateByExampleSelective(mqConsumerLog, example); if(r\u0026lt;=0){ //未修改成功,其他线程并发修改 log.info(\u0026#34;并发修改,稍后处理\u0026#34;); } } }else{ //4. 判断如果没有消费过... mqConsumerLog = new TradeMqConsumerLog(); mqConsumerLog.setMsgTag(tags); mqConsumerLog.setMsgKey(keys); mqConsumerLog.setGroupName(groupName); mqConsumerLog.setConsumerStatus(ShopCode.SHOP_MQ_MESSAGE_STATUS_PROCESSING.getCode()); mqConsumerLog.setMsgBody(body); mqConsumerLog.setMsgId(msgId); mqConsumerLog.setConsumerTimes(0); //将消息处理信息添加到数据库 mqConsumerLogMapper.insert(mqConsumerLog); } //5. 回退库存 MQEntity mqEntity = JSON.parseObject(body, MQEntity.class); Long goodsId = mqEntity.getGoodsId(); TradeGoods goods = goodsMapper.selectByPrimaryKey(goodsId); goods.setGoodsNumber(goods.getGoodsNumber()+mqEntity.getGoodsNum()); goodsMapper.updateByPrimaryKey(goods); //6. 将消息的处理状态改为成功 mqConsumerLog.setConsumerStatus(ShopCode.SHOP_MQ_MESSAGE_STATUS_SUCCESS.getCode()); mqConsumerLog.setConsumerTimestamp(new Date()); mqConsumerLogMapper.updateByPrimaryKey(mqConsumerLog); log.info(\u0026#34;回退库存成功\u0026#34;); } catch (Exception e) { e.printStackTrace(); TradeMqConsumerLogKey primaryKey = new TradeMqConsumerLogKey(); primaryKey.setMsgTag(tags); primaryKey.setMsgKey(keys); primaryKey.setGroupName(groupName); TradeMqConsumerLog mqConsumerLog = mqConsumerLogMapper.selectByPrimaryKey(primaryKey); if(mqConsumerLog==null){ //数据库未有记录 mqConsumerLog = new TradeMqConsumerLog(); mqConsumerLog.setMsgTag(tags); mqConsumerLog.setMsgKey(keys); mqConsumerLog.setGroupName(groupName); mqConsumerLog.setConsumerStatus(ShopCode.SHOP_MQ_MESSAGE_STATUS_FAIL.getCode()); mqConsumerLog.setMsgBody(body); mqConsumerLog.setMsgId(msgId); mqConsumerLog.setConsumerTimes(1); mqConsumerLogMapper.insert(mqConsumerLog); }else{ mqConsumerLog.setConsumerTimes(mqConsumerLog.getConsumerTimes()+1); mqConsumerLogMapper.updateByPrimaryKeySelective(mqConsumerLog); } } } } "},{"id":408,"href":"/zh/docs/technology/RocketMQ/heima_/03messagetype/","title":"03收发消息","section":"基础(黑马)_","content":" 学习来源 https://www.bilibili.com/video/BV1L4411y7mn(添加小部分笔记)感谢作者!前提\n依赖包 # \u0026lt;dependency\u0026gt; \u0026lt;groupId\u0026gt;org.apache.rocketmq\u0026lt;/groupId\u0026gt; \u0026lt;artifactId\u0026gt;rocketmq-client\u0026lt;/artifactId\u0026gt; \u0026lt;version\u0026gt;4.4.0\u0026lt;/version\u0026gt; \u0026lt;/dependency\u0026gt; 消息生产者步骤 # 创建生产者,生产者组名\u0026ndash;\u0026gt;指定nameserver地址\u0026ndash;\u0026gt;启动producer\u0026ndash;\u0026gt;\n创建消息对象(Topic、Tag、消息体)\n发送消息、关闭生产者producer\n消息消费者步骤 # 创建消费者,制定消费者组名\u0026ndash;\u0026gt;指定nameserver地址\n订阅Topic和Tag,设置回调函数处理消息\n启动消费者consumer\n消息发送 # 同步消息 # 发送消息后客户端会进行阻塞,直到得到结果后,客户端才会继续执行\npublic static void main(String[] args) throws MQClientException, MQBrokerException, RemotingException, InterruptedException { //创建Producer,并指定生产者组 DefaultMQProducer producer = new DefaultMQProducer(\u0026#34;group1\u0026#34;); producer.setNamesrvAddr(\u0026#34;192.168.1.135:9876;192.168.1.138:9876\u0026#34;); producer.start(); for (int i = 0; i \u0026lt; 10; i++) { Message msg = new Message(); msg.setTopic(\u0026#34;base\u0026#34;); msg.setTags(\u0026#34;Tag1\u0026#34;); msg.setBody((\u0026#34;hello world\u0026#34; + i).getBytes()); //发送消息 SendResult result = producer.send(msg); //发送状态 SendStatus sendStatus = result.getSendStatus(); //消息id String msgId = result.getMsgId(); //消息接收队列id MessageQueue messageQueue = result.getMessageQueue(); int queueId = messageQueue.getQueueId(); log.info(result.toString()); log.info(messageQueue.toString()); log.info(\u0026#34;status:\u0026#34; + sendStatus + \u0026#34;msgId:\u0026#34; + msgId + \u0026#34;queueId\u0026#34; + queueId); TimeUnit.SECONDS.sleep(1); } log.info(\u0026#34;发送结束===================\u0026#34;); producer.shutdown(); } 异步消息 # 发送消息后不会导致阻塞,当broker返回结果时,会调用回调函数进行处理\npublic static void main(String[] args) throws MQClientException, MQBrokerException, RemotingException, InterruptedException { //创建Producer,并指定生产者组 DefaultMQProducer producer = new DefaultMQProducer(\u0026#34;group1\u0026#34;); producer.setNamesrvAddr(\u0026#34;192.168.1.135:9876;192.168.1.138:9876\u0026#34;); producer.start(); for (int i = 0; i \u0026lt; 10; i++) { Message msg = new Message(); msg.setTopic(\u0026#34;base\u0026#34;); msg.setTags(\u0026#34;Tag1\u0026#34;); msg.setBody((\u0026#34;hello world\u0026#34; + i).getBytes()); //发送消息 producer.send(msg, new SendCallback() { @Override public void onSuccess(SendResult result) { //发送状态 SendStatus sendStatus = result.getSendStatus(); //消息id String msgId = result.getMsgId(); //消息接收队列id MessageQueue messageQueue = result.getMessageQueue(); int queueId = messageQueue.getQueueId(); log.info(result.toString()); log.info(messageQueue.toString()); log.info(\u0026#34;status:\u0026#34; + sendStatus + \u0026#34;msgId:\u0026#34; + msgId + \u0026#34;queueId\u0026#34; + queueId); } @Override public void onException(Throwable throwable) { log.error(\u0026#34;发送异常\u0026#34; + throwable); } }); //TimeUnit.SECONDS.sleep(1); } log.info(\u0026#34;发送结束===================\u0026#34;); TimeUnit.SECONDS.sleep(3); } 单向消息 # 不关心发送结果\npublic static void main(String[] args) throws MQClientException, MQBrokerException, RemotingException, InterruptedException { //创建Producer,并指定生产者组 DefaultMQProducer producer = new DefaultMQProducer(\u0026#34;group1\u0026#34;); producer.setNamesrvAddr(\u0026#34;192.168.1.135:9876;192.168.1.138:9876\u0026#34;); producer.start(); for (int i = 0; i \u0026lt; 10; i++) { Message msg = new Message(); msg.setTopic(\u0026#34;base\u0026#34;); msg.setTags(\u0026#34;Tag3\u0026#34;); msg.setBody((\u0026#34;hello world danxiang\u0026#34; + i).getBytes()); //发送消息 producer.sendOneway(msg); //TimeUnit.SECONDS.sleep(1); } log.info(\u0026#34;发送结束===================\u0026#34;); TimeUnit.SECONDS.sleep(3); } 消费消息 # public static void main(String[] args) throws MQClientException { DefaultMQPushConsumer consumer = new DefaultMQPushConsumer(\u0026#34;group1\u0026#34;); consumer.setNamesrvAddr(\u0026#34;192.168.1.135:9876;192.168.1.138:9876\u0026#34;); consumer.subscribe(\u0026#34;base\u0026#34;, \u0026#34;Tag3\u0026#34;); consumer.registerMessageListener(new MessageListenerConcurrently() { @Override public ConsumeConcurrentlyStatus consumeMessage(List\u0026lt;MessageExt\u0026gt; list, ConsumeConcurrentlyContext consumeConcurrentlyContext) { for (MessageExt messageExt : list) { log.info(messageExt.toString()); String s = new String(messageExt.getBody()); log.info(s); } return ConsumeConcurrentlyStatus.CONSUME_SUCCESS; } }); consumer.start(); } 消费模式 # 注意事项 # 如果一个消息在广播消费模式下被消费过,之后再启动一个消费者,那么它可以在集群消费模式下再被消费一次。或者:\n如果一个消息在集群消费模式下被消费过,之后再启动一个消费者,那么它可以在广播消费模式下再被消费一次 如果一个消息在广播消费模式下被消费过,之后再启动一个消费者,那么它不能在广播模式下再被消费。或者\n如果一个消息在集群消费模式下被消费过,之后再启动一个消费者,那么它不能在集群模式下再被消费。 顺序消息 # 消息实体 # @Data @AllArgsConstructor @NoArgsConstructor @ToString public class OrderStep { private int orderId; private String desc; public static List\u0026lt;OrderStep\u0026gt; getData(){ List\u0026lt;OrderStep\u0026gt; orderSteps=new ArrayList\u0026lt;\u0026gt;(); OrderStep orderStep=new OrderStep(123,\u0026#34;创建\u0026#34;); orderSteps.add(orderStep); orderStep=new OrderStep(125,\u0026#34;创建\u0026#34;); orderSteps.add(orderStep); orderStep=new OrderStep(123,\u0026#34;付款\u0026#34;); orderSteps.add(orderStep); orderStep=new OrderStep(125,\u0026#34;付款\u0026#34;); orderSteps.add(orderStep); orderStep=new OrderStep(123,\u0026#34;推送\u0026#34;); orderSteps.add(orderStep); orderStep=new OrderStep(124,\u0026#34;创建\u0026#34;); orderSteps.add(orderStep); orderStep=new OrderStep(124,\u0026#34;付款\u0026#34;); orderSteps.add(orderStep); orderStep=new OrderStep(124,\u0026#34;推送\u0026#34;); orderSteps.add(orderStep); orderStep=new OrderStep(123,\u0026#34;完成\u0026#34;); orderSteps.add(orderStep); orderStep=new OrderStep(125,\u0026#34;推送\u0026#34;); orderSteps.add(orderStep); return orderSteps; } } 发送消息 # //同一个订单的消息,放在同一个topic的同一个queue里面 public static void main(String[] args) throws MQClientException { DefaultMQPushConsumer consumer = new DefaultMQPushConsumer(\u0026#34;group1\u0026#34;); consumer.setNamesrvAddr(\u0026#34;192.168.1.135:9876;192.168.1.138:9876\u0026#34;); consumer.subscribe(\u0026#34;base\u0026#34;, \u0026#34;Tag1\u0026#34;); consumer.setMessageModel(MessageModel.BROADCASTING); consumer.registerMessageListener(new MessageListenerConcurrently() { @Override public ConsumeConcurrentlyStatus consumeMessage(List\u0026lt;MessageExt\u0026gt; list, ConsumeConcurrentlyContext consumeConcurrentlyContext) { for (MessageExt messageExt : list) { //log.info(messageExt.toString()); String s = new String(messageExt.getBody()); log.info(s); } return ConsumeConcurrentlyStatus.CONSUME_SUCCESS; } }); consumer.start(); } 顺序消费消息 # public class ConsumerOrder { public static void main(String[] args) throws MQClientException { DefaultMQPushConsumer consumer = new DefaultMQPushConsumer(\u0026#34;group1\u0026#34;); consumer.setNamesrvAddr(\u0026#34;192.168.1.135:9876;192.168.1.138:9876\u0026#34;); consumer.subscribe(\u0026#34;OrderTopic\u0026#34;, \u0026#34;*\u0026#34;); consumer.registerMessageListener(new MessageListenerOrderly() { @Override public ConsumeOrderlyStatus consumeMessage(List\u0026lt;MessageExt\u0026gt; list, ConsumeOrderlyContext consumeOrderlyContext) { for (MessageExt messageExt : list) { //log.info(messageExt.toString()); String s = new String(messageExt.getBody()); log.info(s); } return ConsumeOrderlyStatus.SUCCESS; } }); consumer.start(); } } MessageListenerOrderly 保证了同一时刻只有一个线程去消费这个queue,但不能保证每次消费queue的会是同一个线程\n由于queue具有先进先出的有序性,所以这并不影响消费queue中消息的顺序性\n延时消息 # 在生产者端设置,可以设置一个消息在一定延时后才能消费\nmessage.setDelayTimLevel(2) //级别2,即延时10秒//1s 5s 10s 30s 1m\n批量消息发送 # producer.send(List\u0026lt;Message\u0026gt; messages)\n事务消息 # 事务消息的架构图 # 生产者 # public class SyncProducer { public static void main(String[] args) throws MQClientException, MQBrokerException, RemotingException, InterruptedException { //创建Producer,并指定生产者组 TransactionMQProducer producer = new TransactionMQProducer(\u0026#34;group1\u0026#34;); producer.setNamesrvAddr(\u0026#34;192.168.1.135:9876;192.168.1.138:9876\u0026#34;); producer.setTransactionListener(new TransactionListener() { /** * 在该方法中执行本地事务 * @param message * @param o * @return */ @Override public LocalTransactionState executeLocalTransaction(Message message, Object o) { if(\u0026#34;TAGA\u0026#34;.equals(message.getTags())){ return LocalTransactionState.COMMIT_MESSAGE; }else if(\u0026#34;TAGB\u0026#34;.equals(message.getTags())){ return LocalTransactionState.ROLLBACK_MESSAGE; }else if(\u0026#34;TAGC\u0026#34;.equals(message.getTags())){ return LocalTransactionState.UNKNOW; } return LocalTransactionState.UNKNOW; } /** * 该方法时MQ进行消息是无状态的回查 * @param messageExt * @return */ @Override public LocalTransactionState checkLocalTransaction(MessageExt messageExt) { log.info(\u0026#34;消息的回查:\u0026#34;+messageExt.getTags()); try { log.info(\u0026#34;5s后告诉mq可以提交了\u0026#34;); TimeUnit.SECONDS.sleep(5); } catch (InterruptedException e) { e.printStackTrace(); } //可以提交 return LocalTransactionState.COMMIT_MESSAGE; } }); producer.start(); String[] tags={\u0026#34;TAGA\u0026#34;,\u0026#34;TAGB\u0026#34;,\u0026#34;TAGC\u0026#34;}; for (int i = 0; i \u0026lt; 3; i++) { Message msg = new Message(); msg.setTopic(\u0026#34;TransactionTopic\u0026#34;); msg.setTags(tags[i]); msg.setBody((\u0026#34;hello world\u0026#34; + i).getBytes()); //发送消息 //参数:针对某一个消息进行事务控制 SendResult result = producer.sendMessageInTransaction(msg,null); //发送状态 SendStatus sendStatus = result.getSendStatus(); log.info(result.toString()); log.info(\u0026#34;status:\u0026#34; + sendStatus ); } log.info(\u0026#34;发送结束===================\u0026#34;); //producer.shutdown(); } } 消费者 # @Slf4j public class Consumer { public static void main(String[] args) throws MQClientException { DefaultMQPushConsumer consumer = new DefaultMQPushConsumer(\u0026#34;group1\u0026#34;); consumer.setNamesrvAddr(\u0026#34;192.168.1.135:9876;192.168.1.138:9876\u0026#34;); consumer.subscribe(\u0026#34;TransactionTopic\u0026#34;, \u0026#34;*\u0026#34;); consumer.registerMessageListener(new MessageListenerConcurrently() { @Override public ConsumeConcurrentlyStatus consumeMessage(List\u0026lt;MessageExt\u0026gt; list, ConsumeConcurrentlyContext consumeConcurrentlyContext) { for (MessageExt messageExt : list) { String s = new String(messageExt.getBody()); log.info(s); } return ConsumeConcurrentlyStatus.CONSUME_SUCCESS; } }); consumer.start(); log.info(\u0026#34;生产者启动----\u0026#34;); } } "},{"id":409,"href":"/zh/docs/technology/Algorithm/_algorithhms_4th_/1.4.1-1.4.10/","title":"算法红皮书 1.4.1-1.4.10","section":"_算法(第四版)_","content":" 算法分析 # 使用数学分析为算法成本建立简洁的模型,并使用实验数据验证这些模型\n科学方法 # 观察、假设、预测、观察并核实预测、反复确认预测和观察 原则:实验可重现 观察 # 计算性任务的困难程度可以用问题的规模来衡量\n问题规模可以是输入的大小或某个命令行参数的值\n研究问题规模和运行时间的关系\n使用计时器得到大概的运行时间 典型用例\npublic static void main(String[] args) { int N = Integer.parseInt(args[0]); int[] a = new int[N]; for (int i = 0; i \u0026lt; N; i++) a[i] = StdRandom.uniform(-1000000, 1000000); Stopwatch timer = new Stopwatch(); int cnt = ThreeSum.count(a); double time = timer.elapsedTime(); StdOut.println(cnt + \u0026#34; triples \u0026#34; + time + \u0026#34; seconds\u0026#34;); } 使用方法 数据类型的实现\npublic class Stopwatch { private final long start; public Stopwatch() { start = System.currentTimeMillis(); } public double elapsedTime() { long now = System.currentTimeMillis(); return (now - start) / 1000.0; } } 数学模型 # 程序运行的总时间主要和两点有关:执行每条语句的耗时;执行每条语句的频率\n定义:我们用~f(N) 表示所有随着N 的增大除以f(N) 的结果趋近于1 的函数。我们用g(N) ~ f(N) 表示g(N)/f(N) 随着N 的增大趋近于1。 即使用曰等号忽略较小的项\n$$ f(N)=N^b(logN)^c $$将f(N)称为g(N)的增长的数量级\n常见的增长数量级函数 本书用性质表示需要用实验验证的猜想\nThreeSum分析 执行最频繁的指令决定了程序执行的总时间\u0026ndash;我们将这些指令称为程序的内循环\n程序运行时间的分析 算法的分析 ThreeSum的运行时间增长数量级为N^3,与在哪台机器无关\n成本模型 3-sum的成本模型:数组的访问次数(访问数组元素的次数,无论读写)\n总结-得到运行时间的数学模型所需的步骤\n确定输入模型,定义问题的规模 识别内循环 根据内循环中的操作确定成本模型 对于给定的输入,判断这些操作的执行效率 增长数量级的分类 # 成长增长的数量级一般都是问题规模N的若干函数之一,如下表 常数级别表示运行时间不依赖于N 对数级别,经典例子是二分查找 线性级别(常见的for循环) 线性对数级别 ,其中,对数的底数和增长的数量级无关 平方级别,一般指两个嵌套的for循环 立方级别,一般含有三个嵌套的for循环 指数级别 问题规模(图) 典型的增长数量级函数(图) 典型的增长数量级函数 在某个成本模型下可以提出精确的命题 比如,归并排序所需的比较次数在$1/2NlgN$~$NlgN$之间 ,即归并排序所需的运行时间的增长数量级是线性对数的,也就是:归并排序是线性对数的 设计更快的算法 # 前提,目前已知归并排序是线性对数级别的,二分查找是对数级别的\n将3-sum问题简化为2-sum问题,即找出一个输入文件中所有和为0的整数对的数量,为了简化问题,题设所有整数均不相同\n可以使用双层循环,以平方级别来解决\n改进后的算法,当且仅当-a[i]存在于数组中且a[i]非零时,a[i]存在于某个和为0的整数对之中\n代码如下\nimport java.util.Arrays; public class TwoSumFast { public static int count(int[] a) { // 计算和为0的整数对的数目 Arrays.sort(a); int N = a.length; int cnt = 0; for (int i = 0; i \u0026lt; N; i++) if (BinarySearch.rank(-a[i], a) \u0026gt; i) cnt++; return cnt; } public static void main(String[] args) { int[] a = In.readInts(args[0]); StdOut.println(count(a)); } } 3-sum问题的快速算法\n当且仅当-(a[i]+a[j])在数组中,且不是a[i]也不是a[j]时,整数对(a[i]和a[j])为某个和为0的三元组的一部分\n总运行时间和$N^2logN$成正比\n代码如下\nimport java.util.Arrays; public class ThreeSumFast { public static int count(int[] a) { // 计算和为0的三元组的数目 Arrays.sort(a); int N = a.length; int cnt = 0; for (int i = 0; i \u0026lt; N; i++) for (int j = i + 1; j \u0026lt; N; j++) if (BinarySearch.rank(-a[i] - a[j], a) \u0026gt; j) { cnt++; } return cnt; } public static void main(String[] args) { int[] a = In.readInts(args[0]); StdOut.println(count(a)); } } 下界\n为算法在最坏情况下的运行时间给出一个下界的思 想是非常有意义的\n运行时间的总结\n图1 图2 实现并分析该问题的一种简单解法,我们称之为暴力算法\n算法的改进,能降低算法所需的运行时间的增长数量级\n倍率实验 # 翻倍后运行时间,与没翻倍时的运行时间成正比\n代码\npublic class DoublingRatio { public static double timeTrial(int N) // 参见DoublingTest(请见1.4.2.3 节实验程序) public static void main(String[] args) { double prev = timeTrial(125); for (int N = 250; true; N += N) { double time = timeTrial(N); StdOut.printf(\u0026#34;%6d %7.1f \u0026#34;, N, time); StdOut.printf(\u0026#34;%5.1fn\u0026#34;, time/prev); prev = time; } } } 试验结果 预测 倍率定理(没看懂,不管) 评估它解决大型问题的可行性 评估使用更快的计算机所产生的价值 注意事项 # 大常数,$c = 103或106$ 非决定性的内循环 指令时间 系统因素 不分伯仲(相同任务在不同场景效率不一样) 对输入的强烈依赖 多个问题参量 处理对于输入的依赖 # 输入模型,例如假设ThreeSum的所有输入均为随机int值,可能不切实际 输入的分析,需要数学几千 对最坏情况下的性能保证 命题(这里只针对之前的代码) 对计划算法,有时候对输入需要进行打乱 操作序列 均摊分析 通过记录所有操作的总成本并除以操作总数来将成本均摊 内存 # Java的内存分配系统 原始数据类型的常见内存、需求 这里漏了,short也是2字节。总结boolean、byte 1字节;char、short 2字节;int、float 4字节;long、double 8字节 对象(跳过) 要知道一个对象所使用的内存量,需要将所有实例变量使用的内存与内存本身的开销(一般是16字节)\n一般内存的使用都会被填充为8字节的倍数(注意,说的是64位计算机中的机器字)\n引用存储需要8字节\n典型对象的内存需求 例如第一个,16+4=20;20+4 = 24为8的倍数\n链表,嵌套的非静态(内部)类,如上面的Node,需要额外的8字节(用于外部类的引用)\n数组 int值、double值、对象和数组的数组对内存的典型需求 比如一个原始数据类型的数组,需要24字节的头信息(16字节的对象开销,4字节用于保存长度[数组长度],以及4填充字节,再加上保存值需要的内存) Date对象需要的:一个含有N 个Date 对象(请见表1.2.12)的数 组需要使用24 字节(数组开销)加上8N 字节(所有引用)加上每个对象的32 字节,总共(24 +40N)字节 【这里说的是需要,和本身存储是两回事】\n![ly-20241212142056395](img/ly-20241212142056395.png) 字符串对象\nString 的标准实现含有4 个实例变量:一个指向字符数组的引用(8 字节)和三 个int 值(各4 字节)。第一个int 值描述的是字符数组中的偏移量,第二个int 值是一个计数器(字符串的长度)。按照图1.4.10 中所示的实例变量名,对象所表示的字符串由value[offset]到value[offset + count - 1] 中的字符组成。String 对象中的第三个int 值是一个散列值,它在某些情况下可以节省一些计算,我们现在可以忽略它。因此,每个String 对象总共会使用40字节(16 字节表示对象,三个int 实例变量各需4 字节,加上数组引用的8 字节和4 个填充字节)\n字符串的值和子字符串\n一个长度为N 的String 对象一般需要使用40 字节(String 对象本身)加上(24+2N)字节(字符数组),总共(64+2N)字节 Java 对字符串的表示希望能够避免复制字符串中的字符 一个子字符串所需的额外内存是一个常数,构造一个子字符串所需的时间也是常数 关于子字符串 展望 # 最重要的是代码正确,其次才是性能 "},{"id":410,"href":"/zh/docs/technology/Algorithm/_algorithhms_4th_/1.3.3.1-1.3.4/","title":"算法红皮书1.3.3.1-1.3.4","section":"_算法(第四版)_","content":" 背包、队列和栈 # 链表 # 链表是一种递归的数据结构,它或者为空(null),或者是一个指向一个结点(node)的引用,该节点含有一个泛型的元素和一个指向另一条链表的引用。 结点记录 # 使用嵌套类定义结点的抽象数据类型\nprivate class Node { Item item; Node next; } 该类没有其它任何方法,且会在代码中直接引用实例变量,这种类型的变量称为记录 构造链表 # 需要一个Node类型的变量,保证它的值是null或者指向另一个Node对象的next域指向了另一个链表 如下图 链表表示的是一列元素 链式结构在本书中的可视化表示 长方形表示对象;实例变量的值写在长方形中;用指向被引用对象的箭头表示引用关系 术语链接表示对结点的引用 在表头插入结点 # 在首结点为first 的给定链表开头插入字符串not,我们先将first 保存在oldfirst 中, 然后将一个新结点赋予first,并将它的item 域设为not,next 域设为oldfirst\n时间复杂度为O(1)\n如图 从表头删除结点 # 将first指向first.next\n原先的结点称为孤儿,Java的内存管理系统最终将回收它所占用的内存\n如图 在表尾插入结点 # 每个修改链表的操作都需要增加检查是否要修改该变量(以及做出相应修改)的代码\n例如,当删除链表首结点时可能改变指向链表的尾结点的引用,因为链表中只有一个结点时它既是首结点又是尾结点\n如图 其他位置的插入和删除操作 # 删除指定结点;在指定节点插入新结点\n需要将链表尾结点的前一个节点中的链接(它指向的是last)值改为null 为了找到指向last的结点,需要遍历链表,时间复杂度为O(n) 实现任意插入和删除操作的标准解决方案是双向链表 遍历 # 将x初始化为链表首结点,然后通过x.item访问和x相关联的元素,并将x设为x.next来访问链表中的下一个结点,知道x=null(没有下一个结点了,到达链表结尾)\nfor (Node x = first; x != null; x = x.next) { // 处理x.item } 栈的实现 # 使用链表实现栈\n将栈保存为一条链表,栈的顶部即为表头,实例变量first 指向栈顶。这样,当使用push() 压入一个元素时,我们会按照1.3.3.3 节所讨论的代码将该元素添加在表头;当使用pop() 删除一个元素时,我们会按照1.3.3.4 节讨论的代码将该元素从表头删除。要实现size() 方法,我们用实例变量N 保存元素的个数,在压入元素时将N 加1,在弹出元素时将N 减1。要实现isEmpty() 方法,只需检查first 是否为null(或者可以检查N 是否为0)\n实现上述几个操作的时间复杂度为O(1)\n下压堆栈(链表的实现)\npublic class Stack\u0026lt;Item\u0026gt; implements Iterable\u0026lt;Item\u0026gt; { private Node first; // 栈顶(最近添加的元素) private int N; // 元素数量 private class Node { // 定义了结点的嵌套类 Item item; Node next; } public Boolean isEmpty() { return first == null; } // 或:N == 0 public int size() { return N; } public void push(Item item) { // 向栈顶添加元素 Node oldfirst = first; first = new Node(); first.item = item; first.next = oldfirst; N++; } public Item pop() { // 从栈顶删除元素 Item item = first.item; first = first.next; N--; return item; } // iterator() 的实现请见算法1.4 // 测试用例main() 的实现请见本节前面部分 } 测试用例(pop()之前测试用例做了判断)\npublic static void main(String[] args) { // 创建一个栈并根据StdIn中的指示压入或弹出字符串 Stack\u0026lt;String\u0026gt; s = new Stack\u0026lt;String\u0026gt;(); while (!StdIn.isEmpty()) { String item = StdIn.readString(); if (!item.equals(\u0026#34;-\u0026#34;)) s.push(item); else if (!s.isEmpty()) StdOut.print(s.pop() + \u0026#34; \u0026#34;); } StdOut.println(\u0026#34;(\u0026#34; + s.size() + \u0026#34; left on stack)\u0026#34;); } 队列的实现 # 这里维护了first和last两个变量\nQueue实现使用的数据结构和Stack都是链表,但实现了不同的添加和删除元素的算法,所以前者是先入先出,后者是后进先出\nQueue的测试用例\npublic static void main(String[] args) { // 创建一个队列并操作字符串入列或出列 Queue\u0026lt;String\u0026gt; q = new Queue\u0026lt;String\u0026gt;(); while (!StdIn.isEmpty()) { String item = StdIn.readString(); if (!item.equals(\u0026#34;-\u0026#34;)) q.enqueue(item); else if (!q.isEmpty()) StdOut.print(q.dequeue() + \u0026#34; \u0026#34;); } StdOut.println(\u0026#34;(\u0026#34; + q.size() + \u0026#34; left on queue)\u0026#34;); } Queue的测试用例\npublic static void main(String[] args) { // 创建一个队列并操作字符串入列或出列 Queue\u0026lt;String\u0026gt; q = new Queue\u0026lt;String\u0026gt;(); while (!StdIn.isEmpty()) { String item = StdIn.readString(); if (!item.equals(\u0026#34;-\u0026#34;)) q.enqueue(item); else if (!q.isEmpty()) StdOut.print(q.dequeue() + \u0026#34; \u0026#34;); } StdOut.println(\u0026#34;(\u0026#34; + q.size() + \u0026#34; left on queue)\u0026#34;); } Queue的实现\n如下,enqueue()需要额外考虑first,dequeue()需要额外考虑last 如果原队列没有结点,那么增加后last指向了新的元素,应该把first也指向新元素 如果原对队列只有一个元素,那么删除后first确实指向null,而last没有更新,所以需要下面的判断手动更新 public class Queue\u0026lt;Item\u0026gt; implements Iterable\u0026lt;Item\u0026gt; { private Node first; // 指向最早添加的结点的链接 private Node last; // 指向最近添加的结点的链接 private int N; // 队列中的元素数量 private class Node { // 定义了结点的嵌套类 Item item; Node next; } public Boolean isEmpty() { return first == null; } // 或: N == 0. public int size() { return N; } public void enqueue(Item item) { // 向表尾添加元素 Node oldlast = last; last = new Node(); last.item = item; last.next = null; if (isEmpty()) first = last; else oldlast.next = last; N++; } public Item dequeue() { // 从表头删除元素 Item item = first.item; first = first.next; if (isEmpty()) last = null; N--; return item; } // iterator() 的实现请见算法1.4 // 测试用例main() 的实现请见前面 } 在结构化数据集时,链表是数组的一种重要替代方法\n背包的实现 # 只需要将Stack中的push()改为add()即可,并去掉pop()\n下面添加了Iterator实现类,以及iterator()具体方法 其中,嵌套类ListIterator 维护了一个实例变量current来记录链表的当前结点。hasNext() 方法会检测current 是否为null,next() 方法会保存当前元素的引用,将current 变量指向链表中的下个结点并返回所保存的引用。\nimport java.util.Iterator; public class Bag\u0026lt;Item\u0026gt; implements Iterable\u0026lt;Item\u0026gt; { private Node first; // 链表的首结点 private class Node { Item item; Node next; } public void add(Item item) { // 和Stack 的push() 方法完全相同 Node oldfirst = first; first = new Node(); first.item = item; first.next = oldfirst; } public Iterator\u0026lt;Item\u0026gt; iterator() { return new ListIterator(); } private class ListIterator implements Iterator\u0026lt;Item\u0026gt; { private Node current = first; public Boolean hasNext() { return current != null; } public void remove() { } public Item next() { Item item = current.item; current = current.next; return item; } } } 综述 # 学习了支持泛型和迭代的背包、队列和栈\n现在拥有两种表示对象集合的方式,即数组和链表\u0026mdash;\u0026gt;顺序存储和链式存储\n各种含有多个链接的数据结构,如二叉树的数据结构,由含有两个链接的节点组成 复合型的数据结构:背包存储栈,队列存储数组等,例如用数组的背包表示图 基础数据结构 研究新领域时,按以下步骤识别并使用数据抽象解决问题\n定义API 根据应用场景开发用例代码 描述数据结构(一组值的表示),并在API所对应的抽象数据类型的实现中根据它定义类的实例变量 描述算法(实现一组操作的方式),实现类的实例方法 分析算法的性能特点 本书的数据结构举例 End # "},{"id":411,"href":"/zh/docs/technology/RocketMQ/heima_/02buildcluster/","title":"02双主双从集群搭建","section":"基础(黑马)_","content":" 学习来源 https://www.bilibili.com/video/BV1L4411y7mn(添加小部分笔记)感谢作者!\n服务器信息修改 # 在.135和.138均进行下面的操作\n解压 # rocketmq解压到/usr/local/rocketmq目录下\nhost添加 # #添加host vim /etc/hosts ##添加内容 192.168.1.135 rocketmq-nameserver1 192.168.1.138 rocketmq-nameserver2 192.168.1.135 rocketmq-master1 192.168.1.135 rocketmq-slave2 192.168.1.138 rocketmq-master2 192.168.1.138 rocketmq-slave1 ## 保存后 systemctl restart network 防火墙 # 直接关闭 # ## 防火墙关闭 systemctl stop firewalld.service ## 防火墙状态查看 firewall-cmd --state ##禁止开机启动 systemctl disable firewalld.service 或者直接关闭对应端口即可 # 环境变量配置 # 为了执行rocketmq命令方便\n#添加环境变量 vim /etc/profile #添加 ROCKETMQ_HOME=/usr/local/rocketmq/rocketmq-all-4.4.0-bin-release PATH=$PATH:$ROCKETMQ_HOME/bin export ROCKETMQ_HOME PATH #使配置生效 source /etc/profile 消息存储路径创建 # a # mkdir /usr/local/rocketmq/store-a mkdir /usr/local/rocketmq/store-a/commitlog mkdir /usr/local/rocketmq/store-a/consumequeue mkdir /usr/local/rocketmq/store-a/index b # mkdir /usr/local/rocketmq/store-b mkdir /usr/local/rocketmq/store-b/commitlog mkdir /usr/local/rocketmq/store-b/consumequeue mkdir /usr/local/rocketmq/store-b/index 双主双从配置文件的修改 # master-a # #所属集群名字 brokerClusterName=rocketmq-cluster #broker名字,注意此处不同的配置文件填写的不一样 brokerName=broker-a #0 表示 Master,\u0026gt;0 表示 Slave brokerId=0 #nameServer地址,分号分割 namesrvAddr=rocketmq-nameserver1:9876;rocketmq-nameserver2:9876 #在发送消息时,自动创建服务器不存在的topic,默认创建的队列数 defaultTopicQueueNums=4 #是否允许 Broker 自动创建Topic,建议线下开启,线上关闭 autoCreateTopicEnable=true #是否允许 Broker 自动创建订阅组,建议线下开启,线上关闭 autoCreateSubscriptionGroup=true #Broker 对外服务的监听端口 listenPort=10911 #删除文件时间点,默认凌晨 4点 deleteWhen=04 #文件保留时间,默认 48 小时 fileReservedTime=120 #commitLog每个文件的大小默认1G mapedFileSizeCommitLog=1073741824 #ConsumeQueue每个文件默认存30W条,根据业务情况调整 mapedFileSizeConsumeQueue=300000 #destroyMapedFileIntervalForcibly=120000 #redeleteHangedFileInterval=120000 #检测物理文件磁盘空间 diskMaxUsedSpaceRatio=88 #存储路径 storePathRootDir=/usr/local/rocketmq/store-a #commitLog 存储路径 storePathCommitLog=/usr/local/rocketmq/store-a/commitlog #消费队列存储路径存储路径 storePathConsumeQueue=/usr/local/rocketmq/store-a/consumequeue #消息索引存储路径 storePathIndex=/usr/local/rocketmq/store-a/index #checkpoint 文件存储路径 storeCheckpoint=/usr/local/rocketmq/store-a/checkpoint #abort 文件存储路径 abortFile=/usr/local/rocketmq/store-a/abort #限制的消息大小 maxMessageSize=65536 #flushCommitLogLeastPages=4 #flushConsumeQueueLeastPages=2 #flushCommitLogThoroughInterval=10000 #flushConsumeQueueThoroughInterval=60000 #Broker 的角色 #- ASYNC_MASTER 异步复制Master #- SYNC_MASTER 同步双写Master #- SLAVE brokerRole=SYNC_MASTER #刷盘方式 #- ASYNC_FLUSH 异步刷盘 #- SYNC_FLUSH 同步刷盘 flushDiskType=SYNC_FLUSH #checkTransactionMessageEnable=false #发消息线程池数量 #sendMessageThreadPoolNums=128 #拉消息线程池数量 #pullMessageThreadPoolNums=128 slave-b # #所属集群名字 brokerClusterName=rocketmq-cluster #broker名字,注意此处不同的配置文件填写的不一样 brokerName=broker-b #0 表示 Master,\u0026gt;0 表示 Slave brokerId=1 #nameServer地址,分号分割 namesrvAddr=rocketmq-nameserver1:9876;rocketmq-nameserver2:9876 #在发送消息时,自动创建服务器不存在的topic,默认创建的队列数 defaultTopicQueueNums=4 #是否允许 Broker 自动创建Topic,建议线下开启,线上关闭 autoCreateTopicEnable=true #是否允许 Broker 自动创建订阅组,建议线下开启,线上关闭 autoCreateSubscriptionGroup=true #Broker 对外服务的监听端口 listenPort=11011 #删除文件时间点,默认凌晨 4点 deleteWhen=04 #文件保留时间,默认 48 小时 fileReservedTime=120 #commitLog每个文件的大小默认1G mapedFileSizeCommitLog=1073741824 #ConsumeQueue每个文件默认存30W条,根据业务情况调整 mapedFileSizeConsumeQueue=300000 #destroyMapedFileIntervalForcibly=120000 #redeleteHangedFileInterval=120000 #检测物理文件磁盘空间 diskMaxUsedSpaceRatio=88 #存储路径 storePathRootDir=/usr/local/rocketmq/store-b #commitLog 存储路径 storePathCommitLog=/usr/local/rocketmq/store-b/commitlog #消费队列存储路径存储路径 storePathConsumeQueue=/usr/local/rocketmq/store-b/consumequeue #消息索引存储路径 storePathIndex=/usr/local/rocketmq/store-b/index #checkpoint 文件存储路径 storeCheckpoint=/usr/local/rocketmq/store-b/checkpoint #abort 文件存储路径 abortFile=/usr/local/rocketmq/store-b/abort #限制的消息大小 maxMessageSize=65536 #flushCommitLogLeastPages=4 #flushConsumeQueueLeastPages=2 #flushCommitLogThoroughInterval=10000 #flushConsumeQueueThoroughInterval=60000 #Broker 的角色 #- ASYNC_MASTER 异步复制Master #- SYNC_MASTER 同步双写Master #- SLAVE brokerRole=SLAVE #刷盘方式 #- ASYNC_FLUSH 异步刷盘 #- SYNC_FLUSH 同步刷盘 flushDiskType=ASYNC_FLUSH #checkTransactionMessageEnable=false #发消息线程池数量 #sendMessageThreadPoolNums=128 #拉消息线程池数量 #pullMessageThreadPoolNums=128 master-b # #所属集群名字 brokerClusterName=rocketmq-cluster #broker名字,注意此处不同的配置文件填写的不一样 brokerName=broker-b #0 表示 Master,\u0026gt;0 表示 Slave brokerId=0 #nameServer地址,分号分割 namesrvAddr=rocketmq-nameserver1:9876;rocketmq-nameserver2:9876 #在发送消息时,自动创建服务器不存在的topic,默认创建的队列数 defaultTopicQueueNums=4 #是否允许 Broker 自动创建Topic,建议线下开启,线上关闭 autoCreateTopicEnable=true #是否允许 Broker 自动创建订阅组,建议线下开启,线上关闭 autoCreateSubscriptionGroup=true #Broker 对外服务的监听端口 listenPort=10911 #删除文件时间点,默认凌晨 4点 deleteWhen=04 #文件保留时间,默认 48 小时 fileReservedTime=120 #commitLog每个文件的大小默认1G mapedFileSizeCommitLog=1073741824 #ConsumeQueue每个文件默认存30W条,根据业务情况调整 mapedFileSizeConsumeQueue=300000 #destroyMapedFileIntervalForcibly=120000 #redeleteHangedFileInterval=120000 #检测物理文件磁盘空间 diskMaxUsedSpaceRatio=88 #存储路径 storePathRootDir=/usr/local/rocketmq/store-b #commitLog 存储路径 storePathCommitLog=/usr/local/rocketmq/store-b/commitlog #消费队列存储路径存储路径 storePathConsumeQueue=/usr/local/rocketmq/store-b/consumequeue #消息索引存储路径 storePathIndex=/usr/local/rocketmq/store-b/index #checkpoint 文件存储路径 storeCheckpoint=/usr/local/rocketmq/store-b/checkpoint #abort 文件存储路径 abortFile=/usr/local/rocketmq/store-b/abort #限制的消息大小 maxMessageSize=65536 #flushCommitLogLeastPages=4 #flushConsumeQueueLeastPages=2 #flushCommitLogThoroughInterval=10000 #flushConsumeQueueThoroughInterval=60000 #Broker 的角色 #- ASYNC_MASTER 异步复制Master #- SYNC_MASTER 同步双写Master #- SLAVE brokerRole=SYNC_MASTER #刷盘方式 #- ASYNC_FLUSH 异步刷盘 #- SYNC_FLUSH 同步刷盘 flushDiskType=SYNC_FLUSH #checkTransactionMessageEnable=false #发消息线程池数量 #sendMessageThreadPoolNums=128 #拉消息线程池数量 #pullMessageThreadPoolNums=128 slave-a # #所属集群名字 brokerClusterName=rocketmq-cluster #broker名字,注意此处不同的配置文件填写的不一样 brokerName=broker-a #0 表示 Master,\u0026gt;0 表示 Slave brokerId=1 #nameServer地址,分号分割 namesrvAddr=rocketmq-nameserver1:9876;rocketmq-nameserver2:9876 #在发送消息时,自动创建服务器不存在的topic,默认创建的队列数 defaultTopicQueueNums=4 #是否允许 Broker 自动创建Topic,建议线下开启,线上关闭 autoCreateTopicEnable=true #是否允许 Broker 自动创建订阅组,建议线下开启,线上关闭 autoCreateSubscriptionGroup=true #Broker 对外服务的监听端口 listenPort=11011 #删除文件时间点,默认凌晨 4点 deleteWhen=04 #文件保留时间,默认 48 小时 fileReservedTime=120 #commitLog每个文件的大小默认1G mapedFileSizeCommitLog=1073741824 #ConsumeQueue每个文件默认存30W条,根据业务情况调整 mapedFileSizeConsumeQueue=300000 #destroyMapedFileIntervalForcibly=120000 #redeleteHangedFileInterval=120000 #检测物理文件磁盘空间 diskMaxUsedSpaceRatio=88 #存储路径 storePathRootDir=/usr/local/rocketmq/store-a #commitLog 存储路径 storePathCommitLog=/usr/local/rocketmq/store-a/commitlog #消费队列存储路径存储路径 storePathConsumeQueue=/usr/local/rocketmq/store-a/consumequeue #消息索引存储路径 storePathIndex=/usr/local/rocketmq/store-a/index #checkpoint 文件存储路径 storeCheckpoint=/usr/local/rocketmq/store-a/checkpoint #abort 文件存储路径 abortFile=/usr/local/rocketmq/store-a/abort #限制的消息大小 maxMessageSize=65536 #flushCommitLogLeastPages=4 #flushConsumeQueueLeastPages=2 #flushCommitLogThoroughInterval=10000 #flushConsumeQueueThoroughInterval=60000 #Broker 的角色 #- ASYNC_MASTER 异步复制Master #- SYNC_MASTER 同步双写Master #- SLAVE brokerRole=SLAVE #刷盘方式 #- ASYNC_FLUSH 异步刷盘 #- SYNC_FLUSH 同步刷盘 flushDiskType=ASYNC_FLUSH #checkTransactionMessageEnable=false #发消息线程池数量 #sendMessageThreadPoolNums=128 #拉消息线程池数量 #pullMessageThreadPoolNums=128 修改两台主机的runserver.sh及runbroker.sh修改 # 修改runbroker.sh # JAVA_OPT=\u0026#34;${JAVA_OPT} -server -Xms256m -Xmx256m -Xmn128m\u0026#34; 修改runserver.sh # JAVA_OPT=\u0026#34;${JAVA_OPT} -server -Xms256m -Xmx256m -Xmn128m -XX:MetaspaceSize=128m -XX:MaxMetaspaceSize=320m\u0026#34; 两台主机分别启动nameserver和Brocker # ## 在两台主机分别启动nameserver nohup sh mqnamesrv \u0026amp; #135启动master1 nohup sh mqbroker -c /usr/local/rocketmq/rocketmq-all-4.4.0-bin-release/conf/2m-2s-sync/broker-a.properties \u0026amp; #135启动slave2 nohup sh mqbroker -c /usr/local/rocketmq/rocketmq-all-4.4.0-bin-release/conf/2m-2s-sync/broker-b-s.properties \u0026amp; #查看 jps 3478 Jps 3366 BrokerStartup 3446 BrokerStartup 3334 NamesrvStartup #138启动master2 nohup sh mqbroker -c /usr/local/rocketmq/rocketmq-all-4.4.0-bin-release/conf/2m-2s-sync/broker-b.properties \u0026amp; #135启动slave1 nohup sh mqbroker -c /usr/local/rocketmq/rocketmq-all-4.4.0-bin-release/conf/2m-2s-sync/broker-a-s.properties \u0026amp; #查看 jps 3376 Jps 3360 BrokerStartup 3251 NamesrvStartup 3295 BrokerStartup 双主双从集群搭建完毕!\n集群管理工具 # mqadmin # cd bin 进入bin目录:mqadmin,对下面的信息进行操作\ntopic、集群、Broker、消息、消费者组、生产者组、连接相关、namesrv相关、其他\nrocket-console # //已经弃用,现在改用rocketmq-dashboard\nhttps://github.com/apache/rocketmq-dashboard\n"},{"id":412,"href":"/zh/docs/technology/RocketMQ/heima_/01base/","title":"01rocketmq学习","section":"基础(黑马)_","content":" 学习来源 https://www.bilibili.com/video/BV1L4411y7mn(添加小部分笔记)感谢作者!\n基本操作 # 下载 # https://rocketmq.apache.org/download/ 选择Binary下载即可,放到Linux主机中\n前提java运行环境 # yum search java | grep jdk yum install -y java-1.8.0-openjdk-devel.x86_64 # java -version 正常 # javac -version 正常 启动 # #nameserver启动 nohup sh bin/mqnamesrv \u0026amp; #nameserver日志查看 tail -f ~/logs/rocketmqlogs/namesrv.log #输出 2023-04-06 00:08:34 INFO main - tls.client.certPath = null 2023-04-06 00:08:34 INFO main - tls.client.authServer = false 2023-04-06 00:08:34 INFO main - tls.client.trustCertPath = null 2023-04-06 00:08:35 INFO main - Using OpenSSL provider 2023-04-06 00:08:35 INFO main - SSLContext created for server 2023-04-06 00:08:36 INFO NettyEventExecutor - NettyEventExecutor service started 2023-04-06 00:08:36 INFO main - The Name Server boot success. serializeType=JSON 2023-04-06 00:08:36 INFO FileWatchService - FileWatchService service started 2023-04-06 00:09:35 INFO NSScheduledThread1 - -------------------------------------------------------- 2023-04-06 00:09:35 INFO NSScheduledThread1 - configTable SIZE: 0 #broker启动 nohup sh bin/mqbroker -n localhost:9876 \u0026amp; #查看broker日志 tail -f ~/logs/rocketmqlogs/broker.log #日志如下 tail: 无法打开\u0026#34;/root/logs/rocketmqlogs/broker.log\u0026#34; 读取数据: 没有那个文件或目录 tail: 没有剩余文件 👇 #jps查看 2465 Jps 2430 NamesrvStartup #说明没有启动成功,因为默认配置的虚拟机内存较大 vim bin/runbroker.sh 以及 vim runserver.sh #修改 JAVA_OPT=\u0026#34;${JAVA_OPT} -server -Xms8g -Xmx8g -Xmn4g\u0026#34; #修改为 JAVA_OPT=\u0026#34;${JAVA_OPT} -server -Xms256m -Xmx256m -Xmn128m -XX:MetaspaceSize=128m -XX:MaxMetaspaceSize=320m\u0026#34; #修改完毕后启动 #先关闭namesrv后 #按上述启动namesrv以及broker sh bin/mqshutdown namesrv # jsp命令查看进程 2612 Jps 2551 BrokerStartup 2524 NamesrvStartup 测试 # 同一台机器上,两个cmd窗口\n发送端 # #配置namesrv为环境变量 export NAMESRV_ADDR=localhost:9876 #运行程序(发送消息) sh bin/tools.sh org.apache.rocketmq.example.quickstart.Producer #结果 SendResult [sendStatus=SEND_OK, msgId=C0A801640B012503DBD319DEF7D203E1, offsetMsgId=C0A8010300002A9F0000000000057878, messageQueue=MessageQueue [topic=TopicTest, brokerName=rheCentos700, queueId=3], queueOffset=498] SendResult [sendStatus=SEND_OK, msgId=C0A801640B012503DBD319DEF7D803E2, offsetMsgId=C0A8010300002A9F000000000005792C, messageQueue=MessageQueue [topic=TopicTest, brokerName=rheCentos700, queueId=0], queueOffset=498] SendResult [sendStatus=SEND_OK, msgId=C0A801640B012503DBD319DEF7DB03E3, offsetMsgId=C0A8010300002A9F00000000000579E0, messageQueue=MessageQueue [topic=TopicTest, brokerName=rheCentos700, queueId=1], queueOffset=498] 接收端 # #配置namesrv为环境变量 export NAMESRV_ADDR=localhost:9876 #运行程序(发送消息) sh bin/tools.sh org.apache.rocketmq.example.quickstart.Consumer #结果 ConsumeMessageThread_5 Receive New Messages: [MessageExt [queueId=0, storeSize=180, queueOffset=499, sysFlag=0, bornTimestamp=1680712442864, bornHost=/192.168.1.3:45716, storeTimestamp=1680712442878, storeHost=/192.168.1.3:10911, msgId=C0A8010300002A9F0000000000057BFC, commitLogOffset=359420, bodyCRC=1359908749, reconsumeTimes=0, preparedTransactionOffset=0, toString()=Message{topic=\u0026#39;TopicTest\u0026#39;, flag=0, properties={MIN_OFFSET=0, MAX_OFFSET=500, CONSUME_START_TIME=1680712442881, UNIQ_KEY=C0A801640B012503DBD319DEF7F003E6, WAIT=true, TAGS=TagA}, body=[72, 101, 108, 108, 111, 32, 82, 111, 99, 107, 101, 116, 77, 81, 32, 57, 57, 56], transactionId=\u0026#39;null\u0026#39;}]] ConsumeMessageThread_2 Receive New Messages: [MessageExt [queueId=1, storeSize=180, queueOffset=499, sysFlag=0, bornTimestamp=1680712442879, bornHost=/192.168.1.3:45716, storeTimestamp=1680712442883, storeHost=/192.168.1.3:10911, msgId=C0A8010300002A9F0000000000057CB0, commitLogOffset=359600, bodyCRC=638172955, reconsumeTimes=0, preparedTransactionOffset=0, toString()=Message{topic=\u0026#39;TopicTest\u0026#39;, flag=0, properties={MIN_OFFSET=0, MAX_OFFSET=500, CONSUME_START_TIME=1680712442889, UNIQ_KEY=C0A801640B012503DBD319DEF7FF03E7, WAIT=true, TAGS=TagA}, body=[72, 101, 108, 108, 111, 32, 82, 111, 99, 107, 101, 116, 77, 81, 32, 57, 57, 57], transactionId=\u0026#39;null\u0026#39;}]] RocketMQ基本架构 # 简单解释 # nameserver:broker的管理者 broker:自己找nameserer上报 broker:真正存储消息的地方 nameserver是无状态的,即nameserver之间不用同步broker信息,由broker自己上报 Producer集群之间也不需要同步;Consumer集群之间也不需要同步 BrokerMaster和BrokerSlave之间信息是有同步的 如图 # "},{"id":413,"href":"/zh/docs/technology/Algorithm/_algorithhms_4th_/1.3.1.1-1.3.2.5/","title":"算法红皮书 1.3.1.1-1.3.2.5","section":"_算法(第四版)_","content":" 背包、队列和栈 # 数据类型的值就是一组对象的集合,所有操作都是关于添加、删除或是访问集合中的对象 本章将学习三种数据类型:背包Bag、队列Queue、栈Stack 对集合中的对象的表示方式直接影响各种操作的效率 介绍泛型和迭代 介绍并说明链式数据结构的重要性(链表) API # 泛型可迭代的基础集合数据类型的API\n背包\n队列(先进先出FIFO)\n下压(后进先出,LIFO)栈 泛型\n泛型,参数化类型 在每份API 中,类名后的\u0026lt;Item\u0026gt; 记号将Item 定义为一个类型参数,它是一个象征性的占位符,表示的是用例将会使用的某种具体数据类型 自动装箱\n用来处理原始类型 Boolean、Byte、Character、Double、Float、Integer、Long 和Short 分别对应着boolean、byte、char、double、float、int、long 和short 自动将一个原始数据类型转换为一个封装类型称为自动装箱,自动将一个封装类型转换为一个原始数据类型被称为自动拆箱 可迭代的集合类型\n迭代访问集合中的所有元素 背包是一种不支持从中删除元素的集合数据类型\u0026ndash;帮助用例收集元素并迭代遍历所有收集到的元素(无序遍历)\n典型用例,计算标准差\n先进先出队列\n是一种基于先进先出(FIFO)策略的集合类型 使用队列的主要原因:集合保存元素的同时保存它们的相对顺序 如图\nQueue用例(先进先出) 下压栈\n简称栈,是一种基于后进先出LIFO策略的集合类型 比如,收邮件等,如图\nStack的用例\n用栈解决算数表达式的问题\n(双栈算数表达式求值算法)\n集合类数据类型的实现 # 定容栈,表示容量固定的字符串栈的抽象数据类型\n只能处理String值,支持push和pop\n抽象数据类型\n测试用例\n使用方法\n数据类型的实现\n泛型\npublic class FixedCapacityStack\u0026lt;Item\u0026gt; 由于不允许直接创建泛型数组,所以 a =new Item[cap] 不允许,应该改为\na=(Item[])new Object[cap]; 泛型定容栈的抽象数据类型\n测试用例\n使用方法\n数据类型的实现\n调整数组大小\nN为当前元素的数量\n使用resize创建新的数组\n当元素满了的时候进行扩容\n当元素过少(1/4)的时候,进行减半\n对象游离\nJava的垃圾回收策略是回收所有无法被访问的对象的内存\n示例中,被弹出的元素不再需要,但由于数组中的引用仍然让它可以继续存在(垃圾回收器无法回收),这种情况(保存了一个不需要的对象的引用)称为游离,避免游离的做法就是将数组元素设为null\n迭代\nforeach和while\n集合数据类型必须实现iterator()并返回Iterator对象 Iterator类必须包括两个方法,hasNext()和next() 让类继承Iterable\u0026lt;Item\u0026gt;使类可迭代 使用一个嵌套类\n下压栈的代码\nimport java.util.Iterator; public class ResizingArrayStack\u0026lt;Item\u0026gt; implements Iterable\u0026lt;Item\u0026gt; { private\tItem[] a = (Item[]) new Object[1]; /* 栈元素 */ private int\tN = 0; /* 元素数量 */ public boolean isEmpty() { return(N == 0); } public int size() { return(N); } private void resize( int max ) { /* 将栈移动到一个大小为max 的新数组 */ Item[] temp = (Item[]) new Object[max]; for ( int i = 0; i \u0026lt; N; i++ ) temp[i] = a[i]; a = temp; } public void push( Item item ) { /* 将元素添加到栈顶 */ if ( N == a.length ) resize( 2 * a.length ); a[N++] = item; } public Item pop() { /* 从栈顶删除元素 */ Item item = a[--N]; a[N] = null; /* 避免对象游离(请见1.3.2.4 节) */ if ( N \u0026gt; 0 \u0026amp;\u0026amp; N == a.length / 4 ) resize( a.length / 2 ); return(item); } public Iterator\u0026lt;Item\u0026gt; iterator() { return(new ReverseArrayIterator() ); } private class ReverseArrayIterator implements Iterator\u0026lt;Item\u0026gt; { /* 支持后进先出的迭代 */ private int i = N; public boolean hasNext() { return(i \u0026gt; 0); } public Item next() { return(a[--i]); } public void remove() { } } } End # "},{"id":414,"href":"/zh/docs/technology/Algorithm/_algorithhms_4th_/1.2.1-1.2.5/","title":"算法红皮书 1.2.1-1.2.5","section":"_算法(第四版)_","content":" 数据抽象 # 数据类型指的是一组值和一组对这些值的操作的集合\n定义和使用数据类型的过程,也被称为数据抽象 Java编程的基础是使用class关键字构造被称为引用类型的数据类型,也称面向对象编程 定义自己的数据类型来抽象任意对象 抽象数据类型(ADT)是一种能够对使用者隐藏数据表示的数据类型 抽象数据类型将数据和函数的实现相关联,将数据的表示方式隐藏起来 抽象数据类型使用时,关注API描述的操作上而不会去关心数据的表示;实现抽象数据类型时,关注数据本身并将实现对数据的各种操作 研究同一个问题的不同算法的主要原因是他们的性能不同 使用抽象数据类型 # 使用一种数据类型并不一定非得知道它是如何实现的 使用Counter(计数器)的简单数据类型的程序,操作有 创建对象并初始化为0 当前值加1 获取当前值 场景,用于电子计票 抽象数据类型的API(应用程序编程接口) API用来说明抽象数据类型的行为 将列出所有构造函数和实例方法(即操作) 计算器的API\n继承的方法 所有数据类型都会继承toString()方法 Java会在用+运算符将任意数据类型的值和String值连接时调用toString() 默认实现:返回该数据类型值的内存地址 用例代码 可以在用例代码中,声明变量、创建对象来保存数据类型的值并允许通过实例方法来操作它们 对象 对象是能够承载数据类型的值的实体 对象三大特性:状态、标识和行为 状态:数据类型中的值 标识:在内存中的地址 行为:数据类型的操作 Java使用\u0026quot;引用类型\u0026quot;和原始数据类型区别 创建对象 每种数据类型中的值都存储于一个对象中 构造函数总是返回他的数据类型的对象的引用 使用new(),会为新的对象分配内存空间,调用构造函数初始化对象中的值,返回该对象的一个引用 抽象数据类型向用例隐藏了值的表示细节 实例方法:参数按值传递 方法每次触发都和一个对象相关 静态方法的主要作用是实现函数;非静态(实例)方法的主要作用是实现数据类型的操作 使用对象\n开发某种数据类型的用例 声明该类型的变量,以引用对象 使用new触发能够创建该类型的对象的一个构造函数 使用变量名调用实例方法 赋值语句(对象赋值) 别名:两个变量同时指向同一个对象 将对象作为参数 Java将参数值的一个副本从调用端传递给了方法,这种方式称为按值传递 当使用引用类型作为参数时我们创建的都是别名,这种约定会传递引用的值(复制引用),也就是传递对象的引用 虽然无法改变原始的引用(将原变量指向另一个Counter对象),但能够改变该对象的值 将对象作为返回值 由于Java只由一个返回值,有了对象实际上就能返回多个值 数组也是对象 将数组传递给一个方法或是将一个数组变量放在赋值语句的右侧时,我们都是在创建数组引用的一个副本,而非数组的副本 对象的数组\n创建一个对象的数组 使用方括号语法调用数组的构造函数创建数组 对于每个数组元素调用它的构造函数创建相应的对象\n如下图\n运用数据抽象的思想编写代码(定义和使用数据类型,将数据类型的值封装在对象中)的方式称为面向对象编程 总结 数据类型指的是一组值和一组对值的操作的集合 我们会在数据类型的Java类中编写用理 对象是能够存储任意该数据类型的值的实体 对象有三个关键性质:状态、标识和行为 抽象数据类型举例 # 本书中将会用到或开发的所有数据类型 java.lang.* Java标准库中的抽象数据类型,需要import,比如java.io、java.net等 I/O处理嘞抽象数据类型,StdIn和StdOut 面向数据类抽象数据类型,计算机和和信息处理 集合类抽象数据类型,主要是为了简化对同一类型的一组数据的操作,包括Bag、Stack和Queue,PQ(优先队列)、ST(符号表)、SET(集合) 面向操作的抽象数据类型(用来分析各种算法) 图算法相关的抽象数据类型,用来封装各种图的表示的面向数据的抽象数据类型,和一些提供图的处理算法的面向操作的抽象数据类型 几何对象(画图(图形)的)[跳过] 信息处理 抽象数据类型是组织信息的一种自然方式 定义和真实世界中的物体相对应的对象 字符串 java的String 一个String值是一串可以由索引访问的char值 有了String类型可以写出清晰干净的用例代码而无需关心字符串的表示方式 抽象数据类型的实现 # 使用Java的类(class)实现抽象数据类型并将所有代码放入一个和类名相同并带有.java扩展名的文件 如下图\n实例变量\n用来定义数据类型的值(每个对象的状态) 构造函数 每个Java类都至少有一个构造函数以创建一个对象的标识 每个构造函数将创建一个对象并向调用者返回一个该对象的引用 实例方法 如图\n作用域 参数变量、局部变量、实例变量 范围(如图)\nAPI、用例与实现 我们要学习的每个抽象数据类型的实现,都会是一个含有若干私有实例变量、构造函数、实例方法和一个测试用例的Java类 用例和实现分离(一般将用例独立成含有静态方法main()的类) 做法如下 定义一份API,APi的作用是将使用和实现分离,以实现模块化编程 用一个Java类实现API的定义 实现多个测试用例来验证前两步做出的设计决定 例子如下 API\n典型用例\n数据类型的实现\n使用方法(执行程序)\n更多抽象数据类型的实现 # 日期 两种实现方式\n本书反复出现的主题,即理解各种实现对空间和时间的需求 维护多个实现 比较同一份API的两种实现在同一个用例中的性能表现,需要下面非正式的命名约定 使用前缀的描述性修饰符,比如BasicDate和SmallDate,以及是否合法的SmartDate 适合大多数用力的需求的实现,比如Date 累加器 数据类型的设计 # 抽象数据类型是一种向用例隐藏内部表示的数据类型 封装(数据封装) 设计APi 算法与抽象数据类型 能够准确地说明一个算法的目的及其他程序应该如何使用该算法 每个Java程序都是一组静态方法和(或)一种数据类型的实现的集合 本书中关注的是抽象数据类型的实现中的操作和向用例隐藏其中的数据表示 例子,将二分法封装 API\n典型的用例\n数据类型的实现\n接口继承 Java语言为定义对象之间的关系提供了支持,称为接口 接口继承使得我们的程序能够通过调用接口中的方法操作实现该接口的任意类型的对象 本书中使用到的接口\n继承 由Object类继承得到的方法\n继承toString()并自定义 封装类型(内置的引用类型,包括Boolean、Byte、Character、Double、Float、Integer、Long和Short) 等价性 如图\n例子,在Date中重写equals\n内存管理\nJava具有自动内存管理,通过记录孤儿对象并将它们的内存释放到内存池中 不可变性\n使用final保证数据不可变\n使用final修饰的引用类型,不能再引用(指向)其他对象,但对象本身的值可改变 契约式设计 Java语言能够在程序运行时检测程序状态 异常(Exception)+断言(Assertion) 异常与错误\n允许抛出异常或抛出错误 断言\n程序不应该依赖断言 End # "},{"id":415,"href":"/zh/docs/technology/Algorithm/_algorithhms_4th_/1.1.6-1.1.11/","title":"算法红皮书 1.1.6-1.1.11","section":"_算法(第四版)_","content":" 基础编程模型 # 静态方法 # 本书中所有的Java程序要么是数据类型的定义,要么是一个静态方法库 当讨论静态方法和实体方法共有的属性时,我们会使用不加定语的方法一词 方法需要参数(某种数据类型的值)并根据参数计算出某种数据类型的返回值(例如数学函数的结果)或者产生某种副作用(例如打印一个值) 静态方法由签名(public static 以及函数的返回值,方法名及一串参数)和函数体组成 调用静态方法(写出方法名并在后面的括号中列出数值) 方法的性质 方法的参数按值传递,方法中使用的参数变量能够引用调用者的参数并改变其内容(只是不能改变原数组变量本身) 方法名可以被重载 方法只能返回一个值,但能包含多个返回语句 方法可以产生副作用 递归:方法可以调用自己 可以使用数学归纳法证明所解释算法的正确性,编写递归重要的三点 递归总有一个最简单的情况(方法第一条总包含return的条件语句) 递归调用总是去尝试解决一个规模更小的子问题 递归调用的父问题和尝试解决的子问题之间不应该由交集 如下图中,两个子问题各自操作的数组部分是不同的\n基础编程模型 静态方法库是定义在一个Java类中的一组静态方法 Java开发的基本模式是编写一个静态方法库(包含一个main()方法)类完成一个任务 在本书中,当我们提到用于执行一项人物的Java程序时,我们指的就是用这种模式开发的代码(还包括对数据类型的定义) 模块化编程 通过静态方法库实现了模块化编程 一个库中的静态方法也能够调用另一个库中定义的静态方法 单元测试 Java编程最佳实践之一就是每个静态方法库中都包含一个main()函数来测试库中所有的方法 本书中使用main()来说明模块的功能并将测试用例留作练习 外部库 系统标准库 java.lang.*:包括Math库;String和StringBuilder库 导入的系统库 java.util.Arrays 本书中其他库 本书使用了作者开发的标准库Std* API # 模块化编程重要组成部分,记录库方法的用法并供其他人参考的文档 会统一使用应用程序编程接口API的方法列出每个库方法、签名及简述 用例(调用另一个库中的方法的程序),实现(实现了某个API方法的Java代码) 作者自己的两个库,一个扩展Math.random(),一个支持各种统计 随机静态方法库(StdRandom)的API\n数据分析方法库(StdStats)的API\nStdRandom库中的静态方法的实现 编写自己的库 编写用例,实现中将计算过程分解 明确静态方法库和与之对应的API 实现API和一个能够对方法进行独立测试的main()函数 API的目的是将调用和实现分离 字符串 # 字符串拼接,使用 + 类型转换(将用户从键盘输入的内容转换成相应数据类型的值以及将各种数据类型的值转换成能够在屏幕上显示的值)\n如果数字跟在+后面,那么会将数据类型的值自动转换为字符串 命令行参数 Java中字符串的存在,使程序能够接收到从命令行传递来的信息 当输入命令java和一个库名及一系列字符串后,Java系统会调用库的main()方法并将后面的一系列字符串变成一个数组作为参数传递给它 输入输出 # Java程序可以从命令行参数或者一个名为标准输入流的抽象字符流中获得输入,并将输出写入另一个名为标准输出流的字符流中 默认情况下,命令行参数、标准输入和标准输出是和应用程序绑定的,而应用程序是由能够接受命令输入的操作系统或是开发环境所支持 使用终端来指代这个应用程序提供的供输入和显示的窗口,如图\n命令和参数 终端窗口包含一个提示符,通过它我们能够向操作系统输入命令和参数 操作系统常用命令\n标准输出 StdOut库的作用是支持标准输出 标准输出库的静态方法的API\n格式化输出 字符%并紧跟一个字符表示的转换代码(包括d,f和s)。%和转换代码之间可以插入证书表示值的宽度,且转换后会在字符串左边添加空格以达到需要的宽度。负数表示空格从右边加 宽度后用小数点及数值可以指定精度(或String字符串所截取的长度) 格式中转换代码和对应参数的数据类型必须匹配 标准输入 StdIn库从标准输入流中获取数据,然后将标准输出定向到终端窗口 标准输入流最重要的特点,这些值会在程序读取后消失 例子\n标准输入库中的静态方法API\n重定向和管道 将标准输出重定向到一个文件 java RandomSeq 1000 100.0 200.0 \u0026gt; data.txt 从文件而不是终端应用程序中读取数据 java Average \u0026lt; data.txt 将一个程序的输出重定向为另一个程序的输入,叫做管道 java RandomSeq 1000 100.0 200.0 | java Average 突破了我们能够处理的输入输出流的长度限制 即使计算机没有足够的空间来存储十亿个数, 我们仍然可以将例子中的1000 换成1 000 000 000 (当然我们还是需要一些时间来处理它们)。当RandomSeq 调用StdOut.println() 时,它就向输出流的末尾添加了一个字符串;当Average 调用StdIn.readInt() 时,它就从输入流的开头删除了一个字符串。这些动作发生的实际顺序取决于操作系统 命令行的重定向及管道\n基于文件的输入输出 In和Out库提供了一些静态方法,来实现向文件中写入或从文件中读取一个原始数据类型的数组的抽象 用于读取和写入数组的静态方法的API\n标准绘图库(基本方法和控制方法)\u0026ndash;这里跳过 二分查找 # 如图,在终端接收需要判断的数字,如果不存在于白名单(文件中的int数组)中则输出 开发用例以及使用测试文件(数组长度很大的白名单) 模拟实际情况来展示当前算法的必要性,比如 将客户的账号保存在一个文件中,我们称它为白名单; 从标准输入中得到每笔交易的账号; 使用这个测试用例在标准输出中打印所有与任何客户无关的账号,公司很可能拒绝此类交易。 使用顺序查找 public static int rank(int key, int[] a) { for (int i = 0; i \u0026lt; a.length; i++) if (a[i] == key) return i; return -1; } 当处理大量输入的时候,顺序查找的效率极其低 展望 # 下一节,鼓励使用数据抽象,或称面向对象编程,而不是操作预定义的数据类型的静态方法 使用数据抽象的好处 复用性 链式数据结构比数组更灵活 可以准确地定义锁面对的算法问题 1.1 End # "},{"id":416,"href":"/zh/docs/technology/Other/pc_base/","title":"电脑基础操作","section":"其他","content":"\n"},{"id":417,"href":"/zh/docs/technology/Algorithm/_algorithhms_4th_/1.1.1-1.1.5/","title":"算法红皮书 1.1.1-1.1.5","section":"_算法(第四版)_","content":" 基础编程模型 # Java程序的基本结构 # 本书学习算法的方法:用Java编程语言编写的程序来实现算法(相比用自然语言有很多优势) 劣势:编程语言特定,使算法的思想和实现细节变得困难(所以本书尽量使用大部分语言都必须的语法) 把描述和实现算法所用到的语言特性、软件库和操作系统特定总称为基础编程模型 Java程序的基本结构 一段Java程序或者是一个静态方法库,或者定义了一个数据类型,需要用到的语法\n原始数据类型(在计算机中精确地定义整数浮点数布尔值等) 语句(创建变量并赋值,控制运行流程或引发副作用来进行计算,包括声明、赋值、条件、循环、调用和返回) 数组(多个同种数据类型值的集合) 静态方法(封装并重用代码) 字符串(一连串的字符,内置一些对他们的操作) 标准输入/输出(是程序与外界联系的桥梁) 数据抽象(数据抽象封装和重用代码,可以定义非原始数据类型,进而面向对象编程) 把这种输入命令执行程序的环境称为 虚拟终端\n要执行一条Java程序,需要先用javac命令编译,然后用java命令运行,比如下面的文件,需要使用命令\njavac BinarySearch.java java BinarySearch 原始数据类型与表达式 # 数据类型就是一组数据和其所能进行的操作的集合 Java中最基础的数据类型(整型int,双精度实数类型double,布尔值boolean,字符型char) Java程序控制用标识符命名的变量 对于原始类型,用标识符引用变量,+-*/指定操作,用字面量来表示值(如1或3.14),用表达式表示对值的操作( 表达式:(x+2.334)/2 ) 只要能够指定值域和在此值域上的操作,就能定义一个数据类型(很像数学上函数的定义) +-*/是被重载过的 运算产生的数据的数据类型和参与运算的数据的数据类型是相同的(5/3=1,5.0/3.0=1.6667等) 如下图(图歪了亿点点..) 表达式 表达式具有优先级,Java使用的是中缀表达式(一个字面量紧接运算符,然后是另一个字面量)。逻辑运算中优先级 ! \u0026amp;\u0026amp; || ,运算符中 * / % 高于+ - 。括号能改变这些规则。代码中尽量使用括号消除对优先级的依赖 类型转换 数值会自动提升为高级数据类型,如1+2.5 1会被先转为double 1.0,值也为double的3.5 强转(把类型名放在括号里讲其转换为括号中的类型) 讲高级数据类型转为低级可能会导致精度的缺失,尽量少使用 比较 ==、!=、\u0026lt;、\u0026lt;=、\u0026gt;、\u0026gt;=,这些运算符称为 混合类型运算符,因为结果是布尔型而不是参与比较的数据类型 结果是布尔型的表达式称为布尔表达式 其他原始类型(int为32位,double为64位) long,64位整数 short,16位整数 char,16位字符 byte,8位整数 32位单精度实数,float 语句 # 语句用来创建和操作变量、对变量赋值并控制操作的执行流程 包括声明语句、赋值语句、条件语句、循环语句、调用和返回语句 声明:让一个变量名和一个类型在编译时关联起来 赋值:将(由一个表达式定义的)某个数据类型额值和一个变量关联起来 条件语句: if (\u0026lt;boolean expression\u0026gt;) { \u0026lt;block statement\u0026gt; } 循环语句 while(\u0026lt;boolean expression\u0026gt;) { \u0026lt;block statement\u0026gt; } 其中循环语句中的代码段称为循环体 break与continue语句 break,立即退出循环 continue,立即开始下一轮循环 简便记法 # 声明并初始化 隐式赋值 ++i;\u0026ndash;i i/=2;i+=1 单语句代码段(省略if/while代码段的花括号) for语句 for(\u0026lt;initialize\u0026gt;;\u0026lt;boolean expression\u0026gt;;\u0026lt;increment\u0026gt;) { \u0026lt;block statements\u0026gt; } 这段代码等价于后面的 \u0026lt;initialize\u0026gt;; while(\u0026lt;boolean expression\u0026gt;) { \u0026lt;block statments\u0026gt; \u0026lt;increment\u0026gt;; } java语句总结\n数组 # 数组能够存储相同类型的多个数据 N个数组的数组编号为0至N-1;这种数组在Java中称为一维数组 创建并初始化数组 需要三个步骤,声明数组名字和类型,创建数组,初始化数组元素 声明并初始化一个数组\n简化写法\ndouble[] a = new double[N]; 使用数组(访问的索引小于0或者大于N-1时会抛出ArrayIndexOutOfBoundsException) 典型的数组处理代码\n起别名 下面的情况并没有将数组新复制一份,而是a,b指向了同一个数组\n二维数组 Java中二维数组就是一堆数组的数组 二维数组可以是参差不齐,比如a[0]=new double[5],a[1]=new double[6]之类 二维数组的创建及初始化 double[][] a; a = new double[M][N]; for (int i = 0; i \u0026lt; M; i++) for (int j = 0; j \u0026lt; N; j++) a[i][j] = 0.0; 精简后的代码 double[][] a=new double[M][N]; "},{"id":418,"href":"/zh/docs/technology/Linux/hanshunping_/01-06/","title":"linux_韩老师_01-06","section":"韩顺平老师_","content":" 基础介绍 # 本套课程内容\n基础篇: linux入门、vm和Linux的安装、linux目录结构 实操篇 远程登录(xshell,xftp)、实用指令、进程管理、用户管理 vi和vim编辑器、定时任务调度、RPM和YUM 开机、重启和用户登录注销、磁盘分区及挂载、网络配置 linux使用的地方 在linux下开发项目(需要把javaee项目部署到linux下运行) linux运维工程师(服务器规划、优化、监控等) linux嵌入式工程师(linux下驱动开发[c,c++]) linux应用领域 个人桌面 服务器(免费稳定高效) 嵌入式领域(对软件裁剪,内核最小可达几百kb等) linux介绍 # linux是一个开源免费操作系统 linux吉祥物\ntux(/tu\u0026rsquo;ks/唾可si),没找到音标,将就一下\nlinux之父,linus,也是git的创作者\n主要发行版:Ubuntu、RedHat,Centos,Debian等\nRedHat和Centos使用同样的源码,但是RedHat收费 Linux和Unix的关系\nunix也是一个操作系统,贝尔实验室。做一个多用户分时操作系统, multics,但是没完成。其中一个后来在这基础上,完成的操作系统为unix (原本是B语言写的),后面和另一个人用unix用c语言改写了。\nunix源码是公开的,后面商业公司拿来包装做成自己的系统, 后面有个人提倡自由时代用户应该对源码享有读写权利而非垄断\n后面RichardStallman发起GNU计划(开源计划),Linus参加该计划,并共享出linux内核,于是大家在此基础上开发出各种软件。linux又称GNU/linux Linux和Unix关系\nVMWare安装Centos7.6 # 在windows中安装Linux系统\nVM和Linux系统在pc中的关系\n安装过程中,网络模式使用NAT模式\n选择最小安装,且选择CompatibilityLibraries和DevelopmentTools\nlinux分区\n一般分为三个\n一般boot1G,swap分区一般跟内存大小一致,这里是2G,所以根分区就是剩下的,也就是20-1-2=17G\n如图,boot,/,swap都是标准分区。且boot和/是ext4的文件格式,swap是swap的文件格式\n修改主机名\n修改密码及增加除root外的普通用户\n修改网络为固定ip(NAT模式下)\n先在VM里面把子网ip改了,这里改成 192.168.200.0\n然后改网关为192.168.200.200\n使用yum install -y vim 安装文本编辑工具 最后在linux中改配置文件 vim /etc/sysconfig/network-scripts/ifcfg-ens33 其中先修改BOOTPROTO=\u0026ldquo;static\u0026rdquo; 然后设置ip地址、网关和DNS, 下面是添加到上面的ifcfg-ens33后面,不是直接执行代码 IPADDR=192.168.200.200 GATEWAY=192.168.200.2 DNS1=192.168.200.2 使用命令重启网络 service network restart # 或者直接重启电脑 reboot 这里顺便装一下zsx\nsh -c \u0026#34;$(wget https://raw.github.com/ohmyzsh/ohmyzsh/master/tools/install.sh -O -)\u0026#34; "},{"id":419,"href":"/zh/docs/technology/Redis/shangguigu_BV1Rv41177Af_/19-A/","title":"redis_尚硅谷_19-A","section":"基础(尚硅谷)_","content":" 验证码模拟 # 首先需要一个MyRedis单例类 /** * MyRedis单例类 */ public class MyJedis { private static Jedis myJedis; public static Jedis getInstance() { //如果是空则进行初始化 if (myJedis == null) { //由于synchronized同步是在条件判断内,所以同步 //并不会一直都执行,增加了效率 synchronized (MyJedis.class) { if (myJedis == null) { //设置密码 DefaultJedisClientConfig.Builder builder = DefaultJedisClientConfig.builder() .password(\u0026#34;hello.lwm\u0026#34;); DefaultJedisClientConfig config = builder.build(); Jedis jedis = new redis.clients.jedis.Jedis(\u0026#34;192.168.200.200\u0026#34;, 6379, config); return jedis; } } } return myJedis; } } "},{"id":420,"href":"/zh/docs/technology/Redis/shangguigu_BV1Rv41177Af_/18/","title":"redis_尚硅谷_18","section":"基础(尚硅谷)_","content":" Jedis操作Redis6 # 插曲:本地项目关联github远程库 git init git add README.md git commit -m \u0026#34;first commit\u0026#34; #-m表示强制重命名 git branch -M main #使用别名 git remote add origin git@github.com:lwmfjc/jedis_demo.git #用了-u之后以后可以直接用git push替代整行 git push -u origin main jedis pom依赖 \u0026lt;!-- https://mvnrepository.com/artifact/redis.clients/jedis --\u0026gt; \u0026lt;dependency\u0026gt; \u0026lt;groupId\u0026gt;redis.clients\u0026lt;/groupId\u0026gt; \u0026lt;artifactId\u0026gt;jedis\u0026lt;/artifactId\u0026gt; \u0026lt;version\u0026gt;4.0.1\u0026lt;/version\u0026gt; \u0026lt;/dependency\u0026gt; jedis使用 public class Main { public static void main(String[] args) { //设置密码 DefaultJedisClientConfig.Builder builder = DefaultJedisClientConfig.builder() .password(\u0026#34;hello.lwm\u0026#34;); DefaultJedisClientConfig config = builder.build(); Jedis jedis = new Jedis(\u0026#34;192.168.200.200\u0026#34;, 6379, config); //ping String value = jedis.ping(); System.out.println(value); //返回所有key Set\u0026lt;String\u0026gt; keys = jedis.keys(\u0026#34;*\u0026#34;); System.out.println(\u0026#34;key count: \u0026#34; + keys.size()); for (String key : keys) { System.out.printf(\u0026#34;key--:%s---value:%s\\n\u0026#34;, key, jedis.get(key)); } System.out.println(\u0026#34;操作list\u0026#34;); //操作list jedis.lpush(\u0026#34;ly-list\u0026#34;, \u0026#34;java\u0026#34;, \u0026#34;c++\u0026#34;, \u0026#34;css\u0026#34;); List\u0026lt;String\u0026gt; lrange = jedis.lrange(\u0026#34;ly-list\u0026#34;, 0, -1); for (String v : lrange) { System.out.println(\u0026#34;value:\u0026#34; + v); } //操作set System.out.println(\u0026#34;操作set\u0026#34;); jedis.sadd(\u0026#34;ly-set\u0026#34;, \u0026#34;1\u0026#34;, \u0026#34;3\u0026#34;, \u0026#34;3\u0026#34;, \u0026#34;5\u0026#34;, \u0026#34;1\u0026#34;); Set\u0026lt;String\u0026gt; smembers = jedis.smembers(\u0026#34;ly-set\u0026#34;); for (String v : smembers) { System.out.println(\u0026#34;value:\u0026#34; + v); } //操作hash System.out.println(\u0026#34;操作hash\u0026#34;); jedis.hset(\u0026#34;ly-hash\u0026#34;, \u0026#34;name\u0026#34;, \u0026#34;lidian\u0026#34;); jedis.hset(\u0026#34;ly-hash\u0026#34;, \u0026#34;age\u0026#34;, \u0026#34;30\u0026#34;); jedis.hset(\u0026#34;ly-hash\u0026#34;, \u0026#34;sex\u0026#34;, \u0026#34;man\u0026#34;); Map\u0026lt;String, String\u0026gt; lyHash = jedis.hgetAll(\u0026#34;ly-hash\u0026#34;); for (String key : lyHash.keySet()) { System.out.println(key + \u0026#34;:\u0026#34; + lyHash.get(key)); } //操作zset System.out.println(\u0026#34;操作zset\u0026#34;); jedis.zadd(\u0026#34;person\u0026#34;, 100, \u0026#34;xiaohong\u0026#34;); jedis.zadd(\u0026#34;person\u0026#34;, 80, \u0026#34;xiaoli\u0026#34;); jedis.zadd(\u0026#34;person\u0026#34;, 90, \u0026#34;xiaochen\u0026#34;); List\u0026lt;String\u0026gt; person = jedis.zrange(\u0026#34;person\u0026#34;, 0, -1); for (String name : person) { System.out.println(name); } //结束操作 jedis.flushDB(); jedis.close(); } } "},{"id":421,"href":"/zh/docs/technology/Redis/shangguigu_BV1Rv41177Af_/12-17/","title":"redis_尚硅谷_12-17","section":"基础(尚硅谷)_","content":" Redis配置文件 # redis中单位的设置,支持k,kb,m,mb,g,gb,且不区分大小写\ninclude (包含其他文件,比如公共部分)\nbind bind 127.0.0.1 ::1 #listens on loopback IPv4 and IPv6 后面这个::1,相当于ipv6版的127.0.0.1。在redis配置文件中,整句表示只允许本地网卡的某个ip连接(但是它并不能指定某个主机连接到redis中。比如本机有两个网卡,两个ip,可以限定只有其中一个ip可以连接) 如果注释掉了/或者bind 0.0.0.0,表示允许所有主机连接 protected-mode protected-mode yes 设置保护模式为yes,protected是redis本身的一个安全层,这个安全层在同时满足下面三个条件的时候会开启,开启后只有本机可以访问redis protected-mode yes 没有bind指令(bind 0.0.0.0不属于这个条件) 没有设置密码 (没有设置requirepass password) 只要上面一个条件不满足,就不会开启保护模式。换言之,只要设置了bind 0.0.0.0或者没有设置bind,且不满足上面三个条件之一,就能够进行远程访问(当然,linux/windows的6379端口要开放) tcp-backlog 表示未连接队列总和 timeout 秒为单位,时间内没操作则断开连接 tcp-keepalive 300 心跳检测,每隔300s检测连接是否存在 pidfile /var/run/redis_6379.pid 将进程号保存到文件中 loglevel 表示日志的级别/debug/verbose/notice/warning logfile \u0026quot;\u0026quot; 设置日志的路径 database 16 默认有16个库 requirepass password 设置密码 maxclients 设置最大连接数 maxmemory 设置最大内存量,达到则会根据移除策略进行移除操作 Redis的发布和订阅 # 发布订阅,pub/sub,是一种消息通信模式:发送者pub发送消息,订阅器sub接收消息 发布者能发布消息,订阅者可以订阅/接收消息\n操作 subscribe channel1 #客户端A订阅频道 publish channel1 helloly #向频道发送消息 此时订阅channel1频道的客户端就会接收到消息\nredis新数据类型 # Bitmaps # 进行二进制操作\n可以把Bitmaps想象成一个以位为单位的数组,数组的每个单元只能存储0和1,数组的下标在Bitmaps中叫做偏移量\nbitcount:统计字符串被设置为1的bit数,这里结果是5\nbitcount u1 0 1 #统计字符串第0个字节到第1个字节1的bit数\n(1,6,11,15,19bit值为1)[也就是统计第0到第15位的1的个数]\nsetbit u1 1 1 setbit u1 2 1 setbit u1 5 1 setbit u1 9 1 setbit u2 0 1 setbit u2 1 1 setbit u2 4 1 setbit u2 9 1 获取u1,u2共同位为1的个数,如上1,9都是1,所以返回2,且 bitcount u1\u0026ndash;u2的值为2(第1和第9位为1),其实就是u1和u2进行\u0026amp;操作\nbitop and u1-and-u2 u1 u2 获取u1或u2存在值为1的位的个数,如上结果为8-2=6,结果存在u1-or-u2中,即1,2,5,9,0,4的位 值为1(的字符串),其实就是u1和u2进行或操作\n性能比较,假设有一亿个用户,用户id数值递增,需求是存储每个用户是否活跃。下面是使用hashMap和bitmaps的比较\nbitmaps主要用来进行位操作计算\nHyperLogLog # 解决基数问题\n从{1,3,5,5,7,8,8,7,9}找出基数:基数为5,即不重复元素的个数 解决方案 mysql中可以用distinct count redis中可以用hash,set,bitmaps 使用 pfadd a 1 2 3 4 3 3 3 2 1 6 7 pfcount a #得到基数 6 pfadd b 1 10 7 15 #基数4 pfmerge c a b #将a,b合并到c pfcount c #得到基数8 GEO类型 (geographic) # 基本命令 geoadd china:city 121.47 31.43 shanghai geoadd china:city 166.50 29.53 chongqing 114.05 22.52 shenzhen geoadd china:city 16.38 39.90 beijing 不支持南北极,所以有效经度在-180到180度,有效纬度从-85.05xxx度到85.05xxx度 获取坐标值及直线距离 geopos china:city beijing #获取beijing经纬度 geodist china:city beijing shenzhen km #获取beijing到shenzhen的直线距离 # 单位有m,km,ft,mi 以给定的经纬度为中心,找出某一半径内的元素 georadius china:city 110 30 1000 km End # "},{"id":422,"href":"/zh/docs/technology/Redis/shangguigu_BV1Rv41177Af_/06-11/","title":"redis_尚硅谷_06-11","section":"基础(尚硅谷)_","content":" Redis针对key的基本操作 # 常用命令 keys * #查找当前库所有库 exists key1 #key1是否存在 1存在;0不存在 type key2 #key2的类型 del key3 #删除key3 unlink key3 #删除key3(选择非阻塞删除。会先从元数据删除,而真正删除是异步删除) expire key1 10 #设置key1的过期时间,单位秒 ttl key1 #获取key1的剩余存活时间,-2表示key已过期或不存在,-1表示永不过期 select 1 #切换到1号库(redis中有15个库,默认在库1) dbsize #查找当前redis库中有多少个key flushdb #清空当前库 flushall #清空所有库 Redis中常用数据类型 # 字符串(String) # String是二进制安全的,可以包含jpg图片或序列化的对象 一个Redis中字符串value最多可以只能是512M 常用命令 set key1 value1 get key1 set key1 value11 #将覆盖上一个值 append key1 abc #在key1的值追加\u0026#34;abc\u0026#34; strlen key1 #key值的长度 setnx key1 value #当key不存在时才设置key incr n1 #将n1的值加一,,如果n1不存在则会创建key n1 并改为1(0+1) decr n1 #将n1的值减一,如果n1不存在则会创建key n1 并改为-1(0-1) incrby n1 20 #将n1的值加20,其他同上 decrby n1 20 #将n1的值减20,其他同上 redis原子性\nincr具有原子性操作\njava中的i++不是原子操作 其他命令 mset k1 v1 k2 v2 mget k1 k2 msetnx k1 v1 k2 v2 #仅当所有的key都不存在时才会进行设置 getrange name 0 3 #截断字符串[0,3] setrange name 3 123 #从下标[3]开始替换字符串(换成123) setex k1 20 v1 #设置过期时间为20s expire k1 30 #设置过期时间为30s getset k1 123 #获取旧值,并设置一个新值 数据结构,SimpleDynamicString,SDS,简单动态字符串,内部结构类似Java的ArrayList,采用预分配冗余空间的方式来减少内存的频繁分配\n列表 (List) # 单键多值 底层是双向链表 从左放 lpush k1 v1 v2 v3 #从左边放(从左往右推) lrange k1 0 -1 #从左边取(v3 v2 v1) lpush:\n从右放 rpush k2 v1 v2 v3 brpush:\nlpop/rpop lpop k2 #从左边弹出一个值 lpop k2 2 #从左边弹出两个值,当键没有包含值时,键被删除 rpoplpush lpush a a1 a2 a3 rpush b b1 b2 b3 rpoplpush a b #此时a:a1 a2,b:a3 b1 b2 b3 lrange lrange b 1 2 #获取b中下标[1,2]的所有值 lrange b 1 -1 #获取所有值[1,最大下标]的所有值 lindex,llen lindex b 1 #直接取第一个下标的元素 llen b #获取列表的长度 linsert linsert b before b2 myinsert linsert b after b2 myinsert #在某个列表的值(如果重复取第一个)的位置之前/之后插入值 lrem,lset lrem b 2 a #从b列表中,删除两个a(从左往右) lset b 2 AA #把下标2的值设置为AA list数据结构是一个快速列表,quicklist\n当元素较少的时候,会使用连续的内存存储,结构时ziplist,即压缩列表;当数据多的时候会有多个压缩列表,然后会链接到一起(使用双向指针)\n集合(Set) # 特点:无序,不重复 Set:string类型的无序集合,底层是一个value为null的hash表;添加/删除时间复杂度为O(1) 常用命令 sadd k1 v1 v2 v3 v2 v2 v1 #设置集合中的值 smembers k1 #取出集合中的值 sismember k1 v3 #k1是否存在v3,存在返回1,不存在返回0 scard k1 #返回集合中元素的个数 srem k1 v2 v3 #删除集合中的v2和v3 spop k1 #从k1中随机取出一个值 srandmember k1 2 #从k1中随机取出2个值 smove a k a1 #从a中将a1移动到k中 sinter a k #取a,k的交集 sunion a k #取a,k的并集 sdiff a k #返回两个集合的差集(从集合a中,去除存在集合k中的元素,即a-k) Set数据结构时dict字典,字典使用哈希表实现的 哈希(Hash) # 是String类型的field和value的映射表,用来存储对象,类似java中的Map\u0026lt;String,Object\u0026gt; 常用命令 hset user:1001 id 1 #设置(对象)user:1001的id属性值 hset user:1001 name zhangsan hget user:1001 name #取出user:1001的name hmset user:1001 id 1 name zhangsan #批量设置(现在hset也可以批量设置了,hmset已弃用) hexists user:1001 id 1 #判断属性id是否存在 hkeys user:1001 #查看hash结构中的所有filed hvals user:1001 #查看hash结构中所有value hincrby user:1001 age 2 #给hash结构的age属性值加2 hsetnx user:1001 age 10 #给hash结构的age属性设置值为10(如果age属性不存在) hash类型数据结构,当field-value长度较短时用的是ziplist,否则使用的是hashtable 有序集合(ZSet) # 与set很相似,但是是有序的 有序集合的所有元素(成员)都关联一个评分(score),score用来从最低到最高方式进行排序,成员唯一但评分是重复的 常用命令 zadd topn 100 xiaoming 120 xiaohong 60 xiaochen #添加key并为每个成员添加评分 zadd topn xiaoli 200 zrange topn 0 -1 #查找出所有成员(按排名由小到大) zrange topn 0 -1 withscores #从小到大查找所有成员并显示分数 zrangebyscore topn 130 200 #查找所有在130-200的成员 zrevrangebyscore topn 200 130 #从大到小查找所有成员(注意,从大到小时第一个值必须大于等于第二个) zincrby topn 15 xiaohong #给小红添加15分 zrem topn xiaohong #删除元素 zcount topn 10 200 #统计该集合,分数区间内的元素个数 zrank topn xiaohong #xiaohong的排名,从0开始 zset底层数据结构 hash结构\n跳跃表 给元素value排序,根据score的范围获取元素列表 对比有序链表和跳跃表 查找51元素\n跳跃表\n按图中的顺序查找,查找四次就能找到\nEnd "},{"id":423,"href":"/zh/docs/problem/Hugo/p1/","title":"hugo踩坑","section":"Hugo","content":" 对于访问文件资源\nhugo的文件夹名不能以-结尾。 一个文件夹(比如这里是hugo文件夹)中,其中的index.md文件中引用图片时,是以index.md所在文件夹(也就是hugo文件夹)为根目录访问图片;而其中的01a.md文件中引用图片时,是以和该文件同级的01a文件夹(也就是hugo/01a/)为根目录,访问图片\n当一个文件夹下存在index.md文件时,其他文件(代表的文章)不显示在网站的文章列表\n为了某些文件预览功能,我建议使用下面的文件夹结构处理文章及资源\n"},{"id":424,"href":"/zh/docs/problem/Hugo/01a/","title":"图片测试(hugo踩坑)","section":"Hugo","content":" 图片测试 # "},{"id":425,"href":"/zh/docs/technology/Redis/shangguigu_BV1Rv41177Af_/01-05/","title":"redis_尚硅谷_01-05","section":"基础(尚硅谷)_","content":" 课程简介 # NoSQL数据库简介、Redis概述与安装、常用五大数据结构、配置文件详解、发布与订阅、Redis6新数据类型、Redis与spring boot整合、事务操作、持久化之RDB、持久化之AOF、主从复制及集群、Redis6应用问题(缓存穿透、击穿、雪崩以及分布式锁)、Redis6新增功能\nNoSQL数据库简介 # Redis属于NoSQL数据库 技术分为三大类 解决功能性问题:Java、Jsp、RDBMS、Tomcat、Linux、JDBC、SVN 解决扩展性问题:Struts、Spring、SpringMVC、Hibernate、Mybatis 解决性能问题:NoSQL、Java线程、Nginx、MQ、ElasticSearch 缓存数据库的好处 完全在内存中,速度快,结构简单 作为缓存数据库:减少io的读操作 NoSQL=Not Only SQL,不仅仅是SQL,泛指非泛型数据库 不支持ACID(但是NoSQL支持事务) 选超于SQL的性能 NoSQL适用场景 对数据高并发的读写 海量数据的读写 对数据高可扩展性 NoSQL不适用的场景 需要事务支持 基于sql的结构化查询存储 多种NoSQL数据库介绍 Memcache 不支持持久化,数据类型单一,一般作为辅助持久化的数据库 Redis 支持持久化,除了k-v模式还有其他多种数据结构,一般作为辅助持久化的数据库 MongoDB,是文档型数据类型;k-v模型,但是对value提供了丰富的查询功能;支持二进制数据及大型对象;替代RDBMS,成为独立数据库 大数据时代(行式数据库、列式数据库) 行式数据库\n查询某一块数据的时候效率高\n列式数据库\n查询某一列统计信息快\n其他\nHbase,Cassandra,图关系数据库(比如社会关系,公共交通网等) 小计\nNoSQL数据库是为提高性能而产生的非关系型数据库 Redis概述与安装 # 简单概述 Redis是一个开源的kv存储系统 相比Mencached,支持存储的数据类型更多,包括string,list,set,zset以及hash,这些类型都支持(pop、add/remove及取交并集和差集等),操作都是原子性的 Redis数据都是缓存在内存中 Redis会周期性地把数据写入磁盘或修改操作写入追加的记录文件 能在此基础上实现master-slave(主从)同步 Redis功能 配合关系型数据库做高速缓存 Redis具有多样的数据结构存储持久化数据 其他部分功能\nRedis安装 从官网中下载redis-6.xx.tar.gz包(该教程在linux中使用redis6教学) 编译redis需要gcc环境 使用gcc \u0026ndash;version查看服务器是否有gcc环境 如果没有需要进行安装 apt install -y gcc 或者 yum install -y gcc 将redis压缩文件进行解压 tar -zxvf redis-6xx.tar.gz 进入解压后的文件夹,并使用make命令进行编译 make 如果报错了,需要先用下面命令清理,之后再进行编译 make distclean 安装redis make install 进入/usr/local/bin目录,查看目录\nRedis启动 前台启动 redis-server 后台启动 在刚才解压的文件夹中,拷贝出redis.conf文件(这里拷贝到/etc/目录下) cp redis.conf /etc/redis.conf 到etc中修改redis.conf文件 vim /etc/redis.conf # 进入编辑器后使用下面命令进行搜索并回车 /daemonize no 将no改为yes并保存 进入/usr/local/bin目录启动redis redis-server /etc/redis.conf 查看进程,发现redis已经启动 ps -ef | grep redis 使用redis-cli 客户端连接redis redis-cli keys * 相关知识 # Redis6379的由来 人名Merz 在九宫格对应的数字就是6379\nRedis默认有15个库,默认数据都在数据库0中,所有库的密码都是相同的 Redis是单线程+多路复用技术 Redis是串行操作\n火车站的例子\n当1,2,3没有票的时候,不用一直等待买票,可以继续做自己的事情,黄牛买到票就会通知123进行取票\nMemcached和Redis区别 Memcached支持单一数据类型,Redis支持多数据类型 Memcached不支持持久化 Memcached用的多线程+锁的机制,Redis用的是单线程+多路复用程序 End # "},{"id":426,"href":"/zh/docs/life/archive/20121226/","title":"2021年最后一个周日","section":"往日归档","content":" 装宽带 # 太晚了,不想写了- -。简单写几个字吧,满心期待的装了宽带,但是并没有我想像的那么快乐。反而打了两把游戏更难过了,难过的是浪费了时间也什么都没得到\n图书馆 # 下午跑去图书馆收获倒是挺多,可能是我不太熟悉,对于书架上的书没有太大的感触。但是环境真的太棒了,很安静,感觉多发出点声音我都会觉得不好意思,大家都很自觉。也许对经常网上都能找到电子书看(程序员的事怎么能是盗呢)的人帮助不会特别大,但对于很大一部分人绝对帮助特别大,包括学生、老年人、还有一些文学类书籍阅读者等等(我一直认为文学类的一定要纸质的看起来才有味道~)\n当然,从图书馆回来我又打了两把游戏 o_O,dota2 yyds!! 打完日常卸载,哈哈\n每次去图书馆我都会想起那句话,\u0026quot;一个国家为其年轻人所提供的教育,决定了这个国家未来的样子\u0026quot;。\n希望能多办点这样的图书馆,大家都能少点浮躁,多点沉淀;虽然我并不是热心公益人士,但我还是希望咱们国家的人民都生活的越来越好。不要辜负我们曾经受过的苦难。\n"},{"id":427,"href":"/zh/docs/life/archive/20231021/","title":"沉沦","section":"往日归档","content":"玩物丧志并非是错的,如果你命里是的话。可惜我不是,我明显有其他更为重要的事等着我去做。我应该是骨子里的老实人。如果顺利的话我应该属于研究所那种老干部,至少现在思维已经老化得跟他们差不多了。\n"},{"id":428,"href":"/zh/docs/technology/Interview/cs-basics/data-structure/heap/","title":"Index","section":"Data Structure","content":" 堆 # 什么是堆 # 堆是一种满足以下条件的树:\n堆中的每一个节点值都大于等于(或小于等于)子树中所有节点的值。或者说,任意一个节点的值都大于等于(或小于等于)所有子节点的值。\n大家可以把堆(最大堆)理解为一个公司,这个公司很公平,谁能力强谁就当老大,不存在弱的人当老大,老大手底下的人一定不会比他强。这样有助于理解后续堆的操作。\n!!!特别提示:\n很多博客说堆是完全二叉树,其实并非如此,堆不一定是完全二叉树,只是为了方便存储和索引,我们通常用完全二叉树的形式来表示堆,事实上,广为人知的斐波那契堆和二项堆就不是完全二叉树,它们甚至都不是二叉树。 (二叉)堆是一个数组,它可以被看成是一个 近似的完全二叉树。——《算法导论》第三版 大家可以尝试判断下面给出的图是否是堆?\n第 1 个和第 2 个是堆。第 1 个是最大堆,每个节点都比子树中所有节点大。第 2 个是最小堆,每个节点都比子树中所有节点小。\n第 3 个不是,第三个中,根结点 1 比 2 和 15 小,而 15 却比 3 大,19 比 5 大,不满足堆的性质。\n堆的用途 # 当我们只关心所有数据中的最大值或者最小值,存在多次获取最大值或者最小值,多次插入或删除数据时,就可以使用堆。\n有小伙伴可能会想到用有序数组,初始化一个有序数组时间复杂度是 O(nlog(n)),查找最大值或者最小值时间复杂度都是 O(1),但是,涉及到更新(插入或删除)数据时,时间复杂度为 O(n),即使是使用复杂度为 O(log(n)) 的二分法找到要插入或者删除的数据,在移动数据时也需要 O(n) 的时间复杂度。\n相对于有序数组而言,堆的主要优势在于插入和删除数据效率较高。 因为堆是基于完全二叉树实现的,所以在插入和删除数据时,只需要在二叉树中上下移动节点,时间复杂度为 O(log(n)),相比有序数组的 O(n),效率更高。\n不过,需要注意的是:Heap 初始化的时间复杂度为 O(n),而非O(nlogn)。\n堆的分类 # 堆分为 最大堆 和 最小堆。二者的区别在于节点的排序方式。\n最大堆:堆中的每一个节点的值都大于等于子树中所有节点的值 最小堆:堆中的每一个节点的值都小于等于子树中所有节点的值 如下图所示,图 1 是最大堆,图 2 是最小堆\n堆的存储 # 之前介绍树的时候说过,由于完全二叉树的优秀性质,利用数组存储二叉树即节省空间,又方便索引(若根结点的序号为 1,那么对于树中任意节点 i,其左子节点序号为 2*i,右子节点序号为 2*i+1)。\n为了方便存储和索引,(二叉)堆可以用完全二叉树的形式进行存储。存储的方式如下图所示:\n堆的操作 # 堆的更新操作主要包括两种 : 插入元素 和 删除堆顶元素。操作过程需要着重掌握和理解。\n在进入正题之前,再重申一遍,堆是一个公平的公司,有能力的人自然会走到与他能力所匹配的位置\n插入元素 # 插入元素,作为一个新入职的员工,初来乍到,这个员工需要从基层做起\n1.将要插入的元素放到最后\n有能力的人会逐渐升职加薪,是金子总会发光的!!!\n2.从底向上,如果父结点比该元素小,则该节点和父结点交换,直到无法交换\n删除堆顶元素 # 根据堆的性质可知,最大堆的堆顶元素为所有元素中最大的,最小堆的堆顶元素是所有元素中最小的。当我们需要多次查找最大元素或者最小元素的时候,可以利用堆来实现。\n删除堆顶元素后,为了保持堆的性质,需要对堆的结构进行调整,我们将这个过程称之为\u0026quot;堆化\u0026quot;,堆化的方法分为两种:\n一种是自底向上的堆化,上述的插入元素所使用的就是自底向上的堆化,元素从最底部向上移动。 另一种是自顶向下堆化,元素由最顶部向下移动。在讲解删除堆顶元素的方法时,我将阐述这两种操作的过程,大家可以体会一下二者的不同。 自底向上堆化 # 在堆这个公司中,会出现老大离职的现象,老大离职之后,他的位置就空出来了\n首先删除堆顶元素,使得数组中下标为 1 的位置空出。\n那么他的位置由谁来接替呢,当然是他的直接下属了,谁能力强就让谁上呗\n比较根结点的左子节点和右子节点,也就是下标为 2,3 的数组元素,将较大的元素填充到根结点(下标为 1)的位置。\n这个时候又空出一个位置了,老规矩,谁有能力谁上\n一直循环比较空出位置的左右子节点,并将较大者移至空位,直到堆的最底部\n这个时候已经完成了自底向上的堆化,没有元素可以填补空缺了,但是,我们可以看到数组中出现了“气泡”,这会导致存储空间的浪费。接下来我们试试自顶向下堆化。\n自顶向下堆化 # 自顶向下的堆化用一个词形容就是“石沉大海”,那么第一件事情,就是把石头抬起来,从海面扔下去。这个石头就是堆的最后一个元素,我们将最后一个元素移动到堆顶。\n然后开始将这个石头沉入海底,不停与左右子节点的值进行比较,和较大的子节点交换位置,直到无法交换位置。\n堆的操作总结 # 插入元素:先将元素放至数组末尾,再自底向上堆化,将末尾元素上浮 删除堆顶元素:删除堆顶元素,将末尾元素放至堆顶,再自顶向下堆化,将堆顶元素下沉。也可以自底向上堆化,只是会产生“气泡”,浪费存储空间。最好采用自顶向下堆化的方式。 堆排序 # 堆排序的过程分为两步:\n第一步是建堆,将一个无序的数组建立为一个堆 第二步是排序,将堆顶元素取出,然后对剩下的元素进行堆化,反复迭代,直到所有元素被取出为止。 建堆 # 如果你已经足够了解堆化的过程,那么建堆的过程掌握起来就比较容易了。建堆的过程就是一个对所有非叶节点的自顶向下堆化过程。\n首先要了解哪些是非叶节点,最后一个节点的父结点及它之前的元素,都是非叶节点。也就是说,如果节点个数为 n,那么我们需要对 n/2 到 1 的节点进行自顶向下(沉底)堆化。\n具体过程如下图:\n将初始的无序数组抽象为一棵树,图中的节点个数为 6,所以 4,5,6 节点为叶节点,1,2,3 节点为非叶节点,所以要对 1-3 号节点进行自顶向下(沉底)堆化,注意,顺序是从后往前堆化,从 3 号节点开始,一直到 1 号节点。 3 号节点堆化结果:\n2 号节点堆化结果:\n1 号节点堆化结果:\n至此,数组所对应的树已经成为了一个最大堆,建堆完成!\n排序 # 由于堆顶元素是所有元素中最大的,所以我们重复取出堆顶元素,将这个最大的堆顶元素放至数组末尾,并对剩下的元素进行堆化即可。\n现在思考两个问题:\n删除堆顶元素后需要执行自顶向下(沉底)堆化还是自底向上(上浮)堆化? 取出的堆顶元素存在哪,新建一个数组存? 先回答第一个问题,我们需要执行自顶向下(沉底)堆化,这个堆化一开始要将末尾元素移动至堆顶,这个时候末尾的位置就空出来了,由于堆中元素已经减小,这个位置不会再被使用,所以我们可以将取出的元素放在末尾。\n机智的小伙伴已经发现了,这其实是做了一次交换操作,将堆顶和末尾元素调换位置,从而将取出堆顶元素和堆化的第一步(将末尾元素放至根结点位置)进行合并。\n详细过程如下图所示:\n取出第一个元素并堆化:\n取出第二个元素并堆化:\n取出第三个元素并堆化:\n取出第四个元素并堆化:\n取出第五个元素并堆化:\n取出第六个元素并堆化:\n堆排序完成!\n"},{"id":429,"href":"/zh/docs/technology/Interview/high-quality-technical-articles/readme/","title":"Index","section":"High Quality Technical Articles","content":" 程序人生 # 这里主要会收录一些我看到的或者我自己写的和程序员密切相关的非技术类的优质文章,每一篇都值得你阅读 3 遍以上!常看常新!\n练级攻略 # 程序员如何快速学习新技术 程序员的技术成长战略 十年大厂成长之路 美团三年,总结的 10 条血泪教训 给想成长为高级别开发同学的七条建议 糟糕程序员的 20 个坏习惯 工作五年之后,对技术和业务的思考 个人经历 # 从校招入职腾讯的四年工作总结 我在滴滴和头条的两年后端研发工作经验分享 一个中科大差生的 8 年程序员工作总结 华为 OD 275 天后,我进了腾讯! 程序员 # 程序员最该拿的几种高含金量证书 程序员怎样出版一本技术书 程序员高效出书避坑和实践指南 面试 # 斩获 20+ 大厂 offer 的面试经验分享 一位大龄程序员所经历的面试的历炼和思考 从面试官和候选者的角度谈如何准备技术初试 包装严重的 IT 行业,作为面试官,我是如何甄别应聘者的包装程度 普通人的春招总结(阿里、腾讯 offer) 2021 校招我的个人经历和经验 如何在技术初试中考察程序员的技术能力 阿里技术面试的一些秘密 工作 # 新入职一家公司如何快速进入工作状态 32 条总结教你提升职场经验 聊聊大厂的绩效考核 "},{"id":430,"href":"/zh/docs/technology/Interview/java/basis/java-keyword-summary/","title":"Index","section":"Basis","content":" final,static,this,super 关键字总结 # final 关键字 # final 关键字,意思是最终的、不可修改的,最见不得变化 ,用来修饰类、方法和变量,具有以下特点:\nfinal 修饰的类不能被继承,final 类中的所有成员方法都会被隐式的指定为 final 方法;\nfinal 修饰的方法不能被重写;\nfinal 修饰的变量是常量,如果是基本数据类型的变量,则其数值一旦在初始化之后便不能更改;如果是引用类型的变量,则在对其初始化之后便不能让其指向另一个对象。\n说明:使用 final 方法的原因有两个:\n把方法锁定,以防任何继承类修改它的含义; 效率。在早期的 Java 实现版本中,会将 final 方法转为内嵌调用。但是如果方法过于庞大,可能看不到内嵌调用带来的任何性能提升(现在的 Java 版本已经不需要使用 final 方法进行这些优化了)。 static 关键字 # static 关键字主要有以下四种使用场景:\n修饰成员变量和成员方法: 被 static 修饰的成员属于类,不属于单个这个类的某个对象,被类中所有对象共享,可以并且建议通过类名调用。被 static 声明的成员变量属于静态成员变量,静态变量 存放在 Java 内存区域的方法区。调用格式:类名.静态变量名 类名.静态方法名() 静态代码块: 静态代码块定义在类中方法外, 静态代码块在非静态代码块之前执行(静态代码块—\u0026gt;非静态代码块—\u0026gt;构造方法)。 该类不管创建多少对象,静态代码块只执行一次. 静态内部类(static 修饰类的话只能修饰内部类): 静态内部类与非静态内部类之间存在一个最大的区别: 非静态内部类在编译完成之后会隐含地保存着一个引用,该引用是指向创建它的外围类,但是静态内部类却没有。没有这个引用就意味着:1. 它的创建是不需要依赖外围类的创建。2. 它不能使用任何外围类的非 static 成员变量和方法。 静态导包(用来导入类中的静态资源,1.5 之后的新特性): 格式为:import static 这两个关键字连用可以指定导入某个类中的指定静态资源,并且不需要使用类名调用类中静态成员,可以直接使用类中静态成员变量和成员方法。 this 关键字 # this 关键字用于引用类的当前实例。 例如:\nclass Manager { Employees[] employees; void manageEmployees() { int totalEmp = this.employees.length; System.out.println(\u0026#34;Total employees: \u0026#34; + totalEmp); this.report(); } void report() { } } 在上面的示例中,this 关键字用于两个地方:\nthis.employees.length:访问类 Manager 的当前实例的变量。 this.report():调用类 Manager 的当前实例的方法。 此关键字是可选的,这意味着如果上面的示例在不使用此关键字的情况下表现相同。 但是,使用此关键字可能会使代码更易读或易懂。\nsuper 关键字 # super 关键字用于从子类访问父类的变量和方法。 例如:\npublic class Super { protected int number; protected showNumber() { System.out.println(\u0026#34;number = \u0026#34; + number); } } public class Sub extends Super { void bar() { super.number = 10; super.showNumber(); } } 在上面的例子中,Sub 类访问父类成员变量 number 并调用其父类 Super 的 showNumber() 方法。\n使用 this 和 super 要注意的问题:\n在构造器中使用 super() 调用父类中的其他构造方法时,该语句必须处于构造器的首行,否则编译器会报错。另外,this 调用本类中的其他构造方法时,也要放在首行。 this、super 不能用在 static 方法中。 简单解释一下:\n被 static 修饰的成员属于类,不属于单个这个类的某个对象,被类中所有对象共享。而 this 代表对本类对象的引用,指向本类对象;而 super 代表对父类对象的引用,指向父类对象;所以, this 和 super 是属于对象范畴的东西,而静态方法是属于类范畴的东西。\n参考 # https://www.codejava.net/java-core/the-java-language/java-keywords https://blog.csdn.net/u013393958/article/details/79881037 static 关键字详解 # static 关键字主要有以下四种使用场景 # 修饰成员变量和成员方法 静态代码块 修饰类(只能修饰内部类) 静态导包(用来导入类中的静态资源,1.5 之后的新特性) 修饰成员变量和成员方法(常用) # 被 static 修饰的成员属于类,不属于单个这个类的某个对象,被类中所有对象共享,可以并且建议通过类名调用。被 static 声明的成员变量属于静态成员变量,静态变量 存放在 Java 内存区域的方法区。\n方法区与 Java 堆一样,是各个线程共享的内存区域,它用于存储已被虚拟机加载的类信息、常量、静态变量、即时编译器编译后的代码等数据。虽然 Java 虚拟机规范把方法区描述为堆的一个逻辑部分,但是它却有一个别名叫做 Non-Heap(非堆),目的应该是与 Java 堆区分开来。\nHotSpot 虚拟机中方法区也常被称为 “永久代”,本质上两者并不等价。仅仅是因为 HotSpot 虚拟机设计团队用永久代来实现方法区而已,这样 HotSpot 虚拟机的垃圾收集器就可以像管理 Java 堆一样管理这部分内存了。但是这并不是一个好主意,因为这样更容易遇到内存溢出问题。\n调用格式:\n类名.静态变量名 类名.静态方法名() 如果变量或者方法被 private 则代表该属性或者该方法只能在类的内部被访问而不能在类的外部被访问。\n测试方法:\npublic class StaticBean { String name; //静态变量 static int age; public StaticBean(String name) { this.name = name; } //静态方法 static void sayHello() { System.out.println(\u0026#34;Hello i am java\u0026#34;); } @Override public String toString() { return \u0026#34;StaticBean{\u0026#34;+ \u0026#34;name=\u0026#34; + name + \u0026#34;,age=\u0026#34; + age + \u0026#34;}\u0026#34;; } } public class StaticDemo { public static void main(String[] args) { StaticBean staticBean = new StaticBean(\u0026#34;1\u0026#34;); StaticBean staticBean2 = new StaticBean(\u0026#34;2\u0026#34;); StaticBean staticBean3 = new StaticBean(\u0026#34;3\u0026#34;); StaticBean staticBean4 = new StaticBean(\u0026#34;4\u0026#34;); StaticBean.age = 33; System.out.println(staticBean + \u0026#34; \u0026#34; + staticBean2 + \u0026#34; \u0026#34; + staticBean3 + \u0026#34; \u0026#34; + staticBean4); //StaticBean{name=1,age=33} StaticBean{name=2,age=33} StaticBean{name=3,age=33} StaticBean{name=4,age=33} StaticBean.sayHello();//Hello i am java } } 静态代码块 # 静态代码块定义在类中方法外, 静态代码块在非静态代码块之前执行(静态代码块 —\u0026gt; 非静态代码块 —\u0026gt; 构造方法)。 该类不管创建多少对象,静态代码块只执行一次.\n静态代码块的格式是\nstatic { 语句体; } 一个类中的静态代码块可以有多个,位置可以随便放,它不在任何的方法体内,JVM 加载类时会执行这些静态的代码块,如果静态代码块有多个,JVM 将按照它们在类中出现的先后顺序依次执行它们,每个代码块只会被执行一次。\n静态代码块对于定义在它之后的静态变量,可以赋值,但是不能访问.\n静态内部类 # 静态内部类与非静态内部类之间存在一个最大的区别,我们知道非静态内部类在编译完成之后会隐含地保存着一个引用,该引用是指向创建它的外围类,但是静态内部类却没有。没有这个引用就意味着:\n它的创建是不需要依赖外围类的创建。 它不能使用任何外围类的非 static 成员变量和方法。 Example(静态内部类实现单例模式)\npublic class Singleton { //声明为 private 避免调用默认构造方法创建对象 private Singleton() { } // 声明为 private 表明静态内部该类只能在该 Singleton 类中被访问 private static class SingletonHolder { private static final Singleton INSTANCE = new Singleton(); } public static Singleton getUniqueInstance() { return SingletonHolder.INSTANCE; } } 当 Singleton 类加载时,静态内部类 SingletonHolder 没有被加载进内存。只有当调用 getUniqueInstance()方法从而触发 SingletonHolder.INSTANCE 时 SingletonHolder 才会被加载,此时初始化 INSTANCE 实例,并且 JVM 能确保 INSTANCE 只被实例化一次。\n这种方式不仅具有延迟初始化的好处,而且由 JVM 提供了对线程安全的支持。\n静态导包 # 格式为:import static\n这两个关键字连用可以指定导入某个类中的指定静态资源,并且不需要使用类名调用类中静态成员,可以直接使用类中静态成员变量和成员方法\n//将Math中的所有静态资源导入,这时候可以直接使用里面的静态方法,而不用通过类名进行调用 //如果只想导入单一某个静态方法,只需要将*换成对应的方法名即可 import static java.lang.Math.*;//换成import static java.lang.Math.max;具有一样的效果 public class Demo { public static void main(String[] args) { int max = max(1,2); System.out.println(max); } } 补充内容 # 静态方法与非静态方法 # 静态方法属于类本身,非静态方法属于从该类生成的每个对象。 如果您的方法执行的操作不依赖于其类的各个变量和方法,请将其设置为静态(这将使程序的占用空间更小)。 否则,它应该是非静态的。\nExample\nclass Foo { int i; public Foo(int i) { this.i = i; } public static String method1() { return \u0026#34;An example string that doesn\u0026#39;t depend on i (an instance variable)\u0026#34;; } public int method2() { return this.i + 1; //Depends on i } } 你可以像这样调用静态方法:Foo.method1()。 如果您尝试使用这种方法调用 method2 将失败。 但这样可行\nFoo bar = new Foo(1); bar.method2(); 总结:\n在外部调用静态方法时,可以使用”类名.方法名”的方式,也可以使用”对象名.方法名”的方式。而实例方法只有后面这种方式。也就是说,调用静态方法可以无需创建对象。 静态方法在访问本类的成员时,只允许访问静态成员(即静态成员变量和静态方法),而不允许访问实例成员变量和实例方法;实例方法则无此限制 static{}静态代码块与{}非静态代码块(构造代码块) # 相同点:都是在 JVM 加载类时且在构造方法执行之前执行,在类中都可以定义多个,定义多个时按定义的顺序执行,一般在代码块中对一些 static 变量进行赋值。\n不同点:静态代码块在非静态代码块之前执行(静态代码块 -\u0026gt; 非静态代码块 -\u0026gt; 构造方法)。静态代码块只在第一次 new 执行一次,之后不再执行,而非静态代码块在每 new 一次就执行一次。 非静态代码块可在普通方法中定义(不过作用不大);而静态代码块不行。\n🐛 修正(参见: issue #677):静态代码块可能在第一次 new 对象的时候执行,但不一定只在第一次 new 的时候执行。比如通过 Class.forName(\u0026quot;ClassDemo\u0026quot;)创建 Class 对象的时候也会执行,即 new 或者 Class.forName(\u0026quot;ClassDemo\u0026quot;) 都会执行静态代码块。 一般情况下,如果有些代码比如一些项目最常用的变量或对象必须在项目启动的时候就执行的时候,需要使用静态代码块,这种代码是主动执行的。如果我们想要设计不需要创建对象就可以调用类中的方法,例如:Arrays 类,Character 类,String 类等,就需要使用静态方法, 两者的区别是 静态代码块是自动执行的而静态方法是被调用的时候才执行的.\nExample:\npublic class Test { public Test() { System.out.print(\u0026#34;默认构造方法!--\u0026#34;); } //非静态代码块 { System.out.print(\u0026#34;非静态代码块!--\u0026#34;); } //静态代码块 static { System.out.print(\u0026#34;静态代码块!--\u0026#34;); } private static void test() { System.out.print(\u0026#34;静态方法中的内容! --\u0026#34;); { System.out.print(\u0026#34;静态方法中的代码块!--\u0026#34;); } } public static void main(String[] args) { Test test = new Test(); Test.test();//静态代码块!--静态方法中的内容! --静态方法中的代码块!-- } } 上述代码输出:\n静态代码块!--非静态代码块!--默认构造方法!--静态方法中的内容! --静态方法中的代码块!-- 当只执行 Test.test(); 时输出:\n静态代码块!--静态方法中的内容! --静态方法中的代码块!-- 当只执行 Test test = new Test(); 时输出:\n静态代码块!--非静态代码块!--默认构造方法!-- 非静态代码块与构造函数的区别是:非静态代码块是给所有对象进行统一初始化,而构造函数是给对应的对象初始化,因为构造函数是可以多个的,运行哪个构造函数就会建立什么样的对象,但无论建立哪个对象,都会先执行相同的构造代码块。也就是说,构造代码块中定义的是不同对象共性的初始化内容。\n参考 # https://blog.csdn.net/chen13579867831/article/details/78995480 https://www.cnblogs.com/chenssy/p/3388487.html https://www.cnblogs.com/Qian123/p/5713440.html "},{"id":431,"href":"/zh/docs/technology/Interview/java/new-features/java8-tutorial-translate/","title":"Index","section":"New Features","content":" 《Java8 指南》中文翻译 # 随着 Java 8 的普及度越来越高,很多人都提到面试中关于 Java 8 也是非常常问的知识点。应各位要求和需要,我打算对这部分知识做一个总结。本来准备自己总结的,后面看到 GitHub 上有一个相关的仓库,地址: https://github.com/winterbe/java8-tutorial。这个仓库是英文的,我对其进行了翻译并添加和修改了部分内容,下面是正文。\n欢迎阅读我对 Java 8 的介绍。本教程将逐步指导您完成所有新语言功能。 在简短的代码示例的基础上,您将学习如何使用默认接口方法,lambda 表达式,方法引用和可重复注释。 在本文的最后,您将熟悉最新的 API 更改,如流,函数式接口(Functional Interfaces),Map 类的扩展和新的 Date API。 没有大段枯燥的文字,只有一堆注释的代码片段。\n接口的默认方法(Default Methods for Interfaces) # Java 8 使我们能够通过使用 default 关键字向接口添加非抽象方法实现。 此功能也称为 虚拟扩展方法。\n第一个例子:\ninterface Formula{ double calculate(int a); default double sqrt(int a) { return Math.sqrt(a); } } Formula 接口中除了抽象方法计算接口公式还定义了默认方法 sqrt。 实现该接口的类只需要实现抽象方法 calculate。 默认方法sqrt 可以直接使用。当然你也可以直接通过接口创建对象,然后实现接口中的默认方法就可以了,我们通过代码演示一下这种方式。\npublic class Main { public static void main(String[] args) { // 通过匿名内部类方式访问接口 Formula formula = new Formula() { @Override public double calculate(int a) { return sqrt(a * 100); } }; System.out.println(formula.calculate(100)); // 100.0 System.out.println(formula.sqrt(16)); // 4.0 } } formula 是作为匿名对象实现的。该代码非常容易理解,6 行代码实现了计算 sqrt(a * 100)。在下一节中,我们将会看到在 Java 8 中实现单个方法对象有一种更好更方便的方法。\n译者注: 不管是抽象类还是接口,都可以通过匿名内部类的方式访问。不能通过抽象类或者接口直接创建对象。对于上面通过匿名内部类方式访问接口,我们可以这样理解:一个内部类实现了接口里的抽象方法并且返回一个内部类对象,之后我们让接口的引用来指向这个对象。\nLambda 表达式(Lambda expressions) # 首先看看在老版本的 Java 中是如何排列字符串的:\nList\u0026lt;String\u0026gt; names = Arrays.asList(\u0026#34;peter\u0026#34;, \u0026#34;anna\u0026#34;, \u0026#34;mike\u0026#34;, \u0026#34;xenia\u0026#34;); Collections.sort(names, new Comparator\u0026lt;String\u0026gt;() { @Override public int compare(String a, String b) { return b.compareTo(a); } }); 只需要给静态方法Collections.sort 传入一个 List 对象以及一个比较器来按指定顺序排列。通常做法都是创建一个匿名的比较器对象然后将其传递给 sort 方法。\n在 Java 8 中你就没必要使用这种传统的匿名对象的方式了,Java 8 提供了更简洁的语法,lambda 表达式:\nCollections.sort(names, (String a, String b) -\u0026gt; { return b.compareTo(a); }); 可以看出,代码变得更短且更具有可读性,但是实际上还可以写得更短:\nCollections.sort(names, (String a, String b) -\u0026gt; b.compareTo(a)); 对于函数体只有一行代码的,你可以去掉大括号{}以及 return 关键字,但是你还可以写得更短点:\nnames.sort((a, b) -\u0026gt; b.compareTo(a)); List 类本身就有一个 sort 方法。并且 Java 编译器可以自动推导出参数类型,所以你可以不用再写一次类型。接下来我们看看 lambda 表达式还有什么其他用法。\n函数式接口(Functional Interfaces) # 译者注: 原文对这部分解释不太清楚,故做了修改!\nJava 语言设计者们投入了大量精力来思考如何使现有的函数友好地支持 Lambda。最终采取的方法是:增加函数式接口的概念。“函数式接口”是指仅仅只包含一个抽象方法,但是可以有多个非抽象方法(也就是上面提到的默认方法)的接口。 像这样的接口,可以被隐式转换为 lambda 表达式。java.lang.Runnable 与 java.util.concurrent.Callable 是函数式接口最典型的两个例子。Java 8 增加了一种特殊的注解@FunctionalInterface,但是这个注解通常不是必须的(某些情况建议使用),只要接口只包含一个抽象方法,虚拟机会自动判断该接口为函数式接口。一般建议在接口上使用@FunctionalInterface 注解进行声明,这样的话,编译器如果发现你标注了这个注解的接口有多于一个抽象方法的时候会报错的,如下图所示\n示例:\n@FunctionalInterface public interface Converter\u0026lt;F, T\u0026gt; { T convert(F from); } // TODO 将数字字符串转换为整数类型 Converter\u0026lt;String, Integer\u0026gt; converter = (from) -\u0026gt; Integer.valueOf(from); Integer converted = converter.convert(\u0026#34;123\u0026#34;); System.out.println(converted.getClass()); //class java.lang.Integer 译者注: 大部分函数式接口都不用我们自己写,Java8 都给我们实现好了,这些接口都在 java.util.function 包里。\n方法和构造函数引用(Method and Constructor References) # 前一节中的代码还可以通过静态方法引用来表示:\nConverter\u0026lt;String, Integer\u0026gt; converter = Integer::valueOf; Integer converted = converter.convert(\u0026#34;123\u0026#34;); System.out.println(converted.getClass()); //class java.lang.Integer Java 8 允许您通过::关键字传递方法或构造函数的引用。 上面的示例显示了如何引用静态方法。 但我们也可以引用对象方法:\nclass Something { String startsWith(String s) { return String.valueOf(s.charAt(0)); } } Something something = new Something(); Converter\u0026lt;String, String\u0026gt; converter = something::startsWith; String converted = converter.convert(\u0026#34;Java\u0026#34;); System.out.println(converted); // \u0026#34;J\u0026#34; 接下来看看构造函数是如何使用::关键字来引用的,首先我们定义一个包含多个构造函数的简单类:\nclass Person { String firstName; String lastName; Person() {} Person(String firstName, String lastName) { this.firstName = firstName; this.lastName = lastName; } } 接下来我们指定一个用来创建 Person 对象的对象工厂接口:\ninterface PersonFactory\u0026lt;P extends Person\u0026gt; { P create(String firstName, String lastName); } 这里我们使用构造函数引用来将他们关联起来,而不是手动实现一个完整的工厂:\nPersonFactory\u0026lt;Person\u0026gt; personFactory = Person::new; Person person = personFactory.create(\u0026#34;Peter\u0026#34;, \u0026#34;Parker\u0026#34;); 我们只需要使用 Person::new 来获取 Person 类构造函数的引用,Java 编译器会自动根据PersonFactory.create方法的参数类型来选择合适的构造函数。\nLambda 表达式作用域(Lambda Scopes) # 访问局部变量 # 我们可以直接在 lambda 表达式中访问外部的局部变量:\nfinal int num = 1; Converter\u0026lt;Integer, String\u0026gt; stringConverter = (from) -\u0026gt; String.valueOf(from + num); stringConverter.convert(2); // 3 但是和匿名对象不同的是,这里的变量 num 可以不用声明为 final,该代码同样正确:\nint num = 1; Converter\u0026lt;Integer, String\u0026gt; stringConverter = (from) -\u0026gt; String.valueOf(from + num); stringConverter.convert(2); // 3 不过这里的 num 必须不可被后面的代码修改(即隐性的具有 final 的语义),例如下面的就无法编译:\nint num = 1; Converter\u0026lt;Integer, String\u0026gt; stringConverter = (from) -\u0026gt; String.valueOf(from + num); num = 3;//在lambda表达式中试图修改num同样是不允许的。 访问字段和静态变量 # 与局部变量相比,我们在 lambda 表达式中对实例字段和静态变量都有读写访问权限。 该行为和匿名对象是一致的。\nclass Lambda4 { static int outerStaticNum; int outerNum; void testScopes() { Converter\u0026lt;Integer, String\u0026gt; stringConverter1 = (from) -\u0026gt; { outerNum = 23; return String.valueOf(from); }; Converter\u0026lt;Integer, String\u0026gt; stringConverter2 = (from) -\u0026gt; { outerStaticNum = 72; return String.valueOf(from); }; } } 访问默认接口方法 # 还记得第一节中的 formula 示例吗? Formula 接口定义了一个默认方法sqrt,可以从包含匿名对象的每个 formula 实例访问该方法。 这不适用于 lambda 表达式。\n无法从 lambda 表达式中访问默认方法,故以下代码无法编译:\nFormula formula = (a) -\u0026gt; sqrt(a * 100); 内置函数式接口(Built-in Functional Interfaces) # JDK 1.8 API 包含许多内置函数式接口。 其中一些接口在老版本的 Java 中是比较常见的比如:Comparator 或Runnable,这些接口都增加了@FunctionalInterface注解以便能用在 lambda 表达式上。\n但是 Java 8 API 同样还提供了很多全新的函数式接口来让你的编程工作更加方便,有一些接口是来自 Google Guava 库里的,即便你对这些很熟悉了,还是有必要看看这些是如何扩展到 lambda 上使用的。\nPredicate # Predicate 接口是只有一个参数的返回布尔类型值的 断言型 接口。该接口包含多种默认方法来将 Predicate 组合成其他复杂的逻辑(比如:与,或,非):\n译者注: Predicate 接口源码如下\npackage java.util.function; import java.util.Objects; @FunctionalInterface public interface Predicate\u0026lt;T\u0026gt; { // 该方法是接受一个传入类型,返回一个布尔值.此方法应用于判断. boolean test(T t); //and方法与关系型运算符\u0026#34;\u0026amp;\u0026amp;\u0026#34;相似,两边都成立才返回true default Predicate\u0026lt;T\u0026gt; and(Predicate\u0026lt;? super T\u0026gt; other) { Objects.requireNonNull(other); return (t) -\u0026gt; test(t) \u0026amp;\u0026amp; other.test(t); } // 与关系运算符\u0026#34;!\u0026#34;相似,对判断进行取反 default Predicate\u0026lt;T\u0026gt; negate() { return (t) -\u0026gt; !test(t); } //or方法与关系型运算符\u0026#34;||\u0026#34;相似,两边只要有一个成立就返回true default Predicate\u0026lt;T\u0026gt; or(Predicate\u0026lt;? super T\u0026gt; other) { Objects.requireNonNull(other); return (t) -\u0026gt; test(t) || other.test(t); } // 该方法接收一个Object对象,返回一个Predicate类型.此方法用于判断第一个test的方法与第二个test方法相同(equal). static \u0026lt;T\u0026gt; Predicate\u0026lt;T\u0026gt; isEqual(Object targetRef) { return (null == targetRef) ? Objects::isNull : object -\u0026gt; targetRef.equals(object); } 示例:\nPredicate\u0026lt;String\u0026gt; predicate = (s) -\u0026gt; s.length() \u0026gt; 0; predicate.test(\u0026#34;foo\u0026#34;); // true predicate.negate().test(\u0026#34;foo\u0026#34;); // false Predicate\u0026lt;Boolean\u0026gt; nonNull = Objects::nonNull; Predicate\u0026lt;Boolean\u0026gt; isNull = Objects::isNull; Predicate\u0026lt;String\u0026gt; isEmpty = String::isEmpty; Predicate\u0026lt;String\u0026gt; isNotEmpty = isEmpty.negate(); Function # Function 接口接受一个参数并生成结果。默认方法可用于将多个函数链接在一起(compose, andThen):\n译者注: Function 接口源码如下\npackage java.util.function; import java.util.Objects; @FunctionalInterface public interface Function\u0026lt;T, R\u0026gt; { //将Function对象应用到输入的参数上,然后返回计算结果。 R apply(T t); //将两个Function整合,并返回一个能够执行两个Function对象功能的Function对象。 default \u0026lt;V\u0026gt; Function\u0026lt;V, R\u0026gt; compose(Function\u0026lt;? super V, ? extends T\u0026gt; before) { Objects.requireNonNull(before); return (V v) -\u0026gt; apply(before.apply(v)); } // default \u0026lt;V\u0026gt; Function\u0026lt;T, V\u0026gt; andThen(Function\u0026lt;? super R, ? extends V\u0026gt; after) { Objects.requireNonNull(after); return (T t) -\u0026gt; after.apply(apply(t)); } static \u0026lt;T\u0026gt; Function\u0026lt;T, T\u0026gt; identity() { return t -\u0026gt; t; } } Function\u0026lt;String, Integer\u0026gt; toInteger = Integer::valueOf; Function\u0026lt;String, String\u0026gt; backToString = toInteger.andThen(String::valueOf); backToString.apply(\u0026#34;123\u0026#34;); // \u0026#34;123\u0026#34; Supplier # Supplier 接口产生给定泛型类型的结果。 与 Function 接口不同,Supplier 接口不接受参数。\nSupplier\u0026lt;Person\u0026gt; personSupplier = Person::new; personSupplier.get(); // new Person Consumer # Consumer 接口表示要对单个输入参数执行的操作。\nConsumer\u0026lt;Person\u0026gt; greeter = (p) -\u0026gt; System.out.println(\u0026#34;Hello, \u0026#34; + p.firstName); greeter.accept(new Person(\u0026#34;Luke\u0026#34;, \u0026#34;Skywalker\u0026#34;)); Comparator # Comparator 是老 Java 中的经典接口, Java 8 在此之上添加了多种默认方法:\nComparator\u0026lt;Person\u0026gt; comparator = (p1, p2) -\u0026gt; p1.firstName.compareTo(p2.firstName); Person p1 = new Person(\u0026#34;John\u0026#34;, \u0026#34;Doe\u0026#34;); Person p2 = new Person(\u0026#34;Alice\u0026#34;, \u0026#34;Wonderland\u0026#34;); comparator.compare(p1, p2); // \u0026gt; 0 comparator.reversed().compare(p1, p2); // \u0026lt; 0 Optional # Optional 不是函数式接口,而是用于防止 NullPointerException 的漂亮工具。这是下一节的一个重要概念,让我们快速了解一下 Optional 的工作原理。\nOptional 是一个简单的容器,其值可能是 null 或者不是 null。在 Java 8 之前一般某个函数应该返回非空对象但是有时却什么也没有返回,而在 Java 8 中,你应该返回 Optional 而不是 null。\n译者注:示例中每个方法的作用已经添加。\n//of():为非null的值创建一个Optional Optional\u0026lt;String\u0026gt; optional = Optional.of(\u0026#34;bam\u0026#34;); // isPresent():如果值存在返回true,否则返回false optional.isPresent(); // true //get():如果Optional有值则将其返回,否则抛出NoSuchElementException optional.get(); // \u0026#34;bam\u0026#34; //orElse():如果有值则将其返回,否则返回指定的其它值 optional.orElse(\u0026#34;fallback\u0026#34;); // \u0026#34;bam\u0026#34; //ifPresent():如果Optional实例有值则为其调用consumer,否则不做处理 optional.ifPresent((s) -\u0026gt; System.out.println(s.charAt(0))); // \u0026#34;b\u0026#34; 推荐阅读: [Java8]如何正确使用 Optional\nStreams(流) # java.util.Stream 表示能应用在一组元素上一次执行的操作序列。Stream 操作分为中间操作或者最终操作两种,最终操作返回一特定类型的计算结果,而中间操作返回 Stream 本身,这样你就可以将多个操作依次串起来。Stream 的创建需要指定一个数据源,比如java.util.Collection 的子类,List 或者 Set, Map 不支持。Stream 的操作可以串行执行或者并行执行。\n首先看看 Stream 是怎么用,首先创建实例代码需要用到的数据 List:\nList\u0026lt;String\u0026gt; stringList = new ArrayList\u0026lt;\u0026gt;(); stringList.add(\u0026#34;ddd2\u0026#34;); stringList.add(\u0026#34;aaa2\u0026#34;); stringList.add(\u0026#34;bbb1\u0026#34;); stringList.add(\u0026#34;aaa1\u0026#34;); stringList.add(\u0026#34;bbb3\u0026#34;); stringList.add(\u0026#34;ccc\u0026#34;); stringList.add(\u0026#34;bbb2\u0026#34;); stringList.add(\u0026#34;ddd1\u0026#34;); Java 8 扩展了集合类,可以通过 Collection.stream() 或者 Collection.parallelStream() 来创建一个 Stream。下面几节将详细解释常用的 Stream 操作:\nFilter(过滤) # 过滤通过一个 predicate 接口来过滤并只保留符合条件的元素,该操作属于中间操作,所以我们可以在过滤后的结果来应用其他 Stream 操作(比如 forEach)。forEach 需要一个函数来对过滤后的元素依次执行。forEach 是一个最终操作,所以我们不能在 forEach 之后来执行其他 Stream 操作。\n// 测试 Filter(过滤) stringList .stream() .filter((s) -\u0026gt; s.startsWith(\u0026#34;a\u0026#34;)) .forEach(System.out::println);//aaa2 aaa1 forEach 是为 Lambda 而设计的,保持了最紧凑的风格。而且 Lambda 表达式本身是可以重用的,非常方便。\nSorted(排序) # 排序是一个 中间操作,返回的是排序好后的 Stream。如果你不指定一个自定义的 Comparator 则会使用默认排序。\n// 测试 Sort (排序) stringList .stream() .sorted() .filter((s) -\u0026gt; s.startsWith(\u0026#34;a\u0026#34;)) .forEach(System.out::println);// aaa1 aaa2 需要注意的是,排序只创建了一个排列好后的 Stream,而不会影响原有的数据源,排序之后原数据 stringList 是不会被修改的:\nSystem.out.println(stringList);// ddd2, aaa2, bbb1, aaa1, bbb3, ccc, bbb2, ddd1 Map(映射) # 中间操作 map 会将元素根据指定的 Function 接口来依次将元素转成另外的对象。\n下面的示例展示了将字符串转换为大写字符串。你也可以通过 map 来将对象转换成其他类型,map 返回的 Stream 类型是根据你 map 传递进去的函数的返回值决定的。\n// 测试 Map 操作 stringList .stream() .map(String::toUpperCase) .sorted((a, b) -\u0026gt; b.compareTo(a)) .forEach(System.out::println);// \u0026#34;DDD2\u0026#34;, \u0026#34;DDD1\u0026#34;, \u0026#34;CCC\u0026#34;, \u0026#34;BBB3\u0026#34;, \u0026#34;BBB2\u0026#34;, \u0026#34;BBB1\u0026#34;, \u0026#34;AAA2\u0026#34;, \u0026#34;AAA1\u0026#34; Match(匹配) # Stream 提供了多种匹配操作,允许检测指定的 Predicate 是否匹配整个 Stream。所有的匹配操作都是 最终操作 ,并返回一个 boolean 类型的值。\n// 测试 Match (匹配)操作 boolean anyStartsWithA = stringList .stream() .anyMatch((s) -\u0026gt; s.startsWith(\u0026#34;a\u0026#34;)); System.out.println(anyStartsWithA); // true boolean allStartsWithA = stringList .stream() .allMatch((s) -\u0026gt; s.startsWith(\u0026#34;a\u0026#34;)); System.out.println(allStartsWithA); // false boolean noneStartsWithZ = stringList .stream() .noneMatch((s) -\u0026gt; s.startsWith(\u0026#34;z\u0026#34;)); System.out.println(noneStartsWithZ); // true Count(计数) # 计数是一个 最终操作,返回 Stream 中元素的个数,返回值类型是 long。\n//测试 Count (计数)操作 long startsWithB = stringList .stream() .filter((s) -\u0026gt; s.startsWith(\u0026#34;b\u0026#34;)) .count(); System.out.println(startsWithB); // 3 Reduce(规约) # 这是一个 最终操作 ,允许通过指定的函数来将 stream 中的多个元素规约为一个元素,规约后的结果是通过 Optional 接口表示的:\n//测试 Reduce (规约)操作 Optional\u0026lt;String\u0026gt; reduced = stringList .stream() .sorted() .reduce((s1, s2) -\u0026gt; s1 + \u0026#34;#\u0026#34; + s2); reduced.ifPresent(System.out::println);//aaa1#aaa2#bbb1#bbb2#bbb3#ccc#ddd1#ddd2 译者注: 这个方法的主要作用是把 Stream 元素组合起来。它提供一个起始值(种子),然后依照运算规则(BinaryOperator),和前面 Stream 的第一个、第二个、第 n 个元素组合。从这个意义上说,字符串拼接、数值的 sum、min、max、average 都是特殊的 reduce。例如 Stream 的 sum 就相当于Integer sum = integers.reduce(0, (a, b) -\u0026gt; a+b);也有没有起始值的情况,这时会把 Stream 的前面两个元素组合起来,返回的是 Optional。\n// 字符串连接,concat = \u0026#34;ABCD\u0026#34; String concat = Stream.of(\u0026#34;A\u0026#34;, \u0026#34;B\u0026#34;, \u0026#34;C\u0026#34;, \u0026#34;D\u0026#34;).reduce(\u0026#34;\u0026#34;, String::concat); // 求最小值,minValue = -3.0 double minValue = Stream.of(-1.5, 1.0, -3.0, -2.0).reduce(Double.MAX_VALUE, Double::min); // 求和,sumValue = 10, 有起始值 int sumValue = Stream.of(1, 2, 3, 4).reduce(0, Integer::sum); // 求和,sumValue = 10, 无起始值 sumValue = Stream.of(1, 2, 3, 4).reduce(Integer::sum).get(); // 过滤,字符串连接,concat = \u0026#34;ace\u0026#34; concat = Stream.of(\u0026#34;a\u0026#34;, \u0026#34;B\u0026#34;, \u0026#34;c\u0026#34;, \u0026#34;D\u0026#34;, \u0026#34;e\u0026#34;, \u0026#34;F\u0026#34;). filter(x -\u0026gt; x.compareTo(\u0026#34;Z\u0026#34;) \u0026gt; 0). reduce(\u0026#34;\u0026#34;, String::concat); 上面代码例如第一个示例的 reduce(),第一个参数(空白字符)即为起始值,第二个参数(String::concat)为 BinaryOperator。这类有起始值的 reduce() 都返回具体的对象。而对于第四个示例没有起始值的 reduce(),由于可能没有足够的元素,返回的是 Optional,请留意这个区别。更多内容查看: IBM:Java 8 中的 Streams API 详解\nParallel Streams(并行流) # 前面提到过 Stream 有串行和并行两种,串行 Stream 上的操作是在一个线程中依次完成,而并行 Stream 则是在多个线程上同时执行。\n下面的例子展示了是如何通过并行 Stream 来提升性能:\n首先我们创建一个没有重复元素的大表:\nint max = 1000000; List\u0026lt;String\u0026gt; values = new ArrayList\u0026lt;\u0026gt;(max); for (int i = 0; i \u0026lt; max; i++) { UUID uuid = UUID.randomUUID(); values.add(uuid.toString()); } 我们分别用串行和并行两种方式对其进行排序,最后看看所用时间的对比。\nSequential Sort(串行排序) # //串行排序 long t0 = System.nanoTime(); long count = values.stream().sorted().count(); System.out.println(count); long t1 = System.nanoTime(); long millis = TimeUnit.NANOSECONDS.toMillis(t1 - t0); System.out.println(String.format(\u0026#34;sequential sort took: %d ms\u0026#34;, millis)); 1000000 sequential sort took: 709 ms//串行排序所用的时间 Parallel Sort(并行排序) # //并行排序 long t0 = System.nanoTime(); long count = values.parallelStream().sorted().count(); System.out.println(count); long t1 = System.nanoTime(); long millis = TimeUnit.NANOSECONDS.toMillis(t1 - t0); System.out.println(String.format(\u0026#34;parallel sort took: %d ms\u0026#34;, millis)); 1000000 parallel sort took: 475 ms//并行排序所用的时间 上面两个代码几乎是一样的,但是并行版的快了 50% 左右,唯一需要做的改动就是将 stream() 改为parallelStream()。\nMaps # 前面提到过,Map 类型不支持 streams,不过 Map 提供了一些新的有用的方法来处理一些日常任务。Map 接口本身没有可用的 stream()方法,但是你可以在键,值上创建专门的流或者通过 map.keySet().stream(),map.values().stream()和map.entrySet().stream()。\n此外,Maps 支持各种新的和有用的方法来执行常见任务。\nMap\u0026lt;Integer, String\u0026gt; map = new HashMap\u0026lt;\u0026gt;(); for (int i = 0; i \u0026lt; 10; i++) { map.putIfAbsent(i, \u0026#34;val\u0026#34; + i); } map.forEach((id, val) -\u0026gt; System.out.println(val));//val0 val1 val2 val3 val4 val5 val6 val7 val8 val9 putIfAbsent 阻止我们在 null 检查时写入额外的代码;forEach接受一个 consumer 来对 map 中的每个元素操作。\n此示例显示如何使用函数在 map 上计算代码:\nmap.computeIfPresent(3, (num, val) -\u0026gt; val + num); map.get(3); // val33 map.computeIfPresent(9, (num, val) -\u0026gt; null); map.containsKey(9); // false map.computeIfAbsent(23, num -\u0026gt; \u0026#34;val\u0026#34; + num); map.containsKey(23); // true map.computeIfAbsent(3, num -\u0026gt; \u0026#34;bam\u0026#34;); map.get(3); // val33 接下来展示如何在 Map 里删除一个键值全都匹配的项:\nmap.remove(3, \u0026#34;val3\u0026#34;); map.get(3); // val33 map.remove(3, \u0026#34;val33\u0026#34;); map.get(3); // null 另外一个有用的方法:\nmap.getOrDefault(42, \u0026#34;not found\u0026#34;); // not found 对 Map 的元素做合并也变得很容易了:\nmap.merge(9, \u0026#34;val9\u0026#34;, (value, newValue) -\u0026gt; value.concat(newValue)); map.get(9); // val9 map.merge(9, \u0026#34;concat\u0026#34;, (value, newValue) -\u0026gt; value.concat(newValue)); map.get(9); // val9concat Merge 做的事情是如果键名不存在则插入,否则对原键对应的值做合并操作并重新插入到 map 中。\nDate API(日期相关 API) # Java 8 在 java.time 包下包含一个全新的日期和时间 API。新的 Date API 与 Joda-Time 库相似,但它们不一样。以下示例涵盖了此新 API 的最重要部分。译者对这部分内容参考相关书籍做了大部分修改。\n译者注(总结):\nClock 类提供了访问当前日期和时间的方法,Clock 是时区敏感的,可以用来取代 System.currentTimeMillis() 来获取当前的微秒数。某一个特定的时间点也可以使用 Instant 类来表示,Instant 类也可以用来创建旧版本的java.util.Date 对象。\n在新 API 中时区使用 ZoneId 来表示。时区可以很方便的使用静态方法 of 来获取到。 抽象类ZoneId(在java.time包中)表示一个区域标识符。 它有一个名为getAvailableZoneIds的静态方法,它返回所有区域标识符。\njdk1.8 中新增了 LocalDate 与 LocalDateTime 等类来解决日期处理方法,同时引入了一个新的类 DateTimeFormatter 来解决日期格式化问题。可以使用 Instant 代替 Date,LocalDateTime 代替 Calendar,DateTimeFormatter 代替 SimpleDateFormat。\nClock # Clock 类提供了访问当前日期和时间的方法,Clock 是时区敏感的,可以用来取代 System.currentTimeMillis() 来获取当前的微秒数。某一个特定的时间点也可以使用 Instant 类来表示,Instant 类也可以用来创建旧版本的java.util.Date 对象。\nClock clock = Clock.systemDefaultZone(); long millis = clock.millis(); System.out.println(millis);//1552379579043 Instant instant = clock.instant(); System.out.println(instant); Date legacyDate = Date.from(instant); //2019-03-12T08:46:42.588Z System.out.println(legacyDate);//Tue Mar 12 16:32:59 CST 2019 Timezones(时区) # 在新 API 中时区使用 ZoneId 来表示。时区可以很方便的使用静态方法 of 来获取到。 抽象类ZoneId(在java.time包中)表示一个区域标识符。 它有一个名为getAvailableZoneIds的静态方法,它返回所有区域标识符。\n//输出所有区域标识符 System.out.println(ZoneId.getAvailableZoneIds()); ZoneId zone1 = ZoneId.of(\u0026#34;Europe/Berlin\u0026#34;); ZoneId zone2 = ZoneId.of(\u0026#34;Brazil/East\u0026#34;); System.out.println(zone1.getRules());// ZoneRules[currentStandardOffset=+01:00] System.out.println(zone2.getRules());// ZoneRules[currentStandardOffset=-03:00] LocalTime(本地时间) # LocalTime 定义了一个没有时区信息的时间,例如 晚上 10 点或者 17:30:15。下面的例子使用前面代码创建的时区创建了两个本地时间。之后比较时间并以小时和分钟为单位计算两个时间的时间差:\nLocalTime now1 = LocalTime.now(zone1); LocalTime now2 = LocalTime.now(zone2); System.out.println(now1.isBefore(now2)); // false long hoursBetween = ChronoUnit.HOURS.between(now1, now2); long minutesBetween = ChronoUnit.MINUTES.between(now1, now2); System.out.println(hoursBetween); // -3 System.out.println(minutesBetween); // -239 LocalTime 提供了多种工厂方法来简化对象的创建,包括解析时间字符串.\nLocalTime late = LocalTime.of(23, 59, 59); System.out.println(late); // 23:59:59 DateTimeFormatter germanFormatter = DateTimeFormatter .ofLocalizedTime(FormatStyle.SHORT) .withLocale(Locale.GERMAN); LocalTime leetTime = LocalTime.parse(\u0026#34;13:37\u0026#34;, germanFormatter); System.out.println(leetTime); // 13:37 LocalDate(本地日期) # LocalDate 表示了一个确切的日期,比如 2014-03-11。该对象值是不可变的,用起来和 LocalTime 基本一致。下面的例子展示了如何给 Date 对象加减天/月/年。另外要注意的是这些对象是不可变的,操作返回的总是一个新实例。\nLocalDate today = LocalDate.now();//获取现在的日期 System.out.println(\u0026#34;今天的日期: \u0026#34;+today);//2019-03-12 LocalDate tomorrow = today.plus(1, ChronoUnit.DAYS); System.out.println(\u0026#34;明天的日期: \u0026#34;+tomorrow);//2019-03-13 LocalDate yesterday = tomorrow.minusDays(2); System.out.println(\u0026#34;昨天的日期: \u0026#34;+yesterday);//2019-03-11 LocalDate independenceDay = LocalDate.of(2019, Month.MARCH, 12); DayOfWeek dayOfWeek = independenceDay.getDayOfWeek(); System.out.println(\u0026#34;今天是周几:\u0026#34;+dayOfWeek);//TUESDAY 从字符串解析一个 LocalDate 类型和解析 LocalTime 一样简单,下面是使用 DateTimeFormatter 解析字符串的例子:\nString str1 = \u0026#34;2014==04==12 01时06分09秒\u0026#34;; // 根据需要解析的日期、时间字符串定义解析所用的格式器 DateTimeFormatter fomatter1 = DateTimeFormatter .ofPattern(\u0026#34;yyyy==MM==dd HH时mm分ss秒\u0026#34;); LocalDateTime dt1 = LocalDateTime.parse(str1, fomatter1); System.out.println(dt1); // 输出 2014-04-12T01:06:09 String str2 = \u0026#34;2014$$$四月$$$13 20小时\u0026#34;; DateTimeFormatter fomatter2 = DateTimeFormatter .ofPattern(\u0026#34;yyy$$$MMM$$$dd HH小时\u0026#34;); LocalDateTime dt2 = LocalDateTime.parse(str2, fomatter2); System.out.println(dt2); // 输出 2014-04-13T20:00 再来看一个使用 DateTimeFormatter 格式化日期的示例\nLocalDateTime rightNow=LocalDateTime.now(); String date=DateTimeFormatter.ISO_LOCAL_DATE_TIME.format(rightNow); System.out.println(date);//2019-03-12T16:26:48.29 DateTimeFormatter formatter=DateTimeFormatter.ofPattern(\u0026#34;YYYY-MM-dd HH:mm:ss\u0026#34;); System.out.println(formatter.format(rightNow));//2019-03-12 16:26:48 🐛 修正(参见: issue#1157):使用 YYYY 显示年份时,会显示当前时间所在周的年份,在跨年周会有问题。一般情况下都使用 yyyy,来显示准确的年份。\n跨年导致日期显示错误示例:\nLocalDateTime rightNow = LocalDateTime.of(2020, 12, 31, 12, 0, 0); String date= DateTimeFormatter.ISO_LOCAL_DATE_TIME.format(rightNow); // 2020-12-31T12:00:00 System.out.println(date); DateTimeFormatter formatterOfYYYY = DateTimeFormatter.ofPattern(\u0026#34;YYYY-MM-dd HH:mm:ss\u0026#34;); // 2021-12-31 12:00:00 System.out.println(formatterOfYYYY.format(rightNow)); DateTimeFormatter formatterOfYyyy = DateTimeFormatter.ofPattern(\u0026#34;yyyy-MM-dd HH:mm:ss\u0026#34;); // 2020-12-31 12:00:00 System.out.println(formatterOfYyyy.format(rightNow)); 从下图可以更清晰的看到具体的错误,并且 IDEA 已经智能地提示更倾向于使用 yyyy 而不是 YYYY 。\nLocalDateTime(本地日期时间) # LocalDateTime 同时表示了时间和日期,相当于前两节内容合并到一个对象上了。LocalDateTime 和 LocalTime 还有 LocalDate 一样,都是不可变的。LocalDateTime 提供了一些能访问具体字段的方法。\nLocalDateTime sylvester = LocalDateTime.of(2014, Month.DECEMBER, 31, 23, 59, 59); DayOfWeek dayOfWeek = sylvester.getDayOfWeek(); System.out.println(dayOfWeek); // WEDNESDAY Month month = sylvester.getMonth(); System.out.println(month); // DECEMBER long minuteOfDay = sylvester.getLong(ChronoField.MINUTE_OF_DAY); System.out.println(minuteOfDay); // 1439 只要附加上时区信息,就可以将其转换为一个时间点 Instant 对象,Instant 时间点对象可以很容易的转换为老式的java.util.Date。\nInstant instant = sylvester .atZone(ZoneId.systemDefault()) .toInstant(); Date legacyDate = Date.from(instant); System.out.println(legacyDate); // Wed Dec 31 23:59:59 CET 2014 格式化 LocalDateTime 和格式化时间和日期一样的,除了使用预定义好的格式外,我们也可以自己定义格式:\nDateTimeFormatter formatter = DateTimeFormatter .ofPattern(\u0026#34;MMM dd, yyyy - HH:mm\u0026#34;); LocalDateTime parsed = LocalDateTime.parse(\u0026#34;Nov 03, 2014 - 07:13\u0026#34;, formatter); String string = formatter.format(parsed); System.out.println(string); // Nov 03, 2014 - 07:13 和 java.text.NumberFormat 不一样的是新版的 DateTimeFormatter 是不可变的,所以它是线程安全的。 关于时间日期格式的详细信息在 这里。\nAnnotations(注解) # 在 Java 8 中支持多重注解了,先看个例子来理解一下是什么意思。 首先定义一个包装类 Hints 注解用来放置一组具体的 Hint 注解:\n@Retention(RetentionPolicy.RUNTIME) @interface Hints { Hint[] value(); } @Repeatable(Hints.class) @interface Hint { String value(); } Java 8 允许我们把同一个类型的注解使用多次,只需要给该注解标注一下@Repeatable即可。\n例 1: 使用包装类当容器来存多个注解(老方法)\n@Hints({@Hint(\u0026#34;hint1\u0026#34;), @Hint(\u0026#34;hint2\u0026#34;)}) class Person {} 例 2:使用多重注解(新方法)\n@Hint(\u0026#34;hint1\u0026#34;) @Hint(\u0026#34;hint2\u0026#34;) class Person {} 第二个例子里 java 编译器会隐性的帮你定义好@Hints 注解,了解这一点有助于你用反射来获取这些信息:\nHint hint = Person.class.getAnnotation(Hint.class); System.out.println(hint); // null Hints hints1 = Person.class.getAnnotation(Hints.class); System.out.println(hints1.value().length); // 2 Hint[] hints2 = Person.class.getAnnotationsByType(Hint.class); System.out.println(hints2.length); // 2 即便我们没有在 Person类上定义 @Hints注解,我们还是可以通过 getAnnotation(Hints.class)来获取 @Hints注解,更加方便的方法是使用 getAnnotationsByType 可以直接获取到所有的@Hint注解。 另外 Java 8 的注解还增加到两种新的 target 上了:\n@Target({ElementType.TYPE_PARAMETER, ElementType.TYPE_USE}) @interface MyAnnotation {} Where to go from here? # 关于 Java 8 的新特性就写到这了,肯定还有更多的特性等待发掘。JDK 1.8 里还有很多很有用的东西,比如Arrays.parallelSort, StampedLock和CompletableFuture等等。\n"},{"id":432,"href":"/zh/docs/technology/Interview/system-design/J2EE%E5%9F%BA%E7%A1%80%E7%9F%A5%E8%AF%86/","title":"Index","section":"System Design","content":" Servlet 总结 # 在 Java Web 程序中,Servlet主要负责接收用户请求 HttpServletRequest,在doGet(),doPost()中做相应的处理,并将回应HttpServletResponse反馈给用户。Servlet 可以设置初始化参数,供 Servlet 内部使用。一个 Servlet 类只会有一个实例,在它初始化时调用init()方法,销毁时调用destroy()方法==。==Servlet 需要在 web.xml 中配置(MyEclipse 中创建 Servlet 会自动配置),一个 Servlet 可以设置多个 URL 访问。Servlet 不是线程安全,因此要谨慎使用类变量。\n阐述 Servlet 和 CGI 的区别? # CGI 的不足之处 # 1,需要为每个请求启动一个操作 CGI 程序的系统进程。如果请求频繁,这将会带来很大的开销。\n2,需要为每个请求加载和运行一个 CGI 程序,这将带来很大的开销\n3,需要重复编写处理网络协议的代码以及编码,这些工作都是非常耗时的。\nServlet 的优点 # 1,只需要启动一个操作系统进程以及加载一个 JVM,大大降低了系统的开销\n2,如果多个请求需要做同样处理的时候,这时候只需要加载一个类,这也大大降低了开销\n3,所有动态加载的类可以实现对网络协议以及请求解码的共享,大大降低了工作量。\n4,Servlet 能直接和 Web 服务器交互,而普通的 CGI 程序不能。Servlet 还能在各个程序之间共享数据,使数据库连接池之类的功能很容易实现。\n补充:Sun Microsystems 公司在 1996 年发布 Servlet 技术就是为了和 CGI 进行竞争,Servlet 是一个特殊的 Java 程序,一个基于 Java 的 Web 应用通常包含一个或多个 Servlet 类。Servlet 不能够自行创建并执行,它是在 Servlet 容器中运行的,容器将用户的请求传递给 Servlet 程序,并将 Servlet 的响应回传给用户。通常一个 Servlet 会关联一个或多个 JSP 页面。以前 CGI 经常因为性能开销上的问题被诟病,然而 Fast CGI 早就已经解决了 CGI 效率上的问题,所以面试的时候大可不必信口开河的诟病 CGI,事实上有很多你熟悉的网站都使用了 CGI 技术。\n参考:《javaweb 整合开发王者归来》P7\nServlet 接口中有哪些方法及 Servlet 生命周期探秘 # Servlet 接口定义了 5 个方法,其中前三个方法与 Servlet 生命周期相关:\nvoid init(ServletConfig config) throws ServletException void service(ServletRequest req, ServletResponse resp) throws ServletException, java.io.IOException void destroy() java.lang.String getServletInfo() ServletConfig getServletConfig() 生命周期: Web 容器加载 Servlet 并将其实例化后,Servlet 生命周期开始,容器运行其init()方法进行 Servlet 的初始化;请求到达时调用 Servlet 的service()方法,service()方法会根据需要调用与请求对应的doGet 或 doPost等方法;当服务器关闭或项目被卸载时服务器会将 Servlet 实例销毁,此时会调用 Servlet 的destroy()方法。init 方法和 destroy 方法只会执行一次,service 方法客户端每次请求 Servlet 都会执行。Servlet 中有时会用到一些需要初始化与销毁的资源,因此可以把初始化资源的代码放入 init 方法中,销毁资源的代码放入 destroy 方法中,这样就不需要每次处理客户端的请求都要初始化与销毁资源。\n参考:《javaweb 整合开发王者归来》P81\nGET 和 POST 的区别 # 这个问题在知乎上被讨论的挺火热的,地址: https://www.zhihu.com/question/28586791 。\nGET 和 POST 是 HTTP 协议中两种常用的请求方法,它们在不同的场景和目的下有不同的特点和用法。一般来说,可以从以下几个方面来区分它们:\n语义上的区别:GET 通常用于获取或查询资源,而 POST 通常用于创建或修改资源。GET 请求应该是幂等的,即多次重复执行不会改变资源的状态,而 POST 请求则可能有副作用,即每次执行可能会产生不同的结果或影响资源的状态。 格式上的区别:GET 请求的参数通常放在 URL 中,形成查询字符串(querystring),而 POST 请求的参数通常放在请求体(body)中,可以有多种编码格式,如 application/x-www-form-urlencoded、multipart/form-data、application/json 等。GET 请求的 URL 长度受到浏览器和服务器的限制,而 POST 请求的 body 大小则没有明确的限制。 缓存上的区别:由于 GET 请求是幂等的,它可以被浏览器或其他中间节点(如代理、网关)缓存起来,以提高性能和效率。而 POST 请求则不适合被缓存,因为它可能有副作用,每次执行可能需要实时的响应。 安全性上的区别:GET 请求和 POST 请求都不是绝对安全的,因为 HTTP 协议本身是明文传输的,无论是 URL、header 还是 body 都可能被窃取或篡改。为了保证安全性,必须使用 HTTPS 协议来加密传输数据。不过,在一些场景下,GET 请求相比 POST 请求更容易泄露敏感数据,因为 GET 请求的参数会出现在 URL 中,而 URL 可能会被记录在浏览器历史、服务器日志、代理日志等地方。因此,一般情况下,私密数据传输应该使用 POST + body。 重点搞清了,两者在语义上的区别即可。不过,也有一些项目所有的请求都用 POST,这个并不是固定的,项目组达成共识即可。\n什么情况下调用 doGet()和 doPost() # Form 标签里的 method 的属性为 get 时调用 doGet(),为 post 时调用 doPost()。\n转发(Forward)和重定向(Redirect)的区别 # 转发是服务器行为,重定向是客户端行为。\n转发(Forward) 通过 RequestDispatcher 对象的 forward(HttpServletRequest request,HttpServletResponse response)方法实现的。RequestDispatcher 可以通过 HttpServletRequest 的 getRequestDispatcher()方法获得。例如下面的代码就是跳转到 login_success.jsp 页面。\nrequest.getRequestDispatcher(\u0026#34;login_success.jsp\u0026#34;).forward(request, response); 重定向(Redirect) 是利用服务器返回的状态码来实现的。客户端浏览器请求服务器的时候,服务器会返回一个状态码。服务器通过 HttpServletResponse 的 setStatus(int status) 方法设置状态码。如果服务器返回 301 或者 302,则浏览器会到新的网址重新请求该资源。\n从地址栏显示来说\nforward 是服务器请求资源,服务器直接访问目标地址的 URL,把那个 URL 的响应内容读取过来,然后把这些内容再发给浏览器.浏览器根本不知道服务器发送的内容从哪里来的,所以它的地址栏还是原来的地址. redirect 是服务端根据逻辑,发送一个状态码,告诉浏览器重新去请求那个地址.所以地址栏显示的是新的 URL.\n从数据共享来说\nforward:转发页面和转发到的页面可以共享 request 里面的数据. redirect:不能共享数据.\n从运用地方来说\nforward:一般用于用户登陆的时候,根据角色转发到相应的模块. redirect:一般用于用户注销登陆时返回主页面和跳转到其它的网站等\n从效率来说\nforward:高. redirect:低.\n自动刷新(Refresh) # 自动刷新不仅可以实现一段时间之后自动跳转到另一个页面,还可以实现一段时间之后自动刷新本页面。Servlet 中通过 HttpServletResponse 对象设置 Header 属性实现自动刷新例如:\nResponse.setHeader(\u0026#34;Refresh\u0026#34;,\u0026#34;5;URL=http://localhost:8080/servlet/example.htm\u0026#34;); 其中 5 为时间,单位为秒。URL 指定就是要跳转的页面(如果设置自己的路径,就会实现每过 5 秒自动刷新本页面一次)\nServlet 与线程安全 # Servlet 不是线程安全的,多线程并发的读写会导致数据不同步的问题。 解决的办法是尽量不要定义 name 属性,而是要把 name 变量分别定义在 doGet()和 doPost()方法内。虽然使用 synchronized(name){}语句块可以解决问题,但是会造成线程的等待,不是很科学的办法。 注意:多线程的并发的读写 Servlet 类属性会导致数据不同步。但是如果只是并发地读取属性而不写入,则不存在数据不同步的问题。因此 Servlet 里的只读属性最好定义为 final 类型的。\n参考:《javaweb 整合开发王者归来》P92\nJSP 和 Servlet 是什么关系 # 其实这个问题在上面已经阐述过了,Servlet 是一个特殊的 Java 程序,它运行于服务器的 JVM 中,能够依靠服务器的支持向浏览器提供显示内容。JSP 本质上是 Servlet 的一种简易形式,JSP 会被服务器处理成一个类似于 Servlet 的 Java 程序,可以简化页面内容的生成。Servlet 和 JSP 最主要的不同点在于,Servlet 的应用逻辑是在 Java 文件中,并且完全从表示层中的 HTML 分离开来。而 JSP 的情况是 Java 和 HTML 可以组合成一个扩展名为.jsp 的文件。有人说,Servlet 就是在 Java 中写 HTML,而 JSP 就是在 HTML 中写 Java 代码,当然这个说法是很片面且不够准确的。JSP 侧重于视图,Servlet 更侧重于控制逻辑,在 MVC 架构模式中,JSP 适合充当视图(view)而 Servlet 适合充当控制器(controller)。\nJSP 工作原理 # JSP 是一种 Servlet,但是与 HttpServlet 的工作方式不太一样。HttpServlet 是先由源代码编译为 class 文件后部署到服务器下,为先编译后部署。而 JSP 则是先部署后编译。JSP 会在客户端第一次请求 JSP 文件时被编译为 HttpJspPage 类(接口 Servlet 的一个子类)。该类会被服务器临时存放在服务器工作目录里面。下面通过实例给大家介绍。 工程 JspLoginDemo 下有一个名为 login.jsp 的 Jsp 文件,把工程第一次部署到服务器上后访问这个 Jsp 文件,我们发现这个目录下多了下图这两个东东。 .class 文件便是 JSP 对应的 Servlet。编译完毕后再运行 class 文件来响应客户端请求。以后客户端访问 login.jsp 的时候,Tomcat 将不再重新编译 JSP 文件,而是直接调用 class 文件来响应客户端请求。\n由于 JSP 只会在客户端第一次请求的时候被编译 ,因此第一次请求 JSP 时会感觉比较慢,之后就会感觉快很多。如果把服务器保存的 class 文件删除,服务器也会重新编译 JSP。\n开发 Web 程序时经常需要修改 JSP。Tomcat 能够自动检测到 JSP 程序的改动。如果检测到 JSP 源代码发生了改动。Tomcat 会在下次客户端请求 JSP 时重新编译 JSP,而不需要重启 Tomcat。这种自动检测功能是默认开启的,检测改动会消耗少量的时间,在部署 Web 应用的时候可以在 web.xml 中将它关掉。\n参考:《javaweb 整合开发王者归来》P97\nJSP 有哪些内置对象、作用分别是什么 # JSP 内置对象 - CSDN 博客\nJSP 有 9 个内置对象:\nrequest:封装客户端的请求,其中包含来自 GET 或 POST 请求的参数; response:封装服务器对客户端的响应; pageContext:通过该对象可以获取其他对象; session:封装用户会话的对象; application:封装服务器运行环境的对象; out:输出服务器响应的输出流对象; config:Web 应用的配置对象; page:JSP 页面本身(相当于 Java 程序中的 this); exception:封装页面抛出异常的对象。 Request 对象的主要方法有哪些 # setAttribute(String name,Object):设置名字为 name 的 request 的参数值 getAttribute(String name):返回由 name 指定的属性值 getAttributeNames():返回 request 对象所有属性的名字集合,结果是一个枚举的实例 getCookies():返回客户端的所有 Cookie 对象,结果是一个 Cookie 数组 getCharacterEncoding():返回请求中的字符编码方式 = getContentLength()`:返回请求的 Body 的长度 getHeader(String name):获得 HTTP 协议定义的文件头信息 getHeaders(String name):返回指定名字的 request Header 的所有值,结果是一个枚举的实例 getHeaderNames():返回所以 request Header 的名字,结果是一个枚举的实例 getInputStream():返回请求的输入流,用于获得请求中的数据 getMethod():获得客户端向服务器端传送数据的方法 getParameter(String name):获得客户端传送给服务器端的有 name 指定的参数值 getParameterNames():获得客户端传送给服务器端的所有参数的名字,结果是一个枚举的实例 getParameterValues(String name):获得有 name 指定的参数的所有值 getProtocol():获取客户端向服务器端传送数据所依据的协议名称 getQueryString():获得查询字符串 getRequestURI():获取发出请求字符串的客户端地址 getRemoteAddr():获取客户端的 IP 地址 getRemoteHost():获取客户端的名字 getSession([Boolean create]):返回和请求相关 Session getServerName():获取服务器的名字 getServletPath():获取客户端所请求的脚本文件的路径 getServerPort():获取服务器的端口号 removeAttribute(String name):删除请求中的一个属性 request.getAttribute()和 request.getParameter()有何区别 # 从获取方向来看:\ngetParameter()是获取 POST/GET 传递的参数值;\ngetAttribute()是获取对象容器中的数据值;\n从用途来看:\ngetParameter()用于客户端重定向时,即点击了链接或提交按扭时传值用,即用于在用表单或 url 重定向传值时接收数据用。\ngetAttribute() 用于服务器端重定向时,即在 sevlet 中使用了 forward 函数,或 struts 中使用了 mapping.findForward。 getAttribute 只能收到程序用 setAttribute 传过来的值。\n另外,可以用 setAttribute(),getAttribute() 发送接收对象.而 getParameter() 显然只能传字符串。 setAttribute() 是应用服务器把这个对象放在该页面所对应的一块内存中去,当你的页面服务器重定向到另一个页面时,应用服务器会把这块内存拷贝另一个页面所对应的内存中。这样getAttribute()就能取得你所设下的值,当然这种方法可以传对象。session 也一样,只是对象在内存中的生命周期不一样而已。getParameter()只是应用服务器在分析你送上来的 request 页面的文本时,取得你设在表单或 url 重定向时的值。\n总结:\ngetParameter()返回的是 String,用于读取提交的表单中的值;(获取之后会根据实际需要转换为自己需要的相应类型,比如整型,日期类型啊等等)\ngetAttribute()返回的是 Object,需进行转换,可用setAttribute()设置成任意对象,使用很灵活,可随时用\ninclude 指令 include 的行为的区别 # include 指令: JSP 可以通过 include 指令来包含其他文件。被包含的文件可以是 JSP 文件、HTML 文件或文本文件。包含的文件就好像是该 JSP 文件的一部分,会被同时编译执行。 语法格式如下: \u0026lt;%@ include file=\u0026ldquo;文件相对 url 地址\u0026rdquo; %\u0026gt;\ninclude 动作: \u0026lt;jsp:include\u0026gt;动作元素用来包含静态和动态的文件。该动作把指定文件插入正在生成的页面。语法格式如下: \u0026lt;jsp:include page=\u0026ldquo;相对 URL 地址\u0026rdquo; flush=\u0026ldquo;true\u0026rdquo; /\u0026gt;\nJSP 九大内置对象,七大动作,三大指令 # JSP 九大内置对象,七大动作,三大指令总结\n讲解 JSP 中的四种作用域 # JSP 中的四种作用域包括 page、request、session 和 application,具体来说:\npage代表与一个页面相关的对象和属性。 request代表与 Web 客户机发出的一个请求相关的对象和属性。一个请求可能跨越多个页面,涉及多个 Web 组件;需要在页面显示的临时数据可以置于此作用域。 session代表与某个用户与服务器建立的一次会话相关的对象和属性。跟某个用户相关的数据应该放在用户自己的 session 中。 application代表与整个 Web 应用程序相关的对象和属性,它实质上是跨越整个 Web 应用程序,包括多个页面、请求和会话的一个全局作用域。 如何实现 JSP 或 Servlet 的单线程模式 # 对于 JSP 页面,可以通过 page 指令进行设置。 \u0026lt;%@page isThreadSafe=\u0026quot;false\u0026quot;%\u0026gt;\n对于 Servlet,可以让自定义的 Servlet 实现 SingleThreadModel 标识接口。\n说明:如果将 JSP 或 Servlet 设置成单线程工作模式,会导致每个请求创建一个 Servlet 实例,这种实践将导致严重的性能问题(服务器的内存压力很大,还会导致频繁的垃圾回收),所以通常情况下并不会这么做。\n实现会话跟踪的技术有哪些 # 使用 Cookie\n向客户端发送 Cookie\nCookie c =new Cookie(\u0026#34;name\u0026#34;,\u0026#34;value\u0026#34;); //创建Cookie c.setMaxAge(60*60*24); //设置最大时效,此处设置的最大时效为一天 response.addCookie(c); //把Cookie放入到HTTP响应中 从客户端读取 Cookie\nString name =\u0026#34;name\u0026#34;; Cookie[]cookies =request.getCookies(); if(cookies !=null){ for(int i= 0;i\u0026lt;cookies.length;i++){ Cookie cookie =cookies[i]; if(name.equals(cookis.getName())) //something is here. //you can get the value cookie.getValue(); } } 优点: 数据可以持久保存,不需要服务器资源,简单,基于文本的 Key-Value\n缺点: 大小受到限制,用户可以禁用 Cookie 功能,由于保存在本地,有一定的安全风险。\nURL 重写\n在 URL 中添加用户会话的信息作为请求的参数,或者将唯一的会话 ID 添加到 URL 结尾以标识一个会话。\n优点: 在 Cookie 被禁用的时候依然可以使用\n缺点: 必须对网站的 URL 进行编码,所有页面必须动态生成,不能用预先记录下来的 URL 进行访问。\n隐藏的表单域\n\u0026lt;input type=\u0026#34;hidden\u0026#34; name=\u0026#34;session\u0026#34; value=\u0026#34;...\u0026#34; /\u0026gt; 优点: Cookie 被禁时可以使用\n缺点: 所有页面必须是表单提交之后的结果。\nHttpSession\n在所有会话跟踪技术中,HttpSession 对象是最强大也是功能最多的。当一个用户第一次访问某个网站时会自动创建 HttpSession,每个用户可以访问他自己的 HttpSession。可以通过 HttpServletRequest 对象的 getSession 方 法获得 HttpSession,通过 HttpSession 的 setAttribute 方法可以将一个值放在 HttpSession 中,通过调用 HttpSession 对象的 getAttribute 方法,同时传入属性名就可以获取保存在 HttpSession 中的对象。与上面三种方式不同的 是,HttpSession 放在服务器的内存中,因此不要将过大的对象放在里面,即使目前的 Servlet 容器可以在内存将满时将 HttpSession 中的对象移到其他存储设备中,但是这样势必影响性能。添加到 HttpSession 中的值可以是任意 Java 对象,这个对象最好实现了 Serializable 接口,这样 Servlet 容器在必要的时候可以将其序列化到文件中,否则在序列化时就会出现异常。\nCookie 和 Session 的区别 # Cookie 和 Session 都是用来跟踪浏览器用户身份的会话方式,但是两者的应用场景不太一样。\nCookie 一般用来保存用户信息 比如 ① 我们在 Cookie 中保存已经登录过得用户信息,下次访问网站的时候页面可以自动帮你登录的一些基本信息给填了;② 一般的网站都会有保持登录也就是说下次你再访问网站的时候就不需要重新登录了,这是因为用户登录的时候我们可以存放了一个 Token 在 Cookie 中,下次登录的时候只需要根据 Token 值来查找用户即可(为了安全考虑,重新登录一般要将 Token 重写);③ 登录一次网站后访问网站其他页面不需要重新登录。Session 的主要作用就是通过服务端记录用户的状态。 典型的场景是购物车,当你要添加商品到购物车的时候,系统不知道是哪个用户操作的,因为 HTTP 协议是无状态的。服务端给特定的用户创建特定的 Session 之后就可以标识这个用户并且跟踪这个用户了。\nCookie 数据保存在客户端(浏览器端),Session 数据保存在服务器端。\nCookie 存储在客户端中,而 Session 存储在服务器上,相对来说 Session 安全性更高。如果使用 Cookie 的一些敏感信息不要写入 Cookie 中,最好能将 Cookie 信息加密然后使用到的时候再去服务器端解密。\n"},{"id":433,"href":"/zh/docs/technology/System/%E6%B7%B1%E5%85%A5%E7%90%86%E8%A7%A3%E8%AE%A1%E7%AE%97%E6%9C%BA%E7%B3%BB%E7%BB%9F/%E4%B9%A6%E7%B1%8D/%E5%B0%81%E9%9D%A2-%E7%9B%AE%E5%BD%95/","title":"Index","section":"SpringCloud","content":"\n`.\n兰镶尔 E. 布莱恩特\n(Randal E. Bryant)\n1981 年千麻省理工学院获得计算机博士学位 ,\n1984 年至今一直任教 千卡内基-梅隆大学。现任卡内基-梅隆大学计算机科学学院院长、教授,同时还受邀任教 千电子和计算机工程 系。他从事本科生和研究生计算机 系统方面课程的教学近 40 年。他和\nO\u0026rsquo;Hallaron 教授一起在卡内基-梅隆大学开设了15-\n213课程 ”计算机系统导论” , 该课程即为本书的基础。他还是ACM院士、IEEE院士、美国国家工程院院士和美国 人文与科学研究院院 士。其研究成果被\nIntel 、IBM、Fujitsu 和Microsoft 等主要计算机制造商使用, 他还因研究获得过Semiconductor Research Corporation、ACM、IEEE颁发的多项大奖。\n大卫 R. 奥哈拉伦\n(David R. O\u0026rsquo;Hallaron)\n卡内基-梅隆大学电子和计算 机工程系教授 。在弗吉尼亚大学获得计算机科学的博士学位 , 2007 年一2010 年为Intel 匹兹堡实验室主任。他教授本科生和研究生的计算机 系统方面的课程已有 20余年, 井和Bryant 教授一起开设了 ”计算机系统导论 ” 课程。曾获得CMU计算机学院颁发的Herbert Simon杰出教学奖。他主要从事计算机系统领域的研究 , 与\nQuake项目成员一起获得过高性能计算领域中的最高 国际奖项—-G ordon Bell奖。他目前的工作重点是研究自动分级 ( autograding ) 概念, 即评价其他程序质量的程序。\n\u0026ldquo;山匾 ••••••• ·-\n深人理解计算机系统\n兰德尔 E. 布莱恩特 ( Randal E. Bryant)\n[美] 卡内基-梅隆大学 著\n大卫 R. 奥哈拉伦 ( David R. O\u0026rsquo;Hallaron)\n卡内基-梅隆大学\n龚奕利贺莲译 # Computer Systems\nA Program1ner\u0026rsquo;s Perspective Third Edition\n@机械工业出版社 # China Machine P「ess\n图 书在版编目 ( CIP ) 数 据 # 深入理 解计算 机系统(原书第 3 版)/(美)兰德尔. E. 布莱恩特 ( Ra n d a l E. Br y a n t ) 等著; 龚奕 利 ,贺 莲 译 .— 北京 :机械工业出 版社 , 2 0 1 6 .7\n(计算机科学丛书)\n书名原文: Computer Systems: A Programmer\u0026rsquo;s Perspective, Third Edition ISBN 978-7-111-54493-7\n深… IL CD兰\u0026hellip; (2)龚.. ® 贺… III. 计笢机系统 IV. TP338\n中国版本图书馆CIP数据核字 ( 20 1 6 ) 第 1 8 23 6 7 号\n本书版权登记号:图字: 01-2015 -2044\nAuthorized translation from the English language edition, entitled Computer Systems: A Programmer\u0026rsquo;s Perspective, 3E, 9780134092669 by Randal E. Bryant, David R. O\u0026rsquo;Hallaron, published by Pearson Education, Inc., Copyright©2016, 2011, and 2003\nAll rights reserved. No part of this book may be reproduced or transmit ted in any form or by any means, electronic or mechanical, including photocopying, recording or by any information storage retrieval system, without permission from Pearson Education, Inc.\nChinese simplified language edition published by Pearson Educ a t io n Asia Ltd., and China Machine Press Copyr ight © 2016.\n本书中文 简体字版由 Pe arson Education (培生教育出版集团)授权机械工业出版社在中华入民共和国境内(不包括中国台湾地区和中国香港、澳门特别行政区)独家出版发行。未经出版者书面许可, 不得以任何方式抄袭、复制或节录本书中的任何部分。\n本书封底贴有 Pe ars on Education (培生教育出版集团)激光防伪标签,无标签者不得销售。\n本书从程序员的视角详细阐述计算机系统的 本质概念,并 展 示这些概 念如何 实实 在在 地影响应用程序的正确性、性能和实用性。全书共 12 章,主要包括信息的 表示和处 理 、程 序的 机器级表示、处理 器体 系结 构 、优化 程序性能 、存储器层次结构 、链接 、异常控制流 、虚拟存储 器 、系统 级 1/0 、网 络编程、并发编程等内容。书中提供了大量的例子和练习题,并给出部分答案,有助于读者加深对正文所述\n概念和知识的理解。\n本书适合作为高等院校计算机及相关专业本科生、研究生的教材,也可供想要写出更快、更可靠程序的程序员及专业技术入员参考。\n出版发行 :机械工 业出版社 (北京市西城区百万庄大街22 号 邮政编码: 100037)\n责任编辑:和静\n印 刷:中国电影出版社印刷厂\n开本: 185mm x 260mm 1/16\n书号: ISBN 978-7-111-54493-7\n责任校对:殷 虹\n版 次 : 2016 年 11 月 第 1 版 第 1 次 印 刷\n印张: 48.25\n定价: 139.00 元\n凡购本书,如有缺页、倒页 、脱页, 由本社发行部调换\n客服热线: (010) 88378991 88361066 投稿热线: ( 010 ) 88 379604\n购书热线: (010) 68326294 88379649 68995259 读者信箱: hzjs j@ hzbook.com\n版权所有·侵权必究\n封底无防伪标均为盗版\n本书志律顾问: 北京大成律师 事务所 韩光/邹晓东\n文 : 复兴以来,源 远流长的科学精神和逐 步形成的学术规范, 使西国家在自然科学的各个领域取得了垄断性的优势;也正是这样\n的优势, 使美国 在信息技术发展的六十多年间名家辈出、独领风骚。在商业化的进程中,美国的产业界与教育界越来越紧密地结合,计算机学 科中的许多泰山北斗同时身处科研和教学的最前线,由此而产生的经典 科学著作,不仅擘划了研究的范畴,还揭示了学术的源变,既遵循学术 规范, 又自有 学者个性, 其价值并不会因年月的 流逝而减退。\n近年,在全球信息化大潮的推动下,我国的计算机产业发展迅猛, 对专业人才的需求日益迫切。这对计算机教育界和出版界都既是机遇, 也是挑战;而专业教材的建设在教育战略上显得举足轻重。在我匡信息 技术发展时间较短的现状下,美国等发达国家在其计算机科学发展的几 十年间 积淀和发展的经典教材仍有许多值得借鉴之处。因此,引进一批国外优秀计算机教材将对我国计算机教育事业的发展起到积极的推动作 用,也 是 与世界接轨、建设真正的世界一流大学的必由之路。\n机械工业出版社华章公司较早意识到“出版要为教育服务”。 自 1998 年开始 ,我 们就将工作重点放在了遴选、移译国外优秀教 材上。经过多年的 不 懈 努 力, 我 们 与 P earson , McGraw-Hill, Elsevier, MIT, John Wiley \u0026amp; Sons, Cengage等世界著名出版公司建立了良好的合作关系, 从他们现 有的数百 种教 材中甄选出 Andrew S. T anenb aum , Bjarne Strous­ trup, Brian W. Kernighan, Dennis Ritchie, Jim Gray, Afred V. Aho , John E\n. Hopcroft, Jeffrey D. Ullman, Abraham Silberschatz, William Stallings, Donald E. Knuth, John L. Henness y , Larry L. Peterson 等大师名家的一批经典作品,以“计算机科学丛书”为总称出版,供读者学习、研究及 珍藏。大理石纹理的封面,也 正体现了这套丛书的品位和格调 。\n”计 算机科学丛书” 的 出版工作得到了国内外学者的鼎力相助, 国内的专家不仅提供了中 肯的选题指导, 还不辞劳苦地担 任了 翻译 和审校的工作; 而原书的作者也相当关注其作品在中国的传播,有的还专门为其书的中译本 作序。迄今,”计算机科学丛书”已经出版了近两百个品种,这些书籍在读 者中树立了良好的口碑 ,并被许多 高校采用为正式教材和参考书籍。其影印版“经典原版书库”作 为姊妹篇也被越来越多实施 双语教学的学校所采用。\nIV # 权威的作者、经典的教材、一流的译者、严格的审校、精细的编辑,这些因素使我们的图 书有了质量的保证。随着计算机科学与技 术专业学科建设的不断完善和教材改革的逐渐深化, 教育界对国外计算机教材的需求和应用都将步入一个新的阶段,我们的目标是尽善尽美,而反 馈的意见正是我们达到这一终极目标的重要帮助。华章公司欢迎老师和读者对我们的工作提出\n建议或给予指正, 我们的联系方法如下:\n华章网站: www. hzbook. com 电子邮件: hzjsj@ hzbook. com 联系电话: (010)88379604\n联系地 址: 北京 市西城 区百 万庄 南街 1 号\n邮政编码: 100037\n==吾\nITrf:1'1ffl # 华章教育\n华章科技图书出版中心\n4 匕 章 公 司 温 莉 芳 女 士 邀 我 为 即 将 出 版 的《Computer Systems: A\n- \u0026ndash; rramgmer \u0026rsquo;s Pers pective》第3 版的中文译本《深入理解计算机系\n统》写个序, 出于两方面的考虑,欣 然允 之。\n一是源于我个人的背景和兴趣。我长期从事软件工程和系统软件领域的研究,对计算机学科的认识可概括为两大方面:计算系统的构建和基于计算系统的计算技术应用。出于信息时代国家掌握关键核心技术的重大需求以及我个人专业的本位视角,我一直对系统级技术的研发给予更多关注,由于这种“偏爱”和研究习惯的养成,以至于自己在面对非本\n专业领域问题时,也常常喜欢从“系统观”来看待问题和解决问题。我自 己也 和《深入理解计 算机系统》有过“亲密接触\u0026rdquo;。2012 年, 我还在北京大学信息科学技术学院院长任上,学院从更好地培养适应新技术、发展具 有系统设计和系统应用能力的计算机专门人才出发,在调查若干国外高 校计算机学科本科生教学体系基础上,决定加强计算机系统能力培养, 在本科生二年级增设了一门系统级课程,即“计算机系统导论”。其时, 学校正 在倡导小班课教学模式,这 门 课 也 被选为学院的第一个小班课教学试点。为了体现学院的重视, 我亲自担任了这门课的主持人, 带领一个 18 人组成的"豪华“教学团队负责该课程的教学工作, 将学生分成 14 个小班, 每个小班不超过 15 人。同时,该 课 程涉及教师集体备课组合授课、大班授课基础上的小班课教学和讨论、定期教学会议、学生自主习 题课和实验课等新教学模式的探索,其中一项非常重要的举措就是选用 了卡内 基-梅隆大学 Randal E. Brya nt 教授和 David R. O \u0026rsquo; H allaron 教授编写的《Computer Systems: A Programmer\u0026rsquo;s Pers pective》(第 2 版)作为教材。虽然这门课程我只主持了一次, 但对这本教材的印象颇深颇佳。\n发中中 # 展国 # 中科文\n家国院学版科院\n言士序_\n士院\n、一\n梅\n本\u0026hellip;\u0026hellip;.\n二是源于我 和华章公司已有的良好合作和相互了解。2000 年前 后 , 我先后翻译了华章公司引进(机械工业出版社出版)的Roger P ress man 编写的《Sof tware Engineering: A P ra ctitio ner \u0026rsquo; s App roach》一书的第 4 版和第\n5 版。其后, 在计算机学会软件工程专业委员 会和系统 软件专业委员 会的诸多学术活动中也和华章公司及温莉芳女士本人有不少合作。近二十年来,华章公司的编辑们引进出版了大量计算机学科的优秀教材和学术著作,对国内高校计算机学科的教学改革起到了积极的促进作用,本书的\nVI\n翻译出版仍是这项工作的延续。这是一项值得褒扬的工作,我也想借此机会代表计算机界同仁 表达对华章公司的感谢!\n计算机系统类别的课程一直是计算机科学与技术专业的主要教学内容之一。由于历史原 因, 我国的计算机专业的课程体系曾广泛参考 ACM 和 IEEE 制订的计算机科学与技术专业教学计划( Computing C urricula ) 设计,计 算机系统类课程也参照该计划分为汇编语言、操作系统、组成原理、体系结构、计算机网络等多门课程。应该说,该课程体系在历史上对我国的计算机 专业教育起了很好的引导作用。\n进入新世纪以来,计算技术发生了重要的发展和变化,我国的信息技术和产业也得到了迅 猛发展, 对计算 机专业的毕业生提出了更高要求。重新审视原来我们参照 ACM/ IEEE 计算机专业计划的课程体系,会发现存在以下几个方面的主要问题。\n) 课程体系中缺乏 一门独立的能够贯穿整个计算机系统 的基础课程。计算机系统 方面的基础知识被分 成了很多门独立的课程, 课程内容彼此之间缺乏关联和系统性。学生学习之后; 虽然在计算机系统的各个部分理解了很多概念和方法,但往往会忽视各个部分之间的关联,难 以系统性地理解整个计算机系统的工作原理和方法。\n) 现有课程往往偏重理论, 和实践关联较少。如现有的系统课程中通常会介绍函数调用过程中的压栈和退栈方式,但较少和实践关联来理解压栈和退栈过程的主要作用。实际上,压 栈和退栈与理解 C 等高级语言的丁作原理息息相关,也 是常用的攻击手段 Buffer Overflow 的主要技术基础。\n教学内容比较传统 和陈旧, 基本上是早期 PC 时代的内容。比如, 现在的主流台式机CPU 都巳经是 x86-64 指令集, 但较多课程还在教授 80386 甚至更早的指令集。 对于近年 来出现的多核/众核处理器、SSD 硬盘等实际应用中遇到的内容更是涉及较少。 4 ) 课程大多数从设计者的 角度出发, 而不是从使用者的角度出发。对于大多数学生来说, 毕 业之后并不会成为专业的 CP U 设计人员、操作系统 开发人员等, 而是会成为软件开发工程师。对他们而言,最重要的是理解主流计算机系统的整体设计以及这些设计因素对于应用软件 开发和运行 的影响。\n这本教材很好地克服了上述传统课程的不足,这也是当初北大计算机学科本科生教学改革 时选择该教材的主要考量。其一,该 教材系统地介绍了整个计算 机系统的工作原理, 可帮助学生系统性地理解计算机如何执行程序、存储信息和通信;其 二 ,该 教材非常强调实践, 全书包括 9 个配套 的实验, 在这些实验中,学 生需要攻破计算机系统、设计 CPU、实现命令行解 释器、根据缓存优化程序等 , 在新 鲜有趣的实验中理解系统原理, 培养动手能力; 其三,该 教材紧跟时代的发展, 加入了 x86- 64 指令集、Intel Core i7 的虚拟地址结构、SSD 磁盘、IPv6 等新技术内 容:其 四 .该 教材从程序员 的角度看待计算机系统, 重点讨论系统的不同结构对于上层\nVII\n应用软件编写、执行和数据存储的影响,以培养程序员在更广阔空间应用计算机系统知识的 能力。\n基千该教材的北大”计 算机系统导论”课程实施已有五年, 得到了学生的广泛赞誉, 学生们通过这门课程的学习建立了完整的计算机系统的知识体系和整体知识框架,养成了良好的编程 习惯并获得了编写高性能、可移植和健壮的程序的能力,奠定了后续学习操作系统、编译、计 算机体系结构等专业课程的 基础。北大的教学实践表明 , 这是一本值得推荐采用的好教材。\n该书的第 3 版相对千第 2 版进行了较大程度的修改和扩充。第 3 版从一开始就采用最新x86-64 架构来贯穿各部分知识, 在内存技术、网络技术上也有一系列 更新,并 且重组了之前的一些比较难懂的内容。我相信,该 书的出版, 将有助千国 内计算机系统教学的进一步改进, 为\n培养从事系统级创新的计算机人才奠定很好的基础。\n琴 归\n2016 年 10 月 8 日\n2 00 2 年 8 月本书第 1 版首 次印刷。一个月之后, 我在复旦大学软件学院开设 了”计 算机系统 基础”课 程, 成为国 内 第一个采用这本教材授\n课的老师。这本教材有四个特点。第一, 涉及面广, 覆盖了二进制、汇编、组成、体系结构、操作系统、网络与并发程序设计等计算机系统最 重要的方面。第二, 具有相当的深度, 本书从程序出发逐步深入到系统领域的重要问题,而非点到为止,学完本书后读者可以很好地理解计算 机系统的工作原理。第三,它 是 面向 低年级学生的教材, 在过去的教学体系中这本书所涉及的很多内容只能在高年级讲授,而本书通过合理的 安排将计算机系统领域最核心的内容巧妙地展现给学生(例如,不需要掌 握逻辑设计与硬件描述语言的完整知识,就可以体验处理器设计)。第 四, 本书配备了非常实用、有趣的实验。例如, 模仿硬件仅用位操作完成复杂的运算 , 模仿 t racker 和 hacker 去破解密码以及攻击自身的程序, 设 计 处理器, 实现简单但功能强大的 Shel l 和 P ro xy 等。这些实验既强化了学 生对书本知识的理解,也 进一步激发了学生探究计算机系统的热情。\n以低年级开设“深入理解计算机系统”课程为基础,我先后在复旦大 学和上海交 通大学软件学院主导了激进的教学改革。必修课时被大量压缩,现在软件工程专业必修课由问题求解、计算机系统基础、应用开发 基础、软件工程四个模块 9 门 课 构成。其他传统的必修课如操作系统、编译原理、数字逻辑等都成为方向课。课程体系的变化,减少了学生修 读课程的总数和总课时,因而为大幅度增加实验总最、提高实验难度和 强度、增强实验的综合性和创新性提供了有力保障。现在我的课题组的 青年教师全部是首批经历此项教学改革的学生。本科的扎实基础为他们 从事系统软件研究打下了良好基础,他们实现了亚洲学术界在操作系统 旗舰会议 SOS P 上论文发表零的突破,目 前 研 究 成果在国际上具有较大 的影响力。师资力批的补充, 又为全面推进更加激进的教学改革创造了条件。\n本书的出版标志着国际上计算机教学进入了第三阶段。从历史来看, 国 际 上计算机教学先后经历了三个主要阶段。第一阶段是上世纪 70 年代中期至 80 年代中期.那时理论、技术还不成熟, 系统不稳定,因 此教材主要阶绕若干重要问题讲授不同流派的观点,学生解决实际问题的能力\nIX # 不强。第二阶段是上世纪 80 年代中期至本世纪初, 当时计算机单机系统的理论 和技术已逐步趋于成熟,主流系统稳定,因此教材主要围绕主流系统讲解理论和技术,学生的理论基础扎 实, 动手能力强。第三阶段从 本世纪初开始, 主要 背景是随着互 联网的兴起,信 息 技术开始渗透到人类工作和生活的方方面面。技术爆炸迫使教学者必须重构传统的以计算机单机系统为主 导的课程体系。新 的体系大面积调 整了 核心课程的内容。核心课程承担了帮助学生构建专业知识框架的任务, 为学生在毕业后相当长时间内的专业发展奠定坚实基础。现在一般认为问 题抽象、系统抽象和数据抽象是计算机类专业毕业生的核心能力。而本书担负起了系统 抽象的重任, 因此美国的很多高校都采用了该书作为计算机系统核心课程的教材。第三阶段的教材与第二阶段的教材是互补关系。 第三阶段的教材主要强调坚实而宽 广的基础, 第二阶段的教材主要强调深入系统的专门知识,因此依然在本科高年级方向课和研究生专业课中占据重要地位。\n上世纪 80 年代初, 我国借鉴美国经验建立了自己的计算机教学体系并引进了大最教材。从 21 世纪初开 始, 一些学校开始借鉴美国第二阶段的教学方法, 采用了部分第二阶段的著名教材, 这些改革正在走向成熟并得以推广。2012 年北京大学计算机专业采用本书作为教材后, 采用本教材开设“计算机系统 基础”课程的高校快速增 加。以此为契机, 国内的计算机教学也有望全面进入第三阶段。\n本书的第 3 版完全按照 x86- 64 系统进行改写。此外, 第 2 版中删除了以 x87 呈现的浮点指令, 在第 3 版中浮点指令又以标量 AVX2 的形式得以恢复。第 3 版更加强调并发, 增加了较大篇幅用于讨论信号处理程序与主程序间并发时的正确性保障。总体而言, 本书的三个版本在结构上没有太大变化,不同版本的出现主要是为了在细节上能够更好地反映技术的最新变化。\n当然本书的某些部分对于初学者而言还是有些难以阅读。本书涉及大蜇重要概念, 但一些概念首 次亮相时并没有编排好顺序。例如寄存器的概念、汇编指令的顺序执行模式、PC 的概念等对 千初学 者而言非常陌生, 但这些 介绍仅仅出现在第 1 章的总览中, 而当第 3 章介绍汇编时完全没有进一步的展开就 假设读者已经非常清楚这些概念。事实上这些概念原本就介绍得过 千简单,短暂亮相之后又立即退场,相隔较长时间后,当这些概念再次登场时,初学者早已忘 却了它们是什么。同样,第 8 章对进程、并发等概念的介绍也存在类似问 题。因此, 中文翻译版将 配备导读部分, 希望这些导读能够帮助初学者顺利阅读。\n2016 年 10 月 15 日\n书第 1 版出版于 2003 年, 第 2 版出版于 2011 年,去 年发行的巳经是原书第 3 版了。第 3 版还是采用以下组合方式: 在经典的\nx86 架构机器上运行 Linux 操作系统 ,采用 C 语言编程。这样的 组合经受住了时间的考验。这一版的一个明显变化就是从讲解 I A32 和 x86-64 转变为完全以 x86-64 为基础, 相应地修改了第 3、4 、5 、6 和 7 章。同时, 还改写了第 2 章 , 使之更易读、好懂;用 近期的 新技术更新了第 6 、11 和12 章。这些变化 使得本书既 和新技术保持了同步, 又保留了描述系统本质的内容以及从程序员角度出发的 特色。\n除了翻译本书,我们也开始以本书为教材讲授”计算机系统基础”课 程, 对这本书的理解也随之越来越深入, 意 识 到除了阅读之外, 动手实践更是学习计算机系统的 必经之路。本书的官 网提供了很多实 验作业(Lab Assignment), 其中不乏有趣且有一定难度的 实验, 比如 Bomb Lab 。有兴趣的读者除了阅读本书的内容之外,还应该试着去完成这些实验, 让纸面上的内容在实际动手中得到巩固和加强。本书的官方博客也不断 更新着有关这本书和配套课程的最新变化, 这也是对本书的有益补充 。\n第 3 版从翻译 的角度来说, 我 们尽量做到更流畅, 更符合中文表达的习惯。对于一些术语, 比如 memo ry , 以前怕出错就统一翻译 成存储器, 现在则尽可能 地按照语境去区分, 翻译 成内存或者存储器。\n在此 , 要感谢本书的编辑朱劼、姚蕾以及和静, 有她们的支持、鼓励和耐心 细致的工作,才能 让本书如期与读者见面。\n由于本书内 容多, 翻译 时间紧迫,尽 管 我们尽量做到认真仔细, 但还是难以避免出现错误和不尽如人意的地方。在此欢迎广大读者批评指 正。我们也会一如既往地维护勘误表, 及时在网上更新,方 便 大家阅读。\n(另外, 本版第 1 次印刷时, 我们已经根据官网 2016 年 3 月 1 日前 发布的勘误进行了修正, 就不在中文勘误中再翻译 了。)\n龚奕利 贺莲\n201 6 年 5 月 于硌 珈 山\n本 书(简称C,S APP) 的主要读者是计算机科学 家、计算机工程 师,以 及那些想通过学习计算机系统的内在运作而能够写出更好程序的人。\n我们的目的是解释所有计算机系统的本质概念,并向你展示这些概念是如何实实在在地影响应用程序的正确性、性能和实用性的。其他的系统类书籍都是从构建者的角度来写的,讲述如何实现硬件或系统软件, 包括操作系统 、编译器和网络接口。而本书是从程序 员 的 角 度来写的, 讲述应用程序员如何能够利用系统知识来编写出更好的程序。当然,学习一个计算机系统应该做些什么,是学习如何构建一个计算机系统的很好的出发点,所以,对于希望继续学习系统软硬件实现的人来说,本书也是一本很有价值的介绍性读物。大多数系统书籍还倾向于重点关注系统的某一个方面,比如:硬件架构、操作系统、编译器或者网络。本书则以程序员的视角统一覆盖了上述所有方面的内 容。\n如果你研究和领会了这本书里的概念,你将开始成为极少数的“牛 人",这些“牛人“知道事情是如何运作的,也知道当事情出现故障时如 何修复 。你写的程序将能够更好地利用操作系统 和系统 软件提供的功能, 对各种操作条件和运行时参数都能正确操作,运行起来更快,并能避免出 现使程序容易受到网络攻击的缺陷。同时,你也要做好更深入探究的准备, 研究像编译器、计算机体系结构、操作系统、嵌入式系统、网络互联和网络安全这样的高级题目。\n读者应具备的背景知识 # 本书的重点是执行 x86-64 机器代码的系统。对英特尔及其竞争对手而言, x86-64 是他们自 1978 年起 ,以 8086 微处理器为代 表,不 断 进化的最新成果。按照英特尔微处理器产品线的命名规则, 这类微处理器俗称为 \u0026quot; x86\u0026quot;。随着半导体技术的演进,单芯片上集成了更多的晶体管,这些处理器的计算 能力和内存容量有了很大的增长。在这个过程中 ,它 们从处理16 位字, 发展到引入 IA32 处理器处理 32 位字,再 到最近的 x86-64处理 64 位字。\n我们考虑的是这些机器如何在 Linux 操作系统上运行 C 语言程序。\nLinux 是众多继承自最 初由贝尔实验室开发的 U nix 的 操 作 系统中的一种。这类操作系统的其他成员 包括 Solaris 、 Fr eeBSD 和 MacOS X。 近年 来,\nXII # 由 千 Pos ix 和标 准 U nix 规范的标准化努力, 这些操作系统 保持了高度兼容性。因此, 本书内容几乎直接适用千这些 “类 U nix\u0026quot; 操作系统。\n文中 包含大横已在 Linux 系统上编译和运行过的程序示例。我们假设你能访问一台这样的机器,并 且 能 够登录, 做一些诸如切换目录之类的简单操作。如果你的计算机运行的是 Mi­ crosoft Windows 系统 , 我们建议你选择安装一个虚拟机环境(例如 Virt ua!Box 或者 VMWa re ) , 以便为一 种操作系统(客户 OS) 编写的程序能在另一种系统(宿主 OS) 上运行。\n我们还假设你对 C 和 C+ + 有一定的了解。如果你以前只有 Java 经验, 那么你需要付出更多的努力来完成这种 转换, 不过我 们也会帮助你。Java 和 C 有相似的语法和控制语句。不过, 有一些 C 语言的特性(特别是指针、显式的动态内 存分配和格式化 1/ 0 ) 在 Java 中都是没有的。所幸的是,C 是一个较小的语言, 在 Brian Kern ig han 和 Dennis Ritch ie 经典的 \u0026quot; K\u0026amp; R\u0026quot; 文献中得到了清晰优美的描述[ 61] 。无论你的编程背景如何, 都应该考虑将 K \u0026amp; R 作为个人系统 藏书的一部分。如果你只有使用解 释性语言的经验, 如Python 、R uby 或 Perl , 那么在使用本书之前,需 要 花 费 一些时间来学习 C。\n本书的前几章揭示了 C 语言程序和它们相对应的机器语言程序之间的交互作用。 机器语 言示例都是用运行在 x86-64 处理器上的 G NU GCC 编译器生成的。我们不需要你以前有任何硬件、机器语言或是汇编语言编程的经验。\n区 关千 C 编程语言的建议\n为 了帮 助 C 语言编程背景 薄弱(或全无背景)的读者, 我们在书 中加入了这 样一些专 门的注释 未突出 C 中一些特 别重要的特性。我们假设你熟悉 C+ + 或 Java 。\n如何阅读此书\n从程序员的角度学习计算机系统是如何工作的会非常有趣,主要是因为你可以主动地做这 件事情。无论何时你学到一些新的东西, 都可以 马上试验并且直接看到运行结果。事实上, 我们相信学习系统的唯一方法就 是做C do ) 系统 ,即 在真正的系统上解决具体的问题, 或是编写 和运行程序。\n这个主题观念贯穿全书。当引入一个新概念时,将会有一个或多个练习题紧随其后,你应 该马上做一做来检验你的理解。这些练习题的解答在每章的末尾。当你阅读时,尝试自己来解 答每个问 题, 然后 再查阅答案, 看自己的答案是否正确。除第 1 章外, 每章 后面都有难度不同的 家庭作业。对每个家庭作业题, 我们标注了难度级别:\n只 需 要几分钟。几乎或完全不需要编程。 XIII # •• 可能需要将近 20 分钟。通常包括编写和测试一些代码。(许多都源自我们在考试中出的题目。)\n***需 要很大的努力 ,也 许 是 1 ~ 2 个 小 时。一般包括编写和测试大量的代码。\n::- 个实验作业, 需 要 将近 1 0 个小时。\n文中 每段代码示例都是由经过 GCC 编译的 C 程序直接生成并在 Linux 系统 上进行了测试, 没有任何人为的改动。当然, 你的系统上 GCC 的版本可能不同, 或者根本就是另外一种编译器, 那么可能生成不一样的机器代码, 但是整体行为表现应该是一样的。所有的源程序代码都可以从 csapp. cs. emu. edu 上的 CS: APP 主页上获取。在本书中, 源程序的 文件名列在两条水平线的右边,水平线之间是格式化的代码。比如, 图 ]中的程序能在 code/ intro/ 目录下的 hello. c 文件中找到。当遇到这些 示例程序时 , 我们鼓励你在自己的 系统上试着运行它们 。\n#include \u0026lt;stdio.h\u0026gt; int main()\n{\nprintf(\u0026ldquo;hello, world\\n\u0026rdquo;); return O;\n}\ncode/intro/hello.c\ncode/intro/hello.c\n图 1 一个典型的代码示例\n为了 避免本书体积过大、内容过多, 我们添加了许多网络旁注( Web a邓ide ) , 包括一些对本书主要内 容的 补充资料。本书中用 C H AP : T O P 这样的标记形式来引 用 这 些 旁注, 这里CH AP 是该章主题的缩写编码, 而 T O P 是涉及的话题的缩写编码。例如,网 络旁注 DAT A : BOOL 包含对第 2 章中数据表示里面有关布尔 代数内容的补充资 料; 而 网络旁 注 ARC H : V LOG 包含的是用 Verilog 硬件描述语言进行处理器设计的资料, 是对第 4 章 中处理器设计部分的补充。所有的网络旁注都 可以从 CS : AP P 的主页上获取。\nm 什么是旁注\n在整本书中,你将会遇到很多以这种形式出现的旁注。旁注是附加说明,能使你对当前 讨论的主题多一些了解。旁注可以有很多用处。 一 些是 小的 历 史故 事。 例如, C 语 言 、\nLinux 和 Int ernet 是从何而 来的? 有些旁注则是 用 来澄 清学 生们 经常感到疑惑的 问题。例如,\n高速缓存的行、组和块有什 么区 别? 还有些旁注给 出 了一 些现 实世 界 的例 子。 例如 , 一 个浮点错误怎么毁掉了法国的一枚火箭,或是给出市面上出售的一个磁盘驱动器的几何和运行参 数。最后, 还有一些旁注仅 仅就是一些有趣 的内容,例 如 , 什 么是 \u0026quot; hoink y\u0026quot; ?\nXIV\n本书概述\n本书由 12 章组成, 旨在阐述计算机系统的核心概念。内容概述如下:\n笫 1 章: 计算机 系统漫并。这一章通过研究 \u0026quot; hello , world\u0026quot; 这个简单程序的生命周期, 介绍计算机系统的主要概念和主题。\n笫 2 章: 信 息的表示和 处理。我们讲述了计算机的算术运算, 重点描述了会对程序员有影响的无符号数和数的补码表示的特性。我们考虑数字是如何表示的, 以及由此确定对于一个给定的字长, 其可能 编码值的范围。我们探讨有符号和无符号数字之间类型转换的效果,还 阐述算术运算的数 学特性。菜鸟级程序员经常很惊奇地了解 到(用补码表示的) 两个正数的和或者 积可能 为负。另一方面,补 码的算术运算满足很 多整数运算 的代数特性,因 此 , 编译器可以很安全地把一个常量乘法转化为一 系列的移位和加法。我们用 C 语言的位级操作来说明布尔代 数的原理和应用。我们从两个方 面讲述了 IEEE 标准的浮点格式:一 是 如何用它来表示数值,一是 浮点运算的数学属性。\n对计算机的算术运算 有深刻的理解是写出可靠程序的关键。比如, 程序员 和编译器不能用表达式( x- y\u0026lt;O ) 来 替 代( x \u0026lt;y) \u0026rsquo; 因为前者 可能会产生溢出。甚至也不能用表达式( - y\u0026lt;- x ) 来替 代, 因为在补码表示中负数和正 数的范围是不对称的。算术溢出是 造成 程序错误和安全漏洞的一个常见根源, 然而很少有书从程序员的角度来讲述计算机算术运算的特性。\n第 3 章 : 程序的 机器级 表 示。我们教读者如何阅读由 C 编译器生成的 x86-64 机器代码。我们说明为不同控制结构(比如条件、循环和开关语句)生成的基本指令模式。我们还讲述 过程的实现,包 括栈分配、寄存器使用惯例和参数传递。我们讨论不同数据结构(如结构、联合和数组)的分配和访问方式。我们还说明实现整数和浮点 数算术运算的指令。我们还以分析程序在机器级的样子作 为途 径, 来理解常见的代码安全漏洞(例如缓 冲区溢出),以及理解程序员、编译器和操作系统可以采取的减轻这些威胁的措施。学习本章的概念能 够帮助读者成为更好的程序员, 因为你们懂得程序在机器上是如何表示的。另外一个好处就在于读者会对指针有非常 全面而具 体的理解。\n第 4 章 : 处理 器体 系结 构。这一章讲述基本的组合和时序逻辑元素,并 展示这些元素如何在数据通路中组合到一起, 来执行 x86-64 指令集的一个称为 \u0026quot; Y86- 64\u0026quot; 的简化子集。我们从设 计单时钟周期数据通路 开始。这个设计概念上非常简单, 但 是 运行速度不会太快。然后我们引入流水线的思 想, 将处理一条指令所需要的不同步骤实现为独立的阶段。这个设计中,在 任何时刻, 每个阶段都 可以处理不同的指令。我们的五阶段处理器流水线更加实用。本章中处理器设计的控制逻辑是用一种称为 H CL 的简单硬件描述语言来描述的。用 HCL 写的硬件设计能够编译 和链接到本书提供的模拟器中, 还可以 根据这些设计\nXV # 生成 Verilog 描述, 它适合合成到实际可以运行的硬件上去。\n. 第 5 章: 优化程序性 能。在这一章里, 我们 介绍了许多提 高代 码性能的技术 , 主要思 想就是让程序员通过使编译 器能 够生成更有效的 机器代码来学习编写 C 代码。我 们一开始介绍的是减少程序需要做的工作的变换,这些是在任何机器上写任何程序时都应该遴循 的。然后讲的是增 加生成的 机器代码中指令级并行度的 变换 , 因而提 高了程序在现代\n“超标量” 处理器上的性能 。为了 解释这些 变换行 之有效的 原理,我 们介绍 了一个简单的操作模型,它描述了现代乱序处理器是如何工作的,然后给出了如何根据一个程序的 图形化表示中的关键路径来测 量一个程序可能的性能 。你会惊讶 千对 C 代码做 一些简单的变换能 给程序带来多 大的速度提升 。\n. 第 6 章: 存储 器层次结构 。对应用 程序员 来说 , 存储器 系统 是计算 机系统 中最直接可见的部分之一。到目前 为止 , 读者一直认同这样一 个存储 器系统 概念模型, 认为它 是一个有一致访问时间的线性数组 。实际上, 存储 器系统是一个由不同容量、造价和访问 时间的存储设备组成的层次结构 。我们讲述不同类型的随 机存取存储器 ( RAM) 和只读存储 器\nCROM), 以及磁盘和固态硬盘e 的几何形状和组织构造。我们描述这些存储设备是如何放置在层 次结构中的, 讲述访问局部性是如何使这种层次结构成为可能的。我们通过一个 独特的观点使这些理论具体化, 那就是将存储器系统 视为一个“存储器山\u0026quot; \u0026rsquo; 山脊是时间局部性, 而斜坡是空间局部性。最后, 我们向读者阐述如何通过改善程序的时间局部性和空间局部性来提高应用程序的性能。\n第 7 章:链 接 。本 章 讲述静态和动态链接,包 括的概念有可重定位的和可执行的目标文件、符号 解析、重定位、静态库、共享目标库、位置无关代码,以 及 库 打桩。大多数讲述系统的书中都不讲链接, 我们要讲述它是出于以下原因。第一, 程序员遇到的 最令人迷惑的问题中, 有一些和链接时的小故障有关, 尤其是对那些大型软件包来说。第二,链接器生成的目标文件是与一些像加载、虚拟内存和内存映射这 样的概念相关的。\n笫 8 章 : 异常控制流。在本书的这个部分, 我们通过介绍异常控制流(即除正常分支和过程调用以外的控制流的变化)的一般概念,打 破单一程序的模型。我们给出存在于系统所有层次的异常控制流的例子, 从底层的硬件异常和中断,到 并 发进程的上下文切换,到 由 千接 收 Lin ux 信 号 引 起的控制流突变,到 C 语 言 中 破坏栈原则的非本地跳转。\n在这一章, 我们介绍进程的基本概念, 进程是对一个正在执行的程序的一种抽象。读者会学习进程是如何工作的,以 及如何在应用程序中创建和操纵进程。我们\ne 直译应为固态驱动器, 但固态硬盘一词已经被大家接受,所以沿 用 . 一 译 者注\nXVI\n会展示应用程序员如何通过 Linux 系统调用来使用多个进程。学完本章之后, 读者就能够编写带作业控制的 Linux she ll 了。同时,这 里也会向读者初步展示程序的并发执行会引起不确定的行为。\n. 第 9 章: 虚拟内存 。我们讲 述虚拟内存系统是希望读者对它是如何工作的以及它的特 性有所了解。我们想让 读者了解 为什么不同的并发进程各自都有一个完全相同的地址范围,能 共享某些页, 而又独占另外一些页。我们还讲了一些管理和操纵虚拟内存的问题。特别地, 我们讨论了存储分配操作, 就像标准库的 ma l l oc 和 fr ee 操作。阐述这些内容是出于下面几个目的。它加强了这样一个概念,那就是虚拟内存空间只是一 个字节数组, 程序可以把它划分成不同的存储单元。它可以帮助读者理解当程序包含存储泄漏和非法指针引用等内存引用错误时的后果。最后,许多应用程序员编写自己 的优化了的存储分配操作来满足应用 程序的需 要和特性。这一章比其他任何一章都更能展现将计算机系统中的硬件和软件结合起来阐述的优点。而传统的计算机体系结构 和操作系统书籍都只讲述虚拟内存的某一方面。\n笫 10 章 : 系统级 I/ 0 。我们讲述 Unix I/ 0 的基本概念,例 如 文件和描述符。我们描述如何共享文件, I/ 0 重定向是如何工作的, 还有如何访问文件的元数据。我们还开发了一个健壮的带缓冲区的 I/ 0 包 , 可以正确处理一种称为 short counts 的奇特行为,也 就是库函数只读取一部分的输入数据。我们阐述 C 的标准 I/ 0 库,以 及它与 Linu x I / 0 的关系, 重点谈到标准 I/ 0 的局限性, 这些局限性使之不适合网络编程。总的来说,本章的主题是后面两章 网络和并发编程的基础。\n. 第 11 章 : 网络编程。对编程而言,网 络是非常有趣的 I/ 0 设备,它 将许多我们前面文中学习的概念(比如进程、信号、字节顺 序、内存映射和动态内存分配)联系在一起。网络程序还为下一章的主题 并发,提供了一个很令人信服的上下文。本章只是网 络编程的一 个很小的部分, 使读者能够编写一个简单的 Web 服务器。我们还讲述位于所有网络程序底层的 客户端-服务器模型。我们展现了一个程序员对 Internet 的观点, 并 且教 读 者如何用套接字接口来编写 Inte rnet 客户端和服务器。最后, 我们介绍超文本传输协议( HT T P) , 并开发了一个简单的迭代式 Web 服务器。\n笫 1 2 章 : 并发编程。 这一章以 Internet 服务器设计为例介绍了并发编程。我们比较对照了三种编写并发程序的 基本机制(进程、I/ 0 多路复用和线程),并 且展示如何用它们来建造并发 Internet 服务器。我们 探讨了用 P 、V 信号量操作来实现同步、线程安全和可重入、竞争条件以及死锁等的基本原则。对大多数服务器应用来说,写并发 代码都是很关键的。我们还讲述了线程级编程的使用方法,用这种方法来表达应用程 序中的并行性,使 得程序在多核处理 器上能执行得更快。使用所有的核解决同一个计算问题需要很小心谨慎地协调并发线程,既要保证正确性,又要争取获得高性能。\nXVII\n本版新增内容\n本书的第 1 版千 2003 年出版,第 2 版在 2011 年出版。考虑到计算机技术发展如此迅速, 这本书的内 容还算是保持 得很好。事实证明 Int el x86 的机器上运行 Linux( 以及相关操作系统), 加上采用 C 语言编程,是 一种能够涵盖当今许多系统的组合。然而, 硬件技术、编译器和程序库 接口的变化 ,以 及很多教师教授这些内容的经验, 都促使我们做了大量的修改。\n第 2 版以 来的最大整体变化是, 我们的介绍从以 IA 32 和 x86-64 为基础, 转变为完全以 x86-64 为基础。这种重心的转移影响了很多章节的内容。下面列出一些明显的变化 :\n. 第 1 章 。 我们将第 5 章对 Amdah l 定理的讨论移到了本章。\n. 第 2 章 。 读者和评论家的反馈是一致的, 本章的一些内容有点令人不知所措。因此, 我们澄清了一些知识点,用 更 加 数 学 的 方 式 来描述, 使 得这些内容更容易理解。这使得读者能先略过数学细节, 获得高层次的总体概念, 然后回过头来进行更细致深入的阅读。\n笫 3 章。我们将之前基千 IA32 和 x86- 64 的 表现形式转换为完全基于 x86- 64 , 还更新了近期版本 GCC 产生的代码。其结果是大量的重写工作, 包 括修改了一些概念提出的顺序。同时, 我们还首次介绍了对处理浮点数据的程序的机器级支持。由千历史原因, 我们给出了一个网络旁注描述 IA 32 机 器码。\n. 第 4 章。我们将 之前基于 32 位架构的处理器设计修改为支持 64 位字和操作的设计。\n.• 第 5 章。我们更新了内容以反映最近几代 x86-64 处理器的性能。通过引入更多的功能单元和更复杂的控制逻辑, 我们开发的基于程序数据流表示的程序性能模型, 其性 能 预测变得 比之前更加可靠。\n第 6 章。我们对内容进行了更新,以 反映更多的近期 技术 。\n第 7 章。针对 x86- 64 , 我们重写了本章, 扩充了关于用 GOT 和 P LT 创建位置无关代码的讨论, 新增了一节描述更加强大的链接技术, 比如库打桩。\n. 第 8 章 。 我们增加了对信号处理程序更细致的描述, 包括异步信号安全的函数, 编写信号处理程序的具体指导原则, 以 及用 s i gs us pe nd 等待处理程序。\n第 9 章 。 本章变化不大。\n. 第 1 0 章。我们新增了一节说明文件和文件的 层次结构,除 此之外, 本章的 变化不大。\n笫 11 章 。 我们介绍了采用最新 ge t addr i nf o 和 ge t na me i nf o 函数的、与协议无关和线程安全的网络编程, 取代过时的、不可重入的 ge t hos t b yna me 和 g e t hos t ­ bya ddr 函数。\nXVM # 笫 1 2 章。我们扩充了利用线程级并 行性使得程序在多核机器上更快运行的内容。此外,我们还增加和修改了很多练习题和家庭作业。\n本书的起源\n本书起源于 1998 年秋季, 我们在卡内基-梅隆 CCMU ) 大学开设的一门编号为 1 5-213的 介 绍 性课程: 计 算机系统导论 (I nt rod uction to Computer System, ICS) [ 14] 。从 那 以后 , 每学期都开设了 ICS 这门课程, 每学期有超过 400 名学生上课,这 些 学 生从本科二年级到硕士研究生都有,所学专业也很广泛。这门课程是卡内基-梅隆大学计算机科学系 (CS)以及电子和计算机工程系 CE CE) 所有本科生的必修课, 也 是 CS 和 ECE 大多数高级系统课程的先行必修课。\nICS 这门 课程的宗旨是用一种不同的方式向学生介绍计算机。因为,我 们的学生中几乎没有人有机会亲自去构造一个计算机系统。另一方面,大多数学生,甚至包括所有的计 算机科学家和计算机工程师,也需要日常使用计算机和编写计算机程序。所以我们决定从 程序员的角度来讲解系统,并采用这样的原则过滤要讲述的内容:我们只讨论那些影响用 户级 C 语言程序的性能、正确性或实用性的主题。\n比如, 我们排除了诸如硬件加法器和总线设计这样的主题。虽 然我们谈及了机器语言, 但是重点并不在千如何手工编写汇编语言, 而是关注 C 语言编译器是如何将 C 语言的结构翻译成机器代码的, 包括编译器是如何翻译指针、循环、过程调用以及开关( switch ) 语句的。更进一步地,我们将更广泛和全盘地看待系统,包括硬件和系统软件,涵盖了包 括链接、加载、进程、信号、性能优化、虚拟内存、I/ 0 以 及网络与并发编程等在内的主题。\n这种做法使得我们讲授 ICS 课程的方式对学生来讲既实用、具体, 还能动手操作,同 时也非常能调动学生的积极性。很快地,我们收到来自学生和教职工非常热烈而积极的反 响, 我们意识到卡内基-梅隆大学以外的其他人也可以从我们的方法中获益。因此, 这本书从 ICS 课程的笔记中应运而生了, 而现在我们对它做了修改,使 之能 够反映科学技术 以及计算机系统实现中的变化和进步。\n通过本书的多个版本和多种语言译本, ICS 和许多相似课程已经成为世界范围内数百所高校的计算机科学和计算机工程课程的一部分。\n写给指导教师们:可以基千本书的课程\n指导教师可以使用本书来讲授五种不同类型的系统课程(见图 2 ) 。具体每门课程则有\nXIX\n赖于课程大纲的要求、个人喜好、学生的背景和能力。图中的课程从左往右越来越强调以 程序员的角度来看待系统。以下是简单的描述。\nORG: 一门以非传统风格讲述传统 主题的计算机组成原理课程。传统的 主题包括逻辑设计、处理器体系结构、汇编语言和存储器系统, 然而这里更多地强调了对程序员的影响。例如, 要反过来考虑数据表示对 C 语言程序的数据类型 和操作的影响。又例如 , 对汇编代码的讲解是基于 C 语言编译器产生的机器代码, 而不是手工编写的汇编代码。\nORG+ : 一门 特别强调硬件对应用程序性能影响的 ORG 课程。和 ORG 课程相比, 学生要更多地学习代码优化和改进 C 语言程序的 内存性能。\nICS: 基本的 ICS 课程,旨 在培养一类程序员, 他们能够理解硬件、操作系统和编译系统对应用程序的性能和正确性的影响。和 ORG+ 课程的一个显著不同是, 本课程不涉及低层次的处理器体系结构。相反, 程序员只同现代乱序处理器的高级模型打交道。ICS 课程非常适合安排到一个 10 周的小学期, 如果期望步调更从容一些,也 可以延长到一个 15 周的学期。\nICS+ : 在基本的 ICS 课程基础上, 额外论述一些系统编程的问 题, 比 如系统级1/ 0 、网络编程和并发编程。这是卡内基-梅隆大学的一门一学期时长的课程, 会讲述本书中除了低级处理器体系结构以外的所有章 。\nSP: 一门系统编程课程。和 res + 课程相似, 但是剔除了浮点 和性能优化的内容,\n更加强调系统编程, 包括进程控制、动态链 接、系统级 1/0 、网络编程和并发编程。指导教师可能会想从其他渠道对某些高级主题做些补充, 比如守护进程( dae m o n ) 、终端控制和 Unix IPC( 进程间通信)。\n图 2 要表达的主要信息是本书给了学生和指导教师多种选择。如果你希望学生更多地\n图 2 五类基千本书的课程\n注: 符 号0 表 示覆 盖部 分章 节 , 其中: ( a) 只 有 硬 件 ; Cb) 无动 态存储 分配; ( c) 无动态链 接 ; Cd) 无孚点 数 。\nJCS+ 是卡内基-梅隆的 15-213 课 程 。\nxx\n了解低层次的处理器体系结 构,那 么 通过 ORG 和 ORG十课程可以达到目的。另一方面, 如果你想将当前的计算机组成原理课程转换成 ICS 或者 ICS+ 课程, 但是又对突然做这样剧烈的变化感到担心, 那么你可以 逐步递增转向 JCS 课程。你可以从 OGR 课程开始,它以一种非传统的方式教授传统的问题。一旦你对这些内容感到驾轻就熟了,就可以转到\nORG+, 最终转到 JCS。如果学生没有 C 语言的经验(比如他们只用 J ava 编写过程序), 你可以 花几周的时间在 C 语言上, 然后再讲述 ORG 或者 JCS 课程的内容。\n最后,我们 认为 ORG + 和 SP 课程适合安排为两期(两个小学期或者两个学 期)。或者你可以考虑按照一期 ICS 和一期 SP 的方式来教授 JCS+ 课程。\n写给指导教师们:经过课堂验证的实验练习 # JCS+ 课程在卡内基-梅隆大学得到了学生很高的评价。学生对这门课程的 评价,中 值分 数 一 般为 5. 0/ 5. 0 , 平均分数一般为 4. 6 / 5. 0。学生们说这门课非常有趣, 令人兴奋: 主要就是因为相关的实验练习。这些实验练习可以从 CS: APP 的主页上获得。下面是本书提供的一些实验的示例。\n数据实验。这个实验要求学生实现简单的逻辑和算术运算函数, 但是只能使用一个非常有限的 C 语言子集。比如,只 能用位级操作来计算一个数字的绝对值。这个 实验可帮助学生了解 C 语言数据类型的位级表示,以 及 数 据 操 作 的位级行为。\n二进制炸 弹实验。二进制 炸 弹是一个作为目标代码文件提供给学生的程序。运行时,它 提示用户输入 6 个不同的字符串。如果其中的任何一个不正确, 炸弹就会\n“爆炸",打印出一条错误消息,并且在一个打分服务器上记录事件日志。学生必须 通过对程序反 汇编和逆向工程来测定应该是哪 6 个串,从 而解除各自炸弹的 雷管。该实验能教会学生理解汇编语言,并且强制他们学习怎样使用调试器。\n缓冲区溢出实验。它要求学生通过利用一个缓冲区溢出涌洞,来修改一个二进制可 执行文件的运行时行为。这个实验可教会学生栈的原理,并让他们了解写那种易于 遭受缓冲区溢出攻击的代码的危险性。\n体系结 构实验。第 4 章的儿个家庭作业能够组合成一个实验作业, 在实验中,学 生修改处理器的 HCL 描述,增 加新的指令, 修改分支预测策略, 或者增加、删除 旁路路径和寄存器端口。修改后的处理器能够被模拟,并通过运行自动化测试检测出 大多数可能的错误。这个实验使学生能够体验处理器设计中令人激动的部分,而不 需要掌握逻辑设计和硬件描述语言的完整知识。\n性能实验。学生必须优化应用程序的核心函数(比如卷积积分或矩阵转置)的性能。这 个实验可非常清晰地表明高速缓存的特性,并带给学生低级程序优化的经验。\nXXI\ncache 实验。这个实验类似于性能实验,学 生编写一个通用高速缓存模拟器,并 优化小型矩阵转置核心函数,以最小化对模拟的高速缓存的不命中次数。我们使用 Valg r ind 为矩阵转置核心函数生成真实的地址访问记录。 shell 实验。学生实现他们自己的带有作业控制的 U nix s hell 程序, 包括 Ct rl + C 和Ctrl + Z 按键, f g 、 b g 和 j ob s 命令。这是学生第一次接触并发,并 且 让 他 们 对U nix 的 进程控制、信号和信号处理有清晰的了解。 ma l l o c 实验。学生实现他们自己的 ma l l o c 、 f r e e 和 r e a l l oc ( 可选)版本。这个实验可让学生们清晰地理解数据的布局和组织,并且要求他们评估时间和空间效率 的各种权衡及折中。 代理实验。实现一个位千浏览器和万维网其他部分之间的并行 Web 代理。这个实验向学生们揭示了 Web 客户端和服务器这样的主题,并 且把课程中的许多概念联系起来, 比如字节排序、文件 I/ 0 、进程控制、信号、信号处理、内存映射、套接字和并发。学生很高兴能够看到他们的程序在真实的 Web 浏览器和 Web 服务器之间起到的作用。 CS : A P P 的教师手册中有对实验的详细讨论, 还有关千下载支待软件的说明。\n第 3 版的致谢\n很荣幸在此感谢那些帮助我们完成本书第 3 版的人们。\n. 我们要感谢卡内基-梅隆大学的同事们, 他们已经教授了 ICS 课程多年,并 提 供 了 富有见解的反馈意见,给了我们极大的鼓励: Guy Blell och 、Roger Dan nen ber g、David Eck­\nhardt 、F ra nz F ra nche tt i、G reg Ga nger 、Set h Golds tein 、Khaled Harr as 、G reg Kesde n、\nBruce Maggs 、T odd Mowr y、And reas Nowatzyk 、F ra nk P fen ning、Mark us P ueschel 和\nAnthony Rowe。David Winters 在安装和配置参考 Linux 机器方面给予了我们很大的帮助。\nJason Frit ts ( 圣路易斯大学, S t. Louis Universit y ) 和 Cind y Norris(阿帕拉契州立大学, A ppalach ian S tat e ) 对第 2 版提供了细致周密的评论。龚奕利(武汉大学, W uha n Uni­ vers it y) 翻译了中文版,并 为其维护勘误,同 时 还贡献了一些错误报告。God mar Back(弗吉尼亚理工大学, V ir gi nia T e ch ) 向我们介绍了异步信号安全以及与协议无关的网络编程, 帮助我 们显著提升了本书质量。\n非常感谢目光敏锐的读者们,他 们报告了第 2 版中的错误: Rami Ammari、 P a ul A n­ ag nost opo ulos 、L ucas Baren fanger 、Godm ar Back、Ji Bin、S har bel Bousemaa n、Rich a r d Callaha n、Set h Chaiken 、Cheng Chen 、Libo C hen 、T ao D u、Pascal Garcia 、Y山 Go ng、\nXXII\nRonald G re e n berg 、Doru khan Guloz 、Do ng H an 、Dominik H elm 、Ronald J o nes 、M us ta­ fa Kazdagli、 Go r don Kindlma nn 、Sa nkar Kris h nan、Kana k Ks het ri 、J unlin Lu、 Q ian­ gqiang Luo 、Se bas t ia n L uy 、Lei Ma 、As hw in Nanja ppa 、G regoire Para dis 、 J o n as Pfen­\nninger 、Karl P icho t t a、 Da vid Rams ey、Ka us ta bh Ro y、 David Selva ra j、 S a nkar Shan­ mugam 、Dom inique S mulko ws ka 、Dag S0r b0、Michael S pear 、Y u T a naka 、Steven Tri­ canowic z、Scott W rig h t、Wa如 Wrig ht 、 H an X u 、 Zhengs han Yan 、F iro Ya ng、Sh uang Ya ng 、J o hn Ye、T ak eto Yos hida 、Ya n Zh u 和 M icha el Zin k。\n还要感谢对实验做出贡献的读者,他们是: Godmar Back( 弗吉尼亚理工大学, V ir ­ ginia Tech ) 、T aymo n Beal ( 伍斯 特理 工学 院, Worces ter Polytechnic Instit ute ) 、 A ran Cla us o n ( 西 华 盛 顿 大 学, Wes te rn Washington Univer sit y ) 、Ca ry Gray ( 威 顿 学 院, W heaton College ) 、 P a ul H aid uk C 德州农机大学, W es t T e xa s A\u0026amp;M U niversit y ) 、 Len H a mey( 麦考瑞大学 , Macq uar ie U nivers it y) 、Edd ie K oh ler ( 哈佛大学, H a rvard ) 、H ug h L a uer ( 伍斯特理工学院, W o r ces ter Pol ytechnic Ins tit ute ) 、 Ro be rt Marmorst ein( 朗沃德大学, L o ng woo d U nivers it y) 和 James Riely ( 德保罗大学 , D e P a ul U niver si t y) 。\n再次感谢 Wind fall 软件公司的 Pa ul A nag nos to po ulo s 在本书排版和先进的制作过程中所做的精湛工作。非常感谢 Pa ul 和他的优秀团队 : Ric hard Camp( 文字编辑)、J enn ifer M c C lain ( 校对)、La ur e l Mull er ( 美术制作)以及 T ed La ux ( 索引 制作)。Pa ul 甚至找出 了我们对缩写 BSS 的起源描述中的一个错误, 这个错误从第 1 版起一直没有被发现!\n最后, 我们要感谢 P ren tice H all 出版社的朋友们。Marcia H or to n 和我们的编辑 Matt Golds tein 一直坚定不移地给予我们支持和鼓励, 非常感谢他们。\n第 2 版的致谢\n我们深深地感谢那些帮助我们写出 CS : AP P 第 2 版的人们。\n首先, 我们要感谢在卡内基-梅隆大学教授 ICS 课程的同事们,感 谢 你 们见解深刻的反馈 意 见 和鼓 励: Guy B lelloch 、 R og er Dan nenberg、David E ckhard t、Greg Ganger 、Seth Golds tein 、G reg Ke s de n、Bru ce Maggs 、T odd Mow ry、A nd reas Nowatzyk 、F ra n k P fenni ng 和 Mark us P ues ch el。\n还要感谢报告第 1 版勘误的目光敏锐的读者们: Daniel Amelang、Rui Baptista 、 Q uaru p\nBarreirinhas 、Michael Bombyk 、Jorg Brauer、Jordan Brough、Yixin Cao、James Caroll、Rui Car­\nvalho、H young-Kee Choi、 Al Davis、Grant Davis 、Christian Dufour、Mao Fan、飞m Freeman、Inge Fr ic k 、 Max Gebhardt、Jeff Goldblat 、T homas Gross 、Anita G upta、John Hampton、Hiep\nXXIII\nHong、Greg Israelsen、Ronald Jo nes、Haudy Kazemi、Brian Kell、Constantine Kousoulis、Sacha\nKrakowiak 、Arun Krishnaswamy 、Martin Kulas 、Michael Li、Zeyang Li、Ricky Liu 、Mario Lo\nConte、Dirk Maas、Devon Macey、Carl Marcinik、W让I Marrero、Simone Martins 、Tao Men、Mark Morrissey、Venkata Naidu、Bhas Nalabothula、T homas Niemann、Eric Peski n、David Po、Anne Rogers、John Ross、Michael Scott、Se如、Ray Sh巾、 Darre n Shultz、Erik Silkensen、S ury­\nanto、Emil Tarazi、 Nawanan T heera- Ampornpunt、Joe Trdinich 、Michael Trigobo ff 、 Ja mes\nTroup、Martin Vopatek、Alan West、Betsy Wolff 、 T im Wong、James Woodruff 、Scott Wright 、\nJackie 沁ao 、Guanpeng Xu、Qing Xu、Caren Yang、Yin Yongsheng 、Wang Yuanxuan、Steven\nZhang 和 Day Zhong。特别感谢 Inge Frick, 他发现了我们加锁复制Clo ck-and-copy)例子中一个极不明显但很深刻的错误, 还要特别感谢 Ricky Liu, 他的校对水平真的很高。\n我们 Int el 实验室的同事 And rew Chien 和 Limor F ix 在本书的写作过程中一直非常支持。非常感谢 S teve Schlosser 提供了一些关于磁盘驱动器的总结描述, Case y H elfr ich 和Michael Ryan 安装并维护了新的 Core i7 机器。Michael Kozuch 、 Ba bu P illai 和 J aso n Ca mpbell 对存储器系统性能、多核系统和能量墙问题提出了很有价值的见解。P hil Gib­\nbons 和 S himin Chen 跟我们分享了大显关于固态硬盘设计的专业知识。\n我们还有机会邀请了 Wen- Mei H w u、M ark us P ueschel 和 J iri S imsa 这样的高人给予了一些针对具体问题的意见和高层次的建议。James Hoe 帮助我们写了 Y86 处 理 器的Ver ilog 描述, 还完成了所有将设计合成到可运行的硬件上的工作。\n非常感谢审阅本书草稿的同事们: James Archibal d( 百翰杨大学, Br igham Young Univer­\nsity) 、Richard Carver( 乔治梅森大学, G eorge Mason Universit y) 、Mirela Damian(维拉诺瓦大学, Vi llanova U niversity) 、Peter Dinda( 西北大学)、John Fiore( 坦普尔大学, Te mple U niver ­\nsity) 、J ason Fritts ( 圣路易斯大学, S t. Louis Universit y) 、Jo hn Greiner( 莱斯大学)、Bria n Har­\nvey( 加州大学伯克利分校)、Don Heller (宾夕法利亚州立大学)、Wei Chung Hsu(明尼苏达大学)、M呻 elle H ugue( 马里兰大学)、Jeremy Johnson( 德雷克塞尔大学, Drexel U niversity) 、Geoff Kuenning( 哈维马德学院, H ar vey Mudd College) 、Ricky Liu、Sam Madden(麻省理工学院)、Fred Mart in( 马萨诸塞大学洛厄尔分校, U niversity of Massachusetts, Lowell)、Abraham Matta( 波士顿大学)、Markus Pueschel( 卡内基-梅隆大学)、Norman Ramsey(塔夫茨大学, Tufts Universit y) 、Glenn Reinmann( 加州大学洛杉矶分校)、Michela Taufer (特拉华大学, University of Delaware) 和 Craig Zilles ( 伊利诺伊大学香嫔分校)。\nWind fall 软件公司的 Paul A nag nos topoulos 出色地完成了本书的排版,并 领 导 了 制 作团队。非常感谢 Paul 和他超棒的团队: Rick Camp ( 文字编辑)、J oe Snowden(排版)、\nXXIV\nMaryEllen N. Oliver (校对)、Laurel Muller ( 美术)和T ed Laux ( 索引 制作)。\n最后, 我们要感谢 P rent ice Hall 出 版社的朋友们。Marcia H orton 总是支持着我们。我们的编辑 Ma tt Goldst ein 由始至终表现出了一流的领导才能。我们由衷地感谢他们的帮助、鼓励和真知灼见。\n第 1 版的致谢\n我们衷心地感谢那些给了 我们中肯批评和鼓励的众多朋友及同事。特别感谢我们 15-\n213 课程的学生们, 他们充满感染力的精力 和热情鞭策我们前行。Nick Carter 和 Vinny F ur ia 无私地提供了他们的 malloc 程序包。\nGuy Blelloch、Greg Kesden、Bruce Maggs 和 T odd Mowr y 己教授此课多个学期, 他们给了我们鼓励并帮助改进课程内容。Her b Der by 提供了早期的精神指导和鼓励。Allan Fis her、Gar t h Gibs on、T homas G ross 、Sat ya 、Peter Stee nk iste 和 H ui Zhang 从一开始就鼓励我们开设 这门课程。Gart h 早期给的建议促使本书的工作得以开展,并 且在 Alla n Fis her 领导的小组的帮助下又细化 和修订了本书的工作。Mar k Stehlik 和 Peter Lee 提供了极大的支持,使 得 这些内容成为本科生课程的 一部分。Greg Kesde n 针对 ICS 在操作系统课程上的影响提供了有益的反馈意见。Greg Ganger 和 J iri Schindler 提供了一些磁盘驱动的描述说明,并 回 答了我们关于现代磁盘的疑问。Tom St riker 向我们展示了存储器山的比喻。James Hoe 在处理器体系结构方面提出了很多有用的建议和反馈。\n有一群特殊的学生极大地帮助我们发展了这门课程的内容, 他们是 Khalil Amiri 、Angela Demke Brown、 Chr is Colohan 、Jason Crawfo rd、 Peter Dinda、J ulio Lo pez、Bruce Lowekam p、Jeff Pierce 、San jay Rao、Balaji Sar peshkar 、Blake Scholl、San jit Ses­ 扣a、Greg Steff an、兀ankai T u、Kip Walker 和 Yinglian X比。尤其是 Chr is Colohan 建立了愉悦的氛围并持续到今天, 还发明了传奇般的“二进制炸弹“,这 是 一个对教授机器语言代码和调试概念非常有用的工具。\nChris Bauer、Ala n Cox 、Peter Dinda 、Sandhya Dwar kadis 、J ohn Greiner 、Bruce Ja­ cob、Barr y J ohn so n、 Don Heller、 Bru ce Lowekamp 、 Gr eg Morriset t 、 Brian No ble、Bobbie Ot hmer 、Bill P ug h、M呻 ael Scott 、Mark S motherman 、G reg Steff an 和 Bob Wier 花费了大量时间阅读此书的早期草稿, 并 给予 我们建议。特别感谢 Pet er Dinda(西北大学)、John Gre iner ( 莱茨大学)、Wei H s u( 明 尼 苏 达大学)、Bruce Lowekam p( 威廉 & 玛丽大学)、Bobbie O th mer ( 明尼苏达大学)、Michael Scott( 罗彻斯特大学)和Bob Wier ( 落基山学院)在教学中测试此书的试用版。同样特别感谢他们的学生们!\nXXV\n我们还要 感谢 Prentice H all 出版社的同事。感谢 Marcia H or ton 、 Eric Frank 和 H ar­ old Stone 不懈的支持和远见。Haro ld 还帮我们 提供了对 RISC 和 CISC 处理器体系结构准确的历史 观点。Jerr y Ralya 有惊人的见识, 并教会了我们很多如何写作的知识。\n最后, 我们衷心感谢伟大的技术作家 Brian Ke rnighan 以及后来的 W. Richard Ste­\nvens, 他们向我们证明了技术书籍也能写得如此优美。谢谢你们所有的人。\nRandal E. Bryant David R. O\u0026rsquo; Hallaro n\n于匹兹 堡, 宾 夕 法尼 亚 州\nandal E. Bryant 1973 年于密歇根大学获得学士学位, 随即就读于麻省理工学院研究生院 ,并 在 1981 年获计算机科学博士学位。他\n在加州理工学院做了三年助教,从 1 984 年至今一直是卡内基-梅隆大学\n的教师。这其中有五年的时间,他是计算机科学系主任,有十年的时间 是计算机科学学院院长。他现在是计算机科学学院的院长、教授。他同 时还受邀任职千电子与计算机工程系。\n他教授本科生和研究 生计算机系统 方面的课程近 40 年。在讲授计算机体系结构课程多年后,他开始把关注点从如何设计计算机转移到程序 员如何在更好地了解系统的情况下编写出更有效和更可靠的程序。他和 O \u0026rsquo; H allaro n 教授一起在卡内基-梅隆大学开设 了15- 213 课 程 ”计 算机系统 导论” ,那 便 是 此书的基础。他还教授一些有关算法、编程、计算机网络、分布式系统和 VLSI( 超大规模集成电路)设计方面的课程。\nBr yant 教授的主要研究内容是设计软件工具来帮助软件和硬 件设计者验证其系统正确性。其中,包括几种类型的模拟器,以及用数学方法 来证明设计正确性的形式化验证工具。他发表了 150 多篇技术论文。包括 Intel 、IBM 、Fujits u 和 Microso ft 在内的主要计算机制造商都使用着他的研究成果。他还因他的研究获得过数项大奖。其中包括 Semiconductor Research Corpora tion 颁发的两个发明荣誉奖和一个技术成就奖 , ACM 颁发的 Kane llakis 理 论 与 实践 奖, 还 有 IE EE 颁发 的 W. R. G. Baker 奖、Emmanuel Piore 奖和 P hil Kau fman 奖。他还是 ACM 院士、 IEEE 院士、美国国家工程院院士和美国人文与科学研究院院士。\nDavid R. O\u0026rsquo; Halla ron 卡内基-梅隆大学计算机科学和电子与计 算机工程系教授 。在弗 吉尼亚大学获得计算机科学博士学位, 2007 ~ 2010 年为In tel 匹兹堡实验室主任。\n20 年来 , 他教授本科生和研究生计算机系统 方面的课程,例如 计 算机体系结构、计算机系统导论、并行处理器设计和 Internet 服务。他和 Bry­\nant 教授一起在卡内基-梅隆大学开设了作为本 书基础的 ”计 算机系统导论” 课程。2004 年他获得了卡内基-梅隆大学计算机科学学院颁发的 Her­ bert Simon 杰出教学奖, 这个奖项的获得者是基千学生的 投票产生的。\nXXVII # O\u0026rsquo; Hallaro n 教授从事计算机系统领域的研究, 主要兴趣在于科学计算、数据密集型计算和虚拟化方 面的软件系统 。其中最著名的是 Q ua ke 项目,该 项目是一群计算机科学家、土木工程师和地震学 家为提高对强烈 地震中大地运动的预测能力而开发的。2003 年, 他同 Q ua ke 项目中其他成员 一起获得了高性能计算领域中的最高国际奖项—- Gordon Bell 奖。他目前的工作重点是自动分 级( autogra ding ) 概念,即 评价其他程序质量的程序。\n目录 # 出版者的话中文版序— 中文版序二译者序\n前言关于作者\n第 1 章 计算机 系统漫游 1\n1.1 信息就是位 十上下文 1\n1. 2 程序被其他程序翻译成不同的\n格式 3\n1. 3 了解 编译 系统 如何 工作 是\n大有益处的. 4\n处理器读并解释储存在内存\n中的指令. 5\n4. 1 系统的硬件组成 5\n1. 4. 2 运 行 he ll o 程序 7\n1.5 高速缓存至关重要 ......… 9\n1.6 存储设备形成层次结构 9\n7 操作系统管理硬件 1 0\n1. 7. 1 进程. 11\n1. 7. 2 线程. 1 2\n1. 7. 3 虚拟内存. 12\n1. 7. 4 文件. 14\n系统 之间 利用网络通信 1 4 重要主题 16 9. 1 A mda hl 定律 1 6\n1. 9. 2 并发和并行. 1 7\n9. 3 计 算机 系统 中抽 象的\n重要性. 19\n1. 1 0 小结. 20\n参 考文献说明 20\n练习题答案 20\n第一部分 # 程序结构和执行 # 第 2 章 信息的表示和处理 22\n1 信息存储 24\n2. 1. 1 十六进 制表示 法 25\n2. 1. 2 宇 数 据 大小 27\n2. 1. 3 寻址和宇节顺序 29\n2. 1. 4 表示宇符 串 34\n2. 1. 5 表示代码 34\n2. 1. 6 布 尔代数简 介 35\nXXIX # 1. 7 C 语言中的位级运算 … … … 37\n2. 1. 8 C 语 言中的逻辑运算 … … … 39\n2. 1. 9 C 语 言中的移位运算 40\n2. 2 整数表示 4.1\n2. 2. 1 整 型数据类型 42\n2. 2. 2 无符号数的编码. 43\n2. 2. 3 补码 编码 44\n2. 2. 4 有符号数和无符号数之间的\n转换 49\n2. 2. 5 C 语 言中的 有符号数与\n无符号数. 52\n2. 2. 6 扩展 一个数宇的位表示 … … 54\n2. 2. 7 截 断数 宇 56\n2. 2. 8 关于有符号数与无符号数的\n建议. 58\n3 整数运 算 60\n2. 3. 1 无符号加法. 60\n2. 3. 2 补码加 法 62\n2. 3. 3 才卜码 的非 66\n2. 3. 4 无符号乘法\u0026hellip;\u0026hellip;······\u0026quot; 67\n2. 3. 5 补码乘法. 67\n2. 3. 6 乘以常数. 70\n2. 3. 7 除 以 2 的幕 71\n3 . 8 关于整数 运算的最后思考… … 74\n3. 2. 3 关于格式的注解 ....….….117\n3. 3 数据格式 .·. ..·..·\u0026hellip;·..···..· 119\n3.4 访问 信 息 ···..·\u0026hellip;\u0026hellip; ·..······..··.. 119\n3. 4. 1 操作数指示符 121\n3. 4. 2 数据传送指令 122\n3. 4. 3 数据传送示例 125\n3. 4. 4 压入和弹出栈数据 …… … 127\n5 算术和逻辑操作 128\n3. 5. 1 加栽有效地址 1.29\n3. 5. 2 一 元和二元操作 130\n3. 5. 3 移位操作 1.31\n3. 5. 4 讨论..····..·..·\u0026hellip;·..·..·..·.. 131\n3. 5 . 5 特殊的算术操作 133\n3. 6 控 制 135\n3. 6. 1 条件码·\u0026hellip;.·..·\u0026hellip;\u0026hellip;\u0026hellip;·. 135\n3. 6. 2 访问条件码 136\n3. 6. 3 跳 转指令 138\n3. 6. 4 跳转指令的编码 1 39\n3. 6. 5 用条件控制来 实现 条件分支 … 1 41\n3. 6. 6 用条件 传送来实现 条件分支 … 145 3. 6. 7 循环 1.49\n3. 6. 8 switch 语 句 ..·..·\u0026hellip;.. ···\u0026hellip; 159\n3. 7 过程. 164\n3 . 7. 1 运 行 时栈 164\n2.4 浮点数 \u0026hellip;\u0026hellip;\u0026hellip;\u0026hellip;\u0026hellip;\u0026hellip;\u0026hellip;\u0026hellip;\u0026hellip;\u0026hellip;. 75 3. 7. 2 转移控制 \u0026hellip;\u0026hellip;\u0026hellip;\u0026hellip;\u0026hellip;\u0026hellip; 165 2. 4. 1 二 进 制 小数 \u0026hellip;\u0026hellip;\u0026hellip;\u0026hellip; \u0026hellip;\u0026hellip;.. 76 3. 7. 3 数据传送 \u0026hellip;\u0026hellip;\u0026hellip;..\u0026hellip;\u0026hellip;\u0026hellip; 168 2. 4. 2 IEEE 浮点表 示 78\n2. 4. 3 数 字示例 79\n2. 4. 4 舍入. 83\n2. 4. 5 浮点运 算 85\n4. 6 C 语 言 中的 浮点数 86\n2. 5 小结 87\n参考文献说明 88\n家 庭作业 \u0026hellip;\u0026hellip;\u0026hellip;..· 88\n练习题答案 9.7\n7. 4 栈上的局部存储 170\n3. 7. 5 寄存器中的局部存储空间 … 172 3. 7. 6 递归过程. 174\n3. 8 数组分配和访问 1.76\n3. 8. 1 基本原则 176\n3. 8. 2 指针运 算 177\n3. 8. 3 嵌 套 的数 组 178\n3. 8. 4 定长数组. 179\n3. 8 . 5 变长 数组 1.8.1.\n3. 9 异质的数据结构 183\n第 3 章 程序的机器级表示…\u0026hellip;·..···\u0026hellip; 1 09\n3. 1 历史观点 110\n3. 2 程序编码 113\n3. 2. 1 机器级代码 1 13\n3. 2. 2 代码示例 \u0026hellip;·..·..·..·\u0026hellip;\u0026hellip;.· 11 4\n3 . 9. 1 结构. 183\n3 . 9. 2 联合. 1 8 6\n3. 9. 3 数据 对 齐 189\n3. 10 在机器级程序中将控制与\n数据结合起来 1.9.2\nXXX # 3. 10. 1 理解指针. 192\n3. 10. 2 应用: 使 用 GDB调试器 … 193\n3. 10. 3 内存越界引用和缓冲区\n溢出. 194\n3. 10. 4 对抗缓 冲 区 溢 出攻 击 … … 198 3. 10. 5 支持变长栈帧. 201\n3. 11 浮点代码 204\n11. 1 浮点传送 和转换操作 … … 205\n4.4 流水线的通用原理 282\n4. 4. 1 计算流水线 282\n4. 2 流水线操作的详细说明 … 284 4. 4. 3 流水线的局限性 284\n4. 4. 4 带反馈的流水线系统 287\n4. 5 Y86- 64 的 流 水 线 实 现 288\n4. 5. 1 SEQ + : 重新安排计算\n阶段 288\n3 . 1 2 小结 216\n参考文献说 明 216\n家庭作业 2.16\n练习题 答案 226\n第 4 章 处理器体系结构 243\n4. 1 Y86-64 指 令 集体 系结构 … … … 245\n4. 1. 1 程序员可见的状态 ……… 245\n4. 1. 2 Y86-64 指令 \u0026hellip;\u0026hellip;\u0026hellip;\u0026hellip;\u0026hellip;2..4.5\n4. 1. 3 指令编码. 246\n4. 1. 4 Y86-64 异常 250\n4. 1. 5 Y86-64 程序 ..·. \u0026hellip;·. ..·. 251\n4. 1. 6 一些 Y86-64指令的详情 … … 255\n4.2 逻辑设计和硬件控制语言 HCL … 256 4. 2. 1 逻辑门. 257\n4. 2. 2 组合电路和 HCL 布 尔\n表 达 式 257\n4. 2. 3 宇级的 组合 电路和 HCL\n整数表达式 258\n4. 2. 4 集合关系 261\n4. 2. 5 存储器和时钟 262\n4. 3 Y86-64 的 顺 序实现 2.6.4\n4. 3. 1 将处理组织成阶段 ……… 264\n4. 3. 2 SEQ 硬件结构 272\n4. 3. 3 SEQ 的 时序 274\n4. 3. 4 SEQ 阶段 的 实现 ......... 2…77\n5 . 8 流水线控制逻辑 314\n4. 5. 9 性能分析. 322\n4. 5. 10 未完成的工作 323\n4 . 6 ; \\j 结 325\n参考文献说明 326\n家庭作业 327\n练习题 答案 331\n第 5 章 优化程序性能 … … … … … 3 41\n1 优化编译器的能力和局限性 \u0026hellip; 342 5. 2 表示程序性 能 345\n5. 3 程序示例 347\n5. 4 消除循环的低效率 350\n5. 5 减 少过程调用 353\n5. 6 消除不必要的内存引用 354\n5. 7 理解现代处理器 357\n5. 7. 1 整体操作 357\n5. 7. 2 功能单元的性能 361\n5. 7. 3 处理器操作的抽象模型 … 362 5. 8 循环展开 366\n5. 9 提高并行性 3.6.9.\n5. 9. 1 多个累积变量 3.70\n5. 9. 2 重新结合变换 373\n5. 10 优化合并代码的结果小结 377\n5. 11 一 些限制 因素 378\n5. 11. 1 寄存 器溢出 378\nXXXI\n5. 11. 2 分 支预 测和预 测错 误\n处罚 3 79\n5. 12 理 解内存性能 382\n5. 12. 1 加 载的性能 382\n5. 12. 2 存 储 的性 能 383\n5. 13 应用:性 能 提高技术 387\n14 确认和消除性能瓶颈 388\n5. 14. 1 程序剖析 388\n14. 2 使用剖析程序来指导\n3 存储器层次结构 421\n6. 3. 1 存储器层 次 结构中的缓存 … 422\n6. 3. 2 存储器层 次 结构概 念小结 … 424 6. 4 高速缓存存储器 425\n6. 4. 1 通用的 高速缓存存储 器\n组织结构 42 5\n6. 4. 2 直接映射高速缓存 427\n6. 4. 3 组相联高速缓存 433\n6. 4. 4 全相联高速缓存 434\n练习题答案\u0026hellip; .. . \u0026hellip; \u0026hellip; \u0026hellip; \u0026hellip; \u0026hellip; \u0026hellip; .. . .. . .. . 39 ;:i\n第 6 章 存 储 谣 层 次 结 构 399\n6. 1 存储技术 399\n6. 1. 1 随机访问存储 器 40 0\n6. 1. 2 磁 盘 存 储 .··· 406\n6. 1. 3 固态硬盘 414\n6. 1. 4 存储技术趋势 \u0026hellip;\u0026hellip;\u0026hellip;..·..· 415\n6. 2 局 部 性 . .. . .. . .. . .. .. . .. . . . · .. . .. · .. . 4 1 8\n6. 2. 1 对程序数据引用的 局部性 … 41 8 6. 2. 2 取 指 令 的局部性 419\n6. 2. 3 局部性小结 420\n5 编写高速缓存友好的代码 … … 440\n6. 6 综合 : 高 速缓存对程序性能的\n影响 444\n6. 6. 1 存储器 山 444\n6. 6. 2 重新排列循环以提高空间\n局部性 447\n6. 3 在程序中利 用局 部 性 450\n6. 7 小结 4.50\u0026hellip;.\n参考文献说 明 45 1\n家庭作业 45 1\n练习题答案 459\n第二部 分 # 在系统上运行程序 # 第 7 章 链接 464\n1 编译器驱动程序 465\n7. 2 静态链接 466\n7. 3 目标文件 466\n7. 4 可重定位目标文件 4 67\n7. 5 符号和符号表 468\n7. 6 符号解析 470\n7. 6. 1 链接器如何解析多重定义\n的全局符号 471\n7. 6. 2 与静 态库 链 接 475\n7. 7 重定位 478\n7. 7. 1 重定位条 目 479\n7. 7. 2 重定位符号引用 .... 479\n7. 8 可执行目标文件 483\n7. 9 加载可执行目标文件 484\n7. 10 动态链接共享库 485\n7. 11 从应用程序中加载和链接\n共享库 487\n7. 12 位置无关代码 489\n7. 13 库打桩机制 492\n7. 6. 3 链接器如何使用静态库来\n7. 13. 1 编译 时打桩 \u0026hellip;\u0026hellip;\u0026hellip;\u0026hellip;\u0026hellip;.\n492\n解析弓l 用 477\n7. 13. 2 链 接 时打 桩 \u0026hellip;\u0026hellip;\u0026hellip;\u0026hellip;\u0026hellip;_\u0026hellip; 492\nXXXII # 7. 13. 3 运行时打桩. 494\n14 处理目标文件的工具. 496\n7. 15 小结. 496\n参考文献说 明 497\n家庭作业 497\n练习题答案. 499\n第 8 章 异常控制 流 50 1\n8. 1 异常. 502\n8. 1. 1 异常处理. 503\n8. 1. 2 异常的类别 504\n1. 3 Linux / x86-64 系统 中的\n异常. 505\n8. 2 进程. 508\n8. 2. 1 逻样控制流 508\n8. 2. 2 并发流. 509\n8.2. 3 私有地址空间 509\n8. 2. 4 用户模式和内核模式 …… 510 8. 2. 5 上下文切换 511\n8. 3 系统调用错误处理 512\n8. 4 进程控制. 513\n8. 4. 1 获 取 进 程 ID 513\n8. 4.2 创建和终止进程 513\n8. 4. 3 回收子进程 516\n8. 4. 4 让进程休眠. 521\n8. 4. 5 加栽并运行程序 52 1\n8. 4. 6 利 用 f or k 和 e xe cve 运行\n程序. 524\n8. 5 信号 526\n8. 5. 1 信号术语. 527\n8. 5. 2 发送信号. 528\n8. 5. 3 接收信号. 531\n8. 5. 4 阻塞和解除阻塞信号 …… 532 8. 5. 5 编写信号处理程序 ……… 533\n8. 5. 6 同步流以避免讨厌的并发\n错误. 540\n8. 5. 7 显式地等待信号 543\n8. 6 非本地跳转. 546\n7 操作进程的工具… 550\n8. 8 小结. 550\n参考文献说 明 550\n家庭作业 5.5.0..\n练习题答案. 556\n第 9 章 虚拟内存 559\n1 物理和虚拟寻址 560 9. 2 地址空间. 560\n9. 3 虚拟内存作为缓存的工具 …… 561\n9. 3. 1 DRAM 缓存的组织结构 … … 562 9. 3. 2 页表. 562\n9. 3. 3 页命中. 563\n9. 3. 4 缺页. 564\n9. 3. 5 分配页面 \u0026hellip;···.·..·\u0026hellip;· 565\n9. 3. 6 又是局部性救了我们 565\n9. 4 虚拟内存作为内存管理的\n工具 565\n9. 5 虚拟内存作为内存保护的\n工具 5.6.7\u0026hellip;\n9. 6 地址翻译. 567\n9. 6. 1 结合高速缓存和虚拟\n内存. 570\n9. 6. 2 利 用 T LB 加速地址翻译 … … 570 9. 6. 3 多级页表. 571\n9. 6. 4 综合:端到端的地址翻译 … 573\n9. 7 案例研究: Intel Core i7/ Linux\n内存系统. 576\n9. 7. 1 Core i7 地址翻译 5.76\n9. 7. 2 Lin ux 虚拟内存 系统 … … … 580\n9.8 内存映射 582\n9. 8. 1 再看共享对象 583\n9. 8. 2 再 看 f or k 函数 5.84\n9. 8. 3 再 看 e xe c ve 函数. 584\n9. 8. 4 使 用 mma p 函 数 的 用 户级\n内存映射 585\n9. 9 动态内存分配. 587\n9. 9. 1 ma ll o c 和 f r e e 函数 … … 587\n9. 9. 2 为什么要使用动态内存\n分配. 589\n9. 9. 3 分配器的要求和目标 … … 590 9. 9. 4 碎片. 591\n9. 9. 5 实现问题. 592\n9. 9. 6 隐式空闲链表. 592\n9. 9. 7 放置已分配的块 593\n9. 9. 8 分割空闲块 594\n9. 9 获取额外的堆内存 594 XXXIII # Sweep 608\n9.11 C 程序中常见的与内存有关的\n错误 609\n9. 11. 1 间接引用坏指针. 609\n9. 12 小结 613\n参考文献说明 613\n家庭作业 614\n练习题答案. 617\n第三部分 # 程序间的交互和通信 # 第 10 章 系统级 1 /0 622\n10. 1 Unix I/ 0 622\n10. 2 文 件 623\n3 打开和关闭文件. 624\n10. 4 读 和 写 文 件 625\n10. 5 用 RIO 包 健 壮 地读写 626\n10. 5. 1 RIO 的无缓 冲的 输入4俞出\n函数 627\n10. 5. 2 RIO 的带缓 冲的轮入\n函数. 627\n10. 6 读 取 文 件 元 数 据 632\n7 读 取 目 录内容 633\n10. 8 共享文件 634\n10. 9 I/ 0 重定向 637\n第 11 农 网络编程 642\n1 客户端-服务器编程模型 … … 642 11. 2 网络. 643\n11. 3 全球 IP 因特网 646\n11.3.1 IP 地址 647\n11. 3. 2 因 特 网域 名 649\n11. 3. 3 因特 网连 接 651\n11. 4 套 接字接口 652\n11. 4. 1 套接字地 址 结构 653\n11. 4. 2 s oc ke t 函数 654\n11. 4. 3 c onne c t 函数 654\n11. 4. 4 bi nd 函数 654\n11. 4. 5 li s t e n 函数 655\n11. 4. 6 a c c e p七函 数 655\n11. 4. 7 主机和服务的转换 … … … 656\n参考文献说 明 640\n家庭作业 6.40\n练习题答案 641\n11.5.1 Web 基础. 665\n11. 5. 2 Web 内容 666\n11. 5. 3 HTT P 事务 667\nXXXIV\n11. 5. 4 服务动 态内容 669\n6 综合: TINY Web 服务器. 671\n11. 7 小结. 6.7.8 \u0026hellip;..\n12. 4. 1 线程内存模型. 696\n12. 4. 2 将 变 量映射到内存 … … … 697 12. 4. 3 共享变量. 698\n参考文献说明. 678\n家庭作业 678\n练习题答案. 679\n第 1 2 章 并发编程 681\n1 基 千 进程 的并 发 编程 682\n12. 1. 1 基于进程的并发服务器 … 683 12. 1. 2 进程的优劣. 684\n12.2 基千I/0 多路复用的并发\n编程. 684\n12. 2. 1 基于 I/ 0 多 路 复 用的并发\n事件驱动服务器… 686\n12. 2. 2 I/ 0 多路 复 用技 术的优劣 … 690\n12. 3 基于线程的并发编程 691\n12. 3. 1 线程执行模型. 691\n12. 3. 2 Posix 线程 691\n12. 3. 3 创 建线程 6.92\n12. 3.4 终止线程. 693\n12. 3. 5 回收己终止线程的资源 … 693 12. 3. 6 分 离 线程 694\n12. 3. 7 初始化线程. 694\n12. 3. 8 基于线程的并发\n服务器. 694\n12. 4 多线程程序中的共享变批 … … 696\n12. 5 用信号量同步线程 698\n12. 5. 1 进度图. 701\n1 2. 5. 2 信号量 702\n12. 5. 3 使用信号量来实现互斥 … 703\n12. 5. 4 利用信号量来调度共享\n资源. 704\n12. 5. 5 综合:基于预线程化的\n并发服务器. 708\n12. 6 使 用 线 程提高并行性 710\n12. 7 其他并发问题. 716\n12. 7. 1 线程安全 716\n12. 7. 2 可重入性. 717\n12. 7. 3 在线程化的程序中使用\n已存在的库函数 718\n12. 7. 4 竞争. 719\n12. 7. 5 死锁 721\n12. 8 小结. 722\n参考文献说明. 723\n家庭作业 723\n练习题答案. 726\n附录 A 错误处理 729\n参考文献 733\n"},{"id":434,"href":"/zh/docs/technology/System/%E6%B7%B1%E5%85%A5%E7%90%86%E8%A7%A3%E8%AE%A1%E7%AE%97%E6%9C%BA%E7%B3%BB%E7%BB%9F/%E4%B9%A6%E7%B1%8D/%E7%AC%AC10%E7%AB%A0-%E7%B3%BB%E7%BB%9F%E7%BA%A7I_O/","title":"Index","section":"SpringCloud","content":"第 10 章\nC H A P T E R 10\n系统级 1 /0\n输入/扴出(1/0 )是在主存和外部设备(例如磁盘驱动器、终端和网络)之间复制数据的过程。输入操作是从 I/ 0 设备复制数据到主存, 而输出操作是从主存复制数据到1/0 设备。 # 所有语言的运行时系统都 提供执行 1/ 0 的较高级别的工具。例如, ANSI C 提供标准1/ 0 库, 包含像 pr i n t f 和 s c a n f 这样执行带 缓冲区的 I/ 0 函数。C++ 语言用它的重载操作符<<(输入)和>>(输出)提供了类似的功能 。在 Lin ux 系统中, 是通过使用由内核提供的系统级 U nix I/ 0 函数来实现这些较高级别的 I/ 0 函数的。大多数时候,高 级别 1/ 0 函数工作良好, 没有必要直接使用 U nix I/ 0 。那么为什么还要麻烦地学习 U nix 1/ 0 呢?\n了解 Unix 1/ 0 将帮助你理解其他的 系统概念。1/ 0 是系统操作不可或缺的一部分,因此, 我们经常遇到 1/ 0 和其他系统概念之间 的循环依赖。例如, 1/ 0 在进程的创建和执行中扮演着关键的角色。反过来, 进程创建又在不同 进程间的文件共享中扮演着关键角色。因此,要真正理解1/0 , 你必须理解进程,反之亦然。在对存储器层次结构、链接和加载、进程以及虚拟内存的讨论中, 我们已经 接触了 I/ 0 的某些方面。既然你对这些概念有了比较好的理解, 我们就能闭 合这个循环, 更加深入地研究1/0 。 # 有时你除 了使用 U nix 1/ 0 以外别 无选择。在某些重要的情况中, 使用高级 1/ 0 函数不太可能 ,或 者不太合适。例如, 标准 I/ 0 库没有提供读取文件元数据的方式, 例如文件大小或文件创建时间。另外, I / 0 库还存在一些问题,使 得用它来进行网络编程非常冒险。\n这一章介绍 Unix 1/ 0 和标准 I/ 0 的一般概念, 并且向你展示在 C 程序中如何 可靠地使用 它们。除了作为一般 性的介绍之外,这 一章还为我们随后学习网络编程和并发性奠定坚实的基础。\n10 . 1 Unix 1/0\n一个 Linu x 文件就是一个 m 个字节的 序列: # B。, B1, …, B k\u0026rsquo; … , Bm - 1\n所有的 1/0 设备(例如网络、磁盘和终端)都被模型化为文件 , 而所有的输入和输出都被当作对相应文件的读和写来执行。这种将设备优雅地映射为文件的方式,允 许 Lin ux 内核引出一个简单、低级的应用接口, 称为 U nix I/0, 这使得所有的输入和输出都能以一种统一且一致的方式来执行: # 打开文件。一个应用程序通过要求内核打开相应的文件,来宣告它想要访问一个 I/ 0 设备。内核返回一个小的非负整数 ,叫 做 描述符 ,它 在后续对此文件的所有操作中标识这个文件。内核记录有关这个打开文件的所有信息。应用程序只需记住这个描述符。 Linux shell 创建的每个进程开始时都有三个打开的文件: 标准输入(描述符为 0)、标准输出(描述符为1) 和标准错误(描述符为 2) 。头文件\u0026lt; un i s t d . h \u0026gt; 定义了常量 STDIN_ FIL ENO、STDOUT_FIL ENO和 STDERR_ FIL ENO, 它们可用来代替显式的描述符值。 改变当 前的文件位 置。对 于每个打开的文件,内 核保持着一个文件位 置 k , 初始为 # 0。这个文件位置是从文件开头起 始的字节偏移量。应用程序能够通过执行 s ee k 操作, 显式地设置文件的当前位置为 K。\n读写文 件。一个读操作就是 从文件复制 n \u0026gt; O 个字节到内 存, 从当前文件位置 k 开始, 然后将 K 增加到k + n 。给定一个大小为 m 字节的文件 ,当 k;;;:=::m 时执行读操作会触发一个称为 e nd- of-f ile ( EO F ) 的条件, 应用程序能检测到这个条件。在文件结尾处并没有明确的 \u0026quot; EOF 符号”。 类似地, 写操作就是从内存复制 n \u0026gt; O 个字节到一个文件, 从当前文件位置 K # 开始, 然后更新 k 。\n关闭 文件。当应用完成了对 文件的访问之后,它 就通知内核关闭这 个文件。作为响应,内核释放文件打开时创建的数据结构,并将这个描述符恢复到可用的描述符池 中。无论一个进程因为何种原因终止时,内 核都会关闭所有打开的 文件并释放它们的内存资源。 2 文件\n每个 Linu x 文件都有一个类型 ( t y pe) 来表明它在系统中的角色:\n普通文件 ( reg ula r fi le) 包含任意数 据。应用程序常常要 区分文本文件 ( te xt fi le ) 和二进制文件 ( bina r y file) , 文本文件是只含有 A SCII 或 U nicode 字符的普通文件;二进制文件是所有其他的文件。对内核而言,文本文件和二进制文件没有区别。\nLinux 文本文件包含了一个文本行( text line) 序列, 其中每一行都是一个字符序列, 以一个新行符(\u0026quot; \\ n\u0026quot; ) 结束。新行符与ASCII 的换行符CLF ) 是一样的, 其数字值为Ox Oa 。\n目 录( direct or y ) 是包含一组链接 Clink ) 的 文件, 其中每个链接都将一个 文件 名( fi le nam e) 映射到一个文件, 这个文件可能 是另一个目录。 每个目录至 少含有两个条目:“.”是到该目录自身的链接,以及\u0026quot;..\u0026ldquo;是到目录层次结构(见下文)中父目 录( pa ren t director y ) 的链 接。你可以用 mkd ir 命令创建一个目录,用 l s 查看其内容,用 r md i r 删除该目录。\n套接宇( so cket ) 是用来与另一个进程进行 跨网络通信的文件(11. 4 节)。\n其他文件类型包含命名通道( nam ed pipe ) 、符号链 接( s ym bolic link), 以及字符和块\n设备( charact er and block device), 这些不在本书的讨论 范畴。\nLin ux 内核将所有文件都组织成一个目 录层 次结构 ( directo r y hierarchy) , 由名为/(斜杠)的根目 录确定 。系统中的每个文件都是根目录的 直接或间接的后代。图 10-1 显示了Lin u x 系统的目录层次结构的一部分。\ne 七c l\ngroup passwd/ # home /\ndr oh / br yant /\nI\nus r / # i ncl ude / bi n /\nI\nhe l l o. c # stdio. h s ys / vim\nuniIstd.h\n图10-1 Linux 目录层次的一部分。尾部有斜杠表示是目录\n作为其上下文的一部分,每 个 进程都有一个当前工作目 录( c ur r e n t working directory) 来确定其在目录层次结构中的当前位置。你可以 用 c d 命令来修改 s hell 中的当前工作目录。\n目 录层次结构中的位置用路径名( pa t h na m e ) 来指定。路径名是一个字符串,包 括一个\n可选斜杠,其 后 紧跟一系列的文件名,文 件 名 之 间 用 斜 杠 分 隔 。 路 径 名 有 两 种 形 式 : # 绝对路径名 ( a bs ol ut e pa t h na me ) 以一个斜杠开始, 表 示从根节点开始的路径。例如 ,在 图 1 0- 1 中 , h e l l o . c 的 绝 对 路 径 名 为/ h ome / dr o h / h e ll o . c 。 相 对路径名( re la t ive pa t h na me ) 以文件名开始, 表示从当前工作目录开始的路径。例如 ,在 图 1 0-1 中 ,如 果 / h o me / dr o h 是 当前工作目录, 那 么 h e l l o . c 的 相对路径名就是./hello. c。反之, 如果 / h ome / br y a n t 是 当前工作目录, 那 么 相 对路径名就是../ home / dr o h / h e l l o . c 。 3 打开和关闭文件 进程是通过调用 o p e n 函数来打开一个已存在的文件或者创建一个新文件的: # #include \u0026lt;sys/types.h\u0026gt;\n#include \u0026lt;sys/stat.h\u0026gt;\n#include \u0026lt;fcntl.h\u0026gt;\nint open(char *filename, int flags, mode_t mode);\n返回: 若成功则为新文件描述符,若出错 为一 1 。 # op e n 函数将 f i l e name 转换为一个文件描述符,并 且 返 回 描 述 符 数 字 。 返回的描述符总是在进程中当前 没有打开的最小描述符。fl a gs 参数指明了进程打算如何访问这个文件:\nO_RDONLY: 只读。 O_WRONLY: 只写。 O_RDWR: 可读可写。 例如,下 面的代码说明如何以读的方式打开一个已存在的文件: # fd = Dpen(\u0026ldquo;foo.txt\u0026rdquo;, O_RDONLY, O);\nf l a g s 参 数 也 可以 是 一 个 或 者 更 多 位 掩 码 的 或 , 为写提供给一些额外的指示: # O_CREAT: 如果文件不存在,就 创 建 它 的 一 个 截断的( t ru nca t ed )(空)文件。 O_TRUNC: 如果文件已经存在,就截断它。 O_APPEND: 在每次写操作前,设 置文件位置到文件的结尾处。 例如,下面的代码说明的是如何打开一个已存在文件,并在后面添加一些数据: # fd = Open(\u0026ldquo;foo.txt\u0026rdquo;, O_WRONLYID_APPEND, 0);\nmo d e 参 数 指 定 了 新 文件的访问权限位。这些位的符号名字如图 10- 2 所示。\n作为上下文的一部分, 每个进程都有一个 uma s k , 它 是 通 过 调 用 u ma s k 函 数来设置的 。 当 进 程 通过带某个 mo d e 参 数 的 o p e n 函 数 调 用 来 创 建 一 个新文件时, 文 件 的 访问权限 位 被设 置 为 mo d e \u0026amp; ~ u ma s k 。 例 如,假 设 我们给定下面的 mo d e 和 uma s k 默 认值 :\n#define DEF_MODE S_IRUSRIS_IWUSRIS_IRGRPIS_IWGRPIS_IROTHIS_IWOTH\n#define DEF_UMASK S_IWGRPIS_IWOTH\n接下来,下面的代码片段创建一个新文件,文件的拥有者有读写权限,而所有其他的 # 用户都有读权限:\numask(DEF_UMASK);\nfd = Open(\u0026ldquo;foo.txt\u0026rdquo;, O_CREATIO_TRUNCIO_WRONLY, DEF_MODE);\n图 10-2 访问权限位。在 s ys / s t a t . h 中定义\n最后, 进程通过调用 c l o s e 函数关闭一个打开的文件。\n#include \u0026lt;unistd.h\u0026gt;\nint close(int fd);\n返回: 若 成 功 则 为 o, 若 出 错 则 为 一1。\n关闭一个已关闭的描述符会出错。 # _`练习题 10. 1 下面程序的输出是什么?\n1 #include \u0026ldquo;csapp.h\u0026rdquo; 2 . 3 int main() 4 { s int fd1, fd2; 6 7 fd1 = Open(\u0026ldquo;foo.txt\u0026rdquo;, O_RDONLY, 0); 8 Close(fdl); 9 fd2 = Open(\u0026ldquo;baz.txt\u0026rdquo;, O_RDONLY, O); 10 printf(\u0026ldquo;fd2 = %d\\n\u0026rdquo;, fd2); 11 exit(O); 12 } 4 读和写文件\n应用程序是通过分别调用r e a d 和 wr i t e 函数来执行输入和输出的。\n#include \u0026lt;un i s t d . h \u0026gt;\nssize_t read(int fd, void *buf, size_t n);\n返回: 若 成 功 则为读的 字节数 , 若 EOF 则为 o, 若 出错 为 一1。\nssize_t write(int fd, const void *buf, size_t n);\n返回: 若 成 功 则为 写的 字节数 , 若出错 则为 一1。\nr e ad 函数从描述符为 f d 的当前文件位置复制最多 n 个字节到内存位置 bu f 。返回值- 1\n表示一个错误,而返 回值 0 表示 EO F。否则 , 返回值表示的是实际传送的字节数量。 # W豆 t e 函数从内存位置 b uf 复制至多 n 个字节到描述符 f d 的当前文件位置。图 10-3 展\n示了一个程序使用r e a d 和 wr i t e 调用一次一个字节地从标准输 入复制到标准输出。\ncodeliolcpstdin.c # #include \u0026ldquo;csapp. h\u0026rdquo;\n2\n3 int main(void) # 4 {\n5 char c;\n6\n7 while(Read(STDIN_FILENO, \u0026amp;c, 1) != 0)\n8 Write(STDOUT_FILENO, \u0026amp;c, 1); # 9 exit(O);\n10 }\ncode/io/cpstdin.c # 图 10-3 一次一个字节地从标准输入复制到标准输出\n通过调用 l s e e k 函数, 应用程序能够显示地修改当前文件的位置, 这部分内容不在我们的讲述范围之内。 # 田日ss ize _t 和 s ize _t 有些什么区别?\n你可能 已经 注意到 了, r e a d 函数有一个 s i z e _ t 的输入参数和一个 s s i ze _ t 的返回值。那么这两种类 型之 间 有什 么区 别呢?在 x8 6-64 系统 中,s i ze _ 七被定义为 un ­ signed long, 而 s s i z e _ t ( 有符号的 大小)被定义为 l o ng 。r e a d 函数返回一个有符号的大小, 而不是 一个无符号 大小,这是 因 为 出错时它必须返 回 一1 。 有趣的是, 返回一个— 1 的可能性使得 r e a d 的最大值 减小 了一半。\n在某些情况下 ,r e a d 和 wr i t e 传送的字节比应用程序要求的要少。这些不足 值( short cou nt ) 不表示有错误 。出现这样情况的原 因有:\n读时遇到 E O F 。假设 我们准备读一个文件,该 文件从当前文件位置开始只含有 20 多个字节, 而我们以 50 个字节的 片进行读取。这样一来,下 一个r e a d 返回的不足值为 20 , 此后的r e a d 将通过返回不足值 0 来发出 E O F 信号。 从终端读文本行。如果打开文件是与终端相关联的(如键盘和显示器),那么每个 # r e a d 函数将一次传送一个 文本行,返 回的不足值等于文本行的大小。\n读和写网络套接 字 ( sock et ) 。如果打开的 文件对应于网络套接字( 11. 4 节), 那么内部缓冲约束 和较长的网络延迟会引起r e a d 和 wr i t e 返回不足值。对 Lin ux 管道 ( pipe) 调用r e a d 和 wr i t e 时,也 有可能 出现不足值, 这种进程间 通信机制不在我们讨论的范围之内。 实际上, 除了 EO F , 当你在读磁盘文件时,将不会遇到不足值,而且在写磁盘文件时, 也不会遇到不足值。然而,如果你想创建健壮的(可靠的)诸如 Web 服务器这样的网络应用, 就必须通过反复调用 r e ad 和 wr i t e 处理不足值, 直到所有需要的字节都传送完毕。 # 10. 5 用 RIO 包健壮地读写\n在这一小 节里, 我们会讲述一个 1/0 包, 称为 R IO ( Robus t 1/ 0 , 健壮的 1/0 ) 包, 它\n会自动为你处理上文中所述的不足值。在像网络程序这样容易出现不足值的应用中, RIO # 包提供了方便、健壮和高效的 I/ 0 。RIO 提供了两类不同的函数:\n无缓冲的输入输出函数。这些函数直接在内存和文件之间传送数据,没有应用级缓冲。它们对将二进制数据读写到网络和从网络读写二进制数据尤其有用。 # 带缓冲的输入函数。这些函数允许你高效地从文件中读取文本行和二进制数据,这 些文件的内容缓存在应用级缓冲区内, 类似千为 pr i n t f 这样的标准 I/ 0 函数提供的缓冲区。与[ ll O] 中 讲 述 的 带 缓 冲的 I/ 0 例程不同,带 缓 冲的 RIO 输入函数是线程安全的(1 2. 7. 1 节),它在同一个描述符上可以被交错地调用。例如,你 可以从一个描述符中读一些文本行, 然后读取一些二进制数据,接 着 再 多 读取一些文本行。 我们讲述 RIO 例程有两个原因。第一,在接 下 来的两章中, 我们开发的网络应用中使用了它们;第 二 ,通 过学 习 这 些 例 程 的 代码,你 将 从 总体 上 对 Unix I/ 0 有更深入的了解。 # 10. 5. 1 R IO 的 无 缓 冲 的 输 入 输 出 函 数\n通过调用r i o_r ea dn 和r i o_wr i t e n 函数 , 应用程序可以在内存和文件之间直接传送数据。\n#include \u0026ldquo;csapp.h\u0026rdquo;\nssize_t rio_readn(int fd, void *usrbuf, size_t n); ssize_t rio_writen(int fd, void *usrbuf, size_t n);\n返回: 若 成 功 则为 传 送的 字 节数 , 若 EOF 则 为 0 ( 只对r i or_ ea dn 石言), 若 出错 则 为 一1 。\n豆 0 —r e a d n 函数 从 描 述 符 f d 的 当 前 文 件 位置最多传送 n 个字节到内存位置 u sr b u f 。类似地,r i o_ wr i t e n 函数 从 位置 u sr b u f 传送 n 个字节到描述符 f d 。r i o _r e a d 函数在遇到 EOF 时只 能返回一个不足值。r i o _ wr i t e n 函 数 决 不 会 返回不足值。对同一个描述符,\n可以任意交错地调用 rio readn 和 \u0026lsquo;rio wr i t e n 。\n图 1 0- 4 显 示了 r i o _r e a d n 和r i o _ wr i t e n 的 代码。注意, 如 果 r i o _ r e a d n 和r i o _ wr i e n 函数被一个从应用信号处理程序的返回中断,那 么 每个函数都会手动地重启r e a d 或 wr i t e 。 为了尽可能有较好的可移植性, 我们允许被中断的系统调用, 且在必要时重启它们。\n5. 2 R IO 的 带 缓 冲的 输入 函数 假设我们要编写一个程序来计算文本文件中文本行的数量,该如何来实现呢?一种方 法就是用r e a d 函数来一次一个字节地从文件传送到用户内存,检 查每个字节来查找换行符。这个方法的缺点是效率不是很高, 每读取文件中的一个字节都要求陷入内核。 # 一种更好的方法是调用一个包装函数(r i o_r ea dl i ne b) , 它从一个内部读缓冲区复 制一个文本行,当缓 冲区变空时,会 自动 地调用r e ad 重新填满缓冲区。对于既包含文本行也包含二进制数据的文件(例如11. 5. 3 节中描述的 HTTP 响应), 我们也提供了一个r i o_ r e adn 带缓冲区的版本 ,叫做 r i o _r e a dnb , 它从 和r i o_r e a dl i ne b 一样的读缓冲区中传送原始字节。\n#include \u0026ldquo;csapp.h\u0026rdquo;\nvoid rio_readinitb(rio_t *rp, int fd);\nssize_t rio_readlineb(rio_t *rp, void *usrbuf, size_t maxlen); ssize_t rio_readnb(rio_t *rp, void *usrbuf, size_t n);\n返回: 无 。\n返回: 若 成 功 则 为 读的 宇节数 , 若 EOF 则 为 o , 若 出 错 则 为 — l 。\nssize_t rio_readn(int fd, void *usrbuf, size_t n) # 2 {\n3 size_t nleft = n; # 4 ssize_t nread;\ns char *bufp = usrbuf;\n6\n7 while (nleft \u0026gt; 0) { # 8 if ((nread = read(fd, bufp, nleft)) \u0026lt; 0) {\ncode/s吹r\nsapp.c # 9 if (errno == EINTR) I* Interrupted by sig handler return *I\n1o nread = 0; / * and call read() again */\n11 else\n12\n13 }\nreturn -1; # I* errno set by read() *I\nelse if (nread == 0) break; I* EDF *I\nnleft -= nread;\nbufp += nread;\n18 }\n19\n20 }\nreturn (n - nleft); # I* Return\u0026gt;= 0 *I\ncode/srdcsapp.c\nssize_t rio_writen(int fd, void *usrbuf, size_t n)\n2 {\n3 size_t nleft = n; # 4 ssize_t nwritten;\n5 char *bufp = usrbuf;\n6\n7 while (nleft \u0026gt; 0) { # 8 if ((nwritten = write(fd, bufp, nleft)) \u0026lt;= 0) {\ncode/srd csapp.c\n9 if (errno == EINTR) I* Interrupted by sig handler return *I\nn江 i t t en = 0; I* and call write() again *I else 12\n13 }\nreturn -1; I* errno set by write() *I # nleft 一= nwritten;\nbufp += nwritten;\n16 }\n17 return n;\n18 }\ncode/srdcsapp.c # 图 10-4 r i o—r ead n 和 r i o_wr 止 e n 函数\n每打开一个 描述符, 都会调用一次r i o_r e a d i n i t b 函数。它将描述符 f d 和地址 r p\n处的一个类型为r i o _ t 的读缓冲区联系起来。\nr i o_r e a d l i ne b 函数从文件r p 读出下一个文本行(包括结尾的换行符), 将它复制到内 存位置 usr b u f , 并且用 NU L L( 零)字符来结束这个文本行。r i o_r e a d l i ne b 函数最多读 ma x l e n - 1 个字节,余 下的 一个字符留给结尾的 NU LL 字符。超过 ma x l e n - 1 字节的文\n本行被截断, 并用一个 N U L L 字符结束。\nr i o _r e a d nb 函数从文件r p 最多读 n 个字节到内存位置 u sr b u f 。对同一描述符, 对r i o_ r e a d l i n e b 和r i o_ r e a d n b 的调用可以任意交叉 进行。然而,对 这些带 缓冲的函数的调用却不应 和无缓冲的 r i o _ r e a d n 函数交叉使用。\n在本书剩下的部分中将给出大鼠的 RIO 函数的示例。图 10-5 展示了如何使用 RIO 函数来一次一行 地从标准输入复制一个文本文件到标准输出。 # code/io/cpfile.c\n#include \u0026ldquo;csapp. h\u0026rdquo;\n2\n3 int main(int argc, char **argv)\n4 {\n5 int n;\n6 r. 1o_t r10;\n7 char buf[MAXLINE];\n8\nRio_readinitb(\u0026amp;rio, STDIN_FILENO);\nwhile((n = Rio_readlineb(\u0026amp;rio, buf, MAXLINE)) != 0)\nRi o_wr i t en (ST DOUT_FI LENO, buf, n);\n12 }\ncode/io/cpfile.c\n图 10-5 从标准输入复制一个文本文件到标 准输出\n图 10-6 展示了一个读缓冲区 的格式,以 及初始化它的r i o _r e a d i n i t b 函数的代码。rio r e a d i n i t b 函数创建了一个空的读缓冲区, 并且将一个打开的 文件描述符和这个缓冲区联系起来 。\n#define RIO_BUFSIZE 8192\n· t ypede f struct {\nint rio_fd;\n4 int rio_cnt;\nchar *rio_bufptr;\ncharr i o_buf [RIO_BUFSI ZE] ;\n} rio_t;\ncodelinclude/csapp.h\nI* Descriptor for this internal buf *I I* Unread bytes in internal buf *I\nI* Next unread byte in internal buf *I I* Internal buffer *I\ncodelinclude/csapp.h\nvoid rio_readinitb(rio_t *rp, int fd)\n2 {\n3 rp-\u0026gt;rio_fd = fd;\n4 rp-\u0026gt;rio_cnt = O;\n5 rp-\u0026gt;rio_bufptr = rp-\u0026gt;rio_buf;\n6 }\ncode/srdcsapp.c\ncode/srdcsapp.c\n图 10-6 一个类型为 r i o_t 的读缓 冲区和初始化它的r i o_r eadi ni t b 函数\nRIO 读程序的核心是图 10-7 所示的r i o _r e a d 函数。r i o_r e a d 函数是 L in uxr e a d 函数的带缓冲的版本。当调用r i o _r e a d 要求读 n 个字节时, 读缓冲区内 有r p - \u0026gt; 豆 o _ c n t\n个未读字节。如果缓冲区 为空, 那么会通过调用r e a d 再填满它。这个r e a d 调用收到一 个不足值并 不是错误,只 不过读缓 冲区是填充了一部分。一旦缓冲区非空,r i o _r e a d 就从读缓冲区复制 n 和r p - \u0026gt; rio _c nt 中较小值个字节到用户缓冲区, 并返回复制的字节数。\ncode/srdcsapp.c # static ssize_t rio_read(rio_t *rp, char *usrbuf, size_t n)\n2 {\nint cnt; # 5 while (rp-\u0026gt;rio_cnt \u0026lt;= 0) { I* Refill if buf is empty *I\n6 rp-\u0026gt;rio_cnt = read(rp-\u0026gt;rio_fd, rp-\u0026gt;rio_buf, sizeof(rp-\u0026gt;rio_buf));\nif (rp-\u0026gt;rio_cnt \u0026lt; 0) { if (errno != EINTR) I* Interrupted by sig handler return *I 1o return -1; # }\n12 else if (rp-\u0026gt;rio_cnt == 0) I* EDF *I return O; # else\nrp-\u0026gt;rio_bufptr = rp-\u0026gt;rio_buf; I* Reset buffer ptr *I\n16\nI* Copy min(n, rp-\u0026gt;rio_cnt) bytes from internal buf to user buf *I # cnt = n; if (rp-\u0026gt;rio_cnt \u0026lt; n) cnt = rp-\u0026gt;rio_cnt; # memcpy(usrbuf, rp-\u0026gt;rio_bufptr, cnt);\nrp-\u0026gt;rio_bufptr += cnt;\nrp-\u0026gt;rio_cnt -= cnt;\nreturn cnt;\n26 }\ncode/srdcsapp.c # 图 10-7 内 部的r i o_r ead 函数\n对千一个应用 程序,r i o_r e a d 函数和 Lin uxr e a d 函数有同样的语义。在出错时,它返回值- 1 , 并且适当地设置 e rr no 。在 E O F 时,它 返回值 0。如果要求的字节数超过了读缓冲区内 未读的字节的数量, 它会返回一个不足值。两个函数的相似性使得很容易通过用r i o _r e a d 代替 r e a d 来创建不同类型的带缓冲的 读函数。例如,用 r i o _ r e a d 代替\nread, 图10-8中的r i o_r e a d nb 函数和r i o—r e a d n 有相同的结构。相似地,图 10-8 中的\n立 o _r e a d l i ne b 程序最多 调用 ma x l e n - 1 次r i o_r e a d。每次调用都从读缓冲区 返回一个字节,然后检查这个字节是否是结尾的换行符。\n田日RIO 包的起源\nR IO 函数的灵感来自 于 W. Richard Stevens 在他的经典 网络 编程作品[ ll O] 中描述的r e a d l i ne 、r e a d n 和 wr i t e n 函数。r i o _r e a d n 和r i o _ wr i t e n 函数与 S t e vens 的 r e a d n 和 wr i t e n 函数是一样的。然而, S te vens 的r e a d l i ne 函数有一些局 限性在 RIO 中得到 了纠 正。笫一 , 因为r e a d l i ne 是带缓 冲的 , 而r e a d n 不带, 所以这两个函数不能在同一描 述符上一起使用。 第二,因为它使 用一 个 s t a t i c 缓冲区, Ste vens 的 r e a dl i ne\n函数不是线程安全的, 这 就要 求 S te ve n s 引入一个不同的 线程 安全的 版本 , 称 为 r e a d ­ 且 n e _ r 。我 们已经在r i o r e a d l i n e b 和 r i o _r e a d n b 函数 中修 改 了 这 两 个缺 陷, 使 得这两个函数是相互兼容和线程安全的。\ncode/sr吹 sapp.c ssize_t rio_readlineb(rio_t *rp, void *usrbuf, size_t maxlen)\n2 {\n3 int n, re;\n4 char c, *bufp = usrbuf;\n5\n6 for (n = 1; n \u0026lt; maxlen; n++) {\n7 if ((re= rio_read(rp, \u0026amp;c, 1)) == 1) {\n8 *bufp++ = c;\n9 if (C == 1 \\n 1) {\n10 n++;\n11 break;\n12 }\n13 } else if (re == 0) {\n14 if (n == 1)\nreturn O; I* EDF, no data read *I else 17\n18 } else\nbreak;\nI* EDF, some data was read *I\n19\n20 }\nreturn -1;\nI* Error *I\n*bufp = O;\nreturn n-1,·\n23 }\ncode/srdcsapp.c\ncode/srdcsapp.c\n1 ssize_t rio_read.nb(rio_t *rp, void *usrbuf, size_t n)\n2 {\n3 size_t nleft = n;\n4 ssize_t nread;\ns char *bufp = usrbuf;\n6\nwhile (nleft \u0026gt; 0) {\nif ((nread = rio_read(rp, bufp, nleft)) \u0026lt; 0)\n9 return -1; I* errno set by read() *I\n1o else if (nread == 0)\n11\n12\n13\n14 }\nbreak; nleft -= nread; bufp += nread;\nI* EDF *I\n15\n16 }\nreturn (n - nleft);\nI* Return\u0026gt;= 0 *I\ncode/s吹r sapp.c\n图10-8 r i o_r eadl i ne b 和r i o_r ea dnb 函数\n10. 6 读取文件元数据\n应用程序能够通过调用 s t a t 和 f s t a t 函数, 检索到关 千文件的信息(有时也称为文\n件 的 元 数 据 ( m e t a d a t a ) ) 。\n#include \u0026lt;unistd.h\u0026gt;\n#include \u0026lt;sys/stat.h\u0026gt;\nint stat(const char *filename, struct stat *buf); int fstat(int fd, struct stat *buf);\n返回: 若 成 功 则为 o, 若 出错 则为 一1。\ns t a 七函数以一个文件名作为输入, 并填写如图 1 0- 9 所示的一个 s t 扛 数据结构中的各个成员。f s t a t 函数是相似的,只 不过是以文件描述符而不是 文件名作为输 入。当我们在 11. 5 节中讨论 W e b 服务器时 , 会需要 s t a t 数据结构中的 s t _ mo d e 和 s t _ s i z e 成员, 其他成员则不在我们的讨论之列。\nstatbuf h (included by sys/stat.h)\nI* Metadata returned by the stat andfstat functions *I struct stat {\ndev_t st_dev;\nino_t st_ino;\nmode_t st_mode;\nnlink_t st_nlink;\nuid_t st_uid;\ngid_t st_gid;\ndev_t st_rdev;\noff_t st_size;\nI* Device *I I* inode *I\nI* Protection and file type *I\n/* Number of hard links*/ I* User ID of owner *I\n/* Group ID of owner*/\n)* Device type (if inode device */ I* Total size, in bytes *I\nunsigned long st_blksize; /* Block size for filesystem I/□ */\nunsigned long st_blocks; I* Number of blocks allocated *I\ntime_t time_t time_t\n};\nst_atime; st_mtime; st_ctime;\n/* Time of last access*/\n/* Time of last modification*/ I* Time of last change *I\nstatbuf h (included by sys/stat.h)\n图 10-9 s t a t 数据结构\ns t _ s i z e 成员包含了文件的字节数大小。s t _ mo d e 成员则编码了文件访问许可位(图1 0- 2 ) 和文件类型(1 0. 2 节)。L in u x 在 s y s / s t a 七 . h 中定义了宏谓词来确定 s t _ mo d e 成员的文件类型:\nS_IS REG ( m) 。这是一 个普通文件吗? S_ISDIR ( m) 。这是一 个目录文件吗? S_ISSOCK ( m) 。这是 一个网络套接字吗?\n图 10-1 0 展示了我们会如何使用这些宏和 s t a t 函数来读取和解释一个文件的 s t mo d e 位。\ncode/iolstatcheck.c\n1 #include \u0026ldquo;csapp.h\u0026rdquo;\n2\n3 int main (int argc, char **argv)\n4 {\nstruct stat stat;\nchar *type, *readok;\n7\nStat(argv[1], \u0026amp;stat);\nif (S_ISREG (stat.st_mode)) I* Determine file type *I\ntype = \u0026ldquo;regular\u0026rdquo;;\nelse if (S_ISDIR(stat.st_mode))\ntype = \u0026ldquo;directory\u0026rdquo;;\nelse\ntype = \u0026ldquo;other\u0026rdquo;;\nif ((stat.st_mode \u0026amp; S_IRUSR)) I* Check read access *I\nreadok = \u0026ldquo;yes\u0026rdquo;; 17 else\n18 readok = \u0026ldquo;no\u0026rdquo;;\n19\nprintf(\u0026ldquo;type: %s, read: %s\\n\u0026rdquo;, type, readok);\nexit(O); 22 }\n图 10 -1 0 查询和处理一个文件的 s t _mode 位\n7 读取目录内容\n应用程序可以用 r e a d d ir 系列函数来读取目录的内容。\n#include \u0026lt;sys/types.h\u0026gt;\n#fnclude \u0026lt;dirent.h\u0026gt;\ncode/iols ta tcheck . c\nDIR *opendir(const char *name);\n返回: 若 成 功 , 则为 处理的 指针 ; 若 出错 , 则 为 NU LL 。\n函数 o p e n d i r 以路径名为参数, 返回指向目 录流 ( di r ec t o r y s t r ea m ) 的指针。流是对条目有序列表的抽象,在这里是指目录项的列表。\n#include \u0026lt;dirent.h\u0026gt;\nstruct dirent *readdir(DIR *dirp);\n返回: 若 成 功, 则 为 指 向 下 一 个 目 录项的指针 ; 若 没 有 更 多的 目 录 项或 出错 , 则 为 NU LL 。\n每次对r e a d d ir 的调用返回的都是指向流 d ir p 中下一个目录项的指针, 或者, 如果没有更多目 录项则 返回 NU L L 。每个目录项都是一个结构 , 其形式如下:\nstruct dirent {\nino_t d_ino; I* inode number *I char d_name[256]; I* Filename *I\n};\n虽然有些 L i n u x 版本包含了其他的结构成员, 但是只有这两个对所有系统来说都是标\n准的。成员 d _n a me 是文件名 , d _ i n o 是文件位 置。\n如果出错, 则r e a d d ir 返回 N U L L , 并设置 err n o 。可惜的是,唯 一能区分错误和流结束情况的方法是检查 自调用r e a dd i r 以来 err no 是否被修改过。\n#include \u0026lt;dirent.h\u0026gt;\nint closedir(DIR *dirp);\n返回: 成 功为 O; 错 误 为 一1.\n函数 c l o s e d ir 关闭流并释放其 所有的资 源。图 1 0-11 展示了怎样用r e a d d i r 来读取目录的内容。\n1 #include \u0026ldquo;csapp.h\u0026rdquo;\n2\n3 int main(int argc, char **argv)\n4 {\ns DIR *Streamp;\n6 struct dirent *dep;\n7\n8 streamp = Opendir (argv [1]) ;\n9\nerrno = O;\nwhile ((dep = readdir(streamp)) != NULL) {\nprintf(\u0026ldquo;Found file: %s\\n\u0026rdquo;, dep-\u0026gt;d_name);\n13 }\n14 if (errno != 0)\n15 unix_error(\u0026ldquo;readdir error\u0026rdquo;);\n16\n17 Closedir (st reamp) ;\n18 exit (0);\n19 }\ncode/io/readdir.c\ncode/io/readdir.c\n图 10-11 读取目录的内容\n8 共享文件 可以 用许多不同的方式来共享 L in ux 文件。除非你很清楚 内核是如何表示打开的文件, 否则文件共享的概念相当难懂。内核用三个相关的数据结构来表示打开的文件: # 描述符表( des crip to r t a b le ) 。每个进程都有它独立的描述符表,它 的 表项是由进程打开的文件描述符来索引的。每个打开的描 述符表项指向文件表中 的一个表项。\n文件表 ( fi le t a ble ) 。打开文件的集合是由一张文件表来表示的, 所有的 进程共享这张表。每个文件表的表项组成(针对我们的目的)包括当前的文件位置、引用计数(ref erenee count)(即当前指向该表项的描述符表项数), 以及一个指向 v- node 表中对应表项的指针。关闭一个描述符会减少相应的文件表表项中的引用计数。内核不会删除这个文件表表项, 直到它的引用 计数为零。 v-node 表( v- node ta ble ) 。同文件表一样, 所有的进程共享这张 v- node 表。每个表项包含 s 七a t 结 构中的大多数信息, 包括 s t _mo d e 和 s t _s i ze 成员。\n图 10-1 2 展示了一个示例, 其中描述符 1 和 4 通过不同的打开文件表表项来引用两个\n不同的文件。这是一种典型的情况,没有共享文件,并且每个描述符对应一个不同的文件。 # 描述符表\n( 每个进程一张表)\ns t di n fd 0 s t dout fd 1 S 七de rr fd 2 # fd 3\nfd 4\n打开文件表\n( 所有进程共享) 文件 A\nv-node表\n(所有进程共享)\n图 10-12 典 型 的 打 开 文 件 的 内 核 数 据 结 构 。 在这个示例中, 两 个 描 述 符引 用 不 同 的 文 件 。 没 有 共 享\n如图 10-13 所示,多 个描述符也可以通过不同的文件表表项来引用同一个文件。例如, 如果以同一个 巨 l e na me 调用 ope n 函数两次, 就会发生这种情况。关键思想是每个描述符都有它自己的文件位置,所以对不同描述符的读操作可以从文件的不同位置获取 数据。 # 描述符表\n(每个进程一张表 )\n打开文件表\n(所有进程共享) 文件A\nv-node表\n(所有进程共享)\n图 10-13 文件共享。这个例子展示了两个描述符通过两个打开文件表表项共享同一个磁盘文件\n我们也能理解父子进程是如何共享文件的 。假设在调用 f or k 之前, 父进程有如图 10-12 所示的打开文件。然后 , 图 1 0-1 4 展示了调用 f or k 后的情况。子进程有一个父进程描述符表的副本。父子进程共享相同的打开文件表集合 , 因此共享相同的文件位置。一个很重要的结果就是,在内核删除相应文件表表项之前,父子进程必须都关闭了它们的描 述符。 # 描述符表父进程的表\n打开文件表\n(所有进程共享) 文件 A\nv-node 表\n(所有进程共享)\n图 10-14 子进程如何继承父进程的打开文件。初始状态如图 10-12 所示\n沁目 练习题 10. 2 假设 磁 盘 文件 f o o b ar . t x t 由 6 个 ASCII 码 字符 \u0026quot; f o o b ar \u0026quot; 组成。那么,下列程序的输出是什么?\n1 #include \u0026ldquo;csapp.h\u0026rdquo;\n2\n3 int main()\n4 {\ns int fd1, fd2;\n6 char c;\n7\n8 fd1 = Open(\u0026ldquo;foobar.txt\u0026rdquo;, O_RDONLY, O);\n9 fd2 = Open(\u0026ldquo;foobar.txt\u0026rdquo;, O_RDONLY, 0); 10 Read(fd1, \u0026amp;c, 1);\n11 Read(fd2, \u0026amp;c, 1);\n12 printf(\u0026ldquo;c = o/.c\\n\u0026rdquo;, c);\n13 exit(O); 14 }\n已 练习题 10. 3 就像前面那 样,假 设 磁盘文件 f oob ar . t 江 由 6 个 ASCII 码 字符 \u0026quot; f ooba r\u0026rdquo;\n组成。那么下列程序的输出是什么?\n#include \u0026ldquo;csapp.h\u0026rdquo;\n2\n3 int main()\n4 {\nint fd;\nchar c;\n7\n8 fd = Open(\u0026ldquo;foobar.txt\u0026rdquo;, O_RDONLY, O);\n9 if (Fork() == 0) {\nRead(fd, \u0026amp;c, 1);\nexit(O);\n12 }\nWait (NULL) ;\nRead(fd, \u0026amp;c, 1);\nprintf(\u0026ldquo;c = %c\\n\u0026rdquo;, c);\nexit(O);\n17 }\n10. 9 1/ 0 重定向\nLinux shell 提供了 I/ 0 重定向操作符,允 许用户将磁盘文件和标准输入输出联系起来。例如,键入\nlinux\u0026gt; ls\u0026gt; foo.txt # 使得 s hell 加载和执行 l s 程序, 将标准输出重定向到磁盘文件 f o o . t x 七。 就如我们将在\n5 节中看到的那样, 当一个 Web 服务器代表客户端运行 CGI 程序时 ,它 就执行一种相似类型的重定向 。那么1/0 重定向是如何工作的呢?一种方式是使用 d up 2 函数。 #include \u0026lt;unistd.h\u0026gt; # int dup2(int oldfd, int newfd);\n返回: 若 成 功 则为 非 负的 描 述 符 , 若 出错 则 为 一1。\nd up 2 函数复制描述符表表项 o l d f d 到描述符表表项 ne wf d , 覆盖描述符表表项 ne w­ f d 以前的内容。如果 ne wf d 已经打开了, d up 2 会在复制 o l d f d 之前关闭 ne wf d 。\n假设在调用 d up 2 ( 4 , 1 ) 之前,我 们的状态如图 10-1 2 所示, 其中描述符 1 ( 标准输出) 对应于文件 A( 比如一个终端), 描述符 4 对应于文件 B( 比如一个磁盘文件)。A 和 B 的引 用计数都等千 1 。图 1 0-1 5 显示了调用 dup 2 ( 4, 1 ) 之后的情况。两个描述符现在都指向文件 B ; 文件 A 已经被关闭 了,并 且它的文件表和 v- node 表表项也已经被删除了; 文件 B 的引用计数已经增加了。从此以后, 任何写到标准输出的数据都被重定向到文件 B。\n描述符表\n打开文件表\nv-node 表\n..\nfdO\nI ,L\u0026mdash;\u0026mdash;\n:文件访问!\nfd 1\n,I \u0026mdash;\u0026mdash;\u0026mdash;\u0026ndash;一一' 卜 ,\n:文件位置! :文件大小!\nfd 2\n卜\u0026mdash;\u0026mdash;\u0026mdash;-一, I # t\u0026mdash;\u0026mdash;\u0026mdash;\u0026mdash;-·\nfd 3\n:refcnt=o: : 文件类型!\n卜 ------一一-一一-1 卜, ,\nfd 4\n,,I:'\nI• I 图 10-15 通过调用 dup2 (4, l ) 重 定 向 标 准 输 出 之 后 的 内 核 数 据结 构 。 初始状态如图 10-12 所 示\n日 日 左边和右边的 ho ink ie s\n为了 避免和其他括号 类型操作符比如 \u0026quot; J\u0026quot; 和 "[” 相混淆, 我们总是将 s hell 的 \u0026ldquo;\u0026gt; \u0026quot; 操作符称为“右 hoin k y\u0026rdquo; , 而将 \u0026ldquo;\u0026lt; \u0026quot; 操作符称 为“ 左 hoin k y\u0026rdquo; 。\n; 练习题 10. 4 如何 用 d up2 将标 准输入 重定 向到描述 符 5?\n芦 练习题 10. 5 假设磁 盘 文件 f o o b ar . t x t 由 6 个 ASC II 码 字符 \u0026quot; f o o b ar \u0026quot; 组 成, 那么下列程序的输出是什么?\n#include \u0026ldquo;csapp.h\u0026rdquo; # 2\n3 int main() # 4 { 5 int fdl, fd2; 6 char c; 7 8 fd1 = Open(\u0026ldquo;foobar.txt\u0026rdquo;, O_RDONLY, 0); 9 fd2 = Open(\u0026ldquo;foobar.txt\u0026rdquo;, O_RDONLY, O); 10 Read(fd2, \u0026amp;c, 1); 11 Dup2(fd2, fdl); 12 Read(fd1, \u0026amp;c, 1); 13 printf(\u0026ldquo;c = %c\\n\u0026rdquo;, c); 14 exit(O); 15 }\n10. 10 标准 1/ 0\nC 语言定义了一组高级输入输出函数,称 为标准 I/ 0 库 , 为程序员提供了 U nix I/0 的较高级别的替代。这个库( li b c ) 提供了打开和关闭文件的函数 ( f o p e n 和 f c l o s e ) 、读和写字节的函数 ( fr e a d 和 f wr i t e ) 、 读 和 写 字 符 串 的 函 数 ( f g e t s 和 f p u t s ) , 以及复杂的格式化的 I/ 0 函数 Cs c a n f 和 pr i n t f ) 。\n标 准 I/ 0 库将一个打开的文件模型化为一个流。对于程序员而言,一 个 流就是一个指 # 向 FI LE 类型的结 构的指针。每个 ANSI C 程序开始时都有三个打开的流 s 七d i n 、 S 七d ou t\n和 s t d err , 分别对应于标准输入、标准输出和标准错误:\n#include \u0026lt;s t d i o . h \u0026gt;\nextern FILE *stdin; I* Standard input (descriptor 0) *I extern FILE *stdout; I* Standard output (descriptor 1) *I extern FILE *stderr; I* Standard error (descriptor 2) *I\n类型为 FILE 的 流是对文件描述符和流缓 冲区的 抽象 。 流缓 冲区的目的和 RIO 读缓冲区 的 一样: 就 是 使 开销较高的 Lin ux 1/0 系统调用的数量尽可能得小。例如, 假设我们有一个 程序, 它反复调用标准 1/0 的 g e t c 函数 , 每次调用返回文件的下一个字符。当第一\n次调用 g e t c 时 ,库 通过调用一次r e a d 函数来填充 流缓 冲区, 然 后 将 缓 冲区中的第一个字节 返回给应用程序。只要缓冲区中还有未读的字节, 接 下 来对 g e t c 的调用就能直接从流缓冲区得到服务。\n10. 11 综合: 我该使用哪些 1/ 0 函数?\n图 10-16 总结了我们在这一章里讨论过的各种 1/ 0 包。\nfopen fread f s can f sscanf f ge 七s\nf dop en fwrite f pr i nt f sprintf\nfputs\nr· 飞 C应用程序\nfflush f c l os e\nopen wr i 七e stat\nfseek \\\u0026hellip; \u0026hellip;.\nread l seek close\n标准VO 函数 RIO 函数\nUnix 1/0 函数\n(通过系统调用来访问)\n图 10- 1 6 U ni x I / 0 、标准 I / 0 和 RIO 之间的关系\nUnix 1/0 模型是在操作系统内核中实现的。应用程序可以通过诸如 op e n、c l o s e 、l s e e k、r e a d、wr i t e 和 s t a t 这样的函数来访问 U nix 1/0 。较高级别的 RIO 和标准I/ 0 函数都是基于(使用) Unix I/ 0 函数来实现的。RIO 函数是专为本书开发的r e a d 和 wr i t e 的健壮的包装函数 。它们自动处理不足值, 并且为读 文本行提供一种高效的带缓冲的 方法。标准 1/0 函数提供了 U nix I/ 0 函数的一个更加完整的 带缓冲的替代品, 包括格式化的 1/ 0 例程, 如 pr i nt f 和 s c a n f 。\n那么, 在你的程序中该使用这些函数中的哪一个呢?下面是一些 基本的指导原则: # Gl: 只 要有可能就使用标准 I/ 0 。对磁盘和终端设备 I/ 0 来说, 标准 1/ 0 函数是首选方法。大多数 C 程序员在其整个职业生涯中只使用标准 I/ 0 , 从不受较低级的Unix I/ 0 函数的困扰(可能 s t a t 除外, 因为在标准 1/0 库中没有与它对应的函数)。只要可能 , 我们建议你也这样做。 G2 : 不要使用 s c a n f 或 r i o _ r e a d l i ne b 来读二进制文件 。像 s c a n f 或r i o_r e a d­巨 ne b 这样的函数是专门设计来读取文本文件的。学生通常会犯的一个错误就是用这些函数来读取二进制文件, 这就使得他们的程序出现了诡异莫测的失败。比如, 二进制文件 可能散 布着很多 Oxa 字节, 而这些字节又与终止文 本行无关。 G3: 对网络套 接字的 1/0 使用 RIO 函数。不幸的 是, 当我们试着将标准I/ 0 用千网络的输入输出时, 出现了一些令人讨厌的问题。如同我们将在 11. 4 节所见, L inux 对网络的抽象是一种称为套接字的 文件类型。就像所有的 L in ux 文件一样, 套接字由文件描述符来引用, 在这种情况下称为套 接字描述符。应用程序进程通过读写套接字描述符来与运行在其他计算机的进程实现通信。 # 标准 I/ 0 流,从 某种意义上而言是 全双工的 , 因为程序能够在同一个流上执行输入和\n输出。然而, 对流的限制和对套 接字的限制, 有时候会互相冲突 , 而又极少有文档描述这些现象:\n限制一: 跟在输出 函数之后的 输入函数。如果中间没有插入对 f fl u s h、f s e e k、 f s e t po s 或者r e wi n d 的调用, 一个输 入函数不能跟随在一个 输 出 函数之后。 f fl u s h 函数清空与流相关的缓冲区。后三个函数使用 U nix I/ 0 l s e e k 函数来重置当前的文件位置。 限制二: 跟在输入函数之后的 轮出函 数。如果中间没有插入对 f s e e k、f s e 七p o s 或者 r e wi n d 的调用, 一个输出函数不能跟随在一个输入函数之后, 除非该输入函数遇到了 一个文件结束。 这些限制 给网络应用 带来了一个问题, 因为对套接字使用 l s e e k 函数是非法的。对流I/ 0 的第一个限制能够通过采 用在每个输入操作前刷新缓 冲区 这样的规则来满足。然而, 要满足第二个限制的唯一办法是,对同一个打开的套接字描述符打开两个流,一个用来 读,一个用来写: # FILE *f pi n, *f pout ;\nfpin = f d open (s ockf d , # r\u0026quot; \u0026quot; ) ;\nfpout = fdopen (s ockf d , \u0026ldquo;w\u0026rdquo;) ;\n但是这种方 法也有问题, 因为它 要求应用程序在两个流上都要调用 f c l os e , 这样才能释放与每个流相关联的内存资源, 避免内存泄漏:\nf c l o s e ( f p i n) ;\nf c l os e (f pout ) ;\n这些操 作中的每一个都试图关闭同一个底层的套接字描 述符, 所以第二个 c l o s e 操作就会失败。对顺序的程序来说,这并不是问题,但是在一个线程化的程序中关闭一个已经 关闭了的描述符是会导致灾难的(见12. 7. 4 节)。\n因此, 我们建议你在网络套 接字上不 要使用标准 I/ 0 函数来进行输入和输出, 而要使\n用健 壮的 RIO 函数。如果你需要格式 化的输出,使 用 s p r i n 七f 函数在内存中格式化一个字符串 , 然后用r i o _ wr i t e n 把它发送到套接口。如果你需要格式化输入,使 用 r i o\nr e a d l i n e b 来读一个完整的文 本行, 然后用 ss c a n f 从文本行提取不同的字段。\n10. 12 小结\nLinux 提供了少械的基千 U nix I/ 0 模型的系统级函数.它们允许应用程序打开、关闭、读和写文件, 提取文件的元数据 , 以及执行 I/ 0 重定向。Linux 的 读和写操 作会出现不足值, 应用程序必须能正确地 预计和处理这种情 况。应用 程序不 应直接调用 Unix I/ 0 函数, 而应该使用 RIO 包, RIO 包通过反复执行读写操作,直到传送完所有的请求数据,自动处理不足值。\nLinux 内核使用三个相关的数据结构来 表示 打开的 文件。描述符表中的表项指向打开文件 表中的表项,而打开文件表中的 表项又指向 v-node 表中的表项。每个进程都 有它自己单独的描述符表,而所有的进程共享同一 个打开文件表 和 v-node 表。理解这些结构的一般组成就能使我们 清楚 地理解文件共享和I / 0 重定向。\n标准 I/ 0 库是基于 Unix I/ 0 实现的,并 提供 了一组强大的 高级 I/ 0 例程。对千大 多数应用程序而言 . 标准 I/ 0 更简单, 是优千 U nix I/ 0 的选择。然而 , 因为对标准 I/ 0 和网络文件的一些相互不兼容的限制, U nix I/ 0 比之标准 I/ 0 更该适用于网络应用程序 。\n参考文献说明 # Kerr is k 撰写了关于 Unix I/ 0 和 Linux 文件系统的综述 [ 62] 。S tevens 编写了 Unix I/ 0 的标准参考文献[ 111] 。Kern igh an 和 Ritc hie 对千标准 I/ 0 函数给出了清晰 而完整的讨论[ 61] 。\n家庭作业 # 10. 6 下面程序的输出是什么?\n#include \u0026ldquo;csapp.h\u0026rdquo;\n3 int main()\n4 {\n5 int fdl, fd2;\n6\n7 fdl = Open(\u0026ldquo;f oo. t xt \u0026quot; , O_RDONLY, 0);\nB fd2 = Open(\u0026ldquo;bar.txt\u0026rdquo;, O_RDONLY, O);\n9 Cl os e (f d2) ;一\n10 fd2 = Ope n ( \u0026ldquo;baz . t xt \u0026ldquo;, O_RDONLY, O) ; 11 printf(\u0026ldquo;fd2 = 加 \\n\u0026rdquo;, fd2); 12 exit(O); 13 } 10.7 •• 10. 8\n** 10. 9\n修改图 10-5 中所示的 cpf i l e 程序 , 使得它用 RIO 函数从 标准输入复制到标 准输出 ,一 次 MAX­\nBUF 个字节。\n编写图 10-10 中的 s t a t che c k 程序的 一个版本,叫 做 f s 七a t c he c k , 它从命令行上取得一个描述符数字而不是 文件名。\n考虑下面对作业题 10. 8 中的 f s t a t chec k 程序的调用:\nlinux\u0026gt; fstatcbeck 3 \u0026lt; foo. txt\n你可能会预想这个 对 f s t a t c he c k 的调用将提取和显示文件 f oo . t xt 的元数据。然而,当我们在\n系统上运行它时, 它 将失败,返 回 “ 坏 的 文 件 描 述 符"。 根 据 这 种 情 况 ,填 写 出 s hell 在 f o r k 和\ne xe c v e 调 用 之 间 必 须 执 行 的 伪 代 码 :\nif (Fork() == 0) { I* child•/\n/• What code is the shell executing right here?•/ Execve(\u0026ldquo;fstatcheck\u0026rdquo;, argv, envp);\n•• 10. 10 修改 图 1 0- 5 中 的 c p f i l e 程 序 ,使 得 它 有 一 个 可选的命令行参数 i n fi l e 。如果给定了 i n f i l e , 那么复制 i n fi l e 到标准输出,否则 像 以 前 那 样 复制标准输入到标准输出。一个要求是对于两种情况,你 的解答都必须使用原来的复制循环(第9~ 11 行)。只允许你插人代码, 而 不 允 许 更 改 任何已经存在的代码。\n练习题答案 # 10. 1 U nix 进程生命 周期开始时 ,打 开 的 描 述 符 赋 给了 s t d i 认描述符 0 ) 、s t d o u t ( 描述符 1) 和 s t d err\n(描述符 2 ) 。o p e n 函数总是返回最低的未打开的描述符,所 以 第 一 次 调用 o p e n 会 返 回 描 述 符 3 。调用 c l os e 函数 会释放 描述符 3 。最 后对 ope n 的调用会返回描述 符 3 , 因此程序的输出是 \u0026quot; f d2 = 3\u0026rdquo; 。\n10. 2 描 述 符 f d l 和 f d 2 都 有 各 自 的 打 开文件表表项,所 以 每个描述符对于 f oo b a r . t x t 都有它自己的文件位置。因此,从 f d 2 的读操作会读 取 f o o b ar . t x t 的 第一 个字节,并 输 出\nC = f\n而不是像你开始可能想的\nC = 0\n10. 3 回想一下,子进程会继承父进程的描述符表,以及所有进程共享的同一个打开文件表。因此,描述符 f d 在父子进程中都指向同一个打开文件表表项。当子进程读取文件的第一个字节时,文 件 位置加 1。因此, 父进程会读取第二个字节,而 输出就是\nC 一 。\n10. 4 重定向标准输人(描述符 0 ) 到描述符 5\u0026rsquo; 我们将调用 d up 2 (5 , 0 ) 或 者 等 价 的 d up 2 (5 , STDIN _ F IL E­\nNO) 。\n10 . 5 第一眼你可能会想输出应该是\nC = f\n但是因为我们将 f d l 重定向到了 f d 2 , 输出实际上是\nC = 0\n"},{"id":435,"href":"/zh/docs/technology/System/%E6%B7%B1%E5%85%A5%E7%90%86%E8%A7%A3%E8%AE%A1%E7%AE%97%E6%9C%BA%E7%B3%BB%E7%BB%9F/%E4%B9%A6%E7%B1%8D/%E7%AC%AC11%E7%AB%A0-%E7%BD%91%E7%BB%9C%E7%BC%96%E7%A8%8B/","title":"Index","section":"SpringCloud","content":"第 1 1 章\nC H A P T E R 11 # 网络编程\n网络应用随处可见。任何时候浏览 W eb 、发送 ema il 信息或是玩在线游戏, 你就正在使用网络应用程序。有趣的 是, 所有的网络应用都是基千相同的基本编程模型, 有着相似的整体逻辑结构,并且依赖相同的编程接口。 # 网络应用依 赖于很多在系统 研究中巳经学习过的概念。例如, 进程、信号、字节顺序、内存映射以及动态内存 分配, 都扮演着 重要的角色。还有一些新概念要掌握。我们需要理解基本的客户端-服务器编程模型 , 以及如何编写使 用因特网提供的服务的客户端-服务器程序。最后 , 我们将把所有这些概念结合起来, 开发一个虽小但功能齐全的 Web 服务器 , 能够为真实的 Web 浏览器提供静态和动态的 文本和图形内容。\n1 客户端-服务器编程模型\n每个网络应用都是基千客户端-服务器模型的。采用这个模型,一个应用是由一个服 务器进 程和一个或者多个客户端 进程组 成。服务器管理某种资源, 并且通过操作这种资濒来为它的客户端提供某种服务。例如, 一个 Web 服务器管理着一组磁盘文件, 它会代表客户端进行检索和执行。一个 FT P 服务器管理着一组磁盘文件,它 会为客户端进行存储和检索。相似地 , 一个电子邮件服务器管理着一些文件,它 为客户端进行读和更新。\n客户端-服务器模 型中的基本操作是 事务 ( t ra nsaction )( 见图 11-1) 。一个客户端-服务器事务由以下四步组成。\n当一个客户端需要服务时 , 它向服务器发送一个请求, 发起一个事务。例如,当\nWeb 浏览器需要一个文件时,它 就发送一个请求给 Web 服务器。\n服务器收到请求后,解 释它, 并以适当的方式操作它的资源。例如, 当 Web 服务器收到浏览器发出的请求后, 它就读一个磁盘文件。\n) 服务器给客户端发送 一个响应, 并等待下一个请求。例如, Web 服务器将文件发送回客户端 。\n) 客户端收到响应并处理它。例如, 当 Web 浏览器收到来自服务器的一页后, 就在屏幕上显示此页。\n4. 客户端\n处理响应\n图 11-1 一个客户端-服务器事务\n认 识到客户端和服务器 是进程, 而不是常提到的机器或 者主机 , 这是很重要的。一台主机可以同时运行许多不同的客户端 和服务 器,而 且一个客户端和服务器的事务可以在同一台或是不同的主机上。无论 客户端和服务 器是怎样映射到主机上的,客 户端-服务器模 j 型都是相同的。 # m 客户端-服务器事务与数据库事务 # 客户端-服务器 事务 不是 数 据 库事务 , 没有数据库事务的任何特性, 例如原子性。在我们的上下文中, 事务仅仅是客 户端 和服 务 器执 行 的一 系列 步骤。\n2 网络 客户端和服务器通常运行在不同的主机上,并且通过计算机网络的硬件和软件资源来 通信。网络是很复杂的系统,在 这里我们只想了解一点皮毛。我们的目标是从程序员的角度给你一个切实可行的思维模型。 # 对主机而言,网 络只 是 又一种 I/ 0 设备 ,是 数 据 源和数据接收方,如 图 11-2 所 示 。\n一个插到 I/ 0 总线扩展槽的适配器提供了到网络的物理接口。从网络上接收到的数据从适配器经过 I/ 0 和内 存总线复制到内存 , 通常是通过 DMA 传送。相似地,数 据 也 能 从内存复制到网络。\nCPU 芯片\nRegister file # 言 丿 系统总线 内存总线\n图 11-2 一个网络主机的硬件组成\n物理上而言,网 络 是 一 个 按 照 地理远近组成的层次系统。最低层是 LA N ( Local Area # Network, 局域网),在 一 个 建 筑 或 者 校园范围内。迄今为止, 最 流行的局域网技术是以太网 ( E t hern et ) , 它 是 由 施 乐 公 司 帕 洛阿尔托研究中心 ( Xero x P A RC ) 在 20 世纪 70 年代中期提出的。以太网技术被证明是适应力极强的,从 3 Mb/ s 演变到 l OG b/ s。\n一个以太网段 ( E t hern et seg ment ) 包 括 一 些电缆(通常是双绞线)和一个叫做 集线器的小盒子,如 图 11- 3 所示 。以 太 网 段 通常跨越一些小的 区域, 例 如某建筑物的一个房间或者一个楼层。每根电缆都有相同的最大位带宽, 通常是 l OOMb/ s 或者 l Gb/ s。一端连接到主机的适配器,而另一端则连接到集线器的一个\n端口上。集线器不加分辨地将从一个端口上收到的 每个位复制到其他所有 的端 口上。因此, 每台 主机都 能看到每个 位。\n每个以太网适配器都有一个全球唯一的 48 位 地址,\n它存储在这个适配器的非易失性存储器上。一台主机可 图 11-3 以太网段\n以发送一段位(称为帧( fr am e) ) 到这个网段内的其他任何 主机。每个帧包括一些固定数量的 头部 ( h eade r ) 位,用 来标识此帧的源和目的地址以 及此帧的长度, 此后紧随的就是数据位的有效栽荷 ( pa yloa d ) 。每个 主机适配器都能看到这个帧, 但是只有目的主机实际读取它。\n使 用一些电缆和叫做网桥( bridge ) 的小盒子,多 个以太网段可以连接成较大的局 域网,\n称为桥接以太网 ( b ridged Ethernet), 如图 11-4 所示。桥接以 太网能够跨越整个建筑物或者校区。在一个桥接以太网里,一些电缆连接网桥与网桥,而另外一些连接网桥和集线 器。这些电缆的带宽可以是不同的。在我们的示 例中, 网桥与网桥之间的电缆有 l Gb/ s 的带宽 , 而四根网桥 和集线器之间电缆的带宽却是 l OOM b/ s。 # A\n! Gb/s\n图 11-4 桥接以太网\n网桥比集线器更充分地利用了电缆带宽。利用一种聪明的分配算法, 它们随着时间自动学习哪个主机可以通过哪个端口可达,然后只在有必要时,有选择地将帧从一个端口复 制到另一个端口。例如, 如果主机 A 发送一个帧到同网段上的主机 B, 当该帧到达网桥 X 的 输入端口时, X 就将丢弃此帧, 因而节省了其他网段上的带宽。然而, 如果主机 A 发送一个帧到一个不同网段上的主机 C, 那么网桥 X 只会把此帧复制到和网桥 Y 相连的端口上, 网桥 Y 会只把此帧复制到与主机 C 的网段连接的端口 。 # 为了简化局域网的表示, 我们将把集线器和网桥以及连接它 们的电缆画成一根水平线, 如图 11-5 所示。\n在层次的更 高级别中,多 个不兼容的局域网可以 通过叫做路由器( ro ute r ) 的特殊计算机连接起来, 组成一个 int ern et (互联网络)。每台路由器对于它所连接到的每个网 络都有一个适配器(端口)。路由器也能连接高速点 到点电话连接, 这是称为 W A N ( W啦 -Area\nNetwork, 广域网)的网络示例,之所以这么叫是因为它们覆盖的地理范围比局域网的大。一般而言,路由器可以用来由各种局域网和广域网构建互 联网络。例如 ,图 11- 6 展示了一个互联网络的示例, 3 台路由器连接了一对局域 # 网和一对广域网。 图 11-5 局域网的概念视图\n图 11 - 6 一个小型的互联网络。三台路由器连接起两个局域网和两个广域网\n田日Internet 和 internet # 我们总是用小写字母的 in ter net 描述一般概念, 而 用 大写 字母 的 In ter net 来描 述一种具体的 实现,也 就 是 所谓 的全球 IP 因特 网。\n互联网络至关重要的特性是,它能由采用完全不同和不兼容技术的各种局域网和广域 网组成。每台主机和其他每台主机都是物理相连的,但是如何能够让某台源主机跨过所有 这些不兼容的网络发送数据位到另一台目的 主机呢?\n解决办法是一层运行在每台主机和路由器上的协议软件,它消除了不同网络之间的差 异。这个软件实现一种协议,这 种 协 议 控 制主机和路由器如何协同工作来实现数据传输。这种协议必须提供两种基本能力:\n·命名机制。不同的局域网技术有不同和不兼容的方式来为主机分配地址。互联网络 协议通过定义一种一致的主机地址格式消除了这些差异。每台主机会被分配至少一 个这种互联 网络地址( in te rn et address), 这个地址唯一地标识了这台主机。\n传送机制。在电缆上编码位和将这些位封装成帧方面,不同的联网技术有不同的和 不兼容的方式。互联网络协议通过定义一种把数据位捆扎成不连续的片(称为包)的 统一方式,从 而 消 除 了 这些差异。一个包是由 包头 和有效栽荷组成的,其 中 包 头 包括 包 的 大小 以 及 源主机和目的主机的地址, 有效载荷包括从源主机发出的数据位。\n.图11-7 展示了主机和路由器如何使用互联网络协议在不兼容的局域网间传送数据的一个示例。这个互联网络示例由两个局域网通过一台路由器连接而成。一个客户端运行在 主机 A 上 ,主 机 A 与 LANl 相连,它 发 送 一 串 数 据 字 节 到 运行在主机 B 上的服务器端, 主机 B 则连接在 LAN2 上 。这个过程有 8 个基本步骤:\n1 ) 运行在主机 A 上的客户端进行一个系统调用,从 客户端的虚拟地址空间复制数据到内核缓冲区中。 # 主机 A 上的协议软件通过在数据前附加互联网络包头和 LA Nl 帧 头 ,创 建 了 一 个\nLANl 的帧。互联网络包 头寻址到互联网 络 主机 B。LANl 帧头寻址到路由器。然后它传送此帧到适配器。注意, LANl 帧的 有效 载荷是一个互联网络包,而 互 联网络包的有效载荷是实际的用户数据。这种封装是基本的网络互联方法之一。\nLANl 适配器复制该帧到网络上。\n当此帧到达路由器时,路 由 器 的 L ANl 适 配器从电缆上读取它,并 把 它 传 送 到 协议软件。 # 5 ) 路由 器从互 联 网 络 包 头 中 提 取 出目的互联网络地址,并 用 它 作 为 路 由 表 的 索 引 , 确定向哪里转发这个包,在 本 例 中是 L AN2。路由器剥落旧的 LANl 的帧头 ,加 上 寻 址 到主机 B 的新的 LA N2 帧 头 ,并 把得到的帧传送到适配器。\n路由 器的 L AN2 适 配 器复制该帧到网络上。 当此帧到 达主机 B 时, 它的适配器从电 缆上读到此帧 , 并将它传送到协议软件。 # 最后, 主机 B 上的协议 软件剥落包头和帧头。当服务器进行一个读取这些数据的系统调用时,协议软件最终将得到的数据复制到服务器的虚拟地址空间。\n主机A 主机B\n客户端\n( I) 口 歪J i\n\u0026lt; z) I三数据 I产PH Irn三il ! ( 8 ) 巨 蕴]\ni \u0026lt; 7) I 数据 IPH lrn2I\n二二] LAN2\nI 3 l 三: 器丧呈 匐 ;三勹丿亘气更\n\u0026lt; 4) I 数据 IPH I阳 1I ! 1 I 数据 IPH IFH2I ( 5 )\n图 11 - 7 在互联网络上, 数据是如何从一台主机传送到另一台主机的 ( PH , 互联网络包头;\nFHl, LANI 的帧头; FH2, LAN Z 的 帧头)\n当然,在这里我们掩盖了许多很难的问题。如果不同的网络有不同帧大小的最大值,该怎 么办呢?路由器如何知道该往哪里转发帧呢?当网络拓扑变化时,如何通知路由器?如果一个 包丢失了又会如何呢?虽然如此,我们的示例抓住了互联网络思想的精髓,封装是关键。 # 11 . 3 全球 IP 因特网\n全球 IP 因特网是最著名和最成功的互联网络 实现。从 1969 年起,它 就以这样或那样的形 式存在了。虽 然因特网的内部体系结构复杂而且不断变化, 但是自从 20 世纪 80 年代早期以来,客 户端-服务器应用的组织 就一直保持着相当的稳定。图 11-8 展示了一个因特网客户端-服务器应用程序的基本硬件和软件组织。\n互联网络客户端主机 互联网络服务器主机\n图 1 1-8 一个因特网应用程序的硬件和软件组织\n每台因特网主机都运行实现 T CP/ IP 协 议 ( T ransmission Control Protocol/ Internet\nProtocol, 传输控制协 议/互联网络协议)的软件,几 乎每个现代计算机系统都支持这个协议。因特网的客户端 和服务器混合使用 套接宇接 口函数和 U nix I/ 0 函数来进行通信(我们将在 11. 4 节中介绍套接字接口)。通常将套接字函数实现为系统调用, 这些系统调用会陷入内核,并调用各种内核模式的 T CP / IP 函数。 # T CP/ IP 实际是一个协议族, 其中每一个都提供不同的功能。例如, IP 协议提供基本的命名方法和递送机制,这种递送机制能够从一台因特网主机往其他主机发送包,也叫做 数 据报 (datagram ) 。IP 机制从某种意义上而言是不可靠的, 因为, 如果数据报在网络中丢失或者重复,它 并不会试图恢复。U DP (Unreliable Datagram Protocol, 不可靠数据报协议)稍微扩展了IP 协议, 这样一来, 包可以在进程间而不是在主机间传送。T CP 是一个构建在 IP 之上的复杂协议, 提供了进程间可靠的全双工(双向的)连接。为了 简化讨论, 我们将 T CP / IP 看做是一个单独的整体协议。我们将不讨论它的内部工作,只 讨 论 T CP 和\nIP 为应用 程序提供的某些基本功能。我们将不讨论 UDP。\n从程序员的角度,我 们可以把因特网看做一个世界范围的主机集合, 满足以下特性:\n主机集合被映射为一组 32 位的 IP 地址。 这组 IP 地址被映射为一组称为因特 网域名 (I ntern et domain name) 的标识符 。\n因特网主机上的进程能够通过连接(connection) 和任何其他因特网主机上的 进程通信。接下来三节将更详 细地讨论 这些基本的因特网概念。\n日日1P v4 和 1P v6\n最初的 因特 网 协议, 使 用 32 位地址, 称 为 因 特 网 协议版本 4 ( In t ern et Protocol Version 4, IP v4) 。1996 年, 因 特 网 工程任务组织 (I nte rn et Engin eering Task Force, IETF)提出了一 个新版 本的 IP , 称为 因特 网协议版本 6 CIP v6 ) , 它使 用的 是 128 位地址, 意在替代 IP v4。但是直到 2015 年, 大约 20 年后, 因特 网 流量的 绝大部 分还是由 IP v4 网络 承载的。例如, 只有 4% 的访问 Googl e 服务的用 户使 用 IP v6 [ 42] 。 # . 因为 IP v6 的使用率较低, 本 书 不会 讨论 IP v6 的细 节, 而只是集中 注意 力 于 IP v4 背后的 概念。当我们谈论因特 网 时, 我们指的是基于 IP v4 的因特 网。 但是, 本章后 面介绍的 书写客 户端 和服务器的 技术是基于现代接口的,与 任何特殊的协议 无关。\n11. 3. 1 IP 地 址\n一个 IP 地址就是一个 32 位无符号整数。网络程序将 IP 地址存放在如图 11-9 所示的\nIP 地址结构中。\nI* IP address structure *I\nstruct in_addr { # code/netp/netprfagment.sc\nuint32_t s_addr; I* Address in network byte order (big-endian) *I\n};\ncode/netp/nefrtpagments.c # 图 11-9 IP 地址结构\n把一个标量地址存放在结构中, 是套接字接口早期 实现的不幸产物。为 IP 地址定义一个标扯类型应该更有意义, 但是现在更改已经太迟了 , 因为已经有大量应用是基于此的。 # 因为因特网主机可以有不同的主机字节顺序, T CP / IP 为任意整数数 据项定义了统一的网络宇节顺序 ( network byte order)(大端字节顺序), 例如 IP 地址, 它放在包头中跨过网络被\n携带。在 IP 地址结构中存放的地址总是以(大端法)网络字节顺序存放的, 即使主机字节顺序\n(host byte order) 是小端法。U nix 提供了下面这样的函数在网络和主机字节顺序间实现转换。\n#include \u0026lt;arpa/inet.h\u0026gt;\nuint32_t htonl(uint32_t hostlong); uint16_t htons(uint16_t hostshort);\nuint32_t ntohl(uint32_t netlong); uint16_t ntohs(unit16_t netshort);\n返回: 按 照 网 络 字节顺序的值。\n返回: 按 照 主 机 字 节顺序的值。\nho t n l 函数将 32 位整数由主机字 节顺序转换 为网络字节顺序。n t o h l 函数将 32 位整数从网络字节顺序转换为主机字节。h t o n s 和 n t o h s 函数为 1 6 位无符号整数执行相应的转换。注意, 没有对应的处 理 64 位值的函数。\nIP 地址通常是以一种称为点分十进制表示 法来 表示的,这 里, 每个 字节由它的十进制值表示, 并且用句点和其他字节间分开。例如, 1 28 . 2 . 1 9 4 . 2 42 就是地址 Ox 8 0 0 2 c 2 f 2 的点分十进制 表示。在 L in u x 系统上,你 能够使用 HOST NAME 命令来确定你自己主机的点分十进制地址:\nlinux\u0026gt; hostname -i 128.2.210.175\n应用程序使用 i ne 七主 t on 和 i ne t _n t op 函数来实现IP 地址和点分十进制串之间的转换。\n#include \u0026lt;arpa/inet.h\u0026gt;\nint inet_pton(AF_INET, const char *src, void *dst);\n返回: 若 成 功则 为 1. 若 s r c 为非 法点 分 十进制地址则 为 o, 若 出错 则 为 一1。\nconst char *inet_ntop(AF_INET, const void *src, char *dst,\nsocklen_t size);\n返回: 若 成 功 则指向点 分 十进制 字符 串的 指 针 , 若出错 则 为 NU LL .\n在这些函数名中, \u0026quot; n\u0026quot; 代表网络 , \u0026quot; p \u0026quot; 代表表示。它们可以 处理 3 2 位 1P v4 地址 ( AF_IN­\nET) ( 就像这里展示的那样), 或者 1 28 位 1P v6 地址 ( AF_ IN ET 6) ( 这部分我们 不讲)。\ni ne t _p t o n 函数将一个点分十进制串 ( sr c ) 转换为一个二进制的网络字节顺序的 IP 地址( d s t ) 。如果 sr c 没有指向 一个合法的点分十进制字符串, 那么该函数就返回 0。任何其他错误会返回—1, 并设置 err n o 。相似地, i n e t _ n 七o p 函数将一个二进制的网络字节顺序的 IP 地址( sr c ) 转换为它所对应的点分十进制表示, 并把得到的以 n ull 结尾的字符串的最 多 s i z e 个字节复制到 d s 七。\nl \u0026lsquo;ia 勹 练 习 题 11. 1 完成下表:\n笠 练习题 11. 2 编 写程序 h e x 2d d . c , 将它的十六进制参数转换为点分十进制串并打印\n出结果。例如 # linux\u0026gt; ./hex2dd Ox8002c2f2 128.2.194.242\n练习题 11. 3 编 写程序 d d 2h e x . c , 将它的点分十进制参数转换为十六进制数并打印\n出结果。例如 # linux\u0026gt; ./dd2hex 128.2.194.242 Ox8002c2f2\n11. 3. 2 因特网域名 # 因特网客户端 和服务器互相通信时使用的是 IP 地址。然而, 对于人们而言, 大整数是很难记住的, 所以因特网也定义了一组更加人性化的域名( do m ain name), 以及一种将域名映射到 IP 地址的机制。域名是一串用句点分隔的单词(字母、数字和破折号),例 如 wha l e s h ar k . i c s . c s . c mu . e d u 。\n域名集合形成了一个层次结构,每个域名编码了它在这个层次中的位置。通过一个示 例你将很容易理解这点。图 11-10 展示了域名层次结构的一部分。层次结构可以 表示为一棵树。树的 节点表示域名,反 向到根的路径形成了域名。子树称为子域( s u bdo m ain ) 。层次结构中的第一层是一个未命名的根节点。下一层是一组 一级 域名(如s t- le ve l domain # name), 由非营利组织 IC A N N (Internet Corporation for Assigned Names and Numbers,\n因特网分配名字数字协会)定义。常见的第一层域名包括 c o m、e d u 、g o v 、or g 和 ne t 。\n未命名的根\nmil edu gov\nmit emu berkeley\n,,(\u0026rsquo;\\.,\nwhaIleshark wIww\n128.2.210.175 128.2.131.66\ncom\n\amazon\nWWW\n176.32.98.166\n第一层域名第二层域名第三层域名\n图 11-10 因特网域名层次结构的一部分\n下一层是二级( s eco nd- le ve l) 域名, 例如 c mu. e d u , 这些域名是由 ! C A N N 的各个授权代理按照先到先服务的基础分配的。一旦一个组织得到了一个二级域名,那么它就可以在 这个子域中创建任何新的域名了,例 如 c s . c mu . e d u 。\n因特网定义了域名集合和 IP 地址集合之间的映 射。直到 1988 年, 这个映射都是通过一个叫做 HOSTS. TXT 的文本文件来手工维护的。从那以后, 这个映射是通过分布世界范围内的数据库(称为 D N S ( Do m ain Name System, 域名系统))来维护的。从概念上而言, D NS 数据库由上百 万的主机条目结构 ( h o s t entry structur e ) 组成, 其中每条定义了一组域名和一组 IP 地址之间的映射。从数学意义上讲,可 以认为每条主机条目就是一个域名和\nIP 地址的等价类。我们可以用 Lin u x 的 NS LO O K U P 程序来探究 D NS 映射的一些属性, 这个程序能展示与某个 IP 地址对应的域名。e\n每台因特网 主机都有本地定 义的域名 l o c a l h o s t , 这个域名总是映射为回送地址\n(loop back address) 127.0. 0.1:\nlinux\u0026gt; nslookup localhost Address: 127.0.0.1\nl ocal host 名字为引用运行在同一台机器上的客户端和服务器提供了一种便利和可移植的方式 , 这对调试相当有用。我们可以使用 H OST NAME 来确定本地主机的实际域名:\nlinux\u0026gt; hostname\nwha l e s har k . i c s . cs . cmu . edu\n在最简单的情况 中, 一个域名和一个 I P 地址之间是一一映射: # linux\u0026gt; nslookup whaleshark . i sc Address: 128.2. 210. 175\n. cs. emu. edu\n然而, 在某些情况下 ,多 个域名可以映射为同一个 IP 地址: # linux\u0026gt; nslookup cs.mit.edu Address: 18.62.1.6\nlinux\u0026gt; nslookup eecs.mit.edu Address: 18.62.1.6\n在最通常的情 况下 ,多 个域名可以映 射到同一组的多个 IP 地址: # linux\u0026gt; nslookup www . t 忖 i t t er . com\nAddress: 199.16.156.6 Address: 199. 16. 156. 70 Address: 199.16.156.102 Addr e s s : 199.16.156.230 linux\u0026gt; nslookup twitter.com\nAddress: 199.16.156.102 Address: 199.16.156.230 Address: 199.16. 156.6 Address: 199.16. 156.70 最后, 我们注意到某些 合法的域名没有映射到任何 IP 地址: # linux\u0026gt; nslookup edu\n*** Can\u0026rsquo;t find edu: No answer\nlinux\u0026gt; nslookup ics.cs.cmu.edu\n*** Can\u0026rsquo;t find i cs. cs . cmu . edu : No answer\n豆日 有多少因特网主 机? # 因特网软件协会 (I ntern et Software Consortium, www. isc. org) 自从 198 7 年以后,每年进行 两次因特网 域名调查。这个调查通过计算已经分配给一个域名的 IP 地址的数量来估算因特网主机的数量,展 示了一种令人吃惊的趋势。自从 198 7 年以来,当 时一共大约有 20 000 台因特\n网主机,主机的数量已经在指数性增长。到2015 年,已经有大约1 000 000 000台因特网主机了。\ne 我们重新调整了 NSLOOKUP 的输出以 提高可读性。\n11. 3. 3 因特网连接\n因特网客户端和服务器通过在连接上发送和接收字节流来通信。从连接一对进程的意义上而言,连接是点对点的。从数据可以同时双向流动的角度来说,它是全双工的。并且从(除了一些如粗心的耕锄机操作员切断了电缆引起灾难性的失败以外)由源进程发出的字 节流最终被目的进程以它发出的顺序收到它的角度来说,它也是可靠的。 # 一个套接宇是 连接的一个端点。每个套接字都有相应的套接字地 址, 是由一个因特网地址和一个 16 位的整数端口。 组成的,用 “ 地址: 端口” 来表示。\n当客户端发起一个连接请求时,客户端套接字地址中的端口是由内核自动分配的,称 为临时 端口 ( e p hem e ra l por t ) 。然而, 服务器套接字地址中的端口通常是某个知名端口, 是和这个服务相 对应的。例如, W e b 服务器通常使用端口 80 , 而电子邮件服务器使用端 口 25 。每个具有知名端口的服务都有一个对应的 知名的服务名。例如, W e b 服务的知名名字是 h t t p , email 的知名名字是 s m七p 。 文件/ e t c / s er v i c e s 包含一张这台机器提供的知名名字和知名端口之间的映射。\n一个连接是由它两端的套接字地址 唯一确定的。这对套接字地址叫做套接宇对 ( sock et # pair), 由下列元组来表示:\n(cliaddr:cliport, servaddr:servport) # 其中 c l i a d dr 是客户端的 IP 地址 , c l i por t 是客户端的端口, s er v a d d r 是服务器的 IP 地址 , 而 s er v p or t 是服务器的端口 。例如,图 11 - 11 展示了一 个 W eb 客户端和一个 Web 服务器之间的连接。\n客户端套接字地址\n128.2.194.242:51213\n服务器套接字地址\n208.216.181.15:80\n客户端主机地址\n128.2.194.242\n图 11-11 因特网连接分析\n服务器主机地址\n208.216.181.15\n在这个示例中, W e b 客户端的套 接字地址 是\n128.2.194.242:51213\n其中端口号 51 213 是内核分配的临时端口号。Web 服务器的套接字地址是\n208 . 216 . 181 . 15 : 80\n其中端口号 80 是和 W e b 服务相关联的知名端口号。给定这些客户端和服务器套接字地址,客户端和服务器之间的连接就由下列套接字对唯一确定了: # (128.2.194.242:51213, 208.216.181.15:80)\nm 因特网的起源\n因特 网是 政府、学校 和工业界合作的最成功的 示例之一。它成 功的 因素很 多 , 但 是我们认 为有 两点尤其 重要: 美国政 府 30 年持 续不 变的 投资, 以及充满激 情的 研究人 员 # e 这些软件端口与网络中交换机和路由器的硬件端口没有关系。\n对麻省理工学院的 Dave Cla rke 提出的 “粗略一致和能用的 代码” 的投入。\n因特 网的种 子是在 1957 年播下的 , 其时正值冷战的高峰 , 苏联发射 Sput nik , 笫一颗人造地球卫星,震 惊了世界 。作 为响 应, 美国政 府创建了 高级 研 究计划署( ARPA) , 其任务就是重建美国在科学与技 术上的领导地位。1967 年, ARPA 的 Lawrence Roberts 提出了一 个计 划,建 立一个叫做 A阳 决 NET 的新网络。 第一 个 ARPANET 节点是在 1969年建立并运行的 。到 1971 年, 已有 13 个 A团汛 NET 节点 , 而且 email 作为第一 个重要的网络应 用涌现 出来。\n1 972 年,Ro bert Ka hn 概括了网 络互联的一般原则: 一组互相连接的网络 , 通过叫做“路由器”的黑盒子按照“以尽力传送作为基础”在互相独立处理的网络间实现通 信。1974 年, Ka hn 和 Vinton Cerf 发 表 了 T CP / IP 协议 的第一本详细资料 , 到 1982 年它成为 了 AR P A NE T 的标准网络 互联协议 。19 83 年 1 月 1 日 , AR PA NET 的每个节点都切换到 T CP / IP , 标志着全球 IP 因特 网的 诞生。\n1985 年, P aul Mocka petris 发明 了 D NS , 有 1 000 多 台 因特 网 主机。1986 年, 国 家科 学基金 会( NS F ) 用 56KB / s 的电话线连接 了 13 个节点 , 构建了 NSF NET 的骨 干网。其后在 1988 年升级到 1. 5MB/ s T l 的连接速率, 1 991 年为 45MB/ s T 3 的连接速率。到\n1988 年, 有 超过 50 000 台 主机。1989 年, 原始的 ARP A NET 正式 退休 了。 199 年, 已经有 几乎 10 000 000 台因特 网主机了 , NSF 取 消 了 NS F NE T , 并且用基于由公众网络接入点连接的私有商业骨干网的现代因特网架构取代了它。\n11. 4 套接字接口 # 套接宇接 口( socket inte rface ) 是一组函数,它 们 和 U nix I / 0 函 数 结 合 起 来 ,用 以 创建网 络应用 。大多 数 现代系统上都实现套接字接口, 包 括 所 有 的 U nix 变种、Windows 和Macintos h 系统。图 11-12 给出了一个典型的客户端-服务器事务的上下文中的套接字接口概述。当讨论各个函数时,你可以使用这张图来作为向导图。\n客户端 服务器\ngetaddrinfo # s ocke 七\nopen_l i s t enf d\nopen_cl i ent f d bi nd\nl i s t en\nconnec 七 连接请求 accept\nr i o_wr i t en r i or_ ead l i neb # rio_readlineb r i o_wr i t e n 等待来自下一个 # 客户端的连接请求\nEOF\nclose r i o_r eadl i neb # c l os e\n图 11-12 基于套接 字接口的网络应用概述\nm 套接字接口的起源 # 套接宇接 口是加 州 大学伯 克利分校的研究人员在 20 世 纪 80 年代早期提 出的 。因 为这个原因,它也经常被叫做伯克利套接宇。伯克利的研究者使得套接宇接口适用于任何 底层的协议。笫一 个实现的就是针对 T CP / IP 协议的,他 们把它 包括 在 U n ix 4. 2BS D 的内核里,并且分发给许多学校和实验室。这在因特网的历史上是一个重大事件。几乎一 夜之间,成 于上万的人们接触到 了 T C P / IP 和 它的 源代 码 。 它引起 了 巨 大的轰动, 并激发了新的 网络 和 网络互联研 究的浪潮。\n11. 4. 1 套接字地址结构\n从 Lin u x 内核的角度来看, 一 个 套 接 字 就 是 通信的一个端点。从 Lin u x 程序的角度来看,套接字就是一个有相应描述符的打开文件。\n因特网的套接字地址存放在如图 11 -1 3 所示的类型为 s o c ka d d r _ i n 的 1 6 字节结构中。对于因特网应用, s i n _ f a mi l y 成 员 是 AF_INET, sin _por t 成员是一个16 位的端口号, 而 s i n a d dr 成员 就 是 一 个 32 位 的 I P 地址。IP 地址和端口号总是以网络字节顺序(大端法)存放的。\ncode/netp/netpfragments.c\nI* IP socket address structure *I\nstruct sockaddr_in {\nuint16_t sin_family; I* Protocol family (always AF_INET) *I uint16_t sin_port; I* Port number in network byte order *I struct in_addr sin_addr; I* IP address in network byte order *I unsigned char sin_zero[8]; I* Pad to sizeof(struct sockaddr) *I\n};\nI* Generic socket address structure (for connect, bind, and accept) *I\nstruct sockaddr {\nuint16_t sa_family; I* Protocol family *I\nchar sa_data[14]; I* Address data *I\n} ;\n田日_ in 后缀意味什么?\n图 11-13 套接字地址结构\ncode/netp/netpfragments.c\nin 后缀是互联 网络 ( in t ern e t ) 的缩写, 而不 是输入( in put ) 的缩写。\nc o n n e c t 、 b i n d 和 a c c e p t 函数要求一个指向 与协 议 相关的 套 接字地址结 构的 指针。套接字接口的设计者面临的问题是,如何定义这些函数,使之能接受各种类型的套接字地 址结构。今天我们可以使用通用的 V O 过* 指针, 但 是 那时在 C 中并不存在这种类观的指针。解决办法是定义套接字函数要求一个指向通用 s o c ka d dr 结构(图 11 -1 3 ) 的指针, 然后要求应用程序将与协议特定的结构的指针强制转换成这个通用结构。为了简化代码示 例, 我们跟随 S te ven 的指导,定 义 下 面的类型:\ntypedef struct sockaddr SA;\n然后无论何时需要将 s o c ka d dr _ i n 结构强制转换成通用 s o c ka ddr 结构时, 我们都使用这个类型。\n11. 4. 2 s o c k e 七 函 数\n客户端和服务器使用 s o c ke t 函数来创建一个套接字描 述符( s ock e t d es cri pto r ) 。\n#include \u0026lt;sys/types.h\u0026gt;\n#include \u0026lt;sys/socket.h\u0026gt;\nint socket(int domain, int type, int protocol);\n返回: 若 成 功则 为非 负描 述 符 , 若出铢则 为 一1.\n如果想要使套接字成为连接的一个端点, 就用如下硬编码的参数来调用s o c ke t 函数:\nclientfd = Socket(AF_INET, SOCK_STREAM, O);\n其中, AF_INET 表明我们 正在使用 32 位 IP 地址,而 SOCK_ST REAM 表示这个套接字是连接的一个端点。不过最好的方法是 用 g e 七a d dr i n f o 函数( 11. 4. 7 节)来自动生成 这些参数, 这样代码就与协议无关了。我们会在 1 1. 4. 8 节中向你展示如何配合 s o c ke t 函数来使用 g e t a d dr i n f o。\ns o c ke t 返回的 c l i e n 七f d 描述符仅是部分打开的,还 不能用于读写。如何完成 打开套接字的工作,取决于我们是客户端还是服务器。下一节描述当我们是客户端时如何完成 打开套接字的工作。\n11. 4. 3 c o n n e c 七函 数\n客户端通过调用 c o n ne c 七函数来建立和服务器的连接。\n#include \u0026lt;sys/socket.h\u0026gt;\nint connect(int clientfd, const struct sockaddr *addr, socklen_t addrlen);\n返回: 若 成 功 则为 o, 若 出错 则 为一l 。\nc o n ne c t 函数试图与套接字地址为 a ddr 的服务器建立 一个因特网连接,其中 a dd rl en 是 s i z e o f (s o c ka d dr _ i n ) 。c o nne c t 函数会阻塞, 一直到连接成功建立或是发生错误。如果成功, c l i e n t f d 描述 符现在就准备好可以读写了,并 且得到的连接是由套接字对\n(x:y, addr.sin_addr:addr.sin_port)\n刻画的, 其中 x 表示客户端的 IP 地址 , 而 y 表示临时端口,它唯 一地确定了客户端主机上的客户端进程。对于 s o c k e t , 最好的方法是用 g e t a d dr i n f o 来为 c o n n e c t 提供参数\n(见 11. 4. 8 节)。\n11 . 4. 4 b i nd 函数\n剩下的套接字函数- b i nd 、 江 s t e n 和 a c c e p t , 服务器用它们来和客户端建立连接。\n#include \u0026lt;sys/socket.h\u0026gt;\nint bind(int sockfd, const struct sockaddr *addr, socklen_t addrlen);\n返回: 若 成 功则 为 o, 若 出错 则 为— l。\n区 nd 函数告 诉内 核将 ad dr 中的服务器套接字地址和套接字描述符 s oc kf d 联系起来。参数 a d d r l e n 就是 s i z e o f ( s o c ka ddr _ i n ) 。 对 千 s o c ke t 和 c on ne c t , 最好的方法是用 ge t a d d r i nf o 来为 b i nd 提供参数(见11. 4. 8 节)。 # 11. 4. 5 l i s 七 e n 函 数\n客户端是发起连接请求的主动实体。服务器是等待来自客户端的连接请求的被动实 体。默认情况下,内 核会认为 s o c k吐 函数创建的描述符对应于主动套接 宇 ( act ive sock­ # et), 它存在于一个连接的客户端。服务 器调用 1 工s t e n 函 数 告 诉 内 核 , 描 述符是被服务器而不是客户端使用的。\n#include \u0026lt;sys/socket.h\u0026gt;\nint listen(int sockfd, int backlog);\n返回: 若 成 功 则 为 o , 若 出错 则为 一1。\n让 s t e n 函数将 s o c kf d 从一个主动套接字转化为一个监听套接宇 (l is te ning socket), 该套接字可以接受来自客户端的连接请求。ba c kl og 参数暗示了内核在开始拒绝连接请求之前,队列 中要 排队 的 未 完成的连接请求的数量。b a c kl og 参数的确切含义要求对 TCP/ IP 协议的理解, 这 超 出 了 我们讨论的范围。通常我们会把它设 置为一个较大的值, 比如 10 24。 # 4. 6 a c c e p 七 函 数 服务器通过调用 a c c e p t 函数来等待来自客户端的连接请求。 # #include \u0026lt;sys/socket.h\u0026gt;\nint accept(int listenfd, struct sockaddr *addr, int *addrlen);\n返回: 若 成 功 则 为非 负连 接 描 述 符 , 若 出错 则 为— l 。\na c c e p t 函数等待来自客户端的连接请求到达侦听描述符 l i s t e n f d , 然后在 a ddr 中填写客户端的套接字地址,并 返回一个已连接描述符( connect ed descriptor) , 这个描述符可被用来利用 U nix I/ 0 函数与客户端通信。 # 监听描述符和巳连接描述符之间的区别使很多人感到迷惑。监听描述符是作为客户端 连接请求的一个端点 。它通常被创建一次,并 存 在 千服务器的整个生命周期。已连接描述符是客户端和服务器之间已经建立起来了的连接的一个端点。服务器每次接受连接请求时 都会创建一次, 它只 存 在 于服务器为一个客户端服务的过程中。\n图 11-14 描绘 了监 听描 述 符 和已 连接描述 符的 角色。在第一 步 中, 服 务 器 调 用\naccept, 等待连接请求到达监听描述符,具 体 地我们设定为描述符 3。回忆一下, 描 述 符\n0~ 2 是预留给了标准文件的。\n在第二步中,客 户端调用 c o nne c t 函数, 发 送 一个连接请求到 l i s t e n f d。第三步, a c c e p t 函 数 打 开了一个新的已连接描述符 c o nn f d ( 我们假设是描述符 4 )\u0026rsquo; 在 c l i e n 七f d 和 c o nn f d 之间建立连接,并 且 随 后 返 回 c o n n f d 给应用程序。客户端也从 c on ne c t 返回, 在这一点以后, 客户端和服务器就可以分别通过读和写 c l i e n t f d 和 c o nn f d 来回传送数据了。\n曰clientfd\nl i st enfd(3)\nl 服务器阻塞在 a cce pt , 等待监听描述符l i s t e nf d 上的连接请求。\n连接请求 li s t e n f d (3 )\n三 ------- -----] 三\nc l i e n 七 f d\n客户端通过调用和阻塞在 conne c t , 创建连接请求。 # li s t e n f d (3 )\nc l i e n t f d c onn f d ( 4 )\n服务器从 ac ce pt 返回 connf d。客户端从 co nne c t 返回。现在在 c巨 ent f d 和co nn f d 之间已经建立起了连接。 图 11-14 监听描述符和已 连接描述符的角色\n田 日 为何 要有监听描述符和已连接描述符之间的区别? # 你可能很想知道为什 么套 接 宇接 口要区别监听描述符和已连接描述符。乍 一看,这像 是不必要的复杂化。然而, 区分这两者被 证明是 很有用的 , 因 为 它使 得 我们可以建 立并发服务器, 它能够同时处理许多客 户端 连接。例如,每 次一个连接请求到达监听描述符时, 我们可以派生( fork) 一个新的进程, 它通 过 已连接描述符与客 户端通信。在第 12 章 中将介绍更多 关于并发服务器的 内容。\n4. 7 主机和服务的转换\nLinu x 提供了一些强大的函数(称为 ge t a ddr i n f o 和 ge t name i n f o ) 实现二进制套接字地址 结 构 和主机名、主机地址、服务名和端口号的字符串表示之间的相互转化。当和套接字接口 一起 使用 时 , 这些函数能使我们编写独立于任何特定版本的 IP 协议的网络程序。\nge ta d d rinfo 函数\ng e t a d dr i n f o 函数将主机名、主机地址、服务名和端口号的字符串表示转化成套接字 地 址 结 构 。 它 是巳弃用的 g e t h o s 七b y n a me 和 g e t s e r v b yn a me 函数 的 新的替代品。和以前 的那些 函数不同 , 这 个 函 数 是 可重入的(见12. 7. 2 节),适用 于任 何 协议 。\n# i nc l ude \u0026lt;s y s / t yp e s . h\u0026gt;\n#include \u0026lt;s ys / s ocke t . h \u0026gt;\n#include \u0026lt;net db . h \u0026gt;\nint getaddrinfo(const char *host , const char *ser vi ce, const struct addrinfo *hints,\nstr uct addrinfo **result ) ;\n返回: 如 果 成 功 则 为 0 . 如 果 错 误 则 为非 零的错误代码。\nvoid fr eeaddri nfo(str uct addr i nfo *resul t ) ;\nconst char *gai _s tr err or ( i nt errcode);\n返回: 无.\n返回: 错 误 消息。\n给定 ho s t 和 s er v i c e ( 套接字地址的 两个组成部分),g e t a d d r i n f o 返回 r e s ult ,\nr e s u l t 一个指向 a d dr i n f o 结构的链表,其 中 每个结构指向一个对应于 h o s t 和 s e r vi ce ,\n的套接字地址结构(图11-15) 。\nresult # addr i nf o结构\nai canonname 套接字地址结构\nai addr # ai nex 七\nNULL\nai addr # ai next\nNULL\nNUL L\n图 11-15 get addr i nfo 返 回的数据结构\n在客户端调用了 ge t addr i nfo 之后,会 遍历这个列表,依 次尝试每个套 接字 地址 ,直 到调用 s oc ke t 和 conne c t 成功,建立起 连接。类似地,服 务器会尝试遍历列表中的每个套接字地址, 直到调用 s oc ke t 和 b i nd 成功,描 述符会 被绑定到一个合法的套接字地址。为了避免内存泄漏,应用程序必须 在最 后调用 fr ee a ddr i nf o , 释放 该链表。如果 ge t addr i nfo 返回非零的错误代码, 应用程序可以调用 ga i _s tr eer or , 将该代码转换成消息字符串。 # ge t addr i nfo 的 hos t 参数 可以是 域名,也 可以是数字地址(如点分 十进制 IP 地址)。se rv i ce 参数可以是服务名(如h七七p )\u0026rsquo; 也 可以是十进制端口号。如果不想把主机名转换成地址, 可以把 hos t 设置为 NULL。对 s e rv i ce 来说也是一样。但是必须指定两者中至少一个。\n可选的参数 hi n t s 是一个 a ddr i nf o 结构(见图 11-16 ) , 它提供对 g e t a ddr i n f o 返回的套接字地址列表的更好的控制。如果要传递 h i nt s 参数,只 能 设 置 下 列 字 段 : ai_fam­ il y、ai _ sockt ype、ai _ pr ot ocol 和 ai _ f l ags 字段。其他字段必须设置为 0 (或NU LL) 。实际中, 我们用 me ms e 七将 整 个 结 构 清 零 ,然 后 有 选择地设置一些字段:\ng e t a ddr i n f o 默 认 可以返回 IP v4 和 IPv6 套接字地址。a i _ f a m过 y 设 置 为 AF _IN­ ET 会将列表限制为 IPv4 地址;设 置 为 AF _ INET 6 则 限 制 为 IP v6 地址。 对于 h o s t 关联的每个地址, g e t a d dr i n f o 函 数 默 认 最 多 返 回 三个 a ddr i n f o 结构, 每个的 a i _s oc kt yp e 字段不同:一 个 是 连接, 一 个 是数据报(本书未讲述),一 个是 原 始 套 接 字(本 书未 讲 述 )。 a i _ s o c k t yp e 设 置为 SOCK_STREAM 将列表限制为对每个地址最多一个 a d dr i n f o 结构,该 结 构 的 套 接 字 地址可以作为连接的一个端点。这是所有示例程序所期望的行为。 # a i _ fl a gs 字段是一个位掩码, 可 以 进一步修改默认行为。可以把各种值用 OR 组合起来得到该掩码。下面是一些我们认为有用的值:\nAI_ADDRCONFIG。如果在使用连接,就 推荐使用这个标志 [ 34] 。它要求 只有当\n本地主机被配置为 IPv4 时 , ge 七a ddr i nfo 返 回 IPv4 地址。对 IPv6 也是类似。\nAI_CANONNAME 。a i _c a no n na me 字 段默认为 NU LL。如果设 置了该标志, 就是告诉 ge t a d dr i n f o 将列表中第一个 ad dr i n f o 结构的 a i _ca no nna me 字 段 指 向h o s t 的 权 威(官 方 )名字(见 图 11 - 1 5) 。 # AI_NU MERICSERV 。参数 s er v i c e 默认可以是服务名或端口号。这个标志强制参数 s er v i c e 为端口号。\nAI—P ASSIVE。ge t a ddr i n f o 默认返回套接字地址, 客户端可以 在调用 c o nne c t 时用作主动套接字。这个标志告诉该函数,返回的套接字地址可能被服务器用作监听套接字。在这种情况中,参 数 ho 江 应该 为 NU LL。得到的套接字地址结构中的地址字段会是通配符地址 ( w ild card address) , 告诉内核这个服务器会接受发送到该主机所有 IP 地址的请求。这是所有示 例服务器所期望的行为。\ncode/netp/netpfragments.c\nstruct addrinfo { int\nint int int char\nsize_t\nai_flags; I* Hints argument flags *I ai_family; I* First arg to socket function *I ai_socktype; I* Second arg to socket function *I ai_protocol; I* Third arg to socket function *I\n*ai_canonname; I* Canonical hostname *I ai_addrlen; I* Size of ai_addr struct *I\nstruct sockaddr *ai_addr; struct addrinfo *ai_next;\n};\nI* Ptr to socket address structure *I I* Ptr to next item in linked list *I\ncodelnetp/netpfragments.c\n图 11-16 ge t addr i nfo 使用的 addr i nf o 结构\n当 g e t a d dr i n f o 创建输出列表中的 a d dr i n f o 结构时, 会填写每个字段,除 了 a i\nf l a g s 。a i _a d dr 字段指向一个套接字地址结构, a i _ a d dr l e n 字段给出这个套接字地址结构 的大小, 而 a i _ n e x t 字段指向列表中下一个 a d dr i n f o 结构。其他字段描述这个套接字地址的各种属性。\ng e t a d dr i n f o 一个很好的方面是 a d dr i n f o 结构中的字段是不透明的, 即它们可以直接传递给套接字接口中的函数, 应用程序代码无需再做任何处理。例如, a i _ f a mi l y、a i\ns o c k t y p e 和 a i _ p r o t o c o l 可以 直接传递给 s o c ke t 。类似地, a i _ a d dr 和 a i _ a d d r l e n 可以直接传递给 c o n n e c t 和 b i nd 。这个强大的属性使得我们编写的客户端和服务器能够独立于某个特殊版本的 IP 协议。\nge tna me info 函数\ng e t n a me i n f o 函数和 g e t a d dr i n f o 是相反的, 将一个套接字地址结构转换 成相应的主机和服务名字符串 。它是已弃用的 g e t h o s t b y a d dr 和 g e t s er v b y p or t 函数的新的替代品 ,和 以前的那些函数不同, 它是可重入和与协议无关的。\n#include \u0026lt;sys/socket.h\u0026gt;\n#include \u0026lt;netdb.h\u0026gt;\nint getnameinfo(const struct sockaddr *sa, socklen_t salen, char *host, size_t hostlen,\nchar *service, size_t servlen, int flags);\n返回: 如 果 成 功则 力 o. 如果错误则为非零的错误代码。\n参数 s a 指向大小为 s a l e n 字节的套接字地址结构, h o s t 指向大小为 h o s t l e n 字节的缓冲区, s e rv i c e 指向大小为 s er v l e n 字节的缓冲区。g e t narne i n f o 函数将套接字地址结构 sa 转换成对应的主机和服务名字符串,并将它们复制到 ho s t 和 s e rv c i ce 缓冲区。如果 ge t narn-\ne i nf o 返回非零的错误代码, 应用程序可以调用ga i _s tr err or 把它转化成字符串。\n如果不想要主机名 ,可 以把 h o s t 设置为 N U L L , h o s 七l e n 设置为 0 。对服务字段来说也是一样。不过, 两者必须设置其中 之一。\n参数 f l a g s 是一个位掩码, 能够修改默认的行为。可以 把各种值用 O R 组合起来得到该掩码。下面是两个有用的值:\nN I_N U M E R IC H OS T 。g e t na me i n f o 默认试图返回 h o s t 中的域名。设置该标志会使该函数返回一个数字地址字符串。\nNI_N U MERICSER V。ge t name i n f o 默认会检查/ e 七c / s er v i c e s , 如果可能,会返回\n服务名而不是端口号。设置该标志会使该函数跳过查找,简单地返回端口号。\n图 11-17给出了一个简单的程序 , 称为 H OST INF O . 它使用ge t a ddr i nfo 和 ge t name i nf o 展示出域名到和它相关联的 IP 地址之间的映射。该程序类 似于 11. 3. 2 节中的 NSLOO KU P 程序。\n1 #include \u0026ldquo;csapp.h\u0026rdquo;\n2\n3 int main(int argc, char **argv)\n4 {\ns struct addrinfo *P, *listp, hints;\nchar buf[MAXLINE];\nint re, flags;\n8\n9 if (argc != 2) {\nfprintf (stderr, \u0026ldquo;usage: i儿 s \u0026lt;domain name\u0026gt;\\n\u0026rdquo;, argv[O]);\nexit(O);\n12 }\n13\nI* Get a list of addrinfo records *I\nmemset(\u0026amp;hints, 0, sizeof(struct addrinfo));\n· hi nt s . a i_ f am辽 y = AF_INET; I* IPv4 only *I\nhints.ai_socktype = SOCK_STREAM; I* Connections only *I\ncode/netp/hostinfo.c\nif ((re = getaddrinfo(argv[1], NULL, \u0026amp;hints, \u0026amp;listp)) != 0) {\nfprintf(stderr, \u0026ldquo;getaddrinfo error: %s\\n\u0026rdquo;, gai_strerror(rc));\nexit(!);\n21 }\n22\nI* Walk the list anddisplay each IP address *I\nflags= NI_NUMERICHOST; I* Display address string instead of domain name *I\nfor (p = listp; p; p = p-\u0026gt;ai_next) {\nGetnameinfo (p-\u0026gt;ai_addr, p-\u0026gt;ai_addrlen, buf, MAXLINE, NULL, 0, flags) ;\nprintf(\u0026quot;%s\\n\u0026quot;, buf); 28 }\n29\nI* Clean up *I\nFreeaddrinfo(listp); 32\n33 exit(O);\n34 }\ncode/netp/hostinfo.c\n图 11-17 H OSTINFO 展示出域名到 和它相关联的 IP 地址之间的 映射\n首先, 初始化 h i n t s 结构,使 g e t a ddr i n f o 返回我们想要的地址。在这里,我 们想查找 32 位的 IP 地址(第16 行),用 作连接的端点(第1 7 行)。因为只想 ge t a ddr i nf o 转换域名, 所以用 s er v i c e 参数为 N U L L 来调用它。\n调用 g e t a ddr i n f o 之后, 会遍历 a ddr i n f o 结构,用 g e t na me i n f o 将每个套接字地址转换成点分十进制地址字符串 。遍历完列表之后 , 我们调用 fr e e a d dr i n f o 小心地释放这个列表(虽然对于这个简单的程序来说 ,并 不是严格需要这样做的)。\n运行 H OST INF O 时,我们 看到 t wi 七七e r . c om 映射到了四个 IP 地址, 和 11. 3. 2 节用\nNS LO O K U P 的结果一样。\nlinux\u0026gt; ./hostinfo t 甘i t t er .com 199.16.156.102 # 199.16.156.230\n199.16.156.6\n199.16.156.70\n练习题 11. 4 函数 ge t a dd r i n fo 和 ge t narne i nf o 分别包含 了 i ne t _p t o n 和 i ne t _ n t op 的功能,提供 了 更高 级别的、 独 立于任 何特殊地 址格 式的 抽象。想看看这 到底 有多方便, 编写 H OST INFO ( 图 11-17 ) 的一个版本,用 i ne t 主 t on 而不是 ge t narne i nf o 将每个套接字地址转换成点 分十进制地址 字符 串。\n4. 8 套接字接口的辅助函数\n初学时, g e t na me i n f o 函数和套接字接口看上去有些可怕。用高级的辅助函数包装一下会方便很多 , 称为 o pe n_ c l i e nt f d 和 o p e n 让 s t e n f d , 客户端和服务器互相通信时可以使用这些函数。\no pe n_c lie ntfd 函数 # 客户端调用 o p e n_ c l i e n t f d 建立与服务器的连 接。\no pe n_c l i e nt f d 函数建立与服务器的连接,该 服务器运行 在主机 h os t na me 上, 并在端口号 p or t 上监听连接请求。它返回一个打开的套接字描述符,该 描述符准备好 了,可以用 U nix 1/0 函数做输入和输出。图 11-18 给出了 ope n_ c l i e n t f d 的代码。\n我们调用 g e t a ddr i n f o , 它返回 a ddr i n f o 结构的列表,每 个结构指向一个套接字地址结构,可用 于建立与服务器的 连接,该 服务器运行 在 hos t na me 上并监听 po 江 端口。然后遍历该列表, 依次尝试列表中的每个条目, 直到调用 s o c ke t 和 c o n ne 吐 成功。如果c o n ne c t 失败 , 在尝试下一个条目之前, 要小心地关闭套接字描述符。如果 c o n ne c t 成功,我们会释放列表内存,并把套接字描述符返回给客户端,客户端可以立即开始用\nUnix 1/0 与服务器通信了 。\n注意, 所有的代码都与任何 版本的 IP 无关。 s o c ke t 和 c o n ne c t 的参数都是用\ng e 七a dd r i n f o 自动产生的, 这使得我们的代码干净可移植。\nope n_ lis te nfd 函数 # 调用 ope n_l i s t e n f d 函数, 服务器创建一 个监听描述符, 准备好接收连接请求。\n#include \u0026ldquo;csapp.h\u0026rdquo;\nint open_listenfd(char *port);\n返回: 若 成 功则 为描 述 符 , 若出错 则为 一1。\n1 int open_clientfd(char *hostname, char *port) { int clientfd;\n3 struct addrinfo hints, *listp, *Pi\ncode/srdcsapp.c\nI* Get a list of potential server addresses *I\nmemset(\u0026amp;hints, 0, sizeof(struct addrinfo));\nhints.ai_socktype = SOCK_STREAM; I* Open a connection *I\nhints.ai_flags = AI_NUMERICSERV; I* \u0026hellip; using a numeric port arg. *I\nhints.ai_flags I= AI_ADDRCONFIG; I* Recommended for connections *I\nGetaddrinfo(hostname, port, \u0026amp;hints, \u0026amp;listp);\nI * 扣 al k the list for onethat we can successfully connect to *I\nfor (p = listp; p; p = p-\u0026gt;ai_next) {\n/ * Create a socket descriptor *I\nif ((clientfd = socket (p-\u0026gt;ai_family, p-\u0026gt;ai_socktype, p-\u0026gt;ai_protocol))\n\u0026lt; O) continue; I* Socket failed, try the next *I\nI* Connect to the server *I\nif (connect (clientfd, p-\u0026gt;ai_addr, p-\u0026gt;ai_addrlen) != -1)\nbreak; I* Success *I\nClose(clientfd); I* Connect failed, try another *I 22\n23\nI* Clean up *I\n· Fr e e addr i nfo (listp);\nif (! p) / * All connects failed *I\nreturn -1·\nelse I* Thelast connect succeeded *I\nreturn clientfd;\n30 }\ncode/srdcsapp.c\n图 11-18 open_clientfd: 和服务器建立连 接的辅 助函数 。它是可重入 和与协议无关的\nop e n_l i s t e n f d 函数打开和返回一个监听描述符, 这个描述符准备好在端口 p or t 上接收连接请求。图 11 -19 展示了 o pe n_ l i s t e n f d 的代码。\nop e n_ l i s 七e n f d 的 风 格类似于 ope n _ c l i e n七f d 。 调 用 g e t a ddr i n f o , 然后遍历结果列表,直 到调用 s o c ke t 和 b i nd 成功。注意,在 第 20 行, 我们使用 s e t s o c ko p t 函数(本书中没有讲述)来配置服务器,使得服务器能够被终止、重启和立即开始接收连接请求。一个重 启的服务器默认将在大约 30 秒内拒绝客户端的连接请求,这严 重 地阻碍了调试。\n因为我们调用 ge t a d dr i n f o 时, 使 用 了 AI _ PASSIVE 标志并将 h o s t 参数设 置为\nNULL, 每个套接字地址结构中的地址字段会被设置为通配符地址, 这告诉内核这个服务器会接收发送到本主机所有 IP 地址的请求。 # code/srdcsapp.c\n1 int open_listenfd(char *port)\n2 {\n3 struct addrinfo hints, *listp, *p;\n4 int listenfd, optval=l;\n5\n6 I* Get a list of potential server addresses *I memset(\u0026amp;hints, 0, sizeof(struct addrinfo));\nhints.ai_socktype = SOCK_STREAM; hints.ai_flags = AI_PASSIVE I AI_ADDRCONFIG; hints.ai_flags I= AI_NUMERICSERV; Getaddrinfo(NULL, port, \u0026amp;hints, \u0026amp;listp);\nI* Accept connections *I\nI* . . . on any IP address *I I* \u0026hellip; using port number *I\n13 / * Walk the list for onethat we can bind to•/\n14 for(p= listp; p; p = p-\u0026gt;ai_next) {\n15 / * Create a socket descriptor•I\nif ((listenfd = socket (p-\u0026gt;ai_family, p-\u0026gt;ai_socktype, p-\u0026gt;ai_protocol))\n\u0026lt; 0) continue; /• Socket failed, try the next•I\n18\n/• Eliminates \u0026ldquo;Address already in use\u0026rdquo; error from bind•/\nSetsockopt(listenfd, SOL_SOCKET, SO_REUSEADDR,\n(const void•)\u0026amp;optval , sizeof (int));\n22\n23 I• Bind the descriptor to the address•/\n24 if (bind(listenfd, p-\u0026gt;ai_addr, p-\u0026gt;ai_addrlen) == 0)\nbreak; I* Success•I\nClose(listenfd); I• Bind failed, try the next•I\n27 }\n28\nI* Clean up•I\nFreeaddrinfo (listp) ;\nif (!p) I* No address worked•/\nreturn -1;\n33\nI• Make it a listening socket ready to accept connection requests•I\nif (listen(listenfd, LISTENQ) \u0026lt; 0) {\nClose (listenfd) ;\n37 return -1;\n38 }\n39 return listenfd;\n40 }\ncode/srdcsapp.c\n图 11- 1 9 open_listenfd: 打开并返回监听描述符的辅助函数。它是可重人和与协议无关的\n最后, 我们调用 li s 七e n 函 数 ,将 l i s 七e n f d 转 换 为 一 个 监 听 描 述符,并 返 回 给调用者。如果 l i s t e n 失败 ,我 们 要小 心 地避免内存泄漏,在 返回前关闭描述符。\n11. 4. 9 e c ho 客户端和服务器的示例\n学习套接字接口的最好方法是研究示 例代码。图 11-20 展示 了一个 ech o 客户端的代\n码。在和服务器建立连接之后,客户端进入一个循环,反复从标准输入读取文本行,发送 文本行给服务器,从 服务器读取回送的行,并 输 出 结 果 到 标准输出。当 f g e t s 在标准输人上遇到 EOF 时,或 者 因 为用户在键盘上键入 Ctrl + D, 或者因为在一个重定向的输入文件中用尽了所有的文本行时,循环就终止。 # code/netplechoclient.c\n#include \u0026ldquo;csapp.h\u0026rdquo;\n3 int main(int argc, char••argv) int clientfd;\n6 char *host, *port, buf[MAXLINE] ;\nrio_t rio;\n9 if (argc != 3) {\n1O fprintf (stderr, \u0026ldquo;usage: %s \u0026lt;host\u0026gt; \u0026lt;port\u0026gt;\\n\u0026rdquo;, argv [0]) ; exit(O);\n12\nhost = argv[1];\nport = argv [2] ;\n15\nclientfd = Open_clientfd (host, port);\nRio_readinitb(\u0026amp;rio, clientfd);\n18\n吐 hil e (Fgets(buf, MAXLINE, stdin) != NULL) {\nRio_i.riten(clientfd, buf, strlen(buf));\nRio_readlineb(\u0026amp;rio, buf, MAXLINE);\nFputs (buf, stdout) ;\n23\nClose (clientfd) ;\nexit(O);\n26 }\ncode/netplechoclient.c\n图 11-20 echo 客户端的 主程序\n循环终止之后 , 客户端关闭描述符。这会导致发送一个 EOF 通知到服务器, 当 服务器从它的 r e o _r e a d l i n e b 函数收到一个为零的返回码时, 就会检测到这个结果。在关闭它的描述符后, 客户端就终止了。既然客户端内核在一个进程终止时会自动关闭所有打开的描述符 ,第 24 行的 c l o s e 就没有必要了。不过,显 式 地 关 闭 已经打开的任何描述符是一个良好的编程习惯。\n图 11-21 展示 了 e ch o 服务 器的 主程序。在打开监听描述符后, 它 进入一个无限循环。每次循环都等待一个来自客户端的连接请求,输 出 已 连接客户端的域名和 IP 地址,并 调 用 e c h o 函 数 为 这些客户端服务。在 e c h o 程序返回后, 主程序关闭巳连接描述符。一旦客户端和服务器关闭了它们各自的描述符,连接也就终止了。 # 第 9 行 的 c l i e n t a d dr 变量是一个套接字地址结构,被 传 递给 a c c e p t 。在 a c c e p t 返\n回之前, 会在 c 让 e n t ad dr 中填 上连接另一端客户端的套接字地址。注意, 我们将 c l i ­\ne n t a d dr 声明为 s 七r u c t sockaddr _s七or a g e 类 型 , 而 不 是 s 七r u c t sockaddr _i n 类型。根据定义, s o c ka d dr _ s t or a g e 结 构 足 够 大能 够装下任何类型的套接字地址,以 保 持 代 码 的协议无关性。\ncod e/ netp/ ech oserveri.c\n#include \u0026ldquo;csapp.h\u0026rdquo;\n2\n3 void echo (int connfd);\n4\n5 int rnain(int argc, char **argv)\n6 {\nint listenfd, connfd;\nsocklen_t cl i ent l en;\nstruct sockaddr_storage clientaddr; I* Enough space for any address *I\n1O char client_hostname [MAXLINE] , client_port [MAXLINE] ;\n11\nif ( ar g c != 2 ) {\nfprintf (stderr, \u0026ldquo;usage: %s \u0026lt;port\u0026gt;\\n\u0026rdquo;, argv [OJ ) ;\ne x i t ( O) ;\n15 }\n16\nlistenfd = Open_listenfd(argv [1]);\nwhile (1) {\nclientlen = s i ze of ( s tr uc t sockaddr_storage);\nconnfd = Ac ce pt ( l i s t e nf d , (SA * ) \u0026amp;cl i e nt add r , \u0026amp;c l i ent l en ) ;\nGe t n ame i nfo (( S A * ) \u0026amp;clientaddr, clientlen, cli ent_hotsname , MAXLI NE,\nclient_port, MAXLINE, 0 ) ;\npr i nt f ( \u0026ldquo;Conn e c t ed to ( %s , %s ) \\ n \u0026quot; , client_hostname, cl i ent _por t ) ;\necho (connfd) ;\nClose (connf d) ;\n26 }\n27 exit(O);\n28 }\ncode/netplechoserveri.c\n图 11- 21 迭代 echo 服务楛的主程序\n注意 , 简单的 e c h o 服务器一次只能处理一个客户端。这种类型的服务器一次一个地在客户端间迭代, 称为迭代服务器 ( iterative server)。在第 12 章中, 我们将学习如何建立更加复杂的并发服 务器( co n c u r r e n t s e r v e r ) , 它能够同时处理多个客户端。\n最后, 图 11- 22 展示了 ec ho 程序的代码,该 程序反复读写文本行, 直到r i o _ r e a d li ne b\n函数在第 10 行遇到 EOF。\nco d e/netp / echo.c\n#include \u0026ldquo;csapp.h\u0026rdquo;\n2\n3 void echo(int connfd)\n4 {\ns1ze_t n;\nchar buf[MAXLINE] ;\nr1o_t rio;\n8\n9 Ri o r_ e ad i n i t b (\u0026amp;r i o , connfd);\n1O while ((n = Rio_readlineb (\u0026amp;rio, buf, MAXLINE)) ! = 0) {\nprintf(\u0026ldquo;server received %d bytes\\n\u0026rdquo;, (i nt )n) ;\nRio_writen(connfd, buf, n);\n13 }\n14 }\ncode/netp/echo.c\n图11-22 读和回送文本 行的 e c ho 函数\n田 日 在连接中 EOF 意味什么?\nEOF 的概念常常使 人们感到迷惑, 尤其是在因特 网 连接的上下文中。 首先, 我们需要理解其 实并没有像 EOF 宇符 这样的一个 东西。 进一步来说 , EOF 是由内核 检测到的一种条 件。应用程序在 它接 收到一个由r e a d 函数返回的零返回码时, 它就 会发现出 # EOF 条件。对于磁 盘文件 , 当前文件位置超 出 文件 长度时, 会发生 EOF 。 对于因特 网连接,当 一个进程 关闭 连接 它的 那一端 时, 会发生 EOF 。连接另一 端的 进程在试图读 取流中最后 一个字节之后的 字节时, 会检测到 EOF 。\n11. 5 Web 服务器\n迄今为止, 我们已 经在一个简单的 echo 服务器的 上下 文中讨论了网络编程。在这一节里,我们将向你展示如何利用网络编程的基本概念,来创建你自己的虽小但功能齐全的\nWeb 服务器。\n5 . 1 We b 基础\nWeb 客户端和服务器之间的交互用的是一个基于文本的应用级协议,叫 做 HT T P ( H\u0026rsquo;ljpertext Transfer Protocol, 超文本传输协议)。HTTP 是一个简单的协议。一个 Web 客户端(即浏览器)打开一个到服务器的因特网连接,并且请求某些内容。服务器响应所请 求的内容,然后关闭连接。浏览器读取这些内容,并把它显示在屏幕上。\nWeb 服务和常规的文 件检索服务(例如FT P) 有什么区别呢?主要的区别是 Web 内容可以用一种叫做 HT ML ( Hypertext Markup Language, 超文本标记语言)的语言来编写。一个 HT ML 程序(页)包含指令(标记),它 们告诉浏览器如何显示这页中的各种文本和图形对象。例如,代码\n\u0026lt;b\u0026gt; Make me bold! \u0026lt;/b\u0026gt; # 告诉浏 览器用粗体字类型输出\u0026lt; b \u0026gt; 和\u0026lt; / b \u0026gt; 标记之间的文本。然而, HT ML 真正的强大之处在于一个页面可以包含指针(超链接),这些指针可以指向存放在任何因特网主机上的 内容。例如 , 一个格式如下的 HT ML 行\n\u0026lt;a href=\u0026ldquo;http://www.cmu.edu/index.html\u0026rdquo;\u0026gt;Carnegie Mellon\u0026lt;/a\u0026gt; # 告诉浏览器高亮显示文本对象 \u0026quot; Car ne g i e Mellon\u0026rdquo;, 并且创建一个超链接,它指向存放在 CMU Web 服务器上叫做 i nde x . h七ml 的 HT ML 文件。如 果用户单击了这个高亮文本对象,浏 览器就会从 CMU 服务器中请求相应的 HT ML 文件并显示 它。\n田 日 万维网的起源 # 万维网是 Tim Bemers-Lee 发明的, 他是一位在瑞典物理 实验室 CERN( 欧洲粒子物理研究所)工作的软件工程师。1989 年, Berners-Lee 写了一个内部备忘录,提出了一个分布式超文\n本系统,它 能连接“用链接组成的笔记的网(web of notes with links)\u0026quot; 。提出这个 系统的目的是帮助 CERN 的科学家共享和管理信息。在接下来的两年多里, Bemers-Lee 实现了笫一个 Web服务器和Web 浏览器之后, 在 CERN 内部以及其他一些网站中, Web 发展出了小 规模的拥护者。\n1993年一个关键事件发生了, M釭 c Andreesen(他后来创建了 Netscape)和他在NCSA的同事发布了一种图形化的浏览器, 叫做 MOSAIC, 可以在三种主要的平台上所使用: Unix、Windows 和\n:Macintosh。在MOSAIC发布后, 对 Web的兴趣爆发了, Web网站以 每年10 倍或更高的数量 增长。到 2015年,世界上已经有超过975000 000 个 Web 网站了(源自 Netcraft Web Survey)。\n5. 2 W e b 内 容\n对于 W e b 客户端和服务器而言, 内容是与一个 MIME (Multipurpose Internet Mail\nExtensions, 多用途的网际邮件扩充协议)类型相关的字节序列。图 11- 23 展示了一些常用的 MIM E 类型。\n图 11-23 MIME 类型示例\nWeb 服务器以 两种不同的方式向客户端 提供内容: # 取一个磁盘文件 , 并将它的内 容返回给客 户端。磁盘文件称为静态内 容( s ta tic con­\ntent), 而返回文件给客户端的过程称为服务静态内 容( se rving static content ) 。.\n运行一个可执行文件,并将它的输出返回给客户端。运行时可执行文件产生的输出称为动 态内 容( d ynam ic content) , 而运行程序并返回它的输出到客户端的过程称为服务动 态内 容( se r ving dynamic conte nt ) 。\n每条由 Web 服务器返回的内容都是和它管理的某个文件相关联的。这些文件中的每一个都有一个唯一的名字,叫做URL( Universal Resource Locator, 通用资源定位符)。例如, URL\nht t p : / / 吓 w. g oogl e . com: 80/ i ndex . ht ml # 表示因特网主机 www. g o o g l e . c om 上一个 称为/ i nd e x . h t ml 的 H T M L 文件, 它是由一个监听端口 80 的 Web 服务器管 理的。端口号是可选的,默认 为知名的 H T T P 端口 80。可执行文件的 U R L 可以在文件名后包括程序参 数。\u0026quot; ?\u0026quot; 字符分隔文件名和参数, 而且每个参数都用\u0026quot; \u0026amp;\u0026quot; 字符分隔开。例如 , U R L\nhttp://bluefish.ics.cs.cmu.edu:8000/cgi-bin/adder?15000\u0026213 # 标识了一个叫做/ cg 丘 b i n / a dde r 的可执行文件, 会带两个参数字符串 15000 和 213 来调用它。在事务过程中,客户端和 服务器使用的是 URL 的不同部分。例如, 客户端使用前缀\nhttp://www.google.com:80 # 来决定与哪类服务器联系,服务器在哪里,以及它监听的端口号是多少。服务器使用后缀\n/index.html\n来发现在它文件系统中的文件,并确定请求的是静态内容还是动态内容。关于服务器如何解释一个 U RL 的后缀,有 几点需要理解:\n确定一个 U R L 指向的是静态内容还是 动态内容没有标准的规则。每个服务器对它所管理的文件都有自己的规则。一种经典的(老式的)方法是,确定一组目录,例如 c g i - 区 n , 所有的可执行性文件都必须存放这些目录中。 后缀中的最开始的那个 \u0026ldquo;/\u0026rdquo; 不表示 Linux 的根目 录。相反, 它表示的是被请求内容类型的主目录。例如,可以将一个服务器配置成这样:所有的静态内容存放在目录/ usr / htt pd / ht ml 下 ,而所有的动态内 容都存放在目录/ usr / h tt pd / c g 丘 b i n 下。 最小的 UR L 后缀是 \u0026ldquo;I\u0026rdquo; 字符, 所有服务器将其扩展为某个默认的 主页, 例如/ i nde x . h t ml 。这解 释了为什么简单地在浏览器中键入一个域名就可以取出一个网站的主页。浏览器在 U R L 后添加缺失的 \u0026ldquo;/\u0026rdquo; , 并将之传递给服务器, 服务器又把 \u0026ldquo;I\u0026rdquo; 扩展到某个默认的文件名。 11. 5. 3 HT T P 事务\n因为 H T T P 是基于在因特网连接上传送的文本行的, 我们可以使用 L in u x 的 T E L ­ N E T 程序来和因特网上的任何 W e b 服务器执行 事务。对于调试在连 接上通过文本行来与客户端对话的 服务器来说, T E L N E T 程序是非常便利的。例如,图 11 - 24 使用 T E L N E T 向 A O L W e b 服务器请求主页。\nl i nu x\u0026gt; t el n e t w 叮 . a ol . c om 80 2 Trying 205. 188. 146. 23. . .\nConnected to aol . com . Escape character i s \u0026lsquo;- J \u0026rsquo; . s GET I HTTP/ 1 . 1\n6 Hos t : 日 叩 . a ol . co m\n7\nClient: open connection to server Telnet prints 3 lines to the terminal\nClient: request line\nClient: required HTTP/1.1 header Client: empty line terminates headers\nHTTP/1. 0 200 OK Server: response line MI ME- Ver s i on : 1. 0 Server: followed by ti ve response headers 10 Da t e : Mon , 8 Jan 20 1 0 4 : 59 : 4 2 GMT Server: Apa c he - Co y ot e / 1 . 1 Con t e nt - Type : t e xt / ht ml Server: expect HTML in the response body Con t e nt - Le ng t h : 42092 Server: expect 42, 092 bytes in the response body 14\n15 \u0026lt;html\u0026gt;\n16\nSer ver : empty line terminates response headers Server : first HTML line in response body\nSer ver : 766 lines of HTML not shown\n17 \u0026lt;/html\u0026gt; Server: last HTML line in response body 18 · Conn e c t i on c l o s e d by foreign host. Server : closes connection\n19 l i nu x\u0026gt;\nCl i ent : c1 oses connection and terminates\n图 11-24 一个服务静 态内容的 HT T P 事务\n在第 1 行, 我们从 L in u x s hell 运行 T EL NET , 要求它打开一个到 A O L W e b 服务器的连接。T E L N E T 向终端打印三行输出, 打开连接, 然后等待我们输入文本(第5 行)。每次输入一个文本行, 并键入回车键, T E L N E T 会读取该行, 在后面加上回车和换行符号(在C 的表示中为 \u0026quot; \\r \\ n\u0026quot; ) , 并且将这一行发送到服务器。这是和 H TT P 标准相符的, H TT P 标准要求每个文本行都由一对回车和换行符来 结束。为了发起 事务, 我们输入一个 H T T P 请求(第5 ~ 7 行)。服务器返回H TT P 响应(第8 ~ 1 7 行), 然后关闭连接(第18 行)。\nHTT P 请求\n一个 H T T P 请求的组成 是这样的: 一个请求行 ( r eq ues t line ) ( 第 5 行), 后 面跟随零个或更多个请求报 头 ( r e q u es t h ea d e r ) ( 第 6 行), 再跟随 一个空的文本行来终止报头列表\n(第 7 行)。一个请求行的形式是 # method URI version\nHT T P 支持许多不同的方 法,包 括 G E T 、 P O S T 、 O P T I O N S 、 H E A D 、P U T 、 D E L E T E\n和 T R A C E 。我们将只讨论广为应用的 G E T 方法, 大多数 H T T P 请求都是这种类型的。\nG ET 方法指导服务器生成和返回 U RI ( U nifo rm Resource Identifier, 统一资源标识符)标识的内 容。U RI 是相应的 U R L 的后缀, 包括文件名和可选的参数 。e\n请求行中的 version 字段表明了该请求遵循的 H T T P 版本。最新的 H T T P 版本是H T T P / 1. 1 [ 37] 。H T T P / 1. 0 是从 1996 年沿用 至今的老版本 [ 6] 。H T T P / 1. 1 定义了一些附加的报头, 为诸如缓冲和安 全等高级特性提供支持, 它还支持一种机制,允 许客户端和服务器在同一条持久连接 ( persis t e n t con nect io n ) 上执行多个事务。在实际中, 两个版本是互相兼容的, 因为 H T T P / 1. o 的客户端 和服务器会简单地忽略 H T T P / 1. 1 的报头。\n总的来说, 第 5 行的请求行要求服务器取出并返回 H T M L 文件/ i nde x . h t ml 。 它也告知服务器请求剩下的部分是 H T T P / 1. 1 格式的。\n请求报头为服务器提供了额外的信息,例如浏览器的商标名,或者浏览器理解的 # MIME 类型。请求报头的格式为\nhea d er - na me : head er-d a ta # 针对我们的目的, 唯一需要关注的报头是 Ho 江 报头(第6 行), 这个报头在 H T T P / 1. 1 请求中是需要的, 而在 H T T P / 1. 0 请求中是不需要的。代理缓存( pro xy cache ) 会使用 Ho s t 报头, 这个代理缓 存有时作为浏览器和管理被请求文件的原始服 务器 ( origin ser ver ) 的中介。客户端 和原始服务器之间, 可以 有多个代理, 即所谓的代理链( pro xy cha in ) 。 Ho s t 报头中的数据指示了原始服务器的域名,使得代理链中的代理能够判断它是否可以在本地 缓存中拥有一个被请求内容的副本。\n继续图 11- 24 中的示例, 第 7 行的空文本行(通过在键盘上键入回车键生成的)终止了报头, 并指示服务器发送被请求的 H T ML 文件。\nHT T P 响应\nH T T P 响应和 H T T P 请求是相似的。一个 H T T P 响应的组成是这样的: 一个响应行(response line)(第 8 行), 后面跟随 着零个或更多的响应报 头 ( res pons e header)(第 9 ~ 13行),再 跟随一个终止报头的空行(第14 行),再 跟随一个响应主体( res ponse body)(第 15 ~ 17行)。一个响应行的格式是\nversion sta tus -code sta tus-message # version 字段描述的是 响应所遵循的 H T T P 版本。状 态码( stat us飞 code) 是一个 3 位的正整数, 指明对请求的处理。状态消息 ( s tat us message) 给出与错误代码等价的英文描述。图 11- 25 列出了一些常见的状态码, 以及它们相应的消息。\n状态代码 状态消息 描述 200 成功 处理请求无误 301 永久移动 内容巳移动到locat10n头中指明的主机上 400 错误请求 服务器不能理解请求 403 禁止 服务器无权访问所请求的文件 404 未发现 服务器不能找到所请求的文件 501 未实现 服务器不支持请求的方法 505 HTTP版本不支持 服务器不支持请求的版本 图 11-25 一些 HTT P 状态码\ne 实际上,只 有当浏览器请求内容时 , 这才是真的。如果代理服务器请求内容 , 那么这个 URI 必须是完整的\nU RL。\n第 9~ 13 行的响应报头提供了关于响应的附 加信息。针对我们的目的, 两个最重要的报头是 Co n t e n t - Typ e ( 第 1 2 行), 它告诉客户端响应主体中内容的 M IM E 类型; 以及Co n 七e n t - Le ng 店(第13 行),用 来指示响应主体的字节大小。\n第 14 行的终止响应报头的空文本行, 其后跟随着响应主体, 响应主体 中包含着被请求的内容。 # 5. 4 服务动态内容 如果我们停下来考虑一下,一个服务器是如何向客户端提供动态内容的,就会发现一些问题。例如,客户端如何将程序参数传递给服务器?服务器如何将这些参数传递给它所创建的子进程?服务器如何将子进程生成内容所需要的其他信息传递给子进程?子进程将它的输出发送到哪里? 一个称为 CG ICCo mmon Gateway Interface, 通用网关接口)的实际标准的出现解决了这些问题。 # 1 客户端如何 将程序 参数传 递给服务器\nGET 请求的参数在 UR I 中传递。正如我们看到的, 一个 \u0026ldquo;?\u0026rdquo; 字符分隔了文件名和参数,而每个参数都用一个 \u0026quot; \u0026amp;\u0026quot; 字符分隔开。参数中不允许有空格, 而必须 用字符串 \u0026quot; %2 o\u0026quot; 来表示。对其他特殊字符,也存在着相似的编码。\n田 日 在 HTT P POS T 请求中传递参数\nHTTP POST 请求的参数是 在请求主体 中而不 是 U RI 中传递的 。\n服务器如何将参数传递给子进程在服务器接收一个如下的请求后 # GET /cgi-bin/adder?15000\u0026amp;213 HTTP/1.1 # 它调用 f or k 来创建一个子进程, 并调用 e x e c v e 在子进 程的上下文中执行/ c g i - b i n / a d­\nde 程序。像 a d der 这样的程序 , 常常被称为 CG I 程序, 因为它们遵守 CG I 标准的规则。而且, 因为许多 CG I 程序是 用 Pe rl 脚本编写的, 所以 CG I 程序也常被称为 CG I 脚本。在调用 e xe c ve 之前, 子进程将 CG I 环境变量 Q U E R Y_ST RI NG 设置为 \u0026quot; 1 5000 \u0026amp;21 3\u0026quot; , ad­\nder 程序在运行时 可以用 Lin ux g e t e nv 函数来引用它。\n服务器如何将其他信息传递给子进程 # CGI 定义了大量的其他环境变量, 一个 CG I 程序在它运行时可以设置这些环境变量。\n图 11-26 给出了其中的一部分。 # 图 11-26 CGI 环境变量示例\n子进程将它的输出发送到哪里 # 一个 CG I 程序将它的动态内容发送到标准输出 。在子进程加载并 运行 CGI 程序之前,\n它使用 L in u x d u p 2 函数将标准输出重定向到和客户端相关联的已连接描述符。因此, 任何 CG I 程序写到标准输出的东西都会直接到达客户端。\n注意,因为父进程不知道子进程生成的内容的类型或大小,所以子进程就要负责生成 # Content- t ype 和 Co n t e n t - l e n g t h 响应报头, 以及终止报头的空行 。\n图 11 - 27 展示了一个简单的 CGI 程序, 它对两个参数求和, 并返回带结果的 H T M L\n文件给客户端 。图 11 - 28 展示了一个 H T T P 事务, 它根据 a d d er 程序提供动态内容。\ncode/netpltiny/cgi-bin/adder.c\n#include \u0026ldquo;csapp.h\u0026rdquo;\n2\nint main(void) {\nchar *buf, *Pi\nchar argl[MAXLINE], arg2[MAXLINE], c ont e nt [MAX LI NE] ; 6 int n1=0, n2=0;\n7\n. I * Extract the two arguments *I\nif ((buf = getenv (11QUERY_STRING 11)) ! = NULL) { 1o p = strchr (buf ,\u0026rsquo;\u0026amp;\u0026rsquo;) ;\n11 *P =\u0026rsquo;\\0\u0026rsquo;;\nstrcpy(arg1, buf);\nstrcpy(arg2, p+1);\nn1 = atoi(arg1);\nn2 = atoi(arg2); 16 }\n17\nI* Make the response body *I\nsprintf (content, 11QUERY_STRING=%s11 , buf) ;\nsprintf(content, 11Welcome to a dd . com : 11);\nsprintf(content, 11%sTHE Internet addition portal. \\r\\n\u0026lt;p\u0026gt;11, content);\nsprintf (content, 11%sThe answer is: %d + %d = %d\\r\\n\u0026lt;p\u0026gt;11,\ncontent, n1, n2, n1 + n2);\nsprintf (content, 11%sThanks for visiting! \\r\\011, content); 25\nI* Generate the HTTP response *I\nprintf(11Connection: close\\r\\n11);\nprintf(11Content-length: %d\\r\\n11, (int)strlen(content));\nprintf(11Content-type: t ext / html \\r \\ n \\r \\ n 11) ;\nprintf(11%s11, content);\nf fl us h ( s t dout ) ; 32\n33 exit(O); 34 }\ncode/netpltiny/cgi-bin/adder.c\n图 11-27 对两个整数求和的 CGI 程序\nlinux\u0026gt; telnet kit tyha甘k.cmcl. cs. emu. edu 8000 Client: open connect i on\n2 Trying 128. 2.194.242\u0026hellip;\nConnected to kittyhawk.cmcl.cs.cmu.edu. Escape character i s \u0026lsquo;- J \u0026rsquo; .\nGET /cgi-bin/adder?15000\u0026amp;213 HTTP/1.0\nHTTP/1. 0 200 OK\nServer: Tiny Web Server Content-length: 115 Content-type: text/html\nClient: request line\nCl i en t : empty 1 ine terminates headers Server: response line\nServer : identify server\nAdder: expect 115 bytes in response body Adder: expect HTML in response body\nAd der : empty line terminates headers\nWelcome to add.com: THE Internet addition portal. Adder: first HTML line\n\u0026lt;p\u0026gt;The answer is: 15000 + 213 = 15213 Ad der : second HTML line in response body\n\u0026lt;p\u0026gt;Thanks for visiting! Adder: third HTML line in response body\nConnection closed by foreign host.\nlinux\u0026gt;\nServer: closes connection\nClient: closes connection and terminates\n图 11-28 - 个提供动态 H T ML 内容的 HT T P 事务\n_m 将HTTP POST 请求中的 参数传递给 CGI 程序\n对于 POST 请求,子进程也 需要 重定向标 准输入 到已连接描 述符。然后 , CGI 程序会从标准扴入 中读取 请求主体 中的 参数。 # 练习题 11. 5 在 1 0 . 11 节中, 我们警 告过你 关 于在 网络应用 中使用 C 标准 I/ 0 函数的危险。然而, 图 11 - 27 中的 CGI 程序却 能没有任何 问题地使用 标准 I/ 0 。为什 么呢?\n6 综合: TINY Web 服务器 我们通过开发一 个虽小但功能齐全的称为 T INY 的 W e b 服务器来结束对网络编程的讨论。TINY 是一个有趣的程序。在短短 2 5 0 行代码中, 它结合了许多我们已经学习到的 思想, 例如进程控制、Unix I/ 0 、套 接字接口和 HT TP。虽然它缺乏 一个实际服务器所具备的功能性 、健壮性和安全性, 但是它足够用来为实际的 W e b 浏览器提供静态和动态的内容。我们鼓励 你研究它, 并且自己 实现它。将一个实际的浏览器指向你自己的服务器, 看着它显 示一个复杂的带 有文本 和图片的 W e b 页面, 真是非常令人兴奋(甚至对我们这些作者来说,也 是如此!)。 # TINY 的 main 程序\n图 11 - 2 9 展示了 TINY 的主程序。TINY 是一个迭代服务器, 监听在命令行中传递来的端口上的连接请求 。在通过调用 o p e n _ l i s t e n f d 函数打开一个监听套接字以后, T INY 执行典型的无限 服务器循环, 不断地接受连接请求(第3 2 行), 执行事务(第3 6 行), 并关闭连接的它那一 端(第3 7 行)。\ndoit 函数\n图 11 - 30 中的 d o i t 函数处理一个 HT TP 事务。首先, 我们 读和解析请求行(第11 1 4 行)。注意 , 我们使 用图 11 - 8 中的r i o _r e a d l i n e b 函数读取请求行。\nTINY 只支持 GET 方法。如果客户端请求其他方法(比如 POST) , 我们发送给它一个错误信息,并 返回到主程序(第1 5 1 9 行), 主程序 随后关闭连接并等待下一个连接请求。否则,我们 读并且(像我们将要看到的那样)忽略任何请求报头(第 20 行)。\ncode/netpltinyltiny.c\nI*\n* tiny.c - A simple, iterative HTTP/1.0 Web server that uses the GET method to serve static anddynamic content\n4 *I\n5 #include \u0026ldquo;csapp.h\u0026rdquo;\n6\nvoid doit(int fd);\nvoid read_requesthdrs(rio_t *rp);\nint parse_uri(char *uri, char *filename, char *cgiargs);\nvoid serve_static(int fd, cha工 *fil ename , int filesize);\n11 void get_filetype(char *filename, char *filetype);\n12 void serve_dynamic(int fd, char *filename, char *cgiargs);\n13 void clienterror(int fd, char *cause, char *errnum,\n14 char *shortmsg, char *longmsg);\n15\n16 int main(int argc, char **argv)\n17 {\nint listenfd, connfd;\ncha 工 hos t n ame [ MAX LI NE] , port[MAXLINE];\nsocklen_t clientlen;\nstruct sockadd_rs t or age clientaddr;\n22\nI* Check command-line args *I\nif (argc != 2) {\nfprintf (stderr, \u0026ldquo;usage: %s \u0026lt;port\u0026gt;\\n\u0026rdquo;, argv [OJ);\nexit (1);\n27 }\n28\nlistenfd = Open_listenfd(argv[1]);\nwhile (1) {\nclientlen = sizeof (clientaddr)·\nconnfd = Accept (listenfd, (SA *)\u0026amp;clientaddr, \u0026amp;clientlen);\nGetnameinfo((SA *) \u0026amp;clientaddr, clientlen, hostname, MAXLINE,\nport , MAXLINE, 0) ;\nprintf(\u0026ldquo;Accepted connection from (%s, %s)\\n\u0026rdquo;, hostname, port);\ndoit(connfd);\nClose(connfd);\n38 }\n39 }\ncode/netpltinyltiny.c\n图 11-29 TINY Web 服务器\n然后, 我们将 URI 解析为一个文件名和一个可能 为空的 CGI 参数字符串, 并且设置一个标志, 表明请求的是静态内容还是动态内容(第23 行)。如果文件在磁盘上不存在, 我们立即发送一个错误信息给客户端并返回。 # 最后, 如果请求的是静态内容,我 们就验证该文件是一个普通文件, 而我们 是有读权限的(第31 行)。如果是这样, 我们就向客户端提供静态内容(第36 行)。相似地, 如果请求的是动态内容, 我们就验证该文件是可执行 文件(第39 行), 如果是这样,我 们就继续, 并且提供动态内容(第44 行)。\ncode/netpltinyltiny.c\nvoid doit(int fd) 2 {\nint is_static;\nstruct stat sbuf; char buf[MAXLINE], method[MAXLINE], uri[MAXLINE], version[MAXLINE];\nchar filename [MAXLINE], cgiargs [MAXLINE]; rio_t rio;\n8\nI* Read request line andheaders *I\nRio_readinitb(\u0026amp;rio, fd);\nRio_readlineb(\u0026amp;rio, buf, MAXLINE);\nprintf(\u0026ldquo;Request headers:\\n\u0026rdquo;);\nprintf(\u0026quot;%s\u0026quot;, buf);\nsscanf(buf, \u0026ldquo;%s %s %s\u0026rdquo;, method, uri, version);\nif (strcasecmp(method, \u0026ldquo;GET\u0026rdquo;)) {\nclienterror(fd, method, \u0026ldquo;501\u0026rdquo;, \u0026ldquo;Not implemented\u0026rdquo;,\n\u0026ldquo;Tiny does not implement this method\u0026rdquo;);\nreturn;\n19 }\n20 read_requesthdrs (\u0026amp;rio);\n21\nI* Parse URI from GET request *I\nis_static = _par s e _ur i (uri, filename, cgiargs);\nif (stat(filename, \u0026amp;sbuf) \u0026lt; 0) {\nclienterror(fd, filename, \u0026ldquo;404\u0026rdquo;, \u0026ldquo;Not found\u0026rdquo;,\n\u0026ldquo;Tiny couldn\u0026rsquo;t find this file\u0026rdquo;);\nreturn;\n28 }\n29\nif (is_static) { I*Serve static content *I\nif (! (S_ISREG(sbuf.st_mode)) 11 ! (S_IRUSR \u0026amp; s buf . st_mode)) {\nclienterror(fd, filename, \u0026ldquo;403\u0026rdquo;, \u0026ldquo;Forbidden\u0026rdquo;,\n· \u0026ldquo;Ti ny couldn\u0026rsquo;t read the file\u0026rdquo;);\nreturn;\n35 }\n36 serve_static (fd, filename, sbuf. st _s i z e ) ;\n37 }\nelse { I* Serve dynamic content *I\nif (! (S_ISREG (sbuf.st_mode)) I I ! (S_IXUSR \u0026amp; sbuf. st_mode)) {\nclienterror(fd, filename, \u0026ldquo;403\u0026rdquo;, \u0026ldquo;Forbidden\u0026rdquo;,\n\u0026ldquo;Tiny couldn\u0026rsquo;t run the CGI program\u0026rdquo;);\nreturn;\n43 }\n44 serve_dynamic(fd, filename, cgiargs);\n45 }\n46 }\ncode/netp/tinyltiny.c\nc l ie nte rro r 函数 图 11-30 TINY d o i t 处理一个 H T T P 事务\nT I NY 缺乏一个实际服务器的许多错误处理特性。然而, 它会检查一些明显的错误, 并把它们报告给客户端。图 11-31 中的 c l i e n t er r or 函数发送一个 HT TP 响应到客户端, 在响应行中包含 相应的状态码和状态消息, 响应主体中包含一个 HT ML 文件,向 浏览器\n的用户解释这个错误。 # void clienterror(int fd, char•cause, char•errnum,\nchar•shortmsg, char•longmsg)\n3 {\ncode/netpltinyltiny.c\n4 char buf[MAXLINE], body[MAXBUF]; 5 6 I• Build the HTTP response body•/ 7 sprintf(body, \u0026ldquo;\u0026lt;html\u0026gt;\u0026lt;title\u0026gt;Tiny Error\u0026lt;/title\u0026gt;\u0026rdquo;); 8 sprintf (body, 11%s\u0026lt;body bgcolor=\u0026quot; \u0026ldquo;ffffff1111\u0026gt;\\r\\n11, body); 9 sprintf(body, 11%s%s: %s\\r\\n11, body, errnum, shortmsg); 10 sprintf(body, 11%s\u0026lt;p\u0026gt;%s: %s\\r\\n11, body, longmsg, cause); 11 sprintf (body, 11%s\u0026lt;hr\u0026gt;\u0026lt;em\u0026gt;The Tiny Web server\u0026lt;/em\u0026gt;\\r\\n\u0026rdquo;, body); 12 13 /• Print the HTTP response•I 14 sprintf (buf, \u0026ldquo;HTTP/1.0 %s %s\\r\\n11, errnum, shortmsg); 15 Rio_writen(fd, buf, strlen(buf)); 16 sprintf(buf, \u0026ldquo;Content-type: text/html\\r\\n\u0026rdquo;); 17 Rio_writen(fd, buf, strlen(buf)); 18 sprintf(buf, \u0026ldquo;Content-length: %d\\r\\n\\r\\n11, (int)strlen(body)); 19 Rio_writen(fd, buf, strlen(buf)); 20 Rio_writen(fd, body, strlen(body)); 21 } code/netp/tinyltiny.c\n图 11- 31 T I NY cl i e nt err or 向客户端发送一个出错消息\n回想一下, H T M L 响应应该指明主体中内容的大小和类型。因此, 我们选择创建H T M L 内容为一个 字符串, 这样一来我们可以 简单地确定它的大小。还有, 请注意我们为所有的输出使用的都是图 10- 4 中健壮的r i o _wr i t e n 函数。 # re a d _ re q ue s t hd rs 函数\nT INY 不使用请求报头中的任何信息。它仅仅调用图 11-32 中的r e a d—r e q u e s t h d r s 函数来读取并忽略这些报头。注意,终止请求报头的空文本行是由回车和换行符对组成 的, 我们在第 6 行中检查它。\nvoid read_requesthdrs(rio_t *rp)\n2 {\ncha 工 buf [ MAX LI NE] ;\n4\nRio_readlineb(rp, buf, MAXLINE);\nwhile(strcmp(buf, \u0026ldquo;\\r\\n\u0026rdquo;)) {\nRio_readlineb(rp, buf, MAXLINE);\nprintf(\u0026quot;%s\u0026rdquo;, buf);\n9 }\n10 return;\n11 }\ncode/netp/tinyltiny.c\ncode/netpltinyltiny.c\n图 11-32 TINYr ead_ r eque s 七hdr s 读取并忽略请求报头\npa rs e _uri 函数 # T INY 假设静态内容的主目录就是 它的当前目录, 而可执行文件的主目录是 . / cg让 bi n。任何包含字符串 cg丘 bi n 的 URI 都会被认为表示的是 对动态内容的请求。默认的文件名是\n. / home . html 。 # 图 11 - 33 中的 par s e _u r i 函数实现了这些策略。它将 U RI 解析为一 个文件名和一个可选的 CG I 参数字符串。如果请求的是静态内容(第5 行), 我们将清除 CG I 参数字符串\n(第 6 行), 然后将 U RI 转换为一个 Lin ux 相对路径名, 例如 . / i nd e x . h t ml ( 第 7 ~ 8 行)。如果 U RI 是用 \u0026ldquo;/\u0026rdquo; 结尾的(第9 行), 我们将把默认的文件名加在后面(第10 行)。另一方 面, 如果请求的是 动态内容(第13 行), 我们 就会抽取出所有的 CG I 参数(第14 ~ 20 行),并将 U R I 剩下的部分转换为一个 L inu x 相对文件名(第21 ~ 22 行)。\ncode/netpltinyltiny.c int pars e _ur i( cha 工 *ur i , cha 工 *f i l ename , char *cgia 工 gs ) # 2 {\n3 char *ptr; # 5 if (!strstr(uri, \u0026ldquo;cgi-bin\u0026rdquo;)) { I* Static content *I strcpy(cgiargs, \u0026ldquo;\u0026rdquo;);\nstrcpy(filename, \u0026ldquo;. \u0026ldquo;);\nstrcat (filename, uri); if (uri[strlen(uri)-1] ==\u0026rsquo;/\u0026rsquo;) strcat(filename, \u0026ldquo;home.html\u0026rdquo;); return 1; # 12 }\nelse { I* Dynamic content *I # ptr = index(uri,\u0026rsquo;?\u0026rsquo;); if (ptr) { strcpy(cgiargs, ptr+1); # *ptr = I \\0 I j\n18 }\n19 else\n20 strcpy (cgiargs, \u0026ldquo;\u0026rdquo;) ; 21 strcpy (filename, \u0026ldquo;. \u0026ldquo;) ; 22 strcat (filename, uri); 23 r etu 工 n O; 24 } 25 } code/netpltinyltiny.c # 图 11- 33 TINY par s e_ur i 解析一个 HTT P URI\nse rve _s ta t ic 函数 # T I N Y 提供五种常见类型的静态内容: H T M L 文件、无格式的文本文件,以 及编码为 G IF 、P NG 和 ] PG 格式的图片。\n图 11-34 中的 s er ve _s t a t i c 函数发送一个 H T T P 响应, 其主体包含一个本地文件的内容。首先, 我们通过检查文件名的后缀来判断文件类型(第 7 行), 并且发送响应行和响应报头给客户端(第8,\u0026hellip;_,1 3 行)。 注意用一个空行终 止报头。\ncode/netpltinyltiny.c\n1 void serve_static(int fd, char *filename, int filesize)\n2 {\n3 int srcfd;\n4 char *srcp, filetype [MAXLINE] , buf [MAXBUF] ;\n5\n6 I* Send response headers to client *I\nget_filetype(filename, filetype);\nsprintf(buf, \u0026ldquo;HTTP/1.0 200 OK\\r\\n\u0026rdquo;);\nsprintf(buf, \u0026ldquo;%sServer: Tiny Web Server\\r\\n\u0026rdquo;, buf);\nsprintf(buf, \u0026ldquo;%sConnection: close\\r\\n\u0026rdquo;, buf);\n11 sprintf(buf, \u0026ldquo;%sContent-length: %d\\r\\n\u0026rdquo;, buf, filesize);\n12 sprintf(buf, \u0026ldquo;%sContent-type: %s\\r\\n\\r\\n\u0026rdquo;, buf, filetype);\n13 Rio_writen(fd, buf, strlen(buf));\n14 printf(\u0026ldquo;Response headers:\\n\u0026rdquo;);\n1 5 pr i nt f ( \u0026ldquo;知 \u0026quot; , buf);\n16\n17 I* Send response body to client *I\n18 srcfd = Open(filename, O_RDONLY, O);\nsrcp = Mmap(O, filesize, PROT_READ, MAP_PRIVATE, srcfd, O);\nClose(srcfd) ;\nRio_writen (fd, srcp, filesize) ;\nMunmap(srcp, filesize);\n23 }\n24\n2s I*\n26 * get_filetype - Derive file type from filename\n21 *I\n28 void get_filetype (char *filename, char *filetype)\n29 {\nif (strstr(filename, \u0026ldquo;.html\u0026rdquo;))\nstrcpy(filetype, \u0026ldquo;text/html\u0026rdquo;);\nelse if (strstr(filename, \u0026ldquo;.gif\u0026rdquo;))\nstrcpy(filetype, \u0026ldquo;image/gif\u0026rdquo;);\nelse if (strstr(filename, 11.png\u0026rdquo;))\nstrcpy(filetype, \u0026ldquo;image/png\u0026rdquo;);\nelse if (strstr(filename, \u0026ldquo;.jpg\u0026rdquo;))\nstrcpy(filetype, \u0026ldquo;image/jpeg\u0026rdquo;);\nelse\nstrcpy(filetype, \u0026ldquo;text/plain\u0026rdquo;);\n40 }\ncode/netpltinyltiny.c\n图 l 1-3-l T I NY s er ve s t a已 c 为客户端 提供静态内容\n接着,我 们将被请求文 件的内容复制到已 连接描述符 f d 来发送响应主体。这里的 代码是比较微妙的,需 要仔细研究。第 18 行以读方式打开 f i l e n a me , 并获得它的描述符。在第 1 9 行, L in ux mma p 函数将被请求文件映射到一个虚拟内存空间。回想我们在第 9. 8 节中对 mma p 的 讨论 , 调用 mma p 将文件 s r c f d 的前 f i l e s i ze 个字节映射到一个从地址 s r c p 开始的私有只读虚拟内存区域。\n一旦将文件映射到内存,就 不 再 需 要 它 的 描 述符了, 所 以 我们关闭 这个文件(第20 行)。执行这项任务失败将导致潜在的致命的内存泄漏。第 21 行 执 行 的 是 到客户端的实际文件传送。r i o_wr i t e n 函数 复 制 从 s r c p 位 置开始的 f i l e s i ze 个字节(它们当然已经被映射到了所请求的文件)到客户端的已连接描述符。最后,第 22 行 释放了映射的虚拟内存区域。这对于避免潜在的致命的内存泄漏是很重要的。 # s e rve _dyna mic 函 数\nT INY 通过派生一个子进程并在子进程的上下文中运行一个 CGI 程序,来 提供各种类型的动态内容。\n图 11- 35 中的 s er v e _ d yna mi c 函 数 一 开始就向客户端发送一个表明成功的响应行, 同时 还包括带有信息的 Ser ver 报头。CGI 程序负责发送响应的剩余部分。注意, 这并不像我们可能希望的那样健壮,因 为它没有考虑到 CGI 程序会遇到某些错误的可能性。\ncode/netp/tinyltiny.c\nvoid serve_dynamic (int fd, char *filename, char *cgiargs)\n2 {\n3 char buf [MAXLINE], *emptylist [] = { NULL } ; # 4\n5 I* Return first part of HTTP response *I # 6 sprintf(buf, \u0026ldquo;HTTP/1.0 200 OK\\r\\n\u0026rdquo;);\nRio_writen(fd, buf, strlen(buf));\nsprintf(buf, \u0026ldquo;Server: Tiny Web Server\\r\\n\u0026rdquo;);\n9 Rio_writen(fd, buf, strlen(buf));\n10\n11 if (Fork() == 0) { I* Child *I\nI* Real server would set all CGI vars here *I # setenv(\u0026ldquo;QUERY_STRING\u0026rdquo;, cgiargs, 1);\nDup2(fd, STDOUT_FILENO); I* Redirect stdout to client *I\nExecve(filename, emptylist, environ); I* Run CGI program *I\n16 }\n1.7\n18 }\nWait(NULL); I* Parent waits for and reaps child *I # 图 11 - 35 TINY ser ve_dynami c 为客户端提供 动态内容\ncode/ne/ttpinyltiny.c # 在发送了响应的第一部分后, 我们会派生一个新的子进程(第11 行)。子进程用来自请求 URI 的 CGI 参数初始化 QUERY _ ST RING 环境变量(第 13 行)。注意,一 个 真 正 的服务器还会在此处设置其他的 CGI 环境变量。为了简短, 我们省略了这一步。\n接下来,子 进程重定向它的标准输出到已连接文件描述符(第14 行),然后 加 载并运行 # CGI程序(第15 行)。因为 CGI 程序运行在子进程的上下文中,它 能 够 访 问 所 有在调用 e x­ e c ve 函数之前就存在的打开文件和环境变量。因此, CGI程序写到标准输出上的任何东西都将直接送到客户端进程,不 会 受 到 任何来自父进程的干涉。其间,父 进 程阻塞在对 wa i t 的调 用中 , 等待当子进程终止的时候,回 收 操 作 系统分配给子进程的资源(第17 行)。\nm 处理过早关闭的连接\n尽管一个 Web 服务 器的基本功能非常简单,但是 我们不想给你 一个假象,以 为编写 一个实际的 Web 服务器是非常简单的。构造一个长时间运行而不 崩溃的健壮的 Web 服务器是 一件困难的任 务,比起 在 这 里我们已经学习了的内容, 它要求对 Linux 系统 编程有更加深入的\n理解。例如,如果一个服务器写一个已经被客户端关闭了的连接(比如,因为你在浏览器上 单击了 \u0026quot; Stop\u0026rdquo; 按钮),那 么第一 次 这 样 的 写会正 常返回, 但 是 第二 次 写就会引起发送 SIG­ PIP E 信号, 这个信号的默认行 为就 是 终 止 这 个进 程。如 果 捕 获或 者 忽略 SIG PIP E 信 号, 那么笫二 次写操作 会返 回值 - 1, 并将 err no 设 置 为 EP IP E。 s tr e rr 和 perr or 函数 将 EPIPE 错误报 告 为 \u0026quot; Broken pipe\u0026rdquo;, 这是一个迷惑了很多人的不太直观的信息。总的来说,一个健壮的服务器必须捕获这些 SIGP IPE 信号, 并且检查 wr i t e 函 数 调 用是否有 EP IPE 错误。\n11. 7 小结\n每个网络应用都是 基于客户端-服务器模型的。根据这个模型,一个应用是由一个服务器 和一个或多个客户端组成 的。服务器管理资 源,以某 种方式操作资源, 为它的 客户端提供服 务。客户端-服务器模型中的基 本操作是客户端-服务器事务 , 它是由客户端请求和跟随其后的 服务器响应 组成的 。\n客户端和服务器通过因特网这个全球网络来通信。从程序员的观点来看,我 们可以把因特网看成是一个全球范圉的主机集合,具有以下几个属性: 1) 每个因特网主机都有 一个唯一的 32 位名字, 称为它的 IP 地址。2)\nIP 地址的集合被映射为一个因特网域名的集合。3)不同因特网主机上的进程能够通过连接互相通信。\n客户端和服务器通过使用套接字接口建立连接。一个套接字是连接的一个端点,连接以文件描述符的形式提供给应用程序。套接字接口提供了打开 和关闭套接字描述符的函数。客户端和服务器通 过读写这些描述符来实 现彼此间的通信。\nWeb 服务器使用 HTT P 协议和它们的客户端(例如浏览器)彼此通信。浏览器向服务器请求 静态或者动态的内容。对静态内容的请求是通过从服务器磁 盘取得文件并把它返回 给客户 端来服务的 。对 动态内容的请求是通过在服务器上一个 子进程的上下文中运行一个程序并将它的输出返回给客户端来服务的。\nCGI 标准提供了一组规则,来管理客户端 如何将 程序参 数传递给服 务器,服 务器如何将这些参数以及其他信息传递给子进程 ,以 及子进程如何 将它的输出发送回 客户端 。只用几百行 C 代码就能实现一个简单但是有功效的 Web 服务器,它既 可以提供静态内容, 也可以提供动态内 容。\n参考文献说明\n有关因特网的官 方信息源被保存 在一系列的 可免费获取的带编号的文档 中,称 为 RFC( Requests for Comments , 请求注解 , Internet 标准(草案))。在以下网站可获得 可搜索的 RFC 的索引:\nhttp://rfc- editor .org\nRFC 通常是 为因特网基础设施的开发者编写 的, 因此,对 于普通读者来 说, 往往 过于详 细了。然而,要想获得权威信息 ,没有 比它更 好的信息来源了。HTT P/ I. 1 协议记录在 RFC 2616 中。 MIME 类型的权威列表保存在:\nh七t p : / / www. i a na . or g / a s s i gnme nt s / medi a- t ypes\nKerrisk 是全面 Linux 编程的圣经 , 提供了现代网络编程的详 细讨论 [ 62] 。关 于计算机网络互联 有大量很好的通用文献[ 65, 84, 114]。伟大的科技作 家 W. Richard Stevens 编写了一系列相关的经典文献 , 如高级\nUnix 编程[ 111] 、因特网协议 [ 109, 120, 107] , 以及 Unix 网络编程[ 108, ll O] 。 认真学习 Unix 系统编程的学生会想要研究 所有这些内容。不幸的是, St evens 在 1999 年 9 月 1 日逝世。我们会永远纪住他的贡献。\n家庭作业 # u l l . 6 A. 修改 TINY 使得它会原样返回每个请求行 和请求报头 。\n使用你 喜欢的浏览器向TINY发送一个对静态内容的请求。把TINY 的输出记录到一个文件中。\n检查 TINY 的输出 ,确定 你的浏览器使用的 HTT P 的版本。\n参考 RFC 2616 中的 HTT P/ 1. 1 标准, 确定你的浏览器的 HTT P 请求中每个报头的含义。你可以从 www.r f c - edi t or . or g/r f c . ht ml 获得 RFC 2616 。\n** 11. 7 扩展 T INY, 使得它可以提供 MPG 视频文件。用一个真正的浏览 器来检验你的工作 。\n•• 11. 8 修改 TINY, 使 得 它在 SIGCHLD 处 理程序中回收操作系统分配给 CGI 子进程的资源,而 不 是 显式地等待它们终止。\n•• 11. 9 修改 TINY, 使 得 当 它 服 务 静 态内容时,使 用 ma l l o c 、 r i o _r e a dn 和r i o _ wr i t e n , 而 不 是 mma p\n和r i o wr i t e n 来 复 制 被请求文件到已连接描述符。\n•• 11. 10 A. 写 出图 11- 27 中 CGI a dde r 函数的 HT ML 表单。你的表单应该包括两个文本框,用 户 将需 要相 加 的 两个数字填在这两个文本框中。你的表单应该使用 GET 方法请求内容。\nB. 用这样的方法来检查你的程序:使 用 一 个 真 正 的 浏 览器向 TINY 请 求 表单,向 TINY 提 交 填写 好的 表单,然 后显示 a dder 生成的动态内容。\n拿 11. 11 扩展 TINY, 以 支 持 HTT P HEAD 方法。使用 TELNET 作为 W eb 客户端来验证你的工作。\n\\* 11. 12 扩展 TINY, 使 得它服务以 HTT P POST 方式请求的动态内容。用你喜欢的 We b 浏览器来验证你的工作。\n*/ 11. 13 修改 TINY, 使 得 它 可 以 干净 地处理(而不是终止)在wr i t e 函 数 试 图 写 一 个 过 早 关 闭 的 连接时发\n生的 SIGPIPE信号和 EPIPE 错误。\n练习题答案 # 11. 1\ncodelnetplhex2dd.c\ncodelnetpldd2hex.c\n11 . 4 下 面是解决 方案。注意 , 使用 i ne t _ n t o p 要困难多少, 它要求很麻烦的强制类型转换和深层嵌套结构引用。g e t na me i n f o 函数要 简单许多 ,因 为它为我们 完成了这些工作。\ncodelnetplhostinfo-ntop.c\n#include \u0026ldquo;csapp.h\u0026rdquo;\n3 int main(int argc, char oargv) 4 { 5 struct addrinfo *P, •listp, hints; 6 struct sockaddr_in•sockp; 7 char buf[MAXLINE] ; int re; 1 0 if (argc != 2) { 11 fprintf (stderr, \u0026ldquo;usage: %s \u0026lt;domain name\u0026gt;\\n\u0026rdquo;, argv[OJ); exit(O); 15 I• Get a list of addrinfo records•I\n16 memset(\u0026amp;hints, 0, sizeof(struct addrinfo));\n17 hints.ai_family = AF_INET; /• IPv4 only•/\n18 hints.ai_socktype = SOCK_STREAM; I• Connections only•/\n19 if ((re = getaddrinfo(argv[1], NULL, \u0026amp;:hints, \u0026amp;:listp)) != 0) {\n20 fprintf (stderr, \u0026ldquo;getaddrinfo error: %s\\n\u0026rdquo;, gai_strerror(rc)); exit(1);\n22\n23\nI• Walk the list anddisplay each associated IP address•I\nfor (p = listp; p; p = p-\u0026gt;ai_next) {\nsockp = (struct sockaddr_in•)p-\u0026gt;ai_addr;\nInet_ntop(AF_INET, \u0026amp;:(sockp-\u0026gt;sin_addr), buf, MAXLINE);\nprintf(\u0026quot;%s\\n\u0026rdquo;, buf);\n29\n30\n/• Clean up•/\nFreeaddrinfo (listp) ;\n33\n34 exit(O);\n35 }\ncodelnetplhostinfo-ntop.c\n11. 5 标准 I/ 0 能在 CGI 程序里工作的原 因是,在子进程中运行 的 CGI 程序不需 要显式地关闭它的输人输出流。当子进程终止时,内核会自动关闭所有描述符。\n"},{"id":436,"href":"/zh/docs/technology/System/%E6%B7%B1%E5%85%A5%E7%90%86%E8%A7%A3%E8%AE%A1%E7%AE%97%E6%9C%BA%E7%B3%BB%E7%BB%9F/%E4%B9%A6%E7%B1%8D/%E7%AC%AC12%E7%AB%A0-%E5%B9%B6%E5%8F%91%E7%BC%96%E7%A8%8B/","title":"Index","section":"SpringCloud","content":"第 12 章\nC H A P T E R 12 . . . _\n并发编程\n正如我们在第 8 章学到的 , 如果逻辑控制流在时间上重叠, 那么它们就是并发的 ( concu rr e nt ) 。这种常见的现象称为并发 ( co nc urr e ncy ) , 出现在计算机系统的许多不同层面上。硬件 异常处理程序、进程和 L in ux 信号处理程序都是 大家很熟悉的例子。\n到目前为止,我们主要将并发看做是一种操作系统内核用来运行多个应用程序的机 制。但是, 并发不仅仅局限 于内核。它也可以在应用程序中 扮演重要角色。例如,我 们已经看到 Linux 信号处理程序如何允许应用响应异步事件, 例如用户键入 C t rl + C , 或者程序访问虚拟内存的 一个未定义的区域。应用级并发在其他情况下 也是很有用的:\n访问慢速 1/0 设备。当一个应用正在等待来自慢速 1/ 0 设备(例如磁盘)的数据到达时,内 核会运行其他进程,使 CP U 保持繁忙。每个应用都可以按照类似的方式, 通过交替 执行 I/ 0 请求和其他有用的工作来利用并发。\n与人交互。和计算机交互的人要求计算机有同时执行多个任务的能力。例如,他们 在打印一个文档时, 可能想要调整一个窗口的大小。现代视窗系统利用并发来提供这种能力。每次用户请求某种操作(比如通过单击鼠标)时, 一个独立的并发逻辑流被创建来执行这个操作。\n通过推迟工作以降低延迟。有时,应用程序能够通过推迟其他操作和并发地执行它 们, 利用并发来降低某些操作的延 迟。比如, 一个动态内存分配器可以通过推迟合并, 把它放到一个运行在较低优先级上的 并发“合并“ 流中, 在有空闲的 CP U 周期时充分利用这些空闲周期,从 而降低单个 fr e e 操作的延迟。\n服务多个网 络客户端。 我们在第 11 章中学习的迭代网络服务器是不现实的, 因为它们一次只能为一个客户端提供服务。因此, 一个慢速的客户端可能会导致服务 器拒绝为所有其他客户端服务。对千一个真正的服务器来说,可能期望它每秒为成百上千的 客户端提供服务,由千一个慢速客户端导致拒绝为其他客户端服务,这是不能接受 的。一个更好的方法是创建一个并发服务器,它为每个客户端创建一个单独的逻辑 流。这就允许服务器同时为多个客户端服务 , 并且也避免了慢速客户端独占 服务器。\n在多核机器上进行并行计算。许多现代系统都配备多核处理器,多核处理器中包含 有多个 CP U。被划分成并发流的应用程序通常在多 核机器上比 在单处理器机器上运行得快, 因为这些流会并行执行, 而不是交错执行。\n使用应用级并 发的应用程序称为并发程序 ( co nc ur re nt pro g ra m ) 。现代操作系统提供了三种基本的构造并发程序的方法:\n进程。用这种方法 ,每 个逻辑控制流都是一个进程, 由内核来调度和维护。因为进程有独立的虚拟地址空间,想要和其他流通信,控制流必须使用某种显式的进程间通信( in te r proces s communication, IPC) 机制。\nI/ 0 多路 复用。 在这种形式的并发编程中 , 应用程序在一个进程的上下文中显式地调度它们自己的逻辑流。逻辑流被模型化为状态机,数据到达文件描述符后,主程 序显式地从一个状态转换到另一个状态。因为程序是一个单独的进程, 所以所有的流都共享同一个地址空间。\n线程。线程是运行在一个单一进程上下文中的逻辑流,由内核进行调度。你可以把 线程看成是其他两种方式的混合体, 像进程流一样由内核进行调度,而 像 1/0 多路复用流一样共享同一个虚拟地址空间。\n本章研究这兰种不同的并发编程技术。为了使我们的讨论比较具体,我们始终以同一个应用为例一 11. 4. 9 节中的迭代 echo 服务器的并 发版本。\n12. 1 基千进程的并发编程 # 构造并发程序 最简单的方法就是用进程,使 用 那些大家都很熟悉的函数, 像 f o r k、 e xe c 和 wa i t p 过 。 例如, 一个构造并发服务器的自然方法就是, 在父进程中接受 客户端连接请求,然后创建一个新的子进程来为每个新客户端提供服务。\n为了了解这是如何工作的,假设我们有两个客户端和一个服务器,服务器正在监听一 个监听描述符(比如指述符 3) 上的连接请求 。现在假设服务器接受了客户端 1 的连接请求, 并返回一个已连接描述符(比如指述符 4 ) , 如图 12-1 所示。在接受 连接请求之后,服务器派生一个子进程,这个子进程获得服务器描述符表的完整副本。子进程关闭它的副本中的 监听描述符 3\u0026rsquo; 而父进程关闭 它的已连接描述符 4 的副本, 因为不再需要这些描述符了。这就得到了图 12- 2 中的状态, 其中子进程正 忙于为客户端提供服务。\n三一_一、一_、归接请求\nc l i e n t f d\n三\nclientfd\n--、\u0026ndash; listenfd(3) connfd(4)\nclientfd\n三cl i en 七 f d\nlistenfd(3)\n图 1 2-1 第一步:服务器接受客户端的连接请求 图 12- 2 第二步:服务器派生一个子进程为这个客户端服务\n因为父、子进程中的已连接描述符都指向同一个文件表表项,所以父进程关闭它的已连接描述符的副本是至关重要的。否则 , 将永不会释放巳连接描述符 4 的文件表条目 ,而且由此引起的内存泄涌将最终消耗光可用的内存,使系统崩溃。\n现在, 假设在父进程为客户端 1 创建了子进程之后,它 接受一个新的客 户端 2 的连接请求, 并返回一个新的已连接描述符(比如描述符 5) , 如图 12-3 所示。然后,父进程 又派生另一个子进程, 这个子进程用已连接描述符 5 为它的客户端提供服务, 如图 12-4 所示。此时, 父进程正在等待下一个连接请求,而两个子进程正在并发地为它们各自的客户端提供服务。\nclientfd listenfd(3)\nclientfd\nlistenfd(3)\n, \u0026rsquo; connfd(5)\n巨\n图12-3 第三步:服务器接受另一个连接请求 图 12-4 第四步:服务器派生另一个子进程为新的客户端服务\n1. 1 基于进程的并发服务器\n图12-5 展示了一个基千进程 的并发 ech o 服务器的代码。第 29 行调用的 e c h o 函数来自于图 1 1- 21。关于这个服务器 , 有几点重要内容需要说明:\n首先, 通常服务器会运行很长的时间, 所以我们必须 要包括一个 S IG C H L D 处理程序, 来回收僵死( zo m bie ) 子进程的 资 源(第4 9 行)。因为当 S IG C H L D 处理程序执行时, S IG C H LD 信号 是阻塞的, 而 L in u x 信号是不排队的, 所以 SIGC H LD 处理程序必须准备好回收多个僵死子进程的资源。 其次, 父子进程必须关闭 它们各自的 c o n n f d ( 分别为第 33 行和第 30 行)副本。就像我们已经提到过的,这对父进程而言尤为重要,它必须关闭它的已连接描述符, 以避免内存泄漏。 最后, 因为套接字的文件表表项中的引用计数, 直到父子进程的 c o n n f d 都关闭了, 到客户端的连接才会终止。 code/condechoserverp.c\n#include \u0026ldquo;csapp.h\u0026rdquo;\nvoid echo(int connfd);\n3\n4 void sigchld_handler(int sig)\n5 {\n6 while (waitpid(-1, 0, WNOHANG) \u0026gt; 0)\n7\n8 return;\n9 }\n10\n11 int main(int argc, char **argv)\n12 {\n13 int listenfd, connfd;\n14 socklen_t clientlen;\nstruct sockaddr_storage clientaddr;\n16\n7 if (argc != 2) {\nfprintf(stderr, \u0026ldquo;usage: %s \u0026lt;port\u0026gt;\\n\u0026rdquo;, argv[O]);\nexit (0);\n20 }\n21\nSignal(SIGCHLD, sigchld_handler);\nlistenfd = Open_listenfd (argv [1]) ;\n24 while (1) {\nclientlen = sizeof(struct sockaddr_storage);\nconnfd = Accept (listenfd, (SA *) \u0026amp;clientaddr, \u0026amp;clientlen) ;\n27 if (Fork() == 0) {\n28\n29\n30\n31\n32 }\nClose(listenfd) ; echo(connfd); Close(connfd); exit(O);\nI* Child closes its listening socket *I I* Child services client *I\nI* Child closes connection with client *I I* Child exits *I\n33 Close(connfd); I* Parent closes connected socket (important!) *I\n34 }\n35 }\ncode/condechoserverp.c\n图 12-5 基于进程的并发 echo 服务器 。父进程 派生一个 子进程来 处理每个新的 连接请求\n12 . 1. 2 进程的优劣\n对于在父、子进程间共享状态信息,进程有一个非常清晰的模型:共享文件表,但是不共享用户地址空间。进程有独立的地址空间既是优点也是缺点。这样一来, 一个进程不可能不小心覆盖另一个进程的虚拟内存,这就消除了许多令人迷惑的错误—— 这是一个明显的优点。\n另一方面,独立的地址空间使得进程共享状态信息变得更加困难。为了共享信息,它 们必须使用显式的 IPC C进程间通信)机制。(参见下面的 旁注。)基于进程的设计的另一个缺点是, 它们往往比较慢, 因为进程控制 和 IPC 的开销很高。\n日日Unix IPC\n在本 书中 , 你已经遇到 好几个 IPC 的例子了。 笫 8 章中的 wa i t p i d 函数和信号是基本的 IPC 机制, 它们 允许进程发 送小消息 到 同一主机 上的 其他进程。笫 11 章的套接宇接口是 IPC 的一种重要 形式, 它允许 不同主机 上的进程交换任 意的 字 节流。 然而,术语 U n ix IPC 通常指的是所有 允许进程和 同一台主机上其他 进程进行 通信的 技术。其中包括管道、先进先出( FIF O) 、 系统 V 共享内存, 以及 系统 V 信号量( s e m a p h o r e ) 。这些机 制超 出了我 们的讨论范围。 K e r r is k 的著作[ 62] 是很 好的参考资料。\n凶 练习题 12. 1 在图 1 2-5 中, 并发 服 务器的 第 33 行上, 父 进 程 关 闭 了 巳连 接 描述符后, 子进 程仍 然能够使 用该 描述 符和 客户端 通信。 为 什么?\n练习题 12. 2 如果 我们 要 删除 图 1 2-5 中关 闭 巳连 接描述 符的 第 30 行,从 没有内存泄漏的角度来说,代码将仍然是正确的。为什么?\n2 基千 1/ 0 多路复用的并发编程\n假设要求你编写一个 e ch o 服务器,它 也 能对用户从标准输入键入的交互命令做出响应。在这种情况下 , 服务器必须响应 两个互相独立的 I/ 0 事件: 1) 网络客户端发 起连接请求, 2 ) 用 户在键盘上 键人命令行。我们先 等待哪个事件呢? 没有哪个选择是理想的。如果在 a c c e p t 中等待一个连接请求, 我们就不能响应输入的命令。类似地, 如果在r e a d 中等待一个输入命令,我们就不能响应任何连接请求。\n针对这种困 境的一个解决 办法就是 1/ 0 多路复用 CI/ 0 m u lt ip le x in g ) 技术。基本的思路就 是使用 s e l e c t 函数, 要求内核挂起进程,只 有在一个或多个 I/ 0 事件发生后,才将控制返回给应用程序,就像在下面的示例中一样:\n当集合{O, 4}中任意描述符准备好读时返回。 当集合 { 1, 2 , 7}中任意描述符准备好写时返回。\n如果在等待一个 I/ 0 事件发生时过了 152. 13 秒, 就超时。\ns e l e c 七是一个复杂的 函数, 有许多不同的使用场景。我们将只讨论第一种场景: 等待一组描述符准备好读。全面的讨论请参考[ 62, 110] 。\n#include \u0026lt;sys/select.h\u0026gt;\nint select(int n, fd_set *fdset, NULL, NULL, NULL);\n返回已准备 好的描述符的 非零的个数 , 若出错 则 为一 1。\nFD_ZERO(fd_set *fdset); FD_CLR(int fd, fd_set *fdset); FD_SET(int fd, fd_set *fdset); FD_ISSET(int fd, fd_set *fdset);\nI* Clear all bits in fdset *I I* Clear bit fd in fdset *I I* Turn on bit fd in fdset *I\n/* Is bit fd in fdset on? *I\n处理 描述符 集合的 宏。\ns e l e c t 函数处理类型为 f d _s e t 的集合,也 叫做描述符集合 。逻辑上, 我们将描述符集合看成一个大小为 n 的位向量(在2. 1 节中介绍过):\nb,,_1 , \u0026hellip; , b1 , b。\n每个位 从对应于描述符 k 。当且仅当从 = l , 描述符 k 才表明是 描述符集合的一个元素。只允许你对描述符集合做三件事: 1 ) 分配它们, 2 ) 将一个此种类型的变量赋值给另一个变量, 3 ) 用 F D_ ZERO、F D_S ET 、FD_CLR 和 F D_ISS ET 宏来修改 和检查它们。\n针对我们的 目的, s e l e c t 函数有两个输入: 一个称为读 集合 的描述符集合( f d s e t ) 和该读集合的基数( n ) ( 实际上是任何描述符集合的最大基数)。s e l e c t 函数会一直阻塞, 直到读集合中至少有一个 描述符准备好可以读 。当且仅当一个从该描述符读取一个字节的请求不会阻塞时, 描述符 k 就表示准备 好可以 读了。s e l e c t 有一个副作用, 它修改参数f d s e t 指向的 f d _ s e t , 指明读集合的一个子集,称 为准备 好集合 ( read y set) , 这个集合是由读集合中准备好可以 读了的描述符组成的。该函数返回的值指明了准备好集合的基 数。注意, 由于这个副作用, 我们必 须在每次调用 s e l e c t 时都更新读集合。\n理解 s e l e c t 的最好办法是研究一个具体例子。图 12-6 展示了可以如何利用 s e l e c t 来实现一个迭代 echo 服务器, 它也可以接受标准输入上的用户命令。一开始, 我们用图 11-1 9 中的 op e n_止 s t e n f d 函数打开一个监听描述符(第1 6 行), 然后使用 F D_ ZE RO 创建一个空的读集合(第18 行):\nlistenfd\n3 2\nread_set (0) : 曰\nstdin\n1 0\n二\n接下来, 在第 19 和 20 行中, 我们定义由描述符 0 ( 标准输入)和描述符 3 ( 监听描述符)组成的读集合:\nlistenfd stdin\n3 2 1 0\nread_set ({O, 3)) : I 1 I 1 I 1 I\n在这里, 我们开始典 型的服务器循环 。但是我们不调用 a c c e p七函数来等待一个连接请求,而 是调用 s e l e 吐 函数, 这个函数会一直阻塞, 直到监听描述符或者标准输入准备好可以读(第24 行)。例如,下 面是 当用户按回车键, 因此使得标准输入描述符变为可读时, s e l e c t 会返回的r e a d y_ s 包 的值:\nlistenfd stdin\n3 2 1 0\nready_set ({O}): I 1 1 I 1 I\n一旦 s e l e c t 返回, 我们就用 F D _ ISSET 宏指令来确定哪个描述符 准备好可以读了。如果是标准输入准备好了(第25 行), 我们就调用 c omma nd 函数,该 函数在返回到主程序前, 会读、解析和响应命令。如果是监听描述符准备好了(第27 行), 我们就调用 a c c e p t 来得到一个已 连接描述符 , 然后调用图 11-22 中的 e c ho 函数, 它会将来自客户端的每一行又回送回去, 直到客户端关闭 这个连接中它的那一端。\n虽然这个程序是使用 s e l e c t 的一个很好示例,但 是它仍然留下了一些问题待解决。问题是一旦它连接到某个客户端,就会连续回送输入行,直到客户端关闭这个连接中它的那一 端。因此,如果键入一个命令到标准输人,你将不会得到响应,直到服务器和客户端之间结\n束。一个更好的方法是更细粒度的多路复用,服务器每次循环(至多)回送一个文本行。\ncode/co nd se lect. c\n#include \u0026ldquo;csapp.h\u0026rdquo;\nvoid echo(int connfd);\nvoid command(void);\n4\n5 int main(int argc, char **argv)\n6 {\nint listenfd, connfd;\nsocklen_t clientlen;\nstruct sockaddr_storage clientaddr;\nfd_set read_set, ready_set;\n11\nif (argc != 2) {\nfprintf(stderr, \u0026ldquo;usage: %s \u0026lt;port\u0026gt;\\n\u0026rdquo;, argv[O));\nexit(O);\n15 }\n16 listenfd = Open_listenfd(argv[1]); 17\nFD_ZERO(\u0026amp;read_set); I* Clear read set *I\nFD_SET(STDIN_FILENO, \u0026amp;read_set); I* Add stdin to read set *I\nFD_SET(listenfd, \u0026amp;read_set); I* Add listenfd to read set *I 21\nwhile (1) {\nready_set = read_set;\nSelect(listenfd+1, \u0026amp;ready_set, NULL, NULL, NULL);\nif (FD_ISSET(STDIN_FILENO, \u0026amp;ready_set))\ncommand(); I* Read command line from stdin *I\nif (FD_ISSET(listenfd, \u0026amp;ready_set)) {\n28\n29\n30\n31\n32\n33 }\n34 }\n35\nclientlen = sizeof(struct sockaddr_storage);\nconnfd = Accept(listenfd, (SA *)\u0026amp;clientaddr, \u0026amp;clientlen); echo(connfd); I* Echo client input until EDF *I Close(connfd);\nvoid command(void) { char buf[MAXLINE]; if (! Fgets (buf , MAXLINE, st din)) exit(O); I* EOF *I printf(\u0026quot;%s\u0026quot;, buf); I* Process the input command *I 41 } codelcondselect.c\n图 12-6 使用 1/ 0 多路复用的 迭代 echo 服务器。服务器使用 s e l e c t\n等待监听描述符上的连接请求和标准输人上的命令\n沁目 练习题 12. 3 在 L i n u x 系统 里,在标 准输入 上键入 C t rl + D 表 示 EOF 。 图 12-6 中的程序阻塞在 对 s e l e c t 的调 用上 时,如果 你键 入 C t rl + D 会发 生什 么?\n12. 2. 1 基千 1/ 0 多 路 复用的并发事件驱动服务器\nI / 0 多路复用可以用做并发事件 驱动( e ve n t- d r ive n ) 程序的基础, 在事件驱动程序中, 某些事件会导致流向前推进。一般的思路是将逻辑流模型化为状态机。不严格地说,一个\n状态机 ( s t a t e m a c h in e ) 就是一组状 态 ( s t a t e ) 、输 入事件 ( in p u t e ve n t ) 和转移 ( t ra n s it io n ) , 其中转移是将状态和输入事件映射到状态。每个转移是将一 个(输入状态, 输入事件)对映射到一个输出状态。自循环 ( s e lf - lo o p ) 是同一输入和输出状态之间的转移。通常把状态机画成有向图,其中节点表示状态,有向弧表示转移,而弧上的标号表示输入事件。一个状 态机从某种初始状态开始执行。每个输入事件都会引发一个从当前状态到下一状态的 转移。\n对于每个新的 客户端 k , 基于 1/ 0 多路复用的 并发服务器会创建一个新的状态机\nSk\u0026rsquo; 并将它和巳连接描述符d k 联系起来。如图 12-7 所示, 每个状态机 Sk 都有一个状态( \u0026ldquo;等待描述符 d k 准备好可读\u0026rdquo;)、一 个输入事件("描述符 d k 准备好 可以读了\u0026quot;)和一个转移\n(“从描述符 d k 读一个文本行\u0026quot;)。 图 12- 7 并发事件驱动echo 服务器中逻辑流的状态机\n服务器使用 1/ 0 多路复用 , 借助 s e l e c t 函数检测输入事件的发生。当每个已连接描述符准备好可读时,服务器就为相应的状态机执行转移,在这里就是从描述符读和写回一 个文本行。\n图 1 2-8 展示了一个基于 1/ 0 多路复用的并发事件驱动服务器的完整示 例代码。一个\np o o l 结构里维护着活动客户端的集合(第3 11 行)。在调用 i n i t _ p o o l 初始化 池(第27 行)之后, 服务器进入一个无限循环。在 循环的 每次 迭代中, 服务器调用 s e l e c t 函数来检测两种不同类型的输入事件: a ) 来自一个新客户端的连接请 求到达, b ) 一个已存在的客户端的已连接描述符准 备好可以 读了。当一个连接请求到达时(第35 行), 服务器打开连接(第37 行), 并调用 a d d _ c l i e n t 函数, 将该客户端添加到池里(第38 行)。最后, 服务器调用 c h e c k_ c l i e n t s 函数, 把来自每个 准备好的已连接描述符 的一个文本行回送回去\n(第 42 行)。\ncode/condechoservers.c\n1 #include \u0026ldquo;csapp.h\u0026rdquo;\ntypedef struct { /• Represents a pool of connected descriptors•/\nint maxfd; I• Largest descriptor in read_set•I\nfd_set read_set; I• Set of all active descriptors•I\nfd_set ready_set; I• Subset of descriptors ready for reading•/\nint nready; /• Number of ready descriptors from select•I\nint maxi; /• High water index into client array•/\nint clientfd [FD_SETSIZE) ; /• Set of active descriptors•/\nrio_t clientrio[FD_SETSIZE); I* Set of active read buffers•/\n} pool;\n12\n13 int byte_cnt = 0; I* Counts total bytes received by server•/\n14\n15 int main(int argc, char **argv)\n16 {\nint listenfd, connfd;\nsocklen_t clientlen;\nstruct sockaddr_storage clientaddr;\n图12-8 基 于 I/ 0 多路复用的并发 echo 服务器。每次服务器迭代都回送来自每个准备好的描述符的文本行\nstatic pool pool; 21\nif (argc != 2) {\nfprintf(stderr, \u0026ldquo;usage: %s \u0026lt;port\u0026gt;\\n\u0026rdquo;, argv[O]);\nexit(O);\n25 }\nlistenfd = Open_listenfd(argv[1]);\ninit_pool(listenfd, \u0026amp;pool); 28\nwhile (1) {\nI* Wait for listening/connected descriptor(s) to become ready *I\npoolr. eady_s et = pool.read_set;\n32\n33\n34\n35\n36\n37\n38\n39\n40\n41\n42\n43\n44 }\npool.nready = Select(pool.maxfd+l, \u0026amp;pool.ready_set, NULL, NULL, NULL);\nI* If 让 s t eni ng descriptor ready, add new client to pool *I if (FD_ISSET (listenfd, \u0026amp;pool. ready_set)) {\nclientlen = sizeof(struct sockaddr_storage);\nconnfd = Accept(listenfd, (SA *)\u0026amp;clientaddr, \u0026amp;clientlen); add_client(connfd, \u0026amp;pool);\n}\nI* Echo a text line from each ready connected descriptor *I check_clients(\u0026amp;pool);\ncodelcondechoservers.c\n图 12-8 ( 续 )\ni n it _p o o l 函数(图1 2- 9 ) 初始化客户端池。 c l i e n t f d 数组表示已连接描述符的集合, 其中整数—1 表示一个可用的 槽位。初始时,已 连接描述符集合是空的(第5 ~ 7 行),而且监听描 述符是 s e l e c t 读集合中唯 一的描述符(第1 0 ~ 1 2 行)。\ncod e/cond echoservers .c\nvoid init_pool(int listenfd, pool *p)\n3 I* Initially, there are no connected descriptors *I inti;\np-\u0026gt;maxi = -1;\n6 for (i=O; i\u0026lt; FD_SETSIZE; i++)\np-\u0026gt;clientfd[i] = -1;\n8\nI* Initially, listenfd is only member of select read set *I\np-\u0026gt;maxfd = listenfd;\nFD_ZERO(\u0026amp;p-\u0026gt;read_set);\nFD_SET(listenfd, \u0026amp;p-\u0026gt;read_set);\n13 }\ncode/co nd echoservers .c\n图 12-9 init pool 初 始 化 活动客户端池\na d d _ c li e n t 函数(图1 2- 1 0 ) 添加一个新的客户端到活动客户端池中。在 c li e n tf d 数组中找到一个空槽位后,服务器将这个巳连接描述符添加到数组中,并初始化相应的 R I O 读缓冲区 , 这样一 来我们就能够对这个描述符调用r i o _ r e a d l i n e b ( 第 8 ~ 9 行)。然\n后, 我们将 这个已连接描述符添加到 s e l e c t 读集合(第12 行), 并更新该池的一些全局属性。ma x f d 变量(第15 16 行)记录了 s e l e 立 的最大文件描述符。ma x i 变量(第17 18 行)记录的是 到 c l i e n t f d 数组的最大索引, 这样 c h e c k_ c l i e n t s 函数就无需搜索整个数组了。\ncode/condechoservers.c\n1 void add_client(int connfd, pool *p)\n2 {\n3 inti;\n4 p-\u0026gt;nready\u0026ndash;;\ns for (i = O; i \u0026lt; FD_SETSIZE; i++) I* Find an available slot *I\n6 if (p-\u0026gt;clientfd[i] \u0026lt; 0) {\n7 I* Add connected descriptor to the pool *I\n8 p-\u0026gt;clientfd[i] = connfd;\n9 Rio_readinitb(\u0026amp;p-\u0026gt;clientrio[i], connfd);\n10\n11 I* Add the descriptor to descriptor set *I\n12 FD_SET(connfd, \u0026amp;p-\u0026gt;read_set);\n13\nI* Update max descriptor and pool high water mark *I\nif (connfd \u0026gt; p-\u0026gt;maxfd)\np-\u0026gt;maxfd = connfd;\nif (i \u0026gt; p-\u0026gt;maxi)\np-\u0026gt;maxi = i;\nbreak;\n20 }\nif (i == FD_SETSIZE) / * Couldn\u0026rsquo;t find an empty slot *I\napp_error(\u0026ldquo;add_client error: Toomany clients\u0026rdquo;);\n23 }\ncode/condechoservers.c\n图 1 2-10 add_c l i e nt 向池中添加一个新的客户端连接\n图 12-11 中的 c he c k_ c l i e n t s 函数回送来自每个 准备好的已连接描述符的一个文本行。如果成功地从描述符读取了一个文本行, 那么就将该文本行回送到客户端(第15 18 行)。注意,在 第 15 行我们维护着一个从所有客户端接收到的 全部字节的 累计值。如果因为客户端关闭这个连接中它的那一端, 检测到 EOF , 那么将关闭这边的连接端(第23 行), 并 从池中清除掉这个描述符(第24 25 行)。\n根据图 1 2- 7 中的有限状态模型, s e l e c t 函数检测到输入事件, 而 a d d _ c l i e 吐 函数创建一个新 的逻辑流(状态机)。c h e c k _ c l i e n t s 函数回送输入行,从 而执行状态转移, 而且当客户端完成文本行发送时,它还要删除这个状态机。\n沁囡 练习题 12 . 4 图 1 2-8 所 示的 服 务器中, 我们 在每次调 用 s e l e 立 之前都 立 即小心地重新初 始化 p o o l .r e a d y_ s e t 变量。 为什 么?\n豆 日 事件驱 动的 We b 服务器\n尽管有 12. 2. 2 节中说 明的缺点, 现代高性能服务器( 例如 N od e. js 、ng in x 和 T or­ na do ) 使用的都是 基于 1/ 0 多路 复用的 事件 驱动的 编程 方式 , 主要是因为相 比于进程和线程的 方式 , 它有明 显的性能优势。\ncode/condechoservers. c\nvoid check_clients(pool *p)\n2 {\n3 inti, connfd, n;\n4 char buf[MAXLINE];\n5 rio_t rio;\n6\n7 for (i = O; (i \u0026lt;= p-\u0026gt;maxi) \u0026amp;\u0026amp; (p-\u0026gt;nready \u0026gt; 0); i++) {\n8 connfd = p-\u0026gt;clientfd [i] ;\n9 rio = p-\u0026gt;clientrio[i];\n10\n11 /* If the descriptor is ready, echo a text line from it *I\n12 if ((connfd \u0026gt; 0) \u0026amp;\u0026amp; (FD_ISSET(connfd, \u0026amp;p-\u0026gt;ready_set))) {\n13 p-\u0026gt;nready\u0026ndash;;\nif ((n = Rio_readlineb (\u0026amp;rio, buf, MAXLINE)) ! = 0) {\nbyte_cnt += n;\nprintf(\u0026ldquo;Server received %d (%d total) bytes on fd %d\\n\u0026rdquo;,\nn, byte_cnt, connfd);\n18 Rio_writen(connfd, buf, n);\n19 }\n20\n21 I* EDF detected, remove descriptor from pool *I\n22 else {\nClose(connfd);\nFD_CLR(connfd, \u0026amp;p-\u0026gt;read_set);\np-\u0026gt;clientfd [i] = -1;\n26 }\n27 }\n28 }\n29 }\ncode/condechoservers.c\n图 12-11 check cl i ent s 服务准备好的 客户 端连接\n12. 2. 2 1/ 0 多 路 复用技术的优劣\n图 12-8 中的服务器提供了一个 很好的基于 I/ 0 多路复用的事件驱动编程的优缺点示例。事件驱动设计的一个优点是,它比基千进程的设计给了程序员更多的对程序行为的控制。例如,我们可以设想编写一个事件驱动的并发服务器,为某些客户端提供它们需要的服务,而这对于基于进程的并发服务器来说,是很困难的。\n另一个优点是, 一个基千 I/ 0 多路复用的事件驱动服务器是运行在单一进程上下文中的,因此每个逻辑流都能访问该进程的全部地址空间。这使得在流之间共享数据变得很容 易。一个与作为单 个进程运行相关的优点是, 你可以利用熟悉的调试工具, 例如 GDB, 来调试你的并发服务器,就像对顺序程序那样。最后,事件驱动设计常常比基于进程的设 计要高效得多,因为它们不需要进程上下文切换来调度新的流。\n事件驱动设计一个明显的 缺点就是编码复杂。我们的事件驱动的并 发 echo 服务器需要的代码比基于进程的服务器多三倍,并且很不幸,随着并发粒度的减小,复杂性还会上升。这 里的粒度是指每个逻辑流每个时间片执行的指令数量。例如,在示例并发服务器中,并发粒 度就是读一个完整的文本行所需要的指令数 量。只要某个逻辑流正忙于读一个文本行, 其他逻辑流就不可能有进展 。对我们的例子来说这没有问题, 但是它使得在“故意只发送部分文\n本行然后就停止"的恶意客户端的攻击面前,我们的事件驱动服务器显得很脆弱。修改事件 驱动服务器来处理部分文本行不是一个简单的任务,但是基千进程的设计却能处理得很好, 而且是自动处理的。基于事件的设计另一个重要的缺点是它们不能充分利用多核处理器。\n12. 3 基于线程的并发编程\n到目前为止,我们已经看到了两种创建并发逻辑流的方法。在第一种方法中,我们为 每个流使用了单独的进程。内核会自动调度每个进程,而每个进程有它自己的私有地址空 间,这 使得流共享数据很困难。在第二种方法中, 我们 创建自己的逻辑流, 并利用 I/ 0 多路复用来显式地调度流。因为只有一个进程,所有的流共享整个地址空间。本节介绍第三 种方法 基千线程,它是这两种方法的混合。\n线程 ( t hread ) 就是运行在进程上下 文中的逻辑流。在本书里迄今为止, 程序都是由每个进程中一个线程组成的。但是现代系统也允许我们编写一个进程里同时运行多个线程的程序。线 程由内核自动调度。每个线程都有它自己的 线程上 下文( t h read context), 包括一个唯一的整数线程 ID ( T hread ID, T ID) 、栈、栈指针、程序 计数器、通用目的寄存器和条件码。所有的运行在一个进程里的线程共享该进程的整个虚拟地址空间。\n基千线程的逻辑流结合 了基于进程 和基于 I/ 0 多路复用的流的特性。同进程一样, 线程由内核 自动调度 ,并且内核通过一个 整数 ID 来识别线程。同 基于 I/ 0 多路复用的流一样,多个线程运行在单一进程的上下文中,因此共享这个进程虚拟地址空间的所有内容, 包括它的代码、数据、堆、共享库和打开的文件。\n12. 3. 1 线程执行模型\n多线程的执行模型在某些方面和多进程的执行模型是相似的。思考图 12-12 中的示例。每个进程开始生命周期时都是单一线程, 这个线程称为主线程 ( main t hread ) 。在某一时刻,主线程创建一个对等线程(peer thread) , 从这个时间点开始,两个线程就并发地运行。最后,因为主线程执行一个 慢 速 系 统 调 用, 例 如 r e a d 或 者\nsleep, 或者因为被系统的间隔计时器中断,控制就会通过上下文切换传递到对等\n时间\n线程1 线程2\n(对等线程)\n---------------------------\n…- - \u0026mdash;\u0026mdash;- \u0026ndash; \u0026mdash;-r\u0026mdash;\u0026mdash;\u0026ndash; }线程上下文切换\n:::::::一二:二勹}线程上下文切换二::三二::}线程上下文切换\n图12-12 并发线程执行\n线程。对等线程会执行一段时间,然后控制传递回主线程,依次类推。\n在一些重要的方面,线程执行是不同于进程的。因为一个线程的上下文要比一个进程的上下文小得多,线程的上下文切换要比进程的上下文切换快得多。另一个不同就是线程不像进程那样,不是按照严格的父子层次来组织的。和一个进程相关的线程组成一个对等\n(线程)池,独立于其他线程创建的线程。主线程和其他线程的区别仅在于它总是进程中第 一个运行的线程。对等(线程)池概念的主要影响是,一个线程可以杀死它的任何对等线程,或者等待它的任意对等线程终止。另外,每个对等线程都能读写相同的共享数据。\n3 . 2 Pos ix 线程\nPosix 线程( P t hreads ) 是在 C 程序中处理线程的一个标准接口。它最早出现在 1995\n年, 而且在所有的 L in u x 系统上都 可用。P t h r ea d s 定义了大约 60 个函数,允 许程序创建、杀死和回收线程,与对等线程安全地共享数据,还可以通知对等线程系统状态的变化。\n图 12-13 展示了一个简单的 P t h rea ds 程序。主线程创建一个 对等线程, 然后等待它的终止。对等线程输出 \u0026quot; He l l o , world! \\ n\u0026quot; 并且终止。当主线 程检测到对等线 程终止后, 它就通过调用 e x i t 终止该进程。这是我们看到的第一个线程化的程序, 所以让我们仔细地解析它。线程的代码和本地数据被封装在一个线程例 程( t h r ead ro u t in e) 中。正如第二行里的原型所示,每个线程例程都以一个通用指针作为输入,并返回一个通用指针。如果想 传递多个参数给线程例程,那么你应该将参数放到一个结构中,并传递一个指向该结构的 指针。相似地,如果想要线程例程返回多个参数,你可以返回一个指向一个结构的指针。\ncode/cone/hello.c\n#include \u0026ldquo;csapp.h\u0026rdquo;\n2 void *thread(void *vargp);\n3\n4 int main()\n5 {\n6 pthread_t tid;\n7 Pthread_create(\u0026amp;tid, NULL, thread, NULL);\n8 Pthread_join(tid, NULL);\n9 exit(O);\n10 }\n11\n12 void *thread(void *vargp) I* Thread routine *I\n13 {\n14 printf(\u0026ldquo;Hello, world!\\n\u0026rdquo;);\n15 return NULL;\n16 }\ncodelcondhello.c\n图 1 2- 1 3 hello.c: 使用 Pthreads 的 \u0026quot; Hello , world!\u0026quot; 程序\n第 4 行标出了主线程代码的 开始。主线程声明了一个本地变僵 t 过, 可以用来存放对等线程的 ID( 第 6 行)。主线程通过调用 p t hr e a d _ cr e a 七e 函数创建一个新的对等线程(第\n7 行)。当对 p t hr e a d _ c r e a t e 的调用返回时, 主线程和新创建的对等线程同时运行, 并且 t i d 包含新线程的 ID。通过在第 8 行调用 p t hr e a d _ j o i n , 主线程等待对等线程终止。最后, 主线程调用 e x 江(第9 行), 终止当时运行在这个 进程中的所有线程(在这个示例中就只有主线程)。\n第 12 ~ 1 6 行定义了对等线程的 例程。它只 打印一个字符串, 然后就通过执行第 15 行中的r e 七ur n 语句来终止对等线程 。\n12. 3. 3 创建线程\n线程通过调用 p t hr e a d _cr e a 七e 函数来创建其他线程。\n#include \u0026lt;pthread.h\u0026gt;\ntypedef void *(func) (void *);\nint pthread_create(pthread_t *tid, pthread_attr_t *attr,\nfunc *f, void *arg);\n若成功则返回 o, 若出错 则 为 非零。\np t hr e a d_ cr e a t e 函数创建一个新的线程, 并带着一个输入变量 ar g , 在新线程的上下文中运行线程例 程 f 。能用 a t 七r 参 数来改变新创建线程的 默认属性 。改变这些属性已超出我们 学习的范围, 在我们的示 例中,总 是用一个为 N U L L 的 a t tr 参数来调用p t h r e a d_ cr e a 七e 函数。\n当 p t h r e a d _ cr e a t e 返回时 , 参数 t i d 包含新创建线程的 ID 。新线程可以通过调用\np t hr e a d_ s e l f 函数来获得它自己的线 程 ID。\n#include \u0026lt;pthread.h\u0026gt;\npthread_t pthread_self(void);\n返回调用 者的 线 程 ID .\n3. 4 终止线程\n一个线程是以下列方式之一来终止的:\n当顶层的线程例程返回时 , 线程会隐式地终 止。 通过调用 p 七hr e a d _ e x i t 函数, 线程会显 式地终 止。如果主线程调用 p t hr e a d _e x ­\nl 七,它 会等待所有其他对等线程终止,然 后再终止主线程和整个进程, 返回值为\nthread r et urn。\n#include \u0026lt;pthread.h\u0026gt;\nvoid pthread_exit(void *thread_return);\n从不返回。\n某个对等线程调用 Lin ux 的 e x i t 函数,该 函数终止进程以 及所有与该进程相关的线程。 另一个对等线程 通过以 当前线程 ID 作为参数调用 p t h r e a d _ c a n c e l 函数来终止当前线程。 #include \u0026lt;pthread.h\u0026gt;\nint pthread_cancel(pthread_t tid);\n若成功则返回o , 若 出错 则 为 非零。\n12. 3. 5 回收己终止线程的资源\n线程通过 调用 p 七hr e a d _ j o i n 函数等待其他线程终止。\n#include \u0026lt;pthread.h\u0026gt;\nint pthread_join(pthread_t tid, void **thread_return);\n若成功则返 回 o, 若出错则为非零。\np t hr e a d_ j o i n 函数会阻塞, 直到线程 t i d 终止, 将线程例程返回的通用( v o i d * ) 指针赋值为 t h r e a d _r e t ur n 指向的位置, 然后回收己终 止线程占用的所有内存资源。\n注意 , 和 L in u x 的 wa i t 函数不同, p t hr e a d _ j o i n 函数只能等待一个指定的线程终止。没有办法让 p t hr e a d _ wa i t 等待任意一个线程终 止。这使得代码更加复杂, 因为它 迫\n使我们去使用其他一些不那么直观的机制来检测进程的 终止。实际上, S t eve ns 在[ ll O] 中就很有说服力地论证了这是规范中的一个错误。\n12. 3. 6 分离线程\n在任何一个时间点上, 线程是可结合的 ( joina ble ) 或者是 分离的( detached ) 。一个可结合的线程能够被其他线程收回和杀死。在被其他线程回收之前 , 它的内存资源(例如栈)是不释放的。相反,一个分离的线程是不能被其他线程回收或杀死的。它的内存资源在它终 止时由系统自动释放。\n默认情况下 ,线 程被创建成可结合的。为了避免内存泄漏, 每个可结合线程都应该要\n么被其 他线程显式 地收回, 要么通过调用 p t h r e a d_d e t a c h 函数被分离。\n#include \u0026lt;pthread.h\u0026gt;\nint pthread_detach(pthread_t tid);\n若成功则返回 0 , 若 出错 则 为 非零。\np t hr e a d_d e t a c h 函数分离可结合线程 t 过。线程能够通过以 p t hr e a d_ s e l f ()为参数的 p t hr e a d _de t a c h 调用来分离它们自己。\n尽管我们的一些例子会使用 可结合 线程,但 是在现实程序中,有 很好的理由要使用分\n离的线程。例如, 一个高性能 W eb 服务器可能在每次收到 W e b 浏览器的连接请求 时都创建一个新的对等线程。因为每个连接都是由一个单独的线程独立处理的,所以对千服务器 而言,就很没有必要(实际上也不愿意)显式地等待每个对等线程终止。在这种情况下,每 个对等线程都应该在它开始处理请求之前分离它自身,这样就能在它终止后回收它的内存 资源了。\n12. 3. 7 初始化线程\np t hr e a d_onc e 函数允许你初始化与线程例程相关的 状态。\n#include \u0026lt;pthread.h\u0026gt;\npthread_once_t once_control = PTHREAD_ONCE_INIT; int pthread_once(pthread_once_t *once_control,\nvoid (*init_routine)(void));\n总是返回o.\no n c e _c o n tr o l 变量是一 个全局或者静态变量 ,总 是被初始化为 PT H READ_ONCE_ I NIT 。当你第一次用参数 on c e _ c o n tr o l 调用 p t hr e a d _ o nce 时, 它调用 m江 r ou­\ntine, 这是一个没有输入参数 、也不返回什么的函数。接下来的以 o nc e _c on t r o l 五参数的 p t hr e a d _o nc e 调用不做任何事情。无论何时, 当你需要动态初始化多个线程共享的全局变量时, p t hr e a d _ o n c e 函数是很有用的。我们将在 1 2. 5. 5 节里看到一个示例。\n12. 3. 8 基千线程的并发服务器\n图 12-14 展示了基于线程的并发 echo 服务器的代码。整体结构类似于基于进程的设计。主线程不断地等待连接请求,然后创建一个对等线程处理该请求。虽然代码看似简\n单, 但是有几个普遍而且有些 微妙的问题需要我们更 仔细地看一 看。第一个问题是当我们调用 p t hr e a d _ cr e a t e 时, 如何将已连接描述符传递给对等线 程。最明 显的方法就是传递一个指向这个描述符的指针,就像下面这样\nconnfd = Accept(listenfd, (SA*) \u0026amp;clientaddr, \u0026amp;clientlen); Pthread_create(\u0026amp;tid, NULL, thread, \u0026amp;connfd);\n然后,我们让对等线程间接引用这个指针,并将它赋值给一个局部变量,如下所示\nvoid *thread(void *vargp) {\nint connfd = *((int *)vargp);\ncode/co吹n chosevrert.c\nclientlen=sizeof (struct sockaddr_storage);\nconnfdp = Malloc(sizeof(int));\n• connf dp = Accept(listenfd, (SA•) \u0026amp;clientaddr, \u0026amp;clientlen);\nPthread_create(\u0026amp;tid, NULL, thread, connfdp); 24 }\n25 }\n26\nI• Thread routine•I\nvoid•thread(void•vargp)\n29 {\nint connf d = * ((int *)vargp) ;\nPthread_detach(pthread_self ());\nFree(vargp);\necho(connfd) ;\nClose(connfd);\nreturn NULL;\n36 }\ncode/condechoservert.c\n图 12-1 4 基于线程的 并发 echo 服务器\n然而, 这样可能会出错, 因为它在对 等线程的赋值语句和主线程的 a c c e p t 语句间引入了竞争 ( race ) 。如果赋值语句在下一个 a c ce p t 之前完成, 那么对等线程中的局部变量c o nn f d就得到正确的描述符值。然而, 如果赋值语句是在 a c c e p t 之后才完成的, 那么对等线程中的 局部变量 c o n nf d 就得到下一次连接的描述符值。那么不幸的结果就是, 现在两个线程在同一个描述符上执行输入和输出。为了避免这种潜在的致命竞争,我们必须将 a c ce p七返回的每个已连接描述符分配到它自己的动态分配的内存块, 如第 20 21 行所示。我们 会在 12. 7. 4 节中回过来讨论竞争的问题。\n另一个问题是在线程例程中避免内存泄漏。既然不显式地收回线程, 就必须分离每个线程,使 得在它终止时它的内存资源能够被收回(第31 行)。更进一步, 我们必须小心释放主线程分配的内存块(第32 行)。\n沁氐 练习题 12. 5 在图 12- 5 中基 于进 程的服务器中, 我们 在两个位置小心 地关 闭 了 已连接描述 符: 父进 程和子进程。 然而,在 图 1 2-14 中基 于线程 的服务器中, 我们只在 一个位置关闭了巳连接描述符:对等线程。为什么?\n12. 4 多线程程序中的共享变量\n从程序员的角度来看,线程很有吸引力的一个方面是多个线程很容易共享相同的程序 变量。然而, 这种共享也是很棘手的。为了编写正确的多线程程序, 我们 必须对所谓的 共享以及它是如何工作的有很清楚的了解。\n为了理解 C 程序中的一个变量是否是共享的, 有一些基本的问题要解答: 1) 线程的基础内存模型是什么? 2 ) 根据这个 模型, 变量实例是如何映 射到内存的? 3 ) 最后, 有多少线程引用这些实例?一个变量是共享的,当且仅当多个线程引用这个变董的某个实例。\n为了让 我们对共享的讨论具 体化, 我们将使用图 12-15 中的程序作为运行示例。尽管有些人为的痕迹, 但是它仍然值得研究, 因为它说明 了关于共享的许多细微之处。示例程序由一个创建了两个对等线程的主线程组成。主线程传递一个唯一的 ID 给每个 对等线程, 每个对等线程利用这个 ID 输出一条个性化的信息, 以及调用该线程例程的总次数。\n12 . 4. 1 线程内存模型\n一组并发线程运行在一个进程的上下文中。每个线程都有它自己独立的线程上下文, 包括线程 ID、栈、栈指针、程序计数器、条件码和通用目的寄存器值。每个线程和其他线程一起共享进程上下文的剩余部分。这包括整个用户虚拟地址空间,它是由只读文本\n(代码)、读/写数据、堆以及所有的共享库代码和数据区域组成的。线程也共享相同的打开文件的集合。\n从实际操作的角度来说 ,让 一个线程去读或写另一个线程的寄存器值是不可能的。另一方面, 任何线程都可以访问共享虚拟内存的任意位置。如果某个线程修改 了一个内存位置, 那么其他每个线程最终都能 在它读这个位 置时发现这个变化。因此,寄 存器是从不共享的, 而虚拟内存总是共享的 。\n各自独立的线程栈的内存模型不是那么整齐清楚的。这些栈被保存在虚拟地址空间的 栈区域中, 并且通常是被相应的线程独立 地访问 的。我们说通常而不是总是, 是因为不同的线程栈是不对其他线程设防的 。所以, 如果一个线程以某种方式得到一个指向其他线程栈的指针, 那么它就可以 读写这个栈的任何部分。示例程序在第 26 行 展示了这一点, 其中对等线程直接通过全局变量 p tr 间接引用主线程的栈的内容。\ncode/condsharing.c\n#include \u0026ldquo;csapp.h\u0026rdquo;\n#define N 2 void *thread(void *vargp); 5 char **ptr; I* Global variable *I\n6\n7 int main()\n8 {\nint i;\npthread_t tid;\nchar *msgs [NJ = {\n\u0026ldquo;Hello from foo\u0026rdquo;,\n\u0026ldquo;Hello from bar\u0026rdquo;\n14 };\n15\nptr = msgs;\nfor (i = O; i \u0026lt; N; i++)\nPthread_create(\u0026amp;tid, NULL, thread, (void *)i);\nPthread_exit (NULL) ; 20 }\n21\n22 void *thread(void *vargp)\n23 {\nint myid = (int)vargp;\nstatic int cnt = 0;\nprintf(\u0026quot; [%d]: %s (cnt=%d)\\n\u0026quot;, myid, ptr[myid], ++cnt);\nreturn NULL;\n28 }\ncode/condsharing.c\n图 12-1:i 说明共享不同方面的示例程序\n4. 2 将变星映射到内存\n多线程的 C 程序中变量根据它们的存储类 型被映射到虚拟内存:\n全局 变量。 全局变量是定义在函数之外的变量。在运行时, 虚拟内存的读/写区域只包含每个 全局变量的一 个实例, 任何线程都可以引用。例如, 第 5 行声明的全局变量 p tr 在虚拟内存的读/写区域中有一个运行时实例。当一个变量只有一个实例时, 我们只用 变量名(在这里就是 p tr ) 来表示这个实例。 本地自动 变量。 本地自动变量就是定义在函数内部但是没有 s 七a t i c 属性的变量。在运行时,每个线程的栈都包含它自己的所有本地自动变量的实例。即使多个线程 执行同一个线程例程时也是如此。例如, 有一个本地变量 t i d 的实例,它 保存在主线程的栈中。我们用 巨 d . m 来表示这个实例。再来看一个例子, 本地变量 my 过 有两个实例 , 一个在对等线程 0 的栈内, 另一个在对等线程 1 的栈内。我们将这两个实例分别表示为 my i d . p O 和 my i d . p l 。 本地静 态变量。 本地静态变量是定义在函数内部并有 s t a t i c 属性的变量。和全局变量一样, 虚拟内存的读/写区域只包含在程序中声明的每个本地静态变量的一个实例。例如, 即使示例程序中的每个对等线程都 在第 25 行声明了 c n t , 在运行时, 虚拟内存的读/写区域中也只有一个 c n t 的实例。每个对等线程都读和写这个实例。 12. 4. 3 ,.±f:. 吉示亘 立旦\n我们说一个变量 v 是共享的 ,当 且 仅 当它的一个实例被一个以上的线程引用。例如, 示例程序中的变量 c n t 就是共享的,因 为它只有一个运行时实例,并 且这个实例被两个对等线程引用。在另一方面, my 过 不 是 共 享 的 , 因 为它的两个实例中每一个都只被一个线程引用。然而,认 识 到像 ms g s 这样的本地自动变量也能被共享是很重要的。\n饬 练习题 12. 6\n利用 12. 4 节 中的分析, 为 图 12-1 5 中的 示 例 程 序在下 表的每 个条目中填写 “ 是“ 或者“ 否"。在第 一列 中, 符号 v. t 表 示 变 量 v 的 一个实例 , 它 驻 留在线程 t 的本地栈中, 其中 t 要 么是 m( 主 线程),要么是 p O( 对等线程 0 ) 或者 p l ( 对等 线程 1 ) 。 变量实例 ptr cnt i.m msgs.m myid.po myi d . p l 主线程引用的? 对等线程0引用的? 对等线程1引用的? 根据 A 部分的分析, 变 量 p tr 、 c n t 、 1 、 ms g s 和 my 过 哪 些是 共享的? 5 用信号量同步线程 # 共享变量是十分方便,但 是 它 们也引 入了同 步错 误 ( s ynch ro nization er ro r ) 的可能性。考\n虑图 12-16 中的程序 b a d c n t . c , 它创建了两个线程, 每个线程都对共享计数变量 c nt 加 1。\ncod e/conclb ad cnt.c\nI* WARNING : This code is buggy! *I\n#include \u0026ldquo;csapp.h\u0026rdquo;\n3\n4 void *thread(void *vargp); I* Thread routine prototype *I\n5\nI* Global shared variable *I\nvolatile long cnt = 0; I* Counter *I\n8\n9 int main(int argc, char **argv)\n10 {\nlong niters;\npthread_t tid1, tid2;\n13\nI* Check input argument *I\nif (argc != 2) {\nprintf(\u0026ldquo;usage: %s \u0026lt;niters\u0026gt;\\n\u0026rdquo;, argv[O]);\nexit(O);\n18 }\n19 niters = atoi(argv [1]);\n20\nI* Create threads and wait for them to finish *I\nPthread_create(\u0026amp;tid1, 皿 L, thread, \u0026amp;niters);\n图12- 16 ba dc nt . c, 一个同步不正确的计数器程序\n23 Pthread_create(\u0026amp;tid2, 皿 L, thread, \u0026amp;niters); 24 Pthread_join(tid1, NULL); 25 Pthread_join (tid2, NULL) ; 26 27 I* Check result *I 28 if Cent != (2 * niters)) 29 printf(\u0026ldquo;BODM! cnt=%ld\\n\u0026rdquo;, cnt); 30 else 31 printf (\u0026ldquo;DK cnt=%ld\\n\u0026rdquo;, cnt); 32 exit(O); 33 }\n34\nI* Thread routine *I\nvoid *thread(void *vargp) 37 {\n38 long i, niters = *((long *)vargp); 39\nfor (i = O; i \u0026lt; niters; i++)\ncnt++;\n42\n43 return NULL;\n44 }\ncodelcondbadcnt.c\n图 12-16 (续)\n因为每个线 程都对计数器增加了 n i t er s 次, 我们预计它的最终值是 2 X n i t er s 。这看上去简单 而直接 。然而, 当在 L in u x 系统上运行 b a d c n t . c 时,我 们不仅得到错误的答案,而且每次得到的答案都还不相同!\nlinux\u0026gt; ./badcnt 1000000 BOOM! cnt=1445085\nlinux\u0026gt; ./badcnt 1000000 BOOM! cnt=1915220\nlinux\u0026gt; ./badcnt 1000000 BOOM! cnt=1404746\n那么哪里出错 了呢?为了清晰地理解 这个问题, 我们需要研究计数器循环(第40 41 行)的汇编代码, 如图 1 2- 1 7 所示。我们发现, 将线程 1 的循环代码分解成 五个部分是很有帮助的:\nH , : 在循环头部的指令块。\nL,: 加载共享变量 c n t 到累加寄存器%r d x , 的 指令, 这里%r d x , 表示线程 1 中的寄存器%r d x 的 值。\nU, : 更新(增加) %r d x, 的指令。\ns,: 将%r d x ; 的 更新值存回到共享变量 c n t 的指令。\nT;: 循环尾部的指令块。\n注意头和尾只操作本地栈变量 , 而 L, 、U,和 S ,操作共享计数器变量的内容。\n当 b a d c n t . c 中的两个对等线程在一 个单处理器上并发运行时, 机器指 令以某种顺序一个接一个地完成。因此,每个并发执行定义了两个线程中的指令的某种全序(或者交 叉)。不幸的是,这些顺序中的一些将会产生正确结果,但是其他的则不会。\n线程的汇编代码\n线程 的C代码\nf o r (i =O ; i \u0026lt; ni 七 e r s ; i++) 1\n圈畛\ncnt++;\nH,: 头\nL, : 加载c nt\nu,: 更新c nt\nS;: 存储c nt\nT, : 尾\n图 1 2-1 7 ba dc nt . c 中计数器循环(第40~ 41 行)的汇编代码\n这里有个关键点:一般而言,你没有办法预剧操作系统是否将为你的线程选择一个正 确的顺序。例如,图 1 2-1 8 a 展 示了一个正确的指令顺序的分步操作。在每个线程更新了共享变量 e n 七之 后 ,它 在 内 存 中 的 值 就 是 2 , 这正是期望的值。\n另一方面,图 1 2-1 8 b 的 顺 序产生一个不正确的 c n t 的值。会发生这样的问题是因为, 线 程 2 在 第 5 步加载 c n t , 是在第 2 步线程 1 加载 c n t 之后, 而在第 6 步线程 1 存储它的更新值之前。因此, 每个线程最终都会存储一个值为 1 的更新后的计数器值。我们能够借 助千一种叫做进度图 ( p ro g r es s g ra p h ) 的 方法来阐明这些正确的和不正确的指令顺序的概念, 这个图我们将在下一节中介绍。\na ) 正确的顺序 b ) 不正确的顺序\n图 1 2-18 badc nt . c 中第一次循环迭代的指令顺 序\n凶 练习题 12 . 7 根据 b a d c n t . c 的指令顺序 完成 下表:\n。\n这种顺 序会产 生 一个正确的 c n t 值吗?\n12 . 5 . 1 进度图\n进度图( pro g res s g ra ph ) 将 n 个并发线程的执行模型化为一条 n 维笛卡儿空间中的轨迹线。每条轴 k 对应于线程 k 的进度。每个点 ( Ii , lz , …, J\u0026quot; ) 代表线程 k ( k = l , … , n )已经完成 了指令 Ik这一状态。图的原点对应于没有任何线程完成一 条指令 的初始状态。\n图 1 2-19 展示了 b a d c n t . c 程序第一 次循环 迭代的二维进度图 。水平轴对应于线程 1,\n垂直轴对应于线程 2。点 CL 1, S 2) 对应于线程 1 完成了 L1 而线程 2 完成了 S2的状态。\n进度图将 指令执行模型化 为从一种状态到另一种状态的转换 ( t ra ns it io n ) 。转换 被表示为一条从一点到相邻点的有向边。合法的 转换是向右(线程 1 中的一条指令完成)或者向上\n(线程 2 中的一条指令完成)的。两条指令不能在同一时刻完成一 对角线转换是不允许的。程序决不会反向运行,所以向下或者向左移动的转换也是不合法的。\n一个程序的执行历史被模型化为状态空间 中的一条轨迹线。图 12-20 展示了下面指令顺序对应的轨迹线:\nH1, L1, U1, H2, L2, S1, T1 , U2 , S 2 , T 2 # 线程2 线程2\n(L.,, S2)\nS2 S2 # U2 Ui\nL2 L2 # H2\nH , L1 U1 S1 Tl\n线程l\nHi\nH, L, U,\nS, T,\n线程l\n图 12- 1 9\nbadcnt . c 第一次循环迭代的进度图\n图 12-20\n一个轨迹线示例\n对于线程 i\u0026rsquo; 操作共享变量 c n t 内容的指令( L; , U;, S; ) 构成了一个(关于共享变量\nc让 的)临界区 ( crit ica l section), 这个临界区不应该和其他进程的临界区交替执行。换句话说,我们想要确保每个线程在执行它的临界区中的指令时,拥有对共享变量的互斥的访问 ( m ut uall y exclusive access ) 。通常这种现象称为互斥 ( m ut ua l e xcl us io n ) 。\n在进度图 中, 两个临界区的交集形成的状态空间区域称为不安 全区 ( unsafe regio n ) 。图 12-21 展示了变量 c吐 的不安全区。注意 , 不安全区和与它交界的状态相毗邻, 但并不包括这些状态。例如, 状态CH 1 , H z) 和CS 1 , Uz) 毗邻不安全区, 但是它们并不是不安全区的一部分。绕开不安全 区的轨迹线叫做安全轨迹线( sa fe t ra jector y) 。相反, 接触到任何 不安全区的轨迹线就叫做不 安全轨迹线 ( unsa fe t ra jecto r y ) 。图 12- 21 给出了示例程序ba d e n七 . c 的 状态空间中的安全和不安全轨迹线。上面的 轨迹线绕开了不安全区域的左边和上边,所 以是安全的。下面的轨 迹线穿 越不安全区, 因此是不安全的 。\n任何安全轨迹线都将正确地更新共享计数器。为了保证线程化程序示例的正确执行(实 际上任何共享全局数据结构的并发程序的正确执行)我们必须以某种方式同步线程,使它们 总是有一条安全轨迹线。一个经典的方法是基于信号量的思想, 接下来我们就介绍它。\n饬 练习题 12 . 8 使用 图 1 2-21 中的 进度 图, 将下列 轨迹 线划分为 安全的 或者 不 安全 的。\nA. H1, L1, U1, S1, H2 , L 2 , U2 , S 2 , T 2 , T1 # B. H2 , L 2 , H 1 , L 1 , U1, S1, T1, U2, 5 2 , T 2 C. H 1 , H2, L 2 , U2 , S2 , L1, 线程2 U1, S1, T1, T2 T,\ns,\n写 c nt 的 j u,\nL2 # HJ\nH1 L1 亿 SI T, 线程1\n写 cnt 的临界区\n图 12-21 安全和不安全轨迹线。临界区的交集形成了不安全区。绕开不安全区的轨迹线能够正确更新计数器变量\n5. 2 信号量\nEdsger Dijkstra, 并发编程领域的先锋人物,提出了一种经典的解决同步不同执行线程问题的方法, 这种方法是基千一种叫做信号 量( s em a p ho re ) 的特殊类型变量的。信号 量 s 是具有非负整数值的全局变量,只 能由两种特殊的操作来处理,这两 种操作称为 P 和 V :\nPCs): 如果 s 是非零的, 那么 P 将 s 减 1, 并且立即返回。如果 s 为零, 那么就挂起这个线程, 直到 s 变为非 零,而 一个 V 操作会重启 这个线程。在重启之后, P 操作将 s 减 1, 并将控制返回给调用者。\nV(s): V操作将 s 加 1 。如果有任何线程阻 塞在 P 操作等待 s 变成非零, 那么 V 操作会重启这些线程中的一个 , 然后该线程将 s 减 1, 完成它的 P 操作。\nP 中的测试和减 1 操作是不可分割的,也 就是说, 一旦预测信号量 s 变为非 零, 就会将 s 减 1, 不能有中断。V 中的加 1 操作也是不可分割的,也 就是加载、加 1 和存储信号量的过程中没有中断。注意 , V 的定义中没有定义等待线程被重启动的顺序。唯一的要求是 V 必须只能重启一个正在等待的线 程。 因此 , 当有多个线程 在等待 同一个信号量时 ,你不能预 测 V 操作要重启哪 一个线程。\nP 和 V 的定义确保了一个正在运行的程序绝不 可能进入这样一种状态 ,也 就是一个正确初始化了 的信号量有一个负值。这个属性称为信号量不 变性( se m a pho re invariant), 为控制并发程序的轨迹线提供了强有力的工具,在下一节中我们将看到。\nP os ix 标准定义了许多操作信号量的函数。\n#include \u0026lt;semaphore.h\u0026gt;\nint sem_init(sem_t•sem, 0, unsigned int value); int sem_wait(sem_t•s); /• P(s)•I\nint sem_post(sem_t•s); I• V(s)•I\n返回: 若 成 功 则为 0 , 若 出错 则为 一1.\ns e m_ i n i t 函数 将 信 号 量 s e m 初 始 化 为 v a l u e 。每个信号最在使用前必须初始化。针对我们的目的,中 间 的 参 数 总 是 零 。 程 序分别通过调用 s e m_ wa 江 和 s e m_ p o 江 函 数 来执行 P 和 V 操作。为了简明,我 们更喜欢使用下面这些等价的 P 和 V 的包装函数:\n#include \u0026ldquo;csapp. h\u0026rdquo;\nvoid P(sem_t *s); I* Wrapper function for sem_wa i t *I void V(sem_t *s ) ; I* Wr apper f un ct i o n for s e m_pos t *I\n返 回 : 无 。\n匮目P 和 V 名字的起源\nE dsge r Dijk s t ra0 930 —20 0 2 ) 出生于荷 兰。名 字 P 和 V 来源 于荷 兰语 单词 P ro ber en\n(测试)和V e r h og e n ( 增加)。\n5. 3 使用信号量来实现互斥\n信号量提供了一种很方便的方法来确保对共享变量的互斥访问。基本思想是将每个共 享变量(或者一组相关的共享变量)与一个信号量 s ( 初始为 1 ) 联系起来, 然后用 P Cs ) 和 V Cs ) 操 作 将相应的临界区包围起来。\n以这种方式来保护共享变量的信号量叫做二元信号量 ( b in a r y s e m a p ho r e ) , 因为它的值总是 0 或者 1 。以提供互斥为目的的二元信号量常常也称为互 斥 锁 ( m ut ex ) 。在一个互斥锁上执行 P 操作称为对互斥锁加锁。类 似地, 执 行 V 操 作 称 为对互斥锁 解锁。对一个互斥锁加了锁但是还没有解锁的线程称为占 用这 个互斥锁。一个被用作一组可用资源的计数器的信号量被称为计数信号量。\n图 1 2- 22 中的进度图展示了我们如何利用二元信号量来正确地同步计数器程序示例。每个状态都标出了该状态中信号量 s 的值。关键思想是这种 P 和 V 操作的结合创建了一组\n线程2\n.0 .0\nT,\n.0 .0\n.0 .0 .I .I\n.0 .0 .I\nV(s) 。\n一1\nS, :\n禁止区 。\n·· ··-··I···········-··I····,·- ·I ·\u0026rsquo;•···\n。 。 : - I\n卜·:\n.- I\n- I - I i\n;.\nu, 。 。\nL 2 0 。\n不安全区\n1- 1 - I - I - I .\n;·一··I············-···I··············-·1······-··1··!·•\u0026rsquo;· 。\nP (s )\n0 .0 .0 0\nCTfil # ,1-f\n0 0 0\n线 程1\nH , P (s ) L1 U1 SI V(s) T1\n图 12-22 使用信号量来互斥。s\u0026lt; O 的不可行状态定义了一个禁 止区, 禁止区完全包括了不安全区, 阻止了实际可行的轨迹线接触到不安全区\n状态, 叫做禁止 区( fo r b idde n region) , 其中 s\u0026lt; O。 因为信号量的不变性 , 没有实际可行的轨迹线能够包含禁止区中的状态。而且, 因为禁止区完全包括了不 安全区, 所以没有实际可行的轨迹线能够接触不安全区的任何部分。因此,每条实际可行的轨迹线都是安全的, 而且不管运行时指令顺序是怎样的,程序都会正确地增加计数器值。\n从可操作的意义上来说, 由 P 和 V 操作创建的禁止区使得在任何时间点上, 在被包围的临 界区中, 不可能有多个线程在执行 指令。换句话说,信 号量操作确保了对临界区的互斥访问 。\n总的来说, 为了用信号量正确同步图 1 2-1 6 中的计数器程序示例,我 们首先声 明一个信号量 mu 七e x :\nvolatile long cnt = O; I* Counter *I\nsem_t mutex; I* Semaphore that protects counter *I\n然后在主例程中将 mu t e x 初始化 为 1 :\nSem_init(\u0026amp;mutex, 0, 1); I* mutex = 1 *I\n最后, 我们通 过把在线程例程中对共享变僵 c n t 的更新包围 P 和 V 操作, 从而保护它们:\nfor (i = O; i \u0026lt; niters; i++) { P(\u0026amp;mutex);\ncnt++;\nV(\u0026amp;mutex);\n}\n当我们运行这个正确同步的程序时,现在它每次都能产生正确的结果了。\nlinux\u0026gt; ./goodcnt 1000000 OK cnt=2000000\nlinux\u0026gt; ./goodcnt 1000000 OK cnt=2000000\nm 进度图的 局限性\n进度图给了我们一种较好的方法,将在单处理器上的并发程序执行可视化,也帮助 我们理解为什么需要同步。然而,它们确实也有局限性,特别是对于在多处理器上的并 发执行,在 多处 理器上一 组 CPU/ 高速缓 存对共享同一 个主 存。多 处理 器的 工作方式是进度图不能解释的 。特别是 ,一 个多处理 器内存 系统可以 处于一 种状态,不 对应 于进度图中任何轨迹线。不 管如何 , 结论总是一样的 : 无论是 在单处理 器还是 多处 理器上运行程序, 都要 同步你 对共享 变量的 访问。\n5. 4 利用信号量来调度共享资源\n除了提供互斥之外,信 号量的另一个重要作用是调度对共享资源的访问。在这种场景中, 一个线程用信号量操作来通知另一个线程, 程序状 态中的某个条件已经为真了。两个经典而有用的例子是生产者-消费者和读者-写者问 题。\n1 生产者-消费者问题\n图 1 2- 23 给出了生产者-消费者问 题。生产者 和消费者线程共享一个有 n 个槽的有限缓 冲区。生产者线程反复地生成新的项目 ( item ) , 并把它们插入到缓冲区中。消费者线程不断地\n从缓冲区中取出这些项目,然后消费(使用)它们。也可能有多个生产者和消费者的变种。\n图 12- 23 生产者-消费者问题。生产者产生项目并把它们插入到一个有限的缓冲区中。消费者从缓冲区中取出这些项目,然后消费它们\n因为插入和取出项目都涉及更新共享变量,所以我们必须保证对缓冲区的访问是互斥的。但是只保证互斥访问是不够的,我们还需要调度对缓冲区的访问。如果缓冲区是满的\n(没有空的槽位),那么生产者必须等待直到有一个槽位变为可用。与之相似,如果缓冲区 是空的(没有可取用的项目),那么消费者必须等待直到有一个项目变为可用。\n生产者-消费者的相互作用在现实系统中是很普遍的。例如,在一个多媒体系统中, 生产者编码视频帧,而消费者解码并在屏幕上呈现出来。缓冲区的目的是为了减少视频流的抖动,而这种抖动是由各个帧的编码和解码时与数据相关的差异引起的。缓冲区为生产者提供了一个槽位池,而为 消费者提供一个已编码的帧池。另一个常见的示例是图形用户接口设计。生产者检测到鼠 标和键盘事件, 并将它们插入到缓 冲区中。消费者以某种基于优先级的方式从缓冲区取出这些事件,并显示在屏幕上。\n在本节 中, 我们 将开发一个简单的包, 叫做 SBUF , 用来构造生产者-消费者程序。在下一节里 , 我们会看到如何 用它来构造一个基千预 线程化( pr et h rea d in g ) 的有趣的并发服务器。SBUF 操作类型为 s b u f _ t 的有限缓冲区(图1 2- 24 ) 。项目存放在一个动态分配的\nn 项整数数组 ( b u f ) 中。fr o n t 和r e ar 索引值记录该数组中的 第一项 和最后一项。三个信号量同步对缓冲区的 访问。mu t e x 信号量提供互斥的缓冲区访问 。s l o t s 和 i t e ms 信号 量分别记录空槽位和可用项目 的数量。\ntypedef struct { int *buf;\nI* Buffer array *I\ncode/condsbuf h\nint n;\nint front; int rear; sem_t mutex; sem_t slots; sem_t items;\n} sbuf_t;\nI* Maximum number of slots *I\nI* buf[(front+1)%n] is first item *I I* buf[rear%n] is last item *I\nI* Protects accesses to buf *I I* Counts available slots *I I* Counts available items *I\ncode/condsbuf.h\n图 l 2-24 sbuf_t: SBUF 包使用的有限缓 冲区\n图 1 2- 25 给出 了 SBU F 函数的实现。 s b u f _ i n i t 函数为缓 冲区 分配堆内存,设 置f r o n 七和 r e ar 表示一个空的缓冲区 , 并为三个 信号量赋初始值。这个函数在调用其他三个函数中的任何一个之前调用一次。s b u f _ d e i n 江 函数是当应用程序使用完缓冲区时 , 释放缓冲区存储的 。s b u f _ i n s er t 函数等待一个可用的槽 位, 对互斥锁加锁, 添加项目, 对互斥锁解锁, 然后宣布有一个新项目可用。s b u f _r e mo v e 函数是与 s b u f _ i n s er t 函数对称的。在等待一个可用的缓冲区项目之后,对互斥锁加锁,从缓冲区的前面取出该项目, 对互斥锁解锁, 然后发信号通知一个新的槽位可供使用 。\ncode/condsbu肛\n#include II csapp. h11\n#include 11sbuf .h11\n3\nI* Create an empty, bounded, shared FIFO buffer with n slots *I\nvoid sbuf_init(sbuf_t *sp, int n)\n6 {\nsp-\u0026gt;buf = Calloc(n, sizeof(int));\nsp-\u0026gt;n = n; I* Buffer holds max of n items *I\nsp-\u0026gt;front = sp-\u0026gt;rear = 0; I* Empty buffer iff front == rear *I 1O Sem_init (\u0026amp;sp-\u0026gt;mutex, 0, 1); I* Binary semaphore for locking *I\nSem_init(\u0026amp;sp-\u0026gt;slots, 0, n); I* Initially, buf has n empty slots *I\nSem_init(\u0026amp;sp-\u0026gt;items, O, 0); I* Initially, bufhas zero data items *I\n13 }\n14\nI* Clean up buffer sp *I\nvoid sbuf_deinit(sbuf_t *Sp)\n17 {\n18 Free(sp-\u0026gt;buf);\n19 }\n20\nI* Insert item onto the rear of shared buffer sp *I\nvoid sbuf_insert(sbuf_t *Sp, int item) 23 {\nP (\u0026amp;sp-\u0026gt;slots); I* Wait for available slot *I\nP (\u0026amp;sp-\u0026gt;mutex); I* Lock the buffer *I\nsp-\u0026gt;buf [ (++s p- \u0026gt;r ea 工)%(sp-\u0026gt;n)] = item; I* Insert the item *I\nV(\u0026amp;sp-\u0026gt;mutex); I* Unlock the buffer *I\nV(\u0026amp;sp-\u0026gt;items); I* Announce available item *I\n29 }\n30\nI* Remove and return the first item from buffer sp *I\nint sbuf_remove(sbuf_t *Sp)\n33 {\nint item·\nP(\u0026amp;sp-\u0026gt;items); I* Wait for available item *I\nP(\u0026amp;sp-\u0026gt;mutex); I* Lock the buffer *I\nitem= sp-\u0026gt;buf[(++sp-\u0026gt;front)%(sp-\u0026gt;n)]; I* Remove the item *I\nV(\u0026amp;sp-\u0026gt;mutex); I* Unlock the buffer *I\nV(\u0026amp;sp-\u0026gt;slots); I* Announce available slot *I\nreturn item;\n41 }\ncode/condsbu肛\n图 12- 25 SBUF: 同步对有限缓冲区并发访问的包\n练习题 12. 9 设 p 表 示 生产 者数 量, c 表 示 消费者数量, 而 n 表 示 以项目单元 为单 位\n的缓冲 区大小。对于下 面的每个场景 , 指 出 s b u f _ i n s er t 和 s b u f _ r e mo v e 中的互斥锁信号量是否是必需的。\np = I , c = 1 , n \u0026gt; l p = I , c = 1 , n = I p\u0026gt; I, c\u0026gt;l, n=I # 2 读者-者写问题\n读者-者写问题是互斥问题的一个概括。一组并发的线程要访问一个共享对象, 例如\n一个主存中的数据结构,或 者一个磁盘上的数据库。有些线程只读对象, 而其 他 的 线 程只修改对象。修改对象的线程叫做写者。只读对象的线程叫做读者。写者必须拥有对对象的 独占的访问,而读者可以和无限多个其他的读者共享对象。一般来说,有无限多个并发的 读者和写者。\n读者-写者交互在现实系统中很常见。例如,一个在线航空预定系统中,允许有 无限多个客户同时查看座位分配,但是正在预订座位的客户必须拥有对数据库的独占 的访问。再来看另一个例子, 在 一个多线程缓 存 Web 代理中, 无 限 多 个 线 程可以从共享页面缓存中取出已有的页面,但是任何向缓存中写入一个新页面的线程必须拥有 独占的访问。\n读者-写者问题有几个变种,分别基于读者和写者的优先级。第一类读者-写者问题,读者优先,要求不要让读者等待,除非已经\n把使用对象的权限赋予了一个写者。换旬\n话说,读者不会因为有一个写者在等待而等待。第二类读者-写者问题,写者优先, 要求一旦一个写者准备好可以写,它就会尽可能快地完成它的写操作。同第一类问题不同,在一个写者后到达的读者必须等待,即使这个写者也是在等待。\n图 12-26 给出了一个对第一类读者- 写者问题的解答。同许多同步问题的解 答一样,这个解答很微妙,极具欺骗性 地简单。信号量 w 控制对访问共享对象 的临界区的访问。信号量 mu t e x 保 护 对共享变量 r e a d c n t 的访问,r e a d c n t 统计当前在临界区中的读者数量。每当一 个写者进入临界区时, 它 对 互 斥锁 w 加锁 , 每当它离 开 临 界 区 时, 对 w 解 锁 。这就保证了任意时刻临界区中最多只有 一个写者。另一方面,只有第一个进入 临界区的读者对 w 加 锁 , 而 只 有 最 后 一个 离 开临界区的读 者对 w 解 锁 。 当 一个读者进入和离开临界区时,如果还有其 他读者在临界区中,那么这个读者会忽 略互斥锁 w。这 就意味着只要还有一个读者占用互斥锁 w, 无限多数量的读者可以没有障碍地进入临界区。\n对这两种读者-写者问题的正确解答可能导致饥饿 ( s ta r va t io n ) , 饥饿就是一\nI* Global va工ia bl e s *I\nint readcnt; I* Initially= 0 *I\nsem_t mutex, w; I* Both initially= 1 *I\nvoid reader(void)\n{\nwhile (1) {\nP(\u0026amp;mutex); readcnt++;\nif (readcnt == 1) I* First in *I\nP(\u0026amp;w); V(\u0026amp;mutex);\nI* Critical section *I I* Reading happens *I\nP(\u0026amp;mutex); readcnt\u0026ndash;;\nif (readcnt == 0) I* Last out *I\nV(\u0026amp;w); V(\u0026amp;mutex);\nvoid writer(void)\n{\nwhile (1) {\nP(\u0026amp;w);\nI* Critical section *I I* Writing happens *I\nV(\u0026amp;w);\n个线程无限期地阻塞,无法进展。例如,\n图 12-26 所示的解答中,如 果 有 读 者 不 断地到达,写者就可能无限期地等待。\n图 12-26 对第一类读者-写者问题的解答。读者优先级高于写者\n练习题 12. 10 图 12- 26 所 示的 对 第 一 类 读者-写者问题的 解答给 予 读 者 较 高 的 优先级,但是从某种意义上说,这种优先级是很弱的,因为一个离开临界区的写者可能重 启一个在等待的写者,而不是一个在等待的读者。描述出一个场景,其中这种弱优先 级会导致一群写者使得一个读者饥饿。\n应I _其他同步机 制\n我们已经向你展示了如何利用信号量来同步线程,主要是因为它们简单、经典,并且 有一个清晰的语义模型。但是你应该知道还是存在 着其他 同步技 术的。例如 , Ja va 线程是用一种叫做 Java 监控器(J ava Monitor) [ 48] 的机制来同步的 , 它提供了对 信号量 互斥 和调度能力的更高级 别的抽 象; 实际 上,监控 器 可以 用信号量来 实现。再 来看一 个例 子, Pthrea ds 接口定义了一组对互斥锁和条件 变量的 同步 操作。Pthreads 互斥锁 被用 来实现互斥。条件 变量用来调 度对共享资源的访问, 例如在一个生产者-消费者程序中的有限缓冲区。\n12 . 5. 5 综合:基千预线程化的并发服务器\n我们已 经知道了如何使用信号量来访问 共享变量和调度对共享资源的访问。为了帮 助你更清晰地理解这些思想,让 我们把它们应用到一个基千称为预线程化( pret hread ing )技术的并发服务器上。\n在图 1 2-14 所示的并发服务器中, 我们为每一个新客户端创建了一个新线程。这种方法的缺点是我们为每一个新客户端创建一个新线程,导致不小的代价。一个基于预线程化 的服务器试图通过使用如图 1 2-27 所示的生产者-消费者模型来 降低这种开销。服务器是由一个主线程和一组工作者线程构 成的。主线程不断地接受来自客户端的连接请求, 并将得到的连接描述符放 在一个有限缓冲区中。每一个工作 者线程反复 地从共享缓冲区中 取出描述符, 为客户端 服务, 然后等待下一个描述符。\n图 1 2- 27 预线程化的并发服务器的组织结构。一组现有的线程不断地取出\n和处理来自有限缓冲区的已连接描述符\n图 12-28 显示了我们怎样用 SBUF 包来实现一个预线程化的并发 echo 服务器。 在初始化了缓冲区 s b u f ( 第 24 行)后, 主线程创建 了一组工作者线程(第25 ~ 26 行)。然后它进 入了无限的服务器循 环,接 受连接 请求, 并将得到的巳 连接描述符插入到缓冲区 s b uf 中。每个工作者线程的行为都非常简单。它等待直到它能从缓冲区中取出一个已连接描述符\n(第39 行),然后调用 e c ho_c nt 函数回送客户端的输入。\n图 12-29 所示的函数 e c ho_c n t 是图 11-22 中的 e c ho 函数的一个版本, 它在全局变量b yt e _c nt 中记录了从所有客户端接收到的累计字节数。这是一段值得研究的有趣代码, 因为它向你展示了一个从线程例程 调用的初始化 程序包的 一般技术。在这种情况中, 我们\n需要初始化 b y t e _ c n t 计数器和 rnu t e x 信号量。一个方法是 我们为 SBUF 和 R I O 程序包使用过的, 它要求主线程显式地调用一个初始化函数。另外一个方法, 在此显示的, 是当第一次有某个线程调用e c h o _ c n t 函数时, 使 用 p t hr e a d _ o n c e 函数(第 19 行)去调用初始化函数。这个方法的优点是它使程序包的使用更加容易。这种方法的缺点是每一次调用 e c h o _ c n t 都会导致调用 p t hr e a d_ o n c e 函数, 而在大多数时候它没有做什么有用的事。\nco d e/co nd ech os ervert-p re.c\n#include \u0026ldquo;csapp.h\u0026rdquo;\n#include \u0026ldquo;sbuf .h\u0026rdquo; #define NTHREADS 4 #define SBUFSIZE 16 void echo_cnt(int connf d) ; void *thread(void *vargp); 9 sbuf_t sbuf; I* Shared buffer of connected descriptors *I 10\n11 int main(int ar gc, char **argv)\n12 {\nint i , listenfd, connfd;\nsocklen_t cl i ent l en;\nstruct sockaddr_storage clientaddr;\npthread_t tid;\nif (argc != 2) {\nfprintf(stderr, \u0026ldquo;usage: %s \u0026lt;port\u0026gt;\\n\u0026rdquo;, argv[O]);\ne x i t (O) ;\n21\n22 listenfd = Open_listenfd(argv[1]) ;\n23\nsbuf_init(\u0026amp;sbuf, SBUFSI ZE) ;\nfor (i = O; i \u0026lt; NTHREADS; i ++) I* Create worker threads *I 26\u0026rsquo; Pt hr e a d _cr ea t e (\u0026amp;t i d , NULL, thread, NULL);\n27\nwhile (1) {\nclientlen = sizeof (struct sockaddr_s t or ag e ) ;\nconnfd = Accept (listenfd, (SA *) \u0026amp;clientaddr, \u0026amp;clientlen);\nsbuf_insert(\u0026amp;sbuf, connf d) ; I* Insert connfd in buffer *I 32\n33 }\n34\n35 void *thread(void *vargp) 36 {\nPt hr e ad _de t a c h ( pt hr e ad _s e lf O);\nwhile (1) {\nint connfd = sbuf_remove(\u0026amp;sbuf); I* Remove c onn f d from buffer *I\n40\n41\n42\n43 }\necho_cnt(connfd); Cl os e ( conn f d ) ;\nI* Service client *I\ncode/con吹choservert-pre.c\n图12- 28 一个预线程化的并发 echo 服务器。这个 服务器使用的是有一个生产者和多个消费者的生产者-消费者模型\ncode/cone/echo-cnt.c\n#include \u0026ldquo;csapp.h\u0026rdquo;\n3 static int byte_cnt; I* Byte counter *I\n4 static sem_t mutex; I* and the mutex that protects it *I\n6 static void init_echo_cnt(void)\n7 {\n8 Sem_init(\u0026amp;mutex, 0, 1);\n9 byte_cnt = O;\n10 }\n12 void echo_cnt(int connfd)\n13 {\n14 int n·\nchar buf[MAXLINE];\nrio_t rio;\nstatic pthread_once_t once= PTHREAD_ONCE_INIT;\n18\nPthread_once(\u0026amp;once, init_echo_cnt);\nRio_readinitb(\u0026amp;rio, connfd);\nwhile ((n = Rio_readlineb (\u0026amp;rio, buf, MAXLINE)) ! = 0) {\nP (\u0026amp;mutex);\nbyte_cnt += n;\nprintf (11server received %d CJ 儿 d total) bytes on fd %d\\n\u0026quot;,\nn, byte_cnt, connfd);\nV (\u0026amp;mutex) ;\nRio_writen(connfd, buf, n);\n28\n29 }\ncode/conc/echo.-ccnt\n图 12- 29 echo_cnt, echo 的一个版本, 它对从客户端接收的所有字节计数\n一旦 程序包 被初始化, e c h o _ c n t 函 数 会 初始化 RIO 带 缓 冲区的 I/ 0 包(第 20 行),然 后 回 送 从 客 户端接收到的每一个文本行。注意,在 第 23 25 行 中 对共享变量 b yt e _ c n t 的访问是被 P 和 V 操作保护的。\n团 日 基千线程的事件驱动程序\nI/ 0 多路 复 用不 是 编写事件 驱动程序的唯一方 法。 例如 , 你可能已经注意到我们刚才开发的并发的预线程化的服务器实际上是一个事件驱动服务器,带有主线程和工作者 线程的简单状态机。主线程有两种状态(\u0026ldquo;等待连接请求”和”等待可用的缓冲区槽 位\u0026rdquo;)、两个 I/ 0 事件( \u0026ldquo;连接请求到 达” 和 "缓 冲区槽 位 变为 可用\u0026rdquo; )和 两个转换( \u0026ldquo;接受连接请求” 和 “ 插 入 缓 冲区项 目\u0026rdquo; )。 类似 地, 每个工作者线程有一个状 态( \u0026quot; 等待 可用的缓冲项目\u0026quot;)、 一个 I/ 0 事件("缓冲区项 目 变为 可用\u0026quot; )和 一个转换( \u0026ldquo;取出缓 冲区项 目\u0026rdquo;)。\n12. 6 使用线程提高并行性\n到目前为止,在对并发的研究中,我们都假设并发线程是在单处理器系统上执行的。\n然而,大多数现代机器具有多核处理器。并发程序通常在这样的机器上运行得更快,因为 操作系统内核在多个核上并行地调度这些并发线程,而不是在单个核上顺序地调度。在像 繁忙的 Web 服务 器、数据库服务器和大型科学计算代码这样的应用中利用这样的并行性是至关重要的, 而且在像 Web 浏览骈、电子表格处理程序和文档处理程序这样的主流应用中,并行性也变得越来越有用。 所有的程序\n图 12- 30 给出了顺 序、并发和并行程序之间的 口五五示\n集合关系。所有程序的集合能够被划分成不相交\n的顺序程序集合和并发程序的集合。写顺序程序只有一条逻辑流。写并发程序有多条并发流。并行程序是一个运行在多个处理器上的并发程序。因此,并行程序的集合是并发程序集合的真子集。\n并行程序的详细处理超出了本书讲述的范围,\n三 顺序程序\n图 12 -30 顺序、并发和并行程序\n集合之间的关系\n但是研究一个非常简单的示例程序能够帮助你理解并行编程的一些重要的方面。例如,考 虑我们如何并行地对一列整数 o, …, n - l 求和。当然, 对于这个特殊的问 题, 有闭合形式表达式的 解答(译者注: 即有现成的公 式来计算它, 即和等 千 n ( n - 1 ) / 2) , 但是尽管如\n此,它是一个简洁和易于理解的示例,能让我们对并行程序做一些有趣的说明。\n将任务分 配到不同线 程的最 直接方法是将序列划分成 t 个不相交的区域, 然后给 t 个不同的线程每个 分配一个区域。为了简单, 假设 n 是 t 的倍数, 这样每个 区域有 n / t 个元素。让我们来看看多个线程并行处理分配给它们的区域的不同方法。\n最简单也最直接的选择是将线程的和放入一个共享全局变量中,用互斥锁保护这个变 量。图 12-31 给出了我们会如何实 现这种方法。在第 28 ~ 33 行, 主线程创建对等线程 ,然 后等待它们结束。注意 , 主线程传递给每个对等线程一个小整数,作为唯一的线程ID。每个对等线程会用它的线 程 ID 来决定它应该计算序列的哪一部分。这个向对等线程传递一个小的唯一的线程1D 的思想是一项通用技术 , 许多并行应用中都用到了它。在对等线程终止后, 全局变鼠 gs um 包含着最终的和。然后主线程用闭合形式解答来验证结果(第36~ 37 行)。\ncode/condpsum-mutex.c\n#include \u0026ldquo;csapp.h\u0026rdquo;\n2 #define MAXTHREADS 32\n4 void *surn_rnutex(void *vargp); I* Thread routine *I\n6 I* Global sha工ed va 工i abl e s *I\nlong gsurn = O; I* Global sum *I\nlong nelerns_per_thread; I* Number of elements to sum *I\n9 sem_t rnutex; I* Mutex to protect global sum *I\n10\n11 int rnain(int argc, char **argv)\n12 {\nlong i, nelems, log_nelems, nthreads, myid[MAXTHREADS];\npthread_t tid[MAXTHREADS];\n15\n16 I* Get input arguments *I\n图12-31 ps um- mut ex 的主程序, 使用多个线程将 一个序列元素的和放入一个用互斥锁保护的共享全局变扯中\n17 if (argc != 3) { 18 printf(\u0026ldquo;Usage: %s \u0026lt;nthreads\u0026gt; \u0026lt;log_nelems\u0026gt;\\n\u0026rdquo;, argv[O]); 19 exit(O); 20 } 21 nthreads = atoi(argv[1]); 22 log_nelems = atoi(argv[2]); 23 nelems = (1L«log_nelems); 24 nelems_per_thread = nelems / nthreads; 25 sem_init(\u0026amp;mutex, 0, 1); 26 27 I* Create peer threads and wait for them to finish *I 28 for (i = O; i \u0026lt; nthreads; i++) { 29 myid[i] = i; 30 Pthread_create(\u0026amp;tid[i], NULL, sum_mutex, \u0026amp;myid[i]); 31 } 32 for (i = O; i \u0026lt; nthreads; i++) 33 Pthread_join(tid[i], NULL); 34 35 I* Check final answer *I 36 if (gsum != (nelems * (nelems-1))/2) 37 printf(\u0026ldquo;Error: result=%ld\\n\u0026rdquo;, gsum); 38 39 exit(O); 40 } code/condpsum-mutex.c 图 12-31 (续) 图 12-32 给出了每个对等线程执行的函数。在第 4 行 中 ,线 程 从 线 程 参 数 中 提取出线程 ID , 然后用这个 ID 来决定它要计算的序列区域(第5~ 6 行)。在第 9 ~ 13 行中,线 程在它的那部分序列上迭代操作, 每次迭代都更新共享全局变量 g s um。 注 意 , 我们很小心地用 P 和 V 互斥操作来保护每次更新。\ncode/condpsum-mutex.c\nI* Thread routine for psum-mutex.c *I void *sum_mutex(void *vargp)\n{\nlong myid = *((long *)vargp); I* Extract the thread ID *I long start= myid * nelems_per_thread; I* Start element index *I long end= start+ nelems_per_thread; I* End element index *I long i;\nfor (i = start; i \u0026lt; end; i++) { P(\u0026amp;mutex);\ngsum += i;\nV(\u0026amp;mutex);\n}\nreturn NULL;\ncode/condpsum-mutex.c\n图 12-32 ps um- mu七e x 的线程例程。每个对 等线程将各 自的 和累加进一个用互斥锁保护的共享全局变量中\n我们在一个四核系统上, 对一个大小为 n = 沪 的序列运行 p s um- mutex, 测量它的运行时间(以秒为单位),作为线程数的函数,得到的结果难懂又令人奇怪:\n线程数\n版本 16\nps um- mut e x I 68 432 I 719 I 552 I 599\n程序单线程顺序运行时非常慢,几乎比多线程并行运行时慢了一个数量级。不仅如 此, 使用的核数越多 ,性 能越差。造成性能差的原因是相对于内存更新操作的开销,同 步操作( P 和 V ) 代价太大。这突显了并行编程的一项重要教训 : 同 步 开销 巨 大,要 尽 可能 避免。如果无可避免 , 必须要 用尽可能 多的有用计算 弥补这 个开销。\n在我们的例子中,一种避免同步的方法是让每个对等线程在一个私有变量中计算它自 己的 部分和, 这个私有 变量不与其他任何线程共享, 如图 12-33 所示。主线程(图中未显示)定义一个全局 数组 p s u m, 每个对等线程 1 把它的部分和累积在 p s u m [ i ] 中。因为小 心地给了每个对等线程一个不同的内存位置来更新, 所以不需要用互斥锁来保护这些更新。唯一需要同步的地方是主线程必须等待所有的子线程完成。在对等线程结束后, 主线程把p s um 向 量的元素加起来, 得到最终的结果。\ncode/condpsum-array.c\nI* Thread routine for psum-array.c *I\n2 void *sum_array(void *vargp)\n3 {\nlong myid = *((long *)vargp); I* Extract the thread ID *I\n5 long start= myid * nelems_per_thread; I* Start element index *I\n6 long end= start+ nelems_per_thread; I* End element index *I\n7 long i;\n8\n9 for (i = start; i \u0026lt; end; i++) {\n10 psum[myid] += i;\n11 }\n12 return NULL;\n13 }\ncod e/ cond ps um-array.c\n图 12- 33 psum- ar r a y 的线程例程。每个对 等线程把它的 部分和\n累积在一个私有数组元 素中 , 不与其他任何 对等线程共享该元素\n在四核系统上运行 p s u m- arr a y 时 ,我 们看到它比 p s um- mu t e x 运行得快好几个数量级:\n版本\npsum-mutex I 68.00\npsum-array 7.26\n432.00\n3.64\n线程数\n4\n719.00\n1.91\n552.00\n1.85\n16\n599.00\n1.84\n在第 5 章中, 我们学 习到了如何使用局部变量来 消除不必要的内存引用。图 12-34 展示了如何应用这项原则,让每个对等线程把它的部分和累积在一个局部变量而不是全局变 量中。当在四核机器上运行 p s u m- l o c a l 时, 得到一组新 的递减的运行时 间:\n线程数 版本 l 2 4 8 16 psum-mutex 68.00 432.00 719.00 552.00 599.00 psum-array 7.26 3.64 1.91 1.85 1.84 ps um- l oc a l 1.06 0.54 0.28 0.29 0.30 code/condpsum-local.c\nI* Thread routine for psum-local. c */\nvoid•sum_local(void•vargp)\n{\nlong myid =•((long•)vargp); /• Extract the thread ID•I\nlong start= myid * nelems_per_thread; I* Start element index•/ long end= start+ nelems_per_thread; /• End element index•/\nlong i, sum= O; 。\nfor (i = start; i \u0026lt; end; i++) { sum+= i;\n}\npsum[myid] = sum; return NULL;\ncode/condpsum-local.c\n图 1 2-34 ps um- l oca l 的 线程例程 。每个对等线 程把它的部分 和累积在一 个局部变昼中\n从这个练习可以学习到一个重要的 经验, 那就是写并行程序相当棘手。对代码看上去很小的改动可能会对性能有极大的影响 。\n刻画并行程序的性能\n图 1 2-35 给 出 了图 12-34 中 程 序psum- l oca l 的运行时间,它 是线程数的函数。在每个 情况下, 程序运行在一个有四个处理器核的系统上,对一个n = 23 1 个元素的 序列求 和。我 们 看 到,随着线程数的增加,运行时间下降,直到增加到四个线程,此时,运行时间趋于平稳 ,甚 至开始有点 增加。\n1.2\n1.0\n0.2\n0 . 3\n4 16\n线程\n在理想的情况中,我们会期望运行时间随着核数的增加线性下降。也就是说,\n图 12-35 ps um- l o ca l 的 性 能(图 1 2-34) 。用四个处理器核对一个 沪 个元素序列求 和\n我们会期望线程数每增加一倍 , 运行时间 就下降一半。确实是这样, 直到到达 t\u0026gt; 4 的时候, 此时四个核中的每一个都忙于运行 至少一个线程。随着线程数量的增加, 运行时间实际上增加了一点儿, 这是由于在一个核上多个线程上下文切换的开销 。由于这个原 因, 并行程序常常被写为每个核上只运行一个线程。\n虽然绝对运行时间是衡 量程序性能的终极标 准, 但是还是有一些有用的相 对衡量标准能够说明并行 程序有多好地利用了潜在的并行性 。并行程序的加速比( s peed u p) 通常定义为\nSp= -T1\nTp # 这里 p 是处理器核的数量, 吓 是在 K 个核上的运行时间。这个公式有时被称为 强扩展(strong scaling)。当 九是程序顺序执行版本的执行时间时, Sp 称为绝对加 速比 ( a bsol ute speed up) 。当 九是程序并 行版本在一个核上的执行时间时, Sp 称为相对加速比 ( re lat ive speed up) 。绝对加速比比相 对加速比能更真实地衡量并行的好处。即使是当并行程序在 一个处理器上运行时,也常常会受到同步开销的影响,而这些开销会人为地增加相对加速比的数值, 因为它们增 加了分子的大小。另一方面, 绝对加速比比相对加速比更难以测量, 因为测量绝对加 速比需要程序的 两种不同的 版本。对于复杂的并行代码, 创建一个独立的顺序版本可能不太实际,或者因为代码太复杂,或者因为源代码不可得。\n一种相关的 测量量称为效 率( eff icie nc y) , 定义为\nEp Sp T1\np p T p # 通常表示为范围 在( 0 , 1 0 0 ] 之间的百分比。效率是对由于并行化造成的开销的衡量。具 有高效率的程序比效率低的程序在有用的工作上花费更多的时间,在同步和通信上花费更少 的时间。\n效率是因为我们的问题非常容易并行化。在实际中,很少会这样。数十年\n图 12-36 图 12-35 中执行时间的加速比和并行效 率\n来, 并行编程一直是一个很活跃的 研究领域。随着商用多核机器的出现, 这些机器的核数每几年就翻一番,并行编程会继续是一个深入、困难而活跃的研究领域。\n. 加速比还有另外一面, 称为 弱扩展 ( w ea k scaling) , 在增加处理器数掀的同时, 增加问题的规模, 这样随着处理器数量的增加, 每个处理器执行的工作量保持不变。在这种描述中,加 速比和效率被表达为单 位时间 完成的工作总量。 例如, 如果 将处理器数量翻倍, 同时 每个小时也做了两倍的工作量, 那么我们就有线性 的加速比和 1 00 % 的效率。\n弱扩展常常是比强扩展更真实的衡量值, 因为它更准确地反映 了我们用更大的机器做更多的工作的愿望。对于科学计算程序来说尤其如此,科学计算问题的规模很容易增加, 更大的问题规模直接就意味着更好地预测。不过, 还是有一些应用的规模不那么容易增加, 对于这样的 应用,强 扩展是更合适的。例如, 实时信号处 理应用所执行的工作量常常是由产生信号的物理传感器的属性决 定的。改变工 作总量需要用不同的物理传感器, 这不太实际或者不太必要。对于这类应用, 我们通常想要用并行来尽可能快地完成定量的工作。\n练习题 12. 11 对于下表中的并行程序,填写空白处。假设使用强扩展。\n线程 (t) 1 2 4 核 (p ) 1 2 4 运行时间 ( TP ) 12 8 6 加速比 ( Sp ) 1.5 效率 ( EP ) 100% 50% 12. 7 其他并发问题\n你可能已经注意到了,一旦我们要求同步对共享数据的访问,那么事情就变得复杂得 多了。迄今为止,我 们已经看到了用千互斥和生产者-消费者同步的技术, 但 这仅仅是冰山一角。同步从根本上说是很难的问题, 它引 出 了 在普通的顺序程序中不会出现的问题。这一小节是关于你在写并发程序时需 要注意的一些问题的(非常不完整的)综述。为了让事情具体化,我们将以线程为例描述讨论。不过要记住,这些典型问题是任何类型的并发流 操作共享资源时都会出现的。\n12. 7. 1 线程安全\n当用线程编写程序时, 必须 小 心 地编写那些具有称为线程安全性 ( t h read s afe t y ) 属性的函数。一个函数被称为线程安 全的 ( t h read- s a fe ) , 当且仅当被多个并发线程反复地调用时,它 会 一 直 产 生正确的结果。如果一个函数不是线程安全的, 我们就说它是线程不安 全的 ( t h rea d- un s a fe ) 。\n我们能够定义出四个(不相交的)线程不安全函数类:\n笫 1 类: 不保护共享 变量的函数。我们在图 12-1 6 的 吐r e a d 函数中就已经遇到了这样的问 题,该 函数 对 一 个 未受保护的全局计数器变量加 1。将这类线程不安全函数变成线程安全的,相对而言比较容易: 利用像 P 和 V 操作这样的同步操作来保护共享的变量。这个方法的优点是在调 用程序中不需要做任何修改。缺点是同步操作将减慢程序的执行时间。\n笫 2 类 :保 持跨越多 个调 用的状态的 函数。一个伪随机数生成器是这类线程不安全函\n数的简单例子。请参考图 1 2-37 中的伪随机数生成器程序包。r a n d 函数是线程不安全的, 因 为 当前调用的结果依赖于前次调用的中间结果。当调用 sr a nd 为r a nd 设置了一个种子后, 我们从一个单线程中反复地调用 r a nd , 能够预期得到一个可重复的随机数字序列。然而, 如果 多 线 程调用r a n d 函数 , 这种假设就不再成立了。\ncode/cond rand.c\nunsigned next_seed = 1;\n3 I* rand - return pseudorandom integer in the range 0.. 32767 *I\n4 unsigned rand(void)\n6 next_seed = next_seed*1103515245 + 12543;\n7 return (unsigned)(next_seed»16) % 32768;\n10 I* srand - set the initial seed for rand() *I\n11 void srand(unsigned new_seed)\n12 {\n13 next_seed = new_seed;\n14 }\ncode/condrand.c\n图 12-37 一个线程不安全的伪随机数生成器(基于 [ 61] )\n使得像r a n d 这样的函数线程安全的唯一方式是重写它,使 得 它 不再使用任何 s 七a t i c\n数据,而是依靠调用者在参数中传递状态信息。这样做的缺点是,程序员现在还要被迫修\n改调用程序中的代码。在一个大的程序中,可能有成百上千个不同的调用位置,做这样的修改将是非常麻烦的,而且容易出错。\n笫 3 类: 返回指向静 态 变 量的 指针的 函数。某些函数, 例如 c t i me 和 g e t h o s 亡\nbyname, 将计算结果放在一个 s ta tic 变量中,然 后返回一个指向这个变量的指针。如果我们从并发线程中调用这些函数,那么将可能发生灾难,因为正在被一个线程使用的结果会被另一个线程悄悄地覆盖了。\n有两种方法来处理这类线程不安全函数。一种选择是重写函数,使得调用者传递存放结 果的变量的地址。这就消除了所有共享数据, 但是它要求程序员能够修改函数的源代码。\n如果线程不安全函数是难以修改或不可能修改的(例如,代码非常复杂或是没有源代 码可用), 那么另外一种选择就是使用加锁-复制( lock-a n d- co p y ) 技术。基本思想是将线程不安全函数与互斥锁联系起来。在每一个涸用位置,对互斥锁加锁,调用线程不安全函 数,将函数返回的结果复制到一个私有的内存位置,然后对互斥锁解锁。为了尽可能地减 少对调用者的修改,你应该定义一个线程安全的包装函数,它执行加锁-复制,然后通过 调用这个包装函数来取代所有对线程不安全函数的调用。例如, 图 12-38 给出了 c 巨 me 的一个线程安全的版本,利用的就是加锁-复制技术。\ncode/condctime-ts.c\nchar *ctime_ts(const time_t *timep, char *privatep)\n2 {\n3 char *sharedp;\n4\n5 P(\u0026amp;mutex);\n6 sharedp = ctime(timep);\n7 strcpy(privatep, sharedp); I* Copy string from sha 工 ed to private *I\n8 V(\u0026amp;mutex);\n9 return privatep;\n10 }\ncode/condctime-ts.c\n图 12-38 C 标准库函数 c t i me 的线程安全的包装函数。使用加锁-复制技术调用一个第3 类线程不安全函数\n笫 4 类: 调用线程 不安 全函数的 函数。如果 函数 f 调用线程不安全函数 g , 那么 f 就是线程不安全的吗? 不一定。如果 g 是第 2 类函数, 即依赖于跨越多次 调用的 状态, 那么\nJ 也是线程不安全的, 而且除了重写 g 以外, 没有什么办法。然而, 如果 g 是第 1 类或者\n第 3 类函数, 那么只要你用一 个互斥锁保护调用位置和任何 得到的共享数据, J 仍 然可能是线程安全的。在图 1 2-38 中我们看到了一个这种情况很好的示例, 其中我们使用加锁- 复制编写了一 个线程安全函数,它 调用了一个线程不安全的函数。\n所有的函数\n7. 2 可重入性\n有一类重要的线程安全函数,叫做可重入函数( ree n t ra nt function) , 其特点在于它们具有这样一种属性:当它们被多个线程调用时,不会引用任何共享数据。尽管线程安全和可重入有时会\n线程安全函数\n三 线程不安全函数\n(不正确地)被用做同义词,但是它们之间还是有 图 12-39 可重入函数、线程安全函数和线程\n清晰的技术 差别, 值得留意。图 12-39 展示了可 不安全函数之间的集合关系\n重入函数、线程安全函数和线程不安全函数之间的集合关系。所有函数的集合被划分成不 相交的线程安全和线程不安全函数集合。可重入函数集合是线程安全函数的一个真子集。 可重入函数通常要比不可重入的线程安全的函数高效一些,因为它们不需要同步操\n作。更进一步来说,将 第 2 类线程不安全函数转化为线程安全函数的唯一方法就是重写它,使 之 变为可重入的。例如,图 1 2- 40 展 示了图 1 2-37 中 r a nd 函数的一个可重入的版本。关键思想是我们用一个调用者传递进来的指针取代了静态的 ne x t 变量。\ncode/condrand-r.c I* rand_r - return a pseudorandom integer on 0 .. 32767 *I\nint rand_r(unsigned int *nextp)\n{\n*nextp = *nextp * 1103515245 + 12345;\nreturn (unsigned int)(*nextp / 65536) % 32768;\n}\ncodelcondrand-r.c\n图 12-40 rand_r: 图 12-37 中的 r a nd 函数的可重入版本\n检查某个函数的代码并先验地断定它是可重入的,这可能吗?不幸的是,不一定能这 样。如果所有的函数参数都是传值传递的(即没有指针),并且所有的数据引用都是本地的 自动栈变量(即没有引用静态或全局变量),那么函数就是显式 可 重入的 ( ex plicitl y reen­\ntrant), 也就是说,无论它是被如何调用的,都可以断言它是可重入的。\n然而, 如果把假设放宽松一点 ,允 许显式可重入函数中一些参数是引用传递的(即允许它们传递指针),那 么 我们就得到了一个隐式可重入的 ( im plicitl y ree nt ra n t ) 函数,也 就是说,如果调用线程小心地传递指向非共享数据的指针,那么它是可重入的。例如,图 1 2-40 中的r a nd_r 函数就是隐式可重入的。\n我们总是使用术语 可重入的 ( ree nt ra nt ) 既包括显式可重入函数也包括隐式可重入函数。然而,认 识 到 可重入性有时既是调用者也是被调用者的属性,并 不 只 是 被询用者单独的属性是非常重要的。\n练习题 12 . 12 图 12-38 中的 ct ime_t s 函数是线程安全的,但不是可重入的。请解释说明。\n1 2 . 7. 3 在线程化的程序中使用已存在的库函数\n大多数 Lin u x 函数,包 括定义在标准 C 库中的函数(例如 ma l l o c 、 fr e e 、r e a l l o c 、\npr i n t f 和 s c a n f ) 都 是 线 程 安 全的,只 有一小部分是例外。图 1 2-41 列出了常见的例外。\n(参考[ 110] 可以得到一个完整的列表。)s tr t o k 函数是一个已弃用的(不推荐使用)函数。 a s c t i me 、 c t i me 和 l oc a l t i me 函数 是 在 不同时间和数据格式间相互来回转换时经常使用的函数。ge t ho s t b yna me 、 g e t h o s t b ya d dr 和 i ne t _ n t oa 函数是已弃用的网络编程函数,已 经分别被可重入的 ge t a ddr i n f o 、ge t na me i n f o 和 i ne t _ nt o p 函数取代(见第 11 章)。除了 r a nd 和 s tr t o k 以外 ,所 有这些线程不安全函数都是第 3 类的,它 们 返 回 一 个 指向静态变量的指针。如果我们需要在一个线程化的程序中调用这些函数中的某一个,对 调用者来说最不惹麻烦的方法是加锁-复制。然而,加锁-复制方法有许多缺点。首先,额 外的同步降低了程序的速度。第二,像 g e t h o s t b yn a me 这样的函数返回指向复杂结构的结构的指针,要 复 制 整个结构层次,需 要 深层复制 ( dee p copy) 结构。第三,加 锁-复 制方法对像r a nd 这样依赖跨越调用的静态状态的第 2 类函数并不有效。\n线程不安全函数 线程不安全类 Linux 线程安全版本\nrand strtok asctime ctime\ngethostbyaddr gethostbyname inet_ntoa localtime\nrand_r strtok_r asctime_r ctime_r\ngethostbyaddr_r gethostbyname_r\n(无)\nlocaltime_r\n图12-4 1 常见的线程不安全的库函数\n因此, L in u x 系统提供大多数线程不安全函数的可重入版本。可重入版本的名字总是以\u0026quot; r \u0026quot; 后缀结尾。例如, a s c 巨 me 的可重 入版本就叫做 a s c 巨 me _r 。我 们建议尽可能地使用这些函数。\n12. 7. 4 竞争 # 当一个程序的正确性依赖于一个线程要在另一个线程到y达点之前到达它的控制流中的x 点时, 就会发生竞争( ra ce) 。通常发生竞争是因为程序员假定线程将按照某种特殊的轨迹线穿过执行状态空间, 而忘记了另一条准则规定:多 线程的程序必须对任何可行的轨迹线都正确工。作\n例子是理解竞争本质的最简单的方法。让我们来 看看图 12-42 中的简单程序。主线程创建了四个对等线程, 并传递一个指向一个唯一的整数 ID 的指针到每个 线程。每个 对等线程复制它的参数中传递的 ID 到一个局部变量中(第22 行), 然后输出一个包含这个 ID 的信息。它看上去足够简单,但是当我们在系统上运行这个程序时,我们得到以下不正确的结果:\nlinux\u0026gt; ./race\nHello from thread 1 Hello from thread 3 Hello from thread 2 Hello from thread 3\ncode/condrace.c\nI* WARNING: This code is buggy! */\n#include \u0026ldquo;csapp.h\u0026rdquo;\n#define N 4\nvoid *thread(void *vargp); int main()\n{\npthread_t tid[N]; int i;\nfor (i = O; i \u0026lt; N; i++) Pthread_create(\u0026amp;tid[i],\nfor (i = O; i \u0026lt; N; i++)\nNULL, thread, \u0026amp;i);\nPthread_join(tid[i], exit(O);\nNULL);\n图 1 2-42 一个具有竞争的程序\n18\nI* Thread routine *I\nvoid *thread(void *vargp)\n21 {\nint myid = *((int *)vargp);\nprintf(\u0026ldquo;Hello from thread %d\\n\u0026rdquo;, myid);\nreturn NULL;\n25 }\ncode/condrace.c\n图 12- 42 (续)\n问 题 是由每个对等线程和主线程之间的竞争引起的。你能发现这个竞争吗? 下面是发生的 情况 。当主线程在第 1 3 行创建了一个对等线程,它 传递了一个指向本地栈变量 t 的指针。在此时,竞 争 出现在下一次在第 1 2 行对 1 加 1 和第 22 行参数的间接引用和赋值之间。如果对等线程在主线程执 行第 1 2 行对 t 加 1 之前就执行了第 22 行, 那么 my i d 变量就得到正确的 ID。否则,它 包含的就会是其他线程的 ID。令人惊慌的是, 我们是否得到正确的答案依赖千内核是如何调度线程 的执行的。在我们的 系统中它失败了 , 但是在其他系统中,它可 能就能正确工作 , 让程序员“幸福地” 察觉 不到程序的严重错误。\n为了消除竞争,我 们可以动态地为每个整数 ID 分配一个独立的块, 并且传递给线程例程一个指向这个块的指针, 如图 1 2- 43 所示(第1 2 1 4 行)。请注意 线程例程必须释放这些块以避免内存泄漏。\ncode/condnorace.c\n#include \u0026ldquo;csapp.h\u0026rdquo;\n#define N 4\nvoid *thread(void *vargp); int main()\n{\npthread_t tid[N]; inti, *ptr;\nfor (i = O; i \u0026lt; N; i++) {\nptr = Malloc(sizeof(int));\n*ptr = i;\nPthread_create(\u0026amp;tid[i],\n}\nNULL, thread, ptr);\nfor (i = O; i \u0026lt; N; i++) Pthread_join(tid[i],\nexit(O);\nNULL);\nI* Thread routine *I\nvoid *thread(void *vargp)\n{\nint myid = * ((int *)vargp) ; Free (vargp) ;\nprintf(\u0026ldquo;Hello from thread %d\\n\u0026rdquo;, myid); return NULL;\n}\ncode/condnorace.c\n图 12 -43 图 12-42 中程序的一个没有竞争的正确版本\n当我们在系统上运行这个程序时,现在得到了正确的结果:\nlinux\u0026gt; ./norace Hello from thread 0 Hello from thread 1 Hello from thread 2 Hello from thread 3\n练习题 12. 13 在图 12-43 中, 我们可能想 要在 主线程中的 第 1 4 行后立即 释放 巳分 配的内存块,而不是在对等线程中释放它。但是这会是个坏注意。为什么?\n练习题 12. 14\n在图 12-43 中, 我们 通过 为每 个整数 ID 分配 一个 独 立的 块来消除竟争。 给出 一个不调用 ma l l o c 或者 f r e e 函数的不 同的 方 法。 这种方法的利弊是什么? 12. 7. 5 死锁\n信号量引 入了一种 潜在的令人厌恶的运行时错误, 叫做死锁 ( d eadlock ) , 它指的是一组线程被阻塞了,等待一个永远也不会为真的条件。进度图对于理解死锁是一个无价的工 具。例如,图 1 2-44 展示了一对用两个信号晕来实现互斥的线程的进程图。从 这幅图中, 我们能够得到一些关于死锁的重要知识:\n线程2\nV(s)\nV(t)\nP(s)\n死锁区\n勹\u0026quot; # P(s)···P(t)··· V(s) · · ·V(t)\n图12-44 一个会死锁的程序的进度图\n线程1\n程序员使用 P 和 V 操作顺序不当, 以至于两个 信号量的禁止区域 重叠。如果某个执行轨迹线碰巧到达了死锁状态 d , 那么就不可能有进一步的进展了,因为重叠的禁止区域阻塞了每个合法方向上的进展。换句话说,程序死锁是因为每个线程都在等 待其他线程执行一个根不可能发 生的 V 操作。\n重叠的禁止区域引起了一组称为死锁区域 ( d ead lo ck r eg io n ) 的状态。如果一个轨迹线碰巧到达了一个死锁区域中的状态,那么死锁就是不可避免的了。轨迹线可以进 入死锁区域,但是它们不可能离开。\n死锁是一个相当困难的问题,因为它不总是可预测的。一些幸运的执行轨迹线将绕开死 锁区域,而其他的 将会陷入 这个区域。图 12-44展示了每种情况的一个示例。对于程序员来说, 这其中隐含的着实令人惊慌。你可以运行一个程序 1000 次不出任何问题,但是下一次它就死锁了。或者程序在一台机器上可能运行得很好,但 是 在另外 的 机 器 上就会死锁。最糟糕的是,错误常常是不可重复的,因为不同的执行有不同的轨迹线。\n程序死锁有很多原因,要避免死锁一般而言是很困难的。然而,当使用二元信号量来 实现互斥时 ,如 图 1 2- 44 所示,你 可以应用下面的简单而有效的规则来避免死锁:\n互斥锁加锁顺序规则: 给 定所有互斥操作的一个全序 ,如 果 每 个线程都是以一种顺序荻得互斥锁并以相反的顺序释放,那么这个程序就是无死锁的。\n例如, 我们可以通过这样的方法来解决图 12-44 中的死锁问题: 在 每个线程中先对 s\n加锁 ,然 后 再 对 t 加锁。图 12-45 展示了得到的进度图。\n线程2\nV(s)\nV(t)\nP(t)\n勹, , # · · · P (s) · ··P(t)··· V(s) · · ·V(t)\n图 12 -45 一个无死锁程 序的进度图\n线程1\n心 练习题 12 . 15 思考下面的程序,它试图使用一对信号量来实现互斥。\n初始时: 线程1: P(s); s = 1, t = 0 . 线程2: P(s); V(s); V(s); P(t); V(t); P(t); V(t); 画出这个程序的进度图。 它总是会死锁吗? 如果是,那么对初始信号量的值做哪些简单的改变就能消除这种潜在的死锁呢? 画 出 得到 的无死锁程 序的进度图。 12. 8 小结\n一个并发程序是由在时间上重叠的一组逻辑流组成的。在这 一章中, 我们学习了三种不同的构建并发程序的机制 : 进程、1/ 0 多路复用和线程。我们以一个并发网络服务器作为贯穿全章的应用程序。\n进程是由内核自动调度的,而且因为它们有各自独立的虚拟地址空间,所以要实现共享数据,必须 要有显式的 IPC 机制。事件驱动程 序创建它们自己的并发逻辑流, 这些逻辑流被模型化为状态机,用1/ 0 多路复用来显式 地调度这些 流。因 为程 序运行 在一个单一进程中 , 所以在流之间 共享数据速度很快 而且很容易。线程是这些方法的混合 。同基千进程的 流一样, 线程也是由内 核自动调度的。同基千 I/ 0 多路复用的流一样 , 线程是运行 在一个单一进程的上下 文中的 ,因 此可以快速而方便地共 享数据 。\n无论哪种并发机制 ,同 步对共享数据的并 发访问 都是一个困难的问 题。提出对信号 量的 P 和 V 操作就是为了帮助解决这个问题。信号量操作可以用来提供对共享数据的互斥访问,也对诸如生产者-消费者 程序中有限缓 冲区和读者-写者系统中的共享对象这样的资源访 间进行调度。一个并发预线程化的 echo 服务器提供了信号 量使用场景的很好的例子。\n并发也引入了其他一些困难的问题。被线程调用的 函数必须具有一种称为线 程安 全的属性。我们定义了四类线程不安全的函数,以及一些将它们变为线程安全的建议。可重入函数是线程安全函数的一个 真子集,它不访问任何共享数据。可重入函数通常比不可重入函数更为有效,因为它们不需要任何同步 原语。竞争和死锁是并发 程序中出 现的另一些 困难的间题。当程序员错误地假设逻辑流该 如何 调度时 , 就会发生竞争。当一个流等待一个永远不会发生的事件时,就会产生死锁。\n参考文献说明 # 信号蜇操作是 D咄s t ra 提出的 [ 31] 。进度图的概念是 Coff ma n [ 23] 提出的, 后来由 Ca rso n 和 R e yn­ olds [ 16] 形式化的 。Co ur tois 等人[ 25] 提出了读者-写者问题。操作系统教科 书更详 细地 描述了经典的同步问题, 例如哲学家进餐问 题、打睦睡的理发师问 题和吸烟者问 题 [ 102 , 106, 113] 。Buten hof 的书[ 15] 对 P o six 线程接口有全 面的描述。Birrell [ 7] 的论文对线程编程以 及线程编程中容易遇到的问 题做了很好的介绍。 R einde rs 的书[ 90] 描述了 C I C + + 库,简化了线 程化程序的设 计和实现。有一些 课本讲述了多核系统上并行编程的 基础知识 [ 47 , 71] 。P ug h 描述了 Ja va 线程通过内存进行交互的方式的缺陷,并提出了替代的内存模型 [ 88 ] 。G us tafso n 提出了替代强扩展的 弱扩展加速模型 [ 43] 。\n家庭作业 # 12 16 编写 he ll o . c ( 图 12-13 ) 的一个版本, 它创建和回收 n 个可结 合的对等线程,其 中 n 是一个命令行参数。\n12 17 A. 图 12-46 中的程序有一个 b ug 。要求线程睡眠一秒钟 , 然后输出一 个字符串。然而 , 当在 我们\n的系统上运行它时 ,却 没有任何输 出。为什么?\n/• WA 邸 I NG: This code is bu 邸 ;y! •/\n#include \u0026ldquo;csapp.h\u0026rdquo;\nvoid•thread(void•vargp);\n4\n5 int main()\n6 {\n7 pthread_t tid;\n8\n9 Pthread_create(\u0026amp;tid, NULL, thread, NULL);\n10 exit(O);\n11 }\n12\n/• Thread routine•/\nvoid•thread(void•vargp)\n15 {\nSleep(1);\nprintf(\u0026ldquo;Hello, world!\\n\u0026rdquo;);\nreturn NULL;\n19 }\ncode/condhellobug.c\ncodelconclhellobug.c\n图 12-46 练习题 12. 17 的有 bug 的程序\nB. 你可以通过用两个 不同的 P th reads 函数调 用中的一个替代第 10 行中的 e x 让 函数来 改正这个错误。选哪一个呢?\n12. 18 用图 1 2-21 中的进度图, 将下面的 轨迹线分类 为安全或者不安全的。\nA. H2 , L2 , U2 , H, , L1 , S2 , U 1 , S, , T1 , T2\nB. H 2 , H1, L1 , U, , S1 , L 2, T, , U2, S 2 , T2\nC. H1 , L1 , H 2 , L2, U2, S2, U, , S , , T1 , T2\n•• 12 . 19 图 1 2- 2 6 中第一类读 者-写者问题的解答 给予 读者的是有些弱的优先级, 因为读者在离开它的临界区时,可能会重启一个正在等待的写者,而不是一个正在等待的读者。推导出一个解答,它给 予读者更强的优先级,当写者离开它的临界区的时候,如果有读者正在等待的话,就总是重启一 个正在等待的读者。\n\\* 12. 20 考虑读者-写者问题的一个更简单的变种 ,即 最多只有 N 个读者。推导 出一个解答 ,给 予读者和\n写者同等的优先级,即等待中的读者和写者被赋予对资源访问的同等的机会。提示:你可以用一个计数信号蜇和一个互斥锁来解决这个问 题。\n:: 12. 21 推导出第二类读者-写者问题的一个解答,在此写者的优先级高于读者。\n•• 12. 22 检查一下你对 s e l e吐 函数的理 解,请 修改图 12-6 中的服务器, 使得它在主服务 器的每次迭代中最多只回送一个文本行。\n•• 12. 23 图 1 2-8 中的事件驱动并 发 echo 服务器是 有缺陷的 , 因为一个恶意的 客户端能够通过发送部分的文本行,使服务器拒绝为其他客户端服务。编写一个改进的服务器版本,使之能够非阻塞地处理 这些部分文本行。\n12. 24 RIO I/ 0 包中的 函数(1 0. 5 节)都是线程安全的 。它们也都是可重入函数吗?\n12. 25 在图 1 2-28 中的预线程化的并发 echo 服务器中, 每个线程都调用 e c ho _ c nt 函数(图12-29 ) 。e c ho_c n t 是线程安全的吗? 它是可重人的吗?为什么是或 为什么不是呢?\n*/ 12. 26 用加锁-复制技术来实现 g e t ho s t b yna me 的 一个线程安全而又不 可重入 的版本, 称为 ge t hos t -\nb yna me _ t s 。一个正确的解答是使用由互斥 锁保护的 ho s t e nt 结构的深层副本 。\n• • 12. 27 一些网络编程的教科书建议用以下的方法来读 和写套接字 : 和客户端交互之前 , 在同一个打开的已连接套 接字描述符上 ,打开两个标 准 I/ 0 流, 一个用来读, 一个用来写 :\nFILE•fpin, •fpout;\nfpin = fdopen(sockfd, \u0026ldquo;r\u0026rdquo;);\nfpout = fdopen(sockfd, \u0026ldquo;w\u0026rdquo;);\n当服务器完成和客户端的交互之后,像下面这样关闭两个流:\nfclose(fpin); fclose(fpout);\n然而,如果你试图在基千线程的并发服务器上尝试这种方式,将制造一个致命的竞争条件。请解释。\n12 28 在图 12-45 中,将 两个 V 操作的顺序交换 , 对程序死锁是否 有影响? 通过画出四 种可能情况的 进度图来证明你的答案: 情况 l 情况 2 情况 3 情况 4 线程 l 线程2 线程 1 线程2 线程 l 线程2 线程 l 线程2 P (s ) P(t) P(s) P(t) P(s) P(t) P(s) P(t) P(s) P(t ) P(s) P(t ) P(s) P(t) P(s) P(t) V(s) V(s) V(s) V(t) V(t) V(s) V(t) V(t) V(t) V(t) V(t) V(s) V(s) V(t) V(s) V(s) 12. 29 下面的程序会死锁吗?为什么会或者为什么不会? 初始时: a= 1, b = 1, c = 1\n线程1 : 线程2 :\nP(a); P(c);\nP(b); P(b);\nV(b); V(b);\nP(c); V(c);\nV(c);\nV(a);\n12. 30 考虑下面这个会死锁的程序。\n初始时: a= 1, b = 1, c = 1\n线程1 : 线程2: 线程3:\nP(a); P(c); P(c);\nP(b); P(b); V(c);\nV(b); V(b); P(b);\nP(c); V(c); P(a);\nV(c); P(a); V(a);\nV(a); V(a); V(b);\n列出每个线程同时占用的一对互斥锁。\n如果 a \u0026lt; b\u0026lt; c , 那么哪个线程违背了互斥锁加锁顺序规则?\n对于这些线程,指出一个新的保证不会发生死锁的加锁顺序。\n*.* 12. 31 实现标准 I/ 0 函数 f ge t s 的一个版本,叫 做 t f ge t s , 假如它在 5 秒之内 没有从标 准输入 上接收到一个输入行,那么就超时,并返回 一个 NU LL 指针。你的函数应该实现在一个叫做 t f ge t s - pr oc . c 的包中,使用 进程、信号和非本地跳转 。它不应该使用 Linux 的 a l a rm 函数。使用图 12-47 中的驱动程序测试你的结果。\n1 #include \u0026ldquo;csapp.h\u0026rdquo;\n2\n3 char *tfgets (char *s, int size, FILE *stream);\n4\n5 int main()\n6 {\n7 char buf [MAXLINE] ;\n8\n9 if (tfgets(buf, MAXLINE, stdin) == NULL)\n10 printf(\u0026ldquo;BOOM!\\n\u0026rdquo;);\n11 else\n12 printf(\u0026ldquo;o/.s\u0026rdquo;, buf);\n13\n14 exit(O);\n15 }\ncodelcond tfgets-main.c\ncode/contfdgets-main.c\n图 12-47 家庭作业题 12. 31~12. 33 的驱动程序\n*.* 12. 32 使用 s e l e c t 函数来实现练习题 12. 31 中 t f ge t s 函数的一个版本。你的 函数应该在一个叫做 t f ­ ge t s -s e l e c t . c 的包中实现。用练习题 12. 31 中的驱 动程序测试你的结果 。你可以假定标准输人被赋值为描述符 0。\n·.· 12. 33 实现练习 题 12. 31 中 t f ge t s 函数的一个线程化的版本。你的函数应该在一个叫做 t f ge t s ­ t hr e a d . c 的包中实现。用练习题 12. 31 中的驱动程序测试你的结果。\n·: 12. 34 编写一个 N X M 矩阵乘法核心函数的并行线程化版本。比较它的性能 与顺序的版本的性能。\n·: 12. 35 实现一个基于进程的 T I NY Web 服务器的并发版本 。你的解答应该为每一个新的 连接请求创建一个新的子进程。使用一 个实际的 Web 浏览器来测试你的 解答。\n: 12. 36 实现一个基于 l/ 0 多路复用的 T IN Y Web 服务器的并 发版本。使用一个实际的 Web 浏览器来测试你的解答。 ** 12. 37 实现一个基于线程的 T INY Web 服务器的并发版本 。你的解答 应该为 每一个新的连接请求创建 一个新的线 程。使用一 个实际的 Web 浏览器来 测试你的 解答 。 : 12. 38 实现一个 T INY Web 服务器的并发预线 程化的 版本。你的解答应该 根据当前的 负载, 动态地增加或减少线 程的数目 。一个策略是当缓 冲区变满时 , 将线程数晟 翻倍 , 而当缓 冲区 变为空 时, 将线程数目 减半。使用一 个实际的 Web 浏览器来 测试你的 解答 。\n:: 12. 39 Web 代理是 一个在 Web 服务器和浏览 器之间 扮演中间角色 的程序。浏 览器不是直接连接服务器以获取 网页, 而是与代理连接 , 代理再将请求转发给服务器。当服 务器响 应代理 时, 代理将响应发送给浏览器。为了这个试验 , 请你编写一个简单的可以过滤和记 录请求 的 Web 代理:\n试验的第一部分中 , 你要建立以接收请求的代理, 分析 HT T P , 转发请求给服务 器, 并且返回结果给浏览 器。你的代理将所有请求 的 URL 记录在磁盘上 一个日志文件中 ,同时它还要阻塞所有对包含 在磁盘上一 个过滤文件 中的 URL 的请求。 试验的第二部分中, 你要升级代理 , 它通过派生一个独立的线程来处理每一个请求, 使得 代理能够一次处理多个打开的连接。当你的代理在等待远程服务器响应一个请求使它能服务于 一个浏览器时,它应该可以处理来自另一个浏览器未完成的请求。\n使用一个 实际的 Web 浏览骈来 检验你的 解答。\n练习题答案\n12. 1 当父进 程派生子 进程时 ,它 得到一个已连接描 述符的副本, 并将相关文件 表中的引用计数从 1 增加到 2。当父进程关闭 它的描述符 副本时 ,引 用 计数就从 2 减少到 1。因为内 核不会关闭一个文件, 直到文件表中它的引用计数值 变为零 , 所以子进程这边的 连接端将保持 打开。\n12. 2 当一个 进程因为某种原因终止时 ,内 核 将关闭所有打开的 描述符。因此, 当子进 程退出时, 它的已连接文件描述符的副本也将被自动关闭。\n12. 3 回想一下, 如果一个从描述符中读 一个字节的请求不 会阻塞, 那么这个描述符 就准备好 可以读 了。\n假如 EOF 在一个描述符上为 真, 那么描述符也 准备好可读了 , 因为读操作将立 即返回一个零返回码,表示 EOF。因此,键入 Ctrl+ D 会导致 s e l e c t 函数返回, 准备好的 集合中有描述符 0。\n12. 4 因为变最 poo l . r e ad_ s e t 既作为输入参数 也作为输出 参数, 所以 我们在每一次调用 s e l e c t 之前都重新初始化它。在输入时 , 它包含读集合 。在输出 , 它包含准备好的 集合。\n12. 5 因为线程运行在同一个进程中 , 它们都共享相同 的描述符表。无论有多少线程使用这个已 连接描述符, 这个已 连接描述符的 文件表的引 用计数都等于 1。因此, 当我们用完它时, 一个 c l os e 操作就足以 释放与这个已 连接描述符 相关的内存资源了。\n6 这里的 主要的思 想是, 栈变址是私有的 , 而全局和静态变扯是共享的。诸如 c nt 这样的 静态变量有点小麻烦, 因为共享是限制 在它们的函数范围内的一—召:这个例子中, 就是线程例程 。 下面就是 这张表 : 变量实例 被主线程引用? 被对等线程0引用? 被对等线程1引用? ptr 定曰 定曰 定曰 cnt 否 定曰 定曰 i . m 定曰 否 否 ms g s . m 是 是 定El myid.pO 否 是 否 myi d . p l 否 否 是 说明:\np tr : 一个被主线程写 和被对等线程读的 全局变谜。 c nt : 一个静态变储, 在内存中只有一个实例 ,被 两个对等线程读和写。 i.m: 一个存储在主线程栈中的本 地自动变 釐。虽然它的 值被传递给对等线程,但 是对等线程也绝 不会在栈中引用它 , 因此它不是 共享的。 msgs . m: 一个存储在 主线程栈中的 本地自动变量 , 被两个对等线 程通过 p tr 间接地引用 。 myid. 0 和 my 过 . 1 : 一个本地自 动变量的实 例, 分别驻留在对等线 程 0 和线程 1 的栈中。 变量 ptr 、c nt 和 ms g s 被多于一个线程引用, 因此它们是 共享的。\n12. 7 这里的重要思想是,你不能假设当内核调度你的线程时会如何选择顺序。\n变量 c nt 最终有一 个不正确的 值 1。\n12. 8 这道题简单地测试你 对进度图中安全和不安 全轨迹线的理解。像 A 和 C 这样的轨迹线绕开了临界区,是安全的,会产生正确的结果。\nA. H1, L1, U1 , S1, H , , L,, U , , S,, T,, Ti : 安 全 的\nB. H, , L, , H1 , L1 , U1 , S1 , Ti , U, , S, , T, : 不 安 全 的\nC. H, , H,, L, , U,, S,, Li , Ui, S 1 , T1 , T,: 安全的\n12. 9 A.p=l, c=l, n\u0026gt; l : 是,互斥锁是需要的,因为生产者和消费者会并发地访间缓冲区。\np=l, c = l , n=l, 不是, 在这种情况中 不需要互斥锁 信号量 , 因为一个非空的缓 冲区就等千满的缓冲区。当缓冲区包含一个项目时,生产者就被阻塞了。当缓冲区为空时,消费者就被阻 塞了。所以 在任意时刻 ,只 有一个线 程可以访间缓 冲区 , 因此不用互斥锁也能保证互斥 。\np\u0026gt;l, c\u0026gt;l, n=l: 不是, 在这种情况中 , 也不需要互斥锁 ,原因 与前面一种情况相同。\n12 10 假设一个特殊的 信号量实现为每一 个信号 量使用了 一个 LI FO 的线 程栈。当一个线程在 P 操作中阻塞在一个信号蜇 上,它的 ID 就被压入栈中 。类似地, V 操作从栈中弹出栈顶的线程 ID, 并重启这个线程。根据这个栈的实现,一个在它的临界区中的竞争的写者会简单地等待,直到在它释 放这个信号量之前另一个写 者阻塞 在这个信号 量上。在这种场景中, 当两个写者来回地传递控制权时, 正在等待的读者可能会永远地等待下去。\n注意, 虽然用 FIF O 队列而不是用 LIFO 更符合直觉 , 但是使用 LIFO 的栈也 是对的, 而且也没有违反 P 和 V 操作的语义。\n12. 11 这道题简单地检查你对加速比和并行效率的理解:\n线程( t ) I 2 4 核 (p ) I 2 4 运行时间( T,,) 12 8 6 加速比CSP ) I 1.5 2 效率\u0026lt;EP ) 100% 75% 50% 12 . 12 ct i me—t s 函数不是可重入函数, 因为每次调用都共享相同的由 get hos tb yname 函数返回的 s t at i c 变量。然而, 它是线程安全的,因 为对共享变扯的访问是被 P 和 V 操作保护的, 因此是互斥的。\n12. 13 如果在第 14 行调用了 p 七hr e a d _cr e a t e 之后, 我们立即释放块, 那么将引入一个新的竞争, 这次竞争发 生在主线程对 fr e e 的调用 和线程例程中第 24 行的赋值语句之间。\n12. 14 A. 另一种方法是直接传递整数 i , 而不是传递一 个指向 1 的指针:\nfor Ci = 0; i \u0026lt; N; i++)\nPthread_create(\u0026amp;tid[i], NULL, thread, (void•)i);\n在线程例程中 ,我们将参数强 制转换成一个 i nt 类型, 并将它赋值给 rnyi d : int myid = (int) vargp;\nB. 优点是它通过消除对 ma l l o c 和 fr e e 的 调用降低了开销。一个明显的缺点是, 它假设指针至少和 i n t 一样大。即便 这种假设 对于所有的 现代系统来说都为真, 但是它对千那 些过去遗留下来的或今后的系统来说可能就不为真了。\n12. 15 A. 原始的程序的 进度图如图 12-48 所示。\n线程2\nV(t)\nP(t)\nV(s)\n勹(,)\nt 的 禁止区\n三 t 的禁止区\n· · · P(s) .. ·V(s) ..·P(t) ..·V(t)\n图 12- 48 一个有死锁的程序的进度图\n线程1\n因为任何可行的轨迹最终都陷入死锁状态中,所以这个程序总是会死锁。 为了消除 潜在的死锁, 将二元信号蜇 t 初始化为 1 而不是 0。 改成后的程序的进度图 如图 12-49 所示。线程2 V(t) 三\nP(t)\nV(s) 三\n勹\u0026quot;'\n· · ·P(s)··· V(s) · · · P(t) · · ·V(t)\n图 12- 49 改正后的无死锁的程序的进度图\n线程1\n"},{"id":437,"href":"/zh/docs/technology/System/%E6%B7%B1%E5%85%A5%E7%90%86%E8%A7%A3%E8%AE%A1%E7%AE%97%E6%9C%BA%E7%B3%BB%E7%BB%9F/%E4%B9%A6%E7%B1%8D/%E7%AC%AC1%E7%AB%A0-%E8%AE%A1%E7%AE%97%E6%9C%BA%E7%B3%BB%E7%BB%9F%E6%BC%AB%E6%B8%B8/","title":"Index","section":"SpringCloud","content":"第1章\nCHAPTER1\n计算机系统漫游\n计算机系统是由硬件和系统软件组成的,它们共同工作来运行应用程序。虽然系统的具体实现方式随着时间不断变化,但是系统内在的概念却没有改变。所有计算机系统都有相似的硬件和软件组件,它们又执行着相似的功能。一些程序员希望深入了解这些组件是如何工作的以及这些组件是如何影响程序的正确性和性能的,以此来提高自身的技能。本书便是为这些读者而写的。\n现在就要开始一次有趣的漫游历程了。如果你全力投身学习本书中的概念,完全理解底层计算机系统以及它对应用程序的影响,那么你会步上成为为数不多的“大牛"的道路。\n你将会学习一些实践技巧,比如如何避免由计算机表示数字的方式引起的奇怪的数字错误。你将学会怎样通过一些小窍门来优化自己的C代码,以充分利用现代处理器和存储器系统的设计。你将了解编译器是如何实现过程调用的,以及如何利用这些知识来避免缓冲区溢出错误带来的安全漏洞,这些弱点给网络和因特网软件带来了巨大的麻烦。你将学会如何识别和避免链接时那些令人讨厌的错误,它们困扰着普通的程序员。你将学会如何编写自己的Unixshell、自己的动态存储分配包,甚至于自己的Web服务器。你会认识并发带来的希望和陷阱,这个主题随着单个芯片上集成了多个处理器核变得越来越重要。\n在Kernighan和Ritchie的关于C编程语言的经典教材[61]中,他们通过图1-1中所示的hello程序来向读者介绍C。尽管hello程序非常简单,但是为了让它实现运行,系统的每个主要组成部分都需要协调工作。从某种意义上来说,本书的目的就是要帮助你了解当你在系统上执行hello程序时,系统发生了什么以及为什么会这样。\ncodelintrolhello.c\n#include \u0026lt;stdio.h\u0026gt;\nint main()\n{\nprintf(\u0026ldquo;hello, world\\n\u0026rdquo;); return O;\n}\ncode/intro/he/lo .c\n图 1-1 he ll o 程序(来源: [60])\n我们通过跟踪hello程序的生命周期来开始对系统的学习——从它被程序员创建开始,到在系统上运行,输出简单的消息,然后终止。我们将沿着这个程序的生命周期,简要地介绍一些逐步出现的关键概念、专业术语和组成部分。后面的章节将围绕这些内容展开。\n1.1 # 信息就是位+上下文 # hello程序的生命周期是从一个源程序(或者说源文件)开始的,即程序员通过编辑器创\n建并保存的文本文件,文件名是hello.c。源程序实际上就是一个由值0和1组成的位(又称为比特)序列,8个位被组织成一组,称为字节。每个字节表示程序中的某些文本字符。\n大部分的现代计算机系统都使用ASCII标准来表示文本字符,这种方式实际上就是用一个唯一的单字节大小的整数值e来表示每个字符。比如,图1-2中给出了hello.c程序的ASCII码表示。\n# i n C 1 u d e SP < s t d i 。 35 1 05 11 0 9 9 108 11 7 1 00 1 0 1 32 60 1 15 116 1 00 105 111 46 h > \\ n \\n i n t SP m a i n ( ) \\n { 10 4 62 10 1 0 105 11 0 116 32 109 97 105 110 40 41 10 123 \\ n SP SP SP SP p r i n t f ( II h e 1 1 0 32 32 3 2 3 2 112 114 105 110 116 10 2 40 34 104 101 108 1 。 SP `, 。 r 1 d \ n II ) \\n SP\n108 111 44 32 119 111 114 108 32\n\\n\n图 1- 2 he ll o . c 的 ASCII 文本表示\nhello.c程序是以字节序列的方式储存在文件中的。每个字节都有一个整数值,对应千某些字符。例如,第一个字节的整数值是35,它对应的就是字符"#"。第二个字节的整数值为105,它对应的字符是\u0026rsquo;i\u0026rsquo;\u0026lsquo;依此类推。注意,每个文本行都是以一个看不见的换行符\u0026rsquo;\\n\u0026rsquo;来结束的,它所对应的整数值为10。像hello.c这样只由ASCII字符构成的文件称为文本文件,所有其他文件都称为二进制文件。\nhello.c的表示方法说明了一个基本思想:系统中所有的信息-—-包括磁盘文件、内存中的程序、内存中存放的用户数据以及网络上传送的数据,都是由一串比特表示的。区分不同数据对象的唯一方法是我们读到这些数据对象时的上下文。比如,在不同的上下文中,一个同样的字节序列可能表示一个整数、浮点数、字符串或者机器指令。\n作为程序员,我们需要了解数字的机器表示方式,因为它们与实际的整数和实数是不同的。它们是对真值的有限近似值,有时候会有意想不到的行为表现。这方面的基本原理将在第2章中详细描述。\n豆日C编程语言的起源\nC语言是贝尔实验室的DennisRitchie于1969年~1973年间创建的。美国国家标准学会(AmericanNationalStandardsInstitute,ANSI)在1989年颁布了ANSIC的标准,后来C语言的标准化成了国际标准化组织(InternationalStandardsOrganization,ISO)的责任。这些标准定义了C语言和一系列函数库,即所谓的C标准库。Kernighan和Ritchie在他们的经典著作中描述了ANSIC,这本著作被人们满怀感情地称为\u0026quot;K\u0026amp;R\u0026quot;[61]。用凡tchie的话来说[92],C语言是“古怪的、有缺陷的,但同时也是一个巨大的成功”。为什么会成功呢?\n-C语言与Unix操作系统关系密切。C从一开始就是作为一种用于Unix系统的程序语言开发出来的。大部分Unix内核(操作系统的核心部分),以及所有支撑工具和函数库都是用C语言编写的。20世纪70年代后期到80年代初期,Unix风行于高等院校,许多人开始接触C语言并喜欢上它。因为Unix几乎全部是用C编写的,它可以很方便地移植到新的机器上,这种特点为C和Unix嬴得了更为广泛的支持。\ne有其他编码方式用于表示非英语类语言文本。具体讨论参见2.1.4节的旁注。\n圈嗣严l\n笫1章计算机系统漫游3\n-C语言小而简单。C语言的设计是由一个人而非一个协会掌控的,因此这是一个简洁明了、没有什么冗赘的设计。K\u0026amp;.R这本书用大量的例子和练习描述了完整的C语言及其标准库,而全书不过261页。C语言的简单使它相对而言易于学习,也易于移植到不同的计算机上。 -C语言是为实践目的设计的。C语言是设计用来实现Unix操作系统的。后来,其他人发现能够用这门语言无障碍地编写他们想要的程序。\nC语言是系统级编程的首选,同时它也非常适用于应用级程序的编写。然而,它也并非适用于所有的程序员和所有的情况。C语言的指针是造成程序员困惑和程序错误的一个常见原因。同时,C语言还缺乏对非常有用的抽象的显式支持,例如类、对象和异常。像C++和Java这样针对应用级程序的新程序语言解决了这些问题。\n2程序被其他程序翻译成不同的格式 # hello程序的生命周期是从一个高级C语言程序开始的,因为这种形式能够被人读懂。然而,为了在系统上运行hello.c程序,每条C语句都必须被其他程序转化为一系列的低级机器语言指令。然后这些指令按照一种称为可执行目标程序的格式打好包,并以二进制磁盘文件的形式存放起来。目标程序也称为可执行目标文件。\n在Unix系统上,从源文件到目标文件的转化是由编译器驱动程序完成的:\nlinux\u0026gt; gee-o hello hello.e\n在这里,GCC编译器驱动程序读取源程序文件hello.c,并把它翻译成一个可执行目标文件hello。这个翻译过程可分为四个阶段完成,如图1-3所示。执行这四个阶段的程序(预处理器、编译器、汇编器和链接器)一起构成了编译系统(compilationsystem)。\nprintf.o\n图1-3 编译系统\n预处理阶段。预处理器(cpp)根据以字符#开头的命令,修改原始的C程序。比如hello.c中第1行的五nclude\u0026lt;s七dio.h\u0026gt;命令告诉预处理器读取系统头文件stdio.h的内容,并把它直接插入程序文本中。结果就得到了另一个C程序,通常是以.i作为文件扩展名。 编译阶段。编译器(eel)将文本文件hello.i翻译成文本文件hello.s,它包含一个汇编语言程序。该程序包含函数main的定义,如下所示:\nmain:\n2 subq $8, %rsp 3 mo v l $ . LCO, %edi 4 ca ll puts 5 movl $0, %eax 6 addq $8, %rsp 7 ret 定义中2~7行的每条语句都以一种文本格式描述了一条低级机器语言指令。汇编语言是非常有用的,因为它为不同高级语言的不同编译器提供了通用的输出语\n言。例如,C编译器和Fortran编译器产生的输出文件用的都是一样的汇编语言。\n汇编阶段。接下来,汇编器(as)将hello.s翻译成机器语言指令,把这些指令打包成一种叫做可重定位目标程序(relocatableobjectprogram)的格式,并将结果保存在目标文件hello.o中。hello.o文件是一个二进制文件,它包含的17个字节是函数main的指令编码。如果我们在文本编辑器中打开hello.o文件,将看到一堆乱码。 -链接阶段。请注意,hello程序调用了printf函数,它是每个C编译器都提供的标准C库中的一个函数。printf函数存在于一个名为printf.o的单独的预编译好了的目标文件中,而这个文件必须以某种方式合并到我们的hello.o程序中。链接器Cid)就负责处理这种合并。结果就得到hello文件,它是一个可执行目标文件 (或者简称为可执行文件),可以被加载到内存中,由系统执行。\n田日GNU项目\nGCC是GNU(GNU是GNU\u0026rsquo;sNotUnix的缩写)项目开发出来的众多有用工具之一。GNU项目是1984年由RichardStallman发起的一个免税的慈善项目。该项目的目标非常宏大,就是开发出一个完整的类Unix的系统,其源代码能够不受限制地被修改和传播。GNU项目已经开发出了一个包含Unix操作系统的所有主要部件的环境,但内核除外,内核是由Linux项目独立发展而来的。GNU环境包括EMACS编辑器、GCC编译器、GOB调试器、汇编器、链接器、处理二进制文件的工具以及其他一些部件。GCC编译器已经发展到支持许多不同的语言,能够为许多不同的机器生成代码。支持的语言包括C、C++、Fortran、Java、Pascal、面向对象C语言(Objective-C)和Ada。\nGNU项目取得了非凡的成绩,但是却常常被忽略。现代开放源码运动(通常和Linux联系在一起)的思想起源是GNU项目中自由软件(freesoftware)的概念。(此处的free为自由言论(freespeech)中的“自由”之意,而非免费啤酒(freebeer)中的“免费”之意。)而且,Linux如此受欢迎在很大程度上还要归功于GNU工具,它们给Linux内核提供了环境。\n3了解编译系统如何工作是大有益处的 # 对于像hello.c这样简单的程序,我们可以依靠编译系统生成正确有效的机器代码。但是,有一些重要的原因促使程序员必须知道编译系统是如何工作的。\n优化程序性能。现代编译器都是成熟的工具,通常可以生成很好的代码。作为程序员,我们无须为了写出高效代码而去了解编译器的内部工作。但是,为了在C程序中做出好的编码选择,我们确实需要了解一些机器代码以及编译器将不同的C语旬转化为机器代码的方式。比如,一个switch语句是否总是比一系列的辽-else语句高效得多?一个函数调用的开销有多大?while循环比for循环更有效吗?指针引用比数组索引更有效吗?为什么将循环求和的结果放到一个本地变量中,会比将其放到一个通过引用传递过来的参数中,运行起来快很多呢?为什么我们只是简单地重新排列一下算术表达式中的括号就能让函数运行得更快? 在第3章中,我们将介绍x86-64,最近几代Linux、Macintosh和Windows计算机的机器语言。我们会讲述编译器是怎样把不同的C语言结构翻译成这种机器语言的。在第\n5章中,你将学习如何通过简单转换C语言代码,帮助编译器更好地完成工作,从而调整C程序的性能心在第6章中,你将学习存储器系统的层次结构特性,C语言编译器如何将数组存放在内存中,以及C程序又是如何能够利用这些知识从而更高效地运行。\n-理解链接时出现的错误。根据我们的经验,一些最令人困扰的程序错误往往都与链接器操作有关,尤其是当你试图构建大型的软件系统时。比如,链接器报告说它无法解析一个引用,这是什么意思?静态变量和全局变量的区别是什么?如果你在不同的C文件中定义了名字相同的两个全局变量会发生什么?静态库和动态库的区别是什么?我们在命令行上排列库的顺序有什么影响?最严重的是,为什么有些链接错误直到运行时才会出现?在第7章中,你将得到这些问题的答案。 -避免安全漏洞。多年来,缓冲区溢出错误是造成大多数网络和Internet服务器上安全漏洞的主要原因。存在这些错误是因为很少有程序员能够理解需要限制从不受信任的源接收数据的数量和格式。学习安全编程的第一步就是理解数据和控制信息存储在程序栈上的方式会引起的后果。作为学习汇编语言的一部分,我们将在第3章中描述堆栈原理和缓冲区溢出错误。我们还将学习程序员、编译器和操作系统可以用来降低攻击威胁的方法。\n1.4处理器读并解释储存在内存中的指令\n此刻,hello.c源程序已经被编译系统翻译成了可执行目标文件hello,并被存放在磁盘上。要想在Unix系统上运行该可执行文件,我们将它的文件名输入到称为shell的应用程序中:\nlinux\\\u0026gt; ./hello hello, world linux\\\u0026gt; shell 是一个命令行解释器, 它输出一个提示符, 等待 输入一 个命令行, 然后执行 这个命令。如果该命令行的 第一个单词不是 一个内置的 s hell 命令, 那么 shell 就会假设这是一个可执行文件的名字, 它将加载并运行这个文件。所以在此 例中, s hell 将加载并运行he ll o 程序 , 然后等待程序终止。he ll o 程序在屏 幕上输出它的消息,然 后终止。s hell 随后输出一个提示符,等待下一个输入的命令行。 4. 1 系统的硬件组成\n为了理解运行 he ll o 程序时发 生了什么, 我们需 要了解一个典型系统的硬 件组织 , 如图 1-4 所示。这张图是近期 In tel 系统产品族的模型, 但是所有其他系统也有相同的外观和特性。现在不要担心这张图很复杂 我们将 在本书分阶段对其进行详尽的介绍 。\n总线\n贯穿整个系统的是一组电子管道,称作总线,它携带信息字节并负责在各个部件间传 递。通常总线被设计成传 送定长的字节块, 也就是宇( w or d ) 。字 中的字节数(即字长)是一个基本的系统参 数, 各个系统中都不尽相同。现在的大多数机器字长要么是 4 个字节( 32 位), 要么是 8 个字节( 64 位)。本书中, 我们不对字长做任何固定的假设。相反,我们 将在 需要明确定义的上下文中具 体说明一个 ”字” 是多大。\n2. 1/ 0 设 备\n1/ 0 ( 输入/输出)设备是系统与外部世界的联 系通道。我们的示例系统 包括四个 1/ 0 设备:作为用户输入的键盘和鼠标,作为用户输出的显示器,以及用于长期存储数据和程序 的磁盘驱动器(简单地说就是磁盘)。最开始, 可执行 程序 he l l o 就存放在磁盘上。\n每个 1/ 0 设备都通过一个控制 器或适配器与 1/ 0 总线相连。控制器 和适配器之间的区\n别主要在千它们的封装方式 。控制器是 1/ 0 设备本身或者系统的主印制电路板(通常称作主板)上的芯片组。而适配器则是一块插在主板插槽上的卡。无论如何,它们的功能都是 在 1/ 0 总线和 1/ 0 设备之间传递信息 。\n图 1-4 一个典型系统的硬件组成\nCPU: 中央处理单元; ALU: 算木/逻样单元; PC, 程序计数器; USB: 通用串行总线\n第 6 章会更多地说明 磁盘之类的 1/ 0 设备是如何工作的。在第 10 章中, 你将学习如何在应用程序中利用 Unix 1/ 0 接口访问设备。我们将特别关注网络类设备, 不过这些 技术对于其他设备来说也是通用的。\n3. 主存\n主存是一个临时存储设备,在处理器执行程序时,用来存放程序和程序处理的数据。从 物理上来说,主 存是由一组动态随机存取存储器( DRAM) 芯片组成的。从逻辑上来说 , 存储器是一个线性的字节数组,每 个字节都有其 唯一的地址(数组索引), 这些地址是从 零开始的。一般来说,组成程序 的每条机器指令都由不同数量的字节构成。与 C 程序变量相对应的数据项的大小是根据类型变化的。比如, 在运行 Linux 的 x86-64 机器上, s hor t 类型的数据 需要 2 个字节, i 工 和 fl oa t 类型需要 4 个字节 , 而 l ong 和 doubl e 类型需要 8 个字节。\n第 6 章将具体介绍存储器技术, 比如 DRAM 芯片是如何工作的, 它们又是 如何组合起来构成主存的。\n4 处理器\n中央处理 单元 (CPU ) , 简称处理器,是解释(或执行)存储在主存中指令的引擎。处理器的核心是一个大小为一个字的存储设备(或寄存器),称为程序计数 器 ( PC) 。在任何时刻, PC 都指向主存中的某条机器语言指令(即含有该条指令的地址)e。\n从系统通电开始,直到系统断电,处理器一直在不断地执行程序计数器指向的指令, 再更新程序计数器, 使其指向下一条指令。处 理器看上去 是按照一个非常简单的指令 执行模型来操作的,这个模型是由指令集架构决定的。在这个模型中,指令按照严格的顺序执 行,而执行一条指令包含执行一系列的步骤。处理器从程序计数器指向的内存处读取指\n8 PC 也普遍地被用来作为“个 人计算机” 的 缩写。然而,两者之间的 区别应该 可以很 清楚地从上下文中看出来 。\n令, 解释指令中的位 , 执行该指令指示的简单操作 , 然后更新 PC , 使其指向下一条指令, 而这条指令并 不一定和在内存中刚刚 执行的指令相邻 。\n这样的简单操作并 不多, 它们围绕着主存、 寄存 器文件 ( reg is ter fi le ) 和算术/逻辑单元( ALU ) 进行。寄存器文件是一个小的存储设备, 由一些单个字长的寄存器组成, 每个寄存器都有 唯一的名字。ALU 计算新的数据和地址值。下面是一些简单操作的例子,\nCPU 在指令的要求下 可能会执行这些操作 。\n加载: 从主存复 制一个字节或者一个 字到寄存 器, 以覆盖寄存器原来的内容。\n存储: 从寄存器复制一 个字节或者一个 字到主存的 某个位置,以 覆盖这个位置上原来的内容 。\n操作: 把两个寄存器的内 容复制到 ALU , ALU 对这两个字做算术运算,并 将结果存放到一个寄存器中,以覆盖该寄存器中原来的内容。\n跳转: 从指令本身中抽取一个字, 并将这个字复制到程序计数器( PC) 中, 以覆盖\nPC 中原来的值。\n处理器看上去是它的 指令集架构的简单实现 , 但是实际上 现代处理 器使用了非常复杂的机制来加速程序的执行。因此,我们将处理器的指令集架构和处理器的微体系结构区分 开来:指令集架构描述的是每条机器代码指令的效果;而微体系结构描述的是处理器实际 上是如何实现的 。在第 3 章研究机器代码时 , 我们考虑的是 机器的指令 集架构所提供的抽象性。第 4 章将更详 细地介绍处 理器实际上是 如何实现的。第 5 章用一个模型说明现代处理器是 如何工作的, 从而能预测和优化机器语言程序的性能 。\n1. 4. 2 运行 he l l o 程序\n前面简单描述了系统的硬件组成和操作,现在开始介绍当我们运行示例程序时到底发 生了些什么 。在这里必须省略很多 细节 , 稍后会做补充, 但是现在我们 将很满意千这种整体上的描述。\n. 初始时, shell 程序执行它的指令 , 等待我们输入一个命令。当我们在键盘上输入字符串\n\u0026quot; . / he l l o\u0026quot; 后, s hell 程序将字符逐一 读入寄存器, 再把它存放到内存中, 如图 1- 5 所示。\nCPU\n寄存器文件\n二三\n1 系统总线\n`七-石,..•.•. # `` hello',\ng:矿飞广 ooo 凡\n鼠标 键盘用户输入\n\u0026ldquo;h e l l o\u0026rdquo;\n显 示器\n扩展槽, 留待\n言\n图 1 - 5 从键盘上读取 he ll o 命令\n当我们在键 盘上敲回车键时, s h ell 程序就知道我们巳经结束了命令的输入。然后s hell 执行一系列指令来加载可执行的 he l l o 文件 , 这些指令将 h e 荨 o 目标文件中的代码和数据从磁盘复制到 主存。数据包括最终 会被输出的字符串 \u0026quot; h e l l o , wor l d \\ n\u0026quot; 。\n利用直接存储 器存 取CDMA , 将在第 6 章中讨论)技术, 数据可以 不通过处理器而直接从磁盘到达主存 。这个步骤如图 1-6 所示。\nCPU\n兰 # 总线接口\n\u0026ldquo;hello, wor l d\\ n\u0026rdquo; he l l o 代码\n.f.\n鼠标 键盘 显示器 存储在磁盘上的he l l o\n可执行文件\n图 1-6 从磁盘加载可执行文件到主存\n一旦目标文件 h e l l o 中的代码和数 据被加载到 主存 ,处 理器就开始执行 h e ll o 程序的 ma i n 程序中的机器语 言指令。这些指令将 \u0026quot; he l l o , wor l d \\ n\u0026quot; 字符串中的字节从 主存复制到寄存器文件,再从寄存器文件中复制到显示设备,最终显示在屏幕上。这个步骤如 图 1-7 所示。\nCPU\n\u0026ldquo;hello, wo r l d \\ n\u0026rdquo; he l l o 代码\n鼠标\n图 1-7 将输出字符串从存储器写到显示器\n1. 5 高速缓存至关重要\n这个简单的示 例揭元了一个重要的问题, 即系统花费了大 量的时间把信息从一个地方挪到另一个地方。 he l l o 程序的机器指 令最初是存 放在磁 盘上, 当程序 加载时 , 它们被复制到主存 ; 当处理器运行 程序时, 指令又从 主存复制到处理器。相似地, 数据串 \u0026quot; h e l ­ lo, wor l d / n\u0026quot; 开始时 在磁盘上,然 后被复制到主存, 最后从主存上复制到显示设备。从程序员的角度来看,这些复制就是开销,减慢了程序“真正”的工作。因此,系统设计者 的一个主要目标就是使这些复制操作尽可能快地完成。\n根据机械原理,较大的存储设备要比较小的存储设备运行得慢,而快速设备的造价远高 于同类的低速设备。比如说 , 一个典型系统上的 磁盘驱动器可能 比主存大 1000 倍, 但是对处理器而言,从磁盘驱动器上读取一个字的时间开销要比从主存中读取的开销大 1000 万倍。类似地 ,一个典型的寄存器文 件只存储几百字节的信息,而 主存里可存放几十亿字\n节。然而 ,处 理器从寄存器文件中读数据比从主存中 读取几 乎要快 100 倍。更麻烦的是, 随着这些年半导体技术的进步,这种处理器与主存之间的差距还在持续增大。加快处理器 的运行速度比加快主存的运行速度要容易和便宜得多。\n针对这种处理器与主存之间的差异,系统设计者采用了更小更快的存储设备,称为高速缓存存储 器( ca ch e memory, 简称为 cache 或高速缓存), 作为暂时的 集结 区域, 存放处理器近期可能会需 要的信息 。图 1-8 展示了一个典型系统中的 高速缓存 存储器。位于处理器芯片上的 Ll 高速缓存的容量可以达到 数万字节, 访问速度几 乎和访问 寄存器文件一样快。一个容量为数十万 到数百万 字节的更大的 L2 高速缓 存通过一 条特殊的 总线连接到处理器。进程访问 L2 高速缓存的时间要比访问 Ll 高速缓存的时间 长 5 倍, 但是这仍然比访问主存的时间 快 5 ~ 10 倍。Ll 和 L2 高速缓存是用一种叫做 静态随 机访问存储 器( S R AM ) 的硬件技术实现的。比较新的、处理能力更强大的系统甚至有 三级高速缓存: Ll 、 L 2 和\nL3。系统可以 获得一个很大的存储器 , 同时访问速度也很快 ,原 因是利用了高速缓存的 局部性原理,即程序具有访问局部区域里的数据和代码的趋势。通过让高速缓存里存放可能 经常访间的数据,大部分的内存操作都能在快速的高速缓存中完成。\nCPU 芯片\n图 1-8 高速缓存存储骈\n本书得出的重要结论之一就是,意识到高速缓存存储器存在的应用程序员能够利用高速缓 存将程序的 性能提高一个数量级。你将在第 6 章里学习这些重要的设备以及如何利用它们。\n6 存储设备形成层次结构\n在处理器和一个较大较慢的设备(例如主存)之间插入一个更小更快的存储设备(例如高速缓存)的想法已经成为一个普遍的观念。实际上,每个计算机系统中的存储设备都被\n组织成了一个存储器层 次结构, 如 图 1 - 9 所示 。在这个层 次结 构 中 , 从 上 至 下 , 设 备 的 访间速度越来越慢、容量越来越大,并且每字节的造价也越来越便宜。寄存器文件在层次结 构中位于最顶部, 也 就 是 第 0 级 或记为 LO。 这 里 我 们 展 示 的 是 三 层 高 速 缓 存 Ll 到 L3 , 占 据 存 储 器 层 次 结 构 的 第 1 层 到 第 3 层 。 主存在第 4 层 ,以 此 类 推 。\n更小更快\n( 每字节)\n更贵的\n存储设备\n更大\n更慢\n(每字节)\nL4:\nL2 : L 2高速缓存( SRAM )\nL3: L3高速缓存(SRAM)\n主存\n( D RAM )\n} CP U寄存器保存来自高速缓存存储器的字\n} LI 高速缓存保存取自L2高速缓存的高速缓存行\n} L2 高速缓存保存取自L3高速缓存的高速缓存行\n} L3 高速缓存保存取自主存的高速缓存行\n} 主存保存取自本地磁盘\n更便宜的\n存储设备\nL6:\nL5:\n本地二级存储\n( 本地磁盘)\n远程二级存储\n(分布式文件系统,Web服务器)\n图 1-9 一个存储器 层次结构的示例\n的磁盘块\n本地磁盘保存取自远程网络服务器上磁盘的文件\n存储 器层次结构的 主要思 想 是 上一 层 的存 储 器 作 为低一层存储器的高速缓存。因此, 寄 存 器 文 件 就 是 L1 的 高 速 缓 存 , L1 是 L 2 的 高 速 缓 存 , L 2 是 L3 的高速缓存, L3 是 主存的高速缓存, 而 主存又是磁盘的高速缓存。在某些具有分布式文件系统的网络系统中, 本地 磁 盘 就 是 存 储 在 其 他 系 统 中 磁 盘 上 的 数 据 的 高 速 缓 存 。\n正 如 可以运用不同的高速缓存的知识来提高程序性能一样, 程 序 员 同 样 可 以 利 用 对 整个 存 储 器 层 次 结 构 的 理 解 来 提 高 程序性能。第 6 章 将 更 详 细 地 讨 论 这个问 题 。\n7 操作系统管理硬件 # 让我们回到 hell o 程序的例子。当 shell 加载和运行 he ll o 程序时,以 及 he l l o 程 序 输出 自 己的 消息时, she ll 和 he ll o 程 序 都 没 有\n应用程序\n直 接 访 问 键 盘 、显 示 器 、 磁 盘 或 者 主存。取而\n操作系统\n代 之 的 是 ,它 们 依 靠 操 作 系统 提 供 的 服 务 。 我\n}软件\n们可以把操作系统看成是应用程序和硬件之间\n处理器 I 主存 I I /0 设备 }硬件\n插入的一层软件,如 图 1 - 1 0 所 示 。 所 有 应 用 图 1-10 计算机系统的分层视图程序对硬件的操作尝试都必须通过操作系统 。 进程\n操作系统有两个基本功能: (1 ) 防止硬\n件被失控的应用程序滥用; ( 2 ) 向应用程序\n虚拟内存\n提供简单一致的机制来控制复杂而又通常大 文件不 相 同 的 低 级 硬 件 设 备 。 操 作 系 统 通 过 几 个\n基 本 的 抽 象 概 念(进 程 、 虚拟 内存 和 文件)来 处理器 主存 I/ 0 设备\n实 现 这 两 个 功 能 。 如 图 1-11 所 示 ,文 件 是 对 图 1-11 操作系统提供的抽象表示\n1/0 设备的抽象表示, 虚拟内存是对主存和磁盘 1/ 0 设备的抽象表示, 进程则是对处理器、主存 和 1/ 0 设备的抽象表示。我们将依次讨论每种抽象表示。\nm Uni x、Pos ix 和标准 Uni x 规范\n20 世 纪 60 年代是大型 、复杂操 作 系统 盛行的年代,比 如 IB M 的 OS/ 360 和 Honey­ well 的 Multics 系统 。 OS / 36 0 是历 史上 最成功的软件项目之 一, 而 Mult ics 虽 然持 续存在了多年 ,却 从 来没 有被广 泛应 用过。 贝 尔 实验 室曾 经是 Mult ics 项 目的最初参与 者, 但是因为 考虑到该项目的 复杂性和缺乏进展而 于 1 969 年退出。 鉴于 M utics 项目 不愉 快的 经历 ,一 群贝 尔 实验室的 研 究人 员——- Ken T hompson 、Dennis Ritch 比、 Do ug Mell­\nroy 和 J oe Ossanna, 从 1969 年开始在 DEC PDP-7 计算机上完全 用机 器语 言编写 了一个简单得 多的操作系统。这个新 系统 中的很 多思想,比 如 层次文件 系统、作为 用 户级 进 程的 she ll 概念,都 是来自 于 Multics , 只不过 在一个更 小、 更简单 的程序 包里 实现 。 1 970 年, Brian Kernighan 给新系统 命 名为 \u0026quot; U nix\u0026quot; , 这也是 一个双关语 , 暗指 \u0026quot; Multics\u0026quot; 的复杂性 。1973 年用 C 重新 编写其内核 , 1 974 年, U nix 开始正式对外发布[ 93] 。\n贝 尔实 验室以 慷 慨的条件向学校 提 供源代码, 所以 U nix 在 大专院校里获得 了很 多支持并得以 持续发 展 。最有影响的工作发 生在 20 世 纪 70 年代晚期到 80 年代早期,在 美国 加州大 学伯 克利分校 ,研 究人 员在 一 系列发 布版本中增加了虚拟内存 和 Internet 协议, 称为 Unix 4. xBSD(Berkeley Software Dist ribution ) 。与 此同 时, 贝 尔 实验 室也 在发布自己 的版本, 称为 S ystem V U nix 。 其他厂 商的版本, 比 如 S un Micros ystems 的\nSolaris 系统,则是 从 这些原 始的 BSD 和 Syst em V 版本中衍 生而来。\n20 世 纪 80 年代中期 , U nix 厂 商试 图通 过加入新的、往往不兼容的特性来使 它们 的程序 与众不 同 ,麻 烦也 就随之而来了。 为 了 阻止这种趋势, IE E E ( 电 气和电子工程师协会)开始努力标 准化 U nix 的开发 , 后来由 Richard S tallm an 命名为 \u0026quot; Posix\u0026quot; 。结果就得到 了一 系列 的标准, 称作 Posix 标准。 这套标准 涵盖 了很 多 方 面, 比如 Unix 系统调用的 C 语言接口、s hell 程序和工具、线程及网络 编程。最近, 一个被 称为 “ 标准 U nix 规范” 的独立标 准化工作 已经与 P osix 一起创 建了统 一的 Unix 系统标准。这些标准化 工作的结果是 U nix 版本之间的 差异已经基本消失。\n1. 7. 1 进程 # 像 he ll o 这样的程序在现代系统上运行时,操作 系统会提供一种假象, 就好像系统上只有这个程序在运行。程序看上去是独占地使用处理器、主存和 I/ 0 设备。处理器看上去就像在不间断地一条接一条地执行程序中的指令,即该程序的代码和数据是系统内存中唯一的对 象。这些假象是通过进程的概念来实现的, 进程是计算机科学中最重要和最成功的概念之一。\n进程是操作系统对一个正在运行的程序的一种抽象。在一个系统上可以同时运行多个 进程 , 而每个进程都好像在独占地使用硬件。而并发运行,则 是 说 一 个 进程的指令和另一个进程的 指令是交错执行的。在大多数系统中, 需 要 运行的进程数是多于可以运行 它们的CPU 个数的。传统系统在一个时刻只能执行一个程序, 而先进的 多核处理器同时能够执行多个程序。无论是在单核还是多核系统中, 一个 CP U 看上去都像是在并发地执行多个进程 , 这是通过处理器在进程间切换来实现的。操作系统实现这种交错执行的机制称为上下文切换。为了简化讨论,我 们 只 考虑包含一个 CPU 的单处理器 系统的情况。我们会在\n9. 2 节中讨论多 处理 器系统。 操作系统保持跟踪进 程运行所需 的所有状态信息。这种状态,也 就是上下文, 包括许多信息 , 比如 PC 和寄存器文件的 当前值, 以及主存的内 容。在任何 一个时刻, 单处理器系统都只能执行一个进程的代码。当操作系统决定要把控制权从当前进程转移到某个新进 程时,就会进行上下文切换,即保存当前进程的上下文、恢复新进程的上下文,然后将控 制权传递到新进程。新进程就 会从它上次停止的地方开始。图 1-1 2 展示了示例 he l l o 程序运行场景的基本理念。\n示例场景中有两个并发的进程: s hell 进程和 he ll o 进程。最 开始,只 有 s hell 进程在\n运行, 即等待命令行上的输入。当我们让 它运行 he l l o 程序时 , s hell 通过调用一个专门的函数,即系统调用,来执行我们的请求,系统调用会将控制权传递给操作系统。操作系 统保存 s hell 进程的上下 文, 创建一个新的 he ll o 进程及其上下文, 然后将控制权 传给新的 h e l l o 进程。 he ll o 进程终止后 ,操 作系统恢 复 s hell 进程的上下文, 并将控制权传回给它, s hell 进程会继续 等待下一个命令行 输入。\n如图 1-1 2 所示, 从一个进程到另 一个进程的转换是由操作 系统内核 ( kern el) 管理的。内核是操作系统代码常驻主存的部分。当应用程序需要操作系统的某些操作时,比如读写 文件, 它就执行一条特殊的 系统调 用 ( s ys t em call) 指令, 将控制权传递给内核。然后内核执行被请求的操作并返回应用程序。注意,内核不是一个独立的进程。相反,它是系统管 理全部进程所用代码和数据结构的集合。\n, # 进程A I 进程B\nI\n- - -\nread - -►- -\n-\u0026mdash;\u0026rsquo;: - -- 产 二/内用核户代码\n磁盘中断\u0026ndash;► - \u0026ndash; -夕- — 十,一 _J\n用户_代码\n从 r e a d 返回 - -► 一 \u0026ndash;\u0026rsquo;t\n呾 嘎 _ } 上下文切换\nI, 用户代码\n-- : - \u0026ndash;·- \u0026mdash;\u0026ndash;\n图1-21进程的上下文切换\n实现进程这个抽象 概念需要低级硬件 和操作 系统软件之间的紧密合 作。我们将在第 8\n章中揭示这项工作的原理,以及应用程序是如何创建和控制它们的进程的。\n1. 7. 2 线程\n尽管通常我们认 为一个进程只有单 一的控制流,但 是在现代系统中 , 一个进程实际 上可以由多个称为线程的执行单元组成,每个线程都运行在进程的上下文中,并共享同样的 代码和全局数据。由于网络服务器中对并行处理的需求,线程成为越来越重要的编程模 型,因为多线程之间比多进程之间更容易共享数据,也因为线程一般来说都比进程更高 效。当有多处理器可用的时候,多线程也是一种使得程序可以运行得更快的方法,我们将 在 1. 9. 2 节中讨论这个问 题。在第 1 2 章中, 你将学习并发的基本概念, 包括如何写线 程化的程序。\n7. 3 虚拟内存\n虚拟内存是一个抽象概念,它为每个进程提供了一个假象,即每个进程都在独占地使用 主存。每个进程看到的内存都是一致的 , 称为虚拟地址空间。图 1-13 所示的是 Linux 进程的\n虚拟地址空间(其他 Unix 系统的设计也与此类似)。在Linux 中, 地址空间最上 面的区域是保留给操作系统中的代码和数据的 , 这对所有进程来说都是一样。地址 空间的底部区域存放用户进程定义的代码和数据。请注意,图中的地址是从下往上增大的。\n内核虚拟内存 f用户代码不 可见的内存\npr i n t f 函数\n运行时堆\n( 在运行时由ma l l oc 创 建的)\n读泻数据\n只读的代码和数据\n图 1-13 进程的虚拟地址空间\n每个进程看到的虚拟地址空间由大僵准确定义的区构成 , 每个 区都有专门的功能。在本书的后续章节你将学到更多有关这些区的知识,但是先简单了解每一个区是非常有益 的。我们从最低的地址 开始, 逐步向上介绍 。\n·程序代码和数据。对所有的进程来说,代码是从同一固定地址开始,紧接着的是和 C 全局变量相对应的数据位置。代码和数据 区是直接按照可执行目标文件的内容初始化的 , 在示例中就是可执行 文件 h e ll o。在第 7 章我们研究链接和加载时, 你会学习更多有关地址空间的内容。\n堆。代码和数据区后紧随着的是运行时堆。代码和数据区在进程一开始运行时就被 指定了大小 , 与此不同, 当调用像 ma l l o c 和 fr e e 这样的 C 标准库函数时, 堆可以在运行时动态地 扩展和收缩。在第 9 章学习管理虚拟内存时, 我们将 更详细地研究堆。 共享库。大约在地址空间 的中间部分是一块用来存放像 C 标准库和数学库这样的共享库的代码和数据的 区域。共享库的概念非常强大 , 也相当难懂。在第 7 章 介绍动态链接时,将学习共享库是如何工作的。 栈。位 千用户虚拟地址空间 顶部的是用 户栈, 编译器用它来实 现函数调用。和堆一样,用 户栈在程序执行期间 可以动态地扩展和收缩。特别地 , 每次我们调用一个函数时, 栈就会增长; 从一个函数返回时 ,栈就会 收缩。在第 3 章中将学习编译器是如何使用栈的 。 内核虚拟 内存。地址空间 顶部的区域是为内核保留的。不允许应用程序读写这个区域的内容或者直接调用内核代码定义的函数。相反,它们必须调用内核来执行这些 操作。 虚拟内存的运作需要硬件和操作系统软件之间精密复杂的交互,包括对处理器生成的每 个地址的硬件翻译。基本思想是把一个进程虚拟内存的内容存储在磁盘上,然后 用主存作为磁盘的高速缓存。第 9 章将解 释它如何工作,以 及为什么对现代系统的运行如此重要。\n1. 7. 4 文件 # 文件就是字节序列,仅 此 而巳。每个 1/ 0 设备, 包括磁盘、键盘、显示器, 甚至网络, 都可以 看成是文件。系统中的所有输入输出都是通过使用一小组称为 Unix 1/ 0 的系统函数调用读写文件来实现的。\n文件这个简单而精致的概念是非常强大的,因为它向应用程序提供了一个统一的视 图,来 看待系统中可能含有的所有各式各样的 1/ 0 设备。例如,处 理 磁 盘文件内容的应用程序员可以非常幸福,因为他们无须了解具体的磁盘技术。进一步说,同一个程序可以在 使用不同磁盘技术的不同系统上运行。你将在第 10 章中学习 U nix 1/ 0 。\n田日Linu x 项目\n1991 年 8 月 , 芬兰研 究生 Linus T or valds 谨慎地发布了一个新的 类 U nix 的操作 系统内核, 内容如 下。\n来自: tor valds@ klaava. H elsinki. Fl ( Linus Benedict T orvalds)\n新闻 组: comp. os. minix\n主题 : 在 minix 中你最想看 到什么? 摘要: 关于我的 新操作系统的 小调查\n时间: 1 991 年 8 月 25 日 20 : 57 : 08 GMT\n每个使 用 minix 的朋 友, 你们好。\n我 正在做一个(免费的)用在 386 (486) AT 上的操作系统(只是 业余爱 好, 它 不会像GNU 那样庞大和专业)。这个想法 自 4 月份就开始酝酿, 现在快要完成 了。 我 希望得到各位对 minix 的任何反馈 意见 , 因为 我的操作系统在 某 些 方 面 与 它相 类似(其 中包 括相同的文件系统的物理设计(因为某些实际的原因))。\n我 现在巳 经移植了 bas h(1. 08) 和 gcc( l. 40 ) , 并且看上去能运行。这意味着我需要\n几个月的 时间 来让它 变得 更实用 一些, 并且, 我想 要知 道大 多数 人想要什 么特 性。 欢迎任何建议, 但是我无法保 证 我能 实现它们。:-)\nLinus (t or val ds@kr uuna. hel s i nki . f i )\n就像 Tor valds 所说的, 他 创建 Linux 的起点是 Minix , 由 Andrew S. T anen baum 出\n于教 育目的 开发 的一个操作系统[ 113]。\n接下来,如 他 们所说, 这就成了历 史。 Lin ux 逐 渐发展 成 为 一个技 术和文化现象。通过和 G NU 项 目 的 力 量结合, L inux 项 目 发 展 成 了 一 个 完整 的、符合 Posix 标准的Unix 操作 系统 的版本, 包括内核 和所有 支撑的基础设施。从 手持 设备到 大型 计算机,\nLinux 在 范围如此广 泛的 计算机上得到 了应 用。 IBM 的一个工作 组 甚至把 Linux 移植到了一块腕表中!\n1. 8 系统之间利用网络通信\n系统漫游至此, 我们一直是把系统视为一个孤立的硬件和软件的集合体。实际上,现\n代系统经常通过网络 和其 他系统 连接到一起。从一个单独的 系统来看,网 络可视为一个I/ 0 设备, 如图 1-14 所示。当系统从 主存复制 一串字节到网络适 配器时 , 数据流经过网络到达另一台机器,而不是比如说到达本地磁盘驱动器。相似地,系统可以读取从其他机器 发送来的数据,并把数据复制到自己的主存。\n,CPU 芯片\n寄存器文件\n系统总线 内存总线\n已 、心- i 主存储器\n寸\u0026quot;\u0026quot;\u0026rsquo;. I\n, 扩展槽\n\u0026lsquo;;\n名 忒 夺 f \u0026rsquo; \u0026lsquo;-\u0026ldquo;711守 你郓心; ;\nVO 总线 粗\n怂 .怂Y\n二勹 三三三l\n图 1-14 网络也是一种 1/0 设备\n随着 I n t e r n e t 这样的全球网络的出现 , 从一台主机复制信息 到另外 一台主机已经成为计算机系统最重要的用途之一 。比如, 像电子邮件、即时通信 、万维网、FTP 和 t e l n e t 这样的应用都 是基千网络复 制信息的功能。\n回到 h e ll o 示例, 我们可以使用熟悉的 t e ln e t 应用 在一个远程主机上 运行 h e l l o 程序。.假设用本地主机上的 t e ln et 客户端连接远 程主机上的 t e ln e t 服务器。在我 们登录到远程主机并运行 s h e ll 后, 远端的 s h e ll 就在等待接收输入命令 。此后在远端运行 h e l l o 程序包括如图 1-15 所示的五个 基本步骤。\nI. 用户在键盘上输人 \u0026ldquo;he l l o\u0026rdquo;\n5. 客户端在显示器上打印\u0026quot;hello world\\n\u0026rdquo; 字符串\n2. 客户端向 telnet服务器发送字符串 \u0026ldquo;he l l o\u0026rdquo;\n\u0026mdash;\u0026mdash;\u0026mdash;\u0026mdash;\u0026mdash;\u0026mdash;\u0026ndash;\n4. telnet服务器向客户端发送字符串 \u0026ldquo;he l l o wor l d \\ n\u0026rdquo;\n服务器向 shell发送字符串 "hello , she ll 运行he ll o程序并将输出发送给telnet服务 器 图 1- 15 利用 telnet 通过 网络远程运行 he l l o\n当我们在 t e ln e t 客户端键入 \u0026quot; h e l l o \u0026quot; 字符串并 敲下 回车键后 ,客 户端软件就会将这个字符串发送到 t e ln e t 的服务器。te ln e t 服务器从 网络上 接收到这个字符串后, 会把它传递给远端 s h e ll 程序。接下来 ,远 端 s h e ll 运行 h e l l o 程序 , 并将输出行返回 给 t e l n e t 服务器。最后, t e ln e t 服务器通过网络 把输出串转发给 t e ln e t 客户端 , 客户端就将输出串输出到我们的本 地终端上 。\n这种客户端 和服务器之间交 互的类型在所 有的 网络应用中 是非常典型的。在第 11 章中, 你将学会如何构造网络应用 程序 , 并利用这些知识创建 一个简单的 Web 服务器。\n9 重要主题\n在此,小结一下我们旋风式的系统漫游。这次讨论得出一个很重要的观点,那就是系 统不仅仅只是硬件。系统是硬件和系统软件互相交织的集合体,它们必须共同协作以达到 运行应用程序的最终目的。本书的余下部分会讲述硬件和软件的详细内容,通过了解这些 详细内容,你可以写出更快速、更可靠和更安全的程序。\n作为本章的结束,我们在此强调几个贯穿计算机系统所有方面的重要概念。我们会在 本书中的多处讨论这些概念的重要性。\n9. 1 Amda hl 定 律\nGene Amdahl, 计算领域的早期先锋之一,对 提 升系统 某一部分性能所带来的效果做出了简单却有见地的观察。这个观察被称为 Amdahl 定律CAmdahl\u0026rsquo;s law) 。该定律的主要思想是,当我们对系统的某个部分加速时,其对系统整体性能的影响取决于该部分的重要 性和加速程度。若系统执行某应用程序需要时间为 Told 。 假设系统某部分所需执行时间与该时间的比例为 a , 而该部分性能提升比例为 K 。 即 该 部 分 初 始 所 需 时 间 为 a Told, 现在所\n需 时 间 为Ca T01d) / k。 因 此,总 的 执 行 时间应为\nTnew = 0-a)Told + (aTold) / k = T otd[ O - a ) + a / k]\n由 此 ,可 以计算加速比 S = T0 1d/ T new为\ns = ,. 1 (1. 1)\n举个例子, 考虑这样一种情况, 系统的某个部分初始耗时 比例为 60% (a=O. 6), 其加速比例因子为 3Ck= 3)。则我们可以获 得的加速比为 1/ [ 0. 4+0. 6/ 3] = 1. 67 倍。虽然我们对系统的一个主要部分做出了重大改进,但是获得的系统加速比却明显小于这部分的加速比。这就是\nAmdahl 定律的主要观点一— 要想显著加速整个系统,必须 提升全系统中相当大的部分的速度。\n田 日 表示相对性能\n性能提升最好的表示方法就 是用比 例的形式 T old/ T new , 其中, T old 为原始 系统 所需时间 , T new为修 改后的 系统 所需时间。如 果 有所改进, 则 比 值应 大 于 1 。 我们 用后缀\u0026quot; X \u0026quot; 来表示比 例, 因此, \u0026quot; 2. 2 X \u0026quot; 读作 \u0026quot; 2. 2 倍\u0026quot;。\n表示相对变化更传统的方法是用百分比,这种方法适用于变化小的情况,但其定义 是模糊的。应该等于 100 • ( Told - T new ) / T new, 还是 100 • ( T old - T new) / T old , 还是其他的值? 此外, 它对较大的 变化也 没有太大意 义。与 简单地说性能提升 2. 2 X 相比,“性能提升了 1 20 %\u0026quot; 更难理解。\n练习题 1. 1 假设你是个卡 车 司机, 要将土豆从爱达荷州 的 Boise 运送到 明尼 苏 达州的 Minnea polis , 全程 2500 公里。 在限速范围 内, 你估计平 均速度为 1 00 公里/小时, 整个行程需要 25 个小时。\n你听到 新闻说蒙大拿 州刚 刚取消 了限 速, 这使得行 程中 有 1500 公 里卡 车的 速度可\n以为 150 公里/小时。 那么这 对整个行程的加速比是多少?\n你可以在 www. fasttrucks. com 网站 上为 自 己 的卡车买个 新的 涡轮增 压器。 网站现货供应各种型号,不过速度越快,价格越高。如果想要让整个行程的加速比为 67 X , 那么你必须以多快的速度通过蒙大拿州? 练习题 1. 2 公司的 市场部 向你的客户 承诺, 下 一个 版本的 软件性 能 将改进 2 X 。这项任务被 分配给你。 你已 经 确 认只 有 80 % 的 系统 能 够被改进, 那 么, 这部 分需 要被改进 多少(即 k 取何值)才能 达到整体性能目标?\nAmdahl 定律一个有趣的特殊情况是考虑 K 趋向千00 时的效果。这就意味着, 我们可以取系统的 某一部分将其加速到一个点, 在这个点上,这 部 分 花费的时间可以忽略不计。于是我们得到\ns= =\nCl -a)\n(1. 2)\n举个例子 , 如果 60 %的系统能够加速到不花时间的程度, 我们获得的净加速比将仍只有\n1/ 0. 4=2. 5 X 。\nAmdah l 定律描述了改善任何过程的一般原则。除了可以用在加速计算机系统方面之外,它还可以用在公司试图降低刀片制造成本,或学生想要提高自己的绩点平均值等方 面。也 许它在计算机世界里是最有意义的,在 这里我们常常把性能提升 2 倍 或更高的比例因子。这么高的比例因子只有通过优化系统的大部分组件才能获得。\n9. 2 并发和并行\n数字计算机的整个历史中,有两个需求是驱动进步的持续动力:一个是我们想要计算 机做得更多,另一个是我们想要计算机运行得更快。当处理器能够同时做更多的事情时, 这两个因素都会 改进。我们用的术语并发 ( co ncurr ency) 是一个通用的概念,指一 个同时具有多个活动的系统;而术 语 并行 ( para ll elis m ) 指的是用并发来使一个系统运行得更快。并行可以在计算机系统的多个抽象层次上运用。在此,我们按照系统层次结构中由高到低的 顺序重点强调三个层次。\n线程级并发\n构建在进程这个抽象之上,我们能够设计出同时有多个程序执行的系统,这就导致了 并发。使用线程, 我们甚至能够在一个进程中执行多个控制流。自 20 世纪 60 年代初期出现时间共享以来,计算 机 系统中就开始有了对并发执行的支持。传统意义上, 这种并发执行只是模拟出来的,是 通过使一台计算机在它正在执行的进程间快速切换来实现的, 就好像一个杂耍艺人保待多个球在空中飞舞一样。这种并发形式允许多个用户同时与系统交 互, 例如, 当许多人想要从一个 Web 服务器获取页面时。它还允许一个用户同时从事多个任务, 例如,在 一 个 窗 口 中 开启 Web 浏览器, 在 另一窗口中运行字处理器,同 时 又播放音乐。在以前 ,即 使 处 理 器必须在多个任务间切换, 大多数实际的计算也都是由一个处\n理器来完成的。这种配置称为单处理 器 系统 。\n当构建一个由单操作系统内核控制的多处理 器组成的系统时, 我们就得到了一个多 处理 器 系统。其实从 20 世纪 80 年代开始,在 大规模的计算中就有了这种系统,但是直到最近,随着多核 处理器和超线程 ( h ypert hr eading ) 的出现, 这 种系统 才变得常见。图 1-16 给出了这些不同处理\n器类型的分类。\n所有的处理器\n单处理器\n多处理器\n多核处理器是将多个 CP U ( 称为“核\u0026quot;)集成到一个集成电路芯片上。图 1-17 描 述的是一个\n图 1- 1 6 不同的处理器配置分类 。随着多 核\n处理器和超线程的出现,多处理器\n变得普遍了\n典型多核处理器的组织结 构, 其中微处理器芯片 有 4 个 CP U 核, 每个核都有自己的 Ll 和\nL2 高速缓存 , 其中的 Ll 高速缓存分 为两个部分一 一个保存最 近取到的指令,另一个存放数据。这些核共享更高层次的高速缓存,以及到主存的接口。工业界的专家预言他们能 够将几十个、最终会是上百个核做到一个芯片上。\n处理器封装包\n俨· 一一一一一一一一一一一一一一一一一一一一一一一一一一一一一一一一一一一一一\n1 核0 核3 I\nL3统一的高速缓存(所有的核共享)\nI_ - - - - - - 宁 \u0026mdash;\u0026mdash;\n图1-17 多 核处理器的组织结构。4 个处理器核集成 在一个芯片上\n超线程 , 有时称为同时多 线程 ( simultaneo us multi-threading), 是一项允 许一个 CP U 执行多个控制流的技术。它涉 及 CPU 某些硬件有多 个备份, 比如程序计数器 和寄 存器文件, 而其他的硬件部分只有一份, 比如执行浮点算术运算的 单元。常规的处理器需 要大约20 000 个时钟周期做不同 线程间的转换 ,而超线程 的处理器可以在单个周期的 基础上决定要执行哪一个线 程。这使得 CP U 能够更好地利用它的处理资源。比 如, 假设一个线程必须等到某些数 据被装载到高速缓存中 , 那 CPU 就可以继续去执行另一 个线程。举 例来说, Intel Core i7 处理器可以让每个核执行两个 线程, 所以一个 4 核的系统实际上可以并 行地执行 8 个线程。\n多处理器的使用可以从两方面提高系统性能。首先,它减少了在执行多个任务时模拟 并发的需要。正如前面提到的,即使是只有一个用户使用的个人计算机也需要并发地执行 多个活动。其次,它可以使应用程序运行得更快,当然,这必须要求程序是以多线程方式 来书写的 , 这些线程可以并行地高效 执行。因此, 虽然并发原理的 形成和研究已经超过 50 年的时间了,但是多核和超线程系统的出现才极大地激发了一种愿望,即找到书写应用程 序的方法利用硬件开发线程级并 行性。第 12 章 会更深入地探讨并发, 以及使用并发来提供处理器资源的共享,使程序的执行允许有更多的并行。\n2 指令级井行\n在较低的抽象层 次上, 现代处理器可以同时 执行多条指令的属性称为指令级 并行。早期的微处理器 , 如 1978 年的 In tel 8086, 需要多个(通常是 3~ 10 个)时钟周期来执行 一条指令。最 近的处理器可以保持 每个时 钟周期 2 ~ 4 条指令的执行速率。其实每条指令从开\n始到结束需 要长得多的时间 , 大约 20 个或者更多周期, 但是处理器使用了非常多 的聪明技巧来同时处理多 达 100 条指令。在第 4 章中, 我们会研究 流水线 ( pi pelining ) 的使用。在流水线中,将执行一条指令所需要的活动划分成不同的步骤,将处理器的硬件组织成一系 列的阶段 , 每个阶段 执行一个步骤。这些 阶段可以并行地操作 ,用 来处理不同指令的不同部分。我们会看到一个 相当简单的硬件设计 , 它能够达到接近于一个时钟周期一条指令的执行速率。\n如果处理器可以 达到比一个周期一条指令更快的执行 速率 , 就称之为超标 量 ( s uper­ scalar )处理器。大多 数现代处理器都支持超标量操作。 第 5 章中, 我们将描述 超标量处理器的高级模型。应用程序员可以用这个模型来理解程序的性能。然后,他们就能写出拥有 更高程度的指令级并行 性的程序代码 ,因而 也运行得更快 。\n3 单指令、 多数据并行\n在最低层次上,许多现代处理器拥有特殊的硬件,允许一条指令产生多个可以并行执 行的操作 , 这种方式称为 单指令、 多 数据, 即 SIMD 并行。例如, 较新儿代的 In tel 和\nAMD 处理器都具有并行地对 8 对单精度浮点数 (C 数据类型 fl o a t ) 做加法的指令。\n提供这些 SIMD 指令多是为了提高处理影像、声音 和视频数据应用 的执行速度。虽 然有些编译器会试图从 C 程序中自动抽取 SIMD 并行性,但是更可靠 的方法是用编译器支持的特殊的向量数据类型来写 程序, 比如 GCC 就支持向量数据类型。作 为对 第 5 章中比较通用的程序优化描述的 补充, 我们在网络旁 注 OPT :SIMD 中描述了这种 编程方式 。\n9. 3 计算机系统中抽象的重要性\n抽象的使用是计算 机科学中最 为重要的概念之一 。例如, 为一组函数规定一个简单的应用程序接口 ( APD 就是一个很好的编程习惯 , 程序员无须了解它内部的工作便可以使用这些代码。不同的 编程语言提供不 同形式 和等级的 抽象支持 , 例如 J a va 类的声明和 C 语言的函数原型。\n. 我们已经介绍了计算 机系统中使用的几个抽象, 如图 1-18 所示。 在处 理器里 , 指令集架构提供了对实际处理器硬件的抽象。使用这个抽象,机器代码程序表现得就好像运行 在一个一次只执行一条指令的处理器上。底层的 硬件远比抽象 描述的要复杂精细, 它并行地执行多条指令,但又总是与那个简单有序的模型保持一致。只要执行模型一样,不同的 处理器实现也能执行同样的机器代码,而又提供不同的开销和性能。\n虚拟机\n进程\n指令集架构 虚拟内存\n文件\n操作系统 处理器 主存 VO设备\n图 1-18 计算机系统提供的 一些抽象。计算机系统中的一个 重大主题就是提供不同层次的抽象表示,来隐藏实际实现的复杂性\n在学习操作系统时 , 我们介绍了三个抽象 : 文件是 对 I/ 0 设备的抽象, 虚拟内存是 对程序存储器的抽象 , 而进程是 对一个正在运行的程序的 抽象。我们再 增加一个新的抽象 :\n虚拟机 , 它提供对整个计算机的抽象 , 包括操作系统、处理器和程序。虚拟机的思想是\nIBM 在 20 世纪 60 年代提出来的 , 但是最近才显 示出其管理计算机方式 上的优 势, 因为一些计算机必须能够运行 为不 同的操作系统(例如, M icro s o f t W in d o w s 、M a cO S 和 L in u x ) 或同一操作系统的不同版本设计的程序。\n在本书后续的章节中,我们会具体介绍这些抽象。\n10 小结\n计算机系统是由硬件和系统软件组成的,它们共同协作以运行应用程序。计算机内部的信息被表示 为一组组的 位,它 们依据上下文有不同的解释方式。程 序被其他程序 翻译 成不同的形式,开 始时是\nASCII 文本,然后 被编译器 和链接器 翻译成二进制可执行 文件。\n处理器读取并解 释存放在主存里的二进制指 令。因 为计算 机花费了 大量的时间在内 存、1/0 设备和CP U 寄存器之间复制数据 , 所以将系统中的存储设 备划分成层次结构 CPU 寄存器在顶部 , 接着是多层的硬件高 速缓存存储器、 DRAM 主存和磁盘存储器。在层次模型中 , 位于更高层的存储设 备比低层的存储设备要更快,单位比特造价也更高。层次结构中较高层次的存储设备可以作为较低层次设备的高速 缓存。通过理解 和运用 这种存储层次结构的 知识, 程序员可以 优化 C 程序的性能 。\n操作系统内 核是应用程序 和硬件之间的媒介 。它提供三个基本的抽象 : 1) 文件是对 1/ 0 设备的抽象;\n虚拟内存是对 主存和磁盘的抽象 ; 3) 进程是处 理器、主存 和 1/ 0 设备的抽象。 最后,网络 提供了计算机系统之间通信的手段。从特殊 系统的 角度来看,网络就是一种 1/ 0 设备。\n参考文献说明 # Rit chie 写了关于早期 C 和 U nix 的有趣的第一手资 料[ 91 , 92] 。 Ritchie 和 T hompson 提供了最早 出版的 Unix 资料[ 93] 。Silberschatz 、Galvin 和 Gagne[ 102] 提供了关于 Unix 不同版本的详尽历 史。GNU ( www. gnu. org) 和 Linux( www. linux. org) 的网站上有大量的 当前信息 和历史资 料。Posix 标准可以 在线获得( www. unix. org) 。\n练习题答案 # 1 该问题说明 Amdahl 定律不仅仅适用于计算机系统。 根据公 式 1. 1, 有 a = 0. 6 , k = l. 5。更直接地说, 在蒙大拿行驶的 1500 公里需要 10 个小时 , 而其他行程也需 要 10 个小时。则加速比 为 25 / ClO+ l O) = l. 25 X 。\n根据公式 1. 1, 有 a= O. 6, 要求 S = l. 67, 则可算出 k。更直接地说,要使行程 加速度达到1. 67X, 我们必须把全程时间减少到 15 个小时。蒙大拿以外仍要求 为 10 小时, 因此, 通过 蒙大拿的时间就为 5 个小时。这就要求行驶速度为 300 公里/小时, 对卡车来说这个速度太快了!\n1. 2 理解 Amdahl 定律最好的方法就是 解决一些 实例。本题要求你从 特殊的角度来看公 式 1. 1。本题是公式的简单应用。已知 5 = 2 , a=O. 8, 则计算 k :\n2 =\nO. 4 + 1. 6/ k = l. 0\nk = 2. 67\n"},{"id":438,"href":"/zh/docs/technology/System/%E6%B7%B1%E5%85%A5%E7%90%86%E8%A7%A3%E8%AE%A1%E7%AE%97%E6%9C%BA%E7%B3%BB%E7%BB%9F/%E4%B9%A6%E7%B1%8D/%E7%AC%AC2%E7%AB%A0-%E4%BF%A1%E6%81%AF%E7%9A%84%E8%A1%A8%E7%A4%BA%E5%92%8C%E5%A4%84%E7%90%86/","title":"Index","section":"SpringCloud","content":"第 2 章\nC H A P T E R 2\n信息的表示和处理\n现代计算机存储和处理的信息以二值信号表示。这些微不足道的二进制数字,或者称 为位( bit ) , 形成了数字革命的基础。大 家熟悉并使用了 10 00 多年的十进制(以10 为基数) 起源于印度, 在 12 世纪被阿拉伯数学家改进,并 在 13 世纪被意大利数学家 Leona rdo P isano ( 大约公元 11 70- 1250 , 更为大家所熟 知的名字是 Fibo nacci ) 带到西方。对 千有 10 个手指的人类来说,使用十进制表示法是很自然的事情,但是当构造存储和处理信息的机 器时,二进制值工作得更好。二值信号能够很容易地被表示、存储和传输,例如,可以表 示为穿孔卡片上有洞或无洞、导线上的高电压或低电压、或者顺时针或逆时针的磁场。对 二值信号进行存储和执行计算的电子电路非常简单和可靠,制造商能够在一个单独的硅片 上集成数百万甚至数十亿个这样的电路。\n孤立地讲, 单个的位不是非常有用。然而,当把位 组合在一起,再 加上某种解释( inter­ pretation) , 即赋予不同的可能位模式以含意,我们就能够表示任何有限集合的元素。比如, 使用一个二进制数字系统 , 我们能 够用位组来编码非负数。通过使用标准的字符码, 我们能够对文档中的字母和符号进行编码。在本章中,我们将讨论这两种编码,以及负数 表示和实数近似值的编码。\n我们研究三种最重 要的数字表示。无符号 ( unsig ned ) 编码基千传统的 二进制表示法,\n表示大千或者等 千零的 数字。补码( t wo \u0026rsquo; s- com plemen t ) 编码是 表示有符号整数的最常见的方式,有 符号整数就是可以 为正或者为负的 数字。浮点数 ( float ing- poin t ) 编码是表示实数的科学 记数法的以 2 为基数的 版本。计算机用这些不同的 表示方法实现算术运算 ,例如加法和乘法,类似于对应的整数和实数运算。\n计算机的表示法是用有限数量的位来对一个数字编码,因此,当结果太大以至不能表 示时,某些运算 就会溢出 ( overfl o w) 。溢出会导致某些令人吃惊的后果。例如,在 今天的\n大多数计算机上(使用32 位来表示数据类型 i nt ) , 计算表达式 200*300*400*500 会得出结果\n—88 4 901 888。这违背了整数运算的特性,计 算一组正数的乘积不应产生一个负的结果。\n另一方面,整数的计算机运算满足人们所熟知的真正整数运算的许多性质。例如,利 用乘法的结合律和交换律,计 算下面任何一个 C 表达式, 都会得出结果一88 4 901 888:\n(500 * 400) * (300 * 200) ((500 * 400) * 300) * 200 ((200 * 500) * 300) * 400 400 * (200 * (300 * 500)) 计算机可能没有产生期望的结果,但是至少它是一致的!\n浮点运算有完全不同的数学属性。虽 然溢出会产生特殊的 值十(X) \u0026rsquo; 但是一组正数 的乘积总是正的。由千表示的精度有限 , 浮点 运算是 不可结合的。例如, 在大多数机器上 , c 表达式 (3 . 1 4+1e 20 ) - l e 20 求得的值会是0. 0 , 而 3 . 1 4+ (1 e 20 - l e 20 ) 求得的值会是 3. 14。\n整数运算和浮点数运算会有不同的数学属性是因为它们处理数字表示有限性的方式不 同 整数的表示虽然只能编码一个相对较小的数值范围,但是这种表示是精确的;而浮\n点数虽然可以编码一个较大的数值范围,但是这种表示只是近似的。\n通过研究数字的实际表示,我们能够了解可以表示的值的范围和不同算术运算的属性。为了使编写的程序能在全部数值范围内正确工作,而且具有可以跨越不同机器、操作 系统和编译器组合的可移植性,了解这种属性是非常重要的。后面我们会讲到,大量计算 机的安全漏洞都是由于计算机算术运算的微妙细节引发的。在早期,当人们碰巧触发了程 序漏洞,只会给人们带来一些不便,但是现在,有众多的黑客企图利用他们能找到的任何 漏洞,不经过授权就进入他人的系统。这就要求程序员有更多的责任和义务,去了解他们 的程序如何工作,以及如何被迫产生不良的行为。\n计算机用几种不同的二进制表示形式来编码数值。随着第 3 章进入机器级编程,你 需要熟悉这些 表示方式。在本章中, 我们描述这些编码,并 且 教 你 如 何 推出数字的表示。\n通过直接操作数字的位级表示,我们得到了几种进行算术运算的方式。理解这些技术对于理解编译器产生的机器级代码是很重要的,编译器会试图优化算术表达式求值的性能。\n我们对这部分内容的处理是基千一组核心的数学原理的。从编码的基本定义开始,然 后得出一些属性,例如可表示的数字的范围、它们的位级表示以及算术运算的属性。我们 相信从这样一个抽象的观点来分析这些内容,对你来说是很重要的,因为程序员需要对计 算机运算 与更为人熟悉的整数和实数运算之间的关系有清晰的理解。\nm 怎样阅读本章\n本章我们研究在计算机上如何表示数宇和其他形式数据的基本属性,以及计算机对 这些数 据执行操作的属性。这就要求我们深入研究数 学语 言, 编写公 式和方程式,以 及展 示重要 属性的推导。\n为了帮助你阅读,这部分内容安排如下:首先给出以数学形式表示的属性,作为原 理。然后 ,用 例子和非形式化的讨论来解释这个原 理。我们建议你反复阅读原理描述和它的 示例 与讨 论, 直到你对该属性的说明内容及其重要 性 有了 牢固的 直觉。 对于更 加复杂的属性, 还会提供推导, 其结构看上去将会像 一个数 学证 明。虽 然最终你应该尝试理解这些推 导,但 在第一次阅读时你 可以跳过它们 。\n我们也鼓 励你在阅读正文的过程中完成练习题 , 这会促使你主动学习, 帮助你理 论联系实际 。有了这些例 题和练习题作 为背景知 识, 再返回推导, 你将发现理解起来会容易许多。 同时,请放 心, 掌握好高中代数知识的 人都具备理解这些内容 所需要的数学技 能。\nC++ 编程语言建立在 C 语言基础之上, 它 们使用完全相同的数字表示和运算。本章中关于 C 的所有内容对 C++ 都有效。另一方面, Java 语 言 创 造了一套新的数字表示和运算标准。C 标准的设计允许多种实现方式, 而 Java 标准在数据的格式和编码上是非常精确具体的。本章中多处着重介绍了 Java 支持的表示和运算。\n豆日C 编程语言 的演变\n前面提 到过, C 编程 语言是 贝 尔 实验 室的 Dennis Ritch ie 最早开发 出 来的, 目的是 和 U nix 操作系统 一起使用 ( U nix 也是 贝 尔实 验室开 发的)。在那个时候 , 大多数 系统程序, 例如操作 系统 , 为 了访问不同数 据类型的低级表示, 都必须 大量地使 用 汇编代码。比如说,像 malloc 库函数提供的内存 分配功能, 用当 时的其他 高级 语 言是无法编 写的 。\nBrian Kern ighan 和 Dennis Ritchie 的著作的第 1 版 [ 60] 记录了 最初贝 尔 实验 室的 C\n语言版本。随着时间的推移, 经过 多个标准化 组织的努力 , C 语 言也在不断地演变。 1989\n年, 美国 国 家标 准学会 下的一个工作组推出 了 A NSI C 标准, 对最初的贝 尔 实验室的 C 语言做 了重 大修 改。ANSI C 与贝 尔 实验室的 C 有了很 大的不同, 尤其是 函数声 明的方式。Brian Kern ig han 和 Dennis Rit chie 在著作的第 2 版[ 61] 中描述了 A NSI C, 这本书至今仍被公认为关于 C 语言最好的参考手册之一。\n国际标 准化 组织接 替 了对 C 语言进行标准化的任务, 在 1990 年推出 了一个几乎和ANSI C 一样的版本,称 为 \u0026quot; ISO C90\u0026quot;。该组织在 1999 年又对 C 语言做 了 更新, 推出\u0026quot; ISO C99\u0026quot; 。在这一版本中, 引入了 一些新的数据类型, 对使用不符合英语语言宇符 的文本字符 串提 供 了 支持。更新的 版本 2011 年得到批准, 称为 \u0026quot; ISO Cll\u0026quot;, 其中再次添加了更多的数据类型和特性。最近增加的大多数内容都可以向后兼容,这意味着根据早 期标准(至少可以回 溯到 ISO C90 ) 编写的 程序按新标准编译时会有同样的行为。\nGNU 编译 器 套 装 ( G NU Comp iler Collec­ tion, GCC) 可以基 于 不 同 的命令行 选项,依 照多 个不 同版本的 C 语言规则来编译程序,如 图 2-\n1 所示。比如,根 据 ISO Cl l 来编译程序 pr og .\nc, 我们就使用命令行:\nlinux\u0026gt; gee -s t d=e11 rp\nog . e\n图 2-1 向 GCC指定不同的 C 语言版本\n编译选项- a ns i 和- s t d=c89 的 用 法是 一样的一一会根据 A NSI 或者 ISO C90 标准来编译程序。( C90 有时也称为 \u0026quot; C89\u0026quot; , 这是因为 它的 标准化 工作 是从 1989 年开始的。)编译选项- s t d =c 99 会让编译器按 照 ISO C99 的规则进行 编译。\n本书 中,没 有指定任何编译选项时,程 序会按照基于 IS O C90 的 C 语言版本进行编 译,但 是 也 包括 一些 C99、Cll 的特性, 一些 C+ + 的特性, 还 有 一些是与 GCC 相关的 特性。GNU 项目正在开发一个结合 了 ISO Cl l 和其他一些特 性的版本, 可以通过命令行选项- s t d=gnull 来指定。(目前,这 个实现 还未完成。今)后, 这个版本会成为默 认的 版本。\n2. 1 信息存储\n大多数计算机使用 8 位的块, 或者宇节 ( byte ) , 作为最小的可寻址的内存单位, 而不是访问内存中单独的位。机器级程序将内存视为一个非常大的字节数组,称 为虚拟内存( virt ua l memo ry) 。内存 的 每个字节都由 一个唯一的数 字来标识, 称为它的 地址 C ad­ dr ess ) , 所有可能地址的集合就称为虚拟地址空 间 ( vir t ual add ress space) 。顾名思义, 这个 虚拟地址空间只是一个展现给机器级程序的概念性映像。实际的实现(见第 9 章)是将动态随机访问存储器( DRAM ) 、闪存、磁盘存储器、特殊硬件和操作系统软件结合起来, 为程序提供一个看上去统一的字节数组。\n在接下来的几章中, 我们将讲述编译器和运行时系统是如何将存储器空间划分 为更可管理的单元,来 存 放不同的程序对象( progra m object) , 即程序数据、指令和控制信息。可以用各种机制来分配和管理程序不同部分的存储。这种管理完全是在虚拟地址空间里完 成的。例如, C 语 言 中一个指针的值(无论它指向一个整数、一个结构或是某个其他程序对象)都是某个存储块的第一个字节的虚拟地址。C 编译器还把每个指针和类型信息联系起来, 这样就可以根据指针值的类型, 生成不同的机器级代码来访问存储在指针所指向位置处的值。尽管 C 编译器维护着这个类型信息, 但是它生成的实际机器级程序并不包含关于数据类型的信息。每个程序对象可以简单地视为一个字节块, 而程序本身就是一个字节序列。\n区 日 C 语言中指针的作用\n指针是 C 语言的 一个重要 特性。它提 供 了 引用数 据 结构(包括 数 组)的元素的机制。与变量类似,指针也有两个方面:值和类型。它的值表示某个对象的位置,而它的类型 表示那个位 置上 所存储对象的类型(比如整数或者浮点 数)。\n真正理解指针需要查看它们在机器级 上的表示以 及 实现。这将是 第 3 章 的 重点之一, 3. 10. 1 节将 对其进行深入介绍。\n1. 1 十六进制表示法\n一个字节由 8 位组成。在二进制表示法中,它 的 值 域是 00000000 2 ~ 111111112 。 如果看成十进制整数 ,它的 值域就是 010 ~ 25510。 两种符号表示法对于描述位模式来说都不是非常方便。二进制表示 法太冗长, 而十进制表示法与位模式的互相转化很麻烦。替代的方法是, 以 16 为基数 ,或 者叫做十六进制 ( hexadecimal) 数 , 来表示位模式。十六进制(简写为 \u0026quot; hex\u0026quot; ) 使用数字 \u0026rsquo; O\u0026rsquo; ~ \u0026rsquo; 9 \u0026rsquo; 以及字符 \u0026rsquo; A \u0026rsquo; ~ \u0026rsquo; F \u0026rsquo; 来表示 16 个可能的值。图 2- 2 展示 了 1 6 个十六进制数字对应的 十进制值和二进制值。用十六进制书写,一 个字节的值域为 0016 ~FF16 o\n图 2- 2 十六进制表示法。每个十六进制数字都对 16 个值中的一个进行了编码\n在 C 语言中,以 Ox 或 OX 开 头 的 数 字常量被认为是十六进制的值。字符 \u0026rsquo; A \u0026rsquo; ~ \u0026rsquo; F '\n既可以是大写,也 可以是小写。例如, 我们可以将数字 F A1 D37B16 写 作 Ox F A1 D37B, 或者\nOxfald37b, 甚至是大小写混合,比 如 , Ox Fa lD 3 7b 。 在本书中, 我们将使用 C 表示法来表示十六进制值。\n编写机器级程序的一个常见任务就是在位模式的十进制、二进制和十六进制表示之间 人工转换。二进制和十六进制之间的转换比较简单直接,因 为可以一次执行一个十六 进制数字的转换。数字的转换可以参考如图 2-2 所示的表。一个简单的窍门是, 记 住十六进制数字 A、C 和 F 相应的十进制值。而对千把十六进制值 B、D 和 E 转换成十进制值,则 可以通过计算它们与前三个值的相对关系来完成。\n比如, 假设给你一个数字 Ox l 7 3 A4C。 可以通过展开每个十六进制数字, 将它转换为二进制格式,如下所示:\n十六进制 A 二进制 0001 0111 0011 1010 0100 1100 这样就得到了二进制表示 0001 0111 0011101001 0011 00 。\n反过来,如果 给定一个二进制数字 111100101011 011 0110011 , 可以通过首先把它分为每 4 位一组来转换为十六进制。不过要注意, 如果位总数不是 4 的倍 数 , 最左边的一组可以少于 4 位,前 面用 0 补足。然后将每个 4 位组转换为相应的十六进制数字:\n二进制 11 1100 1010 1101 1011 0011 十六进制 3 A D B 练习题 2. 1 完成下面的数字转换:\n将 Ox 3 9A7F8 转换 为 二进 制。\nB. 将二进 制 11 00100101 111011 转换 为 十 六进 制。\nC. 将 Ox D5E4C 转换 为 二进 制。\nD. 将二进制 1001101110011110110101 转换 为 十 六进 制。\n当值 x 是 2 的非负整数 n 次幕时,也 就 是 x = 2飞 我们可以很容易 地将 x 写成十六进制形式,只 要记住 x 的二进制表示就是 1 后面跟 n 个 0。十六进制数字 0 代表 4 个二进制\n0。所以,当 n 表示成曰一句 的形式,其 中 O i 3 , 我们可以把 x 写成开头的十六进制数字为 l( i = O) 、 2( i = l ) 、4 ( i = 2 ) 或者 8 ( i = 3) , 后面跟随着]个十六进制的 0。比如, x = 2048 = 211\u0026rsquo; 我们有 n = ll = 3 + 4 • 2, 从而得到十六进制表示 Ox8 00 。\n练习题 2. 2 填写 下表中 的 空白 项, 给出 2 的不 同次 幕的二进制和十 六进 制表 示:\n十进制和十六进制表示之间的转换需要使用乘法或者除法来处理一般情况。将一个十 进制数字 x 转换为十六进制, 可以反复地用 16 除 x , 得到一个商 q 和一个余数 r , 也就是x=q• l6+ r。然后 , 我们用十六进制数字表示的r 作为最低位数字,并 且 通过对 q 反复进行这个过程得到剩下的数字。例如, 考虑十进制 314 156 的转换:\n314 156=19 634• 16+12 (C)\n19 634= 1227• 16+2 (2)\n1227= 76• 16+11 (B)\n76= 4• 16+12 CC)\n4= 0• 16 + 4 (4)\n从这里, 我们能读出十六进制表示为 Ox 4CB2C。\n反过来, 将一个十六进制数字转换为十进制数字, 我们可以用相应的 16 的 幕乘以每个十六进制数字。比如, 给定数字 Ox 7AF , 我们计算它对应的十进制值为 7 • 1 62 + 10 • 16+15=7• 256+10• 16+ 15 = 1792 + 160 + 1 5 = 1 967 。\n练习题 2. 3 一个 字 节 可以用 两个十 六进制 数 字来 表 示。 填写 下表 中缺 失的项, 给 出不同 字 节模 式的 十进 制、 二进制和 十 六进制值 :\n十。进制 二进制 0000 0000 十六进制 OxOO 167 62 188 00110111 1000 1000 1111 0011 Ox52 OxAC OxE7 m 十进 制和十六进制间的转换\n较大数值的 十进 制和 十六进 制之 间的 转换, 最好是让计算机或者计算器来完 成。有大量的工具可以 完成这 个工作 。一个简单 的方法就是 利用任 何标准的搜 索引 擎, 比如查询:\n把 Ox a b c d 转换为十进 制数\n或\n把 1 23 用十 六进 制表 示。\n练习题 2. 4 不 将数 字 转换 为 十进制或 者 二进制 , 试 着解答下 面 的 算 术题, 答 案 要用十六 进制表示。 提 示: 只要将执行 十进制加法和 减 法所使 用的方 法 改成以 1 6 为基数。\nOx 5 03c +Ox 8 =\nOx 5 03c - Ox 40 =\nC. Ox 5 03c +6 4=\nD. Ox 5 0e a - Ox 5 03c =\n1. 2 字数据大小\n每台计算 机都有一个宇长 ( w o rd size) , 指明指针数据的标称大小( no minal s ize ) 。因为虚拟地址是以这样的一个字来编码的,所以字长决定的最重要的系统参数就是虚拟地址空\n间的最大大小。也就是说, 对于一个字长为 w 位的机器而言 , 虚拟地址的范围为 0 ~ w2\n程序最多 访问 沪 个字节。\n- l ,\n最近这些年,出现了 大规模的从32 位字长机器到 64 位字长机器的迁移。这种情况首先出现在为大型科学和数据库应用设计的高端机器上,之后是台式机和笔记本电脑,最近则出现在 智能手机的处理器 上。32 位字长限制虚拟地址空间为 4 千兆字节(写作 4GB) , 也就是说,刚刚超过 4 X l 铲字节。扩展到 64 位字长使得虚拟地址空间为 16EB, 大约是 1. 84 X l 沪字节。\n大多数 64 位机器也可以运行为 32 位机器编译的程序, 这是一种向后兼容。因此,举例来说, 当程序 pr o g . c 用如下伪指令编译后\nli nux\u0026gt; g e e - m3 2 pr og . e\n该程序就可以在 32 位或 64 位机器上正确运行。另一方面, 若 程序用下述伪指令编译\n码的数字格式,如不同长度的整数和浮点\n示为 4 字节和 8 字节的浮点数。\nC 语言支持整数和浮点数的多种数据格式。图 2-3 展示了为 C 语言各种数据类\n图 2-3 基本 C 数据类型的典型大小(以字节为单位)。分配的字节数受程序是如何 编译的影响而变化。本图给出的是 32 位和 64 位程序的典型值\n型分配的字节数。(我们在 2. 2 节讨论 C 标准保证的字节数 和典型的字节数 之间的关系。) 有些数据类型的 确切字节数依赖于程序是 如何被编译 的。我们给出的是 32 位和 64 位程序的典型值。整数或者为有符号的,即可以表示负数、零和正数;或者为无符号的,即只能 表示非负数 。C 的数据类型 c ha r 表示一个单独的字节。尽管 \u0026quot; cha r\u0026quot; 是由于它被用来存储文本串中的单 个字符这一事 实而得名, 但它也能被用来存储整数值。数据类型 s hor t 、i n t 和 l o ng 可以提供各种数据大小 。即使是为 64 位系统编译, 数据类型 l 阰 通常也只有\n4 个字节。数 据类型 l o ng 一般在 32 位程序中为 4 字节, 在 64 位程序中则 为 8 字节。\n为了避免由于依赖 "典型” 大小和不同 编译器设置带来的奇怪行为, IS O C99 引入了一类数据类型,其数据大小是固定的,不随编译器和机器设置而变化。其中就有数据类型 int32 t 和 i n t 64 t, 它们分别为 4 个字节和 8 个字节。使用确定大小的整数类型是 程序员准确控制数据表示的最佳途径。\n大部分数据类型都编码为有符号数值 , 除非有前 缀关键字 uns i g ne d 或对确定大小的数据类型使 用了特定的无符号声明 。数据类型 c h a r 是一个例外。尽管大多数编译器和机器将它们视为有符号数 , 但 C 标准不保证这一点 。相反, 正如方括号指示的那 样,程序 员应该用有符号字符的声明来保证其为一个字节的有符号数值。不过,在很多情况下,程序 行为对数据类型 c har 是有符号的还是无符号的并不 敏感。\n对关键字的顺序以 及包括还是省略可选关键字来说, C 语 言 允 许 存在多种形式。比如,下面所有的声明都是一个意思:\nunsigned long unsigned long int long unsigned long unsigned int\n我们将始终使 用图 2-3 给出的格式。\n图 2-3 还展示了指针(例如 一个被声明为类 型为 \u0026quot; c h ar * \u0026ldquo;的变量)使用程序的全字长。大多数机器还支持两 种不同的浮点 数格式: 单精度(在C 中声明为 fl o a t ) 和双精度\n(在 C 中声明为 d o ub l e ) 。这些格式分别使用 4 字节和 8 字节。\n声明指针\n对于任何 数据类型 T , 声明\nT *p;\n表明 p 是一个指针 变量,指 向一个类型 为 T 的对象。例如 ,\nchar *p;\n就将一个指针 声明 为指 向一个 c h ar 类型的 对象。\n程序员应该力图使他们的程序在不同的机器和编译器上可移植。可移植性的一个方面就是使程序对不同数据类型的确切大小不敏感。C 语言标准对不同数据类型的数字范围设置了下界(这点在后面还将讲到), 但是却没有上界。因为从 1980 年左右到 2010 年左右, 3 2 位机\n器和 32 位程序是主流的组合, 许多程序的编写都假设为图 2- 3 中 32 位程序的字节分 配。随着 64 位机器的日益普及, 在将这些 程序移植到新机 器上时 , 许多隐藏的对字长的 依赖性就会显现出来, 成为错误。比如,许多程序员假设一个声明为i nt 类型的程序对象能被用来存储一个指针。这在大多数32 位的机鞋上能正常工作, 但是在一台64 位的机器上却会导致问题。\n1. 3 寻址和字节顺序\n对于跨越多字节的程序对象,我们必须建立两个规则:这个对象的地址是什么,以及 在内存中如何排列这些字节。在几乎所有的机器上,多字节对象都被存储为连续的字节序 列, 对象的地址为所使用字节中最小的地址。例如,假设 一个类型为 1 止 的变量 x 的地址为 Ox lO O, 也就是说,地 址 表达式 \u0026amp;x 的 值为 Ox l OO 。那 么 ,(假设数据类型 i n t 为 32 位表示)x 的 4 个字节将被存储在内存的 Ox l OO、 Ox l Ol 、 Ox 1 02 和 Ox 1 0 3 位置。\n排列表示一个对象的字节有两个通用的规则。考虑一个 w 位的整数,其 位表示为[ x..,,- 1\u0026rsquo; X ,.,- 2\u0026rsquo; … , X1, X。J\u0026rsquo; 其 中 X w- 1 是最高有效位, 而 x。是最低有效位。假设 w 是 8 的倍数,这些位就能 被分组成为字 节,其 中最 高 有效字节包含位[ x心 一 I • X..,,- 2 • … , x正 s J\u0026rsquo; 而最低有效\n字节包含位 [ x1\u0026rsquo; Xs\u0026rsquo; … , x 。], 其他字节包含中间的位。某些机器选择在内存中按照从最低 有效字节到最高有效字节的顺序存储对象,而另一些机器则按照从最高有效字节到最低有效 字节的顺 序存储。前一种规则- 最低有效字节在最前面的方式 ,称 为小端 法( little en dian)。后一种规则 —— 最高有效字节在最前面的方式,称 为大端 法( big endian) 。\n假设变量 x 的类型为 i n t , 位于地址 Ox l OO 处 ,它 的 十六进制值为 Ox 01 2 3 45 67。地址范围 Ox l OO~ Ox1 0 3 的 字 节顺序依赖于机器的类型:\n\u0026ndash;壺 # 小端法\nOxlOO OxlOl Ox1 0 2 Ox103\n注意,在字 Ox 01 23 45 67 中, 高位字节的十六进制值 为 Ox Ol , 而低位字节值为 Ox 67 。\n大多数 Intel 兼容机都只用小端模式。另一方面, IB M 和 Oracle ( 从 其 201 0 年收购Sun Microsys tems 开始)的大多数机器则是按大端模式操作。注意我们说的是“大多数”。这些规则并 没有严格按照企业界限来划分。比如, IBM 和 Oracle 制造的个人计算机使用的是 Intel 兼容的处理器,因 此 使 用 小 端法。许多比较新的微处理器是双端 法 ( bi-endian) , 也就是说可以把它们配置成作为大端或者小端的机器运行。然而, 实际情况是: 一 旦 选择了特定操作 系统,那 么 字节顺序也就固定下来。比如,用 于 许 多 移动电话的 AR M 微处理器,其硬件可以按小端或大端两种模式操作,但是这些芯片上最常见的两种操作系统——\nAnd roid( 来自 Google) 和 IOS (来自 Apple) 却只能运行于小端模式。\n令人吃惊的是,在哪种字节顺序是合适的这个问题上,人们表现得非常情绪化。实际上,术语 \u0026quot; lit tle endian(小端)” 和 \u0026quot; big endian( 大端)” 出 自 Jo nat han Swift 的《格 利 佛 游 记》(Gulliver \u0026rsquo; s T ravels)一书, 其 中 交战的两个派别无法就应该从哪一端(小端还是大端) 打开一个半熟的鸡蛋达成一致。就像鸡蛋的问题一样,选择何种字节顺序没有技术上的理 由,因此争论沦为关千社会政治论题的争论。只要选择了一种规则并且始终如一地坚持, 对于哪种字节排序的选择都是任意的。\n田日 ”端”的起源\n以下是 J on at han Swift 在 1 72 6 年关于大小端之 争历史的 描述:\n"……我下 面要 告诉你的是 , L ill ip ut 和 Blefu sc u 这两 大强国 在过去 36 个 月里 一直在苦战。战争开始是 由于以下的原 因: 我们大家都认为 , 吃鸡蛋前, 原始的 方 法是 打破鸡蛋较大的一端,可是当今皇帝的祖父小时候吃鸡蛋,一次按古法打鸡蛋时碰巧将一个 手指弄破了,因此他的父亲,当时的皇帝,就下了一道敕令,命令全体臣民吃鸡蛋时打 破鸡蛋较小的 一端, 违令者重罚。老百姓们 对这项命 令极为反感。 历 史告 诉我 们, 由此曾发 生过 六次叛 乱, 其中一 个皇帝送 了命, 另一 个丢了王位。这些叛 乱大多都是由 Ble­ fu s cu 的国王大臣们 煽动起 来的。叛乱平息后 , 流亡的人 总是逃到 那个 帝国 去寻救避难。据估计, 先后几次有 11 000 人情愿受死也 不肯去打破 鸡蛋 较小的 一端。 关 于这一 争端, 曾出版过几百本大部著作,不过大端派的书一直是受禁的,法律也规定该派的任何人不 得做官。”( 此段译文摘自网上 蒋剑锋 译的《格利佛 游记》第一 卷第 4 章。)\n在他那个时代 , S w ift 是在讽刺 英 国 C Lill ip ut ) 和法国 ( Blefu s cu ) 之间持续的 冲 突。\nDanny Cohen, 一位网络协议的早期开创者,笫一次使用这两个术语来指代字节顺序\n[24], 后来这 个术语被 广泛 接纳了 。\n对于大多数应用程序员来说,其机器所使用的字节顺序是完全不可见的。无论为哪种 类型的机器所编译的程序都会得到同样的结果。不过有时候,字节顺序会成为问题。首先 是在不同类型的机器之间通过网络传送二 进制数 据时, 一个常见 的问题是当小端法机器产生的数据被发送到大端法机器或者反过来时, 接收程序 会发现, 字里的字节成了反序的。为了避免这类问题,网络应用程序的代码编写必须遵守已建立的关于字节顺序的规则,以 确保发送方机器将它的内部表示转换成网络标准,而接收方机器则将网络标准转换为它的 内部表示。我们将在第 11 章中看到这种转换的 例子。\n第二种情况是,当阅读表示整数数据的字节序列时字节顺序也很重要。这通常发生在检查机器级程序时。作为一个示例,从某个文件中摘出了下面这行代码,该文件给出了一个针对 In t el x8 6-64 处理器的机器级代码的文本表示:\n4004d3: 01 05 43 Ob 20 00 add %eax,Ox200b43(%rip)\n这一行是由反汇编 器( d isas s em bler ) 生成 的, 反汇编器是一种确定 可执行程序文件所表示的指 令序列 的工具。我们将 在第 3 章中学习有关 这些工具的更多知识,以 及怎样解释像这样的行。而现在,我们只 是注意这行表述的 意思是: 十六进制字 节串 01 05 43 Ob 20 00 是一条指令的字节级表示,这条指令是把一个字长的数据加到一个值上,该值的存储地址由 Ox 2 00b 43 加上当前 程序计数 器的 值得到, 当前程序计数器的值即为下 一条将要执行指令的地址。如果取出这个序列的最后 4 个字节: 43 Ob 20 00, 并且按照相反的顺序写出,我\n们得到 0 0 20 Ob 43。去掉开头的 o, 得到值 Ox 2 00b 43 , 这就是右边的数值。当阅读像此\n类小端法机器生成的机器级程序表示时,经常会将字节按照相反的顺序显示。书写字节序列的自然方式是最低位字节在左边,而最高位字节在右边,这正好和通常书写数字时最高有效位在左边,最低有效位在右边的方式相反。\n字节顺序变得重要的第三种情况是当编写规避正常的类型系统的程序时。在 C 语言中, 可以通过使用强制 类型 转换 ( ca s t ) 或联合( unio n ) 来允许以一种数据类型引用一个对象,而这种数据类型与创建这个对象时定义的数据类型不同。大多数应用编程都强烈不推 荐这种编码技巧,但是它们对系统级编程来说是非常有用,甚至是必需的。\n图 2-4 展示了一段 C 代码,它 使用强制类型转换 来访问和打印不同程序对象的 字节表示。我们用 t y pe d e f 将数据类型 b yt e _ p o i n t er 定义为一个指向类型为 \u0026quot; u n s i g ne d\ncha r\u0026rdquo; 的对象的指针。这样一个字节指针引用一个字节序列 , 其中每个字节都被认为是一个非负整数 。第一个例程 s h o w_ b y t e s 的 输入是一个字节序列的地址 ,它 用 一个字节指针以及一个字节数 来指示。该字节数指定为数据类型 s i ze —七, 表示数据结构大小的首选数据类型。s how _ b yt e s 打印出每个以 十六 进制表示的字节。C 格式化指令 \u0026quot; %. 2x\u0026quot; 表明整数必须用至 少两个数字的十六进 制格式输出。\n1 #include \u0026lt;s t di o . h\u0026gt;\n2\n3 typedef unsigned char *byte_pointer;\n4\n5 void show_bytes(byte_pointer start, size_t len) {\n6 s. 1ze_t 1;\n7 for (i = O; i \u0026lt; len; i++)\ns printf (\u0026quot; %. 2x\u0026quot;, start [i]) ;\n9 printf(\u0026quot;\\n\u0026quot;);\n10 }\n11\nvoid show_int(int x) {\nshow_bytes((byte_pointer) \u0026amp;x, sizeof(int));\n14 }\n15\nvoid show_float(float x) {\nshow_bytes((byte_pointer) \u0026amp;x, sizeof(float));\n18 }\n19\nvoid show_pointer(void *x) {\nshow_bytes ((byte_pointer) \u0026amp;x, sizeof (void *));\n22 }\n图2- 4 打印程序对象的字节表示。这段代码使用强制类型转换来规避类型系统。很容易定义针对其他数 据类型的类似函数\n过程 s h o w_ i n t 、 s ho w_ f l o 扛 和 s ho w_p o i n t er 展示了如何使用程序 s ho w_b yt e s 来分别输出类型为 i n t 、f l o 红 和 v o i d * 的 C 程序对象的字节表示。可以 观察到它们仅仅传递给 s ho w—b yt e s 一个指向它们参数 x 的指针 \u0026amp;x , 且这个指针被强制类型转换为 \u0026quot; u n ­ signed char * \u0026ldquo;。这种强制类型转换 告诉编译器, 程序应该把这个指针看成指向一个字节序列,而不是指向一个原始数据类型的对象。然后,这个指针会被看成是对象使用的最 低字节地址。\n这些过程使用 C 语言的运算符 s i ze o f 来确定对象使用的字节数。一般来说 , 表达式sizeof (T ) 返回存储一个类型为 T 的对象所需要的字节数。使用 s i ze o f 而不是一个固定的值,是向编写在不同机器类型上可移植的代码迈进了一步。\n在几种 不同的机器上运行如图 2-5 所示的代码, 得到如图 2- 6 所示的 结果。我们使用了以下几种机器:\nLinux 32: 运行 Lin ux 的 In tel IA 32 处理器。\nWindows: 运行 Window s 的 I nt el IA 32 处理器。\nSun: 运行 Solaris 的 Sun Microsystems SPARC 处理器。(这些机器现在由Oracle 生产。)\nLinux 64: 运行 Lin ux 的 In tel x86 - 64 处理器。\nvoid test_show_bytes(int val) {\nint ival = val; float fval = (float) ival; int *pval = \u0026amp;ival; show_int(ival); show_float(fval); show_pointer(pval); 8 }\ncode/data/show-bytes.c\ncode/data/show-bytes.c\n图 2-5 字节表示的示例。这段代码打印示例数据对象的 字节表示\n图2-6 不同数据值的字节表示。除了字节顺序以外 , i nt 和 fl oa t 的结果是一样的。指针值与机器相关参 数 1 2 3 45 的 十六进制表示为 Ox 0 0 00 3 0 3 9 。 对 千 i n t 类型的数据,除 了 字 节 顺 序以\n外, 我们在所有机器上都得到相同的结果。特别地, 我们可以看到在 L in u x 3 2、W in dow s 和 L in u x 64 上,最 低 有效 字 节值 Ox 3 9 最 先 输 出 , 这说明它们是小端法机器; 而 在 S u n 上最后输出,这 说明 S u n 是 大 端 法 机 器 。 同 样 地 , f l o a t 数 据 的 字 节 ,除 了 字 节 顺 序 以 外 , 也 都 是 相 同 的 。 另 一 方 面,指 针值却是完全不同的。不同的机器/操作系统 配置使用不同的存储分配规则。一个值得注意的特性是 L in u x 3 2、W i nd o w s 和 S u n 的机器使用 4 字节地址,而 L i n u x 64 使 用 8 字节地址。\n使用t ype de f 来命名数据类型\nC 语言中的 t y p e d e f 声明 提供了一种给数据类型命名的方式。这能够极 大地 改善代码的可读性,因为深度嵌套的类型声明很难读懂。\nt yp e d e f 的语法与 声明 变量的 语法十分相像 ,除 了它使 用的 是类型名, 而不是 变量名。因此, 图 2- 4 中 b y 七e _ p o i n t er 的声 明和将一个变量声 明 为 类型 \u0026quot; u n s i g n e d char\n* \u0026ldquo;有相同的形式。\n例如,声明:\ntypedef int•int_pointer; int_pointer ip;\n将类型 \u0026quot; i n t _ p o i n t er \u0026quot; 定义为 一个指向 i n t 的指针, 并且声明 了一 个这种类型的变量 i p 。我们还可以将这个变量 直接 声明 为:\nint *ip;\n一 使用 pr i n t f 格式化输出\np r i n t f 函数(还有它的 同 类 f p r i n t f 和 s pr i n t f ) 提供 了一 种 打 印 信 息 的 方式, 这 种方式对格式化细节有相 当 大 的 控 制能力。 第 一 个 参 数 是 格 式 串 ( fo r m a t string), 而其余的参数都是要打印 的值 。在 格 式 串 里 , 每 个 以 \u0026quot; %\u0026rdquo; 开始的 宇符序 列 都 表 示如何格 式化下一个参数。典型的示例 包括 : \u0026rsquo; %ct\u0026rsquo; 是 输 出一 个十进制整数, \u0026rsquo; %f \u0026rsquo; 是 输 出一 个 浮点数, 而 \u0026rsquo; %c \u0026rsquo; 是 轮 出一个宇符 , 其编码由参数给出。\n指定确定大小数据 类型的格式, 如 i n 七3 2 _ t , 要 更 复 杂 一 些, 相 关内容参 见 2. 2. 3\n节的 旁注。\n可以观察到, 尽 管 浮点 型 和整 型数 据都 是对 数 值 1 2 345 编 码 , 但 是 它 们 有 截 然 不 同 的字节模 式 : 整 型 为 Ox 0 0 0 03 03 9 , 而 浮 点 数 为 Ox 4 64 0E 4 0 0。 一 般 而言 , 这 两 种 格 式 使 用 不同的 编 码方法。如果我们将这些 十六 进制模式扩展为二进制形式, 并 且 适 当 地 将 它 们 移位, 就会发 现一 个 有 1 3 个 相 匹 配 的 位 的 序 列 , 用一 串 星号 标识 出来 :\n0 0 0 0 3 0 3 9\n0000000000000000001100000011 1 001\n**** * ********\n4 6 4 0 E 4 0 0\n01000110010000001110010000000000\n这并不是巧合。当我们研究浮点数格式时, 还 将 再 回 到 这 个 例 子 。\n凶 ii1 指 针 和 数 组\n在 函数 s ho w b y t e s ( 图 2-4) 中, 我们看到指针和数组之间 紧密的 联 系, 这 将 在 3. 8 节中详 细描述。这个函数有一个类型 为 b y t e _p o i n t er ( 被 定 义 为一 个指 向 u n s i g ne d c ha r 的指针)的参数 s t ar t , 但是我们在 第 8 行 上 看到数 组引用 s t ar t [ i ] 。在 C 语 言 中, 我 们能够用数组表示法来引用指针,同时我们也能用指针表示法来引用数组元素。在这个例子 中·, 引用 s t a r t [ i ] 表 示我们想要读取以 s t ar t 指向的位置为起 始的 第 i 个位置处的 字节。\nJI 指 针 的 创 建 产 间接引 用\n在图 2-4 的第 1 3、1 7 和 21 行 , 我 们看到对 C 和 C++ 中两种 独 有 操 作 的 使 用。 C 的\n”取 地 址” 运 算 符 & 创建一个指针。在这三行中,表 达 式 \u0026amp;x 创建了 一 个指向保存 变量 x 的位置的 指针。这 个指 针 的 类型取 决 于 x 的 类型, 因 此 这 三 个指 针 的 类 型 分 别 为 i n t *、fl oa t *和 v o i d ** 。(数据类型 vo i d *是一种特殊类型的指针, 没有相 关联的 类型信息。)\n强制类型 转换 运 算 符 可以 将 一 种数 据 类 型 转换 为 另 一 种 。 因 此 , 强 制 类 型 转 换( b y t e主 o i n t e r ) \u0026amp;x 表 明 无 论 指 针 \u0026amp;x 以 前 是 什 么类型, 它现 在 就是 一 个指 向 数 据 类型为 u n s i g n e d c h ar 的 指 针 。 这 里 给 出的 这 些强 制类型转换不会 改 变 真 实的 指 针 , 它们只是告诉编译器以新的数据类型来看待被指向的数据。\nm 生成一张 ASCII 表\n可以 通过执行命令 ma n a s c 江 来得 到一张 ASCII 宇符码的表。\n练习题 2. 5 思考下面对 s h o w_ b y 七e s 的 三次调用:\nint v a l = Ox87654321;\nbyte_pointer valp = (byt e _po i nt er ) \u0026amp;v al ;\nshow_bytes(valp, 1); I* A. *I show_bytes(valp, 2); I* B. *I show_bytes(valp, 3); I* C. *I\n指出在小端法机器和大端法机器上,每次调用的输出值。\n小端法: 大端法: 小端法: 小端法:\n大端法: 大端法:\n练习题 2. 6 使用 s h o w_ i n t 和 s h o w_ f l o a t , 我们确定整数 3510593 的十 六进 制表 示为 Ox 0035 9141 , 而浮 点数 351 05 93 . 0 的 十 六进制表 示为 Ox 4A5645 04。\n写出这两个十六进制值的二进制表示。 移动这两个二进制串的相对位置,使得它们相匹配的位数最多。有多少位相匹配呢? 串中的什么部分不相匹配? 2. 1. 4 表示字符串\nC 语言中字符串被编码为一个以 null ( 其值为 0 ) 字符结尾的字符数组。每个字符都由某个标准编码来表示,最 常 见 的 是 ASCII 字符码。因此, 如果我们以参数 \u0026quot; 12345\u0026rdquo; 和 6\n(包括终止符)来运行例程 s h ow_bytes, 我们得到结果 31 32 33 34 35 00。请注意, 十进制数字 x 的 ASCII 码正好是 Ox3x , 而 终 止 字节的十六进制表示为 Ox OO。 在 使 用 ASCII 码作为字符码的任何系统上都将得到相同的结果,与字节顺序和字大小规则无关。因而,文本数据比二进制数据具有更强的平台独立性。\n_.练习题 2. 7 下 面对 s ho w_b yt e s 的调用将输出什 么结 果?\nconst char *B = \u0026ldquo;abcdef\u0026rdquo;;\n, show_bytes ((byte_pointer) s, strlen (s)) ;\n注意字母 \u0026rsquo; a \u0026rsquo; \u0026rsquo; z \u0026rsquo; 的 ASCII 码为 Ox 6l Ox 7A 。\n因 日 文字编码的 Un icode 标 准\nASCII 字符 集适合 于编码 英语 文档,但 是 在表达一些特殊宇符 方 面并 没有太多 办法, 例如法语的 \u0026ldquo;C\u0026rdquo;。 它完全不适合 编码希腊语、俄语和中文等语言的文档。这些年,提 出了很 多方 法来对不同语 言的文字进行 编码。Unicod e 联 合 会 ( U ni code Co nsorti um) 修订了 最全面且 广泛接 受的文字编码 标准。当前的 Unicode 标准( 7. 0 版)的宇库 包括 将近 100 000 个字符, 支持广泛的 语言种类, 包括古埃及和巴比伦的语言。为 了保 持 信 用, U nicode 技 术委员会 否决了为 K ling on( 即电视 连续剧《星际迷航 》中的虚构文明)编写语 言标准的提 议。\n基本编码, 称为 U nicode 的“统一字符集“,使 用 32 位 来表示字符 。这好像要求文本串中每 个宇符要占用 4 个宇节。 不过 , 可以有一些替代编码, 常见的宇符只需要 1 个或 2 个字节, 而不太常用的 字符 需要多一些的 字节数 。特别地, U T F-8 表 示将每个字符 编码为一 个字节序 列, 这样标准 ASCII 字符还是使 用和它们在 ASCII 中一样的单宇 节编码,这 也 就 意味 着所 有的 ASCII 宇节序 列用 ASCII 码表示和 用 U T F-8 表 示是 一样的。\nJava 编程语言使用 U nicod e 来表示字符 串。 对于 C 语言也有支持 U nicode 的程序库。\n2. 1. 5 表示代码\n考虑下面的 C 函数:\n1 int swn(int x, int y) {\nreturn x + y;\n当我们在示例机器上编译时,生成如下字节表示的机器代码:\nLinux 32 55 89 e5 8b 45 Oc 03 45 08 c9 c3\nWindows 55 89 e5 8b 45 Oc 03 45 08 5d c3\nSun 81 c3 eO 08 90 02 00 09\nLinux 64 55 48 89 e5 89 7d fc 89 75 f8 03 45 fc c9 c3\n我们发现指令编码是不同的。不同的机器类型使用不同的且不兼容的指令和编码方 式。即使是完 全一样的进程, 运行在不同的操作系统上也会有不同的编码规则, 因此二进制代码是 不兼容的。二进制代码很少能在不同 机器和操作系统组合之间移植。\n计算机系统的 一个基本概念就是, 从机器的角度来看, 程序仅仅只是 字节序列。机器没有关千原 始源程序的 任何信息, 除了可能有些用 来帮助调试的辅助表以外。在第 3 章学习机器级编程时 ,我 们将更清楚地看到这一点 。\n1. 6 布尔代数简介\n二进制值是计算 机编码、存储 和操作信息的核心,所以围绕数值 0 和 1 的研究已经演化出了丰富的数学知识 体系。这起源于 18 50 年前后乔治·布尔 ( George Boole, 1815—18 64 ) 的\n工作, 因此也称为布 尔代数 ( Boolean algebra ) 。布尔注意到通过将逻辑值 TRUE ( 真)和\nFALS E ( 假)编码为二进制值 1 和 o, 能够设计出一种代数,以研究逻辑推理的基本原则。最简单的布尔代数是在二元集合{0, 1 }\n基础上的定 义。图 2- 7 定义了这种布尔代数 0 I\n中的几种运算 。我们用 来表示这些运算的符\n号与 C 语言位级运算使用的符号是相匹配的, 这些将在后 面讨论到。布尔运算 ~ 对应于逻辑运 算 NOT , 在命题逻辑中用符号\u0026ndash;,\n表示。也 就是说, 当 P 不是真的时候, 我\n图 2-7 布尔代数的运算 。二进制值 1 和 0 表示逻辑值 T RUE 或者 FALSE , 而运 算符\n~ 、&、I 和^分别表示逻辑运算 NOT 、\nAND、OR 和 EXCLUSIVE-OR\n们就说\u0026ndash;ip 是真的,反 之亦然。相应地 ,当 P 等于0 时, - P 等于 1, 反之亦然。布尔运算\n& 对应于逻辑运算 AND , 在命题逻辑中 用符号I\\ 表示。当 P 和Q 都为真时,我 们说 p I\\\nQ 为真。相 应地,只 有当 p = l 且 q = l 时, p \u0026amp;.q 才等于 1。布尔运算 1 对应于逻辑运算\nOR, 在命题 逻辑中用符 号 V 表示。当 P 或者 Q 为真时, 我们说 P V Q 成立。相应地, 当p= l 或者 q= l 时, p lq 等于 1。布尔运算^对应于逻辑 运算 异或, 在命题逻辑中用符号令表示。 当 P 或者Q 为真但不同 时为真时 ,我们说 P 令Q 成立。相应地 , 当 p = l 且 q = O, 或者 p = O 且 q = l 时, p Aq 等千 1。\n后来创立信息论领域的 C la ud e S ha nno n0 916- 2001 ) 首先建立了布尔代数和数字逻辑之间的联 系。他在 1 937 年的硕士论文中表明了布尔代数可以 用来设计和分 析机电继电器网络。尽管那时计算机技术已经取得了相当的发展, 但是布尔代数仍然在数字系统的设计和分析中扮演着重要的角色。\n我们可以将上述 4 个布尔运算 扩展到位向 量的运算,位 向量就是固定长度为 w 、由 0\n和 1 组成的串。位向量的 运算可以定 义成参数的每个对应元素之间的运算。假设 a 和b 分\n别表示位向量[ a w- 1 • a w- 2 • …, a。] 和[ b.,.- 1 , bw- 2 , …, b。]。我们将 a \u0026amp; b 也定义为一个 长度为 w 的位向量, 其中第 1 个元素等于a ;\u0026amp; b; , O i \u0026lt; w 。可以用类似的方式将运算 I 、^\n和~扩展到位向量上。\n举个例子, 假设 w = 4 , 参数 a = [0110], b= [1100]。那么 4 种运算 a \u0026amp; b、a l b 、a A b\n和- b 分别得到以下结果: ,\n0110\n\u0026amp; 1100\n0100\n0110\nI 1100\n1110\n0110\n- 1100\n1010\n一 11 00\n0011\n饬 练习题 2. 8 填写下表,给出位向量的布尔运算的求值结果。\n运算 结果 a b -a -b a\u0026amp;b a l b a l\\ b [01101001] [0101 0101] “ 关千布尔代数和布尔环的更多内容\n对于任 意整数 w \u0026gt; O, 长度 为 w 的位向量上的 布 尔运算 I 、& 和~ 形成了一 个布 尔\n代数。最简单 的情况是 w = l 时,只有 2 个元素;但 是对于更普 遍的情况,有 沪 个长度为 w 的位向量。布尔代数和整数算术运算有很 多相似之处。例如, 乘法对加 法的 分配律,写 为 a • (b+c)=(a• b)+(a• c), 而布 尔运算 & 对1 的分配律 ,写 为 a \u0026amp; ( b Jc ) = (a\u0026amp;b) I ( a \u0026amp; c) 。 此外,布 尔运 算1 对 & 也有分配律 ,写 为 a l (b\u0026amp;c)=(aJb)\u0026amp;(alc), 但是对于整数我 们不能说 a + ( b • c)=(a+b)• (a+ c)。\n当考 虑长度 为 w 的位向 量上 的^、&和~ 运算时, 会得到一种不同的 数学形 式, 我们称 为布 尔环( Boolea n r ing ) 。布 尔环与整数运算有很 多相同的 属性。例如,整 数运算的一个属性 是每个值 x 都有一个加 法逆元 ( addit ive inverse)-x, 使得 x + ( - x ) = O。 布\n尔环也有类似的属性,这里的“加法”运算是^,不过这时每个元素的加法逆元是它自\n己本 身。也 就是说, 对于任何值 a 来说 , a Aa = O, 这里我们用 0 来表 示全 0 的位向量。可以 看到 对单个位 来说这是成立的 , 即 O AO= l Al = O, 将这个扩展到位向量也是成立的。当我们重新排列组 合顺序,这 个属性也 仍然成 立,因此有 ( a Ab ) Aa = b。这个属性 会引起一些很有趣的结果和聪 明的技 巧,在 练习题 2. 10 中我们 会有所探 讨。\n位向量一个很有用的应用就是表示有 限集合。我们可以用位向量[ a 心 一 I \u0026rsquo; … , a1, a。] 编码任何子集 Ai;;;;;;;{o , 1, …, w - 1 } , 其中 a , = 1 当且仅当 i E A。例如(记住我们是把 a\u0026hellip;,- 1 写 在左边,而 将 a。写在右边), 位向量 a == [ 011 01001 ] 表示集合 A = { O, 3, 5, 6},而 b兰 [ 01010101] 表示集合 B = {O, 2, 4, 6 }。使用 这种编码集合的方法, 布尔运算 1 和\n& 分别对应千集合的并和交,而 ~ 对应于于集合的补。还是用前面那个例子, 运算 a \u0026amp; b\n得到位向量[ 01000001] , 而 A n B = {O, 6} 。\n在大量实际应用中,我 们都能看到用位向量来对集合编码。例如,在第 8 章,我 们会看到有很多不同的信号会中断程序执行。我们能够通过指定一个位向量掩码,有选择地使能或是屏蔽一些信号 , 其中某一位位置上为 1 时 , 表明信号 1 是有效的(使能), 而 0 表明该信号是被屏蔽的。因而,这个掩码表示的就是设置为有效信号的集合。\n练习题 2. 9 通过混合三种不同颜色的光(红色、绿色和蓝色),计算机可以在视频屏幕或者 液晶 显示器上 产 生 彩 色 的 画 面。 设想 一 种简 单的 方 法 , 使 用 三 种 不 同 颜色 的光,每种光都能打开或关闭,投射到玻璃屏幕上,如图所示:\n光源 玻璃屏幕\n那么基于光 源 R ( 红)、G ( 绿)、B ( 蓝)的关 闭 ( 0 ) 或打开 (1 )\u0026rsquo; 我们 就 能 够 创 建 8\n种不 同的颜色 :\n。 。 。 。 。 # 这些颜色中的每 一种都能用一个长度为 3 的位向量来表 示,我们可以对它们进行布尔运算。\n一种颜色的补是通过关掉打开的光源,且打开关闭的光源而形成的。那么上面列 出的 8 种颜色每一种的补是什 么? 描述下列颜色应用布尔运算的结果: 蓝色 l 绿色 黄色 红色 & 蓝绿色 红紫色 1. 7 C 语 言 中 的 位 级 运 算\nC 语言的 一个 很 有 用 的 特性 就 是 它 支待按位布尔运算。事实上, 我们在布尔运算中使用的那些符号就是 C 语言所使用的: I 就 是 O R ( 或),& 就 是 AND ( 与),~就 是 NOT ( 取\n反), 而^就是 EX CLUSIVE-OR ( 异或)。这 些 运算能运用到任何 “ 整型” 的 数 据 类型上, 包括图 2- 3 所示内容。以下是一些对 c h ar 数据类型表达式求值的例子:\nC 的 表达式 二进制表达式 二进制结果 十六进制结果 ~Ox41 - [0100 0001] [! Oll ll!O] OxBE ~OxOO - [0000 0000] [1111 1111] OxFF Ox69\u0026amp;0x55 [OllO 1001]\u0026amp;[0101 0101] [0100 0001] Ox41 Ox69l0x55 (0110 10011 I (0101 01011 [01111101) Ox7D 正如 示例说明的那样,确 定 一 个 位 级 表 达式的结果最好的方法, 就 是 将 十六进制的参数扩展成二进制表示并执行二进制运算,然后再转换回十六进制。\n练习题 2. 10 对于任 一位向量 a , 有 a Aa = O。 应用这 一属性, 考虑下 面的程序:\nvoid inplace_s-wap(int *X, int *Y) {\n*Y = *X- *Yi I* Step 1 *I\n*X = *X- *Yi I* Step 2 *I\n*Y = *X- *Yi I* Step 3 *I\n}\n正如程 序名 字所暗 示的 那样, 我们认为 这个过程的效果是交换指针变 量 x 和 y 所指向的存储位置处存放的值。注意,与通常的交换两个数值的技术不一样,当移动一个值 时,我们不需要第三个位置来临时存储另一个值。这种交换方式并没有性能上的优 势,它仅仅是一个智力游戏。\n以 指针 x 和 y 指 向的位置存储的值分别是 a 和 b 作为 开始, 填 写 下表, 给出在 程序的\n每 一步 之后, 存储在这 两个位 置 中 的 值。 利 用^的属 性证 明 达到 了 所希 望 的 效果。回想一下, 每个元 素 就是它 自 身的加法逆元 ( a Aa = O) 。\n步骤 初始 *x a *y b 第1步 第2步 第3步 练习题 2. 11 在练 习 题 2. 10 中的 i n p l a c e—s wa p 函数的基础 上, 你决定写 一段代码 , 实现将一个数组中的元素头尾两端依次对调。你写出下面这个函数:\nvoid reverse_array(int a[], int cnt) { int first, last;\nfor (first = 0, last = cnt-1; first\u0026lt;= last; first++,last\u0026ndash;)\ninplace_swap(\u0026amp;a[first], \u0026amp;a[last]);\n}\n当你 对一个 包含元 素 l 、 2 、 3 和 4 的数 组使 用这个函 数 时, 正 如 预 期 的 那样, 现在 数组 的元 素 变 成 了 4 、 3 、 2 和 1 。不过, 当你对一个 包含元素 1 、2 、3 、4 和 5 的数组使用 这 个 函数 时, 你 会很惊奇地看到得到 数 字的元 素为 5、4、0、2 和 1。 实际上, 你会发现这段代码对所有偶数长度的数组都能正确地工作,但是当数组的长度为奇数时, 它就 会把 中间的元 素设 置成 0 。\n对于一个 长 度 为 奇数的 数 组 , 长 度 c n t = 2k+ 1,\n循环 中 , 变 量 f i r s t 和 l a s t 的值分别是什 么?\n函 数r e v er s e _ a r r a y 最后 一 次\n为 什 么这 时调用 函 数 i n p l a c e _ s wa p 会 将数组 元素设 置为 0 ?\n对 r e v e r s e _ar r a y 的代码做 哪 些简单改 动就能消除这个问题?\n位级运算的一个常见用法就是实现掩码运算,这里掩码是一个位模式,表示从一个字 中选出的位的集合。让我们来看一个例子, 掩 码 Ox FF( 最 低 的 8 位 为 1) 表 示 一 个 字 的 低 位字 节 。 位 级 运 算 x \u0026amp;Ox FF 生 成 一 个 由 x 的 最 低 有 效 字 节 组 成 的 值 , 而 其 他 的 字 节 就 被 置 为\n。 比 如 , 对 千 x = Ox89ABCDEF, 其 表 达式 将得 到 Ox OOOOOO EF 。 表 达式 - 0 将生 成 一 个 全\n的掩码 , 不 管 机 器 的 字 大 小 是 多 少 。 尽 管 对 于 一 个 32 位 机 器来 说 , 同 样 的 掩 码 可 以 写 成\nOxFFFFFFFF, 但是这样的代码不是可移植的。\n练习题 2. 12 对于下面的 值, 写 出 变 量 x 的 C 语 言表达 式。 你 的 代 码 应 该 对任何 字长 w 8 都 能 工作。我 们 给出 了 当 x = Ox 8 765 4321 以及 w = 32 时表达 式 求值的结果, 仅供参考。\nX 的最低有效 字 节 , 其他位均置为 0 。 [ Ox 0 00000 21 ] 。\n除了 x 的最低有效字节 外, 其他的位都取 补, 最低有效字节保 持不变。 [Ox789 ABC21]。\nX 的最低 有效 字 节设 置成全 1, 其他 字 节都保持不 变 。 [Ox 8 7 65 43FF] 。\n练习题 2. 13 从 20 世纪 70 年代末到 80 年代末, Dig it a l Eq uipm e nt 的 V A X 计 算机是一种非 常流行 的 机 型。 它 没 有 布 尔运 算 A ND 和 OR 指令, 只 有 b i s ( 位 设 置)和b i c ( 位 清除)这两种指令。 两种指令的输入都是 一个 数据 字 x 和 一个 掩码 字 m。 它 们生成 一个 结果 z , z 是由根据掩码 m 的位 来修 改 x 的位得到 的。使用 b i s 指令 , 这 种修改就是在 m 为 1 的每个位置上, 将 z 对应 的位设置为 1 。 使 用 b 江:指 令 , 这 种修 改就是在 m 为 1 的 每 个 位 置, 将 z 对应 的位设 置为 0。\n为 了看清楚这些运 算与 C 语言位级运算的关系,假 设我们 有两个 函数 bi s 和 bi c 来实\n现位设置和位清除操作。只想用这两个 函数, 而 不使 用任何其他 C 语言运算, 来 实现按位1 和^运算。填写下列代码中缺失的代 码。提示: 写出 b i s 和 b i c 运算的 C 语言表达式。\nI* Declarations of functions implementing operations bis and bic *I int bis(int x, int m);\nint bic(int x, int m);\nI* Compute xly using only calls to functions bis and bic *I int bool_or(int x, int y) {\nint r esul t = · return result;\n}\n. I* Compute x-y using only calls to functions bis and bic *I int bool_xor(int x, int y) {\nint result=· return result;\n2. 1. 8 C 语 言 中 的 逻 辑 运 算\nC 语 言 还 提 供 了 一 组 逻 辑 运 算 符 II 、\u0026amp;.\u0026amp;. 和!, 分 别 对 应 于 命 题 逻 辑 中 的 O R 、A N D 和 NOT 运算。逻辑运算很容易 和位级运算 相 混淆, 但 是 它 们 的 功 能 是 完 全 不 同 的 。 逻 辑运算认 为所有非零的参数都 表示 T R U E , 而参数 0 表示 F ALS E。它们返回 1 或者 o, 分别\n表示结果为 T RU E 或者为 F ALSE。以下是一些表达式求值的示例。\n可 以 观 察 到 , 按 位 运算 只 有 在特 殊 情 况 下 , 也 就 是 参 数 被 限 制 为 0 或者 1 时 , 才 和 与\n其对应的逻辑运算 有相同的行为。\n逻辑运算符 && 和 II 与它们对应的位级运算 & 和1 之间第二个重要的区别是,如果 对第一个参数求值就能确定表达式的结果,那么逻辑运算符就不会对第二个参数求值。因此, 例如, 表达式 a \u0026amp;\u0026amp;S/ a 将不会造成被零除, 而表达式 p \u0026amp;\u0026amp;*p +叫 1 不会导致间接引用空指针。沁因 练习题 2. 14 假设 x 和 y 的 字节值 分别为 Ox 66 和 Ox 3 9 。 填写下表 , 指明各 个 C 表达\n式的字节值。\n表达式 值 表达式 值 X \u0026amp; y X I y X \u0026amp;\u0026amp; y X I y ~x I ~y !x 11 !y X \u0026amp; ! y X \u0026amp;\u0026amp; y 练习题 2. 15 只使 用位 级和逻 辑运 算, 编写 一个 C 表达式, 它 等价 于 x = = y 。换句话\n说 , 当 x 和 y 相 等 时 它 将返 回 1, 否则 就 返 回 0。\n2. 1. 9 C 语言中的移位运算\nC 语言还提供了一组移位运算 , 向左或者向右移动位模式。对于一个 位表示为[ x w- 1 • Xw-Z• …, x。]的操作数 x , C 表达式 x\u0026lt;\u0026lt;k 会生成一个值, 其位表示为[ Xw- k- 1 , Xw- k- 2 , …,\nX o , 0, … , O] 。也 就是说, x 向左移动 k 位, 丢弃最高的 K 位,并 在右端补 k 个 0。移位量应该是 一个 o w—1 之间的值。移位运算是从左至右可结合的, 所以 x \u0026lt;\u0026lt; j \u0026lt;\u0026lt; k 等价于\n(x « j ) « k 。\n有一个相应的右移运算 x \u0026gt;\u0026gt;k , 但是它的行为有点 微妙。一般而言, 机器支持两种形式的右移: 逻辑右移 和算术右移。逻辑右移在左端补 K 个 o, 得到的结果是[ O, …, o,\nXw- 1 • Xw- 2 • …, Xk] 。 算术右移是在左端补 K 个最高有效位的值, 得到的结果是[ x w- 1 • …, Xw- 1 , Xw- 1 , Xw-2, …, Xk ] 。 这种做法看 上去可能有点奇特, 但是我们会发现它对有符号整数数 据的运算非常有用。\n让我们来看一个例子 ,下 面的 表给出 了对一个 8 位参数 x 的两个不同的值做不同的移位操作得到的结果:\n操作 值 参数 x [01100011] [10010101] X \u0026lt;\u0026lt; 4 [00110000] [OIO10000] X \u0026gt;\u0026gt; 4 (逻辑右移) [0000011O] [00001001] X \u0026gt;\u0026gt; 4 ( 算术右移) [00000110] [11111001] 斜体的数字表示的是最右端(左移)或最左端(右移)填充的值。可以看到除了一个条目 之外, 其他的都包含填充 0。唯一的例外是算术右移[ 10010101] 的情况。因为操作数的最高位是 1, 填充的值就是 1。\nC 语言标准并没有明确定义对千有符号数应该使用哪种类型的右-移\n算术右移或者逻辑\n右移都可以。不幸地, 这就意味着任何假设一种或者另一种右移形式的代码都可能会遇到可移植性问题。然而, 实际上, 几乎所有的编译器/机器组合都对有符号数使用算术 右移,且许多\n程序员也都假设机器会使用这种右移。另一方面,对于无符号数,右移必须是逻辑的。\n与 C 相比, J ava 对 于 如何进行右移有明确的定义。表达是 x \u0026gt;\u0026gt;k 会将 x 算术右移 k 个位置,而 x \u0026gt;\u0026gt;\u0026gt;k 会对 x 做逻辑右移。\nm 移动 k 位, 这里 k 很大\n对于一 个由 w 位组成的数据类型,如 果 要 移动 k w 位会得 到什 么结果呢? 例如, 计算下 面的 表达式会得到什 么结 果,假 设 数 据 类型 i n t 为 w = 3 2 :\nint lval = OxFEDCBA98«32; int aval = OxFEDCBA98»36; unsigned uval = OxFEDCBA98u»40;\nC 语言标准很 小心地规避了说 明 在 这种情况下该如何做。在许多机 器上 , 当移动一个\nw 位的值 时,移位 指令只考虑位 移量的低 log2w 位,因 此实际上位移量就是通过计 算 k mod\nw 得到的。例如, 当 w = 32 时, 上面三个 移位运算分别是移动 0、4 和 8 位,得 到结果:\n1 val OxFEDCBA98\na val OxFFEDCBA9\nuval OxOOFEDCBA\n不过这种行 为对于 C 程序来说是没有保证的, 所以应该保持位移量小于待移位值的位数。另一方面, J a va 特别要 求位移数量应 该按照我们前面所 讲的求模的方法来计 算。\nm 与移位运算有关的操作符优先级问题\n常常有人会写这样的表达式 1 \u0026lt;\u0026lt;2+3\u0026lt;\u0026lt;4 , 本意是 (1 « 2 ) + (3« 4 ) 。 但 是 在 C 语言中, 前面的 表达式等价于 1 \u0026lt;\u0026lt; (2 +3 ) \u0026lt;\u0026lt; 4, 这是由于加法(和减法)的优先级比移位运算要高。然后 ,按 照从 左至右 结合性规则 ,括 号应该是这样打的 ( l \u0026lt;\u0026lt; (2+3)) \u0026lt;\u0026lt;4, 得到的结果是\n512, 而不是 期望的 52 。\n在 C 表达式中搞错优先级是一种常见的程序错误原因, 而且常常很难检查出 来。 所以.当你拿不准的时候,请加上括号!\n沁§ 练习题 2. 16 填写下表,展示不同移位运算对单字节数的影响。思考移位运算的最好方式是使用二进制表示。将最初的值转换为二进制,执行移位运算,然后再转换回 十 六进 制。每个答案都 应该是 8 个二进制数 字或 者 2 个十 六进 制 数 字。\n2. 2 整数表示\n在本节中, 我们描述用位来编码整数的两种不同的方式: 一 种 只 能 表示非负数, 而另一 种能 够表示负数、零和正数。后面我们将会看到它们在数学属性和机器级实现方面密切相关。我们还会研究扩展或者收缩一个已编码整数以适应不同长度表示的效果。\n图 2-8 列出了我们引入的数学术语,用 于 精确定义和描述计算机如何编码和操作整数。这些术语将在描述的过程中介绍,图在此处列出作为参考。\n符号 类型 含义 B2wT 函数 二进制转补码 B2U,,, 函数 二进制转无符号数 U2B,,, 函数 无符号数转二进制 u2r:切 函数 无符号转补码 T2Bw 函数 补码转二进制 T2Uw TMin \u0026ldquo;\u0026rsquo; TMawx UMawx t +W u +“ *t切 *wu I w u \u0026ldquo;' 函数常数常数常数操作操作操作操作操作操作 补码转无符号数最小补码值 最大补码值最大无符号数补码加法 无符号数加法补码乘法 无符号数乘法补码取反 无符号数取反 图 2-8 整数的数据与算术操作术语。下标 w 表示数据表示中的位数\n2. 2. 1 整型数据类型\nC 语言支持多种整型数 据类型 表示有限范围的整数。这些类型如图 2-9 和图 2- 10\n所示, 其中还给出了 "典型\u0026rdquo; 32 位和 64 位机器的取值范围。每种类型都能用关键字来指定大小,这些关 键字包括 c h ar 、s h or t 、l o n g , 同时还可以指示被表示的数字是非负数\n(声明为 u n s i g n e d ) , 或者可能是 负数(默认)。如图 2-3 所示,为 这些不同的大小分配的字节数根据程序编译为 32 位还是 64 位而有所不同。根据字节分配, 不同的大小所能表示的值的范圉是不同的。这里给出 来的唯一一个与机器相关的取值范围是大小指示符 l o n g 的。大多数 64 位机器使用 8 个字节的表示, 比 32 位机器上使用的 4 个字节的表示的取值范围大很多。\n图 2-9 32 位 程 序上 C 语言整型数据类型的典型取值范围\nC数据类型 最小值 最大值 [signed]char -12。8 -3276。8 -2 147 483 64。8 -9 223 372 036 854 775 80。8 -2 147 483 64。8 -9 223 372 036 854 775 80。8 127 unsigned char 255 short 32 767 unsigned short 65 535 int 2 147 483 647 unsigned 4 294 967 295 long 9 223 372 036 854 775 807 unsigned long 18 446 744 073 709 551 615 int32_t 2 147 483 647 u i n 七32_ t 4 294 967 295 i n七64_ t 9 223 372 036 854 775 807 u i n 七64_ 七 18 446 744 073 709 551 615 图 2- 10 64 位程序上 C 语言整型数据类型的典 型取值范围\n图 2- 9 和图 2- 1 0 中一个很值得注意的特点是取值范围不是对称的一— 负数的范围比整数的范围大 1。当我们考虑如何表示负数的时候, 会看到为什 么会这样。\nC 语言标准定义了每种数 据类型必须能够表示的最小的取值范闱。如图 2- 1 1 所示 ,它们的取值范围与图 2- 9 和图 2- 1 0 所示的典型实现一样 或者小一些。特别地,除 了 固定大小的数据类型是例外,我们看到它们只要求正数和负数的取值范围是对称的。此外,数据类 型 i nt 可以用 2 个字节的数字来实现, 而这几 乎回退到了 1 6 位机器的时代。还可以 看到, l ong 的大小可以用 4 个字节的数字来实 现, 对 32 位程序来说 这是很典型的。固定 大小的数据类型保证数值的范围与图 2- 9 给出的典型数值一致 , 包括负数与正数的不对称性。\nC数据类型 最小值 最大值 [signed]char unsigned char - 12。7 127 255 short unsigned short -3276。7 32 767 65 535 int unsigned 一32 76。7 32 767 65 535 long unsigned long -2 147 483 64。7 2 147 483 647 4 294 967 295 i n 七3 2_ t uint32_t -2 147 483 64。8 2 147 483 647 4 294 967 295 i n七64_ t uint64_t -9 223 372 036 854 775 80。8 9 223 372 036 854 775 807 18 446 744 073 709 551 615 图 2- 11 C 语言的整型数据类型的保证的取值范围 。C 语言标准要求这些数据类型必须至少具有这样的取值范围\n区 C、C++ 和 Java 中的有符号和无符号数\nC 和 C++ 都支持有符号(默认)和无符号数。 J a v a 只支持有符号数 。\n2.2. 2 无符号数的编码\n假设有一个整数数据类型有 w 位。我们 可以将位向量写成 王, 表示整个向量, 或者写成[ X w- 1 • X w- 2 • …, x。J\u0026rsquo; 表示向量中的每一位。把 I 看做一个二进制表示的数,就 获得\n了;的无符号表示。在这个编码中, 每个位 X , 都取值为 0 或 1 , 后一种取值意味着数值2\u0026rsquo; 应为数字值的一部分。我们用一个 函数 B2队 ( Binary to U nsigned 的缩写, 长度为 w ) 来表示:\n原理:无符号数编码的定义\n对向量 士=[立 - I\u0026rsquo; 五 - 2\u0026rsquo; …, Xo ] :\n,,- 1\nB 2U\u0026rdquo;\u0026rsquo; G ) 辛 x , 2'\n,-o\nC2. 1)\n在这个等式中, 符号“兰” 表示左边被定 义为等千右边。函数 BZU \u0026ldquo;\u0026rsquo; 将一个长度为 w 的\n0、1 串映射到非负整数。举一个示例,图 2-11 展示的是下面几种情况下 BZU 给出的从位向掀到整数的映射:\n(2. 2)\n在图中, 我们用长度为 2\u0026rsquo; 的指向右侧箭头的 条表示每个位的位置1。每个位向量对应的数值 就等于所有值为 1 的位对应的条 23 8\n的长度之和。\n让我们来考虑一下 w 位所能表示的值的范围。最小值是用位向量[ 00…\nO] 表示,也 就是整数值 o, 而最大值是\n用位向量[ 11 …l ] 表示, 也就是整数 值\nUMa x w = 江 =沪 - 1 。以 4 位情况\n,- o\n22 = 4 -\n0 I 2 3 4 5 6 7 8 9 10 II 12 13 14 15 16\n(0001)\n(0101)\n为例, UM a x4 = B 2U 八 [ 1111 ] ) = 24 - (1011]\n1 = 1 5。因此, 函数 B2U心 能够被定义 (1111)\n为一个映射 B 2Uw : { O, 1}w - { o, … 图 2-12 w= 4 的无符号数示例。当二进制表示\n沪 — 1 } 。 中位 t 为 1, 数值就会相应加上 2'\n无符号数的二进制表示有一个很重要的属性,也 就是每个介千 o ~ wz - 1 之间的数都\n有唯一一个 w 位的值编码。例如, 十进制 值 11 作为无符号数,只 有一个 4 位的表示, 即\n[ 1011] 。我们用数 学原理来重点 讲述它 ,先 表述原理再 解释。原理:无符号数编码的唯一性\n函数 B2U u. 是一个双射 。\n数学术语双射是指一个函数 J 有两面: 它将数值 x 映射为数值 y , 即 y = f(x), 但它也可以反向操作, 因为对每一个 y 而言, 都有唯一 一个数值 x 使得f 位 )= y 。这可以用反函数 1- 1 来表示, 在本例中, 即 x = 1- 1 ( y ) 。 函 数 B2U u. 将每一个长度为 w 的位向量都映\n射为 0~ w2\n—1 之间的一个唯一值; 反过来 , 我们称其为 U2B w( 即“无符号数到二进制\u0026rdquo;) '\n在 0 ~ 沪 — 1 之间的每一个整数都 可以映射为一个唯一的长度为 w 的位模式。\n2. 2. 3 补码编码\n对于许多应用, 我们还希望表示负数值。最常见的有符号数的计算机表示方式就是补码( t wo \u0026rsquo; s-com plemen t ) 形式。在这个定义中, 将字的最高有效位解 释为负权 ( nega tive weight ) 。我们用函数 B2兀 ( Bina ry to T wo \u0026rsquo; s-com plement 的缩写,长度 为 w ) 来表示:\n原理:补码编码的定义\n对向量 :i:= [ 江 - 1 \u0026rsquo; 五 - 2\u0026rsquo; … ,工。]:\n正 2\nB 2T wG ) 主 — 乓 -1 2匹 1 + x , 2'\n;=o\n(2. 3)\n最高有效位 Xw-1 也 称 为 符号位, 它 的 "权 重 ” 为— zw-1 , 是无符号表示中权重的负\n数。符号 位被设置为 1 时, 表 示值为负, 而 当 设 置为 0 时, 值为非负。这里来看一个示例, 图 2-13 展示的是下面几种情况下 B2T 给出的从位向量到整数的映射。\nB2T4 ([0001]) = — 0 • 23 + 0 • 22 + 0 • 21 + 1 • 2° = 0 + 0 + 0 + 1 = 1\nB2兀 c[ o1 01] ) =-o. 23 +1. 22 +o. 21 +1. 2° = 0+4+0+1 = 5\nB2T4 ([1011]) = — 1 • 沪 + 0 • 22 + 1 • 21 + 1 • 2° = - 8 + 0 + 2 + 1 =- 5\nB 2T 八[ 1111 ] ) = — 1 • 沪+ 1 • 22 + 1 • 21 + 1 • 2° = — 8 + 4 + 2 + 1 = — 1\n(2. 4)\n在这个图中,我们用向左指的条表示符号位具有负权重。于是,与一个位向量相关联的数值是由可能的向左指的条和向右指的条加起来决定的。\n我们可以看 到, 图 2-12 和图 2-13 中的位模式都是一样的, 对 等 式 ( 2. 2) 和等式 ( 2. 4) 来说也 是一 样 , 但 是 当 最高有效 位是 1 时,数 值 是 不 同 的 , 这是因为在一种情况中,最高有效位的权重 是十8 , 而在另一种情况中,它的权重是一8 。\n[0001]\n[0101]\n[1011]\n[1111]\n. 寄宅器嚣- 23 = -8\n22 = 4 -\n-8 -7 -6 -5 -4 -3 -2 -1 0 l 2 3 4 5 6 7 8\n让 我们来 考 虑一下 w 位补码所能 图 2-13 w = 4 的补码示例。把位3 作为符号位, 因此当它\n表示的值的范围。它能表示的最小值是 位向量[ 10…O] ( 也 就 是 设 置 这个位为负\n为 1 时, 对数值的影响是一 沪=—8。 这 个 权 重\n在图中用带向左箭头的条表示\n权,但 是 清除 其 他 所 有 的 位 ),其 整数值为 TMin产三— zw-1 。 而最大值是位向量[ 01… 1]\nur-2\n( 清除具有负权的位, 而设 置其他所有的位),其 整数值为 TMa x 心 == 2· = zur-1 - 1 。 以\n,- o\n长度为 4 为例,我们 有 TMin4 = B 2兀 ( [ 1000] ) = —穸= - 8 , 而 T Ma x4 =B2T4 ([0111]) =\n沪 + 21+ 2°= 4+ 2+ 1 = 7 。\n我们可以看出 B2兀 是一个从长度为 w 的位模式到 TMin 心 和 TMa x 切之 间 数 字 的 映射,写 作 B 2T w : {O, l}w \u0026mdash;- { T M i nw, … , T Ma x w }。 同 无 符 号 表 示 一样, 在 可 表 示的取值范围内的每个数字都有一个唯一的 w 位的补码编码。这就导出了与无符号数相似的补码数原理:\n原理:补码编码的唯一性\n函数 B2兀 是 一个双射。\n我们定义函数 T2B心(即 “补 码 到 二 进 制\u0026quot; )作为 B2兀 的反函数。也就是说,对 千每个数 x , 满足 TMinw女 T M a x 心,则 T 2B w( x ) 是 x 的(唯 一的)w 位模式。\n练习题 2. 17 假设 w = 4 , 我们能给每个可能的十六进制数字赋予一个数值,假设用一个 无符 号或者补码表 示。 请根据这些表 示, 通过写 出 等式( 2. 1 ) 和等 式 ( 2. 3 ) 所 示的求和公 式 中的 2 的非零次幕, 填写下表:\nX\n十六进制 二进制\nB2 U.( 又) B2T 4仅)\nOxE [1110] 23+22+21=14 - 23+2\u0026rsquo; + 2\u0026rsquo;=- 2\nOxO OxS Ox8 OxD OxF\n图 2-14 展示了针 对不同字长, 几个重要数字的位模式和数 值。前三个给出的是可表示的整数的范围,用 UMax w、TMin w 和 TMa x 心 来表示。在后面的讨论中, 我们还会经常引用到这三个特殊的值。如果 可以从上下文中推断出 w , 或者 w 不是讨论的主要内 容时,我们 会省略下 标 w , 直接引用 UMax 、TMin 和 TMa x 。\n图 2-1 4 重要的数字。图中给出了数值和十六进制表示\n关于这些数字, 有几点值得注意。第一, 从图 2-9 和图 2- 10 可以看到, 补码的范围是不对称的: I TMinl = I TM曰 + 1 , 也就是说, TMi n 没有与之对应的正数。正如我们将\n会看到的,这导致了补码运算的某些特殊的属性,并且容易造成程序中细微的错误。之所 以会有这样的不对称性, 是因为一半的位模式(符号位设置为 1 的数)表示负数,而另 一半\n(符号位设置为 0 的数)表示非负数。因为 0 是非负数, 也就意味着能表示的整数比负数少一个。第二, 最大的无符号数值刚好比补码的最大值的 两倍大一点: UMa工w = 2TM a工w +\nl 。补 码表示中所有表示负数的位模式 在无符号表示中都变成了 正数。图 2-14 也 给出了 常\n数— l 和 0 的表示。注意一1 和 UMa工 有同样的位表示一 - 个全 1 的串。数值 0 在两种表示方式中都是 全 0 的串。\nC 语言标准并没有要求 要用补码形式来表示有符号整数, 但是几 乎所有的机器都是这\n么做的。程序员如果希望代码具有最大可移植性,能够在所有可能的机器上运行,那么除 了图 2-11 所示的那些范围之外,我 们不应该假设任何可表示的数值范围,也 不应该假设有符号数会使用何种特殊的表示方式。另一方面,许多程序的书写都假设用补码来表示有 符号数, 并且具有图 2-9 和图 2- 10 所示的 "典型的” 取值范围, 这些程序也 能够在大量的机器和编译器上移植。C 库中的文件\u0026lt; l i mi t s . h \u0026gt;定义了一组常量, 来限定编译器运行的这台机器的 不同整型数据类型的取值范围。比如,它 定义了常量 IN T_ MAX、 I NT_ MIN 和\nUINT_MAX, 它们描述了有符号和无符号整数的范围。对于一个补码的机器, 数据类型 i n t\n有 w 位, 这些常量就对应 于 TMa工U 、 TMi nw 和 UMa工 的值。\nm 关千确定大小的整数类型的更多内容\n对于某些程序来说,用某个确定 大小的表示来编码数据类型非常重要 。例如,当编 写程序 , 使得机器能够按照一个标准协议在因特网上通信时,让数据类型与协议指定的数据类型兼容是 非常重要 的。我们前面看到了,某些 C 数据类型, 特别是 l ong 型,在不同的机器上有不同的取值范围,而实际上 C 语言标准只指定了每种数据 类型的 最小范围,而不是 确定的 范围。虽然 我们可以选择与大多数机器上的标准表示兼容的数据类型,但是这也不能保证可移植性。\n我们已经见 过了 3 2 位和 64 位版本的确定 大小的整数类型(图2-3 ) , 它们是一个更大数据类型 类的 一部 分。ISO C99 标准在 文件 s t d i nt . h 中引入了这个整数类型类。 这个文件定 义了一组数据类型, 它们的声明 形如 i n t N_t 和 u i nt N_t , 对 不 同的 N 值 指 定\nN 位有符号和无符号整数。N 的具体值与 实现 相 关 ,但 是 大 多 数 编译 器 允 许的值 为 8、\n16、32 和 64。因此, 通过将它的 类型 声明 为 u i n t l 6_ t , 我们可以无歧 义地声明一个 16\n位无符号 变量 , 而如 果 声明为 i n t 32 _ t , 就是一个 32 位有符号变量 。\n这些数据类型对应着一组宏,定 义了每 个 N 的值对应的最小和最大值 。这些宏名字\n形如 IN TN_MIN 、IN TN_ MAX 和 UI NTN_MAX 。\n确定宽度类型的带格式打印需要使 用 宏,以 与 系统 相关的方式扩展 为 格 式 串。 因此, 举个例子来说 , 变量 x 和 y 的类型是 i nt3 2_t 和 ui nt 64_七, 可以通过调用 pr i n t f 来打印它们的值,如下所示:\nprintf(\u0026ldquo;x = %\u0026rdquo; PRid32 \u0026ldquo;, y = %\u0026rdquo; PRiu64 \u0026ldquo;\\n\u0026rdquo;, x, y);\n编译为 64 位程序时, 宏 PRi d 32 展 开成字符 串 \u0026quot; d \u0026ldquo;\u0026rsquo; 宏 PRi u 64 则展 开成 两 个 字符 串\u0026quot;1\u0026rdquo; \u0026quot; u\u0026quot; 。 当 C 预处理器遇 到仅 用空格(或其他空白 宇符)分隔的一个字符 串常量序列 时, 就把 它们 串联 起 来。 因此,上 面的 pr i nt f 调用就变成 了 :\nprintf(\u0026ldquo;x = %d, y = %lu\\n\u0026rdquo;, x, y);\n使用宏能保证: 不论代码是如何被 编译的 ,都 能生成 正确的格式字符 串。\n. 关于整数数据类型的取 值范围和表示,Java 标准是非常明确的。它要求采用补码表示,取值范围与图 2-10 中 64 位的情况一 样。在Java 中,单 字节数据类型称为 byt e , 而不是 char 。这些非常具体的要求都是为了保证无论在什么机器上运行, Ja va 程序都能表现地完全一样。\n田 日 有符号数的 其他表示方法\n有符号数还有两种标准的表示方法:\n反码( Ones \u0026rsquo; Com plement ) : 除了最高有 效位的权是一 ( 2心- I —1 ) 而不是 — 2心- I \u0026rsquo; 它\n和补码是一样的:\nB 20 切(印 土— X ur\u0026ndash;1 ( 2urI-\n-1) +\nur-2\nX 心\n,-o\n原 码(Sign-Magnitude) : 最高有效位是符号位,用来确定剩下的位应该取负权还是正权:\n匹 2\nB 2S卫 )辛( - l Yw\u0026ndash;1 • ( x;t)\ni - 0\n这两种表 示方法都有一个奇怪的属性, 那就是 对于数 字 0 有两种 不 同的编码 方式。这两种表 示方法,把 [ 00…O] 都解释为 十0。 而值 —0 在原码中表 示为 [ 10 …O] , 在反码\n中表示为[ 11…1] 。 虽 然过去生产过 基于反 码表示的机 器, 但 是 几乎所有的现代机 器都\n使用补码 。 我们将看到在浮点数中有使 用原码编码。\n请注意补码 ( T wo \u0026rsquo; s co m plem en t ) 和反码 ( O ne s \u0026rsquo; co m plem e nt ) 中撇 号的 位 置是 不 同的 。 术语补 码 来 源 于这样一 个情况, 对 于非 负数 X , 我 们 用 2\u0026quot;\u0026rsquo; - x ( 这 里 只 有一 个 2 ) 未计算—x 的 w 位表示。术语反码 来源 于这样一 个属性 , 我 们用[ 11 1 …1 ] - x ( 这里有很 多个 1 ) 来 计 算 - x 的 反 码 表 示。\n为了更好地理解补码表示,考虑下面的代码:\nshort x = 12345; short mx = -x;\nshow_bytes((byte_pointer) \u0026amp;:x, sizeof(short)); show_bytes ((byte_pointer) \u0026amp;:mx, sizeof (short));\n当在大端法机器上运行时, 这 段 代 码 的 输 出 为 3 0 3 9 和 c f c7, 指 明 x 的 十六进制表示为 Ox 3 03 9 , 而 mx 的 十 六 进 制 表 示 为 Ox CFC7 。 将 它 们 展 开 为 二 进 制 , 我 们 得 到 x 的 位模 式 为[ 0011 000 00011 1 001 ] , 而 mx 的 位 模 式 为 [ 11 0011 11 11 0 001 11 ] 。 如 图 2-15 所 示 , 等式 ( 2. 3 ) 对 这两 个 位 模 式 生成 的 值 为 1 2 345 和 一1 2 345 。\n。 。 1 1 16 384 -32 768 1 1 16 384 32 768 图 2-1 5 12 345 和一1 2 345 的补码表示,以及 53 191 的无符号表示。注意后面两个数有相同的位表示\n练 习题 2. 18 在 第 3 章 中, 我 们将看到由反汇编 器 生成的列表, 反 汇编 器 是 一种将可执 行 程序 文件 转换回可读 性更好的 ASCII 码形 式的程序。这些 文件包含 许 多 十 六进制数 字 , 都是用典型的补码形 式 来 表 示 这 些 值。 能 够 认 识这 些 数 字 并 理 解 它 们 的 意 义\n(例如它们是正数还是负数),是一项重要的技巧。\n在下面的 列 表 中, 对千标 号为 A ~ I ( 标记在右边)的那些行 , 将指令名( s ub 、mov\n和 a d d ) 右边 显示的 ( 3 2 位补码形 式 表 示的)十六进制值 转换 为 等价的十进制值。\n4004d0: 48 81 ec eO 02 00 00 sub $0x2e0,%rsp A . 4004d7: 48 8b 44 24 a8 mov -Ox58(%rsp),%rax B. 4004dc: 48 03 47 28 add Ox28(%rdi),%rax C. 4004e0: 48 89 44 24 dO mov %rax,-Ox30(%rsp) D . 4004e5: 48 8b 44 24 78 mov Ox78(%rsp),%rax E. 4004ea: 48 89 87 88 00 00 00 mov %rax,Ox88 (ir 儿 di ) F . 4004f1: 4004f8: 48 8b 00 84 24 f8 01 00 mov Ox1f8 (%rsp) , %rax G . 4004f9: 48 03 44 24 08 add Ox8(%rsp),%rax 4004fe: 48 89 84 24 cO 00 00 mov %rax,Oxc0(%rsp) H. 400505: 00 400506: 48 8b 44 d4 b8 mov -Ox48(%rsp, ir 人\ndx , 8 ) , %r ax I .\n2. 4 有符号数和无符号数之间的转换\nC 语言允许在各种不同的数字数据类型之间做强 制类型转换。例如, 假设变量 x 声明为i nt , u 声明为 u n s i g n e d 。表达式 (u n s i g n e d ) x 会将 x 的值转换成一个无 符号数值,而(int) u 将 u 的值转换成一个有符号整数。将有符号数强制类型转换成无符号数, 或者反过来,会得到什么结果呢?从数学的角度来说,可以想象到几种不同的规则。很明显,对 于在两 种形式中都能 表示的值, 我们是想要保 持不变的 。另 一方面,将负 数转换成无符号数可能 会得到 0。如果 转换的无符号数太大以至于超出了补码能够表示的范围, 可能会得到 T Ma 工。 不过, 对千大多数 C 语言的实现来说, 对这个问 题的回答都是从位级角度来看的, 而不是数的 角度。\n比如说, 考虑下面的代码:\nshort int v = - 12345 ·\nunsigned short uv = (unsigned short) v;\n3 printf(\u0026ldquo;v = %d, uv = %u\\n\u0026rdquo;, v, uv);\n在一台采用补码的机器上,上述代码会产生如下输出:\nV = -12345, UV= 53191\n我们看到 , 强制类型转换的结果保持位值不变,只是 改变了解释这些位的方式。在图 2-1 5 中我们看 到过 ,一 1 2 3 45 的 16 位补码表示与 53 1 91 的 1 6 位无符号表示是完全一样的。将s ho 江 强制类型转换为 u n s i g ne d s h or t 改变数值, 但是不改变位表示。\n类似地,考虑下面的代码:\nunsigned u = 4294967295u; I* UMax *I\nint tu= (int) u;\nprintf(\u0026ldquo;u = %u, tu= %d\\n\u0026rdquo;, u, tu);\n在一台采用补码的机器上,上述代码会产生如下输出:\nu = 4294967295, tu= -1\n从图 2-1 4 我们可以看到 , 对于 3 2 位字长来 说, 无符号形式的 4 294 967 295 ( UM a 工32) 和补码形 式的- 1 的位模式是完全一样的。将 u n s i g n e d 强制类型转换 成 i n t , 底层的位表示保持不变。\n对于大多数C 语言的实现,处理同样字长的有符号数 和无符号数之间相互转换的一般规则是: 数值可能会改 变,但是位模式不 变。让我们用 更数学化的形式来描述这个规则。我们定义函数 U 2Bw 和 T2 B,,_,, 它们将数值映射为无符号数和补码形式的位表示。也就是说,给\n定 O¾ x ¾ UM a 工心范围内的一个整数 工, 函数 U 2B心( x ) 会给出 工的唯一的 w 位无符号表示。相似地,当 工满足 T M in心\u0026lt; 年;; TM釭心, 函数 尥凡 m 会给出工的 唯_的 w 位补码表示。\n现在, 将函数 T 2U 切 定义为 T 2U心丘 )土B 2U w( T 2 B 心 ( x ) ) 。 这个函数的输入是一个\nTMinw T M a x 立的数,结 果得到一个 o UMa x 心 的 值, 这里两个数有相同的位模式, 除了参数是无符号的, 而结果是以补码表示的。类似地, 对于 o UMax切 之间的值 x , 定义函数 U2兀 为 UZT u. 位) 主B2兀 (U2B w位))。生一成个数的无符号表示和 工的补 码表示相同。\n继续我们前 面的例子,从 图 2-15 中, 我 们看到 T 2U,6 ( — 1 2 345) =53 191, 并且\nU2T 16 (53 1 91) = — 1 2 345 。也就是说, 十六进制表示写作 Ox CFC 7 的 16 位位模式既是\n—1 2 345的补码表示, 又是 53 191 的无 符号表示。同时请注意 12 345 + 53 191 = 65 536 = 沪 。这个属性可以推广到 给定位模式的两个数值(补码和无符号数)之间的关系。类似地, 从图 2-1 4 我们看到 T 2U 32( —1 ) =4 294 967 295, 并且 U2T 32 (4 294 967 295) = —1。也就是\n说,无 符号表示中的 UMa工 有着和补码表示的—1 相同的位模式。我们在这两个数之间也能看到这种关系: l + UMax w = 2三\n接下来,我们 看到函数 U2T 描述了从无符号数到补码的转换, 而 T ZU 描述的是 补码到无符号的转换。这两个 函数描述了在大多 数 C 语言实现中这两种数据类型之间的强制类型转换效果。\n练 习 题 2. 19 利用你解答练 习题 2. 17 时填写的表格, 填 写下列描述函数 T2队 的表格。\n。 # 通过上述这些例子, 我们可以看到给定位 模式的补码与无符号数之间的关系可以表示为函数 T2U 的一个属性:\n原理: 补码转换为无 符号数\n对 满足 TMi n,,_. x T M a x心 的 x 有:\nT 2仄 ( x ) = {\nx + 2\u0026quot;\u0026rsquo; , X \u0026lt; 0\nx , x O\n(2. 5)\n比如, 我们看到 T 2U1 s C—12 34 5 ) = —12 345 + 216 =53 1 91 , 同时 T 2U心( - 1) = - l +\n沪 = U M a x wo\n该 属性可以通过比较公 式( 2. 1 ) 和公式( 2. 3 ) 推导出来。推导: 补码转换为无符号数\n比较等式( 2. 1) 和等式( 2. 3) , 我们可以发现对于位模式 x, 如果我们计 算 B2U w Cx) -\nB2T 切( 王)之差,从 0 到 w—2 的位的加权和将互相抵消掉,剩下一个值: B 2从(王)- B 2兀(动=\n平 一,I c z-w\nI - ( - z-w\nI ) ) = x ,,_.-1 沪 。 这就得到一个关系: B 2U心(x ) = 立 -I 沪 + B 2Tw Cx) 。我\n们因此就有\nB2U w ( T 2B w C x ) ) = T 2Uw ( x ) = x + x w-1- 沪 ( 2. 6)\n根据公式( 2. 5) 的两种情 况,在 x 的补码表示中 , 位 X u,- 1 决定了 x 是否为负。\n比如说, 图 2-1 6 比较了当 w = 4 时函数 B2U 和 B 2T 是如何将数值变成位模式的。对补码来说 , 最高有效位是符号位, 我们用带向左箭头的条来表示。对千无符号数来说, 最高有效位是正权重,我 们用带向右的箭头的条来表示。从补码变为无符号数, 最高有效位\n的权重从- 8 变为十8 。因 此 ,补 码表示的负数如果看成无符号数,值 会 增 加 24 = 1 6。因而, - 5 变成了十11 , 而—1 变成了十15 。\n绣嚣震璧翌黑殴醮-l = -s\n2 =8\n22 = 4 -\n- 8 - 7 -6 -5 -4 - 3 - 2 -1 0 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16\n[! Oll ]\n[ l lll ]\n图 2-1 6 比较当 w = 4 时无符号 表示和补码表示(对补码和无符号数来说,\n最高有效位的权重分别是 一8 和 十8\u0026rsquo; 因而产生一个差为 16 )\n图 2-1 7 说明 了函数 T 2U 的一般行为。如图所示,当 将 一 个 有 符 号 数 映 射 为它相应的无符号数时,负数就被转换成了大的正数,而非负数会保待不变。\n练习题 2. 20 请说明等式(2. 5) 是如何应用 到解答练 习题 2. 19 时生成的表格 中的各项 的。反过来看, 我们希望推导出一个无符号数 u 和与之对应的有符号数 U2Tw( u) 之间的关 系: 原 理: 无符 号数转换为补 码\n对 满足 0冬u UMa x ,., 的 u 有:\n.该原 理证明如下:\nuz 兀 ( u ) = { u,\nu — 沪 ,\n三 TMa x w u \u0026gt; TMaxw\n( 2. 7)\n推导:无 符 号 数 转换为补码\n设 u = U2BwCu) , 这个位向量也是U2兀 ( u) 的补码表示 。公式(2. 1) 和公式(2. 3) 结合起来有\nU2Tw(u) = — U ur- 1 沪 十 u (2. 8)\n在 u 的无符 号 表示中, 对公 式 ( 2. 7 ) 的两 种情况来说, 位 u印一 ]决定 了 u 是 否 大 于\nTMa工切 = 2-w\nl -10 ■\n图 2-18 说明了函数 U 2T 的行 为。对于小的数( T M a 工w)\u0026rsquo; 从 无 符 号 到 有 符 号 的 转 换将保留数字的原 值。对于大的数( \u0026gt; TMa工切) ,数 字 将被转换为一个负数值。\n2w 2 w\n无符号数, - · 斗尸►\n+2 w\u0026ndash;1 T\n. t 2 wI- 无符号数\n- 2 w-1 .l \u0026rsquo;l _2 w-l\n图 2-1 7 从补码到无符号数的转换。函数 图 2-18 从无符号数到补码的转换。函数 U2T T 2U 将负数转换 为大的正数 把大千 zw一l —1 的 数 字 转 换 为 负 值\n总结一下, 我们考 虑无符号与补码表示之间互 相转换的结果。对于在范圉 O x 冬T M a x w 之 内 的 值 x 而 言 , 我们得到 T 2U 心 ( x ) = x 和 U 2兀 ( x ) = x 。也就是说,在 这个范即内的数字有相同的无符号和补码表示。对于这个范围以外的数值,转换需要加上或者减 去 zw。例如, 我们有 T 2U w ( - l ) =-1 + 2w = UM a x 心 最 靠 近 0 的负数映射为最 大的无符号数。在另 一个 极 端, 我们可以看到 T 2队 ( T M in w ) = — 2正 1 + 2 三 = 2 正 1 = T M a xw +\n1 — 最 小 的 负 数 映 射为一个刚好 在补码的正数范围之外的无符号数。使用图 2-1 5 的示例 , 我们能看到 T 2U 16 ( — 1 2 345) = 65 563+ —12 345=53 191。\n2. 2. 5 C 语言 中的 有符号 数与无符号数\n如图 2-9 和图 2- 10 所示, C 语 言 支 持 所 有 整型数据类型的有符号和无符号运算。尽管\nC 语言标准没有指定 有符号数要 采用某种表示, 但 是 几 乎 所 有 的 机 器 都 使 用 补 码。通常, 大 多 数 数 字 都 默 认 为是有符号的。例如,当 声 明 一 个 像 1 2 3 45 或者 Ox 1 A2B 这样的常星时, 这 个 值 就 被 认 为是有符号的。要创建一个无符号常量,必 须 加 上 后 缀 字 符 \u0026rsquo; u\u0026rsquo; 或者 \u0026rsquo; u \u0026rsquo;\u0026rsquo; 例 如 , 1 2 3 45 U 或 者 Ox 1 A2Bu 。\nC 语言允 许无符号数 和有符号数之间 的转换。虽 然 C 标 准没有精确规定应如何进行这种转换, 但 大多数系统遵循的原则是底层的位表示保持不变。因此,在 一 台 采用补码的机器上, 当从 无 符 号 数转换为有符号数时,效 果 就 是 应 用 函 数 U ZT w , 而从有符号数转换为无符号数时,就 是 应 用 函 数 T ZU w , 其 中 w 表示数据类型的位数。\n显式的强制类型转换就会导致转换发生,就像下面的代码:\nint tx, ty; unsigned ux, uy;\ntx \u0026quot;\u0026quot; (int) ux; uy\u0026quot;\u0026quot; (unsigned) t y;\n另外, 当一 种类 型 的表 达式被赋值给另外一种类型的变量时,转 换 是 隐 式 发 生 的 ,就像下面的代码:\nint tx, ty; unsigned ux, uy;\ntx = ux; I* Cast to signed *I uy = ty; I* Cast to unsigned *I\n当用 pr i nt f 输出数值时,分 别 用 指示符%d 、%u 和 %x 以 有 符 号 十进制、无符号十进制和十六进制格式输出一个数字。注意 pr i n 七f 没 有 使 用 任 何 类 型 信 息 ,所 以 它 可 以 用 指 示符%u 来 输 出 类 型 为 1 平 的数值,也 可 以 用 指 示符%d 输 出 类 型 为 un s i g ne d 的数值。例如, 考虑下面的代码:\nint X = -1;\nunsigned u = 2147483648; I* 2 to the 31st *I\nprintf(\u0026ldquo;x = %u = %d\\n\u0026rdquo;, x, x); printf(\u0026ldquo;u = %u = %d \\ n \u0026quot; , u, u);\n当 在 一 个 3 2 位机器上运行时,它 的 输 出 如 下 :\nX = 4294967295 = -1\nu = 2147483648 = -2147483648\n笫 2 章 信息的表示和处理 53\n在这两种情 况下, pr i n t f 首先 将这个字当作一个无符号数输出,然 后 把 它 当 作 一 个 有符号数输出。以下是实际运行中的转换函数: T2U32 (-1) =UMax3 z = 23 2 - 1 和 U 2 T32 (231) = 沪 —232 = - 231 = TMin32o\n由千 C 语言对同时包含有符号和无符号数表达式的这种处理方式,出 现 了 一 些 奇特的行为。当 执行一个运算时, 如果它的一个运算数是有符号的而另一个是无符号的,那 么 C 语言会隐式地将有符号参数强制类型转换为无符号数,并假设这两个数都是非负的,来执 行这个运算。就像我们将要看到的,这种方法对于标准的算术运算来说并无多大差异,但 是对于像< 和> 这样的关系运算符来说, 它 会 导 致非直观的结果。图 2- 1 9 展示了一些关系表达式 的示例以及它们得到的求值结果,这 里假设数据类型 i n t 表示为 3 2 位补码。考\n虑比较式- l \u0026lt; OU 。 因 为第二个运算数是无符号的,第 一 个运算数就会被隐式地转换为无符号数,因 此表达式就等价于 4 2 9 4 96 72 95 0 \u0026lt;0 0 ( 回想 T 2U ,,_,( — l ) = U M a x 心),这 个 答 案显然是错的。其他那些示例也可以通过相似的分析来理解。\n图 2-19 C 语言的升级规则的效果\n注: 非直观的情况标注了` *' 。 当一个运算 数是无符号的时候 ,另 一个运算数也被隐式投制转换为无符号 。\n将 TMin32 写 为 - 21 474 83647- 1 的 原 因请 参 见 网 络 旁 注 DATA:T MIN。\n练习题 2 . 21 假设在采用 补码运算的 3 2 位机器上对这些表达式求值, 按照 图 2- 1 9 的格式填写下表,描述强制类型转换和关系运算的结果。\n\u0026amp;JllEmlill C 语言中 TMin 的 写法\n在图 2- 1 9 和练习题 2 . 21 中, 我们很 小心地将 T M i n 32 写成- 2 1 7 4 8 3 6 4 7 - 1 。 为什 么\n不 简单地 写成 - 2 1 47 4 8 3 6 48 或者 Ox 8 0 0 0 0 0 0 0 ? 看一下 C 头 文 件 l i mi t s . h , 注意到它们使用 了跟 我们写 T M i n 32 和 T M a x 32 类似的方法:\nI* Minimum and maximum values a\u0026rsquo;signed int\u0026rsquo;can hold. *I\n#define INT_MAX 2147483647\n#define INT_MIN (-INT_MAX - 1)\n不幸的是, 补码表示的 不对称性和 C 语言的转换规则之间奇怪的交互 , 迫使 我们用\n这种不寻常的 方式 来写 T M i n 32 。 虽然 理解这 个问 题需要 我们钻研 C 语言标准的 一些比较隐晦的 角落, 但是它能 够帮助我 们充分领会 整数数据类型和表 示的 一些细微之处。\n2. 2. 6 扩展—个数字的位表示\n一个常见的运算是 在不同字长的整数之间转 换,同 时又保持数值不变。当然, 当目标数据类型太小以至千不能表示想要的值时, 这根本就是不 可能的。然而,从 一个较小的数据类型转换到一个较大的类型, 应该总是 可能的 。\n要将一个无符号数转换为一个更大 的数据类型, 我们只 要简单地在表示的开头添加 0。这种运算 被称为零扩展 ( zero e x t e ns io n ) , 表示原理如下:\n原理:无符号数的零扩展\n定义宽 度为 w 的位向量 U= [ u w- 1 , Uw- Z, … , u。] 和宽度为 w \u0026rsquo; 的位向 量 矿 = [ O , 0 , Uw- 1 , Uw- 2 , … , u。J\u0026rsquo; 其中 w \u0026rsquo; \u0026gt; w 。则 B ZU w ( u ) = B ZU w\u0026rsquo; ( 矿)。\n按照公式( 2. 1), 该原理 可以看作是 直接遵循了无符号数编码的定 义。\n要将一个补码数字转换为一个更大的数据类型, 可以执行一个符号扩展 ( s ig n exten­\nsion), 在表示中添加最高有效 位的值, 表示为如 下原理。我们用蓝色标出符号位 五- i\u0026rsquo;来突出它在符号扩展中的角色。\n原理:补码数的符号扩展\n定义宽度为 w 的位向量 王=[五 - I • Xw- Z • … , X。]和宽度为 w 的位向量 x \u0026rsquo; = [ x ,,.- 1 ,\n江 -· I \u0026rsquo; 工三 , 江 - 2 \u0026rsquo; … , x 。J\u0026rsquo; 其 中 w\u0026rsquo; \u0026gt; w 。 则 B Z兀 (x ) = B ZT w,(了 )。例如,考虑下面的代码:\nshort sx = -12345; I* -12345 *I\n2 unsigned short usx = sx; I* 53191 *I\n3 int x = sx; I* -12345 *I\n4 unsigned ux = usx; I* 53191 *I\n6 printf(\u0026ldquo;sx = %d:\\t\u0026rdquo;, sx);\n7 show_bytes((byte_pointer) \u0026amp;sx, sizeof(short));\n8 printf(\u0026ldquo;usx = %u:\\t\u0026rdquo;, usx);\n9 show_bytes((byte_pointer) \u0026amp;usx, sizeof(unsigned short));\n10 printf(\u0026ldquo;x = %d:\\t\u0026rdquo;, x);\n11 show_bytes((byte_pointer) \u0026amp;x, sizeof(int));\n12 printf(\u0026ldquo;ux = %u:\\t\u0026rdquo;, ux);\n13 show_bytes((byte_pointer) \u0026amp;ux, sizeof(unsigned));\n在采用补码表示的 3 2 位大端法机器上运 行这段代码时, 打印出如下输出:\nsx = -12345: cf c7 usx = 53191: cf c7\nX = -12345: ff ff cf c7\nux = 53191: 00 00 cf c7\n我们看到, 尽管—12 345 的补码表示和 53 191 的无符号表示在 16 位字长时是相同的, 但是\n在 32 位字长时却是不同的。特别地, - 12 345 的 十六进制表示为 Ox FFFF CFC7 , 而 53 191 的十六进制表示为Ox 0000 CFC7。前者使用的是符号扩展- 最开头加了 16 位, 都是最高有效位1 , 表示为十六进制就是Ox FFFF 。 后者开头使用16 个 0 来扩展, 表示为十六进制就是 Ox OOOO。\n图 2- 20 给出了从字长 w = 3 到 w = 4 的符号扩展的结果。位向量[ 1 01 ] 表示值- 4 + 1 =\n- 3。对它应用符 号扩展, 得到位向量[ 1101] , 表示的值—8 + 4+ 1 = —3。我们可以看到, 对于 w= 4, 最高两位的组合值是 - 8+ 4 = - 4 , 与 w = 3 时符号位的值相同。类似地, 位向量[ 111] 和[ 1111] 都表示值- 1。\n[101)\n[I IOI]\n-23 =-8\n-2 =-4\n22=4-\n21=2 lllt\n2°=.1\n-8 -7 -6 -5 -4 -3 -2 -I O I 2 3 4 5 6 7 8\n图 2-20 从 w= 3 到 w= 4 的 符 号 扩展示例。对于 w= 4, 最高两位组合权重为- 8+ 4= - 4, 与 w= 3 时 的 符号 位的权重一样\n有了这个直觉,我们现在可以展示保持补码值的符号扩展。推导:补码数值的符号扩展\n令 w\u0026rsquo; = w + k , 我们想要证明的是\nB 2T 叶 k ( [ 工u气 ,…,工匹-1\u0026rsquo; 工匹-I , Xw-z , … ,Xo ] ) = B 2T w ( [ 工u- 1 , Xw-z , … ,工。])\nk次\n下面的证明是对 K 进行归 纳。也就是说,如果 我们能够证明符号扩展一位保持了数值不变,那么符号扩展任意位都能保待这种属性。因此,证明的任务就变为了:\nB2T w+I([ 工正一 I , Xu- I , X匹-2\u0026rsquo; … , X。] ) = B 2T .,.( [ .r \u0026hellip;气 ,工一U\n用等式( 2. 3) 展开左边的表达式, 得到:\n2\u0026rsquo; … ,工。])\nB 2T叶 l ([ 乓 - I , X,一,_\nw\u0026mdash;1\nI\u0026rsquo; 乓 - 2\u0026rsquo; … ,工。])—=立-1 沪 + x i 2'\n, = O\nw\u0026mdash;2\n= - x .,,_- 1 沪 十 五 4 尸 + xi ;2\n, = O\nw-2\n= - Xu - ] ( 2W — z- u J ) + x.2'\n,=O\n\u0026quot; - 乓 _1 2-\u0026rdquo;\nw-2\n1 + x , 2'\n,= O\n= B 2兀 ( [ :r 一u I , Xw\u0026ndash;2 • … ,工。]) .\n我们使用的关键属性是 zw- zw-1 = zw- 1 。 因此,加上 一个权值为—沪 的位, 和将一个权值为\n- z-w 1的 位转换为一个 权值为 zw-1 的 位, 这两项运算的综合效果就会保持原始的数值 。练习题 2. 22 通过 应用 等式( 2. 3), 表明下 面每个位 向量都是 —5 的补 码表 示。A. [1011]\nB. [11011]\nC. [111011]\n可以看到第二个和第三个位向量可以通过对第一个位向量做符号扩展得到。\n值得一提的是,从一个数据大小到另一个数据大小的转换,以及无符号和有符号数字之间的转换的相对顺序能够影响一个程序的行为。考虑下面的代码:\nshort sx = -12345; unsigned uy = sx;\nI* -12345 *I\nI* Mystery! *I\nprintf (\u0026ldquo;uy = i 儿 u : \\ t \u0026quot; , uy); show_bytes((byte_pointer) \u0026amp;uy, sizeof(unsigned));\n在一台大端法机器上,这部分代码产生如下输出:\nuy = 4294954951: ff ff cf c7\n这表明当把 s hor t 转换成 u ns i g ne d 时, 我们先要改变大小,之 后 再完 成 从 有符号到无符 号 的 转 换。也 就 是 说 ( u n s i g n e d ) s x 等 价 于 ( u n s i g n e d ) (int) sx, 求值得到4 294 954 951, 而不等价于 (u n s i g n e d ) (unsigned short) sx, 后者求值得到 53 1 91 。事实上, 这个 规则 是 C 语 言标 准要 求 的 。\n练习题 2. 23 考虑下面的 C 函数:\nint fun1(unsigned word) {\nreturn (int) ((word«24)»24);\n}\nint fun2(unsigned word) {\nreturn ((int) word«24)»24;\n}\n假设在 一个 采用 补码运算的 机器上以 3 2 位程 序来执行这些 函 数。还假设有符号数值的右移是算术右 移 , 而 无符 号数值的右移是逻辑右移。\n填写 下表, 说明这些 函数对几个示 例参数的 结果。你会发现用 十 六进制 表 示来 做会更方便, 只要记住十 六进 制数 字 8 到 F 的最高有效位等于 1。 用语言来描述这些函数执行的有用的计算。 2. 2. 7 截断数字\n假设我们不用额外的位来扩展一个数值,而是减少表示一个数字的位数。例如下面代码中这种情况:\nint X = 53191;\nshort sx = (short) x; int y = sx;\nI* -12345 *I\n/* 一1 2345 *I\n当 我们把 x 强制类型转换为 s hor t 时, 我们就将 32 位的 i nt 截断为了 16 位的 s hor t i nt 。\n就像前面所看到的 ,这 个 16 位的位模式就是—12 345 的补码表示。当我们把它强制类型\n转换回 扛江时, 符号扩展把高 16 位设置为 1, 从而生成—1 2 345 的 32 位补码表示 。\n当将一个 w 位的数 x = [ x w- 1 • Xw-2• … , X。J截断为一个 K 位数字时, 我们会丢弃高w- k 位,得 到一个位向量 X1 = [Xk-1 , Xk-2, …, x。]。截断一个数字可能会改变它的值 溢出的一种形式。对千一个无符号数,我们可以很容易得出其数值结果。\n原理:截断无符号数\n令 I 等于位向 量[ x ..,- 1\u0026rsquo; 立 - 2\u0026rsquo; … , x 。J\u0026rsquo; 而 了是将其截断为 K 位的结果:了 = [ Xk- 1 •\n石 -2 \u0026rsquo; … , Xo ] 。 令 x = BZU 心(印 , x \u0026rsquo; = B ZU* G \u0026rsquo; ) 。 则 x \u0026rsquo; = x mod Z* o\n该原理背后的直觉 就是所有被截去的位其权重形式 都为 2\u0026rsquo;\u0026rsquo; 其中 i k , 因此,每一个权在取模操作下结果都为零。可用如下推导表示:\n推导:截断无符号数\n通过对等式 ( 2. 1) 应用取模 运算就 可以看到:\nw\u0026ndash;1\nB 2队 ( [ x 正 I\u0026rsquo; 工 w\u0026ndash;2\u0026rsquo; …心 )mod 2k = [ x ;2; ] mod 2k\n, =O\n=[江,;2 ]mod 2k\n; = o\nk 一 ]\n= x ; 2'\n,=O\n= B 2队 ( [ x1,1-\n, X1,2-\n, … , X。])\n在这段推导 中,我 们利用了属性: 对于任何 彦 k , 2\u0026rsquo;mod 2k = 0。 补码截断也具有相似的属性,只不过要将最高位转换为符号位: 原理:截断补码数值\n令; 等于位向 量[ x w-1 • 立 -2 \u0026rsquo; …,工。], 而 了是将其截 断为 K 位的结果: X1 =[xk-1,\nXk-\u0026lsquo;2, …, x。]。令 x = B2仄 (x ) , x \u0026rsquo; = B 2兀(了)。则x \u0026rsquo; = U 2兀 ( x mod 2k) 。\n在这个公式中, x mod 2k 将是 0 到 2k —1 之间的一个数。对其应用函数 U2兀 产生的\n效果是把最高有效 位 Xk -1 的 权重从 zk- 1 转变为— z- k 1 。 举例来看, 将数值 x = 53 191 从\ni nt 转换为 s hor t 。由 千 216 = 65 536 :r\u0026rsquo; 我们有 x mod 216 = x 。 但是, 当我们把这个数转换为 16 位的补码时, 我们得到 x \u0026rsquo; = 53 191 - 65 536=-12 345。\n推导:截断补码数值\n使用与无符号数截断相同的参数,则有\nB2U w( [ x 匹 I ,Xw-2• … ,X。] ) mod 2k = B 2队 [ x1,-1 , X1,-2 , … , X。]\n也就是, x mo d 沪能 够被一个位级表示为[ x k- 1 , Xk-2• …, x。]的 无符号数表示。将 其转换为补码 数则有 x \u0026rsquo; = U 2兀 ( x mod 2k ) 。\n总而言之,无符号数的截断结果是:\nB2Uk[x1,-1 ,x1,-2, … ,X。] = B 2U w( [ x U-, I , x 正 2 \u0026rsquo; … , X。]) mod 2k (2. 9)\n而补码数字的截断结果是:\nB2Tk [ x k-1 心 -2 \u0026rsquo; … ,X。]= U 2兀 ( B 2U w( [ x 一u ,,xu一. 2\u0026rsquo; … ,X。]) mod 2勹 ( 2. 10)\n练习题 2. 24 假设 将一个 4 位数值(用十六进 制数 字 O ~ F 表 示)截断到 一个 3 位数值\n(用十六进制 数 字 0 ~ 7 表 示)。填写 下表 , 根据那些位 模 式的 无符 号和补码 解释, 说明这种截断对某些情况的结果。\n原始值 截断值 原始值 原始值\n解释如何将等 式 ( 2. 9) 和等 式 ( 2. 1 0 ) 应用 到这些 示例 上。\n2. 8 关千有符号数与无符号数的建议\n就像我们看到的那样,有符号数到无符号数的隐式强制类型转换导致了某些非直观的行为。而这些非直观的特性经常导致程序错误,并且这种包含隐式强制类型转换的细微差别的错误很难被发现。因为这种强制类型转换是在代码中没有明确指示的情况下发生的, 程序员经常忽视了它的影响。\n下面两个练习题说明了某些由于隐式强制类型转换和无符号数据类型造成的细微的错误。\n练习题 2. 25 考虑下 列代 码 , 这段代码试图计算数组 a 中所有元素的和, 其中 元 素的数量由参数 l e n g t h 给出。\nI* WARNING: This is buggy code *I\nfloat surn_elements (float a[] , unsigned length) { inti;\nfloat result= O;\nfor (i = O; i \u0026lt;= length-1; i++) result+= a[i];\nreturn result;\n当参数 l e n g t h 等于 0 时, 运行这段代码 应 该返回 0. 0 。但 实际 上, 运行时会遇到一个内存错误。请解释为什么会发生这样的情况,并且说明如何修改代码。\n练习题 2. 26 现在给你一个任务, 写 一个函数用 来判定一个字 符 串 是否 比 另 一个更长。 前提是你要用 字符 串 库函数 s t r l e n , 它的 声明 如下:\nf* Prototype for library function strlen *I size_t strlen(const char *s);\n最开始你写的函数是这样的:\nI* Determine whether strings is longer than string t *I I* WARNING: This function is buggy *I\nint strlonger(char *s, char *t) { return strlen(s) - strlen(t) \u0026gt; O;\n}\n当你在一些示例数据上测试这个函数时,一切似乎都是正确的。进一步研究发现 在头文件 s t d i o . h 中 数据类型 s i z e _ t 是定义成 u n s i g n e d i n t 的。\n在什么情况下,这个函数会产生不正确的结果? 解释为什么会出现这样不正确的结果。 说明如何修改这段代码好让它能可靠地工作。 m 函数 g e t p e er n ame 的 安全漏 洞\n2002 年 , 从 事 F re eBSD 开源操 作 系统 项 目 的 程 序 员 意 识 到, 他 们对 ge t pe er na me\n函数的实现 存 在 安 全 漏洞。代码的 简 化 版 本 如 下:\n/*\n* Illustration of code vulnerability similar to that found in\n* FreeBSD\u0026rsquo;s implementation of getpeername 0\n*I\n5\nI* Declaration of library function memcpy *I\nvoid *memcpy(void *dest, void *src, size_t n);\n8\nI* Kernel memory region holding user-accessible data *I\n#define KSIZE 1024\nchar kbuf [KSI ZE] ; 12\n13 / * Copy at most maxlen bytes from kernel region to user buffer *I\n14 int copy_from_kernel(void *user_dest, int maxlen) {\n1s I* Byte count len is minimum of buffer size and maxlen *I\nint len = KSIZE \u0026lt; maxlen? KSIZE: maxlen;\nmemcpy(user_dest, kbuf, len);\nreturn len;\n19 }\n在这段代码里, 第 7 行给 出的 是 库 函 数 me mc p y 的 原 型, 这 个函数是要将一段 指 定长度 为 n 的 宇 节从 内 存 的 一 个 区域复制到 另 一 个 区域 。\n从 笫 14 行 开始的函数 c op y_ fr om_ ker ne l 是 要 将 一些操作 系统 内核 维护的数据复制到指定的 用 户可以访问的内存区域。 对用 户来说 , 大多数 内核 维护的数据结构应该是不可读的,因为这些数据结构可能包含其他用户和系统上运行的其他作业的敏感信息, 但是显示为 kb u f 的 区域 是 用 户可 以 读 的 。 参 数 ma x l e n 给 出的 是 分 配 给 用 户的 缓 冲 区的长度 , 这 个缓冲区是 用参数 u s er _d e s t 指 示的。 然后 , 第 1 6 行 的 计算确保 复制的 字节数 据 不会超 出 源或 者 目标缓 冲区可用的 范围。\n不过 ,假 设 有 些怀有恶意的 程 序 员 在 调 用 c o p y_ fr om_ ker n e l 的 代 码 中 对 ma x l e n 使 用 了 负数 值 , 那么, 第 1 6 行 的 最 小值 计 算会把 这 个值赋给 l e n , 然后 l e n 会 作 为 参数 n 被 传 递给 me mc p y 。 不过, 请 注意参数 n 是被 声明 为数 据 类型 s i ze _ t 的。这个数据类型是在库文件 s t d i o . h 中(通过 t yp e d e f ) 被 声 明 的 。 典 型地, 对 3 2 位 程序 它被 定 义\n为 u ns i g n e d int, 对64 位程序定义为 u n s i g ne d l o n g。 既 然参数 n 是 无符号的 , 那 么\nme mc p y 会 把 它当作 一 个非常大的 正整数 , 并 且 试 图将这样 多 字 节的 数 据 从 内核 区域 复制到用 户的缓 冲区。 虽 然复制这 么 多 字节( 至 少 沪 个)实 际 上 不 会 完成 , 因 为 程 序 会 遇到进程中非法地址的错误,但是程序还是能读到它没有被授权的内核内存区域。\n我们可以看到,这个问题是由于数据类型的不匹配造成的:在一个地方,长度参数 是有符号数; 而另一 个地 方, 它又是无符号数。正如这个例子表明的 那样 , 这 样 的 不 匹配会成为缺 陷的原 因, 甚 至 会 导 致 安 全 漏洞。 幸运 的 是 , 还 没 有 案 例 报 告 有 程 序 员 在F reeBSD 上利 用 了 这 个漏洞。他 们发布 了 一 个安全 建议, \u0026quot; F reeBS D-S A- 0 2 : 38. sig ned ­\nerror\u0026rdquo;, 建议系统 管理 员如 何 应 用补 丁 消除 这 个 漏洞。要 修 正这 个缺陷, 只 要 将 c o p y_ fr o m_ ker n e l 的 参 数 ma x l e n 声 明 为 类型 s i ze _ t , 也就是与 me mc p y 的 参 数 n 一 致 。 同时, 我们也应该将本地 变量 l e n 和返 回值 声 明 为 s i z e _ t 。\n我们已经看到了许多无符号运算的细微特性,尤其是有符号数到无符号数的隐式转 换,会导致错误或者漏洞的方式。避免这类错误的一种方法就是绝不使用无符号数。实际 上,除 了 C 以外 很 少 有语 言 支持无符号整数。很明显, 这些语言的设计者认为它们带来的麻烦要比益处多得多。比如, J a va 只支待有符号整数,并 且 要 求 以 补 码 运 算 来 实 现。正常的右移运算符>>被定义为执行算术右移。特殊的运算符>>>被指定为执行逻辑右移。\n当我们想要把字仅仅看做是位的集合而没有任何数字意义时,无符号数值是非常有用 的。例如,往 一 个 字中放入描述各种布尔条件的标记( flag ) 时,就 是 这 样。地址自然地就是无符号的,所以系统程序员发现无符号类型是很有帮助的。当实现模运算和多精度运算 的数学包时,数字是由字的数组来表示的,无符号值也会非常有用。\n2. 3 整数运算\n许多刚入门的程序员非常惊奇地发现,两个正数相加会得出一个负数,而比较表达式 x\u0026lt;y和比较表达式 x - y\u0026lt;O 会 产 生不同的结果。这些属性是由千计算机运算的有限性造成的。理解计算机运算的细微之处能够帮助程序员编写更可靠的代码。\n2. 3. 1 无符号加法\n考虑两个非负整数 x 和 y , 满足 0冬X , y\u0026lt; Zw。每个数都能 表示为 w 位无符号数字。然而, 如果计算 它们的 和 , 我们就有一个可能的范围 O x + y Z叶 t_ z。表 示 这 个 和可能需要 w + l\n位。例如,图 2-21 展示了 当 x 和 y 有 4 位表示时,函数 x + y 的 坐标图。参数(显示在水平轴上)取值范围为 0~ 15, 但是和的取值范围为 0~ 30。函数的形状是一个有坡度的平面(在两个维度上,函数都是线 性的)。如果保持和为一个 w+ l 位的数字,并 且把它加上另外一个数值,我 们可能需要 w+ 2 个位 ,以 此类推。这种持续的 ”字 长膨胀” 意 味 着 , 要想完整地表示算术运算的结果, 我们不能对字长做任何限制。一些编程语言,例 如 L isp, 实际上就支持无限精度的运算,允许任意的(当然,要在机器的内存限制之内)整数运算。更常见的是,编程语言支持固 定精度的运算,因此像“加法”和"乘法”这样的运算不同千它们在整数上的相应运算。\n14\n图 2- 21 整数加法。对 于一个 4 位的字长 , 其和可能需要 5 位\n让我们为参数 x 和 y 定义运算 十心, 其中 O x , y\u0026lt;Z\u0026quot;\u0026rsquo;, 该操作是把整数 和 x + y 截断为 w 位得到的结果 ,再 把这个结果看做是一个无 符号数。这可以 被视为一 种形式的模运算, 对 x + y 的位级表示,简 单丢弃任何权重大千 zw-1 的位就可以计算出和模 2心。 比如, 考虑一个 4 位数字表示, x = 9 和 y = l Z 的 位表示分别为[ 1001] 和[ 1100] 。它们的和是 21, 5 位的表示 为[ 10101] 。但是如果丢弃最高位, 我们就得到[ 0101] , 也就是说,十进制值 的 5。这就和值 21 mod 16 = 5 一致。\n我们可以将操作十;描述为: 原理:无符号数加法\n对满足 O x , y \u0026lt; wZ 的 x 和 y 有:\nx+y, x+ y\u0026lt; 沪 正常\nX + ,y = {\nX + y- 2\u0026quot;\u0026rsquo;, 2切 x + y \u0026lt; zu+l 溢出\n(2. 11)\n图 2-22 说明了公式( 2. 11 ) 的这两种情况,左 边的和x + y 映射到右边的无符号 w 位的和 x + 切。正常情况下 x + y 的值保持不变,而溢出情况则是该和数减去沪的结果。\n推导:无符号数加法\n一般而言,我 们可以看到 ,如果 x + y \u0026lt; 2\u0026quot;\u0026rsquo; , 和的 w +\n1 位表示中 的最高位会等于 o , 因此丢弃它不会改变这个数\n值。另一 方面,如果 2气乓x + y \u0026lt; 2w+1 \u0026rsquo; 和的 w + l 位表示 图 2-22 整数加法和无符号加法中的最高位会等 于 1, 因此丢弃它就相当于从和中减去 间的关系。当x + y 大 于\n了 2\u0026quot;0\u0026rsquo;\n沪 - 1 时, 其和溢出 说一个算术运算溢出,是指完整的整数结果不能放到数据类型的字长限制中去。如等 式( 2. 11 ) 所示,当两 个运算数的和为 沪 或者更大时, 就发生了溢出。图 2-23 展示了字长w= 4 的无符号加法函数的 坐标图 。这个和是按模 24 = 1 6 计算的。当 x + y \u0026lt; l 6 时, 没有溢出 , 并且 x + y 就是 x + y。这对应于图中标记为“正常" 的斜面。当 x + y l 6 时, 加法溢出 , 结果相当于从 和中减去 16。这 对应于图中标记为 "溢出" 的斜面。\n图 2-23 无符号加法( 4 位字长, 加法是模 16 的)\n当执行 C 程序时, 不会将溢出作 为错误 而发信号。不过有的时候, 我们可能希望判定是否发生了溢出。\n原理:检测无符号数加法中的溢出\n对在 范围 O::( x , y::( UM a x\u0026quot; 中的 x 和 Y • 令 s 兰 x + 切 。 则 对计算 s\u0026rsquo; 当 且 仅 当 s \u0026lt; x\n(或者等价地 s\u0026lt; y ) 时, 发生了溢 出。\n作为说明 ,在 前面的示 例中, 我们看到 9 + n 2 = s 。由于 5 \u0026lt; 9 , 我们可以看出发生了溢出。\n推导:检测无符号数加法中的溢出\n通过观察发现 x + y x , 因此如果 s 没有溢出, 我们能够肯定 s 工。另一方面,如果\ns 确实溢出了, 我们就有 s = x + y — 2中。 假设 y \u0026lt; 2切, 我们就有 y — 沪 \u0026lt; O, 因此 s = x + ( y - 2勹<工。\n练习题 2. 27 写出一个具有如下原型的函数:\nI* Determine whether arguments can be added without overflow *I\nint uadd_ok(unsigned x, unsigned y);\n如果 参数 x 和 y 相加 不会产 生溢 出,这 个函数就 返回 l 。\n模数加法形成了一种数学结构, 称为 阿贝 尔群 ( A be lia n group), 这是以丹麦数学家Niels Henrik Abel( 180 2 18 29 ) 的名字命名。也就说,它 是可交换的(这就是为什么叫\u0026quot; a belia n\u0026quot; 的地方)和可结合的。它有一个单位元 o , 并且每个元素有一个加法逆元。让我们考虑 w 位的无符号数的集合, 执行加法运算 + ::,。 对千每个值 工,必 然有某个值—釭 满足一巨 +扛 = O。该 加法的逆操作可以 表述如下:\n原理:无符号数求反\n对满足 0 工< 沪 的任意 工,其 w 位的无符 号逆元 —扛 由下式给 出:\n玉=厂沪'- x ,\n该结果可以很容易地通过案例分析推导出来: 推导:无符号数求反\nx=O x\u0026gt;O\n( 2. 12)\n.\n+::, 下的逆元。\n练习题 2. 28 我们 能用 一个 十六进 制数 字来表 示长度 w = 4 的位模式。 对于这 些数 字的无符 号解释,使 用等 式( 2. 1 2 ) 填写下表 , 给出所 示数 字的 无符 号加 法逆元的 位表 示\n(用十六进制形式)。\n2. 3. 2 补码加法\n对于补码加法,我们必须确定当结果太大(为正)或者太小(为负)时,应该做些什么。\n给定在范围 - z-w I \u0026lt; x , Y\u0026lt; wz - 1 —1 之内的整数值 x 和 y , 它们的和就在范围— zw\u0026lt; x +\ny w2 - 2 之内, 要想准确表示, 可能需 要 w + l 位。就像以前一样, 我们通过将表示截断\n到 w 位, 来避免数据大小的不断扩张。然而, 结果却不像模数加法那样在数学上感觉很熟悉。定义 x + 切 为整数和 x + y 被截断为 w 位的结果, 并将这个结果看做是补码数。\n原理:补码加法\n对满足 — zw- I \u0026lt; x , Y \u0026lt; zw- 1 — 1 的 整 数 x 和 y , 有:\nx+ y-2\u0026quot;\u0026rsquo;, 2正 l x + y\n气 y { x + y , — 2正\u0026rsquo;,;;;X + y \u0026lt; z-• X + y + zw , X + y \u0026lt; — 2 正 l\n图 2-24 说明了这个原理, 其中, 左边的和 x + y\n的取值范围 为— 2-w 三x + y 冬 wz - 2 , 右边显示的是该\n正溢出正常负溢出\nx+y\n+zw\n(2. 13)\n和数截断为 w 位补码的结果。(图中的标号“情况 l \u0026quot; 情况4\n到“情况 4\u0026quot; 用于该原理形式化推导的案例分析中。)\n当和 x + y 超过 TMax 心时(情况 4) , 我们说发生了正溢 情况3\n出。在这种情况下, 截断的结果是从和数中减去 2\u0026quot;0'\n当和 x + y 小千 TMin 亿,时( 情况 1 )\u0026rsquo; 我们说发 生了 负 溢\n+ 2W一)\nx+\u0026lsquo;y\n+zw-1\n出。 在这种情况下 ,截断的结果是把和数加上 2\u0026quot;\u0026rsquo; 。\n两个数的 w 位补码之和与无 符号之和有完全相同的位级表示。实际上,大多数计算机使用同样的机器指令来执行无符号或者有符号加法。\n情况2\n情况I\n-2 W 一l\n-2 w\n-2 w-1\n推导:补码加法\n既然补码加法与无符号数加法有相同的位级表示, 我们就可以按如下步骤表示运算+ :,,: 将其参数转换为无符号数,执行无符号数加法,再将结果转换为补码:\n图 2- 24 整数和补码加法之间的关系。当 x + y 小 于一 2-u· l 时, 产生负溢出。当它大千 2w- l 时, 产生正溢出\nx + :,,y 丰 U 2Tw ( T 2Uw ( x ) + ::,T ZUw ( y ) ) (2. 14)\n根据等式 ( 2. 6), 我们可以把 TZU w ( x ) 写 成 Xw-1 沪 + x , 把 T ZU w ( y ) 写成 Yw-1 沪 + y 。使用属性,即十;是模沪的加法,以及模数加法的属性,我们就能得到:\nx + 心 y = u z 兀 ( T ZUw Cx ) + 汇 T ZUw( y ) )\n= u z兀 [ ( x 正 1 沪 十 x + Yw1- 沪 + y) mod 2勹\n= UZT w[ ( x + y) mod 2 勹\n消除了 x心一 1 沪 和Yw- 1 沪 这两项, 因为它们模 沪 等于 0。\n为了更 好地理解这个数最,定 义 z 为整数和 z 土 x + y , z \u0026rsquo; 为 z \u0026rsquo; 辛 z mod 2气 而 z\u0026quot; 为z\u0026quot;辛 u z兀 ( z \u0026rsquo; ) 。数值 z\u0026quot;等于 x + 切 。我们分成 4 种情况分析, 如图 2-24 所示。\n—2w:::今 \u0026lt; — 2-w l 。 然后, 我们会有 z \u0026rsquo; = z + 2切。 这就得出 o \u0026lt; z \u0026rsquo; \u0026lt; — 2-w\nl +2w =\nw2 - l 。 检查等式( 2. 7), 我们看到 z \u0026rsquo; 在满足 z\u0026quot;= z \u0026rsquo; 的范围之内。这种情况称为 负 溢出( nega­ ti ve overflow ) 。我们将两个负 数 x 和 y 相加(这是我们能得到 z \u0026lt; - 2w-l 的 唯一方式), 得\n到一个非负的结果 z\u0026quot;= x + y + w2 0\n- 2w气 \u0026lt; z \u0026lt; O。 那么, 我们又将有 z \u0026rsquo; = z + 2心, 得到— 2-w l + 2心 = 2切一1\u0026lt; z \u0026rsquo; \u0026lt; 2 二\n检查等式 ( 2. 7), 我们看到 z \u0026rsquo; 在满足 z\u0026quot; = z \u0026rsquo; —沪 的范围之内, 因此 z\u0026quot; = z\u0026rsquo; — 沪 = z 十沪 — 沪= z。也就 是说,我们 的补码和 z\u0026quot;等千整数 和 x + y 。\n0冬z \u0026lt; 2-w l 。那 么, 我们将有 z \u0026rsquo; = z , 得到 o\u0026lt; z\u0026rsquo; \u0026lt; 2-w\nl , 因此 z\u0026quot; = z\u0026rsquo; = z。补码和\nz\u0026quot;又等于整数和 x + y。\nzw- l z \u0026lt; 2切。 我们 又将有 z \u0026rsquo; = z , 得到 z-w 1 冬 z \u0026rsquo; \u0026lt; zw 。 但是在这个范围内 , 我们有\nz11 = z1 —沪 , 得到 z\u0026quot; = x + y - 2也。 这种情况称为正溢出( positive overflow ) 。我们将正数 x和 y 相加(这是我们能得到 z多 2心一]的唯一方式), 得到一个负数结果 z\u0026quot; = x + y - 2亿\u0026rsquo; 0 ■ 图 2- 25 展示了一些 4 位补码加法的示例作为说 明。每个示例的情况都被标号为对 应\n于等式( 2. 13 ) 的推导过程中的情况 。注意 24 = 1 6\u0026rsquo; 因此负溢出得到的结果比整数和大 16 , 而正溢出得到的结果比之小 16。我 们包括了运算数和结果的位级表示。可以观察到, 能够通过对运算数执行二 进制加法并将结果截断到 4 位,从 而得到结果。\n图 2- 25 补码加法示例。通 过执行运算数的二进制加法并将结果截断到 4 位, 可以获得 4 位补码和的位级表示\n图 2-26 阐述了字长 w = 4 的补码加法。运算数的范围为—8 7 之间。当 x + y \u0026lt; — 8 时, 补码加法就会负溢出 ,导 致和增加了 16。当一8 x + y \u0026lt; 8 时, 加法就产生 x + y。当x + y娑8 , 加法就会正溢出 ,使 得和减少了 16。这三种情况中的每一种都形成了图中的一个斜面。\n图 2- 26 补 码 加 法(字长为 4 位的情况下,当 x + y \u0026lt; - 8 时 ,\n产生负溢出;工 + y 多 8 时, 产生正溢出)\n等式 ( 2. 1 3 ) 也让我们认出了哪些情况下会发生溢出: 原理:梒测补码加法中的溢出\n对满足 T M i nw\u0026lt; x , y \u0026lt; T M a x 心的 x 和 y , 令 烂 = x + 切 。 当 且 仅 当 x \u0026gt; O, y\u0026gt;O, 但\n冬0 时, 计算 s 发 生了 正 溢出。 当 且 仅 当 x \u0026lt; O, y\u0026lt;O, 但s O 时 , 计算 s 发生了 负 溢出。图 2-25 显示了 当 w = 4 时 , 这 个 原 理 的 例 子 。 第 一 个 条 目 是 负 溢 出 的 情 况 , 两 个 负 数\n相加得到一个正数。最后一个条目是正溢出的情况,两个正数相加得到一个负数。 推导:检测补码加法中的溢出\n让我们先来分析正溢出。如果 x \u0026gt; O, y \u0026gt; O, 而 s \u0026lt; O, 那么显然发生了正溢出。反过来,正溢出的条件为: l)x\u0026gt;O, y\u0026gt; O( 或者 x + y \u0026lt; T M a x w ) , 2 ) s \u0026lt; O( 见 公 式 ( 2. 1 3 ) ) 。 同样的讨论也适用于负溢出情况。\n练习题 2. 29 按照 图 2- 25 的形 式填 写 下表。 分别 列 出 5 位参数的整数值、整数和 与补码 和的数值、 补码 和的位级表示 , 以及属于等 式 C2. 1 3 ) 推导中的哪种情况。\nX y [10100] [10001] [11000] [11000] [10111] [01000] (00010] [00101] [01100] [00100] 练习题 2. 30 写出一个具有如下原型的函数:\nI* Determine whether arguments can be added without overflow *I\nint tadd_ok(int x, int y);\n如果参数 x 和 y 相加不会产 生溢出 , 这 个函数就返回 1 。\n练 习题 2. 31 你的同事对你补码加法溢出条件的分析有些不耐烦了,他给出了一个函数 t a d d _o k 的实现, 如下所 示 :\nI* Determine whether arguments can be added without overflow *I I* WARNING: This code is buggy. *f\nint tadd_ok(int x, int y) { int sum= x+y;\nreturn (sum-x == y) \u0026amp;\u0026amp; (sum-y == x);\n}\n你看了代码以后笑了。解释一下为什么。\n练习题 2. 32 你现在有个任务, 编 写 函 数 t s u b_ o k 的代码 , 函数的参数是 x 和 y , 如果计算 x - y 不产 生溢出, 函数就返回 1 。假设你写 的练 习题 2. 30 的代码 如下所 示:\nI* Determine whether arguments can be subtracted without overflow *I I* WARNING: This code is buggy. *I\nint tsub_ok(int x, int y) {\nreturn tadd_ok(x, -y);\n}\nx 和 y 取什 么值时, 这 个 函 数 会 产 生 错误的 结 果? 写 一个 该 函 数 的正确 版 本(家\n庭作业 2. 74) 。\n2. 3. 3 补码的非\n可以看到范围在 TMinw x T M a x 心 中 的 每个数字 x 都有十;下的加法逆元, 我们将\n- :.,x 表示如下。\n原理: 补 码 的非\n对满足 T M i n u女 T M a x w 的 X , 其补码的非 一扛 由 下式给出\n飞={ T M i n w\u0026rsquo; X = T M i n w\n— x , X \u0026gt; TM i n w\n(2. 15)\n也 就 是 说 ,对 w 位的补码加法来说, T M i n切是 自己的加法的逆, 而 对 其 他 任何数值\nx 都有- x 作为其加法的逆。推导:补码的非\n观察发现 T M i un . + T M 匹 =_ z-u· 1 + ( — z-u· 1 ) = — 2心。 这 将导致 负 溢 出, 因 此\nT Mi nw已 T M i n w = —w2 十沪 = O。 对满足 x \u0026gt; T Minw 的 x , 数 值 - x 可以表示为一个 w 位\n的补码, 它 们的 和—x + x = O 。\n练习题 2. 33 我们 可以用 一个 十 六进制数 字 来表 示长 度 w = 4 的位模式。 根据这些 数字的 补码 的解释, 填写 下表, 确定 所示数 字的 加法 逆元。\n对于补码和无符号(练习题 2. 28 ) 非 ( ne ga t ion ) 产 生的 位模式 , 你观察到什么?\n一 补 码非的位级表示\n计算一个位级表示的值的补码非 有几种聪明的方 法。这些技术很有用(例如 当你 在调试程序的时候遇到值 Ox f ff f ff f a ) , 同时它们 也能够让你更了 解补码表示的 本质。\n执行位级补码非的笫一种方法是 对每一位求补, 再 对结果加 1。在 C 语言中, 我们可以说, 对于任 意整数值 x , 计算表达式- x 和 ~x +l 得到的结果 完全 一样。\n下面是 一些示例, 字长为 4 :\n-4 [0011]\n[11 11]\n从 前 面的例子我们知道 Ox f 的补是 Ox O, 而 Ox a 的补是 OxS, 因 而 Ox f ff ff ff a 是\n—6 的补码表示。\n计算一个数 x 的补码非的笫二种方法是建立在将位向 量分为两部分的基础之上的。假设\nk 是最右边的 1 的位置, 因 而 x 的位级表示形如[ x ,.,-1 , Xw-2• … , XHJ , 1, Q, … , O] 。\n( 只要 x #- 0 就 能够找到 这样的 K。)这个值的非写成二进制格 式就是[~乓- 1 , ~ x 心- 2 ,\n~ xHI , 1, Q, …, O] 。 也 就 是 , 我们对位 K 左边的所有位取反。\n我们用一些 4 位数字来说明这个方法,这里我们用斜体 来突出最右边的模式 1, o,…, 0:\nX 一X [1100] -4 [0100] 4 (1000] -8 [1000] -8 [0101] 5 [1011] -5 [0111] 7 [1001] - 7 2. 3. 4 无符号乘法 范围在 O x , y 沪 —l 内的整数 x 和 y 可以被表示为 w 位的无符号数, 但 是 它 们 的乘积 x • y 的取值范围为 0 到( 2w- 1 ) 2= z2w —zw+ l + 1 之 间 。 这可能需要 2w 位来表示 。不过,C 语言中的无符号乘法被定义为产生 w 位的值,就 是 2w 位的整数乘积的低 w 位表示的值。我们将这个值表示为 X * 切 。\n将一个 无符号数截断为 w 位等价于计算该值模 2切, 得到:\n原理:无符号数乘法\n对满足 O x , y UMa x 切 的 x 和 y 有:\nX * 沁y = (x• y)mod 2切 (2. 16)\n2. 3. 5 补码乘法\n范围在—z-w l X, y zw- 1 —1 内的整数 x 和 y 可以被表示为 w 位的补码数字,但 是它们的乘 积 x • y 的取 值范 围 为 - zw- 1 • ( zw- l_ l) = - 2红 - 2 + zw- 1 到 — zw- 1 0 — zw- 1 =\n_ zw-2 2 之 间 。 要 想 用 补 码 来 表 示这个乘积,可 能 需 要 2w 位。然而, C 语 言 中 的 有符号乘\n法是通 过将 2w 位的乘积截断为 w 位来实现的。我们将这个数值表示为 X * 切 。 将 一 个 补码数截 断为 w 位相当于先计算该值模 wz \u0026rsquo; 再 把 无 符 号 数 转换为补码 ,得 到 :\n原理:补码乘法\n对 满足 TM i n,,冬 x , y T M a x w 的 x 和 y 有 :\nx * :.,y = U 2T w ( ( x • y)mod 2勹 ( 2. 17)\n我们认为对于无符号和补码乘法来说,乘法运算的位级表示都是一样的,并用如下原 理说明:\n原理:无符号和补码乘法的位级等价性\n给定长度 为 w 的位向 量 王 和 y, 用 补 码形 式的位向量表 示 来定 义整数 x 和 y : x= B2TwG), y= B2兀 (y ) 。 用 无符号形 式的 位向 量表 示 来定义非 负 整 数 x \u0026rsquo; 和 y \u0026rsquo; : x \u0026rsquo; =\nB2UwG), y\u0026rsquo; = BZU 切(如 。 则\nT 2 凡 ( x 亡 y ) = U ZB 心 ( x \u0026rsquo; 已 y \u0026rsquo; )\n作 为说明,图 2-27 给出了不同 3 位数字的乘法结果。对于每一对位级运算数, 我们执行无符号和补码乘法,得 到 6 位 的 乘 积 ,然 后 再 把 这些乘积截断到 3 位。 无 符号 的截 断后的乘积总是等于 x • y mod 8。虽然无符号和补码两种乘法乘积的 6 位表示不同,但 是 截断后的乘积的位级表示都相同。\n推导:无 符 号 和 补码 乘 法 的 位 级 等价性\n根据等式( 2. 6), 我们有 x \u0026rsquo; = x + x w- 1 沪 和 y \u0026rsquo; = y + y心 一 1 沪 。 计 算 这 些 值 的 乘 积 模 2心\n得到以下结果:\n(x\u0026rsquo;• y\u0026rsquo;)mod w2 = [ ( x + x w1- 沪 ) • (y+ y 正 1 沪 ) ] mod 2心\n= [ x • y + (x匹 1 Y + Yur1- X) 沪 十 Xur1- Yur1- 22 勹mod 2\u0026quot;\u0026rsquo;\n= ( x • y) mod 2 切\n( 2. 18)\n由 千模运算符,所 有带有权重 沪 和 2红的项都丢掉了。根据等式( 2. 1 7 ) , 我们有 x * :Vy = U 2兀 ( ( x • y) mod 2\u0026quot;\u0026rsquo;) 。对等式两边应用操作 T 2Uw 有:\nT 2U心位 * 沁y ) = T 2U,, ( U 2兀 ( ( x • y) mod 2心)) = ( x • y) mod 2 心\n将 上述结果与式 ( 2. 1 6 ) 和式 ( 2. 18 ) 结 合 起 来 得 到 T 2U心 ( x * :Vy ) = ( x \u0026rsquo; • y\u0026rsquo;) mod zw=\nx \u0026rsquo; 亡 y \u0026rsquo; 。 然 后 对 这个等式的两边应用 U2B..,. , 得到 .\n模式 X y x·y 截断的x·y 无符号 5 [101) 3 [Oil] 15 [001III] 7 [111] 补码 -3 [IOI] 3 [011) - 9 [110111) -1 [I l l] 无符号 4 [100] 7 [Ill] 28 [011100] 4 [JOO] 补码 -4 [100] -1 [111] 4 (000100] —4 (100] 无符号 3 (011] 3 [Oil] 9 [001001] l [001] 补码 3 [Oil] 3 [Oil] 9 [001001] I [001] 图 2-27 3 位无符号和补码乘法示例。虽然完整的乘积的位级表示可能会不同 , 但是截断后乘积的位级表示是相同的\n练习题 2. 34 按照 图 2-27 的风格填写 下 表, 说明 不同的 3 位数 字乘 法的 结果 。\n模式 X y x ·y 截断的x ·y 无符号 (100) (101] 补码 [100] [ IO I] 无符号 [010] [III] 补码 [010] [111] 无符号 [I IO] [I IO] 补码 (110] [110] 练习题 2. 35 给你一个 任务, 开发 函数 t mu l t _ o k 的代码 , 该函 数会判断 两个 参数相乘是否会产生溢出。下面是你的解决方案:\nI• Determine whether arguments can be multiplied without overflow•I int tmult_ok(int x, int y) {\nint p = x•y;\n/• Either xis zero, or dividing p by x gives y•I return !x 11 p/x == y;\n}\n你用 x 和 y 的很多值 来测试这段代码 , 似 乎都 工作正 常。 你的同 事挑战 你, 说:\n“ 如果我不能用减法来 检验加法是 否溢 出(参见 练 习题 2. 31), 那么你怎么能用除法来检验乘法是否溢出呢?”\n按照 下面的思路, 用 数 学推 导来证 明 你的 方 法是对的。 首先, 证 明 x = O 的 情 况是正确 的。 另 外, 考虑 w 位数 字 X ( x -=/=-0) 、 y 、 p 和 q\u0026rsquo; 这里 p 是 x 和 y 补码 乘 法的结果, 而 q 是 p 除以 x 的结果。\n1 ) 说明 x 和 y 的整数 乘 积 X • y, 可 以写 成这样的形 式 : X• y= p + tzw , 其中,\nt -=l=-0 当且 仅当 p 的计算溢出。\n2 ) 说 明 p 可以写 成这样的形式 : p = x • q + r , 其 中 1 门\u0026lt; l x l 。\n3 ) 说 明 q = y 当 且 仅 当 r = t = O 。\n练习题 2. 36 对于数据 类型 i n t 为 3 2 位的情况, 设 计一个 版 本的 t mu l 七_ o k 函 数(练\n习题 2. 35), 使用 64 位精度的数据 类 型 i n t 64—七, 而 不使 用除法。\n国日XOR 库中的 安 全 漏 洞\n2002 年 ,人 们发现 S un M icro s ys t ems 公 司提 供 的 实现 XDR 库的代 码 有安 全 漏洞 ,\nXDR 库是一个广 泛使 用的 、 程序 间 共 享数 据 结 构 的 工具 , 造 成 这 个 安 全 漏洞的 原 因是程序会在毫 无察觉的情 况下产生乘法溢出。\n包含安全漏洞的代码与下面所示类似:\nI* Illustration of code vulnerability similar to that found in\n* Sun\u0026rsquo;s XOR library.\n*I\n4 void* copy_elements (void *ele_src [] , int ele_cnt, size_t ele_size) {\ns I*\n* Allocate buffer for ele_cnt objects, each of ele_size bytes\n* and copy from locations designated by ele_src\ns *I\nvoid *result= malloc(ele_cnt * ele_size);\nif (result == NULL)\nI* malloc failed *I\nreturn NULL;\nvoid *next= result·\nint i;\nfor (i ·= O; i \u0026lt; ele_cnt; i++) {\nI* Copy object i to destination *I\nmemcpy(next, ele_src[i], ele_size);\nI* Move pointer to next memory region *I\nnext+= ele_size;\n. 20 }\n21 return result;\n22 }\n函数 c op y_e l e me n t s 设 计 用 来将 e l e _c nt 个数据结构复制到笫 9 行 的 函 数 分 配的缓冲区中, 每 个数据结构 包含 e l e _ s i z e 个宇节。 需要的 字节数 是 通过 计算 e l e _c nt * e l e_s i ze 得到的。\n想象一下, 一个怀有恶意的程序 员在 被 编 译 为 32 位的 程序 中 用参 数 e l e _ c n t 等 于1048 577(220 + 1) 、 e l e _ s i z e 等 于 4096 ( 212) 来 调 用这 个函数。 然后 笫 9 行 上 的 乘 法会 溢出, 导致只 会 分 配 4096 个 字节 , 而不是 装 下这些数据所需要的 4 294 971 392 个宇节 。从第 15 行 开始的循环会试图复 制所有的 字节 , 超 越 已分 配的缓 冲 区的 界 限 , 因 而 破 坏了其他的数据结构。这会导致程序崩溃或者行为异 常。\n几乎每 个操作 系统 都 使 用了这 段 Sun 的代码,像 Intern et Explorer 和 Ker beros 验 证 系统 这 样 使 用 广 泛的 程 序 都 用到 了 它。 计 算机 紧急 响 应 组 ( Computer Emergency Response Team , CERT) , 由卡内基-梅隆软件工程协会 ( Carnegie Mellon Software Engineering Insti­ tute) 运作的一个追踪安全漏洞或失效的组织, 发 布了 建议 \u0026quot; CA-2002- 25\u0026quot; , 于是许多公司急忙对它们的代码打补丁。幸运的是, 还 没 有 由 于这个漏洞引起的安全失效的报告。\n库函数 c a l l o c 的 实现 中存在 着类似的 漏洞。 这 些已 经被修补过 了 。 遗 憾 的 是 , 许\n多程序员调用分 配函数(如 ma l l o c ) 时,使 用算 术表达式 作为参数, 并且 不 对这些表 达式进行 溢出检查。编写 c a l l o c 的可靠版本 留作 一道 练习题(家庭作业 2. 76 ) 。\n练习题 2. 37 现在你 有一个任务, 当数 据类型 i n t 和 s i z e —t 都是 32 位的 , 修补上述旁注给出的 XOR 代码中的 漏 洞。 你决 定将待分配 字节 数设置为数 据类型 u i n t 64_ t , 来消除 乘法溢 出的 可能性。 你把原来 对 ma l l o c 函数的调用(第 9 行)替换如 下: uint64_t asize =\nele_cnt * (uint64_t) ele_size;\nvoid *result= malloc(asize);\n提磋一下, ma l l o c 的参数类型是 s i ze _ 七。\n这段代码对原始的代码有了哪些改进? 你该如何修改代码来消除这个漏洞? 2. 3. 6 乘以常数\n以往,在 大多数机器上, 整数乘法指令相当慢, 需 要 10 个或者更多的时钟周期,然而其他整数运算(例如加法、减法、位级运 算和移位)只需要 1 个时钟周期。即使在我们的参考机器 In t el Core i7 H as well 上, 其整数乘法也需要 3 个时钟周期。因此, 编译器使用了一项重要的优化, 试着用移位和加法运算的组 合来代替乘以常数因子的乘法。首先, 我们会考虑乘以 2 的幕的情况, 然后再 概括成乘以任意常数 。\n原理: 乘以 2 的幕\n设 x 为位模 式[ x w- 1 , X..,_.- 2 • … , x 。] 表示的 无符 号整数 。那么, 对于任何 k O, 我们 都认为[ x w- 1 • Xw-2• …, Xo , 0 , … , O ] 给出 了 x 2k 的 w + k 位的 无符 号表示,这 里右边增 加 了 K 个 0。\n因此, 比如, 当 w = 4 时, 11 可以 被表示为[ 1011] 。k = 2 时将其左移得到 6 位向 量\n[101100], 即可编码 为无符号 数 11 • 4 = 44。\n推导:乘以 2 的幕\n这个属性可以通过等式( 2. 1) 推导出来:\nB 2U吐八[ Xw\u0026ndash;1-\n, Xw-2-\n认 尸 一1\n, …,工O ,o ,…,O ] ) = x ; 2\u0026rsquo;十k\n,- o # =[笘x 心]• 2k\n. = X 2k .\n当对固定字长左移 k 位时, 其高 k 位被丢弃, 得到\n[ x亡 k- 1 , x正 仁 2 \u0026rsquo; … ,Xo , Q , … ,O ]\n而执行固定字长的乘法也 是这种情况。因此,我 们可以看出左 移一个数值等价千执行 一个与 2 的幕相乘的无符号乘法。\n原理: 与 2 的幕相 乘的无符号 乘法\nC 变量 x 和 K 有无符 号数值 x 和 k\u0026rsquo; 且 O k\u0026lt; w , 则 C 表达式 x \u0026lt;\u0026lt;k 产 生数 值 X * ::,2k o\n由于固定大小的补码算术运算的 位级操作与其无符号运算 等价, 我们就可以对补码运算的 2 的幕的乘法与左移之间的关系进行类 似的表述:\n原理: 与 2 的幕相乘的补码乘法\nC 变量 x 和 K 有补码值x 和无符号数值k , 且 O k\u0026lt; w , 则 C 表达式x\u0026lt;\u0026lt;k 产生数值 x 只笠。\n注意,无 论 是 无 符 号 运算还是补码运算,乘 以 2 的 幕都 可能会导致溢出。结果表明, 即使溢出的时候, 我们通过移位得到的结果也是一样的。回到前面的例子, 我们将 4 位模式[10 11] ( 数值 为 11 ) 左移两位得 到[ 101100]( 数值为 44 ) 。将这个值截断 为 4 位得到[ 1100] ( 数值为 12 = 44 mod 16 ) 。\n由于整数乘法比移位和加法的代价要大得多,许 多 C 语言编译器试图以移位、加法和减法的组 合来消除很多整数乘以常数的情况。例如, 假设一个程序包含表达式 X * 1 4。 利用14 = 23 十沪 + 21 , 编 译 器会将乘法重写为 (x \u0026lt;\u0026lt;3 ) + (x \u0026lt;\u0026lt;2 ) + (x \u0026lt;\u0026lt;l ) , 将一个乘法替换为三 个移位和两个 加法。无论 x 是无符号的还是补码, 甚至当乘法会导致溢出时, 两 个 计 算 都会得到一样的结果。(根据整数运算的属性可以证明这一点。)更好的是, 编 译 器 还可以利用属性 14 = 24 - 21 \u0026rsquo; 将 乘 法重写为 (x « 4 ) 一 (x \u0026lt;\u0026lt;l ) , 这时只需要两个移位和一个减法。\n练习题 2. 38 就像我们 将在 第 3 章中看到 的 那样, L E A 指令能 够执行形如 (a \u0026lt;\u0026lt;k ) +b 的计 算, 这里 k 等于 0 、1 、2 或 3\u0026rsquo; 而 b 等于 0 或 者某个程序值。编译器 常 常用 这条指令 来执行常数因子乘法。 例 如, 我们 可 以 用 (a \u0026lt;\u0026lt;l ) +a 来计算 3*a 。\n考虑 b 等于 0 或者等于 a 、K 为 任意可 能的值的 情况 , 用 一条 L E A 指令 可以 计算\na 的哪 些倍 数?\n归纳一下我们的例子,考 虑 一 个 任 务 , 对于某个常数 K 的表达式 x * K 生成代码。编译器会将 K 的二进制表示表达为一组 0 和 1 交替的序列:\n[ (O···O) ( 1· •• l) ( O·· · O) · ··0 ·· · 1 ) ]\n例如, 1 4 可以写成[ ( O… 0 ) (1 11 ) ( 0 ) ] 。 考 虑 一组从位位置 n 到位位置 m 的连续的 l ( n\nm ) 。(对于 14 来说, 我们有 n = 3 和 m = l 。)我们可以用下面两种不同形式中的一种来计算这些位对乘积的影响:\n形 式 A: ( x« n ) + ( x « ( n—1 ) ) + …+ ( x \u0026lt;\u0026lt;m)\n形式 B: (x«(n+l))-(x\u0026lt;\u0026lt;m)\n把每个 这样连续的 1 的结果加起来, 不 用 做 任何乘法, 我们就能计算出 x * K。当然, 选择使用移位、加法和减法的组合, 还是使用一条乘法指令, 取 决 千这些指令的相对速度, 而这些 是与机器高度相关的。大多数编译器只在需要少量移位、加法和减法就足够的时候才使用这种优化。\n练习题 2. 39 对于位位置 n 为 最高有效位的 情况, 我们 要怎样修 改形 式 B 的表达式? 练习题 2. 40 对于下 面每个 K 的 值, 找 出 只 用 指定数 量的运 算表达 X * K 的 方 法, 这里我们认为 加法和 减法的开 销 相 当。 除 了 我们 已 经 考 虑 过的 简 单的 形 式 A 和 B 原则, 你可 能会需要使 用 一些技巧。\nK 6 移位 2 加法/减法 I 表达式 31 I I -6 2 I 55 2 2 练习题 2. 41 对于一组 从位位置 n 开始到 位位置 m 的连 续的 l\u0026lt; n m ) , 我们看到可以 产生 两种 形式的代码, A 和 B。编译器该如何 决定使用哪一种 呢?\n3. 7 除以 2 的幕\n在大多数机器上, 整 数 除 法要比整数乘法更慢一— 需要 30 个或者更多的时钟周期。\n除以 2 的幕也可以用移位运算来实现,只 不 过我们用的是右移, 而不是左移。无符号和补码数分别使用逻辑移位和算术移位来达到目的。\n整数除法总是舍入到零。为了准确进行定义,我 们要引入一些符号。对于任何实数 a ,\n定义La 」为唯一的整数 a \u0026rsquo;\u0026rsquo; 使 得 a \u0026rsquo; a \u0026lt; a \u0026rsquo; + l 。例如 , L 3. 1 4」= 3 , L- 3. 1 4」= - 4 而L 3 」=\n3 。同 样 ,定 义「a l 为唯一的整数 a \u0026rsquo;\u0026rsquo; 使得 a \u0026rsquo; —l \u0026lt; a a \u0026rsquo; 。例如 ,「3. 14 7= 4 , 「—3. 14 l= -3,\n而「3 1= 3。对于 x 多0 和 y\u0026gt; O, 结 果 会 是 L x / y 」, 而 对 于 x \u0026lt; O 和 y \u0026gt; O, 结 果 会是「x / y l。也就是说, 它将 向下 舍入一个正值, 而向上舍入一个负值。\n对无符号运算使用移位是非常简单的,部分原因是由千无符号数的右移一定是逻辑 右移。\n原 理: 除 以 2 的幕的 无符号除法\nC 变量 x 和 K 有无符号数值 x 和 k\u0026rsquo; 且 O k \u0026lt; w , 则 C 表达式 x \u0026gt;\u0026gt;k 产 生数 值L x / 2勹。例如 ,图 2-28 给出了在 12 340 的 16 位表示上执行逻辑右移的结果,以 及 对 它执行除\n以 1、2、16 和 256 的结果。从左端移入的 0 以斜体表示。我们还给出了用真正 的运算做除法得到的结果。这些示例说明,移位总是舍入到零的结果,这一点与整数除法的规则 一样。\n图 2- 28 无符号数除以 2 的幕(这个例子说明了执行一个逻辑右移k 位与\n除以 2k 再舍人到零有一样的效果 )\n推导:除 以 2 的幕的无符号除法\n设 x 为位模式[ x w- 1 • Xw- 2 , …, x 。] 表 示 的 无 符 号 整数, 而 K 的 取 值 范 围 为 O::( k \u0026lt;\nW 。 设 x \u0026rsquo; 为 w —K 位 位 表示[ Xw- 1 , Xw- 2 • … , Xk ] 的 无 符 号 数 , 而 x\u0026quot; 为 K 位 位表示[ Xk- 1 •\n…, x。]的 无 符号数。由此, 我们可以看到 x = 2坛'+工", 而 O::( x\u0026rsquo;\u0026rsquo; \u0026lt; 2k 。 因 此, 可得L x i\n沪」= x \u0026rsquo; 。\n对位向量[ x w- 1 , Xw- 2 , … , x。J逻辑右移 k 位会得到位向量\n[ O\u0026rsquo; … ,O , x 亿 一 1 , X已 ,…,k工] .\n这 个 位向量有数值 x \u0026rsquo;\u0026rsquo; 我们看到,该 值可以通过计算 x\u0026gt;\u0026gt;k 得到。\n对于除以 2 的幕的补码运算来说,情 况要稍微复杂一些。首先, 为了保证负数仍然为负,移 位 要 执行 的 是 算术右移。现在让我们来看看这种右移会产生什么结果。\n原理: 除 以 2 的幕的 补码除法,向 下舍 入\nC 变量 x 和 K 分别有补码值 x 和无符号数值 k\u0026rsquo; 且 O k \u0026lt; w , 则当执行算术移位时,\nC 表达式 x \u0026gt;\u0026gt;k 产生数 值L x / 2k 」。\n对 于 x O, 变扯 x 的最高有效位为 o, 所以效果与逻辑右移是一样的。因此,对千非负 数来说,算 术 右移 K 位 与除 以 沪 是 一样的。作为一个负数的例子,图 2-29 给出了对—12 340 的 16 位表示进行算术右移不同位数的结果。对于不需要舍入的情况( k = 1), 结果 是 x / 2k 0 但是 当需 要 进行舍入时,移 位导 致结 果 向 下 舍 入。例如, 右移 4 位将会把一771. 25 向下舍入为—772。我们需要调整策略来处理负数 x 的除法。\nk \u0026gt;\u0026gt;k C二进制) 十进制 - J234Q / 2k 。 I 4 8 ll OOll l l llOOll 00 -12340 -12340.0 JI 10011111100110 —6170 -6170.0 II JJI IOOI I I II IOO -772 -771.25 11JJJJJJ11001111 -49 —48.203 125 图 2- 29 进行算术 右移(这个例子说明了算术右移类似于除以 2 的幕, 除 了是向下舍入, 而不是向 零舍入)\n推导:除 以 2 的幕的补码除法,向 下 舍 入\n设 x 为位模式[ x w- 1 • 五 - 2\u0026rsquo; … , 工。]表 示 的 补 码 整数, 而 K 的 取 值 范 围 为 O k \u0026lt; w 。设 x \u0026rsquo; 为 w —k 位[ x w- 1\u0026rsquo; 江 - 2\u0026rsquo; … , Xk ] 表 示的补码数, 而 x\u0026quot;为低 k 位[ Xk- 1 , …, x o J 表 示的无符号数。通过与对无符号情况类似的分析, 我们有 x = 2坛\u0026rsquo; + x\u0026quot; , 而 O x \u0026ldquo;\u0026lt; Zk , 得到\nx\u0026rsquo; = 巨 / 2勹。 进 一 步 , 可以观察到, 算术右移位向量[ xw- 1 , Xw-2 , …, X。] k 位 ,得 到 位 向量\n[ x u气 ,…,Xu- 1 , Xu一 l , X已 ,…,Xk]\n它刚好就 是将[五- ] \u0026rsquo; 立 - 2 \u0026rsquo; … , Xk ] 从 w —k 位符号扩展到 w 位。因此, 这 个 移 位 后 的 位向量就是L x / 2勹的 补 码 表 示。\n我们 可以通过在移位之前 "偏置( biasin g) \u0026quot; 这个值, 来修正这种不合适的舍入。原理:除 以 2 的幕的补码除法,向 上舍入\nC 变量 x 和 K 分别有补 码值 x 和无符号数值 k\u0026rsquo; 且 O k \u0026lt; w , 则当执行算术移位时,\nC 表达式 (x + (l « k) — l ) » k 产生数 值L x / 2k 」。\n图 2-30 说明在执行算术右移之前加上一个适当的偏置量是如何导致结果正确舍入的。在第 3 列, 我们给出了—12 340 加上偏最值之后的结果,低 k 位(那些 会向 右移 出的 位)以 斜体表示 。我们可以看到,低 K 位 左 边 的 位可能会加 1 , 也可能不会加 1。对于不需要舍入的情 况( k = 1)\u0026rsquo; 加 上 偏 量 只 影响那些被移掉的位。对于需要舍入的情况,加 上 偏 量导致较高的 位加 1, 所以结果会向零舍入。\nk 偏量 -12 340 + 偏量 \u0026gt;\u0026gt; k (二进制) 十进制 - l 2340 /2k 。 I 4 8 。 I 15 255 1100111111001100 110011111100 I101 1100111111011011 1101000011001011 11001 l llllOOllOO JI 10011111100110 1111110011111101 11111lllllOIOOOO -12340 —6170 -771 -48 -12340.0 - 6170 . 0 —771.25 -48.203125 图 2-30 补码除以 2 的幕(右移之前加上一个偏蜇,结 果就向零舍入了)\n偏置技术利用如下属性:对 于 整 数 x 和 y ( y \u0026gt; O) , 「 x / y l = L\u0026lt;x + y —1) / y 」。例如, 当x = —30 和 y = 4 , 我们有 x + y —1 = — 27 , 而「- 30/ 4 7= - 7 = L— 27 / 4 」。当 x = — 32 和y = 4 时 , 我们有 x + y - l = - 29 , 而「—32/ 4 7= —8 = L—29/ 4」。\n推导: 除 以 2 的 幕 的 补 码除法,向 上 舍 入\n查看「x / y l = l ( x + y - l ) / y 」,假 设 x = qy + r , 其中 o\u0026lt;r \u0026lt; y , 得 到 ( x + y —1 ) / y =\nq+ ( r + y - l ) / y , 因此LC x + y - D / y 」= q+ LrC + y - 1 ) / y 」。 当r = O 时 ,后 面一项等千 o ,\n而当 r \u0026gt; O 时 ,等 于 1 。也 就 是 说 , 通过给 x 增加一个偏量 y - 1, 然后再将除法向下舍入, 当 y 整除 x 时, 我们得到 q , 否则, 就得 到 q+ l 。\n回到 y = 护 的情况, C 表达式 x + ( l \u0026lt;\u0026lt;k ) - 1 得到数值 x + 2k — l 。 将这个值算术右移 K\n位即产生巨 / 2k 」0 ■\n这个分析表明对于使用算术右 移的补码机器 , C 表达式\n(x\u0026lt;O? x+(1\u0026lt;\u0026lt;k)-1 : x)»k\n将会计算数值 x / 2* o\n练习题 2. 42 写 一个 函 数 d i v 1 6 , 对 于整数 参 数 x 返 回 x / 1 6 的 值。 你的 函 数 不 能使用 除法、 模运算、 乘 法、 任 何 条 件 语 句(江 或 者?:)、 任 何 比 较 运 算 符( 例 如 \u0026lt;、\n>或==)或 任何 循 环。 你可以 假设数 据 类 型 i n t 是 3 2 位 长 ,使 用 补码 表 示 , 而右 移\n是算术右 移。\n现在我们看到 , 除以 2 的幕可以通过 逻辑或 者算术右移来实现。这也正是为什 么大多数机器上提供 这两种类型的右移。不幸的是, 这种方法不能推广到 除以任意常 数。同乘法不同, 我们不能用除以 2 的幕的除法来表示除以 任意常数 K 的除法。\n练习题 2. 43 在下 面的 代码 中,我们 省略 了常数 M 和 N 的定 义:\n#define M /* Mystery number 1 *I\n#define N I* Mystery number 2 *I int arith(int x, int y) {\nint result= O;\nresult= x*M + y/N; I* Mand N are mystery numbers. *I return result;\n}\n我们以 某个 M 和 N 的值编 译这段代码。 编译器用 我们 讨论 过的方 法优 化乘 法和除\n法。 下面是将 产 生出的 机器代 码翻译 回 C 语言的 结果 :\n/• Translation of assembly code for arith•/ int optarith(int x, int y) {\nint t = x;\nX \u0026lt;\u0026lt;= 5;\nX -= t;\nif (y \u0026lt; 0) y += 7;\ny»= 3; I• Arithmetic shift•/ return x+y;\nM 和 N 的值为 多少?\n2. 3. 8 关千整数运算的最后思考\n正如我们看到的 , 计算机执行的 “整数” 运算实际 上是一种模运算形式。表示数字的有限字长限制了可能的值的取值范围 ,结 果运算可能溢出。我们还看到, 补码表示提供了一种既能表示负数也能表示正数的灵活方法 ,同 时使用了与执行无符号算术相同的位级实现,这些运算包括像加法、减法、乘法,甚至除法,无论运算数是以无符号形式还是以补 码形式表示的,都有完全一样或者非常类似的位级行为。\n我们看 到了 C 语言中的某些规定可能会产生令人意想不 到的结果 , 而这些结果 可能是难以察觉或理解的缺陷的源头。 我们特别 看到了 u n s i g ne d 数据类型,虽 然它概念上很简单, 但可能导致即使是资深程序员都 意想不到的行为。我们还看到这种数据类型会以出乎意料的方式出现, 比如,当 书写整数常数 和当调用库函数时。\n区U 练习题 2. 44 假设我们在对有符号值使用补码运算的 32 位机器上运行代码。对于有符号值使用的是算术右移,而对于无符号值使用的是逻辑右移。变量的声明和初始化如下:\nint x \u0026ldquo;\u0026lsquo;foo(); I* Arbitrary value *I\nint y \u0026ldquo;\u0026lsquo;bar(); I* Arbitrary value *I\nunsigned ux = x; unsigned uy = y;\n对于下面每个 C 表达 式, 1) 证明对 于所有的 x 和 y 值, 它都 为 真(等于 1) ; 或者\n2 ) 给出使得 它为假(等于 0 ) 的 x 和 y 的值 :\nA. (x \u0026gt; 0) 11 (x-1 \u0026lt; 0)\nB. (x \u0026amp; 7) ! = 7 11 (x«29 \u0026lt; o)\nC. (x * x) \u0026gt;= 0\nx \u0026lt; 0 I I -x \u0026lt;= 0\nx \u0026gt; 0 I I -x \u0026gt;= 0\nx+y == uy+ux\nX*-y + UY*UX == -x\n4 浮点数\n浮点表示对 形如 V= x X 护 的有理 数进行编码。它对执行涉及非常大的数字( I VI \u0026gt;\u0026gt;\n、非常接近于 O( IV l \u0026lt;\u0026lt; D 的数 字, 以及更普遍地作为实数运算的近似 值的计算, 是很有用的。\n直到 20 世纪 80 年代,每 个计算机制造商都设计 了自己的 表示浮点数的规则, 以及对浮点数执行运算的细节。另外,它们常常不会太多地关注运算的精确性,而把实现的速度 和简便性看得比数字精确性更重要。\n大约在 1 985 年, 这些情况随着 IE EE 标准 75 4 的推出而改变了, 这是一个仔细制订的表示浮点 数及其运算的标 准。这项工作是从 1976 年开始由 I nt el 赞助的, 与 808 7 的设计同时进 行, 808 7 是一种为 8086 处理器提供浮点支持的芯片。他们请 Will ia m Kahan(加州大学伯克利分校的一位教授)作为顾问 , 帮助设计未来处理器浮点标准。他们支持 Kaha n 加入一个 IE E E 资助的制订工业标准的委员会。这个委员会最终采纳的标准非常接近于\nKahan 为 Int el 设计的标准。目 前,实 际上所有的计算机都支持这个后来被称为 IE E E 浮点的标准。这大大提高了科学应用程序在不同机器上的可移植性。\nm IEEE ( 电气和电子工程师协会)\n电气和电 子工程 师协会( IE E E , 读做 \u0026quot; eye- t rip le-ee\u0026rdquo; ) 是一个包括所有电子和计算机技术的专业团体。它出版刊物,举办会议,并且建立委员会来定义标准,内容涉及从电 力传 输到软件 工程 。另一 个 IE E E 标准的 例子是 无线 网络的 802. 11 标准。\n在本节中, 我们将看到 IE E E 浮点 格式 中数字是如何表示的。我们还将探讨 舍入( rounding ) 的问题, 即当一个数字不能被准确地表示为这种格式时 , 就必须向上调整或者向下调整。然后,我们将探讨加法、乘法和关系运算符的数学属性。许多程序员认为浮点 数没意思 , 往坏了说, 深奥难懂。我们将看到, 因为 IE E E 格式是定义在一组小而一致的原则上的,所以它实际上是相当优雅和容易理解的。\n2. 4. 1 二进制小数\n理解浮点数的第一步是考虑含有小数值的二进制数字。首先,让我们来看看更熟悉的十进制表示法。十进制表示法使用如下形式的表示:\nd \u0026lsquo;\u0026ldquo;d 1 ···d i d 。. d - 1d-2···d-n\n其中每个十进制数 d, 的取值范围是 0~ 9。这个表达描述的数 值 d 定义如下 :\nd = ;t\u0026quot;lO; X d,\n数字权的定 义与十进制小数点符号( \u0026lsquo;.\u0026rsquo;)相关, 这意味着小数点左边的数字的权是 10\n的正幕, 得到 整 数值, 而小数点右边的数字的权是 10 的负幕, 得到小 数值。例如,\n12. 3 知 表示数字 1 X 101 + 2 X 10° + 3 X 10-1 + 4 X 1-0\n2 =12 34\n100°\n类似, 考虑一个形如\n丛bm- 1 •;• bl b。. b一I b- 2 •••b- n- 1b- n\n的表示法,其中每个二进制数字,或者 称为 位, b; 的取值范围是 0 和 1, 如图 2-31 所示。这种表示方法表示的数 b 定义如下:\n2m\nz m-1\n二:\nb = 2i X b; (2. 19)\n, = - n\n符号\u0026rsquo;.\u0026lsquo;现在变为了二进制的点,点 左边的位的权是 2 的 正幕,点 右边的 位的权是 2 的负幕。例如, 1 01. 112 表示\n数字 1 X 22 + 0 X 21 + 1 X 2° + 1 X -2 1 +\n1/2n-1\n1/2\u0026rdquo;\n1 x -2\n2= 4+ 0 + 1 + 丿 + 1_ = 5 立\n2 4 4° 图 2-31 小数的二进制表示。二进制点左边的数字的\n从 等式 ( 2. 19 ) 中可以 很容易 地 看 权形如 ;2 \u0026rsquo; 而右边的数字的权形如 1 / 2\u0026rsquo;\n出, 二进制小数点向左移动一位相当于这个数被 2 除。例如, 101. 11 2 表示数 5 一, 而\n10. 1112表示数 2+ 0 +\n1 1 1 7\n— + — + - = 2 - 。类 似, 二进制小数点向右移动一位相当千将该\n2 4 8 8\n数乘 2。例如 1011. 12 表示数 s + o + 2+ 1 + - = 11 -\n2 2°\n注意, 形如 0. 11··士 的数表示的是刚好小于 1 的数。例如, 0. 1111112\n63\n表示祈, 我们\n将用简单的表达法 1. 0 - € 来表示这样的数值。\n假定我们仅考虑有限长度的编码,那么十进制表示法不能准确地表达像一和—这样的 数。类似,小 数的二进制表示法只能表示那些能够被写成 x X 护的 数。其他的值只能够被近似地表示。例如, 数字— 可以用十进制小数 0. 20 精确表示。不过, 我们并不能把它准\n确地表示为一个二进制小数,我们只能近似地表示它,增加二进制表示的长度可以提高表示的精度:\n练习题 2. 45 填写下 表中的缺 失的信息 :\n练习题 2 . 46 浮点运算的 不精 确性能够产 生灾 难性 的后果 。1 9 91 年 2 月 2 5 日 , 在第一次海湾战 争期间 , 沙特 阿拉伯 的达摩地 区设 置的 美国爱国 者导 弹, 拦截伊拉克 的飞毛腿导 弹失败。 飞毛腿 导弹 击中 了 美国的 一个 兵 营, 造成 2 8 名 士 兵 死亡。 美国 总审计局 ( G AO ) 对失败原 因做 了 详细的 分析[ 76] , 并且确定底层的原因在于一个数字计算不精确。 在这 个练 习中,你将 重现 总审计局分 析的 一部 分。\n爱国者 导弹 系统 中含 有 一个内置的 时钟 , 其实现 类似 一个 计数器, 每 0. 1 秒就 加\n1 。为 了以 秒为 单位 来确定 时间 , 程 序将用 一个 2 4 位的近似 于 1 / 1 0 的 二进 制小 数值来乘以 这个计数 器的值。 特别地 , 1 / 1 0 的二进 制表 达式是 一个无 穷序 列 0 . 0 0 0 11 0 0 1 1 [ 0 0 11 ] …2\u0026rsquo; 其中, 方括号里 的部 分是无限重复的 。 程序用值 x 来近似 地表 示 0 . 1, X\n只考虑这个 序 列 的 二 进制 小 数 点 右 边 的 前 2 3 位: :r = 0 . 0 0 0 11 0 0 11 0 0 11 0 0 11 0 0 11 0 0 。\n(参考练 习题 2. 51, 里 面有 关于如何 能够 更精确地 近似表 示 0. 1 的讨论。)\n0 . 1- x 的二进 制表 示是什 么?\n0. 1 - x 的近似的十进 制值是 多少?\nc. 当系 统初始启 动 时, 时钟 从 0 开始, 并且一直保持计 数。 在这个例 子中, 系统 巳经运 行了大 约 1 0 0 个小 时。 程序计 算出的 时间和 实际 的时间之差 为 多少?\nD. 系统 根据一枚来袭导 弹的 速 率和 它最 后被 雷达侦 测 到的 时间, 来预 测 它 将在 哪里出现 。假定飞毛腿 的速率 大约是 2 0 0 0 米每 秒, 对它的预测 偏差 了多 少?\n通过一次读取 时钟得到的绝对 时间 中的轻微错误 , 通常不会 影响 跟踪的 计算。相反, 它应该 依赖于两次连续的读取之间的相对时间。问题是爱国者导弹的软件 巳经升级 , 可以使用更精确的函数来读取时间 , 但不 是所有的 函数调 用都用 新的代码替 换了。 结果 就是 , 跟踪软件 一次读取用的是精确的时间 ,而另 一次读取用的是不精确的时间 [ 10 3] 。\n4. 2 IEEE 浮点 表示\n前一节中谈到的定点表示法不能很有效地表示非常大的数字。例如, 表达式 5 Xz 1 0 0 是用 101 后面跟随 1 00 个零的位模式来表示。相反 , 我们希望通过给定 x 和 y 的值, 来表示形如 x X 护 的数 。\nIEEE 浮点标准用 V = C— l ) \u0026rsquo; X M X 沪的形式来表示一个数:\n符号( sig n ) s 决定这数是负数 Cs = 1 ) 还是正 数( s = O) , 而对于数值 0 的符号位解释作为特殊情况处理。 尾数( s ig ni fi cand ) M 是一个二进制小数, 它的范围是 1 ~ 2—€\u0026rsquo; 或者是 O~ l —C。 阶码( ex po nen t ) E 的作用是对浮点数加权 ,这 个权重是2 的 E 次幕(可能是负数)。将浮点数的位表示 划分为三个字段,分别 对这些值进行编码: 一个单独的符号位 s 直接编码符号 s 。 k 位的阶码字段 e xp = ek- 1…e1e 。编码阶码 E。\nn 位小数字段 fr a c = f n- 1 …八儿 编码尾数 M, 但是编码出来的值也依赖于阶码字段的值是否等千 0。\n图 2- 32 给出了将这三个字段装进字中两种最常见的格式。在单精度浮点 格式C C 语言中的 fl oa t ) 中, s 、 e x p 和 fr a c 字段分别 为 1 位、k = 8 位和 n = 23 位, 得到一个 32 位的\n表示。在双精度 浮点格式cc 语言中的 do ub l e ) 中, s 、 e xp 和 fr a c 字段分别为 1 位、k =\n11 位和 n = 52 位, 得到一个 64 位的表示。\n单精度\n31 30 23 22\n双精度\n63 62 52 51 32\nfrac(31:0)\n图 2-32 标 准 浮点格式(浮点数由 3 个字段表示。两种最常见的格式是它们被封装到 32 位(单精度)和64 位(双精度)的字中)\n给定位 表示, 根据 e x p 的值, 被编码的值可以分成三种不同的情况(最后一种情况有两个变种)。图2-33 说明了对单精度格式的情 况。\n规格化的 非规格化的 3a. 无穷大\n3b.NaN\n日粗巨冈荆咽 ¥-0\n图 2-33 单精度浮点数值的分类(阶码的值决定了这个数是规格化的、非规格化的或特殊值)\n情况 1 : 规格化的值\n这是最普遍的 情况。 当 e xp 的位模式既不全为 0 ( 数值 0 ) , 也不全为 1(单精度数值为\n255, 双精度数值为 2047) 时, 都属于这类情况。在这 种情况中,阶码 字段被解释为以偏置\n(biased) 形式表示的 有符号整数。也 就是说, 阶码的值是 E = e- Bias , 其中 e 是无符号数,\n其位表示为 ek -1 …e心 , 而 Bias 是一个等于 2- k l -1 ( 单精度是 127 , 双精度是 1023) 的偏置\n值。由此 产生指数的取值范围, 对千单 精度是—126 ~ + 127, 而对于双精度是—1022 ~\n+ 1023。\n小数字段 fr a c 被解释为描述小数值 f , 其中 o::;;;;J \u0026lt; I. 其二进制表示为 0. f n- 1 …\n!1!0• 也就是二进制小数点在最高 有效位的左边。尾数定义为 M = l + J 。有时 , 这种方式 也叫做隐 含的以 1 开头的 ( implied leading 1 ) 表示 , 因为我们可以 把 M 看成一个二进制表 达式为 l. f n - Jf n- 2 … 儿的数字。既 然我们总是能够调整阶码 E , 使得尾数 M 在范围 1:::;;; M\u0026lt; 2 之中(假设没有溢出), 那么这种表示方法是一种轻松获得一个额外精度位的技巧。既然第一位总是 等千 1, 那么我们就不需要显式地表示它。\n情况 2 : 非规格化的值\n当阶码域 为全 0 时, 所表示的数 是非规 格化形 式。在这种情况下, 阶码值是 E = l ­\nBias, 而尾数的 值是 M= f , 也就是小数字段的 值, 不包含隐含的开头的 1。\nm 对千非规 格化值 为什 么要这样设 置偏置值\n使阶码值为 l - Bia s 而不 是简单的—Bia s 似乎是 违反直觉的 。我们将很快看到 , 这种方式 提供了 一种从 非规 格化值平滑转换到规 格化值的方法 。\n非规格化数有 两个用途。首先, 它们提供了一种表示数值 0 的方法, 因为使用规格化\n数,我们必须总 是使M诊1, 因此我们就不能表示 0。实际上,十 0. 0 的浮点表示的位模式为全 0: 符号位是 o, 阶码字段全为 0( 表明是一个非规格化值), 而小数域也全为 o, 这就得到\nM= f = O。 令人奇怪的是, 当符号位为 1 , 而其他域全为 0 时, 我们得到值— 0. 0。根据\nI庄 E 的浮点格式, 值+ o. o 和—0. 0 在某些方 面被认为是不同的 , 而在其他方面是相同的。非规格化数的 另外一个功能是表示那些非常接近于 0. 0 的数。它们提供了一种属性,\n称为逐渐 溢出 ( grad ual underflow) , 其中, 可能的数值分布均匀地接近于 0. 0。情况 3 : 特殊值\n最后一类数值是当指阶码全为 1 的时候出现的。当小数域全为 0 时, 得到的值表示无穷,当 s = O 时是十= , 或者当 严 1 时是一~ 。当 我们把两 个非常大的数相乘, 或者除以零时,无穷能 够表示溢出的结果 。当小数域为非 零时, 结果值被称为 \u0026quot; NaN\u0026rdquo; , 即“不是一个数( Not a Number)\u0026rdquo; 的缩写。一些运算的结果不能是实数或无穷, 就会返回这样的 NaN 值, 比如当计算F 了或~ —~时。在某些应用中, 表示未初始化的数据时 , 它们也很有用处。\n2. 4. 3 数字示例\n图 2-34 展示了一组数值,它 们可以用假定的 6 位格式来表示, 有 k = 3 的阶码位和n =\n2 的尾数位。偏置量是 23- J —1 = 3。图中的 a 部分显示了所有可表示的 值(除了 Na N ) 。两个无穷值在两个末 端。最大数量值的规 格化数是土14 。非规 格化数聚集在 0 的附近。图 的\nb 部分中 ,我 们只展 示了介于— 1. 0 和十1. 0 之间的数值,这 样就能够看得更加清楚 了。\n两个零是特殊的非 规格化数。可以观察到, 那些可表示的 数并不是均匀分布的一 越靠近原点处它们越稠密。\nn 众\n-oc\n分 志 * 金\n-10\n-5 0 +5\n七 众\n+10\n.. \u0026hellip;. 口\n+ oo\nI • 非规格化的\n\u0026hellip; 规 格化. 的..\no 无-\n穷. J\n^ , 山\n-1 -0.8\n血 , 血 血 , 血 ^\n—0 .6 -0.4\na ) 完整范围\n血..\n- 0 . 2\n. .,.\n+ 0.2\n血 血 I • .t,. I 心\n+0.4 +0.6\n么 , 心\n+0.8\nF 格化的 \u0026hellip; 规格化的 a 无 穷 l\nb) 范围在- 1.0 ~ +1.0的数值\n图 2-34 6 位浮点格式可表示的值( k = 3 的阶码位和 11= 2 的 尾数位。偏 置量 是 3 )\n图 2-35 展示了假定的 8 位浮点格式的示例,其 中 有 k = 4 的阶码位和 n = 3 的 小数 位。偏置量是 2-1 1 - 1 = 7。图被分成了三个区域,来 描 述三类数字。不同的列给出了阶码字段是如何编码阶码 E 的,小 数 字段是如何编码尾数 M 的, 以 及 它 们 一 起 是 如何形成要表示的值 V = 2E X M 的。从 0 自身开始,最 靠 近 0 的是非规格化数。这种格式的非规格化数的\nE = l —7 = —6 ,\n1\n得到权沪=6-4 °\n小数J 的值的范围是o,\n7\n— ,从 而得到数 V 的\n8\n范围是 o -1 x -7 = 7\n64 8 512°\n描述 位表示\n指数 小数\ne E 2£ f M\n。 0 0000 000 。-6 I i 8\n最小的非规格化数 0 0000 001 -6 I\n0 0000 010 。-6\n召 k k\n忐 i i\n0 0000 011 -6 I\n召 i i\n最大的非规格化数 00000111 。-6 I 7 7\ni l\n忐 # I 9\n8 8\n14\n可\n0 0110 Ill 6 - 1 ½ 。 孕\nI 00111 000 7\nl 8 I\n。 l k 9\n2\n8\ni # 7\n8\n图 2-35 8 位浮点 格式的非负值示例( k = 4 的 阶码位的和 n = 3 的小数位。偏置员是 7)\n这种形式的最小规格化数同样有 E = l - 7 = -6, 并且小数取值范围也为 o,\n... ,\n7\n一。然而,\n8\n尾数在范围 1 + 0 = 1 和 1 +\n7 15\n—8 =一8 之间\n得出数 V\n8 1 15\n在范围一 =— 和一-之间。\n512 64 512\n8\n可以观察到最大非规格化数百歹和最小规格化数可石之间的平滑转变。这种平滑性归功\n于我们 对非规格化数的 E 的定义。通过将 E 定义为 1— Bia s , 而不是—Bia s , 我们可以补偿非规格 化数的尾数没有隐含的开头的 1。\n当增大 阶码时, 我们成功地得到更大的规格化值, 通过 1. 0 后得到最大的规格化数。\n这个数具有 阶码 E = 7 , 得到一个 权 2E = 1 28。小 数等 千—8 得到尾数\n15\nM = -8 。因此, 数 值\n是 V= 240 。超出这个值就会溢出到十~ 。\n这种表示 具有一个有趣的属性, 假 如 我们将图 2-35 中 的值的 位 表达式解释为无符号整数, 它们就是按升序排列的, 就 像 它 们 表示的浮点数一样。这不是偶然的 IEEE 格式如此设计 就是 为了浮点数能够使用整数排序函数来进行排序。当处理负数时,有 一 个 小的难点, 因为它们有开头的 1 , 并且它们是按照降序出现的,但是不需要浮点运算来进行比较也能解决 这个问 题(参见家庭作业 2. 84 ) 。\n区i 练习题 2. 47 假设一个基于 IE EE 浮点格式的 5 位浮点表 示, 有 1 个符 号位、2 个阶\n码位 ( k = 2) 和两个 小数位( n = 2 ) 。 阶码偏 置 量是 2- 2 1 —l = l 。\n下表 中列举 了这个 5 位浮点表示的 全部非负 取值范围。 使用 下面的条件, 填写 表格中的空白项:\ne: 假定阶码 字段 是一个 无符号整数所表 示的 值。\nE: 偏置之后的阶码值。\n2气 阶码的权重。\nJ: 小数值。\nM: 尾数的值。\n沪 X M : 该数(未归 约的)小数值。\nV: 该数归约 后的 小数值。\n.十进制 : 该数的 十进制表 示。\n写 出 2气 J、M、2E X M 和 V 的值, 要 么是 整数(如果可能的话),要 么是 形如王的小数 , 这里 y 是 2 的幕。标注为 “一 ” 的条目不用填。\n位 e E 2£ f M 2ExM V 十进制 0 00 00 0 00 01 0 00 10 0 00 11 0 01 00 0 01 01 I 。 I ¾ ¾ ¾ ¾ 1.25 0 01 10 0 01 11 0 1 0 00 0 10 01 0 1 0 1 0 0 1 0 11 0 11 00 0 11 01 0 1110 0 1111 图 2-36 展示了一些重要的单 精度和双精度浮点 数的表示和数字值。根据图 2-35 中展示的 8 位格式, 我们能够看出有 K 位阶码 和 n 位小数的浮点表示的一般属性。\n描述 exp fr a c 单精度 双精度 十进制 十进制 。 最小非规格 化 数 00· · ·00 00· · ·00 0···00 0 - · · 01 。值 2 - 23 X 2-126 (J-E:)xz-126 Jx 2-126 I X 2° (2 - E:) X 2127 0.0 \\.4 X 10- 45 J.2 X J0- 3S J.2 X 10- 38 1.0 3.4 X 1038 。值 z-52 X z-1022 (I - c ) X z-1022 I x 2-1022 I X 2° (2-1,) x 21023 0.0 4.9 X 10-324 2.2 X 10- 308 2.2 X JO- JOS 1.0 J.8 X JQJOS 最 大非规格化数 00· · ·00 I· ··11 最小规格化数 00· ·· 01 0 .. · 00 1 01· ·· II -0 ·· 00 最大规格化数 I I .. ·IO I .. ·II 图 2-36 非 负 浮 点 数 的 示 例\n值+ o. o 总 有一个全为 0 的位表示。 最小的正非规格化值 的位表示 , 是由最低有效位为 1 而其他 所有位为 0 构成的。它具有小数(和尾数)值M = f= 2- • 和阶码值 E = - 2k- ) + 2。因此它的数字值是 V= z - n- -2\u0026rsquo;\n\u0026rsquo; +2 。\n最大的非规格化值的位模式是由全为 0 的阶码字段和全为 1 的小数字段组成 的。它 有小数(和尾数)值M = f = l -\n-2 \u0026quot; ( 我们写成 1 - c) 和阶码值 E = — zk- 1 + z。因此,\n数 值 V = o - z- • ) x -z 2 +2 , 这仅比最小的规格化值小一点。\n最小的正规格化值的位模式 的阶码字段的最低有效位为 1, 其他位全为 0 。它的尾 数值 M = l , 而阶码值 E = — z- k\n1 + z 。 因此, 数值 V = -z l- 1 +2 .\n值 1. 0 的位表示的阶码字段除 了最高有效位等千 l 以外, 其他位都 等于 0。它的尾数值是 M = l , 而它的阶码值是 E = O。\n最大的规格化值的位表示的符号 位为 o, 阶码的最低有效位等千 o, 其他位等于 1。\n它的小数值 f = l - -z • , 尾数 M = z - -z • ( 我们写作 2—c) 。它 的 阶码值 E = zk-_ 1\n1\u0026rsquo; 得到数值 V = ( Z—2一\u0026quot;) X 22 -I = (1 — z - •- l ) X2 ;-2 i 0\n练习把一些整数值转 换成浮点 形式对理解浮点 表示很有用。例如, 在图 2-1 5 中我们看到 1 2 345 具有二进 制表示[ 11 000000111 001 ] 。通过将二进制小数点左移 1 3 位, 我们 创\n建这个数的一个规格化表示 ,得到 1 2345 = 1. 100000011 10012 X 21 3 。 为了 用 IEEE 单精度 形式来编码, 我们丢弃开头的 1\u0026rsquo; 并且在末尾增 加 10 个 o, 来构造小数字段,得到二进制表示[ 10000001110010000000000] 。为了构造阶码字段, 我们用 13 加上偏置扯 127 , 得到\n140, 其二进 制 表示 为[ 100011 00 ] 。加 上 符 号 位 0 , 我们就得到二进制的浮点表示\n[ 0100011001000000111010000000000] 。回想一下 2. 1. 3 节, 我们观察到整数值 1 23 45\n( Ox 303 9) 和单精度浮点值 1 23 45 . 0( 0x 4640E400 ) 在位级表示上有下列关系:\n0 0 0 0 3 0 3 9\n00000000000000000011000000111001\n*************\n4 6 4 0 E 4 0 0\n01000110010000001110010000000000\n现在我们可以看到, 相关的区域对应于整数的低 位, 刚好在等于 1 的最高有效位之前停止(这个位就是隐含的开头的 位 1 )\u0026rsquo; 和浮点表示的小 数部分的高位是相匹配的。\n练习题 2. 48 正 如 在 练 习 题 2. 6 中 提 到 的, 整 数 3 510 593 的 十 六 进 制 表 示 为\nOx00359141, 而单 精度 浮点数 351 05 93 . 0 的十 六进 制表 示为 Ox 4A5 645 0 4。 推导 出这个浮点表示,并解释整数和浮点数表示的位之间的关系。\n练习题 2. 49\n对于一种具 有 n 位小 数的 浮点格 式, 给出不能准 确描 述的 最小正 整数的公 式(因为要想 准确 表 示它需要 n + l 位小 数)。假设 阶码 字 段长度 K 足够大, 可以 表 示的 阶码范围不会限制这个问题。 对于 单精 度格 式( n = 23) , 这个整数的数字值是多少? 2. 4. 4 舍入 # 因为表示方法限 制了浮点数的范围和精度, 所以浮点运算只能近似地表示实数运算。因此, 对于值 x , 我们一般想用一种系统的方法, 能够找到“ 最接近的" 匹配值 x \u0026rsquo;\u0026rsquo; 它可 以用期 望的浮点形式 表示出来。这就是舍入( ro unding ) 运算 的任务。一个关键问题是在两个可能值的中间确定舍入方向。例如, 如果我有 1. 50 美元, 想把它舍入到最接近的美元\n数,应 该是 1 美元还是 2 美元呢?一种可选择的方法是维持实际数字的下界和上界。例如,我 们可以 确定可表示的值 x一和 工+ ,使 得 工的 值位于它们之间: x一冬 工::;;x勹 IEEE\n浮点格式定义了四种不同的舍入方式。默认的方法是找到最接近的匹配,而其他三种可用 于计算上界和下界。\n图 2-37 举例说明了四种舍入方式, 将一个金额数舍入到最接近的 整数美元数。向偶数舍入 ( ro und- to-e ven ) , 也被称为向最接近的值舍入( ro und - to- nea res t ) , 是默认的方式, 试图找到一个 最接近的 匹配值。因此, 它将 1. 40 美元舍入成 1 美元 , 而将 1. 60 美元舍入成 2 美元, 因为它们是 最接近的整数美元 值。唯一的设计决策是确定两个可能结果中间数值的舍入效果。向偶数舍入方式采用的方法是:它将数字向上或者向下舍入,使得结果的 最低有效数字是偶数 。因此,这 种方法将 1. 5 美元和 2. 5 美元都舍入成 2 美元。\n方式 1.40 1.60 1.50 2.50 -1.50 向偶数舍入 1 2 2 2 -2 向零舍入 I I I 2 -1 向下舍入 I I I 2 -2 向上舍入 2 2 2 3 一1 图 2-37 以美元舍入为例说明舍入方式(第一种方法是舍入到一个最接近的值, 而其他三种方法向上或向下限定结果,单位为美元)\n其他三种方式产生实际值的确界 ( g ua ra n teed bo und ) 。这些方法在一些数字应用中是很有用的 。向零舍入方式把正数向下舍入, 把负数向上舍入, 得到值x, 使 得1 分 l \u0026lt; lx l 。向下舍入方式 把正数和负数都向下舍入, 得到值 x一 ,使 得 x- \u0026lt; x。向 上舍入方式把正数和负数都向上舍 入, 得到值 x十 , 满足 x\u0026lt; x勹\n向偶数舍入初看上去好像 是个相当随意的目标一一有什么理由偏向取偶数呢?为什么不始终把位千两个可表示的值中间的值都向上舍入呢?使用这种方法的一个问题就是很容易假想到这样的情景:这种方法舍入一组数值,会在计算这些值的平均数中引入统计偏 差。我们采用这种方式舍入得到的一组数的平均值将比这些数本身的平均值略高一些。相反,如果我们总是把两个可表示值中间的数字向下舍入,那么舍入后的一组数的平均值将比这些数本身的平均值略低一些。向偶数舍入在大多数现实情况中避免了这种统计偏差。\n在 50%的时间里,它 将 向 上舍入,而 在 50 %的时间里,它 将 向 下 舍 入 。\n在我们不想舍入到整数时,也可以使用向偶数舍入。我们只是简单地考虑最低有效数字是奇数还是偶数。例如,假设我们想将十进制数舍人到最接近的百分位。不管用那种舍入方式, 我们都将把 1. 2349999 舍 入到 1. 23, 而 将 1. 2350001 舍入到 1. 24, 因为它们不\n是在 1. 23 和 1. 24 的正中间。另一方面我们将把两个数 1. 2350000 和 1. 2450000 都舍入到\n24, 因为 4 是偶数。\n相似地,向 偶 数 舍 入法能够运用在二进制小数上。我们将最低有效位的值 0 认为是偶数,值 1 认 为是奇数。一般来说,只有对形如 XX… X. YY… Yl OO… 的 二 进 制 位 模 式 的 数 , 这 种 舍 入 方 式 才 有 效 ,其 中 X 和 Y 表示任意位值,最 右 边的 Y 是要 被舍入的位置。只有这种位模式表示在两个可能的结果正中间的值。例如,考虑舍入值到最近的四分之一的问\n题(也就是二进制小数点右边 2 位)。我们 将 10. 000112 ( 2 点 )向下 舍 入 到 10. 002 (2)\u0026rsquo;\n01102(气3)向 上舍 入 到 1 0. 012 ( 2 丁 ),因为这些值不是两个可能值的正中间值。我们将\n10. 111002 ( 2 百 )向上舍 入成 11. 002 ( 3) , 而 10. 101002 ( 2 旬 向 下舍 入成 10. 102 (22— )\u0026rsquo; 因为\n这些值是两个可能值的中间值,并且我们倾向于使最低有效位为零。\n; 练习题 2. 50 根据舍入到偶数规则,说明如何将下列二进制小数值舍入到最接近的 二分之一(二进 制小数点右边 1 位)。对每种情况, 给出舍入前后的数 字值 。\nA. 10. 0102\nB. 10. 0112\nC. 10. 1102\nD. 11. 0012\n; 练习题 2. 51 在 练 习 题 2. 46 中 我 们 看 到 , 爱 国 者 导 弹 软件 将 0. 1 近似 表 示 为 x = 0. 00011 00110 01100110011002 。 假设使用 IEE E 舍入到 偶 数 方 式 来确定 o. 1 的 二进制小数点右边 23 位的近似表 示 x \u0026rsquo; 。\nx \u0026rsquo; 的二进制表 示是 什么?\nx\u0026rsquo;-o. 1 的十进制表 示的 近似值是什么?\n运行 100 小时后 , 计算 时钟值会有 多少 偏 差?\n该程序对飞毛腿导弹位置的预测会有多少偏差?\n笠 练习题 2. 52 考虑下列 基于 IEE E 浮 点 格式 的 7 位 浮 点 表 示。 两 个格 式 都 没 有 符 号位——它们只能表示非负的数字。\n格式 A 有 k = 3 个阶码位。 阶码 的偏 置值是 3。 有 n = 4 个小数位。 格式 B 有 k = 4 个阶码位。 阶码 的偏置值是 7。\n有 n = 3 个小数位。\n下面给出 了一 些格 式 A 表 示的位模 式, 你的 任 务是将它 们 转 换成格式 B 中最接近的值。如果 需要, 请使 用 舍入到 偶 数 的舍入原 则。 另 外, 给出 由格式 A 和格 式 B 表 示的 位模式对应 的数 字的 值。 给出整数(例如 17 ) 或 者小数(例如 17 / 64 ) 。\n格式A 格式B 位 011 0000 101 1110 010 l 001 110 1111 000 0001 值 1 位 0111 000 值 I 4. 5 浮点运算\nIEEE 标准指定了一个简单的规则, 来确定诸如加法和乘法这样的算术运算的结果。把浮点 值 x 和y 看成实数, 而某个运算O 定义在实数上, 计算将产生 R ound ( x0 y ) , 这是对实际运算的精确结果进行舍入后的结果。在实际中,浮点单元的设计者使用一些聪明的 小技巧来避免执行这种精确的计算,因为计算只要精确到能够保证得到一个正确的舍入结 果就可以了 。当参数中有一个是特殊值(如—0 、—~或 N a N ) 时, IE E E 标准定义了一些使之更合理 的规则。例如,定 义 1 / - 0 将产生一= , 而定义 1; + 0 会产生十~ 。\nIEEE 标准中指定浮点运算行 为方法的一 个优势在于,它 可以独立于任何具体的硬件或者软件实现。因此,我们可以检查它的抽象数学属性,而不必考虑它实际上是如何实现的。\n前面我们看到了整数(包括无符号和补码)加法形成了阿贝尔群。实数上的加法也形成了 阿贝尔群, 但是我们必须考虑舍入对这些属性的影响。我们 将 x + 勺定义为 Round ( x + y ) 。这个运算的定 义针对 x 和y 的所有取值, 但是虽然 x 和 y 都是实数, 由于溢出 ,该 运算可能得到无穷 值。对于所有 x 和y 的值, 这个运算是可交换的 ,也 就是说 x +r y = y +r x 。 另\n一方面 , 这个运算是 不可结合的。例如, 使用单精度浮点 , 表达式 (3. 14+lel0) - l el O 求值得到 o. o iz寸为舍入, 值 3. 14 会丢失。另一方面, 表达式 3. 1 4 + (l e l 0- l e l 0 ) 得出值\n14。作为阿贝尔 群, 大多数值在浮点加法下都有逆元,也 就是说 x +r — x = O。 无穷(因 为十 = - = = N a N ) 和 N a N 是例外情况, 因为对于任何 X , 都有 N a N + 1 x = N a N 。\n浮点加法不具有结合性,这是缺少的最重要的群属性。对于科学计算程序员和编译器编写者来说,这具有重要的含义。例如,假设一个编译器给定了如下代码片段:\n编译器可能试图通过产生下列代码来省去一个浮点加法:\nt \u0026ldquo;\u0026lsquo;b + c; xaaa+t; y\u0026rdquo;\u0026rsquo;t + d;\n然而, 对千 x 来说,这个 计算可能会 产生与原始值不同的值, 因为它使用了加法运算的不同的结合方式。在大多数 应用中, 这种差异小得无关紧要。不幸的是,编译 器无法知道在效率和忠实于原始程序的确切行为之间,使用者愿意做出什么样的选择。结果是,编 译器倾向千保守,避免任何对功能产生影响的优化,即使是很轻微的影响。\n另一方面,浮点 加法满足了单调性属性: 如果 a b , 那么对于任何 a 、b 以及x 的值, 除了 Na N , 都有 x + a x + b。无符号或补码加法不具 有这个实数(和整数)加法的属性。\n浮点乘法也遵循通常乘法所具有的许多属性。我们定义 X * f y 为R ou nd ( x X y ) 。这个\n运算在乘法中是 封闭的(虽然可能产生无穷大或 Na N ) , 它是可交换的,而且它的乘法单位元\n为 1. 0。另一方面, 由于可能发生溢出, 或者由于舍入而失去精度,它 不具有可结 合性。例如,单 精度浮点情况下, 表达式 (l e 2 0*l e 2 0 ) * l e - 20 求值为+ = , 而 l e 20* (l e 20 * l e - 20 ) 将得出 l e 20。另外, 浮点乘法在加法上不具备分配性。例如, 单精度浮点 悄况下, 表达式\nl e 2 0 * (l e 2 0 - l e 2 0 ) 求值为 o. o, 而 l e 20* l e 2 0- l e 2 0* l e 20 会得出 N a N 。\n另一方面,对于任何a 、b 和 c , 并且a 、b 和 c 都不等千 N a N , 浮点乘法满足下列单调性:\na b 且 C 0 a* 1 c b * 1c\na b 且 c 冬 0 a * 气\u0026lt; b* re\n此外, 我们还可以保证, 只要 a# N a N , 就有 a *r a O。 像我们先前所看到的, 无符号或补码的乘法没有这些单调性属性。\n对于科学计算程序员和编译器编写者来说,缺乏结合性和分配性是很严重的问题。即使为了在三维空间中确定两条线是否交叉而写代码这样看上去很简单的任务,也可能成为一个很大的挑战。\n4. 6 C 语言中的浮点数\n所有的 C 语言版本提供了两种不同的浮点数据类型: fl oa t 和 doub l e。在支持 IEEE 浮点格式的机器上.这些数据类型就对应千单精度和双精度浮点。另外,这类机器使用向偶数舍入 的舍入方式。不幸的是, 因为C 语言标准不要求机器使用 IEEE 浮点, 所以没有标准的方法来改变舍 入方式或者得到诸如一0 、 十OO 、 - oo 或者 N a N 之类的特殊值。大多数系统提供i nc l ud e ( \u0026rsquo; . h \u0026rsquo; ) 文件和读取这些特征的过程库, 但是细节随系统不同而不同。例,如当程序文件中出现下列句子时, G NU 编译器GCC 会定义程序常数INF INI TY( 表示十oo ) 和 NAN( 表示 Na N ) :\n#define _GNU_SOURCE 1\n#include \u0026lt;math.h\u0026gt;\n霆 练习题 2. 53 完成 下列 宏定 义, 生成双 精度值 + = 、—CX) 和 0 :\n#define POS_INFINITY\n#define NEG_INFINITY\n#define NEG_ZERO\n不 能使 用 任何 i n c l u d e 文件(例如 ma 七h . h ) , 但你能利用这样一个事实:双精度能够表 示的最 大的 有限 数, 大 约是 1. 8 X 10308o\n当在 i n 七、 fl o a t 和 d o u b l e 格式之间进行强制类型转换时, 程序改 变数值和位 模式的原则如下(假设i n t 是 32 位的):\n从 i n t 转换成 f l o a t , 数字不会溢出,但是可能被舍入。 从 i n t 或 fl o a t 转换成 do u b l e , 因为 d o u b l e 有更大的范围(也就是可表示值的范围),也有更高的精度(也就是有效位数),所以能够保留精确的数值。 从 d o u b l e 转换成 f l o a t , 因为范围要小一些,所以值可能溢出成十~或—~。另外,由于精确度较小,它还可能被舍入。\n从 fl o a t 或者 d o u b l e 转换成 i n t , 值将会向零舍入。例如, 1. 999 将被转换成 1\u0026rsquo; 而—1. 99 9 将被转换成—1 。进一步来说, 值可能会溢出。C 语言标准没有对这种情况指定固定的结果。与 I nt el 兼容的微处理器指定位模式[ 10 …00 ] ( 字长为 w 时的 T M i n ..,,) 为整数不确定 ( in t eg e r in d ef init e ) 值。一个从浮点数到整数的转换 , 如果不能为该浮点数找到一个合理的整数近似值,就会产生这样一个值。因此,表达式\n(int) +lel O 会得到- 21 4 83 6 48 , 即从一个正值变成了一个负值。\nfm Ariane 5— 浮点溢出的高昂代价\n将大的 浮点数转换成整数是 一种常见的程序错误 来源。1 996 年 6 月 4 日 , A r ia ne 5\n,火箭初 次航 行 , 一 个错误 便 产 生 了 灾难 性 的后 果。发 射后 仅 仅 37 秒钟 , 火 箭 偏 离 了 它的飞行 路 径 , 解 体 并 且 爆 炸 。 火箭上 栽有价值 5 亿 美元的 通信 卫 星。\n后 来的调 查[ 73 , 33] 显 示 , 控 制 惯性 导航 系统 的 计 算 机 向 控 制 引 擎 喷 嘴 的 计 算 机 发送了一个无效数据。它没有发送飞行控制信息,而是送出了一个诊断位模式,表明在将 一个 64 位 浮点数 转换成 1 6 位 有 符 号 整数 时 , 产 生 了 溢 出。\n溢 出的 值 测 量的 是 火箭的 水 平速 率 , 这 比 早 先 的 A ria ne 4 火 箭 所 能 达到 的 速 度 高 出\nr 了 5 倍 。 在 设 计 A r ia ne 4 火 箭 软 件 时 , 他 们 小 心 地 分 析 了这 些数 宇值 , 并且 确 定 水 平 速率决 不会 超 出一 个 1 6 位 数 的 表 示 范 围。 不 幸 的 是 , 他 们 在 A riane 5 火箭的 系 统 中 简 单地重用了这一部分,而没有检查它所基于的假设。\n练习题 2. 54 假 定 变 量 x 、 f 和 d 的 类型分别是 i nt 、fl oa t 和 do ub l e。 除 了 f 和 d 都不能 等于十~ 、 一~ 或 者 Na N , 它们 的值是 任意 的。 对于 下 面每 个 C 表 达 式, 证 明 它总是 为真(也就 是 求 值 为 1) \u0026rsquo; 或 者 给 出一个使 表达 式不 为 真 的 值(也就 是 求 值 为 0 ) 。\nx == (int) (double) x\nx == (int) (fl oa 七 ) x\nd == (double) (fl o a 七 )d\nf == (fl o a 七)(double) f\nE. f == - (-f) F.1.0/2==1/2.0 G. d*d \u0026gt;= 0. 0\nH. (f+d)-f ==d\n5. 小结\n计算机将信息编码 为位(比特), 通常组织成字 节序列 。有不同的编码方式用来表示整数、实数和字符串。不同的计算机模 型在编码数字 和多字节数据中的字节顺 序时使用不同的约定。\nC 语言的设计可以包容多种不同 字长和数字编码的实现。64 位字长的机器逐渐普及 ,并 正在取代统治市场长达 30 多年的 32 位机器。由千 64 位机器也可以运行 为 32 位机器编译的 程序, 我们的 重点就放在区分 32 位和 64 位程序, 而不是机器本身。 64 位程序的优势是可以突破 32 位程序具有的 4GB地址限制。\n大多数机器对整数使用补码 编码,而对 浮点数使用 IEEE 标准 754 编码。在位级上理解这些编码, 并且理解算术运算的数学特性,对于想使编写的程序能在全部数值范围上正确运算的程序员来说,是很重 要的。在相同 长度的无符 号和有符号整数之间进行强制类型转换时,大多 数 C 语言实现遵循的 原则是底层\n的位模式不变。在补码 机器上 , 对于一个 w 位的值, 这种行为是由 函数 T 2U 立 和 U 2T w 来描述的。C 语言隐式的强制类型转换会出现许多程序员无法预计的结果,常常导致程序错误。\n由于编码的 长度有限 , 与传统整数和实数运算 相比,计 算机运算具有非常不同的属性。当超出表示范围时 ,有限长度能够引起数值溢出。当浮点数非常接近于 0. o, 从而转换成零 时, 也会下溢。\n和大多数 其他程序语言一样,C 语言实现的有限整数 运算和真实的整数运算相比 , 有一些特殊的属性。例如,由 于溢出,表达式 x *x 能够得出负数。但是,无 符号数和补码的运算都满足整数运算的许多其他属性 , 包括结合律、交换律和分 配律。这就允许编译器做很多的 优化。例 如,用 (x \u0026lt;\u0026lt;3 ) - x 取代表达式 7*x 时,我们 就利用了结合律、交换律和分配律的属性, 还利用了移位 和乘以 2 的幕之间的关 系。\n我们已经 看到了几 种使用位级运算和算术运算组合的聪明 方法。例如,使 用 补码运算, ~x +l 等价千议。另外一个例子,假设我们想要 一个形如[ O, …, O, l, ··· , 1] 的 位模式,由 w —k 个 0 后面紧 跟着 K\n个1 组成。这些位模式有助于掩码运算。这种模式能够通过 C 表达式 (l « k) 一1 生 成 ,利 用的是这样一个属性,即 我们想要的位模式的数值为2• - 1。例如,表 达式 (1« 8 ) - 1 将 产 生 位 模 式 OxFF 。\n浮 点 表 示通过将数字编码为 x X 护 的 形 式 来 近似地表示实数。最常见的浮点表示方式是由 IEEE 标准 754 定义的。它提供了几种不同的精度,最 常 见 的 是 单 精 度 ( 32 位)和双精度 ( 64 位)。IE EE 浮点 也能够表示特 殊值 + = 、—CX) 和 Na N。\n必须非常小心地使用浮点运算,因为浮点运算只有有限的范围和精度,而且并不遵守普遍的算术属 性,比如结合性。\n参考文献说明\n关于 C 语言的参考书[ 45 , 61] 讨论了不同的数据类型和运算的 属性。(这两本书中 ,只 有 Stee le 和Harbison 的书[ 45] 涵盖了 ISO C99 中的新特性。目前还没有看到任何涉及 ISO Cl l 新特性的书籍。)对于精确的字长或者数字编码 C 语言标准没有详细的定义。这些细节是故意省去的,这 样 可以在更 大范围的不同机器上实现 C 语言。已经有几本书[ 59 , 74] 给了 C 语言程序员一些建议,菩 告 他 们关于溢出、隐式强制类型转换到无符号数,以及其他一些已经在这一章中谈及的陷阱。这些书还提供了对变昼命名、编 码风格和代码测试的有益建议。Seacord的书[ 97] 是关 千 C 和 C + + 程 序中的安 全问 题 的 , 本书结合了 C 程序的有关信息,介 绍 了如何 编译 和执 行 程 序,以 及 漏洞是如何造成的。关于 Java 的 书(我们推荐 Java 语言的创 始人 James Gosling 参与编写的一本书[ 5] ) 描述了 Java 支持的数据格式和算术运算。\n关于逻辑设计的书[ 58 , 116] 都 有关 于编码和算术运算的 章节 , 描 述 了 实 现 算 术 电 路 的 不 同 方式。\nOverton 的关千 IEEE 浮点数的书[ 82] , 从 数 字应用程序员的角度,详 细描述了格式和属性。\n家庭作业\n拿 2. 55 在你能够访问的不同机器上,使 用 s how_b yt e s ( 文件 s how- byt e s . c ) 编 译 并 运行示例代码。确定这些机楛使用的字节顺序。\n2. 56 试着用不同的示例值来运行 s ho w_byt e s 的 代 码 。 2 . 57 编 写 程 序 s how_s ho r t 、 s how_l ong 和 s how_do ub l e , 它们分别打印类型为 s hor t 、l ong 和 doub­ l e 的 C 语言对象的字节表示。请试着在几种机器上运行。\n拿 拿 2. 58 编写过程 i s _l i 七七l e _e nd i a n , 当在小端法机器上编译和运行时返回 1, 在大端法机器上编译运行时则返回 0。这个程序应该可以运行在任何机器上,无 论 机 器 的 字 长是多少。\n申 2. 59 编写一个 C 表达式,它 生 成 一 个 字 , 由 x 的 最 低 有 效 字节和 y 中剩下的字节组成。对于运算数 x\n=Ox8 9ABCDEF 和 y=Ox 76543210, 就得到 Ox 765432EF。\n申 拿 2. 60 假设我们将一个 w 位的字中的字节从 0 ( 最低位)到w/ 8 - 1 ( 最高位)编号。写出下面 C 函数的代码,它 会返回一个无符号值,其 中 参 数 x 的 字 节 i 被 替 换 成 字 节 b :\nunsigned replace_byte (unsigned x, inti, unsigned char b);\n以下示例,说明了这个函数该如何工作:\nreplace_byte(Ox12345678, 2, OxAB) \u0026ndash;\u0026gt; Ox12AB5678 replace_byte(Ox12345678, 0, OxAB) \u0026ndash;\u0026gt; Ox123456AB\n位级整数编码规则\n在接下来的作业中, 我们特意限制了你能使用的编程结构,来 帮你更好地理解 C 语言的位级、逻辑和算术运算。在回答这些问题时,你的代码必须遵守以下规则:\n假设 整数用补码形式表示。 有符号数的右移是算术右移。 数据类型 i nt 是 w 位长的。对于某些题目,会 给定 w 的值,但 是在其 他情况下 ,只 要 w 是 8 的整 数倍 ,你的 代 码 就 应该 能 工 作 。你 可以用表达式 s iz e of (i n七)\u0026lt;\u0026lt;3 来计算 w。 禁止使用 条件语句( if 或者?:)、循环、分支语句、函数调用 和宏调用。\n除法、模运算和乘法。\n相对比较运算(\u0026lt;、\u0026gt;、<=和>=)。\n·允许的运算\n所有的位级和逻辑运算。\n左移和右移 , 但是位移量只能 在 0 和,互- 1 之间。 加法和减法。\n相等(==)和不相等(! =)测试。(在有些题目中,也不允许这些运算。)\n整型常数 IN T_MIN 和 IN T_MAX。\n对 i n t 和 un s i g ne d 进行强制类型转换 ,无论是显式的 还是隐 式的。\n即使有这些条件的限制,你仍然可以选择带有描述性的变扯名,并且使用注释来描述你的解决方案 的逻辑,尽量提高代码的 可读性。例如 , 下面这段代码从整数参数 x 中抽取出最高有效 字节:\nI• Get most significant byte from x•I int get_msb(int x) {\n/• Shift by 切 一 8 •I\nint shift_val = (sizeof(int)-1)«3;\nI• Arithmetic shift•/\nint xright = x \u0026gt;\u0026gt; shift_val; I• Zero all but LSB•/ return xright \u0026amp; OxFF;\n•• 2. 61\n** 2. fr2\n** 2. 63\n写一个 C 表达式 , 在下列描述的条件下 产生 1, 而在其他情况下得到 0。假设 x 是 i n t 类型。\nA. X 的任何位都等千 l 。\n8. X 的任何位都等 于 0。\nX 的最低有效字节中的 位都等于 1。\nX 的最高有效字节中的 位都等千 0。\n代码应该遵循位级整数编码规则,另外还有一个限制,你不能使用相等(==)和不相等(! =)\n测试。\n编写一个 函数 i n t _s h i f t s _ar e _ar i t hme t i c (), 在对 i nt 类型的 数使用算术右移的 机器上运行 时这个函数生成 1, 而其他情 况下 生成 0。你的代码应该可以 运行 在任何字长的机器上。在几 种机器上测试你的代码。\n将下面的 C 函数代码补充完整。函数 sr l 用算术右移(由值xs r a 给出)来完成逻辑 右移,后 面的其他操作不 包括右移或者除 法。函数 sr a 用逻辑右移(由值 xsr l 给出)来完成算术右移,后 面的其他操作不包 括右移或者除法。可以 通过计算 8*s i ze o f (i n t ) 来确定 数据类型 i n t 中的位数 w。位移量 k 的取值 范围为 O~ w - 1 。\nunsigned srl(unsigned x, int k) {\nI• Perform shift arithmetically•/ unsigned xsra = (int) x»k;\nintsra(int x, int k) {\n/• Perform shift logically•/ int xsrl = (unsigned) x \u0026gt;\u0026gt; k;\n2. 64 写出代码实现如下函数:\n/• Return 1 when any odd bit of x equals 1; 0 otherwise.\nAssume w=32•/\nint any_odd_one(unsigned x);\n函数应该遵循位级整数编码规则 ,不 过 你 可以假设数据类型 i n t 有 w = 3 2 位。\n:: 2. 6 5 写出代码实现如下函数:\n/• Return 1 when x contains an odd number of 1s; 0 ot her 廿 i s e .\nAssume w=32•/\nint odd_ones(unsigned x);\n函数应该遵循位级整数编码规则 ,不 过 你 可以假设数据类型 i n t 有 w = 3 2 位。你的代码最多只能包含 1 2 个算术运算、位运算和逻辑运算。\n·: 2. 66 写出代码实现如下函数:\n/*\nGenerate mask indicating leftmost 1 in x. Assume w=32.\nFor example, OxFFOO -\u0026gt; Ox8000, and Ox6600 \u0026ndash;\u0026gt; Ox4000.\nIf x = 0, then return 0.\n*/\nint leftmost_one(unsigned x);\n函数应该遵循位级整数编码规则 ,不 过你可以假设数据类型 i n t 有 w = 3 2 位。你的代码最多只能包含 1 5 个算术运算、位运算 和逻辑运算。\n提示:先 将 x 转换成形如[ O…011…l ] 的 位向量。\n•• 2. 67 给你一个任务,编 写 一 个 过程 i n t _ s i ze _ i s _ 3 2 (), 当在一个 i n t 是 32 位的机器上运行时, 该 程序产生 1, 而其他情况则产生 0。不允许使用 s i ze o f 运算符。下面是开始时的尝试:\n/• The following code does not run properly on some machines•/\n2 int bad_i nt _s i z e _i s _32 0 {\n3 /• Set most significant bit (msb) of 32-bit machine•/\n4 int set msb = 1«31;\n5 I• Shift past msb of 32-bit word•/\n6 int beyond_msb = 1«32;\n7\n8 I•set_msb is nonzero when word size \u0026gt;= 32\n9 be yond _ms b is zero when word s i ze \u0026lt;= 32•/\n10 return set_msb \u0026amp;\u0026amp; ! b e yond _ms b ;\n11 }\n当在 SUN SP ARC 这样的 32 位机器上编译并运行时, 这个过程返回的却是 0。下面的编译器信息给了我们一个问题的指示:\nwarning: left shift count \u0026gt;= width of type\n我们的代码在哪个方面没有遵守 C 语言标准?\n修改代码,使 得它在 i n t 至少为 32 位的任何机器上都能正确地运行。\n修改代码,使得 它在 i nt 至少为 1 6 位的任何机楛上都能正确地运行。\n•• 2. 68 写出具有如下原型的函数的代码:\n/*\nMask with l east signficant n bits set to 1\nExampl e s : n = 6 \u0026ndash;\u0026gt; Ox3F, n = 17 \u0026ndash;\u0026gt; Ox1FFFF\nAssume 1 \u0026lt;= n \u0026lt;= w\n•I\nint l o 仅 er _one _mas k ( i nt n);\n函 数 应该 遵 循 位级整数编码规则。要注意 n = w 的情况。\n•: 2. 69 写出具有如下原型的函数的代码:\n/*\nDo rotating left shift. Assume O \u0026lt;= n \u0026lt; w\nExamples when x = Ox12345678 and w = 32:\n* n=4 -\u0026gt; Ox23456781, n=20 -\u0026gt; Ox67812345\n*/\nu.ns 屯 ned rotate_left(u.nsigned x, int n);\n函数应该 遵循位级整数 编码规则 。要注意 n = O 的情况。\n.. 2. 70 写出具有如下原型的函数的代码:\n/*\nReturn 1 when x can be represented as an n-bit, 2\u0026rsquo;s-complement\nnumber; 0 otherwise\nAssume 1 \u0026lt;= n \u0026lt;= w\n*/\nint fits_bits(int x, int n);\n函数应该遵循位级整数编码规则。\n2. 71 你刚刚开始在 一家公司工作, 他们要实现一组过程来操 作一个数据结构 , 要将 4 个有符号字节封装成一个 3 2 位 u n s i g ne d。一个字中的字 节从 0 ( 最低有效字节)编号到3 ( 最高有效 字节)。分配给你的任务是:为一个使用补码运算和算术右移的机器编写一个具有如下原型的函数:\nI* Declaration of data t ype 口 here 4 bytes are packed into an unsigned *I\ntypedef unsigned packed_t;\nI* Extract byte from word. Return as signed integer *I int xbyte(packed_t word, int bytenum);\n也就是说, 函数会抽取 出指定的字节, 再把它符号扩展为一个 3 2 位 i n t 。你的前任(因为水平不够高而被解雇了)编写了下面的代码 :\nI* Failed attempt at xbyte *I\nint xbyte(packed_t \u0026ldquo;Word, int bytenum)\n{\nreturn (\u0026lsquo;Word»(bytenum \u0026lt;\u0026lt; 3)) \u0026amp; OxFF;\n}\n这段代码错在哪里?\n给出函数的正确实现,只能使用左右移位和一个减法。\n•• 2. 72 给你一个任务 , 写一个函数, 将整数 v a l 复制到缓冲区 b u f 中, 但是只有当缓冲区中 有足够可用的空间时,才执行 复制。\n你写的代码如下:\nI* Copy integer into buffer if space is available *I I* WARNING: The following code is buggy *I\nvoid copy_int(int val, void *buf, int maxbytes) { if (maxbytes-sizeof(val) \u0026gt;= 0)\nmemcpy(buf, (void*) \u0026amp;val, sizeof(val));\n这段代码使用了库函数 me mc p y 。虽 然在这里用这个函数有点 刻意,因 为我们只 是想复制一个江比, 但是它说明了一种复制较大数 据结构的常见方法。\n你仔细地测试了这段代码后发现 , 哪怕 ma x b y t es 很小的时候, 它也能把值复制到缓 冲区中 。\n解释为什么代码中的条件测试总是成功。提示: s i ze o f 运算符返回 类型为 s i z e _ t 的值 。\n你该如何重写这个 条件测试 ,使 之工作正确。\n.. 2. 73 写出具有如下原型的函数的 代码:\nI* Addition that saturates to TMin or TMax *I int saturating_add(int x, int y);\n同正常的补码加法溢出的方式不同, 当正溢出时, 饱和加法返回 TMax , 负溢出时,返回\nTMin。饱 和运算常常用在执行 数字信号 处理的程序中。你的函数应该遵循位级整数编码规则。\n•• 2. 74 写出具有如下原型的函数的代码:\n/• Determine whether arguments can be subtracted without overf l 叩 */\nint tsub_ok(int x, int y);\n如果计算 x- y 不溢出, 这个函数就返回 1。\n·: 2. 75 假设我们想要计算 x • y 的完整的 2亿,位表示, 其中, x 和 y 都是无符号数,并 且运行在数据类型u n s i g ne d 是 w 位的机器上。乘 积的低 w 位能够用表达式 x * y 计算,所以 ,我 们只需 要一个具有下列原型的函数:\nunsigned unsigned_high_prod(unsigned x, unsigned y);\n这个函数计算 无符号变量 x • y 的高 w 位。我们使用一个具有下面原型的库函数:\nint s i gn ed 上 i gh_pr od ( i nt x, int y);\n它计算在 x 和y 采用补码 形式的情 况下, x • y 的 高 w 位。编写代码 调用这个过程, 以实现用无符号数为参数的函数。验证你的解答的正确性。\n提示: 看看等式 ( 2. 18 ) 的推导中 ,有符 号乘积 x • y 和无符号乘积 x\u0026rsquo; • y\u0026rsquo; 之间的关系。\n2. 76 库函数 c a l l o c 有如下声明 :\nvoid•calloc(size_t nmemb, size_t size);\n根据库文档:" 函数 c a l l o c 为一个数组分配内存, 该数组有 nme mb 个元素, 每个元素为 s i ze 字节。内存设置为 0 。如果 nmemb 或 s i ze 为 o, 则 c a l l o c 返回 NULL。”\n编写 c a l l o c 的实现, 通过调用 ma l l oc 执行分配 , 调用 me ms e t 将内存设 置为 0。你的代码应该没有任何由算 术溢出引起的漏洞, 且无论数据类型 s i ze _ t 用多少位表示, 代码都应该正常工作。\n作为参考 , 函数 ma l l o c 和 me ms e t 声明 如下 :\nvoid•malloc(size_t size); void•memset(void•s, int c, size_t n);\n•• 2. 77 假设我们有一 个任务: 生成一段代码 , 将整数 变量 x 乘以不同的常数因子 K 。为了 提高效率,我们想只使用十、一和<< 运算。对于下 列 K 的值,写 出执行乘法运算 的 C 表达式 ,每个 表达式中最多使用 3 个运算。\nK=l7 K=-7\nK=60\nD. K= -112\n•• 2. 78 写出具有如下原型的函数的代码:\n/• Divide by power of 2. Assume O \u0026lt;= k \u0026lt; w 一 1 • I int divide_power2(int x, int k);\n该函数要用正确的舍入方式计算 x / 2\u0026rsquo; , 并且应该遵循位级整数编码规则。\n•• 2. 79 写出函数 mu l3 d i v 4 的代码,对于整数参 数 x , 计算 3*x / 4 , 但是要遵循位级整数编码规则。你的代码计算 3*x 也会产生溢出。\n•: 2. 80 写出函数 t hr e e f o ur t h s 的代码, 对于整数参数 x , 计算 3 / 4x 的值,向 零舍人。它 不会溢出。函\n数应该遵循位级整数编码规则。\n•• 2. 81 编写 C 表达式产生如下位模式,其中 a• 表示符号a 重复 K 次。假设一个 w 位的数 据类型。代码可以包含对参数 J 和 K 的引用,它 们分别表示 )和K 的 值, 但是不能使用表示 w 的参数。\n1w- k ok\now- k- j1ko1\n, 2. 82 我们在一 个 i n t 类型值为 32 位的机器上运行程序。这些 值以补码形式表示, 而且它们都是算术右移的。u ns i g ne d 类型的值也是 32 位的。\n我们产生随机数 x 和 y, 并且把它们转换成无符号数,显示如下:\n/• Create some arbitrary values•/ int x \u0026ldquo;\u0026lsquo;random() ;\nint y random() ;\nI• Convert to unsigned•/ unsigned ux = (unsigned) x; unsigned uy = (unsigned) y;\n对千下列每个 C 表达式, 你要指出表达式是否 总是为 1。如果它 总是为 1, 那么请描述其中的数学原理 。否则,列 举出一个使它为 0 的参数示例。\nA. (x \u0026lt;y ) = = (- x\u0026gt;- y )\nB. ((x+y)«4) +y-x==l 7*y+l5*x\nC. · x +· y+l = =\u0026rsquo; (x+y )\nD. (ux- u y ) = = - (unsigned) (y-x)\nE. ( (x » 2 ) « 2 ) \u0026lt;=x\n.. 2. 83 一些数字的二进制表示是由形如 o. y y y y y y …的无穷串组成的 , 其中 y 是一个 K 位的序列。例如,— 的二 进制 表示是 0. 01010101 … ( y = O l ) , 而— 的二进 制表示是 o. 00 11 00 11 0011 … ( y =\n0011 ) 。\n设 Y= B2队 ( y ) \u0026rsquo; 也就是说,这个数具 有二进制表示 y 。给出 一个由 Y 和 K 组成的公式表示这个无穷串的值。\n提示: 请考虑将二进制小数点右移 K 位的结果。\n对于下列的 y 值, 串的数值是多少?\n( a) l Ol\n( b ) Oll O ( c) Ol OOll\n, 2. 84 填写下列程序的返 回值, 这个程 序测试它的 第一个参数是否小千或者等千第二个参数。假定函数f 2u 返回一个 无符号 32 位数字,其 位表示与它的浮点 参数相同。你可以假设两个参数都不是Na N。两种 o, + o 和一0 被认为是相等的。\nint float_le(float x, floaty) { unsigned ux = f2u(x); unsigned uy = f2u(y);\nI* Get the sign bits *I unsigned sx = ux»31; unsigned sy = uy»31;\n/• Give an expression using only we, uy, sx, and sy•I return\n}\n拿 2. 85 给定一个浮点格式 , 有 K 位指数和 n 位小数, 对千下 列数, 写出阶码 E 、尾数 M 、小数 f 和值 V\n的公式。另外,请 描述其位表示。\n数 7. 0。\n能够被准确描述的最 大奇整数。\n最小的规格化数的倒数。\n拿 2. 86 与 Intel 兼容的处理器也支待 "扩展精度“ 浮点形式, 这种格式具 有 80 位字长, 被分成 1 个符号\n位、k = l 5 个阶码位、1 个单独的整数位 和 n = 63 个小数位。整数位是 IEEE 浮点表示中隐 含位的显式副本。也就是说 ,对 千规格化的值 它等于 1, 对于非规格 化的值它等于 0。填写下表,给 出用这种格式表示的一些“有趣的“数字的近似值。\n最大的规格化数\n将数据类型声明为 l o ng double, 就可以 把这种格式用于为与 Intel 兼容的机器编译 C 程序。但是, 它会强制编译器以传 统的 8087 浮点指令为基础生成代码 。由此产生的程序很可能会比数据类型为 fl oa t 或 d oub l e 的 情况慢上许多。\n2. 87 2008 版 IEE E 浮点标准, 即 IE EE 754-2008, 包含了一种 16 位的“半精度“ 浮点格式。它最初是由计算机图形公 司设计的 , 其存储的数据所需的动态范围要高 于 16 位整数可获得的 范围。这种格式具 有 1 个符号位、5 个阶码位 Ck = 5 ) 和 10 个小数位( n = 10 ) 。阶码偏置量是 sz- I - 1 = 15 。\n对于每个给定的数,填写下表,其中,每一列具有如下指示说明:\nHex: 描述编码形式的 4 个十六进制数字。\nM: 尾数的值 。这应该是一个形如 x 或王 的数, 其中 x 是一个整数, 而 y 是 2 的整数幕。例\n如: 0 、67 和\n64 256°\nE, 阶码的整数值。\nv, 所表示的数字值。使用 x 或者 x X 2\u0026rdquo; 表示,其 中 x 和 z 都是整数。\nD: ( 可能近似的)数值,用 pr i n t f 的格式 规范%f 打印。\n举一个例子, 为了 表示数— , 我们有 s = O, M = — 和 E = - 1。因此这个数的阶码字段为\n8\n011102( 十进制值 15—1 = 1 4) , 尾数字段为 11000000002, 得到一个十六进制的表示 3B00。其数值\n为 0. 875 。\n标记为 \u0026ldquo;— \u0026quot; 的条目不用填写。\n描述 -o 最小的\u0026gt; 2 的值 Hex M E V -o D - o. o 512 512 512. 0 最大的非规格化数 -oo -co - ex, 十六进制表示为 3BBO的数 3B80 •• 2 . 88 考虑下 面两个 基于 IEEE 浮点格式的 9 位浮点表示。\n格式 A 有一个符号位。 有 k= 5 个阶码位 。阶码偏置量是 15 。 有 n = 3 个小数位 。 格式 B 有一个符号位。 有 k = 4 个阶码位。阶码偏置量是 7。 有 n = 4 个小数位。\n下面给出了一些 格式 A 表示的位模式,你 的任务是把它们转换成最接近的 格式 B 表示的值。如果需要舍入,你要 向+ oo舍入。另外 ,给出用 格式 A 和格式 B 表示的 位模式 对应的值。要么是整数(例如1 7) , 要么是小数(例如 17/ 64 或 17 / 26 ) 。\n格式A 格式B 位 值 位 值 1 01 11 0 001 一9 16 1 Oll O 0010 -9 百 0 1 011 0 1 01 1 00111 110 0 00000 101 1 11011 000 0 11000 100 2. 89 我们在一 个 i n t 类型为 32 位补码表示的 机器上运行 程序。f l oa t 类型的值使用 32 位 IEEE 格式,\n而 doub l e 类型的值使用 64 位 IEEE 格式。\n我们产 生随机整数 x、y 和 z , 并且把它们转换成 doub l e 类型的 值:\n/• Create some arbitrary values•/ int x = random();\nint y = random() ;\nint z * random O ;\n/• Convert to double•/ double dx• (double) x; double dy = (double) y; double dz\u0026rdquo;\u0026rsquo;(double) z;\n对于下列的每个 C 表达式, 你要指出表达式是否总是为 1。如果它总是为 1, 描述其中的数学原理。否则, 列举出使它为 0 的参数的 例子。请注意 , 不能使用 IA32 机器运行 GCC 来测试你的答案 ,因为对 千 f l oa t 和 do ub l e , 它使用的都是 80 位的扩展精 度表示。\n(float)x==(float)dx dx-dy== (double) (x-y) (dx+dy) +dz==dx+ (dy+dz) (dx*dy) *dz==dx* (dy*dz) dx/dx==dz/dz 2. 90 分配给你一个任务 , 编写一个 C 函数来计算 护的浮点表示。你意 识到完成 这个任务的最好方法是直接创建结果的 IEEE单精度表示。当 x 太小 时,你的 程序将 返回 o. o。 当 x 太大时, 它会返回\n+ = 。填写下列代码的空白部分,以 计 算出正确的结果。假设函数 u2f 返回的浮点值与它的无符号参数有相同的位表示。\nfloat fp 江 2(int x)\n/• Result exponent and fraction•/ unsigned exp, frac;\nunsigned u;\nif (x \u0026lt;) {\nI• Too small. Return 0. 0 • I exp= ,\nfrac = ,\n} else if (x \u0026lt;) {\n/• Denormalized result•I exp= ,\nfrac = ,\n} else if (x \u0026lt;) { I* Normalized result. *I exp= ,\nfrac = ,\n} else {\nI* Too big. Return +oo *I exp= ,\nfrac = ,\n}\n/• Pack exp and frac into 32 bits•/ u =exp«23 I frac;\n/• Return as float•/ return u2f(u);\n223 22\n2. 91 大约公元前 250 年, 希腊数学家阿 基米德 证明了— - \u0026lt; re\u0026lt; — 。 如果当时有一台计算机和标 准库\n71\n\u0026lt; math.h\u0026gt;, 他就能够确定 T( 的单精度浮点近似值的十六进制表示为 Ox 40 490 FDB。当然 , 所有的这些都只是近似值,因为穴不是有理数。\n这个浮点值表示的二进制小数是多少?\n22\n一的二进制小数表示是什么? 提示: 参见家庭作业 2. 83。\n这两个 T 的近似值从 哪一位(相对于二进制小数点)开始不同 的? 位级浮点编码规则\n在接下来的题目中,你所写的代码要实现浮点函数在浮点数的位级表示上直接运算。你的代码应该 完全遵循 IEEE 浮点运算的规则 , 包括当需 要舍入时 , 要使用向偶数舍人的方式。\n为此 ,我们 把数据类型 fl o a t - b i t s 等价于 un s i g ne d :\nI• Access bit-level representation floating-point number•I typedef unsigned float_bits;\n你的代码中不使用数 据类型 f l o a t , 而要使用 fl o a t _b it s 。你可以使用数据类型 i n t 和 u n s i g ne d , 包括无符号和整数常数 和运算。你不可以使用任何 联合、结构和数组。更 重要的 是, 你不能使用任何 浮点数据类型、运算或者常数。取而代之 , 你的代码应该执行实 现这些指定的浮点运算的位操作 。\n下面的函数说明了对这些规则的使用。对千参数 f , 如果 J 是非规格化的, 该函数返回士 0 ( 保持 f\n的符号),否 则, 返回 f 。\n/• If f is denorm, return 0. Otherwise, return f•/ float_bits float_denorm_zero(float_bits f) {\nI• Decompose bit representation into parts•/ unsigned sign= f»31;\nunsigned exp= f»23 \u0026amp; OxFF ; unsigned frac = f \u0026amp; Ox7FFFFF; if (exp== 0) {\n/• Denormalized. Set fraction to O•/ frac = O;\n}\nI• Reassemble bits•/\nreturn (sign«31) I (exp«23) I frac;\n}\n•• 2. 92 遵循位级浮点编码规则,实现具有如下原型的函数:\n/• Compute -f. If f is NaN, then return f. •/ float_bits float_negate(float_bits f);\n对于浮点数 J, 这个函数计箕 - J。如果 J 是 Na N , 你的函数应该简单地返回 J。\n测试你的函数, 对参数 f 可以取的所有 232 个值求值 , 将结果与你使 用机器的浮点运算得到的结果\n相比较。\n•• 2. 93 遵循位级 浮点编码规则 ,实 现具有如下原型的函数:\nI• Compute lfl. If f is NaN, then return f. •/ float_bits float_absval(float_bits f);\n对于浮点数 f , 这个函数计算 Ii i 。如果 J 是 N a N , 你的函数应该简单地返回 f 。\n测试你的 函数, 对参数 f 可以取的所有 23 2 个值求值 ,将结果 与你使用机器的浮点运算得到的结果相比较。\n·: 2. 94 遵循位级浮点 编码规则 , 实现具有如下原 型的函数:\nI• Compute 2•f. If f is NaN, then return f. •/ float_bits float_twice(float_bits f);\n对于浮点 数 f , 这个函数计算 2. 0• f 。如果 J 是 N a N , 你的函数应该 简单地返回 f 。\n测试你的函数, 对参数 f 可以取的所有 23 2 个值求值, 将结果与你使用机器的 浮点运算得到的结果相比较。\n,:2. 95 遵循位级浮点编码规则,实现具有如下原型的函数:\nI• Compute 0.5•f. If f is NaN, then return f. •/ float_bits float_half(float_bits f);\n对于浮点数 f , 这个函数计算 0. 5• f 。如果 J 是 N a N , 你的函数应该简单地返回 f 。\n测试你的 函数, 对参数 f 可以取的所有 22\u0026rsquo; 个值求值 ,将结果 与你使用 机器的 浮点运算得到的结果相比较。\n::2. 96 遵循位级浮点编码规则,实 现具有如下原 型的函数:\n/*\n* Compute (int) f.\n* If conversion causes overflow or f is NaN, return Ox80000000\n*/\nint float_f2i(float_bits f);\n对于浮点数 f , 这个函数计算 ( i n t ) / 。如果 f 是 N a N , 你的函数应该向零舍入。如果 f 不能用整数表示(例如, 超出表示 范围,或者 它是一个 N a N ) , 那么函数应该返回 Ox 8 00 00000 。\n测试你的函数, 对参数 f 可以取的所有 232个值求值,将结果 与你使用机 器的浮点运算得到的\n结果相比较。\n::2. 97 遵循位级浮点编码规则,实现具有如下原型的函数,\nI• Compute (float) i•/ float_bits float_i2f(int i);\n对于函数 i , 这个函数计算 (fl o a t ) i 的位级表示。\n测试你的函数, 对参数 f 可以取的所有 223 个值求值 , 将结果与你使用机器的 浮点运算得到的结果相比较。\n练习题答案 # 2. 1 在我们开始查看机器级程序的时候,理解十六进制和二进制格式之间的关系将是很重要的。虽然本书中介绍了完成这些转换的方法,但是做点练习能够让你更加熟练。\nA.\nB.\nC. 将 Ox DSE4 C 转换成二进制:\nD.\n2. 2 这个问题给你一个机会思考 2 的幕和它们的十六进制表示。\nn \u0026lsquo;X\u0026rsquo; ( 十进制) 2\u0026rdquo; (十六进制) 9 512 Ox200 19 524 288 Ox 8 00 0 0 14 16 384 Ox4000 16 65 536 OxlOOOO 17 131 072 Ox 2 0000 5 32 Ox20 7 128 Ox80 2. 3 这个问题给你一个机会试着对一些小 的数在十六 进制和十进制 表示 之间进行转换。对于较大的数, 使用计算器或者转换程序会更加方便和可靠。\n二进制 十六进制 0000 0000 OxOO 167 = 10 · 16 + 7 1010 0111 OxA7 62 = 3 · 16 + 14 OOll 1 11 0 Ox3E 188 = 11 · 16 + 12 1011 1100 OxBC 3·16 +7= 55 0011 0111 Ox37 8 · 16 + 8 = 136 1000 1000 Ox88 15 · 16 + 3 = 243 1111 0011 OxF3 5 · 16 + 2 = 82 0101 0010 Ox52 10 · 16 + 12 = 172 1010 llOO OxAC 14 · 16 + 7 = 23 1 1110 0111 OxE7 4 当开始调试机器级程序时 , 你将发现在许多情况中 , 一些简单的十六进 制运算是很有用的 。可以总是把数转换 成十进制 ,完成运算 ,再把它 们转换 回来,但是能 够直接用十六进制工作 更加有效 , 而且能够提供更多的信息。 Ox503c +Ox 8 =0x50 44 。8 加上十六进 制 c 得到 4 并且进位 1。\nOx 503c - Ox 40 =0x 4ff c 。在第二个数位, 3 减去 4 要从第 三位借 l 。因为第 三位是 o, 所以我们必须从第四位借位。\nOx503c +64=0x507c 。十进制 64( 26 ) 等于十六 进制 Ox 40 。\nOx50 e a - Ox 503c =Oxa e 。 十六进制数 a ( 十进制 10 ) 减去十六 进制数 c ( 十进制 1 2 ) , 我们从第二位借 16 , 得到十六进制数 e ( 十进制数 14 ) 。在第二个数位,我们 现在用 十六进制 d ( 十进制 13 ) 减去 3 , 得到十六进 制 a ( 十进制 10 ) 。\n2. 5 这个练习测试你对数据的字节表示 和两种不同 字节顺序的理 解。\n小端法: 2 1 大端法: 87 小端法: 21 43 大端法: 8 7 65 小端法: 21 43 65 大端法: 87 65 43 回想一下, show_b y t e s 列举了一系列字节,从低位 地址的 字节 开始,然 后逐一列出高位地址的 字\n节。在小端法机器上,它将按照从最低有效字节到最高有效字节的顺序列出字节。在大端法机器 上,它将按照从最高有效字节到最低有效字节的顺序列出字节。\n6 这又是一个练习从十六进制到二进制转换的机会。同时也让你思考整数和浮点表示。我们将在本章后面更加详细地研究这些表示。 利用书中示例的符号,我们将两个串写成:\n00359141\n00000000001101011001000101000001\n********************* 4A564504\n01001010010101100100010100000100\n将第二个字相对于第一 个字向右移 动 2 位, 我们发 现一个有 21 个匹配位的序列。\n我们发现除 了最高有效 位 1. 整数的所有位都嵌在浮点数中。这正好也是书中示例的情况。另外,浮点数有一些非零的高位不与整数中的高位相匹配。\n2. 7 它打印 61 62 63 64 65 66。回想一下, 库函数 s tr l e n 不计算终止的 空字符, 所以 s ho w byt e s 只 打印到字符 \u0026rsquo; f \u0026rsquo; 。\n2. 8 这是一个帮助你更加熟悉布尔运算的练习。\n运算 结果 运算 结果 a [01101001] a\u0026amp;b [01000001] b [01010101] alb (01111101] -a [10010110] a \u0026ldquo;b (OOllllOO] -b [10101010] 9 这个问题说明了怎样用布尔代数来描述和解释现实世界的系统。我们能够看到这个颜色代数和长度为 3 的位向 量上的 布尔代数是一 样的。 颜色的取补是通过对 R、G 和 B 的值取 补得到的 。由此,我们可以看出,白 色是黑色的 补, 黄色是蓝色的补,红紫色是绿色的补,蓝绿色是红色的补。 我们基于颜色的位向量表示来进行布尔运算。据此,我们得到以下结果: 蓝色 \u0026lt;ooD I 绿色( 010 ) = 蓝绿色( 011 ) 黄色(1 10 ) \u0026amp;. 蓝绿色( 011 ) = 绿色( 010 ) 红色(1 00 ) A 紫红色(1 01) = 蓝色( 001 ) 2. 10 这个程序依赖于两个 事实, EXCLUSVI E-OR是可交换的和可结合,以及对于任意的 a , 有 a Aa = O。\n步骤 •x *y 初始 a b 步骤l a a\u0026quot;b 步骤2 a\u0026rdquo; (a\u0026quot; b) =(a\u0026quot;a)\u0026quot; b =b a\u0026quot;b 步骤3 b b\u0026quot; (a \u0026ldquo;b) = (b \u0026ldquo;b) \u0026quot; a = a 某种情况下 这个函数会 失败, 参见练习题 2. 11 。\n11 这个题目说明了我们的原地交换例程微妙而有趣的特性。 fir s t 和 l a s t 的值都为 k , 所以我们试图交换正中间的元素和它自己。 在这种情况 中, i np l a c e _s wa p 的 参数 x 和 y 都指向同一个位置。当 计算* x \u0026quot; * y 的时候,我 们得到 0 。然后将 0 作为数组正中 间的元素, 而后面的 步骤一直都把这个元素设 置为 0 。我们可以看到,练习题 2. 10 的推理隐 含地假设 x 和 y 代表不同的位置。 将r e ver s e _arr a y 的第 4 行的测试简单地替换成 f ir s t \u0026lt;l a s t , 因为没有必要交换正中间的元素和它自己。 12 这些表达式如下: x \u0026amp; OxFF xA~OxFF\nx I OxFF\n这些表达式是 在执行低级 位运算中经常发现的典型类型。表达式 ~ Ox F F 创建一个掩码, 该掩码 8\n个最低位等于 o, 而其余的 位为 1。可以观察到,这些 掩码的产生和字长无关 。而相比之下, 表达式 Ox FFFFFFOO 只 能工作在 32 位的机 器上。\n2. 13 这个问题帮助你思考布尔运算和程序员应用掩码运算的典型方式之间的关系。代码如下:\n/• Decla 工 ·at i ons of functions implementing operations bis and bic•/ int bis(int x, int m);\nint bic(int x, int m);\n/• Compute xly using only calls to functions bis and bic•/ int bool_or(int x, int y) {\nint result= bis(x,y); return result;\n}\n/• Compute x~y using only calls to functions bis and bic•/ int bool_xor(int x, int y) {\nint result= bis(bic(x,y), bic(y,x)); return result;\nbi s 运算等价于 布尔 OR一 如果 x 中或者 m 中 的 这一位置位了, 那么 z 中的这一位就 置位。另一方面, b i c (x, m) 等价于 x \u0026amp;~m; 我们想实现只有 当 x 对应的位为 1 且 m 对应 的位为 0 时, 该位等于 1。\n由此,可以通过对 b i s 的一次调用来实现 1 。 为了实 现^, 我们利用以 下属性\n工 Ay = ( x \u0026amp;.~ y ) I (~x\u0026amp;.y)\n2. 14 这个问 题突出了位级 布尔运算和 C 语言中的逻辑运算之间的关系。常见的 编程错误是在想用 逻辑运算的时候用了位级运算,或者反过来。\n表达式 值 表达式 值 X \u0026amp; y Ox20 X \u0026amp;\u0026amp; y OxOl X I y Ox7F X 11 y OxO l ~x I ~y OxDF !x 11 ! y OxOO X \u0026amp; !y OxOO X \u0026amp;\u0026amp; ~y OxO l 2. 15 这个表达式是 ! ( x A y ) 。\n也就是,当且仅当 x 的每一位和 y 相应的每一位匹配时 , X A y 等于零。然后,我们 利用! 来判定一个字是否包含任何非零位。\n没有任何实际的理由 要去使用这个 表达式 ,因 为可以简单 地写成 x= =y , 但是它说明了位级运算和逻辑运算之间的一些细微差别。\n2. 16 这个练习可以帮助你理解各种移位运算 。\nX X\u0026lt;\u0026lt;3 (逻辑) ( 算术) X\u0026gt;\u0026gt;2 X\u0026gt; \u0026gt;2 十六进制 二进制 二进制 十六进制 二进制 十六进制 二进制 十六进制 OxC3 [11000011) [00011000] OxlB [00110000] Ox30 [l 1110000] OxFO Ox75 (01110101) [10101000] OxAB (00011101] Ox l D [00011101] OxlD Ox87 [10000111] (00111000] Ox38 [00100001) Ox21 [11100001] OxEl Ox66 [01100110] (00110000] Ox30 [00011001) Oxl9 [00011001] Ox19 第 2 章 信息的表示和处理 101\n2. 17 一般而言,研究字长非常小的例子是理解计算机运算的非常好的方法。\n无符号值对应于图 2- 2 中的值。对于补码 值, 十六进制数字 0 ~ 7 的最高有效位为 o , 得到非负值,然 而十六 进制数字 8 ~ F 的最高有效 位为 1 , 得到一个为负的 值。\n2 18 对于 32 位的机器,由 8 个十六进制数 字组 成的, 且开始的那个数字在 8 ~ f 之间的任何值,都 是一个负数。数 字以串 f 开头是很普遍的 事情 , 因为负数的 起始位全为 1 。不过, 你必须看仔细了。例如, 数 Ox80 48337 仅仅有 7 个数字。把起始位 填入 0\u0026rsquo; 从而得到 Ox080 48337 , 这是一个正数。\n4004d0: 48 81 ec eO 02 00 00 sub $0x2e0,%rsp A . 736 4004d7: 48 8b 44 24 a8 mov -Ox58(%rsp),%rax B. -BB 4004dc: 48 03 47 28 add Ox28(%rdi),%rax C. 40 4004e0: 48 89 44 24 dO mov %rax,-Ox30(%rsp) D. -48 4004e5: 48 8b 44 24 78 mov Ox78(%rsp),%rax E. 120 4004ea: 48 89 87 88 00 00 00 mov 如r ax , Ox88 ( r 讼 di ) F. 136 4004f1: 48 8b 84 24 f8 01 00 mov Ox1f8 (%rsp) , %rax G . 504 4004f8: 00 4004f9: 48 03 44 24 08 add Ox8(%rsp),%rax 4004fe: 48 89 84 24 co 00 00 mov %rax,Oxc0(%rsp) H 192 400505: 00 400506: 48 Bb 44 d4 bB mov -Ox48 (%rsp, %rdx, 8), %rax I . -72 2. 19 从数学的 视角来 看,函 数 T2 U 和 U2 T 是非常奇特的 。理解它们的行为非常重 要。\n我们根据补码的 值解答这个间 题, 重新排列练习题 2. 17 的 解答中的行 , 然后列出无符号值作为函数应用的结果。我们展示十六进制值,以使这个过程更加具体。\n2. 20 这个练习题测试你对等式 ( 2. 5 )的理解。\n对千前 4 个条目,x 的值是负的,并 且 T 2队 ( x ) = x + 2\u0026rsquo; 。对于剩下的两个条目, x 的 值是非负的, 并且 T2亿 ( x ) = x 。\n2. 21 这个问题加强你对补码和无符号表示之间关 系的理解,以 及对 C 语言升级规则( pro mo tion ru le ) 的影响的理解。回 想一下 , T M in ,2是一 2 147 483 648 , 并且将它强制类型转换为无符号数后,变成了 2 147 483 648。另外,如果 有任何 一个运算数是无符号的,那 么在比较之前, 另一个运算数会被强制类型转换为无符号数。\n2. 22 这个练习很具 体地说明了符号扩展如何保 持一个补码表示的数值 。\nA. [ 1011 ] :\nB. [ 11 011 ] :\n一 沪 + 21 + 2 0\n—2 4 + 2 3 + 2 1 + 20 =\n- 8 + 2 + 1\n- 16 + 8 + 2 + 1\nc. [ 111 011 ] : 一沪 + 24+ 23 + 2\u0026rsquo; + 2° = - 32 + 16 + 8 + 2 + 1 = - 5\n2 . 23 这些函数 的表达式是 常见的程序 ”习惯用语“, 可以从多个位字段打包成的一个字中提取 值。它们利用不同移位运算的零填充和符 号扩展属性。请注意强制类 型转换和移位运算的顺序。在 f un l 中,移位是 在无符号 wo r d 上进行的,因 此是逻辑移位。在 f un 2 中, 移位是在把 wo r d 强制类 型转换为 i nt 之后进行的 ,因此 是算术移位。\nA.\nB. 函数 f unl 从参数的低 8 位中提取一 个值, 得到范围 0 ~ 255 的一个整数。函数 f un2 也从这个参数的 低 8 位中提取一个值,但是 它还要执行符 号扩展。结果将是介于—128 ~ 127 的一个数。\n2. 24 对于无符号数来说,截断的 影响是相当直观的, 但是对于补码数却不是。这个练习让你使用非常小的字长来研究 它的属性。\n原始数 。 十六进制 截断后的数 。 原始数 。 无符号 截 断后的数 。 补码 原始数 截断后的数 。 。 2 2 2 2 2 2 9 1 9 I - 7 1 B 3 11 3 - 5 3 F 7 15 7 - 1 -1 正如等式( 2. 9 ) 所描述的 , 这种截断无 符号数值的结果就 是发现它们模 8 的余数。截断有符号数的结果要更复杂一些。根据等式 ( 2. 1 0) , 我们首先计算这个参数模 8 后的余数。对千参数 0 ~ 7 , 将得出值 0 ~ 7 , 对于参数 一8 ~ - 1 也是一样。然后我们 对这些余数应用函数 UZT3 , 得出两个O~\n3 和- 4 ~ 1 序列的反复。\n2. 25 设计这个问题是要说明从有符号数到无符号数的隐式强制类型转换很容易引起错误。将参数l e ng t h 作为一个无符号数来传递看上去是件相当自然的 事情,因 为没有人会想 到使用一个长度为负数的值。停止条件 i \u0026lt; =l e ng t h- 1 看上去也很自然。但是把这两点组合 到一起 , 将产生意想不到的结果!\n因 为参数 l e ngt h 是无符号的,计 算 0 - 1 将使用无符号 运算 ,这 等价 于模 数加法。结果得到\nUM釭 0 比较同 样使用无符号 数比较, 而因为任何 数都是小于或者等千 UMa x 的, 所以这个比较总是为真! 因此,代码将试图 访问数组 a 的非法元素。\n有两种方法可以改正这段代码,其 一是将 l e ng吐 声明为 i n t 类型, 其二是将 f o r 循环的测试条件改为 江l e ng t h。\n2 26 这个例子说明了无符号运算的一个 细微的 特性, 同 时 也是我们 执行无符号运算时不会意识 到的属性。这会导致一些非 常棘手的错误。\n在什么情况下,这个函数会产生不正确的结果? 当 s 比 t 短的时候,该 函数会不正确地返回1。\n解释为什么会出现这样不 正确的结果 。由于 s tr l e n 被定义为产生一个无符号的结果, 差和比较都采用无符号运算来计算 。当 s 比 t 短的时候, s tr l e n (s ) - s 七rl e n (t ) 的差 会为负 , 但是变成了一个很大的无符号数,且大 千 0 。\n说明如何修改这段代码好让它能可靠地工作。将测试语句改成:\nreturn strlen(s) \u0026gt; strlen(t);\n2. 27 这个函数是对确定无符号加法是否溢出的规则的直接实现。\nI* Determine whether arguments can be added without overflow *I int uadd_ok(unsigned x, unsigned y) {\nunsigned sum= x+y; return sum\u0026gt;= x;\n2. 28 本题是对算术模 16 的简单示范。最容易的解决方法 是将十六 进制模式 转换成它的无符号十进 制值。对 于非零的 x 值,我们必 须有(一4x ) + x = l 6。然后,我 们就可以 将取补后的值转换回十六进制。\n2. 29 本题的目的是确保你理解了补码加法 。\nX y x+y x+ y 情况 -12 -15 -27 5 I [10100] [10001] [100101] [00101] -8 -8 -16 -16 2 (11000] [11000] [110000] [10000] -9 8 -1 -1 2 [10111] [01000] [111111J [11111] 2 5 7 7 3 [00010] [00101) [000111] [00111] 12 4 16 -16 4 (01100] [00100] [010000] [10000] 2. 30 这个函数是对确定补码加法是否溢出的规则的直接实现。\n/• Determine whether arguments can be added without overflow•I int tadd_ok(int x, int y) {\nint sum= x+y;\nint neg_over = x \u0026lt; 0 \u0026amp;\u0026amp; y \u0026lt; O \u0026amp;\u0026amp; sum\u0026gt;= O; int pos_over = x \u0026gt;= 0 \u0026amp;\u0026amp; y \u0026gt;= 0 \u0026amp;\u0026amp; sum\u0026lt; O; return !neg_over \u0026amp;\u0026amp; !pos_over;\n}\n2. 31 通过学习 2. 3. 2 节,你的同 事可能已经学 到补码加会形成一个阿贝尔群, 因此表达式 (x +y ) - x 求值得到 Y• 无论加法是否溢出 , 而 (x +y ) - y 总是会求值得到 x。\n2. 32 这个函数会 给出正确的 值,除 了当 y 等于 T M in 时。在这 个情况下 ,我 们有- y 也等于 TM in , 因此函数 t a dd_o k 会认为只要 x 是负数时 , 就会溢出, 而 x 为非负 数时, 不会溢出。实际上, 情况恰恰相反: 当 x 为负数时, t s ub_o k( x , TM in ) 为 1 ; 而当 x 为非负时 , 它为 0。\n这个练习说明 ,在 函数的任何 测试过程中 , T M i n 都应该作为一种测试 情况。\n2. 33 本题使用非常小的字长来帮助你理解补码的非。\n对于 w = 4 , 我们 有 T M in, = - 8 。因此一8 是它自己的加法逆元 , 而其他数值是 通过整数非来取非的。\n十六进制 。 X 十进制 。 一4I 十进制 。 X 十六进制 。 5 5 -5 B 8 -8 -8 8 D -3 3 3 F - I I 1 2. 34\n2. 35\n对于无符号数的非,位的模式是相同的。本题目是确保你理解了补码乘法 。\n模式 X y X · Y 截断了的X ·Y 无符号数 4 (100) 5 [IOI] 20 [010100] 4 [100] 补码 -4 [100] -3 [JOI] 12 [001100] -4 [100] 无符号数 2 [010] 7 [lll] 14 (001110] 6 [110] 补码 2 [010] -1 [Ill] -2 (111110] -2 (110] 无符号数 6 [110] 6 [110] 36 (100100] 4 [100) 补码 -2 [110] -2 [110] 4 [000100) -4 (JOO] 对所有可能 的 x 和 y 测试一遍这个 函数是不现实的。当数据类型 i nt 为 32 位时 , 即使你每秒运行一百亿个测试 , 也需要 58 年才能 测试完所有的组合 。另一方面,把 函数中的 数据类型改成 s hor t或者 c har , 然后再穷尽 测试, 倒是测试代码的一 种可行的方法。\n我们 提出以下论 据, 这是一个更理论的方法 :\n我们知 道 工. y 可以写成一个 2w 位的补码数字。用 u 来表示低 w 位表示的无 符号数 , v 表示高 w 位的补码数字。那么,根据公式 ( 2. 3), 我们可以得到 工 y = v沪 十u。\n我们还知道 u = T 2U,,. ( p ) , 因为它们是从同一个位模式得出来的无符号和补码数字,因此根据等式( 2. 6), 我们有 u= p + p,,,_ , 沪 , 这里 Pw- 1 是 p 的 最高有效位 。设 t = v + Pw- 1 , 我们有\n工 y= p + tw2 0\n2. 36\n当 t = O 时 , 有 工. y=p; 乘法不会 溢出。当 t =l=- 0 时 , 有 工. y=/=- p; 乘法不会溢出 。\n2 ) 根据整数除 法的定义 ,用 非零数 工除 以 p 会得到商 q 和余数r \u0026rsquo; 即 p = 工 q+r, 且 I 叶 \u0026lt; I 工 I 。\n( 这里用的是绝对值, 因为 工和 r 的符号可能不一致。例如,一 7 除以 2 得到商- 3 和余数- 1。)\n3 ) 假设 q = y 。那么有 工 y = 工 y + r + t2中。 在此, 我们 可以得到 r + t2w = o。但是 l r l \u0026lt; I 工 I\n沪 , 所以只有当 t = O 时,这个等式 才会成立 ,此时 r = O。假设r = t = O。 那么我们有 工 y = 工. q, 隐 含有 y = q。\n当 工= O 时 ,乘法不溢出 , 所以我们的代码提供了一种可靠的方法来测试补码乘法是否会导致溢。出如果用 64 位表示, 乘法就不 会有溢出 。然后我们 来验证将乘积强制类型转换为 32 位是否会改 变它的值:\nI* Determine whether the arguments can be multiplied without overflow *I\nint tmult_ok(int x, int y) {\nI* Compute product without overflow *I int64_t pll = (int64_t) x*y;\nI* See if casting to int preserves value *I return pll == (int) pll;\n}\n注意 ,第 5 行右边的强制类型转换 至关重要 。如果我们将 这一行写成\nint64_t pll = 江 y;\n就会用 32 位值来计算 乘积(可能会溢出),然后再符号扩展到 64 位。\n2. 37 A. 这个改 动完全没有帮助。虽 然 a s i ze 的计算会更 准确,但是 调用 ma l l oc 会导致这个值被转换成一个 32 位无符号数字,因 而还是会出现同样的溢出条件。\nma l l o c 使用一个 32 位无符号数作为参数 ,它 不可能分配一 个大于 沪 个字节的块, 因此, 没有必要试图 去分配或者复 制这样大的一块内存。取而代 之, 函数应该放弃,返 回 NULL, 用下面的代码取代 对 ma l l o c 原 始的调用(第9 行):\nuint64_t required_size\u0026rsquo;\u0026quot;\u0026rsquo;ele_cnt * (uint64_t) ele_size; size_t request_size = (size_t) required_size;\nif (required_size !• request_size)\n/• Overflow must have occurred. Abort operation•/ return NULL;\nvoid•result= malloc(request_size); if (result== NULL)\n/• malloc failed•/ return NULL;\n2. 38 在第 3 章中, 我们将看到很多实际的 LEA 指令的例子。用这个指令来支持指针运算 , 但是 C 语言编译器经常用它来执行小常数乘法 。\n对于每个 k 的值, 我们可以计算出 2 的倍数: 2 女( 当 b 为 0 时)和2\u0026rsquo; + 1( 当 b 为 a 时)。因此我\n们能够计算出倍数 为 1, 2, 3, 4, 5, 8 和 9 的值。\n2. 39 这个表达式就变成了- (x\u0026lt; \u0026lt;m)。要看清这一点,设字长为w , n = w - l 。形式 B 说我们要计算 (x\u0026lt; \u0026lt;w) ­ (x \u0026lt; \u0026lt;m)\u0026rsquo; 但是将 x 向左移 动 w 位会得到值 0。\n2. 40 本题要求你使用讲过的优化技术 ,同时 也需要自己的一点 儿创造力。\nK 移位 加法碱法 表达式 6 2 1 (X\u0026lt; \u0026lt;2 ) + (X \u0026lt;\u0026lt; l ) 31 l I (X\u0026lt;\u0026lt;5) - X -6 2 I (x \u0026lt;\u0026lt;l ) - (x \u0026lt;\u0026lt;3 ) 55 2 2 (X\u0026lt; \u0026lt; 6 ) - (X \u0026lt;\u0026lt;3 ) - X 可以观察到, 第四种情 况使用了形 式 B 的改进版本。我们可以 将位模式 [ 110111] 看作 6 个连续的 1 中间有一个 o, 因而我们对形式 B 应用这个原则,但 是需要在后来把中间 0 位对应的项减掉。\n2. 41 假设加法和减法有同样的性能, 那么原则就是当 n = m 时, 选择 形式 A, 当 n = m + l 时,随 便选哪种,而当 n\u0026gt; m + l 时,选 择形式 B。\n这个原则的证明 如下。首先假设 m\u0026gt; O。 当 n = m 时, 形式 A 只需要 1 个移位, 而形式 B 需要\n2 个移位和 1 个减法。当 n = m + l 时, 这两种形式都需要 2 个移位和 1 个加法或者 1 个减法。当n\u0026gt; m + l 时, 形式 B 只需要 2 个移位和 1 个减法,而形 式 A 需要 n - m + 1 \u0026gt; 2 个移位和 n - m \u0026gt; l 个加法。对 于 m = O 的 情况, 对千形式 A 和 B 都要少 1 个移位, 所以在两者中选择时, 还是适用同样的原则。\n2. 42 这里唯一的 挑战是不使用任何 测试或条件运算来计算偏 置量。我们利用了一 个诀窍,表 达式 x \u0026gt; \u0026gt;\n31 产生一个字,如果 x 是负数,这个 字为全 1, 否则为全 0。通过掩码屏蔽掉适当的位,我们 就得到期望的偏置值。\nint div16(int x) {\nI• Compute bias to be either O (x \u0026gt;= 0) or 15 (x \u0026lt; 0)•I int bias= (x»31) \u0026amp; OxF;\nreturn (x + bias)»4;\n}\n2. 43 我们发现当人们直接与汇 编代码打交道时是有困难的 。但当把它放入 op t ar i 七h 所示的形式中时,问题就变得更加清晰明了。\n我们可以看到 M 是 31 ; 是用 (x \u0026lt; \u0026lt;S) - x 来计算 x* M。\n106 笫一部分 程序结构和执行\n我们可以 看到 N 是 8 ; 当 y 是负数时, 加上偏置量 7\u0026rsquo; 并且右移 3 位。\n2. 44 这些 \u0026quot; C 的谜题“ 清楚地告诉 程序员 必须理 解计算 机运算的属性。\nA. (x \u0026gt; 0) I I ( (x 一 1 ) \u0026lt; 0)\n假。设 x 等于- 2 147 483 648 ( TM in 32) 。 那么, 我们有 x- 1 等千 2147483647( TMa 工32) 。\nB. (x \u0026amp; 7) != 7 11 (x \u0026lt;\u0026lt; 29 \u0026lt; 0)\n真。如果 (x \u0026amp; 7) != 7 这个表达式的值为 o, 那么我们必须有位 工2 等于 1。当左移 29 位时 , 这个位将变成符号位。\nC. (x * x) \u0026gt; = 0\n假。当 x 为 65 535( Ox FFFF) 时 , X * X 为- 131 071( Ox FFF EOOOl ) 。\nx \u0026lt; 0 I I -x \u0026lt; = 0\n真。如果 x 是非负数, 则- x 是非正的。\nx \u0026gt; 0 I I -x \u0026gt; = 0\n假。设 x 为- 2 147 48 3 648 ( TM in32) 。 那么 x 和- x 都为负数。\nx+y = = uy+ux\n真。补码和无符号乘法有相同的位级行为,而且它们是可交换的。\nx*~y + uy*ux == - x\n真。~y 等千- y- 1。uy*ux 等千 x *y 。因此 , 等式左边等价 千 x *- y- x +x *y 。\n2. 45 理解二进制小数表示是理解浮点编码的一个重要步骤。这个练习让你试验一些简单的例子。\n考虑二进制小数 表示的 一个简单 方法是将一个数表示为形如责 的小数。我们将这个形式表示\n为二进制的过程是:使用 :r: 的二 进制表示 , 并把二进制小 数点插入从右边算起的第 k 个位置。举一个例子,对 25 ,我们有 2510 11001 2 。 然后我们 把二进制小数点放在从右算起的第 4 位, 得\n16\n到 1. 10012 0\n46 在大多数情况中, 浮点数的有限精度不是 主要的问 题,因为计算 的相对误差仍然是相当低的。然而在这个例 子中, 系统对于绝对误 差是很敏感的。 我们 可以看到 o. 1 - 工的二进制表示 为:\n0. 000000000000000000000001100[ 1100] ··· 2\n把这个表示与— 的二进 制表示进行比较 ,我 们可以 看到这就是 2一 2o x - , 也就是大约 9. 54 X 10 10\n10 - s .\nC. 9. 54 X 1-0 s X 100 X 60 X 60 X 10 \u0026lt;::::0 . 3 43 秒 。\nD. O. 343 X 2000 \u0026lt;::::687 米 。\n2. 47 研究字长非常小的 浮点表示能够帮助澄清 IEEE 浮点是怎样工作的。要特别注意非 规格化数和规格化数之间的过渡。\n2. 48 十 六 进 制 Ox 3591 41 等 价 于 二 进 制 [ 1101011 001000 101000001 ] 。将 之 右 移 21 位 得 到\n1. 101011 001 000101000001 2 X 22 1 。除 去起 始位 的 1 并 增 加 2 个 0 形 成小 数字 段,从 而得 到 [ 10101100100010100000100 ] 。阶 码 是 通 过 21 加 上 偏 置 狱 1 27 形 成的, 得 到 148 ( 二 进 制[ 10010100] ) 。我们把它 和符号字段 0 联合起来 , 得到二进制表示\n[01001010010101100100010100000100]\n我们看到两种表示中 匹配的位对应于整数的 低位到最高 有效位等千 1, 匹配小数的高 21 位:\n0 0 3 5 9 1 4 1\n00000000001101011001000101000001\n*********************\n4 A 5 6 4 5 O 4\n01001010010101100100010100000100\n49 这个练习帮助你思考什么数不能用浮点准确表示。 这个数的二进制表示是: 1 后面跟着 n 个 o, 其后再跟 1 , 得到值是 2+• 1+ 1 。\nB. 当 n = 23 时,值是 224+ 1 = 1 6 777 217。\n2. 50 人工舍人帮助你加强二进制数舍人到偶数的概念。\n原始值 舍入后的值\nl 10 .0\n2. 51 A. 从 1/ 10 的无穷序列中我们可以看到 , 舍人位置右边 2 位都是 1 , 所以对 1 / 10 更好一点儿的近似值应该是对 x 加 1, 得到 x \u0026rsquo; = O. 000110011001100110011012, 它比 0. 1 大一点儿。\n我们可以 看到 x \u0026rsquo; - 0. l 的二进制表示为:\n0.0000000000000000000[1100]\n将这个值与 1 / 10 的二进制表示做比较,我 们可以 看到它等于 2一22 X 1 / 10 , 大约等于 2. 38 X\n10- s .\n2. 38X 10-s X l OO X 60 X 60X 10 :::::::::。. 郊6 秒,爱国者导弹系统中的误差是它的 4 倍。\nD. O. 086 X 2000::::::::: 订 1 米 。\n2. 52 这个题目考查了很多关于浮点表示的概念,包括规格化和非规格化的值的编码,以及舍入。\n108 笫一部分 程序结构和执行\n格式A 位 值 格式B 位 注 011 0000 1 0111 000 I 1011110 152 1001111 152 010 1001 舒 OllO 100 ¾ 向下舍入 110 1111 312 1011 000 16 向上舍人 0 0 0 0001 忐 0001 000 忐 非规格化一规格化 2. 53 一般来说,使用库宏 ( librar y macro) 会比你自己写的代码更好 一些。不过, 这段代码似乎可以 在多种机器上工作。\n假设值 l e 400 溢出为无穷 。\n#define POS_INFINITY 1e400\n#define NEG_INFINITY (-POS_INFINITY)\n#define NEG_ZERO (-1.0/POS_INFINITY)\n5 4 这个练习可以 帮助你从 程序员的角度来提高研究 浮点运算的能力 。确信自己理解下 面每一个答案 。\nx == (int) (double) x\n真, 因为 d o ub l e 类型比 i n t 类型具有更 大的精度和范图。\nx == (i n 七 )(double) x\n假,例如当 x 为 TMa x 时。\nd == (double) (float) d\n假,例 如当 d 为 l e 40 时,我 们在右边得 到十C0 。\nf == (float) (double) f\n真,因为 d o ub l e 类型比 f l o a t 类型具有更大的精度 和范围。\nf == -(-fl\n真,因 为浮点 数取非就是 简单地对它的符 号位取反 。\nF. 1.0/2 == 1/2.0\n真,在执行除法之前,分子和分母都会被转换成浮点表示。\nd *d \u0026gt; =O. O\n真 ,虽 然它可能 会溢出到十C0 。\n(f +d ) 一f == d\n假, 例如当 f 是 1 . 0e 20 而 d 是 1. 0 时, 表达式 f +d 会舍入到 1 . Oe20, 因此左边的表达式求值得到 o. o, 而右边是 1. 0。\n"},{"id":439,"href":"/zh/docs/technology/System/%E6%B7%B1%E5%85%A5%E7%90%86%E8%A7%A3%E8%AE%A1%E7%AE%97%E6%9C%BA%E7%B3%BB%E7%BB%9F/%E4%B9%A6%E7%B1%8D/%E7%AC%AC3%E7%AB%A0-%E7%A8%8B%E5%BA%8F%E7%9A%84%E6%9C%BA%E5%99%A8%E7%BA%A7%E8%A1%A8%E7%A4%BA/","title":"Index","section":"SpringCloud","content":"第 3 章\n—- CH APTER 3\n程序的机器级表示 # 计算机执行机器代码,用字节序列编码低级的操作,包括处理数据、管理内存、读写 存储设备上的数据,以及利用网络通信。编译器基于编程语言的规则、目标机器的指令集 和操作系 统遵循的 惯例, 经过一系列的 阶段生成 机器代码 。GCC C 语言编译器以汇编代码的形式产生输出,汇编代码是机器代码的文本表示,给出程序中的每一条指令。然后\nGCC调用汇编 器和 链接器, 根据汇编代码生成可执行的机器代码。在本章中, 我们会近距离地观察机器代码,以及人类可读的表示 汇编代码。\n当我们用高级语言 编程的时候(例如C 语言, Java 语言更是如此), 机器屏蔽了 程序的 细节,即机器级的实现。与此相反,当用汇编代码编程的时候(就像早期的计算),程序员必须 指定程序用来执行计算的低级指令。高级语言提供的抽象级别比较高,大多数时候,在这种 抽象级别上工作效率会更高,也更可靠。编译器提供的类型检查能帮助我们发现许多程序错 误,并能够保证按照一致的方式来引用和处理数据。通常情况下,使用现代的优化编译器产 生的代码至少与一个熟练的汇编语言程序员手工编写的代码一样有效。最大的优点是,用高级 语言编写的程序可以在很多不同的机器上编译和执行,而汇编代码则是与特定机器密切相关的。 那么为什么我们还要花时间学习机器代码呢?即使编译器承担了生成汇编代码的大部\n分工作,对千严谨的程序员来说,能够阅读和理解汇编代码仍是一项很重要的技能。以适 当的命令行选项调用编译器,编译器就会产生一个以汇编代码形式表示的输出文件。通过 阅读这些汇编代码,我们能够理解编译器的优化能力,并分析代码中隐含的低效率。就像 我们将在 第 5 章中体会到的那样, 试图最大化一 段关键代码性能的程序员 , 通常会尝试源代码的各种形式,每次编译并检查产生的汇编代码,从而了解程序将要运行的效率如何。 此外,也有些时候,高级语言提供的抽象层会隐藏我们想要了解的程序的运行时行为。例 如,第 12 章会讲到,用线程包写并 发程序时 ,了 解不同的线程是如何共享程序数 据或保持数据私有的,以及准确知道如何在哪里访问共享数据,都是很重要的。这些信息在机器代码 级是可见的。另外再举一个例子,程序遭受攻击(使得恶意软件侵扰系统)的许多方式中,都涉及程序存储运行时控制信息的方式的细节。许多攻击利用了系统程序中的漏洞重写信息, 从而获得了系统的控制权。了解这些漏洞是如何出现的,以及如何防御它们,需要具备程序 机器级表示的知识。程序员学习汇编代码的需求随着时间的推移也发生了变化,开始时要求 程序员能直接用汇编语言编写程序,现在则要求他们能够阅读和理解编译器产生的代码。\n在本章 中, 我们 将详细学习一 种特别的 汇编语 言,了 解如何将 C 程序编译成这种形式的机器代码。阅读编译器产生的汇编代码,需要具备的技能不同千手工编写汇编代码。我 们必须 了解典型的编译器在将 C 程序结 构变换成 机器代码时所做的转换 。相对于 C 代码表示的计算操作,优化编译器能够重新排列执行顺序,消除不必要的计算,用快速操作替换 慢速操作,甚至将递归计算变换成迭代计算。源代码与对应的汇编代码的关系通常不太容易 理解一一-就像要拼出的 拼图与盒子上图 片的设 计有点不太一样。这是一种逆向 工程 ( reverse engineering ) — 通过研究 系统 和逆向工作, 来试图了解系统 的创建过程。 在这里, 系统是一个机器产生的汇编语言程序,而不是由人设计的某个东西。这简化了逆向工程的任\n务,因为产生的代码遵循比较规则的模式,而且我们可以做试验,让编译器产生许多不同程序的代码。本章提供了许多示例和大量的练习,来说明汇编语言和编译器的各个不同方面。精通细节是理解更深和更基本概念的先决条件。有人说:“我理解了一般规则,不愿意劳神去学习细节!”他们实际上是在自欺欺人。花时间研究这些示例、完成练习并对照提供的答案来检查你的答案,是非常关键的。\n我们的表述基千 x86-64, 它是现在笔记本电脑和台式机中最常见处理器的机器语言,也是驱动大型数据中心和超级计算机的最常见处理器的机器语言。这种语言的历史悠久,开始于 Intel 公司 1978 年的第一个 16 位处理器 , 然后扩展为 32 位, 最近又扩展到 64 位。一路以来,逐渐增加了很多特性,以更好地利用巳有的半导体技术,以及满足市场需求。这些进步中很多是 Intel 自己驱动的 , 但它的对手 AMD( Advanced Micro Devices)也作出了重要的贡献。演化的结果是得到一个相当奇特的设计,有些特性只有从历史的观点来看才有意义,它还具有 提供后向兼容性的特性,而现代编译器和操作系统早已不再使用这些特性。我们将关注\nGCC 和 Linux 使用的那些特性, 这样可以 避免 x86-64 的大量复杂性 和许多隐秘特性。\n我们 在技术讲解之 前,先 快速浏览 C 语言、汇编代码以及机器代码之间 的关系。然后介绍 x86-64 的细节 , 从数据的表示 和处理以及控制的实现开始。了解如何 实现 C 语言中的控制结构, 如 if 、wh i l e 和 s wi t c h 语句。之后 , 我们会 讲到过程的实 现, 包括程序如何维护一个运行栈来支持过程间数据和控制的传递,以及局部变最的存储。接着,我们会考虑在机器级如何实现像数组、结构和联合这样的数据结构。有了这些机器级编程的背景知识,我们会讨论内存访问越界的问题,以及系统容易遭受缓冲区溢出攻击的问题。在这一部分的结尾, 我们会 给出一 些用 GDB 调试器检查机器级程序运行时行为的 技巧。本章的最后展示了包含浮点数据和操作的代码的机器程序表示。\n- IA32 编程\nIA32, x86- 64 的 32 位前身,是 Intel 在 1985 年提出的 。几十年 来一 直是 Intel 的机 器语言之选。今天出售的 大多数 x86微处理器,以及这些机 器上安装的 大多数操作 系统, 都是为运行 x86-64设计的。不过 , 它们也可以向后 兼容执行 IA32 程序。所以,很 多应用 程序还是基于 IA32 的。除此之外, 由于硬件或 系统软件的限制, 许多已有的 系统不能够执行 x86-64。\nIA32 仍然是一种重要的机 器语言。学习过 x86-64会使你很容易地学会 IA32 机器语言。\n计算机工业已经完成从 32 位到 64 位机器的过渡 。32 位机器只能使用大概 4GB( 沪 字节)的随机访问存储器。存储器价格急剧下降,而我们对计算的需求和数据的大小持续增 加, 超越这个 限制既经 济上可行又有技术上的需要。当前的 64 位机器能够使用多达256T B( 248 字节)的内存空间 , 而且很容易 就能扩展至 16EB ( 26 4 字节)。虽然很难想象一台机器需要这么大的内存, 但是回想 20 世纪 70 和 80 年代, 当 32 位机器开始普及的时候,\n4GB 的内存看上去也是超级大的 。\n我们的 表述集 中于以现代操作 系统为目标, 编译 C 或类似编 程语言时, 生成的 机器级程序类型 。x86-64 有一些 特性是 为了 支持遗留下来的 微处理器早期 编程风格 , 在此,我 们不试图去描述这些特性 , 那时候大部分代码都是手工编写的, 而程序员 还在努力与 16 位机器允许的有限地址空间奋战。\n1 历史观点\nIntel 处理器系列俗称 x86, 经历了一个长期的、不断进化的发展过程。开始时,它是第\n一代单芯片 、16 位微处理器之一, 由千当时 集成电 路技术水 平十分 有限, 其中做了很多妥协。以后,它不断地成长,利用进步的技术满足更高性能和支持更高级操作系统的需求。\n以下列举 了一些 In tel 处理器的模型, 以及它们的一些关键特性, 特别是影响机器级编程的特性。我们用实现这些处理器所需要的品体管数量来说明演变过程的复杂性。其 中, \u0026quot; K\u0026quot; 表示 1000 , \u0026quot; M\u0026quot; 表示 1 000 000, 而 \u0026quot; G\u0026quot; 表示 1 000 000 000 。\n8086(1978 年, 29K 个晶体管)。它是第一代单芯片、16 位微处理器之一。 8088 是 8086\n的一个变 种, 在 8086 上增 加了一个 8 位外部总线, 构成最初的 IBM 个人计算机的心脏。\nIBM 与当时还不强大的微软签订合同 , 开发 MS-DOS 操作系统。最初的机器型号有32 768 字节的内存和两个软驱(没有硬盘驱动器)。从体系结构上来说 , 这些机器只有 655 360 字节的地址空间 地址只有 20 位长(可寻址范围为 1 048 576 字节), 而操作系统保 留了 393 216 字节自用。 1980 年, Int el 提出了 8087 浮点 协处理器( 45K 个晶体管), 它与一个 8086 或 8088处理器一同运行 , 执行浮点指令 。8087 建立了 x86 系列的浮点模型 , 通常被称为 \u0026quot; x87\u0026quot;。\n80286(1 982 年, 13 4K 个晶体管)。增加了更多的寻址模式(现在巳 经废弃了), 构成了 IBM PC- AT 个人计算机的基础, 这种计算 机是 MS Windows 最初的使用平台。\ni386(1 985 年, 275K 个晶体管)。将体系结构扩展到 32 位。增 加了平坦寻址模式 ( flat addressing model) , Linux 和最近版本的 Windows 操作系统都是使用的这种模式。这是\nIntel 系列中第一台全 面支持 U nix 操作系统的机器。\ni486 (1 989 年, 1. 2M 个晶体管)。改善了性能, 同时将浮点 单元集成到了处 理器芯片上,但是指令集没有明显的改变。\nPentium (1 993 年, 3. l M 个晶体管)。改善了性能, 不过只对指令集 进行了小 的扩展。\nPentiumP ro(1 995 年, 5. 5M 个晶体管)。引入全新的处理器设计, 在内部被称为 P 6\n微体系 结构。指令集 中增加了一类 ”条件传送 ( cond iti onal move) \u0026quot; 指令。\nPentium/ MMX C1997 年, 4. 5M 个晶体管)。在 Pentium 处理器中增加了一类新的处理整数 向量的指令 。每个数据大小 可以是 1、2 或 4 字节。每个向量 总长 64 位。\nPentium 11(1 997 年, 7M 个晶体管)。P6 微体系结构的延伸。\nPentium 111(1 999 年, 8. 2M 个晶体管)。引入了 SSE , 这是一类处理整数或浮点数向最的指令 。每个数 据可以是 1、2 或 4 个字节, 打包成 128 位的向量。由 千芯片上包括了二级高速缓 存, 这种芯片后来的 版本最多使用了 24M 个品体管。\nPentium 4 ( 2000 年, 42M 个晶体管)。SSE 扩展 到了 SSE 2 , 增加了新的数据类型(包括双精 度浮点数), 以及针对这些格式的 144 条新指令。有了这些 扩展, 编译器可以 使用\nSSE 指令(而不是x87 指令), 来编译浮点 代码。\nPentium 4E ( 2004 年, 1 25M 个晶体管)。增加了超线程 ( hypert hreading ) , 这种技术可以在 一个处理器上同时运行 两个程序; 还增 加了 EM64T , 它是 In tel 对 AMD 提出的对\nIA32 的 64 位扩展的 实现, 我们称之为 x86-64 。\nCore 2( 2006 年, 291 M 个晶体管)。回归到类似于 P6 的微体系结 构。Intel 的第一个多核微处理器,即多处理器实现在一个芯片上。但不支持超线程。\nCore i7, Nehalem ( 2008 年, 781 M 个晶体管)。既支持超线程, 也有多核, 最初的版\n本支持每个核上执行两个程序,每个芯片上最多四个核。\nCore i7, Sandy Brid ge( 20 11 年, 1. 1 7G 个晶体管)。引入了 AV X , 这是对 SSE 的扩展, 支持把数 据封装 进 256 位的向量。\nCore i7, H aswe ll ( 2013 年, 1. 4G 个晶体管)。将AV X 扩展 至 AV X2 , 增加了更多的\n指令和指令格式。\n每个后继处理器的设计都是后向兼容的一较早版本上编译的代码可以在较新的处理 器上运行。正如我们看到的那样,为了保持这种进化传统,指令集中有许多非常奇怪的东 西。Int el 处理器 系列 有好几个名字, 包括 IA 32 , 也就是 \u0026quot; Intel 32 位 体系结构 \u0026lt; Intel Architecture 32-bit) \u0026ldquo;, 以及最新的 In tel6 4 , 即 IA32 的 64 位扩展, 我们也称为 x86-64 。最常用的名字是 \u0026quot; x86\u0026rdquo; , 我们用它指 代整个 系列 , 也反映了直到 i486 处理器命名的惯例。\nm 摩尔定律 ( Moo re \u0026rsquo; s Law)\n如果我们画 出各种不同的 Int el 处理 器 中晶 体管的 数量与 它们 出现 的年份 之间的 图( y 轴为晶 体管数 量的 对数值), 我们能够 看 出, 增长是 很显著的。画一条拟合这些数 据的线, 可以 看到晶 体管数 量以每年 大约 37 % 的速率增 加, 也就是 说, 晶体管数 量每 26 个月就 会翻一 番。在 x86 微处理 器的 历 史上 , 这种增长已经持 续 了好几十年 。\nIntel微处理器的复杂性\nI.OE+ 10\nLOE+ 09\nNeha。lem◊\nPentium4 夕ePentium4\n/Core 2 Duo\nI.OE+ 05\nI.OE+ 04\n1975 1980 1985 1990 1995 2000 2005 2010 2015\n年份\n1965 年, G ordon Moore, Intel 公 司的创始 人, 根据当时 的芯片技术(那时他们能够在一个芯 片 上制造有 大约 64 个晶 体管的电 路)做出推 断, 预测在 未来 10 年, 芯片 上的晶体管数量每年都会翻一番 。这个预测就称为摩 尔定律。正如事实证明的那样, 他的预测有点乐观, 而且 短视。在超过50 年中, 半导体工业一直能够使得晶体管数目每 18 个月翻一倍。\n对计算机技术的其他方面,也有类似的呈指数增长的情况出现,比如磁盘和半导体 存储 器的 存储 容量。 这些惊人的 增长速度一 直是 计 算机 革命的 主要驱动力。\n这些年来 , 许多公司生产出了 与 Inte l 处理器兼 容的处理器, 能够运行完全相同 的机器级程序。其 中, 领头的是 AMD。数年来, AMD 在技术上紧跟 In tel, 执行的市场策略是: 生产性能 稍低但是价格更便宜的处理器。2002 年, AMD 的处理器变得更加有竞争力, 它们率先突破了 可商用微处理器的 1G H z 的时钟速度屏障, 并且引 入了广泛采用的\nIA32 的 64 位扩展 x86-64。虽 然我们讲的是 Inte l 处理器, 但是对于其竞争对手生产的与之兼容的处理器来说 , 这些表述也同样成 立。\n对于由 CCC 编译器产生的 、在 Linux 操作系统平台上运行的程序 , 感兴趣的人大多并不关心 x86 的复杂性。最初的 8086 提供的内 存模型和它在 80286 中的扩展 , 到 i386 的时候就都已经过时了。原来的x87 浮点指令到引入 SSE2 以后就过时了 。虽然在 x86-64 程序中 , 我们能看到历史发展的痕迹 , 但 x86 中许多最晦涩难懂的特性已经不会出现了。\n3. 2 程序编码\n假设一个 C 程序, 有两个 文件 p l. c 和 p 2 . c 。我们用 Unix 命令行编译这些代码 :\nlinux\u0026gt; gee -Og -op p1.e p2.e\n命令 g e e 指的就是 GCC C 编译器。因 为这是 Lin u x 上默认的编译器, 我们也可以 简单地用 cc 来启 动它。编译 选项 - Oge 告诉编译器使用 会生成符合原 始 C 代码整体结 构的机器代码的优化等级。使用较高级别优化产生的代码会严重变形,以至于产生的机器代码和 初始源 代码之间的 关系非常难以 理解。因 此我们会使 用- Og 优化作为学 习工具 , 然后当我们增加优化级别时,再看会发生什么。实际中,从得到的程序的性能考虑,较高级别的优 化(例如, 以选项 - 0 1 或- 0 2 指定)被认为是 较好的 选择。\n实际上 gee 命令调用了一 整套的 程序 , 将源代码转化成可执行代码。首先, C 预 处理器扩展源代码 , 插入所有用 #i ne l ude 命令指定的文件, 并扩展所有用#de f i ne 声明指定的宏。其 次, 编译 器产生两个源文件的 汇编代码 , 名字分别为 p l. s 和 p 2 . s 。接下 来, 汇编器会 将汇编代码转化成二 进制 目标 代码文 件 p l. o 和 p 2 . o 。目 标代码是机器代码的一种形式,它包含所有指令的二进制表示,但是还没有填入全局值的地址。最后,链接器将两 个目标代码 文件与实现库函数(例如 p r i n t f ) 的代码合并 , 并产生最终 的可执行代码文件 p\n(由命令行指示符 - o p 指定的)。可执行代码是我们要考虑的 机器代码的 第二种形式, 也就是处理器执 行的代码格式 。我们会在第 7 章更详细地介绍 这些不同形式的机器代码 之间的关系以及链接的过程。\n2. 1 机器级代码\n正如在 1. 9. 3 节中讲 过的那样, 计算机系统 使用了多种不同形式的抽象 , 利用更简单的抽象模型来隐藏实现的细节。对于机器级编程来说,其中两种抽象尤为重要。第一种是由指令集 体 系结构或指令 集 架构 O ns tru et ion Set Arehiteeture, ISA ) 来定义机器级程序的格式和行为,它定义了处理器状态、指令的格式,以及每条指令对状态的影响。大多数\nISA, 包括 x86-64 , 将程序的行为描述成好像每条指令都是按顺序执行的,一条指令结束后,下一条再开始。处理器的硬件远比描述的精细复杂,它们并发地执行许多指令,但是可 以采取 措施保证整体行为与 ISA 指定的顺序执行的行为完全一致。第二种抽象是 , 机器级程序使用的内存地址是虚拟地址,提供的内存模型看上去是一个非常大的字节数组。存储器系 统的实际实现是将多个硬件存储器和操作系统软件组合起来, 这会在第 9 章中讲到。\n在整个编译过程中 , 编译器会完成大部分的工作 , 将把用 C 语言提供的 相对比较抽象的执行模型表示的程序转化成处理器执行的非常基本的指令。汇编代码表示非常接近于机器代 码。与机器代码的二进制格式 相比, 汇编代码的 主要特点 是它用可读性更好的文本格式 表示。能够理解汇编代码 以及它与原始C 代码的联系, 是理解计算机如何执行程序的关键一步。\nx86-64 的机器代码 和原始的 C 代码差别非常大。一些通常对 C 语言程序员隐藏的处理器状态都是可见的:\n程序计数 器(通常称为 \u0026quot; PC\u0026quot; , 在 x86-64 中用%r i p 表示)给出将要执行的下一条指令在内存中的地址。 8 GCC 版本 4. 8 引入了这个优化等级。较早的 CCC 版本 和其他 一些非 G U 编译器不认 识这个选项 。对这样一些编译器, 使用一级优化(由命令行标志-0 1 指定)可能是最好的选择, 生成的代码能 够符合原始程序的结构。\n整数寄存器文件包含 16 个命名的位置, 分别存储 64 位的值。这些寄存器可以存储地址\n(对应于 C 语言的指针)或整数数据。有的寄存器被用来记录某些重要的程序状态, 而其他的寄存器用来保存临时数据, 例如过程的参数和局部变量, 以及函数的返回值。\n条件码寄存器保存着最近执行的算术或逻辑指令的状态信息。它们用来实现控制或 数据流中的 条件变化 , 比如说用来实现 if 和 wh i l e 语句。\n一组向量寄存器 可以存放一个或多个 整数或 浮点 数值。\n虽然 C 语言提供了一种模型, 可以在内存中声明 和分配各种数 据类型的对象, 但是机器代码只是简单地将内存看成一个很大的、按字节寻址的数组。 C 语言中的聚合数据类型, 例如数组和结构,在机器代码中用一组连续的字节来表示。即使是对标量数据类型,汇编代码 也不区分有符号或无符号整数,不区分各种类型的指针,甚至于不区分指针和整数。\n程序内存包含:程序的可执行机器代码,操作系统需要的一些信息,用来管理过程调 用和返回的运行时栈 ,以 及用户分 配的内存块(比如说用 ma l l o c 库函数分配的)。正如前面提到的,程序内存用虚拟地址来寻址。在任意给定的时刻,只有有限的一部分虚拟地址 被认为是合法的。例如, x86-64 的虚拟地址是由 64 位的字来表示的。在目前的实现中,\n这些地址的高 16 位必须设置为 o, 所以一个地址实际 上能 够指定的是 2 4 8 或 64T B 范围内\n的一个字节。较为典型的程序只会访问几兆字节或几千兆字节的数据。操作系统负责管理虚拟地址空间,将虚拟地址翻译成实际处理器内存中的物理地址。\n一条机器指令只执行一个非常基本的操作。例如,将存放在寄存器中的两个数字相加, 在存储器和寄存器之间传送数据,或是条件分支转移到新的指令地址。编译器必须产生这些 指令的序列,从而实现(像算术表达式求值、循环或过程调用和返回这样的)程序结构。\n田 日 不断变化的 生成 代码的 格式\n在本书的 表述中,我们给 出的 代码是由特定版本的 GCC 在特定的命令行选项设置下产 生的 。如 果你在自己 的机 器上编 译代码, 很有可能 用到 其他的 编译 器或 者不 同版本的 GCC , 因 而会产 生不同的代 码。 支持 GCC 的 开源社区 一 直在修 改代码产 生 器,试图根据微处理 器制 造商提供的 不断 变化的代码规则 ,产 生更有效的 代码。\n本书示例的目标是展示如何查看汇编代码,并将它反向映射到高级编程语言中的结 构。你需要 将这些技 术应 用到 你的 特定的编译 器产 生的 代码格 式上 。\n2. 2 代码示例\n假设我们写 了一个 C 语言代码文 件 ms t or e . c , 包含如下的函数定义:\nlong mult2(long, long);\nvoid multstore(long x, long y, long *dest) { long t = mult2(x, y);\n*dest = t;\n}\n在命令行 上使用 \u0026ldquo;-s\u0026rdquo; 选项 , 就能看到 C 语言编译器产生的 汇编代码 :\nlinux\u0026gt; gee -Og -S mstore.e\n这会使 GCC 运行 编译 器, 产生一个汇编文件 ms t or e . s , 但是不做其他进一步的工作。(通常情况下,它还会继续调用汇编器产生目标代码文件)。\n汇编代码文件包含各种声明,包括下面几行:\nmultstore: pushq %rbx\nmovq %rdx, %rbx\ncall mult2\nmovq %rax, (%rbx)\npopq %rbx ret\n上面代码 中每个缩进去的行都对应于一条机器指令。比如, p us hq 指令表示应该 将寄存器%\nr bx 的内容压入程序栈中。这段代码中已经除去了所有关于局部变星名或数据类型的信息 。如果我们 使用 \u0026quot; - c\u0026quot; 命令行选项, GCC 会编译并 汇编该 代码:\nlinux\u0026gt; gee -Og -e mstore.e\n这就会 产生目标 代码文件 ms t or e . o , 它是二进制格式的, 所以无法直接查看。1 368 字节的文件 ms t or e . o 中有一段 1 4 字节的序列, 它的十六进制 表示为:\n53 48 89 d3 e8 00 00 00 00 48 89 03 Sb c3\n这就是上面列出的汇编指令对应的目标代码。从中得到一个重要信息,即机器执行的程序只 是一个字节序列,它是对一系列指令的编码。机器对产生这些指令的源代码几乎一无所知。\nm 如何展 示程序的 字节表示\n要展示程序(比 如说 ms t or e ) 的二进制目 标代码, 我们 用反汇编 器( 后 面会 讲到)确定该过程的代码 长度是 1 4 宇 节。 然后, 在文件 ms t or e . o 上运行 GNU 调试工具 GOB, 输入命令 :\n(gdb) x/14xb multstore\n这条命令 告诉 GOB 显示( 简写 为 \u0026rsquo; x \u0026rsquo; ) 从 函 数 mu l t s t or e 所处地 址开始的 1 4 个十 六进 制格式表 示(也简写为 \u0026rsquo; x \u0026rsquo; ) 的 宇 节( 简写 为 \u0026rsquo; b \u0026rsquo; ) 。 你会发现, GOB 有很 多 有用的 特性可以用来 分析机 器级程序 , 我们会 在 3. 10. 2 节中讨 论。\n要查看机器代码 文件的内容, 有一类称为反汇 编 器 ( dis assem bier ) 的程序非常有用。这些程 序根据机器代码产生一 种类似于汇编代码的 格式。在 Lin u x 系统中, 带`-扩命令行标志的程序 OBJDUMP ( 表示 \u0026quot; o bject d um p\u0026quot; ) 可以充当这个角色:\nlinux\u0026gt; objdump -d mstore.o\n结果如下(这里,我们在左边增加了行号,在右边增加了斜体表示的注解): Disassembly of function multstore in binary file mst or e . o 0000000000000000 \u0026lt;multstore\u0026gt;:\nOffset Bytes\n0: 53\n1: 48 89 d3\n4: e8 00 00 00 00\n9: 48 89 03\nc: 5b\nd: c3\nEquivalent assembly language\npush %rbx\nmov %rdx,%rbx\ncallq 9 \u0026lt;multstore+Ox9\u0026gt; mov %rax, (%rbx)\npop %rbx retq\n在左边 , 我们看到按照前 面给出 的字节顺序排列的 14 个十六 进制字节值, 它们分成了若干组 , 每组有 1 ~ 5 个字节。每组都是一条指令 , 右边是等价的 汇编语言 。\n其中一些关千机器代码和它的反汇编表示的特性值得注意:\nx8 6- 64 的指令长 度从 1 到 1 5 个字节不等。 常用 的指令以及操作数较少的指令所需的字节数少,而那些不太常用或操作数较多的指令所需字节数较多。\n·设计指令格式的方式是,从某个给定位置开始,可以将字节唯一地解码成机器指 令。例如 ,只 有指令 p us h q % r b x 是以字节值 53 开头的 。\n反汇编器只是基于机器代码文件中的字节序列来确定汇编代码。它不需要访问该程序的源代码或汇编代码。\n反汇编器使 用的指令命名规则与 GCC 生成的汇编代码使用的有些细微 的差别。在我们的示 例中, 它省略了很 多指令结尾的 \u0026rsquo; q \u0026rsquo; 。这些后缀是大小指示符, 在大多数情况中可以省略。相反, 反汇编器 给 c a l l 和r e t 指令添加了'矿后缀, 同样, 省略这些后缀也没有问题。\n生成实际可执行的代码需要对一组目标代码文件运行链接器,而这一组目标代码文件 中必须含有一个 ma i n 函数。假设在文件 ma i n . c 中有下面这样的 函数:\n#include \u0026lt;stdio.h\u0026gt;\nvoid multstore(long, long, long*); int main() {\nlong d;\nmultstore(2, 3, \u0026amp;d); printf(\u0026ldquo;2 * 3 \u0026ndash;\u0026gt; %1d\\n\u0026rdquo;, d); return O;\n}\nlong mult2(long a, long b) { longs= a* b; returns;\n}\n然后, 我们用 如下方法生成 可执行 文件 pr o g : linux\u0026gt; gee -Og -o prog main.e mstore.e\n文件 pr og 变成了 8 655 个字节, 因为它不仅包含了 两个 过程的 代码 , 还包 含了用来启动和终止程序的 代码, 以及用来与操作 系统交互的 代码。我 们也可以反汇编 pr o g 文件:\nlinux\u0026gt; objdump -d prog\n反汇编器会抽取出各种代码序列,包括下面这段: Disassembly of function sum mul tstore binary file prog 0000000000400540 \u0026lt;multstore\u0026gt;:\n这段代码与 ms t or e . c 反汇编产生的 代码几乎完全一样。其 中一个主要的 区别是左边\n列出的地址不同一—-链接器将这段代码的地址移到了一段不同的地址范围中。第二个不同 之处在千链接器 填上了 c a l l q 指令调 用函数 mu l t 2 需要使用的地址(反汇编代码第 4 行)。链接器的任 务之一就是为函数调用 找到匹 配的函数的 可执行 代码的位置。最后一个区别是多了两行代码(第 8 和 9 行)。这两条指 令对程序没有影响 , 因为它们 出现在返回指令后面\n(第 7 行)。插入这些指 令是为了使函数代码变为 1 6 字节, 使得就存储器系统性能而言, 能更好地放置下一个代码块。\n2. 3 关千格式的注解\nGCC 产生的汇编代码 对我们来说有点 儿难读。一 方面, 它包含一些我们不需要关心的信息,另一方面,它不提供任何程序的描述或它是如何工作的描述。例如,假设我们用 如下命令 生成文件 ms t or e . s 。\nlinux\u0026gt; gee -Og -S mstore.e\nmstore. s 的完整内 容如下:\n.file\n.text\n.globl\n.t ype multstore:\npushq movq call movq popq ret\n\u0026ldquo;010-mstore.c\u0026rdquo;\nmultstore\nmultstore, @function\n%rbx\n%rdx, %rbx mult2\n%rax, (%rbx)\n%rbx\n.size multstore, .-multstore\n.ident \u0026ldquo;GCC: (Ubuntu 4.8.1-2ubuntu1-12.04) 4.8.1\u0026rdquo;\n.section .not e . GNU-stack, 1111 ,@progbits\n.所有以`.开,头的行都是 指导汇编器和链 接器工作的伪指令。我们通常可以忽略这些行。另 一方面, 也没有关于指令的用途以及它们 与源代码之间 关系的解释说明。\n为了更清楚地说明汇编代码,我们用这样一种格式来表示汇编代码,它省略了大部分\n伪指令 , 但包括行 号和解释性说明。对于我们的示例 , 带解释的汇编代码 如下:\nvoid multstore(long x, long y, l ong • des t )\nx in %rdi , y in multstore:\nr%s i , dest in %rdx\n通常我们只会给出与讨论内容相关的代码行。每一行的左边都有编号供引用,右边是 注释 , 简单地 描述指令 的效果以及它与原始 C 语言代码中的 计算操作的关 系。这 是一种汇编语言程序员写代码的风格。\n我们还提供网络旁注,为专门的机器语言爱好者提供一些资料。一个网络旁注描述的 是 IA32 机器代码 。有了 x8 6-64 的背景 , 学习 IA 32 会相当简 单。另外一个网络旁 注简要\n描述了在C 语言中插入汇编代码的方法。对千一些应用程序, 程序员必须用汇编代码来访问机器的低级特性。一种方法是用汇编代码编写整个函数, 在链接阶段把它们和 C 函数组合起来。另一种方法是利用 GCC 的支持,直 接在 C 程序中嵌入汇编代码。\n日 日 ATT 与 Inte l 汇编代码格式\n我们的 表述是 AT T ( 根据 \u0026quot; AT \u0026amp; T \u0026quot; 命名的, AT \u0026amp; T 是运营贝 尔 实验 室 多 年的公司)格式的 汇编代码, 这 是 GCC 、 OBJDUMP 和其他一 些我们使 用的 工具的默认格式。其他一些编程工具, 包括 Micro s of t 的 工具, 以 及 未 自 Int el 的 文档, 其 汇 编代码都是\nIntel 格式的。这两种 格 式在许多 方 面 有所不 同。 例如 ,使 用 下述命令行, GCC 可以 产\n生 mul t s t or e 函数的 Intel 格 式的代码:\nlinux\u0026gt; gee -Og -S -masm=intel mstore.e\n这个命令得到下列汇编代码:\nmultstore: push rbx\nmov rbx, rdx\ncall mult2\nmov QWORD PTR [rbx], rax pop rbx\nret\n我们看到 Intel 和 AT T 格式在如下方 面有 所不同 :\nIntel 代码省略了指示大小的后 缀。我们看到指令 pus h 和 mov , 而不是 pus hq 和 movq 。\nI ntel 代码省略 了寄存器名 宇前面的 飞 '符号, 用的 是 r bx , 而不是 %r bx 。 Intel 代码用 不 同的 方式来描 述内存中的位置 , 例如是 \u0026rsquo; QWORD PTR r[\n, ( %r bx ) \u0026rsquo; 。\nbx ) \u0026rsquo; 而 不是\n在带有多 个操 作数的指令情况下, 列 出操 作数的顺序相反。当 在 两种格式之间进行转换的时候 , 这一点非 常令人 困 惑。\n虽 然在我们的表 述中不使 用 In tel 格 式, 但 是 在 来 自 Int el 和 Microso f t 的 文档 中, 你会遇到 它。\n日 百 五 一 把 C 程序和汇编代码结合起来\n虽 然 C 编译 器在 把程序中表 达的 计算转换到机 器代 码方 面表 现出 色,但 是 仍 然有一些机 器特 性是 C 程序访问不 到 的。例 如 , 每 次 x86- 64 处理 器执 行 算术或逻辑运 算 时, 如 果得 到 的 运算 结果的低 8 位 中有偶数 个 1, 那 么 就会把 一 个名为 P F 的 1 位 条件码(condition code) 标志设 置 为 1, 否则 就设置 为 0。 这里的 PF 表 示 \u0026quot; par it y flag ( 奇偶标志)”。 在 C 语言中计算这个信 息需要至 少 7 次移位、掩码和异或运算(参见习题 2. 65) 。即使 作 为 每 次算术或逻辑运算的 一部分,硬 件都完成 了这项计算, 而 C 程序却无法知道 PF 条件码标志的值。在程序中插入几条汇编代码指令就能很容易地 完成 这项任务。\n在 C 程序中插 入汇编代码有两种方法。 笫一 种是, 我们可以 编写 完整 的函数,放 进一个独立的 汇编代码文件 中, 让汇编 器和 链 接 器把 它 和 用 C 语 言 书 写的代码合并起 来。笫 二 种 方法是 , 我们 可以 使 用 GCC 的内联 汇编(i nline assem bly) 特性, 用 as m 伪指令可 以在 C 程序中 包含 简短的汇编 代码。这种方 法的 好处是减 少 了与 机器相关的 代码量。\n当然, 在 C 程序 中 包含 汇 编代码使得这些代 码与 某 类特 殊的机器相 关(例如 x86-\n64), 所以只应该在想要的特性只能以此种方式才能访问到时才使用它。\n3. 3 数据格式\n由千是从 16 位体系结构扩展成 32 位的 , I ntel 用术语 ”字( word )\u0026quot; 表示 16 位数据类型。因此, 称 32 位数为“ 双字 ( double words)\u0026quot;, 称 64 位数 为“ 四 字 ( quad words ) \u0026quot; 。图 3-1 给出了 C 语言基本数据类型对应的 x86-64 表示。标准 i n t 值存储为双字( 32 位)。指针(在此用 c har * 表示)存储为 8 字节的四字, 64 位机器本来就预期如此。x86-64 中, 数据类型 l ong 实现为 64 位,允 许表示的 值范围较大。本章代码示 例中的大部分都使 用了指针和 l ong 数据类型, 所以都是四字操作。x86-64 指令集同 样包括完 整的 针对字节、字和双字的指令。\nC 声明 Intel 数据类型 汇编代码后缀 大小(字节) char 字节 b 1 short 字 w 2 int 双字 1 4 long 四字 q 8 char* 四字 q 8 float 单精度 s 4 double 双精度 1 8 图 3-1 C 语言数据类型在 x86-64 中的大小。在 64 位机器中 , 指针长 8 字节\n浮点 数主要有 两种形式 : 单精度 ( 4 字节)值, 对应于 C 语言数据类型 fl oa t ; 双精度\n(8 字节)值, 对应千 C 语言数据类型 d oub l e 。x86 家族的微处理器历史上实现过对一种特殊的 80 位(1 0 字节)浮点格式进行全套的浮点 运算(参见家庭作业 2. 86) 。可以 在 C 程序中用声明 l ong do ub l e 来指定这种格 式。不过我们不建议使用 这种格式 。它不能移植到其他类型的机器上,而且实现的硬件也不如单精度和双精度算术运算的高效。\n如图所示, 大多数 GCC 生成的 汇编代码指令都有一个字符的后缀, 表明 操作数的大小。例如,数据传送指令有四个变种: mov b ( 传送字节)、mov w ( 传送字)、mov l ( 传送双字)和movq ( 传送四字)。后缀'口用来表示双字, 因为 32 位数被看成是“长字 ( l o ng w or d) \u0026quot; 。注意, 汇编代码也 使用后缀\u0026rsquo; l \u0026rsquo; 来表示 4 字节整数 和 8 字节双精度浮点 数。这不会产生歧义,因为浮点数使用的是一组完全不同的指令和寄存辞。\n3. 4 访问信息\n一个 x86-64 的中央处理单元( CPU ) 包含一组 16 个存储 64 位值的通 用 目 的寄存 器。这些寄存器用来存储 整数数 据和指针。图 3-2 显示了这 16 个寄存器。它们的名字都以%r 开头,不过后面还跟着一些不同的命名规则的名字,这是由于指令集历史演化造成的。最 初的 8086 中有 8 个 16 位的寄存器, 即图 3-2 中的%a x 到%bp 。 每个寄存器都有特殊的用\n途, 它们的名字就反映 了这些不同的用途。扩展到 IA32 架构时, 这些寄存器也扩展成 32 /\n位寄存器, 标号从%e a x 到%e bp 。 扩展到 x86-64 后,原 来的 8 个寄存器扩展成 64 位, 标\n号从 r% a x 到%r bp 。除此之外 , 还增加了 8 个新的寄存器, 它们的 标号是按照新的命名规\n则制定的 :从 %r 8 到%r 1 5。\n31\n%eax\n%ebx [%bx\n7 0\n雪返回值\n二 l 被调用者保存\n毛r d i\n%r bp\n%ecx\n%edx\n毛e s i\nI%bp\n二 | 第4 个参数\n二二]第3个参数三 l 第2个参数工 二 l 第1个参数\n三|被调用者保存\n% r s p %esp\n%r 8 %r8d\n%r9d [%r9w\n%r10d [%rl0w\n%r ll [ %r l l d [ %rllw\n%r l 2 %rl2d [ %rl2w\n%r l 3 %r 1 3d [%rl3w\n%r l 4d [%rl4w\n%rl5d [%r15w\n三 l 栈指针\n二 l 第5个参数\n三 | 第 6 个 参 数\n二 l 调用者保存\n匡 l 调用者保存\n三 l 被调用者保存\n二|被调用者保存严 l 被调用者保存三 l 被调用者保存\n图 3- 2 整数 寄存器。所有 16 个寄存器的低位部分都可以作为字节、字(1 6 位)、双字( 32 位)和四字( 64 位)数字来访问\n如图 3-2 中嵌 套的方框标 明的, 指令可以 对这 16 个寄存器的低位字节中存放的不同大小的数据进行操作。字节级操作可以访问最低的字节 , 1 6 位操作可以访问最低的 2 个字节, 32 位操作 可以访问最低的 4 个字节 , 而 64 位操作 可以访问整个寄存 器。\n在后面的章 节中, 我们会展现很 多指令, 复制和生成 1 字节、2 字节、4 字节和 8 字节值。当这些指令以寄存器作为目标 时, 对于生成小 于 8 字节结果的指令, 寄存器中剩下的字节会怎 么样, 对此有两条规则: 生成 1 字节和 2 字节数字的 指令会保持剩下的字节不变; 生成 4 字节数字的指令会把高位 4 个字节置为 0 。后面这条规则是作为从 IA32 到x86-64 的扩展的一部分 而采用的 。\n就像图 3-2 右边的解释说 明的那样, 在常见的程序里不同的寄存器扮演不同的角色。其中最特别的是栈指针%r s p , 用来指明运行时栈的结束位置。有些程序会明确地读写这个寄存器。另外 15 个寄存器的用法更灵活。少量指令会使用某些特定的寄存器。更重要 的\n是,有一组标准的编程规范控制着如何使用寄存器来管理栈、传递函数参数、从函数的返回值, 以及存储 局部和临时数据。我们会 在描述过程的实现时(特别是在 3. 7 节中), 讲述这些惯例。\n3. 4. 1 操作数指示符\n大多数指令有 一个或多个操作数( o p e ra n d ) , 指示出执行一个操作中要使用的源数据值,以及放 置结果的 目的位置。 x86-64 支持多 种操作数格式(参见图 3-3 ) 。源数据值可以以常数形式 给出 , 或是从寄存器或内存中读出。结果 可以 存放在寄存器或内存中。因此, 各种不同的 操作数的 可能 性被分为三种类型。第一种类型是立 即数( im m e d ia t e ) , 用来表示常数 值。在 A T T 格式的汇编代 码中, 立即数的书写 方式是`$'后面跟一 个用标准 C 表示法 表示的整数 , 比如, $ - 5 77 或$0x1 F。不同的指令允许的立即数值范围不同, 汇编器会自动选 择最紧凑的 方式进行 数值编码。第二种类型是寄存 器 ( r eg is t e r ) , 它表示某个寄存器的 内容, 1 6 个寄存器的低位 1 字节、2 字节、4 字节或 8 字节中的一个作为操作数,这些字节 数分别 对应于 8 位、16 位、32 位或 64 位。在图 3-3 中, 我们用符号r a 来表示任意寄存器 a , 用引用 R[r a] 来表示它的值, 这是 将寄存 器集合看成一个数组 R , 用寄存器标识 符作为索引。\n第三类操作数是内存引用,它会根据计算出来的地址(通常称为有效地址)访问某个内 存位置。 因为将内存 看成一个很大的字节数组, 我们用符号 凶[ Ad d r ] 表示对存储在内 存中从地址 Ad d r 开始的 b 个字节值 的引用。为了 简便, 我们 通常省去下标 b。\n如图 3-3 所示 , 有多种不同的寻址模 式,允 许不同形式的内存引用。表中底部用语法\nImm(rb, r;, s) 表示的 是最常用的形式。这样的引用有四个组成部分: 一个立即数偏移\nImm, 一个基址寄存器r b\u0026rsquo; 一个变址寄存器r ,和一个比例因子 s , 这里 s 必须是1、2、4 或者\n8。基址和变址寄存器都必须是 64 位寄存器。有效地址被计算为 I m m + R[r b] + R[r ;] • s。引用数组元素时,会用到这种通用形式。其他形式都是这种通用形式的特殊情况,只是省略 了某些部分 。正如我们 将看到的 , 当引用数组 和结构元素时 , 比较复杂的寻址模式是很有用的。\n类型 格式 操作数值 名称 立即数 $Imm Imm 立即数寻址 寄存器 ra R[r. ] 寄存器寻址 存储器 Imm M[Imm] 绝对寻址 存储器 (r.) M[R[r。]] 间接寻址 存储器 lmm(r.) M[Imm+R[r.]] (基址+偏移量)寻址 存储器 (rb, r;) M[R[r.J+R[r,]] 变址寻址 存储器 Jmm(r., r,) M[Jmm+R[r.]+R[r,]] 变址寻址 存储器 (,r,, s) M[R[r;) · s] 比例变址寻址 存储器 /mm(,r,,s) M[/mm+R[r,J · s] 比例变址寻址 存储器 (rb, r;,s) M[R[rb ]+R[r;] · s] 比例变址寻址 存储器 Imm(r b, r,, s) M[/mm+R[r.]+R[r,]·s] 比例变址寻址 图 3-3 操作数格式 。操作数 可以 表示立即数(常数)值、寄存器 值或是 来自内存的值 。比例因子 s 必须 是 1、2、4 或者 8\n霆 练习题 3. 1 假设下面的值存放在指明的内存地址和寄存器中:\n寄存器 值\n%r a x\n%r c x\nOxlOO Oxl\n填写下表,给出所示操作数的值:\n%r dx Ox3\n操作数 值 令r a x Ox104 $0xl08 ( %r a x ) 4(%rax) 9(%rax, 毛r dx ) 260 (%rcx, 号r dx ) OxFC (, %r c x , 4) ( % r a x, %r d x, 4) 3. 4. 2 数据传送指令\n最频繁使用的指令是将数据从一个位置复制到另一个位置的指令。操作数表示的通用性使得一条简单的数据传送指令能够完成在许多机器中要好几条不同指令才能完成的功 能。我们会介绍多种不同的数据传送指令,它们或者源和目的类型不同,或者执行的转换不同,或者具有的一些副作用不同。在我们的讲述中,把许多不同的指令划分成指令类, 每一类中的指令执行相同的操作,只不过操作数大小不同。\n图 3-4 列出的是最简单形式的数据传 送指令- MOV 类。这些指令把数据从 源位 置复制到目 的位 置, 不做任 何变 化。MOV 类 由 四条 指令 组 成: mov b 、 mov w、 mov l 和mov q 。 这些指令都 执行同样的操作; 主要区别在于它们操作的数据大小不同: 分别是 l 、\n2、 4 和 8 字节。\n指令 效果 描述 MOV S, D D+-S 传送 movb R 壬 I 传送字节 rnovw 传送字 movl 传送双字 movq movabsq I, R 传送四字 传送绝对的四字 图 3- 4 简单的数据传送指令\n源操作数指定的值是一个立即数,存储在寄存器中或者内存中。目的操作数指定一个位置, 要么是一个寄存器或者 , 要么是一个内存地址。x86-64 加了一 条限制, 传送指令的两个操作数不能都指向内存位置。将一个值从一个内存位置复制到另一个内存位置需要两条指令—— 第一条指令 将源值加载到寄存 器中, 第二条将该寄存 器值写 人目的位置。参考图 3-2 , 这些指令的寄存 器操作数 可以是 16 个寄存器有标号部分中的任意一个 , 寄存器部\n分的大小必须与指令最后一个字符( \u0026rsquo; b\u0026rsquo; , \u0026rsquo; w\u0026rsquo; , \u0026rsquo; l \u0026rsquo; 或 \u0026rsquo; q \u0026rsquo; ) 指定的大小 匹配。大多数情况中, MOV 指令只会更新目的操作数指定的那些寄存器字节或内存位置。 唯一的例外是mov l 指令以 寄存器作 为目的 时, 它会把该寄存器的高 位 4 字节设置为 0 。造成这个 例外的原因是 x 8 6- 6 4 采用的 惯例, 即任何为寄存 器生成 3 2 位值的指令都会把该寄存器的高位部\n分置成 0。\n下面的 MOV 指令示例给出了源和目的类型的 五种可能的组合。记住 , 第一个是源操作数,第二个是目的操作数:\nmovl $0x4050,%eax movw %bp,%sp\nmovb (%rdi,%rcx),%al movb $-17, (%rsp) movq %rax,-12(%rbp)\nImmedi a t e - - Regi s t er , 4 bytes Register\u0026ndash;Register, 2 bytes Memor y 一 Regi s t er , 1 byte\nImmediate\u0026ndash;Memory, 1 byte\nRegister\u0026ndash;Memory, 8 bytes\n图 3- 4 中记录的 最后一条指令是处 理 6 4 位立即数数据的。常规的 mo v q 指令只能以表示为 3 2 位补码数字的 立即数作为源操作数 , 然后把这个值 符号扩展得 到 6 4 位的值, 放到目的位置 。mo v a b s q 指令能够以任意 6 4 位立即数值作 为源操 作数, 并且只能以寄存器作为目的。\n图 3- 5 和图 3- 6 记录的是 两类数 据移动指令, 在将 较小的源值 复制到较 大的目的时使\n用。所有这些指 令都把数据从源(在寄存器或内 存中)复制到目的寄存器。MOVZ 类中的指令把目 的中剩余的字节填充 为 o , 而 MOVS 类中的指令通过符号 扩展来填 充, 把源操作\n的最高位进行复制。可以观察到,每条指令名字的最后两个字符都是大小指示符:第一个 字符指定源的大小,而第二个指明目的的大小。正如看到的那样,这两个类中每个都有三 条指令 , 包括了所有的 源大小 为 1 个和 2 个字节、目的 大小为 2 个和 4 个的 情况 , 当然只考虑目的大千源的情况。\n指令 效果 描述 MOVZ S, R R- 零扩展 ( S ) 以零扩展进行 传送 mov zb w mo v zbl movzwl movzbq movzwq 将做了笭扩展的 字节传 送到字将做了零扩展的 字节传送到 双字将做了零扩展的 字传送 到双字将做了笭 扩展的字节传送 到四字 将做了零扩展的 字传送 到四字 图 3-5 零扩展数据传送指令。这些指令以寄存器或内存地址作为源,以寄存器作为目的\n指令 效果 描述 MOVS S, R R- 符号扩展 (S ) 传送符号扩展的字节 movsbw movsbl movswl movsbq movswq movslq cltq 告r a x - 符号扩展(告ea x) 将做了符号扩展的字节传送到字 将做了符号扩展的字节传送到双字将做了符号扩展的字传送到 双字将做了符号扩展的字节传送到四字将做了符 号扩展的 字传送到四字 将做了符号扩展的双字传送到四字 把%ea x 符号扩展到 r% a x 图 3-6 符号扩展数据传送指令。MO VS 指令以 寄存器或内 存地址作 为源 ,以 寄存器作为目的 。c l t q 指令只作用于寄存器 %e a x 和 %r a x\n因日 理解数据传送如何 改变目的寄存器\n正如我们描述的那样,关于数据传送指令是否以及如何修改目的寄存器的高位字节有两种不同的方法。下面这段代码序列会说明其差别:\nmovabsq $0x0011223344556677, %rax ¾rax = 0011223344556677\nmovb $-1, %al ¾rax = 00112233445566FF mo四 $- 1 , %ax ¾rax = 001122334455FFFF movl $-1, %eax ¾rax = OOOOOOOOFFFFFFFF movq $-1, %rax ¾rax = FFFFFFFFFFFFFFFF 在接下来的讨论中 , 我们使用十六进制表示 。在这个例子中 ,笫 1 行的指令把寄存器%\nr a x 初始化 为位 模式 0011 223344556677 。剩下的指令的 海操作数值是立即数值 一1 。回想一\n1 的十六进制表 示形如 FF… F, 这里 F 的数量是 表述中 宇 节数量的 两倍。因此 movb 指令\n(第 2 行)把%r a x 的低位宇节设 置为 F F, 而 mo vw 指令(第3 行)把低2 位字节 设置为 FFFF, 剩下的 宇节保持 不 变。 mov l 指令(第 4 行)将低4 个宇 节设置为 FFFFFFFF, 同 时把 高位 4 宇节设 置为 00000000 。最后 movq 指令(第5 行)把整个寄存器设置为 FFFFFFFFFFFFFFFF。\n注意图 3-5 中并没有一 条明确的指令把 4 字节源值 零扩展 到 8 字节目的。这样的 指令逻辑上应该被命名为 mo v z l q , 但是并没有这样的指令。不过,这样的数据传送可以用以寄存器为目的的 mov l 指令来实现。这一技 术利用的属性是, 生成 4 字节 值并以寄存器作为目的 的指令会把高 4 字节置为 0。对于 64 位的目标, 所有三种源类 型都有对应的符号 扩展传送,而只有两种较小的源类型有零扩展传送。\n图 3-6 还给出 c l 七q 指令。这条指令 没有操作数: 它总是以寄存器%e a x 作为源,%r a x 作为符号扩展结果的目的。它的效果与指令 mov s l q %eax, %r a x 完全一致 , 不过编码更紧凑。\n; 练习题 3. 2 对于下面汇编代码的每一行,根据操作数,确定适当的指令后缀。(例 如, mov 可以 被 重写成 mo v b 、 mo v w、 mo v l 或者 mo v q 。)\nmov_ mov— mov\n%eax, (%rsp) (%rax), %dx\n$0xFF, %bl\nmov_\nCir儿\ns p , %r dx , 4) , %dl\nmov_ mov\n(%rdx), %rax\n%dx, (%rax)\n日 字节传送指令比较\n下面这个示例说明了不同的数据传送指令如何 改变或者不改 变目的的 高位宇节。仔细观\n察可以发现, 三个字节传送指令 movb 、 movs bq 和 mov zbq 之间有细微的差别。 示例如下 :\nmovabsq $0x0011223344556677, %rax movb $0xAA, %dl\nmovb %dl,%al movsbq %dl,%rax movzbq %dl,%rax\n¼rax = 0011223344556677 7.dl = AA\n¼rax = 00112233445566AA\n¼rax = FFFFFFFFFFFFFFAA 7.rax = OOOOOOOOOOOOOOAA\n在下 面的 讨论中,所有的值都使 用十六进制 表示。代码的 头 2 行将寄存 器%r a x 和%dl分别初始化 为 0011223344556677 和 AA。 剩下的 指令都是将%r dx 的低位宇 节复 制到 %r a x的低位 宇节。 movb 指令(笫3 行)不改 变其 他宇节。根据源宇节的 最高位, mov s bq 指令(第4 行)将其他 7 个宇节设为全 1 或全 0。由于十六进制 A 表示二进制值 1 01 0, 符号扩展会把高位宇节都设 置为 F F。mov zbq 指令(笫 5 行)总是将其他7 个字节全都设 置为 0 。\n讫 练习题 3. 3 当我们调用汇编器的时候,下面代码的每一行都会产生一个错误消息。 解释每一行都是哪里出了错。\nmovb $0xF, (%ebx) movl %rax, (%rsp) movw (%rax),4(%rsp) movb %al,%s1\nmovq %rax,$0x123 movl %eax,%rdx movb %si, 8(%rbp)\n4. 3 数据传送示例\n作为一个使用数 据传送 指令的 代码示例 , 考虑图 3-7 中所示的数据交换函数, 既有 C\n代码 , 也有 GCC 产生的汇编代码 。\nlong exchange(long *xp, long y)\n{\nlong x = *xp;\n*xp = y; return x;\n}\nC语言代码 long exchange(long•xp, long y)\nxp 江 肚 d工, y 卫1 %rsi exchange:\nmovq (%rdi), %rax\nmovq %rsi, (%rdi) ret\nGet x at xp. Set as return val ue . Store y at xp\nRet ru n .\nb ) 汇编代码\n图 3-7 exch ange 函数的 C 语言和汇 编代码。寄存器 r% 中 和r%\ns i 分别 存放参数 xp 和 y\n如图 3-7 b 所示, 函数 e x c h a ng e 由三条指令实现: 两个数据传送 C mov q ) , 加上一条返回函数 被调用点 的指令 Cr e t ) 。 我们 会在 3. 7 节中讲述函数调用和返回的细节。在此之前,知道参数通过寄存器传递给函数就足够了。我们对汇编代码添加注释来加以说明。函数通过把值存储 在寄存器 %r a x 或该寄存器的某个低 位部分 中返回。\n当过程开始 执行时 , 过程参数 xp 和 y 分别存储在寄存器%r d i 和%r s i 中。 然后, 指 令 2 从内存中读出 x , 把它存放 到寄存 器%r a x 中, 直接实现了 C 程序中的 操作 x=*xp。稍后, 用寄存器%r a x 从这个函数返回一个值, 因而返回值就是 x。指令 3 将 y 写入到寄存器%r d i 中的 x p 指向的内存位置, 直接实 现了操作*x p =y。这个例子说明了如何用 MOV 指令从内 存中读值到寄存 器(第2 行), 如何从 寄存器写到内存(第 3 行)。\n关于这段汇编代码有 两点值得注意。首先, 我们看到 C 语言中所谓的 ”指针” 其 实就是地址。间接引用指针就是将该指针放在一个寄存器中,然后在内存引用中使用这个寄存 器。其次 , 像 x 这样的局 部变量通常 是保存在寄存器中 , 而不是内 存中。访问 寄存器比访问内存要快得多。\n芦 练习题 3 . 4 假设 变量 s p 和 d p 被声明 为 类型\nsrc_t *sp; dest_t *dp;\n这里 sr c _ t 和 d e s t _ 七 是用 t y p e d e f 声明 的数 据类型。 我们 想使 用 适 当的数据传 送指令来实现 下 面的操作\n*dp = (dest_t) *sp;\n假设 s p 和 d p 的值分别存储在寄 存器 %r d i 和%r s i 中。 对千表 中的 每个表 项,给出实现指定 数据传 送的 两条指令。其中第 一条指 令应该从内存 中读 数, 做适 当的 转换,并设置寄存器 %r a x 的适 当部 分。 然后, 第二条 指令 要把 %r a x 的 适 当部 分写到内存。 在这两种情况中 , 寄存器的部分可以是 %r a x 、%e a x 、%a x 或 %a l , 两者可以互不相同。\n记住 , 当 执行 强制类型 转换 既 涉及 大小 变化又 涉及 C 语言中符号 变化 时 , 操作 应该先 改 变大 小( 2. 2. 6 节)。\nsrc t de s 七 七 指令 long long mo vq ( 号r di ) , 号r a x movq %r a x, 伐r s i ) char int char unsigned unsigned char long int char unsigned unsigned char char short 一 指针的一些示例\n函数 e x c h a n g e ( 图 3- 7a ) 提供了一 个关 于 C 语言中指针使 用的 很好说明。参数 x p 是一 个指向 l o n g 类型的 整数的指针, 而 y 是一个 l o n g 类型的 整数。语句\nlong x = *xp;\n表示我 们将 读存储在 x p 所指位 置中的 值, 并将它存 放到名 字 为 x 的局部 变量 中。 这个读操 作称 为指 针的间接 引 用 ( po in t er dereferencing), C 操作符* 执行指针的间接 引 用。\n语句\n*XP = y;\n正好相反 它将 参数 y 的值 写到 x p 所指的 位置。这也是 指针 间接 引用的 一种形式(所以有操作符*),但是它表明的是一个写操作,因为它在赋值语句的左边。\n下 面是调用 e x c h a ng e 的一个实际例 子:\nlong a= 4;\nlong b = exchange(\u0026amp;a, 3);\nprintf(\u0026ldquo;a = %ld, b = %ld\\n\u0026rdquo;, a, b);\n这段代码会打印出:\na= 3, b = 4\nC 操作符 &(称为“取 址” 操作符)创建一个指针 , 在本例 中, 该指针 指向保存局 部 变量 a 的位置。 然后 , 函数 e x c ha nge 将用 3 覆盖存储在 a 中的 值, 但是返回原来的 值 4 作为函数的值。 注意如何将指针传递给 e xc ha ng e , 它能修改存在某个远处位置的数据。\n区 练习题 3. 5 已知信息如下。将一个原型为\nvoid decodel(long•xp, long *YP, long•zp);\n的函数编译成汇编代码,得到如下代码:\nvo1d decode1 (long *xp, long *YP, long *zp) xp in¼rdi , yp in¼rsi , zp in¼rdx\ndecode!:\nmovq (%rdi), %r8\nmovq (%rsi), %rcx\nmovq (%rdx), %rax\nmovq %r8, (%rsi) movq %rcx, (%rdx) movq %rax, (%rdi) ret\n参数 x p 、 y p 和 z p 分别存 储在对 应的寄 存器 %r d i 、%r s i 和%r d x 中。\n请写 出 等效于 上 面 汇编 代码 的 d e c o d e l 的 C 代码。\n3. 4. 4 压入和弹出栈数据\n最后两个数据传送操作可以 将数据压入程序栈 中,以 及从程序栈 中弹出数据, 如图 3-8 所示。正如我们将看到的,栈在处理过程调用中起到至关重要的作用。栈是一种数据结 构, 可以添加或者 删除值, 不过要遵循 ”后进先 出” 的原则。通过 p us h 操作把数据压入栈中 , 通过 po p 操作删除数据; 它具有一个属性: 弹出的 值永远是最 近被压 入而且仍然在栈中的值。栈可以实现为一个数组,总是从数组的一端插入和删除元素。这一端被称为栈顶 。在 x86-64 中, 程序栈存 放在内 存中某个区域。如图 3-9 所示, 栈向下增长, 这样一来,栈顶元素的地址是所有栈中元素地址中最低的。(根据惯例,我们的栈是倒过来画的,栈 "顶” 在图的底部。)栈指针%r s p 保存着栈顶元 素的地址。\n图 3-8 入栈和出栈指令\np u s hq 指令的功能是 把数据压入到栈上 , 而 p o p q 指令是弹出 数据。这些指令都只有一个操作数 一一 压入的数 据源和弹出的 数据目的 。\n将一个四字值 压入栈中, 首先要将栈指针减 8 , 然后将值写到新的栈顶地址。因此, 指令 p u s h q %r b p 的行为等价 于下面两条指 令:\nsubq $8,%rsp movq %rbp, (%rsp)\nDecrement stack pointer Store 7.rbp on stack\n它们之间的区别是在机器代码中 p us hq 指令编码为 1 个字节 , 而上面那两条指令一共需 要\n8 个字节。图 3-9 中前两栏给出的是, 当%r s p 为 Ox1 08 , %r a x 为 Ox1 23 时, 执行指令pushq %r a x 的效果。首先%r s p 会减 8 , 得到 Ox l OO, 然后会将 Ox1 23 存放到内存地址Ox l OO 处 。\n最初\n%rax\npushq %rax\npopq %rdx\n%rdx\n¾rsp\n栈"底” 栈"底” 栈"底”\n地址增大\nOxl08 Oxl08 Ox108\n栈"顶” Ox!OO\nOxl23\n栈“顶”\nOxl23\n栈“顶”\n图 3-9 栈操作说明 。根据惯例, 我们的栈是倒过来画的, 因而栈 "顶” 在底部。x86-64 中, 栈向低地址方向增长, 所以压栈是减小栈指针(寄存器%r s p) 的值, 并将数据存放到内存中,而出栈是从内存中读数据,并增加栈指针的值\n弹出一个四字的操作包括从栈顶位 置读出数 据, 然后将栈指针加 8。因此, 指令 p op q\n%r a x 等价千下面两条指令 :\nmovq (%rsp),%rax addq $8,%rsp\nRead 7.rax from stack Increment stack pointer\n图 3-9 的第三栏说明 的是在执行 完 p us hq 后立即执行 指令 po pq %r d x 的效果。先从内存中读出值 Ox1 23 , 再写到寄存器%r d x 中, 然后, 寄存器%r s p 的值将增加回到 Ox10 8 。如图中所示, 值 Ox1 23 仍然会保持 在内存位置 Ox l OO 中, 直到被覆盖(例如被另一条入栈操作覆盖)。无论如何,% rs p 指向的 地址总是栈顶 。\n因为栈和程序代码以及其他形式的程序数据都是放在同一内存中,所以程序可以用标 准的内存寻址方法访问栈内的任意位置。例如, 假设栈顶元素是四字, 指令 mov q 8 (% rsp), %r d x 会将第二个四字从栈中复制到寄存 器%r d x 。\n3. 5 算术和逻辑操作\n图 3-10 列出了 x86-64 的一些整数 和逻辑操作。大多数操作都分成了指令类, 这些指令类有各种带不同大小操作数的变种(只有 l e a q 没有其他大小的变种)。例如, 指令类\nADD 由四条加法指令组成: a d db 、 a d d w、a d d l 和 a d d q , 分别是字节加法、字加法、双字加法和四字加法。事实上,给出的每个指令类都有对这四种不同大小数据的指令。这些\n操作被分为四组:加载有效地址、一元操作、二元操作和移位。二元操作有两个操作数, 而一元操 作有一个操作数 。这些 操作数的描 述方法与 3. 4 节中所讲的一样。\n指令 效果 描述 leaq S,D D 七 - \u0026amp;S 加载有效地址 INC DEC NEG NOT D D D D D 七 - D+l D 仁 D - 1 D ..\u0026ndash;D D\u0026ndash;D 加 1 减 l 取负 取补 ADD SUB IMUL XOR OR AND S,D S,D S,D S,D S,D S,D D 七 D + S D 七 D - S D 七 - D * S v-v-s D 仁 D I S D\u0026lt;-D\u0026amp;S 加 减 乘 异或或 与 SAL SHL SAR SHR k,D k,D k, D k,D D-D«k D 七 - D«k D 七 D »A k D-D»ik 左移 左移(等同于SAL ) 算术右移 逻辑右移 图 3-10 整数算术操作。加 载有效地址 ( l eaq) 指令通常用来执行简单的算术操作。其余的指令 是更加标准的一元或二元操作。我们用\u0026gt; \u0026gt; A 和 \u0026gt; \u0026gt; L 来分别 表示算术右移 和逻辑 右 移。注意 ,这 里 的 操 作 顺 序 与 AT T 格式的汇编代码中的相反\n3. 5. 1 加载有效地址\n加栽有效地 址O oa d effective address ) 指令 l e a q 实际上是 mo v q 指令的变形。它的指令形式是从内存读数据到寄存器,但实际上它根本就没有引用内存。它的第一个操作数看上去是一个内存引用,但该指令并不是从指定的位置读入数据,而是将有效地址写人到目的操作数。在 图 3-10 中我们用 C 语言的地址操作符 \u0026amp;S 说明这种计算 。这条指令可以 为后面的内存引用产生指针。另外,它还可以简洁地描述普通的算术操作。例如,如果寄存器%r d x 的值为 x , 那么指令 l e a q 7 ( %r d x , %rdx, 4), %r a x 将设 置寄 存器%r a x 的值为 5x +\n7。编译器经常 发现 l e a q 的一些灵活用法 , 根本就与有效地址计算无关 。目的操作数必须是一个寄存器。\n为了说明 l e a q 在编译出的 代码中的使用 , 看看下 面这个 C 程序 :\nlong scale(long x, long y, long z) { long t = x + 4 * y + 12 * z; return t;\n编译时 , 该函数的算术 运算以 三条 l e a q 指令实现, 就像右边 注释说明 的那样:\nlong scale(long x, long y, long z)\nX 立 加 di , y in¼rsi , z in¼rdx scale:\nleaq (o/.rdi,o/.rsi,4), o/.rax X + 4*y leaq (o/.rdx,o/.rdx,2), o/.rdx Z + 2*z = 3*Z leaq (o/.rax,o/.rdx,4), o/.rax (x +4*y) + 4* (3*z) = x + 4*y + 12*z ret l e a q 指令能执行加法和有限形式的乘法, 在编译如上简单的算术表达式时 , 是很有用处的。\n芦 练习题 3. 6 假设寄 存器 %r a x 的 值 为 x , %r c x 的 值 为 y 。 填 写 下表, 指明 下 面每条 汇编代 码指令 存储在寄 存器 %r d x 中的值 :\n表达式 结果 leaq 6 ( %ax ) , r% dx leaq (r% ax, r% cx ) , r% dx leaq (r% a x, r沦 cx, 4) , r毛 dx leaq 7 (%r a x, % r ax, 8) , % r dx leaq OxA(,%rcx,4), r令 dx leaq 9 ( 毛r ax, r% cx , 2), r毛 dx 沁 义 练习题 3. 7 考虑下面的代码,我们省略了被计算的表达式:\nlong scale2(long x, long y, long z) { long t = return t;\n}\n用 GCC 编译 实际的 函 数得到 如下的 汇编代码 :\nlong scale2(long x, long y, long z)\nx in r7. di , y in 7.rsi , z in scale2:\nr7. dx\nleaq leaq leaq ret\n(%rdi,%rdi,4), %rax (%rax,%rsi,2), %rax (%rax,%rdx,8), %rax\n填写 出 C 代码 中缺 失的 表达 式。\n3. 5. 2 一元和二元操作\n第二组中的操作是一元操作,只有一个操作数,既是源又是目的。这个操作数可以是一个寄存 器, 也可以是一个内 存位置。比如说, 指令 i n c q ( %r s p ) 会使栈顶的 8 字节元 素加 1。这种语法让人想起 C 语言中的 加 1 运算符 \u0026lt;+ + ) 和减 1 运算符(一—)。\n第三组是 二元操作, 其中, 第二个操作数既是源又是目的 。这种语法让人想起 C 语言中的赋值运算符, 例如 x - =y 。不过, 要注意 , 源操 作数是第一个,目 的操作数是第二个, 对千不可交换操作来说 , 这看上去 很奇特。例 如, 指令 s u b q %r a x , %r d x 使寄存器%r d x 的值减去 %r a x 中的值。(将指令解读成“从%r d x 中 减去%r a x \u0026quot; 会有所帮助。)第一个操作数可以是立即数、寄存器或是内存位置。第二个操作数可以是寄存器或是内存位置。注意, 当第二个操作数为内存地址时,处理器必须从内存读出值,执行操作,再把结果写回 内存。\n; 练习题 3. 8 假设下面的值存放在指定的内存地址和寄存器中:\n寄存器 值\nr号 a x OxlOO\n毛r c x Oxl\n告rd x Ox3\n填写下表,给出下面指令的效果,说明将被更新的寄存器或内存位置,以及得到\n的值:\n指令 目的 值 addq %rcx, 伐rax ) subq %rdx, 8 (r% ax ) 耳 nul q $16, 伐r ax, 毛r dx, 8) incq 16 (r% ax ) decq r% cx subq 号r dx, r% ax 3. 5. 3 移位操作\n最后一组是 移位操作 ,先 给出移位量, 然后第二项给出的 是要移位的 数。可以 进行 算术和逻辑右移。移位量可以 是一个立即数, 或者放在单字节寄存器% c l 中。(这些指令很特别 , 因为只允 许以这个特定的寄存器作 为操作 数。)原则上来说, 1 个字节的移位量使得\n移位量的 编码范围 可以达到 28 — 1 = 255 。x86- 64 中, 移位操作对 w 位长的 数据值进行 操\n作, 移位量是由 %c l 寄存器的低 m 位决定的, 这里 2\u0026rsquo;\u0026quot;= w。高位会被忽略。所以, 例如当寄存器 %c l 的十六进制值 为 Ox FF 时, 指令 s a l b 会移 7 位, s a l w 会移 15 位, s a l l 会移\n31 位, 而 s a l q 会移 63 位。\n如图 3-10 所示 ,左 移指令有两个 名字: S AL 和 S HL。两者的效果是一样的, 都是将右边填上 0。右移指 令不同 , S AR 执行算术移位(填上符号位), 而 SHR 执行逻辑 移位(填上0) 。移位操作的目 的操作数可以 是一个寄存器或是一个内存位置。图 3-10 中用\u0026gt;\u0026gt;A (算\n术)和>>凶逻辑)来表示这两种不同的右移运算。\n练习题 3. 9 假设 我们 想生成以 下 C 函 数的 汇编代 码 :\nlong shift_left4_rightn(long x, long n)\nX \u0026lt;\u0026lt;= 4;\nX \u0026gt;\u0026gt;= n;\nreturn x;\n}\n下 面这 段 汇编代 码执 行 实 际 的 移位 , 并 将最 后 的 结果放在寄 存器%r a x 中。 此处\n省略 了两 条关键 的指令。 参数 x 和 n 分别 存放在寄 存器 %r d i 和%r s i 中。\nlong shi f t _l ef t 4工 i ght n (l ong x, long n)\nX l D r加 di , n in Y.rsi shift_left4_rightn:\nmovq %rdi, %rax Get x\nX \u0026lt;\u0026lt;= 4\nmovl %esi, %ecx Get n (4 byt es )\nX \u0026gt;\u0026gt;= n\n根据右边的注释,填出缺失的指令。请使用算术右移操作。\n3. 5. 4 讨 论\n我们看到图 3-10 所示的大多数指令 , 既可以 用千无 符号 运算 , 也可以 用千补码 运算。\n只有右移操作要求区分有符号和无符号数。这个特性使得补码运算成为实现有符号整数运算的一种比较好的方法的原因之一。\n图 3-11 给出 了一个执行算术操作的函数示例,以 及 它 的 汇编代码。参数 x 、y 和 z 初始时分别存放在内存%r d i 、%r s i 和 %r d x 中 。 汇编代码指令和 C 源代码行对应很紧密。第 2行计算 x \u0026quot; y 的值。指令 3 和 4 用 l e a q 和移位指令的组合来实现表达式 z * 48 。第 5 行 计算 七1 和 Ox OF OF OF OF 的 AND 值 。第 6 行 计算 最 后 的 减法。由于减法的目的寄存器是%\nrax, 函数会返回这个值。\nlong arith(long x, long y, long z)\n{\nlong ti= x y; long t2 = z * 48;\nlong t3 = ti \u0026amp; OxOFOFOFOF; long t4 = t2 - t3;\nreturn t4;\nC语言代码 3 leaq (%rdx,%rdx,2), %rax 3*Z 4 salq $4, %rax t2 = 16 * (3*z) = 48*Z 5 andl $252645135, %edi t3 = t1 \u0026amp; OxOFOFOFOF 6 subq %rdi, %rax Return t2 - t3 7 ret b ) 汇编代码\n图 3-11 算术运算 函数的 C 语言和汇编代 码\n在图 3-11 的汇编代码中,寄 存 器 %r a x 中 的 值 先 后 对 应于程序值 3 * z 、 z * 48 和 t 4( 作为返回值)。通常,编译器产生的代码中,会用一个寄存器存放多个程序值,还会在寄存 器之间传送程序值。\n沁氐 练习题 3. 10 下 面的 函 数是 图 3- ll a 中 函 数 一个 变种 , 其 中有些表达式用 空 格替 代 :\nlong arith2(long x, long y, long z)\n{\nlong t1 = long t2 = _ long t3 = long t4 = return t4;\n}\n实现这些表达式的汇编代码如下:\nlong arith2(long x, long y, long z) x i n ¼r d工, y 工n r¼s 工, z 工丑 肚 dx\narith2:\norq %rsi, %rdi\nsarq $3, %rdi\nnotq %rdi\nmovq ir儿\ndx , %rax\nsubq %rdi, %rax ret\n基于这 些 汇编代码 , 填写 C 语言代码 中缺 失的部分。\n练习题 3. 11 常常可以看见以下形式的汇编代码行:\nxorq %rdx,%rdx\n但是在产 生这 段 汇编代 码的 C 代码 中,并 没 有出现 E XC L U S I V E-O R 操作。\n解释这条 特殊的 E XC L U S I V E- O R 指令 的效果 , 它实现 了什 么有用 的操作。 更直接地表达这个操作的汇编代码是什么? 比较同样一个操作的两种不同实现的编码字节长度。 3. 5. 5 特殊的算术操作\n正如我们在 2. 3 节中看到的 , 两个 6 4 位有符号 或无符号 整数相乘得到的 乘积需 要 1 28\n位来表示 。x8 6-64 指令集对 1 28 位(1 6 字节)数的操作提供 有限的支持。延续字 ( 2 字节)、双字( 4 字节)和四字( 8 字节)的命名惯例 , Intel 把 16 字节的 数称为八 宇 ( oct word ) 。图 3-1 2 描述的是 支持产 生两个 64 位数字的 全 1 28 位乘积以 及整数除 法的指令。\n指令 效果 描述 irnulq s mulq s R[ %r dx] : R[ % r ax] - S XR [ r% ax] R[ %r dx] , R[ % r ax] +-S X R[ r% ax] 有符号全乘法无符号全乘法 clto R[ r% dx] : R[r% ax] - 符号扩 展\u0026lt;R[ r% ax] ) 转换为八字 idivq s R[ 毛r dx] - R[ 毛r dx] : R[ r沧 ax] mod S R[ r% dx]- R[ 毛r dx] : R[ r% ax] -c- S 有符号除法 divq s R[ %rdx]-R[r% dx] : R[r% ax] mod S R[ %r dx] - R[ %r dx] , R[%rax]7S 无符号除法 图 3-12 特殊的算术操作 。这些操作提供了有符号 和无符号数的全 128 位乘法和除法。\n一对寄存器 r%\ndx 和 r%\na x 组成一个 128 位的八 字\ni mu l q 指令有两种不同 的形式。其中一种, 如图 3-1 0 所示, 是 IM U L 指令类中的一种。这种形式的 i mu l q 指令是一个“双操作 数" 乘法指 令。它从两个 64 位操作数产生一个 64 位乘积 , 实现了 2. 3. 4 和 2. 3. 5 节中描述的操作 * 4 和 * 4 。(回想一下, 当将乘积 截取到 64 位时, 无符号乘 和补码 乘的位级行为是一样的 。)\n此外 , x8 6- 64 指令集还提供 了两条不 同的“单操作数” 乘法指令,以 计算两个 64 位 值的全 1 28 位乘积 一个是无符号数乘法( mu l q ) , 而另一个是补码乘法( i mu l q ) 。这 两条指令都要求一个参数必须 在寄存器%r a x 中, 而另一个作为指令的源操作数给出。然后乘积存放在寄存 器%r d x ( 高 64 位)和%r a x ( 低 64 位)中。虽然 i mu l q 这个名字可以 用 于两个不同的乘法操作,但是汇编器能够通过计算操作数的数目,分辨出想用哪条指令。\n下面这段 C 代码是一 个示例, 说明了如何从 两个无符号 64 位数字 x 和 y 生成 1 28 位的乘积:\n#include \u0026lt;inttypes.h\u0026gt;\ntypedef unsigned int128 uint128_t;\nvoid store_uprod(uint128_t *dest, uint64_t x, uint64_t y) {\n*dest = x * (uint128_t) y;\n}\n在这个程序中 , 我们显式 地把 x 和 y 声明为 64 位的数字, 使用文件 i n t t yp e s . h 中声明的定义, 这是对标 准 C 扩展的一部分。不幸的是, 这个标 准没有提供 128 位的 值。所以我们只好 依赖 GCC 提供的 1 28 位整数支持 , 用名字_ _ i n tl 28 来声明。代码用 t yp e de f 声明定义了一 个数据类型 u i n t l 28 _ t , 沿用 的 i n t t yp e s . h 中其他数据类型的 命名规律。这段代码指明 得到的 乘积应该 存放在指 针 d e s t 指向的 16 字节处 。\nGCC 生成的 汇编代码 如下 :\nvoid store_uprod(uint128_t *des t , uint64_t x, uint64_t y) des t 工 n r% di , x 耳 1 %rsi , y in %rdx\nstore_uprod:\nmovq 7.rsi, %rax Copy x to multiplicand mulq movq 7.rdx 7.rax, (%rdi) Mult i p l y by y Store lower 8 bytes at dest movq 7.rdx, 8(7.rdi) Store upper 8 bytes at dest+8 ret 可以 观察到, 存储乘积需要两个 mo v q 指令: 一个存储低 8 个字节(第4 行), 一个存储高 8 个字节(第5 行)。由于生成这段 代码针对的 是小端法机器, 所以高位字节存储 在大地址 , 正如地址 8 ( %r d i ) 表明的那样。\n前面的算术 运算 表(图3-10 ) 没有列 出除法或取模 操作。这些操作是由单操作数除法指令来提供的 , 类似于单操作数乘法指令 。有符号除法指令 J.中 v l 将寄存器%r d x ( 高 64 位)和%r a x ( 低 64 位)中的128 位数作 为被 除数, 而除 数作为指 令的操作数给出。 指令将商存储在寄存器 %r a x 中, 将余数存储 在寄存 器%r d x 中。\n对千大多数 64 位除法应用来说 , 除数也常常是一 个 64 位的值。这个值应该存放在%\nr a x 中 ,%r d x 的 位应该设 置为 全 0 ( 无符号运算)或者%r a x 的符号位(有符号运算)。后面这个操作可以用指令 c q 七0 8 来完成。这条指令不需 要操作数一一 它隐含读出 %r a x 的符号位 , 并 将它复 制到 %r d x 的所有位 。\n我们 用下面这个 C 函数来 说明 x86-6 4 如何实现除 法, 它计算了两个 64 位有符号数的商和余数: -;-\nvoid remdiv(long x, long y,\nlong *qp, long *rp) { long q = x/y;\nlongr = x%y;\n*qp = q;\n*rp = r;\n}\n该函数编译得到如下汇编代码:\ne 在 Intel 的 文档中 , 这条指 令叫做 cqo, 这是指 令的 ATT 格式 名字和 Intel 名字无 关的少数情况之一.\nvoi d remdiv(long x, long y, long•qp, long•rp) x in 7.rdi , y in 7.rsi , qp in 7.rdx, rp in 7.rcx remdiv:\nmovq movq cqto idivq movq movq ret\n%rdx, %r8\n%rdi, %rax\n%rsi\n%rax, (%r8)\n%rdx, (%rcx)\nCopy qp\nMove x to lower 8 bytes of dividend\nSign-extend to upper 8 bytes of dividend Di vi de by y\nStore quotient at qp Store remainder at rp\n在上述代码中 ,必须 首先把参数 qp 保存到另一个寄存器中(第2 行), 因为除 法操作要使用参数寄存器 %r d x 。 接下来 , 第 3~ 4 行准备被除 数, 复制并 符号扩展 x 。除法之后,寄存器 %r a x 中的商被保存 在 qp ( 第 6 行), 而寄存 器%r d x 中的余数被保存 在r p ( 第 7 行)。\n无符号除 法使用 d i v q 指令。通常 , 寄存器%r d x 会事先设 置为 0。\n练习题 3. 12 考虑如下 函数, 它计 算 两个 无符 号 64 位数的 商和 余数 :\nvoid urerndiv(unsigned long x, unsigned long y, unsigned long *qp, unsigned long *rp) {\nunsigned long q = x/y;\nunsigned longr = x%y;\n*qp = q;\n*rp = r;\n3. 6\n}\n修 改有符号除 法的 汇编代 码来 实现这个 函数。\n控制 # 到目前为止 , 我们只 考虑了 直线代 码的行为, 也就是指令一条接着一条顺序地执行。\nC 语言中的某些结构 , 比如条件语句、循 环语句和分支语句, 要求有条件的执行 , 根据数据测试的结果来 决定操作执行的顺序。机器代码提供两种基本的 低级机制来实现有条件的行为: 测试数 据值, 然后根据测试的结果来改 变控制流或者数 据流。\n与数据相关的控制流是实现有条件行为的更一般和更常见的方法,所以我们先来介绍 它。通常 , C 语言中的语句和机器代码中的指令 都是按照它们在程序中 出现的次序, 顺序执行的。 用 jum p 指令可以改 变一组 机器代码指 令的执行顺序, jum p 指令指定控制应该被传递到程序的某个 其他部分, 可能是 依赖于某个测试的结果 。编译器必须产生构 建在这种低级机制基础之上的指令 序列, 来实 现 C 语言的控制结构。\n本文会先涉及实 现条件操作的 两种方式 , 然后描 述表达循 环和 s wi t c h 语句的方法。\n6. 1 条件码\n除了整数寄存 器, CPU 还维护着一组单个位的条件码( co nd it io n cod e ) 寄存器, 它们描述了最 近的算术 或逻辑操作的 属性。可以检测这些寄存器来 执行条件分支指令。最常用的条件码有:\nCF: 进位标志 。最近的操作使最高位产生了进位 。可用来 检查无符号操作的溢出 。\nZF: 零标志。最近的操作得出的结果为 0 。\nSF: 符号标志。最近的操作得到的结果为负数。\nOF : 溢出标志。最近的操作导致一个补码溢出 正溢出或负溢 出。\n比如说, 假设我们 用一条 ADD 指令完成 等价 千 C 表达式 t =a + b 的功能 , 这里变簸\na 、b 和 t 都是整型的 。然后 , 根据下 面的 C 表达式来设 置条 件码:\nCF (unsigned) t \u0026lt; (unsigned) a\nZF ( 七 = 0 )\nSF ( 七\u0026lt;0 )\n无符号溢出零\n负数\nOF (a\u0026lt;O==b\u0026lt;O) \u0026amp;\u0026amp; ( 七\u0026lt;0 ! =a\u0026lt;O) 有符号溢出\nl e aq 指令不改变任何 条件码, 因为它是 用来进行 地址 计算的。除此之外, 图 3-10 中列出的所有指令 都会设置条 件码。对千逻 辑操作, 例如 XOR , 进位标志和溢出标志会设置成 0。对千移 位操作 , 进位标 志将设置为最后一个被移出的位, 而溢出标志设置为 0。I NC 和 DEC 指令会设置溢出 和零标志, 但是不会改变进位标志, 至千原 因, 我们就不在这里深入探讨了。\n除了 图 3-10 中的指令会设置 条 件\n码, 还有两类指令(有8、16 、32 和 64 位形式),它们只设置条件码而不改变任何其他寄存器; 如图 3-13 所示。CMP 指令根据两个操作数之差 来设置 条件码。除了只设置条件码而不更新目的寄存器 之外, CMP 指令与 SUB 指令的行 为是一样的。在 AT T 格式中, 列出操作 数的顺序是相反的,这使代码有点难读。如 果两个 操作数相等, 这些指令会将零标志设置为 1 , 而其他的标志可以用来确定两个操作数之间的大小 关系。T EST 指\n令的行为与 AND 指令一样 ,除 了 它们只设置条件码而不改变目的寄存器的值。\n图 3-13 比较和测试指令。这些指令不修改任何\n寄存器的值,只设置条件码\n典型的用法是, 两个操作数是一样的(例如, t e s t q % r a x, %r a x 用来检查%r a x 是负数、零,还是正数),或其中的一个操作数是一个掩码,用来指示哪些位应该被测试。\n6. 2 访问条件码\n条件码通常不会直接读取,常用的使用方法有三种: 1 ) 可以 根据条件码的某种组合, 将一个字节设 置为 0 或者 1 , 2) 可以 条件跳 转到程序的 某个其他的部分, 3 ) 可以 有条 件地传送数据。对于第一种情 况,图 3-1 4 中描述的指令根据条件码的某种组合, 将一个字节设置为 0 或者 1。我们将 这一整类指令 称为 SET 指令; 它们之间的区别就在于它们考虑的条件码的组合是什么 , 这些指令 名字的不同后缀指明了它们 所考虑的条件码的组合。这些指令的后缀表示不同的条件而不是操 作数大小 ,了 解这一点很重要。例如, 指令 s e t l 和s e t b 表示“小于时设 置( set less ) \u0026quot; 和“低 千时设置( set below)\u0026quot;, 而不是“设置长字 ( set long word ) \u0026quot; 和“设置字节 ( set byte) \u0026quot; 。\n一条 SET 指令的目的操作数是 低位单字节寄 存器元素(图3-2 ) 之一, 或是一个字节的内存位置, 指令会将这个 字节设 置成 0 或者 1。为了得到一个 32 位或 64 位结果, 我们必须对高位 清零。一个计算 C 语言表达式 a \u0026lt; b 的典型指令序列如下 所示 , 这里 a 和 b 都是l o ng 类型 :\n指令 同义名 效果 设置条件 sete setne D D setz setnz D D 七七 ZF - ZF 相等/零不等/非零 sets setns D D D D 七七 SF - SF 负 数 非负数 setg setge setl setle D D D D setnle setnl setnge setng D D D D 七七七 仁 ~(SF - OF) \u0026amp; -ZF - (SF - OF) SF - OF (SF - OF) I ZF 大千(有符号>) 大于等于(有符号>=) 小于(有符号<) 小千等于(有符号<=) seta setae setb setbe D D D D setnbe setnb setnae setna D D D D 七七七 七 - CF \u0026amp;-ZF ~CF CF CF I ZF 超过(无符号>) 超过或相等(无符号>=) 低于(无符号<) 低于或相等(无符号<=) 图 3- 1 4 SET 指 令 。 每条指令 根据条件码的某种组合, 将 一 个 字 节 设 置 为 0 或 者 1 。有些指令有“同义名“,也就是同一条机器指令有别的名字\nnt comp(data_t a, data_t b) a in 7.rdi , b in 7.rsi\ncomp:\ncmpq setl movzbl ret\n%rsi, %rdi\n%al\n%al, %eax\nCompare a: b\nSet low-order byte of 7.eax to O or 1 Clear rest of 7.eax (and rest of 7.rax)\n注意 c mpq 指令的比较顺 序(第2 行)。虽然参 数列出的顺 序先是%r s i ( b ) 再是%r d i ( a ) , 实际上比较 的是 a 和 b 。还要记得 , 正如在 3. 4. 2 节中讨 论过的那样 , mo v z b l 指令不仅会把%e a x 的高 3 个字节清零 , 还会把 整个寄 存器%r a x 的高 4 个字节都 清零。\n某些底层 的机器指令可能有多个名字, 我们称之为“同 义名 ( s y no n ym ) \u0026quot; 。比如说, s e t g ( 表示“设置大千\u0026quot;)和 s e t n l e ( 表示“设 置不 小千等千\u0026quot;)指 的就是同一条机器指令。编译器和反汇编器会随意决定使用哪个名字。\n虽然所有的算术和逻辑操作都会设 置条件码, 但是各个 SET 命令的描述都适用的情况 是: 执行比较指令, 根据计算 t =a - b 设置条件码。更具体地说, 假设 a 、b 和 t 分别是 变量 a 、 b 和 t 的补码形式表示的整数, 因此 t = a - 口,b, 这里 w 取决 千 a 和 b 的大小。\n来看 s e t e 的 情况 , 即“当相等时设置( set when equal) \u0026quot; 指令。当 a = b 时, 会得到t = O,\n因此零标志置位就表示相等。类 似地 , 考虑用 s e t l , 即“当小千时设 詈( set when less) \u0026quot; 指令, 测试一个有符号比较。当没有发生溢出时( OF 设置为0 就表明无溢出), 我们有当 a —:Vb \u0026lt; O时 a \u0026lt; b, 将 SF 设置为 1 即指明这一点, 而当 a —:Vb O 时 a 多b, 由 SF 设置为 0 指明。另一 方面, 当发生溢出时 , 我们有当 a — .b\u0026gt; O( 负溢出)时a \u0026lt; b , 而当 a —汹\u0026lt; O( 正溢出)时a \u0026gt; b。\n当 a = b 时, 不会有溢出。因此 , 当 OF 被设置为 1 时, 当且仅当 SF 被设置为 o, 有 a \u0026lt; b。将\n这些情况组合起来 , 溢出和符号位的 EXCLUSIVE-OR 提供了 a \u0026lt; b 是否为真的测试。其他的有符号比较测试基千 SF A OF 和 ZF 的其他组合。\n对于无符号 比较的测试 , 现在设 a 和b 是变量 a 和 b 的无符号形式表示的 整数。在执行计算 t =a - b 中, 当 a - b\u0026lt; O 时, CMP 指令会设置进位标 志, 因而尤 符号比较使用的是\n进位标志和零标志的组合。\n注意到机器代码 如何区分有符号和无符号值是很重要的 。同 C 语言不同, 机器代码不会将每个程序值都和一个数据类型联系起来。相反,大多数情况下,机器代码对千有符号和无符号两种情况都使用一样的指令,这是因为许多算术运算对无符号和补码算术都有一样的位级行为。有些情况需要用不同的指令来处理有符号和无符号操作,例如,使用不同版本的右移、除法和乘法指令,以及不同的条件码组合。\n芦 练习题 3. 13 考虑下 列的 C 语言代 码 :\nint comp(data_t a, data_t b) { return a COMP b;\n它给 出 了 参 数 a 和 b 之 间 比 较 的 一 般 形 式 , 这 里 , 参 数 的 数 据 类 型 d a t a _ t ( 通 过t yp e d e f ) 被声明 为表 3-1 中列 出的 某种整数 类型 , 可以是 有符 号的也 可以是 无符号的 c omp 通过 # d e f i ne 来定 义。\n假设 a 在 %r 土 中某个部 分, b 在 %r s i 中 某个 部 分。 对于下 面每 个 指令 序 列, 确定哪 种数 据类型 d a t a _ t 和比 较 COMP 会导致编译 器 产 生这 样的代码。(可能 有 多个 正确答案,请列出所有的正确答案。)\ncmpl %esi, %edi setl %al cmpw %si, %di setge %al cmpb %sil, %dil setbe %al cmpq %rsi, %rdi setne %a\n比氐 练习题 3. 14 考虑下 面的 C 语言代 码 :\nint test(data_t a) { return a TEST O;\n}\n它给出了参数 a 和 0 之间比较的 一般形 式,这里,我们 可以 用 t yp e de f 来声明 da t a _t , 从而设置参数的数据类型,用# de f i ne 来声明 TEST, 从而设置比较的类型。对于下面每个指令 序列, 确定 哪种 数据 类 型 d a t a _ t 和比 较 TEST 会导 致 编译器 产 生 这样的代码。(可能有多个正确答案,请列出所有的正确答案。)\ntestq %rdi, %rdi\nsetge %al\ntestw %di, %di sete %al\ntestb %dil, %dil seta %al\ntestl %edi, %edi setne %al\n3. 6. 3 跳转指令\n正常执行的情况 下, 指令按照它们 出现的顺 序一条一条地执行 。跳转( j um p ) 指令会导致执行切换到程序中一个全新的位置。在汇编代码中,这些跳转的目的地通常用一个标号\n(labe l) 指明。考 虑下面的汇编代码 序列(完全是人为编造的):\nmovq $0,%rax jmp .L1\nmovq (%rax),%rdx\n.L1:\npopq %rdx\nSet 7.rax to 0 Goto .L1\nNull pointer dereference (s 虹 pped)\nJump target\n指令 j mp . Ll 会导致程序跳过 mo v q 指令, 而从 p o p q 指令开始继续 执行。在产生目标代码文件时,汇编器会确定所有带标号指令的地址,并将跳转目标(目的指令的地址)编码 为跳转指令的一部分。\n图 3-15 列举了不同的 跳转指令。j mp 指令是无条件跳转 。它可以是直接跳转 , 即跳转目标是作为指令的一部分编码的;也可以是间接跳转,即跳转目标是从寄存器或内存位置 中读出的。汇编语言中,直接跳转是给出一个标号作为跳转目标的,例如上面所示代码中 的标号\u0026quot;.Ll \u0026quot; 。间接跳转的写 法是 \u0026lsquo;* \u0026lsquo;后面跟一个 操作数指 示符 , 使用图 3-3 中描述的内存操作数格式中的一种。举个例子,指令\njmp *%rax\n用寄存器 %r a x 中的值作为跳转目标 , 而指令\njmp *(%rax)\n以%r a x 中的值作为读地址, 从内存中读出跳转目标 。\n指令 同义名 跳转条件 描述 jmp jmp Label *Operand 1 1 直接跳转间接跳转 je jne Label Label jz jnz ZF -ZF 相等/零 不相等/非零 js jns Label Label SF -SF 负 数 非负数 jg jge jl jle Label Label Label Label jnle jnl jnge jng -(SF - OF) \u0026amp;: -ZF -(SF- OF) SF- OF (SF - OF) I ZF 大千(有符号>) 大于或等于(有符号>=) 小于(有符号<) 小于或等于(有符号<=) ja jae jb jbe Label Label Label Label jnbe jnb jnae jna -CF \u0026amp;-ZF -CF CF CF I ZF 超过(无符号>) 超过或相等(无符号 >=) 低于(无符号<) 低于或相等(无符号<=) 图 3-15 ju mp 指令。当跳转条 件满足时 ,这 些 指 令 会 跳 转 到 一 条 带 标 号 的 目 的 地 。有些指令有“同义名“,也就是同一条机器指令的别名\n表中所示的其他跳转指令都是有条件的-它们根据条件码的某种组合,或者跳转, 或者继续 执行代码序列 中下一条指令。这些指令的名字和跳 转条件与 SET 指令的名字和 设置条件是 相匹配的(参见图3-14) 。同 SET 指令一样 , 一些底层的 机器指令有多个名字。条件跳转只能是直接跳转。\n6. 4 跳转指令的编码\n虽然我们不关 心机器代码格式的细节 , 但是理 解跳转指令的目标如何编码, 这对第 7\n章研究链接非常重要。此外,它也能帮助理解反汇编器的输出。在汇编代码中,跳转目标 用符号标号书写。汇编器,以及后来的链接器,会产生跳转目标的适当编码。跳转指令有 几种不同的编码 , 但是最 常用都是 P C 相对的 ( P C- relat ive ) 。也 就是 , 它们会将目标指令 的地址与紧跟在跳转指令后面那条指令的地址之间的差作为编码。这些地址偏移量可以编 码为 1 、2 或 4 个字节。第二 种编码方 法是给出“绝对“地址,用 4 个字节直接指定 目标。汇编器和链接器会选择适当的跳转目的编码。\n下面是一 个 P C 相对寻址的 例子, 这个函数的汇编代码由 编译文件 bra nch. c 产生。它包含两个 跳转: 第 2 行的 j mp 指令前向 跳转 到更高的地址, 而第 7 行的 j g 指令后向跳转到较低的地址。\nmovq jmp\n.13:\nsarq\n.12:\n%rdi, %rax\n.L2\n%rax\ntestq %rax, %rax jg .13\nrep; ret\n汇编器产生的 \u0026quot; . o\u0026quot; 格式的 反汇编版本 如下 :\n0: 48 89 f8 mov %rdi,%rax 3: eb 03 jmp 8 \u0026lt;loop+Ox8\u0026gt; 5: 48 d1 f8 sar %rax 8: 48 85 co test %rax, %rax b: 7f f8 jg 5 \u0026lt;loop+Ox5\u0026gt; d: f3 c3 repz retq 右边反汇编器产生的 注释中 , 第 2 行中跳转指令的跳转目标指明为 Ox B, 第 5 行中跳转指令的跳转目标是 Ox S( 反汇编器以 十六 进制格式给出 所有的数字)。不过, 观察指令的字节编码 , 会看到第一 条跳转 指令的目标 编码(在第二个字节中)为Ox 03 。把它加上 Ox S, 也就是下一条指令的 地址 , 就得到跳转目 标地址 Ox 8 , 也就是第 4 行指令的地址。\n类似, 第二个跳转指令的目标用单字节 、补码表示 编码为 Ox f B( 十进制 -8 ) 。将这个数加上 Oxd ( 十进制 13 ) , 即第 6 行指令的地址 , 我们得到 Ox S, 即第 3 行指令的地址。\n这些例子说明 , 当执行 P C 相对寻址时 , 程序计 数器的值是跳转指令后面的 那条指令的地址,而不是跳转指令本身的地址。这种惯例可以追溯到早期的实现,当时的处理器会将更新程序计数器作为执行一条指令的第一步。\n下面是链接后的程序反汇编版本:\n4004d0: 48 89 f8 mov %rdi,%rax 4004d3: eb 03 jmp 4004d8 \u0026lt;loop+Ox8\u0026gt; 4004d5: 48 d1 f8 sar %rax 4004d8: 48 85 co test %rax,%rax 4004db: 7f f8 jg 4004d5 \u0026lt;loop+Ox5\u0026gt; 4004dd: f3 c3 repz retq 这些指令被重定 位到不同的 地址, 但是 第 2 行和第 5 行中跳转 目标的编码并 没有变。通过使用 与 P C 相对的跳转目标 编码, 指令编码很简洁(只需要 2 个字节), 而且目标代码 可以不做改变就移到内存中不同的位置。\nm 指令r e p 和r e p z 有什么用\n本节开始的 汇编代码的 笫 8 行 包含指令组合r e p ; r e t 。它们在 反汇编 代码中(笫 6 行)对应于r e p zr e 七q 。 可以推 测 出r e p z 是r e p 的 同 义名, 而r e t q 是r e t 的同 义名。查阅 Intel 和 AMD 有关r e p 的 文档,我 们发现它通 常 用 来 实现重复的 字符 串操作[ 3 ,\n51] 。在这 里用它似乎很 不合 适。 这个问 题的答案 可以 在 AMD 给编译器编 写 者的 指导意见书 [ l ] 中找到 。他们建议用r e p 后 面跟r e t 的组合来避免 使r e t 指令成为条件跳 转指令的目标 。如果没有r e p 指令 , 当 分 支不跳 转时, j g 指令(汇编代码的 第 7 行)会继续到 r e t 指令。根据 AM D 的说法, 当r e t 指令通过跳 转指令到 达时 , 处理 器不能 正确预测 r e t 指令的 目的 。这里的r e p 指令就是作为 一种空操 作, 因此 作为 跳转目 的插入它, 除了能使代码在 AMD 上运行得 更快之 外, 不会 改 变代码的 其他 行为。 在本书后 面其他代 码中再遇到 r e p 或r e p z 时,我 们可以很 放心地无视 它们。\n区§练习题 3. 15 在下 面这 些反 汇编 二进 制 代 码 节选 中 , 有 些 信息 被 X 代替 了。 回答下列关于这些指令的问题。\n下 面 j e 指令的 目标是 什 么?(在此, 你不需 要知道任何 有关 c a l l q 指令的 信息。)\n4003fa: 74 02 je xxxxxx\n4003f c : ff dO callq *%rax\n下面尸:指令的目标是什么?\n40042f: 74 f4 je xxxxxx\n400431: 5d pop %rbp\nj a 和 p o p 指令的 地址是 多少?\nXXXXXX: 77 02 ja 400547\nXXXXXX: 5d pop %rbp\n在下 面的代 码 中,跳 转目标的编 码是 PC 相对的 , 且是 一个 4 字节补码数。 字节桉\n. 照从最低位到 最高位 的顺序列 出,反 映 出 x86-6 4 的 小端 法 字节 顺 序。 跳 转 目标 的地址是什么?\n4005e8: e9 73 ff ff ff 4005ed: 90\njmpq XXXXXXX nop\n跳转指令提供了一种实现条件执行(江)和儿种不同循环结构的方式。\n6. 5 用条件控制来实现条件分支\n将条件表达式 和语句从 C 语言翻译 成机器代码 , 最常用的方式是结 合有条件 和无条件跳转。(另一种方式在 3. 6. 6 节中会看到, 有些条件可以 用数据的条件转移实现, 而不是用控制的条 件转移来 实现。)例如, 图 3-1 6a 给出了一个计 算两数之差绝对值 的函数的 C 代码气 这个函数有一 个副作用 , 会增加两个计数 器, 编码为全局 变最 l t _ c n t 和 g e _ c n t 之一。G CC 产生的汇编代码 如图 3-1 6c 所示。把这个 机器代码再转换成 C 语言, 我们称之为函数 g o t o d i f f _s e ( 图 3-1 6b ) 。它 使用了 C 语言中的 go t o 语句, 这个语句类似于汇编代码中的无条件跳转 。使用 go t o 语句通常认 为是一种不好的编程风格, 因为它会使代码非\ne 实际上, 如果一个减法 溢出, 这个函数就会返回一 个负数值。 这里我们主要 是为了 展示机器代码, 而不 是实现代码的健壮性。\n常难以阅 读和调试。本文中使用 goto 语句, 是为了 构造描述汇编代码程序控制流的 C 程序。我们称这样的编程风格 为 \u0026quot; g o t o 代码”。\n在 g o t o 代码中(图3-166 ) , 第 5 行中的 go t o x_g e _y 语句会导致跳转到第 9 行中的标号 x_ge _ y 处(当x 娑y 时会进行跳转)。从这一点继续执行, 完成函数 a b s d i f f _ s e 的e l s e 部分并返回。另一方面, 如果测试 x \u0026gt;=y 失败, 程序会计算 a b s d i f f _ s e 的 江 部分指定的步骤并返回。\n汇编代码的 实现(图3- l 6c ) 首先比较了两个 操作数(第2 行), 设置条件码。如果 比较的结果表明 x 大千或者等 于 y , 那么它就会跳转到第 8 行, 增加全局变量 g e _ c n t , 计算 x\n- y 作为返回 值并返回。由此我们可以 看到 a b s d i f f _s e 对应汇编代码的 控制流非 常类似于\ng o t o d i f f —s e 的 g o t o 代码。\n}\nreturn result;\n}\na ) 原始的C语言代码 b ) 与之等价的got o版本\nlong absdiff_se(long x, long y)\nX 江 肚 di , y 立 肚s i\nabsdiff_se:\ncmpq %rsi, %rdi Compare x : y\njge .L2 If\u0026gt;= goto x_ge _y\n4 addq $1, lt_cnt(%rip) lt_cnt++\n5 movq 。r儿\ns i , %rax\n6 subq %rdi, %rax result = y - x\n7 ret Return\n8 .L2: x_ge_y :\n9 addq $1, ge_cnt Or 儿\nmovq %rdi, %rax\ni p) ge_cnt++\nsubq %rsi, %rax result= x - y\nret Return\nc ) 产生的汇编代码\n图 3-16 条 件 语 句 的 编 译 。 a)C 过程 abs di f f s e 包含一个 迂- e l se 语句 ; b)C 过程 got odif f _se\n模拟了汇编代码的控制; c ) 给出了产生的汇编代码\nC 语言中的 江- e l s e 语旬的通用形式模板如下:\nif (test-expr)\nthen-statement\nelse\nelse-statement\n这里 test-e工 p r 是一个整数表达式 ,它 的 取 值 为 0 ( 解释为“假\u0026quot; )或者为非 0 ( 解释为“真\u0026quot; )。两个分 支语句 中 ( then-sta tement 或 else-sta tement ) 只会执行一个。\n对于这种通用形式, 汇编实现通常会使用下面这种形式, 这里, 我们用 C 语法来描述控制流:\nt = test-expr; if (!t)\ngoto false; then-statement goto done;\nfalse:\nelse-statement done:\n也就是 , 汇编器为 the n-sta tement 和 else-sta tement 产生各自的代码块。它会插入条件和无条件分支,以保证能执行正确的代码块。\nm 用 C 代码描述机器代码\n图 3-1 6 给出 了 一个示例 , 用 来展 示把 C 语言控 制 结构翻译成机 器代码。图 中 包括示例 的 C 函数 a 和由 GCC 生成 的汇编代码的 注释 版本 c , 还有一个与汇编代码结构高度一致的 C 语言版本 b。机 器代 码的 C 语言表 示有 助 于你理解其中的 关键 点 , 能引导你理解实际的汇编代码。\n江 练习题 3 . 16 已 知下列 C 代 码 :\nvoid cond(long a, long *p)\n{\nif (p \u0026amp;\u0026amp; a\u0026gt; *p)\n*P = a;\n}\nGCC 会产 生下 面的 汇编 代码 : void cond(long a, long *p) a in %rdi, p in %rsi\ncond:\ntestq %rsi, %rsi\nje .Ll\ncmpq %rdi, (%rsi)\njge .Ll\nmovq %rdi, (%rsi)\n.Ll:\nrep; ret\n按照 图 3-1 66 中所 示的 风格, 用 C 语言 写 一个 go to 版本, 执行 同 样的 计 算 , 并模拟汇编代码的控制流。像示例中那样给汇编代码加上注解可能会有所帮助。 请说 明为什 么 C 语 言代码 中只有 一个 if 语 句 , 而 汇编 代码包 含 两个 条件分支。 让 练习题 3. 17 将 i f 语句 翻译成 go to 代码 的另 一种 可行 的 规则 如下:\nt = test-expr;\nif Ct)\ngoto true; else-statement goto done;\ntr ue :\nthen-statement done :\n基于这种规则 , 重 写 a b s d i f f _s e 的 go to 版本。 你能想出选用一种规则而不选用另一种规则的理由吗?\n已 练习题 3. 18 从如下形 式 的 C 语 言代码 开 始 :\nlong test(long x, long y, long z) { long val = ;\nif () {\nif () val=\nelse\nval=\n} else if ()\nval= return val;\n}\nGCC产 生 如 下的 汇编代码 :\nlong test (long x, long y, long z)\nx in %rdi, y i n r¼ si , z i n %rdx test:\nleaq (%rdi,%rsi), %rax addq %rdx, %rax\ncmpq $-3, o/.rdi\njge .L2\ncmpq %rdx, %rsi\njge .L3\nmovq %rdi, %rax imulq %rsi, %rax ret\n.L3:\nmovq %rsi, %rax imulq %rdx, %rax ret\n.L2:\ncmpq $2, %rdi\njle .14\nmovq %rdi, %rax imulq %rdx, %rax\n.14:\nrep; ret\n填写 C 代码 中缺 失的表 达 式 。\n6. 6 用条件传送来实现条件分支\n实现条件操 作的传统方法是通过使用 控制的条件转移 。当条件满足时, 程序沿 着一条执行路 径执行, 而当条 件不满足时 , 就走另 一条路径。这种 机制简单而通用 , 但是 在现代处理器上 , 它可能 会非常低效。\n一种替 代的 策略是使用 数据的条 件转移 。这种方法计算 一个条件操作的两种结果 , 然后再根据条件是否满足从中选取一个。只有在一些受限制的情况中,这种策略才可行,但 是如果可行,就可以用一条简单的条件传送指令来实现它,条件传送指令更符合现代处理 器的性能特性 。我们 将介绍 这一策略 , 以及它在 x8 6-64 上的实现。\n图 3- l 7a 给出了一 个可以用条件传送编译的 示例代码。这个函数计算参数 x 和 y 差的绝对值 , 和前面的例子一样(图3-1 6 ) 。不过前面的例子中, 分支里有副作用, 会修改 lt\ncnt 或 g e _ c n t 的值, 而这个 版本只是简单地计算 函数要返 回的值。\nGCC 为该 函数产生 的 汇 编代 码 如图 3- l 7c 所 示, 它与图 3-1 76 中所 示 的 C 函数cmovdif f 有相似的形式。研究 这个 C 版本 , 我们可以 看到它既计算了 y- x , 也计算了 x - y , 分别命名为 r va l 和 e va l 。然后 它再测试 x 是否大于等千 y , 如果 是, 就在函数返回r va l 前,将 e va l 复制到r v a l 中。图 3-l 7c 中的汇编代码有相同的逻辑 。关键就在千汇编代码的那条cmovge 指令(第7 行)实现了cmovd i ff 的条件赋值(第8 行)。只有当第6 行的 cmpq 指令表明一 个值大于等于另一 个值(正如后缀ge 表明的那样)时, 才会把数据源寄存器传送到目的 。\nlong absdiff(long x, long y)\n{\nlong result; if (x \u0026lt; y)\nresult= y - x;\nelse\nresult= x - y; return result;\n}\nlong cmovdiff(long x, long y)\n2 {\n3 long rval = y-x;\n4 long eval = x-y;\n5 long ntest = x \u0026gt;= y;\n6 I* Line below requires\n7 single instruction: *I\n8 if (ntest) rval = eval;\n9 return rval;\n10 }\na ) 原始的C语言代码 b ) 使用条件赋值的实现\n1 abs di ff : 2 movq %rsi, %rax 3 subq %rdi, 1r儿 ax rval = y-x 4 movq %rdi, %rdx 5 subq %rsi, ir儿 dx eval = x- y 6 cmpq %rsi, %rdi Compare xy. 7 crnovge %rdx, %rax If \u0026gt;=, rval = eval 8 ret Return tval C ) 产生的汇编代码\n图 3-17 使用 条件赋值的条件语句的 编译。a)C 函数 absd if f 包 含一个条件表达式 ;\nb)C 函数 cmo vdi f f 模 拟 汇编代码操作; c) 给出产生的 汇编代码\n为了理解为什么基于条件 数据传送的 代码会比基千条 件控制转移的代码(如图 3-16 中那样)性能要好 , 我们必须 了解一些关于现代处理器如何运行的知识。正如我们将在第 4 章 和第 5 章中看到的 , 处理器通过使用 流水线 ( pipelining ) 来获得高性能 , 在流水线中 , 一条指令的 处理要经过一 系列的阶段, 每个阶段执行所需操作的一小部分(例如, 从内存取指令、确定指令类型、从内存读数据、执行算术运算、向内存写数据,以及更新程序计数 器)。这种方法通过重叠连续指令的步骤来获得高性能,例如,在取一条指令的同时,执 行它前面一条指令的算术运算。要做到这一点,要求能够事先确定要执行的指令序列,这 样才能保持流水线中充满了待执行的指令。当机器遇到条件跳转(也称为“分支\u0026quot;)时,只 有当分支条件求值完成之后,才能决定分支往哪边走。处理器采用非常精密的分支预测逻 辑来猜测每条跳转指令是否会执行。只要它的猜测还比较可靠(现代微处理器设计试图达 到 90 % 以上的成功 率), 指令流水线中就会充满着指令。另一方面, 错误预测一个跳转, 要求处理器丢掉它为该跳转指令后所有指令己做的工作,然后再开始用从正确位置处起始 的指令去填充流水线。正如我们会看到的,这样一个错误预测会招致很严重的惩罚,浪费 大约 15 ~ 30 个时钟周期 , 导致程序性能 严重下降 。\n作为一 个示例 , 我们在 In tel H aswe ll 处理器上运行 a bs d if f 函数, 用两种方 法来实现条件操作。在一个典型的应用 中, x \u0026lt; y 的结果非常地不可预测 , 因此即使是最 精密 的分支预测硬件也 只能有大约 50 % 的概率 猜对。此外 , 两个代码 序列中的 计算执行都只需 要一个时钟周期。因此,分支预测错误处罚主导着这个函数的性能。对千包含条件跳转的 x86-64 代码, 我们 发现当分 支行为模式 很容易预测时 , 每次调用函数需要大约 8 个时钟周期; 而分支行为模式 是随机的时 候, 每次调用需 要大约 1 7. 50 个时钟周期。由此我们可以推断出分 支预测错误 的处罚是大约 19 个时钟周期。这就意味着函数需要的时间范围大约在 8 到 27 个周期 之间, 这依赖于分支预测是 否正确。\n田 如何 确定分支预测错误的 处罚\n假设预测错误 的概率是 p , 如果没有 预测错 误, 执行代码的 时间是 T oK , 而预测错误的处罚是 T MP 。 那 么, 作为 p 的一个函数 , 执行代码的平 均 时间 是 T ,v. C p ) = (l - p ) ToK + P (T oK + T MP) = T oK +PT MP 。 如果已知 T oK 和 T ,.\u0026quot;( 当 p = O. 5 时的 平 均 时间), 要确定 T MP 。 将参数代入等式, 我们有 T can = Tavg (0. 5) = ToK + 0. 5T MP , 所以 有 T MP = 2 (Tran - T OK) 。 因此 , 对于 T oK= 8 和 T can= l 7. 5, 我们有 T MP= l 9。\n另一方面,无论测试的数据是什么,编译出来使用条件传送的代码所需的时间都是大 约 8 个时钟周期 。控制流不 依赖于数据, 这使得处理器更容易 保持流水线是 满的 。\n; 练习题 3. 19 在 一个比较旧的处 理器模 型上运 行, 当 分 支行 为模 式非常 可预测 时,我们的代码需要大约 1 6 个时钟周期 , 而当模 式是随机 的时候 , 需要大约 31 个时钟周期。\n预测错误处罚大约是多少? 当分支预测错误时,这个函数需要多少个时钟周期?\n图 3-18 列举了 x86- 64 上一些 可用的 条件传送指令。每条指 令都有两个操作数: 源寄存器或者内存地址 S , 和目的 寄存器 R。与不同的 SET (3. 6. 2 节)和跳转指令( 3. 6. 3 节) 一样,这些指令的结果取决千条件码的值。源值可以从内存或者源寄存器中读取,但是只 有在指定 的条件满 足时 , 才会被复 制到目的 寄存 器中。\n源和目的的值可以是 16 位、32 位或 64 位长。不支持单字节的条件传送。无条件指令的操作数的长度显式地编码在指令名中(例如movw 和 mov U , 汇编器可以从目标寄存器的名字推断\n出条件传送指令的操作数长度,所以对所有的操作数长度,都可以使用同一个的指令名字。\n指令 同义名 传送条件 描述 cmove cmovne S,R S,R cmovz cmovnz ZF -ZF 相等/零 不相等/非零 cmovs cmovns S,R S,R SF -SF 负 数 非负数 cmovg cmovge cmovl cmovle S,R S,R S,R S,R cmovnle cmovnl cmovnge cmovng -(SF~ OF) \u0026amp; -ZF -(SF- OF) SF~ OF (SF~ OF) I ZF 大于(有符号>) 大于或等于(有符号>=) 小千(有符号<) 小于或等千(有符号<=) cmova cmovae cmovb cmovbe S,R S,R S , R S,R crnovnbe crnovnb cmovnae cmovna ~CF \u0026amp; ~ZF ~CF CF CF I ZF 超过(无符号>) 超过或相等( 无符号>=) 低于(无符号<) 低于或相等(无符号<=) 图 3-18 条件传送指令。当传送条件满足时 ,指 令 把 源值 S 复制到目的 R 。有些指令是“同义名",即同一条机器指令的不同名字\n同条件跳转不同,处理器无需预测测试的结果就可以执行条件传送。处理器只是读源值(可能是从内存 中), 检查条 件码, 然后要 么更新目的寄存器, 要么保持不变。我们会在第 4 章中探讨条件传送的 实现。\n为了理解如何通过条件数据传输来实现条件操作,考虑下面的条件表达式和赋值的通 用形式:\nv = test-expr ? then-expr : else-expr;\n用条件控制转移的标准方法来编译这个表达式会得到如下形式:\nif (! test-expr)\ngoto false; v = then-expr; goto done;\nfalse:\nv = else-expr; done:\n这段代码包含两个代码 序列 : 一个对 then-ex p r 求值, 另一个对 els e-ex p r 求值。条件跳转和无条件跳转结合起来使用是为了保证只有一个序列执行。\n基于条 件传送的代码 , 会对 the n-ex p r 和 else-ex p r 都求值, 最终值的选择 基于对 test­\nex pr 的求值。可以用下面的抽象代码描述:\nv = then-expr; ve = else-expr; t = test-expr; if (!t) v = ve;\n这个序列中的最后一条语旬是用条件传送实现的 只有当测试条件 t 满足时, v t 的值才会被复制到 v 中。\n不是所有的条件表达式都可以用条件传送来编译。最重要的是,无论测试结果如何,\n我们 给出的 抽象代码会对 th en-ex p r 和 else-ex p r 都求值。如果这两个表达式 中的任意一个可能 产生错误条件或者副作用 , 就会导 致非法的行 为。前 面的一 个例子(图3-16 ) 就是这种情况。实际 上, 我们在该 例中引 入副作用就是 为了强 制 GCC 用条件转移来实 现这个函数。\n作为说明 , 考虑下面这个 C 函数:\nlong cread(long *xp) { return (xp? *xp : O);\n乍一 看, 这段代码似乎很适 合被编译成使 用条件传送 , 当指针为空时 将结果设置为 o,\n如下面的汇编代码所示:\nlong cread(long•xp)\nInvalid implementation of function cread\nxp 工n register %r d工cread:\nmovq (%rdi), %rax V = *Xp testq %rdi, %rdi Test x movl $0, %edx Set ve = 0 cmove ret %rdx, %rax If x ==O, v = ve Return v 不过, 这个 实现是非 法的 , 因为即使 当测试 为假时 , mo v q 指令(第2 行)对x p 的间接引用还是发生了 , 导致一个间接引 用空指针的错误。所以, 必须用分支代码来 编译这段代码。\n使用条件传送也不 总是会提高 代码的效 率。例如, 如果 th en-ex p r 或者 else-ex p r 的求值需要大量的计算, 那么当相对应的 条件不满 足时 , 这些工 作就白费了。编译器必须 考虑浪费的 计算和由于分支预测错 误所造成的性能处罚 之间的相对性能。说实话 , 编译器并不具有足够的信息来做出 可靠的决定; 例如, 它们不知道分支会多好地遵循可预测 的模式。我们对 GCC 的实验表明 , 只有当两个表达式 都很容易 计算时, 例如表达式 分别都只是一条加法指令,它才会使用条件传送。根据我们的经验,即使许多分支预测错误的开销会超 过更复杂的计算, GCC 还是 会使用条件控制转移 。\n所以,总的来说,条件数据传送提供了一种用条件控制转移来实现条件操作的替代策略。它们只能用于非常受限制的清况,但是这些情况还是相当常见的,而且与现代处理器的运行方式更契合。\n亡 练习题 3. 20 在下 面的 C 函数 中 , 我们对 OP 操作的定 义是 不 完整的 :\n#define OP _ I* Unknown operator *I\nlong arith(long x) { return x OP 8;\n}\n当编译时, GCC 会产 生如下 汇编代 码 :\nlong arith(long x) x in %r d 工\narith:\nleaq testq cmovns sarq ret\n7(%rdi), %rax\n%rdi, %rdi\n%rdi, %rax\n$3, %rax\nOP 进行 的是 什 么操作?\n给代码 添加注释 , 解释 它是 如何工作的。讫§ 练习题 3. 21 C 代码 开始的形 式如下 :\nlong test(long x, long y) { long val = ;\nif () {\nif ()\nval=\nelse\nval= _—;\n} else if ()\nval= return val;\n}\nGCC 会产 生如下 汇编代码 :\nlong test(long x, long y) x in %rdi , y in %rsi\ntest:\nleaq O(,%rdi,8), %rax testq %rsi, %rsi\njle .L2\nmovq %rsi, %rax\nsubq %rdi, %rax\nmovq %rdi, %rdx\nandq %rsi, %rdx\ncmpq %rsi, %rdi cmovge %rdx, %rax ret\n,L2:\naddq %rsi, %rdi\ncmpq $-2, %rsi cmovle %rdi, %rax ret\n填补 C 代码 中缺 失的 表达 式。\n7 循环\nC 语言提供了多种循环结构, 即 d o - wh i l e 、 wh i l e 和 f o r 。汇编中没有相应的指令存在, 可以用条件测试 和跳转组 合起来实现循环的效果。GCC 和其他汇编器产生的循环代码主要 基于两种基本的 循环模式 。我们会循 序渐进地研究循环的 翻译 ,从 d o - wh i l e 开始,然后再研究具有更复杂实现的循环,并覆盖这两种模式。\nd o - wh i l e 循环\ndo - wh i l e 语句的通用形式 如下 :\ndo\nbody-statement while (test-expr);\n这个循环 的效果就是重复执行 body sta tement , 对 test-ex pr 求值, 如果求值的结果为非\n零, 就继续循环。可以 看到 , bod y-sta tement 至少会 执行一次。这种通用形式可以被翻译 成如下所示的条 件和 g o t o 语句: loop:\nbody-statement\nt = test-expr;\nif (t)\ngoto loop;\n也就是说,每次循环,程序会执行循环体里的语句,然后执行测试表达式。如果测试为 真, 就回去再执行一次循环。\n看一个示例 , 图 3-19a 给出了一 个函数的实现, 用 d o - wh i l e 循环来计算函数参 数的阶乘, 写作 n ! 。这个函数只计算 n \u0026gt; 0 时 n 的阶乘的值。\n亡 练习题 3. 22\n用 一个 32 位 i n t 表 示 n !\u0026rsquo; 最 大的 n 的值 是 多少? 如果 用 一个 64 位 l o ng 表 示,最大的 n 的值 是 多少?\n图 3-196 所示的 goto 代码展示了如何把循环变成低级的测试和条件跳转的组合。 r e s ul t 初始化之后 , 程序开始循环。首先执行循环体 , 包括更新变量r e s u止 和 n。然后测试 1\u0026rsquo;!\u0026gt; 1 , 如果是真 , 跳转到循环开始处。图 3-19c 所示的汇编代码就是 goto 代码的原型。条件跳转指令 j g ( 第 7 行)是实现循环的关键指令, 它决定了是需要继续重复还是退出循环。\nlong fact_do(long n)\n{\nlong result = 1; do {\nresult*= n; n = n-1;\n} while (n \u0026gt; 1); return result;\n}\nlong fact_do_goto(long n)\n{\nlong result = 1; loop:\nresult*= n; n = n-1;\nif (n \u0026gt; 1)\ngoto loop; return result;\n}\nC代码 b ) 等价的go七o版本 1 long fact_do(long n) n in %rdi fact_do: 2 movl $1, %eax Set result 1 3 .12: l oop: 4 imulq %rdi, %rax Compute result *= n 5 subq $1, %rdi Decrement n 6 cmpq $1, %rdi Compare n: 1 7 jg .L2 If\u0026gt;, goto l oop 8 rep; ret Return C ) 对应的汇编代码\n佟I 3- 1 9 阶 乘 程序的 do- whi l e 版本的代码。条件跳转会使得程序循环\n逆向工程像图 3-19c 中那样的汇编代码 ,需 要确定 哪个寄存器对应的是 哪个程序值 。本例中, 这个对应 关系很容易确定 : 我们知道 n 在寄存 器%r d i 中传递给函数。可以 看到寄存器%r ax 初始化为 1 ( 第 2 行)。(注意, 虽然指令的目 的寄存 器是 %e a x , 它实际上还会 把%r a x 的高 4 字节设 置为 0。)还可以看到这个寄存器还会在第 4 行被乘法改变值。此外,%r a x 用来返回函数值 , 所以通常会用来存放需要返回的程 序值。因此我们断定%r ax 对应程序值r e s ul t 。\n练习题 3. 23 已 知 C 代 码 如下 :\nlong dw_loop(long x) { long y = x*x;\nlong *P = \u0026amp;x; long n = 2*x; do {\nX += y;\n(*p)++;\nn\u0026ndash;;\n} while (n \u0026gt; 0); return x;\n}\nGCC 产 生的 汇编代码 如下:\nlong d w_l oop(l ong x) x initially in %rdi dw_loop:\n2 movq %rdi, %rax 3 movq %rdi, %rcx 4 imulq %rdi, %rcx 5 leaq (%rdi,%rdi), %rdx 6 . L2: 7 leaq 1(%rcx,%rax), %rax 8 subq $1, %rdx 9 testq %rdx, 1r儿 dx 10 jg .L2 11 rep; ret 哪 些寄 存器用来存 放程序值 x、 y 和 n?\n编译器 如何 消 除对指 针 变 量 p 和表达 式 ( *p ) ++ 隐含的指针 间 接引用的 需求?\n对 汇编 代码 添加 一些注释 , 描述 程序的 操作 , 类似于 图 3-1 9c 中所 示的 那样。\nm 逆向 工程循环\n理解产生的汇编代码与原始沌代码之间的关系,关键是找到程序值和寄存器之间的 映射 关 系。 对于图 3-1 9 的循 环 来说 , 这个任 务非 常 简 单, 但是对于更 复杂的 程序来说 , 就可能是 更具挑战性 的任务。C 语言编译 器常常 会重组 计算, 因此 有些 C 代码中的 变量在机器代码中没有对应的值;而有时,机器代码中又会引入源代码中不存在的新值。此 外,编译器还常常试图将多个程序值映射到一个寄存器上,来最小化寄存器的使用率。\n我们描 述 f a c t _d o 的过程对于逆 向工程循 环 来说 , 是一 个通 用的 策略 。 看 看在循环之前如何初始化寄存器,在循环中如何更新和测试寄存器,以及在循环之后又如何使 用寄存器。这些步骤中的每一步都提供了一个线索,组合起来就可以解开谜团。做好准\n备,你会看到令人惊奇的变换,其中有些情况很明显是编译器能够优化代码,而有些情 况很 难解释编译 器 为 什 么要 选用 那些奇怪的 策略。根 据我们的 经验 , G CC 常 常做 的一些变换,非但不能带来性能好处,反而甚至可能降低代码性能。\nwhile 循环\nwh i l e 语 句 的 通用 形 式如下:\nwhile (test-expr) body-statement\n与 d o- wh i l e 的 不 同 之 处 在 于, 在 第 一 次 执 行 bod y-s ta tem ent 之 前, 它 会 对 tes t- expr 求值 , 循 环 有 可 能就中 止 了。 有很 多 种 方 法 将 wh i l e 循 环 翻 译成 机器代 码 , G CC 在代 码生成 中使 用 其 中 的 两种 方 法。 这 两种 方 法使 用 同 样的 循 环结构, 与 d o - wh i l e 一 样, 不 过 它们实现初始测试的方法不同。\n第 一种 翻译方 法 , 我 们 称之 为 跳 转 到 中 间 ( jum p to middle), 它执行一个无条件跳转跳到 循 环结尾处 的 测试, 以 此来 执行初始的 测 试。 可 以用 以下模板来 表 达这种 方 法 , 这个模板把 通用 的 wh i l e 循 环格 式 翻译 到 g o t o 代码 :\ngoto test; loop:\nbody-statement test:\nt = test-expr;\nif (t)\ngoto loop;\n作为 一个 示 例 , 图 3- 20a 给 出 了使用 wh i l e 循 环的 阶 乘 函 数 的 实 现。这个 函 数 能 够 正确地 计算 0 ! = l 。 它 旁 边的 函 数 f a c t _ wh i l e _ j m_g o t o ( 图 3-20 b ) 是 GCC 带优 化命令行选项-Og 时产 生的 汇编 代码 的 C 语言翻译。 比 较 f a c 七_wh il e ( 图 3-20 b) 和 f a c 七_ d o ( 图 3- l 9b) 的代码 , 可 以 看到 它们 非 常 相 似 , 区 别 仅在 于 循 环 前 的 g o t o t e s t 语 句 使得 程 序 在 修 改 r e s u l t 或 n 的值之前, 先执行对 n 的 测 试。 图 的 最下 面(图 3- 20c) 给出 的是 实际产 生 的 汇编代码。\n立 练习题 3. 24 对于如下 C 代 码 :\nlong loop_while(long a, long b)\n{\nlong result = ; while () {\nresult= ,\na = ,\n}\nreturn result;\n}\n以命令行选项 - Og 运行 GCC 产 生 如下代码 :\nlong l oop _ w 加 l e (l ong a, long b) a in %rdi, b i n %rsi loop_while:\n2 movl $1, %eax 3 jmp .L2 4 .L3:\nleaq (%rdi,%rsi), %rdx\nimulq %rdx, %rax\naddq $1, %rdi\n8 . L2 :\ncmpq %rsi, %rdi\njl .L3\n11 rep; ret\n可以 看到 编译器使用 了 跳 转 到 中 间 的 翻 译 方 法 , 在 第 3 行用 jm p 跳 转 到 以 标 号\n2 开始的 测试。填写 C 代码 中缺失的部分。 long fact_while(long n)\n{\nlong result= 1; while (n \u0026gt; 1) {\nresult*= n; n = n-1;\n}\nreturn result;\n}\nlong fact_while_jm_goto(long n)\n{\nlong result = 1; goto test;\nloop:\nresult*= n; n = n-1;\ntest:\nif (n \u0026gt; 1)\ngoto loop; return result;\n}\nC代码 b ) 等价的goto版本 long f act _ w 加 l e ( l ong n)\nn 工 n %rdi fact_while:\nmovl $1, %eax\njmp .L5\n.L6:\nSet result 1 Goto test\nl oop:\nimulq %rdi, %rax Compute result *= n subq $1, %rdi Decrement n\n.15: t es t :\ncmpq $1 , %rdi Compare n: 1\njg .16 If \u0026gt;, goto loop\nrep; ret Return\nC ) 对应的汇编代码\n图 3-20 使用跳转到中间 翻译方法的 阶乘算 法的 whi l e 版本的 C 代码和汇编代 码。\nC 函数 f ac t _whi l e_ j m_g ot o 说明了汇编代码 版本的操作\n第二种翻译 方法 , 我们称之为 g ua r d ed-d o , 首先用条件分支,如果初始条件不成立就跳过循 环, 把代码变换为 d o - wh i l e 循 环 。 当使用较高优化等级编译时,例 如 使 用 命 令 行选项 - 0 1 , GCC 会采用这种策略。可以用如下模板来表达这种方法, 把通用的 wh i l e 循 环\n格式翻译 成 d o - wh i l e 循 环 :\nt = test-expr;\nif (!t)\ngoto done;\ndo\nbody-statement while (test-expr) ;\ndone:\n相应地, 还可以把它翻译 成 go to 代码如下:\nt = test-expr;\nif (! t)\ngoto done; loop:\nbody-statement\nt = test-expr;\nif (t)\ngoto loop;\ndone:\n利用这种实现策略 , 编译器常常可以 优化初始的测试,例 如 认 为测试条件总是满足。\n再来看 个 例 子,图 3 - 21 给出了图 3- 20 所示阶乘函数同样的 C 代码, 不 过给出的是\nGCC 使用命令行选项- 01 时的编译。图 3-2 l c 给出实际生成的汇编代码,图 3 - 21 b 是这个汇编代码更易读的 C 语言表示。根据 goto 代码, 可以看到如果对千 n 的初始值有 n l, 那 么将跳过该循环。该循环本身的基本结构与该函数 d o - wh 工l e 版 本 产 生的结构(图3-19 ) 一样。不过,一 个 有趣的特性是,循 环测试(汇编代码的第 9 行)从 原 始 C 代码的 n \u0026gt; l 变成 了 n =I= 1 。 编译器知道只有当 n\u0026gt; l 时才会进入循环, 所以将 n 减 1 意味着 n \u0026gt; l 或者 n =\n1 。因此 ,测 试 n =I= l 就 等价于测试 n l 。\nlong fact_while(long n)\n{\nlong result = 1; while (n \u0026gt; 1) {\nresult*= n; n = n-1;\n}\nreturn result;\n}\nlong f act _wh辽 e _gd_got o ( l ong n)\n{\nlong result = 1;\nif (n \u0026lt;= 1)\ngoto done;\nloop:\nresult*= n; n = n-1;\nif (n != 1)\ngoto loop;\ndone:\nreturn result;\n}\nC代码 b ) 等价的goto版本 图 3-21 使用 guarded -do 翻译方法的 阶乘算法的 whil e 版本的 C 代码和汇编代 码。函数 f act _whi l e_gd_got o 说明 了汇编代 码版本的 操作\nlong f act _whi l e (l ong n) n in %rdi\nfact_while:\ncmpq $1 , %rdi\njle .17\nmovl $1, %eax\n.16:\nCompare n:1 If\u0026lt;=, goto done Set result= 1\nloop:\nimulq subq cmpq jne rep;\n.17:\nmovl ret\nret\n%rdi, ;儿r ax\n$1, %rdi\n$1, %rdi\n.L6\n$1, %eax\nCompute result *= n Decrement n\nCompare n:1 If!=, goto loop Return\ndone:\nCompute result = 1 Return\n练习题 3. 25 对 于如下 C 代码 :\nC ) 对应的汇编代码\n图 3-21 (续)\nlong loop_while2(long a, long b)\n{\nlong result = ; while () {\nresult=\nb =\n}\nreturn result;\n}\n以命 令行选项 - 0 1 运行 GCC , 产生如下代码:\na in %rdi , b in %rsi loop_while2:\ntestq %rsi, %rsi jle .L8\nmovq %rsi, %rax\n.L7:\nimulq %rdi,\nsubq %rdi,\ntestq %rsi,\njg .L7\nrep; ret\n.L8:\n%rax\n%rsi\n%rsi\nmovq ret\n%rsi, %rax\n可以看到编译 器使用 了 guard e d- do 的翻译 方法 , 在第 3 行使用了 j l e 指令使得当初 始测试不成 立时 , 忽略循环代 码。 填写缺 失的 C 代码。 注意 汇编 语言中的 控制结构 不 一定 与根据翻译规则 直接 翻译 C 代码得 到的 完全 一致。 特别 地, 它有 两个 不同的r e t 指令(第10 行和第 13 行)。不过 , 你可以根 据等价的 汇编代码 行为填写 C 代码中缺 失的部分。\n让 练习题 3. 26 函数 f un_a 有如下 整体 结构 :\nlong fun_a(unsigned long x) { long val= O;\nwhile (\u0026hellip;) {\n}\nreturn \u0026hellip; ;\n}\nGCC C 编译器 产 生如 下 汇编 代码 :\nlong f un _a ( unsi gned long x) x in¼rdi\nfun_a:\n2 movl $0, %eax 3 jmp .LS 4 .L6: 5 xorq %rdi, %rax 6 shrq %rdi Shift right by 1 7 .LS: 8 testq %rdi, %rdi 9 jne .L6 10 andl $1, %eax 11 ret 逆向工程这段代码的操作,然后宪成下面作业:\n确定这段代码使用的循环翻译方法。 根据 汇编 代码版本填 写 C 代码 中缺 失的部分。 用 自 然语 言描述这 个函 数是 计算 什 么的 。 for 循环\nfor 循环的通用形式如下 :\nfor (init-expr; test-expr; update-expr) body-statement\nC 语言标准说明(有一个例外, 练习题 3. 29 中有特别说明), 这样一个循环的行为与下面这段使 用 wh il e 循环的 代码的 行为一样:\ninit-expr;\nwhile (test-expr) { body-statement update-exp,;\n}\n程序 首先对初始表达式 init-ex pr 求值, 然后 进入循环 ; 在循环 中它先对测试 条件 test 飞 x pr 求值, 如果测试结果为“ 假” 就会退出, 否则执行循环体 bod :r sta tement ; 最后对更新表达式 up d a te-ex pr 求值。\nGCC 为 f o r 循环产生 的代码是 wh i l e 循环的两种翻译 之一, 这取决于优化的等级。也就是, 跳转到 中间策略 会得到如下 go to 代码:\ninit-expr; goto test;\nloop:\nbody-statement update-expr;\ntest:\nt = test-expr; if (t)\ngoto loop;\n而 gua rded-do 策略得到 :\ninit-expr;\nt = test-expr; if (! t)\ngoto done;\nl oop :\nbody-statement update-expr;\nt = test-expr;\nif (t)\ngoto loop;\ndone:\n作为一个 示例 , 考虑用 f or 循环写的 阶乘函数:\nlong fact_for(long n)\nlong i;\nlong result = 1;\nfor (i = 2; i \u0026lt;= n; i++) result*= i;\nreturn result;\n如上述代码所示 , 用 f o r 循环编写阶乘函数最自然的 方式就是将从 2 一直到 n 的因子乘起来 , 因此, 这个 函数与我们使用 wh il e 或者 d o - wh il e 循环的代码很不一 样。\n这段代码中的 f or 循环的不同 组成部分 如下 :\ninit-expr i = 2\ntest-expr i \u0026lt;= n\nupdate-expr i ++\nbody-statement result *= i;\n用这些部分替换前面给出的 模板中相应 的位置, 就把 f or 循环转换成了 wh i l e 循环, 得到下面的代码:\nlong fact_for_while(long n)\n{\nlong i = 2;\nlong result = 1; while (i \u0026lt;= n) { result*= i;\ni++;\n}\nreturn result;\n}\n对 wh i l e 循环进行跳转到中间 变换 , 得到如下 g o to 代码 :\nlong fact_for_jrn_goto(long n)\n{\nlong i = 2;\nlong result= 1; goto test;\nloop:\nresult*= i;\ni++;\ntest:\nif (i \u0026lt;= n)\ngoto loop; return result;\n确实, 仔细查看使用命令行选项\u0026ndash;Og 的 GCC 产生的汇编代码, 会发现它非常接近于以下模板:\nlong fact_for(long n) n in¼rdi\nfact_for:\nmovl movl jmp\n.19:\nimulq addq\n.18:\ncmpq jle rep;\nret\n$1, %eax\n$2, %edx\n.L8\n%rdx, %rax\n$1, %rdx\n%rdi, %rdx\n.L9\nSet result= 1\nSeti= 2\nGoto test loop:\nComputer es ul t *= 工\nIncrement i test:\nCompare i : n If\u0026lt;=, goto loop Return\n; 练习题 3. 27 先 把 f a c t —f or 转 换 成 wh i l e 循 环 , 再 进 行 g ua rd ed- do 变 换, 写出\nf a c t _ f o r 的 g o t o 代码。\n综上所述 , C 语言中三种形式的 所有的 循环 d o - wh i l e 、 wh i l e 和 f o r 都可以用一种简单的策略来翻译,产生包含一个或多个条件分支的代码。控制的条件转移提供了 将循环翻译成机器代码的基本机制。\n江 义 练习题 3. 28 函 数 f u n—b 有如下整体结 构:\nlong fun_b(unsigned long x) { long val= O;\nlong i;\nfor (. . . ; . . . ; . . .) {\n}\nreturn val;\nGCC C 编译器产 生如下 汇编 代码 :\nl ong 丘m _ b ( 皿s i gned long x) x in %rdi\nfun_b:\nmovl movl\n.110:\nmovq\n$64, %edx\n$0, %eax\n%rdi, %rcx\n6 andl $1, %e c x 7 ad dq %rax, %rax 8 or q %rcx, %rax 9 s hr q r% di Shift right by 1 10 s ubq $1, %r dx 11 jne .110 12 rep; ret 逆向工程这段代码的操作,然后完成下面的工作:\n根据 汇编代 码版本填 写 C 代码 中缺 失的部分。\n解释循环前为什么没有初始测试也没有初始跳转到循环内部的测试部分。\n用自然语言描述这个函数是计算什么的。\n讫; 练习题 3. 29 在 C 语 言 中执行 c o n t i n ue 语 句会导 致 程 序 跳 到 当 前 循环 迭代 的 结 尾。当处理 c o n t i n ue 语句 时 , 将 f or 循环 翻译 成 wh i l e 循 环 的 描述 规则 需 要 一 些 改进。例如,考虑下面的代码:\nI* Example of for l oop cont a i ni ng a continue statement *I I* Sum even numbers between O and 9 *I\nlong sum= O; long i;\nfor (i = O; i \u0026lt; 10; i++) {\nif (i \u0026amp; 1)\ncontinue; sum += i ;\n如果 我们 简 单地 直 接 应 用 将 f o r 循 环 翻译 到 wh i l e 循 环 的 规则 , 会得 到 什 么呢? 产生的代码会有什么错误呢?\n如何用 g o t o 语 句来 替代 c o n t i n ue 语句 , 保证 wh i l e 循环的行 为同 f or 循环的行为完全一样?\n6. 8 s w itc h 语句\ns wi t c h ( 开关)语句可以根据一个整数 索引值进行多重分支( m ult iw ay bra nching ) 。 在处理具有多种可能结果 的测试时 , 这种 语句特别有用。它们不仅提高了 C 代码的可读性,而且通 过使用跳 转表 ( jum p ta ble ) 这种数据结构使得实现更加高效。跳转表是一个数组,表项 t 是一个代码段的 地址 , 这个代码 段实现当开关 索引值等千 1 时程序应该 采取的 动作。程序代码用开关索引值来执行一个跳转表内的数组引用,确定跳转指令的目标。和使用一组很 长 的 江- e l s e 语句相比, 使用跳转表的优点 是执行开关 语句的时间与开关情况的 数 扯无关。GCC 根据开关 情况的数 量和开关情况值的稀疏程度来 翻译 开关语句。当开关情况数量比较多(例如4 个以上), 并且值的 范图跨度比较小 时, 就会使用跳转 表。\n图 3- 22a 是一个 C 语言 SW止 c h 语句的示例。这个 例子有些 非常有意思的特征, 包括情况标号 ( case la be!) 跨过一个不 连续 的区域(对于情况 101 和 105 没有标 号), 有些情况有多个标号(情况 104 和 106 ) , 而有些情况则 会落入其他情况之 中(情况 10 2 ) , 因为对应该情况的代码段没有 以 br e a k 语句结尾。\n图 3-23 是编译 s wi t c h_e g 时产生的汇编代码。这段 代码的行为用 C 语言来描述就是图 3-226 中的过程 s wi t c h_e g _ i mp l 。 这段代码使用了 GCC 提供的 对跳转表的支持, 这是\n对 C 语言的扩展。数组 j t 包含 7 个表项 , 每个都是一个代码块的地址 。这些位置由 代码中的标号定义,在]七的表项中由代码指针指明,由标号加上飞矿前缀组成。(回想运算符\n& 创建一个指向数 据值的指针。在做这个扩展时, GCC 的作者们创造了一个新的运算 符\n&&, 这个运算 符创建一个指向代码位 置的指 针。)建议你研究一下 C 语言过程 s wi t c h_e g —\nimpl, 以及它与汇编代码版本之间的关系。\nvoid switch_eg_impl(long x, long n,\n2 long *dest)\n3 {\nI* Table of code pointers *I\nstatic void *jt [7] = {\nvoid switch_eg(long x, long n, 6 \u0026amp;\u0026amp;loc_A, \u0026amp;\u0026amp;loc_def, \u0026amp;\u0026amp;loc_B, long *dest) 7 \u0026amp;\u0026amp;loc_C, \u0026amp;\u0026amp;loc_D, \u0026amp;\u0026amp;loc_def,\n{ 8 \u0026amp;\u0026amp;loc_D\nlong val= x; 9 };\n10 unsigned long index= n - 100;\nswitch (n) { I ,, long val;\n12 case 100: val*= 13; 13 14 if (index\u0026gt; 6) goto loc_def; break; 15 I* Multiway branch *I 16 goto *jt[index]; case 102: val+= 10; 17 18 loc_A: I* Case 100 *I I* Fall through *I 19 val= x * 13; 20 goto done; case 103: 21 loc_B: I* Case 102 *I val += 11; 22 X = X + 10; break; 23 /• Fall through•/ 24 loc_C: I* Case 103 *I case 104: 25 val = x + 11; case 106: 26 goto done; val*= val; 27 loc_D: I* Cases 104, 106 *I break; 28 val= x * x; 29 goto done; default: 30 loc_def: I* Default case *I val= O; 31 val= O; } 32 done: *dest = val; 33 *dest = val; } 34 } a ) s wi t c h语句 b ) 翻译到扩展的 C语言\n图 3-22 s wi t c h 语句示例以及翻译到扩展的 C 语言。该翻译 给出了 跳转表 j t 的结构, 以 及如何访问它。作为对 C 语言的扩展 , GCC 支持 这样 的表\n原始的 C 代码有针对值 100 、102-104 和 10 6 的清况 , 但是开关变量 n 可以是任意整数。编译器首先将 n 减去 100 , 把取值范围移到 0 和 6 之间, 创建一个新的程序变量 , 在我们的 C 版本中称为 i nde x。补码表示的负数会映射成无符号表示的大正数 , 利用这一事实 , 将 i nde x 看作无符号值, 从而进一步简化了分支的可能性。因此可以 通过测试 i nde x 是否大于 6 来判定i nde x 是否在 0 ~ 6 的范围之外。在 C 和汇编代码中 , 根据 i nde x 的值, 有五个不同的跳转位\n置: loc A( 在汇编代码中标识为 . 1 3) , loc B(.LS), loc C(.16), loc D( . 1 7 ) 和 l o c def\n(.18), 最后一个是默认的目的地址。每个标号都标识一个实 现某个情 况分支的代码块。在 C\n和汇编代码 中, 程序都是将 i nde x 和 6 做比较, 如果大千 6 就跳转到默认的代码处。\nvoid switch_eg(long x, long n, long *des t )\nx in %rdi, n in %sr switch_eg:\ni , dest in %rdx\nsubq $100, %rsi\ncmpq $6, %rsi\nja .18\njmp *. 14(,%rsi, 8)\n.13:\nleaq (%rdi,%rdi,2), %rax leaq (%rdi,%rax,4), %rdi jmp .12\n.15:\naddq $10, %rdi\n.16:\naddq $11, %rdi\njmp .12\n.17:\nimulq %rdi, %rdi jmp .12\n.18:\nmovl $0, %edi\n.12:\nmovq %rdi, (%rdx) ret\nComp ut e index = n-1 00\nCompar e i nde x: 6 If\u0026gt;, goto l oc_def Goto *jt [index]\nl oc _A : 3•x\nval = 13•x\nGoto done l oc _B :\nX = X + 10\nl oc_ C :\nval = x + 11 Goto done\nl oc _D:\nval = x * x Goto done\nl oc _de f : val = 0\ndone :\n•dest = val Return\n图 3-23 图 3-22 中 s wi t c h 语句示例的汇编代码\n执行 s wi t c h 语句的关键步骤是通过跳转 表来访问代码位 置。 在 C 代码中是 第 1 6 行, 一条 g o t o 语句引用了跳转表 j t 。GCC 支持计算 g o t o ( co m p u ted goto), 是对 C 语言的扩展。在我们的 汇编代码 版本中, 类似的操作是在第 5 行, j mp 指令的操作数有前缀`*',表明这 是一个间 接跳转 , 操作数指定一个内存位置, 索引由寄存器%r s i 给出 , 这个寄存 器保存着 i n d e x 的值。(我们会在 3. 8 节中看到如何 将数组引 用翻译 成机器代码 。)\nC 代码将跳转 表声明 为一个 有 7 个元素的 数组 , 每个元素都是 一个指向代码位置的指针。这些元素跨越 i n d e x 的值 0 ~ 6 , 对应于 n 的值 100 ~ 10 6。可以 观察到, 跳转表对重复情况 的处理就是 简单地对表项 4 和 6 用同样的 代码标号( l o c _ D) , 而对千缺失的情况的处理就是对表 项 1 和 5 使用默认情 况的标 号( l o c _ d e f ) 。\n在汇编代码中,跳转表用以下声明表示,我们添加了一些注释:\n2 .section .align 8 .rodata Align address to multiple of 8 3 .L4: 4 .quad .L3 Case 100: loc_A 5 .quad .LS Case 101: loc_def 6 .quad .15 Case 102: loc_B 7 .quad .L6 Case 103: loc_C 8 .quad .L7 Case 104: loc_D 9 .quad .L8 Case 105: loc_def 10 .quad .17 Case 106: loc_D 这些声明 表明 , 在叫做 \u0026quot; . r o d a t a \u0026quot; ( 只读数据, R e ad- O nly Dat a ) 的目标代码文件的 段中 , 应该有一组 7 个“四” 字( 8 个字节), 每个字的值都是与指定 的汇编代码标号(例如 . L3) 相关联的指令地址。标号 . L4 标记出这个分配地址 的起始。与这个标号相对应的 地 址会作为间 接跳转(第5 行)的基地址。\n不同的代码块CC 标号 l oc _A 到 l oc _ D 和 l oc—de f ) 实现了 s wi t ch 语句的不同分支。它\n们中的大多数只是简单地计算了 va l 的值, 然后跳转到函数的结 尾。类似地 , 汇编代码块计 算了寄存器 %r 中 的值, 并且跳转到函数结 尾处由标号. L2 指示的位置。只有情况标号 102 的 代码不是这种模式的 , 正好说明在原始 C 代码中情况 102 会落到 情况 103 中。具体处理如下: 以标号. LS 起始的汇编代码块中, 在块结尾处没有 j rnp 指令, 这样代码就会继续执行下一个块。类似地, C 版本 s wi t c h_e g _i rnp l 中以标号 l oc_B 起始的块的结尾处也没有 got o 语句。\n检查所有这些代码需要很仔 细的研究, 但是关键是领会使 用跳转表是一种非常有效 的实现多 重分 支的方法。在我 们的例子中, 程序可以 只用一次跳转表引用就分支到 5 个不同的位置。甚 至当 s wi t c h 语句有上百 种情况的时候 , 也可以只 用一次跳转表访问 去处理。 亡 练习题 3. 30 下 面的 C 函数省略 了 S W 江 c h 语句的 主体 。在 C 代码 中 , 情况标 号是不\n连续的,而有些情况有多个标号。\nvoid switch2(long x, long *dest) { long val= O;\nswitch (x) {\nBody of switc h statement omitted\n*dest = val;\n在编译该函数时, GCC 为程序的初 始部分生成了以 下汇编代码,变 量 x 在寄存器r%\nV O 工 d swi tch2(long x, long *dest)\nx in %rdi swi t ch2 :\ndi 中:\naddq cmpq ja jmp\n$1, %rdi\n$8, 1r儿 di\n.12\n*. 14(,%rdi, 8)\n为跳转表生成以下代码:\n.L4:\n.quad\n.quad\n. quad\n. quad\n.quad\n.quad\n.quad\n.quad\n.quad\n. L9\n.LS\n.L6\n. L7\n. L2\n.L7\n.L8\n.L2\n.LS\n根据 上述 信息 回答下 列问题 :\ns wi t c h 语 句内 情况标 号的值 分别是 多少? C 代码 中哪 些情况 有 多个标 号? 诈 练 习题 3. 31 对于 一 个 通用 结构的 C 函 数 s wi t c h er :\nvoid switcher(long a, long b, long c, long *dest)\n{\nlong val; switch(a) {\ncasa : I* Case A *I\nC =\nI* Fall through *I\ncase I* Case B *I\nval= break;\ncase I* Case C *I\ncase I* Case D *I\nval= break;\ncase I* Case E *I\nval= break;\ndefault:\nval=\n}\n*dest = val;\n}\nGCC 产 生如 图 3- 24 所 示 的 汇 编代码 和跳 转 表。\nVO 工 d switcher(long a, long b, l ong c, long *dest) a in %rdi, b 工 n %rsi, c in %rdx, dest in %rcx switcher:\ncmpq ja jmp\n.section\n.L7:\n$7, %rdi\n.12\n*. 14(,%rdi ,8)\nr. odat a\nxorq $15, %rsi\nmovq %rsi, %rdx\n.L3:\nleaq 112(%rdx), %rdi jmp .L6\n.LS:\nleaq salq jmp\n.L2:\n(%r dx , %r s i ) , %rdi\n$2, %rdi\n.L6\nmovq %rsi, %rdi\n.L6:\nmovq %rdi, (%rcx) ret\na ) 代码\n图 3-2 4 练习题 3. 31 的汇编代 码和跳转表\nb ) 跳转表\n填写 C 代码 中 缺 失的 部 分。除 了 情 况标 号 C 和 D 的 顺 序 之 外, 将 不 同 情 况 填入这个模板的方式是唯一的。\n3. 7 过程\n过程是软件中一种很重要的抽象。它提供了一种封装代码的方式,用一组指定的参数和一个可选的返回值实现了某种功能。然后,可以在程序中不同的地方调用这个函数。设计良好的软件用过程 作为抽象机制,隐藏某个行为的具体实现,同时又提供清晰简洁的接口定义,说明要计算的是哪些值, 过程会对程序状态产生什么样的影响。不同编程语言中, 过程的形式多样: 函数( function) 、方法( method) 、子例程( sub ro utine) 、处理函数( handler ) 等等, 但是它们有一些共有的特性。\n要提供对过程的机器级支持,必须要处理许多不同的属性。为了讨论方便,假设过程\np 调用过程 Q , Q 执行后返回到 P。这些动作包括下 面一个 或多个 机制 :\n传递控 制。在进入过 程 Q 的时候, 程序计数 器必须被设置为 Q 的代码的起始地址 , 然后在返回时, 要把程序计 数器设置为 P 中调用 Q 后面那条指令的 地址。\n传递数 据。P 必须能够向 Q 提供一个或多个参数, Q 必须 能够向 P 返回一个值 。\n分配和释放 内存。在开始 时, Q 可能需 要为局 部变量分 配空间, 而在返回前, 又必 须释放这些存储空间。\nx86-64 的过程实现 包括一组特殊的指令 和一些 对机器资源(例如寄存器和程序内存)使用的约定规则。人们花了大量的力气来尽量减少过程调用的开销。所以,它遵循了被认为 是最低要求策略的方法,只实现上述机制中每个过程所必需的那些。接下来,我们一步步 地构建起不同的机制,先描述控制,再描述数据传递,最后是内存管理。\n3. 7. 1 运行时栈\nC 语言过程调用机制的一个关键特性(大多数其他语言也是如此)在于使用了栈数据结构提供的后进先出的内存管理原则。在过 程 P 调用过程 Q 的例子中, 可以看到当 Q 在执行 时, p 以及所有在向上追溯到 P 的调用链中的过程, 都是暂时被挂起的。当Q 运行时, 它只需要为局部 变量分 配新的存储空间,或者设置到另一个过程的调用。 另一方面, 当 Q 返回时, 任何它所分配的局部存储空间都可以被释放。因此,程序可以用栈来管 理它的过程所需要的存储空间,栈和程序寄存器 存放着传递控制和数据、分配内存所需要的信息。当 P 调用 Q 时, 控制和数据信息添加到栈尾。当 P 返回时,这些信息会释放掉。\n如 3. 4. 4 节中讲过的, x86-64 的栈向低地址方向增长, 而栈指 针%r s p 指向栈顶元 素。 可以用 p us hq 和 p o p q 指令将数据存入栈中或是从栈中取出。将栈指针减小一个适当的量可以 为没有指定初始值的数据在栈上分配空间。类 似地,可以通过增加栈指针来释放空间。\n地址增大\n栈指针\n%rsp\n栈底\n栈“顶”\n较早的帧\n调用函数\nP的帧\n正在执行的\n函数Q的帧\n当 x86-64 过程需要的存储空间 超出寄存器能够存放的大小时,就会在栈上分配空间。这个部分 称为过程的栈帧 ( s t ack fr am ) 。图 3- 25\n图 3-25 通用的栈帧结构(栈用来传递参数、存储返回信息、保存寄存器,以及局部 存储。省略了不必要的部分)\n给出了运行时栈的通用结构,包括把它划分为栈帧。当前正在执行的过程的帧总是在栈 顶。当过程 P 调用过程 Q 时, 会把返回地址压入栈中, 指明当 Q 返回时, 要从 P 程序的哪个位置继续 执行。我们把这个 返回地址 当做 P 的栈帧的一部分, 因为它存 放的是与 P 相关的状态 。Q 的代码会 扩展当前 栈的边界 , 分配它的栈帧所需的空间。在这个空间中, 它可以保存寄存器的 值, 分配局部 变量空间, 为它调用的 过程设 置参数。大多数过程的 栈帧都是定长的 , 在过程的 开始就分 配好了。但是有些过程需 要变长的帧 , 这个问题会在 3. 10. 5 节中讨论。通过寄存 器, 过程 P 可以 传递最多 6 个整数 值(也就是指针和整数), 但是如果\nQ 需要更多的参数 , P 可以在调用 Q 之前在自己 的栈帧里存储好这些参数。\n为了提高空间 和时间效 率, x 8 6 - 64 过程只分 配自己所需 要的栈帧部分。例如, 许多过程有 6 个或者更 少的参数, 那么所有的参数都可以 通过寄存器传递。因此, 图 3- 25 中画出的某些栈 帧部分可以省略。实际上, 许多函数甚至根本不 需要栈帧。当所有的局部变量都可以保存在寄存器中,而且该函数不会调用任何其他函数(有时称之为叶子过程,此时 把过程调用看做树结构)时,就可以这样处理。例如,到目前为止我们仔细审视过的所有 函数都不 需要栈帧。\n7. 2 转移控制\n将控制从函数 P 转移到函数 Q 只需 要简单地把程序计数器 ( PC)设置为 Q 的代码的起始位置。不过 , 当稍后从 Q 返回的时候 , 处理器必须记录好它需要继续 P 的执行的代码位置。在x86-64 机器中, 这个信息是用指令 c a ll Q 调用过程 Q 来记录的。该指 令会把地址 A 压入栈中, 并将 PC 设置为 Q 的起始地址。压入的地址 A 被称为返回地址 , 是紧跟 在 c a l l 指令后面的那 条指令的地址。对应的指令r e t 会从栈中弹出地址 A , 并把 PC 设置为 A 。\n下表给出的是 c a l l 和r e t 指令的一般形式 :\n指令 描述 call Label 过程调用 call Operand 过程调用 ret 从过程调用中返回 (这些指令在程序 OBJDUMP 产生的反汇编输出中 被称为 c a ll q 和r e t q 。添加的后缀\u0026rsquo; q \u0026rsquo; 只是为了强 调这些是 x8 6- 64 版本的调用和返 回, 而不是 I A 3 2 的。在 x8 6- 64 汇编代码中, 这两种版本可以互换。)\nc a l l 指令有一个 目标, 即指明 被调用过程起始的指令地址。同 跳转一样, 调用可以是直接的,也可以是间接的。在汇编代码中,直接调用的目标是一个标号,而间接调用的 目标是 * 后面跟一个操作数指示符 , 使用的是图 3-3 中描述的格式之一。\n图 3- 26 说明了 3. 2. 2 节中介绍的 mu l t s t or e 和 ma i n 函数的 c a l l 和r e t 指令的执行情况。下面是这两个 函数的反汇编代码的 节选 :\nBeginning of function multstore\n1 0000000000400540 \u0026lt;multstore\u0026gt;:\n2 400540: 53\n3 400541: 48 89 d3\npush %rbx\nmov %rdx,%rbx\nReturn from f unc t i on mul tstore\n40054d: c3 retq\nCall to multstore from ma 工 n 400563: e8 d8 ff ff ff 400568: 48 8b 54 24 08\ncallq 400540 \u0026lt;multstore\u0026gt; mov Ox8(%rsp),%rdx\n在这段 代码中 我们 可以 看到 , 在 ma i n 函数中 , 地址 为 Ox 400 5 63 的 c a l l 指令调用 函 数 mu l t s t or e 。此 时的状态如图 3- 26a 所示, 指明了栈指针%r s p 和程序计数 器%豆 p 的值。 c a ll 的效果是 将返回地址 Ox 40 05 68 压入栈中,并跳到函数 mu l t s t or e 的第一条指令,地址为 Ox 0 40 05 40 ( 图 3- 266 ) 。函数 mu l t s t or e 继续执行, 直到 遇 到地址 Ox 40 05 4d 处的\nr e t 指令。这条指 令从 栈中弹出值 Ox 40 05 68 , 然后跳转到这个地址, 就在 c a l l 指令之后, 继续 ma i n 函数的 执行。\n霍。x7f f f驾言罚\nOx400568\na ) 执行ca l l b) ca ll 执行之后\nc) r e t 执行之后\n图 3-26 ca ll 和 r e t 函数的说明。ca l l 指令将控制 转移到一 个函数的起 始, 而 r e t 指令 返回 到这 次调 用后面的 那条指 令\n再来看一个更详细说明在过程间传递控制的 例子, 图 3- 27a 给出了两个函数 t op 和 l e a f 的反汇编代码, 以及 ma i n 函数中调用 t op 处的代码。每条指令都以标号标出: Ll ~ L 2 O e a f 中), T l ~ T 4 ( ma i n 中)和M l ~ M 2 ( ma i n 中)。该图的 b 部分给出了 这段代码执\nDisassembly of leaf(long y) y in¼rdi\n1 0000000000400540 \u0026lt;l e af \u0026gt; :\n2 400540: 48 8d 47 02\n3 400544: c3\n4 0000000000400545 \u0026lt;top\u0026gt;:\n历 s as s embl y of top(long x) x in¼rdi\nlea Ox2 (%rdi) , %rax L1: y+2 retq L2: Return\nCall to top from function main 9 40055b: e8 e5 ff ff ff callq 400545 \u0026lt;top\u0026gt; Ml: Call top(100) 1O 400560: 48 89 c2 rnov %rax, %rdx M2: Resume a ) 说明过程调用和返回的反汇编代码\n图 3-27 包含过 程调 用和返回的 程序的 执行细节 。使用栈来存储返回地址使得能够返回到过程中正确的位置\n指令 状态值(指令执行前) 描述 标号 PC 指令 兮r di %rax %rsp 飞 r s p Ml Ox40055b callq JOO Ox7fffffffe820 调用t op(100) Tl Ox400555 sub 100 Ox7fffffffeS18 Ox400560 进入t op T2 Ox400559 callq 95 Ox7fffffffe818 Ox400560 调用l e a f (95) LI Ox400540 lea 95 Ox7 fffffffe810 Ox40054e 进人l e a f L2 Ox400544 retq 97 Ox7 f ff f ff f e 81 0 Ox40054e 从l e a f 返回97 T3 Ox40054e add 97 Ox7f f f f f f f e 818 Ox400560 继续t op T4 Ox400551 retq 194 Ox7f f f ff ff e 818 Ox400560 从t op返回194 M2 Ox400560 rnov 194 Ox7 ff f ff ff e 820 继续ma i n b ) 示例代码的执行过程图 3-27 (续)\n行的详细 过程, ma i n 调 用 t o p ( l OO) , 然后 t o p 调用 l e a f ( 9 5 ) 。 函数 l e a f 向 t o p 返回\n97, 然后 t o p 向 ma i n 返回 1 9 4 。前面三列描述了被执行的指令, 包括指令标号、地址和指令类 型。后面四列给出了在该指令执行前程序的状态, 包括寄存器%r d i 、%r a x 和 %r s p 的内容,以及位于栈顶的值。仔细研究这张表的内容,它们说明了运行时栈在管理支持过 程调用和返回所需的存储空间中的重要作用。\nl e a f 的指令 L l 将%r a x 设 置为 9 7 , 也就是要返回的值。然后指令 L2 返回,它 从 栈中弹出 Ox 40 0 0 5 4e 。通过将 PC 设置为这个弹出的值,控 制转移回 七o p 的 T3 指令。程序成功 完成对 l e a f 的 调 用 ,返 回 到 t o p 。\n指令 T3 将%r a x 设 置为 1 9 4 , 也就是要从 t o p 返回的值。然后指令 T 4 返回,它 从 栈中弹出 Ox 4 0 0 0 5 60 , 因此将 PC 设置为 ma i n 的 M2 指令。程序成功完成对 t o p 的调用, 返回到 m釭n。可以 看到,此 时 栈 指针也恢复成了 Ox 7 f f f f f f f e 8 2 0 , 即 调 用 t o p 之 前 的 值 。\n可以看到,这种把返回地址压入栈的简单的机制能够让函数在稍后返回到程序中正确 的点。C 语言(以及大多数程序语言)标准的调用/返回机制刚好与栈提供的后进先出的内存管理方法吻合。\n讫 ]练习题 3 . 32 下面 列 出 的是 两个 函 数 f ir s t 和 l a 江 的 反 汇编 代 码 , 以 及 ma i n 函 数调用 f ir s t 的代码 :\nD 工 sas s e mbl y of last (long u, U 立 1 r% di , v in %rsi long v) 1 0000000000400540 \u0026lt;last\u0026gt;: 2 40054 0 : 48 89 f8 rnov %rdi,%rax L1 : u 400543: 48 Of af c6 irnul %rsi,%rax L2 : u• v 4 400547: c3 retq L3 : Return 釭 s ass embl y of first(long x) x in %rdi\n5 0000000000400548 \u0026lt;first\u0026gt;: 6 400548: 48 8d 77 01 lea Ox1(%rdi) , %rsi Fl : x+1 7 40054c: 48 83 ef 01 sub $0x1,%rdi F2: x-1 8 4 00550 : e8 eb ff ff ff callq 400540 \u0026lt;last\u0026gt; F3: Call last (x-1,x+1) 9 400555: f3 c3 repz retq F4: Return\n1o 400560: e8 e3 ff ff ff 11 400565: 48 89 c2\ncallq 400548 \u0026lt;first\u0026gt; M1 : Call f r工s t (10) mov %rax, %rdx M2 : Resume\n每条指令都 有 一个标 号 , 类似 于图 3- 2 7 a 。 从 ma i n 调用 丘r s 七 (1 0 ) 开始 ,到 程序返回 ma i n 时为止 , 填写 下表 记 录指令 执行 的过 程。\n指令 状态值(指令执行前) 标号 PC 指令 r% di r%s i % r ax r% s p * r% s p 描述 Ml Ox400560 ca ll q 10 Ox7fffffffe820 调用 f1rstO O) Fl F2 F3 L1 L2 L3 F4 MZ 7. 3 数据传送\n当调用一个过程时,除了要把控制传递给它并在过程返回时再传递回来之外,过程调 用还可能包括把数 据作为参数 传递, 而从 过程返回还有可能 包括返回一个值。x8 6- 64 中, 大部分 过程间 的数据传送 是通过寄 存器实现的 。例如 , 我们 已经看到无数的函数 示例 , 参数在寄存器 %r d i 、%r s i 和其他寄存 器中传递 。当过程 P 调用过程 Q 时, P 的代码必须首先把参数复制到适 当的寄存器中。类似地 , 当 Q 返回到 P 时, P 的代码 可以 访问寄存器%r a x 中的返回值。在本节中,我们更详细地探讨这些规则。\nx 8 6- 6 4 中, 可以 通过寄存特最多 传递 6 个整型(例如整数 和指针)参数。寄存器的使用是有特殊顺 序的 , 寄存器使用的名字取决千要传递的数据类型的大小, 如图 3- 28 所示。会根据参数在参数列表中的顺 序为它们分配寄 存器。可以 通过 6 4 位寄存器适 当的部分访问小于 6 4 位的参数 。例如 , 如果第一个参数是 3 2 位的 , 那么可以用%e d i 来访间它。\n操作数大小(位) 参数数扭 1 2 3 4 5 6 64 % r di % r s i % r dx 毛r c x r号 8 % r 9 32 %edi %es i %ed x %ec x %r8d r号 9d 16 %di %s i %dx %e x %r8w %r9w 8 令dil % s i l %dl %cl %r8b r皂 9b 图 3-28 传递函数参数的寄存器。寄存器是按照特殊顺序来使用的 , 而使用的 名字是 根据参数的大小来 确定的\n如果一 个函数有 大于 6 个整型参 数, 超出 6 个的部分就要通过栈来传递。假设过程 P\n调用过程 Q, 有 n 个整型 参数 , 且 n \u0026gt; 6 。那么 P 的代码分配的栈帧必须要能容纳 7 到 n\n号参数的存储空间 , 如图 3- 25 所示。要 把参数 1 ~ 6 复制到 对应的寄存 器, 把参数 7 ~ n 放\n到栈上 , 而参数 7 位于栈顶 。通过栈 传递参数时 , 所有的数 据大小都向 8 的倍数对齐。参数到位 以后, 程序就可以 执行 c a l l 指令将控 制转 移到过 程 Q 了。过程 Q 可以 通过寄存器访问参数, 有必要的 话也可以 通过栈 访问。相应地 , 如果 Q 也调用了 某个 有超过 6 个参数的函数 , 它也需要在自己的 栈帧中为超出 6 个部分的参数分配空 间, 如图 3- 25 中标号为“参数构造区”的区域所示。\n作为参数 传递的示例 , 考虑图 3- 2 9 a 所示的 C 函数 p r o c 。这个 函数有 8 个参数 , 包括字节数 不同的整数 ( 8 、4 、2 和 1) 和不同类 型的指针, 每个都是 8 字节的 。\nvoid proc(long a1, long *alp,\nint a2, int *a2p, short a3, short *a3p, char a4, char *a4p)\n{\n*a1p += a1;\n*a2p += a2;\n*a3p += a3;\n*a4p += a4;\n}\nC代码 void proc(a1, a1p, a2, a2p, a3, a3p, a4, a4p) Arguments passed as follows:\na1 in %rdi (64 bi ts)\nalp in %rsi (64 bi ts)\na2 in %edx (32 bi ts)\na2p in %rcx (64 bi ts)\na3 in %r 8 日 (16 bits)\na3pin %r9 (64 bi ts)\na4 at %rsp+8 (8 bi ts)\na4p at %rsp+16 (64 bits) proc:\nmovq addq addl addw movl addb ret\n16(%rsp), %rax\n%rdi, (%rsi)\n%edx, (%rcx)\n%r8w, (%r9) 8(%rsp), %edx\n%dl, (%rax)\nFetch a4p\n•a1p += a1\n•a2p += a2\n•a3p += a3 Fetch a4\n•a4p += a4 Return\n(64 bits)\n(64 bits)\n(32 bits)\n(16 bits)\n(8 bits)\n(8 bits)\n图 3-29\nb ) 生成的汇编代码\n有多个不同 类型参数的函数示例。参数 1 ~ 6 通过寄 存器传递 , 而参 数 7~ 8 通过 栈传递\n图 3- 2 9 6 中给出 pr o c 生成的 汇编代码。前面 6 个参数通过寄存器传递, 后面 2 个通过栈 传递 , 就像图 3-30 中画出来 的那样。可以看到, 作为过程调用的一部分, 返回地址被压 入栈中。因 而这两 个参数位千相对千栈指针距离为 8 和 16 的位置。 在这段代码中, 我们可 以看到根 据操作数的大小, 使用了 ADD 指令的不同版本: a l ( l o n g ) 使用 a d d q , a 2釭n 七)使用 a d d l , a 3 ( s h o r 七) 使用 a d d w, 而 a 4 ( c h ar ) 使用 a d d b 。请注意第 6 行的mov l 指令从内存 读入 4 字节, 而后面的 a d db 指令只使用其中的 低位一字节。\na4p\n返回地址\n16\na4 8\n。( 栈指针r% s p\n图 3-30 函数 proc 的栈帧结构。参数 a 4 和 a 4p 通过栈传递\n芦 练习题 3. 33 C 函 数 pr o c pr o b 有 4 个参数 u、a 、v 和 b , 每个参 数 要 么 是 一个 有 符号数,要么是一个指向有符号数的指针,这里的数大小不同。该函数的函数体如下:\n*U += a;\n*V += b;\nreturn sizeof(a) + sizeof(b);\n编译得到 如下 x86-64 代 码 :\nprocprob:\nmovslq %edi, %rdi addq %rdi, (%rdx)\naddb %s il, (%rcx)\nmovl $6, %eax ret\n确定 4 个参数的合 法 顺 序 和 类 型。 有 两种 正 确 答案。\n7. 4 栈上的局部存储\n到目前为止我们看到的大多数过程示例都不需要超出寄存器大小的本地存储区域。不 过有些时候,局部数据必须存放在内存中,常见的情况包括:\n寄存器不足够存放所有的本地数据。\n对一个局部变最使用地址运算符'&',因此必须能够为它产生一个地址。\n某些局部变量是数组或结构,因 此 必 须 能 够 通过数组或结构引用被访问到。在描述数组和结构分配时 , 我们会讨论这个问题。\n一般来说, 过程通过减小栈指针在栈上分配空间。分配的结果作为栈帧的一部分, 标号为 "局部变量” ,如 图 3- 25 所示。\n来看一个处理地址运算符的例子,图 3-3 l a 中给出的两个函数。函数 s wa p _ a d d 交换指 针 xp 和 yp 指向的两个值,并 返回这两个值的和。函数 c a l l er 创建到局部变量 a r g l 和 ar g 2 的指针,把 它 们传递给 s wa p_a d d 。 图 3-31 6 展 示了 c a l l er 是如何用栈帧来实现这些局 部 变最的。c a l l er 的代码开始的时候把栈指针减掉了 1 6 ; 实际上这就是在栈上分配了 16 个 字节。S 表示栈指针的值, 可以 看到这段代码计算 \u0026amp;ar g 2 为 S + 8 \u0026lt; 第 5 行), 而 \u0026amp;ar g l 为 S 。因此可以推断局部变量 ar g l 和 ar g 2 存放在栈帧中相对于栈指针偏移量为 0 和 8 的 地 方 。 当 对 s wa p _ a d d 的 调 用 完成后, c a l l er 的 代码会从栈上取出这两个值(第8 ~ 9 行),计 算它们的差,再 乘以 s wa p_ a d d 在寄存器 %r a x 中 返回的值(第10 行)。最后, 该 函数把栈指针加 16 , 释放栈帧(第11 行)。通过这个例子可以看到, 运行时栈提供了一种简单的 、在需要时分配、函数完成时释放局部存储的机制。\n如图 3-32 所示 , 函数 c a l l _pr o c 是一个更复杂的例子,说 明 x8 6-64 栈行为的一些特性。尽管这个例子有点儿长,但还是值得仔细研究。它给出了一个必须在栈上分配局部变 量存储空间的函数,同 时 还要向有 8 个参数的函数 pr o c 传递值(图3-29 ) 。该 函数创建一个栈帧 , 如图 3-33 所示。\nlong swap_add(long *XP, long *yp)\n{\nlong x = *xp; long y = *yp;\n*XP = y;\n*YP = x; return x + y;\nlong caller()\n{\nlong argl = 534; long arg2 = 1057;\nlong sum= swap_add(\u0026amp;argl, \u0026amp;arg2); long diff = argl - arg2;\nreturn sum* diff;\n}\na) swap_add和调用函数的代码\nlong caller() caller:\nb ) 调用函数生成的汇编代码\n图 3-31 过程定义和调用的示例。由于会使用地址运算符,所以调用代码必须分配一个栈帧\nlong call_proc ()\n{\nlong xl = 1; int x2 = 2; short x3 = 3; char x4 = 4;\nproc(xl, \u0026amp;xl, x2, \u0026amp;x2, x3, \u0026amp;x3, x4, \u0026amp;x4); return (x1+x2)*(x3-x4);\n}\na) swap_add 和调用函数的代码\n图 3-32 调 用 在图 3-29 中定义的函数 pr oc 的代码示 例。该代码创建了一个栈帧\nlong call_proc() call_proc:\nSet up arguments to proc\n2 subq $32, %rsp Allocate 32-byte stack frame 3 movq $1, 24(%rsp) Store 1 in \u0026amp;xl 4 movl $2, 20(%rsp) Store 2 in \u0026amp;x2 5 movw $3, 18 (%rsp) Store 3 in \u0026amp;x3 6 movb $4, 17(%rsp) Store 4 in \u0026amp;x4 7 leaq 17(%rsp), %rax Create \u0026amp;x4 8 movq %rax, 8(%rsp) Store \u0026amp;x4 as argument 8 9 movl $4, (%rsp) Store 4 as argument 7 10 leaq 18(%rsp), %r9 Pass \u0026amp;x3 as argument 6 11 movl $3, %r8d Pass 3 as argument 5 12 leaq 20(%rsp), %rcx Pass \u0026amp;x2 as argument 4 13 movl $2, 1儿 e dx Pass 2 as argument 3 14 leaq 24()儿r s p ) , )儿r s i Pass \u0026amp;xl as argument 2 15 movl $1, %edi Pass 1 as argument 1 Call proc 16 call proc Retrieve changes to memory 17 movslq 20(%rsp), %rdx Get x2 and convert to long 18 addq 24(%rsp), %rdx Compute x1+x2 19 movswl 18(%rsp), %eax Get x3 and convert to int 20 movsbl 17(%rsp), %ecx Get x4 and convert to int 21 subl %ecx, %eax Compute x3-x4 22 cltq Convert to long 23 imulq %rdx, %rax Compute (x1+x2) * (x3-x4) 24 addq $32, %rsp Deallocate stack frame 25 ret Return b ) 调用函数生成的汇编代码图 3 32 (续)\n看看 c a l l _pr o c 的汇编代码(图3- 3 2 b ) , 可以看到 代码中一 大部分(第2 ~ 1 5 行)是为调用 pr o c 做准备。其中包括为局部 变最 和函数参数建立栈 帧, 将函数参数 加载至寄 存器。如 图 3- 33 所示, 在栈上分 配局部变量 x l ~ x 4 , 它们具有不同的大小: 24~3l(xl), 20~23 (x2), 18~ 19(x3)和 1 7 ( s 3 ) 。用 l e a q 指令生成 到这些 位置的指针(第7 、10 、1 2 和 1 4 行)。参数 7 ( 值为 4 ) 和 8 ( 指向 x 4 的位置的指针)存放在栈中相对于栈指针偏移量为 0 和 8 的地方。\n当调用过 程 pr o c 时, 程序会 开始执行 图 3- 2 9 b 中的代码 。如图 3- 30 所示, 参数 7 和\n8 现在位 千相 对千栈 指针偏移量为 8 和 16 的地方 , 因为返回地址这时已 经被压入栈中了。当程序返 回 c a ll —pr o c 时, 代码会取出\n返回地址\n32\nxl\n4 个局部变量(第1 7 ~ 20 行), 并执行最终的计算。在 程序结束前, 把栈指针加 3 2 , 释放这个栈帧。\n7. 5 寄存器中的局部存储空间\nx2\n参数8 = \u0026amp;x4\n参数7\nol \u0026mdash;一 栈指针釭 s p\n寄存器组是唯一被所有过程共享的资源。\n图 3-33 函数 ca ll _yr oc 的栈帧。该栈帧包含局部\n变量 和两个要传递 给函数 proc 的参数\n虽然在给定时刻只有一个过程是活动的,我们仍然必须确保当一个过程(调用者)调用另一 个过程(被调用者)时, 被调用 者不会覆盖调用 者稍后 会使用的寄 存器值。 为此, x86-64 采用了一组统一的寄存器使用惯例,所有的过程(包括程序库)都必须遵循。\n根据惯例 , 寄存器%r b x 、%r b p 和 %r 1 2~ %r 1 5 被划分为 被调 用者保 存寄存器。当过程 P 调用过程 Q 时, Q 必须保存这些寄存器的值, 保证它们的值在 Q 返回到 P 时与 Q 被调用时是一样 的。过程 Q 保存一个寄存器的值不变, 要么就是根本不去改 变它, 要么就是把原 始值压入栈中,改变寄存器的值,然后在返回前从栈中弹出旧值。压入寄存器的值会在栈帧 中创建标 号为“保 存的寄存器” 的一部分 , 如图 3- 25 中所示。有了 这条惯 例, P 的代码就能安全地 把值存 在被调用 者保存寄存器中(当然, 要先把之前的值保存到栈上), 调用 Q, 然后继续使用寄存器中的值,不用担心值被破坏。\n所有其他的 寄存器 , 除了栈指针%r s p , 都分类为调用者保存寄存器。这就意味着任何函数都 能修改它 们。可以这样来理解“调用 者保存” 这个名 字: 过程 P 在某个此类寄存器中有局部数 据, 然后 调用过程 Q。因为 Q 可以 随意修改 这个 寄存器, 所以在调 用之前首先保存好这 个数据是 p ( 调用者)的责任。\n来看一个例子 , 图 3-34a 中的函数 P。它两次调用 Q。在第一次调用中 ,必 须保存 x 的值以备后面 使用。类似地, 在第二次调用中 , 也必须保存 Q (y ) 的值。图 3-346 中, 可以 看到 GCC 生成的代码使用了 两个被调用 者保存 寄存器:%r b p 保存 x 和%r b x 保存计算出来的\nlong P(long x, long y)\n{\nlong u = Q(y); long v = Q(x); return u + v;\n}\na ) 调用函数\nlong P(long x, long y)\nx in %rdi , y in %rsi 1 P: 2 pushq %rbp Save ¼r bp 3 pushq %rbx Sa ve r¼ bx 4 subq $8, %rsp Align stack frame 5 movq %rdi, %rbp Save x 6 rnovq %rsi, %rdi Move y to first argument 7 call Q Call Q(y) 8 rnovq %rax, %rbx Save result 9 movq %rbp, %rdi Move x to first argument 10 call Q Call Q(x) 11 addq %rbx, %rax Add saved Q(y) to Q( x) · 12 addq $8, %rsp Deallocate last part of stack 13 popq %rbx Restore %rbx 14 popq %rbp Res t or e r 妇 bp 15 ret b ) 调用函数生成的汇编代码\n图 3-34 展示被调用者保存寄存器使用的代码。在第 一次调用中 , 必须保存 x 的值 , 第二次调用中 , 必须保存 Q( y ) 的值\nQ (y ) 的值。在函数的开头 , 把这两个寄存 器的值保存到栈中(第2~ 3 行)。在第一 次调用 Q\n之前, 把参数 x 复制到 %r bp ( 第 5 行)。在第二次调用Q 之前, 把这次调用的结果复制到 %r bx\n(第8 行)。在函数的结尾 ,(第1 3 ~ 1 4 行), 把它们从栈中弹出 , 恢复这两个被调用者保存寄存器的值。注意它们的弹出顺序与压入顺序相反,说明了栈的后进先出规则。\n亡 练习题 3. 34 一个 函数 P 生成名 为 a 0~ a 7 的 局部 变 量 , 然后调 用 函 数 Q, 没 有参数。\nGCC 为 P 的 第一部分 产 生如下代码 :\nlong P(long x) x in %rdi\nP:\n2 pushq .r儿 15 3 pushq %r14 4 pushq %r13 5 pushq %r12 6 pushq %rbp 7 pushq %rbx 8 subq $24, %rsp 9 movq 。r 儿 di , %rbx 10 leaq 1(%rdi), ;r儿 15 1 1 leaq 2(%rdi), %r14 12 leaq 3(%rdi), %r13 13 leaq 4(%rdi), %r12 14 leaq 5(%rdi), %rbp 15 leaq 6(%rdi), %rax 16 movq %rax, (%rsp) 1 7 leaq 7(%rdi), %rdx 18 movq %rdx, 8(%rsp) 19 movl $0, %eax 20 call Q 确定哪些局部值存储在被调用者保存寄存器中。\n确定 哪 些局部 变量存储在栈 上。\nc. 解释 为 什 么不能把所有的局部值都 存储在被调用老保 存寄 存器 中。\n7. 6 递归过程\n前面已 经描述的寄 存器和栈 的惯例使得 x8 6- 64 过程能够递归地调用它们自身。每个过程调用 在栈中都有它自己 的私有空间 , 因此多个未完成 调用的局部变量 不会相互影响 。此外,栈的原则很自然地就提供了适当的策略,当过程被调用时分配局部存储,当返回时 释放存储 。\n图 3- 35 给出了 递归的阶乘函数的 C 代码 和生 成的汇编代码。可以 看到汇编代码使用寄存器%r b x 来保存参数 n , 先把巳有的值保存在栈上(第2 行),随 后在返回前恢复该值\n(第 11 行)。根据栈的使用特性 和寄存器保 存规则 , 可以 保证当递归调用r f a c t (n - 1 ) 返回时(第9 行), (1 ) 该 次调用的结果会保存在寄存器%r a x 中, ( 2 ) 参数 n 的值仍然 在寄存器% r b x 中。把这两个值相乘就能 得到期望的 结果。\n从这个例子我们可以 看到 , 递归调用一个函数本身 与调用其他函数是一样的。栈规则提供了一种机制, 每次 函数调用 都有它自己 私有的状态信息(保存的返回位置和被调 用者保存寄存器的值)存储空间。如果需 要, 它还可以 提供 局部变量的存储。栈分配和释放的\n规则很自然 地就与函数 调用-返回的顺序匹配。这种实现函数调用和返回的方法甚至对更复杂的 情况也适用 , 包括相 互递归调用(例如, 过程 P 词用 Q , Q 再调用 p ) 。\nlongr fa ct (l ong n)\n{\nlong result;\n辽 (n \u0026lt;= 1)\nresult= 1;\nelse\nresult= n *r fact(n-1);\nreturn result;\n}\nC代码 long rfact(long n) n in %rdi\nrfact:\npushq movq movl cmpq jle leaq call imulq\n.135:\npopq ret\n%r b x\n%rdi, %rbx\n$1, %ea x\n$1, %rdi\n. L35\n-1(%rdi), %rdi rfact\n%rbx, %rax\n%rbx\nSave %rbx\nStore n in callee-saved register Set return value = 1\nCompare n: 1\nIf \u0026lt;=, goto done Compute n-1\nCallr f act (n-1)\n加 l t i pl y result by n done :\nRestore %rbx Return\nb ) 生成的汇编代码\n图 3-35\na 练习题 3. 35\n递归的阶乘程序的代码。标准过程处理机制足够用来实现递归函数\n一个具有通用 结构的 C 函 数如下:\nlong rfun(unsigned long x) { if ( - - \u0026mdash;- - )\nreturn - '\nunsigned long nx = ; longr v = rfun(nx);\nreturn \u0026ndash; '\n}\nGCC 产 生 如下 汇 编代 码 :\nlong rfun (uns i gned long x) x in %rdi\nrfun:\npushq movq movl testq\n%rbx\n%rdi, %rbx\n$0, %eax\n%rdi, %rdi\n6 je .12 7 shrq $2, %rdi 8 call rfun 9 addq %rbx, %rax 10 .12: 11 popq %rbx 12 ret r f u n 存储在被调用 者保 存寄 存器%r b x 中的值 是什 么? 填写上述 C 代码 中缺 失的 表达 式。 3. 8 数组分配和访问\nC 语言中的数组是一种将标量数据聚集成更大数据类型的方式。 C 语言实现数组 的方式非常简单 , 因此很容易 翻译成机器代码。 C 语言的一个不同寻常的 特点是可以产生指向数组中元素的指针,并对这些指针进行运算。在机器代码中,这些指针会被翻译成地址计算。\n优化编译器非 常善于简化数组索引 所使用的 地址计算 。不过这使 得 C 代码和它到机器代码的翻译之间的对应关系有些难以理解。\n3. 8. 1 基本原则\n对于数据类型 T 和整型常数 N , 声明如下:\nT A[N];\n起始位 置表示 为 环。这个声明有两个效果。首先 , 它在内存中分配一个 L • N 字节的连续\n区域, 这里 L 是数据类型 T 的大小(单位为字节)。其次 , 它引入了标识符 A , 可以用 A 来作为指向 数组开头的 指针, 这个指针的值就是 X A 。 可以 用 O~ N -1 的整数索引来访问该数\n组元素。数组元 素 z 会被存放 在地址 为 X A +L. i 的地方。作为示例,让我们来看看下面这样的声明:\nchar A[12];\nchar *B[8];\nint C [6]; double *D[5];\n这些声明会产生带下列参数的数组:\n数组 元素大小 总的大小 起始地址 元素 t A B C D 1 8 4 8 12 64 24 40 x. Xa Xe 工·o 乓卢- l xs +, 8 xc+4, X 。+ B, 数组 A 由 1 2 个单字节 ( c h ar ) 元素组成 。数组 C 由 6 个整数组成 , 每个需 要 8 个字节。\nB 和 D 都是指针数组 , 因此每个数组元 素都是 8 个字节。\nx8 6- 64 的内存引用指令可以用来简化数组访问。例如, 假设 E 是一个 i n t 型的数组, 而我们 想计算 E [i], 在此, E 的地址存放在寄存器%r d x 中, 而 l 存放在寄存器%r c x 中。然后,指令\nmovl (%rdx, %rcx, 4) , i儿e ax\n会执行地址计算 XE+ 4 i , 读这个内 存位置的值, 并将结果存放到寄 存器%e a x 中。允许的\n伸缩因子 1 、2 、4 和 8 覆盖了所有基本简单 数据类型的 大小。\n江 练习题 3. 36 考虑下面的声明:\nshort 8[7];\nshort *T [3] ; short **U [6] ; int V[8]; double *W[4];\n填写下表 , 描述每个 数 组的 元 素大小 、整个 数 组的 大小 以及 元 素 t 的地 址:\n数组 元素大小 整个数组的大小 起始地址 元素 t s 工s T .rr u Xu V .rv 付 .:rw 3. 8. 2 指针运算\nC 语言允许对指针进行 运算 , 而计算出来的 值会根据该 指针引 用的数据类型的 大小 进行伸缩。也 就是说 , 如果 p 是一个指向类型 为 T 的数据的指针, p 的值为 芬, 那么表达式p+ i 的值为 丐+ L • i, 这里 L 是数据类型 T 的大小。\n单操作数操作符`矿和\u0026rsquo;*\u0026lsquo;可以产生指针和间接引用指针。也就是,对千一个表示某 个对象的 表达式 Ex pr , \u0026amp;Ex pr 是给出该对象地址的一个指针。对于一 个表示地址的表达式 AExpr , *AEx pr 给出该 地址处的 值。因此 , 表达式 Exp r 与* \u0026amp;Ex pr 是等价的。可以 对数组和指针应用数组下 标操作。数组引 用 A [ i ) 等同千表 达式 * (A+ i ) 。 它计算第 z 个数组元素的地址,然后访问这个内存位置。\n扩展一下前面的例子, 假设整型数组 E 的起始地址和整数索引 z 分别存放在寄存器\n%r dx 和%r c x 中。下 面是一些 与 E 有关的表达式 。我们还给出 了每个 表达式 的汇编代码实现, 结果存放在寄 存器 %e a x ( 如果是 数据)或寄存器 %r a x ( 如果是指针)中。\n表达式 类型 值 汇编代码 E int* 工E movq % r dx , % r a x E[O] int M[ .r r.] mo v l ( % r dx ) , 另r a x E [i ) int M 妇 + 4,] movl ( % r dx, 毛 r c x , 4 ) , %e a x \u0026amp;E[2] int* 工 E+ 8 leaq 8( % r dx ) , 毛 r a x E+i-1 int* 工E 十如一4 l e aq - 4 ( 毛r d x, 毛r c x, 4 ) , %r a x * (E+i-3) int M伍 + , 4 - 1 2] mo v l - 1 2 ( %r d x, %r c x , 4 ) , %e a x \u0026amp;E[i}-E long l movq %r c x , % r a x 在这些 例子中 , 可以 看到返 回数组值的操作类型为 i n t , 因此涉及 4 字节操作(例如\nmov l ) 和寄存器(例如%e a x ) 。 那些返回指针的操作类型为 i n t * , 因此涉及 8 字节操作\n(例如 l e a q ) 和寄存 器(例如%r a x ) 。最后一个例子表明 可以 计算同一个数据结构中的两个指针之差 , 结果的数据类型为 l o ng , 值等于两个地址之差除以该数据类型的大小。\n江 练习题 3. 37 假设短 整 型 数 组 s 的 地 址 X s 和 整 数 索引 1 分 别 存 放 在 寄 存 器%r d x 和\n% r c x 中。 对下 面每个表 达 式, 给出 它的 类型 、值的表达 式和 汇编代码 实现。 如果 结果\n是指针 的话 , 要保 存 在 寄 存 器%r a x 中 , 如果数 据 类 型 为 s h or t , 就保存在寄存器元素 %a x 中。\n表达式 类型 值 汇编代码 S+ 1 S [ 3] \u0026amp;S [ i j S [4 *i + l] S+ i-5 3. 8. 3 嵌套的数组\n当我们创建 数组的数组时 , 数组分 配和引 用的 一般原则也是 成立的。例如 , 声明\nint A [ 5 ] [ 3 ] ;\n等价于下 面的声明\ntypedef int r ow3 _t [ 3) ; row3_t A[5 ] ;\n数据类型r o w3 —t 被定义为一个 3 个整数的 数组。数组 A 包含 5 个这样的元素, 每个 元素需要 1 2 个字节来 存储 3 个整数 。整个数 组的大小就是 4 X 5 X 3 = 6 0 字节。\n数组 A 还可以 被看成一个 5 行 3 列的二维数组, 用 A [ O ] [ 0 ] 到 A [ 4 ) [ 2 ] 来引用。数组元素在内存中按照“行优先” 的顺序排列 , 意味着第 0 行的所有元素, 可以 写作 A [OJ, 后面跟着第 1 行的所有元 素 ( A [l]), 以此类推, 如图 3- 3 6 所示。\n这种 排列顺 序是嵌 套声明的结果。将 A 看作一个有 5 个元素的数组 , 每个元素都 是 3 个 i n t 的数组, 首先是 A [OJ, 然后 是 A [ l ] , 以此类推。\n要访问多维数组的 元素 , 编译 器会以数组 起始为基地址,\n(可能需要经过伸缩的)偏移量为索引 , 产生计算期望的元素的偏移量, 然后使用某种 MO Y 指令。通常来说, 对 千一个声明如下的数组 :\nT D[R ] [CJ;\n它的数组元素 D [ i l [ j J 的内存地址 为\n\u0026amp; D [ i ] [ j ] = 工 。 十 L \u0026lt;C • i + j ) (3.1)\n这里 , L 是数据类型 T 以字节为单 位的大小。作为一个示例,\n图 3-36 按照行优先顺序\n存储的数组元素\n考虑前 面定 义的 5 X 3 的整型数组 A。假设 石、1 和)分别 在寄存器%r d i 、%r s i 和% r d x 中。然后 , 可以用下面的 代码将数组元 素 A [i] [ j l 复制到 寄存器%e a x 中:\nA in % 过 i\u0026rsquo; 工 i n %rsi, and j i n %rdx\nl e a q ( %r s i , %r s i , 2 ) , %r ax\nl e a q ( %r d i , %r a x , 4 ) , %rax mov l (%rax,%rdx,4), %eax\nCompute 31 Compute xA + 12,\nRead from M[xA + 12i + 4 八\n正如可以 看到的那样 , 这段代码计算 元素的地址为 XA + 1 2 i + 4j = x A + 4C 3 i 十 j )\u0026rsquo; 使用了\nx8 6 - 6 4 地址运算的 伸缩和加法特性 。\n诠 练 习 题 3. 38\nlong P [M] [NJ ;\nlong Q [NJ [M] ;\n考 虑下 面的 源 代码 , 其 中 M 和 N 是 用 # d e f i ne 声明 的 常数 :\nlong sum_element(long i, long j) { return P [i] [j] + Q [j] [i] ;\n}\n在编译这个程 序 中 , GCC 产 生 如下 汇编 代 码 :\nlong sum_element(long i , l ong j) i in %rdi , j in %rsi sum_element:\nleaq O (, %rdi, 8), %rdx subq %rdi, %rdx\naddq %rsi, %rdx\nleaq (%rsi,%rsi,4), %rax addq %rax, %rdi\nmovq Q (, %rdi,8), %rax addq P(, %rdx,8), %rax ret\n运用 逆 向 工程 技 能 , 根据这 段 汇编 代 码 , 确定 M 和 N 的值。\n3. 8. 4 定长数组\nC 语 言 编译器能够优化定长多维数组上的操作代码。这里我们展示优化等级设置为-\n01 时 GCC 采用的一些优化。假设我们用如下方式将数据类型 f i x _ ma tr i x 声 明 为 16 X 16\n的整型数组:\n#define N 16\n_ t ypedef int fix_matrix [NJ [NJ ;\n(这个例子说明了一个很好的编码习惯。当程序要用一个常数作为数组的维度或者缓 冲区的大小时,最好通过# d e f i ne 声明将这个常数与一个名字联 系起来, 然后 在后面一直使用这个名字代替常数的数值。这样一来,如果需要修改这个值,只用简单地修改这个# def i ne 声明就可以 了。)图3-3 7a 中的代码计算矩阵 A 和 B 乘积的元素 i , k, 即 A 的 行 t 和\nB 的列 k 的 内 积 。 G CC 产生的代码(我们再反 汇编成 C )\u0026rsquo; 如图 3-3 7 b 中函数 f i x—pr o d _\ne i e _o p t 所示。这段代码包含很多聪明的优化。它去掉了整数索引 j , 并把所有的数组弓I 用都转换 成了指针间接引用,其 中 包 括(1 ) 生成一个指针,命 名为 Ap t r , 指向 A 的 行 1 中连续的元素; ( 2 ) 生成一个指针,命 名为 Bp tr , 指向 B 的 列 k 中连续的元素; ( 3 ) 生成一个指 针,命 名为 Be nd , 当需要终止该循环时, 它 会等于 Bp tr 的 值。Ap tr 的 初始值是 A的行 1 的 第一个元素的地址, 由 C 表达式 \u0026amp;A [ i ) [ O J 给出。Bp tr 的 初始值是 B 的列 k 的 第一个元素的地址, 由 C 表达式 \u0026amp;B [ O J [ k l 给出。Be n d 的 值是 假想中 B 的列)的第 C n + l) 个元素的 地址, 由 C 表达式 \u0026amp;B [NJ [ k ) 给出。\n下面给出 的是 GCC 为函数 f i x _ pr o d _ e l e 生成的这个循环的实际汇编代码。我们看到 4 个寄存器的使用如下: %e a x 保 存 r e s u l 七,%r 土 保存 Ap t r , % r c x 保存 Bp tr , 而%r s i 保 存 Be nd 。\nI* Compute i,k of fixed matrix product *I\nint fix_prod_ele (fix_matrix A, fix_matrix B, long i, long k) { long j;\nint result= O;\nfor (j = 0; j \u0026lt; N; j++)\nresult += A [i] [j] * B [j] [k] ; return result;\na ) 原始的C代码\nI* Compute i,k of fixed matrix product *I\nint fix_prod_ele_opt(fix_matrix A, fix_matrix B, long i, long k) {\nint *Aptr = \u0026amp;A[i] [OJ; I* Points to elements in row i of A *I\nint *Bptr = \u0026amp;B[O] [k]; I* Points to elements in column k of B *I\n5 int *Bend= \u0026amp;B[N] [k]; I* Marks stopping point for Bptr *I int result= O;\ndo {\nresult+= *Aptr * *Bptr; Aptr ++;\nBptr += N;\nI* No need for initial test *I I* Add next product to sum *I I* Move Aptr to next column *I I* Move Bptr to next row *I\n} while (Bptr != Bend);\n12 return result;\n13 }\nI* Test for stopping point *I\n优化过的C代码\n图 3-37 原 始的和优化过的 代码 ,该 代码计算定 长数组的 矩阵乘 积的元素 i , k。\n编译器会自动完成这些优化\nint fix_prod_ele_opt (fix_matrix A , fix_matrix B, long i, long k)\nA in %rdi, Bin %rsi, i in %r dx , kin %rcx fix_prod_ele:\n2 salq $6, %rdx Compute 64 * 1.\n3 addq %rdx, %rdi Compute Aptr = xA + 64i = \u0026amp;A [i] [OJ\n4 leaq (%rsi, %rcx, 4) , %rcx Compute Bptr = x8 + 4k = \u0026amp;B[OJ [k]\n5 leaq 1024(%rcx), %rsi Compute Bend = x8 + 4k +1024 = \u0026amp;B[N] [k]\n6 movl $0, %eax Set result = 0\n7 .17: l oop :\nmovl (%rdi), %edx imull (%rcx), %edx addl %edx, %eax\naddq $4, %rdi\naddq $64, %rcx\ncmpq %rsi, %rcx\njne .17\nrep; ret\nRead *Aptr\nMul t i pl y by *Bptr Add to result Increment Aptr ++ Increment Bptr += N\nCompare Bptr : Bend If!=, got o loop Return\n; 练习题 3. 39 利用 等式 3. 1 来解释图 3-376 的 C 代码中 Ap tr 、 Bptr 和 Be nd 的初始值计算(第3~ 5 行)是如何正确反映 f i x_p ro d—e l e 的 汇编代码中它们的计算 (第 3~ 5 行)的。\n诠 练习题 3. 40 下 面的 C 代码将定 长数 组的对 角 线上的元 素设 置 为 v a l :\nI* Set all diagonal elements to val *I\nvoid fix_set_diag(fix_matrix A, int val) { long i;\nfor (i = 0; i \u0026lt; N; i ++)\nA[i] [i] = val;\n}\n当以优 化等级 - 0 1 编译 时 , GC C 产 生如下 汇编代 码 :\nfix_set_diag:\nvoi d fix_set_diag(fix_matrix A, int val)\nA 立 1 %rdi , val in¼rsi movl $0, %eax\n.113:\nmovl %esi, (%rdi, %rax) addq $68, %rax\ncmpq $1088, %rax\njne .113\nrep; ret\n创建 一个 C 代码 程序 f i x _ s 包 _ d i a g _ o p t , 它使用类似于这段汇编代码中所使用\n的优化 , 风格 与图 3-37 b 中 的 代 码 一致。 使用 含有 参 数 N 的 表 达 式 , 而不 是整 数 常量,使得 如果 重新定 义 了 N , 你的代码仍能够正确地工作。\n3. 8. 5 变长数组\n历史上 , C 语言只支持大小 在编译时就能确定的多维数组(对第一维可能有些例外)。程序员需要变长数组时 不得不用 m a ll o c 或 call o c 这样的函数为这些 数组分配存储 空间 , 而 且不得 不显式地 编码,用 行优先索引将多维数组映射到一维数组 , 如公式( 3. 1) 所示。ISO\nC吁引入了一种功能 ,允 许数组的维度是表达式 , 在数组被分配的时候才计算出来。在变长数组的 C 版本中 , 我们可以将一个数组声明如下 :\nint A [exprl] [expr2]\n它可以作为 一个局部变量 , 也可以作为一个 函数的参数 , 然后在遇到 这个声明的时候, 通过对 表达式 ex p r l 和 ex pr 2 求值来确定数组的维度。因此, 例如要访问 n X n 数组的元素i,, j\u0026rsquo; 我们可以 写一个如下的函数:\nint var_ele(long n, int A[n] [n], long i, long j) { return A [i] [j] ;\n参数 n 必须在参数A[n ] [n ] 之前, 这样函数就可以在遇到这个数组的时候计算出数组的维度。\nGCC 为这个引 用函数产生的代码如下所示 :\nmt vra _el e (long n, int A [n] [n], long i, long j )\nn in¼rdi, A in¼rsi, i in¼rdx, j in¼rcx var_ele:\nimulq\n%rdx, %rdi\nCompute n · 1\nleaq\nCir儿\ns i , %r d i , 4 ) , %rax\nCompute xA + 4(n · i)\nmovl ret\n(%rax,%rcx,4), %eax\nRead from M[ 入A + 4(11 · ,) + 4 八\n正如注释所示, 这段代码计算元素 i\u0026rsquo; j 的 地址为 工A + 4 ( n · i ) + 4j = xA + 4 ( n · i + 户。这个地址的计算类似千定 长数组的地址计算(参见 3. 8. 3 节), 不同点在千 1) 由于增加了参数\nn, 寄存器的使用变化了; 2 ) 用了乘法指令来计算 n · i( 第 2 行), 而不是用 l e a q 指令来计\n算 3i。因此引用变长数组只需要对定长数组做一点儿概括。动态的版本必须用乘法指令对\nt 伸缩 n 倍 , 而不能用一系列的移位和加法。在一些处理器中,乘 法会招致严重的性能处罚 , 但是在这种情况中无可避免。\n在一个循环中引用变长数组时,编译器常常可以利用访问模式的规律性来优化索引的 计算。例如, 图 3-38a 给出 的 C 代码, 它 计 算 两个 n X n 矩阵 A 和 B 乘积的元素 i , k 。\nGCC 产生的汇编代码, 我们再重新变为 C 代码(图3-38b) 。这个代码与固定大小数组的优化代码(图3-37 ) 风格不同, 不过这更多的是编译器选择的结果 , 而不是两个函数有什么根本的不同造成的。图 3-38 b 的 代码保留了循环变量 j\u0026rsquo; 用 以 判定循环是否结束和作为到 A 的行 1 的元 素组成的数组的索引。\nI* Compute i,k of variable matrix product *I\n2 int var_prod_ele(long n, int A[n] [n], int B[n] [n], long i, long k) { 3 long j; 4 int result= O; 5 6 for (j = 0; j \u0026lt; n; j++) 7 result += A[i] [j] * B[j] [k]; 8 9 return result; 10 } a ) 原始的C代码 I* Compute i,k of variable matrix product *I\nint var_prod_ele_opt(long n, int A[n] [n], int B[n] [n], long i, long k) { int *Arow = A[i];\nint *Bptr = \u0026amp;B[O] [k]; int result= O;\nlong j;\nfor (j = O; j \u0026lt; n; j++) { result+= Arow[j] * *Bptr; Bptr += n;\n}\nreturn result;\n}\nb) 优化后的C代码\n图 3\u0026lt;l8 计算变长数组的矩 阵乘积的 元素 i , k 的原始代码 和优化后的 代码。编译 器自动执行 这些优化\n下 面是 v ar —pr o d —e l e 的 循 环的汇编代码:\nRegs工\nt re\ns : n i n r¼ di , Arow in¼rsi, Bptr in¼rcx 4n in %r9, result in¼eax, j in¼edx\n.L24: l oop :\nmovl Cr 儿 s i , %r dx , 4 ) , %r8d imull (%rcx), %r8d\nRead Arow[j]\nMul t i p l y by•Bptr\naddl %r8d, %eax Add t o result addq $1, %rdx j++ addq %r9, %rcx Bptr += n cmpq %rdi, %rdx Compare j:n jne .L24 If!=, goto loop 我们看到 程序既使 用了伸缩过的值 4 n ( 寄存器%r 9) 来增加 Bp tr , 也使用了 n 的值(寄存器 %r 主 )来检查循环的边界。C 代码中并没有体现出需要这两个 值, 但是由于指针运算的伸缩,才使用了这两个值。\n可以看到 , 如果允许使用优化, GCC 能够识别出程序访问多维数组的元素的步长。然后生 成的代码会避免 直接应 用等式 ( 3. 1) 会导致的乘法。不论生成基于指针的 代码(图3- 37b)还是基于数组的代码(图 3-38b) , 这些优化都能显著提高程序的性能。\n9 异质的数据结构\nC 语言提供了 两种将不同类型的对象组合到一起创建数据类型的机制: 结 构 ( s t ru c­ ture) , 用关键字 s 七r u 吐 来声明, 将多个对象 集合到一个单位中; 联合 ( u nio n ) , 用关键字 un i o n 来声明,允 许用几种不同的 类型来引 用一个对象。\n3. 9. 1 结构\nC 语言的 s tr u c t 声明创建一个数据类型 , 将可能不同 类型的 对象聚合到一 个对象 中。用名字来引用结构的各个组成部分。类似于数组的实现,结构的所有组成部分都存放在内 存中一段连续 的区域内, 而指向结 构的指针就是结 构第一个字节的 地址。编译器维护关于每个结构类型的信息 , 指示每个字段(如 Id ) 的字节偏移。它以 这些偏移作为内 存引用指令中的位移 , 从而产生 对结构元素的引用。\n, 3 将一个对 象表 示为s 七r u e 七\n·c语言提供的 s tr uc t 数据类型的构造函数(cons tru ctor) 与 C++ 和 Java 的对象最为接近。\n它允许程序员在一个数据结构中保存关于某个实体的信息,并用名字来引用这些信息。 例如,一个图形程序可能要用结构来表示一个长方形:\nstruct rect {\nlong llx; I*X coordinate of lower-left corner *I\nlong lly; I*Y coordinate of lower-left corner *I\nunsigned long wi dt h ; I* Width (in pixels) */\nunsigned long height; I* Height (in pixels) */\nunsigned color; I* Coding of color */\n};\n可以声明 一个 s tr u c t r e c t 类型的 变量r , 并将它的字段值设置如下:\nstruct rect r; r.llx = r.lly = O; r.color = OxFFOOFF; r.width = 10;\nr.height = 20;\n这里表达式 r . l l x 就会选择结构r 的 l l x 字段。\n另外,我们可以在一条语句中既声明变量又初始化它的宇段:\nstruct rectr = { 0, 0, 10, 20 , OxFFOOFF } ;\n将指向结构的指针从一个地方传递到另一个地方,而不是复制它们,这是很常见的。例如,下面的函数计算长方形的面积,这里,传递给函数的就是一个指向长方形s tr uc 七的指针:\nlong area(struct rect *rp) {\nreturn (*rp).width * (*rp).height;\n}\n表达式 (*r p ) . wi d t h 间接 引 用 了 这个指针, 并且 选取 所得 结构的 wi d t h 字段 。 这里必须要 用括 号, 因 为 编译器会 将 表 达式 *r p . wi d t h 解释 为 * (rp.width), 而这是非法的 。间接 引 用和字段 选取结合起 来使 用非 常常见,以 至 于 C 语言提供了一种替代的表示法-> 。 即 r p - \u0026gt; wi d t h 等价于表 达式 (*r p ) . wi d t h 。 例如 , 我们可以 写一个函数, 它将 一个长方形顺时针旋转 90 度 :\nvoid rotate_left(struct rect *rp) { I* Exchange width and height *I long t = rp-\u0026gt;height;\nrp-\u0026gt;height = rp-\u0026gt;width; rp-\u0026gt;width = t;\nI* Shift to new lower-left corner *I rp-\u0026gt;llx -= t;\n}\nC++ 和 J ava 的对象比 C 语言中的 结构要复杂精 细得 多 , 因 为 它们将一组可以 被调用 来执行计算的方 法与一个对象联 系起 来。在 C 语言中, 我们可以 简 单地把这些 方 法写成普通函数 , 就像上面所示的 函数 ar e a 和r o七a t e —l e f t 。\n让 我们来看看这样一个例子,考 虑 下 面这样的结构声明 :\nstruct rec { inti; int j; inta[2]; int *p;\n};\n这个结构包括4 个 字段: 两个 4 字节 i n t 、一个由两个类型为 i n t 的元素组成的数组和一个 8 字节整型指针,总 共 是 24 个字节:\n偏移 0\n内容 [_二\na [OJ a[l]\n16 24\n可以观察到,数 组 a 是嵌入到这个结构中的。上图中顶部的数字给出的是各个字段相对于结构开始处的字节偏移。\n为了访问结构的字段,编译器产生的代码要将结构的地址加上适当的偏移。例如,假 设 s tr uc t r e c * 类型的变最r 放在寄存器%r 生 中 。 那 么 下 面的代码将元素r - \u0026gt;i 复制到元 素r - \u0026gt; j :\nRegsi\nt esr\n:r in r% d 工\nmovl (%rdi), %eax movl %eax, 4(%rdi)\nGetr - \u0026gt;1. Store in r-\u0026gt;j\n因为字段 1 的偏移盘为 o , 所以这个字段的地址就是r 的值。为了存储到字段 j , 代码要\n将 r 的地址加上偏移量 4 。\n要产生一个指向结构内部对象的指针,我们只需将结构的地址加上该字段的偏移量。 例如,只用 加上偏移量 8 + 4 X l = l 2 , 就可以 得 到指针 \u0026amp; (r - \u0026gt;a [ l ] ) 。 对于在寄存器%r d i 中的指针 r 和在寄存器%r s i 中 的 长整数变量 i , 我们可以用一条指令产生指针\u0026amp; (r-\u0026gt;a [i ]) 的值:\nRegisters:r in %rdi, i %sr 工\nleaq 8(%rdi,%rsi,4), %rax Set %rax to \u0026amp;r-\u0026gt;a [i)\n最后举一个例子,下面的代码实现的是语句:\nr-\u0026gt;p = \u0026amp;r-\u0026gt;a[r-\u0026gt;i + r-\u0026gt;j];\n开始 时 r 在寄存器%r 土 中 :\nRegisters:r in %rd1.\nmovl addl cltq 4(%rdi), %eax (%rdi), %eax Get r-\u0026gt;J Add r-\u0026gt;i Extend to 8 bytes leaq movq 8(%rdi,%rax,4), %rax, 16(%rdi) %rax Compute \u0026amp;r-\u0026gt;a[r-\u0026gt;i + r-\u0026gt;j] Store in r-\u0026gt;p 综上所述,结构的各个字段的选取完全是在编译时处理的。机器代码不包含关于字段声明或字段名字的信息。\n讫 练习题 3. 41 考虑下面的结构声明:\nstruct prob { int *p; struct {\nint x; int y;\n} s;\nstruct prob *next;\n};\n这个声明说明一个结构可以嵌套在另一个结构中,就像数组可以嵌套在结构中、数组可以嵌套在数组中一样。\n下面的过程(省略了某些表达式)对这个结构进行操作:\nvoid sp_init(struct prob *sp) { sp-\u0026gt;s.x = ,\nsp-\u0026gt;p = ,\nsp-\u0026gt;next = ,\n}\n下列字段的偏移量是多少(以字节为单位)?\np: s.x:\ns.y:\nnext:\n这个结构总共需要多少字节? 编译器为 s p _ i 汇 t 的 主体产 生 的 汇编 代码 如下 : void sp_init(struct prob *sp) sp in %rdi\ns p_i ni t :\nmovl movl leaq movq movq ret\n12(%rdi), %eax\n%eax, 8 (%r d沁 8(%rdi), %rax\n%rax, (%rdi)\n%rdi, 16(%rdi)\n根据这 些 信息 , 填写 s p _ i 工 t 代码 中缺 失的表达 式 。\n练习题 3. 42 下 面 的代 码 给 出 了 类 型 ELE 的结构声 明 以及 函 数 f u n 的原 型:\nstruct ELE {\nlong v; struct ELE *p;\n};\nlong fun(struct ELE *ptr);\n当 编译 f u n 的代码 时 , GCC 会 产 生 如下 汇 编代码 :\nlong f un (s rt ptr in %rdi fun:\nmovl jmp\n.L3:\naddq movq\n.L2:\ntestq jne\nuct ELE•ptr)\n$0, %eax\n.L2\n(%rdi), %rax 8(%rdi), %rdi\n%rdi, %rdi\n.L3\nrep; ret\n利 用 逆 向 工 程 技 巧 写 出 f u n 的 C 代码 。 描述这个结 构 实 现的 数 据结 构 以 及 f u n 执行的操 作。 3. 9. 2 联合\n联合提供了一种方式,能 够 规避 C 语言的类型系统 , 允 许 以 多 种 类型来引用一个对象 。 联合声明的语法与结构的语法一样,只 不过语义相差比较大。它们是用不同的字段来引 用 相同的内存块。\n考虑下面的声明:\nstruct S3{\nchar c; int i[2]; double v;\n};\nunion U3 {\nchar c; int i [2]; double v;\n};\n在一台 x86- 64 Linux 机器上编译时 , 字段的偏移最、数据类型 S3 和 U3 的完整大小如下:\n类型 C V 大小\n。 # (稍后会解 释 S3 中 l 的偏移最为什么是 4 而不是 1\u0026rsquo; 以 及为什么 v 的偏移量是 16 而不是 9 或 12 。)对千类型 un i o n U3 * 的 指 针 p , p-\u0026gt; C 、p - \u0026gt; i [ O ] 和 p - \u0026gt; V 引 用 的 都是数据结构的起始位 置。还可以 观察到, 一 个 联合的总的大小等于它最大字段的大小。\n在一些 下上文中 , 联合十分 有用。但是,它 也 能 引 起一些讨厌的错误, 因 为它们绕过了 C 语言类型系统提供的安全措施。一种应用情况是, 我们事先知道对一个数据结构中的两个不同字段的使用是互斥的,那么将这两个字段声明为联合的一部分,而不是结构的一 部分 , 会减小 分配空间的总量。\n例如, 假设我们想实现一个二叉树的数据结构, 每个叶子节点都有两个 doub l e 类型的数据值 , 而每个内部节点都有指向两个孩子节点的指针, 但是没有数据。如果声明如下:\nstruct node_s {\nstruct node_s *left; struct node_s *right; double data[2];\n};\n那么每个 节点需要 32 个字节 , 每种类型的节点都要浪费一半的字节。相反, 如 果 我们如下声明一个节点 :\nunion node_u { struct {\nunion node_u *left; union node_u *right;\n} internal; double data[2];\n} ;\n那么 , 每个 节点就只需要 1 6 个字节。如果 n 是一个指针, 指向 u n i o n no d e _ u *类型的节点, 我们 用 n - \u0026gt; d a 七a [ 0 ] 和 n - \u0026gt; d a t a [ l ] 来引用叶子节点的数据, 而用 n - \u0026gt; internal.\ntypedef enurn { N_LEAF, N_INTERNAL} nodetype_t; struct node_t {\nnodetype_t type; union {\nstruct {\nstruct node_t *left; struct node_t *right;\n} internal; double data[2];\n} info;\n};\n这个结构总共需 要 24 个字节: t ype 是 4 个字节 , i n fo . i n t er na l . l e f t 和 i nfo . i 阰 e r na l . 豆 g h t 各要 8 个字节, 或者是 i n f o . d a t a 要 1 6 个字节。我们后面很 快会谈到, 在字段七yp e 和联合的元 素之间需 要 4 个字节的 填充, 所以 整个 结构大小 为 4 + 4 + 1 6 = 24 。在这种情况中,相对于给代码造成的麻烦,使用联合带来的节省是很小的。对于有较多字段的 数据结构,这样的节省会更加吸引人。\n联合还可以用来访问不同数据类型的位模式。例如,假设我们使用简单的强制类型转换将一个 d o ub l e 类型的值 d 转换为 u ns i g ne d l o ng 类型的值 U :\nunsigned long u = (unsigned long) d;\n值 u 会是 d 的整数表示。除 了 d 的值为 0 . 0 的情况以外, u 的位表示会与 d 的很不一样。再看下面这段 代码 , 从一个 d o ub l e 产生一个 u ns i g ne d l o ng 类型的值:\nunsigned long double2bits(double d) { union {\ndoubled; unsigned long u;\n} temp; temp.d = d;\nreturn temp.u;\n};\n在这段代码中,我们以一种数据类型来存储联合中的参数,又以另一种数据类型来访 问它。结果会是 u 具有和 d 一样的 位表示, 包括符号位字段 、指数和尾数 , 如 3. 11 节中描述的那样。 u 的数值与 d 的数值没有任何关 系, 除了 d 等于 0. 0 的 情况。\n当用联合来将各种不同大小的数据类型结合到一起时,字节顺序问题就变得很重要 了。例如, 假设我们写了一 个过程, 它以两个 4 字节 的 u ns i g ne d 的位模 式, 创建一个 8 字节的 d o ub l e :\ndouble uu2double(unsigned i.ordO, unsigned i.ord1)\n{\nunion {\ndoubled; unsigned u[2];\n} temp;\ntemp.u[O] = wor dO ; temp.u[1] = word1; return temp.d;\n}\n在 x86-64 这样的小端法 机器上 , 参数 wor d O 是 d 的低位 4 个字节, 而 wo r d l 是高位\n4 个字节 。在大端法机器上 , 这两个参数的 角色刚好相反 。\n; 练习题 3. 43 假设 给你个任 务, 检查 一下 C 编译 器 为 结 构 和联 合 的 访 问 产 生正 确的代码。你写了下面的结构声明:\ntypedef union { struct {\nlong u;\nshort v;\nchar w;\n} t1;\nstruct {\nint a[2]; char *p;\n} t2;\n} u_type;\n你写 了一 组具 有下 面这种形 式的 函数 :\nvoid get (u_type *up, type *dest) {\n*dest = expr;\n}\n这组函数有不 一样的 访问 表达 式 ex p r , 而且 根据 ex p r 的 类 型 来设 置目 的 数 据 类 型 t y p e 。然后再检查编译这些函数时产生的代码,看看它们是否与你预期的一样。\n假设在这 些函数 中 , u p 和 d e s t 分别被 加 载 到寄 存器 %r d i 和 %r s i 中。 填 写 下表 中的数据类 型 ty p e , 并用 1 ~ 3 条指令 序列来计 算表达 式 , 并将结果 存储到 d e s 七 中。\nexpr type 代码 up - \u0026gt;t l . u long movq ( %r d 切 ,r% a x movq r% a x, ( % r s i ) up - \u0026gt;t l . v \u0026amp;up-\u0026gt;tl. w up-\u0026gt;t2.a up-\u0026gt;t2.a[up-\u0026gt;tl.u) *up-\u0026gt;t2.p . 3 数据对齐\n许多计算 机系统对基本数 据类型的合法地 址做出了一些限制, 要求某种 类型对象的地址必须是某个值 K ( 通常是 2、 4 或 8) 的倍数。这种对齐限 制简化了形成处 理器和内存 系统之间接口 的硬件设 计。例如, 假设一个处理器总是从内存中取 8 个字节, 则地址必须为 8 的倍数 。如果我们能保证 将所有的 d o u b l e 类型数据的地址对齐成 8 的倍数, 那么就可以用一个内存操作来读 或者写值 了。否则, 我们 可能需 要执行 两次内存访问, 因为对象可能被分放在 两个 8 字节内存 块中。\n无论数 据是否对齐, x8 6- 64 硬件都能正确工作。不过, I n t e l 还是建议要对齐数据以提高内存系统的性能 。对齐原则是 任何 K 字节的基本对象的地址必须是 K 的倍数。可以看到这条原则会得到如下对齐:\n确保 每种数 据类型都是 按照指定 方式来组织 和分配, 即每种 类型的对象都满足它的 对齐限制, 就可保证实施对 齐。编译 器在汇编代码中放入命令, 指明全局数据所需 的对齐。例如, 3. 6. 8 节开始的跳转 表的 汇编代码声明 在第 2 行包含下 面这样 的命令:\n.align 8\n这就保证 了它后面的数 据(在此, 是跳转表的开始)的起始地址是 8 的倍数。因为每个表项长 8 个字节 , 后面的元素都 会遵守 8 字节 对齐的限 制。\n对于包含结构的代码,编译器可能需要在字段的分配中插入间隙,以保证每个结构元素都 满足它的 对齐要求 。而结构本身 对它的起始地址也 有一些对齐要求。\n比如说 , 考虑下面的 结构声明 :\nstruct S1{\nint i; char c; int j;\n};\n假设 编译器用最 小的 9 字节分 配, 画出图 来是 这样的 :\n偏移 0 4 5\n内容I i I C I\n它是不可能 满足字段 认偏移 为 0 ) 和 ](偏移为5 ) 的 4 字节对齐要求的 。取而代 之地, 编译器在 字段 c 和 ]之间插入一 个 3 字节的 间隙(在此用蓝色阴影 表示):\n偏移 0 4 5 12\n内容 I i IC I\n结果, J 的偏 移晕 为 8\u0026rsquo; 而 整 个结构的 大小 为 12 字 节。 此外, 编译 器必须保 证 任 何struct Sl * 类型的 指针 p 都满足 4 字节对 齐。用 我们前面的 符号, 设指针 p 的值为 Xp, 那么, X p必须是 4 的倍数。这就保证了 p - \u0026gt; i ( 地址 X p) 和 p - \u0026gt; j ( 地址 x p + S ) 都满足它们的 4 字节对齐要求 。\n另外 , 编译 器结构的末尾可能 需要一些填充, 这样结 构数组中的每个元 素都 会满 足它的对齐要求 。例如 , 考虑下 面这个结 构声明 :\nstruct S2{\nint i ;\nint j; char c;\n};\n如果 我们将这个结 构打包成 9 个字节,只 要保证结构的起始地址满足 4 字节对齐要求, 我们仍然能够保证满 足字段 l 和 J 的对齐要求。不过 , 考虑下面的声明:\nstruct S2 d[4];\n分配 9 个字节, 不可能满足 d 的每个元素的对齐要求 , 因 为这些元索的地址分别为 互、xd+ 9、xct+ 1 8 和 孔+ 27。相反, 编译器会为结构 S2 分配 12 个字节 ,最后 3 个 字节是浪费的空间:\n偏移 0 4 8 9 12\n内容 I i I j I 叶\n这样一来, d 的 元素的地址分别为 工心 Xct+ 1 2 、工ct + 24 和 工d + 36。 只 要 Xd 是 4 的 倍 数 , 所有的 对齐限制就都可以满足了。\n讫§ 练习题 3. 44 对下 面 每 个 结 构 声 明 , 确 定每 个 字 段 的 偏 移 量 、 结 构 总 的 大 小 , 以 及\n在 x86-64 下 它的 对齐 要 求 :\nstruct P1 {inti; char c; int j ; char d; } ;\nstruct P2 { int i; char c; char d; long j ; } ; struct P3 { short w [3] ; char c [3] };\nstruct P4 { short w [5] ; char *c [3] } ;\nstruct PS { struct P3 a [2] ; struct P2 t } ;\n讫§ 练习题 3. 45 对于下列结构声明回答后续问题:\nstruct {\nchar *a;\nshort b,·\ndouble c·,\nchar d.,\nfloat e,·\nchar f ,·\nlong g;\nint h ;\n}r ec ;\n这个结构中所有的字段的字节偏移量是多少?\n这个结构 总 的 大小是 多少?\n重新排列这个结构中的字段,以最小化浪费的空间,然后再给出重排过的结构的 字节偏移量和总的大小。\nm 强制对齐的 情 况\n对于大多数 x86- 64 指令 来说 , 保 持 数 据对 齐能 够提 高 效率, 但是 它 不 会 影响程序的行 为。 另 一 方 面 , 如 果数据没有对 齐, 某些型号的 Intel 和 AMD 处理 器 对于有些 实现多媒 体操作的 SS E 指令, 就无 法正确执行。这些 指令 对 16 字 节 数 据块进行操作 , 在\nSSE 单元和内存之间传送数据的指令要 求 内存地址必须是 16 的倍数。任何试图以 不 满足对 齐要 求的 地址未访问内存都会导致异常(参见 8. 1 节),默 认 的行为是 程序终止。\n因此 ,任 何针对 x86-64 处理器的 编译 器和运行 时系统都必须保证分配用来保存 可能会被\nSSE 寄存器读或 写的数据结构的 内存, 都必须满足 16 字节对 齐。这个要求有两个后 果:\n任何内存分配函数 ( a l l o c a 、rna l l o c 、 c a l l o c 或r e a l l o c ) 生成的块的起 始地址都必须是 1 6 的倍数。\n大 多数 函数的栈 帧的边界 都必须是 16 字节的倍数。(这个要 求有一些例 外。)\n较近版本的 x86- 64 处理 器 实现了 A V X 多媒 体指令。除了 提 供 SSE 指令的超 集 , 支\n持 AVX 的指令并没有强 制性的对齐要 求。\n3. 10 在机器级程序中将控制与数据结合起来\n到目前为止,我们已经分别讨论机器级代码如何实现程序的控制部分和如何实现不同 的数据结构。在本节中 , 我们会看看数 据和控制如何交互 。首先, 深入审视一下指针, 它是 C 编程语 言中最重要的 概念之一 , 但是许多 程序员 对它的 理解都非 常浅显 。我们复习符号调试器 GDB 的使用,用 它仔细检 查机器级程序的详细运行 。接下来, 看看理解机器级程序如何帮助我们研究缓冲区溢出,这是现实世界许多系统中一种很重要的安全漏洞。最 后,查看机器级程序如何实现函数要求的栈空间大小在每次执行时都可能不同的情况。\n10. 1 理解指针\n指针是 C 语言的一个核心特色。它们以 一种统一方式, 对不同数据结构中的元素产生引用。对于编程新手来说,指针总是会带来很多的困惑,但是基本概念其实非常简单。在此,我们重点介绍一些指针和它们映射到机器代码的关键原则。\n每个指针都对应一个类型。这个类型表明该指针指向的是哪一类对象。以下面的指针声明为例:\nint *ip; char **cpp;\n变最 i p 是一个指向 i n t 类型对象的 指针 ,而 c p p 指针指向的 对象自身 就是一个指向c h a r 类型对象的 指针。通常 , 如果 对象类型为 T , 那么指针的类型为 T * 。特殊的v o i d * 类型代表通用指 针。比 如说 , ma 荨 o c 函数返回一个通用 指针, 然后 通过显式强制类型转换或者赋值操作那样的隐式强制类型转换,将它转换成一个有类型的 指针。指针类型不是 机器代码中的 一部分 ; 它们是 C 语言提供的一种抽象 , 帮助程序员避免寻址错误。\n每个指针 都有一个值。这个值是 某个 指定类型的 对象的地址。特殊的 NULL ( O) 值表示该指针没有指向 任何 地方 。\n指针 用 '矿运 算 符 创 建。 这个运 算符可以应用 到任何 l v a l ue 类的 C 表 达式上, l v a l ue 意指可以 出现在赋值语句左边的表达式。这样的例子包括变量以及结 构、联合 和数组的 元素。我 们已经看到, 因为 l e a q 指令是设 计用来计算内存引用的地址的,&运算符的机器代码实现常常用这条指令来计算表达式的值。\n*操作符用于间接引用指针。其结果是一个值,它的类型与该指针的类型一致。间接引用是用内存引用来实现的,要么是存储到一个指定的地址,要么是从指定的地址读取。\n数组与指针 紧密 联系。 一个数 组的名字可以 像一个指针变最一样引用(但是不能修改)。数组引用(例如a [ 3 ] ) 与指 针运算和间 接引 用(例如 * (a+ 3 ) ) 有一样的效果。数组引用和指针运算都需 要用对象大小对偏移量进行 伸缩。当我们写 表达式 p + i, 这里指 针 p 的值为 p , 得到的 地址计算 为 p + L · i , 这里 L 是与 p 相关联的 数据类型的大小。\n将指针从一种类型强制转换成另一种类型,只改变它的类型,而不改变它的值。强 制类型转换的 一个效果是改 变指针运 算的伸缩。例如, 如果 p 是一个 c har * 类型的指针, 它的值为 p , 那么表达式 (i n t * ) p + 7 计算为 p + 28 , 而 (i n t *) (p+ 7) 计算为 p + 7。(回想一下, 强制类型转换的 优先级高千加法。)\n指针也可以指向函数。这提供了一个很强大的存储和向代码传递引用的功能,这些引用 可以被程序的某个其他部分调用 。例如, 如果我们有一个函数,用 下面这个原型定义:\nint fun(int x, int *p);\n然后 , 我们 可以声 明一个指针 f p , 将它赋值为这个函数,代码如下:\nint (*fp)(int, int*); fp = fun;\n然后用这个 指针来调 用这个函数:\nint y = 1;\nint result= fp(3, \u0026amp;y);\n函数指针的值是该函数机器代码表示中第一条指令的地址。\n四亘正l王詈ll 函数指 针\n函数指针声明的语法对程序员新手来说特别难以理解。对于以下声明:\nint (*f)(int*);\n要从里(从 \u0026quot; f \u0026quot; 开始)往外读。 因此 , 我们看到像 \u0026quot; (* f ) \u0026quot; 表明的 那样 , f 是一个指 针; 而 \u0026quot; (*f ) (i n t * ) \u0026quot; 表明 f 是一个指 向函数 的指针, 这个函数以一 个 辽比* 作为 参数。最后 , 我们 看到 , 它是 指向以 i n 七 * 为参数并返回 i n t 的函数的 指针 。\n*f 两边的 括号是 必需的 , 否则声明 变成\nint *fCint*);\n它会被解读成\n(int•) f(int•);\n也就是说 , 它会 被解释 成一 个函数原 型 , 声明 了一 个函数 f , 它以 一个 l 让 * 作为 参数并返 回一个 i n t * 。\nK ern ig h a n 和 民tch ie [61, 5. 12 节]提供了一 个有 关阅读 C 声明的很 有帮助的教 程。\n10. 2 应用: 使 用 GDB 调试 器\nGNU 的调试器 GDB 提供了许多有用的特性 , 支持机器级 程序的 运行时评估和分析。对千本书中的示 例和练习, 我们试图通过阅读代码, 来推断出程序的行为。有了 GDB , 可以 观察正在运行的程序, 同时又对 程序的执行有相当的 控制, 这使得研究程序 的行为变 为可能 。\n图 3-39 给出了 一些 GDB 命令的例子, 帮助研究机器级 x86-64 程序。 先 运行 OBJ­\nDUMP 来获得程序的 反汇编版本, 是很有好处的。我们的示例都基于对文件 pr o g 运行\nGDB, 程序的描述 和反汇编见 3. 2. 3 节。我们用 下面的命令行来启 动 GDB :\nlinux\u0026gt; gdb prog\n通常的方法是在程序中感兴趣的地方附近设置断点。断点可以设置在函数入口后面, 或是一个程序的 地址处。程序在执行过程中遇到一个 断点时, 程序 会停下来, 并将控制返回给用户。在断点处,我们能够以各种方式查看各个寄存器和内存位置。我们也可以单步 跟踪程序 , 一次只执行几 条指令, 或是前进到下一个 断点。\nA.PP女A 效果 开始和停止 quit run kill 退出 GOB 运行程序(在此给出命令行参数) 停止程序 断点 break mu l 七S 七or e break * Ox400540 delete 1 delete 在函数 mu l t s t or e 入口处设 置断点在地址 Ox 400540 处设 置断点 删除断点 1 删除所有断点 执行 stepi 执行 1 条指令 stepi 4 执行 4 条指令 nexti 类似于 s t e p i , 但以函数调用为单位 continue 继续执行 finish 运行到当前函数返回 检查代码 disas 反汇编当前函数 disas mu l t s t or e 反汇 编函数 mul t s t or e disas Ox400544 反汇编位于地址 Ox 400544 附近的 函数 disas Ox400540, Ox40054d 反汇编指定地址范围内的代码 print /x $rip 以十六进制输出程序计数器的值 检查数据 print $rax 以十进制输出 %r a x 的内容 print /x $rax 以十六进制输出 %r a x 的内容 print /t $rax 以二进制输出 %r a x 的内 容 print OxlOO 输出 Ox l OO 的十进制 表示 print /x 555 输出 555 的十六 进制表示 print /x ($rsp+ 8) 以十六 进制输出 %r s p 的内容加上 8 print *(long *) Ox7fffffffe818 输出位 于地址 Ox 7ff f f f f f e 81 8 的 长整数 print *(long *) ($rsp+ 8) 输出位 于地址 %r s p + 8 处的长整数 x/2g Ox7fffffffe818 检查从 地址 Ox 7f f f f ff f e 81 8 开始的双 ( 8 字节)字 x/20brnultstore 检查函数 mu l t s t or e 的 前 20 个字节 有用的信息 info frame 有关当前栈帧的信息 info registers help 所有寄存器的值 获取有关 GOB 的信息 图 3-39 GDB 命令示例。说明 了一些 GDB 支持 机器级 程序悯试的方式\n正如我们的示 例表明的那样 , GDB 的命令语法有点 晦涩, 但是在线 帮助信息(用 GDB 的 he l p 命令调用)能克服这些 毛病。相对于使用命令行接口来访问 GDB, 许多程序员更愿意使用 DDD , 它是 GDB 的一个扩展 , 提供了图 形用户界 面。\n10 . 3 内存越界引用和缓冲区溢出\n我们已 经看到 , C 对千数组引 用不进行 任何边界检查, 而且局部变量和状态信息(例如保存的寄存器值和返回地址)都存放在栈中。这两种情况结合到一起就能导致严重的程 序错误,对越界的数组元素的写操作会破坏存储在栈中的状态信息。当程序使用这个被破\n坏的状态, 试图重新加载寄存 器或执行 r e t 指令时 , 就会出现很 严重的错误 。\n一种特别常 见的状态破坏称 为缓 冲 区 溢 出 ( b uff e r o ve r fl o w ) 。通常, 在栈 中分配某个字符数组来保 存一个字符串 , 但是字符串的 长度超出了 为数组 分配的 空间。下面这 个程序示例就说明了这个问题:\nI* Implementation of library function gets() *I char *gets(char *s)\n{\nint c;\nchar *dest = s ;\nwhile ((c = getchar()) !=\u0026rsquo;\\n\u0026rsquo;\u0026amp;\u0026amp; c != EDF)\n*dest++ = c;\nif (c == EDF \u0026amp;\u0026amp; dest == s)\nI* No characters read *I return NULL;\n*dest++ =\u0026rsquo;\\0\u0026rsquo;; I*Terminate string *I returns;\n}\nI* Read input line and write it back *I void echo()\n{\nchar buf[8]; gets(buf); puts(buf);\nI* Way too small! *I\n前面的代码 给出了 库函数 g e t s 的一个实现,用来说明这个 函数的严 重问题。它从标准输入读入 一行 ,在遇到一个回 车换行字符或某个 错误情况时 停止。它将这个字符串复制到参数. s 指明 的位置,并在字符串结尾加上 n u l l 字符。在函数 e c h o 中,我们使用了 g e t s , 这个函 数只是简单 地从标准输入中读入 一行, 再把它回送 到标 准输出 。\nge t s 的问题是它没有 办法确定是否为保存 整个 字符串分 配了足够的 空间。在 e c h o 示例中 , 我们故意 将缓 冲区设 得非常小一 只有 8 个字节 长。任何长度超 过 7 个字符的字符串都会导致写越界。\n检查 GCC 为 e c h o 产生的 汇编代码 , 看看栈 是如何组织的 :\nvoid echo() echo :\nsubq $24, %rsp Allocate 24 bytes on stack movq %rsp, %rdi Compute buf as %rsp call movq gets %rsp, %rdi Call gets Compute buf as %rsp call addq puts $24, %rsp Call puts Deallocate stack space ret Return 图 3- 40 画出了 e c h o 执行时 栈的组织 。该程序把栈 指针减去了 24 ( 第 2 行), 在栈上分配了 24 个字节 。字符数组 b u f 位于栈顶, 可以 看到,%r s p 被复制到%r d i 作为调用 g e t s 和 p u t s 的参数。这个调用的参 数和存储的 返回指针之间的 1 6 字节是未 被使用的。只要用户输入 不超过 7 个字符 , g e t s 返回的字符串(包括结尾的 n u ll ) 就能够放进为 b u f 分配的\n空间里。不 过, 长一些 的字符串 就会导致 g e t s 覆盖栈上存储的某些信息。随着字符串变长,下面的信息会被破坏:\n输入的字符数量 附加的被破坏的状态 0-7 无 9-23 未被使用的栈空间 24-31 返回地址 32+ cal l er 中保存的状态 字符串 到 23 个字符之前都没有严重的后果,但是超过以后,返回指针的值以及更多可能的保存状态会被破坏。如果存储的返回地址的值被破坏了 , 那么r e t 指令(第8 行)会导致程序跳转 到一个完全意\n调用者的栈帧\necho\n返回地址 \u0026lt; —\nr% sp + 24\n想不到的位置。如果只 看 C 代码 , 根本 就\n不可能看出会有上面这些行为。只有通过研究机器代码级别的程序才能理解像\nge 七s 这 样 的 函数 进 行 的内 存 越 界 写的影响。\n的栈帧\n[7Jl[ 6Ji(5J!(4 J!(3J!(2J!(l J!(Dl l-+ buf = %rsp\n图 3-40 ech o 函数的栈组织。字符数组 buf 就在保存的 状 态 下 面。对 buf 的 越界写会破坏程序的状态\n我们的 e c h o 代码很 简单 , 但是有点 太随意了。更好一点的版本是使用 f ge 七s 函数, 它包括一个参数 , 限制待读入 的最大字节数。家庭作业 3. 71 要求你写出一个能处理任意长度输入字符串 的 e c h o 函数。通常, 使用 ge t s 或其他任何能导致存储溢出的函数, 都是不好的编程习 惯。 不幸的是, 很多常用的库函数, 包括 s tr c p y、 s tr c a t 和 s pr i n t f , 都有一个属性 不需要告诉 它们目 标缓 冲区的 大小, 就产生一个字节 序列 [ 97] 。这样的情况就会导致缓冲区溢出漏洞。\n\u0026quot; 、练习题 3 . 46 图 3- 41 是 一个 函数的(不大好的 )实现 , 这个函数从标 准 输入 读入 一行 , 将字符串复制到新分配的存储中,并返回一个指向结果的指针。\n考虑下 面这 样 的 场 景。 调 用 过 程 g e t _ l i ne , 返 回地 址 等 于 Ox 40007 6 , 寄存器\n%r b x 等于 Ox 012345678 9ABCDEF。输入 的 字符 串 为 \u0026quot; 012345678901 2345678901 234\u0026quot; 。程\n序会因为段错误 ( segmentation fault ) 而中止。运 行 GDB, 确定 错误是在 执行 g 武 _ l i ne\n的r e t 指令 时发 生的。\n填写下 图 , 尽可能 多地 说 明 在 执行 完反 汇编 代 码 中 第 3 行指 令 后 栈 的 相 关 信息。在右边标注出存储在栈中的数字含意(例如“返回地址\u0026quot;)\u0026lsquo;在方框中写出它们的十 六进 制值(如果知道 的 话)。每 个 方 框 都 代 表 8 个 字节。 指 出 %r s p 的位 置。 记住 , 字符 0 ~ 9 的 ASCII 代码是 Ox 3~ 0x 3 9。\n00 00 00 00 00 40 00 76 1 返回地址\n修改你的 图 , 展现调 用 g e t s 的 影响(第 5 行)。\n程序应该试图返回到什么地址? 当 ge t —巨 n e 返回 时, 哪个(些)寄存器 的值被破坏 了?\n除了可能 会缓冲 区溢 出以 外, g e t —l i ne 的代 码还有哪 两个错误?\nI* This is very low-quality code.\nIt is intended to illustrate badprogramming practices.\nSee Practice Problem 3.46. *I char *get_line ()\n{\nchar buf [4] ; char *result; gets(buf);\nresult= malloc(strlen(buf)); strcpy(result, buf);\nreturn result;\n}\nC代码 char *get _l i ne () 0000000000400720 \u0026lt;get_line\u0026gt;:\n2 400720: 53\n3 400721: 48 83 ec 10\nDi agr 却 st ack at this point\n4 400725: 48 89 e7\n400728: e8 73 ff ff ff\npush %rbx\nsub $0x10,%rsp\nmov %rsp,%rdi callq 4006a0 \u0026lt;gets\u0026gt;\nModify diagram to show stack contents at this point\nb ) 对gets调用的反汇编\n图 3-41 练习题 3. 46 的 C 和反汇编代码\n.缓冲区溢出的一个更加致命的使用就是让程序执行它本来不愿意执行的函数。这是一 种最常见的通过计算机网络攻击系统安全的方法。通常,输入给程序一个字符串,这个字 符串包含一些 可执行 代码的 字节编码 , 称为攻击代码 ( e xploit code), 另外,还有一些字节会用一个指向攻 击代码的 指针覆盖返 回地址。那么, 执行r e t 指令的效果 就是跳转到攻击代码。\n在一种攻 击形式 中, 攻击代码会 使用系统 调用启动一个 shell 程序, 给攻 击者提供一组操作系统函数。在另一种攻击形式中,攻击代码会执行一些未授权的任务,修复对栈的 破坏, 然后第二次执行r e t 指令,(表面上)正常返回到调用者。\n让我们来看一个例子 , 在 1 988 年 11 月, 著名的 In ternet 蠕虫病毒通过 Int ernet 以四种不同 的方法获取 对许多计算机的访问。一种是对 fi nger 守护进程 f i ng er d 的缓冲区 溢出攻击 , f i ng er d 服务 F I NG E R 命令请求。通过以一个适当的字符串调用 F I NG E R , 蠕虫可以使远程的守护进程缓冲区溢出并执行一段代码,让蠕虫访问远程系统。一旦蠕虫获得了对系统的访问,它就能自我复制,几乎完全地消耗掉机器上所有的计算资源。结果, 在安全专家制定出如何消除这种蠕虫的方法之前,成百上千的机器实际上都瘫痪了。这种蠕虫的始作桶者最后被抓住并被起诉。时至今日,人们还是不断地发现遭受缓冲区溢出攻击的系统安全漏洞,这更加突显了仔细编写程序的必要性。任何到外部环境的接口都应该是“防弹的",这样,外部代理的行为才不会导致系统出现错误。\n日 日 蠕虫和病 毒\n蠕虫 和病毒都 试图 在计 算机中 传播它们 自 己的 代码段。正如 S pa fford [ 105 J 所 述, 蠕 虫 ( w o rm ) 可以 自己 运行 , 并且 能 够将 自 己的 等效副 本传播 到 其他 机 器。 病毒( vi ru s ) 能将自己添加到包括操作系统在内的其他程序中,但它不能独立运行。在一些大众媒体 中,“ 病毒“ 用来指 各种在 系统间 传播 攻击代 码的 策略 , 所以 你 可能 会听到人们把 本 来应该叫做"蠕虫”的东西称为“病毒”。\n3. 10 . 4 对抗缓冲区溢出攻击\n缓冲区溢 出攻击的普遍发 生给计算 机系统 造成了许多 的麻烦。现代的 编译器和操作 系统实现了很多 机制 , 以避免遭受这 样的攻 击, 限制入侵者 通过缓 冲区 溢出攻击获得 系统控制的方式。在本节中 , 我们 会介绍一些 L in u x 上最新 G CC 版本所 提供的机制。\n1 栈随机化\n为了 在系统 中插入攻 击代码 , 攻击者既要插入代 码, 也要插入指向这段代码的指针, 这个指针也是 攻击字符串的 一部分。产生这个指 针需 要知道这个 字符串放置的 栈地址 。在过去 , 程序的 栈地址非常容易 预测。对于所 有运行 同样程序和操 作系统 版本的 系统 来说, 在不同的 机器之间 , 栈的位置是相 当固定的 。因此, 如果 攻击者可以确定 一个常见的 We b 服务器所使用的 栈空间 , 就可以设 计一个在许多 机器上都能 实施的攻击。以 传染病来打个\n比方, 许多系统都容易 受到同一种病毒的攻击, 这 种现象常被称作安全 单 一 化 ( sec u rit y monoculture) [ 96] 。\n栈随机化的思 想使得栈的 位置在程序每 次运行时都 有变化。 因此, 即使许多机器都 运行同样的 代码 , 它们的 栈地址 都是不同 的。实现的方式是 : 程序 开始时, 在栈上分配一段 O~ n 字节之间的随 机大小 的空间, 例如, 使用分配函 数 a l l o c a 在栈上 分配指定 字节数 量 的空间 。程序不使 用这段空间, 但是 它会导致程序 每次执行时后续的栈位置发 生了变化。分配的范围 n 必须足够大 , 才能 获得 足够多的 栈地址 变化 , 但是 又要 足够小 , 不至千浪费 程序太多 的空间 。\n下面的代码是一种确定 ”典型的" 栈地址的 方法:\nint main() { long local;\nprintf (\u0026ldquo;local at %p\\n\u0026rdquo;, \u0026amp;local); return O;\n这段 代码只 是简单 地打印出 ma i n 函数中局部 变量的 地址。在 32 位 L in u x 上运行这段 代码\n10 000 次, 这个地址的 变化范围为 Ox f f7 f c 5 9c 到 Ox f f f f d 09c , 范围大小 大约是 2气 在更新 一点 儿的 机 器上 运 行 64 位 L i n ux , 这 个地址的 变 化范围 为 Ox 7 f f f 000 l b 698 到Ox 7 f f f f f f a a 4a 8 , 范围大小大约是 2 32 0\n在 L i n u x 系统 中, 栈随机化已经变成了标准行 为。它是更大的一类技术中的一种, 这类技术 称为地址空间布局 随机化 ( A dd r ess -S pace La yo ut Ra nd omiza tio n ) , 或者简 称 AS LR [ 99] 。采用 AS LR , 每次运行时 程序的 不同部分, 包括程序 代码 、库 代码、栈 、全局 变撮和堆数 据, 都会被 加载到内 存的不同区域 。这就意 味着在 一台机器上运行一个程序 , 与在其他机器上 运行同样的程 序, 它们的地址映射大相径庭。 这样才能够对抗一些形式的攻击。\n然而,一个执著的攻击者总是能够用蛮力克服随机化,他可以反复地用不同的地址进 行攻击 。一种常见的把戏就是在实际的攻击代码前插入很长一段的 no p ( 读作 \u0026quot; no op\u0026quot;, no ope ratio in 的缩写)指令。执行这种指令除了对程序计数器加一,使 之指向下一条指令之外,没有任何的效果。只要攻击者能够猜中这段序列中的某个地址,程序就会经过这个序 列, 到达攻 击代码。这个序列常用的术语是“ 空操 作雪橇 ( no p sled)\u0026quot; [97], 意思是程序会“滑过“ 这个序列。如果我们建立一个 256 个字节的 no p sled, 那么枚举 215 = 32 768 个 起始地 址, 就能破解 n 2 23 的 随 机化, 这对于一个顽固的攻击者来说 , 是完全可行的。对千 64 位的 情况, 要尝试枚举 沪 —1 6 777 216 就有点儿令人畏惧了。我们可以看到栈随机化和其 他一些 AS LR 技术能够增加成功攻击一个系统的难度, 因而大大降低了病毒或者蠕虫的传播速度,但是也不能提供完全的安全保障。\n练习题 3. 47 在运行 L in u x 版本 2. 6. 1 6 的机器上运行栈检查代 码 10 000 次, 我 们 获得地 址的 范 围从 最小的 Ox ff f f b 75 4 到 最 大 的 Ox f f f f d 75 4。\n地址的大概范围是多大?\n如果 我 们 尝试 一个 有 1 28 字节 no p s led 的 缓冲 区 溢 出 , 要 想 穷尽 所 有 的 起始地址, 需要尝试多少次?\n2 栈破坏检测\n计算机的第二道防线是能 够检测到何时栈已经被破坏。我们在 e c ho 函数示例(图3-\n中看到 , 破坏通常发生在当超越局部缓 冲区的边界时。在 C 语言 中 , 没有可靠的方法来防止对数组的越界写。但是,我们能够在发生了越界写的时候,在造成任何有害结果之 前,尝试检测到它。\n最近的 GCC 版本在产生的代码中加\n入了一种栈保护者 ( s t ack pro t ecto r ) 机制,\n来检测缓冲区越界。其思想是在栈帧中任 何局部缓冲区与栈状态之间存储一个特殊 的全·丝 雀 ( cana ry ) 值s , 如 图 3-42 所 示\n调用者\n的栈帧\necho\n返回地址\n\u0026lt; — r%\ns p + 24\n[26, 97] 。这个金丝雀值,也 称为哨兵值 的栈帧\n(guard value), 是在程序每次运行时随机\n金丝雀\n[7 Jl[ 6 Ji [ S Jl[ 4 Ji[ 3Jl [ 2 Jl[l li[ O]\n\u0026lt; — buf = %rsp\n产生的,因此,攻击者没有简单的办法能够知道它是什么。在恢复寄存器状态和从函数返回之前,程序检查这个金丝雀值是否被该函数的某个操作或者该函数调用的\n图 3-42 ech o 函数具有栈保护者的栈组织(在数组\nbuf 和保存的状态之间放了 一个特殊的“金丝雀" 值。代码检查这个金丝雀值 , 确定栈状态是否被破坏)\n某个函数的某个操作改变了。如果是的,那么程序异常中止。\n最近的 GCC 版本会试着确定一个函数是否容易遭受栈溢出攻击,并 且自动 插入这种溢出检测。实际上,对 于前面的栈溢出展示, 我们不得不用命令行选项 \u0026quot; - f no - s t a c k- pr ot e c t or \u0026quot; 来阻止 GCC 产生这种代码。当不用这个选项来编译 e c ho 函数时,也 就是允许使用栈保护者,得到下面的汇编代码:\nvoid echo()\necho: subq $24, %rsp Allocate 24 bytes on stack e 术语“金丝雀"源于历史上用这种鸟在煤矿中察觉有毒的气体。\n议\nmovq %fs:40, %rax Retrieve canary\n4 movq %rax, 8(%rsp) Store on stack\n5 xorl %eax, %eax Zero out register\n6 movq %rsp, %rdi Compute buf as¼rsp\n7 call gets Call gets\n8 movq .r 儿 s p , %rdi Compute but as %rsp\n9 call puts Call puts\n10 movq 8(%rsp), %rax Retrieve canary\n11 xorq %fs:40, %rax Compare to stored value\n12 je .19 If=, goto ok\n13 call stack_chk_fail Stack corrupted!\n14 .L9: ck:\n15 addq $24, %rsp Deallocate stack space\n16 ret\n这个版本的函数从内存中读出一个值(第3 行),再 把它存放在栈中相对千%r s p 偏移量 为 8 的地方 。指令参数%f s : 40 指明金丝雀值是用段寻址 ( s eg m e n ted ad d ress ing ) 从 内 存中读入的, 段寻址机制可以 追溯到 80286 的 寻 址, 而在现代系统上运行的程序中已经很少见到了。将金丝雀值存放在一个特殊的段中,标志为“只读“,这样攻击者就不能覆盖存 储的金丝雀值。在恢复寄存器状态和返回前,函数将存储在栈位置处的值与金丝雀值做比 较(通过第 11 行的 x or q 指令)。如果两个数相同, x or q 指令就会得到 0 , 函数会按照正常的方式完成。非零的值表明栈上的金丝雀值被修改过,那么代码就会调用一个错误处理 例程。\n栈保护很好地防止了缓冲区溢出攻击破坏存储在程序栈上的状态。它只会带来很小的性能损失, 特别是因为 GCC 只在函数中有局部 c h ar 类型缓 冲区的 时 候 才插入这样的代码。当然,也有其他一些方法会破坏一个正在执行的程序的状态,但是降低栈的易受攻击性能够对抗许多常见的攻击策略。\n让 练习题 3. 48 函 数 i n t l e n 、l e n 和 i p t o a 提供 了 一 种很 纠结 的 方 式 , 来计算 表 示 一个整数所 需 要 的 十 进 制 数 字 的 个 数。 我 们 利 用 它 来 研 究 GCC 栈保 护 者 措 施 的 一 些情况。\nint len(char *s) { return strlen(s);\n}\nvoid iptoa(char *s, long *p) { long val= *p;\nsprintf (s, \u0026ldquo;%ld\u0026rdquo;, val) ;\n}\nint intlen(long x) { long v;\nchar buf[12];\nV = x;\niptoa(buf, \u0026amp;v); return len(buf);\n}\n下 面是 i n t l e n 的 部分代码 , 分别 由 带和 不 带栈 保护者 编译:\nint intl en (1 ong x) x in %rdi 1 intlen: i nt 工 nt l en(l ong x) 2 subq $56, %rsp x in %rdi 3 movq %fs:40, %rax intlen: 4 movq %rax, 40(%rsp) 2 subq $40, %rsp 5 xorl %eax, 1儿eax 3 movq %rdi, 24(%rsp) 6 movq %rdi, 8(%rsp) 4 leaq 24(%rsp), %rsi 7 leaq 8(%rsp), %rsi 5 movq %rsp, %rdi 8 leaq 16(%rsp), %rdi 6 call iptoa 9 call iptoa a ) 不带保护者 b ) 带保护者\n对于两个版本: b u f 、v 和金 丝雀值(如果 有的 话)分别 在栈 帧 中的 什 么 位置?\n在有保护的代码中,对局部变量重新排列如何提供更好的安全性来对抗缓冲区越界攻击?\n3 限制可 执行代码区域\n最后一招是消除攻击者向系统中插入可执行代码的能力。一种方法是限制哪些内存区域能够存放可执行代码。在典型的程序中,只有保存编译器产生的代码的那部分内存才需要是可执行的。其他部分可以被限制为只允许读和写。正如第 9 章中会看到的 , 虚拟内存空间 在逻辑上被分成了页( page) , 典型的每页是2048 或者 4096 个字节。硬件支持多种形式的内存保护, 能够指明用户程序和 操作系统内核所允许的访问形式。许多系统允许控制三种访问形式: 读(从内存读数据)、写(存储数据到内存)和执行(将内存的内容看作机器级代码)。, 以x8 前6 体系结构将读和执行访问控制合并成一个1 位的标志, 这样任何被标记为可读的 页也都是可执行的。栈必须是既可读又可写的,因而栈上的字节也都是可执行的。已经实现的很多机制,能够限制一些页是可读但是不可执行的,然而这些机制通常会带来严重的性能损失。\n最近, AMD 为它的 64 位处理器的内 存保护引 入了 \u0026quot; N X\u0026quot; (No-Execute, 不执行)位, 将读和执行 访问模式分开, In t el 也跟进了 。有 了这个特性 , 栈可以 被标记为可读和可写, 但是不可执行,而检查页是否可执行由硬件来完成,效率上没有损失。\n有些类型的 程序要求 动态产生 和执 行代码的能力。例如, ”即时( jus t-in- t ime ) \u0026quot; 编译技术为解 释语言(例如J a va ) 编写的 程序动态地产生 代码 , 以提高执行 性能。是否能够将可执行代码限制在由编译器在创建原始程序时产生的那个部分中,取决千语言和操作系统。\n我们讲到的这些技术 随机化、栈保护和限制哪部分内存可以存储可执行代码\n是用千最小化程序缓冲区溢出攻击漏洞三种最常见的机制。它们都具有这样的属性,即不需要程序员做任何特殊的努力,带来的性能代价都非常小,甚至没有。单独每一种机制都降低了漏洞的等级,而组合起来,它们变得更加有效。不幸的是,仍然有方法能够攻击计算机 [ 85 , 97], 因而蠕虫和病毒继续危害着许多机器的完整性。\n10. 5 支持变长栈帧\n到目前为止,我们已经检查了各种函数的机器级代码,但它们有一个共同点,即编译 器能够预先确定需要为栈帧分配多少空间。但是有些函数,需要的局部存储是变长的。例 如, 当函数调用 a l l o c a 时就会发生这种情况。 a l l o c a 是一个标准库函数, 可以在栈上分配任意字节数量的存储。当代码声明一个局部变长数组时,也会发生这种情况。\n虽然本节介绍 的内容实际上是如何实现过程的一部分, 但我们还是 把它推迟 到现在才\n讲, 因为它需 要理解数组和对齐。\n图 3-43a 的代码 给出了一 个包含变长数组的 例子。该函数声明了 n 个指针的局部数组\np, 这里 n 由第一个参数 给出 。这要求 在栈上分 配 8 n 个字节, 这里 n 的值每次调 用该函数时都会不同 。因此编译 器无 法确定 要给该 函数的 栈帧分配多少空间。此外 ,该 程序 还产生一个对局部 变鼠 1 的地址引 用, 因此该 变星必须存储在栈 中。在执 行工程中 , 程序必须能够访问 局部变最 1 和数组 p 中的元素。返回时, 该函数必须 释放这个栈 帧, 并将栈指针设置为存储 返回地址的 位置。\nlong vframe(long n, long idx, long *q) { long i;\nlong *p[n]; p[O] = \u0026amp;i;\nfor (i = 1; i \u0026lt; n; i++) p[i] = q;\nreturn *p[idx];\n}\nC代码\nlong vframe(long n, l ong 工 dx , long *q)\nn 江I %r 中 , 过 x in %r s 工, q 耳1 %rdx Only portions of code shown vframe:\n2 pushq %rbp Save old %rbp 3 rnovq %rsp, %rbp Set fr 动 e pointer 4 subq $16, %rsp Allocate space for i (%rsp = s1) 5 leaq 22(,%rdi,8), %rax 6 andq $一1 6 , %rax 7 subq %rax, %rsp Allocate space for array p (%rsp = s2) 8 leaq 7(%rsp), %rax 9 shrq $3, %rax 10 leaq 0(,%rax,8), %r8 Set %r8 to !tp[O] 11 rnovq %r8, %rcx Set %rcx to !tp[O] (%rcx = p) Code for initialization loop\ni in¼rax and on stack, n in¼rdi, pin¼rcx, q in¼rdx\n12 .13: loop: 13 movq %rdx, (%rcx,%rax,8) Set p[i] to q 14 addq $1, %rax Increment i 15 movq %rax, - 8 C儿r bp ) Store on stack 16 . 1 2 : 17 movq -8(%rbp), %rax Retrieve i from stack 18 cmpq %rdi, %rax Compare i : n 19 jl .L3 If \u0026lt;, goto loop Code for function exit\nleave ret Restore¾rbp and 7.rsp Return\nb ) 生成的部分汇编代码\n图 3-43 需 要使用帧 指针的 函数。变长数组 意味着在编译 时无法确定栈帧的 大小\n为了管理 变长栈帧 , x8 6-64 代码使用 寄存器%r b p 作为帧 指针( fr am e pointer) (有时称为基指 针 ( base pointer) , 这也是%r bp 中 b p\n两个字母的由来)。当使用帧指针时,栈帧的\n组织结 构与图 3-44 中函数 v fr a me 的情况一 帧指针r% 样。可 以看到代码必须把%r b p 之前的值保存\n到栈中,因为它是一个被调用者保存寄存器。然后在函 数的整个执行过程中, 都使得%r b p 指向那个时刻栈的位置,然后用固定长度的 局部变量(例 如 i ) 相对于%r b p 的偏移趾来引\nbp -­\n\u0026lt; s,\n}e,\n用它们。 8n字节\n图 3-43 b 是 GCC 为 函数 v fr a me 生成的部分代码。在函数的开始,代码建立栈帧, 并为数组 p 分配空间。首先把%r b p 的当前 值\n压入栈 中, 将%r b p 设置为指向当前的栈位詈 栈指针%r sp — — \u0026gt;\n)\u0026hellip;. p\n(s,\n(第 2~ 3 行)。然后, 在栈上分配 1 6 个字节, 图 3-44 函数 vf ra me 的栈帧结构(该函数使用寄其中前 8 个字节 用于存储局部变最 i , 而后 8 存器 %r bp 作为帧指针。图右边的注释供个字节是未 被使用的。接着, 为数组 p 分配 练习题 3. 49 所 用 )\n空间(第 5 ~ 11 行)。练习题3. 49 探讨了分配多 少空间以 及将 p 放在这段 空间 的什么位置。当程序到第 11 行的时候, 已经 (1 ) 在栈上分 配了 8 n 字节 , 并( 2 ) 在已分配的 区域内 放置好数组 p , 至少有 811 字节可 供其使用 。\n初始化循环的 代码展示 了如何引 用局部变蜇 1 和 p 的例子。第 13 行表明 数组元素 p 肛]被设 置为 q 。该指令用 寄存器%r c x 中的值作 为 p 的起始地址 。我们可以 看到修 改局部变量 i( 第 1 5 行)和读局部变最(第1 7 行)的例子。1 的地址是引用- 8 (%rbp), 也就是相对千帧指 针偏移扯 为- 8 的地方。\n在函数的结 尾, l e a v e 指令将帧指针恢 复到它之前的值(第20 行)。这条指令不需要参数,等价千执行下面两条指令:\nmovq %rbp, %rsp Set stack pointer to beginning of frame popq %rbp Restores aved %rbp and set stack ptr\nto end of cal l ers\u0026rsquo; frame\n也就是 , 首先把栈指针设置为保 存%r b p 值的 位置, 然后把该 值从 栈中弹出到%r b p 。 这个指令组合具有释放整个栈帧的效果。\n在较早版本的 x86 代码中 , 每个函数调用都使用 了帧指针。 而现 在,只 在栈帧长可变的情况下才使用 , 就像函数 v fr a me 的情况一样。历 史上, 大多数编译 器在生成 I A32 代码时会使用帧 指针。最 近的 G CC 版本放弃了这个惯 例。可以 看到把 使用帧指针的代码和不使用帧指针的 代码混 在一起 是可以 的,只 要所有的函数都把%r b p 当做被调用者保存寄存器来处理即可。\n让 练习题 3. 49 在这 遗题 中,我们 要探 究图 3- 43 b 第 5 ~ 11 行代 码 背后的逻 辑, 它 分 配了变长大 小的数组 p 。 正如代码 的注释表明的 , S 1 表 示执行 第 4 行的 s ub q 指令 之后 栈指针的地 址。这 条指令 为局部 变量 1 分 配 空间。 S2 表 示执 行 第 7 行的 s ub q 指令之 后栈指针的值。 这条指令 为局部 数 组 p 分 配存 储。 最 后 , p 表 示 第 1 0 ~ 11 行的 指令 赋给寄 存器 %r 8 和%r c x 的值。 这 两个寄存器都用来 引用数组 p 。\n图 3-44 的 右边 画 出 了 s, 、Sz 和 p 指 示的 位置。 图 中 还 画 出 了 S2 和 p 的值之 间 可能有 一个 偏 移 量 为 e 2 字 节 的位置 , 该 空 间是未被使用 的。 数 组 p 的 结 尾和 s , 指 示的 位置之间 还可 能 有 一个 偏 移 量 为 e , 字节的地方。\n用 数 学 语 言解释第 5 ~ 7 行 中 计 算 Sz 的逻辑。提 示 : 想想—16 的位级表 示以 及它在第 6 行 a ndq 指令 中 的作用 。 用 数 学语 言解释第 8 ~ 10 行 中 计 算 p 的 逻 辑。 提 示 : 可 以 参 考 2. 3. 7 节 中 有 关 除以 2 的幕的 讨论。 对 于 下 面 n 和 s , 的值 , 跟 踪 代码 的执行, 确定 Sz 、p、e, 和 e2 的结果值。 sI 们 e,\n这段代码 为 S2 和 p 的 值提供 了什 么 样的 对 齐 属 性? 11 浮点代码 # 处理器的浮点体 系结构包括多个方面,会 影响对浮点 数据操作的程序如何被映射到机器上,包括:\n如何存储和访问浮点数值。通常是通过某种寄存器方式来完成。 对浮点数据操作的指令。\n向函数传递浮点 数参数和从函数返回浮点数结果的规则。\n函数调用过程中保存寄存器的规则 例如, 一 些 寄 存 器被指定为调用者保存, 而其他的被指定为被调用者保存。\n简要回顾历史会对理解 x86- 64 的 浮点体系结构有所帮助。1997 年出现了 Pent i um / MMX, Int el 和 AMD 都引 入了持续数代的媒体( med ia) 指令, 支持图形和图像处理。这些 指令本意是允许多个操作以并行模式执行, 称 为 单指令 多 数 据或 S IMD ( 读作 sim-de e )。在这种模式中,对 多 个不同的数据并行执行同一个操作。近年来, 这些扩展有了长足的发展。名字经过 了 一 系列 大的 修 改, 从 MMX 到 SSE ( Str eaming SIMD Extens ion , 流式\nSIMD 扩展),以 及最新的 AVX (Advanced Vector Extension , 高级向量扩展)。每一代中,都 有一些不同的版本。每个扩展都是管理寄存器组中的数据, 这些寄存器组在 MMX 中 称为 \u0026quot; MM\u0026quot; 寄 存 器 , SS E 中称为 \u0026quot; XMM\u0026quot; 寄 存 器 , 而在 AVX 中 称 为 \u0026quot; YMM\u0026quot; 寄存器;\nMM 寄存 器是 64 位的, XMM 是 128 位的 , 而 YMM 是 256 位的。所以, 每个 YMM 寄存器可以存放 8 个 32 位值, 或 4 个 64 位值, 这些值可以 是整数,也 可以 是浮点数。\n2000 年 Pent i um 4 中 引 入了 SSE2 , 媒体指令开始包括那些对标量浮点数据进行操作的指令,使 用 XMM 或 YMM 寄存 器的低 32 位或 64 位 中 的 单 个 值 。 这个标量模式提供了一组寄存器和指令,它 们 更类似于其他处理器支待浮点 数的 方式。所有能够执行 x86-6 4 代码的处理器都支待 SSE2 或更高的版本,因 此 x86-6 4 浮点数是基于 SSE 或 AVX 的 , 包括传递过程参数和返回值的规则[ 77] 。\n我们的讲述基于 AVX2 , 即 AVX 的 第 二 个 版本, 它 是 在 201 3 年 Core i7 Has well 处理器中引入的。当给定命令行参数- ma v x2 时 , GCC 会生成 AVX2 代 码 。 基于不同版本的\nSSE 以及第一个版本的 AVX 的 代码从概念上来说是类似的,不 过 指 令 名和格式有所不同。我们只介绍用 GCC 编译浮点程序时会出现的那些指令。其中大部分是标量 AVX 指令, 我\n们也会 说明对整个数 据向量进行 操作的 指令出现的情况。后文中的网络旁 注 O PT , SIMD 更全面地 说明了如何利用 SSE 和 AVX 的 SIMD 功能读者可能 希望参考 AM D 和 Intel 对每条指令 的说明 文档[ 4 , 51] 。和整数操 作一样, 注意 我们表述中使用的 AT T 格式不同 千这些文档中 使用的 Intel 格式。特别地, 这两种版本 中列出指令操作数的顺 序是不同 的。\n如图 3-45 所示 , AVX 浮点 体系结 构允许数据存储在 16 个 YM M 寄存器中, 它们的 名字为 %ymrn0~ %ymrn1 5 。 每个 YM M 寄存器都 是 256 位( 32 字节)。当对标最数据操作时, 这些寄 存骈只保 存浮点数, 而且只使用低 32 位(对千 fl o a t ) 或 64 位(对于 d o u b l e ) 。汇编代码 用寄存器的 SS E XM M 寄存器名字%xmrn0~ %xmrn1 5 来引用 它们, 每个 XM M 寄存器\n都是对应 的 YM M 寄存器的 低 1 28 位(1 6 字节)。\n255\n尸 # E三\n尸三三三三E E\n三三三三 # 三三\n127 。\n%xrnm0 II 1st FP arg 返回值\n%xmml II 2nd FP参数\n%xmm2 II 3rd FP参数\n令xmm3 II 4th FP参数\n%xrnm4 II 5th FP参数\n%xmm5 』6th FP参数\n1 %xmm6 17th FP参数\nI%xmm7 18th FP 参数\nJ %xrnm8 ii 调用者保存\nl %xrnm9 II 调用 者保存\nj%xmm1 0 II 调用者保存\nl %xrnmll II调用者保存\n%xrnm1 2 11 调 用者保存\n%xmml 3 II调用者保存\n%xmm14 II调用者保存\nJ %xmml 5 II调用者保存\n图 3-45 媒 体 寄 存 器 。 这些寄存器用于存放浮点 数 据。每个 YMM 寄存器保 存 32 个 字 节 。 低 16 字 节 可以 作为 XMM 寄存器来访问\n3. 11. 1 浮点传送和转换操作\n图 3-46 给出了一组在内存和 XM M 寄存器之间以及从一个 XM M 寄存器到另一个不\n做任何转换的 传送浮点 数的指 令。引用内 存的指令是标量指 令, 意味着它们只对单个而不 是一组封装好的数 据值进行 操作。数据要么保存在内 存中(由表中的 M 32 和 M 64 指明), 要 么保存在 XM M 寄存器中(在表中 用 X 表示 )。无论数据对齐与否, 这些指令都能正确执行, 不过代码优化规则建议 32 位内 存数据满足 4 字节对齐, 64 位数据满足 8 字节 对齐。内存引 用的指定方式与整数 MOV 指令的一样, 包括偏移量、基址寄存器、变址 寄存器和伸缩因子的 所有可能的组合。\n指令 源 目的 描述 vrnovss M32 X 传送单精度数 vmovss X Ms, 传送单精度数 vmovsd M\u0026quot; X 传送 双精 度数 vmovsd X M\u0026quot; 传送双精度数 vmovaps X X 传送对 齐的 封装好的 单精度数 vmovapd X X 传送对齐的封装好的 双精度数 图 3-46 浮点 传送指 令。这些操作在内存和寄存器之间以 及一对寄存器之间传 送值 \u0026lt;X, XMM\n寄存器(例如%x mm3 ) ; M32 : 3 2 位内 存范围; M6, : 64 位内 存范围)\nGCC 只用标量传送操作从内存传送数据到 XM M 寄存器或从 XM M 寄存器传送 数据到内 存。对 于 在 两 个 XM M 寄 存 器 之间 传 送 数 据, GCC 会使用两 种 指令之一, 即用v mo v a p s 传送单精度数 , 用 v mo v a p d 传送双精度数。对于这些情况, 程序复制整个寄存器还是只复制低位值既 不会影响程序功能 , 也不会影响 执行 速度 , 所以使用这些指令还是\n针对标 量数据的指令没有实质上的 差别。指令名字中的字母 '矿表示 \u0026quot; a li g n ed ( 对齐的 )"。当用于读写 内存时 , 如果 地址不满足 16 字节 对齐, 它们会导 致异常。在两个寄存器之间传送数据, 绝不 会出现错 误对齐的状况。\n下面是一 个不同 浮点 传送操作的 例子, 考虑以 下 C 函数\nfloat float_mov(float v1, float *Src, float *dst) { float v2 = *src;\n*dst = v1;\nreturn v2;\n}\n与它相关 联的 x86- 64 汇编代码 为\nfloat float_mov(float v1, float *src, float *dst) v1 in %xmm0, src in %rdi, dst in %rsi\nfloat_mov:\nvmovaps %xmm0, %xmm1 vmovss (%rdi), %xmm0 vmovss %xmm1, (%rsi) ret\nCopy v1\nRead v2 from src Write v1 to dst Return v2 in¼xmmO\n这个例子中可以 看到它 使用了 v mo v a p s 指令把数据从一个寄存器复制到另一个, 使用了\nv mo v s s 指令把数 据从内 存复制到 XM M 寄存器以及从 XM M 寄存器复制到内 存。\n图 3-47 和图 3-48 给出了 在浮点数和整 数数据类型之间以及不同浮点 格式之间进行转换的指令集 合。这些 都是对单 个数据值进行操作的标量指令。图 3-47 中的指令把一个从XM M 寄存器或内存中读出的 浮点值进 行转换, 并 将结果写入一个通用寄存 器(例如\n%r a x 、%e b x 等)。把浮点 值转换 成整数时 , 指令会执 行截断 ( t ru n ca t io n ) , 把值向 0 进行舍\n入, 这是 C 和大多数其他编程语言的要求。\n指令 源 目的 描述 vcv t t s s 2s i X/ M32 R,, 用 截断的 方法把单精 度数转换 成整数 vcvt t sd 2s i X/ M\u0026quot; R,, 用 截断的方 法把 双精 度数转换 成整数 vcvttss2siq X/ M32 R,, 用 截断的方法把单精 度数转换 成四字 整数 vcvttsd2s iq X/ M\u0026quot; R, 4 用 截断的方法 把双精 度数转换 成四字整数 图 3-47 双操作数浮点转换指 令。这些 操作将浮点 数转换成整数 ( X , XMM 寄存器(例如% x mm3 ) ; R32 :\n32 位通用 寄存器(例如%e a x) ; R\u0026quot; : 64 位通用寄存器(例如%r a x ) ; M32 : 32 位内存范围; M \u0026quot; :\n64 位内存范围)\n指令 源 1 源 2 目的 描述 vcvtsi2ss M32 / R32 X X 把整数转换成单精度数 vcvt s i 2s d M32/ R32 X X 把整数转换成双精度数 vcvtsi2s sq M,d R,, X X 把四字整数转换成单精度数 vcvtsi2sdq M64/ R64 X X 把四字整数转换成双精度数 图 3- 48 三操作数 浮点转换 指令 。这些操作将第一个 源的 数据类型转换 成目的的数据类 塑。第二个源值对结果的低位 字节没 有影响CX : XMM 寄存器(例如% x rnm3 ) ; M, 2 : 3 2 位内存范围 ; M , , : 64 位内存范围)\n图 3-48 中的指令把整数转换成浮点数。它们使用的是不太常见的三操作数格式, 有两个源和一个目的。第一个操作数读自于内存或一个通用目的寄存器。这里可以忽略第二 个操作数 ,因 为它的值只会影响结果的高位字节。而我们的目标必须是 X M M 寄存器。在最常见的 使用场景中 ,第 二 个 源和目的操作数都是一样的, 就像下面这 条指令:\nvcvtsi2sdq %rax, %xmm1, %xmm1\n这条指 令从寄存器%r a x 读 出 一 个 长 整数,把 它 转 换成数据类型 d o u b l e , 并把结果存放进\nXM M 寄存器%x mrnl 的 低 字节中。\n最后 , 要在两种不同的浮点格式之间转换, G CC 的当前版本生成的代码需要单独说明。假设 %x mrn0 的低位 4 字 节保 存 着一个单精度值,很 容易 就想到用下面这条指令\nvcvtss2sd %XIIlIIl0, %XIIlIIl0, %XIIlIIl0\n把它转 换成一个双精度值,并 将 结 果 存 储 在寄存器%x mrn0 的 低 8 字节。不过我们发现 GCC\n生成的代码如下\nConversion from single to double precision\nvunpcklps %xmm0, %xmm0, %xmm0 Replicate first vector element\n2 vcvtps2pd %xmm0, %xmm0 Convert two vector elements to double\nvunp c kl p s 指令通常用来交叉放置来自两个 X M M 寄存器的值, 把它们存储到第三个寄存器中 。也 就是说,如 果 一 个 源寄存器的内容为字[ s3 , s2 , s 1 , s。J , 另 一 个 源寄存器为字[ d 3 , dz, d 1 , d 。J , 那 么 目 的 寄 存 器 的 值 会 是 [ s1 , d1, s。, d 。] 。 在上面的代码中, 我们看到三个 操作数使用同一个寄存器 , 所以如果原始寄存器的值为[ x 3 , Xz , X1 , X。J , 那\n么该指令 会将寄存器的值更新为值[ x 1 , X1 , Xo , Xo] 。 v c v t p s 2 p d 指令把源 X M M 寄存器中的两个 低位单精度值扩展成目的 X M M 寄存器中的两个双精度值。对前 面 v u n p c k l p s\n指令的结果应用这条指令会得到值[ d x,o d x o] , 这 里 d x o 是 将 x 转换成双精度后的结果。\n即, 这两条指令的最终效果是 将原 始的%x mrn0 低位 4 字节中的单精度值转换成双精 度值 , 再将其 两个副本保存 到%x mrn0 中。我们不太清楚 GCC 为什么会生成这样的代码, 这样做既没有好处 , 也没有必要 在 XMM 寄存器中 把这个值复 制一遍。\n对于把双精度转换 为单精度 , GCC 会产生类 似的代码 :\nConvers 工 on from double to single prec1s1on\nvmovddup %xrnm0, %xrnm0 Replicate first vector element vcvtpd2psx %xrnm0, %xrnm0 Convert two vector elements to single 假设这些指 令开始执行前 寄存器%x mm0 保存着两个双精度值[工门 工。]。 然后 vrno v d d u p 指\n令把它设 置为[ 工o\u0026rsquo; X。]。 v c v t p d 2p s x 指令把这两个 值转换成单精度, 再存放到该 寄存器的低位一半 中, 并将高位一半设 置为 o , 得到结果[0. 0, 0. 0, Xo , 工。](回想一下, 浮点值\no. 0 是由位模式 全 0 表示的)。同样,用 这种方式 把一种精度转换成 另一种精度 , 而不用下面的单条指令,没有明显直接的意义:\nvcvtsd2ss %xmm0, %xmm0, %xmm0\n下面是一 个不同 浮点 转换 操作的例子 , 考虑以下 C 函数\ndouble fcvt(int i, float•fp, double•dp, long•lp)\n{\nfloat f = *fp;\n*lp = (long)\n*fp = (float)\n*dp= (double) return (double)\n}\ndoubled=•dp; long 1 =•lp; d;\ni;\nl;\nf;\n以及它对应的 x8 6- 64 汇编代码\ndouble fcvt (int i, float *fp, double *dp, long *lp) i in¼edi, fp in¼rsi, dp in¼rdx, lp in¼rcx fcvt:\nvmovss (%rsi), %xmm0 Get f = *fp\nmovq (%rcx) , %rax Get 1 = *lp\nvcvttsd2siq (%rdx), %r8 Get d = *dp and convert to long\nmovq %r8, (%rcx) Store at lp vcvtsi2ss %edi, %xmm1, %xmm1 Convert i to float vmovss %xmm1, (%rsi) Store at fp vcvtsi2sdq %rax, %xmm1, %xmm1 Convert 1 to double vmovsd %xmm1, (%rdx) Store at dp\nThe f ol l owi ng two instructions convert f to double\nvunpcklps %xmm0, %xmm0, %xmm0\nvcvtps2pd %xmm0, %xmm0\nret Return f\nf c v t 的所有参数都是 通过通 用寄存器传递的, 因为它们既不是整数也不是指针。结果通过寄存器 %x mm0 返回。如图 3- 45 中描述的 , 这是 fl o a t 或 d o u b l e 值指定的返回寄存器。在这段 代码 中, 可以看到图 3- 46 ~ 图 3-48 中的许多传送 和转换指令, 还 可以看到GCC 将单精度转换 为双精度的方法 。\n; 练习题 3. 50 对于下面的 C 代码 , 表达式 va ll ~ v a l 4 分别对应程序值 L、 f 、 d 和 1 : double fcvt2(int *ip, float *fp, double *dp, long 1)\n{\ninti= *ip; float f = *fp; doubled= *dp;\n*ip = (int) val!;\n*fp = (float) val2;\n*dp = (double) val3; return (double) val4;\n}\n根据该 函 数 如下的 x8 6-64 代 码 , 确定这个映射 关 系 :\ndouble fcvt2(int *ip, float *fp, double *dp, long 1)\nip in 7.rdi, fp i n 肚 s i , dp i n 7.rdx, 1 in 7.rcx Result returned in 7.xmmO\nfcvt2:\nrnovl (%rdi), %eax\nvrnovss (%rsi), %xrnrn0\n4 vcvttsd2s i (%rdx), %r8d\nmovl %r8d, (%rdi)\nvcvtsi2ss %eax, %xmm1, %xmm1\nvmovss %xmm1, (%rsi)\nvcvtsi2sdq %rcx, %xmm1, %xmm1\n9 vmovsd %xmm1, (%rdx)\nvunpcklps %xmm0, %xmm0, %xmm0\nvcvtps2pd %xmm0, %xmm0\nret\n练习题 3. 51 下 面的 C 函 数 将 类 型 为 s r c _ t 的 参 数 转 换 为 类 型 为 d s 七—t 的 返 回 值 , 这里 两 种 数据类 型都 用 t y p e d e f 定义 :\ndest_t cvt(src_t x)\n{\ndest_t y = (dest_t) x; return y;\n}\n在 x8 6- 64 上执行这 段代 码 , 假设 参 数 x 在 %x rnm0 中 , 或 者在 寄 存 器%r d i 的 某 个适当的命名部分中(即%立江或% e d i ) 。用 一条或 两条 指令来 完 成 类 型 转换 , 并 把 结 果值 复制 到 寄存器%r a x 的 某 个 适 当 命 名 部 分 中(整 数 结 果), 或 %x rnm0 中(浮 点 结果)。给出这条或这些指令,包括源和目的寄存器。\nT, Ty 指令 long double vcvtsi2sdq %r di , %xmm0 double int double float long float float long 11. 2 过程中的 浮点 代 码\n在 x8 6-64 中, XM M 寄存器用来向函数传递浮点 参数,以 及从函数返回浮点 值。如图\n所示, 可以看到如下规则: XM M 寄存器%x mm0 ~ %x mm7 最 多 可以 传递 8 个浮点 参数。按照参数列出的顺序使用这些寄存器。可以通过栈传递额外的浮点 参数。 函数使用 寄存器 %x mm0 来返回 浮点值。\n所有的 XM M 寄存器都是 调用者保存的。被调用者可以 不用保存就覆盖这些 寄存器中任意一个。\n当函数包含指针、整数和浮点 数混合的参数时 , 指针和整数通过通用寄存器传递, 而\n浮点值通过 XM M 寄存器传递。也就是说 , 参数到寄 存器的映射取 决千它们 的类型和排列的顺序。下面是一些例子:\ndouble fi(int x, double y, long z);\n这个函数会把 x 存放在 % e d i 中, y 放在 %x mm0 中 , 而 z 放在 %r s i 中。\ndouble f2(double y, int x, long z);\n这个函数的寄存 器分 配与函数 fl 相同。\ndouble fl(float x, double *Y, long *z);\n这个函数会将 x 放在 %x mm0 中, y 放在 %r 生 中, 而 z 放在%r s i 中。\n; 练习题 3. 52 对于下 面每个 函数声明 , 确定 参数的寄 存器 分配:\ndouble g1(double a, long b, float c, int d); double g2(int a, double *b, float *c, long d); double g3(double *a, double b, int c, float d); double g4(float a, int *b, float c, double d) ; 3. 11. 3 浮点运算操作\n图 3- 49 描述了一组执行算术 运算的标量 AV X2 浮点 指令。每条指 令有一个( S 1 ) 或两个\u0026lt;S 1 , S 2) 源操作 数, 和一个目的 操作数 D。第一个源操作 数 S 1 可以是一个 XMM 寄存器或一个内存位置。第二 个源操作数 和目的 操作数都必 须是 XMM 寄存器。每个操 作都有一条针对单 精度的指 令和一条针对 双精度的 指令。结果存放 在目的寄存 器中。\n单精度 双精度 效果 描述 vaddss vsubss vmulss vdivss vrnaxss vminss vaddsd vsubsd vmulsd vdi vsd vmaxsd vminsd 0-s,+s1 o - s , - s , D- S, XS 1 D- Sz/S1 D 千 max(S 2 , S1) D- min(S 2 , S,) 浮点数加浮点数减浮点数乘浮点数除 浮点数最大值 浮点数最小值 sqrtss sqrtsd D- 尽 浮点数平方根 图 3-49 标扭浮点算术运算。这些指令有一个或两个源操作数和一个目的操作数\n来看一个例子 , 考虑下 面的 浮点 函数:\ndouble funct(double a, float x, double b, inti)\n{\nreturn a*x - b/i;\n}\nx86-64代码如下 :\ndouble funct(double a, float x, double b, 工 nt i) a in %xmm0, x 工n %xmm1, bin %xmm2, i in %edi funct:\nThe following two instructions convert x to double vunpcklps %xmm1, %xmm1, %xmm1\nvcvtps2pd %xmm1, %xmm1\nvmulsd %xmm0, %xmm1, %xmm0 vcvtsi2sd %edi, %xmm1, %xmm1 vdivsd %xmm1, %xmm2, %xmm2\nvsubsd %xmm2, %xmm0, %xmm0 ret\nMul t i pl y a by x Convert i to double Compute b/i\nSubtract from a*x Return\n三个浮点 参数 a 、x 和 b 通过 XM M 寄存器%x mm0~ %x mm2 传递, 而整数参数 通过寄 存器%e 中 传递。标准的 双指令序列用以将参数 x 转换为双精度类 型(第2 ~ 3 行)。另一条转换指令 用来将参数 l 转换为双精度类型(第5 行)。该函数的 值通过寄存 器%x mm0 返回。\n练习题 3. 53 对 于下 面的 C 函 数 , 4 个参数的 类型由 t y p e d e f 定义 :\ndouble funct1(arg1_t p, arg2_t q, arg3_t r,\n{\narg4_t s)\nreturn p/(q+r) - s;\n}\n编译时, GCC 产 生 如下代码 :\ndouble funct1(arg1_t p, arg2_t q, arg3_t r, arg4_t s) funct1:\nvcvtsi2ssq %rsi, %xmm2, %xmm2 vaddss %xmm0, %xmm2, %xmm0\nvcvtsi2ss %edi, %xmm2, %xmm2 vdi vss %xmm0, %xmm2, i 儿 x mmO vunpcklps %xmm0, %xmm0, %xmm0\nvcvtps2pd %xmm0, i儿x mmO vsubsd %xmm1, %xmm0, %xmm0 ret\n确定 4 个参 数类 型可 能 的 组合(答案 可 能 不 止 一种)。让 练习题 3. 54 函 数 f u n c 七2 具有如下原 型:\ndouble funct2(double w, int x, floaty, long z);\nGCC 为该 函 数产 生 如下代码 :\ndouble funct2(double w, int x, float y, long z) w in %xmm0, x in %edi , y in %xmm1 , z in %rsi funct2:\nvcvtsi2ss %edi, %xmm2, %xmrn2 vmulss %xmrn1, %xmm2, %xmrn1 vunpcklps %xmm1, %xmrn1, %xmm1\nvcvtps2pd %x 皿 1 , %xmm2\nvcvtsi2sdq %rsi, %xmm1, %xmm1 vdivsd %xmrn1, %xmrn0, %xmm0\nvsubsd %xmrn0, %xmrn2, %xmm0 ret\n写 出 f u n c t 2 的 C 语言版 本。\n3. 1 1 . 4 定义和使用浮点常数\n和整数 运算 操作不同 , AV X 浮点 操作不能以 立即数值作为操作数。相反, 编译 器必须为所有 的常量值分配和初 始化存储 空间。然后代码在把这些 值从内存读入。下面从 摄氏度到华氏 度转换 的函数就说明 了这个问题:\ndouble cel2fahr(double temp)\n{\nreturn 1.8 * temp + 32 . 0 ;\n}\n相应的 x8 6- 64 汇编代码部分如下 :\ndouble cel 2f ahr (doubl e temp) temp in¼xmmO\ncel2fahr:\n2 vmulsd . LC2 (%rip), %xmm0, %xmm0 Multiply by 1 . 8 3 vaddsd . LC3 (%rip) , %xmm0, %xmm0 Add 32. O 4 ret 5 . LC2 : 6 .long 3435973837 Low-order 4 bytes of 1.8 7 long 1073532108 High-order 4 bytes of 1.8 8 .LC3: 9 .long 0 Low-order 4 bytes of 32 . 0 0 1 .long 1077936128 High-order 4 bytes of 32 . 0 可以 看到函数从标号为 . LC2 的内存位置读出值 1. 8\u0026rsquo; 从标号为 . LC3 的位置读入值 32 . 0。观察这些 标号对应的 值, 可以看出每一个 都是通过一 对 . l o n g 声明和十进 制表示 的值指定的。该怎样把这些 数解释为浮点 值呢?看看标号为 . LC2 的声明, 有两个值: 3435973837\n( Ox c c c c c c c d ) 和 1 0 73532 108 ( 0x 3 f f c c c c c ) 。因为机器采用的 是小端法字节顺 序, 第一个值给出的 是低位 4 字节 , 第二个给出的是 高位 4 字节。从 高位字节, 可以 抽取指数字段为Ox 3 f f (l 0 23 ) , 减去偏 移 10 23 得到指数 0。将两个值的小数位连接起来, 得到小数字段\nOxccccccccccccd, 二进制小数表示 为 0. 8 , 加上隐含的 1 得到 1. 8 。\n五 练习题 3. 55 解释标 号 为 . LC3 处声明 的数 字是 如何 对数 字 3 2. 0 编码的。\n3. 1 1. 5 在浮点代码中使用位级操作\n有时, 我们会发现 GCC 生成的 代码会在 XM M 寄存器上执行位级操作, 得到有用的浮点结果。图 3-50 展示了一些相关的指令, 类似千它们在通用寄存器上对应的操作。这些操作都作用千封装好的 数据, 即它们更新 整个目的 XM M 寄存器, 对两个 源寄存 器的所有位都实 施指定 的位级操 作。 和前面一样, 我们只对标量数据感兴趣, 只 想了解这些指令对目的寄存器的低 4 或 8 字节的 影响 。从下面的例子中可以 看出, 运用 这些操作 通常 可以简单方便地操作 浮点数。\n单精度 双精度 效果 描述\nvxorps vandps\nvorpd andpd\no- s,-s , # o- s, \u0026amp;s ,\n位级异或 ( EXCLUS IVE - OR)\n位级 与C AN DJ\n图 3-50 对封装数 据的位级操 作(这些指令 对一个 XM M 寄存器中的 所有 128 位进行 布尔操作)\n霆 练习题 3. 56 考 虑下 面的 C 函数 , 其 中 EXPR 是用 # d e f i ne 定义的 宏:\ndouble simplefun(double x) { return EXPR(x);\n}\n下面,我们给出 了为不 同 的 EXPR 定义 生成 的 AV X Z 代 码, 其 中, x 的 值保 存在%xmrn0 中。这些代码都对应于某些对浮点数值有用的操作。确定这些操作都是什么。要理解 从内存中取出的常数字的位模式才能找出答案。\n。\n。\n3. 11 . 6 浮点比较操作\nAVX2 提供了两条用 千比较 浮点数值的指令 :\n指令\nUCOffilSS S1, s,\nucornisd S1 , S,\n基于\nS, - S1\ns , —S1\n描述\n比较单精度值比较双精度值\n这些指令类 似千 CMP 指令(参见3. 6 节), 它们都比较操作数 S 1 和 S 2 (但是顺序可能与预计的相反), 并且设置条件码 指示它们的相对值。与 cmpq 一样, 它们遵循以相反顺序列出操 作数的 A T T 格式惯 例。参数 S 2 必 须在 XM M 寄存器中, 而 S 1 可以 在 XM M 寄存器中,也可以在内存中。\n浮点比较指令会设置三个条件码: 零标志位 ZF 、 进位标志位 CF 和奇偶标志位 PF。\n6. 1 节中我们没有讲奇偶 标志位, 因为它 在 GCC 产生的 x86 代码中不 太常见 。对于整数操作 , 当最近的 一次算术或逻辑 运算产生的值的最低位字节是偶校验的(即这个字节中有偶数个 1) \u0026rsquo; 那么就会设置这个标志位。不过对于浮点比较, 当两个操作数中任一个是Na N 时, 会设置该位。根 据惯例, C 语言中如果有个参数为 N a N , 就认为比较失败了, 这个标 志位就被用来发 现这样的 条件。例如 , 当 x 为 N a N 时, 比较 x == x 都会得到 0。\n条件码的设置条件如下:\n顺序 s, ,s, CF ZF PF\n无序的 1 1\nS2 \u0026lt; S1 1\nS 2= 5 1\nS2\u0026gt;S1\n当任一操作数为 N a N 时, 就会出 现无序 的情况。可以 通过奇偶 标志位发 现这种情 况。通常 JP 勺um p on parity ) 指令是条件跳转 , 条件就是 浮点比较得到一个无序的结果。除了这种情况以外, 进位和零标志位的值都 和对应的无符号比较一样 : 当两个操作数相等时 , 设置 ZF; 当 S2\u0026lt; S1 时, 设置 CF。像 j a 和 j b 这样的指令可以根据标志位的各种组合进行条件跳转。\n来看一个浮点比较的例子 , 图 3-5 l a 中的 C 函数会根据参数 x 与 o. 0 的相对关 系进行分\n类, 返回一个枚举类型作为结果 。C 中的枚举类型是编码为整数的 , 所以 函数可能 的值为:\nO ( NEG) , lCZERO), 2 ( POS ) 和 3 ( 0 THER) 。当 x 的值为 N a N 时, 会出现最后一种结果 。\ntypedef enum {NEG, ZERO, POS, OTHER} range_t; range_t find_range(float x)\n{\nint result;\nif (x \u0026lt; 0)\nresult= NEG; else if (x == 0)\nresult= ZERO; else if (x \u0026gt; 0)\nresult= POS;\nelse\nresult= OTHER;\nreturn result;\n}\nC代码 range_t find_range(float x) x in %xmm0 1 find_range: 2 vxorps %xmml , %xmml , %xmm1 Set %xmm1 = 0 3 vucomiss %xmm0, %xmm1 Compare O:x 4 ja .15 If \u0026gt;, goto neg 5 vucom1ss %xmm1, %xmm0 Compare x : O 6 jp .L8 If NaN, goto posornan 7 movl $1, %eax result= ZERO 8 je .L3 It=, goto done 9 .L8: posornan: 10 vucomiss .LCO(%rip), %xmm0 Compare x:O 11 setbe %al Set result= NaN? 1 : 0 12 movzbl %al, %eax Zero-extend 13 addl $2, %eax result += 2 (P OS for \u0026gt; 0, OTHER for NaN) 14 ret Return 15 .L5: neg: 16 movl $0, %eax r se ul t = NEG 17 .L3: done : 18 rep; ret Return b ) 产生的汇编代码\n图 3-51 浮点代码中的条件分支说明\nGCC 为 丘nd _ r a ng e 生成图 3-51 6 中的代码。这段 代码的效率不是很高: 它比较了 x\n和 0. 0 三次 , 即使一次比较就能获得所需的信息。它还生成了浮点 常数两次: 一次使用\nvxorps, 另一次从内存读出这个值。让我们追踪这个函数,看看四种可能的比较结果:\nX \u0026lt; 0. 0 第 4 行的 j a 分支指令会选择跳转 , 跳转到结尾 , 返回值为 0。\nx=O. 0 j a ( 第 4 行)和j p ( 第 6 行)两个分支语句都 会选择不 跳转 , 但是 j e 分支(第8\n行)会选择跳转,以% e a x 等于 1 返回 。\nX \u0026gt; 0. 0 这三个分支都不会选 择跳 转。s e t b e ( 第 11 行)会得到 o ,\n行)会把它增加 , 得到返 回值 2。\na d d l 指令(第1 3\nx=NaN jp 分支(第6 行)会选择跳转。第三个 v uc omi s s 指令(第10 行)会设置进位和零 标志位, 因此 s e t b e 指令(第11 行)和后面的指令会把% e a x 设置为 1 。a d d l 指令\n(第 13 行)会把它增加, 得到返 回值 3。\n家庭作业 3. 73 和 3. 74 中, 你需要试着 手动生成 f i nd _r a ng e 更高效 的实现。\n讫 练习题 3. 57 函数 f unc t 3 有 如下 原 型:\ndouble funct3(int *ap, double b, long c, float *dp);\n对于此函数, GCC 产 生如下 代码 :\ndouble funct3(int•ap, double b, long c, fl oat •dp) ap in r% di , b in %xmm0, c in %rsi, dp in %rdx funct3:\nvmovss (%rdx), %xmm1\nvcvtsi2sd (%rdi), %xmm2, %xmm2\nvucomisd %xmm2, %xmm0 jbe .18\nvcvtsi2ssq %rsi, %xmm0, %xmm0 vmulss %xmm1, %xmm0, %xmm1 vunpcklps %xmm1, o/.xmm1, %xmm1\nvcvtps2pd %xmm1, %xmm0\nret\n.18:\nvaddss %xmm1, %xmm1, %xmm1 vcvtsi2ssq %rsi, %xmm0, %xmm0 vaddss %xmm1, %xmm0, %xmm0 vunpcklps %xmm0, %xmm0, %xmm0\nvcvtps2pd %xmm0, %xmm0 ret\n写出 f unc t 3 的 C 版本。\n11 . 7 对浮点代码的观察结论\n我们可以 看到 ,用 AVX 2 为浮点 数上的 操作产生的机器代码 风格类 似千为 整数上的 操作产生 的代码风格 。它们都 使用一组寄存器来保存和操作数 据值 , 也都使用这些寄存器来传递函数参数。\n当然,处理不同的数据类型以及对包含混合数据类型的表达式求值的规则有许多复杂 之处 , 同时, AVX2 代码包括许 多比只执行整数 运算的 函数更加不同的 指令和格式 。\nAVX2 还有能力 在封装好的 数据上执行并行 操作, 使计算执行 得更快。编译器开发 者正致力于自动化从标量代码到并行代码的转换,但是目前通过并行化获得更高性能的最可\n靠的方法是使 用 GCC 支持的、操纵向量数 据的 C 语言扩展。参见原书 546 页的网络旁 注\nOPT: SIMD, 看看可以怎么做到这样。\n3. 12 小结\n在本章 中, 我们窥 视了 C 语言提供的 抽象层下面的 东西,以 了 解机器级编程。 通过 让编译 器产生机器级程序的 汇编代码 表示 , 我们 了解了编译器和它的优化能力, 以及机器、数 据类型和指 令集。 在第 5 章,我们会看到,当编写能有效映射到机器上的程序时,了解编译器的特性会有所帮助。我们还更完整 地了 解了 程序如何 将数 据存储在不同的内 存区域中 。在第 12 章 会看 到许多 这样的 例子, 应用 程序员需 要知道一 个程序变 量是 在运行时 栈中, 是在某个 动态分 配的 数据结构中, 还是全局程序数 据的一部分。理解程序如何映射到机器上,会让理解这些存储类型之间的区别容易一些。\n机器级 程序和它们的 汇编代 码表示 , 与 C 程序的差别很大。各 种数据类型之间的差别很小。程 序是以指令序列来表示的,每条指令都完成一个单独的操作。部分程序状态,如寄存器和运行时栈,对程序 员来说是直接可见的。本书仅提供了低级操作来支持数据处理和程序控制。编译器必须使用多条指令来 产生和操作各 种数据结构, 以及实现像 条件 、循环和过 程这样的控制结 构。我 们讲述了 C 语言和如 何编译它的许多不同方面。我 们看到 C 语言中缺乏边界检查, 使得许多程序容易 出现缓 冲区 溢出。虽 然最近的运行时系统提供了安全保护,而且编译器帮助使得程序更安全,但是这巳经使许多系统容易受到恶意 入侵者的攻击。\n我们只分析了 C 到 x86-64 的映射, 但是 大多 数内容对其他语言和机器组合来说也是类似的 。例如, 编译 C++ 与编译 C 就非常相似。实际 上 , C++ 的早期 实现 就只是 简单地执行了从 C++ 到 C 的源到源的 转换 , 并对结果 运行 C 编译器, 产生目标 代码。C++ 的对象 用结构来表示 , 类似千 C 的 s t r uc t 。C++ 的方法是 用指向实现方法的 代码的 指针来 表示的。相比而言 , J ava 的实现方式完全不同。Java 的目标代码是一种特殊的 二进制表示 , 称为 Java 宇节代码。这 种代码 可以 看成是虚拟机的机器级 程序。正 如它的名字暗示的那样,这种机器并不是直接用硬件实现的,而是用软件解释器处理字节代码,模拟虚拟机的 行为。另外 , 有一种称 为及 时编译 ( jus 臼 n-t ime compila tion) 的方法, 动态地将字节代码序列 翻译 成机器指令。当代码要执行多次时(例如在循环中),这种方法执行起来更快。用字节代码作为程序的低级表示, 优点是相同的代码可以 在许多 不同的 机器上执行 , 而在本章 谈到的机器代码只能 在 x86-64 机器上运行。\n参考文献说明\nIntel 和 AMD 提供 了关于他们处理器的 大量文档。包括从汇编语言程序员 角度来看硬件的概貌[ 2,\n50], 还包 括每条指 令的详 细参考 [ 3 , 51] 。读指令 描述很 复杂 , 因为 1) 所有的 文档都 基于 Inte l 汇编代码格式 , 2 ) 由于不同的寻址和执行模 式, 每条指 令都 有多个变种, 3 ) 没有说明性示例。不 过这些文档仍然是关于每条指令行为的权威参考。\n组织 x86-64. org 负责定义运行 在 Linux 系统 上的 x86-64 代码的应用二进制接口( A pp licatioin Binary Interface, ABI) [ 77] 。这个 接口描述 了一些细节 , 包括过程 链接、二 进制代码文件和大最的为了让机器代码程序正确运行所需要的其他特性。\n正如我 们讨论过的那 样, GCC 使用的 AT T 格式与 Intel 文档中使用的 Intel 格式和其他编译 器(包括\nMicrosoft 编译器)使用的格式 都很不相同。\nMuchn ick 的关于编译器设 计的书[ 80] 被认 为是 关千代 码优化技术最 全面的 参考书。 它涵盖了许多我们在此讨论过的技术,例如寄存器使用规则。\n已经有很多 文章 是关于使用缓 冲区溢出通过因特网来 攻击系统 的。Spafford 出版了关于 1988 年因特网蠕虫的详细分析[ 105] , 而帮助阻 止它传播的 MIT 团队的成员也出版了一些论著[ 35] 。从那以后,大噩的论文和项目提出了各 种创建和阻 止缓 冲区溢出攻 击的方 法。Seacord 的书[ 97] 提供 了关于缓 冲区 溢出和其他一些 对 C 编译器产生的 代码 进行攻 击的 丰富信息 。\n家庭作业\n3. 58 一个函数的原型为 long decode2(long x, long y, long z);\nGCC 产生如下 汇编代 码:\n1 decode2 :\nsubq imulq movq salq sarq xorq\n8 ret\n%rdx, %rsi\n%rsi, %rdi\n%rsi, %rax\n$63, %rax\n$63, %rax\n%rdi, %rax\n•• 3. 59\n参数 x 、 y 和 z 通过寄 存器%r d i 冷r s i 和% r d x 传递。代码 将返回值 存放在 寄存器 % r a x 中 。写出等价 于上述 汇编代 码的 de c ode 2 的 C 代码 。\n下面的代码计算 两个 64 位有 符号 值 工 和 y 的 128 位乘积, 并将结果存储在内存中:\ntypedef int128 int128_t;\nvoid store_prod(int128_t *dest, int64_t x, int64_t y) {\n*dest = x * (int128_t) y;\n}\nGCC 产出下面的 汇编代 码来实现计算 :\ns t or e _pr od :\nmovq %rdx, %rax cqto\nmovq sarq imulq imulq addq mulq addq movq movq ret\n%rsi, %rcx\n$63, %rcx\n%rax, %rcx\n%rsi, %rdx\n%rdx, %rcx\n%rsi\n%rcx, %rdx\n%r ax , (%rdi)\n%rdx, 8(%rdi)\n•• 3. 60\n为了 满足 在 64 位机器上实现 128 位运 算所需的多精度计算, 这段代 码用了三个乘法。描 述用来计算乘积的 算法, 对汇编代 码加注释, 说明它是如何 实现你的 算法的。提示: 在把参数 x 和 y\n扩展到 1 28 位时 , 它们可以 重写为 x = 264 • x, +x, 和 y = zs• • y, + y ,\u0026rsquo; 这里 x, \u0026rsquo; x ,\u0026rsquo; y, 和 y , 都是\n64 位值。类似地 , 1 28 位的乘 积可以写成 p = 26 1·. p , + p, , 这里 p , 和 p , 是 64 位值。 请解 释这 段代码是 如何 用 x, \u0026rsquo; x ,\u0026rsquo; y, 和 y , 来计算 p , 和 p , 的。\n考虑下面的 汇编代码 :\nlong loop(long x, int n) x in¾rdi, n i n ¾es1\nl oop :\nmovl movl movl jmp\n. L3 :\nmovq andq orq salq\n.L2 :\n%esi, %ecx\n$1, %edx\n$0 , %eax\n. L2\no/.rdi, 壮 8 o/.rdx, o/.r8 o/.r8, o/.rax\n%cl, o/.rdx\ntestq %rdx, %rdx jne . L3\nrep; ret\n以上代 码是编译以 下整体形式的 C 代码产生的 :\nlong loop(long x, int n)\n{\nlong result= long mask;\nfor (mask = ; mask result I=·- \u0026ndash; - '\n; mask =) {\n}\nreturn result;\n}\n•• 3. 61\n•• 3. 62\n你的任务是 填写这个 C 代码中缺失的部分, 得到一个程序 等价 千产生的 汇编代码 。回想一下, 这个 函数的结 果是在寄存 器%r a x 中返回的 。你会发现以下 工作很有帮助: 检查循环之前 、之中和之后的 汇编 代码 , 形成一个寄存器 和程序变最 之间一致的映射 。\n哪个寄存器保存 着程序值 x、n 、r e s u l t 和 ma s k?\nr e s u l t 和 ma s k 的初始值是 什么?\nma s k 的测试条件是什么?\nma s k 是如何被修改的?\nr e s ul t 是如何被修改的\n填写这段 C 代码中所有 缺失的部分。\n在 3. 6. 6 节, 我们 查看了下面的 代码, 作为使用 条件数据传送的一 种选择 :\nlong cread(long•xp) { return (xp? *xp: 0);\n我们给出了使用 条件传送指令的一个尝试实现, 但是 认为它是不合法的, 因为它 试图从一个空地址读数据。\n写一个 C 函数 c r e a d_a l 七, 它与 c r e a d 有一样的 行为,除 了 它可以 被编译成使用条件数 据传送。当编译时,产生的代码应该使用条件传送指令而不是某种跳转指令。\n下面的代码给出了一个 开关语 句中根 据枚 举类型值进行分支选择的 例子。回忆 一下 , C 语言中枚举类型只是一种引人一组与整数值 相对应的 名字的方法。默认情况下,值 是从 0 向上依次赋给名字的。在我们 的代码中 , 省略了与各种情况标 号相对应的动作 。\n/• Enumerated type creates set of constants numbered O and upward•/ typedef enum {MODE_A, MODE_B, MODE_C, MODE_D, MODE 王 } mode_t;\nlong switch3(long *p1, long *p2, mode_t action)\n{\nlong result= O; switch(action) { case MODE_A:\ncase MODE_B: case MODE_C: case MODE_D: case MODE_E: default:\n}\nreturn result;\n产生的 实现各个动 作的汇编代 码部分如图 3-52 所示。注释指明 了参数位置, 寄存器值, 以及各个跳转目的的情况标号。\npl i n 肛 di , p2 i n r幻 si , action in¾edx\n. L 8 : MODE_E\nmovl $27, %eax ret\n. L3 :\nmovq movq movq ret\n.L5:\nmovq\naddq movq ret\n.L6:\nmovq movq ret\n.L7:\nmovq movq movl ret\n.L9:\nmovl ret\n(%rsi), %rax (%rdi), %rdx 沿·dx , (壮 s i )\n(¾rdi), ¾rax (¾r s 立 , ¾r ax\n¾rax, (¾rdi)\n$59, (¾rdi) (¾rsi), ¾rax\n(\u0026lsquo;Y.rsi),\u0026lsquo;Y.rax\n\u0026lsquo;Y.rax, ( 壮 di )\n$27,\u0026lsquo;Y.eax\n$12, %eax\nMODE_A\nMODE_B\nMODE_C\nMDDE_D\ndefault\n图 3- 52 家庭作业 3. 62 的汇编代码。这 段代码 实现了 s wi t c h 语句的各个分 支\n\u0026ldquo;3. 63\n填写 C 代码中缺失的部分。代码包括落人 其他情况的 情况, 试着重建 这个情况。\n这个程 序给你一个 机会, 从反汇编机 器代 码逆向 工程一个 s wi t c h 语句。在下面这 个过程中 , 去掉了 s wi t c h 语句的主体 :\nlong switch_prob(long x, long n) { long result= x;\nswitch(n) {\n/• Fill in code here•/\n}\nreturn result;\n图 3-53 给出了这 个过程的 反汇编机 器代 码。\n跳转表驻 留在内 存的不同区域中。可以从 第 5 行的间接跳 转看出来, 跳转表的 起始地址 为 Ox 4006f 8。用调试器 G DB , 我们可以 用命 令 x / 6g x Ox 4006f 8 来检查组 成跳转表的 6 个 8 字节字的内存。G DB 打印出下 面的内 容:\n(gdb) x/6gx Ox4006f8\nOx4006f 8 : Ox00000000004005a1 Ox400708: Ox00000000004005a1 Ox400718: Ox00000000004005b2\nOx00000000004005c3 Ox00000000004005aa Ox00000000004005bf\n用 C 代码填写开关语句的 主体 , 使它的行 为与机器代 码一致。\nlong s wit c h _pr ob(l ong x , long n) x in r7. di , n i n 肛 si\n0000000000400590 \u0026lt;switch_prob\u0026gt;:\n400590: 48 83 ee 3c\n400594: 48 83 fe 05\n400598: 77 29\n40059a: ff 24 f5 f8 06 40 00\n4005a1: 48 8d 04 fd 00 00 00\n4005a8: 00\n4005a9: c3\n4005aa: 48 89 f8\n4005ad: 48 c1 f8 03\n4005b1: c3\n4005b2: 48 89 f8\n4005b5: 48 c1 eO 04\n4005b9: 48 29 f8\n4005bc: 48 89 c7\n4005bf : 48 Of af ff\n4005c3: 48 8d 47 4b\n4005c7: c3\nsub cmp ja jmpq lea\nretq mov sar retq mov shl sub mov imul lea retq\n$0x3c,%rsi\n$0x5,%rsi\n4005c3 \u0026lt;switch_prob+Ox33\u0026gt;\n*Ox4006f8(,%rsi,8) Ox0(,%rdi,8),%rax\n%r d i , %r a x\n$0x3,%rax\n%rdi,%rax\n$0x4,%rax\n%rdi,%rax\n%rax,%rdi\n%rdi,%rdi Ox4b(%rdi),%rax\n•*• 3. 64\n图 3-53 家庭作业 3. 63 的反汇编代 码\n考虑下面的 源代 码, 这里 R 、 S 和 T 都是用 #d e f i ne 声明的常数 :\nlong A [R] [SJ [Tl ;\nlong store_ele(long i, l ong j, long k, long *dest)\n{\n*\u0026lt;lest = A [i] [j] [k];\nreturn sizeof(A);\n}\n3 65\n在编译这个 程序中 , G CC 产生下面的 汇编代 码:\nlongs t or e _el e ( l ong i , long j, long k, long *des t ) i in¾rdi, j in r 无 si , k i n 肛 dx, dest in¾rcx store_ele:\nleaq (%rsi,%rsi,2), %rax leaq (%rsi,%rax,4), %rax movq %rdi, %rsi\nsalq $6, %rsi\naddq %rsi, %rd1\naddq %rax, %rdi\naddq %rdi, %rdx\nmovq A(,%rdx,8), %rax movq %rax, (%r cx )\nmovl $3640, i 儿 eax\nret\n将等式 ( 3. 1 )从二维扩展 到三维 , 提供数组 元素 A [ i ] [ j ] [ k l 的位置的公 式。\n运用 你的逆向 工程技术 , 根据汇编代码 , 确定 R 、S 和 T 的值。\n下面的代 码转置一个 M X M 矩阵的元素 , 这里 M 是一个用 #d e f i ne 定义的常数 :\nvoid transpose(long A[M] [M]) { long i, j;\nfor (i = 0; i \u0026lt; M; i ++)\nfor (j = 0; j \u0026lt; i ; j ++ ) { long t = A[i][j]; A[i][j] = A[j][i];\nA [j] [i] = t;\n}\n}\n当用优化等级 - 0 1 编译时, GCC 为这 个函数的内 循环产生下 面的 代码 :\n. L6 :\nmovq movq movq movq addq addq cmpq jne\n(%rdx), %rcx (%rax), %rsi\n%rsi, (%rdx)\n%rcx, (%rax)\n$8, %rdx\n$120, %rax\n%rdi, %rax\n. L6\n3. 66\n我们可以 看到 GCC 把数组索 引转换 成了指 针代 码。\n哪个寄存器保 存着指向 数组元素 A [ i ] [ j ]的指针?\n哪个寄 存器保 存着指向 数组元素 A [ j J [ i ] 的指针?\nM 的值是多少?\n考虑下 面的 源代 码, 这里 NR 和 NC 是用 #d e f i ne 声明的宏表达式 , 计算用参数 n 表示 的矩阵 A 的维度。这段代码计算矩阵的第)列的元素之和。\nlong sum_col(long n, long A[NR(n)] [NC(n)], long j) { long i;\nlong result= O;\nfor (i = O; i \u0026lt; NR(n); i++) result += A [i] [j] ;\nreturn result;\n}\n编译这个程 序, GCC 产生下 面的 汇编代码 : long sum_col(long n , long A[NR( n)] [ NC(n)] , l ong j) n i n 肛 di , A 江 Zrs i , j in Zrdx\nsum_col:\nleaq leaq movq testq jle salq leaq movl movl\n.L3:\n1 (, o/.rdi, 4) , o/.r8\n(o/.rdi,o/.rdi,2), o/.rax o/.rax, o/.rdi\no/.rax, o/.rax\n.L4\n$3, o/.r8 (o/.rsi,o/.rdx,8), o/.rcx\n$0, o/.eax\n$0, o/.edx\naddq (%rcx), %rax\naddq $1, %rdx\naddq 缸8 , %rcx\ncmpq %rdi, %rdx\njne .L3\nrep; ret\n.L4:\nmovl ret\n$0, %eax\n\u0026quot; 3. 67\n运用 你的逆向 工程技术 , 确定 NR 和 NC 的定义。\n这个作业要查看 GCC 为参数和返回 值中有结 构的 函数产生的 代码 , 由此可以 看到这 些语言特性通常是如何实现的。\n下面的 C 代码中有 一个函数 pr o c e s s , 它用结 构作为参数 和返 回值, 还有 一个函数 e v a l , 它调用 p r o c e s s :\ntypedef struct { long a[2];\nlong•p;\n} strA;\ntypedef struct { long u[2]; long q;\n} strB;\nstrB process(strA s) { strB r;\nr.u[O) = s . a [1) ;\nr.u[1) = s . a [O) ;\nr.q =•s.p; return r;\n}\nlong eval (long x, long y, long z) { strA s;\ns.a[O] = x;\ns.a[l] = y;\ns.p = \u0026amp;z;\nstrBr = process(s);\nreturn r.u[O] + r.u[l] + r.q;\n}\nGCC 为这 两个函数产生下 面的 代码 :\nstrB process (strA s) process:\nmovq\nmovq movq movq movq movq movq movq ret\n¾rdi, ¾rax 24(¾rsp), ¾rdx (¾rdx), ¾rdx 16(¾rsp), ¾rcx\n¾rcx, (¾rdi) 8(¾rsp), ¾rcx\n¾rcx, 8(¾rdi)\n¾rdx, 16(¾rdi)\nlong eval(long x, long y , long z)\n1 x in r% d,i eval: y in %rsi, z in %rdx 2 3 subq movq $104, 7,rsp 7.rdx, 24(7,rsp) 4 leaq 24(7.rsp), 7.rax 5 movq 7.rdi, (7.rsp) 6 movq 7.rsi, 8(7.rsp) 7 movq 7.rax, 16(7.rsp) 8 leaq 64(7.rsp), 7.rdi 9 call process 10 movq 72(7.rsp), 7,rax 11 addq 64(7.rsp), 7.rax 12 addq 80(7.rsp), 7.rax 13 addq $104, 7.rsp 14 ret 从 e va l 函数的第 2 行我们 可以 看到, 它在栈上分 配了 104 个字节 。画出 e va l 的栈帧 ,给出它在调用 pr oc e s s 前存储在栈上的值。 e va l 调 用 pr oc e s s 时传递了什么值? p r o c e s s 的代码是 如何访间结 构参数 s 的元素的? pr oc e s s 的代码是如何设 置结 果结构r 的字段的? ·: 3. 68\n完成 e va l 的栈帧图 ,给出 在从 pr oc e s s 返回后 e va l 是如何访问 结构 r 的元素的 。 就如何传递作为函数参数的结构以及如何返回作为函数结果的结构值,你可以看出什么通用的 原则?\n在下 面的代码中 , A 和 B 是用ii de f i ne 定义的常数 :\ntypedef struct {\nint x[A] [BJ; /• Unknown constants A and B•/ long y ;\n} strl;\ntypedef struct { char array[B]; int t;\nshort s [A]; long u;\n} str2;\nvoid setVal(strl *P, str2 *q) { long vl = q-\u0026gt;t;\nlong v2 = q-\u0026gt;u; p-\u0026gt;y = vl+v2;\n}\nGCC 为 s e t Va l 产生下 面的代 码:\nvoid set Val ( srt 1 *P, s tr 2 • q)\np i n 肛 di , q 江\nset Val :\nr¼ si\nmovslq addq\nmovq ret\n8(%rsi), %rax 32(%rsi), %rax\n¼rax, 184(%r d立\n·: 3. 69\nA 和 B 的值是多少?(答案是唯一的。)\n你负责维 护一个大型的 C 程序, 遇到下面的代 码:\ntypedef struct {\n2 int first;\n3 a_struct a[CNT];\n4 int last;\n5 } b_struct;\n6\nvoid test(long i, b_struct *bp)\n8 {\n9 int n = bp-\u0026gt;first + bp-\u0026gt;last;\n10 a_struct *ap = \u0026amp;bp-\u0026gt;a[i];\n11 ap-\u0026gt;x[ap-\u0026gt;idx] = n; 12\n编译时常数 CN T 和结构 a _ s tr uc t 的声明是在一 个你没有访问权限的文件中。幸好, 你有代\n码的 .o\u0026rsquo; 版本 , 可以 用 OBJDUMP 程序来 反汇编这些 文件 , 得到下面的 反汇编代码 :\nvoid test (long i, bs_ tr uct • bp) i in 7.rdi, bp in 7.rsi\n0000000000000000 \u0026lt;test\u0026gt;:\n0: Sb Se 20 01 00 00\n6: 03 Oe\nS : 4S Sd 04 bf\nc: 4S Sd 04 c6\n10: 4S Sb 50 OS\n14: 4S 63 c9\nmov Ox120(¾rsi),¾ecx add (¾rsi),¾ecx\nlea (¾rdi,¾rdi,4),¾rax lea (¾rsi,¾rax,8),¾rax mov Ox8(¾rax),¾rdx movslq¾ecx,¾rcx\n17:\nle:\n48 89 4c dO 10\nc3\nmov retq\n%rcx,Ox10(%rax,%rdx,8)\n*** 3. 70\n运用你的逆向工程技术,推断出下列内容:\nCNT 的值。\n结构 a s tr uc t 的完整声 明。假设 这个结构中只有字段 i d x 和 x , 并且这两个字段保存的都是有符号值。\n考虑下面的联合声明:\nunion ele {\nstruct {\nlong *p; long y;\n} el; struct {\nlong x;\nunion ele *next;\n} e2;\n};\n这个声明说明联合中可以 嵌套结 构。\n下面的 函数(省略了一些表达式)对一个链表进行 操作 , 链表是以 上述联 合作 为元素的 :\nvoid proc (union ele *up) {\nup- \u0026gt; - = * C-\n}\n— ) -;\n下列字段的偏移址是多少(以字节为单位):\ne1.p e1.y e2.x e2.next\n这个结构总共需要多少个字节?\n编译器为 pr oc 产生下 面的 汇编代 码:\nvoid proc (union el e • up ) up in¾rdi\nproc:\nmovq movq movq subq movq ret\n8(%rdi), %rax (%rax), %rdx (%rdx), %rdx 8(%rax), %rdx\n%rdx, (%r d 立\n3. 71 •• 3. 72\n在这些 信息的基础上 , 填写 p r oc 代码中 缺失的 表达式。提示: 有些联合引用的解 释可以 有歧义 。当你 清楚引用指引到哪里的 时候, 就能够澄清 这些歧义。只有一个答案, 不需 要进行强制类型转换, 且不违反 任何类 型限 制。\n写一个函数 g ood _e c ho , 它从标准输人读取一行,再把它写到标准输出。你的实现应该对任意长度的 输入行都能工作。可以 使用库 函数 f ge ts , 但是你必须确保即使当输入行要求比你已经为缓冲区分配的更多的空间时,你的函数也能正确地工作。你的代码还应该检查错误条件,要在遇到 错误条件时返 回。参 考标 准 I/ 0 函数的定 义文 档[ 45 , 61] 。\n图 3-54a 给出了一 个函数的代 码, 该函数类 似于函 数 v f u nc t ( 图 3- 43a ) 。我们用 v f unc t 来说明过帧指针在管 理变长栈帧中的 使用情况 。这里的新 函数 a fr a me 调用库函数 a l l oc a 为局 部数组 p 分配空间 。a l l o c a 类似于更常用的 函数 ma l l oc , 区别在于它在运行 时栈上分 配空间。当正在执行的过程返回时 ,该 空间 会自动释放 。\n图 3-54 b 给出了部 分的汇编代码, 建立帧指针, 为局部变量 1 和 p 分 配空间。非常类似于\n第 3 章 程序的机器级表示 225\nv fr a me 对应的 代码。在此使用与练习题 3. 49 中同样的表示 法: 栈指针在第 4 行设置为值 S1 , 在 第 7 行设置为值 切。 数组 p 的起始地址 在第 9 行被设置为值 p。Sz 和 p 之间可能 有额外的 空间 e,\u0026rsquo;\n数组 p 结尾和 S1之间可能 有额外的空间 e, .\n用数学语言解 释计算 S2 的逻辑 。\n用数学语言 解释计算 p 的逻辑 。\n确定使 e1 的值最小 和最大的 n 和 s , 的值。\n这段代 码为 Sz 和 p 的值保证了怎 样的对齐属性?\n#include \u0026lt;alloca.h\u0026gt;\nlong aframe(long n, long idx, long *q) long i;\nlong **P = alloca(n * sizeof(long *)); p[O] = \u0026amp;i;\nfor (i = 1 ; i \u0026lt; n; i ++)\np[i] = q; return *p[idx];\nC代码 l ong 红 r ame (l ong n, long i dx, l ong • q)\nn 江 肛 di , i dx in i.rsi , q 耳 1 ri. dx aframe:\n图 3 - 54\nb ) 部分生成的汇编代码\n家庭作业 3. 72 的代码。该函数类似于图 3- 43 中的函数\n3. 73 •• 3. 74\n3. 75 用汇编代码 写出匹配图 3-51 中函数 f i nd_r a nge 行为的 函数。你的代码必须只包含一个浮点 比较指令,并用条件分支指令来生成正确的结果。在产种可能的参数值上测试你的代码。网络旁注 ASM : EASM 描述了如何在 C 程序中嵌 入汇编代 码。\n用汇编代码写出匹配图 3-51 中函数 f i nd_r a nge 行为的 函数。你的代码必须只包含一个浮点 比较指令 , 并用条件传 送指令 来生成 正确的结果。你可能 会想要 使用指令 c movp ( 如果设置了 偶校验位传送)。在沪 种可能的 参数值上测试你的代 码。网 络旁 注 ASM : EASM 描述了如何在 C 程序中嵌入汇编代码。\nISO C99 包括了支持 复数的 扩展 。任何 浮点 类型都可以 用关键字 c o mp l e x 修饰。这里有一些使用复数数据的示例函数,调用了一些关联的库函数:\n#include \u0026lt;complex.h\u0026gt;\ndouble c_imag(double complex x) { return cimag(x);\n}\ndouble c_real(double complex x) { return creal(x);\n9 } 10 11 double complex c_sub(double complex x, double complex y) { 12 return x - y; 13 } 编译时, G CC 为这些 函数产生如下 代码:\ndouble c_imag(double compl e x x)\nc_imag: movapd %xmm1, %xmm0 ret double c_real (doubl e complex x)\nc_real: rep; ret double complex c_sub(double complex x, double complex y)\nc_sub:\nsubsd %xmm2, %xmm0\nsubsd %xmm3, %xmml\nret\n根据这些例子,回答下列问题:\n如何向函数传递 复数 参数? 如何从函数返回复数值? 练习题答案\n1 这个练习使你熟悉各种操作数格式。 操作数 值 注释 %r a x Ox l OO 寄存器 Oxl 04 OXAB 绝对地址 $0x l 08 Ox l 08 立即数 (%r a x ) OXFF 地址 Ox l OO 4 ( %r a x ) OXAB 地址 Ox l 04 9 ( %r a x, % r dx ) Ox ll 地址 Ox l OC 260 ($r c x, % r d x ) Oxl3 地址 Ox l 08 OXFC (, %r c x , 4) OxFF 地址 Ox l OO ( %r a x , %r dx, 4) Oxll 地址 Oxl OC 3 2 正如我们已 经看到的 , G CC 产生的 汇编代码指令上有后缀, 而反 汇编代码没有。能 够在这两种形式之间转换是 一种很 重要的 需要学习的技能。一个重要的特性就是, x 8 6 - 6 4 中的内存引 用总是用四字长寄存器给出 , 例如釭a x , 哪怕操作数只是 一个字节 、一个字或是 一个双字。\n这里是带后缀的代码:\nmovl %eax, (%rsp) mov11 (%rax), %dx movb $0xFF, %bl\nmovb (%rsp,%rdx,4), %dl movq (%rdx), %rax\nmo 四 %dx , (%rax)\n3. 3 由于我们会依 赖 GCC 来产生大多 数汇编代码 , 所以能够写正确的汇编 代码并不是一项很关键的技能。但是, 这个练习会帮助你熟 悉不同的 指令和操作 数类型。\n下面给出了有错误解释的代码:\nmovb $0xF, (%ebx) Cannot use Y.ebx as address register\nmovl %rax, (%rsp) Mismatch be 切 een ins tr uct io n s 立 f i x 却 d register ID\nmovw (%rax),4(%rsp) Cannot have both source and destination be memory references movb %al, %s1 No register named Y.sl\nmovq %rax, $0x123 Cannot have 1·mmed1·ate as destination\nmovl %eax,%rdx Destination ope 丘 u,d incorrect size\nmovb %s i, 8 (%rbp) Mismatch between instruction s吐丘x 迎 dr egis t er ID\n3.4 这个练习 给你更多 经验,关 于不同的数据传送指令, 以及它们与 C 语言的数据类 型和转换规则的关系。\nsrc t dest t 指令 注释 long long movq ( %r d 习,%r a x rnovq % r a x, ( % r s i ) 读 8 个字节 存 8 个字节 char int novsbl(%rdi),%eax movl %e a x, ( % r s i ) 将 c ha r 转换成 i nt 存 4 个字节 char unsigned rnovs bl ( %r d习,%e a x movl %e a x , 伐 r s i ) 将 c ha r 转换成 i nt 存 4 个字节 unsigned char long movzbl(%rdi),%eax movq %r a x , (%rsi) 读一个字节并零扩展 存 8 个字节 int char movl (%rdi) , %e a x movb %a l , (%rsi) 读 4 个字节 存低位字节 unsigned unsigned char rnov l ( %r d 习,%e a x movb %a l , ( %r s 习 读 4 个字节 存低位字节 char short movs bw ( %r d i ) , %a x movw %a x , (%rsi) 读一个字节并符号扩展 存 2 个字节 5 逆向工程是一 种理解 系统的好方法。在此, 我们想要逆转 C 编译器的效果, 来确定什么样的 C 代码会得到这样的 汇编代码 。最好的方法是 进行 “模拟", 从值 x 、y 和 z 开始, 它们分别在指 针 x p 、\nyp 和 z p 指定的位置。于是 , 我们可以 得到下面这样的效 果:\nvoi d decode1 (l ong •xp , long *YP, l ong 五 p) xp in 7.rdi, yp in 7.rsi, zp in 7.rdx\ndecodel:\nmovq movq (o/.rdi), o/.r8 (%rsi), %rcx Get x =•xp Get y = • yp movq (\u0026lsquo;Y.rdx),\u0026lsquo;Y.rax Getz=•zp movq %r8, (%rsi) Store x at yp movq %rcx, (%rdx) Store y at zp movq ret %rax, (%rdi) Store z at xp 由此可以产生下 面这样 的 C 代码:\nvoid decode1(long *xp, long *YP, long *zp)\n{\nlong x = *xp; long y = *yp; long z = *zp;\n*YP = x;\n•zp = y;\n•xp = z;\n3 6 这个练习说明 了 l e a q 指令的 多样性,同 时也让你更多地练习解读各种操作数形式。虽然在图 3-3\n中有的操作数格式被划分为“内存”类型,但是并没有访存发生。\n指令 结果 leaq 6 ( 毛r a x ) , %r dx 6+.r l eaq ( 令 r a x , %r c x ) , %r dx 工 + y leaq(%rax,%rcx,4),%rdx 工+ 4y leaq 7 (%rax, % r a x , 8), %rdx 7+ 9 工 leaq OxA (, % r c x , 4), % r dx 10+4y l eaq 9(%rax, %r c x , 2 ) , %rdx 9+.r+Zy 3 7 逆向 工程再 次被证明是学 习 C 代码和生成的 汇编代 码之间 关系的 有用 方式。\n解决此类型问题的最好方式是为汇编代码行加注释,说明正在执行的操作信息。下面是一个 例子:\nlong scale2(long x, long y, long z) x in i.rdi, y in½rsi, z in i.rdx\nscale2:\nleaq (%rdi, %rdi, 4) , %rax 5• x leaq (%rax,%rsi,2), %rax 5• x + 2• y leaq (%rax,%rdx,8), %rax 5• x + 2• y + 8• z ret 由此很容易得到缺失的表达式:\nlong t = 5 * x + 2 * y + 8 * z;\n3. 8 这个练习使你有机会检验对操作数和算术指令的理解。指令序列被设计成每条指令的结果都不会影响后续指令的行为。\n指令 目的 值 addq %rcx, (毛r a x ) OxlOO OxlOO subq %rdx, 8 ( % r a x ) Oxl08 OxAB 耳 nul q $16, ( % r a x, % r dx, 8) OxllB OxllO incq 16( 令r a x ) OxllO Oxl4 decq %rcx %rcx OxO subq %rdx, % r a x %rax OXFD 3. 9 这个练习使你有机 会生成一点汇编代码。 答案的代码由 GCC 生成。将参数 n 加载到寄存器%e c x\n中, 它可以 用字节 寄存 器%c l 来指定 s ar l 指令的移位量。使用 mo v l 指令看上去有点 儿奇怪, 因为\nn 的长度是 8 字节, 但是要 记住只有最低位的 那个字节 才指 示着移 位量。\nlong shitt_left4_rightn(long x, long n)\nX 立 7.r di , n in 7.rsi shift_left4_rightn:\nmovq %rdi, %rax Get x salq $4, %rax x«= 4 movl %esi, %ecx Get n (4 bytes) sarq %cl, %rax x»= n 3. 10 这个练习 比较简单 , 因为汇编代 码基本上 沿用 了 C 代码的结构。\nlong tl = x I y; long t2 = tl»3; long t3 = -t2; long t4 = z-t3;\n3. 11\n3. 12\n3 13\n3. 14\n3 15\n这个指令用来将寄存器 %r dx 设置为 o, 运用了对任意 x , 工^工=O 这一属性。它对应于 C 语句 x= O。\n将寄存 器%r dx 设 置为 0 的更直接的 方法是用 指令 mov q $0, %r dx 。\n不过, 汇编 和反 汇编这段 代码 , 我们 发现使用 xor q 的版本 只需要 3 个字节, 而使用 movq 的版本需要 7 个字节。其 他将 %r d x 设 置为 0 的方法都依 赖于这样一个 属性 , 即任何 更新低位 4 字节的指令都 会把 高位字 节设 置为 0 。因此, 我 们 可以使用 xo r l % edx, % edx ( 2 字节)或 mov l\n$0,% e d x( 5 字节)。\n我们可以 简单地把 c q t o 指令替换为将寄 存器 %r d x 设置为 0 的指令 , 并且用 d i vq 而不是 迈i v q 作为我们的除法指令,得到下面的代码:\nvoid ru em 中 v( uns i gned long x, unsigned long y, unsigned long *qp, unsigned long•rp)\nx rn i.rdi , y in i.rsi , qp i n 肛 dx , rp in i.rcx uremdiv:\n汇编代码不会记录程序值的类型,理解这点这很重要。相反地,不同的指令确定操作数的大小以 及是有符号的 还是无 符号的。当从指令序列映 射回 C 代码时 , 我们必 须做一点儿侦查 工作, 推断程序值的数据类型。\n后缀 \u0026rsquo; l \u0026rsquo; 和寄 存器指示符 表明 是 32 位操 作数 , 而比 较是对补码的< 。 我们可以 推断 da t a _ t -\n定是 i n t 。\n后缀`矿和寄存 器指示符 表明是 16 位操 作数, 而比较是对补码的 >= 。 我们可以 推断 da t a _ 七一定是 s ho r t 。\n后缀'矿和寄存器指示符表明是 8 位操作数 , 而比较是对无 符号数的 <= 。 我们可以 推断 da t a _ t\n一定是 uns i g ne d c har 。\n_o. 后缀'矿和寄存器指示符 表明是 64 位操作数, 而比较是!= , 有符 号、无符号和指 针参数都是一样的。我们可以推断 da t a _ t 可以 是 l o ng 、uns i g ne d l ong 或者某 种形式的 指针。\n这道题 与练习题 3. 13 类似, 不同的是它使用了 T EST 指令而不是 CMP 指令。\n后缀'矿和寄存器指 示符 表明是 64 位操 作数, 而比较是>= , 一定是有符号数。我们可以 推断\nda t a _ 七一定是 l o ng 。\n后缀'矿和寄存器指 示符 表明 是 16 位操作数, 而比较是==, 这个对有符号和无 符号都是一样的。我们可以 推断 da t a _ 七一定是 s ho r t 或者 u ns i g ne d s hor t 。\n后缀`矿和寄存器指示符表明是 8 位操作数 , 而比较是 针对无 符号数的>。 我们 可以 推断 da t a _ t\n一定是 uns i g ne d c har 。\n后缀\u0026rsquo; l \u0026rsquo; 和寄存器指示符 表明是 32 位操作数 , 而比较是<= 。 我们可以 推断 da t a_t 一定是 m七。这个练习要求你仔细检查反汇编代码,并推理跳转目标的编码。同时练习十六进制运算。\nj e 指令的目标为 Ox 4003f c + Ox 02。如原始的反汇编代码所示 , 这就是 Ox 4003f e 。\n4003fa: 74 02\n4003fc: ff dO\nJe callq\n4003fe\n*%rax\nj b 指令的目标是 Ox 400431 - 1 2 ( 由于 Ox f 4 是— 1 2 的一个字节的 补码表示)。正如原 始的 反 汇编代码所示 , 这就是 Ox 400425: 40042f: 74 f4\n400431: 5d\nje pop\n400425\n%rbp\n根据反 汇编器 产生的注释, 跳转目标是绝对地址 Ox 400547 。根据字节编码, 一定在距离 po p 指令 Ox 2 的地址处 。减去这个值就得到地址 Ox 400545 。 注意, j a 指令的编码需要 2 个字节, 它一定 位于地址 Ox 4005 43 处。检查原始的反汇编代码 也证实了这一点 :\n400543: 77 02\n400545: 5d\nja 400547\npop %rbp\n以相反的顺 序来 读这些 字 节, 我 们看 到 目 标 偏 移 量 是 Ox f f f f f f7 3 , 或者 十 进 制数 一 141 。\nOx 4005e d ( no p 指令的 地址)加上这个 值得到地址 Ox 4 00560 :\n4005e8 : e9 73 ff ff ff\n4005ed : 90\njmpq 400560 nop\n3 16 对汇编 代码写 注释, 并且模仿 它的控 制流来 编写 C 代码, 是理解汇编语 言程序很好的第一步。本题是一个具有简单 控制流的示 例, 给你一个 检查 逻辑操作实 现的机会。\n这里是 C 代码:\nvoid goto_cond(long a, long•p) {\nif (p == 0)\ngoto done;\nif (*p \u0026gt;= a)\ngoto done;\n*P = a;\ndone : return;\n第一个条件分支是 && 表达式 实现的一部分。如果 对 p 为非 空的测试失败, 代码会跳 过对 a \u0026gt;*p\n的测试。\n17 这个练习帮助你思考一个通用的翻译规则的思想以及如何应用它。\n转换成这种替代的形式,只需要调换一下几行代码:\nlong gotodiff_se_alt(long x, long y) { long result;\nif (x \u0026lt; y)\ngoto x_l t _y ; ge_cnt++; result= x - y ; return result;\nx_lt_y:\nlt_cnt++; result= y - x; return result;\n在大多 数情况下 , 可以 在这 两种方式中 任意选择。但是原来的方法对常见的没有 e l s e 语句的情况更好 一些。对于这种情况, 我们只用 简单地将 翻译规则修改 如下 :\nt = test-expr;\nif (!t)\ngoto done; then-statement\ndone :\n基于这种替代规则的翻译更麻烦一些。\n3. 18 这个题目要求你完成一个嵌 套的分 支结构, 在此 你会看到 如何使用翻译 if 语句的规则。大部 分情况下 , 机器代码就是 C 代码的 直接翻译 。\nlong test (long x, long y, long z) { long val= x+y+z;\nif (x \u0026lt; -3) {\nif (y \u0026lt; z)\nval= x•y;\nelse\nval= y•z;\n} else if (x \u0026gt; 2) val= x•z;\nreturn val;\n}\n19 这道题巩固加强了我们计算预测错误处罚的方法。 可以 直接应用公 式得到 T),le =2 X (31-16) = 30。 当预测错误时 , 函数会需 要大 概 1 6 + 30 = 46 个周期 。 20 这道题提供了研究条件传送使用的机会。 运算 符是'/'。可以 看到这 是一个通过右移 实现除以 2 的 3 次幕的例子(见2. 3. 7 节)。在移位\nk = 3之前 , 如果被除数 是负数的话 ,必 须加上偏移 扯 2\u0026rsquo; - 1 = 7。\n下面是该汇编代码加上注释的一个版本:\nlong arith(long x) x in 7.rdi\narith:\nleaq 7(%rdi) , %rax temp = x+7 testq %rdi, %rdi Test x cmovns %rdi, %rax If x\u0026gt;= O, temp = x sarq $3, %rax result = temp»3 (= x/ 8) ret 这个 程序创建一 个临 时值等于 工 + 7 , 预期 工 为负,需 要加偏 移量时使用。c mov ns 指令在当\n:r o 条件成 立时 把这个 值修改 为 :r , 然后再移动 3 位, 得到 可 8。\n3. 21 这个题目类 似于练 习题 3. 18 , 除了有些条件语句是用条件数据传送实现的。虽然将这段代码装进到原始的 C 代码中看起来 有些 令人惧怕, 但是你会 发现它 相当严格地 遵守了翻译 规则。\nlong test(long x, long y) { long val= 8•x;\nif (y \u0026gt; 0) {\nif (x \u0026lt; y)\nval= y-x;\nelse\nval= x\u0026amp;y;\n} else if (y \u0026lt;= -2) val= x+y;\nreturn val;\n}\n22 A. 如果构建一张使用 数据类型 i nt 来计算的 阶乘表, 得到下 面这 样的 表: n n! OK?\n1 I Y 2 2 Y 3 6 Y 4 24 Y 5 120 Y 6 720 Y 7 5 040 Y 8 40 320 Y 9 362 880 Y 10 3 628 800 Y 11 39916800 Y 12 479 001 600 Y 13 1 932 053 504 N 我们可以 看到 , 计算 13 ! 溢出了。正 如在练习题 2. 35 中学到的那样, 还 可以 通过计算\n.:r/n, 看它 是否 等于 ( n - 1) ! 来测试 n ! 的计算是 否溢出了(假设我们已经能够保证( n— 1 ) ! 的计算没有溢 出)。在此处 , 我们得到 1 932 053 504 / 13 = 161 004 458. 667 。另外有个测试方法,\n可以 看到 10 ! 以上的 阶乘数都 必须是 100 的倍数, 因此最 后两位数字必 然是 0。13 ! 的正确值应该是 6 227 020 800 。\nB. 用数据类 型 l o ng 来计算 , 直到 20 ! 才溢出, 得到 2 432 902 008 176 640 000 。\n3 23 编译循环产生的代码可能会很难分析,因为编译器对循环代码可以执行许多不同的优化,也因为可能 很难把程序变 量和寄存器 匹配起 来。 这个 特殊的例子展 示了几个汇编代码不仅仅是 C 代码直接翻译的地方。\n虽然参 数 x 通过寄存 器%r d i 传递给函 数, 可以 看到一旦进入循环就再也没有引 用过该寄存器了。 相反, 我们看 到第 2 ~ 5 行上寄 存器 %r a x 、%r c x 和%r d x 分别被初始化为 x、x*x 和 x+x 。因此可以推断,这些寄存器包含着程序变量。\n编译器认 为指 针 p 总是指向 X , 因此表达式 (*p ) ++就能够实现 x 加一。代码通过第 7 行的 l e aq\n指令 , 把这个 加一 和加 y 组合起 来。\n添加了注释的代码如下:\nlong d巳 l oop(l ong x) x initially in¼rdi\n1 dw_loop:\nmovq %rdi, %rax Copy x to i.rax movq %rdi, %rcx\n4 imulq %rdi, %rcx Compute y = x*x\n5 leaq (%rdi, %rdi) , %rdx Compute n = 2*x\n6 •12: loop\n7 leaq 1(%rcx,%rax), %rax Compute x += y + 1 8 subq $1, %rdx Decrement n 9 testq %rdx, %rdx Test n 10 jg .L2 If\u0026gt; 0, goto l oop 11 rep; ret Return 3. 24 这个汇编代码 是用跳转到中间 方法对循 环的 相当直接的 翻译。完整的 C 代码 如下 :\nlong loop_while(long a, long b)\nlong result= 1; while (a\u0026lt; b) {\nresult = result * (a+b); a = a+l;\nreturn result;\n3. 25 这个汇编代 码没有完 全遵 循 g ua rded-do 翻译的模式 , 可以 看到它 等价于下 面的 C 代码 :\nlong loop_while2(long a, long b)\nlong result= b; while (b \u0026gt; 0) {\nresult= result* a; b = b-a;\nreturn result;\n我们 会经常看 到这 样的情 况, 特别是用 较高优化 等级 编译 时, 此时 GCC 会自作 主张地 修改生成代码的格式,同时又保留所要求的功能。\n3. 26 能够从汇编代码 工作回 C 代码, 是逆向 工程的 一个主要例子。\n可以 看到这 段代码使用的 是跳转到中间 翻译方法, 在第 3 行使用了 j mp 指令。 下面是原 始的 C 代码:\nlong fun_a(unsigned long x) { long val; O;\nwhile (x) {\nval ; x;\nX \u0026gt;\u0026gt;1; ;\nreturn val\u0026amp;: Ox1;\n这个代码计算 参数 x 的奇偶 性。也就是, 如果 x 中有奇 数个 1\u0026rsquo; 就返回 1, 如果有偶 数个 1, 就返回 0。\n3. 27 这道练习题 意在加强 你对如何 实现循环的理 解。\nlong fact_for_gd_goto(long n)\nlong i; 2; long result ; 1; if (n \u0026lt;; 1)\ngoto done;\nl oop :\nresult*; i;\ni++;\n辽 ( i \u0026lt;; n)\ngoto loop;\ndone :\nreturn result;\n28 这个间 题比练习题 3. 26 要难一些, 因为循 环中的代 码更复杂 , 而整个 操作也不那么熟悉。 以下是原始的 C 代码:\nlong fun_b(unsigned long x) { long val; O;\nlong i;\nfor (i; 64; i !; O; i 一 ){\nval ; (val«1) I (x\u0026amp;: Ox1);\nX»; 1;\nreturn val;\n这段代码是 用 g uarded-do 变换生成的, 但是编译器发现因为 l. 初始 化成了 64 , 所以一定会满足测试 i# O, 因此初始的测试是没必要的。\nc. 这段代 码把 x 中的位反 过来, 创造一个镜像 。实现的 方法是 : 将 x 的位从 左往右移, 然后再填\n入这些 位, 就像是把 va l 从右往左 移。\n29 我们把 f or 循环翻译 成 wh il e 循环的规则有些过于简单 这是唯 一需要特殊考虑的 方面。\n使用我们的翻译规则会得到下面的代码:\nI* Naive translation of for loop into while loop *I I* WARNING: This is buggy code *I\nlong sum= O;\nlong i = O; while (i \u0026lt; 10) {\n辽 ( i \u0026amp; 1)\nI* Thi s 甘 i ll cause an infinite loop *I continue;\nsum += i; i++;\n}\n因为 c o n t i nue 语句会阻止索引变量 l. 被修改 ,所 以 这段代码是无限循环。\n通用的解决方法是用 g o t o 语句替 代 c o n t i nue 语句 ,它 会 跳 过循环体中余下的部分,直 接跳到\nup d a t e 部 分 :\nI* Correct translation of for loop into while loop *I long sum= O;\nlong i = O;\nwhile (i \u0026lt; 10) {\n辽 ( i \u0026amp; 1)\ngoto update; sum += i;\nupda t e :\ni++;\n}\n30 这个练习给你一个机会, 推算出 s wi t c h 语 句 的 控制流。要求你将汇编代码中的多处信息综合起来回答这些问题:\n汇编代码的第 2 行将 x 加上 1, 将情况( cases ) 的下界设置成 0。这就意味着最小的 清况标 号 为一1 。 当调整过的情况值大于 8 时 ,第 3 行 和第 4 行 会导致 程序跳转到默认情况。这就意味着最大情况 标 号 为—1 + 8 = 7。 在 跳 转表中, 我们看到第 6 行 的 表项(情况值 3) 与第 9 行 的 表项(情况值 6) 都以 第 4 行 的 跳 转指令 作 为 同 样的目标( .L2) , 表明这是默认的情况行为。因此 ,在 s wi t c h 语 句 体 中 缺失了情况标号 3 和 一6。 在跳转表中, 我们看到第 3 行和第 10 行上的表项有相同的目的。这对应于情况标号 0 和 7 。 在跳转表中, 我们看到第 5 行 和第 7 行 上 的 表项有相同的目的。这对应于情况标号 2 和 4。从上述推理,我们得出如下结论: s w itch 语句体中的情况标号值为— 1 、0 、1 、2 、4 、5 和 7 。\n目标为.L5 的 清况 标号为 0 和 7。\n目标为.L7 的 情况 标号为 2 和 4。\n3 . 31 逆 向 工 程编译出 s wi t c h 语 句 ,关 键 是 将 来 自汇 编 代码 和跳转表的信息结 合起来 , 理 清 不 同 的情况 。 从 j a 指 令(第 3 行 )可知,默 认 情 况 的 代码的标号是 . L2。我们可以 看到,跳 转表中只有另一个 标 号 重 复出现,就 是 . LS, 因 此 它 一 定 是 情 况 C 和 D 的 代 码 。 代 码 在 第 8 行 落 人 下 面的 情况, 因 而 标 号 . L7 符合情况 A , 标号 . L 3 符合情况 B。只剩下标号 . L6 , 符合情况 E 。\n原始的 C 代 码 如下 :\nvoid switcher(long a, long b, long c, long *dest)\n{\nlong val;\nS廿i t ch(a) { case 5:\nc = b - 15;\nI* Fall through *I case 0:\nval = c + 112; break;\ncase 2:\ncase 7:\nval = (c + b)«2; break;\ncase 4:\nval = a; break;\ndefault:\nval= b;\n}\n*dest = val;\n}\n3 32\n3 33\n追踪此等级上的程序的执行有助于理解过程调用和返回的很多方面。可以明确看到调用时控制是 怎么传 给过 程的以 及返回时 调用函数如何继续执行的。还可以看到参数通过寄存器%r d i 和%工s i传递 ,结 果通过寄 存器% r a x 返回。\n指令 状态值(指令开始执行前) 描述 标号 PC 指令 %rdi 号r s i %r a x %rsp 飞 r s p Ml Ox400560 callq 10 Ox7fffffffe820 调用 f ir s t (10) Fl Ox400548 lea 10 Ox7f f f ff f f e 818 Ox400565 丘r s t 的入口 F2 Ox40054c sub 10 11 Ox7fffffffe818 Ox 40 0565 F3 Ox400550 callq 9 11 Ox7fffffffe818 Ox400565 调 用 l a s t (9, 11) LI Ox400540 rnov 9 11 Ox7fffffffe810 Ox400555 l a s t 的入口 L2 Ox400543 imul 9 11 9 Ox7fffffffe810 Ox400555 L3 Ox400547 retq 9 11 99 Ox7fffffffe810 Ox400555 从 l a s t 返回 99 F4 Ox400555 repz repq 9 11 99 Ox7fffffffe818 Ox400565 从 f ir s t 返回 99 M2 Ox400565 mov 9 11 99 Ox7fffffffe820 继续执行 ma i n 由千是多种数据大小混合在一起,这道题有点儿难。\n让我们先 描述第一种答案, 再解 释第二种可能性。如果 假设第一个加(第3 行)实现* u += a, 第二个加(第4 行)实现 v+= b , 然后 我们 可以 看到 a 通过 % e中 作为第 一个参 数传 递, 把它从 4 个字节转换 成 8 个字节, 再加到 %r d x 指向的 8 个字节上。这就意味着 a 必定 是 i n t 类型, u 一定是 l o ng * 类型。还可以看 到参数 b 的低位字节被加到了%r c x 指向的字节。 这就意味着 v 一定是char* , 但是 b 的类型是不 确定的- 它的大小 可以 是 1 、2、4 或 8 字节。 注意 到返回值为 6 就能解决 这种不 确定性, 这个返回 值是 a 和 b 大小的和。因为我们知道 a 的大小 是 4 字节, 所以可以推断出 b 一定是 2 字节的。\n该函数的一 个加了注释的版本解释了这些 细节 :\nint procprobl (int a, short b, 1ong•u, char•v) a in 7.edi, b in ¾s i , u in¾rdx, v in¾rcx procprob:\nmovslq %edi, %rdi addq %rdi, (%rdx) addb %s il, (%rcx)\nmovl $6, %eax ret\nConvert a to 1 ong\nAdd to•u (long)\nAdd low-order byte of b to•v Return 4+2\n3 34\n3. 35\n此外 , 我们可以 看到 如果以它们在 C 代码中出 现相反的 顺序在汇编代 码中计算这两个和, 这段汇编代码同 样合法。这 会导致交 换参数 a 和 b , 参数 u 和 V , 得到如下原型:\nint procprob(int b, short a, long•v, char•u);\n这个例子展示了被调用者保存寄存器的使用,以及保存局部数据的栈的使用。\n可以 看到第 9 ~ 14 行将局部值 a O~ a S 分别保 存 进 被调用者保存 寄存器%r b x 、%r l S 、%r 1 4、\n沧r 13 、%r 1 2 和%r b p 。\n局部值 a 6 和 a 7 存放在栈中 相对于栈指 针偏移量 为 0 和 8 的地方(第1 6 和 18 行)。\n在存 储完 6 个局部变量之后 , 这个程序用完了 所有的 被调用者保存 寄存器, 所以 剩下的两个值保存在栈上。\n这道题给了一个检查递归函数代码的机会。要学的一个很重要的内容就是,递归代码与我们看到的其他函数的结构一模一样。栈和寄存器保存规则足以让递归函数正确执行。\n寄存器 %r b x 保存参数 x 的值, 所以 它可以 被用来计算结果 表达式 。\n汇编代码是由下 面的 C 代码产生而来的 :\nlong rfun(unsigned long x) { if (x == 0)\nreturn O;\nunsigned long nx = x\u0026gt;\u0026gt;2; long rv = rfun(nx); return x + rv;\n3. 36\n3 37\n3 38\n这个练习测试你 对数据大小 和数 组索引的理解。注意, 任何类型的指针都是 8 个字节长。 s hor t\n数据类型需要 2 个字节 , 而 i n t 需要 4 个。\n数组 元素大小 总大小 起始地址 元素1 s T u V w 2 8 8 4 8 14 24 48 32 32 Xs Xr Xu Xv x. x,+2i XT + 8i Xv + 8i Xv +4i Xw+Bi 这个练习是 关于整数 数组 E 的练习的一个变形。理 解指针与指 针指向的对象之间的区别是很重要的。因为数 据类型 s ho r t 需要 2 个字节, 所以所有的数组索引都将乘以因子 2。前面我 们用的是\nmovl, 现在用的则 是 mov w。\n表达式 类型 值 汇编语句 S+l S [3] \u0026amp;S [i] S[4*i+l] S+i-5 short* short s ho r t * shor t short* X5 + 2 M[x5 +6] x , +2i M[x5 + 8i + 2] X5 + 2i - 10 l e a l 2 ( %r d x ) , %r a x movw6(%rdx),%ax leal(%rdx,%rcx,2),%rax rnovw2(%rdx,%rcx,8),%ax l e a l - 1 0 ( %r d x , %r c x , 2 ) , %r a x 这个练习要求 你完成 缩放操作 , 来确定地址的 计算,并 且应用行 优先索引的公式( 3. 1 ) 。第一步是注释汇编 代码, 来确定 如何计算地址引 用:\nlong sum_element(long i, long J)\n工 in 7.rdi, j in 7.rsi s um_el e ment :\nleaq O (, %r di , 8) , %rdx\nsubq %rdi, %rdx\naddq %rsi, %rdx\nleaq (%rsi,%rsi,4), %rax addq %rax, %rdi\nmovq Q (, %rdi, 8) , %rax\naddq P(,%rdx,8), %rax ret\nCompute 81\nCompute 7i Compute 7i + J Compute 51 Compute i + SJ\nRetrieve M[xQ + 8 (5 」 + i)]\nAdd M (xp + 8 (7i + })]\n3. 39\n3. 40\n我们可以看 出, 对矩阵 P 的引用是在字节偏移 8 X ( 九十))的地方, 而对矩阵 Q 的引用是在字节偏移 8 X ( 5 j + i ) 的地方。由此我们可以 确定 P 有 7 列, 而 Q 有 5 列, 得到 M = 5 和 N = 7。\n这些计算 是公式( 3. 1) 的直接应用 :\n对千 L = 4 , C = 1 6 和 )= O, 指针 Ap tr 等千 x , +4X (1 6i + O) =x, + 64, 。\n对千 L = 4 , C=l6, i= O 和 j = k , 指针 Bp tr 等千 x 8 + 4 X0 6 X O+ k ) = x a + 4 k 。\n对于 L = 4 , C=l6, i= l6 和)= k , Be nd 等于 x 8 +4 X 06 X 16+k) =x8 + 1024 + 4k 。\n这个练习要求你能够研究编译产生的汇编代码,了解执行了哪些优化。在这个情况中,编译器做 一些聪明的优化。\n让我们先来研究 一下 C 代码, 然后看看如何从为原 始函数产生的汇编代码推导出这个 C\n代码。\nI* Set all diagonal elements to val *I\nvoid fix_set_diag_opt(fix_matrix A, int val) { int *Abase = \u0026amp;A [OJ [OJ ;\nlong i = O;\nlong iend = N*(N+1); do {\nAbase[iJ = val; i += (N+1);\n} while (i != iend);\n这个函 数引 入了一 个变量 Aba s e , int * 类型的, 指向数组 A 的起始位置。 这个指针指向一个 4 字节整数序列 , 这个序列由按 照行优先顺 序存放的 A 的元素组 成。我们引 入一个 整数 变量 i n­\ndex, 它一步一步经过 A 的对角线 , 它有一个属性 , 那就是对角线 元素 l 和 i + l 在序 列中 相隔 N +\n1 个元素, 而且一旦 我们 到达对角线 元素 N ( 索引为 N ( N + l ) ) , 我们就超出了边界。\n实际的汇编代 码遵循这样的通 用 格式, 但是现在指针的增加必须乘以因子 4。我们将寄存器釭a x 标记为存放 值 i nd e x 4 , 等于 C 版本中的 i n d e x , 但是使用因子 4 进行伸缩。对于 N = l 6 , 我们可以 看到对于 i n d e x 4 的停止点会是 4 · 160 6 + 1 ) = 1088 。\nfix_set_diag:\nvoid fix_set_diag(fix_matrix A, int val) A i n 肚 di , val in 7.rsi\nmovl $0, %eax\n.L13:\nmovl %esi, (%rdi, %rax) addq $68, %rax\ncmpq $1088, i,rax\njne .L13\nrep; ret\nSet index4 = 0 l oop:\nSet Abase [in dex4/ 4] to val Increment index4 += 4(N+1)\nCompare index4: 4N(N+1) If!=, goto l oop\nReturn\n3. 41\n这个练习让 你思考结构的布局 , 以及用来访问 结构字段的代码。该结构声明是书中 所示例子的 一个变形。它表明嵌套的结构的分配是将内层结构嵌人到外层结构之中。\n该结构的布局图如下: 偏 移 0\n内容[\ns.x 产 s . y\n24\nnext\n它使用 了 24 个字节。\nc. 同平时一样, 我们从 给汇编代 码加注释开始:\nvoid sp_init(struct prob•sp) s p in 7.rdi\nsp_init:\nmovl movl leaq movq movq ret\n12 c 儿r di ) , %eax\n.儿e ax , 8(%rdi) 8(%rdi), %rax\n%rax, (%rdi)\n%rdi, 16(%rdi)\nGet sp-\u0026gt;s.y Save in sp-\u0026gt;s.x\nCompute \u0026amp;(sp-\u0026gt;s .x) Store in sp-\u0026gt;p\nStore spin sp-\u0026gt;next\n由此可以 产生如下 C 代码:\nvoid sp_init(struct prob•sp)\n{\nsp-\u0026gt;s.x sp-\u0026gt;p\nsp-\u0026gt;next\n= sp-\u0026gt;s.y;\n= \u0026amp;(sp-\u0026gt;s.x);\n= sp;\n3. 42\n这道题说明 了一个非常普 通的 数据结构和对它的 操作时 如何在机器代 码中实现。要解答 这些问 题, 还是先对汇编代码加 注释, 确认出该结构的两个字段分 别在偏移 量 0 ( 字段 v ) 和 8 ( 字段 p ) 处。\nloDg f 皿 (s tr uct ELE •ptr )\nptr ill\n1 fun:\nrY. d1\nmovl $0, %eax\nJIDP .12\n4 .L3:\nresult = 0 Goto middle\nloop:\n5 addq\n6 movq\n7 .L2:\n(o/.rdi) , o/.rax\n8(o/.rdi), o/.rd1\nresult+= ptr-\u0026gt;v ptr = ptr-\u0026gt;p\nmiddle:\ntestq\u0026rsquo;Y.rdi,\u0026lsquo;Y.rdi jne .L3\n10 rep; ret\nTest ptr\nIf ! = NULL, goto loop\n根据加了注释的 代码, 可以 得到 C 语言:\nlong fun(struct ELE *ptr) { long val= O;\nwhile (ptr) {\nval+= ptr-\u0026gt;v; ptr = ptr-\u0026gt;p;\nreturn val;\n可以 看到每个结 构都是一个单链 表中的 元素, 字段 v 是元素的 值, 字段 p 是指向下 一个元 素的指针。函数 f u n 计算列表中元素值的 和。\n3. 43 结构和联合涉及的概念很简单,但是需要练习来习惯不同的引用模式和它们的实现。\n表达式 类型 代码 up-\u0026gt;tl. u long movq ( % r d 习 ,%r a x movq 毛r a x, ( %r s 习 up-\u0026gt;tl.v short movw 8 ( %r di ) , 毛a x mo vw 皂 a x, 伐 r s i ) \u0026amp;up-\u0026gt;tl. w char* addq $, %r d i movq % r d i , ( %r s i ) up-\u0026gt;t2.a int* mo v q 乌r d i , %r s i up- \u0026gt;t 2 . a [up- \u0026gt; tl.u) int mo v q ( %r d i ) , %r a x movl ( %r d i , %rax, 4), %e a x movl %e a x , (%r s 习 *up-\u0026gt;t2.p char movq 8 ( %r d习 ,%r a x movb ( %r a x ) , %a l movb %a l , ( 沧r s 习 44 想理解各种数据结构需要多少存储,以及编译器为访问这些结构产生的代码,理解结构的布局和对齐是非常重 要的 。这个练习让你看清楚 一些示例结构的细节 。 struct Pl {inti; char c; int j; chard;); 总共 对齐\n1 6\ns tr u c 七 P2 {inti; char c; chard; long j; }; 勹 # struct P3{ short w [3]; char c [3] } ; w C 总10共 对齐\n6 2\nstruct P4 { short w [5]; char *c[3] } ; w C 总4共0 对齐\n。 16 8\nstruct PS (struct P3a[2]; struct P2 t }; a t 总40共 对齐\n24 8\n45 这是一个理 解结构的布局 和对齐的 练习。 这里是对象大小和字节偏移量: 字段大 小 偏移攸\n这个结构一共是 56 个字节长 。结构的结尾必须 填充 4 个字节来 满足 8 字节对 齐的 要求 。 当所有的 数据元素的 长度都 是 2 的幕时 , 一种行 之有效的策略 是按照 大小的降序排 列结构的元素。导致声明如下:\nstruct {\nchar •a;\ndouble c;\nlong g;\nfloat e;\nint h;\nshort b,·\nchar d;\nchar f;\n} rec;\n得到的偏移扭如下: 字段 a C g e h b d f 大小 8 8 8 4 4 2 偏移量 I o 8 16 24 28 32 34 35 这个结构要填充 4 个字节以满足 8 字节对齐的 要求 , 所以总共是 40 个字节。\n46 这个问 题覆盖的话题比较广泛,例 如栈帧、字符 串表示 、ASCII 码和字节顺 序。 它说明了越界的内存引用的危险性,以及缓冲区溢出背后的基本思想。 执行了第 3 行后的栈: 00 00 00 00 00 40 00 7 61 返回值\n01 23 45 67 89 AB CD EF 保存的釭b x\n\u0026lt; — b u f = 毛 r s p\n执行了 第 5 行后的栈: 00 00 00 00 00 40 00 3 41 返回值 33 32 31 30 39 38 37 36 保存的%r b x 35 34 33 32 31 30 39 38 37 36 35 34 33 32 31 30 I \u0026ndash; buf = %rsp\n这个程序试图 返回 到地址 Ox 0 40034 。低 位 2 字节被字符'矿和结尾的 空( null) 字符覆盖了。\n寄存器 %r b x 的保存 值被 设置为 Ox 333231 3039383 736 。在 ge t _ l i ne 返回前, 这个值会被 加载回这个寄存器中。\n对 ma l l oc 的 调用应该以 s tr l e n (bu f ) + 1 作为它的 参数, 而且代码还应 该检查返回 值是否为\nNULL 。\n3. 47 A. 这对应于大约沪个地址的范围。\nB. 每次尝试, 一个 1 28 字节的空 操作 s l e d 会覆盖 扩个地址 , 因此我们只需 要 26 = 64 次尝试。这个 例子明确地 表明了这个版 本的 L inux 中的随机化程度只能 很小地阻 挡溢出攻击。\n48 这道题让 你看看 x86-64 代码如何管 理栈 , 也让你更 好地理解如何防 卫缓 冲区 溢出攻 击。\n对于没有保护的代码 , 第 4 行和第 5 行计算 v 和 b u f 的地址为相对千%r s p 偏移噩为 24 和 0。在有保护的代码中 , 金丝雀被存放 在偏 移雇为 40 的地方(第4 行), 而 v 和 bu f 在偏移 量为 8 和 16 的地方(第7 行和第 8 行)。 在有保 护的代码中 , 局部变量 v 比 bu f 更靠 近栈 顶 , 因此 b u f 溢出就不会 破坏 v 的值。 49 这段代码中包含许 多我们已 经见到过的 执行位级 运算的 技巧。要仔细研究 才能 看得懂 。\n第 5 行的 l e a q 指令计算值 8 n + 22 , 然后 第 6 行的 a ndq 指令 把它向下舍入 到最接近的 16 的倍数。当 n 是奇数时,结 果值会是 8 n + 8 , 当 n 是偶数时 ,结 果值会是 8 n + l 6 , 这个 值减去 s, 就得到 s, O\n该 序列中的三条指令 将 S2 舍入 到最 近的 8 的倍数。它们利用了 2. 3. 7 节中实现除以 2 的幕用到的偏移 和移 位的组 合。\n这两个 例子可以 看做最小 化和最大化 e1 和 e, 的情况。\nn s, s, p e, e, 5 2065 2017 2024 I 7\n6 2064 2000 2000 16\n可以 看到 s, 的计算方式 会保 留 S1 的偏移 量为 最接近的 1 6 的倍数。还可以 看到 p 会以 8 的倍数对齐, 正是对 8 字节元 素数组建 议使用的 。 `\n3 50 这道题要求你仔细检查代码,小心留意使用的转换和数据传送指令。可以看到取出的值和转换的 情况如下 :\n取出位 千 dp 的值, 转换成 i nt ( 第 4 行), 再存储到 i p 。因 此可以 推断出 va 荨 是 d。\n取出位 千 i p 的值, 转换 成 fl oa t ( 第 6 行), 再存储到 f p。因此可以 推断出 va l 2 是 l 。\n1 的值被转换 成 doub l e ( 第 8 行), 并存储在 dp 。因此 可以 推断出 va l 3 是 1 。\n第 3 行上取出位 千 f p 的值。第 10 和 11 行的两条指 令把它转换为双精度, 值通过寄存 器%xmm0\n返回。因此可以 推断 出 va l 4 是 f 。\n3. 51 可以通过从图 3-47 和图 3- 48 中选择适当的条目或者使用在浮点 格式间转换的代码序列 来处 理这些情况。\nT, Ty 指令 long double double double int float vcvtsi2sdq %r d i , %x mm0 , %x mm0 vcvttsd2si %x mm0 , %e a x vunpcklpd %x mm0 , %x mm0 , %x mm0 vcvtpd2ps %xmm0, 毛 x mmO long float float long vctsi2ssq % r d i , %x mm0 , %x mm0 vcvt t s 2s i q % x mm0 , %r a x 映射参数到寄存器的基本规则非常简单(虽然随着有更多类型的参数出现,这些规则也变得越来越 复杂[ 77] ) 。 double gl (double a, long b, float c, int d);\n寄存器: a 在%x mm0 中, b 在%r d i 中, e 在%x mml 中 , d 在 % e s i 中\ndouble g2(int a, double *b, float *c, long d) ;\n寄存器: a 在 ¾ e小 中, b 在% rsi 中, c 在 % rd x 中, d 在 % rcx 中\ndouble g3(double *a, double b, int c, float d);\n寄存器: a 在%r 中 中 , b 在%x mm0 中, e 在% e s i 中 , d 在%x mml 中\ndouble g4(float a, int *b, float c, double d);\n寄存器: a 在%x mm0 中 , b 在%r d i 中, e 在%x mml 中 , d 在%x mm2 中\n从这段 汇编代码 可以 看出有 两个整数 参数, 通过寄存器%r 生 和%r s i 传递, 将其命名为 过 和 辽。类似地 , 有两个浮点 参数, 通过 寄存器%x mm0 和%x mml 传递, 将其命 名为 fl 和 f 2。\n然后给汇编代码加注释:\nRefer to arguments as 工 1 (r¼ di ) , 立 (¼esi )\nt 1 (¼xmmO) , and t2 (¼xmm1)\ndouble tunct1(arg1_t p, arg2_t q, arg3_t r, arg生t s) functl:\nvcvtsi2ssq %rsi, %xmm2, %xmm2 vaddss %xmm0, %xmm2, %xmm0 vcvtsi2ss %edi, %xmm2, %xmm2 vdivss %xmm0, %xmm2, %xmm0 vunpcklps %xmm0, %xmm0, %xmm0\nvcvtps2pd %xmm0, %xmm0 vsubsd %xmm1, %xmm0, %xmm0 ret\nGet i2 and convert from long to float Add ft (type float)\nGet it and convert from int to float Comp ut e 辽 I (i2 + tt)\nConvert to double\nCompute i1 I (i2 + fl) - f2 (double)\n3. 54\n由此可以 看出这段代码计算 值 i l / (i2+fl) - f2。还 可以 看到, i l 的类型 为 i nt , i 2 的类 型 为long, f l 的类型为 f l oa t , 而 f 2 的类型为 do ub l e 。将参数 匹配到命名的 值只有一个不确定的地方,来自于加法的交换性 得到两种可能的结果:\n·double functla(int p, float q, long r, double s); double functlb(int p, long q, float r, doubles);\n一步步梳理 汇编 代码 , 确定 每一 步计算什么, 就很容易 找到这 道题的答 案, 如下 面的 注释所示 :\ndouble funct2(double w, int x, float y, long z) w in i.xmmO, x in i.edi, y in i.xmm1 , z i n 肚 si funct2:\nvcvtsi2ss %edi, %xmm2, %xmm2 vmulss %xmm1, %xmm2, %xmm1 vunpcklps %xmm1, %xmm1, %xmm1\nvcvtps2pd %xmm1, %xmm2 vcvtsi2sdq %rsi, %xmm1, %xmm1 vdivsd %xmm1, %xmm0, %xmm0\nvsubsd %xmm0, %xmm2, %xmm0 ret\nConvert x to float Multiply by y\nConvert x•y to double Convert z to double Compute w/z\nSubtract from x•y Return\n3. 55\n3. 56\n可以从 分析得出结论,该 函数计算 y*x- w/ z 。\n这道题使用的 推理 与推断 标号 . LC2 处声明的数字是 1. 8 的编码一 样, 不过例子更简单 。\n我们 看到 两个值分别 是 0 和 1077 936128 ( Ox 40400000 ) 。从高 位字 节 可 以抽取出 指 数字段\nOx404Cl028), 减去偏移 量 1023 得到指数为 5 。连 接两个 值的小数位, 得到小数字段为 o, 加上隐含的 开头的 1, 得到 1. 0 。因此这个常数是 1. OX25 =32. 0。\n在此可以 看到从 地址 . LCl 开始的 1 6 个字节是一个掩码 , 它的低 8 个字节是全 1 , 除了最高位, 这是 双精度值的 符号位。计算 这个掩码和%x mm0 的 A ND 值时 , 会清除 x 的符号位, 得到绝对 值。实际上, 定义 EXPR (x ) 为 f a b s (x ) 就 能得到这段 代码, f a b s 是在\u0026lt; ma t h . h \u0026gt; 中定义的。\n可以 看到 v x or p d 指令将 整个寄存器设 置为 0 , 所以这是 一种产生浮点 常数 o. 0 的方法。\n可以 看到从 地址 . LC2 开始的 16 个字节是 一个掩码, 它只有一个 1 位, 位于 XMM 寄存器中低位数值 的符号位。计算这个 掩码与% x mrn0 的 EXCLUSIVE - OR 值时, 会改变 x 符号的值, 计算出 表达式 - x。\n3 57 同样地,为代码加注释,包括处理条件分支:\ndouble funct3(int *ap, double b, long c, float *dp) ap in¼rdi, b in¼xmmO, c in¼rsi, dpin¼rdx\nfunct3:\nvmovss (o/.rdx) , o/.xmml Get d = *dp\nvcvtsi2sd (o/.rdi), o/.xmm2, o/.xmm2 Get a = *ap and convert to double\n4 vucom1sd o/.xmm2, o/.xmmO Compare b:a\njbe . LS\nvcvtsi2ssq %rsi, %xmm0, %xmm0\nvmulss %xmm1, %xmm0, %xmm1\nvunpcklps %xmm1, %xmm1, %xmm1\nIf\u0026lt;=, goto lesseq Conver t c to float 加 l tiply by d\n9 vcvtps2pd %xmm1, %xmm0\n10 ret\n11 .LB:\nConvre Return\nl esseq ·\nt to double\n72 vaddss %xmm1, %xmm1, %xmm1\nvcvtsi2ssq %rsi, %xrom0, %xmm0\nvaddss %xmm1, %xmm0, %xmm0\nvunpcklps ¾xmmO, ¾xmmO, ¾xmmO\nvcvtps2pd %xmm0 , %xmm0\nret\nCompute d+d = 2 . 0 * d Convert c to float Compute c + 2•d\nConver t to double Return\n由此, 可以 写出 f u n c t 3 的代码如下 :\ndouble fu 卫 ct 3(i nt • ap, double b, long c, float•dp) { int a; •ap;\nfloat d; •dp; if (a \u0026lt; b)\nreturn c•d;\nelse\n}\nreturn c+2•d;\n第 4 章\nCH APTER 4\n处理器体系结构 # 现代微处理器可以称得上是人类创造出的最复杂的系统之一。一块手指甲大小的硅片 上,可以容纳一个完整的高性能处理器、大的高速缓存,以及用来连接到外部设备的逻辑电路。从性能上来说 , 今天在一 块芯片上实现的处理器巳经使 20 年前价值 1000 万美元、房间那么大的超级计算机相形见细了。即使是在像手机、导航系统和可编程恒温器这样的日常设备中的嵌入式处理器,也比早期计算机开发者所能想到的强大得多。\n到目前为止,我们看到的计算机系统只限于机器语言程序级。我们知道处理器必须执 行一系列指令,每条指令执行某个简单操作,例如两个数相加。指令被编码为由一个或多 个字节序列组成的二进制格式。一个处理器支持的指令和指令的字节级编码称为它的指令 集体系 结构 (I nst ruct ion-Set Architecture, ISA) 。不同的处 理器“家族” , 例如 In tel IA32 和 x86-64 、IBM/ Freescale Power 和 ARM 处理器家族, 都有不同的 ISA 。一 个程序编译成在一种机器上运行,就不能在另一种机器上运行。另外,同一个家族里也有很多不同型 号的处理 器。虽然每个 厂商制造的处 理器性能 和复杂性不断提高 , 但是不同的型号在 ISA 级别上都 保持着兼容 。一些常见的处理器家族(例如x86-64) 中的处理器分别由多个厂商提供。因此, ISA 在编译器编写者 和处理器设计人员之间提供了一个概念抽象 层, 编译器编写者只需要知道允许哪些指令,以及它们是如何编码的;而处理器设计者必须建造出执行 这些指令的处理器。\n本章将简要介绍处 理器硬件的设 计。我们将研究一个硬件系统执行某种 ISA 指令的 方式。这会使你能更好地理解计算机是如何工作的,以及计算机制造商们面临的技术挑战。一个很重要 的概念是 , 现代处 理器的实际工作方式可能 跟 ISA 隐含的计算模型大相径庭。\nISA 模型看上去应该是 顺序指 令执行 , 也就是先取出一条指令, 等到它执行完毕 , 再开始下一条。然而,与一个时刻只执行一条指令相比,通过同时处理多条指令的不同部分,处 理器可以获得更高的性能。为了保证处理器能得到同顺序执行相同的结果,人们采用了一 些特殊的机制。在计算机科学中,用巧妙的方法在提高性能的同时又保持一个更简单、更 抽象模型的 功能, 这种思想是众所周知的。在 Web 浏览器或平衡二叉树和哈希表这样的信息检索数据结构中使用缓存,就是这样的例子。\n你很可能永 远都不会 自己设 计处理器。这是专家们的任务, 他们工作在全球不到 100\n家的公司里。那么为什么你还应该了解处理器设计呢?\n从智力方面来说,处理器设计是非常有趣而且很重要的。学习事物是怎样工作的有其内在价值。了解作为计算机科学家和工程师日常生活一部分的一个系统的内部工作原理(特别是对很多人来说这还是个谜),是件格外有趣的事情。处理器设计包括许多好的工程实践原理。它需要完成复杂的任务,而结构又要尽可能简单和规则。 理解处理 器如何工作 能帮助 理解整 个计 算机 系统如何 工作。在第 6 章 , 我们将讲述存储器系统,以及用来创建很大的内存映像同时又有快速访问时间的技术。看看处 理器端的处理器 内存接口,会使那些讲述更加完整。 "},{"id":440,"href":"/zh/docs/technology/System/%E6%B7%B1%E5%85%A5%E7%90%86%E8%A7%A3%E8%AE%A1%E7%AE%97%E6%9C%BA%E7%B3%BB%E7%BB%9F/%E4%B9%A6%E7%B1%8D/%E7%AC%AC4%E7%AB%A0-%E5%A4%84%E7%90%86%E5%99%A8%E4%BD%93%E7%B3%BB%E7%BB%93%E6%9E%84/","title":"Index","section":"SpringCloud","content":"第 4 章\nC H A P T E R 4 ·\n处理器体系结构 # 现代微处理器可以称得上是人类创造出的最复杂的系统之一。一块手指甲大小的硅片上,可以容纳一个完整的高性能处理器、大的高速缓存,以及用来连接到外部设备的逻辑电路。 从性能上来说 , 今天在一块芯片上实现的处理器已经使 20 年前价值 1000 万美元、房间那么大的超级计算机相形见细了。即使是在像手机、导航系统和可编程恒温器这样的日常设备中的嵌入式处理器,也比早期计算机开发者所能想到的强大得多。\n到目前为止,我们看到的计算机系统只限于机器语言程序级。我们知道处理器必须执行一系列指令,每条指令执行某个简单操作,例如两个数相加。指令被编码为由一个或多 个字节序列组成的二进制格式。一个处理器支持的指令和指令的字节级编码称为它的指令 集体系 结构 (I nst ru ction-Set Architecture, ISA)。不同的处理器“ 家族”, 例如 In tel IA32 和 x86-64 、IBM/ Freescale Power 和 ARM 处理器家族, 都有不 同的 ISA。一个程序编译成在一种机器上运行,就不能在另一种机器上运行。另外,同一个家族里也有很多不同型 号的处 理器。虽然每个厂商制造的处理器性能和复杂性不断提高, 但是不同的型号在 ISA 级别上都保持着兼容。一些常见的处理器家族(例如x86-64) 中的处理器分别由多 个厂商提供。因此, ISA 在编译器编写者和处理器设计人员之间提供了一个概念抽象层,编 译器编写者只需要知道允许哪些指令,以及它们是如何编码的;而处理器设计者必须建造出执行 这些指令的处理器。\n本章将简要介绍处理骈硬件的设计。我们将研究一个硬件系统执行某种 ISA 指令的方\n式。这会使你能更好地理解计算机是如何工作的,以及计算机制造商们面临的技术挑战。 一个很重要的 概念是 , 现代处理器的实际工作方式可能跟 ISA 隐含的计算模型大相径庭。\nISA 模型看上去应该是顺序指 令执行, 也就是先取出一条指令,等 到它执行完毕 ,再 开始下一条。然而,与一个时刻只执行一条指令相比,通过同时处理多条指令的不同部分,处 理器可以获得更高的性能。为了保证处理器能得到同顺序执行相同的结果,人们采用了一 些特殊的机制。在计算机科学中,用巧妙的方法在提高性能的同时又保待一个更简单、更 抽象模型的功能 , 这种思想是众所周知的。在 Web 浏览器或平衡二叉树和哈希表这样的信息检索数据结构中使用缓存,就是这样的例子。\n你很可能永远都不会自己设计处理器。这是专家们的任务,他们 工作在全球不到 100\n家的公司里。那么为什么你还应该了解处理器设计呢?\n从智力方面来说,处理器设计是非常有趣而且很重要的。学习事物是怎样工作的 有其内在价值。了解作为计算机科学家和工程师日常生活一部分的一个系统的内 部工作原理(特别是对很多人来说这还是个谜),是件格外有趣的事情。处理器设计包括许多好的工程实践原理。它需要完成复杂的任务,而结构又要尽可能简单 和规则。 理解处理器如何工作 能帮助 理解整个计算机 系统如何 工作。在第 6 章, 我们将讲述存储器系统,以及用来创建很大的内存映像同时又有快速访问时间的技术。看看处 理器端的处理器 内存接口,会使那些讲述更加完整。 虽然很少有人设计处理器,但是许多人设计包含处理器的硬件系统。将处理器嵌入到现实世界的系统中,如汽车和家用电器,已经变得非常普通了。嵌入式系统的设计者必须了解处理器是如何工作的,因为这些系统通常在比桌面和基千服务器的系统更低抽象级别上进行设计和编程。 你的工作可能就是处理器设计。虽然生产处理器的公司很少,但是研究处理器的设计人员队伍已经非常巨大了,而且还在壮大。一个主要的处理器设计的各个方面大约涉及 1000 多人。\n本章首先定义一个简单的指令集, 作为我们处理器实现的运行示例。因为受 x86-6 4 指令集的启发, 它被俗称为 \u0026quot; x86\u0026quot; , 所以我们称我们的指令集为 \u0026quot; Y86-64\u0026quot; 指令集。与x86-64 相比, Y86-64 指令集的数据类型、指令和寻址方式都要少一些。它的字节级编码也比较简单, 机器代码 没有相应的 x86- 64 代码紧凑 , 不过设计它的 CPU 译码逻辑也要 简单一些。虽然 Y86- 64 指令集很简单, 它仍然足够完整, 能让我们写一些处理整数的 程序。设计一个实现 Y86-64 的处理器要求我们解决许多处 理器设计者同样会面对的问 题。\n接下来会提供一些数字硬件设计的背景。我们会描述处理器中使用的基本构件块,以及它们如何连接起来和操作。这些介绍是建立在第 2 章对 布尔代数和位级操作的讨论的基础上的。我们还将介 绍一种描述硬件系统控制部分的简单语言, H CL ( Hardwa re Control\nLanguage, 硬件控制 语言)。然后,用 它来描述我们的处理器设计。即使你已经 有了一些逻辑设计的背景知识,也 应该读读这个部分以 了解我们的 特殊符号表示方法。\n作为设计处理器的第一步,我们给出一个基于顺序操作、功能正确但是有点不实用的Y86-64 处理器。这个处理器每个时钟周期 执行一 条完整的 Y86-64 指令。所以它的时钟必须足够慢,以允许在一个周期内完成所有的动作。这样一个处理器是可以实现的,但是它的性能远远低于同样的硬件应该能达到的性能。\n以这个顺序设计为基础, 我们进行一系列的改造,创 建 一个流水 线化的 处理 器 ( pipe­ lined pro cessor ) 。这个处理器将每条指令 的执行分解成五步, 每个步骤由一个独立的硬件部分或阶段( stage )来处理。指令步经流水线的各 个阶段, 且每个时钟周期有一条新指令进入流水线。所以,处理器可以同时执行五条指令的不同阶段。为了使这个处理器保留 Y86-64 IS A 的顺序行为, 就要求处理很多冒险或 冲突( hazard ) 情况, 冒险就是一条指令的位置或操作数依赖于其他仍在流水线中的指令。\n我们设计了一些工具来研究 和测试处理器设计。其中包括 Y86-64 的汇编器、在你的机器上运行 Y86-64 程序的模拟器, 还有针对两个顺序处理器设计和一个流水线化处理器设计的模 拟器。这些设计的控制逻辑用 HCL 符号表示的 文件描述。通过编辑这些文件和重新编译模拟器,你可以改变和扩展模拟器行为。我们还提供许多练习,包括实现新的指令和修改机器处理指令的方式。还提供测试代码以帮助你评价修改的正确性。这些练习将极大地帮助你理解所有这些内容, 也能使你更理解处理器设计者面临的许多不同的设计选择 。\n网络旁注 ARC H : VLOG 给出了用 Verilog 硬件描述语言描述的流水线化的 Y86-64 处\n理器。其中包括为基本的硬件构建块和整个的处理器结构创建模块。我们自动地将控制逻辑的 H CL 描述翻译成 Ver ilog 。 首先用我们的模拟器调试 H CL 描述, 能消除很多在硬件设计中会出现的棘手的问题。给定一 个 Verilog 描述, 有商业和开源工具来支待模拟和逻辑合成(l ogic synthesis), 产生实际的微处理器电路设计。因此,虽然我们在此花费大部分精力创建系统的图形和文字描述,写软件的时候也会花费同样的精力,但是这些设计能够自动地合 成, 这表明我们确实 在创建一个能 够用硬件实 现的系统。\n4. 1 Y86-64 指令集体系结构\n定义一个指令集体系结构(例如 Y86- 64 ) 包括定义各种状态单元、指令集和它们的编码、一组编程规范和异常事件处理。\n1. 1 程序员可见的状态\n如图 4-1 所示, Y8 6- 64 程序中的每条指令都会读取或修改处理器状态的某些部分。这称为程序员可见状态,这里的"程序员”既可以是用汇编代码写程序的人,也可以是产生 机器级代 码的编译器。在处理器实现中, 只 要 RF: 程序寄存器\n我们保证机器级程序能够访问程序员可见状\n态, 就不需要完全按照 ISA 暗示的方式来表示和组织 这个处理器状态。Y8 6-64 的状态类似千 x86 -64 。有 15 个程序寄存器:%r a x 、%r c x 、%\nr dx、%r b x 、%r s p、r% b p 、r% s i 、r% d i 和 %r 8 到\n%r1 4。(我们省略了 x8 6-64 的寄存器%r l 5 以 简化指令的 编码。)每个程序寄存器存储一个 64\nCC: 条件码\nStat: 程序状态\nI I\nDMEM: 内存\n位的字。寄存器%r s p 被入栈、出栈、调用和 PC\n返回指令作为栈指针。除此之外,寄存器没有\n固定的含义或固定值。有 3 个一位的条件码: 图 4-1 Y86-64 程序员可见状态。同 x86-64 一\n样, Y86-64 的程序可以 访问 和修改 程\nZF 、 SF 和 OF , 它们保存着最近的算术或逻辑\n序寄存器、条件码、程序计数器 ( P C )\n指令所造 成影响的有关信息。程序计数器( P C ) 和内存。状态码指明程序是否运行正存放当前正在执行指令的地址。 常,或者发生了某个特殊事件\n内存从概念上来说就是一个很大的字节数组, 保存着程序和数据。Y86-64 程序用虚拟地址 来引用内存位置。硬件和操作系统软件联合起来将虚拟地址翻译成实际或物理地址, 指明数据实际存在内存中哪个地方。第 9 章将更 详细地研究虚拟内存。现在, 我们只认为虚拟内 存系统向 Y86-64 程序提供了一个单一的字节数组映像。\n程序状态的最后一个部分是状态码 St a t , 它表明程序执行的总体状态。它会指示是正常运行 , 还是出现了某种异常, 例如当一条指令试图去读非法的内存地址时。在 4. 1. 4 节中会讲 述可能的状态码以及异常处理。\n4. 1: 2 Y86-64 指 令 # 图 4-2 给出了 Y86-64 IS A 中各个指令的简单描述。这个指令集就是我们处理器实现的目标。Y86- 64 指令集基本上是 x86- 64 指 令集的一个子集。它只包括 8 字节整数操作, 寻址方式 较少,操 作 也 较少。因为我们只有 8 字节数据,所 以 称之为 ”字 ( w o r d ) \u0026quot; 不 会 有任何歧 义。在这个图中,左 边 是 指 令 的 汇 编 码 表示, 右 边是字节编码。图 4-3 给出了其中一些指令更详细的内容。汇编代码格式类似于 x86-64 的 AT T 格式。\n下面是 Y86- 64 指令的一些细节。\nx86-64 的 movq 指 令 分 成 了 4 个不同的指令: i rmovq、r rmovq 、mrmovq 和 rmmovq , 分别 显式 地指 明 源和目的的格式。源可以是立即数(立、寄存器 (r ) 或内存 Cm) 。 指 令名字的第一个字母就表明了源的类型。目的可以是寄存器(r ) 或内存Cm) 。指 令 名字的第二个字母指明了目的的类型。在决定如何实现数据传送时,显式地指明数据传送的 这 4 种类型是很有帮助的。\n两个内存传送指令中的内存引用方式是简单的基址和偏移量形式。在地址计算中, 我们不支持第二变址 寄存 器 ( s eco nd index regis t e r ) 和 任 何 寄 存 器 值 的 伸缩( s ca ling ) 。\n同 x8 6-64 一样, 我们不允许从一个内存地址直接传送到另一个内存地址。另外,也不允许将立即数传送到内存。\n有 4 个整数操作指令, 如图 4- 2 中 的 OPq 。 它 们 是 a d d q 、 s u b q 、 a n d q 和 xo r q。它们只 对寄存器数据进行操作, 而 x8 6-64 还允许对内存数据进行这些操作。这些指令会设置 3 个条件码 ZF 、 S F 和 OF( 零 、符 号 和 溢出)。\n7 个跳转指令(图4-2 中的 环x ) 是 j mp 、 j l e 、 j l 、 j e 、 j n e 、 j g e 和 j g。根据分支指令 的 类 型 和条件代码的设置来选择分支。分支条件和 x86- 64 的一样(见图3-15)。\n有 6 个条件传送指令(图 4- 2 中的 c mo v XX) : c mo v l e 、 c mo v l 、 c mo v e 、 c mov ne 、c mo v g e 和 c mo v g 。 这些指令的格式与寄存器-寄存器传送指令r r mo v q 一 样 ,但是只有当条件码满足所需要的约束时,才会更新目的寄存器的值。\nc a l l 指令将返回地址入栈,然 后 跳到 目 的 地址。r e t 指令从这样的调用中返回。\np u s hq 和 p o p q 指令实现了入栈和出栈,就 像 在 x8 6- 64 中 一 样 。\nh a l t 指 令 停 止 指 令 的 执 行 。 x8 6-64 中有一个与之相当的指令 h lt 。x8 6- 64 的应用程序不允许使用这条指令,因 为它会导致整个系统暂停运行。对 千 Y8 6-64 来说,\n执 行 h a lt 指 令 会 导 致处理器停止,并 将 状 态 码 设 置 为 HL T( 参见 4. 1. 4 节)。字节 。 1 2 3 4\nha l 七 荨\nnop 荨\nrrrnovq rA, rB 2 Io rAI rBI ir mov q V, rB 3 jo F j rBj rmmovq rA, D(rB) 4 jo rAj rBj\nmr mo v q D(rB), rA 5 lo rAlrBI OPq rA, rB 6 I fn J rAI rBI\njXX Dest 尸\ncmovxx rA, rB j2 I fn I rAI rBI c a ll Dest 巨\nret 荨\npushq rA I A 伈 l rAIF I\np o pq rA IB l o lrAIF I\nDest\nDest\n图 4- 2 Y8 6-64 指令集。指令编码长度从 1 个字节到 10 个字节不等。一条指令含有一个单字节的指令指示符, 可能含有一个单 字节的寄存器指示符, 还可能含有一个8 字节的常数字。字段 fn 指明是某个整数操作 ( OPq ) 、数据传送条件( c movXX) 或是分支条件 ( j XX) 。 所有的数值都\n用十六进制表示\n4. 1. 3 指令编码\n图 4-2 还给出了指令的字节级编码。每条指令需要 1 - 10 个字节不等, 这取决于需要哪 些 字 段 。 每条指令的第一个字节表明指令的类型。这个字节分为两个部分, 每部分 4\n位: 高 4 位是代码( cod e ) 部分, 低 4 位是功能( fu nc t io n ) 部分。 如图 4- 2 所示, 代码值为O~ Ox B。功能 值只有在一组相关指令共用一个代码时才有用 。图 4-3 给出了整数操作、分支和条件传送指令的 具体编码。可以 观察到,r r mo v q 与条件传送有同样的指令代码。可\n以把它看作是一个 “无条件传送“, 就好像 j mp 指令是无条件跳转一样,它 们的功能代码都是 0。\n整数操作指令 分支指令 传送指令\naddq I 6 1 O I jmp I 7 I O I jne I 7 I 4 I rrrnovql 2 。cmovne j 2 j 4 j\ns ubq 曰巨] j l e 巨巨] j g e 巨卫] cmovleI 2 1 cmovge I 2 I 5 I\nandq I 612 I jl 1 7 1 2 1 jg 1 7 1 6 1 cmovl I 2 2 cmovg I 2 I 6 I\nxorq I 613 I j e I 7 I 3 I cmove I 2 3\n图 4- 3 Y86-64 指令集的功能 码。这些代码指明是某个整数操作、分支 条件还是数据传送条件。这些指令是图 4-2 中所 示的 OPq 、 j XX 和 cmovXX\n如图 4- 4 所示, 1 5 个程序寄存 器中每个都有一 个相对应的范围在 0 到 Ox E 之间的寄存器标识符 ( reg is te r ID ) 。Y8 6-6 4 中的寄存器编号跟 x86- 64 中的相同。程序寄存器存在\nCPU 中的一个寄存器文件 中, 这个寄存器文件就是一个小的、以寄存器 ID 作为地址的随机访问存储器。在指令编码中以及在我们的硬件设计中,当需要指明不应访问任何寄存器 时,就用 ID 值 Ox F 来表示。\n图 4-4 Y86-64 程序寄存器标识符。 1 5 个程序寄存器中每个都有一个相对应的标识符 ( ID )\u0026rsquo; 范 围 为\nO~ OxE。如果指令中某个寄存器字段的 ID 值为 OxF, 就表明此处没有寄存器操作数\n有的指令只 有一个字节长 ,而有 的需要操作数的指令编码就更长一些。首先, 可能有附加的寄存 器指 示符 字 节 ( r eg is t er specifier byte), 指定一个或两个寄存器。在图 4- 2 中, 这些寄存器字段 称为 rA 和 rB。从指令的汇编代码表 示中可以看到, 根据指令类型, 指令可以指定用于数据源和目的的寄存器,或是用千地址计算的基址寄存器。没有寄存器操作数的指令 , 例如分支指令和 c a l l 指令, 就没有寄存器指示符字节。那些只需要一个寄存器操作数的 指令Cir mo v q 、 p u s h q 和 p o p q ) 将另一个寄存器指示符设为 Ox F 。 这种约定在我们的处理器实现中非常有用。\n有些指令需 要一个附加的 4 字节常数 字 ( co n s ta nt w or d ) 。这个字能作为 ir mo v q 的立即数数 据,r mmov q 和 mr mo v q 的 地址指示符的偏移量,以 及分支指令和调用指令的目的地址。注意 ,分 支指令和调用指令的目的是一个绝对地址, 而不像 IA32 中那样使用 PC\n(程序计数器)相对寻址方式。处理器使用 PC 相对寻址方式, 分支指令的编码会更简洁, 同时这样也能允许代码从内存的一部分复制到另一部分而不需要更新所有的分支目标地 址。因为我们更关心描述的简单性, 所以就使用了绝对寻址方式。同 I A 32 一样, 所有整数采用小端法编码。当指令按照反汇编格式书写时,这些字节就以相反的顺序出现。\n例如,用 十六 进制来 表示指令r mmov q %rsp, Ox123456789abcd ( %r d x ) 的 字节编码。从图 4- 2 我们可以 看到,r mmo vq 的第一个字节为 4 0。源 寄存器%r s p 应该编码放在 rA 字段中, 而基址寄存器%r d x 应该编码放在 rB 字段中。根据图 4- 4 中的寄存器编号,我们得到寄存器指示符 字 节 42 。 最后, 偏 移 量 编码放 在 8 字 节 的 常 数 字 中。首先在Ox l 2 3 45 678 9a b c d 的前面填充 上 0 变成 8 个字节 , 变成字节序列 00 01 23 45 67 89 ab c d。写成按字节反序就是 c d ab 89 67 45 23 01 00 。将它们都连接起来就得到指令的编码40 4 2c d a b 8 9 67 45 23 01 00。\n指令集的一个重要性质就是字节编码必须有唯一的解释。任意一个字节序列要么是一个唯一的指令序列的编码, 要么就不是一个合法的字节序列。Y86-64 就具有这个性质, 因为每条指令的第一个字节有唯一的代码和功能组合,给定这个字节,我们就可以决定所有其他附加字节的长度和含义。这个性质保证了处理器可以无二义性地执行目标代码程 序。即使代码嵌入在程序的其他字节中,只要从序列的第一个字节开始处理,我们仍然可以很容易地确定指令序列。反过来说,如果不知道一段代码序列的起始位置,我们就不能准确地确定怎样将序列划分成单独的指令。对于试图直接从目标代码字节序列中抽取出机器级程序的反汇编程序和其他一些工具来说,这就带来了问题。\n讫 练习题 4 . 1 确定 下面的 Y 8 6-64 指令 序列的 字节编码。 \u0026quot; . p a s Ox l OO\u0026quot; 那一行 表明这段目标 代码 的起 始地 址应该 是 Ox l OO 。\n.pos Ox100 # Start code at address Ox100 irmovq $15,%rbx\nrrmovq %rbx,%rcx loop:\nrmmovq %rcx,-3(%rbx) addq %rbx,%rcx\njmp loop\n芦 练习题 4. 2 确定 下列 每个字 节 序列 所编码的 Y8 6-6 4 指令 序 列。 如果序 列 中有 不合法的字节,指出指令序列中不合法值出现的位置。每个序列都先给出了起始地址,冒 号, 然后是 字节序列 。\nA. Ox100: 30f3fcffffffffffffff40630008000000000000\nB. Ox200: a06f800c020000000000000030f30a0000000000000090\nC. Ox300: 5054070000000000000010f0b01f\nD. Ox400: 611373000400000000000000\nE. Ox500: 6362a0f0\n日日比较 x86-6 4 和 Y86-64 的指令编码\n同 x8 6-64 中的 指令编码相比 , Y 8 6- 64 的编码 简单得多, 但是没那么 紧凑 。在所有: 的 Y 8 6-64 指令中, 寄存 器宇段 的位 置都 是固定的 , 而在不 同的 x8 6-64 指令中, 它们的位置是 不一 样的。x8 6- 64 可以将常数值编码 成 1 、2 、4 或 8 个宇 节 , 而 Y 8 6- 64 总是将i 常数值编码成 8 个字 节。\nBJ R IS C 和 CISC 指 令 集\nx86-64 有 时称 为 “ 复 杂指令 集计 算机\u0026quot; (CISC, 读作 \u0026quot; sisk\u0026quot; ) , 与“精简指令集计算机\u0026quot; (RISC, 读作 \u0026quot; risk\u0026quot; ) 相对。从历 史上看, 先 出现 了 CISC 机 器, 它从 最早的 计算机演化 而来。到 20 世 纪 80 年代早期 , 随 着机 器设 计者加入 了很 多 新 指 令 来 支持 高级 任务(例如 处理循环缓冲区 ,执 行 十进制数计算, 以 及 求 多 项式的值), 大型机和 小型机的指令集 已经 变得 非常 庞 大了。 最 早 的 微 处 理 器 出现 在 20 世 纪 70 年代 早期 , 因 为 当 时的集成电路技 术极 大地制约了一块 芯 片 上 能 实现 些什 么 ,所 以 它们 的 指 令 集非 常有限。微处理 器发 展 得 很 快, 到 20 世 纪 80 年 代早期 , 大型机和小型机的指令 集复 杂度一直都在增加。 x86 家族 沿 着这条道路发展到 IA32 , 最 近 是 x86-64。 即 使 是 x86 系列 也仍 然在不断地变化,基于新出现的应用的需要,增加新的指令类。\n20 世 纪 80 年 代 早 期 , RISC 的设 计理念是作为 上 述 发 展 趋 势 的 一种替代而发 展 起 来\n的。IBM 的一组硬件和编译 器专 家受到 IBM 研 究 员 John Cocke 的很 大影响 , 认 为他 们可以为更 简单的指令 集形式产生 高效 的 代 码 。 实际上, 许 多 加 到指令集中的 高级 指 令 很难被编译 器产 生, 所以也很 少被 用到。一个较为 简 单的指令 集 可以 用很 少的 硬 件 实现 , 能以 高效 的 流水线结构组织起 来, 类似 于本章 后 面描 述 的 情 况。 直到 多 年 以 后 IBM 才将这个理 念商品 化 , 开发 出 了 Power 和 PowerPC ISA。\n加 州大学伯 克利分校的 David Pat terson 和斯坦福 大学的 John Henness y 进 一 步发展\n了 RISC 的概念。Pat terson 将 这 种 新 的 机 器 类型 命 名 为 RISC, 而将以前的那种称为\nCISC, 因为以 前 没 有 必 要 给 一种几乎是通用的 指 令 集格 式起 名宇。\n比较 CISC 和最初的 RISC 指令 集, 我 们发现下面这些一般特性。\nCISC 早期的 R ISC\n指令数品 很多。Intel 描述全套指令的文档[ 51] 有 指令数量少得多。通常 少于 100 个。\n1200多页。\n有些指令的延迟很长。包括将一个整块从内存的 一个 没有较长延迟的指令。有些早 期的 RISC 机器甚至没部分复制到另一部分的指令,以及其他一些将多个寄存 有整数乘法指令,要求编译器通过一系列加法来实现器的值复制到内存或从内存复制到多个寄存器的指令。 乘法。\n编码是可变长度 的。x86-64 的指令长度可以 是 l ~ 编码是 固定长度的。通常所有的指令都编码为 4 个\n15个字节。 字节。\n指定操作数的方式 很多 样。在 x86-64 中 ,内 存操作 简单寻址方式。通常只有基址和偏移抵寻址。数指示符可以有许多不同的组合,这些组合由偏移量、\n基址和变址寄存器以及伸缩因子组成。\n可以对内存和寄存器操作数进行算术和逻辑运算。 只能对寄存器操作数进行算术和逻辑运算。允许使用\n内存引用的只有 load 和 store 指令, load 是从内存读到寄存器,store 是从寄存器写到内 存。这种方法被称为load/ store 体系结构。\n对机器级程序 来说实现细节是 不可见的。ISA 提 供 对机器级程序来说 实现细节是可 见的。 有些 RISC 机了程序和如何执行程序之间的清晰的抽象。 器禁止某些特殊的指令序列,而有些跳转要到下一条指\n令执行完了以后才会生效 。编译器必须在这些约束条件\n下进行性能优化。\n有条件码。作为指令执行的副产品,设置了一些特 没有条件码。相反,对条件检测来说,要用明确的测试殊的标志位,可以用于条件分支检测。 指令,这些指令会将测试结果放在一个普通的寄存器中。\n栈密集的过程链接。栈被用来存取过程参数和返回 寄存器密集的过程链接 。寄存器被用来存取 过程参数地址。 和返回地址。因此有些过程能完全避免内存引用。通常\n处理器有更多的(最多的有32 个)寄存器。\nY86-64 指令 集既 有 CISC 指令集的 属性, 也 有 RISC 指令集的 属性。和 CISC 一样, 它有 条件码、长度 可 变的指令, 并用 栈来保 存返回地址。和 RISC 一样的是, 它 采用load / sto re 体 系结 构和规则编码, 通过寄存器来传递过程参数。Y86-64 指 令 集可以看成是 采 用 CISC 指令集( x86) , 但 又根 据 某些 RISC 的原理进行了 简化 。\n田日R IS C 与 CIS C 之争\n20 世纪 80 年代, 计算机体 系结 构领域里关 于 RISC 指令集和 CISC 指令集优 缺点的争 论 十分激烈。RISC 的支持者声 称在给定硬 件数量的情况下, 通过结合 简 约 式指令集设计、高级编译器技术和流水线化的处理器实现,他们能够得到更强的计算能力。而\nCISC的拥定反驳说要 完成 一个给定的任务只需要用较 少的 CISC 指令, 所以他们的机器能够获得更高的 总体性能。\n大多数 公 司 都 推 出 了 RISC 处理 器 系 列 产 品 , 包括 S un Microsystems ( SPARC ) 、\nIBM 和 Moto rola( PowerPC) , 以及 D屯ital Equipment Corporation ( Alpha) 。一 家英国公 # 司 Acorn Computers Ltd. 提出 了 自 己的 体 系 结构一—-ARM ( 最 开 始是 \u0026quot; Acorn RISC\nMachine\u0026quot; 的首宇母缩写), 广泛应用在 嵌入式 系统中(比如手机)。\n20 世纪 90 年代早期, 争 论逐 渐平息, 因 为 事 实已 经很 清楚了 , 无论是单纯的 RISC 还是 单纯的 CISC 都不如 结合两者 思想精华的设计。RISC 机器发 展 进 化的过程中, 引入了更多的指令, 而许 多这样的指令都需要执行 多 个周期。今天的 RISC 机器的 指令表中有 几百条指令 , 几乎与 “ 精 简指令集机 器” 的名称不相符了。 那种将实现细节暴露给机器级程序的思想已经被证明是目光短浅的。随着使用更加高级硬件结构的新处理器模型 的开发,许多实现细节已经变得很落后了,但它们仍然是指令集的一部分。不过,作为\nRISC 设计的核心的指令集仍然是非常适合在流水线化的机器上 执 行的。\n比较新的 CISC 机器也 利 用 了 高性 能流水线结构。就像我们将在 5. 7 节 中讨论的那样 , 它们读取 CISC 指令, 并动 态地翻译成比较 简 单的、像 RISC 那样的操作的序列。例如,一条将寄存器和内存相加的指令被翻译成三个操作:一个是读原始的内存值,一 个是执行加 法运 算, 第 三就是将和写回 内存 。由于动态翻 译通常可以在 实际 指令执行前进行,处理器仍然可以保持很高的执行速率。\n除了技 术因素 以外, 市场 因素也在决定不 同指 令 集是 否成功 中起 了很 重要的作用。通过保持与 现 有 处理 器的 兼容性, In tel 以及 x86 使得从 一代处理 器迁移到下一代变得很 容 易。 由 于集成 电路 技术的进步, In t el 和其他 x86 处理 器制造 商能够克服原来 8086 指令集设计造成的低效率,使 用 RISC 技 术产 生出 与 最好的 RISC 机 器相 当的性能。正如 我们在笫 3. 1 节中看到的那样 , I A32 发 展 演 变到 x86-64 提 供 了 一个机会,使 得能够将 RISC 的一些特性结合到 x86 中。在桌面、 便 携 计 算机 和基于服务 器的 计 算领域里,\nx86 已经占据 了 完全 的统治地 位。\nRISC 处理 器在 嵌入 式处理器市场上表现得非 常出 色,嵌 入式处理 器 负 责控制移动电话、汽车刹车以 及 因特 网电 器等 系统 。 在 这些应用 中,降 低 成本和功耗比保持后向兼容 性 更重要。就出售的处理器数 量来说, 这是个非常广阔而迅 速 成长着的 市场。\n4. 1. 4 Y86-64 异 常 # 对 Y86-64 来说, 程序员可见的状态(图4-1 ) 包 括 状 态 码 St a t , 它描述程序执行的总体状态。这个代码可能的值如图 4-5 所示。代码值 1, 命名为 AOK, 表示程序执行正常,\n而其他 一些代码则 表示发生了 某种类型的异常。 代码 2 , 命名为 HLT , 表示处理器执行了一条 ha lt 指令。代码 3\u0026rsquo; 命名为 ADR , 表示处理器试图从一个非法内存地址读或者向一个非法内存地址写,可能是当取指令的时候,也\n可能是当读或者写数据的时候。我们会限制最大的地址(确切的限定值因实现而异),任何访问超出这个 限定值的地址都会引发 ADR 异常。代码\n4, 命名为 I NS , 表示遇到了非法的指令代码。\n对于 Y8 6- 64 , 当遇到这些异常的时候,我 图 l - ;i\n们就简单地让处理器停止执行指令。在更完整的设计中,处理器通常会调用一个异常处理程序\nY86-64 状 态码。在我 们的设 计中, 任何 AOK 以 外的代码都会使处理器停止\n(exception handler), 这个过程被指定 用来处理遇到的某种类型的异常。就像在第 8 章中讲述的,异 常处理程序可以被配置成不同的结果, 例如, 中止程序或 者调用一个用户自定义的信号 处理程序 ( s ig n a l h a nd le r ) 。\n4. 1. 5 Y86-64 程 序\n图 4- 6 给出了下 面这个 C 函数的 x8 6- 6 4 和 Y 86- 6 4 汇编代码 :\nlong sum(long *start, long count) 2 { 3 long sum = O; 4 while (count) { 5 sum += *start,· 6 start++; 7 c oun t - - ; 8 } 9 return sum· 10 } x86-64 code Y86-64 code\nlong sum(long *s t ar t , long count) long sum(long•start, long count) start in %rdi, count in %rsi start in %rdi, count in %rsi 1 sum: 1 sum: 2 movl $0, %eax sum= 0 2 irmovq $8,%r8 Constant 8 3 jmp .L2 Goto test 3 irmovq $1,%r9 Constant 1 4 .L3: loop: 4 xorq %rax,%rax sum= 0 5 addq (%rdi), %rax Add *start to sum 5 andq %rsi,%rsi Set CC 6 addq $8, %rdi start++ 6 jmp test Goto test 7 subq $1, %rsi count\u0026ndash; 7 loop: 8 .L2: test: 8 mrmovq (%rdi),壮10 Get *start 9 testq %rsi, %rsi Test sum 9 addq %r10,%rax Add to sum 10 jne .L3 If 1=0, goto loop 10 addq 壮 8 , %r di start++ 11 rep; ret Return 11 subq %r9,%rsi count\u0026ndash;. Set CC 12 test: 13 jne loop Stop when 0 14 ret Return 图 4-6 Y86-64 汇编程序与 x86-64 汇编程序比较。 Sum 函数计算一个整数 数组的和。\nY86-64 代码与 x86-64 代码遵循了相同的通用模式\nx8 6- 64 代码是由 GCC 编译器产生的 。Y8 6- 64 代码与之类似, 但有以下不同点:\nY 8 6- 6 4 将常数加载到寄存 器(第2 3 行), 因为它在算术指令中不能使用立即数。\n要实现从内存读取一个数值并将其与一个寄存器相加, Y8 6-64 代码需要两条指令\n(第 8 9 行),而x8 6- 64 只需要一条 a d d q 指令(第5 行)。\n我们手工编写的 Y86-6 4 实现有一个优势, 即 s ub q 指令(第11 行)同时还设 置了条件码, 因此 GCC 生成代码中的 t e s t q 指令(第9 行)就不是必需的。不过为此, Y8 6-6 4 代码必须 用 a nd q 指令(第5 行)在进入循环之前设 置条件码。\n图 4- 7 给出了用 Y8 6-64 汇编代码编写的一个完整的程序文件的例子。这个程序既包括数据,也 包括指令。伪指令( direct ive ) 指明应该将代码或数据放在什么位置, 以及如何对齐。这个程序详细说明了栈的放置、数据初始化、程序初始化和程序结束等问题。\n# Execution begins at address 0\n2 .pos 0\nirmovq stack, %rsp call main\n#Setup stack pointer\n# Execute main program\n7 # Array halt # Terminate program of 4 elements 8 .align 8 9 array: 10 .quad OxOOOdOOOdOOOd 11 .quad OxOOcOOOcOOOcO 12 .quad OxObOOObOOObOO 13 14 15 main: .quad OxaOOOaOOOaOOO 16 irmovq array,%rdi 17 irmovq $4,%rsi 18 call sum # sum(array, 4) 19 ret 20 21 # long sum(long *start, long count) 22 # start in %rdi, count in %rsi 23 sum: 24 irmovq $8,%r8 # Constant 8 25 irmovq $1, %r9 # Constant 1 26 xorq %rax,%rax #sum= 0 27 andq %rsi,%rsi # Set CC 28 jmp test # Goto test 29 loop: 30 mrmovq (%rdi),%r10 # Get *start 31 addq %r10,%rax # Add to sum 32 addq %r8,%rdi # start++ 33 subq %r9,%rsi # count\u0026ndash;. Set CC 34 test: 35 jne loop # Stop when 0 36 ret # Return 37 38 # Stack starts here and grows to lower addresses 39 . pos Ox200 40 stack: 图4-7 用 Y86-64 汇编代码编写的 一个例子程 序。调用 s um 函数来计算一个具有 4 个元素的数组的和\n在这个程序中, 以 \u0026ldquo;. \u0026quot; 开头的词是汇编器伪 指令( a s s e m b le r directives), 它们告诉汇编器调整地址,以 便 在 那 儿 产 生 代 码或插入一些数据。伪指令 . p o s O( 第 2 行)告诉汇编器应该从 地址 0 处开始产生代码。这个地址是所有 Y 86- 64 程序的起点。接下来的一条指令\n(第3 行)初始化栈指针。我们可以看到程序结尾处(第40 行)声明了标号 s t a c k , 并且用一个 . p o s 伪指令(第3 9 行)指明地址 Ox 2 0 0 。 因 此栈会从这个地址开始,向 低 地 址 增 长 。我们必须 保证栈不会增长得太大以至于覆盖了代码或者其他程序数据。\n程序的 第 8 ~ 1 3 行声明了一个 4 个字的数组,值 分 别 为\nOx OOOd OOOd OOOd OOOd , Ox OOc OOOc OOOc OO Oc O OxObOOObOOObOOObOO, Ox a OOOa OOOa OOOa OOO\n标号 a r r a y 表明了这个数组的起始,并 且 在 8 字节边界处对齐(用.a li g n 伪 指令指定)。\n第 16~ 19行给出了 \u0026quot; ma i n\u0026rdquo; 过程,在过 程中对那个四字数组调用了 s um 函数 ,然 后停 止 。正如例子所示,由 千我们创建 Y8 6- 64 代码的唯一工具是汇编器, 程序员必须执行本\n来通常交 给编译器、链接器和运行时系统来完成的任务。幸好我们只用 Y 8 6- 6 4 来写一些小的程序,对 此一些简单的机制就足够了。\n图 4-8 是 Y A S 的汇编器对图 4- 7 中代码进行 汇编的结果。为了便于理解,汇 编 器的输出结果是 ASCII 码格式。汇编文件中有指令或数据的行上,目 标 代码包含一个地址,后 面跟着 1 ~ 1 0 个 字 节的 值 。\n我们 实现了一个指令集模 拟器,称 为 Y IS , 它的目的是模拟 Y 8 6- 6 4 机器代码程序的执行, 而不用试图去模拟任何具体处理器实现的行为。这种形式的模拟有助于在有实际硬件可用 之前调试程序,也 有 助于检查模拟硬件或者在硬件上运行程序的结果。用 Y IS 运行例子的 目标代码, 产 生如下输出:\nStopped in 34 steps at PC= Ox13 . Status\u0026rsquo;HLT\u0026rsquo;, CC Z=l S=O O=O Changes to r egi st er s :\n%rax: OxOOOOOOOOOOOOOOOO\n%rsp: OxOOOOOOOOOOOOOOOO\n%rdi: OxOOOOOOOOOOOOOOOO\n%r8: OxOOOOOOOOOOOOOOOO\n%r9: OxOOOOOOOOOOOOOOOO\n%r10: OxOOOOOOOOOOOOOOOO\nChanges to memory:\nOx01f0: OxOOOOOOOOOOOOOOOO Ox01f 8 : OxOOOOOOOOOOOOOOOO\nOxOOOOabcdabcdabcd Ox0000000000000200 Ox0000000000000038 Ox0000000000000008 Ox0000000000000001 OxOOOOaOOOaOOOaOOO\nOx0000000000000055 Ox0000000000000013\n模拟输出的第一行总结了执行以及 PC 和程序状态的结果值。模拟器只打印出在模拟 过程中 被改变了的寄存器或内存中的字。左边是原 始值(这里都是 0 )\u0026rsquo; 右 边是最终的值。从输出中 我们可以看到, 寄 存 器 %r a x 的 值 为 Ox a b c d a b c d a b c d a b c d , 即 传 给子函数 s u m 的四元素数组的和。另外, 我们还能看到栈从地址 Ox 2 0 0 开始,向 下 增 长 ,栈 的 使 用 导 致内存地址 Ox lf O Ox lf 8 发 生了变化。可执行代码的最大地址为 Ox 0 9 0 , 所以数值的入栈和出栈不 会破坏可执行代码。\n练习题 4. 3 机器级程序 中 常见的模式之一是 将一个常 数值 与 一个寄存器相加。利用目前 已 给 出 的 Y 8 6- 6 4 指令, 实 现这个操作需 要 一条 ir mo v q 指令把 常数加载 到 寄存器, 然后 一条 a d d q 指令把这个寄存器值 与 目标 寄存器值相加。假设我 们 想增加一条新指 令 i a d d q , 格式如下:\n字节 0 1 2 5 6\niaddq V, rB I c I O I F IrB I V\n该指令 将常数值 V 与 寄存 器 rB 相加。\n使用 i a d d q 指令 重写 图 4- 6 的 Y 8 6- 64 s u m 函 数。 在 之前 的代码 中, 我们 用寄存器%r 8 和%r 9 来保 存常数值。 现在 ,我们 完全 可以 避免使用 这些寄 存器。\nOxOOO:\n# Execution begins at address 0\n.pos 0\nOxOOO: 30f40002000000000000 OxOOa: 803800000000000000\nOx013: 00\nOx018:\nOx018:\nOx018: OdOOOdOOOdOOOOOO Ox020: cOOOcOOOcOOOOOOO Ox028: OOObOOObOOObOOOO Ox030: OOaOOOaOOOaOOOOO\nOx038:\nOx038: 30f71800000000000000 Ox042: 30£60400000000000000 Ox04c: 805600000000000000\nOx055: 90\nirmovq stack, %rsp call main\nhalt\n# Array of 4 elements\n.align 8\narray:\n.quadOxOOOdOOOdOOOd\n.quad OxOOcOOOcOOOcO\n.quad OxObOOObOOObOO\n.quad OxaOOOaOOOaOOO\nmain:\nirmovq array,%rdi irmovq $4,%rsi call sum\nret\n#Setup stack pointer\n# Execute main program\n# Terminate program\n# sum(array, 4)\n# long sum(long *start, long count)\nOx056:\n# start in %rdi, count in 1r儿 s i sum:\nOx056: 30f80800000000000000 Ox060: 30f90100000000000000 Ox06a: 6300\nOx06c: 6266\nOx06e: 708700000000000000 Ox077:\nirmovq $8,%r8 irmovq $1,%r9 xorq %rax,%rax andq %rsi,%rsi jmp test\nloop:\n# Constant 8\n# Constant 1\n#sum= 0\n# Set CC\n# Goto test\nOx200: Ox200:\n# Stack starts here and grows to lower addresses\n.pos Ox200 stack:\n图4-8 YAS 汇编器的输出 。每一行包含一个十六进制的地址 , 以及字节数在 1~ 10 之间的目标代码\n匹 练习题 4. 4 根据下面的 C 代码 , 用 Y86- 64 代码来实现一个 递 归 求和函 数 r s um:\nlong rsum(long *start, long count)\n{\nif (count \u0026lt;= 0) return O;\nreturn *start+ rsum(start+l, count-1);\n}\n使用 与 x86- 64 代码相同的参数传递和寄存器保存 方 法。 在 一 台 x86- 64 机器上编\n译这 段 C 代码, 然后再把那些指 令翻译成 Y86- 64 的指令, 这样做可 能会很有帮助 。练习题 4. 5 修 改 s um 函数的 Y8 6- 64 代码(图 4-6) , 实现函 数 a b s Sum, 它计算一个数组的绝对值的和。在内循环中使用条件跳转指令。\n练习题 4. 6 修改 s um 函 数的 Y8 6- 64 代码(图 4- 6 ) , 实 现函 数 a b s Sum,\n数组的绝对值的和。在内循环中使用条件传送指令。\n它计算一个\n1. 6 一 些 Y86 -6 4 指 令的 详情\n大多数 Y8 6- 64 指令是以一种直接明了的方式修改程序状态的,所 以 定 义 每条指令想要达到的结果并不困难。不过,两个特别的指令的组合需要特别注意一下。\npus hq 指令会把栈指针减 8 , 并且将一个寄存器值写入内存中。因此, 当 执 行 p u s h q\n%rs p 指令时 ,处 理 器 的 行 为是不确定的, 因 为要人栈的寄存器会被同一条指令修改。通常有两种不同的约定: 1 ) 压入%r s p 的 原 始 值 , 2 ) 压入减去 8 的%r s p 的 值 。\n对千 Y86- 64 处理器来说, 我们采用和 x86-64 一样的做法, 就 像 下 面这个练习题确定出的那样。\n练习题 4. 7 确定 x86-64 处理器上指令 pus hq %r s p 的行为。 我们 可以通过阅读 In t el 关于这条指令的文档来了解它们的做法,但更简单的方法是在实际的机器上做个实 验。 C 编译器正常情况下是不会 产生这 条指令的, 所以 我们 必须用 手 工 生 成的 汇编 代码 来完成 这一任务。下面是我 们 写 的 一个测 试程 序(网 络旁 注 ASM : EAS M , 描绘如何编 写 C 代码和手写 汇编 代码结合的程序):\n.text\n.globl pushtest pushtest:\nmovq %rsp, %rax Copy stack pointer pushq %rsp Push stack pointer popq %rdx Pop it back subq %rdx, %rax Return O or 8 ret 在实验中 , 我们发现函数 p u s h 七e s t 总是 返回 o, 这表 示在 x86- 64 中 p u s h q %rsp\n指令的行为是怎样的呢?\n对 po pq %r s p 指 令 也 有 类 似的歧义。可以将%r s p 置为从内存中读出的值, 也 可 以 置为加了增量 后的栈指针。同 练习题 4. 7 一样, 让 我们做个实验来确定 x86-64 机器是怎么处理这条指 令的 ,然 后 Y86- 64 机器就采用同样的方法。\n练习题 4. 8 下面这个汇编 函 数让 我们确定 x86-64 上指 令 popq %r s p 的行为 :\n. t ext\n.globl poptest poptest:\n4 movq %rsp, r% di Save stack pointer\n5 pushq $0xabcd Push test value\n6 popq %r s p Pop to stack pointer\n7 movq 。r1/. s p, %rax Set popped value as return value\n8 movq\n9 ret\nr% di , %rsp Restore stack pointer\n我们发现函数总是返回 Oxa b c d 。 这表 示 po pq %r s p 的行为 是怎样的? 还有什 么\n其他 Y86-64 指令也会有相同的行为吗?\n日 日 正 确了解细节: x86 模型间的 不一致\n练习题 4. 7 和练 习题 4. 8 可以 帮 助我们确定对于压入和弹出 栈 指 针指令的 一致惯例。看上去似乎没有理由会执行这样两种 操 作, 那么一个很 自 然的 问题就是“ 为什 么要担心这样一些吹毛求疵的细节呢?”\n从下面 In tel 关 于 P US H 指令的文档[ 51] 的节 选中, 可以 学到 关 于这个一致的重要性的有用的 教训:\n对于 IA-32 处理器,从 Inte l 286 开始 , P US H ESP 指令将 ESP 寄存 器的 值压入栈\n中,就 好 像 它存 在于这 条指令被执行之前。(对于 Intel 64 体 系结构、IA-32 体系结 构的实地 址模式和虚 8086 模 式 来说 也 是这样。)对于 I ntel ® 8 086 处理 器, P US H SP 将 SP寄存器的 新值压入栈中(也就是减去 2 之后的值)。( P US H ESP 指令。Intel 公 司。50 。)虽然这个说明的具体细节可能难以理解,但是我们可以看到这条注释说明的是当执\n行压入栈指针寄存器指 令时, 不同型号的 x86 处理器会 做 不同的事情。有些会压入原始的值,而有些会压入减去后的值。(有趣的是,对于弹出栈指针寄存器没有类似的歧 义。)这种不一致有两个缺 点:\n它降 低 了代 码 的可移植 性。取 决于处理器模 型, 程序可能会有不 同的行为。 虽 然这样特殊的指令并不常见,但 是 即 使 是 潜在的不兼容也可能带 来严 重的后果。 它增加了文档的复杂性。正如在这里我 们看到的那样 , 需要一个特别的说明来澄清这些不同之 处。即使没有这样的特殊情况, x86 文档就已经够复杂的 了。\n因此我们的结论是,从长远来看,提前了解细节,力争保持完全的一致能够节省很多的麻烦。\n4. 2 逻辑设计和硬件控制语言 HCL\n在硬件设计中, 用电 子电路来计算对位进行运算的函数,以 及 在 各 种 存 储 器 单 元 中存储 位 。 大 多 数 现代电路技术都是用信号线上的高电压或低电压来表示不同的位值。在当前的技术 中 , 逻辑 1 是用 1. 0 伏特左右的高电压表示的 ,而 逻辑 0 是用 0. 0 伏特左右的低电压表示的。要实现一个数字系统需要三个主要的组成部分:计 算 对 位 进行操作的函数的组合 逻辑、存储位的存储器单元,以 及 控 制 存 储 器单元更新的时钟信号。\n本节简要描述这些不同的组成部分。我们还将 介绍 HCL ( Hardware Cont rol Lan­\nguage, 硬件控制语言),用这种语言来描述不同处理器设计的控制逻辑。在此我们只是简略地描述 HCL , H CL 完整的参考请见网络旁注 ARC H : H CL 。\n日 日 现代逻辑设计\n曾经,硬件设计者通过描绘示意性的逻辑电路图来进行电路设计(最早是用纸和笔, 后来是用计 算机图形终 端)。现在, 大多数设 计都是用硬件描 述语言( H ard ware Description\nLanguage, H DL) 来表达的。H DL 是一种 文本表 示, 看上去和编程语言类似, 但 是 它是用来描 述硬件结构而不是 程序行为的。最常用的 语言是 Verilog , 它的 语法类似于 C ; 另一 种是 V H DL , 它的 语法类似 于编程语言 Ada。这些语 言本来都是 用 来表 示数 字电 路的模拟 模型的 。20 世 纪 80 年代中期,研 究者开发 出 了 逻 辑合成 (l ogic synt hes is ) 程序, 它可 以根据 H DL 的描述生成 有效的电路设 计。现在有许多 商用的 合成程序, 已经成为产生 数宇电路 的主要技术。从手工设 计电路到合成生成 的转变就 好 像 从 写 汇编程序到 写 高级语言程序, 再 用编 译器来 产 生机 器代码的转变一 样。\n我们的 HCL 语言只表达硬件设计的控制部分, 只有有限的操作集合 ,也 没有模块化。不过, 正如我们会看到的 那样,控 制逻辑是设计微处理器中 最难的部分。我们已经开发出 了将 HCL 直接翻译成 Verilog 的工具 , 将这个代码与基本硬件单元的 Verilog 代码结合起来, 就能产生 H DL 描述,根 据 这个 H DL 描述就可以合成实际能 够工作的 微处理器。通过小心地分离、设计和测试控制逻辑,再加上适当的努力,我们就能创建出一个可以工作的微 处理器。 网络 旁注 ARCH : VLOG 描述了如何 能产生 Y86-64 处理器的 Verilog 版本。\n4. 2. 1 逻辑门\n逻辑门 是数字电路的基本计算单元。它们产生的输出, 等 千它们输入位值的某个布尔函数。图 4-9 是 布尔函数 AND 、OR 和 NO T 的标 准符号, C 语 言 中 运算符 ( 2. 1. 8 节)的逻辑门下面是对应的 HCL 表达式: A ND 用 && 表示, O R 用 1 1 表示, 而 NOT 用! 表\n示。我 们用这些符号而不用 C 语言中的位运算符 &、 I 和~ , 这是因为逻辑门只对单个\n位的数进行 操作,而 不 是 整个字。虽然图中只说明了 AND 和 OR 门的 两个输入的版本, 但是常 见的是它们作为 n 路操作, n\u0026gt; 2。不过,在 H CL 中 我们还是把它们写作二元运算符, 所以 , 三个输入的 AND 门 ,输 入为 a 、 And OR NOT\nb 和 c , 用 H CL 表示就是 a \u0026amp;\u0026amp;b \u0026amp;\u0026amp;c 。\n逻辑门 总是活动的 ( active ) 。一旦 一个门的输入变化了,在很短的时间内,输出就会\n:0-out\n输出=a\u0026amp;\u0026amp;b D- out a 令 o- out\n输出=a l lb 输出=!a\n相应地 变化。\n2. 2 组 合 电 路 和 HCL 布 尔 表 达 式 图 4- 9 逻辑门类型。每个门产生的输出\n等于它输入的某个布尔函数\n将很多 的逻辑门组合成一个网,就 能 构 建 计 算 块( computa tional block), 称为组合电\n路( combinational circ uits ) 。如何构建这些网有几个限制:\n·每个逻辑门的输入必须连接到下述选项之一: 1 ) 一个系统输入(称为主输入), 2 ) 某个存储器单元的输出, 3 ) 某 个 逻辑门的输出。\n两个或多个逻辑门的输出不能连接在一起。否则它们可能会使线上的信号矛盾, 可能会导致一个不合法的电压或电路故障。\n·这个网必须是无环的。也就是在网中不能有路径经过一系列的门而形成一个回路, 这样的回路会导致该网络计算的函数有歧义。\n图 4-10 是一个 我们觉得非常有用的简单组合电路的例子。它有两个输入 a 和 b , 有唯一的输 出 eq , 当 a 和 b 都是 1 ( 从上面的 AND 门可以看出)或都是 0 ( 从下面的 AND 门可以看出)时, 输出为 1。用 HCL 来写这个网的函数就是:\nbool eq = (a \u0026amp;\u0026amp; b) 11 (!a \u0026amp;\u0026amp; !b);\n这段代码简单 地定义了位级(数据类型 b o o l 表明了这一点)信号e q , 它是输入 a 和 b 的函数。从这个例子可以看出 HCL 使用了 C 语言风格的语法,`= '将一个信号名与一个表达式联系起来。不过同 C 不一样, 我们不把它看成执行了一次计算并将结果放入内存中某个位置。相反,它只是给表达式一个名字。\n比囡 练习题 4. 9 写 出信 号 xor 的 HCL 表达 式, x o r 就是 异或 , 输入 为 a 和 b。信号 xo r\n和上面定 义的 e q 有什 么关 系?\n图 4-11 给出了另一个简单但很有用的 组合电路, 称为多路复用 器 ( m ultiplexo r , 通常称为 \u0026quot; M U X\u0026quot; ) 。多路复用器根据输入控制信号的值,从 一组不同的数据信号中选出一个。在这个单个位的多路复用器中, 两个数据信号是输入位 a 和 b , 控制信号是 输入位 s 。当 s 为 1 时, 输出等于 a ; 而当 s 为 0 时, 输出等于 b。在这个电路中, 我们可以看出两个A N D 门决定了是否将它们相对应的数据输入传送到 OR 门。当 s 为 0 时, 上面的 AND 门 将传送信号 b( 因为这个门的另一个输入是!s )\u0026rsquo; 而当 s 为 1 时,下 面的 AND 门将传送信号 a 。接下来,我 们来写输出信号的 HCL 表达式 , 使用的就是组合逻辑中相同的 操作:\nbool out= (s \u0026amp;\u0026amp; a) I I (!s \u0026amp;\u0026amp; b);\na\ne q\nOU 七\n图 4-10 检测位相等的组 合电路。当输入都为 0 图 4- 11 单个位的多路复用器电路。如果控制信号或都为 1 时, 输出等于 1 s 为 1 . 则 输出 等 千输 入 a ; 当 s 为 0\n时 ,输 出 等 于 输 入 b\nH CL 表达式很清楚地表明了组合逻辑电路和 C 语言中逻辑表达式的对应之处。它们都是用布尔操作来对输入进行计算的函数。值得注意的是,这两种表达计算的方法之间有 以下区别:\n因为组合电路是由一系列的逻辑门组成, 它的属性是输出会持续地响应输入的变化。如果电路的输入变化了,在一定的延迟之后,输出也会相应地变化。相比之 下, C 表达式只 会在程序执行 过程中被 遇到时才进行 求值。\nC 的逻辑表达式允 许参数是任意整数, 0 表示 F ALSE , 其他任何值都表示 T RUE。而逻辑门 只对位值 0 和 1 进行操作。\nC 的逻辑表达式有个属性就是它们可能只被部分求值。如果一个 AND 或 OR 操作的 结果只用对第一个参数求 值就能确定, 那么就不会对第二个参数求值了。例如下面的 C 表达式:\n(a \u0026amp;\u0026amp; !a) \u0026amp;\u0026amp; func(b,c)\n这里函数 f u n c 是不会被调用的 , 因为表达式 ( a \u0026amp;\u0026amp; ! a ) 求值为 0 。而组合逻辑没有部分求值这条规则,逻辑门只是简单地响应输人的变化。\n2. 3 字级的组合电路和 HCL 整数表达式\n通过将逻辑门组合成大的网,可以构造出能计算更加复杂函数的组合电路。通常,我 们设计能对数据字( wo rd ) 进行操作的电 路。有一些位级信号, 代表一个 整数或一些控制模\n式。例如 , 我们的处 理器设计将包含有很 多字,字的 大小的范围为 4 位到 64 位, 代表整数、地址、指令代码和寄存器标识符。\n执行字级计算的组合电路根据输入字的各个位 ,用 逻辑门 来计算输出 字的各个位。例如图 4-12 中的一个组合电路, 它测试两个 64 位字 A 和 B 是否相等。也就是, 当且仅当 A 的每一位都和 B 的相应位相等时, 输出才 为 1。这个电 路是用 64 个图 4-10 中所示的单 个位相等电路实现的。这些单个位电路的输出用一个AND 门连起来 , 形成了这个电路的输出。\n:二勹A B\na ) 位级实现 b ) 字级抽象\n图 4-12 字级相等测试电路。当字 A 的 每一位与字 B 中相应的位均相等时, 输出等于 1。字级相等是 HCL 中的一个 操作\n在 HCL 中, 我们将所有字级的信号都声明为 i n t , 不指定字的大小。这样做是为了简单。在 全功能的硬件描述语言中, 每个 字都可以声明为有特定的位数。HCL 允许比较字是否相等, 因此图 4-12 所示的电 路的函数可以在字级上表达成\nbool Eq = (A == B) ;\n这里参数 A 和 B 是 i n t 型的。注意我们使用 和 C 语言中一样的语法习惯,'= '表示赋值,而\u0026rsquo;==\u0026lsquo;是相等运算符。\n如 图 4-1 2 中右边所示 , 在画字级电路的时候, 我们用中等粗度的线来表示携带字的\n每个位的线路,而用虚线来表示布尔信号结果。\n练习题 4. 10 假设你用练习题 4. 9 中的异或电路而不是位级的相等电路来实现一个 字级的相等电路。设计一个 64 位字的相等电路需要 64 个字级的异或电路, 另外还要两个逻辑门。\n图 4-13 是字级的多路复用器电路。这个电路根据控制输入位 s , 产生一个 64 位的字\nOut, 等于两个输入字 A 或者 B 中的一个。这个电路由 64 个相同的 子电路组成, 每个子电路的结构都类似于图 4-11 中的位级多路复用器。不过这个字级的电路并没有简单地复制\n64 次位级多 路复用器, 它只产生一次! s , 然后在每个位的地方都重复使用它,从而减少反相器或非门 ( inver t ers )的数量。\n处理器中会用到很多种多路复用器,使得我们能根据某些控制条件,从许多源中选出 一个字。 在 HCL 中,多 路复用函数是用情况表 达式 ( cas e ex pres sion ) 来描述的。情况 表达式的通用格式如下:\n[ select1 select2 exp r1 ; expr2; selectk exprk; ] 这个表达式包含一系列的情况, 每种情况 i 都有一个布尔表达式 sel ect ; 和一个整数表达式 ex p r ; , 前者表明什么时候该选择这种情况,后者指明的是得到的值。\ns\nb 63\na 63\nout 63\nb 62\nout 62\n二., \u0026hellip;丑..、Out\nint Out= [ s A;\n1 : B;\nb 。\nou t 。\na 。\n];\nb ) 字级抽象\n图 4-13 字级多路复用器电路。当控制信号 s 为 1 时, 输出会等于输人字 A,\n否则等于 B。HCL 中用情况( case) 表达式来描述多路复用器\n同 C 的 S W工t c h 语句不同,我 们不要求不同的选择表达式之间互斥。从逻辑上讲,这些选择表达式是顺序求值的, 且第一个求值为 1 的情况会被选中。例如, 图 4-13 中的字级多路 复用器用 HCL 来描述就是:\nword Out= [\ns: A;\n1: B;\n];\n在 这段 代 码 中 ,第 二个选择表达式就是 1, 表明如果前面没有情况被选中,那就选择这种情况。这是 HCL 中一种指定默认情况的方法。几乎所有的情况 表达式都是以此结尾的。\n允许不互斥的选择表达式使得 HCL 代码的可读性更好。实际的硬件多路复用器的信号必须互斥,它 们要控制哪个输入字应该被传送到输出,就像 图 4-13 中的信号 s 和 ! s 。要将一个 HCL 情况表达式翻译成硬件, 逻辑合成程序需要分析选择表达式集合,并 解决任何可能的冲突,确保只有第一个满足的情况才会被选中。 s1\n选择表达式可以是任意的布尔表达式,可以有任意 s 0\n多的情况。这就使得情况表达式能描述带复杂选择标准 D\n的、多 种输入信号的块。例如, 考虑图 4-14 中所示的四 B\n路复用器的图。这个电路根据控制信号 s l 和 s 0 , 从 4\nOut 4\n个输入字 A、B、C 和 D 中 选择一个, 将控制信号看作一个两位的二进制数。我们可以用 HCL 来表示这个电路, 用 布尔表达式描述控制位模式的不同组合:\nword Ou t 4 = [\n!s1 \u0026amp;\u0026amp; !s0 : A; # 00\n图 4-14 四路复用器。控制信号 s l 和s 0 的不同组合决 定了哪个数据输人会被传送到输出\n! s1 : Bi # 01\n! s0 : C; # 10 D; # 11\n];\n右边的 注释(任何以#开头到行尾结 束的文字都是注释)表明了 s l 和 s 0 的什么组合会导致该种情况会被选中。可以看到选择表达式有时可以简化,因为只有第一个匹配的情况 才会被选中。例如,第二个表达式可以写成!sl, 而不用写得更完整! s l \u0026amp;\u0026amp; s0, 因为另一种可能 s l 等于 0 已经出现在了第一个选择表达式中 了。类似地 , 第三个表达式可以写作\n!s0, 而第四个可以简单地写成 1。\n来看最 后一个例子, 假设我们想设计一个逻辑电 路来找一组字 A、B 和 C 中的最小值,\n;三 贮n 3\n用 HCL 来表达就是 :\n欢 or d Min3 = [\nA\u0026lt;= B \u0026amp;\u0026amp; A\u0026lt;= C : A;\nB \u0026lt;= A \u0026amp;\u0026amp; B \u0026lt;= C: B; C;\n];\n区 }练习题 4. 11 计算 三个 字中最 小值的 H C L 代码包 含了 4 个形如 X\u0026lt; = Y 的比 较表达 式。重写代码计算同样的结果,但只使用三个比较。\n练习题 4. 12 写 一个 电 路的 H CL 代码, 对于输入 宇 A、 B 和 C , 选择中间值。也就是, 输出等 于三个输入 中居 于最小值 和最 大值 之间的 那个 字。\n组合逻辑电路可以设计成在字级数据上执行许多不同类型的操作。具体的设计已经超\n出了我们讨论的范围。算术/逻辑单元 ( AL U )是一种很重要的组 合电路,图 4-15 是它的一个抽象 的图示。这个电路有三个 输入: 标号为 A 和 B 的两个数据输入, 以及一个控制输人。根据控制输入的设置,电路会对数据输入执行不同的算术或逻辑操作。可以看到,这个 ALU 中画的四个操作对应于 Y86-64 指令集支持的四 种不同的整数操作, 而控制值和这 些操作的功能码相对应(图4-3) 。我们还注意到减法的 操作数顺序, 是输入 B 减去输入 A。之所以这样做, 是为了使 这个顺序与 sub q 指令的参数顺序一致。\n图 4- 15 算术/逻辑单元 ( ALU) 。根据函数输入的设 置,该 电路会执行四种算术和逻辑运算中的一 种\n2. 4 集合关系\n在处理器设计中,很多时候都需要将一个信号与许多可能匹配的信号做比较,以此来 检测正在处理的某个指令代码是否属千某一类指令代码。下面来看一个简单的例子, 假设想从一 个两位信号 c o d e 中选择高位和低位来 为图 4-14 中的四路复用器产生信号 s 1 和 s 0 ,\n如下图所示:\nco de王丑:;\n}- Out4\n在这个电路中, 两位的信号.cod e 就可以用来控制 对 4 个数据字 A、 B、C 和 D 做选择。根据可能的 c od e 值, 可以用相等测试来表示信号 s l 和 s 0 的产生:\nbool s1 =code== 2 II code== 3;\nbool s0 = code == 1 11 code == 3;\n还有一 种更简洁的方式来表示这样的属性: 当 c o d e 在集合{ 2 , 3 }中时 s 1 为 1, 而\nc o d e 在集合{ 1, 3 } 中时 s 0 为 1 :\nbool s1 = code in { 2, 3 };\nbool s0 = code in { 1, 3 };\n判断集 合关系的通用 格式是:\niexpr in {ie 工 Pr1 , iex p r 2 , …,iex p r k }\n这里被测试的值 i ex p r 和待匹配的值 ie x p r 1 ~ ie x p r k 都是整数表达式。\n2. 5 存储器和时钟\n组合电路从本质上讲,不存储任何信息。相反,它们只是简单地响应输入信号,产生等 于输入的某个函数的输出。为了产生时序 电路 ( sequential c订cu it ) , 也就是有状态并且在这个状态上进行计算的系统,我们必须引入按位存储信息的设备。存储设备都是由同一个时钟控制 的,时钟是一个周期性信号,决定什么时候要把新值加载到设备中。考虑两类存储牉设备:\n时钟寄存 器(简称寄存 器)存储单个位或字。时钟信号控制寄存器加载输入值。 随机访问存储 器(简称内存)存储多 个字,用 地址来选择该读或该写哪个字。随机访问存储器的例子包括: 1 ) 处理器的虚 拟内存系统 , 硬件和操作 系统软件结合起来使处理器可以在一个很大的地址空间内访问任意的字; 2 ) 寄存器文件,在 此,寄存器标识符作为地址 。在 IA32 或 Y86-64 处理器中, 寄存器文件有 15 个程序寄存 器(%\nr a x ~ %r l 4) 。\n正如我们看 到的那样, 在说到硬 件和机器级编程时 ," 寄存器” 这个词是两个有细微差别的事情。在硬件中 , 寄存器直接将它的输入和输出线连接到电路的其他部分。在机器级 编程中 , 寄存器代表的是 CPU 中为数不多的可寻址的字, 这里的地址是 寄存器 ID。这些字通常都存在寄存器文件中,虽然我们会看到硬件有时可以直接将一个字从一个指令传 送到另一个指令,以避免先写寄存器文件再读出来的延迟。需要避免歧义时,我们会分别 称呼这两类寄存器为“硬件寄存器”和“程序寄存器”。\n图 4-1 6 更详细地说明 了一个硬件寄存器以及它是如何工作的。大多数时候, 寄存器都保待在稳定状态(用x 表示), 产生的输出等千它的 当前状态。信号沿着寄存器前面的组合逻辑传播, 这时, 产生了一个新的寄存器输入(用 y 表示), 但只要时钟是低电位的,寄存祥的输出就仍然保持不变。当时钟变成高电位的时候,输入信号就加载到寄存器中,成 为下一个状态 y , 直到下一个时钟上升沿, 这个状态就 一直是寄存器的新输出。关键是寄\n存器是作为电路不同部分中的组合逻辑之间的屏障。每当每个时钟到达上升沿时,值才会 从寄存器的 输入传送到输出。我们的 Y86-64 处理器会用时钟寄存器保存程序计数 器(PC)、条件代码 CCC) 和程序状态( S t at ) 。\n状态=X 状态=Y\n输入- 勹 一 二 输出 Y\n图 4-16 寄存器操作。寄存器输出会一直保 持在当前寄存 器状态上 , 直到时 钟信号上升。当时钟上升时,寄存器输人上的值会成为新的寄存器状态\n下面的图展示了一个典型的寄存器文件:\n读端口\nsr cAI A\nvalB\n言 B\n寄存器文件 w\nvalW\nd s t W 写端口\n时钟\n寄存器文件有两个 读端口 CA 和 B)\u0026rsquo; 还有一个写 端口 CW) 。这样一个多端口随 机访问存储器允 许同时进行多个读和写操作。图中所示的寄存器文件中, 电路可以读两个程序寄存器的值,同时更新第三个寄存祥的状态。每个端口都有一个地址输入,表明该选择哪个 程序寄存器, 另外还有一个数据输出或对应该程序寄存器的输入值。地址是用图 4-4 中编码表示 的寄存器标识符。两个读端口有地址 输入 sr c A 和 sr c B( \u0026quot; so urce A\u0026quot; 和 \u0026quot; so ur ce B\u0026quot; 的缩写)和数据输出 v a l A 和 v a l B( \u0026quot; va lue A\u0026quot; 和 \u0026quot; va lue B\u0026quot; 的缩写)。写端口有 地址输入dstw(\u0026ldquo;destination W\u0026rdquo; 的缩写), 以及数据输入 v a l W( \u0026quot; val ue W\u0026quot; 的缩写)。\n虽然寄存器文件不是组合电路,因为它有内部存储。不过,在我们的实现中,从寄存 器文件读数 据就好像它是一个以地址为输入、数据为输出的一个组合逻辑 块。当 s r c A 或s r c B 被设成某个寄存器 ID 时,在 一段延迟之后 , 存储在相应程序寄存器的值就会出现在va l A 或 v a l B 上。例如 , 将 sr c A 设为 3\u0026rsquo; 就会读出程序寄存器%r b x 的值, 然后这个值就会出现在输出 va l A 上。\n向寄存器文件写入字是由时钟信号控制的,控 制方式类似于将值加载到时钟寄存 器。每次时钟上升时 , 输入 va l W 上的值会被写入输入 ds t W 上的寄存器 ID 指示的程序寄存器。当ds t W 设为特殊的 ID 值 Ox F 时 , 不会写任何程序寄存器。由于寄存 器文件既可以读也 可以写, 一个很自然的间题就是“如果我们试图同时读和写同一个寄存器会发生什么?"答案简单明了: 如果更新一个寄存器,同时在读端口上用同一个寄存器 ID, 我们会看到一个从旧值到新值的变化。当我们把这个寄存器文件加入到处理器设计中,我们保证会考虑到这个属性的。\n处理器有一个随机访问存储器来存储程序数 据, 如下图所示:\n数据输出\n时钟\n地址数据输入\n这个内存有一个地址输入,一个写的数据输入,以及一个读的数据输出。同寄存器文件 一样, 从内存中读的操作方式类似于组合逻辑 : 如果我们在输入 a ddr e s s 上提供一个地址,\n并将 wr i t e 控制信号设置为 o, 那么在经过一些延迟之后,存储在那个地址上的值会出现在\n输出 d a 七a 上。如果地址超出了范围 , e rr or 信号 会设置为 1 , 否则就设置为 0。写内存是由时钟控制的: 我们将 a dd r e s s 设置为期望的地址 , 将 da t a i n 设置为期望的值 , 而 wr i t e 设置为 1。然后当我们控制时钟时 ,只 要地址是合法的, 就会更新内存中指定的位置。对于读操作来说, 如果地址是不合法的 , e rr or 信 号会被设置为 1。这个信号是由 组合逻辑产生的 , 因为所需要的边界检查纯粹就是地址输入的函数,不涉及保存任何状态。\n囚 日 现实的存储器设计\n真实微处理器中的存储器系统比我们在设计中假想的这个简单的存储器要复杂得 多。它是由几种形式的硬件存储器组成的,包括几种随机访问存储器和磁盘,以及管理 这些设备的各种硬件和软件机 制。存储器 系统的设计和特点 在第 6 章中描 述。\n不过,我们简单的存储器设计可以用于较小的系统,它提供了更复杂系统的处理器和存储器之间接口的抽象。\n我们的处理器还包括另外一个只读存储器,用来读指令。在大多数实际系统中,这两个存储器被合并为一个具有双端口的存储器: 一个用来读指令,另 一个用来读或者写数据。\n4. 3 Y86-64 的 顺 序实现\n现在已经 有了实现 Y86- 64 处理器所需要的部件。首先, 我们描述一个称为 SE Q ( \u0026quot; se­ q ue n tia l\u0026quot; 顺序的)的处理器。每个时钟周期 上, S E Q 执行处理一条完整指令所需的所有步骤。不过,这需要一个很长的时钟周期时间,因此时钟周期频率会低到不可接受。我们开 发 SEQ 的目标就是提供实现 最终目的的第一步, 我们的最终目的是实现一个高效的、流水线化的处理器。\n3. 1 将处理组织成阶段\n通常,处理一条指令包括很多操作。将它们组织成某个特殊的阶段序列,即使指令的 动作差异很大, 但所有的指令都遵循统一 的序列。每一步的具体处理取决于正在执行的指令。创建这样一个框架, 我们就能 够设计一个充分利用硬件的处理器。下面是关 千各个阶段以及各阶段内执行操作的简略描述:\n取指( fe tch ) : 取指阶段从内存读取指令字节, 地址为程序计数器( PC) 的值。从指令中抽取出指令指示符字节的两个四位部分, 称为 乓o d e ( 指令代码)和辽u n ( 指令功能)。它可能取出一个寄存器指示符字节,指明一个或两个寄存器操作数指示符 r A 和 r B。它还可能取出一 个四字节常数字 v a l e 。它按顺序方式计算当前指令的下一条指令的地址 va l P。也就是说, v a l P 等于 PC 的值加上巳取出指令的长 度。\n译码( d ecode ) : 译码阶段从寄存器文件读入最多两个 操作数, 得到值 va l A 和/或 va lB,\n通常,它 读入指令r A 和 r B 字段指明的寄存器, 不过有些指令是读寄存器r% s p 的。\n执行( exec ute ) : 在执行阶段, 算术/逻辑单元( ALU ) 要么执行指令指明的操作(根据 江u n 的值),计算 内存引用的 有效地址 , 要么增加或减少栈指针。得到的值 我们称为 v a l E。在此 ,也 可能设置条件码。对一条条件传送指令来说, 这个阶段会检验条件码和传送条件(由辽u n 给出), 如果条件成立, 则更新目标寄存器。同样,\n对一条跳转指令来说,这个阶段会决定是不是应该选择分支。\n访存( m em o r y ) : 访存阶段可以将数据写入内存,或者从内存读出数据。读出的值\n为 va l M。\n写回 ( w rit e back) : 写回阶段最多可以写两个结果到寄存 器文件。\n更新 PC(PC update): 将 PC 设置成下一条指令的地址。\n处理器无限循环, 执行这些阶段。在我们简化的实现中 , 发生任何异常时, 处理器就会停止 : 它执行 ha lt 指令或非法指令,或 它试图读或者写非法地址。在更完整的设 计中, 处理器 会进入异常处理模式, 开始执行由异常的类型决定的特殊代码。\n从前面的讲述可以看出, 执行一条指令是需要进行很多处理的。我们不仅必须执行指令所表明的操作,还必须计算地址、更新栈指针,以及确定下一条指令的地址。幸好每条 指令的整个流程 都比较相似。因为我们想使硬件数量尽可能少, 并且最终将把它映射到一个二维的集成电路芯片的表面,在设计硬件时,一个非常简单而一致的结构是非常重要 的。降低复杂 度的一种方法是让不同的指令共享尽 量多的硬件。例如, 我们的每个处 理器设计都只 含有一个算术/逻辑单元 , 根据所执行的指令类型的不同, 它的使用方式也不同。在硬件上复制逻辑块的成本比软件中有重复代码的成本大得多。而且在硬件系统中处理许 多特殊情况和特性要比用软件来处理困难得多。\n我们面临的一个挑战是将每条不同 指令所需要的计算放入到上述那个通用 框架中。我们会使用图 4-1 7 中所示的代码来描述不同 Y8 6-64 指令的处理。图 4-18 ~ 图 4- 21 中的表描述了不同 Y8 6-6 4 指令在各个阶段是怎样处理的。很值得仔细研究一下这些 表。表中的这种格式很容易映射到硬件。表中的每一行都描述了一个信号或存储状态的分配(用分配操 作- 来表示)。阅读时可以把它看成是从上至下的顺序求值。当我们将这些计算映射到硬件时,会发现其实并不需要严格按照顺序来执行这些求值。\n1 OxOOO: 30f20900000000000000 I irmovq $9, %rdx 2. OxOOa: 30f31500000000000000 I irmovq $21, %rbx 3 Ox014: 6123 I subq %rdx, %rbx # subtract 4 Ox016: 30f48000000000000000 I irmovq $128,%rsp # Problem 4.13 5 Ox020: 40436400000000000000 I rmmovq %rsp, 100(%rbx) # store 6 Ox02a: a02f I pushq %rdx # push 7 Ox02c: bOOf I popq %rax # Problem 4. 14 8 Ox02e: 734000000000000000 I je done # Not taken 9 1◊ Ox037: Ox040: 804100000000000000 I I call proc done: # Problem 4. 18 11 Ox040 : 00 I halt 12 Ox041: I proc: 13 Ox041: 90 I ret # Return 14 I 图4-17 Y86-64 指令序列示例。我们会跟踪这些 指令通过各个 阶段的处理\n图 4-18 给出了对 OPq ( 整数和逻辑运算)、r r mo v q ( 寄存器-寄存器传送)和ir mo v q ( 立即数-寄存器传送)类型的指令所需的处理。让我们先来考虑一下整数操作。回顾图 4- 2 , 可以看到我们小心 地选择了指 令编码, 这样四个整数操作 ( a d d q、s ub q、a nd q 和 x or q ) 都有相同的 i c o d e 值。我们可以 以相同的步骤顺序来处理它们,除 了 ALU 计算必须根据if un 中编码的具体的指 令操作来设定。\n图 4-18 Y86-64 指令 OPq 、r r mo vq 和 ir mov q 在顺序实现中的计算 。这些指令计算了一个值 , 并将结果存放在寄存器中。符号 i c ode : i f u n 表明指令字节的两个组成部分 , 而r A:r B 表明寄存器指示符字节的两个组成部分。符号 M1 [x ] 表示访问(读或者写)内存位置x 处的一个字节 ,而 凶 [ x ] 表示访间八个字节\n整 数操 作 指令 的处 理遵 循上面列出的通用模式。在取指阶段, 我们不需要常数字, 所以 v a l P 就计 算为 P C + 2 。在译码阶段, 我们要读两个操作数。在执行阶段, 它 们 和功能指 示符 江 u n 一起 再 提供 给 ALU , 这样一来 v a l E 就成为了指令结果。这个计算是用表达式 v a l B OP v a l A 来表达的 ,这 里 O P 代表 辽u n 指定的操作。要注意两个参数的顺序一这个顺序与 Y 8 6- 6 4 ( 和 x 8 6- 6 4 ) 的习惯是一致的。例如,指 令 s u b q %r a x , %r d x 计 算的是 R [ %r d x ] - R [ %r a x ) 的值。这些指令在访存阶段什么也不做, 而 在 写 回 阶 段 , v a l E 被写入寄 存 器r B , 然后 PC 设为 v a l P , 整个指令的执行就结束了。\n田日跟踪 s ub q 指 令的 执行\n作为一个例 子, 让我们来看看一条 s u b q 指令的处理过程, 这条指令是图 4- 1 7 所示目标代码的第 3 行 中的 s u b q 指令。可以看到前 面 两 条 指令分别将 寄存器%r d x 和%r b x 初 始化成 9 和 21 。我们还能看到 指 令位 于地 址 Ox 0 1 4 , 由两个宇节组成,值分别为Ox 6 1 和 Ox 2 3 。 这 条 指令处理的各个阶段如下表所示, 左边列 出 了 处理一个 OP q 指令的通用的 规则(图4- 1 8 ) , 而右边列出的是对这条具体指令的计算。\n阶段 OPq rA, rB s ubq r 毛 dx, % r b x 取指 icode : ifun ._ M,[ PC] icode : ifun +- M1[ Ox014] = 6: 1 rA:rB +- M1[ PC + l ] rA:rB +- M1[ 0x015] = 2: 3 valP 令 - PC+ 2 valP .- Ox014+2= Ox01 6 译码 valA \u0026hellip;\u0026hellip; R[ rA] valA - R[ r 毛 dx] = 9 valB +- R[ rB] valB .- R[ r% bx] = 21 执行 valE +- valB OP valA Set CC valE +- 21- 9= 1 2 ZF 仁 - 0, SF 七 0, OF+- 0 访存 写回 R[ rB] +- valE R[ 韦 r bx] - valE= 12 更新 PC PC+- valP PC +- valP= Ox016 这个跟踪表明我们达到 了理 想的效果, 寄存器%r bx 设成了 12 , 三个条件码都设成\n了 0 , 而 PC 加 了 2。\n执行 r r mo v q 指令和执行算术运算类似。不 过, 不需要取第二个寄存器操作数。我们将 ALU 的第二个输入设为 o, 先把它和第一个操作数相加, 得到 v a l E= valA, 然后再把\n这个值写到寄 存器文件。对 ir mo v q 的处理与此类似, 除了 ALU 的第一个输入为常数值va l C。另外, 因为是长指令 格式, 对于 i r mo v q , 程序计 数器必须加 1 0 。所有这些指令都不改变条件码。\n练习题 4. 13 填写下表的 右边 一栏 ,这 个表描述 的是 图 4-1 7 中目标 代码 第 4 行上的\nir mo v q 指令的处 理情况 :\n这条指令的 执行会 怎样 改 变寄存器 和 PC 呢?\n图 4-1 9 给出了内存 读写指令r mrno v q 和 mr mo v q 所需要的处理。基本流程也和前面的一样, 不过是用 ALU 来加 v a l C 和 v a l B , 得到内存操作的有效地址(偏移量与基址寄存器值之和)。在访存阶段 , 会将寄存器值 v a l A 写到内存 , 或者从内存中读出 v a l M。\n阶段 取指 r mmo vq rA, D(rB) icode: ifun +- M1[ PC] rA:rB- M1[ PC+ l ] mrmovq D\u0026lt;rB), rA icode: ifun +- M1[PC] rA,rB- M1[ PC+ l ] valC- Ms[ PC+ 2] valC +- Ms[ PC+ 2] valP.- PC+ l O valP- PC+ l O 译码 valA +- R[rA] 执行 valB +- R[rB] valE 仁 - valB+valC va/8 +\u0026mdash; R[ rB] valE ~ valB+valC 访存 Ms[ valE]- valA valE - Ms[ valE] 写回 R[rA]+- valM 更新 PC PC 仁 valP PC七- valP 图 4-19 Y86-64 指令r mmovq 和 mr movq 在顺序实现中的计算 。这些指令读或者写内存\nm 跟踪 rm mo v q 指令的执行\n让我们 来看看图 4- 1 7 中目标代 码的第 5 行r mmo v q 指令的处理情况。 可以 看到 ,前面的指 令已将寄存 器%r s p 初始化成 了 1 28 , 而%r bx 仍然是 s ub q 指令(第3 行)算 出来的\n结果 1 2 。我们还 可以 看到 ,指 令位于地 址 Ox 0 2 0 , 有 1 0 个宇节 。前两个的 值为 Ox 4 0 和\nOx43, 后 8 个是数字 Ox 0 0 0 0 0 0 0 0 0 0 0 0 0 0 6 4 ( 十进制数 1 0 0 ) 按字 节反过来得 到的数。各个阶段的处理如下:\n阶段 通用 具体 rmrnovq rA, D( rB) r mmov q %r s p, 1 00{ 毛r bx ) 取指 icode: ifun - M1[ PC] rA,rB- M1 [ PC+ l ] valP - Ms[ PC+ 2] valP 噜 - PC+ l O ico de: ifun +\u0026ndash; M1 [ Ox020] = 4: O rA, rB - M1 [ 0xo21] = 4: 3 valC.- Ma[ Ox022] = 100 valP +\u0026ndash; Ox020+10 = Ox02a 译码 valA +- R[ rA] valB - R[ rB] valA +- R[ 号r s p ] = 128 valB - R[ 毛 r bx ] = 12 执行 valE +- valB+valC valE +- 1 2+ 100= 112 访存 M式valE] - valA Ms[ 112].- 128 写回 更新 PC PC 嘈- valP PC 令- Ox02a 跟踪记 录表明 这条指令的 效果就是将 1 2 8 写入 内存 地址 11 2 , 并将 PC 加 1 0 。\n图 4 - 2 0 给出了处理 p u s h q 和 p o p q 指令所需的步骤。它 们可以算是最难实现的 Y 8 6- 6 4 指令了, 因为它们既 涉及访问内存, 又要 增加或减少栈指针。虽然这两条指令的流程比较相似,但是它们还是有很重要的区别。\n阶段 pushq rA popq rA 取指 ic ode: ifun - M1 [ PC] icode: ifun +- M1[PC] rA: rB - M, [ PC+ l ] rA, rB - M1 [ PC+ l ] valP- PC+ 2 valP 仁 - PC+ 2 译码 valA- R[ rA] valB - R[ r% s p] valA - valB ._ R[ %r s p] R[ r令 s p] 执行 valE 仁 - valB+ ( - 8) valE .- va1B+ 8 访存 Ms[ valE] - valA valE - M正valA] 写回 R[ %r s p] 亡 valE R[ 毛 r s p] - valE R[ rA] - valM 更新 PC PC+- valP PC 仁 - valP 图 4- 20 Y8 6-64 指令 pus hq 和 po pq 在顺序实现中的计算。这些指令将值压入或弹出栈\np u s h q 指令开始时很像我们 前面讲 过的指令, 但是在译码阶段,用 %r s p 作为第 二个寄存器操作数的标识符, 将栈指针赋值为 v a l B。在执行阶段,用 ALU 将栈指针减 8 。减过 8 的值就是内存写的地址, 在写回阶段还会存回到%r s p 中。将 v a l E 作为写操作的地址 , 是遵循 Y 8 6- 6 4 ( 和 x 8 6- 6 4 ) 的惯例,也 就是在写之前, p u s h q 应该先将栈指针减去 8 , 即使栈指针的更新实际上是在内存操作完成之后才进行的。\n日 日 跟踪 p us hq 指令的执行\n让我们 来看 看图 4- 1 7 中 目 标代码的 笫 6 行 p u s h q 指令的 处理情 况。 此时, 寄存器 %r d x 的值为 9\u0026rsquo; 而寄 存器%r s p 的值为 1 2 8 。 我们 还可以 看到 指令是位于地 址 Ox 0 2 a ,有两个 宇节,值 分别为 Ox a O 和 Ox 2 f 。 各个阶段 的处理如 下:\n阶段 通用 具体 pushq rA pushq r% dx 取指 icode: ifu n +- M, [ PC] rA:rB- M1[ PC + l ] valP - PC+ 2 icode: ifun ~ M1 [ Ox02a] =a: 0 rA: rB +- M1[ 0x02b] = 2: f valP 仁 - Ox02a + 2 = Ox02c 译码 valA +- R[ rA] valB - R[ r毛 s p] valA .._ R[ r% dx] = 9 valB - R[ %rs p] = 128 执行 valE 七 - valB+ ( - 8) valE 仁 12a + \u0026lt;- 8) = 1 20 访存 Ms[ valE] - valA Ms[ 120]+- 9 写回 R[ %r s p] - valE R[ %rsp]- 120 更新 PC PC 仁 - valP PC+- Ox02c 跟踪记录表明 这条指令的效果就是将%r s p 设 为 120 , 将 9 写入 地 址 120 , 并将 PC\n加 2。\npop q 指令 的执行 与 pu s hq 的执行类似,除 了 在 译 码阶段要读两次栈指针以外。这样做看上去 很多余 ,但 是 我们会看到让 v a l A 和 v a l B 都存放栈指针的值,会 使 后 面的流程跟其他的 指令更相似,增 强 设 计 的 整体一致性。在执行阶段, 用 ALU 给栈指针加 8 , 但是用没加 过 8 的原 始值作为内存操作的地址。在写回阶段, 要用加过 8 的栈指针更新栈指 针寄存器 , 还要将寄存器 rA 更新为从内存中读出的值。用没加过 8 的值作为内存读地址, 保持了 Y86-64 ( 和 x86-64) 的惯例, popq 应该首先读内存,然 后 再 增 加 栈 指 针 。\n练习题 4. 14 填写 下表的右边一栏 , 这个表描述的是图 4-17 中目标代码 第 7 行 pop q\n指令 的处理情况:\n这条指令的执 行会怎样改 变寄 存器和 PC 呢?\n练习题 4. 15 根据图 4-20 中列 出的步骤, 指令 pus hq %r s p 会有什么样 的效果? 这与练 习题 4. 7 中确定 的 Y86-64 期望的行为 一致 吗?\n练习题 4. 16 假设 po pq 在写 回阶段中的两个 寄存器写 操作按照 图 4-20 列 出的 顺序进行。po pq %r s p 执行的效果会是怎 样的? 这 与 练 习题 4. 8 中 确定的 Y86-64 期 望的行为一致吗?\n图 4- 2 1 表明了三类控制转移指令的处理: 各种跳转、c a l l 和r e t 。可以看到, 我们能用同前面指令一样的整体流程来实 现这些指令。\n阶段 jXX Dest call Dest ret 取指 icode, ifun - M1[ PC ] ico de , ifun - M1[PC] icode: ifun +- M1 [ PC] vale.- Ms[PC+1] valP +- PC+9 valC - M8[ PC+ 1] valP 仁 - PC+ 9 valP- PC+ l 译码 valA - R[ r% s p ] valB - R[ 毛 r s p] valB +- R[ r 毛 s p] 执行 valE- valB + ( - 8) valE 仁 va1B+ 8 Cnd - Cond(CC, ifun) 访存 Ms[ valE]- valP valM - Ms[ valA] 写回 R[ 号r s p]- valE R[ 号r s p] +- valE 更新 PC PC 仁 - Cnd?valC , valP PC 牛 - vale PC +- valM 图 4- 21 Y86-64 指令 j XX、 c a ll 和r e t 在顺序实现中的计莽。这些指令导致控制转移\n同对整数操作一样,我们能够以一种统一的方式处理所有的跳转指令,因为它们的不同只在千判断是否要选择分支的时候。除了不需要一个寄存器指示符字节以外,跳转指令在取指和译码阶段都和前面讲的其他指令类似。在执行阶段,检查条件码和跳转条件来确定是否要选择分支,产生出一个一位信号 Cnd 。在更新 PC 阶段, 检查这个标志, 如果这个标志为\n1, 就将PC 设为 v a l e ( 跳转目标), 如果为 o, 就设为 v a l P( 下一条指令的地址)。我们的表示法\nx?a: b类似千C 语句中的条件表达式一 当 x 非零时, 它等于a , 当 x 为零时, 等于b。\n田 日 跟踪 je 指令的执行\n让我们来看看图 4- 1 7 中目标 代码的笫 8 行 j e 指令的 处理情况。 s u b q 指令(第3 行) 已经将 所有的 条件码都 置为了 o, 所以 不会选择 分支。该指令位于地 址 Ox 0 2 e , 有 9 个\n宇节。 第一 个字节的值 为 Ox 7 3 , 而剩下的 8 个宇 节是数宇 Ox 0 0 0 0 0 0 0 0 0 0 0 0 0 0 4 0 按宇节反过来得到的数 ,也 就是跳 转的目标 。各个阶段的 处理如 下:\n就像这个跟踪记 录表明 的那样 ,这条指 令的效果就是将 PC 加 9。\n心 练习题 4 . 17 从指令 编码(图4- 2 和图 4- 3 ) 我们 可以 看出,r mmo v q 指令是 一类更通用的、包 括条 件转移在内 的指 令的 无条 件版本。 请 给 出你 要如何 修 改下 面r r mo v q 指令\n的步骤 , 使之也 能处 理 6 个条件 传送 指令。 看看 j XX 指令的 实现(图 4-21 ) 是如何 处理条件行为的,可能会有所帮助。\n指令 c a l l 和r e t 与指令 p u s hq 和 po pq 类似, 除了我们要将程序计数器的值入栈和出栈以外。对指令 c a l l , 我们要将 va l P, 也就是 c a l l 指令后紧跟着 的那条指令的地址, 压人栈中。在 更新 PC 阶段, 将 PC 设为 v a l C, 也就是调用的目的地。对指令r e t , 在更 新 PC 阶段,我 们将 va l M, 即从栈中取出的值, 赋值给 PC。\n练习题 4. 18 填写下表 的右 边一栏,这 个表描 述的是 图 4-17 中目标 代码 第 9 行 c a l l\n指令的处理情况:\n这条指令的执行 会怎样改 变寄 存器、 PC 和内存呢?\n我们创建了一 个统一的框架, 能处理所有不同类型的 Y86-64 指令。虽然指令的行为大不相 同, 但是我们可以 将指令的处理组织成 6 个阶段。现在我们的任务是创建硬件设计来实现这些阶段,并把它们连接起来。\nm 跟踪 ret 指令的执行\n让我们来看看图 4-17 中目 标代码的 第 1 3 行r e t 指令的处理情况。指令的地址是\nOx041, 只有一个宇 节的 编码 , Ox 90 。 前面的 c a l l 指令将% r s p 置为 了 1 20 , 并将返回地址 Ox 040 存放在了内 存地址 1 20 中。各个阶段 的处理如 下:\n通用 具体 阶段 r e t r e t 取指 ico de : ifun - M1[ PC] ico de , ifun - M1[ 0x041] = 9: 0 valP 仁 - PC + l va lP 仁 - Ox041 + 1 = Ox042 译码 valA - R[ r% s p] valB +- R[ r% s p] valA +- R[ r% s p] = 120 valB - R[ r% s p] = 120 执行 val E 仁 val8 + 8 valE +- 120+ 8 = 128 访存 valM +- Ma [ valA] valM +- Ms[ 120]= Ox040 写回 R[ 毛r s p ] +- valE R[ %r s p ] - 1 28 更新 PC PC- valM PC 仁 Ox040 跟踪记 录表明 这条指令的 效果就是将 P C 设 为 Ox 040 , ha l t 指令的 地址。同时 也将\n%r sp 置 为 了 1 28。\n4. 3. 2 SEQ 硬件结构\n实现所有 Y86-64 指令所需要的计算可以被组织成 6 个基本阶段: 取指、译码、执行、访存、写回和更新PC。图 4-22 给出了一个能执行这些计算的硬件结构的抽象表示。程序计 数器放在寄存器中,在图中左下角(标明为 \u0026quot; PC\u0026quot;)。然后, 信息沿着线流动(多条线组合在一起就用宽一点的灰线来表示),先向上,再 向右。同各个阶段相关的硬件单元 ( ha r dw a re uni ts) 负责执行这些处理。在右边, 反馈线路向下,包括要写到寄存器文件的更新值,以及 更新的程序计数器值。正如在 4. 3. 3 节中讨论 的那样,在 SEQ 中, 所有硬件单元的处理都 在一个时钟周期内完成。这张图省略了一些小 的组合逻辑块,还省略了所有用来操作各个硬 件单元以及将相应的值路由到这些单元的控制 逻辑。稍后会补充这些细节。我们从下往上画\n处理器和流程的方法似乎有点奇。怪在开始设计流水线化的处理器时,我们会解释这么画的原因。\n硬件单元与各个处理阶段相关联:\n取指: 将程序计数器寄存器作为地址,指令内存读取指令的字节。PC 增加器(PC incre ­\n程序计数器\n( PC )\n写回\n访存\n执行\n取指\n新PC\nm enter ) 计算v a l P , 即增加了的程序计数器。译码: 寄存器文件有两个读端口 A 和 B,\n从这两个端口同时读寄存器值v a l A 和 v a l B。\n图 4-22 SEQ 的抽象视图,一种 顺序 实现。指令执行\n过程 中的 信息 处理 沿着顺时针方向的流程进行 ,从用程序计数器 ( PC ) 取指令 开始,如图中左下角所示\n执行:执 行 阶 段会根据指令的类型,将 算 术/逻辑单元( ALU ) 用于不同的目的。对整\n数操作 ,它要 执 行 指 令 所 指 定 的 运算。对其他指令,它 会 作 为一个加法器来计算增加或减少栈指针 , 或者计算有效地址,或 者只是简单地加 o , 将一个输入传递到输出。\n条件码寄存器CC C) 有三个条件码位。AL U 负责计算条件码的新值。当执行条件传送指令时 , 根据条件码和传送条件来计算决定是否更新目标寄存器。同样, 当执行一条跳转指令时,会 根据条件码和跳转类型来计算分支信号 Cnd 。\n访存:在 执 行 访 存 操作时,数 据 内 存读出或写入一个内存字。指令和数据内存访问的是相同的 内存位置,但 是用于不同的目的。\n写回 :寄 存器文件有两个写端口。端口 E 用来写 ALU 计算出来的值,而 端 口 M 用来写从数据内存中读出的值。\nPC 更新: 程序计数器的新值选择自: valP, 下一条指令的地址; vale, 调用指令或\n跳转指令指定的目标地址; valM, 从内存读取的返回地址。\n图 4-23 更详细地给出了实现 S EQ 所需要的硬件(分析每个阶段时, 我们会看到完整的\n新PC\n程序计数器\n(PC)更新\n访存\n执行\n译码\ni ns rt _va li\ni me m_ er r or\n取指\n图 4-23 SEQ 的 硬件结 构 , 一 种 顺 序实现。有些控制信号以及寄存器和控制字连接没有画出来\n细节)。我们看到一组和前面一样的硬件单元,但 是现在线路看得更清楚 了。这幅图以及其他的硬件图都使用的是下面的画图惯例。\n白 色方 框表示时钟 寄存器。程 序计数器 PC 是 SEQ 中唯一的时钟寄存器。\n浅蓝 色方框 表示硬 件单元。这 包括内存、ALU 等等。在我们所有的处理器实现中, 都会使用这一组基本的单元。我们把这些单元当作“黑盒子“,不关心它们的细节 设计。\n控制逻辑块用灰色圆角矩形表示。这些块用来从一组信号源中进行选择,或者用来 计算一些布尔函数。我们会非常详细地分 析这些块, 包括给出 HCL 描述。\n线路的 名字在白 色圆 圈中 说明。它们只是 线路的标识, 而不是什么硬件单元。\n宽度 为字长的 数据连接用 中等粗度的线表 示。 每条这样的线实际上都代表一簇 64\n根线, 并列地连在一起 , 将一个字从硬件的一个 部分传送到另一部分。\n宽度为字节或更窄的数据连接用细线表示。根据线上要携带的值的类型,每条这样的线实际上都代表一簇 4 根或 8 根线。\n单个位的连接用虚线来表示。这代表芯片上单元与块之间传递的控制值。\n图 4-18 图 4-21 中所有的计算都有这样的性质, 每一行都代表某个值的计算(如valP), 或者激活某个硬 件单元(如内存)。图4- 24 的第二栏列出了这些计算和动作.。除了我们已经讲过的那些信号以外, 还列出了四个寄存器 ID 信号: srcA, valA 的源; srcB,\nvalB 的源; dstE, 写入 v a lE 的寄存器; 以及 d s t M , 写入 v a l M 的寄存器。\n阶段 取指 计算 icode, ifun OPq rA, rB ico de: ifun - M1[ PC] mr mov q D( rB) , rA icode: ifun +- M1 [ PC] rA , rB rA, rB - M1[ PC+ l ] rA: rB - M, [ PC+ l] va lC . vale - Ma[ PC+ 2] valP valP+- PC+ Z valP 仁 - PC + l o 译码 va lA , srcA va/A - R[ rA] valB, srcB valB - R[ rB] valB ~ R[ rB] 执行 valE valE 仁 - valB OP valA va lE 仁 valB + valC Cond. codes Set CC 访存 Read/ write valM +- M式valE] 写回 E port, dstE R[ rB] - valE M port, dstM R[ rA] - valM 更 新 PC PC PC 仁 - valP PC 仁 - valP 图 4- 24 标识顺序实现中的不同计算步骤 。第二栏标识出 SE Q 阶段中正在被计算的值 , 或正在被执行的操作 。以指令 OPq 和 mr mo v q 的计算作为示例\n图 中, 右边两栏给出的是指令 OP q 和 mr mo v q 的计算, 来说明要计算的值。要将这些计算映射到硬件上,我们要实现控制逻辑,它能在不同硬件单元之间传送数据,以及操作 这些单元,使得对每个不同的指令执行指定的运算。这就是控制逻辑块的目标,控制逻辑 块在图 4-23 中用灰色圆角方框表示。我们的任务就是依次经过每个 阶段, 创建这些块的详细设计。\n4. 3. 3 SEQ 的时序\n在介绍图 4-18 图 4-21 的表时, 我们说过要 把它们看成是用程序符号写的, 那些赋值是从上到下顺 序执行的。然而, 图 4-23 中硬件结构的操作运行根本完全不同 , 一个时\n钟变化会引发一个经过组合逻辑的流,来执行整个指令。让我们来看看这些硬件怎样实现 表中列 出的这一行为。\nSEQ 的实现包括组合逻辑 和两种存储器设备: 时钟寄存器(程序计 数器和条件码寄存器),随机访问存储器(寄存器文件、指令内存和数据内存)。组合逻辑不需要任何时序或控制 只要输入变化了 , 值就通过 逻辑门网络传播 。正如提到过的 那样, 我们也将读随机访问存储器看 成和组合逻辑一样的操作, 根据地址输入产生 输出字。对于较小 的存储器来说(例如寄存器文件),这是一个合理的假设,而对于较大的电路来说,可以用特殊的时钟电路来模拟这个效果。由于指令内存只用来读指令,因此我们可以将这个单元看成是组 合逻辑 。\n现在还剩四个硬 件单元需要对它们的时序进行明 确的控制—— 程序计数器、条件码寄存器、数据内存和寄存器文件。这些单元通过一个时钟信号来控制,它触发将新值装载到 寄存器以及将值写到随机访问存储器。每个时钟周期,程序计数器都会装载新的指令地 址。只有 在执行 整数运算 指令时, 才会 装载条件码寄存楛。只有在执行r mrno v q 、 p u s h q 或 c a l l 指令时 , 才会写数 据内存。寄存器文件的两个写端口允许每个时钟周期更新两个程序寄存器, 不过我们可以 用特殊的寄存器 ID Ox F 作为端口地址, 来表明 在此端口不应该执行写操作。\n要控制处理器中活动的时序,只需要寄存器和内存的时钟控制 。硬件获得了如图 4-18 ~ 图4-21 的表中所示 的那些赋值顺序执行一样的效果, 即使所有的 状态更新实际上同时发生, 且只在 时钟上升开始下一 个周期时。之所以能保持这样的等价性, 是由千 Y86-64 指令集的本质,因为我们遵循以下原则组织计算:\n原则:从不回读\n处理器从未不需要为了完成一条指令的执行而去读由该指令更新了的状态。\n这条原则 对实现的成功来说至关重要。为了说明问 题, 假设我们对 p u s h q 指令的实现是先将 %r s p 减 8 , 再将更新后的%r s p 值作为写操作的地址。这种方法同前面所说的那个原则相违背 。为了执行内 存操作, 它需要先从寄存器文件中读 更新过的栈指针。然而, 我们的实现(图 4- 20 ) 产生出减后的栈指针值, 作为信号 v a l E , 然后再用这个信号既作为寄存器写的数据,也作为内存写的地址。因此,在时钟上升开始下一个周期时,处理器就可 以同时执行寄存器写和内存写了。\n再举个例子来说明这条原则,我们可以看到有些指令(整数运算)会设置条件码,有些指令(跳转指令)会读取条件码,但没有指令必须既设置又读取条件码。虽然要到时钟上升 开始下一个周期时 , 才会设置条件码, 但是在任何指令试图读之前 ,它 们都会更新。\n以下是汇编代码,左 边列出的是指令地址, 图 4- 25 给出了 SEQ 硬件如何处理其中第\n3 和第 4 行指令:\nOxOOO: irmovq $0x100,%rbx # %rbx \u0026lt;\u0026ndash; Ox100 OxOOa: irmovq $0x200,%rdx # %rdx \u0026lt;\u0026ndash; Ox200 Ox014 : Ox016: addq %rdx,%rbx je dest # ir 儿 bx \u0026lt;\u0026ndash; # Not taken Ox300 CC\u0026lt;\u0026ndash; 000 Ox01f: rmmovq %rbx, 0 (r儿Ox029: dest: halt\ndx )\n# M[Ox200] \u0026lt;\u0026ndash; Ox300\n标号为 1 ~ 4 的各个图给出了 4 个状态单元, 还有组合逻辑 , 以及状态单元之间的连接。组合逻辑被条 件码寄存器环绕着, 因为有的 组合逻辑(例如 ALU ) 产生输入到条件码寄存器, 而其他部分(例如分支计算和 PC 选择逻辑)又将条件码寄存器作为输入。图中寄\n存器文件和数据内存有独立的读连接和写连接,因 为读操作沿着这些单元传播,就 好 像它们 是组合逻辑, 而写操作是由时钟控制的。\n时钟\n周期1 周期2: 周期3\nOxO OO : irmovq $s x100 , 毛 r bx # % r b x \u0026lt;- - Ox l OO OxOOa: irmovq $0x200, 号 r bx # 沦r dx \u0026lt;- - Ox200\nOx0 1 4 addq %r d x , 马r b x # 号r b x \u0026lt;- - Ox 300 CC \u0026lt;- - 000\n周期4:• .;·,,··,,O•~,,,. -x\u0026rsquo;·0 1,6.\n,飞e .. -仓-.-s- f\u0026ndash; 心丈-又二\u0026rsquo;, 令.\u0026ndash;,玉t,·,• 七, 己-,-.1,C· 鲁 .N- o t心. t -、ak -e文., ·,.,.心, .-,歹石心`、,,乓飞L ·节· 咚\u0026lt;兮.分- 五-、\u0026rsquo;: $\n周期5:\nOx Olf : r mmo v q % r bx , 0 (% r d x ) # M [ Ox20 0 ] \u0026lt;- - Ox 3 0 0\n心周期3开始时\n@周期4开始时\n@ 周期3结束时\nr毛 bx\n`Ox300\n图 4-25 跟踪 SEQ 的两个 执行周期。每个周期开始时, 状态 单元(程序计数器、条件 码寄存器、寄存 器文件以 及数据内 存)是根据前一条指令设置的。信号传播通过组合逻辑 , 创建出新的状态单元的 值。在下一个周期开始时 , 这些值会被加 载到状态单元中\n图 4-25 中的不同颜色的代码表明电路信号是如何与正在被执行的不同指令相联系的。我们假设处理是从设置条件码开始的,按 照 ZF 、S F 和 OF 的顺序, 设 为 100。在时钟周 期 3 开始的时候(点1) \u0026rsquo; 状态单元保持的是第二条 i r mov q 指 令( 表中第 2 行)更新过的状态,该 指 令 用 浅灰色表示。组合逻辑用白色表示, 表明它还没有来得及对变化了的状态做出 反应。时钟周期开始时,地 址 Ox 01 4 载入程序计数器中。这样就会取出和处理 a dd q 指 令(表中第 3 行)。值沿着组合逻辑流动, 包括读随机访问存储器。在这个周期末尾(点2) , 组合逻辑为条件码产生了新的值( 000) , 程序寄存器%r b x 的 更 新 值 ,以 及程序计数器的新\n值( Ox01 6) 。 在此时 , 组合逻辑已经根据 a d d q 指令被更新了,但 是状态还是保持着第二\n条 ir mo v q 指令(用浅灰色表示)设置的值。\n当时钟上升开始周 期 4 时(点3) , 会更新程序计数器、寄存骈文件和条件码寄存器, 因此我们用蓝 色来表示, 但是组合逻辑还 没有对这些变化做出反应, 所以用白色表示。在这个周期内, 会取出并 执行 j e 指令(表中第 4 行), 在图中用深灰色表示。因为条件码 ZF\n为 o, 所以不会选择分支。在这个周期末尾(点4 ) , 程序计数器巳经产生了新值 Ox Olf 。\n组合逻 辑已经根据 j e 指令(用深灰色表示)被更新过了, 但是直到下个周期开始之前, 状态还是 保持着 a d d q 指令(用蓝色表示)设置的值。\n如此例所示, 用时钟来控制状态单元的更新 , 以及值通过 组合逻辑 来传播, 足够控制我们 SEQ 实现中每条指令 执行的计算了。每次时钟由低变高时, 处理器开始执行一条新指令。\n3. 4 S EQ 阶段的实现\n本节会设 计实现 SEQ 所需要的控制逻辑块的 HCL 描述。完整的 SEQ 的 HCL 描述请参见网络 旁注 ARC H : HCL。在此, 我们给出一 些例子, 而其他的作为练习题。建议你做做这些练习来 检验你的理 解, 即这些块是如何 与不同指令的计算需求相联系的。\n我们没有讲的 那部分 SEQ 的 HCL 描述, 是不同整数 和布尔信号的定义, 它们可以作为 HCL 操作的参数。其中包括不同硬件信号的名字, 以及不同指令代码、功能码、寄存器名字、 ALU 操作和状态码的常数值。只列出了那些在控制逻辑中必须被显式引用的常数。图 4- 26 列出了我们使用的 常数。按照习惯, 常数值都是大写的。\n图 4- 26 HCL 描述中使用的常数值。这些值表示的是指令 、功能码、寄存器 ID、AL U 操作和状态码的编码\n除了图 4-18 图 4- 21 中所示的指令以外,还 包括了对 n a p 和 h a 吐 指令的处理。n a p\n指令只是简单地经过各个阶段,除 了 要 将 PC 加 1 , 不 进 行 任 何 处 理 。 ha lt 指 令 使 得 处 理器状态被设 置为 HLT , 导致处理器停止运行。\n取指阶段\n如图 4-27 所示 , 取 指 阶 段 包 括 指 令 内 存 硬 件 单 元 。 以 PC 作 为 第 一 个 字 节(字 节 0 ) 的地址 , 这 个 单 元一 次从 内 存 读 出 10 个 字 icode ifun rA rB valC valP\n节。第一个字节被解释成指令字节,(标\n号为 \u0026ldquo;Split\u0026rdquo; 的单 元)分为 两个 4 位 的数 。然 后 , 标 号 为 \u0026quot; icode \u0026quot; 和 \u0026quot; if un \u0026quot; 的控制逻辑块计算指令和功能码,或者 使之等千从内存读出的值, 或 者 当 指 令地 址 不 合 法 时( 由 信 号 i me m_ er r o r 指明 ), 使 这 些 值 对 应 于 n op 指 令 。 根 据i c od e 的 值 , 我 们 可以计算 三个一 位 的信号(用虚线表示):\ninstr_valid: 这个字节对应千一个合法的 Y86-64 指令吗? 这个信号 用来发现不合法的指令。\nneed_regids: 这个指令包括一个寄存器指示符字节吗?\nneed_valC: 这个指令包括一个常数字吗?\n图 4-27 SEQ 的取指阶段。以PC 作为起始地址,从指令内 存中读出 10 个字节。根据 这些字节, 我们产生出各个 指令字段。PC 增加模块计算信号valP\n(当指令地 址越界时 会产 生的)信号 i ns tr _v a l i d 和 i me m_ er r or 在 访 存 阶 段 被 用 来产 生 状 态 码 。\n让 我 们 再 来 看一 个 例子 , n e e d _r e g i d s 的 H CL 描述只是确定了 i c od e 的值是否为一\n条带有寄存器指示值字节的指令。\nbool need_regids =\nicode in { IRRMOVQ, IOPQ, IPUSHQ, IPOPQ, IIRMOVQ, IRMMOVQ, IMRMOVQ };\n练 习题 4. 19 写 出 SEQ 实现 中信 号 ne e d _v a l C 的 HCL 代码。\n如图 4-27 所示 , 从 指 令 内 存 中 读 出 的 剩 下 9 个 字 节 是 寄 存 器 指 示 符 字 节 和 常 数 字的 组 合 编 码 。 标 号 为 \u0026quot; A lig n\u0026quot; 的 硬 件 单 元 会 处 理 这些 字 节 , 将 它 们 放 入 寄 存 器 字 段 和常 数 字 中 。 当 被 计 算 出 的 信 号 ne e d _r e g i d s 为 1 时 , 字 节 l 被 分 开 装 入 寄 存 器 指 示符 r A 和 rB 中 。否 则 , 这 两 个 字 段 会 被 设 为 Ox F( RNONE) , 表 明 这 条 指 令 没 有 指 明 寄 存 器。回 想 一 下(图 4-2 ) , 任何只有一个寄存器操作数的指令,寄存器指示值字节的另一个字 段都设为 Ox F( RNONE) 。 因 此 , 可 以 将 信 号 r A 和 rB 看 成 , 要 么 放 着 我 们 想 要 访 问 的寄存 器 , 要 么 表 明 不 需 要 访 问 任 何 寄 存 器 。 这 个 标 号 为 \u0026quot; A lig n\u0026quot; 的 单 元 还 产 生 常 数字 v a l C。 根 据 信 号 ne e d _r e g i d s 的 值 , 要 么 根 据 字 节 1 ~ 8 来 产 生 v a l e , 要 么 根 据 字 节 2~ 9 来 产 生 。\nPC 增加器硬件单元根据当前的 PC 以 及 两 个 信 号 ne e d _r e g i d s 和 ne e d _ v a l C 的 值,\n产 生 信 号 v a l P。对 于 PC 值 p 、ne e d _r e g i d s 值r 以 及 ne e d _ v a l C 值 i\u0026rsquo; 增 加 器 产 生值\np + l + r + 8 i 。\n2 译码和写回 阶段\n图 4-28 给出了 SEQ 中实现译码和写回阶段的逻辑的详细情况。把这两个 阶段联系在一起是因为它们都要访问寄存器文件。\n寄存器文件有 四个端口。它支持同时进行两个读(在端口 A 和 B 上)和两个写(在端口\nE 和 M 上)。每个端口都有一个地址连接和一个数据 Cnd valA valB valM valE\n连接, 地址连接是一个寄存器 ID , 而数据连接是一组 64 根线路 ,既 可以作为寄存 器文件的输出字(对读端口来说),也可以作为它的输入字(对写端口来说)。两个读端口的 地址输入 为 s r c A 和 sr c B, 而两个写端口的地 址输入为 d s t E 和 d s 七M。 如果某个地址端口上的值为特 殊标识符 Ox F ( RNONE) , 则表明不需要访问寄存器。\n根据指令代码 i c o de 以及寄存器指示值r A 和 icode rB, 可能还会 根据执行阶段计算出的 Cn d 条件信号, 图 4- 28 图 4-28 底部的四个块 产生出四个不同的寄存器文件的\n寄存器 ID。寄存器 ID sr c A 表明应该读哪个寄存器以产生 va l A。所需 要的值依赖于指令类型, 如图 4-18 ~ 图 4-21 中译码阶段第一行中所示。将所有这些条目都整合到 一个计算中就得到下面的 sr c A 的 HCL 描述\n(回想 RRSP 是%r s p 的 寄存器 ID) :\nword srcA = [\nrA rB\nSEQ 的 译 码 和写 回 阶段 。指令字段译码,产生寄存器文件使用 的四个地址(两个读和两个写)的 寄存器标识符。从寄存器文件 中读出的 值 成 为 信 号 va l A 和va l B。 两 个 写 回 值 val E 和va l M 作 为写 操作的数据\nicode in { IRRMOVQ, IRMMOVQ, IOPQ, IPUSHQ } : rA; icode in { IPOPQ, IRET} : RRSP;\n1 : RNONE; # Don\u0026rsquo;t need register\n];\n区 综习题 4. 20 寄存器 信号 sr c B 表明 应该 读 哪个寄 存器以 产 生信 号 v a l B。所需 要的值如图 4-18 ~ 图 4-21 中译 码 阶段 第二 步所 示。 写 出 s r c B 的 HCL 代码。\n寄存器 ID d s t E 表明 写端口 E 的目的寄存器,计 算出来的值 v a l E 将放在那里。图 4-18~ 图 4-21写回阶段第一步表明了这一点。如果我们暂 时忽略条件移 动指令, 综合所有不同指令的 目的寄存器, 就得到下 面的 d s t E 的 HCL 描述:\n# WARNING: Conditional move not implemented correctly here\nword dstE = [\nicode in { IRRMOVQ} : rB; icode in { IIRMOVQ, IDPQ} : rB;\nicode in { IPUSHQ, IPOPQ, ICALL, IRET} : RRSP;\n1 : RNONE; # Don\u0026rsquo;t write any register\n];\n我们查看执行 阶段时, 会重新审视这个信号 , 看看如何实现条件传送。\n\u0026quot; 练习 题 4. 21 寄存器 ID d s t M 表 明 写 端 口 M 的 目 的寄存器, 从 内存 中读 出 来的值v a l M 将放 在那里 , 如图 4-18 图 4-21 中写 回 阶 段 第 二 步所 示。 写 出 d s t M 的 HCL 代码。\n区 练习题 4. 22 只有 p o p q 指令会同 时用 到寄 存器 文 件的 两个 写 端 口。 对于指令 p o p q\n%rsp, E 和 M 两个写 端口会用 到 同 一 个地 址, 但是写 入的数据不 同。 为 了 解决这个冲 突, 必须对两个写 端口设 立 一个 优先级, 这样一来, 当同 一个周期内两个写 端口都试图对一个寄存器进行写时,只有较高优先级端口上的写才会发生。那么要实现练习题 4. 8 中确定的行为 , 哪个端口该 具有较高 的优先级呢?\n3 执行阶段\n执行阶段包括算术/逻辑单元 ( ALU ) 。这个单元根据 a l u f u n 信号的设置,对 输 入 a l uA 和 a l u B 执行 ADD、SUBT RACT 、AND 或 EXCLUSIVE­\nOR 运算。如图 4-29 所示, 这些数据和控制信号是由三个 控制块产生的。ALU 的输出就是 v a l E 信号。\n在图 4-18 图 4- 21 中 , 执行阶段的第一步就是每条指令的 ALU 计算。列 出的操作数 a l u B 在\nCnd\n+\nicode ifun\nvalE\nvalC valA valB\n前面,后 面是 a l uA, 这样是为了保证 s u b q 指令 图 4-29\n是 v a l B 减去 v a l A。可以看到, 根据指令的类型, a l u A 的值可以是 v a l A、v a l e , 或者是—8 或 十8。因 此 我们可以用下面的方式来表达产生 a l u A 的控制块的行为:\nword aluA = [\nicode in { IRRMOVQ, IOPQ} : valA;\nS EQ 执行阶段。 ALU 要么为整数\n运算指令执行操作,要么作为加法器。根据 ALU 的值, 设置条件码寄存器。检测条件码的 值, 判断是否该选择分支\nicode in { IIRMOVQ, IRMMOVQ, IMRMOVQ} : valC; icode in { ICALL, IPUSHQ} : -8;\nicode in { IRET, IPOPQ} : 8;\n# Other instructions don\u0026rsquo;t need ALU\n];\n练习题 4. 23 根据图 4-18 图 4- 21 中执行阶段第 一 步的 第 一个 操作数, 写 出 SEQ 中\n信号 a l u B 的 HCL 描述。\n观察 ALU 在执行阶段执行的操作, 可以看到它通常作 为加法器来使用。不过, 对于\nOPq 指令 , 我们 希望 它使 用 指 令 i f u n 字段 中 编码的操作。因此, 可以将 ALU 控制的\nHCL 描述写成:\nword alufun = [\nicode == IDPQ : ifun;\n1 : ALUADD;\n];\n执 行 阶 段 还包括条件码寄存器。每次运行时, ALU 都会产生三个与条件码相关的信号 零 、符 号 和 溢出。不过, 我们只希望在执行 OPq 指令时才设置条件码。因此产生了一 个信号 s e 七_ c c 来 控制是否该更新条件码寄存器:\nbool set_cc = icode in { IDPQ };\n标 号 为 \u0026quot; co n d \u0026quot; 的硬件单元会根据条件码和功能码来确定是否进行条件分支或者条件数 据传送(图4-3) 。它产生信号 Cn d , 用于设置条件传送的 d s t E , 也用在条件分支的下一个 PC 逻辑中。对于其他指令,取 决 于 指 令 的 功 能 码 和 条 件 码的设置, Cn d 信号可以被设置 为 1 或者 0。但是控制逻辑会忽略它。我们省略这个单元的详细设计。\n练习题 4. 24 条件传送指令(简称 c mo v XX) 的指令代 码 为 I RRMOVQ。 如图 4- 28 所 示, 我们 可以用 执行 阶 段 中产 生 的 Cn d 信 号 实现这\n些指 令。修 改 d s t E 的 HCL 代 码 以 实 现 这 些\n指令。\n访存阶段 访存阶段的任务就是读或者写程序数据。如 图 4-30 所示,两 个 控制块产生内存地址和内存输入数据(为写操作)的值。另外两个块产生表明应该执行读操作 还是写操作的控制信号。当执行读操作时, 数据内 存产生值 v a l M。\n图 4-18 ~ 图 4- 21 的 访 存 阶 段给出了每个指令类型所需要的内存操作。可以看到内存读和写的 地 址 总 是 v a l E 或 v a l A。 这 个 块 用 HCL 描 述就是:\nword mem_addr = [\ninstr_valid imem_error\n图 4-30\nicode\nSEQ 访存阶段。数据内存既可以写,也可以读内存的值。从内存中读出的值就形成了信号 val M\nicode in { IRMMOVQ, IPUSHQ, ICALL, IMRMOVQ} : va l E;\nicode in { IPOPQ, IRET} : valA;\n# Other instructions don\u0026rsquo;t need address\n];\n练习题 4. 25 观察图 4-1 8 ~ 图 4-21 所 示 的 不 同 指令的 访存操 作, 我 们 可 以看到 内存写的 数据 总是 v a l A 或 v a l P。写 出 S E Q 中信号 me m_ d a 七a 的 H CL 代码。\n我们 希望只为从内存读数据的指令设置控制信号 me m_r e a d , 用 HCL 代码表示就是:\nbool mem_read = icode in { IMRMOVQ, IPOPQ,\nIRET };\n练习题 4. 26 我 们 希 望 只 为 向 内存 写 数 据的 指令设 置 控 制 信 号 me m wr i t e 。 写出\nS E Q 中信号 me m_ wr i t e 的 HCL 代码。\n访存 阶段最后的功能是根据取值阶段产生的 i c od e 、i me m_ er r or 、 i n s tr _ v a l i d 值以及数据内存产生的 dme m_ er r or 信 号 ,从 指 令 执行的结果来计算状态码 S t a t 。\n练习题 4. 27 写出 St a t 的 HCL代码,产 生四 个\n状态码 SAOK、SADR、SINS 和S HLT( 参见图 4-26)。\n更新 PC 阶段\nS E Q 中最后一个阶段会产生程序计数器的新值\n(见图4-31) 。如图 4-18 ~ 图 4-21 中 最后 步骤所示, 依据指令的类型和是否要选择分支, 新的 PC 可能\n是 v a l C、v a l M 或 v a l P。用 HCL 来描述这个选择就是:\n\u0026ldquo;Word neY_pc = [\n# Call. Use instruction constant icode == ICALL: valC;\n图 4-31\nSEQ 更新 PC 阶段。根据指令代码\n和分支标志, 从信号 val e、val M\n和 val P 中选出下一个PC 的值\n# Taken br an ch . Use instruction constant icode == IJXX \u0026amp;\u0026amp; Cnd: valC;\n# Completion of RET instruction. Usevalue from stack\nicode == IRET: valM;\n# Default: Use incremented PC\n1 : valP;\n];\n6. SEQ 小结\n现在我们已经 浏览了 Y86-64 处理器的一个完整的设计。可以 看到, 通过将执行每条不同指令所需的步骤组织成一个统一的流程,就可以用很少量的各种硬件单元以及一个时钟来控制计算的顺序,从而实现整个处理器。不过这样一来,控制逻辑就必须要在这些单元之间路由信号, 并根据指令类型和分支条件产 生适当的控制信号。\nSEQ 唯一的问题就是它太慢 了。时钟必须非常慢, 以使信号能在一个周期内传播所有的阶段。让我们来看看处 理一条r e t 指令的例子。在时钟周期起始时, 从更新过的 PC 开始, 要从指令内存中读出指令, 从寄存器文件中 读出栈指针, ALU 将栈指针加 8 , 为了得到程序计 数器的下一个值,还要 从内存中读出返回地址 。所有这一切都必须在这个周期结束之前完成。\n这种实现方法不能充分利用硬件单元,因为每个单元只在整个时钟周期的一部分时间内才被使用。我们会看到引入 流水线能获得更好的性能。\n4. 4 流水线的通用原理\n在试图设计一个流水线 化的 Y86-64 处理器之前, 让我们先来看看 流水线化的系统的一些通用属性和原理。对于曾经在自助餐厅的服务线上工作过或者开车通过自动汽车清洗线的人,都会非常熟悉这种系统。在流水线化的系统中,待执行的任务被划分成了若干个独立的阶段。在自助餐厅,这些阶段包括提供沙拉、主菜、甜点以及饮料。在汽车清洗 中,这些阶段包括喷水和打肥皂、擦洗、上蜡和烘干。通常都会允许多个顾客同时经过系统,而不是要等到一个用户完成了所有从头至尾的过程才让下一个开始。在一个典型的自助餐厅流水线上, 顾客按照相同的顺 序经过各个 阶段, 即使他们并 不需要某些菜。在汽车清洗的情况中,当前面一辆汽车从喷水阶段进入擦洗阶段时,下一辆就可以进入喷水阶段了。通常,汽车必须以相同的速度通过这个系统,避免撞车。\n流水线化 的一个重要特性 就是提高了系统的吞吐量( t h ro ug h p u t ) , 也就是单位时间内\n服务的顾客总数, 不过它也会轻微地增 加延迟Cla t e ncy ) , 也就是服务一个用户所需要的时间。例如,自助餐厅里的一个只需要甜点的顾客,能很快通过一个非流水线化的系统,只 在甜点阶段停留。但是在流水线 化的系统中, 这个顾客如果试图直接去甜点阶段就有可能招致其他顾客的愤怒了。\n4. 4. 1 计算流水线\n让我们把注意力放到计算流水线上来,这里的"顾客”就是指令,每个阶段完成指令 执行的一部分。图 4-32a 给出了一个很 简单的非流水线化的硬件系统例子。它是由一些执行计算的逻辑以 及一个保存计算结果的寄存器组成的。时钟信号控制在每个特定 的时间间隔加载寄存器 。CD 播放器中的译 码器就是这样的一个系统。输入信号是从 CD 表面读出的 位, 逻辑电 路对这些位进行译码, 产生音频信号。图中的计算块是用组合逻辑来实现的,意味着信号会穿过一系列逻辑门,在一定时间的延迟之后,输出就成为了输入的某个 函数。\n300 ps 20 ps\n延迟=320 ps\n吞吐量=3.12 GIPS\n时钟\na ) 硬件:未 流水线化的\nI1 I2 I3\nb ) 流水线图\n图 4-32 非 流水线化的计算 硬件。每个 320ps 的 周 期 内 , 系 统 用\n300ps计算 组 合 逻辑 函数, 20ps 将结果 存到输出寄存器中\n在现代 逻辑设计中, 电 路 延迟以微微秒或皮秒( picosecond , 简写成 \u0026quot; ps\u0026rdquo; ) , 也就是\n10-1 2 秒为单位来计算。在这个例子中, 我们假设组合逻辑需要 300 ps , 而加载寄存器需要\n20ps。图 4-32 还给出了一种时序图 ,称 为流水线图 ( pipeline diag ra m ) 。在图中, 时 间 从左向右流动 。从上到下写着一组操作(在此称为 11、 12 和 13 ) 。实 心 的 长 方形表示这些指令执行的时间。这个实现中,在开始下一条指令之前必须完成前一个。因此,这些方框在 垂直方向上 并没有相互重叠。下面这个公式给出了运行这个系统的最大吞吐量:\n1 条指令 l OOO ps\n吞吐量=(20+300)ps l n8s\n:::::::: 3. 12 GIPS\n我们以 每秒千兆条指令 CGIPS ) , 也 就 是 每秒十亿条指令, 为单位来描述吞吐量。从头到尾执行一条指令所需要的时间称为延迟( late ncy) 。在此系统中,延 迟为 320 ps , 也就是吞吐量的倒数。\n假设将系统执行的计算分成三个阶段CA、B 和 C)\u0026rsquo; 每个阶段需要 l OOps , 如图 4-33 所\n100 ps 20 ps 100 ps 20 ps 100 ps 20 ps\n延迟= 360 ps\n吞吐噩= 8.33 GIPS\n时钟\na ) 硬件: 三阶段流水线\n11 公·、r :· B - ·. C '\n12 · • B ··c.:\n13 A· · t fa= 1\u0026rsquo; 气\n时间\nb ) 流水线图\n图 4-33 三阶段流水线化的计算硬件。计算被划分为三个阶段 A、B 和 C。每经过一个 120ps的 周期 , 每条指令就行进通过一个阶段\n8 l ns = l0- 9 s 。\n示。然后在各个阶段之间放上流水线寄存器 ( pipeline register), 这样每条指令都会按照三步经过这个系统,从 头 到 尾需要三个完整的时钟周期。如图 4-33 中的 流水线图所示, 只要 Il 从 A 进入 B , 就可以让 12 进入阶段 A 了,依 此类推。在稳定状态下, 三个阶段都应该是活动的,每个时钟周期,一条指令离开系统,一条新的进入。从流水线图中第三个时钟周期就能看出这一点, 此时, Il 是 在 阶段 C , 12 在阶段 B, 而 13 是在阶段 A。在这个系统 中 , 我们将时钟周期设为 l 00+ 20 = 120 ps , 得到的吞吐量大约为 8. 33 GIPS 。因为处理一条指令需 要 3 个时钟周期 ,所 以 这条流水线的延迟就是 3 X 120 = 360ps 。我们将系统吞吐最提高到原来的 8. 33/ 3. 12 = 2. 67 倍 , 代价是增加了一些硬件,以 及 延 迟的少量增加( 360 / 320 = 1. 12) 。延迟变大是由千增加的流水线寄存器的时间开销。\n4. 4. 2 流水线操作的详细说明\n为了更好地理解流水线是怎样工作的,让我们来详细看看流水线计算的时序和操作。图 4-34 给出了前面我们看到过的三阶段流水线(图4-33) 时钟\n的流水线图。就像流水线图上方指明的那样,流水线阶 Ii\n\u0026rsquo; ,\n;,\u0026rsquo;\n段之间的指令转移是由时钟信号来控制的。每隔 120 ps ,\n信号从 0 上升至 1\u0026rsquo; 开始下一组流水线阶段的计算。\n图 4-35 跟踪 了 时 刻 240 360 之间 的电 路 活 动 , 指 令 I1 经 过 阶段 C, 12 经 过阶段 B, 而 13 经 过 阶段\nI2 A B C\nI3 A B . C\n0 120 240 360 480 600\n时间\nA。就在时刻 240 (点 1 ) 时钟上升之前 , 阶 段 A 中计算 图 4-34 三 阶段流水线的时序 。时钟的 指 令 12 的 值 已 经到达第一个流水 线寄存 器的输入, 信号的上升沿控制指令从一\n但 是 该 寄存器的状态和输出还保持为指令 Il 在阶段 A\n中计算的值。指令 11 在 阶 段 B 中计算 的值 巳经到达第\n个流水线阶段移动到下一个\n阶段\n二个流水线寄存器的输入。当时钟上升时,这些输入被加载到流水线寄存器中,成为寄 存器的输出(点2 ) 。另外 , 阶 段 A 的输入被设置成发起指令 I3 的计算。然后信号传播通过各个阶段的组合逻辑(点3 ) 。就像图中点 3 处的曲线化的波阵面( cur ved wavefront) 表明的那样, 信 号 可能以不同的速率通过各个不同的部分。在时刻 360 之前,结 果 值到达流水线寄存器的输入(点4) 。 当 时 刻 360 时钟上升时, 各 条 指 令 会 前 进 经 过一个流水线阶段。\n从这个对流水线操作详细的描述中, 我们可以看到减缓时钟不会影响流水线的行为。信号传播到流水线寄存器的输入, 但是直到时钟上升时才会改变寄存器的状态。另一方面,如 果 时 钟 运行得太快,就 会 有灾 难 性 的 后果 。值 可能会来不及通过组合逻辑,因 此当时钟上升时,寄存器的输入还不是合法的值。\n根据对 SEQ 处理器时序的讨论( 4. 3. 3 节), 我们看到这种在组合逻辑块之间采用时钟寄存器的简单机制, 足够控制流水线中的指令流。随着时钟周而复始地上升和下降, 不同的 指 令 就会 通过流水线的各个阶段, 不会相互干扰。\n4. 4. 3 流水线的局限性\n图 4-33 的 例子给出了一个理想的流水线化的系统,在 这个系统中, 我们可以将计算分 成 三个相互独立的阶段,每个 阶段需要的时间是原来逻辑需要时间的三分之一。不幸的是,会出现其他一些因素,降低流水线的效率。\n时钟 二\nI1 I2 13\n时间120 庈冰 t /360 G) ®@@\n© 时间= 239\n100 ps 20 ps 100 ps 20 ps 100 ps 20 ps\n® 时间= 241 时钟\n100 ps 20 ps 100 ps 20 ps 100 ps 20 ps\n@时间= 300 时钟\n时钟\n@ 时间= 359\n100 ps\n时钟\n图 4-35 流水线操作的一个时钟周期。在时刻 240( 点 1) 时钟上升之前,指 令 11 和 12 已 经 完 成 了 阶 段B 和 A。在时钟上升后,这 些 指 令开始传送到阶段 C 和 B, 而指令 13 开始经过阶段 A C 点 2 和 3 ) 。就在时钟开始再次上升之前, 这些指令的结果就会传到流水线寄存器的输人(点4 )\n不一致的划分\n图 4- 36 展示的系统中和前面一 样, 我们将计算划分为了三个阶段, 但是通过这些阶 段的延迟从 50 ps 到 1 50 ps 不等。通过所有阶段的延迟和仍然为 300 ps 。不过,运 行时钟的速率是由 最慢的阶段的延迟限制的。流水线图表明, 每个时钟周期, 阶段 A 都会空闲(用白色方框表示) l OOps , 而阶段 C 会空闲 50 ps 。只有阶段 B 会一直处于活动状态。我们必须将时钟周期设 为 1 50 + 20 = l 70ps, 得到吞吐量为 5. 88 GIPS 。另外, 由于时钟周期减慢\n了,延 迟也增加到了 510ps 。\n50 ps 20 ps 150 ps 20 ps 100 ps 20 ps\n延迟= 510 ps\n吞吐量= 5.88 GIPS\nI1 区l\nI2\n时钟\na ) 硬件: 三阶段流水线, 不一致的阶段延迟\nI3\n时间\n图 4-36\nb ) 流水线图\n由不一致的阶段延迟造成的流水线技术的局限性。系统的吞吐量受最慢阶段的速度所限制\n对硬件设计者来说,将系统计算设计划分成一组具有相同延迟的阶段是一个严峻的挑战。通常,处理 器中的某些硬件单元,如 ALU 和内存,是 不 能 被划分成多个延迟较小的单元的。这就使得创建一组 平衡的阶段非常困难。在设计流水线化的 Y86-64处理器中, 我们不会过于关注这一层次的细节,但是理解时序优化在实际系统设计中的重要性还是非常重要的。\n练习题 4. 28 假设我 们 分析图 4-32 中 的 组合逻辑, 认为 它 可以分成 6 个块, 依次命名 为 A F , 延迟分别为 80 、 30、60 、50 、70 和 l Ops , 如下图所示:\n80 ps 30 ps 60 ps 50 ps 70 ps\n在这些块之间插入流水线寄存器,就得到这一设计的流水线化的版本。根据在哪里插入流水线寄存器,会出现不同的流水线深度(有多少个阶段)和最大吞吐量的组 合。假设每个流水线寄 存器的延迟 为 20 ps。\n只插入一个寄存器,得到一个两阶段的流水线。要使吞吐量最大化,该在哪里插入寄存器呢?吞吐量和延迟是多少?\n要使一个三阶段的流水线的吞吐量最大化,该将两个寄存器插在哪里呢?吞吐量和延迟是多少? 要使一个四阶段的流水线的吞吐量最大化,该将三个寄存器插在哪里呢?吞吐量和延迟是多少? 要得到一个吞吐量最大的设计,至少要有几个阶段?描述这个设计及其吞吐量和延迟。 流水线过深,收益反而下降\n图 4-37 说明了流水线技术的另一个局限性。在这个例子中, 我们把计算分成了 6 个阶 段 , 每个 阶段需要 50ps。在每对阶段之间插入流水线寄存器就得到了一个六阶段流水线 。 这 个 系统的最小时钟周期为 50 + 20 = 70ps , 吞吐量为 14. 29 G IPS 。因此, 通过将流\n水线的阶 段数加倍 , 我们将性能提高了 14. 29/8. 33=1. 71。虽 然我们将每个计算 时钟的时间缩短了两倍,但是由于通过流水线寄存器的延迟,吞吐量并没有加倍。这个延迟成了流 水线吞吐量的一个制约因素。在我们的新设计中,这个延迟占到了整个时钟周期的 28. 6 %。\n50 ps 20 p\nt\n时钟 延迟= 420 ps, 吞吐量= 14.29 GIPS\n图 4-37 由开销造成的流水线技术的局限性。在组合逻辑被分成较小的块时,\n由寄存器更新引起的延迟就成为了一个限制因素\n为了提高时钟频率,现代处理器采用了很深的(1 5 或更多的阶段)流水线。处理器架构师将指令 的执行划分成很多非常简单的 步骤, 这样一来每个阶段的延 迟就很小。电路设计者小心地设计流水线寄存器,使其延迟尽可能得小。芯片设计者也必须小心地设计时钟传播网 络,以保证时钟在整个芯片上同时改变。所有这些都是设计高速微处理器面临的挑战。\n练习题 4. 29 让我们来 看看 图 4-32 中的 系统 , 假设将 它划分 成 任意 数 量 的流水线 阶段 k , 每个阶段有 相同的 延迟 300 / k , 每个 流水 线寄 存器的延迟 为 20 ps。\n系统的 延迟和吞 吐量写 成 k 的函数是 什 么? 吞吐量的上限等千多少? 4. 4. 4 带反馈的流水线系统\n到目前为止,我们只考虑一种系统,其中传过流水线的对象,无论是汽车、人或者指 令, 相互都是 完全独 立的。但 是, 对于像 x86-64 或 Y86-64 这样执行机器程序的系统来说,相 邻指令之间很 可能是相关的。例如, 考虑下面这个 Y86-64 指令序列:\n在这个包含 三条 指令的序列中, 每对相邻的指令之间都有数据相关 ( dat a dependen­\ncy)\u0026rsquo; 用带圈的寄存器名字和它们之间的箭头来表示。ir mo v q 指令(第1 行)将它的结果存放在%r a x 中, 然后 a d d q 指令(第2 行)要读这个值 ; 而 a d d q 指令将它的结果存放在%r b x 中,mr mo v q 指令(第3 行)要读这个值。\n另一种相关 是由于指令控制流造成的顺序相关。来看看下面这个 Y86-64 指令序列:\nloop:\nsubq %rdx,%rbx\njne targ\nirmovq $10,%rdx jmp loop\ntarg:\nhalt\nj ne 指令(第3 行)产生了一个控制相 关( cont ro l dependency) , 因为条件测试的结果会决定要执行的新指令是 ir movq 指 令(第 4 行)还是 ha 止 指 令(第 7 行)。在我们的 SEQ 设计中, 这些相关都是由反馈路径来解决的, 如 图 4- 22 的右边所示。这些反馈将更新了的寄存器值向下传送到寄存器文件, 将新的 PC 值向下传送到 PC 寄存器。\n图 4-38 举例说明了将流水线引入含有反馈路径的系统中的危险。在原来的系统(图4-38a) 中, 每条指令的结果都反馈给下一条指令。流水线图(图4-386)就说明了这个情况, 11 的结果成为 12 的输入,依 此类推。如果试图以最直接的方式将它转换成一个三阶段流水线(图4-38c) , 我们将改变系统的行为。如图 4- 38c 所示 ,11 的结果成 为 14 的输入。为了通过流水线技术加速系统,我们改变了系统的行为。\nIl 12 I3\n时间\nb ) 流水线图\nI1 I 2 I3 I4\n时钟\nc ) 硬件: 带反馈的三阶段流水线\nd ) 流水线图\n图 4-38 由逻辑相关造成的流水线技术的局限性。在从未流水线化的带反馈的系统 a 转化到流水 线化的 系统 c 的 过程中,我 们改变了它的计算行为, 可以从两个流水线图Cb 和 d) 中看出来\n当我们将流水线技术引入 Y86- 64 处理器时, 必 须 正 确 处 理反馈的影响。很明显,像图 4-38 中的例子那样改变系统的行为是不可接收的。我们必须以某种方式来处理指令间的数 据 和控制相关,以 使 得 到 的 行 为与 ISA 定义的模型相符。\n4. 5 Y86-64 的 流水线实现\n我们终于准备好要开始本章的主要任务—— 设计一个流水线化的 Y86- 64 处理器。首先 ,对 顺 序 的 SEQ 处 理 器做 一点小的改动,将 PC 的计 算挪到取指阶段。然后, 在各个阶段之间加上流水线寄存器。到这个时候, 我们的尝试还不能正确处理各种数据和控制相关 。 不 过,做 一 些 修 改 ,就 能实现我们的目标—— 一个高效的、流水线化的实现 Y86-64\nISA 的处理器。\n4. 5. 1 SEQ+: 重新安排计算阶段\n作为实现流水线化设计的一个过渡步骤, 我们必须稍微调整一下 SE Q 中五个阶段的顺序,使 得更新 PC 阶段在一个时钟周期开始时执行, 而不是结束时才执行。只需要对整体硬件结构做最小的改动,对 于 流水线阶段中的活动的时序,它 能 工 作 得 更 好 。 我们称这\n种修改 过的设计为 \u0026quot; SEQ + \u0026quot; 。\n我们移动 PC 阶段,使 得 它 的 逻辑在时钟周期开始时活动,使 它 计 算 当前指令的 PC 值。图 4-39 给出了 SEQ 和 SEQ + 在 PC 计算上的不同之处。在 SEQ 中(图 4-39a) , PC 计算发生在时 钟周期结束的时候, 根据当前时钟周期内计算出的信号值来计算 PC 寄存器的新值。在 SEQ + 中(图 4-39 b) , 我们创建状态寄存器来保存在一条指令执行过程中计算出 来的信号。然后, 当一个新的时钟周期开始时, 这些信号值通过同样的逻辑来计算当前指令的 PC。我们将这些寄存器标号为 \u0026quot; plc ode\u0026quot; 、\u0026quot; pCnd\u0026quot; 等等, 来 指 明 在 任一给定的周期, 它们保 存的是前一个周期中产生的控制信号。\nPC\nicode Cnd valC valM valP\na) SEQ 的新PC计算\nplcodelpCndl pValM I pValC I pValP\nb) S E Q +的PC选择\n图 4- 39 移动计算 PC 的时间。在 S EQ + 中,我 们将计算当前状态的程序计数器的 值作为指令执行的第一步\n图 4-4 0 给出了 SEQ + 硬件的一个更为详细的说明。可以看到, 其 中 的 硬件单元和控制块与我们 在 SEQ 中用到的(图4-23 ) 一样 ,只 不过 PC 逻辑从上面(在时钟周期结束时活动)移到了 下面(在时钟周期开始时活动)。\n黜I S E Q + 中的 PC 在哪 里\nSEQ 十有一个很 奇怪的特 色, 那就 是 没有硬件寄存器 来存放程 序 计数 器。 而是根据从 前一 条 指 令保 存 下 来 的 一 些 状 态 信 息 动 态 地 计 算 PC。 这就是 一 个 小 小 的 证\n; 明一— 我们可以 以一种与 IS A 隐含着的概 念模型不 同的 方式 来 实现 处理 器 , 只要处理器能正确 执行任意的机 器语 言程序。我们不 需要将状 态编码成程序员 可见的状 态指定\n;的形式 ,只 要 处理 器能 够为 任意的程序 员 可见状 态(例如 程序计数 器)产 生正 确的值。埠 创建 流水线化的设计中, 我们会 更多地 使 用到 这条原 则。 5. 7 节 中描 述的乱序 ( out­ of-order) 处理技术, 以一种 完全 不 同 于机 器 级 程序 中 出现的顺序的 次序 来执行指令,\n(将这 一思想发挥到 了极 致。\nSEQ 到 SEQ + 中对状态单元的改变是一种很通用的改进的例子, 这种改进称为电路重定时( c订cuit retimin g ) [ 68] 。重定时改变了一个系统的状态表示, 但 是 并不改变它的逻辑行为。通常用它来平衡一个流水线系统中各个阶段之间的延迟。\n4. 5. 2 插入流水线寄存器\n在创建一个流水线化的 Y86-64 处理器的最初尝试中, 我们要在 SEQ + 的各个阶段之间插人流水线寄存器, 并 对 信 号 重 新 排 列 ,得 到 P IP E —处 理器 , 这里的“—” 代 表 这 个处理器和最终的处理器设计相比,性 能 要 差 一 点 。 P IP E—的抽象结构如图 4-41 所 示。流水线寄 存器在该图中用黑色方框表示 , 每个寄存器包括不同的字段, 用 白 色方框表示。正 如多个字段 表明的那样, 每个流水线寄存器可以存放多个字节和字。同两个顺序处理器的硬件结构(图 4-23 和图 4-40 ) 中的圆角方框不同, 这些白色的方框表示实际的硬件组成。\n访存\n执行\n译码\n取指\n图 4-40 SEQ 十的 硬件结构 。将 PC 计算从时钟周期结 束时移到了 开始时 ,使 之更适合于流水线\n可以看到, P I P E —使 用了与顺序设计 SEQ ( 图 4-40 ) 几乎 一样的硬件单元, 但是有流水 线 寄 存 器分隔开这些阶段。两个系统中信号的不同之处在 4. 5. 3 节中讨论。\n流水线寄存器按如下方式标号:\nF 保存程序计数器的预测值,稍后讨论。\n位于取指和译码阶段之间。它保存关千最新取出的指令的信息,即将由译码阶段进行处理。 位于译码和执行阶段之间。它保存关千最新译码的指令和从寄存器文件读出的值 的信息,即将由执行阶段进行处理。 M 位于执行和访存阶段之间。它保存最新执行的指令的结果, 即 将 由 访 存 阶 段 进 行处 理 。 它 还保 存关于用于处理条件转移的分支条件和分支目标的信息。\nW 位于访存阶段和反馈路径之间 , 反馈路径将计算出来的值提供给寄存器文件写, 而当完成 r e t 指令时, 它还要向 PC 选择逻辑提供返回地址。\n图 4-41 PIPE- 的硬件结 构,一 个初始的 流水线化实现。通过往 SEQ+ C图 4-40 ) 中插入流水 线寄存器,我们创建 了一个五阶段的流水线 。这个版本有 几个缺陷, 稍后就会解决 这些问题\n图 4-42 表明以下代码序列 如何通过我们的五阶段流水线, 其 中 注 释将各条指令标识\n为 Il ~ I5 以便引用:\nir movq $1,%rax # 11\n2 irmovq $2,%rbx # 12\nirmovq $3, 儿r c x # 13 irmovq $4, %r dx # I4 h a l t # 15 2 3 4 5 6 7 8 9\n图 4- 42 指令流通过 流水线的示例\n图中右边给出 了这个指令序列的 流水线图 。同 4. 4 节中简单流水 线化的计算单元的流水线图一样,这 个图描述了每条指令通过流水线各个阶段的行进过程,时 间从左往右增大。上面一条数字表明各个阶段发生的时钟周期。例如, 在周期 1 取出指令 11, 然后它开始通过 流水线各个阶段,到 周期 5 结束后, 其结果写入寄存器文件。在周期 2 取出指令\n12, 到周期 6 结束后, 其结果写回, 以此类推。在最下面, 我们 给出了 当周期为 5 时的流水线的扩展图 。此时, 每个流水线阶段中各有一条指令。\n从图 4- 42 中还可以 判断我们画处理器的 习惯是合理的, 这样, 指令是自底向上的流动的。周期 5 时的扩展图表明的 流水线 阶段,取 指阶段在底 部, 写回阶段在最上面, 同流水线硬件图(图 4- 41 ) 表明的一样。如果看看流水线各个阶段中指令的顺序, 就会发现它们出现的顺序与在程序中列出的顺序一样。因为正常的程序是从上到下列出的,我们保留这 种顺序,让流水线从下到上进行。在使用本书附带的模拟器时,这个习惯会特别有用。\n4. 5. 3 对信号进行重新排列和标号\n顺序实现 SEQ 和 SEQ + 在一个时刻只处理一 条指令, 因此诸如 v a l e 、 sr c A 和 v a l E 这样的信号值有唯一的值。在流水线化的设计中, 与各个指令相关联的这些值有多个版本, 会随着指令一起流过系统。例如, 在 PIP E一的 详细结构中, 有 4 个标号为 \u0026ldquo;Sta t\u0026rdquo; 的白 色方框, 保存着 4 条不同 指令的状态码(参见图4- 41 ) 。我们需要很小心以确保使用的是正确版本的信号,否 则会有很严 重的错误,例 如将一 条指令计算出的结果存放到了另一条指令指定的目的寄存器。我们采用的命名机制,通过在信号名前面加上大写的流水线寄存\n器名字作为前缀,存 储 在流水线寄存器中的信号可以唯一地被标识。例如, 4 个状态码 可以被命名为 D_s 七a t 、 E_s t a t 、M_ s t a t 和 W_s t a t 。 我们还需要引用某些在一个阶段内刚 刚计算出来的信号。它们的命名是在信号名前面加上小写的阶段名的第一个字母作为前 缀。以 状态码为例, 可以看到在取指和访存阶段中标号为 \u0026quot; S ta t\u0026quot; 的控制逻辑块。因 而, 这些块 的输出被命名为 f _s t a t 和 m_ s t a t 。 我们还可以看到整个处理器的实际状态 St a t 是根据流水线寄存器 W 中的状态值,由 写 回 阶 段中的块计算出来的。\nm 信号 M _ s tat 和 m _ s tat 的差别\n在命名系统中, 大写的 前缀 \u0026quot; D\u0026quot; 、 \u0026quot; E\u0026quot; 、 \u0026quot; M\u0026quot; 和 \u0026ldquo;W\u0026rdquo; 指的是流水线寄存器, 所以 M _ st at 指的是流水线寄存 器 M 的状态码 宇段。 小 写的前缀 \u0026quot; f\u0026quot; 、 \u0026quot; cl\u0026quot; 、 \u0026quot; e\u0026quot; 、 \u0026quot; m\u0026quot; 和 \u0026quot; w\u0026quot; 指的是流水 线阶段, 所以 m _ sta t 指的是在访存阶段中由控制逻辑块产 生 出的状态信 号。\n理解这个命名规则对理解我们的流水线化处理器的操作是至关重要的。\nSEQ十和 PIPE- 的译码阶段都产生信号 ds t E 和 ds 七M, 它 们 指明 值 va l E 和 va l M 的 目的寄存器。在 SEQ十中 , 我们可以将这些信号直接连到寄存器文件写端口的地址输入。在PIPE- 中,会在 流水线中一直携带这些信号穿过执行和访存阶段,直 到 写 回 阶段才送到寄存器文件(如各个阶段的详细描述所示)。我们这样做是为了确保写端口的地址和数据输入是来 自同一条指令。否则, 会将处于写回阶段的指令的值写入,而 寄 存 器 ID 却 来 自千处于译码阶段的指令。作为一条通用原则,我们要保存处于一个流水线阶段中的指令的所有信息。\nPIPE—中有一个块在相同表示形式的 SEQ + 中是没有的, 那就是译码阶段中标号为\n\u0026ldquo;Select A\u0026rdquo; 的块。我们可以看出,这个 块 会 从 来自流水线寄存器 D 的 va l P 或从 寄存 器 文件\nA 端口中读出的值中选择一个,作 为流水线寄存器 E 的值 va l A。 包括这个块是为了减少要携带给流水线 寄存骈 E 和 M 的状态数量。在所有的指令中,只有 c a ll 在 访存 阶段需 要 va l P 的值。只有跳转 指令在执行阶段(当不需要进行跳转时)需要 va l P 的值。而这些指令又都不需要从寄存器文件中读出的值。因此我们合并这两个信号,将 它 们 作 为信号 va l A 携 带 穿 过流水线 ,从 而 可以减少流水线寄存器的状态数显。这样做就消除了 SEQ(图 4-23 ) 和 SEQ +\n(图4-40 )中标号为 \u0026quot; Data\u0026quot; 的块, 这个块完成的是类似的功能。在硬件设计中,像 这 样 仔 细确认信号 是如何使用的,然后 通过合并信号来减少寄存器状态和线路的数量,是 很 常见 的 。\n如图 4- 41 所示,我 们的流水线寄存器包括一个状态码 s t a t 字段,开 始 时 是 在取指阶段计算出来的,在访存阶段有可能会被修改。在讲完正常指令执行的实现之后,我们会在\n4. 5. 6 节中讨论如何实现异常事件的处理。到目前为止我们可以说,最 系统的方法就是让与每条指令关联的状态码与指令一起通过流水线,就像图中表明的那样。\n4. 5. 4 预 测 下 一 个 PC\n在 PIPE- 设计中, 我们采取了一些措施来正确处理控制相关。流水线化设计的目的就是每个时钟周期都发射一条新指令,也就是说每个时钟周期都有一条新指令进入执行阶段并 最终完成。要是达到这个目的也就意味着吞吐量是每个时钟周期一条指令。要做到这一点, 我们必须在取出当前指令之后,马上确定下一条指令的位置。不幸的是,如果取出的指令是 条件分支指令,要到几个周期后,也就是指令通过执行阶段之后,我们才能知道是否要选择 分支。类似地,如果 取 出的 指 令 是r e 七,要 到指令通过访存阶段, 才能确定返回地址。\n除了条件转移指令和r e t 以外,根 据取指阶段中计算出的信息, 我们能够确定下一条\n指令的地址。对于 c a l l 和 j mp ( 无条件转移)来说,下 一条指令的地址是指令中的常数字\nvalC, 而对于其他指令来说就是 va l P。因 此, 通过预测 PC 的下一个值, 在大多数情况下,我们能达到每个时钟周期发射一条新指令的目的。对大多数指令类型来说,我们的预测是完全可靠的。对条件转移来说, 我们既可以 预测选择了分支, 那么新 PC 值应为\nvalC, 也可以预测没有选择分支, 那么新 PC 值应为 va l P。无论哪种情况, 我们都必须以某种方式来处理预测错误的情况,因为此时已经取出并部分执行了错误的指令。我们会在 4. 5. 8 节中再讨论这个问题。\n猜测分支方向并根据猜测开始取指的技术称为分支预测。实际上所有的处理器都采用 了某种形式的此类技术。对千预测是否选择分支的有效策略已经进行了广泛的研究[ 46,\n2. 3 节]。有的系统花费了大量硬件来解决这个任务。 我们的设计只使用了简单的策略, 即总是预测选择了 条件分支, 因而预测 PC 的新值为 v a l e 。\n田 日 其他的分 支预测策略\n我们的设 计使 用总 是选择 ( always taken ) 分支的预测策略。研究表 明这个策略的成功率大约 为 60 %[ 44 , 122 ] 。相反,从 不选择 ( never taken , NT ) 策略 的成功 率大约为40 % 。稍微复杂一点的是反向选择、正向 不选择( backwa rd taken , forward not- taken , BT F NT ) 的策略 , 当分 支地址比 下一条地址低 时就预 测选择 分支, 而分 支地 址比 较高时, 就预测不 选择分支。这种策略的成功率大约 为 65 % 。这种改进 源自一 个事 实, 即循环是由后向分支结束的, 而循 环通 常会执行 多次。前向分支用 于条 件操作, 而这 种选择的可能性 较小。在 家庭作 业 4. 55 和 4. 56 中,你 可以修改 Y86-64 流水线处理 器来 实现\nNT 和 BT F NT 分支预测策略。\n正如我们在 3. 6. 6 节中看到的 , 分支预测错误 会极大地 降低程序的性能,因此这就促使我们在可能的 时候,要 使用条件 数据传送而不 是条件控制转移 。\n我们还没有讨论预测 r e t 指令的新 PC 值。同条件转移不同 , 此时可能的返回值几乎是无限的, 因为返回地址是 位千栈顶的 字, 其内容可以是任意的。在设计中,我 们不会试图 对返回地址做任何预测。只是简单地暂停处 理新指令, 直到 r 吐 指令通过写 回阶段。在\n4. 5. 8 节中,我 们将回过来讨论 这部分的实现。\nm 使用栈的返回地址预测\n对大多数程序 来说 , 预测返回 值很容易,因为过 程调 用和返回是成对出现的 。大多数函数调用,会返回到调用后的那条指令。高性能处理器中运用了这个属性,在取指单 元中放 入一个硬件栈, 保存过程调用指 令产 生的 返回地址。每次执行过程调用指 令时, 都将其返回 地址压入栈 中。 当取 出一 个返回指令时, 就从 这个栈 中弹出顶部 的值, 作为 \ 预测的返回值 。同分 支预测一样,在预 测错误 时必须提供 一个恢复机制, 因为 还是有调用和返回不匹配的 时候。通常, 这种预测很 可靠。这个硬件栈对程序员来说 是不可见的。\nPIP E 一的 取指阶段,如 图 4-41 底部所示,负 责预测 PC 的下一个值,以 及为取指选择实际 的 PC。我们可以 看到, 标号为 \u0026quot; P redict PC\u0026quot; 的块会从 PC 增加器计算出的 val P 和取出的指令中得到的 va l e 中进行选择。这个值存放在流水线寄存器 F 中, 作为程序计数器的预测值。标号 为 \u0026quot; Select PC\u0026quot; 的块类似于 SEQ + 的 PC 选择阶段中标号为 \u0026quot; PC\u0026quot; 的块(图4-40 ) 。它从三个值中选择一个作为指 令内存的地址 : 预测的 PC , 对千到达流水线\n寄存器 M 的不选择分 支的指令来说是 v a l P 的值(存储在寄存器 M_ v a l A 中), 或是当 r e t\n指令到达流水线 寄存器 WC存储在 W_ v a l M) 时的返回地址的值。\n4. 5. 5 流水线冒险\nPIPE- 结构是创建一个流水线 化的 Y 86- 64 处理器的好开端。不过, 回 忆 4. 4. 4 节中的讨论,将流水线 技术引入一个带反馈的系统, 当相邻指令间存在相关时会导致出现问题。在完成我们的设计之前,必须解决这个问题。这些相关有两种形式: 1 ) 数据相关,下一条指令会用 到这一条指令计算出的结果; 2 ) 控制相 关 , 一条指令要确定下一条指令的位置,例如在执行跳转、调用或返回指令时。这些相关可能会导致流水线产生计算错误,称 为冒险 ( ha za rd ) 。同相关一样, 冒险也可以分为两类: 数据冒险( da t a ha za rd ) 和控制 冒险(control haza r d ) 。我们首先关心的是数据冒险, 然后再 考虑控制冒险。\n图 4-4 3 描述的是 PIPE—处理器处理 pr o g l 指令序列的情况。假设在这个例子以及后面的例子 中,程序 寄存器初始时值都为 0 。这 段代码将值 10 和 3 放入程序寄存器%r d x 和\n%r a x , 执行三条 n a p 指令,然后 将寄存器%r d x 加到%r a x 。 我们重点 关注两条 ir mo v q 指\n令和 a d dq 指令之间的数据相关 造成的 可能 的数据冒险。图的右边是这个指令序列的流水 线图。图 中突出显示了周期 6 和 7 的流水线阶段。流水线图的下面是 周期 6 中写回活动和周期 7 中译码活动的扩展说明。在周期 7 开始以后, 两条 i r mo v q 都 已经通过写回阶段, 所以寄存器文件保 存着更新过的 %r d x 和%r a x 的 值。因 此, 当 a d d q 指令在周期 7 经过译 码阶段时 , 它可以读到源操作数的正确值。在此示例中, 两条 ir mo v q 指令和 a d d q 指令之间的数据相关 没有造成数 据冒险。\n# progl 2 3 4 5 6 7 8 9 10 11 OxOOO: irmovq $10,\u0026lsquo;1/.rdx I F D E M w OxOOa: irmovq $3, 1/.rax F D E M w Ox014 : nop F D E M w Ox015: nop Ox016 : nop Ox017: addq %rdx,%rax Ox019: halt F D F E D F M w E M w D E M I W F 心 D E I M I w \u0026rsquo; \u0026rsquo; ;, 图 4-43 pr ogl 的流水线化的执行,没有 特殊的流水线控制。在周 期 6 中 ,第 二个 i rmovq 将结果\n写入寄存器r% ax。addq 指令在周期 7 读源操作数, 因此得到的是r% dx 和r毛 ax 的正确值\n我们看到 pr o g l 通过流水线并 得到正确的结果, 因为 3 条 no p 指令在有数据相关的指令之间创造了一些延 迟。让我们来看看如果去掉这些 no p 指令会发生些 什么。图 4-44 描述的是 pr o g 2 程序的 流水线 流程 , 在两条产生寄存器%r d x 和%r a x 值的 ir rno v q 指令和以这两个寄存器作为操作数的 a d d q 指令之间有两条 n o p 指令。在这种情况下, 关键步骤发生在周期 6\u0026rsquo; 此时 a d d q 指令从寄存器文件中读取它的操作数。该图底部是这个周期内流水线活动的扩展描述。第一个 ir rno v q 指令巳经通过了写回阶段, 因此程序寄存 器%r d x 巳经在寄存器文件中更新过了。在该周期内, 第二个 ir rno v q 指令处于写回阶段, 因此对程序寄存器%r a x 的 写 要到周期 7 开始,时 钟上升时, 才会发生。结果, 会读出%r a x 的错误值(回想一下, 我们假设 所有的寄存器的初始值为 0 ) , 因为对该寄存器的写还未发生。很明显,我们必须改进流水线让它能够正确处理这样的冒险。\n# prog2\nOxOOO: irmovq $10,%rdx OxOOa: irmovq $3,%rax Ox014: nop\nOx015: nop\nOx016: addq %rdx,%rax Ox018: halt\n2 3 4 5 6 7 8 9 10\n昙 MIW\n气D E M I W\n错误值\n图 4-44 p ro g2 的 流水线化的执行,没 有 特 殊 的 流水线控制。直到周期 7 结 束 时 , 对寄存\n器r% a x 的写才发生,所 以 addq 指 令 在译码阶段读出的是该寄存器的错误值\n图 4- 45 是当 ir mo v q 指令和 a d d q 指令之间只有一条 n o p 指令, 即为程序 p r o g 3 时, 发生的 情况。现在我们必须检查周期 5 内流水线的行为, 此时 a d d q 指令通过译码阶段。不幸的是, 对寄存器%r d x 的写仍处在写回阶段, 而对寄存器%r a x 的 写 还处 在访存阶段。因此, a d d q 指令会得到两个错误的操作数。\n图 4- 46 是当去掉 ir mo v q 指令和 a d d q 指令间的所有 n o p 指令, 即为程序 pr o g 4 时, 发生的情况。现在我们必须检查周期 4 内流水线的行为, 此时 a d d q 指令通过译码阶段。不幸的是, 对寄存器%r d x 的 写 仍处在访存阶段, 而执行阶段正在计算寄存器%r a x 的新值。因此, a d d q 指令的两个操作数都是不正确的。\n这些例子说明 , 如果一条指令的操作数 被它前面三条指 令中的任意一条改变的话,都会出现数据冒险。之所以会出现这些冒险,是因为我们的流水线化的处理器是在译码阶段 从寄存器文件中读取指令的操作数,而要到三个周期以后,指令经过写回阶段时,才会将 指令的结果写到寄存器文件。\n# prog3 2 3 4 5 6 7 8 9\nj W\nI M I w\nM_valE = 3 M_dstE =\u0026lsquo;l.rax\n错误值\n图 4- 45 pr og3 的流水线化的 执行 ,没 有 特 殊 的 流 水 线 控 制 。 在周期 5 , addq 指令从寄存器文件中读源操作数。对寄存器釭dx 的写仍处在写回阶段, 而 对 寄 存器%r a x 的 写 还在访存阶段。两个操作数 va l A 和 va l B 得 到的 都 是 错 误 值\n# prog4 2 3 4 5 6 7 8 OxOOO : irmovq $10,%rdx I F D E M w OxOOa : irmovq $3,%rax F D . E,\u0026rsquo; M w Ox014: addq %r dx 丛r ax F D · E M I W Ox016: halt 了F : D E IM I w e _ valE 仁 0 + 3 = 3 E_dstE = %rax\n图 4- 46 pro g 4 的 流水线化的执行,没 有 特 殊的 流水线控制。在周期 4 , a ddq 指 令从 寄存 器文件中读源操作数。对寄存器 r% dx 的 写 仍 处 在访 存 阶 段 ,而执 行阶段正在计算寄存器r% ax 的新 值 。 两个操作数va l A 和 v a l B 得 到的 都 是 错 误 值\nm 列举数据冒险的类型\n当一 条指令 更新后 面指令会读 到 的 那 些 程 序 状 态 时, 就有 可能 出 现 冒 险。 对 于Y86- 64 来说 ,程 序 状态 包括 程序寄存 器、 程 序计数 器、 内存 、条 件码寄存 器和 状 态寄存器。 让我们来看看在提 出的 设计中每 类状 态出 现冒险 的可能性。\n程序寄存器: 我们已经认 识这种冒险 了。 出现 这种冒险是 因 为寄存器文件的读写是在不同的阶段进行的, 导致不同指令之间可能出现 不希望的 相互作用。\n程序计数器: 更新和读取程序计数 器之 间的 冲突导致了控制冒险。 当我 们的取指阶段逻辑在取下一 条指令之前, 正 确预测了程 序 计数 器的 新值时, 就不会 产 生冒险。预测错误 的分支和r 釭 指令需要特殊的处理,会 在 4. 5. 5 节中讨论。\n内存: 对数 据 内存的 读和写都 发生在访 存阶段。在一条读内存的指令到达这个阶段之前, 前面所有要 写内存的 指令都已经完成这个阶段 了。 另外 ,在 访存阶段中写数 据的 指令和在取指阶段中读指令之间也有冲突 , 因为指 令 和数 据内存 访问的是同一个地址空间 。只有包含自我修改代码的程序才会发生这种情况,在这样的程序中,指令写内存的一部分, 过后会从中取出指 令。有些 系统有复杂的机制来检测和 避免 这种冒险, 而有 些 系统只是简单地强制要 求程序不应该使 用自我修改代码。为 了 简便 ,假 设 程序不能修 改自身,因此我们 不需要 采取特殊的措施 ,根 据在程序执行过程中对数据内存的修 改来修改指令内存。\n条件码寄存器: 在执行阶段中,整 数 操作会写这 些寄存 器。 条件传送指令会在执行阶段以及条件转移会在访存阶段读这些寄存器。在 条件传送或转移到达执行阶段之前, 前面所 有的 整数操作都已经完成 这个阶段 了。 所以不会发 生冒险。\n状态寄存器: 指令流经流水线的时候,会 影响程序状 态。 我们采用流水线中的 每条指令都与一个状态码相关联的机制,使得当异常发生时,处理器能够有条理地停止,就 像在 4. 5. 6 节中会讲到的那样。\n这些分析表明我们只需要处理寄存器数据冒险、控制冒险,以及确保能够正确处理 异常。 当设 计一个复 杂 系统时, 这样的分类分析是很重要 的。这样做可以确认 出 系统实现中可能的 困难,还 可以指导生成 用 于检查 系统正确性的测试 程序。\n用暂停来避免数据冒险\n暂停 ( s ta ll ing ) 是避免冒险的一种常用技术,暂 停 时 ,处 理 器 会 停 止 流水线中一条或多条指令,直到冒险条件不再满足。让一条指令停顿在译码阶段,直到产生它的源操作数的指令通过了写回阶段, 这样我们的处理器就能避免数据冒险。这种机制的细节会在 4. 5. 8 节 中讨论 。它 对流水线控制逻辑做了一些简单的加强。图 4-47 ( p r o g 2) 和图 4-48 (prog4) 中画出了暂停的效果。(在这里的讨论中我们省略了 pr o g 3 , 因为它的运行类似于其他两个例子。)当指令 a d dq 处于译码阶段时, 流水线控制逻辑发现执行、访存或写回阶段中至少 有一 条 指令 会更 新寄存器%r d x 或 %r a x 。 处理器不会让 a ddq 指令带着不正确的结果通过 这 个阶段,而 是 会 暂 停 指 令 ,将 它阻 塞 在译码阶段,时 间 为一个周期(对pr o g 2 来说)或者三个 周 期(对 pr og 4 来说)。对所有这三个程序来说, a d d q 指 令 最终都会在周期 7 中得到两个源操作数的正确值,然后继续沿着流水线进行下去。\n将 addq 指令阻塞在译码阶段时, 我们还必须将紧跟其后的 ha lt 指令阻塞在取指阶段 。通过将程序计数器保持不变就能做到这一点, 这样一来,会 不断地对 ha lt 指令进行取指,直到暂停结束。\n暂停技术就是让 一组指令阻塞在它们所处的阶段, 而允许其他指令继续通过流水 线。那么在本该正常处理 a d d q 指令的阶段中, 我们该做些什么呢? 我们使用的处理方法是: 每次要把一条 指令阻塞在译码阶段 , 就在执行阶段插入一个气泡。气泡就像 一个自动产生的 no p 指令—— 它不会改变寄存器、内存、条件码或程序状态。在图 4- 4 7 和图 4- 4 8 的流 水线图中 ,白 色方框表示的就是气泡。在这些图中, 我们用一个 a d d q 指令的标号为 \u0026quot; D\u0026quot; 的方框到标 号为 \u0026quot; E\u0026quot; 的方 框之间的箭头来表示 一个流水线气泡, 这些箭头表明 ,在 执行阶段中插 入气泡是为了替代 a d d q 指令,它 本来应该经过译 码阶段进入 执行阶段。在\n5. 8 节中 , 我们将 看到使流水线暂停以及插入气泡的详 细机制。 # prog2 OxOOO: irmovq $10,%rdx OxOOa: irmovq $3,%rax Ox014: nop Ox015: nop bubble Ox016: addlq ;r儿 dx , ¼r ax Ox018 : halt 1 2 3 4 5 6 7 8 9 10 11 I F D E M w F D E M w F D E M w F D E M w 广 E M w F D D E M w F F D E M w l 图 4-47 p ro g2 使用暂停的流水线化的执行。在周期 6 中对 ad dq 指令译码之后, 暂停控制逻辑发 现一个数据冒险, 它是由写回阶段中对寄存 器%r a x 未进行 的写造成的。它在执行 阶段中 插人一个气泡,并 在周期 7 中重复对指令 a ddq 的译码。实际上, 机器是 动态地插入一条 nop 指令, 得到的执行流类似于 p ro g l 的执行流(图4-43)\n# prog4 2 3 4 5 6 7 8 9 10 11\n图 4- 48 p r og 4 使用暂 停的流水线化的执行。在周期 4 中对 addq 指令译码 之后,暂停控制逻辑发现了对两个 源寄存器的 数据冒险 。它在执行阶段中插入一个气泡, 并在周期 5 中重复对指令 a ddq 的译码。它再次发现对 两个源寄存 器的冒险 , 就在执行阶段中插入一 个气泡, 并在周期 6 中重复对指令 a ddq 的译码。它再次发 现对寄存 器釭a x 的冒险,就在 执行阶段中插入一个气泡 , 并在周期 7 中重复对指令 addq 的译码。实际上, 机器是动态地插入 三条 no p 指令,得到的执行 流类 似于 p r og l 的执行流(图4-43)\n在使用暂停技术来 解决数据冒险的过程中, 我们通过 动态地 产生和 pr o g l 流(图4- 4 3 ) 一样的 流水线流,有 效地执行了程序 pr o g 2 和 pr o g 4。为 p r o g 2 插入 1 个气泡, 为 p r o g 4 插入 3 个气泡, 与在第 2 条 ir mo v q 指令和 a d d q 指令之间有 3 条 n o p 指令, 有相同的效果。虽 然实现这一机制相当容易(参考家庭作 业 4. 53), 但是得到的性能并不很好。一条指令更新一个寄存器,紧跟其后的指令就使用被更新的寄存器,像这样的情况不胜枚举。这会导致流水线暂停长达三个周期,严重降低了整体的吞吐量。\n用转发来避免数据冒险\nPIPE - 的设计是在译码阶段从寄存器文件中读入源操作数 , 但是对这些源寄存器的写有可能要在写回阶段才能进行。与其暂停直到写完成,不如简单地将要写的值传到流水线寄存 器 E 作为源操作数 。图 4-49 用 pr og 2 周期 6 的流水线图 的扩展描述来说明 了这一策略。译码阶段逻辑发现 , 寄存器%r a x 是操作数 v a l B 的源寄存器 , 而在写端口 E 上还有一个对 %r a x 的未进行的写。它只要简单地将提供到端口 E 的数据字(信号 W_ va l E) 作为操作数 v a l B 的值,就能避免暂停。这种将结果值直接从一个流水线阶段传到较早阶段的技术称为数据转发(data forwarding, 或简称转发, 有时称为 旁路( bypassing ) ) 。它使得pr og 2 的指令能通过流水线而不需要任何暂停。数据转发需要在基本的硬件结构中增加一些额外的数据连接和控制逻辑。\n# prog2 10\nOxOOO: irmovq $10丛r dx w\nOxOOa: irmovq $3,%rax M\nOx014: nop w\nOx015: nop M w\nOx016: addq %rdx,%rax M w\nOx018: halt w\n图 4- 4 9 pr og2 使用 转发的 流水线化的执行。在周期 6 中,译 码阶段逻辑发现有在写回 阶段中\n对寄存器r% ax 未进行的写。它用这个 值, 而不是从寄存器文件中读出的值, 作为源\n操作数 va l B\n如图 4- 50 所示, 当访存阶段中有对寄存器未进行的写时,也 可以使用数据转发, 以避免程 序 p r o g 3 中的暂停。在周期 5 中, 译码阶段逻辑发 现, 在写回阶段中端口 E 上有对寄存器%r d x 未进行的 写, 以及在访存阶段中有会在端口 E 上对寄存器%r a x 未进行的写。它不会暂停直到这些写真正发生,而 是用写回阶段中的值(信号 W_ v a l E) 作为操作数 va­\nlA, 用访存阶段中的值(信号 M_ v a l E) 作为操作数 v a l B。\n为了充分利用数据转发技术,我们还可以将新计算出来的值从执行阶段传到译码阶段, 以避免程序 pr o g 4 所需要的暂停,如图 4-51 所示。在周期 4 中, 译码阶段逻辑发现在访存阶段中有对寄存器 %r dx 未进行的写,而 且执行阶段中 ALU 正在计算的值稍后也会写入寄存器%r a x 。 它 可以将访存阶段中的 值(信号M_ v a l E) 作为操作数 v a l A, 也可以将 ALU 的输出\n(信号 e _v a l E) 作为操作数va l B。注意, 使用 ALU 的输出不会造成任何时序间题 。译码阶段只要在时钟周期结束之前产生信号 va l A 和 va l B, 这样在时钟上升开始下一个周期时,流水线寄存器 E 就能装载来自译码阶段的值了。而在此之前 ALU 的输出巳经是合法的了。\n# prog3 2 3 4 5 6 7 8 9\n图 4-50 pr og 3 使用转发的流水线化的执行。在周期 5 中,译 码阶段逻辑发现有在写回阶段中对寄存器\n%r d x 未进行的写 , 以 及 在访存阶段中对寄存器%r a x 未进行的写。它用这些值, 而不是从寄存器文件中读出的值,作 为 v a l A 和 va l B 的 值\n# prog4 2 3 4 5 6 7 8\nOxOOO: irmovq $10,%rdx 厂户\nOxOOa : irmovq $3,%rax\nOx014: addq %rdx,%rax I F w\nOx016: halt s t) n i= M I W\n图 4-51 pro g 4 使 用 转发的 流水线化的执行。在周期 4 中,译 码 阶 段 逻辑 发现有在访存阶段中对寄存器%r d x 未进行的写, 还发现在执行阶段中正在计算寄存器% r a x 的 新 值 。 它 用 这些值,而 不 是 从 寄存 器文件中读出的值,作 为 va l A 和 va l B 的值\n程序 pr o g 2 ~ pr o g 4 中描述的转发技术的使用都是将 ALU 产生的以及其目标为写端 口 E 的值进行转发,其 实 也 可以 转发从内存中读出的以及其目标为写端口 M 的值。从访存阶段,我 们可以转发刚刚从数据内存中读出的值(信号 m_val M) 。从 写回阶段 , 我们可以转发对 端口 M 未进行的写(信号W_v al M)。这样一共就有五个不同的转发源Ce —v al E 、m v al M、M_v al E、W_v al M 和 W_v al E) , 以 及 两个 不同 的转 发 目的 ( v al A 和 v a l B) 。\nW_valE\nvalE valM dstE IdstMI 屯\n1W_valM\nm_valM\n访存\nM_Cnd M_valA\nstat licode\u0026rsquo; ~\n执行\n勺 , 1 stat licodel ifun\nW_valM W_valE\nifun I rA I rB\n指令内存\nM_valA W_valM\n图 4-52 流水线化的最终 实现一 PIPE 的硬件结构。添加的旁 路路径能够转发前 面三条指令的结果。这使得我们能够不暂停流水线 就处理大多数形式的 数据冒险\n图 4-49~ 图 4-51 的扩展图还表明译码阶段逻辑能够确定是使用来自寄 存器 文件的值, 还是要用转发过来的值。与每个要写回寄存器文件的值相关的是目的寄存器 ID 。逻辑会将这些 ID 与源寄存器 ID sr c A 和 sr c B 相比较,以 此来检测是否需要转发。可能有多个目的寄存 器 ID 与一个源 ID 相等。要解决这样的情况, 我们必须在各个转发源中建立起优先级关系。 在学习转发逻辑的详细设计时, 我们会讨论这个内容。\n图 4- 52 给出的是 P IP E 的结构, 它 是 P IP E — 的扩展,能 通过转发处理数据冒险。将这幅图与 P IP E 一的 结构(图 4- 41 ) 相比, 我们可以看到来自五个转发源的值反馈到译码阶段中两个标号为 \u0026quot; Sel + F w d A\u0026quot; 和 \u0026quot; F w d B\u0026quot; 的 块。标号为 \u0026quot; S e l + F w d A\u0026quot; 的 块 是 P IP E — 中标号为 \u0026quot; S elect A \u0026quot; 的块的功能 与转发逻辑的结合。它允许流水线寄存器 E 的 v a l A 为 巳增加的 程序计数器值 v a l P, 从寄存器文件 A 端口读出的值, 或者某个转发过来的值。标号为 \u0026quot; F w d B\u0026quot; 的块实现的是源操作数 v a l B 的转发逻辑。\n加载/使用数据冒险\n有一类 数据冒险不能单纯用转发来解决,因 为内存读在流水线发生的比较晚。图 4-53 举例说明了加 栽/使 用冒险 Cload / use hazard) , 其中一条指令(位于地址 Ox 028 的 mrmovq ) 从 内 存中读出寄存器%r a x 的 值 ,而 下 一 条 指 令(位于地址 Ox 0 32 的 a d d q ) 需 要 该 值 作 为源操作数。图的下部是 周期 7 和 8 的扩展说明, 在此假设所有的程序寄存器都初始化为 0。a d d q 指令在周期 7 中需要该寄存器的值,但 是 mrmovq 指令直到周期 8 才产生出这个值。为了从 mr mo vq “转发到\u0026quot; addq, 转发逻辑不得不将值送回到过去的时间!这显然是不可能的,我们必须找到其他 机制来解决这种形式的数据冒险。(位于地址 Ox Ol e 的 i r mo vq 指 令产 生的寄存器%r b x 的值,会被位 于地址 Ox 032 的 a ddq 指 令 使用 , 转发能够处理这种数据冒险。)\n# prog5 2 3 4 5 6 7 8 9 10 11\n图 4-53 加载/使用数据冒险的示例。addq 指令在周期 7 译码阶段中需要寄存器%r a x 的值。前 面的\nmr mo v q 指 令 在 周 期 8 访 存 阶段 中 读出这个寄存器的新值, 这对千 addq 指令来说太迟了\n如图 4-54 所示, 我们可以将暂停和转发结合起来, 避免加载/使用数据冒险 。这个需要修改控制逻辑 , 但是可以使用现有的旁路路径。当 mr mo v q 指令通过执行阶段时,流水 线控制逻辑发现译码阶段中的指令( a d d q ) 需要从内存中读出的结果。它会将译码阶段中的指令暂停一个周期,导 致执行阶段中插入一个气泡。如周期 8 的扩展说明所示 ,从 内存中读出的值可以从 访存阶段转发到译码阶段中的 a d d q 指令。寄存器%r b x 的值也可以从 访存阶段转发到译码阶段。就像流水线图 ,从 周 期 7 中标号为 \u0026quot; D\u0026quot; 的方框到周期 8 中标号为\u0026quot; E\u0026quot; 的方框的箭头表明的那样, 插入的 气泡代替了正常情况下本 来应该继续通过流水 线的 a d d q 指令。\n# prog5 2 3 4 5 6 7 8 9 10 11 12 OxOOO: irmovq $128,%rdx F D E M w OxOOa: irmovq $3,%rcx F D E M w Ox014 : rmmovq %rcx, 0(%rdx) F D E Ox01e : irmovq $10,%rbx F D E Ox028: mrmovq 0(%rdx),%rax # Load o/.rax F D bubble E M w\nOx032 : addq %rbx,r; 人 ax # Use %rax Ox034: halt\nF I E\nI M Iw\n图 4-54 用暂停来处 理加载/使用冒险。通过将 a ddq 指令在译码阶段暂停一个周期 , 就可以将 va lB\n的值从访存阶段中的 mr movq 指令转发到译码 阶段中的 a ddq 指令\n这种用暂停来处理加载/使用冒险的方法称为加载互 锁 Clo a d in t e rl o ck ) 。加载互锁和转发技术结合起来足以处理所有可能类型的数据冒险。因为只有加载互锁会降低流水线的 吞吐量,我 们几乎可以 实现每个时钟周期发 射一条新指令的吞吐量目标。\n避免控制冒险\n当处理器无法根据处于取指阶段的当前指令来确定下一条指令的地址时,就会出现控 制冒险。如同 在 4. 5. 4 节讨论过的, 在我们的流水 线化处理器中, 控制冒险只会发生在\nr e t 指令和跳转指 令。而且, 后一种情况只有在条件跳转方向预测错误时才会造成麻烦。在本小节中, 我们概括介 绍如何来处理这些冒险。作为对流水线控制更一般性讨论的一部\n分, 其详细实现将在 4. 5. 8 节给出。\n对于r e t 指令, 考虑下面的示例程序。这个程序是用汇编代码表示的 ,左 边是各个指令的地址,以供参考:\nOxOOO: irmovq stack,%rsp # OxOOa: call proc # Ox013: irmovq $10,%rdx # Ox01d: halt\nOx020 : . pos Ox20\nInitialize stack pointer Procedure call\nReturn point\nOx020: proc: Ox020 : ret\nOx021: rrmovq %rdx, %rbx Ox030: . pos Ox30\nOx030: stack:\n# proc:\n# Return immediately\n# Not executed\n# stack: Stack pointer\n图 4-55 给出了 我们希望流水线如何来处理r e t 指令。同前面的 流水线图一样, 这幅图展示了 流水线的活动, 时间从左向右增加。与前面不同的 是, 指令列出的顺序与它们在程序中出现的顺序并不相同,这是因为这个程序的控制流中指令并不是按线性顺序执行 的。看看指令的地 址就能看出它们在程序中的位置。\n# prog7\nOxOOO: irmovq Stack,%edx OxOOa: call proc\nOx020: ret\nbubble bubble bubble\nOx013: irmovq $10,%rdx # Return point\n2 3 4 5 6 7 8 9 10 11\n图 4-5 5 r e t 指令处理的简化视图 。当 r e t 经过译码 、执行和访存阶段 时,流 水线应该暂停,在处 理过程中插人三个气泡。一旦ret 指令到达写回阶段(周期7) , PC选择逻辑就会选择返回地址作 为指令的取指地址\n如这张 图所示 , 在周期 3 中取出r e t 指令, 并沿着流水线前 进,在周期 7 进入写回阶段。在 它经过译码、执行和访存阶段时, 流水线不能做任何有用的活动。我们只能在流水 线中插入三 个气泡。一旦r e 七指令到达写回阶段, P C 选择逻辑 就会将程序计数器设 为返回地址, 然后取指阶段就会取出位于返回点(地址Ox 013 ) 处的 ir mo v q 指令。\n要处理预测错误的分支,考虑下面这个用汇编代码表示的程序,左边是各个指令的地 址, 以供参考:\nOxOOO: Ox002:\nOxOOb: Ox015: Ox016: Ox016: Ox020:\nOx02a:\nxorq %rax,%rax jne target irmovq $1, %rax halt\ntarget:\nirmovq $2, %rdx irmovq $3, %rbx halt\n# Not taken\n# Fall through\n# Target\n# Target+!\n图 4-56 表明是如何处理这些指令的。同前面一样, 指令是按照它们进入流水线的顺\n序列出的,而不是按照它们出现在程序中的顺序。因为预测跳转指令会选择分支,所以周期 3 中会取出位于跳转目标处的指令, 而周期 4 中会取出该 指令后的那条指令。在周期 4, 分支逻辑发现不应该选择分支之前,已经取出了两条指令,它们不应该继续执行下去了。幸运的是,这两条指令都没有导致程序员可见的状态发生改变。只有到指令到达执行阶段时才会发 生那种情况, 在执行阶段中, 指令会改变条件码 。我们只要在下一个周期往译码和执行阶段中插入气泡,并同时取出跳转指令后面的指令,这样就能取消(有时也称为指令排除( in s t ru c t io n s q u a s h in g ) ) 那两条预 测错误的指令。这样一来, 两条预测错误的指令就会简单地从流水线中消失,因此不会对程序员可见的状态产生影响。唯一的缺点是两个时钟周期的指令处理能力被浪费了。\n# prog7\nOxOOO: xorq 1r儿\nax , %r ax\n2 3 4 5 6 7 8 9 10\nF I D I E I M I W\nOx002: jne target # Not taken Ox016: irmovl $2,:r人 d x # Target\nbubble\nOx020: irmovl $3,%rbx # Target+1 bubble\nOxOOb: irmovq $1,%rax # Fall through Ox015: halt\nF I I I M Iw\nE I M I W\n三 l w\n图 4-56 处理预测错误的分支指令。流水线预测会选择分支,所以开始取跳转目标处的指令。在周期 4 发现预测错误 之前, 已经取出了两条指令, 此时, 跳转指令正在通过执行阶段。在周期 5 中, 流水线往译码和执行阶段 中插入气泡, 取消了两条目标指令, 同时还取出跳转后面的那条指令\n对控制冒险的讨论表明,通过慎重考虑流水线的控制逻辑,控制冒险是可以被处理 的。在出现特殊情 况时 ,暂停 和往流水 线中插入气泡的技术可以 动态调整流水 线的流程。如同我们将在 4. 5. 8 节中讨论的一样 , 对基本时钟寄 存器设计的简单扩展就可以让我们暂停流水段,并向作为流水线控制逻辑一部分的流水线寄存器中插入气泡。\n4. 5. 6 异常处理\n正如第 8 章中将讨论的, 处理器中很多事情都 会导致异常控制流, 此时, 程序执行的正常流程被破 坏掉。异常可以由程序执行从内部产生,也 可以由某个外部信号从外部产生。我们的指令集体系结构包括三种不同的内部产生的异常: 1) h a lt 指令, 2 ) 有非法指令 和功能码组合的指令, 3 ) 取指或数据读写试图访问一个非法地址。一个更完整的处理器设计应该也能处理外部异常,例如当处理器收到一个网络接口收到新包的信号,或是一个 用户点击鼠标按钮的信号。正确处理异常是任何微处理器设计中很有挑战性的一方面。异 常可能出现在不可预测的时间,需要明确地中断通过处理器流水线的指令流。我们对这三 种内部异常的处理只是让你对正确发现和处理异常的真实复杂性略有了解。\n我们把导致异 常的指令称为异常指 令( e x c e p t in g in s t ru c t io n ) 。 在使用非法指令地址的情况中, 没有实际的异常指令, 但是想象在非法地址处有一种“虚拟指令” 会有所帮助。在简化的 ISA 模型中, 我们希望 当处理器遇到异常时 , 会停止, 设置适当的状态码,如图\n4-5 所示。看上去应该是到异常指令之前的所有指令都已经完成 , 而其后 的指令都不应该对程序员可见的状态产生任何影响 。在一个更完整的设 计中, 处理器会继续调用异常处理\n程序 ( e xce pt io n handler), 这是操作系统的一部分,但是实现异常处理的这部分超出了本书讲述的范围。\n在一个流水线化的系统中,异常处理包括一些细节问题。首先,可能同时有多条指 令会引 起异常。例如, 在一个流水线操作的周期内,取 指阶段中有 h a lt 指令, 而数据内存会报告访存阶段中的指令数据地址越界。我们必须确定处理器应该向操作系统报告 哪个异常。基本原则是:由流水线中最深的指令引起的异常,优先级最高。在上面那个 例子中,应该报告访存阶段中指令的地址越界。就机器语言程序来说,访存阶段中的指 令本来应该在取指阶段中的指令开始之前就结束的,所以,只应该向操作系统报告这个 异常。\n第二个细节问题是 , 当首先取出一条指令 , 开始执行 时, 导致了一个异常, 而后来由于分支预测错误,取消了该指令。下面就是一个程序示例的目标代码:\nOxOOO: 6300 xorq %rax,%rax\nOx002: 741600000000000000 I jne target # Not taken\nOxOOb: 30f00100000000000000 I irmovq $1, 1r儿\nOx015: 00 I halt\nOx016: I target:\nax # Fall through\nOx016: ff .byte OxFF # Invalid instruction code\n在这个程序中 , 流水线会预测选择分支, 因此它会取出并以一个值为 Ox FF 的字节作为指令(由汇编代码中 . b y t e 伪指令产生的)。译码阶段 会因此发现一个非法指令异常。稍后,流水线会 发现不应该选 择分支, 因此根本就不应该取出位于地址 Ox 01 6 的指令。流水线控制逻辑会取消该指令,但是我们想要避免出现异常。\n第三个细节问题的产生是因为流水线化的处理器会在不同的阶段更新系统状态的不同部分。有可能会出现这样的情况,一条指令导致了一个异常,它后面的指令在异常指令完 成之前改 变了部分状态。比如说 , 考虑下面的代码序列, 其中假设不允许用户程序访问 64 位范围的高端地址:\nirmovq $1,%rax\nxorq %rsp,%rsp pushq %rax\naddq %rax,%rax\n# Set stack pointer to O and CC to 100\n# Attempt to write to Oxfffffffffffffff8\n# (Should not be executed) Would set CC to 000\npus hq 指令导致一个地址异常, 因为减小 栈指针会导致它绕回到 Ox f f f f f f f f f f f f f f f8 。访存阶段中会发 现这个异常。在同一周期中, a d d q 指令处于执行阶段, 而它会将条件码 设置成新的值 。这就会违反异常指令之后的所有指令都不能 影响系统状态的要求 。\n一般地 , 通过在流水线结构中加入异常处理逻辑 , 我们既能 够从各个异常中做出正确的选择,也能 够避免出 现由千分 支预测 错误取出的指令造成的异常。这就是为什么我们会在每个流水 线寄存器中包括一个状态码 s t a 七(图 4-41 和图 4- 52 ) 。如果一条指令在其处理中于某个阶段 产生了一个异常, 这个状态字段就被设置成指示异常的种类。异常状态和该指令的其他信息一起沿着流水线传播,直到它到达写回阶段。在此,流水线控制逻辑发现 出现了异常,并停止执行。\n为了避免异 常指令之后的指令更新任何程序员可见的状 态, 当处千访存或写回阶段中的指令导致 异常时 , 流水线控制逻辑必须禁 止更新条件码寄存器或是数据内存。在上 面的示例程序 中, 控制逻辑会发现访存阶段中的 p u s hq 导致了异常, 因此应该禁止 a d d q 指令更新条件码寄存器。\n让我们来看看这种处理异常的方法是怎样解决刚才提到的那些细节问题的。当流水线 中有一个或多个阶段出现异常时,信息只是简单地存放在流水线寄存器的状态字段中。异 常事件不会对流水线中的指令流有任何影响,除了会禁止流水线中后面的指令更新程序员 可见的状态(条件码寄存器和内存),直到异常指令到达最后的流水线阶段。因为指令到达写回阶段的顺序与它们在非流水线化的处理器中执行的顺序相同,所以我们可以保证第一 条遇到异常的指令会第一个到达写回阶段,此 时程序执行会停止, 流水 线寄存器 W 中的状态码会被记录为程序状态。如果取出了某条指令,过后又取消了,那么所有关于这条指 令的异常状态信息也都会被取消。所有导致异常的指令后面的指令都不能改变程序员可见 的状态。携带指令的异常状态以及所有其他信息通过流水线的简单原则是处理异常的简单 而可靠的机制。\n5 . 7 P IPE 各 阶 段 的 实现\n现在我们已 经创建了 PIPE 的整体结构, PIP E 是我们使用了转发技术的流水线化的Y8 6- 64 处理器。它使用了一组与前 面顺 序设 计相同的硬件单元, 另外增加了一些流水线寄存器、一些重新配置了的逻辑块,以及增加的流水线控制逻辑。在本节中,我们将浏览 各个逻辑块的设计,而 将流水 线控制逻辑的设计放到下一节中介绍。许多逻辑块与\u0026rsquo; SEQ 和 SEQ+ 中相应部件完全相同, 除了我们必须 从来自不 同流水线寄存器(用大写的流水线寄存器的名字作为前缀)或来自各个阶段计算(用小写的阶段名字的第一个字母作为前缀) 的信号中选择适当的值。\n作为一个示 例,比较 一下 SEQ 中产生 sr c A 信号 的 逻辑的 HCL 代码与 PIPE 中相应的代码:\n# Code from SEQ\nword srcA = [\nicode in { IRRMOVQ, IRMMOVQ, IOPQ, IPUSHQ } : rA; icode in { IPOPQ, IRET} : RRSP;\n1 : RNONE; # Don\u0026rsquo;t need register\n];\n# Code from PIPE\nword d_srcA = [\nD_icode in { IRRMOVQ, IRMMOVQ, IOPQ, IPUSHQ } : D_rA; D_icode in { IPOPQ, IRET} : RRSP;\n1 : RNONE; # Don\u0026rsquo;t need register\n];\n它们的不同之处只在于 PIPE 信号都加上了前缀: \u0026quot; D_ \u0026quot; 表示源值 , 以表明信号是来自流水线 寄存器 D , 而 \u0026quot; ct_\u0026quot; 表示结果值, 以表明它是在译码阶段中产生的。为了避免重复, 我们在此就不列出那些与 SEQ 中代码只有名字前缀不同的块的 HCL 代码。网 络旁注ARCH : HCL 中列出了完整的 PIPE 的 HCL 代码。\nPC 选择和取指阶段\n图 4-57 提供了 PIPE 取指阶段逻辑的一 个详细描述。像前面讨论过的那样, 这个阶段必 须选择程序计数 器的当前值 , 并且预测下一个 PC 值。用于从内存中读取指令和抽取不同指令字段的 硬件单元与 SEQ 中考虑的那些一样(参见4. 3. 4 节中的取指阶段)。\n溺 s tat licodel ifun I rA I rB\nvalC 陑\nM_icode\nM_Cnd\nj M_valA\nj I W_icode\n. : Need\n, \u0026hellip;\u0026hellip;\n..勺., • ;. i\n, _:: PC 、 :\n::\n令 i valC ;\nPC j\nNeed \u0026rsquo; 增加 i re gids;;.-,\u0026quot; i\n厂令内存罕 气 , I I\n亏 L I\n图 4-5 7 PI PE 的 PC 选择 和取指逻辑。在一个周期的时间限 制内 , 处理器只能预测下 一条指令的 地址\nPC 选择逻辑从 三个程序计数 器源中 进行选择。当一条预测错误的分支进入访存阶段时, 会从流水线寄存器 M ( 信号 M_ v a l A) 中读出该指令 v a l P 的值(指明下一条指令的地址)? 当r 吐 指令进入写 回阶段时, 会从流水线寄存器 W ( 信号 W_ v a l M) 中读出返回地址。其他情况会使用存 放在流水线 寄存器 F ( 信号 F_p r e d PC) 中的 P C 的预测值:\nword f_pc = [\n# Mispre\u0026rsquo;.iicted branch. Fetch at incremented PC M_icode == IJXX \u0026amp;\u0026amp; !M_Cnd: M_valA;\n# Completion of RET instruction W_icode == IRET: W_valM;\n# Default: Use predicted value of PC\n1 : F_predPC;\n];\n当取出的 指令为函数调用或跳转时 , P C 预测逻辑会选择 v a l e , 否则就会选择 v a l P: word f_predPC = [\nf_icode in { IJXX, ICALL} : f_valC;\n1 : f_valP;\n];\n标号为 \u0026quot; I n s t r valid \u0026quot; 、\u0026quot; N eed r egid s\u0026quot; 和 \u0026quot; N eed va!C\u0026quot; 的逻辑块和 SEQ 中的一样,使用了适当命名的源信号。\n同 SEQ 中不一样,我们 必须 将指令状态的计算分成两个部分。在取指阶段, 可以测试由千指令 地址越界引 起的内存错误,还 可以发现非法指令或 h a lt 指令。必须推迟到访\n存阶段才能发现非法数据地址。\n练习题 4. 30 写 出信号 f _ s t a t 的 H CL 代码 , 提供取出的 指令的临 时状 态。\n译码和写回阶段\n图 4-58 是 PIP E 的译码和写回逻辑的详细说明。标号为 \u0026quot; cts t E \u0026quot; 、 \u0026quot; cts t M\u0026quot; 、 \u0026quot; s r c A\u0026quot; 和 \u0026quot; sr c B \u0026quot; 的块非常类似千它们在 SEQ 的实现中的相应部件。我们观察到, 提供给写端口的寄存器 ID 来自于写回阶段(信号 W_ d s t E 和 W_ d s t M) , 而不是来自于译码阶段。这是因为我们希望进行写的目的寄存器是由写回阶段中的指令指定的。\ne_dstE\n图 4-58 PIPE 的译码和写回阶段逻辑。没有指令既需要 val P 又需要来自寄存器端口 A 中读出的值,因此对后面的阶段来说 , 这两者可以合并为信号 val A。标号为 \u0026quot; Sel + F wd A \u0026quot; 的块执行该任 务,并实现源操作数 val A 的转发逻辑。标号为 \u0026quot; F wd B\u0026quot; 的块实现源操作数 val B 的转发逻辑。寄存器写的位置是由 来自写回 阶段的 d s t E 和 ds t M 信号指定 的, 而不是来自千译 码阶段, 因为它 要写的是当前正在写回阶段中的指令的结果\n练习题 4. 31 译码 阶段中标号为 \u0026quot; d s t E \u0026quot; 的块根据来 自 流水线寄 存器 D 中 取出的指令的各 个 字 段, 产生寄存器文件 E 端 口 的寄存器 ID。在 PIP E 的 H CL 描述 中, 得到的信号命名 为 d _ d s t E 。 根据 S EQ 信号 d s 七E 的 H CL 描述, 写 出这 个信号 的 H CL 代码。(参考 4. 3. 4 节 中的译码 阶段。)目前还不 用 关心 实现 条件传 送的逻辑。\n这个阶段的复杂性主要是跟转发逻辑相关。就像前面提到的那样,标 号 为 \u0026quot; Sel + Fwd\nA\u0026quot; 的块扮演两个角色。它为后 面的阶段将 v a l P 信号合并到 v a l A 信号,这 样 可以减少流水 线 寄存器中状态的数量。它还实现了源操作数 v a l A 的转发逻辑。\n合并信号 v a l A 和 v a l P 的依据是,只 有 c a l l 和跳转指令在后面的 阶段中需要 v a l P 的值, 而这些指令并不需要从寄存器文件 A 端口中读出的值。这个选择是由该阶段的 i code 信号来控制的。当信号 D_ i c o d e 与 c a l l 或 j XX 的 指令代码相匹配时 , 这个块就会选择 D_ v a l P 作为它 的输出。\n5. 5 节中提到有 5 个不同的转发源, 每个都有一个数 据字和一个目的寄存器 ID : 数据字 寄存器 ID 源描述 e val E e ds t E ALU 输 出 m val M M ds t M 内 存 输 出 M val E M dstE 访存阶段中对端口 E 未进 行 的 写 W val M W dstM 写 回阶 段中 对 端 口 M 未进行的写 W val E W dstE 写回阶段中对端口 E 未 进 行的 写 如果不满足任何 转发条件, 这个块就应该选择 d—r v a l A 作为它的输出,也 就是从寄存器端 口 A 中读出的值。\n综上所述 , 我们得 到以下流水线寄存器 E 的 v a l A 新值的 H CL 描述:\nword d_val A = [\nD_icode in { ICALL, IJXX} : D_valP; # Use incremented PC d_srcA == e_dstE: e_valE; # Forward valE from execute d_srcA == M_dstM: m_valM; # Forward valM from memory d_srcA == M_dstE: M_valE; # Forward valE from memory d_srcA == W_dstM: W_valM; # Forward valM from write back d_srcA == W_dstE: W_va l E; # Forward valE from write back\n1 : d_rvalA; # Use value read from register file\n];\n上述 H CL 代码中赋予这 5 个转发源的优先 级是非常重要的。这种优先级是由 HCL 代码中检测 5 个目的寄存器 ID 的顺序来确定的。如果选择了其他任何顺序, 对某些程序来说, 流水线就会出错。图 4-59 给出了一个程序示例,要 求对执行和访存阶段中的转发源设置正确的优先级。在这个程序中 , 前两条指令写寄存器%r dx , 而第三条指令用这个寄存器作为它的 惊操作数。当指令r r mo vq 在周期 4 到达译码阶段时, 转发逻辑必须在两个都以该 源寄存 器为目的的值中选择一个。它应该选择哪一个呢?为了设定优先级,我们必须考虑当一次执行一条指令时 , 机器语言程序 的行为。第一条 i rmovq 指令会将寄存器%r dx 设 为 10 , 第二条 i rmovq 指令会将之设为 3, 然后r rmovq 指令会从%r dx 中读出 3。为了模 拟这种行为,流 水线化的实现应 该总是给处于最早流水线阶段中的转 发源以较高的优先级, 因为它保 持着程序序列中设置该寄存器的最近的指令。因此,上述 H CL 代码中的逻辑首先会检测执行阶段中的转发源, 然后是访存阶段 , 最后才是写回阶段。只有指令 pop q %r s p 会关心在访存或写回阶段中的两个源之间的转发优先 级, 因为只有这条指令能同时写两个寄存器。\n可 练习题 4. 32 假设 d v a l A 的 H C L 代码中第三和 第四种 情况(来 自 访存阶段的 两个 转发源)的顺序是 反过来的。 请描 述下列程序中 r r mo v q 指令(第5 行)造成的行 为 :\nirmovq $5, %rdx irmovq $0x100,%rsp rmmovq %rdx,0(%rsp) popq %rsp\nrrmovq %rsp,%rax\n# prog8 2 3 4 5 6 7 8\nOxOOO: irmovq $10,%rdx I F I D OxOOa: irmovq $3,%rdx\nOx014: rrmovq %rdx,%rax Ox016: halt\n归l w\n图 4-59 转发优先级的说明。在周 期 4 中,%r dx 的 值既可以从执行阶段也可以从访存阶段得到. 转发逻辑应该选择执行阶段中的值,因为它代表最近产生的该寄存器的值\n练习题 4 . 33 假设 d _ v a l A的 HCL 代码中第五和第 六种情况(来自写 回 阶段的 两个转发源)的顺序是反过来的。写 出 一个 会运行错误的 Y86-64 程 序。 请描述错误 是如何发生的,以及它对程序行为的影响。\n练习题 4 . 34 根据提供到 流水线寄存器 E 的源操作数 v a l B 的值, 写 出 信号 d _ v a lB\n的 HCL 代码。\n写 回 阶 段 的 一 小 部 分 是 保 持 不 变 的 。 如图 4- 5 2 所示, 整 个 处 理 器的状态 St a t 是一个 块 根据流水线寄存器 W 中的状态值计算出来的。回想一下 4. 1. 1 节 , 状 态 码 应该指明 是 正 常 操 作 ( AOK) , 还是三种异常条件中的一种。由于流水线寄存器 W 保存着最近完成的指令的状态,很自然地要用这个值来表示整个处理器状态。唯一要考虑的特殊情况 是当写回阶段有气泡时。这是正常操作的一部分,因此对于这种情况,我们也希望状态 码是 AOK:\nword Stat = [\nW_stat == SBUB: SAOK;\n1 : W_stat;\n];\n3 . 执行阶段\n图 4-60 展现的是 PIPE 执行阶段的逻辑。这些硬件单元和逻辑块同 SEQ 中的相同, 使 用 的 信 号 做 适当的重命名。我们可以看到信号 e —v a l E 和 e _ d s t E 作为转发源, 指向译 码 阶 段 。 一 个 区 别 是 标 号 为 \u0026quot; Se t CC\u0026quot; 的逻辑以信号 m_ s 七a t 和 W_ s t a t 作 为输入, 这个; 逻辑决定了是否要更新条件码。这些信号被用来检查一条导致异常的指令正在通过后面的 ' 流水线阶段的情况,因 此, 任 何 对 条 件 码 的 更 新 都 会 被 禁止。这部分设计在 4. 5. 8 节中; 讨论。\ne_valE e_dstE\n图 4-60 PIPE 的执行阶段逻辑 。这一部分的设 计与 SEQ 实现中的 逻辑非常相似\n练习题 4. 35 d _ va l A 的 HCL 代码 中的 第 二种 情况 使用 了 信号 e _d s t E , 来判断是否要选 择 ALU 的输出 e _v a l E 作为 转发源。 假设我们 用 E_ d s t E , 也就是流水线寄存器\nE 中的 目的寄存器 ID , 来作为这个选择。写出一个采用这个修改过的转发逻辑就会产生错 误结果的 Y86-64 程序。\n4 访存阶段\n图 4-61 是 P IP E 的 访 存阶段逻辑。将这个逻辑与 S E Q 的访存阶段(图4-30 ) 相比较 ,我们看到,正 如 前 面提到的那样, P IP E 中 没 有 SEQ 中标号为 \u0026quot; Da ta\u0026quot; 的块。这个块是用来在数 据源 v a l P( 对 c a l l 指令来说)和v a l A 中 进 行 选择的, 但 是 这个选择现在由译码阶段中标 号为 \u0026quot; S el + Fwd A\u0026quot; 的块来执行。这个阶段中的其他块都和 SEQ 中相应的部件相同,采 用的信号做适当的重命名。在图中,你 还 可以看到许多流水线寄存器 M 和 W 中的值作为转发和流水线控制逻辑的一部分,提供给电路中其他部分。\nW_valE W_valM W_dstE W_ds!M\nm_valM\nM_dstE M_dstM\nM_valA M_valE\n图 4-61 PIPE 的访存 阶段逻辑。许多从流水线寄存器 M 和 W 来的信号被传递到较早的阶段, 以提供写回的结果、指令地址以及转发的结果\n练习题 4. 36 在这个阶段中,通过检查数据内存的非法地址情况,我们能够完成状 态 码 S t a t 的计算。 写 出信号 m_ s t a 七的 H CL 代码。\n5. 8 流水线控制逻辑\n现在准备创建流水线控 制逻辑, 完成我 们的 PIP E 设计。这个逻辑必须处理下面 4 种控制情况,这些情况是其他机制(例如数据转发和分支预测)不能处理的:\n加载/使用冒险: 在一条从内存 中读出一个值的指令和一条使用该值的指令之间,流水线必须暂停一个周期。\n处理r e t : 流水线必须暂停直到 r 过 指令到达写回阶段。\n预测错误的分支:在分支逻辑发现不应该选择分支之前,分支目标处的几条指令巳经进入流水线了。必须取消这些指令,并从跳转指令后面的那条指令开始取指。\n异常:当一条指令导致异常,我们想要禁止后面的指令更新程序员可见的状态,并且在异常指令到达写回阶段时,停止执行。\n我们先浏览每种情况所期望的行为,然后再设计处理这些情况的控制逻辑。\n1 特殊控制情况所期望的处理\n在 4. 5. 5 节中, 我们已 经描述了对加载/使用冒险所期望的流水线操作, 如图 4-5 4 所示。只有 mr mo v q 和 p o p q 指令会从内存中读数据。当这两条指令中的任一条处千执行阶 段,并且需要该目的寄存器的指令正处在译码阶段时,我们要将第二条指令阻塞在译码阶 段, 并在下一个周期往执行阶段中插入一个气泡。此后 , 转发逻辑 会解决这个数据冒险。可以将流水线寄存器 D 保持为固定状 态, 从而将一个指令阻 塞在译码阶段。这样做还可以保证流水线 寄存器 F 保持为固定状态, 由此下一条指令会被再取一次。总之, 实现这个流 水线流需要发现冒险的 清况 , 保持流水线 寄存器 F 和 D 固定不变,并 且在执 行阶段中插入气泡。\n对r e 七指令的处理,我们已经 在 4. 5. 5 节中描述 了所需的流水线操作 。流水线要停顿\n3 个时钟周期, 直到r e 七指令经过访存阶段, 读出返回地址。通过图 4-55 中下面程序的处理的简化流水线图,说明了这种情况:\nOxOOO: irmovq stack,%rsp # Initialize stack pointer OxOOa: call proc # Procedure call Ox013: irmovq $10,%rdx # Return point Ox01d: halt Ox020: . pos Ox20\nOx020: proc: # proc:\nOx020: ret # Return immediately Ox021: rrmovq %rdx,%rbx # Not executed Ox030: . pos Ox30\nOx030: stack: # stack: Stack pointer\n图4-62 是示例程序中 r e t 指令的实际处理过程。在此可以看到, 没有办法在流水线的取指阶段中插入气泡。每个周期 , 取指阶段从指令内存中读出一条指令。看看 4. 5. 7 节中实现 P C 预测逻辑的 HCL 代码,我 们可以 看到, 对r e 七指令来说, PC 的 新值被预测成valP, 也就是下一条指令的地址。在我们的示 例程序中, 这个地址会是 Ox 021 , 即 r e t 后面r r mo v q 指令的地址。对这个例子来说, 这种预测是不对的, 即使对大部分情况来说, 也是不对的, 但是在设计中, 我们并 不试图正确预测返 回地址。取指阶段会暂停 3 个时钟\n周期, 导致取出r rmo v q 指令, 但是在译码阶段就被替换成了气泡。这个过程在图 4- 6 2 中的表示为 , 3 个取指用箭头指 向下面的气 泡,气 泡会经过剩下的流水线阶段。最后, 在周期7 取出 i r mo v q 指令。比较图 4- 62 和图 4- 55 , 可以看到,我们的实现达到了期望的效果, 只不过连续 3 个周期取出了不正确的指令。\n# prog6\nOxOOO: irmovq St ack , %r s p OxOOa: call proc\nOx020: ret\n2 3 4 5 6 7 8 9 10 11\nMIW # F I D I E I M I W\nF ( D J E I M I W\nOx021: rrmovq %r dx , 1r儿 bubble\nb x # Not executed\nF\n曰E I M I w\nOx021: rrmovq %r d x , %r b x # Not executed bubble\nOx021 : r r movq %r d x , %r b x # Not e xecut ed\nF\nD I E I M I w\nF\n图 4-62 r e t 指令的详细处 理过程。取指阶段反复取出r e t 指令后面的r r movq 指 令 ,但 是 流 水 线 控 制逻辑在译码阶段中插入气泡,而 不 是 让 r r movq 指 令 继 续 下 去 。 由 此 得 到 的 行 为与图 4-55 所示的等价\n当分支预测错误发 生时, 我们已 经在 4. 5. 5 节中描述了所需的流水线操作, 并用图 4-\n56 进行了说明 。当跳转指令到达执行 阶段时就可以检测到预测错误。然后在下一个时钟周期, 控制逻辑就 会在译码和执行段插入气泡 , 取消两条不正 确的已取指令。在同一个时钟周期 , 流水线将正确的指令 读取到取指阶段。\n对于导致异常的指令, 我们必须使 流水线化的实现符合期望的 ISA 行为, 也就是在前面所有的指令结束 前, 后面的指令不能影响程 序的状态。一些因素会使得想达到这些 效果比较麻烦: 1 ) 异常在程序执行 的两个不同阶段(取指和访存)被发现的, 2 ) 程序状态在三个不同阶段(执行、访存和写回)被更新。\n在我们的 阶段设计中, 每个流水线寄存器中会包含一个状态码 s t a t , 随着每条指令经过流水 线阶段, 它会记录指令的 状态。当异常发生时, 我们将这个信息作为指令状态的一部分记录下来, 并且继续取指、译码和执行指令, 就好像什么都没有出错似的。当异常指令到达访存阶段时,我们会采取措施防止后面的指令修改程序员可见的状态: 1 ) 禁止执行阶段中的指令设置条件码, 2 ) 向内存阶段中插入气泡, 以禁止向数据内存中写入, 3 ) 当写回阶段中有异常指令时,暂 停 写回阶段, 因而暂停了流水线。\n图 4- 63 中的流水线图说明了我们的流水线控制如何处理导致异常的指令后面跟着一条会改变条件码的指令的 情况。在周期 6 , p u s h q 指令到达访存 阶段, 产生一个内存错误。在同一个周期, 执行阶段中的 addq 指令产生新的条件码的 值。当访存或者写回阶段中有异常指令时(通过检查信号m_ s t a t 和 W_ s t a t , 然后将信号 s e t _ c c 设置为 0) , 禁止设置条件码。在图 4- 63 的例子中, 我们还可以 看到既 向访存阶段插入了气泡, 也在写 回阶段暂停了异常指令一- p u s hq 指令在写回阶段保持暂停, 后面的指令都没有通过 执行阶段。\n对状态信号流水 线化, 控制条件码的设置, 以及控制流水线阶段一 将这些结合起\n来,我们实现了对异常的期望的行为:异常指令之前的指令都完成了,而后面的指令对程\n序员可见的状态都没有影响。\nMlwlwlwl ..·@\n图 4- 63 处理非法内 存引用异常。在周期 6 , pu shq 指令的非法内存引用导 致禁止更新 条件码。流水线开始往访存阶段插入气 泡, 并在写回 阶段暂停 异常指令\n2 发现特殊控 制条件\n图 4-64 总结了需要特殊流水线控制的条件。它给出的表达式 描述了在哪些条件下会出现这三种特殊情况 。一些简单的组合逻辑块实现了 这些表达式 , 为了在时钟上升开始下一个周期时控制流水线寄存器的活动, 这些块 必须在时钟周期 结束之前产生出结果。在- 个时钟周期内,流 水线寄存器 D、E 和 M 分别保持着处千译码、执行和访存阶段中的指令的 状态。在到达时钟 周期末尾时 , 信号 d _ sr c A 和 d _ sr c B 会被设置为译码阶段中指令的源操作数的寄存器 ID。当 r e t 指令通过流水线时, 要想发现它,只 要检查译码、执行和访 存阶段中指令的指令码。发现加载/使用冒险要检查执行阶段中的指令类型( mr mo v q 或 p op q ) \u0026rsquo; 并把它的目的寄存器与译码阶段中 指令的源寄存器相比较。当跳转指令在执行阶段时, 流水线控制逻辑应该能发 现预测错误 的分支, 这样当指令进入访存阶段时, 它就能设 置从错 误预测中恢复所需要的条件。当跳转指 令处于执行阶段时, 信号 e _ Cn d 指明是否要选择分支。通过 检查访存和写回阶段中的指令状态值, 就能发现异常指令。对于访存阶段,我 们使用在这个阶段中计算出来的信号 m _ s t a t , 而不是使用流水线寄存器的 M s t a t 。这个内部信号包含着可能的数 据内存地址错误。\n条件 触发条件\n处理 r e t\n加载/使用冒险预测错误的分支异常\nIRETE {D_icode, E_ic ode, M _icode}\nE_icodeE { IMRM OVL, IPOPL} \u0026amp; \u0026amp; E_dstME { d_sr cA, d_srcB }\nE_icode= IJXX \u0026amp; \u0026amp; ! e_Cnd\nm_statE { SADR,SINS,SHLT} I IW_statE { SADR, SINS,SHLT}\n图 4- 64 流水线控制逻辑的检查条件。四种不同的条件要求改变流水线, 暂停流水线或者取 消已经部分执行的指 令\n流水线控制机制\n图 4-65 是一些低级机制, 它们使得流水线控制逻辑能将指令阻塞在流水线寄存器中,\n或是往流 水线中插入一个气 泡。这些机制包括对 4. 2. 5 节中描述的基本时钟寄存器的小扩展。假 设每个流水线 寄存器有两个控制输入:暂 停( stall) 和气泡C bubble ) 。这些信号的设\n置决定 了当时钟上升时该 如何更新流水线寄存器。在正常操作下(图4-65a ) , 这两个输入都设为 o, 使得寄存器加载它的输入作为新的状 态。当暂停信号设为 1 时(图4-65 b) , 禁止\n更新状态。相反,寄存器会保持它以前的状态。这使得它可以将指令阻塞在某个流水线阶 段中。 当气泡信号设 置为 1 时(图4-65c) , 寄存器状态 会设置成某个固定 的复位 配置 ( res et\nconfiguration), 得到一个等效于 no p 指令的状态。一个流水线寄存器的复位配置的 O、 1 模式是由流 水线寄存器中字段的集合决定的。例如,要 往流水线寄存器 D 中 插入一个气泡, 我们要将 i c o d e 字段设置为常数值 IN OP( 图 4-26 ) 。要往流水线寄存器 E 中插入一个气泡,我们要 将 i c o d e 字段设为 I NOP, 并将 d s t E、d s t M、sr c A 和 sr cB 字段设为常数\nRNONE。 确定复 位配置是 硬件设计师在设计流水线寄存器时的任务之一。在此我们不讨论细节。 我们会将气泡 和暂停信号都设为 1 看成是出错。\n状态=x 状态=y\n门\n一呻 时钟上升沿 -+\n工 # 正常 状态=x 状态==x\n门 输出=x\n工\nb ) 暂停\n状态=x 状态=nop\n门句 # 工\nb ) 气泡\n图 4-65 附加的流水线寄存器操作。a ) 在正常条件下 , 当时钟上升时 , 寄存器的状态和输出 被设置成输入的值; b ) 当运行在暂停模式中时,状 态保持为先前 的值不变; c ) 当运行在气泡模式中时, 会用 nop 操作的状态覆盖 当前状态\n图 4-66 中的表给出了各个 流水线寄存器在三种特殊情况下应该采取的行动。对每种情况的处理都是对流水线寄存器正常、暂停和气泡操作的某个组合。在时序方面,流水线 寄存器的暂停和气泡控制信号是由组合逻辑块产生的。当时钟上升时,这些值必须是合法 的,使得当下一个时钟周期开始时,每个流水线寄存器要么加载,要么暂停,要么产生气\n泡。有了这个对流水线寄存器设计的小扩展,我们就能用组合逻辑、时钟寄存器和随机访问存储器这样的基本构建块,来实现一个完整的、包括所有控制的流水线。\n流水线寄存器 条件 处理r et F 暂停 D 气泡 E 正常 M 正常 w 正常 加载/使用冒险 暂停 暂停 气泡 正常 正常 预测错误的分支 正常 气泡 气泡 正常 正常 图 4-66 流水线控制逻辑的动作。不同的条件需要改变流水线流,或者会暂停流水线, 或者会取消部分已 执行的指令\n4 控制条件的组合\n到目前为止,在我们对特殊流水线控制条件的讨论中,假设在任意一个时钟周期内, 最多只能出现一个特殊情况。在设计系统时,一 个 常 见的缺陷是不能处理同时出现多个特殊情况的 情形 。现在来分析这些可能性。我们不需要担心多个程序异常的组合情况, 因为已经很小心地设计了异常处理机制, 它 能 够 考虑流水线中其他指令的情况。图 4-67 画出了导致其他三种特殊控制条件的流水线状态。图中所示的是译码、执行和访存阶段的块。 暗色的方框代表要出现这种条件必须要满足的特别限制。加载/使用冒险要求执行阶段中 的 指令 将一 个值从内存读到寄存器中,同 时 译 码 阶 段 中 的 指 令 要 以 该 寄 存 器 作 为源操作数 。预测 错 误的 分支要求 执 行阶段中的指令是一个跳转指令。对r 琴 来说有三种可能的情况一 指令可以处在译码、执行或访存阶段。当r e t 指令通过流水线时,前 面的流水线阶段都是气泡。\n加载/使用 预测错误\nret 1 ret 2 ret 3\n五勹巨勹二三丿``\n图 4- 67 特殊控制条件的流水线状态。图中标明的两对情况可能同时出现\n从这些图中我们可以看出, 大 多 数 控 制 条 件 是 互 斥的。例如,不 可能同时既有加载/ 使用 冒险又有预测错误的分支,因 为加载/使用冒险要求执行 阶段中是加载指令C mr movq 或 p opq )\u0026rsquo; 而 预 测 错 误 的 分 支要 求 执 行 阶 段中是一条跳转指令。类似地, 第二个和第三个r e t 组 合 也 不 可 能 与 加 载/使用冒险或预测错误的分支同时出现。只有用箭头标明的两种组合可能同时出现。\n组合 A 中执行阶段中有一条不选择分支的跳转指令,而 译 码 阶 段 中 有 一 条 r e t 指令。出 现这种组合要求r e t 位于不选择分支的目标处。流水线控制逻辑应该发现分支预测错误 ,因 此 要 取 消 r e t 指令。\n练习题 4. 37 写 一个 Y86-64 汇编 语言程序, 它 能 导致出现组合 A 的情 况, 并判断控制逻辑是否处理正确。\n合并组合 A 条件的控制动作(图4-66) , 我们得到以下流水线控制动作(假设气泡或暂\n停会覆盖正常的情况):\n;!1\n也就是说 , 组合情况 A 的处理与预测错误的分支相似,只 不过在取指阶段 是暂停。幸运的是,在下一个周期 , P C 选择逻辑会选择跳转后面那条指令的地址, 而不是预测的程序计数器值, 所以流水线寄存器 F 发生了什么是没有关系的。因此我们得出 结论,流 水线能正确处理这种组合悄况。\n组合 B 包括一个加载/使用 冒险, 其中加载指令设置寄存器%r s p , 然后r e t 指令用这个寄存器 作为源操作数 , 因为它必须从栈中弹出返回地址。流水线控制逻辑应该 将r e t 指令阻塞在译码阶段。\n练习题 4. 38 写 一个 Y8 6- 64 汇编语 言程 序, 它能导致 出现 组合 B 的 情况 , 如果 流水线运行 正确,以 ha l t 指令 结束。\n合并组 合 B 条件的控制动作(图4-66 ) , 我们得到以下流水线控制动作:\n流水线寄存器 条件 处理 re t F 暂停 D 气泡 E 正常 M 正常 w 正常 预测错误的分支 暂停 暂停 气泡 正常 正常 组合 暂停 气泡+暂停 气泡 正常 正常 期望的情况 暂停 暂停 气泡 正常 正常 如果同时触 发两组动作 , 控制逻辑 会试图暂停r e t 指令来避免加载/使用冒险, 同时又会因 为r e t 指令而往译码阶段中插入一个气泡。显然 , 我们不希望流水线同时执行这两组动作。相 反, 我们希望它只采取针对加载/使用冒险的动作。处理 r e t 指令的动作应该推迟一个周期。\n这些分析 表明组合 B 需要特殊处理。实际上, PIPE 控制逻辑原来的实现并没有正确处理这种组合情况。即使设计已经通过了许多模拟测试,它还是有细节问题,只有通过刚 才那样的分 析才能发现。当执行一个含有组合 B 的程序时 , 控制逻辑会 将流水线寄存器 D 的气泡和 暂停信号都置为 1。这个例子表明了系统分析的重要性。只运行 正常的程序是很难发现 这个问题的。如果没有发现这个问题, 流水线就不能忠实地实 现 ISA 的行为。\n5 控制逻 辑实现\n, 图 4-68 是流水线控制逻辑的整体结构。根据来自流水线寄存器和流水线阶段的信号,控制逻辑产生流水线寄存器的暂停和气泡控制信号,同时也决定是否要更新条件码寄存器。我们可\n、 以将图 4-64 的发现条件和图4-66 的动作结合起来,产生各个流水线控制信号的HCL 描述。\n' 遇到加 载/使用冒险或r e t 指令, 流水线寄存器 F 必须暂停:\nbool F_stall \u0026ldquo;\u0026rsquo;\n# Conditions for a load/use hazard E_icode in { IMRMOVQ, IPOPQ} \u0026amp;\u0026amp; E_dstM in { d_srcA, d_srcB} II\n# Stalling at fetch while ret passes through pipeline IRET in { D_icode, E_icode, M_icode };\n图 4-68 PIPE 流水线控制逻辑 。这个逻辑覆盖了通过 流水线的正常指令流 ,以处理特殊条件, 例如过程返回、预测错误的分支、加载/使用冒险和程序异常\n练习题 4. 39 写 出 P IP E 实现中信 号 D_ s t a l l 的 HCL 代码。\n遇到预测错误的分支或 r e t 指令 , 流水线寄存器 D 必须 设 置为气泡。不过, 正如前面一节中的分析所示, 当遇到加载/使用冒险和r e t 指令组合时,不 应该插入气泡:\nbool D_bubble =\n# Mispredicted branch\n(E_icode == IJXX \u0026amp;\u0026amp; !e_Cnd) I I\n# Stalling at fetch while ret passes through pipeline\n# but not condition for a load/use hazard\n!(E_icode in { IMRMOVQ, IPOPQ} \u0026amp;\u0026amp; E_dstM in { d_srcA, d_srcB }) \u0026amp;\u0026amp;\nIRET in { D_icode, E_icode, M_icode };\n练习题 4. 40 写 出 P I P E 实现中信 号 E_ b ub b l e 的 HCL 代码。\n沁 员 练习题 4. 41 写 出 P IP E 实现 中 信号 s e 七— c c 的 HCL 代码。该信号 只 有对 OPq 指令\n才出现,应该考虑程序异常的影响。\n沁囡 练习题 4. 42 写 出 P I P E 实现中信号 M_ b u b b l e 和 W_ s t a l l 的 HCL 代 码。后 一个信号需要修改图 4-64 中列 出的 异常条件。\n现在我们讲完了所有的特殊流水线控制信号的值。在 P IP E 的完整 HCL 代码中,所有其他的流水线控制信号都设为 0。\n田 日 测试设计\n正如我们看到的,即使是对于一个很简单的微处理器,设计中还是有很多地方会出 现问题。使 用流水线, 处于不 同流水线阶段的指令之间有许多 不 易察 觉的 交互 。我们看到一些设计上的挑战来自 于不 常见的指令(例如弹出值 到栈指针), 或是 不 常见的指令组合(例如不选择分支的跳转指令后面跟 一条r e t 指令)。还看到 异常处理增加了 一类全 新的 可能的流水线行 为。 那么怎样确定我们的设计是正确的呢? 对于硬件制造者来说,这\n是主要 关心的 问题 , 因为他 们不能 简 单 地 报 告 一 个 错 误 , 让 用 户通过 Inter net 下栽代码 甘\n补丁。 即 使 是 简单的逻辑设计错误都可能有很严 重的后 果, 特 别是 随 着微 处理 器越 来越多地用于对我们的生命和健康至关重要的系统的运行中,例如汽车防抱死制动系统、心 脏起 搏 器以 及 航 空控制 系统 。\n简单 地 模 拟 设 计, 运 行 一 些“典型的“ 程序, 不足 以 用 来测试一 个 系统 。 相 反 , 全面的测试需要设计一些方法,系统地产生许多测试尽可能多地使用不同指令和指令组 合。在创 建 Y86-64 处理 器的 过 程 中 , 我 们还设计 了 很 多 测试脚本, 每 个脚 本都产 生 出很多不 同的测试, 运 行 处 理 器模拟 , 并 且比较 得到的寄存 器和 内存值 和我们 YIS 指 令 集模拟 器产 生的 值。以 下是这 些脚本的 简 要 介 绍:\noptest: 运行 49 个 不同的 Y86-64 指令 测试, 具 有 不同的 源 和 目 的 寄 存 器。\njtest: 运行64 个不同的 跳转和函数 调 用指令 的 测试,具 有 不同的是否选择 分支的组合。\ncmtest: 运行 28 个不同的条件传送指令的测试, 具 有 不同的 控 制组合。\nhtest: 运行 600 个不同的 数 据 冒险可能性的测试, 具 有 不同的 源 和 目的 的 指 令 的 组合,在 这些指令对之 间有 不同数 量的 na p 指令 。\nctest : 测试 22 个不同的控制组合 , 基 于类似 4. 5. 8 节 中我们做的那样的分析。\netest: 测试 12 种不同的 导致异 常的指令和跟在后面可能改 变程序 员可见状态的指令 组合。这种测试方法的关键思想是我们想要尽量的系统化,生成的测试会创建出不同的可\n能导致流水线错误的条件。\nm 形式 化地 验证 我们的设计\n即使一个设计通过了广泛的测试,我们也不能保证对于所有可能的程序,它都能正 确运行。即使只考虑由短的代码段组成的测试,可以测试的可能的程序的数量也大得难 以想象。 不过 , 形 式化验证 ( fo rmal veri fic ation ) 的新方 法能 够保证 有 工具能 够严格 地 考虑一 个 系统 所有可能的行为 , 并 确定是否有设计错误。\n我们能够形式化验证 Y86-64 处理 器较 早 的 一 个版 本[ 13] 。 建 立一 个框 架 , 比 较 流\n水线化 的设计 PIPE 和非 流水线化的版 本 SEQ。也 就是 , 它 能 够证 明 对 于任 意 Y86-6 4 程序 , 两 个处理器对程 序 员 可见的状 态有 完全一样的影响。 当 然 , 我 们的验证 器不可能真的运行 所有 可能的程序, 因 为这 样 的 程 序 的 数 量是 无 穷大的。相 反 , 它使 用 了 归纳 法来证明, 表 明 两个处理 器之 间在一 个周期到一个周期的基础上都是 一致的。进行这种分析要求用符号方法( sym bolic met hods ) 来推导硬件, 在 符 号 方 法中, 我 们认为 所 有 的 程序值都是任意的整数, 将 ALU 抽 象成 某种 “ 黑盒子“, 根 据 它的 参数 计算 某个未指定的函数。我们只假设 SEQ 和 PIPE 的 ALU 计算相同的函数 。\n用控制逻辑的 HCL 描 述来产 生符号 处理 器模 型的控制逻辑 , 因 此 我们能发现 HCL 代码 中的 问题。能够证 明 SEQ 和 PIPE 是 完全 相 同 的 , 也 不 能保 证 它 们 忠 实地 实 现 了Y86-64 指令 集体 系结 构 。 不过 , 它能 够发现任何 由 于不 正确的 流水线设计导致的错误 , 这是设计错误的主要来源。\n在实验 中 , 我们不仅验证 了在 本 章 中考虑的 PIPE 版本, 还 验 证 了 作 为 家庭 作 业的几个变种,其中,我们增加了更多的指令,修改了硬件的能力,或是使用了不同的分支 预测 策略 。有趣的是 , 在 所 有 的 设 计 中, 只发 现 了 一 个错误 , 涉及 家庭 作 业 4. 58 中描 述的 变种 的 答 案中的控制组合 BC在 4. 5. 8 节中讲述的)。这暴露出测试体制中的一个弱点,\n导致我们在 ctes t 测试脚本中增加 了附 加的情 况。\n形式化 验证仍然处在发展 的早期阶段。工具往往很 难使 用, 而且还不能 验证大规模的设计。我们能 够验证 Y86-64 处理 器的 部分原 因就是 因 为 它们相 对比较简单 。即使如此,也 需要 几周的 时间和 精力, 多次运行 那些 工具, 每次最多 需要 8 个小时的 计算机时间。 这是一个活跃的 研究领域 ,有 些工具成为 可用的 商业版 本, 有些在 In tel、AMD 和IB M 这样的公 司使用。\n_ _ 流水线化的 Y86-6 4 处理器的 Ve rilog 实现\n正如我们提到过的 , 现代的逻辑设计 包括用硬件描述语 言书 写硬 件设计的 文本表示。 然后 , 可以 通过模拟和各种形式化 验证工具来测试设 计。一旦 对设计有了信心,我们就 可以使 用逻 辑合成( log ic s ynt hesis ) 工具将设计翻译成 实际的 逻辑电路 。\n我们用 Ver ilog 硬件描述语 言开发 了 Y86-64 处理 器设 计的模 型。这些设 计将 实现处理器基本构造块的模 块和 直接从 H CL 描述产 生出来的 控制逻辑结合了起来。我们能够合成这些设 计的 一些 , 将逻辑电路描 述下栽到 字段可编 程的 门阵列 CF PG A ) 硬件上,可以在这些处理 器上运行 实际的 Y86-6 4 程序。\n5. 9 性能分析\n我们可以看到, 所有需要流水线控制逻辑进行特殊处理的条件, 都会导致流水线不能够 实现每个时钟周期发射一条新指令的目标。我们可以通过确定往流水线中插入气泡的频率 ,来衡最这种效率的损失, 因为插入气泡 会导致未使用的流水线周期。一条返回指令会产生三个气泡, 一个加载/使用冒险会产生一个,而 一个预测错误的分支会产生两个。我们可以通过计算 PIP E 执行一条指令所需要的平均时钟周期数的估计值, 来量化这些处罚对整体性能的影响, 这种衡量方法称为 CP I (Cycles Per Instruction, 每指令周期数)。这种 衡量值是流水线平均吞吐量的倒数 , 不过时间单位是时钟周期, 而不是微微秒。这是一个设计体系结构效率的很有用的衡量标准。\n如果我们忽略异常带来的性能损失(异常的定义表明它是很少出现的), 另一种思考 CPI 的方法是,假设我们在处理器上运行某个基准程序,并 观察执行阶段的运行。每个周期,执行阶段要么会处理一条指令,然后这条指令继续通过剩下的阶段, 直到完成; 要么会处理一个由丁三种特殊情况之一而插入的气泡。如果这个阶段一共处理了 C,条指令和 G个气泡, 那么处理器总共需要大约 C, 十G个时钟周期来执行 C条指 令。我们说“大约” 是因为忽略了启动指令通过流水线的周期。于是, 可以用如下方法来计算这个基准程序的 CPI :\nCPI= C, 十 Cb\nC,\n=LO+_,,C_\nC,\n也就是说, CPI 等千 1. 0 加上一个处罚项 Cb / C, , 这个项表明执行一条指令平均要插入多少个气泡。因为只有三种指令类型会导致插入气泡, 我们可以将这个处罚项分解成三个部分:\nC PI = 1. 0 + lp + mp +r p\n这里, l p Cloa d penalt y , 加载处罚)是当由于加载/使用冒险 造成暂停时插入气泡的平均数, mp ( mis predict ed branch penalt y , 预测错误分支处罚)是当由于预测错误取消指令时 插入气泡的平均数 ,而 r p ( ret ur n penalt y , 返回处罚)是当由于r e t 指令造成暂停时插\n人气泡的 平均数。每种处罚都是由该种原因引起的插入气泡的总数( Cb 的 一部分)除以执行指令的总数( C;) 。\n为了估计每种处罚,我们需要知道相关指令(加载、条件转移和返回)的出现频率,以 及对每种指 令特殊情况出现的频率。对 CPI 的计算, 我们使用下 面这 组频率(等同于[ 44] 和[ 46] 中 报 告 的 测 量值):\n加 载指令( mr mov q 和 p op q ) 占所有执行指令的 25 % 。其中 20 % 会导 致加 载/使用冒险。\n条件分支指令占所有执行指令的 20 % 。其中 60% 会选择分支, 而 40 %不选择分支。\n返回指令占所有执行指令的 2% 。\n因此,我们可以估计每种处罚,它是指令类型频率、条件出现频率和当条件出现时插 入气泡数的 乘积:\n原因 名称 指令频率 条件频率 气泡 乘积 加载/使用 Ip o. 25 0. 20 1 0. 05 预测错误 mp 0. 20 0. 40 2 o. 16 返回 rp o. 02 1. 00 3 0. 06 总处罚 0. 27 三种处罚的总和是 0. 27, 所以得到 CPI 为 l. 27。\n我们的 目标是设计一个每个周期发射一条指令的流水线,也 就 是 CPI 为 1. 0。虽 然没有完全 达到目标,但 是 整体 性能巳 经很 不 错 了 。我们还能看到,要 想 进一步降低 CPI , 就应该集中注意力预测错误的分支。它们占 到了 整个处罚 0. 27 中 的 0. 16 , 因为条件转移非常常见,我们的预测策略又经常出错,而每次预测错误都要取消两条指令。\n练习题 4. 43 假设我们 使用 了 一种成功 率 可 以达到 65 % 的分支预 测 策 略, 例 如后 向分支选择、前向分支就 不选择 ( BT F NT ) , 如 4. 5. 4 节 中描述的那样。那 么 对 CPI 有什么样的影响呢?假设其他所有频率都不变。\n练习题 4. 44 让我们来分析你为 练 习题 4. 4 和练 习题 4. 5 写的 程序中使 用条件数据传送和 条件控制 转移的 相对 性能。 假设用 这些 程 序 计 算 一个非 常 长 的 数 组 的 绝 对值的和, 所以整体 性能 主要是由内循环所需要的周期数决定的。假设跳 转指 令预测 为 选择分支 , 而大约 50 % 的数 组值 为 正。\n平均来 说, 这两个 程序的内循环中执行了 多少 条指令?\n平均来 说, 这两个程序的内循环中插入了 多少 个气泡?\n对这两个 程序来说, 每个数 组元 素平均需要 多少个时钟周 期?\n5. 10 未完成的工作\n我们已经创建了 PIPE 流水线化的微处理器结构, 设 计 了 控制逻辑块,并 实 现了处理普通流水线流不足以处理的特殊情况的流水线控制逻辑。不过, PIP E 还是缺乏一些实际微处理器设计中所 必需的关键特性。我们会强调其中一些, 并 讨 论 要 增 加 这些特性需要些什么。\n多周期指令\nY86- 64 指令集中的所有指令都包括一些简单的操作,例 如数字加法。这些操作可以在执行 阶段中一个周期内处理完。在一个更完整的指令集中, 我们还将实现一些需要更为复杂操作的指令,例 如 , 整数乘法和除法,以 及 浮点运算。在一个像 PIPE 这样性能中等\n的处理器中, 这些操作的典型执行时间从浮点 加法的 3 或 4 个周期到整数除法的 64 个周期。为了实现这些指令,我们既需要额外的硬件来执行这些计算,还需要一种机制来协调这些指令的处理与流水线其他部分之间的关系。\n实现多周期指令的一种简单方法就是简单地扩展执行阶段逻辑的功能,添加一些整数和浮点算术运算单元。一条指令在执行阶段中逗留它所需要的多个时钟周期,会导致取指和译码阶段暂停。这种方法实现起来很简单,但是得到的性能并不是太好。\n通过采用独立千主流水线的特殊硬件功能单元来处理较为复杂的操作,可以得到更好 的性能。通常,有一个功能单元来执行整数乘法和除法,还有一个来执行浮点操作。当一条指令进入译码阶段时,它可以被发射到特殊单元。在这个特殊单元执行该操作时,流水 线会继续处理其他指令。通常,浮点单元本身也是流水线化的,因此多条指令可以在主流 水线和各个单元中并发执行。\n不同单元的操作必须同步,以避免出错。比如说,如果在不同单元执行的各个指令之 间有数据相关,控制逻辑可能需要暂停系统的某个部分,直到由系统其他某个部分处理的 操作的结果完成。经常使用各种形式的转发,将结果从系统的一个部分传递到其他部分, 这和前面 PIPE 各个阶段之间的转发一样。虽然与 PIPE 相比,整 个设计变得更为复 杂,但还是可以使用暂停、转发以及流水线 控制等同样的技 术来使 整体行 为与顺序的 ISA 模型相匹配。\n与存储系统的接口\n在对 PIPE 的描 述中,我 们假设取指单元和数据内存都可以 在一个时钟周期内读或是写内存中任意的位置。我们还忽略了由自我修改代码造成的可能冒险,在自我修改代码中,一条指令对一个存储区域进行写,而后面又从这个区域中读取指令。进一步说,我们 是以存储器位置的虚拟地址来引用它们的,这要求在执行实际的读或写操作之前,要将虚 拟地址翻译成物理地址。显然,要在一个时钟周期内完成所有这些处理是不现实的。更糟 糕的是,要访问的存储器的值可能位于磁盘上,这会需要上百万个时钟周期才能把数据读 入到处理器内存中。\n正如将在 第 6 章和第 9 章中讲述的那样, 处理器的存储系统是由多种硬件存储器和管理虚拟内存的操作系统软件共同组成的。存储系统被组织成一个层次结构,较快但是较小 的存储器保持着存储器的一个子集,而较慢但是较大的存储器作为它的后备。最靠近处理 器的一层是高速 缓存( cache ) 存储器,它 提供对最常使用的存储器位置的快速访问。一个典型的处理器有两个第一层高速缓存一-个用于读指令,一个用于读和写数据。另一种 类型的高速缓存存储器, 称为翻译后 备缓冲 器( T ra ns la tion Look-aside Buffer, TLB), 它提 供了从虚拟地址 到物理 地址的快速翻译。将 T LB 和高速缓存结合起来使用, 在大多数时候, 确实可能在 一个时钟周期内读指令并读或是写数据。因此,我 们的处理器对访问存储器的简化看法实际上是很合理的。\n虽然高速缓存中保存有最常引用的存储器位置,但是有时候还会出现高速缓存不命中(miss), 也就是有些引用的位置不在高速缓存中。在最好的情况中,可以从较高层的高速缓存或处理器的主存中找到不命中的数据, 这需要 3 ~ 20 个时钟周期。同 时,流水线会简单地暂停,将指令保持在取指或访存阶段,直到高速缓存能够执行读或写操作。至千流水 线设计,通过添加更多的暂停条件到流水线控制逻辑,就能实现这个功能。高速缓存不命 中以及随之而来的与流水线的同步都完全是由硬件来处理的,这样能使所需的时间尽可能 地缩短到 很少数量的时钟周期。\n在有些情况中,被引用的存储器位置实际上是存储在磁盘存储器上的。此时,硬件会 产生一个缺页 ( page fault ) 异常信号。同其他异常一样,这 个 异 常 会 导 致处理器调用操作系统的异常处理程序代码。然后这段代码会发起一个从磁盘到主存的传送操作。一旦完成, 操作系统会返回到原来的程序,而导致缺页的指令会被重新执行。这次,存储器引用将成 功,虽然可能会导致高速缓存不命中。让硬件调用操作系统例程,然 后 操 作 系统例程又会 将控制返回给硬件,这就使得硬件和系统软件在处理缺页时能协同工作。因为访问磁盘需 要数百万 个时钟周期, OS 缺页中断处理程序执行的处理所需的几百个时钟周期对性能的影响可以 忽略不计。\n从处理器的角度来看,将用暂停来处理短时间的高速缓存不命中和用异常处理来处理长时间的缺页结合起来,能够顾及到存储器访问时由千存储器层次结构引起的所有不可预测性。\n四 当前 的微处理器设计\n一个五阶段流水线, 例如 已 经讲过的 PIPE 处理 器 , 代表 了 20 世 纪 80 年代 中期的处理 器 设 计 水 平。 Berkeley 的 Patterson 研 究组开发的 RISC 处理 器 原型 是 笫 一 个\nSPARC 处理 器 的 基 础, 它 是 S un Microsystems 在 198 7 年 开发 的。Stanford 的 Hen­\nnessy 研 究组开发 的处理 器由 MIPS T echnologies ( 一个由 H enness y 成立的 公 司)在 19 86 年商业 化了 。 这 两种 处理 器都 使 用 的 是 五阶段 流水线。Int el 的 i48 6 处理 器 用的也是五阶段 流水线, 只 不过阶段之间的职责划 分不 太一样, 它有 两 个译码 阶段 和一个合并的执 行/访存阶段[ 27] 。\n这些 流水线化的设计的吞吐量都限制在最多一个时钟周期一条指令。4. 5. 9 小 节 中描述的 CP I (Cycles Per Instruction, 每指令周期)测量值不可能 小于 1. 0。不同的阶段一次只能处 理一条指令。较新的处理器 支持超标量( s uperscalar ) 操作, 意味 着它们通过并行地取 指、译码和执行多条 指令, 可以 实现小于 1. 0 的 CPI。当 超标量处理 器 已经广 泛使用时 , 性能测量标准已经从 CPI 转化成了 它的 倒数- 每周期执行指令的平均数 , 即\nIPC。· 对超标量处理 器 未说, IPC 可以 大 于 1. 0 。 最先进的设计使 用 了 一种称 为 乱 序\n(out-of-order ) 执行的技术未并行地执行多 条 指令,执 行 的顺序也可能 完全 不同 于它们在程序 中出现的 顺序,但 是 保 留了顺 序 ISA 模型蕴含的整体行为 。 作 为对程序优化的讨论的一部 分, 我们将会在笫 5 章中讨 论这种形式的 执行。\n不过,流水线化的处理器并不只有传统的用途。现在出售的大部分处理器都用在嵌 入式系统中,控制着汽车运行、消费产品,以及其他一些系统用户不能直接看到处理器 的设备 。在这些应 用 中, 与性能较 高的模型相比 , 流水线化的处理 器的 简 单性(比如说像我们 在本章中讨论的这样)会降低成本和功耗需求。\n最近 ,随 着多核 处理器受到 追捧,有 些人声 称通过在一个芯片 上集成许多 简 单的处理器,比 使 用 少量 更复杂的处理 器能荻得 更 多 的整体计算能力。 这种策略 有时被 称为“多核 ” 处理器[ 10] 。\n6 小结\n我们已经看到 , 指令集体系结构,即 ISA , 在处理器行为(就指令集合及其编码而言)和如何实现处理器之 间提供了 一层抽象。ISA 提供了程序执行的一种顺序说明 , 也就是一条指令执行完了 , 下一条指令才会开始。\n从 IA32 指令开始,大大简化数据类 型、地址模式和指令编码, 我们定义了 Y86-64 指令集。得到的\nISA 既有 RISC 指令集的属性 , 也有 CISC 指令集的属性。然后,将 不同指令组织 放到五 个阶段中处理, 在此,根据被执行的指令的 不同, 每个阶段中的操 作也不相同。据此, 我们构造 了 SEQ 处理器, 其中每个时钟周期执行一条指令,它会通过所有五个阶段。\n流水线化通过让不同 的阶段并行操作, 改进了系统的吞吐扯性能。在任意一 个给定的时刻,多条指令被不同的阶段处理。在引入这种并 行性的过程中 , 我们必须非常小心,以 提供与程序的 顺序执行相同的程序级行 为。通过重新调 整 SEQ 各个部分的顺序 ,引 入流水线, 我们得到 SEQ+ , 接着添加流水线寄存器, 创建出 PIPE一流水 线。然后 , 添加了转发 逻辑 , 加速了将结果从 一条指令发送到 另一条指令, 从而提高了流水线的性能。有几种特殊情况需要额外的流水线控制逻辑来暂停或取消一些流水线阶段。\n我们的设 计中包括了一些基本的异常处理机制,在此 , 保证只有到异常指令之前的指令会影响程序员可见的状态。实现完整的异常处理远比此更具挑战性。在采用了更深流水线和更多并行性的系统中, 要想正确处理异常就更加复杂了。\n在本章中,我们学习了有关处理楛设计的几个重要经验:\n管理 复杂性是 首要问 题。想要优 化使用 硬件资 源, 在最小的成本下获得最大的性能。为了实现这个目的, 我们创建了一个非常简单而一 致的框架, 来处理所有不同的指令类型。有了这个框架, 就能够在处理不同指令类型的逻辑中共享硬件单元。\n我们 不需要 直接实现 IS A。 ISA 的直接实现意味着一个顺序的设 计。为了获得更高的性能,我们想运用硬件能力以同时执行许多操作,这就导致要使用流水线化的设计。通过仔细的设计和分析, 我们 能够处理各种 流水线 冒险, 因此运行一个程序的整体效果, 同 用 ISA 模型获得的效果完全一致。 硬件设计人 员必须非常谨慎 小心。 一旦芯片被制造出来, 就几乎不可能改正 任何错误 了。一开始就使设计正确是非常重要的。这就意味着要仔细地分析各种指令类型和组合,甚至千那些看上去没有 意义的情况 , 例如弹出值到栈指针。必须用系统的模拟测试 程序彻底 地测试设计。在开发 PIPE 的控制逻辑中, 我们的设计有个细微的错误 ,只 有通过对控制组合的仔 细而系统的分析才能发现 。 Y86- 64 处理器的 HCL 描 述\n本 章 已 经介 绍 几 个 简 单 的 逻 辑 设 计 , 以 及 Y86-64 处 理 器 SEQ 和 PIP E 的 控 制 逻样的 部 分 HCL 代 码 。 我 们 提 供 了 HCL 语 言 的 文 档 和 这 两 个 处 理 器 的 控 制 逻 辑 的 完整:\nHCL 描 述 。 这 些描 述 每 个都 只 需要 5 7 页 HCL 代 码 , 完整 地研 究 它们是很 值得 的。\nY86-64 模 拟器 # 本章的实验资料包括 SEQ 和 PIPE 处理器的模拟器。每个模 拟器都有两个版本 :\nGUI(图形用户界面)版本在图形窗口中显示内 存、程序代码以及处理器状态。它提供了一种方式简便地查看指令如何通过处理器。控制面板还允许你交互式地重启动、单步或运行模拟器。\n文本版本运行的是 相同的模拟器, 但是它显示信息 的唯 一方式是打印到终端上。对 调试来讲 ,这个版本不是很有用,但是它允许处理器的自动测试。\n这些模拟器的 控制逻辑是通过将逻 辑块的 HCL 声明翻译 成 C 代码产生的。然后, 编译这些代码井与模拟代码的其他部分进行链接。这样的结合使得你可以用这些模拟器测试原始设计的各种变种。提供 的测试脚本,它们全面地测试各种指令以及各种冒险的可能性。\n参考文献说明\n对千那些有兴 趣更多地学习逻辑设 计的人来说 , Katz 的逻辑设计教科书[ 58] 是标准的入门 教材,它强调了硬件描述语言的 使用。Hennessy 和 Patterson 的计算机体系结构教科书[ 46] 覆盖了处 理器设计的广泛内 容,包 括这里 讲述的简单流水 线, 还有并行执行更多指令的更高级的处理器。Shriver 和 Smith [ 101] 详 细介绍 了 AMD 制造的与 Intel 兼 容的 IA32 处理器。\n家庭作业 # 4. 45 在 3 . 4. 2 节中, x8 6-64 p u s hq 指令被描述成要减少栈指针, 然后将寄存器存储在栈指针的 位置。因此, 如果我们 有一条指令形如对于某个寄存器 R EG , pushq REG, 它等价于下面的代码序列: subq $8,%rsp movq REG, (%rsp)\nDecrement stack pointer Store REG on stack\n借助于练习题 4. 7 中所做的分析, 这段代 码序列 正确地描述了指令 p u s hq %r s p 的 行为吗? 请解释。\n你该如何改写这段 代码序列, 使得它能够像对 REG 是其他寄存器时 一样, 正确地描述 REG\n是% r s p 的情况?\n4. 46 在 3. 4. 2 节中, x86-64 p op q 指令被描述为将来自 栈顶的结果复制到目的寄存 ff \u0026rsquo; 然后 将栈指针减少。因此,如果我们有一 条指令形 如 p o pq REG, 它等价于下面的代码序列: movq (%rsp), REG addq $8,%rsp\nRead REG from stack Increment stack pointer\n借助于练习题 4. B 中 所做的分析, 这段 代码序列 正确地描述了指令 p o p q %r s p 的 行 为吗? 请解释。\n你该如何改写这段 代码序列 , 使得它能够像对 REG 是其他寄存器时一样, 正确地描述 REG\n是%r s p 的 情况?\n·: 4. 47 你的作业是写一 个执行冒泡排序的 Y86-64 程序。下 面这个 C 函数用数组引 用实现冒泡排序,供你参考:\nI* Bubble sort: Array version *I\nvoid bubble_a(long *data, long count) {\nlong i, last;\nfor (last = count-1; last \u0026gt; 0; last\u0026ndash;) {\nfor (i = O; i \u0026lt; last; i++)\nif (data[i +1] \u0026lt; data [i)) {\nI* Swap adjacent elements *I long t = data [i +1]; data[i+1] = data[i];\n10 data[i) = t;\n•• 4. 48\n12\n13 }\n书写并 测试一个 C 版本,它用 指针引用数组元素, 而不是用 数组索引。\n书写并测试一 个由这个函数和测试代码组成 的 Y86-64 程序。你 会发现模仿编译你的 C 代码产生的 x86- 64 代码来做实现会很有帮助。虽然指针比较通常是 用无符号算术运算来实现的, 但是在这个练习中,你可以使用有符号算术运算。\n修改对家庭作业 4. 47 所写的代码 ,实 现冒泡排序函数的 测试和交换( 6 ~ 11 行),要求不使用跳转, 且最多使用 3 次条件传送 。\n修改对家庭作业 4. 47 所写的代码 ,实 现冒泡排序函数的测试 和交换 ( 6~ 11 行), 要求不使用跳转,且只使用 1 次条件传送 。\n在 3. 6. 8 节中, 我们看到实 现 s wi t c h 的一 种常见方法是创建一组代码块 ,再 用跳转表对这些块进行索引。考虑图 4-69 中给出的函数 s wi t c h v 的 C 代码, 以及相应的 测试代码 。\n用跳转表以 Y86-64 实现 s wi t c h v 。虽 然 Y86-64 指令集不包含间 接跳转指令, 但是, 你可以通过把计算好的 地址入栈 ,再 执行 r e t 指令来获得同 样的效果。实现类 似于 C 语言所示的测试代码, 证明你的 s wi t c hv 实现可以处理触发 d e f a ul t 的情况以 及两个显式处 理的情况。\n#include \u0026lt;s t d i o . h\u0026gt;\nI* Example use of switch statement *I\nlong switchv(long idx) { long result = 0; switch(idx) {\ncase 0:\nresult= Oxaaa; break;\ncase 2:\ncase 5:\nresult= Oxbbb; break;\ncase 3:\nresult= Oxccc; break;\ndefault:\nresult= Oxddd;\n}\nreturn result;\nI* Testing Code *I\n#define CNT 8\n#define MINVAL -1\nint main() {\nlong vals[CNT]; long i;\nfor (i = O; i \u0026lt; CNT; i++) {\nvals[i] = switchv(i + MINVAL);\nprintf(\u0026ldquo;idx = %ld, val= Ox%lx\\n\u0026rdquo;, i + MINVAL, vals[i]);\n}\nreturn O;\n}\n图 4-69 Swi t c h 语句可以翻译成 Y86-64 代码。这要求实现一个跳转表\n4. 51 练习题 4. 3 介绍了 i a d dq 指令,即将 立即数与 寄存器相加。描述实 现该指令所执行的计算。参考\nir mo vq 和 OPq 指令的计算(图4-18) 。\n•• 4. 52 文件 s e q - f u l l. hc l 包含 S EQ 的 HCL 描述,并 将常数 I I ADDQ声明为十六进 制值 c, 也就是 i ad­ d q 的指 令代码。修改实现 i a dd q 指令的控制逻辑块的 HCL 描述, 就像练习题 4. 3 和家庭作业\n4. 51 中描述的 那样。可以参考实验资料获得 如何为你的解答生成模拟器以及如何测试模拟器的指导。\n*/ 4. 53 假设要创建一个较低成本的 、基于我们为 P IP E - 设计的结构(图4-41) 的流水线化的处 理器,不使用旁路技术。这个设计用暂停来处理所有的数据相关,直到产生所需值的指令已经通过了写回 阶段。\n文件 p i p e - s t a ll . h c l 包含一个对 P IP E 的 HCL 代码的修改版,其 中禁止了旁路逻辑。也就是, 信号 e —v a l A 和 e —v a l B 只是简单地声明 如下 :\n## DO NOT MODIFY THE FOLLOWING CODE.\n## No f or 口 ar di ng . valA is either valP or value fromr egi s t er file word d_valA = [\nD_icode in { ICALL, IJXX} : D_valP; # Use incremented PC\n1 : d_rvalA; # Use value read from register file\n];\n## No forwarding. valB is value from register file word d_valB = d_rvalB;\n.. 4. 54\n*** 4. 55\n*** 4. 56\n拿** 4. 57\n修改文件结尾处的流水线控制逻辑 , 使之能正确处理 所有可能的控制和数据冒险。作为设计工作的一部分, 你应该分析各种 控制情况的组合, 就像我们 在 PIP E 的流水线控制逻辑设计中做的那样。你会发现有许多不同的组合,因为有更多的情况需要流水线暂停。要确保你的控制逻辑 能正确处理每种组合情况。可以参考实验资料指导你如何为解答生成模拟器以及如何测试模拟 器的。\n文件 pi pe - fu ll . hc l 包含一份 P IP E 的 HCL 描述, 以及常数值 I I ADDQ的声明。修改该文件以 实现指令 i a dd q , 就像练习题 4. 3 和家庭作业 4. 51 中描述的那 样。可以 参考实验资料获得如何为你的解答生成模拟器以及如何测试模拟器的指导。\n文件 pi pe - nt . hc l 包含一份 P IP E 的 HCL 描述, 并将常数 J _ YES 声明为值 0 , 即无条件转移指令\n的功能码。修改分支预测逻辑 ,使 之对条件转移预测为不选择分支 , 而对无条件转移 和 c a l l 预测为选择分支。你需 要设计一种方法来得到跳转目标 地址 v a l e , 并送到流水线寄存器 M , 以便从错误的分支预测中恢复。可以参考实验资料获得如何为你的解答生成模拟器以及如何测试模拟器的 指导。\n文件 pi pe -b t fn t . hc l 包含一份 P IP E 的 HCL 描述, 并将常数 J _ YES 声明 为值 o, 即无条件转移\n指令的功能码 。修改分支预测逻 辑, 使得当 v a l C\u0026lt; va l P 时(后向分支),就 预测条件转移为选择分支, 当 va l e 娑va l P 时(前向分支), 就预测为不选择分支。(由于 Y86-64 不支持无符号运算,你应该使用有符号比较 来实现这个测试。)并且将无条件转移和 c a l l 预测为选择分支。你需 要设计一种方法来得到 va l C 和 v a l P, 并送到流水线寄存器 M, 以便从错误的分支预测中恢复。可以 参考实验资料获得如何为你的解答生成模拟器以及如何测试模拟器的指导。\n在我们的 P IP E 的设计中,只 要一条指令 执行了 l oa d 操作, 从内存中读一个值到寄存 器,并 且下一条指令要用这个寄存器作为源操作数,就会产生一个暂停。如果要在执行阶段中使用这个源操 作数,暂停 是避免冒险的唯一方法。对于第 二条指令将源操作数存储 到内存的情况,例 如 r mmovq 或 p us hq 指令, 是不需要这样的暂停的。考虑下面这段代码示例 :\nmrmovq 0(%rcx),%rdx pushq %rdx\nnop\npopq %rdx\nrmmovq %rax,O(%rdx)\n# Load 1\n# Store 1\n# Load 2\n# Store 2\n在第 1 行和第 2 行 , mr movq 指令从内存读一个值到%r dx , 然后 pu s hq 指令将这个值压入栈中。我们的 P IP E 设计会让 pus hq 指令暂停 , 以避免装载/使用冒险 。不过, 可以 看到, p us hq 指令要到访存阶段才会需 要%r d x 的值。我们 可以再添加一条 旁路通路 , 如图 4-70 所示, 将内存输出\n(信号m_va l M) 转发到 流水线寄存器 M 中的 va l A 字段。在下 一个时 钟周期,被传 送的值就能写入内存了。这种技术称为加栽转发 (l oad fo r warding ) 。\n注意, 上述代码序列中的第二 个例子(第4 行和第 5 行)不能利用加载转发。p opq 指令加载的值是作为下一条指令地址计算的一部分的,而在执行阶段而非访存阶段就需要这个值了。\n写出描述发现加载 /使用冒险 条件的逻辑公 式, 类似于图 4-64 所示,除 了 能用加载转发时不会导致暂停以外。 文件 pi pe - lf . hc l 包含一个 P IP E 控制逻辑的修改版。它含有信号 e _ v a l A 的 定 义, 用来实现图 4- 70 中标号为 \u0026quot; F w d A\u0026rdquo; 的块。它还将 流水线控制逻辑中的加载/使用冒险的条件设置为 0\u0026rsquo; 因此流水线控制逻辑将不会发现任何形式的加载/使用冒险。修改这个 HCL 描述以实现加载转发。可以参考实验资料获得如何为你的解答生成模拟器以及如何测试模拟器 的指导。 图 4- 70 能够进行加载转发的 执行和访存 阶段。通过 添加一条从内 存输出到流水线寄存器 M 中va l A的源的旁路通路 , 对于这种形 式的加载/使用冒险,我 们可以使用转发 而不必暂停 。这是家庭作业 4. 57 的 主旨\n*:4 . 58 我们的流水线化的设 计有点不太现实 ,因 为寄存 器文件有两个写 端口, 然而只有 p op q 指令需要对寄 存器文件同时 进行 两个写操作。因此, 其他指令只使 用一个写端口, 共享这个端口来写 va l E 和va l M。 下面这个图是一个对写回逻辑的 修改版, 其中, 我们 将写回寄存器 lD ( W_ d s t E 和 W_ds t M)\n合并成一个信号 w—d s t E, 同时也将写回值 ( W—va l E 和 W_v a l M) 合并成一个信号 w_va l E:\n用 HCL 写执行这些合并的逻辑, 如下所示 ;\n## Set E port register ID wor d 廿 _ds t E = [\n## writing from valM W_dstM != RNONE: W_dstM; 1 : W_dstE;\nw_valE w_dslE\n];\n## Set E port value word w_valE = [\nW_dstM != RNONE : W_valM; 1: W_valE;\n];\n对这些多路复用 器的控制是由 ds t E 确定的一— 当它表明 有某个寄存 器时 , 就选择 端口 E 的值,否则就选择端 口 M 的值。\n在模拟模型中 , 我们可以禁 止寄存器端口 M , 如下面这段 H CL 代码所示:\n## Disable register port M\n## Set M port register ID w or d 廿 _ds t M = RNONE;\n## Set M port value word w_valM = O;\n接下来的问 题就是要设计处理 popq 的方法。一种方法是用控制逻辑动态地处 理指令 popq\nrA, 使之与下 面两条指令序列 有一样的效果:\niaddq $8, %rsp mrmovq -8(%rsp), rA\n(关 于指令 i a ddq 的描述, 请参考练习题 4. 3 ) 要注意 两条指令的顺序, 以保证 popq 务r s p 能正确工作。要达到这个 目的,可以 让译码阶段 的逻辑对上面列 出的 popq 指令和 a dd q 指令一视同仁,除了它 会预测下一个 PC 与当前 PC 相等以外。在下一个周期, 再次取出了 po pq 指令, 但是指令代码变成了特殊的 值 I POP2。它会被当作 一条特殊的指令来处理 , 行为与上面列 出的 mr movq 指令一样。\n文件 p i pe - l w. hc l 包含上 面讲的 修改 过的 写端口逻辑。它将常数 I POP2 声明为十六进制值E。还包括信号 f _ i c ode 的定义, 它产生流水线寄存器 D 的 i c ode 字段。 可以 修改这个定义,使得当第二次取出 po pq 指令时, 插人指令代码 I POP2。这个 H CL 文件还包含信号 fy c a9?\u0026lsquo;i , 也就是标号为 \u0026quot; Se lect PC\u0026quot; 的块(图4-57 ) 在取指阶段 产生的程序计数器的 值。\n修改该文 件中的 控制逻辑 , 使之按照我们描述的方式来处理 popq 指令。可以参考实验资料获得如何为你的解答生成模拟器以 及如何测试模 拟器的指导。\n., 4, 59 比较三个版本的冒泡 排序的性能(家庭作业 4. 47 、4. 48 和 4. 49 ) 。解释为什么一个版本的性能比其他两个的好 。\n练习题 答 案 # 1 手工对指令 编码是非常乏味的 , 但是它将 巩固你对汇编器将汇编代码变成 字节序列的理解。在下面这段 Y86-64 汇编器的输出中 , 每一行都给出了一 个地址 和一个从该 地址开始的 字节序列 :\nOxl OO: I . pos Ox100 # Start code at address Ox100\n2 Ox100: 30f30f00000000000000 I irmovq $15,%rbx\n3 Ox10a : 2031 rrmovq %rbx, r 儿 cx\n4 Ox 10c : I loop:\n5 Ox10c: 4013fdffffffffffffff I r mmo v q %rcx, -3(%rbx)\n6 Ox116: 6031 I addq %rbx,%rcx\n7 Ox118 : 700c01000000000000 I jmp loop\n这段编码有些 地方值得 注意 :\n十进制的 15 ( 第 2 行)的十六 进制表示 为 Ox OOOOOOOOOOOOOOOf 。 以反向顺 序来写就是 Of 00 00 00 00 00 00 00 。 十进制 - 3 ( 第 5 行)的十六进制表 示为 Oxf f f ff ff f ff ff ff f d 。 以反向顺 序来写就 f d ff ff f f ff ff ff ff. 代码从地址 Ox l OO 开始。第一条指令需要 10 个字节, 而第二条需要 2 个字节。因 此, 循环的目标地址 为 Ox000001 0c 。以反向顺 序来写就是 Oc 01 00 00 00 00 00 00. 2 手工对一个字节序列进行译码 能帮助你理 解处理器面临的 任务 。它必须读入字 节序列 , 并确定要执行什么指令。接下来 ,我 们给出的是用来产生每个字节序列 的汇编代码。在汇编代码的 左边,你 可以看到每条指 令的地址和字节序列。\n一些带立即数 和地址偏移 量的操作:\nOx100: 30f3fcffffffffffffff I\nOx10a: 40630008000000000000 I\nOx114: 00 I\n包含一 个函数调用的代码 :\nOx200: a06f\nOx202: 800c02000000000000 Ox20b: 00\nirmovq $-4,%rbx\nrmmovq %rsi,Ox800(%rbx) halt\npushq %rsi call proc halt\nOx20c: I proc:\nOx20c: 30f30a00000000000000 I irmovq $10,%rbx Ox216: 90 I ret\n包含非法指令 指示字节 Ox f O 的 代码:\nOx300: 50540700000000000000 I mrmovq 7(%rsp),%rbp\nOx30a: 10 I nop\nOx30b: fO I .byte OxfO # Invalid instruction code\nOx30c: b01f I popq /,rcx\n包含一个跳转操作的代码:\nOx400: I loop:\nOx400: 6113 I subq %rcx, %rbx Ox402: 730004000000000000 I je loop\nOx40b: 00 I halt\npushq 指令中第二个字节非法的代码。 Ox500: 6362 Ox502: aO\ncode Ox503: fO\nspecifier byte\nxorq %rsi,%rdx\n.byte OxaO # pushq instruction\n.byte OxfO # Invalid register\n4. 3 使用 i a d d q 指令, 我们将 s um 函数重新编写为\n# long sum(long *Start, long count)\n# start in %rdi, count in %rsi sum :\nloop:\ntest:\nxorq %r ax , %r ax andq %rsi,%rsi jmp test\nmrmovq (%rdi),%r10 addq %r10,%rax iaddq $8 丛 r di\niaddq $-1,%rsi\njne loop ret\n#sum= 0\n# Set condition codes\n# Get *start\n# Add to sum\n# start++\n# count\u0026ndash;\n# Stop 吐en 0\n4. 4 在x86-64 机器上运行 时, GCC 生成如下r s um 代码:\nlongr s um(l ong • s tar t , long count) start in¼rdi, count in¼rsi\nr sum :\nmovl $0, %eax testq %rsi, %rsi jle .19\npushq %rbx\nmovq ( %r d i ) , %rbx\nsubq $1, %rsi\naddq $8, %rdi\ncall rsum\naddq %rbx, %rax\npopq %rbx\n.L9:\nrep; ret\n上述代 码很容易改编为 Y86-64 代码:\n# long rsum(long *start, long count)\n# start in %rdi, count in %rsi rsum:\nreturn:\nxorq %rax,%rax andq %rsi,%rsi je return pushq %rbx\nmrmovq (%rdi),%rbx irmovq $-1,%r10 addq %r10,%rsi irmovq $8,%r10 addq %r10,%rdi call rsum\naddq %rbx,%rax popq %rbx\nret\n# Set return value to 0\n# Set condition codes\n# If count== 0, return 0\n# Save callee-saved register\n# Get *start\n# count\u0026ndash;\n# start++\n# Add *Start to sum\n# Restore callee-saved register\n5 这道题给了你一个练习写汇编代码的机会。\n# long absSum(long *start, long count)\n# start in %rdi, count in %rsi\nabsSum:\nirmovq $8,%r8 # Constant 8\nirmovq $1,%r9 # Constant 1\nxorq %rax,%rax #sum= 0\nandq %rsi,%rsi # Set condition codes\njmp test\nloop:\nmrmovq (%rdi),%r10 # x = *start\nxorq %r11, %r11 # Constant 0\n12 subq %r10,%r11 # -x\njle pas # Skip if -x \u0026lt;= 0\nrrmovq %r11,%r10 # X = -x\npos:\naddq %r10,%rax # Add to sum\naddq %r8,%rdi # start++\nsubq %r9,%rsi # count\u0026ndash;\ntest:\njne loop # Stop when 0\nret\n4. 6 这道题 给了你一 个练习写带 条件传送 汇编代码的 机会。我们只给出循环的 代码。剩下的部分与练习题 4. 5 的一样。\n9 loop: 10 mrmovq (%rdi),%r10 # X = *Start 11 xorq %r11,%r11 # Constant 0 12 subq %r10,%r11 # -x 13 cmovg %r11,%r10 # If -x \u0026gt; 0 then x = -x 14 addq %r10,%rax # Add to sum 15 addq %r8,%rdi # start++ 16 subq %r9,%rsi # count\u0026ndash; 17 test: 18 jne loop # Stop when 0\n[,\n4. 7 虽然难以想象这条特殊的指令有什么实际的用处,但 是 在设计一个系统时, 在描述中避免任何歧义是 很 重要的。我们想要为这条指令的行 为确定 一个合理的规则, 并 且 保 证 每个实现都遵循这个规则。\n在这个测试中, s ubq 指令将% r s p 的 起 始 值 与压入栈中的值进行了比较。这个减法的结果为 o ,\n表明压入的是%r s p 的 旧 值 。\n4. 8 更难以想象 为什么会有人想要把值弹出到栈 指针。我们还是应该 确定一个规则,并 且坚 持 它 。这段代码序列将 Oxab cd 压 人栈中, 弹出到%r s p , 然后返回弹出的值。由于结果等于 Oxab cd , 我们可以推断出\npopq r% s p 将 栈指针设置为从内存中读出来的那个值。因此, 它等 价 千指令 mrmov q (r% s p ) ,\n4. 9 E XCL US IV E-O R 函数要求两个位有相反的值:\nbool xor = (!a \u0026amp;\u0026amp; b) II (a \u0026amp;\u0026amp; !b);\nr% s p 。\n通常 , 信号 e q 和 xor 是互补的。也就是,一 个 等 于 1\u0026rsquo; 另 一 个 就 等于 0。\nEq\n由 于 第 一 行 将检 测 出 A 为最小元素的情况,因 此 第 二\nb,\n行 就 只需 要 确 定 B 还是 C 是最小元素。\na,\n4. 12 这个设计只是对从三个输入中找出最小值的简单\n改变。 坏\n气\nword Med3 = [\nA\u0026lt;= B \u0026amp;\u0026amp; B \u0026lt;= C : B; C \u0026lt;= B \u0026amp;\u0026amp; B \u0026lt;= A : B; B \u0026lt;= A \u0026amp;\u0026amp; A\u0026lt;= C : A; C \u0026lt;= A \u0026amp;\u0026amp; A\u0026lt;= B : A; 1 : C;\n图 4-71 练习题 4. 10 的答案\n];\n4. 13 这些练习使各个阶段的计算更加具体。从目标代码中我们可以看到,指 令 位于地址 Ox 01 6。它由\n10 个 字 节 组 成 ,前 两 个字节为 Ox 3 0 和 Oxf 4。后八个字节是 Ox OOO OOOOO OO OOO OBO( 十进制 128) 按\n字节反过来的形式。\n通用 i r rnov q V, rB ico de , ifun ~ M, [ PC] rA: rB - M1[ PC+ l] vale+- Ms[ PC+ 2] valP 仁 - PC+ l O valE 仁 - o+ valC R[ rB] - valE PC- valP 具体 阶段 ir mo vq $ 1 28, r% s p 取指 icode,ifun- M, [ Ox016] = 3: 0 rA , rB - M, [ Ox017] = f : 4 valC - Ms[ Ox018] = 128 valP - Ox0 1 6+ 10= Ox020 译码 执行 valE 仁 - o+ 128= 128 访问 写回 R[ % r s p ] - valE = 128 更新 PC PC - valP= Ox020 这个指令将寄存器% r s p 设 为 128 , 并将 PC 加 10。\n4. 14 我们 可以看到指令位于地址 Ox 02 c , 由两个字节组成,值 分 别 为 Oxb O 和 Ox OO f 。 pus hq 指 令(第 6\n行)将 寄 存 器%r s p 设 为了 120 , 并且将 9 存放在了这个内存位置。\nvalP 仁 - PC+ 2 valP 仁 - Ox02c + 2 = Ox02e 译码 valA - valB - R[ r% sp ] R[ %r s p] valA - valB - R[ r% s p] = 120 R[ r毛 s p] = 120 执行 valE - valB+ 8 valE - 120+8= 128 访存 valM - 队[ valA] va!M - Ms[ 120] = 9 写回 R[ %rsp]- valE R[ %rsp]- 128 R[ rA] - valM R 巨 r s p] - 9 更新 PC PC 仁 - valP PC - Ox02e 该指令将%r a x 设 为 9\u0026rsquo; 将 %r s p 设 为 128 , 并将 PC 加 2.\n4. 15 沿着图 4- 20 中列 出的步骤, 这里r A 等 于 %r s p , 我们可以看到, 在访存阶段, 指令会将 va l A( 即栈指针的原始值)存放到内存中,与 我 们在 x86-64 中发现的一样。\n4. 16 沿着图 4-20 中列出的步骤,这 里r A 等 于 % r s p , 我们可以看到,两 个写回操作都会更新%r s p 。 因\n为写 va l M 的 操 作 后 发生,指 令的 最 终 效 果 会 是 将 从内存中读出的值写入% r s p , 就 像 在 x86-6 4 中看到的一样。\n4. 17 实现条件传送只需 要对寄存器到寄存器的传送做很小的修改。我们简单地以条件测试的结果作为写回步骤的条件:\n4. 18 我们可以看到这条指令位于地址 Ox037, 长度为 9 个字节。第一个字节值为 Ox80, 而后面 8 个字节是\nOx0 000000000000014 按字节反过来的形式,即调用的目标地址。 p::\u0026gt;pq 指令(第7 行)将栈指针设为128。\n这条指令的效果 就是将%r s p 设为 1 20 , 将 Ox 0 40( 返回地址)存放到该内存地址 , 并将 PC 设为\nOx 0 41 ( 调用的目标地址)。\n4. 19 练习题中所有的 HCL 代码都很简单明了 , 但是试着自己写会帮助你思 考各个指令 , 以及如何处理它们。对于这个问 题, 我们只要 看看 Y8 6- 64 的指令集(图4- 2 ) , 确定哪些有常数字段。\nbool need_valC =\nicode in { IIRMOVQ, IRMMDVQ, IMRMDVQ, IJXX, !CALL};\n4. 20 这段代码类似 千 s r c A 的代码 :\nword srcB = [\nicode in { IDPQ, IRMMOVQ, IMRMOVQ } : rB;\nicode in { IPUSHQ, IPDPQ, ICALL, IRET} : RRSP;\n1 : RNONE; # Don\u0026rsquo;t need register\n];\n4 . 2 1 这段代码类 似千 d s t E 的代码 :\nword dstM = [\nicode in { IMRMOVQ, IPOPQ} : rA;\n1 : RNDNE; # Don\u0026rsquo;t write any register\n];\n4 . 22 像在练习题 4. 16 中发现的那样, 为了将从内 存中读出的值存放到% r s p , 我们 想让通过 M 端口写的优先级 高于通过 E 端口写。\n4. 23 这段代码类 似千 a l uA 的代码 :\nword aluB = [\nicode in { IRMMOVQ, IMRMOVQ, IDPQ, !CALL,\nIPUSHQ, I RET, IPOPQ} : valB; icode in { IRRMOVQ, IIRMOVQ} : O;\n# Other instructions don\u0026rsquo;t need ALU\n];\n4 . 24 实现条件传送令人吃 惊的简单: 当条件不满足时 ,通 过将目的寄存器设置为 RNONE 禁止写寄存器文件。\nword dstE = [\nicode in { IRRMOVQ} \u0026amp;\u0026amp;: Cnd : rB; icode in { IIRMOVQ, IOPQ} : rB;\nicode in { IPUSHQ, IPOPQ, !CALL, IRET} : RRSP;\n1 : RNONE; # Don\u0026rsquo;t write any register\n];\n4 . 25 这段代码类 似千 me m a d d r 的 代码:\nword mem_data = [\n# Value from register\nicode in { IRMMOVQ, IPUSHQ} : valA;\n# Return PC\nicode == ICALL : valP;\n# Default: Don 飞 口 r i t e anything\n]?\n4. 26 这段代码类 似于 me m_r e a d 的代码:\nbool mem_write = icode in { IRMMOVQ, IPUSHQ, !CALL};\n4. 27 计算 S t a t 字段需要从几个阶段收集状 态信息 :\n## Determine instruction status word Stat = [\nimem_error 11 dmem_error : SADR;\n!instr_valid: SINS; icode == !HALT : SHLT;\n1 : SAOK·\n];\n4. 28\n4. 29\n这个题目非常有趣,它试图在一组划分中找到优化平衡。它提供了大昼的机会来计算许多流水线的吞吐拭和延迟。\n对一个两阶段流 水线来说, 最好的划分是块 A、 B 和 C 在第一阶段 , 块 D、 E 和 F 在第二阶段。第一阶段的 延迟 为 170 ps , 所以 整个周期的时长为 1 70 + 20 = 190ps 。因此吞吐量为 5. 26\nGIPS, 而延迟为 380ps 。\n对一个三阶段 流水线来说 , 应该使块 A 和 B 在第一阶段, 块 C 和 D 在第二阶段, 而块 E 和 F\n在第三阶段 。前两个阶段的延迟均 为 ll Ops , 所以 整个周期时长为 130 ps , 而吞吐昼为 7. 69\nGIPS 。延迟为 390ps 。\n对一个四阶段流水线来说 , 块 A 为第一阶 段, 块 B 和 C 在第二阶段, 块 D 是第三阶段, 而块\nE 和 F 在第四 阶段。第二阶段需要 90 ps , 所以整个周期时 长为 ll Ops , 而吞吐最为 9. 09 GIP S。延迟为 440 ps。\n最优的设计应该是五阶段 流水线 ,除 了 E 和 F 处千第五阶段以外 , 其他每个块是一个阶段。周期时长为 80 -t- 20 = lOOps, 吞吐批为大约 10. 00 GIPS, 而延迟为 500 ps。变成更多的阶段也不会有帮助了 , 因为不可能使 流水线运行 得比以 l OOps 为一周期 还要快了。\n每个阶段的组合逻辑都需 要 300 / k ps , 而流水线 寄存器需要 20 ps。\n整个的延迟应该是 300 + 20k ps , 而吞吐量(以 G IPS 为单位)应该是\n1 000 1 OOOk\n300\nk\n= 300 + 2 0 k\n20\n4. 30\n当 K 趋近于无穷 大, 吞吐橄变 为 1 000/20=50 GIPS。当然, 这也使得延迟为无穷大。\n这个练习题量化了很 深的流水线引起的 收益下降。当我们试 图将逻辑 分割为很 多阶段时, 流水线寄存楛的延迟成 为了一 个制约因素 。\n这段代码非常类似于 SEQ 中相应的 代码,除 了我们还不能确定数据内存是 否会为这条指令产生一个错误信号。\n# Determine status code for fetched instruction 11ord f_stat• [\nimem_error: SADR;\n!instr_valid: SINS; f_icode == !HALT : SHLT;\n1 : SAOK;\n4. 31\n];\n这段代码只是简单地 给 SEQ 代码中的信号名前加上前缀 \u0026quot; ct_\u0026quot; 和 \u0026quot; o_\u0026quot;。\nword d_dstE = [\nD_icode in { IRRMOVQ, IIRMOVQ, IOPQ} : D_rB; D_icode in { IPUSHQ, IPOPQ, !CALL, IRET} : RRSP;\n: RNONE; # Don\u0026rsquo;t write any register 4. 32\n];\n由于 popq 指令(第4 行)造成的加载/使用冒险, r r movq 指令(第5 行)会暂停一个周期。当它进入译码阶段, popq 指令处于访存阶段 , 使 M_d s t E 和 M_d s t M 都等于 % r s p 。 如果两种情况反过来 , 那么来自 M_va l E 的写回 优先级较高, 导致增加了的栈指针被传 送到 rr movq 指令作为参数。这与练习题 4. 8 中确定的处理 po pq %r s p 的 惯例不 一致。\n这个问题让你体验一下处理器设 计中一个很重要的 任务一 为一个新处理器设 计测试程序。通常, 我们的测试程序应该能测试所有的冒险可能性,而且一旦有相关不能被正确处理,就会产生错误 的结果。\n对于此例 , 我们可以使用对练习题 4. 32 中所示的 程序稍微修改的版本 :\nirmovq $5, %rdx irmovq $0x100,%rsp rmmovq %rdx,0(%rsp) popq %rsp\nnop nop\nrrmovq %rsp,;r人 ax\n4. 34\n两个 no p 指令会导致 当r r mo v q 指令在译码 阶段中 时, p o p q 指令处于写 回阶段。如果给予处于写回阶段中的两个转发源错误 的优先级 , 那么寄存器釭a x 会设登成增加了的程序计数器,而不是从内存中读出的值。\n这个逻辑只需 要检查 5 个转发源:\nword d_valB = [\nd_srcB == e_dstE : e_valE; # Forward valE from execute d_srcB == M_dstM: m_valM; # Forward valM from memory d_srcB == M_dstE : M_valE; # Forward valE from memory d_srcB == W_dstM : W_valM; # Forward valM from write back d_srcB == W_dstE : W_valE; # Forward valE from write back\n1 : d_rvalB; # Use value read from register file\n4. 35\n];\n这个改变不会处理条件传送不满足条件的情况, 因此将 d s t E 设置为 RNONE。即使条件传送并没有发生,结果 值还是会被转发到下一条指令。\nirmovq $0x123,%rax irmovq $0x321,%rdx\nxorq %rcx,%rcx cmovne %rax,%rdx addq %rdx,%rdx halt\n#cc= 100\n# Not transferred\n# Should be Ox642\n4. 36\n这段代码将寄存器% r d x 初始化为 Ox3 2 1 。 条件数据传送没有发生, 所以最后的 a dd q 指令应该 把%r d x 中的值翻倍 , 得到 O x 6 4 2。不过, 在修改过的版本中 , 条件传送源值 Ox l 2 3 被转发到 AL U 的输 入 va l A, 而 v a l B 正 确地 得到 了 操作数值 Ox 3 2 1 。 两 个 输 入 加起来就得到结果 Ox 4 4 4 。\n这段代码 完成了对这条指令的状态码的计算。\n## Update the status word m_stat = [\ndmem_error : SADR;\n1 : M_stat;\n4. 37\n];\n设 计下面这个 测试程序来建立控制组合 AC图 4 - 67 ) , 并探测是否出了错:\n# Code to generate a combination of not-taken branch and ret irmovq Stack, %rsp\ni rmovq rtnp,%rax\npushq ir 儿 ax # Set up return pointer xorq %rax,%rax # Set Z condition code\njne t ar get # No t taken (First part of combination) irmovq $1,%rax # Should execute this\nhalt\nt a 工 ge t : ret\nirmovq $2,%rbx halt\n# Second part of combination\n# Should not execut e this\nrtnp:\nir ovq$3,%r dx halt\n# Should not execute this\n.pos Ox 40 St a c k :\n设计这个程序是为了出错(例如如果实际上执行了 r e t 指令)时,程 序会执行一条额外的 江-\nmovq 指令, 然后停止。因此,流水线中的错误 会导致某个寄存 器更新错 误。这段代码说明实现测试程序需要非常小心。它必须建立起可能的错误条 件, 然后再探 测是否有错误发生。\n4. 38 设计下面这个测试 程序用来建 立控制组合 BC图 4-67 ) 。 模拟器会发现流水 线寄存骈的 气泡和暂停\n控制信号都设 置成 0 的情况, 因此我们的 测试程序 只需要建立它需 要发现 的组合情况。最大的挑战在千当处理正确时,程序要做正确的事情。\n1 # Test instruction that modifies %esp followed by ret\nirmovq mem, %rbx mrmovq O(%rbx) , %rsp # Sets %rsp to point to return point 4 ret # Returns to return point 5 halt # 6 r t npt : 7 irmovq $5,%rsi halt # Return point a . pos Ox40\nme m: . quad stack # Holds desired stack pointer . pos Ox50 s t a c k : . quad rtnpt # Topof stack: Holds return point 这个 程序使 用了内存中两个初始化了的字。第一 个字( me m) 保存 着第二 个字( s t a c k一 期望的栈指针)的地址。第 二个字保存着 r e t 指令期望的返回点的地址。这 个程序将栈指针加载到\n% r s p , 并执行 r e t 指令。\n4. 39 从图 4- 66 我们可以 看到,由 千加 载/使用冒险, 流水线寄存 器 D 必须暂停 。\nbool D_stall =\n# Conditions for a load/use hazard E_icode in { IMRMOVQ, IPOPQ} \u0026amp;\u0026amp; E_dstM in { d_srcA, d_srcB };\n4. 40 从图 4- 66 中可以看到, 由于加载/使用冒险, 或者由 于分 支预测 错误, 流水线寄存器 E 必须设置成气泡:\nbool E_bubble =\n# Mispredicted branch\n(E_icode == IJXX \u0026amp;\u0026amp; !e_Cnd) I I\n# Conditions for a load/use hazard E_icode in { IMRMOVQ, IPOPQ} \u0026amp;\u0026amp; E_dstM in { d_srcA, d_srcB};\n4.41 这个控制 需要检查正 在执行的指令的代码 , 还需要检查流水线中更后 面阶段中的异 常。\n## Should the condition codes be updated7 bool set_cc = E_icode == IOPQ \u0026amp;\u0026amp;\n# State changes only during normal operation\n!m_stat in { SADR, SINS, SHLT} \u0026amp;\u0026amp; !W_stat in { SADR, SINS, SHLT };\n4. 42 在下一个周期向访存阶段插入气 泡需要检查当前周期 中访存或者写回阶段中是 否有异常。\n# Start injecting bubbles as soon as except i on passes through memory stage\nbool M_bubble = m_stat in { SADR, SINS, SHLT } I I W_stat in { SADR, SINS, SHLT } ;\n对于暂停写回阶段,只用检查这个阶段中的指令的状态。如果当访存阶段中有异常指令时我 们也暂停了, 那么这条指令就不能 进入写回阶段。\nbool W_stall = W_stat in { SADR, SINS, SHLT } ;\n4. 43 此时, 预测错误的频率是 0. 35, 得到 m p = O. ZO X O. 35X2=0. 14, 而整个 CPI 为 1. 25 。看上 去收获非常小,但是如果实现新的分支预测策略的成本不是很高的话,这样做还是值得的。\n44 在这个简化的分析中 , 我们把注意力放在了内 循环上 , 这是估计程序 性能的一种很有用的方法。\n只要数组 足够大 , 花在代码 其他部分的时间 可以忽略不 计。\n使用条件转移的代码的内循环有 9 条指令, 当数组元素是 0 或者为负时 , 这些指令都要执行, 当数组元素为正时 , 要执行其中的 8 条。平均是 8. 5 条。使用条件传送的代码的内 循环有 8 条指令,每次都必须执行。\n用来实现循环闭合的跳转除了当循环中止时之外,都能预测正确。对于非常长的数组,这个预 测错误对性能的影响可以忽略不计。对于基于跳转的代码,其他唯一可能引起气泡的源取决于 数组元素是否为正的 条件转 移。这会导致两个气泡, 但是只在 50 % 的时间里会出现,所以平均值是 1. 0。在条件传送代码中 ,没有气 泡。 我们的 条件转移代码对于每个元素 平均需 要 8. 5 + 1. 0 = 9. 5 个周期(最好情况要 9 个周期,最差情况要 10 个周期), 而条件传送代码对 千所有的 情况都需要 8. 0 个周期。\n我们的 流水线的分 支预测错误处罚只有两 个周期—- 远比 对性能更高的处理器中很 深的流水线造成的处罚要小得多。因此, 使用条件传送对程序性 能的影响不是很 大。\n"},{"id":441,"href":"/zh/docs/technology/System/%E6%B7%B1%E5%85%A5%E7%90%86%E8%A7%A3%E8%AE%A1%E7%AE%97%E6%9C%BA%E7%B3%BB%E7%BB%9F/%E4%B9%A6%E7%B1%8D/%E7%AC%AC5%E7%AB%A0-%E4%BC%98%E5%8C%96%E7%A8%8B%E5%BA%8F%E6%80%A7%E8%83%BD/","title":"Index","section":"SpringCloud","content":"C H— _\n第 5 章\n_ A P T E R 5\n优化程序性能 # 写程序最主要的目标就是使它在所有可能的情况下都正确工作。一个运行得很快但是给出错误结果的程序没有任何用处。程序员必须写出清晰简洁的代码,这样做不仅是为了 自己能够看懂代码,也是为了在检查代码和今后需要修改代码时,其他人能够读懂和理解 代码。\n另一方面,在很多情况下,让程序运行得快也是一个重要的考虑因素。如果一个程序要实时地处理视频帧或者网络包,一个运行得很慢的程序就不能提供所需的功能。当一个 计算任务的 计算量非常 大,需 要执行数日或者数周, 那么哪怕只是让它运行得快 20 %也会产生重大的影响。本章会探讨如何使用几种不同类型的程序优化技术,使程序运行得 更快。\n编写高效程序需要做到以下几点:第一,我们必须选择一组适当的算法和数据结构。 第二,我们必须编写出编译器能够有效优化以转换成高效可执行代码的源代码。对于这第 二点,理解优化编译器的能力和局限性是很重要的。编写程序方式中看上去只是一点小小 的变动,都 会引起编译器优化方式很大的变化。有些编程语言比其他语言容易优化。C 语言的有些特性,例如执行指针运算和强制类型转换的能力,使得编译器很难对它进行优 化。程序员经常能够以一种使编译器更容易产生高效代码的方式来编写他们的程序。第三 项技术针对处理运算量特别大的计算,将一个任务分成多个部分,这些部分可以在多核和 多处理器的 某种组合上并行地计算。我们会 把这种性能改进的方法推迟到第 12 章中去讲。即使是要利用并行性,每个并行的线程都以最高性能执行也是非常重要的,所以无论如何 本章所讲的内容也还是有意义的。\n在程序开发和优化的过程中,我们必须考虑代码使用的方式,以及影响它的关键因\n素。通常,程序员必须在实现和维护程序的简单性与它的运行速度之间做出权衡。在算法 级上,几分钟就能编写一个简单的插入排序,而一个高效的排序算法程序可能需要一天或更长的时间来实现和优化。在代码级上,许多低级别的优化往往会降低程序的可读性和模块性,使得程序容易出错,并且更难以修改或扩展。对于在性能重要的环境中反复执行的代码,进行大量的优化会比较合适。一个挑战就是尽管做了大量的变化,但还是要维护代码一定程度的简洁和可读性。\n我们描述许多提高代码性能的技术。理想的情况是,编译器能够接受我们编写的任何代码,并产生尽可能高效的、具有指定行为的机器级程序。现代编译器采用了复杂的分析 和优化形式,而且变得越来越好。然而,即使是最好的编译器也受到妨碍优化的因素(optimization blocker ) 的阻碍, 妨碍优化的因素就是程序行为中那些严重依赖于执行环境的方面。程序员必须编写容易优化的代码,以帮助编译器。\n程序优化的第一步就是消除不必要的工作,让代码尽可能有效地执行所期望的任务。这包括消除不必要的函数调用、条件测试和内存引用。这些优化不依赖于目标机器的任何 具体属性。\n为了使程序性能最大化,程序员和编译器都需要一个目标机器的模型,指明如何处理指\n令,以 及各个操作的时序特性。例如, 编译器必须知道时序信息, 才能够确定是用一条乘法指令, 还是用移位和加法的某种组合 。现代计算机用复杂的技术来处理机器级程序, 并行地执行许多指令,执行顺序还可能不同 千它们在程序中出现的顺序。程序员必须理解这些处理器是如何工作的, 从而调整他们的程序以获得最大的 速度。基千 Intel 和 AMD 处理器最近的设计 , 我们提出了这种机器的一个高级模型。我们 还设计了一种图形数据流( data-flow) 表示法, 可以使处理器对指令的执行 形象化, 我们还可以 利用它预测程序的性能。\n了解了处理器的运作,我们就可以进行程序优化的第二步,利用处理器提供的指令级并 行(inst ru ction-level para llelism ) 能力, 同时执行多条指令。我们会讲述儿个对程序的 变化,降低一个计算的不同部分之间的数据相关, 增加并行度, 这样就可以同时执行这些 部分了。\n我们以对优化 大型程序的问 题的讨论来结束这一章。我们描述了代码剖析 程序 ( profi­ le r ) 的使用 , 代码剖析程序是测量程序各个部分性能的工具。这种分析能够帮助找到代码中低效率的地方,并且确定程序中 我们应该 着重优化的部分。\n在本章的描述中,我们使代码优化看起来像按照某种特殊顺序,对代码进行一系列转 换的简单线性过程。实际上, 这项工作远非这么简单。需要相当多的试错法试验。当我们进行到后面的优化阶段时, 尤其是这样, 到那时 , 看上去很 小的变化 会导致性能上很大的变化。相反, 一些看上去很有希望的技术被证明是 无效的。正如后面的例子中会看到的那样, 要确切解释为什么某段 代码序列具有特定的执行时间, 是很困难的。性能可能依赖于处理器设计的许多细节特性 ,而 对此我们所知甚少。这也是 为什 么要尝试各种技术的变形和组合的 另一个原因。\n研究程序的汇编代码表示是理解编译楛以及产生的代码会如何运行的最有效手段之 一。仔细研究内 循环的代码是一个很好的开端,识别 出降低性能的属性, 例如过多的内存引 用 和对寄存器使用不当。从汇编代码开始 , 我们还可以预测什么操作会并行执行, 以及它们会如何使用处理器资源。正如我们会看到的, 常常通过 确认关键路径( crit ica l pa t h) 来决定执行一个循环所需要的时间(或者说, 至少是一个时间下 界)。所谓关键路径是在循环的 反复执行过程中形成的数据相关链。然后 , 我们会回过头来 修改源代码 , 试着控制编译器使之产生更有效率的实现。\n大多数编译器, 包括 GCC, 一直都在更新和改进, 特别是在优化能力方面。一个很有用的策略是只重写程序到 编译器由此就能产生有效代码所需要的程度就好了。这样,能尽量避免损 害代码的可读性 、模块性和可移植性, 就好像我们使用的是具有最低能力的编译器。同样, 通过测量值和检查生成的 汇编代码 , 反复修改源代 码和分析它的性能是很有帮助的。\n对千新手程序员来说,不断修改源代码,试图欺骗编译器产生有效的代码,看起来很 奇怪 , 但这确实是编写很多高性能程序的方式。比较千另 一种方法- 用汇编语言写代码, 这种间 接的方法具 有的优点是:虽 然性能不一定是最好的, 但得到的代码仍然能够在其他机器上运行。\n5. 1 优化编译器的能力和局限性 # 现代编译器运用复杂精细的算法来确定一个程序中计算的是什么值,以及它们是被如 何使用的。然后会利用一些机会来简化表达式 , 在几个不同的地方使用同一个计算, 以及降低一个给定的计算必须被执行的次数。大多数编译器, 包括 GCC, 向用户提供了一些对它们所使用的 优化的 控制。就像在第 3 章中讨论过的, 最简单的控制就是指定优化级\n别。例如,以 命 令 行 选项 \u0026quot; - og\u0026quot; 调用 GCC 是让 GCC 使用一组基本的优化。以选项 \u0026quot; -\n01\u0026quot; 或更高(如 \u0026quot; - 0 2\u0026quot; 或 \u0026quot; - 0 3\u0026quot; ) 调用 GCC 会让它使用更大翟的优化。这样做可以进一步提高程序的性能,但是也可能增加程序的规模,也可能使标准的调试工具更难对程序进行 调试。我们的表述,虽 然 对 于 大 多 数 使 用 GCC 的软件项目来说, 优 化 级 别 - 0 2 已经成为了被接受的标准,但 是 还是主要考虑以优化级别- 0 1 编译出的代码。我们特意限制了优化级别,以 展 示写 C 语言函数的不同方法如何影响编译器产生代码的效率。我们会发现可以写出的 C 代码, 即 使 用 - 0 1 选项编译得到的性能,也 比用 可能的最高的优化等级编译一个更原始的 版本得到的性能好。\n编译器必须很小心地对程序只使用安全的优化,也就是说对于程序可能遇到的所有可 能的情况 , 在 C 语言标准提供的保证之下,优 化 后 得 到 的 程 序 和 未 优 化 的 版本有一样的行为。限制编译器只进行安全的优化,消除了造成不希望的运行时行为的一些可能的原因, 但是这也意 味着程序员 必须花费 更大的力气写出编译器能够将之转换成有效机器代码的程序。为了理 解决定一种程序转换是否安全的难度,让 我们来看看下面这两个过程:\n1 void twiddle1(long *xp, long *yp)\n2 {\n3 *XP += *yp;\n4 *XP += *yp;\n5 }\n6\n7 void twiddle2(long *XP, long *yp)\n8 {\n9 *XP += 2* *yp;\n10 }\n乍一看,这 两个过程似乎有相同的行为。它们都是将存储在由指针 y p 指示的位置处的值两次加 到指针 x p 指示的位置处的值。另一方面, 函 数 t wi d d l e 2 效 率 更 高 一 些 。 它只要求 3 次内存引用(读*x p , 读* y p , 写*x p ) , 而 t wi d d l e l 需 要 6 次( 2 次读*x p , 2 次读\n*yp , 2 次写*x p ) 。因此, 如 果 要 编 译 器 编 译 过程 t wi d d l e 1 , 我们会认为基于 t wi d d l e 2\n执行的计算能产生更有效的代码。\n不过, 考虑 x p 等于 yp 的 清况 。 此 时 , 函 数 t wi d d l e l 会执行下面的计算:\n3 *xp += *xp; I* Double value at xp *I 4 *xp += *xp; I* Double value at xp *I 结果是 x p 的值增加 4 倍。另一方面, 函数 t wi d d l e 2 会 执 行 下 面的计算:\n9 *XP += 2* *xp; I* Triple value at xp *I\n结果是 xp 的值增加 3 倍。编译器不知道 t wi dd l e l 会如何被调用, 因 此它必须假设参数 xp\n和 yp 可能会相等。因此, 它不 能 产 生 t wi dd l e 2 风格的代码作为 t wi dd l e l 的 优 化 版 本 。\n这种两个指针可能指向同一个内存位置的情况称为内存别 名使 用 ( m e m o r y a li a s ing ) 。在只执行安全的优化中, 编 译 器 必 须 假 设 不 同 的 指 针可能会指向内存中同一个位置。再看一个例子, 对千一个使用指针变量 p 和 q 的程序, 考虑下面的代码序列:\nX = 1000; y = 3000;\n*q = y; I* 3000 *I *P = X j I* 1000 *I t1 = *q; I* 1000 or 3000 *I t l 的计算值依赖于指针 p 和 q 是否指向内存中同一个位置 如果不是, t l 就等于\n3000, 但如果是, t1 就等于 1000 。这造成了一个主要的妨碍优化的因素, 这 也 是 可能严重限制编译器产生优化代码机会的程序的一个方面。如果编译器不能确定两个指针是否指 向 同 一 个 位 置 , 就必 须 假设 什 么情 况都有可能,这 就 限 制了可能的优化策略。\n沁囡 练习题 5. 1 下面的问题说明了内存别名使用可能会导致意想不到的程序行为的方式。 考虑下面这 个交换 两个 值的过程:\nI* Swap value x at xp with value y at yp *I void swap(long *XP, long *yp)\n{\n*XP = *XP + *yp;\n*YP = *XP - *yp;\n*XP = *XP - *yp;\n如果调用这个过程 时 xp 等 于 yp , 会有什么样的效果?\n第二个妨碍优化的因素是函数调用。作为一个示例, 考虑下面这两个过程:\nlong f();\nlong func10 {\nreturn f () + f () + f () + f () ;\n}\nlong func2() { return 4*f () ;\n}\n最初看上去两个过程计算的都是相同的结果, 但 是 f u n c 2 只 调 用 f 一 次 , 而 fu ncl\n调用 f 四次 。以 f u nc l 作为源代码时,会 很 想 产 生 f u n c 2 风 格 的 代 码 。不 过 ,考 虑下 面 f 的代码:\nlong counter= O;\nlong f O {\nreturn counter++;\n}\n这个函数有个副作用一 它 修 改 了 全 局 程序状态的一部分。改变调用它的次数会改变程 序 的 行 为。特别地, 假设开始时全局变最 c oun t er 都设詈为 o, 对 f u n c l 的调用会返回\n0 + 1 + 2 + 3 = 6 , 而对 f un c 2 的调用会返回 4 • O= O。\n大 多 数 编译器不会试图判断一个函数是否没有副作用, 如果没有,就 可能被优化成像\nf unc 2 中的样 子 。 相反, 编译器会假设最糟的情况 ,并保持所有的 函数调用不 变。\n田 日 用内联函数替换优化函数调用\n包含 函 数 调用的 代码可以 用一个称为 内联 函数替 换 ( inli ne s ubstit ut ion , 或者简称S\n“ 内联( in linin g ) \u0026quot; ) 的过程进行优化, 此时, 将函数调用替 换 为 函数体。例如 , 我们可以通过替换掉对函数 f 的四次调用,展 开 f u n c l 的代码:\nI* Result of inlining fin funcl *I long funclinO {\nlong t = counter++; I * +O * I\nt += counter++; t += counter++; t += counter++; return t·,\nI* +1 *I\nI* +2 *I\nI* +3 *I\n这样的转换既减少了函数 调用的 开销,也 允许 对展开的代码做进一步优 化。例如, 编译器可以 统一 f unc li n 中对全 局变量 c ou nt e r 的更新,产 生这 个函数的一个优化版本:\nI* Optimization of inlined code *I long func1opt () {\nlong t = 4 *counter+ 6;\ncounter+= 4; return t;\n}\n对于这 个特定的函数 f 的定义, 上述代码忠实地 重现了 f u n c l 的行为。\nGCC 的最近版本会尝试进行这种形式的优化,要 么是 被 用命 令 行 选项 \u0026quot; - f i n l i ne \u0026quot; 指示时 ,要 么是 使 用优 化等级- 01 或者更高的等级 时。遗憾的是, G CC 只 尝试在单个文件中定义的函数的内联。这就意味着它将无法应用于常见的情况,即一组库函数在一个 文件中被定义,却被其他文件内的函数所调用。\n在某些情 况下, 最好能阻止编译 器执 行 内联替 换。一种情况是用符 号调试器来评估代码,比如 G DB, 如 3. 10. 2 节描 述的一样。如果一个函数调 用已经用内联替 换优化过 了,那 么任何对这个调用进行追踪或设置断点的尝试都会失败。还有一种情况是用代码剖析的方式来 评估程序 性能,如 5. 14. 1 节讨论的一样 。用内联替 换消除的 函数调用是无法被正确剖析的。\n在各种编译 器中 , 就 优 化 能 力 来 说 , G CC 被认为是胜任的, 但 是 并 不 是 特 别 突 出 。它完成基本的优化,但是它不会对程序进行更加“有进取心的“编译器所做的那种激进变 换。因 此,使 用 G CC 的 程 序员 必 须 花费更多的精力,以 一 种 简 化 编译 器生成高效代码的任务的方式来编写程序。\n5. 2 表示程序性能\n我们引入度量标准每元素的周期数 ( C ycl es Per E le men t , CPE), 作为一种表示程序性能并指导我们改进代码的方法。CP E 这种度量标准帮助我们在更细节的级别上理解迭代程序的循环性能。这样的度量标准对执行重复计算的程序来说是很适当的,例如处理图像中的像素 , 或是计算矩阵乘积中的元素。\n处理器活动的顺序是由时钟控制的, 时 钟提供了某个频率的规律信号, 通常用千兆赫兹 CG H z) , 即十亿周期每秒来表示。例如, 当 表明一个系统有 \u0026quot; 4G H z\u0026quot; 处 理 器,这 表示处理器时 钟运行频率为每秒 4 X l 炉个 周 期 。 每个时钟周期的时间是时钟频率的倒数。通常\n是以纳秒 ( nanoseco nd , 1 纳秒等于 1-0 g 秒 )或 皮 秒 ( p icoseco nd , 1 皮秒等于 1-0 12 秒 )为单\n位的。 例如, 一个 4G H z 的时钟其周期为 o. 25 纳秒, 或 者 250 皮秒。从程序员的角度来看,用时 钟周 期 来 表示度量标准要比用纳秒或皮秒来表示有帮助得多。用时钟周期来表示, 度量值表示的是执行了多少条指令,而 不 是 时 钟 运行得有多快。\n许多过程含有在一组元素上迭代的循环。例如,图 5 -1 中 的 函数 p s um l 和 p s um2 计 算的都是 一个长度为 n 的向量的前置和 ( prefi x s um ) 。对于向量 a = ( a。, a1 \u0026rsquo; … , a . -1 〉, 前置和 p = ( p。, P1 , … , P . - 1 〉定 义为\n( 5. 1)\nI* Compute prefix sum of vector a *I\n2 void psum1(float a[] , float p[] , long n)\n3 {\n4 long i ;\n5 p[O] = a [O] ;\n6 for (i = 1; i \u0026lt; n ; i ++)\n7 p[i] = p[i-1] + a [ i ] ;\n8 }\n9\n10 voi d psum2(float a[], float p[J, long n)\n11 {\n12 long i;\n13 p[O] = a[O];\n14 for (i = 1; i \u0026lt; n- 1 ; i+=2) {\nfloat mid_val = p [ i 一 1] + a [ i ] ;\np [ i ] = mid_val;\n17 p[i+1] = mid_val + a[i+1];\n18 }\nI* For even n, finishr ema i n i ng e l em e n t * I\ni f ( i \u0026lt; n )\n21 p[i] = p [ i 一 1] + a [i] ;\n22 }\n图 5-1 前置和函数。这些函数 提供了 我们 如何表示程序性 能的示例\n函数 p s u ml 每次迭代计 算结果向量的一个元素。第二个函数使用循环展 开( loo p un­ ro lling ) 的技术, 每次迭 代计算两个元素。本章后 面我们会探讨循环展开的好处。(关于分析和优化前 置和计算的内容请参见练习题 5. 11、5. 1 2 和家庭作业 5. 1 9。)\n这样一个过程所需 要的时间可以用一个常数加 上一个与被处理元素个数成正比的因子来描述。例如,图 5- 2 是这两个函数需要的周期数关于 n 的取值范围图。使用最小二乘拟\n2500\n跺亟\n。 20 40 60 80 100 120 140 160 180 200\n元素\n图 5-2 前置和函数的性能。两 条线的斜率表明 每元素的周期数 (CPE )的值\n合(least squares fit) , 我们发现, p s uml 和 ps um2 的 运行时间(用时钟周期为单位)分别近似于等式 368 + 9. On 和 368 + 6. On 。 这 两 个 等 式 表 明 对 代 码 计 时 和初始化过程、准备循环以及完成过程的开销为 368 个周期加上每个元素 6. 0 或 9. 0 周期的线性因子。对千较大的\nn 的值(比如说大千 200 ) , 运行时间就会主要由线性因子来决定。这些项中的系数称为每元素的 周期数(简称 CP E) 的有效值。注意,我 们更愿意用每个元素的周期数而不是每次循环的周期数来度量,这是因为像循环展开这样的技术使得我们能够用较少的循环完成计算,而\n我们最终关心的是,对 于给定的向量长度,程 序运行的速度如何。我们将精力集中在减小计算的 CPE 上。根据这种度量标准, ps urn2 的 CPE 为 6. o, 优于 CPE 为 9. 0 的 p s uml 。\n日 日 什么是最小二乘拟合\n对于一 个数据点Cx 1 , Yi), …, (立 , y . ) 的集合 , 我们常常试图 画一条线, 它能最接近于这 些数 据代 表的 X- Y 趋势。使用最小二 乘拟合, 寻找一条形如 y = mx + b 的线, 使得下面这个误差度量最小:\nE(m,b) = (mx1 +b - y;) 2\ni - 1. n\n将 E ( m , b) 分别对 m 和b 求导,把 两个 导数函数设置为 o, 进 行 推导就能得 出计 算 m 和\nb 的算 法。\n练习题 5. 2 在本章后面,我们会从一个函数开始,生成许多不同的变种,这些变种 保持函 数的 行为 , 又具 有 不 同 的 性 能 特性。 对 于其中 三 个 变 种, 我 们 发现运行时 间\n(以时钟周期为单位)可以用下面的函数近似地估计: 版本 1 : 60+35n\n版本 2 : 136+4n\n版本 3 : 157+1. 25n\n每个版 本在 n 取什 么值 时是 三个版 本中最快的? 记住, n 总是 整数。\n3 程序示例 为了说明一个抽象的程序是如何被系统\n地转换成更有效的代码的,我们将使用一个 基于图 5-3 所示向量数据结构的运行示例。向\nd ::三 声酮\n囊由两个内存块表示:头部和数据数组。头部是一个声明如下的结构:\n图 5-3\n向扯的抽象数据类型。向量由头信息\n加上指定长度的数组来表示\ncodeloptlvec.h\nI* Create abstract data type for vector *I typedef struct {\nlong len; data_t *data;\n} vec_rec, *vec_ptr;\ncode/opt/vec.h\n这个声明用 d a t a _t 来表示基本元素的数据类型。在测试中, 我们度量代码对于整数( C 语言的 i n t 和 l o ng ) 和浮点数( C 语言的 fl oa t 和 doub l e ) 数据的性能。为此, 我们会分别为不同的类型声明编译和运行程序,就 像 下 面这个例子对数据类型 l o ng 一样:\ntypedef long data_t;\n我们还会分配一个 l e n 个 d a t a _ t 类型对象的数组,来 存 放 实 际 的 向 量 元 素。\n图 5-4 给出的是一些生成向扯、访问向量元素以及确定向扯长度的基本过程。一个值得 注意的重要特性是向量访问程序 g e t _ ve c _ e l e me n 七, 它 会对每个向量引用进行边界检查。这段代码类似于许多其他语言(包括J a va ) 所使用的数组表示法。边界 检查降低了程序出错的机会,但是它也会减缓程序的执行。\ncode/optlvec.c\nI* Create vector of specified length *I\n2 vec_ptr new_vec (long len)\n3 {\n4 I* Allocate header structure *I\n5 vec_ptr result= (vec_ptr) malloc(sizeof(vec_rec));\n6 data_t *data = NULL;\n7 if (!result)\n8 return NULL; I* Couldn\u0026rsquo;t allocate storage *I\n9 result-\u0026gt;len = len;\nI* Allocate array *I\nif (len \u0026gt; 0) {\ndata = (data_t *)calloc(len, sizeof(data_t));\nif (!data) {\n14 free((void *) result);\n15 return NULL; I* Couldn\u0026rsquo;t allocate storage *I\n16 }\n17 }\n18 I* Data will either be NULL or allocated array *I\n19 result-\u0026gt;data = data;\n20 return result;\n21 }\n22\n23 f*\n* Retrieve vector element and store at dest.\n* Return O (out of bounds) or 1 (successful)\n*I\n27 int get_vec_element(vec_ptr v, long index, data_t *dest)\n28 {\nif (index \u0026lt; 0 11 index \u0026gt;= v-\u0026gt;len)\nreturn O;\n*dest = v-\u0026gt;data[index];\nreturn 1;\n33 }\n34\nI* Return length of vector *I\nlong vec_length(vec_ptr v)\n37 {\n38 return v-\u0026gt;len;\n39 }\ncode/otp/vec.c\n图 -5 4 向量抽象数据类型的实现。在实际 程序中 , 数据类型 da t a_t 被声明为\n耳 北、 l ong 、 fl oat 或 doub l e\n作为一个优化示例, 考虑图 5-5 中所示的代码, 它使用某种运算, 将一个向量中所有的元素合并 成一个值。通过 使用编译时常数 IDEN T 和 OP 的不同定义, 这段代码 可以重编译成对数据执行不同的运算。特别地,使用声明:\n#define !DENT 0\n#define OP +\n它对向量的元素求和。使用声明:\n#define !DENT 1\n#define OP *\n它计算的 是向量元素的乘积。\nI* Implementation with maximum use of data abstraction *I void combinel(vec_ptr v, data_t *dest)\n{\nlong i;\n*dest = !DENT;\nfor (i = O; i \u0026lt; vec_length(v); i++) { data_t val;\nget_vec_element(v, i, \u0026amp;val);\n*dest = *dest OP val;\n}\n图 5-5 合并运算 的初始实 现。使用基本元素 IDENT 和合 并运算 OP 的不同声明, 我们可以测量该函数对不同运算的性能\n在我们的讲述中 , 我们会对这段代码进行一系列的 变化, 写出这个合并 函数的不同版本。为了评估性能变化, 我们会在一个具 有 Intel Core i7 H as well 处理器的机器上测量这些函数的\nCPE性能, 这个机器称为参考机。3.1 节中给出了一些有关这个处理器的特性。这些 测量值刻画的是程序在某个特定的机器上的性能,所以在其他机器和编译器组合中不保证有同等的性能。 不过,我们 把这些结果与许多不同编译器/处理器组合上的结果做了比较, 发现也非常相似。\n我们会进行一组变换,发现有很多只能带来很小的性能提高,而其他的能带来更巨大的效果。确定该使用哪 些变换组合确实是编写快速代码的 "糜术( black a r t ) \u0026quot; 。有些不能提供可测量的好处的组合确实是无效的,然而有些组合是很重要的,它们使编译器能够进 一步优化。根据我们的经验,最好的方法是实验加上分析:反复地尝试不同的方法,进行 测最,并检查汇编代码表示以确定底层的性能瓶颈。\n作为一个起点 ,下 表给出的是 c omb i n e l 的 CPE 度量值,它 运行在我们的参考机上, 尝试了操作(加法或乘法)和数据类型(长整数和双精度浮点数)的不同组合。使用多个不同 的程序,我 们的实验显示 32 位整数操作和 64 位整数操 作有相同的性能,除 了 涉及除法操作的代码 之外。同样, 对于操作单精度和双精度浮点数据的程序 , 其性能也是相同的。因此在表中 , 我们将 只给出整数数 据和浮点数据各自的结 果。\n函数 方法\n整数 浮点数\ncombinel combinel\n抽象的未优化的抽象的- 01\n*\n20.02\n10. 12\n+\n19. 98\n10. 17\n*\n20. 18\n11. 14\n可以看到测量值 有 些 不 太 精 确。 对 于 整 数 求 和 的 CPE 数 更 像 是 23. 00 , 而不是\n22. 68; 对千整数乘积的 CPE 数则是 20. 0 而非 20. 0 2。我们不会 "捏造“ 数据让它们看起来好看一点儿,只 是 给出了实际获得的测量值。有很多因素会使得可靠地测量某段代码序列 需 要 的 精 确 周 期 数 这个任务变得复杂。检查这些数字时,在 头 脑 里 把 结 果 向 上 或者向下取整几百分之一个时钟周期会很有帮助。\n未经优化的代码是从 C 语言代码到机器代码的直接翻译 , 通常效率明显较低。简单地使 用 命 令 行 选 项 \u0026quot; - 0 1\u0026quot; , 就会进行一些基本的优化。正如可以看到的,程序员不需要做什么 ,就 会 显 著 地提高程序性能—— 超过两个数量级。通常,养 成 至少使用这个级别优化的习 惯 是 很 好 的 。(使 用 - Og 优化级 别 能得到相似的性能结果。)在剩下的测试中, 我们使用\n- 0 1 和 - 0 2 级 别 的 优 化来 生成 和测量程序。\n4 消除循环的低效率 # 可以观察到, 过程 c omb i n e l 调用 函 数 v e c —l e n g t h 作 为 f or 循环的测试条件,如图 5 - 5 所 示。回想关于如何将含有循环的代码翻译成机器级程序的讨论(见3. 6. 7 节),每次 循 环 迭 代时都必须对测试条件求值。另一方面,向 量的长度并不会随着循环的进行而改变 。因 此 , 只需 计 算 一 次 向量 的 长 度,然 后 在 我们的测试条件中都使用这个值。\n图 5-6 是一个修改了的版本, 称为 c ombi n e 2 , 它在开始时调用 ve c _ l e ng t h , 并将结果 赋 值 给局部变量 l e n g t h。对千某些数据类型和操作, 这个变换明显地影响了某些数据类 型 和操作的整体 性能 ,对 千其他的则只有很小甚至没有影响。无论是哪种情况,都 需要这种变换来消除这个低效率,这有可能成为尝试进一步优化时的瓶颈。\nI* Move call to vec_length out of loop *I\nvoid combine2(vec_ptr v, data_t *dest)\n3 {\nlong i;\nlong length= vec_length(v);\n6\n7 *dest = IDENT;\n8 for (i = O; i \u0026lt; length; i++) {\ndata_t val;\nget_vec_element(v, i ,. \u0026amp;val);\n*dest = *dest OP val; 12 }\n13 }\n图 5-6 改进循环测试的效率。通过把对 ve c _l e ngt h 的 调用移 出循环测试, 我们不 再需要每次迭代时都执行这个函数\n函数 方法 整数 浮点数 + * 十 * combinel combine2 抽象的 -01 移 动 vec_l engt h 10. 12 10. 12 7. 02 9. 03 10. 17 11. 14 9. 02 11. 03 这个 优 化 是 一 类常见的优化的一个例子,称 为代码移动( co d e m o t io n ) 。这类优化包括识 别 要 执 行 多 次(例 如在循环里)但是计算结果不会改变的计算。因而可以将计算移动到代码 前 面不会被多次求值的部分。在本例中, 我们将对 v e c _ l e n g t h 的调用从循环内部移动\n到循环的前面。\n优化编译器会试着进行代码移动。不幸的是,就像前面讨论过的那样,对于会改变在 哪里调用函数或调用多少次的变换,编译器通常会非常小心。它们不能可靠地发现一个函 数是否会 有副作用, 因而假设函数会有副作用。例如, 如果 v e c _ l e n g t h 有某种副作用, 那么 c o mbi n e l 和 c o mbi n e 2 可能就会有不同的行为。为了改进代码, 程序员必须经常帮助编译器显式地完成代码的移动。\n举一个 c ombi n e l 中看到的循环低效率的极端例子, 考虑图 5-7 中所示的过程 l o w­ e r l 。这个过程模仿几个学生的函数设计, 他们的函数是作为一个网络编程项目的一部分交上来 的。这个过程的 目的是将一个字符串中所有大写字母转换成小写字母。这个大小写转换涉 及将 \u0026quot; A\u0026quot; 到 \u0026quot; z\u0026quot; 范围内的字符转换成 \u0026quot; a\u0026quot; 到 \u0026quot; z\u0026quot; 范围内的字符。\nI* Convert string to lowercase: slow *I void lower1(char *s)\n{\nlong i;\nfor (i\n7 if\n8\n9\n10\n= O; i \u0026lt;\n(s [i] \u0026gt;=\ns (i] -=\nstrlen(s); i++)\nI A\u0026rsquo;\u0026amp;\u0026amp; s [i] \u0026lt;= I z I)\n(\u0026lsquo;A\u0026rsquo;-\u0026lsquo;a\u0026rsquo;);\nI*Convert string to lowercase: faster *I\nvoid lower2(char *s)\n13 {\n14 long i;\n15 long len = strlen(s);\n16\nfor (i\nif\n19\n20\n21\n= O; i \u0026lt; len; i++) (s[i] \u0026gt;=\u0026lsquo;A\u0026rsquo;\u0026amp;\u0026amp; s[i] \u0026lt;= s [i] -= (\u0026lsquo;A\u0026rsquo;-\u0026lsquo;a\u0026rsquo;) ;\nI Z\u0026rsquo;)\nI* Sample implementation of library function strlen *I\nI* Compute length of string *I\nsize_t strlen(const char *s)\n25 {\n26 long length= O;\n27 while (*s !=\u0026rsquo;\\0\u0026rsquo;) {\n28 s++;\n29 length++;\n30 }\n31 return length;\n32 }\n图5-7 小写字母转换函数 。两个过程的性能差别很大\n对库函数 s tr l e n 的调用是 l o wer l 的循环测试的一部分。虽 然 s tr l e n 通常是用特殊的 x86 字符串处 理指令来实现的, 但是它的整体执行也类似于图 5- 7 中给出的这个简单版本。因为 C 语言中的字符串是以 n u l l 结尾的字符序列, s tr l e n 必 须一步一步地检查这\n个序列,直 到遇到 n u l l 字符。对于一个长度为 n 的字符串, s tr l e n 所用的时间与 n 成正比。因为对 l o wer l 的 n 次迭代的每一次 都会调用 s tr l e n , 所以 l ower l 的整 体运行时间是字符串长度的二 次项, 正比千 n勹\n如图 5-8 所示(使用s tr l e n 的库版本), 这个函数对各种长度的字符串的实际测量值证实了上述分析。l o wer l 的运行时间曲线图随着字符串 长度的增 加上升得很陡峭(图5-8a)。图 5- 8 b 展示了 7 个不同长度字符串的运行时间(与曲线图中所示的有所不同), 每个长度都是 2 的幕。可以观察到, 对于 l o we r l 来说, 字符串长度每增 加一倍,运 行时间都会变为原来的 4 倍。这很明显地表明运行时间是二次的。对于一个长度为 1 04 8 5 76 的字符串来说, l o wer l 需 要超过 1 7 分钟的 CPU 时间。\n250\n200\n150\n记 # u 100\n50\n100 000 200 000 300 000\n字符串长度\na )\n字符串长度\n400 000 500 000\nlower2 0.0000 0.0001 0.0001 0.0003 0.0005 0.0010 0.0020\nb)\n图 5-8 小写字母转换函数的性能 比较。由 千循环结构 的效率比较低, 初始代码 l owe r l\n的运行时间是二次项的 。修改过的代 码 l ower 2 的运行 时间是线性的\n除了把对 s 七r l e n 的调用移出了循环以外,图 5 - 7 中所示的 l ower 2 与 l o wer l 是一样的。做 了这样的变化之后, 性能有了显著改善。对千一个长度为1 048 576 的字符串, 这个函数只需 要 2. 0 毫秒—— 比 l o wer l 快了 500 000 多倍。字符串长度每增加一倍, 运行时间也会增加一倍一 很显然运行时间是线性的。对于更长的字符串 ,运 行时间的改进会更大。\n在理想 的世界里, 编译器会认出循 环测试中对 s tr l e n 的每次调用都会返回相同的结果, 因此应该能够把这个调用移出循环。这需要非 常成熟完善的分析, 因为 s tr l e n 会检查字符串的元素, 而随着 l o wer l 的 进行, 这些值会改变。编译器需要探查, 即使字符串中的字符发生了改变, 但是没有字符会从非 零变为零 , 或是反过来 ,从 零变为非零 。即使是使用内联函数,这样的分析也远远超出了最成熟完善的编译器的能力,所以程序员必须 自已进行这样的变换。\n这个示例说明了编程时一个常见的问题,一个看上去无足轻重的代码片断有隐藏的浙 近低效 率( as ym p to tic ine fficie ncy ) 。人们可不希望一个小写字母转换函数成为程序性能的限制因素。通常 ,会 在小数据集上测试和分析程序 , 对此, l o wer l 的 性能是足够的。不过,当程序 最终部署好以 后, 过程完全可能 被应用到一个有 1 00 万个字符的串上。突然,\n这段无危险的代码变成了一个主要的性能瓶颈。相 比较而言, l o wer 2 的性能对于任意长度的字符串来说都是足够的。大型编程项目中出现这样问题的故事比比皆是。一个有经验 的程序员工作的一部分就是避免引入这样的 渐近低效 率。\n练习题 5. 3 考虑下面的函数:\nlong min(long x, long y) { return x \u0026lt; y? x : y; } long max(long x, long y) { return x \u0026lt; y? y : x; } void incr(long *xp, long v) { *XP += v; }\nlong square(long x) { return x*x; }\n下面 三个代码片 断调 用这 些 函数 :\nA. for (i = min(x, y); i \u0026lt; max(x, y); incr(\u0026amp;i, 1)) t += square(i);\n:, B. for (i = max(x, y) - 1; i \u0026gt;= min(x, y); incr(\u0026amp;i, -1))\nt += square(i);\nC. long low= min(x, y); long high= max(x, y);\nfor_ (i = low; i \u0026lt; high; incr(\u0026amp;i, 1)) t += square(i);\n假设 x 等于 1 0 , 而 y 等于 1 0 0。填写下表 ,指 出在代 码片断 A C 中 4 个函数每 个被调用的次数:\n5. 5··减·-少过.一 ·程— 调. . 用.\n像我们看到过的那样,过程调用会带来开销,而且妨碍大多数形式的程序优化。从 cornbi n e 2 的代码(见图 5-6 ) 中我们可以 看出, 每次循环迭代都会调用 g e t _ v e c _ e l e me n t 来获取下 一个向量元素。对 每个向 量引用, 这个函数要把向批索引 i 与循环边界 做比较, 很明显 会造成低效 率。在处理任意的数组访问时,边 界检查可能 是个很有用的特性, 但是对 c omb i n e 2 代码的简单分析表明所有的引用都是合法的。\n作为替代, 假设为我们的 抽象数据类型增加一个函数 g e t —v e c _ s 七ar t 。 这个函数返回\n数组的起始地址, 如图 5-9 所示。然后就能写出此图中 c o mbi ne 3 所示的过程,其 内 循环里没有函 数调用。它没有用函数调用来获取每个向 量元素, 而是直接访问 数组。一个纯粹主义者可能 会说这种变换严重 损害了程序的模块 性。原则上来说, 向扯抽象数据类观的使用者甚至不应该需 要知道向批的内容是作为数组来 存储的, 而不是作为诸如链表之类的某种其他 数据结构来存储的。比较实际的程序员 会争论说这种变换是 获得高性能结果的必要步骤。\n函数 方法 整数 浮点数 + * + * cornbine2 combine3 移 动 vec_l engt h 直接数据访问 7. 02 9. 03 7. 17 9. 02 9.02 11. 03 9. 02 11. 03 data_t *get_vec_start(vec_ptr v)\n{\nreturn v-\u0026gt;data;\n}\ncode/opt/vec.c\ncode/otplvec.c\nI* Direct access to vector data *f void combine3(vec_ptr v, data_t *dest)\n{\nlong i;\nlong length= vec_length(v); data_t *data = get_vec_start(v);\n*dest = IDENT;\nfor (i = O; i \u0026lt; length; i++) {\n*dest = *dest OP data[i];\n}\n}\n图5-9 消除循环中的 函数调用。结果代码没有显示性能 提升,但是 它有其他的优化\n令人吃惊的是, 性能没有明显的提升。事实上 , 整数求和的性能还略有下降。显然,内循环中的其他操作形成了瓶颈, 限制性能超过调用ge t _ve c _e l e me n t 。 我们还会再回到这 个函数(见5. 11. 2 节), 看看为什么 c ombi ne 2 中反复的边界检查不会让性能更差 。而现在,我们 可以将这个转换视为一系列步骤中的一步, 这些步骤将最终产生显著的 性能提升。\n5. 6 消除不必要的内存引用\nc o mb i ne 3 的代码将合并运算 计算的值累积在指针 d e 江 指定的位置。通过检查编译出来的为内循环产生的汇编代码 , 可以看出这个属性。在此我们给出数 据类型为 d o ubl e , 合并运算为乘法的 x8 6-64 代码:\nInner loop of combi ne3 . data_t = doubl e , OP=*\ndest in r7. bx, data+i i n r7. dx, data+length in\n. L17: l oop:\nr7. ax\nvmovsd (%rbx) , %xmm0 Read product from dest vmulsd (%rdx), %xmm0, %xmm0 Multiply product by data[i] vmovsd addq %xmm0, (%rbx) $8, %rdx Store product at dest Increment data+i cmpq %rax, %rdx Compare to data+length jne .L17 If !=, goto loop 在这段循环代码中, 我们看到, 指针 d e s t 的地址存放在寄存器%r b x 中,它 还改变了代码, 将第 i 个数据元素的指针保存在寄存器%r d x 中, 注释中显示为 d a t a + i 。每次迭代, 这个指针都加 8。循环终止操作通过比较 这个指 针与保存在寄存器%r a x 中的数值来判断。我们可以看到每次迭代时,累积变量的数值都要从内存读出再写入到内存。这样的读 写很浪费 , 因为每次迭代开始时从 d e s t 读出的值就是上次迭代最后写入的值。\n我们能够消除这种不必要的内存读写,按 照图 5-1 0 中 c ombi n e 4 所示的方式重写代码。引入一个临时 变量 a c e , 它在循环中用来累积计算出来的值。只有在循环完成之后结果才存放在 d e s t 中。正如下面的汇编代码所示, 编译器现在可以用寄存器%x mm0 来保存\n累积值。 与 c o mb i n e 3 中的循环相比, 我们将 每次迭代的内存操作从两次读和一次写减少到只需要一次读。\nInner loop of combi ne4 . data_t = double, OP = * ace in 7.xmmO, data+i i n 肛 dx, data+length in 7.rax\n. L25: loop:\nvmulsd (%rdx), %xmm0, %xmm0 Multiply ace by data[i] addq $8, %rdx Increment data+i cmpq jne\n%rax, %rdx\n.L25\nCompare to data+length\nIf !=, goto loop\nI* Accumulate result in local variable *I\nvoid combine4(vec_ptr v, data_t *dest)\n3 {\nlong i;\nlong length= vec_length(v);\ndata_t *data= get_vec_start(v);\ndata_t acc = IDENT;\n8\n9 for (i = O; i \u0026lt; length; i++) { 1o acc = acc OP data [i] ;\n11 }\n12 *dest = acc;\n13 }\n图 5-10 把结果累积在临时变量中。将 累积值存放在局部变最 a c e ( 累积器 ( accumulator ) 的简写)中, 消除 了每次循环迭代中从内 存中读出并 将更新值写 回的需要\n我们看到程序性能有了显著的提高,如下表所示:\n函数 方法 整数 浮点数 + * + * cornbine3 直接数据访问 7. 17 9.02 9. 02 11. 03 combine4 累积在临时变批中 1. 27 3. 01 3. 01 5.01 所有的时间改进范围从 2. 2 X 到 5. ? X , 整数加法情况的时间 下降到了每元素只需 1. 27 个\n时钟周期。\n可能又有人会认为编译 器应该能够自动将图 5- 9 中所示的 c o mbi ne 3 的代码转换为在寄存器中累积那个值, 就像图 5-10 中所示的 c o mbi ne 4 的代码所做的那 样。然而实际上, 由于内存别名使用 , 两个函数可能会有不同的行 为。例如,考虑整数数据,运算为乘法,标识元 素为 1 的 情况。设 v= [ 2, 3, 5] 是一个由3 个元素组成的向量, 考虑下面两个函数调用 :\ncombine3(v, get_vec_start(v) + 2); combine4(v, get_vec_start(v) + 2);\n也就是在向量最后一个元素和存放结果的目标之间创建一个别名。那么,这两个函数的执\n行如下: # 函数 初始值 循环之前 i = 0 i = 1 i = 2 最后 combine3 [2, 3, 5] [2, 3, l] [2, 3, 2) (2, 3, 6) (2, 3, 36) (2, 3, 36] combine4 [2, 3, 5] [2, 3, SJ [2, 3, 5) (2, 3, 5) (2, 3, 5) (2, 3, 30] 567\n正如前面讲到过的, c o mb i n e 3 将它的结果累积在目标位置中,在 本例中, 目 标位置就 是 向 量的最后一个元素。因此, 这个值首先被设置为 1\u0026rsquo; 然 后 设 为 2 • 1 = 2 , 然后设为\n• 2 = 6 。最后一次迭代中,这 个 值会乘以它自己 ,得 到最后结果 3 6 。对千 c o mb i n e 4 的情 况来说 ,直 到 最 后 向扯都保持不变,结 束之前, 最后一个元素会被设置为计算出来的值1 • 2 • 3 • 5 = 30 。\n当然, 我们说明 c o mb i n e 3 和 c o mb i n e 4 之间差别的例子是人为设计的。有人会说c o mb i n e 4 的 行为更加符合函数描述的意图。不幸的是, 编 译 器不能判断函数会在什么情况 下 被调用,以 及 程序员的本意可能是什么。取而代之, 在编译 c o mb i n e 3 时,保 守 的方法 是 不 断 地读和写内存, 即 使 这样做效率不太高。\n练习题 5 . 4 当 用 带 命令行 选 项 \u0026quot; - 0 2 \u0026quot; 的 GCC 来 编 译 c o 邧江 n e 3 时 , 得 到 的 代 码\nCPE 性 能 远好于使用 - 0 1 时的 :\n函数 方法 整数 浮点数 + 关 + * combine3 用- 01 编译 7. 17 9. 02 9. 02 11. 03 cornbi ne 3 用 - 0 2 编译 I. 60 3. 01 3. 01 5. 01 combine4 累积在临时变扯中 J. 27 3. O l 3 . 0 1 5. 01. 由此得 到 的性能 与 c o mb i n e 4 相 当 , 不 过对于整数 求和的情况除外, 虽 然 性能已经得到 了 显著 的提高 , 但还是低于 c o mb i n e 4。 在 检查编译器产 生的 汇编代码 时,我们发现对内循 环的 一个有趣的 变 化:\nInner loop of combi ne3 . da t a _t = doub l e , OP = *·Compiled -02 dest in¾rbx, data+i in¾rdx, data+length in¾rax\nAce 皿 ml a t ed product in¾xmmO\n.L22: l oop : vmulsd addq (%rdx), %xmm0, $8, %r dx %xmm0 Multipl y product by data[i] Increment dat a+i cmpq %rax, %rdx Compare to da t a +l engt h vmovsd %xmm0, ( %r b x ) Store product at dest jne .L22 If!=, goto loop 把上 面的 代码 与用 优 化等级 1 产 生的 代码进行比较:\nInner loop of combi ne3 . data_t = double, OP = * . Compiled -01 des t in i.r bx, data+i i n 肛 dx, data+length in i.rax\n我们 看 到 , 除 了 指 令 顺 序 有 些 不 同 , 唯 一 的 区 别 就 是 使 用 更 优 化 的 版 本 不 含有\nvm o v s d 指令 , 它 实现的是从 d e s t 指定的位置读 数据(第 2 行)。\n寄存器%x mm0 的角 色在 两个循环中有什 么不 同?\n这个更优化的版本忠 实地实现 了 c o mb i n e 3 的 C 语言代码吗(包 括在 d e s t 和向量数据之间使用内存别名的时候)? 解释为什么这个优化保持了期望的行为,或者给出一个例子说明它产生了与使用较少优化的代码不同的结果。\n使用了这最后的变换,至 此, 对于每个元素的计算, 都只需要 l. 25 ~ 5 个时钟周期。\n比起最开 始采用优化时的 9 ~ 11 个周期, 这是相当大的提高了。现在我们想看看是什么因素在制约着代码的性能,以及可以如何进一步提高。\n5. 7 理解现代处理器\n到目前为止,我们运用的优化都不依赖于目标机器的任何特性。这些优化只是简单 地降低了过程调用的开销,以及消除了一些重大的"妨碍优化的因素",这些因素会给 优化编译器造成困难。随着试图进一步提高性能,必须考虑利用处理器微体系结构的优 化,也就是处理器用来执行指令的底层系统设计。要想充分提高性能,需要仔细分析程 序,同时代码的生成也要针对目标处理器进行调整。尽管如此,我们还是能够运用一些 基本的优化,在很大一类处理器上产生整体的性能提高。我们在这里公布的详细性能结 果,对其他机器不一定有同样的效果,但是操作和优化的通用原则对各种各样的机器都 适用。\n为了理解改进性能的方法,我们需要理解现代处理器的微体系结构。由于大扯的晶 体管可 以被集成 到一块芯片上,现 代微处理器采用 了复杂的硬件, 试图使程序性能最大化。带来的一个后果就是处理器的实际操作与通过观察机器级程序所察觉到的大相径 庭。在代码级上,看上去似乎是一次执行一条指令,每条指令都包括从寄存器或内存取 值,执行一个操作,并把结果存回到一个寄存器或内存位置。在实际的处理器中,是同时 对多条 指令求值的, 这个现象称为指令级并行。在某些设 计中,可 以有 100 或更多条指令在处理中。采用一些精细的机制来确保这种并行执行的行为,正好能获得机器级程序要求 的顺序语义模型的效果。现代微处理器取得的了不起的功绩之一是:它们采用复杂而奇异 的微处理器结构,其中,多条指令可以并行地执行,同时又呈现出一种简单的顺序执行指 令的表象。\n虽然现代微处理器的详细设计超出了本书讲授的范围,对这些微处理器运行的原则有一般性的了解就足够能够理解它们如何实现指令级并行。我们会发现两种下界描述了程序 的最大性能。当一系列操作 必 须按照严格顺序执行时, 就会 遇 到 延迟界限 ( latency\nbound), 因为在下一条指令开始之前,这条指令必须结束。当代码中的数据相关限制了处理器利用 指令级并行的能力时, 延迟界限能够限制程 序性能。吞吐量界限( t hro ug hput bound) 刻画了处理器功能单元的原始计算能 力。这个界限 是程序性能的终极限制。\n7. 1 整体操作\n图 5-11 是现代微处 理器的一个非常简单化的示意图。我们假想的处理器设计是不太严格地 基千近期的 In tel 处理器的结构。这些处理器在工业界称为超标 量 ( s uperscala r ) , 意思是它可以 在每个时钟周期执行多个操作, 而且是乱序的( o ut- of - or de r ) , 意思就是指令执行的顺序不一定要与它们在机器级 程序中的顺序一致。整个设计有两个主要部 分: 指令 控制 单元 (I ns t ru ct io n Control Unit, ICU) 和执行单元 ( E xecu t io n Unit, EU)。前者负责 从内存中读出指令序列,并根据这些指令序列生成一组针对程序数据的基本操作;而后者执行这 些操作。和第 4 章中研究过的按序( in- order ) 流水线相比,乱 序处理器需要更大、更复杂的硬件,但是它们能更好地达到更高的指令级并行度。\n\u0026quot;\n指令控制单元\n:\u0026mdash;\u0026mdash;\u0026mdash;\u0026mdash;\u0026mdash;\u0026mdash;\u0026mdash;\u0026mdash;\u0026mdash;•\n1\u0026mdash;- # 指令高速缓存\n寄存器更新\n,,\n!,预测OK?\n操作结果 地址I I地址\n数据I I I数据\n数据高速缓存\n执行单元\n图 5-11 一个乱序处 理器的框图。指令控制单元负责从内存中读出指令,并 产 生 一 系 列 基 本操作。然后执行单元完成这些操作,以及指出分支预测是否正确\nICU 从指令高速 缓存( ins tru ction cache) 中读取指令, 指令高速缓存是一个特殊的高速存储器,它 包含最近访问 的指令。通常, ICU 会在当前正在执行的指令很早之前取指, 这样它才有足够的时间对指令译码, 并把操作发送到 EU。不过, 一个问题是当程序遇到分支气讨, 程序有两个 可能的前进方向。一种可能会选择分支, 控制被传递到分支目标。另一种可能是,不选择分支,控制被传递到指令序列的下一条指令。现代处理器采用了一 种称为分支预测( bra nch prediction ) 的技术, 处理器会猜测是否会选择分支,同 时还预测分支的目标地址。使用投机执行( speculative execution ) 的技术, 处理器会开始取出位于它预测的分支会跳到的地方的指令,并对指令译码,甚至在它确定分支预测是否正确之前就 开始执行这些操作。如果过后确定分支预测错误,会将状态重新设置到分支点的状态,并 开始取出和执行另一个方向上的指令。标记为取指控制的块包括分支预测,以完成确定取 哪些指令的任务。\n指令译码逻辑 接收实际的程序指令 ,并 将它们转换成一组基本操作(有时称为微操作)。每个这样的操作都完成某个简单的计算任务,例如两个数相加,从内存中读数据,或是向内 存写数据。对千具有复杂指令的 机器,比如 x86 处理器, 一条指令可以被译码成多个操作。关于指令如何被译码成操作序列的细节,不同的机器都会不同,这个信息可谓是高度机密。 幸运的是, 不需要知道某台机器实现的底层细节,我 们也能优化自己的程序。\ne 术语“分支” 专指条件转移指令 。对处理器来说,其他可能 将控 制传送到多 个目的地址的 指令, 例如过程返回和间接跳转,带来的也是类似的挑战。\n在一个典 型的 x86 实现中, 一条只对寄存器操作的指令, 例如\naddq %rax,%rdx\n会被转化成一个操作。另一方面,一条包括一个或者多个内存引用的指令,例如\naddq %rax,8(%rdx)\n会产生多 个操作, 把内存引用和算术运算分开。这条指 令会被译码成为三个操作: 一个操作从内存 中加载一 个值到处理器中, 一个操作将加载进来的值加上寄存器%r a x 中的值,而一个操作将结果存回到内存。这种译码逻辑对指令进行分解,允许任务在一组专门的硬 件单元之间进行分割。这些单元可以并行地执行多条指令的不同部分。\nEU 接收来自取指单元的 操作。通常, 每个时钟周期会接收多个操作。这些操作会被分派到一组功能单元中,它们会执行实际的操作。这些功能单元专门用来处理不同类型的 操作。\n读写内存是由 加载和存储单元实现的。加载单元 处理从内存读数据到处理器的操作。这个单元有一个加法器来完成地址计算。类似,存储单元处理从处理器写数据到内存的操 作。它也有 一个加法器来完成地址 计算。如图中所示,加载 和存储单元通过数据高速 缓存(data cache )来访问内存。数据高速缓存是一个高速存储 器, 存放着最近访问的数据值。\n使用投机执行技术对操作求值,但是最终结果不会存放在程序寄存器或数据内存中, 直到处理器能 确定应该实际执行这些指令。分支操作被送到 E U , 不是确定分支该往哪里去,而是确定分 支预测是否正确。如果预测错误 , E U 会丢弃分支点之后计算出来的结果。它还会发 信号给分支单元, 说预测是错误的, 并指出正 确的分支目的。在这种情况中,分支单元开始 在新的位置取指。如在 3. 6. 6 节中看到的, 这样的预测错 误会导致很大的性能开销。在可以取出新指令、译码和发送到执行单 元之前 , 要花费一点时间。\n图 5-11 说明不同的功能单元被设计来执行不同的操作。那些标记为执行“算术运算” 的单元通常是专门用来执行整数和浮点数操作的不同组合。随着时间的推移,在单个微处 理器芯片上能够集成的晶体管数量越来越多,后续的微处理器型号都增加了功能单元的数 量以及每个单元能执行的操作组合,还提升了每个单元的性能。由于不同程序间所要求的 操作变化很大,因此,算术运算单元被特意设计成能够执行各种不同的操作。比如,有些 程序也许会涉及整数操作,而其他则要求许多浮点操作。如果一个功能单元专门执行整数 操作,而另一个只能执行浮点操作,那么,这些程序就没有一个能够完全得到多个功能单 元带来的好处了。\n举个例子 , 我们的 In t el Core i7 H as well 参考机有 8 个功能单元, 编号为 0 7。下面\n部分列出了每个单元的功能:\n0: 整数运算、浮点乘、整数和浮点数除法、分支\n1: 整数运算、浮点加、整数乘、浮点乘\n2: 加载、地址计算\n3: 加载、地址计算\n4: 存储\n5: 整数运算\n6: 整数运算、分支\n7: 存储、地址计算\n在上面的列表中,“整数运算”是指基本的操作,比如加法、位级操作和移位。乘法\n坎\n和除法需要更多的专用资源。我们看到存储操作要两个功能单元 一个计算存储地址, 一个实际保存数据。5. 1 2 节将讨论存储(和加载)操作的机制。\n我们可以看出功能单元的这 种组合具有同时 执行多个同类型操作的潜力。它有 4 个功能单元可以执行整数操作, 2 个单元能执行加载操作, 2 个单元能执行浮点乘法。稍后我们将看到这些资源对程序获得最大性能所带来的影响。\n在 IC U 中, 退役单元 ( retirem e n t u nit ) 记录正在进行的处理,并 确保它遵守机器级程序的顺序语义。我们的图 中展示了一个寄存器文件 ,它 包含整数 、浮点数和最近的 SSE 和A V X 寄存器, 是退役单 元的一部分, 因为退役单 元控制这些 寄存器的更新。指令译码时, 关千指令的信息被放置在一个先进先出的队列中。这个信息会一直保持在队列中,直到发 生以下两个结果中的一个。首先,一旦一条指令的操作完成了,而且所有引起这条指令的 分支点也都被确认为预测正确, 那么这条指令就可以 退役 ( ret ired ) 了, 所有对程序寄存器的更新都可以被实际执行了。另一方面,如果引起该指令的某个分支点预测错误,这条指 令会被清空 ( fl us hed ) , 丢弃所有计算出来的结果。通过这种方法,预测错误就不会改变程序的状态了。\n正如我们已经描述的那样,任何对程序寄存器的更新都只会在指令退役时才会发生, 只有在处理器能够确信导致这条指令的所有分支都预测正确了,才会这样做。为了加速一 条指令到另一条指令的结果的传送,许多此类信息是在执行单元之间交换的,即图中的\n“操作结果”。 如图中的箭头所示, 执行单元可以直接将结果发送给彼此。这是 4. 5. 5 节中简单处理器设计中采用的数据转发技术的更复杂精细版本。\n控制操作数在执行单元间传送的最常见的机制称为寄存 器重 命名( register renaming) 。当一条更新寄存器r 的指令译码时, 产生标记 t , 得到一个指向该操作结果的唯一的标识符。条目(r , t ) 被加入到一张表中,该表维护着每 个程序寄存器 r 与会更新该寄存器的操作的标记 t 之间的关联。当随后以寄存器 r 作为操作数的指令译码时, 发送到执行单元的操作会包含 t 作为操作数源的值。当某个执行单元完成第一个操作时, 会生成一 个结果( v , t )\u0026rsquo; 指明标记为 t 的操作产生值 V。所有等待t 作为源的操作都能使用 v 作为源值, 这就是一种形式的数据转发。通过这种机制,值可以从一个操作直接转发到另一个操作,而不是写到寄存器文件再读出来,使得第二个操作能够在第一个操作完成后尽快开始。重命名表只包含关于有未进行写操作的寄存器条目。当一条被译码的指令需要寄存 器 r , 而又没有标记与这个寄存器相关联,那么可以直接从寄存器文件中获取这个操作数。有了寄存器重命名,即使只有在处理器确定了分支结果之后才能更新寄存器,也可以预测着执行操作的整个序列。\n田 日 乱序处理的历史\n乱序处理 最早是 在 1 964 年 Co nt ro l Da ta Cor pora t ion 的 6600 处理 器中实现的。指令} 由十个不同的功 能单元处理 , 每个单元都 能独立地运 行。在那个 时候 , 这种时钟 频率为 勺\nl OM hz 的机 器被认为是科学计算最好的机器。\n在 1 9 66 年, IB M 首先是在 IB M 360 / 91 上 实现了乱序处理,但 只是用来执行 浮点指令。在大约 25 年的时间 里, 乱序处理 都被认为是一项异乎寻常的 技术,只 在追求尽 可1\n能高性能 的机器中使 用,直到 1 990 年 IBM 在 RS / 6000 系列 工作站中重新 引入 了 这项技术。这种设计成 为 了 IB M / M o t o ro la P o w erP C 系列 的基础, 1 9 93 年引入的 型号 601 , 它i\n成为笫一 个使 用乱序 处理的 单芯片微处理 器。I nt el 在 1995 年的 P ent ium P ro 型号 中引入} 了乱序处理 , P e nt i umP ro 的底 层微体系结构类似 于我们的 参考机 。 i\n为\n7. 2 功能单元的性能\n图 5-1 2 提供了 I nt el Core i7 H as well 参考机的 一些算术运算的性能 , 有的是测量出来的,有的是引用 In tel 的文献[ 49] 。这些时间对于其他处理器来说 也是具有代表性的。每个运算 都是由以下这些数值来刻画的: 一个是延迟(l a te ncy ) , 它表示完成运算所需要的总时间; 另一个是 发射时间 ( is s ue time), 它表示两个连续的同类型的运算之间需要的最小时钟周期 数; 还有一个 是容量( capacit y) , 它表示能够执行该运算的功能单元的数量。\n整数 浮点数 运算 延迟 发射 容扯 延迟 发射 容扯 加法 1 1 4 3 I I 乘法 3 I I 5 1 2 除法 3 - 30 3 - 30 I 3 - 15 3 - 15 I 图 5-12 参考机的操作的延迟、发射时间和容量特性。延迟表明执行实际运算所需要的时钟周期总数, 而发射时间表明两次运算之间间隔的最小周期数。容最表明同时能发射多少个这样的操作。除法 需要的时间依赖于数据值\n我们看到 , 从整数 运算到浮点运算, 延迟是增加的。还可以 看到加法和乘法运算的发射时间 都为 1 , 意思是说在每个时钟周期,处理器都可以开始一条新的这样的运算。这种很短的 发射时间 是通过使用 流 水线实现的。流水线化的功能单元实现为一系列的阶段\n(stage), 每个阶段完成一 部分的运算。例 如, 一个典型的浮点 加法器包含三个阶段(所以有三个周期的延迟):一个阶段处理指数值,一个阶段将小数相加,而另一个阶段对结果 进行舍入。算术运算可以连续地通过各个阶段,而不用等待一个操作完成后再开始下一 个。只有当要执行的运算是连续的、逻辑上独立的时候,才能利用这种功能。发射时间为\n1 的功能单元 被称为完全 流水 线化的 ( f ull y pipelined) : 每个时钟周期可以开始一个新的运\n算。出现容量大于 1 的运算是由于有多个功能单元,就如 前面所述的 参考机一样。\n我们还看到,除法器(用于整数和浮点除法,还用来计算浮点平方根)不是完全流水线 化的一—-它的发射时间等于它的延迟。这就意味着在开始一条新运算之前,除法楛必须完成整个除法。我们还看到,对千除法的延迟和发射时间是以范围的形式给出的,因为某些 被除数和除数的组合比其他的组合需要更多的步骤。除法的长延迟和长发射时间使之成为 了一个相对开销很大的运算。\n表达发射时间的一种更常见的方法是指明这个功能单元的最大吞吐量,定义为发射时间的倒数。一个完全流水线化的功能单元有最大的吞吐量,每个时钟周期一个运算,而发射时间较大的功能单元的最大吞吐量比较小。具有多个功能单元可以进一步提高吞吐量。对一个容量为 C, 发射时间 为 I 的操作来说, 处理器可能获得的吞吐量为每时钟周期 C/ I 个操作。 比如,我 们的参考机可以 每个时 钟周期执行两个浮点乘法运算。我们将看到如何利用这种能力来提高程序的性能。\n电路设计者可以创建具有各种性能特性的功能单元。创建一个延迟短或使用流水线的 单元需要较多的硬件,特别是对于像乘法和浮点操作这样比较复杂的功能。因为微处理器 芯片上 , 对于这些单元 ,只 有有限的空间,所 以 CPU 设计者必须小心地平衡功能单元的数最和它们各自的性能,以获得最优的整体性能。设计者们评估许多不同的基准程序,将 大多数 资源用 千最关 键的操作。如图 5-1 2 表明的那样, 在 Core i7 H as well 处理器的设计中,整数乘法、浮点乘法和加法被认为是重要的操作,即使为了获得低延迟和较高的流水\n线化程度需要大盘的硬件。另一方面,除法相对不太常用,而且要想实现低延迟或完全流 水线化是很困难的。\n这些算术运算的延 迟、发射时 间和容量会影响合并函数的性能。我们用 CP E 值的两个基本界限来描述这种影响 :\n延迟界限给出了任何必须按照 严格顺序完成合并运算的函数所需要的最小 CPE 值。根据功能单元产生结果的最大速率,吞 吐量界 限给出 了 CPE 的最小界限。例如, 因为只有一个 整数乘法器, 它的发射时间为 1 个时钟周期, 处理楛不可能支持每个时钟周期大 于 1 条乘法的速度。另一方面,四个功能单元都可以执行整数加法,处理器就有可能持续每个周 期执行 4 个操作的 速率。不幸的是,因 为需 要从内存读数据, 这造成了另一个吞吐量界限。两个加载单元限制了处理器每个时钟周期最多只能读取两个数据值,从而使得吞吐量 界限为 0. 50。我们会展示延迟界限 和吞吐量界限对合并函数不同版本的影响。\n5. 7. 3 处理器操作的抽象模型\n作为分析在现代处理器上执行的机器级程序性能的一个工具,我们会使用程序的数据 流( data-flow) 表示, 这是一种图形化的 表示方法, 展现了不同操作之间的数据相关 是如何限 制它们的执行顺序的。这些限制形成了图中的关键 路径( critical path) , 这是执行一组机器指令所需时钟周期数的一个下界。\n在继续技术细节之前 ,检 查一下函数 c ombi ne 4 的 CP E 测量值是很有帮助的,到目前为止 c ombi ne 4 是最快的代码:\n我们可以看到,除 了整数加法的 情况,这 些测量值与处理器的延迟界限是一样的。这不是巧合一 它表明这些函数的性能是由所执行的求和或者乘积计算主宰的。计算 n 个元素的乘积或者和需要大约L · n+ K 个时钟周期, 这里 L 是合并运算的延迟, 而 K 表示调用 函数和初始化以 及终止循环的开销。因此 , CP E 就等于延迟界限 L。\n1 从机器级 代码到数 据流图\n程序的数据流表示是非正式 的。我们只是想用 它来形象地描述程序中的数据相关是如何主宰程序的性能的。以 combi ne 4( 图 5-10 ) 为例来描述数据流表示法。我们将注意力集中在循环执行的计算上,因为对于大向量来说,这是决定性能的主要因素。我们考虑类型 为 d o ub l e 的数据、以乘法作为合并运算的情况 , 不过其他数据类型和运算的组合也有几乎一样的结构。这个循 环编译出的代码由 4 条指令组成, 寄存器%r d x 存放指向数组 dat a中第 i 个元素的指 针,%r a x 存放指向数组末尾的指针 , 而%x mm0 存放累积值 a c e。\nInner loop of combi ne4 . data_t = double, OP = *\nace i n 胚 江 皿 0 , data+i i n r 加 dx, data+length in Y.rax\n. L25: l oop:\nvmulsd (%rdx), %xmm0, %xmm0 Multiply ace by data[i] addq $8, %rdx Increment data+i cmpq jne %rax, %rdx .L25 Compare to data+length If !=, goto loop 如图 5-13 所示,在我 们假想的处理器设计中, 指令译码器会把这 4 条指令扩展成为一系列 的五步操作, 最开始的乘法指令被扩展 成一个 l o a d 操作,从 内 存读出源操作数, 和一个 mul 操作, 执行乘法。\n} =ulsd (% cd x ) , %x= O , %x= O addq $8, %r dx\ncmpq %r a x, %r dx jne loop\n毛r a x I % r dx I 号 xmmO\n图 5-13 combi ne 4 的内循 环代码的图形化表示。指令动态地被 翻译成一个或两个操作, 每个操作从其他操作或 寄存器接收 值, 并且为其他操作和寄存器产生值。我们给出 最后一条指令的目标 为标号 l oop 。它跳转到给出的第一条指令\n作为生成程序数据流图表示的一步,图 5-13 左手边的方框和线给出了各个指令是如何使用和更新寄存器的,顶 部的方框表示循环开始时寄存器的值,而底 部的方框表示最后寄存器的值。例如, 寄存器%r a x 只 被 c rnp 操作作为源值, 因此这个寄存器在循环结束时有着同循环开始时 一样的值。另一方面, 在循环中, 寄存器% r d x 既 被使用也被修改。它的初始值被 l o a d 和 a d d 操作使用; 它的新值由 a d d 操作产生, 然后被 c rnp 操作使用。在循环中, rnu l 操作首先使用寄存器%x mm0 的 初始值作为源值 , 然后会修改它的值。\n图 5-13 中的某些操作产生的值不对应于任何寄存器。在右边, 用操作间的弧线来表示。l o a d 操作从内存读出一个 值, 然后把它直接传递到 rnu l 操作。由千这两个操作是通过对一条 vmu l s d 指令译码产生的,所 以这个在两个操作之间传递的中间值没有与之相关联的寄存器。c rnp 操作更新条件码, 然后 j n e 操作会测试这些条件码。\n对于形成循环的代码片段,我们可以将访问到的寄存器分为四类:\n只读:这些寄存器只用作源值,可以作为数据,也可以用来计算内存地址,但是在循 环中它们是不会被修改的 。循环 c o mbi ne 4 的只读寄存器是%r a x 。\n只 写: 这些寄存器作为数据传送操作的目的。在本循环中没有这样的 寄存器。\n局部: 这些寄存器在循环内部被修改和使用, 迭代与迭代之间不相关。在这个循环中,条件码寄存器就是例子: c rnp 操作会修改它们, 然后 j n e 操作会使用它们, 不过这种相关是在单次迭代之内的。\n循环:对于循环来说,这些寄存器既作为源值,又作为目的,一次迭代中产生的值会在另一次迭代中用 到。可以看到,%r d x 和%x mm0 是 c ombi n e 4 的循环寄存器, 对应于程序\n值 da t a +i 和 a c e 。\n正如我们会看到的,循环寄存器之间的操作链决定了限制性能的数据相关。\n图 5-14 是对图 5-13 的图形化表示的进一步改进,目 标是只给出影响 程序执行时间的操作和数据相关。在图 5-14a 中看到, 我们重新排列了操作符, 更清晰地表明了从顶部源寄存器(只读寄存器和循环寄存器)到底部目的寄存器(只写寄存器和循环寄存器)的数据流。\na ) 蜇新排列了图5-13的操作符, 更消晰地表明了数据相关\nb ) 操作在一次迭代中使用某些值, 产生出在下一次迭代中需要的新值\n图 5-14 将 combi ne 4 的 操 作 抽 象 成 数 据 流图\n在图 5-14a 中,如 果操作符不属于某个循环寄存器之间的相关链,那么就把它们标识成白色。例如,比 较( cmp ) 和分支( j ne ) 操作不直接影响程序中的数据流。假设指令控制单元预测会选择分支,因此程序会继续循环。比较和分支操作的目的是测试分支条件,如果不选择分支的话, 就通知 ICU。我们假设这个检查能够完成得足够快,不会减漫处理器的执行。\n在图 5-1 4b 中, 消除了左边标识为白 色的\n操作符,而且只保留了循环寄存器。剩下的 是一个抽象的模板, 表明的是由千循环的一次迭代在循环寄存器中形成的数据相关。在 这个图中可以看到,从一次迭代到下一次迭 代有两个数据相关。在一边,我们看到存储 在寄存器%x mrn0 中的程序值 a c e 的连续的值之间有相关。通过将 a c e 的旧值乘以一个数据元素, 循环计算出 a c e 的新值, 这个数据元素是由 l oad 操作产生的。在另一边, 我们看到循环索引 i 的连续的值之间有相关。每次迭代中,\ni 的旧 值用来计算 l oa d 操作的地址, 然后 add\n操作也会增加它的值,计算出新值。\n图 5-1 5 给出了函数 c ombi ne 4 内循环的 n\n次迭代的数据流表示。可以看出,简单地重\n关键路径\n图 5-15 co mbi ne 4 的 内 循 环的 n 次 迭代计算的数 据 流表示。乘法操作的序列形成了恨制程序性能的关键路径\n复图 5-1 4 右边的模板 n 次, 就 能 得 到 这 张图。我们可以看到, 程 序 有 两 条 数 据 相 关 链 , 分别对 应于操作 mu l 和 a d d 对程序值 a c e 和 d a 七a 江 的 修 改 。 假设浮点乘法延迟为 5 个周期, 而整数加法延迟为 1 个周期,可 以 看 到左边的链会成为关键路径,需 要 Sn 个周期 执 行。右边的 链只需 要 n 个周期执行, 因此, 它不会制约程序的性能。\n图 5-15 说明在执行单精度浮点乘法时,对 于 c o mbi ne 4 , 为什么我们获得了等于 5 个周期延迟界限的 CPE。当执行这个函数时,浮点 乘法器成为了制约资源。循环中需要的其他操作- 控制和测试指针值 da t a +i , 以及从内存中读数据 与乘法器并行地进行。每次后继的\nace 的值被计算出来,它 就反馈回来计算下一 个值, 不过只有等到 5 个周期后才能完成。\n其他数据类型和运算组合的数据流与图 5- 15 所示的内容一样,只 是 在左边的形成数据相关链的数据操作不同。对于所有情况, 如果运算的延迟, L 大 于 1, 那么可以看到测量出来的 CPE 就是 L , 表明这个链是制约性能的关键路径。\n其他性能因素\n另一方面,对 于 整数 加 法的情况, 我们对 c ombi n e 4 的测试表明 CPE 为 1. 27, 而根据沿着图 5-1 5 中左边和右边形成的相关链预测的 CPE 为 1. 00, 测试值比预测值要慢。这说明了一个原则,那就是数据流表示中的关键路径提供的只是程序需要周期数的下界。还 有其他一 些因素会限制性能, 包括可用的功能单元的数量和任何一步中功能单元之间能够传递数据值的数量。对于合并运算为整数加法的情况,数据操作足够快,使得其他操作供 应数据的 速度不够快。要准确地确定为什么程序中每个元素需要 1. 27 个周期,需 要 比 公开可以获得的更详细的硬件设计知识。\n总结一下 c ombi n e 4 的性能分析: 我们对程序操作的抽象数据流表示说明, c o mbi ne 4\n的关键路 径长 L · n 是由对程序值 a c e 的连续更新造成的 , 这条路径将 CPE 限制为最多\nL。除了整数加法之外,对 于 所 有 的 其 他情况, 测 量出的 CPE 确实等千 L , 对于整数加法, 测量出的 CPE 为 1. 27 而不是根据关键路径的长度所期望的 1. 00 。\n看上去,延迟界限是基本的限制,决定了我们的合并运算能执行多快。接下来的任务是重新调整操作的结构,增强指令级并行性。我们想对程序做变换,使得唯一的限制变成吞吐量界 限,得 到接近于 1. 00 的 CPE。\n练习题 5. 5 假设写 一个对多项 式求值的 函 数,这 里, 多项 式的 次数为 n , 系数为 a o ,\na1, ···, a., 。 对于值 X , 我们对多项式求值,计算\na。+ a 1x + a 江 + … + a.,工n (5, 2)\n这个 求值 可以用下面 的函 数来实现, 参数包 括 一个系 数 数 组 a 、值 x 和 多项 式的次 数de gr e e ( 等 式 ( 5. 2 ) 中的值 n ) 。在这个函数的 一个 循环 中, 我们 计算连续的等 式的项, 以及 连续的 x 的幕:\ndouble poly(double a[], double x, long degree)\n2 {\nlong i;\n4 double result= a[O];\n5 double xpwr = x; I* Equals x-i at start of loop *I\n6 for (i = 1; i \u0026lt;= degree; i++) {\na[i] * xpwr; xpwr;\n对于次数 n , 这段代码执行多少次加法和多少次乘法运算?\n在我们 的参 考机 上, 算术运算的 延迟如图 5-1 2 所 示, 我们 测 量 了 这个函 数的 CPE 等于 5. 00 。根据由于实现函数 第 7 ~ 8 行的操作迭代之间形成的数据相关 , 解释 为什么 会得到这样的 CPE。\n沁曷 练习题 5. 6 我们继续探索练 习题 5. 5 中描述的 多项 式求值的 方 法。 通过采用 H orner 法(以英国数学家 W ill iam G. HornerCl 786- 1837) 命名)对多项 式 求值 , 我们 可以减少乘 法的 数量。 其思 想是 反复提出 工 的幕, 得到 下面的求值:\na 。十工Ca 1 + x Ca 2 +··· 十工( a 广]+ 立 ,,)… )) 使 用 H or ner 法, 我们 可 以用 下 面的 代码实现 多项 式求值:\nI* Apply Horner\u0026rsquo;s method *I\ndouble pol yh (doubl e a[], double x, long degree)\n{\n(5. 3)\nlong i;\ndouble result= a[degree];\nfor (i = degree-1; i \u0026gt;= O; i\u0026ndash;) result= a[i] + x*result;\nreturn result;\n}\n5. 8\n对于次数 n , 这段代码执行多少次加法和多少次乘法运算?\n在我们的参考机上, 算术运 算 的廷迟如 图 5-12 所 示, 测 量这个函 数 的 CPE 等于\n8. 00 。 根据由于实现 函数 第 7 行的操作迭代之间形成 的 数据相关, 解释为 什 么会得到 这样的 CPE。\n请解 释虽 然练 习题 5. 5 中所 示的 函数需 要更多的操作 , 但是它是 如何运行得更快的。\n循环展开 # 循环展开是一种程序变换,通过增加每次迭代计算的元素的数批,减少循环的迭代次数。 p s um2 函数(见 图 5-1 ) 就是这样一个例子,其 中 每次迭代计算前置和的两个元素, 因而将需要的迭代次数减半。循环展开能够从两个方面改进程序的性能。首先,它减少了不直接 有助于程序结果的操作的数量, 例 如循环索引计算和条件分支。第二, 它 提供了一些方法, 可以进一步变化代码,减 少 整个计算中关键路径上的操作数量。在本节中, 我们会看 一 些 简 单 的 循 环 展开,不 做 任何 进一 步的变化。\n图 5-16 是合并代码的使用 \u0026quot; 2 X l 循环展开"的版本。第一个循环每次处理数组的两个元素。也就是每次迭代, 循环 索引 l 加 2, 在一次迭代中,对数组元 素 l 和 i + l 使用合并运算。\n一般来说,向 量 长度不一定是 2 的倍 数 。 想 要 使 我们的代码对任意向量长度都能正确\n工 作 ,可 以 从 两个方面来解释这个需求。首先,要 确 保 第一次循环不会超出数组的界限。对 于 长度为 n 的向量,我 们将循环界限设为 n - l 。然后,保 证 只 有 当 循 环 索 引 1 满足 i \u0026lt;\nn —1 时 才会执行这个循环,因 此最大数组索引 i + l 满足 i + l \u0026lt; ( n - l) + l = n。\n把这个思想归纳为对一个循环按任意因子 k 进行 展开,由 此 产 生 k X l 循环展开。为此, 上限 设 为 n - k + l , 在循环内 对元素 年 加+ k— l 应用合并运算。每次迭代 ,循 环 索引 1加k。\n那 么 最大 循 环 索引 汁 k—1 会 小 千 n。要使用第二个循环,以 每次处理一个元素的方式处理向 最 的 最 后几 个元素。这个循环体将会执行 O k - l 次。对 于 k = 2 , 我们能用一个简单的条件语句, 可选地增加最后一次迭代, 如函数 ps um2( 图 5-1 ) 所示 。 对 于 k \u0026gt; 2 , 最后的这些情\n况最好用一个 循环来 表示 ,所 以 对 k = 2 的 情 况, 我们同样也采用这个编程惯例。我们称这种变换为 \u0026quot; k X l 循环展开“,因 为循环展开因子为 k , 而累积值只在单个变量 a c e 中。\nI* 2 x 1 loop unrolling *I\n2 void combine5(vec_ptr v, data_t *dest)\n3 {\nlong i;\nlong length= vec_length(v);\nlong limit= length-1;\ndata_t *data = get_vec_start(v);\ndata_t ace= !DENT;\n9\nI* Combine 2 elements at a time *I\nfor (i = O; i \u0026lt; limit; i+=2) {\nace= (ace OP data[i]) OP data[i+1];\n13 }\n14\nI* Finish any remaining elements *I\nfor (; i \u0026lt; length; i++) {\nace = ace OP data [i] ; 18 }\n19 *dest = ace; 20 }\n图 5-16 使用 2 X l 循环展开。这种变换能减小循环开销的影响区! 练习题 5 . 7 修改 c o mb i n e s 的代码, 展开循 环 k = 5 次。\n当测 量展 开次数 k = 2 ( c o mbi ne 5 ) 和 k = 3 的展开代码的性能时, 得到 下面的结果 :\n函数 方法 整数 浮点数 + * + 兴 combine4 无展开 1. 27 3.01 3. 01 5. 01 combines 2 X l 展 开 1. 01 3.01 3. 01 5. 01 3 X l 展 开 1. 01 3.01 3. 01 5. 01 延迟界限 1. 00 3.00 3. 00 5. 00 吞吐盘界限 o. 50 1. 00 1. 00 0. 50 我们 看到对于整数加法, CPE 有所改进, 得到的延迟界限为 1. 0 0 。会有这样的结果是得益千减少 了循环开销操作。相对于计算向量和所需要的加法数量,降 低 开销操作的数量,此时,整数加法的一个周期的延迟成为了限制性能的因素。另一方面,其他情况并没有性能提高——-它 们 已经达到了其延迟界限。图 5-1 7 给出了当循环展开到 10 次时的 CPE测量值。 对于展开 2 次 和 3 次时观察到的趋势还在继续一 没有一个低千其延迟界限。\n要理解为什么 k X l 循环展开不能将性能改进到超过延迟界限,让 我们来查看一下 k =\n2 时, c o mb i n e s 内 循 环 的机 器级代码。当类型 d a t a —t 为 d o u b l e , 操作为乘法时,生成如下代码:\nInner loop of combi nes . data_t = double, OP=* i in %rdx, data %rax, limit in %rbp, ace in %xmm0\n.L35: loop:\nvmulsd (%rax, %rdx, 8), %xmm0, %xmmO Multiply ace by data[i]\nvmulsd 8(%rax,%rdx,8), %xmm0, %xmm0 Multiply ace by data[i+1]\naddq\ncmpq\n6 jg\n$2, %rdx\n%rdx, %rbp\n.135\n6\n5\n4\nIncrement i by 2 Compare to limit:1. If\u0026gt;, goto loop\ndouble * double + u lQ\u0026hellip;t..l 3\n2\n。\n牖耋 瞿 瞿 攫\nX、、、\n-·-一-· 一一- -、于 一一- 沃\n3 4\n展开次数K\n矗 lo ng *\nlong+\n图 5-17 不同程度 k X l 循 环展开的 CPE 性能。这种变换只改进了整数加法的性能\n我们可以看到,相 比 c o mbi n e 4 生成的基千指针的代码, GCC 使用了 C 代码中数组引用 的 更 加 直 接的转换气 循环索引 J.. 在 寄 存 器%r d x 中 , d a t a 的 地址在寄存器%r a x 中6 和前 面一样,累 积值 a c e 在向量寄存器%x mm0 中 。 循 环 展 开会导致两条 vmu l s d 指令_ _ 条 将 d a t a [ i ) 加 到 a c e 上 , 第 二 条将 d a t a [ i 十 l l 加到 a c e 上。图 5- 1 8 给出了这段代码的图 形 化 表 示。每条 vmu l s d 指令被翻译成两个操作: 一 个 操 作 是 从 内 存 中 加 载 一 个数组元素,另 一个是把这个值乘以已有的 累积值。这里我们 看到,循 环的每次执行中,对 寄存 器 %x mm0 读 和写两次。可以重新排列、简化和抽象这张图,按 照 图 5- 1 9a 所 示的过程得到图 5- 1 9 b 所 示的模板。然后,把 这个模板复制 n / 2 次, 给出一个长度为 n 的向量的计算, 得 到 如图 5- 20 所示的数据流表示。在此我们看到, 这 张 图 中 关 键 路 径 还是 n 个 mu l 操作 一 迭代次数减半了, 但 是 每次迭代中还是有两个顺序的乘法操作。这个关键路径是循环 没有展开代码的性能制约因素, 而它仍然是 k X l 循环展开代码的性能制约因素。\nvmulsd ( %r a x , %r d x, 8 ) , %xmm0, %xmm0\nvmu l s d 8 ( %r a x, % r d x, 8 ) , %x mm0, %xmm0 addq $2 , %r dx\ncmpq % r d x, 号r bp jg l oop\n令r a x l %r bp l %r d x l令xmmo:\n图 5 - 1 8 co mbi ne s 内循环代码的图形化表示。每次迭代有两条 vmul s d 指令, 每条指令被翻译 成一个 l oa d 和一个 mul 操 作\n8 GCC 优化 器 产生一个函数的多个版本, 并 从 中 选择 它预测会获得最佳性能和最小代码蜇的那一个。其结果就是, 源代码中微 小的变化就会生成各种不同形 式的机器码。我们已经发现对基于指针和基于数组的代码的 选择不会影响在参考机上运行的程序的性能。\na ) 重新排列、简 化和抽象图5-18的表示,给出连续迭代之间的数据相关\nb) 每次迭代必须顺序地执行两个乘法图 5-19 将 c ombi ne s 的操作抽象成\n数据流图\nm 让编译器展开循环\n关键路径\n\u0026rsquo;\u0026ndash;\ndata[O]\ndata[l]\nda七a [ 2 J\ndata[3]\ndata[n-2)\ndata[n-1]\n图 5- 20 c ombi ne s 对一个 长度为 n 的向量进行操作的数据流表示。虽然循环展开了 2 次 ,但 是 关 键 路 径上还是 有\nn 个 mul 操作\n编译器可以很容易地执行循环展开。只要优化级别设置得足够高,许多编译器都能 例行公事地做到这 一点。用优 化等级 3 或更高等级调 用 GCC , 它就会执行循环展开。\n9 提高并行性\n在此,程序的性能是受运算单元的延迟限制的。不过,正如我们表明的,执行加法和乘 法的功 能单元是完全流水线化的, 这意味着它们可以每个时钟周期开始一个新操作 ,并 且有些操作可以被多个功能单元执行。硬件具有以更高速率执行乘法和加法的潜力,但是代码不 能利用这种能力 , 即使是使用循环展开也不能, 这是因为我们将 累积值放在一个单独的 变量a c e 中。在前面的计算完成之前 , 都不能计算 a c e 的新值。虽然计算 a c e 新值的功能单元能\n够每个时钟周期开始一个新的操作 , 但是它只会每 L 个周期开始一条新操作, 这里L 是合并操作的延迟。现在我们要考察打破这种顺序相关,得到比延迟界限更好性能的方法。\n5. 9. 1 多个累积变量\n对于一个可结合和可交换的合并运算来说,比如说整数加法或乘法,我们可以通过将\n一组合并运算分割成两个或更多的部分, 并在最后合并结果来提高性能。例如, P\u0026quot; 表示元素 a o\u0026rsquo; a 1\u0026rsquo; … , a n- 1 的 乘积:\n..- 1\nPn=IIa,\ni=O\n假设 n 为偶数, 我们还可以把它写成Pn = PEn X P On\u0026rsquo; 这里 P E\u0026quot; 是索引值为偶数的元素的乘积, 而 P O\u0026quot; 是索引\n值为奇数的元素的乘积:\nn/ 2- 1\nPE.= az,\n,=O\nn/2- 1\nPO.= II 釭+I\ni = O\n图 5- 21 展示的是使用这种方法的代码。它既使用了两次循环展 开, 以使每次迭代合并更多的元素,也使用了两路 并行,将索引值为偶数的元素累积在变 量 a c c O 中, 而索引值为奇数的元素累积在变量 a c c l 中。因此, 我们将其称为\u0026quot; 2X 2 循环展开"。同前面一样,我们 还\nI* 2 x 2 loop unrolling *I\nvoid combine6 (vec_ptr v, data_t *dest)\n{\nlong i;\nlong length= vec_l engt h (v ) ; long limit= l enght-1;\ndata_t *data = ge t _ve c _s t ar t (v) ; data_t accO = !DENT;\ndata_t acc1 = !DENT;\nI* Combine 2 elements at a time *I for (i = O; i \u0026lt; limit; i+=2) {\naccO = accO OP data[i]; acc1 = acc1 OP data[i+1];\n}\nI* Finish anyr ema i ni ng e l ement s * I for (; i \u0026lt; length; i++) {\naccO = accO OP dat a [ i ] ;\n}\n*dest = accO OP ace!;\n包括了第二个循环, 对千向量长度不为2\n的倍数时,这个循环要累积所有剩下的数\n图5-21 运用 2 X 2 循环展开。通 过维护多个累积变位,\n组元素。然后, 我们对 a c c O 和 a cc l 应用 这种方法利用了多个功能单元以及它们的流水线\n合并运算,计算最终的结果。 能力\n比较只做循环展开和既做循环展开同时也使用两路并行这两种方法,我们得到下面的 性能:\n函数 方法 整数 浮点数 + * + * combine4 在临时变址中累积 1. 27 3.01 3. 01 5. 01 combines 2Xl 展 开 1. 01 3. 01 3. 01 5. 01 combi ne6 2 X 2 展 开 0. 81 1. 51 1. 51 2. 51 延迟界限 1. 00 3. 00 3. 00 5. 00 吞吐拭界限 0. 50 1. 00 1. 00 0. 50 我们看到所有情况都得到了改进, 整数乘 、浮点加、浮点乘改进了约 2 倍, 而整数加也有所改进。最棒的是,我们打破了由延迟界限设下的限制。处理器不再需要延迟一个加 法或乘法操作以待前一个操作完成。\n要理解 c ombi ne 6 的性能 , 我们从图 5- 22 所示的代码和操作序列开始。通过图 5-23\n所示的过 程,可 以推导出一个模板, 给出迭代之间 的数据相关 。同 c ombi n e s 一样, 这个内循环包括 两个 vrnu l s d 运算, 但是这些指令被翻译成读写不同寄存器的 mu l 操作,它 们之间没有数 据相关(图5- 23 6 ) 。然后, 把这个模板复制 n / 2 次(图5- 24 ) , 就是在一个长度为 n 的向量上执行这 个函数的模型。可以看到, 现在有两条关 键路径, 一条对应于计算索引为偶数的元素的 乘积(程序值a c c O) , 另一条对应千计算索引为奇数的元素的乘积(程序值 a c c l ) 。 每条关键路径只包含 n / 2 个操作, 因此导致 C P E 大约为 5. 00 / 2 = 2. 50 。相似的分析可 以解释我们观察 到的对于不同的数 据类型和合并运算的 组合, 延迟为 L 的操作的\nCPE 等于 L / 2 。实际上 , 程序正在利用功能单元的流水线能 力, 将利用率提高到 2 倍。唯一的例外是 整数加。我们已将将 CP E 降低到 1. 0 以下, 但是还是有太多的循环开销, 而无法达到 理论界限 0. 50 。\n%r a x l%r bp lr% dx l%xmmO I号xmml\nvmul s d ( 毛r a x, r% dx , 8 ) , 沧xmmO, 号xmmO\nv mul s d 8 (r% ax, 号 r dx, 8) , %xmml , %xmml\na ddq $2, r枭 dx\nc mpq 号 r d x, %r bp\njg loop\n釭 ax l号r bp lr马 dx l%xmm0I%xmml\n图 5-22 co mbi ne 6 内循环代码的图形化表示。每次循环有两条 vrnul s d 指令, 每条指令被翻译成一个 l oad 和一个 mul 操作\n我们可以将多个 累积变量变换归纳为将 循环展开 k 次, 以及并行累积 k 个值, 得到 k X k 循环展 开。图 5- 25 显示了当数值达到 k = 10 时, 应用这种变换 的效果。可以看到, 当 K 值足够大时,程序 在所有情况下几乎都能达到吞吐量界限。整数加在 k = 7 时达到的\nCPE 为 0. 54 , 接近由两个加载单元导致的吞 吐量界限 0. 50 。整数乘和浮点加在 k 3 时达到的 CP E 为 1. 01, 接近由它们的功能单元设 置的吞吐最界限 1. 00 。浮点乘在 k l O 时达\n到的 CP E 为 0. 5 1 , 接近由 两个浮点乘法器和两个加载单元设置的吞吐量界限 o. 5 0 。值得\n注意的是, 即使乘法是更加复杂的操作, 我们的代码在浮点乘上达到的 吞吐量几乎是浮点加可以达到的两倍。\n通常 , 只 有保待能够执行该操作的所有功能单元的流水线 都是满的 , 程序才能达到这个操作的吞吐量界限。对延迟为 L , 容量为 C 的操作而言,这 就要求循环展开因子 k\nC · L 。比如, 浮点乘有 C = 2 , L = 5 , 循环展开因子就必须为 k l O。 浮点 加有 C = l ,\nL = 3 , 则在 k 3 时达到最大吞吐量。\n在执行 k X k 循环展开变换 时, 我们 必须考虑是否要保 留原 始函数的功能。在第 2 章 已经看到, 补码运算是可交换和可结合的, 甚至是当溢出时也是如此。因此, 对于整数 数据类型, 在所有 可能的情况下, c o mbi ne 6 计算出的结果都和 c o mbi ne s 计算出的相同。因此,优化 编译器潜在地能够将 c o mbi ne 4 中所示的代码首先转换成 c ombi n e s 的二路循环展开 的版本, 然后再通过引入并行性, 将之转换成 c o mbi ne 6 的版本。有些编译器可以 做这种或 与之类似的变换来 提高整数数 据的性能 。\na ) 重新排列、简化和抽象图5-22的表示, 给出连续迭代之间的数据相关\ndata[OJ\ndata (1]\ndata[2]\ndata[3]\ndata [n-2」]\nda t a [ n 一1 ]\nb ) 两个mul 操作之间没有相关\n图 5-23 将 c ombi ne 6 的运算\n抽象成数据流图\n图 5- 24 c o mbi ne 6 对一个长度为 n 的向最进行操作的\n数据流表示。现在有两条关键路径,每条关键路径包含 n / 2 个操作\n2 3 4 5 6 7 8 9 10\n展开次数K\ndouble*\ndouble+\nlong *\n- long+\n图 5-25 k X k 循环展开的 CP E 性能。使用这种变换后, 所有的 C P E 都有所改进,接近或达到其吞吐量界限\n另一方面, 浮点乘法和加法不是可结合的。因此,由 于 四 舍 五 入或溢出, c o mb i ne s 和 c o mb i n e 6 可能产生不同的结果。例如, 假想这样一种情况,所 有索引值为偶数的元素都 是 绝 对值非常大的数,而 索引值为奇数的元素都非常接近于 o. 0 。 那 么 ,即 使 最 终的乘\n积 P\u0026quot; 不 会 溢出,乘 积 PE ,, 也 可能上溢,或 者 PO \u0026quot; 也 可 能 下 溢。不过在大多数现实的程序中,不太可能出现这样的情况。因为大多数物理现象是连续的,所以数值数据也趋向千相\n当平滑,不会出什么问题。即使有不连续的时候,它们通常也不会导致前面描述的条件那 样的周期性模式。按照严格顺序对元素求积的准确性不太可能从根本上比“分成两组独立 求积,然后再将这两个积相乘”更好。对大多数应用程序来说,使性能翻倍要比冒对奇怪 的数据模式产生不同的结果的风险更重要。但是,程序开发人员应该与潜在的用户协商, 看看是否有特殊的条件,可能会导致修改后的算法不能接受。大多数编译器并不会尝试对 浮点数代码进行这种变换,因为它们没有办法判断引入这种会改变程序行为的转换所带来 的风险,不论这种改变是多么小。\n5. 9. 2 重新结合变换\n现在来探讨另一种打破顺序相关从而使性能提高到延迟界限之外的方法。我们看到过 做 k X l 循环展开的 c ombi n e s 没有改 变合并向量元素形成和或者乘积中执行的操作。不过,对代码做很小的改动,我们可以从根本上改变合并执行的方式,也极大地提高程序的 性能。\n图 5- 26 给出了一 个函数 c o mbi n e 7, 它与 c o mbi n e s 的展开代码(图5-1 6 ) 的唯一区别在于内循环中元素合并的方式。在 c o mbi n e s 中, 合并是以下面这条语 句来实现的\n12 ace = (ace OP data[i]) OP data[i+1];\n而在 c o mbi ne 7 中, 合并是以这条语句来实现的\n12 ace= ace OP (data[i] OP data[i+1]);\n差别仅在 于两个括号是如何放置的。我们称之为重新 结合变换( rea ss o cia t ion transforma­ tion), 因为括号改 变了向釐元素与累积值 a c e 的合并顺序, 产生了我们称为 \u0026quot; 2 X l a \u0026quot; 的循环展开形式 。\nI* 2 x 1a loop unrolling *I\n2 void combine7(vec_ptr v, data_t *dest)\n3 {\n4 long i;\n5 long length= vec_length(v);\n6 long limit= length-1;\n7 data_t *data = get_vec_start(v);\n8 data_t ace = !DENT;\n9\n10 I* Combine 2 elements at a time *I\n11 for (i = O; i \u0026lt; limit; i+=2) {\n12 ace= ace OP (data[i] OP data[i+1]);\n13 }\n14\nI* Finish any remaining elements *I\nfor (; i \u0026lt; length; i++) {\nace= ace OP data[i];\n18 }\n19 *dest = ace·\n20 }\n图5-26 运用 Z X l a 循环展开 , 重新结合合并操作 。这种方法增加了可以并行执行的操作数最\n对于未 经训练的人来说, 这两个语句可能看上去本质上是一样的, 但是当我们测扯\nCPE 的时候, 得到令人吃惊的结果:\n整数 浮点数 函数 方法 + * + * combi ne 4 累 积 在临时变量中 1. 27 3. 01 3. 01 5. 01 combi ne s 2 X l 展 开 1. 01 3. 01 3. 01 5. 01 combi ne 6 2 X 2 展 开 0. 81 1. 51 I. 51 2. 51 combi ne ? 2 X la 展 开 1. 01 1. 51 1. 51 2. 51 延迟 界限 1. 00 3. 00 3. 00 5. 00 吞吐拭界 限 o. 50 1. 00 1. 00 0. 50 整数加的性能几 乎与使用 k X l 展开的 版本 ( c o mb i n e s ) 的性能相同,而 其他三种情况则 与使用并行累积变量的版本( c o mb i n e 6 ) 相同, 是 k X l 扩展的性能的两倍。这些情况已经突破了延迟界限造成的 限制。\n图 5- 2 7 说明了 c o mb i n e 7 内循环的代码(对千合并操作为乘法, 数据类型为 d o ub l e 的 情况 )是如何被译码成操作, 以及由此得到的数据相关。我们看到, 来自于 vm o v s d 和第一个 vm u l s d 指令的 l o a d 操作从内存中加载向量元素 t 和曰- 1, 第一个 mu l 操作把它们乘起来。然后, 第二个 mu l 操作把这个结果乘以累积值 a c e 。图 5- 28 a 给出了我们如何对图 5- 2 7 的操作进行重新 排列、优化和抽象, 得到表示一次迭代中数据相关的模板(图 5- 28 b ) 。对 于 c o mb i n e s 和 c o mb i n e 7 的模板, 有两个 l o a d 和两个 mu l 操作, 但是只有一个mu l 操作形成了循环寄存 器间的数据相关链。然后, 把这个模板复制 n / 2 次, 给出了 n 个向 量元素相乘所执 行的计算(图5 - 2 9 ) , 我们可以看 到关键路径上只有 n / 2 个操作。每次迭代内的第一个乘法都不需要等待前一次迭代的累积值就可以执行。因此, 最小可能的 CPE 减 少 了 2 倍。\n%r a x I %r bp I %rdx l %x mmOl %x mml\nvmo v s d ( 皂r a x , 毛 r d x , 8), 令x mmO\n} = o vs d 8( 沧 c a a , 号 n ix , 8) , 令 x = O , 号 x= O\nv mo v s d 令x mmO, 令x mml , 号xmm l a dd q $2 , %r d x\nc mp q % r d x , % r bp jg l o op\n图 5- 27 c o mbi n e ? 内循 环代码的图形化表示 。每次 迭代被译码成与 c ombi n e s 或\nc ombi ne 6 类似的 操作, 但是数据相关不同\n图 5- 3 0 展示了当数值达到 k = l O 时, 实现 k X l a 循环展开并重新结合变换的效果。可以看到, 这种变换带来的性能结果与 k X k 循环展开中保持 K 个累积变量的结果相似。对所有的情况来说 ,我 们都接近了由 功能单元造成的吞吐 量界限。\na ) 重新排列、简化和抽象图 5-27的表示, 给出连续迭代之间的数据相关\ndata [i]\ndata[OJ\ndata[l]\ndata[2]\ndata[3)\n关键路径\ndata[i+l]\n上面的mu l 操作让两个二向量元素相乘,而下面的mu l 操作将前面的结果乘以循环变盘a c e . 图 5- 28 将 co rnbi ne 7 的 操作\n抽象成数据流图\n6\n5\n4\nu # data[n-2)\ndata[n-1]\n\u0026lsquo;\\ . 、\n图 5-29 co mbi ne 7 对一个 长度为 n 的向量进行操作的数据流表示。我们 只有一条关键路径 , 它 只 包 含n / 2 个 操 作\ndouble* double+ 1匕1}..\n3\n亡产 ,\u0026rsquo;\n夏 ,,■\n.t. long*\n- - - long+\n0 I I I I I I I\n2 3 4 5 6 7 8 9 JO\n展开次数K\n图 5-30 kX l a 循环展开的 CP E 性能。在这种变换下,所 有 的 CP E 都 有 所改进,几乎达到了它们的吞吐量界限\n在执行重新结合变换时,我们又一次改变向批元素合并的顺序。对于整数加法和乘 法,这些运算是可结合的,这表示这种重新变换顺序对结果没有影响。对于浮点数情况,\n` 必须再次评估这种重新结合是否有可能严重影响结果。我们会说对大多数应用来说,这种差别不重要。\n总的来说, 重 新 结 合 变 换 能 够 减 少 计 算 中 关 键 路 径 上 操 作 的 数 量 , 通 过 更 好 地 利 用 功能单元的 流水线能力得到更好 的性能。大多数 编译 器不会 尝试 对 浮点 运算 做重新结 合 ,因 为这些 运算不保 证 是 可结合 的 。当前 的 GCC 版 本 会对 整 数 运算执行重新结合,但 不 是 总 有 好的效果 。通常 ,我 们 发 现 循 环 展 开 和并 行 地 累 积 在 多 个 值 中 , 是 提 高 程 序 性 能 的 更 可靠的 方 法。沁氐 练习题 5. 8 考虑下面的计算 n 个双精度数 组 成 的 数组 乘 积 的 函 数。 我 们 3 次展开这\n个循环。\ndouble apr od (doubl e a[], long n)\nlong i;\ndouble x, y, z; doubler= 1;\nfor (i = O; i \u0026lt; n- 2 ; i+= 3) {\nx = a[i]; y = a [ i +l ] ; z = a[i+2];\nr =r * x * y * z; I * Product comput a t i on *I\nfor (; i \u0026lt; n ; i ++)\nr *= a [ i ] ; return r ;\n对于标记为 Pr o d uc t c omp u t a t i o n 的行, 可 以用 括 号得 到该 计 算的五 种不 同的结合, 如下所 示 :\nr = ((r * x) * y) * z; I* Ai *I\nr = (r * (x * y)) * z ; I * A2 *I r =r * ((x * y) * z ) ; I* A3 *I r = r * (x * (y * z ) ) ; I* A4 *I r = (r * x ) * (y * z ) ; I* A5 *I\n假 设在一台浮点数乘法延迟为 5 个时钟周期的机器上运行这些函数。 确定 由乘法的数据相 关限定的 CPE 的下界。(提示: 画 出每 次迭代如何计算 r 的图形化表 示会 所帮助。)\n口 开 ;一 用 向 量 指 令 达 到 更 高的 并 行 度\n就像 在 3. 1 节 中 讲 述 的 , I ntel 在 1 999 年 引入 了 SS E 指 令 , SS E 是 \u0026quot; S t re aming SIM D E xt e ns io ns ( 流 SIM D 扩展 )” 的 缩 写, 而 S IM D ( 读 作 \u0026quot; sim- dee\u0026quot; ) 是 \u0026quot; S ingle-In­ s t ru ct ion , M ul tip le- Da ta ( 单指令多 数 据 )” 的 缩写。SS E 功能历 经几代, 最 新 的 版 本为高级 向 量 扩 展 ( advanced vector extens ion ) 或 AVX 。SIMD 执行 模型是 用单条指令对整个向量数 据进行操 作。 这 些向 量保存在一组特殊的向量寄存 器 ( vector register ) 中, 名 字为%\nymmO %ymml 5 。 目前的 AVX 向 量寄存器长为 32 字节 , 因此每一个都可以存放 8 个 32 位数或 4 个 64 位数, 这 些数 据既可以是整数也可以是 浮点数。AVX 指令 可以对这些寄存器执行向 量操作, 比如 并行执行 8 组 数 值 或 4 组 数 值 的 加 法或 乘 法。 例如, 如 果 Y M M 寄存\n器%ymm0 包含 8 个单精度浮点数, 用 a o\u0026rsquo; …, a1 表示, 而%r c x 包含 8 个单精度浮点数的内\n存 地 址 , 用 b。, …, b1 表 示, 那么指 令\nvmul p s ( %r cx) , %ymm0 , %ymm1\n会 从 内 存 中 读 出 8 个值 , 并 行 地 执 行 8 个乘法, 计算 a ;- a ; • b;, O i 7, 并将得到的\n8 个乘积 保存到向 量寄存器 %y mml 。 我们看到 , 一条指令能够产 生对多 个数据值的计算, 因此称 为 \u0026quot; SIMD\u0026quot; 。\nGCC 支持 对 C 语言的扩展 , 能够让程序 员在 程序中使 用向 量操作, 这些操 作能够被编译成 AVX 的向量指令(以及基于早前的 SSE 指令的代码)。这种代码凤格比直接 用汇编 语言写代 码要好, 因 为 GCC 还可以为其他处理器上 的向量指令产生代 码。\n使用GCC 指令、循环展开和多个累积变量的组合, 我们的合并函数能够达到下面的性能 :\n方法 整数 浮点数 int long long int + * `+ * + * + * 标噩 l O X 10 o. 54 1. 01 0. 55 1. 00 1. 01 0. 51 1. 01 o. 52 标最吞吐最界限 0. 50 1. 00 o. 50 1. 00 1. 00 o. 50 1. 00 o. 50 向量 8 X 8 o. 05 0. 24 0. 13 1. 51 o. 12 0 . 08 0. 2 5 0. 16 向量吞吐益界限 0. 06 0. 12 o. 12 o. 12 0 . 0 6 o. 25 0. 12 上表中 , 笫一组数字对应的是按照 c ornbi ne 6 的风格编写的传统标量代码, 循环展开因子为 1 0 , 并维护 10 个 累积 变 量。 第 二组数 字对 应的代码编写形 式 可以被 GCC 编译成\nAVX 向 量代 码。除了使 用向 量操 作外, 这个版本也进行了循环展 开,展 开因子为 8 , 并维护 8 个不 同的 向量累积 变量 。我们给出 了 32 位和 64 位数字的 结果, 因 为向 量指令在笫一种情 况中达 到 8 路并行, 而在笫二种情况中只能达到 4 路 并行。\n可以 看到 ,向 量代码在 32 位 的 4 种情况下几乎都荻得 了 8 倍的提升, 对于 64 位 来说, 在其中的 3 种情况下 荻得 了 4 倍 的提升。只有长整 数 乘法代码在我们尝试将其表 示为向量代 码时性 能不佳。AVX 指令集不 包括 64 位整数的并行乘法指令, 因此 GCC 无法为 此种 情况生成 向量代码。使用向 量指令对合并操作产 生了 新的吞吐量界 限。与标量界限相比 , 32 位 操 作的新界限 小 了 8 倍, 64 位 操作的新界限小了 4 倍。 我们的代码在几种 数据类型和操作的组合上接近了这些界 限。\n5. 10 优化合并代码的结果小结\n我们极大化对向霓元素加或者乘的函数性能的努力获得了成功。下表总结了对千标量 代码所获得的结果,没 有使用 AVX 向量指令提供的向量并行性:\n使用多项优化技术, 我们获得的 CPE 已经接近千 0. 50 和 1. 00 的吞吐量界限, 只 受限于功 能单元的容量。与原始代码相比提升了 10 20 倍 , 且使用普通的 C 代码和标准编译器就获 得了 所有 这些改进。重写代码利用较新的 SIMD 指令得到了将近 4 倍 或 8 倍的性能提升。 比如单精度乘法, CPE 从初值 11. 14 降 到 了 0. 06, 整体性能提升超过 180 倍。这个例子说明现代处理器具有相当的计算能力,但 是 我们可能需要按非常程式化的方式来编写程序以便将这些能力诱发出来。\n5. 11 一些限制因素\n我们已经看到在一个程序的数据流图表示中,关 键 路 径 指 明 了 执 行 该 程 序 所 需 时间的一 个 基本的下界。也就是说,如 果 程序中有某条数据相关链, 这条链上的所有延迟之和等于 T , 那 么 这 个 程 序 至少需要 T 个周期才能执行完。\n我们还看到功能单元的吞吐量界限也是程序执行时间的一个下界。也就是说,假设一 个 程 序 一 共需 要 N 个 某 种 运算的计算,而 微 处 理器只有 C 个能执行这个操作的功能单元, 并 且 这些单元的发射时间为 I 。那么,这 个 程序 的执 行 至 少需 要 N · I / C 个周期。\n在本节中, 我们会考虑其他一些制约程序在实际机器上性能的因素。\n5. 11. 1 寄存器溢出\n循环并行性的好处受汇编代码描述计算的能力限制。如果我们的并行度 p 超过了可用的 寄 存 器 数 量 ,那 么 编译 器会诉诸溢出( s pilling ) , 将某些临时值存放到内存中,通常是在运行时堆栈上分配空间。举个例子,将 c o mbi ne 6 的 多 累 积 变最模式扩展到 k = l O 和k=\n20, 其结果的比较如下表所示:\n我们可以看到对这种循环展 开程度的 增加 没有改善 CPE , 有些甚至还变差了。现代x86-6 4 处理器有 16 个寄存器,并 可以使用 16 个 Y M M 寄存器来保存浮点数。一旦循环变量的数量超过了可用寄存器的数量, 程序就必须在栈上分配一些变量。\n例如,下 面的代码片段展示了在 l O X 10 循环展开的内循环中, 累 积变 量 a c c O 是如何更新的:\nUpdating of accumulator accO in 10 x 10 urolling vmulsd (%rdx) , %xmm0, %xmm0 accO *= data[i]\n我们看到该累积变量被保存在寄存器% x mm0 中 ,因 此 程序可以简单地从内存中读取 d a t a\n[i l , 并 与 这 个 寄 存 器相乘。\n与之相比, 20 X 20 循环展开的相应部分非常不同:\nUpdating of accumulator accO in 20 x 20 unrolling vmovsd 40(%rsp), %xmm0\nvmulsd (%rdx), %xmm0, %xmm0 vmovsd %xmm0, 40(%rsp)\n累积变量保存为栈上的一个局部变量,其 位 置距离栈指针偏移量为 40。程序必须从内存中读 取 两个 数 值 :累 积 变 量的值和 d a t a [ i ] 的值, 将两者相乘后,将 结果 保 存 回内存。\n一旦编译器必须要诉诸寄存器溢出,那 么维 护 多 个 累 积 变量的优势就很可能消失。幸运的是 , x86-64 有足够多的 寄存器,大 多 数 循 环 在 出现寄存器溢出之前就将达到吞吐量限 制 。\n11. 2 分支预测和预测错误处罚\n在 3. 6. 6 节中通 过实验证明 , 当分支预测 逻辑不能正 确预测一个分支是否要跳转的时候,条件分支可能会招致很大的预测错误处罚。既然我们已经学习到了一些关于处理器是 如何工作的知识,就能理解这样的处罚是从哪里产生出来的了。\n现代处理器的工作远超前千当前正在执行的指令,从内存读新指令,译码指令,以确 定在什 么操作数上执行 什么操作。只要指令遵循的是一种简单的顺 序, 那么这种指令流水线化 ( ins tru ctio n pipel ining ) 就能很好地工作。当遇到 分支的时候, 处理器必须猜测分支该往哪个 方向走。对于条件转移的情况, 这意味着要预 测是否会选择分支。对于像间接跳转\n(跳转到由一个跳转表条目指定的地址)或过程返回这样的指令,这意味着要预测目标地 址。在 这里 , 我们 主要讨论条件分支。\n在一个使用投机执行( s peculat ive exec ut ion ) 的处理器中, 处理器会开始执行预测的 分支目标 处的指令。它会避免修改 任何实际的寄存器或内存位置, 直到确定了实际的结果。如果预测正确,那么处理器就会”提交“投机执行的指令的结果,把它们存储到寄存器或 内存。如果 预测错误 , 处理器必须丢弃掉所有投机执行 的结果, 在正确的位置,重 新开始取指令的过程。这样做会引起预测错误处罚,因为在产生有用的结果之前,必须重新填充 指令流 水线。\n在 3. 6. 6 节中我们看 到, 最近的 x8 6 处理器(包含所有可以执行 x86- 64 程序的处理器)有条件传 送指令。在编译条件语句和表达式的时候, GCC 能产生使用这些指令的代码,而 不是更传统的基于控制的条件转移的实现。 翻译成条 件传送的基本思想是计算出一个条件 表达式或语句 两个方向上的值, 然后用 条件传送选 择期望的值。在 4. 5. 7 节中我们看到 , 条件传送指令 可以被实现为普通指 令流水线 化处理的一部分。没有必要猜测条件是否满足 , 因此猜测错误也 没有处罚。\n那么一个 C 语言程序员怎么能够保证分支预测处罚不 会阻碍程序的效 率呢?对于参考机来说, 预测错误处罚是 19 个时钟周期, 赌注很高。对于这个问题没有简单的答案, 但是下面的通用原则是可用的。\n1 不要过分关心可预 测的分支\n我们已经 看到错误的分支预测的 影响可能非常 大, 但是这并不意味着所有的程序分支都会减 缓程序的 执行。实际上, 现代处理器中的分支预测 逻辑非常善于辨别不同的分支指令的有规律的模式和长期的趋势。例如,在合并函数中结束循环的分支通常会被预测为选 择分支, 因此只在最后一次会导致预测错误处罚。\n再来看另一个例子, 当从 c ombi n e 2 变化到 c ombi ne 3 时, 我们把 函数 ge t _v e c _e l e ­ m ent 从函数的内 循环中拿了出来, 考虑一下我们观察 到的结果, 如下所示:\n函数 方法 整数 浮点数 + 骨 + * combi ne 2 combine3 移动 ve c_l e ng t h 直接数据访问 7. 02 9. 03 7. 17 9. 02 9. 02 11. 03 9. 0 2 11. 03 CPE 基本上没变, 即使这个转变消除了每次迭代中用 于检查向量索引是否在界限内的两个条件语句。对 这个函数来说, 这些检测总是确定索引 是在界内的, 所以是高度可预测的 。作为一种测试边界检查 对性能影响的方法, 考虑下面的合并代码 ,修 改 c ombi n e 4 的\n内循环,用 执行 g e t _ v e c _ e l e rne 江 代码的 内联函数结果替换对数据元素的访问。我们称这个新版本为 c o mb i n e 4b 。这段 代码执行了边界检查, 还通过向晕数据结构来引用向量元素。\nI* Include bounds check in loop *I\n2 void comb i ne 4 b ( ve c _p tr v, d at a _t *dest)\n3 {\nlong i ; long length= vec_length(v); data_t acc = !DENT; 8 for (i = O; i \u0026lt; length; i++) { 9 1o if (i \u0026gt;= 0 \u0026amp;\u0026amp; i \u0026lt; v-\u0026gt;len) { acc = acc OP v-\u0026gt;data [i] ; 11 } 12 }\n13 *dest = acc; 14 }\n然后, 我们直接比较使用和不使用边界检查的 函数的 CPE :\n函数 方法 整数 浮点数 + * + * combi ne 4 co rnbi ne 4b 无边界检查 有边界检查 1. 27 3. 01 2. 02 3. 01 3. 01 5. 01 3. 01 5. 01 对整数加法来说, 带边界检测的版本会慢一点, 但对其他三种情况来说, 性能是一样的, 这些情况受限于它们各自的合并操作的延迟。执行边界检测所需 的额外计算可以与合并操作并行执行。处理器能够预测这些 分支的结果, 所以这些求值都不会对形成程序执行中关键 路径的指令的取指和处理产生太大的影响。\n2. 书写适合用条件传送实现的代码\n分支预测只对有规律的模式可行 。程序中的许多测试是完全不可预测的, 依赖于数据的任意特性, 例如一个数是负数还是正数。对千这 些测试, 分 支预测逻辑 会处理得很精糕。对千本质上无法预测的情况, 如果编译器能够产生使用条件数据传送而不是使用条件控制转移的代码,可 以极大地提高程序的性能。这不是 C 语言程序员 可以直接控制的,但是有些表达条 件行为的方法能够更直接地被翻译成条件传送, 而不是其他操作。\n我们发现 GCC 能够为以一种更 ”功能性的“风格书写的代码产生条件传送 ,在这种风 格的代码中, 我们用条件操作来计算值, 然后用这些值来更新程序状态, 这种风格对立于一种更 ”命令式的“ 风格, 这种风格中, 我们用 条件语句来有 选择地 更新程序状态。\n这两种风格也没有严格的规则, 我们用一个例子来说明。假设给定两个整数数组 a 和\nb, 对千每个 位置 i , 我们想将 a [ i ] 设置为 a 巨]和b 巨]中较小的那一个, 而将 b [ i ] 设置为两者中较大的那一个。\n用命令式的风格实现这个函数是检查 每个位置 i\u0026rsquo; 如果它们的顺序与我们想要的不同, 就交换两个元素:\nI* Rearrange two vectors so that for each i , b[i] \u0026gt;= a[i] *I\nv o i d mi nrnax 1 ( l ong a[], long b[], l ong n) { long i; 4 for (i = O; i \u0026lt; n; i++) {\n5 if (a [i] \u0026gt; b[i]) { long t = a[i]; a[i] = b[i]; b[i] = t;\n10\n在随机数据上测试这个函数,得 到 的 CPE 大约 为 13. 50, 而对千可预测的数据, CP E\n为 2. 5~3. 5, 其预测错误惩罚约为 20 个周期。\n用功能式的风格实现这个函数是计算每个位置 1 的最大值和最小值,然 后 将 这些值分\n别赋给 a[ i] 和 b[ i] :\nI* Rearrange two vectors so that for each i, b[i] \u0026gt;= a[i] *I void mirunax2(long a[], long b[], long n) { long i; 4 for (i = O; i \u0026lt; n; i++) { 5 long min= a[i] \u0026lt; b[i] ? a[i] : b[i]; 6 long max= a[i] \u0026lt; b[i] ? b[i] : a[i]; 7 a(i] = min; s b(i] = max; 9 } 10 } 对这个函数的测试表明无论数据是任意的, 还是可预测的, C PE 都 大 约 为 4. 0。(我们还检查 了产生的汇编代码,确 认 它确 实 使 用 了条件传送。)\n在 3. 6. 6 节中讨论过 ,不 是 所 有 的 条 件 行 为都能用条件数据传送来实现,所 以 无 可避免地在某 些情况中, 程序员不能避免写出会导致条件分支的代码, 而 对 于 这 些 条 件 分 支 , 处理器用 分支预测可能会处理得很糟糕。但是,正 如我们讲过的,程 序 员 方 面用一点点聪 明, 有时就能使代码更容易被翻译 成条件数据传送。这需要一些试验, 写 出函数的不同版本,然后 检查产生的汇编代码, 并 测 试 性 能 。\n讫 }练习题 5. 9 对于归 并排序的合并步 骤的传统的 实现 需要 三个 循环[ 98] :\n1 void merge (long src1[] , long src2[] , long dest [] , long n) { long i1 = O;\n3 long i2 = O;\n4 long id= O;\n5 while (i1 \u0026lt; n \u0026amp;\u0026amp; i2 \u0026lt; n) {\n6 if (src1[i1] \u0026lt; src2[i 2])\n7 dest [id++] = s r c1 [ i1 ++] ; else\n9 de s t [ i d++] = sr c 2 [ i 2++] ;\n10\n11 while (i1 \u0026lt; n) 1 2 dest [id++] = src1[i1++] ; 13 while (i 2 \u0026lt; n) 14 dest [id++] = src2[i 2++] ; 对于把 变量 il 和 i 2 与 n 做比较 导致的分 支, 有很 好的预 测性 能---唯一的预测错误\n发生在 它们 第 一次 变成错 误时。 另 一方面,值 sr c l [ il l 和 sr c 2 [ i 2 ] 之间的比 较(第6 行),对于通常的数据来说,都是非常难以预测的。这个比较控制一个条件分支,运 行在随 机数据 上时,得到 的 CP E 大约为 1 5. 0 ( 这里元 素的数 量为 2 n ) 。\n重写这段代码,使得可以用一个条件传送语句来实现第一个循环中条件语句(第\n6 ~ 9 行)的功能。\n5. 12 理解内存性能 # 到目前为止我们写的所有代码,以及运行的所有测试,只访问相对比较少量的内存。 例如,我 们都是在长度小于 1000 个元素的向扯上测试这些合并函数, 数据量不会超过8 000 个字节。所有的 现代处理器都包含一个或多个高速 缓存( ca c h e ) 存储器, 以对这样少量的存储器提供快速的访问。本节会进一步研究涉及加载(从内存读到寄存器)和存储(从 寄存器写到内存)操作的程序的性能,只考虑所有的数据都存放在高速缓存中的情况。在 第 6 章 , 我们会更详细地探究高速缓存是如何丁作的,它 们的性能特性, 以及如何编写充分利用高速缓存的代码。\n如图 5-11 所示, 现代处理器有专门的功能单元来执行加载和存储操作, 这些单元有内部的缓冲区来保存未完成的内存操作请求集合。例如,我们的参考机有两个加载单元, 每一个可以保存多达 72 个未完成的读请求。它还有一个存储单元, 其存储缓冲区能保存最多 42 个写请求。每个这样的单元 通常可以每个时 钟周期开始一个操作。\n5. 12. 1 加载的性能\n一个包含加载操作的程序的性能既依赖于流水线的能力,也依赖于加载单元的延迟。 在参考机上运行合并操作的实验中, 我们 看到除了使用 SIMD 操作时以外, 对任何数据类型组合和合并操作来说, CPE 从 没有到过 0. 50 以下。一个制约示例的 CPE 的因素是,对于每个被计算的元素,所有的示例都需要从内存读一个值。对两个加载单元而言,其每个\n时钟周期只能启动一条加载操作 , 所以 CPE 不可能小于 o. 50。对于每个被计算的元素必\n须加载 k 个值的应用, 我们不可能获得低千 k / 2 的 CP E ( 例 如参见家庭作业 5. 15) 。\n到目前为止,我们在示例中还没有看到加载操作的延迟产生的影响。加载操作的地址 只依赖于循 环索引 i\u0026rsquo; 所以加载操作不会 成为限制性能的关键路径的一部分。\n要确定一台机器上加载操作的延迟,我们可 I 1 typedef struct ELE {\n以建立由一系列加载操作组成的一个计算,一条加载操作的结果决定下一条操作的地址。作为一个例子, 考虑函数图 5-31 中的函数 l i s 七— l e n , 它计算一个链表的长度。在这个函数的循环中, 变扯 l s 的每个后续值依赖千指针引 用 l s - \u0026gt; n e x 七读出的值。测试表明函数 l i s t _ l e n 的 CPE 为\n4.00, 我们认为这直接表明了加载操作的延迟。\nstruct ELE *next; long data; } list_ele, *list_ptr; long list_len(list_ptr ls) { long len = O; while (ls) { len++; 要弄懂这一点,考虑循环的汇编代码:\nInner loop of 1 工s t _l en ls in %rdi, len in %rax\n10\n11\n12\n13 }\nls= ls-\u0026gt;next; return len;\n.13:\naddq $1, %rax\nl oop : I nrc\nement len\n图5-31 链表函数。其性能受限于\nmovq (%rdi), %rdi\nls= ls-\u0026gt;next\n加载操作的延迟\ntestq jne\n%rdi, %rdi\n.L3\nTest ls\nIf nonnull, goto loop\n第 3 行上的 mov q 指令是这个循环中关键的瓶颈。后 面寄存 器%r 中 的每个值 都依赖于加载操作的结果, 而加载操作又以 %r 土 中 的 值作为它的地址。因此, 直到前一次迭代的加载操作完成 , 下一次迭代的加载操作才能 开始 。这个函数的 CPE 等于 4. 00 , 是由加载操作的延迟决定 的。事实上 , 这个测试结果与文档中参考机的 L1 级 cach e 的 4 周期访问时间是一致的 , 相关内容将在 6. 4 节中讨论。\n5. 12. 2 存储的性能\n在迄今 为止所有的示例 中, 我们只分 析了大部分内存引 用都是加载操作 的函数,也 就是从内存位置读到寄存器中。与之对应的是存储 ( s to re ) 操作, 它将一个寄存器值写到内存。这 个操作的性能, 尤其是与加载操作的相互关系, 包括一些很细微的问 题。\n与加载操作 一样, 在大多数情况中, 存储操作能 够在完全流水线 化的模式中丁作, 每个周期 开始一条新的存储。例如, 考虑图 5-32 中所示的函数, 它们将一个长度为 n 的数组 de s t 的元素设置为 0。我们测 试结果为 CPE 等于 1. 00 。对于只具有单个存储功能单元的机器,这已 经达到了最佳情况。\nI* Set elements of array to O *I\nvoid clear_array(long *dest, long n) { long i;\nfor (i = O; i \u0026lt; n; i++)\ndest [i] = O;\n}\n图 5-32 将数组元素设置为 0 的函数。该代码 CPE 达到 1. 0\n与到目前 为止我们已经 考虑过的其他操作不同 , 存储操作并不影响任何寄存器值。因此, 就其本性来 说, 一系列存储操作 不会产生数据相关。只有加载操作会受存储操作结果的影响 , 因为只 有加载操作能从由存储操作写的那个位置读回值。图 5-33 所示的函数write r ead 说明了加载和存储操作之间可能的相互影响。这幅图也展示了该函数的两个示例执行 , 是对两元素数组 a 调用的,该 数组的 初始内容为—10 和 17 , 参数 c n t 等于 3。这些执行说明了加载和存储操作的一些细微之处。\n在图 5-33 的示例 A 中, 参数 s r c 是一个指向数组元素 a [0 l 的 指针, 而 d e s t 是一个指向数组元素 a [1 ] 的指针。在此种情况中, 指针引用 *sr c 的每次加载都会得到值—1 0。因此, 在两次迭代之后 , 数组元素就会分别保持固定为—10 和—9。从 sr c 读出的结果不受对 de s t 的写的 影响。在较大次数的迭代上测试这个示 例得到 CPE 等于 1. 3。\n在图 5-33 的示例 B 中,参数 sr c 和 de s t 都是指向数组元素 a [ OJ 的 指针。在这种情况中, 指针引用*sr c 的每次加载都会得到指针引用* de s t 的前次执行存储的值。因而, 一系列不断增加的值会被存储在这个 位置。通 常, 如果调用函数 wr i t e _r e a d 时 参数 sr c 和 de s t 指向同一个内存位置, 而参数 c n t 的值为 n\u0026gt; O, 那么净效果是将这个位置设置为 n- 1。这个示例说明了一个现象, 我们称之为写/读相 关 ( wr ite / read dependency)-­ 个内存读的结果依赖于一个最近的 内存写。我们的性能测试表明示例 B 的 CPE 为 7. 3。写/读相关导 致处理速度下降 约 6 个时钟周期。\nI* Write to dest, read from src *I\n2 void write_read(long *src, long *dst, long n)\n3 {\nlong cnt = n;\nlong val= O;\n6\nwhile (cnt) {\n*dst = val;\n9 val= (*src)+1;\n10 cnt\u0026ndash;·\n11 }\n12 }\n示例A: wr i t e _ r e a d ( \u0026amp;a [ O ] , \u0026amp;a [ l ] , 3 )\n示例B: wr i 七e _r e ad ( \u0026amp;a [ O] , \u0026amp;a [ 0 ] , 3 )\n图 5-33 写 和读内存位置的代码,以 及示例执行。这个函数突出的是当参数\ns r c 和 de s t 相等时,存 储 和加载之间的相互影响\n为了了解处理器如何区别这两种情况,以及为什么一种情况比另一种运行得慢,我们必 须更加仔细地看看加载和存储执行单元, 如图 5-34 所示。存储单元包含一个存储缓冲区 , 它 包 含巳经被发射到存储单元而又还没有完成的存储操作的地址和数据, 这里的完成包括更新数据高速缓存。提供这样一个缓冲区,使得一系列存储操作不必等待每个操作都更新 高速缓存就能够执行。当一个加载操作发生时,它必须检查存储缓冲区中的条目,看有没\n有地址相匹配。如果有地址相匹配(意味着在写的\n字节与在读的字节有相同的地址),它 就 取 出 相 应的数据条目作为加载操作的结果。\nGCC 生成的 wr i t e r e a d 内循环代码如下 :\nInner loop of 口r i t e_r ead\nsrc in %rdi, dst in %rsi, val in %rax\n.L3: l oop:\nI 加载单元\n存储单元\n存储缓冲区地址数据\n地址\n机器地址{\n数据\nmovq %rax, (%rsi) Write val to dst movq (%rdi), %rax t = *STC\naddq $1, %rax val = t+1\nsubq $1, 。%r dx cnt\u0026ndash; 图5-34\njne .L3 If!= 0, goto loop\n图 5-35 给出了这个循环代码的数 据流表示。\n指令 mo v q %r ax, ( % r s i ) 被翻译 成 两个 操作: S\n数据高速 缓存\n加载和存储单元的细节。存储单元包 含一个未执行的写的缓冲区。加载 单 元 必 须 检 查 它 的 地址是否与存储单元中的地址相符,以发现 写/读相关\naddr 指令计算存储操作的地址 , 在存储缓冲区 创建一个条目, 并且设置该 条目的地址字段。s _ d a t a 操作设置该 条目的数据字段。正如我们会看到的 , 两个计算是独立执行的, 这对程 序的性能来说很重要。这使得 参考机中不同 的功能单元来执行这些操作。\nr% a x I %rdi I %r s i I %r dx\n%r a x: I %rdi 1·% r s i I %rdx\n图 5-35 writer e ad 内循环代码的图 形化表示。第一个 movl 指令被译码两个独立的操作,计算存储地址和将数据存储到内存\n除了由于写和读寄存器造成的 操作之间的数据相关, 操作符右边的弧线表示这些操作隐含的相关。特别地, s —a d d r 操作的地址计算必须在 s —d a t a 操作之前。此外, 对指令movq ( %r d i ) , %r a x 译 码得到的 l o a d 操作必须检查所有未完成的 存储操作的地址, 在这个操作和 s _ a d dr 操作之间创建一个数据相关。这张图中 s _ d a t a 和 l o a d 操作之间有虚弧线。这个数据相关 是有条件的: 如果两个地址相同, l o a d 操作必须等待直 到 s—d a t a 将它的结果存放到 存储缓冲区中,但 是如果两个地址不同, 两个操作就可以独立地进行。\n图 5- 36 说明了 wr i t e _r e a d 内循环操作之间的数据相关。在图 5- 36a 中, 重新排列了操作, 让相关显得更清楚。我们标出了三个涉及加载和存储操 作的相关, 希望引起大家特别的注 意。标号为(1 ) 的弧线表示存储地址必须在数据 被存储之前计算出来。标号为( 2 ) 的弧线表示需要 l o a d 操作将它的地址与所有未完成的存储操作的地址进行比较。最后, 标号为 ( 3 ) 的虚弧线表示条件数据相关, 当加载和存储地址 相同时会出现。\nb ) 图 5-36 抽象 wr i t e r_ ead 的操作。我们首先 重新排列图 5-35 的操作(a)\u0026rsquo; 然后只显示\n那些使用一次迭代中的值 为下一次迭代产生新值的操作 C b)\n图 5-366 说明 了当 移走那些不直接影响迭代与 迭代之间数据流的 操作之后, 会发生什么。这个数据流图给出两个相关链:左边的一条,存储、加载和增加数据值(只对地址相 同的情况有效),右边的一条, 减小变量 c n t 。\n现在我们 可以理 解函数 wr i t e _ r e a d 的 性能特征了。图 5-37 说明的是内循环的多次迭代形成的数据相关。对于图 5-33 示例 A 的情况,有 不同的源和目的地址, 加载和存储操作可以独立进行 , 因此唯一的关键路径是由减少变量 c nt 形成的, 这使得 CP E 等于 1. 0。对于图 5-33 示例 B 的情况, 源地址 和目的地址相同, s _d a t a 和 l oa d 指令之间的数据相关使得关键路径的形成包括了存储、加载和增加数据。我们发现顺序执行这三个操作一共 需要 7 个时钟周期。\n示例A\n二\n关键路径\n示例B\n:丁\n亡\n图 5-37 函数 rw i t e_r ead 的数据流表示。当 两个地址不同时, 唯一的关键路径是减少 cnt\u0026lt;示例\nA ) 。当两个地址 相同时 , 存储、加载和增加数据的 链形成了 关键路径(示例 B)\n这两个例子说明 ,内 存操作的实现包括许多 细微之处 。对于寄存器操作, 在指令被译码成操作的时候, 处理器就可以 确定哪些指令 会影响其他哪些指令。另一方面,对 千内存操作,只有到计算出加载和存储的地址被计算出来以后,处理器才能确定哪些指令会影响 其他的哪些。高效地处理内存操作对许多程序的性能来说至关重要。内存子系统使用了很 多优化,例 如当操作可以独立地进行时 , 就利用这种潜在的并 行性。\n讫i 练习题 5. 10 作为 另 一个具有 潜在的加载-存储相互 影 响的代码 , 考虑下 面 的 函数, 它将 一个数组的内容 复制到另 一个数 组 :\nvoid copy_array(long *src, long *dest, long n)\n{\nlong i;\nfor (i = 0 ; i \u0026lt; n; i ++) dest [i] = src [i] ;\n}\n假设 a 是一个长 度为 1 00 0 的数组, 被初始化为 每个元素 a [ i ] 等于 1。\n调用 c o p y _ arr a y (a+l, a, 999) 的效果是什么?\n调用 c o p y _ arr a y (a , a +l , 9 9 9 ) 的效果是什么?\n我们的性能测试表明问题 A 调用的 CPE 为 1. 2( 循环展开因子为 4 时 , 该值下降到\n0 )\u0026rsquo; 而问题 B 调用 的 CPE 为 5. 0 。 你认为是什么 因 素造成 了这样的性能差 异? 你预计调用 c o p y_ a rr a y (a , a , 99 9 ) 的性能会是怎样的?\n讫; 练习题 5 . 11 我们 测量 出前置和函 数 p s u ml ( 图 5-1 ) 的 CPE 为 9. 00 , 在测试机器上, 要执 行的基本操作一 浮点加法 的延迟只是 3 个时钟周期。 试着理 解为 什 么 我们 的 函数执行效果这么差。\n下面是 这个函数内循 环的 汇编 代码 :\nInner loop of psum1\na in %rdi, i in %rax, cnt in %rdx\n. L5 :\nvmovss vaddss vmovss addq cmpq jne\n-4(%rsi,%rax,4), %xmm0 (%rdi,%rax,4), %xmm0, %xmm0\n%xmm0, (%rsi,%rax,4)\n$1, %rax\n%rdx, %rax\n. L5\nl oop:\nGe t p [ i 一 1]\nAdd a[i] Store at p[i] Increment i Compare i:cnt\nIf !=, goto loop\n参考对 c ombi ne 3( 图 5- 1 4 ) 和 wr 迂 e _ r e a d ( 图 5- 36) 的分析, 画 出这 个循环 生成 的数据相 关图 , 再画出 计算进行 时由 此形 成的关键 路径。 解释 为 什么 CPE 如此之 高。\n江 练习 题 5. 12 重写 p s u ml ( 图 5-1 ) 的代 码 , 使之 不 需 要 反复地从内存 中读取 p [ i ] 的值。 不需要使 用循环展开。 得到 的代 码 测 试 出 的 CPE 等 于 3. 00, 受浮点加法延迟的限制。\n13 应用: 性能提高技术\n虽然只考虑了有限的一组应用程序,但是我们能得出关千如何编写高效代码的很重要 的经验教 训。我们已经描述了许多优化程序性能的基本策略:\n高级 设 计。为遇到的问题选择适当的算法和数据结构。要特别警觉, 避 免 使 用 那些会渐进地产生糟糕性能的算法或编码技术。 基本编码原则。 避免限制优化的因素, 这样编译器就能产生高效的代码。 消除连续的函数 调用。在可能时,将 计 算移到循环外。考虑有选择地妥协程序的模块性以获得更大的效 率。 消除不必要的内存引用。引入临时变量来保存中间结果。只有在最后的值计算出来 时, 才将结果存放到数组或全局变量中。 3 ) 低级优化。结构化代码以利用硬件功能。\n展开循环,降低开销,并且使得进一步的优化成为可能。 通过使用 例如多个累积变量和重新结合等技术 , 找到方法 提高指令级并行。 用功能性的风格重写条件操作,使得编译采用条件数据传送。\n最后要给读者一个忠告,要警惕,在为了提高效率重写程序时避免引入错误。在引入 新变蜇、改变循环 边界和使得代码整体上更复杂时, 很容易犯错误。一项有用的技术是在优化函数时,用检查代码来测试函数的每个版本,以确保在这个过程没有引入错误。检查 代码对函数的新版本实施一系列的测试, 确保它们产生与原来一样的结果。对于高度优化的 代码 , 这组测试情况必须变得更 加广泛, 因为要考虑的情 况也更多。例如, 使用循环展开的检查代码需要测试许多不同的循环界限,保证它能够处理最终单步迭代所需要的所有 不同的可能的数字。\n5. 14 确认和消除性能瓶颈\n至此,我们只考虑了优化小的程序,在这样的小程序中有一些很明显限制性能的地方, 因此应该是集中注意力 对它们进行优化。在处理大程序时, 连知道应该优化什么地方都是很难的。本节会描述如何使用代码剖析程序 ( code profiler) , 这是在程序执行时收集性能数据的分 析工具。我们还展示了一个系统优化的通用原则, 称为 A mda hl 定律( A m­ dahl\u0026rsquo;s law), 参见 1. 9. 1 节。\n14. 1 程序剖析\n程序剖析 ( pro fil ing ) 运行 程序的一 个版本, 其中插入了工具代码, 以确定程序的各个部分需要多少时间。这对于确认程序中我们 需要集中注意力优化的部分是很有用的。剖析的 一个有力之处在千可以在现实的基准数据( be nchm a r k da ta ) 上运行实际程序的同时,进 行剖析。\nU nix 系统提供了一个剖析程序 GPROF。这个程序产生两种形 式的信息。首先, 它确定 程序中每个函数花费了多少 CP U 时间。其次, 它计算每个 函数被调用的次数, 以执行调用的函数来分类。这两种形式的信息都非常有用。这些计时给出了不同函数在确定整体 运行时间中的相对重要性。调用信息使得我们能理解程序的 动态行为。\n用 GPROF 进行 剖析需要 3 个步骤, 就像 C 程序 pr og . c 所示, 它运行时命令行参数\n为 f i l e . t xt :\n) 程序必须为剖析而编译和链接。使用 GCC( 以及其他 C 编译器), 就是在命令行上简单 地包括运行时标志 \u0026quot; - pg\u0026quot; 。确保编译器不通过内 联替换来尝试执行任何优化是很重要的,否则就可能无法正确刻画函数调用。我们使用优化标志- Og , 以保证能正确跟踪函数调用。\nlinux\u0026gt; gee -Og -pg prog.e -o prog\n然后程序像往常一样执行:\nlinux\u0026gt; ./prog t il e. txt\n它运行得会比正常时稍微慢一点(大约慢 2 倍), 不过除此之外唯一的区别就是它产生了 一个文件 grno n . o u t 。\n3 ) 调用 GPROF 来分析 grno n . o 江 中的数据。\nlinux\u0026gt; gprof prog\n剖析报告的第一部分列出了执行各个函数花费的时间,按照降序排列。作为一个示 例, 下面列出了报告的一部分, 是关于程序中最耗费时间的三个函数的:\no。/ cumul at i ve time seconds self seconds calls self s/call total s/call name 97.58 203.66 203.66 1 203.66 203.66 sort_words 2.32 208.50 4.85 965027 0.00 0.00 find_ele_rec 0.14 208.81 0.30 12511031 0.00 0.00 Strlen 每一行代 表对某个 函数的所有调用所花费的时间。第一列表明花费在这个函数上的时间占整 个时间的百分 比。第二列显示的是 直到这一行并 包括这一行的函数所花费的累计时间。第三列显示的是 花费在这个函数上的时间, 而第四列显示的是它被调用的次数(递归调用不 计算在内)。在例子中, 函数 s or t _ wor d s 只 被调用了一次, 但就是这一次调用需 要 203. 66 秒, 而函数 f i n d_ e l e _ r e c 被调用了 965 0 27 次(递归调用不计算在内),总 共需 要 4. 8 5 秒。 函数 S 七r l e n 通过调用库函数 s tr l e n 来计算字符串的长度。GPROF 的结果中通常不显示库函数调用。库函数耗费的时间通常计算 在调用 它们的函数内。通过创建这个“包 装函数( w ra p pe r fu nct io n ) \u0026quot; S 七r l e n , 我们可以 可靠地跟踪 对 s t r l e n 的调用, 表明它被 调用了 1 2 511 0 31 次, 但是一共只需 要 0. 30 秒。\n剖析报告的第二部分是函数的调用历史 。下面是一个递归函数 f i nd _e l e _r e c 的历史:\n[5] 2.4\n这个历史既显示了调用 f i n d_ e l e _r e c 的函数, 也显示了它调用的函数。头两行显示的是对这个 函数的调用: 被它自身递归地调用了 158 655 72 5 次,被 函数 i n s er t _ s tr i n g 调用了 9\u0026amp;5 0 27 次(它本身被调用 了 965 0 27 次)。函数 f i n d _ e l e _r e c 也调用了另外两个函数 s ave _ s tr i n g 和 n e w_ e l e , 每个函数总共被调用了 3 63 039 次。\n根据这个调用信息,我们通常可以推断出关于程序行为的有用信息。例如,函数 釭nd_e l e _r e c 是一个递归过程,它 扫描一个哈希桶( ha s h b uck et ) 的链表,查 找一个特殊的字符 串。对于这个函数, 比较递归调用的数量和顶层调用的 数量, 提供了关千遍历 这些链表的长度的统计信息。这里递归与顶层调用的比率是 1 64. 4, 我们可以推断出程序每次\n平均大约扫描 1 64 个元素。\nGPROF 有些属 性值得注意 :\n计时不是很准确。它的计时基于一个简单的间隔计数 ( interval co un ting ) 机制, 编译过\n的程序为每个函数维护一个计数器, 记录花费 在执行该 函数上的时间。操作系统使得每隔某个规则的时间间隔 o, 程序被中 断一次。8 的典型值的范围为 l. 0 ~ 10. 0 毫秒。\n当中断发生时, 它会确定程序正在执行 什么函数, 并将该函数的计数器值增加 8。当\n`\n然,也可能这个函数只是刚开始执行,而很快就会完成,却赋给它从上次中断以来整个\n的执行花费。在两次中断之间也可能运行其他某个程序,却因此根本没有计算花费。\n对千运行时间较长的程序,这种机制工作得相当好。从统计上来说,应该根据 花费在执行 函数上的相对时间来计算每个 函数的花费。不过, 对于那些运行 时间少于 1 秒的程序来说, 得到的统计数字只能 看成是粗略 的估计值。\n假设没有执行内联替换,则调用信息相当可靠。编译过的程序为每对调用者和被调 用者维护一个计数器。每次调用一个过程时 , 就会对适当的计数器加 1 。 默认情况下,不会显示对库函数的计时。相反,库函数的时间都被计算到调用它们 的函数的时间中。 14. 2 使用剖析程序来指导优化\n作为一个用剖析程序来指导程序优化的示 例, 我们创建 了一 个包括几个不同任务和数据结构的应用。这个应用分析一个文本文档的 订g ra m 统计信息, 这里 n-g ra m 是一个出现在文档中 n 个单词的序列。对于 n = l , 我们收集每个单词的统计信息, 对于 n = 2 , 收集每对单词的 统计信息, 以此类推。对于一个给定的 n 值, 程序读一个文本文件, 创建一张互不相同的 n-gra m 的表, 指出每个 n-gra m 出现了多少次, 然后按照出现次数的降序对单词排序。\n作为基 准程序 , 我们在一个由《莎士比亚全集》组成的文件上运行这个程序,一共有965 028 个单词 , 其中 23 706 个是互不相同的。我们发现, 对于 n = I , 即使是一个写得很烂的分析程序也能在 1 秒以内处理完整个文件, 所以我们设置 n = Z, 使得事情更加有挑战。对于 n = Z 的情况, n- gra m 被称为 bigram ( 读作 \u0026quot; bye-gra m\u0026quot; ) 。我们确定《莎士比亚全集》包含 363 039 个互不相同的 bigra m。最常见的是 \u0026quot; I am\u0026quot;, 出现了 1892 次。词组 \u0026ldquo;to\nbe\u0026rdquo; 出现了 1020 次。bigr am 中有 266 018 个只出现了一 次。\n程序是由下列部分组成的。我们创建了多个版本 , 从各部分简单的算法开始 ,然后再换成更成熟完善的算法:\n从文件中读出每个单词, 并转换成小写字母。我们最初的版本使用的是函数 l owed\n(图 5-7 ) , 我们知道由于反复地调用 s tr l e n , 它的时间复杂度是二次的。\n对字符串应用一个哈希函数, 为一个有 s 个桶( bucket ) 的哈希表产生一个 O~ s—l\n之间的数 。最初的函数只是简单地对字符的 ASCII 代码求和,再 对 s 求模。\n) 每个哈希桶 都组织成一个链表。程序沿着这个链表扫描, 寻找一个匹配的条目, 如果找到了, 这个 n-gra m 的频度就加 1。否则, 就创建一个新的链表元素。最初的版本递归地完成这个操作,将新元素插在链表尾部。\n) 一旦已经生成了这张表, 我们就根据频度对所有的元 素排序。最初的版本使用插入排序。\n图 5-38 是 兀gra m 频度分析程序 6 个不同版本的剖析结果。对千每个版本, 我们将时间分为下面的 5 类。\nSort: 按照频度对 n-gram 进行排序\nList: 为匹配 n-g ra m 扫描链表, 如果需要, 插入一个新的元素\nLower: 将字符串转换为小写字母\nStrlen: 计算字符串的长度\nHash: 计算哈希函数\nRest: 其他所有函数的和\n如图 5-38a 所示, 最初的版本需要 3. 5 分钟, 大多数时间花在了排序上。这并不奇怪, 因为插入排序有二次 的运行时间, 而程序对 363 039 个值进行排序。\n在下一个版本中 , 我们用库函数 qs or t 进行排序, 这个函数是基于快速排序算法的\n[ 98] , 其预期运行时 间为 O( nlogn ) 。在图中这个版本称为 \u0026quot; Q uicksort\u0026quot; 。更有效的排序算\n第 5 章 优 化 程序 性 能 391\n法使花在排序上的 时间降低到可以忽略不计, 而整个运行时间降低到大约 5. 4 秒。图 5-38b\n是剩下各个版本的时间,所用的比例能使我们看得更清楚。\n250\n200L 尸\n,沪 150 +\u0026ndash;\n记u 100士 —\n50 +—\n。\n6\n5\ni 4 # Initial\n-Quicksort -Iter first\nlter last Big table Better hash Linear lower\na ) 所有的版本\nU二p.. 3\n2\nQuicksort\nIter first\nlter last Big table Better hash Linear lower\nb ) 除了最慢的版本外的所有版本\n图 5-38 big ram 频度计数程序的各个 版本的剖析结果。时间是 根据程序中 不同的 主要操作划分的\n.改进了排序,现在发现链表扫描变成了瓶颈。想想这个低效率是由于函数的递归结构 引起的, 我们用一个 迭代的结构替换它, 显示为 \u0026quot; It e r fi r s t \u0026quot; 。令人奇 怪的是, 运行时 间增加到了大 约 7. 5 秒。根据更近一步的研究,我 们发现两个链表函数之间有一个细微的差 别。递归 版本将新元 素插入到链 表尾部 , 而迭代版本 把它们插到链表头部。为了使性能最大化, 我们希望频 率最高的 n- g ra m 出现在链表的开始处。这样一来, 函数就能快速地定位常见 的情况。假设 n- g r a m 在文档中是均匀分布的 , 我们期望频度高的单词的第一次出现在频度 低的单词之前。通过将新的 n- g ra m 插入尾部, 第一个函数倾向于按照频度的降序排序,而第二个函数则相反。因此我们创建第三个链表扫描函数,它使用迭代,但是将 新元素插 入到链表的尾部。使用这个版本, 显示为 \u0026quot; lt er last\u0026quot;, 时间降到了大约 5. 3 秒, 比递归版本稍微 好一点。这些测量展示了对 程序 做实验作 为优化工 作一部分的重要性。开始时,我们假设将递归代码转换成迭代代码会改进程序的性能,而没有考虑添加元素到链 表末尾和开头的差别。\n接下来 ,我们 考虑哈希表的结 构。最初的版本只有 10 21 个桶(通常会选择桶的个数为质数,以 增强哈希 函数将关键字均匀分布在桶中的 能力)。对于一个有 363 039 个条目的表来说 , 这就意味着平均负 栽(l oad ) 是 363 039 / 1021 = 355. 6。这就解释了为什 么有那么多时间花在了 执行链表操作 上了一 搜索包括测试大量的候选 n- g ra m 。它还解释了为什么性能对链表的 排序这么敏感。然后, 我们将桶的数量增加到了 199 999 , 平均负载降低到了\n8。不过, 很奇怪的是, 整体运行时间 只下降到 5. 1 秒, 差距只有 0. 2 秒。\n进一步观察,我们可以看到,表变大了但是性能提高很小,这是由于哈希函数选择的 不好。简单地对字符串的字符编码求和不能产生一个大范围的值。特别是,一个字母最大 的编码值是 122 , 因而 n 个字符产生的和最多是 122n 。在文档中, 最长的 big ra m( \u0026quot; honor­ 巾 ca b小t udinita ti bus t ho u\u0026quot; ) 的和也不过是 3371 , 所以,我们哈希表中大多数桶都是不会被使用的。此外,可交换的哈希函数,例如加法,不能对一个字符串中不同的可能的字符顺 序做出区分。例如, 单词 \u0026quot; ra t\u0026quot; 和 \u0026quot; t ar\u0026quot; 会产生同样的和。\n我们 换成一个使用移位和异或操作的哈希函数。使用这个版本, 显示为 \u0026quot; Better\nHash\u0026quot;, 时间下降到了 0. 6 秒。一个更加系统化的 方法是更加仔细地研究关键字在桶中的分布,如果哈希函数的输出分布是均匀的,那么确保这个分布接近于人们期望的那样。\n最后,我 们把运行时间降到了大部分时间是花在 s tr l e n 上, 而大多数对 s tr l e n 的调用是作为小写字母转换的一部分。我们已 经看到了函数 l o wer l 有二次的性能, 特别是对长字符串来说。这篇文档中的单词足够短,能避免二次性能的灾难性的结果;最长的 bigra m 只有 32 个字符 。不过换成使用 l ower 2 , 显示为 \u0026quot; L inea r Low e r \u0026quot; 得到很好的性能, 整个时间降 到了 0. 2 秒。\n通过这个练习,我们展示 了代码剖析能够帮 助将一个简单应用程序所需的时间从 ,3. 5 分 钟降低到 0. 2 秒, 得到的性 能提升约为 1000 倍。剖析程序帮助我们把注意 力集中在程序最耗时的部分上,同时还提供了关于过程调用结构的有用信息。代码中的一些瓶颈,例 如二次的排序函数,很容易看出来;而其他的,例如插入到链表的开始还是结尾,只有通 过仔细的分析才能看出。\n我们可以看到,剖析是工具箱中一个很有用的工具,但是它不应该是唯一一个。计时测量不是很准确 , 特别是对较短的运行时间(小于 1 秒)来说。更重要的是 ,结 果只适用于被测试的那些特殊的数据。例如,如果在由较少数扯的较长字符串组成的数据上运行最初的函 数,我们会发现小写字母转换函数才是主要的性能瓶颈。更糟糕的是,如果它只剖析包含短单词的文档, 我们可能永远 不会发现隐藏着的性能瓶颈, 例如 l ower l 的二次性能。通常, 假设在有代表性的数据上运行程序,剖析能帮助我们对典型的情况进行优化,但是我们还应该确保对所有可能的 情况,程序 都有相当的性能。这 主要包括避免得到糟 糕的渐近性能 (as­ ymptotic performance) 的算法(例如插入算法)和坏的编程实践(如例 l owe rl ) 。\n9. 1 中讨论了 Amdahl 定律, 它为通过有针对性的优化来获取性能提升提供了一些其他的见解。 对千 n-gra m 代码来说 , 当用 q uickso r t 代替了插入排序后,我 们看到总的执行 时间从 209. 0 秒下降到 5. 4 秒。初始版本的 20 9. 0 秒中的 203. 7 秒用于执行插入排序, 得到 a = 0. 974 , 被此次优化加速的时间比例。使用 q uicksort, 花在排序上的时间变得微不足道,得到 预计的 加速比为 209/ a = 39 . O, 接近千测量加速比 38. 5。我们之所以能获得大的加速比,是因为排序在整个执行时间中占了非常大的比例。然而,当一个瓶颈消除, 而新的瓶颈出现时,就需要关注程序的其他部分以获得更多的加速比。 5. 15 小结\n虽然关于代码优化的大多数论述都描述了编译器是如何能生成高效代码的,但是应用程序员有很多方 法来协助编译器完成这项任务 。没有 任何编译器能用一 个好的算法或数据结构代替 低效率的算法或数据结构, 因此程序设计的这些方面仍然应该是程序员 主要关心的 。我们 还看到 妨碍优化的 因素, 例如内存别名使用 和过程调用 , 严重限制了编译器执行大量优化的能力。同样, 程序员 必须对消除这些妨碍优化的因素负主要的责任。这些应该被 看作好的编程习惯的一部分, 因为它们可以用来消除不必要的工作。\n基本级别之外调整性能需要一些对处 理器微体系结构的理解,描 述处理器用来实现它的指令集体系结构的底 层机制。对千乱序处理器的情况,只 需 要 知 道一些关千操作、容量、延迟和功能单元发 射时间的信息 , 就能够基本地预测程序的性能了。\n我们研究了一系列技术,包括循环展开、创建多个累积变榄和重新结合,它们可以利用现代处理器 提供 的指 令级并 行 。随 着对 优化的深入,研究 产 生 的 汇编代码以及试着理解机器如何执行计算变得重要起来 。确认 由程序中的数据相关决定的关键路径, 尤其是循环的不同迭代之间的数据相关 ,会 收获良多。我们还可以 根据必 须要计算的操作数量以及执行这些操作的功能单元的数噩和发射时间, 计 算 一 个 计 算的吞吐扯 界限。\n包含条 件分支或与内存系统复杂交互的程序, 比我们最开始考虑的简单循环程序,更 难 以 分 析和优化。基本策 略是使分支更容易 预测,或 者使它们很容易用条件数据传送来实现。我们还必须注意存储和加载操 作。将数值保存在局部变量中,使 得 它 们可以存放在寄存器中 , 这会很有帮助。\n当处理大型程序时 ,将 注意力集中在最耗时的部分变得很重要。代 码剖析程序和相关的工具能帮助我们系 统地评价 和改进程序性 能。我们描述了 GP RO F , 一个标准的 U nix 剖 析工具。还有更加复杂完善的剖析 程序可用 ,例 如 Intel 的 VT U NE 程序开发系统, 还有 Linux 系统 基本上都有的 V ALGRIND。这些工具可以在过程级分韶执行时间,估 计 程序每个基本块( basic block ) 的性能。(基本块是内部没有控制转移的指令 序列,因 此基本块 总是 整个 被执行的。)\n参考文献说明 # 我们的关注点是从程序员的角度描述代码优化,展示如何使书写的代码能够使编译器更容易地产生 高效的代码。Chellappa 、F ranchetti 和 P uschel 的扩展的论文[ 19] 采用了类似的方法,但 关 于处 理 器的特性描述 得更详细。\n有许 多著作从编译器的角度描述了代码优化, 形 式 化 描 述 了 编 辑器可以产生更有效 代码的方法。Muchni ck 的著作被认为是最全面的[ 80] 。Wad leig h 和 Cra wfo r d 的关于软件优化的著作[ 115] 覆盖了一些 我们已经谈到的内容, 不 过它还描述了在并行机器上获得高性能的过程。Mahlke 等人的一篇比较早期的论文[ 75] , 描述了几种为编译器开发的将程序映射到并行机器上的技术,它们是如何能够被改造成利用现代处理器的指令级并行的。这篇论文覆盖了我们讲过的代码变换,包括循环展开、多个累积变扯(他们 称之为 累积变量 扩展 ( accum ula tor va ria ble expans io n ) ) 和 重新结合(他们称之为树 高 度 减 少 ( t ree h e ig h t reduct io n) ) 。\n我们对乱序处理器的 操作的描述相当简单和抽象。可以 在高级计算机体系结构教科书中找到对通用原则更完整的 描述,例 如 H enness y 和 Pa tt erson 的著作[ 46 , 第 2~ 3 章]。S he n 和 L ipas t i 的 书[ 1 00 ] 提供了对现代处理器设计深人的论述。\n家庭作业 # •• 5. 13 假设 我们想编写一个计算两个向量 u 和 v 内积的过程。这个函数的一个抽象版本对整数和浮点数类型, 在 x86-64 上 C PE 等于 14 ~ 18 。 通过进行与我们将 抽象 程序 c ombi n e l 变换 为更有效的\nc ombi ne 4 相同 类型的变换, 我们得到如下代码:\nI* Inner product. Acc umul at e in t emp ro ar y *I\nvo i d i nner 4 ( ve c_ptr u, vec_ptr v, data_t *dest)\n{\nlong i;\nlong length= vec_length(u); data_t *udata = get _vec _s t ar t (u) ; data_t *Vdata = get_vec_start(v); data_t sum = (data_t) O;\nfor (i = O; i \u0026lt; length; i++) {\nsum= sum+ udata[i] * vdat a [i ] ;\n}\n*dest = sum;\n测试显示 , 对千整数这个函数的 CP E 等于 1. 50 , 对千浮点 数据 C PE 等于 3. 00 。对于数据类型 d o ub l e , 内循环的 x86-64 汇编代码如下 所示:\nInner loop of i nner 4. dat a_t = double, OP=,.\nudata in %r bp, vdata in %rax, sum in %xmm0 i in %rcx, lim1t in %rbx\n.L15:\nvmovsd vmulsd vaddsd addq cmpq jne\n0(%rbp,%rcx,8), %xmm1 (%rax,%rcx,8), %xmm1, %xmm1\n%xmm1, %xmm0, %xmm0\n$1, %rcx\n%rbx, %rcx\n.L15\nloop.\nGet udata[i] Multiply by vdata[i] Add to s 皿\nIncrement i Compare i : limit If 1=, goto loop\n5. 14 5. 15 5. 16 ** 5. 17\n假设功能单元的特性如图 5-12 所示。\n按照图 5-13 和图 5-14 的风格,画出这个 指令序列会如何被译码成操作, 并给出它们之间的数据相关如何形成一条操作的关键路径。\n对于数据类型 do ub l e , 这条关键路径决定的 CP E 的下界是什么?\n假设对于整数代码也有类 似的 指令序列 , 对于整数数据的关 键路径决定的 CPE 的下界是什么?\n请解释虽然乘法操作需要 5 个时钟周期, 但是为什么两个 浮点版本的 CP E 都是 3. 00。\n编写习题 5. 13 中描述的内 积过程的一个版本, 使用 6 X l 循环展开。对 于 x86-64 , 我们对这个展开的版本的测试 得到,对整数数据 CP E 为 1. 07, 而对两种 浮点数据 CP E 仍然为 3. 01 。\n解释为什么 在 Intel Core i7 H aswell 上运行的 任何(标盘)版本的内积过程都不能 达到比 1. 00 更小 的 C PE 了 。\n解释为什么对浮点数据的性能不会通过循环展开而得到提高。\n编写习题 5. 13 中描述的内积过程的一个版本 , 使用 6 X 6 循环展开。对 千 x86-6 4 , 我们对这个函数的测试得到对整数数据的 CP E 为 1. 06, 对浮点数据的 CP E 为 1. 01 。\n什么因素制约了性能 达到 CP E 等于 1. 00?\n编写习题 5. 13 中描述的内积 过程的一 个版本, 使用 6 X l a 循环展开产生更高的并行性。我们对这个函数的测试得到对 整数数据的 CP E 为 1. 10, 对浮点数 据的 CP E 为 1. 05 。\n库函数 me ms e t 的原型如下:\nvoid•memset(void•s, int c, size_t n);\n这个函数将从 s 开始的 n 个字节的内存区域都填 充为 c 的低位字节。例如,通 过将参数 c 设置为\n0, 可以用这个函数来对一个内存区域清零,不过用其他值也是可以的。下面是 me ms e t 最直接的实现 :\nI• Basic implementation of memset•I void•basic_memset(void•s, int c, size_t n)\n{\nsize_t cnt = O;\nunsigned char•schar = s; while (cnt \u0026lt; n) {\n•schar++ = (unsigned char) c; cnt++;\n}\nreturns;\n}\n实现该函数一 个更有效的 版本, 使用数据类型为 uns i g ne d l ong 的字来装下 8 个 C, 然后用字级的写遍历目标内存区域 。你可能发现增 加额外 的循环展 开会有所帮助。在我们 的参考机上, 能 够把 C P E 从直接实 现的 1. 00 降低到 o. 127。即, 程序每个 周期可以写 8 个字节。\n这里是一些 额外的 指导原则。在此,假 设 K 表示 你运行程序的 机器上的 s i ze o f (unsigned l o ng ) 的 值。\n**5. 18\n·: 5. 19\n你不可以调用任何库函数。\n你的代码应该 对任意 n 的值都能工作, 包括当它不是 K 的倍数的时候。你可以用 类似于使用循环展开时完成最后几次迭代的方法做到这一点。\n你写的代码应该无论 K 的值是多 少,都 能够正确编译 和运行。使用操作 s i ze o f 来做到这一点 。\n在某些机器上 , 未对齐的写可能比对齐的写 慢很多。(在某些非 x8 6 机器上 , 未对齐的写甚至可能会导致段错误。)写出这样的代码, 开始时 直到目的 地址是 K 的倍数时,使用 字节级的写,然后进行字级的写,(如果需要)最后采用用字节级的写 。\n注意 c n t 足够小以 至于一些 循环上界变成负数的情 况。对 千涉及 s i ze o f 运算符的 表达式 ,可以用无符号运算来执行测试。(参见 2. 2. 8 节和家庭作 业 2. 72。)\n在练习题 5. 5 和 5. 6 中我们考虑了多项式求值的任务,既 有直接求 值, 也有用 H orner 方法求 值。试着用我们讲过的优化技术写出这个函数更快的版本,这些技术包括循环展开、并行累积和重新 结合。你会发现有很多 不同的方法可以将 H o rner 方法和直接求值与这些 优化技术混合起来。\n理想状况下 , 你能达到的 CPE 应该接近于你的 机器的吞吐盘界限 。我们的 最佳版本在参 考机上能\n使 CPE 达到 1. 07。\n在练习题 5. 12 中 ,我们能 够把前置和计算 的 CPE 减少到 3. 00, 这是由该机器上浮点加法的延迟决定的。简单的循环展开没有改 进什么 。\n使用循环展开和重新结 合的组合,写 出求前 置和的代码, 能够得到一个小 于你机器上浮点 加法延迟的 CP E。要达到这个目标 , 实际上需要增加执行的加法次数。例如,我 们使用 2 次循环展开的 版本每次迭代需 要 3 个加法 , 而使用 4 次循环展开的版本需要 5 个。在参考机上, 我们的最佳实现能 达到 CPE 为 1. 67。\n确定你的机器的吞吐 益和延迟界限是 如何限制前 置和操作所能 达到的最小 CPE 的。\n练习题 答 案 # 5. 1\n5. 2\n5. 4\n这个问 题说明了内 存别名使用的 某些细微的 影响。\n正如下面加了注释的代码所示 ,结 果会是将 xp 处的值设置为 0 :\n•xp =•xp +•xp; /• 2x•/\n•xp= • xp -•xp; I• 2x-2x = 0•/\n•xp = • xp - • xp ; I• 0-0 = 0•I\n这个示例说明我们关 于程序行 为的直觉往往会是错误 的。我们自然地会认 为 xp 和 yp 是不同的情况,却忽略了它们相等的可能性。错误通常源自程序员没想到的情况。\n这个问 题说明了 CPE 和绝对性能之间的关 系。可以用初等代数解决这个问 题。我们发现对于 n 2 ,\n版本 1 最快。对于 3 n 7 , 版本 2 最快 , 而对于 n多8 , 版本 3 最快。\n这是个简单 的练习,但是认识到一个 f or 循环的 4 个语句(初始化、测试、更新和循环体)执行的次数是不同的很重要。\n代码 min max incr square A. l l 9 90 90 B. l 9 l 90 90 C. l l 90 90 这段汇编代码展示了 GCC 发现的一个 很聪明的优化 机会。要更 好地理解代码优化的细微之处, 仔细研究这段代码是很值得的。\n在没经过优 化的代码中,寄 存器%xrnm0 简单地被用 作临时值, 每次循环迭代 中都会设置和使用 。在经过更多 优化的代码中,它 被使用的 方式更像 c ombi ne 4 中的 变量 X , 累积向量元素的乘积。不过,与 c ombi ne 4 的区别 在于每次迭代第 二条 vmo v s d 指令都会更新位置 de s t 。\n我们可以 看到, 这个优化过的 版本运行起 来很像下 面的 C 代码:\nI* Make sure dest updated on each iteration *I\n2 void combi ne3 汃 vec _ptr v, data_t *dest)\n3 {\nlong i;\nlong length = vec_length(v);\ndata_t *data = get_vec_start(v);\ndata_t ace = IDENT;\nI* Initialize in event length \u0026lt;= 0 *I\n*dest = ace;\nfor (i = O; i \u0026lt; length; i++) {\nace = a c e OP data [i] ;\n14 *dest = a ce · 15\n16 }\nc o mbi ne 3 的两个版本有相同的功能, 甚至于相同的内存 别名使用。\n这个变换可以不 改变程序的行为 , 因为,除 了 第一次迭代, 每次迭代 开始时从 d e 江 读出的值和前一次迭代最后写入到这个寄存器的值是相同的。因此,合并指令可以简单地使用在循环开始 时就已经在%x mm0 中的值。\n5 多项式求值是解决许多问题的核心技术。例如, 多 项式函数常常用作对数学库中三角函数求近似值。\n这个函数执行 2n 个乘法和 n 个加法。 我们可以看到, 这里限制性能 的计算是反复地计算表达式 xp wr = x * xp wr 。这需要一个浮点数乘法( 5 个时钟周期),并且直到前 一次迭代完成 , 下一次迭代的 计算才能开始。两次连续的迭代之间 ,对r e s u l t 的更新只需要一个浮点加法( 3 个时钟周期)。 6 这道题说明了最小化一个计算中的操作数量不 一定会提高它的性能。\n这个函数执行 n 个乘法和 n 个加法 , 是原始函数 p o l y 中乘法数量的一半。\n我们可以看到 , 这里的性能限 制计算是反复地计算 表达式r e s u l 七=a [ i ) +x *r e s u l t 。从来自上一次迭代的r e s u l t 的值开始 , 我们必须先把它乘以 x ( 5 个时钟周期), 然后把它加上 a [习 (3 个时钟 周期), 然后得到本次 迭代的值 。因此, 每次迭代 造成了最小 延迟时间 8 个周期 , 正好等于我们 测最到的 CPE。\n虽然函数 p o l y 中每次迭代需 要两个乘法, 而不是一个,但是只有一条乘法是 在每次 迭代的关键路径上出现。\n5. 7 下面的代码直接遵循了 我们对 K 次展开一个循环所阐述的规则:\nvoid unroll5(vec_ptr v, data_t *dest)\n2 { 4 long i ; long length= vec_length(v); 5 long limit= length-4; 6 data_t *data= get_vec_start(v); data_t ace = IDENT; 8 9 I* Combine 5 elements at a time *I 10 for (i = O; i \u0026lt; limit; i+=S) { 11 ace= ace OP data[i] OP data[i+1]; 12 ace= ace OP data[i+2] OP data[i+3]; 13 ace= ace OP data[i+4]; 14 } 15 16 I* Finish any remaining elements *I 17 for (; i \u0026lt; length; i++) { 18 ace = ace DP data[i]; 19 } 20 *dest = a c e ; 21 } 5. 8 这道题目说明了程序中小小的改动可能 会造成很大的 性能不 同,特别是在乱序执行的机器上。图 5-39 画出了该函数一 次迭代的 3 个乘法操作。在这张图中 , 关键路径上的操作用黑 色方框表示 它们需要按照顺序计算 , 计算出循环变最r 的新值。浅色方框表示的操作可以与关 键路径操作并 行地计算。对于一个 关键路径上有 P 个操作的循环 , 每次迭代 需要最少 5 P 个时钟周期, 会计算出 3 个元素的乘积, 得到 C P E 的下界 5P / 3。也就是说, A l 的 下界为 5. 00 , A Z 和 A 5 的为 3. 33, 而 A 3 和\nA4 的为 1. 67。我们在 In t el Core i7 H as well 处理器上运行这些函数, 发现得到的 C P E 值与前述一致。\nAl : ((r*x) *y)*z A2: (r* (x*y)) *z A3: r* ( (x * y ) *z) A4 : r* (x* (y* z ) )\n, y i z l i r l x ! y l z '\n图 5- 39 对于练习题 5. 8 中各种情况乘法操 作之间的数据相关。用黑色方框表示的操作形成了迭代的 关键路径\n5. 9 这道题又说明了 编码风格上的小 变化能够让编译器更容易 地察觉到使用 条件传送的 机会:\nwhile (il \u0026lt; n \u0026amp;\u0026amp; i2 \u0026lt; n) { long vl = src1[i1];\nl ong v2 = src2[i2]; long take!= v1 \u0026lt; v 2 ;\ndest[id++] = take1? v1 : v2;\ni1 += take!;\ni2 += (1-take1);\n对于这个版本的代码,我 们测量到 CP E 大约为 1 2. 0 , 比原始的 C P E 1 5. 0 有了明显的提高。\n5. 10 这道题要求你分 析一个程序中潜在的加载-存储相互影响。\n. A. 对于 O i 9 98 , 它要将每个元 素 a [ i ] 设置为 i + l 。\n对于 l i 999 , 它要将每个元 素 a [ i ] 设置为 0 。\n在第二种情 况中, 每次迭代的 加载都依赖于前一次迭代的存储结果 。因此,在连 续的迭代之间有写/读相关。\n得到的 C P E 等于 1. 2\u0026rsquo; 与示例 A 的相同, 这是因为存储 和后续的加 载之间 没有相关。\n5. 11 我们 可以看到 , 这个函数在连续的 迭代之间有写/读相关 一次迭代 中的目 的值 p [ i ] 与下一次迭代中的 源值 p [ i - l ] 相同。因此, 每次迭代形成的关键路径就包括: 一次存储(来自前一次迭代), 一次加载和一次浮点加。当存在数 据相关时 , 测扯得 到的 C P E 值为 9. 0 , 与 wr i t e —r e a d 的 C P E 测益值 7. 3 是一致的, 因为 wr i t e _r e a d 包括一个整数加 (1 时钟周期延迟), 而 p s u ml 包括一个浮点加( 3 时钟周期延迟)。\n5. 12 下面是对这个 函数的一个修改版本:\nv oi d psum1a(float a[], float p[J , long n)\n2 {\nlong i;\n4 I* last_val holds p[i 一 1] ; val holds p[i] *I\nfloat last_val, val;\n6 last_val = p [OJ = a[O];\n7 for (i = 1; i \u0026lt; n; i++) {\n8 val = last_val + a [i] ;\n9 p[i] = val;\n10 last_val = va l ;\n12 }\n我们引 入了局部变量 l a s t _ va l 。在迭代 l. 的开始, l a s t —va l 保存着 p [ i - l ] 的值。然后我们计算va l 为 p [ i ] 的值, 也是 l a s t _ v a l 的新值。\n这个版本编译得到如下汇编代码:\nInner loop of psumla\na 立 % 过 i , i 1n r¼ ax , cnt i n 石 dx, last_ val in 7.xmmO\n.L16: l oop :\nvaddss (%rdi,%rax,4), %xmm0, %xmm0 l as t _val = val = l as t _val + a[i]\nvmovss %xmm0 ,\nCor。/\ns i , /,r ax , 4 ) Store val in p[i]\naddq $1, %rax Increment i\ncmpq %rdx, %rax Compare i : cnt\njne .L16 If !=, goto l oop\n这段代码将 l a s t _v a l 保存在%x mm0 中 , 避免了需要从内存中读出 p [ i 一l ] •\n看到的写/读相关。\n因而消除了 ps urnl 中\n"},{"id":442,"href":"/zh/docs/technology/System/%E6%B7%B1%E5%85%A5%E7%90%86%E8%A7%A3%E8%AE%A1%E7%AE%97%E6%9C%BA%E7%B3%BB%E7%BB%9F/%E4%B9%A6%E7%B1%8D/%E7%AC%AC6%E7%AB%A0-%E5%AD%98%E5%82%A8%E5%99%A8%E5%B1%82%E6%AC%A1%E7%BB%93%E6%9E%84/","title":"Index","section":"SpringCloud","content":"---\n第 6 章\nH A P T E R 6\n存储器层次结构 # 到目前 为止,在对系 统的研究中, 我们依赖千一 个简单的计算机系统 模型, C P U 执行指令,而 存储器系统为 CP U 存放指令和数据。在简单模型中, 存储器系统是一个线性的字节数组, 而 CP U 能够在一个常数时间内访问每个存储器位置。虽然迄今为止这都是一个有效的模型, 但是它没有反映现代系统 实际工作的方式。\n实际上, 存储器 系统( m em o r y s ys te m ) 是一个具有不同容量、成本和访问 时间的存储设备的层 次结构。CP U 寄存器保存着最常用的数据。靠近 C P U 的小的、快 速的 高速 缓存存储器 ( cache memor y) 作为一部分存储在相对慢速的 主存储器( main mem o r y ) 中数据和指令的缓冲区域。主存缓存存储在容量较大的、慢速磁盘上的数据,而这些磁盘常常又作为 存储在通过网络连接的其他机器的磁盘或磁带上的数据的缓冲区域。\n存储器层次 结构是可行的,这 是因为与下 一个更低层次的存储设备相比来说, 一个编写良好的程序倾向千更频繁地访问某一个层次上的存储设 备。所以, 下一层的存储设备可以更慢速 一点,也 因此可以更大, 每个比特位更便宜。整体效果是一个大的存储器池, 其成本与 层次结构底层最便宜的存储设备相当, 但是却以接近于层次结构顶部存储设备的高 速率向程序 提供数据。\n作为一个 程序员 , 你需要理解存储器层次结构, 因为它对应用程序的性能有着巨大的影响。如果你的程序需要的数据是存储在 C P U 寄存器中的 , 那么在指令的执行期间, 在 0 个周期内 就能访问 到它们。如果存储在高速缓存中,需 要 4 ~ 75 个周期。如果存储在主存中,需要上百个周期 。而如果存 储在磁盘上,需 要 大约几千万个周期!\n这里就是计算机系统中一个基本而持久的思想:如果你理解了系统是如何将数据在存 储器层次结构中上上下下移动的,那么你就可以编写自己的应用程序,使得它们的数据项 存储在层次结构 中较高的地方 , 在那里 CP U 能更快地访问到它们。\n这个思想围 绕着计算 机程序的一 个称为局部性 (l oca lit y ) 的基本属性 。具有良好局部性的程序倾向于一次又一次地访问 相同的数据项集合 , 或是倾向于访问 邻近的 数据项集合。具有良好局部性的程序比局部性差的程序更多 地倾向 于从存储器层次 结构中较高层次处访问数据项, 因此运行得更快。例如, 在 C ore i7 系统, 不同的矩阵乘法核心程序执行相同数量的算术操作, 但是有不同程度的局部性, 它们的运行时间可以 相差 40 倍!\n在本章中 , 我们会看看基本的存储技术一 SRA M 存储器、DRAM 存储器、ROM 存储器以及旋转的 和固态的硬盘一一-并描述它们是如何被组织成层次结 构的。特别地, 我们将注意力集中在高 速缓存存储器上, 它是作为 C P U 和主存之间的缓存区域, 因为它们对应用程序性能的 影响最大。我们向你展示如何分析 C 程序的 局部性, 并且介绍改进你的程序中局部性的技术。你还会学到一种描绘某台机器上存储器层次结构的性能的有趣方法, 称为“ 存储器山( memor y mountain)\u0026quot;, 它展示出读访问时间是局部性的一个函数。\n1 存储技术\n计算机技术的成 功很大程度上源自于存储 技术的 巨大进步。早期的计算机只有几千字\n节的随机访问存储器。最早的 IBM PC 甚至千没有硬盘。1982 年引入的 IBM PC-XT 有 l OM 字节的磁盘。到 2015 年,典 型的计算机巳有 300 000 倍于 PC-XT 的磁盘存储,而且磁盘的容量以每两年加倍的速度增长。\n1. 1 随机访问存储器\n随机访问存储 器( Ra ndom- Access Memory, R AM ) 分为两类 : 静态的 和动态的 。静态RA M CS RAM ) 比动态 R A M ( DRAM ) 更快, 但也贵得多。SR A M 用来作为高速缓存存储器,既 可以在 CP U 芯片上 , 也可以在片下。DR AM 用来作为主存以及图形系统的帧缓冲区 。典 型地, 一个桌面系统的 SRAM 不会超过几兆字节, 但是 DRA M 却有几百 或几千兆字节。\n静态 RAM\nSR AM 将每个位 存储在一个双稳 态的 ( bista ble ) 存储器单元里。每个 单元是用一个六晶体管电路来实现的。这个电路有这样一个属性,它可以无限期地保持在两个不同的电压 配置( config ur a tion ) 或状态( s tate) 之一。其他任何状态都是不稳定的一 从不稳定状态开始, 电路会迅速地转移到 两个稳定状态中的一个。这样一个存储器单元类似于图 6-1 中画出的倒转的钟摆。\n之 # 图 6-l 倒转的钟摆 。同 SRAM 单元一样,钟摆只有两个稳定的 配置或状态\n当钟摆倾斜到最 左边或最右边时 ,它 是稳定的。从其他任何位置, 钟摆都会倒向一边或另一边。原则上, 钟摆也能 在垂直的 位置无限期地保待 平衡 , 但是这个状态是亚稳 态的\n(metastable) 最细微的 扰动也能使它倒下, 而且一旦倒下就永远不会再恢 复到垂直的位置。\n由于 SRAM 存储器单元的双稳 态特性,只 要有电, 它就会永远地保持它的值。即使有干扰(例如电子噪音)来扰乱电压, 当干扰消除 时, 电路就会恢复到稳定值。\n2 动 态 RAM\nDR AM 将每个位存储 为对一个电 容的充电。这个电容非常小, 通常只有大约 30 毫微\n微法拉 Cfe mtofarad ) - —- 3 0 X 10- is 法拉 。不过, 回想一下法拉是一个非常大的计量单位.\nDR A M 存储器可以制造得 非常密集 每个单元由一个电容和一个访问晶体管组成。但是,与 SR AM 不同, D RAM 存储器单元对 干扰非常敏感。当电容的电压被扰乱之后,它就永 远不会恢复了。暴露在光线下会导致电容电压改变。实际 上, 数码照相机和摄像机中的 传感器本质上就是 DR AM 单元的阵列 。\n很多原因会导致漏电 , 使得 DR AM 单元在 10 ~ 100 毫秒时间内 失去电荷。幸运的是, 计算机运行的时钟周期是以纳秒来衡撒的 , 所以相对而言这个保持时间是比较长的。内存系统必须周期性地通 过读出, 然后重写来 刷新内存 每一位 。有些系统也使用纠错码, 其中计算机的字会被多编码几个位(例如 64 位的字可能用 72 位来编码), 这样一来, 电路可以发现并纠正一个字中任何单个的错误位。\n图 6-2 总结 了 SRAM 和 DRAM 存 储器的特性。只要有供电, SR AM 就会保持不变。与 DRAM 不同 , 它不需要刷新。SRAM 的 存取比 DRAM 快。SRAM 对诸 如光和电噪声这样的干扰不敏感。代价是 SRAM 单元 比 DRAM 单 元 使 用 更 多 的 晶 体 管 , 因 而 密集度低,而且更贵,功耗更大。\nSRAM DRAM\n传统的 D R A M\n了1二I :心:\n图 6-2 DR AM 和 SR AM 存储器的特性\nDRAM 芯片中的单元(位)被分成 d 个超单元( supercell) , 每个超单元都由 w 个 DRAM 单元组 成。一个 d X w 的 DRAM 总共存 储了 dw 位信息。超单元被组织成一个 r 行 c 列的长方形阵列, 这里 rc= d。每个超单元有形如Ci , j ) 的 地址,这 里 1 表示行,而 )表示列。\n例如,图 6-3 展示的是一个 16X 8 的 DRAM 芯片的组织,有 d = 16 个超单元, 每个超单元有 w= 8 位, r = 4 行 ,c= 4 列。带阴影的方框表示地址( 2\u0026rsquo; 1) 处 的 超 单 元 。 信 息 通过称为引脚 ( pin) 的外部连接器流入和流出芯片。每个引脚携带一个 1 位的信号。图 6-3 给出了两组引脚: 8 个 dat a 引脚,它们 能 传 送一个字节到芯片或从芯片传出一个字节,以 及 2 个 addr 引脚, 它们携带 2 位的行 和列超单元地址。其他携带控制信息的引脚没有显示出来。\nDRAM芯片\n;\n2 :\na/ddr►,:\n`\n列\n0 I 2 3\n. , 1-丁 I\n(佥到C氐PU吵)\n控内制存器 2\n3\n超单元\n( 2 , I )\nj\nd a 七a\n图 6-3 一个 128 位 16 X 8 的 DRA M 芯片的高级视图\nm 关千术语的注释\n存储领域从来没有为 DRAM 的阵列 元素确 定一个标准的 名 字。 计算机构架师倾向于称 之为 “ 单元“,使 这个术语 具有 DRAM 存储 单元 之 意。电路 设 计 者倾向 于称之为\n“宇",使之 具 有 主存一个字之 意。为 了避 免混淆, 我们采用 了无歧 义的术语“超单元”。\n每个 DRAM 芯片被连接到某个称为内存控制 器 ( memory cont roller) 的电路, 这个电路可以一次传送 w 位到每个 DRAM 芯片或一次从每个 DRAM 芯片传出 w 位。为了读出超单元( i , j ) 的内容,内 存控制器将行地址 t 发送到 DRAM , 然后是列地址 J。 DRAM 把 超单元( i , j ) 的内容发回给控制器作为响应。行 地址 t 称为 RAS ( Row Access Strobe, 行访间选 通脉冲)请求。列地址 ]称为 CAS ( Column Access Strobe, 列访问选通脉冲)请求。注意, RAS 和 CAS 请求共享相同的 DRAM 地址引脚。\n例如,要 从 图 6-3 中 16 X 8 的 DR AM 中读出超单元 ( 2 , 1), 内存控制器发送行地址\n2\u0026rsquo; 如 图 6-4a 所示。DRAM 的响应是将行 2 的整个内容都复制到一个内部行缓 冲区。接下来 ,内 存 控 制器发送列地址 1 , 如图 6-46 所示。DRAM 的响应是从行缓冲区复制出超单元 ( 2\u0026rsquo; 1) 中 的 8 位 ,并 把它们发送到内存控制器。\nDRAM芯片 DRAM芯片\n: 列 !\n内存\n控制器\naddr\ndata\n, \u0026hellip;\u0026hellip;\u0026hellip;\u0026hellip;\u0026hellip;\u0026hellip;\u0026hellip;\u0026hellip;\u0026hellip;\u0026hellip;..内\u0026hellip;\u0026hellip;部\u0026hellip;\u0026hellip;行\u0026hellip;\u0026hellip;缓\u0026hellip;\u0026hellip;冲..区\u0026hellip;\u0026hellip;\u0026hellip;\u0026hellip;\u0026hellip;\u0026hellip;.,\n、\u0026hellip;\u0026hellip;\u0026hellip;\u0026hellip;\u0026hellip;\u0026hellip;\u0026hellip;\u0026hellip;..内\u0026hellip;\u0026hellip;\u0026hellip;..行\u0026hellip;.缓\u0026hellip;..冲\u0026hellip;..区\u0026hellip;\u0026hellip;\u0026hellip;\u0026hellip;\u0026hellip;\u0026hellip;:\na ) 选择行 2 ( RAS请求)\nb ) 选择列 I ( CAS 请求) 图 6- 4 读 一 个 DRAM 超单元的内容\n电路设计者将 DRAM 组织成二维阵列而不是线性数组的一个原因是降低芯片上地址\n引 脚 的 数 晕 。 例 如 ,如 果 示 例 的 1 28 位 DRA M 被组织成一个 16 个超单 元的线 性数组,地址 为 0 ~ 15 , 那么芯片会需要 4 个地址引脚而不是 2 个 。二维阵列 组织的缺点是必须分两步 发 送 地址, 这增加了访问时间。\n内存模块\nDRAM 芯片封装在内存模 块( memory mod ule ) 中 , 它 插到主板的扩展槽上。Core i7\n系统使用的 240 个引脚的双列直插内存模块( Dua l lnline Memory Module, DIMM), 它以\n64 位为块传送数据到内存控制器和从内存控制器传出数据。\n图 6-5 展示了一个内存模块的基本思想。示例模块用 8 个 64 Mbit 的 8 M X 8 的 DRAM 芯 片 ,总共 存储 64MB(兆字节), 这 8 个芯片编号为 0~ 7。每个超单元存储主存的一个字节,而 用 相 应超 单元 地址 为(i\u0026rsquo; j ) 的 8 个超单元来表示主存中字节地址 A 处的 64 位字。在图 6-5 的 示例中, DRAM O 存储第一个(低位)字节, DRA M 1 存储下一个字节,依 此类 推。\n要取出内存地址 A 处的一个字,内 存 控制器将 A 转换成一个超单元地址( i\u0026rsquo; j )\u0026rsquo; 并将它 发 送 到 内 存 模 块 , 然 后 内 存 模 块 再 将 t 和 ] 广播 到 每个 DRAM。作 为响应, 每个DR A M 输出它的( i\u0026rsquo; j ) 超 单 元 的 8 位内容。模块中的电路收集这些输出,并 把 它们合并成一 个 64 位字,再 返 回 给内存控制器。\n通过将多个内存模块连接到内存控制器,能 够 聚 合 成 主 存 。 在 这 种 情 况 中 , 当控制器收 到 一 个 地 址 A 时 , 控制器选择包含 A 的模块 k\u0026rsquo; 将 A 转换成它的 ( i\u0026rsquo; j ) 的 形 式,并将( i\u0026rsquo; j ) 发 送 到 模 块 k。\n练习题 6. 1 接下来, 设 r 表 示 一个 DR AM 阵列 中的行数, c 表 示 列 数, br 表 示行寻址所需的位数,从 表 示 列 寻址所 需 的位数。 对于下 面 每个 DRAM , 确定 2 的 幕数的\n阵列 维数,使 得 max(rb , be ) 最小, ma x( rb\n较 大的值 。\n, b, ) 是对阵 列 的行或列 寻址所需的位数中\n组织 r C b, bC max(b,, b) 16X I 16X4 128X8 512X4 1024X4 addr (row= i, col= j)\n63 5655 4847 4039 3231 2423 1615 8 7 0\nI I I I I I I I I # 位于主存地址 A处的64位字\n口:超单元 ( i\u0026rsquo; 八\n由8个8M x 8的\nDRAM组成的64MB\n内存模块\n内存\n控制器\n增强的 DRAM\n图 6-5 读一个内存模块的内容\n有许多种 DRAM 存储器, 而生产厂商试图跟上迅速增长的处理器速度, 市场上就会定期推 出新的种类。每种都是基于传统的 DRAM 单元, 并进行一些优化, 提高访问基本\nDRAM 单元的速度。\n快页模 式 DRAM CFast Page Mode DRAM, FPM DRAM ) 。传统的 DRAM 将超单元的 一整行复制到它的内部行缓 冲区中, 使用一个, 然后丢弃剩余的。FPM\nDRAM 允许对同一行连续地访问可以 直接从行缓冲区 得到服务,从 而改进了这一点。例如,要从 一个传统的 DRAM 的行 t 中读 4 个超单元,内 存控制器必须发送 4 个 RAS / CAS 请求, 即使是行地址 1 在每个情况中都是一样的。要从一个 FPM DRAM 的同一行中读取超单元,内 存控制器发送第一个 RAS/ CAS 请求, 后面跟三个 CAS 请求。初始的RAS/ CAS请求将行1 复制到行缓冲区, 并返回 CAS 寻址的那个超单元。接下来三个超单元直接从行缓冲区获得,因此返回得比初始的超单元更快。\n扩展数据捡出 DRAM ( Extended Data Out DRAM, EDO DRAM) 。FP M DRAM 的一个增强的形式,它允 许各个 CAS 信号在时间上靠得更紧密一点 。\n同步 DRAM(Synchronous DRAM, SDRAM)。就它们与内存控制器通信使用一组显式的控制信号来说, 常规的、FPM 和 EOO DRAM 都是异步的。SDRAM 用与驱动内存控制器相同的外部时钟信号的上升沿来代替许多这样的控制信号。我们不会深入讨论细节, 最终效果就是SDRAM能够比那些异步的存储器更快地输出它的超单元的内容。\n双倍 数据速 率 同 步 DRAM ( Double Data-Rate Synchronous DRAM, DOR SDRAM )。DDR SDRA M 是对 S DRA M 的一种增强, 它通过使用两个时钟沿作为控制信号, 从 而使 DR AM 的速度翻倍。不同类型的 DOR SDRA M 是用提高有效 带宽的很小的预取缓冲区的大小来划分的: DDR( 2 位)、DDR2( 4 位)和DDR( 8 位)。\n视 频 RA M( Video RAM, VRAM ) 。它用在图形系统的帧缓冲区中。 VRAM 的思想与 FPM DRAM 类似。两个主要区别是: 1) VRAM 的输出是通过依次对内部缓冲区的整个内 容进行移位得到的; 2) V R AM 允许对内存并行地读和写。因此, 系统可以在写下一次更新的新值(写)的同时,用帧缓冲区中的像素刷屏幕(读)。\n田日DRAM 技术流行的 历史\n直到 1995 年, 大 多 数 PC 都是 用 F P M DRAM 构 造的。1 996 1999 年, EDO DR AM 在市场 上占 据了主 导, 而 F P M DRAM 几乎销声 匿迹 了。 SD RAM 最早 出现在199 5 年的 高端 系统中, 到 2002 年, 大 多 数 PC 都是用 SDR AM 和 DOR SDR AM 制造的。到 2010 年之前, 大多数服务器和 桌面 系统都是 用 D DR3 SDRAM 构造的。 实际上, Intel Core i7 只支持 DDR3 SDRAM。\n6 非易失性存储器\n如果断电, DRAM 和 SR AM 会丢失它们的信息, 从这个意义上说, 它们是易失的 ( vola tile) 。另一方 面,非易 失性 存储器 ( nonvola tile memory ) 即使是在关电 后, 仍然保存着它们的信息。现在有很多 种非易失性存储器。由于历史原因, 虽然 RO M 中有的类型既\n可以读也 可以写, 但是它们整体上都被 称为 只读 存储 器 C Read-Only Memory, ROM)。ROM 是以它们能够被重编 程(写)的次数和对它们 进行重编程所用的机制来区分的。\nPROM(Programmable ROM, 可编程 RO M ) 只能被编程一次。P ROM 的每个存储器单元有一种熔丝 ( fuse) , 只能用高电 流熔断一次。\n可掠 写 可编 程 RO M (Erasable Programmable ROM, EPRO M ) 有一个透明的石英窗口,允 许光到 达 存储单元。紫 外线 光 照射 过 窗 口, EP RO M 单 元就被 清 除 为 0。对E PR O M 编程是通过使用 一种把 1 写入 EP RO M 的特殊设备来完成的。EP RO M 能够被擦除和重编程的次 数的数量级可以达到 1000 次。电 子 可擦 除 PRO M C Electrically Erasable PROM, EEPROM) 类似于 EP RO M , 但是它不需要一个物理上独立的编程设备,因此可以直接在印 制电路卡上编程。E EP RO M 能够被编程的次 数的数量级可以达到 105 次。\n闪存 ( flas h memory )是一类非易失性存储器, 基于 EE PRO M , 它已经成为了一种重要的存储技术 。闪存无处不在, 为大量的电子设备提供快速而持久的非易失性存储, 包括数码相机、手机、音乐 播放器、PDA 和笔记本、台式机和服务器计算机系统。在 6. 1. 3 节中, 我们会仔细研究一种新型的基于闪存的磁盘驱动器, 称为 固 态硬 盘 C Solid State Disk, SSD), 它能提供相对千传统旋转磁盘的一种更快速、更强健和更低能耗的选择。\n存储在 RO M 设备中的程序通 常被称为固件 ( firmware) 。当一个计算机系统通电以后, 它 会运行存储在 ROM 中的固件。一些系统在固件中提供了少最基本的输入和输出函数一 例如 PC 的 BIOS( 基本输入/输出系统)例程。复杂的设备, 像图形卡和磁盘驱动控\n制器, 也依赖固件翻译来自 CP U 的 I / 0 ( 输入/输出)请求。\n7. 访问主存\n数据流通过 称为总线 ( bus ) 的共享电子电路在处理器和 DRA M 主存之间来来 回回。 每次 CPU 和主存之间的数据传送都是通过一系列步骤来完成的, 这些步骤称为 总线事务(bus t ransact io n) 。读事务 ( read t ra nsactio n) 从主存传送数据到 CP U。写事务 ( write trans­ action) 从 CPU 传送数据到主存。\n总线是一组并行的导线, 能携带地址、数据和控制信号 。取决千总线 的设计,数 据和地址信号可以共享同一组导线,也可以使用不同的。同时,两个以上的设备也能共享同一 总线。控制线携带的 信号会同步事务,并标识出当前正在被 执行的事务的类型。例如, 当前关注的这 个事务是 到主存的吗?还是到诸如磁盘控制器这 样的其他 I / 0 设备? 这个事务是读还是写? 总线上的信息是地址还是数据项?\n图 6-6 展示了一个示例计算机系统的配置。主要部件是 CPU 芯片、我们将称为 1/ 0\n桥接器 CI / 0 bridg e )的芯片组(其中包括内存控制器), 以及组成主存的 DR AM 内存模块。这些部 件由一对总线连接起来, 其中一条总线是 系统总线( s ys tem bus ) , 它连接 CP U 和1/ 0 桥接器 , 另一条总线是内存 总线( memor y bus) , 它连接 1/ 0 桥接器 和主存。1/ 0 桥接器将系 统总线的电子信号翻译成内 存总线的电子信号。正如我们看到的那样, I / 0 桥也将 系统总线和内存总线连接到 I/ 0 总线, 像磁盘和图形卡这样的 1/ 0 设备共享 1/ 0 总线。不过现在,我 们将注意力 集中在内存总线上。\nCPU芯片\n系统总线\n总线接口 三\n内存总线\nI\n图 6-6 连接 C P U 和主存的总线结构示例\nm 关千总线设计的 注释\n总线设 计是计算机 系统一个复杂而且 变化迅速的方面 。 不同的 厂商提 出了不同的 总线体系结构,作为产品 差异化的一种方法 。例如, Intel 系统使用称 为北桥 ( northbridg e) 和南桥(so uth bridge)的芯片组分别 将 CPU 连接到内存和 1/ 0 设备。在比较老的 Pent ium 和Core\n2 系统中, 前端总 线( Fr ont Side Bus , FSB) 将 CPU 连接到北桥 。来自 AMD 的 系统将 FSB\n替换为超传输 ( H yperTransport ) 互联 , 而更新一些的 Intel Core i7 系统使用的 是快速通道\n(QuickPath) 互联。这些不同 总线体 系结构的细节超 出了 本书的 范围。反之, 我们会使 用图6-6 中的 高级 总线体系结构作 为一 个运行 示例贯穿本书。 这是一个简单但是有用的 抽象, 使得我们可以很 具体, 并且可以 掌握主要思想而不必与任何私有设计的 细节绑得 太紧。\n考虑当 CP U 执行一个如下加载操作时会发生什么\nmovq A,%rax\n这里, 地址 A 的内容被加载到寄存器%r a x 中。CP U 芯片上称为总线接 口( bus interface )\n的电路在总线上发起读事务。读事务是由三个步骤组成的。首先, CPU 将地址 A 放到系统 总 线 上 。 I / 0 桥将信号传递到内存总线(图6- 7a ) 。接下来,主 存 感 觉 到 内 存 总 线 上的地址 信 号 ,从 内 存 总 线 读 地址,从 DRAM 取出数据字 ,并 将 数 据 写 到内存总线。I/ 0 桥将内 存 总 线 信 号 翻译成系统总线信号, 然后沿着系统总线传递(图6-7 b ) 。最后, CPU 感觉到系统总线上的数据,从 总 线上 读数据,并 将 数 据 复 制到寄存器%r a x ( 图 6- 7c) 。\n寄存器文件\n总 线 接口\nI / 0 桥\nI I\nVO桥\n二 I I X\n总线接 口\nc ) CP U从总线读 出字x, 并将它复制到寄存器 % r a x中图 6- 7 加 载操作 mo vqA, %r a x 的内存读事务\n反过来, 当 CPU 执行一个像下面这样的存储操作时\nmovq %rax,A\n这里, 寄 存器 %r a x 的 内 容 被写到地址 A , CPU 发起写事务。同样, 有三个基本步骤。首先,\nCPU 将地址放到系统总线上。内存从内存总线读出地址, 并 等待 数 据 到 达(图 6-8a) 。接下来 , CPU 将%r a x 中 的 数 据 字 复 制到系统总线(图6-8 6 ) 。最后, 主 存 从 内 存总线读出数据字 ,并 且 将这些位存储到 DRAM 中(图 6-8 c ) 。\n6. 1. 2 磁盘存储\n磁盘是广为应用的保存大量数据的存储设备,存 储 数 据 的 数 量 级 可 以 达到儿百到几千千 兆 字节, 而基 千 RAM 的存储器只能有几百或几千兆字节。不过,从 磁 盘 上 读 信息 的时间 为毫秒级,比 从 DRAM 读慢了 10 万倍, 比从 S RAM 读慢了 100 万倍。\n寄存器文件\n亳c a\n,\n总线接口\nVO桥\nI I A\na ) CPU将地址A放到 内存 总 线。主存读出这 个 地址 ,并等待数据字寄存器文件\n主存\ny\nb ) CPU将数据字y放到总线上\n寄存器文件\nhax\n总线接口\nc ) 主存从总线读数据字y , 并将它存储在地址A\n图 6-8 存 储 操 作 mo vq %r ax , A 的 内 存 写 事 务\ni 磁盘是由 盘片 ( plat t e r ) 构成的。每个盘片有两面或者称为表 面 ( s ur fa ce ) , 表面覆盖着磁性记录材料。盘片中央有一个可以旋转的主轴 ( s pin dle) , 它使得盘片以固定的旋转速率(rota tion al ra te) 旋转, 通常是 5400 1 5 000 转每分钟( Revol u t io n Per M in ute , RP M) 。磁\ni 盘通常 包含一个或多个这样的盘片, 并封装在一个密封的容器内。\n卜 图 6- 9a 展示了一个典型的磁盘表面的结构。每个 表面是由一组称为磁道( t rac k ) 的同\n!勺 心圆组 成的。每个磁 道被划分为一组扇区 ( s e cto r ) 。 每个 扇区包含相等数量的数据位(通常叱 是 512 字节), 这些数据编码在扇区上的磁性材料中。扇区之间由一些间隙 ( ga p ) 分 隔 开,\n这些间隙中不存 储数据位。间隙存储用来标识扇区的格式化位。\n磁盘是由 一个或多个叠放在一起的盘片组成的,它 们被封装在一个密封的包装里, 如图 6-9b 所示。整个装置通常被称为磁盘驱动 器( d is k drive ) , 我们通常简称为磁盘( dis k ) 。有时 , 我们会称磁盘为 旋转磁盘 ( ro t at ing dis k ) , 以使之区别千基于闪 存的 固 态硬盘(SSD), SSD 是没有移动部分的 。\n磁盘制 造商通常用术 语柱面 ( cy linde r ) 来描述多个盘片驱动器的构造, 这里, 柱面是所有盘片表面上到主轴中心的距离相等的磁道的集合。例如, 如果一个驱动器有三个盘片和六个面 , 每个表面上的磁道的编号都是一致的, 那么柱面 k 就是 6 个磁道 k 的集合。\na ) 一个盘片的视图\n图 6-9 磁盘构造\n柱面k\n主轴\nb ) 多个盘片的视图\n盘片0 盘片1 盘片2\n2. 磁盘容量\n一个磁盘上可以记录的最大位数称为它的最大容量,或 者 简 称 为 容 量。磁盘容量是由以下 技术因素决定的:\n记录密度 ( recording density)( 位/英寸): 磁道一英寸的段中可以放入的位数。·\n磁 道密度 ( t rack de nsit y) ( 道/英寸): 从 盘片中心出发半径上一英寸的段内可以 有的磁道数。\n面 密度 ( a rea l density)( 位/平方英寸): 记 录密度与磁道密度的乘积。\n磁盘制造商不懈地努力以提高面密度(从而增加容量),而 面密度每隔几年就会翻倍。最初 的磁 盘, 是 在 面密度很低的时代设计的,将 每个磁道分为数目相同的扇区, 扇区的数目是由最靠内的磁道能记录的扇区数决定的。为了保持每个磁道有固定的扇区数,越往外 的磁道扇区隔得越开。在面密度相对比较低的时候,这种方法还算合理。不过,随着面密 度的提高,扇区之间的间隙(那里没有存储数据位)变得不可接受地大。因此,现代大容蜇 磁盘使用一种称为多 区记 录( multip le zone recordi ng ) 的技术,在 这种技术中,柱 面的集合被分割成不相交的子集合,称 为记录区 ( recordi ng zone) 。每个区包含一组连续的柱面。一个区中的每个柱面中的每条磁道都有相同数量的扇区,这个扇区的数量是由该区中最里面的 磁道所能包含的扇区数确定的。\n下面的公式给出了一个磁盘的容量:\n磁 盘容 量 =\n字节数 X 平均扇区数 磁道数 X 表面数 X 盘片数扇区 磁道 表面 盘片 磁盘\n例如, 假设我们有一个磁盘, 有 5 个盘片, 每个扇区 512 个字节, 每个面 20 000 条磁道, 每条磁道平均 300 个扇区。那么这个磁盘的容量是:\n磁盘容量= 512 字 节 X 30 0 扇 区 20 000 磁道 X 2 表面 5 盘 片扇区 磁道 表面 盘片 磁盘\n= 30 720 000 000 字 节\n= 30. 72 GB\n注意, 制 造商是以千兆字节CGB) 或兆兆字节 ( T B) 为单位来表达磁盘容量的,这里\nl GB= l 沪字 节 , 1 T B= 1012 字 节 。\n田 日 - 千兆字节有多大\n不幸地 ,像 K C k ilo ) 、M( mega) 、G( giga) 和 T ( tera ) 这样的前缀的含义依 赖 于上下\n文。对于与 DR A M 和 SR AM 容量相 关的 计量单位, 通常 K = 210 , M = 220 , G = 2 气 而\nT = 2o4\n。 对 于与 像 磁 盘和网 络 这样的 I/ 0 设 备 容 量相关的 计 量 单位, 通常 K = 103 ,\nM = l 06 , G = l 0 9 , 而 T = l O气 速 率和吞吐量常常也使 用这些前缀。\n幸运地,对于我们通常依赖的不需要复杂计算的估计值,无论是哪种假设在实际中 都工作 得很好。例如, 230 和 10 9 之 间 的相 对差 别 不 大: ( 230 - 10 勹 / 10 9 :::::::::7 % 。 类 似,\n( 240 - 1 0 12 ) / 101 2:::::::::1 0 % 。\n练习题 6. 2 计算这 样一个 磁盘的容量, 它 有 2 个 盘 片 , 10 000 个柱 面, 每条磁 道平均有 400 个扇 区 , 而每 个扇 区有 51 2 个字 节。\n3 磁盘操作\n磁盘用读/写 头( rea d/ writ e hea d ) 来读写存储在磁性表面的位, 而 读 写 头 连接到一个传动 臂( act uator arm ) 一端,如 图 6- l Oa 所示。通过沿着半径轴前后移动这个传动臂, 驱动器可以 将读/写头定位在盘面上的任何磁道上。这样的机械运动称为寻道( seek ) 。一旦读/ 写头定位到了期望的磁道上, 那么当磁道上的每个位通过它的下面时,读 /写 头 可 以 感 知到这个位的值(读该位),也可以修改这个位的值(写该位)。有多个盘片的磁盘针对每个盘 面都有一个独立的读/写头 , 如 图 6-1 0 6 所 示。读/写头垂直排列, 一 致 行 动 。 在 任何时刻, 所有的读/写头都位于同一个柱面上。\n磁盘表面以固定 ,,,..,,,的旋转速率旋转 /\n,/\n读/写头连到传动臂的末端.在磁盘表面上一层薄薄的气垫上飞翔\n主轴\na ) 一个盘片的视图\n图 6-10\n磁盘的动态特性\nb ) 多个盘片的视图\n在传动臂末端的 读/写头在磁盘表面高度大约 0. 1 微米处的一层薄薄的气垫上飞翔(就是字面上这个意思),速度大约为80 km/ h。这可以比喻成将一座摩天大楼( 442 米高)放倒,然 后让 它在距离 地面 2. 5 cmCl 英寸)的高度上环绕地球飞行,绕 地球一天只需要 8 秒钟!在这样小的间隙里,盘 面上一粒微小的灰尘都像一块巨石。如果读/写头碰到了这样的一块巨石,读 /写 头会停下来, 撞到盘面一 所谓的读/写头冲撞 ( head crash ) 。为此,磁盘总是 密封包装的。\n磁盘以扇区大小的块来读写数据。对扇区的 访问 时间 ( acces s t im e ) 有 三个主要的部分: 寻道 时间 ( see k t im e ) 、旋转时间( rot at io na l la t ency ) 和传送时间 ( t ra ns fe r time) :\n寻道时间: 为了读取某个目标扇区的内容, 传 动 臂 首 先 将 读/写 头 定 位到包含目标扇区的磁道上。移动传动臂所需的时间称为寻道时间。寻道时间 T,eek 依 赖于读/写头以前的位置和传动臂在盘面上移动的速度。现代驱动器中平均寻道时间 T avg seek 是 通过对几千次对随机扇区的寻道求平均值来测扯的, 通常为 3 9 ms 。一次寻道的最大时间 T max seek 可 以 高 达 20 ms 。 旋转时间: 一旦读/写头定位到了期望的磁道, 驱动器等待目标扇区的第一个位旋转到读/写头下。这个步骤的性能依 赖于当读/写头到达目标扇区时盘面的位置以及磁盘的旋转速度。在最 坏的情况下, 读/写头刚刚错过了目标扇区,必 须 等待磁盘转一整圈。因此,最大旋转延迟(以秒为单位)是 Tmax rotation=\nRPM\nX 60s\nlmin\n平均旋转时间 Tavg rotation是 Tmax rotation的 一半。\n传送时 间: 当目标扇区的 第一个位位千读/写头下时, 驱动器就可以 开始读或者写该扇区的内容了。一个扇区的 传送时间依赖于旋转速度和每条磁道的扇区数目。因此,我们可以粗略地估计一个扇区以秒为单位的平均传送时间如下\nT = l X 1 X 60s\navg transfer RPM ( 平均扇 区数 / 磁道) lmin\n我们可以估计访问一个磁盘扇区内容的平均时间 为平均 寻道时间、平均旋转延迟和平均传送时间之和。例如,考虑一个有如下参数的磁盘:\n参数 值\n旋转速率\nT,vg江 , k\n每条磁道的平均扇区数\n7200RPM\n9ms\n400\n对于这个磁盘, 平均旋转延迟(以ms 为单位)是\nTavg rotauon = 1 / 2 X T max rotation = 1 / 2 X ( 60s / 7200 RPM) X 1000 ms / s ::::,::: 4 ms\n平均传送时间是\nTavg transfer = 60/ 7200 RPM X 1/ 400 扇 区/ 磁道 X 1000 ms / s ::::,::: 0. 02 ms\n总之,整个估计的访问时间是\nTaccess = T, vg seek + T avg rotation + T ,vg transfer = 9 ms+ 4 ms + o. 02 ms = 13. 02 ms\n这个例子说明了一些很重要的间题:\n访问一个磁盘扇区中 512 个字节的时间主要是寻道时间和旋转延迟。访问扇区中的第一个字节用了很长时间, 但是访问剩下的字节几乎不用时间。\n因为寻道时间 和旋转延迟大致相 等, 所以将寻道时间乘 2 是估计磁盘访问时间的简单而合理的方法。\n对存储在 SR AM 中的一个 64 位字的访问时间大约是 4n s , 对 DRAM 的访问时间是\n60ns 。因此, 从内存中读一个 512 个字节扇区大小的块的时间对 SR AM 来说大约是\n256n s , 对 DRAM 来说大约是 4000ns 。磁盘访问 时间, 大约 l Oms , 是 SRAM 的大约 40 000 倍, 是 DR AM 的大约 2500 倍。\n沁因 练习题 6. 3 估计访问 下面这个缢盘上 一个扇 区的访问 时间(以 ms 为单 位):\n参数\n旋转速率\nTavg seek\n每条磁道的平均扇区数\n值\n15 000RPM\n8 ms\n500\n逻辑磁盘块\n正如我们看到的那样, 现代磁盘构造复杂, 有多个盘面, 这些盘面上有不同的记录区 。 为了对操作系统隐藏这样的复杂性, 现代磁盘将它们的构造呈现为一个简单的视图,\n一个 B 个扇区大小的逻辑块的序列, 编 号 为 o, 1, …, B —1 。 磁 盘 封装中有一个小的硬件/固件设备,称 为磁 盘控制器,维 护 着 逻辑块号和实际(物理)磁盘扇区之间的映射关系。\n当操作系统想要执行一个1/0 操作时 ,例 如读一个磁盘扇区的数据到主存,操 作 系统会发\n送一个命令到磁盘控制器 ,让 它读某个逻辑块号。控制器上的固件执行一个快速表查找, 将一个逻辑块号翻译成一个(盘面, 磁 道, 扇区)的三元组, 这个三元组唯一地标识了对应的物理扇区。控制器上的硬件会解释这个三元组, 将读/写头移动到适当的柱面, 等 待 扇区移动到读/写头下, 将读/写头感知到的位放到控制器上的一个小缓冲区中,然后 将它们复制到主存中。\nm 格式化的磁盘容量\n磁盘控制器必须对磁盘进行格式化,然后才能在该磁盘上存储数据。格式化包括用 标识扇区的信息填写扇区之间的间隙,标识出表面有故障的柱面并且不使用它们,以及 在每个 区中预留出一 组柱面作 为备 用,如 果 区中一个或多 个柱 面在磁盘使用过程中坏掉了, 就可以使 用这 些备 用的 柱面。 因 为存 在 着这些备 用的 柱面, 所以磁盘制造商所说的格式化容量比最大容量要小。\n练习题 6. 4 假设 1MB 的文件由 512 个字节 的逻辑块组成, 存储在具 有如下特性的磁盘驱动器上:\n对于下面的 情况, 假设程 序顺 序地读 文 件的逻 辑块, 一个接 一个, 将读/写 头定位到 第一块上的 时间 是 T avg seek+T avgrota11o n o\n·A. 最好的情况: 给定 逻辑块到 磁 盘 扇 区的 最好的 可 能 的映 射(即顺序的), 估计读这\n个文件需要的最优时间(以 ms 为 单位)。\nB. 随机的情况: 如果块是随机地映射到 磁 盘扇 区的, 估计读这个 文件需要的 时间(以\nms 为 单位)。\n连接 1/ 0 设 备\n例如图形卡、监视器、鼠标、键盘和磁盘这样的输入/输出(l / 0 ) 设备,都 是 通过 l/ 0\n总线,例 如 Int el 的 外围设备 互 连( Periphe ral Component Int erconnect , PCD 总线连接到\nCPU 和主存 的。系统总线和内存总线是与 CPU 相关的,与 它 们 不 同 ,诸 如 PCI 这样的 I/\n0 总线设 计成与底层 CPU 无关。例如, P C 和 Mac 都可以使用 PCI 总线。图 6-11 展 示 了一个典型的 I/ 0 总线结构,它 连接了 CPU、主存和 l/ 0 设备。\n虽然 l/ 0 总线比系统总线和内存总线慢,但 是 它 可以容纳种类繁多的第三方 l/ 0 设备。例如,在 图 6-11 中 , 有 三种不同类型的设备连接到总线。\n通用串行 总线( Universa l Serial Bus, USB)控制器是一个连接到 USB 总线的设备的中转机构, USB总线是一个广泛使用的标准, 连接各种外围 l/ 0 设备,包 括键盘、鼠标、调制解调器、数码相机、游戏操纵杆、打印机、外部磁盘驱动器和固态硬盘。 USB 3. 0 总线的最大带宽 为 625MB / s。USB 3. 1 总线的最大带宽为 1250MB/ s。\n图形卡(或适配器)包含硬 件 和软件逻辑,它 们 负 责 代表 CPU 在显示器上画像素。\n主机 总线适配器将 一 个 或 多 个 磁盘连接到 I/ 0 总线,使 用 的 是 一 个 特 别 的 主机总线接 口定义的通信协议。两个最常用的这样的磁盘接口是 SCSI( 读作 \u0026quot; scuzzy\u0026quot; ) 和S AT A( 读作 \u0026quot; sat- uh\u0026quot; 。SCSI 磁盘通常比 SAT A 驱动器更快但是也更贵。SCSI主机 总 线 适 配器(通常称为 SCSI 控制器)可以支持多个磁盘驱动器, 与 S AT A 适配器不 同 , 它 只 能 支 待 一 个 驱 动 器 。\n寄口存器文件日\n系统总线 内存总线\n总线接口 凶,\n!,\u0026rsquo;.•·•C.·..\u0026rsquo;\u0026quot;,B·\nUSB Il三\nVO总线\nI\n000 ;\n针对诸如网络适\n配器这样的其他\nI 控制器 主机总线 设备的扩展插槽\nt t t\n鼠标 固态 键盘 监视器\n硬盘 : I 如 盘 扣 妇 l 盎 i:,\n* 磁 盘\n'\n'\n'\n一一一一一 -----I'\n图 6-11 总线结构示例,它连 接 CP U、主存和 I/ 0 设备\n其他的设备,例如网络适配器,可以通过将适配器插入到主板上空的扩展槽中,从而 连接到 I/ 0 总线, 这些插槽提供了到总线的直接电路连接。\n6 访问磁盘\n虽然详细描述 I/ 0 设备是如何工作的以及如何对它们进行编程超出了我们讨论的范围 ,但 是 我们可以给你一个概要的描述。例如,图 6-12 总结 了 当 CP U 从磁盘读数据时发生的步骤。\n田 0 总线设计进展\n图 6-11 中的 I/ 0 总线是一个简单的抽 象,使得 我们可以 具体描述但又不必和某个 系统的细节联 系过 于紧密。 它是 基 于外 围设 备 互联 ( Peripheral Component Interconnect, PCI) 总线的, 在 2010 年前使用非 常广泛。 PCI 模型中, 系统中所有的设备共享总线 , 一个时刻只 能有一台设备 访问这些线路。在现代系统中, 共享的 PCI 总线已经被 PCEe(PCI express) 总线取代, PCie 是 一组高速 串行 、通过开关连 接的点到点链路, 类似于你 将在第 11 章中学习到 的开关以 太网。PCie 总线, 最大吞吐率为 16GB / s , 比 PCI 总线快一个数量级 , PCI 总线的最大吞吐率为 533MB/ s。除 了测 量出的 I/ 0 性能, 不同总线设计之间的 区别 对应用程 序 来说是不可见的 ,所以 在本书中,我 们只使 用 简单的共享总线抽象。\nCP U 使用一种称为内存映射 I/ O ( memor y-ma pped I/ 0 ) 的技术来向 I/ 0 设备发射命令\n(图 6-12a) 。在使用内存映射 I/ 0 的系统中, 地址空间中有一块地址是为与 I/ 0 设备 通信保留的 。每个这样的地址称为一个 I/ 0 端口 (I / 0 port ) 。当一个设备连接到总线时, 它 与一个或多个端口相关联(或它被映射到一个或多个端口)。\nCPU芯片\nI\n寄存器文件\n三]\n罕\n鼠标 键盘 监视器 Cf\nCPU芯片\n1 寄存器文件\n已 三\n雪罕 芦忑\nVO总线\nb ) 磁盘控制器读扇区, 并 执行到主存的DMA传送\n图 6-12\n当 OMA传送完成时, 磁盘控制器用中断的方式通知CPU 读一个磁盘扇区 来看一个简单的例子,假 设 磁 盘控制器映射到端口 OxaO 。 随 后 , CPU 可能通过执行三个对地址 OxaO 的 存储 指 令 ,发 起 磁盘读:第 一 条 指 令 是发送一个命令字,告 诉 磁 盘发起一个读, 同时还发送了其他的参数,例如 当读完 成时 ,是否 中断 CP U( 我们会在 8. 1 节中讨论中断)。第二条指令指明应该读的逻辑块号。第三条指令指明应该存储磁盘扇区内容的主存地址。\n当 CP U 发出了请求之后,在 磁 盘 执 行 读 的 时 候 , 它 通常会做些其他的工作。回想一下, 一个 1G Hz 的 处理器时钟周期为 I ns , 在用来读磁盘的 16ms 时间里, 它 潜 在 地 可能执行 16 00 万条指令。在传输进行时,只 是 简单地等待, 什么都不做,是 一 种 极 大 的 浪费。\n在磁盘控制器收到来自 CP U 的读命令之后 ,它 将逻辑块号翻译 成一个扇区地址,读该扇区的内容,然 后 将这 些内 容 直 接传送到主存,不需 要 CPU 的干涉(图6-12b) 。设 备可以自己 执行读或者写总线事 务 而不需 要 CP U 干涉的 过程, 称 为 直接 内 存 访问 ( Direct\nMemory Access, DMA ) 。这种数 据传送称为 DMA 传送 CDMA trans fer ) 。\n在 DMA 传送完成, 磁盘扇区的内容被安全地存储在主存中以后, 磁盘控制器通过给CPU 发送一个中断信号来通知 CPU(图 6-12c) 。基本思想是中断会发信号到 CPU 芯片的一个外部引脚上。这会 导致 CPU 暂停它当前正在做的工作, 跳转到一个操作系统例程, 这个程序会记 录下 1/0 已经完成, 然后将控制返 回到 CPU 被中断的地方。\n田 日 商用磁盘的特性\n磁盘制造商在他们的 网 页上公 布了许多 高级技术信息。例如, 希捷( Seagate) 公司 i 的网站 包含关 于他 们 最受 欢迎的 驱 动 器之一 Barracuda 7400 的如下信息。(远 不止如 : 此 ! ) (S eagate. com)\n,志\n1. 3 固态硬盘\n固态硬盘CSolid Stat e Dis k , SSD) 是一种基于闪存的存储技术(参见 6. 1. 1 节),在某些情况下是传统旋转磁盘的极有吸引力的替代产品。图 6-1 3 展示了它的基本思想。SSD 封装插到 I/ 0 总线上标准硬盘插槽(通常是 USB 或 SAT A ) 中, 行为就和其他硬盘一样, 处理来自 CP U 的读写逻辑磁 盘块的请求。一个 SSD 封装由一个或多个闪存芯片和闪存翻译层( flas h translation layer) 组成,闪 存芯片替代传统旋转磁盘中的机械驱动器, 而闪存翻译层是 一个硬件/固件设备, 扮演与磁盘控制器相同的角色,将 对逻辑块的请求翻译成对底层物理设备的访问。\n1/0 总线\n固态硬盘 ( SSD )\n- - - - - - \u0026ndash;\n! 闪存\n闪存翻译层\n- - - - - - - - - -\n! 块0 块B-1\nII 页 o I 页I I 口三工JI \u0026mdash; 11 页o I 页I\n..I. [勹芒了门\n一一一一- - \u0026mdash;- \u0026mdash;- \u0026mdash;- \u0026mdash;\u0026mdash;\u0026mdash;\u0026mdash;\u0026ndash; \u0026mdash;- \u0026mdash;- \u0026mdash;\u0026mdash;\u0026mdash;\u0026mdash;\u0026mdash;\u0026mdash;\u0026mdash;\u0026mdash;\u0026mdash;\u0026ndash; \u0026mdash;\u0026mdash;\u0026mdash;\u0026mdash;\u0026mdash;\u0026mdash;\u0026mdash;\u0026mdash;\u0026mdash;\u0026mdash;\n图 6-13 固态硬盘C SS D)\n图 6-14 展示了典型 SSD 的性能特性。注意, 读 SSD 比写要快。随机读和写的 性能差别 是由底层闪存基本属性决定的。如图 6-13 所示, 一个闪存由 B 个块的序列组成, 每个块由 P 页组成。通常 , 页的大小是 512 字节~ 4KB, 块是由 32 ~ 1 28 页组成的, 块的大小\n为 16 KB~ 512KB。数据是以页为单位读写的。只有在一页所属的块整个被擦除之后, 才能写这一页(通常是 指该块中的所有 位都被设 置为 1 ) 。不 过, 一旦一个块被擦除了, 块中每一个页都 可以不需 要再进行擦除就写一次。在大约进行 100 000 次重复写之后, 块就会磨损坏。一旦 一个块磨损坏之后 , 就不能再使用了。\n随机读吞吐量 (MB /s ) 365MB/s 随机写吞吐童 (MB/s ) 303MB/s 平均顺序读访问时间 50µs 平均随机写访问时间 60µs 图 6-1 4 一个商业固态硬盘的性能特性\n资料来源: Intel SSD 730 产品 规 格 书[ 53] 。IOPS 是每秒 1/0 操 作 数 。吞 吐 量数 量基 于 4KB 块的 读 写\n随机写很慢, 有两个原因。首先, 擦除块需要相对较长的 时间, l m s 级 的 , 比访间页所需时 间要高一个数量级。其次, 如果写操作试图修改一个包含巳经有数据(也就是不是全为 1 ) 的页 p, 那么这个块中所有带有用数据的页都必须被复制到一个新(擦除过的)块, 然后才能 进行对页 p 的写。制造商已 经在闪存 翻译层中实现了 复杂的逻辑 , 试图抵消擦写块的高 昂代价, 最小化内部写的次数, 但是随 机写的性能不太可能 和读一样好。\n比起旋转磁盘 , SSD 有很多优点。它们由半导体存储器构成 , 没有移动的 部件, 因而随机访问 时间比旋转磁盘要快, 能耗更低, 同时也更结实。不过, 也有一些缺点。首先, 因为反复 写之后,闪 存块会磨损,所 以 SSD 也容易磨损。闪存翻译层中的平均 磨损( wear leveling ) 逻辑试图通 过将擦除平均分布在所 有的块上来最大化每个 块的 寿命。实际 上, 平均磨损逻 辑处理得非常好 , 要很多 年 SSD 才会磨损坏(参考练习题6. 5 ) 。其次, SS D 每字 节比旋转磁盘 贵大约 30 倍, 因此常用的存储容量比旋转磁盘小 100 倍。不过,随 着 SSD 变得越来越受 欢迎,它 的价 格下降 得非常快,而两者 的价格差也 在减少。\n在便携音乐设 备中, SSD 巳经完全的取代了旋转磁盘, 在笔记 本电脑中也越来越多地作为硬 盘的替代品, 甚至在台式机和服务器中也开始 出现了。虽然旋转磁盘还会继续存在,但 是显然, SS D 是一项重要的替代选择。\n练习题 6. 5 正 如我 们 已 经 看到 的, SSD 的 一个 潜 在 的 缺 陷 是 底 层 闪 存 会磨损。例如, 图 6-14 所 示的 SSD , In tel 保证 能够经得 起 128 PB C 128 X 1015 字 节)的写。 给定 这 样的假 设, 根据下面的工 作负 载, 估计这款 SSD 的寿命(以年为 单位):\n顺序写的最糟情况 : 以 470MB/s( 该设备的平均顺序写吞吐量)的速度持续地写 SSD 。 随机写的最糟情况 : 以 303MB/ s( 该设备的平均随机写吞吐量)的速度持续地写 SSD 。 平均情况 : 以 20GB/ 天(某些计 算机 制造商在他 们的 移 动计 算机 工作 负 载模 拟测 试中假设 的平 均每 天写速 率)的速度 写 SSD 。 6. 1. 4 存储技术趋势\n从我们对存储技术的讨论中,可以总结出几个很重要的思想:\n不同 的存储技 术有 不 同的 价格和性能折中。S RAM 比 DRAM 快一点, 而 DR AM 比磁盘要快 很多。另一方面, 快速存储总是比慢速存储要贵的。SR AM 每字节的造价比DRAM 高, DRAM 的造价又比磁 盘高得多。SSD 位千 DRAM 和旋转磁盘之间。\n不同 存储技术的价格和性能属性以 截然不 同的 速率 变化 着。图 6-15 总结了从 1985 年\n以来的存储技术的价格和性能属性,那 时笫 一 台 P C 刚 刚 发明不久。这些数字是从以前的商 业 杂 志 中 和 W e b 上挑选出来的。虽然它们是从非正式的调查中得到的, 但 是 这些数字还是能揭示出一些有趣的趋势。\n自从 1 98 5 年以来, S RAM 技术的成本和性能基本上是以相同的速度改善的。访问时间 和 每兆字节成本下降了大约 1 0 0 倍(图6- 1 5 a ) 。不过, D RAM 和磁盘的变化趋势更大, 而 且 更 不 一 致。DRAM 每兆字节成本下降了 44 0 0 0 倍(超过了四个数量级!), 而 DRAM的 访 问 时 间 只 下 降 了 大 约 1 0 倍(图 6- 1 5 b ) 。 磁 盘技术有和 DRAM 相同的趋势, 甚至变化更 大 。 从 1 9 8 5 年以来,磁 盘存储的每兆字节成本暴跌了 3 000 0 0 0 倍(超过了六个数量 级!),但是 访问 时 间 提高得很慢,只 有 2 5 倍 左 右(图 6- 1 5 c ) 。 这些惊人的长期趋势突出了内 存 和 磁 盘 技术的一个基本事实:增 加 密度(从而降低成本)比降低访问时间容易得多。\nDRAM 和磁盘的性 能滞后 于 C P U 的性能。正如我们在图 6- 1 5 d 中看到的那样,从\n1 9 8 5 年到 2 0 1 0 年, C P U 周 期 时 间 提高了 5 0 0 倍。如果我们看有效周期时间 ( e ff ec t ive cy­ cle time) 我们定义为一个单独的 C P U ( 处理器)的周期时间除以它的处理器核数一 那么 从 1 9 8 5 年到 20 1 0 年的提高还要大一些, 为 2 0 0 0 倍。C P U 性能曲线在 2 0 0 3 年附近的突然 变 化 反映的是多核处理器的出现(参见 6 . 2 节的旁注),在 这 个 分 割 点 之 后 ,单 个 核的周期时间实际上增加了一点点,然后又开始下降,不过比以前的速度要慢一些。\n度量标准 1985 1990 1995 2000 2005 2010 2015 2015:1985 美元/MB 2900 320 256 100 75 60 25 116 访问时间 Cns) 150 35 15 3 2 1.5 1.3 115 a) SRAM趋势 度量标准 1985 1990 1995 2000 2005 2010 2015 2015:1985 美元/MB 880 100 30 I 0.1 0.06 0,02 44 000 访问时间 ( ns ) 200 100 70 60 50 40 20 10 典型的大小 ( MB) 0.256 4 16 64 2000 8000 16000 62 500 b ) DRAM趋势 度量标准 1985 1990 1995 2000 2005 2010 2015 2015:1985 美元 !GB 100 000 8000 300 10 5 0.3 0.03 3 333 333 最小寻 道时间 ( ms) 75 28 10 8 5 3 3 25 典型的大小 (G B ) 0.01 0.16 I 20 160 1500 3000 300 000 c ) 旋转磁盘 趋 势 度呈标准 1985 1990 1995 2000 2003 2005 2010 2015 2015:1985 Intel CPU 80 286 80 386 Pent. P-田 Pent.4 Core2 Core i7 (n) Core i7 (h) 时钟频率 ( MHz) 6 20 150 600 3300 2000 2500 3000 500 时钟周期 ( ns) 166 50 6 1.6 0.3 0.5 0.4 0.33 500 核数 I I I 1 l 2 4 4 4 有效周期时间 ( ns ) 166 50 6 1.6 0.30 0.25 0.10 0.08 2075 d) CPU趋势 图 6-15 存储和处理器技术发展趋势 。2010 年的 Co re i7 使用的是 Nehalem 处理器 ,\n2015 年的 Co re i7 使用的是 H as well 核\n注意,虽 然 SRAM 的 性能 滞后 千 CPU 的性能, 但还是 在保待增长。不 过, DRA M 和磁盘性能与 CPU 性能之间的差距实际上是在加大的。直到 20 03 年左右多核处理器的出现, 这个性能差距都是延迟的函数, DRAM 和磁盘的访问时间比单个处理器的周期时间提高得更 慢。不过,随 着 多 核 的 出 现, 这个性能越来越成为了吞吐量的函数, 多 个 处 理 器核并发 地向 DRAM 和磁盘发请求。\n图 6-1 6 清楚地表明了各种趋势,以 半 对 数 为比例( s em i-log scale ) , 画出了图 6-1 5 中的访问时间和周期时间。\nI00 000 000.0\n10 000 000.0\nI 000 000.0\nJOO 000.0 \u0026ndash;+- 磁 盘寻道时间\n_._ SSD 访问 时间\nJO 000.0\n1000.0\n100.0\n10.0\n1.0\n0.1\n0.0\n1985 1990\n1995\n2000 2003 2005 2010\n年份\n2015\n, 气 ) RAM访问时间\n千 SRAM访问时间\n-0- CPU周期时间\n今 有效CPU周期时间\n图 6-16 磁 盘 、 DRAM 和 CPU 速度之间逐渐增大的差距\n正如我们将在 6. 4 节中看到的那样, 现代计算机频繁地使用基千 SRAM 的高速缓存, 试图弥补处理器-内存之间的差距。这种方法行之有效是因为应用程序的一个称为局部性 (localit y ) 的 基本属性,接 下 来 我们就讨论这个间题。\n练习题 6. 6 使用图 6-15c 中从 200 5 年到 2015 年的数据, 估计到 哪一年你可以以 $ 500\n的价 格买到 一个 1PBC1015 字节)的 旋转磁 盘。假 设美元价值不 变(没有通货 膨胀 )。\nm 当周 期时间保持不变: 多核处理器的到来\n计算机历史是由一些在工业界和整个世界产生深远变化的单个事件标记出来的。有趣 的是,这些变化点趋向于每十年发生一次: 20 世纪 50 年代 Fortra n 的提出, 20 世 纪 60 年代早期 IBM 360 的出现 , 20 世 纪 70 年代早期 Int ernet 的曙光(当 时称为 AP RA NE T ) , 20世纪 80 年代早期 IBM PC 的出现 ,以 及 20 世 纪 90 年代万维网 ( World Wide Web) 的出现 。最近这样的事件出现在 21 世纪初, 当计 算机制造商迎 头撞 上了所谓 的“能量墙( power\nwall)\u0026quot;, 发现他们无法再像以前一样迅速地增加CPU的时钟频率了,因为如果 那样芯片的功耗会太大。解决方法是用多个小处理 器核(core ) 取代单个大处理 器,从而提 高性能,每 个完整的处理器能够独立地、与其他核 并行地执行程序。这种多核 ( m ulti- core) 方法部 分有效,因为一 个处理器的功耗正比于 P = J C寸, 这里J 是时钟频率,C 是电容,而 v 是电压。电容 C 大致上正比于面积,所 以只要所有核的总面积不变,多核 造成的能耗就能保持不变。只要特征尺寸继续按照摩尔定律指数性地下降,每 个处理器中的核数,以及每个处理 器的有效性能,都会继续增加 。\n从这个时间点以后,计算机越来越快,不是因为时钟频率的增加,而是因为每个处理器 中核数的 增加,也 因为体 系结构上的创新提高了 在这些核上运行程序的效率。我们可以从图6-16 中很清楚地看到这 个趋势。CPU 周期时间在 2003 年达到最低点,然后实际上是又开始上\n升的,然 后变得平稳,之后 又开始以比以前慢一些的速率下降 。不过,由 于多核处理器的出现(2004 年出现双核, 2007 年出现四核), 有效周期时间以接近于以前的速率持续下降。\n6. 2 局部性\n一个编写良好的计算机程序常常具有良 好的局部性 (l o ca lit y ) 。也就是, 它们倾向于引用邻近千其 他最近引用过的数据项的数据项, 或者最近引 用过的数据项本身。这种倾向性, 被称为局部 性原理( p rin cipl e of loca lit y ) , 是一个持久的概念, 对硬件和软件系统的设计 和性能都 有着极大的影 响。\n局部性通常有 两种不同的 形式: 时间局 部性( t e m po ra l lo ca l it y ) 和空间 局部性( s patial lo ca lit y) 。在一个具有良好时间局部性的程序中 , 被引用过一次的内存位置很可能 在不远的 将来再被多次引用。在一个具有良好空间局部性的程序中 , 如果一个内存位置被引用了一次, 那么程序很可能在不远的将来引用附 近的一 个内存位置。\n程序员应该理解局部性原理,因为一般而言,有良好局部性的程序比局部性差的程序 运行得更快。现代计算 机系统的各 个层次 , 从硬件到操作系统、再到应用 程序, 它们的设计都利用了局部性。在硬件层,局 部性原理允许计算机设计者通过引入称为高速缓存存储器的 小 而快速的存储器来保存最近被引 用的 指令和数据项, 从而提高对主存的访问速度。在操作系统级, 局部性原理允许系统使用主存作为虚拟地址空间最近被引用块的高速缓存。类似地, 操作系统用主存来缓存磁盘 文件系统中最近被使 用的磁盘块。局部性原理在应用程序的设计中也扮演着重要的角色。例如, Web 浏览器 将最近被引 用的文档放在本地磁盘上,利用的 就是时间局部性。大容屈的 Web 服务器将最近被请求的文档放在前端磁盘高速缓存中, 这些缓存能满足对这些 文档的请 求, 而不需要服务器的任何干预。\n6. 2. 1 对程序数据引用的局部性\n考虑图 6-17a 中的简单函数, 它对一个向员的元素求和。这个程序有良好的局部性吗?要回答这个问题, 我们来看看每个变 量的引用模式。在这个例子中,变 量 s u m 在每次循环迭代中被引用一次 , 因此, 对于 s u m 来说 , 有好的时间 局部性。另一方面, 因为 s um 是标量, 对于 s um 来说, 没有空间局部性。\nint swnvec(int v[N])\n{\nint i, sum = O;\nfor (i = O; i \u0026lt; N; i++) sum+= v[i];\nreturn sum;\n地址内容\n访问顺序\n16\n82\nvI V\n5\n24\n2 87\nv 6\n7\na ) 一 个具 有良好局部性的程序 b ) 向量v的引用模式 ( N = 8)\n图 6-17 注意如何按照向量元素存储在内存中的顺序来访间它们\n正如我们在图 6-176 中看到的, 向量 v 的元素是 被顺序读取的, 一个接一个, 按照它们存储在内存中的 顺序(为了方便, 我们假设数 组是从 地址 0 开始的)。因此, 对于变量 V, 函数有很好的空间局部性,但是时间局部性很差,因为每个向噩元素只被访问一次。因为 对千循环体中的每个变量,这个函数要么有好的空间局部性,要么有好的时间局部性,所 以我们可以断定 s umv e c 函数有良好的局部性。\n我们说像 s umv e c 这 样顺序访问一个向批每个元素的函数,具 有 步 长 为 1 的引用 模 式(str ide- I reference pattern)(相对千元素的大小)。有时我们称步长为 1 的引用模式为顺序引用模式( seq uent ial reference pat tern ) 。一个连续向最中, 每隔 K 个 元 素 进 行 访 问 , 就 称为步长为 K 的引 用模式( s t r id e- k reference pattern ) 。步长为 l 的引用模式是程序中空间局部性常见和重要的来源。一般而言,随着步长的增加,空间局部性下降。\n对于引用多维数组的程序来说,步 长也是一个很重要的问题。例如,考 虑 图 6-18a 中的函数 s umarr a yr ows , 它 对 一个二维数组的元素求和。双重嵌套循环按照行优先顺序( ro w­ major order ) 读 数组 的元素。也就是,内 层 循 环读第一行的元素, 然后读第二行,依 此 类 推 。函数 s umarr a yr ows 具 有良好的空间局部性,因 为它按照数组被存储的行优先顺序来访问这个 数组(图6-186 ) 。其结果是得到一个很好的步长为 1 的引用模式 ,具有良好的空间局部性 。\nint sumarrayrows (int a[M] [NJ)\n{\ninti, j, sum= O;\nfor (i = 0 ; i \u0026lt; M; i ++)\nfor (j = 0; j \u0026lt; N; j ++) sum += a[i] [j];\nreturn sum;\n地址内容\n访问顺序\n。\naoo\na 01\n12 16\n20\na12\n图 6-18\na ) 另一个具有良好局部性的程序 b ) 数组a的引用模式( M = 2, N=3)\n有良好的空间局部性,是因为数组是按照与它存储在内存中一样的行优先顺序来被访问的\n一些看上去很小的对程序的改动能够对它的局部性有很大的影响。例如,图 6-1 9a 中的函数 s umarr a y c o l s 计 算 的 结 果 和图 6-18a 中函数 s umar r a y r o ws 的 一 样。唯一的区别是我们交换了 1 和)的循环。这样交换循环对它的局部性有何影响? 函数 s umarr a y c o l s 的空间局 部性很差 ,因 为它按照列顺序来扫描数组,而 不是按照行顺序。因为 C 数组在内存中是按照行顺序来存放的,结 果 就 得 到步长为 N 的引用模式, 如图 6-1 96 所示。\nint surnarraycols(int a[M] [N])\n{\ninti, j, sum= O;\nfor (j = 0; j \u0026lt; N; j ++)\nfor (i = 0 ; i \u0026lt; M; i ++)\nsum+= a[i][j]; return sum,·\n地址内容\n访问顺序\na 01\n12 16\n20\na12\n6. 2. 2\na ) 一个空间局部性很差的程序 b ) 数组a的引用模式( M = 2, N=3)\n图 6- 1 9 函数的空间局部性很差 , 这是因为它使用步长为 N 的引用模式来扫描\n取指令的局部性\n因为程序指令是存放在内存中的, CPU 必须取出(读出)这些指令,所 以 我们也能够评价一个程序关于取指令的局部性。例如,图 6-1 7 中 f or 循环体里的指令是按照连续的内存顺序执行的,因此循环有良好的空间局部性。因为循环体会被执行多次,所以它也有 很好的时间局部性。\n--,\n代码区别千程序数据的一个重要属性是在运行时它是不能被修改的。当程序正在执行 时, CPU 只从内存中读出它的指令。CPU 很少会重写或修改这些指令。\n2. 3 局部性小结\n在这一节中, 我们介绍了局部性的基本思想, 还给出了量化评价程序中局部性的一些简单原则:\n重复引用相同变量的 程序有良 好的时间局部性。\n对于具有步长为 K 的引用模式的程序, 步长越小, 空间局部性越好。具有步长为 l 的引用模式的程序有很好的空间局部性。在内存中以大步长跳来跳去的程序空间局 部性会很差。\n对千取指令来说,循环有好的时间和空间局部性。循环体越小,循环迭代次数越多, 局部性越好。\n在本章后面,在我们学习了高速缓存存储器以及它们是如何工作的之后,我们会介绍 如何用高速缓存命中率和不命中率来量化局部性的概念。你还会弄明白为什么有良好局部性的程序通常比局部性差的程序运行得更快。尽管如此,了解如何看一眼源代码就能获得 对程序中局部性的高层次的认识,是程序员要掌握的一项有用而且重要的技能。 .\n练习题 6. 7 改变下面函数中循环的顺序,使得 它以步长为 1 的引用模式扫描三维数组 a :\nint sumarray3d (int a[NJ [NJ [NJ)\n{\nint i , j , k, sum \u0026ldquo;\u0026rsquo; 0 ;\nfor (i = O; i \u0026lt; N; i++) {\nfor (j = O; j \u0026lt; N; j++) {\nfor (k = O; k \u0026lt; N; k++) { sum += a[k] [i] [j];\n}\n}\n}\nreturn sum;\n练习题 6 . 8 图 6- 20 中的 三个函数,以不同的 空间局部 性程度 , 执行 相同的 操作。请对这些函数就空间局部性进行排序。解释你是如何得到排序结果的。\nvoid clear1(point *P, int n)\n{\n#define N 1000 typedef struct {\nint vel[3];\nint acc[3];\n} point;\npoint p[N];\na) str uc t s 数组\n图 6-20\nint i , j;\nfor (i = O; i \u0026lt; n; i++) { for (j = O; j \u0026lt; 3; j++)\np[i] .vel[j] = O;\nfor (j = O; j \u0026lt; 3; j++)\np[i] . acc[j] = 0;\n}\nb ) c l e ar l 函数\n练习题 6. 8 的代码示 例\nvoid clear2(point *P, int n)\n{\nint i, j;\nfor (i = 0; i \u0026lt; n; i ++) {\nfor (j = 0; j \u0026lt; 3; j++) { p [i] . vel[j] = 0;\np [i] . ace [j] = 0;\n}\n}\nvoid clear3(point *P, int n)\n{\nint i, j;\nfor (j = 0; j \u0026lt; 3; j++) { for (i = O; i \u0026lt; n; i++)\np[i] .vel[ j] = O;\nfor (i = O; i \u0026lt; n; i++) p[i] .acc[j] = O;\n}\nc) c l e ar 2 函数 d) c l e ar 3函数\n图 6- 20 (续)\n3 存储器层次结构\n6. 1 节和 6. 2 节描述了存储技术和计算机软件的一些基本的和持久的属性 :\n存储技 术: 不同存储技术的访问 时间差异很大。速度较快 的技术每字 节的成本要比速度较慢的技术高 , 而且容最 较小。CP U 和主存之间的速度差距在增大。\n计算机软件 : 一个编写良好的程序倾向 于展示出良 好的局部性。\n计算中一个喜人的巧合是, 硬件和软件的这些 基本属性互 相补充 得很完美 。它们这种相互补充的性 质使 人想 到一种组 织存储 器 系统的方 法, 称 为 存 储 器 层 次 结 构 ( memory 加 rarchy) , 所有的现代计算 机系统中都使用了这种方法。图 6- 21 展示了一个典型的存储器层次结构。一般而言,从高层往底层走,存储设备变得更慢、更便宜和更大。在最高层\n(LO), 是少量快速的 CPU 寄存器, C P U 可以在一个时钟周 期内访间它们。接下来是一个\n更小更快和\n(每字节) 成本更高的存储设备\n更大\nL3:\nLl :\nL2\n高速缓存\n(SRAM)\nL3\n高速缓存\n(SRAM)\nCPU寄存器保存着从高速缓存存储器取出的字\n} LI 高速缓存保存着从L2 高速缓存取出的缓存行\n} L2高速缓存保存着从L3\n高速缓存取出的缓存行\n} L3高速缓存保存着从主存高速缓存取出的缓存行\n更慢和\n(每字节) 成本更低的存储设备\nLS:\nL4: 主存 ( DRAM )\n本地二级存储(本地磁盘)\n主存保存着从本地磁盘取出的磁盘块\n本地磁盘保存着从远程网络服务器磁盘上取出的文件\n图 6-21 存储器层次结构\n或多个小型到中型的基于 SRAM 的高速缓存存储器, 可以 在儿个 CPU 时钟周期内访问它们 。 然后是一个大的基于 DRAM 的主存, 可以在几十到几百个时钟周期内访问它们。接下 来是慢速但是容扯很大的本地磁盘。最后,有 些 系统甚至包括了一层附加的远程服务器上 的 磁 盘 , 要通 过 网 络来访问 它 们。例 如, 像安 德鲁 文件 系统 ( Andrew File System, AFS )或者网络文件系统 ( Netwo rk File System, NFS ) 这样的分布式文件系统,允 许程序访 问 存 储在远程的网络服务器上的文件。类似地,万 维 网允 许程序访问存储在世界上任何地 方的 Web 服务器上的远程文件。\nm 其他的存储器层次结构\n我们向你展示了一个存储器层次结构的示例,但是其他的组合也是可能的,而且确 实也 很 常见。例如,许 多站点(包括谷歌的数据 中心 )将本地磁盘备份到存 档的磁带上。其中有些站点,在 需要时由人工装好磁 带。 而其他 站点则是 由磁 带机 器人 自动 地完成这项任务。 无论在哪种情况中,磁 带都是存储器层 次结构中的一层, 在本地磁盘层下 面, 本 书中提到的 通用原 则也 同样 适用于它。磁 带每 宇节比 磁 盘更便 宜, 它允 许站点将本地磁} 盘的多 个快照存档。代价是磁带的 访问时间要比磁盘的更长。 未看另一个例子, 固 态硬盘, 在存储器层 次结构 中扮 演着越 来越重要的角 色,连接 起 DRAM 和旋转磁盘之间的鸿沟。.\n3. 1 存储器层次结构中的缓存\n一般而言, 高速缓存( cache , 读作 \u0026quot; cas h\u0026rdquo; ) 是 一 个 小 而快速的存储设备, 它 作 为存储在更大、也更慢的设备中的数据对象的缓冲区域。使用高速缓存的过程称为缓存( caching, 读作 \u0026quot; cashing\u0026quot; ) 。\n存储器层次结构的中心思想是, 对于每个 k , 位于 K 层 的 更 快更小的存储设备作为位于k + l 层 的 更 大更 慢的存储设备的缓存。换句话说, 层 次结 构中的每一层都缓存来自较低一层 的 数 据 对象 。 例 如,本地 磁盘作为通过网络从远程磁盘取出的文件(例如 Web 页面)的缓存, 主存作为本地磁盘上数据的缓存, 依此类推, 直 到 最小的缓存- CPU 寄存器组。\n图 6- 22 展示了存储楛层次结构中缓存的一般性概念。第 k + l 层 的 存 储 器被划分成连续 的 数 据 对 象 组块( ch unk ) , 称为块\u0026lt; block ) 。每个块都有一个唯一的地址或名字, 使之区别 于 其 他 的 块。块可以是固定大小的(通常是这样的), 也 可以是可变大小的(例如存储在 Web 服务器上的远程 H T ML 文件)。例如,图 6- 22 中第 k + l 层 存 储 器被划分成 16 个大小 固 定 的 块 ,编 号 为 0 ~ 15。\n第K 层: 亡工丿仁工丿仁五丿仁工丿 第K 层更小、更快、更昂贵的设备\n` 缓存着第k+l 层块的一个子集\n二 二 数据以块为大小传输\n单元在层与层之间复制\n亡严]二亡仁仁二二仁\n第k+I 层: 1\n工二三二正]巨三]\n口口口三]巨三]\n第k+ I层更大、更慢、更便宜的设备被划分成块\n图 6-22 存储器层次结构中基本的缓存原理\n类似地, 第 k 层的存储器被划分成较少的 块的集合, 每个块的 大小与 k + l 层的块的大小一样。在任何时刻 , 第 K 层的缓存包含第 k + l 层块的 一个子集的副本。 例如, 在图 6-22 中, 第 k 层的缓存有 4 个块的空间 , 当前包含块 4 、9、14 和 3 的副本。\n数据总是以 块大小为传送单元 ( t ra nsfer un it ) 在第 k 层和第 k + I 层之间来回复制的。虽然在层次结构中任何一对相邻的层次之间块大小是固定的,但是其他的层次对之间可以 有不同的块大小。例如, 在图 6- 21 中, L l 和 LO 之间的传送通常使用的是 1 个字大小的块。 L2 和 L1 之间(以及 L 3 和 L2 之间、L4 和 L3 之间)的传送通常 使用的是几十个字 节的块。而 L 5 和 L4 之间的传送用的是大小为几百 或几千字节的块。一 般而言 , 层次结构中较 低层(离 CP U 较远)的设备的访问时间较长, 因此为了 补偿这些较长的 访问时间,倾 向 于 使用较大的块。\n缓存命中\n当程序需要第 k + l 层的某个数据对象 d 时,它 首先在当前存储在第 k 层的一个块中查找 d 。 如果 d 刚好缓存在第 k 层中, 那么就是我们所说的缓存命 中( cache h代)。该 程序直接从第 k 层读取 d , 根据存储 器层次结构的性质,这 要比从第 k + l 层读取 d 更快。例如, 一个有良好时间局部 性的程序 可以从块 14 中读出一个数据对象, 得到一个对第 k 层的缓存命中。\n缓存不命中\n另一 方面, 如果第 K 层中没有缓存数据对象 d , 那么就是我们所说的缓存不命中(cache miss ) 。 当发生缓存不命中时, 第 k 层的缓存从第 k + l 层缓存中取出 包含 cl 的 那个块, 如果第 k 层的缓存已经满了 , 可能就会覆盖现存的一 个块。\n覆盖一个现存的 块的过程称为替 换( replacing ) 或驱逐( evicting ) 这个块。被驱逐的这个块有时 也称为牺牲块 ( vict im blo ck) 。决定该替换哪个块是由缓存的替换 策略 ( replace­ ment polic y) 来控制的。例如, 一个具有随机替 换策略 的缓存会随机选择一 个牺牲块。一个具有 最近最少被使用CLRU) 替换策略的缓存会选择那个最后被访问的时间距现在最远的块。\n在第 k 层缓存从第 k + l 层取出那个块之后, 程序就能像前面一样从 第 k 层读出 d 了。例如,在 图 6-22 中, 在第 k 层中读块 1 2 中的一个数据对象, 会导致一个缓存不命 中, 因为块 1 2 当前不 在第 k 层缓存中。一旦把块 12 从第 k + l 层复制到第 k 层之后, 它就会保持在那里,等待稍后的访问。\n缓存不命中的 种类\n区分不同种类的缓存不命中有时候是很有帮助的。如果第 K 层的缓存是空的 , 那么对任何数 据对象的访问都会不命中。一个空的缓存有时被称为冷缓 存 ( cold cache), 此类不命中称为 强制性 不命中( co m pulso r y m is s ) 或冷不命 中( cold mis s ) 。冷不命中很 重要 , 因为它们通 常是短暂的 事件 ,不会 在反复访问 存储器使得缓存暖身 ( w a r m ed up ) 之后的稳定状态中出现。\n只要发生了不命中, 第 K 层的缓存就必须执行某个放置策略 ( place men t policy), 确定把它从第 k + l 层中取出的块放在哪里。最灵活的替 换策略是允 许来自第 k + l 层的任何块放在第 k 层的任何块中。对于存储器层次结 构中高层的缓存(靠近 CP U ) , 它们是用硬件来实现的,而且速度是最优的,这个策略实现起来通常很昂贵,因为随机地放置块,定位起 来代价很高。\n因此, 硬件缓存通常使用的是更严格的放置策略, 这个策略将第 k + l 层的某个块限制放置在第 k 层块的一个小的子集中(有时只是一个块)。例如, 在图 6- 22 中, 我们可以确定第 k + l 层的块 1 必须放置在第 k 层的块(i mod 4 ) 中。例 如, 第 k + l 层的块 0、4、8 和 1 2 会映射到第 K 层的块 O; 块 1、5、9 和 1 3 会映射到块 1 ; 依此类推。注意 , 图 6-22 中的示例缓存使用 的就是这个策略 。\n这种限制性的放置策略 会引起一种不命中,称 为冲 突不命 中( confl ict miss ) , 在这种\n情况中,缓存足够大,能够保存被引用的数据对象,但是因为这些对象会映射到同一个缓 存块, 缓存会一直不命中。例如,在图 6- 22 中,如 果程序请求块 0\u0026rsquo; 然后块 8 , 然后块 o,\n然后块 8 , 依此类推, 在第 k 层的缓存中, 对这两个块的 每次引 用都会不命中, 即使这个缓存总共可以容纳 4 个块。\n程序通常是按照一系列阶段(如循环)来运行的,每个阶段访问缓存块的某个相对稳定不变的集合。例如, 一个嵌套的循环可能会 反复地访问 同一个数组的元素。这个块的集合称为这个阶段的工作 集 ( w or k ing set ) 。当 工作集的大小超过缓存的大小时, 缓存会经历容量不命 中( capacit y m iss ) 。换句话说就是, 缓存太小了, 不能处理这个工作集。\n缓存管理\n正如我们提到过的,存储器层次结构的本质是,每一层存储设备都是较低一层的缓 存。在每一层上,某种形式的逻辑必须管理缓存。这里,我们的意思是指某个东西要将缓 存划分成块, 在不同的层之间传送块 , 判定是命中还是不 命中, 并处理它们。管理缓存的逻辑可以是硬件、软件, 或是两者的结 合。\n例如, 编译器管理寄存器文件, 缓存层次结构的最高层。它决定当发生不命中时何时发射加载, 以及确定哪个寄存器来存放数据。Ll 、L2 和 L3 层的缓存完全是由内 置在缓存中的硬件逻辑来管理的。在一个有虚拟内存的系统中, DRAM 主存作为存储在磁盘上的数据块的缓存, 是由操作 系统 软件和 CP U 上的 地址翻译 硬件共同管理的。对千一个具 有像 AFS 这样的分 布式文件系统的机器来说, 本地磁盘作为缓存,它 是由运行在本地机器上的 AFS 客户端进程管理的。在大多数时候, 缓存都是自动运行的, 不需要程序采取特殊的或显式的行动。\n3. 2 存储器层次结构概念小结\n概括来说,基于缓存的存储器层次结构行之有效,是因为较慢的存储设备比较快的存 储设备更便宜, 还因 为程序倾向 千展示局部性:\n利 用时间局部 性: 由于时间局部性, 同一数据对象可能会被多次使用。一旦一个数据对象在第一 次不命中时被复制到缓存中 , 我们就会期望后面对该目标有一系列的访问命中。因为缓存比低一层的存储设备更快 , 对后面的命中的服务会比最开始的不命中快很多。 利 用空间局部性 : 块通常包 含有多个数据对象。由于空间局部性, 我们会期望后面对该块中其他对象的访问能够补偿不命中后复制该块的花费。\n现代系统中到处都使用了缓存。正如从图 6-23 中能够看到的 那样, CP U 芯片、操作系统、分布式文件系统中和万维网上都使用了缓存 。各种各样硬件和软件的组合构成和管理着缓存。注意, 图 6-23 中有大量我们还未涉及的术语和缩写。在此我们包括这些术语和缩写是为了说明 缓存是多么的普遍。\n类型 缓存什么 被缓存在何处 延迟(周期数) 由谁管理\nCPU寄存器TLB Ll 高速缓存 4节字或8字节字 地址翻译 64字节块 芯片上的CPU寄存器芯片上的TLB 芯片上的Ll 高速缓存 。 4 编译器 硬件 MMU 硬件 L2高速缓存 64字节块 芯片上的L2高速缓存 10 硬件 L3高速缓存 64字节块 芯片上的L3高速缓存 50 硬件 虚拟内存 4KB页 主存 200 硬件 + OS 缓冲区缓存 部分文件 主存 200 OS 磁盘缓存 磁盘扇区 磁盘控制器 100 000 控制器固件 网络缓存 部分文件 本地磁盘 10 000 000 NFS客户 浏览器缓存 Web页 本地磁盘 10 000 000 Web浏览器 Web缓存 Web页 远程服务器磁盘 I 000 000 000 Web代理服务器 图 6~23 缓 存 在 现 代 计 算 机 系 统 中 无 处 不 在 。 T L B : 翻译后备缓 冲器 ( T ra ns la tion Lookas ide Ruffer); MMU: 内存管理单元 ( Memory Management Unit ) ; OS: 操作系统 ( Operating System);\nAFS: 安德鲁文件系统( Andrew File System) ; NFS : 网络文件系统 ( Network File System)\n6. 4 高速缓存存储器\n早期计算机系统的存储器层次结构只有三层: CPU 寄存器、DRA M 主存储 器 和磁 盘存储。不 过, 由 千 CP U 和主存之间逐渐增大的差距, 系统设计者被迫在 CPU 寄存器文件和主存之 间插入了一个小的 SRAM 高速 缓 存存储 器, 称为 L1 高 速 缓 存(一级缓存), 如 图 6-24 所 示。L1 高速缓存的访问速度几乎和寄存器一样快,典 型 地是大约 4 个时钟周期。\nCPU芯片\n系统总线 内存总线\n总接线口 三]\n图 6- 24 高速缓存存储器的典烈总线结构\n随着 CPU 和主存之间的 性能差 距不断增大 , 系统设计者在 Ll 高速缓存和主存之间又插入了一 个更大的高速缓存, 称为 L2 高 速缓存, 可 以 在 大 约 1 0 个时钟周期内访问到它。有些现代系统还包括有一个更大的高速缓存,称 为 L3 高 速缓存, 在 存 储 器 层 次 结 构 中 , 它位于 L2 高速缓存和主存之间, 可以在大约 50 个周期内访问到它。虽然安排上有相当多的变化, 但是通用原则是一样的。对于下一节中的讨论, 我们会假设一个简单的存储器层次结构, CPU 和主存之间只有一个 Ll 高速缓存。\n6. 4. 1 通用的高速缓存存储器组织结构\n考虑一个计算机系统 ,其 中 每 个 存 储 器地址有 m 位,形 成 M = 沪 个 不 同 的 地 址 。 如\n图 6 - 25 a 所 示, 这样一个机器的高速缓存被组织成一个有 5 = 2\u0026rsquo; 个高速缓 存组( ca ch e s e t ) 的\n数组。每个组包含 E 个 高 速缓存行 ( cach e line ) 。每个行是由一个 B = Z1\u0026rsquo; 字 节的数据块( block ) 组成的,一 个 有效位 ( va lid bit ) 指明这个行是否包含有意义的信息, 还有 t = m— ( b+ s ) 个标记位( t ag bit ) ( 是 当前块的内存地址的位的一个子集), 它 们 唯 一 地标识存储在这个高速缓存行中的块。\n每行1个 每行t个有效位标记位\n每个高速缓存块有B=钞字节\n广,''\n匠言Io I I I···IB-\n组0:\n匡巠 二 1。I\nI I \u0026hellip; IB-lI }每组珩\nS=25组\n组I :\n国 口三口1。I I I \u0026hellip; IB-lI\n阿 勹压门I I 1 I···la-1I\n匡口\n组s-\nI I I \u0026hellip; IB-1I # 匣 口巨门I I I I \u0026hellip; IB-11\n高速缓存大小C=BxE xS数据字节\na)\nt位 s位 b位 地址: m-1 \u0026lsquo;y \u0026quot; y 0 八 y , 标记 组索引 块偏移 图 6- 23\nb)\n高 速 缓 存 ( S , E, B, m ) 的 通 用 组 织 。 a) 高 速 缓 存 是 一 个 高 速 缓 存 组 的 数 组 。 每 个 组 包 含一 个 或 多 个行 , 每 个 行 包 含 一 个 有 效 位 , 一 些 标 记 位 , 以 及一 个 数 据块 ; b) 高速缓存的结构将 m 个地址位 划分 成 了 t 个标记 位 、s 个 组索 引位 和 b 个块 偏移 位\n一般而言, 高速缓存的结构可以用元组 CS , E, B, m ) 来描述。高速缓存的大小(或容矗)C 指的是所有块的大小的和。标记位和有效位不包括在内。因此, C= S X E X B。\n当一条加载指令指示 CP U 从 主存地址 A 中读一个字时, 它 将地址 A 发送到高速缓存。如果高速缓存正保存着地址 A 处那个字的副本,它 就立即将那个字发回给 CPU。那么高速缓存如何知道它是否包含地址 A 处那个字的副本的呢? 高速缓存的结构使得它能通过简单地检查地址位, 找到所请求的字, 类似于使用极其简单的哈希函数的哈希表。下面介绍它是如何工作的:\n参数 S 和 B 将 m 个地址位分为了三个字段,如 图 6-25b 所示。A 中 s 个组索引位是一个到 S 个组的数组的索引。第一个组是组 0 , 第二个组是组 1 , 依此类推。组索引位被解释为一个无符号整数,它告诉我们这个字必须存储在哪个组中。一旦我们知道了这个字必 须放在哪个组中, A 中 的 t 个标记位就告诉我们这个组中的哪一行包含这个字(如果有的话)。当且仅当设置了有效位并且该行的标记位与地址 A 中的标记位相匹配时,组 中的这一 行 才包含这个字。一旦我们在由组索引标识的组中定位了由标号所标识的行, 那么 b 个块偏移位给出了在 B 个字节的数据块中的字偏移。\n你可能已经注意到了, 对 高速缓存的描述使用了很多符号。图 6- 26 对这些符号做了个小结,供你参考。\n基本参数 参数 描述 S=2\u0026rsquo; 组数 E 每个组的行数 B=2b 块大小(字节) m=log2 (M) (主存)物理地址位数 图 6-26 高速缓存参数小结\n讫; 练习题 6. 9 下表 给出 了几 个不 同的 高速 缓存的 参数。 确定 每个高 速缓存的 高 速缓存组数 CS ) 、 标记位 数 ( t ) 、 组 索引位 数( s ) 以及块偏 移位 数 ( b) 。\n高速缓存 m C B E s t s b I. 32 1024 4 I 2. 32 1024 8 4 3. 32 1024 32 32 4. 2 直接映射高速缓存\n根据 每个组的高速缓存行数 E , 高速缓存被 分为不同的类。每个组只有一行( E = l ) 的高速缓存称 为直接映射高速缓存( dir ec t- mapped cache)( 见图 6-27 ) 。直接映射高速缓存是最容易实现和理解的 , 所以我们会以 它为例来说明一些高速缓存 工作方式的通用概念。\n组0: I五邑口匡勹1 高速缓存块 I I} £=匋组 1 行\n组 I : I 匡囡口三勹二五亘亘五二=:J I\n组S- 1: I匡囡口亘口1 高速缓存 块 I I\n图 6-27 直 接映射高速缓存 ( £ = 1) 。 每个组只有一行\n假设我们有 这样一个系统, 它有一个 CPU 、一个寄存器文件、一个 Ll 高速缓存和一个主存。当 CPU 执行一条读内存字 w 的指令, 它向 Ll 高速缓存请求这个 字。如果 Ll 高速缓存有 w 的一个缓存的副本, 那么就得到 L1 高速缓存命中, 高速缓存会很快抽取出\nw, 并将它返 回给 CPU。否则就是缓存不命中, 当 Ll 高速缓存向主存请求包含 w 的块的一个副 本时, CPU 必须等待。当被请求的块最 终从内存到达时, Ll 高速缓存将这个块存放在它 的一个高速缓存行里, 从被存储的块中抽取出字 w , 然后将它返回给 CPU。高速\n缓存确定一个请求是否命中,然后抽取出被请求的字的过程.分为三步: 1) 组选择; 2)\n行匹配; 3 ) 字抽 取 。\n直接映射高速缓存 中的组选择\n在这一步中 , 高速缓存从 w 的地址中间抽取出 s 个组索引位。这些位被解释成一个对应千一个组号的无符号整数。换旬话来说, 如果我们把高速缓 存看成是一个关于组的一维数 组, 那么这些组索引位就是一个到这个数组的索引 。图 6-28 展示了直接映射高速缓存的组选\n择是如何工作的。在这个例子中, 组索引位 00001 2 被解释为一个选择组 1 的整数索引。\n组0 : I 工[] I 标记 11 高速缓存块 I I\n选择的组 , 组I : I国豆\n高速缓存块\nt位 丿s 位 b位\nI 标 记 1 1 I I\nI 0 0 0 0 I I I\nm-1\n标记 组索引 块偏移\n组S- 1: I 匡邑I 标记 II\n高速缓存块 I I\n图 6- 28 直接映射高速缓存中的组选择\n直接映射高速缓存中的 行匹配\n在上一步中我们已经选择了某个组 i\u0026rsquo; 接下来的一步就要确定是否有字 w 的一个副本存储在组 t 包含的一个高速缓存行 中。在直 接映射高速缓存 中这很容易,而 且很快,这是因为每个组只有一行。当且仅当设置了有效位, 而且高速缓存行中的标记与 w 的地址中的标记相匹配时 , 这一行中包含 w 的一个副本。\n图 6- 29 展示了直接映 射高速缓 存中行匹配是 如何工作的。在这个例子中, 选中的组中只有一个高速缓存行。这个行的有效位设置了,所以我们知道标记和块中的位是有意义 的。因为这个高速缓存行中的标记位与地址中的标记位相匹配,所以我们知道我们想要的 那个字的一个副本确实存储在这个行中。换旬话说,我们得到一个缓存命中。另一方面, 如果有效位没有设晋,或者标记不相匹配,那么我们就得到一个缓存不命中。\n选择的组 ( i ) :\nL\n=I? ( I ) 有效位必须设置\n01234567\n( 2 ) 高速缓存行中的标\n记位必须与地址中 =?\n的标记位相匹配; —\nt位 s位\nI 0110 I i\nm-1\n( 3 ) 如果 ( I ) 和 ( 2 ) 满足, 那么高速缓存命中,块偏移就选择起始字节。\n标记 组索引 块偏移\n图 6-29 直接映射高速缓存中的行匹配和字选择。在高速缓存块中, w 。表示字 w\n的低位字节, W 1 是下一个字节 ,依 此 类推\n直接映射高速缓存中的 字选择\n一旦命中, 我们知道 w 就在这个块中的 某个地方。最后一步确定所需要的字在块中是从 哪里开始的。如图 6- 29 所示, 块偏移位提供了所需要的字的第一个字节的偏移。就像我们把高速缓存看成一个行的数组一样,我们把块看成一个字节的数组,而字节偏移是到\n这个数组的一个索引 。在这个示例中, 块偏移位是 100 2\u0026rsquo; 它表明 w 的副本 是从块中的字节 4 开始的(我们假设字长为 4 字节)。\n直接映射高速缓存中不命中时的行替 换\n如果缓存不命中 , 那么它需要从存储器层次结 构中的下一层取出被请求的 块, 然后将新的块存储在组索引 位指示的组中的一个高速缓存行中。一般而言, 如果组中都是有效高速缓存行了, 那么必须要驱逐出一 个现存的行。对于直接映射高速缓存来说, 每个组只包含有一行,替 换策略非 常简单: 用 新取出的行替 换当前的行。\n综合 : 运行中的 直接映射高速缓存\n高速缓 存用来选择组 和标识行的机制极其简单, 因为硬件必须在几个纳秒的时间内完成这些 工作。不过,用 这种方式来处理位是很令人因惑 的。一个具体的例子能帮助解释清楚这个过程。假 设我们有一个直接映射高速缓存 , 描述如下\n( S , E , B , m ) = ( 4 , 1, 2 , 4 )\n换句话说, 高速缓存有 4 个组, 每个组一行, 每个块 2 个字节, 而地址是 4 位的。我们还假设每个字都是单字节的。当然, 这样一些假设完全是不现实的, 但是它们能使示例保持简单。\n当你初学高 速缓存 时, 列举出整个地址空间并划分好位是很有帮助的, 就像我们在\n图 6-3 0 对 4 位的示例所做的那样。关于这个列举出的空间, 有一些有趣的事情值得注意 :\n图 6-30 示例直接映射高速缓存的 4 位地址空间\n标记位和索引位连起来唯 一地标识了内存中的每个块。例如, 块 0 是由地址 0 和 1\n组成的, 块 1 是由地址 2 和 3 组成的, 块 2 是由地址 4 和 5 组成的, 依此类推。\n因为有 8 个内存块, 但是只有 4 个高速缓存组 , 所以多个块会映射到同一个高速缓存组(即它们有相同的组索引)。例如, 块 0 和 4 都映射到组 0 , 块 1 和 5 都映射到组 1\u0026rsquo; 等等。\n映射到同一个高速缓存组的块由标记位唯一地标识。例如, 块 0 的标记位为 o, 而\n块 4 的标记位为 1, 块 1 的标记位为 o , 而块 5 的标记位为 1, 以此类推。\n让我们来模 拟一下当 CPU 执行一系列读的时候, 高速缓存的执行情况。记住对于这\n个示例,我 们假设 CPU 读 1 字节的字。虽然这种手工的模拟很乏味,你 可能想要跳过它, 但是根据我们的经验,在学生们做过几个这样的练习之前,他们是不能真正理解高速缓存 是如何工作的。\n初始时,高 速缓存是空的(即每个有效位都是 0 ) :\n表中的每一行都代表一个高速缓存行。第一列表明该行所属的组, 但 是 请 记 住提供这个位只 是 为了方便,实 际 上 它并 不真是高速缓存的一部分。后面四列代表每个高速缓存行的实际 的 位 。 现在,让 我们来看看当 CP U 执行一系列读时,都 发 生 了 什 么 :\n读地址 0 的字。因为组 0 的有效位是 o, 是缓存不命中。高速缓存从内存(或低一\n层的高速缓存)取出块 o , 并把这个块存储在组 0 中。然后, 高速缓存返回新取出的高速缓存 行 的 块[ OJ的 m[ O] ( 内存位置 0 的内 容)。\n。 。\n读地 址 l 的字。这次会是高速缓存命中。高速缓存立即从高速缓存行的块[ 1] 中返\n回 m[ l ] 。高速缓存的状态没有变化。\n读地址 13 的字。由于组 2 中的高速缓存行不是有效的,所 以 有缓存不命中。高速缓 存 把 块 6 加载到组 2 中,然 后 从新 的 高速缓存行的块[ 1] 中返回 m[ 13] 。 读地址 8 的字。 这会发生缓存不命中。组 0 中的高速缓存行确实是有效的, 但是标 1 记不匹配。高速缓存将块 4 加 载到组 0 中(替换读地址 0 时读入的那一行), 然后从新的商速缓存行的块[ OJ中返回 m[ 8] 。\n组 有效位 标记位 块[OJ 块[ I]\nI 2 3 。I 。I I I m[8] m[l2] m[9] m[l3] 读 地 址 0 的字。又会发生缓存不命中,因 为在前面引用地址 8 时, 我们刚好替换了块 0 。 这就是冲突不命中的一个例子,也 就是我们有足够的高速缓存空间, 但 是 却交替地引 用 映 射 到 同 一 个组的块。 。 。 # 直接映射高速缓存中的 冲突不命中\n冲突不命中 在真实的程序中很常见,会导 致令人困惑的 性能问题。当程序访问大小为\n2 的幕的数组时 , 直接映射 高速缓存中通常 会发生冲突不命中。例如, 考虑一个计算两个向量点积的函数:\nfloat dotprod (fl oat x[8] , fl oat y [8 ])\n{\nf l oa t sum= 0. 0;\ninti;\nfor (i =O;i \u0026lt; 8 ; i ++ ) sum += x [ i ] * y [i] ;\nr e t ur n sum;\n对于 x 和 y 来说, 这个函数有良好的空间局部性, 因此我们期望它的命中率会比较高。不幸的是 , 并不总是如此。\n假设浮点数是 4 个字节, x 被加载到从 地址 0 开始的 32 字节连续内存中 , 而 y 紧跟在\nx 之后, 从地址 32 开始。为了简便 , 假设一个块是 16 个字节(足够容纳 4 个浮点数), 高速缓存 由两个组组成, 高速缓存的整个 大小为 32 字节。我们会假设变量 sum 实际上存放在一个 CPU 寄存器中, 因此不需要内存引用。根据这些假设每个 x [ i ] 和 y [ i ] 会映射到相同的高速缓存 组:\n在运行时, 循环的第一次迭代引用 X [ O J\u0026rsquo; 缓存不命中会导致包含 x [OJ ~x [ 3 ) 的 块被\n加载到组 0。接下来是对 y [ O ] 的引 用 , 又一次缓存不命中,导 致包含 y [ OJ ~ y [ 3 J 的 块被复制到组 o , 覆盖前一次引用复制进来的 x 的值。在下一次迭代中, 对 X (1 ) 的引用不命中,导致 x [ OJ ~ x [ 3 ] 的 块被加载回组 o, 覆盖掉 y [ OJ ~ y [ 3 ) 的块。因而现在我们就有了\n一个冲突不命中,而 且实际上后面每次 对 x 和 y 的引用都会导致冲突不 命中, 因为我们在\nx 和 y 的块之间抖动( t h ra s h ) 。术语“抖动” 描述的是这样一种情况, 即高速缓存反复地加载和驱 逐相同的高速缓存块的组。\n简要来说就是, 即使程序有良好的空间局部性 , 而且我们的高速缓存中也有 足够的空间来存放 x [ i ] 和 y [ i ] 的块, 每次引用还是会导致冲突不命 中, 这是因为这些 块被映射到了同\n一个高速缓存组。这种抖动导致速度下降 2 或 3 倍并不稀奇。另外, 还要 注意虽 然 我们的示例极其简单,但是对于更大、更现实的直接映射高速缓存来说,这个问题也是很真实的。\n幸运的是, 一 旦 程序 员 意 识 到 了 正 在 发 生 什 么 ,就 很 容易 修 正 抖 动问 题 。 一 个 很 简 单的方法 是 在 每 个 数 组 的 结 尾 放 B 字 节 的 填 充 。 例 如 , 不 是 将 x 定 义 为 fl oa t x (8), 而是定义成f l oa 七 x [12 ) 。 假 设 在 内 存 中 y 紧 跟 在 x 后 面 ,我 们有 下 面这 样的 从数组元素到组 的映 射:\n在 x 结 尾 加 了 填 充 , x [i ] 和 y [ i ] 现 在 就 映 射 到 了 不同 的 组 ,消除 了 抖 动 冲突不命中。\n已 练习题 6. 10 在前面 d o 七p r o d 的 例 子 中 , 在 我 们 对数 组 x 做 了 填 充之后, 所有对 x\n和 y 的引用 的命 中率是 多少?\nm 为什么用中间的位来做 索引\n你也许 会奇怪, 为什 么 高速 缓 存 用 中间的位 来作为 组 索 引 , 而不是 用 高 位 。 为什么 用中间的位 更好 , 是 有 很 好 的 原 因 的 。 图 6-31 说 明 了 原 因。 如 果 高 位 用做 索引, 那么. 一些连续的 内存块就会映射到相同的 高速缓 存 块 。例如 , 在 图 中, 头四 个块映射到笫 一\n个高速 缓 存 组 , 笫 二 个四个块映射到笫二 个组, 依 此 类推。如 果一 个程序 有良好的 空间局 部 性 , 顺 序 扫 描 一 个数组的元素, 那么在 任 何 时 刻 , 高速 缓 存 都 只 保 存 着一个块大小i\n高位索引 中间位索引\nI\n凶00\n凶 01 凶 JO 卯 11\n4组高速缓存\n= # 组索引位\n图 6- 3 1 为什么用中间位来作为高速缓存的索引\n的数组内容。这样对高速 缓 存 的使用效率很低。相比较 而言, 以中间位作为 索引, 相 邻的块总是 映射到不同的 高速缓存行。在这里的情况中, 高速缓存能够存 放整个大小为 C 的数 组片 , 这里 C 是 高速 缓 存的大小。\n练习题 6. 11 假想一个高 速缓存, 用 地址 的 高 s 位做 组 索 引, 那 么 内存 块连续 的 片\n( ch un k ) 会被映射到 同 一个 高速 缓存组。\n每个这样的连续的数组片中有多少个块?\n考虑下面的代码 , 它 运行在 一 个高 速 缓存 形 式 为 CS , E, B, m)=(512, 1, 32,\n的系统上 : int array[4096];\nfor (i = O; i \u0026lt; 4096; i++)\nsum += array [i] ;\n在任意时刻,存储在高速缓存中的数组块的最大数量为多少?\n4. 3 组相联高速缓存\n直接映射高速缓存中冲突不命中造成的问题源千每个组只有一行(或者,按 照 我们的术语来描述就是 E = l) 这个限制。组相联高速 缓 存( set as socia t ive cac he ) 放松了这条限制, 所以每个 组都保存有多千一个的高速缓存行。一个 l \u0026lt; E \u0026lt; C/ B 的高速缓存通常称为 E 路 组相联 高速缓存。在下一节中,我 们会讨论 E = C/ B 这种特殊情况。图 6-32 展 示了一个 2 路组相联高速缓存的结构。\n组o 1 [1 门勹尸`冒三昙I}Ea每组2竹\n组I : I围记I 标记 1 1 高速缓存块\n匝 I 标记 11 高速缓存块\n匠 I 标记 1 1\n高速缓存块\n组S- 1: I 匡琶J I 标 记 11 苞速缓存块\n图 6-32 组相联高速缓存 O \u0026lt; E\u0026lt; C/ B)。在一个组相联高速缓存中, 每个组包含多于 一个行。这里的特例是一个 2 路组相联高速缓存\n组相联高速缓存中的组选择\n它的组选择与直接映射高速缓存的组选择一样, 组索引位标识组。图 6-33 总结了这个原理。\n组相联高速缓存中的行匹配和字选择\n组相联高速缓存中的行匹配比直接映射高速缓存中的更复杂,因 为它必须检查多个行的标记 位和有效位,以 确 定 所 请 求 的 字是 否 在 集合中。传统的内存是一个值的数组,以 地址作为输 入,并 返 回 存 储 在 那 个 地 址 的 值 。 另 一 方 面, 相联存储 器是 一 个 ( k e y , va lu e ) 对的数组 , 以 k e y 为输入,返 回 与 输 入 的 key 相匹配的( ke y , value ) 对中的 valu e 值。因此, 我们可以 把组相联高速缓存中的每个组都看成一个小的相联存储器, ke y 是 标 记和有效位, 而 valu e 就是块的内容。\n选择的组\n组0:\n会 组I :\n匣口亘勹[ 匡门居勹[\n匣 口亘勹1\n高速缓存块高速缓存块\n高速缓存块\n匣口亘三]I 高速缓存块\nt位 b位\n组S- 1:\n匡 荨 I\n高速缓存块\nI\nm-I\n标记\n组索引\n匝 口压口1 高速缓存块\n组相联高速缓存中的组选择\n图 6-34 展示了相联高速缓存中行匹配的基本思想。这里的一个重要思想就是组中的任何一行都可以包含任何映射到这个组的内存块。所以高速缓存必须搜索组中的每一行, 寻找一个有效的行,其标记与地址中的标记相匹配。如果高速缓存找到了这样一行,那么 我们就命中,块 偏移从这个块中选择一个字, 和前面一样。\n=1? ( I ) 有效位必须设置\n01234567\n选择的组 ( i ) :\n( 2 ) 高速缓存行中某一行\n的标记位必须匹配地址中的标记位。\n日 I I I wo I w, Iw 2 I 叭\n( 3 ) 如果 ( 1 ) 和 ( 2 ) 为真, 那么高速缓存命中,然后块偏移选择起始字节。\nt位\nI 0110 I\nm-1\n标记\ns位\n-:­\nl b\n组索引 块偏移\n图 6-34\n组相联高速缓存中的行匹配和字选择\n3. 组相联高速缓存中不命中时的行替换\n如果 CP U 请求的字不在组的任何一行中,那 么 就 是 缓 存 不 命 中 , 高速缓存必须从内存中取出包含这个字的块。不过,一旦高速缓存取出了这个块,该替换哪个行呢?当然, 如果有一个空行,那它就是个很好的候选。但是如果该组中没有空行,那么我们必须从中 选择一个非空的行,希 望 CP U 不 会 很 快 引 用 这个被替换的行。\n程序员很难在代码中利用高速缓存替换策略,所以在此我们不会过多地讲述其细节。 最简单的替换策略是随机选择要替换的行。其他更复杂的策略利用了局部性原理,以使在 比较近的 将来引用被替 换的行的概率最小。例如, 最不 常使 用 ( Leas t-F req uently-Used, LF U ) 策略会替换在过去某个时间窗口内引用次数最少的那一行。最近最少使 用 ( Least­ Recently-Used, LRU ) 策略会替换最后一次访问时间最久远的那一行。所有这些策略都需要额 外的 时间 和 硬 件 。 但 是 ,越 往存储器层次结构下面走, 远离 CPU , 一次不命中的开销就会更加昂贵,用更好的替换策略使得不命中最少也变得更加值得了。\n6. 4. 4 全相联高速缓存\n全相联高速 缓 存( fully associative cache) 是由一个包含所有高速缓存行的组(即E = C/\nB ) 组成的。图 6-35 给出了基本结构。\n匣 仁亘 三 l 厂 高速缓存块匡 口 压口 1 高速缓存块\n组\nE =唯一的一组中有E =C/B行\n匣 口 亘 口 1 高速缓存块\n图 6- 35 全相联高速缓存( E = C/ B) 。在全相联高速缓存中, 一个组包含所有的行\n全相联高速缓存中的组选择\n全相联高速缓存中的组选择非常简单,因 为只 有一个组,图 6-36 做了 个 小 结 。 注 意地址中没有组索引位,地址只被划分成了一个标记和一个块偏移。\nI 匡荨厂一高速缓存块\nm-1\n整个高速缓存只有一个组, 所以默认总是选择组0。\nt位 b位\n标记 块偏移\n组0 :\n1 击 勹 歪 勹 1 高速缓存块匠 勹 亘口 1 高速缓存块\n图 6-36 全相联高速缓存中的组选择。注意没有组索引位\n全相联高速缓存中的行匹配和字选择\n全相联高速缓存中的行匹配和字选择与组相联高速缓存中的是一样的, 如图 6- 37 所示。它们之间的区别主要是规模大小的问题。\n=l? ( l ) 有效位必须设置\n整个高速缓存\n( 2 ) 高速缓存行中某一行的 =?\n霍昙严匹配地址中户—[—、\nt位\nl 0110\nm-1\nb位\n100\n( 3 ) 如果 ( I ) 和 ( 2 ) 满足,那么 高速缓存命中,然后块偏移选\n择起始字节。\n。I\n标记 块偏移\n图 6-37 全相联高速缓存中的行匹配和字选择\n因为高速缓存电路必须并行地搜索许多相匹配的标记,构 造一个又大又快的相联高速缓存很困难,而且很昂贵。因此,全相联高速缓存只适合做小的高速缓存,例如虚拟内存 系统中的 翻译备用缓冲器 ( T LB) , 它缓存 页表项(见9. 6. 2 节)。\n压 练习题 6. 12 下面的问题能帮助你加强理解高速缓存是如何工作的。有如下假设:\n内存是字节寻址的。 内存访 问的 是 1 字 节 的 字(不是 4 字 节的 字)。 436 笫一部分 程序结构和执行\n地址的宽度为 1 3 位 。 高速 缓存是 2 路组相联的CE = 2 ) , 块 大小为 4 字 节 ( B = 4 ) , 有 8 个 组 ( 5 = 8 ) 。高速缓存的内容如下, 所有的数 字都是以 十 六进 制来表 示的:\n2路组相联高速缓存\n行0 行1\n。 。\n。 。\n2 EB\n3 06\nOB\n32 I 12 08 78 AD\n4 C7 I 06 78 07 cs 05 I 40 67 C2 3B\n6 91 I AO B7 26 2D FO 。\n7 46 DE 1 12 co 88 37\n下面的图展 示的是地址格 式(每个小方框 一个位)。指出(在图 中标 出)用来确定下列内容的字段:\nco 高速缓存块偏移\nCI 高速缓存组索引\nCT 高速缓存标记\n12 11 10 9 8 7 6 5 4 3 2 1 0\n芦 练 习题 6 . 13 假 设一个程序运行在练 习题 6-1 2 中的机器上, 它引用地 址 Ox 0E 3 4 处的1 个 字 节的 字。 指 出 访问的高 速 缓存条 目和十 六 进 制 表 示 的 返 回的 高 速 缓 存 字 节值。指 出是否会发 生缓存不命 中。 如果会出 现缓存不命 中, 用 “ 一” 来表 示 “ 返回的高速缓存字节”。\n地址格式(每个小方框一个 位):\n12 11 10 9 8 7 6 5 4 3 2 1 0\n内存引用:\n沁囡 练习题 6 . 14 对于存储器地址 Ox ODDS, 再做一遍 练 习 题 6 . 1 3 。\n地址格式(每个小方框一个位);\n12 11 10 9 8 7 6 5 4 3 2 I 0\n内存引用:\n参数 值 高速缓存块偏移 ( CO ) Ox 高速缓存组索引CC I) Ox 高速缓存标记 ( C T ) Ox 高速缓存命中? (是I否) 返回的高速缓存字节 Ox 区 练习题 6. 15 对于内存 地址 Ox 1 F E4 , 再做 一遍练 习题 6. 13。\n地址格式(每个小方框一个位): 12 11\n仁\n内存引用:\n10 9 8 7 6 5 .\nI I I I I I\n3 2 I\n[\n亿 练习题 6. 16\n制内存地址。\n对于练习题 6. 1 2 中的 高速 缓存 , 列 出 所 有的 在 组 3 中会命 中的 十 六进\n4. 5 有关写的问题\n正如我们看到的, 高速缓存关于读的操作非常简单。首先, 在高速缓存中查找所需字\nw 的副本 。如果命中, 立即返 回字 w 给 CPU。如果 不命中,从 存储器层次结构中较低层中取出包含字 w 的块, 将这个块 存储到某个高速缓存行中(可能会驱逐一个有效的行), 然后返回字 w 。\n写的情况就要复杂一些了。假设我们要写一个已经缓 存了的字 w ( 写命 中, w r it e hit ) 。在高速缓存 更新了它的 w 的副本之后, 怎么更新 w 在层次 结构中紧接着低一层中的副本呢? 最简单的 方法, 称为直写 ( w rit e- t h ro ug h ) , 就是立即将 w 的高速缓存块写回到紧接着的低一层中。虽然简单,但是直写的缺点是每次写都会引起总线流批。另一种方法,称为\n写回 ( writ e- back ) , 尽可能地推迟更新,只有当替换算法要驱逐这个更新过的块时,才把\n它写到紧接着的低一层中。由于局部性,写回能显著地减少总线流量,但是它的缺点是增 加了复 杂性。高速缓存必须为每个高速缓存行维护一个额外的修改位 ( d ir t y bit), 表明这个高速 缓存块是否被修改过。\n另一个问 题是如何处理写不命中。一种方法, 称为写分配( w rite-a llocat e ) , 加载相应的低一层中的块到高速缓存中,然后更新这个高速缓存块。写分配试图利用写的空间局部 性,但是缺点是每次不命中都会导致一个块从低一层传送到高速缓存。另一种方法,称为 非写 分配( not- w r ite-a lloca te ) , 避开高速缓存, 直接把这个字写到低一层中。直写高速缓存通常 是非写分配的。写回高速缓存通常是写分配的。\n为写操作优 化高速缓存是一个细致而困难的问题, 在此我们只略讲皮毛。细节随系统的不同而 不同, 而且通常 是私有的, 文档记录不详细。对于试图 编写高速缓存比较友好的\n程序的程序员来说,我们建议在心里采用一个使用写回和写分配的高速缓存的模型。这样建议有几个原因。通常,由千较长的传送时间,存储器层次结构中较低层的缓存更可能使用写回, 而不是直写。例如, 虚拟内存系统(用主存作为存储在 磁盘上的 块的缓存)只使用写回。但是由于逻辑电路密度的提高,写回的高复杂性也越来越不成为阻碍了,我们在现代系统的所有层次上都能看到写回缓存。所以这种假设符合当前的趋势。假设使用写回写分配方法的另一个原因是,它与处理读的方式相对称,因为写回写分配试图利用局部性, 因此,我们可以在高层次上开发我们的程序,展示良好的空间和时间局部性,而不是试图为某一个存储器系统进行优化。\n6. 4. 6 一个真实的高速缓存层次结构的解剖\n到目前为止,我们一直假设高速缓存只保存程序数据。不过,实际上,高速缓存既保 存数据, 也保存指令。只保存指令的高速缓存称为 i-ca che 。只保存程序数据的高速缓存称为 d-ca che。既保存指令又包括数 据的高速缓存称为统 一的 高速缓存( unified cache ) 。现代处理器包括独立的 i- cache 和 cl- ca che 。这样做有很多原因。有两个独立 的高速缓存, 处理器能够同时读一个指令字和一个数据字。i-cache 通常是只 读的, 因此比较简单。通常会针对不同的访问模式来优化这两个高速缓存,它们可以有不同的块大小,相联度和容量。使 用不同的高速缓存也确保了数据访问不会与指令访问形成冲突不命中,反过来也是一样, 代价就是可能会引起容量不命中增加。\n图 6-38 给出了 Intel Core i7 处理器的高速缓存层次结构。每个 CPU 芯片有四个核。每个核有自己私有的 L l i-cac he 、L l cl- cache 和 L2 统一的高速缓存。所有的核共享片上L3 统一的高速缓存。这个层次结构的一个有趣的特性是所有的 SRAM 高速缓存存储器都 在 CP U 芯片上。\n处理器封装\nL3统一的高速缓存\n- \u0026lsquo;-. - - - - - _-\u0026rsquo;\u0026ndash; - - - - - -气产尸产 ) J :\n图 6- 38 Intel Core i7 的高速缓 存层次结构\n图 6-39 总结了 Core i7 高速缓存的 基本特性。\n高速缓存类型 访问时间(周期) 高速缓存大小 CC) 相联度 ( £ ) 块大小 CB ) 组数 ( S ) LI i-cache 4 32KB 8 648 64 LI d-cache 4 32KB 8 64B 64 L2统一的高速缓存 10 256KB 8 64B 512 L3统一的高速缓存 40-75 8MB 16 64B 8192 6. 4. 7\n图 6-39 Core i7 高速缓存层次结构的特性\n高速缓存参数的性能影响\n有许多指标来衡量高速缓存的性能:\n不命 中率 ( m is s r a t e ) 。在一 个程序执行或程序的一部分执行期间,内 存引用不命中的比率。它是这样计算的: 不命 中数 量/引 用数 量。 命中率( h it ra t e ) 。命 中的内存引用比率。它等于 1 一不命 中率。\n命中时间 ( h it t im e ) 。从高速缓存传送一个字到 C P U 所需的时间, 包括组选择、行确认和字选择的时 间。对于 L1 高速缓存来说,命 中时间的数量级是几个时钟周期。\n不命 中处罚 ( m is s p e n al t y ) 。由于不命中所需要的额外的时间。L1 不命中需要从 L2 得到服务的处罚 ,通常 是数 10 个周期 ;从 L3 得到服 务的处罚, 50 个周期;,从 主存得到的服务的处罚 , 200 个周期。\n优化高速缓存的成本和性能的折中是一项很精细的工作, 它需要在现实的基准程序代码上进行大量的模拟, 因此超出了我们讨论的范酣。不过, 还是可以认识一些定 性的折 中考量的。\n高速缓存大小的影响\n一方面,较大的高速缓存可能会提高命中率。另一方面,使大存储器运行得更快总是 要难一些的 。结果, 较大的高速缓存可能会增加命中时间。这解释了为什么 L1 高速缓存比 L2 高速缓存小 ,以 及为什么 L2 高速缓存比 L3 高速缓存小 。\n块大小的 影响\n大的块有利有弊。一方面,较大的块能利用程序中可能存在的空间局部性,帮助提高 命中率。 不过, 对于给定的高速缓存 大小, 块越大就 意味着高速缓存行数越少,这 会损害时间局部性比空间局部性更好的程序中的命中率。较大的块对不命中处罚也有负面影响, 因为块越大 , 传送时间就越长。现代系统(如C o r e i7 ) 会折中使高速缓存块包含 64 个字节。\n相联度的 影响\n这里的问 题是参数 E 选择的影响 , E 是每个组中高速缓存行数。较高的相联度(也就是 E 的值较大)的优点是降低了高速缓存 由于冲突不 命中出现抖动的可能性。不过, 较高的相联 度会造成 较高的成本。较高的相联度实现起来很昂 贵, 而且很 难使之速度变快。每一行需要更多的标 记位, 每一行需 要额外的 LRU 状态位和额外的控制逻辑。较高的相联度会增加命中时间,因为复杂性增加了,另外,还会增加不命中处罚,因为选择牺牲行的 复杂性 也增加了。\n相联度的选择最终变成了命中时间 和不命中处罚 之间的折中。传统上, 努力争取时钟频率的高性能系统会 为 L1 高速缓存选择较低的相联度(这里的不命中处罚 只是几 个周期), 而在不 命中处罚比较高的较低层上使用比较小的相联度。例 如, I n t e l Core i7 系统中, L1 和 L2 高速缓存 是 8 路组相联的, 而 L3 高速缓存是 16 路组相联的。\n写策略的 影响\n直写高 速缓存比较容易实现, 而且能使用独立于高速缓存的写缓冲 区 ( w r it e buffer),\n用来更 新内存。此外, 读不命中开销没这么大, 因为它们不会触发内存写。另一方面,写\n回高速缓存引起的传送比较少,它允 许更多的到内存的带宽用于执行 OMA 的 1/0 设备。此外 , 越往层次结构下面走 , 传送时间增加, 减少传送的数量就变得更 加重要。一般而言, 高速缓存越往下 层,越 可能使用写回而不是直写。\n日 日 高速缓存行、组和块有什么区别?\n很容易混淆高速缓存行、组和块之间的区别。让我们来回顾一下这些概念,确 保概念清晰:\n块是一个固定大小的信息 包, 在高速缓存和主存(或下一层高速缓存)之间来回传送。 行是高速 缓存中的一个容 器,存 储块以及其他信 息(例如 有效位和标记位)。\n组是一个或 多 个行的集合 。直接映 射高速 缓存中的组只由一行组成。组相联和全相联高速缓存中的 组是由多 个行组成的。\n在直接映射高速缓 存中, 组和行实际上 是等价的 。不过 , 在相联高速缓存中, 组和行是很不一 样的, 这两个词 不能互换 使用。\n因为一 行总是存储一个块 , 术语“行” 和“块“ 通常互换 使用。例如, 系统专 家总是说高速缓 存的“行大小", 实际上 他们指 的是块大小。这样的 用 法十分普遍,只要你理解块和行 之间的 区别 , 它不会 造成任何误会。\n5 编写高速缓存友好的代码\n在 6. 2 节中, 我们介绍了局部性的思想, 而且定性地谈了一下什么会具有良好的局部性。明白了高速缓存存储器是如何工作的,我们就能更加准确一些了。局部性比较好的程 序更容易有较低的不命中率,而不命中率较低的程序往往比不命中率较高的程序运行得更 快。因此,从 具 有良好局部性的意义上来说 , 好的程序员 总是应该试着去编写高速缓存友 好( ca c he fr ie nd ly ) 的代码。下面就是我们用来确保代码高速缓存友好的基本方法 。\n让最常见的情况运 行得快。程序通常把大部分时间都花在少僵的核心函数上,而这些函数通常把大部分时间都 花在了 少量循环上。所以要把注意力集中在核心函数里的循环上, 而忽略其他部分。\n2 ) 尽量减 小每 个循 环 内部 的缓存不命 中数量。在其他条件(例如加载和存储的总次\n数)相同的情况下, 不命中率较低的循环运行得更快。\n为了看看实际上这是怎么工作的 , 考虑 6. 2 节中的函数 s umv e c :\nint sumvec(int v[N])\n{\ninti, sum= O;\nfor (i = 0; i \u0026lt; N; i ++) sum+= v[i];\nreturn sum;\n这个函数高速缓存友好吗? 首先, 注意对 于局部变量 1 和 s um, 循环体有良好的时间局部性。实际上,因为它们都是局部变量,任何合理的优化编译器都会把它们缓存在寄存器文 件中, 也就是存储器层次结 构的最高层中。现在考虑一下对向量 v 的步长为 1 的引用。一般而言, 如果一个高速缓存的块大小为 B 字节, 那么一个步长为 K 的引用模式(这里 k 是以字为单位的)平均每次循环迭代 会有 m in ( 1 , (wordsize X k) / B) 次缓存不命中。当 k = l 时 ,它 取最小值, 所以对 v 的步长为 1 的引用确实是高速缓存友好的。例如,假 设 v 是块对齐的, 字为 4 个字节, 高速缓存块为 4 个字,而 高速缓存初始为空(冷高速缓存)。然\n后,无 论 是 什 么 样的高速缓存结构,对 v 的 引 用 都 会得到下面的命中和不命中模式:\nv [ i l i=O i=I i=2 i=3 i=4 i=S i=6 i=7\n1 访问顺序, 命中[h]或不命中 [m] I/ I [ml I 2 [h] I 3 (h] I 4 [h] I 5 [m) I 6 [h] I 7 [h] 8 [h]\n在这个例子中,对 v [ O] 的 引 用 会 不命中, 而相应的包含 v [ O] ~ v [ 3 ] 的 块 会 被从内存加载到 高速缓存中。因此, 接下来三个引用都会命中。对 v [ 4 ] 的引 用会导致不命中, 而一个新 的块被加载到高速缓存中, 接下来的三个引用都命中,依 此类推。总的来说, 四 个引用中, 三个会命中,在 这种冷缓存的情况下, 这是我们所能做到的最好的情况了。\n总之,简 单 的 s u mv e c 示例说明了两个关千编写高速缓存友好的代码的重要问题:\n对 局 部 变量的反复引用是好的,因 为编译器能够将它们缓存在寄存器文件中(时间局部性)。\n步长为 1 的引用模式是好的,因 为存储器层次结构中所有层次上的缓存都是将数据存储为连续的块(空间局部性)。\n在对多维数组进行操作的程序中, 空间局 部性尤其重要。例如, 考 虑 6. 2 节 中 的\ns umar r a y r o ws 函 数 ,它 按 照 行 优 先 顺 序对一个二维数组的元素求 和:\nint s umarr a yr o 甘 s (int a[M] [NJ)\n{\ninti, j, sum= O;\nfor (i = 0; i \u0026lt; M; i ++)\nfor (j = O; j \u0026lt; N; j++) sum += a[i] [j];\nreturn sum;\n由于 C 语言以行优先顺序存储数组,所 以 这 个函数中的内循环有与 s u rnv e c 一样好的步长为 1 的访问 模式 。例如,假 设 我们对这个高速缓存做与对 s urnv e c 一 样的假设。那么对数组 a 的引用会得到下面的命中和不命中模式:\na [ i J f j J J=O\nJ = I\n)=2\n)=3\n}=4\nj=5\nj=6\nj=7\n但是如果我们做一个看似无伤大雅的改变—— 交换循环的次序,看 看 会 发生 什么 :\nint sumarraycols(int a[M] [N])\n{\ninti, j, sum= O;\nfor (j = 0; j \u0026lt; N; j++)\nfor (i = 0; i \u0026lt; M; i ++) sum += a [i] [j] ;\nreturn sum;\n在这种情况中, 我们是一列一列而不是一行一行地扫描数组的。如果我们够幸运, 整 个数组都在 高速缓存中,那 么 我们也会有相同的不命中率 1/ 4。不过, 如果数组比高速缓存要\n大(更可能出现这种情况), 那 么 每 次 对 a [il [j l 的 访 问 都 会 不 命 中!\na [il [j l )=0 J=I j=2 J=3 J = 4 j=5 J=6 j = 7 i = 0 I [m) 5 [m) 9[m] 13 [m] 17 [m) 21 (m] 25 (m] 29 (m) i = I 2 [m) 6 (m) IO[m) 141m) 18 [m) 22 [m) 26(m] 30 fm] i=2 3 [m) 7[m] II [m] 15 [m) 19[m] 23 [m) 27 [m) 31 (m] i= 3 4[m] 8 [m) 12 [m) 16 [ml 20 [m] 24 [m) 28 [m] 32 [m) 较 高 的不 命 中 率 对 运 行 时 间 可 以 有 显 著 的 影 响 。 例 如 , 在 桌 面 机 器 上 , s u mar r a y­ r o ws 运 行 速 度 比 s u marr a y c o l s 快 25 倍。总之 , 程 序 员 应 该 注 意 他 们 程 序 中 的 局部性, 试着编写利用局部性的程序。\n; 练习题 6. 17 在信号处理和科学计算的应用中,转置矩阵的行和列是一个很重要的问题。从局部性的角 度 来 看, 它 也 很 有 趣, 因 为 它 的 引 用 模 式 既 是 以行 为 主 ( ro w­ w is e ) 的, 也 是以 列 为 主( co l u m n- w is e ) 的。例如, 考虑下面的转暨 函数:\ntypedef int array[2] [2];\n3 void transposel(array dst, array src)\n4 {\ninti, j;\n7 for (i = O; i \u0026lt; 2; i++) {\n8 for (j = O; j \u0026lt; 2; j++) {\n9 dst [j] [i] = src[i] [j] ; 10 }\n}\n12 }\n假设 在一 台具 有如下属性的机器上运行这段代码 :\nsizeof (int) ==4。 sr c 数组从地址 0 开始 , d s t 数组从地址 1 6 ( 十进制)开始。 只有一个 L1 数据高速缓存, 它是 直接映射的、直写和 写分 配的, 块 大小为 8 个字节。 这个高 速 缓存总的大小 为 1 6 个 数据 字 节 , 一开始是 空 的。 对 sr c 和 d s t 数组 的访问 分别是读和 写 不命 中的唯 一来 源。 对每个r o w 和 c o l , 指明 对 s r c [ row] [col ) 和 d s t [row] [col ] 的 访问是命中( h)\n还是 不命 中( m ) 。 例如, 读 s r c [OJ [ OJ 会 不命 中, 写 d s t [ O J [ OJ 也不命 中。\nd s t 数组\n列0 列 l\nm 0行\n1行\n对于一个大小 为 32 数据 字 节的高速缓存重 复这 个练 习。 sr c数组\n列0 列l\nm\n沁 员 练习题 6. 18 最 近 一 个很 成 功 的 游 戏 S im A q ua rium 的 核心就是 一 个 紧 密 循 环 ( tight loo p ) , 它计算 256 个 海藻( algae ) 的平均位置。 在一 台具 有块大小为 16 字 节 ( B = 1 6 ) 、整个大小为 10 24 字 节的直接映射数据缓存的机器 上测量它的高速缓存性能。 定 义如下:\nstruct algae_position { 2 int x; 3 int y; 4 } ; struct algae_position grid[16] [16]; int total_x = 0, total_y = O; inti, j;\n还有如下假设:\nsizeof ( i n t ) ==4 。\ng r i d 从内存地 址 0 开始。\n这个高速缓存开始时是空的。\n唯一的内存 访问是 对数 组 gr i d 的元素的 访问。放在寄存器中。\n确定下面代码的高速缓存性能:\nfor (i = O; i \u0026lt; 16; i++) {\nfor (j = 0; j \u0026lt; 16; j++) { total_x += grid[i] [j] .x;\n变量 i 、 j 、total x和七0 七a l y 存\n}\nfor (i = O; i \u0026lt; 16; i++) {\nfor (j = O; j \u0026lt; 16; j++) { total_y += grid[i] [j] .y;\n10 }\n11 }\n读总数是多少?\n缓存不命中的读总数是多少?\n不命中率是多少?\n饬 练习题 6. 19 给定 练 习题 6. 18 的假设, 确定下列代码的高速缓存性能:\nfor (i = O; i \u0026lt; 16; i++){\nfor (j = 0; j \u0026lt; 16; j++) { total_x +=grid[j] [i] .x; total_y +=grid[j] [i] .y;\n}\n}\n读总数是多少?\n高速缓存不命中的读总数是多少?\n不命中率是多少?\n如果高速缓存有两倍大,那么不命中率会是多少呢?\n讫§ 练习题 6. 20 给定 练 习题 6. 18 的假 设, 确定 下列代码 的高 速缓存 性能 :\nfor (i = O; i \u0026lt; 16; i++){\nfor (j = O; j \u0026lt; 16; j++) { total_x +=grid[i] [j] .x; totaLy +=grid[i] [j] .y;\n}\n}\n读总数是多少?\n高速缓存不命中的读总数是多少?\n不命中率是多少?\n如果高速缓存有两倍大,那么不命中率会是多少呢?\n6. 6 综合:高速缓存对程序性能的影响\n本节通过研究高速缓存对运行在实际机器上的程序的性能影响,综合了我们对存储器层次结构的讨论。\n6. 6. 1 存储器山\n一个程序从存储系统中读数据的速率称为读吞吐 量( r e a d throughput), 或者有时称为读带宽( r e a d b a n d w id t h ) 。如果一个程序在 s 秒的时间段内读 n 个字节,那 么 这段时间内的读吞吐量就等于 n / s , 通常以兆字节每秒( M B / s ) 为单位。\n如果我们要编写一个程序,它从 一个紧密程序循环( t ig h t program loop) 中发出一系列读请求 ,那 么测 量出 的读 吞 吐 量能让我们看到对于这个读序列来说的存储系统的性能。图 6-40\ncode/mem/mountainlmountain.c\nlong data[MAXELEMS];\n2\nI* The global array we\u0026rsquo;ll be traversing *f\nI* test - Iterate over first \u0026ldquo;elems\u0026rdquo; elements of array \u0026ldquo;data\u0026rdquo; with\n* stride of \u0026ldquo;stride\u0026rdquo;, using 4 x 4 loop unrolling. s *I\n6 int test (int elems, int stride)\n7 {\nlong i, sx2 = stride*2, sx3 = stride*3, sx4 = stride*4;\nlong accO = 0, accl = 0, acc2 = 0, acc3 = O; 1o long length = el ems;\n11 long limit = length - sx4; 12\n/* Combine 4 elements at a time *I\nfor (i = O; i \u0026lt; 1i 工 t ; i += sx4) {\naccO = accO + data [i] ;\naccl = acc1 + data[i+stride];\nacc2 = acc2 + data[i +sx2] ;\nacc3 = acc3 + data[i +sx3] ; 19 }\n20\nI* Finish any remaining elements *I\nfor (; i \u0026lt; length; i+=stride) {\naccO = accO + data [i] ; 24 }\n25 return ((accO + accl) + (acc2 + acc3)); 26 }\n27\n28 I* run - Run test(elems, stride) andreturn read throughput (MB/s). \u0026ldquo;size\u0026rdquo; is in bytes, \u0026ldquo;stride\u0026rdquo; is in array elements, and Mhz is CPU clock frequency in Mhz.\n32 double run (int size, int stride, double Mhz) 33 {\ndouble cycles; int elerns = size / sizeof (double) ; 36 test (elerns, stride); I* Warm up the cache *I cycles = fcyc2(test, elerns, stride, 0); I* Call test(elerns,stride) *I return (size / stride) / (cycles / Mhz); I* Convert cycles to MB/s *I 40 } code/memlmountain/mountain.c\n图 6-40 测量和计 算读吞吐量的函数。我们可以通过以不同的 s i ze ( 对应千时间局部性)和\ns tr i d e ( 对应于空间局部性)的值来调用r un 函数, 产生某台计算机的存储器山\n给出了一对测量某个读序列读吞吐量的函数。\nt e s t 函数通过以 步长 s tr i de 扫描一个数组的头 e l e ms 个元素来产生读序列。为了提高内 循环中 可用 的并行性, 使用了 4 X 4 展开(见5. 9 节)。r un 函数是一个包装函数, 调用 t e 江 函数, 并返回测最出的读吞吐量。第 37 行对 t e s t 函数的词用会对高速缓存做暖身。第 38 行的 f c yc 2 函数以参数 e l e ms 调用 t e s t 函数, 并估计 t e s t 函数的运行时间,以 CP U 周期为单位。注意,r un 函数的参数 s i ze 是以字节为单 位的, 而 t e s t 函数对应的参数 e l e ms 是以数组元素为单位的。另外 , 注意 第 39 行将 MB/ s 计算为 1矿字节/秒, 而不是 220 字节/秒。\nr un 函数的参数 s i ze 和 s tr i de 允许我们控制产生出的读序列的时间和空间局部性程度。 s i ze 的值越小, 得到的工作集越小 , 因此时间局部性越好。s tr i de 的值越小, 得到的空间 局部性越好。如果我们反复以 不同的 s i ze 和 s tr i de 值调用r un 函数, 那么我们就能 得到一个读带 宽的时间和空间 局部性的二维函数, 称为存储器山 ( memor y moun­ tain )[ ll 2] 。\n每个计算 机都有表明它存储器系统的能力特色的唯一的存储器山。例如, 图 6- 41 展示了 Intel Core i7 系统的存储器山。在这个例子中, s i ze 从 16KB 变到 128K B, stride 从 1 变到 1 2 个元素,每 个元素是一个 8 个字节的 l o ng i nt 。\n空间局部性\n的斜坡\n16000\n4000\n2000\nCore i7 Haswell\n2.1 GHz\n32 KB LI 高速缓存\n256 KB L2高速缓存\n8MB L3高速缓存\n64B块大小\n时间局部性\n山脊\n3 1.28K\n512K\n2M\n盓\n128M\n大小(字节)\n图 6- 41 存储器山。展示了读吞吐量, 它是时间 和空间局部性的函数\n这座 Core i7 山的地形地势展现了一个很丰富 的结构。垂直千大小轴的是四条山脊, 分别对应 千工作 集完 全在 Ll 高速缓存、LZ 高速缓存、L3 高速缓存和主存 内的时间局部性区域。注意 , Ll 山脊的最高点(那里 CP U 读速率为 14GB / s ) 与主存山脊的最 低点(那里\nCPU 读速率 为 900M B/ s) 之间的差别有一个 数量级。\n在 LZ、 L3 和主存山脊上,随 着步长的增加, 有一个空间局部性的斜坡, 空间局部性下降。注意,即使当工作集太大,不能全都装进任何一个高速缓存时,主存山脊的最高点 也比它的最低点高 8 倍。因此,即 使是当程序的时间局部性很差时, 空间局部性仍然能补救,并且是非常重要的。\n有一条特别有趣的平 坦的山脊线, 对于步长 1 垂直于步长轴, 此时读吞吐輩相对保持不变, 为 1 2GB/ s , 即使工作集超出了 L1 和 L2 的大小。这显然是由 于 Core i7 存储器系统中的硬件预取( prefe tch ing ) 机制, 它 会自动 地识别顺序的、步长为 1 的引用模式, 试图在一些块被访问之前, 将它们取到高 速缓存中。虽然文档里没有记录这种预取算法的 细节, 但是从存储器山可以明显池看到这个算法对小步长效果最好 这也是代码中要使用步长 为 1 的顺序访问的另一个理由。\n如果我们从这座山 中取出一个片段, 保持步长为常数, 如图 6-42 所示, 我们就能很 清楚地看到高速缓存的大小和时间局部性对性能的影响了。大小最大为 32KB 的工作集完全能放进 Ll cl-c ache 中, 因此, 读都是由 L1 来服务的, 吞吐最保持在峰值 12GB/ s 处。大小最大为 256KB 的工作集完全能放进统一的 L2 高速缓存中 , 对千大小 最大为 8 M , 工作集完全 能放进统一的 L3 高速缓存中。更大的工作集大小 主要由主存来 服务。\n14 000\n12 000\n10 000\n乏芦 8000\n、\n主存区域 L3高速缓存区域\nLI高速\nL2高速缓存区域 缓存区域\ni 6000\n4000\n2000\n0 .\n工作集大小(字节)\n图 6-42 存储 器山中时间局部性的山脊。这幅图展示了图 6-41 中 s 七r i cte = S 时 的 一 个 片段\nL2 和 L3 高速缓存区域最左边的边缘上读吞吐量的下降很有趣, 此时工作集大小为256K B 和 8 MB, 等于对应的高速缓 存的大小。为什么会出现这样的下降, 还不是完全清楚 。要确认的唯一方法就是执行一个详细的高速缓存模 拟, 但是这些下降很有可能是与其他数据和代码行的冲突造成的。\n以相反的方向横切这座山,保持工作集大小不变,我们从中能看到空间局部性对读吞吐量 的影响。例如, 图 6-43 展示了丁作集大小固定为 4MB 时的片段。这个片段是沿着图 6- 41 中的L3 山脊切的,这里, 工作集完全能够放到 L3 高速缓存中, 但是对 L2 高速缓存来说太大了。\n注意随着步长从 1 个字增长到 8 个字, 读吞吐量是如何平稳地下降的。在山的这个区域中, L2 中的读不命中会导致一个块从 L3 传送到 L2。后 面在 L2 中这个块上会有一定数量的命中, 这是取决千步长的 。随着步长的增加, L 2 不命中与 L2 命中的比值也增加了。因为服务不命中要比命中更慢, 所以读吞 吐量也下降了。一旦步长达到了 8 个字, 在这个系统上就等于块的 大小 64 个字节了, 每个读请求在 L2 中都会不命中, 必 须从 L3 服务。\n因此, 对于至少为 8 个字的步长来说, 读吞吐最是一个常数速率, 是由从 L3 传送高速缓存块到 L2 的速率决定的。\n12 000\n10 000\n8000\n6000\n4000\n2000\ns1 s2 s3 s4 s5 s6 s7 s8\n步长 ( X 8字节)\ns9 s10 s11\n图 6-43 一个空间局部性的斜坡。这幅图展示了图 6-41 中大小= 4MB 时的一个片段\n总结一下我们对存储器山的讨论,存储器系统的性能不是一个数字就能描述的。相 反,它是一座时间和空间局部性的山,这座山的上升高度差别可以超过一个数量级。明智 的程序员会试图构造他们的程序,使得程序运行在山峰而不是低谷。目标就是利用时间局 部性, 使得频繁使用的 字从 L1 中取出, 还要 利用空间局部性, 使得尽可能多的字从一个L1 高速缓存行 中访问到。\n讫 练习题 6. 21 利用 图 6-41 中的存储器 山来估计从 L1 d- ca c h e 中读 一个 8 字 节的 字所需要的 时间(以 CPU 周期为 单位)。\n6. 2. 重新排列循环以提高空间局部性\n考虑一对 n X n 矩阵相乘的问题: C= AB 。例如, 如果 n = 2 , 那么\n[ C11C 12 ] = [ au a12][加b 12 ] C21 c22 a 21a22 b21 b22\n其中\nc11 =a11b11 +a12b21\nC1z = a 11 如 + a12 b22\nc21 = a 21 b11 +a22 b21 C22 = a 21 如 + a 22b 22\n矩阵乘法 函数通常是用 3 个嵌套的循环来实现的,分别 用 索引 z、 1 和K 来标识。如果改变循环的次 序, 对代码进行一些其他的小改动, 我们就能 得到矩阵乘法的 6 个在功能上等价的版本 , 如图 6-44 所示。每个版本都以它循环的顺 序来唯一地标识。\n在高层次来看, 这 6 个版本是非常相似的。如果加 法是可结 合的, 那么每个版本计算出的结果完全 一样气 每个版本总共都执行 O ( n3 ) 个操作, 而加法和乘法的数量相同。A\n8 正如我们在第 2 章中学到的 ,浮点 加法是可交换的 , 但是通常是 不可结 合的。实际 上,如果 矩阵不 把极大的数和极小的数混在一起一存储物理属性的矩阵常常这样,那么假设浮点加法是可结合的也是合理的。\n和B 的 矿个 元素中的每一个都要读 n 次;计 算 C 的 示 个 元素中的 每一个都要对 n 个值求和。不过,如果分析最里层循环迭代的行为,我们发现在访问数量和局部性上还是有区别的。为了分析,我们做了如下假设: - - ,.三\n每个数组都是一个 do ub l e 类型的 n X n 的数组, s i z e o f (d o ub l e ) = B。\n只 有一个高速缓存,其 块大小为 32 字节( B = 32 ) 。 ',\n数组大小 n 很大, 以至于矩阵的 一行都不能完全装进 Ll 高速缓存中。\n·编译器将局部变量存储到寄存器中,因此循环内对局部变量的引用不需要任何加载\n或存储指令\ncodelm.emlmatmult/mm.c for (i = 0; i \u0026lt;::i;i; i++)\nfor (j = 0; j \u0026lt; n; j ++)\nsum= 0.0;\nfor (k = 0; k \u0026lt; n ;, k++)\nsum += A[i] [kl*B[k] [j];\nfor (j for\n, \u0026lsquo;Cl-\ncode/memlmatmultlmm.c\n== O; j \u0026lt; \u0026rsquo;n; · j ++)\n(i a;= 0 ; i \u0026lt; n ; i ++) {\nsum = 0., , ,,,\n, f or , (k = 0; k \u0026lt; n; k++)\n\u0026rsquo; ··· · s um ,f = A [i] [k] *B [k] [j];\nC [i] [j] += suin;\n; ·; . ,.\nC[il[j] += sum;\n·\u0026rsquo;·_;, • h ide/ me成lmatmultlmm.,c - _\ncode/mem/matmultlmm.c\n. • _ a) ij k版 本 、. . , ` 一 ,.. ,:._.,· . _,.. ,,• • _b) j ik版本 , . . .\n, • \u0026rsquo; : : .,·\n;,, : ., \u0026lsquo;、 . . . ..屯: ..,. .\n• .,.. ,,·. ,• , _, , ,. · I \u0026lsquo;· . \u0026hellip;_-\n., \u0026rsquo; , . , r· .. ..· ,\u0026rsquo;•_,\ncode/mem/matmultlinm.c code/mem/matm it\u0026rsquo;Wmm.c\nC .\u0026rsquo; , . \u0026rsquo; . . . . • ·,: · \u0026rsquo; . .•. · , .· , ·, ., . , . . .. , \u0026rsquo; . \u0026rsquo;\u0026rsquo; , j l \u0026rsquo; •.- ., , , , .、.•··. .. ·.\u0026rsquo;\u0026rsquo; t.,r. •.· · ·\u0026rsquo;\u0026rsquo; · 千·二J- ,\n,\u0026gt; '\n, · , : f or · ( j \u0026rsquo; ==· O; j \u0026lt; Ii; j +\u0026rsquo;+) , \u0026rsquo; ··• f \u0026rsquo; fo r \u0026rsquo; \u0026rsquo; Ck •,;; O; \u0026lsquo;.k \u0026lsquo;\u0026lt; ri;\u0026rsquo; k ++) ·····-\n2 ;· \u0026lsquo;.\u0026lt; \u0026lt; .\u0026rsquo; fo r ( k: =, \u0026lsquo;O; k \u0026lt; ri.; k ++) { i- ;•. · , :_• ; :2 ·, ,, ;;, _\u0026lsquo;f or \\ f; ,,;_ o ;\u0026rsquo; j \u0026lt;: n ;;-;j ++f { \u0026lsquo;一,心 t 1\n3 . .. _,·.\n._ · .: r.\n:;,- J3[k]{jJ,; ., -:.\n:i .. ·,. ;,.· '\n) 3 ;; ; , i \u0026lsquo;. ) • :• r . r= _B[k} .f jJ ,;: \u0026gt;\u0026rsquo;::i ) -1\u0026rsquo; ; ;., ;:,: . :fi 心\n4 for (i = O; i \u0026lt; n; i++) 4 for (i =;o, O; _, i\n社吵 .,\n, 、 . . ! ..! 5 , C[i] [j] += A[i] [k]*r; . . 5 C i)(jJ += A[i] [k]*r;\n/ _.,:\u0026ndash; · ·, \u0026rsquo; }_ :-, :• . _ ; . :, -,-: . ,, · ; : , . i : - .·,.. ;. 6 \u0026rsquo; \\_: ·\u0026rsquo;·: j:\u0026rsquo; f: : \u0026rsquo; ,; ·\u0026rsquo;.,\u0026rsquo;. .:·\ncodelmem/matmult/mm.c\n, , \u0026rsquo; 飞 ;` ' ', 、\n.令\n钉\nc) j ld版本\ncodelmem/matmultlmm.c\nd ) 炉版本\ncode/mem/matmultlmm.c\n,; . ;\u0026rsquo;, .. . . . , \\\n, \u0026rsquo; , . . . . ··, ,,,•\n\u0026lsquo;产 i ,\u0026rsquo; ,\n.,.\nfor (k = O; k \u0026lt; Ii; k++,) · -, : . ..\n· · ·\nf or \u0026rsquo; Ci -\u0026rsquo; \u0026lsquo;= b;、.i\n\u0026lt; ri;· i ++) \u0026lsquo;. . , `\nfor (i = O; i \u0026lt; n; i++) { i ,\nr = A (i] [k] ; , • ,\nfor (j = O; j \u0026lt; n; j++) C[i] [j] += r*B[k] [j];\n}\ncodelmemlmatmultlmm.c\ne ) 的版本\nf o 士 ( k = O; k \u0026lt; n; k++) { r = A[i][k];\nfor (j = O; j \u0026lt; n; j++)\nC [i] [j] += A [i] [k] *r; rt d t\n心:• \u0026lt; }\ncode/mem/matmultlmm.c\nf) ikj版本\n图 6- 44 矩阵乘法的六个版本。每:个 版本都以它循环的顺序来唯一地标识\n, _,. ,: _;. . \u0026lsquo;! '\n_ ; , , . , .. .\n.~, -\n户:·, 占\u0026rsquo;\n_: l .:: f. ·. \u0026lsquo;..,-: ·:..-,;:- \u0026hellip; :·: . . ·-\u0026quot;: ; ·,, _ _.,i\n.,.\u0026gt;.._,.,.,,·;\u0026rsquo;··七,; ;一.,,., /,:'}·;; 言获\n图 6;,45总结了我们对内循环的分析结果。注意6个版本成对地形成了 3个等价类,\u0026rsquo;;._用内循环中访问的矩阵对来表示每个类。例如. 版本 ij k 和ji k 是类f.-1$的成员,: 因为它们在最内层的循环中引用的是矩阵A和I?(而不是C),。对千每个类,我们统计了每个内循环\n迭代中加载(读)和存储()写的数量,每次循环迭代中对A、B 和C的引用在高速缓存中丕\n命中的数量,以及每次迭代缓存不命中的总数。\n* 类 AB 例程的内循环(图t 44a 和图 6— b)以步长1 扫描数组 A 的- 行汗 因为每个高\n速缓存块保存四个 8 字节的字,A 的不命中率是每次迭代不 命中 0·.-25 次。另 一方 面,内\n循环以步长 n 扫描数组B 的一列。因 为 n 很大,每次对数组 B 的访问都会不命中,所以每次迭代总共会有 1 心 5 次不命中。\n矩阵乘法版本\n\u0026lsquo;, • (类);··,.\n匕 、 \u0026rsquo; . '\nijk \u0026amp;jik (AB)\n``\n丿kt\u0026amp; kji (AC)\nkij \u0026amp; ikj(BC) 图 6-45\n每次迭代\n加载次数 存储次数 A未命中次数 B未命中次数 C未命中次数 未命中总次数\n0.25 1.00 0.00 1.25\n1.00 0.00 LOO 2.00\n0.00 0.25 0.25 0.50\n矩阵乘法内循环的分析。6 个版本分为 3 个等价类,用 内循 环中访问 的数组对来 表示\n类 AC 例程的内循环(图6-44c 和图5 _:4 4d ) 有、一些问题。每次迭代执行两个加载和一个存储(相对千类AB例 程, 它们执行 2 个 加载而没有存储)。内循环以步长n 扫描A 和C 的列。名结 果是每次加载都会不命中 ,所 以每次迭代总共有两个不命中。注意 , 与类AB例程\n相比,交换循环降低了空间局部 性。\nBC 例程(图6 44e 和图 5:_44f)展示了一个很有趣的折中: 使用了两个加载和一个存储 ,\n它们比 AB 例程多需要一个内存操作。另一方 面, 因为内循环以步长为 1 的访问模式按行\n扫描B和c ·, 每 次迭代每个数组上的不命中率只有0 : 25 次不命中,所 以每次迭代总 共有\no·:so个不命中。\n图 6 4 6 小结了一个Cotei7 系统上矩阵乘法各个版本的性能 。这个图画出了测量出的每次内循环迭代所需 的CPU周期数作为数组大小( n) 的函数。、\n100\n睾\n; \u0026lsquo;. \\·: : , :· : \\ : .\n子\n太\n,.\u0026rsquo;\n50 100 150 200 250 300 350 400 450 500 550 600 650 7 的\n! 飞 .,.、·,:.:;-:\u0026rsquo;\n数组大小( n ) ..\n图 6- 46 Core· 17 矩阵乘法性能\n_·: .-,_\n;_ , · :\n对千这幅图有很多有意思的地方值得注意:\n对于大的 n 值, 即使每个版本都执行相同数量的浮点算术操作 , 最快的版本比最慢 、 的 版本运行得快几乎 40 俨口。\n. : . .\n每次迭代内存引用和不命中数量都相同的一对版本,有大致相同的测最性能。\n) • 内 存行为最糟糕的两个版本,就每次迭代 的访问 数量和不命中数量 而言,明 显地比\n其他4个 版本运行得慢 , 其他 4 个版本有较少的不命中 次数或者较少的 访问次数, 或者兼而有之。\n, • 在这个情况中 、, 与 内 存访问总数相比, 不命中率是一个更好的性能预测指标。例\n如,即 使 类 BC 例 程( 2 个 加 载和 1 个存储)在内循环中比类 AB 例程( 2 个加载)执行更多的内存引用,类 BC 例程(每次迭代有 0. 5 个不命中)比类AB 例程(每次迭代有\n1. 25 个不命中)性能还是要好很多。\n对于大的 n 值,最 快 的 一对版本( ki j 和心 )的性能保持不变。虽然这个数组远大于任何 SR A M 高速缓存存储器, 但 预 取 硬件足够聪明,能 够 认 出 步 长为 1 的访问模式 ,而 且速度足够快能够跟上内循环中的内存访问。这是设计这个内存系统的 Intel 的 工 程师所做的一项极好成就,向 程 序 员 提 供了甚至更多的鼓励,鼓 励 他们开发出具有良好空间局部性的程序。\nLi 使用分块来提高时间局部性\n有一项很有趣的技术, 称为 分 块( blocking ) , 它可以提高内循环的时间局部性。分块的大致思想是将一 个程 序 中的数 据结构组织 成的 大的 片 ( ch unk ) , 称 为 块 C blo ck) 。\n(在这个上下文中,“块” 指的是一个应 用级 的数据组块, 而 不是 高速 缓 存块。)这样构造程序,使 得 能够将一个片加 栽到 Ll 高 速 缓 存中, 并在这个片 中进行 所需的所有的读和写, 然后 丢掉这个片 ,加 栽下一 个片 ,依 此类推 。\n与为提 高空 间局部性所做 的简单循环变换 不同 , 分块使得代码更难阅读和理解。由于这个原因 , 它最适合 于优 化编译 器或 者频繁执行的库函数。由于 Core i7 有完善 的预取硬 件, 分块不会 提高矩阵 乘在 Core i7 上的性能。不过, 学习和理解这项技 术还是很有趣的, 因为它是 一个通用的 概念, 可以在一些没有 预取的 系统 上获得极大的性能收益。\n6. 3 在程序中利用局部性\n正如我们看到的,存储系统被组织成一个存储设备的层次结构,较小、较快的设备靠近顶部,较大、较慢的设备靠近底部。由千采用了这种层次结构,程序访问存储位置的实际速率不是一个数字能描述的。相反,它是一个变化很大的程序局部性的函数(我们称之为存储器山),变化可以有几个数量级。有良好局部性的程序从快速的高速缓存存储器中访问它的大部分数据。局部性差的程序从相对慢速的 DRA M 主存中访问它的大部分数据。\n理解存储器层次结构本质的程序员能够利用这些知识编写出更有效的程序,无论具体 的存储系统结构是怎样的。特别地, 我们推荐下列技术:\n将你的注意力集中在内循环上,大部分计算和内存访问都发生在这里。 通过按照数据对象存储在内存中的顺序、以步长为 1 的来读数据,从 而使得你程序中的空间局部性最大。\n一旦从存储器中读入了一个数据对象, 就 尽 可 能 多 地 使 用 它 ,从 而 使 得 程序中的时间局部性最大。\n6. 7 小结\n基本存储技术包括随机存储器 ( RAM) 、非易失性存储器( ROM) 和磁盘。 RAM 有两种基本类型。 静态 RAM(SRAM) 快一些, 但是也贵一些, 它既可以用做 CPU 芯片上的高速缓存 , 也可以用做芯片下的高速缓存。动态 RAM( DRAM) 慢一点,也便宜一些, 用做主存和图形帧缓 冲区。即使是在关电的时候 ,\nROM 也能保持它们的信息, 可以用来存储固件 。旋转磁盘是机械的非易失性存储设备 , 以每个位很低的成本保存大量的数据,但是其访问时间比 DRAM 长得多 。固态硬盘( SSD) 基丁非易失性的闪存 , 对某些应用来说,越来越成为旋转磁盘的具有吸引力的替代产品。\n一般而言, 较快的存储技术每个位会更贵 , 而且容量更小。这些技术的价格和性能属性正在以显 著\n厅-\n笫 6 章 存储器层次结构 451\n不同的速度变化着 。特别地, DRAM 和磁盘访问时间 远远大于 CPU 周期时间。系统 通过将存储器组织成存储设备的层次结构来弥补这些差异,在这个层次结构中,较小、较快的设备在顶部,较大、较慢的设备在底部。因为编写良好的程序有好的局部性,大多数数据都可以从较高层得到服务,结果就是存储系统能以较高层的速度运行,但却有较低层的成本和容社。\n程序员可以通过编写有良好空间和时间局部性的程序来显著地改进程序的运行时间。利用基于\nSRAM的高速缓存 存储器特别重要 。主要从高速缓存取数据的程序能 比主要从内 存取数据的程序 运行得快得多 。\n参考文献说明\n内存和磁盘技术 变化得很快 。根据我们的 经验, 最好的技术信息 来源是制造商维 护的 Web 页面。像Micron、Toshiba 和 Samsung 这样的公 司, 提供了丰富的当前有关内存设备的技术信息。Seagate 和Western D屯ital 的页 面也提供了类 似的有关 磁盘的有用信息。\n关于电路和逻辑设计的教科书提供了关于内存技术的详细信息[ 58 , 89] 。IEEE Spect rum 出版了 一系列有关 DRAM 的综述文章[ 55] 。计算机体系结构国际 会议( ISCA) 和高性能计算机体系结构 ( HPCA) 是关于 DRAM 存储性能特性的公 共论坛[ 28 , 29 , 18 ] 。\nWilkes 写了第一篇关千高速缓 存存储器的论文[ 117] 。Smith 写了一篇经典的综述[ 104] 。 Przyby lski 编写了一本 关于高速缓存设计的权威著作[ 86] 。 Hennessy 和 Patterson 提供了对高 速缓 存设计问题的全面讨论[ 46] 。Levinthal 写了一篇有关 Intel Core i7 的全面性能 指南[ 70] 。\nStricker 在[ 112] 中介绍了存储楛山的思想, 作为对存储 器系统的 全面描 述, 并且在后来的 工作描述中非正式 地提出了 术语“存储器山"。编译器研究者通过 自动执行我们 在 6. 6 节中讨论过的那些手工代码转换来增加 局部性[ 22, 32, 66. 72, 79, 87 , 119] 。Carter 和他的 同事们提出了一个高速缓存 可知晓 的内存控制器 (cac he-aware memory contro ller) [ 17] 。其他的研究 者开 发出了 高速缓存不知晓的 ( cache obliv­\nious)算法,它被设 计用来在不明确知 道底层高速 缓存存储 器结构的情况下也能运行得很好[ 30, 38, 39,\n9] 。\n关于构造和使用磁 盘存储设备也有大最的论 著。许多存储技术研究 者找寻 方法,将 单个的磁盘集合成更大、更健壮 和更安 全的存储池[ 20 , 40 , 41, 83, 121] 。其他研究 者找寻利用高速 缓存和局部性来改进磁盘 访问性能的方法[ 12, 21] 。像 Exokernel 这样的系统提供了更多的对磁盘和存储 器资 源的 用户级控制[ 57] 。像安 德鲁文件系统[ 78] 和 Coda[94] 这样的 系统, 将存储器层 次结构扩展到了计算机网络和移动笔记本电脑。 Schindler 和 Ganger 开发了一个有趣的工具 , 它能自动描述 SCSI 磁盘驱动器的构造和性能[ 95] 。研究者正 在研究构 造和使用基于闪存的 SSD的技术[ 8 , 81] 。\n家庭作业\n•• 6. 22\n假设要求你设计一个每条磁道位数固定的旋转磁盘。你知道每条磁道的位数是由最里层磁道的周 长决定的,可以假设它就是中间那个圆洞的周长。因此,如果你把磁盘中间的洞做得大一点,每 条磁道的 位数就会增大 , 但是总的 磁道数会减少 。如果用 r 来表示盘面的 半径, X • r 表示圆洞的半径, 那么 x 取什么值能使这个 磁盘的容量最大?\n估计访问 下面这个 磁盘上扇区的平均时间(以ms 为单位):\n假设一个 2MB 的文件, 由 512 个字节的逻辑块组 成,存储 在具 有下述特性的磁 盘驱动器上 :\n;\u0026rsquo;. ·:\u0026rsquo;·, ,,)\n. .\n\u0026rsquo; · \u0026rsquo; ) .. ,\n\u0026lt; -.气\u0026rsquo;- -\u0026rsquo;\n6 .\u0026lsquo;\u0026lsquo;2 5\n对于下面的 每种情况 , 假设程序顺序 地读文件的逻辑块, 一个接一个, 并且对第一个块定位读/ 写头的时间 等于 T\u0026hellip; ,.. k + T吓 g rotat,on o\n最好情况: 估计在所有可能的逻辑块到磁盘扇区的映射上读该文件所需 要的 最优时间(以ms 为单位)。\n随 机情况 :.估计如果块是随机映射到磁盘扇区上时读 该文件所需要 的时间(以ins为单位)。\n下 面的表给出了一些不同的高速缓存的 参数。对千每个 高速缓存,填 写出表中缺失的字段。记住\nm 是物理地址的 位数,C 是高速缓存大小(数据字节数), B 是以 字节为 单位的块大小, E 是相联\n、度 ,S 是高速缓存组数, t 是标记位数,s是组索引位数,而 b 是块偏移位数。、 ; \u0026lsquo;..•: : \u0026lsquo;、\n::\n} .., i;\n•\u0026rsquo; '\n.,.,.\n6 . 26 下面的表给出了一些 不同的高速缓存的 参数。你的任务是填写 出表中缺失的字段。记住 m炟物理\n地址的 位数, C 是高速缓存大小(数据字节数), B 是以字节为单位的 块大小, E 是相联度, S是高速缓存组数, t 是标记位数, s 是组索引位数,而 b 是块偏移位数。\n,\u0026rsquo;) -: ci ··.. ,:, , :\u0026lsquo;c\u0026rsquo;\n6 . 27 这个问题是关于练习题 6. 12 中的 高速缓存的。 列出 所有会在组 1 中命中的十六进制内 存地址。\n列出所有会 在组 6 中命中的十六进制内存地址 。\n•• 6.-!2 , 这 个问题是关于练习题 6. 12 中的高速缓 存的。\nA. 列 出所有会在组 2 中命中的十六进制内 存地址。\n. \u0026hellip;,, . B. 列出所有会在组 4中 命中的十六进制内存地址。 列出所有会在组 5 中命中的十六进制内存地址。\n列出所有会 在组 7 中命中的十六进 制内存地 址。\n•• 6 29 假设我们有一个具有如下属性的系统:\n内存 是字节寻址的 。 内存访问是对 1字 节字的(而不是 4 字 节字)。 地址宽 12 位。 I\u0026rsquo;\ni ;- , ; :\n/ ·..\n、,\n、 1\u0026rsquo;. •\u0026rsquo; .\u0026rsquo;.: \u0026rsquo; . . ,· ,\n高速缓存是两路组相联 的CE = 2) , 块大小为 4 字节 ( B = 4) , 有 4 个组CS = 4) 。高 速缓存的内 容如下,所有的地址 、标记和值都以十六进制表示 :' 组索引 标记 有效位 字节0 字节l\n字节2\n字节3\n00\n:.83\n.0. 0\n83\n`\n40\n- FE ,\n_44\n. .\n41 \u0026lsquo;. I .42\n97 cc\n45 46\n—\n·43\nDO 47\n\u0026lsquo;\u0026quot; ,,,.\na,\n,.,,•.·. ·\u0026rsquo;. 、,.\n' ,1 :: ·\u0026quot;,' 坎\n; -,\n; , 2\n\u0026lsquo;I _一\n40 . .48 49\n-;- 古 ~. ;\n. ,\n. 4A ., . .、... 4B J\n;; I ; II : I\n9A CO,\n,\u0026rsquo;, 03 Ff\n下面的图给出了 一个地址的格式(每个小框表示一位)。指出用来确定下列信息的字段(在图中标号出来):\nco 高速缓 存块偏移_\nCI 高速缓 存组索引~\nCT 高速缓存标记\n\u0026lsquo;.; J ;_.., . \u0026rsquo; 、 、 ;\n12 11 ·_·- lO ·· 9 8 \u0026rsquo; -\u0026lsquo;7 . • .\u0026lsquo;6 ·· -5· •· 4\n. I I -I · I I:-· ,I\u0026ndash;• I··-·\u0026quot;°I # 对于下面每个内存访问 ,当 它 们是按照列出来的顺 序执行时, 指出是高速缓存命中还是不命中。如果可以从高速缓存中的信息 推断出来 ,请 也给出读出的 值。 . ,), · '\n6. 30 假设我们有一个具有如下属性的系统: 内存是字节寻址的? 飞` \u0026rsquo; ;:: • 飞\n.• 内存访问是对 1 字节字的(而不是4 字节字)。,..c: .· •: :\n• 地址宽13 位。 ,· _; , .· .\u0026quot; ·:, •· ;- ·· ;\n高速缓存是四路组相联 的( £ = . 4) \u0026rsquo; 块大小为 4 字节( B = 4 ) , 有 8 个组 ( 5 = 8 ) 。\n考虑下 面的高速缓存 状态。所有的地址、标记和值都以十 六进制表示。每组有 4 行 ,索引 列\n包含组索引。标记列包含每 一行的标记值。V列包含每一行的有效位。字节 0 ~ 3 列包含每一行 的数据, 标号从左向右,字 节 0在 左边。\n. . . ,i\u0026rsquo;· 一,,;· \u0026lsquo;\u0026rsquo;··\u0026rsquo;\n·, . . . \u0026rsquo; . , , . \u0026lsquo;.\u0026lt;· \u0026rsquo; , · .. , . . . ., .. . ·•\n4 路组相联高速缓存\n., . .. ,.\n; , ,; \u0026lsquo;.·,, ;\n这个高速缓 存的大小 ( C ) 是多少字节? 下面的图 给出了一个地址的格式(每个小框表示一位)。指出用来确定下列信息的字段(在图中 标号出来):\nco 高速缓存块偏移\n\u0026lsquo;\u0026hellip;;- !- . . - ·\u0026rsquo; . '\n安\nCI 高速缓存组索引\nCT 高速缓存标记\n12 11 10 9 8 7 6 5 4 3 2 I 0\n** 6. 31 假设程序使用作业 6. 30 中的高速缓存,引 用位于地 址 Ox071 A 处 的 1 字节字。用 十六 进制表示出它所访间的高速缓存条目,以及返回的高速缓存字节值。指明是否发生了高速缓存不命中。如果 有高速缓存不命中,对千"返回的高速缓存字节"输人”一"。提示:注意那些有效位!\n地址格式(每个小框表示一位):\n12 11 10 9 8 7 6 5 4 3 2 1 0\n内存引用:\n•• 6 . 32 对千内存地址 Ox1 6 E8 重复作业 6. 31。\n地址格式(每个小框表示一位):\n12 11 IO 9 8 7 6 5 4 3 2 I 0\n内存引用:\n•• 6 . 33 对于作业 6. 30 中的高速 缓存 ,列 出会在组 2 中命中的 8 个内存地址(以十六进制表示)。\n•• 6. 34 考虑下面的矩阵转置函数:\ntypedef int array[4] [4];\n2\n3 void transpose2(array dst, array src)\n4 {\n5 int i, j;\n6\n7 for (i = o; i \u0026lt; 4; i ++) {\n8 for (j = O; j \u0026lt; 4; j++) {\n9 dst [j] [i] z src [i] [j];\n10 }\n11 }\n12 }\n假设这段代码运行在一台具有如下属性的机器上:\ns i ze o f (i n t ) ==4 。 数组 sr c 从地址 0 开始 , 而数组 d s t 从地址 64 开始(十进制)。 只有一个 L1 数据高速缓存 ,它是直接映射 、直写、写分配的, 块大小为 1 6 字节。\n这个高速缓 存总共有 32 个数据字节 , 初始为空 。\n对 s r c 和 d 江 数组的访问分别是读和写不命中的唯一来 源。\n对于每个r ow 和 c o l , 指明对 sr c [row] [c ol ] 和 ds t [row] [col ] 的访间是命中Ch) 还是不命 中Cm) 。例如,读 sr c [0] [ 0] 会不命中, 而写 ds t [0] [0 ]也会不命中。\nd s t 数组 sr c 数组\n列0 列1 列2 列3 列0 列1 列2 列3 行0 m 行0 m 行1 行1 行2 行2 行3 行3 .. 6. 35\n对于一个总大小为 128 数据字节的高 速缓存 ,重复 练习题 6. 34。\nd s t 数组\ns r c 数组\n列0 列l 列2 列3 列0 列l 列2 列3 行0 m 行0 m 行l 行1 行2 行2 行3 行3 •• 6. 36\n这道题测试你 预测 C 语言代码的高速缓存行 为的能力。对下 面这段代码 进行分析 :\nint X [2] [128] ;\nint i;\nint sum= O;\nfor (i,. O; i \u0026lt; 128; i++) { sum +: x [O] [i] * x [1] [i] ;\n}\n拿拿 6. 37\n假设我们在下列条件下执行这段代码:\ns i ze o f (i n t ) ==4 。 数组 x 从内存地址 OxO 开始 ,按 照行优先顺序存储 。 在下面每种情 况中, 高速缓存最开始时 都是空的 。 唯一的内存访间是对数组 x 的条目进行访问。其他所有的 变量都存储 在寄存器中。给定这些假设,估计下列情况中的不命中率; 情况 1 : 假设高速缓 存是 512 字节,直 接映射, 高速缓存 块大小 为 1 6 字节。不命中率是多少?\n情况 2 : 如果我们把高速 缓存的大小 翻倍到 1024 字节,不 命中率是多 少?\n情况 3 : 现在假设高 速缓存是 51 2 字节, 两路组相联, 使用 LRU 替换策略 , 高速缓存块 大小为\n1 6 字节。不命中率是多 少?\n对于情况 3\u0026rsquo; 更大的高速缓存 大小会帮助降 低不命中率吗?为什么能或者为什么不能?\n对于情况 3, 更大的块大小会帮 助降低不命中率吗? 为什么能或者 为什么不能?\n这道题也是测试你 分析 C 语言代码的高速缓存行 为的能力 。假设我们在下列条件下 执行图 6-47 中的 3 个求 和函数 :\ns i ze of (i n t ) ==4 。\n机器有 4K B 直接映射的高速缓存,块 大小为 16 字节。\n在两个循环中 , 代码只对数组数据进行内存访问 。循环索引 和值 sum 都存放在寄存器中 。\n数组 a 从内存地址 Ox 08000000 处开始存储。\n对于 N = 64 和 N = 60 两种情况, 在表中填写它们大概的 高速缓 存不命中率 。\n,- ,.:\n,i: _\u0026rsquo;.\u0026rsquo;;\n'\n, 、 ; . : . '\n`令,,·.·.\ntypedef int ar r\u0026rsquo;ay_t [N] [N]; \u0026rsquo; ·\u0026rsquo;· ,-.·\n·i ,\u0026rsquo; .• •.·,\u0026rsquo;\n3 , int\n4 {\n5\n6\n7\n9\nsum.A(array_t a)\nint i, j;\n.. int sum = O,·\nfor (i = O; i \u0026lt; N; i++)\nfor (j = O; j _\u0026lt; N; j++) { sum += a [i] [j] ;\n10 } ·一\n. , \u0026rsquo; '\n...1.. 1\n12\n1·3•\n14\n15\n16\n17\nreturn sum;\n} ..\n•,. (\u0026rsquo; \u0026lsquo;•\ni nt . sumB(arr.ay_t a)\n{ .\nint i,\u0026lsquo;j;\nints 山 q = O;\n, .._\n18 for (j = 0; j \u0026lt; N; j++)\n19\ni or (-i\n= -0; i : \u0026lt; N; i ++) {\\ : :•\n. ·. ,·. .: ·.\n20\n21 }\nsum += a[i] [j];\n-.-,-::\n22 return sum;\n23\n24\n25 int sU1DC(array_t a) 26 {\ninti, j; intsum= O,· J\u0026rsquo;\u0026rsquo;;\n,\n, . . . ..\n又. .、; ; i \u0026gt;\u0026rsquo;\n29 for (j = O; j \u0026lt; N; j\u0026rsquo;+=2)\u0026rsquo;\n30 for (i = O; i \u0026lt; N; i+=2) {\n31 sum:, +,;, fa [i l[ j l t,\na[i+1] [jl . . 迁\n32\n33\n3斗.,,.\n35\n,,,·,r.\n}\n}\ne t ur n ·s 11m; 1 '\n+ a,[i] [j+1] + a[i+1] [j+1]) ; ,\n,人 \u0026lt;,· \u0026rsquo; .\n气, '', '\n\u0026rsquo; ,,,\n.,'``,\n; 、 ; ; 、 . '\n,.,, 心\n, 、 \u0026lsquo;::-\n` '\n\\•. ,• 图,64- 7 · 作业5 :·37 中引 用 的函数 ;·. \u0026hellip; • \u0026hellip; 、· I ,\n6. 3.8 , ;iM决定在白纸上印黄方格 , 做成 Pos t l t 小贴纸。在打印过程中,他们需要设置方格中每个点的\nCMYK( 蓝色, 红色, 黄色, 黑色)值。3M 雇佣你判定下面算法在一个 具有2 048 字节、直接映射、\n块大小为 32 字节的数据高速缓存上的效率。,有 如下定义:\n1 struct point_colo;r:.: { .\u0026quot;: · . ·\n2 int c;\n;.\n\u0026rsquo; : \u0026ldquo;i ,·\n\u0026rsquo; \u0026rsquo; \u0026lsquo;;\u0026rsquo; ; . '\n.. . .\n_、\u0026rsquo;;I . .\n7\n-j \u0026rsquo; St 如 ct point_color _s quar e (16) (16); t ; 、-.: .: :·: i \u0026lsquo;- .嘈; 1 ;;-f· ·\u0026rsquo;. , ,: \u0026lsquo;\u0026lsquo;j 心\n心·:; :.::, :•i .·:\u0026rsquo;:·: . !\u0026rsquo; : i-; ·:\\,: : ;•\u0026rsquo;: ,-. g.\ninti, j; 有 如下 假设\n、: ,\n:-, \u0026hellip;I-; ·\u0026rsquo;\n! 、:\u0026rsquo;, ·.\u0026rsquo;, •.\u0026rsquo;, 心 俨\n,.., .\ns i zeof (i n t ) ==4。 s qu ar e 起始千内存 地址 0。 高速缓存初始为空。 -· :,;-•·.·\n..\n唯一的内存访问是对于 s q ua r e 数组中的元 素。变量 i 和]存放在寄存器中。. . .\u0026rdquo; , ·\u0026rsquo;· 『\n确定下列代码的高速缓存性能:\n\u0026lt;\u0026lsquo;T \u0026gt; . \u0026lsquo;,.\n,: •\nfor (i = O; i \u0026lt; 16; i++){\nfor ( j = O; j \u0026lt; 16; j++) { square[i] [j] .c = O;\ns quar e [ i ] [ j ] . m = O;\nsquare[i] [j] .y = 1;\nsquare[i] [j] .k = O;\n., ·• : : : • .i:\u0026rsquo;. •.\n\u0026rsquo; \u0026lsquo;,\u0026rsquo;, . \u0026lsquo;\u0026rsquo;?,\u0026rsquo;\n}\n* .\u0026rsquo;、\n, . 矗 · 、\n.,;· C, \\ I\u0026rsquo;\n,,.\u0026lsquo;\u0026lsquo;良\n: .';' .,\n写总数是多少? 在高速缓存中不命中的写总数是多少? 不命中率是多少? ·.:\n. ,.., . .\n.:,\u0026hellip;• ;! . (\n: 一 ,, .: , ,..、\n.人、飞\n6. 39 给定作业 6. 38 中的假设,确定 下列代码的 高速缓存性 能:寸\n\u0026rsquo; . ..,.,.,\u0026hellip;.. ., \u0026hellip;\n,. .\u0026rsquo; ,_; ,-;\u0026rsquo;, t·.\u0026rsquo;\nfor (i = O; i \u0026lt; 16; i++){\nfor (j = O; j \u0026lt; 16; j++) { square [j] [i] . c = 0;\nsquare[j] [i] .m = O;\n.% , . 、; , ..\nsquare CiJ [iJ _.y= 1寸\nsquare [j] [i] . k = 0;\n\u0026rsquo; .:,\n\, • . \\ I\n\u0026quot; - : ,,\u0026rsquo;,. . '\n.,.,\n:, ,, ,.\u0026rsquo;.\n',户\n:\u0026ndash;, : ·\n\u0026rsquo; (· 中 ,l \u0026lsquo;· \u0026rsquo; \u0026quot;\n,飞: , .\u0026quot; .,..\u0026rsquo;\n写总数是多少? 在高速缓存中不命 中的写总数 是多少? \u0026lsquo;\u0026gt; ! '\n. :, · ·: ! .\u0026rsquo;\n\u0026quot; ! \u0026rsquo; \u0026lsquo;. 、. .\n,:、、\n不命中率是多少?\n.. ! \u0026rsquo; ! :·.. \u0026hellip; 、 、勹\n,,, ; • : 气· 、 , .\u0026rsquo;\n.. ., . \u0026hellip;\n一、宁 ,:\n6. 40 给定作业 6. 38 中的假设,确定下列代码的 高速缓存性能 : ;,,-.,;.、: ::,\nfor \u0026rsquo; O : ,..; d;\n.\',\ni _\u0026lt;. \u0026rsquo; i 6 ; i +\u0026rsquo;+-) 1\u0026rsquo; {\u0026rsquo; :·: ·1\n- ,: : '\n,,.. ·::\u0026rsquo; ; I,\n2\n:i: . . .\u0026rsquo; 3 ,: :\u0026rsquo;::·,: ;·- \u0026rsquo; ,:\nfor (j = O; j \u0026lt; 16 ; j++) {\n, ,, squ e[iJ [j] .y, = 1,;\n} \u0026quot; .,,\u0026rsquo;:\u0026rsquo;\u0026rsquo;\n! ! !,•! \u0026hellip;.\n个_;、: ,,·.一•;\nl:\u0026rsquo;; .·\n4·\u0026rsquo;. . .,. ..\nf :, \u0026rsquo; \u0026rsquo; :, ·•·:.2 , ,, ; .·,. :·:, .,-:\n, , \u0026rsquo; '\n\u0026lsquo;,,\n·.,\n\u0026rsquo; , ·\u0026quot; , ,\u0026rsquo;\n6 ; ,. f 吐 心 : ,: =, , O/ : i , \u0026lt;; ,16 ; i,\n++) . { · ·\n\\, : :- :\u0026lt;.\u0026rsquo;·I\n:·\u0026rsquo;\n,',: ,.\n. \u0026rsquo; `、!·\u0026rsquo; ,;.._-,·\u0026rsquo;\n7 for (j = O; j \u0026lt; 16; j ++) {\nsquare [i] [j].c = 0 ; 、·!!t\nsquare[i] [j] .m = O ;\nsquare [i] [j] .k = 0 ;\n11\n;;、上\n. ,,..\n,,,\n' 、 ,. J , , :. 心 ;,\u0026quot;- j •• r , ·., '\n,; _ \u0026rsquo; .\n12\n写总数是多少? 在高速缓存中不命中的写总数是多少? (\n\u0026quot; :· . \u0026gt; , \u0026rsquo; .• 飞 .\n.-·\u0026quot; :夕 , .;\n..- _-. :. : :\n\u0026rsquo; . 一 飞,. i .•. ..\u0026rsquo;. \u0026rsquo; 飞”\n,\n.·,\nl-;:,\n不命中率是多少\u0026rsquo;!.) ' •• 6. 41\n!,飞\n你正在编写一个新的 30 游戏 , 希望能名利双收.,。现 在正在写 一个 函数} 使得在画下一帧之前先清空屏幕 缓冲区 。工 作的屏幕是 640-X 480 像 素数组 '。工 作的机器有一个 64K B 直接映 射高速缓 存,\n1:-,\n每行 4个字节; 使用下面的C语言数据结构: I · \u0026rsquo; ; \u0026rsquo; \u0026rsquo; ·. . \u0026rsquo; \u0026quot; .,. : . .. . l ·.. i _-\u0026rsquo; . ; \\;: ·\u0026rsquo;·.,:, /\u0026rsquo;.\u0026rsquo;\n\u0026rsquo; . \u0026rsquo; . ,:, \u0026quot; \\ : ; \u0026rsquo; \u0026quot; ; \u0026rsquo; ·\u0026rsquo; \u0026rsquo; ., \u0026quot; \u0026rsquo; : \u0026rsquo; ·. . /; \u0026lsquo;·. 、..,., • ,,_·. ·; ;\n, 1 ,,sfr uc, t\nP} Xe l {\n、: `仁 \u0026rsquo; :\u0026rsquo;,· 2 . .\n• 七h at:\u0026rsquo; • r; \u0026lsquo;\u0026rsquo;\u0026rsquo;、:(,\u0026ndash;\u0026rsquo;.;: :: : \u0026rsquo; \u0026rsquo; ·.\u0026rsquo;. •.. \u0026lsquo;.\u0026rsquo; 户 、 , \u0026rsquo; : . '\nI \u0026rsquo; 、\n. .. . ,\n;;:; · : .··- 3 : ,:: ;;, ch 吐 g; .-:-: '\n,,_,. :、i . 、,\u0026rsquo;、:·\u0026gt; ·:-:: :·;,: '\n.才I i;,r\nchar b; char a;\n};\nstruct pixel buffer[480] [640]; int i, j;\nchar•cptr; int•iptr;\n有如下假设:\ns i ze o f (c har ) ==l 和 s i ze c f (i n t ) ==4 。\nb u f f er 起 始 于内存地址 0。 高速缓存初始为空 。\n唯一的内存访问是对千 b u f f er 数组中元素的访问 。变昼 1 、j 、c p tr 和 i p tr 存放在寄存器中。下面代码中百分之多少的写会在高速缓存中不命中?\nfor (j = 0; j \u0026lt; 640; j ++) {\nfor (i = O; i \u0026lt; 480; i++){\nbuff er [i ] [ j] r. • O;\nbuffer[i] [j] .g• O;\nbuffer[i] [j] . b • O;\nbuffer[i) [j] .a = O;\n}\n}\n** 6. 42\n•• 6. 43\n*** 6. 44\n:: 6 45\n给定作业 6. 41 中 的 假 设 ,下 面代码中百分之多少的写会在高速缓存中不命中?\nchar•cptr = (char•) buffer;\nfor (; cptr \u0026lt; (((char *) buff er) + 640 * 480 * 4) ; cptr++)\ncptr 一 O; 给定作业 6. 41 中 的 假设 ,下 面代 码中百分之多少的写会在高速缓存中不命中?\nint *iptr z (int•)buffer;\nfor(; iptr \u0026lt; ((int•)buffer+ 640•480); iptr++)\n*iptr = 0;\n从 CS : A P P 的网站上下载 mo u n t a i n 程 序, 在你最喜欢的 PC/ L in u x 系统上运行它。根据结果估计你 系统上的高速缓存的大小。\n在这项任务中,你 会把在第 5 章和第 6 章中学习到的概念应用到一个内存使用频繁的代码的优化 问 船 上。考虑一个复制并 转置一个类型为 i n t 的 N X N 矩阵的过程。也 就是, 对 于源矩阵 S 和目的矩阵 D , 我们要将每个元素 S; ,J 复制到 d,., 。只用一个简单的循环就能实现这段代码:\nvoid transpose(int•dst, int•src, int dim)\n{\nint i, j;\nfor (is O; i \u0026lt; dim; i++)\nfor (j = O; j \u0026lt; dim; j++)\ndst [j•dim + i ] 一 s r c [i*dim + j];\n:: 6 . 46\n这里 ,过 程的参数是指向目的矩阵 ( d s t ) 和源矩阵 ( s r c ) 的指针,以 及矩阵的大小 N ( d i m) 。 你的工作是设计一个运行得尽可能快的转置函数。\n这是练习题 6. 45 的一个有趣的变体。考虑将一个有向图 g 转换成它对应的无向图 g \u0026rsquo; 。图 g \u0026rsquo; 有一条\n从 顶点 u 到顶点 v 的边,当 且仅当原图 g 中有一条 u 到 u 或者 v 到 u 的边。图 g 是由如下的它的邻接 矩阵( adjacenc y ma t rix ) G 表示的。如果 N 是 g 中顶点的数量, 那 么 G 是 一 个 N X N 的 矩阵,\n它 的 元 素是全 0 或者全 1。假设 g 的顶点是这样命名的: V o , V 1 , …, “平 1 。 那 么 如 果 有一条从 v,\n到 v,的 边,那 么 G [ i] [ 月 为 1, 否则为 0。注意, 邻 接矩阵对角线上的元素总是 1, 而无向图的邻\n接矩阵是对称的。只用一个简单的循环就能实现这段代码:\nvoid col_convert(int *G, int dim) { int i, j;\nfor (i = O; i \u0026lt; dim; i++)\nfor (j = O; j \u0026lt; dim; j++)\nG [j *dim + i] = G [j *dim + i] 11 G[ 江 di m + j];\n你的工作是设计一个运行得尽可能快的函数。同前面一样,要提出一个好的解答,你需要应用在第5 章和第 6 章中所学 到的概念。\n练习题答案\n6 1 这里的思 想是通过使 纵横比 ma x(r , c)/min(r, c)最小, 使得地址位数最小。换句话说, 数组越接近于正方形,地址位数越少。\n组织 r C b, be max(b,, b) 16X l 4 4 2 2 2 16X4 4 4 2 2 2 128X8 16 8 4 3 4 512X4 32 16 5 4 5 !024X4 32 32 5 5 5 6 2 这个小练习的主旨是确保你理解柱面和磁道之间的关系。一旦你弄明白了这个关系,那问题就很简单了:\n磁盘容量= 51 2 字节 X 400 扇 区数\nX 10 000 磁道数 X 2 表面数 X 2 盘 片数\n扇区 track\n=8 192 000 000 字 节\n=8. 192GB\n表面 盘片 磁盘\n6 3\n6 4\n6. 5\n对这个问题的解答是对磁盘访问 时间公式的直接应用 。平均旋转时间(以ms 为单位)为\nT., g ,ot,11on = 1 / 2 X T max rntallon = 1 / 2 X (60s/15 000RPM) X lOOOms/s\u0026quot;\u0026quot;\u0026quot; 2ms\n平均传送时间为\nT,vg,,,n,r«= (60s/15 000RPM) X 1 / 500 扇 区/磁 道 X l OOOms / s ,::::: 0. 008ms\n总的来说,总的预计访问时间为\nT,cms = T, vg seek + T,vg ,oi,11on + T, vg mnsfe, = 8ms + 2ms + 0. 008ms \u0026quot;\u0026quot;\u0026quot; 1 Oms\n这道题很好的检查了你对影响磁盘性能的因素的理解。首先我们需要确定这个文件和磁盘的一些基本属性。这个文件由 2000 个 512 字节的逻辑块组成。对于磁盘, T avg seek = 5 ms\u0026rsquo;Tmax rnt,t1on = 6 ms\u0026rsquo; 而 T., . \u0026lsquo;°\u0026rsquo; \u0026ldquo;\u0026rsquo; o• = 3ms 。\n最好情 况: 在好的情况中 , 块被映射到连续的扇区, 在同一柱面上 , 那样就可以一块接一块地\n读, 不用移动读/写头。一旦读/写头定位到了第一个扇区,需 要磁盘转两整圈(每圈 1000 个扇区)来读所有 2000 个块。所 以, 读这个文件的总时间为 Ta,g seek + T.,g ,oi,1;on + 2 X T max ,om;on = 5 +\n3 + 12 = 20ms 。\n随机的情况: 在这种情 况中,块 被随机地映射到扇区上 , 读 2000 块中的每一块都需 要 Tavg seek +\nT .v. , o., uon ms, 所以读这个文件的总时间为( T\u0026hellip; mk + T .,., 0 1 a,;on) X 2000 = 16 OOOmsCl 6 秒!)。 你现在可以看到为什么清理磁盘碎片是个好主意!\n这是一个简单的练习,让 你对 SSD 的可行性有一些有趣的了解。回想一下对于磁盘, l P B = 109\nMB 。 那么下面对单位的直接翻译得到了下 面的每种情 况的预测时间:\nA. 最糟糕悄况顺序写( 470 MB/ s ) : (1 09 X 128) X Cl / 470 ) X Cl/(86 400X 365) ) ,:::::8 年。\nB. 最糟糕情况随 机写( 303 MB/ s): 0 0 X.128) X (l / 303 ) .X (111/ ( 8 6· 400 X 365 )、)\n1 3年 。 心\n6. 6\nc. 平均情况( 20G B/ 天): (109 X 128) X0 / 20 000) X0 / 65) :\u0026ldquo;\u0026ldquo;1,7- 535 年。 \u0026quot;\u0026rdquo; \u0026lsquo;.\u0026rsquo;-\u0026rsquo;,:. 、,、\n所以即使 SSD 连续工作 , 也能持续至少 8 年时间, 这大于大多数计算机的 预期寿命 。\n在 2005 年到 2015 年的 10 年间,旋 转磁盘的单位价格下降 了大约, 16 6 倍,这 意味着价格大约每 18 个月下降 2 倍。假设这个趋势 一直持续 , l P B 的 存储设备 ,在2 0.15 年 花费-3.0 000 美元, 在 7 次这种 2 倍的下降之后会降到 500 美元以下。因为这种下降每 i\u0026rsquo;s 个月发生一次 , 我们 可以 预期在大约\n20 25 年, 可以用 500 美元买到 l P B 的存储设备。\n6. 7 为 了创建一个步长为1 的引用模式 ,必须改变循 环的次序 , 使得最右边的索引变化得最快 :\nint s uma 工r ay3d ( i nt a[N] [NJ [NJ)\n{\ninti, j, k, sum= O;\n'\u0026rsquo;,,.,..,,\n.\u0026rsquo; ', '\u0026rsquo;\n..\\ , \u0026lsquo;.., • ,\n心 for\n廿(k = O;\u0026rdquo; k \u0026rsquo; \u0026lt; N,:;\u0026rsquo;\u0026rsquo; k++) { \u0026rsquo; '\nfor (i = 0; i \u0026lt; N; i ++) {\nfor (j = O; j \u0026lt; N; j++) {\n\u0026rsquo; ·,,\u0026rsquo;\n,_ · · \u0026lsquo;· .. .:\n·, . 处 ; _: ,\u0026rsquo; ·\u0026rsquo; 寸i\n-- . }\n}\nS\u0026rsquo;\\llD += a [k] [i) [j] ;\n;. .、;,一:\nreturn sum;\n这是一个很重要的思想。模式。. .\n.'、,:、:-\n要保证 你理解了 为什么这种循环次序改变就能得到一个步长为 1 的访问\nfs 解 决这个问题的关键在干想象出 数组是S如何在内存中排列的,然 飞后分析引用模式。! 函 数 l e ar l 以\n步长为 1 的引用模式访问 数组, 因此明显地具有最好的空间局部性。函数 c l e a r 2 依次扫描 N 个结构中的每一个 , 这是好的,但是在每个结构中,它以 步长不为 1 的模式跳 到下列相 对于结构起始位置的偏移处: 0 、12 、4 、16 、8、20。所以 c l e a r 2 的空间局部性比 c l .e ar l 的要差。函数 c l ea r 3 不仅在每个结 构中跳来跳去, 而且还从结构跳到结构, 所以 c,l e. r.3 的空间局部性比 c l e a r 2 和 c l e ar l 都 要差。\n6. 9\n; 1\n,.,.\n6. 10\n这个解答是对图 6-26 中各种高速缓存参数定义的直接应用。不那么令人兴奋,但 是在能真正理解\n高速缓存如何工作之前, 你需要理解高速缓存的结构是如何 导致这样划分地址位的 。\u0026lsquo;r•: ·t 、\n填充消除了 冲突不 命中。因此,四分之 三的引用 是命中的。\n6 门 有时候,`理 解为什 么某种思想是不好的,能够帮助你理解为什么另一种 是好的。,( 这 里 ,我 们看到\n•.•',-\u0026rsquo;、i\n6. 12\n的 坏的想 法是用高位来索引高速缓存, 而不是用 中间的位。\nA 用高位做索引 , 每个连续的 数组片( chun灼由 2\u0026rsquo; 个块组成 ,;这 里 t 是 标记位数。因此,数组头\n2\u0026rsquo; 个连续的块都 会映射到组 o, 接下来的 2\u0026rsquo; 个块会映 射到组 1 . 依此类推o.: :\u0026rsquo;. 习\nB, 对于直接映射高速缓存( S ,.: E ,, B, •1?1! :\u0026rdquo;\u0026quot;\u0026rsquo;:( 51 2 ,、1, 3-2, \u0026lt;32h · 高速缓 存容量是 \u0026lsquo;.512 个 3 2 字节的块 ,每个高速缓存行中 有 t = l 8 个标记位。因此,数组中头 沪个块会映射到组 o, 接下来沪个块会映射到组 l 。因为我们的 数组只由 ( 409 (] X 4 ) / 32 =;c51? 个块组成,所以数组中所有的块都\n\! 被 映射到组 0。因此',在任 何时刻、,、高 速缓存 至多只能保存七个数组块,,\u0026lsquo;即使 数 组足够小,能 够完全放到高速缓 存中。i 很明显,,用高位 做 索引 不能充分利用高速缓存。` '':, 炉\n两个低位是块偏移( CO) • 然后是 3 位的组索引( CI) , 剩下的位作为标记 \u0026lt;CT ) \u0026lsquo;.- 厂 _;;,· \u0026lsquo;.\n~ 笫 6 章 存储器层次结构 461\n, ,• • I\n;\u0026rsquo;. .·, .: \u0026rsquo; : 1,2 · · 11; 10\n9. · 8 .:,7: · · 6 .. 5 : 4 : .• 3 .•.2 : ·i l 久。\n\u0026gt;.,., .,\u0026rsquo;\nlcTlcTlcT\nCT . I CT . I CT lcr I CT I c 1 」CI ·1 .C\u0026rsquo;\nI I.co I co I\n6. 13\n,;,,\n地址: Ox0E34 .\u0026rsquo;、\n地址格式(每个小格子表示一个位):\n.,户,..\n.\u0026rsquo;.,· ·\u0026rsquo; ,\n; , :;\n'., ...,;. ;;`\n, ' , -12 · 11. 10 9 8· :. 7 6 . 5 4 . . 3 2 I . 0\nI O I 1\u0026rsquo; I 1 I 1 I O 1· .0 I O·1 i - l · 1. I. o I 1 口\n:,. . ,.,.\n内存引用:\nCT. · C L CT CT CT CT CT ct C.l\nCI cr c o · c o· ·\n, : ,, :; \u0026rsquo; 、. . ·;· .•\n(; ··.:.·.\u0026rsquo;::\u0026rsquo;·;!\u0026rsquo;:/ ·.\u0026rsquo;; \u0026lsquo;:: -:.,. :.: 、: ;;.-, • •\n\u0026rsquo; ,._,\n6. 14\n:一. .. \u0026hellip; .-\n地址: Ox0DD5 一\n地址格式(每个小格子表示一个位): / ,.\n12 · , 11·. 10 ·· · 9 8 7 : 6 ·· 5\n:、·\n-4 3 2 I 0\nI O I I. , I· 1 1 · I , I 1· .l r o , · 1\nI I O I I I\n:,,;\u0026lt; :··: - ; / : \u0026ldquo;,:: .-,;;: er \u0026lt; CT :. er : CT : CJ; · CT . CT CT.•·\n\u0026hellip; . fB; 内存引用: •·.. .-..: :;-·( .\u0026rsquo;、:.\u0026rsquo;,1 : \u0026lsquo;,、::·,·\u0026rsquo;\nCI 甲\n., 俨:,,. :. ..\nCI· CO , CO\n\u0026lsquo;; ; · : ,·\n;, / ,:·;::,:r :i\\f·\n心,:,八: !,\u0026lt; 1 ; 女, · i : \u0026rsquo; ;,, ; ;:: ; ·\\\n::,:; ,\u0026rsquo;•. ::. .;. \u0026lsquo;, , .;; :\n,;\u0026rsquo;, ,; \u0026lsquo;.:\u0026rsquo;:/ ;·· , ·;\n. .- \u0026rsquo; ..\u0026ndash; . !,\u0026rsquo;,., .. . ,\n· ·\u0026rsquo;:\u0026rsquo; : , 丿\n.\n, ,., `'\n·,, . ,·\n, \u0026rsquo; \u0026rsquo; :\u0026rsquo;; \u0026rsquo; \u0026rsquo; \u0026quot; . -\nl ,_ _. . ,\n\u0026lt;· ·\u0026rsquo;\n-、i . _.\u0026rsquo;,-;\n;., -\n6. 15 地址: OxlFF4 \u0026rsquo; •. , · 人. \u0026rsquo; ; \u0026hellip;.·,,, ; ,\n. •· \u0026rsquo; ! \u0026lsquo;:\n地址格式(每个小格子表示一个位): \u0026lsquo;•. .; . ..\u0026rsquo;\n仑 ,.-. _,.\n,\n12 11 10 9 8 7 •, : •.,6 . 5\n3· /!· 2 · . ·r: \u0026rsquo; d\n. 令 .\n、:.\nI 1 I I I 1 I 1 I I I I I I I I\n::: \u0026gt;-,: : CT\u0026gt;,. c t, , C T :/ :CT, CT \u0026lt; CT ·\u0026rsquo; . er :·CT\n已\nCI·\n0 I I \u0026lsquo;I 。 1。 |\nCl ·,; •Cl • CO :\u0026rsquo;.CO ;. ;,;\n.、.,.\nB.\n.• .\nI•.,i-\u0026rsquo;.\u0026gt;, ; : : : \u0026lsquo;. : \u0026rsquo; ·,\n,- .•· \u0026gt;,\u0026rsquo; ; :\n\\ ··•,\n,\u0026rsquo;:\u0026rsquo;.,\n.. : ;\u0026rsquo;\n,, ,. .\n, \u0026ldquo;,C -\u0026rsquo;;\n: 、乒 \u0026rsquo; .,.._..,·.\u0026lt;.;-\u0026rsquo;· ,··J .\n一 声 ,\n! .-,; •:- \u0026lt;: 、 』; \u0026rsquo; •:·( ,;,\n\u0026lsquo;.\u0026rsquo;.·; _; ;\n; , ,! ; ( , , •:\n\u0026quot; 勹\u0026rsquo;.,.:. ( - ..-\n-)r·\n\u0026gt; 一 j? 气 ,\u0026rsquo;. t ,\u0026rsquo;\n\u0026lt; :,, ;•;\u0026lt; \u0026lsquo;.\nI ; !,\n6.16 这个问题是练习题6\u0026rsquo;. 12-::练习题6. 15 的 一种逆 过程,要 求你反向 工作,从高速缓存 的内 容推出\n觅:.,!.\n.•.\n会在某个组中命中的地址 。在这种情况中,组 3 包含一个有效行.,标 记为 Ox32。因 为组中只有一\n个有效行, 4 个地址会命中。这些地址的二进制形式为 ·o\u0026rsquo;on: o io611 。因此,在组 3中 命中的\n4 个十六进制地址是 : Ox06 4C、 Ox0 64D、 Ox0 64E 和 Ox0 64F。\n6 17 A. 解决这个问 题的关键是想象 出图 6-48 中的图像。注意,每个高 速缓存行只包含数组的一 个行, 高速缓存正好只够保存一个数组, 而且对于所有的 i , sr c 和 ds t 的行 t 映射到同一个高速缓存行。因为高速缓存不够大,不足以\n主存\n容纳这两个数组,所以对一个数组的 o\n引用总是驱逐出另一个数组的有用的 src { 16\n行。例如, 对 ds t (OJ (O J 写会驱逐当我 dst {\n们读 sr c [OJ [ O J 时 加载进 来的那一行。\n高速缓存\n雷\n所以,当我们 接下来读 sr c [ O J ( l J 时, 会有一个不命中。\n图 6\u0026ndash;18 练 习题 6. 17 的图\n当高速缓存为 32 字节时 , 它足够大, 能容纳这两个数组。因此,所 有的不命中都是开始时的冷不命中。\nds t 数组 sr c数组\n列0 列l 列0 列1\n行行0l\nm m\nm m I\n行0 m m\n行1 m h\nds t 数组 sr c 数组\n列0 列l 列0 列1\n行行 m h 行0 m h\nm h 行1 m h\n18 每个 16 字节的高速缓存行包含着两个 连续的 a l ga e_yos i t i on 结构。每个 循环按照内存顺序访问这些结构,每次读一个整数元索。所以,每个循环的模式就是不命中、命中、不命中、命中,依此类推。注意, 对于这个问题, 我们不 必实际列举出读和不命中的 总数, 就能预测出不命中率。\n读总数是多少? 512 个读。\n缓存不命中的读总数是多少? 256 个不命中。\nC. 不命中率是多少? 256/ 512 = 50 %。\n6 19 对这个问题的关键是注意到这个 高速缓存只能保存数组的 1 / 2。所以 ,按 照列顺序来 扫描数组的第二部分会 驱逐扫描第一部分时 加载进来的那些行。例 如, 读 gr i d [8 ) [OJ 的第一个元索会驱逐当我们读 gr i d [OJ [ OJ 的 元素时加载进来的 那一行。这一行也包含 gr i d [ OJ [1 )。所 以, 当我们开始扫描下一列时, 对 g r i d [O) [ 1 ) 第一个元素的引用会不命中 。\n读总数是多少? 512 个读。\n缓存不命中的读总数是多少? 256 个不命中。\nc. 不命中率是多少? 256/ 512 = 50 % 。\nD. 如果高速缓存有两倍大,那么不命中率会是多少呢?如果高速缓存有现在的两倍大,那么它能够保存整个 g r 沁 数组。所有的不命中都 会是开始时的 冷不命中 , 而不命中率会是 1/ 4 = 25% 。\n20 这个循环有很好的步长 为 1 的引用模式 , 因此所有的不命中都是最开始时的 冷不命中。\n读总数是多少? 512 个读。\n缓存不命中的读总数是多少? 128 个不命中。\nc. 不命中率是多少? 128 / 512 = 25 % 。\nD. 如果高速缓存 有两倍大, 那么不命中率会是多少呢?无论高速缓 存的大小 增加多少, 都不会改变不命中率,因为冷不命中是不可避免的。\n6 21 从 Ll 的吞吐晕峰值是大约 12 OOOMB/ s , 时钟频率是 2100 MH z, 而每次读访问都是以 8 字节 l ong 类型为单位的 。所以,从 这张图中我们 可以估计出在这台机器 上从 Ll 访间一个字需要大约 2100/ 12 OOOX8=1. 4::::::1. 5 周期' 比正常访问 口 的延迟 4 周期快大约 2. 5 倍o 这是由于 4 X 4 的循环展开得到的并行允 许同时进行多个加载操作 。\n"},{"id":443,"href":"/zh/docs/technology/System/%E6%B7%B1%E5%85%A5%E7%90%86%E8%A7%A3%E8%AE%A1%E7%AE%97%E6%9C%BA%E7%B3%BB%E7%BB%9F/%E4%B9%A6%E7%B1%8D/%E7%AC%AC7%E7%AB%A0-%E9%93%BE%E6%8E%A5/","title":"Index","section":"SpringCloud","content":"第 7 章\n· · · 0 · H A . · P T · .E R 7\n链接\n链接Clink ing ) 是将各种代码和数据片段收集并 组合成 为一个单一文件的过程, 这个文件可被加栽(复制)到内存并执行。链接可以 执行于编译 时( com pile time), 也就是在源代码被翻译成 机器代码时; 也可以执行千加 载 时 ( loa d time) , 也就是在程序被加栽 器( lo ad­ er ) 加载到内存并 执行时; 甚至执行 于运行 时( ru n time), 也就是由应用程序来执行。在早期的计算机系统中, 链接是手 动执行的。在现代系统中, 链接是由叫做链接器 Clinker ) 的程序自动 执行的 。\n链接器在软 件开发中扮演着一个关键的角色, 因为它们使得分 离 编译( separa te com­ pila t io n ) 成为可能。我们不用将一个 大型的应用程序组织为一个巨大的源文 件, 而是可以把它分解为更小、更好管理的模块,可以独立地修改和编译这些模块。当我们改变这些模 块中的一个时,只需简单地重新编译它,并重新链接应用,而不必重新编译其他文件。\n链接通常是由链接器来默默地处理的,对于那些在编程入门课堂上构造小程序的学生而言,链接不是一个重要的议题。那为什么还要这么麻烦地学习关于链接的知识呢?\n理解链接器将帮助你构造大型程序。构造大型程序的程序员经常会遇到由千缺少模块、缺少库或者不兼容的库版本引起的链接器错误。除非你理解链接器是如何解析引用、什么是库以及链接器是如何使用库来解析引用的,否则这类错误将令你感到迷惑和挫败。 理解链接器将帮助你避免 一些危 险的编程错误。Lin ux 链接器解析符号引用时所做的决定可以不动声色地影响你程序的正确性。在默认情况下, 错误地定义多个全局变量的 程序将通过链 接器, 而不产生任何警告信息。由此得到的程序会产生令人迷惑的运行时行为,而且非常难以调试。我们将向你展示这是如何发生的,以及该如 何避免它。\n理解链接 将帮助你理 解语言的作 用域规则是 如何实现的。例如, 全局和局部 变量之间的 区别 是什么?当你定义一个具有 s 七a t i c 属性的变量或者函数时, 实际到底意味着什么?\n理解链接将帮助你理解其他重要的系统概念。链接器产生的可执行目标文件在重要的系 统功能中扮演着关键角色, 比如加载和运行程序、虚拟内存、分页、内存映射。\n理解链接 将使你能够利 用共 享库。 多年以来, 链接都被认 为是相当简单和无趣的。然而,随着共享库和动态链接在现代操作系统中重要性的日益加强,链接成为一个 复杂的过 程, 为掌握它的程序员 提供了强大的能力。比如, 许多软件产品在运行时使用共享库来升级压缩包装的 ( s h r ink- w ra pped ) 二进制程序。 还有 , 大多数 Web 服 ` 务器都依赖于共享库的动态链接来提供动态内容。\n这一章提供了关于链接各方面的全面讨论, 从传统静态链接到加载时的共享库的动态链接,以及到运行 时的共享库的动态链接。我们将使用实际示例来描 述基本的 机制, 而且指出链接问题在哪些情况中会影响程序的 性能和正确性。为了使描述具体和便千理解 ,我们的讨论是基千这样的环境: 一个运行 Linux 的 x86-64 系统, 使用标准的 ELF-64 (此后称为 ELF)\n目标文件格式。不过,无 论是什么样的操作系统、ISA 或者目标文件格式, 基本的链接概 念是通用的,认识 到这一点是很重要的。细节可能不尽相同 , 但是概念是相同的。\n1 编译器驱动程序\n考虑图 7-1 中的 C 语言程序。它将 作为贯穿本章的一个小的运行示例, 帮助我们说明关千链接是 如何工作的一些重要知识 点。\ncode/link/main.c int sum(int *a, int n);\nint array [2] = {1, 2};\nint main()\n{\nint val= sum(array, 2);\nreturn val;\ncode/link/sum.c int sum(int *a, int n)\n{\ninti, s = O;\nfor (i = O; i \u0026lt; n; i++) {\ns += a[i]\n}\nreturns;\n}\ncode/linmka/in.c\nmain. c b) sum. c code/link/sum.c\n图 7-1\n示例程序 1。这个示 例程序由 两个源文件组成 , ma i n. c 和 s um. c 。 ma i n 函数初始化一个整数数组, 然后调用 s um 函数来对数组元素求 和\n大多数编译 系统提供编译 器驱 动程序 ( co mpile r driver), 它代表用户在需 要时调用语\n言预处理器、编译器、汇编器和链接器。\nma i n . c\ns um . c 源文件\n比如 ,要用 G NU 编译系统构造示例程序,\n我们就要 通过在 s hell 中输入下列命令来调用 G CC 驱动程序:\nl_inux\u0026gt; gee -Og -o prog mai n . e sum. e\n图 7-2 概括了驱动程序在 将示例程序从\n翻译器\n(epp, eel, as)\nma1.n.o\n!\n翻译器\n(epp, eel, as)\n可重定位目标文件\nASCII 码源文件翻译成可执行目标文件时的行为。(如果你想看看这些 步骤,用 - v 选项来运行 GC C。)驱动程序首先 运行 C 预处理器 ( c p p )e , 它将 C 的源程序 ma i n . c 翻译成一个 AS CII 码的中间 文件 ma i n . i :\n图 7- 2\n链接器 ( l d )\nprlog 完全链接的\n可执行目标文件\n静态链接。链接器将可重定位目标文件组合起来, 形成一个可执行目标 文件 pr og\ncpp [other arguments] main. c /tmp/main. i\n接下来, 驱动程序运行 C 编译器( e el ) ,\n件 ma i n . s :\n它将 ma i n . 工 翻译成一个 AS C II 汇编语言文\ncc1 /tmp/main. i -Dg [other arguments] -o /tmp/main.s\n然后, 驱动程序运行 汇编器( a s ) , eatable object file) main. o:\n它将 ma i n . s 翻译 成一个可重定位目 标文件( re lo-\nas [other arguments] -o /tmp/main.o /tmp/main.s\n8 在某些 GCC 版本中,预 处 理 器 被 集 成 到 编译 器驱动程序中。\n驱动程序经过相同的过程生成 s um. o 。 最后,它 运行链接器程序 l d , 将 ma i n . a 和s um. o 以及一些 必要的系统目标文件组合起来, 创建一个可执行目标 文件 ( e xec uta ble ob­ ject file)prog:\nld -o prog [system objectfiles and args] / t mp/ ma i n . o /tmp/sum. o\n要运行 可执行 文件 pr og , 我们在 Lin ux s hell 的命令行上输入它的名 字:\nlinux\u0026gt; ./prog\nshell调用操作系统中一个叫做加载 器 ( load er ) 的 函数, 它将可执行文件 pr og 中的代码和数据复制到内存 , 然后 将控制转 移到这个程序的开头。\n2 静态链接\n像 L in ux LD 程序这样 的静态链接 器( s tat ic lin ker ) 以一组可重定位目标文件和命令行参数作为输入,生成一个完全链接的、可以加载和运行的可执行目标文件作为输出。输入 的可重定位目标 文件由 各种不同的 代码和数据节( s ect ion ) 组成, 每一节都是一个 连续的字节序列。指令在一节中,初始化了的全局变储在另一节中,而未初始化的变量又在另外一 节中。\n为了构造可执行文件,链接器必须完成两个主要任务:\n符号解析 ( s ym bol resolut io n ) 。目标文件定义和引用符号, 每个符号对应于一个函数、一个全局变量或一个静态 变量(即 C 语言中任何以 s t a t i c 属性声明的变量)。符号解析的目的是将每个符号引用正好和一个符号定义关联起来。\n重定位( re loca tion ) 。编译器和汇编器生成从 地址 0 开始的代码和数据节。链接器通过把每个符号定义与一个内存位置关联起来,从而重定位这些节,然后修改所有对 这些符号的引用,使 得它们指向这个内 存位置。链接器使用汇编器产生的重定位条目 ( relocat ion e nt r y) 的详细指令, 不加甄别 地执行这样的重定 位。\n接下来的章节将更加详细地描述这些任务。在你阅读的时候,要记住关千链接器的一些基本事实 :目 标文件纯粹是字节块的集 合。这些块中, 有些包含程序代码, 有些包含程序数据,而其他的则包含引导链接器和加载器的数据结构。链接器将这些块连接起来,确定被连接块的运行时位置,并且修改代码和数据块中的各种位置。链接器对目标机器了解 甚少。产生目标文件的编译器和汇编器已经完成了大部分工作。\n3 目标文件\n目标文件有三种形式:\n可重定位 目标 文件。包含二进制代码 和数据, 其形式可以在编译时与其他可重定位目 标文件合并 起来, 创建一个可执行目标 文件。\n可执行目标 文件。包含二进制代码和数据,其形式 可以被直接复制到内存并执行。\n共享目标 文件。一种特殊类型的可 重定位目标文件, 可以在加载或者运行时被动态地加载进内存并 链接。\n编译牉和汇编骈生成可重定位目标文件(包括共享目标文件)。链接器生成可执行目标文件。从技术上来说, 一个目标 模块 ( object mod ule ) 就是一个字节序列, 而一个目标 文件Cob­ ject file) 就是一个以文件形式存放在磁盘中的目标模块。不过, 我们会互换地使用这些术语。\n目标文件是按照特定的目标文件格式来组织的,各个系统的目标文件格式都不相同。\n从贝尔实 验室诞生的第一个 U nix 系统使用的是 a . ou t 格式(直到今天, 可执行文件仍然称为 a . o 江 文 件)。Windo ws 使用 可移植 可执行 ( Por table Executable, PE ) 格式。Mac os-x 使 用 Mach-0 格式。现代 x86-64 Lin ux 和 U nix 系统使用可执 行 可链接格式( E xec ut ­ able and Linkable Format, ELF)。尽管我们的讨论集中在 E LF 上,但 是 不 管是 哪 种格式,\n基本的概念是相似的。\n4 可重定位目标文件 # 图 7-3 展示了一个典型的 ELF 可重定位目标文件的格式。E LF 头 ( E LF header ) 以一个 16 字节的序列开始, 这个序列描述了生成该文件\n的系统的字的大小和字节顺序。E L F 头剩下的部分包含帮助链接器语法分析和解释目标文件的信息。其 中包括 ELF 头的大小、目标文件的类型(如可重定位、可 执行或者共享的)、机器类型(如x86-64 ) 、 节\n头部表 ( sect io n header table ) 的 文件 偏 移 ,以 及 节头\n部表中条目的大小和数量。不同节的位置和大小是由节头部表描述的,其中目标文件中每个节都有一个固定大小的条目 ( en t r y ) 。\n夹在 E LF 头和节头部表之间的都是节。一个典型的 E LF 可重定位目标文件包含下 面儿个节:\n.text: 巳编译程序的机器代码。\n节\n描述目标文件的节{\n.rodata: 只读数据, 比 如 p r i n t f 语 句 中 的 格 图 7-3 典型的 ELF 可重定位目标文件\n式串和开关语句的跳转表。\n.data: 已初始化的全局和静态 C 变量。局部 C 变扯在运行时被保存在栈中,既 不 出现在 . da 七a 节 中 , 也不 出 现 在 . b s s 节中 。 bss: 未初始化的全局和静态 C 变量,以 及 所有被初始化为 0 的全局或静态变量。在目标文件中这个节不占据实际的空间, 它仅仅是一个占位符。目标文件格式区分已初始化 和未初始化变量是为了空间效率:在目标文件中,未初始化变量不需要占据任何实际的磁 盘空间 。运行时, 在内存中分配这些变量, 初始值为 0。\n.symtab: 一个符号表,它 存 放在程序中定义和引用的函数和全局变批的信息。一些程序员 错误地认为必须通过 - g 选项来编译一个程序, 才能得到符号表信息。实际上, 每个可重定 位目标文件在 . s ymt a b 中 都 有 一 张符号表(除非程序员特意用 ST R IP 命令去掉它)。然而, 和编译器中的符号表不同,. s ymt a b 符 号 表 不包含局部变量的条目。\n.rel.text: 一个.te江节中位置的列表, 当链接器把这个目标文件和其他文件组合时,需要修改这些位置。一般而言, 任何调用外部函数或者引用全局变篮的指令都需要修改。另 一方面,调 用 本 地函数的指令则不需要修改。注意, 可执行目标文件中并不需要重定位信息,因此通常省略,除非用户显式地指示链接器包含这些信息。\n.rel.data: 被模块引用或定义的所有全局变最的重定位信息。一般而言, 任何已初始化的 全局变扯, 如果 它 的 初始值是一个全局变量地址或者外部定义函数的地址,都 需 要被修改。\n.debug: 一个调试符号表,其 条 目 是 程序中定义的局部变量和类型定义, 程 序 中 定义和引 用的全局变扯,以 及 原 始 的 C 源文件。只有以 - g 选项调用编译器驱动程序时, 才\n会得到这张表。\n.line: 原始 C 源程序中的行号和 . t e x t 节中机器指令之间的映射。只有以- g 选项调\n用编译器驱动程序时,才会得到这张表。\n.strtab: 一个字符串表, 其 内 容包括 . s ymt a b 和 . d e b u g 节中的符号表,以 及节头部 中 的 节 名字。字符串表就是以 nul l 结尾的字符串的序列。\n囚 日 为什么未初始化的数据称为 . b ss\n用术语 . bs s 来表 示 未初 始化的数据是很普遍的。 它起 始于 IB M 704 汇编语言(大约在 1 957 年)中"块存储开始 ( Block Storage Start )\u0026quot; 指令的 首 字母 缩 写 , 并 沿 用 至今。一种记住 . d a t a 和 . b s s 节之间 区 别的 简 单方 法是把 \u0026quot; bss \u0026quot; 看成是“ 更好地节 省空间 , ( Be tt e r S ave S pace ) \u0026quot; 的缩写。\n5 符号和符号表\n每个可重定位目标模块 m 都有一个符号表, 它 包 含 m 定 义 和引用的符号的信息。在链 接器的上下文中,有 三种不同的符号:\n由 模 块 m 定义并能被其他模块引用的全局符号。全局链接器符号对应千非静态的 C\n函数和全局变量。\n由其他模块定义并被模块 m 引用的全局符号。这些符号称为外部符号, 对应于在其他模块中定义的非静态 C 函数和全局变量。\n只被模块 m 定义和引用的局部符号。它们对应于带 s t a t i c 属性的 C 函数和全局变握。这些符号在模块 m 中任何位置都可见,但 是 不 能 被其他模块引用。\n认识到本地链接器符号和本地程序变量不同是很重要的。.s ymt a b 中 的 符号表不包含对 应于本地非静态程序变量的任何符号。这些符号在运行时在栈中被管理,链 接器对此类符号不感兴趣。\n有趣的是,定 义为带有 C s t a t i c 属性的本地过程变量是不在栈中管理的。相反,编译 骈在 . d a t a 或 . bs s 中为每个定义分配空间,并 在 符 号 表中创建一个有唯一名字的本地链 接器符号。比如, 假设在同一模块中的两个函数各自定义了一个静态局部变量 X :\nint f ()\n2 {\n3 static int x = O;\nreturn x·\n1 int gO\nstatic int x = 1;\n10 return x;\n在这种情况中, 编译器向汇编器输出两个不同名字的局部链接器符号。比如, 它可以用 x . 1 表 示 函 数 f 中 的定 义, 而用 x . 2 表示函数 g 中的定义。\n田 注 皿 勾 利用 s t a 七i c 属性隐藏变量 和函数名字\nC 程序员使 用 s t a t i c 属性隐藏模块内部的 变量 和函数声明 ,就 像 你在 Java 和 C++,\n中使用 p u b 江 e 和 pr i v a t e 声 明一样。在 C 中, 源 文件扮演模块的 角 色。 任何带 有\nS 七a t i c 属性声明的 全局 变量 或者函数都是模块私有的。类似地, 任何不 带 s t a t i c 属性声明的 全局变量 和函数都是公共的, 可以被其他模块访问。尽可能 用 s t a 巨 c 属性来保护你的变量和函数是很好的编程习惯。\n符号表是由汇编器构造的 ,使用 编译器输出到汇编语言. s 文件中的符号。. s ymt a b 节中包含 ELF 符号表。这张符号表包含一个条目的数组。图 7-4 展示了每个条目 的格式。\ncode/linklelfstructs.c\ntypedef struct {\nint name; I* String table offset *I\nchar type:4, I* Function or data (4 bits) *I\nbinding:4; I* Local or global (4 bits) *I\nchar reserved; I* Unused *I\nshort section; I* Section header index *I\nlong value; I* Section offset or absolute address *I\nlong size; I* Object size in bytes *I\n} Elf64_Symbol;\n图 7-4\ncode/link/elfstructs.c\nELF 符号表条目 。t ype 和 bi ndi ng 字段每个都是 4 位\nna me 是字符串表中的字节偏移, 指向符号的以 n u l l 结尾的字符 串名字。v a l ue 是符号的地址。对 于可重定位的 模块来说, v a l u e 是距定义目标的节的起始位置的偏移。对于可执行目标文件来说 ,该 值是一个绝对运行时地址 。s i z e 是目标的大小(以字节为单位)。t ype 通常要 么是数据, 要么是函数。符号表还可以 包含各个节的条目, 以及对应原始源文件的路径名的 条目。所以这些目标的类型也有所不同。b i n d i ng 字段表示符号是本地的还是全局的。\n每个符号都被分配到 目标文件的某个节, 由 s e c t i o n 字段 表示 , 该字段也是一个到节头部表的 索引。有三个特殊的伪节( ps e ud os ect io n ) , 它们在节头部表中是没有条目的:\nABS 代表不该被重定位的符号; UNDEF 代表未定义的符号, 也就是在本目标模块中引用,但是却在其他地方定义的符号; COM MON 表示还未被分配位置的未初始化的数据目标。对于 COMMON 符号, v a l u e 字段给出对齐要求, 而 s i z e 给出最小的大小。注意, 只有可重定位目标文件中才有这些伪节,可执行目标文件中是没有的。\nCOMMON 和. b s s 的区别很细微。现代的 GCC 版本根据以下规则来将可重定位目标\n文件中的符号 分配到 CO MMO N 和. b s s 中:\nCOMMON 未初始化的全局变量\n.bss 未初始 化的静 态变篮,以及 初始 化为 0 的全 局或静 态变量\n采用这种看上去很绝对的区分方式的原因来自于链接器执行符号解析的方式, 我们会在\n7. 6 节中加以 解释。\nGNU READELF 程序是一 个查看目标文件内 容的很方便的工具 。比如, 下面是图 7-1 中示例程序的 可重定 位目标文件 ma i n . .o 的符号表中的 最后三个条目。开始的 8 个条目没有显示出来 , 它们是链接器内部使用的局部符号。\nNum. :\nValue\nSize Type\nBind Vis\nNdx Name\n8: 0000000000000000\n9: 0000000000000000\n10: 0000000000000000\n24 FUNC\n8 OBJECT\n0 NOTYPE\nGLOBAL DEFAULT GLOBAL DEFAULT GLOBAL DEFAULT\n1 main\n3 array UND sum\n在这个例子中, 我们看到全局 符号 ma i n 定 义的条目, 它是一个位于. t e x t 节中偏移量 为 0 ( 即 va l ue 值)处的24 字节函数。其后跟随着的是全局符号 arr a y 的定义, 它是一个位于. da t a 节中偏移量为 0 处的 8 字节目标。最后一个条目来自对外部符号 s um 的引用. READEL F 用一个整数索引来标识 每个节。 Ndx =l 表示. t e xt 节, 而 Ndx =3 表示. da t a 节。\n沁囡 练习题 7. 1 这个题 目针 对图 7-5 中的 m. o 和 s wa p . a 模块。 对于每 个在 s wa p . a 中定义或引 用 的符 号, 请 指 出 它 是否在模块 s wa p . a 中的 . s ym七a b 节 中 有 一个 符号表条目。 如果 是, 请指 出定义该 符号的模 块( s wa p . a 或者 m. o ) 、 符号 类型(局部、 全局或者外部)以及它在模 块中被 分配到的 节( . t e x t 、. d a t a 、. b s s 或 COMMON ) 。\nvoid swap();\ncode/link/m.c\nextern int buf [) ;\ncode/linklswap.c\nint buf[2] = {1, 2};\nint main()\n{\nswap(); return O;\n}\ncode/link/m.c\nint *bufpO = \u0026amp;buf[O]; int *bufpl;\nvoid swap()\n{\nint temp;\nbufp1 = \u0026amp;buf[1]; temp= *bufpO;\n*bufpO = *bufp1;\n*bufp1 = temp;\nm.c b) swap. c 图 7-5 练习题 7. 1 的示例程序\ncode/link/swap.c\n7. 6 符号解析\n链接器解析符号引用的方法是将每个引用与它输入的可重定位目标文件的符号表中的 一个确定的符号定义关联起来。对那些和引用定义在相同模块中的局部符号的引用,符号 解析是非常简单明了的。编译器只允许每个模块中每个局部符号有一个定义。静态局部变 量也会有本地链接器符号,编译器还要确保它们拥有唯一的名字。\n不过,对全局符号的引用解析就棘手得多。当编译器遇到一个不是在当前模块中定义 的符号(变扯或函数名)时,会假设该符号是在其他某个模块中定义的,生成一个链接器符号表条目,并 把它交给链接器处理。如果链接器在它的 任何输入模块中都找不到这个被引用符号的定义,就输出一条(通常很难阅读的)错误信息并终止。比如,如果我们试着在一\n台 L in ux 机器上编译和链接下面的源文件 :\nvoid foo(void);\nint main() { foo(); return O;\n}\n那么编译 器会没有障碍地运行, 但是当链接器无法解析对 f o o 的引用时, 就会终止:\nlinux\u0026gt; gee -Wall -Og -o linkerror linkerror. e\n/tmp/ccSz5uti.o: In function\u0026rsquo;main':\n/tmp/ccSz5uti.o(.text+Ox7): u 卫 defi ned reference t o \u0026rsquo; f oo'\n对全局符号的符号解析很棘手,还因为多个目标文件可能会定义相同名字的全局符 号。在这种情况中,链接器必须要么标志一个错误,要么以某种方法选出一个定义并抛弃 其他定义。 Li nu x 系统采纳的方 法涉及 编译器、汇编器和链接器之间的协作, 这样也可能\n给不警觉的程 序员带来一些麻烦。\nm 对 C + + 和 J a va 中链接器符号的重整\nC++ 和 Java 都允许重栽方法,这 些方法在 源代码中有 相同的名宇,却 有不同的 参数\n列表。那么链接器是如何区别这些不同的重栽函数之间的差异呢? C + + 和 J ava 中能使 用重栽函数,是因为编译器将每个唯一的方法和参数列表组合编码成一个对链接器来说唯一的名字。 这种编码过程叫做 重整 ( mangling ) , 而相反的过程叫做 恢复( demangling ) 。\n幸运的是 , C ++ 和 Java 使用兼容的 重整 策略。一个被 重整的 类名 字是由名字中宇符的整数数 量, 后面跟原始名 字组成的 。比如,类 Fo o 被编码成 3Foo 。方法被 编码为原始方法名,后 面加上__, 加上被重整的 类名 ,再加 上每个参数的 单宇母 编码。比如, Foo : :bar (int, l ong) 被编码为 b ar 3Foo 斗。 重整全局 变量 和模板名字的 策略是相似的。\n6. 1 链接器如何解析多重定义的全局符号\n链接器的输入是一 组可重定位目标模块。每个模块定义一组符号, 有些是局部的(只对定义该符号的模块可见),有些是全局的(对其他模块也可见)。如果多个模块定义同名 的全局符号 , 会发生什么呢?下面是 L in ux 编译系统采用的 方法。\n在编译时, 编译器向汇编器输出每个全局符号, 或者是强 ( st ro n g ) 或者是弱 ( w ea k ) , 而汇编器把这个信息隐含地编码在可重定位目标文件的符号表里。函数和已初始化的全局 变量是强符号,未初始化的全局变量是弱符号。\n根据强弱符号 的定义, L in ux 链接器使用下面的规则来处 理多重定义的符号名:\n规则 1 : 不允许有多个同名的强符号。 规则 2 : 如果有一个强符号和多个弱符号同名,那么选择强符号。\n规则 3: 如果有多个弱符号同名,那么从这些弱符号中任意选择一个。比如, 假设我们试图编译和链接下面两个 C 模块:\nI* fool.c *I\nint main()\n{\nreturn O;\n}\nI* barl.c *I\n2 int main()\n3 {\nreturn O;\n5 }\n在这个情况中,链 接 器将生成一条错误信息,因 为强符号 ma i n 被定义了多次(规则D:\nlinux\u0026gt; gee foo1.c bar1.c\n/tmp/ccq2Uxnd.o: In function\u0026rsquo;main\u0026rsquo;: barl.c:(.text+OxO): multiple definition of\u0026rsquo;main'\n相似地,链 接 器 对 于下面的模块也会生成一条错误信息,因 为 强 符号 x 被定义了两次\n(规 则 U :\nI* foo2.c *I\n2 int X = 15213;\n4 int main()\n5 {\nreturn O;\n7 }\nI* bar2.c *I\n2 int X = 15213;\n4 void f()\n5 {\n6 }\n然而,如 果 在 一 个 模 块 里 x 未被初始化,那 么 链 接器将安静地选择在另一个模块中定义 的 强 符 号(规则 2 ) :\nI* foo3.c *I\n2 #include \u0026lt;stdio.h\u0026gt; 3 void f(void); 5 int X = 15213; 7 int main() 8 { 10 f (); printf(\u0026ldquo;x = 炽\\ n\u0026rdquo;, x); return O; 12 } I* bar3.c *I\n2 int x·\n4 void f ()\n5 {\n6 X = 15212;\n7 }\n在运行时,函 数 f 将 x 的 值 由 1 5 21 3 改 为 1 521 2 , 这会 给 ma i n 函 数 的 作 者带来不受欢 迎 的 意 外! 注 意, 链接器通常不会表明它检测到多个 x 的定义:\nlinux\u0026gt; gee -o foobar3 foo3.e bar3. e linux\u0026gt; ./foobar3\nX = 15212\n如果 x 有两个弱定义,也 会发生相同的事情(规则3 ) :\nI* foo4.c *I\n#include \u0026lt;s t d i o . h\u0026gt; void f(void);\nint x;\nint main()\n{\nX = 15213 ;\nf O;\nprintf(\u0026ldquo;x = %d\\n\u0026rdquo;, x); return O;\n}\nI* bar4.c *I\nint x;\nvoid f 0\n{\nX = 15212;\n}\n规则 2 和规则 3 的应用会造成一些不易察觉的运行时错误, 对于不警觉的程序员来说,是很难理解的, 尤其是如果重复的符号定义还有不同的类型时。考虑下面这个例子, 其中 x 不幸地在一个模块中定义为 i n t , 而在另一个模块中定义为 d o u b l e :\nI* foo5.c *I\n#include \u0026lt;s t di o . h\u0026gt; void f(void);\nint int\n15212;\n15213;\nint main()\n{\nf O;\npr i nt f (\u0026ldquo;x = Ox% x y = Ox 妘 \\ n \u0026ldquo;\u0026rsquo; x, y);\nreturn O;\n}\nI* bar 5 . c *I\ndouble x;\nvoid f 0\n{\nX \u0026ldquo;\u0026rsquo; - 0 . Q;\n}\n在一台 x86- 64 / L in u x 机器上, d o u b l e 类 型 是 8 个 字节 ,而 i n t 类 型 是 4 个字节。在我们的系统中, x 的 地址是 Ox 601 02 0 , y 的 地址是 Ox 601 0 2 4。因此, b ar 5 . c 的 第 6 行中的赋值 x = -0. 0 将用负零的双精度浮点 表示覆盖内存中 x 和 y 的位置( fo o 5 . c 中的第 5 行和第 6 行)!\nlinux\u0026gt; gee -Wall -Og -o foobar5 foo5. e bar5. e\n/usr/bin/ld: Warning: alignment 4 of symbol\u0026rsquo;x\u0026rsquo;in /tmp/cclUFK5g.o is smaller than 8 in /tmp/ccbTLcb9.o\nlinux\u0026gt; ./toobar5\nx = OxO y = Ox80000000\n这是一个细微而令人讨厌的错误,尤其是因为它只会触发链接器发出一条警告,而且 通常要在程序执行很久以后才表现出来,且 远离错误发生地。在一个拥有成百上千个模块的大型系统中,这种类型的错误相当难以修正,尤其因为许多程序员根本不知道链接器是 如何工作的。当你怀疑有此类错误时, 用 像 G C C - f n o - c o mmo n 标 志 这样的选项调用链接器,这个选项会告诉链接器,在遇到多重定义的全局符号时,触发一个错误。或者使用\n- Wer r or 选 项 ,它 会把所有的警告都变为错误。\n在 7. 5 节中, 我们看到了编译器如何按照一个看似绝对的规则来把符号分配为 OM\nMON 和. bs s 。实际上, 采用这个惯例是由千在某些情况中链接器允许多个模块定义同名航全局符号。当编译器在翻译某个模块时, 遇到一个弱全局符号,比 如说 x , 它并不知道其他模块是否也定义了 x, 如果是,它 无法 预 测链接器该使用 x 的多重定义中的哪一个。所以编译\n器把 x 分配成COMMON, 把决定权留给链接器。另一方面, 如果 x 初始化为o, 那么它是一个\n强符 号(因此根据规则 2 必须是唯一的),所以 编译 器可以很自信地将它分配成. bs s 。类似地, 静态符号的构造就必须是唯一的,所以编译 器可以自信地把它们分配成. da t a 或. bs s 。\n; 练习题 7. 2 在此题 中, REF (x. i)-DEF (x.k) 表 示链 接器 将把模 块 1 中对符 号 x 的任意引用 与模块 k 中 x 的定 义关联 起来。对于下 面的 每个 示例 ,用 这种表 示 法来 说明链接器将如何解析 每个模块 中对 多 重定义 符 号 的引 用。 如果有 一个链接 时错误(规则 1 )\u0026rsquo; 写\n“错 误"。 如 果链接 器从 定义中任意选择 一个(规则 3) , 则写“未知”。\nI* Module 1 *I\nint main()\n{\n}\nI* Module 2 *I\nint main;\nint p20\n{\n}\nREF( ma i n .1) DEF(.)\n(REF(ma i n .2) DEF(.)\nBI.\nModule 1 *I\nI* Module 2 *I\nvoid main()\n{\n}\nint main= 1; int p2()\n{\n}\n(a) REF(ma i n .1) DEF(.)\n(REF(ma i n .2) DEF(.)\nCIModule 1 *I\nint x;\nvoid main()\n{\n}\nI* Module 2 *I double x = 1.0; int p2()\n{\n}\nREF(x.1) DEF( . )\nCb) REF(x.2) DEF(.)\n7. 6. 2 # 与静态库链接\n迄今为止 , 我们都 是假设链 接器读取一组可重定位目标文件, 并把它们链接起来,形成一个输出的可执行文件。实际上,所有的编译系统都提供一种机制,将所有相关的目标模块打包成为一个单独的文件, 称为静态库 ( s t atic library), 它可以用做链接器的输入。当链接器构造一个输出的可执行文件时,它只复制静态库里被应用程序引用的目标模块。\n为什么系统要 支持库的概念呢?以 ISO C99 为例, 它定义了一组广泛的标准 I/ 0 、字符串操作 和整数数学函数, 例如 a t o i 、p r i n t f 、s c a n f 、S 七r c p y 和r a nd 。它们在 l i b c .\na 库中, 对每个 C 程序来说都是可用的。ISO C99 还在 li b m. a 库中定义了一组广泛的浮点数学函数 , 例如 s i n 、c o s 和 s q r t 。\n让我们来看看如果不使用静态库,编译器开发人员会使用什么方法来向用户提供这些 函数。一种方法是让编译器辨认出对标准函数的调用 , 并直接生成 相应的代码。Pascal ( 只提供了一小部分标准函数)采用的就是这种方法, 但是这种方法对 C 而言是不合适的, 因为 C 标准定义了大量的标准函数。这种方法将给编译器增加显著的复杂性, 而且每次添加、删除 或修改一个标准函数时,就需 要一个新的编译器版本。然而,对 于应用程序员而言,这 种方法会是 非常方便的 , 因为标准函数将总是可用的 。\n另一种方法是 将所有的标准 C 函数都放在一个单独的可重定位目标模块中(比如说\nlibc.o中)应用程序员 可以把这个模块链接到他们的 可执行文件中 :\nlinux\u0026gt; gee main.e /usr/lib/libe.o\n这种方法的优点是它将编译器的实现与标准函数的实现分离开来,并且仍然对程序员 保持适度的便 利。然而, 一个很大的缺点是系统中每个可执行 文件现在都包含着一份标准函数集合的完 全副本, 这对磁盘空间是很大的浪费。(在一个典型的系统上, 辽 b e . a 大 约是 5MB , 而 让bm. a 大约是 2M B。)更糟的是, 每个正 在运行的程序都将它 自己的 这些函数的副本放在内存中 , 这是对内存的极度浪费。另一个大的缺点是, 对任何标准函数的任何改变,无论多么小的改变,都要求库的开发人员重新编译整个源文件,这是一个非常耗时 的操作,使得标准函数的开发和维护变得很复杂。\n我们可以通过为每个标准函数创建一个独立的可重定位文件,把它们存放在一个为大\n六仁 家都知道的目 录中来解 决其中的一些间题。然而, 这种方法要求应用程序员显式地链接合\n适的目标模块到它们的可执行文件中,这是一个容易出错而且耗时的过程:\nlinux\u0026gt; gee main. e /usr/lib/printf.o /usr/lib/seanf.o . . .\n静态库概念被提出来,以解决这些不同方法的缺点。相关的函数可以被编译为独立的目标模块,然后封装成一个单独的静态库文件。然后,应用程序可以通过在命令行上指定单独的文件名字来使用这些在库中定义的函数。比如, 使用 C 标准库和数学库中函数的程序可以用形式如下的命令行来编译和链接:\nlinux\u0026gt; gee main.e /usr/lib/libm.a /usr/lib/libe.a\n在链接时,链接器将只复制被程序引用的目标模块,这就减少了可执行文件在磁盘和内 存中的大小。另一方面,应用 程序员只需要包含较少的 库文件的名字(实际上, C 编译器驱\n动程序总是传送 li b c . a 给链接器,所以前 面提到的对 让be . a 的引用是不必要的)。\n在 L in u x 系统中, 静态库以一种称为存档( a rc h ive ) 的特殊文件格式存放在磁盘中。存档文件是一组连接起来的可重定位目标文件的集合,有一个头部用来描述每个成员目标文 件的大小和位置。存档文 件名由后 缀 . a 标识。\n为了使我们对库的讨论更加形象具体, 考虑图 7- 6 中的两个向量例程。每个例 程, 定义在它自己的目标模块中,对两个输入向量进行一个向量操作,并把结果存放在一个输出 向量中。每个例程有一个副作用,会记录它自已被调用的次数,每次被调用会把一个全局 变量加 1。(当我们在 7. 1 2 节中解释位置无关 代码的思想 时会起作用。)\n1 int addcnt = 0;\ncode/link/addvec.c\nint multcnt = O;\ncode/linklmultvec.c\n2 2\nvoid addvec(int *X, int *Y, 3 void multvec(int *X, int *Y,\nint *Z, int n) 4 int *z, int n)\n5 { 5 {\n6 inti; 6 inti;\n7 7\n8 addcnt++; 8 multcnt++;\n9 9\n10 for (i = O; i \u0026lt; n; i++) 10 for (i = O; i \u0026lt; n; i++) 11 Z (i] = X [i] + y [i] ; 11 Z [i] = X [i] * y [i] ;\n12 } 12 }\ncode/linkladdvec.c codellinklmultvec.c\naddvec. o b) multvec.o\n图7-6 l i bvec t or 库中的成员目标 文件\n要创建这些函数的一个静态库 , 我们将使用 AR 工具, 如下:\nlinux\u0026gt; gee -e addvee.e multvee.e\nlinux\u0026gt; ar res l i bveet or .a addvee.o multvee.o\n为了使用这个库 , 我们可以编写一个应用, 比如图 7-7 中的 ma i n 2 . c , 它调用 a ddve c\n库例程。包含(或头)文件v e c t o r . h 定义了 巨 bv e c t or . a 中例程的函数原型。\ncode/link/main2.c\n#include \u0026lt;stdio. h\u0026gt;\n#include \u0026ldquo;vector .h\u0026rdquo;\n3\n4 int X[2] = {1, 2};\n5 int y[2] = {3, 4};\n6 int z[2];\n7\n8 int marnO\n9 {\n1o addvec (x, y, z, 2) ;\n11 printf(\u0026ldquo;z = [%d %d]\\n\u0026rdquo;, z[O], z[1]);\n12 return O,·\n13 }\ncode/link/main2.c\n图 7-7 示例程序 2。这个程序调用 l i bve c t or 库中的函数\n为了创建这个可执行 文件, 我们要 编译和链接输入文件 ma i n . a 和 l i b v e c t o r .a:\nlinux\u0026gt; gee -e main2.e\nlinux\u0026gt; gee -statie -o prog2e mai n 2 . o . / l i bve ct ro . a\n或者等价地使用:\nl i nux\u0026gt; gee - e ma i n2 . e\nl i nu x \u0026gt; gee -statie -opr og2e mai n2 . o - L . -lveetor\n图7-8 概括了链接器的行为。- s t a t i c 参数告诉编译器驱动程序,链 接 器 应 该 构 建 一个完全链 接的可执行目标文件, 它 可以加载到内存并运行, 在 加 载时无须更进一步的链接。 - l v e c t or 参 数是 l i b v e c t or . a 的 缩写, - L . 参 数 告诉 链接器在当前目录下查找 li b - ve c t o r . a 。\n源文件 main2.c vector.h\n翻译器\n(epp, eel, as) I l i bvee t or . a l i bc . a 静态库\n可重定位目标文件 ma1.n 2 . o I addvec. o\n链接器 (l d )\npr i nt f. o和其他pr i nt f. o调用的模块\npr og 2_c 完全链接的\n可执行目标文件图 7-8 与静态库链接\n当链接器运行时 , 它判定 ma i n 2 . o 引 用 了 a d d v e c . o 定 义的 a d d v e c 符号 ,所 以 复 制addve c . o 到可执行文件。因为程序不引用任何由 mu l t v e c . o 定 义 的 符 号 ,所 以 链 接 器 就不会复制这个模块到可执行文件。链接器还会复制 l i b c . a 中的 pr i n t f . o 模块,以 及 许多 C 运行 时系统中的其他模块。\n6. 3 链接器如何使用静态库来解析引用\n虽然静态库很有用,但 是 它 们 同 时 也 是 一个程序员迷惑的源头,原 因 在 于 L in u x 链接器使用它们解析外部引用的方式。在符号解析阶段,链接器从左到右按照它们在编译器驱 动程序命令行上出现的顺序来扫描可重定位目标文件和存档文件。(驱动程序自动将命令 行中所有 的 . c 文件翻译为 . o 文件。)在这次扫描中, 链接器维护一个可重定位目标文件的集合 E C这个集合中的文件会被合并起来形成可执行文件), 一 个 未解析的符号(即引用了\n但是尚未定 义的符号)集合 u , 以及一个在前面输入文件中已定义的符号集合 D。初始时,\nE、U 和 D 均为空。\n对千命令行上的每个输入文件 f , 链接器会判断 J 是一个目标文件还是一个存档文件。如果 J 是一个目标文件,那 么链 接器把 f 添加到 E , 修改 U 和 D 来反映 f 中的符号定义和引用, 并 继 续下 一 个 输 入 文 件 。\n如果 J 是一个存档文件,那 么链接器就尝试匹配 U 中未解析的符号和由存档文件成员定\n义的符号。如果某个存档文件成员 m, 定义了一个符号来解析 U 中的一个引 用,那么就将 m 加到E 中, 并 且链 接 器修改 U 和D 来反映 m 中的符号定义和引用。对存档文件中所有的成员目标文件都依次进行这个过程, 直 到 U 和 D 都不再发生变化。此时,任何不包含在 E 中的成员目标文件都简单地被丢弃,而 链接器将继续处理下一个输入文件。\n;,\n如果当链接器完成对命令行上输入文件的扫描后, U 是非空的, 那么链接器就会输出一个错误并终止。否则,它 会合并和重定位E 中的目标文件,构 建输出的可执行文件。\n不幸的是,这种算法会导致一些令人困扰的链接时错误,因为命令行上的库和目标文 件的顺序非常重要。在命令行中, 如果定义一个符号的库出现在引用这个符号的目标文件之前, 那么引用就不能被解析, 链接会失败 。比如, 考虑下面的命令行发生了什么?\nlinux\u0026gt; gee -static ./libvector.a main2.c\n/tmp/cc9XH6Rp.o: In function\u0026rsquo;main':\n/ t mp / cc9XH6Rp . o ( . t e xt +Ox18 ) : undefined reference to\u0026rsquo;addvec'\n在处理 l i b v e c t o r . a 时, U 是空的,所 以没有 l i b v e c t or . a 中的成员目标文件会添加到 E 中。因此, 对 a d d v e c 的引用是绝不会 被解析的, 所以链接器会产生一条错误信息并终止。\n关于库的一般准则是将它们放在命令行的结尾。如果各个库的成员是相互独立的(也就是说没有成员引用另一个成员定义的符号),那么这些库就可以以任何顺序放置在命令 行的结尾处。另一方面,如果库不是相互独立的,那么必须对它们排序,使得对千每个被 存档文件的成员外部引用的符号 s\u0026rsquo; 在命令行中 至少有一个 s 的定义是在对 s 的引用之后的。比如, 假设 f o o . c 调用 l i b x . a 和 l i b z . a 中的函数, 而这两个库又调 用 li b y : a 中的 函数。那么,在 命令行中 让b x . a 和 l i b z . a 必须处在 l i b y . a 之前:\nlinux\u0026gt; gee foo.e l i bx.a libz.a liby.a\n如果需要满足依 赖需求, 可以在命令行上重复库。比如, 假设 f o o . c 调用 li bx . a 中的 函数 ,该 库又调用 l i b y . a 中的函数, 而 l i b y . a 又调用 l i b x . a 中的函数。那么 li bx.\na 必须在命令行 上重复出现:\nlinux\u0026gt; gee foo.e l i bx.a liby.a libx.a\n另一种方法是, 我们可以将 l i b x . a 和 li b y . a 合并成一个单独的存档文件。\n让 练习题 7. 3 a 和 b 表示当前目录中的目标模 块或者静态库,而 a- b 表示 a 依赖于b, 也就是说 b 定义了一 个被 a 引用的符号。对于下面每种场景,请给出最小的命令行(即 一个含有最少数量的目标文件和库参数的命令), 使得静态链接器能解析所有的符号引用。\nA. p.o - libx.a B. p.o - libx.a - liby.a C. p . o - 巨 b x . a - l i b y . a 且 巨 b y . a - libx.a - p.o 7 重定位\n一旦链接器完成了符号解析这一 步, 就把代码中的 每个符号引 用和正好一个符号定义\n(即它的一个输入目标模块中的一个符号表条目)关联起来。此时,链接器就知道它的输人目标模块中的代码节和数据节的确切大小。现在就可以开始重定位步骤了,在这个步骇中,将合并输入模块,并为每个符号分配运行时地址。重定位由两步组成:\n重定位节和符号定义。在这一步中,链接器将所有相同类型的节合并为同一类型的 新的聚合节。例如 ,来自所有 输入模块的 . d a t a 节被全部合并成一个节, 这个节成为输出的可执行目 标文件的 . d a t a 节。然后, 链接器将运行时内存地址赋给新的聚合节,赋 给输入模块定 义的每个节,以 及赋给输入模 块定义的每个符号。当这一步完成时, 程序中 的每条指令和全局变量都有唯一的运行时内存地址了。 重定位节中的符号引用。在这一步中,链接器修改代码节和数据节中对每个符号的引用,使得它们指向正确的运行时地址。要执行这一步,链接器依赖于可重定位目标模块中称为重定位 条目 ( r e lo ca t ion e n t r y ) 的数据结构, 我们接下来将会描述这种数据结构。 7. 1 重定位条目\n当汇编器生成一个目标模块时,它 并不知道数据和代码最终将放在内存中的什么位置。它也不知道这个模块引用的任何外部定义的函数或者全局变量的位置。所以,无论何 时汇编器遇到对最终位置未知的目标引用,它就会生成一个重定位条目,告诉链接器在将 目标文件合并成可执行文件时如何修改这个引用。代码的重定位条目放在 .r e l . 七e x t 中。已初始 化数据的重定位条目放在 . r e l . d a 七a 中。\n图 7-9 展示了 ELF 重定位条目 的格式。o f f s e t 是需要被修改的引用的节偏移。 s ymbo l 标识被修改引 用应该指向的符号。t y pe 告知链接器如何修改新的引用。a d d e n d 是一个有符号常数,一些类型的重定位要使用它对被修改引用的值做偏移调整。\ncodellinklelfstructs.c\ntypedef struct {\nlong offset; I* Offset of the reference to relocate *I\nlong type:32, I* Relocation type *I\nsymbol:32; I* Symbol table index *I\nlong addend; I* Constant part of relocation expression *I\n} Elf64_Rela;\n图 7-9\ncodellink/elfstructs.c\nELF 重定位条目。每个条目表示一个必须被重定位的引用 , 并指明如何计算被修改的引用\nELF 定义了 3 2 种不同的重定位类型,有 些相当隐秘。我们只关心其中两种最基本的重定位类型:\nR_ X8 6_ 6 4_ PC32。重定位一个使用 32 位 PC 相对地址的引用。回想一下 3. 6. 3 节,\n一个 PC 相对地址就是距程序计数器( PC ) 的当前运行时值的偏移量。当 CPU 执行一条使用 PC 相对寻址的指令时, 它就将在指令中编码的 32 位值加上 PC 的当前运行时值, 得到有效地址(如c a l l 指令的目标), PC 值通常是下一条指令在内存中的地址。\nR_ X8 6_ 6 4_ 32 。重定位一个使用 3 2 位绝对地址的引用。通过绝对寻址, CP U 直接使用在指令 中编码的 32 位值作为有效地址, 不需要进一 步修改。\n这两种重定位类型支持 x86- 64 小型 代码模型( small code model) , 该模型假设可执行目标 文件中的代码和数据的总体大小小 于 2G B, 因此在运行时 可以用 32 位 PC 相对地址来访问。GCC 默认使用小 型代码模型。大千 2G B 的程序可以用- mc mod e l =me d i u m( 中型代码模型) 和- mc mo d e l =l ar g e ( 大型代码模型)标志来编译, 不过在此我们 不讨论 这些模型。\n7. 2 重定位符号引用\n图 7-10 展示了链接器的重定位算法的伪代码。第 1 行和第 2 行在每个节 s 以及与每个节相关联 的重定位条目r 上迭代执行。为了 使描述具体化, 假设每个 节 s 是一个字节数组,每个重 定位条目r 是一个类型为 El f 6 4_ Re l a 的结构, 如图 7- 9 中的定义。另外, 还\n假设当算法运行时 , 链接器巳经为每个节(用 ADDR (s ) 表示)和每个符号都选择了运行时地址(用 ADDR (r. s ymbo l ) 表示)。第3 行计算的是需要被重定位的 4 字节引用的数组 s 中的地址。如果这个引用使用的是 PC 相对寻址 , 那么它就用第 5~ 9 行来重定位。如果该引用使用 的是 绝对寻址,它 就通过第 11 ~ 1 3 行来重定 位。\nforeach sections {\n2 foreach relocation entryr {\n3 refptr = s + r.offset; I* ptr to reference to be relocated *I 4 5 I* Relocate a PC-relative reference *I 6 if Cr.type== R_X86_64_PC32) { 7 refaddr = ADDR(s) + r.offset; I* ref\u0026rsquo;s run-time address *I 8 *refptr = (unsigned) (ADDR(r.symbol) + r.addend - refaddr); 9 } 10 11 / * Relocate an absolute reference *I 12 if (r.type == R_X86_64_32) 13 *refptr = (unsigned) (ADDR(r.symbol) + r.addend); 14 } 15 } 图 7-10 重定位算法\n让我们来看看链接器 如何用这个算法来重定位图 7-1 示例程序中的引 用。图 7-11 给出了(用o b j dump-dx main. o 产生的)GNU OBJDU MP 工具产生的 ma i n . o 的反 汇编代码。\ncodellinklmain-relo.d\n1 0000000000000000 \u0026lt;main\u0026gt; :\n2 0: 48 83 ec 08 sub $0x8,%rsp 3 4 : be 02 00 00 00 mov $0x2,%esi 4 9 : bf 00 00 00 00 mov $0xO,%edi ¾edi = \u0026amp;array 5 a: R_X86_64_32 array Relocation entry 6 e: e8 00 00 00 00 callq 13 \u0026lt;main+Ox13\u0026gt; sum() 7 f: R_X86_64_PC32 sum-Ox4 Rel ocat 工on entry 8 13: 17: 48 c3 83 c4 08 add $0x8,%rsp retq code/linklmain-relo.d 图 7-11 ma i n. o 的 代码和重定位条目 。原始 C 代码在图 7-1 中\nma i n 函数引用了两个全局符号: ar r a y 和 s u m。 为每个引用, 汇编器产生一个重定位条目,显 示在引用的后面一行上 产 这些重定位条目告诉链接器对 s um 的引用要使用 32 位 PC 相对地址进行重定位, 而对 arr a y 的引用要使用 32 位绝对地址进行重定 位。接下来两节会详细介绍链接器是如何重定位这些引用的。\n1 重定位 PC 相对引 用\n图 7-11 的第 6 行中, 函数 ma i n 调用 s um 函数, s u m 函数是在模块 s u m. o 中定义的,\ne 回想一下, 重定 位条目和指令实际上存 放在目 标 文件的 不同 节中 。 为了 方便, O BJDUMP 工具把它们显示在一起。\nca ll 指令开始于节偏 移 Ox e 的地方, 包括 1 字节的操作码 Ox e 8 , 后面跟着的是对目标\ns um 的 32 位 PC 相对引用的占位符。\n相应的重定位条目r 由 4 个字段组成:\nr.offset = Oxf r.symbol = sum\nr.type = R_X86_64_PC32\nr.addend = -4\n这些字段告 诉链接器修改 开始千偏移最 Ox f 处的 32 位 PC 相对引用, 这样在运行时它会指向 s um 例程。现在, 假设链接器已经确定\nADDR(s) = ADDR( . text) = Ox4004d0\n和\nADDR(r.symbol) = ADDR(sum) = Ox4004e8\n使用图 7-10 中的算法, 链接器首先计算出引用的运行时地址(第 7 行):\nrefaddr = ADDR(s) + r.offset\n= Ox4004d0 + Oxf\n= Ox4004df\n然后, 更新该引用,使 得它在运行时指向 s um 程序(第8 行):\n*refptr = (unsigned) (ADDR(r.symbol) + r.addend - refaddr)\n= (unsigned) (Ox4004e8 + (-4) - Ox4004df)\n= (unsigned) (Ox5)\n在得到的可执行目 标文件中, c a l l 指令有如下的重定位的形式 :\n4004de: e8 05 00 00 00 callq 4004e8 \u0026lt;sum\u0026gt; sum()\n在运行时 , c a l l 指 令将存放在地址 Ox 4 00 4d e 处。当 CPU 执行 c a l l 指令时, P C 的值为 Bx 40 0 4e 3 , 即紧随在 c a 荨 指令之后的指令的地址。为了执行这条指令, CPU 执行以下的步骤:\n将 PC 压 入栈中\nPC 七 PC + Ox5 = Ox4004e3 +Ox5 = Ox4004e8 因此, 要执行的下一 条指令就是 s um 例程的第一条指令, 这当然就是 我们想 要的!\n2. 重定位绝对引用\n重定位绝对引用相当简单。例如,图 7-11 的第 4 行中, mo v 指令将 arr a y 的地址(一个32 位立即数值 )复制到寄存器%e d i 中。mo v 指令开始于节偏移量 Ox 9 的 位置, 包括 1 字节操作码 Ox b f , 后 面跟着对 a rr a y 的 32 位绝对引用 的占位符。\n对应的占 位符条目r 包括 4 个字段:\nr.offset = Oxa\nsymbol = array\nr .t ype = R_X86_64_32 r.addend = 0\n这些字段告诉链接器要 修改从偏移量 Ox a 开始的绝 对引用, 这样在运行时它将会指向\narr a y 的第一个字 节。现 在, 假设链接器已经确定\nADDR(r.symbol) = ADDR(array) = Ox601018\n链接器使用图 7-1 0 中算法的第 1 3 行修改了引用:\n*refptr = (unsigned)\n(unsigned) (unsigned)\n(ADDR(r.symbol) + r.addend) (Ox601018 + 0) (Ox601018)\n在得到的可执行目标文件中, 该 引 用 有 下 面的重定位形式:\n4004d9 : bf 18 10 60 00 mov $0x601018,%edi ¼edi = \u0026amp;array\n综合到一起,图 7- 1 2 给出了最终可执行目标文件中已重定位的 . t e xt 节和 . da t a 节。在加载的时 候 ,加 载器会把这些节中的字节直接复制到内存,不 再进行 任何修改地执行这些指令。\n00000000004004d0 \u0026lt;main\u0026gt;:\n4004d0: 48 83 ec 08\nsub\n$0x8 , %r s p\n4004d4: be 02 00 00 00 mov $0x2,%esi 4004d9: bf 18 10 60 00 mov $0x601018,%edi ¼edi = \u0026amp;array 4004de: e8 05 00 00 00 callq 4004e8 \u0026lt;sum\u0026gt; sum() 4004e3: 48 83 c4 08 add $0x8,%rsp 4004e7: c3 retq 00000000004004e8 \u0026lt;sum\u0026gt;:\n4004e8: b8 00 00 00 00\nmov\n$0x0,%eax\n4004ed: ba 00 00 00 00\n4004£2: eb 09\n4004£4: 48 63 ca\n4004£7: 03 04 8f\n4004fa: 83 c2 01\n4004fd: 39 f2\n4004ff: 7c f3 400501: f3 c3\nmov $0x0,%edx\njmp 4004f d \u0026lt;sum+Ox15\u0026gt; mo v s l q %edx,%rcx\nadd (%rdi,%rcx,4),%eax add $0x1,%edx\ncmp %esi,%edx\njl 4004f4 \u0026lt;sum+Oxc\u0026gt; repz retq\n已重定位的 . t ext 节 图 7-12\n0000000000601018 \u0026lt;arr a y\u0026gt; :\n601018: 01 00 00 00 02 00 00 00\n巳重定位的. dat a 节 可执行文件 pr og 的已重定位的 . t e江 节和 . da t a 节。原始的 C 代码在图 7-1 中\n; 练习题 7. 4 本题是关 于图 7- 1 2a 中 的 已 重定位程序的。\n第 5 行中对 s um 的 重定 位引用 的 十 六进 制地 址是 多少?\n第 5 行中 对 s u m 的 重定位引用的十 六进制值是多少?\n; 练习题 7. 5 考虑目标 文件 m . o 中对 s wa p 函数 的调用(图 7- 5 ) 。\n9: e8 00 00 00 00 callq e \u0026lt;ma i n +Ox e \u0026gt; swap()\n它的重定位条目如下:\nr.offset = Oxa r . s ymbol = swap\nr.type = R_X86_64_PC32 r.addend = -4\n现在假设链接器 将 m . o 中 的 . t e x t 重 定位到 地 址 Ox 400 4d 0 , 将 s wa p 重定位到地 址\nOx 4 0 0 4e 8 。那么 c a l l q 指令中对 s wa p 的重定 位引用的值是什么?\n7. 8 可执行目标文件\n我们已经看到链接器如何将多个目标文件合并成一个可执行目标文件。我们的示例 C 程序 , 开始时是一组 ASCII 文本文件,现 在 已经被转化为一个二进制文件, 且这个二进制文件包 含加载程序到内存并运行它所需 的所有信息。图 7-13 概括了一个典 型的 ELF 可 执行文件中的各类信息。\n只读内存段(代码段 )\n}读/写内存段(数据段)\n描述目标文件的节{\n不加载到内存的符号表\n和调 试信 息\n图 7-13 典型的 ELF 可执行目标 文件\n\u0026lsquo;,,\n可执行目标文件的格式类似于可重定位目标文件的格式。ELF 头描述文件的总体格 式。它 还包 括程序的入口点( e n t r y point), 也就是当程序运行时要执行的第一条指令的地址。. t e x t 、.r o d a t a 和 . d a 七a 节 与可重定位目标文件中的节是相似的,除 了 这些节巳经被重定位到它们最终的运行时内存地址以外。. i n it 节定 义了一个小函数,叫 做 _ i n i 七,程 序 的初始 化代码会调用它。因为可执行文件是完全链接的(已被重定位),所以 它 不 再 需 要 . r e l 节。\nELF 可执行文件被设计得很容易加载到内存, 可执行文件的连续的片( c h u n k ) 被映射到连续的内存段。程序头部 表 ( p ro g ra m header table ) 描述了这种映射关系。图 7- 1 4 展示了可执行 文件 pr o g 的程序头部表, 是 由 O BJDU MP 显示的。\ncode/linklp rog-exe.d\nRead-only code s egme nt\n1 LOAD off OxOOOOOOOOOOOOOOOO vaddr Ox0000000000400000 paddr Ox0000000000400000 align 2**21\n2 filesz Ox000000000000069c memsz Ox000000000000069c flags r-x\nRea d/ wr i t e datas egme nt\n3 LOAD off Ox0000000000000df8 vaddr Ox0000000000600df8 paddr Ox0000000000600df8 align 2**21\n4 filesz Ox0000000000000228 memsz Ox0000000000000230 flags rw-\ncodellinklprog-exe.d\n图 7-14 示例可执行文件 p r og 的程序头部表\noff: 目标文件中的偏移; vaddr/paddr: 内存地址; al i gn : 对齐要求; filesz: mems z: 内存 中的 段 大小; flags : 运 行 时访 问权 限 。\n目标文件中的段大小;\n从程序头部表, 我们会看到根据可执行目标文件的内容初始化两个内存段。第 1 行和\n第 2 行告诉我们第一个段(代码段)有读/执行访问权限, 开始于内存地址 Ox 40 0000 处, 总共的内存大小是 Ox 69c 字节, 并且被初始化为 可执行目标 文件的头 Ox 69c 个字节 , 其中包括 E L F 头、程序头部表以及 . i n it 、. t e x t 和.r o da t a 节。\n第 3 行和第 4 行 告 诉我们第二个段(数据段)有读/写访问权限, 开始于内 存地址\nOx 60 0d f 8 处,总 的 内 存大小为 Ox 230 字节, 并用从目标文件中偏移 Ox d f 8 处开始的\n. d a t a 节中的 Ox 2 28 个字节初始化。该段中剩下的 8 个字节对应于运行时将被初始化为 0\n的 . b s s 数据。\n对于任何段 s , 链接器必须 选择一个起始地址 v a d dr , 使得\nvaddr mod align= off modalign\n这里, o ff 是目标 文件中段的 第一个节的偏移扯, a 止 g n 是程序头部中指定的对齐 c 221 =\nOx 2 0000 0) 。例如,图 7-1 4 中的数据段中\nvaddr mod align = Ox600df8 mod Ox200000 = Oxdf8\n以及\noff mod align= Oxdf8 mod Ox200000 = Oxdf8\n这个对齐要求是一种优化,使得当程序执行时,目标文件中的段能够很有效率地传送到内 存中。原因有点儿微妙,在千虚拟内存的组织方式,它被组织成一些很大的、连续的、大 小为 2 的幕的字节片。第 9 章中你会学习到虚拟内存的知识。\n7. 9 加载可执行目标文件\n要运行 可执行目标文件 pr o g , 我们可以在 Lin ux s hell 的命令行中输入它的名字:\nlinux\u0026gt; ./prog\n因为 pr og 不是一个内置的 s hell 命令, 所以 shell 会认为 pr og 是一个可执行目标文件,通 过洞用 某个驻留在存储器中称为加载器( loade r ) 的操作系统代码来运行它。任何L in ux 程序都 可以通过调用 e xe c ve 函数来调用加载器, 我们将在 8. 4. 6 节中详细描述这个函数。加载器将可执行目标文件中的代码和数据从磁盘复制到内存中 ,然后通过跳转到程序的第一条指令或入口点来运行该程序。这个将程序复制到内存并运行的过程叫做加栽。\n每个 L in ux 程序都有一个运行时内存映像, 类似千图 7-1 5 中所示。在 Lin ux x86-64 系统中, 代码段总是从地址 Ox 400000 处开始, 后面是数据段。运行时堆在数据段之后, 通过调用 ma l l o c 库往上增长。(我们将在 9. 9 节中详细描述 ma l l o c 和堆。)堆后面的区域是为共享模块保留的。 用户栈总是从最大的合法用户地址 ( 24 8- 1 ) 开始, 向较小内存地址增长。栈上的区域, 从地址 沪 开始, 是为内核 ( kern el ) 中的代码和数据保留的 , 所谓内核就是操作系统驻留在内存的部分。\n为了简洁,我们把堆、数据和代码段画得彼此相邻,并且把栈顶放在了最大的合法用 户地址处。实际上, 由千. d a t a 段有对齐要求(见 7. 8 节), 所以代码段和数据段之间是有间隙的。同时,在分配栈、共享库和堆段运行时地址的时候,链接器还会使用地址空间布 局随机化( AS L R , 参见 3. 10. 4 节)。虽然每次程序运行时 这些区域的地址都会改变, 它们的相对位置是不变的。\n当加载 器运行时, 它创建类似于图 7-1 5 所示的内存映像。在程序头部表的引导下, 加载器将可执行文件的片( ch un k ) 复制到代码段和数据段。接下来, 加载器跳转到程序的\n入口点 ,也 就 是 _s t ar t 函 数 的 地址。这个函数是在系统目标文件 c tr l . o 中定义的,对 所有的 C 程序都是一样的。_s t ar t 函数调用系统启动函数__让 b c _ s t a r t —ma i n , 该函数定义在 l i b c .s o 中。它初始化执行环境, 调 用 用 户 层 的 ma i n 函 数 ,处 理 ma i n 函 数 的 返 回值,并且在需要的时候把控制返回给内核。\n248- 1\n内核内存\n用户栈\n(运行时创建)\n了 飞/汇\u0026rsquo; • 心气勺_;\n共享库的内存映射区域\n见的内存\n千- %rsp ( 栈指针)\n护.、,又\n`,令— br k\n运行时堆\n(由ma l l o c 创建 )\nOx 400 0 00\n乏,\n图 7-15 Linux x86-64 运行时内存映像。没有展示出由于段对齐要求和地址空间布局随机化( ASLR) 造成的空隙。区域大小不成比例\n日 日-加载器实际是如何工作的? # 我们对于加 栽的描述从概念上来说 是 正确的,但 也 不是 完全准确, 这是有意为 之 。要理解加栽实际是如何工作的,你必须理解进程、虚拟内存和内存映射的概念,这些我 们还 没有加以讨论。在后面 笫 8 章 和 笫 9 章 中遇到这些概念时, 我们将重新回到加栽的问题上, 并逐渐向 你揭开它的 神秘面纱 。\n对于不够有耐心的读者,下面是关于加栽实际是如何工作的一个概述: Lin ux 系统中的 每个程序都运行在一个进程上下文中, 有自 己的 虚拟地址空间。 当 s hell 运行一个程序时, 父 s hell 进程生成 一个子进程, 它是 父进 程的一个复 制。子进程通过 e xe cv e 系统调 用启动加栽器。加 栽器删 除子进 程现有的虚拟内存段, 并创 建 一组新的代码、数据、堆 和栈段。新的栈和堆段被初始化为零。通过将虚拟地址空间中的 页映 射 到 可执行文件的 页大小的 片 ( c h unk ) , 新的代码和数据段被初始化为 可执行 文件的 内容。 最后 , 加栽器跳 转到_s t ar t 地址, 它最终会调用应 用程 序的 ma i n 函数。除了一 些头部 信息,在加栽过 程中没有 任何从磁盘到内存 的数据复制。直到 CPU 引 用一 个被 映射的虚拟页时 才会进行复制, 此时,操 作 系统 利用它的 页面 调度机制自动将 页面从磁 盘传送到内存。\n10 动态链接共享库\n我们在 7. 6. 2 节中研究 的静 态库解决了许多关千如何让大量相关函数对应用程序可用的问题。然而,静 态库仍然有一些明显的缺点。静态库和所有的软件一样,需 要 定 期 维 护和更新 。如果应用程序员想要使用一个库的最新版本,他 们 必 须 以 某种方式了解到该库的\n更新情况, 然后显 式地将他们的程序与更 新了的库重新链 接。\n另一个问题是几乎每个 C 程序都使用标准 I/ 0 函数, 比如 pr i n t f 和 s c a n f 。在运行时,这些函数的代码会被复制到每个运行进程的文本段中。在一个运行上百个进程的典型 系统上, 这将是对稀缺的内 存系统资源的 极大浪费。(内存的一个有趣属性就是不论 系统的内存 有多大,它 总 是一种稀缺资源。磁 盘空间和厨房的垃圾桶同样有这种 属性。)\n共享库 ( s ha red lib ra r y) 是致力于解决静态库缺陷的一个现代创新产物。共享库是一个目标模块, 在运行或加 载时, 可以加载到任意的 内存地址 ,并和一个在内存中的程序链接起来。这个过程称为动 态链接( dynamic linking) , 是由一个叫做动 态链 接器 ( dyn amic linker) 的程序来执行的。共享库也称为共享目标 ( s ha red object), 在 Linu x 系统中通常用 . s o后缀来表示。微软的操作系统大量地使用了 共享库, 它们称为 DLLC动态链接库)。\n共享库是以两种不同的方式来“共 main2. c vec 七ro . h\n享" 的。首先, 在任何给定的文件系统中, 对于一 个库只有一个 . s o 文件。所有引用该库的可执行目标 文件共享这个.\nl i bc . so\nl i bvect or . so\ns o 文件中的代码和数 据, 而不是像静态库的内容那样被复制和嵌入到引用它们的可执行的文件中。其次,在内存中, 一个共享库的 . t e x t 节的一个副本可以被不同的正在运行的进程共享。在第 9 章我们学习虚拟内存时将更加详细地讨论这个问题。\n可重定 位目标文件 ma i n2 . o\n! # 链接器 ( l d )\n部分链接的可 rp og21\n执行目标文件\nL\n加载器\n(e x ecve ) I l i bc . so\n动态链接过程。为了构造图 7-6 中示例向蜇例程的共享库 l i bve c t or . s o , 我\n内存中完全链 接\n代码和数据\n们调用编译器驱动程序,给编译器和链接器如下特殊指令:\n的可执行文件 1 动态链接骈 ( l d- li nux. so ) I\n图 7-16 动态链接共享库\nlinux\u0026gt; gee -shared -fpie -o libveetor. so addvee.c multvec. c\n- f pi c 选项指示 编译 器生成与位置无 关的代码(下一节将详细讨论 这个问题)。\n- s ha r e d 选项指示链接器创建一个 共享的目标文件。一旦创建了这个库,随 后就要将它链接到图 7-7 的示例程序中 :\nlinux\u0026gt; gee -o prog21 main2.e ./libveetor.so\n这样就创建了一个可执行目标文件 pr og 21, 而此文件的形式使得它在运行时可以和l i b v e c t or . s o 链接。基本的思路是当创建可执行文件时, 静态执行一些链接, 然后在程序加载时, 动态完成链接过程。认识到这一点 是很重要的: 此时, 没有任何 l i b ve c t o r . so 的代码和数据节真的被复制到可执行 文件 pr o g 21 中。反之, 链接器复制了一些重定位和符号表信息 ,它 们使得运行 时可以解 析对 l i b ve c t or . s o 中代码和数据的引用。\n当加载器加载和运行可执行 文件 pr og21 时,它 利用 7. 9 节中讨论过的技术 , 加载部分链接的可执行文件 p ro g 21。接着,它 注意到 pro g21 包含一个. i nt er p 节, 这一节包含动态链接器的路径名, 动态链接器本身就是一个共享目标(如在 Linux 系统上的 l d - linux.so)。加载器不会像它通常所做地那样将控制传递给应用,而是加载和运行这个动态链接器。然\n后,动态链接器通过执行下面的重定位完成链接任务:\n重定位 l i b c . s o 的文本和数据到某个内存段。 重定位 l i b v e c 七or . s o 的文本和数据到另一个内存段。\n重定位 pr o g 21 中所有对由 l i b c . s o 和 l i b v e c t or . s o 定义的符号的引用。\n最后,动态链接器将控制传递给应用程序。从这个时刻开始,共享库的位置就固定 了,并且在程序执行的过程中都不会改变。\n11 从应用程序中加载和链接共享库 # 到目前为止,我们巳经讨论了在应用程序被加载后执行前时,动态链接器加载和链接共享库的情景。然而,应用程序还可能在它运行时要求动态链接器加载和链接某个共享 库,而无需在编译时将那些库链接到应用中。\n动态链接是一项强大有用的技术。下面是一些现实世界中的例子:\n分发软件 。微软 W in do w s 应用的开发者常常利用共享库来分发软件更新。他们生成一个共享库的新版本,然后用户可以下载,并用它替代当前的版本。下一次他们运行应用程序时,应用将自动链接和加载新的共享库。 构建 高性能 W e b 服务器。 许多 W e b 服务器生成动 态内 容, 比如个性化的 W e b 页面、账户余额和广告标 语。早期的 W eb 服务器通过 使用 f or k 和 e x e c v e 创建一个子进程 , 并在该子进程的上下 文中运行 CGI 程序来生成 动态内容。然而, 现代高性能的 W e b 服务器可以使用基于动态链接的更 有效和完善的方法来生成动态内容。\n其思路是将 每个生成动态内容的函数打包在共享库 中。当一个来自 W e b 浏览器的请求到达时 , 服务器动态地加 载和链接适当的函数, 然后直接调用它, 而不是使用 f or k 和e xe c v e 在子进程的上下 文中运行 函数。函数会一直缓存在服务器的地址 空间 中, 所以只要一个简单 的函数调用的开销就可以 处理随后的请求了。这对一个繁忙的网站来说是有很大影响的。更进一步地说,在运行时无需停止服务器,就可以更新已存在的函数,以及添 加新的函数。\nL in u x 系统为动态链接器提供 了一个简单的接口,允 许应用程序在运行时加载和链接共享库。\n#include \u0026lt;dlfcn.h\u0026gt;\nvoid *dlopen(const char *filename, int flag);\n返回: 若 成 功 则 为指 向 句 柄 的 指 针 , 若 出错 则 为 NULL。\nd l op e n 函数加载和链接共享库 f i l e na me 。 用 已用带 RTL D_ GLOBAL 选项打开了的库解析 f i l e n a me 中的外部符号。如果当前可执行 文件是带-r d yn a mi c 选项编译的, 那么对符号解析 而言,它的 全局符号也是可用的 。fl a g 参数必须要么包括 RTL D_ NOW, 该标志告诉链接器立即解 析对外部符号的引用, 要么包括 RTLD_ LAZY 标志,该 标志指示链接器推迟符号解 析直到执行来自库中 的代码。这两个值中的任意一个都可以 和 RTL D_ GLOBAL 标志取或 。\n#include \u0026lt;dlfcn.h\u0026gt;\nvoid *dlsym(void *handle, char *symbol);\n返回: 若 成 功 则 为指 向 符 号 的 指 针 , 若 出错 则 为 NULL 。\ndl s yrn 函数 的输 入是一个指向前 面巳 经打开了的共享库的句柄和一个 s ym bol 名字, 如 果 该 符 号 存 在 ,就 返回符号的地址,否 则 返回 NU LL 。\n#include \u0026lt;dlfcn.h\u0026gt;\nint dlclose (void *handle);\n返回: 若 成 功 则 为 o, 若 出错 则为 一1。\n如果没有其他共享库还在使用这个共享库, d l c l o s e 函 数 就 卸 载该共享库。\n#include \u0026lt;dlfcn.h\u0026gt;\nconst char *dlerror(void);\n返回: 如 果 前 面对 dl open、 dl s ym 或 dl cl so e 的 调 用失败 , 则 为铸 误 消息 ,如 果 前 面的调 用 成 功 , 则 为 NUL,L\nd l er r or 函 数 返回一个字符串,它 描 述 的 是 调 用 d l o p e n 、 d l s ym 或者 d l c l o s e 函数\n时 发 生的最近的错误,如 果没有错误发生,就 返回 NU LL 。\n图 7-17 展示了如何利用这个接口动态链接我们的 l i b v e c 七or . s o 共 享 库 , 然后调用它的 a d d v e c 例程。要 编译 这个程序, 我们将以下面的方式调用 GCC:\nlinux\u0026gt; gee -rdynamic -o prog2r dll.e -ldl\ncode/link/dll.c\n#include \u0026lt;stdio.h\u0026gt;\n#include \u0026lt;stdlib.h\u0026gt;\n#include \u0026lt;dlfcn.h\u0026gt;\n4\n5 int x[2] = {1, 2};\n6 int y[2] = {3, 4};\n7 int z[2];\n8\n9 int main()\n10 {\nvoid *handle;\nvoid (*addvec)(int *, int*, int*, int);\nchar *error;\n14\nI* Dynamically load the shared library containing addvec() *I\nhandle= dlopen(\u0026rdquo;. / l i bvect or.so\u0026rdquo;, RTLD_LAZY);\nif (!handle) {\nfprintf(stderr, \u0026ldquo;%s\\n\u0026rdquo;, dlerrorO);\nexit (1);\n20 }\n21\n22 I* Get a pointer to the addvec() function we just loaded *I\n23 addvec = dlsym(handle, \u0026ldquo;addvec\u0026rdquo;);\nif ((error = dlerror O) != NULL) {\nfprintf (stderr, \u0026ldquo;%s\\n\u0026rdquo;, error);\n图7-17 示例 程序 3。在运行时 动态加载 和链接共享库 l i bvec t or . so\n佐(\n笫 7 章 链 接 489\nexit (1);\n27 }\n28\nI* Now we can call addvec() just like anyother function *I\naddvec(x, y, z, 2);\n31 printf(\u0026ldquo;z = [%d 儿 d] \\ n \u0026quot; , z[O], z[l]);\n32\nI*Unload the shared library *I\nif (dlclose (handle) \u0026lt; 0) {\nfprintf (stderr, \u0026ldquo;%s\\n\u0026rdquo; , dlerror ()) ;\nexit (1);\n37 }\n38 return O;\n39 }\ncode/link/dll.c\nm一共 享库和 J a va 本地接口\n图 7-17 (续)\nJava 定义了一 个标准调 用规 则,叫做 J ava 本地接口(J ava Native Interface, JNI), 它允许 Java 程序调 用“本 地的\u0026rdquo; C 和 C+ + 函数。J NI 的基本思想是将本地 C 函数(如fo o ) 编译到一 个共 享库 中(如f oe . s o ) 。当一个正在运行的 Java 程序试图调用函数 f oo 时, J ava 解释器利 用 d l op e n 接口(或者与其类似的 接口)动态链接 和加 栽 f o o . s o , 然后 再调用 f oo 。\n12 位置无关代码\n共享库的一个主要目的就是允许多个正在运行的进程共享内存中相同的库代码,因 而节约宝贵的内存资源。那么,多 个 进程是如何共享程序的一个副本的呢? 一种方法是给每个共享库分配一个事先预备的专用的地址空间片,然后要求加载器总是在这个地址加载共 享库。虽然这种方法很简单,但 是 它 也 造成了一些严重的问题。它对地址空间的使用效率不高,因 为即使一个进程不使用这个库,那 部 分 空 间 还是会被分配出来。它也难以管理。我们必须保证没有片会重叠。每次当一个库修改了之后,我们必须确认已分配给它的片还 适合它的大小。如果不适合了,必 须 找一个新的片。并且, 如果创建了一个新的库, 我们还必须为它寻找空间。随着时间的进展,假设在一个系统中有了成百个库和库的各个版本 库,就很 难避免地址空间分裂成大量小的、未使用而又不再能使用的小洞。更糟的是,对每个系统而言,库 在内 存 中的 分 配 都是 不同的, 这就引起了更多令人头痛的管理问题。\n要避免这些问题,现代系统以这样一种方式编译共享模块的代码段,使得可以把它们加载到内存的任何位置而无需链接器修改。使用这种方法,无限多个进程可以共享一个共享模块的代码段的单一副本。(当然, 每个进程仍然会有它自己的读/写 数据块。)\n可以加载而无需重定位的代码称为位置无关代码( P os it io n- I n d e pe nd e n t Code, PIC) 。用户对 GCC 使用- f p i c 选项指示 G N U 编译系统生成 P IC 代码。共享库的编译必须总是使用该选项。\n在一个 x86-64系统中,对 同 一 个 目 标 模 块 中 符 号 的 引 用 是 不 需 要 特 殊 处 理 使 之成为\nPIC。可以用 PC 相对寻址来编译这些引用,构 造目标文件时由静态链接器重定位。然而,对共享模块定义的外部过程和对全局变量的引用需要一些特殊的技巧, 接下来我们会谈到。\nPIC 数据引用\n编译器通过运用以下这个有趣的事实来生成对全局变量的 PIC 引用: 无论我们在内存 中的何处加载一个目标模块(包括共享目标模块),数 据段与代码段的距离总是保持不变。因此, 代码段中任何 指令和数据段 中任何变量之间的距 离都是一个运行时常撮, 与代码段和数据段的绝对内存位置是无关的。\n想要生成对全 局变量 PIC 引用的 编译器利用了这个事实,它 在数据段开始的地方创建了一个表, 叫做全局偏移量表 ( G lo b a l Offset Table, GOT ) 。在 G O T 中,每 个被这个目标模块引用的全局数据目标(过程或全局变量)都有一个 8 字节条目。编译器还为 G O T 中每个条目生成一个 重定位记录。在加 载时, 动态链接器会蜇定位 G O T 中的每个条目,使得它包含目标的正确的绝对地址。每个引用 全局目标的目标模块都有自己的 G O T 。\n图 7-18 展示了示例 l i b v e c t or . s o 共享模块的 G O T 。a d d v e c 例程通过 G O T [ 3] 间接\n地加载全局变量 a d d c 泣 的地址,然 后把 a d d c n t 在内 存中加 1。这里的关键思想是对\nG O T [ 3 ] 的 PC 相对引用中的偏移批是一 个运行时常量。\n数据段\n全局偏移量表 (GOT) GOT [ O ): \u0026hellip;\nGOT[l) \u0026hellip; .\nGOT [ 2 ) : \u0026hellip;\nGOT [ 3 J: \u0026amp;add,cnt\n运行时GOT [3 ] 和\na dd l 指令之间的固定距离是\nOx 2008b9 add vec :\n、 认\nmov Ox 2008b 9 { 毛r i p ) , 毛 r a x # 毛r a x =*GOT [ 3 ] =\u0026amp;ad dc n t\naddl $0xl, { %rax) # addcnt++\n图 7-18 用 GOT 引 用 全局 变 量 。 l i bve c 七or . s o 中的 addve c 例程通过 l i bve c 七or . s o 的\nGOT 间 接引用 了 a ddc n七\n因为 a d d c n 七是由 l i b v e c 七o r . s o 模块定义的, 编译器可以利用代码段和数据段之间不变的距离, 产生对 a d d c n t 的直接 PC 相对引用,并 增加一个重定位, 让链接器在构造这个共享模块时解析它。不 过, 如果 a d d c n 七是由另一个共享模块定义的, 那么就需要通过 G O T 进行间接访问。在这里, 编译器选择采用最通用的解决方案, 为所有的引用使用 G O T 。\n. P IC 函数调用\n假设程序调用一个由共享库定义的函数。编译器没有办法预测这个函数的运行时地址, 因为定义它的共享模块在运行时可以加载到任意位置。正常的方法是为该引用生成一条重定 位记录, 然后动态链接器在程序加载的时候再 解析它。不过, 这种方法并不是 PIC, 因为它需 要链接器修改调用模块的代码段 , GNU 编译系统使用了一种很有趣的技术来 解决这个间题 , 称为延迟绑定 Clazy binding), 将过程地址的绑定推迟到第 一次调用该过程时。\n使用延迟绑定的动机是对于一个像l i b c . s o 这样的共享库输出的成百上 千个函数中, 一个典型的应用程序只会使用其中很少的一部分。把函数地址的解析推迟到它实际被调用的地 方,能避免动态链接器在加载时进行成百上千个其实并不需要的重定位。第一次调用过程的运行时开销很大 , 但是其后的每次调用都只会花费 一条指令和一个间接的内存引用。\n延迟绑定是通过两个数据结构之间简洁但又有些 复杂的交互来实现的, 这两个数据结\n@\n笫 7 章 链 接 491\n构是: G O T 和过程链接表( P r o ce d u r e L in k a g e T a b le , PL T ) 。如果一个目标模 块调用定义在共享库中的 任何 函数, 那么它就有自己 的 G O T 和 P L T 。G O T 是数据段的一部分, 而P L T 是代码段的一部分。\n图 7-1 9 展示的是 P L T 和 G O T 如何协作在运行时解析函数的地址。首先,让 我们检查一下这两个表的内容。\n过程链接表 ( P L T ) 。P L T 是一个数组, 其中每个条目是 1 6 字节代码。P LT [ O] 是一个特殊条目, 它跳转到动态链接器中。每个被可执行程序调用的库函数都有它自己的 P L T 条目。每个条目 都负责询用一个具体的函数。 PLT [ l ] ( 图中未显示)调用系统启动函数(__ 让 b c _ s 七a 江 _ m a in ) , 它初始化执行环境, 调用 ma i n 函数并处理其\n返回值。从 P LT [ 2 ] 开始 的条目调用用户代码调用的函数。在我们的例子中, P LT [ 2 ] 调用 a d d v e c , PLT [ 3 ] ( 图中未显示e)调用pr i n t f 。\n全局偏移量表 ( G O T ) 。正如我们看到的, G O T 是一个数组, 其中每个条目是 8 字节地址。和 P L T 联合使用时 , GOT [ OJ 和 GOT [1 ] 包含动态链接器 在斛析函数地址时会使用的信息。GOT [ 2 ] 是动态链接器在 l d - l i n u x . s o 模块中的入口点。其余的每个条目 对应千一个被调用的函数, 其地址需要在运行时被解析。每个条目都有一个相匹配的 P L T 条目。例如, GOT [ 4 ] 和 P L T [ 2 ] 对应于 a d d v e c 。初始时, 每个 G O T 条目都指向对应 P L T 条目的第二条指令。\n数据段\n全局偏移量表 (GOT)\nGOT [ O] : addr of .yd na mi c GOT[l]: addr of reloc entries GOT [ 2 ] : addr of dynamic linker GOT [ 3 ] : Ox 40 05 b 6 # sys startup\nGOT[4]: Ox 40 0 5c 6 # addvec() GOT[S]: Ox 400 Sd 6 # printf()\nI\n(Z) C\na ) 第一 次调用 a dd v e c b ) 后续再调用a ddv e c\n图 7-19 用 P LT 和 GO T 调用外部 函数。在第一次调用 a ddve c 时 , 动 态 链 接 器 解 析 它 的 地 址\n图 7 - 1 9 a 展示了 G O T 和 P L T 如何协同工作, 在 a d d v e c 被第一次调用时, 延迟解析它的运行时地址:\n第 1 步。不直 接调用 a d d v e c , 程序调用进入 P LT [2], 这是 a d d v e c 的 P L T 条目。 笫 2 步。第一 条 P L T 指令通过 GOT [ 4 ] 进行间接跳转。因为每个 G O T 条目初始时都指向它对应的 P L T 条目的第二条指令, 这个间接跳转只 是简单地把控制传送回PLT [ 2 ] 中的下一条指令。 . 第 3 步。 在把 a dd v e c 的 ID ( Ox l ) 压入栈中之后, P LT [ 2 ] 跳转到 PLT [ OJ 。\n第 4 步。 PLT [ O] 通过 GOT [ l ] 间接地把动态链接器的一个参数压入栈中, 然后通过GOT [2 ] 间接跳转进动态链接器中。动态链 接器使用两个栈条目来确定 a d d v e c 的运行时位置,用 这个地址重写 GOT [ 4 ] , 再把控制传递给 a d d v e c 。\n图 7- 1 9b 给出的是后续再调用 a d d v e c 时的控制流 :\n. 第 1 步。 和前面一样, 控制传递到 PLT [ 2 ] 。\n第 2 步。不过 这次通过 GOT [ 4 ] 的间接跳转 会将控制直接转移到 a d d v e c 。 7. 13 库打桩机制\nLin u x 链接器支持一个很强大的技术, 称为库打桩(l ib ra r y interpositioning), 它允许你截获对共享库函数的调用,取而代之执行自己的代码。使用打桩机制,你可以追踪对某 个特殊库函数的调用次数,验证和追踪它的输入和输出值,或者甚至把它替换成一个完全不同的实现。\n下面是它的基本思想:给定一个需要打桩的目标函数,创建一个包装函数,它的原型\n与目标函数完全一样。使用某种特殊的打桩机制,你就可以欺骗系统调用包装函数而不是 目标函数了。包装函数通常会执行它自己的逻辑,然后调用目标函数,再将目标函数的返 回值传递给调用者。\n打桩可以发生在编译时、链接时或当程序被加载和执行的运行时。要研究这些不同的 机制,我 们以图 7- 20a 中的示例程序作为运行 例子。它调用 C 标准库 ( li b c . s o ) 中的 ma l ­ l a c 和 fr e e 函数。对 ma l l o c 的调用从堆中分配一个 32 字节的块, 并返回指向该块的指针。对 fr e e 的调用把块还回到 堆, 供后续的 ma l l o c 调用使用。我们的目标是用打桩来追踪程序运 行时对 ma l l o c 和 f r e e 的调用。\n7. 13. 1 编译时打桩\n图7-20 展示了如何使用 C 预处理器在编译时打桩。myma l l o c . c 中的包装函数(图7-20c) 调用目标函数 , 打印追踪记录, 并返回。本地的ma l l o c . h 头文件(图7- 20 b) 指示预处理器用对相应包装函数的调用替换掉对目标函数的调用。像下面这样编译和链接这个程序:\nlinux\u0026gt; gee -DCOMPILETIME -e mymalloe. e linux\u0026gt; gee -I. -o inte int.e mymalloe.o\n由于有- I. 参数, 所以会进行打桩,它 告 诉 C 预处理器在搜索通常的系统 目录之前, 先 在当前目录中查 找 ma l l o c . h 。注意 , myma l l o c . c 中的包装函数是使用标准 ma ll oc . h 头文件编译的。\n运行这个程序会得到如下的追踪信息:\nlinux\u0026gt; ./intc malloc(32)=0x9ee010 free(Ox9ee010)\n7. 13 . 2 链接时打桩\nLin ux 静态链接器支持用 - -wrap f 标志进行链接时打桩。这个标志告诉链接器, 把对符号 f 的引用解析成_ _wr a p_ f ( 前缀是两个下划线),还 要把对符号——r e a l _ f ( 前缀是两个下划线)的引用解析为 f 。图 7- 21 给出我们示 例程序的包装函数。\n#include \u0026lt;stdio.h\u0026gt;\n#include \u0026lt;malloc.h\u0026gt;\nint main()\n{\ncode/link/interpose/int.c\nint *P = malloc(32); free(p);\nreturn(O);\n}\n示例程序 i nt . c codellinklinterposelint.c cod e/lin k/interp osel m a l/oc .h\n#define malloc(size) mymalloc(size)\n#define free(ptr) myfree(ptr)\nvoid *mymalloc(size_t size); void myfree(void *ptr);\ncode/linklinterpose/malloch.\n#if def COMPILETIME\n#include \u0026lt;s t di o . h\u0026gt;\n#include \u0026lt;mal l oc . h \u0026gt;\n本地 mall oc . h 文件\ncode/link/interpose/mymalloc.c\nI* malloc wrapper function *I voi d *mymalloc(size_t si ze)\n{\nvoid *ptr = mal l oc (s i z e ) ;\nrp i nt f ( \u0026ldquo;m all oc (%d ) =%p\\ n\u0026rdquo; , (int)size, ptr);\nreturn ptr;\n}\nI* free 口rapperfunct ion *I void myfree(void *ptr)\n{\nfree(ptr);\nprintf (\u0026ldquo;free(%p) \\ n\u0026rdquo; , ptr);\n}\n#enidf\ncode/ lin k/int erp ose/mymalloc.c\nmyma l l oc . c 中的 包 装函数 图7- 20\n用 C 预处理器进行编译时打桩\n用下述方法把这些源文件 编译成可重定位目标文件:\nl i n ux \u0026gt; gee - DLI NKTIME -e myma l l oe . e l i nu x \u0026gt; gee -e int.e\n然后把目标文件链接成可执行文件:\nlinux\u0026gt; gee - Wl , - - wr pa , ma l l o e - Wl , - - wr ap , fr e e -o int;l i nt;.o myma l l oe . o\n- Wl , o p t i o n 标志把 o p t i o n 传递给链接器。o p 巨 o n 中的 每个逗号都要替 换为一个空\n纵ii\n494 笫二部分 在系统上运行程序\n格。所以- Wl , - -wrap, ma l l o c 就 把 - -wrap ma l l o c 传 递给链接器,以 类 似的方式传递\n-Wl, - -wrap, fr ee。\ncode/linklinterpose/mymalloc.c\n#ifdef LINKTIME\n2 #include \u0026lt;stdio.h\u0026gt;\n3\n4 void * real_malloc(size_t size);\n5 void real_free(void *ptr);\n6\n7 I* malloc -wrapper function *I\n8 void * -wrap_malloc(size_t size)\n9 {\n10 void *ptr = real_malloc(size); I* Call libc malloc *I\n11 printf(\u0026ldquo;malloc(%d) = %p\\n\u0026rdquo;, (int)size, ptr);\n12 return ptr;\n13 }\n14\nI* free -wrapper function *I\nvoid -wrap_free(void *ptr)\n17 {\nreal_free(ptr); I* Call libc free *I\nprintf(\u0026ldquo;free(%p)\\n\u0026rdquo;, ptr);\n20 }\n21 #endif\ncode/linklinterposelmymalloc.c\n图 7-21 用 一 wr ap 标志进行链 接时打桩\n运行该程序会得到如下追踪信息:\nlinux\u0026gt; ./intl malloc(32) = Ox18cf010 free(Ox18cf010)\n7. 13 . 3 运行时打桩\n编译时打桩需要能够访问程序的源代码,链接时打桩需要能够访问程序的可重定位对 象文件。不过, 有一种机制能够在运行时打桩.它只需要能够访间可执行目标文件。这个很 厉 害 的 机 制基于动态链接器的 LD_ P RE LOAD 环境变量。\n如果 LD—PRE LO AD 环境变量被设置为一个共享库路径名的列表(以空格或分号分隔),\n那 么 当 你 加 载和执行一个程序,需 要 解析未定义的引用时, 动 态 链 接器 ( L D- 荨 NUX. SO) 会先 搜 索 L D—P RE LOAD 库,然 后 才 搜索任何其他的库。有了这个机制, 当你加载和执行任意可 执 行 文 件 时 ,可 以 对任何共享库中的任何函数打桩,包 括 让b e . s o。\n图 7- 22 展示了 ma l l o c 和 fr e e 的包装函数。每个包装函数中,对 d l s ym 的 调用返回指向目标 l i b c 函数的 指针 。然后包装函数调用目标函数, 打印 追踪记录,再 返回。\n下面是如何构建包含这些包装函数的共享库的方法:\nlinux\u0026gt; gee -DRUNTIME -shared -tpie -o mymall oe. so mymall oe. e - ldl\n这是如何编译主程序:\nlinux\u0026gt; gee -o intr int.e\ncodellinklinterpose/mymalloc.c\noc.c\n图 7- 22 用 LD_ PRELOAD 进行运行时打桩\n下面是如何从 b a s h s h e ll 中运行这个程序 气\nlinux\u0026gt; LD_PRELOAD=\u0026rdquo; ./mymalloc. so\u0026quot; ./intr malloc(32) = Ox1bf7010\nfree(Ox1bf7010)\ne 如果你不知道运行的 shell 是哪一种,在命 令行上输人 pr i nt en v SHE LL.\n下面是如何 在 c s h 或 t c s h 中运行这个程序:\nlinux\u0026gt; (setenv LD_PRELOAD 11 ./mymalloc. so\u0026quot;; ./ i ntr ; unsetenv LD_PRELOAD) malloc(32) = Ox2157010\nfree(Ox2157010)\n请注意 , 你可以用 LD_ PRELOAD 对任何可执行 程序的库函数调用打桩!\nlinux\u0026gt; LD_PRELOAD=\u0026quot; ./mymalloc. so\u0026quot; /usr/bin/uptime malloc(568) = Ox21bb010\nfree(Ox21bb010) malloc(15) = Ox21bb010 malloc(568) = Ox21bb030 malloc(2255) = Ox21bb270 free(Ox21bb030) malloc(20) = Ox21bb030 malloc(20) = Ox21bb050 malloc(20) = Ox21bb070 malloc(20) = Ox 21 bb090\nma ll oc( 20 ) = Ox 21bb0b0 malloc(384) = Ox2 1 bb0d0\n20:47:36 up 85 days , 6:04, 1 user, load average: 0.10, 0.04, 0.05\n14 处理目标文件的工具\n在 L in u x 系统中有大量可用 的工具 可以帮助你理解和处理目标文件。特别地, GNU\nb in ut ils 包尤其有帮助, 而且可以 运行在每个 L in ux 平台上。\nA R : 创建静态库 , 插入、删除、列出和提取成员。\nST RINGS : 列出一个目标文件中所 有可打印 的字符串。\nST RIP : 从目标文件中 删除符号表信息。\nNM : 列出一个目标文件的符号表中定义的符号。\nSIZE : 列出目标文件中节的名字和大小。\nR E A D E L F : 显示一个目标文件的完整结构, 包括 E L F 头中编码的所有信息。包含\nS I ZE 和 NM 的功能。\nOBJDUMP: 所有二进制工具之母。能够显示一个目标文件中所有的信息。它最大的 作用是反汇编 . t e x t 节中的二进制指令 。\nLin u x 系统为操作 共享库还提供了 L DD 程序 :\nLDD: 列出一个可执行文件在运 行时所需要的共享库。\n7. 15 小结\n链接可以在编译时由 静态编译 器来 完成 ,也可以在加载时 和运行时由动态链 接器 来完成。链接器处理称为目标 文件的 二进制文件 ,它有 3 种不同的形式: 可重定 位的、可执 行的和共享的。可重定位的目标文件由静态链接器合并 成一个可执行的目标文件, 它可以 加载到内存中并执行。共 享目标 文件(共享库)是在运行时由动态链接器链接 和加载的 , 或者隐含地在凋用程序被加 载和开始执行时 , 或者根据需要在程序调用 dl ope n 库的函数时。\n链接器的 两个主要任务是符号解析 和重定位 , 符号解析将目标 文件中的 每个全局符号都绑定到一个唯一的定义, 而重定位确定 每个符号的最终内存地址,并修改对那些目标的引 用。\n静态链接器是由像 GCC 这样的 编译驱动程序调用的 。它们将多个可重定位目标 文件合并成一个单独的可执行目标文件。多个目标文件可以定 义相同的符号, 而链接器用来 悄悄地解析这些 多重定义的规则可能在用户程 序中引人微妙的错误 。\n多个目标文件可以 被连接到一个单独的静态库中。链接器用库来解析其他目标模块中的符号引用。许多链接器通 过从左到右的顺序扫描来解析符号引用, 这是另一个引起令人迷惑的链接时错误的来源。\n加载器将可执行文件的内 容映射到内存,并 运行这个程序。链接器还可能生成部分链接的可执行目标文件 , 这样的文件中有对定 义在共享库 中的例程 和数据的 未解析的引用。在加载时,加 载器将部分链接的可执行文件映 射到内存 , 然后调用动态链接器 , 它通过 加载共享库和重定位程序中的引用来完成链接任务 。\n被编译 为位 置无关代码的共享库可以加载到任何地方 , 也可以在运行时被多个进程共享。为了加载、链接和访问 共享库的函数和数据 , 应用程序也可以在运行时 使用动态链接器。\n参考文献说明 # 在计算机系统 文献中并 没有很好地 记录链接。因为链 接是处在编译器、计算机体系结构和操作系统的交叉点上, 它要求理解代码生成、机器语 言编程、程序实例化和虚拟内存。它没有恰好落在某个通常 的计算机系统领域中 , 因此这些领域的经典文献并 没有很 好地描述它。然而,Le vin e 的专著提供了有关 这个主题的很好的一般性参考资 料[ 69] 。[ 54] 描述了 ELF 和 DW AR F ( 对. de b ug 和 . l i ne 节内容的规范) 的原始 IA32 规范。[ 36] 描述了对 E LF 文件格式的 x86-64 扩展。x8 6-64 应用二进制接口( ABD 描述了编译、链接和运行 x86-64 程序的惯 例, 其中包括重定位和位置无关代码的规则[ 77] 。\n家庭作业\n7. 6 这道题是关于图 7-5 的 rn. o 模块和下面的 s wa p . c 函数版本的 , 该函数计算 自已被调用的次数 :\nextern int buf[];\nint *bufpO = \u0026amp;buf[O]; static int *bufp1;\nstatic void incrO\n{\nstatic int count=O; count++;\nvoid swap()\n{\nint temp;\nincr();\nbufp1 = \u0026amp;buf[1]; temp= *bufpO;\n*bufpO = *bufp1;\n*bufp1 = temp;\n对于每个 s wa p . o 中定 义和引用的 符号,请指出 它是否在模块 s wa p . o 的 . s ymt a b 节中有符 号表条目。如果是这样 ,请 指出定义该符号的模块( s wa p . o 或 m. o ) 、符号类型(局部、全局或外部)以及它在模块中所处的节( . t e x t 、. da t a 或 . b s s ) 。\n7. 7\n7. 8\n不改变任何 变量名字 , 修改 7. 6. 1 节中的 b ar S . c , 使得 f o o S . c 输出 x 和 y 的正确值(也就是整数\n15 213 和 1 521 2 的十六进制表示)。\n在此题中, REF (x , i ) \u0026mdash;+ DEF (x , k ) 表示链 接器将任意对模 块 1 中符号 x 的引用与模块 k 中符号 x 的定义相关联。在下面每个例子中,用这种符号来说明链接器是如何解析在每个模块中有多重定义的 引用的。如果出现链接时 错误(规则1 ) • 写“错误"。如果 链接器从定义中任意选择一个(规则3)\u0026rsquo; 那么写“未知”。\nI• Module 1•/\nint main()\nI* Module 2 *I\nstatic int main=l [ int p20\n{\n}\nREF(main.1) - DEF(.) ( REF(main.2) -+ DEF( . )\nI* Module 1 *I I* Module 2 *I\nint x; double x;\nvoid main() int p20\n{ {\n} }\n(a) REF(x.1)-+ DEF(_ .)\n(b) REF(x.2)- DEF(._ — ._ _ )\nI* Module 1 *I I* Module 2 *I\nint x=l; double x=l.0;\nvoid main() int p2()\n{ {\n} }\n(a) REF(x.1) - DEF( - ·- )\nREF(x.2) - DEF_ (_\n— — - ·\u0026mdash; ·-—一 .)\n7. 9\n考虑下面的程序,它由两个目标模块组成:\nI* foo6.c *I\nvoid p2(void);\nint main()\n{\np20;\nreturn O;\n}\nI* bar 6 . c *I\n#include \u0026lt;stdio.h\u0026gt; char main;\nvoid p20\n{\nprintf(\u0026ldquo;Ox%x\\n\u0026rdquo;, main);\n}\n•• 7. 10\n当在 x8 6- 64 L in ux 系统中编译和执行这个 程序时 ,即 使函数 p 2 不初始化变 量 ma i n , 它也能打印字符串 \u0026quot; Ox 48 \\ n\u0026quot; 并正常终止。你能 解释这一点 吗?\na 和 b 表示当前 路径中的目标模 块或静 态库 , 而 a -+-b 表示 a 依赖于 b , 也就是说 a 引用了一个 b\n定义的符办 。对千下 面的每个场 景, 给出使得静态链 接器能够解析所有符号引用的最小的命令行\n(即含有最少数量的目标文件和库参数的命令)。\np.o-libx.a-p.o\np. o-+ libx.a- 让by.a 和l i by.a- 江bx.a\nC.p.o- 巨bx.a- 且by.a- l ibz.a 和l i by.a-libx.a-+libz.a\n.. 7. 11 图 7-14 中的程序头部表明 数据段占用了内存中 Ox230 个字节。然而 , 其中只有开始的 Ox228 字节来自可执行文件的节。是什么引起了这种差异?\n•• 7. 12 考虑目标 文件 m. o 中 对函数 s wa p 的调用(作业题7. 6 ) 。\n9: e8 00 00 00 00 callq e \u0026lt;main+Oxe\u0026gt; swap()\n具有如下重定位条目:\nr.offset = Oxa r.symbol = swap\nr.type = R_X86_64_pc32 r.addend = -4\n假设链接器将 m . o 中的. t e xt 重定位到地址 Ox 4004e 0 , 把 s wa p 重定位到地址 Ox 4004f 8。那么c a ll q 指令中对 s wa p 的 重定位引用的值应该是什么?\n假设链 接器将 m. o 中的 . t e x t 重定位到地址 Ox 4004d 0 , 把 s wa p 重定位到地址 Ox 400500 。那么\nc a l l q 指令中对 s wa p 的 重定位引用的值应该是什么?\n•• 7. 13 完成下面的任务将帮助你更熟悉处理目标文件的各种工具。\n在你的系统上,巨 b . c 和 li bm. a 的版本中包含多少目标文件?\ng c c - Og 产生的 可执行 代码与 g c c - Og - g 产生的不同吗?\n在你的系统上 , GCC 驱动程序使用的是 什么共享库?\n练习题答案 # 7. 1 这道练习题的目 的是帮助你理解链接器符号 和 C 变最及函数之间的关系。注意 C 的局部 变最 t e mp\n没有符号表条目。\n符号 . s ymt a b 条目? 符号类型 在哪个模块中定义 廿Tl buf 定曰 外部 ma1.n.o .data bufpO 是 全局 swap.o . dat a bufpl 定巨 全局 swap.o COMMON swap 是 全局 swap.o .text temp 否 2 这是一个简单的 练习,检查你对 Unix 链接器解析 在一个以上模块中有定 义的 全局符号时所使用规则的理解。理解 这些规则可以 帮助你避免一些 讨厌的编程错误。\n链接器选择定 义在模块 1 中的强符号, 而不是定义在模块 2 中的弱符号(规则 2) : REF(ma i n.1) DEF(main.1) REF( ma i n .2) DEF(main.1) 这是一 个错误 , 因为每个模块 都定义了一个强符号 ma i n( 规则 1 ) 。 链接器选择定义 在模块 2 中的强符号, 而不是定 义在模块 l 中的弱符号(规则2) : REF(x.1) DEF(x.2)\nREF(x.2) DEF(x.2)\n7 3 在命令行中以错误的顺序放置静态库是造成令许多程序员迷惑的链接器错误的常见原因。然而,\n旦你理解了链 接器是如何使用 静态库来 解析引用的 , 它就相当简单易 懂了。这个小 练习检查了 你对这个概念的理解:\nlinux\u0026gt; gee p.o li bx. a\nlinux\u0026gt; gee p.o libx.a liby.a\nl inux\u0026gt; gee p.o l i b x. a liby.a libx.a\n4 这道题 涉及的是 图 7-1 2a 中的反汇编列表。目的是让 你练习阅读反汇编列 表, 并检查你 对 PC 相对\n寻址的理解。\n第 5 行被重定位引用的十六进制地址为 Ox 4004d f 。 第 5 行被重定位引用的 十六进制值为 Ox5 。 记住, 反汇编列 表给出的引 用值是用小端法字节顺序表示的。\n7. 5 这道题 是测试你对链 接器重定 位 PC 相对引用的理解的。给定\nADDR(s) = ADDR(. text) = Ox4004d0\n和\nADDR(r.symbol) = ADDR(swap) = Ox4004e8\n使用图 7-10 中的算法, 链接器首先 计算引用的运行时 地址:\nrefaddr = ADDR(s) + r.offset\n= Ox4004d0 + Oxa\n= Ox4004da\n然后修改 此引用:\n*refptr = (unsigned) (ADDR(r.symbol) + r.addend - refaddr)\n= (unsigned) (Ox4004e8 + (-4) - Ox4004da)\n- (un s i gned ) (Oxa)\n因此, 得到的 可执行目标文件中 , 对 s wa p 的 P C 相对引用的值为 Oxa : 4004d9: e8 Oa 00 00 00 callq 4004e8 \u0026lt;swap\u0026gt;\n"},{"id":444,"href":"/zh/docs/technology/System/%E6%B7%B1%E5%85%A5%E7%90%86%E8%A7%A3%E8%AE%A1%E7%AE%97%E6%9C%BA%E7%B3%BB%E7%BB%9F/%E4%B9%A6%E7%B1%8D/%E7%AC%AC8%E7%AB%A0-%E5%BC%82%E5%B8%B8%E6%8E%A7%E5%88%B6%E6%B5%81/","title":"Index","section":"SpringCloud","content":"第 8 章\nC H A P T E R 8\n异常控制流\n从给处理器加电开始,直到你断电为止,程序计数器假设一个值的序列\na0 , a , , \u0026hellip; , a ,, _1\n其中,每个 ak 是某个相应的指令 I k的地址。每次从 Qk 到 a k一 1 的过渡称为控 制 转移 ( co ntro l trans £er ) 。 这样的 控制转移 序列叫做处理器的控制流( flow of cont rol 或 cont ro l flow ) 。\n最简单的一种控制流 是一个“平滑的" 序列, 其中每个 L 和 I尸!在内存中都是相邻\n的。这种 平滑流的 突变(也就是 I尸]与 L 不相邻)通常是由诸如跳转 、调用和返回这样一些熟悉的 程序指令造成的 。这样一些指令都是必要的机制, 使得程序能够对由程序变扯表示的内部程序状 态中的 变化做出反应 。\n但是系统也必须能够对系统状态的变化做出反应,这 些系统状态不是被内部程序变量捕获的, 而且也不一定要和程序的执行相关。比如, 一个硬件定时 器定期产生信号 , 这个事 件必须得到处理 。包到达网络适配器后,必 须存放在内存中。程序向磁盘请求数据, 然后休眠, 直到被通知说数据巳就绪。当子进程终止时, 创造这些子进程的父进程必须得到通知。\n现代系统通过使 控制流发生突 变来对这些情况做出反应。一般而言, 我们把这些突变称为异常控制流 ( Exceptiona l Control Flow, ECF) 。异常控制流发 生在计算机系统的各个层次。比如, 在硬件层, 硬件检测到的 事件会触发控制突 然转移到异常处理程序。在操作系统层 ,内 核通过上下 文切换 将控制从一个用户进程转 移到 另一个用户进程。在应用层, 一个进 程可以发送信 号到 另一个进程, 而接收者会将控制 突然转移到它的一个信号处理程序。一个程序可以通 过回避通常的栈规则 , 并执行到其他 函数中任意位置的非本地跳转来对错误做出反应 。\n作为程序员, 理解 ECF 很重要 , 这有很多原因:\n理解 ECF 将帮助 你理解重要 的 系统概念。ECF 是操作系统 用来实现 I/ 0 、进程和虚拟内存的基本机制。在能够真正理解这些 重要概念之前 , 你必须理解 ECF 。\n理解 ECF 将帮助你理 解应 用程序是如何与操作 系统交互的 。应用程序 通过使用一个叫做陷阱 ( t ra p) 或者 系统调 用 ( s ys tem call ) 的 ECF 形式, 向操作系统请求服务。比如,向磁盘写数据、从网络读取数据、创建一个新进程,以及终止当前进程,都 是通过应用程序调用系统调用来实现的。理解基本的 系统调用机制将帮助你理 解这些服务是如何提供给应用的。\n理解 ECF 将帮 助 你 编写 有趣的 新应 用程 序。操作系统为应用程序提供了强大的\nECF 机制,用 来创建新进程、等待进程终止 、通知其他进程系统 中的异常事件, 以及检测和响应这些 事件。如果理解了这些 ECF 机制, 那么你就能用它们来编写诸如 U nix shell 和 Web 服务器 之类的有趣程序 了。\n理解 ECF 将帮助你理 解并发 。ECF 是计算机系统 中实现并发的基本机制。在运行中的并发的例子有:中断应用程序执行的异常处理程序,在时间上重叠执行的进程 和线程 , 以及中断应用程序执行的信号处理程序。理解 ECF 是理解并发的第一步。我们会 在第 12 章中更详细地研究并 发。\n理解 ECF 将帮助你理解软件异常如何 工作。像 C+ + 和 J a va 这样的语言通过 t r y、c a t c h 以及 t hr o w 语 句 来 提供软件异常机制。软件异常允许程序进行非 本地跳转\n(即违反通常的调用/返回栈规则的跳转)来响应错误情况。非本地跳转是一种应用 层 ECF , 在 C 中是通过 s e t j mp 和 l o ng j mp 函 数 提供的。理解这些低级函数将帮助你理解高级软件异常如何得以实现。\n对系统的学习,到目前为止你巳经了解了应用是如何与硬件交互的。本章的重要性在 千你将开始学习应用是如何与操作系统交互的。有趣的是, 这些交互都是围绕着 ECF 的。我们将描述存在千一个计算机系统中所有层次上的各种形式的 ECF。从异常开始, 异常位于 硬 件和操作系统交界的部分。我们还会讨论系统调用,它 们 是 为应用程序提供到操作系统 的 入口点的异常。然后, 我们会提升抽象的层次,描 述 进程和信号, 它 们 位 于应用和操作系统的交界之处。最后讨论非本地跳转, 这是 ECF 的一种应用层形式。\n1 异常\n异常是异常控制流的一种形式,它一部分由硬件实现,一部分由操作系统实现。因为它们有一部分是由硬件实现的,所以具体细节将随系统的不同而有所不同。然而,对于每个系统而言,基本的思想都是相同的。在这一节中我们的目的是让你对异常和异常处理有一个一般性的了解,并 且 向 你 揭示现代计算机系统的一个经常令人感到迷惑的方面。\n异常( exception ) 就 是控制流中的突\n变,用来响应处理器状态中的某些变化。图 8-1 展示了基本的思想。\n在图中,当处理器状态中发生一个\n事件在\n\u0026hellip;\u0026hellip;\u0026hellip;\u0026hellip;.. .\n应用程序 异常处理程序\nIcurr\n重要的变化时,处 理 器正在执行某个当前指令 J curr 。在处理器中,状 态被编码为不同的位和信号。状态变化称为事件(event) 。事件可能和当前指令的执行直接相关。比如,发 生虚拟内存缺页、算\n这里发生 / next\n术溢出,或者一条指令试图除以零。另 图 8- 1 异常的剖析。处理器状态中的变化(事件 )触发从\n应用程序到异常处理程序的突发的控制转移(异\n一方面,事件也可能和当前指令的执行\n没有关系。比如,一个系统定时器产生信号或者一个1/0 请求完成。\n常)。在异常处理程序完成处理后,它将控制返回给被中断的程序或者终止\n在任何情况下, 当处 理器检测到有事件发生时, 它 就 会通过一张叫做异常表( excep- tion ta ble ) 的跳转表,进 行 一 个间 接过程调用(异常), 到 一 个 专门设计用来处理这类事件 的 操 作 系统子程序(异常 处理程序( exce pt io n ha ndle r ) ) 。当异常处理程序完成处理后,根 } 据引 起异常的事件的类型, 会发生以下 3 种情况中的一种:\n处 理 程 序将控制返回给当前指令 ICUTT \u0026rsquo; 即当事件发生时正在执行的指令。\n) 处理程序将控制返回给 [ next • 如果没有发生异常将会执行的下一条指令。\n) 处理程序终止被中断的程序。\n8. l. 2 节将讲述关于这些可能性的更多内容。\n囚 日 硬件异常与软件异常\nC ++ 和 J ava 的程序员会 注意 到术语“异常” 也 用 来描述由 C+ + 和 J a va 以 c a t ch、\nt h r o w 和 七r y 语句形 式提供的应用级 ECF 。如 果想严格 清晰, 我们必须区别“ 硬件” 和\n“软 件” 异 常,但 这通常是不必要的, 因 为从 上 下文中就能够很 清楚 地知道是哪种含义。\n1. 1 异常处理\n异常可能会难以理解,因为处理异常需要硬件和软件紧密合作。很容易搞混哪个部分执行哪个任务。让我们更详细地来看看硬件和软件的分工吧。\n系统中可能的每种类型的异常都分配了一个唯一的非负整数的异常号 ( exce ptio n n um ­ her ) 。其中一些号码是由处理器的设计者分配的, 其 他 号 码 是 由 操 作系统内核(操作系统常驻内存的部分)的设计者分配的。前者的示例包括被零除、缺页、内存访问违例、断点 以及算术运算溢出。后者的示例包括系统调用和来自外部 I / 0 设备的信号。\n在系统启动时(当计算机重启或者加电\n时), 操作系统分配和初始化一张称为异常表的跳转表,使 得表目 K 包 含异常 k 的处理程序的地址。图 8-2 展 示了异常表的格式。\n在运行时(当系统在执行某个程序时),处 理器检测到发生了一个事件,并且确定了相应 的异常号 k。随后, 处理器触发异常,方 法是执行间 接过程调用,通 过异常表的表目 k , 转到相应的处理程序。图 8-3 展示了处理器如何\n二1\n异常处理程序0的代码\n异常处理程序l的代码异常处理程序2的代码\n上\n使用异常表来形成适当的异常处理程序的地址。 图 8-2 异常表。异常表是一 张跳转表, 其中表目 K\n异常号是到异常表中的索引,异常表的起始地 包含异常k 的处理程序代码的地址\n址放在一个叫做异常表基 址寄存器( e xce ption table base register ) 的 特殊 CPU 寄存器里。\n异常号\n(X 84)\ni 异常表\nn- 11 I\n图 8-3 生成异常处理程序的地址 。异常号是到异常表中的索引\n异常类似于过程调用 ,但 是有一些重要的不同之处:\n过程调用时,在跳转到处理程序之前,处理器将返回地址压入栈中。然而,根据异常的类型,返回地址要么是当前指令(当事件发生时正在执行的指令),要么是下一 条指令(如果事件不发生,将 会在当前指令后执行的指令)。 处理器也把一些额外的处理器状态压到栈里,在 处理程序返回时, 重新开始执行被中断的程序会需要这些状态。比如, x86-64 系统会将包含当前条件码的 EF LAGS 寄存器和其他内容压入栈中。 如果控制从用户程序转移到内核,所 有这些项目都被压到内核栈中, 而不是压到用户栈中。\n异常处理程序运行在内核模式下(见 8. 2. 4 节), 这意味着它们对所有的系统资源都有完全的访间权限。\n一旦硬件触发了异常,剩下的工作就是由异常处理程序在软件中完成。在处理程序处理完事件之后,它通过执行一条特殊的“从中断返回”指令,可选地返回到被中断的程\n序,该指令将适当的状态弹回到处理器的控制和数据寄存器中,如果异常中断的是一个用户程序 , 就将状态恢复为用 户模式(见8. 2. 4 节), 然后将控制返回给被中断的程序。\n1. 2 异常的类别\n异常可以分为四类: 中断 ( interru pt ) 、陷阱( tra p) 、故障( fault) 和终止( abort )。图 8-4 中的表对这些类别 的属性做了小结。\n类别 原因 异步/同步 返回行为 中断 来自 1/0 设备的信号 异步 总是返回到下一条指令 陷阱 有意的异常 同步 总是返回到下一条指令 故障 潜在可恢复的错误 同步 可能返回到当前指令 终止 不可恢复的错误 同步 不会返回 图 8-4 异 常 的 类 别 。 异步异常是由处理器外部的 I/ 0 设备中的事件产生的。同 步异常是执行一条指令的直接产物\n中断\n中断是 异步发生的 , 是来自处理器外部的I/ 0 设备的信号的结果。硬件中断不是由任何一条专门的指令造成的,从 这个意义上来说它是异步的。硬件中断的异常处理程序常常称为中断处理 程序 ( in t er ru pt hand ler ) 。\n图 8-5 概述了一个中断的处理。I/ 0 设备, 例如网 络适配器、磁盘控制器和定时 器芯片,通过向处理器芯片上的一个引脚发信号,并将异常号放到系统总线上,来触发中断, 这个异常号标识 了引起中断的设备。\n( I ) 在当前指令的执行过程中,中\nf cu斤\nI\n断引脚 电压变高了\nnext\n( 3 ) 中断处\n理程序运行\n图 8-5 中断处理。中断处理程序将控制返回给应用程序控制流中的下一 条指令\n在当前指令完成 执行之后, 处理器注意到中 断引脚的电压变高了, 就从系统总线读取异常号, 然后调用适当的中断处理程序。当处 理程序返 回时,它 就将控制返回给下一条指令(也即如果没有发生中断,在控制流中会在当前指令之后的那条指令)。结果是程序继续执行, 就好像没有发生过中断一样。\n剩下的异常类型(陷阱、故障和终止)是同 步发 生的,是 执行当前指令的结果。我们把这类指令叫做故障指令 ( fa ult ing ins t ru ct ion ) 。\n陷阱和系统调用\n陷阱是有意的 异常 , 是执行一条指令的结果。就像中断处理程序一样, 陷阱处理程序将控制返回到下一条指令。陷阱最重要的用途是在用户程序 和内核之间提供一个像过程一样的接口, 叫做系统调用。\n用 户程序经常需 要向内核请求服务, 比如读一个文件 (r e a d ) 、创建一个新的进程( for k ) 、加载一个新的程序( e x e c v e ) , 或者终止当前 进程( e x 江)。为了允许对这些内核服务的受控的访问, 处理器提供了一条特殊的 \u0026quot; s y s c a l l n \u0026quot; 指令, 当用户程序想要请求\n服务 n 时, 可以执行这条指令。执行 s y s c a l l 指令会导致一个到异常处理程序的陷阱, 这个处 理程序解析参数, 并调用适当的内核程序。图 8-6 概述了一个系统调用的处理。\n( 1 ) 应用程 s ys c a l l 序执行一次系 /next 统调用\n( 3 ) 陷阱处理程序运行\n图 8-6 陷阱处理。陷阱处 理程序将控制返回给应用程序控制流中的下一条指令\n从程序员的角度来看 ,系 统调用和普通的函数调用是 一样的。然而, 它们的实现非常不同。普通的函数运行在 用户 模式中,用 户模式限制了函数可以执行的指令的类型, 而且它们只能访问与调用函数相同的栈。系统调用运行在内核模 式中 ,内 核模式允许系统调用执行特权指令, 并访问定义在内核中的栈。8. 2. 4 节会更详细地讨论用 户模式和内核模式。\n故陪\n故障由错误 情况引起,它 可能能 够被故障处理程序修 正。当故障发生时, 处理器将控制转移给故 障处理程序。如果处理程序能够修正这个错误情况,它 就将控制返回到引起故障的指令 ,从 而重新执行它。否则, 处理程序返 回到内核中的 a bor t 例程, a b o 江 例程会终止引起故 障的应用程序。图 8-7 概述了一个故障的处理。\n( 3 ) 故障处理程序运行\n•••••••••••••••••.•… \u0026hellip; ..►.\n( 4 ) 处理程序要么重新执行当前指令,要么终止\nabort\n图 8-7 故障处 理。根据故障是否能够被修复,故 障 处 理 程序要么重新执行引起故障的指令,要 么 终 止\n一个经典的故 障示例是缺页异常, 当指令引用一个虚拟地址, 而与该地址相对应的物理页面不 在内存中, 因此必须从磁盘中取出时, 就会发生故障。就像我们将在第 9 章中看到的那样 , 一个页面就是 虚拟内存的一个连续的块(典型的是 4K B) 。缺页处理程序从 磁盘加载适当的 页面, 然后将控制返回 给引起 故障的指令。当指令再次执行时, 相应的物理页面已 经驻留在内存中 了, 指令就可以 没有故障地运行 完成了。\n终止\n终止是不 可恢 复的致命错误造成的结果, 通常是一些硬件错误, 比如 DR AM 或者\nSRAM 位被损坏时 发生的奇偶错误。终止处理程序从不将控制返回给应用程序。如图 8-8\n所示,处 理程序将控制返 回给一个 a bor t 例程, 该例程会终止这个应用 程序。\n1. 3 Linux/ x86-64 系统中的异常\n为了使描述更具体 , 让我们来看看为 x86-64 系统定义的一些异常。有高达 256 种不同的异常类型 [ 50] 。0 31 的号码对应的是由 Intel 架构师定义的异常, 因此对任何 x86-64 系统都是一样 的。32 255 的号码对应的是操作系统定义的中断和陷阱 。图 8-9 展示了一些示 例。\n( I ) 发生致命I\n的硬件错误\ncurr\n( 2 ) 传递控制给处理程序\n( 3 ) 终止处理程序运行\n\u0026hellip;\u0026hellip;\u0026hellip;\u0026hellip;…\u0026hellip;\u0026hellip;\u0026hellip;..…. \u0026hellip;\u0026hellip;..►.. abort\n( 4 ) 处理程序返回到\nabor t 例程\n图 8-8 终止处理。终止处理程序将控制传递给一个内核 abor t 例程,该 例 程会终止这个应用程序\n。异常号 描述 异常类别\n图 8-9 x86-64 系统中的异常示例\nLinux/ x86-6 4 故障和终止\n除法错误 。当应用试图除以零时, 或者当一个除法指令的结果对于目标操作数来说太大了的时候, 就会发生除法错误(异常 0 ) 。U nix 不会试图从除法错误中恢复, 而是选择终止程序。Linu x s hell 通常会把除法错误 报告为“ 浮点异常 ( F loa ting except io n ) \u0026quot; 。\n一般保护故障 。许多原因都会 导致不为人知的一般保护故障(异常 13 ) , 通常是因为一个程序引用了一个未定义的虚拟内存区域, 或者因为程序试图写一个只读的文本段。L in ux 不会尝试恢复这类故障。Lin ux s hell 通常会把这种一般保护故障报告为 "段故樟\n( S eg m e n tat io n fa ult ) \u0026quot; 。\n缺页(异常 14) 是会重新执行产生故障的指令的一个 异常示例。处理程序将适当的磁盘上虚拟内存的一个页面映射到物理内存的一个页面,然 后重新执行这条产生故障的指令。我们将在第 9 章中看到缺页是 如何工作的细节。\n机器桧 查。机 器检查(异常 18 ) 是在导致故障 的指令执行中检测到致命 的硬件错误时发生的。机器检查处理程序从不返回控制给应用程序。\nLinux/ 86-64 系统调 用\nL in ux 提供几百 种系统调用, 当应用程序想要请求内核服务时可以使用, 包括读文件、写文件或是创建一个新进程。图 8-10 给出了一些常见的 Lin ux 系统调用。每个系统调用都有一个唯一的整数号, 对应于一个到内核中跳转表的偏移址。(注意: 这个跳转表和异常表不一样 。)\nC 程序用 s ys c a l l 函数可以直接调用任何系统调用。然而,实 际中几乎没必要这么做。对于大多数系统调用, 标准 C 库提供了一组方便的包装函数。这些包装函数将参数打包到一 起, 以 适当的系统调用指令陷人内核, 然后将系统调用的返回状态传递回调用程序 。在本书中,我们将系统调用和与它们相关联的包装函数都称为系统级函数, 这两个术语可以互换地使用。\n在 x86- 64 系统上, 系统调用是通过 一条称为 s ys c a l l 的陷阱指令来提供的。研究程序能够如何使用这条指令来直接调用 L in u x 系统调用是很有趣的。所有到 Lin ux 系统调用的 参 数都是通过通用寄存器而不 是栈传递的。按照惯例, 寄存器%r a x 包含系统 调用号,\n寄存器%r d i 、%r s i 、%r d x 、%r 1 0 、r% 8 和%r 9 包含最多 6 个参数。第一个参数在% r 中 中,第\n二个在%r s i 中 , 以此类推。从系统调用 返回时, 寄存器%r c x 和%r ll 都 会被破坏,%r a x 包\n含返回值。—40 9 5 到一1 之间的 负 数返回值表明发生了错误, 对应于负的 er r n o 。\n编号\n图 8-10 Linux x86-64 系 统中 常用的系统调用示例\n例如, 考 虑 大家熟悉的 h e l l o 程序的下 面这个版本, 用 系统级函数 wr i t e ( 见 1 0 . 4\n节)来写,而 不是用 pr i n t f :\nint ma i n ()\n2 {\n3 vrite(l, \u0026ldquo;hello, vorld\\n\u0026rdquo;, 13) ;\n_e xi t ( O) ;\n5 }\nwr i t e 函 数的第一个参数将输出发送到 s t d o u 七。 第二个参数是要写的字节序列, 而第三个参数是要写的字节数。\n图 8-11 给出的是 h e l l o 程序的汇编语言版本, 直 接 使 用 s y s c a l l 指 令 来 调 用 wr i t e\n和 e x i t 系统调用。第 9 ~ 1 3 行调用 wr i t e 函 数 。 首先, 第 9 行将系统调用 wr i t e 的 编号存放在%r a x 中 , 第 1 0 ~ 1 2 行设 置 参数 列 表。然后第 1 3 行使用 s y s c a l l 指令来调用系统调用。类 似地,第 1 4 ~ 1 6 行调用_e x i t 系统调用。\ncode/ecf/hello-asm64.sa\n.section .dat a str i ng:\n. a s c i i \u0026ldquo;hello, vorld\\n\u0026rdquo;\ns t r i ng _e nd :\n.equ len, string_end - string\n.section .text\n.globl main\nmain:\nFirst, call write(1, \u0026ldquo;hello, world\\n\u0026rdquo;, 13)\nmovq $1, %rax write 1s system call 1\nmovq $1, %rdi Argl: stdout has descriptor 1\nmovq $s tr i ng , %rsi Arg2: hello world string\n12 mo v q $ l e n , %rdx Arg3: string length 13 syscall Make the system call Next, call _exit(O)\nmovq $60, %rax movq $0, %rdi syscall _ex1 t is system call 60 Arg1: exit status is 0 Make the system call\ncode/ecfh/ello-asm64.as\n图8-11 直接用Linux 系统调用来实现 he ll o 程序\n日 日 关千术 语的注释\n各种异常类型的 术语根据系统的不同 而有所不同 。处理 器 ISA 规范通常会 区分异步\n“中 断” 和同 步“异 常", 但是并没有提供 描述这些非 常相 似的 概念的概括性的术语。为了避免不断地提到“异常和中断”以及“异常或者中断",我们用单词“异常”作为通 用的 术语, 而且 只有在必要时才 区别异 步异 常(中断)和同 步异 常(陷阱、故障和终止)。正如我们提到过的,对于每个系统而言,基本的概念都是相同的,但是你应该意识到一 些制 造厂商的 手册会 用“ 异常” 仅仅 表示同 步事件 引起的 控制流的 改变。\n2 进程\n异常是允许操作系统内核提供进程( pro cess ) 概念的基本构造块 , 进程是计算机科学中最深刻、最成功的概念之一。\n在现代系统上运行 一个程序时 , 我们会得到一个假象, 就好像我们的程序是 系统中当前运行的唯一的程序一样。我们的 程序好像是独占地使用处理器和内存。处理器就好像是无间断地一条接一条地执行我们程序中的指令。最后 , 我们程序中 的代码和数据好像是系统内存中唯一的对象。这些假象都 是通过进程的概念提供给我们的。\n进程的经典定义就是一个执行中程序的实例。系统中的每个程序都运行在某个进程的 上下文 ( co n t e x t ) 中。上下文是由程序正确 运行所需的状态组成的。这个状 态包括存放在内存中的程序的代码和数据,它的栈、通用目的寄存器的内容、程序计数器、环境变最以及 打开文件描述符的 集合。\n每次用 户通过向 s hell 输入一个可执行目 标文件的名字, 运行 程序时, s hell 就会创建一个新的进程, 然后在这个新进程的上下文中运行这个 可执行目标文件。应用程序也能够创建新进程, 并且在这个新进程的上下 文中运行它们自己的代码或其他应用程序。\n关千操作系统如何实现进程的 细节的讨论超出了本 书的范围。反之,我 们将关注进程提供给应用程序的关键抽象:\n一个独立的逻辑控制流 , 它提供一个假象 , 好像我们的程序独占地使用处理器。 一个私有的地址空间, 它提供一个假象 , 好像我们的 程序独占地使用内 存系统。让我们更 深入地看看这些 抽象。 8. 2. 1 逻辑控制流\n即使在系统中通常有许 多其他程序在运行 , 进程也 可以向每个 程序提供一种假象,好像它在独占地使用处理器。如果想用调试器单步执行程序 , 我们会看到一系列的程序计数器(PC) 的值, 这些值唯一地对应于包含 进程A 进程B 进程C\n在程 序的 可执行目标文件中的指令, 或是包含在运行时动态链接到程序的共享\n对象中的指令。这个 PC 值的序列叫做逻\n时间\n辑控 制流,或者 简称逻辑流。\n考虑一个运行着 三个 进 程的 系统, 如图 8-12 所示。处理器的一个物理控制\n流被分成了三个逻辑流, 每个 进程一个。 图 8-12 逻辑控制流。进程为每个程序提供了一种假象,\n每个竖直的条表示一个进程的逻辑流的\n一部分。在这个例子中, 二个逻辑流的\n好像程序在独占地使用处理器 。每个竖直的条表示一个进程的逻辑控制流的一部分 j\n执行是交错的 。进程 A 运行 了一会儿, 然后是进程 B 开始运行到完成。然后, 进程 C 运行了一会儿 , 进程 A 接着运行 直到完成。最后, 进程 C 可以运行到结束了。\n图 8-1 2 的关键点在于进程是轮流使 用处理器的。每个 进程执 行它的流的一部分, 然后被抢占 ( preem pted )(暂时挂起), 然后 轮到其他进程。对千一个运行在这些进程之一的上下文中的程序, 它看上去就像是在独占地使用处 理器。唯一的反面例证是 , 如果我们 精确地测扯 每条指令使用的时间, 会发现在程序中一些指令的执行之间, CPU 好像会周期性地停顿 。然而 , 每次处 理器停顿 , 它随后会继续执行我们的程序 , 并不改变程序内存位置或寄存器的内 容。\n8. 2. 2 并发流\n计算机系统中逻辑 流有许 多不同的形式。异常处理程序、进程、信号处理程序、线程和 Java 进程都是逻辑 流的例子。\n一个逻辑流的执行在时间上与另一个流重叠, 称为并发 流 ( co nc urr e n t flow), 这两个流被称为并发 地运行 。更准确地说 ,流 X 和 Y 互相并发, 当且仅当 X 在 Y 开始之后和 Y 结束之前开始,或者 Y 在 X 开始之后和 X 结束 之前开始。例如,图 8-1 2 中,进 程 A 和 B 并发地运行 , A 和 C 也一样。另一方面, B 和 C 没有并发地运行 , 因为 B 的最后一条指令在 C 的第一条指令之前执行。\n多个流并发地执行的一般现象被称为并发 ( co ncu rr e ncy ) 。一个进程和其 他进程轮流运行的概念称为 多任务( m ult itas king ) 。一个进程执行它的控制流的 一部分的每一时间段叫做时间 片 ( t im e s lice ) 。因此,多 任务也叫 做时间分 片 ( t im e s licing ) 。例如, 图 8-1 2 中, 进程 A 的流由两个时间片组成。\n注意, 并发流的思想与流运行的 处理器核数或者计算机数无关。如果两个 流在时间 上重叠 , 那么它们就是并 发的, 即使它们是运行在同一个处理器上。不 过, 有时我们会发现确认并行 流是很有帮助的,它 是并发流的一个真子集。如果两个流并发地运行 在不同的处理器核或 者计算机上, 那么我们称它们为并行 流( pa ra ll el fl o w ) , 它们并行 地运行 ( ru n ning in para llel) , 且并行地执行( para llel exec ut ion ) 。\n让 练习题 8. 1 考虑 三个具有下述起 始和结束 时间的 进程:\n起始时间\nI 3\n结束时间\n2\n4\n5\n对于每 对进 程,指 出它 们是 否是 并发地运行 :\n8. 2. 3 私有地址空间\n进程也为每个 程序提 供一种假象 , 好像它独占地使用 系统地址空间。在一台 n 位地址的机器上 ,地 址空间是 2\u0026quot; 个可能地址的集合, o, 1, … , 2\u0026quot; - 1。进程为每个程序提供它自己的私有地 址空间 。一 般而言, 和这个空间中某个地址相关 联的那个内存字节是不能被\n其他进程读或者写的, 从这个意义上说, 这个地址空间 是私有的。\n尽管和每个私有 地址空间 相关联的内存的内容一般是不同的, 但是每个这样的空间都有相同的通用结 构。比如,图 8-1 3 展示了一个 x8 6- 64 L in u x 进程的地址空间 的组织结构。\n地址空间底部是保留给用户程序的, 包括通常的 代码、数据、堆和栈段。代码段总是从地址 Ox 400000 开始。地址空间顶部保留给内 核(操作系统 常驻内存的部分)。地址空间的这个部分包含内核在代表进程执行指令时(比如当应用程序执行系统调用时)使用的代 码、数据和栈。\n248-1\u0026mdash;+\n内核虚拟内存\n(代码、数据、堆、栈)\n用户栈\n(运行时创建的)\n-\u0026hellip;\ni 用户代码不可见的内存\n七 %e sp (栈指针)\n,. ,,\n之- ,. ·.\u0026rsquo;·\n) -气\n共享库的内存映射区域\n.•• ., 丸 _. 才. `- \u0026ndash; . ; 千 、、,•.、:-.\n; i \u0026quot;\n运行时堆\n(用ma l l oc 创建的)\n读/写段\n( . da t a、.bss)\n只读代码段\n.七 br k\nOx 0 0 4 00 0 0 0 \u0026ndash;+\n( . i ni 七、. t ex t 、.rodata)\n,, . i°; i 飞 宁\n图 8-13 进程地址空间\n8. 2. 4 用户模式和内核模式\n为了使 操作系统内核提供一个无懈可击的 进程抽象 , 处理器必须提供一种机制, 限制一个应用可以 执行的指令以及它可以访问的地址空间范围。\n处理器通常 是用某个控制寄存器中的一个模式位( m o de b it ) 来提供这种功能的, 该寄\n存器描述了进程当前享有的特权。当设置了 模式位时 , 进程就运行在内核 模式中(有时叫做超级 用户 模式)。一个运行 在内核模式 的进程可以执行指令集中的任何指令, 并且可以访问系统中的任何内存位置。\n没有设置模式位时 , 进程就运行 在用户 模式中。用户模式中的 进程不允 许执行特权指令( pr ivileged ins t ru ct ion ) , 比如停止处理器、改变模式位,或 者发起一个 1/ 0 操作。也不允许用户模式中的进程直接引用 地址空间中内核区内的代码和数据。任何这样的尝试都会导致致命的保护故障。反之, 用 户程序必须通过系统凋用接口间 接地访问内核代码和数据 。\n运行 应用程序代码的 进程初始时 是在用户模式中 的。进程从 用户模式变为内核模式的唯一方法是通过诸如中断、故障或者陷入系统调用这样的异常。当异常发生时,控制传递 到异常处理程序, 处理器将模式 从用户模式变为内 核模式。处理程序运行在内核模式中, 当它返回到应用程序代码时,处理器就把模式从内核摸式改回到用户模式。\nLin ux 提供了一种聪明的机制 , 叫做/ pr o c 文件系统 , 它允许用户模式进程访问内核数\n据结构的内 容。/ pr oc 文件系统将许多内核数据结构的内容输出为一个用户程序可以读的文本文件的层次结构。比如,你 可以使用/ pr oc 文件系统找出一般的系统属性,比 如 CPU 类型(/proc/ cpuinfo), 或者某个特殊的进程使用的内存段( / pr oc / \u0026lt;p ro c e s s - i d \u0026gt; / ma ps ) 。2. 6 版本的 Linux 内核引入/ s ys 文件系统,它输 出 关 千系统总线和设备的额外的低层信息。\n8. 2. 5 上下文切换\n操作系统内核使用一种称为上下文切换 ( context switch ) 的较高层形式的异常控制流来实现多任务 。上下文切换机制是建立在 8. 1 节中已经讨论过的那些较低层异常机制之上的。\n内核为每个进程维持一个上下文( conte xt ) 。上下文就是内核重新启动一个被抢占的进程所需的状态。它由一些对象的值组成,这些对象包括通用目的寄存器、浮点寄存器、程 序计数器、用户栈、状态寄存器、内核栈和各种内核数据结构,比如描述地址空间的页 表、包含有关当前进程信息的进程表,以及包含进程已打开文件的信息的文件表。\n在进程执行的某些时刻,内核可以决定抢占当前进程,并重新开始一个先前被抢占了\n的进程。这种决策就叫做调度( sched uling ) , 是由内核中称为调度器 ( sched ule r ) 的 代码处理的。当内核选择一个新的进程运行时 , 我们说内核调度了这个进程。在内核调度了一个新的进程运行后,它就抢占当前进程,并使用一种称为上下文切换的机制来将控制转移到 新的进 程, 上下文切换 1 ) 保存当前进程的上下文, 2 ) 恢复某个先前被抢占的进程被保存的上下文, 3 ) 将控制传递给这个新恢复的进程。\n当内核代表用户执行系统调用时,可能会发生上下文切换。如果系统调用因为等待某 个事件 发生而阻塞,那 么 内 核 可以让当前进程休眠,切 换 到另一个进程。比如, 如 果 一 个 r e a d 系统调用需要访问磁盘,内 核 可以选择执行上下文切换, 运行另外一个进程, 而 不是等待数 据从磁盘到达。另一个示例是 s l e e p 系统调用, 它 显 式 地请求让调用进程休眠。一般而言,即使系统调用没有阻塞,内核也可以决定执行上下文切换,而不是将控制返回 给涸用进程。\n中 断也可能引发上下文切换。比如,所 有的系统都有某 种产生周期性定时器中断的机制,通 常为每 1 毫秒或每 10 毫秒。每次发生定时器中断时,内 核 就 能 判定当前进程已经运行了 足够长的时间,并 切 换 到 一 个 新 的 进 程。\n图 8-14 展示了一对进程 A 和 B 之间上下文切换的示例。在这个例子中, 进程 A 初始运行 在用户模式中,直 到它通过执行系统调用 r e a d 陷入到内核。内核中的陷阱处理程序请求来 自磁盘控制器的 OMA 传输,并 且安排在磁盘控制器完成从磁盘到内存的数据传输后,磁盘中断处理器。\n时间 进程A 进程B\nread········► + 用户模式\n芒 内核模式 }上下文切换\n磁盘中断 \u0026hellip;\u0026hellip;.. — { 用户模式\n从r ead 返回 \u0026hellip;..►.\n内核模式 }上下文切换\n用户模式图 8-14 进程上下文切换的剖析\n磁盘取数据要用一段相对较长的时间(数量级为几 十毫秒),所以内核执行从 进程 A 到进程 B 的上下文切换, 而不是在这个间 歇时间内等待, 什么都不做。注意在切换之前, 内核正代表进程 A 在用户模式下执行指令(即没有单独的 内核进程)。在切换的第一部分中, 内 核代表进程 A 在内核模式下执行指令。然后在某一时刻, 它开始代表进程 B ( 仍然是内核模式下)执行指令。在切换之后,内 核代表进程 B 在用户模式下执行指令。\n随后,进程 B 在用户模式下运行一 会儿, 直到磁盘发出一个 中断信号, 表示数据已经从磁盘传送到了内存。内核判定进程 B 已经运行了足够长的时间, 就执行一个从 进程 B 到进程 A 的上下文切换 , 将控制返回给进程 A 中紧随 在系统调用 r e a d 之后的那条指令。进程 A 继续运行, 直到下一 次异常发生 ,依 此类推。\n8. 3 系统调用错误处理\n当 U nix 系统级函数遇到错 误时, 它们通常会返回一1, 并设置全局整数变量 e r r no 来表示什么出错了。程序员应该总是检查错误,但是不幸的是,许多人都忽略了错误检 查, 因为它使 代码变得膀 肿, 而且难以读懂。比如,下 面是我们调用 U n ix f or k 函数时会如何检查错误:\nif ((pid = fork()) \u0026lt; 0) {\nfprintf(stderr, \u0026ldquo;fork error: %s\\n\u0026rdquo;, strerror(errno)); exit(O);\n}\ns t re rr or 函数返回一 个文本串 , 描述了和某个 err n o 值相关联的错误。通过定义下面的错误报告函数,我们能够在某种程度上简化这个代码:\nvoid unix_error(char *msg) I* Unix-style error *I\n{\nfprintf(stderr, \u0026ldquo;%s: %s\\n\u0026rdquo;, msg, strerror(errno)); exit(O);\n}\n给定这个函数, 我们对 f or k 的调用从 4 行缩减到 2 行:\nif ((pid = fork()) \u0026lt; 0) unix_error(\u0026ldquo;fork error\u0026rdquo;);\n通过使用 错误处理 包装函数, 我们可以更 进一步地简化代码, S t eve n s 在[ ll O] 中首先提出了 这种方法。对于一个给定的基本函数 f o o , 我们定义一个具有相同参数的包装函数\nFoo, 但是第一个字母大写了。包装函数调用基本函数, 检查错误, 如果有任何问题就终止。比如,下 面是 f o r k 函数的错误处 理包装函数 :\npid_t Fork(void)\n{\npid_t pid;\nif ((pid = fork()) \u0026lt; 0) unix_error(\u0026ldquo;Fork error\u0026rdquo;);\nreturn pid;\n给定这个包装函数, 我们对 f o r k 的调用就缩减为 1 行:\npid = Fork() ;\n我们将在本书剩余的部分中都使用错误处理包装函数。它们能够保持代码示例简洁,而 又不会给你错误的假象,认为允许忽略错误检查。注意,当在本书中谈到系统级函数时,我 们总是用它们的小写字母的基本名字来引用它们 , 而不是用它们大写的包装函数名来引用。\n关千 U nix 错误处理以及本书中使用的错误处理包装函数的讨论, 请参见附录 A 。包装函数定 义在一个 叫做 c s a pp . c 的文件中, 它们的原型定义在一个叫做 c s a p p . h 的头文件中; 可以从 CS : APP 网站上在线地得到这些代码。\n8. 4 进程控制\nUnix 提供了大量从 C 程序中操作进程的系统 调用。这一节将描述这些重要的函数, 并举例说明如何使用它们。\n8. 4. 1 获 取进程 ID\n每个进程都有一个唯一的正数(非零)进程 ID( PID) 。ge t pi d 函数返回调用进程的 PIO。\nge七pp i d 函数返回它的父进程的PIO( 创建调用进程的进程)。\n#include \u0026lt;sys/types.h\u0026gt;\n#include \u0026lt;unistd.h\u0026gt;\npid_t getpid(void); pid_t getppid(void);\n返回: 调 用者或 其 父进程的 PID,\ng e t p i d 和 g e t p p i d 函数返回一个类型为 p i d t 的整数值, 在 Lin ux 系统上它在\nt ype s . h 中被定义为 i n 七。\n4·. 2 创建和终止进程\n从程序员的角度 , 我们可以认为进程总是处于下面三种状态之一:\n运行。进程要么在 CP U 上执行, 要么在等待 被执行且最终会被内核调度。 停止。进程的执行被挂起 ( s us pended ) , 且不会被调度。当收到 SIGS T O P 、S IG T ­ S T P、SIG T T I N 或者 SIG T T O U 信号时, 进程就停止, 并且保持停止直到它收到一个 S IGCO NT 信号, 在这个时刻,进 程再次开始运行。(信号是一种软件中断的形式, 将在 8. 5 节中详细描述。) 终止。进 程永远地停止了。进程会因为三种原 因终止: 1) 收到一个信号,该 信 号的默认行为是终 止进程, 2 ) 从 主程序返 回, 3 ) 调用 e x i t 函数。 #include \u0026lt;stdlib.h\u0026gt;\nvoid exit(int status);\n该函数不返回 。\ne x i t 函数以 s t a t u s 退出状 态来 终止进程(另一种设置退出状态的方法是从主程序中返回一个整数值)。\n父进程通过调用 f or k 函数创建一个新的运行的子 进程。\n#include \u0026lt;sys/types.h\u0026gt;\n#include \u0026lt;unistd.h\u0026gt;\npid_t fork(void);\n返回: 子 进 程 返 回 o, 父进程返 回 子进 程的 PID, 如果出错 ,则 为一 l 。\n新创 建的子 进程几乎但不完全与父进程相同。子进程得到与父进程用户级虚拟地址空间相同的(但是独立的)一份副本,包括代码和数据段、堆、共享库以及用户栈。子进程还获得与父 进程任何打开文件描述符相 同的副本, 这就意味着当父进程调用 for k 时,子 进 程可以读写父进程中打开的任何文件。父进程和新创建的子进程之间最大的区别在于它们有不同的PID。\nf or k 函数是有趣的(也常常令人迷惑),因 为它只被调用一次, 却 会返回两 次: 一次是在调用进程(父进程)中,一 次 是 在新创建的子进程中。在父进程中, f o r k 返回子进程的 PID 。在子进程中, f o r k 返回 0。因为子进程的 PID 总是为非零, 返回值就提供一个明确的方法来分辨程序是在父进程还是在子进程中执行。\n图 8-15 展示了一个使用 f o r k 创建子进程的父进程的示例。当 f or k 调用在第 6 行返回 时 ,在 父 进 程和子进程中 x 的值都为 1。子进程在第 8 行加一并输出它的 x 的副本。相似地 ,父 进 程 在第 13 行减一并输出它的 x 的副本。\n1 int main()\n2 {\n3 pid_t pid;\n4 int X = 1·,\n5\n6 pid = Fork();\nif (pid == 0) { I* Child *I\nprintf (\u0026ldquo;child : x=加 \\ n \u0026quot; , ++x);\n9 exit(O);\n10 }\n11\n12 I* Parent *I\n13 printf (\u0026ldquo;parent: x=加 \\ n \u0026quot; , \u0026ndash;x);\n14 exit (0);\n15 }\ncode/ecf/fork.c\ncode/ecf/fork.c\n图 8-15 使用 fo r k 创建一个新进程\n当在 U nix 系统上运行这个程序时, 我们得到下面的结果:\nlinux\u0026gt; . / f ork parent: x=O chil d : x=2\n这个简单的例子有一些微妙的方面。\n调用一次,返 回 两次。 f o r k 函数被父进程调用一次, 但 是 却 返 回 两 次 一 一次是返回到父进程,一 次 是 返回到新创建的子进程。对于只创建一个子进程的程序来说 , 这还是相当简单直接的。但是具有多个 f or k 实例的程序可能就会令人迷惑, 需要仔细地推敲了。 并发执行。父进程和子进程是并发运行的独立进程。内核能够以任意方式交替执行 它 们 的 逻辑控制流中的指令。在我们的系统上运行这个程序时,父 进程先完成它的pr i n t f 语 句 ,然 后 是 子 进程。然而, 在另一个系统上可能正好相反。一般而言, 作为程序员,我们决不能对不同进程中指令的交替执行做任何假设。\n相同但是独立的 地址空间。 如果能够在 f or k 函数在父进程和子进程 中返回后立即暂停这两个进程,我们会看到两个进程的地址空间都是相同的。每个进程有相同的 用户栈、相同的本地变量值 、相同的堆、相同的全局变量值, 以及相同的代码。因此, 在我们 的示例程序 中, 当 f or k 函数在第 6 行返回时 , 本地变量 x 在父进程和子进程中都 为 1。然而 , 因为父进程和子进程是独立的进程,它 们都有自己 的私有地址空间。后面, 父进程和子进程对 x 所做的任何改变都是 独立的 , 不会反映在另一个进程的内 存中。这就是为什么当父进程 和子进程调用它们各自的 p r i n t f 语句时, 它们中的变量 x 会有不同的值。 共享文件。 当运行这个示例程序时, 我们注意 到父进 程和子进程都 把它们的输出显示在屏幕上。原因是子进程继 承了父进程所有的 打开文件。当父进程调用 f or k 时, s t d o 江 文件是打开的, 并指向屏幕。子进程继承了这个文件, 因此它的输出也是指向屏幕的。\n如果你是第一次学习 for k 函数, 画进程图通常会有所帮助,进程图 是刻画程序语 句的 偏序的一种简单的前趋图。每个顶点a 对应于一条程序语句的执行。有向边 a - b 表示语 句 a 发生在语句 b 之前。边上可以标记出一些信息, 例如一个变量的当前值。对应于 pr i nt f 语句的顶点可以 标记上 pr i n t f 的输出。每张图从一个顶点开始,对应千调用 mai n 的父进程。这个顶 点没有入边,并且只有一个出边 。每个进程的顶点序列结束于一 个对应于 e x i t 调用\n的顶点。这个顶点只有一条入边,没有出边。例如, 图 8-16 展示了图 8-15 中示例程序\n的进程图 。初始时, 父进程将变量 x 设置为\nX==l\nch i l d: x=2 printf\npra ent : x=O\nexit\n子进程\nl 。父进程调用 f or k , 创建一个子进程,它在自己的私有地址空间中与父进程并发执行。对千运行在单处理器上的程序,对应进\nmain f or k printf exi七\n图 8-16 图 8-15 中示 例程序 的进程图\n父进程\n程图中所 有顶点的 拓扑排序( to po log ica l so r t ) 表示程序中语句的一个可行的全序排 列。下面是一个理解拓扑排序概念的简单方法:给定进程图中顶点的一个排列,把顶点序列从左 到右写成 一行,然后 画出每条有向边。排列 是一个拓扑排序, 当且仅当画出的每条边的方向都是从 左往右的。因此, 在图 8-15 的示例程序中 , 父进程和子进程 的 pr i n t f 语句可以以任意先 后顺序执行, 因为每种顺 序都对应千图顶点的某种拓扑 排序。\n进程图特别有 助千理解带 有嵌套 f or k 调用的程序。例如,图 8-17 中的程序源码中两次调用了 f or k。对应 的进程图可帮 助我们 看清这个程序运行 了四个进程, 每个 都调用了一次 pr i n t f , 这些 pr i n t f 可以以 任意顺序执行。\nint main()\n{\nFork();\nFork(); printf(\u0026ldquo;hello\\n\u0026rdquo;); exit(O);\nhel l o pr i nt f he ll o\nf or k printf\nhell o\ne x i t exi 七\n图 8 - 17\nmain f or k\n嵌套 f or k 的进程图\nf or k pr i nt f exit\ni 练习题 8. 2 考虑下面的程序:\nint main()\n{\ncode/ecflforkprobO.c\nint X = 1;\nif (Fork() == 0)\nprintf(\u0026ldquo;p1: x=%d\\n\u0026rdquo;, ++x); printf(\u0026ldquo;p2: x=%d\\n\u0026rdquo;, \u0026ndash;x); exit(O);\ncodelecflforkprobO.c\n子进程的输出是什么? 父进程的输出是什么? 8. 4. 3 回收子进程\n当一个进程由于某种原因终止时,内核并不是立即把它从系统中清除。相反,进程被保持在一种已终止的状态中, 直到被它的 父进程回收( r ea ped ) 。当父进程回收己终止的子进程时,内核将子进程的退出状态传递给父进程,然后抛弃己终止的进程,从此时开始, 该进程就不 存在了。一 个终止了但还未被回收的 进程称为僵 死进 程( zo m bie ) 。\n日 日 为什么已终止的子进程被 称为僵死进程?\n在民 间传说 中,僵 尸是 活着的 尸体 , 一种半 生半 死的 实体。僵死进程已经终止了, 而内核仍保留着它的某些状态直到父进程回收它为止,从这个意义上说它们是类似的。\n如果一个父进程终 止了,内 核会安排 i n i t 进程成 为它的 孤儿进程的养父。 i n i t 进程的 P ID 为 1, 是在系统启动时由内核创建的,它不会终止,是所有进程的祖先。如果父进程没有回收它的僵死子进程就终 止了, 那么内核会安排 i n i t 进程去回收它们。不过,长时间运行 的程序,比如 shell 或者服务器,总 是应该回收它们的僵死子进程。即使僵死子进程没有运行,它们仍然消耗系统的内存资源。\n一个进程可以 通过调用 wa i t p i d 函数来等待它的 子进程终止或者停止。\n#include \u0026lt;sys/types.h\u0026gt;\n#include \u0026lt;s ys / wa i t . h \u0026gt;\npid_t waitpid(pid_t pid, int *statusp, int options);\n返回: 如 果 成 功 , 则为 子 进 程 的 PIO, 如 果 WNO HAN G , 则 为 o, 如 果其他错误 , 则为— 1.\nwa i t p 过 函数有点复杂。默认情况下(当o p巨 o ns = O 时), wa i t p i d 挂起调用进程的执行, 直到它的等待 集合 ( w ait set ) 中的一个子进程终止。如果等待集合 中的一个进程在刚调用的时刻就已经终止了, 那么 wa i t p i d 就立即返回。在这两种情况中, wa i t p i d 返回导致 w ait pid 返回的已终止子进程的 P IO 。此 时, 已终止的子进程巳经被回收, 内核会从系统中删除掉它的所有痕迹。\n1 判定等待集合的 成员\n等待集合的成员是由参数 p 过 来确定的:\n如果 p 过 \u0026gt;O, 那么等待集合就是一个单独的子进程 ,它的 进程 ID 等千 p i d。\n如果 pi d= - 1 , 那么等待集合就是由父进程所有的子进程组成的。\nwa i t p 迈 函数还支持其他类型的等待集合, 包括 Unix 进程组, 对此我们将不做讨论。\n修改默认行 为\n可以通过将 op t i on s 设置为常晕 WNO H ANG 、 WU NT RACED 和 WCO NT INUED\n的各种组合来修改默认行为:\nWNOHANG: 如果等待集合中的任何子进程都还没有终止,那么就立即返回(返回值为 0 ) 。默认的行 为是挂起调用进程, 直到有子进程终 止。在等待子进程终 止的同时,如果还想做些有用的工作,这个选项会有用。\nWUNTRACED: 挂起调用进程的执行, 直到等待集合中的一个进程变成巳终止或者被停止。返回的 PID 为导致返回的已终止或被停止 子进程的 PID。默认的行为是只返回己终止的子进程。当你想要检查己终止和被停止的子进程时,这 个选项会有用。\nWCONT I NU ED: 挂起调用进程的 执行, 直到等待集合中一个正在运行的进程终止或等待集合中一个 被停止的进程收 到 S IGCO NT 信号重新开始执行。( 8. 5 节会解释这些信号。)\n可以用或运算把这些选项组合起来。例如:\nWNOHANG I WUNTRACED: 立即返回,如果等待集合中的子进程都没有被停止或终止, 则返回值为 O; 如果有一个停止或终 止, 则返回值为该子进程的 PID。\n检查己回 收子进程的 退出状态\n如果 s 七a t us p 参数是非空的, 那么 wa i t p i d 就会在 s 七a t us 中放上关于导致返回的子进程 的状态信息, s 七a t us 是 s t a t us p 指向的值。wa i t . h 头文件定义了解释 s 七a 七us 参数的几个宏:\nWIFEXITED(s t a 七us ) : 如果子进程通过调用 e xi t 或者一个返回( ret urn ) 正常终止, 就返回真。\nWEXITSTATUS ( s t a 七us ) : 返回一 个正常终止的 子进程的退出状态。只有 在\nWIFEXIT ED( ) 返回为真时 , 才会定义这个状态。\nWIFSIGNALED(status): 如果子进程是因为一个未被捕获的信号终止的, 那么就返回真。\nWTERMSIG(status): 返回导致子进程终止的信号的编号。只有在 WIFSIG­ NALE D( ) 返回为真时 , 才定义这个状态。\nWIF ST OPP ED(s t a 七us ) : 如果引起返回的子进程当前是 停止的, 那么就返回真。\nWSTOPSIG(status): 返回引起子进程停止的信号的编号。只有在 WIFSTOPP D ( )\n返回为真时,才定义这个状态。\nWIFCONTINUED( s t a 七us ) : 如果子进程收到 SIGCONT 信号重新启动, 则返回真。\n错误条件\n如果调用进程没有子进程, 那么 wa i t p i d 返回—1, 并且设置 err no 为 EC H ILD 。如果 wa i t p 卫 函数被一个信号中断, 那么它返回- 1 , 并设置 err no 为 EINT R。\nm 和 Unix 函数相关的 常量\n像 WNOHANG 和 WUNT RACED 这样的常量 是由 系统头文件定义的。例如, WNO­ HANG 和 WU NT RACE D 是由 wa i t . h 头文件(间接)定义的 :\nI* Bits\n#define\n#define\nin the third WNOHANG 1\nWUNTRACED 2\nargument to\u0026rsquo;waitpid\u0026rsquo;. */ I* Don\u0026rsquo;t block waiting. *I\nI* Report status of stopped children. *I\n为了使用这些常量, 必须在代码中 包含 wa i t . h 头文件:\n#include \u0026lt;sys/wait.h\u0026gt;\n每个 U nix 函数的 ma n 页列 出 了 无论何 时你在代码中使 用 那个函数都要 包含 的头文件。同时, 为 了检 查诸如 ECH ILD 和 EINT R 之 类的 返回代码, 你必须 包含 er r n o . h 。 为了简化代码示例 , 我们 包含 了 一个称 为 c s a p p . h 的 头 文件, 它 包括 了 本 书 中使 用的 所有函数的头文件。c s a pp . h 头文件可以从 CS: APP 网站在线荻得。\n沁凶 练习题 8. 3 列出下面程序所有可能的输出序列:\nint main()\n{\ncode/ecf/waitprobO.c\nif (Fork() == 0) {\nprintf(\u0026ldquo;a\u0026rdquo;);\n}\nfflush(stdout);\nelse {\nprintf(\u0026ldquo;b\u0026rdquo;); fflush(stdout); waitpid(-1, NULL, O);\n}\nprintf(\u0026ldquo;c\u0026rdquo;); fflush(stdout); exit(O);\n5 . wa i t 函数\nwa i t 函数 是 wa i t p i d 函 数 的 简单版本:\ncode/ecflwaitprob0.c\n#include \u0026lt;sys/types.h\u0026gt;\n#include \u0026lt;s ys 压 a i t . h\u0026gt; pid_t wait(int *statusp);\n返回: 如 果 成 功, 则 为 子进程的 PID, 如 果 出错 , 则为 一1。\n调 用 wa i t ( \u0026amp;s 七a t u s ) 等价千调用 wa i t p i d (- l , \u0026amp;s t a t u s , O ) 。\n6 使用 wa 江 p i d 的示例\n因为 wa i t p i d 函 数有些复杂,看 几 个 例 子会有所帮助。图 8-18 展示了一个程序, 它使 用 wa i 七p 过 ,不 按 照 特定的顺序等待它的所有 N 个子进程终止。在第 11 行, 父进程创建 N 个子进程,在 第 12 行 , 每个子进程以一个唯一的退出状态退出。在我们继续讲解之前 ,请 确 认 你 已经理解为什么每个子进程会执行第 12 行 , 而父进程不会。\n在第 15 行, 父进程用 wa i t p i d 作 为 wh i l e 循 环 的 测 试 条 件,等 待它所有的子进程终止 。 因 为第一个参数是 — 1, 所以对 wa i t p i d 的 调 用 会 阻 塞 , 直 到 任意一个子进程终止。在每个子进程终止时, 对 wa i 七p i d 的 调 用 会 返回, 返回值为该子进程的非零的 PID。第1 6 行检查子进程的退出状态。如果子进程是正常终止的一 在此是以调用 e x i t 函数终止 的 那 么 父进程就提取出退出状态,把 它 输 出 到 s t d o u t 上。\ncode/ecflwaitpidl.c\nprintf(\u0026ldquo;child %d terminated normally with exit status=%d\\n\u0026rdquo;,\npid, WEXITSTATUS(status));\nelse\nprintf (\u0026ldquo;child %d terminated abnormally\\n\u0026rdquo;, pid);\n21 }\n22\nI* The only normal termination is if there are no more children *I\nif (errno != ECHILD)\nunix_error(\u0026ldquo;waitpid error\u0026rdquo;);\n26\n27 exit(O);\n28 }\ncode/ecf/waitpidl.c\n图 8-18 使用 wa i t pi d 函数不按照特定的顺 序回收僵死 子进程\n当回收了所有的子 进 程之后, 再 调用 wa i t p i d 就返回 — 1, 并且设 置 err n o 为\n£ C H IL D。第 24 行检查 wa i 七p i d 函数是正常终止的, 否则就输出一个错误 消息。在我们的 L in ux 系统上 运行 这个程序时 ,它 产生如下输出:\nlinux\u0026gt; ./ 甘 ai t p i d1\nchild 22966 terminated normally with exit status=lOO child 22967 terminated normally with exit status=101\n注意,程序不会按照特定的顺序回收子进程。子进程回收的顺序是这台特定的计算机系统的属性。在另一个系统上,甚至在同一个系统上再执行一次,两个子进程都可能以相反的顺序被回收。这是非确定性行为的一个示例,这种非确定性行为使得对并发进行推理非常困难。两种 可能的结果都同样是正确的, 作为一个 程序员, 你绝不可以 假设总是会出现某一个结果,无论多么不可能出现另一个结果。唯一正确的假设是每一个可能的结果都同样可能出现。\n图 8-19 展示了一个简单的改变,它消除了这种不确定性 , 按照父进程创建子进程的相同顺序来回收这些子进程。在第 11 行中,父进程按照顺 序存储了它的子进程的 PIO, 然后通过用适当的 PIO 作为第一个参数来调用 wa i t p i d , 按照同样的顺序来等待每个子进程。\ncode/ecflwaitpid2.c\nI* Parent reaps N children in order *I\ni = O;\nwhile ((retpid = waitpid(pid[i++], \u0026amp;status, 0)) \u0026gt; 0) {\n1 7 if (WIFEXITED(status))\nprintf(\u0026ldquo;child %ct terminated normally with exit status=%d\\n\u0026rdquo;,\nretpid, WEXITSTATUS(status));\nelse\nprintf (11child %d terminated abnormally\\n11, retpid) ;\n22 } 23 24 I* The only normal termination is if there are no more children *I 25 if (errno != ECHILD) 26 unix_error(11waitpid error\u0026rdquo;);\n27\n28 exit(O);\n29 }\ncode/ecf/waitpid2.c\n图 8-19 使用 wa i t pi d 按照创建子进程的顺序来回收这些 僵死子进程\n亡 练习题 8. 4 考虑下面的程序:\ncode/ecflwaitprobl.c\ncode/ecflwaitprob1.,\n这个程序会产生多少输出行? 这些输 出行的一种 可能的 顺 序是 什么? 8. 4. 4 让进程休眠\ns l e ep 函数将一个进程挂起一段指定的时间。\n#include \u0026lt;unistd.h\u0026gt;\nunsigned int sleep(unsigned int secs);\n返回: 还 要 休 眠 的 秒 数 。\n如果请求的时间量已经到了, s l e e p 返回 o, 否则返回还剩下的要休眠的秒数。后一种情况 是可能的,如果 因 为 s l e e p 函数被一个信号中断而过早地返回。我们将在 8. 5 节中详细讨论信号。\n我们会发现另一个很有用的函数是 p a u s e 函数,该 函 数让 调 用 函数 休 眠 ,直 到 该 进 程收到一 个信号。\n#include \u0026lt;unistd.h\u0026gt;\nint pause(void);\n总是返回一1。\n让目 练习题 8. 5 编写 一个 s l e e p 的包 装函数,叫做 s n o o z e , 带有下面的接口:\nunsigned int snooze(unsigned int secs);\nsnooze 函数和 s l e e p 函数的行 为 完全 一样 , 除 了它 会打印 出 一条 消息来描述进程 实际休眠了多长时间:\nlept for 4 of 5 secs.\n4. 5 加载并运行程序\ne xe cve 函数在当前进程的上下文中加载并运行一个新程序。\n#include \u0026lt;unistd.h\u0026gt;\nint execve(const char *filename, const char *argv[J, const char *envp []) ;\n如果成功,则不返回 ,如果错误,则 返回- 1 .\ne x e c v e 函 数 加 载 并 运行可执行目标文件 f i l e na me , 且 带 参 数 列 表 ar gv 和环境变量列表 e nv p。只有当出现错误时, 例 如 找 不 到 f i l e n a me , e x e c v e 才会返回到调用程序。所以 , 与 f or k 一次调用返回两次不同, e x e c v e 调 用 一 次并 从 不 返 回 。\n参 数 列 表是 用 图 8-20 中的数据结构表示的。ar g v 变量指向一个以 n ull 结尾的指针数组,其 中 每个指针都指向一个参数字符串。按照惯例, a r g v [ OJ 是 可 执行目标文件的名字。环 境变量的列表是由一个类似的数据结构表示的, 如图 8-21 所示。e nv p 变最指向一个以 n ull 结尾的指针数组, 其 中 每个指针指向一个环境变量字符串, 每个串都是形如\u0026rdquo; na me =v a l u e \u0026quot; 的 名 字—值 对 。\nI argv\nargv[] argv[O]\nI argv [1]\nargv [ar gc - 1]\n1 ·I\ni # \u0026ldquo;ls\u0026rdquo; \u0026ldquo;-lt\u0026rdquo;\n皿 L '\n佟I 8-20 参数列表的组织结构\nI \u0026ldquo;/ user / i ncl ude\u0026rdquo; j\n「一二 ,\nen vp[]\nenvp [OJ envp [1)\nj\nenvp [n - 1)\nNULL\n图 8- 21 环境变扯列表的组织结构\n在 e x e c v e 加载了 f i l e n a me 之后, 它调用 7. 9 节中描述的启动代码。启动代码设置栈, 并将控制传递给新程 序的主函数, 该主函数 有如下形式的原型\nint main(int argc,\n或者等价的\nint main(int argc,\nchar **argv,\nchar *argv [] ,\nchar **envp);\nchar *envp(]);\n当 ma i n 开始执行时,用 户栈的组织结构如图 8- 22 所示。让我们从栈底(高地址)往栈顶\n(低地址 )依次看一看。首先是 参数和环境字符串。栈往上紧随其后的是以 n u ll 结尾的指针数组 , 其中每个指针都指向栈中的一个环境变量字符串。全局变量 e n v ir o n 指向这些指针中的第一个 e n v p [ O J 。 紧随环境变量数组之后的是以 n u ll 结尾的 ar g v [ ]数组, 其中每个元素都指向栈 中的一个参数字符串。在栈的顶部是系统启动函数 l i b c _ s t ar t _ ma i n ( 见\n9 节)的栈帧。\n栈底\n以null结尾的环境变扯字符串\n. 以null结尾的命令行字符串\nenvp[n] == NULL\ne nvp [ n - 1 ]\n...\ne n vp [ O ] •\nar gv [ar g c ) = NUL L\nargv[argc-1]\n..\n. , argv[O]\nenviron\n(全局变量)\nargc\n(在寄存器%r d i 中 )\n·.\nl i b c _ s t ar t _ma i n 的栈帧\n栈顶\nma i n 的未来的栈帧\n图 8-22 一个新程序开始时,用 户 栈的典型组织结构\nma i n 函 数有 3 个 参 数 : l)argc, 它给出 ar g v [ ]数组中非空指针的数量, 2 ) ar g v ,\n指向 ar g v [ ] 数组中的第一个条目, 3 ) e n v p , 指 向 e nv p ( ] 数组中的第一个条目。\nLin ux 提供了几个函数来操作环境数组:\n#include \u0026lt;stdlib.h\u0026gt;\nchar *getenv(const char *name);\n返回: 若存在则为指 向 name 的 指 针 , 若 无匹 配的 , 则 为 NU LL。\ng e t e nv 函 数 在 环境 数组中搜索字符串 \u0026quot; na me =v a l ue \u0026quot; 。 如果找到了, 它 就 返回一个指向 va l ue 的指针,否 则 它 就 返回 NULL 。\n#include \u0026lt;stdlib.h\u0026gt;\nint setenv(const char *name, const char *newvalue, int overwrite);\n返回: 若成功 则 为 0 , 若 错 误 则 为 一1 。\nvoid unsetenv(const char *name);\n返回: 无。\n如果环境数组包含一个形如 \u0026quot; n a me = o l d v a l u e \u0026quot; 的 字 符 串 ,那 么 u n s e t e nv 会 删除它, 而 s e t e nv 会用 ne wv a l u e 代替 o l d v a l u e , 但是只有在 ov er wir 七e 非 零 时 才会这样。如果 na me 不存在,那 么 s e t e n v 就 把 \u0026quot; n a me =n e wv a l u e \u0026quot; 添加到数组中。\n豆 日 程序与进程\n这是一个适当的地方,停下来,确认一下你理解了程序和进程之间的区别。程序是 一堆代码和数据;程序可以作为目标文件存在于磁盘上,或者作为段存在于地址空间 中。进程是执行中程序的一个具体的实例;程序总是运行在某个进程的上下文中。如果 你想要 理解 f or k 和 e x e c ve 函数, 理解这个差 异是很 重要 的。f or k 函数在新的子进程中运行 相同的 程序, 新的子进程是父进程的一个复制品。e x e c v e 函数在 当 前进程的上下文中加栽并运行一个新的程序。它会覆盖当前进程的地址空间,但并没有创建一个新 进程。 新的程序仍然有 相同的 PIO, 并且继承了调用 e x e c v e 函数时已打开的 所有文件描述符。\n江 练习题 8. 6 编 写 一 个 叫 做 my e c h o 的 程 序, 打 印 出 它 的 命令行 参 数 和 环境 变 量。\n例如:\nlinux\u0026gt; ./myecho arg1 arg2 Command-ine arguments:\nargv[ OJ: myecho argv [ 1] : argl argv[ 2]: arg2\nEnvironment variables:\nenvp[ OJ: PWD=/usrO/droh/ics/code/ecf envp[ 1]: TERM=emacs\nenvp[25]: USER=droh\nenvp[26]: SHELL=/usr/local/bin/tcsh envp[27]: HOME=/usrO/droh\n4. 6 利 用 f or k 和 e x e c v e 运行 程序\n像 U nix s hell 和 We b 服务器这样的程序大量使用了 f or k 和 e xe c ve 函数。s hell 是一个交互型的应用级程序, 它 代表用户运行其他程序。最早的 shell 是 s h 程序,后 面出现了一些 变种,比 如 c s h、t c s h 、ks h 和 b a s h。s hell 执行一系列的读/求值( read / evaluate ) 步骤,然后终止。读步骤读取来自用户的一个命令行。求值步骤解析命令行,并代表用户运 行程序。\n图 8- 23 展示了一个简单 shell 的 ma i n 例 程 。 s hell 打印一个命令行提示符, 等待用户\n在 s t d i n 上输入命令行,然 后对这个命令行求值。\n#include \u0026ldquo;csapp.h\u0026rdquo;\n2 #define MAXARGS 128\n3\n4 I* Function prototypes *I\n5 void eval(char *cmdline);\n6 int parseline(char *buf, char **argv);\n7 int builtin_command(char **argv);\n8\n9 int main()\n10 {\n11 char cmdline[MAXLINE]; I* Command line *I\n12\n13 while (1) {\n14 I* Read *I\nprintf(\u0026quot;\u0026gt; \u0026ldquo;);\nFgets(cmdline, MAXLINE, stdin);\nif (feof (stdin))\nexit(O);\n19\n20 I* Evaluate *I\n21 eval (cmdline);\n22 }\n23 }\ncode/ecf/shellex. c\ncode/ecflshellex.c\n图 8-23 一个简单的 shell 程序的 ma i n 例程\n图 8- 24 展示了对命令行求值的代码。它的首要任务是调用 par s e l i ne 函数(见图8-25) , 这个函数解析了以空格分隔的命令行参数,并 构 造最终会传递给 e xe c ve 的 a r gv 向量。第 一个参数被假设为要么是一个内置的 s hell 命令名, 马上就会解释这个命令, 要么是一个可执行目标文件,会在一个新的子进程的上下文中加载并运行这个文件。\n如果最后一个参数是一个 \u0026quot; \u0026amp;.\u0026rdquo; 字符,那 么 p ar s e l i ne 返回 1, 表示应该在后台执行该程序( s h ell 不会等待它完成)。否则,它 返 回 0 , 表示应该在前台执行 这个 程序( shell 会等待它完成)。\n在解析了命令行之后, e v a l 函 数 调 用 b u i l t i n_ c o mma nd 函 数 ,该 函 数 检 查第一个命令 行 参数是 否是一个内置的 s he ll 命令。如果是,它 就 立 即 解 释这个命令,并 返 回值 1。否则 返回 0。简单的 s hell 只有一个内置命令——- qu i t 命令,该 命 令 会 终 止 s hell 。实际使用\n的 s h ell 有大僵的命令,比 如 p wd 、 j o b s 和 f g。\n如果 b ui l t i n_comma nd 返 回 o, 那 么 s hell 创 建 一 个 子 进 程 ,并 在 子 进程中执行所请求的程序。如果 用户要求在后台运行该程序, 那么 s hell 返回到循环的顶部, 等 待下一个命令 行。否则, s hell 使用 wa 江 p 卫 函数 等 待作 业 终 止 。 当作业终止时, s hell 就 开始下一轮迭代。\ncode/ecflshellex.c\n9 strcpy(buf, cmdline);\n1o bg = parseline (buf , argv) ;\nif (argv [OJ == NULL)\nreturn; I* Ignore empty lines *I\n13\nif (!builtin_command(argv)) {\nif ((pid = Fork()) == 0) { I* Child runs user job *I\nif (execve(argv[O], argv, environ)\u0026lt; 0) {\n7 printf (\u0026quot;%s: Command not found. \\n\u0026quot;, argv [O]);\nexit(O);\n19 }\n20 }\n21\nI* Parent waits for foreground job to terminate *I\nif (!bg) {\nint st at us·\nif (waitpid(pid, \u0026amp;status, 0) \u0026lt; 0)\nunix_error(\u0026ldquo;waitfg: waitpid err or\u0026rdquo;) ;\n27 }\nelse\nprintf(\u0026quot;%d %s\u0026quot;, pid, cmdline);\n30 }\n31 return;\n32 }\n33\nI* If first arg is a builtin command, run it and return true *I\nint builtin_command(char **argv)\n36 {\n37 if (!strcmp(argv[O], \u0026ldquo;quit\u0026rdquo;)) I* quit command *I\nexit(O);\nif (!strcmp(argv[O], \u0026ldquo;\u0026amp;\u0026rdquo;)) I* Ignore singleton \u0026amp; *I\nreturn 1;\nreturn O; I* Not a builtin command *I\ncod e/ecfrshellex.c\n图 8-24 eva l 对 shell 命令行求值\ncode/ecf/shellex.c I* parseline - Parse the command line andbuild the argv array *I\n2 int parseline(char *buf, char **argv)\n3 {\nchar *delim; int argc; int bg;\nI* Points to first space delimiter *I I* Number of args *I\nI* Background job? *I\n8 buf[strlen(buf)-1] =\u0026rsquo;\u0026rsquo;; I Replace trailing\u0026rsquo;\\n\u0026rsquo;with space *I 9 while (*buf \u0026amp;\u0026amp; (*buf ==\u0026rsquo;\u0026rsquo;)) I*Ignore leading spaces *I 10 buf++; 11 12 I* Build the argv list *I 13 argc = O; 14 while ((delim = strchr (buf,\u0026rsquo;\u0026rsquo;))) { 15 argv [argc++] = buf ; 16 *delim = \u0026rsquo; \\ O\u0026rsquo; · 1 7 buf = delim + 1· 18 while (*buf \u0026amp;\u0026amp; (*buf ==\u0026rsquo;\u0026rsquo;)) I*Ignore spaces *I 19 buf++· 20 } 21 argv [argc] = NULL; 22 23 if (argc == 0) I* Ignore blank line *I 24 return 1; 25 26 I* Should the job run in the background? *I 27 if ((bg\u0026quot;\u0026rsquo;(*argv[argc-1] \u0026ldquo;\u0026rsquo;\u0026rdquo;\u0026rsquo;\u0026rsquo;\u0026amp;\u0026rsquo;))\u0026rsquo;\u0026quot;\u0026lsquo;0) 28 argv [\u0026ndash;argc] \u0026ldquo;\u0026lsquo;NULL; 29 30 return bg;\n31 }\n图 8-25 par s e li ne 解析 shell 的一个输入行\ncodelecf/shellex.c\n注意 , 这个简单的 s h ell 是有缺陷的, 因为它并 不回收它的后台子进程。修改这个缺陷就要求使用信号,我们将在下一节中讲述信号。\n8. 5 信号\n到目前为止对异常控制流的学习中,我们已经看到了硬件和软件是如何合作以提供基 本的低层异常机制的。我们也看到了操作系统如何利用异常来支持进程上下文切换的异常 控制流形式 。在本节中, 我们将研究一种更高层 的软件形式的异常, 称为 L in ux 信号,它允许进程和内核中断其他进程。\n一个信号就是一条小消息,它 通知进程系统中发生了一个某种类型的事件 。比如,图 8-26\n展示了 L in u x 系统上支持的 30 种不同类型的信号。\n每种信号类型都对应于某种系统事件。低层的硬件异常是由内核异常处理程序处理的,正 常情况下,对用户进程而言是不可见的。信号提供了一种机制,通知用户进程发生了这些异常。比如,如果一个进程试图除以0, 那么内核就发送给它一个SIGFPE信号(号码8)。如果一个进\n程执行一条非法指令, 那么内核就发送给它一个SIGILL 信号(号码4) 。如果进程进行非法内存引用,内 核就发送给它一个SIGSEGV信号(号码11)。其他信号对应千内核或者其他用户进程中较高层的软件事件。比如, 如果当进程在前台运行时, 你键入 Ctrl+ CC也就是同时按下 Ctrl 键和 C 键), 那么内核就会发送一个 SIGINT 信号(号码2) 给这个前台进程组中的每个进程。一个进程可以通过向另 一个进程发送一个 SIGKILL 信号(号码9) 强制终止它。当一个子进程终止或者停止时,内核会发送一个SIGCHLD 信号(号码17) 给父进程。\n序号 名称 默认行为 相应事件 1 SIGHUP 终止 终端线挂断 2 SIGINT 终止 来自键盘的中断 3 SIGQUIT 终止 来自键盘的退出 4 SIGILL 终止 非法指令 5 SIGTRAP 终止并转储内存(l) 跟踪陷阱 6 SIGABRT 终止并转储内存(!) 来自 a b or t 函数的终止信号 7 SIGBUS 终止 总线错误 8 SIGFPE 终止并转储内存° 浮点异常 9 SIGKILL 终止© 杀死程序 10 SIGUSRI 终止 用户定义的信号1 11 SIGSEGV 终止并转储内存° 无效的内存引用(段故障) 12 SIGUSR2 终止 用户定义的信号2 13 SIGPIPE 终止 向一个没有读用户的管道做写操作 14 SIGALRM 终止 来自a l a r m 函数的定时器信号 15 SIGTERM 终止 软件终止信号 16 SIGSTKFLT 终止 协处理器上的栈故隐 17 SIGCHLD 忽略 一个子进程停止或者终止 18 SIGCONT 忽略 继续进程如果该进程停止 19 SIGSTOP 停止直到下一个 SIGCONT@ 不是来自终端的停止信号 20 SIGTSTP 停止直到下一个SIGCONT 来自终端的停止信号 21 SIGTTIN 停止直到下一个 SIGCONT 后台进程从终端读 22 SIGTTOU 停止直到下一个 SIGCONT 后台进程向终端写 23 SIGURG 忽略 套接字上的紧急情况 24 SIGXCPU 终止 CPU 时间限制超出 25 SIGXFSZ 终止 文件大小限制超出 26 SIGVTALRM 终止 虚拟定时器期满 27 SIGPROF 终止 剖析定时器期满 28 SIGWINCH 忽略 窗口大小变化 29 SIGIO 终止 在某个描述符上可执行 J/0 操作 30 SIGPWR 终止 电源故障 图 8-26 L in ux 信 号\n汪: O 多年前, 主存是用一种称为磁 芯存 储器( core memory) 的技术来实现的。“转储 内存\u0026rdquo; ( dump ing core ) 是 一个历史术语 ,意 思是把代码和数据内存 段的映像写到磁盘上。\n@)这个信 号既 不能被捕获,也 不能被忽略。\n(来源: man 7 signal。数据来自 Linux Found ation. )\n5. 1 信号术语\n传送一个信号到目的进程是由两个不同步骤组成的:\n发送信号。内核 通过更新目的进程上下文中的 某个状态, 发送(递送)一个信号给目的进程。发送信号可以有如下两种原因: 1) 内 核检测到一个系统事件, 比如除零错误或者子进程终止。2) 一个进程调用了 ki ll 函数(在下一节中讨论),显 式地要求内核发送一个信号给目的进程。一个进程 可以发 送信号给它自己 。 ·接收信号。当目的进程被内核强迫以某种方式对信号的发送做出反应时,它就接收了 信号。进程可以忽略这个信号,终 止或 者 通过执行一个称为信号处理 程序( sig nal han­ dler ) 的用户层函数捕获这个信号。图 8-27 给出了信号处理程序捕获信号的基本思想。\n( I ) 进程接 I\ncurr\n收到信号\nIn,.,\n( 3 ) 信号处理程序运行\n图 8- 27 信号处理。接收到信号会触发控制转移到信号处理程序 。在信号处理程序完成处理之 后, 它将控制返回给被中断的程序\n一个发出而没有被接收的信号叫做待处理信号( pe nd ing s ig n al) 。在 任何 时 刻 , 一种类型至多 只会有一个待处理信号。如果一个进程有一个类型为 K 的 待 处 理 信 号 ,那 么 任何接下 来发送到这个进程的类型为 K 的 信 号 都 不会排队等待;它 们 只 是 被 简单地丢弃。一个进程 可 以 有 选择性地阻塞接收某种信号 。当一种信号被阻塞时, 它 仍 可 以 被发送, 但是产生的待 处 理信号不会被接收, 直到 进程 取 消对这种信号的阻塞。\n一个待处理信号最多只能被接收一次。内核为每个进程在 p e n d i n g 位向量中维护着待处 理信号的集合, 而在 b l o c ke d 位向量e 中维护着被阻塞的信号集合。只要传送了一个类型为 K 的 信 号 ,内 核 就 会 设 置 p e n d i n g 中的第 k 位, 而 只 要 接 收 了 一 个类型为 K 的信号 ,内 核 就 会 清 除 p e n d i ng 中 的 第 K 位。\n5. 2 发送信号\nU nix 系统提供了大量向进程发送信号的机制。所有这些机制都是基于进程组( pro cess gro u p ) 这个概念的。\n进程组\n每个进程都只属于一个进程组, 进程组是由一个正整数进程组 ID 来标识的。ge t pgr p\n函数返回当前进程的进程组 ID :\n#include \u0026lt;unistd.h\u0026gt;\npid_t getpgrp(void);\n返回: 调 用进 程的 进 程 组 ID。\n默认地,一 个 子 进程和它的父进程同属千一个进程组。一个进程可以通过使用 s e t ­ pg i d 函数来改变自己或者其他进程的进程组:\n#include \u0026lt;unistd.h\u0026gt;\nint setpgid(pid_t pid, pid_t pgid);\n返回: 若 成 功 则 为 0 , 若铸 误 则为 一1。\ns e 七p g i d 函 数 将 进程 p 过 的进程组改为 pg i d。如果 p i d 是 o, 那么就使用当前进程\n8 也称为信号掩码( sig na l ma s k ) 。\n的 PID。如果 pg过 是 o, 那么就用 pi d 指定的进程的 PID 作为进程组 ID。例如, 如果进程 1521 3 是调用进程, 那么\nsetpgid(O, O);\n会创建一 个新的进程组,其 进程组 ID 是 15213, 并且把进程 15213 加入到这个新的进程\n组中。\n用/ b i n / k i l l 程序发 送信号\n/ b i n / k过1 程序可以向另外的进程发 送任意的 信号。比如,命 令\nlinux\u0026gt; /bin/kill -9 15213\n发送信号 9(SIGKIL L) 给进程 15213。一 个为负的 PID 会导致信号被发送到进程组 PID 中的每个进程 。比如,命 令\nlinux\u0026gt; /bin/kill -9 -15213\n发送一个 SIGKILL 信号给进程组 15213 中的每个 进程。注意, 在此我们使用完整路径/\nbi n / k i ll , 因为有些 U nix s h ell 有自己内 置的 k i ll 命令。\n从键盘发送信号\nUnix shell 使用作业( jo b ) 这个抽象概念来表示为对一条命令行求值而创建的进程。在任何时刻, 至多只有一个前台作业 和 0 个或多个后台作业。比如 , 键入\nlinux\u0026gt; ls I sort\n会创建一个由两个进程组成的前 台作业, 这两个进程是通过 U n ix 管道连接起来的: 一个进程运 行 l s 程序, 另一个运行 s or t 程序。s h ell 为每个作 业创建 一个独立的 进程组。进程组 ID 通常取 自作 业中父进程中的一个 。比如, 图 8-28 展示了有一个前台作业和两个后台作业的 s h e ll 。前台作业中的父进程 PID 为 20 , 进程组 ID 也为 20。父进程创建两个子进程,每 个也都是进程组 20 的成员 。\np i d = 21 pid=22\npgid=20 pgid=20\n-\u0026mdash;\u0026mdash;\u0026mdash;\u0026mdash;\u0026mdash;\n前台进程组20\n图 8-28 前 台 和后 台 进程组\n在键盘上输入 Ctrl + C 会导致内核发送一个 SIGINT 信号到前台进程组中的每个进程。默认情况下 ,结果 是终止前台作业。类似地, 输入 Ctrl + z 会发送一个 SIGTST P 信号到前台进程组中 的每个进程。默认情 况下, 结果是停止(挂起)前台作业。\n用 k i l l 函数发 送信号\n进程通过调用 K过 1 函数发送信号 给其他进程(包括它们 自己)。\n#include \u0026lt;sys/types.h\u0026gt;\n#include \u0026lt;signal.h\u0026gt;\nint kill(pid_t pid, int sig);\n返回: 若 成 功 则 为 o, 若 错 误 则 为 一1。\n如果 p i d 大于零, 那么 k i ll 函数发送信号号码 s i g 给进程 p i d 。如果 p i d 等千零 , 那么k i ll 发送信号 s i g 给调用进 程所在进程组中的每个 进程, 包括调用进程自己。如果 p i d 小千零, k i ll 发送信号 s i g 给进程组 I pid I ( p i d 的绝对值)中的每个进程。图 8- 29 展示了一个示例, 父进程用 ki ll 函数发送 SIGK ILL 信号给它的 子进程。\ncode/ecf/kill.c\n#include \u0026ldquo;csapp.h\u0026rdquo;\n2\n3 int main()\n4 {\ns pid_t pid;\n6\n7 I* Child sleeps until SIGKILL signal received, then dies *I\n8 if ((pid = Fork()) == 0) {\n9 Pause(); I* Wait for a signal to arrive *I\n10 printf(\u0026ldquo;control should never reach here!\\n\u0026rdquo;);\n11 exit(O);\n12 }\n13\n14 f* Parent sends a SIGKILL signal to a child *I\nKill (pid, SIGKILL) ;\nexit(O);\n17 }\ncode/ecf/kill.c\n图 8-29 使用 K过 1 函数发送信号 给子进 程\n用 a l a rm 函数发送信号\n进程可以通过调用 a l a r m 函数向它自己发送 S IGALRM 信号。\na l ar m 函数安排内核在 s e c s 秒后发送一个 S IGALRM 信号给调用进程。如果 s e cs 是零, 那么不会调度安排新的闹 钟( a lar m ) 。在任何情况下, 对 a l ar m 的调用都将取消任何 待处理的( pe nd in g ) 闹钟, 并且返回任何待处理的闹钟在被发送前还剩下 的秒数(如果这次对 a l ar m 的调用没有取消它的 话); 如果没有任何待处理的闹钟,就返 回零。\n5. 3 接收信号\n当内核把进程 p 从内核模式切换到用户模式时(例如, 从系统调用返回或是完成了一次上下文切换), 它会检查进程 p 的未被阻塞的待处理 信号的集合 ( p e nd i ng \u0026amp; ~b l o c ke d ) 。如果这个集合 为空(通常情况下), 那么内核将控制 传递到 p 的逻辑控制流中的下一条指令 (J next ) 。 然而 , 如果集合是非空的 , 那么内核选择集合中的某个信号 k ( 通常是最小的 k ) , 并且强制 p 接收信号 k 。收到这个信号会触发进 程采取 某种行为。一旦进程完成了这个行为,那 么控制就传递回 p 的逻辑控制流中的下一条指令( J next ) 。 每个信号类型都有一个预定义的默认行为,是下面中的一种:\n进程终止。\n进程终止并转储内存。\n进程停止(挂起)直到被 SIG CO NT 信号重启。\n进程忽略该信号。\n图 8- 26 展示了与每个信号类 型相关联的默认行为。比 如, 收到 S IG K IL L 的默认行为就是终止 接收进程。另外, 接收到 S IGCH LD 的默认行 为就是忽略这个信号。进程可以 通过使用 s i g na l 函数修改和信号相关联的默认行为。唯一的 例外是 SIGS T OP 和 SIG K I L L , 它们的默认行为是不能修改的。\n#include \u0026lt;signal.h\u0026gt;\ntypedef void (*sighandler_t)(int);\nsighandler_t signal(int signum, sighandler_t handler);\n返回: 若 成 功则 为 指 向 前 次 处 理 程 序 的 指 针 , 若 出错 则 为 SIG_ERR C不设 置 err no )。\ns i g na l 函数可以通过下列 三种方法之 一来改变和信号 s i g n um 相关联的行 为:\n如果 h a n d l er 是 SIG _IG N , 那么忽略类型为 s i g num 的信号。 如果 ha nd l er 是 S IG _DF L , 那么类型为 s i g nu m 的 信号行为恢复为默认行 为。\n否则, ha ndl e r 就是用户定义的函数的地址,这个 函数被称为信 号处理 程序,只 要进程接收到一个类型为 s i g nwn 的信号, 就会调用这个程序。通 过把处理程序的 地址传递到 s i gna l 函数从而改变默认行为,这 叫做设置信 号处理 程序( installing the han­\ndler) 。调用信号处理程序被称为捕 获信号。执行信号处理程序被称 为处理信号。\n当一个 进程捕 获了一个类型为 K 的信号时, 会调用为信号 k 设置的处理程序, 一个整数参数被设置 为 K。 这个参数允许同一个处理函数捕获不同类 型的信号。\n当处理程序执行它 的 r e t ur n 语句时, 控制(通常)传递回控制流中进程被信号 接收中断位置处的指令。我们说“通常”是因为在某些系统中,被中断的系统调用会立即返回一 个错误。\n图 8-30 展示了一个 程序,它 捕获用户在键盘上输入 C t rl + C 时发送的 S IG I NT 信号。SIGINT 的默认行为是立 即终止该进程。 在这个示例中, 我们将默认行为修改为捕获信号,输出一条消息,然后终止该进程。\n信号处理程序可以被其 他信号处理程序中断, 如图 8-31 所示。在这个例子中, 主程\n,, 序捕获到信号 s\u0026rsquo; 该 信 号会中断主程序, 将控制转移 到处理程序 S。S 在运行时, 程序捕获信号 t #- s , 该信号会中断 s, 控制转移到 处理程序 T 。当 T 返回时 , S 从它被中断的地方继续 执行。最 后, S 返回, 控制传送回主程序 , 主程序从它 被中断 的地方继续执行。\n#include \u0026ldquo;csapp.h\u0026rdquo;\n2\ncode/ecf/sigint.c\n3 void sigint_handler(int sig) I* SIGINT handler *I\n4 {\ns printf(\u0026ldquo;Caught SIGINT!\\n\u0026rdquo;);\n6 exit(O);\n7 }\n8\n9 int main()\n10 {\nI* Install the SIGINT handler *I\n2 if (signal(SIGINT, sigint_handler) == SIG_ERR)\n3 uni.x_error(\u0026ldquo;signal error\u0026rdquo;);\n14\n15 pause(); I* Wait for the receipt of a signal *I\n16\n17 return O·\n18 }\ncode/ecf/sigint.c\n图 S :111 一 个用信号处理程序捕获 SIGINT 信号 的 程 序\n主程序\n( I ) 程序捕获信号s\nCWT\n主程序继续执行 I\u0026quot;°\u0026rsquo;\u0026rsquo;\n( 2 ) 控制信号传递给处理程序S\n处理程序S 处理程序T\n图 8飞 l 信号处理程序可以被其他信号处理程序中断\n让 练习题 8. 7 编写 一个叫做 s no o z e 的程序 , 它 有 一个命令行参 数, 用 这个参数调用练 习题 8. 5 中的 s n o o z e 函数 , 然 后终 止。编写 程 序, 使 得用 户 可以 通过在键 盘上输入 C t rl + C 中断 s n o o z e 函 数。 比如:\nlinux\u0026gt; ./ snooze 5\nCTRL+C\nSlept for 3 of 5 secs. linux\u0026gt;\n8. 5. 4 阻塞和解除阻塞信号\nUser hi t s Cr t l +C after 3 seconds\nLinux 提供阻塞信号的隐式和显式的机制:\n隐式阻塞机制。内核默认阻塞任何当前处理程序正在处理信号类型的待处理的信号。 例如,图 8-31 中 , 假设程序捕获了信号 s\u0026rsquo; 当前正在运行处理程序 S 。如果发送给该进程另 一 个 信 号 s , 那 么 直 到 处 理 程序 S 返回, s 会 变成待处理而没有被接收。\n显式阻寒机制。应用程序可以使用 s i g p r o c ma s k 函 数 和它的辅助函数,明 确地阻塞和解除阻塞选定的信号。\n#include \u0026lt;signal.h\u0026gt;\nint sigprocmask(int how, const sigset_t *set, sigset_t *oldset); int sigemptyset(sigset_t *set);\nint sigfillset(sigset_t *set);\nint sigaddset(sigset_t *Set, int signum); int sigdelset(sigset_t *set, int signum);\nint sigismember(const sigset_t *set, int signum);\n返回: 如 果 成 功 则为 0\u0026rsquo; 若 出错 则为 - 1。\n返回: 若 s i gnum 是 set 的 成 员 则 为 1, 如 果 不是 则 为 0\u0026rsquo; 若 出错 则 为 - 1。\ns i g p r o c ma s k 函数改变当前阻塞的信号集合C 8. 5. 1 节中描述的 block ed 位向最)。具体的行为依赖 于 h o w 的 值 :\nSIG_BLOCK: 把 s e t 中的信号添加到 b l o c ke d 中( b l o c ke d=b l o c ke d I s e t ) 。SIG_ UNBLOCK: 从bl oc ked 中删除 s e t 中的信号( b l o c ke d =b l o c ke d \u0026amp;\u0026ndash;se t ) 。SIG_SETMASK: bl oc k=se t 。\n如果 o l d s e t 非空, 那么 b l o c ke d 位向量之前的值保存在 o l d s e t 中。\n使用下述函数对s e t 信号集合进行操作: s i ge mpt ys e t 初始化 s e t 为空集合。s i g f i ll s e t 函数把每个信号都添加到 s e t 中。s i ga dd s e t 函数把s i g nurn 添加到 s e t , s i gde l s e 七从 s e t 中删除 s i gnurn, 如果 s i g nurn 是 s e t 的成员 , 那么 s i gi s me mber 返回 1, 否则返回0。\n例如, 图 8-32 展示了如何用 s i gpr o c ma s k 来临时阻 塞接收 S IG INT 信号。\nsigset_t mask, prev_mask;\n2\n3 Sigemptyset(\u0026amp;mask);\n4 Sigaddset(\u0026amp;mask, SIGINT);\n5\nI* Block SIGINT a 丑 d save previous blocked set *I Sigprocmask(SIG_BLOCK, \u0026amp;mask, \u0026amp;prev_mask); 8 : // Code region that will not be interrupted by SIG INT\n9 I* Restore previous blocked set, unblocking SIGINT *I\n10 Sigprocmask(SIG_SETMASK, \u0026amp;prev_mask, NULL);\n11\n5. 5 编写信号处理程序\n图 8- 32 临时阻塞接收 一个信号\n信号处理是 L in ux 系统编程最棘手的一个问题。处理程序有几个属性使得它们很难推理分析: 1) 处理程序与主程序并发 运行 , 共享同样的全局变量, 因此可能 与主程序和其他处理程序互相干扰; 2 ) 如何以及何时接收信号的规则常常有违人的直觉; 3 ) 不同的系统有不同的信号处 理语义。\n在本节中 ,我们 将讲述这些问题, 介绍编写安全、正确和可移植的信号处理程序的一些基本规则 。\n安全的信号处理\n信号处理程序很麻烦 是因为它们 和主程序以及其他信号处理程序并 发地运行 , 正如我们在图 8-31 中看到的那样。如果处理程序和主程 序并发地访问同样的全局数据结构, 那\n么结果可能就不可预知,而且经常是致命的。\n我们会在第 12 章详细讲述并 发编程。这里我们的目标是给你一些保守的编写处理程序的原则, 使得这些处理程序能安全地并 发运行 。如果你忽视这些原则, 就可能有引入细微的并发错误的风险。如果有这些错误,程序可能在绝大部分时候都能正确工作。然而当 它出错的时候, 就会错得不可预测和不可重复 , 这样是很难调试的。一定要防患于未然!\nGO. 处理程序要尽可能简单。避免麻烦的最好方法是保持处理程序尽可能小和简单。例如, 处理程序可能只是简单地设置全局标志并立即返回; 所有与接收信号相关的处理都 由主程序执行 , 它周期性地检查(并重置)这个标志。 Gl. 在处理程序中只调用异步 信号安全的函数。所谓异步信号安全的函数(或简称安全的函数)能够被信号处理程序安全地调,用原 因有二: 要么它是可重入的(例如只访问局部变量, 见 12. 7. 2 节),要么它不能被信号处理程序中断。图 8-33列出了 Linux 保证安全的系统级函数。注意, 许多常见的函数(例如pr i ntf 、s pr i nt f 、mall oc 和 e xi t )都不在此列。 _Exit fexecve poll sigqueue exit fork posix_trace_event sigset abort fstat pselect sigsuspend accept fstatat raise sleep access fsync read sockatmark aio_error ftruncate readlink socket aio_return futimens readlinkat socketpair aio_suspend getegid recv stat alarm geteuid recvfrom symlink bind getgid recvmsg symlinkat cfgetispeed getgroups rename tcdrain cfgetospeed getpeername renameat tcflow cfsetispeed getpgrp rmdir tcflush cfsetospeed getpid select tcgetattr chdir getppid sem_post tcgetpgrp chmod getsockname send tcsendbreak chown getsockopt sendmsg tcsetattr clock_gettime getuid sendto tcsetpgrp close kill setgid time connect link setpgid timer_getoverrun creat linkat setsid timer_gettime dup listen setsockopt timer_settime dup2 lseek setuid times execl lstat shutdo\u0026rsquo;\\ffi umask execle mkdir sigaction 皿 ame execv mkdirat sigaddset unlink execve mkfifo sigdelset unlinkat faccessat mkfifoat sigemptyset ut i me fchmod mknod sigfillset utimensat fchmodat mknodat sigismember utimes fcho= open signal wait fchownat openat sigpause waitpid fcntl pause sigpending write fdatasync pipe sigprocmask 图 8-33 异步信号安全的函数(来源: ma n 7 signal。数据来自 Lin ux Foundation)\n信号处理程序中产生输出唯一安全的方法是使用 wr i t e 函数(见10. 1 节)。特别地, 调用 pr i n 七f 或 s p r i n t f 是 不安全的。为了绕 开这个不幸的限制, 我们开发一些 安全的函数,称为 S IO ( 安全的 I/ 0 ) 包,可 以用来在信号处理程序中打印简单的消息。\n#include \u0026ldquo;csapp.h\u0026rdquo;\nssize_t sio_putl(long v); ssize_t sio_puts(char s[]);\nvoid sio_error(char s[]);\n返回: 如 果 成 功则 为 传 送 的 字 节数 ,如 果 出错 , 则 为 一1。\n返回: 空。\nsio p u t l 和 s i o p u t s 函数分别向标准输出传送一个 l o n g 类型数和一个字符串。\nsio e rr or 函数打印一条错误消息并终止。\n图 8- 34 给出的是 SIO 包的实现, 它使用了 c s a p p . c 中两个私有的可重入函数。第 3 行的 s i o_ s 七r l e n 函数返回字符串 s 的长度。第 10 行的 s i o _ l 七o a 函数基于来自[ 61] 的it o a 函数, 把 v 转换成它的基 b 字符串表示, 保存在 s 中。第 1 7 行的_ e x i t 函数是 e x 江的一个异步信号安全的变种。\ncodelsrc/csapp.c\nssize_t sio_puts(char s[]) I* Put string *I\n2 {\n3 return write(STDOUT_FILENO, s, sio_strlen(s));\n4 }\n5\n6 ssize_t sio_putl(long v) I* Put long *I\n7 {\n8 char s (128) ;\n9\n10 sio_ltoa(v, s, 10); I* Based on K\u0026amp;R itoa() *I\n11 return sio_puts(s);\n12 }\n13\n14 void sio_error(char s[]) I* Put error message and exit *I\n15 {\n16 sio_puts(s);\n17 _exit(!);\n18 }\n图 8-3.J 信号处理程序的 SIO C安全 I/0) 包\ncodelsrc/csapp.c\n图 8-3 5 给出了图 8- 30 中 S IG I NT 处理程序的一个 安全的版本。\ncode/ecflsigintsafe.c\n#include \u0026ldquo;csapp.h\u0026rdquo;\n2\n3 void sigint_handler(int sig) I* Safe SIGINT handler *I\n4 {\ns Sio_puts(\u0026ldquo;Caught SIGINT!\\n\u0026rdquo;); I* Safe output *I\n6 _exit(O); I* Safe exit *I\n7 }\ncode/ecf/sigien.ctsaf\n图 8-35 图 8-30 的 SIGINT 处理程序的 一个安全版本\nG2. 保存和恢复 err no 。许多 Lin ux 异步信号安全的函数都会在出错返回时设置e rr no 。 在处理 程序中调用 这样的函数可能会干扰 主程序中其他依赖于 e r r no 的部分。解决方法是在进入处 理程序时把 err no 保存在一个局部 变量中 , 在处理程序返回前恢复它。注意,只有在处理程序要返回时才有此必要。如果处理程序调用\n_e x i t 终止该进程 , 那么就不需要这样做 了。\nG3. 阻塞 所有的信 号, 保护对共享全局数 据结构的访问。如果处理程序和主程序或其他处理程序共享一个全局数据结构, 那么在访问(读或者写)该数据结构时, 你的处理程序和主程序应该暂时阻塞所有的 信号。这条规则的原因 是从主程序访问一个数据结构 d 通常需要一系列的指令 , 如果指令序列被访问 d 的处理程序中断, 那么处理程序可能会发现 d 的状态不一致, 得到不可预知的结果。在访问 d 时暂时阻塞信号保证了处理程序不会中断该指令序列。\nG4. 用 vol a t i l e 声明全 局变量 。考虑一个处理程序和一个mai n 函数, 它们共享一个全局变 量 g。处理程序更新 g , mai n 周期性地读 g。对于一个优化编译器而言, mai n 中 g 的值看上去从来没有变化过, 因此使用缓存在寄存器中g 的副本来满足对g 的每次引用是很安全的。如果这样, mai n 函数可能永远都无法看到处理程序更新过的值。\n可以用 vol a已 l e 类型限定符来定义一个变量,告 诉编译器不要缓存这个量变。例如:\nvolatile int g;\nvol a巨 l e 限定符强迫编译器每次在代码中引用 g 时, 都要从内存中读取 g 的值。一般来说 , 和其他所有共享数据结构一样, 应该暂时阻塞信号, 保护每次对全局变量的访问。\nGS. 用 s i g_a t omi c _ 七 声明标志 。在常见的处理程序设计中, 处理程序会写全局标志来记录收到了信 号。主程序周期性 地读这个标志, 响应信号,再 清除该标志。对千通过这种方式 来共享的标志, C 提供一种整型数据类型 s i g _ a t omi c _ 七,对它的读和写保证会是原子的(不可中断的), 因为可以用 一条指令来实现它们:\nvolatile sig_atomic_t flag;\n因为它们是不 可中断的,所 以可以安全地读和写 s i g _a t omi c _ t 变量,而不需要暂时阻塞信号。注意 , 这里对原子性的保证只适用于单个的读和写, 不适用于像f l a g + + 或 fl a g=fl a g +l O 这样的更新, 它们可能需 要多条指令。\n要记住 我们这里讲述的规则是保守的 ,也 就是说它们不总是严格必需的。例如,如果你知道处理 程序绝对 不会修改 err no , 那么就不需要保存和恢复 err no 。或者如果你可以证明 pr i nt f 的实例都不会被处理 程序中断 , 那么在处理程序中 调用 pr i n t f 就是安全的。对共享全局数据结构的访问也是同样。不过,一般来说这种断言很难证明。所以我们建议 你采用保守的方法,遵循这些规则,使得处理程序尽可能简单,调用安全函数,保存和恢\n复 er r n o , 保护对共享数 据结构的访问, 并使用 v ol a t i l e 和 s i g _a t omi c _ t 。\n正确的信号处理\n信号的一个与直觉不符的方面是未处理的信号是不排队的。因为 p e nd i ng 位向量中每种类型的 信号只对应有一位, 所以每种类型最多 只能有一个未处理的信号。因此,如果两个类型 k 的信号发送 给一个目的进程,而 因为目的进程当前正在执行信号 k 的处理程序, 所以信号 k 被阻塞了, 那么第二个信号就简单地被丢 弃了;它 不会排队。关键思想是 如果存在一个未处理的信号就表明至少有一个 信号到达了。\n要了解这样会如何影响正确性, 来 看 一个简单的应用, 它 本 质 上 类似于像 sh ell 和\nWeb 服务器这样的真实程序。基本的结构是父进程创建一些子进程,这 些子进程各自独立运行一 段时间, 然后终止。父进程必须回收子进程以避免在系统中留下僵死进程。但是我们还 希 望 父 进 程 能 够 在 子 进 程 运 行 时 自 由 地 去 做 其 他 的 工 作。所以, 我 们 决 定 用\nSIGCHLD 处 理程序来回收子进程, 而不是显式地等待子进程终止。(回想一下, 只 要 有一个子进程终止或者停止, 内 核 就会发 送一个 SIGCHLD 信号给父进程。)\n图 8-36 展示 了我们的初次尝试。父进程设 置了一 个 SIGCH LD 处理程序, 然后创建\ncode/ecf/signall .c\nI* WARNING: This code is buggy! *I\n2\n3 void handlerl(int sig)\n4 {\n5 int olderrno = errno;\n6\n7 if ((waitpid(-1, NULL, 0)) \u0026lt; 0)\n8 sio_error(\u0026ldquo;waitpid er or\u0026rdquo;) ;\nSio_puts(\u0026ldquo;Handler reaped child\\n\u0026rdquo;);\nSleep(!);\nerrno = olderrno;\n12 }\n13\n14 int main()\n15 {\nint i, n;\nchar buf[MAXBUF];\n18\nif (signal(SIGCHLD, handler!)== SIG_ERR)\nunix_error (\u0026ldquo;signal error\u0026rdquo;);\n21\n22 I* Parent creates children *I\n23 for (i = 0; i \u0026lt; 3; i ++) {\n24 if (Fork() == 0) {\nprintf(\u0026ldquo;Hello from child %d\\n\u0026rdquo;, (int)getpid());\nexit(O);\n27 }\n28 }\n29\nI* Parent waits for terminal input and then processes it *I\nif ((n = read(STDIN_FILENO, buf, sizeof(buf))) \u0026lt; 0)\nun i x _err or (\u0026ldquo;read\u0026rdquo;) ;\n33\nprintf(\u0026ldquo;Parent processing input\\n\u0026rdquo;);\nwhile (1)\n36\n37\n38 exit (0);\n39 }\ncod/eecflignall.c\n图8-36 signa ll : 这个程序是有缺陷的 , 因 为它假设信号是排队的\n了 3 个子进程。同时, 父进程等待来自终端的一个输入行,随 后 处 理 它 。 这个处理被模型化 为一个无限循环。当每个子进程终止时,内 核 通过发送一个 S IG C H LD 信号通知父进程。父进程捕获这个 SIG C H L D 信号, 回 收 一 个 子 进程, 做 一 些 其他的清理工作(模型化为 s l e e p 语句), 然后返回。\n图 8- 36 中的 s i g na l l 程序看起来相当简单。然而, 当 在 L in u x 系统上运行它时, 我\n们得到如下输出:\nlinux\u0026gt; ./signal1\nHello from child 14073 Hello from child 14074 Hello from child 14075 Handler reaped child Handler reaped child CR\nParent processing input\n从输出中我们 注意到,尽 管 发送了 3 个 SIGC H LD 信号给父进程,但 是其中只有两个信号被接收了,因此父进程只是回收了两个子进程。如果挂起父进程,我们看到,实际上子进程14075 没有被回收,它成 了一 个僵 死 进程(在p s 命令的输出中由字符串 \u0026quot; de f unc t \u0026quot; 表明):\nCtrl+Z Suspended linux\u0026gt; ps t\nPID TTY STAT TIME COMMAND 14072 pts/3 T O: 02 . /signall 14075 pts/3 Z 0:00 [signall] \u0026lt;defunct\u0026gt; 14076 pts/3 R+ 0:00 ps t 哪里出错了呢?问题就在于我们的代码没有解决信号不会排队等待这样的情况。所发 生的 情 况 是 : 父进程接收并捕获了第一个信号。当处理程序还在处理第一个信号时, 第二个 信 号 就 传 送并 添 加 到了待处理信号集合里。然而,因 为 SIG C H LD 信号被 SIG C H LD 处理程序阻塞了,所 以 第二个信号就不会被接收。此后不久,就 在 处 理 程序还在处理第一个信 号 时 ,第 三个信号到达了。因为已经有了一个待处理的 S IG C H L D , 第三个 S IG C H LD 信号 会被 丢弃。一段时间之后, 处理程序返回,内 核 注意到有一个待处理的 S IG C H LD 信号 , 就迫使父进程接收这个信号。父进程捕获这个信号, 并第二次执行处理程序。在处理程序完成对第二个信号的处理之后, 已经没有待处理的 S IG C H L D 信号了, 而且也绝不会再 有,因 为第三个 S IG C H L D 的所有信息都已经丢失了。由此得到的重要 教 训是, 不 可以用信 号来对其他进程中发 生的 事件计数。\n为了修正这个问题,我们必须回想一下,存在一个待处理的信号只是暗示自进程最后 一次收到一个信号以来, 至少已经有一个这种类型的信号被发送了。所以我们必须修改S IG C H L D 的 处 理 程序,使 得每次 S IG C H LD 处理程序被调用时 , 回 收 尽 可能多的僵死子进程。图 8- 37 展示了修改后的 SIGC H L D 处理程序。\n当我们在 Lin u x 系统上运行 s i g n a l 2 时, 它 现 在可以正 确地回收所有的僵死子进程了:\nlinux\u0026gt; ./signal2\nHello from child 15237\nHello from child 15238 Hello from child 15239 Handler reaped child Handler reaped child Handler reaped child CR\nParent processing input\nvoid handler2(int si g)\n2 {\n3 int olderrno = errno;\n4\ns while (waitpid(-1, NULL, 0) \u0026gt; 0) {\n6 Sio_puts(\u0026ldquo;Handler reaped ch辽d\\ n\u0026rdquo;) ;\n7 }\n8 if (errno != ECHILD)\n9 Sio_error(\u0026ldquo;waitpid error\u0026rdquo;);\nSleep(i);\nerrno = olderrno; 12 }\ncode/ecfilgsna/2.c\ncode/ecfl signal2.c\n图 8-3 7 s i gnal 2: 图 8-36 的一个改进版本 , 它能够正确解决信号不 会排队 等待的情况\n沁 练习题 8. 8 下 面这 个程序 的输 出是 什么?\nvolatile long counter= 2;\n2\n3 void handlerl(int sig)\n4 {\n5 sigset_t mask, prev_mask;\n6\nSigfillset(\u0026amp;mask); codelecfls ig nalp ro bO.c\nSigprocmask(SIG_BLOCK, \u0026amp;mask, \u0026amp;pr ev _ma s k) ; I* Block sigs *I\n9 Si o_putl (- - c oun t er ) ;\n10 Si gpr oc mas k (SI G_SETMAS K , \u0026amp;prev_mask, NULL); I* Restore sigs *I\n11\n12 _e x i t ( O) ;\n13 }\n14\n15 int main()\n16 {\npid_t pid;\nsigset_t mask, prev_mask;\n19\nprintf (11%ld11 , counter) ;\nf fl us h ( s t dout ) ;\n22\n23 signal (SIGUSR1, handler!) ;\n24 if ((pid = Fork()) == 0) {\n25 Yhile(1) {};\n26\nKill (pid, SIGUSR1);\nWaitpid(-1, NULL, 0);\n29\nSigfillset (\u0026amp;mask);\nSigprocmask(SIG_BLOCK, \u0026amp;mask, \u0026amp;prev_mask); I* Block sigs *I\nprintf (11%ld11 , ++counter) ;\nSigprocmask(SIG_SETMASK, \u0026amp;prev_mask, NULL); I* Restore sigs *I\n34\n35 exit (0);\n36 }\n可移植的信号处理\ncode/ef俎ic gnalprobO.c\nU n ix 信号处理的另一个缺陷在于不同的系统有不同的信号处理语义。例如:\ns i g n a l 函数的语 义各 有不同。有 些老的 U n ix 系统在信号 K 被处理程序捕获之后就把对信号k 的反应恢 复到默认值。在这些 系统上, 每次运行 之后, 处理程序必须调用 s i g n a l 函数, 显式地重新设置它自己。\n系统调用可 以被中断 。像 r e a d 、 wr i 七e 和 a c c e p t 这样的系统调用潜在地会阻塞进程一段较长的时间 , 称为慢 速 系统调用。在 某些较早版本的 U nix 系统 中, 当处理程序捕 获到一个信号时 , 被中断的慢速系统 调用在信号处理程序返回时不再继续, 而是立即返回给用户一个错误条件, 并将 e rr n o 设置为 E I N T R 。在这些系统上, 程序员必须 包括手动重启 被中断的系统调用的代码。\n要解决这些问 题, P o s ix 标准定 义了 s i g a c t i o n 函数, 它允许用户在设置信号处理时,明确指定他们想要的信号处理语义。\n#include \u0026lt;signal.h\u0026gt;\nint sigaction(int signum, struct sigaction *act, struct sigaction *oldact);\n返回: 若 成 功则 为 0 , 若 出错 则 为 - I .\ns i g a c t i o n 函数运用并不广泛, 因为它要求用户设置一个复杂结构的条目。一个更简洁的方 式, 最初是由 W. Richard Stevens 提出的[ 11 0 ] , 就是定义一个包装 函数, 称为\nSignal, 它调用 s i g a c t i o n。图 8-38 给出了 S i g n a l 的定义,它的 调用方式与 s i g na l 函\n数的调用方式一样。\nS i g n a l 包装函数设置了一 个信号处理程序, 其信号处理语义如下 :\n只 有这个处 理程序当前正在处理的那种类型的 信号被阻塞。\n和所有信号实现一样,信号不会排队等待。\n只要可能 , 被中断的系统调用会自动重启。\n一旦设置了信号处理程序, 它就会一直保持, 直到 S i g n a l 带着 h a nd l e r 参数为\nS IG _ IG N 或 者 S IG _DF L 被调用。\n我们在所有的 代码中实现 Si g n a l 包装函数 。\n8. 5. 6 同步流以避免讨厌的并发错误\n如何编写读写相同存储位置的并发流程序的问 题, 困扰着数代计算机科学家。一般而\n言,流可能交错的数量与指令的数量呈指数关系。这些交错中的一些会产生正确的结果, 而有些则不会。基本的问题是以某种方式同步并发流,从而得到最大的可行的交错的集 合, 每个可行的 交错都能得到正确的结果。\ncode/src/csapp.c\nhandler_t *Signal(int signum, handler_t *handler)\n{\nstruct sigaction action, old_action;\naction.sa_handler = handler;\nsigemptyset(\u0026amp;action.sa_mask); I* Block sigs of type being handled *I action.sa_flags = SA_RESTART; I* Restart syscalls if possible *I\nif (sigaction(signurn, \u0026amp;action, \u0026amp;old_action) \u0026lt; 0) unix_error(\u0026ldquo;Signal error\u0026rdquo;);\nreturn (old_action.sa_handler);\ncodelsrdcsapp.c\n图8-38 Si gna l : s i ga c t i on 的一个包装函数 , 它提供在 Posix 兼容系统上的可移植的 信号处理\n并发编程是一个很深且很重要的问题, 我们将在第 12 章 中更详细地讨 论。不过, 在本章中学习的有关 异常控制流的 知识, 可以让你感觉一下与并发相关的有趣的智力挑战。例如, 考虑图 8-39 中的程序, 它总结了一个典型的 U nix shell 的结构。父进程在一个全局 作业列 表中记录着它的 当前子进程, 每个作 业一个条目。a d d j o b 和 d e l e 七e j o b 函数分别 向这个作业列表 添加和从中删除作业。\n当父进程创建一个新的子进程后 , 它就把这 个子进程添加到 作业列表中。当父进程在\nSIGCHLD 处理程序中回收一个终止的(僵死)子进程时, 它就从作业列表中删除这个子进程。\n乍一看 , 这段代码是对的。不幸的是, 可能发生下面这样的 事件序列 :\n父进程执行 f o r k 函数,内 核调度新创建的子进程运行 , 而不是父进程。 ) 在父进程能 够再次运行之前, 子进程就终止, 并且变成一个僵死进程, 使得内核传递一个 SIGCH LD 信号给父进程。\n) 后来, 当父 进程再次变成可运行但又 在它执行之前,内 核注意到有未处理的\nSIGCHLD 信号, 并通过在父进程中运行处 理程序接收 这个信号。\n) 信号处理程序回收终止的子进程,并 调用 d e l e t e j o b , 这个函数什么也不做, 为父进程还没有把该子进程添加到列表中。\n) 在处理程序运行完毕后,内 核运行父进程, 父进程从 f or k 返回, 通过调用 a d d­ j ob 错误地把(不存在的)子进程添加到作 业列表中。\n因此, 对千父进 程的 ma i n 程序和信号处理流的某些交错, 可能会在 a d d j o b 之前调用 d e l e t e j o b 。这导 致作业列 表中出现一个不正确的条目, 对应于一个不再存在而且永远也不会被删 除的作业。另一方面,也 有一些交错 , 事件按照正确的顺 序发生。例如, 如果在 fo r k 调用返回时,内 核刚好调度父进程而不是子进程运行, 那么父进程就会正确地把子进程添加到作业列表中,然后子进程终止,信号处理函数把该作业从列表中删除。\n这是一个称为竞争 ( ra ce ) 的经典同步错误的示例。在这个情况中, ma i n 函数中调用\nadd j ob 和处理程序中调用 d e l e t e j ob 之间存在竞争。如果 a d d j o b 赢得进展,那 么结果\n就是正确的。如果它没有, 那么结果就是错误的。这样的错误非常难以调试, 因为几乎不可能测试所有的交错。你可能运行这段代码十亿次, 也 没有一次错误, 但是下一次测试却导致引发竞争的交错。\ncodelecf/p rocmaskl .c\nI* WARNING: This code is buggy! *I\n2 void handler(int sig) 3 {\n4 int olderrno = errno;\n5 sigset_t mask_all, prev_all;\n6 pid_t pid;\n8 Sigf illset(\u0026amp;mask_all) ;\n9 while ((pid = waitpid(-1, NULL, 0)) \u0026gt; 0) { I* Reap a zombie child *I\nSigprocmask(SIG_BLOCK, \u0026amp;mask_all, \u0026amp;pr e v _a ll ) ;\nde l et e j ob (pi d) ; I* Delete the child from the job list *I\nSigprocmask(SIG_SETMASK, \u0026amp;prev_all, NULL);\n13 }\n14 if (errno != ECHILD)\n15 Sio_error(\u0026ldquo;waitpid error\u0026rdquo;);\n16 errno = olderrno·\n17 }\n18\n19 int main(int argc, char **argv)\n20 {\nint pid;\nsigset_t mask_all, prev_all;\n23\nSigfillset (\u0026amp;ma s k_a ll ) ;\nSignal(SIGCHLD, handler);\ninitjobs(); I* Initialize the job list *I\n27\nwhile (1) {\nif ((pid = Fork()) == 0) { I* Child process *I\nExecve(\u0026quot;/bin/date\u0026quot;, argv, NULL);\n31 }\nSigprocmask(SIG_BLOCK, \u0026amp;mask_all, \u0026amp;pr ev _a ll ) ; I* Parent process *I\naddjob(pid); I* Add the child to the job list *I\nSigprocmask(SIG_SETMASK, \u0026amp;prev_all, NULL);\n35 }\n36 e x i t ( O) ;\n37 }\ncod e/ ecf/ p roc maskl.c\n图 8-39 一 个 具 有细微同步错误的 shell 程序。如果子进程在父进程能够开始运行前就结束了, 那么\nadd j ob 和 de l e t e j ob 会以错误的方式被调用\n图 8-40 展示 了 消除图 8-39 中竞争 的一种 方 法。通 过 在调用 f or k 之前, 阻塞S IGCH LD 信号, 然后在调用 a dd j o b 之后取消阻塞这些信号, 我们保证了在子进程被添加到作业列表中之后回收该子进程。注意 , 子进程继 承了它们父进程的被阻塞集合, 所以我们必须在调用 e x e c v e 之前,小 心地解除子进程中阻 塞的 SIGCHLD 信号。\ncode/ecflprocmask2.c\nvoid handler(int sig)\n2 {\nint olderrno = errno;\nsigset_t mask_all, prev_all;\npid_t pid;\nSigfillset (\u0026amp;mask_all);\nwhile ((pid = waitpid(-1, NULL, 0)) \u0026gt; 0) { f* Reap a zombie child *f\nSigprocmask(SIG_BLOCK, \u0026amp;mask_all, \u0026amp;prev_all);\ndeletejob(pid); I* Delete the child from the job list *I\nSigprocmask (SIG_SETMASK, \u0026amp;prev_all, NULL) ; 12 }\n3 if (errno != ECHILD)\nSio_error(\u0026ldquo;waitpid error\u0026rdquo;);\nerrno = olderrno; 16 }\n17\n18 int main(int argc, char **argv) 19 {\nint pid;\nsigset_t mask_all, mask_one, prev_one;\n22\nSigfillset(\u0026amp;mask_all);\nSigemptyset (\u0026amp;mask_one) ;\nSigaddset(\u0026amp;mask_one, SIGCHLD);\nSignal(SIGCHLD, handler);\ninitjobs(); I* Initialize the job list *I\n28\nwhile (1) {\n· Si gpr oc ma s k (S I G_BLOCK, \u0026amp;mask_one, \u0026amp;prev_one); I* Block SIGCHLD *f\nif ((pid = Fork()) == 0) { I* Child process *I\nSigprocmask(SIG_SETMASK, \u0026amp;prev_one, NULL); f* Unblock SIGCHLD *f\nExeeve (11/bin/date11, argv, NULL) ; 34 }\nSigprocmask(SIG_BLOCK, \u0026amp;mask_all, NULL); I* Parent process *I\naddjob(pid); I* Add the child to the job list *I\nSigprocmask(SIG_SETMASK, \u0026amp;prev_one, NULL); f* Unblock SIGCHLD *f\n38 }\n39 exit(O); 40 }\ncode/ecfl p roc mask2.c\n图 8-40 用 s i gpr ocmas k 来同步进程。在这个例子中 ,父进程保证在相应的 del et e job 之前执行 add job\n5. 7 显式地等待信号\n有时候 主程序需要显式地等待某个信号处理程序运行。例如,当 Linu x shell 创建一个前台作业时 , 在接收下一条用户命令之前, 它必须等待作业终止, 被 SIGCHLD 处理程序回收。\n图 8-41 给出了一个基本的思路。父进程设置 SIGINT 和 SIGCH LD 的处理程序, 然后\n进入一个无限循环。它阻塞 S IG C H L D 信号, 避免 8. 5. 6 节中讨论过的父进程和子进程之间的竞争。创建了 子进程之后, 把 p 过 重置为 o, 取消阻塞 S IG C H L D , 然后以循环的方式等待 p 迈 变为非零。子进程终止后, 处理程序回收它, 把它非零的 P ID 赋值给全局 p i d\n变温。这会终止循环,父进程 继续其他的工作, 然后开始下一次迭代。\ncode/ecflwaitforsignal.c\n#include \u0026ldquo;csapp.h\u0026rdquo;\n2\n3 volatile sig_atomic_t pid;\n4\n5 void sigchld_handler(int s)\n6 {\nint olderrno = errno;\npid = wai tpid( 一 1 , NULL, O);\nerrno = olderrno;\n10 }\n11\n12 void sigint_handler(int s)\n13 {\n14 }\n15\n16 int main(int argc, char **argv)\n17 {\n18 sigset_t mask, prev;\n19\nSignal(SIGCHLD, sigchld_handler);\nSignal (SIGINT, sigint_handler) ;\nSigemptyset(\u0026amp;mask);\n23 Sigaddset (\u0026amp;mask, SIGCHLD) ;\n24\nwhile (1) {\nSigprocmask(SIG_BLOCK, \u0026amp;mask, \u0026amp;prev); I* Block SIGCHLD *I\n27 if (Fork() == 0) I* Child *I\n28 exit (0);\n29\nI* Parent *I\npid = O;\nSigprocmask(SIG_SETMASK, \u0026amp;prev, NULL); I* Unblock SIGCHLD *I\n33\n34 I* Wait for SIGCHLD to be received (wasteful) *I\n35 while (! pid)\n36\n37\nI* Do some work after receiving SIGCHLD *I\nprintf(\u0026quot;.\u0026quot;);\n40 }\n41 exit(O);\n42 }\ncode/ecflwaitforsignal.c\n图 8- 41 用循环来等待信号 。这段代码正确, 但循环是一种浪费\n当这段代码正确执行的时候,循环在浪费处理器资源。我们可能会想要修补这个问 题, 在循环体内插入 pa us e :\nwhile (! pid) I* Race! *I pause();\n注意, 我们仍然需要一个循环, 因 为收到一个或多个 S IGINT 信号, p a u s e 会 被 中断。不过, 这段代码有很严直 的竞 争 条 件: 如果在 wh i l e 测 试 后 和 p a u s e 之前 收到SIGC H LD 信号, p a u s e 会永远睡眠。\n另一个选择是用 s l e e p 替换 p a us e :\nwhile (! pid) I* Too slow! *I sleep(!);\n当这段代码正确执行时, 它太慢了。如果在 wh i l e 之 后 p a u s e 之 前 收 到 信 号 , 程 序必须 等相当长的一段时间才会再次检查循环的终止条件。使用像 na nos l e e p 这样更高精度的休眠函数也是不可接受的,因为没有很好的方法来确定休眠的间隔。间隔太小,循环 会太浪费。间隔太大,程序又会太慢。\n合适的解决方法是使用 s i g s u s p e nd 。\n#include \u0026lt;signal.h\u0026gt;\nint sigsuspend(const sigset_t *mask);\n返回: —1。\ns i g s us pe nd 函数暂时用 ma s k 替换当前的阻塞集合, 然后挂起该进程, 直 到收到一个信号,其行为要么是运行一个处理程序,要么是终止该进程。如果它的行为是终止,那 么该进程不从 s i g s us pe nd 返回就直接终止。如果 它的行为是运行一个处 理程序, 那 么s i g u s p e n d 从处理程序返回,恢 复 调 用 s i g s u s pe nd 时 原 有的阻塞集合。\ns i g s us pe nd 函数等价于下述代码的原子的(不可中断的)版本:\nsigprocmask(SIG_SETMASK, \u0026amp;mask, \u0026amp;prev); pause();\n3 sigprocmask(SIG_SETMASK, \u0026amp;prev, NULL);\n原子属 性保证对 s i g pr oc ma s k( 第 1 行)和pa us e ( 第 2 行)的调用总是一起发生的,不 会 被中断。这样就消除了潜在的竞争, 即 在 调 用 s i g pr o c ma s k 之后但在调用 pa us e 之前收到了一个信号。\n图 8- 42 展示了如何使用 s i g s u s pe nd 来替代图 8- 41 中的循 环。在每次调用 s i g s us ­ pe nd 之前,都 要 阻 塞 SIG CH LD。 s i g s us p e nd 会暂时取消阻塞 S IGCH LD , 然后休眠, 直到父进程捕获信号。在返回之前, 它会恢复原始的阻塞集合, 又再次阻塞 SIG C H L D。如果父进程捕获一个 SIG IN T 信号,那 么 循 环 测 试 成 功 ,下 一 次 迭代又再次调用 s i g s us ­ pe nd。如果 父 进 程 捕 获 一 个 SIGCH LD , 那么循环测试失败,会退出循环。此时, SIGCH LD 是被阻塞的,所 以 我们可以可选地取消阻塞 SIG CH LD。在真实的有后台作业需要回收的 shell 中这样做可能会有用处。\ns i g s us pe nd 版本比起原来的循环版本不那么浪费, 避免了引入 p a us e 带来的竞争, 又比 s l e e p 更有 效 率 。\ncode/ecflsigsuspend.c\n1 #include \u0026ldquo;csapp.h\u0026rdquo;\n2\n3 volatile sig_atomic_t pid;\n4\n5 void sigchld_handler(int s)\n6 {\n7 int olderrno = errno;\n8 . p过 = 扣釭 t p 过( 一1 , NULL, O);\n9 errno = olderrno·\n10 }\n11\n12 void sigint_handler(int s)\n13 {\n14 }\n15\n16 int main(int argc, char **argv)\n17 {\n18 sigset_t mask, prev;\n19\nSignal(SIGCHLD, sigchld_handler);\nSignal(SIGINT, sigint_handler);\nSigemptyset(\u0026amp;mask);\nSigaddset(\u0026amp;mask, SIGCHLD); 24\nwhile (1) {\nSigprocmask(SIG_BLOCK, \u0026amp;mask, \u0026amp;prev); I* Block SIGCHLD *I\nif (Fork() == 0) I* Child *I\n28 exit(O);\n29\nI* Wait for SIGCHLD to be received *I\npid = O;\nwhile (! pid)\nsigsuspend(\u0026amp;prev);\n34\nI* Optionally unblock SIGCHLD *I\nSigprocmask(SIG_SETMASK, \u0026amp;prev, NULL);\n37\nI* Do some work after receiving SIGCHLD *I\nprintf(\u0026quot;. \u0026ldquo;);\n40 }\n41 exit(O); 42 }\ncode/ecfl sigs uspend.c\n图 8-42 用 s i gs us pe nd 来等待信号\n6 非本地跳转\nC 语言提供了一种用户级异常控制流形式,称 为非本地跳转( no nloca l jump), 它将控\n制 直 接从一个函数转移到另一个当前正在执行的函数,而 不 需 要 经 过 正 常 的 调 用- 返回序\n列。非本地跳转是通过 s e t j mp 和 l o ng j mp 函数来提供的 。\n#include \u0026lt;setjmp.h\u0026gt;\nint int\nsetjmp(jmp_buf env);\nsi gset jmp (s 屯 j mp _buf env, int savesigs);\n返回: se t jmp 返 回 O, l ong jmp 返 回 非零。\ns e t j mp 函数在 e nv 缓冲区中保 存当前调用环境 , 以供后面的 l o n g j mp 使 用 , 并返回\n0。调用环境包括程序计数器、栈指针和通用目的寄存器。出于某种超出本书描述范围的 原因, s e t j mp 返回的值不能被赋值给变量:\nre= setjmp(env); I* Wrong! *I\n不过它可以安全地用在 S W止 c h 或条件语句的测 试中[ 62] 。\n#include \u0026lt;setjmp.h\u0026gt;\nvoid longjmp(jmp_buf env, int retval);\nvoid siglongjmp(sigjmp_buf env, int retval);\n从不返回 。\nl o n g j mp 函数从 e nv 缓冲区中恢复调用环境, 然后触发一个从最近一次初始化 e nv\n的 s e t j mp 调用的返回。然后 s e t j mp 返回, 并带有非零的返回值r e t v a l 。\n第一眼看过去, s e t j mp 和 l o n g j mp 之间的相互关系令人迷惑。s e t j mp 函数只被调用一次, 但返回多 次: 一次是当第一次调用 s e t j mp , 而调用环境保存在缓 冲区 e nv 中时, 一次是为每个相应 的 l o ng j mp 调用。另一方面, l o ng j mp 函数被调用一次, 但从不返回。\n非本地跳转的一个重要应用就是允 许从一个深层嵌套的函数调用中立即返回, 通常是由检测到某个错误 情况引起的。如果在一个深层嵌套的函数调用中发现了一个错误情况, 我们可 以使用非本地跳转直接返回到一个普通的本 地化的错 误处理程序, 而不是费力地解开调用栈 。\n图 8-43 展示了一个示例,说 明这可能是如何工作的。ma i n 函数首先调用 s e t j mp 以保存当前的调用环境, 然后调用 函数 f o o , f o o 依次调用函数 bar 。如果 f o o 或者 b ar 遇到一个错误 , 它们立即通过一次 l o ng j mp 调用从 s e t j mp 返回。s e 七 j mp 的 非零返回值指明了错误类型, 随后可以被解码 , 且在代码中的某个位置进行处 理。\ncode/ecf/setjmp.c\n#include \u0026ldquo;csapp.h\u0026rdquo; jmp _buf buf;\nint error!= O;\nint error2 = 1;\nvo i d foo(void), bar(void);\n图8-43 非本地跳转的示例。本示例表明了使用非本地跳转来从深层嵌套的函数调用中的错误情况恢复, 而不需要解开整个栈 的基本框架\n10 int main()\n11 {\nswitch(setjmp(buf)) {\ncase 0:\n14 foo();\n15 break;\ncase 1:\nprintf(\u0026ldquo;Detected an errorl condition in foo\\n\u0026rdquo;);\nbreak;\ncase 2:\nprintf(\u0026ldquo;Detected an error2 condition in foo\\n\u0026rdquo;);\nbreak;\ndefault:\nprintf (\u0026ldquo;Unknown error condition in foo\\n\u0026rdquo;);\n24 }\n25 exit(O);\n26 }\n27\nI* Deeply nested function foo *I\nvoid foo(void)\n30 {\nif (errorl)\nlongjmp(buf, 1);\nbar();\n34 }\n35\n36 void bar(void)\n37 {\nif (error2)\nlongjmp (buf, 2);\n40 }\ncode/ecf/setjmp.c\n图 8- 43 (续)\nl o ng j mp 允 许它跳过所有中间 调用的特性可能产 生意外的后果。例如, 如果中间函数调用中分配了某些数据结构,本来预期在函数结尾处释放它们,那么这些释放代码会被跳 过,因而会产生内存泄涌。\n非本地跳转的另一个重要应用是使一个信号处理程序分支到一个特殊的代码位置,而不是返回到被信号到达中断了的指令的位置。图8-44 展示了一个简单的程序,说明 了这种基本技术。当用户在键盘上键入 C trl + C 时, 这个程序用 信号 和非本地跳转来实现软重启。 s i g­ s e t j mp 和 s i g l o ng j mp 函数是 s e t j mp 和 l o ng j mp 的可 以被信号处理程序使用的版本。\n在程序第一次启动时, 对 s i g s e t j mp 函数的初始调用保存调用环境和信号的上下文\n(包括待处理的和被阻塞的信号向扯)。随后,主函数进入一个无限处理循环。当用户键入 C t rl + C 时,内 核发送一个 S IG I N T 信号给这个进程,该 进程捕获这个信号。不是从信号处理程序返回,如果是这样那么信号处理程序会将控制返回给被中断的处理循环,反之, 处理程序完成一个非本地跳转, 回到 ma i n 函数的开始处。当我们在系统上运行这个程序时,得到以下输出:\nlinux\u0026gt; ./restart starting processing .. . processing . . .\nCtrl+C restarting processing\u0026hellip; Ctrl+C restarting processing . . .\n关千这个程序有两件很有趣的事情。首先, 为了避免竞争,必须 在调用 了 s i g s e t j mp 之后再设 置处 理 程序 。否 则 ,就 会 冒在初始调用 s i gs e t j mp 为 s i g l o ng j mp 设 置调用环境之前运行处理程序的风险。其次,你 可 能 巳 经 注 意 到 了 , s i g s e t j mp 和 s i g l ong j mp 函 数 不 在 图8- 33 中异 步信号安全的函数之列。原因是一般来说 s i g l ong j mp 可以 跳到任意代码,所 以 我们必须小心, 只在 s i g l o ng j mp 可达的代码中调用安全的函数。在本例中, 我们调用安全的 s i o主 u t s 和 s l e e p 函数。不安全的 e x i t 函数是不可达的。\n#include \u0026ldquo;csapp.h\u0026rdquo;\n2\ncode/ecf/restart.c\n3 sigjmp_buf buf;\n4\ns void handler(int sig)\n6 {\n7 s iglongjmp (buf , 1) ;\n8 }\n9\n1o int main()\n11 {\nif (!sigsetjmp(buf, 1)) {\nSignal(SIGINT, handler);\nSio_puts(\u0026ldquo;starting\\n\u0026rdquo;);\n15 }\nelse\nSio_puts (\u0026ldquo;restarting\\n\u0026rdquo;) ;\n18\nwhile(!) {\nSleep(! ) ;\nSio_puts (\u0026ldquo;processing \u0026hellip; \\n\u0026rdquo;);\n22 }\n23 exit(O); I* Control never reaches here *I\n24 }\ncode/ecflrestart.c\n图8-44 当用户键入 Ctrl+ C 时, 使 用 非本地跳转来重启 动它自身的 程序\n豆日C++ 和 J a va 中的软件异常\nC++ 和 J ava 提供的异常机制是较 高层次的 , 是 C 语言的 s e t j mp 和 l o n g j mp 函数的更加结构化的版本。你可以把 t r y 语句中 的 c a t c h 子句 看做 类似于 s e 七 j mp 函数。相似地, t hr o w 语句就 类似于 l o n g j mp 函数。\n8. 7 操作进程的工具\nLin u x 系统提供了大量的监 控和操作进程的有用 工具。\nST RACE : 打印一个正在运行的程序和它的子进程调用的每个系统调用的轨迹。对于好奇的学生而言,这 是一个令人着迷的 工具。用- s t a t i c 编译你的 程序, 能得到一个更干净的、不带有大量与共享库相关的输出的轨迹。\nPS: 列出当前 系统中的进程(包括僵死进程)。\nT OP: 打印出关于当前进程资源使用的信息。\nPMAP: 显示进程的内存映射。\n/ pr o c : 一个虚拟文件系统,以 ASCII 文本格式输出大量内核数据结构的内容, 用户程序可以读取这些内容。比如, 输入 \u0026quot; c a t / p r o c / l o a d a v g\u0026rdquo; , 可以看到你的 Lin u x 系统上当前的平均负载。\n8. 8 小结\n异常控制流 ( ECF) 发生在计算机系统的各个层次 , 是计算机系统中 提供并发的 基本机 制。\n在硬件层 , 异常是由处理器中的 事件触发的 控制流中的 突变。控制流传 递给一 个软件处理程序,该处理程序进行一些处理 , 然后 返回控制给被中断的 控制流。\n有四种不同类 型的异常 : 中断、故障、终止和陷阱 。当一个外部 1/0 设备(例如定时器芯片或者磁盘\n控制器)设置了处理 器芯片上的中断管脚时 ,(对于任意指令)中断会异步地发生。控制返回到故障指令后面的那条指令。一条指令的 执行可能导致 故障 和终止同步发生。故障处理程序会重新启动故障指 令, 而终止处理程序从 不将控制返回 给被中断的 流。最后 , 陷阱就像是用来实现向应用 提供到操作系统代码的受控的入口点的系统调用的函数调用。\n在操作 系统层,内 核用 ECF 提供进程的 基本概念。进程提供给应 用两个重要的抽象: 1) 逻辑控制 流,它 提供给每个程序一个假象 , 好像它是 在独占 地使用处理器, 2 ) 私有地 址空间 , 它提供 给每个程序一个假象,好像它是在独占地使用主存。\n在操作系统和应用程序之间的接口处,应用程序可以创建子进程,等待它们的子进程停止或者终止, 运行新的 程序 , 以及捕获来 自其他进 程的信号。信号处理的语义是微妙的, 并且随系统不同而不同。然而, 在与 Pos ix 兼容的系统 上存在着一些机制 ,允 许程序清楚 地指定期望的信号处理语义。\n最后, 在应用层, C 程序可以 使用非本 地跳转来 规避正常的调用/返回栈规则 , 并且直接从 一个函数分支到另一个函数.\n参考文献说明 # Ke r risk 是 Linux 环境编程的 完全参考手册 [ 62] 。Intel ISA 规范包含对 Intel 处理器上的异常和中断的详 细讨论 [ 50] 。操作系统教科书 [ 102. 106, 113] 包括关于异 常、进 程和信号的其他信息。W. Richard St evens 的[ 111 ] 是一本有价值的和可读性很高的 经典著作, 是关于如何 在应用程序中处 理进程和信号的。Bovet 和 Cesati[ 11] 给出了一个关千 Linux 内核的非常清晰的描述, 包括进程和信号实现的 细节。\n家庭作业 # 8. 9 考虑四个具有 如下开始和结束时间的进程 : 进程 开始时间 结束时间 A 5 7 B 2 4 C 3 6 D I 8 对于每对进程,指明它们是否是并发地运行的:\n8. 10 在这一章里 , 我们介绍 了一些具有不寻常的调用和返回行为的 函数: s e t j mp 、 l ong j mp 、 e xe c ve\n和 f or k。找到下列行为中和每个函数相匹 配的一种 :\n调用一次, 返回两次。 调用一次,从不返回。\nc. 调用一次,返回一次或者多次。\n8. 11 这个程序会输出多 少个 \u0026quot; hello\u0026quot; 输出行?\n妇 ncl ude \u0026ldquo;csapp.h\u0026rdquo;\n2\ncodelecf/forkprobl.c\n3 int main()\n4 {\n5 inti;\n6\n7 for(i = 0; i \u0026lt; 2; i ++)\n8 Fork();\n9 printf(\u0026ldquo;hello\\n\u0026rdquo;);\n10 exit(O);\n11 }\ncodelecf/forkprobl.c\n8. 12 这个程序会输出多 少个 \u0026quot; hello\u0026quot; 输出行? #include \u0026ldquo;csapp.h\u0026rdquo;\n3 void doit 0\n4 {\n5 Fork();\n6 Fork(); printf(\u0026ldquo;hello\\n\u0026rdquo;);\n8 return·,\n9 }\n10\n11 int main()\n12 {\n13 doit();\n14 printf (\u0026ldquo;hello\\n\u0026rdquo;) ;\n15 exit(O);\n16 }\ncode/ecflforkprob4.c\ncode/ecflforkprob4.c\n8. 13 下面程序的 一种可能的输出是 什么? 扣 ncl ude \u0026ldquo;cs app . h\u0026rdquo;\ncodelecf/forkprob3.c\nint main()\n4 { 5 int X = 3; 6 7 if (Fork() != 0) 8 printf(\u0026ldquo;x=%d\\n\u0026rdquo;, ++x); 9 10 printf(\u0026ldquo;x=%d\\n\u0026rdquo;, \u0026ndash;x);\n11 exit(O);\n12 }\ncodelecflforkprob3.c\n8. 14 下 面这个程序会输出多 少个 \u0026quot; hello\u0026quot; 输出行? 1 #include \u0026ldquo;csapp.h\u0026rdquo;\n2\n3 void doitO\n4 {\n5 if (Fork() == 0) {\nFork();\nprintf(\u0026ldquo;hello\\n\u0026rdquo;);\nexit(O);\n9 }\n10 return; 11 }\n12\n13 int main()\n14 {\n1s doitO;\nprintf(\u0026ldquo;hello\\n\u0026rdquo;);\nexit(O);\n18 }\ncodelecflforkprob5.c\ncodelecflforkprob5.c\n8. 15 下面这个程序会 输出多 少个 \u0026quot; hello\u0026quot; 输出行? #include \u0026ldquo;csapp.h\u0026rdquo;\n2\n3 void doit ()\n4 {\ns if (Fork() == 0) {\nFork();\nprintf (\u0026ldquo;hello\\n\u0026rdquo;);\ns return·,\n9 }\n10 return; 11 }\n12\n13 int main()\n14 {\n1s doitO;\nprintf(\u0026ldquo;hello\\n\u0026rdquo;);\nexit(O);\n18 }\ncodelecflforkprob6.c\ncodelecf/forkprob6.c\n8. 16 下面这个程序的输出是什么? codelecf/forkprob7.c\n#include \u0026ldquo;csapp. h\u0026rdquo; int counter= 1;\nint main()\n{\n辽 (for k () == 0) { counter\u0026ndash;; exit(O);\n}\nelse {\nWait(NULL);\nprintf(\u0026ldquo;counter = o/,d\\n\u0026rdquo;, ++counter);\n}\nexit(O);\n}\ncode/ecf/forkprob7.c\n列举练习题 8. 4 中程序所有可能的 输出。考虑下面的程序:\n#include \u0026ldquo;csapp.h\u0026rdquo; void end(void)\n{\ncode/ecflforkprob2.c\nprintf(\u0026ldquo;2\u0026rdquo;); fflush(stdout);\n}\nint main()\n{\nif (Fork() == 0) atexit(end);\nif (Fork() == 0) {\nprintf(\u0026ldquo;O\u0026rdquo;); fflush(stdout);\n}\nelse {\nprintf(\u0026ldquo;1\u0026rdquo;); fflush(stdout);\n}\nexit(O);\n}\ncode/ecflforkprob2.c\n判断下面哪个输出是 可能的 。注意: a t e x 江 函数以一个指向函数的指针为输入 , 并将它添加到函数列 表中(初始为空), 当 e x 江 函数被调用时 , 会调用该列 表中的函数。\n•• 8. 19\nA. 112002 B. 211020 C. 102120 D. 122001\n下面的函数会打印多 少行输出? 用一个 n 的函数给出答 案。假设 n l 。\ncode/ecflforkprob8.c\nE . 100212\nvoid foo(int n)\n{\ninti;\nfor(i = 0; i \u0026lt; n; i ++)\nFork(); printf(\u0026ldquo;hello\\n\u0026rdquo;); exit(O);\ncode/ecflrfkoprob8.c\n** 8. 20\n使用 e xe c ve 编写一个叫做 myl s 的 程 序 ,该 程序的行为和 / bi n / l s 程序的一样。你的程序应该接受相同的命令行参数 , 解释同样的环境变量,并 产 生 相 同 的 输 出 。\nl s 程 序从 CO L U M NS 环境变扯中获得屏幕的宽度。如果没有设 置 CO L U MNS , 那么 l s 会假设 屏幕宽 80 列。因此,你 可以 通过把 CO LU M NS 环境设置得小于 80 , 来检查你对环境变址的处理:\nlinux\u0026gt; setenv COLUMNS 40 linux\u0026gt; ./ myls\nII Output is 40 columns wide\nlinux\u0026gt; unsetenv COLUMNS linux\u0026gt; ./ myls\nII Output is now 80 columns wide\n** 8. 21\n下面的程序可能的输出序列是什么?\nint main()\n{\ncodelecflwaitprob3.c\nif (fork() == 0) {\nprintf(\u0026ldquo;a\u0026rdquo;); fflush(stdout); exit (O) ;\n}\nelse {\npri ntf (\u0026ldquo;b\u0026rdquo;) ; fflush(stdout); waitpid(-1, NULL, 0);\n}\nprintf(\u0026ldquo;c\u0026rdquo;); fflush(stdout); exit(O);\n*** 8. 22\n编写 U nixs ys t e m 函 数的你自己的版本\n立 t mysystem(char *command);\ncode/ecwfa/itprob3 .c\n•• 8. 23\nmys ys t e m 函 数 通过调用 \u0026quot; / b i n / s h - c c omma nd \u0026quot; 来 执 行 c omma nd , 然 后 在 c omma nd 完成后返回。如果 c omma nd ( 通过 调用 e xi t 函数 或 者 执 行一 条r e t u r n 语 句)正常 退出, 那 么 mys ys t e m 返回 c omma nd 退出状态。例如, 如 果 c o mma nd 通过调用 e xi t (8 ) 终 止,那 么 mys ys t e m 返回值 8。否则,如 果 c o mma nd 是 异常终止的,那 么 mys y s t e m 就 返 回 s he ll 返回的状态。\n你的一个同事想要使用信号来让一个父进程对发生在子进程中的事件计数。其想法是每次发生一 个事件时,通过向父进程发送一个信号来通知它,并且让父进程的信号处理程序对一个全局变量 coun t e r 加一, 在子进程终止之后, 父进程就可以检查这个变量。然而, 当他在系统上运行图 8-\n45 中的测试程序时,发 现 当父进程调用 pr i n t f 时, c o unt er 的值总是 2 , 即使子进程向父进程发\n送了 5 个信号也是如此。他很困惑,向 你 寻 求 帮助。你能解释这个程序有什么错误吗?\ncodelecf/counterprob.c\n#include \u0026ldquo;csapp.h\u0026rdquo; int counter= O;\nvoid handler(int sig)\n{\ncounter++;\nsleep(1); I* Do some work in the handler *I return;\n图8-\u0026lsquo;15 家庭作业 8. 23 中引用的计数器程序\n10 }\n11\n12 int main()\n13 {\n14 int i;\n15\n16 Signal(SIGUSR2, handler); 17\n18 if (Fork() == 0) { I* Child *I 19 for (i = O; i \u0026lt; 5; i++) {\nKill(getppid () , SIGUSR2) ;\nprintf(\u0026ldquo;sent SIGUSR2 to parent \\n\u0026rdquo;) ; 22 }\n23 exit(O);\n24 }\n25\nWait (NULL) ;\nprintf(\u0026ldquo;counter=%d\\n\u0026rdquo;, counter);\nexit(O); 29 }\ncodelecf/counterprob.c\n图 8-45 (续)\n\\* 8. 24 修改图 8-18 中的程序,以 满足下 面两个条件:\n每个子进程在试图写一个只读文本段中的位置时会异常终止。\n父进 程打印和下 面所示相同(除了 PID) 的输出:\nchild 12255 terminated by signal 11: Segmentation fault child 12254 terminated by signal 11: Segmentation fault\n提示:请 参 考 ps i g na l (3 )的 ma n 页。\n*/ 8 . 25 编写 f ge t s 函 数 的 一 个 版本, 叫做 t f ge t s , 它 5 秒钟后会超时。t f ge t s 函数接收和 f ge t s 相同的输入。如果用户在 5 秒内不键人一个输入行, t f ge t s 返回 NU LL。否则, 它 返 回一 个 指向 输 入\n.行的指针。\n:: 8 . 26 以图 8-23 中的示例作为开始点,编 写一个 支持作业控制的 s hell 程序。s hell 必须具有以下特性:\n用 户输 入的命令行由一个 na me 、 零 个 或 者 多 个 参 数 组成,它 们 都 由 一 个 或 者 多 个 空 格分隔开。如果 na me 是 一 个 内 置 命 令 ,那 么 s hell 就 立即处理它,并 等 待 下 一 个 命 令 行 。 否 则 , s hell 就 假设 na me 是 一 个 可执行文件, 在一个初始的子进程(作业)的上下文中加载并运行它。作业的进程组 ID 与子进程的 P ID 相同。 每个作业是由一个进程 IDCPID ) 或 者一个作业 ID(J ID) 来标识的,它 是 由 一 个 she ll 分配的任意的小正整数。J ID 在命令行上用前缀 \u0026quot; %\u0026quot; 来表示。比如, \u0026quot; %5\u0026quot; 表示 J ID 5, 而 \u0026quot; s\u0026quot; 表示 PID 5。 如果 命令行以 &来结 束 , 那么 shell 就在后台运行这个作业。否则, she ll 就在前台运行这个作业。 输入 Ctr l+ C( Ctrl+ Z) , 使得内核发送一个 S IGI NT ( SIGT ST P ) 信号给 s hell , s hell 再转发给前台进程组中的每个进程e 内置命令 j ob s 列出所有的后台作业。 内置命令 bg j ob 通过发送一个 S IGCO NT 信号重启 j ob, 然后在后台运行它。j ob 参数可以是一个 PID , 也可以是一个 JID。 内置命令 f g J动 通过发送一个 SIGCO NT 信号重启 j ob, 然后在前台运行它。 9 注意这是对真实的 shell 工作方式的简化。真实的shell 里, 内核响应Ct rl + C( Ctr!+ Z), 把 SIGINT ( SIGT ­\nSTP) 直接发送给终端前台进程组中的 每个进程。shell 用 t c s e t pgr p 函数管理这个 进程组的成员 ,用 t c­ se t a t t r 函数管 理 终 端 的 属 性 ,这 两个函数都超出了本书讲述的范围。可以参考[ 62] 获 得 详 细信息。\nshell 回收它所有的僵死子进程。如果 任何作业 因为收到一个未捕获的信号而终止 , 那么 s hell 就输出一条 消息到终端, 消息中包含该作业的 PID 和对该信号的描述。\n图 8- 46 展示了一个 s hell 会话示例。\nlinux\u0026gt; ./shell\n\u0026gt;bogus\nbogus: Command not found.\n\u0026gt;foo 10\nRun your shell program Execve can \u0026rsquo; t find executable\nJob 5035 terminated by signal: Interrupt User types Crt l +C\n\u0026gt;foo 100 \u0026amp;\n[1] 5036 foo 100 \u0026amp;\n\u0026gt;foo 200 \u0026amp;\n[2] 5037 foo 200 \u0026amp;\n\u0026gt;jobs\n5036 Running foo 100 \u0026amp;\n5037 Running foo 200 \u0026amp;\n\u0026gt;fg %1\nJob [1] 5036 stopped by signal: Stopped User types Ctrl +Z\n\u0026gt;jobs\n5036 Stopped foo 100 \u0026amp;\n5037 Running foo 200 \u0026amp;\n\u0026gt;bg 5035\n5035: No such process\n\u0026gt;bg 5036\n[1] 5036 foo 100 \u0026amp;\n\u0026gt;/bin/kill 5036\nJob 5036 terminated by si gnal : Terminated\n\u0026gt; fg %2 Wait for fg job to finish\n\u0026gt;quit\nlinux\u0026gt; Back to the Uni x shell\n图 8- 46 家庭作业 8. 26 的 s hell 会话示例\n练习题答案\n8. 1 进程 A 和 B 是互相并发的, 就像 B 和 C 一样, 因为它们各自的执行是重叠的, 也就是一个进程在另一个进程结 束前开始 。进程 A 和 C 不是并发的 , 因为它们的执行没有 重叠; A 在 C 开始之前就结束了。\n. 2 在图 8- 1 5 的示例程序中 ,父子进程 执行无关的指令集合。然而, 在这个程序中, 父子进程执行 的\n指令集合是相关的,这是有可能的,因为父子进程有相同的代码段。这会是一个概念上的啼碍,所 以请确认你理解了本题的答 案。图 8- 47 给出了进 程图。\n这里的关键点是子进程执行 了两个 pr i nt f 语句。在 f or k 返回之后, 它执行第 6 行的 p r i nt f。然后它从 辽 语句中出来, 执行第 7 行的 pr i n t f 语句。下面是子进程产生 的输出: 父进程只执行 第 7 行的 p r i n t f : p2: x=O\nPl,: .x =2 P2•: • x=l\npr i n 七 f printf exit\nx==l I P2: x=O\nmain f or k pr i n七 f exit\n图 8-4 7 练习题 8. 2 的进程图\n子进程父进程\n8 3 我们知道序列 ache、a bcc 和 bacc 是可能的, 因为它们对应有进程图的拓扑排序(图8-48 ) 。而像\nbcac 和 c bca 这样的 序列不对应有任何拓扑排序, 因此它们是不可行的。\n. # ma i n\na\npr i n t f b\np r"i n t f\nC\np"r i n 七 f\nC\np r i n t f\ne x i t\n图 8 - 48\n练习题 8. 3 的进程图 -\n8. 4\n只简单地计算进程图(图8 - 4 9 ) 中 pr i n t f 顶点的个数就能确 定输出行数。在这里, 有 6 个这样的顶点, 因此程序会打印 6 行输出。\n任何对应有进程图的拓扑排序的 输出序列都是可能的 。例如: He l l o 、 1 、 0 、 Bye 、 2、Bye 是可\n能的。\n. # ma i n\nHe..l.l o p r i n t f\n1\npr 一i 。n t f pr i n t f\nBye\npr i n t f\n二wa 让 p i d pr i n 七f\nB,y.e printf\ne x i t\n图 8 - 49\n练习 题 8. 4 的进程图\n8 5\nun s i gn ed int snooze(unsigned int secs ) { unsigned int re= sl eep(secs ) ;\ncode/ecflsnoo ze.c\nprintf(\u0026ldquo;Slept for %d of %d s e c s . \\ n \u0026quot; , secs-re, secs) ; return re;\n8. 6\n#incl ude \u0026ldquo;csapp.h\u0026rdquo;\ni nt ma i n ( i nt ar g c , c har *argv[], char *envp[])\n{\ncodelecfs/nooze.c codelecfm/yecho.c\ni nt i ;\npr i nt f (\u0026ldquo;Comman d - l i n e ar gument s : \\ n \u0026quot; ) ; for ( i =O; ar gv [ i ] ! = NULL ; i ++)\nprintf (\u0026rdquo; argv[o/.2d] : %s \\n\u0026rdquo; , i , ar g v [i]) ;\nprintf (\u0026quot;\\n\u0026quot;);\nprintf (\u0026ldquo;Envir onme nt var i a bl e s : \\ n \u0026quot; ) ; for ( i =O; envp[i] != NULL ; i ++)\nprintf (\u0026rdquo; envp[%2d] : %s \\n\u0026quot; , i, envp[i]) ; exit (O) ;\n8. 7\nco d ele cf/ my ec ho .c\n只 要休眠进程收到一个未被忽略的信号, s l e e p 函数就会提前返回。但是 , 因为收到一个 SIGINT 信号的默认行为就是终止进程(图 8- 26 ) , 我们必须设置一个 SIGINT 处理程序来允许 s l e e p 函数返回。处理程序简单地捕获 SIGNA L, 并将控制返回给 s l e e p 函数, 该 函数会立即返回。\ncode/ecflsnooze.c\n#i ncl ude \u0026quot; cs app . h\u0026quot;\n3 /• SIGINT handler•/\n4 void handler (int sig)\n5 {\n6 return; /• Catch the signal and return•I\n7 }\n8\n9 unsigned int snooze(unsigned int secs) {\n10 unsigned int re= sleep(secs);\n11\n12 printf(\u0026ldquo;Slept for %d of %d s e cs . \\ n \u0026quot; , secs-re, secs);\n13 return re;\n14 }\n15\n16 int main(int argc, char **argv) {\n17\nif (argc != 2) {\nfprintf(stderr, \u0026ldquo;usage: %s \u0026lt;secs\u0026gt;\\n\u0026rdquo;, argv[O]);\n20 exit(O);\n21 }\n22\nif (signal(SIGINT, handler) == SIG_ERR) I• Install SIGINT•I\nunix_error(\u0026ldquo;signal error\\n\u0026rdquo;); /• handler•/\n(void)snooze(atoi(argv[l]));\nexit(O);\n27 }\ncode/ecf/snooze.c\n8. 8 这个 程序打印 字符串 \u0026quot; 213\u0026rdquo; , 这是卡内 基-梅隆大学 CS: APP 课程的缩写名。父进程开始时 打印\n\u0026ldquo;2\u0026rdquo;, 然后创 建子进程 , 子进程会陷入一 个无限循环。然 后父进程向 子进程发送 一个信号, 并等待它终止。子进程捕获这个信 号(中断这个无限循环), 对计数器值(从初始值 2) 减一, 打印 \u0026ldquo;1\u0026rdquo;\u0026rsquo; 然后终止。在父进程回收子进程之后 , 它对计数器值(从初始值 2) 加一, 打印 \u0026quot; 3\u0026quot; , 并且终止。\n"},{"id":445,"href":"/zh/docs/technology/System/%E6%B7%B1%E5%85%A5%E7%90%86%E8%A7%A3%E8%AE%A1%E7%AE%97%E6%9C%BA%E7%B3%BB%E7%BB%9F/%E4%B9%A6%E7%B1%8D/%E7%AC%AC9%E7%AB%A0-%E8%99%9A%E6%8B%9F%E5%86%85%E5%AD%98/","title":"Index","section":"SpringCloud","content":"第 9 章\nC H A P T E A 9 . .\n虚拟内存\n一个系统中 的进程是与其他进程共享 CPU 和主存资源的。然而, 共享主存会形成一些特殊的挑战。随着 对 CPU 需求的增长, 进程以 某种合理的平滑方式慢了下来。但是如果太多的 进程需要太多的内存, 那么它们中的一些就根本无法运行 。当一个程序没有空间可用时 , 那就是它运气不好了。内存还很容易被破坏 。如果某个进程不小心写了另一个进程使用的内存 , 它就可能以某种完全和程序逻辑无关 的令人迷惑的方式失 败。\n为了更加有效 地管理内存并且少出错, 现代系统提供了一种对主存的抽象概念, 叫做虚拟内存 CV M) 。虚拟内存 是硬件异常、硬件地址翻译 、主存 、磁盘文件和内核软件的完美交互 , 它为每个进程提供了一个 大的、一致的和私有的地址空间。通过一个很清晰的机制,虚拟内存提供了三个重要的能力: 1 ) 它将主存看成是 一个存储在磁盘上的 地址空间的高速缓存,在主存中只保存活动区域,并根据需要在磁盘和主存之间来回传送数据,通过 这种方式 , 它高效地使用了主存。2 ) 它为每个进程提 供了一致的地址空间,从 而简化了内存管理。3 ) 它保护了每个进程的地址空间不被其他进程破坏。\n虚拟内存是计算机系统最重要的概念之一。它成功的一个主要原因就是因为它是沉默地、自动 地工作的 , 不需要应用程序员的任何干涉。既然虚拟内存在幕后工作得如此之好,为什么程序员还需要理解它呢?有以下儿个原因:\n虚拟内存是核心的。虚拟内存遍及计算机系统的所有层面,在硬件异常、汇编器、链接器、加载器、共享对象、文件和进程的设 计中扮演着重要 角色。理解虚拟内存将帮助你更好地理解系统通常是如何工作的。\n. • 虚拟内存是强大的。虚拟内存给予应用程序强大的能力,可以创建和销毁内存片 ( ch unk ) 、将内存片映射到磁盘文件的 某个部分, 以及与其他进程共享内存。比如, 你知道可以通过读写内存位置读或者修改一个磁盘文件的内容吗?或者可以加载一 个文件的内容到内存中,而不需要进行任何显式地复制吗?理解虚拟内存将帮助你 利用它的强大功能在应用程序中添加动力。\n虚拟内存是危险的。每次应用程序引用一个变量、间接引用一个指针,或者调用一个 诸如 ma l l oc 这样的动态分配程序时 , 它就会和虚拟内存发生交互。如果虚拟内存使用不当,应用将遇到复杂危险的与内存有关的错误。例如,一个带有错误指针的程序 可以立即崩溃于"段错误”或者“保护错误",它可能在崩溃之前还默默地运行了几 个小时, 或者是最令人惊慌地, 运行完成却产生不正确的结果。理解虚拟内存以及诸如 ma l l oc 之类的管理虚拟内存的分配程序, 可以帮助你避免这些错误。\n这一章从两个角度来看虚拟内存。本章的前一部分描述虚拟内存是如何工作的。后一部分描述的是应用程序如何使用和管理虚拟内存。无可避免的事实是虚拟内存很复杂,本 章很多地方都反映了这一点 。好消息就是如果你掌握这些 细节, 你就能够手工模拟一个小系统的虚 拟内存机制, 而且虚拟内存的概念将永远不再神秘。\n第二部分是建立在这种理解之上的,向 你展示了如何在程序中使用和管理虚拟内存。你将学会 如何通过显式的内存映射和对像 ma l l oc 程序这样的动态内存分配器的调用来管\n理虚拟内存。你还将了解到 C 程序中的大多数常见的与内存有关的错误, 并学会如何避免它们的出现。\n9. 1 物理和虚拟寻址\n计算机系统的主存被组织 成一个由 M 个连续的字节大小的单元组成的数组。每字节\n都有 一 个 唯 一 的 物 理 地 址 ( Physical Address,\nPA)。第一个字节的地址为 o, 接下来的字节地址为 1, 再下 一个为 2\u0026rsquo; 依此类推。给定 这种简单的结构, CP U 访问内存的最自然的方式就是使用物理地址。我们 把这种方式称为物理寻址 ( phys ic al add ress ing ) 。图 9-1 展示了一个物理 寻址的示例, 该示例的上下文是一条加载指令,它读取从物理 地址 4 处开始的 4 字节字。当 CP U 执行这条加载指令时, 会生成一个有效物理地址, 通 过内存总线, 把它传递给主存。主存取出从物理地址 4 处开始的 4 字节字, 并将它返回给 CP U , CP U 会将它存放在一个寄存器里。\n主存\n0:\n物理地址 2:\n3\n4:\n5:\n6:\n7:\n8:\nM-l:E3 # 数据字\n图 9-1 一个使用物理寻址的系统\n早期的 PC 使用物理寻址, 而且诸如数字信号处理器、嵌入式微控制器以及 Cray 超级\n计算机这样的系统仍然继续使用这种寻址方式。然而,现代处理器使用的是一种称为虚拟 寻址( vir t ual address ing ) 的寻址形式 , 参见图 9-2。\nCPU 芯片 主存\n;\u0026mdash;\u0026mdash;\u0026mdash;\u0026mdash;\u0026mdash;\u0026mdash;\u0026mdash;\u0026ndash; \u0026ndash; \u0026ndash; \u0026mdash;\u0026mdash;\u0026mdash;\u0026mdash;\u0026mdash;\u0026mdash;\n! 虚拟地址 地址翻译 ! 物理地址 I:\n3:\n4:\n, - - - - _- - - _j_ - - - - - - - - - - - - - \u0026ndash; - - - - - - - - \u0026ndash; - - \u0026ndash; - - - - _- - - j 5:\n6:\n7:\nM-1:尸三\n数据字\n图 9- 2 一个使用虚拟寻址的系统\n使用虚拟寻址, CP U 通过生成一 个虚拟地址( Virt ual Address, VA ) 来访问主存,这个虚拟地址在被送到内存 之前先转换成适当的物理地址。将一个虚拟地址转换为物理地址的 任务叫做地址翻译 ( address t ra nslat io n ) 。就像异常处理一样, 地址翻译需要 CPU 硬件和操作系统之间的紧密合作 。CP U 芯片上叫 做内存 管理 单元 ( Memory Managem ent Unit, MM U ) 的专用硬件, 利用存放在主存中的 查询表来动态 翻译 虚拟地址, 该表的内容由操作系统管理。\n9. 2 地址空间\n地址空间 ( add ress s pace) 是一个非负整数地址的有序集合 :\n{0,1,2, ..,}\n如果地址空间中的 整数 是连续 的, 那 么 我 们 说 它 是 一 个 线性地址 空 间 ( linea r address\nspace) 。为了简化讨论, 我们总是假设使用的是线性地址空间。在一个带虚拟内存的系统中, CPU 从一个有 N = 沪个 地 址 的 地 址 空 间 中 生成虚拟地址, 这个地址空间称为虚拟地\n址空间 ( vir t ual address space) :\n{0 , 1, 2 ,…, N — 1 }\n一 个地址空间的大小是由表示最大地址所需要的位数来描述的。例如,一 个 包 含 N = 矿个 地址的虚拟地址空间就叫做一个 n 位地址空间。现代系统通常支持 32 位或者 64 位虚拟地址空间。\n一个系统还有一个物理地址空间 ( ph ys ic al address space), 对应于系统中物理内存的\nM 个字节:\n{0,1,2,… , M — 1 }\nM 不要求是 2 的幕,但 是为了简化讨论, 我们假设 M = 2勹\n地址空间的概念是很重要的,因为它清楚地区分了数据对象(字节)和它们的属性(地 址)。一旦认识到了这种区别,那么我们就可以将其推广,允许每个数据对象有多个独立的地址,其中每个地址都选自一个不同的地址空间。这就是虚拟内存的基本思想。主存中的每字节都有一个选自虚拟地址空间的虚拟地址和一个选自物理地址空间的物理地址。\n让 练习题 9. 1 完成下面的表格,填写缺失的条目,并且用适当的整数取代每个问号。利用下列单位: K= z10 ( 如lo , 千),M = 沪 ( m ega , 兆, 百 万),G = 230 (giga, 千兆, 十 亿), T = 2气 t era , 万亿 ),P = 250 (peta, 于于兆),或 E = 260 (exa, 千兆兆)。\n虚拟地址位数( n ) 虚拟地址数( N ) 最大可能的虚拟地址 8 21 = 64K 232— I =?G-1 2\u0026quot; = 256T 64 3 虚拟内存作为缓存的工具\n概念上而言,虚 拟内存被组织为一个由存放在磁盘上的 N 个连续的字节大小的单元组成的 数组。每字节都有一个唯一的虚拟地址,作 为到数组的索引。磁盘上数组的内容被缓存在主存中。和存储器层次结构中其他缓存一样,磁 盘(较低层)上的数据被分割成块, 这些块作为磁盘和主存(较高层)之间的传输单元。V M 系统通过将虚拟内存分割为称为虚拟页CV 江 t ua l Page, VP)的大小固定的块来处理这个问题。每个虚拟页的大小为 P = 沪字节。类似地 , 物理内存被分割为物理 页 ( P h ysica l Page, PP), 大小也为 P 字节(物理页也被称为 页帧 ( pag e fr am e) ) 。\n在任意时刻,虚拟页面的集合都分为三个不相交的子集:\n未分配的: V M 系统还未分配(或者创建)的页。未分配的块没有任何数据和它们相关联,因此也就不占用任何磁盘空间。 缓存的:当 前 已缓存在物理内存中的已分配页。 未缓存的:未 缓 存 在 物 理内存中的已分配页。\n图 9-3 的示例展示了一个有 8 个虚拟页的小虚拟内存。虚拟页 0 和 3 还没有被分配,\n因此在磁盘上还不存在。虚拟页 l 、4 和 6 被缓存在物理内存中。页 2、5 和 7 已经被分配了,但是当前并未缓存在主存中。\nVPO VP I\n虚拟内存 物理内存\npp 0\npp I\nVP 2•-p - 11 不 农 仔 口 \u0026lsquo;J IN- I\n虚拟页 ( VP ) 物理 页 ( pp )\nPP 2m-p - I\n存储在磁盘上 缓存在DRAM 中\n图 9分 一个 VM 系统是如何使用 主存作为缓存的\n9. 3. 1 DRAM 缓存的组织结构\n为了有助于清晰理解存储层次结构中不同的缓存概念, 我们将使用术语 SRAM 缓存来表示位于 CPU 和主存之间的 L1、L2 和 L3 高速缓存, 并且用术语 DR AM 缓存来表示虚拟内存系统的缓存,它 在主存中缓存虚拟页。\n在存储层 次结构中, DRAM 缓存的位置对它的组织结构有很大的影响。回想一下, DR AM 比 SRAM 要 慢 大约 10 倍, 而磁盘要 比 DRAM 慢大约 100 000 多倍。因此, DR AM 缓存中 的不命中比起 SR AM 缓存中的不命中要昂贵得多, 这是因为 DRA M 缓存不命中要由磁盘来服务, 而 S RAM 缓存不命中通常是由基 于 DR AM 的主存来 服务的。而且,从 磁盘的一个扇区读取 第一个字节的时间开销比起读这个扇区中连续的字节要慢大约\n100 000 倍。归根到底 , D RAM 缓存的组织结构完全是由巨大的不命中开销驱动的 。\n因为大的不命中处罚和访问第一个字节的开销, 虚拟页往往很 大, 通常是 4KB ~ 2M B。由于大的不命中处罚, DR AM 缓存是全相联的, 即任何虚拟页都可以 放置在任何的 物理页中。不命中时的替换策略也很 重要 , 因为替换错了虚拟页的处罚也非常之高。因此, 与硬件对 S RA M 缓存相比, 操作系统 对 DR AM 缓存使用了更复杂精密的替换算法。\n(这些替 换算法超出了我们的讨论范围)。最后, 因为对磁盘的访间时间很长, DRAM 缓存总是使用写回,而不是直写。\n9. 3. 2 页表\n同任何缓存一样, 虚 拟内 存系统必须有某种方法来判定一个虚拟 页是否缓存在D RAM 中的某个地方 。如果是, 系统还必须确定 这个虚拟页存放在哪个物理页中。如果不命中,系统必须判断这个虚拟页存放在磁盘的哪个位置,在物理内存中选择一个牺牲 页, 并将虚拟页从磁盘复制到 DRA M 中,替 换这个牺牲页。\n这些功能 是由软硬件联合提供的 , 包括操作系统软件、MMU ( 内存管理单元)中的地址翻译硬件和一个存放在物理内 存中叫做页表( page ta ble ) 的数据结构, 页表将虚拟页映射到物理页。每次地址 翻译硬件 将一个虚 拟地址转换为物理 地址时,都 会读取页表。操作系统负责维护页表的内容,以 及在磁盘与 DRAM 之间来回传送页。\n图 9-4 展示了一个页表的基本组织结构。页表就是一个页表 条目 ( P age Table Entry, PT E) 的数组。虚拟地址 空间中的每个页在页表中一个固定偏移昼处都有一个 PT E。为了\n我们的目的 ,我 们 将 假 设 每 个 PT E 是 由 一 个 有 效 位 C valid bit ) 和一 个 n 位 地 址 字 段 组 成的。有效位表明了该虚拟页当前是否被 物理内存\n缓存在 DRA M 中。如果设 置了有效位 , 物理页号或 ( D RAM )\n有效位 磁舟铀计 」 VPI I PP O\n那 么 地 址 字 段 就 表 示 D RAM 中 相 应 的物理页的起始位置,这个物理页中缓存 了该虚拟页。如果没有设置有效位,那 么一个空地址表示这个虚拟页还未被分 配。否则,这个地址就指向该虚拟页在\n磁盘上的起始位置。\nPTE O I 0\nPTE71 I\n常驻内存的页表\ 、\\\nVP2 VP7\nVP4\n虚拟内存\n(磁盘)\nVP!\nVP2\npp 3\n图 9-4 中 的 示 例 展 示 了 一 个 有 8 个\n、、、、、、、谥\n( DRAM) 、、、、、 I VP3\n虚拟页和 4 个 物理页的系统的页表。四\n、、 I\nVP4\n个虚 拟 页 ( VP 1、 VP 2 、 VP 4 和 VP\n7 ) 当 前 被 缓 存 在 DRAM 中。 两 个 页\n、、、、、、\n勹 VP6\nVP7\n(VP 0 和 VP 5) 还 未 被 分 配 , 而 剩 下 的 件I 9- I 页表\n页 ( VP 3 和 VP 6) 已 经被分 配 了 , 但 是 当 前 还 未 被 缓 存 。 图 9-4 中 有 一 个 要 点 要 注 意 , 因为 DRA M 缓存是 全相联的 , 所以 任意 物理 页 都 可以 包 含 任 意 虚 拟 页 。\n心 练习题 9. 2 确定 下列 虚拟地址大小( n ) 和 页大小CP ) 的 组合所需要的 PT E 数量:\nn P=2P I PTE 数 量 16 4K 16 8K 32 4K 32 8K 9. 3. 3 页命中\n考 虑 一 下 当 CPU 想要读包含在 VP 2 中 的 虚 拟 内 存 的 一 个 字 时 会 发 生 什 么(图 9-5) , VP 2 被 缓 存 在 DR AM 中。使用我们将在 9. 6 节 中 详 细 描 述 的 一 种 技 术 , 地 址 翻 译 硬 件 将 虚拟地 址 作 为 一 个 索 引 来 定 位 PT E 2, 并从内存中读取它。因为设置了有效位,那么地址翻译硬件就知道 VP 2 是 缓 存 在 内 存 中 的 了 。 所 以 它 使 用 PT E 中 的 物 理 内 存 地 址(该 地 址 指向 p p 1 中 缓 存 页 的 起 始 位 置 ),构 造 出 这 个 字 的 物 理 地 址 。\n物理内存\n(DRAM)\n如 I PPO\nVP2\n立\n妇 I PP 3\n、、、、、\n、、\\\n、、、\n、、\n常驻内存的页表、一、、、、 、\ !\n( D RAM ) 、、、、、[\n、、、、、、、、\n图 9 - 5 V M 页 命 中 。 对 V P 2 中 一 个 字的引用就会命中\n9. 3. 4 缺页\n在虚拟内存的习惯说法中, D RAM 缓存不命中称为缺页 ( page fault ) 。图 9-6 展示了在缺页之前 我们的示例页表的状态。 CP U 引用了 V P 3 中的一个字, V P 3 并未缓存在DR AM 中。地址翻译硬件从内存中读取 PT E 3, 从有效位推断出 VP 3 未被缓存, 并且触发一个缺页异 常。缺页异常调 用内核中的缺页异常处理程序,该 程序会 选择一个牺牲页, 在此例中就是存放在 p p 3 中的 VP 4。如果 VP 4 已经被修改了, 那么内核就会将它复制回磁盘。无论 哪种情况,内 核都会修改 VP 4 的页表条目, 反映出 VP 4 不再缓存在主存中这一事实。\n厂有三 # o I·-\nPTE 7 I汇飞二 、、\n常驻内存的页表\、、、\ \、、\n( DRAM ) 、、、 、\n物理内存\n(DRAM)\nVPI I PPO\n义义\nVP4 I PP 3\n虚拟内存\n(磁盘)\nVPI VP2\nVP3\n、、、、、 I\n、、、、I、\n、、勹\nVP4 VP6\nVP7\n图 9-6 V M 缺页(之前)。对 VP 3 中的字的引用会不命中,从 而 触 发 了 缺页\n接下来,内 核从磁盘复制 V P 3 到内存中的 pp 3, 更新 PT E 3, 随后返回。当异常处理程序返回时,它会重新启动导致缺页的指令,该指令会把导致缺页的虚拟地址重发送到 地址翻译硬件。但是 现在, VP 3 已经缓存在主存中了, 那么页命中也能由地址 翻译硬件正常处理了。图 9-7 展示了在缺页之后我 们的示例页表的状态。\n物理内存\n( DRAM )\nVP I I PPO VP2\nVP7\nVP3 I PP 3\nPTE 7 I I\n、\\n、、、\n、、、\n、、、、、 、、、、、\n虚拟内存\n(磁盘)\nVP I\nVP2\n(DRAM) -,,、、、、、I、、、V、P、3 、、\n、、、、、、\u0026mdash;\nVP4 VP6\nVP7\n图 9-7 VM 缺页(之后。)缺页处理程序选择 VP 4 作为牺牲页,并 从磁盘上用 VP 3 的副本取代它。在缺页处理程序重新启动导致缺页的指令之后,该指令将从内存中正常地读取字,而不会再产生异常\n虚拟内存是在 20 世纪 60 年代早 期发明的, 远在 CPU-内存之间差距的加大引发产生SRA M 缓存之前。因此,虚 拟内存系统使用了和 S R A M 缓存不同的术语, 即 使 它 们 的 许多概念是相似的。在虚拟内存的习惯说法中,块被称为页。在磁盘和内存之间传送页的活动叫做交换 ( s wapping ) 或者 页 面调 度( paging ) 。页从磁盘换入(或者页 面调入)DR AM 和从 DRA M 换出(或者 页 面调 出)磁盘。一直等待,直 到 最 后 时 刻 , 也 就 是 当有不命中发生时, 才换入页面的这种策略称为按 需 页 面调 度( dem and paging ) 。也可以采用其他的方法, 例如尝试着预测不命中,在页面实际被引用之前就换入页面。然而,所有现代系统都使用的 是按需页面调度的方式。\n9. 3. 5 分配页面\n图 9-8展示了当操作系统分配一个新的虚拟内存页时对我们示例页表的 影响,例 如,调 用 ma l l o c 的 结 果 。 在这个示例中, VP5 的分配过程是在磁盘上创建空间并更新 PT E 5, 使它指向磁盘上这个新创建的页面。\n物理内存\n(DRAM)\nVP I IPP 0 PTE O I O I null \u0026ndash;r J VP2\nVP3IPP 3\n虚拟内存\n(磁盘)\nPTE 7 I I ,, 、 、、主、\ VP!\n常驻内存的页表\、、、、、、、、、、、 I VP2\n9. 3. 6 又是局部性救了我们\n(DRAM)\n\\、、、、、、:\u0026rsquo;,,,,.[\nVP3\nVP4\n当我们中的许多人都了解了虚拟内存的概念之后,我们的第一印象通常是它的效率应该是非常低。因为不命中处罚很大,我们担心页面调度会破坏程序性能。实际上,虚拟内存工\n\u0026lsquo;,,\u0026lt;、、、、、、、寸\nVP5\nVP6\n舌 # 图 9-8 分配一个新的虚拟页面。内核在磁盘上分配 VP 5,\n并且将 PTE 5 指向这个新的位 置\n作得相当好, 这主要归功于我们的老朋友局部性(l ocalit y) 。\n尽 管在整个运行 过程中程序引用的不同页面的总数可能超出物理内存总的大小,但 是局 部性原则保证了在任意时刻,程序将趋向 千在一个较小的活动页面 (active page)集合上工作, 这个集合叫做工作集 ( working set) 或者常驻集合( resident set) 。在初始开销 ,也 就是 将工作集页面调度到内存中之后,接下来对这个工作集的引用将导致命中,而不会产生额外的磁盘流量。\n只要我们的程序有好的时间局部性,虚 拟 内 存 系统就能工作得相当好。但是, 当 然 不是所有的程序都能展现良好的时间局部性。如果工作集的大小超出了物理内存的大小,那 么程序将产生一种不幸的状态,叫 做 抖 动( t hra s hing ) , 这时页面将不断地换进换出。虽然虚拟内存通常是有效的,但是如果一个程序性能慢得像爬一样,那么聪明的程序员会考虑 是不是发生了抖动。\n区 且 统计缺页次数\n你可以利 用 L m u x 的 ge tr u s a ge 函数监测缺 页的 数量(以及许多其他 的信息)。\n9. 4 虚拟内存作为内存管理的工具\n在上一节中, 我们看到虚拟内存是如何提供一种机制,利 用 DR A M 缓 存 来 自通常更大的虚拟地址空间的页面。有趣的是, 一些早期的系统, 比 如 DEC PDP-11 / 70 , 支持的是一个比物理内存更小的虚拟地址空间。然而,虚拟地址仍然是一个有用的机制,因为它\n大大地 简化了内存管理, 并提供了一 种自然的保护内存的方法。到目前为止,我们都假设有一个单\n虚拟地址空间\n物理内存\n独的页表,将一个虚拟地址空间映射到\n物理地址空间。实际上,操作系统为每个进程提供了一个独立的页表,因而也就是一个独立的虚拟地址空间。图 9- 9 展示了基本思想。在这个示例中,进程 l 的页表将 V P l 映射到 P P 2, VP 2 映射到 pp 7。相似地, 进程 )的页表将\n进程i:\n进程 j :\nN-1\n地址翻译\n共享页面\nVP 1 映射到 PP 7, VP 2 映射到 PP N - 1\nM - 1\n10 。注意 ,多 个虚拟页面可以 映射到同 图 9-9 VM 如何为进程提供独立的地址空间。操作系统一个共享物理页面上。 为系统中的每个进程都维护一个独立的页表\n按需页面调度和独立的虚拟地址空间的结合,对系统中内存的使用和管理造成了深远的影响。特别地 , VM 简化了链接和加载、代码 和数据共享, 以及应用程序的内存分配。\n简化链接。独立的 地址空间允 许每个进程的内存映像使用相同的基本格式 , 而不管代码和数据实际存 放在物理内 存的何处。例如, 像我们在图 8-13 中看到的, 一个给定 的 L in ux 系统上的 每个进程都使用类似的内存格式。对于 64 位地址空间, 代码段总是从虚拟地址 Ox 4 0 00 0 0 开始。数据段跟在代码段之后, 中间有一段符合要求的对齐空白。栈占据用户进程地址空间 最高的 部分, 并向下生长。这 样的一致性极大地简化了链接器的设计和实现,允 许链接器生成完全链接的可执行文件,这些可执行文件是独立于物理内 存中代码和数据的最终位置的。\n简化加栽。虚拟内存还使得容易向内存中加载可执行文件和共享对象文件。要把目 标文件中 . t e x t 和 . da 七a 节加载到 一个新创建的进程中, L in u x 加载器为代码 和数据段分配虚拟页,把它们标记为无效的(即未被缓存的),将页表条目指向目标文件 中适当的位 置。有趣的是, 加载器从不从磁盘到内 存实际复制任何数据。在每个页初次被引用时, 要么是 CP U 取指令时引用的, 要么是一条正在执行的指令引用一个内存位置时引用的,虚拟内存系统会按照需要自动地调入数据页。\n将一组连续的虚拟页映射到任意一个 文件中的任意位 置的表示法称作内存映 射( mem­ ory m a pping ) 。Lin u x 提供一个称为 mma p 的 系统调用,允 许应用程序自己做内存映射。我们会在 9. 8 节中更详细地描述应用级内存映射。\n简化共享。独立地址空间为操作系统提供了一个管理用户进程和操作系统自身之间 共享的一致机制。一般而言, 每个进程都有自己 私有的代码、数据、堆以及栈区域, 是不和其他进程共 享的。在这种情 况中, 操作系统创建页表,将 相应的 虚拟页映射到不连续的物理页 面。\n然而, 在一些情况中, 还是需要进程来共享代码和数据。例如, 每个进程必须询用相同的操作系统内核代码 , 而每个 C 程序都会 调用 C 标准库中的程序, 比如 pr i nt f 。操作系统通过将不同进程中适当的虚拟页面映射到相同的物理页面,从而安排多个进程共享这部分代 码的一个副本, 而不是在每个 进程中都包括单独的内 核和 C 标准库的副本, 如图 9-9 所示。\n简化内存 分配。虚拟内存为向用 户进程提供一个简单的分配额外 内存的机制。当一个运行在用户进程中的 程序要求额外的堆空间时(如调用 ma l l o c 的结果), 操作系统分配一个适当数字(例如 k ) 个连续的虚拟内存页面, 并且将它们映射到物理内存\n中任意位置的 K 个任意的物理页面。由 于页表工作的方式, 操作系统没有必要分配\nk 个连续的物理内存页面。页面可以随机地分散在物 理内存中。\n9. 5 虚拟内存作为内存保护的工具\n任何现代计算机系统必须为操作系统提供手段来控制对内存系统的访问。不应该允许 一个用户进程修改它的只读代码段。而且也不应该允许它读或修改任何内核中的代码和数 据结构。不应该允许它读或者写其他进程的私有内存,并且不允许它修改任何与其他进程 共享的虚拟页面,除非所有的共享者都显式地允许它这么做(通过调用明确的进程间通信 系统调用)。\n就像我们所看到的,提供独立的地址空间使得区分不同进程的私有内存变得容易。但 是,地址 翻译机制 可以以一种自然 的方式扩展到提供更好的访问控制。因为每次 CP U 生成一个地址时 , 地址翻译硬件都会读一个 PT E , 所以通过在 PT E 上添加一些额外的许可位来控制对 一个虚拟页面内容的访问十分简 单。图 9-10 展示了大致的思想 。\n带许可位的页表\nSUP READ WRITE 地址 物理内存\nI PPO\n亡 pp 2\nPP4\nSUP READ WRITE 地址\n./\u0026quot;\nPP6\nVPO: 进程}: VP I: 否 是 是 是 不口 是 pp 9 pp 6 V I PP 9 VP 2: 不口 是 定曰 PP I I 恤 I PP 11 啊 I 图 9-10 用虚拟内存来提供页面级的内存保护\n在这个示例 中, 每个 PT E 中已经添加了三个许可位。SUP 位表示进程是否必须运行在内核(超级用户)模式下才能访问 该页。运行在内核模式中的进程可以访问任何页面, 但是运行在用户模式中的进程只允 许访问那些 SU P 为 0 的页面。READ 位和 WRIT E 位控制对页 面的读和写访问。例如, 如果进程 1 运行在用户模式下, 那么它有读 VP 0 和读写VP 1 的权限。然 而, 不允许它访问 VP 2。\n如果一 条指令违反了这些许可条件, 那么 CPU 就触发一 个一般保护故障, 将控制传递给一个内核中的异常处理程序。Linu x s hell 一般将这种异常报告为 "段错误( seg menta­ tion fault ) \u0026quot; 。\n6 地址翻译\n这一节讲述的是 地址翻译的 基础知识 。我们的目 标是让你了解硬件在支持虚拟内存中的角色,并给出足够多的细节使得你可以亲手演示一些具体的示例。不过,要记住我们省 略了大量的细节, 尤其是和时序相关的细节, 虽然这些细节对硬件设计者来说是非常重要的, 但是超出 了我们 讨论的范围。图 9-11 概括了我们在这节里将要使用的所有符号,供读者参考。\n符 号 物理地址 ( PA) 的组成部分 描述 PPO 物理页面偏移量(字节) PPN co 物理页号 缓冲块内的字节偏移鱼 CI 高速缓存索引 CT 高速缓存标记 图 9-11 地 址 翻译符号小结\n形式上来说,地 址 翻译是一个 N 元素的虚拟地址空间( VAS ) 中的元素和一个 M 元素\n的 物 理地址空间( PAS ) 中元素之间的映射,\nMAP : VAS\u0026ndash; PASU0\n这里\nA\u0026rsquo; 如果虚 拟地 址 A 处的数据在 P AS 的 物理地址 A\u0026rsquo; 处\nMAPCA) = { O 如果虚拟地址 A 处的数据不 在 物理 内存 中\n图 9-12 展示 了 MMU 如何利用页表来实现这种映射。CPU 中的一个控制寄存器,页 表\n基址寄存 器( Page Table Base Register , PT BR)指向当前页表。n 位的虚拟地址 包含两个部分: 一 个 p 位的虚拟页 面 偏移 ( Virt ual Page Offset, VPO) 和一个( n - p ) 位的虚拟 页号 ( Virtu al\n页表基址寄存器(PTBR)\nn-1\n虚拟页号( VPN )\n虚拟地址\np p - 1 0\n虚拟页偏移批\u0026lt; v o )\n物理页号( PPN )\n页表\n如果有效位= 0,\n那么页面就不在\n存储器中(缺页) m- 1\n物理页号 ( PPN )\nP /- 1 i 。1\n物理页偏移量( PPO )\n物理地址图 9-12 使 用 页表的地址翻译\nPage Numbe r , VPN) 。 MMU 利用 VPN 来选择适当的 PTE。例如, VPN 0 选择 PTE O , VPN 1 选择 PTE 1, 以此类推。将页表条目中物理 页号 ( Physical Page Number, PPN) 和虚拟地址中的 VPO 串联起来,就 得到相应的物理地址。注意,因 为物理和虚拟页面都是 P 字节的, 所以 物理 页面偏 移( Physical Page Offset, PPO) 和 VPO 是相同的。\n图 9-13a 展示了当页面命中时, CPU 硬件执行的步骤。\n笫 l 步 : 处 理 器生成一个虚拟地址,并 把 它 传送给 MMU 。 笫 2 步 : MMU 生成 PT E 地址,并 从 高速缓存/主存请求得到它。\n笫 3 步 : 高速缓存/主存向 MMU 返回 PT E。\n笫 4 步 : MMU 构造物理地址,并 把 它 传送给高速缓存/主存。\n. 第 5 步 : 高速缓存/主存返回所请求的数据字给处理器。\nCPU芯片 CD\n: P\u0026rsquo;I\u0026rsquo;EA\na ) 页面命中\nb ) 缺页\n图 9-13\n页面命中 和缺页的 操作图 ( VA, 虚拟地址 。PT EA : 页表条目 地址 。\nPTE: 页表条目。PA: 物理地址)\n页面命中完全是由硬件来处理的,与之不同的是,处理缺页要求硬件和操作系统内核 协作完成, 如图 9-136 所示。\n笫 l 步到 笫 3 步: 和图 9-13a 中的第 1 步到第 3 步相同。 笫 4 步: PT E 中 的 有 效 位是零,所 以 MMU 触发了一次异常,传 递 CPU 中的控制到操作系统内核中的缺页异常处理程序。\n笫 5 步: 缺页处理程序确定出物理内存中的牺牲页, 如果这个页面已经被修改了, 则 把它换出到磁盘。\n. 第 6 步: 缺 页 处理程序页面调入新的页面,并 更 新 内 存 中 的 PT E 。\n. 第 7 步: 缺页处理程序返回到原来的进程,再 次执行导致缺页的指令。CPU 将引起缺页的 虚拟地址重新发送给 MMU。因为虚拟页面现在缓存在物理内存中, 所以就会命中, 在 MMU 执行了图 9-13b 中的步骤之后, 主存就会将所请求字返回给处理器。\n; 练习题 9. 3 给定一个 32 位的虚拟地址空 间 和 一个 24 位的 物理 地址, 对于下 面的 页面 大 小 P , 确定 VP N 、VPO、PPN 和 P PO 中的位数:\np VPN位数 VPO位数 PPN位数 PPO位数 IKB 2KB 4KB 8KB 9. 6. 1 结合高速缓存和虚拟内存\n在任何既使用虚拟内存又使 用 S RAM 高速缓存的系统中,都 有应该使用虚拟地址还是 使 用 物理地址来访问 SRAM 高速缓存的问题。尽管关千这个折中的详细讨论已经超出了我们的讨论范围,但是大多数系统是选择物理寻址的。使用物理寻址,多个进程同时在 高速缓存中有存储块和共享来自相同虚拟页面的块成为很简单的事情。而且, 高速缓存无需 处理保 护问 题 ,因 为访问权限的检查是地址翻译过程的一部分。\n图 9-14 展示了一个物理寻址的高速缓存如何和虚拟内存结合起来。主要的思路是地址 翻译发生在高速缓存查找之前。注意,页 表条目可以缓存, 就 像其他的数据字一样。\nCPU 芯片 i\u0026mdash;\u0026mdash;:\u0026ndash;\nPTE\n:\n不命中\nPTEA\nPA\nI 内存\nPA I 数 据\n命中\nLI\n高速缓存\n图 9-1\u0026quot;1 将 V M 与物理寻址的高速缓存结合起来 ( V A , 虚拟地址。\nPTEA, 页表条目地址。P T E , 页表条目。P A , 物理地址)\n9. 6. 2 利用 TLB 加速地 址 翻译\n正如我们看到的, 每次 CP U 产生一个虚拟地址, MMU 就必须查阅一个 PT E , 以便将虚拟地址翻译为物理地址。在最糟糕的情况下 , 这会要求从内存多取一次数据, 代价是几 十到几百个周期。如果 PT E 碰巧缓存在 Ll 中,那 么 开销就下降到 1 个或 2 个 周 期。然而, 许多系统都试图消除即使是这样的开销, 它 们 在 MM U 中包括了一个关于 PT E 的小的缓 存,称 为翻译后备缓冲 器 ( T ra ns la t io n Lookaside Buffer, TLB)。\nTLB是一个小的、虚拟寻址的缓存, 其 n- 1 p +t p+t- 1 p p-1 0\n中每一 行都保存着一 个由单 个 PTE 组 成 的块。 j TLB标记 (TLBT) I TLB索引 (TLBI) I VPO\n\u0026lsquo;y ,\nT L B 通常有高度的相联度。如图 9-15 所示,\nVPN\n用于组选择和行匹配的索引和标记字段是从 图 9- l5 虚拟地址中用以访 问 TLB 的组成部分\n虚拟地址中的虚拟页号中提取出 来的。如果 TLB 有 T = 2\u0026rsquo; 个组, 那么 T LB 索引 ( T LBD是由\nVPN 的 t 个最低位组成的, 而 TLB 标 记 (T LBT)是由 VPN 中剩余的位组成的。\n图 9- l 6a 展示了当 T LB 命中时(通常情况)所包括的步骤。这里的关 键点是, 所有的地址翻译步骤都是在芯片 上的 MMU 中执行的 , 因此非常快。\n笫 1 步: CPU 产生一个虚拟地址 。\n笫 2 步和 笫 3 步: MMU 从 T LB 中取出相应的 PT E。\n. 第 4 步: MMU 将这个虚拟地址翻译成一个物理地址,并且 将它发送到高速缓存/主存。\n笫 5 步: 高速缓存/主存将所请求的数 据字返回给 CPU 。\n当 T LB 不命中时, MMU 必须从 L1 缓存中取出相应的 PT E , 如图 9-166 所示。新取出的 PT E 存放在 T LB 中, 可能会覆盖— 个已经存 在的条目。\nCPU 芯片\n\u0026mdash;\u0026mdash;\u0026mdash;\u0026mdash;\u0026mdash;\u0026mdash;\u0026mdash;\u0026mdash;\u0026mdash;\u0026mdash;\u0026mdash;\u0026mdash;\u0026mdash;-\niC\u0026ndash;P\u0026ndash;U\u0026ndash;芯\u0026ndash;片\u0026mdash;\u0026mdash;\u0026mdash;\u0026mdash;\u0026mdash;\u0026mdash;\u0026mdash;\u0026mdash;\u0026mdash;\u0026mdash;\u0026mdash;\n'\n血 - - -\nG) 数据\nTLB命中 图 9-16 T LB 命中和不命中的操作图\n@ # TLB不命中 9. 6. 3 多级页表\n到目前为止,我们一直假设系统只用一个单独的页表来进行地址翻译。但是如果我们 有一个 32 位的地址空间 、4KB 的页面和一个 4 字节的 PT E , 那么即使应用所引用的只是虚拟地址空间中很小的 一部分, 也总是需要一个 4MB 的页表驻留在内存中。对于地址空间为 64 位的 系统来说, 间题将变得更 复杂。\n用来压缩页表的常用方法是使用层次结构的页表。用一个具体的示例是最容易理解这 个思想的。假设 32 位虚拟地址空间被分为 4KB 的页, 而每个页表条目都是 4 字节。还假设在这一时刻, 虚拟地址空间有 如下形式 :内 存的前 ZK 个页面分 配给了代码和数据,接下来的 6K 个页面还未分配,再 接下来的 1023 个页面也未分配, 接下来的 1 个页面分配给了用户栈。图 9-17 展示了我们 如何为这个虚拟地址空间 构造一 个两级的页表层次结构。\n一级页表中的每个 PT E 负责映射虚拟地址空间 中一个 4MB 的片( chunk ) , 这里每一片都是由 1024 个连续的页面组成 的。比如, PT E 0 映射第一片, PT E 1 映射接下来的一片, 以此类推。假设 地址空间是 4GB, 1024 个 PT E 已经足够覆盖 整个空间 了。\n如果片 1 中的每个页面都未被分配, 那么一级 PTE i 就为空。例如 , 图 9-17 中, 片 2~ 7 是未被分 配的。然而 , 如果在片 1 中至少有一个页是分配了的, 那么一级 PT E i 就指向一个二级 页表的基址。例如, 在图 9-17 中, 片 0、1 和 8 的所有或者部分巳被分配, 所以它们的一级 PTE 就指向二级页表。\n一级页表 二级页表 虚拟内存\n已分配的 2K 个代码和数据 VM 页\nPTE 5 (null)\nPTE 6 (null)\nPTE 7 (null)\nGap I 6K 个未分配的 VM 页\n(IK-9)\n空PTE\n1023 』\n图 9- 17 一个两级页表层次结构。注意地址是从上往下增加的\n二级页表中的每个 PT E 都负责映射一个 4KB 的 虚拟内存页面,就 像 我们查看只有一级的 页表一样。注意,使 用 4 字节的 PT E , 每个一级和二级页表都是 4KB 字节, 这刚好和一个页面的大小是一样的。\n这种方法从两个方面减少了内存要求。第一, 如果一级页表中的一个 PT E 是空的, 那 么 相 应的二级页表就根本不会存在。这代表着一种巨大的潜在节约, 因 为对于一个典型的 程序, 4GB 的虚拟地址空间的大部分都会是未分配的。第二,只 有 一 级 页 表才需要总是在主存中;虚拟内存系统可以在需要时创建、页面调入或调出二级页表,这就减少了主存 的压力;只 有 最 经 常 使 用 的 二 级 页 表才需要缓存在主存中。\n图 9-18 描述了使用 K 级 页 表层次结构的地址翻译 。虚拟地址被划分成为 K 个 VP N 和\n1 个 VPO 。每个 VP N i 都是一个到第 z 级 页 表的索引,其 中 1 ::::;;;i::::;;; k。 第 )级 页 表中的每个 PT E , 1::::;;;j 冬 k —1, 都 指 向 第 j + l 级的某个页表的基址。第 k 级 页 表 中 的 每个 PTE 包含 某 个物理页面的 PP N , 或 者 一个磁盘块的地址。为了构造物理地址,在 能 够 确定 PPN 之前 , MMU 必须访问 K 个 PT E。对于只有一级的页表结构, PPO 和 VPO 是相同的。\n虚拟地址\nn - 1\n曰 勹\nm-1 p-q\nPPN I PPO\n物理地址\n图 9- 18 使用 K 级 页 表 的 地 址 翻 译\n访问k 个 PT E , 第一眼看上去昂 贵而不切实际。然而, 这里 T LB 能够起作用, 正是通过将不同层次上 页表的 PT E 缓存起来。实际上, 带多级页表的地址翻译并不比单级页表慢很多。\n6. 4 综合:端到端的地址翻译\n在这一节里,我们通过一个具体的端到端的地址翻译示例,来综合一下我们刚学过的 这些内容 , 这个示例运行在有一个 T LB 和 Ll cl- cache 的小系统上。为了保证可管理性, 我们做出如下假设:\n内存是按字节寻址的。 内存访问是针对 1 字节的字 的(不是4 字节的字)。 虚拟地址是 14 位长的( n = 14) 。 物理地址是 12 位长的( m = 1 2) 。 页面大小是 64 字节( P = 64) 。 T LB 是四路组相联的 ,总 共有 16 个条目。\nLl cl- cache 是物理寻址、直接映射的, 行大小为 4 字节, 而总共有 16 个组。\n图 9-19 展示了虚拟地址和物理地址的格式。因为每个 页面是 26 = 64 字节, 所以虚拟地址和物理地址的低 6 位分别作为 VPO 和 PPO 。虚拟地址的高 8 位作为 VP N。物理地址的高 6 位作为 PP N。\n13 12 11 10 9 8 7 6 5 4 3 2 1 0\n虚拟地址 I I I I I I I I I I I I I j\nVPN\n(虚拟页号)\nVPO\n(虚拟页偏移)\n物理地址\n11 . 10 . 9l\n8 . 7 . 6 .\n5 . 4 .\n3 . 2 .\n1 . 0\nPPN\n(物理页号)\nPPO\n(物理页偏移)\n图 9-19 小内存系统的寻址。假设 14 位的虚拟地址 ( n = l 4 ) ,\n1 2 位的物理地址 ( m = l 2 ) 和 64 字节的页面 CP = 64 )\n图 9-20 展示了小内存系统的一个快照, 包括 T LB C 图 9- ZOa ) 、页表的一部分(图9 - 206 ) 和 Ll 高速缓存(图9- ZOc) 。 在 T LB 和高速缓存的图上面, 我们还展示了访问这些设备时硬件是如何划 分虚拟地址 和物理地址的位的。\nT LB。T LB 是利用 VP N 的位进行虚拟寻址的。因为 T LB 有 4 个组, 所以 VP N 的低 2 位就作为组索引( T LBI) 。VP N 中剩下的高 6 位作为标记CT L BT ) , 用来区别可能映 射到同一个 T LB 组的不同的 VP N。\n页表。这个 页表是一个单级设计, 一共有 28 = 256 个页表条目( PT E ) 。然而, 我们只对这些条目中的开头 16 个感兴趣。为了方便 , 我们用索引 它的 VP N 来标识每个\nPTE; 但是要记住这些 VP N 并不是页表的一部分,也 不储存在内存中。另外, 注意每个无效 PT E 的 PPN 都用一个破折号来表示, 以加强一个概念:无 论刚好这里存储的是什么位值, 都是没有任何意义的。\n高速缓存 。直接映射的缓存是通过 物理地址中的字段来寻址的。因为每个块都是 4 字节, 所以物理地址的低 2 位作为块偏移( CO ) 。因为有 16 组, 所以接下来的 4 位就用来表示组索引 ( CD 。剩下的 6 位作为标记CCT ) 。\n虚拟地址\n\u0026lsquo;TLBT I七TLBI-\n13 12 II IO 9 8 7 6 5 4 3 2 I 0\nI I I I I I I I I I I I I I I\n, VPN .\u0026lsquo;VPO I\n11I1IJ1iJlI II会 # TLB: 四组, 16 个条目,四路 组相联 VPN 00\n01\n02\n03\n04\n05\n06\n07\nPPN 有效位\n28\n33\n02\n16\nVPN 08\n09\nOA OB\noc\nOD OE OF\nPPN 有效位\n2D\n11\nOD\n页表:只 展示了前16 个 PTE 物理地址\n• CT• • CI• +- CO -+\nII 10 9 8 7 6 5 4 3 2 I 0\nI I I I I I I I I I I I I\nPPN• • PPO• 索引有效位 块 0 块 l 块 2 块 3\n高速 缓存: 16 个组,4 字节的块,直 接映射 图 9 - 2 0\n小内存系统的 T LB、页表以及缓存。T LB、页表和缓存中所有的值都 是十六进制表示的\n给定了这种初始化设定,让 我们来看看当 CPU 执行一条读地址 Ox 0 3 d 4 处 字节的加载指 令 时 会 发 生什么。(回想一下我们假定 CPU 读取 1 字节的字,而 不 是 4 字 节的字。)为了\n开始这种手工的模拟,我们发现写下虚拟地址的各个位,标识出我们会需要的各种字段, 并确定它们的十六进制值,是非常有帮助的。当硬件解码地址时,它也执行相似的任务。\n开始时 , MMU 从虚拟地址中抽取出 VP N ( OxOF) , 并且检查 T LB, 看它是否因为前面的某 个内存引用缓存了 PT E OxO F 的一个副本。T LB 从 VPN 中抽取出 T LB 索引( Ox 03) 和 T LB 标记 ( Ox3 ) , 组 Ox 3 的 第 二 个 条 目 中 有效匹配, 所 以 命 中 , 然后 将缓 存 的 PP N ( OxOD) 返回给 MMU。\n如果 T LB 不命中,那 么 MMU 就需要从主存中取出相应的 PT E。然而,在 这种情况中, 我们很幸运, T L B 会命中。现在, MMU 有了形成物理地址所需要的所有东西。它通过将来自 PT E 的 PP N ( Ox OD) 和来 自虚拟地址的 VPO ( Ox l 4) 连接起来,这 就 形 成 了 物 理 地址( Ox 35 4) 。\n接下来, MMU 发送物理地址给缓存, 缓 存 从 物 理 地 址 中 抽 取 出 缓 存 偏 移 CO ( OxO) 、缓存组索引 CIC Ox5 ) 以 及 缓 存 标 记 CT ( Ox OD) 。\n因 为组 Ox5 中 的 标 记 与 CT 相 匹 配, 所以缓存检测到一个命中,读 出在偏移量 co 处的数据字节( Ox 3 6) , 并将它返回给 MMU , 随后 MMU 将它传递回 CP U。\n翻译过程的其他路径也是可能的。例如, 如 果 T LB 不命中,那 么 MMU 必 须从 页 表中的 PT E 中取出 PP N。如果得到的 PT E 是无效的,那 么 就 产 生 一 个 缺 页 ,内 核 必 须 调 入合适的页面,重 新 运行这条加载指令。另一种可能性是 PT E 是有效的, 但 是 所 需 要 的 内存块在缓存中不命中。\n练习题 9. 4 说明 9. 6. 4 节中的 示例内存 系统 是如何将一个虚拟地址翻译成 一个物理地址和 访问缓存的。对于给定的 虚 拟地址, 指明 访 问的 T LB 条目、 物理地 址和返回的缓存 字节值 。指出是 否发 生 了 T LB 不命中, 是否发 生 了 缺 页, 以 及是否 发 生 了 缓存不命中。如果是缓存不命中,在“返回的缓存字节”栏中输入“—\u0026quot;。如果有缺页, 则在 \u0026quot; PP N\u0026quot; 一栏 中输入“—“ , 并且将 C 部分和 D 部分空着 。\n虚拟地址: Ox03d7\n虚拟地址格式 13 12\nI I\n11 10 9\nI I\nL8J 7 6\n5 4 3 2 1 0\nI I I I I I\n地址翻译 物理地址格式 11 10 9 8 7 6 5 4 3 2 I 0\n物理内存引用 9. 7 案例研究: Intel Core i7/ Linux 内存系统\n我们以一个实际系统的案例研究来总结我们对虚拟内存的讨论: 一个运行 Linux 的Intel Core i7。虽 然底层的 Haswe ll 微体系结构允许完全的 64 位虚拟和物理地址空间,而现在的(以及可预见的未来的)Core i7 实现支持 48 位( 256T B) 虚拟地址空间和 52 位( 4PB) 物理地址空间 , 还有一个兼容模式, 支持 32 位( 4GB) 虚拟和物理地址空间。\n图 9-21 给出了 Core i7 内存 系统的重要部分。处理 器封 装( processor package) 包括四个核、一个大的所有核共享的 L3 高速缓存,以 及一个 DDR3 内存控制器。每个核包含一个层次结构的 T LB、一个层次结构的数据和指令高速缓存, 以及一组快速的点到点链路, 这种链路基于 QuickPat h 技术, 是为了让一个核与其他核和外部 1/ 0 桥直接通信。TLB 是 虚拟寻址的, 是四路组相联的。Ll 、L2 和 L3 高速缓存是物理寻址的, 块大小为 64 字节。Ll 和 L2 是 8 路组相联的, 而 L3 是 16 路组相联的。页大小可以 在启动时被配置为\n4KB 或 4MB 。Lin ux 使用的是 4KB 的页。\n9. 7. 1 Core i7 地址翻译\n图 9-22 总结了完整的 Core i7 地址翻译 过程,从 CPU 产生虚拟地址的时刻一直到来自内存的数据字到达 CPU 。Core i7 采用四级页表层次结构。每个进程有它自己 私有的页表层次结构。当一个 Linux 进程在运行时,虽 然 Core i7 体系结构允许页表换进换出 ,但是 与已分配了的页相关联的页表都是驻留在内存中的。CR3 控制寄存器指向第一级页表( Ll) 的起始位置。CR3 的值是每个进程上下文的一部分, 每次上下文切换时, CR3 的值都会被恢复。\n处理器封装\n,\u0026mdash;\u0026mdash;\u0026mdash;\u0026mdash;\u0026mdash;\u0026mdash;\u0026mdash;\u0026mdash;\u0026mdash;\u0026mdash;\u0026mdash;\u0026mdash;\u0026mdash;\u0026mdash;\u0026mdash;\u0026mdash;\u0026mdash;\u0026mdash;\u0026mdash;\u0026mdash;\u0026mdash;\u0026mdash;\u0026mdash;\u0026mdash;\u0026mdash;\u0026mdash;\u0026mdash;\u0026mdash;\u0026mdash;\u0026mdash;\u0026mdash;\u0026mdash;\u0026mdash;\u0026mdash;\u0026mdash;-\n核 x 4\nMMU\n(地址翻译)\nLI d-TLB 1 1 LI i-TLB\n64 个条目, 4 路 128 个条目,4 路\nL2 统一高速缓存\n256 KB, 8 路\nu 统 一 TLB 512 个条目,4 路\n到其他核到1/0 桥\n13统一高速缓存\n8 MB, 16 路\n(所有的核共享)\nDDR3存储器控制器\n(所有的核共享)\n! ._ 卜\u0026ndash;匕\u0026ndash; \u0026mdash;\u0026mdash;\u0026mdash; \u0026mdash; \u0026mdash;\u0026mdash;-\n主存\n图 9- 21 Core i7 的内存系统\nLl d-cache\n( 64 组, 8 行 /组)\nTLB\n不命中\nCR3 页表\n图9-22 Core i7 地址翻译的概况。为了简化,没 有 显 示 i-cache 、i-T LB 和 L2 统一 T LB\n图 9-23 给出了第一级、第二级或第三级页表中条目的格式。当 P = l 时( Linux 中就总是 如此),地址字段包含一个 40 位物理页号( PPN ) , 它指向适当的页表的开始处。注意, 这强加了一个要 求, 要求物理页表 4KB 对齐。\n63 62 52 51\n2 11\n98 7 6 5 4 3 2 I 0\n三 页表物理基地址\nI\n未使用\nG I PS I I A I CD I WT IUIS尸1\nOS 可用(磁盘上的页表位置) 三\n图 9-23 第 一级 、第 二级 和第 三级页表条目格式。每个条目引用一个 4K B 子页表\n图 9-24 给出了第四级页表中条目的格式。当 P = l , 地址字段包括一个 40 位 PPN,\n它指向物理内存中某一页的基地址。这又强加了一个要求, 要求物理页 4KB 对齐。\n63 62 5251\n三 页表物理基地址\n12 11 9 8 7\nI\n未使用\n6 5 4 3 2 1 0\nD I A ICD IWTIUIS匠千=1\nOS可用(磁盘上的页表位置) 曰\n图 9-24 第 四级 页表条目的格式 。每个条目引用一个 4K B 子页\nPT E 有三个权限位, 控制对页的 访问。R / W 位确定 页的内容是可以读写的还是只读的。U/ S 位确定是否能够在用户 模式中访问该页,从 而保护操作系统内核中的代码和数据\n不被用户程序访问。XDC禁 止 执 行)位 是 在 64 位系统中引入的, 可以用来禁止从某些内存页取指令。这是一个重要的新特性,通过限制只能执行只读代码段,使得操作系统内核降 低了缓冲区溢出攻击的风险。\n当 MMU 翻译每一个虚拟地址时, 它 还会更新另外两个内核缺页处理程序会用到的位。每次访问一个页时, MMU 都会设置 A 位,称 为引 用位( reference bit ) 。内 核 可以用这个引 用位来实现它的页替换算法。每次对一个页进行了写之后, MMU 都会设置 D 位, 又称修改位或脏位Cdirty bit ) 。 修 改 位 告 诉 内 核 在 复 制替换页之前是否必须写回牺牲页。内核可以通过调用一条特殊的内核模式指令来清除引用位或修改位。\n图 9-25 给出了 Core i7 MMU 如何使用四级的页表来将虚拟地址翻译成物理地址。36 位 VP N 被划分成四个 9 位的 片, 每个片被用作到一个页表的偏移量。CR3 寄存器包含 Ll 页表的物理地址。VP N 1 提供 到一个 Ll P ET 的偏移量 , 这个 PT E 包含 L2 页 表的基地址。VP N 2 提供 到一个 L2 PT E 的偏 移量 ,以 此 类 推。\n9\nVPN!\n9\nVPN2\n9\nVPN3\n9\nVPN4\n12\nVPO 虚拟地址\nCR3\nLI PT 的\n物理地址\n12 到物理和虚拟页的偏移量\n每个条目 每个条目\n512 GB 区域 1 GB 区域\n每个条目\n2 MB 区域\n每个条目 1 页的物理\n4 KB 区域 地址\n40\n40\nPPN\n12\nPPO 物理地址\n图 9- 25 Core i7 页表翻译 (PT: 页表, PT E: 页表条目 , VPN: 虚拟页号 , VPO: 虚拟页偏移,\nPPN: 物理页号 , PPO: 物理页偏移量。图中还给出了这四级页表的 Linu x 名字)\nm 优化地址翻译\n在对地址翻译的讨论中, 我们描述了一个顺序的两 个步骤的过程, l) M MU 将虚拟 地址翻译成物理地址, 2 ) 将 物理地址传送到 L l 高速缓 存。然 而 , 实际的硬件 实现 使 用了一 个灵 活的技巧, 允 许 这些步骤部分重叠, 因此也就加速 了 对 Ll 高 速 缓 存 的访问。例如, 页 面大小为 4KB 的 Core i7 系统 上的一个虚拟地址有 12 位的 VP O , 并且这些位和相应物理地址中的 PP O 的 1 2 位是相同的。因 为八 路 组相联的、物理寻址的 Ll 高 速缓存 有 64 个组和大小为 64 字节的 缓存块, 每 个物理地址有 6 个Oog 2 64 ) 缓存偏 移位和\n6 个(l og为4) 索引 位。这 12 位恰好符合虚拟地址的 VPO 部分, 这绝不是 偶 然! 当 CP U\n需要 翻译一个虚拟地址时, 它就发 送 VP N 到 MMU, 发送 VPO 到高速 L1 缓存。当 MMU\n向 T LB 请求一 个页表 条目时 , L1 高速缓存正忙着利 用 VPO 位查找相应的组, 并读出1 这个组里的 8 个标记和相应的数 据宇。 当 MMU 从 T LB 得到 PPN 时,缓存 已经准备好试着把 这个 PPN 与这 8 个标记中的 一个进行 匹配 了。\n9 . 7 . 2 Linux 虚拟内存系统\n一个虚拟内存系统要求 硬件和内核软件之间的紧密协作 。版本与版本之间细节都不尽相同, 对此完整的阐释超出了我们讨论的范围。但是, 在这一小节中我们的目标是对L in u x 的虚 拟内存系统做一个描述, 使你能够大致了解一个实际的操作系统是如何组织虚拟内存,以 及如何处理缺页的。\nLin u x 为每个进程维护了一个单独的\n虚拟地址空间,形 式如图 9-26 所示。我们已经多次看到过这幅图了,包括它那些熟悉的代码、数据、堆、共享库以及栈段。\n与进程相关的数据结构\n(例如, 页表、task 和\nmm结构,内核栈)\n内 核虚拟\n内存\n既然我们理解了地址翻译,就能够填入更\n多的关千内核虚拟内存的细节了,这部分虚拟内存位千用户栈之上 。\n内核虚拟内存包含内核中的代码和数 据结构。内核虚拟内存的某些区域被映射到所有进程共享的物理页面。例如, 每个进程共享内核的代码和全局数据结构。有趣的是, L in u x 也将一组连续的虚拟页面\n(大小等千系统中 DRAM 的总量)映射到相应的一组连续的物理页面。这就为内核提供了一种便利的方法来访问物理内存中任何特定的位置,例如,当它需要访问页\n%r sp 一 令\nbr k -\n物理内存\n内核代码和数据\n进程虚拟\n内存\n表,或在一 些设备上执行内存映射的 J/0 O x4 0 0 00 0 0 0 \u0026ndash;+\n操作,而这些设备被映射到特定的物理内 °\n存位置时。 图 9-26 一个 Linux 进程的虚拟内存\n内核虚拟内存的其他区域包含每个进程都不相同的数据。比如说,页表、内核在进程 的上下文中执行代码时使用的栈, 以及记录虚拟地址空间 当前组织 的各种数据结构。\nL inux 虚拟内存区域\nLin ux 将虚拟内存组织成一些 区域(也叫做段)的集合。一个区域( area ) 就是巳 经存在着的(已分配的)虚拟内存的连续 片( ch unk ) , 这些页是以某种方式相关联的。例如,代码段、数据段、堆、共享库段,以及用户栈都是不同的区域。每个存在的虚拟页面都保存在 某个区域中,而不 属于某个区域的 虚拟页是不存在的,并且不能被进程引用。区域的概念很重要 ,因为它允 许虚拟地址空间有间 隙。内核不用记录那些不存在的虚拟页,而这样的页也不占用内存、磁盘或者内核本身中的任何额外资源。\n图 9- 27 强调了记录一个 进程中 虚拟内存区域的内核数据结构。内核为系统中的每个进程维护一个单独的任务结构(源代码中的七a s k_s tr uc t ) 。任务结构中的元素包含或者指向内核运行该 进程所需要的所有信息(例如, PI O、指向用户栈的指针、可执行目标文件的名字, 以及程序计数器)。\nt as k_s r七\nuc t mm_ s rt\nuc t\nvm_ar ea_s tr uc t 进程虚拟内存\nvrn_start vrn_prot\nvrn_flags 数据\n代码\nvrn prot vrn_flags vrn_next\n图 9- 27 Linux 是如何 组织虚拟内存的\n任务结构中的一个条目指向mm_s tr uc t , 它描述了虚拟内存的当前状态。我们感兴趣的两个字段是 pgd 和 mma p , 其中 pgd 指向第一级页表(页全局目录)的基址,而 mma p 指向 一个vrn_ar e a _ s tr uc t s ( 区域结构)的链表, 其中每个vrn_ ar e a _s tr uc t s 都描述了当前虚拟地址空间的一个区域。当内核运行这个进程时, 就将 pgd 存放在 CR3 控制寄存器中。\n为了我们的目的,一个具体区域的区域结构包含下面的字段:\nvrn_ s t a r 七: 指向这个区域的起始处。 • vm_ e nd : 指向这个区域的结束处。 vrn王r o t : 描述这个区域内包含的所有页的读写许可权限。 vm_flags: 描述这个区域内的页面是与其他进程共享的,还是这个进程私有的(还描述了其他一些信息)。 vm next: 指向链表中下一个区域结构。 Linux 缺页异常处理\n假设 MMU 在试图翻译某个虚拟地址 A 时,触 发了一个缺页。这个异常导致控制转移到内核的缺页处理程序,处理程序随后就执行下面的步骤:\n虚拟地址 A 是合法的吗? 换句话说, A 在某个区域结构定义的区域内吗? 为了回答这个问题 , 缺页处理程序搜索区域结 构的链表, 把 A 和每个区域结 构中的 vrn_ s t ar t 和vm_e nd 做比较。如果这个指令是不合法的, 那么缺页处理程序就触发一个段错误,从 而 终止这个 进程。这个情况在图 9-28 中标识为 \u0026quot; l \u0026quot; 。 因为一个进程可以创建任意数量的新 虚拟内存区域(使用在下一节中描述的 mma p 函数),所以顺序搜索区域结构的链表花销可能会很大。因此在实际中, L in ux 使用某些我们没有显示出来的 字段, L in ux 在链表中构建了一棵树, 并在这棵树上进行查找。\n2 ) 试图进行的内存访问是否合法? 换句话说, 进程是否有读、写或者执行这个区域内页面的权限?例如,这个缺页是不是由一条试图对这个代码段里的只读页面进行写操作\n的存储指令造成的?这个缺页是不是因为一个运行在用户模式中的进程试图从内核虚拟内存中读取字造成的?如果试图进行的访问是不合法的,那么缺页处理程序会触发一个保护异常,从 而终止这个进程。这种情况 在图 9-28 中标识为 \u0026quot; 2\u0026quot; 。\n3 ) 此刻,内 核知道了这个缺页是由于对合法的虚拟地址进行合法的操作造成的。它是这样来处理这个缺页的:选择一个牺牲页面,如果这个牺牲页面被修改过,那么就将它交换 出去, 换入新的页面并更新页表。当缺页处理程序返回时, CPU 重新启动引起缺页的指令,这条指令将再次发送A 到 MMU。这次, MMU 就能正常地翻译 A, 而不会再产生缺页中断了。\nvrn_ar ea_s t r uct 进程虚拟内存\n段 错误:\n访 问 一个不存在的页面\n@) 正常 缺页\n保护异常:\n© 例如, 违反许可, 写 一个只读的页面\nvm_next\n图 9-28 Linux 缺 页处 理\n8 内存映射\nLin ux 通过将一个虚拟内存区域与一个磁盘上的对象( o bject ) 关 联起来, 以初始化这个虚拟内存区域的内容, 这个过程称为内存 映射( memory mapping ) 。虚拟内存区域可以映射到两种类型的对象中的一种:\nLinu x 文件 系统中的 普通文件: 一个区域可以 映射到一个普通磁盘文件的连续部分, 例如一个可执行目标文件。文件区( sect io n ) 被分成页大小的片, 每一片包含一个虚拟页面的初始内容。因为按需 进行页面调度, 所以这些虚拟页面没有实际交换进入物理内存, 直到 CPU 第一次引用到页面(即发射一个虚拟地址, 落在地址空间这个页面的范围之内)。如果区域比文件区 要大, 那么就用零来填充这个区域的余下部分。 ) 匿名 文件 : 一个区域也可以 映射到一个匿名文件, 匿名文件是由内核创建的,包\n含的全是二进制零。CP U 第一次引用这样一个区域内的虚拟页面时,内 核就在物理内存中找到一个合适的牺牲页面, 如果该页面被修改 过, 就将这个页面换出来,用 二进制零覆盖牺牲页面并更新页表,将这个页面标记为是驻留在内存中的。注意在磁盘和内存之间并 没有实际的数据传送。因为这个原因,映射到匿名文件的区域中的页面有时也叫做请求二 进制零的 页( dem and-ze ro page ) 。\n无论在哪种情况中,一旦一个虚拟页面被初始化了,它就在一个由内核维护的专门的 交换文件( s wa p file) 之间换来换去。交换文件也叫做交换 空间 ( s wa p s pace ) 或者交换区域\n(swap area)。需要意识到的很重要的一点是, 在任何时刻, 交换空间都限制着当前 运行着的进程能够分配的虚拟页面的总数。\n8. 1 再看共享对象\n内存映射的概念来源于一个聪明的发现:如果虚拟内存系统可以集成到传统的文件系统中,那么就能提供一种简单而高效的把程序和数据加载到内存中的方法。\n正如我们已经看到的,进程这一抽象能够为每个进程提供自己私有的虚拟地址空间, 可以免受其他进程的错误读写。不过,许多进程有同样的只读代码区域。例如,每个运行 Linux shell 程序 ba s h 的进程都有相同的代码区域。而且, 许多程序需要访问只读运行时 库代码的相同副本。例如 ,每 个 C 程序都需要来自标 准 C 库的诸如 pr i nt f 这样的函数。那么,如果每个进程都在物理内存中保持这些常用代码的副本,那就是极端的浪费了。幸 运的是,内存映射给我们提供了一种清晰的机制,用来控制多个进程如何共享对象。\n一个对象可以被映射到虚拟内存的一个区域,要么作为共享对象,要么作为私有对 象。如果一个进程将一个共享对象映射到它的虚拟地址空间的一个区域内,那么这个进程对这个区域的任何写操作,对于那些也把这个共享对象映射到它们虚拟内存的其他进程而言,也是可见的。而且,这些变化也会反映在磁盘上的原始对象中。\n另一方面,对于一个映射到私有对象的区域做的改变,对于其他进程来说是不可见 的,并且进程对这个区域所做的任何写操作都不会反映在磁盘上的对象中。一个映射到共享对象的虚拟内存区域叫做共享区域。类似地,也有私有区域。\n假设进程 1 将一个共享对象映射到它的虚拟内存的一个区域中, 如图 9- 29a 所示。现在假设 进程 2 将同一个共享对象映射到它的地址空间(并不一定要和进程 1 在相同的虚拟地址处 , 如图 9-296 所示)。\n进程 l 的 物理 进程2 的 进程 1 的 物理 进程 2 的 虚拟内存 内存 虚拟内存 虚拟内存 内存 虚拟内存 共享对象\na ) 进程 1 映射了共享对象之后\n共享对象\nb ) 进程 2 映射了同一个共享对象之后\n图 9-29 一个共享对象(注意,物理页面不一定是连续的)\n因为每个 对象都有一个唯一的文件名,内 核可以迅速地判 定进程 1 已经映射了这个对象, 而且可以使进程 2 中的页表条目指向相应的物理页面。关 键点在于即使对象被映射到了多个共享区域,物理内存中也只需要存放共享对象的一个副本。为了方便,我们将物理 页面显示为连续的,但是在一般情况下当然不是这样的。\n私有对象使用一种叫做 写时复 制( copy-on-write) 的巧妙技术被映射到虚拟内存中。一个\n私有对象开始生命周期的方式基本上与共享对象的一样,在物理内存中只保存有私有对象的 一份副本。比如,图 9-30a 展示了一种情况,其中两个进程将一个私有对象映射到它们虚拟内存的不同区域,但是共享这个对象同一个物理副本。对于每个映射私有对象的进程,相应私有 区域的页表条目都被标记为只读,并且区域结构被标记为私有的写时复制。只要没有进程试图 写它自己的私有区域,它们就可以继续共享物理内存中对象的一个单独副本。然而,只要有一 个进程试图写私有区域内的某个页面,那么这个写操作就会触发一个保护故障。\n当故障处理程序注意到保护异常是由于进程试图写私有的写时复制区域中的一个页面 而引起的,它就会在物理内存中创建这个页面的一个新副本,更新页表条目指向这个新的 副本, 然后恢 复这个页面的可写权 限, 如图 9-30b 所示。当故障处理程序返回时, CPU 重新执行这个写操作, 现在在新创建的页面上这个写操作就可以正常执行了。\n进程1 的虚拟内存\n物理 进程2 的\n内存 虚拟内存\n私有的写时复制对象\na ) 两个进程都映射了私有的写时复制对象之后\n私有的写时复制对象\nb ) 进程2 写了私有区域中的一个页之后\n写私有的写时复制的页\n图 9-30 一个私有的写时复制对象\n通过延迟私有对象中的副本直到最后可能的时刻,写时复制最充分地使用了稀有的物理内存。\n9. 8. 2 再看 f or k 函数\n既然我们理解了虚 拟内存和内存映射, 那么我们可以 清晰地知道 f or k 函数是如何创建一个带有自己独立虚拟地址空间的新进程的。\n当 f or k 函数被当前进程调用时,内 核为新进 程创建各种数据结构, 并分配给它一个唯一的 PID。为了给这个新 进程创建 虚拟内存, 它创建了当前进程的 mrn_ s tr uc t 、区域结构和页表的原样副本。它将两个进程中的每个页面都标记为只读,并将两个进程中的每个 区域结构都标记为私有 的写时复制。\n当 f or k 在新进程中返回时, 新进程现在的虚拟内存刚好和调用 f or k 时存在的虚拟内存相同。当这两个进程中的任一个后来进行写操作时,写时复制机制就会创建新页面, 因此,也就为每个进程保持了私有地址空间的抽象概念。\n9. 8. 3 再看 ex ecv e 函数\n虚拟内存和内存映射在将程序加载到内存的过程中也扮演着关键的角色。既然已经理 解了这些 概念, 我们就能够理解 e x e c v e 函数实际上是如何加载和执行程序的。假设运行\n在当前进程中的程 序执行了如下的 e xe c ve 调用:\nexecve(\u0026ldquo;a.out\u0026rdquo;, NULL, NULL);\n正如在第8 章中学到的,e xe cve 函数在当前进程中加载并运行包含在可执行目标文件a . out\n中的程序,用a .out 程序有效地替代了当前程序。加载并运行a . OU七需 要以下几个步骤:\n删除已存在的用户区域。删除当前进程虚拟地址的用户部分中的已存在的区域结构。 映射私有区域。为 新程序的代码、数据、bss 和栈区域创建新的区域结构。所有这些新的区域都是私有的、写时复制的。代码和数据区域被映射为 a . out 文件中的. t e xt 和. da t a 区。bss 区域是请求二进制零的, 映射到匿名文件, 其大小包含在a . out 中。栈和堆区域也是请求二进制零的,初始长度为零。图9-31 概括了私有区域的不同映射。\n映射共享区域。如果 a . o ut 程序与共享对象(或目标)链接, 比如标准 C 库 li bc .\nso, 那么这些对象都是动态链接到这个程序的,然后再映射到用户虚拟地址空间中的共享区域内。\n设置程序 计数 器 ( PC ) 。 e x e c v e 做的最后一件事情就是设 置当前进程上下 文中的程序计数器,使 之指向代码区域的入口点。\n下一次调度这个进程时 ,它 将从这个入口点 开始执行。L in u x 将根据需要换入代码和数据页面。\n运行时堆\n(通过 malloc 分配的 )\n}私有的,请求二进制零的\n未初始化的数据(.bss) }私有的,请求二进制零的\nI已初始化的数据(. da ca )\n代码(.t ex七)\n。\n}私有的,文件提供的\n图 9- 31 加载器是如何映射用户地址空间的区域的\n8. 4 使 用 mmap 函 数的 用 户 级内 存 映 射\nLinux 进程可以使用 mmap 函数来创建新的虚拟内存区域, 并将对象映射到这些区域中。\n#include \u0026lt;unistd.h\u0026gt;\n#include \u0026lt;sys/mman.h\u0026gt;\nvoid *mmap(void *start, size_t length, int prot, int flags, int fd, off_t offset);\n返回: 若 成 功 时 则 为指 向 映 射 区域 的 指 针 , 若 出错 则 为 MAP_FAILED( —1) 。\nmrna p 函 数要求内核创建一个新的虚拟内存区域, 最好是从地址 s t ar t 开始的一个区域,并将文件描述符 f d 指定的对象的一 个连续的片( ch un k ) 映射到这个新的区域。连续的对象片大小为 l e n g t h 字节, 从距文件开始处偏移量为 o f f s e t 字节的地方开始。s t a r t 地址仅仅是一个暗示, 通常被定 义为 NU LL 。为了我们的目的, 我们总是假设起始地址为NU LL。图 9-32 描述了这些参数的意义。\n} length (字节)\ns t ar t\n( 或\n定的地址)\n文件描述符f d 指定 进程虚拟内存的磁盘文件\n图 9-32 mmap 参 数的可视化解 释\n参数 p r o t 包含描述新映射的虚拟内存区域的访问权限位(即在相应区域结构中的 vrn_ p r o t 位)。\nPROT_EXEC: 这个区域内 的页面由可以被 CPU 执行的指令组成。 PROT_READ: 这个区域内的页面可读。\nPROT_WRITE: 这个区域内的页面可写。\nPROT_NONE: 这个区域内的页面不能被访问。\n参数 fl a g s 由描述被映射对象类型的位组成。如果设置了 MAP _A NON 标记位, 那么被映 射的对象就是一个匿名对象, 而相应的虚拟页面是请求二进制零的。MAP _PRI­ VAT E 表示被映射的对象是一个私有的、写时复制的对象, 而 MAP _SH ARED 表示是一个共享对象。例如\nbufp = Mmap(NULL, size, PROT_READ, MAP_PRIVATEIMAP_ANON, 0, O);\n让内 核创建一个新的包含 s i z e 字节的只读、私有、请求二进制零的虚拟内存区域。如果调用成功, 那么 b u f p 包含新区域的地址。\nmu n ma p 函数删除虚拟内存 的区域:\n#include \u0026lt;un i s t d . h\u0026gt;\n#include \u0026lt;sys/mman.h\u0026gt;\nint munmap(void *start, size_t length);\n返回: 若 成 功 则 为 o, 若 出错 则为 一1.\nmu n ma p 函数删除从虚拟地址 s t ar t 开始的, 由接下来 l e n g t h 字节组成的区域。接下 来对已删除区域的引用会导致段错误。\n沁§ 练习题 9. 5 编写 一个 C 程序 mma p c o p y . c , 使 用 mma p 将一个 任意大小 的磁 盘文件复制到 s t d o u t 。 输入 文件 的名 字必须作 为 一个命令行参数 来传 递。\n9 动态内存分配\n虽然可以使用低级的 mma p 和 mu nma p 函数来创建和删除虚拟内存的区域, 但是 C 程序员还是会觉得当运行时需 要额外虚拟内存时,用 动态内 存分配器( d ynam ic memory allo­ cator ) 更方便,也 有更好的可移植性。\n动态内存分配器维护着一个进程的虚拟内存区\n域, 称为堆 ( hea p ) ( 见图 9-33 ) 。系统之间细节不同, 但是不失通用性,假设堆是一个请求二进制零的区 域,它紧接在未初始化的数据区域后开始,并向上生 长(向更高的地址)。对于每个进程,内核维护着一个变量 br k( 读做 \u0026quot; break\u0026quot; ) , 它指向堆的顶部。\n分配器将堆视 为一组不 同大小的块( block ) 的集合来维护。每个块就是一 个连续的虚拟内存片( ch un k ) , 要么是已分配的,要么是空闲的。已分配的块显式地 保留为供应用 程序使用。空闲块可用来分配。空闲块保持空闲 , 直到它显式地被应用所分配。一个巳分配的块保持已分配状态,直到它被释放,这种释放要么 是应用程序显式执行的,要么是内存分配器自身隐式\n执行的。\n分配器有两种基本风格。两种风格都要求应用显 °\n式地分配块。它们的不同之处在千由哪个实体来负责释放已分配的块。\n用户栈\n共享库的内存映射区域\n堆\n未初始化的数据 ( .bs s )\n已初始化的数据 ( . da t a )\n代码(.七ext )\n图 9-33 堆\n仁 堆顶\n( br k 指针)\n显式分 配器 ( ex plicit allocator) , 要求应用显式地释放任何巳分配的块。例如, C 标准库提供一种叫 做 ma l l o c 程序包的显式分配器。C 程序通过调用 ma l l o c 函数来\n.分配一个块, 并通过调用 fr e e 函数来释放一个块。C++ 中的 ne w 和 d e l e t e 操作符与 C 中的 ma l l o c 和 fr e e 相当。\n隐式分 配器 ( im plicit allocator), 另一方面, 要求分配器检测一个巳 分配块何时不再被程序所使用 , 那么就释放这个块。隐式分配器也叫做垃圾收集 器( ga r bage collec­\ntor), 而自动 释放未使用的已分配的块的过程叫做垃圾收集 ( ga r ba ge collect io n ) 。例如, 诸如 L is p、M L 以及 Java 之类的高级语言就依赖垃圾收集来释放已分配的块。\n本节剩下的部分讨 论的是显式分配器的设计和实现。我们将在 9. 10 节中讨论隐式分配器。为了更具体,我 们的讨论集中于管理堆内存的分配器。然而,应 该明白内存分配是一个普遍的概念,可以出现在各种上下文中。例如,图形处理密集的应用程序就经常使用标准分配器来要求获得一大块虚拟内存,然后使用与应用相关的分配器来管理内存,在该块中创建和销毁图形的节点。\n9. 1 ma l l o c 和 fr e e 函 数\nC 标准库提供了一个称为 ma l l o c 程序包的显式分配器。程序通过调用 ma l l o c 函数来从堆中分配块。\n#include \u0026lt;stdlib.h\u0026gt;\nvoid *malloc(size_t size);\n返回: 若 成 功则 为 己分 配块的指针 , 若出错 则为 NULL。\nma l l o c 函数 返 回一个指针,指 向 大小 为至少 s i z e 字节的内存块,这 个块会为可能包含在这个块内的任何数据对象类型做对齐。实际中, 对 齐 依 赖 于 编 译 代码在 32 位模式(gee -m3 2) 还 是 64 位 模式(默认的)中运行。在 3 2 位模式中, ma l l o e 返 回 的 块的地址总是 8 的倍数。在 64 位模式中,该 地 址 总 是 1 6 的倍 数 。\n田 日 - 个字有多大\n回想一下在第 3 章中我们对机器代 码的讨论, In tel 将 4 宇节对 象称为双宇。 然而,在本节中,我 们会假设宇是 4 字 节的对象, 而 双 宇是 8 宇 节的对象, 这和传统术语是一致的。\n如 果 ma l l o e 遇到问题(例如,程 序要求的内存块比可用的虚拟内存还要大), 那么它就返回 N U LL , 并设 置 e rr n o。ma l l o e 不 初 始 化 它 返 回 的 内 存 。 那 些 想 要 已 初始化的动态内 存的 应用 程序可以使用 e a l l o e , e a l l o e 是一个基于 ma l l o e 的 瘦 包装函数, 它将分配的内存初始化为零。想要改变一个以前已分配块的大小, 可以使用r e a l l oe 函数。\n动态内存分配器,例 如 ma l l o e , 可 以 通过使用 mrna p 和 mu n ma p 函数 ,显 式 地分配和释放堆内存,或 者 还可以使用 s br k 函数 :\n#include \u0026lt;unistd.h\u0026gt;\nvoid *sbrk(intptr_t incr);\n返回: 若 成 功 则 为 旧 的 brk 指 针 , 若 出铸 则为一1.\ns br k 函数通过将内核的 b r k 指针增加 i ncr 来扩展和收缩堆。如果成功, 它 就 返回b r k 的旧值 ,否 则 ,它 就 返 回 —1 , 并将 er r n o 设置为 ENOMEM。如果 i n cr 为零, 那么s br k 就返回 b r k 的 当前值。用一个为负的 i n cr 来调用 s br k 是合法的, 而且很巧妙, 因为返回值( br k 的旧值)指向距新堆顶向上 a b s ( i n cr ) 字节处。\n程序是通过调用 f r e e 函数来释放已分配的堆块。\n#include \u0026lt;stdlib.h\u0026gt; void free(void *ptr);\n返回: 无.\np tr 参数必须指向一个从 ma l l o c 、 c a l l o c 或 者r e a l l o c 获 得 的 已分配块的起始位置 。如果 不是 ,那 么 fr e e 的 行 为就是未定义的。更糟的是,既 然 它 什 么 都 不 返回, f r ee 就 不 会 告 诉 应 用 出 现了错误。就像我们将在 9. 11 节里看到的,这 会 产 生 一 些 令 人 迷惑的运行时错误。\n图 9- 3 4 展示了一个 ma l l o c 和 f r e e 的 实 现 是 如 何 管 理 一 个 C 程 序 的 1 6 字的(非常)小\n的堆的。每个方框代表了一个 4 字节的字。粗线标出的矩形对应于已分配块(有阴影的)和空 闲 块(无阴影的)。初始时,堆 是 由 一 个 大 小 为 16 个字的、双字对齐的、空闲块组成的。\n(本节中, 我们假设分配器返回的块是 8 字节双字边界对齐的。)\n图 9- 3 4 a : 程序请求一个 4 字的块。ma l l o c 的响应是: 从空闲块的前部切出一个 4\n字的块,并返回一个指向这个块的第一字的指针。\n图 9- 34 b : 程序请求一个 5 字的 块。 ma l l o c 的响应是:从 空闲块的前部分配一个 6 字的块。在本例中, ma l l o c 在块里填充了一个额外的字,是为了 保待空闲块是双字边界对齐的。\n图 9- 3 4 c : 程序请求一个 6 字的块, 而ma l l o c 就从空闲块的前部切出一个 6 字的块。 图 9- 3 4 d : 程序释放在图 9- 3 4 b 中分配的那个 6 字的块。注意, 在调用 fr e e 返回之后 ,指针 p 2 仍然指向被释放了的块。应用有责任在它被一个新的 ma l l o c 调用重新初始化之前, 不再使用 p 2。\n图 9- 3 4 e : 程序请求一个 2 字的块。在这种情况中 , ma l l o c 分配在前一步中被释放了的块的一部分,并返回一个\n1\nI I I I I I I I I I I I I # a)pl = malloc(4*sizeof(int))\n1 2\nII I I I I I I I 牖 I I # b)p2 = malloc (5*sizeof (i n 七 ))\n1 2 3\nI I I I I I I I 蹋 I I # c)p3 = ma l l oc ( 6*s i zeo f (i n 七 ))\np1 p2\n+ +\nI I I I I I I # free (p2)\n1 p!,4 3\nI I I I I I I I I I I # e)p4 = ma ll oc ( 2* 江 zeof ( i nt ) )\n指向这个新块的指针。\n9. 2 为什么要使用动态内存分配\n程序使用动态内存分配的最重要的原因是经常直到程序实际运行时,才知道某些数\n图 9-34\n用 ma l l oc 和 fr e e 分配和释放块。每个\n方框对应于一个字。每个粗线标出的矩形对应于一个块。阴影部分是已分配的块。已分配的块的填充区域是深阴影的。无阴影部分是空闲块。堆地址是从左往右增加的\n据结构的大小。例如, 假设要求我们编写一个 C 程序,它 读一个 n 个 ASCII 码整数的链表,每一行一 个整数,从 s t d i n 到一个 C 数组。输入是由整数 n 和接下来要读和存储到数组中的 n 个整数组成的。最简单的方法就是静态地定义这个数组, 它的最大数组大小是硬编码的:\n#include \u0026ldquo;csapp.h\u0026rdquo;\n#define MAXN 15213\n3\n4 int array [MAXN] ;\n5\n6 int main()\n7 {\ns int i, n;\n9\n10 scanf(\u0026quot;%d\u0026quot;, \u0026amp;n);\nif (n \u0026gt; MAXN)\napp_error(\u0026ldquo;Input file toobig\u0026rdquo;);\nfor (i = O; i \u0026lt; n; i++)\nscanf (\u0026quot;%d\u0026quot;, \u0026amp;array [i]);\nexit(O); 16 }\n像这样用硬编码的 大小来分配数组通常不是一种好想法。MAXN 的值是任意的,与机器上可用的虚拟内存的实际数量没有关系。而且,如果这个程序的使用者想读取一个比 MAXN 大的文件, 唯一的办法就是用一个更大的 MAXN 值来重新编译这个程序。虽然对 于这个简单的示例来说这不成问题,但是硬编码数组界限的出现对于拥有百万行代码和大 量使用 者的大 型软件产品而言, 会变成一场维护的 噩梦。\n一种更好的方法是在运行时 ,在已 知了 n 的值之后, 动态地分 配这个数组 。使用这种方法,数组大小的最大值就只由可用的虚拟内存数量来限制了。\n1 #include \u0026ldquo;csapp.h\u0026rdquo;\n3 int main()\n5 int *array, i, n;\n7 scanf (\u0026quot;%d\u0026quot;, \u0026amp;n);\n8 array = (int *)Malloc(n * sizeof(int)); 9 for (i = 0; i \u0026lt; n; i ++)\ns can f ( \u0026quot; %d \u0026quot; , \u0026amp;array[i]); free(array);\nexit(O);\n13 }\n动态内存分配是一种有用而重要的编程技术。然而,为了正确而高效地使用分配器, 程序员需要对它们是如何工作的有所了解。我们将在 9. 11 节中讨论因为不 正确地使用分配器所导致的一些可怕的错误。\n9. 3 分配器的要求和目标\n显式分配器必须在一些相当严格的约束条件下工作:\n处理任意请求序列。一个应用可以有任意的分配请求和释放请求序列,只要满足约 束条件:每个释放请求必须对应于一个当前巳分配块,这个块是由一个以前的分配 请求获得的。因此,分配器不可以假设分配和释放请求的顺序。例如,分配器不能假设所有的分配请求都有相匹配的释放请求,或者有相匹配的分配和空闲请求是嵌 套的。\n立即响 应请求。分 配器必须立 即响应分配请求。因此, 不允许分配器为了提高性能重新排列或者缓冲请求。\n只使用堆 。为了使分 配器是可扩展的 ,分 配器使用的任何非标量数据结构都必须保存在堆里。\n对齐块(对齐要 求)。分配器必须对齐块 ,使 得它们可以保存任何类型的数据对象。\n不修 改已分 配的块。分 配器只能操作或者改变空闲块。特别是, 一旦块被分配了, 就不允许修改或者移动它了。因此, 诸如压缩已分配块这样的技术是不允许使用的。\n在这些限制条件下,分配器的编写者试图实现吞吐率最大化和内存使用率最大化,而 这两个性能目标通常是相互冲突的。\n目标 1 : 最大化吞吐 率。假定 n 个分配和释放请 求的某种序列:\nR 。,R1 , …,Rk\u0026rsquo; …,Rn - I\n我们希望一个分配器的吞吐率最大化,吞吐率定义为每个单位时间里完成的请求 数。例如, 如果一个分配器在 1 秒内完成 500 个分配请求和 500 个释放请求, 那么它的吞吐率就是每秒 1000 次操作。一般而言, 我们可以 通过使满足分配和释放请求的平均时间最小化来使吞吐率最大化。正如我们会看到的,开发一个具有合理性能的分配器并不困难,所谓合理性能是指一个分配请求的最糟运行时间与空闲块的数量成线性关系,而一个释放请求的运行时间是个常数。\n目标 2: 最大化内存利用率。天真的程序员经常不正确地假设虚拟内存是一个无限的资源。实际上,一个系统中被所有进程分配的虚拟内存的全部数量是受磁盘上交换空间的数量限制的。好的程序员知道虚拟内存是一个有限的空间,必须高效地使用。对于可能被要求分配和释放大块内存的动态内存分配器来说,尤其如此。\n有很多方式来描述一个分配器使用堆的效率如何。在我们的经验中,最有用的标准是峰值利用率 ( peak utiliza tion ) 。像以 前一样, 我们 给定 n 个分配和释放请求的某种顺序\nR。R,1, …,Rk\u0026rsquo; …,Rn- 1\n如果一个应用程序请求一个 p 字节的块, 那么得到的巳分配块的有效栽荷 ( payload ) 是 p 字节。在请 求凡 完成之后 , 聚集有 效栽荷 ( agg rega te pa yload ) 表示为p k\u0026rsquo; 为当前已 分配的块的有效载荷之和,而几表示堆的当前的(单调非递减的)大小。\n那么,前 k+ l 个请求的峰值利 用率 , 表示为 Uk , 可以通过下式得到:\n队= m a x;,;;k; P ;\nHk\n那么,分 配器的目标 就是在整个 序列中使峰值利用率 Un- 1 最大化。正如我们将要看到的, 在最大化吞吐率和最大化利用率之间是互相牵制的。特别是,以堆利用率为代价,很容易 编写出吞吐率最大化的分配器。分配器设计中一个有趣的挑战就是在两个目标之间找到一 个适当的平衡。\n调性假设\n我们可以通过让 Hk 成为前k + l 个请 求的 最高峰 ,从 而使 得在我们对 队 的定义中放宽单调非 递减的 假设, 并且允许 堆增 长和降低 。\n9. 9. 4 碎片\n造成堆利用率很 低的主要原因是一种称为碎 片 ( fr ag m ent a tio n ) 的现象, 当虽然有未使用的内存但不能用来满足分配请求时,就发生这种现象。有两种形式的碎片:内部碎片(internal fragmentation) 和外部碎片 ( ext e rn a l fr a gm e n ta t ion ) 。\n内部碎片是在一个已分配块比有效载荷大时发生的。很多原因都可能造成这个问题。 例如,一个分配器的实现可能对已分配块强加一个最小的大小值,而这个大小要比某个请 求的有效载荷大。或者, 就如我们在图 9-34b 中看到的,分 配器可能 增加块大小以满足对齐约束条件。\n内部碎片的量化是简单明了的。它就是已分配块大小和它们的有效载荷大小之差的 和。因此,在任意时刻,内部碎片的数最只取决于以前请求的模式和分配器的实现方式。\n外部碎片是当空闲内存合计起来足够满足一个分配请求,但是没有一个单独的空闲块 足够大 可以来处理这个请求时发生的。例如, 如果图 9-34e 中的请求要求 6 个字,而 不是\n2 个字, 那么如果不向内核请求额外的虚拟内存就无法满足这个请求, 即使在堆中仍然有\n6 个空闲的字。问题的产生是由于这 6 个字是分在两个 空闲块中的。\n外部碎片比内部碎片的扯化要困难得多,因为它不仅取决于以前请求的模式和分配器 的实现方式 , 还取决 于将来请求的模式 。例如, 假设在 K 个请求之后,所有 空闲块的大小都恰好是 4 个字。这个堆会有外部碎片吗? 答案取决于将来请求的模式。如果将来所有的分配请求都 要求小于或者等于 4 个字的块, 那么就不会有外部碎片。另一方面, 如果有一个或者多个请求要求比 4 个字大的块, 那么这个堆就会有外部碎片。\n因为外部碎片难以量化且不可能预测,所以分配器通常采用启发式策略来试图维持少量的大空闲块,而不是维持大量的小空闲块。\n9. 5 实现问题\n可以想 象出的最简单的分配器会把堆组织成一个大的字节数组, 还有一个指针 p , 初始指向这个数组的第一个字节。为了分配 s i ze 个字节, ma l l o c 将 p 的当前值保存在栈里, 将 p 增加 s 工ze , 并 将 p 的旧值返回到调用函数。fr e e 只是简单地返回到调用函数, 而不做其他任何事情。\n这个简单的分配器是设计中的一种极端情况。因为每个 ma l l o c 和 fr e e 只执行很少量的指令, 吞吐率会极好。然而, 因为分配器从不重复使用任何块,内 存利用率将极差。一个实际的 分配器要 在吞吐 率和利用率之间把握好平衡, 就必须考虑以下几个问题:\n空闲块组织:我们如何记录空闲块? 放置: 我们如何选择一个合适的空闲块来放置一个新分配的块?\n分割: 在将一个新分配的块放置到某个空 闲块之后, 我们如何处理这个空闲块中的剩余部分?\n合并: 我们如何处理一个刚刚被释放的块?\n本节剩下的部分将更详细地讨论这些问题。因为像放置、分割以及合并这样的基本技 术贯穿在许多不同的空闲块组织中,所以我们将在一种叫做隐式空闲链表的简单空闲块组 织结构中来介绍它们。\n9. 9. 6 隐式空闲链表\n任何实际的分配器都需要一些数据结构,允许它来区别块边界,以及区别巳分配块和 空闲块。大多数分配器将这些信息嵌入块本身。一个简单的方法如图 9-35 所示。\nrna l l oc 返回一个指针, 它指向有效载荷的开始处\n31 头部\n, # 3 2 I 0\n} a= I: 已分配的\na=O: 空闲的\n块大小包括头部、\n有效载荷和所有的填充\n图 9-35 一个简单的堆块的格式\n在这种情况中,一个块是由一个字的头部、有效载荷,以及可能的一些额外的埃充组 成的。头部编码了这个块的大小(包括头部和所有的填充),以及这个块是已分配的还是空\n闲的。如果我们强加一个双字的对齐约束条件 , 那么块大小就总是 8 的倍数, 且块大小的最低 3 位总是零。因此,我 们只需要内存 大小的 29 个高位, 释放剩余的 3 位来编码其他信息。在这种情况中,我们用其中的最低位(已分配位)来指明这个块是已分配的还是空闲的。例如 , 假设我们有一个已分 配的块 ,大小 为 24 ( 0x1 8) 字节。那么它的头部将是\nOx00000018 I Ox1 = Ox00000019 类似地, 一个块大小为 40 ( 0x 28) 字节的空闲块有如下的 头部:\nOx00000028 I OxO = Ox00000028 头部后 面就是应用调用 ma l l o c 时请求的有效载荷。有效载荷后面是一 片不使用的填充块,其大小可以是任意的。需要填充有很多原因。比如,填充可能是分配器策略的一部分,用来对付外部碎片。或者也需要用它来满足对齐要求。\n假设块的格式如图 9-35 所示,我 们可以将堆组织为一 个连续的已分配块和空闲块的序列, 如图 9-36 所示。\n目口勹16\u0026quot; I I 圈立。I I I I I\nTi•11I I I - 圃 扂的\n' , , '\u0026rsquo; : ,\u0026rsquo; '\u0026rsquo; ,\u0026rsquo;\n\u0026rsquo;\u0026rsquo; \u0026rsquo;\u0026rsquo; '\u0026rsquo;\n\u0026rsquo;\u0026rsquo; '\u0026rsquo;\n\u0026rsquo;\u0026rsquo; :\u0026rsquo;\n图 9-36 用隐式空闲链表来组织堆。阴影部分是已分配块。没有阴影的部分是空闲块 。头部标记为(大小(字节/)已分配位)\n我们称这种结构为隐式空闲链表,是因为空闲块是通过头部中的大小字段隐含地连接着 的。分配器可以通过遍历堆中所有的块,从而间接地遍历整个空闲块的集合。注意,我们需要 某种特殊标记的结束块, 在这个示例中, 就是一个设置了已分配位而大小为零的终止头部 ( ter­ minating header) 。(就像我们将在9. 9. 12 节中看到的,设 置已分配位简化了空闲块的合并。)\n隐式空闲链表的优点是简单。显著的缺点是任何操作的开销 , 例如放置分配的块, 要求对空闲链表进行搜索,该搜索所需时间与堆中已分配块和空闲块的总数呈线性关系。\n很重要的一点就是意识到系统对齐要求和分配器对块格式的选择会对分配器上的最小 块大小有强制的要 求。没有巳分配块或者空闲块可以比这个最小值还小。例如, 如果我们假设一个双字的 对齐要求, 那么每个块的大小都必须是双字( 8 字节)的倍数。因此,图 9-\n35 中的块格式就 导致最小的块大小为两个字: 一个字作头, 另一个字维持对齐要求。即使应用只请求一字节,分配器也仍然需要创建一个两字的块。\n练习题 9. 6 确定 下面 ma l l o c 请求序列 产 生的 块大小和头部 值。 假设: 1 ) 分配器 保持双 字对齐,并 且使用块格 式如图 9-35 中所 示的 隐 式 空闲 链表。 2) 块大小向 上舍入为最接近的 8 字节的倍 数。\n9. 9. 7 放置已分配的块\n当一个应用请求一个 K 字节的块时, 分配器搜索空 闲链表, 查找一个足够大可以放置\n所请求块的空闲块。分配器执行 这种搜索的方式是由放置策略 ( placem e n t policy ) 确定的。一些常见的策略是首次适配( fi rs t fit ) 、下一 次适 配( next fi t ) 和最佳适配( bes t fi t ) 。\n首次适配从头开始搜索空闲链表,选择第一个合适的空闲块。下一次适配和首次适配 很相似,只不过不是从链表的起始处开始每次搜索,而是从上一次查询结束的地方开始。最佳适配检查每个空闲块,选择适合所需请求大小的最小空闲块。\n首次适配的优点是它趋向于将大的空闲块保留在链表的后面。缺点是它趋向千在靠近 链表起始处留下小空闲块的"碎片“,这就增加了对较大块的搜索时间。下一次适配是由Donald Knut h 作为首次适配的一种代替品最早提出的, 源千这样一个想法: 如果我们上一次在某个空闲块里已经发现了一个匹配,那么很可能下一次我们也能在这个剩余块中发 现匹配。下一次适配比首次适配运行起来明显要快一些,尤其是当链表的前面布满了许多 小的碎片时。然而, 一些研究表明,下 一次适配的内存 利用率要比首次适配低得多 。研究还表明最佳适配比首次适配和下一次适配的内存利用率都要高一些。然而,在简单空闲链 表组织结构中,比如隐式空闲链表中,使用最佳适配的缺点是它要求对堆进行彻底的搜 索。在后面,我们将看到更加精细复杂的分离式空闲链表组织,它接近于最佳适配策略, 不需要进行彻底的堆搜索。\n9. 9. 8 分割空闲块\n一旦分配器找到一个匹配的空闲块,它就必须做另一个策略决定,那就是分配这个空 闲块中多少空间。一个选择是用整个空闲块。虽然这种方式简单而快捷,但是主要的缺点 就是它会造成内部碎片。如果放置策略趋向于产生好的匹配,那么额外的内部碎片也是可 以接受的。\n然而, 如果匹配不太好, 那么分配器通常会选择 将这个空闲块分割为两部分。第一部分变成分配块, 而剩下的变成一个新的 空闲块。图 9-37 展示了分配器如何分割图 9-36 中\n8 个字的空闲块 , 来满足一个应用的对堆内存 3 个字的请求。\n!双字\ni对齐的\n图 9-37 分割一 个空闲块 , 以满足一个 3 个字的分配请 求。阴影 部分是已分配块。没有阴影的部分是空闲 块。头部标 记为(大小(字节/)已分配位)\n9. 9. 9 获取额外的堆内存\n如果分配器不能为请求块找到合适的空闲块将发生什么呢?一个选择是通过合并那些在内存中物理上相邻的空闲块来创建一些更大的空闲块(在下一节中描述)。然而,如果这样还 是不能生成一个足够大的块,或者如果空闲块巳经最大程度地合并了,那么分配器就会通过调用 s br k 函数,向内 核请求额外的堆内存。分配器将额外的内存转化成一个大的空闲块, 将这个块插入到空闲链表中,然后将被请求的块放置在这个新的空闲块中。\n9. 9. 10 合并空闲块\n当分配器释放一个已分配块时 , 可能有其他空闲块与这个新释放的空闲块相邻。这些邻接的空闲块可能引起一种现象,叫 做 假碎片 ( fa ult fragmentation), 就是有许多可用的\n空闲块被切割成 为小的、无法使用的空闲块。比如,图 9- 38 展示了释放图 9-37 中分配的块后得到的结果 。结果是两个相邻的空闲块 , 每一个的有效载荷都为 3 个字。因此, 接下来一个对 4 字有效载荷的 请求就会失败, 即使两个空闲块的合计大小足够大, 可以满足这个请求。\n图 9-38 假碎片的示例。阴影部分是已分配块。没有阴影的部分是空闲块。头部标记为(大小(字节)/已分配位)\n为了解决假碎片问题,任何实际的分配器都必须合并相邻的空闲块,这个过程称为合并( coalescing ) 。这就 出现了一个重要的策略决定, 那就是何时执行合并。分配器可以选择立即合并 ( im mediat e coalescing), 也就是在每次一个块被释放时,就合并所有的相邻块。或者它也可以选 择推迟合并( defe r red coalescing) , 也就是等到某个稍晚的时候再合并空闲块。例如,分配器可以推迟合并,直到某个分配请求失败,然后扫描整个堆,合并所有的空闲块。\n立即合并很简单明了,可以在常数时间内执行完成,但是对于某些请求模式,这种方 式会产生一 种形式的抖动, 块会反复地合并, 然后马上分割。例如,在图 9-38 中, 反复地分配和释放一 个 3 个字的块将产生 大最不必要的分 割和合并。在对分配器的讨论中,我们会假设使用立即合并,但是你应该了解,快速的分配器通常会选择某种形式的推迟 合并。\n9. 11 带边界标记的合井\n分配器是如何实现合并的?让我们称想要释放的块为当前块。那么,合并(内存中的) 下一个空闲块很简单而且高效。当前块的头部指向下一个块的头部,可以检查这个指针以判断下一个块是否是空闲的。如果是,就将它的大小简单地加到当前块头部的大小上,这两个块在常数时间内 被合并。\n但是我们该如何合并前面的块呢?给定一个带头部的隐式空闲链表,唯一的选择将是 搜索整个链 表, 记住前面块的位置,直到 我们到 达当前块。使用隐式空闲链表,这意 味着每次调用 fr e e 需要的时间都与堆的大小成线性关系。即使使用更复杂精细的空闲链表组\n织, 搜索时间也 不会是常数。\nKnuth 提出了一种聪明而通用的技术, 叫做 31\n3 2 I 0\na= 001: 已分配的\n边界标记 ( boundary tag), 允许在常数时间内进行对前面块的合并。 这种思想,如图 9-39 所示, 是在每个块的 结尾处添加一个脚部( footer , 边界标记), 其中脚部就是头部的一个副本。如果每个块包括这样一个脚部,那么分配器就可以通过检查它的脚部,判断前面一个块的起始位置和状态, 这个脚部总是在距当前块开始位置一个字的距离。考虑当分配器释放当前块时所有可能存在的\n情况:\n头部 a= 000: 空闲的\n脚部\n图 9-39 使用边界标记的堆块的格式\n前面的块和后面的块都是已分配的。\n) 前面的块是已分配的, 后面的块是空闲的。\n) 前面的块是空闲的 , 而后面的块是已分配的。\n) 前面的和后面的块都是空闲的。\n图 9-40 展示了我们如何对这四种情况进行合并 。\n, ,\n情况 l\n情况2\nn+ m1+ m2 J f\n, .\n情况3 情况 4\nn+ m1+ m2 j f\n图 9-40 使用边界标记的 合并(情况 1 : 前面的 和后面块都已分配。情 况 2 : 前面块巳分配,后面块空闲。情况 3: 前面块空闲 , 后面块已分配 。情况 4 : 后面块和前面块都空闲)\n在情况 1 中, 两个邻接的块都是已分配的, 因此不可能进行合并。所以当前块的状态 只 是简单地从已分配变成空闲 。在情况 2 中,当 前块与后面的块合并。用当前块和后面块 的 大小的和来更新当前块的头部和后面块的脚部。在情况 3 中, 前面的块和当前块合并。用 两个块大小的和来更新前面块的头部和当前块的脚部。在情况 4 中, 要合并所有的三个块形成一个单独的空闲块,用三个块大小的和来更新前面块的头部和后面块的脚部。在每 种情况中, 合并都是在常数时间内 完成的。\n边界标记的概念是简单优雅的,它对许多不同类型的分配器和空闲链表组织都是通用 的。然而,它也 存在一个潜在的缺陷。它要求每个块都保 持一个头部和一个脚部,在应用程序操作许多个小块时,会产生显著的内存开销。例如,如果一个图形应用通过反复调用 ma l l o c 和 fr e e 来动态地创建 和销毁图形节点 ,并且每个图 形节点都只要求两个内存字, 那么头部和脚部将占用每个已 分配块的一半 的空间。\n幸运的是,有一种非常聪明的边界标记的优化方法,能够使得在已分配块中不再需要 脚部。回想一下,当我们试图 在内存中合并当前块以及前面的块和后面的块时, 只有在前面的块是空闲时,才会需 要用到它的脚部。如果我们把前 面块的巳分配/空闲位存放在当前 块中多出来的低位中, 那么巳分配的块就不需要脚部了, 这样我们就可以将这个多出来的空间用作有效载荷了。不过请注意,空闲块仍然需要脚部。\n练习题 9. 7 确定下面每种对齐要求和块格式的组合的最小的块大小。假设:隐式空 闲链表 , 不允 许有 效载荷 为零 , 头部和 脚部存 放在 4 字节的 字中。\n对齐要求 已分配的块 空闲块 最小块大小(字节) 单字 头部和脚部 头部和脚部 单字 头部,但是无脚部 头部和脚部 双字 头部和脚部 头部和脚部 双字 头部,但是没有脚部 头部和脚部 9. 12 综合:实现一个简单的分配器\n构造一个分配器是一 件富有挑战性的任务。设计空间很大, 有多种块格式 、空闲链表格式,以及放置、分割和合并策略可供选择。另一个挑战就是你经常被迫在类型系统的安全和熟悉的限定之外编程,依赖于容易出错的指针强制类型转换和指针运算,这些操作都属千典型的低层系统编程。\n虽然分配器不需要大量的代码,但是它们也还是细微而不可忽视的。熟悉诸如\nC+ + 或者 Java 之类高级语言的学生通常在他们第一次遇到这种类型的编程时, 会遭遇一个概念上的障碍。为了帮助你清除这个障碍,我们将基于隐式空闲链表,使用立即边 界标记合并方式,从 头至尾地讲述一个简单分配器的实现。最大的块大小为 232 = 4GB 。代码是 64 位干净的, 即代码能不加修改地运行 在 3 2 位( ge e - m3 2 ) 或 6 4 位( g e e - m6 4) 的\n进程中。\n通用分配器设计\n我们的分 配器使用 如图 9-41 所示的 me ml i b . e 包所提供的一个内存系统模型。模型的目的在于允许我们在不干涉已 存在的系统层 ma l l o e 包的情况下, 运行 分配器。\nme m_ i n i t 函数将对千堆来 说可用 的虚拟内存模型化为一个大的、双字对齐的字节数组。在 me m_ he a p 和 me m_ br k 之间的字节表示已分配的虚拟内存。me m_ br k 之后的字节表示未 分配的虚拟内存。分配器通过调用 me m_ s br k 函数来请求额外的堆内存, 这个函数和系统的 s br k 函数的接口相同,而 且语义也相同, 除了它会拒绝收缩堆的请求。\n.分配器包含在 一个源文件中( mm. e ) , 用户可以编译和链接这个源文件到他们的应用之中。分配器输出三个函数到应用程序:\nextern int mm_init(void);\n2 extern void *mm_malloc (size_t size);\n3 extern void mm_free (void *ptr);\nmm i n i t 函数初始化分配器, 如果成功就返回 o, 否则就返回—1 o mm_ma l l o c 和 mm_ f r e e 函数与它们对应的系统函数有相同的接口和语义。分配器使用如图 9-39 所示的块格式。最小块的大小为 16 字节。空闲链表组织成为一个隐式空闲链表,具 有如图 9-42 所示的恒定形式。\n第一个字是一个双字边界对齐的不使用的填充字。填充后面紧跟着一个特殊的序言块(prologue block) , 这是一个 8 字节的已 分配块,只 由 一个头部和一个脚部组成。序言块是在初始化时创建的, 并且永不释放。在序言块后紧跟的是零个或者多个由 ma l l o c 或者fr e e 调用创建的普通块。堆总是以一个特殊的结尾 块( epilog ue block ) 来结束, 这个块是一个大小为零的已分配块,只由一个头部组成。序言块和结尾块是一种消除合并时边界条件的技巧。分配器使用一个 单独的私有( s t a t i c ) 全局变量( he a p _ 让 s t p ) , 它总是指向序言块。(作为一个小优化,我们可以让它指向下一个块,而不是这个序言块。)\ncode/vmlma llod memlib.c\nI* Private global variables *I\nstatic char *mem_heap; I* Points to first byte of heap *I\nstatic char *mem_brk; I* Points to last byte of heap plus 1 *I\nstatic char *mem_max_addr; I* Max legal heap addr plus 1*/\n5\nI*\n* mem_init - Initialize the memory system model\ns *I\n9 void mem_init (void) 10 {\nmem_heap = (char *)Malloc(MAX_HEAP);\nmem_brk = (char *)mem_heap;\nmem_max_addr = (char *)(mem_heap + MAX_HEAP); 14 }\n15\nI*\n* mem_sbrk - Simple model of the sbrk function. Extends the heap\n* by incr bytes and returns the start address of the new area. In\n* this model, the heap cannot be shrunk.\n*I\nvoid *mem_sbrk(int incr) 22 {\n23 cha 工 *ol d_br k = mem_brk;\n24\nif ((incr \u0026lt; 0) 11 ((mem_brk + incr) \u0026gt; mem_max_addr)) {\nerrno = ENOMEM;\nfprintf(stderr, \u0026ldquo;ERROR: mem_sbrk failed. Ran out of memory \u0026hellip; \\n\u0026rdquo;);\nreturn (void *)-1; 29 }\nmem_brk += incr;\nreturn (void *)old_brk; 32 }\ncode/vmlmal/odmemlib.c\n图 9-41 memlib.c: 内存系统模型\n序言块 普通块 l\n普通块2 普通块n 结尾块 hdr\n!双字\n!对齐的\nstatic char *heap_listp\n图9- 42 隐式空闲链表的恒定形式\n2 操作空闲链表的基本常数 和宏\n图 9-43 展示了一些我们在分配器编码中将要 使用的基本常数和宏。第 2 4 行定义了一些基本的大小常数: 字的大小( WSIZE ) 和双字的 大小( DSIZE ) , 初始空闲块的大小和扩展堆时的默认大小CCH UNKSIZE) 。\n在空闲链表中操作头部和脚部可能是很麻烦的,因为它要求大量使用强制类型转换和指针 运算。因此, 我们发现定义一小组宏来访问和遍历空闲链表是很有帮助的(第9 25 行)。PACK\n宏(第9 行)将大小和已分配位结合起来并返回一个值,可以 把它存放在头部或者脚部中。\ncodelvmlmallodmm.c\nI* Basic constants and macros *I\n#define WSIZE 4 I* Word and header/footer size (bytes) *I\n#define DSIZE 8 I* Double word size (bytes) *I\n4 #define CHUNKSIZE (1«12) I* Extend heap by this amount (bytes) *I\n5\n6 #define MAX(x, y) ((x) \u0026gt; (y)? (x) : (y))\n7\nB I* Pack a size and allocated bit into a word *I\n9 #define PACK(size, alloc) ((size) I (alloc))\n10\n11 I* Read and write a word at address p *I\n12 #define GET(p) (*(unsigned int *)(p))\n13 #define PUT(p, val) (*(unsigned int *) (p) = (val))\n14\nI* Read the size and allocated fields from address p *I\n#define GET_SIZE(p) (GET(p) \u0026amp; -Ox7)\n#define GET_ALLOC(p) (GET(p) \u0026amp; Ox1)\n18\nI* Given block ptr bp, compute address of its header and footer *I\n#define HDRP(bp) ((char *) (bp) - WSIZE)\n#define FTRP(bp) ((char *)(bp) + GET_SIZE(HDRP(bp)) - DSIZE)\n22\n23 I* Given block ptr bp, compute address of next and previous blocks *I\n24 #define NEXT_BLKP(bp) ((char *) (bp) + GET_SIZE(((char *) (bp) - WSIZE)))\n25 #define PREV_BLKP(bp) ((char *)(bp) - GET_SIZE(((char *) (bp) - DSIZE)))\ncode/vm/ma llod mm.c\n图 9- 43 操作空闲链表的基本常数和宏\nGE T 宏(第1 2 行)读取和返回参数 p 引用的字。这里强制类型转换是至关重要的。参数 p 典型地是一个( v i o d * ) 指针, 不可以直接进行间接引用。类似地, P U T 宏(第1 3 行) 将 v a l 存放在参数 p 指向的字中。\nG ET _S IZE 和 G E T _ A L L O C 宏(第1 6~ 17 行)从地址 p 处的头部或者脚部分别返回大小和巳分配位。剩下的 宏是对块指针( blo ck pointer, 用bp 表示)的操作, 块指针指向第 一个有效载荷字节。给定一个块指针 b p , H DR P 和 F T R P 宏(第20 ~ 21 行)分别返回指向这个块的头部 和脚部的指针。N E XT _BL K P 和 P R E V _ BL K P 宏(第24 ~ 25 行)分别返回指向后面的块和前面的块的块指针。\n可以用多种方式来编辑宏,以 操作空闲链表。比如, 给定一个指向当前块的指针 b p ,\n我们可以使用下面的代码行来确定内存中后面的块的大小:\nsize_t size= GET_SIZE(HDRP(NEXT_BLKP(bp)));\n3. 创建初始空闲链表\n在调用 mm_ ma l l o c 或者 mm_ fr e e 之前, 应用必须通过调用 mm_ i n i t 函数来初始化堆\n(见图9- 44 ) 。\nmm_ i n i t 函数从内存系统得到 4 个字, 并将它们初始化 , 创建一个空的空闲链表(第4\n~ 10 行)。然后它调用 e x 七e n d _ h e a p 函数(图9- 45 ) , 这个函数将堆扩展 C H U N KSIZE 字\n节, 并且创建初始的空闲块。此刻,分 配器已初始化 了,并且准备好接受来自应用的分配和释放请求。\ncode/vm/mallodmm.c\nint rnm_init(void)\n{\nI* Create the initial empty heap *I\nif ((heap_listp = mem_sbrk(4*WSIZE)) == (void *)-1) r et urn 一 1 ;\nPUT(heap_listp, O);\nPUT(heap_listp + O*WSIZE), PACK(DSIZE, 1)); PUT(heap_listp + (2*WSIZE), PACK(DSIZE, 1));\nPUT(heap_listp + (3*WSIZE), PACK(O, 1)); heap_listp += (2*WSIZE);\nI* Alignment padding *I I* Prologue header *I I* Prologue footer *I I* Epilogue header *I\nI* Extend the empty heap with a free block of CHUNKSIZE bytes *I\nif (extend_heap(CHUNKSIZE/WSIZE) == NULL) return -1;\nreturn O;\n图 9-44 mm_ini t: 创建带一个初始空闲块的堆\n1 static void *extend_heap(size_t words)\n2 {\n3 char *bp; size_t size;\ncode/ vmlmallod mm.c codelvm/ma llod mm.c\n6 I* Allocate an even number of words to maintain alignment *I\n7 size= (words% 2)? (words+!) * WSI ZE : words* WSIZE;\n8 if ((long) (bp = mem_sbrk(size)) == -1)\n9 return NULL;\n10\nI* Initialize free block header/footer and the epilogue header *I\nPUT(HDRP(bp), PACK(size, 0)); I* Free block header *I\nPUT(FTRP(bp), PACK(size, O)); I* Free block footer *I\nPUT(HDRP(NEXT_BLKP(bp)), PACK(O, 1)); I* New epilogue header*/\n15\nI* Coalesce if the previous block was free *I\nreturn coalesce(bp);\n18 }\ncodelvm/mallodmm.c\n图 9-45 ext e nd_heap : 用一个新的空闲块扩展堆\ne x 七e n d_ h e a p 函数会在两种不 同的环境中被调用: 1 ) 当堆被初始化 时; 2 ) 当 mm—ma l ­ l a c 不 能找到一个合适的匹配块时。为了保持对齐 , e x 七e n d _ h e a p 将请求大小向上舍入为最接近的 2 字( 8 字节)的倍数, 然后向内存系统请求额外的堆空间(第7 9 行)。\ne x t e n d _ h e a p 函数的剩余部分(第12 17 行)有点儿微妙。堆开始于一个双字对齐的边界,并 且每次对 e x t e nd _ he a p 的调用都返回一个块,该 块的大小是双字的整数倍。因此, 对 me m_ s br k 的 每次调用 都返回一个双字对齐的内存片,紧 跟在结尾块的头部后面。这个头部变成了新的空闲块的 头部(第12 行),并且这个片的最后一个字变成了新的结尾\n块的头部(第14 行)。最后, 在很可能出现的 前一个堆以一个空闲块结束的情况中, 我们调用 c o a l e s c e 函数来合并两个空闲 块, 并返回指向合并后的块的块指针(第1 7 行)。\n4 释放和合并块\n应用通过调用 mm_ fr e e 函数(图9-46) , 来释放一个以前分配的块,这个函数释放所请求的块( b p) , 然后使用 9. 9. 11 节中描述的边界标记合并技术将之与邻接的空闲块合并起来。\ncode/vm/mallodmm.c\n1 void mm_free(void *bp)\n2 {\n3 size_t size= GET_SIZE(HDRP(bp));\n4\n5 PUT(HDRP(bp), PACK(size, O));\n6 PUT(FTRP(bp), PACK(size, 0));\n7 coalesce(bp);\n8 }\n9\n10 static void *coalesce(void *bp)\n11 {\n12 size_t prev_alloc = GET_ALLOC(FTRP(PREV_BLKP(bp)));\n13 size_t next_alloc = GET_ALLOC(HDRP(NEXT_BLKP(bp)));\n14 size_t size= GET_SIZE(HDRP(bp));\n15\nif (prev_alloc \u0026amp;\u0026amp; next_alloc) { I* Case 1 *I\nreturn bp;\n18 }\n19\nelse if (prev_alloc \u0026amp;\u0026amp; !next_alloc) { I* Case 2 *I\nsize += GET_SIZE (HDRP (NEXT_BLKP(bp))) ;\n22 PUT(HDRP(bp), PACK(size, O));\n23 PUT(FTRP(bp), PACK(size,O));\n24 }\n25\nelse if (!prev_alloc \u0026amp;\u0026amp; next_alloc) { I* Case 3 *I\nsize += GET_SIZE(HDRP(PREV_BLKP(bp)));\nPUT(FTRP(bp), PACK(size, 0));\nPUT(HDRP(PREV_BLKP(bp)), PACK(size, 0));\nbp = PREV_BLKP(bp);\n31 }\n32\nelse { I* Case 4 *I\nsize+= GET_SIZE(HDRP(PREV_BLKP(bp))) +\nGET_SIZE(FTRP(NEXT_BLKP(bp)));\nPUT(HDRP(PREV_BLKP(bp)), PACK(size, 0));\nPUT(FTR 王 )(NEXT_BLKP(bp)), PACK(size, O));\nbp = PREV_BLKP(bp);\n39 }\n40 return bp;\n41 }\ncode/vm/mallodmm.c\n图 9-46 mm_free: 释放一个块,并使用边界标记合并将之与所有的邻接空闲块在常数时间内合并\nc o a l e s c e 函数中的代码是图 9-40 中勾画的四种情况的一种简单直接的实现。这里也有一个微妙的方面。我们选择的空闲链表格式(它的序言块和结尾块总是标记为已分配)允 许我们忽略潜在的麻烦边界情况,也 就是,请 求块 b p 在堆的起始处 或者是在堆的结尾处。如果没有这些特殊块,代码将混乱得多,更加容易出错,并且更慢,因为我们将不得不在 每次释放请求时,都去检查这些并不常见的边界情况。\n5. 分配块\n一个应用通过调用 mm_ma l l o c 函数(见图9-47 ) 来向内存请求大小为 s i z e 字节的块。在检查完请求的真假之后,分配器必须调整请求块的大小,从而为头部和脚部留有空间, 并满足双字对齐的要求 。第 12 13 行强制了最小块大小是 16 字节: 8 字节用来满足对齐要求, 而另外 8 个用来放头部和脚部。对于超过 8 字节的请求(第15 行), 一般的规则是加上开销字节 ,然后向 上舍入到最接近的 8 的整数倍 。\ncode/vmlmallodmm.c\nvoid *mm_malloc(size_t size)\n3 size_t asize; I* Adjusted block size *I 4 size_t extendsize; I* Amount to extend heap if no fit *I 5 char *bp; 7 I* Ignore spurious requests *I 8 if (size == 0) return NULL; 10 11 I* Adjust block size to include overhead and alignment reqs. *I 12 if (size \u0026lt;= DSIZE) 13 asize = 2*DSIZE; 14 else 15 asize = DSIZE * ((size + (DSIZE) + (DSIZE-1)) / DSIZE); 16 17 I* Se a 工 ch the free list for a fit *I 18 if ((bp = find_fit (asize)) != NULL) { 19 place(bp, asize); 20 return bp; 21 22 23 I* No fit found. Get more memory and place the block *I 24 extendsize = MAX(asize,CHUNKSIZE); 25 if ((bp = extend_heap(extendsize/WSIZE)) == NULL) 26 return NULL; 27 place(bp, asize); 28 return bp; 29 } code/v mlma llod mm.c 图 9- 47 mm_malloc: 从空闲链表分配一个块 一旦分配器调整了请求的 大小, 它就会搜索空闲链表, 寻找一个合适的空闲块(第18 行)。如果有合适的, 那么分配器就放置这个请求块,并 可选地分割出多余的部分(第19 行),然后返回新分配块的地址。\n如果分配器不能够发现一个匹配的块, 那么就用一个新的空闲块来扩展堆(第24 26 行), 把请求块放置在这个新的空 闲块里, 可选地分割这个块(第27 行), 然后返回一个指针, 指向这个新分配的块。\n练习题 9. 8 为 9. 9. 1 2 节 中描 述的 简单分 配器 实现 一个 f i n d _ 丘 t 函数。\nstatic void *f i nd_fi t (s i ze_t a s i z e )\n你的解答应该对隐式空闲链表执行首次适配搜索。\n练习题 9. 9 为 示例 的分 配器编 写 一个 p l a c e 函数。\ns t a t i c v o i d p l a ce ( v o i d *bp, si ze_t as i ze)\n你的解答应该将请求块放置在空闲块的起始位置,只有当剩余部分的大小等于或 者超 出最小块的 大小 时, 才进行 分割。\n9. 13 显式空闲链表\n隐式空闲链表 为我们提供了一种介绍一些 基本分配器概念的简单方法。然而, 因为块分配与堆块的总数呈线性关系,所以对于通用的分配器,隐式空闲链表是不适合的(尽管对于堆块数最预先就知道是很小的特殊的分配器来说它是可以的)。\n一种更好的方法是将空闲块组织为某种形式的显式数据结构。因为根据定义, 程序不需要一个空闲块的主体,所 以实现这个数据结构的指针可以存放在这些空闲块的主体里面。例如 , 堆可以组织 成一个双向空闲链表, 在每个空闲块中, 都包含一个 pr e d ( 前驱) 和 s uccC后继)指针, 如图 9-48 所示。\n31 3 2 I 0 31 3 2 1 0\n头部 头部\n原来的有效载荷\n脚部\na ) 分配块\n脚部\nb ) 空闲块\n图 9-48 使用双向空闲链表的堆块的格式\n使用双向链表而不是隐式空闲链表, 使首次适配的分配时间从块总数的线性时间减少到了空闲块数 量的线性时间。不过, 释放一个块的时间可以是线性的,也 可能是个常数, 这取决于我们所选择的空闲链表中块的排序策略。\n一种方法是用后进先出( LIFO) 的顺序维护链表, 将新释放的块放置在链 表的开始处。使用 LIFO 的顺序和首次适配的放 置策略, 分配器会最先检查最近使用 过的块。在这种情况下, 释放一个块可以在常数时间内完成。如果使用了边界标 记, 那么合并也可以 在常数 时间内完成。\n另一种方法是按照地址顺序来维护链表, 其中链表中每个块的地址都小于它后继的地址。在这种情况下, 释放一个块需要线性时间的搜索来定位合适的前驱。平衡点在于, 按\n照地址排序的首次适配比 LIFO 排序的首次适 配有更高的内存利用率, 接近最佳适配的利用率。\n一般而言,显式链表的缺点是空闲块必须足够大,以包含所有需要的指针,以及头部和可能的脚部。这就导致了更大的最小块大小,也潜在地提高了内部碎片的程度。\n9. 9. 14 分离的空闲链表\n就像我们已经看到的,一个使用单向空闲块链表的分配器需要与空闲块数量呈线性关 系的时间 来分配块。一 种流行的减少分配时间的方法, 通常称为分 离 存储 ( s eg regated\nstorage), 就是维护多个空闲链表,其中每个链表中的块有大致相等的大小。一般的思路是将所有可能的块大小分成一些等价类,也叫 做 大小类 ( size clas s ) 。有很多种方式来定义大小类。例如, 我们可以根据 2 的幕来划分块大小 :\n{1},{2},{3,4},{5 ~ 8},..·,{1025 ~ 2048},{2049 ~ 4096},{4097 ~ =}\n或者我们可以将小的块分派到它们自己的大小类里, 而将大块按照 2 的幕分类:\n{1},{2},{3}, \u0026hellip; , {1023}, {1024}, {1025 ~ 2048},{2049 ~ 4096 } , {4097 ~ =}\n分配器维护着一个空闲链表数组,每个大小类一个空闲链表,按照大小的升序排列。 当分配器需要一个大小为 n 的块时, 它就搜索相应的空闲链表。如果不能找到合适的块与之匹配,它就搜索下一个链表,以此类推。\n有关动态内存分配的文献描述了几十种分离存储方法,主要的区别在千它们如何定义 大小类,何时进行合并,何时向操作系统请求额外的堆内存,是否允许分割,等等。为了 使你大致了解有哪些可能性 ,我 们会描述两种基本的方 法: 简单分 离存储( sim ple segrega­ ted s to ra ge ) 和分 离适配( segrega t ed fit ) 。\n1 简单分离存储\n使用简单分离存储,每个大小类的空闲链表包含大小相等的块,每个块的大小就是这 个大小类中最大元索的大小。例如, 如果某个大小类定 义为{1 7 ~ 32} , 那么这个类的空闲\n链表全由大小为 32 的块组成。\n为了分配一个给定大小的块,我们检查相应的空闲链表。如果链表非空,我们简单地 分配其中第一块的全部。空闲块是不会分割以满足分配请求的。如果链表为空,分配器就 向操作系统请求一个固定大小的额外内存片(通常是页大小的整数倍),将这个片分成大小相等的块,并将这些块链接起来形成新的空闲链表。要释放一个块,分配器只要简单地将 这个块插入到相应的空闲链表的前部。\n这种简单的方法有许多优点。分配和释放块都是很快的常数时间操作。而且,每个片 中都是大小相等的块,不分割,不合并,这意味着每个块只有很少的内存开销。由于每个 片只有大小相同的块,那么一个已分配块的大小就可以从它的地址中推断出来。因为没有 合并,所以 已分配块的头部就不需要一个已分配/空闲标记。因此已分配块不需要头部, 同 时 因为没有合并 , 它们也不需要脚部。因为分配和释放操 作都是在空闲链表的起始处操作,所以链表只需要是单向的,而不用是双向的。关键点在于,在任何块中都需要的唯一 字段是每个空闲块 中的一个字的 su c c 指针, 因此最小块大小就是一个字。\n一个显著的缺点是,简单分离存储很容易造成内部和外部碎片。因为空闲块是不会被 分割的,所以可能会造成内部碎片。更糟的是,因为不会合并空闲块,所以某些引用模式 会引起极多的外部碎片(见练习题 9. 10 ) 。\n练习题 9. 10 描述一个在基于简单分离存储的分配器中会导致严重外部碎片的引用模式。\n分离适配\n使用这种方法,分配器维护着一个空闲链表的数组。每个空闲链表是和一个大小类相关联的,并且被组织成某种类型的显式或隐式链表。每个链表包含潜在的大小不同的块, 这些块的大小是大小类的成员。有许多种不同的分离适配分配器。这里,我们描述了一种简单的版本。\n为了分配一个块,必须确定请求的大小类,并且对适当的空闲链表做首次适配,查找一个合适的 块。如果找到了一个 , 那么就(可选地)分割它,并 将剩余的部分插入到适当的空闲链表中。如果找不到合适的块,那么就搜索下一个更大的大小类的空闲链表。如此重复,直到找到一个合适的块。如果空闲链表中没有合适的块,那么就向操作系统请求额外的堆内存,从这个新的堆内存中分配出一个块,将剩余部分放置在适当的大小类中。要释放一个块,我们执行合并,并将结果放置到相应的空闲链表中。\n分离适配方法是一种常见的选择, C 标准库中提供的 G N U rna ll o c 包就是采用的这种方法,因为这种方法既快速,对内存的使用也很有效率。搜索时间减少了,因为搜索被限 制在堆的某个部分 , 而不是整个堆。内存利用率得到了改善, 因为有一个有趣的事实: 对分离空闲链表的简单的首次适配搜索,其内存利用率近似于对整个堆的最佳适配搜索的内 存利用率。\n伙伴系统\n伙伴系统( buddy s ys tem ) 是分离适配的一种特例, 其中每个大小类都是 2 的幕。基本的思路是假设一个堆的大小为沪个字,我们为每个块大小沪维护一个分离空闲链表,其 中 O\u0026lt; k\u0026lt; m。请求块大小向上舍入到最接近的 2 的幕。最开始时,只 有一个大小为 沪个字的空闲块。\n为了分配一个大小为 2k 的 块, 我们找到第一个可用的 、大小 为 3 的块, 其中 k\u0026lt; J \u0026lt; m。如果 j = k , 那么我们就完成了。否则, 我们递归地二分割这个块,直到 j = k 。当我们进行这样的分割时,每个剩下的半块(也叫做伙伴)被放置在相应的空闲链表中。要释放一个大小为\n2k 的块, 我们继续合并 空闲的伙伴。当遇到一个巳分配的伙伴时 , 我们就停 止合并。\n关千伙伴系统的一个关键事实是,给定地址和块的大小,很容易计算出它的伙伴的地 址。例如, 一个块, 大小为 32 字节, 地址为:\nXX X …x OOO OO\n它的伙伴的地址为\nXX X …x l OOOO\n换句话说 , 一个块的地址和它的伙伴的地址只有一位不相同。\n伙伴系统分 配器的 主要优点是它的快速搜索 和快速合并。主要缺点是要求块大小为 2 的幕可能导致显 著的内部碎片。因此, 伙伴系统分配器不适合通用目的的工作负载。然而,对 于某些特定应用的工作负载, 其中块大小预先知 道是 2 的幕, 伙伴系统分配器就很有吸引力了。\n9. 10 垃圾收集\n在诸如 C rna l l oc 包这样的显式分配器中, 应用通过调用 rna l l o c 和 fr e e 来分配和释放堆块。应 用要负责释放所有不再需要的已分配块。\n未能释放已分配的块是一种常见的编程错误。例如, 考虑下面的 C 函数 , 作为处理的一部分, 它分配一块临时存储:\nvoid garbage()\n{\nint *P = (int *)Malloc(15213);\nreturn; I* Array pis garbage at this point *I\n因为程序不 再需要 p , 所以在 g ar b a g e 返回前应该释放 p。不幸的是,程序 员忘了释放这个块。它在程序的生命周期内都保持为己分配状态,毫无必要地占用着本来可以用来 满足后 面分配请求的堆空间 。\n垃圾收 集器 ( ga r bage coll ecto r ) 是一种动态内存分配器,它 自动释放程序不再需要的已分配块。这些块被称为垃圾( ga r ba ge ) (因此术语就称之为垃圾收集器)。自动回收堆存储的过程叫做垃圾收 集( ga r bage collection ) 。在一个支待垃圾收集的系统中, 应用显式分配堆块, 但是从不显示地释放它们。在 C 程序的上下文中, 应用调用 ma l l o c , 但是从不调用 fr e e 。反之 ,垃圾 收集器定期识别垃圾块, 并相应地调用 fr e e , 将这些块放回到空闲链表中。\n垃圾收集可以 追溯到 J oh n M cCa r t h y 在 20 世纪 60 年代早期在 MIT 开发的 Lis p 系统。它是诸如 Java 、ML 、Perl 和 M a t hema tica 等现代语言系统的一个重要部分, 而且它仍然是一个重要而活跃的研究 领域。有关 文献描述了大量的垃圾收集方法 , 其数量令人吃惊。我们的 讨论局限于 McCa r t h y 独创的 Ma r k \u0026amp; S weep ( 标记 & 清除)算法, 这个算法很有趣, 因为它可以建立在已存 在的 ma l l o c 包的基础之上, 为 C 和 C ++ 程序提供垃圾收集 。\n9. 10. 1 垃圾收集器的基本知识\n垃圾收集器将内存视为一张有向 可达图 ( rea cha bilit y graph), 其形式如图 9-49 所示。该图的节点被分成一组根节点 ( ro ot node ) 和一组堆节点( hea p node ) 。每个堆节点对应千堆中的一个已分配块。有向边 p- q 意味着块 p 中的某个位置指向块 q 中的某个位置。根节点对应于这样一种不在堆中的位置,它们中包含指向堆中的指针。这些位置可以是寄存器、栈里的变量,或者是虚拟内存中读写数据区域内的全局变量。\n,.\n喻 气 , ..\n、. -.,\u0026rsquo;\u0026rsquo;-星.\u0026rsquo;.飞,•\n图 9-49 垃圾收集器将内存视为一张有向图\n当存在一条从任意 根节点 出发并到达 p 的有向路径时, 我们说节点 p 是 可达的( reacha ble ) 。 在任何时刻, 不可达节点对应千垃圾, 是不能被应用再次使用的。垃圾收集器的角色是维护可达图的某种表示,并通过释放不可达节点且将它们返回给空闲链表,来 定期地回收它们。\n像 M L 和 J ava 这样的语言的垃圾收集器, 对应用如何创建和使用指针有很严格的控制, 能够维护可达图的 一种精确的 表示 , 因此也就能够回收所有垃圾。然而, 诸如 C 和\nC++ 这样的 语言的收集器通 常不能维持可达图 的精确表示。这样的收集器也叫做保守的垃圾 收 集 器 ( co nse r va tive garbage collector) 。从某种意义上来说它们是保守的, 即每个 可达块都被正确地标记为可达了,而一些不可达节点却可能被错误地标记为可达。\n收集器可以按需提供它们的服务,或者它们可以作为一个和应用并行的独立线程,不 断地更新可达图 和回收垃圾 。例如, 考虑如何将一个 C 程序的保守的收 集器加 入到已存在的 ma l l o c 包中, 如图 9- 50 所示。\n动态内存分配器\n! ,\u0026mdash;\u0026mdash;\u0026ndash;, # 一 一1 \u0026ndash; - - \u0026ndash; - - \u0026ndash; \u0026ndash; - - \u0026ndash; - - - \u0026ndash; - - - - \u0026ndash; - - \u0026ndash; - - - - - \u0026ndash; - - \u0026ndash; - - - - \u0026ndash; - - - \u0026ndash; - - \u0026ndash; \u0026ndash; \u0026ndash; \u0026ndash; - - J 图 9-50 将一个保守的垃圾收集器加入到 C 的 ma l l oc 包中\n无论何时需要堆空间时, 应用都会用通常的方式调用 ma l l o c 。 如果 ma l l o c 找不到一个合适的空闲块,那么它就调用垃圾收集器,希望能够回收一些垃圾到空闲链表。收集器 识别出垃圾块, 并通过调用 fr e e 函数将它们返回给堆。关键的思想是收集器代替应用去调用 f r e e 。当对收集器的调用 返回时, ma l l o c 重试 , 试图发现一个合适的空 闲块。如果还是失败了 , 那么它就会向操作系统要求额外 的内存。最后, ma l l o c 返回一个指向请求块的指针(如果成功)或者返回一个空指针(如果不成功)。\n10. 2 Mark \u0026amp; Sweep 垃圾收 集器\nMark \u0026amp;.Sw ee p 垃圾收集器由 标记 ( ma r k ) 阶段和清除 ( sweep ) 阶段组成, 标记阶段标记出根节点的所有可达的和已分配的后继,而后面的清除阶段释放每个未被标记的已分配 块。块头部中空闲的低位中的一位通常用来 表示这个块是否被标记了。\n我们对 Mark \u0026amp;.Swee p 的描述将假设使用下列函数, 其中 ptr 定义为t ype de f void *p七r :\n•• ptr i s P七r (ptr p) 。如果 p 指向一个巳分配块中的某个字, 那么就返回一个指向这个块的起始位置的指针 b。否则返回 NU L L 。\nint blockMarked (ptr b) 。 如果块 b 是已标 记的, 那么就返回 tr ue 。\ni n七 b l o c kA ll o c a t e d (ptr b) 。如果 块 b 是已分配的, 那么就返回 tr ue 。\nvoid markBlock (p 七r b ) 。 标记块 b。\nint length (b ) 。返回 块 b 的以字为单位的长度(不包括头部)。\nvoid unmarkBlock (ptr b) 。将块 b 的状态由己标记的改为未标记的。\nptr ne x 七Bl o c k (p 七r b ) 。返回堆中块 b 的后继。\n标记阶段为每个根 节点调用一次图 9-Sl a 所示的 mar k 函数。如果 p 不指向一个已分配并且未标记的堆块, mar k 函数就立即返回。否则,它 就标记这个块, 并对块中的每个字递归地调用它自己。 每次对 mar k 函数的调用都标记某个根节点的所有未标记并且可达的后继节点。在标记阶段的末尾,任何未标记的已分配块都被认定为是不可达的,是垃 圾,可以在清除阶段回收。\n清除阶段是对图 9-51 6 所示的 s we e p 函数的一次调用。s we e p 函数在堆中每个块上反复循环,释放它所遇到的所有未标记的已分配块(也就是垃圾)。\n图 9-52 展示了一个小堆的 Mark \u0026amp;.Sweep 的图 形化解释。块边界用粗线条表示。每个方\n块对应于内存中的一个字。每个块有一个字的头部,要么是已标记的,要么是未标记的。\nvoid mark(ptr p) {\nif ((b = isPtr(p)) == NULL) return;\nif (blockMarked(b))\nreturn; markBlock(b);\nlen = length(b);\nfor (i=O; i \u0026lt; len; i++) mark (b [i]) ;\nreturn;\nvoid sweep(ptr b, ptr end) { while (b \u0026lt; end) {\nif (blockMarked(b)) unmarkBlock(b);\nelse if (blockAllocated(b)) free(b);\nb = nextBlock(b);\n}\nreturn;\n}\n}\na) mar k 函数 b ) s we e p 函数\n图 9-51 ma r k 和 s we ep 函 数的伪代码\n标记前:\n根节点\n1 2 3 4 i 5\n标记后:\ni\n久七:,\n口未标记的块头部口 巳标记的块头部\n清除后: I 全闲\ni\n空闲的\n图 9-52 Mark \u0026amp;.S weep 示例。注意这个示例中的 箭头表示内 存引用,而不是空闲链表指针\n初始情况下,图 9-52 中的堆由六个已分配块组成, 其中每个块都是未分配的。第 3\n块包含一 个指向第 1 块的指针。第 4 块包含指向第 3 块和第 6 块的指针。根指向第 4 块。\n在标记阶段之后, 第 1 块、第 3 块、第 4 块和第 6 块被做了标记, 因为它们是从根节点可达的。第 2 块和第 5 块是未标 记的, 因为它们是不 可达的。在清除阶段之后, 这两个不可达块被回收到空闲链表。\n9. 10. 3 C 程序的保守 Ma rk \u0026amp; Sweep\nMark\u0026amp;Sweep对 C 程序的垃圾收集是一种合适的方法, 因为它 可以就地工作,而不需 要移动任何块。然而, C 语言为 i s Ptr 函数的实现造成了一些有趣的挑战。\n第一, C 不会用任何类型信息来标记内存位置。因此, 对 i s Ptr 没有一种明显的方式来判断它的输入参数 p 是不是一个指针。第二, 即使我们知道 p 是一个指针 , 对 i s Pt r 也没有明显 的方式来判断 p 是否指向一个已分配块的有效 载荷中的 某个位 置。\n对后一问题的解决方法是将已分配块集合维护成 一棵平衡二叉树, 这棵树保持着这样一个属性:左子树中的所有块都放在较小的地址处,而右子树中的所有块都放在较大的地址 处。如图 9-53 所示, 这就要求每个已 分配块的头部里有两个附加字段Cl e 红 和 r i ght )。每个字段指向某个巳分配块的头部。i s Ptr (ptr p)函数用树来执行对已分配块的二分查找。在每一步中,它 依赖于块头部中的大小字段来判断 p 是否落在这个块的范围之内 。\n三> 块剩余的部分\n图 9-53 一棵已分配块的平衡树中的左右指针\n平衡树方法保证会标记所有从根节点可达的节点,从这个意义上来说它是正确的。这是一个必要的保证,因为应用程序的用户当然不会喜欢把他们的已分配块过早地返回给空闲链表。然而,这种方法从某种意义上而言又是保守的,因为它可能不正确地标记实际上不可达的块,因此它可能不会释放某些垃圾。虽然这并不影响应用程序的正确性,但是这可能导致不必要的外部碎片。\nC 程序的 Mar k \u0026amp;. S weep 收集器必须是 保守的 , 其根本原因是 C 语言不会用类型信息来标记内存位置。因此,像 i n t 或者 f l oa t 这样的标量可以伪装成指针。例如, 假设某个可达的巳分配块在它的有效载荷中包含一个 i nt , 其值碰巧对应于某个其他已分配块 b 的有效载荷中的一 个地址。对收集器而言 , 是没有办法推断出这个数据实际上是 i n t 而不是指针。因此 ,分 配器必须保守地将块 b 标记为可达, 尽管事实上 它可能是不可达的。\n9. 11 C 程序中常见的与内存有关的错误\n对 C 程序员来说,管 理和使用虚拟内存可能是 个困难的、容易出错的任务。与内存有关的错误属于那些最令人惊恐的错误,因为它们在时间和空间上,经常在距错误源一段距离之后才表现出来。将错误的数据写到错误的位置,你的程序可能在最终失败之前运行了好几个小时,且使程序中止的位置距离错误的位置已经很远了。我们用一些常见的与内存有关错误的讨论,来结束对虚拟内存的讨论。\n9. 11. 1 间接引用坏指针\n正如我们在9. 7. 2 节中学到的, 在进程的虚拟地址空间中有较 大的洞, 没有映射到任何有意义的数据。如果我们试图间接引用一个指向这些洞的指针,那么操作系统就会以段异常中止 程序。而且, 虚拟内存的某些区域是只读的。试图写这些区域将会以保护异常中止这个程序。\n间接引用坏指针的一个常见示例是经典的 s c a n f 错误。假设我们想要使用 s c a n f 从\ns t d i n 读一个整数到一个变量。正确的方法是传 递给 s c a n f 一个格式串和变量的地址:\nscanf(\u0026ldquo;¼d\u0026rdquo;, \u0026amp;val)\n然而 , 对于 C 程序员初学者而言(对有经验者也是如此!), 很容易传递 v a l 的内容,而 不是它的地址:\ns canf ( \u0026ldquo;1 儿 d\u0026rdquo; , val)\n在这种 情况下 , s c a n f 将把 va l 的内容解释为一个地址, 并试图将一个字写到这个位置。在最好的 清况下 ,程序 立即以异常终止。在最糟糕的情况下, v a l 的 内 容对应于虚拟内存的某个合法的读/写区域,千是我们就覆盖了这块内存,这通常会在相当长的一段时间以 后造成灾难性的、令人困惑的后果。\n9. 11 . 2 读未初始化的内存\n虽然 bss 内存位置(诸如未初始化的全局 C 变量)总是被加载器初始化为零 ,但 是对于堆内存却并不是这样的。一个常见的错误就是假设堆内存被初始化为零:\nI* Return y = Ax *I\nint *matvec(int **A, int *x,\n{\nint n)\nint l., j;\nint *Y = (int *)Malloc(n * sizeof(int));\nfor (i = O; i \u0026lt; n; i++)\nfor (j = O; j \u0026lt; n; j++) y[i] += A[i] [j] * x[j];\nreturn y;\n在这个示例中, 程序员 不正确地假设向量 y 被初始化为零。正确的实现方式是显式地将\ny [ i ]设置为零 ,或 者使用 c a l l o c 。\n9. 11. 3 允许栈缓冲区溢出\n正如我们在 3. 10. 3 节中看到的, 如果一个程序不检查输入串的大小就写入栈中的目标缓冲区, 那么这个程序就会有缓 冲区 溢出错 误( b uff e r overflow bug ) 。例如, 下面的函数就有缓 冲区溢出错误, 因为 g e t s 函数复制一个任意长度的串到缓冲区。为了纠正这个错误, 我们必 须使用 f g e t s 函数, 这个函数限制了输入串的大小:\nvoid bufoverflow()\n{\nchar buf[64];\ngets(buf); I* Here is the stack buffer overflow bug *I return;\n9. 11. 4 假设指针和它们指向的对象是相同大小的\n一种常见的错误是假设指向对象的指针和它们所指向的对象是相同大小的:\nf* Create an nxm array *f\nint **makeArrayl(int n, int m)\n{\nint i;\nint **A= (int **)Malloc(n * sizeof(int));\nfor (i \u0026ldquo;\u0026lsquo;0; i \u0026lt; n; i ++)\nA[i] \u0026ldquo;\u0026rsquo;(int•)Malloc(m * sizeof(int)); return A;\n这里的目的是创建一个由 n 个指针组成的数组, 每个指针都指向一个包含 m 个 i nt 的数组。然而, 因为程序员 在第 5 行将 s i z e o f (int *)写成 了 江ze o f (int), 代码实际上创建的是一个 i n 七的 数组。\n这段代码只有在 i n t 和指向 i n t 的指针大小相同的机器上运行良好。但是, 如果我们在像 Core i7 这样的机器上运行这段代码, 其中指针大于 i nt , 那么第 7 行和第 8 行的循环将\n写到超出 A 数组结尾的地方。因为这些字中的一个很可能是已分配块的边界标记脚部,所 以我们可能不会发现这个错误,直到在这个程序的后面很久释放这个块时,此时,分配器中的 合并代码会戏剧性地失败, 而 没有任何明显的原因。这是“在远处起作用 ( actio n at dis­ tance)\u0026rdquo; 的一个阴险的示例,这类 ”在 远处起作用” 是 与内 存有关的编程错误的典型情况。\n9. 11. 5 造成错位错误\n错位(off- by-one)错误是另一种很常见的造成覆盖错误的来源: I* Create an nxm array *I\n2 int **makeArray2(int n, int m)\n3 {\n4 int i;\n5 int **A= (int **)Malloc(n * sizeof(int *));\n7 for (i = 0; i \u0026lt;= n; i ++)\n8 A[i] = (int *)Malloc(m * sizeof(int)); return A;\n10 }\n这是前面一节中程序的另一个版本。这里我们在第 5 行创建了一个 n 个元 素的指针数组,但 是 随后 在第 7 行 和第 8 行试图初始化这个数组的 n + l 个 元 素, 在这个过程中覆盖了 A 数组后面的某个内存位置。\n9. 11. 6 引用指针,而不是它所指向的对象\n如果不太注意 C 操作符的优先级和结合性, 我们就会错误地操作指针, 而不是指针所指向的对象。比如, 考虑下面的函数,其 目 的 是 删除一个有 *s i z e 项的二叉堆里的第一项,然 后 对剩 下的 *s i z e - 1 项 重 新 建堆 :\nint *binheapDelete(int **binheap, int *Size)\n3 int *packet= binheap[O];\n5 binheap[O] = binheap[*size - 1];\n6 *size\u0026ndash;; I* This should be (*size)\u0026ndash; *I\nheapify(binheap, *size, 0); return(packet); 在第 6 行,目 的 是 减 少 s i z e 指 针 指 向 的 整 数 的 值 。 然而,因 为一元运算符- - 和 * 的 优先级相同 ,从 右 向 左 结 合 ,所 以 第 6 行 中的代码实际减少的是指针自己的值, 而 不 是它所指向的整数的值。如果幸运地话,程序会立即失败;但是更有可能发生的是,当程序在执行过程后很久才产生出一个不正确的结果时,我们只有一头的雾水。这里的原则是当你对优先级和结合性有疑问的时候,就 使 用 括号。比如, 在第 6 行 , 我们可以使用表达式(*s i ze ) 一一,清 晰地表明我们的意图。\n9. 11. 7 误解指针运算\n另一种常见的错误是忘记了指针的算术操作是以它们指向的对象的大小为单位来进行 的,而这 种 大 小 单 位并不一定是字节。例如,下 面函数的目的是扫描一个 i n t 的数组,并\n返回一个指针, 指向 v a l 的首次出现:\nint *Search(int *P, int val)\n{\nwhile (*p \u0026amp;\u0026amp; *P != val)\np += sizeof(int); I* Should be p++ *I return p;\n}\n然而, 因为每次循 环时,第 4 行都把指针加了 4 ( 一个整数的字节数), 函数就不正确地扫描数组中每 4 个整数。\n9. 11. 8 引用不存在的变量\n没有太多经验的 C 程序员不理解栈的规则, 有时会引用不再合法的本地变量, 如下列所示:\nint *stackref 0\n{\nint val; return \u0026amp;val;\n这个函数返回一个指针(比如说是 p ) , 指向栈里的一个局部变量,然后弹出它的栈帧。尽管 p 仍然指向一个合法的内存地址, 但是它已经不再指向一个合法的 变量了。当以后在程序中调用其他函数时,内存将重用它们的栈帧。再后来,如果程序分配某个值给\n*p, 那么它可能实际上正在修改另一个函数的栈帧中的一个条目,从而潜在地带来灾难性 的、令人困惑的后果。\n9. 11. 9 引用空闲堆块中的数据\n一个相似的错误是引用已经被释放了的堆块中的数据。例如,考虑下面的示例,这个示例 在第 6 行分配了一个整数数组x , 在第 10 行中先释放了块x , 然后在第14 行中又引用了它:\nint *heapref (int n, int m)\n{\nint i;\nint *X, *y;\nx = (int *)Malloc(n * sizeof(int));\nIIOther calls to mal/oc and free go here\nfree(x);\ny = (int *)Malloc(m * sizeof(int));\nfor\n(i = O; i \u0026lt; m; i++)\ny[i] = x[i]++; I* Oops! x[i] is a word in a free block *I\nreturn y;\n取决千在第 6 行和第 10 行发生的 ma l l o c 和 fr e e 的调用模式, 当程序在第 1 4 行引用x [ i ] 时, 数组 x 可能是某 个其他巳分配堆块的一部分 了, 因此其内容被重写了 。和其他许多与内存有关的错误一样, 这个错误只 会在程序执行 的后面, 当我们 注意到 y 中的值被破坏了时才会显现出来。\n9. 11. 10 引起内存泄漏\n内存泄漏是缓慢、隐性的杀手,当程序员不小心忘记释放巳分配块,而在堆里创建了 垃圾时 , 会发生这种问题。例如,下 面的函数分 配了一个堆块 x , 然后不释放它就返回:\nvoid leak(int n)\n{\nint *X = (int *)Malloc(n * sizeof(int)); return; I* xis garbage at this point *I\n如果经常调用 l e a k , 那么渐渐地,堆里就会充满了垃圾,最糟糕的情况下,会占用整个虚拟地址空间。对于像守护进程和服务器这样的程序来说,内存泄漏是特别严重的, 根据定义这些程序是不会终止的。\n12 小结\n虚拟内存是对主存的一个抽象。支待虚拟内存的处理器通过使用一种叫做虚拟寻址的间接形式来引 用主存。处理器产生一个虚拟地址,在 被发送到主存之前,这 个 地 址 被 翻译 成一个物理地址。从虚拟地址空间到物理地址空间的地址翻译要求硬件和软件紧密合作。专门的硬件通过使用页表来翻译虚拟地址, 而页表的内容是由操作系统提供的。\n虚拟内存提供三个重要的功能。第一,它 在 主存 中 自 动缓存最近使用的存放磁盘上的虚拟地址空间的内容。虚拟内存缓存中的块叫做页。对磁盘上页的引用会触发缺页,缺页将控制转移到操作系统中的 一个缺页处理程序。缺页处理程序将页面从磁盘复制到主存缓存,如 果 必 要 ,将 写 回 被 驱逐的页。第二, 虚拟内存简化了内存管理,进 而又简化了链接、在进程间共享数据、进程的内存分配以及程序加载。最后,虚拟内存通过在每条页表条目中加入保护位,从而了简化了内存保护。\n地址翻译的过程必须和系统中所有的硬件缓存的操作集成在一起。大多数页表条目位于 Ll 高速缓存中, 但是一个称为 T LB 的 页表条目的 片上高速缓存, 通常会消除访问 在 Ll 上的页表条目的开销。\n现代系统通过将虚拟内存片和磁盘上的文件片关联起来,来初始化虚拟内存片,这个过程称为内存 映射。内存映射为共享数据、创建新的进程以及加载程序提供了一种高效的机制。应用可以使用 mmap 函数来手工地创建和删除虚拟地址空间的区域。然而,大 多 数 程 序 依 赖于动态内存分配器, 例 如 ma l l oc , 它管 理 虚 拟 地址 空 间 区 域内 一 个 称 为 堆 的 区 域 。 动 态 内 存 分 配 器 是 一 个 感 觉 像 系 统 级 程 序 的 应 用 级 程 序 , 它直接操作内存,而无需类型系统的很多帮助。分配器有两种类型。显式分配器要求应用显式地释放它 们的内存块。隐式分配器(垃圾收集器)自动释放任何未使用的和不可达的块。\n对千 C 程序员来说 ,管 理 和使 用 虚拟内 存是 一 件困 难 和容易 出错 的任务。常见的错误示例包括:间接引用坏指针,读取未初始化的内存,允许栈缓冲区溢出,假设指针和它们指向的对象大小相同,引用 指针而不是它所指向的对象,误解指针运算,引用不存在的变量,以及引起内存泄湍。\n参考文献说明\nKilburn 和他 的 同 事 们发 表 了 第 一 篇关 于虚拟内存的描述[ 63] 。体系结构教科书包括关 千硬 件在虚拟内存中的角色的更多细节[ 46] 。 操 作 系 统 教 科 书 包 含 关 千操作系统角色的更多信息[ 102 , 106, 113] 。\nBovet 和 Cesati [ 11] 给出了 Linu x 虚拟内存系统的详细描述。Intel 公司提供了 IA 处 理 器 上 32 位 和 64 位\n地址翻译的详 细文档 [ 52]。\nKnuth 在 1968 年编写了有关内 存分配的经典之作[ 64] 。从那以后,在 这个领域就有了大量的 文献。\nWilson、Johnstone 、Neely 和 Boles 编写了一篇关 于显式 分配器的漂亮综 述和性能评价的文章[ 118] 。本书中关 千各种 分配器策略的吞吐率和利用率 的一般评价就引自千他们 的调查。Jones 和 Lins 提供了关于垃圾收集的全面综述[ 56] 。Kern ighan 和 Rit chie [ 61] 展示了一个简单分配器的 完整代码, 这个简单的分配器是基 千显式空闲链 表的,每个空闲块中都有 一个块大小 和后继指针 。这段代码使用联合( union ) 来消除 大量的复杂指针运算 , 这是很 有趣的 , 但是代价是 释放操作是线性时间(而不是常数时间)。Doug Lea 开发了广泛使用的开源 malloc 包, 称为 dl ma ll oc [ 67 ] 。\n家庭作业\n9. 11\n在下面的一 系列间题中, 你要展示 9. 6. 4 节中的示例内存系统如何将虚拟地址翻译成物理地址, 以 及如何访问缓存。对于给定的 虚拟地址 , 请指出访问的 T LB 条目、物理地址,以 及返回的缓存字节值。请指明是否 T LB 不命中, 是否发生了缺页, 是否发生了缓存不命中。如果 有缓存不命中,对 千"返回的缓存字节”用\u0026rdquo;-\u0026ldquo;来表示。如果有缺页,对 千\u0026rdquo; PP N\u0026quot; 用\u0026quot;-\u0026ldquo;来表示,而 C 部分和 D 部分就空着。\n虚拟地址: Ox027c\n虚拟地址格式\n地址翻译\n3 12 JI 10 9 8 7 6 5 4 3\nI 仁勹\n物理地址格式\n物理地址引用\n11 10\n仁工二] # 9 8 7 6 5 4 3 2 1 0\nI I I I I I I I I I\n9. 12\n对于下面的地址,重复习题 9. 11:\n虚拟地址: Ox03a9\n虚拟地址格式 13 12 II 10 9 8 7 6 5 4 3 2 I 0\n地址翻译 c. 物理地址格式\n11 10 9 8 7 6 5 4 3 2 I 0\nD. 物理地址引用\n9 13 对于下面的地址, 重复习题 9. 11:\n虚拟地址: Ox 00 40\n虚拟地址格式\n13 12 l l 10 9 8 7 6 5 4 3 2 I 0\n地址翻译\n参 数 值 VPN TLB 索引 TLB 标记 TLB 命中?(是 I 否) 缺页? (是 I 否 ) PPN 物理地址格式 11 10 9 8 7 6 5 4 3 2 1 0\n物理地址引用 •• 9. 14 假设 有一 个输入文件 he l l o . t x t , 由 字符串\u0026rdquo; He l l o , world! \\ n\u0026quot;组成,编 写 一 个 C 程序,使 用\nmma p 将 he ll o . t 江 的 内 容 改 变为\u0026quot; J e l l o , wor l d ! \\ n\u0026quot; 。\n9. 15 确定下面的 ma l l oc 请 求序列得到的块大小和头部值。假设: 1) 分 配器保持双字对齐,使 用隐式空闲 链 表 ,以 及图 9-35 中的块格式。2) 块大小向上舍入为最接近的 8 字节的倍数。 9. 16 确定下面对齐要求和块格式的每个组合的最小块大小。假设:显式空闲链表、每个空闲块中有四字节的 pr e d 和 s uc c 指针、不允许有效载荷的大小为零,并 且头部和脚部存放在一个四字节的字中。 对齐要求 已分配块 空闲块 最小块大小(字节) 单字 头部和脚部 头部和脚部 单字 头部,但是没有脚部 头部和脚部 双字 头部和脚部 头部和脚部 双字 头部,但是没有脚部 头部和脚部 *.* 9. 17 开发 9. 9. 12 节中的分配器的一个版本,执行 下 一 次 适 配搜索,而 不是 首次适配搜索。\n•: 9. 18 9. 9. 12 节中的分配器要求每个块既有头部也有脚部,以 实 现常数时间的合并。修改分配器, 使得空闲块需要头部和脚部,而已分配块只需要头部。\n9. 19 下 面给出了三组关于内存管理和垃圾收集的陈述。在每一组中,只 有 一 句 陈 述 是 正 确的。你的任务就是判断哪一句是正确的。 a ) 在一个伙伴系统中,最 高 可达 50 %的空间可以因为内部碎片而被浪费了。\nb ) 首次适配内存分配算法比最佳适配算法要慢一些(平均而言)。\nc ) 只有当空闲链表按照内存地址递增排序时,使 用 边界标记来回收才会快速。\nd ) 伙伴系统只会有内部碎片,而 不 会 有 外 部 碎 片 。\na ) 在 按 照 块 大小 递减顺序排序的空闲链表上,使 用 首 次 适 配 算 法会导致分配性能很低, 但是可以避免外部碎片。\nb ) 对于最佳适配方法,空 闲 块 链 表 应 该 按 照内 存 地 址 的 递 增 顺 序 排 序 。\nc ) 最 佳 适 配 方 法 选择与请求段匹配的最大的空闲块。\nd ) 在按照块大小递增的顺序排序的空闲链表上,使 用 首 次 适 配算法与使用最佳适配算法等价。\nM ark \u0026amp; Sweep 垃圾收集器在下列哪种情况下叫做保守的:\na ) 它 们 只 有 在内存请求不能被满足时才合并被释放的内存。\n) 它们把一切看起来像指针的东西都当做指针。 ) 它们只在内存用尽时,才 执行 垃圾收集。 ) 它们不释放形成循环链表的内存块。 :: 9. 20 编写你自己的 ma l l oc 和 fr e e 版本, 将它的运行时间 和空间 利用率与标准 C 库提供的 ma l l oc 版本进行比较。\n练习题答案\n9. 1 这道题让你对不同地 址空间的大小有了些 了解。曾 儿何时,一 个 32 位地址空间看上去似乎是无法想象的大。但是,现在有些数据库和科学应用需要更大的地址空间,而且你会发现这种趋势会继 续。在有生之年 , 你可能会抱怨个人电 脑上那狭促的 64 位地址空间!\n虚拟地址位数( n ) 虚拟地址数( N) 最大可能的虚拟地址 8 z8=256 2\u0026rsquo;-1 =255 16 i16= 64K 2'6- I =64K-1 32 232= 4G 232- I =4G- l 48 248= 256T 248 - l = 256T- l 64 264 = 16 384P 264 - I= 16 384P-1 9. 2 因为每个虚拟页面是 P = 沪 字节,所以 在系统中总共有 2勹沪= 2-\u0026ldquo;p 个可能的页面,其 中每个都需要一个页表条目 ( PT E) 。\nn P=2p PTE的数量 16 4K 16 16 8K 8 32 4K IM 32 8K 512K 9. 3 为了完全掌 握地址 翻译,你需 要很好地理解这类问题。下面是如何解决第一个子问题: 我们有 n =\n32 个虚拟地址位 和 m = 24 个物理地址位。页面大小是 P = l KB, 这意味着对于 VPO 和 PPO , 我们都需要 log2 OK)= 10 位。(回想一下, VPO 和 PPO 是相同的。)剩下的地址位分别 是 V PN 和 PP N。\np VPN位数 VPO位数 PPN位数 PPO位数 IKB 22 10 14 10 2KB 21 11 13 11 4KB 20 12 12 12 8KB 19 13 11 13 9. 4 做一些这样的手工模拟,能很好 地巩固你对地址 翻译的理解 。你会发 现写出地址中的 所有的位, 然后在不同的位字段上画出方框,例 如 VP N、T LBI 等,这会很有 帮助。在这个 特殊的练习中,没有任何类型的不命中 : T LB 有一份 PT E 的副本, 而缓存有一份所请求 数据字的副本。对于命中和不命中的一些不同的组合, 请参见习题 9. 11 、9. 12 和 9. 13 。\nA. 00 0011 1101 0111\nB.\nC. OOll 0101 Olll\nD.\n9. 5 解决这个题目将帮助你很好地理解内存映 射。请自己独立完成这道题 。我们 没有讨论 o pe n 、f s t a t\n或者 wr i t e 函数,所以 你需要阅读它们的 帮助页来看看它们是 如何工作的。\npcopy.c\ncode/vmlmmapcopy.c\n9. 6 这道题触及了一些核心的概念,例如对齐要求、最小块大小以及头部编码。确定块大小的一般方法是,将所请求的 有效载荷和头部大小 的和舍 入到对齐要求(在此例中是 8 字节)最近的 整数倍。比如, ma ll o c (1 ) 请求的 块大小是 4 + 1 = 5 , 然后舍入到 8 。而 ma ll o c ( 13 ) 请求的块大小是 13 + 4 = 17, 舍入到 24 。\n请求 块大小(十进制字节) 块头部(十六进制) malloc (1) 8 Ox9 malloc ( 5 ) 16 Oxll malloc (12) 16 Ox ll ma ll o c (13) 24 Ox 1 9 9. 7 最小块大小对内部碎片有显著的影响。因此,理解和不同分配器设计和对齐要求相关联的最小块大小是很好的。很有技巧的 一部分是,要意识 到相同的块可以 在不同时刻被分配或者被释放。因此, 最小块大小就是最小已分配块大小 和最小空闲块大小两者的最大值。例如, 在最后一个子问题中,\n最小的已分配块大小 是一个 4 字节头部和一个 1 字节有效 载荷, 舍人到 8 字节。而最小空闲块的大小是一个 4 字节的头部 和一个 4 字节的脚部 , 加起来是 8 字节, 已经是 8 的倍数,就不需 要再舍入了。所以, 这个分配器的最小块大小就是 8 字节。\n对齐要求 已分配块 空闲块 最小块大小(字节) 单字 头部和脚部 头部和脚部 12 单字 头部,但是没有脚部 头部和脚部 8 双字 头部和脚部 头部和脚部 16 双字 头部,但是没有脚部 头部和脚部 8 9. 8 这里没有特别的技巧 。但是解答此题要求你理解简单的隐式链表分配器的 剩余部分是如何工作的, 是如何操作和遍历块的。\ncodelv mlmallod mm .c\nstatic void *find_fit(size_t asize)\n2 {\n3 I* First-fit search *I\n4 void *bp;\n5\nfor (bp = heap_listp; GET_SIZE(HDRP(bp)) \u0026gt; O; bp = NEXT_BLKP(bp)) {\nif (!GET_ALLOC(HDRP(bp)) \u0026amp;\u0026amp; (asize \u0026lt;= GET_SIZE(HDRP(bp)))) {\nreturn bp;\n9 }\n10 }\nreturn NULL; I* No fit *I\n#endif\n13 }\ncode/vmlmallod mm .c\n9. 9 这又是一个帮 助你熟悉分 配器的热身 练习。注意 对于这个分配器, 最小块大小是 16 字节。如果分割后剩下的块 大于或者等于最小块大小,那么我们就分割这个块 (第 6~ 10 行)。这里唯一有技巧的部分是要意识到在移动到下一块之前(第8 行),你必 须放置新的已分配块(第 6 行和第 7 行)。\ncode/vmlmallodmm.c\n1 static void place(void *bp, size_t asize)\n2 {\n3 size_t csize = GET_SIZE(HDRP(bp));\n4\nif ((csize - asize) \u0026gt;= (2*DSIZE)) {\nPUT(HDRP(bp), PACK(asize, 1));\nPUT(FTRP(bp), PACK(asize, 1));\ns bp= NEXT_BLKP(bp);\nPUT(HDRP(bp), PACK(csize-asize, O));\nPUT(ITRP(bp), PACK(csize-asize, O)); 11 }\nelse {\nPUT(HDRP(bp), PACK(csize, 1));\nPUT(FTRP(bp), PACK(csize, 1));\n15 }\n16 }\ncode/vm/mallod mm.c\n9. 10 这里有一个会引起外部 碎片的模式: 应用对第一个大小类做大员的分 配和释放 请求, 然后对第二个大小类做大鼠的分配和释放请求 ,接下来 是对第三个大小类做大量的分配和释 放请求, 以此类推。对于每个大小类,分 配器都创建了许多不会被回收的存储器, 因为分配器不会合并, 也因为应用不会再向这个大小类再次请求块了。\n"},{"id":446,"href":"/zh/docs/technology/System/%E6%B7%B1%E5%85%A5%E7%90%86%E8%A7%A3%E8%AE%A1%E7%AE%97%E6%9C%BA%E7%B3%BB%E7%BB%9F/%E4%B9%A6%E7%B1%8D/%E7%AC%AC%E4%B8%80%E9%83%A8%E5%88%86-%E7%A8%8B%E5%BA%8F%E7%BB%93%E6%9E%84%E5%92%8C%E6%89%A7%E8%A1%8C-2-6/","title":"Index","section":"SpringCloud","content":"第一部分\n尸\n心r\n程序结构和执行\n我们对计算机系统的探索是从学习计算机本身开始的,它由 处理器和存储器子系统组成。在核心部分,我们需要方法来表示 基本数据类型,比如整数和实数运算的近似值。然后,我们考虑 机器级指令如何操作这 样 的 数 据, 以 及 编译器 又如何 将 C 程 序 翻译成这样的指令。接下来,研究几种实现处理器的方法,帮助我 们更好地了解硬件资源如何被用来执行指令。一旦理解了编译器 和机器级代码 , 我们 就 能 了 解如何通 过编写 C 程 序 以 及 编译 它 们来最大化程序的性能。本部分以存储器子系统的设计作为结束, 这是现代计算机系统最复杂的部分之一。\n本书的这一部分将领着你深入了解如何表示和执行应用程序。你将学会一些技巧,来帮助你写出安全、可靠且充分利用计算资 源的程序。\n"},{"id":447,"href":"/zh/docs/technology/System/%E6%B7%B1%E5%85%A5%E7%90%86%E8%A7%A3%E8%AE%A1%E7%AE%97%E6%9C%BA%E7%B3%BB%E7%BB%9F/%E4%B9%A6%E7%B1%8D/%E7%AC%AC%E4%B8%89%E9%83%A8%E5%88%86-%E7%A8%8B%E5%BA%8F%E9%97%B4%E7%9A%84%E4%BA%A4%E4%BA%92%E5%92%8C%E9%80%9A%E4%BF%A1-10-12/","title":"Index","section":"SpringCloud","content":"第三部分\n程序间的交互和通信\n我们学习计算机系统到现在,一直假设程序是独立运行的, 只包含最小 限度 的 输入 和 输 出 。 然 而 , 在 现实 世界 里, 应 用 程 序利用 操作 系统提供的服 务 来 与 I/ 0 设 备 及 其他程序通信。\n本书 的 这 一部分将使你 了 解 U ni x 操作 系统提供 的基本 I/ 0 服务 , 以及如何用这 些服务 来构 造 应 用 程 序 , 例如 Web 客 户 端 和服务器, 它 们是 通过 Intern et 彼 此 通 信 的 。 你 将 学 习 编 写 诸 如 Web 服务器这样的 可 以 同 时 为 多 个 客 户 端提 供 服 务 的并 发 程 序。 编 写并发应用程序还能使程序在现代多核处理器上执行得更快。当学 完了这个部分,你将逐渐变成一个很牛的程序员,对计算机系统 以及它们对程序的影响有很成熟的理解。\n"},{"id":448,"href":"/zh/docs/technology/System/%E6%B7%B1%E5%85%A5%E7%90%86%E8%A7%A3%E8%AE%A1%E7%AE%97%E6%9C%BA%E7%B3%BB%E7%BB%9F/%E4%B9%A6%E7%B1%8D/%E7%AC%AC%E4%BA%8C%E9%83%A8%E5%88%86-%E5%9C%A8%E7%B3%BB%E7%BB%9F%E4%B8%8A%E8%BF%90%E8%A1%8C%E7%A8%8B%E5%BA%8F-7-9/","title":"Index","section":"SpringCloud","content":"霄'\n第二部分\n在系统上运行程序\n继续我们对计算机系统的探索,进一步来看看构建和运行应 用程序的系统软件。链接器把程序的各个部分联合成一个文件, 处理器可以将这个文件加载到内存,并且执行它。现代操作系统 与硬件合作,为每个程序提供一种幻象,好像这个程序是在独占 地使用处 理器和 主存 , 而 实际 上,在 任何 时 刻, 系 统 上 都 有 多 个程序在运行。\n在本书的第一部分,你很好地理解了程序和硬件之间的交互 关系。本书的第二部分将拓宽你对系统的了解,使你牢固地掌握 程序和操作系统之间的交互关系。你将学习到如何使用操作系统 提供的 服 务 来 构 建 系 统 级 程 序, 例 如 U nix shell 和 动 态 内 存 分配包。\n"},{"id":449,"href":"/zh/docs/technology/System/%E6%B7%B1%E5%85%A5%E7%90%86%E8%A7%A3%E8%AE%A1%E7%AE%97%E6%9C%BA%E7%B3%BB%E7%BB%9F/%E4%B9%A6%E7%B1%8D/%E9%99%84%E5%BD%95A-%E5%B0%81%E5%BA%95/","title":"Index","section":"SpringCloud","content":"附录 A\nA P P E N D I X A # 错误处理\n程序员应该总是检查系统级函数返回的错误代码。有许多细微的方式会导致出现错 误,只 有使用内核能够提供给我们的状态信息才能理解为什么有这样的错误。不幸的是, 程序员 往往不愿意进行错误检查, 因为这使他们的代码变得 很庞大,将 一行代码变成一个多行的条件语句。错误检查也是很令人迷惑的,因为不同的函数以不同的方式表示错误。 # 在编写本书时, 我们面临类似的问题。一方面, 我们希望代码示例阅读起来简洁简单;另一方面,我们又不希望给学生们一个错误的印象,以为可以省略错误检查。为了解 决这些问题, 我们采用了一种基于错误处理 包装函数 ( er ro r- ha ndling wra pper ) 的方法, 这是由 W. Richard Steve ns 在他的网络编程教材 [ 11 0] 中最先提出的。\n其思想是 ,给 定某个基本的 系统级函数 f oo , 我们定义一个有相同参数、只不过开头字母大写了的包装函数 Foo。包装函数调用基本函数并检查错误。如果包装函数发现了错误,那么它就打印一条信息并终止进程。否则,它返回到调用者。注意,如果没有错误, 包装函数的行为与基本函数完全一样。换句话说,如果程序使用包装函数运行正确,那么 我们把每个 包装函数的第一个字母小 写并重新编译,也 能正确运行。 # 包装函数被封装在一个源文件( c s a p p . c ) 中, 这个文件被编译 和链接到每个程序中。一个独立的 头文件Cc s a p p . h ) 中包含这些包装函数的 函数原型。\n本附录给出 了一个关于 U nix 系统中不同种类的错误处理的教程, 还给出 了不同风格的错误处 理包装函数的示例。c s a p p . h 和 c s a pp . c 文件可以从 CS : AP P 网站上获得。\n.A. 1 Unix 系统中的错误处理\n本书中我们遇到的系统 级函数调用使用三种不同风格的返回错误: U n ix 风格的、 # Posix 风格的和 G Ai 风格的。\nUn ix 风格的错误处理\n像 f o r k 和 wa i t 这样 U nix 早期开发出来的函数(以及一些较老的 Pos ix 函数)的函数返回值既 包括错误代码,也 包括有用的结果。例如, 当 U nix 风格的 wa i t 函数遇到一个错误(例如没有子进程要 回收), 它就返回一1, 并将全局变量 e rr n o 设置为指明错误原因的错误代码。如果 wa i t 成功完成, 那么它就返回有用的结果,也 就是回收的子进程的P ID。U nix 风格的错误处理代码通常具有以 下形式:\n1 if ((pid = -wait (NULL)) \u0026lt; 0) { 2 fprintf(stderr, 节 ait error: %s\\n\u0026quot;, strerror(errno)); exit(O); str err or 函数返回某个 err no 值的文本描述。\nPos ix 风格的错误处理\n许多较新的 Posix 函数, 例如 P t h read 函数,只 用 返回值来表明成功( 0 ) 或者失败(非\n。任何有用的结果都返回在通过引用传递进来的函数参数中。我们称这种方法为 P osix # 730 附录 A 错 误 处 理\n风格的错误处理。例如, P os ix 风格的 p t hr e a d _ cr e a t e 函数用它的返回值来表明成功或者失败, 而通过引 用将新创建的线程的 ID ( 有用的结果)返回放在它的第一个参数中。P a s­\nix 风格的错误处理代码通常具有以下形式: # if ((retcode = pthread_create(\u0026amp;tid, NULL, thread, NULL)) != 0) {\n2 fprintf(stderr, \u0026ldquo;pthread_create error: %s\\n\u0026rdquo;, strerror(retcode));\nexit(O);\n4 }\nS七r er r or 函数返回r e t c o d e 某个值对应的 文本描述。\nGAi 风格的错误处理\ng e t a d d r i n fo ( G A D 和 g e t n a me i n f o 函数成功时返回零,失 败时返回非零值。G A I\n错误处理代码通常具有以下形式: # if ((retcode = getaddrinfo(host, service, \u0026amp;hints, \u0026amp;result)) != 0) {\n2 fprintf(stderr, \u0026ldquo;getaddrinfo error: %s\\n\u0026rdquo;, gai_strerror(retcode));\n3 exit(O);\n4 }\ngai _s tr err or 函数返回r e t c o d e 某个值对应的文本描述。\n错误报告函数小结 # 贯穿本书,我们使用下列错误报告函数来包容不同的错误处理风格:\n#include \u0026ldquo;csapp.h\u0026rdquo;\nvoid unix_error(char•msg);\nvoid posix_error(int code, char•msg); void gai_error(int code, char•msg); void app_error(char•msg);\n返回: 无。\n正如它们的名字表明的那样, u n i x _ er r or 、 p o s i x _ er r or 和 g a i _ er r or 函数报告U n ix 风格的错误、P osix 风格的错误和 G A I 风格的错误,然后 终止。包括 a p p _ e r r o r 函数是为了方便 报告应用错误。它只是简单地打印它的输入, 然后终止。图 A-1 展示了这些错误报告函数的代码。\ncodelsr 吹 sapp.c\nvoid unix_error(char *msg) I* Unix-style error *I\n2 {\n3 fprintf(stderr, \u0026ldquo;%s: %s\\n\u0026rdquo;, msg, strerror(errno));\n4 exit(O);\n5 }\n6\n7 void posix_error(int code, char *msg) I* Posix-style error *I\n8 {\n9 fprintf(stderr, \u0026ldquo;%s: %s\\n\u0026rdquo;, msg, strerror(code));\n10 exit(O);\n11 }\n12\n13 void gai_error(int code, char *msg) I* Getaddrinfo-style error *I\n图A-1 错误报告函数\n附录 A 错 误 处 理 731 # 14 { 15 fprintf(stderr, \u0026ldquo;%s: %s\\n\u0026rdquo;, msg, gai_strerror(code)); 16 exit(O); 17 } 18 19 void app_error(char *msg) I* App 辽 ca t i on error *I 20 { 21 fprintf(stderr, 欢 s\\n\u0026quot;, msg); 22 exit(O); 23 } code/srdcsapp.c 图 A-1 C 续 ) 2 错误处理包装函数\n下面是一些不同错误处理包装函数的示例:\nU nix 凤格的错误处理 包装函数。图 A-2 展示了 U nix 风格的 wa i t 函数的包装函数。如果 wa i t 返回一个错误, 包装函数打印一条消息, 然后退出。否则,它 向调用者 返回一个 P ID 。图 A-3 展示了 U nix 风格的 K过 1 函数的包装函数。注意, 这个函数和 wa i t 不同,成 功时返回 V O 过。 pid_t Wait(int *status)\n2 {\n3 pid_t pid;\n4\nif ((pid = wait(status)) \u0026lt; 0)\n6 un1x_error(\u0026ldquo;Wait error\u0026rdquo;);\n7 return pid;\n8 }\ncodelsrdcsapp.c\ncode/srdcsapp.c\n图 A-2 Unix 风格的 wa i t 函数的包装函数\nvoid Kill(pid_t pid, int signum)\n2 {\n3 int re;\n4\n5 if ((re = kill(pid, signum)) \u0026lt; 0)\n6 unix_error(\u0026ldquo;Kill error\u0026rdquo;);\n7 }\ncode/srdcsapp.c\ncoder/s sapp.c\n图A-3 Unix 风格的 ki ll 函数的包装 函数\nP os ix 风格的错误处理 包装函数。图 A-4 展示了 P o si x 风格的 p t h r e a d _ d e t a c h 函数的包装函数。同大多数 P os ix 风格的函数一样,它 的 错误返回码中不会包含有用的结果,所 以成功时 , 包装函数返回 V O 过。 732 附录 A 错 误 处 理\nvoid Pthread_detach(pthread_t tid) {\n2 int re·\n3\n4 if ((re = pthread_detach(tid)) != 0)\n5 posix_error(rc, \u0026ldquo;Pthread_detach error\u0026rdquo;);\n6 }\ncode/src/csapp.c\ncode/srdcsapp.c\n图 A-4 Posix 风格的 pt hr ead_de t ach 函数的包装函数\nGAI 风格的错 误 处理 包装 函 数 。 图 A-5 展示了 GAI 风 格 的 g e t a d dr i n f o 函数 的 包装函数。 code/srdcsapp.c\nvoid Getaddrinfo(const char *node, const char *service,\n2 const struct addrinfo *hints, struct addrinfo **res)\n3 {\n4 int rc;\n5\n6 if ((re = getaddrinfo (node, service, hints, res)) != 0)\n7 gai_error(rc, \u0026ldquo;Getaddrinfo error\u0026rdquo;);\n8 }\ncode/sr吹 sapp.c\n图 A-5 GAI 风格的 ge t addr i nf o 函数的包装函数\n参考文献 # [1] Advanced Micro Devices, Inc. Software Proceedings of the 4th Symposium on Operating Optimization Guide for AMD64 Processors, Systems Design and Implementation (OSDI) 2005. Publication Number 25112. pages 31\u0026ndash;44. Usenix, October 2000.\n[2] Advanced Micro Devices, Inc. AMD64 [13] R. E. Bryant. Term-level verification of a Architecture Programmer \u0026rsquo;s Manual, Volume pipelined CISC microprocessor. Technical 1: Application Programming, 2013. Publication Report CMU-CS-05-195, Carnegie Mellon\nNumber 24592. University, School of Computer Science, 2005.\n[3] Advanced Micro Devices, Inc. AMD64 [14] R. E. Bryant and D.R. O\u0026rsquo;Hallaron. Introducing Architecture P rogrammer\u0026rsquo;s Manual, Volume computer systems from a programmer\u0026rsquo;s\n3: General-Purpose and System Instructions, perspective. In Proceedings of the Technical 2013. Publication Number 24594. Symposium on Computer Science Education\n[4] Advanced Micro Devices, Inc. AMD64 (S/GCSE), pages 90-94. ACM, February 2001. Architecture Programmer\u0026rsquo;s Manual, Volume [15] D. Butenhof. Programming with Posix Threads 4:128-Bit and 256-Bit Media Instructions, 2013. Addison-Wesley, 1997.\nPublication Number 26568. [16] S. Carson and P. Reynolds. The geometry of\n[5] K. Arnold, J. G osling, and D. Holmes. The semaphore programs. ACM Transactions on Java Programming Language, Fourth Edition. Programming Languages and Systems9(l):25- Prentice Hall, 2005. 53, 1987.\n[6] T. Berners-Lee, R. Fielding, and H. Frystyk. [17] J. B. Carter, W. C. Hsieh, L. B. Stoller, M. R. Hypertext transfer protocol - HTIP/1.0. RFC Swanson, L. Zhang, E. L. Brunvand, A. Davis, 1945, 1996. C.-C. Kuo, R. Kuramkote, M. A. Parker,\n[7] A. Birrell. An introduction to programming L. Schaelicke, and T. Tateyama. Impulse: with threads. Technical Report 35, Digital Building a smarter memory controller. In Systems Research Center, 1989. Proceedings of the5th International Symposium\non High Performance Computer Architecture\n[8] A. Birrell, M. Isard, C. Thacker, and T. Wobber. (HPCA), pages 70-79. ACM, January 1999.\nA design for high-performance flash disks. [18] K. Chang, D. Lee, Z. Chishti, A. Alameldeen,\nSIGOPS Operating Systems Review 41(2):88- 93, 2007.\nC. Wilkerson, Y. Kim, and 0. Mutlu. Improving DRAM performance by parallelizing refreshes\n[9] G. E. Blelloch, J. T. Fineman, P. B. Gibbons, with accesses. In Proceedings of the 20th\nand H. V. Simhadri. Scheduling irregular International Symposium on High-Performance parallel computations on hierarchical caches. Computer Architecture (HP CA). ACM,\nIn Proceedings of the 23rd Symposium on February 2014.\nParallelism in Algorithms and Architectures [19] S. Chellappa, F. Franchetti, and M. Pilschel. (SPAA), pages 355-366. ACM, June 2011. How to write fast numerical code: A small in-\n[10] S. Borkar. Thousand core chips: A technology troduction . In Generative and Transformational perspective. In Proceedings of the 44th Design Techniques in Software Engineering II, volume Automation Conference, pages 746\u0026mdash;749. AC M, 5235 of Lecture Notes in Computer Science, 2007. pages 196-259. Springer-Verlag, 2008.\n[11] D. Bovet and M. Cesati. Understanding the [20] P. Chen, E. Lee, G. Gibson, R. Katz, and Linux Kernel, Third Edition. O\u0026rsquo;Reilly Media, D. Patterson. RAID: High-performance, Inc., 2005. reliable secondary storage. ACM Computing\nSurveys 26(2):145-185, June 1994.\n[12) A. Demke Brown and T. Mowry. Taming the\nmemory hogs: Using compiler-inserted releases t21] S. Chen, P. Gibbons, and T. Mowry. Improving to manage physical memory intelligently. In index performance through prefetching. In\n734 参考文献\nProceedings of the 2001 ACM SIGMOD ACM, May 1999.\nInternational Conference on Management of [33] M. Dowson. The Ariane 5 software failure. Data, pages 235-246. ACM, May 2001. SIGSOFTSoftware Engineering Notes 22(2):84,\n[22) T. Chilimbi, M. Hill, and J. Larus. Cache- 1997.\nconscious structure layout. In P roceedings of [34] U. D re pper. User-level IPv6 programming the 1999 ACM Conference onP rogramming introduction. Available at http://www.akkadia Language Design and Imp lementation (P LD /), .or g/drepper/userap曰pv6.html, 2008.\npages 1- 12. ACM, May 1999\n[23] E. Co ffman, M. Elphick, and A. Shoshani. System deadlocks. ACM Computing Surveys 3(2):67-78, June 1971.\n[35) M. W. Eichen and J. A. Rochlis. With micro- scope and tweezers: An analysis of the In ternet viru s of November , 1988. In P roceedings of the IEEE Symposium on Research in Security and\n(24] D. Cohen. On holy wars and a plea for peace. Priva cy, pages 326-343. IEEE, 1989.\nIEEE Computer 14(10):48- 54, October 1981. [36] ELF-64 Object File Format, Version1.5 Draft 2, [25) P. J. Courtois, F. Heymans , and D. L. Parnas. 1998. Available at http://www.uclibc.org/docs/\nConcurrent control with \u0026quot; readers \u0026quot; and elf-64-gen.pdf.\n\u0026quot; wn ters.\u0026quot; Communications of the ACM [37] R. Fielding , J. Ge ttys, J. Mogul, H. Frystyk, 14(10):667-668, 1971. L. Masinter, P. Leach , and T. Berners-Lee.\nC. Cowan , P. Wagle, C. Pu, S. Beattie, and Hypert ext transfer protocol - HIT P/1.1. RFC\nJ. Walpole. Buffer overflows: A ttack s and 2616, 1999.\ndefenses for the vulnerability of the decade. In [38] M. Frigo, C. E. Leiserson , H. Pro kop, and\nDARPA Information Survivability Conference S. Ramachandran. Cache-oblivious algorithms. and Expo (DISCEX), volum e 2, pages 119-129, In P roceedings of the 40th IEEE Symposium\nMarch 2000. on Foundations of Computer Science (FOCS),\nJ. H. Crawford . The i486 CPU: Executing pages 285-297. IEEE, August 1999.\ninstructions in one clock cycle. IEEE Micro [39] M. Frigo and V. Strumpen. The cache complex- 10(1):27- 36, February 1990. ity of multithreaded cache oblivious algorithms.\nV. Cuppu, B. Jacob, B. Davis, and T. Mudge. In Proceedings of the18th Symposium on Para[-\nA performance comparison of cont empo rary le/ism in Algorithms and Ar chitectures (SPAA), DRAM architectures. In Proceedings of the pages 271- 280. ACM, 2006.\n26th International Symposium on Computer [40] G. Gibson , D. Nagle, K. Amiri, J. Butler, Architecture (!SCA), pages 222- 233, ACM, F. Ch ang, H. Go bioff, C. Hardin , E. 凡edel,\n1999 . D. Rochb erg, and J. Zelenka. A cost-effective ,\n(29] B. Davis, B. Jaco b, and T. Mudge. The new high-bandwidth storage architecture. In DRAM interfaces: SDRA M, RDRAM, and Proceedings of the 8th International Conference variants. In Proceedings of the 3rd International on Architectural Support for Programming\nSymposium on High Performance Computing Lang uages and Operating Systems (ASPLOS), (ISHPC), volume 1940 of Lectur e Noces m pages 92- 103. ACM, October 1998.\nComputer Science, pages 26-31. Springer- [41] G. Gibson and R. Van Meter. Network attach ed Verlag, October 2000. storage architect ure. Communications of the\nE . Demaine. Cache-oblivious algorithms and ACM 43(11):37-45, November 2000.\ndata structures. In Lecture Notes from the EEF [42] Google . 1Pv6 Adoption. Available at http://\nSummer School on Massive Data Sets. BRICS ,\nUniversity of Aarhus , Denmark , 2002.\nW 吓 .google.com/intl/en/ipv6/statistics.html.\n[43) J. Gustafson. Reevaluating Amdahl\u0026rsquo;s law.\nE . W. Dijkstra. Cooperating sequential Communications of the ACM 31(5):532-533, processes. Technical Report EWD-123, August 1988.\nTechnological University, Eindhoven, the\nNetherlands, 1965.\n(44] L. Gwennap. New algorithm improves branch\nprediction. Microprocessor Report 9(4), March\nC. Ding and K. Kennedy. Improving cache 1995. performance of dynamic applications through data and computation reorganizations at run time. In Proceedings of the 1999 ACM\nConference on Programming Language Design and Implementation (PLDI ), pages 229-241.\n[45] S. P. Harbison and G. L. Steele, Jr. C, A\nReference Manual, Fifth Edition. Prentice Hall, 2002.\n[46) J. L. Hennessy and D. A. Patterson. Computer\n参考文献 735\nArchitecture: A Quantitative Approach, Fifth [58] R. Katz and G. Borriello. Contemporary Logic Edition. Morgan Kaufmann , 2011. Design, Second Edttion. Prentice Hall, 2005.\nM. Herlihy and N. Shavit. The Art of Multi- [59] B. W. Kernighan and R. Pike. The Practice of processor Programming. Morgan Kaufmann, Programming. Addison-Wesley, 1999.\n2008. [60] B. Kernighan and D. Ritchie. The C Program-\nC. A. R. Hoare. Monitors: An operating system ming Language, First Edition. Prentice Hall, structuring concept. Communications of the 1978.\nACM 17(10):549-557, October 1974. [61] B. Kernighan and D. Ritchie. The C Program-\nIntel Corporation. Intel 64 and IA-32 Ar- ming Language, Second Edi tion. Prentice Hall, chitectures Optimization Reference Manual . 1988.\nAvailable at http: //www.inte l.com/content / [62] Michael Kerrisk. The Linux Programming\nW 吓 /us/en/processors/architectures-so ftware- Interface. No Starch Press, 2010.\ndeveloper-manuals.html.\n[63] T. Kilbu rn, B. Edwards, M. Lanigan, and\nIntel Corporation. Intel 64 and IA-32 Ar- F. Sumner. One-level storage system. IRE\nchitectures Software Developer\u0026rsquo;s Manual, Transactions on Electronic Computers EC- Volume 1: Basic Architecture. Available at 11:223- 235, April 1962.\nhttp: // www.intel.com/content/www/us/en/\nprocessors/architectures-software-developer- [64] D. Knuth. The Art of Computer Programming, manuals.html. Volume 1: Fundamental Algorithms, Third\nIntel Corporation. Intel 64 and IA-32 Ar- Edition. Addison-Wesley, 1997.\nchitectures Software Developer \u0026rsquo;s Manual, [65] J. Kurose and K. Ross. Computer Networking: A Volume 2: Instruction Set Reference. Available Top-Down App roach, Sixth Edition. Addison- at http://www.intel.com/content/www/us/en/ Wesley, 2012.\nprocessors/architectures-software-developer- [66] M. Lam, E. Rothberg, and M. Wolf. The manuals.html. cache performance and optimizations of\n[52) Intel Corporation. Intel 64 and IA-32 Architec- blocked algorithms. In Proceedings of the tures Software Develop er \u0026rsquo;s Manual, Volume 3a 4th International Conference on Architectural System Programming Guide, Part 1. Available Support for Programming Languages and\nat http :/ /www.intel.com/content/ www/us/en/ Operating Systems (ASPLOS), pages 63-74. processors/architectures-software-developer- ACM, April 1991.\nmanuals.html. [67] D. Lea. A memory allocator. Available at\n[53] Intel Corporation. Intel Solid-State Drive 730 http://gee.cs.oswego.edu/dl/html/malloc.html, Series: Product Specification. Available at 1996.\nhtt p://www.inte l.com/content/www/us/en/solid- [68] C. E. Leiserson and J. B. Saxe. Retiming state-drives/ssd-730-series-spec.html. synchronous circuitry. Algorithmica 6(1-6),\n[54) Intel Corporation. Tool Interface Standards June 1991.\nPortable Formats Specification, Version 1.1, [69] J. R. Levine. Linkers and Loaders. Morgan 1993. Order number 241597. Kaufmann , 1999.\n(55] F. Jo nes, B. Prince , R. Norwood, J. Hartigan, [70] David Levinthal. Performance Analysis Guide\nW. Vogley, C. Hart, and D. Bondurant. for Intel Core i7 Processor and Intel Xeon Memory a new era of fast dynamic RAMs 5500 Processors. Available at https://softwa re (for video applications). IEEE Spectrum, pages .intel.com/sites/products/collatera l/hpc/vtune/ 43\u0026ndash;45, October 1992. performance_analysis_guide.pdf.\nR. Jones and R. Lins. Garbage Collection: [71] C. Lin and L. Snyder. Principles of Parallel\nAlgorithms for Automatic Dynanuc Memory Management. Wiley, 1996.\nProgramming. Addison Wesley, 2008.\nM. Kaashoek , D. Engler, G. Ganger , H. Briceo, [72] Y. Lin and D. Padua. Compiler analysis of R. Hunt, D. Maziers, T. Pinckney, R. Gr皿m,\nJ. Jannotti , and K. MacKenzie. Application performance and flexibility on E xokernel systems. In Proceedings of the 16th ACM\nirregular memory accesses. In Proceedings of the 2000 ACM Conference on Programming Language Design and Implementation (PLDJ), pages 157- 168. ACM, June 2000.\nSymposium on Operating System Principles (73] J. L. Lions. Ariane 5 Flight 501failure. Technical\n(SOSP), pages 52-65. ACM, October 1997. Re port, European Space Agency, July 1996.\n736 参考文献\nS. Macguire . Writing Solid Code. Microsoft (87) W. Pugh. The Omega test: A fast and practical Press, 1993. integer programming algorithm for depen- S. A. Mahlke, W. Y. Chen, J. C. Gyllenhal, and dence analysis. Communications of the ACM\nW.W. Hwu. Compiler code transformations for 35(8):102-114, August 1992.\nsuperscalar-based high-performance systems. [88] W. Pugh. Fixing the Java memory model. In In Proceedings of the 1992 ACM/IEEE Proceedings of the ACM Conference on Java Conference on Supercomputing, pages 808-817 Grande, pages 89-98. ACM, June 1999.\nACM, 1992.\n[89] J. Rabaey, A. Chandrakasan, and B. Nikolic.\nE. Marshall. Fatal error: How Patriot over- Digital Integrated Circuits: A Design Perspec- looked a Scud. Science, page 1347, March 13, tive, Second Edition. Prentice Hall, 2003. 1992.\nM. Matz, J. Hubicka, A. Jaeger, and M. Mitchell. [90] J. Reinders. Intel Threading Building Blocks.\nSystem V application binary interface AMD64 architecture processor supplement. Technical [91] D. Ritchie . The evolution of the Unix time- Report, x86-64.org, 2013. Available at http:// sharing system. AT\u0026amp;T Bell Laboratories www.x86-64.org/documenta tion_folder/abi-0 Technical Journal 63(6 Part 2):1577-1593, .99.pdf. October 1984. J. Morris, M. Satyanarayanan, M. Conner, [92] D. Ritchie . The development of the C language.\nJ. Howard, D. Rosenthal, and F.Smith. Andrew: In Proceedings of the 2nd ACM SIGPLAN A distributed personal computing environment. Conference on History of Programming Communications of the ACM, pages 184-201, Languages, pages 201-208. ACM, April 1993. March 1986.\nT. Mowry, M. Lam, and A. Gupta. Design [93] D. Ritchie and K. Thompson. The Unix time- and evaluation of a compiler algorithm for prefetching. In Proceedings of the 5th\nsharing system. Communications of the ACM 17(7):365-367, July 1974.\nInternational Conference on Architectural [94] M. Satyana rayanan , J. Kistler, P. Kumar, Support for Programming Languages and M. Okasaki, E. Siegel, and D. Steere. Coda: Operating Systems (ASP L OS), pages 62-73 A highly available file system for a distributed ACM, October 1992. workstation environment. IEEE Transactions\nS. S. Muchnick. Advanced Compiler Design and on Computers 39(4):447-459, April 1990.\nImplementation. Morgan Kaufmann, 1997. (95) J. Schindler and G. Ganger. Automated disk\nS. Nath and P. Gibbons. Online maintenance of drive characterization. Technical Report CMU- very large random samples on flash sto rage. In CS-99-176, School of Computer Science, Proceedings of VLDB, pages 970-983. VLDB Carnegie Mellon University, 1999.\nEndowment, August 2008. [96] F. B. Schneider and K. P Birman. The\nM. Overton . Numerical Computing with IEEE monoculture risk put into context. IEEE Floating Point A rithmetic. SIAM, 2001. Security and Privacy 7(1):14-17, January 2009.\nD. Patterson , G. Gibson, and R. Katz. A case for [97] R. C. Seacord. Secure Coding in C and C++, redundant arrays of inexpensive disks (RAID). Second Edition. Addison-Wesley, 2013.\nIn Proceedings of the 1998 ACM SIG MOD\nInternational Conference on Management of [98] R. Sedgewick and K. Wayne. Algorithms, Fourth Data, pages 109-116. ACM, June 1988. Edition. Addison-Wesley, 2011.\nL. Peterson and B. Davie . Computer Networks: (99] H. Shacham, M. Page, B. Pfaff, E.-J. Goh, A Systems Approach, Fifth Edition. Morgan N. Modadugu, and D. Boneh. On the effec -\nKaufma nn, 2011. tiveness of address-space randomization. In\nJ. Pincus and B. Baker. Beyond stack smashing: Proceedings of the 11th ACM Conference on Recent advances in exploiting buffer overruns. Computer and CommunicationsSecurity (CCS), IEEE Security and Privacy 2(4):20-27, 2004. pages 298-307. ACM, 2004.\nS. Przybylski. Cache and Memory Hierarchy [100) J.P. Shen and M. Lipasti. Modern Processor De- Design: A Performance-Directed Approach. sign: Fundamentals of Superscalar Processors. Morgan Kaufmann, 1990. McGraw Hill, 2005.\n参考文献 737\n[101] B. Shriver and B. Smith. The Anatomy of a High-Performance Microprocessor:A Systems Perspective. IEEE Computer Society, 1998.\nArchitecture (HPCA), pages 168-179. IEEE, February 1997.\nE. H. Spafford. The Internet worm program: An analysis. Technical Report CSD-TR-823, Department of Computer Science, Purdue University, 1988.\nW. Stallings. Operating Systems: Internals and Design Principles, Eighth Edition. Prentice Hall, 2014.\nW. R. Stevens . TCP/IP Illustrated, Volume 3: TCP for Transactions, H TTP, NNTP and the Unix Domain Protocols. Addison-Wesley, 1996.\nW. R. Stevens . Unix Network Programming: Interprocess Communications, Second Edition, volume 2. Prentice Hall, 1998.\n[109) W.R. Stevens and K. R. Fall. TCP/IP Illustrated, Volume 1: The P rotocols, Second Edition.\nAddison-Wesley, 2011.\nW. R. Stevens, B. Fenner, and A. M. Rudoff. Unix Network Programming: The Sockets Networking AP/ , Third Edition, volume 1. Prentice Hall, 2003.\nW. R. Stevens and S. A. Rago. Advanced Programming in the Unix Environment , Third Edition. Addison-Wesley, 2013.\nT. Stricker and T. Gross. Global address space, non-uniform bandwidth: A memory system performance characterization of parallel systems. In Proceedings of the 3rd International Symposium on High Performance Computer\nJ. F. Wakerly. Digital Design Principles and P ractices,Fourth Edition. Prentice Hall, 2005.\nM. V. Wilkes. Slave memories and dynamic storage allocation. IEEE Transactions on Electronic Computers, EC-14(2), April 1965.\nP. Wilson, M. Johnstone, M. Neely, and D. Boles. Dynamic storage allocation: A survey and critical review. In International Workshop on Memory Management , volume 986 of Lecture Notes in Computer Science, pages 1- 116. Springer-Verlag, 1995.\nM. Wolf and M. Lam. A data locality algorithm. In P roceedings of the 1991 ACM Conference on Programming Language Design and Implementation (PLDI), pages 30-44, June 1991.\nG. R. Wright and W. R. Stevens . TCP/IP Illustrated, Volume 2: The Implementation . Addison-Wesley, 1995.\nJ. Wylie, M. Bigrigg, J. Strunk, G. Ganger,\nH. Kiliccote, and P. Khosla. Survivable information storage systems. IEEE Computer 33:61-6 8, August 2000.\nT.-Y. Yeh and Y. N. Patt. Alternative implemen­ tation of two-level adaptive branch prediction. In Proceedings of the19th Annual International Symposium on Computer Architecture (!SCA), pages 451-461. ACM, 1998.\n推 荐 阅 读 - # n-\n严气 咱世rfflll\n.\n\u0026ldquo;-\u0026rsquo; cOMPUTER \u0026rsquo; · ORGANIZATIO '\nAND DESIG\n. ,,, I \u0026rsquo;''\n\u0026lsquo;I,, .·\n,, ..\n1,,\u0026rsquo;\n,, If/•;.\n计算机组成与设计: 硬件/软件接(口原书第 5 版) 计算机组咸与设计: 硬件/软件接(口英文饭 ·第5版·亚洲饭) # 作者 戴维A. 帕特森等\nISBN, 978- 7- 111- 50482- 5 定 价 99.00 元\n作 者 David A. Patterson\nISBN: 978-7-111-45316-1 定 价 139.00 元\n# 计算机体系结构: 量化研究方法(英文版,第5版) 计算机系统:系统架构与操作系统的高度纂成\n- . . . ..\n作者JohnL.Henne等ssy\n作者 阿麦肯尚尔·拉姆阿堪德兰 等\nISBN: 978- 7- 111- 36458- 0 定 价 138.00元 ISBN 978- 7- 111- 50636- 2 定 价 99.00 元\n如何使用本书\n从程序员的角度来学习计算机系统是如何工作的会非常有趣。最理想的学习方法是在真正的系统上解决具体的问题,或是编写和运行程序。这个主题观念贯穿本书始终。因此我们建议你用如下方式学习这本书:\n.学习一个新概念时,你应 该立刻做一做紧随其后的一个或多个练习题来检验你的理解。这些练习题的解答在每章的末尾。要先尝试自己来解答每个问题, 然后再查阅答案。\n每一章后都有一组难度不同的作业题,这些题目需要的时间从十几分钟到十几个小时,但建议你尝试完成这些作业题,完成之后你会发现对系统的理解更加深入。 本书中有丰富的代码示例, 鼓励你在系统上运行这些示例的源代码。 我们邀请国内名师录制了本书的导读,从中你可以了解各章的重点内容和知识关联,形成关千计算机系统的知识架构。 向老师或他人请教和交流是很好的学习方式 。我们将不定期组织线上线下的学习活动, 你可以登录本书网络社区及时了解活动的信息,井与学习本书的其他读者交流、讨论。 为帮助读者更好地学习本书,我们开设了本书的网络社区,请扫描如下二维码或登录 http://www. hzmedia.com .cn/e/jsj 加入社区,获 得本书相关学习资源,了 解活动信息。\n深入理解计算机系统 "},{"id":450,"href":"/zh/docs/technology/Interview/cs-basics/network/computer-network-xiexiren-summary/","title":"《计算机网络》(谢希仁)内容总结","section":"Network","content":"本文是我在大二学习计算机网络期间整理, 大部分内容都来自于谢希仁老师的 《计算机网络》第七版这本书。为了内容更容易理解,我对之前的整理进行了一波重构,并配上了一些相关的示意图便于理解。\n相关问题: 如何评价谢希仁的计算机网络(第七版)? - 知乎 。\n1. 计算机网络概述 # 1.1. 基本术语 # 结点 (node):网络中的结点可以是计算机,集线器,交换机或路由器等。\n链路(link ) : 从一个结点到另一个结点的一段物理线路。中间没有任何其他交点。\n主机(host):连接在因特网上的计算机。\nISP(Internet Service Provider):因特网服务提供者(提供商)。\nIXP(Internet eXchange Point):互联网交换点 IXP 的主要作用就是允许两个网络直接相连并交换分组,而不需要再通过第三个网络来转发分组。\nhttps://labs.ripe.net/Members/fergalc/ixp-traffic-during-stratos-skydive\nRFC(Request For Comments):意思是“请求评议”,包含了关于 Internet 几乎所有的重要的文字资料。\n广域网 WAN(Wide Area Network):任务是通过长距离运送主机发送的数据。\n城域网 MAN(Metropolitan Area Network):用来将多个局域网进行互连。\n局域网 LAN(Local Area Network):学校或企业大多拥有多个互连的局域网。\nhttp://conexionesmanwman.blogspot.com/\n个人区域网 PAN(Personal Area Network):在个人工作的地方把属于个人使用的电子设备用无线技术连接起来的网络 。\nhttps://www.itrelease.com/2018/07/advantages-and-disadvantages-of-personal-area-network-pan/\n分组(packet ):因特网中传送的数据单元。由首部 header 和数据段组成。分组又称为包,首部可称为包头。\n存储转发(store and forward ):路由器收到一个分组,先检查分组是否正确,并过滤掉冲突包错误。确定包正确后,取出目的地址,通过查找表找到想要发送的输出端口地址,然后将该包发送出去。\n带宽(bandwidth):在计算机网络中,表示在单位时间内从网络中的某一点到另一点所能通过的“最高数据率”。常用来表示网络的通信线路所能传送数据的能力。单位是“比特每秒”,记为 b/s。\n吞吐量(throughput ):表示在单位时间内通过某个网络(或信道、接口)的数据量。吞吐量更经常地用于对现实世界中的网络的一种测量,以便知道实际上到底有多少数据量能够通过网络。吞吐量受网络的带宽或网络的额定速率的限制。\n1.2. 重要知识点总结 # 计算机网络(简称网络)把许多计算机连接在一起,而互联网把许多网络连接在一起,是网络的网络。 小写字母 i 开头的 internet(互联网)是通用名词,它泛指由多个计算机网络相互连接而成的网络。在这些网络之间的通信协议(即通信规则)可以是任意的。大写字母 I 开头的 Internet(互联网)是专用名词,它指全球最大的,开放的,由众多网络相互连接而成的特定的互联网,并采用 TCP/IP 协议作为通信规则,其前身为 ARPANET。Internet 的推荐译名为因特网,现在一般流行称为互联网。 路由器是实现分组交换的关键构件,其任务是转发收到的分组,这是网络核心部分最重要的功能。分组交换采用存储转发技术,表示把一个报文(要发送的整块数据)分为几个分组后再进行传送。在发送报文之前,先把较长的报文划分成为一个个更小的等长数据段。在每个数据段的前面加上一些由必要的控制信息组成的首部后,就构成了一个分组。分组又称为包。分组是在互联网中传送的数据单元,正是由于分组的头部包含了诸如目的地址和源地址等重要控制信息,每一个分组才能在互联网中独立的选择传输路径,并正确地交付到分组传输的终点。 互联网按工作方式可划分为边缘部分和核心部分。主机在网络的边缘部分,其作用是进行信息处理。由大量网络和连接这些网络的路由器组成核心部分,其作用是提供连通性和交换。 计算机通信是计算机中进程(即运行着的程序)之间的通信。计算机网络采用的通信方式是客户-服务器方式(C/S 方式)和对等连接方式(P2P 方式)。 客户和服务器都是指通信中所涉及的应用进程。客户是服务请求方,服务器是服务提供方。 按照作用范围的不同,计算机网络分为广域网 WAN,城域网 MAN,局域网 LAN,个人区域网 PAN。 计算机网络最常用的性能指标是:速率,带宽,吞吐量,时延(发送时延,处理时延,排队时延),时延带宽积,往返时间和信道利用率。 网络协议即协议,是为进行网络中的数据交换而建立的规则。计算机网络的各层以及其协议集合,称为网络的体系结构。 五层体系结构由应用层,运输层,网络层(网际层),数据链路层,物理层组成。运输层最主要的协议是 TCP 和 UDP 协议,网络层最重要的协议是 IP 协议。 下面的内容会介绍计算机网络的五层体系结构:物理层+数据链路层+网络层(网际层)+运输层+应用层。\n2. 物理层(Physical Layer) # 2.1. 基本术语 # 数据(data):运送消息的实体。\n信号(signal):数据的电气的或电磁的表现。或者说信号是适合在传输介质上传输的对象。\n码元( code):在使用时间域(或简称为时域)的波形来表示数字信号时,代表不同离散数值的基本波形。\n单工(simplex ):只能有一个方向的通信而没有反方向的交互。\n半双工(half duplex ):通信的双方都可以发送信息,但不能双方同时发送(当然也就不能同时接收)。\n全双工(full duplex):通信的双方可以同时发送和接收信息。\n失真:失去真实性,主要是指接受到的信号和发送的信号不同,有磨损和衰减。影响失真程度的因素:1.码元传输速率 2.信号传输距离 3.噪声干扰 4.传输媒体质量\n奈氏准则:在任何信道中,码元的传输的效率是有上限的,传输速率超过此上限,就会出现严重的码间串扰问题,使接收端对码元的判决(即识别)成为不可能。\n香农定理:在带宽受限且有噪声的信道中,为了不产生误差,信息的数据传输速率有上限值。\n基带信号(baseband signal):来自信源的信号。指没有经过调制的数字信号或模拟信号。\n带通(频带)信号(bandpass signal):把基带信号经过载波调制后,把信号的频率范围搬移到较高的频段以便在信道中传输(即仅在一段频率范围内能够通过信道),这里调制过后的信号就是带通信号。\n调制(modulation ):对信号源的信息进行处理后加到载波信号上,使其变为适合在信道传输的形式的过程。\n信噪比(signal-to-noise ratio ):指信号的平均功率和噪声的平均功率之比,记为 S/N。信噪比(dB)=10*log10(S/N)。\n信道复用(channel multiplexing ):指多个用户共享同一个信道。(并不一定是同时)。\n比特率(bit rate ):单位时间(每秒)内传送的比特数。\n波特率(baud rate):单位时间载波调制状态改变的次数。针对数据信号对载波的调制速率。\n复用(multiplexing):共享信道的方法。\nADSL(Asymmetric Digital Subscriber Line ):非对称数字用户线。\n光纤同轴混合网(HFC 网):在目前覆盖范围很广的有线电视网的基础上开发的一种居民宽带接入网\n2.2. 重要知识点总结 # 物理层的主要任务就是确定与传输媒体接口有关的一些特性,如机械特性,电气特性,功能特性,过程特性。 一个数据通信系统可划分为三大部分,即源系统,传输系统,目的系统。源系统包括源点(或源站,信源)和发送器,目的系统包括接收器和终点。 通信的目的是传送消息。如话音,文字,图像等都是消息,数据是运送消息的实体。信号则是数据的电气或电磁的表现。 根据信号中代表消息的参数的取值方式不同,信号可分为模拟信号(或连续信号)和数字信号(或离散信号)。在使用时间域(简称时域)的波形表示数字信号时,代表不同离散数值的基本波形称为码元。 根据双方信息交互的方式,通信可划分为单向通信(或单工通信),双向交替通信(或半双工通信),双向同时通信(全双工通信)。 来自信源的信号称为基带信号。信号要在信道上传输就要经过调制。调制有基带调制和带通调制之分。最基本的带通调制方法有调幅,调频和调相。还有更复杂的调制方法,如正交振幅调制。 要提高数据在信道上的传递速率,可以使用更好的传输媒体,或使用先进的调制技术。但数据传输速率不可能任意被提高。 传输媒体可分为两大类,即导引型传输媒体(双绞线,同轴电缆,光纤)和非导引型传输媒体(无线,红外,大气激光)。 为了有效利用光纤资源,在光纤干线和用户之间广泛使用无源光网络 PON。无源光网络无需配备电源,其长期运营成本和管理成本都很低。最流行的无源光网络是以太网无源光网络 EPON 和吉比特无源光网络 GPON。 2.3. 补充 # 2.3.1. 物理层主要做啥? # 物理层主要做的事情就是 透明地传送比特流。也可以将物理层的主要任务描述为确定与传输媒体的接口的一些特性,即:机械特性(接口所用接线器的一些物理属性如形状和尺寸),电气特性(接口电缆的各条线上出现的电压的范围),功能特性(某条线上出现的某一电平的电压的意义),过程特性(对于不同功能的各种可能事件的出现顺序)。\n物理层考虑的是怎样才能在连接各种计算机的传输媒体上传输数据比特流,而不是指具体的传输媒体。 现有的计算机网络中的硬件设备和传输媒体的种类非常繁多,而且通信手段也有许多不同的方式。物理层的作用正是尽可能地屏蔽掉这些传输媒体和通信手段的差异,使物理层上面的数据链路层感觉不到这些差异,这样就可以使数据链路层只考虑完成本层的协议和服务,而不必考虑网络的具体传输媒体和通信手段是什么。\n2.3.2. 几种常用的信道复用技术 # 频分复用(FDM):所有用户在同样的时间占用不同的带宽资源。 时分复用(TDM):所有用户在不同的时间占用同样的频带宽度(分时不分频)。 统计时分复用 (Statistic TDM):改进的时分复用,能够明显提高信道的利用率。 码分复用(CDM):用户使用经过特殊挑选的不同码型,因此各用户之间不会造成干扰。这种系统发送的信号有很强的抗干扰能力,其频谱类似于白噪声,不易被敌人发现。 波分复用( WDM):波分复用就是光的频分复用。 2.3.3. 几种常用的宽带接入技术,主要是 ADSL 和 FTTx # 用户到互联网的宽带接入方法有非对称数字用户线 ADSL(用数字技术对现有的模拟电话线进行改造,而不需要重新布线。ADSL 的快速版本是甚高速数字用户线 VDSL。),光纤同轴混合网 HFC(是在目前覆盖范围很广的有线电视网的基础上开发的一种居民宽带接入网)和 FTTx(即光纤到······)。\n3. 数据链路层(Data Link Layer) # 3.1. 基本术语 # 链路(link):一个结点到相邻结点的一段物理链路。\n数据链路(data link):把实现控制数据运输的协议的硬件和软件加到链路上就构成了数据链路。\n循环冗余检验 CRC(Cyclic Redundancy Check):为了保证数据传输的可靠性,CRC 是数据链路层广泛使用的一种检错技术。\n帧(frame):一个数据链路层的传输单元,由一个数据链路层首部和其携带的封包所组成协议数据单元。\nMTU(Maximum Transfer Uint ):最大传送单元。帧的数据部分的的长度上限。\n误码率 BER(Bit Error Rate ):在一段时间内,传输错误的比特占所传输比特总数的比率。\nPPP(Point-to-Point Protocol ):点对点协议。即用户计算机和 ISP 进行通信时所使用的数据链路层协议。以下是 PPP 帧的示意图: MAC 地址(Media Access Control 或者 Medium Access Control):意译为媒体访问控制,或称为物理地址、硬件地址,用来定义网络设备的位置。在 OSI 模型中,第三层网络层负责 IP 地址,第二层数据链路层则负责 MAC 地址。因此一个主机会有一个 MAC 地址,而每个网络位置会有一个专属于它的 IP 地址 。地址是识别某个系统的重要标识符,“名字指出我们所要寻找的资源,地址指出资源所在的地方,路由告诉我们如何到达该处。”\n网桥(bridge):一种用于数据链路层实现中继,连接两个或多个局域网的网络互连设备。\n交换机(switch ):广义的来说,交换机指的是一种通信系统中完成信息交换的设备。这里工作在数据链路层的交换机指的是交换式集线器,其实质是一个多接口的网桥\n3.2. 重要知识点总结 # 链路是从一个结点到相邻结点的一段物理链路,数据链路则在链路的基础上增加了一些必要的硬件(如网络适配器)和软件(如协议的实现) 数据链路层使用的主要是点对点信道和广播信道两种。 数据链路层传输的协议数据单元是帧。数据链路层的三个基本问题是:封装成帧,透明传输和差错检测 循环冗余检验 CRC 是一种检错方法,而帧检验序列 FCS 是添加在数据后面的冗余码 点对点协议 PPP 是数据链路层使用最多的一种协议,它的特点是:简单,只检测差错而不去纠正差错,不使用序号,也不进行流量控制,可同时支持多种网络层协议 PPPoE 是为宽带上网的主机使用的链路层协议 局域网的优点是:具有广播功能,从一个站点可方便地访问全网;便于系统的扩展和逐渐演变;提高了系统的可靠性,可用性和生存性。 计算机与外接局域网通信需要通过通信适配器(或网络适配器),它又称为网络接口卡或网卡。计算器的硬件地址就在适配器的 ROM 中。 以太网采用的无连接的工作方式,对发送的数据帧不进行编号,也不要求对方发回确认。目的站收到有差错帧就把它丢掉,其他什么也不做 以太网采用的协议是具有冲突检测的载波监听多点接入 CSMA/CD。协议的特点是:发送前先监听,边发送边监听,一旦发现总线上出现了碰撞,就立即停止发送。然后按照退避算法等待一段随机时间后再次发送。 因此,每一个站点在自己发送数据之后的一小段时间内,存在着遭遇碰撞的可能性。以太网上的各站点平等地争用以太网信道 以太网的适配器具有过滤功能,它只接收单播帧,广播帧和多播帧。 使用集线器可以在物理层扩展以太网(扩展后的以太网仍然是一个网络) 3.3. 补充 # 数据链路层的点对点信道和广播信道的特点,以及这两种信道所使用的协议(PPP 协议以及 CSMA/CD 协议)的特点 数据链路层的三个基本问题:封装成帧,透明传输,差错检测 以太网的 MAC 层硬件地址 适配器,转发器,集线器,网桥,以太网交换机的作用以及适用场合 4. 网络层(Network Layer) # 4.1. 基本术语 # 虚电路(Virtual Circuit) : 在两个终端设备的逻辑或物理端口之间,通过建立的双向的透明传输通道。虚电路表示这只是一条逻辑上的连接,分组都沿着这条逻辑连接按照存储转发方式传送,而并不是真正建立了一条物理连接。 IP(Internet Protocol ) : 网际协议 IP 是 TCP/IP 体系中两个最主要的协议之一,是 TCP/IP 体系结构网际层的核心。配套的有 ARP,RARP,ICMP,IGMP。 ARP(Address Resolution Protocol) : 地址解析协议。地址解析协议 ARP 把 IP 地址解析为硬件地址。 ICMP(Internet Control Message Protocol ):网际控制报文协议 (ICMP 允许主机或路由器报告差错情况和提供有关异常情况的报告)。 子网掩码(subnet mask ):它是一种用来指明一个 IP 地址的哪些位标识的是主机所在的子网以及哪些位标识的是主机的位掩码。子网掩码不能单独存在,它必须结合 IP 地址一起使用。 CIDR( Classless Inter-Domain Routing ):无分类域间路由选择 (特点是消除了传统的 A 类、B 类和 C 类地址以及划分子网的概念,并使用各种长度的“网络前缀”(network-prefix)来代替分类地址中的网络号和子网号)。 默认路由(default route):当在路由表中查不到能到达目的地址的路由时,路由器选择的路由。默认路由还可以减小路由表所占用的空间和搜索路由表所用的时间。 路由选择算法(Virtual Circuit):路由选择协议的核心部分。因特网采用自适应的,分层次的路由选择协议。 4.2. 重要知识点总结 # TCP/IP 协议中的网络层向上只提供简单灵活的,无连接的,尽最大努力交付的数据报服务。网络层不提供服务质量的承诺,不保证分组交付的时限,所传送的分组可能出错、丢失、重复和失序。进程之间通信的可靠性由运输层负责 在互联网的交付有两种,一是在本网络直接交付不用经过路由器,另一种是和其他网络的间接交付,至少经过一个路由器,但最后一次一定是直接交付 分类的 IP 地址由网络号字段(指明网络)和主机号字段(指明主机)组成。网络号字段最前面的类别指明 IP 地址的类别。IP 地址是一种分等级的地址结构。IP 地址管理机构分配 IP 地址时只分配网络号,主机号由得到该网络号的单位自行分配。路由器根据目的主机所连接的网络号来转发分组。一个路由器至少连接到两个网络,所以一个路由器至少应当有两个不同的 IP 地址 IP 数据报分为首部和数据两部分。首部的前一部分是固定长度,共 20 字节,是所有 IP 数据包必须具有的(源地址,目的地址,总长度等重要地段都固定在首部)。一些长度可变的可选字段固定在首部的后面。IP 首部中的生存时间给出了 IP 数据报在互联网中所能经过的最大路由器数。可防止 IP 数据报在互联网中无限制的兜圈子。 地址解析协议 ARP 把 IP 地址解析为硬件地址。ARP 的高速缓存可以大大减少网络上的通信量。因为这样可以使主机下次再与同样地址的主机通信时,可以直接从高速缓存中找到所需要的硬件地址而不需要再去以广播方式发送 ARP 请求分组 无分类域间路由选择 CIDR 是解决目前 IP 地址紧缺的一个好办法。CIDR 记法在 IP 地址后面加上斜线“/”,然后写上前缀所占的位数。前缀(或网络前缀)用来指明网络,前缀后面的部分是后缀,用来指明主机。CIDR 把前缀都相同的连续的 IP 地址组成一个“CIDR 地址块”,IP 地址分配都以 CIDR 地址块为单位。 网际控制报文协议是 IP 层的协议。ICMP 报文作为 IP 数据报的数据,加上首部后组成 IP 数据报发送出去。使用 ICMP 数据报并不是为了实现可靠传输。ICMP 允许主机或路由器报告差错情况和提供有关异常情况的报告。ICMP 报文的种类有两种,即 ICMP 差错报告报文和 ICMP 询问报文。 要解决 IP 地址耗尽的问题,最根本的办法是采用具有更大地址空间的新版本 IP 协议-IPv6。 IPv6 所带来的变化有 ① 更大的地址空间(采用 128 位地址)② 灵活的首部格式 ③ 改进的选项 ④ 支持即插即用 ⑤ 支持资源的预分配 ⑥IPv6 的首部改为 8 字节对齐。 虚拟专用网络 VPN 利用公用的互联网作为本机构专用网之间的通信载体。VPN 内使用互联网的专用地址。一个 VPN 至少要有一个路由器具有合法的全球 IP 地址,这样才能和本系统的另一个 VPN 通过互联网进行通信。所有通过互联网传送的数据都需要加密。 MPLS 的特点是:① 支持面向连接的服务质量 ② 支持流量工程,平衡网络负载 ③ 有效的支持虚拟专用网 VPN。MPLS 在入口节点给每一个 IP 数据报打上固定长度的“标记”,然后根据标记在第二层(链路层)用硬件进行转发(在标记交换路由器中进行标记交换),因而转发速率大大加快。 5. 传输层(Transport Layer) # 5.1. 基本术语 # 进程(process):指计算机中正在运行的程序实体。\n应用进程互相通信:一台主机的进程和另一台主机中的一个进程交换数据的过程(另外注意通信真正的端点不是主机而是主机中的进程,也就是说端到端的通信是应用进程之间的通信)。\n传输层的复用与分用:复用指发送方不同的进程都可以通过同一个运输层协议传送数据。分用指接收方的运输层在剥去报文的首部后能把这些数据正确的交付到目的应用进程。\nTCP(Transmission Control Protocol):传输控制协议。\nUDP(User Datagram Protocol):用户数据报协议。\n端口(port):端口的目的是为了确认对方机器的哪个进程在与自己进行交互,比如 MSN 和 QQ 的端口不同,如果没有端口就可能出现 QQ 进程和 MSN 交互错误。端口又称协议端口号。\n停止等待协议(stop-and-wait):指发送方每发送完一个分组就停止发送,等待对方确认,在收到确认之后在发送下一个分组。\n流量控制 : 就是让发送方的发送速率不要太快,既要让接收方来得及接收,也不要使网络发生拥塞。\n拥塞控制:防止过多的数据注入到网络中,这样可以使网络中的路由器或链路不致过载。拥塞控制所要做的都有一个前提,就是网络能够承受现有的网络负荷。\n5.2. 重要知识点总结 # 运输层提供应用进程之间的逻辑通信,也就是说,运输层之间的通信并不是真正在两个运输层之间直接传输数据。运输层向应用层屏蔽了下面网络的细节(如网络拓补,所采用的路由选择协议等),它使应用进程之间看起来好像两个运输层实体之间有一条端到端的逻辑通信信道。 网络层为主机提供逻辑通信,而运输层为应用进程之间提供端到端的逻辑通信。 运输层的两个重要协议是用户数据报协议 UDP 和传输控制协议 TCP。按照 OSI 的术语,两个对等运输实体在通信时传送的数据单位叫做运输协议数据单元 TPDU(Transport Protocol Data Unit)。但在 TCP/IP 体系中,则根据所使用的协议是 TCP 或 UDP,分别称之为 TCP 报文段或 UDP 用户数据报。 UDP 在传送数据之前不需要先建立连接,远地主机在收到 UDP 报文后,不需要给出任何确认。虽然 UDP 不提供可靠交付,但在某些情况下 UDP 确是一种最有效的工作方式。 TCP 提供面向连接的服务。在传送数据之前必须先建立连接,数据传送结束后要释放连接。TCP 不提供广播或多播服务。由于 TCP 要提供可靠的,面向连接的传输服务,难以避免地增加了许多开销,如确认,流量控制,计时器以及连接管理等。这不仅使协议数据单元的首部增大很多,还要占用许多处理机资源。 硬件端口是不同硬件设备进行交互的接口,而软件端口是应用层各种协议进程与运输实体进行层间交互的一种地址。UDP 和 TCP 的首部格式中都有源端口和目的端口这两个重要字段。当运输层收到 IP 层交上来的运输层报文时,就能够根据其首部中的目的端口号把数据交付应用层的目的应用层。(两个进程之间进行通信不光要知道对方 IP 地址而且要知道对方的端口号(为了找到对方计算机中的应用进程)) 运输层用一个 16 位端口号标志一个端口。端口号只有本地意义,它只是为了标志计算机应用层中的各个进程在和运输层交互时的层间接口。在互联网的不同计算机中,相同的端口号是没有关联的。协议端口号简称端口。虽然通信的终点是应用进程,但只要把所发送的报文交到目的主机的某个合适端口,剩下的工作(最后交付目的进程)就由 TCP 和 UDP 来完成。 运输层的端口号分为服务器端使用的端口号(0˜1023 指派给熟知端口,1024˜49151 是登记端口号)和客户端暂时使用的端口号(49152˜65535) UDP 的主要特点是 ① 无连接 ② 尽最大努力交付 ③ 面向报文 ④ 无拥塞控制 ⑤ 支持一对一,一对多,多对一和多对多的交互通信 ⑥ 首部开销小(只有四个字段:源端口,目的端口,长度和检验和) TCP 的主要特点是 ① 面向连接 ② 每一条 TCP 连接只能是一对一的 ③ 提供可靠交付 ④ 提供全双工通信 ⑤ 面向字节流 TCP 用主机的 IP 地址加上主机上的端口号作为 TCP 连接的端点。这样的端点就叫做套接字(socket)或插口。套接字用(IP 地址:端口号)来表示。每一条 TCP 连接唯一地被通信两端的两个端点所确定。 停止等待协议是为了实现可靠传输的,它的基本原理就是每发完一个分组就停止发送,等待对方确认。在收到确认后再发下一个分组。 为了提高传输效率,发送方可以不使用低效率的停止等待协议,而是采用流水线传输。流水线传输就是发送方可连续发送多个分组,不必每发完一个分组就停下来等待对方确认。这样可使信道上一直有数据不间断的在传送。这种传输方式可以明显提高信道利用率。 停止等待协议中超时重传是指只要超过一段时间仍然没有收到确认,就重传前面发送过的分组(认为刚才发送过的分组丢失了)。因此每发送完一个分组需要设置一个超时计时器,其重传时间应比数据在分组传输的平均往返时间更长一些。这种自动重传方式常称为自动重传请求 ARQ。另外在停止等待协议中若收到重复分组,就丢弃该分组,但同时还要发送确认。连续 ARQ 协议可提高信道利用率。发送维持一个发送窗口,凡位于发送窗口内的分组可连续发送出去,而不需要等待对方确认。接收方一般采用累积确认,对按序到达的最后一个分组发送确认,表明到这个分组位置的所有分组都已经正确收到了。 TCP 报文段的前 20 个字节是固定的,其后有 40 字节长度的可选字段。如果加入可选字段后首部长度不是 4 的整数倍字节,需要在再在之后用 0 填充。因此,TCP 首部的长度取值为 20+4n 字节,最长为 60 字节。 TCP 使用滑动窗口机制。发送窗口里面的序号表示允许发送的序号。发送窗口后沿的后面部分表示已发送且已收到确认,而发送窗口前沿的前面部分表示不允许发送。发送窗口后沿的变化情况有两种可能,即不动(没有收到新的确认)和前移(收到了新的确认)。发送窗口的前沿通常是不断向前移动的。一般来说,我们总是希望数据传输更快一些。但如果发送方把数据发送的过快,接收方就可能来不及接收,这就会造成数据的丢失。所谓流量控制就是让发送方的发送速率不要太快,要让接收方来得及接收。 在某段时间,若对网络中某一资源的需求超过了该资源所能提供的可用部分,网络的性能就要变坏。这种情况就叫拥塞。拥塞控制就是为了防止过多的数据注入到网络中,这样就可以使网络中的路由器或链路不致过载。拥塞控制所要做的都有一个前提,就是网络能够承受现有的网络负荷。拥塞控制是一个全局性的过程,涉及到所有的主机,所有的路由器,以及与降低网络传输性能有关的所有因素。相反,流量控制往往是点对点通信量的控制,是个端到端的问题。流量控制所要做到的就是抑制发送端发送数据的速率,以便使接收端来得及接收。 为了进行拥塞控制,TCP 发送方要维持一个拥塞窗口 cwnd 的状态变量。拥塞控制窗口的大小取决于网络的拥塞程度,并且动态变化。发送方让自己的发送窗口取为拥塞窗口和接收方的接受窗口中较小的一个。 TCP 的拥塞控制采用了四种算法,即慢开始,拥塞避免,快重传和快恢复。在网络层也可以使路由器采用适当的分组丢弃策略(如主动队列管理 AQM),以减少网络拥塞的发生。 运输连接的三个阶段,即:连接建立,数据传送和连接释放。 主动发起 TCP 连接建立的应用进程叫做客户,而被动等待连接建立的应用进程叫做服务器。TCP 连接采用三报文握手机制。服务器要确认用户的连接请求,然后客户要对服务器的确认进行确认。 TCP 的连接释放采用四报文握手机制。任何一方都可以在数据传送结束后发出连接释放的通知,待对方确认后进入半关闭状态。当另一方也没有数据再发送时,则发送连接释放通知,对方确认后就完全关闭了 TCP 连接 5.3. 补充(重要) # 以下知识点需要重点关注:\n端口和套接字的意义 UDP 和 TCP 的区别以及两者的应用场景 在不可靠的网络上实现可靠传输的工作原理,停止等待协议和 ARQ 协议 TCP 的滑动窗口,流量控制,拥塞控制和连接管理 TCP 的三次握手,四次挥手机制 6. 应用层(Application Layer) # 6.1. 基本术语 # 域名系统(DNS):域名系统(DNS,Domain Name System)将人类可读的域名 (例如,www.baidu.com) 转换为机器可读的 IP 地址 (例如,220.181.38.148)。我们可以将其理解为专为互联网设计的电话薄。\nhttps://www.seobility.net/en/wiki/HTTP_headers\n文件传输协议(FTP):FTP 是 File Transfer Protocol(文件传输协议)的英文简称,而中文简称为“文传协议”。用于 Internet 上的控制文件的双向传输。同时,它也是一个应用程序(Application)。基于不同的操作系统有不同的 FTP 应用程序,而所有这些应用程序都遵守同一种协议以传输文件。在 FTP 的使用当中,用户经常遇到两个概念:\u0026ldquo;下载\u0026rdquo;(Download)和\u0026quot;上传\u0026quot;(Upload)。 \u0026ldquo;下载\u0026quot;文件就是从远程主机拷贝文件至自己的计算机上;\u0026ldquo;上传\u0026quot;文件就是将文件从自己的计算机中拷贝至远程主机上。用 Internet 语言来说,用户可通过客户机程序向(从)远程主机上传(下载)文件。\n简单文件传输协议(TFTP):TFTP(Trivial File Transfer Protocol,简单文件传输协议)是 TCP/IP 协议族中的一个用来在客户机与服务器之间进行简单文件传输的协议,提供不复杂、开销不大的文件传输服务。端口号为 69。\n远程终端协议(TELNET):Telnet 协议是 TCP/IP 协议族中的一员,是 Internet 远程登陆服务的标准协议和主要方式。它为用户提供了在本地计算机上完成远程主机工作的能力。在终端使用者的电脑上使用 telnet 程序,用它连接到服务器。终端使用者可以在 telnet 程序中输入命令,这些命令会在服务器上运行,就像直接在服务器的控制台上输入一样。可以在本地就能控制服务器。要开始一个 telnet 会话,必须输入用户名和密码来登录服务器。Telnet 是常用的远程控制 Web 服务器的方法。\n万维网(WWW):WWW 是环球信息网的缩写,(亦作“Web”、“WWW”、“\u0026lsquo;W3\u0026rsquo;”,英文全称为“World Wide Web”),中文名字为“万维网”,\u0026ldquo;环球网\u0026quot;等,常简称为 Web。分为 Web 客户端和 Web 服务器程序。WWW 可以让 Web 客户端(常用浏览器)访问浏览 Web 服务器上的页面。是一个由许多互相链接的超文本组成的系统,通过互联网访问。在这个系统中,每个有用的事物,称为一样“资源”;并且由一个全局“统一资源标识符”(URI)标识;这些资源通过超文本传输协议(Hypertext Transfer Protocol)传送给用户,而后者通过点击链接来获得资源。万维网联盟(英语:World Wide Web Consortium,简称 W3C),又称 W3C 理事会。1994 年 10 月在麻省理工学院(MIT)计算机科学实验室成立。万维网联盟的创建者是万维网的发明者蒂姆·伯纳斯-李。万维网并不等同互联网,万维网只是互联网所能提供的服务其中之一,是靠着互联网运行的一项服务。\n万维网的大致工作工程:\n统一资源定位符(URL):统一资源定位符是对可以从互联网上得到的资源的位置和访问方法的一种简洁的表示,是互联网上标准资源的地址。互联网上的每个文件都有一个唯一的 URL,它包含的信息指出文件的位置以及浏览器应该怎么处理它。\n超文本传输协议(HTTP):超文本传输协议(HTTP,HyperText Transfer Protocol)是互联网上应用最为广泛的一种网络协议。所有的 WWW 文件都必须遵守这个标准。设计 HTTP 最初的目的是为了提供一种发布和接收 HTML 页面的方法。1960 年美国人 Ted Nelson 构思了一种通过计算机处理文本信息的方法,并称之为超文本(hypertext),这成为了 HTTP 超文本传输协议标准架构的发展根基。\nHTTP 协议的本质就是一种浏览器与服务器之间约定好的通信格式。HTTP 的原理如下图所示:\n代理服务器(Proxy Server):代理服务器(Proxy Server)是一种网络实体,它又称为万维网高速缓存。 代理服务器把最近的一些请求和响应暂存在本地磁盘中。当新请求到达时,若代理服务器发现这个请求与暂时存放的的请求相同,就返回暂存的响应,而不需要按 URL 的地址再次去互联网访问该资源。代理服务器可在客户端或服务器工作,也可以在中间系统工作。\n简单邮件传输协议(SMTP) : SMTP(Simple Mail Transfer Protocol)即简单邮件传输协议,它是一组用于由源地址到目的地址传送邮件的规则,由它来控制信件的中转方式。 SMTP 协议属于 TCP/IP 协议簇,它帮助每台计算机在发送或中转信件时找到下一个目的地。 通过 SMTP 协议所指定的服务器,就可以把 E-mail 寄到收信人的服务器上了,整个过程只要几分钟。SMTP 服务器则是遵循 SMTP 协议的发送邮件服务器,用来发送或中转发出的电子邮件。\nhttps://www.campaignmonitor.com/resources/knowledge-base/what-is-the-code-that-makes-bcc-or-cc-operate-in-an-email/\n搜索引擎 :搜索引擎(Search Engine)是指根据一定的策略、运用特定的计算机程序从互联网上搜集信息,在对信息进行组织和处理后,为用户提供检索服务,将用户检索相关的信息展示给用户的系统。搜索引擎包括全文索引、目录索引、元搜索引擎、垂直搜索引擎、集合式搜索引擎、门户搜索引擎与免费链接列表等。\n垂直搜索引擎:垂直搜索引擎是针对某一个行业的专业搜索引擎,是搜索引擎的细分和延伸,是对网页库中的某类专门的信息进行一次整合,定向分字段抽取出需要的数据进行处理后再以某种形式返回给用户。垂直搜索是相对通用搜索引擎的信息量大、查询不准确、深度不够等提出来的新的搜索引擎服务模式,通过针对某一特定领域、某一特定人群或某一特定需求提供的有一定价值的信息和相关服务。其特点就是“专、精、深”,且具有行业色彩,相比较通用搜索引擎的海量信息无序化,垂直搜索引擎则显得更加专注、具体和深入。\n全文索引 :全文索引技术是目前搜索引擎的关键技术。试想在 1M 大小的文件中搜索一个词,可能需要几秒,在 100M 的文件中可能需要几十秒,如果在更大的文件中搜索那么就需要更大的系统开销,这样的开销是不现实的。所以在这样的矛盾下出现了全文索引技术,有时候有人叫倒排文档技术。\n目录索引:目录索引( search index/directory),顾名思义就是将网站分门别类地存放在相应的目录中,因此用户在查询信息时,可选择关键词搜索,也可按分类目录逐层查找。\n6.2. 重要知识点总结 # 文件传输协议(FTP)使用 TCP 可靠的运输服务。FTP 使用客户服务器方式。一个 FTP 服务器进程可以同时为多个用户提供服务。在进行文件传输时,FTP 的客户和服务器之间要先建立两个并行的 TCP 连接:控制连接和数据连接。实际用于传输文件的是数据连接。 万维网客户程序与服务器之间进行交互使用的协议是超文本传输协议 HTTP。HTTP 使用 TCP 连接进行可靠传输。但 HTTP 本身是无连接、无状态的。HTTP/1.1 协议使用了持续连接(分为非流水线方式和流水线方式) 电子邮件把邮件发送到收件人使用的邮件服务器,并放在其中的收件人邮箱中,收件人可随时上网到自己使用的邮件服务器读取,相当于电子邮箱。 一个电子邮件系统有三个重要组成构件:用户代理、邮件服务器、邮件协议(包括邮件发送协议,如 SMTP,和邮件读取协议,如 POP3 和 IMAP)。用户代理和邮件服务器都要运行这些协议。 6.3. 补充(重要) # 以下知识点需要重点关注:\n应用层的常见协议(重点关注 HTTP 协议) 域名系统-从域名解析出 IP 地址 访问一个网站大致的过程 系统调用和应用编程接口概念 "},{"id":451,"href":"/zh/docs/technology/Interview/high-quality-technical-articles/work/32-tips-improving-career/","title":"32条总结教你提升职场经验","section":"Work","content":" 推荐语:阿里开发者的一篇职场经验的分享。\n原文地址: https://mp.weixin.qq.com/s/6BkbGekSRTadm9j7XUL13g\n成长的捷径 # 入职伊始谦逊的态度是好的,但不要把“我是新人”作为心理安全线; 写一篇技术博客大概需要两周左右,但可能是最快的成长方式; 一定要读两本书:金字塔原理、高效能人士的七个习惯(这本书名字像成功学,实际讲的是如何塑造性格); 多问是什么、为什么,追本溯源把问题解决掉,试图绕过的问题永远会在下个路口等着你; 不要沉迷于忙碌带来的虚假安全感中,目标的确定和追逐才是最真实的安全; 不用过于计较一时的得失,在公平的环境中,吃亏是福不是鸡汤; 思维和技能不要受限于前端、后端、测试等角色,把自己定位成业务域问题的终结者; 好奇和热爱是成长最大的捷径,长期主义者会认同自己的工作价值,甚至要高于组织当下给的认同(KPI)。 功夫在日常 # 每行代码要代表自己当下的最高水平,你觉得无所谓的小细节,有可能就是在晋升场上伤害你的暗箭; 双周报不是工作日志流水账,不要被时间推着走,最起码要知道下次双周报里会有什么(小目标驱动); 觉得日常都是琐碎工作、不技术、给师兄打杂等,可以尝试对手头事情做一下分类,想象成每个分类都是个小格子,这些格子连起来的终点就是自己的目标,这样每天不再是机械的做需求,而是有规划的填格子、为目标努力,甚至会给自己加需求,因为自己看清楚了要去哪里; 日常的言行举止是能力的显微镜,大部分人可能意识不到,自己的强大和虚弱是那么的明显,不要无谓的试图掩盖,更不存在蒙混过关。 最后一条大概意思就是有时候我们会在意自己在聚光灯下(述职、晋升、周报、汇报等)的表现,以为大家会根据这个评价自己。实际上日常是怎么完成业务需求、帮助身边同学、创造价值的,才是大家评价自己的依据,而且每个人是什么样的特质,合作过三次的伙伴就可以精准评价,在聚光灯下的表演只能骗自己。\n学会被管理 # 上级、主管是泛指,开发对口的 PD 主管等也在范围内。\n不要传播负面情绪,不要总是抱怨;\n对上级不卑不亢更容易获得尊重,但不要当众反驳对方观点,分歧私下沟通;\n好好做向上管理,尤其是对齐预期,沟通绩效出现 Surprise 双方其实都有责任,但倒霉的是自己;\n尽量站在主管角度想问题:\n这样能理解很多过去感觉匪夷所思的决策; 不要在意谁执行、功劳是谁的等,为团队分忧赢得主管信任的重要性远远高于这些; 不要把这个原则理解为唯上,这种最让人不齿。 思维转换 # 定义问题是个高阶能力,尽早形成 发现问题-\u0026gt;定义问题-\u0026gt;解决问题-\u0026gt;消灭问题 的思维闭环; 定事情价值导向,做事情结果导向,讲事情问题导向; 讲不清楚,大概率不是因为自己是实干型,而是没想清楚,在晋升场更加明显; 当一个人擅长解决某一场景的问题的时候,时间越久也许越离不开这个场景(被人贴上一个标签很难,撕掉一个标签更难)。 要栓住情绪 # 学会控制情绪,没人会认真听一个愤怒的人在说什么; 再委屈、再愤怒也要保持理智,不要让自己成为需要被哄着的那种人; 足够自信的人才会坦率的承认自己的问题,很多时候我们被激怒了,只是因为对方指出了自己藏在深处的自卑; 伤害我们最深的既不是别人的所作所为,也不是自己犯的错误,而是我们对错误的回应。 成为 Leader # Manager 有下属,Leader 有追随者,管理者不需要很多,但人人都可以是 Leader。\n让你信服、愿意追随的人不是职务上的 Manager,而是在帮助自己的那个人,自己想服众的话道理一样; 不要轻易对人做负面评价,片面认知下的评价可能不准确,不经意的传播更是会给对方带来极大的困扰; Leader 如果不认同公司的使命、愿景、价值观,会过的特别痛苦; 困难时候不要否定自己的队友,多给及时、正向的反馈; 船长最重要的事情不是造船,而是激发水手对大海的向往; Leader 的天然职责是让团队活下去,唯一的途径是实现上级、老板、公司经营者的目标,越是艰难的时候越明显; Leader 的重要职责是识别团队需要被做的事情,并坚定信念,使众人行,越是艰难的时候越要坚定; Leader 应该让自己遇到的每个人都感觉自己很重要、被需要。 "},{"id":452,"href":"/zh/docs/technology/Interview/database/redis/3-commonly-used-cache-read-and-write-strategies/","title":"3种常用的缓存读写策略详解","section":"Redis","content":"看到很多小伙伴简历上写了“熟练使用缓存”,但是被我问到“缓存常用的 3 种读写策略”的时候却一脸懵逼。\n在我看来,造成这个问题的原因是我们在学习 Redis 的时候,可能只是简单写了一些 Demo,并没有去关注缓存的读写策略,或者说压根不知道这回事。\n但是,搞懂 3 种常见的缓存读写策略对于实际工作中使用缓存以及面试中被问到缓存都是非常有帮助的!\n下面介绍到的三种模式各有优劣,不存在最佳模式,根据具体的业务场景选择适合自己的缓存读写模式。\nCache Aside Pattern(旁路缓存模式) # Cache Aside Pattern 是我们平时使用比较多的一个缓存读写模式,比较适合读请求比较多的场景。\nCache Aside Pattern 中服务端需要同时维系 db 和 cache,并且是以 db 的结果为准。\n下面我们来看一下这个策略模式下的缓存读写步骤。\n写:\n先更新 db 然后直接删除 cache 。 简单画了一张图帮助大家理解写的步骤。\n读 :\n从 cache 中读取数据,读取到就直接返回 cache 中读取不到的话,就从 db 中读取数据返回 再把数据放到 cache 中。 简单画了一张图帮助大家理解读的步骤。\n你仅仅了解了上面这些内容的话是远远不够的,我们还要搞懂其中的原理。\n比如说面试官很可能会追问:“在写数据的过程中,可以先删除 cache ,后更新 db 么?”\n答案: 那肯定是不行的!因为这样可能会造成 数据库(db)和缓存(Cache)数据不一致的问题。\n举例:请求 1 先写数据 A,请求 2 随后读数据 A 的话,就很有可能产生数据不一致性的问题。\n这个过程可以简单描述为:\n请求 1 先把 cache 中的 A 数据删除 -\u0026gt; 请求 2 从 db 中读取数据-\u0026gt;请求 1 再把 db 中的 A 数据更新\n当你这样回答之后,面试官可能会紧接着就追问:“在写数据的过程中,先更新 db,后删除 cache 就没有问题了么?”\n答案: 理论上来说还是可能会出现数据不一致性的问题,不过概率非常小,因为缓存的写入速度是比数据库的写入速度快很多。\n举例:请求 1 先读数据 A,请求 2 随后写数据 A,并且数据 A 在请求 1 请求之前不在缓存中的话,也有可能产生数据不一致性的问题。\n这个过程可以简单描述为:\n请求 1 从 db 读数据 A-\u0026gt; 请求 2 更新 db 中的数据 A(此时缓存中无数据 A ,故不用执行删除缓存操作 ) -\u0026gt; 请求 1 将数据 A 写入 cache\n现在我们再来分析一下 Cache Aside Pattern 的缺陷。\n缺陷 1:首次请求数据一定不在 cache 的问题\n解决办法:可以将热点数据可以提前放入 cache 中。\n缺陷 2:写操作比较频繁的话导致 cache 中的数据会被频繁被删除,这样会影响缓存命中率 。\n解决办法:\n数据库和缓存数据强一致场景:更新 db 的时候同样更新 cache,不过我们需要加一个锁/分布式锁来保证更新 cache 的时候不存在线程安全问题。 可以短暂地允许数据库和缓存数据不一致的场景:更新 db 的时候同样更新 cache,但是给缓存加一个比较短的过期时间,这样的话就可以保证即使数据不一致的话影响也比较小。 Read/Write Through Pattern(读写穿透) # Read/Write Through Pattern 中服务端把 cache 视为主要数据存储,从中读取数据并将数据写入其中。cache 服务负责将此数据读取和写入 db,从而减轻了应用程序的职责。\n这种缓存读写策略小伙伴们应该也发现了在平时在开发过程中非常少见。抛去性能方面的影响,大概率是因为我们经常使用的分布式缓存 Redis 并没有提供 cache 将数据写入 db 的功能。\n写(Write Through):\n先查 cache,cache 中不存在,直接更新 db。 cache 中存在,则先更新 cache,然后 cache 服务自己更新 db(同步更新 cache 和 db)。 简单画了一张图帮助大家理解写的步骤。\n读(Read Through):\n从 cache 中读取数据,读取到就直接返回 。 读取不到的话,先从 db 加载,写入到 cache 后返回响应。 简单画了一张图帮助大家理解读的步骤。\nRead-Through Pattern 实际只是在 Cache-Aside Pattern 之上进行了封装。在 Cache-Aside Pattern 下,发生读请求的时候,如果 cache 中不存在对应的数据,是由客户端自己负责把数据写入 cache,而 Read Through Pattern 则是 cache 服务自己来写入缓存的,这对客户端是透明的。\n和 Cache Aside Pattern 一样, Read-Through Pattern 也有首次请求数据一定不再 cache 的问题,对于热点数据可以提前放入缓存中。\nWrite Behind Pattern(异步缓存写入) # Write Behind Pattern 和 Read/Write Through Pattern 很相似,两者都是由 cache 服务来负责 cache 和 db 的读写。\n但是,两个又有很大的不同:Read/Write Through 是同步更新 cache 和 db,而 Write Behind 则是只更新缓存,不直接更新 db,而是改为异步批量的方式来更新 db。\n很明显,这种方式对数据一致性带来了更大的挑战,比如 cache 数据可能还没异步更新 db 的话,cache 服务可能就就挂掉了。\n这种策略在我们平时开发过程中也非常非常少见,但是不代表它的应用场景少,比如消息队列中消息的异步写入磁盘、MySQL 的 Innodb Buffer Pool 机制都用到了这种策略。\nWrite Behind Pattern 下 db 的写性能非常高,非常适合一些数据经常变化又对数据一致性要求没那么高的场景,比如浏览量、点赞量。\n"},{"id":453,"href":"/zh/docs/technology/Interview/distributed-system/api-gateway/","title":"API网关基础知识总结","section":"Distributed System","content":" 什么是网关? # 微服务背景下,一个系统被拆分为多个服务,但是像安全认证,流量控制,日志,监控等功能是每个服务都需要的,没有网关的话,我们就需要在每个服务中单独实现,这使得我们做了很多重复的事情并且没有一个全局的视图来统一管理这些功能。\n一般情况下,网关可以为我们提供请求转发、安全认证(身份/权限认证)、流量控制、负载均衡、降级熔断、日志、监控、参数校验、协议转换等功能。\n上面介绍了这么多功能,实际上,网关主要做了两件事情:请求转发 + 请求过滤。\n由于引入网关之后,会多一步网络转发,因此性能会有一点影响(几乎可以忽略不计,尤其是内网访问的情况下)。 另外,我们需要保障网关服务的高可用,避免单点风险。\n如下图所示,网关服务外层通过 Nginx(其他负载均衡设备/软件也行) 进⾏负载转发以达到⾼可⽤。Nginx 在部署的时候,尽量也要考虑高可用,避免单点风险。\n网关能提供哪些功能? # 绝大部分网关可以提供下面这些功能(有一些功能需要借助其他框架或者中间件):\n请求转发:将请求转发到目标微服务。 负载均衡:根据各个微服务实例的负载情况或者具体的负载均衡策略配置对请求实现动态的负载均衡。 安全认证:对用户请求进行身份验证并仅允许可信客户端访问 API,并且还能够使用类似 RBAC 等方式来授权。 参数校验:支持参数映射与校验逻辑。 日志记录:记录所有请求的行为日志供后续使用。 监控告警:从业务指标、机器指标、JVM 指标等方面进行监控并提供配套的告警机制。 流量控制:对请求的流量进行控制,也就是限制某一时刻内的请求数。 熔断降级:实时监控请求的统计信息,达到配置的失败阈值后,自动熔断,返回默认值。 响应缓存:当用户请求获取的是一些静态的或更新不频繁的数据时,一段时间内多次请求获取到的数据很可能是一样的。对于这种情况可以将响应缓存起来。这样用户请求可以直接在网关层得到响应数据,无需再去访问业务服务,减轻业务服务的负担。 响应聚合:某些情况下用户请求要获取的响应内容可能会来自于多个业务服务。网关作为业务服务的调用方,可以把多个服务的响应整合起来,再一并返回给用户。 灰度发布:将请求动态分流到不同的服务版本(最基本的一种灰度发布)。 异常处理:对于业务服务返回的异常响应,可以在网关层在返回给用户之前做转换处理。这样可以把一些业务侧返回的异常细节隐藏,转换成用户友好的错误提示返回。 API 文档: 如果计划将 API 暴露给组织以外的开发人员,那么必须考虑使用 API 文档,例如 Swagger 或 OpenAPI。 协议转换:通过协议转换整合后台基于 REST、AMQP、Dubbo 等不同风格和实现技术的微服务,面向 Web Mobile、开放平台等特定客户端提供统一服务。 证书管理:将 SSL 证书部署到 API 网关,由一个统一的入口管理接口,降低了证书更换时的复杂度。 下图来源于 百亿规模 API 网关服务 Shepherd 的设计与实现 - 美团技术团队 - 2021这篇文章。\n有哪些常见的网关系统? # Netflix Zuul # Zuul 是 Netflix 开发的一款提供动态路由、监控、弹性、安全的网关服务,基于 Java 技术栈开发,可以和 Eureka、Ribbon、Hystrix 等组件配合使用。\nZuul 核心架构如下:\nZuul 主要通过过滤器(类似于 AOP)来过滤请求,从而实现网关必备的各种功能。\n我们可以自定义过滤器来处理请求,并且,Zuul 生态本身就有很多现成的过滤器供我们使用。就比如限流可以直接用国外朋友写的 spring-cloud-zuul-ratelimit (这里只是举例说明,一般是配合 hystrix 来做限流):\n\u0026lt;dependency\u0026gt; \u0026lt;groupId\u0026gt;org.springframework.cloud\u0026lt;/groupId\u0026gt; \u0026lt;artifactId\u0026gt;spring-cloud-starter-netflix-zuul\u0026lt;/artifactId\u0026gt; \u0026lt;/dependency\u0026gt; \u0026lt;dependency\u0026gt; \u0026lt;groupId\u0026gt;com.marcosbarbero.cloud\u0026lt;/groupId\u0026gt; \u0026lt;artifactId\u0026gt;spring-cloud-zuul-ratelimit\u0026lt;/artifactId\u0026gt; \u0026lt;version\u0026gt;2.2.0.RELEASE\u0026lt;/version\u0026gt; \u0026lt;/dependency\u0026gt; Zuul 1.x 基于同步 IO,性能较差。 Zuul 2.x 基于 Netty 实现了异步 IO,性能得到了大幅改进。\nGitHub 地址: https://github.com/Netflix/zuul 官方 Wiki: https://github.com/Netflix/zuul/wiki Spring Cloud Gateway # SpringCloud Gateway 属于 Spring Cloud 生态系统中的网关,其诞生的目标是为了替代老牌网关 Zuul。准确点来说,应该是 Zuul 1.x。SpringCloud Gateway 起步要比 Zuul 2.x 更早。\n为了提升网关的性能,SpringCloud Gateway 基于 Spring WebFlux 。Spring WebFlux 使用 Reactor 库来实现响应式编程模型,底层基于 Netty 实现同步非阻塞的 I/O。\nSpring Cloud Gateway 不仅提供统一的路由方式,并且基于 Filter 链的方式提供了网关基本的功能,例如:安全,监控/指标,限流。\nSpring Cloud Gateway 和 Zuul 2.x 的差别不大,也是通过过滤器来处理请求。不过,目前更加推荐使用 Spring Cloud Gateway 而非 Zuul,Spring Cloud 生态对其支持更加友好。\nGithub 地址: https://github.com/spring-cloud/spring-cloud-gateway 官网: https://spring.io/projects/spring-cloud-gateway OpenResty # 根据官方介绍:\nOpenResty 是一个基于 Nginx 与 Lua 的高性能 Web 平台,其内部集成了大量精良的 Lua 库、第三方模块以及大多数的依赖项。用于方便地搭建能够处理超高并发、扩展性极高的动态 Web 应用、Web 服务和动态网关。\nOpenResty 基于 Nginx,主要还是看中了其优秀的高并发能力。不过,由于 Nginx 采用 C 语言开发,二次开发门槛较高。如果想在 Nginx 上实现一些自定义的逻辑或功能,就需要编写 C 语言的模块,并重新编译 Nginx。\n为了解决这个问题,OpenResty 通过实现 ngx_lua 和 stream_lua 等 Nginx 模块,把 Lua/LuaJIT 完美地整合进了 Nginx,从而让我们能够在 Nginx 内部里嵌入 Lua 脚本,使得可以通过简单的 Lua 语言来扩展网关的功能,比如实现自定义的路由规则、过滤器、缓存策略等。\nLua 是一种非常快速的动态脚本语言,它的运行速度接近于 C 语言。LuaJIT 是 Lua 的一个即时编译器,它可以显著提高 Lua 代码的执行效率。LuaJIT 将一些常用的 Lua 函数和工具库预编译并缓存,这样在下次调用时就可以直接使用缓存的字节码,从而大大加快了执行速度。\n关于 OpenResty 的入门以及网关安全实战推荐阅读这篇文章: 每个后端都应该了解的 OpenResty 入门以及网关安全实战。\nGithub 地址: https://github.com/openresty/openresty 官网地址: https://openresty.org/ Kong # Kong 是一款基于 OpenResty (Nginx + Lua)的高性能、云原生、可扩展、生态丰富的网关系统,主要由 3 个组件组成:\nKong Server:基于 Nginx 的服务器,用来接收 API 请求。 Apache Cassandra/PostgreSQL:用来存储操作数据。 Kong Dashboard:官方推荐 UI 管理工具,当然,也可以使用 RESTful 方式 管理 Admin api。 由于默认使用 Apache Cassandra/PostgreSQL 存储数据,Kong 的整个架构比较臃肿,并且会带来高可用的问题。\nKong 提供了插件机制来扩展其功能,插件在 API 请求响应循环的生命周期中被执行。比如在服务上启用 Zipkin 插件:\n$ curl -X POST http://kong:8001/services/{service}/plugins \\ --data \u0026#34;name=zipkin\u0026#34; \\ --data \u0026#34;config.http_endpoint=http://your.zipkin.collector:9411/api/v2/spans\u0026#34; \\ --data \u0026#34;config.sample_ratio=0.001\u0026#34; Kong 本身就是一个 Lua 应用程序,并且是在 Openresty 的基础之上做了一层封装的应用。归根结底就是利用 Lua 嵌入 Nginx 的方式,赋予了 Nginx 可编程的能力,这样以插件的形式在 Nginx 这一层能够做到无限想象的事情。例如限流、安全访问策略、路由、负载均衡等等。编写一个 Kong 插件,就是按照 Kong 插件编写规范,写一个自己自定义的 Lua 脚本,然后加载到 Kong 中,最后引用即可。\n除了 Lua,Kong 还可以基于 Go 、JavaScript、Python 等语言开发插件,得益于对应的 PDK(插件开发工具包)。\n关于 Kong 插件的详细介绍,推荐阅读官方文档: https://docs.konghq.com/gateway/latest/kong-plugins/,写的比较详细。\nGithub 地址: https://github.com/Kong/kong 官网地址: https://konghq.com/kong APISIX # APISIX 是一款基于 OpenResty 和 etcd 的高性能、云原生、可扩展的网关系统。\netcd 是使用 Go 语言开发的一个开源的、高可用的分布式 key-value 存储系统,使用 Raft 协议做分布式共识。\n与传统 API 网关相比,APISIX 具有动态路由和插件热加载,特别适合微服务系统下的 API 管理。并且,APISIX 与 SkyWalking(分布式链路追踪系统)、Zipkin(分布式链路追踪系统)、Prometheus(监控系统) 等 DevOps 生态工具对接都十分方便。\n作为 Nginx 和 Kong 的替代项目,APISIX 目前已经是 Apache 顶级开源项目,并且是最快毕业的国产开源项目。国内目前已经有很多知名企业(比如金山、有赞、爱奇艺、腾讯、贝壳)使用 APISIX 处理核心的业务流量。\n根据官网介绍:“APISIX 已经生产可用,功能、性能、架构全面优于 Kong”。\nAPISIX 同样支持定制化的插件开发。开发者除了能够使用 Lua 语言开发插件,还能通过下面两种方式开发来避开 Lua 语言的学习成本:\n通过 Plugin Runner 来支持更多的主流编程语言(比如 Java、Python、Go 等等)。通过这样的方式,可以让后端工程师通过本地 RPC 通信,使用熟悉的编程语言开发 APISIX 的插件。这样做的好处是减少了开发成本,提高了开发效率,但是在性能上会有一些损失。 使用 Wasm(WebAssembly) 开发插件。Wasm 被嵌入到了 APISIX 中,用户可以使用 Wasm 去编译成 Wasm 的字节码在 APISIX 中运行。 Wasm 是基于堆栈的虚拟机的二进制指令格式,一种低级汇编语言,旨在非常接近已编译的机器代码,并且非常接近本机性能。Wasm 最初是为浏览器构建的,但是随着技术的成熟,在服务器端看到了越来越多的用例。\nGithub 地址: https://github.com/apache/apisix 官网地址: https://apisix.apache.org/zh/ 相关阅读:\n为什么说 Apache APISIX 是最好的 API 网关? 有了 NGINX 和 Kong,为什么还需要 Apache APISIX APISIX 技术博客 APISIX 用户案例(推荐) Shenyu # Shenyu 是一款基于 WebFlux 的可扩展、高性能、响应式网关,Apache 顶级开源项目。\nShenyu 通过插件扩展功能,插件是 ShenYu 的灵魂,并且插件也是可扩展和热插拔的。不同的插件实现不同的功能。Shenyu 自带了诸如限流、熔断、转发、重写、重定向、和路由监控等插件。\nGithub 地址: https://github.com/apache/incubator-shenyu 官网地址: https://shenyu.apache.org/ 如何选择? # 上面介绍的几个常见的网关系统,最常用的是 Spring Cloud Gateway、Kong、APISIX 这三个。\n对于公司业务以 Java 为主要开发语言的情况下,Spring Cloud Gateway 通常是个不错的选择,其优点有:简单易用、成熟稳定、与 Spring Cloud 生态系统兼容、Spring 社区成熟等等。不过,Spring Cloud Gateway 也有一些局限性和不足之处, 一般还需要结合其他网关一起使用比如 OpenResty。并且,其性能相比较于 Kong 和 APISIX,还是差一些。如果对性能要求比较高的话,Spring Cloud Gateway 不是一个好的选择。\nKong 和 APISIX 功能更丰富,性能更强大,技术架构更贴合云原生。Kong 是开源 API 网关的鼻祖,生态丰富,用户群体庞大。APISIX 属于后来者,更优秀一些,根据 APISIX 官网介绍:“APISIX 已经生产可用,功能、性能、架构全面优于 Kong”。下面简单对比一下二者:\nAPISIX 基于 etcd 来做配置中心,不存在单点问题,云原生友好;而 Kong 基于 Apache Cassandra/PostgreSQL ,存在单点风险,需要额外的基础设施保障做高可用。 APISIX 支持热更新,并且实现了毫秒级别的热更新响应;而 Kong 不支持热更新。 APISIX 的性能要优于 Kong 。 APISIX 支持的插件更多,功能更丰富。 参考 # Kong 插件开发教程[通俗易懂]: https://cloud.tencent.com/developer/article/2104299 API 网关 Kong 实战: https://xie.infoq.cn/article/10e4dab2de0bdb6f2c3c93da6 Spring Cloud Gateway 原理介绍和应用: https://blog.fintopia.tech/60e27b0e2078082a378ec5ed/ 微服务为什么要用到 API 网关?: https://apisix.apache.org/zh/blog/2023/03/08/why-do-microservices-need-an-api-gateway/ "},{"id":454,"href":"/zh/docs/technology/Interview/java/concurrent/aqs/","title":"AQS 详解","section":"Concurrent","content":" AQS 介绍 # AQS 的全称为 AbstractQueuedSynchronizer ,翻译过来的意思就是抽象队列同步器。这个类在 java.util.concurrent.locks 包下面。\nAQS 就是一个抽象类,主要用来构建锁和同步器。\npublic abstract class AbstractQueuedSynchronizer extends AbstractOwnableSynchronizer implements java.io.Serializable { } AQS 为构建锁和同步器提供了一些通用功能的实现。因此,使用 AQS 能简单且高效地构造出应用广泛的大量的同步器,比如我们提到的 ReentrantLock,Semaphore,其他的诸如 ReentrantReadWriteLock,SynchronousQueue等等皆是基于 AQS 的。\nAQS 原理 # 在面试中被问到并发知识的时候,大多都会被问到“请你说一下自己对于 AQS 原理的理解”。下面给大家一个示例供大家参考,面试不是背题,大家一定要加入自己的思想,即使加入不了自己的思想也要保证自己能够通俗的讲出来而不是背出来。\nAQS 快速了解 # 在真正讲解 AQS 源码之前,需要对 AQS 有一个整体层面的认识。这里会先通过几个问题,从整体层面上认识 AQS,了解 AQS 在整个 Java 并发中所位于的层面,之后在学习 AQS 源码的过程中,才能更加了解同步器和 AQS 之间的关系。\nAQS 的作用是什么? # AQS 解决了开发者在实现同步器时的复杂性问题。它提供了一个通用框架,用于实现各种同步器,例如 可重入锁(ReentrantLock)、信号量(Semaphore)和 倒计时器(CountDownLatch)。通过封装底层的线程同步机制,AQS 将复杂的线程管理逻辑隐藏起来,使开发者只需专注于具体的同步逻辑。\n简单来说,AQS 是一个抽象类,为同步器提供了通用的 执行框架。它定义了 资源获取和释放的通用流程,而具体的资源获取逻辑则由具体同步器通过重写模板方法来实现。 因此,可以将 AQS 看作是同步器的 基础“底座”,而同步器则是基于 AQS 实现的 具体“应用”。\nAQS 为什么使用 CLH 锁队列的变体? # CLH 锁是一种基于 自旋锁 的优化实现。\n先说一下自旋锁存在的问题:自旋锁通过线程不断对一个原子变量执行 compareAndSet(简称 CAS)操作来尝试获取锁。在高并发场景下,多个线程会同时竞争同一个原子变量,容易造成某个线程的 CAS 操作长时间失败,从而导致 “饥饿”问题(某些线程可能永远无法获取锁)。\nCLH 锁通过引入一个队列来组织并发竞争的线程,对自旋锁进行了改进:\n每个线程会作为一个节点加入到队列中,并通过自旋监控前一个线程节点的状态,而不是直接竞争共享变量。 线程按顺序排队,确保公平性,从而避免了 “饥饿” 问题。 AQS(AbstractQueuedSynchronizer)在 CLH 锁的基础上进一步优化,形成了其内部的 CLH 队列变体。主要改进点有以下两方面:\n自旋 + 阻塞: CLH 锁使用纯自旋方式等待锁的释放,但大量的自旋操作会占用过多的 CPU 资源。AQS 引入了 自旋 + 阻塞 的混合机制: 如果线程获取锁失败,会先短暂自旋尝试获取锁; 如果仍然失败,则线程会进入阻塞状态,等待被唤醒,从而减少 CPU 的浪费。 单向队列改为双向队列:CLH 锁使用单向队列,节点只知道前驱节点的状态,而当某个节点释放锁时,需要通过队列唤醒后续节点。AQS 将队列改为 双向队列,新增了 next 指针,使得节点不仅知道前驱节点,也可以直接唤醒后继节点,从而简化了队列操作,提高了唤醒效率。 AQS 的性能比较好,原因是什么? # 因为 AQS 内部大量使用了 CAS 操作。\nAQS 内部通过队列来存储等待的线程节点。由于队列是共享资源,在多线程场景下,需要保证队列的同步访问。\nAQS 内部通过 CAS 操作来控制队列的同步访问,CAS 操作主要用于控制 队列初始化 、 线程节点入队 两个操作的并发安全。虽然利用 CAS 控制并发安全可以保证比较好的性能,但同时会带来比较高的 编码复杂度 。\nAQS 中为什么 Node 节点需要不同的状态? # AQS 中的 waitStatus 状态类似于 状态机 ,通过不同状态来表明 Node 节点的不同含义,并且根据不同操作,来控制状态之间的流转。\n状态 0 :新节点加入队列之后,初始状态为 0 。\n状态 SIGNAL :当有新的节点加入队列,此时新节点的前继节点状态就会由 0 更新为 SIGNAL ,表示前继节点释放锁之后,需要对新节点进行唤醒操作。如果唤醒 SIGNAL 状态节点的后续节点,就会将 SIGNAL 状态更新为 0 。即通过清除 SIGNAL 状态,表示已经执行了唤醒操作。\n状态 CANCELLED :如果一个节点在队列中等待获取锁锁时,因为某种原因失败了,该节点的状态就会变为 CANCELLED ,表明取消获取锁,这种状态的节点是异常的,无法被唤醒,也无法唤醒后继节点。\nAQS 核心思想 # AQS 核心思想是,如果被请求的共享资源空闲,则将当前请求资源的线程设置为有效的工作线程,并且将共享资源设置为锁定状态。如果被请求的共享资源被占用,那么就需要一套线程阻塞等待以及被唤醒时锁分配的机制,这个机制 AQS 是基于 CLH 锁 (Craig, Landin, and Hagersten locks) 进一步优化实现的。\nCLH 锁 对自旋锁进行了改进,是基于单链表的自旋锁。在多线程场景下,会将请求获取锁的线程组织成一个单向队列,每个等待的线程会通过自旋访问前一个线程节点的状态,前一个节点释放锁之后,当前节点才可以获取锁。CLH 锁 的队列结构如下图所示。\nAQS 中使用的 等待队列 是 CLH 锁队列的变体(接下来简称为 CLH 变体队列)。\nAQS 的 CLH 变体队列是一个双向队列,会暂时获取不到锁的线程将被加入到该队列中,CLH 变体队列和原本的 CLH 锁队列的区别主要有两点:\n由 自旋 优化为 自旋 + 阻塞 :自旋操作的性能很高,但大量的自旋操作比较占用 CPU 资源,因此在 CLH 变体队列中会先通过自旋尝试获取锁,如果失败再进行阻塞等待。 由 单向队列 优化为 双向队列 :在 CLH 变体队列中,会对等待的线程进行阻塞操作,当队列前边的线程释放锁之后,需要对后边的线程进行唤醒,因此增加了 next 指针,成为了双向队列。 AQS 将每条请求共享资源的线程封装成一个 CLH 变体队列的一个结点(Node)来实现锁的分配。在 CLH 变体队列中,一个节点表示一个线程,它保存着线程的引用(thread)、 当前节点在队列中的状态(waitStatus)、前驱节点(prev)、后继节点(next)。\nAQS 中的 CLH 变体队列结构如下图所示:\n关于 AQS 核心数据结构-CLH 锁的详细解读,强烈推荐阅读 Java AQS 核心数据结构-CLH 锁 - Qunar 技术沙龙 这篇文章。\nAQS(AbstractQueuedSynchronizer)的核心原理图:\nAQS 使用 int 成员变量 state 表示同步状态,通过内置的 FIFO 线程等待/等待队列 来完成获取资源线程的排队工作。\nstate 变量由 volatile 修饰,用于展示当前临界资源的获取情况。\n// 共享变量,使用volatile修饰保证线程可见性 private volatile int state; 另外,状态信息 state 可以通过 protected 类型的getState()、setState()和compareAndSetState() 进行操作。并且,这几个方法都是 final 修饰的,在子类中无法被重写。\n//返回同步状态的当前值 protected final int getState() { return state; } // 设置同步状态的值 protected final void setState(int newState) { state = newState; } //原子地(CAS操作)将同步状态值设置为给定值update如果当前同步状态的值等于expect(期望值) protected final boolean compareAndSetState(int expect, int update) { return unsafe.compareAndSwapInt(this, stateOffset, expect, update); } 以可重入的互斥锁 ReentrantLock 为例,它的内部维护了一个 state 变量,用来表示锁的占用状态。state 的初始值为 0,表示锁处于未锁定状态。当线程 A 调用 lock() 方法时,会尝试通过 tryAcquire() 方法独占该锁,并让 state 的值加 1。如果成功了,那么线程 A 就获取到了锁。如果失败了,那么线程 A 就会被加入到一个等待队列(CLH 变体队列)中,直到其他线程释放该锁。假设线程 A 获取锁成功了,释放锁之前,A 线程自己是可以重复获取此锁的(state 会累加)。这就是可重入性的体现:一个线程可以多次获取同一个锁而不会被阻塞。但是,这也意味着,一个线程必须释放与获取的次数相同的锁,才能让 state 的值回到 0,也就是让锁恢复到未锁定状态。只有这样,其他等待的线程才能有机会获取该锁。\n线程 A 尝试获取锁的过程如下图所示(图源 从 ReentrantLock 的实现看 AQS 的原理及应用 - 美团技术团队):\n再以倒计时器 CountDownLatch 以例,任务分为 N 个子线程去执行,state 也初始化为 N(注意 N 要与线程个数一致)。这 N 个子线程开始执行任务,每执行完一个子线程,就调用一次 countDown() 方法。该方法会尝试使用 CAS(Compare and Swap) 操作,让 state 的值减少 1。当所有的子线程都执行完毕后(即 state 的值变为 0),CountDownLatch 会调用 unpark() 方法,唤醒主线程。这时,主线程就可以从 await() 方法(CountDownLatch 中的await() 方法而非 AQS 中的)返回,继续执行后续的操作。\nNode 节点 waitStatus 状态含义 # AQS 中的 waitStatus 状态类似于 状态机 ,通过不同状态来表明 Node 节点的不同含义,并且根据不同操作,来控制状态之间的流转。\nNode 节点状态 值 含义 CANCELLED 1 表示线程已经取消获取锁。线程在等待获取资源时被中断、等待资源超时会更新为该状态。 SIGNAL -1 表示后继节点需要当前节点唤醒。在当前线程节点释放锁之后,需要对后继节点进行唤醒。 CONDITION -2 表示节点在等待 Condition。当其他线程调用了 Condition 的 signal() 方法后,节点会从等待队列转移到同步队列中等待获取资源。 PROPAGATE -3 用于共享模式。在共享模式下,可能会出现线程在队列中无法被唤醒的情况,因此引入了 PROPAGATE 状态来解决这个问题。 0 加入队列的新节点的初始状态。 在 AQS 的源码中,经常使用 \u0026gt; 0 、 \u0026lt; 0 来对 waitStatus 进行判断。\n如果 waitStatus \u0026gt; 0 ,表明节点的状态已经取消等待获取资源。\n如果 waitStatus \u0026lt; 0 ,表明节点的状态处于正常的状态,即没有取消等待。\n其中 SIGNAL 状态是最重要的,节点状态流转以及对应操作如下:\n状态流转 对应操作 0 新节点入队时,初始状态为 0 。 0 -\u0026gt; SIGNAL 新节点入队时,它的前继节点状态会由 0 更新为 SIGNAL 。SIGNAL 状态表明该节点的后续节点需要被唤醒。 SIGNAL -\u0026gt; 0 在唤醒后继节点时,需要清除当前节点的状态。通常发生在 head 节点,比如 head 节点的状态由 SIGNAL 更新为 0 ,表示已经对 head 节点的后继节点唤醒了。 0 -\u0026gt; PROPAGATE AQS 内部引入了 PROPAGATE 状态,为了解决并发场景下,可能造成的线程节点无法唤醒的情况。(在 AQS 共享模式获取资源的源码分析会讲到) 自定义同步器 # 基于 AQS 可以实现自定义的同步器, AQS 提供了 5 个模板方法(模板方法模式)。如果需要自定义同步器一般的方式是这样(模板方法模式很经典的一个应用):\n自定义的同步器继承 AbstractQueuedSynchronizer 。 重写 AQS 暴露的模板方法。 AQS 使用了模板方法模式,自定义同步器时需要重写下面几个 AQS 提供的钩子方法:\n//独占方式。尝试获取资源,成功则返回true,失败则返回false。 protected boolean tryAcquire(int) //独占方式。尝试释放资源,成功则返回true,失败则返回false。 protected boolean tryRelease(int) //共享方式。尝试获取资源。负数表示失败;0表示成功,但没有剩余可用资源;正数表示成功,且有剩余资源。 protected int tryAcquireShared(int) //共享方式。尝试释放资源,成功则返回true,失败则返回false。 protected boolean tryReleaseShared(int) //该线程是否正在独占资源。只有用到condition才需要去实现它。 protected boolean isHeldExclusively() 什么是钩子方法呢? 钩子方法是一种被声明在抽象类中的方法,一般使用 protected 关键字修饰,它可以是空方法(由子类实现),也可以是默认实现的方法。模板设计模式通过钩子方法控制固定步骤的实现。\n篇幅问题,这里就不详细介绍模板方法模式了,不太了解的小伙伴可以看看这篇文章: 用 Java8 改造后的模板方法模式真的是 yyds!。\n除了上面提到的钩子方法之外,AQS 类中的其他方法都是 final ,所以无法被其他类重写。\nAQS 资源共享方式 # AQS 定义两种资源共享方式:Exclusive(独占,只有一个线程能执行,如ReentrantLock)和Share(共享,多个线程可同时执行,如Semaphore/CountDownLatch)。\n一般来说,自定义同步器的共享方式要么是独占,要么是共享,他们也只需实现tryAcquire-tryRelease、tryAcquireShared-tryReleaseShared中的一种即可。但 AQS 也支持自定义同步器同时实现独占和共享两种方式,如ReentrantReadWriteLock。\nAQS 资源获取源码分析(独占模式) # AQS 中以独占模式获取资源的入口方法是 acquire() ,如下:\n// AQS public final void acquire(int arg) { if (!tryAcquire(arg) \u0026amp;\u0026amp; acquireQueued(addWaiter(Node.EXCLUSIVE), arg)) selfInterrupt(); } 在 acquire() 中,线程会先尝试获取共享资源;如果获取失败,会将线程封装为 Node 节点加入到 AQS 的等待队列中;加入队列之后,会让等待队列中的线程尝试获取资源,并且会对线程进行阻塞操作。分别对应以下三个方法:\ntryAcquire() :尝试获取锁(模板方法),AQS 不提供具体实现,由子类实现。 addWaiter() :如果获取锁失败,会将当前线程封装为 Node 节点加入到 AQS 的 CLH 变体队列中等待获取锁。 acquireQueued() :对线程进行阻塞,并调用 tryAcquire() 方法让队列中的线程尝试获取锁。 tryAcquire() 分析 # AQS 中对应的 tryAcquire() 模板方法如下:\n// AQS protected boolean tryAcquire(int arg) { throw new UnsupportedOperationException(); } tryAcquire() 方法是 AQS 提供的模板方法,不提供默认实现。\n因此,这里分析 tryAcquire() 方法时,以 ReentrantLock 的非公平锁(独占锁)为例进行分析,ReentrantLock 内部实现的 tryAcquire() 会调用到下边的 nonfairTryAcquire() :\n// ReentrantLock final boolean nonfairTryAcquire(int acquires) { final Thread current = Thread.currentThread(); // 1、获取 AQS 中的 state 状态 int c = getState(); // 2、如果 state 为 0,证明锁没有被其他线程占用 if (c == 0) { // 2.1、通过 CAS 对 state 进行更新 if (compareAndSetState(0, acquires)) { // 2.2、如果 CAS 更新成功,就将锁的持有者设置为当前线程 setExclusiveOwnerThread(current); return true; } } // 3、如果当前线程和锁的持有线程相同,说明发生了「锁的重入」 else if (current == getExclusiveOwnerThread()) { int nextc = c + acquires; if (nextc \u0026lt; 0) // overflow throw new Error(\u0026#34;Maximum lock count exceeded\u0026#34;); // 3.1、将锁的重入次数加 1 setState(nextc); return true; } // 4、如果锁被其他线程占用,就返回 false,表示获取锁失败 return false; } 在 nonfairTryAcquire() 方法内部,主要通过两个核心操作去完成资源的获取:\n通过 CAS 更新 state 变量。state == 0 表示资源没有被占用。state \u0026gt; 0 表示资源被占用,此时 state 表示重入次数。 通过 setExclusiveOwnerThread() 设置持有资源的线程。 如果线程更新 state 变量成功,就表明获取到了资源, 因此将持有资源的线程设置为当前线程即可。\naddWaiter() 分析 # 在通过 tryAcquire() 方法尝试获取资源失败之后,会调用 addWaiter() 方法将当前线程封装为 Node 节点加入 AQS 内部的队列中。addWaite() 代码如下:\n// AQS private Node addWaiter(Node mode) { // 1、将当前线程封装为 Node 节点。 Node node = new Node(Thread.currentThread(), mode); Node pred = tail; // 2、如果 pred != null,则证明 tail 节点已经被初始化,直接将 Node 节点加入队列即可。 if (pred != null) { node.prev = pred; // 2.1、通过 CAS 控制并发安全。 if (compareAndSetTail(pred, node)) { pred.next = node; return node; } } // 3、初始化队列,并将新创建的 Node 节点加入队列。 enq(node); return node; } 节点入队的并发安全:\n在 addWaiter() 方法中,需要执行 Node 节点 入队 的操作。由于是在多线程环境下,因此需要通过 CAS 操作保证并发安全。\n通过 CAS 操作去更新 tail 指针指向新入队的 Node 节点,CAS 可以保证只有一个线程会成功修改 tail 指针,以此来保证 Node 节点入队时的并发安全。\nAQS 内部队列的初始化:\n在执行 addWaiter() 时,如果发现 pred == null ,即 tail 指针为 null,则证明队列没有初始化,需要调用 enq() 方法初始化队列,并将 Node 节点加入到初始化后的队列中,代码如下:\n// AQS private Node enq(final Node node) { for (;;) { Node t = tail; if (t == null) { // 1、通过 CAS 操作保证队列初始化的并发安全 if (compareAndSetHead(new Node())) tail = head; } else { // 2、与 addWaiter() 方法中节点入队的操作相同 node.prev = t; if (compareAndSetTail(t, node)) { t.next = node; return t; } } } } 在 enq() 方法中初始化队列,在初始化过程中,也需要通过 CAS 来保证并发安全。\n初始化队列总共包含两个步骤:初始化 head 节点、tail 指向 head 节点。\n初始化后的队列如下图所示:\nacquireQueued() 分析 # 为了方便阅读,这里再贴一下 AQS 中 acquire() 获取资源的代码:\n// AQS public final void acquire(int arg) { if (!tryAcquire(arg) \u0026amp;\u0026amp; acquireQueued(addWaiter(Node.EXCLUSIVE), arg)) selfInterrupt(); } 在 acquire() 方法中,通过 addWaiter() 方法将 Node 节点加入队列之后,就会调用 acquireQueued() 方法。代码如下:\n// AQS:令队列中的节点尝试获取锁,并且对线程进行阻塞。 final boolean acquireQueued(final Node node, int arg) { boolean failed = true; try { boolean interrupted = false; for (;;) { // 1、尝试获取锁。 final Node p = node.predecessor(); if (p == head \u0026amp;\u0026amp; tryAcquire(arg)) { setHead(node); p.next = null; // help GC failed = false; return interrupted; } // 2、判断线程是否可以阻塞,如果可以,则阻塞当前线程。 if (shouldParkAfterFailedAcquire(p, node) \u0026amp;\u0026amp; parkAndCheckInterrupt()) interrupted = true; } } finally { // 3、如果获取锁失败,就会取消获取锁,将节点状态更新为 CANCELLED。 if (failed) cancelAcquire(node); } } 在 acquireQueued() 方法中,主要做两件事情:\n尝试获取资源: 当前线程加入队列之后,如果发现前继节点是 head 节点,说明当前线程是队列中第一个等待的节点,于是调用 tryAcquire() 尝试获取资源。\n阻塞当前线程 :如果尝试获取资源失败,就需要阻塞当前线程,等待被唤醒之后获取资源。\n1、尝试获取资源\n在 acquireQueued() 方法中,尝试获取资源总共有 2 个步骤:\np == head :表明当前节点的前继节点为 head 节点。此时当前节点为 AQS 队列中的第一个等待节点。 tryAcquire(arg) == true :表明当前线程尝试获取资源成功。 在成功获取资源之后,就需要将当前线程的节点 从等待队列中移除 。移除操作为:将当前等待的线程节点设置为 head 节点(head 节点是虚拟节点,并不参与排队获取资源)。\n2、阻塞当前线程\n在 AQS 中,当前节点的唤醒需要依赖于上一个节点。如果上一个节点取消获取锁,它的状态就会变为 CANCELLED ,CANCELLED 状态的节点没有获取到锁,也就无法执行解锁操作对当前节点进行唤醒。因此在阻塞当前线程之前,需要跳过 CANCELLED 状态的节点。\n通过 shouldParkAfterFailedAcquire() 方法来判断当前线程节点是否可以阻塞,如下:\n// AQS:判断当前线程节点是否可以阻塞。 private static boolean shouldParkAfterFailedAcquire(Node pred, Node node) { int ws = pred.waitStatus; // 1、前继节点状态正常,直接返回 true 即可。 if (ws == Node.SIGNAL) return true; // 2、ws \u0026gt; 0 表示前继节点的状态异常,即为 CANCELLED 状态,需要跳过异常状态的节点。 if (ws \u0026gt; 0) { do { node.prev = pred = pred.prev; } while (pred.waitStatus \u0026gt; 0); pred.next = node; } else { // 3、如果前继节点的状态不是 SIGNAL,也不是 CANCELLED,就将状态设置为 SIGNAL。 compareAndSetWaitStatus(pred, ws, Node.SIGNAL); } return false; } shouldParkAfterFailedAcquire() 方法中的判断逻辑:\n如果发现前继节点的状态是 SIGNAL ,则可以阻塞当前线程。 如果发现前继节点的状态是 CANCELLED ,则需要跳过 CANCELLED 状态的节点。 如果发现前继节点的状态不是 SIGNAL 和 CANCELLED ,表明前继节点的状态处于正常等待资源的状态,因此将前继节点的状态设置为 SIGNAL ,表明该前继节点需要对后续节点进行唤醒。 当判断当前线程可以阻塞之后,通过调用 parkAndCheckInterrupt() 方法来阻塞当前线程。内部使用了 LockSupport 来实现阻塞。LockSupoprt 底层是基于 Unsafe 类来阻塞线程,代码如下:\n// AQS private final boolean parkAndCheckInterrupt() { // 1、线程阻塞到这里 LockSupport.park(this); // 2、线程被唤醒之后,返回线程中断状态 return Thread.interrupted(); } 为什么在线程被唤醒之后,要返回线程的中断状态呢?\n在 parkAndCheckInterrupt() 方法中,当执行完 LockSupport.park(this) ,线程会被阻塞,代码如下:\n// AQS private final boolean parkAndCheckInterrupt() { LockSupport.park(this); // 线程被唤醒之后,需要返回线程中断状态 return Thread.interrupted(); } 当线程被唤醒之后,需要执行 Thread.interrupted() 来返回线程的中断状态,这是为什么呢?\n这个和线程的中断协作机制有关系,线程被唤醒之后,并不确定是被中断唤醒,还是被 LockSupport.unpark() 唤醒,因此需要通过线程的中断状态来判断。\n在 acquire() 方法中,为什么需要调用 selfInterrupt() ?\nacquire() 方法代码如下:\n// AQS public final void acquire(int arg) { if (!tryAcquire(arg) \u0026amp;\u0026amp; acquireQueued(addWaiter(Node.EXCLUSIVE), arg)) selfInterrupt(); } 在 acquire() 方法中,当 if 语句的条件返回 true 后,就会调用 selfInterrupt() ,该方法会中断当前线程,为什么需要中断当前线程呢?\n当 if 判断为 true 时,需要 tryAcquire() 返回 false ,并且 acquireQueued() 返回 true 。\n其中 acquireQueued() 方法返回的是线程被唤醒之后的 中断状态 ,通过执行 Thread.interrupted() 来返回。该方法在返回中断状态的同时,会清除线程的中断状态。\n因此如果 if 判断为 true ,表明线程的中断状态为 true ,但是调用 Thread.interrupted() 之后,线程的中断状态被清除为 false ,因此需要重新执行 selfInterrupt() 来重新设置线程的中断状态。\nAQS 资源释放源码分析(独占模式) # AQS 中以独占模式释放资源的入口方法是 release() ,代码如下:\n// AQS public final boolean release(int arg) { // 1、尝试释放锁 if (tryRelease(arg)) { Node h = head; // 2、唤醒后继节点 if (h != null \u0026amp;\u0026amp; h.waitStatus != 0) unparkSuccessor(h); return true; } return false; } 在 release() 方法中,主要做两件事:尝试释放锁和唤醒后继节点。对应方法如下:\n1、尝试释放锁\n通过 tryRelease() 方法尝试释放锁,该方法为模板方法,由自定义同步器实现,因此这里仍然以 ReentrantLock 为例来讲解。\nReentrantLock 中实现的 tryRelease() 方法如下:\n// ReentrantLock protected final boolean tryRelease(int releases) { int c = getState() - releases; // 1、判断持有锁的线程是否为当前线程 if (Thread.currentThread() != getExclusiveOwnerThread()) throw new IllegalMonitorStateException(); boolean free = false; // 2、如果 state 为 0,则表明当前线程已经没有重入次数。因此将 free 更新为 true,表明该线程会释放锁。 if (c == 0) { free = true; // 3、更新持有资源的线程为 null setExclusiveOwnerThread(null); } // 4、更新 state 值 setState(c); return free; } 在 tryRelease() 方法中,会先计算释放锁之后的 state 值,判断 state 值是否为 0。\n如果 state == 0 ,表明该线程没有重入次数了,更新 free = true ,并修改持有资源的线程为 null,表明该线程完全释放这把锁。 如果 state != 0 ,表明该线程还存在重入次数,因此不更新 free 值,free 值为 false 表明该线程没有完全释放这把锁。 之后更新 state 值,并返回 free 值,free 值表明线程是否完全释放锁。\n2、唤醒后继节点\n如果 tryRelease() 返回 true ,表明线程已经没有重入次数了,锁已经被完全释放,因此需要唤醒后继节点。\n在唤醒后继节点之前,需要判断是否可以唤醒后继节点,判断条件为: h != null \u0026amp;\u0026amp; h.waitStatus != 0 。这里解释一下为什么要这样判断:\nh == null :表明 head 节点还没有被初始化,也就是 AQS 中的队列没有被初始化,因此无法唤醒队列中的线程节点。 h != null \u0026amp;\u0026amp; h.waitStatus == 0 :表明头节点刚刚初始化完毕(节点的初始化状态为 0),后继节点线程还没有成功入队,因此不需要对后续节点进行唤醒。(当后继节点入队之后,会将前继节点的状态修改为 SIGNAL ,表明需要对后继节点进行唤醒) h != null \u0026amp;\u0026amp; h.waitStatus != 0 :其中 waitStatus 有可能大于 0,也有可能小于 0。其中 \u0026gt; 0 表明节点已经取消等待获取资源,\u0026lt; 0 表明节点处于正常等待状态。 接下来进入 unparkSuccessor() 方法查看如何唤醒后继节点:\n// AQS:这里的入参 node 为队列的头节点(虚拟头节点) private void unparkSuccessor(Node node) { int ws = node.waitStatus; // 1、将头节点的状态进行清除,为后续的唤醒做准备。 if (ws \u0026lt; 0) compareAndSetWaitStatus(node, ws, 0); Node s = node.next; // 2、如果后继节点异常,则需要从 tail 向前遍历,找到正常状态的节点进行唤醒。 if (s == null || s.waitStatus \u0026gt; 0) { s = null; for (Node t = tail; t != null \u0026amp;\u0026amp; t != node; t = t.prev) if (t.waitStatus \u0026lt;= 0) s = t; } if (s != null) // 3、唤醒后继节点 LockSupport.unpark(s.thread); } 在 unparkSuccessor() 中,如果头节点的状态 \u0026lt; 0 (在正常情况下,只要有后继节点,头节点的状态应该为 SIGNAL ,即 -1),表示需要对后继节点进行唤醒,因此这里提前清除头节点的状态标识,将状态修改为 0,表示已经执行了对后续节点唤醒的操作。\n如果 s == null 或者 s.waitStatus \u0026gt; 0 ,表明后继节点异常,此时不能唤醒异常节点,而是要找到正常状态的节点进行唤醒。\n因此需要从 tail 指针向前遍历,来找到第一个状态正常(waitStatus \u0026lt;= 0)的节点进行唤醒。\n为什么要从 tail 指针向前遍历,而不是从 head 指针向后遍历,寻找正常状态的节点呢?\n遍历的方向和 节点的入队操作 有关。入队方法如下:\n// AQS:节点入队方法 private Node addWaiter(Node mode) { Node node = new Node(Thread.currentThread(), mode); Node pred = tail; if (pred != null) { // 1、先修改 prev 指针。 node.prev = pred; if (compareAndSetTail(pred, node)) { // 2、再修改 next 指针。 pred.next = node; return node; } } enq(node); return node; } 在 addWaiter() 方法中,node 节点入队需要修改 node.prev 和 pred.next 两个指针,但是这两个操作并不是 原子操作 ,先修改了 node.prev 指针,之后才修改 pred.next 指针。\n在极端情况下,可能会出现 head 节点的下一个节点状态为 CANCELLED ,此时新入队的节点仅更新了 node.prev 指针,还未更新 pred.next 指针,如下图:\n这样如果从 head 指针向后遍历,无法找到新入队的节点,因此需要从 tail 指针向前遍历找到新入队的节点。\n图解 AQS 工作原理(独占模式) # 至此,AQS 中以独占模式获取资源、释放资源的源码就讲完了。为了对 AQS 的工作原理、节点状态变化有一个更加清晰的认识,接下来会通过画图的方式来了解整个 AQS 的工作原理。\n由于 AQS 是底层同步工具,获取和释放资源的方法并没有提供具体实现,因此这里基于 ReentrantLock 来画图进行讲解。\n假设总共有 3 个线程尝试获取锁,线程分别为 T1 、 T2 和 T3 。\n此时,假设线程 T1 先获取到锁,线程 T2 排队等待获取锁。在线程 T2 进入队列之前,需要对 AQS 内部队列进行初始化。head 节点在初始化后状态为 0 。AQS 内部初始化后的队列如下图:\n此时,线程 T2 尝试获取锁。由于线程 T1 持有锁,因此线程 T2 会进入队列中等待获取锁。同时会将前继节点( head 节点)的状态由 0 更新为 SIGNAL ,表示需要对 head 节点的后继节点进行唤醒。此时,AQS 内部队列如下图所示:\n此时,线程 T3 尝试获取锁。由于线程 T1 持有锁,因此线程 T3 会进入队列中等待获取锁。同时会将前继节点(线程 T2 节点)的状态由 0 更新为 SIGNAL ,表示线程 T2 节点需要对后继节点进行唤醒。此时,AQS 内部队列如下图所示:\n此时,假设线程 T1 释放锁,会唤醒后继节点 T2 。线程 T2 被唤醒后获取到锁,并且会从等待队列中退出。\n这里线程 T2 节点退出等待队列并不是直接从队列移除,而是令线程 T2 节点成为新的 head 节点,以此来退出资源获取的等待。此时 AQS 内部队列如下所示:\n此时,假设线程 T2 释放锁,会唤醒后继节点 T3 。线程 T3 获取到锁之后,同样也退出等待队列,即将线程 T3 节点变为 head 节点来退出资源获取的等待。此时 AQS 内部队列如下所示:\nAQS 资源获取源码分析(共享模式) # AQS 中以独占模式获取资源的入口方法是 acquireShared() ,如下:\n// AQS public final void acquireShared(int arg) { if (tryAcquireShared(arg) \u0026lt; 0) doAcquireShared(arg); } 在 acquireShared() 方法中,会先尝试获取共享锁,如果获取失败,则将当前线程加入到队列中阻塞,等待唤醒后尝试获取共享锁,分别对应一下两个方法:tryAcquireShared() 和 doAcquireShared() 。\n其中 tryAcquireShared() 方法是 AQS 提供的模板方法,由同步器来实现具体逻辑。因此这里以 Semaphore 为例,来分析共享模式下,如何获取资源。\ntryAcquireShared() 分析 # Semaphore 中实现了公平锁和非公平锁,接下来以非公平锁为例来分析 tryAcquireShared() 源码。\nSemaphore 中重写的 tryAcquireShared() 方法会调用下边的 nonfairTryAcquireShared() 方法:\n// Semaphore 重写 AQS 的模板方法 protected int tryAcquireShared(int acquires) { return nonfairTryAcquireShared(acquires); } // Semaphore final int nonfairTryAcquireShared(int acquires) { for (;;) { // 1、获取可用资源数量。 int available = getState(); // 2、计算剩余资源数量。 int remaining = available - acquires; // 3、如果剩余资源数量 \u0026lt; 0,则说明资源不足,直接返回;如果 CAS 更新 state 成功,则说明当前线程获取到了共享资源,直接返回。 if (remaining \u0026lt; 0 || compareAndSetState(available, remaining)) return remaining; } } 在共享模式下,AQS 中的 state 值表示共享资源的数量。\n在 nonfairTryAcquireShared() 方法中,会在死循环中不断尝试获取资源,如果 「剩余资源数不足」 或者 「当前线程成功获取资源」 ,就退出死循环。方法返回 剩余的资源数量 ,根据返回值的不同,分为 3 种情况:\n剩余资源数量 \u0026gt; 0 :表示成功获取资源,并且后续的线程也可以成功获取资源。 剩余资源数量 = 0 :表示成功获取资源,但是后续的线程无法成功获取资源。 剩余资源数量 \u0026lt; 0 :表示获取资源失败。 doAcquireShared() 分析 # 为了方便阅读,这里再贴一下获取资源的入口方法 acquireShared() :\n// AQS public final void acquireShared(int arg) { if (tryAcquireShared(arg) \u0026lt; 0) doAcquireShared(arg); } 在 acquireShared() 方法中,会先通过 tryAcquireShared() 尝试获取资源。\n如果发现方法的返回值 \u0026lt; 0 ,即剩余的资源数小于 0,则表明当前线程获取资源失败。因此会进入 doAcquireShared() 方法,将当前线程加入到 AQS 队列进行等待。如下:\n// AQS private void doAcquireShared(int arg) { // 1、将当前线程加入到队列中等待。 final Node node = addWaiter(Node.SHARED); boolean failed = true; try { boolean interrupted = false; for (;;) { final Node p = node.predecessor(); if (p == head) { // 2、如果当前线程是等待队列的第一个节点,则尝试获取资源。 int r = tryAcquireShared(arg); if (r \u0026gt;= 0) { // 3、将当前线程节点移出等待队列,并唤醒后续线程节点。 setHeadAndPropagate(node, r); p.next = null; // help GC if (interrupted) selfInterrupt(); failed = false; return; } } if (shouldParkAfterFailedAcquire(p, node) \u0026amp;\u0026amp; parkAndCheckInterrupt()) interrupted = true; } } finally { // 3、如果获取资源失败,就会取消获取资源,将节点状态更新为 CANCELLED。 if (failed) cancelAcquire(node); } } 由于当前线程已经尝试获取资源失败了,因此在 doAcquireShared() 方法中,需要将当前线程封装为 Node 节点,加入到队列中进行等待。\n以 共享模式 获取资源和 独占模式 获取资源最大的不同之处在于:共享模式下,资源的数量可能会大于 1,即可以多个线程同时持有资源。\n因此在共享模式下,当线程线程被唤醒之后,获取到了资源,如果发现还存在剩余资源,就会尝试唤醒后边的线程去尝试获取资源。对应的 setHeadAndPropagate() 方法如下:\n// AQS private void setHeadAndPropagate(Node node, int propagate) { Node h = head; // 1、将当前线程节点移出等待队列。 setHead(node); // 2、唤醒后续等待节点。 if (propagate \u0026gt; 0 || h == null || h.waitStatus \u0026lt; 0 || (h = head) == null || h.waitStatus \u0026lt; 0) { Node s = node.next; if (s == null || s.isShared()) doReleaseShared(); } } 在 setHeadAndPropagate() 方法中,唤醒后续节点需要满足一定的条件,主要需要满足 2 个条件:\npropagate \u0026gt; 0 :propagate 代表获取资源之后剩余的资源数量,如果 \u0026gt; 0 ,则可以唤醒后续线程去获取资源。 h.waitStatus \u0026lt; 0 :这里的 h 节点是执行 setHead() 之前的 head 节点。判断 head.waitStatus 时使用 \u0026lt; 0 ,主要为了确定 head 节点的状态为 SIGNAL 或 PROPAGATE 。如果 head 节点为 SIGNAL ,则可以唤醒后续节点;如果 head 节点状态为 PROPAGATE ,也可以唤醒后续节点(这是为了解决并发场景下出现的问题,后续会细讲)。 代码中关于 唤醒后续等待节点 的 if 判断稍微复杂一些,这里来讲一下为什么这样写:\nif (propagate \u0026gt; 0 || h == null || h.waitStatus \u0026lt; 0 || (h = head) == null || h.waitStatus \u0026lt; 0) h == null || h.waitStatus \u0026lt; 0 : h == null 用于防止空指针异常。正常情况下 h 不会为 null ,因为执行到这里之前,当前节点已经加入到队列中了,队列不可能还没有初始化。\nh.waitStatus \u0026lt; 0 主要判断 head 节点的状态是否为 SIGNAL 或者 PROPAGATE ,直接使用 \u0026lt; 0 来判断比较方便。\n(h = head) == null || h.waitStatus \u0026lt; 0 :如果到这里说明之前判断的 h.waitStatus \u0026lt; 0 ,说明存在并发。\n同时存在其他线程在唤醒后续节点,已经将 head 节点的值由 SIGNAL 修改为 0 了。因此,这里重新获取新的 head 节点,这次获取的 head 节点为通过 setHead() 设置的当前线程节点,之后再次判断 waitStatus 状态。\n如果 if 条件判断通过,就会走到 doReleaseShared() 方法唤醒后续等待节点,如下:\nprivate void doReleaseShared() { for (;;) { Node h = head; // 1、队列中至少需要一个等待的线程节点。 if (h != null \u0026amp;\u0026amp; h != tail) { int ws = h.waitStatus; // 2、如果 head 节点的状态为 SIGNAL,则可以唤醒后继节点。 if (ws == Node.SIGNAL) { // 2.1 清除 head 节点的 SIGNAL 状态,更新为 0。表示已经唤醒该节点的后继节点了。 if (!compareAndSetWaitStatus(h, Node.SIGNAL, 0)) continue; // 2.2 唤醒后继节点 unparkSuccessor(h); } // 3、如果 head 节点的状态为 0,则更新为 PROPAGATE。这是为了解决并发场景下存在的问题,接下来会细讲。 else if (ws == 0 \u0026amp;\u0026amp; !compareAndSetWaitStatus(h, 0, Node.PROPAGATE)) continue; } if (h == head) break; } } 在 doReleaseShared() 方法中,会判断 head 节点的 waitStatus 状态来决定接下来的操作,有两种情况:\nhead 节点的状态为 SIGNAL :表明 head 节点存在后继节点需要唤醒,因此通过 CAS 操作将 head 节点的 SIGNAL 状态更新为 0 。通过清除 SIGNAL 状态来表示已经对 head 节点的后继节点进行唤醒操作了。 head 节点的状态为 0 :表明存在并发情况,需要将 0 修改为 PROPAGATE 来保证在并发场景下可以正常唤醒线程。 为什么需要 PROPAGATE 状态? # 在 doReleaseShared() 释放资源时,第 3 步不太容易理解,即如果发现 head 节点的状态是 0 ,就将 head 节点的状态由 0 更新为 PROPAGATE 。\nAQS 中,Node 节点的 PROPAGATE 就是为了处理并发场景下可能出现的无法唤醒线程节点的问题。PROPAGATE 只在 doReleaseShared() 方法中用到一次。\n接下来通过案例分析,为什么需要 PROPAGATE 状态?\n在共享模式下,线程获取和释放资源的方法调用链如下:\n线程获取资源的方法调用链为: acquireShared() -\u0026gt; tryAcquireShared() -\u0026gt; 线程阻塞等待唤醒 -\u0026gt; tryAcquireShared() -\u0026gt; setHeadAndPropagate() -\u0026gt; if (剩余资源数 \u0026gt; 0) || (head.waitStatus \u0026lt; 0) 则唤醒后续节点 。\n线程释放资源的方法调用链为: releaseShared() -\u0026gt; tryReleaseShared() -\u0026gt; doReleaseShared() 。\n如果在释放资源时,没有将 head 节点的状态由 0 改为 PROPAGATE :\n假设总共有 4 个线程尝试以共享模式获取资源,总共有 2 个资源。初始 T3 和 T4 线程获取到了资源,T1 和 T2 线程没有获取到,因此在队列中排队等候。\n在时刻 1 时,线程 T1 和 T2 在等待队列中,T3 和 T4 持有资源。此时等待队列内节点以及对应状态为(括号内为节点的 waitStatus 状态):\nhead(-1) -\u0026gt; T1(-1) -\u0026gt; T2(0) 。\n在时刻 2 时,线程 T3 释放资源,通过 doReleaseShared() 方法将 head 节点的状态由 SIGNAL 更新为 0 ,并唤醒线程 T1 ,之后线程 T3 退出。\n线程 T1 被唤醒之后,通过 tryAcquireShared() 获取到资源,但是此时还未来得及执行 setHeadAndPropagate() 将自己设置为 head 节点。此时等待队列内节点状态为:\nhead(0) -\u0026gt; T1(-1) -\u0026gt; T2(0) 。\n在时刻 3 时,线程 T4 释放资源, 由于此时 head 节点的状态为 0 ,因此在 doReleaseShared() 方法中无法唤醒 head 的后继节点, 之后线程 T4 退出。\n在时刻 4 时,线程 T1 继续执行 setHeadAndPropagate() 方法将自己设置为 head 节点。\n但是此时由于线程 T1 执行 tryAcquireShared() 方法返回的剩余资源数为 0 ,并且 head 节点的状态为 0 ,因此线程 T1 并不会在 setHeadAndPropagate() 方法中唤醒后续节点。此时等待队列内节点状态为:\nhead(-1,线程 T1 节点) -\u0026gt; T2(0) 。\n此时,就导致线程 T2 节点在等待队列中,无法被唤醒。对应时刻表如下:\n时刻 线程 T1 线程 T2 线程 T3 线程 T4 等待队列 时刻 1 等待队列 等待队列 持有资源 持有资源 head(-1) -\u0026gt; T1(-1) -\u0026gt; T2(0) 时刻 2 (执行)被唤醒后,获取资源,但未来得及将自己设置为 head 节点 等待队列 (执行)释放资源 持有资源 head(0) -\u0026gt; T1(-1) -\u0026gt; T2(0) 时刻 3 等待队列 已退出 (执行)释放资源。但 head 节点状态为 0 ,无法唤醒后继节点 head(0) -\u0026gt; T1(-1) -\u0026gt; T2(0) 时刻 4 (执行)将自己设置为 head 节点 等待队列 已退出 已退出 head(-1,线程 T1 节点) -\u0026gt; T2(0) 如果在线程释放资源时,将 head 节点的状态由 0 改为 PROPAGATE ,则可以解决上边出现的并发问题,如下:\n在时刻 1 时,线程 T1 和 T2 在等待队列中,T3 和 T4 持有资源。此时等待队列内节点以及对应状态为:\nhead(-1) -\u0026gt; T1(-1) -\u0026gt; T2(0) 。\n在时刻 2 时,线程 T3 释放资源,通过 doReleaseShared() 方法将 head 节点的状态由 SIGNAL 更新为 0 ,并唤醒线程 T1 ,之后线程 T3 退出。\n线程 T1 被唤醒之后,通过 tryAcquireShared() 获取到资源,但是此时还未来得及执行 setHeadAndPropagate() 将自己设置为 head 节点。此时等待队列内节点状态为:\nhead(0) -\u0026gt; T1(-1) -\u0026gt; T2(0) 。\n在时刻 3 时,线程 T4 释放资源, 由于此时 head 节点的状态为 0 ,因此在 doReleaseShared() 方法中会将 head 节点的状态由 0 更新为 PROPAGATE , 之后线程 T4 退出。此时等待队列内节点状态为:\nhead(PROPAGATE) -\u0026gt; T1(-1) -\u0026gt; T2(0) 。\n在时刻 4 时,线程 T1 继续执行 setHeadAndPropagate() 方法将自己设置为 head 节点。此时等待队列内节点状态为:\nhead(-1,线程 T1 节点) -\u0026gt; T2(0) 。\n在时刻 5 时,虽然此时由于线程 T1 执行 tryAcquireShared() 方法返回的剩余资源数为 0 ,但是 head 节点状态为 PROPAGATE \u0026lt; 0 (这里的 head 节点是老的 head 节点,而不是刚成为 head 节点的线程 T1 节点)。\n因此线程 T1 会在 setHeadAndPropagate() 方法中唤醒后续 T2 节点,并将 head 节点的状态由 SIGNAL 更新为 0。此时等待队列内节点状态为:\nhead(0,线程 T1 节点) -\u0026gt; T2(0) 。\n在时刻 6 时,线程 T2 被唤醒后,获取到资源,并将自己设置为 head 节点。此时等待队列内节点状态为:\nhead(0,线程 T2 节点) 。\n有了 PROPAGATE 状态,就可以避免线程 T2 无法被唤醒的情况。对应时刻表如下:\n时刻 线程 T1 线程 T2 线程 T3 线程 T4 等待队列 时刻 1 等待队列 等待队列 持有资源 持有资源 head(-1) -\u0026gt; T1(-1) -\u0026gt; T2(0) 时刻 2 (执行)被唤醒后,获取资源,但未来得及将自己设置为 head 节点 等待队列 (执行)释放资源 持有资源 head(0) -\u0026gt; T1(-1) -\u0026gt; T2(0) 时刻 3 未继续向下执行 等待队列 已退出 (执行)释放资源。此时会将 head 节点状态由 0 更新为 PROPAGATE head(PROPAGATE) -\u0026gt; T1(-1) -\u0026gt; T2(0) 时刻 4 (执行)将自己设置为 head 节点 等待队列 已退出 已退出 head(-1,线程 T1 节点) -\u0026gt; T2(0) 时刻 5 (执行)由于 head 节点状态为 PROPAGATE \u0026lt; 0 ,因此会在 setHeadAndPropagate() 方法中唤醒后续节点,此时将新的 head 节点的状态由 SIGNAL 更新为 0 ,并唤醒线程 T2 等待队列 已退出 已退出 head(0,线程 T1 节点) -\u0026gt; T2(0) 时刻 6 已退出 (执行)线程 T2 被唤醒后,获取到资源,并将自己设置为 head 节点 已退出 已退出 head(0,线程 T2 节点) AQS 资源释放源码分析(共享模式) # AQS 中以共享模式释放资源的入口方法是 releaseShared() ,代码如下:\n// AQS public final boolean releaseShared(int arg) { if (tryReleaseShared(arg)) { doReleaseShared(); return true; } return false; } 其中 tryReleaseShared() 方法是 AQS 提供的模板方法,这里同样以 Semaphore 来讲解,如下:\n// Semaphore protected final boolean tryReleaseShared(int releases) { for (;;) { int current = getState(); int next = current + releases; if (next \u0026lt; current) // overflow throw new Error(\u0026#34;Maximum permit count exceeded\u0026#34;); if (compareAndSetState(current, next)) return true; } } 在 Semaphore 实现的 tryReleaseShared() 方法中,会在死循环内不断尝试释放资源,即通过 CAS 操作来更新 state 值。\n如果更新成功,则证明资源释放成功,会进入到 doReleaseShared() 方法。\ndoReleaseShared() 方法在前文获取资源(共享模式)的部分已进行了详细的源码分析,此处不再重复。\n常见同步工具类 # 下面介绍几个基于 AQS 的常见同步工具类。\nSemaphore(信号量) # 介绍 # synchronized 和 ReentrantLock 都是一次只允许一个线程访问某个资源,而Semaphore(信号量)可以用来控制同时访问特定资源的线程数量。\nSemaphore 的使用简单,我们这里假设有 N(N\u0026gt;5) 个线程来获取 Semaphore 中的共享资源,下面的代码表示同一时刻 N 个线程中只有 5 个线程能获取到共享资源,其他线程都会阻塞,只有获取到共享资源的线程才能执行。等到有线程释放了共享资源,其他阻塞的线程才能获取到。\n// 初始共享资源数量 final Semaphore semaphore = new Semaphore(5); // 获取1个许可 semaphore.acquire(); // 释放1个许可 semaphore.release(); 当初始的资源个数为 1 的时候,Semaphore 退化为排他锁。\nSemaphore 有两种模式:。\n公平模式: 调用 acquire() 方法的顺序就是获取许可证的顺序,遵循 FIFO; 非公平模式: 抢占式的。 Semaphore 对应的两个构造方法如下:\npublic Semaphore(int permits) { sync = new NonfairSync(permits); } public Semaphore(int permits, boolean fair) { sync = fair ? new FairSync(permits) : new NonfairSync(permits); } 这两个构造方法,都必须提供许可的数量,第二个构造方法可以指定是公平模式还是非公平模式,默认非公平模式。\nSemaphore 通常用于那些资源有明确访问数量限制的场景比如限流(仅限于单机模式,实际项目中推荐使用 Redis +Lua 来做限流)。\n原理 # Semaphore 是共享锁的一种实现,它默认构造 AQS 的 state 值为 permits,你可以将 permits 的值理解为许可证的数量,只有拿到许可证的线程才能执行。\n以无参 acquire 方法为例,调用semaphore.acquire() ,线程尝试获取许可证,如果 state \u0026gt; 0 的话,则表示可以获取成功,如果 state \u0026lt;= 0 的话,则表示许可证数量不足,获取失败。\n如果可以获取成功的话(state \u0026gt; 0 ),会尝试使用 CAS 操作去修改 state 的值 state=state-1。如果获取失败则会创建一个 Node 节点加入等待队列,挂起当前线程。\n// 获取1个许可证 public void acquire() throws InterruptedException { sync.acquireSharedInterruptibly(1); } // 获取一个或者多个许可证 public void acquire(int permits) throws InterruptedException { if (permits \u0026lt; 0) throw new IllegalArgumentException(); sync.acquireSharedInterruptibly(permits); } acquireSharedInterruptibly方法是 AbstractQueuedSynchronizer 中的默认实现。\n// 共享模式下获取许可证,获取成功则返回,失败则加入等待队列,挂起线程 public final void acquireSharedInterruptibly(int arg) throws InterruptedException { if (Thread.interrupted()) throw new InterruptedException(); // 尝试获取许可证,arg为获取许可证个数,当获取失败时,则创建一个节点加入等待队列,挂起当前线程。 if (tryAcquireShared(arg) \u0026lt; 0) doAcquireSharedInterruptibly(arg); } 这里再以非公平模式(NonfairSync)的为例,看看 tryAcquireShared 方法的实现。\n// 共享模式下尝试获取资源(在Semaphore中的资源即许可证): protected int tryAcquireShared(int acquires) { return nonfairTryAcquireShared(acquires); } // 非公平的共享模式获取许可证 final int nonfairTryAcquireShared(int acquires) { for (;;) { // 当前可用许可证数量 int available = getState(); /* * 尝试获取许可证,当前可用许可证数量小于等于0时,返回负值,表示获取失败, * 当前可用许可证大于0时才可能获取成功,CAS失败了会循环重新获取最新的值尝试获取 */ int remaining = available - acquires; if (remaining \u0026lt; 0 || compareAndSetState(available, remaining)) return remaining; } } 以无参 release 方法为例,调用semaphore.release(); ,线程尝试释放许可证,并使用 CAS 操作去修改 state 的值 state=state+1。释放许可证成功之后,同时会唤醒等待队列中的一个线程。被唤醒的线程会重新尝试去修改 state 的值 state=state-1 ,如果 state \u0026gt; 0 则获取令牌成功,否则重新进入等待队列,挂起线程。\n// 释放一个许可证 public void release() { sync.releaseShared(1); } // 释放一个或者多个许可证 public void release(int permits) { if (permits \u0026lt; 0) throw new IllegalArgumentException(); sync.releaseShared(permits); } releaseShared方法是 AbstractQueuedSynchronizer 中的默认实现。\n// 释放共享锁 // 如果 tryReleaseShared 返回 true,就唤醒等待队列中的一个或多个线程。 public final boolean releaseShared(int arg) { //释放共享锁 if (tryReleaseShared(arg)) { //释放当前节点的后置等待节点 doReleaseShared(); return true; } return false; } tryReleaseShared 方法是Semaphore 的内部类 Sync 重写的一个方法, AbstractQueuedSynchronizer中的默认实现仅仅抛出 UnsupportedOperationException 异常。\n// 内部类 Sync 中重写的一个方法 // 尝试释放资源 protected final boolean tryReleaseShared(int releases) { for (;;) { int current = getState(); // 可用许可证+1 int next = current + releases; if (next \u0026lt; current) // overflow throw new Error(\u0026#34;Maximum permit count exceeded\u0026#34;); // CAS修改state的值 if (compareAndSetState(current, next)) return true; } } 可以看到,上面提到的几个方法底层基本都是通过同步器 sync 实现的。Sync 是 CountDownLatch 的内部类 , 继承了 AbstractQueuedSynchronizer ,重写了其中的某些方法。并且,Sync 对应的还有两个子类 NonfairSync(对应非公平模式) 和 FairSync(对应公平模式)。\nprivate static final class Sync extends AbstractQueuedSynchronizer { // ... } static final class NonfairSync extends Sync { // ... } static final class FairSync extends Sync { // ... } 实战 # public class SemaphoreExample { // 请求的数量 private static final int threadCount = 550; public static void main(String[] args) throws InterruptedException { // 创建一个具有固定线程数量的线程池对象(如果这里线程池的线程数量给太少的话你会发现执行的很慢) ExecutorService threadPool = Executors.newFixedThreadPool(300); // 初始许可证数量 final Semaphore semaphore = new Semaphore(20); for (int i = 0; i \u0026lt; threadCount; i++) { final int threadnum = i; threadPool.execute(() -\u0026gt; {// Lambda 表达式的运用 try { semaphore.acquire();// 获取一个许可,所以可运行线程数量为20/1=20 test(threadnum); semaphore.release();// 释放一个许可 } catch (InterruptedException e) { // TODO Auto-generated catch block e.printStackTrace(); } }); } threadPool.shutdown(); System.out.println(\u0026#34;finish\u0026#34;); } public static void test(int threadnum) throws InterruptedException { Thread.sleep(1000);// 模拟请求的耗时操作 System.out.println(\u0026#34;threadnum:\u0026#34; + threadnum); Thread.sleep(1000);// 模拟请求的耗时操作 } } 执行 acquire() 方法阻塞,直到有一个许可证可以获得然后拿走一个许可证;每个 release 方法增加一个许可证,这可能会释放一个阻塞的 acquire() 方法。然而,其实并没有实际的许可证这个对象,Semaphore 只是维持了一个可获得许可证的数量。 Semaphore 经常用于限制获取某种资源的线程数量。\n当然一次也可以一次拿取和释放多个许可,不过一般没有必要这样做:\nsemaphore.acquire(5);// 获取5个许可,所以可运行线程数量为20/5=4 test(threadnum); semaphore.release(5);// 释放5个许可 除了 acquire() 方法之外,另一个比较常用的与之对应的方法是 tryAcquire() 方法,该方法如果获取不到许可就立即返回 false。\nissue645 补充内容:\nSemaphore 基于 AQS 实现,用于控制并发访问的线程数量,但它与共享锁的概念有所不同。Semaphore 的构造函数使用 permits 参数初始化 AQS 的 state 变量,该变量表示可用的许可数量。当线程调用 acquire() 方法尝试获取许可时,state 会原子性地减 1。如果 state 减 1 后大于等于 0,则 acquire() 成功返回,线程可以继续执行。如果 state 减 1 后小于 0,表示当前并发访问的线程数量已达到 permits 的限制,该线程会被放入 AQS 的等待队列并阻塞,而不是自旋等待。当其他线程完成任务并调用 release() 方法时,state 会原子性地加 1。release() 操作会唤醒 AQS 等待队列中的一个或多个阻塞线程。这些被唤醒的线程将再次尝试 acquire() 操作,竞争获取可用的许可。因此,Semaphore 通过控制许可数量来限制并发访问的线程数量,而不是通过自旋和共享锁机制。\nCountDownLatch (倒计时器) # 介绍 # CountDownLatch 允许 count 个线程阻塞在一个地方,直至所有线程的任务都执行完毕。\nCountDownLatch 是一次性的,计数器的值只能在构造方法中初始化一次,之后没有任何机制再次对其设置值,当 CountDownLatch 使用完毕后,它不能再次被使用。\n原理 # CountDownLatch 是共享锁的一种实现,它默认构造 AQS 的 state 值为 count。这个我们通过 CountDownLatch 的构造方法即可看出。\npublic CountDownLatch(int count) { if (count \u0026lt; 0) throw new IllegalArgumentException(\u0026#34;count \u0026lt; 0\u0026#34;); this.sync = new Sync(count); } private static final class Sync extends AbstractQueuedSynchronizer { Sync(int count) { setState(count); } //... } 当线程调用 countDown() 时,其实使用了tryReleaseShared方法以 CAS 的操作来减少 state,直至 state 为 0 。当 state 为 0 时,表示所有的线程都调用了 countDown 方法,那么在 CountDownLatch 上等待的线程就会被唤醒并继续执行。\npublic void countDown() { // Sync 是 CountDownLatch 的内部类 , 继承了 AbstractQueuedSynchronizer sync.releaseShared(1); } releaseShared方法是 AbstractQueuedSynchronizer 中的默认实现。\n// 释放共享锁 // 如果 tryReleaseShared 返回 true,就唤醒等待队列中的一个或多个线程。 public final boolean releaseShared(int arg) { //释放共享锁 if (tryReleaseShared(arg)) { //释放当前节点的后置等待节点 doReleaseShared(); return true; } return false; } tryReleaseShared 方法是CountDownLatch 的内部类 Sync 重写的一个方法, AbstractQueuedSynchronizer中的默认实现仅仅抛出 UnsupportedOperationException 异常。\n// 对 state 进行递减,直到 state 变成 0; // 只有 count 递减到 0 时,countDown 才会返回 true protected boolean tryReleaseShared(int releases) { // 自选检查 state 是否为 0 for (;;) { int c = getState(); // 如果 state 已经是 0 了,直接返回 false if (c == 0) return false; // 对 state 进行递减 int nextc = c-1; // CAS 操作更新 state 的值 if (compareAndSetState(c, nextc)) return nextc == 0; } } 以无参 await方法为例,当调用 await() 的时候,如果 state 不为 0,那就证明任务还没有执行完毕,await() 就会一直阻塞,也就是说 await() 之后的语句不会被执行(main 线程被加入到等待队列也就是 变体 CLH 队列中了)。然后,CountDownLatch 会自旋 CAS 判断 state == 0,如果 state == 0 的话,就会释放所有等待的线程,await() 方法之后的语句得到执行。\n// 等待(也可以叫做加锁) public void await() throws InterruptedException { sync.acquireSharedInterruptibly(1); } // 带有超时时间的等待 public boolean await(long timeout, TimeUnit unit) throws InterruptedException { return sync.tryAcquireSharedNanos(1, unit.toNanos(timeout)); } acquireSharedInterruptibly方法是 AbstractQueuedSynchronizer 中的默认实现。\n// 尝试获取锁,获取成功则返回,失败则加入等待队列,挂起线程 public final void acquireSharedInterruptibly(int arg) throws InterruptedException { if (Thread.interrupted()) throw new InterruptedException(); // 尝试获得锁,获取成功则返回 if (tryAcquireShared(arg) \u0026lt; 0) // 获取失败加入等待队列,挂起线程 doAcquireSharedInterruptibly(arg); } tryAcquireShared 方法是CountDownLatch 的内部类 Sync 重写的一个方法,其作用就是判断 state 的值是否为 0,是的话就返回 1,否则返回 -1。\nprotected int tryAcquireShared(int acquires) { return (getState() == 0) ? 1 : -1; } 实战 # CountDownLatch 的两种典型用法:\n某一线程在开始运行前等待 n 个线程执行完毕 : 将 CountDownLatch 的计数器初始化为 n (new CountDownLatch(n)),每当一个任务线程执行完毕,就将计数器减 1 (countdownlatch.countDown()),当计数器的值变为 0 时,在 CountDownLatch 上 await() 的线程就会被唤醒。一个典型应用场景就是启动一个服务时,主线程需要等待多个组件加载完毕,之后再继续执行。 实现多个线程开始执行任务的最大并行性:注意是并行性,不是并发,强调的是多个线程在某一时刻同时开始执行。类似于赛跑,将多个线程放到起点,等待发令枪响,然后同时开跑。做法是初始化一个共享的 CountDownLatch 对象,将其计数器初始化为 1 (new CountDownLatch(1)),多个线程在开始执行任务前首先 coundownlatch.await(),当主线程调用 countDown() 时,计数器变为 0,多个线程同时被唤醒。 CountDownLatch 代码示例:\npublic class CountDownLatchExample { // 请求的数量 private static final int THREAD_COUNT = 550; public static void main(String[] args) throws InterruptedException { // 创建一个具有固定线程数量的线程池对象(如果这里线程池的线程数量给太少的话你会发现执行的很慢) // 只是测试使用,实际场景请手动赋值线程池参数 ExecutorService threadPool = Executors.newFixedThreadPool(300); final CountDownLatch countDownLatch = new CountDownLatch(THREAD_COUNT); for (int i = 0; i \u0026lt; THREAD_COUNT; i++) { final int threadNum = i; threadPool.execute(() -\u0026gt; { try { test(threadNum); } catch (InterruptedException e) { e.printStackTrace(); } finally { // 表示一个请求已经被完成 countDownLatch.countDown(); } }); } countDownLatch.await(); threadPool.shutdown(); System.out.println(\u0026#34;finish\u0026#34;); } public static void test(int threadnum) throws InterruptedException { Thread.sleep(1000); System.out.println(\u0026#34;threadNum:\u0026#34; + threadnum); Thread.sleep(1000); } } 上面的代码中,我们定义了请求的数量为 550,当这 550 个请求被处理完成之后,才会执行System.out.println(\u0026quot;finish\u0026quot;);。\n与 CountDownLatch 的第一次交互是主线程等待其他线程。主线程必须在启动其他线程后立即调用 CountDownLatch.await() 方法。这样主线程的操作就会在这个方法上阻塞,直到其他线程完成各自的任务。\n其他 N 个线程必须引用闭锁对象,因为他们需要通知 CountDownLatch 对象,他们已经完成了各自的任务。这种通知机制是通过 CountDownLatch.countDown()方法来完成的;每调用一次这个方法,在构造函数中初始化的 count 值就减 1。所以当 N 个线程都调 用了这个方法,count 的值等于 0,然后主线程就能通过 await()方法,恢复执行自己的任务。\n再插一嘴:CountDownLatch 的 await() 方法使用不当很容易产生死锁,比如我们上面代码中的 for 循环改为:\nfor (int i = 0; i \u0026lt; threadCount-1; i++) { ....... } 这样就导致 count 的值没办法等于 0,然后就会导致一直等待。\nCyclicBarrier(循环栅栏) # 介绍 # CyclicBarrier 和 CountDownLatch 非常类似,它也可以实现线程间的技术等待,但是它的功能比 CountDownLatch 更加复杂和强大。主要应用场景和 CountDownLatch 类似。\nCountDownLatch 的实现是基于 AQS 的,而 CycliBarrier 是基于 ReentrantLock(ReentrantLock 也属于 AQS 同步器)和 Condition 的。\nCyclicBarrier 的字面意思是可循环使用(Cyclic)的屏障(Barrier)。它要做的事情是:让一组线程到达一个屏障(也可以叫同步点)时被阻塞,直到最后一个线程到达屏障时,屏障才会开门,所有被屏障拦截的线程才会继续干活。\n原理 # CyclicBarrier 内部通过一个 count 变量作为计数器,count 的初始值为 parties 属性的初始化值,每当一个线程到了栅栏这里了,那么就将计数器减 1。如果 count 值为 0 了,表示这是这一代最后一个线程到达栅栏,就尝试执行我们构造方法中输入的任务。\n//每次拦截的线程数 private final int parties; //计数器 private int count; 下面我们结合源码来简单看看。\n1、CyclicBarrier 默认的构造方法是 CyclicBarrier(int parties),其参数表示屏障拦截的线程数量,每个线程调用 await() 方法告诉 CyclicBarrier 我已经到达了屏障,然后当前线程被阻塞。\npublic CyclicBarrier(int parties) { this(parties, null); } public CyclicBarrier(int parties, Runnable barrierAction) { if (parties \u0026lt;= 0) throw new IllegalArgumentException(); this.parties = parties; this.count = parties; this.barrierCommand = barrierAction; } 其中,parties 就代表了有拦截的线程的数量,当拦截的线程数量达到这个值的时候就打开栅栏,让所有线程通过。\n2、当调用 CyclicBarrier 对象调用 await() 方法时,实际上调用的是 dowait(false, 0L)方法。 await() 方法就像树立起一个栅栏的行为一样,将线程挡住了,当拦住的线程数量达到 parties 的值时,栅栏才会打开,线程才得以通过执行。\npublic int await() throws InterruptedException, BrokenBarrierException { try { return dowait(false, 0L); } catch (TimeoutException toe) { throw new Error(toe); // cannot happen } } dowait(false, 0L)方法源码分析如下:\n// 当线程数量或者请求数量达到 count 时 await 之后的方法才会被执行。上面的示例中 count 的值就为 5。 private int count; /** * Main barrier code, covering the various policies. */ private int dowait(boolean timed, long nanos) throws InterruptedException, BrokenBarrierException, TimeoutException { final ReentrantLock lock = this.lock; // 锁住 lock.lock(); try { final Generation g = generation; if (g.broken) throw new BrokenBarrierException(); // 如果线程中断了,抛出异常 if (Thread.interrupted()) { breakBarrier(); throw new InterruptedException(); } // count 减1 int index = --count; // 当 count 数量减为 0 之后说明最后一个线程已经到达栅栏了,也就是达到了可以执行await 方法之后的条件 if (index == 0) { // tripped boolean ranAction = false; try { final Runnable command = barrierCommand; if (command != null) command.run(); ranAction = true; // 将 count 重置为 parties 属性的初始化值 // 唤醒之前等待的线程 // 下一波执行开始 nextGeneration(); return 0; } finally { if (!ranAction) breakBarrier(); } } // loop until tripped, broken, interrupted, or timed out for (;;) { try { if (!timed) trip.await(); else if (nanos \u0026gt; 0L) nanos = trip.awaitNanos(nanos); } catch (InterruptedException ie) { if (g == generation \u0026amp;\u0026amp; ! g.broken) { breakBarrier(); throw ie; } else { // We\u0026#39;re about to finish waiting even if we had not // been interrupted, so this interrupt is deemed to // \u0026#34;belong\u0026#34; to subsequent execution. Thread.currentThread().interrupt(); } } if (g.broken) throw new BrokenBarrierException(); if (g != generation) return index; if (timed \u0026amp;\u0026amp; nanos \u0026lt;= 0L) { breakBarrier(); throw new TimeoutException(); } } } finally { lock.unlock(); } } 实战 # 示例 1:\npublic class CyclicBarrierExample1 { // 请求的数量 private static final int threadCount = 550; // 需要同步的线程数量 private static final CyclicBarrier cyclicBarrier = new CyclicBarrier(5); public static void main(String[] args) throws InterruptedException { // 创建线程池 ExecutorService threadPool = Executors.newFixedThreadPool(10); for (int i = 0; i \u0026lt; threadCount; i++) { final int threadNum = i; Thread.sleep(1000); threadPool.execute(() -\u0026gt; { try { test(threadNum); } catch (InterruptedException e) { // TODO Auto-generated catch block e.printStackTrace(); } catch (BrokenBarrierException e) { // TODO Auto-generated catch block e.printStackTrace(); } }); } threadPool.shutdown(); } public static void test(int threadnum) throws InterruptedException, BrokenBarrierException { System.out.println(\u0026#34;threadnum:\u0026#34; + threadnum + \u0026#34;is ready\u0026#34;); try { /**等待60秒,保证子线程完全执行结束*/ cyclicBarrier.await(60, TimeUnit.SECONDS); } catch (Exception e) { System.out.println(\u0026#34;-----CyclicBarrierException------\u0026#34;); } System.out.println(\u0026#34;threadnum:\u0026#34; + threadnum + \u0026#34;is finish\u0026#34;); } } 运行结果,如下:\nthreadnum:0is ready threadnum:1is ready threadnum:2is ready threadnum:3is ready threadnum:4is ready threadnum:4is finish threadnum:0is finish threadnum:1is finish threadnum:2is finish threadnum:3is finish threadnum:5is ready threadnum:6is ready threadnum:7is ready threadnum:8is ready threadnum:9is ready threadnum:9is finish threadnum:5is finish threadnum:8is finish threadnum:7is finish threadnum:6is finish ...... 可以看到当线程数量也就是请求数量达到我们定义的 5 个的时候, await() 方法之后的方法才被执行。\n另外,CyclicBarrier 还提供一个更高级的构造函数 CyclicBarrier(int parties, Runnable barrierAction),用于在线程到达屏障时,优先执行 barrierAction,方便处理更复杂的业务场景。\n示例 2:\npublic class CyclicBarrierExample2 { // 请求的数量 private static final int threadCount = 550; // 需要同步的线程数量 private static final CyclicBarrier cyclicBarrier = new CyclicBarrier(5, () -\u0026gt; { System.out.println(\u0026#34;------当线程数达到之后,优先执行------\u0026#34;); }); public static void main(String[] args) throws InterruptedException { // 创建线程池 ExecutorService threadPool = Executors.newFixedThreadPool(10); for (int i = 0; i \u0026lt; threadCount; i++) { final int threadNum = i; Thread.sleep(1000); threadPool.execute(() -\u0026gt; { try { test(threadNum); } catch (InterruptedException e) { // TODO Auto-generated catch block e.printStackTrace(); } catch (BrokenBarrierException e) { // TODO Auto-generated catch block e.printStackTrace(); } }); } threadPool.shutdown(); } public static void test(int threadnum) throws InterruptedException, BrokenBarrierException { System.out.println(\u0026#34;threadnum:\u0026#34; + threadnum + \u0026#34;is ready\u0026#34;); cyclicBarrier.await(); System.out.println(\u0026#34;threadnum:\u0026#34; + threadnum + \u0026#34;is finish\u0026#34;); } } 运行结果,如下:\nthreadnum:0is ready threadnum:1is ready threadnum:2is ready threadnum:3is ready threadnum:4is ready ------当线程数达到之后,优先执行------ threadnum:4is finish threadnum:0is finish threadnum:2is finish threadnum:1is finish threadnum:3is finish threadnum:5is ready threadnum:6is ready threadnum:7is ready threadnum:8is ready threadnum:9is ready ------当线程数达到之后,优先执行------ threadnum:9is finish threadnum:5is finish threadnum:6is finish threadnum:8is finish threadnum:7is finish ...... 参考 # Java 并发之 AQS 详解: https://www.cnblogs.com/waterystone/p/4920797.html 从 ReentrantLock 的实现看 AQS 的原理及应用: https://tech.meituan.com/2019/12/05/aqs-theory-and-apply.html "},{"id":455,"href":"/zh/docs/technology/Interview/cs-basics/network/arp/","title":"ARP 协议详解(网络层)","section":"Network","content":"每当我们学习一个新的网络协议的时候,都要把他结合到 OSI 七层模型中,或者是 TCP/IP 协议栈中来学习,一是要学习该协议在整个网络协议栈中的位置,二是要学习该协议解决了什么问题,地位如何?三是要学习该协议的工作原理,以及一些更深入的细节。\nARP 协议,可以说是在协议栈中属于一个偏底层的、非常重要的、又非常简单的通信协议。\n开始阅读这篇文章之前,你可以先看看下面几个问题:\nARP 协议在协议栈中的位置? ARP 协议在协议栈中的位置非常重要,在理解了它的工作原理之后,也很难说它到底是网络层协议,还是链路层协议,因为它恰恰串联起了网络层和链路层。国外的大部分教程通常将 ARP 协议放在网络层。 ARP 协议解决了什么问题,地位如何? ARP 协议,全称 地址解析协议(Address Resolution Protocol),它解决的是网络层地址和链路层地址之间的转换问题。因为一个 IP 数据报在物理上传输的过程中,总是需要知道下一跳(物理上的下一个目的地)该去往何处,但 IP 地址属于逻辑地址,而 MAC 地址才是物理地址,ARP 协议解决了 IP 地址转 MAC 地址的一些问题。 ARP 工作原理? 只希望大家记住几个关键词:ARP 表、广播问询、单播响应。 MAC 地址 # 在介绍 ARP 协议之前,有必要介绍一下 MAC 地址。\nMAC 地址的全称是 媒体访问控制地址(Media Access Control Address)。如果说,互联网中每一个资源都由 IP 地址唯一标识(IP 协议内容),那么一切网络设备都由 MAC 地址唯一标识。\n可以理解为,MAC 地址是一个网络设备真正的身份证号,IP 地址只是一种不重复的定位方式(比如说住在某省某市某街道的张三,这种逻辑定位是 IP 地址,他的身份证号才是他的 MAC 地址),也可以理解为 MAC 地址是身份证号,IP 地址是邮政地址。MAC 地址也有一些别称,如 LAN 地址、物理地址、以太网地址等。\n还有一点要知道的是,不仅仅是网络资源才有 IP 地址,网络设备也有 IP 地址,比如路由器。但从结构上说,路由器等网络设备的作用是组成一个网络,而且通常是内网,所以它们使用的 IP 地址通常是内网 IP,内网的设备在与内网以外的设备进行通信时,需要用到 NAT 协议。\nMAC 地址的长度为 6 字节(48 比特),地址空间大小有 280 万亿之多($2^{48}$),MAC 地址由 IEEE 统一管理与分配,理论上,一个网络设备中的网卡上的 MAC 地址是永久的。不同的网卡生产商从 IEEE 那里购买自己的 MAC 地址空间(MAC 的前 24 比特),也就是前 24 比特由 IEEE 统一管理,保证不会重复。而后 24 比特,由各家生产商自己管理,同样保证生产的两块网卡的 MAC 地址不会重复。\nMAC 地址具有可携带性、永久性,身份证号永久地标识一个人的身份,不论他到哪里都不会改变。而 IP 地址不具有这些性质,当一台设备更换了网络,它的 IP 地址也就可能发生改变,也就是它在互联网中的定位发生了变化。\n最后,记住,MAC 地址有一个特殊地址:FF-FF-FF-FF-FF-FF(全 1 地址),该地址表示广播地址。\nARP 协议工作原理 # ARP 协议工作时有一个大前提,那就是 ARP 表。\n在一个局域网内,每个网络设备都自己维护了一个 ARP 表,ARP 表记录了某些其他网络设备的 IP 地址-MAC 地址映射关系,该映射关系以 \u0026lt;IP, MAC, TTL\u0026gt; 三元组的形式存储。其中,TTL 为该映射关系的生存周期,典型值为 20 分钟,超过该时间,该条目将被丢弃。\nARP 的工作原理将分两种场景讨论:\n同一局域网内的 MAC 寻址; 从一个局域网到另一个局域网中的网络设备的寻址。 同一局域网内的 MAC 寻址 # 假设当前有如下场景:IP 地址为137.196.7.23的主机 A,想要给同一局域网内的 IP 地址为137.196.7.14主机 B,发送 IP 数据报文。\n再次强调,当主机发送 IP 数据报文时(网络层),仅知道目的地的 IP 地址,并不清楚目的地的 MAC 地址,而 ARP 协议就是解决这一问题的。\n为了达成这一目标,主机 A 将不得不通过 ARP 协议来获取主机 B 的 MAC 地址,并将 IP 报文封装成链路层帧,发送到下一跳上。在该局域网内,关于此将按照时间顺序,依次发生如下事件:\n主机 A 检索自己的 ARP 表,发现 ARP 表中并无主机 B 的 IP 地址对应的映射条目,也就无从知道主机 B 的 MAC 地址。\n主机 A 将构造一个 ARP 查询分组,并将其广播到所在的局域网中。\nARP 分组是一种特殊报文,ARP 分组有两类,一种是查询分组,另一种是响应分组,它们具有相同的格式,均包含了发送和接收的 IP 地址、发送和接收的 MAC 地址。当然了,查询分组中,发送的 IP 地址,即为主机 A 的 IP 地址,接收的 IP 地址即为主机 B 的 IP 地址,发送的 MAC 地址也是主机 A 的 MAC 地址,但接收的 MAC 地址绝不会是主机 B 的 MAC 地址(因为这正是我们要问询的!),而是一个特殊值——FF-FF-FF-FF-FF-FF,之前说过,该 MAC 地址是广播地址,也就是说,查询分组将广播给该局域网内的所有设备。\n主机 A 构造的查询分组将在该局域网内广播,理论上,每一个设备都会收到该分组,并检查查询分组的接收 IP 地址是否为自己的 IP 地址,如果是,说明查询分组已经到达了主机 B,否则,该查询分组对当前设备无效,丢弃之。\n主机 B 收到了查询分组之后,验证是对自己的问询,接着构造一个 ARP 响应分组,该分组的目的地只有一个——主机 A,发送给主机 A。同时,主机 B 提取查询分组中的 IP 地址和 MAC 地址信息,在自己的 ARP 表中构造一条主机 A 的 IP-MAC 映射记录。\nARP 响应分组具有和 ARP 查询分组相同的构造,不同的是,发送和接受的 IP 地址恰恰相反,发送的 MAC 地址为发送者本身,目标 MAC 地址为查询分组的发送者,也就是说,ARP 响应分组只有一个目的地,而非广播。\n主机 A 终将收到主机 B 的响应分组,提取出该分组中的 IP 地址和 MAC 地址后,构造映射信息,加入到自己的 ARP 表中。\n在整个过程中,有几点需要补充说明的是:\n主机 A 想要给主机 B 发送 IP 数据报,如果主机 B 的 IP-MAC 映射信息已经存在于主机 A 的 ARP 表中,那么主机 A 无需广播,只需提取 MAC 地址并构造链路层帧发送即可。 ARP 表中的映射信息是有生存周期的,典型值为 20 分钟。 目标主机接收到了问询主机构造的问询报文后,将先把问询主机的 IP-MAC 映射存进自己的 ARP 表中,这样才能获取到响应的目标 MAC 地址,顺利的发送响应分组。 总结来说,ARP 协议是一个广播问询,单播响应协议。\n不同局域网内的 MAC 寻址 # 更复杂的情况是,发送主机 A 和接收主机 B 不在同一个子网中,假设一个一般场景,两台主机所在的子网由一台路由器联通。这里需要注意的是,一般情况下,我们说网络设备都有一个 IP 地址和一个 MAC 地址,这里说的网络设备,更严谨的说法应该是一个接口。路由器作为互联设备,具有多个接口,每个接口同样也应该具备不重复的 IP 地址和 MAC 地址。因此,在讨论 ARP 表时,路由器的多个接口都各自维护一个 ARP 表,而非一个路由器只维护一个 ARP 表。\n接下来,回顾同一子网内的 MAC 寻址,如果主机 A 发送一个广播问询分组,那么 A 所在的子网内所有设备(接口)都将会捕获该分组,因为该分组的目的 IP 与发送主机 A 的 IP 在同一个子网中。但是当目的 IP 与 A 不在同一子网时,A 所在子网内将不会有设备成功接收该分组。那么,主机 A 应该发送怎样的查询分组呢?整个过程按照时间顺序发生的事件如下:\n主机 A 查询 ARP 表,期望寻找到目标路由器的本子网接口的 MAC 地址。\n目标路由器指的是,根据目的主机 B 的 IP 地址,分析出 B 所在的子网,能够把报文转发到 B 所在子网的那个路由器。\n主机 A 未能找到目标路由器的本子网接口的 MAC 地址,将采用 ARP 协议,问询到该 MAC 地址,由于目标接口与主机 A 在同一个子网内,该过程与同一局域网内的 MAC 寻址相同。\n主机 A 获取到目标接口的 MAC 地址,先构造 IP 数据报,其中源 IP 是 A 的 IP 地址,目的 IP 地址是 B 的 IP 地址,再构造链路层帧,其中源 MAC 地址是 A 的 MAC 地址,目的 MAC 地址是本子网内与路由器连接的接口的 MAC 地址。主机 A 将把这个链路层帧,以单播的方式,发送给目标接口。\n目标接口接收到了主机 A 发过来的链路层帧,解析,根据目的 IP 地址,查询转发表,将该 IP 数据报转发到与主机 B 所在子网相连的接口上。\n到此,该帧已经从主机 A 所在的子网,转移到了主机 B 所在的子网了。\n路由器接口查询 ARP 表,期望寻找到主机 B 的 MAC 地址。\n路由器接口如未能找到主机 B 的 MAC 地址,将采用 ARP 协议,广播问询,单播响应,获取到主机 B 的 MAC 地址。\n路由器接口将对 IP 数据报重新封装成链路层帧,目标 MAC 地址为主机 B 的 MAC 地址,单播发送,直到目的地。\n"},{"id":456,"href":"/zh/docs/technology/Interview/java/collection/arrayblockingqueue-source-code/","title":"ArrayBlockingQueue 源码分析","section":"Collection","content":" 阻塞队列简介 # 阻塞队列的历史 # Java 阻塞队列的历史可以追溯到 JDK1.5 版本,当时 Java 平台增加了 java.util.concurrent,即我们常说的 JUC 包,其中包含了各种并发流程控制工具、并发容器、原子类等。这其中自然也包含了我们这篇文章所讨论的阻塞队列。\n为了解决高并发场景下多线程之间数据共享的问题,JDK1.5 版本中出现了 ArrayBlockingQueue 和 LinkedBlockingQueue,它们是带有生产者-消费者模式实现的并发容器。其中,ArrayBlockingQueue 是有界队列,即添加的元素达到上限之后,再次添加就会被阻塞或者抛出异常。而 LinkedBlockingQueue 则由链表构成的队列,正是因为链表的特性,所以 LinkedBlockingQueue 在添加元素上并不会向 ArrayBlockingQueue 那样有着较多的约束,所以 LinkedBlockingQueue 设置队列是否有界是可选的(注意这里的无界并不是指可以添加任务数量的元素,而是说队列的大小默认为 Integer.MAX_VALUE,近乎于无限大)。\n随着 Java 的不断发展,JDK 后续的几个版本又对阻塞队列进行了不少的更新和完善:\nJDK1.6 版本:增加 SynchronousQueue,一个不存储元素的阻塞队列。 JDK1.7 版本:增加 TransferQueue,一个支持更多操作的阻塞队列。 JDK1.8 版本:增加 DelayQueue,一个支持延迟获取元素的阻塞队列。 阻塞队列的思想 # 阻塞队列就是典型的生产者-消费者模型,它可以做到以下几点:\n当阻塞队列数据为空时,所有的消费者线程都会被阻塞,等待队列非空。 当生产者往队列里填充数据后,队列就会通知消费者队列非空,消费者此时就可以进来消费。 当阻塞队列因为消费者消费过慢或者生产者存放元素过快导致队列填满时无法容纳新元素时,生产者就会被阻塞,等待队列非满时继续存放元素。 当消费者从队列中消费一个元素之后,队列就会通知生产者队列非满,生产者可以继续填充数据了。 总结一下:阻塞队列就说基于非空和非满两个条件实现生产者和消费者之间的交互,尽管这些交互流程和等待通知的机制实现非常复杂,好在 Doug Lea 的操刀之下已将阻塞队列的细节屏蔽,我们只需调用 put、take、offer、poll 等 API 即可实现多线程之间的生产和消费。\n这也使得阻塞队列在多线程开发中有着广泛的运用,最常见的例子无非是我们的线程池,从源码中我们就能看出当核心线程无法及时处理任务时,这些任务都会扔到 workQueue 中。\npublic ThreadPoolExecutor(int corePoolSize, int maximumPoolSize, long keepAliveTime, TimeUnit unit, BlockingQueue\u0026lt;Runnable\u0026gt; workQueue, ThreadFactory threadFactory, RejectedExecutionHandler handler) {// ...} ArrayBlockingQueue 常见方法及测试 # 简单了解了阻塞队列的历史之后,我们就开始重点讨论本篇文章所要介绍的并发容器——ArrayBlockingQueue。为了后续更加深入的了解 ArrayBlockingQueue,我们不妨基于下面几个实例了解以下 ArrayBlockingQueue 的使用。\n先看看第一个例子,我们这里会用两个线程分别模拟生产者和消费者,生产者生产完会使用 put 方法生产 10 个元素给消费者进行消费,当队列元素达到我们设置的上限 5 时,put 方法就会阻塞。 同理消费者也会通过 take 方法消费元素,当队列为空时,take 方法就会阻塞消费者线程。这里笔者为了保证消费者能够在消费完 10 个元素后及时退出。便通过倒计时门闩,来控制消费者结束,生产者在这里只会生产 10 个元素。当消费者将 10 个元素消费完成之后,按下倒计时门闩,所有线程都会停止。\npublic class ProducerConsumerExample { public static void main(String[] args) throws InterruptedException { // 创建一个大小为 5 的 ArrayBlockingQueue ArrayBlockingQueue\u0026lt;Integer\u0026gt; queue = new ArrayBlockingQueue\u0026lt;\u0026gt;(5); // 创建生产者线程 Thread producer = new Thread(() -\u0026gt; { try { for (int i = 1; i \u0026lt;= 10; i++) { // 向队列中添加元素,如果队列已满则阻塞等待 queue.put(i); System.out.println(\u0026#34;生产者添加元素:\u0026#34; + i); } } catch (InterruptedException e) { e.printStackTrace(); } }); CountDownLatch countDownLatch = new CountDownLatch(1); // 创建消费者线程 Thread consumer = new Thread(() -\u0026gt; { try { int count = 0; while (true) { // 从队列中取出元素,如果队列为空则阻塞等待 int element = queue.take(); System.out.println(\u0026#34;消费者取出元素:\u0026#34; + element); ++count; if (count == 10) { break; } } countDownLatch.countDown(); } catch (InterruptedException e) { e.printStackTrace(); } }); // 启动线程 producer.start(); consumer.start(); // 等待线程结束 producer.join(); consumer.join(); countDownLatch.await(); producer.interrupt(); consumer.interrupt(); } } 代码输出结果如下,可以看到只有生产者往队列中投放元素之后消费者才能消费,这也就意味着当队列中没有数据的时消费者就会阻塞,等待队列非空再继续消费。\n生产者添加元素:1 生产者添加元素:2 消费者取出元素:1 消费者取出元素:2 生产者添加元素:3 消费者取出元素:3 生产者添加元素:4 生产者添加元素:5 消费者取出元素:4 生产者添加元素:6 消费者取出元素:5 生产者添加元素:7 生产者添加元素:8 生产者添加元素:9 生产者添加元素:10 消费者取出元素:6 消费者取出元素:7 消费者取出元素:8 消费者取出元素:9 消费者取出元素:10 了解了 put、take 这两个会阻塞的存和取方法之后,我我们再来看看阻塞队列中非阻塞的入队和出队方法 offer 和 poll。\n如下所示,我们设置了一个大小为 3 的阻塞队列,我们会尝试在队列用 offer 方法存放 4 个元素,然后再从队列中用 poll 尝试取 4 次。\npublic class OfferPollExample { public static void main(String[] args) { // 创建一个大小为 3 的 ArrayBlockingQueue ArrayBlockingQueue\u0026lt;String\u0026gt; queue = new ArrayBlockingQueue\u0026lt;\u0026gt;(3); // 向队列中添加元素 System.out.println(queue.offer(\u0026#34;A\u0026#34;)); System.out.println(queue.offer(\u0026#34;B\u0026#34;)); System.out.println(queue.offer(\u0026#34;C\u0026#34;)); // 尝试向队列中添加元素,但队列已满,返回 false System.out.println(queue.offer(\u0026#34;D\u0026#34;)); // 从队列中取出元素 System.out.println(queue.poll()); System.out.println(queue.poll()); System.out.println(queue.poll()); // 尝试从队列中取出元素,但队列已空,返回 null System.out.println(queue.poll()); } } 最终代码的输出结果如下,可以看到因为队列的大小为 3 的缘故,我们前 3 次存放到队列的结果为 true,第 4 次存放时,由于队列已满,所以存放结果返回 false。这也是为什么我们后续的 poll 方法只得到了 3 个元素的值。\ntrue true true false A B C null 了解了阻塞存取和非阻塞存取,我们再来看看阻塞队列的一个比较特殊的操作,某些场景下,我们希望能够一次性将阻塞队列的结果存到列表中再进行批量操作,我们就可以使用阻塞队列的 drainTo 方法,这个方法会一次性将队列中所有元素存放到列表,如果队列中有元素,且成功存到 list 中则 drainTo 会返回本次转移到 list 中的元素数,反之若队列为空,drainTo 则直接返回 0。\npublic class DrainToExample { public static void main(String[] args) { // 创建一个大小为 5 的 ArrayBlockingQueue ArrayBlockingQueue\u0026lt;Integer\u0026gt; queue = new ArrayBlockingQueue\u0026lt;\u0026gt;(5); // 向队列中添加元素 queue.add(1); queue.add(2); queue.add(3); queue.add(4); queue.add(5); // 创建一个 List,用于存储从队列中取出的元素 List\u0026lt;Integer\u0026gt; list = new ArrayList\u0026lt;\u0026gt;(); // 从队列中取出所有元素,并添加到 List 中 queue.drainTo(list); // 输出 List 中的元素 System.out.println(list); } } 代码输出结果如下\n[1, 2, 3, 4, 5] ArrayBlockingQueue 源码分析 # 自此我们对阻塞队列的使用有了基本的印象,接下来我们就可以进一步了解一下 ArrayBlockingQueue 的工作机制了。\n整体设计 # 在了解 ArrayBlockingQueue 的具体细节之前,我们先来看看 ArrayBlockingQueue 的类图。\n从图中我们可以看出,ArrayBlockingQueue 继承了阻塞队列 BlockingQueue 这个接口,不难猜出通过继承 BlockingQueue 这个接口之后,ArrayBlockingQueue 就拥有了阻塞队列那些常见的操作行为。\n同时, ArrayBlockingQueue 还继承了 AbstractQueue 这个抽象类,这个继承了 AbstractCollection 和 Queue 的抽象类,从抽象类的特定和语义我们也可以猜出,这个继承关系使得 ArrayBlockingQueue 拥有了队列的常见操作。\n所以我们是否可以得出这样一个结论,通过继承 AbstractQueue 获得队列所有的操作模板,其实现的入队和出队操作的整体框架。然后 ArrayBlockingQueue 通过继承 BlockingQueue 获取到阻塞队列的常见操作并将这些操作实现,填充到 AbstractQueue 模板方法的细节中,由此 ArrayBlockingQueue 成为一个完整的阻塞队列。\n为了印证这一点,我们到源码中一探究竟。首先我们先来看看 AbstractQueue,从类的继承关系我们可以大致得出,它通过 AbstractCollection 获得了集合的常见操作方法,然后通过 Queue 接口获得了队列的特性。\npublic abstract class AbstractQueue\u0026lt;E\u0026gt; extends AbstractCollection\u0026lt;E\u0026gt; implements Queue\u0026lt;E\u0026gt; { //... } 对于集合的操作无非是增删改查,所以我们不妨从添加方法入手,从源码中我们可以看到,它实现了 AbstractCollection 的 add 方法,其内部逻辑如下:\n调用继承 Queue 接口的来的 offer 方法,如果 offer 成功则返回 true。 如果 offer 失败,即代表当前元素入队失败直接抛异常。 public boolean add(E e) { if (offer(e)) return true; else throw new IllegalStateException(\u0026#34;Queue full\u0026#34;); } 而 AbstractQueue 中并没有对 Queue 的 offer 的实现,很明显这样做的目的是定义好了 add 的核心逻辑,将 offer 的细节交由其子类即我们的 ArrayBlockingQueue 实现。\n到此,我们对于抽象类 AbstractQueue 的分析就结束了,我们继续看看 ArrayBlockingQueue 中另一个重要的继承接口 BlockingQueue。\n点开 BlockingQueue 之后,我们可以看到这个接口同样继承了 Queue 接口,这就意味着它也具备了队列所拥有的所有行为。同时,它还定义了自己所需要实现的方法。\npublic interface BlockingQueue\u0026lt;E\u0026gt; extends Queue\u0026lt;E\u0026gt; { //元素入队成功返回true,反之则会抛出异常IllegalStateException boolean add(E e); //元素入队成功返回true,反之返回false boolean offer(E e); //元素入队成功则直接返回,如果队列已满元素不可入队则将线程阻塞,因为阻塞期间可能会被打断,所以这里方法签名抛出了InterruptedException void put(E e) throws InterruptedException; //和上一个方法一样,只不过队列满时只会阻塞单位为unit,时间为timeout的时长,如果在等待时长内没有入队成功则直接返回false。 boolean offer(E e, long timeout, TimeUnit unit) throws InterruptedException; //从队头取出一个元素,如果队列为空则阻塞等待,因为会阻塞线程的缘故,所以该方法可能会被打断,所以签名定义了InterruptedException E take() throws InterruptedException; //取出队头的元素并返回,如果当前队列为空则阻塞等待timeout且单位为unit的时长,如果这个时间段没有元素则直接返回null。 E poll(long timeout, TimeUnit unit) throws InterruptedException; //获取队列剩余元素个数 int remainingCapacity(); //删除我们指定的对象,如果成功返回true,反之返回false。 boolean remove(Object o); //判断队列中是否包含指定元素 public boolean contains(Object o); //将队列中的元素全部存到指定的集合中 int drainTo(Collection\u0026lt;? super E\u0026gt; c); //转移maxElements个元素到集合中 int drainTo(Collection\u0026lt;? super E\u0026gt; c, int maxElements); } 了解了 BlockingQueue 的常见操作后,我们就知道了 ArrayBlockingQueue 通过继承 BlockingQueue 的方法并实现后,填充到 AbstractQueue 的方法上,由此我们便知道了上文中 AbstractQueue 的 add 方法的 offer 方法是哪里是实现的了。\npublic boolean add(E e) { //AbstractQueue的offer来自下层的ArrayBlockingQueue从BlockingQueue继承并实现的offer方法 if (offer(e)) return true; else throw new IllegalStateException(\u0026#34;Queue full\u0026#34;); } 初始化 # 了解 ArrayBlockingQueue 的细节前,我们不妨先看看其构造函数,了解一下其初始化过程。从源码中我们可以看出 ArrayBlockingQueue 有 3 个构造方法,而最核心的构造方法就是下方这一个。\n// capacity 表示队列初始容量,fair 表示 锁的公平性 public ArrayBlockingQueue(int capacity, boolean fair) { //如果设置的队列大小小于0,则直接抛出IllegalArgumentException if (capacity \u0026lt;= 0) throw new IllegalArgumentException(); //初始化一个数组用于存放队列的元素 this.items = new Object[capacity]; //创建阻塞队列流程控制的锁 lock = new ReentrantLock(fair); //用lock锁创建两个条件控制队列生产和消费 notEmpty = lock.newCondition(); notFull = lock.newCondition(); } 这个构造方法里面有两个比较核心的成员变量 notEmpty(非空) 和 notFull (非满) ,需要我们格外留意,它们是实现生产者和消费者有序工作的关键所在,这一点笔者会在后续的源码解析中详细说明,这里我们只需初步了解一下阻塞队列的构造即可。\n另外两个构造方法都是基于上述的构造方法,默认情况下,我们会使用下面这个构造方法,该构造方法就意味着 ArrayBlockingQueue 用的是非公平锁,即各个生产者或者消费者线程收到通知后,对于锁的争抢是随机的。\npublic ArrayBlockingQueue(int capacity) { this(capacity, false); } 还有一个不怎么常用的构造方法,在初始化容量和锁的非公平性之后,它还提供了一个 Collection 参数,从源码中不难看出这个构造方法是将外部传入的集合的元素在初始化时直接存放到阻塞队列中。\npublic ArrayBlockingQueue(int capacity, boolean fair, Collection\u0026lt;? extends E\u0026gt; c) { //初始化容量和锁的公平性 this(capacity, fair); final ReentrantLock lock = this.lock; //上锁并将c中的元素存放到ArrayBlockingQueue底层的数组中 lock.lock(); try { int i = 0; try { //遍历并添加元素到数组中 for (E e : c) { checkNotNull(e); items[i++] = e; } } catch (ArrayIndexOutOfBoundsException ex) { throw new IllegalArgumentException(); } //记录当前队列容量 count = i; //更新下一次put或者offer或用add方法添加到队列底层数组的位置 putIndex = (i == capacity) ? 0 : i; } finally { //完成遍历后释放锁 lock.unlock(); } } 阻塞式获取和新增元素 # ArrayBlockingQueue 阻塞式获取和新增元素对应的就是生产者-消费者模型,虽然它也支持非阻塞式获取和新增元素(例如 poll() 和 offer(E e) 方法,后文会介绍到),但一般不会使用。\nArrayBlockingQueue 阻塞式获取和新增元素的方法为:\nput(E e):将元素插入队列中,如果队列已满,则该方法会一直阻塞,直到队列有空间可用或者线程被中断。 take() :获取并移除队列头部的元素,如果队列为空,则该方法会一直阻塞,直到队列非空或者线程被中断。 这两个方法实现的关键就是在于两个条件对象 notEmpty(非空) 和 notFull (非满),这个我们在上文的构造方法中有提到。\n接下来笔者就通过两张图让大家了解一下这两个条件是如何在阻塞队列中运用的。\n假设我们的代码消费者先启动,当它发现队列中没有数据,那么非空条件就会将这个线程挂起,即等待条件非空时挂起。然后 CPU 执行权到达生产者,生产者发现队列中可以存放数据,于是将数据存放进去,通知此时条件非空,此时消费者就会被唤醒到队列中使用 take 等方法获取值了。\n随后的执行中,生产者生产速度远远大于消费者消费速度,于是生产者将队列塞满后再次尝试将数据存入队列,发现队列已满,于是阻塞队列就将当前线程挂起,等待非满。然后消费者拿着 CPU 执行权进行消费,于是队列可以存放新数据了,发出一个非满的通知,此时挂起的生产者就会等待 CPU 执行权到来时再次尝试将数据存到队列中。\n简单了解阻塞队列的基于两个条件的交互流程之后,我们不妨看看 put 和 take 方法的源码。\npublic void put(E e) throws InterruptedException { //确保插入的元素不为null checkNotNull(e); //加锁 final ReentrantLock lock = this.lock; //这里使用lockInterruptibly()方法而不是lock()方法是为了能够响应中断操作,如果在等待获取锁的过程中被打断则该方法会抛出InterruptedException异常。 lock.lockInterruptibly(); try { //如果count等数组长度则说明队列已满,当前线程将被挂起放到AQS队列中,等待队列非满时插入(非满条件)。 //在等待期间,锁会被释放,其他线程可以继续对队列进行操作。 while (count == items.length) notFull.await(); //如果队列可以存放元素,则调用enqueue将元素入队 enqueue(e); } finally { //释放锁 lock.unlock(); } } put方法内部调用了 enqueue 方法来实现元素入队,我们继续深入查看一下 enqueue 方法的实现细节:\nprivate void enqueue(E x) { //获取队列底层的数组 final Object[] items = this.items; //将putindex位置的值设置为我们传入的x items[putIndex] = x; //更新putindex,如果putindex等于数组长度,则更新为0 if (++putIndex == items.length) putIndex = 0; //队列长度+1 count++; //通知队列非空,那些因为获取元素而阻塞的线程可以继续工作了 notEmpty.signal(); } 从源码中可以看到入队操作的逻辑就是在数组中追加一个新元素,整体执行步骤为:\n获取 ArrayBlockingQueue 底层的数组 items。 将元素存到 putIndex 位置。 更新 putIndex 到下一个位置,如果 putIndex 等于队列长度,则说明 putIndex 已经到达数组末尾了,下一次插入则需要 0 开始。(ArrayBlockingQueue 用到了循环队列的思想,即从头到尾循环复用一个数组) 更新 count 的值,表示当前队列长度+1。 调用 notEmpty.signal() 通知队列非空,消费者可以从队列中获取值了。 自此我们了解了 put 方法的流程,为了更加完整的了解 ArrayBlockingQueue 关于生产者-消费者模型的设计,我们继续看看阻塞获取队列元素的 take 方法。\npublic E take() throws InterruptedException { //获取锁 final ReentrantLock lock = this.lock; lock.lockInterruptibly(); try { //如果队列中元素个数为0,则将当前线程打断并存入AQS队列中,等待队列非空时获取并移除元素(非空条件) while (count == 0) notEmpty.await(); //如果队列不为空则调用dequeue获取元素 return dequeue(); } finally { //释放锁 lock.unlock(); } } 理解了 put 方法再看take 方法就很简单了,其核心逻辑和put 方法正好是相反的,比如put 方法在队列满的时候等待队列非满时插入元素(非满条件),而take 方法等待队列非空时获取并移除元素(非空条件)。\ntake方法内部调用了 dequeue 方法来实现元素出队,其核心逻辑和 enqueue 方法也是相反的。\nprivate E dequeue() { //获取阻塞队列底层的数组 final Object[] items = this.items; @SuppressWarnings(\u0026#34;unchecked\u0026#34;) //从队列中获取takeIndex位置的元素 E x = (E) items[takeIndex]; //将takeIndex置空 items[takeIndex] = null; //takeIndex向后挪动,如果等于数组长度则更新为0 if (++takeIndex == items.length) takeIndex = 0; //队列长度减1 count--; if (itrs != null) itrs.elementDequeued(); //通知那些被打断的线程当前队列状态非满,可以继续存放元素 notFull.signal(); return x; } 由于dequeue 方法(出队)和上面介绍的 enqueue 方法(入队)的步骤大致类似,这里就不重复介绍了。\n为了帮助理解,我专门画了一张图来展示 notEmpty(非空) 和 notFull (非满)这两个条件对象是如何控制 ArrayBlockingQueue 的存和取的。\n消费者:当消费者从队列中 take 或者 poll 等操作取出一个元素之后,就会通知队列非满,此时那些等待非满的生产者就会被唤醒等待获取 CPU 时间片进行入队操作。 生产者:当生产者将元素存到队列中后,就会触发通知队列非空,此时消费者就会被唤醒等待 CPU 时间片尝试获取元素。如此往复,两个条件对象就构成一个环路,控制着多线程之间的存和取。 非阻塞式获取和新增元素 # ArrayBlockingQueue 非阻塞式获取和新增元素的方法为:\noffer(E e):将元素插入队列尾部。如果队列已满,则该方法会直接返回 false,不会等待并阻塞线程。 poll():获取并移除队列头部的元素,如果队列为空,则该方法会直接返回 null,不会等待并阻塞线程。 add(E e):将元素插入队列尾部。如果队列已满则会抛出 IllegalStateException 异常,底层基于 offer(E e) 方法。 remove():移除队列头部的元素,如果队列为空则会抛出 NoSuchElementException 异常,底层基于 poll()。 peek():获取但不移除队列头部的元素,如果队列为空,则该方法会直接返回 null,不会等待并阻塞线程。 先来看看 offer 方法,逻辑和 put 差不多,唯一的区别就是入队失败时不会阻塞当前线程,而是直接返回 false。\npublic boolean offer(E e) { //确保插入的元素不为null checkNotNull(e); //获取锁 final ReentrantLock lock = this.lock; lock.lock(); try { //队列已满直接返回false if (count == items.length) return false; else { //反之将元素入队并直接返回true enqueue(e); return true; } } finally { //释放锁 lock.unlock(); } } poll 方法同理,获取元素失败也是直接返回空,并不会阻塞获取元素的线程。\npublic E poll() { final ReentrantLock lock = this.lock; //上锁 lock.lock(); try { //如果队列为空直接返回null,反之出队返回元素值 return (count == 0) ? null : dequeue(); } finally { lock.unlock(); } } add 方法其实就是对于 offer 做了一层封装,如下代码所示,可以看到 add 会调用没有规定时间的 offer,如果入队失败则直接抛异常。\npublic boolean add(E e) { return super.add(e); } public boolean add(E e) { //调用offer方法如果失败直接抛出异常 if (offer(e)) return true; else throw new IllegalStateException(\u0026#34;Queue full\u0026#34;); } remove 方法同理,调用 poll,如果返回 null 则说明队列没有元素,直接抛出异常。\npublic E remove() { E x = poll(); if (x != null) return x; else throw new NoSuchElementException(); } peek() 方法的逻辑也很简单,内部调用了 itemAt 方法。\npublic E peek() { //加锁 final ReentrantLock lock = this.lock; lock.lock(); try { //当队列为空时返回 null return itemAt(takeIndex); } finally { //释放锁 lock.unlock(); } } //返回队列中指定位置的元素 @SuppressWarnings(\u0026#34;unchecked\u0026#34;) final E itemAt(int i) { return (E) items[i]; } 指定超时时间内阻塞式获取和新增元素 # 在 offer(E e) 和 poll() 非阻塞获取和新增元素的基础上,设计者提供了带有等待时间的 offer(E e, long timeout, TimeUnit unit) 和 poll(long timeout, TimeUnit unit) ,用于在指定的超时时间内阻塞式地添加和获取元素。\npublic boolean offer(E e, long timeout, TimeUnit unit) throws InterruptedException { checkNotNull(e); long nanos = unit.toNanos(timeout); final ReentrantLock lock = this.lock; lock.lockInterruptibly(); try { //队列已满,进入循环 while (count == items.length) { //时间到了队列还是满的,则直接返回false if (nanos \u0026lt;= 0) return false; //阻塞nanos时间,等待非满 nanos = notFull.awaitNanos(nanos); } enqueue(e); return true; } finally { lock.unlock(); } } 可以看到,带有超时时间的 offer 方法在队列已满的情况下,会等待用户所传的时间段,如果规定时间内还不能存放元素则直接返回 false。\npublic E poll(long timeout, TimeUnit unit) throws InterruptedException { long nanos = unit.toNanos(timeout); final ReentrantLock lock = this.lock; lock.lockInterruptibly(); try { //队列为空,循环等待,若时间到还是空的,则直接返回null while (count == 0) { if (nanos \u0026lt;= 0) return null; nanos = notEmpty.awaitNanos(nanos); } return dequeue(); } finally { lock.unlock(); } } 同理,带有超时时间的 poll 也一样,队列为空则在规定时间内等待,若时间到了还是空的,则直接返回 null。\n判断元素是否存在 # ArrayBlockingQueue 提供了 contains(Object o) 来判断指定元素是否存在于队列中。\npublic boolean contains(Object o) { //若目标元素为空,则直接返回 false if (o == null) return false; //获取当前队列的元素数组 final Object[] items = this.items; //加锁 final ReentrantLock lock = this.lock; lock.lock(); try { // 如果队列非空 if (count \u0026gt; 0) { final int putIndex = this.putIndex; //从队列头部开始遍历 int i = takeIndex; do { if (o.equals(items[i])) return true; if (++i == items.length) i = 0; } while (i != putIndex); } return false; } finally { //释放锁 lock.unlock(); } } ArrayBlockingQueue 获取和新增元素的方法对比 # 为了帮助理解 ArrayBlockingQueue ,我们再来对比一下上面提到的这些获取和新增元素的方法。\n新增元素:\n方法 队列满时处理方式 方法返回值 put(E e) 线程阻塞,直到中断或被唤醒 void offer(E e) 直接返回 false boolean offer(E e, long timeout, TimeUnit unit) 指定超时时间内阻塞,超过规定时间还未添加成功则返回 false boolean add(E e) 直接抛出 IllegalStateException 异常 boolean 获取/移除元素:\n方法 队列空时处理方式 方法返回值 take() 线程阻塞,直到中断或被唤醒 E poll() 返回 null E poll(long timeout, TimeUnit unit) 指定超时时间内阻塞,超过规定时间还是空的则返回 null E peek() 返回 null E remove() 直接抛出 NoSuchElementException 异常 boolean ArrayBlockingQueue 相关面试题 # ArrayBlockingQueue 是什么?它的特点是什么? # ArrayBlockingQueue 是 BlockingQueue 接口的有界队列实现类,常用于多线程之间的数据共享,底层采用数组实现,从其名字就能看出来了。\nArrayBlockingQueue 的容量有限,一旦创建,容量不能改变。\n为了保证线程安全,ArrayBlockingQueue 的并发控制采用可重入锁 ReentrantLock ,不管是插入操作还是读取操作,都需要获取到锁才能进行操作。并且,它还支持公平和非公平两种方式的锁访问机制,默认是非公平锁。\nArrayBlockingQueue 虽名为阻塞队列,但也支持非阻塞获取和新增元素(例如 poll() 和 offer(E e) 方法),只是队列满时添加元素会抛出异常,队列为空时获取的元素为 null,一般不会使用。\nArrayBlockingQueue 和 LinkedBlockingQueue 有什么区别? # ArrayBlockingQueue 和 LinkedBlockingQueue 是 Java 并发包中常用的两种阻塞队列实现,它们都是线程安全的。不过,不过它们之间也存在下面这些区别:\n底层实现:ArrayBlockingQueue 基于数组实现,而 LinkedBlockingQueue 基于链表实现。 是否有界:ArrayBlockingQueue 是有界队列,必须在创建时指定容量大小。LinkedBlockingQueue 创建时可以不指定容量大小,默认是Integer.MAX_VALUE,也就是无界的。但也可以指定队列大小,从而成为有界的。 锁是否分离: ArrayBlockingQueue中的锁是没有分离的,即生产和消费用的是同一个锁;LinkedBlockingQueue中的锁是分离的,即生产用的是putLock,消费是takeLock,这样可以防止生产者和消费者线程之间的锁争夺。 内存占用:ArrayBlockingQueue 需要提前分配数组内存,而 LinkedBlockingQueue 则是动态分配链表节点内存。这意味着,ArrayBlockingQueue 在创建时就会占用一定的内存空间,且往往申请的内存比实际所用的内存更大,而LinkedBlockingQueue 则是根据元素的增加而逐渐占用内存空间。 ArrayBlockingQueue 和 ConcurrentLinkedQueue 有什么区别? # ArrayBlockingQueue 和 ConcurrentLinkedQueue 是 Java 并发包中常用的两种队列实现,它们都是线程安全的。不过,不过它们之间也存在下面这些区别:\n底层实现:ArrayBlockingQueue 基于数组实现,而 ConcurrentLinkedQueue 基于链表实现。 是否有界:ArrayBlockingQueue 是有界队列,必须在创建时指定容量大小,而 ConcurrentLinkedQueue 是无界队列,可以动态地增加容量。 是否阻塞:ArrayBlockingQueue 支持阻塞和非阻塞两种获取和新增元素的方式(一般只会使用前者), ConcurrentLinkedQueue 是无界的,仅支持非阻塞式获取和新增元素。 ArrayBlockingQueue 的实现原理是什么? # ArrayBlockingQueue 的实现原理主要分为以下几点(这里以阻塞式获取和新增元素为例介绍):\nArrayBlockingQueue 内部维护一个定长的数组用于存储元素。 通过使用 ReentrantLock 锁对象对读写操作进行同步,即通过锁机制来实现线程安全。 通过 Condition 实现线程间的等待和唤醒操作。 这里再详细介绍一下线程间的等待和唤醒具体的实现(不需要记具体的方法,面试中回答要点即可):\n当队列已满时,生产者线程会调用 notFull.await() 方法让生产者进行等待,等待队列非满时插入(非满条件)。 当队列为空时,消费者线程会调用 notEmpty.await()方法让消费者进行等待,等待队列非空时消费(非空条件)。 当有新的元素被添加时,生产者线程会调用 notEmpty.signal()方法唤醒正在等待消费的消费者线程。 当队列中有元素被取出时,消费者线程会调用 notFull.signal()方法唤醒正在等待插入元素的生产者线程。 关于 Condition接口的补充:\nCondition是 JDK1.5 之后才有的,它具有很好的灵活性,比如可以实现多路通知功能也就是在一个Lock对象中可以创建多个Condition实例(即对象监视器),线程对象可以注册在指定的Condition中,从而可以有选择性的进行线程通知,在调度线程上更加灵活。 在使用notify()/notifyAll()方法进行通知时,被通知的线程是由 JVM 选择的,用ReentrantLock类结合Condition实例可以实现“选择性通知” ,这个功能非常重要,而且是 Condition 接口默认提供的。而synchronized关键字就相当于整个 Lock 对象中只有一个Condition实例,所有的线程都注册在它一个身上。如果执行notifyAll()方法的话就会通知所有处于等待状态的线程,这样会造成很大的效率问题。而Condition实例的signalAll()方法,只会唤醒注册在该Condition实例中的所有等待线程。\n参考文献 # 深入理解 Java 系列 | BlockingQueue 用法详解: https://juejin.cn/post/6999798721269465102 深入浅出阻塞队列 BlockingQueue 及其典型实现 ArrayBlockingQueue: https://zhuanlan.zhihu.com/p/539619957 并发编程大扫盲:ArrayBlockingQueue 底层原理和实战: https://zhuanlan.zhihu.com/p/339662987 "},{"id":457,"href":"/zh/docs/technology/Interview/java/collection/arraylist-source-code/","title":"ArrayList 源码分析","section":"Collection","content":" ArrayList 简介 # ArrayList 的底层是数组队列,相当于动态数组。与 Java 中的数组相比,它的容量能动态增长。在添加大量元素前,应用程序可以使用ensureCapacity操作来增加 ArrayList 实例的容量。这可以减少递增式再分配的数量。\nArrayList 继承于 AbstractList ,实现了 List, RandomAccess, Cloneable, java.io.Serializable 这些接口。\npublic class ArrayList\u0026lt;E\u0026gt; extends AbstractList\u0026lt;E\u0026gt; implements List\u0026lt;E\u0026gt;, RandomAccess, Cloneable, java.io.Serializable{ } List : 表明它是一个列表,支持添加、删除、查找等操作,并且可以通过下标进行访问。 RandomAccess :这是一个标志接口,表明实现这个接口的 List 集合是支持 快速随机访问 的。在 ArrayList 中,我们即可以通过元素的序号快速获取元素对象,这就是快速随机访问。 Cloneable :表明它具有拷贝能力,可以进行深拷贝或浅拷贝操作。 Serializable : 表明它可以进行序列化操作,也就是可以将对象转换为字节流进行持久化存储或网络传输,非常方便。 ArrayList 和 Vector 的区别?(了解即可) # ArrayList 是 List 的主要实现类,底层使用 Object[]存储,适用于频繁的查找工作,线程不安全 。 Vector 是 List 的古老实现类,底层使用Object[] 存储,线程安全。 ArrayList 可以添加 null 值吗? # ArrayList 中可以存储任何类型的对象,包括 null 值。不过,不建议向ArrayList 中添加 null 值, null 值无意义,会让代码难以维护比如忘记做判空处理就会导致空指针异常。\n示例代码:\nArrayList\u0026lt;String\u0026gt; listOfStrings = new ArrayList\u0026lt;\u0026gt;(); listOfStrings.add(null); listOfStrings.add(\u0026#34;java\u0026#34;); System.out.println(listOfStrings); 输出:\n[null, java] Arraylist 与 LinkedList 区别? # 是否保证线程安全: ArrayList 和 LinkedList 都是不同步的,也就是不保证线程安全; 底层数据结构: ArrayList 底层使用的是 Object 数组;LinkedList 底层使用的是 双向链表 数据结构(JDK1.6 之前为循环链表,JDK1.7 取消了循环。注意双向链表和双向循环链表的区别,下面有介绍到!) 插入和删除是否受元素位置的影响: ArrayList 采用数组存储,所以插入和删除元素的时间复杂度受元素位置的影响。 比如:执行add(E e)方法的时候, ArrayList 会默认在将指定的元素追加到此列表的末尾,这种情况时间复杂度就是 O(1)。但是如果要在指定位置 i 插入和删除元素的话(add(int index, E element)),时间复杂度就为 O(n)。因为在进行上述操作的时候集合中第 i 和第 i 个元素之后的(n-i)个元素都要执行向后位/向前移一位的操作。 LinkedList 采用链表存储,所以在头尾插入或者删除元素不受元素位置的影响(add(E e)、addFirst(E e)、addLast(E e)、removeFirst()、 removeLast()),时间复杂度为 O(1),如果是要在指定位置 i 插入和删除元素的话(add(int index, E element),remove(Object o),remove(int index)), 时间复杂度为 O(n) ,因为需要先移动到指定位置再插入和删除。 是否支持快速随机访问: LinkedList 不支持高效的随机元素访问,而 ArrayList(实现了 RandomAccess 接口) 支持。快速随机访问就是通过元素的序号快速获取元素对象(对应于get(int index)方法)。 内存空间占用: ArrayList 的空间浪费主要体现在在 list 列表的结尾会预留一定的容量空间,而 LinkedList 的空间花费则体现在它的每一个元素都需要消耗比 ArrayList 更多的空间(因为要存放直接后继和直接前驱以及数据)。 ArrayList 核心源码解读 # 这里以 JDK1.8 为例,分析一下 ArrayList 的底层源码。\npublic class ArrayList\u0026lt;E\u0026gt; extends AbstractList\u0026lt;E\u0026gt; implements List\u0026lt;E\u0026gt;, RandomAccess, Cloneable, java.io.Serializable { private static final long serialVersionUID = 8683452581122892189L; /** * 默认初始容量大小 */ private static final int DEFAULT_CAPACITY = 10; /** * 空数组(用于空实例)。 */ private static final Object[] EMPTY_ELEMENTDATA = {}; //用于默认大小空实例的共享空数组实例。 //我们把它从EMPTY_ELEMENTDATA数组中区分出来,以知道在添加第一个元素时容量需要增加多少。 private static final Object[] DEFAULTCAPACITY_EMPTY_ELEMENTDATA = {}; /** * 保存ArrayList数据的数组 */ transient Object[] elementData; // non-private to simplify nested class access /** * ArrayList 所包含的元素个数 */ private int size; /** * 带初始容量参数的构造函数(用户可以在创建ArrayList对象时自己指定集合的初始大小) */ public ArrayList(int initialCapacity) { if (initialCapacity \u0026gt; 0) { //如果传入的参数大于0,创建initialCapacity大小的数组 this.elementData = new Object[initialCapacity]; } else if (initialCapacity == 0) { //如果传入的参数等于0,创建空数组 this.elementData = EMPTY_ELEMENTDATA; } else { //其他情况,抛出异常 throw new IllegalArgumentException(\u0026#34;Illegal Capacity: \u0026#34; + initialCapacity); } } /** * 默认无参构造函数 * DEFAULTCAPACITY_EMPTY_ELEMENTDATA 为0.初始化为10,也就是说初始其实是空数组 当添加第一个元素的时候数组容量才变成10 */ public ArrayList() { this.elementData = DEFAULTCAPACITY_EMPTY_ELEMENTDATA; } /** * 构造一个包含指定集合的元素的列表,按照它们由集合的迭代器返回的顺序。 */ public ArrayList(Collection\u0026lt;? extends E\u0026gt; c) { //将指定集合转换为数组 elementData = c.toArray(); //如果elementData数组的长度不为0 if ((size = elementData.length) != 0) { // 如果elementData不是Object类型数据(c.toArray可能返回的不是Object类型的数组所以加上下面的语句用于判断) if (elementData.getClass() != Object[].class) //将原来不是Object类型的elementData数组的内容,赋值给新的Object类型的elementData数组 elementData = Arrays.copyOf(elementData, size, Object[].class); } else { // 其他情况,用空数组代替 this.elementData = EMPTY_ELEMENTDATA; } } /** * 修改这个ArrayList实例的容量是列表的当前大小。 应用程序可以使用此操作来最小化ArrayList实例的存储。 */ public void trimToSize() { modCount++; if (size \u0026lt; elementData.length) { elementData = (size == 0) ? EMPTY_ELEMENTDATA : Arrays.copyOf(elementData, size); } } //下面是ArrayList的扩容机制 //ArrayList的扩容机制提高了性能,如果每次只扩充一个, //那么频繁的插入会导致频繁的拷贝,降低性能,而ArrayList的扩容机制避免了这种情况。 /** * 如有必要,增加此ArrayList实例的容量,以确保它至少能容纳元素的数量 * * @param minCapacity 所需的最小容量 */ public void ensureCapacity(int minCapacity) { // 如果不是默认空数组,则minExpand的值为0; // 如果是默认空数组,则minExpand的值为10 int minExpand = (elementData != DEFAULTCAPACITY_EMPTY_ELEMENTDATA) // 如果不是默认元素表,则可以使用任意大小 ? 0 // 如果是默认空数组,它应该已经是默认大小 : DEFAULT_CAPACITY; // 如果最小容量大于已有的最大容量 if (minCapacity \u0026gt; minExpand) { // 根据需要的最小容量,确保容量足够 ensureExplicitCapacity(minCapacity); } } // 根据给定的最小容量和当前数组元素来计算所需容量。 private static int calculateCapacity(Object[] elementData, int minCapacity) { // 如果当前数组元素为空数组(初始情况),返回默认容量和最小容量中的较大值作为所需容量 if (elementData == DEFAULTCAPACITY_EMPTY_ELEMENTDATA) { return Math.max(DEFAULT_CAPACITY, minCapacity); } // 否则直接返回最小容量 return minCapacity; } // 确保内部容量达到指定的最小容量。 private void ensureCapacityInternal(int minCapacity) { ensureExplicitCapacity(calculateCapacity(elementData, minCapacity)); } //判断是否需要扩容 private void ensureExplicitCapacity(int minCapacity) { modCount++; // overflow-conscious code if (minCapacity - elementData.length \u0026gt; 0) //调用grow方法进行扩容,调用此方法代表已经开始扩容了 grow(minCapacity); } /** * 要分配的最大数组大小 */ private static final int MAX_ARRAY_SIZE = Integer.MAX_VALUE - 8; /** * ArrayList扩容的核心方法。 */ private void grow(int minCapacity) { // oldCapacity为旧容量,newCapacity为新容量 int oldCapacity = elementData.length; //将oldCapacity 右移一位,其效果相当于oldCapacity /2, //我们知道位运算的速度远远快于整除运算,整句运算式的结果就是将新容量更新为旧容量的1.5倍, int newCapacity = oldCapacity + (oldCapacity \u0026gt;\u0026gt; 1); //然后检查新容量是否大于最小需要容量,若还是小于最小需要容量,那么就把最小需要容量当作数组的新容量, if (newCapacity - minCapacity \u0026lt; 0) newCapacity = minCapacity; //再检查新容量是否超出了ArrayList所定义的最大容量, //若超出了,则调用hugeCapacity()来比较minCapacity和 MAX_ARRAY_SIZE, //如果minCapacity大于MAX_ARRAY_SIZE,则新容量则为Integer.MAX_VALUE,否则,新容量大小则为 MAX_ARRAY_SIZE。 if (newCapacity - MAX_ARRAY_SIZE \u0026gt; 0) newCapacity = hugeCapacity(minCapacity); // minCapacity is usually close to size, so this is a win: elementData = Arrays.copyOf(elementData, newCapacity); } //比较minCapacity和 MAX_ARRAY_SIZE private static int hugeCapacity(int minCapacity) { if (minCapacity \u0026lt; 0) // overflow throw new OutOfMemoryError(); return (minCapacity \u0026gt; MAX_ARRAY_SIZE) ? Integer.MAX_VALUE : MAX_ARRAY_SIZE; } /** * 返回此列表中的元素数。 */ public int size() { return size; } /** * 如果此列表不包含元素,则返回 true 。 */ public boolean isEmpty() { //注意=和==的区别 return size == 0; } /** * 如果此列表包含指定的元素,则返回true 。 */ public boolean contains(Object o) { //indexOf()方法:返回此列表中指定元素的首次出现的索引,如果此列表不包含此元素,则为-1 return indexOf(o) \u0026gt;= 0; } /** * 返回此列表中指定元素的首次出现的索引,如果此列表不包含此元素,则为-1 */ public int indexOf(Object o) { if (o == null) { for (int i = 0; i \u0026lt; size; i++) if (elementData[i] == null) return i; } else { for (int i = 0; i \u0026lt; size; i++) //equals()方法比较 if (o.equals(elementData[i])) return i; } return -1; } /** * 返回此列表中指定元素的最后一次出现的索引,如果此列表不包含元素,则返回-1。. */ public int lastIndexOf(Object o) { if (o == null) { for (int i = size - 1; i \u0026gt;= 0; i--) if (elementData[i] == null) return i; } else { for (int i = size - 1; i \u0026gt;= 0; i--) if (o.equals(elementData[i])) return i; } return -1; } /** * 返回此ArrayList实例的浅拷贝。 (元素本身不被复制。) */ public Object clone() { try { ArrayList\u0026lt;?\u0026gt; v = (ArrayList\u0026lt;?\u0026gt;) super.clone(); //Arrays.copyOf功能是实现数组的复制,返回复制后的数组。参数是被复制的数组和复制的长度 v.elementData = Arrays.copyOf(elementData, size); v.modCount = 0; return v; } catch (CloneNotSupportedException e) { // 这不应该发生,因为我们是可以克隆的 throw new InternalError(e); } } /** * 以正确的顺序(从第一个到最后一个元素)返回一个包含此列表中所有元素的数组。 * 返回的数组将是“安全的”,因为该列表不保留对它的引用。 * (换句话说,这个方法必须分配一个新的数组)。 * 因此,调用者可以自由地修改返回的数组结构。 * 注意:如果元素是引用类型,修改元素的内容会影响到原列表中的对象。 * 此方法充当基于数组和基于集合的API之间的桥梁。 */ public Object[] toArray() { return Arrays.copyOf(elementData, size); } /** * 以正确的顺序返回一个包含此列表中所有元素的数组(从第一个到最后一个元素); * 返回的数组的运行时类型是指定数组的运行时类型。 如果列表适合指定的数组,则返回其中。 * 否则,将为指定数组的运行时类型和此列表的大小分配一个新数组。 * 如果列表适用于指定的数组,其余空间(即数组的列表数量多于此元素),则紧跟在集合结束后的数组中的元素设置为null 。 * (这仅在调用者知道列表不包含任何空元素的情况下才能确定列表的长度。) */ @SuppressWarnings(\u0026#34;unchecked\u0026#34;) public \u0026lt;T\u0026gt; T[] toArray(T[] a) { if (a.length \u0026lt; size) // 新建一个运行时类型的数组,但是ArrayList数组的内容 return (T[]) Arrays.copyOf(elementData, size, a.getClass()); //调用System提供的arraycopy()方法实现数组之间的复制 System.arraycopy(elementData, 0, a, 0, size); if (a.length \u0026gt; size) a[size] = null; return a; } // Positional Access Operations @SuppressWarnings(\u0026#34;unchecked\u0026#34;) E elementData(int index) { return (E) elementData[index]; } /** * 返回此列表中指定位置的元素。 */ public E get(int index) { rangeCheck(index); return elementData(index); } /** * 用指定的元素替换此列表中指定位置的元素。 */ public E set(int index, E element) { //对index进行界限检查 rangeCheck(index); E oldValue = elementData(index); elementData[index] = element; //返回原来在这个位置的元素 return oldValue; } /** * 将指定的元素追加到此列表的末尾。 */ public boolean add(E e) { ensureCapacityInternal(size + 1); // Increments modCount!! //这里看到ArrayList添加元素的实质就相当于为数组赋值 elementData[size++] = e; return true; } /** * 在此列表中的指定位置插入指定的元素。 * 先调用 rangeCheckForAdd 对index进行界限检查;然后调用 ensureCapacityInternal 方法保证capacity足够大; * 再将从index开始之后的所有成员后移一个位置;将element插入index位置;最后size加1。 */ public void add(int index, E element) { rangeCheckForAdd(index); ensureCapacityInternal(size + 1); // Increments modCount!! //arraycopy()这个实现数组之间复制的方法一定要看一下,下面就用到了arraycopy()方法实现数组自己复制自己 System.arraycopy(elementData, index, elementData, index + 1, size - index); elementData[index] = element; size++; } /** * 删除该列表中指定位置的元素。 将任何后续元素移动到左侧(从其索引中减去一个元素)。 */ public E remove(int index) { rangeCheck(index); modCount++; E oldValue = elementData(index); int numMoved = size - index - 1; if (numMoved \u0026gt; 0) System.arraycopy(elementData, index + 1, elementData, index, numMoved); elementData[--size] = null; // clear to let GC do its work //从列表中删除的元素 return oldValue; } /** * 从列表中删除指定元素的第一个出现(如果存在)。 如果列表不包含该元素,则它不会更改。 * 返回true,如果此列表包含指定的元素 */ public boolean remove(Object o) { if (o == null) { for (int index = 0; index \u0026lt; size; index++) if (elementData[index] == null) { fastRemove(index); return true; } } else { for (int index = 0; index \u0026lt; size; index++) if (o.equals(elementData[index])) { fastRemove(index); return true; } } return false; } /* * 该方法为私有的移除方法,跳过了边界检查,并且不返回被移除的值。 */ private void fastRemove(int index) { modCount++; int numMoved = size - index - 1; if (numMoved \u0026gt; 0) System.arraycopy(elementData, index + 1, elementData, index, numMoved); elementData[--size] = null; // 在移除元素后,将该位置的元素设为 null,以便垃圾回收器(GC)能够回收该元素。 } /** * 从列表中删除所有元素。 */ public void clear() { modCount++; // 把数组中所有的元素的值设为null for (int i = 0; i \u0026lt; size; i++) elementData[i] = null; size = 0; } /** * 按指定集合的Iterator返回的顺序将指定集合中的所有元素追加到此列表的末尾。 */ public boolean addAll(Collection\u0026lt;? extends E\u0026gt; c) { Object[] a = c.toArray(); int numNew = a.length; ensureCapacityInternal(size + numNew); // Increments modCount System.arraycopy(a, 0, elementData, size, numNew); size += numNew; return numNew != 0; } /** * 将指定集合中的所有元素插入到此列表中,从指定的位置开始。 */ public boolean addAll(int index, Collection\u0026lt;? extends E\u0026gt; c) { rangeCheckForAdd(index); Object[] a = c.toArray(); int numNew = a.length; ensureCapacityInternal(size + numNew); // Increments modCount int numMoved = size - index; if (numMoved \u0026gt; 0) System.arraycopy(elementData, index, elementData, index + numNew, numMoved); System.arraycopy(a, 0, elementData, index, numNew); size += numNew; return numNew != 0; } /** * 从此列表中删除所有索引为fromIndex (含)和toIndex之间的元素。 * 将任何后续元素移动到左侧(减少其索引)。 */ protected void removeRange(int fromIndex, int toIndex) { modCount++; int numMoved = size - toIndex; System.arraycopy(elementData, toIndex, elementData, fromIndex, numMoved); // clear to let GC do its work int newSize = size - (toIndex - fromIndex); for (int i = newSize; i \u0026lt; size; i++) { elementData[i] = null; } size = newSize; } /** * 检查给定的索引是否在范围内。 */ private void rangeCheck(int index) { if (index \u0026gt;= size) throw new IndexOutOfBoundsException(outOfBoundsMsg(index)); } /** * add和addAll使用的rangeCheck的一个版本 */ private void rangeCheckForAdd(int index) { if (index \u0026gt; size || index \u0026lt; 0) throw new IndexOutOfBoundsException(outOfBoundsMsg(index)); } /** * 返回IndexOutOfBoundsException细节信息 */ private String outOfBoundsMsg(int index) { return \u0026#34;Index: \u0026#34; + index + \u0026#34;, Size: \u0026#34; + size; } /** * 从此列表中删除指定集合中包含的所有元素。 */ public boolean removeAll(Collection\u0026lt;?\u0026gt; c) { Objects.requireNonNull(c); //如果此列表被修改则返回true return batchRemove(c, false); } /** * 仅保留此列表中包含在指定集合中的元素。 * 换句话说,从此列表中删除其中不包含在指定集合中的所有元素。 */ public boolean retainAll(Collection\u0026lt;?\u0026gt; c) { Objects.requireNonNull(c); return batchRemove(c, true); } /** * 从列表中的指定位置开始,返回列表中的元素(按正确顺序)的列表迭代器。 * 指定的索引表示初始调用将返回的第一个元素为next 。 初始调用previous将返回指定索引减1的元素。 * 返回的列表迭代器是fail-fast 。 */ public ListIterator\u0026lt;E\u0026gt; listIterator(int index) { if (index \u0026lt; 0 || index \u0026gt; size) throw new IndexOutOfBoundsException(\u0026#34;Index: \u0026#34; + index); return new ListItr(index); } /** * 返回列表中的列表迭代器(按适当的顺序)。 * 返回的列表迭代器是fail-fast 。 */ public ListIterator\u0026lt;E\u0026gt; listIterator() { return new ListItr(0); } /** * 以正确的顺序返回该列表中的元素的迭代器。 * 返回的迭代器是fail-fast 。 */ public Iterator\u0026lt;E\u0026gt; iterator() { return new Itr(); } ArrayList 扩容机制分析 # 先从 ArrayList 的构造函数说起 # ArrayList 有三种方式来初始化,构造方法源码如下(JDK8):\n/** * 默认初始容量大小 */ private static final int DEFAULT_CAPACITY = 10; private static final Object[] DEFAULTCAPACITY_EMPTY_ELEMENTDATA = {}; /** * 默认构造函数,使用初始容量10构造一个空列表(无参数构造) */ public ArrayList() { this.elementData = DEFAULTCAPACITY_EMPTY_ELEMENTDATA; } /** * 带初始容量参数的构造函数。(用户自己指定容量) */ public ArrayList(int initialCapacity) { if (initialCapacity \u0026gt; 0) {//初始容量大于0 //创建initialCapacity大小的数组 this.elementData = new Object[initialCapacity]; } else if (initialCapacity == 0) {//初始容量等于0 //创建空数组 this.elementData = EMPTY_ELEMENTDATA; } else {//初始容量小于0,抛出异常 throw new IllegalArgumentException(\u0026#34;Illegal Capacity: \u0026#34; + initialCapacity); } } /** *构造包含指定collection元素的列表,这些元素利用该集合的迭代器按顺序返回 *如果指定的集合为null,throws NullPointerException。 */ public ArrayList(Collection\u0026lt;? extends E\u0026gt; c) { elementData = c.toArray(); if ((size = elementData.length) != 0) { // c.toArray might (incorrectly) not return Object[] (see 6260652) if (elementData.getClass() != Object[].class) elementData = Arrays.copyOf(elementData, size, Object[].class); } else { // replace with empty array. this.elementData = EMPTY_ELEMENTDATA; } } 细心的同学一定会发现:以无参数构造方法创建 ArrayList 时,实际上初始化赋值的是一个空数组。当真正对数组进行添加元素操作时,才真正分配容量。即向数组中添加第一个元素时,数组容量扩为 10。 下面在我们分析 ArrayList 扩容时会讲到这一点内容!\n补充:JDK6 new 无参构造的 ArrayList 对象时,直接创建了长度是 10 的 Object[] 数组 elementData 。\n一步一步分析 ArrayList 扩容机制 # 这里以无参构造函数创建的 ArrayList 为例分析。\nadd 方法 # /** * 将指定的元素追加到此列表的末尾。 */ public boolean add(E e) { // 加元素之前,先调用ensureCapacityInternal方法 ensureCapacityInternal(size + 1); // Increments modCount!! // 这里看到ArrayList添加元素的实质就相当于为数组赋值 elementData[size++] = e; return true; } 注意:JDK11 移除了 ensureCapacityInternal() 和 ensureExplicitCapacity() 方法\nensureCapacityInternal 方法的源码如下:\n// 根据给定的最小容量和当前数组元素来计算所需容量。 private static int calculateCapacity(Object[] elementData, int minCapacity) { // 如果当前数组元素为空数组(初始情况),返回默认容量和最小容量中的较大值作为所需容量 if (elementData == DEFAULTCAPACITY_EMPTY_ELEMENTDATA) { return Math.max(DEFAULT_CAPACITY, minCapacity); } // 否则直接返回最小容量 return minCapacity; } // 确保内部容量达到指定的最小容量。 private void ensureCapacityInternal(int minCapacity) { ensureExplicitCapacity(calculateCapacity(elementData, minCapacity)); } ensureCapacityInternal 方法非常简单,内部直接调用了 ensureExplicitCapacity 方法:\n//判断是否需要扩容 private void ensureExplicitCapacity(int minCapacity) { modCount++; //判断当前数组容量是否足以存储minCapacity个元素 if (minCapacity - elementData.length \u0026gt; 0) //调用grow方法进行扩容 grow(minCapacity); } 我们来仔细分析一下:\n当我们要 add 进第 1 个元素到 ArrayList 时,elementData.length 为 0 (因为还是一个空的 list),因为执行了 ensureCapacityInternal() 方法 ,所以 minCapacity 此时为 10。此时,minCapacity - elementData.length \u0026gt; 0成立,所以会进入 grow(minCapacity) 方法。 当 add 第 2 个元素时,minCapacity 为 2,此时 elementData.length(容量)在添加第一个元素后扩容成 10 了。此时,minCapacity - elementData.length \u0026gt; 0 不成立,所以不会进入 (执行)grow(minCapacity) 方法。 添加第 3、4···到第 10 个元素时,依然不会执行 grow 方法,数组容量都为 10。 直到添加第 11 个元素,minCapacity(为 11)比 elementData.length(为 10)要大。进入 grow 方法进行扩容。\ngrow 方法 # /** * 要分配的最大数组大小 */ private static final int MAX_ARRAY_SIZE = Integer.MAX_VALUE - 8; /** * ArrayList扩容的核心方法。 */ private void grow(int minCapacity) { // oldCapacity为旧容量,newCapacity为新容量 int oldCapacity = elementData.length; // 将oldCapacity 右移一位,其效果相当于oldCapacity /2, // 我们知道位运算的速度远远快于整除运算,整句运算式的结果就是将新容量更新为旧容量的1.5倍, int newCapacity = oldCapacity + (oldCapacity \u0026gt;\u0026gt; 1); // 然后检查新容量是否大于最小需要容量,若还是小于最小需要容量,那么就把最小需要容量当作数组的新容量, if (newCapacity - minCapacity \u0026lt; 0) newCapacity = minCapacity; // 如果新容量大于 MAX_ARRAY_SIZE,进入(执行) `hugeCapacity()` 方法来比较 minCapacity 和 MAX_ARRAY_SIZE, // 如果minCapacity大于最大容量,则新容量则为`Integer.MAX_VALUE`,否则,新容量大小则为 MAX_ARRAY_SIZE 即为 `Integer.MAX_VALUE - 8`。 if (newCapacity - MAX_ARRAY_SIZE \u0026gt; 0) newCapacity = hugeCapacity(minCapacity); // minCapacity is usually close to size, so this is a win: elementData = Arrays.copyOf(elementData, newCapacity); } int newCapacity = oldCapacity + (oldCapacity \u0026gt;\u0026gt; 1),所以 ArrayList 每次扩容之后容量都会变为原来的 1.5 倍左右(oldCapacity 为偶数就是 1.5 倍,否则是 1.5 倍左右)! 奇偶不同,比如:10+10/2 = 15, 33+33/2=49。如果是奇数的话会丢掉小数.\n\u0026ldquo;\u0026raquo;\u0026quot;(移位运算符):\u0026raquo;1 右移一位相当于除 2,右移 n 位相当于除以 2 的 n 次方。这里 oldCapacity 明显右移了 1 位所以相当于 oldCapacity /2。对于大数据的 2 进制运算,位移运算符比那些普通运算符的运算要快很多,因为程序仅仅移动一下而已,不去计算,这样提高了效率,节省了资源\n我们再来通过例子探究一下grow() 方法:\n当 add 第 1 个元素时,oldCapacity 为 0,经比较后第一个 if 判断成立,newCapacity = minCapacity(为 10)。但是第二个 if 判断不会成立,即 newCapacity 不比 MAX_ARRAY_SIZE 大,则不会进入 hugeCapacity 方法。数组容量为 10,add 方法中 return true,size 增为 1。 当 add 第 11 个元素进入 grow 方法时,newCapacity 为 15,比 minCapacity(为 11)大,第一个 if 判断不成立。新容量没有大于数组最大 size,不会进入 hugeCapacity 方法。数组容量扩为 15,add 方法中 return true,size 增为 11。 以此类推······ 这里补充一点比较重要,但是容易被忽视掉的知识点:\nJava 中的 length属性是针对数组说的,比如说你声明了一个数组,想知道这个数组的长度则用到了 length 这个属性. Java 中的 length() 方法是针对字符串说的,如果想看这个字符串的长度则用到 length() 这个方法. Java 中的 size() 方法是针对泛型集合说的,如果想看这个泛型有多少个元素,就调用此方法来查看! hugeCapacity() 方法 # 从上面 grow() 方法源码我们知道:如果新容量大于 MAX_ARRAY_SIZE,进入(执行) hugeCapacity() 方法来比较 minCapacity 和 MAX_ARRAY_SIZE,如果 minCapacity 大于最大容量,则新容量则为Integer.MAX_VALUE,否则,新容量大小则为 MAX_ARRAY_SIZE 即为 Integer.MAX_VALUE - 8。\nprivate static int hugeCapacity(int minCapacity) { if (minCapacity \u0026lt; 0) // overflow throw new OutOfMemoryError(); // 对minCapacity和MAX_ARRAY_SIZE进行比较 // 若minCapacity大,将Integer.MAX_VALUE作为新数组的大小 // 若MAX_ARRAY_SIZE大,将MAX_ARRAY_SIZE作为新数组的大小 // MAX_ARRAY_SIZE = Integer.MAX_VALUE - 8; return (minCapacity \u0026gt; MAX_ARRAY_SIZE) ? Integer.MAX_VALUE : MAX_ARRAY_SIZE; } System.arraycopy() 和 Arrays.copyOf()方法 # 阅读源码的话,我们就会发现 ArrayList 中大量调用了这两个方法。比如:我们上面讲的扩容操作以及add(int index, E element)、toArray() 等方法中都用到了该方法!\nSystem.arraycopy() 方法 # 源码:\n// 我们发现 arraycopy 是一个 native 方法,接下来我们解释一下各个参数的具体意义 /** * 复制数组 * @param src 源数组 * @param srcPos 源数组中的起始位置 * @param dest 目标数组 * @param destPos 目标数组中的起始位置 * @param length 要复制的数组元素的数量 */ public static native void arraycopy(Object src, int srcPos, Object dest, int destPos, int length); 场景:\n/** * 在此列表中的指定位置插入指定的元素。 *先调用 rangeCheckForAdd 对index进行界限检查;然后调用 ensureCapacityInternal 方法保证capacity足够大; *再将从index开始之后的所有成员后移一个位置;将element插入index位置;最后size加1。 */ public void add(int index, E element) { rangeCheckForAdd(index); ensureCapacityInternal(size + 1); // Increments modCount!! //arraycopy()方法实现数组自己复制自己 //elementData:源数组;index:源数组中的起始位置;elementData:目标数组;index + 1:目标数组中的起始位置; size - index:要复制的数组元素的数量; System.arraycopy(elementData, index, elementData, index + 1, size - index); elementData[index] = element; size++; } 我们写一个简单的方法测试以下:\npublic class ArraycopyTest { public static void main(String[] args) { // TODO Auto-generated method stub int[] a = new int[10]; a[0] = 0; a[1] = 1; a[2] = 2; a[3] = 3; System.arraycopy(a, 2, a, 3, 3); a[2]=99; for (int i = 0; i \u0026lt; a.length; i++) { System.out.print(a[i] + \u0026#34; \u0026#34;); } } } 结果:\n0 1 99 2 3 0 0 0 0 0 Arrays.copyOf()方法 # 源码:\npublic static int[] copyOf(int[] original, int newLength) { // 申请一个新的数组 int[] copy = new int[newLength]; // 调用System.arraycopy,将源数组中的数据进行拷贝,并返回新的数组 System.arraycopy(original, 0, copy, 0, Math.min(original.length, newLength)); return copy; } 场景:\n/** 以正确的顺序返回一个包含此列表中所有元素的数组(从第一个到最后一个元素); 返回的数组的运行时类型是指定数组的运行时类型。 */ public Object[] toArray() { //elementData:要复制的数组;size:要复制的长度 return Arrays.copyOf(elementData, size); } 个人觉得使用 Arrays.copyOf()方法主要是为了给原有数组扩容,测试代码如下:\npublic class ArrayscopyOfTest { public static void main(String[] args) { int[] a = new int[3]; a[0] = 0; a[1] = 1; a[2] = 2; int[] b = Arrays.copyOf(a, 10); System.out.println(\u0026#34;b.length\u0026#34;+b.length); } } 结果:\n10 两者联系和区别 # 联系:\n看两者源代码可以发现 copyOf()内部实际调用了 System.arraycopy() 方法\n区别:\narraycopy() 需要目标数组,将原数组拷贝到你自己定义的数组里或者原数组,而且可以选择拷贝的起点和长度以及放入新数组中的位置 copyOf() 是系统自动在内部新建一个数组,并返回该数组。\nensureCapacity方法 # ArrayList 源码中有一个 ensureCapacity 方法不知道大家注意到没有,这个方法 ArrayList 内部没有被调用过,所以很显然是提供给用户调用的,那么这个方法有什么作用呢?\n/** 如有必要,增加此 ArrayList 实例的容量,以确保它至少可以容纳由minimum capacity参数指定的元素数。 * * @param minCapacity 所需的最小容量 */ public void ensureCapacity(int minCapacity) { int minExpand = (elementData != DEFAULTCAPACITY_EMPTY_ELEMENTDATA) // any size if not default element table ? 0 // larger than default for default empty table. It\u0026#39;s already // supposed to be at default size. : DEFAULT_CAPACITY; if (minCapacity \u0026gt; minExpand) { ensureExplicitCapacity(minCapacity); } } 理论上来说,最好在向 ArrayList 添加大量元素之前用 ensureCapacity 方法,以减少增量重新分配的次数\n我们通过下面的代码实际测试以下这个方法的效果:\npublic class EnsureCapacityTest { public static void main(String[] args) { ArrayList\u0026lt;Object\u0026gt; list = new ArrayList\u0026lt;Object\u0026gt;(); final int N = 10000000; long startTime = System.currentTimeMillis(); for (int i = 0; i \u0026lt; N; i++) { list.add(i); } long endTime = System.currentTimeMillis(); System.out.println(\u0026#34;使用ensureCapacity方法前:\u0026#34;+(endTime - startTime)); } } 运行结果:\n使用ensureCapacity方法前:2158 public class EnsureCapacityTest { public static void main(String[] args) { ArrayList\u0026lt;Object\u0026gt; list = new ArrayList\u0026lt;Object\u0026gt;(); final int N = 10000000; long startTime1 = System.currentTimeMillis(); list.ensureCapacity(N); for (int i = 0; i \u0026lt; N; i++) { list.add(i); } long endTime1 = System.currentTimeMillis(); System.out.println(\u0026#34;使用ensureCapacity方法后:\u0026#34;+(endTime1 - startTime1)); } } 运行结果:\n使用ensureCapacity方法后:1773 通过运行结果,我们可以看出向 ArrayList 添加大量元素之前使用ensureCapacity 方法可以提升性能。不过,这个性能差距几乎可以忽略不计。而且,实际项目根本也不可能往 ArrayList 里面添加这么多元素。\n"},{"id":458,"href":"/zh/docs/technology/Interview/system-design/framework/spring/async1/","title":"Async 注解原理分析","section":"Framework","content":"@Async 注解由 Spring 框架提供,被该注解标注的类或方法会在 异步线程 中执行。这意味着当方法被调用时,调用者将不会等待该方法执行完成,而是可以继续执行后续的代码。\n@Async 注解的使用非常简单,需要两个步骤:\n在启动类上添加注解 @EnableAsync ,开启异步任务。 在需要异步执行的方法或类上添加注解 @Async 。 @SpringBootApplication // 开启异步任务 @EnableAsync public class YourApplication { public static void main(String[] args) { SpringApplication.run(YourApplication.class, args); } } // 异步服务类 @Service public class MyService { // 推荐使用自定义线程池,这里只是演示基本用法 @Async public CompletableFuture\u0026lt;String\u0026gt; doSomethingAsync() { // 这里会有一些业务耗时操作 // ... // 使用 CompletableFuture 可以更方便地处理异步任务的结果,避免阻塞主线程 return CompletableFuture.completedFuture(\u0026#34;Async Task Completed\u0026#34;); } } 接下来,我们一起来看看 @Async 的底层原理。\n@Async 原理分析 # @Async 可以异步执行任务,本质上是使用 动态代理 来实现的。通过 Spring 中的后置处理器 BeanPostProcessor 为使用 @Async 注解的类创建动态代理,之后 @Async 注解方法的调用会被动态代理拦截,在拦截器中将方法的执行封装为异步任务提交给线程池处理。\n接下来,我们来详细分析一下。\n开启异步 # 使用 @Async 之前,需要在启动类上添加 @EnableAsync 来开启异步,@EnableAsync 注解如下:\n// 省略其他注解 ... @Import(AsyncConfigurationSelector.class) public @interface EnableAsync { /* ... */ } 在 @EnableAsync 注解上通过 @Import 注解引入了 AsyncConfigurationSelector ,因此 Spring 会去加载通过 @Import 注解引入的类。\nAsyncConfigurationSelector 类实现了 ImportSelector 接口,因此在该类中会重写 selectImports() 方法来自定义加载 Bean 的逻辑,如下:\npublic class AsyncConfigurationSelector extends AdviceModeImportSelector\u0026lt;EnableAsync\u0026gt; { @Override @Nullable public String[] selectImports(AdviceMode adviceMode) { switch (adviceMode) { // 基于 JDK 代理织入的通知 case PROXY: return new String[] {ProxyAsyncConfiguration.class.getName()}; // 基于 AspectJ 织入的通知 case ASPECTJ: return new String[] {ASYNC_EXECUTION_ASPECT_CONFIGURATION_CLASS_NAME}; default: return null; } } } 在 selectImports() 方法中,会根据通知的不同类型来选择加载不同的类,其中 adviceMode 默认值为 PROXY 。\n这里以基于 JDK 代理的通知为例,此时会加载 ProxyAsyncConfiguration 类,如下:\n@Configuration @Role(BeanDefinition.ROLE_INFRASTRUCTURE) public class ProxyAsyncConfiguration extends AbstractAsyncConfiguration { @Bean(name = TaskManagementConfigUtils.ASYNC_ANNOTATION_PROCESSOR_BEAN_NAME) @Role(BeanDefinition.ROLE_INFRASTRUCTURE) public AsyncAnnotationBeanPostProcessor asyncAdvisor() { // ... // 加载后置处理器 AsyncAnnotationBeanPostProcessor bpp = new AsyncAnnotationBeanPostProcessor(); // ... return bpp; } } 后置处理器 # 在 ProxyAsyncConfiguration 类中,会通过 @Bean 注解加载一个后置处理器 AsyncAnnotationBeanPostProcessor ,这个后置处理器是使 @Async 注解起作用的关键。\n如果某一个类或者方法上使用了 @Async 注解,AsyncAnnotationBeanPostProcessor 处理器就会为该类创建一个动态代理。\n该类的方法在执行时,会被代理对象的拦截器所拦截,其中被 @Async 注解标记的方法会异步执行。\nAsyncAnnotationBeanPostProcessor 代码如下:\npublic class AsyncAnnotationBeanPostProcessor extends AbstractBeanFactoryAwareAdvisingPostProcessor { @Override public void setBeanFactory(BeanFactory beanFactory) { super.setBeanFactory(beanFactory); // 创建 AsyncAnnotationAdvisor,它是一个 Advisor // 用于拦截带有 @Async 注解的方法并将这些方法异步执行。 AsyncAnnotationAdvisor advisor = new AsyncAnnotationAdvisor(this.executor, this.exceptionHandler); // 如果设置了自定义的 asyncAnnotationType,则将其设置到 advisor 中。 // asyncAnnotationType 用于指定自定义的异步注解,例如 @MyAsync。 if (this.asyncAnnotationType != null) { advisor.setAsyncAnnotationType(this.asyncAnnotationType); } advisor.setBeanFactory(beanFactory); this.advisor = advisor; } } AsyncAnnotationBeanPostProcessor 的父类实现了 BeanFactoryAware 接口,因此在该类中重写了 setBeanFactory() 方法作为扩展点,来加载 AsyncAnnotationAdvisor 。\n创建 Advisor # Advisor 是 Spring AOP 对 Advice 和 Pointcut 的抽象。Advice 为执行的通知逻辑,Pointcut 为通知执行的切入点。\n在后置处理器 AsyncAnnotationBeanPostProcessor 中会去创建 AsyncAnnotationAdvisor , 在它的构造方法中,会构建对应的 Advice 和 Pointcut ,如下:\npublic class AsyncAnnotationAdvisor extends AbstractPointcutAdvisor implements BeanFactoryAware { private Advice advice; // 异步执行的 Advice private Pointcut pointcut; // 匹配 @Async 注解方法的切点 // 构造函数 public AsyncAnnotationAdvisor(/* 参数省略 */) { // 1. 创建 Advice,负责异步执行逻辑 this.advice = buildAdvice(executor, exceptionHandler); // 2. 创建 Pointcut,选择要被增强的目标方法 this.pointcut = buildPointcut(asyncAnnotationTypes); } // 创建 Advice protected Advice buildAdvice(/* 参数省略 */) { // 创建处理异步执行的拦截器 AnnotationAsyncExecutionInterceptor interceptor = new AnnotationAsyncExecutionInterceptor(null); // 使用执行器和异常处理器配置拦截器 interceptor.configure(executor, exceptionHandler); return interceptor; } // 创建 Pointcut protected Pointcut buildPointcut(Set\u0026lt;Class\u0026lt;? extends Annotation\u0026gt;\u0026gt; asyncAnnotationTypes) { ComposablePointcut result = null; for (Class\u0026lt;? extends Annotation\u0026gt; asyncAnnotationType : asyncAnnotationTypes) { // 1. 类级别切点:如果类上有注解则匹配 Pointcut cpc = new AnnotationMatchingPointcut(asyncAnnotationType, true); // 2. 方法级别切点:如果方法上有注解则匹配 Pointcut mpc = new AnnotationMatchingPointcut(null, asyncAnnotationType, true); if (result == null) { result = new ComposablePointcut(cpc); } else { // 使用 union 合并之前的切点 result.union(cpc); } // 将方法级别切点添加到组合切点 result = result.union(mpc); } // 返回组合切点,如果没有提供注解类型则返回 Pointcut.TRUE return (result != null ? result : Pointcut.TRUE); } } AsyncAnnotationAdvisor 的核心在于构建 Advice 和 Pointcut :\n构建 Advice :会创建 AnnotationAsyncExecutionInterceptor 拦截器,在拦截器的 invoke() 方法中会执行通知的逻辑。 构建 Pointcut :由 ClassFilter 和 MethodMatcher 组成,用于匹配哪些方法需要执行通知( Advice )的逻辑。 后置处理逻辑 # AsyncAnnotationBeanPostProcessor 后置处理器中实现的 postProcessAfterInitialization() 方法在其父类 AbstractAdvisingBeanPostProcessor 中,在 Bean 初始化之后,会进入到 postProcessAfterInitialization() 方法进行后置处理。\n在后置处理方法中,会判断 Bean 是否符合后置处理器中 Advisor 通知的条件,如果符合,则创建代理对象。如下:\n// AbstractAdvisingBeanPostProcessor public Object postProcessAfterInitialization(Object bean, String beanName) { if (this.advisor == null || bean instanceof AopInfrastructureBean) { return bean; } if (bean instanceof Advised) { Advised advised = (Advised) bean; if (!advised.isFrozen() \u0026amp;\u0026amp; isEligible(AopUtils.getTargetClass(bean))) { if (this.beforeExistingAdvisors) { advised.addAdvisor(0, this.advisor); } else { advised.addAdvisor(this.advisor); } return bean; } } // 判断给定的 Bean 是否符合后置处理器中 Advisor 通知的条件,符合的话,就创建代理对象。 if (isEligible(bean, beanName)) { ProxyFactory proxyFactory = prepareProxyFactory(bean, beanName); if (!proxyFactory.isProxyTargetClass()) { evaluateProxyInterfaces(bean.getClass(), proxyFactory); } // 添加 Advisor。 proxyFactory.addAdvisor(this.advisor); customizeProxyFactory(proxyFactory); // 返回代理对象。 return proxyFactory.getProxy(getProxyClassLoader()); } return bean; } @Async 注解方法的拦截 # @Async 注解方法的执行会在 AnnotationAsyncExecutionInterceptor 中被拦截,在 invoke() 方法中执行拦截器的逻辑。此时会将 @Async 注解标注的方法封装为异步任务,交给执行器来执行。\ninvoke() 方法在 AnnotationAsyncExecutionInterceptor 的父类 AsyncExecutionInterceptor 中定义,如下:\npublic class AsyncExecutionInterceptor extends AsyncExecutionAspectSupport implements MethodInterceptor, Ordered { @Override @Nullable public Object invoke(final MethodInvocation invocation) throws Throwable { Class\u0026lt;?\u0026gt; targetClass = (invocation.getThis() != null ? AopUtils.getTargetClass(invocation.getThis()) : null); Method specificMethod = ClassUtils.getMostSpecificMethod(invocation.getMethod(), targetClass); final Method userDeclaredMethod = BridgeMethodResolver.findBridgedMethod(specificMethod); // 1、确定异步任务执行器 AsyncTaskExecutor executor = determineAsyncExecutor(userDeclaredMethod); // 2、将要执行的方法封装为 Callable 异步任务 Callable\u0026lt;Object\u0026gt; task = () -\u0026gt; { try { // 2.1、执行方法 Object result = invocation.proceed(); // 2.2、如果方法返回值是 Future 类型,阻塞等待结果 if (result instanceof Future) { return ((Future\u0026lt;?\u0026gt;) result).get(); } } catch (ExecutionException ex) { handleError(ex.getCause(), userDeclaredMethod, invocation.getArguments()); } catch (Throwable ex) { handleError(ex, userDeclaredMethod, invocation.getArguments()); } return null; }; // 3、提交任务 return doSubmit(task, executor, invocation.getMethod().getReturnType()); } } 在 invoke() 方法中,主要有 3 个步骤:\n确定执行异步任务的执行器。 将 @Async 注解标注的方法封装为 Callable 异步任务。 将任务提交给执行器执行。 1、获取异步任务执行器 # 在 determineAsyncExecutor() 方法中,会获取异步任务的执行器(即执行异步任务的 线程池 )。代码如下:\n// 确定异步任务的执行器 protected AsyncTaskExecutor determineAsyncExecutor(Method method) { // 1、先从缓存中获取。 AsyncTaskExecutor executor = this.executors.get(method); if (executor == null) { Executor targetExecutor; // 2、获取执行器的限定符。 String qualifier = getExecutorQualifier(method); if (StringUtils.hasLength(qualifier)) { // 3、根据限定符获取对应的执行器。 targetExecutor = findQualifiedExecutor(this.beanFactory, qualifier); } else { // 4、如果没有限定符,则使用默认的执行器。即 Spring 提供的默认线程池:SimpleAsyncTaskExecutor。 targetExecutor = this.defaultExecutor.get(); } if (targetExecutor == null) { return null; } // 5、将执行器包装为 TaskExecutorAdapter 适配器。 // TaskExecutorAdapter 是 Spring 对于 JDK 线程池做的一层抽象,还是继承自 JDK 的线程池 Executor。这里可以不用管太多,只要知道它是线程池就可以了。 executor = (targetExecutor instanceof AsyncListenableTaskExecutor ? (AsyncListenableTaskExecutor) targetExecutor : new TaskExecutorAdapter(targetExecutor)); this.executors.put(method, executor); } return executor; } 在 determineAsyncExecutor() 方法中确定了异步任务的执行器(线程池),主要是通过 @Async 注解的 value 值来获取执行器的限定符,根据限定符再去 BeanFactory 中查找对应的执行器就可以了。\n如果在 @Async 注解中没有指定线程池,则会通过 this.defaultExecutor.get() 来获取默认的线程池,其中 defaultExecutor 在下边方法中进行赋值:\n// AsyncExecutionInterceptor protected Executor getDefaultExecutor(@Nullable BeanFactory beanFactory) { // 1、尝试从 beanFactory 中获取线程池。 Executor defaultExecutor = super.getDefaultExecutor(beanFactory); // 2、如果 beanFactory 中没有,则创建 SimpleAsyncTaskExecutor 线程池。 return (defaultExecutor != null ? defaultExecutor : new SimpleAsyncTaskExecutor()); } 其中 super.getDefaultExecutor() 会在 beanFactory 中尝试获取 Executor 类型的线程池。代码如下:\nprotected Executor getDefaultExecutor(@Nullable BeanFactory beanFactory) { if (beanFactory != null) { try { // 1、从 beanFactory 中获取 TaskExecutor 类型的线程池。 return beanFactory.getBean(TaskExecutor.class); } catch (NoUniqueBeanDefinitionException ex) { try { // 2、如果有多个,则尝试从 beanFactory 中获取执行名称的 Executor 线程池。 return beanFactory.getBean(DEFAULT_TASK_EXECUTOR_BEAN_NAME, Executor.class); } catch (NoSuchBeanDefinitionException ex2) { if (logger.isInfoEnabled()) { // ... } } } catch (NoSuchBeanDefinitionException ex) { try { // 3、如果没有,则尝试从 beanFactory 中获取执行名称的 Executor 线程池。 return beanFactory.getBean(DEFAULT_TASK_EXECUTOR_BEAN_NAME, Executor.class); } catch (NoSuchBeanDefinitionException ex2) { // ... } } } return null; } 在 getDefaultExecutor() 中,如果从 beanFactory 获取线程池失败的话,则会创建 SimpleAsyncTaskExecutor 线程池。\n该线程池的在每次执行异步任务时,都会创建一个新的线程去执行任务,并不会对线程进行复用,从而导致异步任务执行的开销很大。一旦在 @Async 注解标注的方法某一瞬间并发量剧增,应用就会大量创建线程,从而影响服务质量甚至出现服务不可用。\n同一时刻如果向 SimpleAsyncTaskExecutor 线程池提交 10000 个任务,那么该线程池就会创建 10000 个线程,其的 execute() 方法如下:\n// SimpleAsyncTaskExecutor:execute() 内部会调用 doExecute() protected void doExecute(Runnable task) { // 创建新线程 Thread thread = (this.threadFactory != null ? this.threadFactory.newThread(task) : createThread(task)); thread.start(); } 建议:在使用 @Async 时需要自己指定线程池,避免 Spring 默认线程池带来的风险。\n在 @Async 注解中的 value 指定了线程池的限定符,根据限定符可以获取 自定义的线程池 。获取限定符的代码如下:\n// AnnotationAsyncExecutionInterceptor protected String getExecutorQualifier(Method method) { // 1.从方法上获取 Async 注解。 Async async = AnnotatedElementUtils.findMergedAnnotation(method, Async.class); // 2. 如果方法上没有找到 @Async 注解,则尝试从方法所在的类上获取 @Async 注解。 if (async == null) { async = AnnotatedElementUtils.findMergedAnnotation(method.getDeclaringClass(), Async.class); } // 3. 如果找到了 @Async 注解,则获取注解的 value 值并返回,作为线程池的限定符。 // 如果 \u0026#34;value\u0026#34; 属性值为空字符串,则使用默认的线程池。 // 如果没有找到 @Async 注解,则返回 null,同样使用默认的线程池。 return (async != null ? async.value() : null); } 2、将方法封装为异步任务 # 在 invoke() 方法获取执行器之后,会将方法封装为异步任务,代码如下:\n// 将要执行的方法封装为 Callable 异步任务 Callable\u0026lt;Object\u0026gt; task = () -\u0026gt; { try { // 2.1、执行被拦截的方法 (proceed() 方法是 AOP 中的核心方法,用于执行目标方法) Object result = invocation.proceed(); // 2.2、如果被拦截方法的返回值是 Future 类型,则需要阻塞等待结果, // 并将 Future 的结果作为异步任务的结果返回。 这是为了处理异步方法嵌套调用的情况。 // 例如,一个异步方法内部调用了另一个异步方法,则需要等待内部异步方法执行完成, // 才能返回最终的结果。 if (result instanceof Future) { return ((Future\u0026lt;?\u0026gt;) result).get(); // 阻塞等待 Future 的结果 } } catch (ExecutionException ex) { // 2.3、处理 ExecutionException 异常。 ExecutionException 是 Future.get() 方法抛出的异常, handleError(ex.getCause(), userDeclaredMethod, invocation.getArguments()); // 处理原始异常 } catch (Throwable ex) { // 2.4、处理其他类型的异常。 将异常、被拦截的方法和方法参数作为参数调用 handleError() 方法进行处理。 handleError(ex, userDeclaredMethod, invocation.getArguments()); } // 2.5、如果方法返回值不是 Future 类型,或者发生异常,则返回 null。 return null; }; 相比于 Runnable ,Callable 可以返回结果,并且抛出异常。\n将 invocation.proceed() 的执行(原方法的执行)封装为 Callable 异步任务。这里仅仅当 result (方法返回值)类型为 Future 才返回,如果是其他类型则直接返回 null 。\n因此使用 @Async 注解标注的方法如果使用 Future 类型之外的返回值,则无法获取方法的执行结果。\n3、提交异步任务 # 在 AsyncExecutionInterceptor # invoke() 中将要执行的方法封装为 Callable 任务之后,就会将任务交给执行器来执行。提交相关的代码如下:\nprotected Object doSubmit(Callable\u0026lt;Object\u0026gt; task, AsyncTaskExecutor executor, Class\u0026lt;?\u0026gt; returnType) { // 根据方法的返回值类型,选择不同的异步执行方式并返回结果。 // 1. 如果方法返回值是 CompletableFuture 类型 if (CompletableFuture.class.isAssignableFrom(returnType)) { // 使用 CompletableFuture.supplyAsync() 方法异步执行任务。 return CompletableFuture.supplyAsync(() -\u0026gt; { try { return task.call(); } catch (Throwable ex) { throw new CompletionException(ex); // 将异常包装为 CompletionException,以便在 future.get() 时抛出 } }, executor); } // 2. 如果方法返回值是 ListenableFuture 类型 else if (ListenableFuture.class.isAssignableFrom(returnType)) { // 将 AsyncTaskExecutor 强制转换为 AsyncListenableTaskExecutor, // 并调用 submitListenable() 方法提交任务。 // AsyncListenableTaskExecutor 是 ListenableFuture 的专用异步执行器, // 它可以返回一个 ListenableFuture 对象,允许添加回调函数来监听任务的完成。 return ((AsyncListenableTaskExecutor) executor).submitListenable(task); } // 3. 如果方法返回值是 Future 类型 else if (Future.class.isAssignableFrom(returnType)) { // 直接调用 AsyncTaskExecutor 的 submit() 方法提交任务,并返回一个 Future 对象。 return executor.submit(task); } // 4. 如果方法返回值是 void 或其他类型 else { // 直接调用 AsyncTaskExecutor 的 submit() 方法提交任务。 // 由于方法返回值是 void,因此不需要返回任何结果,直接返回 null。 executor.submit(task); return null; } } 在 doSubmit() 方法中,会根据 @Async 注解标注方法的返回值不同,来选择不同的任务提交方式,最后任务会由执行器(线程池)执行。\n总结 # 理解 @Async 原理的核心在于理解 @EnableAsync 注解,该注解开启了异步任务的功能。\n主要流程如上图,会通过后置处理器来创建代理对象,之后代理对象中 @Async 方法的执行会走到 Advice 内部的拦截器中,之后将方法封装为异步任务,并提交线程池进行处理。\n@Async 使用建议 # 自定义线程池 # 如果没有显式地配置线程池,在 @Async 底层会先在 BeanFactory 中尝试获取线程池,如果获取不到,则会创建一个 SimpleAsyncTaskExecutor 实现。SimpleAsyncTaskExecutor 本质上不算是一个真正的线程池,因为它对于每个请求都会启动一个新线程而不重用现有线程,这会带来一些潜在的问题,例如资源消耗过大。\n具体线程池获取可以参考这篇文章: 浅析 Spring 中 Async 注解底层异步线程池原理|得物技术。\n一定要显式配置一个线程池,推荐ThreadPoolTaskExecutor。并且,还可以根据任务的性质和需求,为不同的异步方法指定不同的线程池。\n@Configuration @EnableAsync public class AsyncConfig { @Bean(name = \u0026#34;executor1\u0026#34;) public Executor executor1() { ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor(); executor.setCorePoolSize(3); executor.setMaxPoolSize(5); executor.setQueueCapacity(50); executor.setThreadNamePrefix(\u0026#34;AsyncExecutor1-\u0026#34;); executor.initialize(); return executor; } @Bean(name = \u0026#34;executor2\u0026#34;) public Executor executor2() { ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor(); executor.setCorePoolSize(2); executor.setMaxPoolSize(4); executor.setQueueCapacity(100); executor.setThreadNamePrefix(\u0026#34;AsyncExecutor2-\u0026#34;); executor.initialize(); return executor; } } @Async 注解中指定线程池的 Bean 名称:\n@Service public class AsyncService { @Async(\u0026#34;executor1\u0026#34;) public void performTask1() { // 任务1的逻辑 System.out.println(\u0026#34;Executing Task1 with Executor1\u0026#34;); } @Async(\u0026#34;executor2\u0026#34;) public void performTask2() { // 任务2的逻辑 System.out.println(\u0026#34;Executing Task2 with Executor2\u0026#34;); } } 避免 @Async 注解失效 # @Async 注解会在以下几个场景失效,需要注意:\n1、同一类中调用异步方法\n如果你在同一个类内部调用一个@Async注解的方法,那这个方法将不会异步执行。\n@Service public class MyService { public void myMethod() { // 直接通过 this 引用调用,绕过了 Spring 的代理机制,异步执行失效 asyncMethod(); } @Async public void asyncMethod() { // 异步执行的逻辑 } } 这是因为 Spring 的异步机制是通过 代理 实现的,而在同一个类内部的方法调用会绕过 Spring 的代理机制,也就是绕过了代理对象,直接通过 this 引用调用的。由于没有经过代理,所有的代理相关的处理(即将任务提交线程池异步执行)都不会发生。\n为了避免这个问题,比较推荐的做法是将异步方法移至另一个 Spring Bean 中。\n@Service public class AsyncService { @Async public void asyncMethod() { // 异步执行的逻辑 } } @Service public class MyService { @Autowired private AsyncService asyncService; public void myMethod() { asyncService.asyncMethod(); } } 2、使用 static 关键字修饰异步方法\n如果@Async注解的方法被 static 关键字修饰,那这个方法将不会异步执行。\n这是因为 Spring 的异步机制是通过代理实现的,由于静态方法不属于实例而是属于类且不参与继承,Spring 的代理机制(无论是基于 JDK 还是 CGLIB)无法拦截静态方法来提供如异步执行这样的增强功能。\n篇幅问题,这里没有进一步详细介绍,不了解的代理机制的朋友,可以看看我写的 Java 代理模式详解这篇文章。\n如果你需要异步执行一个静态方法的逻辑,可以考虑设计一个非静态的包装方法,这个包装方法使用 @Async 注解,并在其内部调用静态方法\n@Service public class AsyncService { @Async public void asyncWrapper() { // 调用静态方法 SClass.staticMethod(); } } public class SClass { public static void staticMethod() { // 执行一些操作 } } 3、忘记开启异步支持\nSpring Boot 默认情况下不启用异步支持,确保在主配置类 Application 上添加@EnableAsync注解以启用异步功能。\n@SpringBootApplication @EnableAsync public class Application { public static void main(String[] args) { SpringApplication.run(Application.class, args); } } 4、@Async 注解的方法所在的类必须是 Spring Bean\n@Async 注解的方法必须位于 Spring 管理的 Bean 中,只有这样,Spring 才能在创建 Bean 时应用代理,代理能够拦截方法调用并实现异步执行的逻辑。如果该方法不在 Spring 管理的 bean 中,Spring 就无法创建必要的代理,@Async 注解就不会产生任何效果。\n返回值类型 # 建议将 @Async 注解方法的返回值类型定义为 void 和 Future 。\n如果不需要获取异步方法返回的结果,将返回值类型定义为 void 。 如果需要获取异步方法返回的结果,将返回值类型定义为 Future(例如CompletableFuture 、 ListenableFuture )。 如果将 @Async 注解方法的返回值定义为其他类型(如 Object 、 String 等等),则无法获取方法返回值。\n这种设计符合异步编程的基本原则,即调用者不应立即期待一个结果,而是应该能够在未来某个时间点获取结果。如果返回类型是 Future,调用者可以使用这个返回的 Future 对象来查询任务的状态,取消任务,或者在任务完成时获取结果。\n处理异步方法中的异常 # 异步方法中抛出的异常默认不会被调用者捕获。为了管理这些异常,建议使用CompletableFuture的异常处理功能,或者配置一个全局的AsyncUncaughtExceptionHandler来处理没有正确捕获的异常。\n@Configuration @EnableAsync public class AsyncConfig implements AsyncConfigurer{ @Override public AsyncUncaughtExceptionHandler getAsyncUncaughtExceptionHandler() { return new CustomAsyncExceptionHandler(); } } // 自定义异常处理器 class CustomAsyncExceptionHandler implements AsyncUncaughtExceptionHandler { @Override public void handleUncaughtException(Throwable ex, Method method, Object... params) { // 日志记录或其他处理逻辑 } } 未考虑事务管理 # @Async注解的方法需要事务支持时,务必在该异步方法上独立使用。\n@Service public class AsyncTransactionalService { @Async // Propagation.REQUIRES_NEW 表示 Spring 在执行异步方法时开启一个新的、与当前事务无关的事务 @Transactional(propagation = Propagation.REQUIRES_NEW) public void asyncTransactionalMethod() { // 这里的操作会在新的事务中执行 // 执行一些数据库操作 } } 未指定异步方法执行顺序 # @Async注解的方法执行是非阻塞的,它们可能以任意顺序完成。如果需要按照特定的顺序处理结果,你可以将方法的返回值设定为 Future 或 CompletableFuture ,通过返回值对象来实现一个方法在另一个方法完成后再执行。\n@Async public CompletableFuture\u0026lt;String\u0026gt; fetchDataAsync() { return CompletableFuture.completedFuture(\u0026#34;Data\u0026#34;); } @Async public CompletableFuture\u0026lt;String\u0026gt; processDataAsync(String data) { return CompletableFuture.supplyAsync(() -\u0026gt; \u0026#34;Processed \u0026#34; + data); } processDataAsync 方法在 fetchDataAsync后执行:\nCompletableFuture\u0026lt;String\u0026gt; dataFuture = asyncService.fetchDataAsync(); dataFuture.thenCompose(data -\u0026gt; asyncService.processDataAsync(data)) .thenAccept(result -\u0026gt; System.out.println(result)); # "},{"id":459,"href":"/zh/docs/technology/Interview/system-design/framework/spring/Async/","title":"Async 注解原理分析","section":"Framework","content":"@Async 注解由 Spring 框架提供,被该注解标注的类或方法会在 异步线程 中执行。这意味着当方法被调用时,调用者将不会等待该方法执行完成,而是可以继续执行后续的代码。\n@Async 注解的使用非常简单,需要两个步骤:\n在启动类上添加注解 @EnableAsync ,开启异步任务。 在需要异步执行的方法或类上添加注解 @Async 。 @SpringBootApplication // 开启异步任务 @EnableAsync public class YourApplication { public static void main(String[] args) { SpringApplication.run(YourApplication.class, args); } } // 异步服务类 @Service public class MyService { // 推荐使用自定义线程池,这里只是演示基本用法 @Async public CompletableFuture\u0026lt;String\u0026gt; doSomethingAsync() { // 这里会有一些业务耗时操作 // ... // 使用 CompletableFuture 可以更方便地处理异步任务的结果,避免阻塞主线程 return CompletableFuture.completedFuture(\u0026#34;Async Task Completed\u0026#34;); } } 接下来,我们一起来看看 @Async 的底层原理。\n@Async 原理分析 # @Async 可以异步执行任务,本质上是使用 动态代理 来实现的。通过 Spring 中的后置处理器 BeanPostProcessor 为使用 @Async 注解的类创建动态代理,之后 @Async 注解方法的调用会被动态代理拦截,在拦截器中将方法的执行封装为异步任务提交给线程池处理。\n接下来,我们来详细分析一下。\n开启异步 # 使用 @Async 之前,需要在启动类上添加 @EnableAsync 来开启异步,@EnableAsync 注解如下:\n// 省略其他注解 ... @Import(AsyncConfigurationSelector.class) public @interface EnableAsync { /* ... */ } 在 @EnableAsync 注解上通过 @Import 注解引入了 AsyncConfigurationSelector ,因此 Spring 会去加载通过 @Import 注解引入的类。\nAsyncConfigurationSelector 类实现了 ImportSelector 接口,因此在该类中会重写 selectImports() 方法来自定义加载 Bean 的逻辑,如下:\npublic class AsyncConfigurationSelector extends AdviceModeImportSelector\u0026lt;EnableAsync\u0026gt; { @Override @Nullable public String[] selectImports(AdviceMode adviceMode) { switch (adviceMode) { // 基于 JDK 代理织入的通知 case PROXY: return new String[] {ProxyAsyncConfiguration.class.getName()}; // 基于 AspectJ 织入的通知 case ASPECTJ: return new String[] {ASYNC_EXECUTION_ASPECT_CONFIGURATION_CLASS_NAME}; default: return null; } } } 在 selectImports() 方法中,会根据通知的不同类型来选择加载不同的类,其中 adviceMode 默认值为 PROXY 。\n这里以基于 JDK 代理的通知为例,此时会加载 ProxyAsyncConfiguration 类,如下:\n@Configuration @Role(BeanDefinition.ROLE_INFRASTRUCTURE) public class ProxyAsyncConfiguration extends AbstractAsyncConfiguration { @Bean(name = TaskManagementConfigUtils.ASYNC_ANNOTATION_PROCESSOR_BEAN_NAME) @Role(BeanDefinition.ROLE_INFRASTRUCTURE) public AsyncAnnotationBeanPostProcessor asyncAdvisor() { // ... // 加载后置处理器 AsyncAnnotationBeanPostProcessor bpp = new AsyncAnnotationBeanPostProcessor(); // ... return bpp; } } 后置处理器 # 在 ProxyAsyncConfiguration 类中,会通过 @Bean 注解加载一个后置处理器 AsyncAnnotationBeanPostProcessor ,这个后置处理器是使 @Async 注解起作用的关键。\n如果某一个类或者方法上使用了 @Async 注解,AsyncAnnotationBeanPostProcessor 处理器就会为该类创建一个动态代理。\n该类的方法在执行时,会被代理对象的拦截器所拦截,其中被 @Async 注解标记的方法会异步执行。\nAsyncAnnotationBeanPostProcessor 代码如下:\npublic class AsyncAnnotationBeanPostProcessor extends AbstractBeanFactoryAwareAdvisingPostProcessor { @Override public void setBeanFactory(BeanFactory beanFactory) { super.setBeanFactory(beanFactory); // 创建 AsyncAnnotationAdvisor,它是一个 Advisor // 用于拦截带有 @Async 注解的方法并将这些方法异步执行。 AsyncAnnotationAdvisor advisor = new AsyncAnnotationAdvisor(this.executor, this.exceptionHandler); // 如果设置了自定义的 asyncAnnotationType,则将其设置到 advisor 中。 // asyncAnnotationType 用于指定自定义的异步注解,例如 @MyAsync。 if (this.asyncAnnotationType != null) { advisor.setAsyncAnnotationType(this.asyncAnnotationType); } advisor.setBeanFactory(beanFactory); this.advisor = advisor; } } AsyncAnnotationBeanPostProcessor 的父类实现了 BeanFactoryAware 接口,因此在该类中重写了 setBeanFactory() 方法作为扩展点,来加载 AsyncAnnotationAdvisor 。\n创建 Advisor # Advisor 是 Spring AOP 对 Advice 和 Pointcut 的抽象。Advice 为执行的通知逻辑,Pointcut 为通知执行的切入点。\n在后置处理器 AsyncAnnotationBeanPostProcessor 中会去创建 AsyncAnnotationAdvisor , 在它的构造方法中,会构建对应的 Advice 和 Pointcut ,如下:\npublic class AsyncAnnotationAdvisor extends AbstractPointcutAdvisor implements BeanFactoryAware { private Advice advice; // 异步执行的 Advice private Pointcut pointcut; // 匹配 @Async 注解方法的切点 // 构造函数 public AsyncAnnotationAdvisor(/* 参数省略 */) { // 1. 创建 Advice,负责异步执行逻辑 this.advice = buildAdvice(executor, exceptionHandler); // 2. 创建 Pointcut,选择要被增强的目标方法 this.pointcut = buildPointcut(asyncAnnotationTypes); } // 创建 Advice protected Advice buildAdvice(/* 参数省略 */) { // 创建处理异步执行的拦截器 AnnotationAsyncExecutionInterceptor interceptor = new AnnotationAsyncExecutionInterceptor(null); // 使用执行器和异常处理器配置拦截器 interceptor.configure(executor, exceptionHandler); return interceptor; } // 创建 Pointcut protected Pointcut buildPointcut(Set\u0026lt;Class\u0026lt;? extends Annotation\u0026gt;\u0026gt; asyncAnnotationTypes) { ComposablePointcut result = null; for (Class\u0026lt;? extends Annotation\u0026gt; asyncAnnotationType : asyncAnnotationTypes) { // 1. 类级别切点:如果类上有注解则匹配 Pointcut cpc = new AnnotationMatchingPointcut(asyncAnnotationType, true); // 2. 方法级别切点:如果方法上有注解则匹配 Pointcut mpc = new AnnotationMatchingPointcut(null, asyncAnnotationType, true); if (result == null) { result = new ComposablePointcut(cpc); } else { // 使用 union 合并之前的切点 result.union(cpc); } // 将方法级别切点添加到组合切点 result = result.union(mpc); } // 返回组合切点,如果没有提供注解类型则返回 Pointcut.TRUE return (result != null ? result : Pointcut.TRUE); } } AsyncAnnotationAdvisor 的核心在于构建 Advice 和 Pointcut :\n构建 Advice :会创建 AnnotationAsyncExecutionInterceptor 拦截器,在拦截器的 invoke() 方法中会执行通知的逻辑。 构建 Pointcut :由 ClassFilter 和 MethodMatcher 组成,用于匹配哪些方法需要执行通知( Advice )的逻辑。 后置处理逻辑 # AsyncAnnotationBeanPostProcessor 后置处理器中实现的 postProcessAfterInitialization() 方法在其父类 AbstractAdvisingBeanPostProcessor 中,在 Bean 初始化之后,会进入到 postProcessAfterInitialization() 方法进行后置处理。\n在后置处理方法中,会判断 Bean 是否符合后置处理器中 Advisor 通知的条件,如果符合,则创建代理对象。如下:\n// AbstractAdvisingBeanPostProcessor public Object postProcessAfterInitialization(Object bean, String beanName) { if (this.advisor == null || bean instanceof AopInfrastructureBean) { return bean; } if (bean instanceof Advised) { Advised advised = (Advised) bean; if (!advised.isFrozen() \u0026amp;\u0026amp; isEligible(AopUtils.getTargetClass(bean))) { if (this.beforeExistingAdvisors) { advised.addAdvisor(0, this.advisor); } else { advised.addAdvisor(this.advisor); } return bean; } } // 判断给定的 Bean 是否符合后置处理器中 Advisor 通知的条件,符合的话,就创建代理对象。 if (isEligible(bean, beanName)) { ProxyFactory proxyFactory = prepareProxyFactory(bean, beanName); if (!proxyFactory.isProxyTargetClass()) { evaluateProxyInterfaces(bean.getClass(), proxyFactory); } // 添加 Advisor。 proxyFactory.addAdvisor(this.advisor); customizeProxyFactory(proxyFactory); // 返回代理对象。 return proxyFactory.getProxy(getProxyClassLoader()); } return bean; } @Async 注解方法的拦截 # @Async 注解方法的执行会在 AnnotationAsyncExecutionInterceptor 中被拦截,在 invoke() 方法中执行拦截器的逻辑。此时会将 @Async 注解标注的方法封装为异步任务,交给执行器来执行。\ninvoke() 方法在 AnnotationAsyncExecutionInterceptor 的父类 AsyncExecutionInterceptor 中定义,如下:\npublic class AsyncExecutionInterceptor extends AsyncExecutionAspectSupport implements MethodInterceptor, Ordered { @Override @Nullable public Object invoke(final MethodInvocation invocation) throws Throwable { Class\u0026lt;?\u0026gt; targetClass = (invocation.getThis() != null ? AopUtils.getTargetClass(invocation.getThis()) : null); Method specificMethod = ClassUtils.getMostSpecificMethod(invocation.getMethod(), targetClass); final Method userDeclaredMethod = BridgeMethodResolver.findBridgedMethod(specificMethod); // 1、确定异步任务执行器 AsyncTaskExecutor executor = determineAsyncExecutor(userDeclaredMethod); // 2、将要执行的方法封装为 Callable 异步任务 Callable\u0026lt;Object\u0026gt; task = () -\u0026gt; { try { // 2.1、执行方法 Object result = invocation.proceed(); // 2.2、如果方法返回值是 Future 类型,阻塞等待结果 if (result instanceof Future) { return ((Future\u0026lt;?\u0026gt;) result).get(); } } catch (ExecutionException ex) { handleError(ex.getCause(), userDeclaredMethod, invocation.getArguments()); } catch (Throwable ex) { handleError(ex, userDeclaredMethod, invocation.getArguments()); } return null; }; // 3、提交任务 return doSubmit(task, executor, invocation.getMethod().getReturnType()); } } 在 invoke() 方法中,主要有 3 个步骤:\n确定执行异步任务的执行器。 将 @Async 注解标注的方法封装为 Callable 异步任务。 将任务提交给执行器执行。 1、获取异步任务执行器 # 在 determineAsyncExecutor() 方法中,会获取异步任务的执行器(即执行异步任务的 线程池 )。代码如下:\n// 确定异步任务的执行器 protected AsyncTaskExecutor determineAsyncExecutor(Method method) { // 1、先从缓存中获取。 AsyncTaskExecutor executor = this.executors.get(method); if (executor == null) { Executor targetExecutor; // 2、获取执行器的限定符。 String qualifier = getExecutorQualifier(method); if (StringUtils.hasLength(qualifier)) { // 3、根据限定符获取对应的执行器。 targetExecutor = findQualifiedExecutor(this.beanFactory, qualifier); } else { // 4、如果没有限定符,则使用默认的执行器。即 Spring 提供的默认线程池:SimpleAsyncTaskExecutor。 targetExecutor = this.defaultExecutor.get(); } if (targetExecutor == null) { return null; } // 5、将执行器包装为 TaskExecutorAdapter 适配器。 // TaskExecutorAdapter 是 Spring 对于 JDK 线程池做的一层抽象,还是继承自 JDK 的线程池 Executor。这里可以不用管太多,只要知道它是线程池就可以了。 executor = (targetExecutor instanceof AsyncListenableTaskExecutor ? (AsyncListenableTaskExecutor) targetExecutor : new TaskExecutorAdapter(targetExecutor)); this.executors.put(method, executor); } return executor; } 在 determineAsyncExecutor() 方法中确定了异步任务的执行器(线程池),主要是通过 @Async 注解的 value 值来获取执行器的限定符,根据限定符再去 BeanFactory 中查找对应的执行器就可以了。\n如果在 @Async 注解中没有指定线程池,则会通过 this.defaultExecutor.get() 来获取默认的线程池,其中 defaultExecutor 在下边方法中进行赋值:\n// AsyncExecutionInterceptor protected Executor getDefaultExecutor(@Nullable BeanFactory beanFactory) { // 1、尝试从 beanFactory 中获取线程池。 Executor defaultExecutor = super.getDefaultExecutor(beanFactory); // 2、如果 beanFactory 中没有,则创建 SimpleAsyncTaskExecutor 线程池。 return (defaultExecutor != null ? defaultExecutor : new SimpleAsyncTaskExecutor()); } 其中 super.getDefaultExecutor() 会在 beanFactory 中尝试获取 Executor 类型的线程池。代码如下:\nprotected Executor getDefaultExecutor(@Nullable BeanFactory beanFactory) { if (beanFactory != null) { try { // 1、从 beanFactory 中获取 TaskExecutor 类型的线程池。 return beanFactory.getBean(TaskExecutor.class); } catch (NoUniqueBeanDefinitionException ex) { try { // 2、如果有多个,则尝试从 beanFactory 中获取执行名称的 Executor 线程池。 return beanFactory.getBean(DEFAULT_TASK_EXECUTOR_BEAN_NAME, Executor.class); } catch (NoSuchBeanDefinitionException ex2) { if (logger.isInfoEnabled()) { // ... } } } catch (NoSuchBeanDefinitionException ex) { try { // 3、如果没有,则尝试从 beanFactory 中获取执行名称的 Executor 线程池。 return beanFactory.getBean(DEFAULT_TASK_EXECUTOR_BEAN_NAME, Executor.class); } catch (NoSuchBeanDefinitionException ex2) { // ... } } } return null; } 在 getDefaultExecutor() 中,如果从 beanFactory 获取线程池失败的话,则会创建 SimpleAsyncTaskExecutor 线程池。\n该线程池的在每次执行异步任务时,都会创建一个新的线程去执行任务,并不会对线程进行复用,从而导致异步任务执行的开销很大。一旦在 @Async 注解标注的方法某一瞬间并发量剧增,应用就会大量创建线程,从而影响服务质量甚至出现服务不可用。\n同一时刻如果向 SimpleAsyncTaskExecutor 线程池提交 10000 个任务,那么该线程池就会创建 10000 个线程,其的 execute() 方法如下:\n// SimpleAsyncTaskExecutor:execute() 内部会调用 doExecute() protected void doExecute(Runnable task) { // 创建新线程 Thread thread = (this.threadFactory != null ? this.threadFactory.newThread(task) : createThread(task)); thread.start(); } 建议:在使用 @Async 时需要自己指定线程池,避免 Spring 默认线程池带来的风险。\n在 @Async 注解中的 value 指定了线程池的限定符,根据限定符可以获取 自定义的线程池 。获取限定符的代码如下:\n// AnnotationAsyncExecutionInterceptor protected String getExecutorQualifier(Method method) { // 1.从方法上获取 Async 注解。 Async async = AnnotatedElementUtils.findMergedAnnotation(method, Async.class); // 2. 如果方法上没有找到 @Async 注解,则尝试从方法所在的类上获取 @Async 注解。 if (async == null) { async = AnnotatedElementUtils.findMergedAnnotation(method.getDeclaringClass(), Async.class); } // 3. 如果找到了 @Async 注解,则获取注解的 value 值并返回,作为线程池的限定符。 // 如果 \u0026#34;value\u0026#34; 属性值为空字符串,则使用默认的线程池。 // 如果没有找到 @Async 注解,则返回 null,同样使用默认的线程池。 return (async != null ? async.value() : null); } 2、将方法封装为异步任务 # 在 invoke() 方法获取执行器之后,会将方法封装为异步任务,代码如下:\n// 将要执行的方法封装为 Callable 异步任务 Callable\u0026lt;Object\u0026gt; task = () -\u0026gt; { try { // 2.1、执行被拦截的方法 (proceed() 方法是 AOP 中的核心方法,用于执行目标方法) Object result = invocation.proceed(); // 2.2、如果被拦截方法的返回值是 Future 类型,则需要阻塞等待结果, // 并将 Future 的结果作为异步任务的结果返回。 这是为了处理异步方法嵌套调用的情况。 // 例如,一个异步方法内部调用了另一个异步方法,则需要等待内部异步方法执行完成, // 才能返回最终的结果。 if (result instanceof Future) { return ((Future\u0026lt;?\u0026gt;) result).get(); // 阻塞等待 Future 的结果 } } catch (ExecutionException ex) { // 2.3、处理 ExecutionException 异常。 ExecutionException 是 Future.get() 方法抛出的异常, handleError(ex.getCause(), userDeclaredMethod, invocation.getArguments()); // 处理原始异常 } catch (Throwable ex) { // 2.4、处理其他类型的异常。 将异常、被拦截的方法和方法参数作为参数调用 handleError() 方法进行处理。 handleError(ex, userDeclaredMethod, invocation.getArguments()); } // 2.5、如果方法返回值不是 Future 类型,或者发生异常,则返回 null。 return null; }; 相比于 Runnable ,Callable 可以返回结果,并且抛出异常。\n将 invocation.proceed() 的执行(原方法的执行)封装为 Callable 异步任务。这里仅仅当 result (方法返回值)类型为 Future 才返回,如果是其他类型则直接返回 null 。\n因此使用 @Async 注解标注的方法如果使用 Future 类型之外的返回值,则无法获取方法的执行结果。\n3、提交异步任务 # 在 AsyncExecutionInterceptor # invoke() 中将要执行的方法封装为 Callable 任务之后,就会将任务交给执行器来执行。提交相关的代码如下:\nprotected Object doSubmit(Callable\u0026lt;Object\u0026gt; task, AsyncTaskExecutor executor, Class\u0026lt;?\u0026gt; returnType) { // 根据方法的返回值类型,选择不同的异步执行方式并返回结果。 // 1. 如果方法返回值是 CompletableFuture 类型 if (CompletableFuture.class.isAssignableFrom(returnType)) { // 使用 CompletableFuture.supplyAsync() 方法异步执行任务。 return CompletableFuture.supplyAsync(() -\u0026gt; { try { return task.call(); } catch (Throwable ex) { throw new CompletionException(ex); // 将异常包装为 CompletionException,以便在 future.get() 时抛出 } }, executor); } // 2. 如果方法返回值是 ListenableFuture 类型 else if (ListenableFuture.class.isAssignableFrom(returnType)) { // 将 AsyncTaskExecutor 强制转换为 AsyncListenableTaskExecutor, // 并调用 submitListenable() 方法提交任务。 // AsyncListenableTaskExecutor 是 ListenableFuture 的专用异步执行器, // 它可以返回一个 ListenableFuture 对象,允许添加回调函数来监听任务的完成。 return ((AsyncListenableTaskExecutor) executor).submitListenable(task); } // 3. 如果方法返回值是 Future 类型 else if (Future.class.isAssignableFrom(returnType)) { // 直接调用 AsyncTaskExecutor 的 submit() 方法提交任务,并返回一个 Future 对象。 return executor.submit(task); } // 4. 如果方法返回值是 void 或其他类型 else { // 直接调用 AsyncTaskExecutor 的 submit() 方法提交任务。 // 由于方法返回值是 void,因此不需要返回任何结果,直接返回 null。 executor.submit(task); return null; } } 在 doSubmit() 方法中,会根据 @Async 注解标注方法的返回值不同,来选择不同的任务提交方式,最后任务会由执行器(线程池)执行。\n总结 # 理解 @Async 原理的核心在于理解 @EnableAsync 注解,该注解开启了异步任务的功能。\n主要流程如上图,会通过后置处理器来创建代理对象,之后代理对象中 @Async 方法的执行会走到 Advice 内部的拦截器中,之后将方法封装为异步任务,并提交线程池进行处理。\n@Async 使用建议 # 自定义线程池 # 如果没有显式地配置线程池,在 @Async 底层会先在 BeanFactory 中尝试获取线程池,如果获取不到,则会创建一个 SimpleAsyncTaskExecutor 实现。SimpleAsyncTaskExecutor 本质上不算是一个真正的线程池,因为它对于每个请求都会启动一个新线程而不重用现有线程,这会带来一些潜在的问题,例如资源消耗过大。\n具体线程池获取可以参考这篇文章: 浅析 Spring 中 Async 注解底层异步线程池原理|得物技术。\n一定要显式配置一个线程池,推荐ThreadPoolTaskExecutor。并且,还可以根据任务的性质和需求,为不同的异步方法指定不同的线程池。\n@Configuration @EnableAsync public class AsyncConfig { @Bean(name = \u0026#34;executor1\u0026#34;) public Executor executor1() { ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor(); executor.setCorePoolSize(3); executor.setMaxPoolSize(5); executor.setQueueCapacity(50); executor.setThreadNamePrefix(\u0026#34;AsyncExecutor1-\u0026#34;); executor.initialize(); return executor; } @Bean(name = \u0026#34;executor2\u0026#34;) public Executor executor2() { ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor(); executor.setCorePoolSize(2); executor.setMaxPoolSize(4); executor.setQueueCapacity(100); executor.setThreadNamePrefix(\u0026#34;AsyncExecutor2-\u0026#34;); executor.initialize(); return executor; } } @Async 注解中指定线程池的 Bean 名称:\n@Service public class AsyncService { @Async(\u0026#34;executor1\u0026#34;) public void performTask1() { // 任务1的逻辑 System.out.println(\u0026#34;Executing Task1 with Executor1\u0026#34;); } @Async(\u0026#34;executor2\u0026#34;) public void performTask2() { // 任务2的逻辑 System.out.println(\u0026#34;Executing Task2 with Executor2\u0026#34;); } } 避免 @Async 注解失效 # @Async 注解会在以下几个场景失效,需要注意:\n1、同一类中调用异步方法\n如果你在同一个类内部调用一个@Async注解的方法,那这个方法将不会异步执行。\n@Service public class MyService { public void myMethod() { // 直接通过 this 引用调用,绕过了 Spring 的代理机制,异步执行失效 asyncMethod(); } @Async public void asyncMethod() { // 异步执行的逻辑 } } 这是因为 Spring 的异步机制是通过 代理 实现的,而在同一个类内部的方法调用会绕过 Spring 的代理机制,也就是绕过了代理对象,直接通过 this 引用调用的。由于没有经过代理,所有的代理相关的处理(即将任务提交线程池异步执行)都不会发生。\n为了避免这个问题,比较推荐的做法是将异步方法移至另一个 Spring Bean 中。\n@Service public class AsyncService { @Async public void asyncMethod() { // 异步执行的逻辑 } } @Service public class MyService { @Autowired private AsyncService asyncService; public void myMethod() { asyncService.asyncMethod(); } } 2、使用 static 关键字修饰异步方法\n如果@Async注解的方法被 static 关键字修饰,那这个方法将不会异步执行。\n这是因为 Spring 的异步机制是通过代理实现的,由于静态方法不属于实例而是属于类且不参与继承,Spring 的代理机制(无论是基于 JDK 还是 CGLIB)无法拦截静态方法来提供如异步执行这样的增强功能。\n篇幅问题,这里没有进一步详细介绍,不了解的代理机制的朋友,可以看看我写的 Java 代理模式详解这篇文章。\n如果你需要异步执行一个静态方法的逻辑,可以考虑设计一个非静态的包装方法,这个包装方法使用 @Async 注解,并在其内部调用静态方法\n@Service public class AsyncService { @Async public void asyncWrapper() { // 调用静态方法 SClass.staticMethod(); } } public class SClass { public static void staticMethod() { // 执行一些操作 } } 3、忘记开启异步支持\nSpring Boot 默认情况下不启用异步支持,确保在主配置类 Application 上添加@EnableAsync注解以启用异步功能。\n@SpringBootApplication @EnableAsync public class Application { public static void main(String[] args) { SpringApplication.run(Application.class, args); } } 4、@Async 注解的方法所在的类必须是 Spring Bean\n@Async 注解的方法必须位于 Spring 管理的 Bean 中,只有这样,Spring 才能在创建 Bean 时应用代理,代理能够拦截方法调用并实现异步执行的逻辑。如果该方法不在 Spring 管理的 bean 中,Spring 就无法创建必要的代理,@Async 注解就不会产生任何效果。\n返回值类型 # 建议将 @Async 注解方法的返回值类型定义为 void 和 Future 。\n如果不需要获取异步方法返回的结果,将返回值类型定义为 void 。 如果需要获取异步方法返回的结果,将返回值类型定义为 Future(例如CompletableFuture 、 ListenableFuture )。 如果将 @Async 注解方法的返回值定义为其他类型(如 Object 、 String 等等),则无法获取方法返回值。\n这种设计符合异步编程的基本原则,即调用者不应立即期待一个结果,而是应该能够在未来某个时间点获取结果。如果返回类型是 Future,调用者可以使用这个返回的 Future 对象来查询任务的状态,取消任务,或者在任务完成时获取结果。\n处理异步方法中的异常 # 异步方法中抛出的异常默认不会被调用者捕获。为了管理这些异常,建议使用CompletableFuture的异常处理功能,或者配置一个全局的AsyncUncaughtExceptionHandler来处理没有正确捕获的异常。\n@Configuration @EnableAsync public class AsyncConfig implements AsyncConfigurer{ @Override public AsyncUncaughtExceptionHandler getAsyncUncaughtExceptionHandler() { return new CustomAsyncExceptionHandler(); } } // 自定义异常处理器 class CustomAsyncExceptionHandler implements AsyncUncaughtExceptionHandler { @Override public void handleUncaughtException(Throwable ex, Method method, Object... params) { // 日志记录或其他处理逻辑 } } 未考虑事务管理 # @Async注解的方法需要事务支持时,务必在该异步方法上独立使用。\n@Service public class AsyncTransactionalService { @Async // Propagation.REQUIRES_NEW 表示 Spring 在执行异步方法时开启一个新的、与当前事务无关的事务 @Transactional(propagation = Propagation.REQUIRES_NEW) public void asyncTransactionalMethod() { // 这里的操作会在新的事务中执行 // 执行一些数据库操作 } } 未指定异步方法执行顺序 # @Async注解的方法执行是非阻塞的,它们可能以任意顺序完成。如果需要按照特定的顺序处理结果,你可以将方法的返回值设定为 Future 或 CompletableFuture ,通过返回值对象来实现一个方法在另一个方法完成后再执行。\n@Async public CompletableFuture\u0026lt;String\u0026gt; fetchDataAsync() { return CompletableFuture.completedFuture(\u0026#34;Data\u0026#34;); } @Async public CompletableFuture\u0026lt;String\u0026gt; processDataAsync(String data) { return CompletableFuture.supplyAsync(() -\u0026gt; \u0026#34;Processed \u0026#34; + data); } processDataAsync 方法在 fetchDataAsync后执行:\nCompletableFuture\u0026lt;String\u0026gt; dataFuture = asyncService.fetchDataAsync(); dataFuture.thenCompose(data -\u0026gt; asyncService.processDataAsync(data)) .thenAccept(result -\u0026gt; System.out.println(result)); # "},{"id":460,"href":"/zh/docs/technology/Interview/java/concurrent/atomic-classes/","title":"Atomic 原子类总结","section":"Concurrent","content":" Atomic 原子类介绍 # Atomic 翻译成中文是“原子”的意思。在化学上,原子是构成物质的最小单位,在化学反应中不可分割。在编程中,Atomic 指的是一个操作具有原子性,即该操作不可分割、不可中断。即使在多个线程同时执行时,该操作要么全部执行完成,要么不执行,不会被其他线程看到部分完成的状态。\n原子类简单来说就是具有原子性操作特征的类。\njava.util.concurrent.atomic 包中的 Atomic 原子类提供了一种线程安全的方式来操作单个变量。\nAtomic 类依赖于 CAS(Compare-And-Swap,比较并交换)乐观锁来保证其方法的原子性,而不需要使用传统的锁机制(如 synchronized 块或 ReentrantLock)。\n这篇文章我们只介绍 Atomic 原子类的概念,具体实现原理可以阅读笔者写的这篇文章: CAS 详解。\n根据操作的数据类型,可以将 JUC 包中的原子类分为 4 类:\n1、基本类型\n使用原子的方式更新基本类型\nAtomicInteger:整型原子类 AtomicLong:长整型原子类 AtomicBoolean:布尔型原子类 2、数组类型\n使用原子的方式更新数组里的某个元素\nAtomicIntegerArray:整型数组原子类 AtomicLongArray:长整型数组原子类 AtomicReferenceArray:引用类型数组原子类 3、引用类型\nAtomicReference:引用类型原子类 AtomicMarkableReference:原子更新带有标记的引用类型。该类将 boolean 标记与引用关联起来,也可以解决使用 CAS 进行原子更新时可能出现的 ABA 问题。 AtomicStampedReference:原子更新带有版本号的引用类型。该类将整数值与引用关联起来,可用于解决原子的更新数据和数据的版本号,可以解决使用 CAS 进行原子更新时可能出现的 ABA 问题。 🐛 修正(参见: issue#626) : AtomicMarkableReference 不能解决 ABA 问题。\n4、对象的属性修改类型\nAtomicIntegerFieldUpdater:原子更新整型字段的更新器 AtomicLongFieldUpdater:原子更新长整型字段的更新器 AtomicReferenceFieldUpdater:原子更新引用类型里的字段 基本类型原子类 # 使用原子的方式更新基本类型\nAtomicInteger:整型原子类 AtomicLong:长整型原子类 AtomicBoolean:布尔型原子类 上面三个类提供的方法几乎相同,所以我们这里以 AtomicInteger 为例子来介绍。\nAtomicInteger 类常用方法 :\npublic final int get() //获取当前的值 public final int getAndSet(int newValue)//获取当前的值,并设置新的值 public final int getAndIncrement()//获取当前的值,并自增 public final int getAndDecrement() //获取当前的值,并自减 public final int getAndAdd(int delta) //获取当前的值,并加上预期的值 boolean compareAndSet(int expect, int update) //如果输入的数值等于预期值,则以原子方式将该值设置为输入值(update) public final void lazySet(int newValue)//最终设置为newValue, lazySet 提供了一种比 set 方法更弱的语义,可能导致其他线程在之后的一小段时间内还是可以读到旧的值,但可能更高效。 AtomicInteger 类使用示例 :\n// 初始化 AtomicInteger 对象,初始值为 0 AtomicInteger atomicInt = new AtomicInteger(0); // 使用 getAndSet 方法获取当前值,并设置新值为 3 int tempValue = atomicInt.getAndSet(3); System.out.println(\u0026#34;tempValue: \u0026#34; + tempValue + \u0026#34;; atomicInt: \u0026#34; + atomicInt); // 使用 getAndIncrement 方法获取当前值,并自增 1 tempValue = atomicInt.getAndIncrement(); System.out.println(\u0026#34;tempValue: \u0026#34; + tempValue + \u0026#34;; atomicInt: \u0026#34; + atomicInt); // 使用 getAndAdd 方法获取当前值,并增加指定值 5 tempValue = atomicInt.getAndAdd(5); System.out.println(\u0026#34;tempValue: \u0026#34; + tempValue + \u0026#34;; atomicInt: \u0026#34; + atomicInt); // 使用 compareAndSet 方法进行原子性条件更新,期望值为 9,更新值为 10 boolean updateSuccess = atomicInt.compareAndSet(9, 10); System.out.println(\u0026#34;Update Success: \u0026#34; + updateSuccess + \u0026#34;; atomicInt: \u0026#34; + atomicInt); // 获取当前值 int currentValue = atomicInt.get(); System.out.println(\u0026#34;Current value: \u0026#34; + currentValue); // 使用 lazySet 方法设置新值为 15 atomicInt.lazySet(15); System.out.println(\u0026#34;After lazySet, atomicInt: \u0026#34; + atomicInt); 输出:\ntempValue: 0; atomicInt: 3 tempValue: 3; atomicInt: 4 tempValue: 4; atomicInt: 9 Update Success: true; atomicInt: 10 Current value: 10 After lazySet, atomicInt: 15 数组类型原子类 # 使用原子的方式更新数组里的某个元素\nAtomicIntegerArray:整形数组原子类 AtomicLongArray:长整形数组原子类 AtomicReferenceArray:引用类型数组原子类 上面三个类提供的方法几乎相同,所以我们这里以 AtomicIntegerArray 为例子来介绍。\nAtomicIntegerArray 类常用方法:\npublic final int get(int i) //获取 index=i 位置元素的值 public final int getAndSet(int i, int newValue)//返回 index=i 位置的当前的值,并将其设置为新值:newValue public final int getAndIncrement(int i)//获取 index=i 位置元素的值,并让该位置的元素自增 public final int getAndDecrement(int i) //获取 index=i 位置元素的值,并让该位置的元素自减 public final int getAndAdd(int i, int delta) //获取 index=i 位置元素的值,并加上预期的值 boolean compareAndSet(int i, int expect, int update) //如果输入的数值等于预期值,则以原子方式将 index=i 位置的元素值设置为输入值(update) public final void lazySet(int i, int newValue)//最终 将index=i 位置的元素设置为newValue,使用 lazySet 设置之后可能导致其他线程在之后的一小段时间内还是可以读到旧的值。 AtomicIntegerArray 类使用示例 :\nint[] nums = {1, 2, 3, 4, 5, 6}; // 创建 AtomicIntegerArray AtomicIntegerArray atomicArray = new AtomicIntegerArray(nums); // 打印 AtomicIntegerArray 中的初始值 System.out.println(\u0026#34;Initial values in AtomicIntegerArray:\u0026#34;); for (int j = 0; j \u0026lt; nums.length; j++) { System.out.print(\u0026#34;Index \u0026#34; + j + \u0026#34;: \u0026#34; + atomicArray.get(j) + \u0026#34; \u0026#34;); } // 使用 getAndSet 方法将索引 0 处的值设置为 2,并返回旧值 int tempValue = atomicArray.getAndSet(0, 2); System.out.println(\u0026#34;\\nAfter getAndSet(0, 2):\u0026#34;); System.out.println(\u0026#34;Returned value: \u0026#34; + tempValue); for (int j = 0; j \u0026lt; atomicArray.length(); j++) { System.out.print(\u0026#34;Index \u0026#34; + j + \u0026#34;: \u0026#34; + atomicArray.get(j) + \u0026#34; \u0026#34;); } // 使用 getAndIncrement 方法将索引 0 处的值加 1,并返回旧值 tempValue = atomicArray.getAndIncrement(0); System.out.println(\u0026#34;\\nAfter getAndIncrement(0):\u0026#34;); System.out.println(\u0026#34;Returned value: \u0026#34; + tempValue); for (int j = 0; j \u0026lt; atomicArray.length(); j++) { System.out.print(\u0026#34;Index \u0026#34; + j + \u0026#34;: \u0026#34; + atomicArray.get(j) + \u0026#34; \u0026#34;); } // 使用 getAndAdd 方法将索引 0 处的值增加 5,并返回旧值 tempValue = atomicArray.getAndAdd(0, 5); System.out.println(\u0026#34;\\nAfter getAndAdd(0, 5):\u0026#34;); System.out.println(\u0026#34;Returned value: \u0026#34; + tempValue); for (int j = 0; j \u0026lt; atomicArray.length(); j++) { System.out.print(\u0026#34;Index \u0026#34; + j + \u0026#34;: \u0026#34; + atomicArray.get(j) + \u0026#34; \u0026#34;); } 输出:\nInitial values in AtomicIntegerArray: Index 0: 1 Index 1: 2 Index 2: 3 Index 3: 4 Index 4: 5 Index 5: 6 After getAndSet(0, 2): Returned value: 1 Index 0: 2 Index 1: 2 Index 2: 3 Index 3: 4 Index 4: 5 Index 5: 6 After getAndIncrement(0): Returned value: 2 Index 0: 3 Index 1: 2 Index 2: 3 Index 3: 4 Index 4: 5 Index 5: 6 After getAndAdd(0, 5): Returned value: 3 Index 0: 8 Index 1: 2 Index 2: 3 Index 3: 4 Index 4: 5 Index 5: 6 引用类型原子类 # 基本类型原子类只能更新一个变量,如果需要原子更新多个变量,需要使用 引用类型原子类。\nAtomicReference:引用类型原子类 AtomicStampedReference:原子更新带有版本号的引用类型。该类将整数值与引用关联起来,可用于解决原子的更新数据和数据的版本号,可以解决使用 CAS 进行原子更新时可能出现的 ABA 问题。 AtomicMarkableReference:原子更新带有标记的引用类型。该类将 boolean 标记与引用关联起来,也可以解决使用 CAS 进行原子更新时可能出现的 ABA 问题。 上面三个类提供的方法几乎相同,所以我们这里以 AtomicReference 为例子来介绍。\nAtomicReference 类使用示例 :\n// Person 类 class Person { private String name; private int age; //省略getter/setter和toString } // 创建 AtomicReference 对象并设置初始值 AtomicReference\u0026lt;Person\u0026gt; ar = new AtomicReference\u0026lt;\u0026gt;(new Person(\u0026#34;SnailClimb\u0026#34;, 22)); // 打印初始值 System.out.println(\u0026#34;Initial Person: \u0026#34; + ar.get().toString()); // 更新值 Person updatePerson = new Person(\u0026#34;Daisy\u0026#34;, 20); ar.compareAndSet(ar.get(), updatePerson); // 打印更新后的值 System.out.println(\u0026#34;Updated Person: \u0026#34; + ar.get().toString()); // 尝试再次更新 Person anotherUpdatePerson = new Person(\u0026#34;John\u0026#34;, 30); boolean isUpdated = ar.compareAndSet(updatePerson, anotherUpdatePerson); // 打印是否更新成功及最终值 System.out.println(\u0026#34;Second Update Success: \u0026#34; + isUpdated); System.out.println(\u0026#34;Final Person: \u0026#34; + ar.get().toString()); 输出:\nInitial Person: Person{name=\u0026#39;SnailClimb\u0026#39;, age=22} Updated Person: Person{name=\u0026#39;Daisy\u0026#39;, age=20} Second Update Success: true Final Person: Person{name=\u0026#39;John\u0026#39;, age=30} AtomicStampedReference 类使用示例 :\n// 创建一个 AtomicStampedReference 对象,初始值为 \u0026#34;SnailClimb\u0026#34;,初始版本号为 1 AtomicStampedReference\u0026lt;String\u0026gt; asr = new AtomicStampedReference\u0026lt;\u0026gt;(\u0026#34;SnailClimb\u0026#34;, 1); // 打印初始值和版本号 int[] initialStamp = new int[1]; String initialRef = asr.get(initialStamp); System.out.println(\u0026#34;Initial Reference: \u0026#34; + initialRef + \u0026#34;, Initial Stamp: \u0026#34; + initialStamp[0]); // 更新值和版本号 int oldStamp = initialStamp[0]; String oldRef = initialRef; String newRef = \u0026#34;Daisy\u0026#34;; int newStamp = oldStamp + 1; boolean isUpdated = asr.compareAndSet(oldRef, newRef, oldStamp, newStamp); System.out.println(\u0026#34;Update Success: \u0026#34; + isUpdated); // 打印更新后的值和版本号 int[] updatedStamp = new int[1]; String updatedRef = asr.get(updatedStamp); System.out.println(\u0026#34;Updated Reference: \u0026#34; + updatedRef + \u0026#34;, Updated Stamp: \u0026#34; + updatedStamp[0]); // 尝试用错误的版本号更新 boolean isUpdatedWithWrongStamp = asr.compareAndSet(newRef, \u0026#34;John\u0026#34;, oldStamp, newStamp + 1); System.out.println(\u0026#34;Update with Wrong Stamp Success: \u0026#34; + isUpdatedWithWrongStamp); // 打印最终的值和版本号 int[] finalStamp = new int[1]; String finalRef = asr.get(finalStamp); System.out.println(\u0026#34;Final Reference: \u0026#34; + finalRef + \u0026#34;, Final Stamp: \u0026#34; + finalStamp[0]); 输出结果如下:\nInitial Reference: SnailClimb, Initial Stamp: 1 Update Success: true Updated Reference: Daisy, Updated Stamp: 2 Update with Wrong Stamp Success: false Final Reference: Daisy, Final Stamp: 2 AtomicMarkableReference 类使用示例 :\n// 创建一个 AtomicMarkableReference 对象,初始值为 \u0026#34;SnailClimb\u0026#34;,初始标记为 false AtomicMarkableReference\u0026lt;String\u0026gt; amr = new AtomicMarkableReference\u0026lt;\u0026gt;(\u0026#34;SnailClimb\u0026#34;, false); // 打印初始值和标记 boolean[] initialMark = new boolean[1]; String initialRef = amr.get(initialMark); System.out.println(\u0026#34;Initial Reference: \u0026#34; + initialRef + \u0026#34;, Initial Mark: \u0026#34; + initialMark[0]); // 更新值和标记 String oldRef = initialRef; String newRef = \u0026#34;Daisy\u0026#34;; boolean oldMark = initialMark[0]; boolean newMark = true; boolean isUpdated = amr.compareAndSet(oldRef, newRef, oldMark, newMark); System.out.println(\u0026#34;Update Success: \u0026#34; + isUpdated); // 打印更新后的值和标记 boolean[] updatedMark = new boolean[1]; String updatedRef = amr.get(updatedMark); System.out.println(\u0026#34;Updated Reference: \u0026#34; + updatedRef + \u0026#34;, Updated Mark: \u0026#34; + updatedMark[0]); // 尝试用错误的标记更新 boolean isUpdatedWithWrongMark = amr.compareAndSet(newRef, \u0026#34;John\u0026#34;, oldMark, !newMark); System.out.println(\u0026#34;Update with Wrong Mark Success: \u0026#34; + isUpdatedWithWrongMark); // 打印最终的值和标记 boolean[] finalMark = new boolean[1]; String finalRef = amr.get(finalMark); System.out.println(\u0026#34;Final Reference: \u0026#34; + finalRef + \u0026#34;, Final Mark: \u0026#34; + finalMark[0]); 输出结果如下:\nInitial Reference: SnailClimb, Initial Mark: false Update Success: true Updated Reference: Daisy, Updated Mark: true Update with Wrong Mark Success: false Final Reference: Daisy, Final Mark: true 对象的属性修改类型原子类 # 如果需要原子更新某个类里的某个字段时,需要用到对象的属性修改类型原子类。\nAtomicIntegerFieldUpdater:原子更新整形字段的更新器 AtomicLongFieldUpdater:原子更新长整形字段的更新器 AtomicReferenceFieldUpdater:原子更新引用类型里的字段的更新器 要想原子地更新对象的属性需要两步。第一步,因为对象的属性修改类型原子类都是抽象类,所以每次使用都必须使用静态方法 newUpdater()创建一个更新器,并且需要设置想要更新的类和属性。第二步,更新的对象属性必须使用 public volatile 修饰符。\n上面三个类提供的方法几乎相同,所以我们这里以 AtomicIntegerFieldUpdater为例子来介绍。\nAtomicIntegerFieldUpdater 类使用示例 :\n// Person 类 class Person { private String name; // 要使用 AtomicIntegerFieldUpdater,字段必须是 public volatile private volatile int age; //省略getter/setter和toString } // 创建 AtomicIntegerFieldUpdater 对象 AtomicIntegerFieldUpdater\u0026lt;Person\u0026gt; ageUpdater = AtomicIntegerFieldUpdater.newUpdater(Person.class, \u0026#34;age\u0026#34;); // 创建 Person 对象 Person person = new Person(\u0026#34;SnailClimb\u0026#34;, 22); // 打印初始值 System.out.println(\u0026#34;Initial Person: \u0026#34; + person); // 更新 age 字段 ageUpdater.incrementAndGet(person); // 自增 System.out.println(\u0026#34;After Increment: \u0026#34; + person); ageUpdater.addAndGet(person, 5); // 增加 5 System.out.println(\u0026#34;After Adding 5: \u0026#34; + person); ageUpdater.compareAndSet(person, 28, 30); // 如果当前值是 28,则设置为 30 System.out.println(\u0026#34;After Compare and Set (28 to 30): \u0026#34; + person); // 尝试使用错误的比较值进行更新 boolean isUpdated = ageUpdater.compareAndSet(person, 28, 35); // 这次应该失败 System.out.println(\u0026#34;Compare and Set (28 to 35) Success: \u0026#34; + isUpdated); System.out.println(\u0026#34;Final Person: \u0026#34; + person); 输出结果:\nInitial Person: Name: SnailClimb, Age: 22 After Increment: Name: SnailClimb, Age: 23 After Adding 5: Name: SnailClimb, Age: 28 After Compare and Set (28 to 30): Name: SnailClimb, Age: 30 Compare and Set (28 to 35) Success: false Final Person: Name: SnailClimb, Age: 30 参考 # 《Java 并发编程的艺术》 "},{"id":461,"href":"/zh/docs/technology/Interview/java/basis/bigdecimal/","title":"BigDecimal 详解","section":"Basis","content":"《阿里巴巴 Java 开发手册》中提到:“为了避免精度丢失,可以使用 BigDecimal 来进行浮点数的运算”。\n浮点数的运算竟然还会有精度丢失的风险吗?确实会!\n示例代码:\nfloat a = 2.0f - 1.9f; float b = 1.8f - 1.7f; System.out.println(a);// 0.100000024 System.out.println(b);// 0.099999905 System.out.println(a == b);// false 为什么浮点数 float 或 double 运算的时候会有精度丢失的风险呢?\n这个和计算机保存浮点数的机制有很大关系。我们知道计算机是二进制的,而且计算机在表示一个数字时,宽度是有限的,无限循环的小数存储在计算机时,只能被截断,所以就会导致小数精度发生损失的情况。这也就是解释了为什么浮点数没有办法用二进制精确表示。\n就比如说十进制下的 0.2 就没办法精确转换成二进制小数:\n// 0.2 转换为二进制数的过程为,不断乘以 2,直到不存在小数为止, // 在这个计算过程中,得到的整数部分从上到下排列就是二进制的结果。 0.2 * 2 = 0.4 -\u0026gt; 0 0.4 * 2 = 0.8 -\u0026gt; 0 0.8 * 2 = 1.6 -\u0026gt; 1 0.6 * 2 = 1.2 -\u0026gt; 1 0.2 * 2 = 0.4 -\u0026gt; 0(发生循环) ... 关于浮点数的更多内容,建议看一下 计算机系统基础(四)浮点数这篇文章。\nBigDecimal 介绍 # BigDecimal 可以实现对浮点数的运算,不会造成精度丢失。\n通常情况下,大部分需要浮点数精确运算结果的业务场景(比如涉及到钱的场景)都是通过 BigDecimal 来做的。\n《阿里巴巴 Java 开发手册》中提到:浮点数之间的等值判断,基本数据类型不能用 == 来比较,包装数据类型不能用 equals 来判断。\n具体原因我们在上面已经详细介绍了,这里就不多提了。\n想要解决浮点数运算精度丢失这个问题,可以直接使用 BigDecimal 来定义浮点数的值,然后再进行浮点数的运算操作即可。\nBigDecimal a = new BigDecimal(\u0026#34;1.0\u0026#34;); BigDecimal b = new BigDecimal(\u0026#34;0.9\u0026#34;); BigDecimal c = new BigDecimal(\u0026#34;0.8\u0026#34;); BigDecimal x = a.subtract(b); BigDecimal y = b.subtract(c); System.out.println(x.compareTo(y));// 0 BigDecimal 常见方法 # 创建 # 我们在使用 BigDecimal 时,为了防止精度丢失,推荐使用它的BigDecimal(String val)构造方法或者 BigDecimal.valueOf(double val) 静态方法来创建对象。\n《阿里巴巴 Java 开发手册》对这部分内容也有提到,如下图所示。\n加减乘除 # add 方法用于将两个 BigDecimal 对象相加,subtract 方法用于将两个 BigDecimal 对象相减。multiply 方法用于将两个 BigDecimal 对象相乘,divide 方法用于将两个 BigDecimal 对象相除。\nBigDecimal a = new BigDecimal(\u0026#34;1.0\u0026#34;); BigDecimal b = new BigDecimal(\u0026#34;0.9\u0026#34;); System.out.println(a.add(b));// 1.9 System.out.println(a.subtract(b));// 0.1 System.out.println(a.multiply(b));// 0.90 System.out.println(a.divide(b));// 无法除尽,抛出 ArithmeticException 异常 System.out.println(a.divide(b, 2, RoundingMode.HALF_UP));// 1.11 这里需要注意的是,在我们使用 divide 方法的时候尽量使用 3 个参数版本,并且RoundingMode 不要选择 UNNECESSARY,否则很可能会遇到 ArithmeticException(无法除尽出现无限循环小数的时候),其中 scale 表示要保留几位小数,roundingMode 代表保留规则。\npublic BigDecimal divide(BigDecimal divisor, int scale, RoundingMode roundingMode) { return divide(divisor, scale, roundingMode.oldMode); } 保留规则非常多,这里列举几种:\npublic enum RoundingMode { // 2.5 -\u0026gt; 3 , 1.6 -\u0026gt; 2 // -1.6 -\u0026gt; -2 , -2.5 -\u0026gt; -3 UP(BigDecimal.ROUND_UP), // 2.5 -\u0026gt; 2 , 1.6 -\u0026gt; 1 // -1.6 -\u0026gt; -1 , -2.5 -\u0026gt; -2 DOWN(BigDecimal.ROUND_DOWN), // 2.5 -\u0026gt; 3 , 1.6 -\u0026gt; 2 // -1.6 -\u0026gt; -1 , -2.5 -\u0026gt; -2 CEILING(BigDecimal.ROUND_CEILING), // 2.5 -\u0026gt; 2 , 1.6 -\u0026gt; 1 // -1.6 -\u0026gt; -2 , -2.5 -\u0026gt; -3 FLOOR(BigDecimal.ROUND_FLOOR), // 2.5 -\u0026gt; 3 , 1.6 -\u0026gt; 2 // -1.6 -\u0026gt; -2 , -2.5 -\u0026gt; -3 HALF_UP(BigDecimal.ROUND_HALF_UP), //...... } 大小比较 # a.compareTo(b) : 返回 -1 表示 a 小于 b,0 表示 a 等于 b , 1 表示 a 大于 b。\nBigDecimal a = new BigDecimal(\u0026#34;1.0\u0026#34;); BigDecimal b = new BigDecimal(\u0026#34;0.9\u0026#34;); System.out.println(a.compareTo(b));// 1 保留几位小数 # 通过 setScale方法设置保留几位小数以及保留规则。保留规则有挺多种,不需要记,IDEA 会提示。\nBigDecimal m = new BigDecimal(\u0026#34;1.255433\u0026#34;); BigDecimal n = m.setScale(3,RoundingMode.HALF_DOWN); System.out.println(n);// 1.255 BigDecimal 等值比较问题 # 《阿里巴巴 Java 开发手册》中提到:\nBigDecimal 使用 equals() 方法进行等值比较出现问题的代码示例:\nBigDecimal a = new BigDecimal(\u0026#34;1\u0026#34;); BigDecimal b = new BigDecimal(\u0026#34;1.0\u0026#34;); System.out.println(a.equals(b));//false 这是因为 equals() 方法不仅仅会比较值的大小(value)还会比较精度(scale),而 compareTo() 方法比较的时候会忽略精度。\n1.0 的 scale 是 1,1 的 scale 是 0,因此 a.equals(b) 的结果是 false。\ncompareTo() 方法可以比较两个 BigDecimal 的值,如果相等就返回 0,如果第 1 个数比第 2 个数大则返回 1,反之返回-1。\nBigDecimal a = new BigDecimal(\u0026#34;1\u0026#34;); BigDecimal b = new BigDecimal(\u0026#34;1.0\u0026#34;); System.out.println(a.compareTo(b));//0 BigDecimal 工具类分享 # 网上有一个使用人数比较多的 BigDecimal 工具类,提供了多个静态方法来简化 BigDecimal 的操作。\n我对其进行了简单改进,分享一下源码:\nimport java.math.BigDecimal; import java.math.RoundingMode; /** * 简化BigDecimal计算的小工具类 */ public class BigDecimalUtil { /** * 默认除法运算精度 */ private static final int DEF_DIV_SCALE = 10; private BigDecimalUtil() { } /** * 提供精确的加法运算。 * * @param v1 被加数 * @param v2 加数 * @return 两个参数的和 */ public static double add(double v1, double v2) { BigDecimal b1 = BigDecimal.valueOf(v1); BigDecimal b2 = BigDecimal.valueOf(v2); return b1.add(b2).doubleValue(); } /** * 提供精确的减法运算。 * * @param v1 被减数 * @param v2 减数 * @return 两个参数的差 */ public static double subtract(double v1, double v2) { BigDecimal b1 = BigDecimal.valueOf(v1); BigDecimal b2 = BigDecimal.valueOf(v2); return b1.subtract(b2).doubleValue(); } /** * 提供精确的乘法运算。 * * @param v1 被乘数 * @param v2 乘数 * @return 两个参数的积 */ public static double multiply(double v1, double v2) { BigDecimal b1 = BigDecimal.valueOf(v1); BigDecimal b2 = BigDecimal.valueOf(v2); return b1.multiply(b2).doubleValue(); } /** * 提供(相对)精确的除法运算,当发生除不尽的情况时,精确到 * 小数点以后10位,以后的数字四舍五入。 * * @param v1 被除数 * @param v2 除数 * @return 两个参数的商 */ public static double divide(double v1, double v2) { return divide(v1, v2, DEF_DIV_SCALE); } /** * 提供(相对)精确的除法运算。当发生除不尽的情况时,由scale参数指 * 定精度,以后的数字四舍五入。 * * @param v1 被除数 * @param v2 除数 * @param scale 表示表示需要精确到小数点以后几位。 * @return 两个参数的商 */ public static double divide(double v1, double v2, int scale) { if (scale \u0026lt; 0) { throw new IllegalArgumentException( \u0026#34;The scale must be a positive integer or zero\u0026#34;); } BigDecimal b1 = BigDecimal.valueOf(v1); BigDecimal b2 = BigDecimal.valueOf(v2); return b1.divide(b2, scale, RoundingMode.HALF_EVEN).doubleValue(); } /** * 提供精确的小数位四舍五入处理。 * * @param v 需要四舍五入的数字 * @param scale 小数点后保留几位 * @return 四舍五入后的结果 */ public static double round(double v, int scale) { if (scale \u0026lt; 0) { throw new IllegalArgumentException( \u0026#34;The scale must be a positive integer or zero\u0026#34;); } BigDecimal b = BigDecimal.valueOf(v); BigDecimal one = new BigDecimal(\u0026#34;1\u0026#34;); return b.divide(one, scale, RoundingMode.HALF_UP).doubleValue(); } /** * 提供精确的类型转换(Float) * * @param v 需要被转换的数字 * @return 返回转换结果 */ public static float convertToFloat(double v) { BigDecimal b = new BigDecimal(v); return b.floatValue(); } /** * 提供精确的类型转换(Int)不进行四舍五入 * * @param v 需要被转换的数字 * @return 返回转换结果 */ public static int convertsToInt(double v) { BigDecimal b = new BigDecimal(v); return b.intValue(); } /** * 提供精确的类型转换(Long) * * @param v 需要被转换的数字 * @return 返回转换结果 */ public static long convertsToLong(double v) { BigDecimal b = new BigDecimal(v); return b.longValue(); } /** * 返回两个数中大的一个值 * * @param v1 需要被对比的第一个数 * @param v2 需要被对比的第二个数 * @return 返回两个数中大的一个值 */ public static double returnMax(double v1, double v2) { BigDecimal b1 = new BigDecimal(v1); BigDecimal b2 = new BigDecimal(v2); return b1.max(b2).doubleValue(); } /** * 返回两个数中小的一个值 * * @param v1 需要被对比的第一个数 * @param v2 需要被对比的第二个数 * @return 返回两个数中小的一个值 */ public static double returnMin(double v1, double v2) { BigDecimal b1 = new BigDecimal(v1); BigDecimal b2 = new BigDecimal(v2); return b1.min(b2).doubleValue(); } /** * 精确对比两个数字 * * @param v1 需要被对比的第一个数 * @param v2 需要被对比的第二个数 * @return 如果两个数一样则返回0,如果第一个数比第二个数大则返回1,反之返回-1 */ public static int compareTo(double v1, double v2) { BigDecimal b1 = BigDecimal.valueOf(v1); BigDecimal b2 = BigDecimal.valueOf(v2); return b1.compareTo(b2); } } 相关 issue: 建议对保留规则设置为 RoundingMode.HALF_EVEN,即四舍六入五成双,#2129 。\n总结 # 浮点数没有办法用二进制精确表示,因此存在精度丢失的风险。\n不过,Java 提供了BigDecimal 来操作浮点数。BigDecimal 的实现利用到了 BigInteger (用来操作大整数), 所不同的是 BigDecimal 加入了小数位的概念。\n"},{"id":462,"href":"/zh/docs/technology/Interview/distributed-system/protocol/cap-and-base-theorem/","title":"CAP \u0026 BASE理论详解","section":"Protocol","content":"经历过技术面试的小伙伴想必对 CAP \u0026amp; BASE 这个两个理论已经再熟悉不过了!\n我当年参加面试的时候,不夸张地说,只要问到分布式相关的内容,面试官几乎是必定会问这两个分布式相关的理论。一是因为这两个分布式基础理论是学习分布式知识的必备前置基础,二是因为很多面试官自己比较熟悉这两个理论(方便提问)。\n我们非常有必要将这两个理论搞懂,并且能够用自己的理解给别人讲出来。\nCAP 理论 # CAP 理论/定理起源于 2000 年,由加州大学伯克利分校的 Eric Brewer 教授在分布式计算原理研讨会(PODC)上提出,因此 CAP 定理又被称作 布鲁尔定理(Brewer’s theorem)\n2 年后,麻省理工学院的 Seth Gilbert 和 Nancy Lynch 发表了布鲁尔猜想的证明,CAP 理论正式成为分布式领域的定理。\n简介 # CAP 也就是 Consistency(一致性)、Availability(可用性)、Partition Tolerance(分区容错性) 这三个单词首字母组合。\nCAP 理论的提出者布鲁尔在提出 CAP 猜想的时候,并没有详细定义 Consistency、Availability、Partition Tolerance 三个单词的明确定义。\n因此,对于 CAP 的民间解读有很多,一般比较被大家推荐的是下面 👇 这种版本的解读。\n在理论计算机科学中,CAP 定理(CAP theorem)指出对于一个分布式系统来说,当设计读写操作时,只能同时满足以下三点中的两个:\n一致性(Consistency) : 所有节点访问同一份最新的数据副本 可用性(Availability): 非故障的节点在合理的时间内返回合理的响应(不是错误或者超时的响应)。 分区容错性(Partition Tolerance) : 分布式系统出现网络分区的时候,仍然能够对外提供服务。 什么是网络分区?\n分布式系统中,多个节点之间的网络本来是连通的,但是因为某些故障(比如部分节点网络出了问题)某些节点之间不连通了,整个网络就分成了几块区域,这就叫 网络分区。\n不是所谓的“3 选 2” # 大部分人解释这一定律时,常常简单的表述为:“一致性、可用性、分区容忍性三者你只能同时达到其中两个,不可能同时达到”。实际上这是一个非常具有误导性质的说法,而且在 CAP 理论诞生 12 年之后,CAP 之父也在 2012 年重写了之前的论文。\n当发生网络分区的时候,如果我们要继续服务,那么强一致性和可用性只能 2 选 1。也就是说当网络分区之后 P 是前提,决定了 P 之后才有 C 和 A 的选择。也就是说分区容错性(Partition tolerance)我们是必须要实现的。\n简而言之就是:CAP 理论中分区容错性 P 是一定要满足的,在此基础上,只能满足可用性 A 或者一致性 C。\n因此,分布式系统理论上不可能选择 CA 架构,只能选择 CP 或者 AP 架构。 比如 ZooKeeper、HBase 就是 CP 架构,Cassandra、Eureka 就是 AP 架构,Nacos 不仅支持 CP 架构也支持 AP 架构。\n为啥不可能选择 CA 架构呢? 举个例子:若系统出现“分区”,系统中的某个节点在进行写操作。为了保证 C, 必须要禁止其他节点的读写操作,这就和 A 发生冲突了。如果为了保证 A,其他节点的读写操作正常的话,那就和 C 发生冲突了。\n选择 CP 还是 AP 的关键在于当前的业务场景,没有定论,比如对于需要确保强一致性的场景如银行一般会选择保证 CP 。\n另外,需要补充说明的一点是:如果网络分区正常的话(系统在绝大部分时候所处的状态),也就说不需要保证 P 的时候,C 和 A 能够同时保证。\nCAP 实际应用案例 # 我这里以注册中心来探讨一下 CAP 的实际应用。考虑到很多小伙伴不知道注册中心是干嘛的,这里简单以 Dubbo 为例说一说。\n下图是 Dubbo 的架构图。注册中心 Registry 在其中扮演了什么角色呢?提供了什么服务呢?\n注册中心负责服务地址的注册与查找,相当于目录服务,服务提供者和消费者只在启动时与注册中心交互,注册中心不转发请求,压力较小。\n常见的可以作为注册中心的组件有:ZooKeeper、Eureka、Nacos\u0026hellip;。\nZooKeeper 保证的是 CP。 任何时刻对 ZooKeeper 的读请求都能得到一致性的结果,但是, ZooKeeper 不保证每次请求的可用性比如在 Leader 选举过程中或者半数以上的机器不可用的时候服务就是不可用的。 Eureka 保证的则是 AP。 Eureka 在设计的时候就是优先保证 A (可用性)。在 Eureka 中不存在什么 Leader 节点,每个节点都是一样的、平等的。因此 Eureka 不会像 ZooKeeper 那样出现选举过程中或者半数以上的机器不可用的时候服务就是不可用的情况。 Eureka 保证即使大部分节点挂掉也不会影响正常提供服务,只要有一个节点是可用的就行了。只不过这个节点上的数据可能并不是最新的。 Nacos 不仅支持 CP 也支持 AP。 🐛 修正(参见: issue#1906):\nZooKeeper 通过可线性化(Linearizable)写入、全局 FIFO 顺序访问等机制来保障数据一致性。多节点部署的情况下, ZooKeeper 集群处于 Quorum 模式。Quorum 模式下的 ZooKeeper 集群, 是一组 ZooKeeper 服务器节点组成的集合,其中大多数节点必须同意任何变更才能被视为有效。\n由于 Quorum 模式下的读请求不会触发各个 ZooKeeper 节点之间的数据同步,因此在某些情况下还是可能会存在读取到旧数据的情况,导致不同的客户端视图上看到的结果不同,这可能是由于网络延迟、丢包、重传等原因造成的。ZooKeeper 为了解决这个问题,提供了 Watcher 机制和版本号机制来帮助客户端检测数据的变化和版本号的变更,以保证数据的一致性。\n总结 # 在进行分布式系统设计和开发时,我们不应该仅仅局限在 CAP 问题上,还要关注系统的扩展性、可用性等等\n在系统发生“分区”的情况下,CAP 理论只能满足 CP 或者 AP。要注意的是,这里的前提是系统发生了“分区”\n如果系统没有发生“分区”的话,节点间的网络连接通信正常的话,也就不存在 P 了。这个时候,我们就可以同时保证 C 和 A 了。\n总结:如果系统发生“分区”,我们要考虑选择 CP 还是 AP。如果系统没有发生“分区”的话,我们要思考如何保证 CA 。\n推荐阅读 # CAP 定理简化 (英文,有趣的案例) 神一样的 CAP 理论被应用在何方 (中文,列举了很多实际的例子) 请停止呼叫数据库 CP 或 AP (英文,带给你不一样的思考) BASE 理论 # BASE 理论起源于 2008 年, 由 eBay 的架构师 Dan Pritchett 在 ACM 上发表。\n简介 # BASE 是 Basically Available(基本可用)、Soft-state(软状态) 和 Eventually Consistent(最终一致性) 三个短语的缩写。BASE 理论是对 CAP 中一致性 C 和可用性 A 权衡的结果,其来源于对大规模互联网系统分布式实践的总结,是基于 CAP 定理逐步演化而来的,它大大降低了我们对系统的要求。\nBASE 理论的核心思想 # 即使无法做到强一致性,但每个应用都可以根据自身业务特点,采用适当的方式来使系统达到最终一致性。\n也就是牺牲数据的一致性来满足系统的高可用性,系统中一部分数据不可用或者不一致时,仍需要保持系统整体“主要可用”。\nBASE 理论本质上是对 CAP 的延伸和补充,更具体地说,是对 CAP 中 AP 方案的一个补充。\n为什么这样说呢?\nCAP 理论这节我们也说过了:\n如果系统没有发生“分区”的话,节点间的网络连接通信正常的话,也就不存在 P 了。这个时候,我们就可以同时保证 C 和 A 了。因此,如果系统发生“分区”,我们要考虑选择 CP 还是 AP。如果系统没有发生“分区”的话,我们要思考如何保证 CA 。\n因此,AP 方案只是在系统发生分区的时候放弃一致性,而不是永远放弃一致性。在分区故障恢复后,系统应该达到最终一致性。这一点其实就是 BASE 理论延伸的地方。\nBASE 理论三要素 # 基本可用 # 基本可用是指分布式系统在出现不可预知故障的时候,允许损失部分可用性。但是,这绝不等价于系统不可用。\n什么叫允许损失部分可用性呢?\n响应时间上的损失: 正常情况下,处理用户请求需要 0.5s 返回结果,但是由于系统出现故障,处理用户请求的时间变为 3 s。 系统功能上的损失:正常情况下,用户可以使用系统的全部功能,但是由于系统访问量突然剧增,系统的部分非核心功能无法使用。 软状态 # 软状态指允许系统中的数据存在中间状态(CAP 理论中的数据不一致),并认为该中间状态的存在不会影响系统的整体可用性,即允许系统在不同节点的数据副本之间进行数据同步的过程存在延时。\n最终一致性 # 最终一致性强调的是系统中所有的数据副本,在经过一段时间的同步后,最终能够达到一个一致的状态。因此,最终一致性的本质是需要系统保证最终数据能够达到一致,而不需要实时保证系统数据的强一致性。\n分布式一致性的 3 种级别:\n强一致性:系统写入了什么,读出来的就是什么。 弱一致性:不一定可以读取到最新写入的值,也不保证多少时间之后读取到的数据是最新的,只是会尽量保证某个时刻达到数据一致的状态。 最终一致性:弱一致性的升级版,系统会保证在一定时间内达到数据一致的状态。 业界比较推崇是最终一致性级别,但是某些对数据一致要求十分严格的场景比如银行转账还是要保证强一致性。\n那实现最终一致性的具体方式是什么呢? 《分布式协议与算法实战》 中是这样介绍:\n读时修复 : 在读取数据时,检测数据的不一致,进行修复。比如 Cassandra 的 Read Repair 实现,具体来说,在向 Cassandra 系统查询数据的时候,如果检测到不同节点的副本数据不一致,系统就自动修复数据。 写时修复 : 在写入数据,检测数据的不一致时,进行修复。比如 Cassandra 的 Hinted Handoff 实现。具体来说,Cassandra 集群的节点之间远程写数据的时候,如果写失败 就将数据缓存下来,然后定时重传,修复数据的不一致性。 异步修复 : 这个是最常用的方式,通过定时对账检测副本数据的一致性,并修复。 比较推荐 写时修复,这种方式对性能消耗比较低。\n总结 # ACID 是数据库事务完整性的理论,CAP 是分布式系统设计理论,BASE 是 CAP 理论中 AP 方案的延伸。\n"},{"id":463,"href":"/zh/docs/technology/Interview/java/concurrent/cas/","title":"CAS 详解","section":"Concurrent","content":"乐观锁和悲观锁的介绍以及乐观锁常见实现方式可以阅读笔者写的这篇文章: 乐观锁和悲观锁详解。\n这篇文章主要介绍 :Java 中 CAS 的实现以及 CAS 存在的一些问题。\nJava 中 CAS 是如何实现的? # 在 Java 中,实现 CAS(Compare-And-Swap, 比较并交换)操作的一个关键类是Unsafe。\nUnsafe类位于sun.misc包下,是一个提供低级别、不安全操作的类。由于其强大的功能和潜在的危险性,它通常用于 JVM 内部或一些需要极高性能和底层访问的库中,而不推荐普通开发者在应用程序中使用。关于 Unsafe类的详细介绍,可以阅读这篇文章:📌 Java 魔法类 Unsafe 详解。\nsun.misc包下的Unsafe类提供了compareAndSwapObject、compareAndSwapInt、compareAndSwapLong方法来实现的对Object、int、long类型的 CAS 操作:\n/** * 以原子方式更新对象字段的值。 * * @param o 要操作的对象 * @param offset 对象字段的内存偏移量 * @param expected 期望的旧值 * @param x 要设置的新值 * @return 如果值被成功更新,则返回 true;否则返回 false */ boolean compareAndSwapObject(Object o, long offset, Object expected, Object x); /** * 以原子方式更新 int 类型的对象字段的值。 */ boolean compareAndSwapInt(Object o, long offset, int expected, int x); /** * 以原子方式更新 long 类型的对象字段的值。 */ boolean compareAndSwapLong(Object o, long offset, long expected, long x); Unsafe类中的 CAS 方法是native方法。native关键字表明这些方法是用本地代码(通常是 C 或 C++)实现的,而不是用 Java 实现的。这些方法直接调用底层的硬件指令来实现原子操作。也就是说,Java 语言并没有直接用 Java 实现 CAS。\n更准确点来说,Java 中 CAS 是 C++ 内联汇编的形式实现的,通过 JNI(Java Native Interface) 调用。因此,CAS 的具体实现与操作系统以及 CPU 密切相关。\njava.util.concurrent.atomic 包提供了一些用于原子操作的类。\n关于这些 Atomic 原子类的介绍和使用,可以阅读这篇文章: Atomic 原子类总结。\nAtomic 类依赖于 CAS 乐观锁来保证其方法的原子性,而不需要使用传统的锁机制(如 synchronized 块或 ReentrantLock)。\nAtomicInteger是 Java 的原子类之一,主要用于对 int 类型的变量进行原子操作,它利用Unsafe类提供的低级别原子操作方法实现无锁的线程安全性。\n下面,我们通过解读AtomicInteger的核心源码(JDK1.8),来说明 Java 如何使用Unsafe类的方法来实现原子操作。\nAtomicInteger核心源码如下:\n// 获取 Unsafe 实例 private static final Unsafe unsafe = Unsafe.getUnsafe(); private static final long valueOffset; static { try { // 获取“value”字段在AtomicInteger类中的内存偏移量 valueOffset = unsafe.objectFieldOffset (AtomicInteger.class.getDeclaredField(\u0026#34;value\u0026#34;)); } catch (Exception ex) { throw new Error(ex); } } // 确保“value”字段的可见性 private volatile int value; // 如果当前值等于预期值,则原子地将值设置为newValue // 使用 Unsafe#compareAndSwapInt 方法进行CAS操作 public final boolean compareAndSet(int expect, int update) { return unsafe.compareAndSwapInt(this, valueOffset, expect, update); } // 原子地将当前值加 delta 并返回旧值 public final int getAndAdd(int delta) { return unsafe.getAndAddInt(this, valueOffset, delta); } // 原子地将当前值加 1 并返回加之前的值(旧值) // 使用 Unsafe#getAndAddInt 方法进行CAS操作。 public final int getAndIncrement() { return unsafe.getAndAddInt(this, valueOffset, 1); } // 原子地将当前值减 1 并返回减之前的值(旧值) public final int getAndDecrement() { return unsafe.getAndAddInt(this, valueOffset, -1); } Unsafe#getAndAddInt源码:\n// 原子地获取并增加整数值 public final int getAndAddInt(Object o, long offset, int delta) { int v; do { // 以 volatile 方式获取对象 o 在内存偏移量 offset 处的整数值 v = getIntVolatile(o, offset); } while (!compareAndSwapInt(o, offset, v, v + delta)); // 返回旧值 return v; } 可以看到,getAndAddInt 使用了 do-while 循环:在compareAndSwapInt操作失败时,会不断重试直到成功。也就是说,getAndAddInt方法会通过 compareAndSwapInt 方法来尝试更新 value 的值,如果更新失败(当前值在此期间被其他线程修改),它会重新获取当前值并再次尝试更新,直到操作成功。\n由于 CAS 操作可能会因为并发冲突而失败,因此通常会与while循环搭配使用,在失败后不断重试,直到操作成功。这就是 自旋锁机制 。\nCAS 算法存在哪些问题? # ABA 问题是 CAS 算法最常见的问题。\nABA 问题 # 如果一个变量 V 初次读取的时候是 A 值,并且在准备赋值的时候检查到它仍然是 A 值,那我们就能说明它的值没有被其他线程修改过了吗?很明显是不能的,因为在这段时间它的值可能被改为其他值,然后又改回 A,那 CAS 操作就会误认为它从来没有被修改过。这个问题被称为 CAS 操作的 \u0026ldquo;ABA\u0026quot;问题。\nABA 问题的解决思路是在变量前面追加上版本号或者时间戳。JDK 1.5 以后的 AtomicStampedReference 类就是用来解决 ABA 问题的,其中的 compareAndSet() 方法就是首先检查当前引用是否等于预期引用,并且当前标志是否等于预期标志,如果全部相等,则以原子方式将该引用和该标志的值设置为给定的更新值。\npublic boolean compareAndSet(V expectedReference, V newReference, int expectedStamp, int newStamp) { Pair\u0026lt;V\u0026gt; current = pair; return expectedReference == current.reference \u0026amp;\u0026amp; expectedStamp == current.stamp \u0026amp;\u0026amp; ((newReference == current.reference \u0026amp;\u0026amp; newStamp == current.stamp) || casPair(current, Pair.of(newReference, newStamp))); } 循环时间长开销大 # CAS 经常会用到自旋操作来进行重试,也就是不成功就一直循环执行直到成功。如果长时间不成功,会给 CPU 带来非常大的执行开销。\n如果 JVM 能够支持处理器提供的pause指令,那么自旋操作的效率将有所提升。pause指令有两个重要作用:\n延迟流水线执行指令:pause指令可以延迟指令的执行,从而减少 CPU 的资源消耗。具体的延迟时间取决于处理器的实现版本,在某些处理器上,延迟时间可能为零。 避免内存顺序冲突:在退出循环时,pause指令可以避免由于内存顺序冲突而导致的 CPU 流水线被清空,从而提高 CPU 的执行效率。 只能保证一个共享变量的原子操作 # CAS 操作仅能对单个共享变量有效。当需要操作多个共享变量时,CAS 就显得无能为力。不过,从 JDK 1.5 开始,Java 提供了AtomicReference类,这使得我们能够保证引用对象之间的原子性。通过将多个变量封装在一个对象中,我们可以使用AtomicReference来执行 CAS 操作。\n除了 AtomicReference 这种方式之外,还可以利用加锁来保证。\n总结 # 在 Java 中,CAS 通过 Unsafe 类中的 native 方法实现,这些方法调用底层的硬件指令来完成原子操作。由于其实现依赖于 C++ 内联汇编和 JNI 调用,因此 CAS 的具体实现与操作系统以及 CPU 密切相关。\nCAS 虽然具有高效的无锁特性,但也需要注意 ABA 、循环时间长开销大等问题。\n"},{"id":464,"href":"/zh/docs/technology/Interview/high-performance/cdn/","title":"CDN工作原理详解","section":"High Performance","content":" 什么是 CDN ? # CDN 全称是 Content Delivery Network/Content Distribution Network,翻译过的意思是 内容分发网络 。\n我们可以将内容分发网络拆开来看:\n内容:指的是静态资源比如图片、视频、文档、JS、CSS、HTML。 分发网络:指的是将这些静态资源分发到位于多个不同的地理位置机房中的服务器上,这样,就可以实现静态资源的就近访问比如北京的用户直接访问北京机房的数据。 所以,简单来说,CDN 就是将静态资源分发到多个不同的地方以实现就近访问,进而加快静态资源的访问速度,减轻服务器以及带宽的负担。\n类似于京东建立的庞大的仓储运输体系,京东物流在全国拥有非常多的仓库,仓储网络几乎覆盖全国所有区县。这样的话,用户下单的第一时间,商品就从距离用户最近的仓库,直接发往对应的配送站,再由京东小哥送到你家。\n你可以将 CDN 看作是服务上一层的特殊缓存服务,分布在全国各地,主要用来处理静态资源的请求。\n我们经常拿全站加速和内容分发网络做对比,不要把两者搞混了!全站加速(不同云服务商叫法不同,腾讯云叫 ECDN、阿里云叫 DCDN)既可以加速静态资源又可以加速动态资源,内容分发网络(CDN)主要针对的是 静态资源 。\n绝大部分公司都会在项目开发中使用 CDN 服务,但很少会有自建 CDN 服务的公司。基于成本、稳定性和易用性考虑,建议直接选择专业的云厂商(比如阿里云、腾讯云、华为云、青云)或者 CDN 厂商(比如网宿、蓝汛)提供的开箱即用的 CDN 服务。\n很多朋友可能要问了:既然是就近访问,为什么不直接将服务部署在多个不同的地方呢?\n成本太高,需要部署多份相同的服务。 静态资源通常占用空间比较大且经常会被访问到,如果直接使用服务器或者缓存来处理静态资源请求的话,对系统资源消耗非常大,可能会影响到系统其他服务的正常运行。 同一个服务在在多个不同的地方部署多份(比如同城灾备、异地灾备、同城多活、异地多活)是为了实现系统的高可用而不是就近访问。\nCDN 工作原理是什么? # 搞懂下面 3 个问题也就搞懂了 CDN 的工作原理:\n静态资源是如何被缓存到 CDN 节点中的? 如何找到最合适的 CDN 节点? 如何防止静态资源被盗用? 静态资源是如何被缓存到 CDN 节点中的? # 你可以通过 预热 的方式将源站的资源同步到 CDN 的节点中。这样的话,用户首次请求资源可以直接从 CDN 节点中取,无需回源。这样可以降低源站压力,提升用户体验。\n如果不预热的话,你访问的资源可能不在 CDN 节点中,这个时候 CDN 节点将请求源站获取资源,这个过程是大家经常说的 回源。\n回源:当 CDN 节点上没有用户请求的资源或该资源的缓存已经过期时,CDN 节点需要从原始服务器获取最新的资源内容,这个过程就是回源。当用户请求发生回源的话,会导致该请求的响应速度比未使用 CDN 还慢,因为相比于未使用 CDN 还多了一层 CDN 的调用流程。 预热:预热是指在 CDN 上提前将内容缓存到 CDN 节点上。这样当用户在请求这些资源时,能够快速地从最近的 CDN 节点获取到而不需要回源,进而减少了对源站的访问压力,提高了访问速度。 如果资源有更新的话,你也可以对其 刷新 ,删除 CDN 节点上缓存的旧资源,并强制 CDN 节点回源站获取最新资源。\n几乎所有云厂商提供的 CDN 服务都具备缓存的刷新和预热功能(下图是阿里云 CDN 服务提供的相应功能):\n命中率 和 回源率 是衡量 CDN 服务质量两个重要指标。命中率越高越好,回源率越低越好。\n如何找到最合适的 CDN 节点? # GSLB (Global Server Load Balance,全局负载均衡)是 CDN 的大脑,负责多个 CDN 节点之间相互协作,最常用的是基于 DNS 的 GSLB。\nCDN 会通过 GSLB 找到最合适的 CDN 节点,更具体点来说是下面这样的:\n浏览器向 DNS 服务器发送域名请求; DNS 服务器向根据 CNAME( Canonical Name ) 别名记录向 GSLB 发送请求; GSLB 返回性能最好(通常距离请求地址最近)的 CDN 节点(边缘服务器,真正缓存内容的地方)的地址给浏览器; 浏览器直接访问指定的 CDN 节点。 为了方便理解,上图其实做了一点简化。GSLB 内部可以看作是 CDN 专用 DNS 服务器和负载均衡系统组合。CDN 专用 DNS 服务器会返回负载均衡系统 IP 地址给浏览器,浏览器使用 IP 地址请求负载均衡系统进而找到对应的 CDN 节点。\nGSLB 是如何选择出最合适的 CDN 节点呢? GSLB 会根据请求的 IP 地址、CDN 节点状态(比如负载情况、性能、响应时间、带宽)等指标来综合判断具体返回哪一个 CDN 节点的地址。\n如何防止资源被盗刷? # 如果我们的资源被其他用户或者网站非法盗刷的话,将会是一笔不小的开支。\n解决这个问题最常用最简单的办法设置 Referer 防盗链,具体来说就是根据 HTTP 请求的头信息里面的 Referer 字段对请求进行限制。我们可以通过 Referer 字段获取到当前请求页面的来源页面的网站地址,这样我们就能确定请求是否来自合法的网站。\nCDN 服务提供商几乎都提供了这种比较基础的防盗链机制。\n不过,如果站点的防盗链配置允许 Referer 为空的话,通过隐藏 Referer,可以直接绕开防盗链。\n通常情况下,我们会配合其他机制来确保静态资源被盗用,一种常用的机制是 时间戳防盗链 。相比之下,时间戳防盗链 的安全性更强一些。时间戳防盗链加密的 URL 具有时效性,过期之后就无法再被允许访问。\n时间戳防盗链的 URL 通常会有两个参数一个是签名字符串,一个是过期时间。签名字符串一般是通过对用户设定的加密字符串、请求路径、过期时间通过 MD5 哈希算法取哈希的方式获得。\n时间戳防盗链 URL 示例:\nhttp://cdn.wangsu.com/4/123.mp3? wsSecret=79aead3bd7b5db4adeffb93a010298b5\u0026amp;wsTime=1601026312 wsSecret:签名字符串。 wsTime: 过期时间。 时间戳防盗链的实现也比较简单,并且可靠性较高,推荐使用。并且,绝大部分 CDN 服务提供商都提供了开箱即用的时间戳防盗链机制。\n除了 Referer 防盗链和时间戳防盗链之外,你还可以 IP 黑白名单配置、IP 访问限频配置等机制来防盗刷。\n总结 # CDN 就是将静态资源分发到多个不同的地方以实现就近访问,进而加快静态资源的访问速度,减轻服务器以及带宽的负担。 基于成本、稳定性和易用性考虑,建议直接选择专业的云厂商(比如阿里云、腾讯云、华为云、青云)或者 CDN 厂商(比如网宿、蓝汛)提供的开箱即用的 CDN 服务。 GSLB (Global Server Load Balance,全局负载均衡)是 CDN 的大脑,负责多个 CDN 节点之间相互协作,最常用的是基于 DNS 的 GSLB。CDN 会通过 GSLB 找到最合适的 CDN 节点。 为了防止静态资源被盗用,我们可以利用 Referer 防盗链 + 时间戳防盗链 。 参考 # 时间戳防盗链 - 七牛云 CDN: https://developer.qiniu.com/fusion/kb/1670/timestamp-hotlinking-prevention CDN 是个啥玩意?一文说个明白: https://mp.weixin.qq.com/s/Pp0C8ALUXsmYCUkM5QnkQw 《透视 HTTP 协议》- 37 | CDN:加速我们的网络服务: http://gk.link/a/11yOG "},{"id":465,"href":"/zh/docs/technology/Interview/java/concurrent/completablefuture-intro/","title":"CompletableFuture 详解","section":"Concurrent","content":"实际项目中,一个接口可能需要同时获取多种不同的数据,然后再汇总返回,这种场景还是挺常见的。举个例子:用户请求获取订单信息,可能需要同时获取用户信息、商品详情、物流信息、商品推荐等数据。\n如果是串行(按顺序依次执行每个任务)执行的话,接口的响应速度会非常慢。考虑到这些任务之间有大部分都是 无前后顺序关联 的,可以 并行执行 ,就比如说调用获取商品详情的时候,可以同时调用获取物流信息。通过并行执行多个任务的方式,接口的响应速度会得到大幅优化。\n对于存在前后调用顺序关系的任务,可以进行任务编排。\n获取用户信息之后,才能调用商品详情和物流信息接口。 成功获取商品详情和物流信息之后,才能调用商品推荐接口。 可能会用到多线程异步任务编排的场景(这里只是举例,数据不一定是一次返回,可能会对接口进行拆分):\n首页:例如技术社区的首页可能需要同时获取文章推荐列表、广告栏、文章排行榜、热门话题等信息。 详情页:例如技术社区的文章详情页可能需要同时获取作者信息、文章详情、文章评论等信息。 统计模块:例如技术社区的后台统计模块可能需要同时获取粉丝数汇总、文章数据(阅读量、评论量、收藏量)汇总等信息。 对于 Java 程序来说,Java 8 才被引入的 CompletableFuture 可以帮助我们来做多个任务的编排,功能非常强大。\n这篇文章是 CompletableFuture 的简单入门,带大家看看 CompletableFuture 常用的 API。\nFuture 介绍 # Future 类是异步思想的典型运用,主要用在一些需要执行耗时任务的场景,避免程序一直原地等待耗时任务执行完成,执行效率太低。具体来说是这样的:当我们执行某一耗时的任务时,可以将这个耗时任务交给一个子线程去异步执行,同时我们可以干点其他事情,不用傻傻等待耗时任务执行完成。等我们的事情干完后,我们再通过 Future 类获取到耗时任务的执行结果。这样一来,程序的执行效率就明显提高了。\n这其实就是多线程中经典的 Future 模式,你可以将其看作是一种设计模式,核心思想是异步调用,主要用在多线程领域,并非 Java 语言独有。\n在 Java 中,Future 类只是一个泛型接口,位于 java.util.concurrent 包下,其中定义了 5 个方法,主要包括下面这 4 个功能:\n取消任务; 判断任务是否被取消; 判断任务是否已经执行完成; 获取任务执行结果。 // V 代表了Future执行的任务返回值的类型 public interface Future\u0026lt;V\u0026gt; { // 取消任务执行 // 成功取消返回 true,否则返回 false boolean cancel(boolean mayInterruptIfRunning); // 判断任务是否被取消 boolean isCancelled(); // 判断任务是否已经执行完成 boolean isDone(); // 获取任务执行结果 V get() throws InterruptedException, ExecutionException; // 指定时间内没有返回计算结果就抛出 TimeOutException 异常 V get(long timeout, TimeUnit unit) throws InterruptedException, ExecutionException, TimeoutExceptio } 简单理解就是:我有一个任务,提交给了 Future 来处理。任务执行期间我自己可以去做任何想做的事情。并且,在这期间我还可以取消任务以及获取任务的执行状态。一段时间之后,我就可以 Future 那里直接取出任务执行结果。\nCompletableFuture 介绍 # Future 在实际使用过程中存在一些局限性比如不支持异步任务的编排组合、获取计算结果的 get() 方法为阻塞调用。\nJava 8 才被引入CompletableFuture 类可以解决Future 的这些缺陷。CompletableFuture 除了提供了更为好用和强大的 Future 特性之外,还提供了函数式编程、异步任务编排组合(可以将多个异步任务串联起来,组成一个完整的链式调用)等能力。\n下面我们来简单看看 CompletableFuture 类的定义。\npublic class CompletableFuture\u0026lt;T\u0026gt; implements Future\u0026lt;T\u0026gt;, CompletionStage\u0026lt;T\u0026gt; { } 可以看到,CompletableFuture 同时实现了 Future 和 CompletionStage 接口。\nCompletionStage 接口描述了一个异步计算的阶段。很多计算可以分成多个阶段或步骤,此时可以通过它将所有步骤组合起来,形成异步计算的流水线。\nCompletableFuture 除了提供了更为好用和强大的 Future 特性之外,还提供了函数式编程的能力。\nFuture 接口有 5 个方法:\nboolean cancel(boolean mayInterruptIfRunning):尝试取消执行任务。 boolean isCancelled():判断任务是否被取消。 boolean isDone():判断任务是否已经被执行完成。 get():等待任务执行完成并获取运算结果。 get(long timeout, TimeUnit unit):多了一个超时时间。 CompletionStage 接口描述了一个异步计算的阶段。很多计算可以分成多个阶段或步骤,此时可以通过它将所有步骤组合起来,形成异步计算的流水线。\nCompletionStage 接口中的方法比较多,CompletableFuture 的函数式能力就是这个接口赋予的。从这个接口的方法参数你就可以发现其大量使用了 Java8 引入的函数式编程。\n由于方法众多,所以这里不能一一讲解,下文中我会介绍大部分常见方法的使用。\nCompletableFuture 常见操作 # 创建 CompletableFuture # 常见的创建 CompletableFuture 对象的方法如下:\n通过 new 关键字。 基于 CompletableFuture 自带的静态工厂方法:runAsync()、supplyAsync() 。 new 关键字 # 通过 new 关键字创建 CompletableFuture 对象这种使用方式可以看作是将 CompletableFuture 当做 Future 来使用。\n我在我的开源项目 guide-rpc-framework 中就是这种方式创建的 CompletableFuture 对象。\n下面咱们来看一个简单的案例。\n我们通过创建了一个结果值类型为 RpcResponse\u0026lt;Object\u0026gt; 的 CompletableFuture,你可以把 resultFuture 看作是异步运算结果的载体。\nCompletableFuture\u0026lt;RpcResponse\u0026lt;Object\u0026gt;\u0026gt; resultFuture = new CompletableFuture\u0026lt;\u0026gt;(); 假设在未来的某个时刻,我们得到了最终的结果。这时,我们可以调用 complete() 方法为其传入结果,这表示 resultFuture 已经被完成了。\n// complete() 方法只能调用一次,后续调用将被忽略。 resultFuture.complete(rpcResponse); 你可以通过 isDone() 方法来检查是否已经完成。\npublic boolean isDone() { return result != null; } 获取异步计算的结果也非常简单,直接调用 get() 方法即可。调用 get() 方法的线程会阻塞直到 CompletableFuture 完成运算。\nrpcResponse = completableFuture.get(); 如果你已经知道计算的结果的话,可以使用静态方法 completedFuture() 来创建 CompletableFuture 。\nCompletableFuture\u0026lt;String\u0026gt; future = CompletableFuture.completedFuture(\u0026#34;hello!\u0026#34;); assertEquals(\u0026#34;hello!\u0026#34;, future.get()); completedFuture() 方法底层调用的是带参数的 new 方法,只不过,这个方法不对外暴露。\npublic static \u0026lt;U\u0026gt; CompletableFuture\u0026lt;U\u0026gt; completedFuture(U value) { return new CompletableFuture\u0026lt;U\u0026gt;((value == null) ? NIL : value); } 静态工厂方法 # 这两个方法可以帮助我们封装计算逻辑。\nstatic \u0026lt;U\u0026gt; CompletableFuture\u0026lt;U\u0026gt; supplyAsync(Supplier\u0026lt;U\u0026gt; supplier); // 使用自定义线程池(推荐) static \u0026lt;U\u0026gt; CompletableFuture\u0026lt;U\u0026gt; supplyAsync(Supplier\u0026lt;U\u0026gt; supplier, Executor executor); static CompletableFuture\u0026lt;Void\u0026gt; runAsync(Runnable runnable); // 使用自定义线程池(推荐) static CompletableFuture\u0026lt;Void\u0026gt; runAsync(Runnable runnable, Executor executor); runAsync() 方法接受的参数是 Runnable ,这是一个函数式接口,不允许返回值。当你需要异步操作且不关心返回结果的时候可以使用 runAsync() 方法。\n@FunctionalInterface public interface Runnable { public abstract void run(); } supplyAsync() 方法接受的参数是 Supplier\u0026lt;U\u0026gt; ,这也是一个函数式接口,U 是返回结果值的类型。\n@FunctionalInterface public interface Supplier\u0026lt;T\u0026gt; { /** * Gets a result. * * @return a result */ T get(); } 当你需要异步操作且关心返回结果的时候,可以使用 supplyAsync() 方法。\nCompletableFuture\u0026lt;Void\u0026gt; future = CompletableFuture.runAsync(() -\u0026gt; System.out.println(\u0026#34;hello!\u0026#34;)); future.get();// 输出 \u0026#34;hello!\u0026#34; CompletableFuture\u0026lt;String\u0026gt; future2 = CompletableFuture.supplyAsync(() -\u0026gt; \u0026#34;hello!\u0026#34;); assertEquals(\u0026#34;hello!\u0026#34;, future2.get()); 处理异步结算的结果 # 当我们获取到异步计算的结果之后,还可以对其进行进一步的处理,比较常用的方法有下面几个:\nthenApply() thenAccept() thenRun() whenComplete() thenApply() 方法接受一个 Function 实例,用它来处理结果。\n// 沿用上一个任务的线程池 public \u0026lt;U\u0026gt; CompletableFuture\u0026lt;U\u0026gt; thenApply( Function\u0026lt;? super T,? extends U\u0026gt; fn) { return uniApplyStage(null, fn); } //使用默认的 ForkJoinPool 线程池(不推荐) public \u0026lt;U\u0026gt; CompletableFuture\u0026lt;U\u0026gt; thenApplyAsync( Function\u0026lt;? super T,? extends U\u0026gt; fn) { return uniApplyStage(defaultExecutor(), fn); } // 使用自定义线程池(推荐) public \u0026lt;U\u0026gt; CompletableFuture\u0026lt;U\u0026gt; thenApplyAsync( Function\u0026lt;? super T,? extends U\u0026gt; fn, Executor executor) { return uniApplyStage(screenExecutor(executor), fn); } thenApply() 方法使用示例如下:\nCompletableFuture\u0026lt;String\u0026gt; future = CompletableFuture.completedFuture(\u0026#34;hello!\u0026#34;) .thenApply(s -\u0026gt; s + \u0026#34;world!\u0026#34;); assertEquals(\u0026#34;hello!world!\u0026#34;, future.get()); // 这次调用将被忽略。 future.thenApply(s -\u0026gt; s + \u0026#34;nice!\u0026#34;); assertEquals(\u0026#34;hello!world!\u0026#34;, future.get()); 你还可以进行 流式调用:\nCompletableFuture\u0026lt;String\u0026gt; future = CompletableFuture.completedFuture(\u0026#34;hello!\u0026#34;) .thenApply(s -\u0026gt; s + \u0026#34;world!\u0026#34;).thenApply(s -\u0026gt; s + \u0026#34;nice!\u0026#34;); assertEquals(\u0026#34;hello!world!nice!\u0026#34;, future.get()); 如果你不需要从回调函数中获取返回结果,可以使用 thenAccept() 或者 thenRun()。这两个方法的区别在于 thenRun() 不能访问异步计算的结果。\nthenAccept() 方法的参数是 Consumer\u0026lt;? super T\u0026gt; 。\npublic CompletableFuture\u0026lt;Void\u0026gt; thenAccept(Consumer\u0026lt;? super T\u0026gt; action) { return uniAcceptStage(null, action); } public CompletableFuture\u0026lt;Void\u0026gt; thenAcceptAsync(Consumer\u0026lt;? super T\u0026gt; action) { return uniAcceptStage(defaultExecutor(), action); } public CompletableFuture\u0026lt;Void\u0026gt; thenAcceptAsync(Consumer\u0026lt;? super T\u0026gt; action, Executor executor) { return uniAcceptStage(screenExecutor(executor), action); } 顾名思义,Consumer 属于消费型接口,它可以接收 1 个输入对象然后进行“消费”。\n@FunctionalInterface public interface Consumer\u0026lt;T\u0026gt; { void accept(T t); default Consumer\u0026lt;T\u0026gt; andThen(Consumer\u0026lt;? super T\u0026gt; after) { Objects.requireNonNull(after); return (T t) -\u0026gt; { accept(t); after.accept(t); }; } } thenRun() 的方法是的参数是 Runnable 。\npublic CompletableFuture\u0026lt;Void\u0026gt; thenRun(Runnable action) { return uniRunStage(null, action); } public CompletableFuture\u0026lt;Void\u0026gt; thenRunAsync(Runnable action) { return uniRunStage(defaultExecutor(), action); } public CompletableFuture\u0026lt;Void\u0026gt; thenRunAsync(Runnable action, Executor executor) { return uniRunStage(screenExecutor(executor), action); } thenAccept() 和 thenRun() 使用示例如下:\nCompletableFuture.completedFuture(\u0026#34;hello!\u0026#34;) .thenApply(s -\u0026gt; s + \u0026#34;world!\u0026#34;).thenApply(s -\u0026gt; s + \u0026#34;nice!\u0026#34;).thenAccept(System.out::println);//hello!world!nice! CompletableFuture.completedFuture(\u0026#34;hello!\u0026#34;) .thenApply(s -\u0026gt; s + \u0026#34;world!\u0026#34;).thenApply(s -\u0026gt; s + \u0026#34;nice!\u0026#34;).thenRun(() -\u0026gt; System.out.println(\u0026#34;hello!\u0026#34;));//hello! whenComplete() 的方法的参数是 BiConsumer\u0026lt;? super T, ? super Throwable\u0026gt; 。\npublic CompletableFuture\u0026lt;T\u0026gt; whenComplete( BiConsumer\u0026lt;? super T, ? super Throwable\u0026gt; action) { return uniWhenCompleteStage(null, action); } public CompletableFuture\u0026lt;T\u0026gt; whenCompleteAsync( BiConsumer\u0026lt;? super T, ? super Throwable\u0026gt; action) { return uniWhenCompleteStage(defaultExecutor(), action); } // 使用自定义线程池(推荐) public CompletableFuture\u0026lt;T\u0026gt; whenCompleteAsync( BiConsumer\u0026lt;? super T, ? super Throwable\u0026gt; action, Executor executor) { return uniWhenCompleteStage(screenExecutor(executor), action); } 相对于 Consumer , BiConsumer 可以接收 2 个输入对象然后进行“消费”。\n@FunctionalInterface public interface BiConsumer\u0026lt;T, U\u0026gt; { void accept(T t, U u); default BiConsumer\u0026lt;T, U\u0026gt; andThen(BiConsumer\u0026lt;? super T, ? super U\u0026gt; after) { Objects.requireNonNull(after); return (l, r) -\u0026gt; { accept(l, r); after.accept(l, r); }; } } whenComplete() 使用示例如下:\nCompletableFuture\u0026lt;String\u0026gt; future = CompletableFuture.supplyAsync(() -\u0026gt; \u0026#34;hello!\u0026#34;) .whenComplete((res, ex) -\u0026gt; { // res 代表返回的结果 // ex 的类型为 Throwable ,代表抛出的异常 System.out.println(res); // 这里没有抛出异常所有为 null assertNull(ex); }); assertEquals(\u0026#34;hello!\u0026#34;, future.get()); 异常处理 # 你可以通过 handle() 方法来处理任务执行过程中可能出现的抛出异常的情况。\npublic \u0026lt;U\u0026gt; CompletableFuture\u0026lt;U\u0026gt; handle( BiFunction\u0026lt;? super T, Throwable, ? extends U\u0026gt; fn) { return uniHandleStage(null, fn); } public \u0026lt;U\u0026gt; CompletableFuture\u0026lt;U\u0026gt; handleAsync( BiFunction\u0026lt;? super T, Throwable, ? extends U\u0026gt; fn) { return uniHandleStage(defaultExecutor(), fn); } public \u0026lt;U\u0026gt; CompletableFuture\u0026lt;U\u0026gt; handleAsync( BiFunction\u0026lt;? super T, Throwable, ? extends U\u0026gt; fn, Executor executor) { return uniHandleStage(screenExecutor(executor), fn); } 示例代码如下:\nCompletableFuture\u0026lt;String\u0026gt; future = CompletableFuture.supplyAsync(() -\u0026gt; { if (true) { throw new RuntimeException(\u0026#34;Computation error!\u0026#34;); } return \u0026#34;hello!\u0026#34;; }).handle((res, ex) -\u0026gt; { // res 代表返回的结果 // ex 的类型为 Throwable ,代表抛出的异常 return res != null ? res : \u0026#34;world!\u0026#34;; }); assertEquals(\u0026#34;world!\u0026#34;, future.get()); 你还可以通过 exceptionally() 方法来处理异常情况。\nCompletableFuture\u0026lt;String\u0026gt; future = CompletableFuture.supplyAsync(() -\u0026gt; { if (true) { throw new RuntimeException(\u0026#34;Computation error!\u0026#34;); } return \u0026#34;hello!\u0026#34;; }).exceptionally(ex -\u0026gt; { System.out.println(ex.toString());// CompletionException return \u0026#34;world!\u0026#34;; }); assertEquals(\u0026#34;world!\u0026#34;, future.get()); 如果你想让 CompletableFuture 的结果就是异常的话,可以使用 completeExceptionally() 方法为其赋值。\nCompletableFuture\u0026lt;String\u0026gt; completableFuture = new CompletableFuture\u0026lt;\u0026gt;(); // ... completableFuture.completeExceptionally( new RuntimeException(\u0026#34;Calculation failed!\u0026#34;)); // ... completableFuture.get(); // ExecutionException 组合 CompletableFuture # 你可以使用 thenCompose() 按顺序链接两个 CompletableFuture 对象,实现异步的任务链。它的作用是将前一个任务的返回结果作为下一个任务的输入参数,从而形成一个依赖关系。\npublic \u0026lt;U\u0026gt; CompletableFuture\u0026lt;U\u0026gt; thenCompose( Function\u0026lt;? super T, ? extends CompletionStage\u0026lt;U\u0026gt;\u0026gt; fn) { return uniComposeStage(null, fn); } public \u0026lt;U\u0026gt; CompletableFuture\u0026lt;U\u0026gt; thenComposeAsync( Function\u0026lt;? super T, ? extends CompletionStage\u0026lt;U\u0026gt;\u0026gt; fn) { return uniComposeStage(defaultExecutor(), fn); } public \u0026lt;U\u0026gt; CompletableFuture\u0026lt;U\u0026gt; thenComposeAsync( Function\u0026lt;? super T, ? extends CompletionStage\u0026lt;U\u0026gt;\u0026gt; fn, Executor executor) { return uniComposeStage(screenExecutor(executor), fn); } thenCompose() 方法会使用示例如下:\nCompletableFuture\u0026lt;String\u0026gt; future = CompletableFuture.supplyAsync(() -\u0026gt; \u0026#34;hello!\u0026#34;) .thenCompose(s -\u0026gt; CompletableFuture.supplyAsync(() -\u0026gt; s + \u0026#34;world!\u0026#34;)); assertEquals(\u0026#34;hello!world!\u0026#34;, future.get()); 在实际开发中,这个方法还是非常有用的。比如说,task1 和 task2 都是异步执行的,但 task1 必须执行完成后才能开始执行 task2(task2 依赖 task1 的执行结果)。\n和 thenCompose() 方法类似的还有 thenCombine() 方法, 它同样可以组合两个 CompletableFuture 对象。\nCompletableFuture\u0026lt;String\u0026gt; completableFuture = CompletableFuture.supplyAsync(() -\u0026gt; \u0026#34;hello!\u0026#34;) .thenCombine(CompletableFuture.supplyAsync( () -\u0026gt; \u0026#34;world!\u0026#34;), (s1, s2) -\u0026gt; s1 + s2) .thenCompose(s -\u0026gt; CompletableFuture.supplyAsync(() -\u0026gt; s + \u0026#34;nice!\u0026#34;)); assertEquals(\u0026#34;hello!world!nice!\u0026#34;, completableFuture.get()); 那 thenCompose() 和 thenCombine() 有什么区别呢?\nthenCompose() 可以链接两个 CompletableFuture 对象,并将前一个任务的返回结果作为下一个任务的参数,它们之间存在着先后顺序。 thenCombine() 会在两个任务都执行完成后,把两个任务的结果合并。两个任务是并行执行的,它们之间并没有先后依赖顺序。 除了 thenCompose() 和 thenCombine() 之外, 还有一些其他的组合 CompletableFuture 的方法用于实现不同的效果,满足不同的业务需求。\n例如,如果我们想要实现 task1 和 task2 中的任意一个任务执行完后就执行 task3 的话,可以使用 acceptEither()。\npublic CompletableFuture\u0026lt;Void\u0026gt; acceptEither( CompletionStage\u0026lt;? extends T\u0026gt; other, Consumer\u0026lt;? super T\u0026gt; action) { return orAcceptStage(null, other, action); } public CompletableFuture\u0026lt;Void\u0026gt; acceptEitherAsync( CompletionStage\u0026lt;? extends T\u0026gt; other, Consumer\u0026lt;? super T\u0026gt; action) { return orAcceptStage(asyncPool, other, action); } 简单举一个例子:\nCompletableFuture\u0026lt;String\u0026gt; task = CompletableFuture.supplyAsync(() -\u0026gt; { System.out.println(\u0026#34;任务1开始执行,当前时间:\u0026#34; + System.currentTimeMillis()); try { Thread.sleep(500); } catch (InterruptedException e) { e.printStackTrace(); } System.out.println(\u0026#34;任务1执行完毕,当前时间:\u0026#34; + System.currentTimeMillis()); return \u0026#34;task1\u0026#34;; }); CompletableFuture\u0026lt;String\u0026gt; task2 = CompletableFuture.supplyAsync(() -\u0026gt; { System.out.println(\u0026#34;任务2开始执行,当前时间:\u0026#34; + System.currentTimeMillis()); try { Thread.sleep(1000); } catch (InterruptedException e) { e.printStackTrace(); } System.out.println(\u0026#34;任务2执行完毕,当前时间:\u0026#34; + System.currentTimeMillis()); return \u0026#34;task2\u0026#34;; }); task.acceptEitherAsync(task2, (res) -\u0026gt; { System.out.println(\u0026#34;任务3开始执行,当前时间:\u0026#34; + System.currentTimeMillis()); System.out.println(\u0026#34;上一个任务的结果为:\u0026#34; + res); }); // 增加一些延迟时间,确保异步任务有足够的时间完成 try { Thread.sleep(2000); } catch (InterruptedException e) { e.printStackTrace(); } 输出:\n任务1开始执行,当前时间:1695088058520 任务2开始执行,当前时间:1695088058521 任务1执行完毕,当前时间:1695088059023 任务3开始执行,当前时间:1695088059023 上一个任务的结果为:task1 任务2执行完毕,当前时间:1695088059523 任务组合操作acceptEitherAsync()会在异步任务 1 和异步任务 2 中的任意一个完成时触发执行任务 3,但是需要注意,这个触发时机是不确定的。如果任务 1 和任务 2 都还未完成,那么任务 3 就不能被执行。\n并行运行多个 CompletableFuture # 你可以通过 CompletableFuture 的 allOf()这个静态方法来并行运行多个 CompletableFuture 。\n实际项目中,我们经常需要并行运行多个互不相关的任务,这些任务之间没有依赖关系,可以互相独立地运行。\n比说我们要读取处理 6 个文件,这 6 个任务都是没有执行顺序依赖的任务,但是我们需要返回给用户的时候将这几个文件的处理的结果进行统计整理。像这种情况我们就可以使用并行运行多个 CompletableFuture 来处理。\n示例代码如下:\nCompletableFuture\u0026lt;Void\u0026gt; task1 = CompletableFuture.supplyAsync(()-\u0026gt;{ //自定义业务操作 }); ...... CompletableFuture\u0026lt;Void\u0026gt; task6 = CompletableFuture.supplyAsync(()-\u0026gt;{ //自定义业务操作 }); ...... CompletableFuture\u0026lt;Void\u0026gt; headerFuture=CompletableFuture.allOf(task1,.....,task6); try { headerFuture.join(); } catch (Exception ex) { ...... } System.out.println(\u0026#34;all done. \u0026#34;); 经常和 allOf() 方法拿来对比的是 anyOf() 方法。\nallOf() 方法会等到所有的 CompletableFuture 都运行完成之后再返回\nRandom rand = new Random(); CompletableFuture\u0026lt;String\u0026gt; future1 = CompletableFuture.supplyAsync(() -\u0026gt; { try { Thread.sleep(1000 + rand.nextInt(1000)); } catch (InterruptedException e) { e.printStackTrace(); } finally { System.out.println(\u0026#34;future1 done...\u0026#34;); } return \u0026#34;abc\u0026#34;; }); CompletableFuture\u0026lt;String\u0026gt; future2 = CompletableFuture.supplyAsync(() -\u0026gt; { try { Thread.sleep(1000 + rand.nextInt(1000)); } catch (InterruptedException e) { e.printStackTrace(); } finally { System.out.println(\u0026#34;future2 done...\u0026#34;); } return \u0026#34;efg\u0026#34;; }); 调用 join() 可以让程序等future1 和 future2 都运行完了之后再继续执行。\nCompletableFuture\u0026lt;Void\u0026gt; completableFuture = CompletableFuture.allOf(future1, future2); completableFuture.join(); assertTrue(completableFuture.isDone()); System.out.println(\u0026#34;all futures done...\u0026#34;); 输出:\nfuture1 done... future2 done... all futures done... anyOf() 方法不会等待所有的 CompletableFuture 都运行完成之后再返回,只要有一个执行完成即可!\nCompletableFuture\u0026lt;Object\u0026gt; f = CompletableFuture.anyOf(future1, future2); System.out.println(f.get()); 输出结果可能是:\nfuture2 done... efg 也可能是:\nfuture1 done... abc CompletableFuture 使用建议 # 使用自定义线程池 # 我们上面的代码示例中,为了方便,都没有选择自定义线程池。实际项目中,这是不可取的。\nCompletableFuture 默认使用全局共享的 ForkJoinPool.commonPool() 作为执行器,所有未指定执行器的异步任务都会使用该线程池。这意味着应用程序、多个库或框架(如 Spring、第三方库)若都依赖 CompletableFuture,默认情况下它们都会共享同一个线程池。\n虽然 ForkJoinPool 效率很高,但当同时提交大量任务时,可能会导致资源竞争和线程饥饿,进而影响系统性能。\n为避免这些问题,建议为 CompletableFuture 提供自定义线程池,带来以下优势:\n隔离性:为不同任务分配独立的线程池,避免全局线程池资源争夺。 资源控制:根据任务特性调整线程池大小和队列类型,优化性能表现。 异常处理:通过自定义 ThreadFactory 更好地处理线程中的异常情况。 private ThreadPoolExecutor executor = new ThreadPoolExecutor(10, 10, 0L, TimeUnit.MILLISECONDS, new LinkedBlockingQueue\u0026lt;Runnable\u0026gt;()); CompletableFuture.runAsync(() -\u0026gt; { //... }, executor); 尽量避免使用 get() # CompletableFuture的get()方法是阻塞的,尽量避免使用。如果必须要使用的话,需要添加超时时间,否则可能会导致主线程一直等待,无法执行其他任务。\nCompletableFuture\u0026lt;String\u0026gt; future = CompletableFuture.supplyAsync(() -\u0026gt; { try { Thread.sleep(10_000); } catch (InterruptedException e) { e.printStackTrace(); } return \u0026#34;Hello, world!\u0026#34;; }); // 获取异步任务的返回值,设置超时时间为 5 秒 try { String result = future.get(5, TimeUnit.SECONDS); System.out.println(result); } catch (InterruptedException | ExecutionException | TimeoutException e) { // 处理异常 e.printStackTrace(); } } 上面这段代码在调用 get() 时抛出了 TimeoutException 异常。这样我们就可以在异常处理中进行相应的操作,比如取消任务、重试任务、记录日志等。\n正确进行异常处理 # 使用 CompletableFuture的时候一定要以正确的方式进行异常处理,避免异常丢失或者出现不可控问题。\n下面是一些建议:\n使用 whenComplete 方法可以在任务完成时触发回调函数,并正确地处理异常,而不是让异常被吞噬或丢失。 使用 exceptionally 方法可以处理异常并重新抛出,以便异常能够传播到后续阶段,而不是让异常被忽略或终止。 使用 handle 方法可以处理正常的返回结果和异常,并返回一个新的结果,而不是让异常影响正常的业务逻辑。 使用 CompletableFuture.allOf 方法可以组合多个 CompletableFuture,并统一处理所有任务的异常,而不是让异常处理过于冗长或重复。 …… 合理组合多个异步任务 # 正确使用 thenCompose() 、 thenCombine() 、acceptEither()、allOf()、anyOf()等方法来组合多个异步任务,以满足实际业务的需求,提高程序执行效率。\n实际使用中,我们还可以利用或者参考现成的异步任务编排框架,比如京东的 asyncTool 。\n后记 # 这篇文章只是简单介绍了 CompletableFuture 的核心概念和比较常用的一些 API 。如果想要深入学习的话,还可以多找一些书籍和博客看,比如下面几篇文章就挺不错:\nCompletableFuture 原理与实践-外卖商家端 API 的异步化 - 美团技术团队:这篇文章详细介绍了 CompletableFuture 在实际项目中的运用。参考这篇文章,可以对项目中类似的场景进行优化,也算是一个小亮点了。这种性能优化方式比较简单且效果还不错! 读 RocketMQ 源码,学习并发编程三大神器 - 勇哥 java 实战分享:这篇文章介绍了 RocketMQ 对CompletableFuture的应用。具体来说,从 RocketMQ 4.7 开始,RocketMQ 引入了 CompletableFuture来实现异步消息处理 。 另外,建议 G 友们可以看看京东的 asyncTool 这个并发框架,里面大量使用到了 CompletableFuture 。\n"},{"id":466,"href":"/zh/docs/technology/Interview/java/collection/concurrent-hash-map-source-code/","title":"ConcurrentHashMap 源码分析","section":"Collection","content":" 本文来自公众号:末读代码的投稿,原文地址: https://mp.weixin.qq.com/s/AHWzboztt53ZfFZmsSnMSw 。\n上一篇文章介绍了 HashMap 源码,反响不错,也有很多同学发表了自己的观点,这次又来了,这次是 ConcurrentHashMap 了,作为线程安全的 HashMap ,它的使用频率也是很高。那么它的存储结构和实现原理是怎么样的呢?\n1. ConcurrentHashMap 1.7 # 1. 存储结构 # Java 7 中 ConcurrentHashMap 的存储结构如上图,ConcurrnetHashMap 由很多个 Segment 组合,而每一个 Segment 是一个类似于 HashMap 的结构,所以每一个 HashMap 的内部可以进行扩容。但是 Segment 的个数一旦初始化就不能改变,默认 Segment 的个数是 16 个,你也可以认为 ConcurrentHashMap 默认支持最多 16 个线程并发。\n2. 初始化 # 通过 ConcurrentHashMap 的无参构造探寻 ConcurrentHashMap 的初始化流程。\n/** * Creates a new, empty map with a default initial capacity (16), * load factor (0.75) and concurrencyLevel (16). */ public ConcurrentHashMap() { this(DEFAULT_INITIAL_CAPACITY, DEFAULT_LOAD_FACTOR, DEFAULT_CONCURRENCY_LEVEL); } 无参构造中调用了有参构造,传入了三个参数的默认值,他们的值是。\n/** * 默认初始化容量 */ static final int DEFAULT_INITIAL_CAPACITY = 16; /** * 默认负载因子 */ static final float DEFAULT_LOAD_FACTOR = 0.75f; /** * 默认并发级别 */ static final int DEFAULT_CONCURRENCY_LEVEL = 16; 接着看下这个有参构造函数的内部实现逻辑。\n@SuppressWarnings(\u0026#34;unchecked\u0026#34;) public ConcurrentHashMap(int initialCapacity,float loadFactor, int concurrencyLevel) { // 参数校验 if (!(loadFactor \u0026gt; 0) || initialCapacity \u0026lt; 0 || concurrencyLevel \u0026lt;= 0) throw new IllegalArgumentException(); // 校验并发级别大小,大于 1\u0026lt;\u0026lt;16,重置为 65536 if (concurrencyLevel \u0026gt; MAX_SEGMENTS) concurrencyLevel = MAX_SEGMENTS; // Find power-of-two sizes best matching arguments // 2的多少次方 int sshift = 0; int ssize = 1; // 这个循环可以找到 concurrencyLevel 之上最近的 2的次方值 while (ssize \u0026lt; concurrencyLevel) { ++sshift; ssize \u0026lt;\u0026lt;= 1; } // 记录段偏移量 this.segmentShift = 32 - sshift; // 记录段掩码 this.segmentMask = ssize - 1; // 设置容量 if (initialCapacity \u0026gt; MAXIMUM_CAPACITY) initialCapacity = MAXIMUM_CAPACITY; // c = 容量 / ssize ,默认 16 / 16 = 1,这里是计算每个 Segment 中的类似于 HashMap 的容量 int c = initialCapacity / ssize; if (c * ssize \u0026lt; initialCapacity) ++c; int cap = MIN_SEGMENT_TABLE_CAPACITY; //Segment 中的类似于 HashMap 的容量至少是2或者2的倍数 while (cap \u0026lt; c) cap \u0026lt;\u0026lt;= 1; // create segments and segments[0] // 创建 Segment 数组,设置 segments[0] Segment\u0026lt;K,V\u0026gt; s0 = new Segment\u0026lt;K,V\u0026gt;(loadFactor, (int)(cap * loadFactor), (HashEntry\u0026lt;K,V\u0026gt;[])new HashEntry[cap]); Segment\u0026lt;K,V\u0026gt;[] ss = (Segment\u0026lt;K,V\u0026gt;[])new Segment[ssize]; UNSAFE.putOrderedObject(ss, SBASE, s0); // ordered write of segments[0] this.segments = ss; } 总结一下在 Java 7 中 ConcurrentHashMap 的初始化逻辑。\n必要参数校验。 校验并发级别 concurrencyLevel 大小,如果大于最大值,重置为最大值。无参构造默认值是 16. 寻找并发级别 concurrencyLevel 之上最近的 2 的幂次方值,作为初始化容量大小,默认是 16。 记录 segmentShift 偏移量,这个值为【容量 = 2 的 N 次方】中的 N,在后面 Put 时计算位置时会用到。默认是 32 - sshift = 28. 记录 segmentMask,默认是 ssize - 1 = 16 -1 = 15. 初始化 segments[0],默认大小为 2,负载因子 0.75,扩容阀值是 2*0.75=1.5,插入第二个值时才会进行扩容。 3. put # 接着上面的初始化参数继续查看 put 方法源码。\n/** * Maps the specified key to the specified value in this table. * Neither the key nor the value can be null. * * \u0026lt;p\u0026gt; The value can be retrieved by calling the \u0026lt;tt\u0026gt;get\u0026lt;/tt\u0026gt; method * with a key that is equal to the original key. * * @param key key with which the specified value is to be associated * @param value value to be associated with the specified key * @return the previous value associated with \u0026lt;tt\u0026gt;key\u0026lt;/tt\u0026gt;, or * \u0026lt;tt\u0026gt;null\u0026lt;/tt\u0026gt; if there was no mapping for \u0026lt;tt\u0026gt;key\u0026lt;/tt\u0026gt; * @throws NullPointerException if the specified key or value is null */ public V put(K key, V value) { Segment\u0026lt;K,V\u0026gt; s; if (value == null) throw new NullPointerException(); int hash = hash(key); // hash 值无符号右移 28位(初始化时获得),然后与 segmentMask=15 做与运算 // 其实也就是把高4位与segmentMask(1111)做与运算 int j = (hash \u0026gt;\u0026gt;\u0026gt; segmentShift) \u0026amp; segmentMask; if ((s = (Segment\u0026lt;K,V\u0026gt;)UNSAFE.getObject // nonvolatile; recheck (segments, (j \u0026lt;\u0026lt; SSHIFT) + SBASE)) == null) // in ensureSegment // 如果查找到的 Segment 为空,初始化 s = ensureSegment(j); return s.put(key, hash, value, false); } /** * Returns the segment for the given index, creating it and * recording in segment table (via CAS) if not already present. * * @param k the index * @return the segment */ @SuppressWarnings(\u0026#34;unchecked\u0026#34;) private Segment\u0026lt;K,V\u0026gt; ensureSegment(int k) { final Segment\u0026lt;K,V\u0026gt;[] ss = this.segments; long u = (k \u0026lt;\u0026lt; SSHIFT) + SBASE; // raw offset Segment\u0026lt;K,V\u0026gt; seg; // 判断 u 位置的 Segment 是否为null if ((seg = (Segment\u0026lt;K,V\u0026gt;)UNSAFE.getObjectVolatile(ss, u)) == null) { Segment\u0026lt;K,V\u0026gt; proto = ss[0]; // use segment 0 as prototype // 获取0号 segment 里的 HashEntry\u0026lt;K,V\u0026gt; 初始化长度 int cap = proto.table.length; // 获取0号 segment 里的 hash 表里的扩容负载因子,所有的 segment 的 loadFactor 是相同的 float lf = proto.loadFactor; // 计算扩容阀值 int threshold = (int)(cap * lf); // 创建一个 cap 容量的 HashEntry 数组 HashEntry\u0026lt;K,V\u0026gt;[] tab = (HashEntry\u0026lt;K,V\u0026gt;[])new HashEntry[cap]; if ((seg = (Segment\u0026lt;K,V\u0026gt;)UNSAFE.getObjectVolatile(ss, u)) == null) { // recheck // 再次检查 u 位置的 Segment 是否为null,因为这时可能有其他线程进行了操作 Segment\u0026lt;K,V\u0026gt; s = new Segment\u0026lt;K,V\u0026gt;(lf, threshold, tab); // 自旋检查 u 位置的 Segment 是否为null while ((seg = (Segment\u0026lt;K,V\u0026gt;)UNSAFE.getObjectVolatile(ss, u)) == null) { // 使用CAS 赋值,只会成功一次 if (UNSAFE.compareAndSwapObject(ss, u, null, seg = s)) break; } } } return seg; } 上面的源码分析了 ConcurrentHashMap 在 put 一个数据时的处理流程,下面梳理下具体流程。\n计算要 put 的 key 的位置,获取指定位置的 Segment。\n如果指定位置的 Segment 为空,则初始化这个 Segment.\n初始化 Segment 流程:\n检查计算得到的位置的 Segment 是否为 null. 为 null 继续初始化,使用 Segment[0] 的容量和负载因子创建一个 HashEntry 数组。 再次检查计算得到的指定位置的 Segment 是否为 null. 使用创建的 HashEntry 数组初始化这个 Segment. 自旋判断计算得到的指定位置的 Segment 是否为 null,使用 CAS 在这个位置赋值为 Segment. Segment.put 插入 key,value 值。\n上面探究了获取 Segment 段和初始化 Segment 段的操作。最后一行的 Segment 的 put 方法还没有查看,继续分析。\nfinal V put(K key, int hash, V value, boolean onlyIfAbsent) { // 获取 ReentrantLock 独占锁,获取不到,scanAndLockForPut 获取。 HashEntry\u0026lt;K,V\u0026gt; node = tryLock() ? null : scanAndLockForPut(key, hash, value); V oldValue; try { HashEntry\u0026lt;K,V\u0026gt;[] tab = table; // 计算要put的数据位置 int index = (tab.length - 1) \u0026amp; hash; // CAS 获取 index 坐标的值 HashEntry\u0026lt;K,V\u0026gt; first = entryAt(tab, index); for (HashEntry\u0026lt;K,V\u0026gt; e = first;;) { if (e != null) { // 检查是否 key 已经存在,如果存在,则遍历链表寻找位置,找到后替换 value K k; if ((k = e.key) == key || (e.hash == hash \u0026amp;\u0026amp; key.equals(k))) { oldValue = e.value; if (!onlyIfAbsent) { e.value = value; ++modCount; } break; } e = e.next; } else { // first 有值没说明 index 位置已经有值了,有冲突,链表头插法。 if (node != null) node.setNext(first); else node = new HashEntry\u0026lt;K,V\u0026gt;(hash, key, value, first); int c = count + 1; // 容量大于扩容阀值,小于最大容量,进行扩容 if (c \u0026gt; threshold \u0026amp;\u0026amp; tab.length \u0026lt; MAXIMUM_CAPACITY) rehash(node); else // index 位置赋值 node,node 可能是一个元素,也可能是一个链表的表头 setEntryAt(tab, index, node); ++modCount; count = c; oldValue = null; break; } } } finally { unlock(); } return oldValue; } 由于 Segment 继承了 ReentrantLock,所以 Segment 内部可以很方便的获取锁,put 流程就用到了这个功能。\ntryLock() 获取锁,获取不到使用 scanAndLockForPut 方法继续获取。\n计算 put 的数据要放入的 index 位置,然后获取这个位置上的 HashEntry 。\n遍历 put 新元素,为什么要遍历?因为这里获取的 HashEntry 可能是一个空元素,也可能是链表已存在,所以要区别对待。\n如果这个位置上的 HashEntry 不存在:\n如果当前容量大于扩容阀值,小于最大容量,进行扩容。 直接头插法插入。 如果这个位置上的 HashEntry 存在:\n判断链表当前元素 key 和 hash 值是否和要 put 的 key 和 hash 值一致。一致则替换值 不一致,获取链表下一个节点,直到发现相同进行值替换,或者链表表里完毕没有相同的。 如果当前容量大于扩容阀值,小于最大容量,进行扩容。 直接链表头插法插入。 如果要插入的位置之前已经存在,替换后返回旧值,否则返回 null.\n这里面的第一步中的 scanAndLockForPut 操作这里没有介绍,这个方法做的操作就是不断的自旋 tryLock() 获取锁。当自旋次数大于指定次数时,使用 lock() 阻塞获取锁。在自旋时顺表获取下 hash 位置的 HashEntry。\nprivate HashEntry\u0026lt;K,V\u0026gt; scanAndLockForPut(K key, int hash, V value) { HashEntry\u0026lt;K,V\u0026gt; first = entryForHash(this, hash); HashEntry\u0026lt;K,V\u0026gt; e = first; HashEntry\u0026lt;K,V\u0026gt; node = null; int retries = -1; // negative while locating node // 自旋获取锁 while (!tryLock()) { HashEntry\u0026lt;K,V\u0026gt; f; // to recheck first below if (retries \u0026lt; 0) { if (e == null) { if (node == null) // speculatively create node node = new HashEntry\u0026lt;K,V\u0026gt;(hash, key, value, null); retries = 0; } else if (key.equals(e.key)) retries = 0; else e = e.next; } else if (++retries \u0026gt; MAX_SCAN_RETRIES) { // 自旋达到指定次数后,阻塞等到只到获取到锁 lock(); break; } else if ((retries \u0026amp; 1) == 0 \u0026amp;\u0026amp; (f = entryForHash(this, hash)) != first) { e = first = f; // re-traverse if entry changed retries = -1; } } return node; } 4. 扩容 rehash # ConcurrentHashMap 的扩容只会扩容到原来的两倍。老数组里的数据移动到新的数组时,位置要么不变,要么变为 index+ oldSize,参数里的 node 会在扩容之后使用链表头插法插入到指定位置。\nprivate void rehash(HashEntry\u0026lt;K,V\u0026gt; node) { HashEntry\u0026lt;K,V\u0026gt;[] oldTable = table; // 老容量 int oldCapacity = oldTable.length; // 新容量,扩大两倍 int newCapacity = oldCapacity \u0026lt;\u0026lt; 1; // 新的扩容阀值 threshold = (int)(newCapacity * loadFactor); // 创建新的数组 HashEntry\u0026lt;K,V\u0026gt;[] newTable = (HashEntry\u0026lt;K,V\u0026gt;[]) new HashEntry[newCapacity]; // 新的掩码,默认2扩容后是4,-1是3,二进制就是11。 int sizeMask = newCapacity - 1; for (int i = 0; i \u0026lt; oldCapacity ; i++) { // 遍历老数组 HashEntry\u0026lt;K,V\u0026gt; e = oldTable[i]; if (e != null) { HashEntry\u0026lt;K,V\u0026gt; next = e.next; // 计算新的位置,新的位置只可能是不变或者是老的位置+老的容量。 int idx = e.hash \u0026amp; sizeMask; if (next == null) // Single node on list // 如果当前位置还不是链表,只是一个元素,直接赋值 newTable[idx] = e; else { // Reuse consecutive sequence at same slot // 如果是链表了 HashEntry\u0026lt;K,V\u0026gt; lastRun = e; int lastIdx = idx; // 新的位置只可能是不变或者是老的位置+老的容量。 // 遍历结束后,lastRun 后面的元素位置都是相同的 for (HashEntry\u0026lt;K,V\u0026gt; last = next; last != null; last = last.next) { int k = last.hash \u0026amp; sizeMask; if (k != lastIdx) { lastIdx = k; lastRun = last; } } // ,lastRun 后面的元素位置都是相同的,直接作为链表赋值到新位置。 newTable[lastIdx] = lastRun; // Clone remaining nodes for (HashEntry\u0026lt;K,V\u0026gt; p = e; p != lastRun; p = p.next) { // 遍历剩余元素,头插法到指定 k 位置。 V v = p.value; int h = p.hash; int k = h \u0026amp; sizeMask; HashEntry\u0026lt;K,V\u0026gt; n = newTable[k]; newTable[k] = new HashEntry\u0026lt;K,V\u0026gt;(h, p.key, v, n); } } } } // 头插法插入新的节点 int nodeIndex = node.hash \u0026amp; sizeMask; // add the new node node.setNext(newTable[nodeIndex]); newTable[nodeIndex] = node; table = newTable; } 有些同学可能会对最后的两个 for 循环有疑惑,这里第一个 for 是为了寻找这样一个节点,这个节点后面的所有 next 节点的新位置都是相同的。然后把这个作为一个链表赋值到新位置。第二个 for 循环是为了把剩余的元素通过头插法插入到指定位置链表。这样实现的原因可能是基于概率统计,有深入研究的同学可以发表下意见。\n内部第二个 for 循环中使用了 new HashEntry\u0026lt;K,V\u0026gt;(h, p.key, v, n) 创建了一个新的 HashEntry,而不是复用之前的,是因为如果复用之前的,那么会导致正在遍历(如正在执行 get 方法)的线程由于指针的修改无法遍历下去。正如注释中所说的:\n当它们不再被可能正在并发遍历表的任何读取线程引用时,被替换的节点将被垃圾回收。\nThe nodes they replace will be garbage collectable as soon as they are no longer referenced by any reader thread that may be in the midst of concurrently traversing table\n为什么需要再使用一个 for 循环找到 lastRun ,其实是为了减少对象创建的次数,正如注解中所说的:\n从统计上看,在默认的阈值下,当表容量加倍时,只有大约六分之一的节点需要被克隆。\nStatistically, at the default threshold, only about one-sixth of them need cloning when a table doubles.\n5. get # 到这里就很简单了,get 方法只需要两步即可。\n计算得到 key 的存放位置。 遍历指定位置查找相同 key 的 value 值。 public V get(Object key) { Segment\u0026lt;K,V\u0026gt; s; // manually integrate access methods to reduce overhead HashEntry\u0026lt;K,V\u0026gt;[] tab; int h = hash(key); long u = (((h \u0026gt;\u0026gt;\u0026gt; segmentShift) \u0026amp; segmentMask) \u0026lt;\u0026lt; SSHIFT) + SBASE; // 计算得到 key 的存放位置 if ((s = (Segment\u0026lt;K,V\u0026gt;)UNSAFE.getObjectVolatile(segments, u)) != null \u0026amp;\u0026amp; (tab = s.table) != null) { for (HashEntry\u0026lt;K,V\u0026gt; e = (HashEntry\u0026lt;K,V\u0026gt;) UNSAFE.getObjectVolatile (tab, ((long)(((tab.length - 1) \u0026amp; h)) \u0026lt;\u0026lt; TSHIFT) + TBASE); e != null; e = e.next) { // 如果是链表,遍历查找到相同 key 的 value。 K k; if ((k = e.key) == key || (e.hash == h \u0026amp;\u0026amp; key.equals(k))) return e.value; } } return null; } 2. ConcurrentHashMap 1.8 # 1. 存储结构 # 可以发现 Java8 的 ConcurrentHashMap 相对于 Java7 来说变化比较大,不再是之前的 Segment 数组 + HashEntry 数组 + 链表,而是 Node 数组 + 链表 / 红黑树。当冲突链表达到一定长度时,链表会转换成红黑树。\n2. 初始化 initTable # /** * Initializes table, using the size recorded in sizeCtl. */ private final Node\u0026lt;K,V\u0026gt;[] initTable() { Node\u0026lt;K,V\u0026gt;[] tab; int sc; while ((tab = table) == null || tab.length == 0) { // 如果 sizeCtl \u0026lt; 0 ,说明另外的线程执行CAS 成功,正在进行初始化。 if ((sc = sizeCtl) \u0026lt; 0) // 让出 CPU 使用权 Thread.yield(); // lost initialization race; just spin else if (U.compareAndSwapInt(this, SIZECTL, sc, -1)) { try { if ((tab = table) == null || tab.length == 0) { int n = (sc \u0026gt; 0) ? sc : DEFAULT_CAPACITY; @SuppressWarnings(\u0026#34;unchecked\u0026#34;) Node\u0026lt;K,V\u0026gt;[] nt = (Node\u0026lt;K,V\u0026gt;[])new Node\u0026lt;?,?\u0026gt;[n]; table = tab = nt; sc = n - (n \u0026gt;\u0026gt;\u0026gt; 2); } } finally { sizeCtl = sc; } break; } } return tab; } 从源码中可以发现 ConcurrentHashMap 的初始化是通过自旋和 CAS 操作完成的。里面需要注意的是变量 sizeCtl (sizeControl 的缩写),它的值决定着当前的初始化状态。\n-1 说明正在初始化,其他线程需要自旋等待 -N 说明 table 正在进行扩容,高 16 位表示扩容的标识戳,低 16 位减 1 为正在进行扩容的线程数 0 表示 table 初始化大小,如果 table 没有初始化 \u0026gt;0 表示 table 扩容的阈值,如果 table 已经初始化。 3. put # 直接过一遍 put 源码。\npublic V put(K key, V value) { return putVal(key, value, false); } /** Implementation for put and putIfAbsent */ final V putVal(K key, V value, boolean onlyIfAbsent) { // key 和 value 不能为空 if (key == null || value == null) throw new NullPointerException(); int hash = spread(key.hashCode()); int binCount = 0; for (Node\u0026lt;K,V\u0026gt;[] tab = table;;) { // f = 目标位置元素 Node\u0026lt;K,V\u0026gt; f; int n, i, fh;// fh 后面存放目标位置的元素 hash 值 if (tab == null || (n = tab.length) == 0) // 数组桶为空,初始化数组桶(自旋+CAS) tab = initTable(); else if ((f = tabAt(tab, i = (n - 1) \u0026amp; hash)) == null) { // 桶内为空,CAS 放入,不加锁,成功了就直接 break 跳出 if (casTabAt(tab, i, null,new Node\u0026lt;K,V\u0026gt;(hash, key, value, null))) break; // no lock when adding to empty bin } else if ((fh = f.hash) == MOVED) tab = helpTransfer(tab, f); else { V oldVal = null; // 使用 synchronized 加锁加入节点 synchronized (f) { if (tabAt(tab, i) == f) { // 说明是链表 if (fh \u0026gt;= 0) { binCount = 1; // 循环加入新的或者覆盖节点 for (Node\u0026lt;K,V\u0026gt; e = f;; ++binCount) { K ek; if (e.hash == hash \u0026amp;\u0026amp; ((ek = e.key) == key || (ek != null \u0026amp;\u0026amp; key.equals(ek)))) { oldVal = e.val; if (!onlyIfAbsent) e.val = value; break; } Node\u0026lt;K,V\u0026gt; pred = e; if ((e = e.next) == null) { pred.next = new Node\u0026lt;K,V\u0026gt;(hash, key, value, null); break; } } } else if (f instanceof TreeBin) { // 红黑树 Node\u0026lt;K,V\u0026gt; p; binCount = 2; if ((p = ((TreeBin\u0026lt;K,V\u0026gt;)f).putTreeVal(hash, key, value)) != null) { oldVal = p.val; if (!onlyIfAbsent) p.val = value; } } } } if (binCount != 0) { if (binCount \u0026gt;= TREEIFY_THRESHOLD) treeifyBin(tab, i); if (oldVal != null) return oldVal; break; } } } addCount(1L, binCount); return null; } 根据 key 计算出 hashcode 。\n判断是否需要进行初始化。\n即为当前 key 定位出的 Node,如果为空表示当前位置可以写入数据,利用 CAS 尝试写入,失败则自旋保证成功。\n如果当前位置的 hashcode == MOVED == -1,则需要进行扩容。\n如果都不满足,则利用 synchronized 锁写入数据。\n如果数量大于 TREEIFY_THRESHOLD 则要执行树化方法,在 treeifyBin 中会首先判断当前数组长度 ≥64 时才会将链表转换为红黑树。\n4. get # get 流程比较简单,直接过一遍源码。\npublic V get(Object key) { Node\u0026lt;K,V\u0026gt;[] tab; Node\u0026lt;K,V\u0026gt; e, p; int n, eh; K ek; // key 所在的 hash 位置 int h = spread(key.hashCode()); if ((tab = table) != null \u0026amp;\u0026amp; (n = tab.length) \u0026gt; 0 \u0026amp;\u0026amp; (e = tabAt(tab, (n - 1) \u0026amp; h)) != null) { // 如果指定位置元素存在,头结点hash值相同 if ((eh = e.hash) == h) { if ((ek = e.key) == key || (ek != null \u0026amp;\u0026amp; key.equals(ek))) // key hash 值相等,key值相同,直接返回元素 value return e.val; } else if (eh \u0026lt; 0) // 头结点hash值小于0,说明正在扩容或者是红黑树,find查找 return (p = e.find(h, key)) != null ? p.val : null; while ((e = e.next) != null) { // 是链表,遍历查找 if (e.hash == h \u0026amp;\u0026amp; ((ek = e.key) == key || (ek != null \u0026amp;\u0026amp; key.equals(ek)))) return e.val; } } return null; } 总结一下 get 过程:\n根据 hash 值计算位置。 查找到指定位置,如果头节点就是要找的,直接返回它的 value. 如果头节点 hash 值小于 0 ,说明正在扩容或者是红黑树,查找之。 如果是链表,遍历查找之。 总结:\n总的来说 ConcurrentHashMap 在 Java8 中相对于 Java7 来说变化还是挺大的,\n3. 总结 # Java7 中 ConcurrentHashMap 使用的分段锁,也就是每一个 Segment 上同时只有一个线程可以操作,每一个 Segment 都是一个类似 HashMap 数组的结构,它可以扩容,它的冲突会转化为链表。但是 Segment 的个数一但初始化就不能改变。\nJava8 中的 ConcurrentHashMap 使用的 Synchronized 锁加 CAS 的机制。结构也由 Java7 中的 Segment 数组 + HashEntry 数组 + 链表 进化成了 Node 数组 + 链表 / 红黑树,Node 是类似于一个 HashEntry 的结构。它的冲突再达到一定大小时会转化成红黑树,在冲突小于一定数量时又退回链表。\n有些同学可能对 Synchronized 的性能存在疑问,其实 Synchronized 锁自从引入锁升级策略后,性能不再是问题,有兴趣的同学可以自己了解下 Synchronized 的锁升级。\n"},{"id":467,"href":"/zh/docs/technology/Interview/java/collection/copyonwritearraylist-source-code/","title":"CopyOnWriteArrayList 源码分析","section":"Collection","content":" CopyOnWriteArrayList 简介 # 在 JDK1.5 之前,如果想要使用并发安全的 List 只能选择 Vector。而 Vector 是一种老旧的集合,已经被淘汰。Vector 对于增删改查等方法基本都加了 synchronized,这种方式虽然能够保证同步,但这相当于对整个 Vector 加上了一把大锁,使得每个方法执行的时候都要去获得锁,导致性能非常低下。\nJDK1.5 引入了 Java.util.concurrent(JUC)包,其中提供了很多线程安全且并发性能良好的容器,其中唯一的线程安全 List 实现就是 CopyOnWriteArrayList 。关于java.util.concurrent 包下常见并发容器的总结,可以看我写的这篇文章: Java 常见并发容器总结 。\nCopyOnWriteArrayList 到底有什么厉害之处? # 对于大部分业务场景来说,读取操作往往是远大于写入操作的。由于读取操作不会对原有数据进行修改,因此,对于每次读取都进行加锁其实是一种资源浪费。相比之下,我们应该允许多个线程同时访问 List 的内部数据,毕竟对于读取操作来说是安全的。\n这种思路与 ReentrantReadWriteLock 读写锁的设计思想非常类似,即读读不互斥、读写互斥、写写互斥(只有读读不互斥)。CopyOnWriteArrayList 更进一步地实现了这一思想。为了将读操作性能发挥到极致,CopyOnWriteArrayList 中的读取操作是完全无需加锁的。更加厉害的是,写入操作也不会阻塞读取操作,只有写写才会互斥。这样一来,读操作的性能就可以大幅度提升。\nCopyOnWriteArrayList 线程安全的核心在于其采用了 写时复制(Copy-On-Write) 的策略,从 CopyOnWriteArrayList 的名字就能看出了。\nCopy-On-Write 的思想是什么? # CopyOnWriteArrayList名字中的“Copy-On-Write”即写时复制,简称 COW。\n下面是维基百科对 Copy-On-Write 的介绍,介绍的挺不错:\n写入时复制(英语:Copy-on-write,简称 COW)是一种计算机程序设计领域的优化策略。其核心思想是,如果有多个调用者(callers)同时请求相同资源(如内存或磁盘上的数据存储),他们会共同获取相同的指针指向相同的资源,直到某个调用者试图修改资源的内容时,系统才会真正复制一份专用副本(private copy)给该调用者,而其他调用者所见到的最初的资源仍然保持不变。这过程对其他的调用者都是透明的。此作法主要的优点是如果调用者没有修改该资源,就不会有副本(private copy)被创建,因此多个调用者只是读取操作时可以共享同一份资源。\n这里再以 CopyOnWriteArrayList为例介绍:当需要修改( add,set、remove 等操作) CopyOnWriteArrayList 的内容时,不会直接修改原数组,而是会先创建底层数组的副本,对副本数组进行修改,修改完之后再将修改后的数组赋值回去,这样就可以保证写操作不会影响读操作了。\n可以看出,写时复制机制非常适合读多写少的并发场景,能够极大地提高系统的并发性能。\n不过,写时复制机制并不是银弹,其依然存在一些缺点,下面列举几点:\n内存占用:每次写操作都需要复制一份原始数据,会占用额外的内存空间,在数据量比较大的情况下,可能会导致内存资源不足。 写操作开销:每一次写操作都需要复制一份原始数据,然后再进行修改和替换,所以写操作的开销相对较大,在写入比较频繁的场景下,性能可能会受到影响。 数据一致性问题:修改操作不会立即反映到最终结果中,还需要等待复制完成,这可能会导致一定的数据一致性问题。 …… CopyOnWriteArrayList 源码分析 # 这里以 JDK1.8 为例,分析一下 CopyOnWriteArrayList 的底层核心源码。\nCopyOnWriteArrayList 的类定义如下:\npublic class CopyOnWriteArrayList\u0026lt;E\u0026gt; extends Object implements List\u0026lt;E\u0026gt;, RandomAccess, Cloneable, Serializable { //... } CopyOnWriteArrayList 实现了以下接口:\nList : 表明它是一个列表,支持添加、删除、查找等操作,并且可以通过下标进行访问。 RandomAccess :这是一个标志接口,表明实现这个接口的 List 集合是支持 快速随机访问 的。 Cloneable :表明它具有拷贝能力,可以进行深拷贝或浅拷贝操作。 Serializable : 表明它可以进行序列化操作,也就是可以将对象转换为字节流进行持久化存储或网络传输,非常方便。 初始化 # CopyOnWriteArrayList 中有一个无参构造函数和两个有参构造函数。\n// 创建一个空的 CopyOnWriteArrayList public CopyOnWriteArrayList() { setArray(new Object[0]); } // 按照集合的迭代器返回的顺序创建一个包含指定集合元素的 CopyOnWriteArrayList public CopyOnWriteArrayList(Collection\u0026lt;? extends E\u0026gt; c) { Object[] elements; if (c.getClass() == CopyOnWriteArrayList.class) elements = ((CopyOnWriteArrayList\u0026lt;?\u0026gt;)c).getArray(); else { elements = c.toArray(); // c.toArray might (incorrectly) not return Object[] (see 6260652) if (elements.getClass() != Object[].class) elements = Arrays.copyOf(elements, elements.length, Object[].class); } setArray(elements); } // 创建一个包含指定数组的副本的列表 public CopyOnWriteArrayList(E[] toCopyIn) { setArray(Arrays.copyOf(toCopyIn, toCopyIn.length, Object[].class)); } 插入元素 # CopyOnWriteArrayList 的 add()方法有三个版本:\nadd(E e):在 CopyOnWriteArrayList 的尾部插入元素。 add(int index, E element):在 CopyOnWriteArrayList 的指定位置插入元素。 addIfAbsent(E e):如果指定元素不存在,那么添加该元素。如果成功添加元素则返回 true。 这里以add(E e)为例进行介绍:\n// 插入元素到 CopyOnWriteArrayList 的尾部 public boolean add(E e) { final ReentrantLock lock = this.lock; // 加锁 lock.lock(); try { // 获取原来的数组 Object[] elements = getArray(); // 原来数组的长度 int len = elements.length; // 创建一个长度+1的新数组,并将原来数组的元素复制给新数组 Object[] newElements = Arrays.copyOf(elements, len + 1); // 元素放在新数组末尾 newElements[len] = e; // array指向新数组 setArray(newElements); return true; } finally { // 解锁 lock.unlock(); } } 从上面的源码可以看出:\nadd方法内部用到了 ReentrantLock 加锁,保证了同步,避免了多线程写的时候会复制出多个副本出来。锁被修饰保证了锁的内存地址肯定不会被修改,并且,释放锁的逻辑放在 finally 中,可以保证锁能被释放。 CopyOnWriteArrayList 通过复制底层数组的方式实现写操作,即先创建一个新的数组来容纳新添加的元素,然后在新数组中进行写操作,最后将新数组赋值给底层数组的引用,替换掉旧的数组。这也就证明了我们前面说的:CopyOnWriteArrayList 线程安全的核心在于其采用了 写时复制(Copy-On-Write) 的策略。 每次写操作都需要通过 Arrays.copyOf 复制底层数组,时间复杂度是 O(n) 的,且会占用额外的内存空间。因此,CopyOnWriteArrayList 适用于读多写少的场景,在写操作不频繁且内存资源充足的情况下,可以提升系统的性能表现。 CopyOnWriteArrayList 中并没有类似于 ArrayList 的 grow() 方法扩容的操作。 Arrays.copyOf 方法的时间复杂度是 O(n),其中 n 表示需要复制的数组长度。因为这个方法的实现原理是先创建一个新的数组,然后将源数组中的数据复制到新数组中,最后返回新数组。这个方法会复制整个数组,因此其时间复杂度与数组长度成正比,即 O(n)。值得注意的是,由于底层调用了系统级别的拷贝指令,因此在实际应用中这个方法的性能表现比较优秀,但是也需要注意控制复制的数据量,避免出现内存占用过高的情况。\n读取元素 # CopyOnWriteArrayList 的读取操作是基于内部数组 array 并没有发生实际的修改,因此在读取操作时不需要进行同步控制和锁操作,可以保证数据的安全性。这种机制下,多个线程可以同时读取列表中的元素。\n// 底层数组,只能通过getArray和setArray方法访问 private transient volatile Object[] array; public E get(int index) { return get(getArray(), index); } final Object[] getArray() { return array; } private E get(Object[] a, int index) { return (E) a[index]; } 不过,get方法是弱一致性的,在某些情况下可能读到旧的元素值。\nget(int index)方法是分两步进行的:\n通过getArray()获取当前数组的引用; 直接从数组中获取下标为 index 的元素。 这个过程并没有加锁,所以在并发环境下可能出现如下情况:\n线程 1 调用get(int index)方法获取值,内部通过getArray()方法获取到了 array 属性值; 线程 2 调用CopyOnWriteArrayList的add、set、remove 等修改方法时,内部通过setArray方法修改了array属性的值; 线程 1 还是从旧的 array 数组中取值。 获取列表中元素的个数 # public int size() { return getArray().length; } CopyOnWriteArrayList中的array数组每次复制都刚好能够容纳下所有元素,并不像ArrayList那样会预留一定的空间。因此,CopyOnWriteArrayList中并没有size属性CopyOnWriteArrayList的底层数组的长度就是元素个数,因此size()方法只要返回数组长度就可以了。\n删除元素 # CopyOnWriteArrayList删除元素相关的方法一共有 4 个:\nremove(int index):移除此列表中指定位置上的元素。将任何后续元素向左移动(从它们的索引中减去 1)。 boolean remove(Object o):删除此列表中首次出现的指定元素,如果不存在该元素则返回 false。 boolean removeAll(Collection\u0026lt;?\u0026gt; c):从此列表中删除指定集合中包含的所有元素。 void clear():移除此列表中的所有元素。 这里以remove(int index)为例进行介绍:\npublic E remove(int index) { // 获取可重入锁 final ReentrantLock lock = this.lock; // 加锁 lock.lock(); try { //获取当前array数组 Object[] elements = getArray(); // 获取当前array长度 int len = elements.length; //获取指定索引的元素(旧值) E oldValue = get(elements, index); int numMoved = len - index - 1; // 判断删除的是否是最后一个元素 if (numMoved == 0) // 如果删除的是最后一个元素,直接复制该元素前的所有元素到新的数组 setArray(Arrays.copyOf(elements, len - 1)); else { // 分段复制,将index前的元素和index+1后的元素复制到新数组 // 新数组长度为旧数组长度-1 Object[] newElements = new Object[len - 1]; System.arraycopy(elements, 0, newElements, 0, index); System.arraycopy(elements, index + 1, newElements, index, numMoved); //将新数组赋值给array引用 setArray(newElements); } return oldValue; } finally { // 解锁 lock.unlock(); } } 判断元素是否存在 # CopyOnWriteArrayList提供了两个用于判断指定元素是否在列表中的方法:\ncontains(Object o):判断是否包含指定元素。 containsAll(Collection\u0026lt;?\u0026gt; c):判断是否保证指定集合的全部元素。 // 判断是否包含指定元素 public boolean contains(Object o) { //获取当前array数组 Object[] elements = getArray(); //调用index尝试查找指定元素,如果返回值大于等于0,则返回true,否则返回false return indexOf(o, elements, 0, elements.length) \u0026gt;= 0; } // 判断是否保证指定集合的全部元素 public boolean containsAll(Collection\u0026lt;?\u0026gt; c) { //获取当前array数组 Object[] elements = getArray(); //获取数组长度 int len = elements.length; //遍历指定集合 for (Object e : c) { //循环调用indexOf方法判断,只要有一个没有包含就直接返回false if (indexOf(e, elements, 0, len) \u0026lt; 0) return false; } //最后表示全部包含或者制定集合为空集合,那么返回true return true; } CopyOnWriteArrayList 常用方法测试 # 代码:\n// 创建一个 CopyOnWriteArrayList 对象 CopyOnWriteArrayList\u0026lt;String\u0026gt; list = new CopyOnWriteArrayList\u0026lt;\u0026gt;(); // 向列表中添加元素 list.add(\u0026#34;Java\u0026#34;); list.add(\u0026#34;Python\u0026#34;); list.add(\u0026#34;C++\u0026#34;); System.out.println(\u0026#34;初始列表:\u0026#34; + list); // 使用 get 方法获取指定位置的元素 System.out.println(\u0026#34;列表第二个元素为:\u0026#34; + list.get(1)); // 使用 remove 方法删除指定元素 boolean result = list.remove(\u0026#34;C++\u0026#34;); System.out.println(\u0026#34;删除结果:\u0026#34; + result); System.out.println(\u0026#34;列表删除元素后为:\u0026#34; + list); // 使用 set 方法更新指定位置的元素 list.set(1, \u0026#34;Golang\u0026#34;); System.out.println(\u0026#34;列表更新后为:\u0026#34; + list); // 使用 add 方法在指定位置插入元素 list.add(0, \u0026#34;PHP\u0026#34;); System.out.println(\u0026#34;列表插入元素后为:\u0026#34; + list); // 使用 size 方法获取列表大小 System.out.println(\u0026#34;列表大小为:\u0026#34; + list.size()); // 使用 removeAll 方法删除指定集合中所有出现的元素 result = list.removeAll(List.of(\u0026#34;Java\u0026#34;, \u0026#34;Golang\u0026#34;)); System.out.println(\u0026#34;批量删除结果:\u0026#34; + result); System.out.println(\u0026#34;列表批量删除元素后为:\u0026#34; + list); // 使用 clear 方法清空列表中所有元素 list.clear(); System.out.println(\u0026#34;列表清空后为:\u0026#34; + list); 输出:\n列表更新后为:[Java, Golang] 列表插入元素后为:[PHP, Java, Golang] 列表大小为:3 批量删除结果:true 列表批量删除元素后为:[PHP] 列表清空后为:[] "},{"id":468,"href":"/zh/docs/technology/Interview/java/collection/delayqueue-source-code/","title":"DelayQueue 源码分析","section":"Collection","content":" DelayQueue 简介 # DelayQueue 是 JUC 包(java.util.concurrent)为我们提供的延迟队列,用于实现延时任务比如订单下单 15 分钟未支付直接取消。它是 BlockingQueue 的一种,底层是一个基于 PriorityQueue 实现的一个无界队列,是线程安全的。关于PriorityQueue可以参考笔者编写的这篇文章: PriorityQueue 源码分析 。\nDelayQueue 中存放的元素必须实现 Delayed 接口,并且需要重写 getDelay()方法(计算是否到期)。\npublic interface Delayed extends Comparable\u0026lt;Delayed\u0026gt; { long getDelay(TimeUnit unit); } 默认情况下, DelayQueue 会按照到期时间升序编排任务。只有当元素过期时(getDelay()方法返回值小于等于 0),才能从队列中取出。\nDelayQueue 发展史 # DelayQueue 最早是在 Java 5 中引入的,作为 java.util.concurrent 包中的一部分,用于支持基于时间的任务调度和缓存过期删除等场景,该版本仅仅支持延迟功能的实现,还未解决线程安全问题。 在 Java 6 中,DelayQueue 的实现进行了优化,通过使用 ReentrantLock 和 Condition 解决线程安全及线程间交互的效率,提高了其性能和可靠性。 在 Java 7 中,DelayQueue 的实现进行了进一步的优化,通过使用 CAS 操作实现元素的添加和移除操作,提高了其并发操作性能。 在 Java 8 中,DelayQueue 的实现没有进行重大变化,但是在 java.time 包中引入了新的时间类,如 Duration 和 Instant,使得使用 DelayQueue 进行基于时间的调度更加方便和灵活。 在 Java 9 中,DelayQueue 的实现进行了一些微小的改进,主要是对代码进行了一些优化和精简。 总的来说,DelayQueue 的发展史主要是通过优化其实现方式和提高其性能和可靠性,使其更加适用于基于时间的调度和缓存过期删除等场景。\nDelayQueue 常见使用场景示例 # 我们这里希望任务可以按照我们预期的时间执行,例如提交 3 个任务,分别要求 1s、2s、3s 后执行,即使是乱序添加,1s 后要求 1s 执行的任务会准时执行。\n对此我们可以使用 DelayQueue 来实现,所以我们首先需要继承 Delayed 实现 DelayedTask,实现 getDelay 方法以及优先级比较 compareTo。\n/** * 延迟任务 */ public class DelayedTask implements Delayed { /** * 任务到期时间 */ private long executeTime; /** * 任务 */ private Runnable task; public DelayedTask(long delay, Runnable task) { this.executeTime = System.currentTimeMillis() + delay; this.task = task; } /** * 查看当前任务还有多久到期 * @param unit * @return */ @Override public long getDelay(TimeUnit unit) { return unit.convert(executeTime - System.currentTimeMillis(), TimeUnit.MILLISECONDS); } /** * 延迟队列需要到期时间升序入队,所以我们需要实现compareTo进行到期时间比较 * @param o * @return */ @Override public int compareTo(Delayed o) { return Long.compare(this.executeTime, ((DelayedTask) o).executeTime); } public void execute() { task.run(); } } 完成任务的封装之后,使用就很简单了,设置好多久到期然后将任务提交到延迟队列中即可。\n// 创建延迟队列,并添加任务 DelayQueue \u0026lt; DelayedTask \u0026gt; delayQueue = new DelayQueue \u0026lt; \u0026gt; (); //分别添加1s、2s、3s到期的任务 delayQueue.add(new DelayedTask(2000, () -\u0026gt; System.out.println(\u0026#34;Task 2\u0026#34;))); delayQueue.add(new DelayedTask(1000, () -\u0026gt; System.out.println(\u0026#34;Task 1\u0026#34;))); delayQueue.add(new DelayedTask(3000, () -\u0026gt; System.out.println(\u0026#34;Task 3\u0026#34;))); // 取出任务并执行 while (!delayQueue.isEmpty()) { //阻塞获取最先到期的任务 DelayedTask task = delayQueue.take(); if (task != null) { task.execute(); } } 从输出结果可以看出,即使笔者先提到 2s 到期的任务,1s 到期的任务 Task1 还是优先执行的。\nTask 1 Task 2 Task 3 DelayQueue 源码解析 # 这里以 JDK1.8 为例,分析一下 DelayQueue 的底层核心源码。\nDelayQueue 的类定义如下:\npublic class DelayQueue\u0026lt;E extends Delayed\u0026gt; extends AbstractQueue\u0026lt;E\u0026gt; implements BlockingQueue\u0026lt;E\u0026gt; { //... } DelayQueue 继承了 AbstractQueue 类,实现了 BlockingQueue 接口。\n核心成员变量 # DelayQueue 的 4 个核心成员变量如下:\n//可重入锁,实现线程安全的关键 private final transient ReentrantLock lock = new ReentrantLock(); //延迟队列底层存储数据的集合,确保元素按照到期时间升序排列 private final PriorityQueue\u0026lt;E\u0026gt; q = new PriorityQueue\u0026lt;E\u0026gt;(); //指向准备执行优先级最高的线程 private Thread leader = null; //实现多线程之间等待唤醒的交互 private final Condition available = lock.newCondition(); lock : 我们都知道 DelayQueue 存取是线程安全的,所以为了保证存取元素时线程安全,我们就需要在存取时上锁,而 DelayQueue 就是基于 ReentrantLock 独占锁确保存取操作的线程安全。 q : 延迟队列要求元素按照到期时间进行升序排列,所以元素添加时势必需要进行优先级排序,所以 DelayQueue 底层元素的存取都是通过这个优先队列 PriorityQueue 的成员变量 q 来管理的。 leader : 延迟队列的任务只有到期之后才会执行,对于没有到期的任务只有等待,为了确保优先级最高的任务到期后可以即刻被执行,设计者就用 leader 来管理延迟任务,只有 leader 所指向的线程才具备定时等待任务到期执行的权限,而其他那些优先级低的任务只能无限期等待,直到 leader 线程执行完手头的延迟任务后唤醒它。 available : 上文讲述 leader 线程时提到的等待唤醒操作的交互就是通过 available 实现的,假如线程 1 尝试在空的 DelayQueue 获取任务时,available 就会将其放入等待队列中。直到有一个线程添加一个延迟任务后通过 available 的 signal 方法将其唤醒。 构造方法 # 相较于其他的并发容器,延迟队列的构造方法比较简单,它只有两个构造方法,因为所有成员变量在类加载时都已经初始完成了,所以默认构造方法什么也没做。还有一个传入 Collection 对象的构造方法,它会将调用 addAll()方法将集合元素存到优先队列 q 中。\npublic DelayQueue() {} public DelayQueue(Collection\u0026lt;? extends E\u0026gt; c) { this.addAll(c); } 添加元素 # DelayQueue 添加元素的方法无论是 add、put 还是 offer,本质上就是调用一下 offer ,所以了解延迟队列的添加逻辑我们只需阅读 offer 方法即可。\noffer 方法的整体逻辑为:\n尝试获取 lock 。 如果上锁成功,则调 q 的 offer 方法将元素存放到优先队列中。 调用 peek 方法看看当前队首元素是否就是本次入队的元素,如果是则说明当前这个元素是即将到期的任务(即优先级最高的元素),于是将 leader 设置为空,通知因为队列为空时调用 take 等方法导致阻塞的线程来争抢元素。 上述步骤执行完成,释放 lock。 返回 true。 源码如下,笔者已详细注释,读者可自行参阅:\npublic boolean offer(E e) { //尝试获取lock final ReentrantLock lock = this.lock; lock.lock(); try { //如果上锁成功,则调q的offer方法将元素存放到优先队列中 q.offer(e); //调用peek方法看看当前队首元素是否就是本次入队的元素,如果是则说明当前这个元素是即将到期的任务(即优先级最高的元素) if (q.peek() == e) { //将leader设置为空,通知调用取元素方法而阻塞的线程来争抢这个任务 leader = null; available.signal(); } return true; } finally { //上述步骤执行完成,释放lock lock.unlock(); } } 获取元素 # DelayQueue 中获取元素的方式分为阻塞式和非阻塞式,先来看看逻辑比较复杂的阻塞式获取元素方法 take,为了让读者可以更直观的了解阻塞式获取元素的全流程,笔者将以 3 个线程并发获取元素为例讲述 take 的工作流程。\n想要理解下面的内容,需要用到 AQS 相关的知识,推荐阅读下面这两篇文章:\n图文讲解 AQS ,一起看看 AQS 的源码……(图文较长) AQS 都看完了,Condition 原理可不能少! 1、首先, 3 个线程会尝试获取可重入锁 lock,假设我们现在有 3 个线程分别是 t1、t2、t3,随后 t1 得到了锁,而 t2、t3 没有抢到锁,故将这两个线程存入等待队列中。\n2、紧接着 t1 开始进行元素获取的逻辑。\n3、线程 t1 首先会查看 DelayQueue 队列首元素是否为空。\n4、如果元素为空,则说明当前队列没有任何元素,故 t1 就会被阻塞存到 conditionWaiter 这个队列中。\n注意,调用 await 之后 t1 就会释放 lcok 锁,假如 DelayQueue 持续为空,那么 t2、t3 也会像 t1 一样执行相同的逻辑并进入 conditionWaiter 队列中。\n如果元素不为空,则判断当前任务是否到期,如果元素到期,则直接返回出去。如果元素未到期,则判断当前 leader 线程(DelayQueue 中唯一一个可以等待并获取元素的线程引用)是否为空,若不为空,则说明当前 leader 正在等待执行一个优先级比当前元素还高的元素到期,故当前线程 t1 只能调用 await 进入无限期等待,等到 leader 取得元素后唤醒。反之,若 leader 线程为空,则将当前线程设置为 leader 并进入有限期等待,到期后取出元素并返回。\n自此我们阻塞式获取元素的逻辑都已完成后,源码如下,读者可自行参阅:\npublic E take() throws InterruptedException { // 尝试获取可重入锁,将底层AQS的state设置为1,并设置为独占锁 final ReentrantLock lock = this.lock; lock.lockInterruptibly(); try { for (;;) { //查看队列第一个元素 E first = q.peek(); //若为空,则将当前线程放入ConditionObject的等待队列中,并将底层AQS的state设置为0,表示释放锁并进入无限期等待 if (first == null) available.await(); else { //若元素不为空,则查看当前元素多久到期 long delay = first.getDelay(NANOSECONDS); //如果小于0则说明已到期直接返回出去 if (delay \u0026lt;= 0) return q.poll(); //如果大于0则说明任务还没到期,首先需要释放对这个元素的引用 first = null; // don\u0026#39;t retain ref while waiting //判断leader是否为空,如果不为空,则说明正有线程作为leader并等待一个任务到期,则当前线程进入无限期等待 if (leader != null) available.await(); else { //反之将我们的线程成为leader Thread thisThread = Thread.currentThread(); leader = thisThread; try { //并进入有限期等待 available.awaitNanos(delay); } finally { //等待任务到期时,释放leader引用,进入下一次循环将任务return出去 if (leader == thisThread) leader = null; } } } } } finally { // 收尾逻辑:当leader为null,并且队列中有任务时,唤醒等待的获取元素的线程。 if (leader == null \u0026amp;\u0026amp; q.peek() != null) available.signal(); //释放锁 lock.unlock(); } } 我们再来看看非阻塞的获取元素方法 poll ,逻辑比较简单,整体步骤如下:\n尝试获取可重入锁。 查看队列第一个元素,判断元素是否为空。 若元素为空,或者元素未到期,则直接返回空。 若元素不为空且到期了,直接调用 poll 返回出去。 释放可重入锁 lock 。 源码如下,读者可自行参阅源码及注释:\npublic E poll() { //尝试获取可重入锁 final ReentrantLock lock = this.lock; lock.lock(); try { //查看队列第一个元素,判断元素是否为空 E first = q.peek(); //若元素为空,或者元素未到期,则直接返回空 if (first == null || first.getDelay(NANOSECONDS) \u0026gt; 0) return null; else //若元素不为空且到期了,直接调用poll返回出去 return q.poll(); } finally { //释放可重入锁lock lock.unlock(); } } 查看元素 # 上文获取元素时都会调用到 peek 方法,peek 顾名思义仅仅窥探一下队列中的元素,它的步骤就 4 步:\n上锁。 调用优先队列 q 的 peek 方法查看索引 0 位置的元素。 释放锁。 将元素返回出去。 public E peek() { final ReentrantLock lock = this.lock; lock.lock(); try { return q.peek(); } finally { lock.unlock(); } } DelayQueue 常见面试题 # DelayQueue 的实现原理是什么? # DelayQueue 底层是使用优先队列 PriorityQueue 来存储元素,而 PriorityQueue 采用二叉小顶堆的思想确保值小的元素排在最前面,这就使得 DelayQueue 对于延迟任务优先级的管理就变得十分方便了。同时 DelayQueue 为了保证线程安全还用到了可重入锁 ReentrantLock,确保单位时间内只有一个线程可以操作延迟队列。最后,为了实现多线程之间等待和唤醒的交互效率,DelayQueue 还用到了 Condition,通过 Condition 的 await 和 signal 方法完成多线程之间的等待唤醒。\nDelayQueue 的实现是否线程安全? # DelayQueue 的实现是线程安全的,它通过 ReentrantLock 实现了互斥访问和 Condition 实现了线程间的等待和唤醒操作,可以保证多线程环境下的安全性和可靠性。\nDelayQueue 的使用场景有哪些? # DelayQueue 通常用于实现定时任务调度和缓存过期删除等场景。在定时任务调度中,需要将需要执行的任务封装成延迟任务对象,并将其添加到 DelayQueue 中,DelayQueue 会自动按照剩余延迟时间进行升序排序(默认情况),以保证任务能够按照时间先后顺序执行。对于缓存过期这个场景而言,在数据被缓存到内存之后,我们可以将缓存的 key 封装成一个延迟的删除任务,并将其添加到 DelayQueue 中,当数据过期时,拿到这个任务的 key,将这个 key 从内存中移除。\nDelayQueue 中 Delayed 接口的作用是什么? # Delayed 接口定义了元素的剩余延迟时间(getDelay)和元素之间的比较规则(该接口继承了 Comparable 接口)。若希望元素能够存放到 DelayQueue 中,就必须实现 Delayed 接口的 getDelay() 方法和 compareTo() 方法,否则 DelayQueue 无法得知当前任务剩余时长和任务优先级的比较。\nDelayQueue 和 Timer/TimerTask 的区别是什么? # DelayQueue 和 Timer/TimerTask 都可以用于实现定时任务调度,但是它们的实现方式不同。DelayQueue 是基于优先级队列和堆排序算法实现的,可以实现多个任务按照时间先后顺序执行;而 Timer/TimerTask 是基于单线程实现的,只能按照任务的执行顺序依次执行,如果某个任务执行时间过长,会影响其他任务的执行。另外,DelayQueue 还支持动态添加和移除任务,而 Timer/TimerTask 只能在创建时指定任务。\n参考文献 # 《深入理解高并发编程:JDK 核心技术》: 一口气说出 Java 6 种延时队列的实现方法(面试官也得服): https://www.jb51.net/article/186192.htm 图解 DelayQueue 源码(java 8)——延时队列的小九九: https://blog.csdn.net/every__day/article/details/113810985 "},{"id":469,"href":"/zh/docs/technology/Interview/high-performance/message-queue/disruptor-questions/","title":"Disruptor常见问题总结","section":"High Performance","content":"Disruptor 是一个相对冷门一些的知识点,不过,如果你的项目经历中用到了 Disruptor 的话,那面试中就很可能会被问到。\n一位球友之前投稿的面经(社招)中就涉及一些 Disruptor 的问题,文章传送门: 圆梦!顺利拿到字节、淘宝、拼多多等大厂 offer! 。\n这篇文章可以看作是对 Disruptor 做的一个简单总结,每个问题都不会扯太深入,主要针对面试或者速览 Disruptor。\nDisruptor 是什么? # Disruptor 是一个开源的高性能内存队列,诞生初衷是为了解决内存队列的性能和内存安全问题,由英国外汇交易公司 LMAX 开发。\n根据 Disruptor 官方介绍,基于 Disruptor 开发的系统 LMAX(新的零售金融交易平台),单线程就能支撑每秒 600 万订单。Martin Fowler 在 2011 年写的一篇文章 The LMAX Architecture 中专门介绍过这个 LMAX 系统的架构,感兴趣的可以看看这篇文章。。\nLMAX 公司 2010 年在 QCon 演讲后,Disruptor 获得了业界关注,并获得了 2011 年的 Oracle 官方的 Duke\u0026rsquo;s Choice Awards(Duke 选择大奖)。\n“Duke 选择大奖”旨在表彰过去一年里全球个人或公司开发的、最具影响力的 Java 技术应用,由甲骨文公司主办。含金量非常高!\n我专门找到了 Oracle 官方当年颁布获得 Duke\u0026rsquo;s Choice Awards 项目的那篇文章(文章地址: https://blogs.oracle.com/java/post/and-the-winners-arethe-dukes-choice-award) 。从文中可以看出,同年获得此大奖荣誉的还有大名鼎鼎的 Netty、JRebel 等项目。\nDisruptor 提供的功能优点类似于 Kafka、RocketMQ 这类分布式队列,不过,其作为范围是 JVM(内存)。\nGithub 地址: https://github.com/LMAX-Exchange/disruptor 官方教程: https://lmax-exchange.github.io/disruptor/user-guide/index.html 关于如何在 Spring Boot 项目中使用 Disruptor,可以看这篇文章: Spring Boot + Disruptor 实战入门 。\n为什么要用 Disruptor? # Disruptor 主要解决了 JDK 内置线程安全队列的性能和内存安全问题。\nJDK 中常见的线程安全的队列如下:\n队列名字 锁 是否有界 ArrayBlockingQueue 加锁(ReentrantLock) 有界 LinkedBlockingQueue 加锁(ReentrantLock) 有界 LinkedTransferQueue 无锁(CAS) 无界 ConcurrentLinkedQueue 无锁(CAS) 无界 从上表中可以看出:这些队列要不就是加锁有界,要不就是无锁无界。而加锁的的队列势必会影响性能,无界的队列又存在内存溢出的风险。\n因此,一般情况下,我们都是不建议使用 JDK 内置线程安全队列。\nDisruptor 就不一样了!它在无锁的情况下还能保证队列有界,并且还是线程安全的。\n下面这张图是 Disruptor 官网提供的 Disruptor 和 ArrayBlockingQueue 的延迟直方图对比。\nDisruptor 真的很快,关于它为什么这么快这个问题,会在后文介绍到。\n此外,Disruptor 还提供了丰富的扩展功能比如支持批量操作、支持多种等待策略。\nKafka 和 Disruptor 什么区别? # Kafka:分布式消息队列,一般用在系统或者服务之间的消息传递,还可以被用作流式处理平台。 Disruptor:内存级别的消息队列,一般用在系统内部中线程间的消息传递。 哪些组件用到了 Disruptor? # 用到 Disruptor 的开源项目还是挺多的,这里简单举几个例子:\nLog4j2:Log4j2 是一款常用的日志框架,它基于 Disruptor 来实现异步日志。 SOFATracer:SOFATracer 是蚂蚁金服开源的分布式应用链路追踪工具,它基于 Disruptor 来实现异步日志。 Storm : Storm 是一个开源的分布式实时计算系统,它基于 Disruptor 来实现工作进程内发生的消息传递(同一 Storm 节点上的线程间,无需网络通信)。 HBase:HBase 是一个分布式列存储数据库系统,它基于 Disruptor 来提高写并发性能。 …… Disruptor 核心概念有哪些? # Event:你可以把 Event 理解为存放在队列中等待消费的消息对象。 EventFactory:事件工厂用于生产事件,我们在初始化 Disruptor 类的时候需要用到。 EventHandler:Event 在对应的 Handler 中被处理,你可以将其理解为生产消费者模型中的消费者。 EventProcessor:EventProcessor 持有特定消费者(Consumer)的 Sequence,并提供用于调用事件处理实现的事件循环(Event Loop)。 Disruptor:事件的生产和消费需要用到 Disruptor 对象。 RingBuffer:RingBuffer(环形数组)用于保存事件。 WaitStrategy:等待策略。决定了没有事件可以消费的时候,事件消费者如何等待新事件的到来。 Producer:生产者,只是泛指调用 Disruptor 对象发布事件的用户代码,Disruptor 没有定义特定接口或类型。 ProducerType:指定是单个事件发布者模式还是多个事件发布者模式(发布者和生产者的意思类似,我个人比较喜欢用发布者)。 Sequencer:Sequencer 是 Disruptor 的真正核心。此接口有两个实现类 SingleProducerSequencer、MultiProducerSequencer ,它们定义在生产者和消费者之间快速、正确地传递数据的并发算法。 下面这张图摘自 Disruptor 官网,展示了 LMAX 系统使用 Disruptor 的示例。\nDisruptor 等待策略有哪些? # 等待策略(WaitStrategy) 决定了没有事件可以消费的时候,事件消费者如何等待新事件的到来。\n常见的等待策略有下面这些:\nBlockingWaitStrategy:基于 ReentrantLock+Condition 来实现等待和唤醒操作,实现代码非常简单,是 Disruptor 默认的等待策略。虽然最慢,但也是 CPU 使用率最低和最稳定的选项生产环境推荐使用; BusySpinWaitStrategy:性能很好,存在持续自旋的风险,使用不当会造成 CPU 负载 100%,慎用; LiteBlockingWaitStrategy:基于 BlockingWaitStrategy 的轻量级等待策略,在没有锁竞争的时候会省去唤醒操作,但是作者说测试不充分,因此不建议使用; TimeoutBlockingWaitStrategy:带超时的等待策略,超时后会执行业务指定的处理逻辑; LiteTimeoutBlockingWaitStrategy:基于TimeoutBlockingWaitStrategy的策略,当没有锁竞争的时候会省去唤醒操作; SleepingWaitStrategy:三段式策略,第一阶段自旋,第二阶段执行 Thread.yield 让出 CPU,第三阶段睡眠执行时间,反复的睡眠; YieldingWaitStrategy:二段式策略,第一阶段自旋,第二阶段执行 Thread.yield 交出 CPU; PhasedBackoffWaitStrategy:四段式策略,第一阶段自旋指定次数,第二阶段自旋指定时间,第三阶段执行 Thread.yield 交出 CPU,第四阶段调用成员变量的waitFor方法,该成员变量可以被设置为BlockingWaitStrategy、LiteBlockingWaitStrategy、SleepingWaitStrategy三个中的一个。 Disruptor 为什么这么快? # RingBuffer(环形数组) : Disruptor 内部的 RingBuffer 是通过数组实现的。由于这个数组中的所有元素在初始化时一次性全部创建,因此这些元素的内存地址一般来说是连续的。这样做的好处是,当生产者不断往 RingBuffer 中插入新的事件对象时,这些事件对象的内存地址就能够保持连续,从而利用 CPU 缓存的局部性原理,将相邻的事件对象一起加载到缓存中,提高程序的性能。这类似于 MySQL 的预读机制,将连续的几个页预读到内存里。除此之外,RingBuffer 基于数组还支持批量操作(一次处理多个元素)、还可以避免频繁的内存分配和垃圾回收(RingBuffer 是一个固定大小的数组,当向数组中添加新元素时,如果数组已满,则新元素将覆盖掉最旧的元素)。 避免了伪共享问题:CPU 缓存内部是按照 Cache Line(缓存行)管理的,一般的 Cache Line 大小在 64 字节左右。Disruptor 为了确保目标字段独占一个 Cache Line,会在目标字段前后增加字节填充(前 56 个字节和后 56 个字节),这样可以避免 Cache Line 的伪共享(False Sharing)问题。同时,为了让 RingBuffer 存放数据的数组独占缓存行,数组的设计为 无效填充(128 字节)+ 有效数据。 无锁设计:Disruptor 采用无锁设计,避免了传统锁机制带来的竞争和延迟。Disruptor 的无锁实现起来比较复杂,主要是基于 CAS、内存屏障(Memory Barrier)、RingBuffer 等技术实现的。 综上所述,Disruptor 之所以能够如此快,是基于一系列优化策略的综合作用,既充分利用了现代 CPU 缓存结构的特点,又避免了常见的并发问题和性能瓶颈。\n关于 Disruptor 高性能队列原理的详细介绍,可以查看这篇文章: Disruptor 高性能队列原理浅析 (参考了美团技术团队的 高性能队列——Disruptor这篇文章)。\n🌈 这里额外补充一点:数组中对象元素地址连续为什么可以提高性能?\nCPU 缓存是通过将最近使用的数据存储在高速缓存中来实现更快的读取速度,并使用预取机制提前加载相邻内存的数据以利用局部性原理。\n在计算机系统中,CPU 主要访问高速缓存和内存。高速缓存是一种速度非常快、容量相对较小的内存,通常被分为多级缓存,其中 L1、L2、L3 分别表示一级缓存、二级缓存、三级缓存。越靠近 CPU 的缓存,速度越快,容量也越小。相比之下,内存容量相对较大,但速度较慢。\n为了加速数据的读取过程,CPU 会先将数据从内存中加载到高速缓存中,如果下一次需要访问相同的数据,就可以直接从高速缓存中读取,而不需要再次访问内存。这就是所谓的 缓存命中 。另外,为了利用 局部性原理 ,CPU 还会根据之前访问的内存地址预取相邻的内存数据,因为在程序中,连续的内存地址通常会被频繁访问到,这样做可以提高数据的缓存命中率,进而提高程序的性能。\n参考 # Disruptor 高性能之道-等待策略:\u0026laquo; http://wuwenliang.net/2022/02/28/Disruptor\u003e 高性能之道-等待策略/\u0026gt; 《Java 并发编程实战》- 40 | 案例分析(三):高性能队列 Disruptor: https://time.geekbang.org/column/article/98134 "},{"id":470,"href":"/zh/docs/technology/Interview/cs-basics/network/dns/","title":"DNS 域名系统详解(应用层)","section":"Network","content":"DNS(Domain Name System)域名管理系统,是当用户使用浏览器访问网址之后,使用的第一个重要协议。DNS 要解决的是域名和 IP 地址的映射问题。\n在实际使用中,有一种情况下,浏览器是可以不必动用 DNS 就可以获知域名和 IP 地址的映射的。浏览器在本地会维护一个hosts列表,一般来说浏览器要先查看要访问的域名是否在hosts列表中,如果有的话,直接提取对应的 IP 地址记录,就好了。如果本地hosts列表内没有域名-IP 对应记录的话,那么 DNS 就闪亮登场了。\n目前 DNS 的设计采用的是分布式、层次数据库结构,DNS 是应用层协议,基于 UDP 协议之上,端口为 53 。\nDNS 服务器 # DNS 服务器自底向上可以依次分为以下几个层级(所有 DNS 服务器都属于以下四个类别之一):\n根 DNS 服务器。根 DNS 服务器提供 TLD 服务器的 IP 地址。目前世界上只有 13 组根服务器,我国境内目前仍没有根服务器。 顶级域 DNS 服务器(TLD 服务器)。顶级域是指域名的后缀,如com、org、net和edu等。国家也有自己的顶级域,如uk、fr和ca。TLD 服务器提供了权威 DNS 服务器的 IP 地址。 权威 DNS 服务器。在因特网上具有公共可访问主机的每个组织机构必须提供公共可访问的 DNS 记录,这些记录将这些主机的名字映射为 IP 地址。 本地 DNS 服务器。每个 ISP(互联网服务提供商)都有一个自己的本地 DNS 服务器。当主机发出 DNS 请求时,该请求被发往本地 DNS 服务器,它起着代理的作用,并将该请求转发到 DNS 层次结构中。严格说来,不属于 DNS 层级结构。 世界上并不是只有 13 台根服务器,这是很多人普遍的误解,网上很多文章也是这么写的。实际上,现在根服务器数量远远超过这个数量。最初确实是为 DNS 根服务器分配了 13 个 IP 地址,每个 IP 地址对应一个不同的根 DNS 服务器。然而,由于互联网的快速发展和增长,这个原始的架构变得不太适应当前的需求。为了提高 DNS 的可靠性、安全性和性能,目前这 13 个 IP 地址中的每一个都有多个服务器,截止到 2023 年底,所有根服务器之和达到了 600 多台,未来还会继续增加。\nDNS 工作流程 # 以下图为例,介绍 DNS 的查询解析过程。DNS 的查询解析过程分为两种模式:\n迭代 递归 下图是实践中常采用的方式,从请求主机到本地 DNS 服务器的查询是递归的,其余的查询时迭代的。\n现在,主机cis.poly.edu想知道gaia.cs.umass.edu的 IP 地址。假设主机cis.poly.edu的本地 DNS 服务器为dns.poly.edu,并且gaia.cs.umass.edu的权威 DNS 服务器为dns.cs.umass.edu。\n首先,主机cis.poly.edu向本地 DNS 服务器dns.poly.edu发送一个 DNS 请求,该查询报文包含被转换的域名gaia.cs.umass.edu。 本地 DNS 服务器dns.poly.edu检查本机缓存,发现并无记录,也不知道gaia.cs.umass.edu的 IP 地址该在何处,不得不向根服务器发送请求。 根服务器注意到请求报文中含有edu顶级域,因此告诉本地 DNS,你可以向edu的 TLD DNS 发送请求,因为目标域名的 IP 地址很可能在那里。 本地 DNS 获取到了edu的 TLD DNS 服务器地址,向其发送请求,询问gaia.cs.umass.edu的 IP 地址。 edu的 TLD DNS 服务器仍不清楚请求域名的 IP 地址,但是它注意到该域名有umass.edu前缀,因此返回告知本地 DNS,umass.edu的权威服务器可能记录了目标域名的 IP 地址。 这一次,本地 DNS 将请求发送给权威 DNS 服务器dns.cs.umass.edu。 终于,由于gaia.cs.umass.edu向权威 DNS 服务器备案过,在这里有它的 IP 地址记录,权威 DNS 成功地将 IP 地址返回给本地 DNS。 最后,本地 DNS 获取到了目标域名的 IP 地址,将其返回给请求主机。 除了迭代式查询,还有一种递归式查询如下图,具体过程和上述类似,只是顺序有所不同。\n另外,DNS 的缓存位于本地 DNS 服务器。由于全世界的根服务器甚少,只有 600 多台,分为 13 组,且顶级域的数量也在一个可数的范围内,因此本地 DNS 通常已经缓存了很多 TLD DNS 服务器,所以在实际查找过程中,无需访问根服务器。根服务器通常是被跳过的,不请求的。这样可以提高 DNS 查询的效率和速度,减少对根服务器和 TLD 服务器的负担。\nDNS 报文格式 # DNS 的报文格式如下图所示:\nDNS 报文分为查询和回答报文,两种形式的报文结构相同。\n标识符。16 比特,用于标识该查询。这个标识符会被复制到对查询的回答报文中,以便让客户用它来匹配发送的请求和接收到的回答。 标志。1 比特的”查询/回答“标识位,0表示查询报文,1表示回答报文;1 比特的”权威的“标志位(当某 DNS 服务器是所请求名字的权威 DNS 服务器时,且是回答报文,使用”权威的“标志);1 比特的”希望递归“标志位,显式地要求执行递归查询;1 比特的”递归可用“标志位,用于回答报文中,表示 DNS 服务器支持递归查询。 问题数、回答 RR 数、权威 RR 数、附加 RR 数。分别指示了后面 4 类数据区域出现的数量。 问题区域。包含正在被查询的主机名字,以及正被询问的问题类型。 回答区域。包含了对最初请求的名字的资源记录。在回答报文的回答区域中可以包含多条 RR,因此一个主机名能够有多个 IP 地址。 权威区域。包含了其他权威服务器的记录。 附加区域。包含了其他有帮助的记录。 DNS 记录 # DNS 服务器在响应查询时,需要查询自己的数据库,数据库中的条目被称为 资源记录(Resource Record,RR) 。RR 提供了主机名到 IP 地址的映射。RR 是一个包含了Name, Value, Type, TTL四个字段的四元组。\nTTL是该记录的生存时间,它决定了资源记录应当从缓存中删除的时间。\nName和Value字段的取值取决于Type:\n如果Type=A,则Name是主机名信息,Value 是该主机名对应的 IP 地址。这样的 RR 记录了一条主机名到 IP 地址的映射。 如果 Type=AAAA (与 A 记录非常相似),唯一的区别是 A 记录使用的是 IPv4,而 AAAA 记录使用的是 IPv6。 如果Type=CNAME (Canonical Name Record,真实名称记录) ,则Value是别名为Name的主机对应的规范主机名。Value值才是规范主机名。CNAME 记录将一个主机名映射到另一个主机名。CNAME 记录用于为现有的 A 记录创建别名。下文有示例。 如果Type=NS,则Name是个域,而Value是个知道如何获得该域中主机 IP 地址的权威 DNS 服务器的主机名。通常这样的 RR 是由 TLD 服务器发布的。 如果Type=MX ,则Value是个别名为Name的邮件服务器的规范主机名。既然有了 MX 记录,那么邮件服务器可以和其他服务器使用相同的别名。为了获得邮件服务器的规范主机名,需要请求 MX 记录;为了获得其他服务器的规范主机名,需要请求 CNAME 记录。 CNAME记录总是指向另一则域名,而非 IP 地址。假设有下述 DNS zone:\nNAME TYPE VALUE -------------------------------------------------- bar.example.com. CNAME foo.example.com. foo.example.com. A 192.0.2.23 当用户查询 bar.example.com 的时候,DNS Server 实际返回的是 foo.example.com 的 IP 地址。\n参考 # DNS 服务器类型: https://www.cloudflare.com/zh-cn/learning/dns/dns-server-types/ DNS Message Resource Record Field Formats: http://www.tcpipguide.com/free/t_DNSMessageResourceRecordFieldFormats-2.htm Understanding Different Types of Record in DNS Server: https://www.mustbegeek.com/understanding-different-types-of-record-in-dns-server/ "},{"id":471,"href":"/zh/docs/technology/Interview/tools/docker/docker-intro/","title":"Docker核心概念总结","section":"Docker","content":"本文只是对 Docker 的概念做了较为详细的介绍,并不涉及一些像 Docker 环境的安装以及 Docker 的一些常见操作和命令。\n容器介绍 # Docker 是世界领先的软件容器平台,所以想要搞懂 Docker 的概念我们必须先从容器开始说起。\n什么是容器? # 先来看看容器较为官方的解释 # 一句话概括容器:容器就是将软件打包成标准化单元,以用于开发、交付和部署。\n容器镜像是轻量的、可执行的独立软件包 ,包含软件运行所需的所有内容:代码、运行时环境、系统工具、系统库和设置。 容器化软件适用于基于 Linux 和 Windows 的应用,在任何环境中都能够始终如一地运行。 容器赋予了软件独立性,使其免受外在环境差异(例如,开发和预演环境的差异)的影响,从而有助于减少团队间在相同基础设施上运行不同软件时的冲突。 再来看看容器较为通俗的解释 # 如果需要通俗地描述容器的话,我觉得容器就是一个存放东西的地方,就像书包可以装各种文具、衣柜可以放各种衣服、鞋架可以放各种鞋子一样。我们现在所说的容器存放的东西可能更偏向于应用比如网站、程序甚至是系统环境。\n图解物理机,虚拟机与容器 # 关于虚拟机与容器的对比在后面会详细介绍到,这里只是通过网上的图片加深大家对于物理机、虚拟机与容器这三者的理解(下面的图片来源于网络)。\n物理机:\n虚拟机:\n容器:\n通过上面这三张抽象图,我们可以大概通过类比概括出:容器虚拟化的是操作系统而不是硬件,容器之间是共享同一套操作系统资源的。虚拟机技术是虚拟出一套硬件后,在其上运行一个完整操作系统。因此容器的隔离级别会稍低一些。\n容器 VS 虚拟机 # 每当说起容器,我们不得不将其与虚拟机做一个比较。就我而言,对于两者无所谓谁会取代谁,而是两者可以和谐共存。\n简单来说:容器和虚拟机具有相似的资源隔离和分配优势,但功能有所不同,因为容器虚拟化的是操作系统,而不是硬件,因此容器更容易移植,效率也更高。\n传统虚拟机技术是虚拟出一套硬件后,在其上运行一个完整操作系统,在该系统上再运行所需应用进程;而容器内的应用进程直接运行于宿主的内核,容器内没有自己的内核,而且也没有进行硬件虚拟。因此容器要比传统虚拟机更为轻便。\n容器和虚拟机的对比:\n容器是一个应用层抽象,用于将代码和依赖资源打包在一起。 多个容器可以在同一台机器上运行,共享操作系统内核,但各自作为独立的进程在用户空间中运行 。与虚拟机相比, 容器占用的空间较少(容器镜像大小通常只有几十兆),瞬间就能完成启动 。\n虚拟机 (VM) 是一个物理硬件层抽象,用于将一台服务器变成多台服务器。管理程序允许多个 VM 在一台机器上运行。每个 VM 都包含一整套操作系统、一个或多个应用、必要的二进制文件和库资源,因此 占用大量空间 。而且 VM 启动也十分缓慢 。\n通过 Docker 官网,我们知道了这么多 Docker 的优势,但是大家也没有必要完全否定虚拟机技术,因为两者有不同的使用场景。虚拟机更擅长于彻底隔离整个运行环境。例如,云服务提供商通常采用虚拟机技术隔离不同的用户。而 Docker 通常用于隔离不同的应用 ,例如前端,后端以及数据库。\n就我而言,对于两者无所谓谁会取代谁,而是两者可以和谐共存。\nDocker 介绍 # 什么是 Docker? # 说实话关于 Docker 是什么并不太好说,下面我通过四点向你说明 Docker 到底是个什么东西。\nDocker 是世界领先的软件容器平台。 Docker 使用 Google 公司推出的 Go 语言 进行开发实现,基于 Linux 内核 提供的 CGroup 功能和 namespace 来实现的,以及 AUFS 类的 UnionFS 等技术,对进程进行封装隔离,属于操作系统层面的虚拟化技术。 由于隔离的进程独立于宿主和其它的隔离的进程,因此也称其为容器。 Docker 能够自动执行重复性任务,例如搭建和配置开发环境,从而解放了开发人员以便他们专注在真正重要的事情上:构建杰出的软件。 用户可以方便地创建和使用容器,把自己的应用放入容器。容器还可以进行版本管理、复制、分享、修改,就像管理普通的代码一样。 Docker 思想:\n集装箱:就像海运中的集装箱一样,Docker 容器包含了应用程序及其所有依赖项,确保在任何环境中都能以相同的方式运行。 ==标准化:==运输方式、存储方式、API 接口。 隔离:每个 Docker 容器都在自己的隔离环境中运行,与宿主机和其他容器隔离。 Docker 容器的特点 # 轻量 : 在一台机器上运行的多个 Docker 容器可以共享这台机器的操作系统内核;它们能够迅速启动,只需占用很少的计算和内存资源。镜像是通过文件系统层进行构造的,并共享一些公共文件。这样就能尽量降低磁盘用量,并能更快地下载镜像。 标准 : Docker 容器基于开放式标准,能够在所有主流 Linux 版本、Microsoft Windows 以及包括 VM、裸机服务器和云在内的任何基础设施上运行。 安全 : Docker 赋予应用的隔离性不仅限于彼此隔离,还独立于底层的基础设施。Docker 默认提供最强的隔离,因此应用出现问题,也只是单个容器的问题,而不会波及到整台机器。 为什么要用 Docker ? # Docker 的镜像提供了除内核外完整的运行时环境,确保了应用运行环境一致性,从而不会再出现 “这段代码在我机器上没问题啊” 这类问题;——一致的运行环境 可以做到秒级、甚至毫秒级的启动时间。大大的节约了开发、测试、部署的时间。——更快速的启动时间 避免公用的服务器,资源会容易受到其他用户的影响。——隔离性 善于处理集中爆发的服务器使用压力;——弹性伸缩,快速扩展 可以很轻易的将在一个平台上运行的应用,迁移到另一个平台上,而不用担心运行环境的变化导致应用无法正常运行的情况。——迁移方便 使用 Docker 可以通过定制应用镜像来实现持续集成、持续交付、部署。——持续交付和部署 Docker 基本概念 # Docker 中有非常重要的三个基本概念:镜像(Image)、容器(Container)和仓库(Repository)。\n理解了这三个概念,就理解了 Docker 的整个生命周期。\n镜像(Image):一个特殊的文件系统 # 操作系统分为内核和用户空间。对于 Linux 而言,内核启动后,会挂载 root 文件系统为其提供用户空间支持。而 Docker 镜像(Image),就相当于是一个 root 文件系统。\nDocker 镜像是一个特殊的文件系统,除了提供容器运行时所需的程序、库、资源、配置等文件外,还包含了一些为运行时准备的一些配置参数(如匿名卷、环境变量、用户等)。 镜像不包含任何动态数据,其内容在构建之后也不会被改变。\nDocker 设计时,就充分利用 Union FS 的技术,将其设计为分层存储的架构 。镜像实际是由多层文件系统联合组成。\n镜像构建时,会一层层构建,前一层是后一层的基础。每一层构建完就不会再发生改变,后一层上的任何改变只发生在自己这一层。 比如,删除前一层文件的操作,实际不是真的删除前一层的文件,而是仅在当前层标记为该文件已删除。在最终容器运行的时候,虽然不会看到这个文件,但是实际上该文件会一直跟随镜像。因此,在构建镜像的时候,需要额外小心,每一层尽量只包含该层需要添加的东西,任何额外的东西应该在该层构建结束前清理掉。\n分层存储的特征还使得镜像的复用、定制变的更为容易。甚至可以用之前构建好的镜像作为基础层,然后进一步添加新的层,以定制自己所需的内容,构建新的镜像。\n容器(Container):镜像运行时的实体 # 镜像(Image)和容器(Container)的关系,就像是面向对象程序设计中的 类 和 实例 一样,镜像是静态的定义,容器是镜像运行时的实体。容器可以被创建、启动、停止、删除、暂停等 。\n容器的实质是进程,但与直接在宿主执行的进程不同,容器进程运行于属于自己的独立的 命名空间。前面讲过镜像使用的是分层存储,容器也是如此。\n容器存储层的生存周期和容器一样,容器消亡时,容器存储层也随之消亡。因此,任何保存于容器存储层的信息都会随容器删除而丢失。\n按照 Docker 最佳实践的要求,容器不应该向其存储层内写入任何数据 ,容器存储层要保持无状态化。所有的文件写入操作,都应该使用数据卷(Volume)、或者绑定宿主目录,在这些位置的读写会跳过容器存储层,直接对宿主(或网络存储)发生读写,其性能和稳定性更高。数据卷的生存周期独立于容器,容器消亡,数据卷不会消亡。因此, 使用数据卷后,容器可以随意删除、重新 run ,数据却不会丢失。\n仓库(Repository):集中存放镜像文件的地方 # 镜像构建完成后,可以很容易的在当前宿主上运行,但是, 如果需要在其它服务器上使用这个镜像,我们就需要一个集中的存储、分发镜像的服务,Docker Registry 就是这样的服务。\n一个 Docker Registry 中可以包含多个仓库(Repository);每个仓库可以包含多个标签(Tag);每个标签对应一个镜像。所以说:镜像仓库是 Docker 用来集中存放镜像文件的地方类似于我们之前常用的代码仓库。\n通常,一个仓库会包含同一个软件不同版本的镜像,而标签就常用于对应该软件的各个版本 。我们可以通过\u0026lt;仓库名\u0026gt;:\u0026lt;标签\u0026gt;的格式来指定具体是这个软件哪个版本的镜像。如果不给出标签,将以 latest 作为默认标签.。\n这里补充一下 Docker Registry 公开服务和私有 Docker Registry 的概念:\nDocker Registry 公开服务 是开放给用户使用、允许用户管理镜像的 Registry 服务。一般这类公开服务允许用户免费上传、下载公开的镜像,并可能提供收费服务供用户管理私有镜像。\n最常使用的 Registry 公开服务是官方的 Docker Hub ,这也是默认的 Registry,并拥有大量的高质量的官方镜像,网址为: https://hub.docker.com/ 。官方是这样介绍 Docker Hub 的:\nDocker Hub 是 Docker 官方提供的一项服务,用于与您的团队查找和共享容器镜像。\n比如我们想要搜索自己想要的镜像:\n在 Docker Hub 的搜索结果中,有几项关键的信息有助于我们选择合适的镜像:\nOFFICIAL Image:代表镜像为 Docker 官方提供和维护,相对来说稳定性和安全性较高。 Stars:和点赞差不多的意思,类似 GitHub 的 Star。 Downloads:代表镜像被拉取的次数,基本上能够表示镜像被使用的频度。 当然,除了直接通过 Docker Hub 网站搜索镜像这种方式外,我们还可以通过 docker search 这个命令搜索 Docker Hub 中的镜像,搜索的结果是一致的。\n➜ ~ docker search mysql NAME DESCRIPTION STARS OFFICIAL AUTOMATED mysql MySQL is a widely used, open-source relation… 8763 [OK] mariadb MariaDB is a community-developed fork of MyS… 3073 [OK] mysql/mysql-server Optimized MySQL Server Docker images. Create… 650 [OK] 在国内访问 Docker Hub 可能会比较慢国内也有一些云服务商提供类似于 Docker Hub 的公开服务。比如 时速云镜像库、 网易云镜像服务、 DaoCloud 镜像市场、 阿里云镜像库等。\n除了使用公开服务外,用户还可以在 本地搭建私有 Docker Registry 。Docker 官方提供了 Docker Registry 镜像,可以直接使用做为私有 Registry 服务。开源的 Docker Registry 镜像只提供了 Docker Registry API 的服务端实现,足以支持 Docker 命令,不影响使用。但不包含图形界面,以及镜像维护、用户管理、访问控制等高级功能。\nImage、Container 和 Repository 的关系 # 下面这一张图很形象地展示了 Image、Container、Repository 和 Registry/Hub 这四者的关系:\nDockerfile 是一个文本文件,包含了一系列的指令和参数,用于定义如何构建一个 Docker 镜像。运行 docker build命令并指定一个 Dockerfile 时,Docker 会读取 Dockerfile 中的指令,逐步构建一个新的镜像,并将其保存在本地。 docker pull 命令可以从指定的 Registry/Hub 下载一个镜像到本地,默认使用 Docker Hub。 docker run 命令可以从本地镜像创建一个新的容器并启动它。如果本地没有镜像,Docker 会先尝试从 Registry/Hub 拉取镜像。 docker push 命令可以将本地的 Docker 镜像上传到指定的 Registry/Hub。 上面涉及到了一些 Docker 的基本命令,后面会详细介绍大。\nBuild Ship and Run # Docker 的概念基本上已经讲完,我们再来谈谈:Build, Ship, and Run。\n如果你搜索 Docker 官网,会发现如下的字样:“Docker - Build, Ship, and Run Any App, Anywhere”。那么 Build, Ship, and Run 到底是在干什么呢?\nBuild(构建镜像):镜像就像是集装箱包括文件以及运行环境等等资源。 Ship(运输镜像):主机和仓库间运输,这里的仓库就像是超级码头一样。 Run (运行镜像):运行的镜像就是一个容器,容器就是运行程序的地方。 Docker 运行过程也就是去仓库把镜像拉到本地,然后用一条命令把镜像运行起来变成容器。所以,我们也常常将 Docker 称为码头工人或码头装卸工,这和 Docker 的中文翻译搬运工人如出一辙。\nDocker 常见命令 # 基本命令 # docker version # 查看docker版本 docker images # 查看所有已下载镜像,等价于:docker image ls 命令 docker container ls # 查看所有容器 docker ps #查看正在运行的容器 docker image prune # 清理临时的、没有被使用的镜像文件。-a, --all: 删除所有没有用的镜像,而不仅仅是临时文件; 拉取镜像 # docker pull 命令默认使用的 Registry/Hub 是 Docker Hub。当你执行 docker pull 命令而没有指定任何 Registry/Hub 的地址时,Docker 会从 Docker Hub 拉取镜像。\ndocker search mysql # 查看mysql相关镜像 docker pull mysql:5.7 # 拉取mysql镜像 docker image ls # 查看所有已下载镜像 构建镜像 # 运行 docker build命令并指定一个 Dockerfile 时,Docker 会读取 Dockerfile 中的指令,逐步构建一个新的镜像,并将其保存在本地。\n# # imageName 是镜像名称,1.0.0 是镜像的版本号或标签 docker build -t imageName:1.0.0 . 需要注意:Dockerfile 的文件名不必须为 Dockerfile,也不一定要放在构建上下文的根目录中。使用 -f 或 --file 选项,可以指定任何位置的任何文件作为 Dockerfile。当然,一般大家习惯性的会使用默认的文件名 Dockerfile,以及会将其置于镜像构建上下文目录中。\n删除镜像 # 比如我们要删除我们下载的 mysql 镜像。\n通过 docker rmi [image] (等价于docker image rm [image])删除镜像之前首先要确保这个镜像没有被容器引用(可以通过标签名称或者镜像 ID 删除)。通过我们前面讲的docker ps命令即可查看。\n➜ ~ docker ps CONTAINER ID IMAGE COMMAND CREATED STATUS PORTS NAMES c4cd691d9f80 mysql:5.7 \u0026#34;docker-entrypoint.s…\u0026#34; 7 weeks ago Up 12 days 0.0.0.0:3306-\u0026gt;3306/tcp, 33060/tcp mysql 可以看到 mysql 正在被 id 为 c4cd691d9f80 的容器引用,我们需要首先通过 docker stop c4cd691d9f80 或者 docker stop mysql暂停这个容器。\n然后查看 mysql 镜像的 id\n➜ ~ docker images REPOSITORY TAG IMAGE ID CREATED SIZE mysql 5.7 f6509bac4980 3 months ago 373MB 通过 IMAGE ID 或者 REPOSITORY 名字即可删除\ndocker rmi f6509bac4980 # 或者 docker rmi mysql 镜像推送 # docker push 命令用于将本地的 Docker 镜像上传到指定的 Registry/Hub。\n# 将镜像推送到私有镜像仓库 Harbor # harbor.example.com是私有镜像仓库的地址,ubuntu是镜像的名称,18.04是镜像的版本标签 docker push harbor.example.com/ubuntu:18.04 镜像推送之前,要确保本地已经构建好需要推送的 Docker 镜像。另外,务必先登录到对应的镜像仓库。\nDocker 数据管理 # 在容器中管理数据主要有两种方式:\n数据卷(Volumes) 挂载主机目录 (Bind mounts) 数据卷是由 Docker 管理的数据存储区域,有如下这些特点:\n可以在容器之间共享和重用。 即使容器被删除,数据卷中的数据也不会被自动删除,从而确保数据的持久性。 对数据卷的修改会立马生效。 对数据卷的更新,不会影响镜像。 # 创建一个数据卷 docker volume create my-vol # 查看所有的数据卷 docker volume ls # 查看数据卷的具体信息 docker inspect web # 删除指定的数据卷 docker volume rm my-vol 在用 docker run 命令的时候,使用 --mount 标记来将一个或多个数据卷挂载到容器里。\n还可以通过 --mount 标记将宿主机上的文件或目录挂载到容器中,这使得容器可以直接访问宿主机的文件系统。Docker 挂载主机目录的默认权限是读写,用户也可以通过增加 readonly 指定为只读。\nDocker Compose # 什么是 Docker Compose?有什么用? # Docker Compose 是 Docker 官方编排(Orchestration)项目之一,基于 Python 编写,负责实现对 Docker 容器集群的快速编排。通过 Docker Compose,开发者可以使用 YAML 文件来配置应用的所有服务,然后只需一个简单的命令即可创建和启动所有服务。\nDocker Compose 是开源项目,地址: https://github.com/docker/compose。\nDocker Compose 的核心功能:\n多容器管理:允许用户在一个 YAML 文件中定义和管理多个容器。 服务编排:配置容器间的网络和依赖关系。 一键部署:通过简单的命令,如docker-compose up和docker-compose down,可以轻松地启动和停止整个应用程序。 Docker Compose 简化了多容器应用程序的开发、测试和部署过程,提高了开发团队的生产力,同时降低了应用程序的部署复杂度和管理成本。\nDocker Compose 文件基本结构 # Docker Compose 文件是 Docker Compose 工具的核心,用于定义和配置多容器 Docker 应用。这个文件通常命名为 docker-compose.yml,采用 YAML(YAML Ain\u0026rsquo;t Markup Language)格式编写。\nDocker Compose 文件基本结构如下:\n版本(version): 指定 Compose 文件格式的版本。版本决定了可用的配置选项。 服务(services): 定义了应用中的每个容器(服务)。每个服务可以使用不同的镜像、环境设置和依赖关系。 镜像(image): 从指定的镜像中启动容器,可以是存储仓库、标签以及镜像 ID。 命令(command): 可选,覆盖容器启动后默认执行的命令。在启动服务时运行特定的命令或脚本,常用于启动应用程序、执行初始化脚本等。 端口(ports): 可选,映射容器和宿主机的端口。 依赖(depends_on): 依赖配置的选项,意思是如果服务启动是如果有依赖于其他服务的,先启动被依赖的服务,启动完成后在启动该服务。 环境变量(environment): 可选,设置服务运行所需的环境变量。 重启(restart): 可选,控制容器的重启策略。在容器退出时,根据指定的策略自动重启容器。 服务卷(volumes): 可选,定义服务使用的卷,用于数据持久化或在容器之间共享数据。 构建(build): 指定构建镜像的 dockerfile 的上下文路径,或者详细配置对象。 网络(networks): 定义了容器间的网络连接。 卷(volumes): 用于数据持久化和共享的数据卷定义。常用于数据库存储、配置文件、日志等数据的持久化。 version: \u0026#34;3.8\u0026#34; # 定义版本, 表示当前使用的 docker-compose 语法的版本 services: # 服务,可以存在多个 servicename1: # 服务名字,它也是内部 bridge 网络可以使用的 DNS name,如果不是集群模式相当于 docker run 的时候指定的一个名称, #集群(Swarm)模式是多个容器的逻辑抽象 image: # 镜像的名字 command: # 可选,如果设置,则会覆盖默认镜像里的 CMD 命令 environment: # 可选,等价于 docker container run 里的 --env 选项设置环境变量 volumes: # 可选,等价于 docker container run 里的 -v 选项 绑定数据卷 networks: # 可选,等价于 docker container run 里的 --network 选项指定网络 ports: # 可选,等价于 docker container run 里的 -p 选项指定端口映射 restart: # 可选,控制容器的重启策略 build: #构建目录 depends_on: #服务依赖配置 servicename2: image: command: networks: ports: servicename3: #... volumes: # 可选,需要创建的数据卷,类似 docker volume create db_data: networks: # 可选,等价于 docker network create Docker Compose 常见命令 # 启动 # docker-compose up会根据 docker-compose.yml 文件中定义的服务来创建和启动容器,并将它们连接到默认的网络中。\n# 在当前目录下寻找 docker-compose.yml 文件,并根据其中定义的服务启动应用程序 docker-compose up # 后台启动 docker-compose up -d # 强制重新创建所有容器,即使它们已经存在 docker-compose up --force-recreate # 重新构建镜像 docker-compose up --build # 指定要启动的服务名称,而不是启动所有服务 # 可以同时指定多个服务,用空格分隔。 docker-compose up service_name 另外,如果 Compose 文件名称不是 docker-compose.yml 也没问题,可以通过 -f 参数指定。\ndocker-compose -f docker-compose.prod.yml up 暂停 # docker-compose down用于停止并移除通过 docker-compose up 启动的容器和网络。\n# 在当前目录下寻找 docker-compose.yml 文件 # 根据其中定义移除启动的所有容器,网络和卷。 docker-compose down # 停止容器但不移除 docker-compose down --stop # 指定要停止和移除的特定服务,而不是停止和移除所有服务 # 可以同时指定多个服务,用空格分隔。 docker-compose down service_name 同样地,如果 Compose 文件名称不是 docker-compose.yml 也没问题,可以通过 -f 参数指定。\ndocker-compose -f docker-compose.prod.yml down 查看 # docker-compose ps用于查看通过 docker-compose up 启动的所有容器的状态信息。\n# 查看所有容器的状态信息 docker-compose ps # 只显示服务名称 docker-compose ps --services # 查看指定服务的容器 docker-compose ps service_name 其他 # 命令 介绍 docker-compose version 查看版本 docker-compose images 列出所有容器使用的镜像 docker-compose kill 强制停止服务的容器 docker-compose exec 在容器中执行命令 docker-compose logs 查看日志 docker-compose pause 暂停服务 docker-compose unpause 恢复服务 docker-compose push 推送服务镜像 docker-compose start 启动当前停止的某个容器 docker-compose stop 停止当前运行的某个容器 docker-compose rm 删除服务停止的容器 docker-compose top 查看进程 Docker 底层原理 # 首先,Docker 是基于轻量级虚拟化技术的软件,那什么是虚拟化技术呢?\n简单点来说,虚拟化技术可以这样定义:\n虚拟化技术是一种资源管理技术,是将计算机的各种 实体资源)( CPU、 内存、 磁盘空间、 网络适配器等),予以抽象、转换后呈现出来并可供分割、组合为一个或多个电脑配置环境。由此,打破实体结构间的不可切割的障碍,使用户可以比原本的配置更好的方式来应用这些电脑硬件资源。这些资源的新虚拟部分是不受现有资源的架设方式,地域或物理配置所限制。一般所指的虚拟化资源包括计算能力和数据存储。\nDocker 技术是基于 LXC(Linux container- Linux 容器)虚拟容器技术的。\nLXC,其名称来自 Linux 软件容器(Linux Containers)的缩写,一种操作系统层虚拟化(Operating system–level virtualization)技术,为 Linux 内核容器功能的一个用户空间接口。它将应用软件系统打包成一个软件容器(Container),内含应用软件本身的代码,以及所需要的操作系统核心和库。通过统一的名字空间和共用 API 来分配不同软件容器的可用硬件资源,创造出应用程序的独立沙箱运行环境,使得 Linux 用户可以容易的创建和管理系统或应用容器。\nLXC 技术主要是借助 Linux 内核中提供的 CGroup 功能和 namespace 来实现的,通过 LXC 可以为软件提供一个独立的操作系统运行环境。\ncgroup 和 namespace 介绍:\nnamespace 是 Linux 内核用来隔离内核资源的方式。 通过 namespace 可以让一些进程只能看到与自己相关的一部分资源,而另外一些进程也只能看到与它们自己相关的资源,这两拨进程根本就感觉不到对方的存在。具体的实现方式是把一个或多个进程的相关资源指定在同一个 namespace 中。Linux namespaces 是对全局系统资源的一种封装隔离,使得处于不同 namespace 的进程拥有独立的全局系统资源,改变一个 namespace 中的系统资源只会影响当前 namespace 里的进程,对其他 namespace 中的进程没有影响。\n(以上关于 namespace 介绍内容来自 https://www.cnblogs.com/sparkdev/p/9365405.html ,更多关于 namespace 的内容可以查看这篇文章 )。\nCGroup 是 Control Groups 的缩写,是 Linux 内核提供的一种可以限制、记录、隔离进程组 (process groups) 所使用的物理资源 (如 cpu memory i/o 等等) 的机制。\n(以上关于 CGroup 介绍内容来自 https://www.ibm.com/developerworks/cn/linux/1506_cgroup/index.html ,更多关于 CGroup 的内容可以查看这篇文章 )。\ncgroup 和 namespace 两者对比:\n两者都是将进程进行分组,但是两者的作用还是有本质区别。namespace 是为了隔离进程组之间的资源,而 cgroup 是为了对一组进程进行统一的资源监控和限制。\n总结 # 本文主要把 Docker 中的一些常见概念和命令做了详细的阐述。从零到上手实战可以看 Docker 从入门到上手干事这篇文章,内容非常详细!\n另外,再给大家推荐一本质量非常高的开源书籍 《Docker 从入门到实践》 ,这本书的内容非常新,毕竟书籍的内容是开源的,可以随时改进。\n参考 # Docker Compose:从零基础到实战应用的全面指南 Linux Namespace 和 Cgroup LXC vs Docker: Why Docker is Better CGroup 介绍、应用实例及原理描述 "},{"id":472,"href":"/zh/docs/technology/Interview/tools/docker/docker-in-action/","title":"Docker实战","section":"Docker","content":" Docker 介绍 # 开始之前,还是简单介绍一下 Docker,更多 Docker 概念介绍可以看前一篇文章 Docker 核心概念总结。\n什么是 Docker? # 说实话关于 Docker 是什么并不太好说,下面我通过四点向你说明 Docker 到底是个什么东西。\nDocker 是世界领先的软件容器平台,基于 Go 语言 进行开发实现。 Docker 能够自动执行重复性任务,例如搭建和配置开发环境,从而解放开发人员。 用户可以方便地创建和使用容器,把自己的应用放入容器。容器还可以进行版本管理、复制、分享、修改,就像管理普通的代码一样。 Docker 可以对进程进行封装隔离,属于操作系统层面的虚拟化技术。 由于隔离的进程独立于宿主和其它的隔离的进程,因此也称其为容器。 官网地址: https://www.docker.com/ 。\n为什么要用 Docker? # Docker 可以让开发者打包他们的应用以及依赖包到一个轻量级、可移植的容器中,然后发布到任何流行的 Linux 机器上,也可以实现虚拟化。\n容器是完全使用沙箱机制,相互之间不会有任何接口(类似 iPhone 的 app),更重要的是容器性能开销极低。\n传统的开发流程中,我们的项目通常需要使用 MySQL、Redis、FastDFS 等等环境,这些环境都是需要我们手动去进行下载并配置的,安装配置流程极其复杂,而且不同系统下的操作也不一样。\nDocker 的出现完美地解决了这一问题,我们可以在容器中安装 MySQL、Redis 等软件环境,使得应用和环境架构分开,它的优势在于:\n一致的运行环境,能够更轻松地迁移 对进程进行封装隔离,容器与容器之间互不影响,更高效地利用系统资源 可以通过镜像复制多个一致的容器 另外, 《Docker 从入门到实践》 这本开源书籍中也已经给出了使用 Docker 的原因。\nDocker 的安装 # Windows # 接下来对 Docker 进行安装,以 Windows 系统为例,访问 Docker 的官网:\n然后点击Get Started:\n在此处点击Download for Windows即可进行下载。\n如果你的电脑是Windows 10 64位专业版的操作系统,则在安装 Docker 之前需要开启一下Hyper-V,开启方式如下。打开控制面板,选择程序:\n点击启用或关闭Windows功能:\n勾选上Hyper-V,点击确定即可:\n完成更改后需要重启一下计算机。\n开启了Hyper-V后,我们就可以对 Docker 进行安装了,打开安装程序后,等待片刻点击Ok即可:\n安装完成后,我们仍然需要重启计算机,重启后,若提示如下内容:\n它的意思是询问我们是否使用 WSL2,这是基于 Windows 的一个 Linux 子系统,这里我们取消即可,它就会使用我们之前勾选的Hyper-V虚拟机。\n因为是图形界面的操作,这里就不介绍 Docker Desktop 的具体用法了。\nMac # 直接使用 Homebrew 安装即可\nbrew install --cask docker Linux # 下面来看看 Linux 中如何安装 Docker,这里以 CentOS7 为例。\n在测试或开发环境中,Docker 官方为了简化安装流程,提供了一套便捷的安装脚本,执行这个脚本后就会自动地将一切准备工作做好,并且把 Docker 的稳定版本安装在系统中。\ncurl -fsSL get.docker.com -o get-docker.sh sh get-docker.sh --mirror Aliyun 安装完成后直接启动服务:\nsystemctl start docker 推荐设置开机自启,执行指令:\nsystemctl enable docker Docker 中的几个概念 # 在正式学习 Docker 之前,我们需要了解 Docker 中的几个核心概念:\n镜像 # 镜像就是一个只读的模板,镜像可以用来创建 Docker 容器,一个镜像可以创建多个容器\n容器 # 容器是用镜像创建的运行实例,Docker 利用容器独立运行一个或一组应用。它可以被启动、开始、停止、删除,每个容器都是相互隔离的、保证安全的平台。 可以把容器看作是一个简易的 Linux 环境和运行在其中的应用程序。容器的定义和镜像几乎一模一样,也是一堆层的统一视角,唯一区别在于容器的最上面那一层是可读可写的\n仓库 # 仓库是集中存放镜像文件的场所。仓库和仓库注册服务器是有区别的,仓库注册服务器上往往存放着多个仓库,每个仓库中又包含了多个镜像,每个镜像有不同的标签。 仓库分为公开仓库和私有仓库两种形式,最大的公开仓库是 DockerHub,存放了数量庞大的镜像供用户下载,国内的公开仓库有阿里云、网易云等\n总结 # 通俗点说,一个镜像就代表一个软件;而基于某个镜像运行就是生成一个程序实例,这个程序实例就是容器;而仓库是用来存储 Docker 中所有镜像的。\n其中仓库又分为远程仓库和本地仓库,和 Maven 类似,倘若每次都从远程下载依赖,则会大大降低效率,为此,Maven 的策略是第一次访问依赖时,将其下载到本地仓库,第二次、第三次使用时直接用本地仓库的依赖即可,Docker 的远程仓库和本地仓库的作用也是类似的。\nDocker 初体验 # 下面我们来对 Docker 进行一个初步的使用,这里以下载一个 MySQL 的镜像为例(在CentOS7下进行)。\n和 GitHub 一样,Docker 也提供了一个 DockerHub 用于查询各种镜像的地址和安装教程,为此,我们先访问 DockerHub: https://hub.docker.com/\n在左上角的搜索框中输入MySQL并回车:\n可以看到相关 MySQL 的镜像非常多,若右上角有OFFICIAL IMAGE标识,则说明是官方镜像,所以我们点击第一个 MySQL 镜像:\n右边提供了下载 MySQL 镜像的指令为docker pull MySQL,但该指令始终会下载 MySQL 镜像的最新版本。\n若是想下载指定版本的镜像,则点击下面的View Available Tags:\n这里就可以看到各种版本的镜像,右边有下载的指令,所以若是想下载 5.7.32 版本的 MySQL 镜像,则执行:\ndocker pull MySQL:5.7.32 然而下载镜像的过程是非常慢的,所以我们需要配置一下镜像源加速下载,访问阿里云官网,点击控制台:\n然后点击左上角的菜单,在弹窗的窗口中,将鼠标悬停在产品与服务上,并在右侧搜索容器镜像服务,最后点击容器镜像服务:\n点击左侧的镜像加速器,并依次执行右侧的配置指令即可。\nsudo mkdir -p /etc/docker sudo tee /etc/docker/daemon.json \u0026lt;\u0026lt;-\u0026#39;EOF\u0026#39; { \u0026#34;registry-mirrors\u0026#34;: [\u0026#34;https://679xpnpz.mirror.aliyuncs.com\u0026#34;] } EOF sudo systemctl daemon-reload sudo systemctl restart docker Docker 镜像指令 # Docker 需要频繁地操作相关的镜像,所以我们先来了解一下 Docker 中的镜像指令。\n若想查看 Docker 中当前拥有哪些镜像,则可以使用 docker images 命令。\n[root@izrcf5u3j3q8xaz ~]# docker images REPOSITORY TAG IMAGE ID CREATED SIZE MySQL 5.7.32 f07dfa83b528 11 days ago 448MB tomcat latest feba8d001e3f 2 weeks ago 649MB nginx latest ae2feff98a0c 2 weeks ago 133MB hello-world latest bf756fb1ae65 12 months ago 13.3kB 其中REPOSITORY为镜像名,TAG为版本标志,IMAGE ID为镜像 id(唯一的),CREATED为创建时间,注意这个时间并不是我们将镜像下载到 Docker 中的时间,而是镜像创建者创建的时间,SIZE为镜像大小。\n该指令能够查询指定镜像名:\ndocker image MySQL 若如此做,则会查询出 Docker 中的所有 MySQL 镜像:\n[root@izrcf5u3j3q8xaz ~]# docker images MySQL REPOSITORY TAG IMAGE ID CREATED SIZE MySQL 5.6 0ebb5600241d 11 days ago 302MB MySQL 5.7.32 f07dfa83b528 11 days ago 448MB MySQL 5.5 d404d78aa797 20 months ago 205MB 该指令还能够携带-q参数:docker images -q , -q表示仅显示镜像的 id:\n[root@izrcf5u3j3q8xaz ~]# docker images -q 0ebb5600241d f07dfa83b528 feba8d001e3f d404d78aa797 若是要下载镜像,则使用:\ndocker pull MySQL:5.7 docker pull是固定的,后面写上需要下载的镜像名及版本标志;若是不写版本标志,而是直接执行docker pull MySQL,则会下载镜像的最新版本。\n一般在下载镜像前我们需要搜索一下镜像有哪些版本才能对指定版本进行下载,使用指令:\ndocker search MySQL 不过该指令只能查看 MySQL 相关的镜像信息,而不能知道有哪些版本,若想知道版本,则只能这样查询:\ndocker search MySQL:5.5 若是查询的版本不存在,则结果为空:\n删除镜像使用指令:\ndocker image rm MySQL:5.5 若是不指定版本,则默认删除的也是最新版本。\n还可以通过指定镜像 id 进行删除:\ndocker image rm bf756fb1ae65 然而此时报错了:\n[root@izrcf5u3j3q8xaz ~]# docker image rm bf756fb1ae65 Error response from daemon: conflict: unable to delete bf756fb1ae65 (must be forced) - image is being used by stopped container d5b6c177c151 这是因为要删除的hello-world镜像正在运行中,所以无法删除镜像,此时需要强制执行删除:\ndocker image rm -f bf756fb1ae65 该指令会将镜像和通过该镜像执行的容器全部删除,谨慎使用。\nDocker 还提供了删除镜像的简化版本:docker rmi 镜像名:版本标志 。\n此时我们即可借助rmi和-q进行一些联合操作,比如现在想删除所有的 MySQL 镜像,那么你需要查询出 MySQL 镜像的 id,并根据这些 id 一个一个地执行docker rmi进行删除,但是现在,我们可以这样:\ndocker rmi -f $(docker images MySQL -q) 首先通过docker images MySQL -q查询出 MySQL 的所有镜像 id,-q表示仅查询 id,并将这些 id 作为参数传递给docker rmi -f指令,这样所有的 MySQL 镜像就都被删除了。\nDocker 容器指令 # 掌握了镜像的相关指令之后,我们需要了解一下容器的指令,容器是基于镜像的。\n若需要通过镜像运行一个容器,则使用:\ndocker run tomcat:8.0-jre8 当然了,运行的前提是你拥有这个镜像,所以先下载镜像:\ndocker pull tomcat:8.0-jre8 下载完成后就可以运行了,运行后查看一下当前运行的容器:docker ps 。\n其中CONTAINER_ID为容器的 id,IMAGE为镜像名,COMMAND为容器内执行的命令,CREATED为容器的创建时间,STATUS为容器的状态,PORTS为容器内服务监听的端口,NAMES为容器的名称。\n通过该方式运行的 tomcat 是不能直接被外部访问的,因为容器具有隔离性,若是想直接通过 8080 端口访问容器内部的 tomcat,则需要对宿主机端口与容器内的端口进行映射:\ndocker run -p 8080:8080 tomcat:8.0-jre8 解释一下这两个端口的作用(8080:8080),第一个 8080 为宿主机端口,第二个 8080 为容器内的端口,外部访问 8080 端口就会通过映射访问容器内的 8080 端口。\n此时外部就可以访问 Tomcat 了:\n若是这样进行映射:\ndocker run -p 8088:8080 tomcat:8.0-jre8 则外部需访问 8088 端口才能访问 tomcat,需要注意的是,每次运行的容器都是相互独立的,所以同时运行多个 tomcat 容器并不会产生端口的冲突。\n容器还能够以后台的方式运行,这样就不会占用终端:\ndocker run -d -p 8080:8080 tomcat:8.0-jre8 启动容器时默认会给容器一个名称,但这个名称其实是可以设置的,使用指令:\ndocker run -d -p 8080:8080 --name tomcat01 tomcat:8.0-jre8 此时的容器名称即为 tomcat01,容器名称必须是唯一的。\n再来引申一下docker ps中的几个指令参数,比如-a:\ndocker ps -a 该参数会将运行和非运行的容器全部列举出来。\n-q参数将只查询正在运行的容器 id:docker ps -q 。\n[root@izrcf5u3j3q8xaz ~]# docker ps -q f3aac8ee94a3 074bf575249b 1d557472a708 4421848ba294 若是组合使用,则查询运行和非运行的所有容器 id:docker ps -qa 。\n[root@izrcf5u3j3q8xaz ~]# docker ps -aq f3aac8ee94a3 7f7b0e80c841 074bf575249b a1e830bddc4c 1d557472a708 4421848ba294 b0440c0a219a c2f5d78c5d1a 5831d1bab2a6 d5b6c177c151 接下来是容器的停止、重启指令,因为非常简单,就不过多介绍了。\ndocker start c2f5d78c5d1a 通过该指令能够将已经停止运行的容器运行起来,可以通过容器的 id 启动,也可以通过容器的名称启动。\ndocker restart c2f5d78c5d1a 该指令能够重启指定的容器。\ndocker stop c2f5d78c5d1a 该指令能够停止指定的容器。\ndocker kill c2f5d78c5d1a 该指令能够直接杀死指定的容器。\n以上指令都能够通过容器的 id 和容器名称两种方式配合使用。\n当容器被停止之后,容器虽然不再运行了,但仍然是存在的,若是想删除它,则使用指令:\ndocker rm d5b6c177c151 需要注意的是容器的 id 无需全部写出来,只需唯一标识即可。\n若是想删除正在运行的容器,则需要添加-f参数强制删除:\ndocker rm -f d5b6c177c151 若是想删除所有容器,则可以使用组合指令:\ndocker rm -f $(docker ps -qa) 先通过docker ps -qa查询出所有容器的 id,然后通过docker rm -f进行删除。\n当容器以后台的方式运行时,我们无法知晓容器的运行状态,若此时需要查看容器的运行日志,则使用指令:\ndocker logs 289cc00dc5ed 这样的方式显示的日志并不是实时的,若是想实时显示,需要使用-f参数:\ndocker logs -f 289cc00dc5ed 通过-t参数还能够显示日志的时间戳,通常与-f参数联合使用:\ndocker logs -ft 289cc00dc5ed 查看容器内运行了哪些进程,可以使用指令:\ndocker top 289cc00dc5ed 若是想与容器进行交互,则使用指令:\ndocker exec -it 289cc00dc5ed bash 此时终端将会进入容器内部,执行的指令都将在容器中生效,在容器内只能执行一些比较简单的指令,如:ls、cd 等,若是想退出容器终端,重新回到 CentOS 中,则执行exit即可。\n现在我们已经能够进入容器终端执行相关操作了,那么该如何向 tomcat 容器中部署一个项目呢?\ndocker cp ./test.html 289cc00dc5ed:/usr/local/tomcat/webapps 通过docker cp指令能够将文件从 CentOS 复制到容器中,./test.html为 CentOS 中的资源路径,289cc00dc5ed为容器 id,/usr/local/tomcat/webapps为容器的资源路径,此时test.html文件将会被复制到该路径下。\n[root@izrcf5u3j3q8xaz ~]# docker exec -it 289cc00dc5ed bash root@289cc00dc5ed:/usr/local/tomcat# cd webapps root@289cc00dc5ed:/usr/local/tomcat/webapps# ls test.html root@289cc00dc5ed:/usr/local/tomcat/webapps# 若是想将容器内的文件复制到 CentOS 中,则反过来写即可:\ndocker cp 289cc00dc5ed:/usr/local/tomcat/webapps/test.html ./ 所以现在若是想要部署项目,则先将项目上传到 CentOS,然后将项目从 CentOS 复制到容器内,此时启动容器即可。\n虽然使用 Docker 启动软件环境非常简单,但同时也面临着一个问题,我们无法知晓容器内部具体的细节,比如监听的端口、绑定的 ip 地址等等,好在这些 Docker 都帮我们想到了,只需使用指令:\ndocker inspect 923c969b0d91 Docker 数据卷 # 学习了容器的相关指令之后,我们来了解一下 Docker 中的数据卷,它能够实现宿主机与容器之间的文件共享,它的好处在于我们对宿主机的文件进行修改将直接影响容器,而无需再将宿主机的文件再复制到容器中。\n现在若是想将宿主机中/opt/apps目录与容器中webapps目录做一个数据卷,则应该这样编写指令:\ndocker run -d -p 8080:8080 --name tomcat01 -v /opt/apps:/usr/local/tomcat/webapps tomcat:8.0-jre8 然而此时访问 tomcat 会发现无法访问:\n这就说明我们的数据卷设置成功了,Docker 会将容器内的webapps目录与/opt/apps目录进行同步,而此时/opt/apps目录是空的,导致webapps目录也会变成空目录,所以就访问不到了。\n此时我们只需向/opt/apps目录下添加文件,就会使得webapps目录也会拥有相同的文件,达到文件共享,测试一下:\n[root@centos-7 opt]# cd apps/ [root@centos-7 apps]# vim test.html [root@centos-7 apps]# ls test.html [root@centos-7 apps]# cat test.html \u0026lt;h1\u0026gt;This is a test html!\u0026lt;/h1\u0026gt; 在/opt/apps目录下创建了一个 test.html 文件,那么容器内的webapps目录是否会有该文件呢?进入容器的终端:\n[root@centos-7 apps]# docker exec -it tomcat01 bash root@115155c08687:/usr/local/tomcat# cd webapps/ root@115155c08687:/usr/local/tomcat/webapps# ls test.html 容器内确实已经有了该文件,那接下来我们编写一个简单的 Web 应用:\npublic class HelloServlet extends HttpServlet { @Override protected void doGet(HttpServletRequest req, HttpServletResponse resp) throws ServletException, IOException { resp.getWriter().println(\u0026#34;Hello World!\u0026#34;); } @Override protected void doPost(HttpServletRequest req, HttpServletResponse resp) throws ServletException, IOException { doGet(req,resp); } } 这是一个非常简单的 Servlet,我们将其打包上传到/opt/apps中,那么容器内肯定就会同步到该文件,此时进行访问:\n这种方式设置的数据卷称为自定义数据卷,因为数据卷的目录是由我们自己设置的,Docker 还为我们提供了另外一种设置数据卷的方式:\ndocker run -d -p 8080:8080 --name tomcat01 -v aa:/usr/local/tomcat/webapps tomcat:8.0-jre8 此时的aa并不是数据卷的目录,而是数据卷的别名,Docker 会为我们自动创建一个名为aa的数据卷,并且会将容器内webapps目录下的所有内容复制到数据卷中,该数据卷的位置在/var/lib/docker/volumes目录下:\n[root@centos-7 volumes]# pwd /var/lib/docker/volumes [root@centos-7 volumes]# cd aa/ [root@centos-7 aa]# ls _data [root@centos-7 aa]# cd _data/ [root@centos-7 _data]# ls docs examples host-manager manager ROOT 此时我们只需修改该目录的内容就能能够影响到容器。\n最后再介绍几个容器和镜像相关的指令:\ndocker commit -m \u0026#34;描述信息\u0026#34; -a \u0026#34;镜像作者\u0026#34; tomcat01 my_tomcat:1.0 该指令能够将容器打包成一个镜像,此时查询镜像:\n[root@centos-7 _data]# docker images REPOSITORY TAG IMAGE ID CREATED SIZE my_tomcat 1.0 79ab047fade5 2 seconds ago 463MB tomcat 8 a041be4a5ba5 2 weeks ago 533MB MySQL latest db2b37ec6181 2 months ago 545MB 若是想将镜像备份出来,则可以使用指令:\ndocker save my_tomcat:1.0 -o my-tomcat-1.0.tar [root@centos-7 ~]# docker save my_tomcat:1.0 -o my-tomcat-1.0.tar [root@centos-7 ~]# ls anaconda-ks.cfg initial-setup-ks.cfg 公共 视频 文档 音乐 get-docker.sh my-tomcat-1.0.tar 模板 图片 下载 桌面 若是拥有.tar格式的镜像,该如何将其加载到 Docker 中呢?执行指令:\ndocker load -i my-tomcat-1.0.tar root@centos-7 ~]# docker load -i my-tomcat-1.0.tar b28ef0b6fef8: Loading layer [==================================================\u0026gt;] 105.5MB/105.5MB 0b703c74a09c: Loading layer [==================================================\u0026gt;] 23.99MB/23.99MB ...... Loaded image: my_tomcat:1.0 [root@centos-7 ~]# docker images REPOSITORY TAG IMAGE ID CREATED SIZE my_tomcat 1.0 79ab047fade5 7 minutes ago 463MB "},{"id":473,"href":"/zh/docs/technology/Interview/distributed-system/rpc/dubbo/","title":"Dubbo常见问题总结","section":"Rpc","content":"::: tip\nDubbo3 已经发布,这篇文章是基于 Dubbo2 写的。Dubbo3 基于 Dubbo2 演进而来,在保持原有核心功能特性的同时, Dubbo3 在易用性、超大规模微服务实践、云原生基础设施适配、安全设计等几大方向上进行了全面升级。 本文中的很多链接已经失效,主要原因是因为 Dubbo 官方文档进行了修改导致 URL 失效。 :::\n这篇文章是我根据官方文档以及自己平时的使用情况,对 Dubbo 所做的一个总结。欢迎补充!\nDubbo 基础 # 什么是 Dubbo? # Apache Dubbo |ˈdʌbəʊ| 是一款高性能、轻量级的开源 WEB 和 RPC 框架。\n根据 Dubbo 官方文档的介绍,Dubbo 提供了六大核心能力\n面向接口代理的高性能 RPC 调用。 智能容错和负载均衡。 服务自动注册和发现。 高度可扩展能力。 运行期流量调度。 可视化的服务治理与运维。 简单来说就是:Dubbo 不光可以帮助我们调用远程服务,还提供了一些其他开箱即用的功能比如智能负载均衡。\nDubbo 目前已经有接近 34.4 k 的 Star 。\n在 2020 年度 OSC 中国开源项目 评选活动中,Dubbo 位列开发框架和基础组件类项目的第 7 名。相比几年前来说,热度和排名有所下降。\nDubbo 是由阿里开源,后来加入了 Apache 。正是由于 Dubbo 的出现,才使得越来越多的公司开始使用以及接受分布式架构。\n为什么要用 Dubbo? # 随着互联网的发展,网站的规模越来越大,用户数量越来越多。单一应用架构、垂直应用架构无法满足我们的需求,这个时候分布式服务架构就诞生了。\n分布式服务架构下,系统被拆分成不同的服务比如短信服务、安全服务,每个服务独立提供系统的某个核心服务。\n我们可以使用 Java RMI(Java Remote Method Invocation)、Hessian 这种支持远程调用的框架来简单地暴露和引用远程服务。但是!当服务越来越多之后,服务调用关系越来越复杂。当应用访问压力越来越大后,负载均衡以及服务监控的需求也迫在眉睫。我们可以用 F5 这类硬件来做负载均衡,但这样增加了成本,并且存在单点故障的风险。\n不过,Dubbo 的出现让上述问题得到了解决。Dubbo 帮助我们解决了什么问题呢?\n负载均衡:同一个服务部署在不同的机器时该调用哪一台机器上的服务。 服务调用链路生成:随着系统的发展,服务越来越多,服务间依赖关系变得错踪复杂,甚至分不清哪个应用要在哪个应用之前启动,架构师都不能完整的描述应用的架构关系。Dubbo 可以为我们解决服务之间互相是如何调用的。 服务访问压力以及时长统计、资源调度和治理:基于访问压力实时管理集群容量,提高集群利用率。 …… 另外,Dubbo 除了能够应用在分布式系统中,也可以应用在现在比较火的微服务系统中。不过,由于 Spring Cloud 在微服务中应用更加广泛,所以,我觉得一般我们提 Dubbo 的话,大部分是分布式系统的情况。\n我们刚刚提到了分布式这个概念,下面再给大家介绍一下什么是分布式?为什么要分布式?\n分布式基础 # 什么是分布式? # 分布式或者说 SOA 分布式重要的就是面向服务,说简单的分布式就是我们把整个系统拆分成不同的服务然后将这些服务放在不同的服务器上减轻单体服务的压力提高并发量和性能。比如电商系统可以简单地拆分成订单系统、商品系统、登录系统等等,拆分之后的每个服务可以部署在不同的机器上,如果某一个服务的访问量比较大的话也可以将这个服务同时部署在多台机器上。\n为什么要分布式? # 从开发角度来讲单体应用的代码都集中在一起,而分布式系统的代码根据业务被拆分。所以,每个团队可以负责一个服务的开发,这样提升了开发效率。另外,代码根据业务拆分之后更加便于维护和扩展。\n另外,我觉得将系统拆分成分布式之后不光便于系统扩展和维护,更能提高整个系统的性能。你想一想嘛?把整个系统拆分成不同的服务/系统,然后每个服务/系统 单独部署在一台服务器上,是不是很大程度上提高了系统性能呢?\nDubbo 架构 # Dubbo 架构中的核心角色有哪些? # 官方文档中的框架设计章节 已经介绍的非常详细了,我这里把一些比较重要的点再提一下。\n上述节点简单介绍以及他们之间的关系:\nContainer: 服务运行容器,负责加载、运行服务提供者。必须。 Provider: 暴露服务的服务提供方,会向注册中心注册自己提供的服务。必须。 Consumer: 调用远程服务的服务消费方,会向注册中心订阅自己所需的服务。必须。 Registry: 服务注册与发现的注册中心。注册中心会返回服务提供者地址列表给消费者。非必须。 Monitor: 统计服务的调用次数和调用时间的监控中心。服务消费者和提供者会定时发送统计数据到监控中心。 非必须。 Dubbo 中的 Invoker 概念了解么? # Invoker 是 Dubbo 领域模型中非常重要的一个概念,你如果阅读过 Dubbo 源码的话,你会无数次看到这玩意。就比如下面我要说的负载均衡这块的源码中就有大量 Invoker 的身影。\n简单来说,Invoker 就是 Dubbo 对远程调用的抽象。\n按照 Dubbo 官方的话来说,Invoker 分为\n服务提供 Invoker 服务消费 Invoker 假如我们需要调用一个远程方法,我们需要动态代理来屏蔽远程调用的细节吧!我们屏蔽掉的这些细节就依赖对应的 Invoker 实现, Invoker 实现了真正的远程服务调用。\nDubbo 的工作原理了解么? # 下图是 Dubbo 的整体设计,从下至上分为十层,各层均为单向依赖。\n左边淡蓝背景的为服务消费方使用的接口,右边淡绿色背景的为服务提供方使用的接口,位于中轴线上的为双方都用到的接口。\nconfig 配置层:Dubbo 相关的配置。支持代码配置,同时也支持基于 Spring 来做配置,以 ServiceConfig, ReferenceConfig 为中心 proxy 服务代理层:调用远程方法像调用本地的方法一样简单的一个关键,真实调用过程依赖代理类,以 ServiceProxy 为中心。 registry 注册中心层:封装服务地址的注册与发现。 cluster 路由层:封装多个提供者的路由及负载均衡,并桥接注册中心,以 Invoker 为中心。 monitor 监控层:RPC 调用次数和调用时间监控,以 Statistics 为中心。 protocol 远程调用层:封装 RPC 调用,以 Invocation, Result 为中心。 exchange 信息交换层:封装请求响应模式,同步转异步,以 Request, Response 为中心。 transport 网络传输层:抽象 mina 和 netty 为统一接口,以 Message 为中心。 serialize 数据序列化层:对需要在网络传输的数据进行序列化。 Dubbo 的 SPI 机制了解么? 如何扩展 Dubbo 中的默认实现? # SPI(Service Provider Interface) 机制被大量用在开源项目中,它可以帮助我们动态寻找服务/功能(比如负载均衡策略)的实现。\nSPI 的具体原理是这样的:我们将接口的实现类放在配置文件中,我们在程序运行过程中读取配置文件,通过反射加载实现类。这样,我们可以在运行的时候,动态替换接口的实现类。和 IoC 的解耦思想是类似的。\nJava 本身就提供了 SPI 机制的实现。不过,Dubbo 没有直接用,而是对 Java 原生的 SPI 机制进行了增强,以便更好满足自己的需求。\n那我们如何扩展 Dubbo 中的默认实现呢?\n比如说我们想要实现自己的负载均衡策略,我们创建对应的实现类 XxxLoadBalance 实现 LoadBalance 接口或者 AbstractLoadBalance 类。\npackage com.xxx; import org.apache.dubbo.rpc.cluster.LoadBalance; import org.apache.dubbo.rpc.Invoker; import org.apache.dubbo.rpc.Invocation; import org.apache.dubbo.rpc.RpcException; public class XxxLoadBalance implements LoadBalance { public \u0026lt;T\u0026gt; Invoker\u0026lt;T\u0026gt; select(List\u0026lt;Invoker\u0026lt;T\u0026gt;\u0026gt; invokers, Invocation invocation) throws RpcException { // ... } } 我们将这个实现类的路径写入到resources 目录下的 META-INF/dubbo/org.apache.dubbo.rpc.cluster.LoadBalance文件中即可。\nsrc |-main |-java |-com |-xxx |-XxxLoadBalance.java (实现LoadBalance接口) |-resources |-META-INF |-dubbo |-org.apache.dubbo.rpc.cluster.LoadBalance (纯文本文件,内容为:xxx=com.xxx.XxxLoadBalance) org.apache.dubbo.rpc.cluster.LoadBalance\nxxx=com.xxx.XxxLoadBalance 其他还有很多可供扩展的选择,你可以在 官方文档中找到。\nDubbo 的微内核架构了解吗? # Dubbo 采用 微内核(Microkernel) + 插件(Plugin) 模式,简单来说就是微内核架构。微内核只负责组装插件。\n何为微内核架构呢? 《软件架构模式》 这本书是这样介绍的:\n微内核架构模式(有时被称为插件架构模式)是实现基于产品应用程序的一种自然模式。基于产品的应用程序是已经打包好并且拥有不同版本,可作为第三方插件下载的。然后,很多公司也在开发、发布自己内部商业应用像有版本号、说明及可加载插件式的应用软件(这也是这种模式的特征)。微内核系统可让用户添加额外的应用如插件,到核心应用,继而提供了可扩展性和功能分离的用法。\n微内核架构包含两类组件:核心系统(core system) 和 插件模块(plug-in modules)。\n核心系统提供系统所需核心能力,插件模块可以扩展系统的功能。因此, 基于微内核架构的系统,非常易于扩展功能。\n我们常见的一些 IDE,都可以看作是基于微内核架构设计的。绝大多数 IDE 比如 IDEA、VSCode 都提供了插件来丰富自己的功能。\n正是因为 Dubbo 基于微内核架构,才使得我们可以随心所欲替换 Dubbo 的功能点。比如你觉得 Dubbo 的序列化模块实现的不满足自己要求,没关系啊!你自己实现一个序列化模块就好了啊!\n通常情况下,微核心都会采用 Factory、IoC、OSGi 等方式管理插件生命周期。Dubbo 不想依赖 Spring 等 IoC 容器,也不想自己造一个小的 IoC 容器(过度设计),因此采用了一种最简单的 Factory 方式管理插件:JDK 标准的 SPI 扩展机制 (java.util.ServiceLoader)。\n关于 Dubbo 架构的一些自测小问题 # 注册中心的作用了解么? # 注册中心负责服务地址的注册与查找,相当于目录服务,服务提供者和消费者只在启动时与注册中心交互。\n服务提供者宕机后,注册中心会做什么? # 注册中心会立即推送事件通知消费者。\n监控中心的作用呢? # 监控中心负责统计各服务调用次数,调用时间等。\n注册中心和监控中心都宕机的话,服务都会挂掉吗? # 不会。两者都宕机也不影响已运行的提供者和消费者,消费者在本地缓存了提供者列表。注册中心和监控中心都是可选的,服务消费者可以直连服务提供者。\nDubbo 的负载均衡策略 # 什么是负载均衡? # 先来看一下稍微官方点的解释。下面这段话摘自维基百科对负载均衡的定义:\n负载均衡改善了跨多个计算资源(例如计算机,计算机集群,网络链接,中央处理单元或磁盘驱动)的工作负载分布。负载平衡旨在优化资源使用,最大化吞吐量,最小化响应时间,并避免任何单个资源的过载。使用具有负载平衡而不是单个组件的多个组件可以通过冗余提高可靠性和可用性。负载平衡通常涉及专用软件或硬件。\n上面讲的大家可能不太好理解,再用通俗的话给大家说一下。\n我们的系统中的某个服务的访问量特别大,我们将这个服务部署在了多台服务器上,当客户端发起请求的时候,多台服务器都可以处理这个请求。那么,如何正确选择处理该请求的服务器就很关键。假如,你就要一台服务器来处理该服务的请求,那该服务部署在多台服务器的意义就不复存在了。负载均衡就是为了避免单个服务器响应同一请求,容易造成服务器宕机、崩溃等问题,我们从负载均衡的这四个字就能明显感受到它的意义。\nDubbo 提供的负载均衡策略有哪些? # 在集群负载均衡时,Dubbo 提供了多种均衡策略,默认为 random 随机调用。我们还可以自行扩展负载均衡策略(参考 Dubbo SPI 机制)。\n在 Dubbo 中,所有负载均衡实现类均继承自 AbstractLoadBalance,该类实现了 LoadBalance 接口,并封装了一些公共的逻辑。\npublic abstract class AbstractLoadBalance implements LoadBalance { static int calculateWarmupWeight(int uptime, int warmup, int weight) { } @Override public \u0026lt;T\u0026gt; Invoker\u0026lt;T\u0026gt; select(List\u0026lt;Invoker\u0026lt;T\u0026gt;\u0026gt; invokers, URL url, Invocation invocation) { } protected abstract \u0026lt;T\u0026gt; Invoker\u0026lt;T\u0026gt; doSelect(List\u0026lt;Invoker\u0026lt;T\u0026gt;\u0026gt; invokers, URL url, Invocation invocation); int getWeight(Invoker\u0026lt;?\u0026gt; invoker, Invocation invocation) { } } AbstractLoadBalance 的实现类有下面这些:\n官方文档对负载均衡这部分的介绍非常详细,推荐小伙伴们看看,地址: https://dubbo.apache.org/zh/docs/v2.7/dev/source/loadbalance/#m-zhdocsv27devsourceloadbalance 。\nRandomLoadBalance # 根据权重随机选择(对加权随机算法的实现)。这是 Dubbo 默认采用的一种负载均衡策略。\nRandomLoadBalance 具体的实现原理非常简单,假如有两个提供相同服务的服务器 S1,S2,S1 的权重为 7,S2 的权重为 3。\n我们把这些权重值分布在坐标区间会得到:S1-\u0026gt;[0, 7) ,S2-\u0026gt;[7, 10)。我们生成[0, 10) 之间的随机数,随机数落到对应的区间,我们就选择对应的服务器来处理请求。\nRandomLoadBalance 的源码非常简单,简单花几分钟时间看一下。\n以下源码来自 Dubbo master 分支上的最新的版本 2.7.9。\npublic class RandomLoadBalance extends AbstractLoadBalance { public static final String NAME = \u0026#34;random\u0026#34;; @Override protected \u0026lt;T\u0026gt; Invoker\u0026lt;T\u0026gt; doSelect(List\u0026lt;Invoker\u0026lt;T\u0026gt;\u0026gt; invokers, URL url, Invocation invocation) { int length = invokers.size(); boolean sameWeight = true; int[] weights = new int[length]; int totalWeight = 0; // 下面这个for循环的主要作用就是计算所有该服务的提供者的权重之和 totalWeight(), // 除此之外,还会检测每个服务提供者的权重是否相同 for (int i = 0; i \u0026lt; length; i++) { int weight = getWeight(invokers.get(i), invocation); totalWeight += weight; weights[i] = totalWeight; if (sameWeight \u0026amp;\u0026amp; totalWeight != weight * (i + 1)) { sameWeight = false; } } if (totalWeight \u0026gt; 0 \u0026amp;\u0026amp; !sameWeight) { // 随机生成一个 [0, totalWeight) 区间内的数字 int offset = ThreadLocalRandom.current().nextInt(totalWeight); // 判断会落在哪个服务提供者的区间 for (int i = 0; i \u0026lt; length; i++) { if (offset \u0026lt; weights[i]) { return invokers.get(i); } } return invokers.get(ThreadLocalRandom.current().nextInt(length)); } } LeastActiveLoadBalance # LeastActiveLoadBalance 直译过来就是最小活跃数负载均衡。\n这个名字起得有点不直观,不仔细看官方对活跃数的定义,你压根不知道这玩意是干嘛的。\n我这么说吧!初始状态下所有服务提供者的活跃数均为 0(每个服务提供者的中特定方法都对应一个活跃数,我在后面的源码中会提到),每收到一个请求后,对应的服务提供者的活跃数 +1,当这个请求处理完之后,活跃数 -1。\n因此,Dubbo 就认为谁的活跃数越少,谁的处理速度就越快,性能也越好,这样的话,我就优先把请求给活跃数少的服务提供者处理。\n如果有多个服务提供者的活跃数相等怎么办?\n很简单,那就再走一遍 RandomLoadBalance 。\npublic class LeastActiveLoadBalance extends AbstractLoadBalance { public static final String NAME = \u0026#34;leastactive\u0026#34;; @Override protected \u0026lt;T\u0026gt; Invoker\u0026lt;T\u0026gt; doSelect(List\u0026lt;Invoker\u0026lt;T\u0026gt;\u0026gt; invokers, URL url, Invocation invocation) { int length = invokers.size(); int leastActive = -1; int leastCount = 0; int[] leastIndexes = new int[length]; int[] weights = new int[length]; int totalWeight = 0; int firstWeight = 0; boolean sameWeight = true; // 这个 for 循环的主要作用是遍历 invokers 列表,找出活跃数最小的 Invoker // 如果有多个 Invoker 具有相同的最小活跃数,还会记录下这些 Invoker 在 invokers 集合中的下标,并累加它们的权重,比较它们的权重值是否相等 for (int i = 0; i \u0026lt; length; i++) { Invoker\u0026lt;T\u0026gt; invoker = invokers.get(i); // 获取 invoker 对应的活跃(active)数 int active = RpcStatus.getStatus(invoker.getUrl(), invocation.getMethodName()).getActive(); int afterWarmup = getWeight(invoker, invocation); weights[i] = afterWarmup; if (leastActive == -1 || active \u0026lt; leastActive) { leastActive = active; leastCount = 1; leastIndexes[0] = i; totalWeight = afterWarmup; firstWeight = afterWarmup; sameWeight = true; } else if (active == leastActive) { leastIndexes[leastCount++] = i; totalWeight += afterWarmup; if (sameWeight \u0026amp;\u0026amp; afterWarmup != firstWeight) { sameWeight = false; } } } // 如果只有一个 Invoker 具有最小的活跃数,此时直接返回该 Invoker 即可 if (leastCount == 1) { return invokers.get(leastIndexes[0]); } // 如果有多个 Invoker 具有相同的最小活跃数,但它们之间的权重不同 // 这里的处理方式就和 RandomLoadBalance 一致了 if (!sameWeight \u0026amp;\u0026amp; totalWeight \u0026gt; 0) { int offsetWeight = ThreadLocalRandom.current().nextInt(totalWeight); for (int i = 0; i \u0026lt; leastCount; i++) { int leastIndex = leastIndexes[i]; offsetWeight -= weights[leastIndex]; if (offsetWeight \u0026lt; 0) { return invokers.get(leastIndex); } } } return invokers.get(leastIndexes[ThreadLocalRandom.current().nextInt(leastCount)]); } } 活跃数是通过 RpcStatus 中的一个 ConcurrentMap 保存的,根据 URL 以及服务提供者被调用的方法的名称,我们便可以获取到对应的活跃数。也就是说服务提供者中的每一个方法的活跃数都是互相独立的。\npublic class RpcStatus { private static final ConcurrentMap\u0026lt;String, ConcurrentMap\u0026lt;String, RpcStatus\u0026gt;\u0026gt; METHOD_STATISTICS = new ConcurrentHashMap\u0026lt;String, ConcurrentMap\u0026lt;String, RpcStatus\u0026gt;\u0026gt;(); public static RpcStatus getStatus(URL url, String methodName) { String uri = url.toIdentityString(); ConcurrentMap\u0026lt;String, RpcStatus\u0026gt; map = METHOD_STATISTICS.computeIfAbsent(uri, k -\u0026gt; new ConcurrentHashMap\u0026lt;\u0026gt;()); return map.computeIfAbsent(methodName, k -\u0026gt; new RpcStatus()); } public int getActive() { return active.get(); } } ConsistentHashLoadBalance # ConsistentHashLoadBalance 小伙伴们应该也不会陌生,在分库分表、各种集群中就经常使用这个负载均衡策略。\nConsistentHashLoadBalance 即一致性 Hash 负载均衡策略。 ConsistentHashLoadBalance 中没有权重的概念,具体是哪个服务提供者处理请求是由你的请求的参数决定的,也就是说相同参数的请求总是发到同一个服务提供者。\n另外,Dubbo 为了避免数据倾斜问题(节点不够分散,大量请求落到同一节点),还引入了虚拟节点的概念。通过虚拟节点可以让节点更加分散,有效均衡各个节点的请求量。\n官方有详细的源码分析: https://dubbo.apache.org/zh/docs/v2.7/dev/source/loadbalance/#23-consistenthashloadbalance 。这里还有一个相关的 PR#5440 来修复老版本中 ConsistentHashLoadBalance 存在的一些 Bug。感兴趣的小伙伴,可以多花点时间研究一下。我这里不多分析了,这个作业留给你们!\nRoundRobinLoadBalance # 加权轮询负载均衡。\n轮询就是把请求依次分配给每个服务提供者。加权轮询就是在轮询的基础上,让更多的请求落到权重更大的服务提供者上。比如假如有两个提供相同服务的服务器 S1,S2,S1 的权重为 7,S2 的权重为 3。\n如果我们有 10 次请求,那么 7 次会被 S1 处理,3 次被 S2 处理。\n但是,如果是 RandomLoadBalance 的话,很可能存在 10 次请求有 9 次都被 S1 处理的情况(概率性问题)。\nDubbo 中的 RoundRobinLoadBalance 的代码实现被修改重建了好几次,Dubbo-2.6.5 版本的 RoundRobinLoadBalance 为平滑加权轮询算法。\nDubbo 序列化协议 # Dubbo 支持哪些序列化方式呢? # Dubbo 支持多种序列化方式:JDK 自带的序列化、hessian2、JSON、Kryo、FST、Protostuff,ProtoBuf 等等。\nDubbo 默认使用的序列化方式是 hessian2。\n谈谈你对这些序列化协议了解? # 一般我们不会直接使用 JDK 自带的序列化方式。主要原因有两个:\n不支持跨语言调用 : 如果调用的是其他语言开发的服务的时候就不支持了。 性能差:相比于其他序列化框架性能更低,主要原因是序列化之后的字节数组体积较大,导致传输成本加大。 JSON 序列化由于性能问题,我们一般也不会考虑使用。\n像 Protostuff,ProtoBuf、hessian2 这些都是跨语言的序列化方式,如果有跨语言需求的话可以考虑使用。\nKryo 和 FST 这两种序列化方式是 Dubbo 后来才引入的,性能非常好。不过,这两者都是专门针对 Java 语言的。Dubbo 官网的一篇文章中提到说推荐使用 Kryo 作为生产环境的序列化方式。\nDubbo 官方文档中还有一个关于这些 序列化协议的性能对比图可供参考。\n"},{"id":474,"href":"/zh/docs/technology/Interview/database/elasticsearch/elasticsearch-questions-01/","title":"Elasticsearch常见面试题总结(付费)","section":"Elasticsearch","content":"Elasticsearch 相关的面试题为我的 知识星球(点击链接即可查看详细介绍以及加入方法)专属内容,已经整理到了 《Java 面试指北》中。\n"},{"id":475,"href":"/zh/docs/technology/Interview/tools/git/github-tips/","title":"Github实用小技巧总结","section":"Git","content":"我使用 Github 已经有 6 年多了,今天毫无保留地把自己觉得比较有用的 Github 小技巧送给关注 JavaGuide 的各位小伙伴。\n一键生成 Github 简历 \u0026amp; Github 年报 # 通过 https://resume.github.io/ 这个网站你可以一键生成一个在线的 Github 简历。\n当时我参加的校招的时候,个人信息那里就放了一个在线的 Github 简历。我觉得这样会让面试官感觉你是一个内行,会提高一些印象分。\n但是,如果你的 Github 没有什么项目的话还是不要放在简历里面了。生成后的效果如下图所示。\n通过 https://www.githubtrends.io/wrapped 这个网站,你可以生成一份 Github 个人年报,这个年报会列举出你在这一年的项目贡献情况、最常使用的编程语言、详细的贡献信息。\n个性化 Github 首页 # Github 目前支持在个人主页自定义展示一些内容。展示效果如下图所示。\n想要做到这样非常简单,你只需要创建一个和你的 Github 账户同名的仓库,然后自定义README.md的内容即可。\n展示在你主页的自定义内容就是README.md的内容(不会 Markdown 语法的小伙伴自行面壁 5 分钟)。\n这个也是可以玩出花来的!比如说:通过 github-readme-stats 这个开源项目,你可以 README 中展示动态生成的 GitHub 统计信息。展示效果如下图所示。\n关于个性化首页这个就不多提了,感兴趣的小伙伴自行研究一下。\n自定义项目徽章 # 你在 Github 上看到的项目徽章都是通过 https://shields.io/ 这个网站生成的。我的 JavaGuide 这个项目的徽章如下图所示。\n并且,你不光可以生成静态徽章,shield.io 还可以动态读取你项目的状态并生成对应的徽章。\n生成的描述项目状态的徽章效果如下图所示。\n自动为项目添加贡献情况图标 # 通过 repobeats 这个工具可以为 Github 项目添加如下图所示的项目贡献基本情况图表,挺不错的 👍\n地址: https://repobeats.axiom.co/ 。\nGithub 表情 # 如果你想要在 Github 使用表情的话,可以在这里找找: www.webfx.com/tools/emoji-cheat-sheet/。\n高效阅读 Github 项目的源代码 # Github 前段时间推出的 Codespaces 可以提供类似 VS Code 的在线 IDE,不过目前还没有完全开发使用。\n简单介绍几种我最常用的阅读 Github 项目源代码的方式。\nChrome 插件 Octotree # 这个已经老生常谈了,是我最喜欢的一种方式。使用了 Octotree 之后网页侧边栏会按照树形结构展示项目,为我们带来 IDE 般的阅读源代码的感受。\nChrome 插件 SourceGraph # 我不想将项目 clone 到本地的时候一般就会使用这种方式来阅读项目源代码。SourceGraph 不仅可以让我们在 Github 优雅的查看代码,它还支持一些骚操作,比如:类之间的跳转、代码搜索等功能。\n当你下载了这个插件之后,你的项目主页会多出一个小图标如下图所示。点击这个小图标即可在线阅读项目源代码。\n使用 SourceGraph 阅读代码的就像下面这样,同样是树形结构展示代码,但是我个人感觉没有 Octotree 的手感舒服。不过,SourceGraph 内置了很多插件,而且还支持类之间的跳转!\n克隆项目到本地 # 先把项目克隆到本地,然后使用自己喜欢的 IDE 来阅读。可以说是最酸爽的方式了!\n如果你想要深入了解某个项目的话,首选这种方式。一个git clone 就完事了。\n扩展 Github 的功能 # Enhanced GitHub 可以让你的 Github 更好用。这个 Chrome 插件可以可视化你的 Github 仓库大小,每个文件的大小并且可以让你快速下载单个文件。\n自动为 Markdown 文件生成目录 # 如果你想为 Github 上的 Markdown 文件生成目录的话,通过 VS Code 的 Markdown Preview Enhanced 这个插件就可以了。\n生成的目录效果如下图所示。你直接点击目录中的链接即可跳转到文章对应的位置,可以优化阅读体验。\n不过,目前 Github 已经自动为 Markdown 文件生成了目录,只是需要通过点击的方式才能显示出来。\n善用 Github Explore # 其实,Github 自带的 Explore 是一个非常强大且好用的功能。不过,据我观察,国内很多 Github 用户都不知道这个到底是干啥的。\n简单来说,Github Explore 可以为你带来下面这些服务:\n可以根据你的个人兴趣为你推荐项目; Githunb Topics 按照类别/话题将一些项目进行了分类汇总。比如 Data visualization 汇总了数据可视化相关的一些开源项目, Awesome Lists 汇总了 Awesome 系列的仓库; 通过 Github Trending 我们可以看到最近比较热门的一些开源项目,我们可以按照语言类型以及时间维度对项目进行筛选; Github Collections 类似一个收藏夹集合。比如 Teaching materials for computational social science 这个收藏夹就汇总了计算机课程相关的开源资源, Learn to Code 这个收藏夹就汇总了对你学习编程有帮助的一些仓库; …… GitHub Actions 很强大 # 你可以简单地将 GitHub Actions 理解为 Github 自带的 CI/CD ,通过 GitHub Actions 你可以直接在 GitHub 构建、测试和部署代码,你还可以对代码进行审查、管理 API、分析项目依赖项。总之,GitHub Actions 可以自动化地帮你完成很多事情。\n关于 GitHub Actions 的详细介绍,推荐看一下阮一峰老师写的 GitHub Actions 入门教程 。\nGitHub Actions 有一个官方市场,上面有非常多别人提交的 Actions ,你可以直接拿来使用。\n后记 # 这一篇文章,我毫无保留地把自己这些年总结的 Github 小技巧分享了出来,真心希望对大家有帮助,真心希望大家一定要利用好 Github 这个专属程序员的宝藏。\n另外,这篇文章中,我并没有提到 Github 搜索技巧。在我看来,Github 搜索技巧不必要记网上那些文章说的各种命令啥的,真没啥卵用。你会发现你用的最多的还是关键字搜索以及 Github 自带的筛选功能。\n"},{"id":476,"href":"/zh/docs/technology/Interview/tools/git/git-intro/","title":"Git核心概念总结","section":"Git","content":" 版本控制 # 什么是版本控制 # 版本控制是一种记录一个或若干文件内容变化,以便将来查阅特定版本修订情况的系统。 除了项目源代码,你可以对任何类型的文件进行版本控制。\n为什么要版本控制 # 有了它你就可以将某个文件回溯到之前的状态,甚至将整个项目都回退到过去某个时间点的状态,你可以比较文件的变化细节,查出最后是谁修改了哪个地方,从而找出导致怪异问题出现的原因,又是谁在何时报告了某个功能缺陷等等。\n本地版本控制系统 # 许多人习惯用复制整个项目目录的方式来保存不同的版本,或许还会改名加上备份时间以示区别。 这么做唯一的好处就是简单,但是特别容易犯错。 有时候会混淆所在的工作目录,一不小心会写错文件或者覆盖意想外的文件。\n为了解决这个问题,人们很久以前就开发了许多种本地版本控制系统,大多都是采用某种简单的数据库来记录文件的历次更新差异。\n集中化的版本控制系统 # 接下来人们又遇到一个问题,如何让在不同系统上的开发者协同工作? 于是,集中化的版本控制系统(Centralized Version Control Systems,简称 CVCS)应运而生。\n集中化的版本控制系统都有一个单一的集中管理的服务器,保存所有文件的修订版本,而协同工作的人们都通过客户端连到这台服务器,取出最新的文件或者提交更新。\n这么做虽然解决了本地版本控制系统无法让在不同系统上的开发者协同工作的诟病,但也还是存在下面的问题:\n单点故障: 中央服务器宕机,则其他人无法使用;如果中心数据库磁盘损坏又没有进行备份,你将丢失所有数据。本地版本控制系统也存在类似问题,只要整个项目的历史记录被保存在单一位置,就有丢失所有历史更新记录的风险。 必须联网才能工作: 受网络状况、带宽影响。 分布式版本控制系统 # 于是分布式版本控制系统(Distributed Version Control System,简称 DVCS)面世了。 Git 就是一个典型的分布式版本控制系统。\n这类系统,客户端并不只提取最新版本的文件快照,而是把代码仓库完整地镜像下来。 这么一来,任何一处协同工作用的服务器发生故障,事后都可以用任何一个镜像出来的本地仓库恢复。 因为每一次的克隆操作,实际上都是一次对代码仓库的完整备份。\n分布式版本控制系统可以不用联网就可以工作,因为每个人的电脑上都是完整的版本库,当你修改了某个文件后,你只需要将自己的修改推送给别人就可以了。但是,在实际使用分布式版本控制系统的时候,很少会直接进行推送修改,而是使用一台充当“中央服务器”的东西。这个服务器的作用仅仅是用来方便“交换”大家的修改,没有它大家也一样干活,只是交换修改不方便而已。\n分布式版本控制系统的优势不单是不必联网这么简单,后面我们还会看到 Git 极其强大的分支管理等功能。\n认识 Git # Git 简史 # Linux 内核项目组当时使用分布式版本控制系统 BitKeeper 来管理和维护代码。但是,后来开发 BitKeeper 的商业公司同 Linux 内核开源社区的合作关系结束,他们收回了 Linux 内核社区免费使用 BitKeeper 的权力。 Linux 开源社区(特别是 Linux 的缔造者 Linus Torvalds)基于使用 BitKeeper 时的经验教训,开发出自己的版本系统,而且对新的版本控制系统做了很多改进。\nGit 与其他版本管理系统的主要区别 # Git 在保存和对待各种信息的时候与其它版本控制系统有很大差异,尽管操作起来的命令形式非常相近,理解这些差异将有助于防止你使用中的困惑。\n下面我们主要说一个关于 Git 与其他版本管理系统的主要差别:对待数据的方式。\nGit 采用的是直接记录快照的方式,而非差异比较。我后面会详细介绍这两种方式的差别。\n大部分版本控制系统(CVS、Subversion、Perforce、Bazaar 等等)都是以文件变更列表的方式存储信息,这类系统将它们保存的信息看作是一组基本文件和每个文件随时间逐步累积的差异。\n具体原理如下图所示,理解起来其实很简单,每当我们提交更新一个文件之后,系统都会记录这个文件做了哪些更新,以增量符号 Δ(Delta)表示。\n我们怎样才能得到一个文件的最终版本呢?\n很简单,高中数学的基本知识,我们只需要将这些原文件和这些增加进行相加就行了。\n这种方式有什么问题呢?\n比如我们的增量特别特别多的话,如果我们要得到最终的文件是不是会耗费时间和性能。\nGit 不按照以上方式对待或保存数据。 反之,Git 更像是把数据看作是对小型文件系统的一组快照。 每次你提交更新,或在 Git 中保存项目状态时,它主要对当时的全部文件制作一个快照并保存这个快照的索引。 为了高效,如果文件没有修改,Git 不再重新存储该文件,而是只保留一个链接指向之前存储的文件。 Git 对待数据更像是一个 快照流。\nGit 的三种状态 # Git 有三种状态,你的文件可能处于其中之一:\n已提交(committed):数据已经安全的保存在本地数据库中。 已修改(modified):已修改表示修改了文件,但还没保存到数据库中。 已暂存(staged):表示对一个已修改文件的当前版本做了标记,使之包含在下次提交的快照中。 由此引入 Git 项目的三个工作区域的概念:Git 仓库(.git directory)、工作目录(Working Directory) 以及 暂存区域(Staging Area) 。\n基本的 Git 工作流程如下:\n在工作目录中修改文件。 暂存文件,将文件的快照放入暂存区域。 提交更新,找到暂存区域的文件,将快照永久性存储到 Git 仓库目录。 Git 使用快速入门 # 获取 Git 仓库 # 有两种取得 Git 项目仓库的方法。\n在现有目录中初始化仓库: 进入项目目录运行 git init 命令,该命令将创建一个名为 .git 的子目录。 从一个服务器克隆一个现有的 Git 仓库: git clone [url] 自定义本地仓库的名字: git clone [url] directoryname 记录每次更新到仓库 # 检测当前文件状态 : git status 提出更改(把它们添加到暂存区):git add filename (针对特定文件)、git add *(所有文件)、git add *.txt(支持通配符,所有 .txt 文件) 忽略文件:.gitignore 文件 提交更新: git commit -m \u0026quot;代码提交信息\u0026quot; (每次准备提交前,先用 git status 看下,是不是都已暂存起来了, 然后再运行提交命令 git commit) 跳过使用暂存区域更新的方式 : git commit -a -m \u0026quot;代码提交信息\u0026quot;。 git commit 加上 -a 选项,Git 就会自动把所有已经跟踪过的文件暂存起来一并提交,从而跳过 git add 步骤。 移除文件:git rm filename (从暂存区域移除,然后提交。) 对文件重命名:git mv README.md README(这个命令相当于mv README.md README、git rm README.md、git add README 这三条命令的集合) 一个好的 Git 提交消息 # 一个好的 Git 提交消息如下:\n标题行:用这一行来描述和解释你的这次提交 主体部分可以是很少的几行,来加入更多的细节来解释提交,最好是能给出一些相关的背景或者解释这个提交能修复和解决什么问题。 主体部分当然也可以有几段,但是一定要注意换行和句子不要太长。因为这样在使用 \u0026#34;git log\u0026#34; 的时候会有缩进比较好看。 提交的标题行描述应该尽量的清晰和尽量的一句话概括。这样就方便相关的 Git 日志查看工具显示和其他人的阅读。\n推送改动到远程仓库 # 如果你还没有克隆现有仓库,并欲将你的仓库连接到某个远程服务器,你可以使用如下命令添加:git remote add origin \u0026lt;server\u0026gt; ,比如我们要让本地的一个仓库和 GitHub 上创建的一个仓库关联可以这样git remote add origin https://github.com/Snailclimb/test.git\n将这些改动提交到远端仓库:git push origin master (可以把 master 换成你想要推送的任何分支)\n如此你就能够将你的改动推送到所添加的服务器上去了。\n远程仓库的移除与重命名 # 将 test 重命名为 test1:git remote rename test test1 移除远程仓库 test1:git remote rm test1 查看提交历史 # 在提交了若干更新,又或者克隆了某个项目之后,你也许想回顾下提交历史。 完成这个任务最简单而又有效的工具是 git log 命令。git log 会按提交时间列出所有的更新,最近的更新排在最上面。\n可以添加一些参数来查看自己希望看到的内容:\n只看某个人的提交记录:\ngit log --author=bob 撤销操作 # 有时候我们提交完了才发现漏掉了几个文件没有添加,或者提交信息写错了。 此时,可以运行带有 --amend 选项的提交命令尝试重新提交:\ngit commit --amend 取消暂存的文件\ngit reset filename 撤消对文件的修改:\ngit checkout -- filename 假如你想丢弃你在本地的所有改动与提交,可以到服务器上获取最新的版本历史,并将你本地主分支指向它:\ngit fetch origin git reset --hard origin/master 分支 # 分支是用来将特性开发绝缘开来的。在你创建仓库的时候,master 是“默认”的分支。在其他分支上进行开发,完成后再将它们合并到主分支上。\n我们通常在开发新功能、修复一个紧急 bug 等等时候会选择创建分支。单分支开发好还是多分支开发好,还是要看具体场景来说。\n创建一个名字叫做 test 的分支\ngit branch test 切换当前分支到 test(当你切换分支的时候,Git 会重置你的工作目录,使其看起来像回到了你在那个分支上最后一次提交的样子。 Git 会自动添加、删除、修改文件以确保此时你的工作目录和这个分支最后一次提交时的样子一模一样)\ngit checkout test 你也可以直接这样创建分支并切换过去(上面两条命令的合写)\ngit checkout -b feature_x 切换到主分支\ngit checkout master 合并分支(可能会有冲突)\ngit merge test 把新建的分支删掉\ngit branch -d feature_x 将分支推送到远端仓库(推送成功后其他人可见):\ngit push origin 学习资料推荐 # 在线演示学习工具:\n「补充,来自 issue729」Learn Git Branching https://oschina.gitee.io/learn-git-branching/ 。该网站可以方便的演示基本的 git 操作,讲解得明明白白。每一个基本命令的作用和结果。\n推荐阅读:\nGit 入门图文教程(1.5W 字 40 图):超用心的一篇文章,内容全面且附带详细的图解,强烈推荐! Git - 简明指南:涵盖 Git 常见操作,非常清晰。 图解 Git:图解 Git 中的最常用命令。如果你稍微理解 git 的工作原理,这篇文章能够让你理解的更透彻。 猴子都能懂得 Git 入门:有趣的讲解。 Pro Git book:国外的一本 Git 书籍,被翻译成多国语言,质量很高。 "},{"id":477,"href":"/zh/docs/technology/Interview/distributed-system/protocol/gossip-protocl/","title":"Gossip 协议详解","section":"Protocol","content":" 背景 # 在分布式系统中,不同的节点进行数据/信息共享是一个基本的需求。\n一种比较简单粗暴的方法就是 集中式发散消息,简单来说就是一个主节点同时共享最新信息给其他所有节点,比较适合中心化系统。这种方法的缺陷也很明显,节点多的时候不光同步消息的效率低,还太依赖与中心节点,存在单点风险问题。\n于是,分散式发散消息 的 Gossip 协议 就诞生了。\nGossip 协议介绍 # Gossip 直译过来就是闲话、流言蜚语的意思。流言蜚语有什么特点呢?容易被传播且传播速度还快,你传我我传他,然后大家都知道了。\nGossip 协议 也叫 Epidemic 协议(流行病协议)或者 Epidemic propagation 算法(疫情传播算法),别名很多。不过,这些名字的特点都具有 随机传播特性 (联想一下病毒传播、癌细胞扩散等生活中常见的情景),这也正是 Gossip 协议最主要的特点。\nGossip 协议最早是在 ACM 上的一篇 1987 年发表的论文 《Epidemic Algorithms for Replicated Database Maintenance》中被提出的。根据论文标题,我们大概就能知道 Gossip 协议当时提出的主要应用是在分布式数据库系统中各个副本节点同步数据。\n正如 Gossip 协议其名一样,这是一种随机且带有传染性的方式将信息传播到整个网络中,并在一定时间内,使得系统内的所有节点数据一致。\n在 Gossip 协议下,没有所谓的中心节点,每个节点周期性地随机找一个节点互相同步彼此的信息,理论上来说,各个节点的状态最终会保持一致。\n下面我们来对 Gossip 协议的定义做一个总结:Gossip 协议是一种允许在分布式系统中共享状态的去中心化通信协议,通过这种通信协议,我们可以将信息传播给网络或集群中的所有成员。\nGossip 协议应用 # NoSQL 数据库 Redis 和 Apache Cassandra、服务网格解决方案 Consul 等知名项目都用到了 Gossip 协议,学习 Gossip 协议有助于我们搞清很多技术的底层原理。\n我们这里以 Redis Cluster 为例说明 Gossip 协议的实际应用。\n我们经常使用的分布式缓存 Redis 的官方集群解决方案(3.0 版本引入) Redis Cluster 就是基于 Gossip 协议来实现集群中各个节点数据的最终一致性。\nRedis Cluster 是一个典型的分布式系统,分布式系统中的各个节点需要互相通信。既然要相互通信就要遵循一致的通信协议,Redis Cluster 中的各个节点基于 Gossip 协议 来进行通信共享信息,每个 Redis 节点都维护了一份集群的状态信息。\nRedis Cluster 的节点之间会相互发送多种 Gossip 消息:\nMEET:在 Redis Cluster 中的某个 Redis 节点上执行 CLUSTER MEET ip port 命令,可以向指定的 Redis 节点发送一条 MEET 信息,用于将其添加进 Redis Cluster 成为新的 Redis 节点。 PING/PONG:Redis Cluster 中的节点都会定时地向其他节点发送 PING 消息,来交换各个节点状态信息,检查各个节点状态,包括在线状态、疑似下线状态 PFAIL 和已下线状态 FAIL。 FAIL:Redis Cluster 中的节点 A 发现 B 节点 PFAIL ,并且在下线报告的有效期限内集群中半数以上的节点将 B 节点标记为 PFAIL,节点 A 就会向集群广播一条 FAIL 消息,通知其他节点将故障节点 B 标记为 FAIL 。 …… 下图就是主从架构的 Redis Cluster 的示意图,图中的虚线代表的就是各个节点之间使用 Gossip 进行通信 ,实线表示主从复制。\n有了 Redis Cluster 之后,不需要专门部署 Sentinel 集群服务了。Redis Cluster 相当于是内置了 Sentinel 机制,Redis Cluster 内部的各个 Redis 节点通过 Gossip 协议共享集群内信息。\n关于 Redis Cluster 的详细介绍,可以查看这篇文章 Redis 集群详解(付费) 。\nGossip 协议消息传播模式 # Gossip 设计了两种可能的消息传播模式:反熵(Anti-Entropy) 和 传谣(Rumor-Mongering)。\n反熵(Anti-entropy) # 根据维基百科:\n熵的概念最早起源于 物理学,用于度量一个热力学系统的混乱程度。熵最好理解为不确定性的量度而不是确定性的量度,因为越随机的信源的熵越大。\n在这里,你可以把反熵中的熵理解为节点之间数据的混乱程度/差异性,反熵就是指消除不同节点中数据的差异,提升节点间数据的相似度,从而降低熵值。\n具体是如何反熵的呢?集群中的节点,每隔段时间就随机选择某个其他节点,然后通过互相交换自己的所有数据来消除两者之间的差异,实现数据的最终一致性。\n在实现反熵的时候,主要有推、拉和推拉三种方式:\n推方式,就是将自己的所有副本数据,推给对方,修复对方副本中的熵。 拉方式,就是拉取对方的所有副本数据,修复自己副本中的熵。 推拉就是同时修复自己副本和对方副本中的熵。 伪代码如下:\n在我们实际应用场景中,一般不会采用随机的节点进行反熵,而是可以设计成一个闭环。这样的话,我们能够在一个确定的时间范围内实现各个节点数据的最终一致性,而不是基于随机的概率。像 InfluxDB 就是这样来实现反熵的。\n节点 A 推送数据给节点 B,节点 B 获取到节点 A 中的最新数据。 节点 B 推送数据给 C,节点 C 获取到节点 A,B 中的最新数据。 节点 C 推送数据给 A,节点 A 获取到节点 B,C 中的最新数据。 节点 A 再推送数据给 B 形成闭环,这样节点 B 就获取到节点 C 中的最新数据。 虽然反熵很简单实用,但是,节点过多或者节点动态变化的话,反熵就不太适用了。这个时候,我们想要实现最终一致性就要靠 谣言传播(Rumor mongering) 。\n谣言传播(Rumor mongering) # 谣言传播指的是分布式系统中的一个节点一旦有了新数据之后,就会变为活跃节点,活跃节点会周期性地联系其他节点向其发送新数据,直到所有的节点都存储了该新数据。\n如下图所示(下图来自于 INTRODUCTION TO GOSSIP 这篇文章):\n伪代码如下:\n谣言传播比较适合节点数量比较多的情况,不过,这种模式下要尽量避免传播的信息包不能太大,避免网络消耗太大。\n总结 # 反熵(Anti-Entropy)会传播节点的所有数据,而谣言传播(Rumor-Mongering)只会传播节点新增的数据。 我们一般会给反熵设计一个闭环。 谣言传播(Rumor-Mongering)比较适合节点数量比较多或者节点动态变化的场景。 Gossip 协议优势和缺陷 # 优势:\n1、相比于其他分布式协议/算法来说,Gossip 协议理解起来非常简单。\n2、能够容忍网络上节点的随意地增加或者减少,宕机或者重启,因为 Gossip 协议下这些节点都是平等的,去中心化的。新增加或者重启的节点在理想情况下最终是一定会和其他节点的状态达到一致。\n3、速度相对较快。节点数量比较多的情况下,扩散速度比一个主节点向其他节点传播信息要更快(多播)。\n缺陷 :\n1、消息需要通过多个传播的轮次才能传播到整个网络中,因此,必然会出现各节点状态不一致的情况。毕竟,Gossip 协议强调的是最终一致,至于达到各个节点的状态一致需要多长时间,谁也无从得知。\n2、由于拜占庭将军问题,不允许存在恶意节点。\n3、可能会出现消息冗余的问题。由于消息传播的随机性,同一个节点可能会重复收到相同的消息。\n总结 # Gossip 协议是一种允许在分布式系统中共享状态的通信协议,通过这种通信协议,我们可以将信息传播给网络或集群中的所有成员。 Gossip 协议被 Redis、Apache Cassandra、Consul 等项目应用。 谣言传播(Rumor-Mongering)比较适合节点数量比较多或者节点动态变化的场景。 参考 # 一万字详解 Redis Cluster Gossip 协议: https://segmentfault.com/a/1190000038373546 《分布式协议与算法实战》 《Redis 设计与实现》 "},{"id":478,"href":"/zh/docs/technology/Interview/tools/gradle/gradle-core-concepts/","title":"Gradle核心概念总结","section":"Gradle","content":" 这部分内容主要根据 Gradle 官方文档整理,做了对应的删减,主要保留比较重要的部分,不涉及实战,主要是一些重要概念的介绍。\nGradle 这部分内容属于可选内容,可以根据自身需求决定是否学习,目前国内还是使用 Maven 普遍一些。\nGradle 介绍 # Gradle 官方文档是这样介绍的 Gradle 的:\nGradle is an open-source build automation tool flexible enough to build almost any type of software. Gradle makes few assumptions about what you’re trying to build or how to build it. This makes Gradle particularly flexible.\nGradle 是一个开源的构建自动化工具,它足够灵活,可以构建几乎任何类型的软件。Gradle 对你要构建什么或者如何构建它做了很少的假设。这使得 Gradle 特别灵活。\n简单来说,Gradle 就是一个运行在 JVM 上的自动化的项目构建工具,用来帮助我们自动构建项目。\n对于开发者来说,Gradle 的主要作用主要有 3 个:\n项目构建:提供标准的、跨平台的自动化项目构建方式。 依赖管理:方便快捷的管理项目依赖的资源(jar 包),避免资源间的版本冲突问题。 统一开发结构:提供标准的、统一的项目结构。 Gradle 构建脚本是使用 Groovy 或 Kotlin 语言编写的,表达能力非常强,也足够灵活。\nGroovy 介绍 # Gradle 是运行在 JVM 上的一个程序,它可以使用 Groovy 来编写构建脚本。\nGroovy 是运行在 JVM 上的脚本语言,是基于 Java 扩展的动态语言,它的语法和 Java 非常的相似,可以使用 Java 的类库。Groovy 可以用于面向对象编程,也可以用作纯粹的脚本语言。在语言的设计上它吸纳了 Java、Python、Ruby 和 Smalltalk 语言的优秀特性,比如动态类型转换、闭包和元编程支持。\n我们可以用学习 Java 的方式去学习 Groovy ,学习成本相对来说还是比较低的,即使开发过程中忘记 Groovy 语法,也可以用 Java 语法继续编码。\n基于 JVM 的语言有很多种比如 Groovy,Kotlin,Java,Scala,他们最终都会编译生成 Java 字节码文件并在 JVM 上运行。\nGradle 优势 # Gradle 是新一代的构建系统,具有高效和灵活等诸多优势,广泛用于 Java 开发。不仅 Android 将其作为官方构建系统, 越来越多的 Java 项目比如 Spring Boot 也慢慢迁移到 Gradle。\n在灵活性上,Gradle 支持基于 Groovy 语言编写脚本,侧重于构建过程的灵活性,适合于构建复杂度较高的项目,可以完成非常复杂的构建。 在粒度性上,Gradle 构建的粒度细化到了每一个 task 之中。并且它所有的 Task 源码都是开源的,在我们掌握了这一整套打包流程后,我们就可以通过去修改它的 Task 去动态改变其执行流程。 在扩展性上,Gradle 支持插件机制,所以我们可以复用这些插件,就如同复用库一样简单方便。 Gradle Wrapper 介绍 # Gradle 官方文档是这样介绍的 Gradle Wrapper 的:\nThe recommended way to execute any Gradle build is with the help of the Gradle Wrapper (in short just “Wrapper”). The Wrapper is a script that invokes a declared version of Gradle, downloading it beforehand if necessary. As a result, developers can get up and running with a Gradle project quickly without having to follow manual installation processes saving your company time and money.\n执行 Gradle 构建的推荐方法是借助 Gradle Wrapper(简而言之就是“Wrapper”)。Wrapper 它是一个脚本,调用了已经声明的 Gradle 版本,如果需要的话,可以预先下载它。因此,开发人员可以快速启动并运行 Gradle 项目,而不必遵循手动安装过程,从而为公司节省时间和金钱。\n我们可以称 Gradle Wrapper 为 Gradle 包装器,它将 Gradle 再次包装,让所有的 Gradle 构建方法在 Gradle 包装器的帮助下运行。\nGradle Wrapper 的工作流程图如下(图源 Gradle Wrapper 官方文档介绍):\n整个流程主要分为下面 3 步:\n首先当我们刚创建的时候,如果指定的版本没有被下载,就先会去 Gradle 的服务器中下载对应版本的压缩包; 下载完成后需要先进行解压缩并且执行批处理文件; 后续项目每次构建都会重用这个解压过的 Gradle 版本。 Gradle Wrapper 会给我们带来下面这些好处:\n在给定的 Gradle 版本上标准化项目,从而实现更可靠和健壮的构建。 可以让我们的电脑中不安装 Gradle 环境也可以运行 Gradle 项目。 为不同的用户和执行环境(例如 IDE 或持续集成服务器)提供新的 Gradle 版本就像更改 Wrapper 定义一样简单。 生成 Gradle Wrapper # 如果想要生成 Gradle Wrapper 的话,需要本地配置好 Gradle 环境变量。Gradle 中已经内置了 Wrapper Task,在项目根目录执行执行gradle wrapper命令即可帮助我们生成 Gradle Wrapper。\n执行命令 gradle wrapper 命令时可以指定一些参数来控制 wrapper 的生成。具体有如下两个配置参数:\n--gradle-version 用于指定使用的 Gradle 的版本 --gradle-distribution-url 用于指定下载 Gradle 版本的 URL,该值的规则是 http://services.gradle.org/distributions/gradle-${gradleVersion}-bin.zip 执行gradle wrapper命令之后,Gradle Wrapper 就生成完成了,项目根目录中生成如下文件:\n├── gradle │ └── wrapper │ ├── gradle-wrapper.jar │ └── gradle-wrapper.properties ├── gradlew └── gradlew.bat 每个文件的含义如下:\ngradle-wrapper.jar:包含了 Gradle 运行时的逻辑代码。 gradle-wrapper.properties:定义了 Gradle 的版本号和 Gradle 运行时的行为属性。 gradlew:Linux 平台下,用于执行 Gralde 命令的包装器脚本。 gradlew.bat:Windows 平台下,用于执行 Gralde 命令的包装器脚本。 gradle-wrapper.properties 文件的内容如下:\ndistributionBase=GRADLE_USER_HOME distributionPath=wrapper/dists distributionUrl=https\\://services.gradle.org/distributions/gradle-6.0.1-bin.zip zipStoreBase=GRADLE_USER_HOME zipStorePath=wrapper/dists distributionBase:Gradle 解包后存储的父目录。 distributionPath:distributionBase指定目录的子目录。distributionBase+distributionPath就是 Gradle 解包后的存放的具体目录。 distributionUrl:Gradle 指定版本的压缩包下载地址。 zipStoreBase:Gradle 压缩包下载后存储父目录。 zipStorePath:zipStoreBase指定目录的子目录。zipStoreBase+zipStorePath就是 Gradle 压缩包的存放位置。 更新 Gradle Wrapper # 更新 Gradle Wrapper 有 2 种方式:\n接修改distributionUrl字段,然后执行 Gradle 命令。 执行 gradlew 命令gradlew wrapper –-gradle-version [version]。 下面的命令会将 Gradle 版本升级为 7.6。\ngradlew wrapper --gradle-version 7.6 gradle-wrapper.properties 文件中的 distributionUrl 属性也发生了改变。\ndistributionUrl=https\\://services.gradle.org/distributions/gradle-7.6-all.zip 自定义 Gradle Wrapper # Gradle 已经内置了 Wrapper Task,因此构建 Gradle Wrapper 会生成 Gradle Wrapper 的属性文件,这个属性文件可以通过自定义 Wrapper Task 来设置。比如我们想要修改要下载的 Gralde 版本为 7.6,可以这么设置:\ntask wrapper(type: Wrapper) { gradleVersion = \u0026#39;7.6\u0026#39; } 也可以设置 Gradle 发行版压缩包的下载地址和 Gradle 解包后的本地存储路径等配置。\ntask wrapper(type: Wrapper) { gradleVersion = \u0026#39;7.6\u0026#39; distributionUrl = \u0026#39;../../gradle-7.6-bin.zip\u0026#39; distributionPath=wrapper/dists } distributionUrl 属性可以设置为本地的项目目录,你也可以设置为网络地址。\nGradle 任务 # 在 Gradle 中,任务(Task)是构建执行的单个工作单元。\nGradle 的构建是基于 Task 进行的,当你运行项目的时候,实际就是在执行了一系列的 Task 比如编译 Java 源码的 Task、生成 jar 文件的 Task。\nTask 的声明方式如下(还有其他几种声明方式):\n// 声明一个名字为 helloTask 的 Task task helloTask{ doLast{ println \u0026#34;Hello\u0026#34; } } 创建一个 Task 后,可以根据需要给 Task 添加不同的 Action,上面的“doLast”就是给队列尾增加一个 Action。\n//在Action 队列头部添加Action Task doFirst(Action\u0026lt;? super Task\u0026gt; action); Task doFirst(Closure action); //在Action 队列尾部添加Action Task doLast(Action\u0026lt;? super Task\u0026gt; action); Task doLast(Closure action); //删除所有的Action Task deleteAllActions(); 一个 Task 中可以有多个 Acton,从队列头部开始向队列尾部执行 Acton。\nAction 代表的是一个个函数、方法,每个 Task 都是一堆 Action 按序组成的执行图。\nTask 声明依赖的关键字是dependsOn,支持声明一个或多个依赖:\ntask first { doLast { println \u0026#34;+++++first+++++\u0026#34; } } task second { doLast { println \u0026#34;+++++second+++++\u0026#34; } } // 指定多个 task 依赖 task print(dependsOn :[second,first]) { doLast { logger.quiet \u0026#34;指定多个task依赖\u0026#34; } } // 指定一个 task 依赖 task third(dependsOn : print) { doLast { println \u0026#39;+++++third+++++\u0026#39; } } 执行 Task 之前,会先执行它的依赖 Task。\n我们还可以设置默认 Task,脚本中我们不调用默认 Task ,也会执行。\ndefaultTasks \u0026#39;clean\u0026#39;, \u0026#39;run\u0026#39; task clean { doLast { println \u0026#39;Default Cleaning!\u0026#39; } } task run { doLast { println \u0026#39;Default Running!\u0026#39; } } Gradle 本身也内置了很多 Task 比如 copy(复制文件)、delete(删除文件)。\ntask deleteFile(type: Delete) { delete \u0026#34;C:\\\\Users\\\\guide\\\\Desktop\\\\test\u0026#34; } Gradle 插件 # Gradle 提供的是一套核心的构建机制,而 Gradle 插件则是运行在这套机制上的一些具体构建逻辑,其本质上和 .gradle 文件是相同。你可以将 Gradle 插件看作是封装了一系列 Task 并执行的工具。\nGradle 插件主要分为两类:\n脚本插件:脚本插件就是一个普通的脚本文件,它可以被导入都其他构建脚本中。 二进制插件 / 对象插件:在一个单独的插件模块中定义,其他模块通过 Plugin ID 应用插件。因为这种方式发布和复用更加友好,我们一般接触到的 Gradle 插件都是指二进制插件的形式。 虽然 Gradle 插件与 .gradle 文件本质上没有区别,.gradle 文件也能实现 Gradle 插件类似的功能。但是,Gradle 插件使用了独立模块封装构建逻辑,无论是从开发开始使用来看,Gradle 插件的整体体验都更友好。\n逻辑复用: 将相同的逻辑提供给多个相似项目复用,减少重复维护类似逻辑开销。当然 .gradle 文件也能做到逻辑复用,但 Gradle 插件的封装性更好; 组件发布: 可以将插件发布到 Maven 仓库进行管理,其他项目可以使用插件 ID 依赖。当然 .gradle 文件也可以放到一个远程路径被其他项目引用; 构建配置: Gradle 插件可以声明插件扩展来暴露可配置的属性,提供定制化能力。当然 .gradle 文件也可以做到,但实现会麻烦些。 Gradle 构建生命周期 # Gradle 构建的生命周期有三个阶段:初始化阶段,配置阶段和运行阶段。\n在初始化阶段与配置阶段之间、配置阶段结束之后、执行阶段结束之后,我们都可以加一些定制化的 Hook。\n初始化阶段 # Gradle 支持单项目和多项目构建。在初始化阶段,Gradle 确定哪些项目将参与构建,并为每个项目创建一个 Project 实例 。本质上也就是执行 settings.gradle 脚本,从而读取整个项目中有多少个 Project 实例。\n配置阶段 # 在配置阶段,Gradle 会解析每个工程的 build.gradle 文件,创建要执行的任务子集和确定各种任务之间的关系,以供执行阶段按照顺序执行,并对任务的做一些初始化配置。\n每个 build.gradle 对应一个 Project 对象,配置阶段执行的代码包括 build.gradle 中的各种语句、闭包以及 Task 中的配置语句。\n在配置阶段结束后,Gradle 会根据 Task 的依赖关系会创建一个 有向无环图 。\n运行阶段 # 在运行阶段,Gradle 根据配置阶段创建和配置的要执行的任务子集,执行任务。\n参考 # Gradle 官方文档: https://docs.gradle.org/current/userguide/userguide.html Gradle 入门教程: https://www.imooc.com/wiki/gradlebase Groovy 快速入门看这篇就够了: https://cloud.tencent.com/developer/article/1358357 【Gradle】Gradle 的生命周期详解: https://juejin.cn/post/7067719629874921508 手把手带你自定义 Gradle 插件 —— Gradle 系列(2): https://www.cnblogs.com/pengxurui/p/16281537.html Gradle 爬坑指南 \u0026ndash; 理解 Plugin、Task、构建流程: https://juejin.cn/post/6889090530593112077 "},{"id":479,"href":"/zh/docs/technology/Interview/java/collection/hashmap-source-code/","title":"HashMap 源码分析","section":"Collection","content":" 感谢 changfubai 对本文的改进做出的贡献!\nHashMap 简介 # HashMap 主要用来存放键值对,它基于哈希表的 Map 接口实现,是常用的 Java 集合之一,是非线程安全的。\nHashMap 可以存储 null 的 key 和 value,但 null 作为键只能有一个,null 作为值可以有多个\nJDK1.8 之前 HashMap 由 数组+链表 组成的,数组是 HashMap 的主体,链表则是主要为了解决哈希冲突而存在的(“拉链法”解决冲突)。 JDK1.8 以后的 HashMap 在解决哈希冲突时有了较大的变化,当链表长度大于等于阈值(默认为 8)(将链表转换成红黑树前会判断,如果当前数组的长度小于 64,那么会选择先进行数组扩容,而不是转换为红黑树)时,将链表转化为红黑树,以减少搜索时间。\nHashMap 默认的初始化大小为 16。之后每次扩充,容量变为原来的 2 倍。并且, HashMap 总是使用 2 的幂作为哈希表的大小。\n底层数据结构分析 # JDK1.8 之前 # JDK1.8 之前 HashMap 底层是 数组和链表 结合在一起使用也就是 链表散列。\nHashMap 通过 key 的 hashCode 经过扰动函数处理过后得到 hash 值,然后通过 (n - 1) \u0026amp; hash 判断当前元素存放的位置(这里的 n 指的是数组的长度),如果当前位置存在元素的话,就判断该元素与要存入的元素的 hash 值以及 key 是否相同,如果相同的话,直接覆盖,不相同就通过拉链法解决冲突。\n所谓扰动函数指的就是 HashMap 的 hash 方法。使用 hash 方法也就是扰动函数是为了防止一些实现比较差的 hashCode() 方法 换句话说使用扰动函数之后可以减少碰撞。\nJDK 1.8 HashMap 的 hash 方法源码:\nJDK 1.8 的 hash 方法 相比于 JDK 1.7 hash 方法更加简化,但是原理不变。\nstatic final int hash(Object key) { int h; // key.hashCode():返回散列值也就是hashcode // ^:按位异或 // \u0026gt;\u0026gt;\u0026gt;:无符号右移,忽略符号位,空位都以0补齐 return (key == null) ? 0 : (h = key.hashCode()) ^ (h \u0026gt;\u0026gt;\u0026gt; 16); } 对比一下 JDK1.7 的 HashMap 的 hash 方法源码.\nstatic int hash(int h) { // This function ensures that hashCodes that differ only by // constant multiples at each bit position have a bounded // number of collisions (approximately 8 at default load factor). h ^= (h \u0026gt;\u0026gt;\u0026gt; 20) ^ (h \u0026gt;\u0026gt;\u0026gt; 12); return h ^ (h \u0026gt;\u0026gt;\u0026gt; 7) ^ (h \u0026gt;\u0026gt;\u0026gt; 4); } 相比于 JDK1.8 的 hash 方法 ,JDK 1.7 的 hash 方法的性能会稍差一点点,因为毕竟扰动了 4 次。\n所谓 “拉链法” 就是:将链表和数组相结合。也就是说创建一个链表数组,数组中每一格就是一个链表。若遇到哈希冲突,则将冲突的值加到链表中即可。\nJDK1.8 之后 # 相比于之前的版本,JDK1.8 以后在解决哈希冲突时有了较大的变化。\n当链表长度大于阈值(默认为 8)时,会首先调用 treeifyBin()方法。这个方法会根据 HashMap 数组来决定是否转换为红黑树。只有当数组长度大于或者等于 64 的情况下,才会执行转换红黑树操作,以减少搜索时间。否则,就是只是执行 resize() 方法对数组扩容。相关源码这里就不贴了,重点关注 treeifyBin()方法即可!\n类的属性:\npublic class HashMap\u0026lt;K,V\u0026gt; extends AbstractMap\u0026lt;K,V\u0026gt; implements Map\u0026lt;K,V\u0026gt;, Cloneable, Serializable { // 序列号 private static final long serialVersionUID = 362498820763181265L; // 默认的初始容量是16 static final int DEFAULT_INITIAL_CAPACITY = 1 \u0026lt;\u0026lt; 4; // 最大容量 static final int MAXIMUM_CAPACITY = 1 \u0026lt;\u0026lt; 30; // 默认的负载因子 static final float DEFAULT_LOAD_FACTOR = 0.75f; // 当桶(bucket)上的结点数大于等于这个值时会转成红黑树 static final int TREEIFY_THRESHOLD = 8; // 当桶(bucket)上的结点数小于等于这个值时树转链表 static final int UNTREEIFY_THRESHOLD = 6; // 桶中结构转化为红黑树对应的table的最小容量 static final int MIN_TREEIFY_CAPACITY = 64; // 存储元素的数组,总是2的幂次倍 transient Node\u0026lt;k,v\u0026gt;[] table; // 一个包含了映射中所有键值对的集合视图 transient Set\u0026lt;map.entry\u0026lt;k,v\u0026gt;\u0026gt; entrySet; // 存放元素的个数,注意这个不等于数组的长度。 transient int size; // 每次扩容和更改map结构的计数器 transient int modCount; // 阈值(容量*负载因子) 当实际大小超过阈值时,会进行扩容 int threshold; // 负载因子 final float loadFactor; } loadFactor 负载因子\nloadFactor 负载因子是控制数组存放数据的疏密程度,loadFactor 越趋近于 1,那么 数组中存放的数据(entry)也就越多,也就越密,也就是会让链表的长度增加,loadFactor 越小,也就是趋近于 0,数组中存放的数据(entry)也就越少,也就越稀疏。\nloadFactor 太大导致查找元素效率低,太小导致数组的利用率低,存放的数据会很分散。loadFactor 的默认值为 0.75f 是官方给出的一个比较好的临界值。\n给定的默认容量为 16,负载因子为 0.75。Map 在使用过程中不断的往里面存放数据,当数量超过了 16 * 0.75 = 12 就需要将当前 16 的容量进行扩容,而扩容这个过程涉及到 rehash、复制数据等操作,所以非常消耗性能。\nthreshold\nthreshold = capacity * loadFactor,当 Size\u0026gt;threshold的时候,那么就要考虑对数组的扩增了,也就是说,这个的意思就是 衡量数组是否需要扩增的一个标准。\nNode 节点类源码:\n// 继承自 Map.Entry\u0026lt;K,V\u0026gt; static class Node\u0026lt;K,V\u0026gt; implements Map.Entry\u0026lt;K,V\u0026gt; { final int hash;// 哈希值,存放元素到hashmap中时用来与其他元素hash值比较 final K key;//键 V value;//值 // 指向下一个节点 Node\u0026lt;K,V\u0026gt; next; Node(int hash, K key, V value, Node\u0026lt;K,V\u0026gt; next) { this.hash = hash; this.key = key; this.value = value; this.next = next; } public final K getKey() { return key; } public final V getValue() { return value; } public final String toString() { return key + \u0026#34;=\u0026#34; + value; } // 重写hashCode()方法 public final int hashCode() { return Objects.hashCode(key) ^ Objects.hashCode(value); } public final V setValue(V newValue) { V oldValue = value; value = newValue; return oldValue; } // 重写 equals() 方法 public final boolean equals(Object o) { if (o == this) return true; if (o instanceof Map.Entry) { Map.Entry\u0026lt;?,?\u0026gt; e = (Map.Entry\u0026lt;?,?\u0026gt;)o; if (Objects.equals(key, e.getKey()) \u0026amp;\u0026amp; Objects.equals(value, e.getValue())) return true; } return false; } } 树节点类源码:\nstatic final class TreeNode\u0026lt;K,V\u0026gt; extends LinkedHashMap.Entry\u0026lt;K,V\u0026gt; { TreeNode\u0026lt;K,V\u0026gt; parent; // 父 TreeNode\u0026lt;K,V\u0026gt; left; // 左 TreeNode\u0026lt;K,V\u0026gt; right; // 右 TreeNode\u0026lt;K,V\u0026gt; prev; // needed to unlink next upon deletion boolean red; // 判断颜色 TreeNode(int hash, K key, V val, Node\u0026lt;K,V\u0026gt; next) { super(hash, key, val, next); } // 返回根节点 final TreeNode\u0026lt;K,V\u0026gt; root() { for (TreeNode\u0026lt;K,V\u0026gt; r = this, p;;) { if ((p = r.parent) == null) return r; r = p; } HashMap 源码分析 # 构造方法 # HashMap 中有四个构造方法,它们分别如下:\n// 默认构造函数。 public HashMap() { this.loadFactor = DEFAULT_LOAD_FACTOR; // all other fields defaulted } // 包含另一个“Map”的构造函数 public HashMap(Map\u0026lt;? extends K, ? extends V\u0026gt; m) { this.loadFactor = DEFAULT_LOAD_FACTOR; putMapEntries(m, false);//下面会分析到这个方法 } // 指定“容量大小”的构造函数 public HashMap(int initialCapacity) { this(initialCapacity, DEFAULT_LOAD_FACTOR); } // 指定“容量大小”和“负载因子”的构造函数 public HashMap(int initialCapacity, float loadFactor) { if (initialCapacity \u0026lt; 0) throw new IllegalArgumentException(\u0026#34;Illegal initial capacity: \u0026#34; + initialCapacity); if (initialCapacity \u0026gt; MAXIMUM_CAPACITY) initialCapacity = MAXIMUM_CAPACITY; if (loadFactor \u0026lt;= 0 || Float.isNaN(loadFactor)) throw new IllegalArgumentException(\u0026#34;Illegal load factor: \u0026#34; + loadFactor); this.loadFactor = loadFactor; // 初始容量暂时存放到 threshold ,在resize中再赋值给 newCap 进行table初始化 this.threshold = tableSizeFor(initialCapacity); } 值得注意的是上述四个构造方法中,都初始化了负载因子 loadFactor,由于 HashMap 中没有 capacity 这样的字段,即使指定了初始化容量 initialCapacity ,也只是通过 tableSizeFor 将其扩容到与 initialCapacity 最接近的 2 的幂次方大小,然后暂时赋值给 threshold ,后续通过 resize 方法将 threshold 赋值给 newCap 进行 table 的初始化。\nputMapEntries 方法:\nfinal void putMapEntries(Map\u0026lt;? extends K, ? extends V\u0026gt; m, boolean evict) { int s = m.size(); if (s \u0026gt; 0) { // 判断table是否已经初始化 if (table == null) { // pre-size /* * 未初始化,s为m的实际元素个数,ft=s/loadFactor =\u0026gt; s=ft*loadFactor, 跟我们前面提到的 * 阈值=容量*负载因子 是不是很像,是的,ft指的是要添加s个元素所需的最小的容量 */ float ft = ((float)s / loadFactor) + 1.0F; int t = ((ft \u0026lt; (float)MAXIMUM_CAPACITY) ? (int)ft : MAXIMUM_CAPACITY); /* * 根据构造函数可知,table未初始化,threshold实际上是存放的初始化容量,如果添加s个元素所 * 需的最小容量大于初始化容量,则将最小容量扩容为最接近的2的幂次方大小作为初始化。 * 注意这里不是初始化阈值 */ if (t \u0026gt; threshold) threshold = tableSizeFor(t); } // 已初始化,并且m元素个数大于阈值,进行扩容处理 else if (s \u0026gt; threshold) resize(); // 将m中的所有元素添加至HashMap中,如果table未初始化,putVal中会调用resize初始化或扩容 for (Map.Entry\u0026lt;? extends K, ? extends V\u0026gt; e : m.entrySet()) { K key = e.getKey(); V value = e.getValue(); putVal(hash(key), key, value, false, evict); } } } put 方法 # HashMap 只提供了 put 用于添加元素,putVal 方法只是给 put 方法调用的一个方法,并没有提供给用户使用。\n对 putVal 方法添加元素的分析如下:\n如果定位到的数组位置没有元素 就直接插入。 如果定位到的数组位置有元素就和要插入的 key 比较,如果 key 相同就直接覆盖,如果 key 不相同,就判断 p 是否是一个树节点,如果是就调用e = ((TreeNode\u0026lt;K,V\u0026gt;)p).putTreeVal(this, tab, hash, key, value)将元素添加进入。如果不是就遍历链表插入(插入的是链表尾部)。 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\u0026lt;K,V\u0026gt;[] tab; Node\u0026lt;K,V\u0026gt; p; int n, i; // table未初始化或者长度为0,进行扩容 if ((tab = table) == null || (n = tab.length) == 0) n = (tab = resize()).length; // (n - 1) \u0026amp; hash 确定元素存放在哪个桶中,桶为空,新生成结点放入桶中(此时,这个结点是放在数组中) if ((p = tab[i = (n - 1) \u0026amp; hash]) == null) tab[i] = newNode(hash, key, value, null); // 桶中已经存在元素(处理hash冲突) else { Node\u0026lt;K,V\u0026gt; e; K k; //快速判断第一个节点table[i]的key是否与插入的key一样,若相同就直接使用插入的值p替换掉旧的值e。 if (p.hash == hash \u0026amp;\u0026amp; ((k = p.key) == key || (key != null \u0026amp;\u0026amp; key.equals(k)))) e = p; // 判断插入的是否是红黑树节点 else if (p instanceof TreeNode) // 放入树中 e = ((TreeNode\u0026lt;K,V\u0026gt;)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); // 结点数量达到阈值(默认为 8 ),执行 treeifyBin 方法 // 这个方法会根据 HashMap 数组来决定是否转换为红黑树。 // 只有当数组长度大于或者等于 64 的情况下,才会执行转换红黑树操作,以减少搜索时间。否则,就是只是对数组扩容。 if (binCount \u0026gt;= TREEIFY_THRESHOLD - 1) // -1 for 1st treeifyBin(tab, hash); // 跳出循环 break; } // 判断链表中结点的key值与插入的元素的key值是否相等 if (e.hash == hash \u0026amp;\u0026amp; ((k = e.key) == key || (key != null \u0026amp;\u0026amp; key.equals(k)))) // 相等,跳出循环 break; // 用于遍历桶中的链表,与前面的e = p.next组合,可以遍历链表 p = e; } } // 表示在桶中找到key值、hash值与插入元素相等的结点 if (e != null) { // 记录e的value V oldValue = e.value; // onlyIfAbsent为false或者旧值为null if (!onlyIfAbsent || oldValue == null) //用新值替换旧值 e.value = value; // 访问后回调 afterNodeAccess(e); // 返回旧值 return oldValue; } } // 结构性修改 ++modCount; // 实际大小大于阈值则扩容 if (++size \u0026gt; threshold) resize(); // 插入后回调 afterNodeInsertion(evict); return null; } 我们再来对比一下 JDK1.7 put 方法的代码\n对于 put 方法的分析如下:\n① 如果定位到的数组位置没有元素 就直接插入。 ② 如果定位到的数组位置有元素,遍历以这个元素为头结点的链表,依次和插入的 key 比较,如果 key 相同就直接覆盖,不同就采用头插法插入元素。 public V put(K key, V value) if (table == EMPTY_TABLE) { inflateTable(threshold); } if (key == null) return putForNullKey(value); int hash = hash(key); int i = indexFor(hash, table.length); for (Entry\u0026lt;K,V\u0026gt; e = table[i]; e != null; e = e.next) { // 先遍历 Object k; if (e.hash == hash \u0026amp;\u0026amp; ((k = e.key) == key || key.equals(k))) { V oldValue = e.value; e.value = value; e.recordAccess(this); return oldValue; } } modCount++; addEntry(hash, key, value, i); // 再插入 return null; } get 方法 # public V get(Object key) { Node\u0026lt;K,V\u0026gt; e; return (e = getNode(hash(key), key)) == null ? null : e.value; } final Node\u0026lt;K,V\u0026gt; getNode(int hash, Object key) { Node\u0026lt;K,V\u0026gt;[] tab; Node\u0026lt;K,V\u0026gt; first, e; int n; K k; if ((tab = table) != null \u0026amp;\u0026amp; (n = tab.length) \u0026gt; 0 \u0026amp;\u0026amp; (first = tab[(n - 1) \u0026amp; hash]) != null) { // 数组元素相等 if (first.hash == hash \u0026amp;\u0026amp; // always check first node ((k = first.key) == key || (key != null \u0026amp;\u0026amp; key.equals(k)))) return first; // 桶中不止一个节点 if ((e = first.next) != null) { // 在树中get if (first instanceof TreeNode) return ((TreeNode\u0026lt;K,V\u0026gt;)first).getTreeNode(hash, key); // 在链表中get do { if (e.hash == hash \u0026amp;\u0026amp; ((k = e.key) == key || (key != null \u0026amp;\u0026amp; key.equals(k)))) return e; } while ((e = e.next) != null); } } return null; } resize 方法 # 进行扩容,会伴随着一次重新 hash 分配,并且会遍历 hash 表中所有的元素,是非常耗时的。在编写程序中,要尽量避免 resize。resize 方法实际上是将 table 初始化和 table 扩容 进行了整合,底层的行为都是给 table 赋值一个新的数组。\nfinal Node\u0026lt;K,V\u0026gt;[] resize() { Node\u0026lt;K,V\u0026gt;[] oldTab = table; int oldCap = (oldTab == null) ? 0 : oldTab.length; int oldThr = threshold; int newCap, newThr = 0; if (oldCap \u0026gt; 0) { // 超过最大值就不再扩充了,就只好随你碰撞去吧 if (oldCap \u0026gt;= MAXIMUM_CAPACITY) { threshold = Integer.MAX_VALUE; return oldTab; } // 没超过最大值,就扩充为原来的2倍 else if ((newCap = oldCap \u0026lt;\u0026lt; 1) \u0026lt; MAXIMUM_CAPACITY \u0026amp;\u0026amp; oldCap \u0026gt;= DEFAULT_INITIAL_CAPACITY) newThr = oldThr \u0026lt;\u0026lt; 1; // double threshold } else if (oldThr \u0026gt; 0) // initial capacity was placed in threshold // 创建对象时初始化容量大小放在threshold中,此时只需要将其作为新的数组容量 newCap = oldThr; else { // signifies using defaults 无参构造函数创建的对象在这里计算容量和阈值 newCap = DEFAULT_INITIAL_CAPACITY; newThr = (int)(DEFAULT_LOAD_FACTOR * DEFAULT_INITIAL_CAPACITY); } if (newThr == 0) { // 创建时指定了初始化容量或者负载因子,在这里进行阈值初始化, // 或者扩容前的旧容量小于16,在这里计算新的resize上限 float ft = (float)newCap * loadFactor; newThr = (newCap \u0026lt; MAXIMUM_CAPACITY \u0026amp;\u0026amp; ft \u0026lt; (float)MAXIMUM_CAPACITY ? (int)ft : Integer.MAX_VALUE); } threshold = newThr; @SuppressWarnings({\u0026#34;rawtypes\u0026#34;,\u0026#34;unchecked\u0026#34;}) Node\u0026lt;K,V\u0026gt;[] newTab = (Node\u0026lt;K,V\u0026gt;[])new Node[newCap]; table = newTab; if (oldTab != null) { // 把每个bucket都移动到新的buckets中 for (int j = 0; j \u0026lt; oldCap; ++j) { Node\u0026lt;K,V\u0026gt; e; if ((e = oldTab[j]) != null) { oldTab[j] = null; if (e.next == null) // 只有一个节点,直接计算元素新的位置即可 newTab[e.hash \u0026amp; (newCap - 1)] = e; else if (e instanceof TreeNode) // 将红黑树拆分成2棵子树,如果子树节点数小于等于 UNTREEIFY_THRESHOLD(默认为 6),则将子树转换为链表。 // 如果子树节点数大于 UNTREEIFY_THRESHOLD,则保持子树的树结构。 ((TreeNode\u0026lt;K,V\u0026gt;)e).split(this, newTab, j, oldCap); else { Node\u0026lt;K,V\u0026gt; loHead = null, loTail = null; Node\u0026lt;K,V\u0026gt; hiHead = null, hiTail = null; Node\u0026lt;K,V\u0026gt; next; do { next = e.next; // 原索引 if ((e.hash \u0026amp; oldCap) == 0) { if (loTail == null) loHead = e; else loTail.next = e; loTail = e; } // 原索引+oldCap else { if (hiTail == null) hiHead = e; else hiTail.next = e; hiTail = e; } } while ((e = next) != null); // 原索引放到bucket里 if (loTail != null) { loTail.next = null; newTab[j] = loHead; } // 原索引+oldCap放到bucket里 if (hiTail != null) { hiTail.next = null; newTab[j + oldCap] = hiHead; } } } } } return newTab; } HashMap 常用方法测试 # package map; import java.util.Collection; import java.util.HashMap; import java.util.Set; public class HashMapDemo { public static void main(String[] args) { HashMap\u0026lt;String, String\u0026gt; map = new HashMap\u0026lt;String, String\u0026gt;(); // 键不能重复,值可以重复 map.put(\u0026#34;san\u0026#34;, \u0026#34;张三\u0026#34;); map.put(\u0026#34;si\u0026#34;, \u0026#34;李四\u0026#34;); map.put(\u0026#34;wu\u0026#34;, \u0026#34;王五\u0026#34;); map.put(\u0026#34;wang\u0026#34;, \u0026#34;老王\u0026#34;); map.put(\u0026#34;wang\u0026#34;, \u0026#34;老王2\u0026#34;);// 老王被覆盖 map.put(\u0026#34;lao\u0026#34;, \u0026#34;老王\u0026#34;); System.out.println(\u0026#34;-------直接输出hashmap:-------\u0026#34;); System.out.println(map); /** * 遍历HashMap */ // 1.获取Map中的所有键 System.out.println(\u0026#34;-------foreach获取Map中所有的键:------\u0026#34;); Set\u0026lt;String\u0026gt; keys = map.keySet(); for (String key : keys) { System.out.print(key+\u0026#34; \u0026#34;); } System.out.println();//换行 // 2.获取Map中所有值 System.out.println(\u0026#34;-------foreach获取Map中所有的值:------\u0026#34;); Collection\u0026lt;String\u0026gt; values = map.values(); for (String value : values) { System.out.print(value+\u0026#34; \u0026#34;); } System.out.println();//换行 // 3.得到key的值的同时得到key所对应的值 System.out.println(\u0026#34;-------得到key的值的同时得到key所对应的值:-------\u0026#34;); Set\u0026lt;String\u0026gt; keys2 = map.keySet(); for (String key : keys2) { System.out.print(key + \u0026#34;:\u0026#34; + map.get(key)+\u0026#34; \u0026#34;); } /** * 如果既要遍历key又要value,那么建议这种方式,因为如果先获取keySet然后再执行map.get(key),map内部会执行两次遍历。 * 一次是在获取keySet的时候,一次是在遍历所有key的时候。 */ // 当我调用put(key,value)方法的时候,首先会把key和value封装到 // Entry这个静态内部类对象中,把Entry对象再添加到数组中,所以我们想获取 // map中的所有键值对,我们只要获取数组中的所有Entry对象,接下来 // 调用Entry对象中的getKey()和getValue()方法就能获取键值对了 Set\u0026lt;java.util.Map.Entry\u0026lt;String, String\u0026gt;\u0026gt; entrys = map.entrySet(); for (java.util.Map.Entry\u0026lt;String, String\u0026gt; entry : entrys) { System.out.println(entry.getKey() + \u0026#34;--\u0026#34; + entry.getValue()); } /** * HashMap其他常用方法 */ System.out.println(\u0026#34;after map.size():\u0026#34;+map.size()); System.out.println(\u0026#34;after map.isEmpty():\u0026#34;+map.isEmpty()); System.out.println(map.remove(\u0026#34;san\u0026#34;)); System.out.println(\u0026#34;after map.remove():\u0026#34;+map); System.out.println(\u0026#34;after map.get(si):\u0026#34;+map.get(\u0026#34;si\u0026#34;)); System.out.println(\u0026#34;after map.containsKey(si):\u0026#34;+map.containsKey(\u0026#34;si\u0026#34;)); System.out.println(\u0026#34;after containsValue(李四):\u0026#34;+map.containsValue(\u0026#34;李四\u0026#34;)); System.out.println(map.replace(\u0026#34;si\u0026#34;, \u0026#34;李四2\u0026#34;)); System.out.println(\u0026#34;after map.replace(si, 李四2):\u0026#34;+map); } } "},{"id":480,"href":"/zh/docs/technology/Interview/cs-basics/network/http1.0-vs-http1.1/","title":"HTTP 1.0 vs HTTP 1.1(应用层)","section":"Network","content":"这篇文章会从下面几个维度来对比 HTTP 1.0 和 HTTP 1.1:\n响应状态码 缓存处理 连接方式 Host 头处理 带宽优化 响应状态码 # HTTP/1.0 仅定义了 16 种状态码。HTTP/1.1 中新加入了大量的状态码,光是错误响应状态码就新增了 24 种。比如说,100 (Continue)——在请求大资源前的预热请求,206 (Partial Content)——范围请求的标识码,409 (Conflict)——请求与当前资源的规定冲突,410 (Gone)——资源已被永久转移,而且没有任何已知的转发地址。\n缓存处理 # 缓存技术通过避免用户与源服务器的频繁交互,节约了大量的网络带宽,降低了用户接收信息的延迟。\nHTTP/1.0 # HTTP/1.0 提供的缓存机制非常简单。服务器端使用Expires标签来标志(时间)一个响应体,在Expires标志时间内的请求,都会获得该响应体缓存。服务器端在初次返回给客户端的响应体中,有一个Last-Modified标签,该标签标记了被请求资源在服务器端的最后一次修改。在请求头中,使用If-Modified-Since标签,该标签标志一个时间,意为客户端向服务器进行问询:“该时间之后,我要请求的资源是否有被修改过?”通常情况下,请求头中的If-Modified-Since的值即为上一次获得该资源时,响应体中的Last-Modified的值。\n如果服务器接收到了请求头,并判断If-Modified-Since时间后,资源确实没有修改过,则返回给客户端一个304 not modified响应头,表示”缓冲可用,你从浏览器里拿吧!”。\n如果服务器判断If-Modified-Since时间后,资源被修改过,则返回给客户端一个200 OK的响应体,并附带全新的资源内容,表示”你要的我已经改过的,给你一份新的”。\nHTTP/1.1 # HTTP/1.1 的缓存机制在 HTTP/1.0 的基础上,大大增加了灵活性和扩展性。基本工作原理和 HTTP/1.0 保持不变,而是增加了更多细致的特性。其中,请求头中最常见的特性就是Cache-Control,详见 MDN Web 文档 Cache-Control.\n连接方式 # HTTP/1.0 默认使用短连接 ,也就是说,客户端和服务器每进行一次 HTTP 操作,就建立一次连接,任务结束就中断连接。当客户端浏览器访问的某个 HTML 或其他类型的 Web 页中包含有其他的 Web 资源(如 JavaScript 文件、图像文件、CSS 文件等),每遇到这样一个 Web 资源,浏览器就会重新建立一个 TCP 连接,这样就会导致有大量的“握手报文”和“挥手报文”占用了带宽。\n为了解决 HTTP/1.0 存在的资源浪费的问题, HTTP/1.1 优化为默认长连接模式 。 采用长连接模式的请求报文会通知服务端:“我向你请求连接,并且连接成功建立后,请不要关闭”。因此,该 TCP 连接将持续打开,为后续的客户端-服务端的数据交互服务。也就是说在使用长连接的情况下,当一个网页打开完成后,客户端和服务器之间用于传输 HTTP 数据的 TCP 连接不会关闭,客户端再次访问这个服务器时,会继续使用这一条已经建立的连接。\n如果 TCP 连接一直保持的话也是对资源的浪费,因此,一些服务器软件(如 Apache)还会支持超时时间的时间。在超时时间之内没有新的请求达到,TCP 连接才会被关闭。\n有必要说明的是,HTTP/1.0 仍提供了长连接选项,即在请求头中加入Connection: Keep-alive。同样的,在 HTTP/1.1 中,如果不希望使用长连接选项,也可以在请求头中加入Connection: close,这样会通知服务器端:“我不需要长连接,连接成功后即可关闭”。\nHTTP 协议的长连接和短连接,实质上是 TCP 协议的长连接和短连接。\n实现长连接需要客户端和服务端都支持长连接。\nHost 头处理 # 域名系统(DNS)允许多个主机名绑定到同一个 IP 地址上,但是 HTTP/1.0 并没有考虑这个问题,假设我们有一个资源 URL 是 http://example1.org/home.html,HTTP/1.0 的请求报文中,将会请求的是GET /home.html HTTP/1.0.也就是不会加入主机名。这样的报文送到服务器端,服务器是理解不了客户端想请求的真正网址。\n因此,HTTP/1.1 在请求头中加入了Host字段。加入Host字段的报文头部将会是:\nGET /home.html HTTP/1.1 Host: example1.org 这样,服务器端就可以确定客户端想要请求的真正的网址了。\n带宽优化 # 范围请求 # HTTP/1.1 引入了范围请求(range request)机制,以避免带宽的浪费。当客户端想请求一个文件的一部分,或者需要继续下载一个已经下载了部分但被终止的文件,HTTP/1.1 可以在请求中加入Range头部,以请求(并只能请求字节型数据)数据的一部分。服务器端可以忽略Range头部,也可以返回若干Range响应。\n206 (Partial Content) 状态码的主要作用是确保客户端和代理服务器能正确识别部分内容响应,避免将其误认为完整资源并错误地缓存。这对于正确处理范围请求和缓存管理非常重要。\n一个典型的 HTTP/1.1 范围请求示例:\n# 获取一个文件的前 1024 个字节 GET /z4d4kWk.jpg HTTP/1.1 Host: i.imgur.com Range: bytes=0-1023 206 Partial Content 响应:\nHTTP/1.1 206 Partial Content Content-Range: bytes 0-1023/146515 Content-Length: 1024 … (二进制内容) 简单解释一下 HTTP 范围响应头部中的字段:\nContent-Range 头部:指示返回数据在整个资源中的位置,包括起始和结束字节以及资源的总长度。例如,Content-Range: bytes 0-1023/146515 表示服务器端返回了第 0 到 1023 字节的数据(共 1024 字节),而整个资源的总长度是 146,515 字节。 Content-Length 头部:指示此次响应中实际传输的字节数。例如,Content-Length: 1024 表示服务器端传输了 1024 字节的数据。 Range 请求头不仅可以请求单个字节范围,还可以一次性请求多个范围。这种方式被称为“多重范围请求”(multiple range requests)。\n客户端想要获取资源的第 0 到 499 字节以及第 1000 到 1499 字节:\nGET /path/to/resource HTTP/1.1 Host: example.com Range: bytes=0-499,1000-1499 服务器端返回多个字节范围,每个范围的内容以分隔符分开:\nHTTP/1.1 206 Partial Content Content-Type: multipart/byteranges; boundary=3d6b6a416f9b5 Content-Length: 376 --3d6b6a416f9b5 Content-Type: application/octet-stream Content-Range: bytes 0-99/2000 (第 0 到 99 字节的数据块) --3d6b6a416f9b5 Content-Type: application/octet-stream Content-Range: bytes 500-599/2000 (第 500 到 599 字节的数据块) --3d6b6a416f9b5 Content-Type: application/octet-stream Content-Range: bytes 1000-1099/2000 (第 1000 到 1099 字节的数据块) --3d6b6a416f9b5-- 状态码 100 # HTTP/1.1 中新加入了状态码100。该状态码的使用场景为,存在某些较大的文件请求,服务器可能不愿意响应这种请求,此时状态码100可以作为指示请求是否会被正常响应,过程如下图:\n然而在 HTTP/1.0 中,并没有100 (Continue)状态码,要想触发这一机制,可以发送一个Expect头部,其中包含一个100-continue的值。\n压缩 # 许多格式的数据在传输时都会做预压缩处理。数据的压缩可以大幅优化带宽的利用。然而,HTTP/1.0 对数据压缩的选项提供的不多,不支持压缩细节的选择,也无法区分端到端(end-to-end)压缩或者是逐跳(hop-by-hop)压缩。\nHTTP/1.1 则对内容编码(content-codings)和传输编码(transfer-codings)做了区分。内容编码总是端到端的,传输编码总是逐跳的。\nHTTP/1.0 包含了Content-Encoding头部,对消息进行端到端编码。HTTP/1.1 加入了Transfer-Encoding头部,可以对消息进行逐跳传输编码。HTTP/1.1 还加入了Accept-Encoding头部,是客户端用来指示他能处理什么样的内容编码。\n总结 # 连接方式 : HTTP 1.0 为短连接,HTTP 1.1 支持长连接。 状态响应码 : HTTP/1.1 中新加入了大量的状态码,光是错误响应状态码就新增了 24 种。比如说,100 (Continue)——在请求大资源前的预热请求,206 (Partial Content)——范围请求的标识码,409 (Conflict)——请求与当前资源的规定冲突,410 (Gone)——资源已被永久转移,而且没有任何已知的转发地址。 缓存处理 : 在 HTTP1.0 中主要使用 header 里的 If-Modified-Since,Expires 来做为缓存判断的标准,HTTP1.1 则引入了更多的缓存控制策略例如 Entity tag,If-Unmodified-Since, If-Match, If-None-Match 等更多可供选择的缓存头来控制缓存策略。 带宽优化及网络连接的使用 :HTTP1.0 中,存在一些浪费带宽的现象,例如客户端只是需要某个对象的一部分,而服务器却将整个对象送过来了,并且不支持断点续传功能,HTTP1.1 则在请求头引入了 range 头域,它允许只请求资源的某个部分,即返回码是 206(Partial Content),这样就方便了开发者自由的选择以便于充分利用带宽和连接。 Host 头处理 : HTTP/1.1 在请求头中加入了Host字段。 参考资料 # Key differences between HTTP/1.0 and HTTP/1.1\n"},{"id":481,"href":"/zh/docs/technology/Interview/cs-basics/network/http-vs-https/","title":"HTTP vs HTTPS(应用层)","section":"Network","content":" HTTP 协议 # HTTP 协议介绍 # HTTP 协议,全称超文本传输协议(Hypertext Transfer Protocol)。顾名思义,HTTP 协议就是用来规范超文本的传输,超文本,也就是网络上的包括文本在内的各式各样的消息,具体来说,主要是来规范浏览器和服务器端的行为的。\n并且,HTTP 是一个无状态(stateless)协议,也就是说服务器不维护任何有关客户端过去所发请求的消息。这其实是一种懒政,有状态协议会更加复杂,需要维护状态(历史信息),而且如果客户或服务器失效,会产生状态的不一致,解决这种不一致的代价更高。\nHTTP 协议通信过程 # HTTP 是应用层协议,它以 TCP(传输层)作为底层协议,默认端口为 80. 通信过程主要如下:\n服务器在 80 端口等待客户的请求。 浏览器发起到服务器的 TCP 连接(创建套接字 Socket)。 服务器接收来自浏览器的 TCP 连接。 浏览器(HTTP 客户端)与 Web 服务器(HTTP 服务器)交换 HTTP 消息。 关闭 TCP 连接。 HTTP 协议优点 # 扩展性强、速度快、跨平台支持性好。\nHTTPS 协议 # HTTPS 协议介绍 # HTTPS 协议(Hyper Text Transfer Protocol Secure),是 HTTP 的加强安全版本。HTTPS 是基于 HTTP 的,也是用 TCP 作为底层协议,并额外使用 SSL/TLS 协议用作加密和安全认证。默认端口号是 443.\nHTTPS 协议中,SSL 通道通常使用基于密钥的加密算法,密钥长度通常是 40 比特或 128 比特。\nHTTPS 协议优点 # 保密性好、信任度高。\nHTTPS 的核心—SSL/TLS 协议 # HTTPS 之所以能达到较高的安全性要求,就是结合了 SSL/TLS 和 TCP 协议,对通信数据进行加密,解决了 HTTP 数据透明的问题。接下来重点介绍一下 SSL/TLS 的工作原理。\nSSL 和 TLS 的区别? # SSL 和 TLS 没有太大的区别。\nSSL 指安全套接字协议(Secure Sockets Layer),首次发布与 1996 年。SSL 的首次发布其实已经是他的 3.0 版本,SSL 1.0 从未面世,SSL 2.0 则具有较大的缺陷(DROWN 缺陷——Decrypting RSA with Obsolete and Weakened eNcryption)。很快,在 1999 年,SSL 3.0 进一步升级,新版本被命名为 TLS 1.0。因此,TLS 是基于 SSL 之上的,但由于习惯叫法,通常把 HTTPS 中的核心加密协议混称为 SSL/TLS。\nSSL/TLS 的工作原理 # 非对称加密 # SSL/TLS 的核心要素是非对称加密。非对称加密采用两个密钥——一个公钥,一个私钥。在通信时,私钥仅由解密者保存,公钥由任何一个想与解密者通信的发送者(加密者)所知。可以设想一个场景,\n在某个自助邮局,每个通信信道都是一个邮箱,每一个邮箱所有者都在旁边立了一个牌子,上面挂着一把钥匙:这是我的公钥,发送者请将信件放入我的邮箱,并用公钥锁好。\n但是公钥只能加锁,并不能解锁。解锁只能由邮箱的所有者——因为只有他保存着私钥。\n这样,通信信息就不会被其他人截获了,这依赖于私钥的保密性。\n非对称加密的公钥和私钥需要采用一种复杂的数学机制生成(密码学认为,为了较高的安全性,尽量不要自己创造加密方案)。公私钥对的生成算法依赖于单向陷门函数。\n单向函数:已知单向函数 f,给定任意一个输入 x,易计算输出 y=f(x);而给定一个输出 y,假设存在 f(x)=y,很难根据 f 来计算出 x。\n单向陷门函数:一个较弱的单向函数。已知单向陷门函数 f,陷门 h,给定任意一个输入 x,易计算出输出 y=f(x;h);而给定一个输出 y,假设存在 f(x;h)=y,很难根据 f 来计算出 x,但可以根据 f 和 h 来推导出 x。\n上图就是一个单向函数(不是单项陷门函数),假设有一个绝世秘籍,任何知道了这个秘籍的人都可以把苹果汁榨成苹果,那么这个秘籍就是“陷门”了吧。\n在这里,函数 f 的计算方法相当于公钥,陷门 h 相当于私钥。公钥 f 是公开的,任何人对已有输入,都可以用 f 加密,而要想根据加密信息还原出原信息,必须要有私钥才行。\n对称加密 # 使用 SSL/TLS 进行通信的双方需要使用非对称加密方案来通信,但是非对称加密设计了较为复杂的数学算法,在实际通信过程中,计算的代价较高,效率太低,因此,SSL/TLS 实际对消息的加密使用的是对称加密。\n对称加密:通信双方共享唯一密钥 k,加解密算法已知,加密方利用密钥 k 加密,解密方利用密钥 k 解密,保密性依赖于密钥 k 的保密性。\n对称加密的密钥生成代价比公私钥对的生成代价低得多,那么有的人会问了,为什么 SSL/TLS 还需要使用非对称加密呢?因为对称加密的保密性完全依赖于密钥的保密性。在双方通信之前,需要商量一个用于对称加密的密钥。我们知道网络通信的信道是不安全的,传输报文对任何人是可见的,密钥的交换肯定不能直接在网络信道中传输。因此,使用非对称加密,对对称加密的密钥进行加密,保护该密钥不在网络信道中被窃听。这样,通信双方只需要一次非对称加密,交换对称加密的密钥,在之后的信息通信中,使用绝对安全的密钥,对信息进行对称加密,即可保证传输消息的保密性。\n公钥传输的信赖性 # SSL/TLS 介绍到这里,了解信息安全的朋友又会想到一个安全隐患,设想一个下面的场景:\n客户端 C 和服务器 S 想要使用 SSL/TLS 通信,由上述 SSL/TLS 通信原理,C 需要先知道 S 的公钥,而 S 公钥的唯一获取途径,就是把 S 公钥在网络信道中传输。要注意网络信道通信中有几个前提:\n任何人都可以捕获通信包 通信包的保密性由发送者设计 保密算法设计方案默认为公开,而(解密)密钥默认是安全的 因此,假设 S 公钥不做加密,在信道中传输,那么很有可能存在一个攻击者 A,发送给 C 一个诈包,假装是 S 公钥,其实是诱饵服务器 AS 的公钥。当 C 收获了 AS 的公钥(却以为是 S 的公钥),C 后续就会使用 AS 公钥对数据进行加密,并在公开信道传输,那么 A 将捕获这些加密包,用 AS 的私钥解密,就截获了 C 本要给 S 发送的内容,而 C 和 S 二人全然不知。\n同样的,S 公钥即使做加密,也难以避免这种信任性问题,C 被 AS 拐跑了!\n为了公钥传输的信赖性问题,第三方机构应运而生——证书颁发机构(CA,Certificate Authority)。CA 默认是受信任的第三方。CA 会给各个服务器颁发证书,证书存储在服务器上,并附有 CA 的电子签名(见下节)。\n当客户端(浏览器)向服务器发送 HTTPS 请求时,一定要先获取目标服务器的证书,并根据证书上的信息,检验证书的合法性。一旦客户端检测到证书非法,就会发生错误。客户端获取了服务器的证书后,由于证书的信任性是由第三方信赖机构认证的,而证书上又包含着服务器的公钥信息,客户端就可以放心的信任证书上的公钥就是目标服务器的公钥。\n数字签名 # 好,到这一小节,已经是 SSL/TLS 的尾声了。上一小节提到了数字签名,数字签名要解决的问题,是防止证书被伪造。第三方信赖机构 CA 之所以能被信赖,就是 靠数字签名技术 。\n数字签名,是 CA 在给服务器颁发证书时,使用散列+加密的组合技术,在证书上盖个章,以此来提供验伪的功能。具体行为如下:\nCA 知道服务器的公钥,对证书采用散列技术生成一个摘要。CA 使用 CA 私钥对该摘要进行加密,并附在证书下方,发送给服务器。\n现在服务器将该证书发送给客户端,客户端需要验证该证书的身份。客户端找到第三方机构 CA,获知 CA 的公钥,并用 CA 公钥对证书的签名进行解密,获得了 CA 生成的摘要。\n客户端对证书数据(包含服务器的公钥)做相同的散列处理,得到摘要,并将该摘要与之前从签名中解码出的摘要做对比,如果相同,则身份验证成功;否则验证失败。\n总结来说,带有证书的公钥传输机制如下:\n设有服务器 S,客户端 C,和第三方信赖机构 CA。 S 信任 CA,CA 是知道 S 公钥的,CA 向 S 颁发证书。并附上 CA 私钥对消息摘要的加密签名。 S 获得 CA 颁发的证书,将该证书传递给 C。 C 获得 S 的证书,信任 CA 并知晓 CA 公钥,使用 CA 公钥对 S 证书上的签名解密,同时对消息进行散列处理,得到摘要。比较摘要,验证 S 证书的真实性。 如果 C 验证 S 证书是真实的,则信任 S 的公钥(在 S 证书中)。 对于数字签名,我这里讲的比较简单,如果你没有搞清楚的话,强烈推荐你看看 数字签名及数字证书原理这个视频,这是我看过最清晰的讲解。\n总结 # 端口号:HTTP 默认是 80,HTTPS 默认是 443。 URL 前缀:HTTP 的 URL 前缀是 http://,HTTPS 的 URL 前缀是 https://。 安全性和资源消耗:HTTP 协议运行在 TCP 之上,所有传输的内容都是明文,客户端和服务器端都无法验证对方的身份。HTTPS 是运行在 SSL/TLS 之上的 HTTP 协议,SSL/TLS 运行在 TCP 之上。所有传输的内容都经过加密,加密采用对称加密,但对称加密的密钥用服务器方的证书进行了非对称加密。所以说,HTTP 安全性没有 HTTPS 高,但是 HTTPS 比 HTTP 耗费更多服务器资源。 "},{"id":482,"href":"/zh/docs/technology/Interview/cs-basics/network/http-status-codes/","title":"HTTP 常见状态码总结(应用层)","section":"Network","content":"HTTP 状态码用于描述 HTTP 请求的结果,比如 2xx 就代表请求被成功处理。\n1xx Informational(信息性状态码) # 相比于其他类别状态码来说,1xx 你平时你大概率不会碰到,所以这里直接跳过。\n2xx Success(成功状态码) # 200 OK:请求被成功处理。例如,发送一个查询用户数据的 HTTP 请求到服务端,服务端正确返回了用户数据。这个是我们平时最常见的一个 HTTP 状态码。 201 Created:请求被成功处理并且在服务端创建了一个新的资源。例如,通过 POST 请求创建一个新的用户。 202 Accepted:服务端已经接收到了请求,但是还未处理。例如,发送一个需要服务端花费较长时间处理的请求(如报告生成、Excel 导出),服务端接收了请求但尚未处理完毕。 204 No Content:服务端已经成功处理了请求,但是没有返回任何内容。例如,发送请求删除一个用户,服务器成功处理了删除操作但没有返回任何内容。 🐛 修正(参见: issue#2458):201 Created 状态码更准确点来说是创建一个或多个新的资源,可以参考: https://httpwg.org/specs/rfc9110.html#status.201。\n这里格外提一下 204 状态码,平时学习/工作中见到的次数并不多。\nHTTP RFC 2616 对 204 状态码的描述如下:\nThe server has fulfilled the request but does not need to return an entity-body, and might want to return updated metainformation. The response MAY include new or updated metainformation in the form of entity-headers, which if present SHOULD be associated with the requested variant.\nIf the client is a user agent, it SHOULD NOT change its document view from that which caused the request to be sent. This response is primarily intended to allow input for actions to take place without causing a change to the user agent\u0026rsquo;s active document view, although any new or updated metainformation SHOULD be applied to the document currently in the user agent\u0026rsquo;s active view.\nThe 204 response MUST NOT include a message-body, and thus is always terminated by the first empty line after the header fields.\n简单来说,204 状态码描述的是我们向服务端发送 HTTP 请求之后,只关注处理结果是否成功的场景。也就是说我们需要的就是一个结果:true/false。\n举个例子:你要追一个女孩子,你问女孩子:“我能追你吗?”,女孩子回答:“好!”。我们把这个女孩子当做是服务端就很好理解 204 状态码了。\n3xx Redirection(重定向状态码) # 301 Moved Permanently:资源被永久重定向了。比如你的网站的网址更换了。 302 Found:资源被临时重定向了。比如你的网站的某些资源被暂时转移到另外一个网址。 4xx Client Error(客户端错误状态码) # 400 Bad Request:发送的 HTTP 请求存在问题。比如请求参数不合法、请求方法错误。 401 Unauthorized:未认证却请求需要认证之后才能访问的资源。 403 Forbidden:直接拒绝 HTTP 请求,不处理。一般用来针对非法请求。 404 Not Found:你请求的资源未在服务端找到。比如你请求某个用户的信息,服务端并没有找到指定的用户。 409 Conflict:表示请求的资源与服务端当前的状态存在冲突,请求无法被处理。 5xx Server Error(服务端错误状态码) # 500 Internal Server Error:服务端出问题了(通常是服务端出 Bug 了)。比如你服务端处理请求的时候突然抛出异常,但是异常并未在服务端被正确处理。 502 Bad Gateway:我们的网关将请求转发到服务端,但是服务端返回的却是一个错误的响应。 参考 # https://www.restapitutorial.com/httpstatuscodes.html https://developer.mozilla.org/zh-CN/docs/Web/HTTP/Status https://en.wikipedia.org/wiki/List_of_HTTP_status_codes https://segmentfault.com/a/1190000018264501 "},{"id":483,"href":"/zh/docs/technology/Interview/database/mysql/innodb-implementation-of-mvcc/","title":"InnoDB存储引擎对MVCC的实现","section":"Mysql","content":" 多版本并发控制 (Multi-Version Concurrency Control) # MVCC 是一种并发控制机制,用于在多个并发事务同时读写数据库时保持数据的一致性和隔离性。它是通过在每个数据行上维护多个版本的数据来实现的。当一个事务要对数据库中的数据进行修改时,MVCC 会为该事务创建一个数据快照,而不是直接修改实际的数据行。\n1、读操作(SELECT):\n当一个事务执行读操作时,它会使用快照读取。快照读取是基于事务开始时数据库中的状态创建的,因此事务不会读取其他事务尚未提交的修改。具体工作情况如下:\n对于读取操作,事务会查找符合条件的数据行,并选择符合其事务开始时间的数据版本进行读取。 如果某个数据行有多个版本,事务会选择不晚于其开始时间的最新版本,确保事务只读取在它开始之前已经存在的数据。 事务读取的是快照数据,因此其他并发事务对数据行的修改不会影响当前事务的读取操作。 2、写操作(INSERT、UPDATE、DELETE):\n当一个事务执行写操作时,它会生成一个新的数据版本,并将修改后的数据写入数据库。具体工作情况如下:\n对于写操作,事务会为要修改的数据行创建一个新的版本,并将修改后的数据写入新版本。 新版本的数据会带有当前事务的版本号,以便其他事务能够正确读取相应版本的数据。 原始版本的数据仍然存在,供其他事务使用快照读取,这保证了其他事务不受当前事务的写操作影响。 3、事务提交和回滚:\n当一个事务提交时,它所做的修改将成为数据库的最新版本,并且对其他事务可见。 当一个事务回滚时,它所做的修改将被撤销,对其他事务不可见。 4、版本的回收:\n为了防止数据库中的版本无限增长,MVCC 会定期进行版本的回收。回收机制会删除已经不再需要的旧版本数据,从而释放空间。\nMVCC 通过创建数据的多个版本和使用快照读取来实现并发控制。读操作使用旧版本数据的快照,写操作创建新版本,并确保原始版本仍然可用。这样,不同的事务可以在一定程度上并发执行,而不会相互干扰,从而提高了数据库的并发性能和数据一致性。\n一致性非锁定读和锁定读 # 一致性非锁定读 # 对于 一致性非锁定读(Consistent Nonlocking Reads)的实现,通常做法是加一个版本号或者时间戳字段,在更新数据的同时版本号 + 1 或者更新时间戳。查询时,将当前可见的版本号与对应记录的版本号进行比对,如果记录的版本小于可见版本,则表示该记录可见\n在 InnoDB 存储引擎中, 多版本控制 (multi versioning) 就是对非锁定读的实现。如果读取的行正在执行 DELETE 或 UPDATE 操作,这时读取操作不会去等待行上锁的释放。相反地,InnoDB 存储引擎会去读取行的一个快照数据,对于这种读取历史数据的方式,我们叫它快照读 (snapshot read)\n在 Repeatable Read 和 Read Committed 两个隔离级别下,如果是执行普通的 select 语句(不包括 select ... lock in share mode ,select ... for update)则会使用 一致性非锁定读(MVCC)。并且在 Repeatable Read 下 MVCC 实现了可重复读和防止部分幻读\n锁定读 # 如果执行的是下列语句,就是 锁定读(Locking Reads)\nselect ... lock in share mode select ... for update insert、update、delete 操作 在锁定读下,读取的是数据的最新版本,这种读也被称为 当前读(current read)。锁定读会对读取到的记录加锁:\nselect ... lock in share mode:对记录加 S 锁,其它事务也可以加S锁,如果加 x 锁则会被阻塞\nselect ... for update、insert、update、delete:对记录加 X 锁,且其它事务不能加任何锁\n在一致性非锁定读下,即使读取的记录已被其它事务加上 X 锁,这时记录也是可以被读取的,即读取的快照数据。上面说了,在 Repeatable Read 下 MVCC 防止了部分幻读,这边的 “部分” 是指在 一致性非锁定读 情况下,只能读取到第一次查询之前所插入的数据(根据 Read View 判断数据可见性,Read View 在第一次查询时生成)。但是!如果是 当前读 ,每次读取的都是最新数据,这时如果两次查询中间有其它事务插入数据,就会产生幻读。所以, InnoDB 在实现Repeatable Read 时,如果执行的是当前读,则会对读取的记录使用 Next-key Lock ,来防止其它事务在间隙间插入数据\nInnoDB 对 MVCC 的实现 # MVCC 的实现依赖于:隐藏字段、Read View、undo log。在内部实现中,InnoDB 通过数据行的 DB_TRX_ID 和 Read View 来判断数据的可见性,如不可见,则通过数据行的 DB_ROLL_PTR 找到 undo log 中的历史版本。每个事务读到的数据版本可能是不一样的,在同一个事务中,用户只能看到该事务创建 Read View 之前已经提交的修改和该事务本身做的修改\n隐藏字段 # 在内部,InnoDB 存储引擎为每行数据添加了三个 隐藏字段:\nDB_TRX_ID(6字节):表示最后一次插入或更新该行的事务 id。此外,delete 操作在内部被视为更新,只不过会在记录头 Record header 中的 deleted_flag 字段将其标记为已删除 DB_ROLL_PTR(7字节) 回滚指针,指向该行的 undo log 。如果该行未被更新,则为空 DB_ROW_ID(6字节):如果没有设置主键且该表没有唯一非空索引时,InnoDB 会使用该 id 来生成聚簇索引 ReadView # class ReadView { /* ... */ private: trx_id_t m_low_limit_id; /* 大于等于这个 ID 的事务均不可见 */ trx_id_t m_up_limit_id; /* 小于这个 ID 的事务均可见 */ trx_id_t m_creator_trx_id; /* 创建该 Read View 的事务ID */ trx_id_t m_low_limit_no; /* 事务 Number, 小于该 Number 的 Undo Logs 均可以被 Purge */ ids_t m_ids; /* 创建 Read View 时的活跃事务列表 */ m_closed; /* 标记 Read View 是否 close */ } Read View 主要是用来做可见性判断,里面保存了 “当前对本事务不可见的其他活跃事务”\n主要有以下字段:\nm_low_limit_id:目前出现过的最大的事务 ID+1,即下一个将被分配的事务 ID。大于等于这个 ID 的数据版本均不可见 m_up_limit_id:活跃事务列表 m_ids 中最小的事务 ID,如果 m_ids 为空,则 m_up_limit_id 为 m_low_limit_id。小于这个 ID 的数据版本均可见 m_ids:Read View 创建时其他未提交的活跃事务 ID 列表。创建 Read View时,将当前未提交事务 ID 记录下来,后续即使它们修改了记录行的值,对于当前事务也是不可见的。m_ids 不包括当前事务自己和已提交的事务(正在内存中) m_creator_trx_id:创建该 Read View 的事务 ID 事务可见性示意图( 图源):\nundo-log # undo log 主要有两个作用:\n当事务回滚时用于将数据恢复到修改前的样子 另一个作用是 MVCC ,当读取记录时,若该记录被其他事务占用或当前版本对该事务不可见,则可以通过 undo log 读取之前的版本数据,以此实现非锁定读 在 InnoDB 存储引擎中 undo log 分为两种:insert undo log 和 update undo log:\ninsert undo log:指在 insert 操作中产生的 undo log。因为 insert 操作的记录只对事务本身可见,对其他事务不可见,故该 undo log 可以在事务提交后直接删除。不需要进行 purge 操作 insert 时的数据初始状态:\nupdate undo log:update 或 delete 操作中产生的 undo log。该 undo log可能需要提供 MVCC 机制,因此不能在事务提交时就进行删除。提交时放入 undo log 链表,等待 purge线程 进行最后的删除 数据第一次被修改时:\n数据第二次被修改时:\n不同事务或者相同事务的对同一记录行的修改,会使该记录行的 undo log 成为一条链表,链首就是最新的记录,链尾就是最早的旧记录。\n数据可见性算法 # 在 InnoDB 存储引擎中,创建一个新事务后,执行每个 select 语句前,都会创建一个快照(Read View),快照中保存了当前数据库系统中正处于活跃(没有 commit)的事务的 ID 号。其实简单的说保存的是系统中当前不应该被本事务看到的其他事务 ID 列表(即 m_ids)。当用户在这个事务中要读取某个记录行的时候,InnoDB 会将该记录行的 DB_TRX_ID 与 Read View 中的一些变量及当前事务 ID 进行比较,判断是否满足可见性条件\n具体的比较算法如下( 图源):\n如果记录 DB_TRX_ID \u0026lt; m_up_limit_id,那么表明最新修改该行的事务(DB_TRX_ID)在当前事务创建快照之前就提交了,所以该记录行的值对当前事务是可见的\n如果 DB_TRX_ID \u0026gt;= m_low_limit_id,那么表明最新修改该行的事务(DB_TRX_ID)在当前事务创建快照之后才修改该行,所以该记录行的值对当前事务不可见。跳到步骤 5\nm_ids 为空,则表明在当前事务创建快照之前,修改该行的事务就已经提交了,所以该记录行的值对当前事务是可见的\n如果 m_up_limit_id \u0026lt;= DB_TRX_ID \u0026lt; m_low_limit_id,表明最新修改该行的事务(DB_TRX_ID)在当前事务创建快照的时候可能处于“活动状态”或者“已提交状态”;所以就要对活跃事务列表 m_ids 进行查找(源码中是用的二分查找,因为是有序的)\n如果在活跃事务列表 m_ids 中能找到 DB_TRX_ID,表明:① 在当前事务创建快照前,该记录行的值被事务 ID 为 DB_TRX_ID 的事务修改了,但没有提交;或者 ② 在当前事务创建快照后,该记录行的值被事务 ID 为 DB_TRX_ID 的事务修改了。这些情况下,这个记录行的值对当前事务都是不可见的。跳到步骤 5\n在活跃事务列表中找不到,则表明“id 为 trx_id 的事务”在修改“该记录行的值”后,在“当前事务”创建快照前就已经提交了,所以记录行对当前事务可见\n在该记录行的 DB_ROLL_PTR 指针所指向的 undo log 取出快照记录,用快照记录的 DB_TRX_ID 跳到步骤 1 重新开始判断,直到找到满足的快照版本或返回空\nRC 和 RR 隔离级别下 MVCC 的差异 # 在事务隔离级别 RC 和 RR (InnoDB 存储引擎的默认事务隔离级别)下,InnoDB 存储引擎使用 MVCC(非锁定一致性读),但它们生成 Read View 的时机却不同\n在 RC 隔离级别下的 每次select 查询前都生成一个Read View (m_ids 列表) 在 RR 隔离级别下只在事务开始后 第一次select 数据前生成一个Read View(m_ids 列表) MVCC 解决不可重复读问题 # 虽然 RC 和 RR 都通过 MVCC 来读取快照数据,但由于 生成 Read View 时机不同,从而在 RR 级别下实现可重复读\n举个例子:\n在 RC 下 ReadView 生成情况 # 1. 假设时间线来到 T4 ,那么此时数据行 id = 1 的版本链为:\n由于 RC 级别下每次查询都会生成Read View ,并且事务 101、102 并未提交,此时 103 事务生成的 Read View 中活跃的事务 m_ids 为:[101,102] ,m_low_limit_id为:104,m_up_limit_id为:101,m_creator_trx_id 为:103\n此时最新记录的 DB_TRX_ID 为 101,m_up_limit_id \u0026lt;= 101 \u0026lt; m_low_limit_id,所以要在 m_ids 列表中查找,发现 DB_TRX_ID 存在列表中,那么这个记录不可见 根据 DB_ROLL_PTR 找到 undo log 中的上一版本记录,上一条记录的 DB_TRX_ID 还是 101,不可见 继续找上一条 DB_TRX_ID为 1,满足 1 \u0026lt; m_up_limit_id,可见,所以事务 103 查询到数据为 name = 菜花 2. 时间线来到 T6 ,数据的版本链为:\n因为在 RC 级别下,重新生成 Read View,这时事务 101 已经提交,102 并未提交,所以此时 Read View 中活跃的事务 m_ids:[102] ,m_low_limit_id为:104,m_up_limit_id为:102,m_creator_trx_id为:103\n此时最新记录的 DB_TRX_ID 为 102,m_up_limit_id \u0026lt;= 102 \u0026lt; m_low_limit_id,所以要在 m_ids 列表中查找,发现 DB_TRX_ID 存在列表中,那么这个记录不可见\n根据 DB_ROLL_PTR 找到 undo log 中的上一版本记录,上一条记录的 DB_TRX_ID 为 101,满足 101 \u0026lt; m_up_limit_id,记录可见,所以在 T6 时间点查询到数据为 name = 李四,与时间 T4 查询到的结果不一致,不可重复读!\n3. 时间线来到 T9 ,数据的版本链为:\n重新生成 Read View, 这时事务 101 和 102 都已经提交,所以 m_ids 为空,则 m_up_limit_id = m_low_limit_id = 104,最新版本事务 ID 为 102,满足 102 \u0026lt; m_low_limit_id,可见,查询结果为 name = 赵六\n总结: 在 RC 隔离级别下,事务在每次查询开始时都会生成并设置新的 Read View,所以导致不可重复读\n在 RR 下 ReadView 生成情况 # 在可重复读级别下,只会在事务开始后第一次读取数据时生成一个 Read View(m_ids 列表)\n1. 在 T4 情况下的版本链为:\n在当前执行 select 语句时生成一个 Read View,此时 m_ids:[101,102] ,m_low_limit_id为:104,m_up_limit_id为:101,m_creator_trx_id 为:103\n此时和 RC 级别下一样:\n最新记录的 DB_TRX_ID 为 101,m_up_limit_id \u0026lt;= 101 \u0026lt; m_low_limit_id,所以要在 m_ids 列表中查找,发现 DB_TRX_ID 存在列表中,那么这个记录不可见 根据 DB_ROLL_PTR 找到 undo log 中的上一版本记录,上一条记录的 DB_TRX_ID 还是 101,不可见 继续找上一条 DB_TRX_ID为 1,满足 1 \u0026lt; m_up_limit_id,可见,所以事务 103 查询到数据为 name = 菜花 2. 时间点 T6 情况下:\n在 RR 级别下只会生成一次Read View,所以此时依然沿用 m_ids:[101,102] ,m_low_limit_id为:104,m_up_limit_id为:101,m_creator_trx_id 为:103\n最新记录的 DB_TRX_ID 为 102,m_up_limit_id \u0026lt;= 102 \u0026lt; m_low_limit_id,所以要在 m_ids 列表中查找,发现 DB_TRX_ID 存在列表中,那么这个记录不可见\n根据 DB_ROLL_PTR 找到 undo log 中的上一版本记录,上一条记录的 DB_TRX_ID 为 101,不可见\n继续根据 DB_ROLL_PTR 找到 undo log 中的上一版本记录,上一条记录的 DB_TRX_ID 还是 101,不可见\n继续找上一条 DB_TRX_ID为 1,满足 1 \u0026lt; m_up_limit_id,可见,所以事务 103 查询到数据为 name = 菜花\n3. 时间点 T9 情况下:\n此时情况跟 T6 完全一样,由于已经生成了 Read View,此时依然沿用 m_ids:[101,102] ,所以查询结果依然是 name = 菜花\nMVCC➕Next-key-Lock 防止幻读 # InnoDB存储引擎在 RR 级别下通过 MVCC和 Next-key Lock 来解决幻读问题:\n1、执行普通 select,此时会以 MVCC 快照读的方式读取数据\n在快照读的情况下,RR 隔离级别只会在事务开启后的第一次查询生成 Read View ,并使用至事务提交。所以在生成 Read View 之后其它事务所做的更新、插入记录版本对当前事务并不可见,实现了可重复读和防止快照读下的 “幻读”\n2、执行 select\u0026hellip;for update/lock in share mode、insert、update、delete 等当前读\n在当前读下,读取的都是最新的数据,如果其它事务有插入新的记录,并且刚好在当前事务查询范围内,就会产生幻读!InnoDB 使用 Next-key Lock 来防止这种情况。当执行当前读时,会锁定读取到的记录的同时,锁定它们的间隙,防止其它事务在查询范围内插入数据。只要我不让你插入,就不会发生幻读\n参考 # 《MySQL 技术内幕 InnoDB 存储引擎第 2 版》 Innodb 中的事务隔离级别和锁的关系 MySQL 事务与 MVCC 如何实现的隔离级别 InnoDB 事务分析-MVCC "},{"id":484,"href":"/zh/docs/technology/Interview/system-design/framework/spring/ioc-and-aop/","title":"IoC \u0026 AOP详解(快速搞懂)","section":"Framework","content":"这篇文章会从下面从以下几个问题展开对 IoC \u0026amp; AOP 的解释\n什么是 IoC? IoC 解决了什么问题? IoC 和 DI 的区别? 什么是 AOP? AOP 解决了什么问题? AOP 的应用场景有哪些? AOP 为什么叫做切面编程? AOP 实现方式有哪些? 首先声明:IoC \u0026amp; AOP 不是 Spring 提出来的,它们在 Spring 之前其实已经存在了,只不过当时更加偏向于理论。Spring 在技术层次将这两个思想进行了很好的实现。\nIoC (Inversion of control ) # 什么是 IoC? # IoC (Inversion of Control )即控制反转/反转控制。它是一种思想不是一个技术实现。描述的是:Java 开发领域对象的创建以及管理的问题。\n例如:现有类 A 依赖于类 B\n传统的开发方式 :往往是在类 A 中手动通过 new 关键字来 new 一个 B 的对象出来 使用 IoC 思想的开发方式 :不通过 new 关键字来创建对象,而是通过 IoC 容器(Spring 框架) 来帮助我们实例化对象。我们需要哪个对象,直接从 IoC 容器里面去取即可。 从以上两种开发方式的对比来看:我们 “丧失了一个权力” (创建、管理对象的权力),从而也得到了一个好处(不用再考虑对象的创建、管理等一系列的事情)\n为什么叫控制反转?\n控制 :指的是对象创建(实例化、管理)的权力 反转 :控制权交给外部环境(IoC 容器) IoC 解决了什么问题? # IoC 的思想就是两方之间不互相依赖,由第三方容器来管理相关资源。这样有什么好处呢?\n对象之间的耦合度或者说依赖程度降低; 资源变的容易管理;比如你用 Spring 容器提供的话很容易就可以实现一个单例。 例如:现有一个针对 User 的操作,利用 Service 和 Dao 两层结构进行开发\n在没有使用 IoC 思想的情况下,Service 层想要使用 Dao 层的具体实现的话,需要通过 new 关键字在UserServiceImpl 中手动 new 出 IUserDao 的具体实现类 UserDaoImpl(不能直接 new 接口类)。\n很完美,这种方式也是可以实现的,但是我们想象一下如下场景:\n开发过程中突然接到一个新的需求,针对IUserDao 接口开发出另一个具体实现类。因为 Server 层依赖了IUserDao的具体实现,所以我们需要修改UserServiceImpl中 new 的对象。如果只有一个类引用了IUserDao的具体实现,可能觉得还好,修改起来也不是很费力气,但是如果有许许多多的地方都引用了IUserDao的具体实现的话,一旦需要更换IUserDao 的实现方式,那修改起来将会非常的头疼。\n使用 IoC 的思想,我们将对象的控制权(创建、管理)交由 IoC 容器去管理,我们在使用的时候直接向 IoC 容器 “要” 就可以了\nIoC 和 DI 有区别吗? # IoC(Inverse of Control:控制反转)是一种设计思想或者说是某种模式。这个设计思想就是 将原本在程序中手动创建对象的控制权交给第三方比如 IoC 容器。 对于我们常用的 Spring 框架来说, IoC 容器实际上就是个 Map(key,value),Map 中存放的是各种对象。不过,IoC 在其他语言中也有应用,并非 Spring 特有。\nIoC 最常见以及最合理的实现方式叫做依赖注入(Dependency Injection,简称 DI)。\n老马(Martin Fowler)在一篇文章中提到将 IoC 改名为 DI,原文如下,原文地址: https://martinfowler.com/articles/injection.html 。\n老马的大概意思是 IoC 太普遍并且不表意,很多人会因此而迷惑,所以,使用 DI 来精确指名这个模式比较好。\nAOP(Aspect oriented programming) # 这里不会涉及太多专业的术语,核心目的是将 AOP 的思想说清楚。\n什么是 AOP? # AOP(Aspect Oriented Programming)即面向切面编程,AOP 是 OOP(面向对象编程)的一种延续,二者互补,并不对立。\nAOP 的目的是将横切关注点(如日志记录、事务管理、权限控制、接口限流、接口幂等等)从核心业务逻辑中分离出来,通过动态代理、字节码操作等技术,实现代码的复用和解耦,提高代码的可维护性和可扩展性。OOP 的目的是将业务逻辑按照对象的属性和行为进行封装,通过类、对象、继承、多态等概念,实现代码的模块化和层次化(也能实现代码的复用),提高代码的可读性和可维护性。\nAOP 为什么叫面向切面编程? # AOP 之所以叫面向切面编程,是因为它的核心思想就是将横切关注点从核心业务逻辑中分离出来,形成一个个的切面(Aspect)。\n这里顺带总结一下 AOP 关键术语(不理解也没关系,可以继续往下看):\n横切关注点(cross-cutting concerns) :多个类或对象中的公共行为(如日志记录、事务管理、权限控制、接口限流、接口幂等等)。 切面(Aspect):对横切关注点进行封装的类,一个切面是一个类。切面可以定义多个通知,用来实现具体的功能。 连接点(JoinPoint):连接点是方法调用或者方法执行时的某个特定时刻(如方法调用、异常抛出等)。 通知(Advice):通知就是切面在某个连接点要执行的操作。通知有五种类型,分别是前置通知(Before)、后置通知(After)、返回通知(AfterReturning)、异常通知(AfterThrowing)和环绕通知(Around)。前四种通知都是在目标方法的前后执行,而环绕通知可以控制目标方法的执行过程。 切点(Pointcut):一个切点是一个表达式,它用来匹配哪些连接点需要被切面所增强。切点可以通过注解、正则表达式、逻辑运算等方式来定义。比如 execution(* com.xyz.service..*(..))匹配 com.xyz.service 包及其子包下的类或接口。 织入(Weaving):织入是将切面和目标对象连接起来的过程,也就是将通知应用到切点匹配的连接点上。常见的织入时机有两种,分别是编译期织入(Compile-Time Weaving 如:AspectJ)和运行期织入(Runtime Weaving 如:AspectJ、Spring AOP)。 AOP 常见的通知类型有哪些? # Before(前置通知):目标对象的方法调用之前触发 After (后置通知):目标对象的方法调用之后触发 AfterReturning(返回通知):目标对象的方法调用完成,在返回结果值之后触发 AfterThrowing(异常通知):目标对象的方法运行中抛出 / 触发异常后触发。AfterReturning 和 AfterThrowing 两者互斥。如果方法调用成功无异常,则会有返回值;如果方法抛出了异常,则不会有返回值。 Around (环绕通知):编程式控制目标对象的方法调用。环绕通知是所有通知类型中可操作范围最大的一种,因为它可以直接拿到目标对象,以及要执行的方法,所以环绕通知可以任意的在目标对象的方法调用前后搞事,甚至不调用目标对象的方法 AOP 解决了什么问题? # OOP 不能很好地处理一些分散在多个类或对象中的公共行为(如日志记录、事务管理、权限控制、接口限流、接口幂等等),这些行为通常被称为 横切关注点(cross-cutting concerns) 。如果我们在每个类或对象中都重复实现这些行为,那么会导致代码的冗余、复杂和难以维护。\nAOP 可以将横切关注点(如日志记录、事务管理、权限控制、接口限流、接口幂等等)从 核心业务逻辑(core concerns,核心关注点) 中分离出来,实现关注点的分离。\n以日志记录为例进行介绍,假如我们需要对某些方法进行统一格式的日志记录,没有使用 AOP 技术之前,我们需要挨个写日志记录的逻辑代码,全是重复的的逻辑。\npublic CommonResponse\u0026lt;Object\u0026gt; method1() { // 业务逻辑 xxService.method1(); // 省略具体的业务处理逻辑 // 日志记录 ServletRequestAttributes attributes = (ServletRequestAttributes) RequestContextHolder.getRequestAttributes(); HttpServletRequest request = attributes.getRequest(); // 省略记录日志的具体逻辑 如:获取各种信息,写入数据库等操作... return CommonResponse.success(); } public CommonResponse\u0026lt;Object\u0026gt; method2() { // 业务逻辑 xxService.method2(); // 省略具体的业务处理逻辑 // 日志记录 ServletRequestAttributes attributes = (ServletRequestAttributes) RequestContextHolder.getRequestAttributes(); HttpServletRequest request = attributes.getRequest(); // 省略记录日志的具体逻辑 如:获取各种信息,写入数据库等操作... return CommonResponse.success(); } // ... 使用 AOP 技术之后,我们可以将日志记录的逻辑封装成一个切面,然后通过切入点和通知来指定在哪些方法需要执行日志记录的操作。\n// 日志注解 @Target({ElementType.PARAMETER,ElementType.METHOD}) @Retention(RetentionPolicy.RUNTIME) @Documented public @interface Log { /** * 描述 */ String description() default \u0026#34;\u0026#34;; /** * 方法类型 INSERT DELETE UPDATE OTHER */ MethodType methodType() default MethodType.OTHER; } // 日志切面 @Component @Aspect public class LogAspect { // 切入点,所有被 Log 注解标注的方法 @Pointcut(\u0026#34;@annotation(cn.javaguide.annotation.Log)\u0026#34;) public void webLog() { } /** * 环绕通知 */ @Around(\u0026#34;webLog()\u0026#34;) public Object doAround(ProceedingJoinPoint joinPoint) throws Throwable { // 省略具体的处理逻辑 } // 省略其他代码 } 这样的话,我们一行注解即可实现日志记录:\n@Log(description = \u0026#34;method1\u0026#34;,methodType = MethodType.INSERT) public CommonResponse\u0026lt;Object\u0026gt; method1() { // 业务逻辑 xxService.method1(); // 省略具体的业务处理逻辑 return CommonResponse.success(); } AOP 的应用场景有哪些? # 日志记录:自定义日志记录注解,利用 AOP,一行代码即可实现日志记录。 性能统计:利用 AOP 在目标方法的执行前后统计方法的执行时间,方便优化和分析。 事务管理:@Transactional 注解可以让 Spring 为我们进行事务管理比如回滚异常操作,免去了重复的事务管理逻辑。@Transactional注解就是基于 AOP 实现的。 权限控制:利用 AOP 在目标方法执行前判断用户是否具备所需要的权限,如果具备,就执行目标方法,否则就不执行。例如,SpringSecurity 利用@PreAuthorize 注解一行代码即可自定义权限校验。 接口限流:利用 AOP 在目标方法执行前通过具体的限流算法和实现对请求进行限流处理。 缓存管理:利用 AOP 在目标方法执行前后进行缓存的读取和更新。 …… AOP 实现方式有哪些? # AOP 的常见实现方式有动态代理、字节码操作等方式。\nSpring AOP 就是基于动态代理的,如果要代理的对象,实现了某个接口,那么 Spring AOP 会使用 JDK Proxy,去创建代理对象,而对于没有实现接口的对象,就无法使用 JDK Proxy 去进行代理了,这时候 Spring AOP 会使用 Cglib 生成一个被代理对象的子类来作为代理,如下图所示:\n当然你也可以使用 AspectJ !Spring AOP 已经集成了 AspectJ ,AspectJ 应该算的上是 Java 生态系统中最完整的 AOP 框架了。\nSpring AOP 属于运行时增强,而 AspectJ 是编译时增强。 Spring AOP 基于代理(Proxying),而 AspectJ 基于字节码操作(Bytecode Manipulation)。\nSpring AOP 已经集成了 AspectJ ,AspectJ 应该算的上是 Java 生态系统中最完整的 AOP 框架了。AspectJ 相比于 Spring AOP 功能更加强大,但是 Spring AOP 相对来说更简单,\n如果我们的切面比较少,那么两者性能差异不大。但是,当切面太多的话,最好选择 AspectJ ,它比 Spring AOP 快很多。\n"},{"id":485,"href":"/zh/docs/technology/Interview/java/new-features/java10/","title":"Java 10 新特性概览","section":"New Features","content":"Java 10 发布于 2018 年 3 月 20 日,最知名的特性应该是 var 关键字(局部变量类型推断)的引入了,其他还有垃圾收集器改善、GC 改进、性能提升、线程管控等一批新特性。\n概览(精选了一部分):\nJEP 286:局部变量类型推断 JEP 304:垃圾回收器接口 JEP 307:G1 并行 Full GC JEP 310:应用程序类数据共享(扩展 CDS 功能) JEP 317:实验性的基于 Java 的 JIT 编译器 局部变量类型推断(var) # 由于太多 Java 开发者希望 Java 中引入局部变量推断,于是 Java 10 的时候它来了,也算是众望所归了!\nJava 10 提供了 var 关键字声明局部变量。\nvar id = 0; var codefx = new URL(\u0026#34;https://mp.weixin.qq.com/\u0026#34;); var list = new ArrayList\u0026lt;\u0026gt;(); var list = List.of(1, 2, 3); var map = new HashMap\u0026lt;String, String\u0026gt;(); var p = Paths.of(\u0026#34;src/test/java/Java9FeaturesTest.java\u0026#34;); var numbers = List.of(\u0026#34;a\u0026#34;, \u0026#34;b\u0026#34;, \u0026#34;c\u0026#34;); for (var n : list) System.out.print(n+ \u0026#34; \u0026#34;); var 关键字只能用于带有构造器的局部变量和 for 循环中。\nvar count=null; //❌编译不通过,不能声明为 null var r = () -\u0026gt; Math.random();//❌编译不通过,不能声明为 Lambda表达式 var array = {1,2,3};//❌编译不通过,不能声明数组 var 并不会改变 Java 是一门静态类型语言的事实,编译器负责推断出类型。\n另外,Scala 和 Kotlin 中已经有了 val 关键字 ( final var 组合关键字)。\n相关阅读: 《Java 10 新特性之局部变量类型推断》。\n垃圾回收器接口 # 在早期的 JDK 结构中,组成垃圾收集器 (GC) 实现的组件分散在代码库的各个部分。 Java 10 通过引入一套纯净的垃圾收集器接口来将不同垃圾收集器的源代码分隔开。\nG1 并行 Full GC # 从 Java9 开始 G1 就了默认的垃圾回收器,G1 是以一种低延时的垃圾回收器来设计的,旨在避免进行 Full GC,但是 Java9 的 G1 的 FullGC 依然是使用单线程去完成标记清除算法,这可能会导致垃圾回收期在无法回收内存的时候触发 Full GC。\n为了最大限度地减少 Full GC 造成的应用停顿的影响,从 Java10 开始,G1 的 FullGC 改为并行的标记清除算法,同时会使用与年轻代回收和混合回收相同的并行工作线程数量,从而减少了 Full GC 的发生,以带来更好的性能提升、更大的吞吐量。\n集合增强 # List,Set,Map 提供了静态方法copyOf()返回入参集合的一个不可变拷贝。\nstatic \u0026lt;E\u0026gt; List\u0026lt;E\u0026gt; copyOf(Collection\u0026lt;? extends E\u0026gt; coll) { return ImmutableCollections.listCopy(coll); } 使用 copyOf() 创建的集合为不可变集合,不能进行添加、删除、替换、 排序等操作,不然会报 java.lang.UnsupportedOperationException 异常。 IDEA 也会有相应的提示。\n并且,java.util.stream.Collectors 中新增了静态方法,用于将流中的元素收集为不可变的集合。\nvar list = new ArrayList\u0026lt;\u0026gt;(); list.stream().collect(Collectors.toUnmodifiableList()); list.stream().collect(Collectors.toUnmodifiableSet()); Optional 增强 # Optional 新增了orElseThrow()方法来在没有值时抛出指定的异常。\nOptional.ofNullable(cache.getIfPresent(key)) .orElseThrow(() -\u0026gt; new PrestoException(NOT_FOUND, \u0026#34;Missing entry found for key: \u0026#34; + key)); 应用程序类数据共享(扩展 CDS 功能) # 在 Java 5 中就已经引入了类数据共享机制 (Class Data Sharing,简称 CDS),允许将一组类预处理为共享归档文件,以便在运行时能够进行内存映射以减少 Java 程序的启动时间,当多个 Java 虚拟机(JVM)共享相同的归档文件时,还可以减少动态内存的占用量,同时减少多个虚拟机在同一个物理或虚拟的机器上运行时的资源占用。CDS 在当时还是 Oracle JDK 的商业特性。\nJava 10 在现有的 CDS 功能基础上再次拓展,以允许应用类放置在共享存档中。CDS 特性在原来的 bootstrap 类基础之上,扩展加入了应用类的 CDS 为 (Application Class-Data Sharing,AppCDS) 支持,大大加大了 CDS 的适用范围。其原理为:在启动时记录加载类的过程,写入到文本文件中,再次启动时直接读取此启动文本并加载。设想如果应用环境没有大的变化,启动速度就会得到提升。\n实验性的基于 Java 的 JIT 编译器 # Graal 是一个基于 Java 语言编写的 JIT 编译器,是 JDK 9 中引入的实验性 Ahead-of-Time (AOT) 编译器的基础。\nOracle 的 HotSpot VM 便附带两个用 C++ 实现的 JIT compiler:C1 及 C2。在 Java 10 (Linux/x64, macOS/x64) 中,默认情况下 HotSpot 仍使用 C2,但通过向 java 命令添加 -XX:+UnlockExperimentalVMOptions -XX:+UseJVMCICompiler 参数便可将 C2 替换成 Graal。\n相关阅读: 深入浅出 Java 10 的实验性 JIT 编译器 Graal - 郑雨迪\n其他 # 线程-局部管控:Java 10 中线程管控引入 JVM 安全点的概念,将允许在不运行全局 JVM 安全点的情况下实现线程回调,由线程本身或者 JVM 线程来执行,同时保持线程处于阻塞状态,这种方式使得停止单个线程变成可能,而不是只能启用或停止所有线程 备用存储装置上的堆分配:Java 10 中将使得 JVM 能够使用适用于不同类型的存储机制的堆,在可选内存设备上进行堆内存分配 …… 参考 # Java 10 Features and Enhancements : https://howtodoinjava.com/java10/java10-features/\nGuide to Java10 : https://www.baeldung.com/java-10-overview\n4 Class Data Sharing : https://docs.oracle.com/javase/10/vm/class-data-sharing.htm#JSJVM-GUID-7EAA3411-8CF0-4D19-BD05-DF5E1780AA91\n"},{"id":486,"href":"/zh/docs/technology/Interview/java/new-features/java11/","title":"Java 11 新特性概览","section":"New Features","content":"Java 11 于 2018 年 9 月 25 日正式发布,这是很重要的一个版本!Java 11 和 2017 年 9 月份发布的 Java 9 以及 2018 年 3 月份发布的 Java 10 相比,其最大的区别就是:在长期支持(Long-Term-Support)方面,Oracle 表示会对 Java 11 提供大力支持,这一支持将会持续至 2026 年 9 月。这是据 Java 8 以后支持的首个长期版本。\n下面这张图是 Oracle 官方给出的 Oracle JDK 支持的时间线。\n概览(精选了一部分):\nJEP 321:HTTP Client 标准化 JEP 333:ZGC(可伸缩低延迟垃圾收集器) JEP 323:Lambda 参数的局部变量语法 JEP 330:启动单文件源代码程序 HTTP Client 标准化 # Java 11 对 Java 9 中引入并在 Java 10 中进行了更新的 Http Client API 进行了标准化,在前两个版本中进行孵化的同时,Http Client 几乎被完全重写,并且现在完全支持异步非阻塞。\n并且,Java 11 中,Http Client 的包名由 jdk.incubator.http 改为java.net.http,该 API 通过 CompleteableFuture 提供非阻塞请求和响应语义。使用起来也很简单,如下:\nvar request = HttpRequest.newBuilder() .uri(URI.create(\u0026#34;https://javastack.cn\u0026#34;)) .GET() .build(); var client = HttpClient.newHttpClient(); // 同步 HttpResponse\u0026lt;String\u0026gt; response = client.send(request, HttpResponse.BodyHandlers.ofString()); System.out.println(response.body()); // 异步 client.sendAsync(request, HttpResponse.BodyHandlers.ofString()) .thenApply(HttpResponse::body) .thenAccept(System.out::println); String 增强 # Java 11 增加了一系列的字符串处理方法:\n//判断字符串是否为空 \u0026#34; \u0026#34;.isBlank();//true //去除字符串首尾空格 \u0026#34; Java \u0026#34;.strip();// \u0026#34;Java\u0026#34; //去除字符串首部空格 \u0026#34; Java \u0026#34;.stripLeading(); // \u0026#34;Java \u0026#34; //去除字符串尾部空格 \u0026#34; Java \u0026#34;.stripTrailing(); // \u0026#34; Java\u0026#34; //重复字符串多少次 \u0026#34;Java\u0026#34;.repeat(3); // \u0026#34;JavaJavaJava\u0026#34; //返回由行终止符分隔的字符串集合。 \u0026#34;A\\nB\\nC\u0026#34;.lines().count(); // 3 \u0026#34;A\\nB\\nC\u0026#34;.lines().collect(Collectors.toList()); Optional 增强 # 新增了isEmpty()方法来判断指定的 Optional 对象是否为空。\nvar op = Optional.empty(); System.out.println(op.isEmpty());//判断指定的 Optional 对象是否为空 ZGC(可伸缩低延迟垃圾收集器) # ZGC 即 Z Garbage Collector,是一个可伸缩的、低延迟的垃圾收集器。\nZGC 主要为了满足如下目标进行设计:\nGC 停顿时间不超过 10ms 即能处理几百 MB 的小堆,也能处理几个 TB 的大堆 应用吞吐能力不会下降超过 15%(与 G1 回收算法相比) 方便在此基础上引入新的 GC 特性和利用 colored 针以及 Load barriers 优化奠定基础 当前只支持 Linux/x64 位平台 ZGC 目前 处在实验阶段,只支持 Linux/x64 平台。\n与 CMS 中的 ParNew 和 G1 类似,ZGC 也采用标记-复制算法,不过 ZGC 对该算法做了重大改进。\n在 ZGC 中出现 Stop The World 的情况会更少!\n详情可以看: 《新一代垃圾回收器 ZGC 的探索与实践》\nLambda 参数的局部变量语法 # 从 Java 10 开始,便引入了局部变量类型推断这一关键特性。类型推断允许使用关键字 var 作为局部变量的类型而不是实际类型,编译器根据分配给变量的值推断出类型。\nJava 10 中对 var 关键字存在几个限制\n只能用于局部变量上 声明时必须初始化 不能用作方法参数 不能在 Lambda 表达式中使用 Java11 开始允许开发者在 Lambda 表达式中使用 var 进行参数声明。\n// 下面两者是等价的 Consumer\u0026lt;String\u0026gt; consumer = (var i) -\u0026gt; System.out.println(i); Consumer\u0026lt;String\u0026gt; consumer = (String i) -\u0026gt; System.out.println(i); 启动单文件源代码程序 # 这意味着我们可以运行单一文件的 Java 源代码。此功能允许使用 Java 解释器直接执行 Java 源代码。源代码在内存中编译,然后由解释器执行,不需要在磁盘上生成 .class 文件了。唯一的约束在于所有相关的类必须定义在同一个 Java 文件中。\n对于 Java 初学者并希望尝试简单程序的人特别有用,并且能和 jshell 一起使用。一定能程度上增强了使用 Java 来写脚本程序的能力。\n其他新特性 # 新的垃圾回收器 Epsilon:一个完全消极的 GC 实现,分配有限的内存资源,最大限度的降低内存占用和内存吞吐延迟时间 低开销的 Heap Profiling:Java 11 中提供一种低开销的 Java 堆分配采样方法,能够得到堆分配的 Java 对象信息,并且能够通过 JVMTI 访问堆信息 TLS1.3 协议:Java 11 中包含了传输层安全性(TLS)1.3 规范(RFC 8446)的实现,替换了之前版本中包含的 TLS,包括 TLS 1.2,同时还改进了其他 TLS 功能,例如 OCSP 装订扩展(RFC 6066,RFC 6961),以及会话散列和扩展主密钥扩展(RFC 7627),在安全性和性能方面也做了很多提升 飞行记录器(Java Flight Recorder):飞行记录器之前是商业版 JDK 的一项分析工具,但在 Java 11 中,其代码被包含到公开代码库中,这样所有人都能使用该功能了。 …… 参考 # JDK 11 Release Notes: https://www.oracle.com/java/technologies/javase/11-relnote-issues.html Java 11 – Features and Comparison: https://www.geeksforgeeks.org/java-11-features-and-comparison/ "},{"id":487,"href":"/zh/docs/technology/Interview/java/new-features/java12-13/","title":"Java 12 \u0026 13 新特性概览","section":"New Features","content":" Java12 # String 增强 # Java 12 增加了两个的字符串处理方法,如以下所示。\nindent() 方法可以实现字符串缩进。\nString text = \u0026#34;Java\u0026#34;; // 缩进 4 格 text = text.indent(4); System.out.println(text); text = text.indent(-10); System.out.println(text); 输出:\nJava Java transform() 方法可以用来转变指定字符串。\nString result = \u0026#34;foo\u0026#34;.transform(input -\u0026gt; input + \u0026#34; bar\u0026#34;); System.out.println(result); // foo bar Files 增强(文件比较) # Java 12 添加了以下方法来比较两个文件:\npublic static long mismatch(Path path, Path path2) throws IOException mismatch() 方法用于比较两个文件,并返回第一个不匹配字符的位置,如果文件相同则返回 -1L。\n代码示例(两个文件内容相同的情况):\nPath filePath1 = Files.createTempFile(\u0026#34;file1\u0026#34;, \u0026#34;.txt\u0026#34;); Path filePath2 = Files.createTempFile(\u0026#34;file2\u0026#34;, \u0026#34;.txt\u0026#34;); Files.writeString(filePath1, \u0026#34;Java 12 Article\u0026#34;); Files.writeString(filePath2, \u0026#34;Java 12 Article\u0026#34;); long mismatch = Files.mismatch(filePath1, filePath2); assertEquals(-1, mismatch); 代码示例(两个文件内容不相同的情况):\nPath filePath3 = Files.createTempFile(\u0026#34;file3\u0026#34;, \u0026#34;.txt\u0026#34;); Path filePath4 = Files.createTempFile(\u0026#34;file4\u0026#34;, \u0026#34;.txt\u0026#34;); Files.writeString(filePath3, \u0026#34;Java 12 Article\u0026#34;); Files.writeString(filePath4, \u0026#34;Java 12 Tutorial\u0026#34;); long mismatch = Files.mismatch(filePath3, filePath4); assertEquals(8, mismatch); 数字格式化工具类 # NumberFormat 新增了对复杂的数字进行格式化的支持\nNumberFormat fmt = NumberFormat.getCompactNumberInstance(Locale.US, NumberFormat.Style.SHORT); String result = fmt.format(1000); System.out.println(result); 输出:\n1K Shenandoah GC # Redhat 主导开发的 Pauseless GC 实现,主要目标是 99.9% 的暂停小于 10ms,暂停与堆大小无关等\n和 Java11 开源的 ZGC 相比(需要升级到 JDK11 才能使用),Shenandoah GC 有稳定的 JDK8u 版本,在 Java8 占据主要市场份额的今天有更大的可落地性。\nG1 收集器优化 # Java12 为默认的垃圾收集器 G1 带来了两项更新:\n可中止的混合收集集合:JEP344 的实现,为了达到用户提供的停顿时间目标,JEP 344 通过把要被回收的区域集(混合收集集合)拆分为强制和可选部分,使 G1 垃圾回收器能中止垃圾回收过程。 G1 可以中止可选部分的回收以达到停顿时间目标 及时返回未使用的已分配内存:JEP346 的实现,增强 G1 GC,以便在空闲时自动将 Java 堆内存返回给操作系统 预览新特性 # 作为预览特性加入,需要在javac编译和java运行时增加参数--enable-preview 。\n增强 Switch # 传统的 switch 语法存在容易漏写 break 的问题,而且从代码整洁性层面来看,多个 break 本质也是一种重复。\nJava12 增强了 switch 表达式,使用类似 lambda 语法条件匹配成功后的执行块,不需要多写 break 。\nswitch (day) { case MONDAY, FRIDAY, SUNDAY -\u0026gt; System.out.println(6); case TUESDAY -\u0026gt; System.out.println(7); case THURSDAY, SATURDAY -\u0026gt; System.out.println(8); case WEDNESDAY -\u0026gt; System.out.println(9); } instanceof 模式匹配 # instanceof 主要在类型强转前探测对象的具体类型。\n之前的版本中,我们需要显示地对对象进行类型转换。\nObject obj = \u0026#34;我是字符串\u0026#34;; if(obj instanceof String){ String str = (String) obj; System.out.println(str); } 新版的 instanceof 可以在判断是否属于具体的类型同时完成转换。\nObject obj = \u0026#34;我是字符串\u0026#34;; if(obj instanceof String str){ System.out.println(str); } Java13 # 增强 ZGC(释放未使用内存) # 在 Java 11 中实验性引入的 ZGC 在实际的使用中存在未能主动将未使用的内存释放给操作系统的问题。\nZGC 堆由一组称为 ZPages 的堆区域组成。在 GC 周期中清空 ZPages 区域时,它们将被释放并返回到页面缓存 ZPageCache 中,此缓存中的 ZPages 按最近最少使用(LRU)的顺序,并按照大小进行组织。\n在 Java 13 中,ZGC 将向操作系统返回被标识为长时间未使用的页面,这样它们将可以被其他进程重用。\nSocketAPI 重构 # Java Socket API 终于迎来了重大更新!\nJava 13 将 Socket API 的底层进行了重写, NioSocketImpl 是对 PlainSocketImpl 的直接替代,它使用 java.util.concurrent 包下的锁而不是同步方法。如果要使用旧实现,请使用 -Djdk.net.usePlainSocketImpl=true。\n并且,在 Java 13 中是默认使用新的 Socket 实现。\npublic final class NioSocketImpl extends SocketImpl implements PlatformSocketImpl { } FileSystems # FileSystems 类中添加了以下三种新方法,以便更容易地使用将文件内容视为文件系统的文件系统提供程序:\nnewFileSystem(Path) newFileSystem(Path, Map\u0026lt;String, ?\u0026gt;) newFileSystem(Path, Map\u0026lt;String, ?\u0026gt;, ClassLoader) 动态 CDS 存档 # Java 13 中对 Java 10 中引入的应用程序类数据共享(AppCDS)进行了进一步的简化、改进和扩展,即:允许在 Java 应用程序执行结束时动态进行类归档,具体能够被归档的类包括所有已被加载,但不属于默认基层 CDS 的应用程序类和引用类库中的类。\n这提高了应用程序类数据共享( AppCDS)的可用性。无需用户进行试运行来为每个应用程序创建类列表。\njava -XX:ArchiveClassesAtExit=my_app_cds.jsa -cp my_app.jar java -XX:SharedArchiveFile=my_app_cds.jsa -cp my_app.jar 预览新特性 # 文本块 # 解决 Java 定义多行字符串时只能通过换行转义或者换行连接符来变通支持的问题,引入三重双引号来定义多行文本。\nJava 13 支持两个 \u0026quot;\u0026quot;\u0026quot; 符号中间的任何内容都会被解释为字符串的一部分,包括换行符。\n未支持文本块之前的 HTML 写法:\nString json =\u0026#34;{\\n\u0026#34; + \u0026#34; \\\u0026#34;name\\\u0026#34;:\\\u0026#34;mkyong\\\u0026#34;,\\n\u0026#34; + \u0026#34; \\\u0026#34;age\\\u0026#34;:38\\n\u0026#34; + \u0026#34;}\\n\u0026#34;; 支持文本块之后的 HTML 写法:\nString json = \u0026#34;\u0026#34;\u0026#34; { \u0026#34;name\u0026#34;:\u0026#34;mkyong\u0026#34;, \u0026#34;age\u0026#34;:38 } \u0026#34;\u0026#34;\u0026#34;; 未支持文本块之前的 SQL 写法:\nString query = \u0026#34;SELECT `EMP_ID`, `LAST_NAME` FROM `EMPLOYEE_TB`\\n\u0026#34; + \u0026#34;WHERE `CITY` = \u0026#39;INDIANAPOLIS\u0026#39;\\n\u0026#34; + \u0026#34;ORDER BY `EMP_ID`, `LAST_NAME`;\\n\u0026#34;; 支持文本块之后的 SQL 写法:\nString query = \u0026#34;\u0026#34;\u0026#34; SELECT `EMP_ID`, `LAST_NAME` FROM `EMPLOYEE_TB` WHERE `CITY` = \u0026#39;INDIANAPOLIS\u0026#39; ORDER BY `EMP_ID`, `LAST_NAME`; \u0026#34;\u0026#34;\u0026#34;; 另外,String 类新增加了 3 个新的方法来操作文本块:\nformatted(Object... args):它类似于 String 的format()方法。添加它是为了支持文本块的格式设置。 stripIndent():用于去除文本块中每一行开头和结尾的空格。 translateEscapes():转义序列如 “\\\\t” 转换为 “\\t” 由于文本块是一项预览功能,可以在未来版本中删除,因此这些新方法被标记为弃用。\n@Deprecated(forRemoval=true, since=\u0026#34;13\u0026#34;) public String stripIndent() { } @Deprecated(forRemoval=true, since=\u0026#34;13\u0026#34;) public String formatted(Object... args) { } @Deprecated(forRemoval=true, since=\u0026#34;13\u0026#34;) public String translateEscapes() { } 增强 Switch(引入 yield 关键字到 Switch 中) # Switch 表达式中就多了一个关键字用于跳出 Switch 块的关键字 yield,主要用于返回一个值\nyield和 return 的区别在于:return 会直接跳出当前循环或者方法,而 yield 只会跳出当前 Switch 块,同时在使用 yield 时,需要有 default 条件\nprivate static String descLanguage(String name) { return switch (name) { case \u0026#34;Java\u0026#34;: yield \u0026#34;object-oriented, platform independent and secured\u0026#34;; case \u0026#34;Ruby\u0026#34;: yield \u0026#34;a programmer\u0026#39;s best friend\u0026#34;; default: yield name +\u0026#34; is a good language\u0026#34;; }; } 补充 # 关于预览特性 # 先贴一段 oracle 官网原文:This is a preview feature, which is a feature whose design, specification, and implementation are complete, but is not permanent, which means that the feature may exist in a different form or not at all in future JDK releases. To compile and run code that contains preview features, you must specify additional command-line options.\n这是一个预览功能,该功能的设计,规格和实现是完整的,但不是永久性的,这意味着该功能可能以其他形式存在或在将来的 JDK 版本中根本不存在。 要编译和运行包含预览功能的代码,必须指定其他命令行选项。\n就以switch的增强为例子,从 Java12 中推出,到 Java13 中将继续增强,直到 Java14 才正式转正进入 JDK 可以放心使用,不用考虑后续 JDK 版本对其的改动或修改\n一方面可以看出 JDK 作为标准平台在增加新特性的严谨态度,另一方面个人认为是对于预览特性应该采取审慎使用的态度。特性的设计和实现容易,但是其实际价值依然需要在使用中去验证\nJVM 虚拟机优化 # 每次 Java 版本的发布都伴随着对 JVM 虚拟机的优化,包括对现有垃圾回收算法的改进,引入新的垃圾回收算法,移除老旧的不再适用于今天的垃圾回收算法等\n整体优化的方向是高效,低时延的垃圾回收表现\n对于日常的应用开发者可能比较关注新的语法特性,但是从一个公司角度来说,在考虑是否升级 Java 平台时更加考虑的是JVM 运行时的提升\n参考 # JDK Project Overview: https://openjdk.java.net/projects/jdk/ Oracle Java12 ReleaseNote: https://www.oracle.com/java/technologies/javase/12all-relnotes.htm What is new in Java 12: https://mkyong.com/java/what-is-new-in-java-12/ Oracle Java13 ReleaseNote https://www.oracle.com/technetwork/java/javase/13all-relnotes-5461743.html#NewFeature New Java13 Features https://www.baeldung.com/java-13-new-features Java13 新特性概述 https://www.ibm.com/developerworks/cn/java/the-new-features-of-Java-13/index.html "},{"id":488,"href":"/zh/docs/technology/Interview/java/new-features/java14-15/","title":"Java 14 \u0026 15 新特性概览","section":"New Features","content":" Java14 # 空指针异常精准提示 # 通过 JVM 参数中添加-XX:+ShowCodeDetailsInExceptionMessages,可以在空指针异常中获取更为详细的调用信息,更快的定位和解决问题。\na.b.c.i = 99; // 假设这段代码会发生空指针 Java 14 之前:\nException in thread \u0026#34;main\u0026#34; java.lang.NullPointerException at NullPointerExample.main(NullPointerExample.java:5) Java 14 之后:\n// 增加参数后提示的异常中很明确的告知了哪里为空导致 Exception in thread \u0026#34;main\u0026#34; java.lang.NullPointerException: Cannot read field \u0026#39;c\u0026#39; because \u0026#39;a.b\u0026#39; is null. at Prog.main(Prog.java:5) switch 的增强(转正) # Java12 引入的 switch(预览特性)在 Java14 变为正式版本,不需要增加参数来启用,直接在 JDK14 中就能使用。\nJava12 为 switch 表达式引入了类似 lambda 语法条件匹配成功后的执行块,不需要多写 break ,Java13 提供了 yield 来在 block 中返回值。\nString result = switch (day) { case \u0026#34;M\u0026#34;, \u0026#34;W\u0026#34;, \u0026#34;F\u0026#34; -\u0026gt; \u0026#34;MWF\u0026#34;; case \u0026#34;T\u0026#34;, \u0026#34;TH\u0026#34;, \u0026#34;S\u0026#34; -\u0026gt; \u0026#34;TTS\u0026#34;; default -\u0026gt; { if(day.isEmpty()) yield \u0026#34;Please insert a valid day.\u0026#34;; else yield \u0026#34;Looks like a Sunday.\u0026#34;; } }; System.out.println(result); 预览新特性 # record 关键字 # record 关键字可以简化 数据类(一个 Java 类一旦实例化就不能再修改)的定义方式,使用 record 代替 class 定义的类,只需要声明属性,就可以在获得属性的访问方法,以及 toString(),hashCode(), equals()方法。\n类似于使用 class 定义类,同时使用了 lombok 插件,并打上了@Getter,@ToString,@EqualsAndHashCode注解。\n/** * 这个类具有两个特征 * 1. 所有成员属性都是final * 2. 全部方法由构造方法,和两个成员属性访问器组成(共三个) * 那么这种类就很适合使用record来声明 */ final class Rectangle implements Shape { final double length; final double width; public Rectangle(double length, double width) { this.length = length; this.width = width; } double length() { return length; } double width() { return width; } } /** * 1. 使用record声明的类会自动拥有上面类中的三个方法 * 2. 在这基础上还附赠了equals(),hashCode()方法以及toString()方法 * 3. toString方法中包括所有成员属性的字符串表示形式及其名称 */ record Rectangle(float length, float width) { } 文本块 # Java14 中,文本块依然是预览特性,不过,其引入了两个新的转义字符:\n\\ : 表示行尾,不引入换行符 \\s:表示单个空格 String str = \u0026#34;凡心所向,素履所往,生如逆旅,一苇以航。\u0026#34;; String str2 = \u0026#34;\u0026#34;\u0026#34; 凡心所向,素履所往, \\ 生如逆旅,一苇以航。\u0026#34;\u0026#34;\u0026#34;; System.out.println(str2);// 凡心所向,素履所往, 生如逆旅,一苇以航。 String text = \u0026#34;\u0026#34;\u0026#34; java c++\\sphp \u0026#34;\u0026#34;\u0026#34;; System.out.println(text); //输出: java c++ php instanceof 增强 # 依然是预览特性 , Java 12 新特性中介绍过。\n其他 # 从 Java11 引入的 ZGC 作为继 G1 过后的下一代 GC 算法,从支持 Linux 平台到 Java14 开始支持 MacOS 和 Windows(个人感觉是终于可以在日常开发工具中先体验下 ZGC 的效果了,虽然其实 G1 也够用) 移除了 CMS(Concurrent Mark Sweep) 垃圾收集器(功成而退) 新增了 jpackage 工具,标配将应用打成 jar 包外,还支持不同平台的特性包,比如 linux 下的deb和rpm,window 平台下的msi和exe Java15 # CharSequence # CharSequence 接口添加了一个默认方法 isEmpty() 来判断字符序列为空,如果是则返回 true。\npublic interface CharSequence { default boolean isEmpty() { return this.length() == 0; } } TreeMap # TreeMap 新引入了下面这些方法:\nputIfAbsent() computeIfAbsent() computeIfPresent() compute() merge() ZGC(转正) # Java11 的时候 ,ZGC 还在试验阶段。\n当时,ZGC 的出现让众多 Java 开发者看到了垃圾回收器的另外一种可能,因此备受关注。\n经过多个版本的迭代,不断的完善和修复问题,ZGC 在 Java 15 已经可以正式使用了!\n不过,默认的垃圾回收器依然是 G1。你可以通过下面的参数启动 ZGC:\njava -XX:+UseZGC className EdDSA(数字签名算法) # 新加入了一个安全性和性能都更强的基于 Edwards-Curve Digital Signature Algorithm (EdDSA)实现的数字签名算法。\n虽然其性能优于现有的 ECDSA 实现,不过,它并不会完全取代 JDK 中现有的椭圆曲线数字签名算法( ECDSA)。\nKeyPairGenerator kpg = KeyPairGenerator.getInstance(\u0026#34;Ed25519\u0026#34;); KeyPair kp = kpg.generateKeyPair(); byte[] msg = \u0026#34;test_string\u0026#34;.getBytes(StandardCharsets.UTF_8); Signature sig = Signature.getInstance(\u0026#34;Ed25519\u0026#34;); sig.initSign(kp.getPrivate()); sig.update(msg); byte[] s = sig.sign(); String encodedString = Base64.getEncoder().encodeToString(s); System.out.println(encodedString); 输出:\n0Hc0lxxASZNvS52WsvnncJOH/mlFhnA8Tc6D/k5DtAX5BSsNVjtPF4R4+yMWXVjrvB2mxVXmChIbki6goFBgAg== 文本块(转正) # 在 Java 15 ,文本块是正式的功能特性了。\n隐藏类(Hidden Classes) # 隐藏类是为框架(frameworks)所设计的,隐藏类不能直接被其他类的字节码使用,只能在运行时生成类并通过反射间接使用它们。\n预览新特性 # 密封类 # 密封类(Sealed Classes) 是 Java 15 中的一个预览新特性。\n没有密封类之前,在 Java 中如果想让一个类不能被继承和修改,我们可以使用final 关键字对类进行修饰。不过,这种方式不太灵活,直接把一个类的继承和修改渠道给堵死了。\n密封类可以对继承或者实现它们的类进行限制,这样这个类就只能被指定的类继承。\n// 抽象类 Person 只允许 Employee 和 Manager 继承。 public abstract sealed class Person permits Employee, Manager { //... } 另外,任何扩展密封类的类本身都必须声明为 sealed、non-sealed 或 final。\npublic final class Employee extends Person { } public non-sealed class Manager extends Person { } 如果允许扩展的子类和封闭类在同一个源代码文件里,封闭类可以不使用 permits 语句,Java 编译器将检索源文件,在编译期为封闭类添加上许可的子类。\ninstanceof 模式匹配 # Java 15 并没有对此特性进行调整,继续预览特性,主要用于接受更多的使用反馈。\n在未来的 Java 版本中,Java 的目标是继续完善 instanceof 模式匹配新特性。\n其他 # Nashorn JavaScript 引擎彻底移除:Nashorn 从 Java8 开始引入的 JavaScript 引擎,Java9 对 Nashorn 做了些增强,实现了一些 ES6 的新特性。在 Java 11 中就已经被弃用,到了 Java 15 就彻底被删除了。 DatagramSocket API 重构 禁用和废弃偏向锁(Biased Locking):偏向锁的引入增加了 JVM 的复杂性大于其带来的性能提升。不过,你仍然可以使用 -XX:+UseBiasedLocking 启用偏向锁定,但它会提示这是一个已弃用的 API。 …… "},{"id":489,"href":"/zh/docs/technology/Interview/java/new-features/java16/","title":"Java 16 新特性概览","section":"New Features","content":"Java 16 在 2021 年 3 月 16 日正式发布,非长期支持(LTS)版本。\n相关阅读: OpenJDK Java 16 文档 。\nJEP 338:向量 API(第一次孵化) # 向量(Vector) API 最初由 JEP 338 提出,并作为 孵化 API集成到 Java 16 中。第二轮孵化由 JEP 414 提出并集成到 Java 17 中,第三轮孵化由 JEP 417 提出并集成到 Java 18 中,第四轮由 JEP 426 提出并集成到了 Java 19 中。\n该孵化器 API 提供了一个 API 的初始迭代以表达一些向量计算,这些计算在运行时可靠地编译为支持的 CPU 架构上的最佳向量硬件指令,从而获得优于同等标量计算的性能,充分利用单指令多数据(SIMD)技术(大多数现代 CPU 上都可以使用的一种指令)。尽管 HotSpot 支持自动向量化,但是可转换的标量操作集有限且易受代码更改的影响。该 API 将使开发人员能够轻松地用 Java 编写可移植的高性能向量算法。\n在 Java 18 新特性概览 中,我有详细介绍到向量 API,这里就不再做额外的介绍了。\nJEP 347:启用 C++ 14 语言特性 # Java 16 允许在 JDK 的 C++ 源代码中使用 C++14 语言特性,并提供在 HotSpot 代码中可以使用哪些特性的具体指导。\n在 Java 15 中,JDK 中 C++ 代码使用的语言特性仅限于 C++98/03 语言标准。它要求更新各种平台编译器的最低可接受版本。\nJEP 376:ZGC 并发线程堆栈处理 # Java16 将 ZGC 线程栈处理从安全点转移到一个并发阶段,甚至在大堆上也允许在毫秒内暂停 GC 安全点。消除 ZGC 垃圾收集器中最后一个延迟源可以极大地提高应用程序的性能和效率。\nJEP 387:弹性元空间 # 自从引入了 Metaspace 以来,根据反馈,Metaspace 经常占用过多的堆外内存,从而导致内存浪费。弹性元空间这个特性可将未使用的 HotSpot 类元数据(即元空间,metaspace)内存更快速地返回到操作系统,从而减少元空间的占用空间。\n并且,这个提案还简化了元空间的代码以降低维护成本。\nJEP 390:对基于值的类发出警告 # 以下介绍摘自: 实操 | 剖析 Java16 新语法特性,原文写的很不错,推荐阅读。\n早在 Java9 版本时,Java 的设计者们就对 @Deprecated 注解进行了一次升级,增加了 since 和 forRemoval 等 2 个新元素。其中,since 元素用于指定标记了 @Deprecated 注解的 API 被弃用时的版本,而 forRemoval 则进一步明确了 API 标记 @Deprecated 注解时的语义,如果forRemoval=true时,则表示该 API 在未来版本中肯定会被删除,开发人员应该使用新的 API 进行替代,不再容易产生歧义(Java9 之前,标记 @Deprecated 注解的 API,语义上存在多种可能性,比如:存在使用风险、可能在未来存在兼容性错误、可能在未来版本中被删除,以及应该使用更好的替代方案等)。\n仔细观察原始类型的包装类(比如:java.lang.Integer、java.lang.Double),不难发现,其构造函数上都已经标记有@Deprecated(since=\u0026quot;9\u0026quot;, forRemoval = true)注解,这就意味着其构造函数在将来会被删除,不应该在程序中继续使用诸如new Integer();这样的编码方式(建议使用Integer a = 10;或者Integer.valueOf()函数),如果继续使用,编译期将会产生\u0026rsquo;Integer(int)\u0026rsquo; is deprecated and marked for removal 告警。并且,值得注意的是,这些包装类型已经被指定为同 java.util.Optional 和 java.time.LocalDateTime 一样的值类型。\n其次,如果继续在 synchronized 同步块中使用值类型,将会在编译期和运行期产生警告,甚至是异常。在此大家需要注意,就算编译期和运行期没有产生警告和异常,也不建议在 synchronized 同步块中使用值类型,举个自增的例子。示例 1-5:\npublic void inc(Integer count) { for (int i = 0; i \u0026lt; 10; i++) { new Thread(() -\u0026gt; { synchronized (count) { count++; } }).start(); } } 当执行上述程序示例时,最终的输出结果一定会与你的期望产生差异,这是许多新人经常犯错的一个点,因为在并发环境下,Integer 对象根本无法通过 synchronized 来保证线程安全,这是因为每次的count++操作,所产生的 hashcode 均不同,简而言之,每次加锁都锁在了不同的对象上。因此,如果希望在实际的开发过程中保证其原子性,应该使用 AtomicInteger。\nJEP 392:打包工具 # 在 Java 14 中,JEP 343 引入了打包工具,命令是 jpackage。在 Java 15 中,继续孵化,现在在 Java 16 中,终于成为了正式功能。\n这个打包工具允许打包自包含的 Java 应用程序。它支持原生打包格式,为最终用户提供自然的安装体验,这些格式包括 Windows 上的 msi 和 exe、macOS 上的 pkg 和 dmg,还有 Linux 上的 deb 和 rpm。它还允许在打包时指定启动时参数,并且可以从命令行直接调用,也可以通过 ToolProvider API 以编程方式调用。注意 jpackage 模块名称从 jdk.incubator.jpackage 更改为 jdk.jpackage。这将改善最终用户在安装应用程序时的体验,并简化了“应用商店”模型的部署。\n关于这个打包工具的实际使用,可以看这个视频 Playing with Java 16 jpackage(需要梯子)。\nJEP 393:外部内存访问 API(第三次孵化) # 引入外部内存访问 API 以允许 Java 程序安全有效地访问 Java 堆之外的外部内存。\nJava 14( JEP 370) 的时候,第一次孵化外部内存访问 API,Java 15 中进行了第二次复活( JEP 383),在 Java 16 中进行了第三次孵化。\n引入外部内存访问 API 的目的如下:\n通用:单个 API 应该能够对各种外部内存(如本机内存、持久内存、堆内存等)进行操作。 安全:无论操作何种内存,API 都不应该破坏 JVM 的安全性。 控制:可以自由的选择如何释放内存(显式、隐式等)。 可用:如果需要访问外部内存,API 应该是 sun.misc.Unsafe. JEP 394:instanceof 模式匹配(转正) # JDK 版本 更新类型 JEP 更新内容 Java SE 14 preview JEP 305 首次引入 instanceof 模式匹配。 Java SE 15 Second Preview JEP 375 相比较上个版本无变化,继续收集更多反馈。 Java SE 16 Permanent Release JEP 394 模式变量不再隐式为 final。 从 Java 16 开始,你可以对 instanceof 中的变量值进行修改。\n// Old code if (o instanceof String) { String s = (String)o; ... use s ... } // New code if (o instanceof String s) { ... use s ... } JEP 395:记录类型(转正) # 记录类型变更历史:\nJDK 版本 更新类型 JEP 更新内容 Java SE 14 Preview JEP 359 引入 record 关键字,record 提供一种紧凑的语法来定义类中的不可变数据。 Java SE 15 Second Preview JEP 384 支持在局部方法和接口中使用 record。 Java SE 16 Permanent Release JEP 395 非静态内部类可以定义非常量的静态成员。 从 Java SE 16 开始,非静态内部类可以定义非常量的静态成员。\npublic class Outer { class Inner { static int age; } } 在 JDK 16 之前,如果写上面这种代码,IDE 会提示你静态字段 age 不能在非静态的内部类中定义,除非它用一个常量表达式初始化。(The field age cannot be declared static in a non-static inner type, unless initialized with a constant expression)\nJEP 396:默认强封装 JDK 内部元素 # 此特性会默认强封装 JDK 的所有内部元素,但关键内部 API(例如 sun.misc.Unsafe)除外。默认情况下,使用早期版本成功编译的访问 JDK 内部 API 的代码可能不再起作用。鼓励开发人员从使用内部元素迁移到使用标准 API 的方法上,以便他们及其用户都可以无缝升级到将来的 Java 版本。强封装由 JDK 9 的启动器选项–illegal-access 控制,到 JDK 15 默认改为 warning,从 JDK 16 开始默认为 deny。(目前)仍然可以使用单个命令行选项放宽对所有软件包的封装,将来只有使用–add-opens 打开特定的软件包才行。\nJEP 397:密封类(预览) # 密封类由 JEP 360 提出预览,集成到了 Java 15 中。在 JDK 16 中, 密封类得到了改进(更加严格的引用检查和密封类的继承关系),由 JEP 397 提出了再次预览。\n在 Java 14 \u0026amp; 15 新特性概览 中,我有详细介绍到密封类,这里就不再做额外的介绍了。\n其他优化与改进 # JEP 380:Unix-Domain 套接字通道:Unix-domain 套接字一直是大多数 Unix 平台的一个特性,现在在 Windows 10 和 Windows Server 2019 也提供了支持。此特性为 java.nio.channels 包的套接字通道和服务器套接字通道 API 添加了 Unix-domain(AF_UNIX)套接字支持。它扩展了继承的通道机制以支持 Unix-domain 套接字通道和服务器套接字通道。Unix-domain 套接字用于同一主机上的进程间通信(IPC)。它们在很大程度上类似于 TCP/IP,区别在于套接字是通过文件系统路径名而不是 Internet 协议(IP)地址和端口号寻址的。对于本地进程间通信,Unix-domain 套接字比 TCP/IP 环回连接更安全、更有效 JEP 389:外部链接器 API(孵化): 该孵化器 API 提供了静态类型、纯 Java 访问原生代码的特性,该 API 将大大简化绑定原生库的原本复杂且容易出错的过程。Java 1.1 就已通过 Java 原生接口(JNI)支持了原生方法调用,但并不好用。Java 开发人员应该能够为特定任务绑定特定的原生库。它还提供了外来函数支持,而无需任何中间的 JNI 粘合代码。 JEP 357:从 Mercurial 迁移到 Git:在此之前,OpenJDK 源代码是使用版本管理工具 Mercurial 进行管理,现在迁移到了 Git。 JEP 369:迁移到 GitHub:和 JEP 357 从 Mercurial 迁移到 Git 的改变一致,在把版本管理迁移到 Git 之后,选择了在 GitHub 上托管 OpenJDK 社区的 Git 仓库。不过只对 JDK 11 以及更高版本 JDK 进行了迁移。 JEP 386:移植 Alpine Linux:Alpine Linux 是一个独立的、非商业的 Linux 发行版,它十分的小,一个容器需要不超过 8MB 的空间,最小安装到磁盘只需要大约 130MB 存储空间,并且十分的简单,同时兼顾了安全性。此提案将 JDK 移植到了 Apline Linux,由于 Apline Linux 是基于 musl lib 的轻量级 Linux 发行版,因此其他 x64 和 AArch64 架构上使用 musl lib 的 Linux 发行版也适用。 JEP 388:Windows/AArch64 移植:这些 JEP 的重点不是移植工作本身,而是将它们集成到 JDK 主线存储库中;JEP 386 将 JDK 移植到 Alpine Linux 和其他使用 musl 作为 x64 上主要 C 库的发行版上。此外,JEP 388 将 JDK 移植到 Windows AArch64(ARM64)。 参考文献 # Java Language Changes Consolidated JDK 16 Release Notes Java 16 正式发布,新特性一一解析 实操 | 剖析 Java16 新语法特性(写的很赞) "},{"id":490,"href":"/zh/docs/technology/Interview/java/new-features/java17/","title":"Java 17 新特性概览(重要)","section":"New Features","content":"Java 17 在 2021 年 9 月 14 日正式发布,是一个长期支持(LTS)版本。\n下面这张图是 Oracle 官方给出的 Oracle JDK 支持的时间线。可以看得到,Java\n17 最多可以支持到 2029 年 9 月份。\nJava 17 将是继 Java 8 以来最重要的长期支持(LTS)版本,是 Java 社区八年努力的成果。Spring 6.x 和 Spring Boot 3.x 最低支持的就是 Java 17。\n这次更新共带来 14 个新特性:\nJEP 306:Restore Always-Strict Floating-Point Semantics(恢复始终严格的浮点语义) JEP 356:Enhanced Pseudo-Random Number Generators(增强的伪随机数生成器) JEP 382:New macOS Rendering Pipeline(新的 macOS 渲染管道) JEP 391:macOS/AArch64 Port(支持 macOS AArch64) JEP 398:Deprecate the Applet API for Removal(删除已弃用的 Applet API) JEP 403:Strongly Encapsulate JDK Internals(更强大的封装 JDK 内部元素) JEP 406:Pattern Matching for switch (switch 的类型匹配)(预览) JEP 407:Remove RMI Activation(删除远程方法调用激活机制) JEP 409:Sealed Classes(密封类)(转正) JEP 410:Remove the Experimental AOT and JIT Compiler(删除实验性的 AOT 和 JIT 编译器) JEP 411:Deprecate the Security Manager for Removal(弃用安全管理器以进行删除) JEP 412:Foreign Function \u0026amp; Memory API (外部函数和内存 API)(孵化) JEP 414:Vector(向量) API(第二次孵化) JEP 415:Context-Specific Deserialization Filters 这里只对 356、398、413、406、407、409、410、411、412、414 这几个我觉得比较重要的新特性进行详细介绍。\n相关阅读: OpenJDK Java 17 文档 。\nJEP 356:增强的伪随机数生成器 # JDK 17 之前,我们可以借助 Random、ThreadLocalRandom和SplittableRandom来生成随机数。不过,这 3 个类都各有缺陷,且缺少常见的伪随机算法支持。\nJava 17 为伪随机数生成器 (pseudorandom number generator,PRNG,又称为确定性随机位生成器)增加了新的接口类型和实现,使得开发者更容易在应用程序中互换使用各种 PRNG 算法。\nPRNG 用来生成接近于绝对随机数序列的数字序列。一般来说,PRNG 会依赖于一个初始值,也称为种子,来生成对应的伪随机数序列。只要种子确定了,PRNG 所生成的随机数就是完全确定的,因此其生成的随机数序列并不是真正随机的。\n使用示例:\nRandomGeneratorFactory\u0026lt;RandomGenerator\u0026gt; l128X256MixRandom = RandomGeneratorFactory.of(\u0026#34;L128X256MixRandom\u0026#34;); // 使用时间戳作为随机数种子 RandomGenerator randomGenerator = l128X256MixRandom.create(System.currentTimeMillis()); // 生成随机数 randomGenerator.nextInt(10); JEP 398:弃用 Applet API 以进行删除 # Applet API 用于编写在 Web 浏览器端运行的 Java 小程序,很多年前就已经被淘汰了,已经没有理由使用了。\nApplet API 在 Java 9 时被标记弃用( JEP 289),但不是为了删除。\nJEP 406:switch 的类型匹配(预览) # 正如 instanceof 一样, switch 也紧跟着增加了类型匹配自动转换功能。\ninstanceof 代码示例:\n// Old code if (o instanceof String) { String s = (String)o; ... use s ... } // New code if (o instanceof String s) { ... use s ... } switch 代码示例:\n// Old code static String formatter(Object o) { String formatted = \u0026#34;unknown\u0026#34;; if (o instanceof Integer i) { formatted = String.format(\u0026#34;int %d\u0026#34;, i); } else if (o instanceof Long l) { formatted = String.format(\u0026#34;long %d\u0026#34;, l); } else if (o instanceof Double d) { formatted = String.format(\u0026#34;double %f\u0026#34;, d); } else if (o instanceof String s) { formatted = String.format(\u0026#34;String %s\u0026#34;, s); } return formatted; } // New code static String formatterPatternSwitch(Object o) { return switch (o) { case Integer i -\u0026gt; String.format(\u0026#34;int %d\u0026#34;, i); case Long l -\u0026gt; String.format(\u0026#34;long %d\u0026#34;, l); case Double d -\u0026gt; String.format(\u0026#34;double %f\u0026#34;, d); case String s -\u0026gt; String.format(\u0026#34;String %s\u0026#34;, s); default -\u0026gt; o.toString(); }; } 对于 null 值的判断也进行了优化。\n// Old code static void testFooBar(String s) { if (s == null) { System.out.println(\u0026#34;oops!\u0026#34;); return; } switch (s) { case \u0026#34;Foo\u0026#34;, \u0026#34;Bar\u0026#34; -\u0026gt; System.out.println(\u0026#34;Great\u0026#34;); default -\u0026gt; System.out.println(\u0026#34;Ok\u0026#34;); } } // New code static void testFooBar(String s) { switch (s) { case null -\u0026gt; System.out.println(\u0026#34;Oops\u0026#34;); case \u0026#34;Foo\u0026#34;, \u0026#34;Bar\u0026#34; -\u0026gt; System.out.println(\u0026#34;Great\u0026#34;); default -\u0026gt; System.out.println(\u0026#34;Ok\u0026#34;); } } JEP 407:删除远程方法调用激活机制 # 删除远程方法调用 (RMI) 激活机制,同时保留 RMI 的其余部分。RMI 激活机制已过时且不再使用。\nJEP 409:密封类(转正) # 密封类由 JEP 360 提出预览,集成到了 Java 15 中。在 JDK 16 中, 密封类得到了改进(更加严格的引用检查和密封类的继承关系),由 JEP 397 提出了再次预览。\n在 Java 14 \u0026amp; 15 新特性概览 中,我有详细介绍到密封类,这里就不再做额外的介绍了。\nJEP 410:删除实验性的 AOT 和 JIT 编译器 # 在 Java 9 的 JEP 295 ,引入了实验性的提前 (AOT) 编译器,在启动虚拟机之前将 Java 类编译为本机代码。\nJava 17,删除实验性的提前 (AOT) 和即时 (JIT) 编译器,因为该编译器自推出以来很少使用,维护它所需的工作量很大。保留实验性的 Java 级 JVM 编译器接口 (JVMCI),以便开发人员可以继续使用外部构建的编译器版本进行 JIT 编译。\nJEP 411:弃用安全管理器以进行删除 # 弃用安全管理器以便在将来的版本中删除。\n安全管理器可追溯到 Java 1.0,多年来,它一直不是保护客户端 Java 代码的主要方法,也很少用于保护服务器端代码。为了推动 Java 向前发展,Java 17 弃用安全管理器,以便与旧版 Applet API ( JEP 398 ) 一起移除。\nJEP 412:外部函数和内存 API(孵化) # Java 程序可以通过该 API 与 Java 运行时之外的代码和数据进行互操作。通过高效地调用外部函数(即 JVM 之外的代码)和安全地访问外部内存(即不受 JVM 管理的内存),该 API 使 Java 程序能够调用本机库并处理本机数据,而不会像 JNI 那样危险和脆弱。\n外部函数和内存 API 在 Java 17 中进行了第一轮孵化,由 JEP 412 提出。第二轮孵化由 JEP 419 提出并集成到了 Java 18 中,预览由 JEP 424 提出并集成到了 Java 19 中。\n在 Java 19 新特性概览 中,我有详细介绍到外部函数和内存 API,这里就不再做额外的介绍了。\nJEP 414:向量 API(第二次孵化) # 向量(Vector) API 最初由 JEP 338 提出,并作为 孵化 API集成到 Java 16 中。第二轮孵化由 JEP 414 提出并集成到 Java 17 中,第三轮孵化由 JEP 417 提出并集成到 Java 18 中,第四轮由 JEP 426 提出并集成到了 Java 19 中。\n该孵化器 API 提供了一个 API 的初始迭代以表达一些向量计算,这些计算在运行时可靠地编译为支持的 CPU 架构上的最佳向量硬件指令,从而获得优于同等标量计算的性能,充分利用单指令多数据(SIMD)技术(大多数现代 CPU 上都可以使用的一种指令)。尽管 HotSpot 支持自动向量化,但是可转换的标量操作集有限且易受代码更改的影响。该 API 将使开发人员能够轻松地用 Java 编写可移植的高性能向量算法。\n在 Java 18 新特性概览 中,我有详细介绍到向量 API,这里就不再做额外的介绍了。\n"},{"id":491,"href":"/zh/docs/technology/Interview/java/new-features/java18/","title":"Java 18 新特性概览","section":"New Features","content":"Java 18 在 2022 年 3 月 22 日正式发布,非长期支持版本。\nJava 18 带来了 9 个新特性:\nJEP 400:UTF-8 by Default(默认字符集为 UTF-8) JEP 408:Simple Web Server(简易的 Web 服务器) JEP 413:Code Snippets in Java API Documentation(Java API 文档中的代码片段) JEP 416:Reimplement Core Reflection with Method Handles(使用方法句柄重新实现反射核心) JEP 417:Vector(向量) API(第三次孵化) JEP 418:Internet-Address Resolution(互联网地址解析)SPI JEP 419:Foreign Function \u0026amp; Memory API(外部函数和内存 API)(第二次孵化) JEP 420:Pattern Matching for switch(switch 模式匹配)(第二次预览) JEP 421:Deprecate Finalization for Removal Java 17 中包含 14 个特性,Java 16 中包含 17 个特性,Java 15 中包含 14 个特性,Java 14 中包含 16 个特性。相比于前面发布的版本来说,Java 18 的新特性少了很多。\n这里只对 400、408、413、416、417、418、419 这几个我觉得比较重要的新特性进行详细介绍。\n相关阅读:\nOpenJDK Java 18 文档 IntelliJ IDEA | Java 18 功能支持 JEP 400:默认字符集为 UTF-8 # JDK 终于将 UTF-8 设置为默认字符集。\n在 Java 17 及更早版本中,默认字符集是在 Java 虚拟机运行时才确定的,取决于不同的操作系统、区域设置等因素,因此存在潜在的风险。就比如说你在 Mac 上运行正常的一段打印文字到控制台的 Java 程序到了 Windows 上就会出现乱码,如果你不手动更改字符集的话。\nJEP 408:简易的 Web 服务器 # Java 18 之后,你可以使用 jwebserver 命令启动一个简易的静态 Web 服务器。\n$ jwebserver Binding to loopback by default. For all interfaces use \u0026#34;-b 0.0.0.0\u0026#34; or \u0026#34;-b ::\u0026#34;. Serving /cwd and subdirectories on 127.0.0.1 port 8000 URL: http://127.0.0.1:8000/ 这个服务器不支持 CGI 和 Servlet,只限于静态文件。\nJEP 413:优化 Java API 文档中的代码片段 # 在 Java 18 之前,如果我们想要在 Javadoc 中引入代码片段可以使用 \u0026lt;pre\u0026gt;{@code ...}\u0026lt;/pre\u0026gt; 。\n\u0026lt;pre\u0026gt;{@code lines of source code }\u0026lt;/pre\u0026gt; \u0026lt;pre\u0026gt;{@code ...}\u0026lt;/pre\u0026gt; 这种方式生成的效果比较一般。\n在 Java 18 之后,可以通过 @snippet 标签来做这件事情。\n/** * The following code shows how to use {@code Optional.isPresent}: * {@snippet : * if (v.isPresent()) { * System.out.println(\u0026#34;v: \u0026#34; + v.get()); * } * } */ @snippet 这种方式生成的效果更好且使用起来更方便一些。\nJEP 416:使用方法句柄重新实现反射核心 # Java 18 改进了 java.lang.reflect.Method、Constructor 的实现逻辑,使之性能更好,速度更快。这项改动不会改动相关 API ,这意味着开发中不需要改动反射相关代码,就可以体验到性能更好反射。\nOpenJDK 官方给出了新老实现的反射性能基准测试结果。\nJEP 417: 向量 API(第三次孵化) # 向量(Vector) API 最初由 JEP 338 提出,并作为 孵化 API集成到 Java 16 中。第二轮孵化由 JEP 414 提出并集成到 Java 17 中,第三轮孵化由 JEP 417 提出并集成到 Java 18 中,第四轮由 JEP 426 提出并集成到了 Java 19 中。\n向量计算由对向量的一系列操作组成。向量 API 用来表达向量计算,该计算可以在运行时可靠地编译为支持的 CPU 架构上的最佳向量指令,从而实现优于等效标量计算的性能。\n向量 API 的目标是为用户提供简洁易用且与平台无关的表达范围广泛的向量计算。\n这是对数组元素的简单标量计算:\nvoid scalarComputation(float[] a, float[] b, float[] c) { for (int i = 0; i \u0026lt; a.length; i++) { c[i] = (a[i] * a[i] + b[i] * b[i]) * -1.0f; } } 这是使用 Vector API 进行的等效向量计算:\nstatic final VectorSpecies\u0026lt;Float\u0026gt; SPECIES = FloatVector.SPECIES_PREFERRED; void vectorComputation(float[] a, float[] b, float[] c) { int i = 0; int upperBound = SPECIES.loopBound(a.length); for (; i \u0026lt; upperBound; i += SPECIES.length()) { // FloatVector va, vb, vc; var va = FloatVector.fromArray(SPECIES, a, i); var vb = FloatVector.fromArray(SPECIES, b, i); var vc = va.mul(va) .add(vb.mul(vb)) .neg(); vc.intoArray(c, i); } for (; i \u0026lt; a.length; i++) { c[i] = (a[i] * a[i] + b[i] * b[i]) * -1.0f; } } 在 JDK 18 中,向量 API 的性能得到了进一步的优化。\nJEP 418:互联网地址解析 SPI # Java 18 定义了一个全新的 SPI(service-provider interface),用于主要名称和地址的解析,以便 java.net.InetAddress 可以使用平台之外的第三方解析器。\nJEP 419:Foreign Function \u0026amp; Memory API(第二次孵化) # Java 程序可以通过该 API 与 Java 运行时之外的代码和数据进行互操作。通过高效地调用外部函数(即 JVM 之外的代码)和安全地访问外部内存(即不受 JVM 管理的内存),该 API 使 Java 程序能够调用本机库并处理本机数据,而不会像 JNI 那样危险和脆弱。\n外部函数和内存 API 在 Java 17 中进行了第一轮孵化,由 JEP 412 提出。第二轮孵化由 JEP 419 提出并集成到了 Java 18 中,预览由 JEP 424 提出并集成到了 Java 19 中。\n在 Java 19 新特性概览 中,我有详细介绍到外部函数和内存 API,这里就不再做额外的介绍了。\n"},{"id":492,"href":"/zh/docs/technology/Interview/java/new-features/java19/","title":"Java 19 新特性概览","section":"New Features","content":"JDK 19 定于 2022 年 9 月 20 日正式发布以供生产使用,非长期支持版本。不过,JDK 19 中有一些比较重要的新特性值得关注。\nJDK 19 只有 7 个新特性:\nJEP 405: Record Patterns(记录模式)(预览) JEP 422: Linux/RISC-V Port JEP 424: Foreign Function \u0026amp; Memory API(外部函数和内存 API)(预览) JEP 425: Virtual Threads(虚拟线程)(预览) JEP 426: Vector(向量)API(第四次孵化) JEP 427: Pattern Matching for switch(switch 模式匹配) JEP 428: Structured Concurrency(结构化并发)(孵化) 这里只对 424、425、426、428 这 4 个我觉得比较重要的新特性进行详细介绍。\n相关阅读: OpenJDK Java 19 文档\nJEP 424: 外部函数和内存 API(预览) # Java 程序可以通过该 API 与 Java 运行时之外的代码和数据进行互操作。通过高效地调用外部函数(即 JVM 之外的代码)和安全地访问外部内存(即不受 JVM 管理的内存),该 API 使 Java 程序能够调用本机库并处理本机数据,而不会像 JNI 那样危险和脆弱。\n外部函数和内存 API 在 Java 17 中进行了第一轮孵化,由 JEP 412 提出。第二轮孵化由 JEP 419 提出并集成到了 Java 18 中,预览由 JEP 424 提出并集成到了 Java 19 中。\n在没有外部函数和内存 API 之前:\nJava 通过 sun.misc.Unsafe 提供一些执行低级别、不安全操作的方法(如直接访问系统内存资源、自主管理内存资源等),Unsafe 类让 Java 语言拥有了类似 C 语言指针一样操作内存空间的能力的同时,也增加了 Java 语言的不安全性,不正确使用 Unsafe 类会使得程序出错的概率变大。 Java 1.1 就已通过 Java 原生接口(JNI)支持了原生方法调用,但并不好用。JNI 实现起来过于复杂,步骤繁琐(具体的步骤可以参考这篇文章: Guide to JNI (Java Native Interface) ),不受 JVM 的语言安全机制控制,影响 Java 语言的跨平台特性。并且,JNI 的性能也不行,因为 JNI 方法调用不能从许多常见的 JIT 优化(如内联)中受益。虽然 JNA、 JNR和 JavaCPP等框架对 JNI 进行了改进,但效果还是不太理想。 引入外部函数和内存 API 就是为了解决 Java 访问外部函数和外部内存存在的一些痛点。\nForeign Function \u0026amp; Memory API (FFM API) 定义了类和接口:\n分配外部内存:MemorySegment、MemoryAddress和SegmentAllocator; 操作和访问结构化的外部内存:MemoryLayout, VarHandle; 控制外部内存的分配和释放:MemorySession; 调用外部函数:Linker、FunctionDescriptor和SymbolLookup。 下面是 FFM API 使用示例,这段代码获取了 C 库函数的 radixsort 方法句柄,然后使用它对 Java 数组中的四个字符串进行排序。\n// 1. 在C库路径上查找外部函数 Linker linker = Linker.nativeLinker(); SymbolLookup stdlib = linker.defaultLookup(); MethodHandle radixSort = linker.downcallHandle( stdlib.lookup(\u0026#34;radixsort\u0026#34;), ...); // 2. 分配堆上内存以存储四个字符串 String[] javaStrings = { \u0026#34;mouse\u0026#34;, \u0026#34;cat\u0026#34;, \u0026#34;dog\u0026#34;, \u0026#34;car\u0026#34; }; // 3. 分配堆外内存以存储四个指针 SegmentAllocator allocator = implicitAllocator(); MemorySegment offHeap = allocator.allocateArray(ValueLayout.ADDRESS, javaStrings.length); // 4. 将字符串从堆上复制到堆外 for (int i = 0; i \u0026lt; javaStrings.length; i++) { // 在堆外分配一个字符串,然后存储指向它的指针 MemorySegment cString = allocator.allocateUtf8String(javaStrings[i]); offHeap.setAtIndex(ValueLayout.ADDRESS, i, cString); } // 5. 通过调用外部函数对堆外数据进行排序 radixSort.invoke(offHeap, javaStrings.length, MemoryAddress.NULL, \u0026#39;\\0\u0026#39;); // 6. 将(重新排序的)字符串从堆外复制到堆上 for (int i = 0; i \u0026lt; javaStrings.length; i++) { MemoryAddress cStringPtr = offHeap.getAtIndex(ValueLayout.ADDRESS, i); javaStrings[i] = cStringPtr.getUtf8String(0); } assert Arrays.equals(javaStrings, new String[] {\u0026#34;car\u0026#34;, \u0026#34;cat\u0026#34;, \u0026#34;dog\u0026#34;, \u0026#34;mouse\u0026#34;}); // true JEP 425: 虚拟线程(预览) # 虚拟线程(Virtual Thread-)是 JDK 而不是 OS 实现的轻量级线程(Lightweight Process,LWP),许多虚拟线程共享同一个操作系统线程,虚拟线程的数量可以远大于操作系统线程的数量。\n虚拟线程在其他多线程语言中已经被证实是十分有用的,比如 Go 中的 Goroutine、Erlang 中的进程。\n虚拟线程避免了上下文切换的额外耗费,兼顾了多线程的优点,简化了高并发程序的复杂,可以有效减少编写、维护和观察高吞吐量并发应用程序的工作量。\n知乎有一个关于 Java 19 虚拟线程的讨论,感兴趣的可以去看看: https://www.zhihu.com/question/536743167 。\nJava 虚拟线程的详细解读和原理可以看下面这两篇文章:\n虚拟线程原理及性能分析|得物技术 Java19 正式 GA!看虚拟线程如何大幅提高系统吞吐量 虚拟线程 - VirtualThread 源码透视 JEP 426: 向量 API(第四次孵化) # 向量(Vector) API 最初由 JEP 338 提出,并作为 孵化 API集成到 Java 16 中。第二轮孵化由 JEP 414 提出并集成到 Java 17 中,第三轮孵化由 JEP 417 提出并集成到 Java 18 中,第四轮由 JEP 426 提出并集成到了 Java 19 中。\n在 Java 18 新特性概览 中,我有详细介绍到向量 API,这里就不再做额外的介绍了。\nJEP 428: 结构化并发(孵化) # JDK 19 引入了结构化并发,一种多线程编程方法,目的是为了通过结构化并发 API 来简化多线程编程,并不是为了取代java.util.concurrent,目前处于孵化器阶段。\n结构化并发将不同线程中运行的多个任务视为单个工作单元,从而简化错误处理、提高可靠性并增强可观察性。也就是说,结构化并发保留了单线程代码的可读性、可维护性和可观察性。\n结构化并发的基本 API 是 StructuredTaskScope。StructuredTaskScope 支持将任务拆分为多个并发子任务,在它们自己的线程中执行,并且子任务必须在主任务继续之前完成。\nStructuredTaskScope 的基本用法如下:\ntry (var scope = new StructuredTaskScope\u0026lt;Object\u0026gt;()) { // 使用fork方法派生线程来执行子任务 Future\u0026lt;Integer\u0026gt; future1 = scope.fork(task1); Future\u0026lt;String\u0026gt; future2 = scope.fork(task2); // 等待线程完成 scope.join(); // 结果的处理可能包括处理或重新抛出异常 ... process results/exceptions ... } // close 结构化并发非常适合虚拟线程,虚拟线程是 JDK 实现的轻量级线程。许多虚拟线程共享同一个操作系统线程,从而允许非常多的虚拟线程。\n"},{"id":493,"href":"/zh/docs/technology/Interview/java/new-features/java20/","title":"Java 20 新特性概览","section":"New Features","content":"JDK 20 于 2023 年 3 月 21 日发布,非长期支持版本。\n根据开发计划,下一个 LTS 版本就是将于 2023 年 9 月发布的 JDK 21。\nJDK 20 只有 7 个新特性:\nJEP 429:Scoped Values(作用域值)(第一次孵化) JEP 432:Record Patterns(记录模式)(第二次预览) JEP 433:switch 模式匹配(第四次预览) JEP 434: Foreign Function \u0026amp; Memory API(外部函数和内存 API)(第二次预览) JEP 436: Virtual Threads(虚拟线程)(第二次预览) JEP 437:Structured Concurrency(结构化并发)(第二次孵化) JEP 432:向量 API(第五次孵化) JEP 429:作用域值(第一次孵化) # 作用域值(Scoped Values)它可以在线程内和线程间共享不可变的数据,优于线程局部变量,尤其是在使用大量虚拟线程时。\nfinal static ScopedValue\u0026lt;...\u0026gt; V = new ScopedValue\u0026lt;\u0026gt;(); // In some method ScopedValue.where(V, \u0026lt;value\u0026gt;) .run(() -\u0026gt; { ... V.get() ... call methods ... }); // In a method called directly or indirectly from the lambda expression ... V.get() ... 作用域值允许在大型程序中的组件之间安全有效地共享数据,而无需求助于方法参数。\n关于作用域值的详细介绍,推荐阅读 作用域值常见问题解答这篇文章。\nJEP 432:记录模式(第二次预览) # 记录模式(Record Patterns) 可对 record 的值进行解构,也就是更方便地从记录类(Record Class)中提取数据。并且,还可以嵌套记录模式和类型模式结合使用,以实现强大的、声明性的和可组合的数据导航和处理形式。\n记录模式不能单独使用,而是要与 instanceof 或 switch 模式匹配一同使用。\n先以 instanceof 为例简单演示一下。\n简单定义一个记录类:\nrecord Shape(String type, long unit){} 没有记录模式之前:\nShape circle = new Shape(\u0026#34;Circle\u0026#34;, 10); if (circle instanceof Shape shape) { System.out.println(\u0026#34;Area of \u0026#34; + shape.type() + \u0026#34; is : \u0026#34; + Math.PI * Math.pow(shape.unit(), 2)); } 有了记录模式之后:\nShape circle = new Shape(\u0026#34;Circle\u0026#34;, 10); if (circle instanceof Shape(String type, long unit)) { System.out.println(\u0026#34;Area of \u0026#34; + type + \u0026#34; is : \u0026#34; + Math.PI * Math.pow(unit, 2)); } 再看看记录模式与 switch 的配合使用。\n定义一些类:\ninterface Shape {} record Circle(double radius) implements Shape { } record Square(double side) implements Shape { } record Rectangle(double length, double width) implements Shape { } 没有记录模式之前:\nShape shape = new Circle(10); switch (shape) { case Circle c: System.out.println(\u0026#34;The shape is Circle with area: \u0026#34; + Math.PI * c.radius() * c.radius()); break; case Square s: System.out.println(\u0026#34;The shape is Square with area: \u0026#34; + s.side() * s.side()); break; case Rectangle r: System.out.println(\u0026#34;The shape is Rectangle with area: + \u0026#34; + r.length() * r.width()); break; default: System.out.println(\u0026#34;Unknown Shape\u0026#34;); break; } 有了记录模式之后:\nShape shape = new Circle(10); switch(shape) { case Circle(double radius): System.out.println(\u0026#34;The shape is Circle with area: \u0026#34; + Math.PI * radius * radius); break; case Square(double side): System.out.println(\u0026#34;The shape is Square with area: \u0026#34; + side * side); break; case Rectangle(double length, double width): System.out.println(\u0026#34;The shape is Rectangle with area: + \u0026#34; + length * width); break; default: System.out.println(\u0026#34;Unknown Shape\u0026#34;); break; } 记录模式可以避免不必要的转换,使得代码更建简洁易读。而且,用了记录模式后不必再担心 null 或者 NullPointerException,代码更安全可靠。\n记录模式在 Java 19 进行了第一次预览, 由 JEP 405 提出。JDK 20 中是第二次预览,由 JEP 432 提出。这次的改进包括:\n添加对通用记录模式类型参数推断的支持, 添加对记录模式的支持以出现在增强语句的标题中for 删除对命名记录模式的支持。 注意:不要把记录模式和 JDK16 正式引入的记录类搞混了。\nJEP 433:switch 模式匹配(第四次预览) # 正如 instanceof 一样, switch 也紧跟着增加了类型匹配自动转换功能。\ninstanceof 代码示例:\n// Old code if (o instanceof String) { String s = (String)o; ... use s ... } // New code if (o instanceof String s) { ... use s ... } switch 代码示例:\n// Old code static String formatter(Object o) { String formatted = \u0026#34;unknown\u0026#34;; if (o instanceof Integer i) { formatted = String.format(\u0026#34;int %d\u0026#34;, i); } else if (o instanceof Long l) { formatted = String.format(\u0026#34;long %d\u0026#34;, l); } else if (o instanceof Double d) { formatted = String.format(\u0026#34;double %f\u0026#34;, d); } else if (o instanceof String s) { formatted = String.format(\u0026#34;String %s\u0026#34;, s); } return formatted; } // New code static String formatterPatternSwitch(Object o) { return switch (o) { case Integer i -\u0026gt; String.format(\u0026#34;int %d\u0026#34;, i); case Long l -\u0026gt; String.format(\u0026#34;long %d\u0026#34;, l); case Double d -\u0026gt; String.format(\u0026#34;double %f\u0026#34;, d); case String s -\u0026gt; String.format(\u0026#34;String %s\u0026#34;, s); default -\u0026gt; o.toString(); }; } switch 模式匹配分别在 Java17、Java18、Java19 中进行了预览,Java20 是第四次预览了。每一次的预览基本都会有一些小改进,这里就不细提了。\nJEP 434: 外部函数和内存 API(第二次预览) # Java 程序可以通过该 API 与 Java 运行时之外的代码和数据进行互操作。通过高效地调用外部函数(即 JVM 之外的代码)和安全地访问外部内存(即不受 JVM 管理的内存),该 API 使 Java 程序能够调用本机库并处理本机数据,而不会像 JNI 那样危险和脆弱。\n外部函数和内存 API 在 Java 17 中进行了第一轮孵化,由 JEP 412 提出。Java 18 中进行了第二次孵化,由 JEP 419 提出。Java 19 中是第一次预览,由 JEP 424 提出。\nJDK 20 中是第二次预览,由 JEP 434 提出,这次的改进包括:\nMemorySegment 和 MemoryAddress 抽象的统一 增强的 MemoryLayout 层次结构 MemorySession拆分为Arena和SegmentScope,以促进跨维护边界的段共享。 在 Java 19 新特性概览 中,我有详细介绍到外部函数和内存 API,这里就不再做额外的介绍了。\nJEP 436: 虚拟线程(第二次预览) # 虚拟线程(Virtual Thread)是 JDK 而不是 OS 实现的轻量级线程(Lightweight Process,LWP),由 JVM 调度。许多虚拟线程共享同一个操作系统线程,虚拟线程的数量可以远大于操作系统线程的数量。\n在引入虚拟线程之前,java.lang.Thread 包已经支持所谓的平台线程,也就是没有虚拟线程之前,我们一直使用的线程。JVM 调度程序通过平台线程(载体线程)来管理虚拟线程,一个平台线程可以在不同的时间执行不同的虚拟线程(多个虚拟线程挂载在一个平台线程上),当虚拟线程被阻塞或等待时,平台线程可以切换到执行另一个虚拟线程。\n虚拟线程、平台线程和系统内核线程的关系图如下所示(图源: How to Use Java 19 Virtual Threads):\n关于平台线程和系统内核线程的对应关系多提一点:在 Windows 和 Linux 等主流操作系统中,Java 线程采用的是一对一的线程模型,也就是一个平台线程对应一个系统内核线程。Solaris 系统是一个特例,HotSpot VM 在 Solaris 上支持多对多和一对一。具体可以参考 R 大的回答: JVM 中的线程模型是用户级的么?。\n相比较于平台线程来说,虚拟线程是廉价且轻量级的,使用完后立即被销毁,因此它们不需要被重用或池化,每个任务可以有自己专属的虚拟线程来运行。虚拟线程暂停和恢复来实现线程之间的切换,避免了上下文切换的额外耗费,兼顾了多线程的优点,简化了高并发程序的复杂,可以有效减少编写、维护和观察高吞吐量并发应用程序的工作量。\n虚拟线程在其他多线程语言中已经被证实是十分有用的,比如 Go 中的 Goroutine、Erlang 中的进程。\n知乎有一个关于 Java 19 虚拟线程的讨论,感兴趣的可以去看看: https://www.zhihu.com/question/536743167 。\nJava 虚拟线程的详细解读和原理可以看下面这几篇文章:\n虚拟线程极简入门 Java19 正式 GA!看虚拟线程如何大幅提高系统吞吐量 虚拟线程 - VirtualThread 源码透视 虚拟线程在 Java 19 中进行了第一次预览,由 JEP 425提出。JDK 20 中是第二次预览,做了一些细微变化,这里就不细提了。\n最后,我们来看一下四种创建虚拟线程的方法:\n// 1、通过 Thread.ofVirtual() 创建 Runnable fn = () -\u0026gt; { // your code here }; Thread thread = Thread.ofVirtual(fn) .start(); // 2、通过 Thread.startVirtualThread() 、创建 Thread thread = Thread.startVirtualThread(() -\u0026gt; { // your code here }); // 3、通过 Executors.newVirtualThreadPerTaskExecutor() 创建 var executorService = Executors.newVirtualThreadPerTaskExecutor(); executorService.submit(() -\u0026gt; { // your code here }); class CustomThread implements Runnable { @Override public void run() { System.out.println(\u0026#34;CustomThread run\u0026#34;); } } //4、通过 ThreadFactory 创建 CustomThread customThread = new CustomThread(); // 获取线程工厂类 ThreadFactory factory = Thread.ofVirtual().factory(); // 创建虚拟线程 Thread thread = factory.newThread(customThread); // 启动线程 thread.start(); 通过上述列举的 4 种创建虚拟线程的方式可以看出,官方为了降低虚拟线程的门槛,尽力复用原有的 Thread 线程类,这样可以平滑的过渡到虚拟线程的使用。\nJEP 437: 结构化并发(第二次孵化) # Java 19 引入了结构化并发,一种多线程编程方法,目的是为了通过结构化并发 API 来简化多线程编程,并不是为了取代java.util.concurrent,目前处于孵化器阶段。\n结构化并发将不同线程中运行的多个任务视为单个工作单元,从而简化错误处理、提高可靠性并增强可观察性。也就是说,结构化并发保留了单线程代码的可读性、可维护性和可观察性。\n结构化并发的基本 API 是 StructuredTaskScope。StructuredTaskScope 支持将任务拆分为多个并发子任务,在它们自己的线程中执行,并且子任务必须在主任务继续之前完成。\nStructuredTaskScope 的基本用法如下:\ntry (var scope = new StructuredTaskScope\u0026lt;Object\u0026gt;()) { // 使用fork方法派生线程来执行子任务 Future\u0026lt;Integer\u0026gt; future1 = scope.fork(task1); Future\u0026lt;String\u0026gt; future2 = scope.fork(task2); // 等待线程完成 scope.join(); // 结果的处理可能包括处理或重新抛出异常 ... process results/exceptions ... } // close 结构化并发非常适合虚拟线程,虚拟线程是 JDK 实现的轻量级线程。许多虚拟线程共享同一个操作系统线程,从而允许非常多的虚拟线程。\nJDK 20 中对结构化并发唯一变化是更新为支持在任务范围内创建的线程StructuredTaskScope继承范围值 这简化了跨线程共享不可变数据,详见 JEP 429。\nJEP 432:向量 API(第五次孵化) # 向量计算由对向量的一系列操作组成。向量 API 用来表达向量计算,该计算可以在运行时可靠地编译为支持的 CPU 架构上的最佳向量指令,从而实现优于等效标量计算的性能。\n向量 API 的目标是为用户提供简洁易用且与平台无关的表达范围广泛的向量计算。\n向量(Vector) API 最初由 JEP 338 提出,并作为 孵化 API集成到 Java 16 中。第二轮孵化由 JEP 414 提出并集成到 Java 17 中,第三轮孵化由 JEP 417 提出并集成到 Java 18 中,第四轮由 JEP 426 提出并集成到了 Java 19 中。\nJava20 的这次孵化基本没有改变向量 API ,只是进行了一些错误修复和性能增强,详见 JEP 438。\n"},{"id":494,"href":"/zh/docs/technology/Interview/java/new-features/java21/","title":"Java 21 新特性概览(重要)","section":"New Features","content":"JDK 21 于 2023 年 9 月 19 日 发布,这是一个非常重要的版本,里程碑式。\nJDK21 是 LTS(长期支持版),至此为止,目前有 JDK8、JDK11、JDK17 和 JDK21 这四个长期支持版了。\nJDK 21 共有 15 个新特性,这篇文章会挑选其中较为重要的一些新特性进行详细介绍:\nJEP 430:String Templates(字符串模板)(预览)\nJEP 431:Sequenced Collections(序列化集合)\nJEP 439:Generational ZGC(分代 ZGC)\nJEP 440:Record Patterns(记录模式)\nJEP 441:Pattern Matching for switch(switch 的模式匹配)\nJEP 442:Foreign Function \u0026amp; Memory API(外部函数和内存 API)(第三次预览)\nJEP 443:Unnamed Patterns and Variables(未命名模式和变量(预览)\nJEP 444:Virtual Threads(虚拟线程)\nJEP 445:Unnamed Classes and Instance Main Methods(未命名类和实例 main 方法 )(预览)\nJEP 430:字符串模板(预览) # String Templates(字符串模板) 目前仍然是 JDK 21 中的一个预览功能。\nString Templates 提供了一种更简洁、更直观的方式来动态构建字符串。通过使用占位符${},我们可以将变量的值直接嵌入到字符串中,而不需要手动处理。在运行时,Java 编译器会将这些占位符替换为实际的变量值。并且,表达式支持局部变量、静态/非静态字段甚至方法、计算结果等特性。\n实际上,String Templates(字符串模板)再大多数编程语言中都存在:\n\u0026#34;Greetings {{ name }}!\u0026#34;; //Angular `Greetings ${ name }!`; //Typescript $\u0026#34;Greetings { name }!\u0026#34; //Visual basic f\u0026#34;Greetings { name }!\u0026#34; //Python Java 在没有 String Templates 之前,我们通常使用字符串拼接或格式化方法来构建字符串:\n//concatenation message = \u0026#34;Greetings \u0026#34; + name + \u0026#34;!\u0026#34;; //String.format() message = String.format(\u0026#34;Greetings %s!\u0026#34;, name); //concatenation //MessageFormat message = new MessageFormat(\u0026#34;Greetings {0}!\u0026#34;).format(name); //StringBuilder message = new StringBuilder().append(\u0026#34;Greetings \u0026#34;).append(name).append(\u0026#34;!\u0026#34;).toString(); 这些方法或多或少都存在一些缺点,比如难以阅读、冗长、复杂。\nJava 使用 String Templates 进行字符串拼接,可以直接在字符串中嵌入表达式,而无需进行额外的处理:\nString message = STR.\u0026#34;Greetings \\{name}!\u0026#34;; 在上面的模板表达式中:\nSTR 是模板处理器。 \\{name}为表达式,运行时,这些表达式将被相应的变量值替换。 Java 目前支持三种模板处理器:\nSTR:自动执行字符串插值,即将模板中的每个嵌入式表达式替换为其值(转换为字符串)。 FMT:和 STR 类似,但是它还可以接受格式说明符,这些格式说明符出现在嵌入式表达式的左边,用来控制输出的样式。 RAW:不会像 STR 和 FMT 模板处理器那样自动处理字符串模板,而是返回一个 StringTemplate 对象,这个对象包含了模板中的文本和表达式的信息。 String name = \u0026#34;Lokesh\u0026#34;; //STR String message = STR.\u0026#34;Greetings \\{name}.\u0026#34;; //FMT String message = STR.\u0026#34;Greetings %-12s\\{name}.\u0026#34;; //RAW StringTemplate st = RAW.\u0026#34;Greetings \\{name}.\u0026#34;; String message = STR.process(st); 除了 JDK 自带的三种模板处理器外,你还可以实现 StringTemplate.Processor 接口来创建自己的模板处理器,只需要继承 StringTemplate.Processor接口,然后实现 process 方法即可。\n我们可以使用局部变量、静态/非静态字段甚至方法作为嵌入表达式:\n//variable message = STR.\u0026#34;Greetings \\{name}!\u0026#34;; //method message = STR.\u0026#34;Greetings \\{getName()}!\u0026#34;; //field message = STR.\u0026#34;Greetings \\{this.name}!\u0026#34;; 还可以在表达式中执行计算并打印结果:\nint x = 10, y = 20; String s = STR.\u0026#34;\\{x} + \\{y} = \\{x + y}\u0026#34;; //\u0026#34;10 + 20 = 30\u0026#34; 为了提高可读性,我们可以将嵌入的表达式分成多行:\nString time = STR.\u0026#34;The current time is \\{ //sample comment - current time in HH:mm:ss DateTimeFormatter .ofPattern(\u0026#34;HH:mm:ss\u0026#34;) .format(LocalTime.now()) }.\u0026#34;; JEP431:序列化集合 # JDK 21 引入了一种新的集合类型:Sequenced Collections(序列化集合,也叫有序集合),这是一种具有确定出现顺序(encounter order)的集合(无论我们遍历这样的集合多少次,元素的出现顺序始终是固定的)。序列化集合提供了处理集合的第一个和最后一个元素以及反向视图(与原始集合相反的顺序)的简单方法。\nSequenced Collections 包括以下三个接口:\nSequencedCollection SequencedSet SequencedMap SequencedCollection 接口继承了 Collection接口, 提供了在集合两端访问、添加或删除元素以及获取集合的反向视图的方法。\ninterface SequencedCollection\u0026lt;E\u0026gt; extends Collection\u0026lt;E\u0026gt; { // New Method SequencedCollection\u0026lt;E\u0026gt; reversed(); // Promoted methods from Deque\u0026lt;E\u0026gt; void addFirst(E); void addLast(E); E getFirst(); E getLast(); E removeFirst(); E removeLast(); } List 和 Deque 接口实现了SequencedCollection 接口。\n这里以 ArrayList 为例,演示一下实际使用效果:\nArrayList\u0026lt;Integer\u0026gt; arrayList = new ArrayList\u0026lt;\u0026gt;(); arrayList.add(1); // List contains: [1] arrayList.addFirst(0); // List contains: [0, 1] arrayList.addLast(2); // List contains: [0, 1, 2] Integer firstElement = arrayList.getFirst(); // 0 Integer lastElement = arrayList.getLast(); // 2 List\u0026lt;Integer\u0026gt; reversed = arrayList.reversed(); System.out.println(reversed); // Prints [2, 1, 0] SequencedSet接口直接继承了 SequencedCollection 接口并重写了 reversed() 方法。\ninterface SequencedSet\u0026lt;E\u0026gt; extends SequencedCollection\u0026lt;E\u0026gt;, Set\u0026lt;E\u0026gt; { SequencedSet\u0026lt;E\u0026gt; reversed(); } SortedSet 和 LinkedHashSet 实现了SequencedSet接口。\n这里以 LinkedHashSet 为例,演示一下实际使用效果:\nLinkedHashSet\u0026lt;Integer\u0026gt; linkedHashSet = new LinkedHashSet\u0026lt;\u0026gt;(List.of(1, 2, 3)); Integer firstElement = linkedHashSet.getFirst(); // 1 Integer lastElement = linkedHashSet.getLast(); // 3 linkedHashSet.addFirst(0); //List contains: [0, 1, 2, 3] linkedHashSet.addLast(4); //List contains: [0, 1, 2, 3, 4] System.out.println(linkedHashSet.reversed()); //Prints [5, 3, 2, 1, 0] SequencedMap 接口继承了 Map接口, 提供了在集合两端访问、添加或删除键值对、获取包含 key 的 SequencedSet、包含 value 的 SequencedCollection、包含 entry(键值对) 的 SequencedSet以及获取集合的反向视图的方法。\ninterface SequencedMap\u0026lt;K,V\u0026gt; extends Map\u0026lt;K,V\u0026gt; { // New Methods SequencedMap\u0026lt;K,V\u0026gt; reversed(); SequencedSet\u0026lt;K\u0026gt; sequencedKeySet(); SequencedCollection\u0026lt;V\u0026gt; sequencedValues(); SequencedSet\u0026lt;Entry\u0026lt;K,V\u0026gt;\u0026gt; sequencedEntrySet(); V putFirst(K, V); V putLast(K, V); // Promoted Methods from NavigableMap\u0026lt;K, V\u0026gt; Entry\u0026lt;K, V\u0026gt; firstEntry(); Entry\u0026lt;K, V\u0026gt; lastEntry(); Entry\u0026lt;K, V\u0026gt; pollFirstEntry(); Entry\u0026lt;K, V\u0026gt; pollLastEntry(); } SortedMap 和LinkedHashMap 实现了SequencedMap 接口。\n这里以 LinkedHashMap 为例,演示一下实际使用效果:\nLinkedHashMap\u0026lt;Integer, String\u0026gt; map = new LinkedHashMap\u0026lt;\u0026gt;(); map.put(1, \u0026#34;One\u0026#34;); map.put(2, \u0026#34;Two\u0026#34;); map.put(3, \u0026#34;Three\u0026#34;); map.firstEntry(); //1=One map.lastEntry(); //3=Three System.out.println(map); //{1=One, 2=Two, 3=Three} Map.Entry\u0026lt;Integer, String\u0026gt; first = map.pollFirstEntry(); //1=One Map.Entry\u0026lt;Integer, String\u0026gt; last = map.pollLastEntry(); //3=Three System.out.println(map); //{2=Two} map.putFirst(1, \u0026#34;One\u0026#34;); //{1=One, 2=Two} map.putLast(3, \u0026#34;Three\u0026#34;); //{1=One, 2=Two, 3=Three} System.out.println(map); //{1=One, 2=Two, 3=Three} System.out.println(map.reversed()); //{3=Three, 2=Two, 1=One} JEP 439:分代 ZGC # JDK21 中对 ZGC 进行了功能扩展,增加了分代 GC 功能。不过,默认是关闭的,需要通过配置打开:\n// 启用分代ZGC java -XX:+UseZGC -XX:+ZGenerational ... 在未来的版本中,官方会把 ZGenerational 设为默认值,即默认打开 ZGC 的分代 GC。在更晚的版本中,非分代 ZGC 就被移除。\nIn a future release we intend to make Generational ZGC the default, at which point -XX:-ZGenerational will select non-generational ZGC. In an even later release we intend to remove non-generational ZGC, at which point the ZGenerational option will become obsolete.\n在将来的版本中,我们打算将 Generational ZGC 作为默认选项,此时-XX:-ZGenerational 将选择非分代 ZGC。在更晚的版本中,我们打算移除非分代 ZGC,此时 ZGenerational 选项将变得过时。\n分代 ZGC 可以显著减少垃圾回收过程中的停顿时间,并提高应用程序的响应性能。这对于大型 Java 应用程序和高并发场景下的性能优化非常有价值。\nJEP 440:记录模式 # 记录模式在 Java 19 进行了第一次预览, 由 JEP 405 提出。JDK 20 中是第二次预览,由 JEP 432 提出。最终,记录模式在 JDK21 顺利转正。\nJava 20 新特性概览已经详细介绍过记录模式,这里就不重复了。\nJEP 441:switch 的模式匹配 # 增强 Java 中的 switch 表达式和语句,允许在 case 标签中使用模式。当模式匹配时,执行 case 标签对应的代码。\n在下面的代码中,switch 表达式使用了类型模式来进行匹配。\nstatic String formatterPatternSwitch(Object obj) { return switch (obj) { case Integer i -\u0026gt; String.format(\u0026#34;int %d\u0026#34;, i); case Long l -\u0026gt; String.format(\u0026#34;long %d\u0026#34;, l); case Double d -\u0026gt; String.format(\u0026#34;double %f\u0026#34;, d); case String s -\u0026gt; String.format(\u0026#34;String %s\u0026#34;, s); default -\u0026gt; obj.toString(); }; } JEP 442:外部函数和内存 API(第三次预览) # Java 程序可以通过该 API 与 Java 运行时之外的代码和数据进行互操作。通过高效地调用外部函数(即 JVM 之外的代码)和安全地访问外部内存(即不受 JVM 管理的内存),该 API 使 Java 程序能够调用本机库并处理本机数据,而不会像 JNI 那样危险和脆弱。\n外部函数和内存 API 在 Java 17 中进行了第一轮孵化,由 JEP 412 提出。Java 18 中进行了第二次孵化,由 JEP 419 提出。Java 19 中是第一次预览,由 JEP 424 提出。JDK 20 中是第二次预览,由 JEP 434 提出。JDK 21 中是第三次预览,由 JEP 442 提出。\n在 Java 19 新特性概览 中,我有详细介绍到外部函数和内存 API,这里就不再做额外的介绍了。\nJEP 443:未命名模式和变量(预览) # 未命名模式和变量使得我们可以使用下划线 _ 表示未命名的变量以及模式匹配时不使用的组件,旨在提高代码的可读性和可维护性。\n未命名变量的典型场景是 try-with-resources 语句、 catch 子句中的异常变量和for循环。当变量不需要使用的时候就可以使用下划线 _代替,这样清晰标识未被使用的变量。\ntry (var _ = ScopedContext.acquire()) { // No use of acquired resource } try { ... } catch (Exception _) { ... } catch (Throwable _) { ... } for (int i = 0, _ = runOnce(); i \u0026lt; arr.length; i++) { ... } 未命名模式是一个无条件的模式,并不绑定任何值。未命名模式变量出现在类型模式中。\nif (r instanceof ColoredPoint(_, Color c)) { ... c ... } switch (b) { case Box(RedBall _), Box(BlueBall _) -\u0026gt; processBox(b); case Box(GreenBall _) -\u0026gt; stopProcessing(); case Box(_) -\u0026gt; pickAnotherBox(); } JEP 444:虚拟线程 # 虚拟线程是一项重量级的更新,一定一定要重视!\n虚拟线程在 Java 19 中进行了第一次预览,由 JEP 425提出。JDK 20 中是第二次预览。最终,虚拟线程在 JDK21 顺利转正。\nJava 20 新特性概览已经详细介绍过虚拟线程,这里就不重复了。\nJEP 445:未命名类和实例 main 方法 (预览) # 这个特性主要简化了 main 方法的的声明。对于 Java 初学者来说,这个 main 方法的声明引入了太多的 Java 语法概念,不利于初学者快速上手。\n没有使用该特性之前定义一个 main 方法:\npublic class HelloWorld { public static void main(String[] args) { System.out.println(\u0026#34;Hello, World!\u0026#34;); } } 使用该新特性之后定义一个 main 方法:\nclass HelloWorld { void main() { System.out.println(\u0026#34;Hello, World!\u0026#34;); } } 进一步精简(未命名的类允许我们不定义类名):\nvoid main() { System.out.println(\u0026#34;Hello, World!\u0026#34;); } 参考 # Java 21 String Templates: https://howtodoinjava.com/java/java-string-templates/ Java 21 Sequenced Collections: https://howtodoinjava.com/java/sequenced-collections/ "},{"id":495,"href":"/zh/docs/technology/Interview/java/new-features/java22-23/","title":"Java 22 \u0026 23 新特性概览","section":"New Features","content":"JDK 23 和 JDK 22 一样,这也是一个非 LTS(长期支持)版本,Oracle 仅提供六个月的支持。下一个长期支持版是 JDK 25,预计明年 9 月份发布。\n由于 JDK 22 和 JDK 23 重合的新特性较多,这里主要以 JDK 23 为主介绍,会补充 JDK 22 独有的一些特性。\nJDK 23 一共有 12 个新特性:\nJEP 455: 模式中的原始类型、instanceof 和 switch(预览) JEP 456: 类文件 API(第二次预览) JEP 467:Markdown 文档注释 JEP 469:向量 API(第八次孵化) JEP 473:流收集器(第二次预览) JEP 471:弃用 sun.misc.Unsafe 中的内存访问方法 JEP 474:ZGC:默认的分代模式 JEP 476:模块导入声明 (预览) JEP 477:未命名类和实例 main 方法 (第三次预览) JEP 480:结构化并发 (第三次预览) JEP 481: 作用域值 (第三次预览) JEP 482:灵活的构造函数体(第二次预览) JDK 22 的新特性如下:\n其中,下面这 3 条新特性我会单独拎出来详细介绍一下:\nJEP 423:G1 垃圾收集器区域固定 JEP 454:外部函数与内存 API JEP 456:未命名模式和变量 JEP 458:启动多文件源代码程序 JDK 23 # JEP 455: 模式中的原始类型、instanceof 和 switch(预览) # 在 JEP 455 之前, instanceof 只支持引用类型,switch 表达式和语句的 case 标签只能使用整数字面量、枚举常量和字符串字面量。\nJEP 455 的预览特性中,instanceof 和 switch 全面支持所有原始类型,包括 byte, short, char, int, long, float, double, boolean。\n// 传统写法 if (i \u0026gt;= -128 \u0026amp;\u0026amp; i \u0026lt;= 127) { byte b = (byte)i; ... b ... } // 使用 instanceof 改进 if (i instanceof byte b) { ... b ... } long v = ...; // 传统写法 if (v == 1L) { // ... } else if (v == 2L) { // ... } else if (v == 10_000_000_000L) { // ... } // 使用 long 类型的 case 标签 switch (v) { case 1L: // ... break; case 2L: // ... break; case 10_000_000_000L: // ... break; default: // ... } JEP 456: 类文件 API(第二次预览) # 类文件 API 在 JDK 22 进行了第一次预览,由 JEP 457 提出。\n类文件 API 的目标是提供一套标准化的 API,用于解析、生成和转换 Java 类文件,取代过去对第三方库(如 ASM)在类文件处理上的依赖。\n// 创建一个 ClassFile 对象,这是操作类文件的入口。 ClassFile cf = ClassFile.of(); // 解析字节数组为 ClassModel ClassModel classModel = cf.parse(bytes); // 构建新的类文件,移除以 \u0026#34;debug\u0026#34; 开头的所有方法 byte[] newBytes = cf.build(classModel.thisClass().asSymbol(), classBuilder -\u0026gt; { // 遍历所有类元素 for (ClassElement ce : classModel) { // 判断是否为方法 且 方法名以 \u0026#34;debug\u0026#34; 开头 if (!(ce instanceof MethodModel mm \u0026amp;\u0026amp; mm.methodName().stringValue().startsWith(\u0026#34;debug\u0026#34;))) { // 添加到新的类文件中 classBuilder.with(ce); } } }); JEP 467:Markdown 文档注释 # 在 JavaDoc 文档注释中可以使用 Markdown 语法,取代原本只能使用 HTML 和 JavaDoc 标签的方式。\nMarkdown 更简洁易读,减少了手动编写 HTML 的繁琐,同时保留了对 HTML 元素和 JavaDoc 标签的支持。这个增强旨在让 API 文档注释的编写和阅读变得更加轻松,同时不会影响现有注释的解释。Markdown 提供了对常见文档元素(如段落、列表、链接等)的简化表达方式,提升了文档注释的可维护性和开发者体验。\nJEP 469:向量 API(第八次孵化) # 向量计算由对向量的一系列操作组成。向量 API 用来表达向量计算,该计算可以在运行时可靠地编译为支持的 CPU 架构上的最佳向量指令,从而实现优于等效标量计算的性能。\n向量 API 的目标是为用户提供简洁易用且与平台无关的表达范围广泛的向量计算。\n这是对数组元素的简单标量计算:\nvoid scalarComputation(float[] a, float[] b, float[] c) { for (int i = 0; i \u0026lt; a.length; i++) { c[i] = (a[i] * a[i] + b[i] * b[i]) * -1.0f; } } 这是使用 Vector API 进行的等效向量计算:\nstatic final VectorSpecies\u0026lt;Float\u0026gt; SPECIES = FloatVector.SPECIES_PREFERRED; void vectorComputation(float[] a, float[] b, float[] c) { int i = 0; int upperBound = SPECIES.loopBound(a.length); for (; i \u0026lt; upperBound; i += SPECIES.length()) { // FloatVector va, vb, vc; var va = FloatVector.fromArray(SPECIES, a, i); var vb = FloatVector.fromArray(SPECIES, b, i); var vc = va.mul(va) .add(vb.mul(vb)) .neg(); vc.intoArray(c, i); } for (; i \u0026lt; a.length; i++) { c[i] = (a[i] * a[i] + b[i] * b[i]) * -1.0f; } } JEP 473:流收集器(第二次预览) # 流收集器在 JDK 22 进行了第一次预览,由 JEP 461 提出。\n这个改进使得 Stream API 可以支持自定义中间操作。\nsource.gather(a).gather(b).gather(c).collect(...) JEP 471:弃用 sun.misc.Unsafe 中的内存访问方法 # JEP 471 提议弃用 sun.misc.Unsafe 中的内存访问方法,这些方法将来的版本中会被移除。\n这些不安全的方法已有安全高效的替代方案:\njava.lang.invoke.VarHandle :JDK 9 (JEP 193) 中引入,提供了一种安全有效地操作堆内存的方法,包括对象的字段、类的静态字段以及数组元素。 java.lang.foreign.MemorySegment :JDK 22 (JEP 454) 中引入,提供了一种安全有效地访问堆外内存的方法,有时会与 VarHandle 协同工作。 这两个类是 Foreign Function \u0026amp; Memory API(外部函数和内存 API) 的核心组件,分别用于管理和操作堆外内存。Foreign Function \u0026amp; Memory API 在 JDK 22 中正式转正,成为标准特性。\nimport jdk.incubator.foreign.*; import java.lang.invoke.VarHandle; // 管理堆外整数数组的类 class OffHeapIntBuffer { // 用于访问整数元素的VarHandle private static final VarHandle ELEM_VH = ValueLayout.JAVA_INT.arrayElementVarHandle(); // 内存管理器 private final Arena arena; // 堆外内存段 private final MemorySegment buffer; // 构造函数,分配指定数量的整数空间 public OffHeapIntBuffer(long size) { this.arena = Arena.ofShared(); this.buffer = arena.allocate(ValueLayout.JAVA_INT, size); } // 释放内存 public void deallocate() { arena.close(); } // 以volatile方式设置指定索引的值 public void setVolatile(long index, int value) { ELEM_VH.setVolatile(buffer, 0L, index, value); } // 初始化指定范围的元素为0 public void initialize(long start, long n) { buffer.asSlice(ValueLayout.JAVA_INT.byteSize() * start, ValueLayout.JAVA_INT.byteSize() * n) .fill((byte) 0); } // 将指定范围的元素复制到新数组 public int[] copyToNewArray(long start, int n) { return buffer.asSlice(ValueLayout.JAVA_INT.byteSize() * start, ValueLayout.JAVA_INT.byteSize() * n) .toArray(ValueLayout.JAVA_INT); } } JEP 474:ZGC:默认的分代模式 # Z 垃圾回收器 (ZGC) 的默认模式切换为分代模式,并弃用非分代模式,计划在未来版本中移除。这是因为分代 ZGC 是大多数场景下的更优选择。\nJEP 476:模块导入声明 (预览) # 模块导入声明允许在 Java 代码中简洁地导入整个模块的所有导出包,而无需逐个声明包的导入。这一特性简化了模块化库的重用,特别是在使用多个模块时,避免了大量的包导入声明,使得开发者可以更方便地访问第三方库和 Java 基本类。\n此特性对初学者和原型开发尤为有用,因为它无需开发者将自己的代码模块化,同时保留了对传统导入方式的兼容性,提升了开发效率和代码可读性。\n// 导入整个 java.base 模块,开发者可以直接访问 List、Map、Stream 等类,而无需每次手动导入相关包 import module java.base; public class Example { public static void main(String[] args) { String[] fruits = { \u0026#34;apple\u0026#34;, \u0026#34;berry\u0026#34;, \u0026#34;citrus\u0026#34; }; Map\u0026lt;String, String\u0026gt; fruitMap = Stream.of(fruits) .collect(Collectors.toMap( s -\u0026gt; s.toUpperCase().substring(0, 1), Function.identity())); System.out.println(fruitMap); } } JEP 477:未命名类和实例 main 方法 (第三次预览) # 这个特性主要简化了 main 方法的的声明。对于 Java 初学者来说,这个 main 方法的声明引入了太多的 Java 语法概念,不利于初学者快速上手。\n没有使用该特性之前定义一个 main 方法:\npublic class HelloWorld { public static void main(String[] args) { System.out.println(\u0026#34;Hello, World!\u0026#34;); } } 使用该新特性之后定义一个 main 方法:\nclass HelloWorld { void main() { System.out.println(\u0026#34;Hello, World!\u0026#34;); } } 进一步简化(未命名的类允许我们省略类名)\nvoid main() { System.out.println(\u0026#34;Hello, World!\u0026#34;); } JEP 480:结构化并发 (第三次预览) # Java 19 引入了结构化并发,一种多线程编程方法,目的是为了通过结构化并发 API 来简化多线程编程,并不是为了取代java.util.concurrent,目前处于孵化器阶段。\n结构化并发将不同线程中运行的多个任务视为单个工作单元,从而简化错误处理、提高可靠性并增强可观察性。也就是说,结构化并发保留了单线程代码的可读性、可维护性和可观察性。\n结构化并发的基本 API 是 StructuredTaskScope。StructuredTaskScope 支持将任务拆分为多个并发子任务,在它们自己的线程中执行,并且子任务必须在主任务继续之前完成。\nStructuredTaskScope 的基本用法如下:\ntry (var scope = new StructuredTaskScope\u0026lt;Object\u0026gt;()) { // 使用fork方法派生线程来执行子任务 Future\u0026lt;Integer\u0026gt; future1 = scope.fork(task1); Future\u0026lt;String\u0026gt; future2 = scope.fork(task2); // 等待线程完成 scope.join(); // 结果的处理可能包括处理或重新抛出异常 ... process results/exceptions ... } // close 结构化并发非常适合虚拟线程,虚拟线程是 JDK 实现的轻量级线程。许多虚拟线程共享同一个操作系统线程,从而允许非常多的虚拟线程。\nJEP 481:作用域值 (第三次预览) # 作用域值(Scoped Values)可以在线程内和线程间共享不可变的数据,优于线程局部变量,尤其是在使用大量虚拟线程时。\nfinal static ScopedValue\u0026lt;...\u0026gt; V = new ScopedValue\u0026lt;\u0026gt;(); // In some method ScopedValue.where(V, \u0026lt;value\u0026gt;) .run(() -\u0026gt; { ... V.get() ... call methods ... }); // In a method called directly or indirectly from the lambda expression ... V.get() ... 作用域值允许在大型程序中的组件之间安全有效地共享数据,而无需求助于方法参数。\nJEP 482:灵活的构造函数体(第二次预览) # 这个特性最初在 JDK 22 由 JEP 447: Statements before super(\u0026hellip;) (Preview)提出。\nJava 要求在构造函数中,super(...) 或 this(...) 调用必须作为第一条语句出现。这意味着我们无法在调用父类构造函数之前在子类构造函数中直接初始化字段。\n灵活的构造函数体解决了这一问题,它允许在构造函数体内,在调用 super(..) 或 this(..) 之前编写语句,这些语句可以初始化字段,但不能引用正在构造的实例。这样可以防止在父类构造函数中调用子类方法时,子类的字段未被正确初始化,增强了类构造的可靠性。\n这一特性解决了之前 Java 语法限制了构造函数代码组织的问题,让开发者能够更自由、更自然地表达构造函数的行为,例如在构造函数中直接进行参数验证、准备和共享,而无需依赖辅助方法或构造函数,提高了代码的可读性和可维护性。\nclass Person { private final String name; private int age; public Person(String name, int age) { if (age \u0026lt; 0) { throw new IllegalArgumentException(\u0026#34;Age cannot be negative.\u0026#34;); } this.name = name; // 在调用父类构造函数之前初始化字段 this.age = age; // ... 其他初始化代码 } } class Employee extends Person { private final int employeeId; public Employee(String name, int age, int employeeId) { this.employeeId = employeeId; // 在调用父类构造函数之前初始化字段 super(name, age); // 调用父类构造函数 // ... 其他初始化代码 } } JDK 22 # JEP 423:G1 垃圾收集器区域固定 # JEP 423 提出在 G1 垃圾收集器中实现区域固定(Region Pinning)功能,旨在减少由于 Java Native Interface (JNI) 关键区域导致的延迟问题。\nJNI 关键区域内的对象不能在垃圾收集时被移动,因此 G1 以往通过禁用垃圾收集解决该问题,导致线程阻塞及严重的延迟。通过在 G1 的老年代和年轻代中引入区域固定机制,允许在关键区域内固定对象所在的内存区域,同时继续回收未固定的区域,避免了禁用垃圾回收的需求。这种改进有助于显著降低延迟,提升系统在与 JNI 交互时的吞吐量和稳定性。\nJEP 454:外部函数和内存 API # Java 程序可以通过该 API 与 Java 运行时之外的代码和数据进行互操作。通过高效地调用外部函数(即 JVM 之外的代码)和安全地访问外部内存(即不受 JVM 管理的内存),该 API 使 Java 程序能够调用本机库并处理本机数据,而不会像 JNI 那样危险和脆弱。\n外部函数和内存 API 在 Java 17 中进行了第一轮孵化,由 JEP 412 提出。Java 18 中进行了第二次孵化,由 JEP 419 提出。Java 19 中是第一次预览,由 JEP 424 提出。JDK 20 中是第二次预览,由 JEP 434 提出。JDK 21 中是第三次预览,由 JEP 442 提出。\n最终,该特性在 JDK 22 中顺利转正。\n在 Java 19 新特性概览 中,我有详细介绍到外部函数和内存 API,这里就不再做额外的介绍了。\nJEP 456:未命名模式和变量 # 未命名模式和变量在 JDK 21 中由 JEP 443提出预览,JDK 22 中就已经转正。\n关于这个新特性的详细介绍,可以看看 Java 21 新特性概览(重要)这篇文章中的介绍。\nJEP 458:启动多文件源代码程序 # Java 11 引入了 JEP 330:启动单文件源代码程序,增强了 java 启动器的功能,使其能够直接运行单个 Java 源文件。通过命令 java HelloWorld.java,Java 可以在内存中隐式编译源代码并立即执行,而不需要在磁盘上生成 .class 文件。这简化了开发者在编写小型工具程序或学习 Java 时的工作流程,避免了手动编译的额外步骤。\n假设文件Prog.java声明了两个类:\nclass Prog { public static void main(String[] args) { Helper.run(); } } class Helper { static void run() { System.out.println(\u0026#34;Hello!\u0026#34;); } } java Prog.java命令会在内存中编译两个类并执行main该文件中声明的第一个类的方法。\n这种方式有一个限制,程序的所有源代码必须放在一个.java文件中。\nJEP 458:启动多文件源代码程序 是对 JEP 330 功能的扩展,允许直接运行由多个 Java 源文件组成的程序,而无需显式的编译步骤。\n假设一个目录中有两个 Java 源文件 Prog.java 和 Helper.java,每个文件各自声明了一个类:\n// Prog.java class Prog { public static void main(String[] args) { Helper.run(); } } // Helper.java class Helper { static void run() { System.out.println(\u0026#34;Hello!\u0026#34;); } } 当你运行命令 java Prog.java 时,Java 启动器会在内存中编译并执行 Prog 类的 main 方法。由于 Prog 类中的代码引用了 Helper 类,启动器会自动在文件系统中找到 Helper.java 文件,编译其中的 Helper 类,并在内存中执行它。这个过程是自动的,开发者无需显式调用 javac 来编译所有源文件。\n这一特性使得从小型项目到大型项目的过渡更加平滑,开发者可以自由选择何时引入构建工具,避免在快速迭代时被迫设置复杂的项目结构。该特性消除了单文件的限制,进一步简化了从单一文件到多文件程序的开发过程,特别适合原型开发、快速实验以及早期项目的探索阶段。\n"},{"id":496,"href":"/zh/docs/technology/Interview/java/new-features/java9/","title":"Java 9 新特性概览","section":"New Features","content":"Java 9 发布于 2017 年 9 月 21 日 。作为 Java 8 之后 3 年半才发布的新版本,Java 9 带来了很多重大的变化其中最重要的改动是 Java 平台模块系统的引入,其他还有诸如集合、Stream 流……。\n你可以在 Archived OpenJDK General-Availability Releases 上下载自己需要的 JDK 版本!官方的新特性说明文档地址: https://openjdk.java.net/projects/jdk/ 。\n概览(精选了一部分):\nJEP 222: Java 命令行工具 JEP 261: 模块化系统 JEP 248:G1 成为默认垃圾回收器 JEP 193: 变量句柄 JEP 254:字符串存储结构优化 JShell # JShell 是 Java 9 新增的一个实用工具。为 Java 提供了类似于 Python 的实时命令行交互工具。\n在 JShell 中可以直接输入表达式并查看其执行结果。\nJShell 为我们带来了哪些好处呢?\n降低了输出第一行 Java 版\u0026quot;Hello World!\u0026ldquo;的门槛,能够提高新手的学习热情。 在处理简单的小逻辑,验证简单的小问题时,比 IDE 更有效率(并不是为了取代 IDE,对于复杂逻辑的验证,IDE 更合适,两者互补)。 …… JShell 的代码和普通的可编译代码,有什么不一样?\n一旦语句输入完成,JShell 立即就能返回执行的结果,而不再需要编辑器、编译器、解释器。 JShell 支持变量的重复声明,后面声明的会覆盖前面声明的。 JShell 支持独立的表达式比如普通的加法运算 1 + 1。 …… 模块化系统 # 模块系统是 Jigsaw Project的一部分,把模块化开发实践引入到了 Java 平台中,可以让我们的代码可重用性更好!\n什么是模块系统? 官方的定义是:\nA uniquely named, reusable group of related packages, as well as resources (such as images and XML files) and a module descriptor。\n简单来说,你可以将一个模块看作是一组唯一命名、可重用的包、资源和模块描述文件(module-info.java)。\n任意一个 jar 文件,只要加上一个模块描述文件(module-info.java),就可以升级为一个模块。\n在引入了模块系统之后,JDK 被重新组织成 94 个模块。Java 应用可以通过新增的 jlink 工具 (Jlink 是随 Java 9 一起发布的新命令行工具。它允许开发人员为基于模块的 Java 应用程序创建自己的轻量级、定制的 JRE),创建出只包含所依赖的 JDK 模块的自定义运行时镜像。这样可以极大的减少 Java 运行时环境的大小。\n我们可以通过 exports 关键词精准控制哪些类可以对外开放使用,哪些类只能内部使用。\nmodule my.module { //exports 公开指定包的所有公共成员 exports com.my.package.name; } module my.module { //exports…to 限制访问的成员范围 export com.my.package.name to com.specific.package; } 想要深入了解 Java 9 的模块化,可以参考下面这几篇文章:\n《Project Jigsaw: Module System Quick-Start Guide》 《Java 9 Modules: part 1》 Java 9 揭秘(2. 模块化系统) G1 成为默认垃圾回收器 # 在 Java 8 的时候,默认垃圾回收器是 Parallel Scavenge(新生代)+Parallel Old(老年代)。到了 Java 9, CMS 垃圾回收器被废弃了,G1(Garbage-First Garbage Collector) 成为了默认垃圾回收器。\nG1 还是在 Java 7 中被引入的,经过两个版本优异的表现成为成为默认垃圾回收器。\n快速创建不可变集合 # 增加了List.of()、Set.of()、Map.of() 和 Map.ofEntries()等工厂方法来创建不可变集合(有点参考 Guava 的味道):\nList.of(\u0026#34;Java\u0026#34;, \u0026#34;C++\u0026#34;); Set.of(\u0026#34;Java\u0026#34;, \u0026#34;C++\u0026#34;); Map.of(\u0026#34;Java\u0026#34;, 1, \u0026#34;C++\u0026#34;, 2); 使用 of() 创建的集合为不可变集合,不能进行添加、删除、替换、 排序等操作,不然会报 java.lang.UnsupportedOperationException 异常。\nString 存储结构优化 # Java 8 及之前的版本,String 一直是用 char[] 存储。在 Java 9 之后,String 的实现改用 byte[] 数组存储字符串,节省了空间。\npublic final class String implements java.io.Serializable,Comparable\u0026lt;String\u0026gt;, CharSequence { // @Stable 注解表示变量最多被修改一次,称为“稳定的”。 @Stable private final byte[] value; } 接口私有方法 # Java 9 允许在接口中使用私有方法。这样的话,接口的使用就更加灵活了,有点像是一个简化版的抽象类。\npublic interface MyInterface { private void methodPrivate(){ } } try-with-resources 增强 # 在 Java 9 之前,我们只能在 try-with-resources 块中声明变量:\ntry (Scanner scanner = new Scanner(new File(\u0026#34;testRead.txt\u0026#34;)); PrintWriter writer = new PrintWriter(new File(\u0026#34;testWrite.txt\u0026#34;))) { // omitted } 在 Java 9 之后,在 try-with-resources 语句中可以使用 effectively-final 变量。\nfinal Scanner scanner = new Scanner(new File(\u0026#34;testRead.txt\u0026#34;)); PrintWriter writer = new PrintWriter(new File(\u0026#34;testWrite.txt\u0026#34;)) try (scanner;writer) { // omitted } 什么是 effectively-final 变量? 简单来说就是没有被 final 修饰但是值在初始化后从未更改的变量。\n正如上面的代码所演示的那样,即使 writer 变量没有被显示声明为 final,但它在第一次被赋值后就不会改变了,因此,它就是 effectively-final 变量。\nStream \u0026amp; Optional 增强 # Stream 中增加了新的方法 ofNullable()、dropWhile()、takeWhile() 以及 iterate() 方法的重载方法。\nJava 9 中的 ofNullable() 方 法允许我们创建一个单元素的 Stream,可以包含一个非空元素,也可以创建一个空 Stream。 而在 Java 8 中则不可以创建空的 Stream 。\nStream\u0026lt;String\u0026gt; stringStream = Stream.ofNullable(\u0026#34;Java\u0026#34;); System.out.println(stringStream.count());// 1 Stream\u0026lt;String\u0026gt; nullStream = Stream.ofNullable(null); System.out.println(nullStream.count());//0 takeWhile() 方法可以从 Stream 中依次获取满足条件的元素,直到不满足条件为止结束获取。\nList\u0026lt;Integer\u0026gt; integerList = List.of(11, 33, 66, 8, 9, 13); integerList.stream().takeWhile(x -\u0026gt; x \u0026lt; 50).forEach(System.out::println);// 11 33 dropWhile() 方法的效果和 takeWhile() 相反。\nList\u0026lt;Integer\u0026gt; integerList2 = List.of(11, 33, 66, 8, 9, 13); integerList2.stream().dropWhile(x -\u0026gt; x \u0026lt; 50).forEach(System.out::println);// 66 8 9 13 iterate() 方法的新重载方法提供了一个 Predicate 参数 (判断条件)来决定什么时候结束迭代\npublic static\u0026lt;T\u0026gt; Stream\u0026lt;T\u0026gt; iterate(final T seed, final UnaryOperator\u0026lt;T\u0026gt; f) { } // 新增加的重载方法 public static\u0026lt;T\u0026gt; Stream\u0026lt;T\u0026gt; iterate(T seed, Predicate\u0026lt;? super T\u0026gt; hasNext, UnaryOperator\u0026lt;T\u0026gt; next) { } 两者的使用对比如下,新的 iterate() 重载方法更加灵活一些。\n// 使用原始 iterate() 方法输出数字 1~10 Stream.iterate(1, i -\u0026gt; i + 1).limit(10).forEach(System.out::println); // 使用新的 iterate() 重载方法输出数字 1~10 Stream.iterate(1, i -\u0026gt; i \u0026lt;= 10, i -\u0026gt; i + 1).forEach(System.out::println); Optional 类中新增了 ifPresentOrElse()、or() 和 stream() 等方法\nifPresentOrElse() 方法接受两个参数 Consumer 和 Runnable ,如果 Optional 不为空调用 Consumer 参数,为空则调用 Runnable 参数。\npublic void ifPresentOrElse(Consumer\u0026lt;? super T\u0026gt; action, Runnable emptyAction) Optional\u0026lt;Object\u0026gt; objectOptional = Optional.empty(); objectOptional.ifPresentOrElse(System.out::println, () -\u0026gt; System.out.println(\u0026#34;Empty!!!\u0026#34;));// Empty!!! or() 方法接受一个 Supplier 参数 ,如果 Optional 为空则返回 Supplier 参数指定的 Optional 值。\npublic Optional\u0026lt;T\u0026gt; or(Supplier\u0026lt;? extends Optional\u0026lt;? extends T\u0026gt;\u0026gt; supplier) Optional\u0026lt;Object\u0026gt; objectOptional = Optional.empty(); objectOptional.or(() -\u0026gt; Optional.of(\u0026#34;java\u0026#34;)).ifPresent(System.out::println);//java 进程 API # Java 9 增加了 java.lang.ProcessHandle 接口来实现对原生进程进行管理,尤其适合于管理长时间运行的进程。\n// 获取当前正在运行的 JVM 的进程 ProcessHandle currentProcess = ProcessHandle.current(); // 输出进程的 id System.out.println(currentProcess.pid()); // 输出进程的信息 System.out.println(currentProcess.info()); ProcessHandle 接口概览:\n响应式流 ( Reactive Streams ) # 在 Java 9 中的 java.util.concurrent.Flow 类中新增了反应式流规范的核心接口 。\nFlow 中包含了 Flow.Publisher、Flow.Subscriber、Flow.Subscription 和 Flow.Processor 等 4 个核心接口。Java 9 还提供了SubmissionPublisher 作为Flow.Publisher 的一个实现。\n关于 Java 9 响应式流更详细的解读,推荐你看 Java 9 揭秘(17. Reactive Streams )- 林本托 这篇文章。\n变量句柄 # 变量句柄是一个变量或一组变量的引用,包括静态域,非静态域,数组元素和堆外数据结构中的组成部分等。\n变量句柄的含义类似于已有的方法句柄 MethodHandle ,由 Java 类 java.lang.invoke.VarHandle 来表示,可以使用类 java.lang.invoke.MethodHandles.Lookup 中的静态工厂方法来创建 VarHandle 对象。\nVarHandle 的出现替代了 java.util.concurrent.atomic 和 sun.misc.Unsafe 的部分操作。并且提供了一系列标准的内存屏障操作,用于更加细粒度的控制内存排序。在安全性、可用性、性能上都要优于现有的 API。\n其它 # 平台日志 API 改进:Java 9 允许为 JDK 和应用配置同样的日志实现。新增了 System.LoggerFinder 用来管理 JDK 使 用的日志记录器实现。JVM 在运行时只有一个系统范围的 LoggerFinder 实例。我们可以通过添加自己的 System.LoggerFinder 实现来让 JDK 和应用使用 SLF4J 等其他日志记录框架。 CompletableFuture类增强:新增了几个新的方法(completeAsync ,orTimeout 等)。 Nashorn 引擎的增强:Nashorn 是从 Java8 开始引入的 JavaScript 引擎,Java9 对 Nashorn 做了些增强,实现了一些 ES6 的新特性(Java 11 中已经被弃用)。 I/O 流的新特性:增加了新的方法来读取和复制 InputStream 中包含的数据。 改进应用的安全性能:Java 9 新增了 4 个 SHA- 3 哈希算法,SHA3-224、SHA3-256、SHA3-384 和 SHA3-512。 改进方法句柄(Method Handle):方法句柄从 Java7 开始引入,Java9 在类java.lang.invoke.MethodHandles 中新增了更多的静态方法来创建不同类型的方法句柄。 …… 参考 # Java version history: https://en.wikipedia.org/wiki/Java_version_history Release Notes for JDK 9 and JDK 9 Update Releases : https://www.oracle.com/java/technologies/javase/9-all-relnotes.html 《深入剖析 Java 新特性》-极客时间 - JShell:怎么快速验证简单的小问题? New Features in Java 9: https://www.baeldung.com/new-java-9 Java – Try with Resources: https://www.baeldung.com/java-try-with-resources "},{"id":497,"href":"/zh/docs/technology/Interview/java/io/io-basis/","title":"Java IO 基础知识总结","section":"Io","content":" IO 流简介 # IO 即 Input/Output,输入和输出。数据输入到计算机内存的过程即输入,反之输出到外部存储(比如数据库,文件,远程主机)的过程即输出。数据传输过程类似于水流,因此称为 IO 流。IO 流在 Java 中分为输入流和输出流,而根据数据的处理方式又分为字节流和字符流。\nJava IO 流的 40 多个类都是从如下 4 个抽象类基类中派生出来的。\nInputStream/Reader: 所有的输入流的基类,前者是字节输入流,后者是字符输入流。 OutputStream/Writer: 所有输出流的基类,前者是字节输出流,后者是字符输出流。 字节流 # InputStream(字节输入流) # InputStream用于从源头(通常是文件)读取数据(字节信息)到内存中,java.io.InputStream抽象类是所有字节输入流的父类。\nInputStream 常用方法:\nread():返回输入流中下一个字节的数据。返回的值介于 0 到 255 之间。如果未读取任何字节,则代码返回 -1 ,表示文件结束。 read(byte b[ ]) : 从输入流中读取一些字节存储到数组 b 中。如果数组 b 的长度为零,则不读取。如果没有可用字节读取,返回 -1。如果有可用字节读取,则最多读取的字节数最多等于 b.length , 返回读取的字节数。这个方法等价于 read(b, 0, b.length)。 read(byte b[], int off, int len):在read(byte b[ ]) 方法的基础上增加了 off 参数(偏移量)和 len 参数(要读取的最大字节数)。 skip(long n):忽略输入流中的 n 个字节 ,返回实际忽略的字节数。 available():返回输入流中可以读取的字节数。 close():关闭输入流释放相关的系统资源。 从 Java 9 开始,InputStream 新增加了多个实用的方法:\nreadAllBytes():读取输入流中的所有字节,返回字节数组。 readNBytes(byte[] b, int off, int len):阻塞直到读取 len 个字节。 transferTo(OutputStream out):将所有字节从一个输入流传递到一个输出流。 FileInputStream 是一个比较常用的字节输入流对象,可直接指定文件路径,可以直接读取单字节数据,也可以读取至字节数组中。\nFileInputStream 代码示例:\ntry (InputStream fis = new FileInputStream(\u0026#34;input.txt\u0026#34;)) { System.out.println(\u0026#34;Number of remaining bytes:\u0026#34; + fis.available()); int content; long skip = fis.skip(2); System.out.println(\u0026#34;The actual number of bytes skipped:\u0026#34; + skip); System.out.print(\u0026#34;The content read from file:\u0026#34;); while ((content = fis.read()) != -1) { System.out.print((char) content); } } catch (IOException e) { e.printStackTrace(); } input.txt 文件内容:\n输出:\nNumber of remaining bytes:11 The actual number of bytes skipped:2 The content read from file:JavaGuide 不过,一般我们是不会直接单独使用 FileInputStream ,通常会配合 BufferedInputStream(字节缓冲输入流,后文会讲到)来使用。\n像下面这段代码在我们的项目中就比较常见,我们通过 readAllBytes() 读取输入流所有字节并将其直接赋值给一个 String 对象。\n// 新建一个 BufferedInputStream 对象 BufferedInputStream bufferedInputStream = new BufferedInputStream(new FileInputStream(\u0026#34;input.txt\u0026#34;)); // 读取文件的内容并复制到 String 对象中 String result = new String(bufferedInputStream.readAllBytes()); System.out.println(result); DataInputStream 用于读取指定类型数据,不能单独使用,必须结合其它流,比如 FileInputStream 。\nFileInputStream fileInputStream = new FileInputStream(\u0026#34;input.txt\u0026#34;); //必须将fileInputStream作为构造参数才能使用 DataInputStream dataInputStream = new DataInputStream(fileInputStream); //可以读取任意具体的类型数据 dataInputStream.readBoolean(); dataInputStream.readInt(); dataInputStream.readUTF(); ObjectInputStream 用于从输入流中读取 Java 对象(反序列化),ObjectOutputStream 用于将对象写入到输出流(序列化)。\nObjectInputStream input = new ObjectInputStream(new FileInputStream(\u0026#34;object.data\u0026#34;)); MyClass object = (MyClass) input.readObject(); input.close(); 另外,用于序列化和反序列化的类必须实现 Serializable 接口,对象中如果有属性不想被序列化,使用 transient 修饰。\nOutputStream(字节输出流) # OutputStream用于将数据(字节信息)写入到目的地(通常是文件),java.io.OutputStream抽象类是所有字节输出流的父类。\nOutputStream 常用方法:\nwrite(int b):将特定字节写入输出流。 write(byte b[ ]) : 将数组b 写入到输出流,等价于 write(b, 0, b.length) 。 write(byte[] b, int off, int len) : 在write(byte b[ ]) 方法的基础上增加了 off 参数(偏移量)和 len 参数(要读取的最大字节数)。 flush():刷新此输出流并强制写出所有缓冲的输出字节。 close():关闭输出流释放相关的系统资源。 FileOutputStream 是最常用的字节输出流对象,可直接指定文件路径,可以直接输出单字节数据,也可以输出指定的字节数组。\nFileOutputStream 代码示例:\ntry (FileOutputStream output = new FileOutputStream(\u0026#34;output.txt\u0026#34;)) { byte[] array = \u0026#34;JavaGuide\u0026#34;.getBytes(); output.write(array); } catch (IOException e) { e.printStackTrace(); } 运行结果:\n类似于 FileInputStream,FileOutputStream 通常也会配合 BufferedOutputStream(字节缓冲输出流,后文会讲到)来使用。\nFileOutputStream fileOutputStream = new FileOutputStream(\u0026#34;output.txt\u0026#34;); BufferedOutputStream bos = new BufferedOutputStream(fileOutputStream) DataOutputStream 用于写入指定类型数据,不能单独使用,必须结合其它流,比如 FileOutputStream 。\n// 输出流 FileOutputStream fileOutputStream = new FileOutputStream(\u0026#34;out.txt\u0026#34;); DataOutputStream dataOutputStream = new DataOutputStream(fileOutputStream); // 输出任意数据类型 dataOutputStream.writeBoolean(true); dataOutputStream.writeByte(1); ObjectInputStream 用于从输入流中读取 Java 对象(ObjectInputStream,反序列化),ObjectOutputStream将对象写入到输出流(ObjectOutputStream,序列化)。\nObjectOutputStream output = new ObjectOutputStream(new FileOutputStream(\u0026#34;file.txt\u0026#34;) Person person = new Person(\u0026#34;Guide哥\u0026#34;, \u0026#34;JavaGuide作者\u0026#34;); output.writeObject(person); 字符流 # 不管是文件读写还是网络发送接收,信息的最小存储单元都是字节。 那为什么 I/O 流操作要分为字节流操作和字符流操作呢?\n个人认为主要有两点原因:\n字符流是由 Java 虚拟机将字节转换得到的,这个过程还算是比较耗时。 如果我们不知道编码类型就很容易出现乱码问题。 乱码问题这个很容易就可以复现,我们只需要将上面提到的 FileInputStream 代码示例中的 input.txt 文件内容改为中文即可,原代码不需要改动。\n输出:\nNumber of remaining bytes:9 The actual number of bytes skipped:2 The content read from file:§å®¶å¥½ 可以很明显地看到读取出来的内容已经变成了乱码。\n因此,I/O 流就干脆提供了一个直接操作字符的接口,方便我们平时对字符进行流操作。如果音频文件、图片等媒体文件用字节流比较好,如果涉及到字符的话使用字符流比较好。\n字符流默认采用的是 Unicode 编码,我们可以通过构造方法自定义编码。\nUnicode 本身只是一种字符集,它为每个字符分配一个唯一的数字编号,并没有规定具体的存储方式。UTF-8、UTF-16、UTF-32 都是 Unicode 的编码方式,它们使用不同的字节数来表示 Unicode 字符。例如,UTF-8 :英文占 1 字节,中文占 3 字节。\nReader(字符输入流) # Reader用于从源头(通常是文件)读取数据(字符信息)到内存中,java.io.Reader抽象类是所有字符输入流的父类。\nReader 用于读取文本, InputStream 用于读取原始字节。\nReader 常用方法:\nread() : 从输入流读取一个字符。 read(char[] cbuf) : 从输入流中读取一些字符,并将它们存储到字符数组 cbuf中,等价于 read(cbuf, 0, cbuf.length) 。 read(char[] cbuf, int off, int len):在read(char[] cbuf) 方法的基础上增加了 off 参数(偏移量)和 len 参数(要读取的最大字符数)。 skip(long n):忽略输入流中的 n 个字符 ,返回实际忽略的字符数。 close() : 关闭输入流并释放相关的系统资源。 InputStreamReader 是字节流转换为字符流的桥梁,其子类 FileReader 是基于该基础上的封装,可以直接操作字符文件。\n// 字节流转换为字符流的桥梁 public class InputStreamReader extends Reader { } // 用于读取字符文件 public class FileReader extends InputStreamReader { } FileReader 代码示例:\ntry (FileReader fileReader = new FileReader(\u0026#34;input.txt\u0026#34;);) { int content; long skip = fileReader.skip(3); System.out.println(\u0026#34;The actual number of bytes skipped:\u0026#34; + skip); System.out.print(\u0026#34;The content read from file:\u0026#34;); while ((content = fileReader.read()) != -1) { System.out.print((char) content); } } catch (IOException e) { e.printStackTrace(); } input.txt 文件内容:\n输出:\nThe actual number of bytes skipped:3 The content read from file:我是Guide。 Writer(字符输出流) # Writer用于将数据(字符信息)写入到目的地(通常是文件),java.io.Writer抽象类是所有字符输出流的父类。\nWriter 常用方法:\nwrite(int c) : 写入单个字符。 write(char[] cbuf):写入字符数组 cbuf,等价于write(cbuf, 0, cbuf.length)。 write(char[] cbuf, int off, int len):在write(char[] cbuf) 方法的基础上增加了 off 参数(偏移量)和 len 参数(要读取的最大字符数)。 write(String str):写入字符串,等价于 write(str, 0, str.length()) 。 write(String str, int off, int len):在write(String str) 方法的基础上增加了 off 参数(偏移量)和 len 参数(要读取的最大字符数)。 append(CharSequence csq):将指定的字符序列附加到指定的 Writer 对象并返回该 Writer 对象。 append(char c):将指定的字符附加到指定的 Writer 对象并返回该 Writer 对象。 flush():刷新此输出流并强制写出所有缓冲的输出字符。 close():关闭输出流释放相关的系统资源。 OutputStreamWriter 是字符流转换为字节流的桥梁,其子类 FileWriter 是基于该基础上的封装,可以直接将字符写入到文件。\n// 字符流转换为字节流的桥梁 public class OutputStreamWriter extends Writer { } // 用于写入字符到文件 public class FileWriter extends OutputStreamWriter { } FileWriter 代码示例:\ntry (Writer output = new FileWriter(\u0026#34;output.txt\u0026#34;)) { output.write(\u0026#34;你好,我是Guide。\u0026#34;); } catch (IOException e) { e.printStackTrace(); } 输出结果:\n字节缓冲流 # IO 操作是很消耗性能的,缓冲流将数据加载至缓冲区,一次性读取/写入多个字节,从而避免频繁的 IO 操作,提高流的传输效率。\n字节缓冲流这里采用了装饰器模式来增强 InputStream 和OutputStream子类对象的功能。\n举个例子,我们可以通过 BufferedInputStream(字节缓冲输入流)来增强 FileInputStream 的功能。\n// 新建一个 BufferedInputStream 对象 BufferedInputStream bufferedInputStream = new BufferedInputStream(new FileInputStream(\u0026#34;input.txt\u0026#34;)); 字节流和字节缓冲流的性能差别主要体现在我们使用两者的时候都是调用 write(int b) 和 read() 这两个一次只读取一个字节的方法的时候。由于字节缓冲流内部有缓冲区(字节数组),因此,字节缓冲流会先将读取到的字节存放在缓存区,大幅减少 IO 次数,提高读取效率。\n我使用 write(int b) 和 read() 方法,分别通过字节流和字节缓冲流复制一个 524.9 mb 的 PDF 文件耗时对比如下:\n使用缓冲流复制PDF文件总耗时:15428 毫秒 使用普通字节流复制PDF文件总耗时:2555062 毫秒 两者耗时差别非常大,缓冲流耗费的时间是字节流的 1/165。\n测试代码如下:\n@Test void copy_pdf_to_another_pdf_buffer_stream() { // 记录开始时间 long start = System.currentTimeMillis(); try (BufferedInputStream bis = new BufferedInputStream(new FileInputStream(\u0026#34;深入理解计算机操作系统.pdf\u0026#34;)); BufferedOutputStream bos = new BufferedOutputStream(new FileOutputStream(\u0026#34;深入理解计算机操作系统-副本.pdf\u0026#34;))) { int content; while ((content = bis.read()) != -1) { bos.write(content); } } catch (IOException e) { e.printStackTrace(); } // 记录结束时间 long end = System.currentTimeMillis(); System.out.println(\u0026#34;使用缓冲流复制PDF文件总耗时:\u0026#34; + (end - start) + \u0026#34; 毫秒\u0026#34;); } @Test void copy_pdf_to_another_pdf_stream() { // 记录开始时间 long start = System.currentTimeMillis(); try (FileInputStream fis = new FileInputStream(\u0026#34;深入理解计算机操作系统.pdf\u0026#34;); FileOutputStream fos = new FileOutputStream(\u0026#34;深入理解计算机操作系统-副本.pdf\u0026#34;)) { int content; while ((content = fis.read()) != -1) { fos.write(content); } } catch (IOException e) { e.printStackTrace(); } // 记录结束时间 long end = System.currentTimeMillis(); System.out.println(\u0026#34;使用普通流复制PDF文件总耗时:\u0026#34; + (end - start) + \u0026#34; 毫秒\u0026#34;); } 如果是调用 read(byte b[]) 和 write(byte b[], int off, int len) 这两个写入一个字节数组的方法的话,只要字节数组的大小合适,两者的性能差距其实不大,基本可以忽略。\n这次我们使用 read(byte b[]) 和 write(byte b[], int off, int len) 方法,分别通过字节流和字节缓冲流复制一个 524.9 mb 的 PDF 文件耗时对比如下:\n使用缓冲流复制PDF文件总耗时:695 毫秒 使用普通字节流复制PDF文件总耗时:989 毫秒 两者耗时差别不是很大,缓冲流的性能要略微好一点点。\n测试代码如下:\n@Test void copy_pdf_to_another_pdf_with_byte_array_buffer_stream() { // 记录开始时间 long start = System.currentTimeMillis(); try (BufferedInputStream bis = new BufferedInputStream(new FileInputStream(\u0026#34;深入理解计算机操作系统.pdf\u0026#34;)); BufferedOutputStream bos = new BufferedOutputStream(new FileOutputStream(\u0026#34;深入理解计算机操作系统-副本.pdf\u0026#34;))) { int len; byte[] bytes = new byte[4 * 1024]; while ((len = bis.read(bytes)) != -1) { bos.write(bytes, 0, len); } } catch (IOException e) { e.printStackTrace(); } // 记录结束时间 long end = System.currentTimeMillis(); System.out.println(\u0026#34;使用缓冲流复制PDF文件总耗时:\u0026#34; + (end - start) + \u0026#34; 毫秒\u0026#34;); } @Test void copy_pdf_to_another_pdf_with_byte_array_stream() { // 记录开始时间 long start = System.currentTimeMillis(); try (FileInputStream fis = new FileInputStream(\u0026#34;深入理解计算机操作系统.pdf\u0026#34;); FileOutputStream fos = new FileOutputStream(\u0026#34;深入理解计算机操作系统-副本.pdf\u0026#34;)) { int len; byte[] bytes = new byte[4 * 1024]; while ((len = fis.read(bytes)) != -1) { fos.write(bytes, 0, len); } } catch (IOException e) { e.printStackTrace(); } // 记录结束时间 long end = System.currentTimeMillis(); System.out.println(\u0026#34;使用普通流复制PDF文件总耗时:\u0026#34; + (end - start) + \u0026#34; 毫秒\u0026#34;); } BufferedInputStream(字节缓冲输入流) # BufferedInputStream 从源头(通常是文件)读取数据(字节信息)到内存的过程中不会一个字节一个字节的读取,而是会先将读取到的字节存放在缓存区,并从内部缓冲区中单独读取字节。这样大幅减少了 IO 次数,提高了读取效率。\nBufferedInputStream 内部维护了一个缓冲区,这个缓冲区实际就是一个字节数组,通过阅读 BufferedInputStream 源码即可得到这个结论。\npublic class BufferedInputStream extends FilterInputStream { // 内部缓冲区数组 protected volatile byte buf[]; // 缓冲区的默认大小 private static int DEFAULT_BUFFER_SIZE = 8192; // 使用默认的缓冲区大小 public BufferedInputStream(InputStream in) { this(in, DEFAULT_BUFFER_SIZE); } // 自定义缓冲区大小 public BufferedInputStream(InputStream in, int size) { super(in); if (size \u0026lt;= 0) { throw new IllegalArgumentException(\u0026#34;Buffer size \u0026lt;= 0\u0026#34;); } buf = new byte[size]; } } 缓冲区的大小默认为 8192 字节,当然了,你也可以通过 BufferedInputStream(InputStream in, int size) 这个构造方法来指定缓冲区的大小。\nBufferedOutputStream(字节缓冲输出流) # BufferedOutputStream 将数据(字节信息)写入到目的地(通常是文件)的过程中不会一个字节一个字节的写入,而是会先将要写入的字节存放在缓存区,并从内部缓冲区中单独写入字节。这样大幅减少了 IO 次数,提高了读取效率\ntry (BufferedOutputStream bos = new BufferedOutputStream(new FileOutputStream(\u0026#34;output.txt\u0026#34;))) { byte[] array = \u0026#34;JavaGuide\u0026#34;.getBytes(); bos.write(array); } catch (IOException e) { e.printStackTrace(); } 类似于 BufferedInputStream ,BufferedOutputStream 内部也维护了一个缓冲区,并且,这个缓存区的大小也是 8192 字节。\n字符缓冲流 # BufferedReader (字符缓冲输入流)和 BufferedWriter(字符缓冲输出流)类似于 BufferedInputStream(字节缓冲输入流)和BufferedOutputStream(字节缓冲输入流),内部都维护了一个字节数组作为缓冲区。不过,前者主要是用来操作字符信息。\n打印流 # 下面这段代码大家经常使用吧?\nSystem.out.print(\u0026#34;Hello!\u0026#34;); System.out.println(\u0026#34;Hello!\u0026#34;); System.out 实际是用于获取一个 PrintStream 对象,print方法实际调用的是 PrintStream 对象的 write 方法。\nPrintStream 属于字节打印流,与之对应的是 PrintWriter (字符打印流)。PrintStream 是 OutputStream 的子类,PrintWriter 是 Writer 的子类。\npublic class PrintStream extends FilterOutputStream implements Appendable, Closeable { } public class PrintWriter extends Writer { } 随机访问流 # 这里要介绍的随机访问流指的是支持随意跳转到文件的任意位置进行读写的 RandomAccessFile 。\nRandomAccessFile 的构造方法如下,我们可以指定 mode(读写模式)。\n// openAndDelete 参数默认为 false 表示打开文件并且这个文件不会被删除 public RandomAccessFile(File file, String mode) throws FileNotFoundException { this(file, mode, false); } // 私有方法 private RandomAccessFile(File file, String mode, boolean openAndDelete) throws FileNotFoundException{ // 省略大部分代码 } 读写模式主要有下面四种:\nr : 只读模式。 rw: 读写模式 rws: 相对于 rw,rws 同步更新对“文件的内容”或“元数据”的修改到外部存储设备。 rwd : 相对于 rw,rwd 同步更新对“文件的内容”的修改到外部存储设备。 文件内容指的是文件中实际保存的数据,元数据则是用来描述文件属性比如文件的大小信息、创建和修改时间。\nRandomAccessFile 中有一个文件指针用来表示下一个将要被写入或者读取的字节所处的位置。我们可以通过 RandomAccessFile 的 seek(long pos) 方法来设置文件指针的偏移量(距文件开头 pos 个字节处)。如果想要获取文件指针当前的位置的话,可以使用 getFilePointer() 方法。\nRandomAccessFile 代码示例:\nRandomAccessFile randomAccessFile = new RandomAccessFile(new File(\u0026#34;input.txt\u0026#34;), \u0026#34;rw\u0026#34;); System.out.println(\u0026#34;读取之前的偏移量:\u0026#34; + randomAccessFile.getFilePointer() + \u0026#34;,当前读取到的字符\u0026#34; + (char) randomAccessFile.read() + \u0026#34;,读取之后的偏移量:\u0026#34; + randomAccessFile.getFilePointer()); // 指针当前偏移量为 6 randomAccessFile.seek(6); System.out.println(\u0026#34;读取之前的偏移量:\u0026#34; + randomAccessFile.getFilePointer() + \u0026#34;,当前读取到的字符\u0026#34; + (char) randomAccessFile.read() + \u0026#34;,读取之后的偏移量:\u0026#34; + randomAccessFile.getFilePointer()); // 从偏移量 7 的位置开始往后写入字节数据 randomAccessFile.write(new byte[]{\u0026#39;H\u0026#39;, \u0026#39;I\u0026#39;, \u0026#39;J\u0026#39;, \u0026#39;K\u0026#39;}); // 指针当前偏移量为 0,回到起始位置 randomAccessFile.seek(0); System.out.println(\u0026#34;读取之前的偏移量:\u0026#34; + randomAccessFile.getFilePointer() + \u0026#34;,当前读取到的字符\u0026#34; + (char) randomAccessFile.read() + \u0026#34;,读取之后的偏移量:\u0026#34; + randomAccessFile.getFilePointer()); input.txt 文件内容:\n输出:\n读取之前的偏移量:0,当前读取到的字符A,读取之后的偏移量:1 读取之前的偏移量:6,当前读取到的字符G,读取之后的偏移量:7 读取之前的偏移量:0,当前读取到的字符A,读取之后的偏移量:1 input.txt 文件内容变为 ABCDEFGHIJK 。\nRandomAccessFile 的 write 方法在写入对象的时候如果对应的位置已经有数据的话,会将其覆盖掉。\nRandomAccessFile randomAccessFile = new RandomAccessFile(new File(\u0026#34;input.txt\u0026#34;), \u0026#34;rw\u0026#34;); randomAccessFile.write(new byte[]{\u0026#39;H\u0026#39;, \u0026#39;I\u0026#39;, \u0026#39;J\u0026#39;, \u0026#39;K\u0026#39;}); 假设运行上面这段程序之前 input.txt 文件内容变为 ABCD ,运行之后则变为 HIJK 。\nRandomAccessFile 比较常见的一个应用就是实现大文件的 断点续传 。何谓断点续传?简单来说就是上传文件中途暂停或失败(比如遇到网络问题)之后,不需要重新上传,只需要上传那些未成功上传的文件分片即可。分片(先将文件切分成多个文件分片)上传是断点续传的基础。\nRandomAccessFile 可以帮助我们合并文件分片,示例代码如下:\n我在 《Java 面试指北》中详细介绍了大文件的上传问题。\nRandomAccessFile 的实现依赖于 FileDescriptor (文件描述符) 和 FileChannel (内存映射文件)。\n"},{"id":498,"href":"/zh/docs/technology/Interview/java/io/io-model/","title":"Java IO 模型详解","section":"Io","content":"IO 模型这块确实挺难理解的,需要太多计算机底层知识。写这篇文章用了挺久,就非常希望能把我所知道的讲出来吧!希望朋友们能有收获!为了写这篇文章,还翻看了一下《UNIX 网络编程》这本书,太难了,我滴乖乖!心痛~\n个人能力有限。如果文章有任何需要补充/完善/修改的地方,欢迎在评论区指出,共同进步!\n前言 # I/O 一直是很多小伙伴难以理解的一个知识点,这篇文章我会将我所理解的 I/O 讲给你听,希望可以对你有所帮助。\nI/O # 何为 I/O? # I/O(Input/Output) 即输入/输出 。\n我们先从计算机结构的角度来解读一下 I/O。\n根据冯.诺依曼结构,计算机结构分为 5 大部分:运算器、控制器、存储器、输入设备、输出设备。\n输入设备(比如键盘)和输出设备(比如显示器)都属于外部设备。网卡、硬盘这种既可以属于输入设备,也可以属于输出设备。\n输入设备向计算机输入数据,输出设备接收计算机输出的数据。\n从计算机结构的视角来看的话, I/O 描述了计算机系统与外部设备之间通信的过程。\n我们再先从应用程序的角度来解读一下 I/O。\n根据大学里学到的操作系统相关的知识:为了保证操作系统的稳定性和安全性,一个进程的地址空间划分为 用户空间(User space) 和 内核空间(Kernel space ) 。\n像我们平常运行的应用程序都是运行在用户空间,只有内核空间才能进行系统态级别的资源有关的操作,比如文件管理、进程通信、内存管理等等。也就是说,我们想要进行 IO 操作,一定是要依赖内核空间的能力。\n并且,用户空间的程序不能直接访问内核空间。\n当想要执行 IO 操作时,由于没有执行这些操作的权限,只能发起系统调用请求操作系统帮忙完成。\n因此,用户进程想要执行 IO 操作的话,必须通过 系统调用 来间接访问内核空间\n我们在平常开发过程中接触最多的就是 磁盘 IO(读写文件) 和 网络 IO(网络请求和响应)。\n从应用程序的视角来看的话,我们的应用程序对操作系统的内核发起 IO 调用(系统调用),操作系统负责的内核执行具体的 IO 操作。也就是说,我们的应用程序实际上只是发起了 IO 操作的调用而已,具体 IO 的执行是由操作系统的内核来完成的。\n当应用程序发起 I/O 调用后,会经历两个步骤:\n内核等待 I/O 设备准备好数据 内核将数据从内核空间拷贝到用户空间。 有哪些常见的 IO 模型? # UNIX 系统下, IO 模型一共有 5 种:同步阻塞 I/O、同步非阻塞 I/O、I/O 多路复用、信号驱动 I/O 和异步 I/O。\n这也是我们经常提到的 5 种 IO 模型。\nJava 中 3 种常见 IO 模型 # BIO (Blocking I/O) # BIO 属于同步阻塞 IO 模型 。\n同步阻塞 IO 模型中,应用程序发起 read 调用后,会一直阻塞,直到内核把数据拷贝到用户空间。\n在客户端连接数量不高的情况下,是没问题的。但是,当面对十万甚至百万级连接的时候,传统的 BIO 模型是无能为力的。因此,我们需要一种更高效的 I/O 处理模型来应对更高的并发量。\nNIO (Non-blocking/New I/O) # Java 中的 NIO 于 Java 1.4 中引入,对应 java.nio 包,提供了 Channel , Selector,Buffer 等抽象。NIO 中的 N 可以理解为 Non-blocking,不单纯是 New。它是支持面向缓冲的,基于通道的 I/O 操作方法。 对于高负载、高并发的(网络)应用,应使用 NIO 。\nJava 中的 NIO 可以看作是 I/O 多路复用模型。也有很多人认为,Java 中的 NIO 属于同步非阻塞 IO 模型。\n跟着我的思路往下看看,相信你会得到答案!\n我们先来看看 同步非阻塞 IO 模型。\n同步非阻塞 IO 模型中,应用程序会一直发起 read 调用,等待数据从内核空间拷贝到用户空间的这段时间里,线程依然是阻塞的,直到在内核把数据拷贝到用户空间。\n相比于同步阻塞 IO 模型,同步非阻塞 IO 模型确实有了很大改进。通过轮询操作,避免了一直阻塞。\n但是,这种 IO 模型同样存在问题:应用程序不断进行 I/O 系统调用轮询数据是否已经准备好的过程是十分消耗 CPU 资源的。\n这个时候,I/O 多路复用模型 就上场了。\nIO 多路复用模型中,线程首先发起 select 调用,询问内核数据是否准备就绪,等内核把数据准备好了,用户线程再发起 read 调用。read 调用的过程(数据从内核空间 -\u0026gt; 用户空间)还是阻塞的。\n目前支持 IO 多路复用的系统调用,有 select,epoll 等等。select 系统调用,目前几乎在所有的操作系统上都有支持。\nselect 调用:内核提供的系统调用,它支持一次查询多个系统调用的可用状态。几乎所有的操作系统都支持。 epoll 调用:linux 2.6 内核,属于 select 调用的增强版本,优化了 IO 的执行效率。 IO 多路复用模型,通过减少无效的系统调用,减少了对 CPU 资源的消耗。\nJava 中的 NIO ,有一个非常重要的选择器 ( Selector ) 的概念,也可以被称为 多路复用器。通过它,只需要一个线程便可以管理多个客户端连接。当客户端数据到了之后,才会为其服务。\nAIO (Asynchronous I/O) # AIO 也就是 NIO 2。Java 7 中引入了 NIO 的改进版 NIO 2,它是异步 IO 模型。\n异步 IO 是基于事件和回调机制实现的,也就是应用操作之后会直接返回,不会堵塞在那里,当后台处理完成,操作系统会通知相应的线程进行后续的操作。\n目前来说 AIO 的应用还不是很广泛。Netty 之前也尝试使用过 AIO,不过又放弃了。这是因为,Netty 使用了 AIO 之后,在 Linux 系统上的性能并没有多少提升。\n最后,来一张图,简单总结一下 Java 中的 BIO、NIO、AIO。\n参考 # 《深入拆解 Tomcat \u0026amp; Jetty》 如何完成一次 IO: https://llc687.top/126.html 程序员应该这样理解 IO: https://www.jianshu.com/p/fa7bdc4f3de7 10 分钟看懂, Java NIO 底层原理: https://www.cnblogs.com/crazymakercircle/p/10225159.html IO 模型知多少 | 理论篇: https://www.cnblogs.com/sheng-jie/p/how-much-you-know-about-io-models.html 《UNIX 网络编程 卷 1;套接字联网 API 》6.2 节 IO 模型 "},{"id":499,"href":"/zh/docs/technology/Interview/java/io/io-design-patterns/","title":"Java IO 设计模式总结","section":"Io","content":"这篇文章我们简单来看看我们从 IO 中能够学习到哪些设计模式的应用。\n装饰器模式 # 装饰器(Decorator)模式 可以在不改变原有对象的情况下拓展其功能。\n装饰器模式通过组合替代继承来扩展原始类的功能,在一些继承关系比较复杂的场景(IO 这一场景各种类的继承关系就比较复杂)更加实用。\n对于字节流来说, FilterInputStream (对应输入流)和FilterOutputStream(对应输出流)是装饰器模式的核心,分别用于增强 InputStream 和OutputStream子类对象的功能。\n我们常见的BufferedInputStream(字节缓冲输入流)、DataInputStream 等等都是FilterInputStream 的子类,BufferedOutputStream(字节缓冲输出流)、DataOutputStream等等都是FilterOutputStream的子类。\n举个例子,我们可以通过 BufferedInputStream(字节缓冲输入流)来增强 FileInputStream 的功能。\nBufferedInputStream 构造函数如下:\npublic BufferedInputStream(InputStream in) { this(in, DEFAULT_BUFFER_SIZE); } public BufferedInputStream(InputStream in, int size) { super(in); if (size \u0026lt;= 0) { throw new IllegalArgumentException(\u0026#34;Buffer size \u0026lt;= 0\u0026#34;); } buf = new byte[size]; } 可以看出,BufferedInputStream 的构造函数其中的一个参数就是 InputStream 。\nBufferedInputStream 代码示例:\ntry (BufferedInputStream bis = new BufferedInputStream(new FileInputStream(\u0026#34;input.txt\u0026#34;))) { int content; long skip = bis.skip(2); while ((content = bis.read()) != -1) { System.out.print((char) content); } } catch (IOException e) { e.printStackTrace(); } 这个时候,你可以会想了:为啥我们直接不弄一个BufferedFileInputStream(字符缓冲文件输入流)呢?\nBufferedFileInputStream bfis = new BufferedFileInputStream(\u0026#34;input.txt\u0026#34;); 如果 InputStream的子类比较少的话,这样做是没问题的。不过, InputStream的子类实在太多,继承关系也太复杂了。如果我们为每一个子类都定制一个对应的缓冲输入流,那岂不是太麻烦了。\n如果你对 IO 流比较熟悉的话,你会发现ZipInputStream 和ZipOutputStream 还可以分别增强 BufferedInputStream 和 BufferedOutputStream 的能力。\nBufferedInputStream bis = new BufferedInputStream(new FileInputStream(fileName)); ZipInputStream zis = new ZipInputStream(bis); BufferedOutputStream bos = new BufferedOutputStream(new FileOutputStream(fileName)); ZipOutputStream zipOut = new ZipOutputStream(bos); ZipInputStream 和ZipOutputStream 分别继承自InflaterInputStream 和DeflaterOutputStream。\npublic class InflaterInputStream extends FilterInputStream { } public class DeflaterOutputStream extends FilterOutputStream { } 这也是装饰器模式很重要的一个特征,那就是可以对原始类嵌套使用多个装饰器。\n为了实现这一效果,装饰器类需要跟原始类继承相同的抽象类或者实现相同的接口。上面介绍到的这些 IO 相关的装饰类和原始类共同的父类是 InputStream 和OutputStream。\n对于字符流来说,BufferedReader 可以用来增加 Reader (字符输入流)子类的功能,BufferedWriter 可以用来增加 Writer (字符输出流)子类的功能。\nBufferedWriter bw = new BufferedWriter(new OutputStreamWriter(new FileOutputStream(fileName), \u0026#34;UTF-8\u0026#34;)); IO 流中的装饰器模式应用的例子实在是太多了,不需要特意记忆,完全没必要哈!搞清了装饰器模式的核心之后,你在使用的时候自然就会知道哪些地方运用到了装饰器模式。\n适配器模式 # 适配器(Adapter Pattern)模式 主要用于接口互不兼容的类的协调工作,你可以将其联想到我们日常经常使用的电源适配器。\n适配器模式中存在被适配的对象或者类称为 适配者(Adaptee) ,作用于适配者的对象或者类称为适配器(Adapter) 。适配器分为对象适配器和类适配器。类适配器使用继承关系来实现,对象适配器使用组合关系来实现。\nIO 流中的字符流和字节流的接口不同,它们之间可以协调工作就是基于适配器模式来做的,更准确点来说是对象适配器。通过适配器,我们可以将字节流对象适配成一个字符流对象,这样我们可以直接通过字节流对象来读取或者写入字符数据。\nInputStreamReader 和 OutputStreamWriter 就是两个适配器(Adapter), 同时,它们两个也是字节流和字符流之间的桥梁。InputStreamReader 使用 StreamDecoder (流解码器)对字节进行解码,实现字节流到字符流的转换, OutputStreamWriter 使用StreamEncoder(流编码器)对字符进行编码,实现字符流到字节流的转换。\nInputStream 和 OutputStream 的子类是被适配者, InputStreamReader 和 OutputStreamWriter是适配器。\n// InputStreamReader 是适配器,FileInputStream 是被适配的类 InputStreamReader isr = new InputStreamReader(new FileInputStream(fileName), \u0026#34;UTF-8\u0026#34;); // BufferedReader 增强 InputStreamReader 的功能(装饰器模式) BufferedReader bufferedReader = new BufferedReader(isr); java.io.InputStreamReader 部分源码:\npublic class InputStreamReader extends Reader { //用于解码的对象 private final StreamDecoder sd; public InputStreamReader(InputStream in) { super(in); try { // 获取 StreamDecoder 对象 sd = StreamDecoder.forInputStreamReader(in, this, (String)null); } catch (UnsupportedEncodingException e) { throw new Error(e); } } // 使用 StreamDecoder 对象做具体的读取工作 public int read() throws IOException { return sd.read(); } } java.io.OutputStreamWriter 部分源码:\npublic class OutputStreamWriter extends Writer { // 用于编码的对象 private final StreamEncoder se; public OutputStreamWriter(OutputStream out) { super(out); try { // 获取 StreamEncoder 对象 se = StreamEncoder.forOutputStreamWriter(out, this, (String)null); } catch (UnsupportedEncodingException e) { throw new Error(e); } } // 使用 StreamEncoder 对象做具体的写入工作 public void write(int c) throws IOException { se.write(c); } } 适配器模式和装饰器模式有什么区别呢?\n装饰器模式 更侧重于动态地增强原始类的功能,装饰器类需要跟原始类继承相同的抽象类或者实现相同的接口。并且,装饰器模式支持对原始类嵌套使用多个装饰器。\n适配器模式 更侧重于让接口不兼容而不能交互的类可以一起工作,当我们调用适配器对应的方法时,适配器内部会调用适配者类或者和适配类相关的类的方法,这个过程透明的。就比如说 StreamDecoder (流解码器)和StreamEncoder(流编码器)就是分别基于 InputStream 和 OutputStream 来获取 FileChannel对象并调用对应的 read 方法和 write 方法进行字节数据的读取和写入。\nStreamDecoder(InputStream in, Object lock, CharsetDecoder dec) { // 省略大部分代码 // 根据 InputStream 对象获取 FileChannel 对象 ch = getChannel((FileInputStream)in); } 适配器和适配者两者不需要继承相同的抽象类或者实现相同的接口。\n另外,FutureTask 类使用了适配器模式,Executors 的内部类 RunnableAdapter 实现属于适配器,用于将 Runnable 适配成 Callable。\nFutureTask参数包含 Runnable 的一个构造方法:\npublic FutureTask(Runnable runnable, V result) { // 调用 Executors 类的 callable 方法 this.callable = Executors.callable(runnable, result); this.state = NEW; } Executors中对应的方法和适配器:\n// 实际调用的是 Executors 的内部类 RunnableAdapter 的构造方法 public static \u0026lt;T\u0026gt; Callable\u0026lt;T\u0026gt; callable(Runnable task, T result) { if (task == null) throw new NullPointerException(); return new RunnableAdapter\u0026lt;T\u0026gt;(task, result); } // 适配器 static final class RunnableAdapter\u0026lt;T\u0026gt; implements Callable\u0026lt;T\u0026gt; { final Runnable task; final T result; RunnableAdapter(Runnable task, T result) { this.task = task; this.result = result; } public T call() { task.run(); return result; } } 工厂模式 # 工厂模式用于创建对象,NIO 中大量用到了工厂模式,比如 Files 类的 newInputStream 方法用于创建 InputStream 对象(静态工厂)、 Paths 类的 get 方法创建 Path 对象(静态工厂)、ZipFileSystem 类(sun.nio包下的类,属于 java.nio 相关的一些内部实现)的 getPath 的方法创建 Path 对象(简单工厂)。\nInputStream is = Files.newInputStream(Paths.get(generatorLogoPath)) 观察者模式 # NIO 中的文件目录监听服务使用到了观察者模式。\nNIO 中的文件目录监听服务基于 WatchService 接口和 Watchable 接口。WatchService 属于观察者,Watchable 属于被观察者。\nWatchable 接口定义了一个用于将对象注册到 WatchService(监控服务) 并绑定监听事件的方法 register 。\npublic interface Path extends Comparable\u0026lt;Path\u0026gt;, Iterable\u0026lt;Path\u0026gt;, Watchable{ } public interface Watchable { WatchKey register(WatchService watcher, WatchEvent.Kind\u0026lt;?\u0026gt;[] events, WatchEvent.Modifier... modifiers) throws IOException; } WatchService 用于监听文件目录的变化,同一个 WatchService 对象能够监听多个文件目录。\n// 创建 WatchService 对象 WatchService watchService = FileSystems.getDefault().newWatchService(); // 初始化一个被监控文件夹的 Path 类: Path path = Paths.get(\u0026#34;workingDirectory\u0026#34;); // 将这个 path 对象注册到 WatchService(监控服务) 中去 WatchKey watchKey = path.register( watchService, StandardWatchEventKinds...); Path 类 register 方法的第二个参数 events (需要监听的事件)为可变长参数,也就是说我们可以同时监听多种事件。\nWatchKey register(WatchService watcher, WatchEvent.Kind\u0026lt;?\u0026gt;... events) throws IOException; 常用的监听事件有 3 种:\nStandardWatchEventKinds.ENTRY_CREATE:文件创建。 StandardWatchEventKinds.ENTRY_DELETE : 文件删除。 StandardWatchEventKinds.ENTRY_MODIFY : 文件修改。 register 方法返回 WatchKey 对象,通过WatchKey 对象可以获取事件的具体信息比如文件目录下是创建、删除还是修改了文件、创建、删除或者修改的文件的具体名称是什么。\nWatchKey key; while ((key = watchService.take()) != null) { for (WatchEvent\u0026lt;?\u0026gt; event : key.pollEvents()) { // 可以调用 WatchEvent 对象的方法做一些事情比如输出事件的具体上下文信息 } key.reset(); } WatchService 内部是通过一个 daemon thread(守护线程)采用定期轮询的方式来检测文件的变化,简化后的源码如下所示。\nclass PollingWatchService extends AbstractWatchService { // 定义一个 daemon thread(守护线程)轮询检测文件变化 private final ScheduledExecutorService scheduledExecutor; PollingWatchService() { scheduledExecutor = Executors .newSingleThreadScheduledExecutor(new ThreadFactory() { @Override public Thread newThread(Runnable r) { Thread t = new Thread(r); t.setDaemon(true); return t; }}); } void enable(Set\u0026lt;? extends WatchEvent.Kind\u0026lt;?\u0026gt;\u0026gt; events, long period) { synchronized (this) { // 更新监听事件 this.events = events; // 开启定期轮询 Runnable thunk = new Runnable() { public void run() { poll(); }}; this.poller = scheduledExecutor .scheduleAtFixedRate(thunk, period, period, TimeUnit.SECONDS); } } } 参考 # Patterns in Java APIs: http://cecs.wright.edu/~tkprasad/courses/ceg860/paper/node26.html 装饰器模式:通过剖析 Java IO 类库源码学习装饰器模式: https://time.geekbang.org/column/article/204845 sun.nio 包是什么,是 java 代码么? - RednaxelaFX https://www.zhihu.com/question/29237781/answer/43653953 "},{"id":500,"href":"/zh/docs/technology/Interview/java/io/nio-basis/","title":"Java NIO 核心知识总结","section":"Io","content":"在学习 NIO 之前,需要先了解一下计算机 I/O 模型的基础理论知识。还不了解的话,可以参考我写的这篇文章: Java IO 模型详解。\nNIO 简介 # 在传统的 Java I/O 模型(BIO)中,I/O 操作是以阻塞的方式进行的。也就是说,当一个线程执行一个 I/O 操作时,它会被阻塞直到操作完成。这种阻塞模型在处理多个并发连接时可能会导致性能瓶颈,因为需要为每个连接创建一个线程,而线程的创建和切换都是有开销的。\n为了解决这个问题,在 Java1.4 版本引入了一种新的 I/O 模型 — NIO (New IO,也称为 Non-blocking IO) 。NIO 弥补了同步阻塞 I/O 的不足,它在标准 Java 代码中提供了非阻塞、面向缓冲、基于通道的 I/O,可以使用少量的线程来处理多个连接,大大提高了 I/O 效率和并发。\n下图是 BIO、NIO 和 AIO 处理客户端请求的简单对比图(关于 AIO 的介绍,可以看我写的这篇文章: Java IO 模型详解,不是重点,了解即可)。\n⚠️需要注意:使用 NIO 并不一定意味着高性能,它的性能优势主要体现在高并发和高延迟的网络环境下。当连接数较少、并发程度较低或者网络传输速度较快时,NIO 的性能并不一定优于传统的 BIO 。\nNIO 核心组件 # NIO 主要包括以下三个核心组件:\nBuffer(缓冲区):NIO 读写数据都是通过缓冲区进行操作的。读操作的时候将 Channel 中的数据填充到 Buffer 中,而写操作时将 Buffer 中的数据写入到 Channel 中。 Channel(通道):Channel 是一个双向的、可读可写的数据传输通道,NIO 通过 Channel 来实现数据的输入输出。通道是一个抽象的概念,它可以代表文件、套接字或者其他数据源之间的连接。 Selector(选择器):允许一个线程处理多个 Channel,基于事件驱动的 I/O 多路复用模型。所有的 Channel 都可以注册到 Selector 上,由 Selector 来分配线程来处理事件。 三者的关系如下图所示(暂时不理解没关系,后文会详细介绍):\n下面详细介绍一下这三个组件。\nBuffer(缓冲区) # 在传统的 BIO 中,数据的读写是面向流的, 分为字节流和字符流。\n在 Java 1.4 的 NIO 库中,所有数据都是用缓冲区处理的,这是新库和之前的 BIO 的一个重要区别,有点类似于 BIO 中的缓冲流。NIO 在读取数据时,它是直接读到缓冲区中的。在写入数据时,写入到缓冲区中。 使用 NIO 在读写数据时,都是通过缓冲区进行操作。\nBuffer 的子类如下图所示。其中,最常用的是 ByteBuffer,它可以用来存储和操作字节数据。\n你可以将 Buffer 理解为一个数组,IntBuffer、FloatBuffer、CharBuffer 等分别对应 int[]、float[]、char[] 等。\n为了更清晰地认识缓冲区,我们来简单看看Buffer 类中定义的四个成员变量:\npublic abstract class Buffer { // Invariants: mark \u0026lt;= position \u0026lt;= limit \u0026lt;= capacity private int mark = -1; private int position = 0; private int limit; private int capacity; } 这四个成员变量的具体含义如下:\n容量(capacity):Buffer可以存储的最大数据量,Buffer创建时设置且不可改变; 界限(limit):Buffer 中可以读/写数据的边界。写模式下,limit 代表最多能写入的数据,一般等于 capacity(可以通过limit(int newLimit)方法设置);读模式下,limit 等于 Buffer 中实际写入的数据大小。 位置(position):下一个可以被读写的数据的位置(索引)。从写操作模式到读操作模式切换的时候(flip),position 都会归零,这样就可以从头开始读写了。 标记(mark):Buffer允许将位置直接定位到该标记处,这是一个可选属性; 并且,上述变量满足如下的关系:0 \u0026lt;= mark \u0026lt;= position \u0026lt;= limit \u0026lt;= capacity 。\n另外,Buffer 有读模式和写模式这两种模式,分别用于从 Buffer 中读取数据或者向 Buffer 中写入数据。Buffer 被创建之后默认是写模式,调用 flip() 可以切换到读模式。如果要再次切换回写模式,可以调用 clear() 或者 compact() 方法。\nBuffer 对象不能通过 new 调用构造方法创建对象 ,只能通过静态方法实例化 Buffer。\n这里以 ByteBuffer为例进行介绍:\n// 分配堆内存 public static ByteBuffer allocate(int capacity); // 分配直接内存 public static ByteBuffer allocateDirect(int capacity); Buffer 最核心的两个方法:\nget : 读取缓冲区的数据 put :向缓冲区写入数据 除上述两个方法之外,其他的重要方法:\nflip :将缓冲区从写模式切换到读模式,它会将 limit 的值设置为当前 position 的值,将 position 的值设置为 0。 clear: 清空缓冲区,将缓冲区从读模式切换到写模式,并将 position 的值设置为 0,将 limit 的值设置为 capacity 的值。 …… Buffer 中数据变化的过程:\nimport java.nio.*; public class CharBufferDemo { public static void main(String[] args) { // 分配一个容量为8的CharBuffer CharBuffer buffer = CharBuffer.allocate(8); System.out.println(\u0026#34;初始状态:\u0026#34;); printState(buffer); // 向buffer写入3个字符 buffer.put(\u0026#39;a\u0026#39;).put(\u0026#39;b\u0026#39;).put(\u0026#39;c\u0026#39;); System.out.println(\u0026#34;写入3个字符后的状态:\u0026#34;); printState(buffer); // 调用flip()方法,准备读取buffer中的数据,将 position 置 0,limit 的置 3 buffer.flip(); System.out.println(\u0026#34;调用flip()方法后的状态:\u0026#34;); printState(buffer); // 读取字符 while (buffer.hasRemaining()) { System.out.print(buffer.get()); } // 调用clear()方法,清空缓冲区,将 position 的值置为 0,将 limit 的值置为 capacity 的值 buffer.clear(); System.out.println(\u0026#34;调用clear()方法后的状态:\u0026#34;); printState(buffer); } // 打印buffer的capacity、limit、position、mark的位置 private static void printState(CharBuffer buffer) { System.out.print(\u0026#34;capacity: \u0026#34; + buffer.capacity()); System.out.print(\u0026#34;, limit: \u0026#34; + buffer.limit()); System.out.print(\u0026#34;, position: \u0026#34; + buffer.position()); System.out.print(\u0026#34;, mark 开始读取的字符: \u0026#34; + buffer.mark()); System.out.println(\u0026#34;\\n\u0026#34;); } } 输出:\n初始状态: capacity: 8, limit: 8, position: 0 写入3个字符后的状态: capacity: 8, limit: 8, position: 3 准备读取buffer中的数据! 调用flip()方法后的状态: capacity: 8, limit: 3, position: 0 读取到的数据:abc 调用clear()方法后的状态: capacity: 8, limit: 8, position: 0 为了帮助理解,我绘制了一张图片展示 capacity、limit和position每一阶段的变化。\nChannel(通道) # Channel 是一个通道,它建立了与数据源(如文件、网络套接字等)之间的连接。我们可以利用它来读取和写入数据,就像打开了一条自来水管,让数据在 Channel 中自由流动。\nBIO 中的流是单向的,分为各种 InputStream(输入流)和 OutputStream(输出流),数据只是在一个方向上传输。通道与流的不同之处在于通道是双向的,它可以用于读、写或者同时用于读写。\nChannel 与前面介绍的 Buffer 打交道,读操作的时候将 Channel 中的数据填充到 Buffer 中,而写操作时将 Buffer 中的数据写入到 Channel 中。\n另外,因为 Channel 是全双工的,所以它可以比流更好地映射底层操作系统的 API。特别是在 UNIX 网络编程模型中,底层操作系统的通道都是全双工的,同时支持读写操作。\nChannel 的子类如下图所示。\n其中,最常用的是以下几种类型的通道:\nFileChannel:文件访问通道; SocketChannel、ServerSocketChannel:TCP 通信通道; DatagramChannel:UDP 通信通道; Channel 最核心的两个方法:\nread :读取数据并写入到 Buffer 中。 write :将 Buffer 中的数据写入到 Channel 中。 这里我们以 FileChannel 为例演示一下是读取文件数据的。\nRandomAccessFile reader = new RandomAccessFile(\u0026#34;/Users/guide/Documents/test_read.in\u0026#34;, \u0026#34;r\u0026#34;)) FileChannel channel = reader.getChannel(); ByteBuffer buffer = ByteBuffer.allocate(1024); channel.read(buffer); Selector(选择器) # Selector(选择器) 是 NIO 中的一个关键组件,它允许一个线程处理多个 Channel。Selector 是基于事件驱动的 I/O 多路复用模型,主要运作原理是:通过 Selector 注册通道的事件,Selector 会不断地轮询注册在其上的 Channel。当事件发生时,比如:某个 Channel 上面有新的 TCP 连接接入、读和写事件,这个 Channel 就处于就绪状态,会被 Selector 轮询出来。Selector 会将相关的 Channel 加入到就绪集合中。通过 SelectionKey 可以获取就绪 Channel 的集合,然后对这些就绪的 Channel 进行相应的 I/O 操作。\n一个多路复用器 Selector 可以同时轮询多个 Channel,由于 JDK 使用了 epoll() 代替传统的 select 实现,所以它并没有最大连接句柄 1024/2048 的限制。这也就意味着只需要一个线程负责 Selector 的轮询,就可以接入成千上万的客户端。\nSelector 可以监听以下四种事件类型:\nSelectionKey.OP_ACCEPT:表示通道接受连接的事件,这通常用于 ServerSocketChannel。 SelectionKey.OP_CONNECT:表示通道完成连接的事件,这通常用于 SocketChannel。 SelectionKey.OP_READ:表示通道准备好进行读取的事件,即有数据可读。 SelectionKey.OP_WRITE:表示通道准备好进行写入的事件,即可以写入数据。 Selector是抽象类,可以通过调用此类的 open() 静态方法来创建 Selector 实例。Selector 可以同时监控多个 SelectableChannel 的 IO 状况,是非阻塞 IO 的核心。\n一个 Selector 实例有三个 SelectionKey 集合:\n所有的 SelectionKey 集合:代表了注册在该 Selector 上的 Channel,这个集合可以通过 keys() 方法返回。 被选择的 SelectionKey 集合:代表了所有可通过 select() 方法获取的、需要进行 IO 处理的 Channel,这个集合可以通过 selectedKeys() 返回。 被取消的 SelectionKey 集合:代表了所有被取消注册关系的 Channel,在下一次执行 select() 方法时,这些 Channel 对应的 SelectionKey 会被彻底删除,程序通常无须直接访问该集合,也没有暴露访问的方法。 简单演示一下如何遍历被选择的 SelectionKey 集合并进行处理:\nSet\u0026lt;SelectionKey\u0026gt; selectedKeys = selector.selectedKeys(); Iterator\u0026lt;SelectionKey\u0026gt; keyIterator = selectedKeys.iterator(); while (keyIterator.hasNext()) { SelectionKey key = keyIterator.next(); if (key != null) { if (key.isAcceptable()) { // ServerSocketChannel 接收了一个新连接 } else if (key.isConnectable()) { // 表示一个新连接建立 } else if (key.isReadable()) { // Channel 有准备好的数据,可以读取 } else if (key.isWritable()) { // Channel 有空闲的 Buffer,可以写入数据 } } keyIterator.remove(); } Selector 还提供了一系列和 select() 相关的方法:\nint select():监控所有注册的 Channel,当它们中间有需要处理的 IO 操作时,该方法返回,并将对应的 SelectionKey 加入被选择的 SelectionKey 集合中,该方法返回这些 Channel 的数量。 int select(long timeout):可以设置超时时长的 select() 操作。 int selectNow():执行一个立即返回的 select() 操作,相对于无参数的 select() 方法而言,该方法不会阻塞线程。 Selector wakeup():使一个还未返回的 select() 方法立刻返回。 …… 使用 Selector 实现网络读写的简单示例:\nimport java.io.IOException; import java.net.InetSocketAddress; import java.nio.ByteBuffer; import java.nio.channels.SelectionKey; import java.nio.channels.Selector; import java.nio.channels.ServerSocketChannel; import java.nio.channels.SocketChannel; import java.util.Iterator; import java.util.Set; public class NioSelectorExample { public static void main(String[] args) { try { ServerSocketChannel serverSocketChannel = ServerSocketChannel.open(); serverSocketChannel.configureBlocking(false); serverSocketChannel.socket().bind(new InetSocketAddress(8080)); Selector selector = Selector.open(); // 将 ServerSocketChannel 注册到 Selector 并监听 OP_ACCEPT 事件 serverSocketChannel.register(selector, SelectionKey.OP_ACCEPT); while (true) { int readyChannels = selector.select(); if (readyChannels == 0) { continue; } Set\u0026lt;SelectionKey\u0026gt; selectedKeys = selector.selectedKeys(); Iterator\u0026lt;SelectionKey\u0026gt; keyIterator = selectedKeys.iterator(); while (keyIterator.hasNext()) { SelectionKey key = keyIterator.next(); if (key.isAcceptable()) { // 处理连接事件 ServerSocketChannel server = (ServerSocketChannel) key.channel(); SocketChannel client = server.accept(); client.configureBlocking(false); // 将客户端通道注册到 Selector 并监听 OP_READ 事件 client.register(selector, SelectionKey.OP_READ); } else if (key.isReadable()) { // 处理读事件 SocketChannel client = (SocketChannel) key.channel(); ByteBuffer buffer = ByteBuffer.allocate(1024); int bytesRead = client.read(buffer); if (bytesRead \u0026gt; 0) { buffer.flip(); System.out.println(\u0026#34;收到数据:\u0026#34; +new String(buffer.array(), 0, bytesRead)); // 将客户端通道注册到 Selector 并监听 OP_WRITE 事件 client.register(selector, SelectionKey.OP_WRITE); } else if (bytesRead \u0026lt; 0) { // 客户端断开连接 client.close(); } } else if (key.isWritable()) { // 处理写事件 SocketChannel client = (SocketChannel) key.channel(); ByteBuffer buffer = ByteBuffer.wrap(\u0026#34;Hello, Client!\u0026#34;.getBytes()); client.write(buffer); // 将客户端通道注册到 Selector 并监听 OP_READ 事件 client.register(selector, SelectionKey.OP_READ); } keyIterator.remove(); } } } catch (IOException e) { e.printStackTrace(); } } } 在示例中,我们创建了一个简单的服务器,监听 8080 端口,使用 Selector 处理连接、读取和写入事件。当接收到客户端的数据时,服务器将读取数据并将其打印到控制台,然后向客户端回复 \u0026ldquo;Hello, Client!\u0026quot;。\nNIO 零拷贝 # 零拷贝是提升 IO 操作性能的一个常用手段,像 ActiveMQ、Kafka 、RocketMQ、QMQ、Netty 等顶级开源项目都用到了零拷贝。\n零拷贝是指计算机执行 IO 操作时,CPU 不需要将数据从一个存储区域复制到另一个存储区域,从而可以减少上下文切换以及 CPU 的拷贝时间。也就是说,零拷贝主要解决操作系统在处理 I/O 操作时频繁复制数据的问题。零拷贝的常见实现技术有: mmap+write、sendfile和 sendfile + DMA gather copy 。\n下图展示了各种零拷贝技术的对比图:\nCPU 拷贝 DMA 拷贝 系统调用 上下文切换 传统方法 2 2 read+write 4 mmap+write 1 2 mmap+write 4 sendfile 1 2 sendfile 2 sendfile + DMA gather copy 0 2 sendfile 2 可以看出,无论是传统的 I/O 方式,还是引入了零拷贝之后,2 次 DMA(Direct Memory Access) 拷贝是都少不了的。因为两次 DMA 都是依赖硬件完成的。零拷贝主要是减少了 CPU 拷贝及上下文的切换。\nJava 对零拷贝的支持:\nMappedByteBuffer 是 NIO 基于内存映射(mmap)这种零拷⻉⽅式的提供的⼀种实现,底层实际是调用了 Linux 内核的 mmap 系统调用。它可以将一个文件或者文件的一部分映射到内存中,形成一个虚拟内存文件,这样就可以直接操作内存中的数据,而不需要通过系统调用来读写文件。 FileChannel 的transferTo()/transferFrom()是 NIO 基于发送文件(sendfile)这种零拷贝方式的提供的一种实现,底层实际是调用了 Linux 内核的 sendfile系统调用。它可以直接将文件数据从磁盘发送到网络,而不需要经过用户空间的缓冲区。关于FileChannel的用法可以看看这篇文章: Java NIO 文件通道 FileChannel 用法。 代码示例:\nprivate void loadFileIntoMemory(File xmlFile) throws IOException { FileInputStream fis = new FileInputStream(xmlFile); // 创建 FileChannel 对象 FileChannel fc = fis.getChannel(); // FileChannel.map() 将文件映射到直接内存并返回 MappedByteBuffer 对象 MappedByteBuffer mmb = fc.map(FileChannel.MapMode.READ_ONLY, 0, fc.size()); xmlFileBuffer = new byte[(int)fc.size()]; mmb.get(xmlFileBuffer); fis.close(); } 总结 # 这篇文章我们主要介绍了 NIO 的核心知识点,包括 NIO 的核心组件和零拷贝。\n如果我们需要使用 NIO 构建网络程序的话,不建议直接使用原生 NIO,编程复杂且功能性太弱,推荐使用一些成熟的基于 NIO 的网络编程框架比如 Netty。Netty 在 NIO 的基础上进行了一些优化和扩展比如支持多种协议、支持 SSL/TLS 等等。\n参考 # Java NIO 浅析: https://tech.meituan.com/2016/11/04/nio.html\n面试官:Java NIO 了解? https://mp.weixin.qq.com/s/mZobf-U8OSYQfHfYBEB6KA\nJava NIO:Buffer、Channel 和 Selector: https://www.javadoop.com/post/java-nio\n"},{"id":501,"href":"/zh/docs/technology/Interview/java/basis/spi/","title":"Java SPI 机制详解","section":"Basis","content":" 本文来自 Kingshion 投稿。欢迎更多朋友参与到 JavaGuide 的维护工作,这是一件非常有意义的事情。详细信息请看: JavaGuide 贡献指南 。\n面向对象设计鼓励模块间基于接口而非具体实现编程,以降低模块间的耦合,遵循依赖倒置原则,并支持开闭原则(对扩展开放,对修改封闭)。然而,直接依赖具体实现会导致在替换实现时需要修改代码,违背了开闭原则。为了解决这个问题,SPI 应运而生,它提供了一种服务发现机制,允许在程序外部动态指定具体实现。这与控制反转(IoC)的思想相似,将组件装配的控制权移交给了程序之外。\nSPI 机制也解决了 Java 类加载体系中双亲委派模型带来的限制。 双亲委派模型虽然保证了核心库的安全性和一致性,但也限制了核心库或扩展库加载应用程序类路径上的类(通常由第三方实现)。SPI 允许核心或扩展库定义服务接口,第三方开发者提供并部署实现,SPI 服务加载机制则在运行时动态发现并加载这些实现。例如,JDBC 4.0 及之后版本利用 SPI 自动发现和加载数据库驱动,开发者只需将驱动 JAR 包放置在类路径下即可,无需使用Class.forName()显式加载驱动类。\nSPI 介绍 # 何谓 SPI? # SPI 即 Service Provider Interface ,字面意思就是:“服务提供者的接口”,我的理解是:专门提供给服务提供者或者扩展框架功能的开发者去使用的一个接口。\nSPI 将服务接口和具体的服务实现分离开来,将服务调用方和服务实现者解耦,能够提升程序的扩展性、可维护性。修改或者替换服务实现并不需要修改调用方。\n很多框架都使用了 Java 的 SPI 机制,比如:Spring 框架、数据库加载驱动、日志接口、以及 Dubbo 的扩展实现等等。 SPI 和 API 有什么区别? # 那 SPI 和 API 有啥区别?\n说到 SPI 就不得不说一下 API(Application Programming Interface) 了,从广义上来说它们都属于接口,而且很容易混淆。下面先用一张图说明一下:\n一般模块之间都是通过接口进行通讯,因此我们在服务调用方和服务实现方(也称服务提供者)之间引入一个“接口”。\n当实现方提供了接口和实现,我们可以通过调用实现方的接口从而拥有实现方给我们提供的能力,这就是 API。这种情况下,接口和实现都是放在实现方的包中。调用方通过接口调用实现方的功能,而不需要关心具体的实现细节。 当接口存在于调用方这边时,这就是 SPI 。由接口调用方确定接口规则,然后由不同的厂商根据这个规则对这个接口进行实现,从而提供服务。 举个通俗易懂的例子:公司 H 是一家科技公司,新设计了一款芯片,然后现在需要量产了,而市面上有好几家芯片制造业公司,这个时候,只要 H 公司指定好了这芯片生产的标准(定义好了接口标准),那么这些合作的芯片公司(服务提供者)就按照标准交付自家特色的芯片(提供不同方案的实现,但是给出来的结果是一样的)。\n实战演示 # SLF4J (Simple Logging Facade for Java)是 Java 的一个日志门面(接口),其具体实现有几种,比如:Logback、Log4j、Log4j2 等等,而且还可以切换,在切换日志具体实现的时候我们是不需要更改项目代码的,只需要在 Maven 依赖里面修改一些 pom 依赖就好了。\n这就是依赖 SPI 机制实现的,那我们接下来就实现一个简易版本的日志框架。\nService Provider Interface # 新建一个 Java 项目 service-provider-interface 目录结构如下:(注意直接新建 Java 项目就好了,不用新建 Maven 项目,Maven 项目会涉及到一些编译配置,如果有私服的话,直接 deploy 会比较方便,但是没有的话,在过程中可能会遇到一些奇怪的问题。)\n│ service-provider-interface.iml │ ├─.idea │ │ .gitignore │ │ misc.xml │ │ modules.xml │ └─ workspace.xml │ └─src └─edu └─jiangxuan └─up └─spi Logger.java LoggerService.java Main.class 新建 Logger 接口,这个就是 SPI , 服务提供者接口,后面的服务提供者就要针对这个接口进行实现。\npackage edu.jiangxuan.up.spi; public interface Logger { void info(String msg); void debug(String msg); } 接下来就是 LoggerService 类,这个主要是为服务使用者(调用方)提供特定功能的。这个类也是实现 Java SPI 机制的关键所在,如果存在疑惑的话可以先往后面继续看。\npackage edu.jiangxuan.up.spi; import java.util.ArrayList; import java.util.List; import java.util.ServiceLoader; public class LoggerService { private static final LoggerService SERVICE = new LoggerService(); private final Logger logger; private final List\u0026lt;Logger\u0026gt; loggerList; private LoggerService() { ServiceLoader\u0026lt;Logger\u0026gt; loader = ServiceLoader.load(Logger.class); List\u0026lt;Logger\u0026gt; list = new ArrayList\u0026lt;\u0026gt;(); for (Logger log : loader) { list.add(log); } // LoggerList 是所有 ServiceProvider loggerList = list; if (!list.isEmpty()) { // Logger 只取一个 logger = list.get(0); } else { logger = null; } } public static LoggerService getService() { return SERVICE; } public void info(String msg) { if (logger == null) { System.out.println(\u0026#34;info 中没有发现 Logger 服务提供者\u0026#34;); } else { logger.info(msg); } } public void debug(String msg) { if (loggerList.isEmpty()) { System.out.println(\u0026#34;debug 中没有发现 Logger 服务提供者\u0026#34;); } loggerList.forEach(log -\u0026gt; log.debug(msg)); } } 新建 Main 类(服务使用者,调用方),启动程序查看结果。\npackage org.spi.service; public class Main { public static void main(String[] args) { LoggerService service = LoggerService.getService(); service.info(\u0026#34;Hello SPI\u0026#34;); service.debug(\u0026#34;Hello SPI\u0026#34;); } } 程序结果:\ninfo 中没有发现 Logger 服务提供者 debug 中没有发现 Logger 服务提供者\n此时我们只是空有接口,并没有为 Logger 接口提供任何的实现,所以输出结果中没有按照预期打印相应的结果。\n你可以使用命令或者直接使用 IDEA 将整个程序直接打包成 jar 包。\nService Provider # 接下来新建一个项目用来实现 Logger 接口\n新建项目 service-provider 目录结构如下:\n│ service-provider.iml │ ├─.idea │ │ .gitignore │ │ misc.xml │ │ modules.xml │ └─ workspace.xml │ ├─lib │ service-provider-interface.jar | └─src ├─edu │ └─jiangxuan │ └─up │ └─spi │ └─service │ Logback.java │ └─META-INF └─services edu.jiangxuan.up.spi.Logger 新建 Logback 类\npackage edu.jiangxuan.up.spi.service; import edu.jiangxuan.up.spi.Logger; public class Logback implements Logger { @Override public void info(String s) { System.out.println(\u0026#34;Logback info 打印日志:\u0026#34; + s); } @Override public void debug(String s) { System.out.println(\u0026#34;Logback debug 打印日志:\u0026#34; + s); } } 将 service-provider-interface 的 jar 导入项目中。\n新建 lib 目录,然后将 jar 包拷贝过来,再添加到项目中。\n再点击 OK 。\n接下来就可以在项目中导入 jar 包里面的一些类和方法了,就像 JDK 工具类导包一样的。\n实现 Logger 接口,在 src 目录下新建 META-INF/services 文件夹,然后新建文件 edu.jiangxuan.up.spi.Logger (SPI 的全类名),文件里面的内容是:edu.jiangxuan.up.spi.service.Logback (Logback 的全类名,即 SPI 的实现类的包名 + 类名)。\n这是 JDK SPI 机制 ServiceLoader 约定好的标准。\n这里先大概解释一下:Java 中的 SPI 机制就是在每次类加载的时候会先去找到 class 相对目录下的 META-INF 文件夹下的 services 文件夹下的文件,将这个文件夹下面的所有文件先加载到内存中,然后根据这些文件的文件名和里面的文件内容找到相应接口的具体实现类,找到实现类后就可以通过反射去生成对应的对象,保存在一个 list 列表里面,所以可以通过迭代或者遍历的方式拿到对应的实例对象,生成不同的实现。\n所以会提出一些规范要求:文件名一定要是接口的全类名,然后里面的内容一定要是实现类的全类名,实现类可以有多个,直接换行就好了,多个实现类的时候,会一个一个的迭代加载。\n接下来同样将 service-provider 项目打包成 jar 包,这个 jar 包就是服务提供方的实现。通常我们导入 maven 的 pom 依赖就有点类似这种,只不过我们现在没有将这个 jar 包发布到 maven 公共仓库中,所以在需要使用的地方只能手动的添加到项目中。\n效果展示 # 为了更直观的展示效果,我这里再新建一个专门用来测试的工程项目:java-spi-test\n然后先导入 Logger 的接口 jar 包,再导入具体的实现类的 jar 包。\n新建 Main 方法测试:\npackage edu.jiangxuan.up.service; import edu.jiangxuan.up.spi.LoggerService; public class TestJavaSPI { public static void main(String[] args) { LoggerService loggerService = LoggerService.getService(); loggerService.info(\u0026#34;你好\u0026#34;); loggerService.debug(\u0026#34;测试Java SPI 机制\u0026#34;); } } 运行结果如下:\nLogback info 打印日志:你好 Logback debug 打印日志:测试 Java SPI 机制\n说明导入 jar 包中的实现类生效了。\n如果我们不导入具体的实现类的 jar 包,那么此时程序运行的结果就会是:\ninfo 中没有发现 Logger 服务提供者 debug 中没有发现 Logger 服务提供者\n通过使用 SPI 机制,可以看出服务(LoggerService)和 服务提供者两者之间的耦合度非常低,如果说我们想要换一种实现,那么其实只需要修改 service-provider 项目中针对 Logger 接口的具体实现就可以了,只需要换一个 jar 包即可,也可以有在一个项目里面有多个实现,这不就是 SLF4J 原理吗?\n如果某一天需求变更了,此时需要将日志输出到消息队列,或者做一些别的操作,这个时候完全不需要更改 Logback 的实现,只需要新增一个服务实现(service-provider)可以通过在本项目里面新增实现也可以从外部引入新的服务实现 jar 包。我们可以在服务(LoggerService)中选择一个具体的 服务实现(service-provider) 来完成我们需要的操作。\n那么接下来我们具体来说说 Java SPI 工作的重点原理—— ServiceLoader 。\nServiceLoader # ServiceLoader 具体实现 # 想要使用 Java 的 SPI 机制是需要依赖 ServiceLoader 来实现的,那么我们接下来看看 ServiceLoader 具体是怎么做的:\nServiceLoader 是 JDK 提供的一个工具类, 位于package java.util;包下。\nA facility to load implementations of a service. 这是 JDK 官方给的注释:一种加载服务实现的工具。\n再往下看,我们发现这个类是一个 final 类型的,所以是不可被继承修改,同时它实现了 Iterable 接口。之所以实现了迭代器,是为了方便后续我们能够通过迭代的方式得到对应的服务实现。\npublic final class ServiceLoader\u0026lt;S\u0026gt; implements Iterable\u0026lt;S\u0026gt;{ xxx...} 可以看到一个熟悉的常量定义:\nprivate static final String PREFIX = \u0026quot;META-INF/services/\u0026quot;;\n下面是 load 方法:可以发现 load 方法支持两种重载后的入参;\npublic static \u0026lt;S\u0026gt; ServiceLoader\u0026lt;S\u0026gt; load(Class\u0026lt;S\u0026gt; service) { ClassLoader cl = Thread.currentThread().getContextClassLoader(); return ServiceLoader.load(service, cl); } public static \u0026lt;S\u0026gt; ServiceLoader\u0026lt;S\u0026gt; load(Class\u0026lt;S\u0026gt; service, ClassLoader loader) { return new ServiceLoader\u0026lt;\u0026gt;(service, loader); } private ServiceLoader(Class\u0026lt;S\u0026gt; svc, ClassLoader cl) { service = Objects.requireNonNull(svc, \u0026#34;Service interface cannot be null\u0026#34;); loader = (cl == null) ? ClassLoader.getSystemClassLoader() : cl; acc = (System.getSecurityManager() != null) ? AccessController.getContext() : null; reload(); } public void reload() { providers.clear(); lookupIterator = new LazyIterator(service, loader); } 其解决第三方类加载的机制其实就蕴含在 ClassLoader cl = Thread.currentThread().getContextClassLoader(); 中,cl 就是线程上下文类加载器(Thread Context ClassLoader)。这是每个线程持有的类加载器,JDK 的设计允许应用程序或容器(如 Web 应用服务器)设置这个类加载器,以便核心类库能够通过它来加载应用程序类。\n线程上下文类加载器默认情况下是应用程序类加载器(Application ClassLoader),它负责加载 classpath 上的类。当核心库需要加载应用程序提供的类时,它可以使用线程上下文类加载器来完成。这样,即使是由引导类加载器加载的核心库代码,也能够加载并使用由应用程序类加载器加载的类。\n根据代码的调用顺序,在 reload() 方法中是通过一个内部类 LazyIterator 实现的。先继续往下面看。\nServiceLoader 实现了 Iterable 接口的方法后,具有了迭代的能力,在这个 iterator 方法被调用时,首先会在 ServiceLoader 的 Provider 缓存中进行查找,如果缓存中没有命中那么则在 LazyIterator 中进行查找。\npublic Iterator\u0026lt;S\u0026gt; iterator() { return new Iterator\u0026lt;S\u0026gt;() { Iterator\u0026lt;Map.Entry\u0026lt;String, S\u0026gt;\u0026gt; knownProviders = providers.entrySet().iterator(); public boolean hasNext() { if (knownProviders.hasNext()) return true; return lookupIterator.hasNext(); // 调用 LazyIterator } public S next() { if (knownProviders.hasNext()) return knownProviders.next().getValue(); return lookupIterator.next(); // 调用 LazyIterator } public void remove() { throw new UnsupportedOperationException(); } }; } 在调用 LazyIterator 时,具体实现如下:\npublic boolean hasNext() { if (acc == null) { return hasNextService(); } else { PrivilegedAction\u0026lt;Boolean\u0026gt; action = new PrivilegedAction\u0026lt;Boolean\u0026gt;() { public Boolean run() { return hasNextService(); } }; return AccessController.doPrivileged(action, acc); } } private boolean hasNextService() { if (nextName != null) { return true; } if (configs == null) { try { //通过PREFIX(META-INF/services/)和类名 获取对应的配置文件,得到具体的实现类 String fullName = PREFIX + service.getName(); if (loader == null) configs = ClassLoader.getSystemResources(fullName); else configs = loader.getResources(fullName); } catch (IOException x) { fail(service, \u0026#34;Error locating configuration files\u0026#34;, x); } } while ((pending == null) || !pending.hasNext()) { if (!configs.hasMoreElements()) { return false; } pending = parse(service, configs.nextElement()); } nextName = pending.next(); return true; } public S next() { if (acc == null) { return nextService(); } else { PrivilegedAction\u0026lt;S\u0026gt; action = new PrivilegedAction\u0026lt;S\u0026gt;() { public S run() { return nextService(); } }; return AccessController.doPrivileged(action, acc); } } private S nextService() { if (!hasNextService()) throw new NoSuchElementException(); String cn = nextName; nextName = null; Class\u0026lt;?\u0026gt; c = null; try { c = Class.forName(cn, false, loader); } catch (ClassNotFoundException x) { fail(service, \u0026#34;Provider \u0026#34; + cn + \u0026#34; not found\u0026#34;); } if (!service.isAssignableFrom(c)) { fail(service, \u0026#34;Provider \u0026#34; + cn + \u0026#34; not a subtype\u0026#34;); } try { S p = service.cast(c.newInstance()); providers.put(cn, p); return p; } catch (Throwable x) { fail(service, \u0026#34;Provider \u0026#34; + cn + \u0026#34; could not be instantiated\u0026#34;, x); } throw new Error(); // This cannot happen } 可能很多人看这个会觉得有点复杂,没关系,我这边实现了一个简单的 ServiceLoader 的小模型,流程和原理都是保持一致的,可以先从自己实现一个简易版本的开始学:\n自己实现一个 ServiceLoader # 我先把代码贴出来:\npackage edu.jiangxuan.up.service; import java.io.BufferedReader; import java.io.InputStream; import java.io.InputStreamReader; import java.lang.reflect.Constructor; import java.net.URL; import java.net.URLConnection; import java.util.ArrayList; import java.util.Enumeration; import java.util.List; public class MyServiceLoader\u0026lt;S\u0026gt; { // 对应的接口 Class 模板 private final Class\u0026lt;S\u0026gt; service; // 对应实现类的 可以有多个,用 List 进行封装 private final List\u0026lt;S\u0026gt; providers = new ArrayList\u0026lt;\u0026gt;(); // 类加载器 private final ClassLoader classLoader; // 暴露给外部使用的方法,通过调用这个方法可以开始加载自己定制的实现流程。 public static \u0026lt;S\u0026gt; MyServiceLoader\u0026lt;S\u0026gt; load(Class\u0026lt;S\u0026gt; service) { return new MyServiceLoader\u0026lt;\u0026gt;(service); } // 构造方法私有化 private MyServiceLoader(Class\u0026lt;S\u0026gt; service) { this.service = service; this.classLoader = Thread.currentThread().getContextClassLoader(); doLoad(); } // 关键方法,加载具体实现类的逻辑 private void doLoad() { try { // 读取所有 jar 包里面 META-INF/services 包下面的文件,这个文件名就是接口名,然后文件里面的内容就是具体的实现类的路径加全类名 Enumeration\u0026lt;URL\u0026gt; urls = classLoader.getResources(\u0026#34;META-INF/services/\u0026#34; + service.getName()); // 挨个遍历取到的文件 while (urls.hasMoreElements()) { // 取出当前的文件 URL url = urls.nextElement(); System.out.println(\u0026#34;File = \u0026#34; + url.getPath()); // 建立链接 URLConnection urlConnection = url.openConnection(); urlConnection.setUseCaches(false); // 获取文件输入流 InputStream inputStream = urlConnection.getInputStream(); // 从文件输入流获取缓存 BufferedReader bufferedReader = new BufferedReader(new InputStreamReader(inputStream)); // 从文件内容里面得到实现类的全类名 String className = bufferedReader.readLine(); while (className != null) { // 通过反射拿到实现类的实例 Class\u0026lt;?\u0026gt; clazz = Class.forName(className, false, classLoader); // 如果声明的接口跟这个具体的实现类是属于同一类型,(可以理解为Java的一种多态,接口跟实现类、父类和子类等等这种关系。)则构造实例 if (service.isAssignableFrom(clazz)) { Constructor\u0026lt;? extends S\u0026gt; constructor = (Constructor\u0026lt;? extends S\u0026gt;) clazz.getConstructor(); S instance = constructor.newInstance(); // 把当前构造的实例对象添加到 Provider的列表里面 providers.add(instance); } // 继续读取下一行的实现类,可以有多个实现类,只需要换行就可以了。 className = bufferedReader.readLine(); } } } catch (Exception e) { System.out.println(\u0026#34;读取文件异常。。。\u0026#34;); } } // 返回spi接口对应的具体实现类列表 public List\u0026lt;S\u0026gt; getProviders() { return providers; } } 关键信息基本已经通过代码注释描述出来了,\n主要的流程就是:\n通过 URL 工具类从 jar 包的 /META-INF/services 目录下面找到对应的文件, 读取这个文件的名称找到对应的 spi 接口, 通过 InputStream 流将文件里面的具体实现类的全类名读取出来, 根据获取到的全类名,先判断跟 spi 接口是否为同一类型,如果是的,那么就通过反射的机制构造对应的实例对象, 将构造出来的实例对象添加到 Providers 的列表中。 总结 # 其实不难发现,SPI 机制的具体实现本质上还是通过反射完成的。即:我们按照规定将要暴露对外使用的具体实现类在 META-INF/services/ 文件下声明。\n另外,SPI 机制在很多框架中都有应用:Spring 框架的基本原理也是类似的方式。还有 Dubbo 框架提供同样的 SPI 扩展机制,只不过 Dubbo 和 spring 框架中的 SPI 机制具体实现方式跟咱们今天学得这个有些细微的区别,不过整体的原理都是一致的,相信大家通过对 JDK 中 SPI 机制的学习,能够一通百通,加深对其他高深框架的理解。\n通过 SPI 机制能够大大地提高接口设计的灵活性,但是 SPI 机制也存在一些缺点,比如:\n遍历加载所有的实现类,这样效率还是相对较低的; 当多个 ServiceLoader 同时 load 时,会有并发问题。 "},{"id":502,"href":"/zh/docs/technology/Interview/java/concurrent/java-concurrent-collections/","title":"Java 常见并发容器总结","section":"Concurrent","content":"JDK 提供的这些容器大部分在 java.util.concurrent 包中。\nConcurrentHashMap : 线程安全的 HashMap CopyOnWriteArrayList : 线程安全的 List,在读多写少的场合性能非常好,远远好于 Vector。 ConcurrentLinkedQueue : 高效的并发队列,使用链表实现。可以看做一个线程安全的 LinkedList,这是一个非阻塞队列。 BlockingQueue : 这是一个接口,JDK 内部通过链表、数组等方式实现了这个接口。表示阻塞队列,非常适合用于作为数据共享的通道。 ConcurrentSkipListMap : 跳表的实现。这是一个 Map,使用跳表的数据结构进行快速查找。 ConcurrentHashMap # 我们知道,HashMap 是线程不安全的,如果在并发场景下使用,一种常见的解决方式是通过 Collections.synchronizedMap() 方法对 HashMap 进行包装,使其变为线程安全。不过,这种方式是通过一个全局锁来同步不同线程间的并发访问,会导致严重的性能瓶颈,尤其是在高并发场景下。\n为了解决这一问题,ConcurrentHashMap 应运而生,作为 HashMap 的线程安全版本,它提供了更高效的并发处理能力。\n在 JDK1.7 的时候,ConcurrentHashMap 对整个桶数组进行了分割分段(Segment,分段锁),每一把锁只锁容器其中一部分数据(下面有示意图),多线程访问容器里不同数据段的数据,就不会存在锁竞争,提高并发访问率。\n到了 JDK1.8 的时候,ConcurrentHashMap 取消了 Segment 分段锁,采用 Node + CAS + synchronized 来保证并发安全。数据结构跟 HashMap 1.8 的结构类似,数组+链表/红黑二叉树。Java 8 在链表长度超过一定阈值(8)时将链表(寻址时间复杂度为 O(N))转换为红黑树(寻址时间复杂度为 O(log(N)))。\nJava 8 中,锁粒度更细,synchronized 只锁定当前链表或红黑二叉树的首节点,这样只要 hash 不冲突,就不会产生并发,就不会影响其他 Node 的读写,效率大幅提升。\n关于 ConcurrentHashMap 的详细介绍,请看我写的这篇文章: ConcurrentHashMap 源码分析。\nCopyOnWriteArrayList # 在 JDK1.5 之前,如果想要使用并发安全的 List 只能选择 Vector。而 Vector 是一种老旧的集合,已经被淘汰。Vector 对于增删改查等方法基本都加了 synchronized,这种方式虽然能够保证同步,但这相当于对整个 Vector 加上了一把大锁,使得每个方法执行的时候都要去获得锁,导致性能非常低下。\nJDK1.5 引入了 Java.util.concurrent(JUC)包,其中提供了很多线程安全且并发性能良好的容器,其中唯一的线程安全 List 实现就是 CopyOnWriteArrayList 。\n对于大部分业务场景来说,读取操作往往是远大于写入操作的。由于读取操作不会对原有数据进行修改,因此,对于每次读取都进行加锁其实是一种资源浪费。相比之下,我们应该允许多个线程同时访问 List 的内部数据,毕竟对于读取操作来说是安全的。\n这种思路与 ReentrantReadWriteLock 读写锁的设计思想非常类似,即读读不互斥、读写互斥、写写互斥(只有读读不互斥)。CopyOnWriteArrayList 更进一步地实现了这一思想。为了将读操作性能发挥到极致,CopyOnWriteArrayList 中的读取操作是完全无需加锁的。更加厉害的是,写入操作也不会阻塞读取操作,只有写写才会互斥。这样一来,读操作的性能就可以大幅度提升。\nCopyOnWriteArrayList 线程安全的核心在于其采用了 写时复制(Copy-On-Write) 的策略,从 CopyOnWriteArrayList 的名字就能看出了。\n当需要修改( add,set、remove 等操作) CopyOnWriteArrayList 的内容时,不会直接修改原数组,而是会先创建底层数组的副本,对副本数组进行修改,修改完之后再将修改后的数组赋值回去,这样就可以保证写操作不会影响读操作了。\n关于 CopyOnWriteArrayList 的详细介绍,请看我写的这篇文章: CopyOnWriteArrayList 源码分析。\nConcurrentLinkedQueue # Java 提供的线程安全的 Queue 可以分为阻塞队列和非阻塞队列,其中阻塞队列的典型例子是 BlockingQueue,非阻塞队列的典型例子是 ConcurrentLinkedQueue,在实际应用中要根据实际需要选用阻塞队列或者非阻塞队列。 阻塞队列可以通过加锁来实现,非阻塞队列可以通过 CAS 操作实现。\n从名字可以看出,ConcurrentLinkedQueue这个队列使用链表作为其数据结构.ConcurrentLinkedQueue 应该算是在高并发环境中性能最好的队列了。它之所有能有很好的性能,是因为其内部复杂的实现。\nConcurrentLinkedQueue 内部代码我们就不分析了,大家知道 ConcurrentLinkedQueue 主要使用 CAS 非阻塞算法来实现线程安全就好了。\nConcurrentLinkedQueue 适合在对性能要求相对较高,同时对队列的读写存在多个线程同时进行的场景,即如果对队列加锁的成本较高则适合使用无锁的 ConcurrentLinkedQueue 来替代。\nBlockingQueue # BlockingQueue 简介 # 上面我们己经提到了 ConcurrentLinkedQueue 作为高性能的非阻塞队列。下面我们要讲到的是阻塞队列——BlockingQueue。阻塞队列(BlockingQueue)被广泛使用在“生产者-消费者”问题中,其原因是 BlockingQueue 提供了可阻塞的插入和移除的方法。当队列容器已满,生产者线程会被阻塞,直到队列未满;当队列容器为空时,消费者线程会被阻塞,直至队列非空时为止。\nBlockingQueue 是一个接口,继承自 Queue,所以其实现类也可以作为 Queue 的实现来使用,而 Queue 又继承自 Collection 接口。下面是 BlockingQueue 的相关实现类:\n下面主要介绍一下 3 个常见的 BlockingQueue 的实现类:ArrayBlockingQueue、LinkedBlockingQueue、PriorityBlockingQueue 。\nArrayBlockingQueue # ArrayBlockingQueue 是 BlockingQueue 接口的有界队列实现类,底层采用数组来实现。\npublic class ArrayBlockingQueue\u0026lt;E\u0026gt; extends AbstractQueue\u0026lt;E\u0026gt; implements BlockingQueue\u0026lt;E\u0026gt;, Serializable{} ArrayBlockingQueue 一旦创建,容量不能改变。其并发控制采用可重入锁 ReentrantLock ,不管是插入操作还是读取操作,都需要获取到锁才能进行操作。当队列容量满时,尝试将元素放入队列将导致操作阻塞;尝试从一个空队列中取一个元素也会同样阻塞。\nArrayBlockingQueue 默认情况下不能保证线程访问队列的公平性,所谓公平性是指严格按照线程等待的绝对时间顺序,即最先等待的线程能够最先访问到 ArrayBlockingQueue。而非公平性则是指访问 ArrayBlockingQueue 的顺序不是遵守严格的时间顺序,有可能存在,当 ArrayBlockingQueue 可以被访问时,长时间阻塞的线程依然无法访问到 ArrayBlockingQueue。如果保证公平性,通常会降低吞吐量。如果需要获得公平性的 ArrayBlockingQueue,可采用如下代码:\nprivate static ArrayBlockingQueue\u0026lt;Integer\u0026gt; blockingQueue = new ArrayBlockingQueue\u0026lt;Integer\u0026gt;(10,true); LinkedBlockingQueue # LinkedBlockingQueue 底层基于单向链表实现的阻塞队列,可以当做无界队列也可以当做有界队列来使用,同样满足 FIFO 的特性,与 ArrayBlockingQueue 相比起来具有更高的吞吐量,为了防止 LinkedBlockingQueue 容量迅速增,损耗大量内存。通常在创建 LinkedBlockingQueue 对象时,会指定其大小,如果未指定,容量等于 Integer.MAX_VALUE 。\n相关构造方法:\n/** *某种意义上的无界队列 * Creates a {@code LinkedBlockingQueue} with a capacity of * {@link Integer#MAX_VALUE}. */ public LinkedBlockingQueue() { this(Integer.MAX_VALUE); } /** *有界队列 * Creates a {@code LinkedBlockingQueue} with the given (fixed) capacity. * * @param capacity the capacity of this queue * @throws IllegalArgumentException if {@code capacity} is not greater * than zero */ public LinkedBlockingQueue(int capacity) { if (capacity \u0026lt;= 0) throw new IllegalArgumentException(); this.capacity = capacity; last = head = new Node\u0026lt;E\u0026gt;(null); } PriorityBlockingQueue # PriorityBlockingQueue 是一个支持优先级的无界阻塞队列。默认情况下元素采用自然顺序进行排序,也可以通过自定义类实现 compareTo() 方法来指定元素排序规则,或者初始化时通过构造器参数 Comparator 来指定排序规则。\nPriorityBlockingQueue 并发控制采用的是可重入锁 ReentrantLock,队列为无界队列(ArrayBlockingQueue 是有界队列,LinkedBlockingQueue 也可以通过在构造函数中传入 capacity 指定队列最大的容量,但是 PriorityBlockingQueue 只能指定初始的队列大小,后面插入元素的时候,如果空间不够的话会自动扩容)。\n简单地说,它就是 PriorityQueue 的线程安全版本。不可以插入 null 值,同时,插入队列的对象必须是可比较大小的(comparable),否则报 ClassCastException 异常。它的插入操作 put 方法不会 block,因为它是无界队列(take 方法在队列为空的时候会阻塞)。\n推荐文章: 《解读 Java 并发队列 BlockingQueue》\nConcurrentSkipListMap # 下面这部分内容参考了极客时间专栏 《数据结构与算法之美》以及《实战 Java 高并发程序设计》。\n为了引出 ConcurrentSkipListMap,先带着大家简单理解一下跳表。\n对于一个单链表,即使链表是有序的,如果我们想要在其中查找某个数据,也只能从头到尾遍历链表,这样效率自然就会很低,跳表就不一样了。跳表是一种可以用来快速查找的数据结构,有点类似于平衡树。它们都可以对元素进行快速的查找。但一个重要的区别是:对平衡树的插入和删除往往很可能导致平衡树进行一次全局的调整。而对跳表的插入和删除只需要对整个数据结构的局部进行操作即可。这样带来的好处是:在高并发的情况下,你会需要一个全局锁来保证整个平衡树的线程安全。而对于跳表,你只需要部分锁即可。这样,在高并发环境下,你就可以拥有更好的性能。而就查询的性能而言,跳表的时间复杂度也是 O(logn) 所以在并发数据结构中,JDK 使用跳表来实现一个 Map。\n跳表的本质是同时维护了多个链表,并且链表是分层的,\n最低层的链表维护了跳表内所有的元素,每上面一层链表都是下面一层的子集。\n跳表内的所有链表的元素都是排序的。查找时,可以从顶级链表开始找。一旦发现被查找的元素大于当前链表中的取值,就会转入下一层链表继续找。这也就是说在查找过程中,搜索是跳跃式的。如上图所示,在跳表中查找元素 18。\n查找 18 的时候原来需要遍历 18 次,现在只需要 7 次即可。针对链表长度比较大的时候,构建索引查找效率的提升就会非常明显。\n从上面很容易看出,跳表是一种利用空间换时间的算法。\n使用跳表实现 Map 和使用哈希算法实现 Map 的另外一个不同之处是:哈希并不会保存元素的顺序,而跳表内所有的元素都是排序的。因此在对跳表进行遍历时,你会得到一个有序的结果。所以,如果你的应用需要有序性,那么跳表就是你不二的选择。JDK 中实现这一数据结构的类是 ConcurrentSkipListMap。\n参考 # 《实战 Java 高并发程序设计》 https://javadoop.com/post/java-concurrent-queue https://juejin.im/post/5aeebd02518825672f19c546 "},{"id":503,"href":"/zh/docs/technology/Interview/java/basis/proxy/","title":"Java 代理模式详解","section":"Basis","content":" 1. 代理模式 # 代理模式是一种比较好理解的设计模式。简单来说就是 我们使用代理对象来代替对真实对象(real object)的访问,这样就可以在不修改原目标对象的前提下,提供额外的功能操作,扩展目标对象的功能。\n代理模式的主要作用是扩展目标对象的功能,比如说在目标对象的某个方法执行前后你可以增加一些自定义的操作。\n举个例子:新娘找来了自己的姨妈来代替自己处理新郎的提问,新娘收到的提问都是经过姨妈处理过滤之后的。姨妈在这里就可以看作是代理你的代理对象,代理的行为(方法)是接收和回复新郎的提问。\nhttps://medium.com/@mithunsasidharan/understanding-the-proxy-design-pattern-5e63fe38052a\n代理模式有静态代理和动态代理两种实现方式,我们 先来看一下静态代理模式的实现。\n2. 静态代理 # 静态代理中,我们对目标对象的每个方法的增强都是手动完成的(后面会具体演示代码),非常不灵活(比如接口一旦新增加方法,目标对象和代理对象都要进行修改)且麻烦(需要对每个目标类都单独写一个代理类)。 实际应用场景非常非常少,日常开发几乎看不到使用静态代理的场景。\n上面我们是从实现和应用角度来说的静态代理,从 JVM 层面来说, 静态代理在编译时就将接口、实现类、代理类这些都变成了一个个实际的 class 文件。\n静态代理实现步骤:\n定义一个接口及其实现类; 创建一个代理类同样实现这个接口 将目标对象注入进代理类,然后在代理类的对应方法调用目标类中的对应方法。这样的话,我们就可以通过代理类屏蔽对目标对象的访问,并且可以在目标方法执行前后做一些自己想做的事情。 下面通过代码展示!\n1.定义发送短信的接口\npublic interface SmsService { String send(String message); } 2.实现发送短信的接口\npublic class SmsServiceImpl implements SmsService { public String send(String message) { System.out.println(\u0026#34;send message:\u0026#34; + message); return message; } } 3.创建代理类并同样实现发送短信的接口\npublic class SmsProxy implements SmsService { private final SmsService smsService; public SmsProxy(SmsService smsService) { this.smsService = smsService; } @Override public String send(String message) { //调用方法之前,我们可以添加自己的操作 System.out.println(\u0026#34;before method send()\u0026#34;); smsService.send(message); //调用方法之后,我们同样可以添加自己的操作 System.out.println(\u0026#34;after method send()\u0026#34;); return null; } } 4.实际使用\npublic class Main { public static void main(String[] args) { SmsService smsService = new SmsServiceImpl(); SmsProxy smsProxy = new SmsProxy(smsService); smsProxy.send(\u0026#34;java\u0026#34;); } } 运行上述代码之后,控制台打印出:\nbefore method send() send message:java after method send() 可以输出结果看出,我们已经增加了 SmsServiceImpl 的send()方法。\n3. 动态代理 # 相比于静态代理来说,动态代理更加灵活。我们不需要针对每个目标类都单独创建一个代理类,并且也不需要我们必须实现接口,我们可以直接代理实现类( CGLIB 动态代理机制)。\n从 JVM 角度来说,动态代理是在运行时动态生成类字节码,并加载到 JVM 中的。\n说到动态代理,Spring AOP、RPC 框架应该是两个不得不提的,它们的实现都依赖了动态代理。\n动态代理在我们日常开发中使用的相对较少,但是在框架中的几乎是必用的一门技术。学会了动态代理之后,对于我们理解和学习各种框架的原理也非常有帮助。\n就 Java 来说,动态代理的实现方式有很多种,比如 JDK 动态代理、CGLIB 动态代理等等。\nguide-rpc-framework 使用的是 JDK 动态代理,我们先来看看 JDK 动态代理的使用。\n另外,虽然 guide-rpc-framework 没有用到 CGLIB 动态代理 ,我们这里还是简单介绍一下其使用以及和JDK 动态代理的对比。\n3.1. JDK 动态代理机制 # 3.1.1. 介绍 # 在 Java 动态代理机制中 InvocationHandler 接口和 Proxy 类是核心。\nProxy 类中使用频率最高的方法是:newProxyInstance() ,这个方法主要用来生成一个代理对象。\npublic static Object newProxyInstance(ClassLoader loader, Class\u0026lt;?\u0026gt;[] interfaces, InvocationHandler h) throws IllegalArgumentException { ...... } 这个方法一共有 3 个参数:\nloader :类加载器,用于加载代理对象。 interfaces : 被代理类实现的一些接口; h : 实现了 InvocationHandler 接口的对象; 要实现动态代理的话,还必须需要实现InvocationHandler 来自定义处理逻辑。 当我们的动态代理对象调用一个方法时,这个方法的调用就会被转发到实现InvocationHandler 接口类的 invoke 方法来调用。\npublic interface InvocationHandler { /** * 当你使用代理对象调用方法的时候实际会调用到这个方法 */ public Object invoke(Object proxy, Method method, Object[] args) throws Throwable; } invoke() 方法有下面三个参数:\nproxy :动态生成的代理类 method : 与代理类对象调用的方法相对应 args : 当前 method 方法的参数 也就是说:你通过Proxy 类的 newProxyInstance() 创建的代理对象在调用方法的时候,实际会调用到实现InvocationHandler 接口的类的 invoke()方法。 你可以在 invoke() 方法中自定义处理逻辑,比如在方法执行前后做什么事情。\n3.1.2. JDK 动态代理类使用步骤 # 定义一个接口及其实现类; 自定义 InvocationHandler 并重写invoke方法,在 invoke 方法中我们会调用原生方法(被代理类的方法)并自定义一些处理逻辑; 通过 Proxy.newProxyInstance(ClassLoader loader,Class\u0026lt;?\u0026gt;[] interfaces,InvocationHandler h) 方法创建代理对象; 3.1.3. 代码示例 # 这样说可能会有点空洞和难以理解,我上个例子,大家感受一下吧!\n1.定义发送短信的接口\npublic interface SmsService { String send(String message); } 2.实现发送短信的接口\npublic class SmsServiceImpl implements SmsService { public String send(String message) { System.out.println(\u0026#34;send message:\u0026#34; + message); return message; } } 3.定义一个 JDK 动态代理类\nimport java.lang.reflect.InvocationHandler; import java.lang.reflect.InvocationTargetException; import java.lang.reflect.Method; /** * @author shuang.kou * @createTime 2020年05月11日 11:23:00 */ public class DebugInvocationHandler implements InvocationHandler { /** * 代理类中的真实对象 */ private final Object target; public DebugInvocationHandler(Object target) { this.target = target; } @Override public Object invoke(Object proxy, Method method, Object[] args) throws InvocationTargetException, IllegalAccessException { //调用方法之前,我们可以添加自己的操作 System.out.println(\u0026#34;before method \u0026#34; + method.getName()); Object result = method.invoke(target, args); //调用方法之后,我们同样可以添加自己的操作 System.out.println(\u0026#34;after method \u0026#34; + method.getName()); return result; } } invoke() 方法: 当我们的动态代理对象调用原生方法的时候,最终实际上调用到的是 invoke() 方法,然后 invoke() 方法代替我们去调用了被代理对象的原生方法。\n4.获取代理对象的工厂类\npublic class JdkProxyFactory { public static Object getProxy(Object target) { return Proxy.newProxyInstance( target.getClass().getClassLoader(), // 目标类的类加载器 target.getClass().getInterfaces(), // 代理需要实现的接口,可指定多个 new DebugInvocationHandler(target) // 代理对象对应的自定义 InvocationHandler ); } } getProxy():主要通过Proxy.newProxyInstance()方法获取某个类的代理对象\n5.实际使用\nSmsService smsService = (SmsService) JdkProxyFactory.getProxy(new SmsServiceImpl()); smsService.send(\u0026#34;java\u0026#34;); 运行上述代码之后,控制台打印出:\nbefore method send send message:java after method send 3.2. CGLIB 动态代理机制 # 3.2.1. 介绍 # JDK 动态代理有一个最致命的问题是其只能代理实现了接口的类。\n为了解决这个问题,我们可以用 CGLIB 动态代理机制来避免。\nCGLIB(Code Generation Library)是一个基于 ASM的字节码生成库,它允许我们在运行时对字节码进行修改和动态生成。CGLIB 通过继承方式实现代理。很多知名的开源框架都使用到了 CGLIB, 例如 Spring 中的 AOP 模块中:如果目标对象实现了接口,则默认采用 JDK 动态代理,否则采用 CGLIB 动态代理。\n在 CGLIB 动态代理机制中 MethodInterceptor 接口和 Enhancer 类是核心。\n你需要自定义 MethodInterceptor 并重写 intercept 方法,intercept 用于拦截增强被代理类的方法。\npublic interface MethodInterceptor extends Callback{ // 拦截被代理类中的方法 public Object intercept(Object obj, java.lang.reflect.Method method, Object[] args,MethodProxy proxy) throws Throwable; } obj : 被代理的对象(需要增强的对象) method : 被拦截的方法(需要增强的方法) args : 方法入参 proxy : 用于调用原始方法 你可以通过 Enhancer类来动态获取被代理类,当代理类调用方法的时候,实际调用的是 MethodInterceptor 中的 intercept 方法。\n3.2.2. CGLIB 动态代理类使用步骤 # 定义一个类; 自定义 MethodInterceptor 并重写 intercept 方法,intercept 用于拦截增强被代理类的方法,和 JDK 动态代理中的 invoke 方法类似; 通过 Enhancer 类的 create()创建代理类; 3.2.3. 代码示例 # 不同于 JDK 动态代理不需要额外的依赖。 CGLIB(Code Generation Library) 实际是属于一个开源项目,如果你要使用它的话,需要手动添加相关依赖。\n\u0026lt;dependency\u0026gt; \u0026lt;groupId\u0026gt;cglib\u0026lt;/groupId\u0026gt; \u0026lt;artifactId\u0026gt;cglib\u0026lt;/artifactId\u0026gt; \u0026lt;version\u0026gt;3.3.0\u0026lt;/version\u0026gt; \u0026lt;/dependency\u0026gt; 1.实现一个使用阿里云发送短信的类\npackage github.javaguide.dynamicProxy.cglibDynamicProxy; public class AliSmsService { public String send(String message) { System.out.println(\u0026#34;send message:\u0026#34; + message); return message; } } 2.自定义 MethodInterceptor(方法拦截器)\nimport net.sf.cglib.proxy.MethodInterceptor; import net.sf.cglib.proxy.MethodProxy; import java.lang.reflect.Method; /** * 自定义MethodInterceptor */ public class DebugMethodInterceptor implements MethodInterceptor { /** * @param o 被代理的对象(需要增强的对象) * @param method 被拦截的方法(需要增强的方法) * @param args 方法入参 * @param methodProxy 用于调用原始方法 */ @Override public Object intercept(Object o, Method method, Object[] args, MethodProxy methodProxy) throws Throwable { //调用方法之前,我们可以添加自己的操作 System.out.println(\u0026#34;before method \u0026#34; + method.getName()); Object object = methodProxy.invokeSuper(o, args); //调用方法之后,我们同样可以添加自己的操作 System.out.println(\u0026#34;after method \u0026#34; + method.getName()); return object; } } 3.获取代理类\nimport net.sf.cglib.proxy.Enhancer; public class CglibProxyFactory { public static Object getProxy(Class\u0026lt;?\u0026gt; clazz) { // 创建动态代理增强类 Enhancer enhancer = new Enhancer(); // 设置类加载器 enhancer.setClassLoader(clazz.getClassLoader()); // 设置被代理类 enhancer.setSuperclass(clazz); // 设置方法拦截器 enhancer.setCallback(new DebugMethodInterceptor()); // 创建代理类 return enhancer.create(); } } 4.实际使用\nAliSmsService aliSmsService = (AliSmsService) CglibProxyFactory.getProxy(AliSmsService.class); aliSmsService.send(\u0026#34;java\u0026#34;); 运行上述代码之后,控制台打印出:\nbefore method send send message:java after method send 3.3. JDK 动态代理和 CGLIB 动态代理对比 # JDK 动态代理只能代理实现了接口的类或者直接代理接口,而 CGLIB 可以代理未实现任何接口的类。 另外, CGLIB 动态代理是通过生成一个被代理类的子类来拦截被代理类的方法调用,因此不能代理声明为 final 类型的类和方法。 就二者的效率来说,大部分情况都是 JDK 动态代理更优秀,随着 JDK 版本的升级,这个优势更加明显。 4. 静态代理和动态代理的对比 # 灵活性:动态代理更加灵活,不需要必须实现接口,可以直接代理实现类,并且可以不需要针对每个目标类都创建一个代理类。另外,静态代理中,接口一旦新增加方法,目标对象和代理对象都要进行修改,这是非常麻烦的! JVM 层面:静态代理在编译时就将接口、实现类、代理类这些都变成了一个个实际的 class 文件。而动态代理是在运行时动态生成类字节码,并加载到 JVM 中的。 5. 总结 # 这篇文章中主要介绍了代理模式的两种实现:静态代理以及动态代理。涵盖了静态代理和动态代理实战、静态代理和动态代理的区别、JDK 动态代理和 Cglib 动态代理区别等内容。\n文中涉及到的所有源码,你可以在这里找到: https://github.com/Snailclimb/guide-rpc-framework-learning/tree/master/src/main/java/github/javaguide/proxy 。\n"},{"id":504,"href":"/zh/docs/technology/Interview/system-design/schedule-task/","title":"Java 定时任务详解","section":"System Design","content":" 为什么需要定时任务? # 我们来看一下几个非常常见的业务场景:\n某系统凌晨 1 点要进行数据备份。 某电商平台,用户下单半个小时未支付的情况下需要自动取消订单。 某媒体聚合平台,每 10 分钟动态抓取某某网站的数据为自己所用。 某博客平台,支持定时发送文章。 某基金平台,每晚定时计算用户当日收益情况并推送给用户最新的数据。 …… 这些场景往往都要求我们在某个特定的时间去做某个事情,也就是定时或者延时去做某个事情。\n定时任务:在指定时间点执行特定的任务,例如每天早上 8 点,每周一下午 3 点等。定时任务可以用来做一些周期性的工作,如数据备份,日志清理,报表生成等。 延时任务:一定的延迟时间后执行特定的任务,例如 10 分钟后,3 小时后等。延时任务可以用来做一些异步的工作,如订单取消,推送通知,红包撤回等。 尽管二者的适用场景有所区别,但它们的核心思想都是将任务的执行时间安排在未来的某个点上,以达到预期的调度效果。\n单机定时任务 # Timer # java.util.Timer是 JDK 1.3 开始就已经支持的一种定时任务的实现方式。\nTimer 内部使用一个叫做 TaskQueue 的类存放定时任务,它是一个基于最小堆实现的优先级队列。TaskQueue 会按照任务距离下一次执行时间的大小将任务排序,保证在堆顶的任务最先执行。这样在需要执行任务时,每次只需要取出堆顶的任务运行即可!\nTimer 使用起来比较简单,通过下面的方式我们就能创建一个 1s 之后执行的定时任务。\n// 示例代码: TimerTask task = new TimerTask() { public void run() { System.out.println(\u0026#34;当前时间: \u0026#34; + new Date() + \u0026#34;n\u0026#34; + \u0026#34;线程名称: \u0026#34; + Thread.currentThread().getName()); } }; System.out.println(\u0026#34;当前时间: \u0026#34; + new Date() + \u0026#34;n\u0026#34; + \u0026#34;线程名称: \u0026#34; + Thread.currentThread().getName()); Timer timer = new Timer(\u0026#34;Timer\u0026#34;); long delay = 1000L; timer.schedule(task, delay); //输出: 当前时间: Fri May 28 15:18:47 CST 2021n线程名称: main 当前时间: Fri May 28 15:18:48 CST 2021n线程名称: Timer 不过其缺陷较多,比如一个 Timer 一个线程,这就导致 Timer 的任务的执行只能串行执行,一个任务执行时间过长的话会影响其他任务(性能非常差),再比如发生异常时任务直接停止(Timer 只捕获了 InterruptedException )。\nTimer 类上的有一段注释是这样写的:\n* This class does not offer real-time guarantees: it schedules * tasks using the \u0026lt;tt\u0026gt;Object.wait(long)\u0026lt;/tt\u0026gt; method. *Java 5.0 introduced the {@code java.util.concurrent} package and * one of the concurrency utilities therein is the {@link * java.util.concurrent.ScheduledThreadPoolExecutor * ScheduledThreadPoolExecutor} which is a thread pool for repeatedly * executing tasks at a given rate or delay. It is effectively a more * versatile replacement for the {@code Timer}/{@code TimerTask} * combination, as it allows multiple service threads, accepts various * time units, and doesn\u0026#39;t require subclassing {@code TimerTask} (just * implement {@code Runnable}). Configuring {@code * ScheduledThreadPoolExecutor} with one thread makes it equivalent to * {@code Timer}. 大概的意思就是:ScheduledThreadPoolExecutor 支持多线程执行定时任务并且功能更强大,是 Timer 的替代品。\nScheduledExecutorService # ScheduledExecutorService 是一个接口,有多个实现类,比较常用的是 ScheduledThreadPoolExecutor 。\nScheduledThreadPoolExecutor 本身就是一个线程池,支持任务并发执行。并且,其内部使用 DelayedWorkQueue 作为任务队列。\n// 示例代码: TimerTask repeatedTask = new TimerTask() { @SneakyThrows public void run() { System.out.println(\u0026#34;当前时间: \u0026#34; + new Date() + \u0026#34;n\u0026#34; + \u0026#34;线程名称: \u0026#34; + Thread.currentThread().getName()); } }; System.out.println(\u0026#34;当前时间: \u0026#34; + new Date() + \u0026#34;n\u0026#34; + \u0026#34;线程名称: \u0026#34; + Thread.currentThread().getName()); ScheduledExecutorService executor = Executors.newScheduledThreadPool(3); long delay = 1000L; long period = 1000L; executor.scheduleAtFixedRate(repeatedTask, delay, period, TimeUnit.MILLISECONDS); Thread.sleep(delay + period * 5); executor.shutdown(); //输出: 当前时间: Fri May 28 15:40:46 CST 2021n线程名称: main 当前时间: Fri May 28 15:40:47 CST 2021n线程名称: pool-1-thread-1 当前时间: Fri May 28 15:40:48 CST 2021n线程名称: pool-1-thread-1 当前时间: Fri May 28 15:40:49 CST 2021n线程名称: pool-1-thread-2 当前时间: Fri May 28 15:40:50 CST 2021n线程名称: pool-1-thread-2 当前时间: Fri May 28 15:40:51 CST 2021n线程名称: pool-1-thread-2 当前时间: Fri May 28 15:40:52 CST 2021n线程名称: pool-1-thread-2 不论是使用 Timer 还是 ScheduledExecutorService 都无法使用 Cron 表达式指定任务执行的具体时间。\nDelayQueue # DelayQueue 是 JUC 包(java.util.concurrent)为我们提供的延迟队列,用于实现延时任务比如订单下单 15 分钟未支付直接取消。它是 BlockingQueue 的一种,底层是一个基于 PriorityQueue 实现的一个无界队列,是线程安全的。关于PriorityQueue可以参考笔者编写的这篇文章: PriorityQueue 源码分析 。\nDelayQueue 和 Timer/TimerTask 都可以用于实现定时任务调度,但是它们的实现方式不同。DelayQueue 是基于优先级队列和堆排序算法实现的,可以实现多个任务按照时间先后顺序执行;而 Timer/TimerTask 是基于单线程实现的,只能按照任务的执行顺序依次执行,如果某个任务执行时间过长,会影响其他任务的执行。另外,DelayQueue 还支持动态添加和移除任务,而 Timer/TimerTask 只能在创建时指定任务。\n关于 DelayQueue 的详细介绍,请参考我写的这篇文章: DelayQueue 源码分析。\nSpring Task # 我们直接通过 Spring 提供的 @Scheduled 注解即可定义定时任务,非常方便!\n/** * cron:使用Cron表达式。 每分钟的1,2秒运行 */ @Scheduled(cron = \u0026#34;1-2 * * * * ? \u0026#34;) public void reportCurrentTimeWithCronExpression() { log.info(\u0026#34;Cron Expression: The time is now {}\u0026#34;, dateFormat.format(new Date())); } 我在大学那会做的一个 SSM 的企业级项目,就是用的 Spring Task 来做的定时任务。\n并且,Spring Task 还是支持 Cron 表达式 的。Cron 表达式主要用于定时作业(定时任务)系统定义执行时间或执行频率的表达式,非常厉害,你可以通过 Cron 表达式进行设置定时任务每天或者每个月什么时候执行等等操作。咱们要学习定时任务的话,Cron 表达式是一定是要重点关注的。推荐一个在线 Cron 表达式生成器: http://cron.qqe2.com/ 。\n但是,Spring 自带的定时调度只支持单机,并且提供的功能比较单一。之前写过一篇文章: 《5 分钟搞懂如何在 Spring Boot 中 Schedule Tasks》 ,不了解的小伙伴可以参考一下。\nSpring Task 底层是基于 JDK 的 ScheduledThreadPoolExecutor 线程池来实现的。\n优缺点总结:\n优点:简单,轻量,支持 Cron 表达式 缺点:功能单一 时间轮 # Kafka、Dubbo、ZooKeeper、Netty、Caffeine、Akka 中都有对时间轮的实现。\n时间轮简单来说就是一个环形的队列(底层一般基于数组实现),队列中的每一个元素(时间格)都可以存放一个定时任务列表。\n时间轮中的每个时间格代表了时间轮的基本时间跨度或者说时间精度,假如时间一秒走一个时间格的话,那么这个时间轮的最高精度就是 1 秒(也就是说 3 s 和 3.9s 会在同一个时间格中)。\n下图是一个有 12 个时间格的时间轮,转完一圈需要 12 s。当我们需要新建一个 3s 后执行的定时任务,只需要将定时任务放在下标为 3 的时间格中即可。当我们需要新建一个 9s 后执行的定时任务,只需要将定时任务放在下标为 9 的时间格中即可。\n那当我们需要创建一个 13s 后执行的定时任务怎么办呢?这个时候可以引入一叫做 圈数/轮数 的概念,也就是说这个任务还是放在下标为 1 的时间格中, 不过它的圈数为 2 。\n除了增加圈数这种方法之外,还有一种 多层次时间轮 (类似手表),Kafka 采用的就是这种方案。\n针对下图的时间轮,我来举一个例子便于大家理解。\n上图的时间轮(ms -\u0026gt; s),第 1 层的时间精度为 1 ,第 2 层的时间精度为 20 ,第 3 层的时间精度为 400。假如我们需要添加一个 350s 后执行的任务 A 的话(当前时间是 0s),这个任务会被放在第 2 层(因为第二层的时间跨度为 20*20=400\u0026gt;350)的第 350/20=17 个时间格子。\n当第一层转了 17 圈之后,时间过去了 340s ,第 2 层的指针此时来到第 17 个时间格子。此时,第 2 层第 17 个格子的任务会被移动到第 1 层。\n任务 A 当前是 10s 之后执行,因此它会被移动到第 1 层的第 10 个时间格子。\n这里在层与层之间的移动也叫做时间轮的升降级。参考手表来理解就好!\n时间轮比较适合任务数量比较多的定时任务场景,它的任务写入和执行的时间复杂度都是 0(1)。\n分布式定时任务 # Redis # Redis 是可以用来做延时任务的,基于 Redis 实现延时任务的功能无非就下面两种方案:\nRedis 过期事件监听 Redisson 内置的延时队列 这部分内容的详细介绍我放在了 《后端面试高频系统设计\u0026amp;场景题》中,有需要的同学可以进入星球后阅读学习。篇幅太多,这里就不重复分享了。\nMQ # 大部分消息队列,例如 RocketMQ、RabbitMQ,都支持定时/延时消息。定时消息和延时消息本质其实是相同的,都是服务端根据消息设置的定时时间在某一固定时刻将消息投递给消费者消费。\n不过,在使用 MQ 定时消息之前一定要看清楚其使用限制,以免不适合项目需求,例如 RocketMQ 定时时长最大值默认为 24 小时且不支持自定义修改、只支持 18 个 Level 的延时并不支持任意时间。\n优缺点总结:\n优点:可以与 Spring 集成、支持分布式、支持集群、性能不错 缺点:功能性较差、不灵活、需要保障消息可靠性 分布式任务调度框架 # 如果我们需要一些高级特性比如支持任务在分布式场景下的分片和高可用的话,我们就需要用到分布式任务调度框架了。\n通常情况下,一个分布式定时任务的执行往往涉及到下面这些角色:\n任务:首先肯定是要执行的任务,这个任务就是具体的业务逻辑比如定时发送文章。 调度器:其次是调度中心,调度中心主要负责任务管理,会分配任务给执行器。 执行器:最后就是执行器,执行器接收调度器分派的任务并执行。 Quartz # 一个很火的开源任务调度框架,完全由 Java 写成。Quartz 可以说是 Java 定时任务领域的老大哥或者说参考标准,其他的任务调度框架基本都是基于 Quartz 开发的,比如当当网的elastic-job就是基于 Quartz 二次开发之后的分布式调度解决方案。\n使用 Quartz 可以很方便地与 Spring 集成,并且支持动态添加任务和集群。但是,Quartz 使用起来也比较麻烦,API 繁琐。\n并且,Quartz 并没有内置 UI 管理控制台,不过你可以使用 quartzui 这个开源项目来解决这个问题。\n另外,Quartz 虽然也支持分布式任务。但是,它是在数据库层面,通过数据库的锁机制做的,有非常多的弊端比如系统侵入性严重、节点负载不均衡。有点伪分布式的味道。\n优缺点总结:\n优点:可以与 Spring 集成,并且支持动态添加任务和集群。 缺点:分布式支持不友好,不支持任务可视化管理、使用麻烦(相比于其他同类型框架来说) Elastic-Job # ElasticJob 当当网开源的一个面向互联网生态和海量任务的分布式调度解决方案,由两个相互独立的子项目 ElasticJob-Lite 和 ElasticJob-Cloud 组成。\nElasticJob-Lite 和 ElasticJob-Cloud 两者的对比如下:\nElasticJob-Lite ElasticJob-Cloud 无中心化 是 否 资源分配 不支持 支持 作业模式 常驻 常驻 + 瞬时 部署依赖 ZooKeeper ZooKeeper + Mesos ElasticJob 支持任务在分布式场景下的分片和高可用、任务可视化管理等功能。\nElasticJob-Lite 的架构设计如下图所示:\n从上图可以看出,Elastic-Job 没有调度中心这一概念,而是使用 ZooKeeper 作为注册中心,注册中心负责协调分配任务到不同的节点上。\nElastic-Job 中的定时调度都是由执行器自行触发,这种设计也被称为去中心化设计(调度和处理都是执行器单独完成)。\n@Component @ElasticJobConf(name = \u0026#34;dayJob\u0026#34;, cron = \u0026#34;0/10 * * * * ?\u0026#34;, shardingTotalCount = 2, shardingItemParameters = \u0026#34;0=AAAA,1=BBBB\u0026#34;, description = \u0026#34;简单任务\u0026#34;, failover = true) public class TestJob implements SimpleJob { @Override public void execute(ShardingContext shardingContext) { log.info(\u0026#34;TestJob任务名:【{}】, 片数:【{}】, param=【{}】\u0026#34;, shardingContext.getJobName(), shardingContext.getShardingTotalCount(), shardingContext.getShardingParameter()); } } 相关地址:\nGitHub 地址: https://github.com/apache/shardingsphere-elasticjob。 官方网站: https://shardingsphere.apache.org/elasticjob/index_zh.html 。 优缺点总结:\n优点:可以与 Spring 集成、支持分布式、支持集群、性能不错、支持任务可视化管理 缺点:依赖了额外的中间件比如 Zookeeper(复杂度增加,可靠性降低、维护成本变高) XXL-JOB # XXL-JOB 于 2015 年开源,是一款优秀的轻量级分布式任务调度框架,支持任务可视化管理、弹性扩容缩容、任务失败重试和告警、任务分片等功能,\n根据 XXL-JOB 官网介绍,其解决了很多 Quartz 的不足。\nQuartz 作为开源作业调度中的佼佼者,是作业调度的首选。但是集群环境中 Quartz 采用 API 的方式对任务进行管理,从而可以避免上述问题,但是同样存在以下问题:\n问题一:调用 API 的的方式操作任务,不人性化; 问题二:需要持久化业务 QuartzJobBean 到底层数据表中,系统侵入性相当严重。 问题三:调度逻辑和 QuartzJobBean 耦合在同一个项目中,这将导致一个问题,在调度任务数量逐渐增多,同时调度任务逻辑逐渐加重的情况下,此时调度系统的性能将大大受限于业务; 问题四:quartz 底层以“抢占式”获取 DB 锁并由抢占成功节点负责运行任务,会导致节点负载悬殊非常大;而 XXL-JOB 通过执行器实现“协同分配式”运行任务,充分发挥集群优势,负载各节点均衡。 XXL-JOB 弥补了 quartz 的上述不足之处。\nXXL-JOB 的架构设计如下图所示:\n从上图可以看出,XXL-JOB 由 调度中心 和 执行器 两大部分组成。调度中心主要负责任务管理、执行器管理以及日志管理。执行器主要是接收调度信号并处理。另外,调度中心进行任务调度时,是通过自研 RPC 来实现的。\n不同于 Elastic-Job 的去中心化设计, XXL-JOB 的这种设计也被称为中心化设计(调度中心调度多个执行器执行任务)。\n和 Quzrtz 类似 XXL-JOB 也是基于数据库锁调度任务,存在性能瓶颈。不过,一般在任务量不是特别大的情况下,没有什么影响的,可以满足绝大部分公司的要求。\n不要被 XXL-JOB 的架构图给吓着了,实际上,我们要用 XXL-JOB 的话,只需要重写 IJobHandler 自定义任务执行逻辑就可以了,非常易用!\n@JobHandler(value=\u0026#34;myApiJobHandler\u0026#34;) @Component public class MyApiJobHandler extends IJobHandler { @Override public ReturnT\u0026lt;String\u0026gt; execute(String param) throws Exception { //...... return ReturnT.SUCCESS; } } 还可以直接基于注解定义任务。\n@XxlJob(\u0026#34;myAnnotationJobHandler\u0026#34;) public ReturnT\u0026lt;String\u0026gt; myAnnotationJobHandler(String param) throws Exception { //...... return ReturnT.SUCCESS; } 相关地址:\nGitHub 地址: https://github.com/xuxueli/xxl-job/。 官方介绍: https://www.xuxueli.com/xxl-job/ 。 优缺点总结:\n优点:开箱即用(学习成本比较低)、与 Spring 集成、支持分布式、支持集群、支持任务可视化管理。 缺点:不支持动态添加任务(如果一定想要动态创建任务也是支持的,参见: xxl-job issue277)。 PowerJob # 非常值得关注的一个分布式任务调度框架,分布式任务调度领域的新星。目前,已经有很多公司接入比如 OPPO、京东、中通、思科。\n这个框架的诞生也挺有意思的,PowerJob 的作者当时在阿里巴巴实习过,阿里巴巴那会使用的是内部自研的 SchedulerX(阿里云付费产品)。实习期满之后,PowerJob 的作者离开了阿里巴巴。想着说自研一个 SchedulerX,防止哪天 SchedulerX 满足不了需求,于是 PowerJob 就诞生了。\n更多关于 PowerJob 的故事,小伙伴们可以去看看 PowerJob 作者的视频 《我和我的任务调度中间件》。简单点概括就是:“游戏没啥意思了,我要扛起了新一代分布式任务调度与计算框架的大旗!”。\n由于 SchedulerX 属于人民币产品,我这里就不过多介绍。PowerJob 官方也对比过其和 QuartZ、XXL-JOB 以及 SchedulerX。\nQuartZ xxl-job SchedulerX 2.0 PowerJob 定时类型 CRON CRON CRON、固定频率、固定延迟、OpenAPI CRON、固定频率、固定延迟、OpenAPI 任务类型 内置 Java 内置 Java、GLUE Java、Shell、Python 等脚本 内置 Java、外置 Java(FatJar)、Shell、Python 等脚本 内置 Java、外置 Java(容器)、Shell、Python 等脚本 分布式计算 无 静态分片 MapReduce 动态分片 MapReduce 动态分片 在线任务治理 不支持 支持 支持 支持 日志白屏化 不支持 支持 不支持 支持 调度方式及性能 基于数据库锁,有性能瓶颈 基于数据库锁,有性能瓶颈 不详 无锁化设计,性能强劲无上限 报警监控 无 邮件 短信 WebHook、邮件、钉钉与自定义扩展 系统依赖 JDBC 支持的关系型数据库(MySQL、Oracle\u0026hellip;) MySQL 人民币 任意 Spring Data Jpa 支持的关系型数据库(MySQL、Oracle\u0026hellip;) DAG 工作流 不支持 不支持 支持 支持 定时任务方案总结 # 单机定时任务的常见解决方案有 Timer、ScheduledExecutorService、DelayQueue、Spring Task 和时间轮,其中最常用也是比较推荐使用的是时间轮。另外,这几种单机定时任务解决方案同样可以实现延时任务。\nRedis 和 MQ 虽然可以实现分布式定时任务,但这两者本身不是专门用来做分布式定时任务的,它们并不提供较为完整和强大的分布式定时任务的功能。而且,两者不太适合执行周期性的定时任务,因为它们只能保证消息被消费一次,而不能保证消息被消费多次。因此,它们更适合执行一次性的延时任务,例如订单取消、红包撤回。实际项目中,MQ 延时任务用的更多一些,可以降低业务之间的耦合度。\nQuartz、Elastic-Job、XXL-JOB 和 PowerJob 这几个是专门用来做分布式调度的框架,提供的分布式定时任务的功能更为完善和强大,更加适合执行周期性的定时任务。除了 Quartz 之外,另外三者都是支持任务可视化管理的。\nXXL-JOB 2015 年推出,已经经过了很多年的考验。XXL-JOB 轻量级,并且使用起来非常简单。虽然存在性能瓶颈,但是,在绝大多数情况下,对于企业的基本需求来说是没有影响的。PowerJob 属于分布式任务调度领域里的新星,其稳定性还有待继续考察。ElasticJob 由于在架构设计上是基于 Zookeeper ,而 XXL-JOB 是基于数据库,性能方面的话,ElasticJob 略胜一筹。\n这篇文章并没有介绍到实际使用,但是,并不代表实际使用不重要。我在写这篇文章之前,已经动手写过相应的 Demo。像 Quartz,我在大学那会就用过。不过,当时用的是 Spring 。为了能够更好地体验,我自己又在 Spring Boot 上实际体验了一下。如果你并没有实际使用某个框架,就直接说它并不好用的话,是站不住脚的。\n"},{"id":505,"href":"/zh/docs/technology/Interview/java/basis/reflection/","title":"Java 反射机制详解","section":"Basis","content":" 何为反射? # 如果说大家研究过框架的底层原理或者咱们自己写过框架的话,一定对反射这个概念不陌生。\n反射之所以被称为框架的灵魂,主要是因为它赋予了我们在运行时分析类以及执行类中方法的能力。\n通过反射你可以获取任意一个类的所有属性和方法,你还可以调用这些方法和属性。\n反射的应用场景了解么? # 像咱们平时大部分时候都是在写业务代码,很少会接触到直接使用反射机制的场景。\n但是,这并不代表反射没有用。相反,正是因为反射,你才能这么轻松地使用各种框架。像 Spring/Spring Boot、MyBatis 等等框架中都大量使用了反射机制。\n这些框架中也大量使用了动态代理,而动态代理的实现也依赖反射。\n比如下面是通过 JDK 实现动态代理的示例代码,其中就使用了反射类 Method 来调用指定的方法。\npublic class DebugInvocationHandler implements InvocationHandler { /** * 代理类中的真实对象 */ private final Object target; public DebugInvocationHandler(Object target) { this.target = target; } public Object invoke(Object proxy, Method method, Object[] args) throws InvocationTargetException, IllegalAccessException { System.out.println(\u0026#34;before method \u0026#34; + method.getName()); Object result = method.invoke(target, args); System.out.println(\u0026#34;after method \u0026#34; + method.getName()); return result; } } 另外,像 Java 中的一大利器 注解 的实现也用到了反射。\n为什么你使用 Spring 的时候 ,一个@Component注解就声明了一个类为 Spring Bean 呢?为什么你通过一个 @Value注解就读取到配置文件中的值呢?究竟是怎么起作用的呢?\n这些都是因为你可以基于反射分析类,然后获取到类/属性/方法/方法的参数上的注解。你获取到注解之后,就可以做进一步的处理。\n谈谈反射机制的优缺点 # 优点:可以让咱们的代码更加灵活、为各种框架提供开箱即用的功能提供了便利\n缺点:让我们在运行时有了分析操作类的能力,这同样也增加了安全问题。比如可以无视泛型参数的安全检查(泛型参数的安全检查发生在编译时)。另外,反射的性能也要稍差点,不过,对于框架来说实际是影响不大的。相关阅读: Java Reflection: Why is it so slow?\n反射实战 # 获取 Class 对象的四种方式 # 如果我们动态获取到这些信息,我们需要依靠 Class 对象。Class 类对象将一个类的方法、变量等信息告诉运行的程序。Java 提供了四种方式获取 Class 对象:\n1. 知道具体类的情况下可以使用:\nClass alunbarClass = TargetObject.class; 但是我们一般是不知道具体类的,基本都是通过遍历包下面的类来获取 Class 对象,通过此方式获取 Class 对象不会进行初始化\n2. 通过 Class.forName()传入类的全路径获取:\nClass alunbarClass1 = Class.forName(\u0026#34;cn.javaguide.TargetObject\u0026#34;); 3. 通过对象实例instance.getClass()获取:\nTargetObject o = new TargetObject(); Class alunbarClass2 = o.getClass(); 4. 通过类加载器xxxClassLoader.loadClass()传入类路径获取:\nClassLoader.getSystemClassLoader().loadClass(\u0026#34;cn.javaguide.TargetObject\u0026#34;); 通过类加载器获取 Class 对象不会进行初始化,意味着不进行包括初始化等一系列步骤,静态代码块和静态对象不会得到执行\n反射的一些基本操作 # 创建一个我们要使用反射操作的类 TargetObject。 package cn.javaguide; public class TargetObject { private String value; public TargetObject() { value = \u0026#34;JavaGuide\u0026#34;; } public void publicMethod(String s) { System.out.println(\u0026#34;I love \u0026#34; + s); } private void privateMethod() { System.out.println(\u0026#34;value is \u0026#34; + value); } } 使用反射操作这个类的方法以及属性 package cn.javaguide; import java.lang.reflect.Field; import java.lang.reflect.InvocationTargetException; import java.lang.reflect.Method; public class Main { public static void main(String[] args) throws ClassNotFoundException, NoSuchMethodException, IllegalAccessException, InstantiationException, InvocationTargetException, NoSuchFieldException { /** * 获取 TargetObject 类的 Class 对象并且创建 TargetObject 类实例 */ Class\u0026lt;?\u0026gt; targetClass = Class.forName(\u0026#34;cn.javaguide.TargetObject\u0026#34;); TargetObject targetObject = (TargetObject) targetClass.newInstance(); /** * 获取 TargetObject 类中定义的所有方法 */ Method[] methods = targetClass.getDeclaredMethods(); for (Method method : methods) { System.out.println(method.getName()); } /** * 获取指定方法并调用 */ Method publicMethod = targetClass.getDeclaredMethod(\u0026#34;publicMethod\u0026#34;, String.class); publicMethod.invoke(targetObject, \u0026#34;JavaGuide\u0026#34;); /** * 获取指定参数并对参数进行修改 */ Field field = targetClass.getDeclaredField(\u0026#34;value\u0026#34;); //为了对类中的参数进行修改我们取消安全检查 field.setAccessible(true); field.set(targetObject, \u0026#34;JavaGuide\u0026#34;); /** * 调用 private 方法 */ Method privateMethod = targetClass.getDeclaredMethod(\u0026#34;privateMethod\u0026#34;); //为了调用private方法我们取消安全检查 privateMethod.setAccessible(true); privateMethod.invoke(targetObject); } } 输出内容:\npublicMethod privateMethod I love JavaGuide value is JavaGuide 注意 : 有读者提到上面代码运行会抛出 ClassNotFoundException 异常,具体原因是你没有下面把这段代码的包名替换成自己创建的 TargetObject 所在的包 。 可以参考: https://www.cnblogs.com/chanshuyi/p/head_first_of_reflection.html 这篇文章。\nClass\u0026lt;?\u0026gt; targetClass = Class.forName(\u0026#34;cn.javaguide.TargetObject\u0026#34;); "},{"id":506,"href":"/zh/docs/technology/Interview/java/basis/unsafe/","title":"Java 魔法类 Unsafe 详解","section":"Basis","content":" 本文整理完善自下面这两篇优秀的文章:\nJava 魔法类:Unsafe 应用解析 - 美团技术团队 -2019 Java 双刃剑之 Unsafe 类详解 - 码农参上 - 2021 阅读过 JUC 源码的同学,一定会发现很多并发工具类都调用了一个叫做 Unsafe 的类。\n那这个类主要是用来干什么的呢?有什么使用场景呢?这篇文章就带你搞清楚!\nUnsafe 介绍 # Unsafe 是位于 sun.misc 包下的一个类,主要提供一些用于执行低级别、不安全操作的方法,如直接访问系统内存资源、自主管理内存资源等,这些方法在提升 Java 运行效率、增强 Java 语言底层资源操作能力方面起到了很大的作用。但由于 Unsafe 类使 Java 语言拥有了类似 C 语言指针一样操作内存空间的能力,这无疑也增加了程序发生相关指针问题的风险。在程序中过度、不正确使用 Unsafe 类会使得程序出错的概率变大,使得 Java 这种安全的语言变得不再“安全”,因此对 Unsafe 的使用一定要慎重。\n另外,Unsafe 提供的这些功能的实现需要依赖本地方法(Native Method)。你可以将本地方法看作是 Java 中使用其他编程语言编写的方法。本地方法使用 native 关键字修饰,Java 代码中只是声明方法头,具体的实现则交给 本地代码。\n为什么要使用本地方法呢?\n需要用到 Java 中不具备的依赖于操作系统的特性,Java 在实现跨平台的同时要实现对底层的控制,需要借助其他语言发挥作用。 对于其他语言已经完成的一些现成功能,可以使用 Java 直接调用。 程序对时间敏感或对性能要求非常高时,有必要使用更加底层的语言,例如 C/C++甚至是汇编。 在 JUC 包的很多并发工具类在实现并发机制时,都调用了本地方法,通过它们打破了 Java 运行时的界限,能够接触到操作系统底层的某些功能。对于同一本地方法,不同的操作系统可能会通过不同的方式来实现,但是对于使用者来说是透明的,最终都会得到相同的结果。\nUnsafe 创建 # sun.misc.Unsafe 部分源码如下:\npublic final class Unsafe { // 单例对象 private static final Unsafe theUnsafe; ...... private Unsafe() { } @CallerSensitive public static Unsafe getUnsafe() { Class var0 = Reflection.getCallerClass(); // 仅在引导类加载器`BootstrapClassLoader`加载时才合法 if(!VM.isSystemDomainLoader(var0.getClassLoader())) { throw new SecurityException(\u0026#34;Unsafe\u0026#34;); } else { return theUnsafe; } } } Unsafe 类为一单例实现,提供静态方法 getUnsafe 获取 Unsafe实例。这个看上去貌似可以用来获取 Unsafe 实例。但是,当我们直接调用这个静态方法的时候,会抛出 SecurityException 异常:\nException in thread \u0026#34;main\u0026#34; java.lang.SecurityException: Unsafe at sun.misc.Unsafe.getUnsafe(Unsafe.java:90) at com.cn.test.GetUnsafeTest.main(GetUnsafeTest.java:12) 为什么 public static 方法无法被直接调用呢?\n这是因为在getUnsafe方法中,会对调用者的classLoader进行检查,判断当前类是否由Bootstrap classLoader加载,如果不是的话那么就会抛出一个SecurityException异常。也就是说,只有启动类加载器加载的类才能够调用 Unsafe 类中的方法,来防止这些方法在不可信的代码中被调用。\n为什么要对 Unsafe 类进行这么谨慎的使用限制呢?\nUnsafe 提供的功能过于底层(如直接访问系统内存资源、自主管理内存资源等),安全隐患也比较大,使用不当的话,很容易出现很严重的问题。\n如若想使用 Unsafe 这个类的话,应该如何获取其实例呢?\n这里介绍两个可行的方案。\n1、利用反射获得 Unsafe 类中已经实例化完成的单例对象 theUnsafe 。\nprivate static Unsafe reflectGetUnsafe() { try { Field field = Unsafe.class.getDeclaredField(\u0026#34;theUnsafe\u0026#34;); field.setAccessible(true); return (Unsafe) field.get(null); } catch (Exception e) { log.error(e.getMessage(), e); return null; } } 2、从getUnsafe方法的使用限制条件出发,通过 Java 命令行命令-Xbootclasspath/a把调用 Unsafe 相关方法的类 A 所在 jar 包路径追加到默认的 bootstrap 路径中,使得 A 被引导类加载器加载,从而通过Unsafe.getUnsafe方法安全的获取 Unsafe 实例。\njava -Xbootclasspath/a: ${path} // 其中path为调用Unsafe相关方法的类所在jar包路径 Unsafe 功能 # 概括的来说,Unsafe 类实现功能可以被分为下面 8 类:\n内存操作 内存屏障 对象操作 数据操作 CAS 操作 线程调度 Class 操作 系统信息 内存操作 # 介绍 # 如果你是一个写过 C 或者 C++ 的程序员,一定对内存操作不会陌生,而在 Java 中是不允许直接对内存进行操作的,对象内存的分配和回收都是由 JVM 自己实现的。但是在 Unsafe 中,提供的下列接口可以直接进行内存操作:\n//分配新的本地空间 public native long allocateMemory(long bytes); //重新调整内存空间的大小 public native long reallocateMemory(long address, long bytes); //将内存设置为指定值 public native void setMemory(Object o, long offset, long bytes, byte value); //内存拷贝 public native void copyMemory(Object srcBase, long srcOffset,Object destBase, long destOffset,long bytes); //清除内存 public native void freeMemory(long address); 使用下面的代码进行测试:\nprivate void memoryTest() { int size = 4; long addr = unsafe.allocateMemory(size); long addr3 = unsafe.reallocateMemory(addr, size * 2); System.out.println(\u0026#34;addr: \u0026#34;+addr); System.out.println(\u0026#34;addr3: \u0026#34;+addr3); try { unsafe.setMemory(null,addr ,size,(byte)1); for (int i = 0; i \u0026lt; 2; i++) { unsafe.copyMemory(null,addr,null,addr3+size*i,4); } System.out.println(unsafe.getInt(addr)); System.out.println(unsafe.getLong(addr3)); }finally { unsafe.freeMemory(addr); unsafe.freeMemory(addr3); } } 先看结果输出:\naddr: 2433733895744 addr3: 2433733894944 16843009 72340172838076673 分析一下运行结果,首先使用allocateMemory方法申请 4 字节长度的内存空间,调用setMemory方法向每个字节写入内容为byte类型的 1,当使用 Unsafe 调用getInt方法时,因为一个int型变量占 4 个字节,会一次性读取 4 个字节,组成一个int的值,对应的十进制结果为 16843009。\n你可以通过下图理解这个过程:\n在代码中调用reallocateMemory方法重新分配了一块 8 字节长度的内存空间,通过比较addr和addr3可以看到和之前申请的内存地址是不同的。在代码中的第二个 for 循环里,调用copyMemory方法进行了两次内存的拷贝,每次拷贝内存地址addr开始的 4 个字节,分别拷贝到以addr3和addr3+4开始的内存空间上:\n拷贝完成后,使用getLong方法一次性读取 8 个字节,得到long类型的值为 72340172838076673。\n需要注意,通过这种方式分配的内存属于 堆外内存 ,是无法进行垃圾回收的,需要我们把这些内存当做一种资源去手动调用freeMemory方法进行释放,否则会产生内存泄漏。通用的操作内存方式是在try中执行对内存的操作,最终在finally块中进行内存的释放。\n为什么要使用堆外内存?\n对垃圾回收停顿的改善。由于堆外内存是直接受操作系统管理而不是 JVM,所以当我们使用堆外内存时,即可保持较小的堆内内存规模。从而在 GC 时减少回收停顿对于应用的影响。 提升程序 I/O 操作的性能。通常在 I/O 通信过程中,会存在堆内内存到堆外内存的数据拷贝操作,对于需要频繁进行内存间数据拷贝且生命周期较短的暂存数据,都建议存储到堆外内存。 典型应用 # DirectByteBuffer 是 Java 用于实现堆外内存的一个重要类,通常用在通信过程中做缓冲池,如在 Netty、MINA 等 NIO 框架中应用广泛。DirectByteBuffer 对于堆外内存的创建、使用、销毁等逻辑均由 Unsafe 提供的堆外内存 API 来实现。\n下图为 DirectByteBuffer 构造函数,创建 DirectByteBuffer 的时候,通过 Unsafe.allocateMemory 分配内存、Unsafe.setMemory 进行内存初始化,而后构建 Cleaner 对象用于跟踪 DirectByteBuffer 对象的垃圾回收,以实现当 DirectByteBuffer 被垃圾回收时,分配的堆外内存一起被释放。\nDirectByteBuffer(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 { // 分配内存并返回基地址 base = unsafe.allocateMemory(size); } catch (OutOfMemoryError x) { Bits.unreserveMemory(size, cap); throw x; } // 内存初始化 unsafe.setMemory(base, size, (byte) 0); if (pa \u0026amp;\u0026amp; (base % ps != 0)) { // Round up to page boundary address = base + ps - (base \u0026amp; (ps - 1)); } else { address = base; } // 跟踪 DirectByteBuffer 对象的垃圾回收,以实现堆外内存释放 cleaner = Cleaner.create(this, new Deallocator(base, size, cap)); att = null; } 内存屏障 # 介绍 # 在介绍内存屏障前,需要知道编译器和 CPU 会在保证程序输出结果一致的情况下,会对代码进行重排序,从指令优化角度提升性能。而指令重排序可能会带来一个不好的结果,导致 CPU 的高速缓存和内存中数据的不一致,而内存屏障(Memory Barrier)就是通过阻止屏障两边的指令重排序从而避免编译器和硬件的不正确优化情况。\n在硬件层面上,内存屏障是 CPU 为了防止代码进行重排序而提供的指令,不同的硬件平台上实现内存屏障的方法可能并不相同。在 Java8 中,引入了 3 个内存屏障的函数,它屏蔽了操作系统底层的差异,允许在代码中定义、并统一由 JVM 来生成内存屏障指令,来实现内存屏障的功能。\nUnsafe 中提供了下面三个内存屏障相关方法:\n//内存屏障,禁止load操作重排序。屏障前的load操作不能被重排序到屏障后,屏障后的load操作不能被重排序到屏障前 public native void loadFence(); //内存屏障,禁止store操作重排序。屏障前的store操作不能被重排序到屏障后,屏障后的store操作不能被重排序到屏障前 public native void storeFence(); //内存屏障,禁止load、store操作重排序 public native void fullFence(); 内存屏障可以看做对内存随机访问的操作中的一个同步点,使得此点之前的所有读写操作都执行后才可以开始执行此点之后的操作。以loadFence方法为例,它会禁止读操作重排序,保证在这个屏障之前的所有读操作都已经完成,并且将缓存数据设为无效,重新从主存中进行加载。\n看到这估计很多小伙伴们会想到volatile关键字了,如果在字段上添加了volatile关键字,就能够实现字段在多线程下的可见性。基于读内存屏障,我们也能实现相同的功能。下面定义一个线程方法,在线程中去修改flag标志位,注意这里的flag是没有被volatile修饰的:\n@Getter class ChangeThread implements Runnable{ /==volatile==/ boolean flag=false; @Override public void run() { try { Thread.sleep(3000); } catch (InterruptedException e) { e.printStackTrace(); } System.out.println(\u0026#34;subThread change flag to:\u0026#34; + flag); flag = true; } } 在主线程的while循环中,加入内存屏障,测试是否能够感知到flag的修改变化:\npublic static void main(String[] args){ ChangeThread changeThread = new ChangeThread(); new Thread(changeThread).start(); while (true) { boolean flag = changeThread.isFlag(); unsafe.loadFence(); //加入读内存屏障 if (flag){ System.out.println(\u0026#34;detected flag changed\u0026#34;); break; } } System.out.println(\u0026#34;main thread end\u0026#34;); } 运行结果:\nsubThread change flag to:false detected flag changed main thread end 而如果删掉上面代码中的loadFence方法,那么主线程将无法感知到flag发生的变化,会一直在while中循环。可以用图来表示上面的过程:\n了解 Java 内存模型(JMM)的小伙伴们应该清楚,运行中的线程不是直接读取主内存中的变量的,只能操作自己工作内存中的变量,然后同步到主内存中,并且线程的工作内存是不能共享的。上面的图中的流程就是子线程借助于主内存,将修改后的结果同步给了主线程,进而修改主线程中的工作空间,跳出循环。\n典型应用 # 在 Java 8 中引入了一种锁的新机制——StampedLock,它可以看成是读写锁的一个改进版本。StampedLock 提供了一种乐观读锁的实现,这种乐观读锁类似于无锁的操作,完全不会阻塞写线程获取写锁,从而缓解读多写少时写线程“饥饿”现象。由于 StampedLock 提供的乐观读锁不阻塞写线程获取读锁,当线程共享变量从主内存 load 到线程工作内存时,会存在数据不一致问题。\n为了解决这个问题,StampedLock 的 validate 方法会通过 Unsafe 的 loadFence 方法加入一个 load 内存屏障。\npublic boolean validate(long stamp) { U.loadFence(); return (stamp \u0026amp; SBITS) == (state \u0026amp; SBITS); } 对象操作 # 介绍 # 例子\nimport sun.misc.Unsafe; import java.lang.reflect.Field; public class Main { private int value; public static void main(String[] args) throws Exception{ Unsafe unsafe = reflectGetUnsafe(); assert unsafe != null; long offset = unsafe.objectFieldOffset(Main.class.getDeclaredField(\u0026#34;value\u0026#34;)); Main main = new Main(); System.out.println(\u0026#34;value before putInt: \u0026#34; + main.value); unsafe.putInt(main, offset, 42); System.out.println(\u0026#34;value after putInt: \u0026#34; + main.value); System.out.println(\u0026#34;value after putInt: \u0026#34; + unsafe.getInt(main, offset)); } private static Unsafe reflectGetUnsafe() { try { Field field = Unsafe.class.getDeclaredField(\u0026#34;theUnsafe\u0026#34;); field.setAccessible(true); return (Unsafe) field.get(null); } catch (Exception e) { e.printStackTrace(); return null; } } } 输出结果:\nvalue before putInt: 0 value after putInt: 42 value after putInt: 42 对象属性\n对象成员属性的内存偏移量获取,以及字段属性值的修改,在上面的例子中我们已经测试过了。除了前面的putInt、getInt方法外,Unsafe 提供了全部 8 种基础数据类型以及Object的put和get方法,并且所有的put方法都可以越过访问权限,直接修改内存中的数据。阅读 openJDK 源码中的注释发现,基础数据类型和Object的读写稍有不同,基础数据类型是直接操作的属性值(value),而Object的操作则是基于引用值(reference value)。下面是Object的读写方法:\n//在对象的指定偏移地址获取一个对象引用 public native Object getObject(Object o, long offset); //在对象指定偏移地址写入一个对象引用 public native void putObject(Object o, long offset, Object x); 除了对象属性的普通读写外,Unsafe 还提供了 volatile 读写和有序写入方法。volatile读写方法的覆盖范围与普通读写相同,包含了全部基础数据类型和Object类型,以int类型为例:\n//在对象的指定偏移地址处读取一个int值,支持volatile load语义 public native int getIntVolatile(Object o, long offset); //在对象指定偏移地址处写入一个int,支持volatile store语义 public native void putIntVolatile(Object o, long offset, int x); 相对于普通读写来说,volatile读写具有更高的成本,因为它需要保证可见性和有序性。在执行get操作时,会强制从主存中获取属性值,在使用put方法设置属性值时,会强制将值更新到主存中,从而保证这些变更对其他线程是可见的。\n有序写入的方法有以下三个:\npublic native void putOrderedObject(Object o, long offset, Object x); public native void putOrderedInt(Object o, long offset, int x); public native void putOrderedLong(Object o, long offset, long x); 有序写入的成本相对volatile较低,因为它只保证写入时的有序性,而不保证可见性,也就是一个线程写入的值不能保证其他线程立即可见。为了解决这里的差异性,需要对内存屏障的知识点再进一步进行补充,首先需要了解两个指令的概念:\nLoad:将主内存中的数据拷贝到处理器的缓存中 Store:将处理器缓存的数据刷新到主内存中 顺序写入与volatile写入的差别在于,在顺序写时加入的内存屏障类型为StoreStore类型,而在volatile写入时加入的内存屏障是StoreLoad类型,如下图所示:\n在有序写入方法中,使用的是StoreStore屏障,该屏障确保Store1立刻刷新数据到内存,这一操作先于Store2以及后续的存储指令操作。而在volatile写入中,使用的是StoreLoad屏障,该屏障确保Store1立刻刷新数据到内存,这一操作先于Load2及后续的装载指令,并且,StoreLoad屏障会使该屏障之前的所有内存访问指令,包括存储指令和访问指令全部完成之后,才执行该屏障之后的内存访问指令。\n综上所述,在上面的三类写入方法中,在写入效率方面,按照put、putOrder、putVolatile的顺序效率逐渐降低。\n对象实例化\n使用 Unsafe 的 allocateInstance 方法,允许我们使用非常规的方式进行对象的实例化,首先定义一个实体类,并且在构造函数中对其成员变量进行赋值操作:\n@Data public class A { private int b; public A(){ this.b =1; } } 分别基于构造函数、反射以及 Unsafe 方法的不同方式创建对象进行比较:\npublic void objTest() throws Exception{ A a1=new A(); System.out.println(a1.getB()); A a2 = A.class.newInstance(); System.out.println(a2.getB()); A a3= (A) unsafe.allocateInstance(A.class); System.out.println(a3.getB()); } 打印结果分别为 1、1、0,说明通过allocateInstance方法创建对象过程中,不会调用类的构造方法。使用这种方式创建对象时,只用到了Class对象,所以说如果想要跳过对象的初始化阶段或者跳过构造器的安全检查,就可以使用这种方法。在上面的例子中,如果将 A 类的构造函数改为private类型,将无法通过构造函数和反射创建对象(可以通过构造函数对象 setAccessible 后创建对象),但allocateInstance方法仍然有效。\n典型应用 # 常规对象实例化方式:我们通常所用到的创建对象的方式,从本质上来讲,都是通过 new 机制来实现对象的创建。但是,new 机制有个特点就是当类只提供有参的构造函数且无显式声明无参构造函数时,则必须使用有参构造函数进行对象构造,而使用有参构造函数时,必须传递相应个数的参数才能完成对象实例化。 非常规的实例化方式:而 Unsafe 中提供 allocateInstance 方法,仅通过 Class 对象就可以创建此类的实例对象,而且不需要调用其构造函数、初始化代码、JVM 安全检查等。它抑制修饰符检测,也就是即使构造器是 private 修饰的也能通过此方法实例化,只需提类对象即可创建相应的对象。由于这种特性,allocateInstance 在 java.lang.invoke、Objenesis(提供绕过类构造器的对象生成方式)、Gson(反序列化时用到)中都有相应的应用。 数组操作 # 介绍 # arrayBaseOffset 与 arrayIndexScale 这两个方法配合起来使用,即可定位数组中每个元素在内存中的位置。\n//返回数组中第一个元素的偏移地址 public native int arrayBaseOffset(Class\u0026lt;?\u0026gt; arrayClass); //返回数组中一个元素占用的大小 public native int arrayIndexScale(Class\u0026lt;?\u0026gt; arrayClass); 典型应用 # 这两个与数据操作相关的方法,在 java.util.concurrent.atomic 包下的 AtomicIntegerArray(可以实现对 Integer 数组中每个元素的原子性操作)中有典型的应用,如下图 AtomicIntegerArray 源码所示,通过 Unsafe 的 arrayBaseOffset、arrayIndexScale 分别获取数组首元素的偏移地址 base 及单个元素大小因子 scale 。后续相关原子性操作,均依赖于这两个值进行数组中元素的定位,如下图二所示的 getAndAdd 方法即通过 checkedByteOffset 方法获取某数组元素的偏移地址,而后通过 CAS 实现原子性操作。\nCAS 操作 # 介绍 # 这部分主要为 CAS 相关操作的方法。\n/** * CAS * @param o 包含要修改field的对象 * @param offset 对象中某field的偏移量 * @param expected 期望值 * @param update 更新值 * @return true | false */ public final native boolean compareAndSwapObject(Object o, long offset, Object expected, Object update); public final native boolean compareAndSwapInt(Object o, long offset, int expected,int update); public final native boolean compareAndSwapLong(Object o, long offset, long expected, long update); 什么是 CAS? CAS 即比较并替换(Compare And Swap),是实现并发算法时常用到的一种技术。CAS 操作包含三个操作数——内存位置、预期原值及新值。执行 CAS 操作的时候,将内存位置的值与预期原值比较,如果相匹配,那么处理器会自动将该位置值更新为新值,否则,处理器不做任何操作。我们都知道,CAS 是一条 CPU 的原子指令(cmpxchg 指令),不会造成所谓的数据不一致问题,Unsafe 提供的 CAS 方法(如 compareAndSwapXXX)底层实现即为 CPU 指令 cmpxchg 。\n典型应用 # 在 JUC 包的并发工具类中大量地使用了 CAS 操作,像在前面介绍synchronized和AQS的文章中也多次提到了 CAS,其作为乐观锁在并发工具类中广泛发挥了作用。在 Unsafe 类中,提供了compareAndSwapObject、compareAndSwapInt、compareAndSwapLong方法来实现的对Object、int、long类型的 CAS 操作。以compareAndSwapInt方法为例:\npublic final native boolean compareAndSwapInt(Object o, long offset,int expected,int x); 参数中o为需要更新的对象,offset是对象o中整形字段的偏移量,如果这个字段的值与expected相同,则将字段的值设为x这个新值,并且此更新是不可被中断的,也就是一个原子操作。下面是一个使用compareAndSwapInt的例子:\nprivate volatile int a; public static void main(String[] args){ CasTest casTest=new CasTest(); new Thread(()-\u0026gt;{ for (int i = 1; i \u0026lt; 5; i++) { casTest.increment(i); System.out.print(casTest.a+\u0026#34; \u0026#34;); } }).start(); new Thread(()-\u0026gt;{ for (int i = 5 ; i \u0026lt;10 ; i++) { casTest.increment(i); System.out.print(casTest.a+\u0026#34; \u0026#34;); } }).start(); } private void increment(int x){ while (true){ try { long fieldOffset = unsafe.objectFieldOffset(CasTest.class.getDeclaredField(\u0026#34;a\u0026#34;)); if (unsafe.compareAndSwapInt(this,fieldOffset,x-1,x)) break; } catch (NoSuchFieldException e) { e.printStackTrace(); } } } 运行代码会依次输出:\n1 2 3 4 5 6 7 8 9 在上面的例子中,使用两个线程去修改int型属性a的值,并且只有在a的值等于传入的参数x减一时,才会将a的值变为x,也就是实现对a的加一的操作。流程如下所示:\n需要注意的是,在调用compareAndSwapInt方法后,会直接返回true或false的修改结果,因此需要我们在代码中手动添加自旋的逻辑。在AtomicInteger类的设计中,也是采用了将compareAndSwapInt的结果作为循环条件,直至修改成功才退出死循环的方式来实现的原子性的自增操作。\n线程调度 # 介绍 # Unsafe 类中提供了park、unpark、monitorEnter、monitorExit、tryMonitorEnter方法进行线程调度。\n//取消阻塞线程 public native void unpark(Object thread); //阻塞线程 public native void park(boolean isAbsolute, long time); //获得对象锁(可重入锁) @Deprecated public native void monitorEnter(Object o); //释放对象锁 @Deprecated public native void monitorExit(Object o); //尝试获取对象锁 @Deprecated public native boolean tryMonitorEnter(Object o); 方法 park、unpark 即可实现线程的挂起与恢复,将一个线程进行挂起是通过 park 方法实现的,调用 park 方法后,线程将一直阻塞直到超时或者中断等条件出现;unpark 可以终止一个挂起的线程,使其恢复正常。\n此外,Unsafe 源码中monitor相关的三个方法已经被标记为deprecated,不建议被使用:\n//获得对象锁 @Deprecated public native void monitorEnter(Object var1); //释放对象锁 @Deprecated public native void monitorExit(Object var1); //尝试获得对象锁 @Deprecated public native boolean tryMonitorEnter(Object var1); monitorEnter方法用于获得对象锁,monitorExit用于释放对象锁,如果对一个没有被monitorEnter加锁的对象执行此方法,会抛出IllegalMonitorStateException异常。tryMonitorEnter方法尝试获取对象锁,如果成功则返回true,反之返回false。\n典型应用 # Java 锁和同步器框架的核心类 AbstractQueuedSynchronizer (AQS),就是通过调用LockSupport.park()和LockSupport.unpark()实现线程的阻塞和唤醒的,而 LockSupport 的 park、unpark 方法实际是调用 Unsafe 的 park、unpark 方式实现的。\npublic static void park(Object blocker) { Thread t = Thread.currentThread(); setBlocker(t, blocker); UNSAFE.park(false, 0L); setBlocker(t, null); } public static void unpark(Thread thread) { if (thread != null) UNSAFE.unpark(thread); } LockSupport 的park方法调用了 Unsafe 的park方法来阻塞当前线程,此方法将线程阻塞后就不会继续往后执行,直到有其他线程调用unpark方法唤醒当前线程。下面的例子对 Unsafe 的这两个方法进行测试:\npublic static void main(String[] args) { Thread mainThread = Thread.currentThread(); new Thread(()-\u0026gt;{ try { TimeUnit.SECONDS.sleep(5); System.out.println(\u0026#34;subThread try to unpark mainThread\u0026#34;); unsafe.unpark(mainThread); } catch (InterruptedException e) { e.printStackTrace(); } }).start(); System.out.println(\u0026#34;park main mainThread\u0026#34;); unsafe.park(false,0L); System.out.println(\u0026#34;unpark mainThread success\u0026#34;); } 程序输出为:\npark main mainThread subThread try to unpark mainThread unpark mainThread success 程序运行的流程也比较容易看懂,子线程开始运行后先进行睡眠,确保主线程能够调用park方法阻塞自己,子线程在睡眠 5 秒后,调用unpark方法唤醒主线程,使主线程能继续向下执行。整个流程如下图所示:\nClass 操作 # 介绍 # Unsafe 对Class的相关操作主要包括类加载和静态变量的操作方法。\n静态属性读取相关的方法\n//获取静态属性的偏移量 public native long staticFieldOffset(Field f); //获取静态属性的对象指针 public native Object staticFieldBase(Field f); //判断类是否需要初始化(用于获取类的静态属性前进行检测) public native boolean shouldBeInitialized(Class\u0026lt;?\u0026gt; c); 创建一个包含静态属性的类,进行测试:\n@Data public class User { public static String name=\u0026#34;Hydra\u0026#34;; int age; } private void staticTest() throws Exception { User user=new User(); // 也可以用下面的语句触发类初始化 // 1. // unsafe.ensureClassInitialized(User.class); // 2. // System.out.println(User.name); System.out.println(unsafe.shouldBeInitialized(User.class)); Field sexField = User.class.getDeclaredField(\u0026#34;name\u0026#34;); long fieldOffset = unsafe.staticFieldOffset(sexField); Object fieldBase = unsafe.staticFieldBase(sexField); Object object = unsafe.getObject(fieldBase, fieldOffset); System.out.println(object); } 运行结果:\nfalse Hydra 在 Unsafe 的对象操作中,我们学习了通过objectFieldOffset方法获取对象属性偏移量并基于它对变量的值进行存取,但是它不适用于类中的静态属性,这时候就需要使用staticFieldOffset方法。在上面的代码中,只有在获取Field对象的过程中依赖到了Class,而获取静态变量的属性时不再依赖于Class。\n在上面的代码中首先创建一个User对象,这是因为如果一个类没有被初始化,那么它的静态属性也不会被初始化,最后获取的字段属性将是null。所以在获取静态属性前,需要调用shouldBeInitialized方法,判断在获取前是否需要初始化这个类。如果删除创建 User 对象的语句,运行结果会变为:\ntrue null 使用defineClass方法允许程序在运行时动态地创建一个类\npublic native Class\u0026lt;?\u0026gt; defineClass(String name, byte[] b, int off, int len, ClassLoader loader,ProtectionDomain protectionDomain); 在实际使用过程中,可以只传入字节数组、起始字节的下标以及读取的字节长度,默认情况下,类加载器(ClassLoader)和保护域(ProtectionDomain)来源于调用此方法的实例。下面的例子中实现了反编译生成后的 class 文件的功能:\nprivate static void defineTest() { String fileName=\u0026#34;F:\\\\workspace\\\\unsafe-test\\\\target\\\\classes\\\\com\\\\cn\\\\model\\\\User.class\u0026#34;; File file = new File(fileName); try(FileInputStream fis = new FileInputStream(file)) { byte[] content=new byte[(int)file.length()]; fis.read(content); Class clazz = unsafe.defineClass(null, content, 0, content.length, null, null); Object o = clazz.newInstance(); Object age = clazz.getMethod(\u0026#34;getAge\u0026#34;).invoke(o, null); System.out.println(age); } catch (Exception e) { e.printStackTrace(); } } 在上面的代码中,首先读取了一个class文件并通过文件流将它转化为字节数组,之后使用defineClass方法动态的创建了一个类,并在后续完成了它的实例化工作,流程如下图所示,并且通过这种方式创建的类,会跳过 JVM 的所有安全检查。\n除了defineClass方法外,Unsafe 还提供了一个defineAnonymousClass方法:\npublic native Class\u0026lt;?\u0026gt; defineAnonymousClass(Class\u0026lt;?\u0026gt; hostClass, byte[] data, Object[] cpPatches); 使用该方法可以用来动态的创建一个匿名类,在Lambda表达式中就是使用 ASM 动态生成字节码,然后利用该方法定义实现相应的函数式接口的匿名类。在 JDK 15 发布的新特性中,在隐藏类(Hidden classes)一条中,指出将在未来的版本中弃用 Unsafe 的defineAnonymousClass方法。\n典型应用 # Lambda 表达式实现需要依赖 Unsafe 的 defineAnonymousClass 方法定义实现相应的函数式接口的匿名类。\n系统信息 # 介绍 # 这部分包含两个获取系统相关信息的方法。\n//返回系统指针的大小。返回值为4(32位系统)或 8(64位系统)。 public native int addressSize(); //内存页的大小,此值为2的幂次方。 public native int pageSize(); 典型应用 # 这两个方法的应用场景比较少,在java.nio.Bits类中,在使用pageCount计算所需的内存页的数量时,调用了pageSize方法获取内存页的大小。另外,在使用copySwapMemory方法拷贝内存时,调用了addressSize方法,检测 32 位系统的情况。\n总结 # 在本文中,我们首先介绍了 Unsafe 的基本概念、工作原理,并在此基础上,对它的 API 进行了说明与实践。相信大家通过这一过程,能够发现 Unsafe 在某些场景下,确实能够为我们提供编程中的便利。但是回到开头的话题,在使用这些便利时,确实存在着一些安全上的隐患,在我看来,一项技术具有不安全因素并不可怕,可怕的是它在使用过程中被滥用。尽管之前有传言说会在 Java9 中移除 Unsafe 类,不过它还是照样已经存活到了 Java16。按照存在即合理的逻辑,只要使用得当,它还是能给我们带来不少的帮助,因此最后还是建议大家,在使用 Unsafe 的过程中一定要做到使用谨慎使用、避免滥用。\n"},{"id":507,"href":"/zh/docs/technology/Interview/java/concurrent/java-thread-pool-summary/","title":"Java 线程池详解","section":"Concurrent","content":"池化技术想必大家已经屡见不鲜了,线程池、数据库连接池、HTTP 连接池等等都是对这个思想的应用。池化技术的思想主要是为了减少每次获取资源的消耗,提高对资源的利用率。\n这篇文章我会详细介绍一下线程池的基本概念以及核心原理。\n线程池介绍 # 顾名思义,线程池就是管理一系列线程的资源池,其提供了一种限制和管理线程资源的方式。每个线程池还维护一些基本统计信息,例如已完成任务的数量。\n这里借用《Java 并发编程的艺术》书中的部分内容来总结一下使用线程池的好处:\n降低资源消耗。通过重复利用已创建的线程降低线程创建和销毁造成的消耗。 提高响应速度。当任务到达时,任务可以不需要等到线程创建就能立即执行。 提高线程的可管理性。线程是稀缺资源,如果无限制的创建,不仅会消耗系统资源,还会降低系统的稳定性,使用线程池可以进行统一的分配,调优和监控。 线程池一般用于执行多个不相关联的耗时任务,没有多线程的情况下,任务顺序执行,使用了线程池的话可让多个不相关联的任务同时执行。\nExecutor 框架介绍 # Executor 框架是 Java5 之后引进的,在 Java 5 之后,通过 Executor 来启动线程比使用 Thread 的 start 方法更好,除了更易管理,效率更好(用线程池实现,节约开销)外,还有关键的一点:有助于避免 this 逃逸问题。\nthis 逃逸是指在构造函数返回之前其他线程就持有该对象的引用,调用尚未构造完全的对象的方法可能引发令人疑惑的错误。\nExecutor 框架不仅包括了线程池的管理,还提供了线程工厂、队列以及拒绝策略等,Executor 框架让并发编程变得更加简单。\nExecutor 框架结构主要由三大部分组成:\n1、任务(Runnable /Callable)\n执行任务需要实现的 Runnable 接口 或 Callable接口。Runnable 接口或 Callable 接口 实现类都可以被 ThreadPoolExecutor 或 ScheduledThreadPoolExecutor 执行。\n2、任务的执行(Executor)\n如下图所示,包括任务执行机制的核心接口 Executor ,以及继承自 Executor 接口的 ExecutorService 接口。ThreadPoolExecutor 和 ScheduledThreadPoolExecutor 这两个关键类实现了 ExecutorService 接口。\n这里提了很多底层的类关系,但是,实际上我们需要更多关注的是 ThreadPoolExecutor 这个类,这个类在我们实际使用线程池的过程中,使用频率还是非常高的。\n注意: 通过查看 ScheduledThreadPoolExecutor 源代码我们发现 ScheduledThreadPoolExecutor 实际上是继承了 ThreadPoolExecutor 并实现了 ScheduledExecutorService ,而 ScheduledExecutorService 又实现了 ExecutorService,正如我们上面给出的类关系图显示的一样。\nThreadPoolExecutor 类描述:\n//AbstractExecutorService实现了ExecutorService接口 public class ThreadPoolExecutor extends AbstractExecutorService ScheduledThreadPoolExecutor 类描述:\n//ScheduledExecutorService继承ExecutorService接口 public class ScheduledThreadPoolExecutor extends ThreadPoolExecutor implements ScheduledExecutorService 3、异步计算的结果(Future)\nFuture 接口以及 Future 接口的实现类 FutureTask 类都可以代表异步计算的结果。\n当我们把 Runnable接口 或 Callable 接口 的实现类提交给 ThreadPoolExecutor 或 ScheduledThreadPoolExecutor 执行。(调用 submit() 方法时会返回一个 FutureTask 对象)\nExecutor 框架的使用示意图:\n主线程首先要创建实现 Runnable 或者 Callable 接口的任务对象。 把创建完成的实现 Runnable/Callable接口的 对象直接交给 ExecutorService 执行: ExecutorService.execute(Runnable command))或者也可以把 Runnable 对象或Callable 对象提交给 ExecutorService 执行(ExecutorService.submit(Runnable task)或 ExecutorService.submit(Callable \u0026lt;T\u0026gt; task))。 如果执行 ExecutorService.submit(…),ExecutorService 将返回一个实现Future接口的对象(我们刚刚也提到过了执行 execute()方法和 submit()方法的区别,submit()会返回一个 FutureTask 对象)。由于 FutureTask 实现了 Runnable,我们也可以创建 FutureTask,然后直接交给 ExecutorService 执行。 最后,主线程可以执行 FutureTask.get()方法来等待任务执行完成。主线程也可以执行 FutureTask.cancel(boolean mayInterruptIfRunning)来取消此任务的执行。 ThreadPoolExecutor 类介绍(重要) # 线程池实现类 ThreadPoolExecutor 是 Executor 框架最核心的类。\n线程池参数分析 # ThreadPoolExecutor 类中提供的四个构造方法。我们来看最长的那个,其余三个都是在这个构造方法的基础上产生(其他几个构造方法说白点都是给定某些默认参数的构造方法比如默认制定拒绝策略是什么)。\n/** * 用给定的初始参数创建一个新的ThreadPoolExecutor。 */ public ThreadPoolExecutor(int corePoolSize,//线程池的核心线程数量 int maximumPoolSize,//线程池的最大线程数 long keepAliveTime,//当线程数大于核心线程数时,多余的空闲线程存活的最长时间 TimeUnit unit,//时间单位 BlockingQueue\u0026lt;Runnable\u0026gt; workQueue,//任务队列,用来储存等待执行任务的队列 ThreadFactory threadFactory,//线程工厂,用来创建线程,一般默认即可 RejectedExecutionHandler handler//拒绝策略,当提交的任务过多而不能及时处理时,我们可以定制策略来处理任务 ) { if (corePoolSize \u0026lt; 0 || maximumPoolSize \u0026lt;= 0 || maximumPoolSize \u0026lt; corePoolSize || keepAliveTime \u0026lt; 0) throw new IllegalArgumentException(); if (workQueue == null || threadFactory == null || handler == null) throw new NullPointerException(); this.corePoolSize = corePoolSize; this.maximumPoolSize = maximumPoolSize; this.workQueue = workQueue; this.keepAliveTime = unit.toNanos(keepAliveTime); this.threadFactory = threadFactory; this.handler = handler; } 下面这些参数非常重要,在后面使用线程池的过程中你一定会用到!所以,务必拿着小本本记清楚。\nThreadPoolExecutor 3 个最重要的参数:\ncorePoolSize : 任务队列未达到队列容量时,最大可以同时运行的线程数量。 maximumPoolSize : 任务队列中存放的任务达到队列容量的时候,当前可以同时运行的线程数量变为最大线程数。 workQueue: 新任务来的时候会先判断当前运行的线程数量是否达到核心线程数,如果达到的话,新任务就会被存放在队列中。 ThreadPoolExecutor其他常见参数 :\nkeepAliveTime:线程池中的线程数量大于 corePoolSize 的时候,如果这时没有新的任务提交,核心线程外的线程不会立即销毁,而是会等待,直到等待的时间超过了 keepAliveTime才会被回收销毁。 unit : keepAliveTime 参数的时间单位。 threadFactory :executor 创建新线程的时候会用到。 handler :拒绝策略(后面会单独详细介绍一下)。 下面这张图可以加深你对线程池中各个参数的相互关系的理解(图片来源:《Java 性能调优实战》):\nThreadPoolExecutor 拒绝策略定义:\n如果当前同时运行的线程数量达到最大线程数量并且队列也已经被放满了任务时,ThreadPoolExecutor 定义一些策略:\nThreadPoolExecutor.AbortPolicy:抛出 RejectedExecutionException来拒绝新任务的处理。 ThreadPoolExecutor.CallerRunsPolicy:调用执行自己的线程运行任务,也就是直接在调用execute方法的线程中运行(run)被拒绝的任务,如果执行程序已关闭,则会丢弃该任务。因此这种策略会降低对于新任务提交速度,影响程序的整体性能。如果您的应用程序可以承受此延迟并且你要求任何一个任务请求都要被执行的话,你可以选择这个策略。 ThreadPoolExecutor.DiscardPolicy:不处理新任务,直接丢弃掉。 ThreadPoolExecutor.DiscardOldestPolicy:此策略将丢弃最早的未处理的任务请求。 举个例子:\n举个例子:Spring 通过 ThreadPoolTaskExecutor 或者我们直接通过 ThreadPoolExecutor 的构造函数创建线程池的时候,当我们不指定 RejectedExecutionHandler 拒绝策略来配置线程池的时候,默认使用的是 AbortPolicy。在这种拒绝策略下,如果队列满了,ThreadPoolExecutor 将抛出 RejectedExecutionException 异常来拒绝新来的任务 ,这代表你将丢失对这个任务的处理。如果不想丢弃任务的话,可以使用CallerRunsPolicy。CallerRunsPolicy 和其他的几个策略不同,它既不会抛弃任务,也不会抛出异常,而是将任务回退给调用者,使用调用者的线程来执行任务\npublic static class CallerRunsPolicy implements RejectedExecutionHandler { public CallerRunsPolicy() { } public void rejectedExecution(Runnable r, ThreadPoolExecutor e) { if (!e.isShutdown()) { // 直接主线程执行,而不是线程池中的线程执行 r.run(); } } } 线程池创建的两种方式 # 方式一:通过ThreadPoolExecutor构造函数来创建(推荐)。\n方式二:通过 Executor 框架的工具类 Executors 来创建。\nExecutors工具类提供的创建线程池的方法如下图所示:\n可以看出,通过Executors工具类可以创建多种类型的线程池,包括:\nFixedThreadPool:固定线程数量的线程池。该线程池中的线程数量始终不变。当有一个新的任务提交时,线程池中若有空闲线程,则立即执行。若没有,则新的任务会被暂存在一个任务队列中,待有线程空闲时,便处理在任务队列中的任务。 SingleThreadExecutor: 只有一个线程的线程池。若多余一个任务被提交到该线程池,任务会被保存在一个任务队列中,待线程空闲,按先入先出的顺序执行队列中的任务。 CachedThreadPool: 可根据实际情况调整线程数量的线程池。线程池的线程数量不确定,但若有空闲线程可以复用,则会优先使用可复用的线程。若所有线程均在工作,又有新的任务提交,则会创建新的线程处理任务。所有线程在当前任务执行完毕后,将返回线程池进行复用。 ScheduledThreadPool:给定的延迟后运行任务或者定期执行任务的线程池。 《阿里巴巴 Java 开发手册》强制线程池不允许使用 Executors 去创建,而是通过 ThreadPoolExecutor 构造函数的方式,这样的处理方式让写的同学更加明确线程池的运行规则,规避资源耗尽的风险\nExecutors 返回线程池对象的弊端如下(后文会详细介绍到):\nFixedThreadPool 和 SingleThreadExecutor:使用的是无界的 LinkedBlockingQueue,任务队列最大长度为 Integer.MAX_VALUE,可能堆积大量的请求,从而导致 OOM。 CachedThreadPool:使用的是同步队列 SynchronousQueue, 允许创建的线程数量为 Integer.MAX_VALUE ,如果任务数量过多且执行速度较慢,可能会创建大量的线程,从而导致 OOM。 ScheduledThreadPool 和 SingleThreadScheduledExecutor:使用的无界的延迟阻塞队列DelayedWorkQueue,任务队列最大长度为 Integer.MAX_VALUE,可能堆积大量的请求,从而导致 OOM。 // 无界队列 LinkedBlockingQueue public static ExecutorService newFixedThreadPool(int nThreads) { return new ThreadPoolExecutor(nThreads, nThreads,0L, TimeUnit.MILLISECONDS,new LinkedBlockingQueue\u0026lt;Runnable\u0026gt;()); } // 无界队列 LinkedBlockingQueue public static ExecutorService newSingleThreadExecutor() { return new FinalizableDelegatedExecutorService (new ThreadPoolExecutor(1, 1,0L, TimeUnit.MILLISECONDS,new LinkedBlockingQueue\u0026lt;Runnable\u0026gt;())); } // 同步队列 SynchronousQueue,没有容量,最大线程数是 Integer.MAX_VALUE` public static ExecutorService newCachedThreadPool() { return new ThreadPoolExecutor(0, Integer.MAX_VALUE,60L, TimeUnit.SECONDS,new SynchronousQueue\u0026lt;Runnable\u0026gt;()); } // DelayedWorkQueue(延迟阻塞队列) public static ScheduledExecutorService newScheduledThreadPool(int corePoolSize) { return new ScheduledThreadPoolExecutor(corePoolSize); } public ScheduledThreadPoolExecutor(int corePoolSize) { super(corePoolSize, Integer.MAX_VALUE, 0, NANOSECONDS, new DelayedWorkQueue()); } 线程池常用的阻塞队列总结 # 新任务来的时候会先判断当前运行的线程数量是否达到核心线程数,如果达到的话,新任务就会被存放在队列中。\n不同的线程池会选用不同的阻塞队列,我们可以结合内置线程池来分析。\n容量为 Integer.MAX_VALUE 的 LinkedBlockingQueue(无界队列):FixedThreadPool 和 SingleThreadExector 。FixedThreadPool最多只能创建核心线程数的线程(核心线程数和最大线程数相等),SingleThreadExector只能创建一个线程(核心线程数和最大线程数都是 1),二者的任务队列永远不会被放满。 SynchronousQueue(同步队列):CachedThreadPool 。SynchronousQueue 没有容量,不存储元素,目的是保证对于提交的任务,如果有空闲线程,则使用空闲线程来处理;否则新建一个线程来处理任务。也就是说,CachedThreadPool 的最大线程数是 Integer.MAX_VALUE ,可以理解为线程数是可以无限扩展的,可能会创建大量线程,从而导致 OOM。 DelayedWorkQueue(延迟阻塞队列):ScheduledThreadPool 和 SingleThreadScheduledExecutor 。DelayedWorkQueue 的内部元素并不是按照放入的时间排序,而是会按照延迟的时间长短对任务进行排序,内部采用的是“堆”的数据结构,可以保证每次出队的任务都是当前队列中执行时间最靠前的。DelayedWorkQueue 添加元素满了之后会自动扩容原来容量的 1/2,即永远不会阻塞,最大扩容可达 Integer.MAX_VALUE,所以最多只能创建核心线程数的线程。 线程池原理分析(重要) # 我们上面讲解了 Executor框架以及 ThreadPoolExecutor 类,下面让我们实战一下,来通过写一个 ThreadPoolExecutor 的小 Demo 来回顾上面的内容。\n线程池示例代码 # 首先创建一个 Runnable 接口的实现类(当然也可以是 Callable 接口,我们后面会介绍两者的区别。)\nMyRunnable.java\nimport java.util.Date; /** * 这是一个简单的Runnable类,需要大约5秒钟来执行其任务。 * @author shuang.kou */ public class MyRunnable implements Runnable { private String command; public MyRunnable(String s) { this.command = s; } @Override public void run() { System.out.println(Thread.currentThread().getName() + \u0026#34; Start. Time = \u0026#34; + new Date()); processCommand(); System.out.println(Thread.currentThread().getName() + \u0026#34; End. Time = \u0026#34; + new Date()); } private void processCommand() { try { Thread.sleep(5000); } catch (InterruptedException e) { e.printStackTrace(); } } @Override public String toString() { return this.command; } } 编写测试程序,我们这里以阿里巴巴推荐的使用 ThreadPoolExecutor 构造函数自定义参数的方式来创建线程池。\nThreadPoolExecutorDemo.java\nimport java.util.concurrent.ArrayBlockingQueue; import java.util.concurrent.ThreadPoolExecutor; import java.util.concurrent.TimeUnit; public class ThreadPoolExecutorDemo { private static final int CORE_POOL_SIZE = 5; private static final int MAX_POOL_SIZE = 10; private static final int QUEUE_CAPACITY = 100; private static final Long KEEP_ALIVE_TIME = 1L; public static void main(String[] args) { //使用阿里巴巴推荐的创建线程池的方式 //通过ThreadPoolExecutor构造函数自定义参数创建 ThreadPoolExecutor executor = new ThreadPoolExecutor( CORE_POOL_SIZE, MAX_POOL_SIZE, KEEP_ALIVE_TIME, TimeUnit.SECONDS, new ArrayBlockingQueue\u0026lt;\u0026gt;(QUEUE_CAPACITY), new ThreadPoolExecutor.CallerRunsPolicy()); for (int i = 0; i \u0026lt; 10; i++) { //创建WorkerThread对象(WorkerThread类实现了Runnable 接口) Runnable worker = new MyRunnable(\u0026#34;\u0026#34; + i); //执行Runnable executor.execute(worker); } //终止线程池 executor.shutdown(); while (!executor.isTerminated()) { } System.out.println(\u0026#34;Finished all threads\u0026#34;); } } 可以看到我们上面的代码指定了:\ncorePoolSize: 核心线程数为 5。 maximumPoolSize:最大线程数 10 keepAliveTime : 等待时间为 1L。 unit: 等待时间的单位为 TimeUnit.SECONDS。 workQueue:任务队列为 ArrayBlockingQueue,并且容量为 100; handler:拒绝策略为 CallerRunsPolicy。 输出结构:\npool-1-thread-3 Start. Time = Sun Apr 12 11:14:37 CST 2020 pool-1-thread-5 Start. Time = Sun Apr 12 11:14:37 CST 2020 pool-1-thread-2 Start. Time = Sun Apr 12 11:14:37 CST 2020 pool-1-thread-1 Start. Time = Sun Apr 12 11:14:37 CST 2020 pool-1-thread-4 Start. Time = Sun Apr 12 11:14:37 CST 2020 pool-1-thread-3 End. Time = Sun Apr 12 11:14:42 CST 2020 pool-1-thread-4 End. Time = Sun Apr 12 11:14:42 CST 2020 pool-1-thread-1 End. Time = Sun Apr 12 11:14:42 CST 2020 pool-1-thread-5 End. Time = Sun Apr 12 11:14:42 CST 2020 pool-1-thread-1 Start. Time = Sun Apr 12 11:14:42 CST 2020 pool-1-thread-2 End. Time = Sun Apr 12 11:14:42 CST 2020 pool-1-thread-5 Start. Time = Sun Apr 12 11:14:42 CST 2020 pool-1-thread-4 Start. Time = Sun Apr 12 11:14:42 CST 2020 pool-1-thread-3 Start. Time = Sun Apr 12 11:14:42 CST 2020 pool-1-thread-2 Start. Time = Sun Apr 12 11:14:42 CST 2020 pool-1-thread-1 End. Time = Sun Apr 12 11:14:47 CST 2020 pool-1-thread-4 End. Time = Sun Apr 12 11:14:47 CST 2020 pool-1-thread-5 End. Time = Sun Apr 12 11:14:47 CST 2020 pool-1-thread-3 End. Time = Sun Apr 12 11:14:47 CST 2020 pool-1-thread-2 End. Time = Sun Apr 12 11:14:47 CST 2020 Finished all threads // 任务全部执行完了才会跳出来,因为executor.isTerminated()判断为true了才会跳出while循环,当且仅当调用 shutdown() 方法后,并且所有提交的任务完成后返回为 true 线程池原理分析 # 我们通过前面的代码输出结果可以看出:线程池首先会先执行 5 个任务,然后这些任务有任务被执行完的话,就会去拿新的任务执行。 大家可以先通过上面讲解的内容,分析一下到底是咋回事?(自己独立思考一会)\n现在,我们就分析上面的输出内容来简单分析一下线程池原理。\n为了搞懂线程池的原理,我们需要首先分析一下 execute方法。 在示例代码中,我们使用 executor.execute(worker)来提交一个任务到线程池中去。\n这个方法非常重要,下面我们来看看它的源码:\n// 存放线程池的运行状态 (runState) 和线程池内有效线程的数量 (workerCount) private final AtomicInteger ctl = new AtomicInteger(ctlOf(RUNNING, 0)); private static int workerCountOf(int c) { return c \u0026amp; CAPACITY; } //任务队列 private final BlockingQueue\u0026lt;Runnable\u0026gt; workQueue; public void execute(Runnable command) { // 如果任务为null,则抛出异常。 if (command == null) throw new NullPointerException(); // ctl 中保存的线程池当前的一些状态信息 int c = ctl.get(); // 下面会涉及到 3 步 操作 // 1.首先判断当前线程池中执行的任务数量是否小于 corePoolSize // 如果小于的话,通过addWorker(command, true)新建一个线程,并将任务(command)添加到该线程中;然后,启动该线程从而执行任务。 if (workerCountOf(c) \u0026lt; corePoolSize) { if (addWorker(command, true)) return; c = ctl.get(); } // 2.如果当前执行的任务数量大于等于 corePoolSize 的时候就会走到这里,表明创建新的线程失败。 // 通过 isRunning 方法判断线程池状态,线程池处于 RUNNING 状态并且队列可以加入任务,该任务才会被加入进去 if (isRunning(c) \u0026amp;\u0026amp; workQueue.offer(command)) { int recheck = ctl.get(); // 再次获取线程池状态,如果线程池状态不是 RUNNING 状态就需要从任务队列中移除任务,并尝试判断线程是否全部执行完毕。同时执行拒绝策略。 if (!isRunning(recheck) \u0026amp;\u0026amp; remove(command)) reject(command); // 如果当前工作线程数量为0,新创建一个线程并执行。 else if (workerCountOf(recheck) == 0) addWorker(null, false); } //3. 通过addWorker(command, false)新建一个线程,并将任务(command)添加到该线程中;然后,启动该线程从而执行任务。 // 传入 false 代表增加线程时判断当前线程数是否少于 maxPoolSize //如果addWorker(command, false)执行失败,则通过reject()执行相应的拒绝策略的内容。 else if (!addWorker(command, false)) reject(command); } 这里简单分析一下整个流程(对整个逻辑进行了简化,方便理解):\n如果当前运行的线程数小于核心线程数,那么就会新建一个线程来执行任务。 如果当前运行的线程数等于或大于核心线程数,但是小于最大线程数,那么就把该任务放入到任务队列里等待执行。 如果向任务队列投放任务失败(任务队列已经满了),但是当前运行的线程数是小于最大线程数的,就新建一个线程来执行任务。 如果当前运行的线程数已经等同于最大线程数了,新建线程将会使当前运行的线程超出最大线程数,那么当前任务会被拒绝,拒绝策略会调用RejectedExecutionHandler.rejectedExecution()方法。 在 execute 方法中,多次调用 addWorker 方法。addWorker 这个方法主要用来创建新的工作线程,如果返回 true 说明创建和启动工作线程成功,否则的话返回的就是 false。\n// 全局锁,并发操作必备 private final ReentrantLock mainLock = new ReentrantLock(); // 跟踪线程池的最大大小,只有在持有全局锁mainLock的前提下才能访问此集合 private int largestPoolSize; // 工作线程集合,存放线程池中所有的(活跃的)工作线程,只有在持有全局锁mainLock的前提下才能访问此集合 private final HashSet\u0026lt;Worker\u0026gt; workers = new HashSet\u0026lt;\u0026gt;(); //获取线程池状态 private static int runStateOf(int c) { return c \u0026amp; ~CAPACITY; } //判断线程池的状态是否为 Running private static boolean isRunning(int c) { return c \u0026lt; SHUTDOWN; } /** * 添加新的工作线程到线程池 * @param firstTask 要执行 * @param core参数为true的话表示使用线程池的基本大小,为false使用线程池最大大小 * @return 添加成功就返回true否则返回false */ private boolean addWorker(Runnable firstTask, boolean core) { retry: for (;;) { //这两句用来获取线程池的状态 int c = ctl.get(); int rs = runStateOf(c); // Check if queue empty only if necessary. if (rs \u0026gt;= SHUTDOWN \u0026amp;\u0026amp; ! (rs == SHUTDOWN \u0026amp;\u0026amp; firstTask == null \u0026amp;\u0026amp; ! workQueue.isEmpty())) return false; for (;;) { //获取线程池中工作的线程的数量 int wc = workerCountOf(c); // core参数为false的话表明队列也满了,线程池大小变为 maximumPoolSize if (wc \u0026gt;= CAPACITY || wc \u0026gt;= (core ? corePoolSize : maximumPoolSize)) return false; //原子操作将workcount的数量加1 if (compareAndIncrementWorkerCount(c)) break retry; // 如果线程的状态改变了就再次执行上述操作 c = ctl.get(); if (runStateOf(c) != rs) continue retry; // else CAS failed due to workerCount change; retry inner loop } } // 标记工作线程是否启动成功 boolean workerStarted = false; // 标记工作线程是否创建成功 boolean workerAdded = false; Worker w = null; try { w = new Worker(firstTask); final Thread t = w.thread; if (t != null) { // 加锁 final ReentrantLock mainLock = this.mainLock; mainLock.lock(); try { //获取线程池状态 int rs = runStateOf(ctl.get()); //rs \u0026lt; SHUTDOWN 如果线程池状态依然为RUNNING,并且线程的状态是存活的话,就会将工作线程添加到工作线程集合中 //(rs=SHUTDOWN \u0026amp;\u0026amp; firstTask == null)如果线程池状态小于STOP,也就是RUNNING或者SHUTDOWN状态下,同时传入的任务实例firstTask为null,则需要添加到工作线程集合和启动新的Worker // firstTask == null证明只新建线程而不执行任务 if (rs \u0026lt; SHUTDOWN || (rs == SHUTDOWN \u0026amp;\u0026amp; firstTask == null)) { if (t.isAlive()) // precheck that t is startable throw new IllegalThreadStateException(); workers.add(w); //更新当前工作线程的最大容量 int s = workers.size(); if (s \u0026gt; largestPoolSize) largestPoolSize = s; // 工作线程是否启动成功 workerAdded = true; } } finally { // 释放锁 mainLock.unlock(); } //// 如果成功添加工作线程,则调用Worker内部的线程实例t的Thread#start()方法启动真实的线程实例 if (workerAdded) { t.start(); /// 标记线程启动成功 workerStarted = true; } } } finally { // 线程启动失败,需要从工作线程中移除对应的Worker if (! workerStarted) addWorkerFailed(w); } return workerStarted; } 更多关于线程池源码分析的内容推荐这篇文章:硬核干货: 4W 字从源码上分析 JUC 线程池 ThreadPoolExecutor 的实现原理\n现在,让我们在回到示例代码, 现在应该是不是很容易就可以搞懂它的原理了呢?\n没搞懂的话,也没关系,可以看看我的分析:\n我们在代码中模拟了 10 个任务,我们配置的核心线程数为 5、等待队列容量为 100 ,所以每次只可能存在 5 个任务同时执行,剩下的 5 个任务会被放到等待队列中去。当前的 5 个任务中如果有任务被执行完了,线程池就会去拿新的任务执行。\n几个常见的对比 # Runnable vs Callable # Runnable自 Java 1.0 以来一直存在,但Callable仅在 Java 1.5 中引入,目的就是为了来处理Runnable不支持的用例。Runnable 接口不会返回结果或抛出检查异常,但是 Callable 接口可以。所以,如果任务不需要返回结果或抛出异常推荐使用 Runnable 接口,这样代码看起来会更加简洁。\n工具类 Executors 可以实现将 Runnable 对象转换成 Callable 对象。(Executors.callable(Runnable task) 或 Executors.callable(Runnable task, Object result))。\nRunnable.java\n@FunctionalInterface public interface Runnable { /** * 被线程执行,没有返回值也无法抛出异常 */ public abstract void run(); } Callable.java\n@FunctionalInterface public interface Callable\u0026lt;V\u0026gt; { /** * 计算结果,或在无法这样做时抛出异常。 * @return 计算得出的结果 * @throws 如果无法计算结果,则抛出异常 */ V call() throws Exception; } execute() vs submit() # execute() 和 submit()是两种提交任务到线程池的方法,有一些区别:\n返回值:execute() 方法用于提交不需要返回值的任务。通常用于执行 Runnable 任务,无法判断任务是否被线程池成功执行。submit() 方法用于提交需要返回值的任务。可以提交 Runnable 或 Callable 任务。submit() 方法返回一个 Future 对象,通过这个 Future 对象可以判断任务是否执行成功,并获取任务的返回值(get()方法会阻塞当前线程直到任务完成, get(long timeout,TimeUnit unit)多了一个超时时间,如果在 timeout 时间内任务还没有执行完,就会抛出 java.util.concurrent.TimeoutException)。 异常处理:在使用 submit() 方法时,可以通过 Future 对象处理任务执行过程中抛出的异常;而在使用 execute() 方法时,异常处理需要通过自定义的 ThreadFactory (在线程工厂创建线程的时候设置UncaughtExceptionHandler对象来 处理异常)或 ThreadPoolExecutor 的 afterExecute() 方法来处理 示例 1:使用 get()方法获取返回值。\n// 这里只是为了演示使用,推荐使用 `ThreadPoolExecutor` 构造方法来创建线程池。 ExecutorService executorService = Executors.newFixedThreadPool(3); Future\u0026lt;String\u0026gt; submit = executorService.submit(() -\u0026gt; { try { Thread.sleep(5000L); } catch (InterruptedException e) { e.printStackTrace(); } return \u0026#34;abc\u0026#34;; }); String s = submit.get(); System.out.println(s); executorService.shutdown(); 输出:\nabc 示例 2:使用 get(long timeout,TimeUnit unit)方法获取返回值。\nExecutorService executorService = Executors.newFixedThreadPool(3); Future\u0026lt;String\u0026gt; submit = executorService.submit(() -\u0026gt; { try { Thread.sleep(5000L); } catch (InterruptedException e) { e.printStackTrace(); } return \u0026#34;abc\u0026#34;; }); String s = submit.get(3, TimeUnit.SECONDS); System.out.println(s); executorService.shutdown(); 输出:\nException in thread \u0026#34;main\u0026#34; java.util.concurrent.TimeoutException at java.util.concurrent.FutureTask.get(FutureTask.java:205) shutdown()VSshutdownNow() # shutdown() :关闭线程池,线程池的状态变为 SHUTDOWN。线程池不再接受新任务了,但是队列里的任务得执行完毕。 shutdownNow() :关闭线程池,线程池的状态变为 STOP。线程池会终止当前正在运行的任务,并停止处理排队的任务并返回正在等待执行的 List。 isTerminated() VS isShutdown() # isShutDown 当调用 shutdown() 方法后返回为 true。 isTerminated 当调用 shutdown() 方法后,并且所有提交的任务完成后返回为 true 几种常见的内置线程池 # FixedThreadPool # 介绍 # FixedThreadPool 被称为可重用固定线程数的线程池。通过 Executors 类中的相关源代码来看一下相关实现:\n/** * 创建一个可重用固定数量线程的线程池 */ public static ExecutorService newFixedThreadPool(int nThreads, ThreadFactory threadFactory) { return new ThreadPoolExecutor(nThreads, nThreads, 0L, TimeUnit.MILLISECONDS, new LinkedBlockingQueue\u0026lt;Runnable\u0026gt;(), threadFactory); } 另外还有一个 FixedThreadPool 的实现方法,和上面的类似,所以这里不多做阐述:\npublic static ExecutorService newFixedThreadPool(int nThreads) { return new ThreadPoolExecutor(nThreads, nThreads, 0L, TimeUnit.MILLISECONDS, new LinkedBlockingQueue\u0026lt;Runnable\u0026gt;()); } 从上面源代码可以看出新创建的 FixedThreadPool 的 corePoolSize 和 maximumPoolSize 都被设置为 nThreads,这个 nThreads 参数是我们使用的时候自己传递的。\n即使 maximumPoolSize 的值比 corePoolSize 大,也至多只会创建 corePoolSize 个线程。这是因为FixedThreadPool 使用的是容量为 Integer.MAX_VALUE 的 LinkedBlockingQueue(无界队列),队列永远不会被放满。\n执行任务过程介绍 # FixedThreadPool 的 execute() 方法运行示意图(该图片来源:《Java 并发编程的艺术》):\n上图说明:\n如果当前运行的线程数小于 corePoolSize, 如果再来新任务的话,就创建新的线程来执行任务; 当前运行的线程数等于 corePoolSize 后, 如果再来新任务的话,会将任务加入 LinkedBlockingQueue; 线程池中的线程执行完 手头的任务后,会在循环中反复从 LinkedBlockingQueue 中获取任务来执行; 为什么不推荐使用FixedThreadPool? # FixedThreadPool 使用无界队列 LinkedBlockingQueue(队列的容量为 Integer.MAX_VALUE)作为线程池的工作队列会对线程池带来如下影响:\n当线程池中的线程数达到 corePoolSize 后,新任务将在无界队列中等待,因此线程池中的线程数不会超过 corePoolSize; 由于使用无界队列时 maximumPoolSize 将是一个无效参数,因为不可能存在任务队列满的情况。所以,通过创建 FixedThreadPool的源码可以看出创建的 FixedThreadPool 的 corePoolSize 和 maximumPoolSize 被设置为同一个值。 由于 1 和 2,使用无界队列时 keepAliveTime 将是一个无效参数; 运行中的 FixedThreadPool(未执行 shutdown()或 shutdownNow())不会拒绝任务,在任务比较多的时候会导致 OOM(内存溢出)。 SingleThreadExecutor # 介绍 # SingleThreadExecutor 是只有一个线程的线程池。下面看看SingleThreadExecutor 的实现:\n/** *返回只有一个线程的线程池 */ public static ExecutorService newSingleThreadExecutor(ThreadFactory threadFactory) { return new FinalizableDelegatedExecutorService (new ThreadPoolExecutor(1, 1, 0L, TimeUnit.MILLISECONDS, new LinkedBlockingQueue\u0026lt;Runnable\u0026gt;(), threadFactory)); } public static ExecutorService newSingleThreadExecutor() { return new FinalizableDelegatedExecutorService (new ThreadPoolExecutor(1, 1, 0L, TimeUnit.MILLISECONDS, new LinkedBlockingQueue\u0026lt;Runnable\u0026gt;())); } 从上面源代码可以看出新创建的 SingleThreadExecutor 的 corePoolSize 和 maximumPoolSize 都被设置为 1,其他参数和 FixedThreadPool 相同。\n执行任务过程介绍 # SingleThreadExecutor 的运行示意图(该图片来源:《Java 并发编程的艺术》):\n上图说明 :\n如果当前运行的线程数少于 corePoolSize,则创建一个新的线程执行任务; 当前线程池中有一个运行的线程后,将任务加入 LinkedBlockingQueue 线程执行完当前的任务后,会在循环中反复从LinkedBlockingQueue 中获取任务来执行; 为什么不推荐使用SingleThreadExecutor? # SingleThreadExecutor 和 FixedThreadPool 一样,使用的都是容量为 Integer.MAX_VALUE 的 LinkedBlockingQueue(无界队列)作为线程池的工作队列。SingleThreadExecutor 使用无界队列作为线程池的工作队列会对线程池带来的影响与 FixedThreadPool 相同。说简单点,就是可能会导致 OOM。\nCachedThreadPool # 介绍 # CachedThreadPool 是一个会根据需要创建新线程的线程池。下面通过源码来看看 CachedThreadPool 的实现:\n/** * 创建一个线程池,根据需要创建新线程,但会在先前构建的线程可用时重用它。 */ public static ExecutorService newCachedThreadPool(ThreadFactory threadFactory) { return new ThreadPoolExecutor(0, Integer.MAX_VALUE, 60L, TimeUnit.SECONDS, new SynchronousQueue\u0026lt;Runnable\u0026gt;(), threadFactory); } public static ExecutorService newCachedThreadPool() { return new ThreadPoolExecutor(0, Integer.MAX_VALUE, 60L, TimeUnit.SECONDS, new SynchronousQueue\u0026lt;Runnable\u0026gt;()); } CachedThreadPool 的corePoolSize 被设置为空(0),maximumPoolSize被设置为 Integer.MAX.VALUE,即它是无界的,这也就意味着如果主线程提交任务的速度高于 maximumPool 中线程处理任务的速度时,CachedThreadPool 会不断创建新的线程。极端情况下,这样会导致耗尽 cpu 和内存资源。\n执行任务过程介绍 # CachedThreadPool 的 execute() 方法的执行示意图(该图片来源:《Java 并发编程的艺术》):\n上图说明:\n首先执行 SynchronousQueue.offer(Runnable task) 提交任务到任务队列。如果当前 maximumPool 中有闲线程正在执行 SynchronousQueue.poll(keepAliveTime,TimeUnit.NANOSECONDS),那么主线程执行 offer 操作与空闲线程执行的 poll 操作配对成功,主线程把任务交给空闲线程执行,execute()方法执行完成,否则执行下面的步骤 2; 当初始 maximumPool 为空,或者 maximumPool 中没有空闲线程时,将没有线程执行 SynchronousQueue.poll(keepAliveTime,TimeUnit.NANOSECONDS)。这种情况下,步骤 1 将失败,此时 CachedThreadPool 会创建新线程执行任务,execute 方法执行完成; 为什么不推荐使用CachedThreadPool? # CachedThreadPool 使用的是同步队列 SynchronousQueue, 允许创建的线程数量为 Integer.MAX_VALUE ,可能会创建大量线程,从而导致 OOM。\nScheduledThreadPool # 介绍 # ScheduledThreadPool 用来在给定的延迟后运行任务或者定期执行任务。这个在实际项目中基本不会被用到,也不推荐使用,大家只需要简单了解一下即可。\npublic static ScheduledExecutorService newScheduledThreadPool(int corePoolSize) { return new ScheduledThreadPoolExecutor(corePoolSize); } public ScheduledThreadPoolExecutor(int corePoolSize) { super(corePoolSize, Integer.MAX_VALUE, 0, NANOSECONDS, new DelayedWorkQueue()); } ScheduledThreadPool 是通过 ScheduledThreadPoolExecutor 创建的,使用的DelayedWorkQueue(延迟阻塞队列)作为线程池的任务队列。\nDelayedWorkQueue 的内部元素并不是按照放入的时间排序,而是会按照延迟的时间长短对任务进行排序,内部采用的是“堆”的数据结构,可以保证每次出队的任务都是当前队列中执行时间最靠前的。DelayedWorkQueue 添加元素满了之后会自动扩容原来容量的 1/2,即永远不会阻塞,最大扩容可达 Integer.MAX_VALUE,所以最多只能创建核心线程数的线程。\nScheduledThreadPoolExecutor 继承了 ThreadPoolExecutor,所以创建 ScheduledThreadExecutor 本质也是创建一个 ThreadPoolExecutor 线程池,只是传入的参数不相同。\npublic class ScheduledThreadPoolExecutor extends ThreadPoolExecutor implements ScheduledExecutorService ScheduledThreadPoolExecutor 和 Timer 对比 # Timer 对系统时钟的变化敏感,ScheduledThreadPoolExecutor不是; Timer 只有一个执行线程,因此长时间运行的任务可以延迟其他任务。 ScheduledThreadPoolExecutor 可以配置任意数量的线程。 此外,如果你想(通过提供 ThreadFactory),你可以完全控制创建的线程; 在TimerTask 中抛出的运行时异常会杀死一个线程,从而导致 Timer 死机即计划任务将不再运行。ScheduledThreadExecutor 不仅捕获运行时异常,还允许您在需要时处理它们(通过重写 afterExecute 方法ThreadPoolExecutor)。抛出异常的任务将被取消,但其他任务将继续运行。 关于定时任务的详细介绍,可以看这篇文章: Java 定时任务详解 。\n线程池最佳实践 # Java 线程池最佳实践这篇文章总结了一些使用线程池的时候应该注意的东西,实际项目使用线程池之前可以看看。\n参考 # 《Java 并发编程的艺术》 Java Scheduler ScheduledExecutorService ScheduledThreadPoolExecutor Example java.util.concurrent.ScheduledThreadPoolExecutor Example ThreadPoolExecutor – Java Thread Pool Example "},{"id":508,"href":"/zh/docs/technology/Interview/java/concurrent/java-thread-pool-best-practices/","title":"Java 线程池最佳实践","section":"Concurrent","content":"简单总结一下我了解的使用线程池的时候应该注意的东西,网上似乎还没有专门写这方面的文章。\n1、正确声明线程池 # 线程池必须手动通过 ThreadPoolExecutor 的构造函数来声明,避免使用Executors 类创建线程池,会有 OOM 风险。\nExecutors 返回线程池对象的弊端如下(后文会详细介绍到):\nFixedThreadPool 和 SingleThreadExecutor:使用的是有界阻塞队列 LinkedBlockingQueue,任务队列的默认长度和最大长度为 Integer.MAX_VALUE,可能堆积大量的请求,从而导致 OOM。 CachedThreadPool:使用的是同步队列 SynchronousQueue,允许创建的线程数量为 Integer.MAX_VALUE ,可能会创建大量线程,从而导致 OOM。 ScheduledThreadPool 和 SingleThreadScheduledExecutor : 使用的无界的延迟阻塞队列DelayedWorkQueue,任务队列最大长度为 Integer.MAX_VALUE,可能堆积大量的请求,从而导致 OOM。 说白了就是:使用有界队列,控制线程创建数量。\n除了避免 OOM 的原因之外,不推荐使用 Executors提供的两种快捷的线程池的原因还有:\n实际使用中需要根据自己机器的性能、业务场景来手动配置线程池的参数比如核心线程数、使用的任务队列、饱和策略等等。 我们应该显示地给我们的线程池命名,这样有助于我们定位问题。 2、监测线程池运行状态 # 你可以通过一些手段来检测线程池的运行状态比如 SpringBoot 中的 Actuator 组件。\n除此之外,我们还可以利用 ThreadPoolExecutor 的相关 API 做一个简陋的监控。从下图可以看出, ThreadPoolExecutor提供了获取线程池当前的线程数和活跃线程数、已经执行完成的任务数、正在排队中的任务数等等。\n下面是一个简单的 Demo。printThreadPoolStatus()会每隔一秒打印出线程池的线程数、活跃线程数、完成的任务数、以及队列中的任务数。\n/** * 打印线程池的状态 * * @param threadPool 线程池对象 */ public static void printThreadPoolStatus(ThreadPoolExecutor threadPool) { ScheduledExecutorService scheduledExecutorService = new ScheduledThreadPoolExecutor(1, createThreadFactory(\u0026#34;print-images/thread-pool-status\u0026#34;, false)); scheduledExecutorService.scheduleAtFixedRate(() -\u0026gt; { log.info(\u0026#34;=========================\u0026#34;); log.info(\u0026#34;ThreadPool Size: [{}]\u0026#34;, threadPool.getPoolSize()); log.info(\u0026#34;Active Threads: {}\u0026#34;, threadPool.getActiveCount()); log.info(\u0026#34;Number of Tasks : {}\u0026#34;, threadPool.getCompletedTaskCount()); log.info(\u0026#34;Number of Tasks in Queue: {}\u0026#34;, threadPool.getQueue().size()); log.info(\u0026#34;=========================\u0026#34;); }, 0, 1, TimeUnit.SECONDS); } 3、建议不同类别的业务用不同的线程池 # 很多人在实际项目中都会有类似这样的问题:我的项目中多个业务需要用到线程池,是为每个线程池都定义一个还是说定义一个公共的线程池呢?\n一般建议是不同的业务使用不同的线程池,配置线程池的时候根据当前业务的情况对当前线程池进行配置,因为不同的业务的并发以及对资源的使用情况都不同,重心优化系统性能瓶颈相关的业务。\n我们再来看一个真实的事故案例! (本案例来源自: 《线程池运用不当的一次线上事故》 ,很精彩的一个案例)\n上面的代码可能会存在死锁的情况,为什么呢?画个图给大家捋一捋。\n试想这样一种极端情况:假如我们线程池的核心线程数为 n,父任务(扣费任务)数量为 n,父任务下面有两个子任务(扣费任务下的子任务),其中一个已经执行完成,另外一个被放在了任务队列中。由于父任务把线程池核心线程资源用完,所以子任务因为无法获取到线程资源无法正常执行,一直被阻塞在队列中。父任务等待子任务执行完成,而子任务等待父任务释放线程池资源,这也就造成了 \u0026ldquo;死锁\u0026rdquo; 。\n解决方法也很简单,就是新增加一个用于执行子任务的线程池专门为其服务。\n4、别忘记给线程池命名 # 初始化线程池的时候需要显示命名(设置线程池名称前缀),有利于定位问题。\n默认情况下创建的线程名字类似 pool-1-thread-n 这样的,没有业务含义,不利于我们定位问题。\n给线程池里的线程命名通常有下面两种方式:\n1、利用 guava 的 ThreadFactoryBuilder\nThreadFactory threadFactory = new ThreadFactoryBuilder() .setNameFormat(threadNamePrefix + \u0026#34;-%d\u0026#34;) .setDaemon(true).build(); ExecutorService threadPool = new ThreadPoolExecutor(corePoolSize, maximumPoolSize, keepAliveTime, TimeUnit.MINUTES, workQueue, threadFactory) 2、自己实现 ThreadFactory。\nimport java.util.concurrent.ThreadFactory; import java.util.concurrent.atomic.AtomicInteger; /** * 线程工厂,它设置线程名称,有利于我们定位问题。 */ public final class NamingThreadFactory implements ThreadFactory { private final AtomicInteger threadNum = new AtomicInteger(); private final String name; /** * 创建一个带名字的线程池生产工厂 */ public NamingThreadFactory(String name) { this.name = name; } @Override public Thread newThread(Runnable r) { Thread t = new Thread(r); t.setName(name + \u0026#34; [#\u0026#34; + threadNum.incrementAndGet() + \u0026#34;]\u0026#34;); return t; } } 5、正确配置线程池参数 # 说到如何给线程池配置参数,美团的骚操作至今让我难忘(后面会提到)!\n我们先来看一下各种书籍和博客上一般推荐的配置线程池参数的方式,可以作为参考。\n常规操作 # 很多人甚至可能都会觉得把线程池配置过大一点比较好!我觉得这明显是有问题的。就拿我们生活中非常常见的一例子来说:并不是人多就能把事情做好,增加了沟通交流成本。你本来一件事情只需要 3 个人做,你硬是拉来了 6 个人,会提升做事效率嘛?我想并不会。 线程数量过多的影响也是和我们分配多少人做事情一样,对于多线程这个场景来说主要是增加了上下文切换 成本。不清楚什么是上下文切换的话,可以看我下面的介绍。\n上下文切换:\n多线程编程中一般线程的个数都大于 CPU 核心的个数,而一个 CPU 核心在任意时刻只能被一个线程使用,为了让这些线程都能得到有效执行,CPU 采取的策略是为每个线程分配时间片并轮转的形式。当一个线程的时间片用完的时候就会重新处于就绪状态让给其他线程使用,这个过程就属于一次上下文切换。概括来说就是:当前任务在执行完 CPU 时间片切换到另一个任务之前会先保存自己的状态,以便下次再切换回这个任务时,可以再加载这个任务的状态。任务从保存到再加载的过程就是一次上下文切换。\n上下文切换通常是计算密集型的。也就是说,它需要相当可观的处理器时间,在每秒几十上百次的切换中,每次切换都需要纳秒量级的时间。所以,上下文切换对系统来说意味着消耗大量的 CPU 时间,事实上,可能是操作系统中时间消耗最大的操作。\nLinux 相比与其他操作系统(包括其他类 Unix 系统)有很多的优点,其中有一项就是,其上下文切换和模式切换的时间消耗非常少。\n类比于现实世界中的人类通过合作做某件事情,我们可以肯定的一点是线程池大小设置过大或者过小都会有问题,合适的才是最好。\n如果我们设置的线程池数量太小的话,如果同一时间有大量任务/请求需要处理,可能会导致大量的请求/任务在任务队列中排队等待执行,甚至会出现任务队列满了之后任务/请求无法处理的情况,或者大量任务堆积在任务队列导致 OOM。这样很明显是有问题的,CPU 根本没有得到充分利用。 如果我们设置线程数量太大,大量线程可能会同时在争取 CPU 资源,这样会导致大量的上下文切换,从而增加线程的执行时间,影响了整体执行效率。 有一个简单并且适用面比较广的公式:\nCPU 密集型任务 (N): 这种任务消耗的主要是 CPU 资源,线程数应设置为 N(CPU 核心数)。由于任务主要瓶颈在于 CPU 计算能力,与核心数相等的线程数能够最大化 CPU 利用率,过多线程反而会导致竞争和上下文切换开销。 I/O 密集型任务(M * N): 这类任务大部分时间处理 I/O 交互,线程在等待 I/O 时不占用 CPU。 为了充分利用 CPU 资源,线程数可以设置为 M * N,其中 N 是 CPU 核心数,M 是一个大于 1 的倍数,建议默认设置为 2 ,具体取值取决于 I/O 等待时间和任务特点,需要通过测试和监控找到最佳平衡点。 CPU 密集型任务不再推荐 N+1,原因如下:\n\u0026ldquo;N+1\u0026rdquo; 的初衷是希望预留线程处理突发暂停,但实际上,处理缺页中断等情况仍然需要占用 CPU 核心。 CPU 密集场景下,CPU 始终是瓶颈,预留线程并不能凭空增加 CPU 处理能力,反而可能加剧竞争。 如何判断是 CPU 密集任务还是 IO 密集任务?\nCPU 密集型简单理解就是利用 CPU 计算能力的任务比如你在内存中对大量数据进行排序。但凡涉及到网络读取,文件读取这类都是 IO 密集型,这类任务的特点是 CPU 计算耗费时间相比于等待 IO 操作完成的时间来说很少,大部分时间都花在了等待 IO 操作完成上。\n🌈 拓展一下(参见: issue#1737):\n线程数更严谨的计算的方法应该是:最佳线程数 = N(CPU 核心数)∗(1+WT(线程等待时间)/ST(线程计算时间)),其中 WT(线程等待时间)=线程运行总时间 - ST(线程计算时间)。\n线程等待时间所占比例越高,需要越多线程。线程计算时间所占比例越高,需要越少线程。\n我们可以通过 JDK 自带的工具 VisualVM 来查看 WT/ST 比例。\nCPU 密集型任务的 WT/ST 接近或者等于 0,因此, 线程数可以设置为 N(CPU 核心数)∗(1+0)= N,和我们上面说的 N(CPU 核心数)+1 差不多。\nIO 密集型任务下,几乎全是线程等待时间,从理论上来说,你就可以将线程数设置为 2N(按道理来说,WT/ST 的结果应该比较大,这里选择 2N 的原因应该是为了避免创建过多线程吧)。\n注意:上面提到的公示也只是参考,实际项目不太可能直接按照公式来设置线程池参数,毕竟不同的业务场景对应的需求不同,具体还是要根据项目实际线上运行情况来动态调整。接下来介绍的美团的线程池参数动态配置这种方案就非常不错,很实用!\n美团的骚操作 # 美团技术团队在 《Java 线程池实现原理及其在美团业务中的实践》这篇文章中介绍到对线程池参数实现可自定义配置的思路和方法。\n美团技术团队的思路是主要对线程池的核心参数实现自定义可配置。这三个核心参数是:\ncorePoolSize : 核心线程数定义了最小可以同时运行的线程数量。 maximumPoolSize : 当队列中存放的任务达到队列容量的时候,当前可以同时运行的线程数量变为最大线程数。 workQueue: 当新任务来的时候会先判断当前运行的线程数量是否达到核心线程数,如果达到的话,新任务就会被存放在队列中。 为什么是这三个参数?\n我在这篇 《新手也能看懂的线程池学习总结》 中就说过这三个参数是 ThreadPoolExecutor 最重要的参数,它们基本决定了线程池对于任务的处理策略。\n如何支持参数动态配置? 且看 ThreadPoolExecutor 提供的下面这些方法。\n格外需要注意的是corePoolSize, 程序运行期间的时候,我们调用 setCorePoolSize()这个方法的话,线程池会首先判断当前工作线程数是否大于corePoolSize,如果大于的话就会回收工作线程。\n另外,你也看到了上面并没有动态指定队列长度的方法,美团的方式是自定义了一个叫做 ResizableCapacityLinkedBlockIngQueue 的队列(主要就是把LinkedBlockingQueue的 capacity 字段的 final 关键字修饰给去掉了,让它变为可变的)。\n最终实现的可动态修改线程池参数效果如下。👏👏👏\n如果我们的项目也想要实现这种效果的话,可以借助现成的开源项目:\nHippo4j:异步线程池框架,支持线程池动态变更\u0026amp;监控\u0026amp;报警,无需修改代码轻松引入。支持多种使用模式,轻松引入,致力于提高系统运行保障能力。 Dynamic TP:轻量级动态线程池,内置监控告警功能,集成三方中间件线程池管理,基于主流配置中心(已支持 Nacos、Apollo,Zookeeper、Consul、Etcd,可通过 SPI 自定义实现)。 6、别忘记关闭线程池 # 当线程池不再需要使用时,应该显式地关闭线程池,释放线程资源。\n线程池提供了两个关闭方法:\nshutdown() :关闭线程池,线程池的状态变为 SHUTDOWN。线程池不再接受新任务了,但是队列里的任务得执行完毕。 shutdownNow() :关闭线程池,线程池的状态变为 STOP。线程池会终止当前正在运行的任务,停止处理排队的任务并返回正在等待执行的 List。 调用完 shutdownNow 和 shuwdown 方法后,并不代表线程池已经完成关闭操作,它只是异步的通知线程池进行关闭处理。如果要同步等待线程池彻底关闭后才继续往下执行,需要调用awaitTermination方法进行同步等待。\n在调用 awaitTermination() 方法时,应该设置合理的超时时间,以避免程序长时间阻塞而导致性能问题。另外。由于线程池中的任务可能会被取消或抛出异常,因此在使用 awaitTermination() 方法时还需要进行异常处理。awaitTermination() 方法会抛出 InterruptedException 异常,需要捕获并处理该异常,以避免程序崩溃或者无法正常退出。\n// ... // 关闭线程池 executor.shutdown(); try { // 等待线程池关闭,最多等待5分钟 if (!executor.awaitTermination(5, TimeUnit.MINUTES)) { // 如果等待超时,则打印日志 System.err.println(\u0026#34;线程池未能在5分钟内完全关闭\u0026#34;); } } catch (InterruptedException e) { // 异常处理 } 7、线程池尽量不要放耗时任务 # 线程池本身的目的是为了提高任务执行效率,避免因频繁创建和销毁线程而带来的性能开销。如果将耗时任务提交到线程池中执行,可能会导致线程池中的线程被长时间占用,无法及时响应其他任务,甚至会导致线程池崩溃或者程序假死。\n因此,在使用线程池时,我们应该尽量避免将耗时任务提交到线程池中执行。对于一些比较耗时的操作,如网络请求、文件读写等,可以采用 CompletableFuture 等其他异步操作的方式来处理,以避免阻塞线程池中的线程。\n8、线程池使用的一些小坑 # 重复创建线程池的坑 # 线程池是可以复用的,一定不要频繁创建线程池比如一个用户请求到了就单独创建一个线程池。\n@GetMapping(\u0026#34;wrong\u0026#34;) public String wrong() throws InterruptedException { // 自定义线程池 ThreadPoolExecutor executor = new ThreadPoolExecutor(5,10,1L,TimeUnit.SECONDS,new ArrayBlockingQueue\u0026lt;\u0026gt;(100),new ThreadPoolExecutor.CallerRunsPolicy()); // 处理任务 executor.execute(() -\u0026gt; { // ...... } return \u0026#34;OK\u0026#34;; } 出现这种问题的原因还是对于线程池认识不够,需要加强线程池的基础知识。\nSpring 内部线程池的坑 # 使用 Spring 内部线程池时,一定要手动自定义线程池,配置合理的参数,不然会出现生产问题(一个请求创建一个线程)。\n@Configuration @EnableAsync public class ThreadPoolExecutorConfig { @Bean(name=\u0026#34;threadPoolExecutor\u0026#34;) public Executor threadPoolExecutor(){ ThreadPoolTaskExecutor threadPoolExecutor = new ThreadPoolTaskExecutor(); int processNum = Runtime.getRuntime().availableProcessors(); // 返回可用处理器的Java虚拟机的数量 int corePoolSize = (int) (processNum / (1 - 0.2)); int maxPoolSize = (int) (processNum / (1 - 0.5)); threadPoolExecutor.setCorePoolSize(corePoolSize); // 核心池大小 threadPoolExecutor.setMaxPoolSize(maxPoolSize); // 最大线程数 threadPoolExecutor.setQueueCapacity(maxPoolSize * 1000); // 队列程度 threadPoolExecutor.setThreadPriority(Thread.MAX_PRIORITY); threadPoolExecutor.setDaemon(false); threadPoolExecutor.setKeepAliveSeconds(300);// 线程空闲时间 threadPoolExecutor.setThreadNamePrefix(\u0026#34;test-Executor-\u0026#34;); // 线程名字前缀 return threadPoolExecutor; } } 线程池和 ThreadLocal 共用的坑 # 线程池和 ThreadLocal共用,可能会导致线程从ThreadLocal获取到的是旧值/脏数据。这是因为线程池会复用线程对象,与线程对象绑定的类的静态属性 ThreadLocal 变量也会被重用,这就导致一个线程可能获取到其他线程的ThreadLocal 值。\n不要以为代码中没有显示使用线程池就不存在线程池了,像常用的 Web 服务器 Tomcat 处理任务为了提高并发量,就使用到了线程池,并且使用的是基于原生 Java 线程池改进完善得到的自定义线程池。\n当然了,你可以将 Tomcat 设置为单线程处理任务。不过,这并不合适,会严重影响其处理任务的速度。\nserver.tomcat.max-threads=1 解决上述问题比较建议的办法是使用阿里巴巴开源的 TransmittableThreadLocal(TTL)。TransmittableThreadLocal类继承并加强了 JDK 内置的InheritableThreadLocal类,在使用线程池等会池化复用线程的执行组件情况下,提供ThreadLocal值的传递功能,解决异步执行时上下文传递的问题。\nTransmittableThreadLocal 项目地址: https://github.com/alibaba/transmittable-thread-local 。\n"},{"id":509,"href":"/zh/docs/technology/Interview/java/basis/serialization/","title":"Java 序列化详解","section":"Basis","content":" 什么是序列化和反序列化? # 如果我们需要持久化 Java 对象比如将 Java 对象保存在文件中,或者在网络传输 Java 对象,这些场景都需要用到序列化。\n简单来说:\n序列化:将数据结构或对象转换成可以存储或传输的形式,通常是二进制字节流,也可以是 JSON, XML 等文本格式 反序列化:将在序列化过程中所生成的数据转换为原始数据结构或者对象的过程 对于 Java 这种面向对象编程语言来说,我们序列化的都是对象(Object)也就是实例化后的类(Class),但是在 C++这种半面向对象的语言中,struct(结构体)定义的是数据结构类型,而 class 对应的是对象类型。\n下面是序列化和反序列化常见应用场景:\n对象在进行网络传输(比如远程方法调用 RPC 的时候)之前需要先被序列化,接收到序列化的对象之后需要再进行反序列化; 将对象存储到文件之前需要进行序列化,将对象从文件中读取出来需要进行反序列化; 将对象存储到数据库(如 Redis)之前需要用到序列化,将对象从缓存数据库中读取出来需要反序列化; 将对象存储到内存之前需要进行序列化,从内存中读取出来之后需要进行反序列化。 维基百科是如是介绍序列化的:\n序列化(serialization)在计算机科学的数据处理中,是指将数据结构或对象状态转换成可取用格式(例如存成文件,存于缓冲,或经由网络中发送),以留待后续在相同或另一台计算机环境中,能恢复原先状态的过程。依照序列化格式重新获取字节的结果时,可以利用它来产生与原始对象相同语义的副本。对于许多对象,像是使用大量引用的复杂对象,这种序列化重建的过程并不容易。面向对象中的对象序列化,并不概括之前原始对象所关系的函数。这种过程也称为对象编组(marshalling)。从一系列字节提取数据结构的反向操作,是反序列化(也称为解编组、deserialization、unmarshalling)。\n综上:序列化的主要目的是通过网络传输对象或者说是将对象存储到文件系统、数据库、内存中。\nhttps://www.corejavaguru.com/java/serialization/interview-questions-1\n序列化协议对应于 TCP/IP 4 层模型的哪一层?\n我们知道网络通信的双方必须要采用和遵守相同的协议。TCP/IP 四层模型是下面这样的,序列化协议属于哪一层呢?\n应用层 传输层 网络层 网络接口层 如上图所示,OSI 七层协议模型中,表示层做的事情主要就是对应用层的用户数据进行处理转换为二进制流。反过来的话,就是将二进制流转换成应用层的用户数据。这不就对应的是序列化和反序列化么?\n因为,OSI 七层协议模型中的应用层、表示层和会话层对应的都是 TCP/IP 四层模型中的应用层,所以序列化协议属于 TCP/IP 协议应用层的一部分。\n常见序列化协议有哪些? # JDK 自带的序列化方式一般不会用 ,因为序列化效率低并且存在安全问题。比较常用的序列化协议有 Hessian、Kryo、Protobuf、ProtoStuff,这些都是基于二进制的序列化协议。\n像 JSON 和 XML 这种属于文本类序列化方式。虽然可读性比较好,但是性能较差,一般不会选择。\nJDK 自带的序列化方式 # JDK 自带的序列化,只需实现 java.io.Serializable接口即可。\n@AllArgsConstructor @NoArgsConstructor @Getter @Builder @ToString public class RpcRequest implements Serializable { private static final long serialVersionUID = 1905122041950251207L; private String requestId; private String interfaceName; private String methodName; private Object[] parameters; private Class\u0026lt;?\u0026gt;[] paramTypes; private RpcMessageTypeEnum rpcMessageTypeEnum; } serialVersionUID 有什么作用?\n序列化号 serialVersionUID 属于版本控制的作用。反序列化时,会检查 serialVersionUID 是否和当前类的 serialVersionUID 一致。如果 serialVersionUID 不一致则会抛出 InvalidClassException 异常。强烈推荐每个序列化类都手动指定其 serialVersionUID,如果不手动指定,那么编译器会动态生成默认的 serialVersionUID。\nserialVersionUID 不是被 static 变量修饰了吗?为什么还会被“序列化”?\nstatic 修饰的变量是静态变量,位于方法区,本身是不会被序列化的。 static 变量是属于类的而不是对象。你反序列之后,static 变量的值就像是默认赋予给了对象一样,看着就像是 static 变量被序列化,实际只是假象罢了。\n🐛 修正(参见: issue#2174):static 修饰的变量是静态变量,属于类而非类的实例,本身是不会被序列化的。然而,serialVersionUID 是一个特例,serialVersionUID 的序列化做了特殊处理。当一个对象被序列化时,serialVersionUID 会被写入到序列化的二进制流中;在反序列化时,也会解析它并做一致性判断,以此来验证序列化对象的版本一致性。如果两者不匹配,反序列化过程将抛出 InvalidClassException,因为这通常意味着序列化的类的定义已经发生了更改,可能不再兼容。\n官方说明如下:\nA serializable class can declare its own serialVersionUID explicitly by declaring a field named \u0026quot;serialVersionUID\u0026quot; that must be static, final, and of type long;\n如果想显式指定 serialVersionUID ,则需要在类中使用 static 和 final 关键字来修饰一个 long 类型的变量,变量名字必须为 \u0026quot;serialVersionUID\u0026quot; 。\n也就是说,serialVersionUID 只是用来被 JVM 识别,实际并没有被序列化。\n如果有些字段不想进行序列化怎么办?\n对于不想进行序列化的变量,可以使用 transient 关键字修饰。\ntransient 关键字的作用是:阻止实例中那些用此关键字修饰的的变量序列化;当对象被反序列化时,被 transient 修饰的变量值不会被持久化和恢复。\n关于 transient 还有几点注意:\ntransient 只能修饰变量,不能修饰类和方法。 transient 修饰的变量,在反序列化后变量值将会被置成类型的默认值。例如,如果是修饰 int 类型,那么反序列后结果就是 0。 static 变量因为不属于任何对象(Object),所以无论有没有 transient 关键字修饰,均不会被序列化。 为什么不推荐使用 JDK 自带的序列化?\n我们很少或者说几乎不会直接使用 JDK 自带的序列化方式,主要原因有下面这些原因:\n不支持跨语言调用 : 如果调用的是其他语言开发的服务的时候就不支持了。 性能差:相比于其他序列化框架性能更低,主要原因是序列化之后的字节数组体积较大,导致传输成本加大。 存在安全问题:序列化和反序列化本身并不存在问题。但当输入的反序列化的数据可被用户控制,那么攻击者即可通过构造恶意输入,让反序列化产生非预期的对象,在此过程中执行构造的任意代码。相关阅读: 应用安全:JAVA 反序列化漏洞之殇 - Cryin、 Java 反序列化安全漏洞怎么回事? - Monica。 Kryo # Kryo 是一个高性能的序列化/反序列化工具,由于其变长存储特性并使用了字节码生成机制,拥有较高的运行速度和较小的字节码体积。\n另外,Kryo 已经是一种非常成熟的序列化实现了,已经在 Twitter、Groupon、Yahoo 以及多个著名开源项目(如 Hive、Storm)中广泛的使用。\nguide-rpc-framework 就是使用的 kryo 进行序列化,序列化和反序列化相关的代码如下:\n/** * Kryo serialization class, Kryo serialization efficiency is very high, but only compatible with Java language * * @author shuang.kou * @createTime 2020年05月13日 19:29:00 */ @Slf4j public class KryoSerializer implements Serializer { /** * Because Kryo is not thread safe. So, use ThreadLocal to store Kryo objects */ private final ThreadLocal\u0026lt;Kryo\u0026gt; kryoThreadLocal = ThreadLocal.withInitial(() -\u0026gt; { Kryo kryo = new Kryo(); kryo.register(RpcResponse.class); kryo.register(RpcRequest.class); return kryo; }); @Override public byte[] serialize(Object obj) { try (ByteArrayOutputStream byteArrayOutputStream = new ByteArrayOutputStream(); Output output = new Output(byteArrayOutputStream)) { Kryo kryo = kryoThreadLocal.get(); // Object-\u0026gt;byte:将对象序列化为byte数组 kryo.writeObject(output, obj); kryoThreadLocal.remove(); return output.toBytes(); } catch (Exception e) { throw new SerializeException(\u0026#34;Serialization failed\u0026#34;); } } @Override public \u0026lt;T\u0026gt; T deserialize(byte[] bytes, Class\u0026lt;T\u0026gt; clazz) { try (ByteArrayInputStream byteArrayInputStream = new ByteArrayInputStream(bytes); Input input = new Input(byteArrayInputStream)) { Kryo kryo = kryoThreadLocal.get(); // byte-\u0026gt;Object:从byte数组中反序列化出对象 Object o = kryo.readObject(input, clazz); kryoThreadLocal.remove(); return clazz.cast(o); } catch (Exception e) { throw new SerializeException(\u0026#34;Deserialization failed\u0026#34;); } } } GitHub 地址: https://github.com/EsotericSoftware/kryo 。\nProtobuf # Protobuf 出自于 Google,性能还比较优秀,也支持多种语言,同时还是跨平台的。就是在使用中过于繁琐,因为你需要自己定义 IDL 文件和生成对应的序列化代码。这样虽然不灵活,但是,另一方面导致 protobuf 没有序列化漏洞的风险。\nProtobuf 包含序列化格式的定义、各种语言的库以及一个 IDL 编译器。正常情况下你需要定义 proto 文件,然后使用 IDL 编译器编译成你需要的语言\n一个简单的 proto 文件如下:\n// protobuf的版本 syntax = \u0026#34;proto3\u0026#34;; // SearchRequest会被编译成不同的编程语言的相应对象,比如Java中的class、Go中的struct message Person { //string类型字段 string name = 1; // int 类型字段 int32 age = 2; } GitHub 地址: https://github.com/protocolbuffers/protobuf。\nProtoStuff # 由于 Protobuf 的易用性较差,它的哥哥 Protostuff 诞生了。\nprotostuff 基于 Google protobuf,但是提供了更多的功能和更简易的用法。虽然更加易用,但是不代表 ProtoStuff 性能更差。\nGitHub 地址: https://github.com/protostuff/protostuff。\nHessian # Hessian 是一个轻量级的,自定义描述的二进制 RPC 协议。Hessian 是一个比较老的序列化实现了,并且同样也是跨语言的。\nDubbo2.x 默认启用的序列化方式是 Hessian2 ,但是,Dubbo 对 Hessian2 进行了修改,不过大体结构还是差不多。\n总结 # Kryo 是专门针对 Java 语言序列化方式并且性能非常好,如果你的应用是专门针对 Java 语言的话可以考虑使用,并且 Dubbo 官网的一篇文章中提到说推荐使用 Kryo 作为生产环境的序列化方式。(文章地址: https://cn.dubbo.apache.org/zh-cn/docsv2.7/user/serialization/)。\n像 Protobuf、 ProtoStuff、hessian 这类都是跨语言的序列化方式,如果有跨语言需求的话可以考虑使用。\n除了我上面介绍到的序列化方式的话,还有像 Thrift,Avro 这些。\n"},{"id":510,"href":"/zh/docs/technology/Interview/java/basis/syntactic-sugar/","title":"Java 语法糖详解","section":"Basis","content":" 作者:Hollis\n原文: https://mp.weixin.qq.com/s/o4XdEMq1DL-nBS-f8Za5Aw\n语法糖是大厂 Java 面试常问的一个知识点。\n本文从 Java 编译原理角度,深入字节码及 class 文件,抽丝剥茧,了解 Java 中的语法糖原理及用法,帮助大家在学会如何使用 Java 语法糖的同时,了解这些语法糖背后的原理。\n什么是语法糖? # 语法糖(Syntactic Sugar) 也称糖衣语法,是英国计算机学家 Peter.J.Landin 发明的一个术语,指在计算机语言中添加的某种语法,这种语法对语言的功能并没有影响,但是更方便程序员使用。简而言之,语法糖让程序更加简洁,有更高的可读性。\n有意思的是,在编程领域,除了语法糖,还有语法盐和语法糖精的说法,篇幅有限这里不做扩展了。\n我们所熟知的编程语言中几乎都有语法糖。作者认为,语法糖的多少是评判一个语言够不够牛逼的标准之一。很多人说 Java 是一个“低糖语言”,其实从 Java 7 开始 Java 语言层面上一直在添加各种糖,主要是在“Project Coin”项目下研发。尽管现在 Java 有人还是认为现在的 Java 是低糖,未来还会持续向着“高糖”的方向发展。\nJava 中有哪些常见的语法糖? # 前面提到过,语法糖的存在主要是方便开发人员使用。但其实, Java 虚拟机并不支持这些语法糖。这些语法糖在编译阶段就会被还原成简单的基础语法结构,这个过程就是解语法糖。\n说到编译,大家肯定都知道,Java 语言中,javac命令可以将后缀名为.java的源文件编译为后缀名为.class的可以运行于 Java 虚拟机的字节码。如果你去看com.sun.tools.javac.main.JavaCompiler的源码,你会发现在compile()中有一个步骤就是调用desugar(),这个方法就是负责解语法糖的实现的。\nJava 中最常用的语法糖主要有泛型、变长参数、条件编译、自动拆装箱、内部类等。本文主要来分析下这些语法糖背后的原理。一步一步剥去糖衣,看看其本质。\n我们这里会用到 反编译,你可以通过 Decompilers online 对 Class 文件进行在线反编译。\nswitch 支持 String 与枚举 # 前面提到过,从 Java 7 开始,Java 语言中的语法糖在逐渐丰富,其中一个比较重要的就是 Java 7 中switch开始支持String。\n在开始之前先科普下,Java 中的switch自身原本就支持基本类型。比如int、char等。对于int类型,直接进行数值的比较。对于char类型则是比较其 ascii 码。所以,对于编译器来说,switch中其实只能使用整型,任何类型的比较都要转换成整型。比如byte。short,char(ascii 码是整型)以及int。\n那么接下来看下switch对String的支持,有以下代码:\npublic class switchDemoString { public static void main(String[] args) { String str = \u0026#34;world\u0026#34;; switch (str) { case \u0026#34;hello\u0026#34;: System.out.println(\u0026#34;hello\u0026#34;); break; case \u0026#34;world\u0026#34;: System.out.println(\u0026#34;world\u0026#34;); break; default: break; } } } 反编译后内容如下:\npublic class switchDemoString { public switchDemoString() { } public static void main(String args[]) { String str = \u0026#34;world\u0026#34;; String s; switch((s = str).hashCode()) { default: break; case 99162322: if(s.equals(\u0026#34;hello\u0026#34;)) System.out.println(\u0026#34;hello\u0026#34;); break; case 113318802: if(s.equals(\u0026#34;world\u0026#34;)) System.out.println(\u0026#34;world\u0026#34;); break; } } } 看到这个代码,你知道原来 字符串的 switch 是通过equals()和hashCode()方法来实现的。 还好hashCode()方法返回的是int,而不是long。\n仔细看下可以发现,进行switch的实际是哈希值,然后通过使用equals方法比较进行安全检查,这个检查是必要的,因为哈希可能会发生碰撞。因此它的性能是不如使用枚举进行 switch 或者使用纯整数常量,但这也不是很差。\n泛型 # 我们都知道,很多语言都是支持泛型的,但是很多人不知道的是,不同的编译器对于泛型的处理方式是不同的,通常情况下,一个编译器处理泛型有两种方式:Code specialization和Code sharing。C++和 C#是使用Code specialization的处理机制,而 Java 使用的是Code sharing的机制。\nCode sharing 方式为每个泛型类型创建唯一的字节码表示,并且将该泛型类型的实例都映射到这个唯一的字节码表示上。将多种泛型类形实例映射到唯一的字节码表示是通过类型擦除(type erasue)实现的。\n也就是说,对于 Java 虚拟机来说,他根本不认识Map\u0026lt;String, String\u0026gt; map这样的语法。需要在编译阶段通过类型擦除的方式进行解语法糖。\n类型擦除的主要过程如下:1.将所有的泛型参数用其最左边界(最顶级的父类型)类型替换。 2.移除所有的类型参数。\n以下代码:\nMap\u0026lt;String, String\u0026gt; map = new HashMap\u0026lt;String, String\u0026gt;(); map.put(\u0026#34;name\u0026#34;, \u0026#34;hollis\u0026#34;); map.put(\u0026#34;wechat\u0026#34;, \u0026#34;Hollis\u0026#34;); map.put(\u0026#34;blog\u0026#34;, \u0026#34;www.hollischuang.com\u0026#34;); 解语法糖之后会变成:\nMap map = new HashMap(); map.put(\u0026#34;name\u0026#34;, \u0026#34;hollis\u0026#34;); map.put(\u0026#34;wechat\u0026#34;, \u0026#34;Hollis\u0026#34;); map.put(\u0026#34;blog\u0026#34;, \u0026#34;www.hollischuang.com\u0026#34;); 以下代码:\npublic static \u0026lt;A extends Comparable\u0026lt;A\u0026gt;\u0026gt; A max(Collection\u0026lt;A\u0026gt; xs) { Iterator\u0026lt;A\u0026gt; xi = xs.iterator(); A w = xi.next(); while (xi.hasNext()) { A x = xi.next(); if (w.compareTo(x) \u0026lt; 0) w = x; } return w; } 类型擦除后会变成:\npublic static Comparable max(Collection xs){ Iterator xi = xs.iterator(); Comparable w = (Comparable)xi.next(); while(xi.hasNext()) { Comparable x = (Comparable)xi.next(); if(w.compareTo(x) \u0026lt; 0) w = x; } return w; } 虚拟机中没有泛型,只有普通类和普通方法,所有泛型类的类型参数在编译时都会被擦除,泛型类并没有自己独有的Class类对象。比如并不存在List\u0026lt;String\u0026gt;.class或是List\u0026lt;Integer\u0026gt;.class,而只有List.class。\n自动装箱与拆箱 # 自动装箱就是 Java 自动将原始类型值转换成对应的对象,比如将 int 的变量转换成 Integer 对象,这个过程叫做装箱,反之将 Integer 对象转换成 int 类型值,这个过程叫做拆箱。因为这里的装箱和拆箱是自动进行的非人为转换,所以就称作为自动装箱和拆箱。原始类型 byte, short, char, int, long, float, double 和 boolean 对应的封装类为 Byte, Short, Character, Integer, Long, Float, Double, Boolean。\n先来看个自动装箱的代码:\npublic static void main(String[] args) { int i = 10; Integer n = i; } 反编译后代码如下:\npublic static void main(String args[]) { int i = 10; Integer n = Integer.valueOf(i); } 再来看个自动拆箱的代码:\npublic static void main(String[] args) { Integer i = 10; int n = i; } 反编译后代码如下:\npublic static void main(String args[]) { Integer i = Integer.valueOf(10); int n = i.intValue(); } 从反编译得到内容可以看出,在装箱的时候自动调用的是Integer的valueOf(int)方法。而在拆箱的时候自动调用的是Integer的intValue方法。\n所以,装箱过程是通过调用包装器的 valueOf 方法实现的,而拆箱过程是通过调用包装器的 xxxValue 方法实现的。\n可变长参数 # 可变参数(variable arguments)是在 Java 1.5 中引入的一个特性。它允许一个方法把任意数量的值作为参数。\n看下以下可变参数代码,其中 print 方法接收可变参数:\npublic static void main(String[] args) { print(\u0026#34;Holis\u0026#34;, \u0026#34;公众号:Hollis\u0026#34;, \u0026#34;博客:www.hollischuang.com\u0026#34;, \u0026#34;QQ:907607222\u0026#34;); } public static void print(String... strs) { for (int i = 0; i \u0026lt; strs.length; i++) { System.out.println(strs[i]); } } 反编译后代码:\npublic static void main(String args[]) { print(new String[] { \u0026#34;Holis\u0026#34;, \u0026#34;\\u516C\\u4F17\\u53F7:Hollis\u0026#34;, \u0026#34;\\u535A\\u5BA2\\uFF1Awww.hollischuang.com\u0026#34;, \u0026#34;QQ\\uFF1A907607222\u0026#34; }); } public static transient void print(String strs[]) { for(int i = 0; i \u0026lt; strs.length; i++) System.out.println(strs[i]); } 从反编译后代码可以看出,可变参数在被使用的时候,他首先会创建一个数组,数组的长度就是调用该方法是传递的实参的个数,然后再把参数值全部放到这个数组当中,然后再把这个数组作为参数传递到被调用的方法中。(注:trasient 仅在修饰成员变量时有意义,此处 “修饰方法” 是由于在 javassist 中使用相同数值分别表示 trasient 以及 vararg,见 此处。)\n枚举 # Java SE5 提供了一种新的类型-Java 的枚举类型,关键字enum可以将一组具名的值的有限集合创建为一种新的类型,而这些具名的值可以作为常规的程序组件使用,这是一种非常有用的功能。\n要想看源码,首先得有一个类吧,那么枚举类型到底是什么类呢?是enum吗?答案很明显不是,enum就和class一样,只是一个关键字,他并不是一个类,那么枚举是由什么类维护的呢,我们简单的写一个枚举:\npublic enum t { SPRING,SUMMER; } 然后我们使用反编译,看看这段代码到底是怎么实现的,反编译后代码内容如下:\npublic final class T extends Enum { private T(String s, int i) { super(s, i); } public static T[] values() { T at[]; int i; T at1[]; System.arraycopy(at = ENUM$VALUES, 0, at1 = new T[i = at.length], 0, i); return at1; } public static T valueOf(String s) { return (T)Enum.valueOf(demo/T, s); } public static final T SPRING; public static final T SUMMER; private static final T ENUM$VALUES[]; static { SPRING = new T(\u0026#34;SPRING\u0026#34;, 0); SUMMER = new T(\u0026#34;SUMMER\u0026#34;, 1); ENUM$VALUES = (new T[] { SPRING, SUMMER }); } } 通过反编译后代码我们可以看到,public final class T extends Enum,说明,该类是继承了Enum类的,同时final关键字告诉我们,这个类也是不能被继承的。\n当我们使用enum来定义一个枚举类型的时候,编译器会自动帮我们创建一个final类型的类继承Enum类,所以枚举类型不能被继承。\n内部类 # 内部类又称为嵌套类,可以把内部类理解为外部类的一个普通成员。\n内部类之所以也是语法糖,是因为它仅仅是一个编译时的概念,outer.java里面定义了一个内部类inner,一旦编译成功,就会生成两个完全不同的.class文件了,分别是outer.class和outer$inner.class。所以内部类的名字完全可以和它的外部类名字相同。\npublic class OutterClass { private String userName; public String getUserName() { return userName; } public void setUserName(String userName) { this.userName = userName; } public static void main(String[] args) { } class InnerClass{ private String name; public String getName() { return name; } public void setName(String name) { this.name = name; } } } 以上代码编译后会生成两个 class 文件:OutterClass$InnerClass.class、OutterClass.class 。当我们尝试对OutterClass.class文件进行反编译的时候,命令行会打印以下内容:Parsing OutterClass.class...Parsing inner class OutterClass$InnerClass.class... Generating OutterClass.jad 。他会把两个文件全部进行反编译,然后一起生成一个OutterClass.jad文件。文件内容如下:\npublic class OutterClass { class InnerClass { public String getName() { return name; } public void setName(String name) { this.name = name; } private String name; final OutterClass this$0; InnerClass() { this.this$0 = OutterClass.this; super(); } } public OutterClass() { } public String getUserName() { return userName; } public void setUserName(String userName){ this.userName = userName; } public static void main(String args1[]) { } private String userName; } 为什么内部类可以使用外部类的 private 属性:\n我们在 InnerClass 中增加一个方法,打印外部类的 userName 属性\n//省略其他属性 public class OutterClass { private String userName; ...... class InnerClass{ ...... public void printOut(){ System.out.println(\u0026#34;Username from OutterClass:\u0026#34;+userName); } } } // 此时,使用javap -p命令对OutterClass反编译结果: public classOutterClass { private String userName; ...... static String access$000(OutterClass); } // 此时,InnerClass的反编译结果: class OutterClass$InnerClass { final OutterClass this$0; ...... public void printOut(); } 实际上,在编译完成之后,inner 实例内部会有指向 outer 实例的引用this$0,但是简单的outer.name是无法访问 private 属性的。从反编译的结果可以看到,outer 中会有一个桥方法static String access$000(OutterClass),恰好返回 String 类型,即 userName 属性。正是通过这个方法实现内部类访问外部类私有属性。所以反编译后的printOut()方法大致如下:\npublic void printOut() { System.out.println(\u0026#34;Username from OutterClass:\u0026#34; + OutterClass.access$000(this.this$0)); } 补充:\n匿名内部类、局部内部类、静态内部类也是通过桥方法来获取 private 属性。 静态内部类没有this$0的引用 匿名内部类、局部内部类通过复制使用局部变量,该变量初始化之后就不能被修改。以下是一个案例: public class OutterClass { private String userName; public void test(){ //这里i初始化为1后就不能再被修改 int i=1; class Inner{ public void printName(){ System.out.println(userName); System.out.println(i); } } } } 反编译后:\n//javap命令反编译Inner的结果 //i被复制进内部类,且为final class OutterClass$1Inner { final int val$i; final OutterClass this$0; OutterClass$1Inner(); public void printName(); } 条件编译 # —般情况下,程序中的每一行代码都要参加编译。但有时候出于对程序代码优化的考虑,希望只对其中一部分内容进行编译,此时就需要在程序中加上条件,让编译器只对满足条件的代码进行编译,将不满足条件的代码舍弃,这就是条件编译。\n如在 C 或 CPP 中,可以通过预处理语句来实现条件编译。其实在 Java 中也可实现条件编译。我们先来看一段代码:\npublic class ConditionalCompilation { public static void main(String[] args) { final boolean DEBUG = true; if(DEBUG) { System.out.println(\u0026#34;Hello, DEBUG!\u0026#34;); } final boolean ONLINE = false; if(ONLINE){ System.out.println(\u0026#34;Hello, ONLINE!\u0026#34;); } } } 反编译后代码如下:\npublic class ConditionalCompilation { public ConditionalCompilation() { } public static void main(String args[]) { boolean DEBUG = true; System.out.println(\u0026#34;Hello, DEBUG!\u0026#34;); boolean ONLINE = false; } } 首先,我们发现,在反编译后的代码中没有System.out.println(\u0026quot;Hello, ONLINE!\u0026quot;);,这其实就是条件编译。当if(ONLINE)为 false 的时候,编译器就没有对其内的代码进行编译。\n所以,Java 语法的条件编译,是通过判断条件为常量的 if 语句实现的。其原理也是 Java 语言的语法糖。根据 if 判断条件的真假,编译器直接把分支为 false 的代码块消除。通过该方式实现的条件编译,必须在方法体内实现,而无法在整个 Java 类的结构或者类的属性上进行条件编译,这与 C/C++的条件编译相比,确实更有局限性。在 Java 语言设计之初并没有引入条件编译的功能,虽有局限,但是总比没有更强。\n断言 # 在 Java 中,assert关键字是从 JAVA SE 1.4 引入的,为了避免和老版本的 Java 代码中使用了assert关键字导致错误,Java 在执行的时候默认是不启动断言检查的(这个时候,所有的断言语句都将忽略!),如果要开启断言检查,则需要用开关-enableassertions或-ea来开启。\n看一段包含断言的代码:\npublic class AssertTest { public static void main(String args[]) { int a = 1; int b = 1; assert a == b; System.out.println(\u0026#34;公众号:Hollis\u0026#34;); assert a != b : \u0026#34;Hollis\u0026#34;; System.out.println(\u0026#34;博客:www.hollischuang.com\u0026#34;); } } 反编译后代码如下:\npublic class AssertTest { public AssertTest() { } public static void main(String args[]) { int a = 1; int b = 1; if(!$assertionsDisabled \u0026amp;\u0026amp; a != b) throw new AssertionError(); System.out.println(\u0026#34;\\u516C\\u4F17\\u53F7\\uFF1AHollis\u0026#34;); if(!$assertionsDisabled \u0026amp;\u0026amp; a == b) { throw new AssertionError(\u0026#34;Hollis\u0026#34;); } else { System.out.println(\u0026#34;\\u535A\\u5BA2\\uFF1Awww.hollischuang.com\u0026#34;); return; } } static final boolean $assertionsDisabled = !com/hollis/suguar/AssertTest.desiredAssertionStatus(); } 很明显,反编译之后的代码要比我们自己的代码复杂的多。所以,使用了 assert 这个语法糖我们节省了很多代码。其实断言的底层实现就是 if 语言,如果断言结果为 true,则什么都不做,程序继续执行,如果断言结果为 false,则程序抛出 AssertError 来打断程序的执行。-enableassertions会设置$assertionsDisabled 字段的值。\n数值字面量 # 在 java 7 中,数值字面量,不管是整数还是浮点数,都允许在数字之间插入任意多个下划线。这些下划线不会对字面量的数值产生影响,目的就是方便阅读。\n比如:\npublic class Test { public static void main(String... args) { int i = 10_000; System.out.println(i); } } 反编译后:\npublic class Test { public static void main(String[] args) { int i = 10000; System.out.println(i); } } 反编译后就是把_删除了。也就是说 编译器并不认识在数字字面量中的_,需要在编译阶段把他去掉。\nfor-each # 增强 for 循环(for-each)相信大家都不陌生,日常开发经常会用到的,他会比 for 循环要少写很多代码,那么这个语法糖背后是如何实现的呢?\npublic static void main(String... args) { String[] strs = {\u0026#34;Hollis\u0026#34;, \u0026#34;公众号:Hollis\u0026#34;, \u0026#34;博客:www.hollischuang.com\u0026#34;}; for (String s : strs) { System.out.println(s); } List\u0026lt;String\u0026gt; strList = ImmutableList.of(\u0026#34;Hollis\u0026#34;, \u0026#34;公众号:Hollis\u0026#34;, \u0026#34;博客:www.hollischuang.com\u0026#34;); for (String s : strList) { System.out.println(s); } } 反编译后代码如下:\npublic static transient void main(String args[]) { String strs[] = { \u0026#34;Hollis\u0026#34;, \u0026#34;\\u516C\\u4F17\\u53F7\\uFF1AHollis\u0026#34;, \u0026#34;\\u535A\\u5BA2\\uFF1Awww.hollischuang.com\u0026#34; }; String args1[] = strs; int i = args1.length; for(int j = 0; j \u0026lt; i; j++) { String s = args1[j]; System.out.println(s); } List strList = ImmutableList.of(\u0026#34;Hollis\u0026#34;, \u0026#34;\\u516C\\u4F17\\u53F7\\uFF1AHollis\u0026#34;, \u0026#34;\\u535A\\u5BA2\\uFF1Awww.hollischuang.com\u0026#34;); String s; for(Iterator iterator = strList.iterator(); iterator.hasNext(); System.out.println(s)) s = (String)iterator.next(); } 代码很简单,for-each 的实现原理其实就是使用了普通的 for 循环和迭代器。\ntry-with-resource # Java 里,对于文件操作 IO 流、数据库连接等开销非常昂贵的资源,用完之后必须及时通过 close 方法将其关闭,否则资源会一直处于打开状态,可能会导致内存泄露等问题。\n关闭资源的常用方式就是在finally块里是释放,即调用close方法。比如,我们经常会写这样的代码:\npublic static void main(String[] args) { BufferedReader br = null; try { String line; br = new BufferedReader(new FileReader(\u0026#34;d:\\\\hollischuang.xml\u0026#34;)); while ((line = br.readLine()) != null) { System.out.println(line); } } catch (IOException e) { // handle exception } finally { try { if (br != null) { br.close(); } } catch (IOException ex) { // handle exception } } } 从 Java 7 开始,jdk 提供了一种更好的方式关闭资源,使用try-with-resources语句,改写一下上面的代码,效果如下:\npublic static void main(String... args) { try (BufferedReader br = new BufferedReader(new FileReader(\u0026#34;d:\\\\ hollischuang.xml\u0026#34;))) { String line; while ((line = br.readLine()) != null) { System.out.println(line); } } catch (IOException e) { // handle exception } } 看,这简直是一大福音啊,虽然我之前一般使用IOUtils去关闭流,并不会使用在finally中写很多代码的方式,但是这种新的语法糖看上去好像优雅很多呢。看下他的背后:\npublic static transient void main(String args[]) { BufferedReader br; Throwable throwable; br = new BufferedReader(new FileReader(\u0026#34;d:\\\\ hollischuang.xml\u0026#34;)); throwable = null; String line; try { while((line = br.readLine()) != null) System.out.println(line); } catch(Throwable throwable2) { throwable = throwable2; throw throwable2; } if(br != null) if(throwable != null) try { br.close(); } catch(Throwable throwable1) { throwable.addSuppressed(throwable1); } else br.close(); break MISSING_BLOCK_LABEL_113; Exception exception; exception; if(br != null) if(throwable != null) try { br.close(); } catch(Throwable throwable3) { throwable.addSuppressed(throwable3); } else br.close(); throw exception; IOException ioexception; ioexception; } } 其实背后的原理也很简单,那些我们没有做的关闭资源的操作,编译器都帮我们做了。所以,再次印证了,语法糖的作用就是方便程序员的使用,但最终还是要转成编译器认识的语言。\nLambda 表达式 # 关于 lambda 表达式,有人可能会有质疑,因为网上有人说他并不是语法糖。其实我想纠正下这个说法。Lambda 表达式不是匿名内部类的语法糖,但是他也是一个语法糖。实现方式其实是依赖了几个 JVM 底层提供的 lambda 相关 api。\n先来看一个简单的 lambda 表达式。遍历一个 list:\npublic static void main(String... args) { List\u0026lt;String\u0026gt; strList = ImmutableList.of(\u0026#34;Hollis\u0026#34;, \u0026#34;公众号:Hollis\u0026#34;, \u0026#34;博客:www.hollischuang.com\u0026#34;); strList.forEach( s -\u0026gt; { System.out.println(s); } ); } 为啥说他并不是内部类的语法糖呢,前面讲内部类我们说过,内部类在编译之后会有两个 class 文件,但是,包含 lambda 表达式的类编译后只有一个文件。\n反编译后代码如下:\npublic static /* varargs */ void main(String ... args) { ImmutableList strList = ImmutableList.of((Object)\u0026#34;Hollis\u0026#34;, (Object)\u0026#34;\\u516c\\u4f17\\u53f7\\uff1aHollis\u0026#34;, (Object)\u0026#34;\\u535a\\u5ba2\\uff1awww.hollischuang.com\u0026#34;); strList.forEach((Consumer\u0026lt;String\u0026gt;)LambdaMetafactory.metafactory(null, null, null, (Ljava/lang/Object;)V, lambda$main$0(java.lang.String ), (Ljava/lang/String;)V)()); } private static /* synthetic */ void lambda$main$0(String s) { System.out.println(s); } 可以看到,在forEach方法中,其实是调用了java.lang.invoke.LambdaMetafactory#metafactory方法,该方法的第四个参数 implMethod 指定了方法实现。可以看到这里其实是调用了一个lambda$main$0方法进行了输出。\n再来看一个稍微复杂一点的,先对 List 进行过滤,然后再输出:\npublic static void main(String... args) { List\u0026lt;String\u0026gt; strList = ImmutableList.of(\u0026#34;Hollis\u0026#34;, \u0026#34;公众号:Hollis\u0026#34;, \u0026#34;博客:www.hollischuang.com\u0026#34;); List HollisList = strList.stream().filter(string -\u0026gt; string.contains(\u0026#34;Hollis\u0026#34;)).collect(Collectors.toList()); HollisList.forEach( s -\u0026gt; { System.out.println(s); } ); } 反编译后代码如下:\npublic static /* varargs */ void main(String ... args) { ImmutableList strList = ImmutableList.of((Object)\u0026#34;Hollis\u0026#34;, (Object)\u0026#34;\\u516c\\u4f17\\u53f7\\uff1aHollis\u0026#34;, (Object)\u0026#34;\\u535a\\u5ba2\\uff1awww.hollischuang.com\u0026#34;); List\u0026lt;Object\u0026gt; HollisList = strList.stream().filter((Predicate\u0026lt;String\u0026gt;)LambdaMetafactory.metafactory(null, null, null, (Ljava/lang/Object;)Z, lambda$main$0(java.lang.String ), (Ljava/lang/String;)Z)()).collect(Collectors.toList()); HollisList.forEach((Consumer\u0026lt;Object\u0026gt;)LambdaMetafactory.metafactory(null, null, null, (Ljava/lang/Object;)V, lambda$main$1(java.lang.Object ), (Ljava/lang/Object;)V)()); } private static /* synthetic */ void lambda$main$1(Object s) { System.out.println(s); } private static /* synthetic */ boolean lambda$main$0(String string) { return string.contains(\u0026#34;Hollis\u0026#34;); } 两个 lambda 表达式分别调用了lambda$main$1和lambda$main$0两个方法。\n所以,lambda 表达式的实现其实是依赖了一些底层的 api,在编译阶段,编译器会把 lambda 表达式进行解糖,转换成调用内部 api 的方式。\n可能遇到的坑 # 泛型 # 一、当泛型遇到重载\npublic class GenericTypes { public static void method(List\u0026lt;String\u0026gt; list) { System.out.println(\u0026#34;invoke method(List\u0026lt;String\u0026gt; list)\u0026#34;); } public static void method(List\u0026lt;Integer\u0026gt; list) { System.out.println(\u0026#34;invoke method(List\u0026lt;Integer\u0026gt; list)\u0026#34;); } } 上面这段代码,有两个重载的函数,因为他们的参数类型不同,一个是List\u0026lt;String\u0026gt;另一个是List\u0026lt;Integer\u0026gt; ,但是,这段代码是编译通不过的。因为我们前面讲过,参数List\u0026lt;Integer\u0026gt;和List\u0026lt;String\u0026gt;编译之后都被擦除了,变成了一样的原生类型 List,擦除动作导致这两个方法的特征签名变得一模一样。\n二、当泛型遇到 catch\n泛型的类型参数不能用在 Java 异常处理的 catch 语句中。因为异常处理是由 JVM 在运行时刻来进行的。由于类型信息被擦除,JVM 是无法区分两个异常类型MyException\u0026lt;String\u0026gt;和MyException\u0026lt;Integer\u0026gt;的\n三、当泛型内包含静态变量\npublic class StaticTest{ public static void main(String[] args){ GT\u0026lt;Integer\u0026gt; gti = new GT\u0026lt;Integer\u0026gt;(); gti.var=1; GT\u0026lt;String\u0026gt; gts = new GT\u0026lt;String\u0026gt;(); gts.var=2; System.out.println(gti.var); } } class GT\u0026lt;T\u0026gt;{ public static int var=0; public void nothing(T x){} } 以上代码输出结果为:2!\n有些同学可能会误认为泛型类是不同的类,对应不同的字节码,其实 由于经过类型擦除,所有的泛型类实例都关联到同一份字节码上,泛型类的静态变量是共享的。上面例子里的GT\u0026lt;Integer\u0026gt;.var和GT\u0026lt;String\u0026gt;.var其实是一个变量。\n自动装箱与拆箱 # 对象相等比较\npublic static void main(String[] args) { Integer a = 1000; Integer b = 1000; Integer c = 100; Integer d = 100; System.out.println(\u0026#34;a == b is \u0026#34; + (a == b)); System.out.println((\u0026#34;c == d is \u0026#34; + (c == d))); } 输出结果:\na == b is false c == d is true 在 Java 5 中,在 Integer 的操作上引入了一个新功能来节省内存和提高性能。整型对象通过使用相同的对象引用实现了缓存和重用。\n适用于整数值区间-128 至 +127。\n只适用于自动装箱。使用构造函数创建对象不适用。\n增强 for 循环 # for (Student stu : students) { if (stu.getId() == 2) students.remove(stu); } 会抛出ConcurrentModificationException异常。\nIterator 是工作在一个独立的线程中,并且拥有一个 mutex 锁。 Iterator 被创建之后会建立一个指向原来对象的单链索引表,当原来的对象数量发生变化时,这个索引表的内容不会同步改变,所以当索引指针往后移动的时候就找不到要迭代的对象,所以按照 fail-fast 原则 Iterator 会马上抛出java.util.ConcurrentModificationException异常。\n所以 Iterator 在工作的时候是不允许被迭代的对象被改变的。但你可以使用 Iterator 本身的方法remove()来删除对象,Iterator.remove() 方法会在删除当前迭代对象的同时维护索引的一致性。\n总结 # 前面介绍了 12 种 Java 中常用的语法糖。所谓语法糖就是提供给开发人员便于开发的一种语法而已。但是这种语法只有开发人员认识。要想被执行,需要进行解糖,即转成 JVM 认识的语法。当我们把语法糖解糖之后,你就会发现其实我们日常使用的这些方便的语法,其实都是一些其他更简单的语法构成的。\n有了这些语法糖,我们在日常开发的时候可以大大提升效率,但是同时也要避过度使用。使用之前最好了解下原理,避免掉坑。\n"},{"id":511,"href":"/zh/docs/technology/Interview/java/basis/why-there-only-value-passing-in-java/","title":"Java 值传递详解","section":"Basis","content":"开始之前,我们先来搞懂下面这两个概念:\n形参\u0026amp;实参 值传递\u0026amp;引用传递 形参\u0026amp;实参 # 方法的定义可能会用到 参数(有参的方法),参数在程序语言中分为:\n实参(实际参数,Arguments):用于传递给函数/方法的参数,必须有确定的值。 形参(形式参数,Parameters):用于定义函数/方法,接收实参,不需要有确定的值。 String hello = \u0026#34;Hello!\u0026#34;; // hello 为实参 sayHello(hello); // str 为形参 void sayHello(String str) { System.out.println(str); } 值传递\u0026amp;引用传递 # 程序设计语言将实参传递给方法(或函数)的方式分为两种:\n值传递:方法接收的是实参值的拷贝,会创建副本。 引用传递:方法接收的直接是实参所引用的对象在堆中的地址,不会创建副本,对形参的修改将影响到实参。 很多程序设计语言(比如 C++、 Pascal )提供了两种参数传递的方式,不过,在 Java 中只有值传递。\n为什么 Java 只有值传递? # 为什么说 Java 只有值传递呢? 不需要太多废话,我通过 3 个例子来给大家证明。\n案例 1:传递基本类型参数 # 代码:\npublic static void main(String[] args) { int num1 = 10; int num2 = 20; swap(num1, num2); System.out.println(\u0026#34;num1 = \u0026#34; + num1); System.out.println(\u0026#34;num2 = \u0026#34; + num2); } public static void swap(int a, int b) { int temp = a; a = b; b = temp; System.out.println(\u0026#34;a = \u0026#34; + a); System.out.println(\u0026#34;b = \u0026#34; + b); } 输出:\na = 20 b = 10 num1 = 10 num2 = 20 解析:\n在 swap() 方法中,a、b 的值进行交换,并不会影响到 num1、num2。因为,a、b 的值,只是从 num1、num2 的复制过来的。也就是说,a、b 相当于 num1、num2 的副本,副本的内容无论怎么修改,都不会影响到原件本身。\n通过上面例子,我们已经知道了一个方法不能修改一个基本数据类型的参数,而对象引用作为参数就不一样,请看案例 2。\n案例 2:传递引用类型参数 1 # 代码:\npublic static void main(String[] args) { int[] arr = { 1, 2, 3, 4, 5 }; System.out.println(arr[0]); change(arr); System.out.println(arr[0]); } public static void change(int[] array) { // 将数组的第一个元素变为0 array[0] = 0; } 输出:\n1 0 解析:\n看了这个案例很多人肯定觉得 Java 对引用类型的参数采用的是引用传递。\n实际上,并不是的,这里传递的还是值,不过,这个值是实参的地址罢了!\n也就是说 change 方法的参数拷贝的是 arr (实参)的地址,因此,它和 arr 指向的是同一个数组对象。这也就说明了为什么方法内部对形参的修改会影响到实参。\n为了更强有力地反驳 Java 对引用类型的参数采用的不是引用传递,我们再来看下面这个案例!\n案例 3:传递引用类型参数 2 # public class Person { private String name; // 省略构造函数、Getter\u0026amp;Setter方法 } public static void main(String[] args) { Person xiaoZhang = new Person(\u0026#34;小张\u0026#34;); Person xiaoLi = new Person(\u0026#34;小李\u0026#34;); swap(xiaoZhang, xiaoLi); System.out.println(\u0026#34;xiaoZhang:\u0026#34; + xiaoZhang.getName()); System.out.println(\u0026#34;xiaoLi:\u0026#34; + xiaoLi.getName()); } public static void swap(Person person1, Person person2) { Person temp = person1; person1 = person2; person2 = temp; System.out.println(\u0026#34;person1:\u0026#34; + person1.getName()); System.out.println(\u0026#34;person2:\u0026#34; + person2.getName()); } 输出:\nperson1:小李 person2:小张 xiaoZhang:小张 xiaoLi:小李 解析:\n怎么回事???两个引用类型的形参互换并没有影响实参啊!\nswap 方法的参数 person1 和 person2 只是拷贝的实参 xiaoZhang 和 xiaoLi 的地址。因此, person1 和 person2 的互换只是拷贝的两个地址的互换罢了,并不会影响到实参 xiaoZhang 和 xiaoLi 。\n引用传递是怎么样的? # 看到这里,相信你已经知道了 Java 中只有值传递,是没有引用传递的。 但是,引用传递到底长什么样呢?下面以 C++ 的代码为例,让你看一下引用传递的庐山真面目。\n#include \u0026lt;iostream\u0026gt; void incr(int\u0026amp; num) { std::cout \u0026lt;\u0026lt; \u0026#34;incr before: \u0026#34; \u0026lt;\u0026lt; num \u0026lt;\u0026lt; \u0026#34;\\n\u0026#34;; num++; std::cout \u0026lt;\u0026lt; \u0026#34;incr after: \u0026#34; \u0026lt;\u0026lt; num \u0026lt;\u0026lt; \u0026#34;\\n\u0026#34;; } int main() { int age = 10; std::cout \u0026lt;\u0026lt; \u0026#34;invoke before: \u0026#34; \u0026lt;\u0026lt; age \u0026lt;\u0026lt; \u0026#34;\\n\u0026#34;; incr(age); std::cout \u0026lt;\u0026lt; \u0026#34;invoke after: \u0026#34; \u0026lt;\u0026lt; age \u0026lt;\u0026lt; \u0026#34;\\n\u0026#34;; } 输出结果:\ninvoke before: 10 incr before: 10 incr after: 11 invoke after: 11 分析:可以看到,在 incr 函数中对形参的修改,可以影响到实参的值。要注意:这里的 incr 形参的数据类型用的是 int\u0026amp; 才为引用传递,如果是用 int 的话还是值传递哦!\n为什么 Java 不引入引用传递呢? # 引用传递看似很好,能在方法内就直接把实参的值修改了,但是,为什么 Java 不引入引用传递呢?\n注意:以下为个人观点看法,并非来自于 Java 官方:\n出于安全考虑,方法内部对值进行的操作,对于调用者都是未知的(把方法定义为接口,调用方不关心具体实现)。你也想象一下,如果拿着银行卡去取钱,取的是 100,扣的是 200,是不是很可怕。 Java 之父 James Gosling 在设计之初就看到了 C、C++ 的许多弊端,所以才想着去设计一门新的语言 Java。在他设计 Java 的时候就遵循了简单易用的原则,摒弃了许多开发者一不留意就会造成问题的“特性”,语言本身的东西少了,开发者要学习的东西也少了。 总结 # Java 中将实参传递给方法(或函数)的方式是 值传递:\n如果参数是基本类型的话,很简单,传递的就是基本类型的字面量值的拷贝,会创建副本。 如果参数是引用类型,传递的就是实参所引用的对象在堆中地址值的拷贝,同样也会创建副本。 参考 # 《Java 核心技术卷 Ⅰ》基础知识第十版第四章 4.5 小节 Java 到底是值传递还是引用传递? - Hollis 的回答 - 知乎 Oracle Java Tutorials - Passing Information to a Method or a Constructor Interview with James Gosling, Father of Java "},{"id":512,"href":"/zh/docs/technology/Interview/java/new-features/java8-common-new-features/","title":"Java8 新特性实战","section":"New Features","content":" 本文来自 cowbi的投稿~\nOracle 于 2014 发布了 Java8(jdk1.8),诸多原因使它成为目前市场上使用最多的 jdk 版本。虽然发布距今已将近 7 年,但很多程序员对其新特性还是不够了解,尤其是用惯了 Java8 之前版本的老程序员,比如我。\n为了不脱离队伍太远,还是有必要对这些新特性做一些总结梳理。它较 jdk.7 有很多变化或者说是优化,比如 interface 里可以有静态方法,并且可以有方法体,这一点就颠覆了之前的认知;java.util.HashMap 数据结构里增加了红黑树;还有众所周知的 Lambda 表达式等等。本文不能把所有的新特性都给大家一一分享,只列出比较常用的新特性给大家做详细讲解。更多相关内容请看 官网关于 Java8 的新特性的介绍。\nInterface # interface 的设计初衷是面向抽象,提高扩展性。这也留有一点遗憾,Interface 修改的时候,实现它的类也必须跟着改。\n为了解决接口的修改与现有的实现不兼容的问题。新 interface 的方法可以用default 或 static修饰,这样就可以有方法体,实现类也不必重写此方法。\n一个 interface 中可以有多个方法被它们修饰,这 2 个修饰符的区别主要也是普通方法和静态方法的区别。\ndefault修饰的方法,是普通实例方法,可以用this调用,可以被子类继承、重写。 static修饰的方法,使用上和一般类静态方法一样。但它不能被子类继承,只能用Interface调用。 我们来看一个实际的例子。\npublic interface InterfaceNew { static void sm() { System.out.println(\u0026#34;interface提供的方式实现\u0026#34;); } static void sm2() { System.out.println(\u0026#34;interface提供的方式实现\u0026#34;); } default void def() { System.out.println(\u0026#34;interface default方法\u0026#34;); } default void def2() { System.out.println(\u0026#34;interface default2方法\u0026#34;); } //须要实现类重写 void f(); } public interface InterfaceNew1 { default void def() { System.out.println(\u0026#34;InterfaceNew1 default方法\u0026#34;); } } 如果有一个类既实现了 InterfaceNew 接口又实现了 InterfaceNew1接口,它们都有def(),并且 InterfaceNew 接口和 InterfaceNew1接口没有继承关系的话,这时就必须重写def()。不然的话,编译的时候就会报错。\npublic class InterfaceNewImpl implements InterfaceNew , InterfaceNew1{ public static void main(String[] args) { InterfaceNewImpl interfaceNew = new InterfaceNewImpl(); interfaceNew.def(); } @Override public void def() { InterfaceNew1.super.def(); } @Override public void f() { } } 在 Java 8 ,接口和抽象类有什么区别的?\n很多小伙伴认为:“既然 interface 也可以有自己的方法实现,似乎和 abstract class 没多大区别了。”\n其实它们还是有区别的\ninterface 和 class 的区别,好像是废话,主要有:\n接口多实现,类单继承 接口的方法是 public abstract 修饰,变量是 public static final 修饰。 abstract class 可以用其他修饰符 interface 的方法是更像是一个扩展插件。而 abstract class 的方法是要继承的。\n开始我们也提到,interface 新增default和static修饰的方法,为了解决接口的修改与现有的实现不兼容的问题,并不是为了要替代abstract class。在使用上,该用 abstract class 的地方还是要用 abstract class,不要因为 interface 的新特性而将之替换。\n记住接口永远和类不一样。\nfunctional interface 函数式接口 # 定义:也称 SAM 接口,即 Single Abstract Method interfaces,有且只有一个抽象方法,但可以有多个非抽象方法的接口。\n在 java 8 中专门有一个包放函数式接口java.util.function,该包下的所有接口都有 @FunctionalInterface 注解,提供函数式编程。\n在其他包中也有函数式接口,其中一些没有@FunctionalInterface 注解,但是只要符合函数式接口的定义就是函数式接口,与是否有\n@FunctionalInterface注解无关,注解只是在编译时起到强制规范定义的作用。其在 Lambda 表达式中有广泛的应用。\nLambda 表达式 # 接下来谈众所周知的 Lambda 表达式。它是推动 Java 8 发布的最重要新特性。是继泛型(Generics)和注解(Annotation)以来最大的变化。\n使用 Lambda 表达式可以使代码变的更加简洁紧凑。让 java 也能支持简单的函数式编程。\nLambda 表达式是一个匿名函数,java 8 允许把函数作为参数传递进方法中。\n语法格式 # (parameters) -\u0026gt; expression 或 (parameters) -\u0026gt;{ statements; } Lambda 实战 # 我们用常用的实例来感受 Lambda 带来的便利\n替代匿名内部类 # 过去给方法传动态参数的唯一方法是使用内部类。比如\n1.Runnable 接口\nnew Thread(new Runnable() { @Override public void run() { System.out.println(\u0026#34;The runable now is using!\u0026#34;); } }).start(); //用lambda new Thread(() -\u0026gt; System.out.println(\u0026#34;It\u0026#39;s a lambda function!\u0026#34;)).start(); 2.Comparator 接口\nList\u0026lt;Integer\u0026gt; strings = Arrays.asList(1, 2, 3); Collections.sort(strings, new Comparator\u0026lt;Integer\u0026gt;() { @Override public int compare(Integer o1, Integer o2) { return o1 - o2;} }); //Lambda Collections.sort(strings, (Integer o1, Integer o2) -\u0026gt; o1 - o2); //分解开 Comparator\u0026lt;Integer\u0026gt; comparator = (Integer o1, Integer o2) -\u0026gt; o1 - o2; Collections.sort(strings, comparator); 3.Listener 接口\nJButton button = new JButton(); button.addItemListener(new ItemListener() { @Override public void itemStateChanged(ItemEvent e) { e.getItem(); } }); //lambda button.addItemListener(e -\u0026gt; e.getItem()); 4.自定义接口\n上面的 3 个例子是我们在开发过程中最常见的,从中也能体会到 Lambda 带来的便捷与清爽。它只保留实际用到的代码,把无用代码全部省略。那它对接口有没有要求呢?我们发现这些匿名内部类只重写了接口的一个方法,当然也只有一个方法须要重写。这就是我们上文提到的函数式接口,也就是说只要方法的参数是函数式接口都可以用 Lambda 表达式。\n@FunctionalInterface public interface Comparator\u0026lt;T\u0026gt;{} @FunctionalInterface public interface Runnable{} 我们自定义一个函数式接口\n@FunctionalInterface public interface LambdaInterface { void f(); } //使用 public class LambdaClass { public static void forEg() { lambdaInterfaceDemo(()-\u0026gt; System.out.println(\u0026#34;自定义函数式接口\u0026#34;)); } //函数式接口参数 static void lambdaInterfaceDemo(LambdaInterface i){ i.f(); } } 集合迭代 # void lamndaFor() { List\u0026lt;String\u0026gt; strings = Arrays.asList(\u0026#34;1\u0026#34;, \u0026#34;2\u0026#34;, \u0026#34;3\u0026#34;); //传统foreach for (String s : strings) { System.out.println(s); } //Lambda foreach strings.forEach((s) -\u0026gt; System.out.println(s)); //or strings.forEach(System.out::println); //map Map\u0026lt;Integer, String\u0026gt; map = new HashMap\u0026lt;\u0026gt;(); map.forEach((k,v)-\u0026gt;System.out.println(v)); } 方法的引用 # Java 8 允许使用 :: 关键字来传递方法或者构造函数引用,无论如何,表达式返回的类型必须是 functional-interface。\npublic class LambdaClassSuper { LambdaInterface sf(){ return null; } } public class LambdaClass extends LambdaClassSuper { public static LambdaInterface staticF() { return null; } public LambdaInterface f() { return null; } void show() { //1.调用静态函数,返回类型必须是functional-interface LambdaInterface t = LambdaClass::staticF; //2.实例方法调用 LambdaClass lambdaClass = new LambdaClass(); LambdaInterface lambdaInterface = lambdaClass::f; //3.超类上的方法调用 LambdaInterface superf = super::sf; //4. 构造方法调用 LambdaInterface tt = LambdaClassSuper::new; } } 访问变量 # int i = 0; Collections.sort(strings, (Integer o1, Integer o2) -\u0026gt; o1 - i); //i =3; lambda 表达式可以引用外边变量,但是该变量默认拥有 final 属性,不能被修改,如果修改,编译时就报错。\nStream # java 新增了 java.util.stream 包,它和之前的流大同小异。之前接触最多的是资源流,比如java.io.FileInputStream,通过流把文件从一个地方输入到另一个地方,它只是内容搬运工,对文件内容不做任何CRUD。\nStream依然不存储数据,不同的是它可以检索(Retrieve)和逻辑处理集合数据、包括筛选、排序、统计、计数等。可以想象成是 Sql 语句。\n它的源数据可以是 Collection、Array 等。由于它的方法参数都是函数式接口类型,所以一般和 Lambda 配合使用。\n流类型 # stream 串行流 parallelStream 并行流,可多线程执行 常用方法 # 接下来我们看java.util.stream.Stream常用方法\n/** * 返回一个串行流 */ default Stream\u0026lt;E\u0026gt; stream() /** * 返回一个并行流 */ default Stream\u0026lt;E\u0026gt; parallelStream() /** * 返回T的流 */ public static\u0026lt;T\u0026gt; Stream\u0026lt;T\u0026gt; of(T t) /** * 返回其元素是指定值的顺序流。 */ public static\u0026lt;T\u0026gt; Stream\u0026lt;T\u0026gt; of(T... values) { return Arrays.stream(values); } /** * 过滤,返回由与给定predicate匹配的该流的元素组成的流 */ Stream\u0026lt;T\u0026gt; filter(Predicate\u0026lt;? super T\u0026gt; predicate); /** * 此流的所有元素是否与提供的predicate匹配。 */ boolean allMatch(Predicate\u0026lt;? super T\u0026gt; predicate) /** * 此流任意元素是否有与提供的predicate匹配。 */ boolean anyMatch(Predicate\u0026lt;? super T\u0026gt; predicate); /** * 返回一个 Stream的构建器。 */ public static\u0026lt;T\u0026gt; Builder\u0026lt;T\u0026gt; builder(); /** * 使用 Collector对此流的元素进行归纳 */ \u0026lt;R, A\u0026gt; R collect(Collector\u0026lt;? super T, A, R\u0026gt; collector); /** * 返回此流中的元素数。 */ long count(); /** * 返回由该流的不同元素(根据 Object.equals(Object) )组成的流。 */ Stream\u0026lt;T\u0026gt; distinct(); /** * 遍历 */ void forEach(Consumer\u0026lt;? super T\u0026gt; action); /** * 用于获取指定数量的流,截短长度不能超过 maxSize 。 */ Stream\u0026lt;T\u0026gt; limit(long maxSize); /** * 用于映射每个元素到对应的结果 */ \u0026lt;R\u0026gt; Stream\u0026lt;R\u0026gt; map(Function\u0026lt;? super T, ? extends R\u0026gt; mapper); /** * 根据提供的 Comparator进行排序。 */ Stream\u0026lt;T\u0026gt; sorted(Comparator\u0026lt;? super T\u0026gt; comparator); /** * 在丢弃流的第一个 n元素后,返回由该流的 n元素组成的流。 */ Stream\u0026lt;T\u0026gt; skip(long n); /** * 返回一个包含此流的元素的数组。 */ Object[] toArray(); /** * 使用提供的 generator函数返回一个包含此流的元素的数组,以分配返回的数组,以及分区执行或调整大小可能需要的任何其他数组。 */ \u0026lt;A\u0026gt; A[] toArray(IntFunction\u0026lt;A[]\u0026gt; generator); /** * 合并流 */ public static \u0026lt;T\u0026gt; Stream\u0026lt;T\u0026gt; concat(Stream\u0026lt;? extends T\u0026gt; a, Stream\u0026lt;? extends T\u0026gt; b) 实战 # 本文列出 Stream 具有代表性的方法之使用,更多的使用方法还是要看 Api。\n@Test public void test() { List\u0026lt;String\u0026gt; strings = Arrays.asList(\u0026#34;abc\u0026#34;, \u0026#34;def\u0026#34;, \u0026#34;gkh\u0026#34;, \u0026#34;abc\u0026#34;); //返回符合条件的stream Stream\u0026lt;String\u0026gt; stringStream = strings.stream().filter(s -\u0026gt; \u0026#34;abc\u0026#34;.equals(s)); //计算流符合条件的流的数量 long count = stringStream.count(); //forEach遍历-\u0026gt;打印元素 strings.stream().forEach(System.out::println); //limit 获取到1个元素的stream Stream\u0026lt;String\u0026gt; limit = strings.stream().limit(1); //toArray 比如我们想看这个limitStream里面是什么,比如转换成String[],比如循环 String[] array = limit.toArray(String[]::new); //map 对每个元素进行操作返回新流 Stream\u0026lt;String\u0026gt; map = strings.stream().map(s -\u0026gt; s + \u0026#34;22\u0026#34;); //sorted 排序并打印 strings.stream().sorted().forEach(System.out::println); //Collectors collect 把abc放入容器中 List\u0026lt;String\u0026gt; collect = strings.stream().filter(string -\u0026gt; \u0026#34;abc\u0026#34;.equals(string)).collect(Collectors.toList()); //把list转为string,各元素用,号隔开 String mergedString = strings.stream().filter(string -\u0026gt; !string.isEmpty()).collect(Collectors.joining(\u0026#34;,\u0026#34;)); //对数组的统计,比如用 List\u0026lt;Integer\u0026gt; number = Arrays.asList(1, 2, 5, 4); IntSummaryStatistics statistics = number.stream().mapToInt((x) -\u0026gt; x).summaryStatistics(); System.out.println(\u0026#34;列表中最大的数 : \u0026#34;+statistics.getMax()); System.out.println(\u0026#34;列表中最小的数 : \u0026#34;+statistics.getMin()); System.out.println(\u0026#34;平均数 : \u0026#34;+statistics.getAverage()); System.out.println(\u0026#34;所有数之和 : \u0026#34;+statistics.getSum()); //concat 合并流 List\u0026lt;String\u0026gt; strings2 = Arrays.asList(\u0026#34;xyz\u0026#34;, \u0026#34;jqx\u0026#34;); Stream.concat(strings2.stream(),strings.stream()).count(); //注意 一个Stream只能操作一次,不能断开,否则会报错。 Stream stream = strings.stream(); //第一次使用 stream.limit(2); //第二次使用 stream.forEach(System.out::println); //报错 java.lang.IllegalStateException: stream has already been operated upon or closed //但是可以这样, 连续使用 stream.limit(2).forEach(System.out::println); } 延迟执行 # 在执行返回 Stream 的方法时,并不立刻执行,而是等返回一个非 Stream 的方法后才执行。因为拿到 Stream 并不能直接用,而是需要处理成一个常规类型。这里的 Stream 可以想象成是二进制流(2 个完全不一样的东东),拿到也看不懂。\n我们下面分解一下 filter 方法。\n@Test public void laziness(){ List\u0026lt;String\u0026gt; strings = Arrays.asList(\u0026#34;abc\u0026#34;, \u0026#34;def\u0026#34;, \u0026#34;gkh\u0026#34;, \u0026#34;abc\u0026#34;); Stream\u0026lt;Integer\u0026gt; stream = strings.stream().filter(new Predicate() { @Override public boolean test(Object o) { System.out.println(\u0026#34;Predicate.test 执行\u0026#34;); return true; } }); System.out.println(\u0026#34;count 执行\u0026#34;); stream.count(); } /*-------执行结果--------*/ count 执行 Predicate.test 执行 Predicate.test 执行 Predicate.test 执行 Predicate.test 执行 按执行顺序应该是先打印 4 次「Predicate.test 执行」,再打印「count 执行」。实际结果恰恰相反。说明 filter 中的方法并没有立刻执行,而是等调用count()方法后才执行。\n上面都是串行 Stream 的实例。并行 parallelStream 在使用方法上和串行一样。主要区别是 parallelStream 可多线程执行,是基于 ForkJoin 框架实现的,有时间大家可以了解一下 ForkJoin 框架和 ForkJoinPool。这里可以简单的理解它是通过线程池来实现的,这样就会涉及到线程安全,线程消耗等问题。下面我们通过代码来体验一下并行流的多线程执行。\n@Test public void parallelStreamTest(){ List\u0026lt;Integer\u0026gt; numbers = Arrays.asList(1, 2, 5, 4); numbers.parallelStream() .forEach(num-\u0026gt;System.out.println(Thread.currentThread().getName()+\u0026#34;\u0026gt;\u0026gt;\u0026#34;+num)); } //执行结果 main\u0026gt;\u0026gt;5 ForkJoinPool.commonPool-worker-2\u0026gt;\u0026gt;4 ForkJoinPool.commonPool-worker-11\u0026gt;\u0026gt;1 ForkJoinPool.commonPool-worker-9\u0026gt;\u0026gt;2 从结果中我们看到,for-each 用到的是多线程。\n小结 # 从源码和实例中我们可以总结出一些 stream 的特点\n通过简单的链式编程,使得它可以方便地对遍历处理后的数据进行再处理。 方法参数都是函数式接口类型 一个 Stream 只能操作一次,操作完就关闭了,继续使用这个 stream 会报错。 Stream 不保存数据,不改变数据源 Optional # 在 阿里巴巴开发手册关于 Optional 的介绍中这样写到:\n防止 NPE,是程序员的基本修养,注意 NPE 产生的场景:\n1) 返回类型为基本数据类型,return 包装数据类型的对象时,自动拆箱有可能产生 NPE。\n反例:public int f() { return Integer 对象}, 如果为 null,自动解箱抛 NPE。\n2) 数据库的查询结果可能为 null。\n3) 集合里的元素即使 isNotEmpty,取出的数据元素也可能为 null。\n4) 远程调用返回对象时,一律要求进行空指针判断,防止 NPE。\n5) 对于 Session 中获取的数据,建议进行 NPE 检查,避免空指针。\n6) 级联调用 obj.getA().getB().getC();一连串调用,易产生 NPE。\n正例:使用 JDK8 的 Optional 类来防止 NPE 问题。\n他建议使用 Optional 解决 NPE(java.lang.NullPointerException)问题,它就是为 NPE 而生的,其中可以包含空值或非空值。下面我们通过源码逐步揭开 Optional 的红盖头。\n假设有一个 Zoo 类,里面有个属性 Dog,需求要获取 Dog 的 age。\nclass Zoo { private Dog dog; } class Dog { private int age; } 传统解决 NPE 的办法如下:\nZoo zoo = getZoo(); if(zoo != null){ Dog dog = zoo.getDog(); if(dog != null){ int age = dog.getAge(); System.out.println(age); } } 层层判断对象非空,有人说这种方式很丑陋不优雅,我并不这么认为。反而觉得很整洁,易读,易懂。你们觉得呢?\nOptional 是这样的实现的:\nOptional.ofNullable(zoo).map(o -\u0026gt; o.getDog()).map(d -\u0026gt; d.getAge()).ifPresent(age -\u0026gt; System.out.println(age) ); 是不是简洁了很多呢?\n如何创建一个 Optional # 上例中Optional.ofNullable是其中一种创建 Optional 的方式。我们先看一下它的含义和其他创建 Optional 的源码方法。\n/** * Common instance for {@code empty()}. 全局EMPTY对象 */ private static final Optional\u0026lt;?\u0026gt; EMPTY = new Optional\u0026lt;\u0026gt;(); /** * Optional维护的值 */ private final T value; /** * 如果value是null就返回EMPTY,否则就返回of(T) */ public static \u0026lt;T\u0026gt; Optional\u0026lt;T\u0026gt; ofNullable(T value) { return value == null ? empty() : of(value); } /** * 返回 EMPTY 对象 */ public static\u0026lt;T\u0026gt; Optional\u0026lt;T\u0026gt; empty() { Optional\u0026lt;T\u0026gt; t = (Optional\u0026lt;T\u0026gt;) EMPTY; return t; } /** * 返回Optional对象 */ public static \u0026lt;T\u0026gt; Optional\u0026lt;T\u0026gt; of(T value) { return new Optional\u0026lt;\u0026gt;(value); } /** * 私有构造方法,给value赋值 */ private Optional(T value) { this.value = Objects.requireNonNull(value); } /** * 所以如果of(T value) 的value是null,会抛出NullPointerException异常,这样貌似就没处理NPE问题 */ public static \u0026lt;T\u0026gt; T requireNonNull(T obj) { if (obj == null) throw new NullPointerException(); return obj; } ofNullable 方法和of方法唯一区别就是当 value 为 null 时,ofNullable 返回的是EMPTY,of 会抛出 NullPointerException 异常。如果需要把 NullPointerException 暴漏出来就用 of,否则就用 ofNullable。\nmap() 和 flatMap() 有什么区别的?\nmap 和 flatMap 都是将一个函数应用于集合中的每个元素,但不同的是map返回一个新的集合,flatMap是将每个元素都映射为一个集合,最后再将这个集合展平。\n在实际应用场景中,如果map返回的是数组,那么最后得到的是一个二维数组,使用flatMap就是为了将这个二维数组展平变成一个一维数组。\npublic class MapAndFlatMapExample { public static void main(String[] args) { List\u0026lt;String[]\u0026gt; listOfArrays = Arrays.asList( new String[]{\u0026#34;apple\u0026#34;, \u0026#34;banana\u0026#34;, \u0026#34;cherry\u0026#34;}, new String[]{\u0026#34;orange\u0026#34;, \u0026#34;grape\u0026#34;, \u0026#34;pear\u0026#34;}, new String[]{\u0026#34;kiwi\u0026#34;, \u0026#34;melon\u0026#34;, \u0026#34;pineapple\u0026#34;} ); List\u0026lt;String[]\u0026gt; mapResult = listOfArrays.stream() .map(array -\u0026gt; Arrays.stream(array).map(String::toUpperCase).toArray(String[]::new)) .collect(Collectors.toList()); System.out.println(\u0026#34;Using map:\u0026#34;); mapResult.forEach(arrays-\u0026gt; System.out.println(Arrays.toString(arrays))); List\u0026lt;String\u0026gt; flatMapResult = listOfArrays.stream() .flatMap(array -\u0026gt; Arrays.stream(array).map(String::toUpperCase)) .collect(Collectors.toList()); System.out.println(\u0026#34;Using flatMap:\u0026#34;); System.out.println(flatMapResult); } } 运行结果:\nUsing map: [[APPLE, BANANA, CHERRY], [ORANGE, GRAPE, PEAR], [KIWI, MELON, PINEAPPLE]] Using flatMap: [APPLE, BANANA, CHERRY, ORANGE, GRAPE, PEAR, KIWI, MELON, PINEAPPLE] 最简单的理解就是flatMap()可以将map()的结果展开。\n在Optional里面,当使用map()时,如果映射函数返回的是一个普通值,它会将这个值包装在一个新的Optional中。而使用flatMap时,如果映射函数返回的是一个Optional,它会将这个返回的Optional展平,不再包装成嵌套的Optional。\n下面是一个对比的示例代码:\npublic static void main(String[] args) { int userId = 1; // 使用flatMap的代码 String cityUsingFlatMap = getUserById(userId) .flatMap(OptionalExample::getAddressByUser) .map(Address::getCity) .orElse(\u0026#34;Unknown\u0026#34;); System.out.println(\u0026#34;User\u0026#39;s city using flatMap: \u0026#34; + cityUsingFlatMap); // 不使用flatMap的代码 Optional\u0026lt;Optional\u0026lt;Address\u0026gt;\u0026gt; optionalAddress = getUserById(userId) .map(OptionalExample::getAddressByUser); String cityWithoutFlatMap; if (optionalAddress.isPresent()) { Optional\u0026lt;Address\u0026gt; addressOptional = optionalAddress.get(); if (addressOptional.isPresent()) { Address address = addressOptional.get(); cityWithoutFlatMap = address.getCity(); } else { cityWithoutFlatMap = \u0026#34;Unknown\u0026#34;; } } else { cityWithoutFlatMap = \u0026#34;Unknown\u0026#34;; } System.out.println(\u0026#34;User\u0026#39;s city without flatMap: \u0026#34; + cityWithoutFlatMap); } 在Stream和Optional中正确使用flatMap可以减少很多不必要的代码。\n判断 value 是否为 null # /** * value是否为null */ public boolean isPresent() { return value != null; } /** * 如果value不为null执行consumer.accept */ public void ifPresent(Consumer\u0026lt;? super T\u0026gt; consumer) { if (value != null) consumer.accept(value); } 获取 value # /** * Return the value if present, otherwise invoke {@code other} and return * the result of that invocation. * 如果value != null 返回value,否则返回other的执行结果 */ public T orElseGet(Supplier\u0026lt;? extends T\u0026gt; other) { return value != null ? value : other.get(); } /** * 如果value != null 返回value,否则返回T */ public T orElse(T other) { return value != null ? value : other; } /** * 如果value != null 返回value,否则抛出参数返回的异常 */ public \u0026lt;X extends Throwable\u0026gt; T orElseThrow(Supplier\u0026lt;? extends X\u0026gt; exceptionSupplier) throws X { if (value != null) { return value; } else { throw exceptionSupplier.get(); } } /** * value为null抛出NoSuchElementException,不为空返回value。 */ public T get() { if (value == null) { throw new NoSuchElementException(\u0026#34;No value present\u0026#34;); } return value; } 过滤值 # /** * 1. 如果是empty返回empty * 2. predicate.test(value)==true 返回this,否则返回empty */ public Optional\u0026lt;T\u0026gt; filter(Predicate\u0026lt;? super T\u0026gt; predicate) { Objects.requireNonNull(predicate); if (!isPresent()) return this; else return predicate.test(value) ? this : empty(); } 小结 # 看完 Optional 源码,Optional 的方法真的非常简单,值得注意的是如果坚决不想看见 NPE,就不要用 of()、 get()、flatMap(..)。最后再综合用一下 Optional 的高频方法。\nOptional.ofNullable(zoo).map(o -\u0026gt; o.getDog()).map(d -\u0026gt; d.getAge()).filter(v-\u0026gt;v==1).orElse(3); Date-Time API # 这是对java.util.Date强有力的补充,解决了 Date 类的大部分痛点:\n非线程安全 时区处理麻烦 各种格式化、和时间计算繁琐 设计有缺陷,Date 类同时包含日期和时间;还有一个 java.sql.Date,容易混淆。 我们从常用的时间实例来对比 java.util.Date 和新 Date 有什么区别。用java.util.Date的代码该改改了。\njava.time 主要类 # java.util.Date 既包含日期又包含时间,而 java.time 把它们进行了分离\nLocalDateTime.class //日期+时间 format: yyyy-MM-ddTHH:mm:ss.SSS LocalDate.class //日期 format: yyyy-MM-dd LocalTime.class //时间 format: HH:mm:ss 格式化 # Java 8 之前:\npublic void oldFormat(){ Date now = new Date(); //format yyyy-MM-dd SimpleDateFormat sdf = new SimpleDateFormat(\u0026#34;yyyy-MM-dd\u0026#34;); String date = sdf.format(now); System.out.println(String.format(\u0026#34;date format : %s\u0026#34;, date)); //format HH:mm:ss SimpleDateFormat sdft = new SimpleDateFormat(\u0026#34;HH:mm:ss\u0026#34;); String time = sdft.format(now); System.out.println(String.format(\u0026#34;time format : %s\u0026#34;, time)); //format yyyy-MM-dd HH:mm:ss SimpleDateFormat sdfdt = new SimpleDateFormat(\u0026#34;yyyy-MM-dd HH:mm:ss\u0026#34;); String datetime = sdfdt.format(now); System.out.println(String.format(\u0026#34;dateTime format : %s\u0026#34;, datetime)); } Java 8 之后:\npublic void newFormat(){ //format yyyy-MM-dd LocalDate date = LocalDate.now(); System.out.println(String.format(\u0026#34;date format : %s\u0026#34;, date)); //format HH:mm:ss LocalTime time = LocalTime.now().withNano(0); System.out.println(String.format(\u0026#34;time format : %s\u0026#34;, time)); //format yyyy-MM-dd HH:mm:ss LocalDateTime dateTime = LocalDateTime.now(); DateTimeFormatter dateTimeFormatter = DateTimeFormatter.ofPattern(\u0026#34;yyyy-MM-dd HH:mm:ss\u0026#34;); String dateTimeStr = dateTime.format(dateTimeFormatter); System.out.println(String.format(\u0026#34;dateTime format : %s\u0026#34;, dateTimeStr)); } 字符串转日期格式 # Java 8 之前:\n//已弃用 Date date = new Date(\u0026#34;2021-01-26\u0026#34;); //替换为 SimpleDateFormat sdf = new SimpleDateFormat(\u0026#34;yyyy-MM-dd\u0026#34;); Date date1 = sdf.parse(\u0026#34;2021-01-26\u0026#34;); Java 8 之后:\nLocalDate date = LocalDate.of(2021, 1, 26); LocalDate.parse(\u0026#34;2021-01-26\u0026#34;); LocalDateTime dateTime = LocalDateTime.of(2021, 1, 26, 12, 12, 22); LocalDateTime.parse(\u0026#34;2021-01-26 12:12:22\u0026#34;); LocalTime time = LocalTime.of(12, 12, 22); LocalTime.parse(\u0026#34;12:12:22\u0026#34;); Java 8 之前 转换都需要借助 SimpleDateFormat 类,而Java 8 之后只需要 LocalDate、LocalTime、LocalDateTime的 of 或 parse 方法。\n日期计算 # 下面仅以一周后日期为例,其他单位(年、月、日、1/2 日、时等等)大同小异。另外,这些单位都在 java.time.temporal.ChronoUnit 枚举中定义。\nJava 8 之前:\npublic void afterDay(){ //一周后的日期 SimpleDateFormat formatDate = new SimpleDateFormat(\u0026#34;yyyy-MM-dd\u0026#34;); Calendar ca = Calendar.getInstance(); ca.add(Calendar.DATE, 7); Date d = ca.getTime(); String after = formatDate.format(d); System.out.println(\u0026#34;一周后日期:\u0026#34; + after); //算两个日期间隔多少天,计算间隔多少年,多少月方法类似 String dates1 = \u0026#34;2021-12-23\u0026#34;; String dates2 = \u0026#34;2021-02-26\u0026#34;; SimpleDateFormat format = new SimpleDateFormat(\u0026#34;yyyy-MM-dd\u0026#34;); Date date1 = format.parse(dates1); Date date2 = format.parse(dates2); int day = (int) ((date1.getTime() - date2.getTime()) / (1000 * 3600 * 24)); System.out.println(dates1 + \u0026#34;和\u0026#34; + dates2 + \u0026#34;相差\u0026#34; + day + \u0026#34;天\u0026#34;); //结果:2021-02-26和2021-12-23相差300天 } Java 8 之后:\npublic void pushWeek(){ //一周后的日期 LocalDate localDate = LocalDate.now(); //方法1 LocalDate after = localDate.plus(1, ChronoUnit.WEEKS); //方法2 LocalDate after2 = localDate.plusWeeks(1); System.out.println(\u0026#34;一周后日期:\u0026#34; + after); //算两个日期间隔多少天,计算间隔多少年,多少月 LocalDate date1 = LocalDate.parse(\u0026#34;2021-02-26\u0026#34;); LocalDate date2 = LocalDate.parse(\u0026#34;2021-12-23\u0026#34;); Period period = Period.between(date1, date2); System.out.println(\u0026#34;date1 到 date2 相隔:\u0026#34; + period.getYears() + \u0026#34;年\u0026#34; + period.getMonths() + \u0026#34;月\u0026#34; + period.getDays() + \u0026#34;天\u0026#34;); //打印结果是 “date1 到 date2 相隔:0年9月27天” //这里period.getDays()得到的天是抛去年月以外的天数,并不是总天数 //如果要获取纯粹的总天数应该用下面的方法 long day = date2.toEpochDay() - date1.toEpochDay(); System.out.println(date1 + \u0026#34;和\u0026#34; + date2 + \u0026#34;相差\u0026#34; + day + \u0026#34;天\u0026#34;); //打印结果:2021-02-26和2021-12-23相差300天 } 获取指定日期 # 除了日期计算繁琐,获取特定一个日期也很麻烦,比如获取本月最后一天,第一天。\nJava 8 之前:\npublic void getDay() { SimpleDateFormat format = new SimpleDateFormat(\u0026#34;yyyy-MM-dd\u0026#34;); //获取当前月第一天: Calendar c = Calendar.getInstance(); c.set(Calendar.DAY_OF_MONTH, 1); String first = format.format(c.getTime()); System.out.println(\u0026#34;first day:\u0026#34; + first); //获取当前月最后一天 Calendar ca = Calendar.getInstance(); ca.set(Calendar.DAY_OF_MONTH, ca.getActualMaximum(Calendar.DAY_OF_MONTH)); String last = format.format(ca.getTime()); System.out.println(\u0026#34;last day:\u0026#34; + last); //当年最后一天 Calendar currCal = Calendar.getInstance(); Calendar calendar = Calendar.getInstance(); calendar.clear(); calendar.set(Calendar.YEAR, currCal.get(Calendar.YEAR)); calendar.roll(Calendar.DAY_OF_YEAR, -1); Date time = calendar.getTime(); System.out.println(\u0026#34;last day:\u0026#34; + format.format(time)); } Java 8 之后:\npublic void getDayNew() { LocalDate today = LocalDate.now(); //获取当前月第一天: LocalDate firstDayOfThisMonth = today.with(TemporalAdjusters.firstDayOfMonth()); // 取本月最后一天 LocalDate lastDayOfThisMonth = today.with(TemporalAdjusters.lastDayOfMonth()); //取下一天: LocalDate nextDay = lastDayOfThisMonth.plusDays(1); //当年最后一天 LocalDate lastday = today.with(TemporalAdjusters.lastDayOfYear()); //2021年最后一个周日,如果用Calendar是不得烦死。 LocalDate lastMondayOf2021 = LocalDate.parse(\u0026#34;2021-12-31\u0026#34;).with(TemporalAdjusters.lastInMonth(DayOfWeek.SUNDAY)); } java.time.temporal.TemporalAdjusters 里面还有很多便捷的算法,这里就不带大家看 Api 了,都很简单,看了秒懂。\nJDBC 和 java8 # 现在 jdbc 时间类型和 java8 时间类型对应关系是\nDate \u0026mdash;\u0026gt; LocalDate Time \u0026mdash;\u0026gt; LocalTime Timestamp \u0026mdash;\u0026gt; LocalDateTime 而之前统统对应 Date,也只有 Date。\n时区 # 时区:正式的时区划分为每隔经度 15° 划分一个时区,全球共 24 个时区,每个时区相差 1 小时。但为了行政上的方便,常将 1 个国家或 1 个省份划在一起,比如我国幅员宽广,大概横跨 5 个时区,实际上只用东八时区的标准时即北京时间为准。\njava.util.Date 对象实质上存的是 1970 年 1 月 1 日 0 点( GMT)至 Date 对象所表示时刻所经过的毫秒数。也就是说不管在哪个时区 new Date,它记录的毫秒数都一样,和时区无关。但在使用上应该把它转换成当地时间,这就涉及到了时间的国际化。java.util.Date 本身并不支持国际化,需要借助 TimeZone。\n//北京时间:Wed Jan 27 14:05:29 CST 2021 Date date = new Date(); SimpleDateFormat bjSdf = new SimpleDateFormat(\u0026#34;yyyy-MM-dd HH:mm:ss\u0026#34;); //北京时区 bjSdf.setTimeZone(TimeZone.getTimeZone(\u0026#34;Asia/Shanghai\u0026#34;)); System.out.println(\u0026#34;毫秒数:\u0026#34; + date.getTime() + \u0026#34;, 北京时间:\u0026#34; + bjSdf.format(date)); //东京时区 SimpleDateFormat tokyoSdf = new SimpleDateFormat(\u0026#34;yyyy-MM-dd HH:mm:ss\u0026#34;); tokyoSdf.setTimeZone(TimeZone.getTimeZone(\u0026#34;Asia/Tokyo\u0026#34;)); // 设置东京时区 System.out.println(\u0026#34;毫秒数:\u0026#34; + date.getTime() + \u0026#34;, 东京时间:\u0026#34; + tokyoSdf.format(date)); //如果直接print会自动转成当前时区的时间 System.out.println(date); //Wed Jan 27 14:05:29 CST 2021 在新特性中引入了 java.time.ZonedDateTime 来表示带时区的时间。它可以看成是 LocalDateTime + ZoneId。\n//当前时区时间 ZonedDateTime zonedDateTime = ZonedDateTime.now(); System.out.println(\u0026#34;当前时区时间: \u0026#34; + zonedDateTime); //东京时间 ZoneId zoneId = ZoneId.of(ZoneId.SHORT_IDS.get(\u0026#34;JST\u0026#34;)); ZonedDateTime tokyoTime = zonedDateTime.withZoneSameInstant(zoneId); System.out.println(\u0026#34;东京时间: \u0026#34; + tokyoTime); // ZonedDateTime 转 LocalDateTime LocalDateTime localDateTime = tokyoTime.toLocalDateTime(); System.out.println(\u0026#34;东京时间转当地时间: \u0026#34; + localDateTime); //LocalDateTime 转 ZonedDateTime ZonedDateTime localZoned = localDateTime.atZone(ZoneId.systemDefault()); System.out.println(\u0026#34;本地时区时间: \u0026#34; + localZoned); //打印结果 当前时区时间: 2021-01-27T14:43:58.735+08:00[Asia/Shanghai] 东京时间: 2021-01-27T15:43:58.735+09:00[Asia/Tokyo] 东京时间转当地时间: 2021-01-27T15:43:58.735 当地时区时间: 2021-01-27T15:53:35.618+08:00[Asia/Shanghai] 小结 # 通过上面比较新老 Date 的不同,当然只列出部分功能上的区别,更多功能还得自己去挖掘。总之 date-time-api 给日期操作带来了福利。在日常工作中遇到 date 类型的操作,第一考虑的是 date-time-api,实在解决不了再考虑老的 Date。\n总结 # 我们梳理总结的 java 8 新特性有\nInterface \u0026amp; functional Interface Lambda Stream Optional Date time-api 这些都是开发当中比较常用的特性。梳理下来发现它们真香,而我却没有更早的应用。总觉得学习 java 8 新特性比较麻烦,一直使用老的实现方式。其实这些新特性几天就可以掌握,一但掌握,效率会有很大的提高。其实我们涨工资也是涨的学习的钱,不学习终究会被淘汰,35 岁危机会提前来临。\n"},{"id":513,"href":"/zh/docs/technology/Interview/java/concurrent/java-concurrent-questions-01/","title":"Java并发常见面试题总结(上)","section":"Concurrent","content":" 线程 # ⭐️什么是线程和进程? # 何为进程? # 进程是程序的一次执行过程,是系统运行程序的基本单位,因此进程是动态的。系统运行一个程序即是一个进程从创建,运行到消亡的过程。\n在 Java 中,当我们启动 main 函数时其实就是启动了一个 JVM 的进程,而 main 函数所在的线程就是这个进程中的一个线程,也称主线程。\n如下图所示,在 Windows 中通过查看任务管理器的方式,我们就可以清楚看到 Windows 当前运行的进程(.exe 文件的运行)。\n何为线程? # 线程与进程相似,但线程是一个比进程更小的执行单位。一个进程在其执行的过程中可以产生多个线程。与进程不同的是同类的多个线程共享进程的堆和方法区资源,但每个线程有自己的程序计数器、虚拟机栈和本地方法栈,所以系统在产生一个线程,或是在各个线程之间做切换工作时,负担要比进程小得多,也正因为如此,线程也被称为轻量级进程。\nJava 程序天生就是多线程程序,我们可以通过 JMX 来看看一个普通的 Java 程序有哪些线程,代码如下。\npublic class MultiThread { public static void main(String[] args) { // 获取 Java 线程管理 MXBean ThreadMXBean threadMXBean = ManagementFactory.getThreadMXBean(); // 不需要获取同步的 monitor 和 synchronizer 信息,仅获取线程和线程堆栈信息 ThreadInfo[] threadInfos = threadMXBean.dumpAllThreads(false, false); // 遍历线程信息,仅打印线程 ID 和线程名称信息 for (ThreadInfo threadInfo : threadInfos) { System.out.println(\u0026#34;[\u0026#34; + threadInfo.getThreadId() + \u0026#34;] \u0026#34; + threadInfo.getThreadName()); } } } 上述程序输出如下(输出内容可能不同,不用太纠结下面每个线程的作用,只用知道 main 线程执行 main 方法即可):\n[5] Attach Listener //添加事件 [4] Signal Dispatcher // 分发处理给 JVM 信号的线程 [3] Finalizer //调用对象 finalize 方法的线程 [2] Reference Handler //清除 reference 线程 [1] main //main 线程,程序入口 从上面的输出内容可以看出:一个 Java 程序的运行是 main 线程和多个其他线程同时运行。\nJava 线程和操作系统的线程有啥区别? # JDK 1.2 之前,Java 线程是基于绿色线程(Green Threads)实现的,这是一种用户级线程(用户线程),也就是说 JVM 自己模拟了多线程的运行,而不依赖于操作系统。由于绿色线程和原生线程比起来在使用时有一些限制(比如绿色线程不能直接使用操作系统提供的功能如异步 I/O、只能在一个内核线程上运行无法利用多核),在 JDK 1.2 及以后,Java 线程改为基于原生线程(Native Threads)实现,也就是说 JVM 直接使用操作系统原生的内核级线程(内核线程)来实现 Java 线程,由操作系统内核进行线程的调度和管理。\n我们上面提到了用户线程和内核线程,考虑到很多读者不太了解二者的区别,这里简单介绍一下:\n用户线程:由用户空间程序管理和调度的线程,运行在用户空间(专门给应用程序使用)。 内核线程:由操作系统内核管理和调度的线程,运行在内核空间(只有内核程序可以访问)。 顺便简单总结一下用户线程和内核线程的区别和特点:用户线程创建和切换成本低,但不可以利用多核。内核态线程,创建和切换成本高,可以利用多核。\n一句话概括 Java 线程和操作系统线程的关系:现在的 Java 线程的本质其实就是操作系统的线程。\n线程模型是用户线程和内核线程之间的关联方式,常见的线程模型有这三种:\n一对一(一个用户线程对应一个内核线程) 多对一(多个用户线程映射到一个内核线程) 多对多(多个用户线程映射到多个内核线程) 在 Windows 和 Linux 等主流操作系统中,Java 线程采用的是一对一的线程模型,也就是一个 Java 线程对应一个系统内核线程。Solaris 系统是一个特例(Solaris 系统本身就支持多对多的线程模型),HotSpot VM 在 Solaris 上支持多对多和一对一。具体可以参考 R 大的回答: JVM 中的线程模型是用户级的么?。\n⭐️请简要描述线程与进程的关系,区别及优缺点? # 下图是 Java 内存区域,通过下图我们从 JVM 的角度来说一下线程和进程之间的关系。\n从上图可以看出:一个进程中可以有多个线程,多个线程共享进程的堆和方法区 (JDK1.8 之后的元空间)资源,但是每个线程有自己的程序计数器、虚拟机栈 和 本地方法栈。\n总结: 线程是进程划分成的更小的运行单位。线程和进程最大的不同在于基本上各进程是独立的,而各线程则不一定,因为同一进程中的线程极有可能会相互影响。线程执行开销小,但不利于资源的管理和保护;而进程正相反。\n下面是该知识点的扩展内容!\n下面来思考这样一个问题:为什么程序计数器、虚拟机栈和本地方法栈是线程私有的呢?为什么堆和方法区是线程共享的呢?\n程序计数器为什么是私有的? # 程序计数器主要有下面两个作用:\n字节码解释器通过改变程序计数器来依次读取指令,从而实现代码的流程控制,如:顺序执行、选择、循环、异常处理。 在多线程的情况下,程序计数器用于记录当前线程执行的位置,从而当线程被切换回来的时候能够知道该线程上次运行到哪儿了。 需要注意的是,如果执行的是 native 方法,那么程序计数器记录的是 undefined 地址,只有执行的是 Java 代码时程序计数器记录的才是下一条指令的地址。\n所以,程序计数器私有主要是为了线程切换后能恢复到正确的执行位置。\n虚拟机栈和本地方法栈为什么是私有的? # 虚拟机栈: 每个 Java 方法在执行之前会创建一个栈帧用于存储局部变量表、操作数栈、常量池引用等信息。从方法调用直至执行完成的过程,就对应着一个栈帧在 Java 虚拟机栈中入栈和出栈的过程。 本地方法栈: 和虚拟机栈所发挥的作用非常相似,区别是:虚拟机栈为虚拟机执行 Java 方法 (也就是字节码)服务,而本地方法栈则为虚拟机使用到的 Native 方法服务。 在 HotSpot 虚拟机中和 Java 虚拟机栈合二为一。 所以,为了保证线程中的局部变量不被别的线程访问到,虚拟机栈和本地方法栈是线程私有的。\n一句话简单了解堆和方法区 # 堆和方法区是所有线程共享的资源,其中堆是进程中最大的一块内存,主要用于存放新创建的对象 (几乎所有对象都在这里分配内存),方法区主要用于存放已被加载的类信息、常量、静态变量、即时编译器编译后的代码等数据。\n如何创建线程? # 一般来说,创建线程有很多种方式,例如继承Thread类、实现Runnable接口、实现Callable接口、使用线程池、使用CompletableFuture类等等。\n不过,这些方式其实并没有真正创建出线程。准确点来说,这些都属于是在 Java 代码中使用多线程的方法。\n严格来说,Java 就只有一种方式可以创建线程,那就是通过new Thread().start()创建。不管是哪种方式,最终还是依赖于new Thread().start()。\n关于这个问题的详细分析可以查看这篇文章: 大家都说 Java 有三种创建线程的方式!并发编程中的惊天骗局!。\n⭐️说说线程的生命周期和状态? # Java 线程在运行的生命周期中的指定时刻只可能处于下面 6 种不同状态的其中一个状态:\nNEW: 初始状态,线程被创建出来但没有被调用 start() 。 RUNNABLE: 运行状态,线程被调用了 start()等待运行的状态。 BLOCKED:阻塞状态,需要等待锁释放。 WAITING:等待状态,表示该线程需要等待其他线程做出一些特定动作(通知或中断)。 TIME_WAITING:超时等待状态,可以在指定的时间后自行返回而不是像 WAITING 那样一直等待。 TERMINATED:终止状态,表示该线程已经运行完毕。 线程在生命周期中并不是固定处于某一个状态而是随着代码的执行在不同状态之间切换。\nJava 线程状态变迁图(图源: 挑错 |《Java 并发编程的艺术》中关于线程状态的三处错误):\n由上图可以看出:线程创建之后它将处于 NEW(新建) 状态,调用 start() 方法后开始运行,线程这时候处于 READY(可运行) 状态。可运行状态的线程获得了 CPU 时间片(timeslice)后就处于 RUNNING(运行) 状态。\n在操作系统层面,线程有 READY 和 RUNNING 状态;而在 JVM 层面,只能看到 RUNNABLE 状态(图源: HowToDoInJava: Java Thread Life Cycle and Thread States),所以 Java 系统一般将这两个状态统称为 RUNNABLE(运行中) 状态 。\n为什么 JVM 没有区分这两种状态呢? (摘自: Java 线程运行怎么有第六种状态? - Dawell 的回答 ) 现在的时分(time-sharing)多任务(multi-task)操作系统架构通常都是用所谓的“时间分片(time quantum or time slice)”方式进行抢占式(preemptive)轮转调度(round-robin 式)。这个时间分片通常是很小的,一个线程一次最多只能在 CPU 上运行比如 10-20ms 的时间(此时处于 running 状态),也即大概只有 0.01 秒这一量级,时间片用后就要被切换下来放入调度队列的末尾等待再次调度。(也即回到 ready 状态)。线程切换的如此之快,区分这两种状态就没什么意义了。\n当线程执行 wait()方法之后,线程进入 WAITING(等待) 状态。进入等待状态的线程需要依靠其他线程的通知才能够返回到运行状态。 TIMED_WAITING(超时等待) 状态相当于在等待状态的基础上增加了超时限制,比如通过 sleep(long millis)方法或 wait(long millis)方法可以将线程置于 TIMED_WAITING 状态。当超时时间结束后,线程将会返回到 RUNNABLE 状态。 当线程进入 synchronized 方法/块或者调用 wait 后(被 notify)重新进入 synchronized 方法/块,但是锁被其它线程占有,这个时候线程就会进入 BLOCKED(阻塞) 状态。 线程在执行完了 run()方法之后将会进入到 TERMINATED(终止) 状态。 相关阅读: 线程的几种状态你真的了解么? 。\n什么是线程上下文切换? # 线程在执行过程中会有自己的运行条件和状态(也称上下文),比如上文所说到过的程序计数器,栈信息等。当出现如下情况的时候,线程会从占用 CPU 状态中退出。\n主动让出 CPU,比如调用了 sleep(), wait() 等。 时间片用完,因为操作系统要防止一个线程或者进程长时间占用 CPU 导致其他线程或者进程饿死。 调用了阻塞类型的系统中断,比如请求 IO,线程被阻塞。 被终止或结束运行 这其中前三种都会发生线程切换,线程切换意味着需要保存当前线程的上下文,留待线程下次占用 CPU 的时候恢复现场。并加载下一个将要占用 CPU 的线程上下文。这就是所谓的 上下文切换。\n上下文切换是现代操作系统的基本功能,因其每次需要保存信息恢复信息,这将会占用 CPU,内存等系统资源进行处理,也就意味着效率会有一定损耗,如果频繁切换就会造成整体效率低下。\nThread#sleep() 方法和 Object#wait() 方法对比 # 共同点:两者都可以暂停线程的执行。\n区别:\nsleep() 方法没有释放锁,而 wait() 方法释放了锁 。 wait() 通常被用于线程间交互/通信,sleep()通常被用于暂停执行。 wait() 方法被调用后,线程不会自动苏醒,需要别的线程调用同一个对象上的 notify()或者 notifyAll() 方法。sleep()方法执行完成后,线程会自动苏醒,或者也可以使用 wait(long timeout) 超时后线程会自动苏醒。 sleep() 是 Thread 类的静态本地方法,wait() 则是 Object 类的本地方法。为什么这样设计呢?下一个问题就会聊到。 为什么 wait() 方法不定义在 Thread 中? # wait() 是让获得对象锁的线程实现等待,会自动释放当前线程占有的对象锁。每个对象(Object)都拥有对象锁,既然要释放当前线程占有的对象锁并让其进入 WAITING 状态,自然是要操作对应的对象(Object)而非当前的线程(Thread)。\n类似的问题:为什么 sleep() 方法定义在 Thread 中?\n因为 sleep() 是让当前线程暂停执行,不涉及到对象类,也不需要获得对象锁。\n可以直接调用 Thread 类的 run 方法吗? # 这是另一个非常经典的 Java 多线程面试问题,而且在面试中会经常被问到。很简单,但是很多人都会答不上来!\nnew 一个 Thread,线程进入了新建状态。调用 start()方法,会启动一个线程并使线程进入了就绪状态,当分配到时间片后就可以开始运行了。 start() 会执行线程的相应准备工作,然后自动执行 run() 方法的内容,这是真正的多线程工作。 但是,直接执行 run() 方法,会把 run() 方法当成一个 main 线程下的普通方法去执行,并不会在某个线程中执行它,所以这并不是多线程工作。\n总结:调用 start() 方法方可启动线程并使线程进入就绪状态,直接执行 run() 方法的话不会以多线程的方式执行。\n多线程 # 并发与并行的区别 # 并发:两个及两个以上的作业在同一 时间段 内执行。 并行:两个及两个以上的作业在同一 时刻 执行。 最关键的点是:是否是 同时 执行。\n同步和异步的区别 # 同步:发出一个调用之后,在没有得到结果之前, 该调用就不可以返回,一直等待。 异步:调用在发出之后,不用等待返回结果,该调用直接返回。 ⭐️为什么要使用多线程? # 先从总体上来说:\n从计算机底层来说: 线程可以比作是轻量级的进程,是程序执行的最小单位,线程间的切换和调度的成本远远小于进程。另外,多核 CPU 时代意味着多个线程可以同时运行,这减少了线程上下文切换的开销。 从当代互联网发展趋势来说: 现在的系统动不动就要求百万级甚至千万级的并发量,而多线程并发编程正是开发高并发系统的基础,利用好多线程机制可以大大提高系统整体的并发能力以及性能。 再深入到计算机底层来探讨:\n单核时代:在单核时代多线程主要是为了提高单进程利用 CPU 和 IO 系统的效率。 假设只运行了一个 Java 进程的情况,当我们请求 IO 的时候,如果 Java 进程中只有一个线程,此线程被 IO 阻塞则整个进程被阻塞。CPU 和 IO 设备只有一个在运行,那么可以简单地说系统整体效率只有 50%。当使用多线程的时候,一个线程被 IO 阻塞,其他线程还可以继续使用 CPU。从而提高了 Java 进程利用系统资源的整体效率。 多核时代: 多核时代多线程主要是为了提高进程利用多核 CPU 的能力。举个例子:假如我们要计算一个复杂的任务,我们只用一个线程的话,不论系统有几个 CPU 核心,都只会有一个 CPU 核心被利用到。而创建多个线程,这些线程可以被映射到底层多个 CPU 核心上执行,在任务中的多个线程没有资源竞争的情况下,任务执行的效率会有显著性的提高,约等于(单核时执行时间/CPU 核心数)。 ⭐️单核 CPU 支持 Java 多线程吗? # 单核 CPU 是支持 Java 多线程的。操作系统通过时间片轮转的方式,将 CPU 的时间分配给不同的线程。尽管单核 CPU 一次只能执行一个任务,但通过快速在多个线程之间切换,可以让用户感觉多个任务是同时进行的。\n这里顺带提一下 Java 使用的线程调度方式。\n操作系统主要通过两种线程调度方式来管理多线程的执行:\n抢占式调度(Preemptive Scheduling):操作系统决定何时暂停当前正在运行的线程,并切换到另一个线程执行。这种切换通常是由系统时钟中断(时间片轮转)或其他高优先级事件(如 I/O 操作完成)触发的。这种方式存在上下文切换开销,但公平性和 CPU 资源利用率较好,不易阻塞。 协同式调度(Cooperative Scheduling):线程执行完毕后,主动通知系统切换到另一个线程。这种方式可以减少上下文切换带来的性能开销,但公平性较差,容易阻塞。 Java 使用的线程调度是抢占式的。也就是说,JVM 本身不负责线程的调度,而是将线程的调度委托给操作系统。操作系统通常会基于线程优先级和时间片来调度线程的执行,高优先级的线程通常获得 CPU 时间片的机会更多。\n⭐️单核 CPU 上运行多个线程效率一定会高吗? # 单核 CPU 同时运行多个线程的效率是否会高,取决于线程的类型和任务的性质。一般来说,有两种类型的线程:\nCPU 密集型:CPU 密集型的线程主要进行计算和逻辑处理,需要占用大量的 CPU 资源。 IO 密集型:IO 密集型的线程主要进行输入输出操作,如读写文件、网络通信等,需要等待 IO 设备的响应,而不占用太多的 CPU 资源。 在单核 CPU 上,同一时刻只能有一个线程在运行,其他线程需要等待 CPU 的时间片分配。如果线程是 CPU 密集型的,那么多个线程同时运行会导致频繁的线程切换,增加了系统的开销,降低了效率。如果线程是 IO 密集型的,那么多个线程同时运行可以利用 CPU 在等待 IO 时的空闲时间,提高了效率。\n因此,对于单核 CPU 来说,如果任务是 CPU 密集型的,那么开很多线程会影响效率;如果任务是 IO 密集型的,那么开很多线程会提高效率。当然,这里的“很多”也要适度,不能超过系统能够承受的上限。\n使用多线程可能带来什么问题? # 并发编程的目的就是为了能提高程序的执行效率进而提高程序的运行速度,但是并发编程并不总是能提高程序运行速度的,而且并发编程可能会遇到很多问题,比如:内存泄漏、死锁、线程不安全等等。\n如何理解线程安全和不安全? # 线程安全和不安全是在多线程环境下对于同一份数据的访问是否能够保证其正确性和一致性的描述。\n线程安全指的是在多线程环境下,对于同一份数据,不管有多少个线程同时访问,都能保证这份数据的正确性和一致性。 线程不安全则表示在多线程环境下,对于同一份数据,多个线程同时访问时可能会导致数据混乱、错误或者丢失。 ⭐️死锁 # 什么是线程死锁? # 线程死锁描述的是这样一种情况:多个线程同时被阻塞,它们中的一个或者全部都在等待某个资源被释放。由于线程被无限期地阻塞,因此程序不可能正常终止。\n如下图所示,线程 A 持有资源 2,线程 B 持有资源 1,他们同时都想申请对方的资源,所以这两个线程就会互相等待而进入死锁状态。\n下面通过一个例子来说明线程死锁,代码模拟了上图的死锁的情况 (代码来源于《并发编程之美》):\npublic class DeadLockDemo { private static Object resource1 = new Object();//资源 1 private static Object resource2 = new Object();//资源 2 public static void main(String[] args) { new Thread(() -\u0026gt; { synchronized (resource1) { System.out.println(Thread.currentThread() + \u0026#34;get resource1\u0026#34;); try { Thread.sleep(1000); } catch (InterruptedException e) { e.printStackTrace(); } System.out.println(Thread.currentThread() + \u0026#34;waiting get resource2\u0026#34;); synchronized (resource2) { System.out.println(Thread.currentThread() + \u0026#34;get resource2\u0026#34;); } } }, \u0026#34;线程 1\u0026#34;).start(); new Thread(() -\u0026gt; { synchronized (resource2) { System.out.println(Thread.currentThread() + \u0026#34;get resource2\u0026#34;); try { Thread.sleep(1000); } catch (InterruptedException e) { e.printStackTrace(); } System.out.println(Thread.currentThread() + \u0026#34;waiting get resource1\u0026#34;); synchronized (resource1) { System.out.println(Thread.currentThread() + \u0026#34;get resource1\u0026#34;); } } }, \u0026#34;线程 2\u0026#34;).start(); } } Output\nThread[线程 1,5,main]get resource1 Thread[线程 2,5,main]get resource2 Thread[线程 1,5,main]waiting get resource2 Thread[线程 2,5,main]waiting get resource1 线程 A 通过 synchronized (resource1) 获得 resource1 的监视器锁,然后通过Thread.sleep(1000);让线程 A 休眠 1s 为的是让线程 B 得到执行然后获取到 resource2 的监视器锁。线程 A 和线程 B 休眠结束了都开始企图请求获取对方的资源,然后这两个线程就会陷入互相等待的状态,这也就产生了死锁。\n上面的例子符合产生死锁的四个必要条件:\n互斥条件:该资源任意一个时刻只由一个线程占用。 请求与保持条件:一个线程因请求资源而阻塞时,对已获得的资源保持不放。 不剥夺条件:线程已获得的资源在未使用完之前不能被其他线程强行剥夺,只有自己使用完毕后才释放资源。 循环等待条件:若干线程之间形成一种头尾相接的循环等待资源关系。 如何检测死锁? # 使用jmap、jstack等命令查看 JVM 线程栈和堆内存的情况。如果有死锁,jstack 的输出中通常会有 Found one Java-level deadlock:的字样,后面会跟着死锁相关的线程信息。另外,实际项目中还可以搭配使用top、df、free等命令查看操作系统的基本情况,出现死锁可能会导致 CPU、内存等资源消耗过高。 采用 VisualVM、JConsole 等工具进行排查。 这里以 JConsole 工具为例进行演示。\n首先,我们要找到 JDK 的 bin 目录,找到 jconsole 并双击打开。\n对于 MAC 用户来说,可以通过 /usr/libexec/java_home -V查看 JDK 安装目录,找到后通过 open . + 文件夹地址打开即可。例如,我本地的某个 JDK 的路径是:\nopen . /Users/guide/Library/Java/JavaVirtualMachines/corretto-1.8.0_252/Contents/Home 打开 jconsole 后,连接对应的程序,然后进入线程界面选择检测死锁即可!\n如何预防和避免线程死锁? # 如何预防死锁? 破坏死锁的产生的必要条件即可:\n破坏请求与保持条件:一次性申请所有的资源。 破坏不剥夺条件:占用部分资源的线程进一步申请其他资源时,如果申请不到,可以主动释放它占有的资源。 破坏循环等待条件:靠按序申请资源来预防。按某一顺序申请资源,释放资源则反序释放。破坏循环等待条件。 如何避免死锁?\n避免死锁就是在资源分配时,借助于算法(比如银行家算法)对资源分配进行计算评估,使其进入安全状态。\n安全状态 指的是系统能够按照某种线程推进顺序(P1、P2、P3……Pn)来为每个线程分配所需资源,直到满足每个线程对资源的最大需求,使每个线程都可顺利完成。称 \u0026lt;P1、P2、P3.....Pn\u0026gt; 序列为安全序列。\n我们对线程 2 的代码修改成下面这样就不会产生死锁了。\nnew Thread(() -\u0026gt; { synchronized (resource1) { System.out.println(Thread.currentThread() + \u0026#34;get resource1\u0026#34;); try { Thread.sleep(1000); } catch (InterruptedException e) { e.printStackTrace(); } System.out.println(Thread.currentThread() + \u0026#34;waiting get resource2\u0026#34;); synchronized (resource2) { System.out.println(Thread.currentThread() + \u0026#34;get resource2\u0026#34;); } } }, \u0026#34;线程 2\u0026#34;).start(); 输出:\nThread[线程 1,5,main]get resource1 Thread[线程 1,5,main]waiting get resource2 Thread[线程 1,5,main]get resource2 Thread[线程 2,5,main]get resource1 Thread[线程 2,5,main]waiting get resource2 Thread[线程 2,5,main]get resource2 Process finished with exit code 0 我们分析一下上面的代码为什么避免了死锁的发生?\n线程 1 首先获得到 resource1 的监视器锁,这时候线程 2 就获取不到了。然后线程 1 再去获取 resource2 的监视器锁,可以获取到。然后线程 1 释放了对 resource1、resource2 的监视器锁的占用,线程 2 获取到就可以执行了。这样就破坏了破坏循环等待条件,因此避免了死锁。\n虚拟线程 # 虚拟线程在 Java 21 正式发布,这是一项重量级的更新。我写了一篇文章来总结虚拟线程常见的问题: 虚拟线程常见问题总结,包含下面这些问题:\n什么是虚拟线程? 虚拟线程和平台线程有什么关系? 虚拟线程有什么优点和缺点? 如何创建虚拟线程? 虚拟线程的底层原理是什么? "},{"id":514,"href":"/zh/docs/technology/Interview/java/concurrent/java-concurrent-questions-03/","title":"Java并发常见面试题总结(下)","section":"Concurrent","content":" ThreadLocal # ThreadLocal 有什么用? # 通常情况下,我们创建的变量可以被任何一个线程访问和修改。这在多线程环境中可能导致数据竞争和线程安全问题。那么,如果想让每个线程都有自己的专属本地变量,该如何实现呢?\nJDK 中提供的 ThreadLocal 类正是为了解决这个问题。ThreadLocal 类允许每个线程绑定自己的值,可以将其形象地比喻为一个“存放数据的盒子”。每个线程都有自己独立的盒子,用于存储私有数据,确保不同线程之间的数据互不干扰。\n当你创建一个 ThreadLocal 变量时,每个访问该变量的线程都会拥有一个独立的副本。这也是 ThreadLocal 名称的由来。线程可以通过 get() 方法获取自己线程的本地副本,或通过 set() 方法修改该副本的值,从而避免了线程安全问题。\n举个简单的例子:假设有两个人去宝屋收集宝物。如果他们共用一个袋子,必然会产生争执;但如果每个人都有一个独立的袋子,就不会有这个问题。如果将这两个人比作线程,那么 ThreadLocal 就是用来避免这两个线程竞争同一个资源的方法。\npublic class ThreadLocalExample { private static ThreadLocal\u0026lt;Integer\u0026gt; threadLocal = ThreadLocal.withInitial(() -\u0026gt; 0); public static void main(String[] args) { Runnable task = () -\u0026gt; { int value = threadLocal.get(); value += 1; threadLocal.set(value); System.out.println(Thread.currentThread().getName() + \u0026#34; Value: \u0026#34; + threadLocal.get()); }; Thread thread1 = new Thread(task, \u0026#34;Thread-1\u0026#34;); Thread thread2 = new Thread(task, \u0026#34;Thread-2\u0026#34;); thread1.start(); // 输出: Thread-1 Value: 1 thread2.start(); // 输出: Thread-2 Value: 1 } } ⭐️ThreadLocal 原理了解吗? # 从 Thread类源代码入手。\npublic class Thread implements Runnable { //...... //与此线程有关的ThreadLocal值。由ThreadLocal类维护 ThreadLocal.ThreadLocalMap threadLocals = null; //与此线程有关的InheritableThreadLocal值。由InheritableThreadLocal类维护 ThreadLocal.ThreadLocalMap inheritableThreadLocals = null; //...... } 从上面Thread类 源代码可以看出Thread 类中有一个 threadLocals 和 一个 inheritableThreadLocals 变量,它们都是 ThreadLocalMap 类型的变量,我们可以把 ThreadLocalMap 理解为ThreadLocal 类实现的定制化的 HashMap。默认情况下这两个变量都是 null,只有当前线程调用 ThreadLocal 类的 set或get方法时才创建它们,实际上调用这两个方法的时候,我们调用的是ThreadLocalMap类对应的 get()、set()方法。\nThreadLocal类的set()方法\npublic void set(T value) { //获取当前请求的线程 Thread t = Thread.currentThread(); //取出 Thread 类内部的 threadLocals 变量(哈希表结构) ThreadLocalMap map = getMap(t); if (map != null) // 将需要存储的值放入到这个哈希表中 map.set(this, value); else createMap(t, value); } ThreadLocalMap getMap(Thread t) { return t.threadLocals; } 通过上面这些内容,我们足以通过猜测得出结论:最终的变量是放在了当前线程的 ThreadLocalMap 中,并不是存在 ThreadLocal 上,ThreadLocal 可以理解为只是ThreadLocalMap的封装,传递了变量值。 ThrealLocal 类中可以通过Thread.currentThread()获取到当前线程对象后,直接通过getMap(Thread t)可以访问到该线程的ThreadLocalMap对象。\n每个Thread中都具备一个ThreadLocalMap,而ThreadLocalMap可以存储以ThreadLocal为 key ,Object 对象为 value 的键值对。\nThreadLocalMap(ThreadLocal\u0026lt;?\u0026gt; firstKey, Object firstValue) { //...... } 比如我们在同一个线程中声明了两个 ThreadLocal 对象的话, Thread内部都是使用仅有的那个ThreadLocalMap 存放数据的,ThreadLocalMap的 key 就是 ThreadLocal对象,value 就是 ThreadLocal 对象调用set方法设置的值。\nThreadLocal 数据结构如下图所示:\nThreadLocalMap是ThreadLocal的静态内部类。\n⭐️ThreadLocal 内存泄露问题是怎么导致的? # ThreadLocal 内存泄漏的根本原因在于其内部实现机制。\n通过上面的内容我们已经知道:每个线程维护一个名为 ThreadLocalMap 的 map。 当你使用 ThreadLocal 存储值时,实际上是将值存储在当前线程的 ThreadLocalMap 中,其中 ThreadLocal 实例本身作为 key,而你要存储的值作为 value。\nThreadLocalMap 的 key 和 value 引用机制:\nkey 是弱引用:ThreadLocalMap 中的 key 是 ThreadLocal 的弱引用 (WeakReference\u0026lt;ThreadLocal\u0026lt;?\u0026gt;\u0026gt;)。 这意味着,如果 ThreadLocal 实例不再被任何强引用指向,垃圾回收器会在下次 GC 时回收该实例,导致 ThreadLocalMap 中对应的 key 变为 null。 value 是强引用:ThreadLocalMap 中的 value 是强引用。 即使 key 被回收(变为 null),value 仍然存在于 ThreadLocalMap 中,被强引用,不会被回收。 static class Entry extends WeakReference\u0026lt;ThreadLocal\u0026lt;?\u0026gt;\u0026gt; { /** The value associated with this ThreadLocal. */ Object value; Entry(ThreadLocal\u0026lt;?\u0026gt; k, Object v) { super(k); value = v; } } 当 ThreadLocal 实例失去强引用后,其对应的 value 仍然存在于 ThreadLocalMap 中,因为 Entry 对象强引用了它。如果线程持续存活(例如线程池中的线程),ThreadLocalMap 也会一直存在,导致 key 为 null 的 entry 无法被垃圾回收,机会造成内存泄漏。\n也就是说,内存泄漏的发生需要同时满足两个条件:\nThreadLocal 实例不再被强引用; 线程持续存活,导致 ThreadLocalMap 长期存在。 虽然 ThreadLocalMap 在 get(), set() 和 remove() 操作时会尝试清理 key 为 null 的 entry,但这种清理机制是被动的,并不完全可靠。\n如何避免内存泄漏的发生?\n在使用完 ThreadLocal 后,务必调用 remove() 方法。 这是最安全和最推荐的做法。 remove() 方法会从 ThreadLocalMap 中显式地移除对应的 entry,彻底解决内存泄漏的风险。 即使将 ThreadLocal 定义为 static final,也强烈建议在每次使用后调用 remove()。 在线程池等线程复用的场景下,使用 try-finally 块可以确保即使发生异常,remove() 方法也一定会被执行。 如何跨线程传递 ThreadLocal 的值? # 由于 ThreadLocal 的变量值存放在 Thread 里,而父子线程属于不同的 Thread 的。因此在异步场景下,父子线程的 ThreadLocal 值无法进行传递。\n如果想要在异步场景下传递 ThreadLocal 值,有两种解决方案:\nInheritableThreadLocal :InheritableThreadLocal 是 JDK1.2 提供的工具,继承自 ThreadLocal 。使用 InheritableThreadLocal 时,会在创建子线程时,令子线程继承父线程中的 ThreadLocal 值,但是无法支持线程池场景下的 ThreadLocal 值传递。 TransmittableThreadLocal : TransmittableThreadLocal (简称 TTL) 是阿里巴巴开源的工具类,继承并加强了InheritableThreadLocal类,可以在线程池的场景下支持 ThreadLocal 值传递。项目地址: https://github.com/alibaba/transmittable-thread-local。 InheritableThreadLocal 原理扩展 # InheritableThreadLocal 实现了创建异步线程时,继承父线程 ThreadLocal 值的功能。该类是 JDK 团队提供的,通过改造 JDK 源码包中的 Thread 类来实现创建线程时,ThreadLocal 值的传递。\nInheritableThreadLocal 的值存储在哪里?\n在 Thread 类中添加了一个新的 ThreadLocalMap ,命名为 inheritableThreadLocals ,该变量用于存储需要跨线程传递的 ThreadLocal 值。如下:\nclass Thread implements Runnable { ThreadLocal.ThreadLocalMap threadLocals = null; ThreadLocal.ThreadLocalMap inheritableThreadLocals = null; } 如何完成 ThreadLocal 值的传递?\n通过改造 Thread 类的构造方法来实现,在创建 Thread 线程时,拿到父线程的 inheritableThreadLocals 变量赋值给子线程即可。相关代码如下:\n// Thread 的构造方法会调用 init() 方法 private void init(/* ... */) { // 1、获取父线程 Thread parent = currentThread(); // 2、将父线程的 inheritableThreadLocals 赋值给子线程 if (inheritThreadLocals \u0026amp;\u0026amp; parent.inheritableThreadLocals != null) this.inheritableThreadLocals = ThreadLocal.createInheritedMap(parent.inheritableThreadLocals); } TransmittableThreadLocal 原理扩展 # JDK 默认没有支持线程池场景下 ThreadLocal 值传递的功能,因此阿里巴巴开源了一套工具 TransmittableThreadLocal 来实现该功能。\n阿里巴巴无法改动 JDK 的源码,因此他内部通过 装饰器模式 在原有的功能上做增强,以此来实现线程池场景下的 ThreadLocal 值传递。\nTTL 改造的地方有两处:\n实现自定义的 Thread ,在 run() 方法内部做 ThreadLocal 变量的赋值操作。\n基于 线程池 进行装饰,在 execute() 方法中,不提交 JDK 内部的 Thread ,而是提交自定义的 Thread 。\n如果想要查看相关源码,可以引入 Maven 依赖进行下载。\n\u0026lt;dependency\u0026gt; \u0026lt;groupId\u0026gt;com.alibaba\u0026lt;/groupId\u0026gt; \u0026lt;artifactId\u0026gt;transmittable-thread-local\u0026lt;/artifactId\u0026gt; \u0026lt;version\u0026gt;2.12.0\u0026lt;/version\u0026gt; \u0026lt;/dependency\u0026gt; 应用场景 # 压测流量标记: 在压测场景中,使用 ThreadLocal 存储压测标记,用于区分压测流量和真实流量。如果标记丢失,可能导致压测流量被错误地当成线上流量处理。 上下文传递:在分布式系统中,传递链路追踪信息(如 Trace ID)或用户上下文信息。 线程池 # 什么是线程池? # 顾名思义,线程池就是管理一系列线程的资源池。当有任务要处理时,直接从线程池中获取线程来处理,处理完之后线程并不会立即被销毁,而是等待下一个任务。\n⭐️为什么要用线程池? # 池化技术想必大家已经屡见不鲜了,线程池、数据库连接池、HTTP 连接池等等都是对这个思想的应用。池化技术的思想主要是为了减少每次获取资源的消耗,提高对资源的利用率。\n线程池提供了一种限制和管理资源(包括执行一个任务)的方式。 每个线程池还维护一些基本统计信息,例如已完成任务的数量。\n这里借用《Java 并发编程的艺术》提到的来说一下使用线程池的好处:\n降低资源消耗。通过重复利用已创建的线程降低线程创建和销毁造成的消耗。 提高响应速度。当任务到达时,任务可以不需要等到线程创建就能立即执行。 提高线程的可管理性。线程是稀缺资源,如果无限制的创建,不仅会消耗系统资源,还会降低系统的稳定性,使用线程池可以进行统一的分配,调优和监控。 如何创建线程池? # 方式一:通过ThreadPoolExecutor构造函数来创建(推荐)。\n方式二:通过 Executor 框架的工具类 Executors 来创建。\nExecutors工具类提供的创建线程池的方法如下图所示:\n可以看出,通过Executors工具类可以创建多种类型的线程池,包括:\nFixedThreadPool:固定线程数量的线程池。该线程池中的线程数量始终不变。当有一个新的任务提交时,线程池中若有空闲线程,则立即执行。若没有,则新的任务会被暂存在一个任务队列中,待有线程空闲时,便处理在任务队列中的任务。 SingleThreadExecutor: 只有一个线程的线程池。若多余一个任务被提交到该线程池,任务会被保存在一个任务队列中,待线程空闲,按先入先出的顺序执行队列中的任务。 CachedThreadPool: 可根据实际情况调整线程数量的线程池。线程池的线程数量不确定,但若有空闲线程可以复用,则会优先使用可复用的线程。若所有线程均在工作,又有新的任务提交,则会创建新的线程处理任务。所有线程在当前任务执行完毕后,将返回线程池进行复用。 ScheduledThreadPool:给定的延迟后运行任务或者定期执行任务的线程池。 ⭐️为什么不推荐使用内置线程池? # 在《阿里巴巴 Java 开发手册》“并发处理”这一章节,明确指出线程资源必须通过线程池提供,不允许在应用中自行显式创建线程。\n为什么呢?\n使用线程池的好处是减少在创建和销毁线程上所消耗的时间以及系统资源开销,解决资源不足的问题。如果不使用线程池,有可能会造成系统创建大量同类线程而导致消耗完内存或者“过度切换”的问题。\n另外,《阿里巴巴 Java 开发手册》中强制线程池不允许使用 Executors 去创建,而是通过 ThreadPoolExecutor 构造函数的方式,这样的处理方式让写的同学更加明确线程池的运行规则,规避资源耗尽的风险\nExecutors 返回线程池对象的弊端如下:\nFixedThreadPool 和 SingleThreadExecutor:使用的是有界阻塞队列是 LinkedBlockingQueue ,其任务队列的最大长度为 Integer.MAX_VALUE ,可能堆积大量的请求,从而导致 OOM。 CachedThreadPool:使用的是同步队列 SynchronousQueue, 允许创建的线程数量为 Integer.MAX_VALUE ,如果任务数量过多且执行速度较慢,可能会创建大量的线程,从而导致 OOM。 ScheduledThreadPool 和 SingleThreadScheduledExecutor :使用的无界的延迟阻塞队列 DelayedWorkQueue ,任务队列最大长度为 Integer.MAX_VALUE ,可能堆积大量的请求,从而导致 OOM。 // 有界队列 LinkedBlockingQueue public static ExecutorService newFixedThreadPool(int nThreads) { return new ThreadPoolExecutor(nThreads, nThreads,0L, TimeUnit.MILLISECONDS,new LinkedBlockingQueue\u0026lt;Runnable\u0026gt;()); } // 无界队列 LinkedBlockingQueue public static ExecutorService newSingleThreadExecutor() { return new FinalizableDelegatedExecutorService (new ThreadPoolExecutor(1, 1,0L, TimeUnit.MILLISECONDS,new LinkedBlockingQueue\u0026lt;Runnable\u0026gt;())); } // 同步队列 SynchronousQueue,没有容量,最大线程数是 Integer.MAX_VALUE` public static ExecutorService newCachedThreadPool() { return new ThreadPoolExecutor(0, Integer.MAX_VALUE,60L, TimeUnit.SECONDS,new SynchronousQueue\u0026lt;Runnable\u0026gt;()); } // DelayedWorkQueue(延迟阻塞队列) public static ScheduledExecutorService newScheduledThreadPool(int corePoolSize) { return new ScheduledThreadPoolExecutor(corePoolSize); } public ScheduledThreadPoolExecutor(int corePoolSize) { super(corePoolSize, Integer.MAX_VALUE, 0, NANOSECONDS, new DelayedWorkQueue()); } ⭐️线程池常见参数有哪些?如何解释? # /** * 用给定的初始参数创建一个新的ThreadPoolExecutor。 */ public ThreadPoolExecutor(int corePoolSize,//线程池的核心线程数量 int maximumPoolSize,//线程池的最大线程数 long keepAliveTime,//当线程数大于核心线程数时,多余的空闲线程存活的最长时间 TimeUnit unit,//时间单位 BlockingQueue\u0026lt;Runnable\u0026gt; workQueue,//任务队列,用来储存等待执行任务的队列 ThreadFactory threadFactory,//线程工厂,用来创建线程,一般默认即可 RejectedExecutionHandler handler//拒绝策略,当提交的任务过多而不能及时处理时,我们可以定制策略来处理任务 ) { if (corePoolSize \u0026lt; 0 || maximumPoolSize \u0026lt;= 0 || maximumPoolSize \u0026lt; corePoolSize || keepAliveTime \u0026lt; 0) throw new IllegalArgumentException(); if (workQueue == null || threadFactory == null || handler == null) throw new NullPointerException(); this.corePoolSize = corePoolSize; this.maximumPoolSize = maximumPoolSize; this.workQueue = workQueue; this.keepAliveTime = unit.toNanos(keepAliveTime); this.threadFactory = threadFactory; this.handler = handler; } ThreadPoolExecutor 3 个最重要的参数:\ncorePoolSize : 任务队列未达到队列容量时,最大可以同时运行的线程数量。 maximumPoolSize : 任务队列中存放的任务达到队列容量的时候,当前可以同时运行的线程数量变为最大线程数。 workQueue: 新任务来的时候会先判断当前运行的线程数量是否达到核心线程数,如果达到的话,新任务就会被存放在队列中。 ThreadPoolExecutor其他常见参数 :\nkeepAliveTime:当线程池中的线程数量大于 corePoolSize ,即有非核心线程(线程池中核心线程以外的线程)时,这些非核心线程空闲后不会立即销毁,而是会等待,直到等待的时间超过了 keepAliveTime才会被回收销毁。 unit : keepAliveTime 参数的时间单位。 threadFactory :executor 创建新线程的时候会用到。 handler :拒绝策略(后面会单独详细介绍一下)。 下面这张图可以加深你对线程池中各个参数的相互关系的理解(图片来源:《Java 性能调优实战》):\n线程池的核心线程会被回收吗? # ThreadPoolExecutor 默认不会回收核心线程,即使它们已经空闲了。这是为了减少创建线程的开销,因为核心线程通常是要长期保持活跃的。但是,如果线程池是被用于周期性使用的场景,且频率不高(周期之间有明显的空闲时间),可以考虑将 allowCoreThreadTimeOut(boolean value) 方法的参数设置为 true,这样就会回收空闲(时间间隔由 keepAliveTime 指定)的核心线程了。\npublic void allowCoreThreadTimeOut(boolean value) { // 核心线程的 keepAliveTime 必须大于 0 才能启用超时机制 if (value \u0026amp;\u0026amp; keepAliveTime \u0026lt;= 0) { throw new IllegalArgumentException(\u0026#34;Core threads must have nonzero keep alive times\u0026#34;); } // 设置 allowCoreThreadTimeOut 的值 if (value != allowCoreThreadTimeOut) { allowCoreThreadTimeOut = value; // 如果启用了超时机制,清理所有空闲的线程,包括核心线程 if (value) { interruptIdleWorkers(); } } } ⭐️线程池的拒绝策略有哪些? # 如果当前同时运行的线程数量达到最大线程数量并且队列也已经被放满了任务时,ThreadPoolExecutor 定义一些策略:\nThreadPoolExecutor.AbortPolicy:抛出 RejectedExecutionException来拒绝新任务的处理。 ThreadPoolExecutor.CallerRunsPolicy:调用执行者自己的线程运行任务,也就是直接在调用execute方法的线程中运行(run)被拒绝的任务,如果执行程序已关闭,则会丢弃该任务。因此这种策略会降低对于新任务提交速度,影响程序的整体性能。如果你的应用程序可以承受此延迟并且你要求任何一个任务请求都要被执行的话,你可以选择这个策略。 ThreadPoolExecutor.DiscardPolicy:不处理新任务,直接丢弃掉。 ThreadPoolExecutor.DiscardOldestPolicy:此策略将丢弃最早的未处理的任务请求。 举个例子:Spring 通过 ThreadPoolTaskExecutor 或者我们直接通过 ThreadPoolExecutor 的构造函数创建线程池的时候,当我们不指定 RejectedExecutionHandler 拒绝策略来配置线程池的时候,默认使用的是 AbortPolicy。在这种拒绝策略下,如果队列满了,ThreadPoolExecutor 将抛出 RejectedExecutionException 异常来拒绝新来的任务 ,这代表你将丢失对这个任务的处理。如果不想丢弃任务的话,可以使用CallerRunsPolicy。CallerRunsPolicy 和其他的几个策略不同,它既不会抛弃任务,也不会抛出异常,而是将任务回退给调用者,使用调用者的线程来执行任务。\npublic static class CallerRunsPolicy implements RejectedExecutionHandler { public CallerRunsPolicy() { } public void rejectedExecution(Runnable r, ThreadPoolExecutor e) { if (!e.isShutdown()) { // 直接主线程执行,而不是线程池中的线程执行 r.run(); } } } 如果不允许丢弃任务任务,应该选择哪个拒绝策略? # 根据上面对线程池拒绝策略的介绍,相信大家很容易能够得出答案是:CallerRunsPolicy 。\n这里我们再来结合CallerRunsPolicy 的源码来看看:\npublic static class CallerRunsPolicy implements RejectedExecutionHandler { public CallerRunsPolicy() { } public void rejectedExecution(Runnable r, ThreadPoolExecutor e) { //只要当前程序没有关闭,就用执行execute方法的线程执行该任务 if (!e.isShutdown()) { r.run(); } } } 从源码可以看出,只要当前程序不关闭就会使用执行execute方法的线程执行该任务。\nCallerRunsPolicy 拒绝策略有什么风险?如何解决? # 我们上面也提到了:如果想要保证任何一个任务请求都要被执行的话,那选择 CallerRunsPolicy 拒绝策略更合适一些。\n不过,如果走到CallerRunsPolicy的任务是个非常耗时的任务,且处理提交任务的线程是主线程,可能会导致主线程阻塞,影响程序的正常运行。\n这里简单举一个例子,该线程池限定了最大线程数为 2,阻塞队列大小为 1(这意味着第 4 个任务就会走到拒绝策略),ThreadUtil为 Hutool 提供的工具类:\npublic class ThreadPoolTest { private static final Logger log = LoggerFactory.getLogger(ThreadPoolTest.class); public static void main(String[] args) { // 创建一个线程池,核心线程数为1,最大线程数为2 // 当线程数大于核心线程数时,多余的空闲线程存活的最长时间为60秒, // 任务队列为容量为1的ArrayBlockingQueue,饱和策略为CallerRunsPolicy。 ThreadPoolExecutor threadPoolExecutor = new ThreadPoolExecutor(1, 2, 60, TimeUnit.SECONDS, new ArrayBlockingQueue\u0026lt;\u0026gt;(1), new ThreadPoolExecutor.CallerRunsPolicy()); // 提交第一个任务,由核心线程执行 threadPoolExecutor.execute(() -\u0026gt; { log.info(\u0026#34;核心线程执行第一个任务\u0026#34;); ThreadUtil.sleep(1, TimeUnit.MINUTES); }); // 提交第二个任务,由于核心线程被占用,任务将进入队列等待 threadPoolExecutor.execute(() -\u0026gt; { log.info(\u0026#34;非核心线程处理入队的第二个任务\u0026#34;); ThreadUtil.sleep(1, TimeUnit.MINUTES); }); // 提交第三个任务,由于核心线程被占用且队列已满,创建非核心线程处理 threadPoolExecutor.execute(() -\u0026gt; { log.info(\u0026#34;非核心线程处理第三个任务\u0026#34;); ThreadUtil.sleep(1, TimeUnit.MINUTES); }); // 提交第四个任务,由于核心线程和非核心线程都被占用,队列也满了,根据CallerRunsPolicy策略,任务将由提交任务的线程(即主线程)来执行 threadPoolExecutor.execute(() -\u0026gt; { log.info(\u0026#34;主线程处理第四个任务\u0026#34;); ThreadUtil.sleep(2, TimeUnit.MINUTES); }); // 提交第五个任务,主线程被第四个任务卡住,该任务必须等到主线程执行完才能提交 threadPoolExecutor.execute(() -\u0026gt; { log.info(\u0026#34;核心线程执行第五个任务\u0026#34;); }); // 关闭线程池 threadPoolExecutor.shutdown(); } } 输出:\n18:19:48.203 INFO [pool-1-thread-1] c.j.concurrent.ThreadPoolTest - 核心线程执行第一个任务 18:19:48.203 INFO [pool-1-thread-2] c.j.concurrent.ThreadPoolTest - 非核心线程处理第三个任务 18:19:48.203 INFO [main] c.j.concurrent.ThreadPoolTest - 主线程处理第四个任务 18:20:48.212 INFO [pool-1-thread-2] c.j.concurrent.ThreadPoolTest - 非核心线程处理入队的第二个任务 18:21:48.219 INFO [pool-1-thread-2] c.j.concurrent.ThreadPoolTest - 核心线程执行第五个任务 从输出结果可以看出,因为CallerRunsPolicy这个拒绝策略,导致耗时的任务用了主线程执行,导致线程池阻塞,进而导致后续任务无法及时执行,严重的情况下很可能导致 OOM。\n我们从问题的本质入手,调用者采用CallerRunsPolicy是希望所有的任务都能够被执行,暂时无法处理的任务又被保存在阻塞队列BlockingQueue中。这样的话,在内存允许的情况下,我们可以增加阻塞队列BlockingQueue的大小并调整堆内存以容纳更多的任务,确保任务能够被准确执行。\n为了充分利用 CPU,我们还可以调整线程池的maximumPoolSize (最大线程数)参数,这样可以提高任务处理速度,避免累计在 BlockingQueue的任务过多导致内存用完。\n如果服务器资源以达到可利用的极限,这就意味我们要在设计策略上改变线程池的调度了,我们都知道,导致主线程卡死的本质就是因为我们不希望任何一个任务被丢弃。换个思路,有没有办法既能保证任务不被丢弃且在服务器有余力时及时处理呢?\n这里提供的一种任务持久化的思路,这里所谓的任务持久化,包括但不限于:\n设计一张任务表将任务存储到 MySQL 数据库中。 Redis 缓存任务。 将任务提交到消息队列中。 这里以方案一为例,简单介绍一下实现逻辑:\n实现RejectedExecutionHandler接口自定义拒绝策略,自定义拒绝策略负责将线程池暂时无法处理(此时阻塞队列已满)的任务入库(保存到 MySQL 中)。注意:线程池暂时无法处理的任务会先被放在阻塞队列中,阻塞队列满了才会触发拒绝策略。 继承BlockingQueue实现一个混合式阻塞队列,该队列包含 JDK 自带的ArrayBlockingQueue。另外,该混合式阻塞队列需要修改取任务处理的逻辑,也就是重写take()方法,取任务时优先从数据库中读取最早的任务,数据库中无任务时再从 ArrayBlockingQueue中去取任务。 整个实现逻辑还是比较简单的,核心在于自定义拒绝策略和阻塞队列。如此一来,一旦我们的线程池中线程以达到满载时,我们就可以通过拒绝策略将最新任务持久化到 MySQL 数据库中,等到线程池有了有余力处理所有任务时,让其优先处理数据库中的任务以避免\u0026quot;饥饿\u0026quot;问题。\n当然,对于这个问题,我们也可以参考其他主流框架的做法,以 Netty 为例,它的拒绝策略则是直接创建一个线程池以外的线程处理这些任务,为了保证任务的实时处理,这种做法可能需要良好的硬件设备且临时创建的线程无法做到准确的监控:\nprivate static final class NewThreadRunsPolicy implements RejectedExecutionHandler { NewThreadRunsPolicy() { super(); } public void rejectedExecution(Runnable r, ThreadPoolExecutor executor) { try { //创建一个临时线程处理任务 final Thread t = new Thread(r, \u0026#34;Temporary task executor\u0026#34;); t.start(); } catch (Throwable e) { throw new RejectedExecutionException( \u0026#34;Failed to start a new thread\u0026#34;, e); } } } ActiveMQ 则是尝试在指定的时效内尽可能的争取将任务入队,以保证最大交付:\nnew RejectedExecutionHandler() { @Override public void rejectedExecution(final Runnable r, final ThreadPoolExecutor executor) { try { //限时阻塞等待,实现尽可能交付 executor.getQueue().offer(r, 60, TimeUnit.SECONDS); } catch (InterruptedException e) { throw new RejectedExecutionException(\u0026#34;Interrupted waiting for BrokerService.worker\u0026#34;); } throw new RejectedExecutionException(\u0026#34;Timed Out while attempting to enqueue Task.\u0026#34;); } }); 线程池常用的阻塞队列有哪些? # 新任务来的时候会先判断当前运行的线程数量是否达到核心线程数,如果达到的话,新任务就会被存放在队列中。\n不同的线程池会选用不同的阻塞队列,我们可以结合内置线程池来分析。\n容量为 Integer.MAX_VALUE 的 LinkedBlockingQueue(有界阻塞队列):FixedThreadPool 和 SingleThreadExecutor 。FixedThreadPool最多只能创建核心线程数的线程(核心线程数和最大线程数相等),SingleThreadExecutor只能创建一个线程(核心线程数和最大线程数都是 1),二者的任务队列永远不会被放满。 SynchronousQueue(同步队列):CachedThreadPool 。SynchronousQueue 没有容量,不存储元素,目的是保证对于提交的任务,如果有空闲线程,则使用空闲线程来处理;否则新建一个线程来处理任务。也就是说,CachedThreadPool 的最大线程数是 Integer.MAX_VALUE ,可以理解为线程数是可以无限扩展的,可能会创建大量线程,从而导致 OOM。 DelayedWorkQueue(延迟队列):ScheduledThreadPool 和 SingleThreadScheduledExecutor 。DelayedWorkQueue 的内部元素并不是按照放入的时间排序,而是会按照延迟的时间长短对任务进行排序,内部采用的是“堆”的数据结构,可以保证每次出队的任务都是当前队列中执行时间最靠前的。DelayedWorkQueue 添加元素满了之后会自动扩容,增加原来容量的 50%,即永远不会阻塞,最大扩容可达 Integer.MAX_VALUE,所以最多只能创建核心线程数的线程。 ArrayBlockingQueue(有界阻塞队列):底层由数组实现,容量一旦创建,就不能修改。 ⭐️线程池处理任务的流程了解吗? # 如果当前运行的线程数小于核心线程数,那么就会新建一个线程来执行任务。 如果当前运行的线程数等于或大于核心线程数,但是小于最大线程数,那么就把该任务放入到任务队列里等待执行。 如果向任务队列投放任务失败(任务队列已经满了),但是当前运行的线程数是小于最大线程数的,就新建一个线程来执行任务。 如果当前运行的线程数已经等同于最大线程数了,新建线程将会使当前运行的线程超出最大线程数,那么当前任务会被拒绝,拒绝策略会调用RejectedExecutionHandler.rejectedExecution()方法。 再提一个有意思的小问题:线程池在提交任务前,可以提前创建线程吗?\n答案是可以的!ThreadPoolExecutor 提供了两个方法帮助我们在提交任务之前,完成核心线程的创建,从而实现线程池预热的效果:\nprestartCoreThread():启动一个线程,等待任务,如果已达到核心线程数,这个方法返回 false,否则返回 true; prestartAllCoreThreads():启动所有的核心线程,并返回启动成功的核心线程数。 ⭐️线程池中线程异常后,销毁还是复用? # 直接说结论,需要分两种情况:\n使用execute()提交任务:当任务通过execute()提交到线程池并在执行过程中抛出异常时,如果这个异常没有在任务内被捕获,那么该异常会导致当前线程终止,并且异常会被打印到控制台或日志文件中。线程池会检测到这种线程终止,并创建一个新线程来替换它,从而保持配置的线程数不变。 使用submit()提交任务:对于通过submit()提交的任务,如果在任务执行中发生异常,这个异常不会直接打印出来。相反,异常会被封装在由submit()返回的Future对象中。当调用Future.get()方法时,可以捕获到一个ExecutionException。在这种情况下,线程不会因为异常而终止,它会继续存在于线程池中,准备执行后续的任务。 简单来说:使用execute()时,未捕获异常导致线程终止,线程池创建新线程替代;使用submit()时,异常被封装在Future中,线程继续复用。\n这种设计允许submit()提供更灵活的错误处理机制,因为它允许调用者决定如何处理异常,而execute()则适用于那些不需要关注执行结果的场景。\n具体的源码分析可以参考这篇: 线程池中线程异常后:销毁还是复用? - 京东技术。\n⭐️如何给线程池命名? # 初始化线程池的时候需要显示命名(设置线程池名称前缀),有利于定位问题。\n默认情况下创建的线程名字类似 pool-1-thread-n 这样的,没有业务含义,不利于我们定位问题。\n给线程池里的线程命名通常有下面两种方式:\n1、利用 guava 的 ThreadFactoryBuilder\nThreadFactory threadFactory = new ThreadFactoryBuilder() .setNameFormat(threadNamePrefix + \u0026#34;-%d\u0026#34;) .setDaemon(true).build(); ExecutorService threadPool = new ThreadPoolExecutor(corePoolSize, maximumPoolSize, keepAliveTime, TimeUnit.MINUTES, workQueue, threadFactory); 2、自己实现 ThreadFactory。\nimport java.util.concurrent.ThreadFactory; import java.util.concurrent.atomic.AtomicInteger; /** * 线程工厂,它设置线程名称,有利于我们定位问题。 */ public final class NamingThreadFactory implements ThreadFactory { private final AtomicInteger threadNum = new AtomicInteger(); private final String name; /** * 创建一个带名字的线程池生产工厂 */ public NamingThreadFactory(String name) { this.name = name; } @Override public Thread newThread(Runnable r) { Thread t = new Thread(r); t.setName(name + \u0026#34; [#\u0026#34; + threadNum.incrementAndGet() + \u0026#34;]\u0026#34;); return t; } } 如何设定线程池的大小? # 很多人甚至可能都会觉得把线程池配置过大一点比较好!我觉得这明显是有问题的。就拿我们生活中非常常见的一例子来说:并不是人多就能把事情做好,增加了沟通交流成本。你本来一件事情只需要 3 个人做,你硬是拉来了 6 个人,会提升做事效率嘛?我想并不会。 线程数量过多的影响也是和我们分配多少人做事情一样,对于多线程这个场景来说主要是增加了上下文切换成本。不清楚什么是上下文切换的话,可以看我下面的介绍。\n上下文切换:\n多线程编程中一般线程的个数都大于 CPU 核心的个数,而一个 CPU 核心在任意时刻只能被一个线程使用,为了让这些线程都能得到有效执行,CPU 采取的策略是为每个线程分配时间片并轮转的形式。当一个线程的时间片用完的时候就会重新处于就绪状态让给其他线程使用,这个过程就属于一次上下文切换。概括来说就是:当前任务在执行完 CPU 时间片切换到另一个任务之前会先保存自己的状态,以便下次再切换回这个任务时,可以再加载这个任务的状态。任务从保存到再加载的过程就是一次上下文切换。\n上下文切换通常是计算密集型的。也就是说,它需要相当可观的处理器时间,在每秒几十上百次的切换中,每次切换都需要纳秒量级的时间。所以,上下文切换对系统来说意味着消耗大量的 CPU 时间,事实上,可能是操作系统中时间消耗最大的操作。\nLinux 相比与其他操作系统(包括其他类 Unix 系统)有很多的优点,其中有一项就是,其上下文切换和模式切换的时间消耗非常少。\n类比于现实世界中的人类通过合作做某件事情,我们可以肯定的一点是线程池大小设置过大或者过小都会有问题,合适的才是最好。\n如果我们设置的线程池数量太小的话,如果同一时间有大量任务/请求需要处理,可能会导致大量的请求/任务在任务队列中排队等待执行,甚至会出现任务队列满了之后任务/请求无法处理的情况,或者大量任务堆积在任务队列导致 OOM。这样很明显是有问题的,CPU 根本没有得到充分利用。 如果我们设置线程数量太大,大量线程可能会同时在争取 CPU 资源,这样会导致大量的上下文切换,从而增加线程的执行时间,影响了整体执行效率。 有一个简单并且适用面比较广的公式:\nCPU 密集型任务(N+1): 这种任务消耗的主要是 CPU 资源,可以将线程数设置为 N(CPU 核心数)+1。比 CPU 核心数多出来的一个线程是为了防止线程偶发的缺页中断,或者其它原因导致的任务暂停而带来的影响。一旦任务暂停,CPU 就会处于空闲状态,而在这种情况下多出来的一个线程就可以充分利用 CPU 的空闲时间。 I/O 密集型任务(2N): 这种任务应用起来,系统会用大部分的时间来处理 I/O 交互,而线程在处理 I/O 的时间段内不会占用 CPU 来处理,这时就可以将 CPU 交出给其它线程使用。因此在 I/O 密集型任务的应用中,我们可以多配置一些线程,具体的计算方法是 2N。 如何判断是 CPU 密集任务还是 IO 密集任务?\nCPU 密集型简单理解就是利用 CPU 计算能力的任务比如你在内存中对大量数据进行排序。但凡涉及到网络读取,文件读取这类都是 IO 密集型,这类任务的特点是 CPU 计算耗费时间相比于等待 IO 操作完成的时间来说很少,大部分时间都花在了等待 IO 操作完成上。\n🌈 拓展一下(参见: issue#1737):\n线程数更严谨的计算的方法应该是:最佳线程数 = N(CPU 核心数)∗(1+WT(线程等待时间)/ST(线程计算时间)),其中 WT(线程等待时间)=线程运行总时间 - ST(线程计算时间)。\n线程等待时间所占比例越高,需要越多线程。线程计算时间所占比例越高,需要越少线程。\n我们可以通过 JDK 自带的工具 VisualVM 来查看 WT/ST 比例。\nCPU 密集型任务的 WT/ST 接近或者等于 0,因此, 线程数可以设置为 N(CPU 核心数)∗(1+0)= N,和我们上面说的 N(CPU 核心数)+1 差不多。\nIO 密集型任务下,几乎全是线程等待时间,从理论上来说,你就可以将线程数设置为 2N(按道理来说,WT/ST 的结果应该比较大,这里选择 2N 的原因应该是为了避免创建过多线程吧)。\n公式也只是参考,具体还是要根据项目实际线上运行情况来动态调整。我在后面介绍的美团的线程池参数动态配置这种方案就非常不错,很实用!\n⭐️如何动态修改线程池的参数? # 美团技术团队在 《Java 线程池实现原理及其在美团业务中的实践》这篇文章中介绍到对线程池参数实现可自定义配置的思路和方法。\n美团技术团队的思路是主要对线程池的核心参数实现自定义可配置。这三个核心参数是:\ncorePoolSize : 核心线程数线程数定义了最小可以同时运行的线程数量。 maximumPoolSize : 当队列中存放的任务达到队列容量的时候,当前可以同时运行的线程数量变为最大线程数。 workQueue: 当新任务来的时候会先判断当前运行的线程数量是否达到核心线程数,如果达到的话,新任务就会被存放在队列中。 为什么是这三个参数?\n我在 Java 线程池详解 这篇文章中就说过这三个参数是 ThreadPoolExecutor 最重要的参数,它们基本决定了线程池对于任务的处理策略。\n如何支持参数动态配置? 且看 ThreadPoolExecutor 提供的下面这些方法。\n格外需要注意的是corePoolSize, 程序运行期间的时候,我们调用 setCorePoolSize()这个方法的话,线程池会首先判断当前工作线程数是否大于corePoolSize,如果大于的话就会回收工作线程。\n另外,你也看到了上面并没有动态指定队列长度的方法,美团的方式是自定义了一个叫做 ResizableCapacityLinkedBlockIngQueue 的队列(主要就是把LinkedBlockingQueue的 capacity 字段的 final 关键字修饰给去掉了,让它变为可变的)。\n最终实现的可动态修改线程池参数效果如下。👏👏👏\n还没看够?我在 《后端面试高频系统设计\u0026amp;场景题》中详细介绍了如何设计一个动态线程池,这也是面试中常问的一道系统设计题。\n如果我们的项目也想要实现这种效果的话,可以借助现成的开源项目:\nHippo4j:异步线程池框架,支持线程池动态变更\u0026amp;监控\u0026amp;报警,无需修改代码轻松引入。支持多种使用模式,轻松引入,致力于提高系统运行保障能力。 Dynamic TP:轻量级动态线程池,内置监控告警功能,集成三方中间件线程池管理,基于主流配置中心(已支持 Nacos、Apollo,Zookeeper、Consul、Etcd,可通过 SPI 自定义实现)。 ⭐️如何设计一个能够根据任务的优先级来执行的线程池? # 这是一个常见的面试问题,本质其实还是在考察求职者对于线程池以及阻塞队列的掌握。\n我们上面也提到了,不同的线程池会选用不同的阻塞队列作为任务队列,比如FixedThreadPool 使用的是LinkedBlockingQueue(有界队列),默认构造器初始的队列长度为 Integer.MAX_VALUE ,由于队列永远不会被放满,因此FixedThreadPool最多只能创建核心线程数的线程。\n假如我们需要实现一个优先级任务线程池的话,那可以考虑使用 PriorityBlockingQueue (优先级阻塞队列)作为任务队列(ThreadPoolExecutor 的构造函数有一个 workQueue 参数可以传入任务队列)。\nPriorityBlockingQueue 是一个支持优先级的无界阻塞队列,可以看作是线程安全的 PriorityQueue,两者底层都是使用小顶堆形式的二叉堆,即值最小的元素优先出队。不过,PriorityQueue 不支持阻塞操作。\n要想让 PriorityBlockingQueue 实现对任务的排序,传入其中的任务必须是具备排序能力的,方式有两种:\n提交到线程池的任务实现 Comparable 接口,并重写 compareTo 方法来指定任务之间的优先级比较规则。 创建 PriorityBlockingQueue 时传入一个 Comparator 对象来指定任务之间的排序规则(推荐)。 不过,这存在一些风险和问题,比如:\nPriorityBlockingQueue 是无界的,可能堆积大量的请求,从而导致 OOM。 可能会导致饥饿问题,即低优先级的任务长时间得不到执行。 由于需要对队列中的元素进行排序操作以及保证线程安全(并发控制采用的是可重入锁 ReentrantLock),因此会降低性能。 对于 OOM 这个问题的解决比较简单粗暴,就是继承PriorityBlockingQueue 并重写一下 offer 方法(入队)的逻辑,当插入的元素数量超过指定值就返回 false 。\n饥饿问题这个可以通过优化设计来解决(比较麻烦),比如等待时间过长的任务会被移除并重新添加到队列中,但是优先级会被提升。\n对于性能方面的影响,是没办法避免的,毕竟需要对任务进行排序操作。并且,对于大部分业务场景来说,这点性能影响是可以接受的。\nFuture # 重点是要掌握 CompletableFuture 的使用以及常见面试题。\n除了下面的面试题之外,还推荐你看看我写的这篇文章: CompletableFuture 详解。\nFuture 类有什么用? # Future 类是异步思想的典型运用,主要用在一些需要执行耗时任务的场景,避免程序一直原地等待耗时任务执行完成,执行效率太低。具体来说是这样的:当我们执行某一耗时的任务时,可以将这个耗时任务交给一个子线程去异步执行,同时我们可以干点其他事情,不用傻傻等待耗时任务执行完成。等我们的事情干完后,我们再通过 Future 类获取到耗时任务的执行结果。这样一来,程序的执行效率就明显提高了。\n这其实就是多线程中经典的 Future 模式,你可以将其看作是一种设计模式,核心思想是异步调用,主要用在多线程领域,并非 Java 语言独有。\n在 Java 中,Future 类只是一个泛型接口,位于 java.util.concurrent 包下,其中定义了 5 个方法,主要包括下面这 4 个功能:\n取消任务; 判断任务是否被取消; 判断任务是否已经执行完成; 获取任务执行结果。 // V 代表了Future执行的任务返回值的类型 public interface Future\u0026lt;V\u0026gt; { // 取消任务执行 // 成功取消返回 true,否则返回 false boolean cancel(boolean mayInterruptIfRunning); // 判断任务是否被取消 boolean isCancelled(); // 判断任务是否已经执行完成 boolean isDone(); // 获取任务执行结果 V get() throws InterruptedException, ExecutionException; // 指定时间内没有返回计算结果就抛出 TimeOutException 异常 V get(long timeout, TimeUnit unit) throws InterruptedException, ExecutionException, TimeoutExceptio } 简单理解就是:我有一个任务,提交给了 Future 来处理。任务执行期间我自己可以去做任何想做的事情。并且,在这期间我还可以取消任务以及获取任务的执行状态。一段时间之后,我就可以 Future 那里直接取出任务执行结果。\nCallable 和 Future 有什么关系? # 我们可以通过 FutureTask 来理解 Callable 和 Future 之间的关系。\nFutureTask 提供了 Future 接口的基本实现,常用来封装 Callable 和 Runnable,具有取消任务、查看任务是否执行完成以及获取任务执行结果的方法。ExecutorService.submit() 方法返回的其实就是 Future 的实现类 FutureTask 。\n\u0026lt;T\u0026gt; Future\u0026lt;T\u0026gt; submit(Callable\u0026lt;T\u0026gt; task); Future\u0026lt;?\u0026gt; submit(Runnable task); FutureTask 不光实现了 Future接口,还实现了Runnable 接口,因此可以作为任务直接被线程执行。\nFutureTask 有两个构造函数,可传入 Callable 或者 Runnable 对象。实际上,传入 Runnable 对象也会在方法内部转换为Callable 对象。\npublic FutureTask(Callable\u0026lt;V\u0026gt; callable) { if (callable == null) throw new NullPointerException(); this.callable = callable; this.state = NEW; } public FutureTask(Runnable runnable, V result) { // 通过适配器RunnableAdapter来将Runnable对象runnable转换成Callable对象 this.callable = Executors.callable(runnable, result); this.state = NEW; } FutureTask相当于对Callable 进行了封装,管理着任务执行的情况,存储了 Callable 的 call 方法的任务执行结果。\nCompletableFuture 类有什么用? # Future 在实际使用过程中存在一些局限性比如不支持异步任务的编排组合、获取计算结果的 get() 方法为阻塞调用。\nJava 8 才被引入CompletableFuture 类可以解决Future 的这些缺陷。CompletableFuture 除了提供了更为好用和强大的 Future 特性之外,还提供了函数式编程、异步任务编排组合(可以将多个异步任务串联起来,组成一个完整的链式调用)等能力。\n下面我们来简单看看 CompletableFuture 类的定义。\npublic class CompletableFuture\u0026lt;T\u0026gt; implements Future\u0026lt;T\u0026gt;, CompletionStage\u0026lt;T\u0026gt; { } 可以看到,CompletableFuture 同时实现了 Future 和 CompletionStage 接口。\nCompletionStage 接口描述了一个异步计算的阶段。很多计算可以分成多个阶段或步骤,此时可以通过它将所有步骤组合起来,形成异步计算的流水线。\nCompletionStage 接口中的方法比较多,CompletableFuture 的函数式能力就是这个接口赋予的。从这个接口的方法参数你就可以发现其大量使用了 Java8 引入的函数式编程。\n⭐️一个任务需要依赖另外两个任务执行完之后再执行,怎么设计? # 这种任务编排场景非常适合通过CompletableFuture实现。这里假设要实现 T3 在 T2 和 T1 执行完后执行。\n代码如下(这里为了简化代码,用到了 Hutool 的线程工具类 ThreadUtil 和日期时间工具类 DateUtil):\n// T1 CompletableFuture\u0026lt;Void\u0026gt; futureT1 = CompletableFuture.runAsync(() -\u0026gt; { System.out.println(\u0026#34;T1 is executing. Current time:\u0026#34; + DateUtil.now()); // 模拟耗时操作 ThreadUtil.sleep(1000); }); // T2 CompletableFuture\u0026lt;Void\u0026gt; futureT2 = CompletableFuture.runAsync(() -\u0026gt; { System.out.println(\u0026#34;T2 is executing. Current time:\u0026#34; + DateUtil.now()); ThreadUtil.sleep(1000); }); // 使用allOf()方法合并T1和T2的CompletableFuture,等待它们都完成 CompletableFuture\u0026lt;Void\u0026gt; bothCompleted = CompletableFuture.allOf(futureT1, futureT2); // 当T1和T2都完成后,执行T3 bothCompleted.thenRunAsync(() -\u0026gt; System.out.println(\u0026#34;T3 is executing after T1 and T2 have completed.Current time:\u0026#34; + DateUtil.now())); // 等待所有任务完成,验证效果 ThreadUtil.sleep(3000); 通过 CompletableFuture 的 allOf()这个静态方法来并行运行 T1 和 T2 。当 T1 和\n⭐️使用 CompletableFuture,有一个任务失败,如何处理异常? # 使用 CompletableFuture的时候一定要以正确的方式进行异常处理,避免异常丢失或者出现不可控问题。\n下面是一些建议:\n使用 whenComplete 方法可以在任务完成时触发回调函数,并正确地处理异常,而不是让异常被吞噬或丢失。 使用 exceptionally 方法可以处理异常并重新抛出,以便异常能够传播到后续阶段,而不是让异常被忽略或终止。 使用 handle 方法可以处理正常的返回结果和异常,并返回一个新的结果,而不是让异常影响正常的业务逻辑。 使用 CompletableFuture.allOf 方法可以组合多个 CompletableFuture,并统一处理所有任务的异常,而不是让异常处理过于冗长或重复。 …… ⭐️在使用 CompletableFuture 的时候为什么要自定义线程池? # CompletableFuture 默认使用全局共享的 ForkJoinPool.commonPool() 作为执行器,所有未指定执行器的异步任务都会使用该线程池。这意味着应用程序、多个库或框架(如 Spring、第三方库)若都依赖 CompletableFuture,默认情况下它们都会共享同一个线程池。\n虽然 ForkJoinPool 效率很高,但当同时提交大量任务时,可能会导致资源竞争和线程饥饿,进而影响系统性能。\n为避免这些问题,建议为 CompletableFuture 提供自定义线程池,带来以下优势:\n隔离性:为不同任务分配独立的线程池,避免全局线程池资源争夺。 资源控制:根据任务特性调整线程池大小和队列类型,优化性能表现。 异常处理:通过自定义 ThreadFactory 更好地处理线程中的异常情况。 private ThreadPoolExecutor executor = new ThreadPoolExecutor(10, 10, 0L, TimeUnit.MILLISECONDS, new LinkedBlockingQueue\u0026lt;Runnable\u0026gt;()); CompletableFuture.runAsync(() -\u0026gt; { //... }, executor); AQS # 关于 AQS 源码的详细分析,可以看看这一篇文章: AQS 详解。\nAQS 是什么? # AQS (AbstractQueuedSynchronizer ,抽象队列同步器)是从 JDK1.5 开始提供的 Java 并发核心组件。\nAQS 解决了开发者在实现同步器时的复杂性问题。它提供了一个通用框架,用于实现各种同步器,例如 可重入锁(ReentrantLock)、信号量(Semaphore)和 倒计时器(CountDownLatch)。通过封装底层的线程同步机制,AQS 将复杂的线程管理逻辑隐藏起来,使开发者只需专注于具体的同步逻辑。\n简单来说,AQS 是一个抽象类,为同步器提供了通用的 执行框架。它定义了 资源获取和释放的通用流程,而具体的资源获取逻辑则由具体同步器通过重写模板方法来实现。 因此,可以将 AQS 看作是同步器的 基础“底座”,而同步器则是基于 AQS 实现的 具体“应用”。\n⭐️AQS 的原理是什么? # AQS 核心思想是,如果被请求的共享资源空闲,则将当前请求资源的线程设置为有效的工作线程,并且将共享资源设置为锁定状态。如果被请求的共享资源被占用,那么就需要一套线程阻塞等待以及被唤醒时锁分配的机制,这个机制 AQS 是基于 CLH 锁 (Craig, Landin, and Hagersten locks) 进一步优化实现的。\nCLH 锁 对自旋锁进行了改进,是基于单链表的自旋锁。在多线程场景下,会将请求获取锁的线程组织成一个单向队列,每个等待的线程会通过自旋访问前一个线程节点的状态,前一个节点释放锁之后,当前节点才可以获取锁。CLH 锁 的队列结构如下图所示。\nAQS 中使用的 等待队列 是 CLH 锁队列的变体(接下来简称为 CLH 变体队列)。\nAQS 的 CLH 变体队列是一个双向队列,会暂时获取不到锁的线程将被加入到该队列中,CLH 变体队列和原本的 CLH 锁队列的区别主要有两点:\n由 自旋 优化为 自旋 + 阻塞 :自旋操作的性能很高,但大量的自旋操作比较占用 CPU 资源,因此在 CLH 变体队列中会先通过自旋尝试获取锁,如果失败再进行阻塞等待。 由 单向队列 优化为 双向队列 :在 CLH 变体队列中,会对等待的线程进行阻塞操作,当队列前边的线程释放锁之后,需要对后边的线程进行唤醒,因此增加了 next 指针,成为了双向队列。 AQS 将每条请求共享资源的线程封装成一个 CLH 变体队列的一个结点(Node)来实现锁的分配。在 CLH 变体队列中,一个节点表示一个线程,它保存着线程的引用(thread)、 当前节点在队列中的状态(waitStatus)、前驱节点(prev)、后继节点(next)。\nAQS 中的 CLH 变体队列结构如下图所示:\nAQS(AbstractQueuedSynchronizer)的核心原理图:\nAQS 使用 int 成员变量 state 表示同步状态,通过内置的 线程等待队列 来完成获取资源线程的排队工作。\nstate 变量由 volatile 修饰,用于展示当前临界资源的获锁情况。\n// 共享变量,使用volatile修饰保证线程可见性 private volatile int state; 另外,状态信息 state 可以通过 protected 类型的getState()、setState()和compareAndSetState() 进行操作。并且,这几个方法都是 final 修饰的,在子类中无法被重写。\n//返回同步状态的当前值 protected final int getState() { return state; } // 设置同步状态的值 protected final void setState(int newState) { state = newState; } //原子地(CAS操作)将同步状态值设置为给定值update如果当前同步状态的值等于expect(期望值) protected final boolean compareAndSetState(int expect, int update) { return unsafe.compareAndSwapInt(this, stateOffset, expect, update); } 以 ReentrantLock 为例,state 初始值为 0,表示未锁定状态。A 线程 lock() 时,会调用 tryAcquire() 独占该锁并将 state+1 。此后,其他线程再 tryAcquire() 时就会失败,直到 A 线程 unlock() 到 state=0(即释放锁)为止,其它线程才有机会获取该锁。当然,释放锁之前,A 线程自己是可以重复获取此锁的(state 会累加),这就是可重入的概念。但要注意,获取多少次就要释放多少次,这样才能保证 state 是能回到零态的。\n再以 CountDownLatch 以例,任务分为 N 个子线程去执行,state 也初始化为 N(注意 N 要与线程个数一致)。这 N 个子线程是并行执行的,每个子线程执行完后countDown() 一次,state 会 CAS(Compare and Swap) 减 1。等到所有子线程都执行完后(即 state=0 ),会 unpark() 主调用线程,然后主调用线程就会从 await() 函数返回,继续后续动作。\nSemaphore 有什么用? # synchronized 和 ReentrantLock 都是一次只允许一个线程访问某个资源,而Semaphore(信号量)可以用来控制同时访问特定资源的线程数量。\nSemaphore 的使用简单,我们这里假设有 N(N\u0026gt;5) 个线程来获取 Semaphore 中的共享资源,下面的代码表示同一时刻 N 个线程中只有 5 个线程能获取到共享资源,其他线程都会阻塞,只有获取到共享资源的线程才能执行。等到有线程释放了共享资源,其他阻塞的线程才能获取到。\n// 初始共享资源数量 final Semaphore semaphore = new Semaphore(5); // 获取1个许可 semaphore.acquire(); // 释放1个许可 semaphore.release(); 当初始的资源个数为 1 的时候,Semaphore 退化为排他锁。\nSemaphore 有两种模式:。\n公平模式: 调用 acquire() 方法的顺序就是获取许可证的顺序,遵循 FIFO; 非公平模式: 抢占式的。 Semaphore 对应的两个构造方法如下:\npublic Semaphore(int permits) { sync = new NonfairSync(permits); } public Semaphore(int permits, boolean fair) { sync = fair ? new FairSync(permits) : new NonfairSync(permits); } 这两个构造方法,都必须提供许可的数量,第二个构造方法可以指定是公平模式还是非公平模式,默认非公平模式。\nSemaphore 通常用于那些资源有明确访问数量限制的场景比如限流(仅限于单机模式,实际项目中推荐使用 Redis +Lua 来做限流)。\nSemaphore 的原理是什么? # Semaphore 是共享锁的一种实现,它默认构造 AQS 的 state 值为 permits,你可以将 permits 的值理解为许可证的数量,只有拿到许可证的线程才能执行。\n调用semaphore.acquire() ,线程尝试获取许可证,如果 state \u0026gt;= 0 的话,则表示可以获取成功。如果获取成功的话,使用 CAS 操作去修改 state 的值 state=state-1。如果 state\u0026lt;0 的话,则表示许可证数量不足。此时会创建一个 Node 节点加入阻塞队列,挂起当前线程。\n/** * 获取1个许可证 */ public void acquire() throws InterruptedException { sync.acquireSharedInterruptibly(1); } /** * 共享模式下获取许可证,获取成功则返回,失败则加入阻塞队列,挂起线程 */ public final void acquireSharedInterruptibly(int arg) throws InterruptedException { if (Thread.interrupted()) throw new InterruptedException(); // 尝试获取许可证,arg为获取许可证个数,当可用许可证数减当前获取的许可证数结果小于0,则创建一个节点加入阻塞队列,挂起当前线程。 if (tryAcquireShared(arg) \u0026lt; 0) doAcquireSharedInterruptibly(arg); } 调用semaphore.release(); ,线程尝试释放许可证,并使用 CAS 操作去修改 state 的值 state=state+1。释放许可证成功之后,同时会唤醒同步队列中的一个线程。被唤醒的线程会重新尝试去修改 state 的值 state=state-1 ,如果 state\u0026gt;=0 则获取令牌成功,否则重新进入阻塞队列,挂起线程。\n// 释放一个许可证 public void release() { sync.releaseShared(1); } // 释放共享锁,同时会唤醒同步队列中的一个线程。 public final boolean releaseShared(int arg) { //释放共享锁 if (tryReleaseShared(arg)) { //唤醒同步队列中的一个线程 doReleaseShared(); return true; } return false; } CountDownLatch 有什么用? # CountDownLatch 允许 count 个线程阻塞在一个地方,直至所有线程的任务都执行完毕。\nCountDownLatch 是一次性的,计数器的值只能在构造方法中初始化一次,之后没有任何机制再次对其设置值,当 CountDownLatch 使用完毕后,它不能再次被使用。\nCountDownLatch 的原理是什么? # CountDownLatch 是共享锁的一种实现,它默认构造 AQS 的 state 值为 count。当线程使用 countDown() 方法时,其实使用了tryReleaseShared方法以 CAS 的操作来减少 state,直至 state 为 0 。当调用 await() 方法的时候,如果 state 不为 0,那就证明任务还没有执行完毕,await() 方法就会一直阻塞,也就是说 await() 方法之后的语句不会被执行。直到count 个线程调用了countDown()使 state 值被减为 0,或者调用await()的线程被中断,该线程才会从阻塞中被唤醒,await() 方法之后的语句得到执行。\n用过 CountDownLatch 么?什么场景下用的? # CountDownLatch 的作用就是 允许 count 个线程阻塞在一个地方,直至所有线程的任务都执行完毕。之前在项目中,有一个使用多线程读取多个文件处理的场景,我用到了 CountDownLatch 。具体场景是下面这样的:\n我们要读取处理 6 个文件,这 6 个任务都是没有执行顺序依赖的任务,但是我们需要返回给用户的时候将这几个文件的处理的结果进行统计整理。\n为此我们定义了一个线程池和 count 为 6 的CountDownLatch对象 。使用线程池处理读取任务,每一个线程处理完之后就将 count-1,调用CountDownLatch对象的 await()方法,直到所有文件读取完之后,才会接着执行后面的逻辑。\n伪代码是下面这样的:\npublic class CountDownLatchExample1 { // 处理文件的数量 private static final int threadCount = 6; public static void main(String[] args) throws InterruptedException { // 创建一个具有固定线程数量的线程池对象(推荐使用构造方法创建) ExecutorService threadPool = Executors.newFixedThreadPool(10); final CountDownLatch countDownLatch = new CountDownLatch(threadCount); for (int i = 0; i \u0026lt; threadCount; i++) { final int threadnum = i; threadPool.execute(() -\u0026gt; { try { //处理文件的业务操作 //...... } catch (InterruptedException e) { e.printStackTrace(); } finally { //表示一个文件已经被完成 countDownLatch.countDown(); } }); } countDownLatch.await(); threadPool.shutdown(); System.out.println(\u0026#34;finish\u0026#34;); } } 有没有可以改进的地方呢?\n可以使用 CompletableFuture 类来改进!Java8 的 CompletableFuture 提供了很多对多线程友好的方法,使用它可以很方便地为我们编写多线程程序,什么异步、串行、并行或者等待所有线程执行完任务什么的都非常方便。\nCompletableFuture\u0026lt;Void\u0026gt; task1 = CompletableFuture.supplyAsync(()-\u0026gt;{ //自定义业务操作 }); ...... CompletableFuture\u0026lt;Void\u0026gt; task6 = CompletableFuture.supplyAsync(()-\u0026gt;{ //自定义业务操作 }); ...... CompletableFuture\u0026lt;Void\u0026gt; headerFuture=CompletableFuture.allOf(task1,.....,task6); try { headerFuture.join(); } catch (Exception ex) { //...... } System.out.println(\u0026#34;all done. \u0026#34;); 上面的代码还可以继续优化,当任务过多的时候,把每一个 task 都列出来不太现实,可以考虑通过循环来添加任务。\n//文件夹位置 List\u0026lt;String\u0026gt; filePaths = Arrays.asList(...) // 异步处理所有文件 List\u0026lt;CompletableFuture\u0026lt;String\u0026gt;\u0026gt; fileFutures = filePaths.stream() .map(filePath -\u0026gt; doSomeThing(filePath)) .collect(Collectors.toList()); // 将他们合并起来 CompletableFuture\u0026lt;Void\u0026gt; allFutures = CompletableFuture.allOf( fileFutures.toArray(new CompletableFuture[fileFutures.size()]) ); CyclicBarrier 有什么用? # CyclicBarrier 和 CountDownLatch 非常类似,它也可以实现线程间的技术等待,但是它的功能比 CountDownLatch 更加复杂和强大。主要应用场景和 CountDownLatch 类似。\nCountDownLatch 的实现是基于 AQS 的,而 CycliBarrier 是基于 ReentrantLock(ReentrantLock 也属于 AQS 同步器)和 Condition 的。\nCyclicBarrier 的字面意思是可循环使用(Cyclic)的屏障(Barrier)。它要做的事情是:让一组线程到达一个屏障(也可以叫同步点)时被阻塞,直到最后一个线程到达屏障时,屏障才会开门,所有被屏障拦截的线程才会继续干活。\nCyclicBarrier 的原理是什么? # CyclicBarrier 内部通过一个 count 变量作为计数器,count 的初始值为 parties 属性的初始化值,每当一个线程到了栅栏这里了,那么就将计数器减 1。如果 count 值为 0 了,表示这是这一代最后一个线程到达栅栏,就尝试执行我们构造方法中输入的任务。\n//每次拦截的线程数 private final int parties; //计数器 private int count; 下面我们结合源码来简单看看。\n1、CyclicBarrier 默认的构造方法是 CyclicBarrier(int parties),其参数表示屏障拦截的线程数量,每个线程调用 await() 方法告诉 CyclicBarrier 我已经到达了屏障,然后当前线程被阻塞。\npublic CyclicBarrier(int parties) { this(parties, null); } public CyclicBarrier(int parties, Runnable barrierAction) { if (parties \u0026lt;= 0) throw new IllegalArgumentException(); this.parties = parties; this.count = parties; this.barrierCommand = barrierAction; } 其中,parties 就代表了有拦截的线程的数量,当拦截的线程数量达到这个值的时候就打开栅栏,让所有线程通过。\n2、当调用 CyclicBarrier 对象调用 await() 方法时,实际上调用的是 dowait(false, 0L)方法。 await() 方法就像树立起一个栅栏的行为一样,将线程挡住了,当拦住的线程数量达到 parties 的值时,栅栏才会打开,线程才得以通过执行。\npublic int await() throws InterruptedException, BrokenBarrierException { try { return dowait(false, 0L); } catch (TimeoutException toe) { throw new Error(toe); // cannot happen } } dowait(false, 0L)方法源码分析如下:\n// 当线程数量或者请求数量达到 count 时 await 之后的方法才会被执行。上面的示例中 count 的值就为 5。 private int count; /** * Main barrier code, covering the various policies. */ private int dowait(boolean timed, long nanos) throws InterruptedException, BrokenBarrierException, TimeoutException { final ReentrantLock lock = this.lock; // 锁住 lock.lock(); try { final Generation g = generation; if (g.broken) throw new BrokenBarrierException(); // 如果线程中断了,抛出异常 if (Thread.interrupted()) { breakBarrier(); throw new InterruptedException(); } // cout减1 int index = --count; // 当 count 数量减为 0 之后说明最后一个线程已经到达栅栏了,也就是达到了可以执行await 方法之后的条件 if (index == 0) { // tripped boolean ranAction = false; try { final Runnable command = barrierCommand; if (command != null) command.run(); ranAction = true; // 将 count 重置为 parties 属性的初始化值 // 唤醒之前等待的线程 // 下一波执行开始 nextGeneration(); return 0; } finally { if (!ranAction) breakBarrier(); } } // loop until tripped, broken, interrupted, or timed out for (;;) { try { if (!timed) trip.await(); else if (nanos \u0026gt; 0L) nanos = trip.awaitNanos(nanos); } catch (InterruptedException ie) { if (g == generation \u0026amp;\u0026amp; ! g.broken) { breakBarrier(); throw ie; } else { // We\u0026#39;re about to finish waiting even if we had not // been interrupted, so this interrupt is deemed to // \u0026#34;belong\u0026#34; to subsequent execution. Thread.currentThread().interrupt(); } } if (g.broken) throw new BrokenBarrierException(); if (g != generation) return index; if (timed \u0026amp;\u0026amp; nanos \u0026lt;= 0L) { breakBarrier(); throw new TimeoutException(); } } } finally { lock.unlock(); } } 虚拟线程 # 虚拟线程在 Java 21 正式发布,这是一项重量级的更新。\n虽然目前面试中问的不多,但还是建议大家去简单了解一下,具体可以阅读这篇文章: 虚拟线程极简入门 。重点搞清楚虚拟线程和平台线程的关系以及虚拟线程的优势即可。\n参考 # 《深入理解 Java 虚拟机》 《实战 Java 高并发程序设计》 Java 线程池的实现原理及其在业务中的最佳实践:阿里云开发者: https://mp.weixin.qq.com/s/icrrxEsbABBvEU0Gym7D5Q 带你了解下 SynchronousQueue(并发队列专题): https://juejin.cn/post/7031196740128768037 阻塞队列 — DelayedWorkQueue 源码分析: https://zhuanlan.zhihu.com/p/310621485 Java 多线程(三)——FutureTask/CompletableFuture: https://www.cnblogs.com/iwehdio/p/14285282.html Java 并发之 AQS 详解: https://www.cnblogs.com/waterystone/p/4920797.html Java 并发包基石-AQS 详解: https://www.cnblogs.com/chengxiao/archive/2017/07/24/7141160.html "},{"id":515,"href":"/zh/docs/technology/Interview/java/concurrent/java-concurrent-questions-02/","title":"Java并发常见面试题总结(中)","section":"Concurrent","content":" ⭐️JMM(Java 内存模型) # JMM(Java 内存模型)相关的问题比较多,也比较重要,于是我单独抽了一篇文章来总结 JMM 相关的知识点和问题: JMM(Java 内存模型)详解 。\n⭐️volatile 关键字 # 如何保证变量的可见性? # 在 Java 中,volatile 关键字可以保证变量的可见性,如果我们将变量声明为 volatile ,这就指示 JVM,这个变量是共享且不稳定的,每次使用它都到主存中进行读取。\nvolatile 关键字其实并非是 Java 语言特有的,在 C 语言里也有,它最原始的意义就是禁用 CPU 缓存。如果我们将一个变量使用 volatile 修饰,这就指示 编译器,这个变量是共享且不稳定的,每次使用它都到主存中进行读取。\nvolatile 关键字能保证数据的可见性,但不能保证数据的原子性。synchronized 关键字两者都能保证。\n如何禁止指令重排序? # 在 Java 中,volatile 关键字除了可以保证变量的可见性,还有一个重要的作用就是防止 JVM 的指令重排序。 如果我们将变量声明为 volatile ,在对这个变量进行读写操作的时候,会通过插入特定的 内存屏障 的方式来禁止指令重排序。\n在 Java 中,Unsafe 类提供了三个开箱即用的内存屏障相关的方法,屏蔽了操作系统底层的差异:\npublic native void loadFence(); public native void storeFence(); public native void fullFence(); 理论上来说,你通过这个三个方法也可以实现和volatile禁止重排序一样的效果,只是会麻烦一些。\n下面我以一个常见的面试题为例讲解一下 volatile 关键字禁止指令重排序的效果。\n面试中面试官经常会说:“单例模式了解吗?来给我手写一下!给我解释一下双重检验锁方式实现单例模式的原理呗!”\n双重校验锁实现对象单例(线程安全):\npublic class Singleton { private volatile static Singleton uniqueInstance; private Singleton() { } public static Singleton getUniqueInstance() { //先判断对象是否已经实例过,没有实例化过才进入加锁代码 if (uniqueInstance == null) { //类对象加锁 synchronized (Singleton.class) { if (uniqueInstance == null) { uniqueInstance = new Singleton(); } } } return uniqueInstance; } } uniqueInstance 采用 volatile 关键字修饰也是很有必要的, uniqueInstance = new Singleton(); 这段代码其实是分为三步执行:\n为 uniqueInstance 分配内存空间 初始化 uniqueInstance 将 uniqueInstance 指向分配的内存地址 但是由于 JVM 具有指令重排的特性,执行顺序有可能变成 1-\u0026gt;3-\u0026gt;2。指令重排在单线程环境下不会出现问题,但是在多线程环境下会导致一个线程获得还没有初始化的实例。例如,线程 T1 执行了 1 和 3,此时 T2 调用 getUniqueInstance() 后发现 uniqueInstance 不为空,因此返回 uniqueInstance,但此时 uniqueInstance 还未被初始化。\nvolatile 可以保证原子性么? # volatile 关键字能保证变量的可见性,但不能保证对变量的操作是原子性的。\n我们通过下面的代码即可证明:\n/** * 微信搜 JavaGuide 回复\u0026#34;面试突击\u0026#34;即可免费领取个人原创的 Java 面试手册 * * @author Guide哥 * @date 2022/08/03 13:40 **/ public class VolatileAtomicityDemo { public volatile static int inc = 0; public void increase() { inc++; } public static void main(String[] args) throws InterruptedException { ExecutorService threadPool = Executors.newFixedThreadPool(5); VolatileAtomicityDemo volatileAtomicityDemo = new VolatileAtomicityDemo(); for (int i = 0; i \u0026lt; 5; i++) { threadPool.execute(() -\u0026gt; { for (int j = 0; j \u0026lt; 500; j++) { volatileAtomicityDemo.increase(); } }); } // 等待1.5秒,保证上面程序执行完成 Thread.sleep(1500); System.out.println(inc); threadPool.shutdown(); } } 正常情况下,运行上面的代码理应输出 2500。但你真正运行了上面的代码之后,你会发现每次输出结果都小于 2500。\n为什么会出现这种情况呢?不是说好了,volatile 可以保证变量的可见性嘛!\n也就是说,如果 volatile 能保证 inc++ 操作的原子性的话。每个线程中对 inc 变量自增完之后,其他线程可以立即看到修改后的值。5 个线程分别进行了 500 次操作,那么最终 inc 的值应该是 5*500=2500。\n很多人会误认为自增操作 inc++ 是原子性的,实际上,inc++ 其实是一个复合操作,包括三步:\n读取 inc 的值。 对 inc 加 1。 将 inc 的值写回内存。 volatile 是无法保证这三个操作是具有原子性的,有可能导致下面这种情况出现:\n线程 1 对 inc 进行读取操作之后,还未对其进行修改。线程 2 又读取了 inc的值并对其进行修改(+1),再将inc 的值写回内存。 线程 2 操作完毕后,线程 1 对 inc的值进行修改(+1),再将inc 的值写回内存。 这也就导致两个线程分别对 inc 进行了一次自增操作后,inc 实际上只增加了 1。\n其实,如果想要保证上面的代码运行正确也非常简单,利用 synchronized、Lock或者AtomicInteger都可以。\n使用 synchronized 改进:\npublic synchronized void increase() { inc++; } 使用 AtomicInteger 改进:\npublic AtomicInteger inc = new AtomicInteger(); public void increase() { inc.getAndIncrement(); } 使用 ReentrantLock 改进:\nLock lock = new ReentrantLock(); public void increase() { lock.lock(); try { inc++; } finally { lock.unlock(); } } ⭐️乐观锁和悲观锁 # 什么是悲观锁? # 悲观锁总是假设最坏的情况,认为共享资源每次被访问的时候就会出现问题(比如共享数据被修改),所以每次在获取资源操作的时候都会上锁,这样其他线程想拿到这个资源就会阻塞直到锁被上一个持有者释放。也就是说,共享资源每次只给一个线程使用,其它线程阻塞,用完后再把资源转让给其它线程。\n像 Java 中synchronized和ReentrantLock等独占锁就是悲观锁思想的实现。\npublic void performSynchronisedTask() { synchronized (this) { // 需要同步的操作 } } private Lock lock = new ReentrantLock(); lock.lock(); try { // 需要同步的操作 } finally { lock.unlock(); } 高并发的场景下,激烈的锁竞争会造成线程阻塞,大量阻塞线程会导致系统的上下文切换,增加系统的性能开销。并且,悲观锁还可能会存在死锁问题,影响代码的正常运行。\n什么是乐观锁? # 乐观锁总是假设最好的情况,认为共享资源每次被访问的时候不会出现问题,线程可以不停地执行,无需加锁也无需等待,只是在提交修改的时候去验证对应的资源(也就是数据)是否被其它线程修改了(具体方法可以使用版本号机制或 CAS 算法)。\n在 Java 中java.util.concurrent.atomic包下面的原子变量类(比如AtomicInteger、LongAdder)就是使用了乐观锁的一种实现方式 CAS 实现的。 // LongAdder 在高并发场景下会比 AtomicInteger 和 AtomicLong 的性能更好 // 代价就是会消耗更多的内存空间(空间换时间) LongAdder sum = new LongAdder(); sum.increment(); 高并发的场景下,乐观锁相比悲观锁来说,不存在锁竞争造成线程阻塞,也不会有死锁的问题,在性能上往往会更胜一筹。但是,如果冲突频繁发生(写占比非常多的情况),会频繁失败和重试,这样同样会非常影响性能,导致 CPU 飙升。\n不过,大量失败重试的问题也是可以解决的,像我们前面提到的 LongAdder以空间换时间的方式就解决了这个问题。\n理论上来说:\n悲观锁通常多用于写比较多的情况(多写场景,竞争激烈),这样可以避免频繁失败和重试影响性能,悲观锁的开销是固定的。不过,如果乐观锁解决了频繁失败和重试这个问题的话(比如LongAdder),也是可以考虑使用乐观锁的,要视实际情况而定。 乐观锁通常多用于写比较少的情况(多读场景,竞争较少),这样可以避免频繁加锁影响性能。不过,乐观锁主要针对的对象是单个共享变量(参考java.util.concurrent.atomic包下面的原子变量类)。 如何实现乐观锁? # 乐观锁一般会使用版本号机制或 CAS 算法实现,CAS 算法相对来说更多一些,这里需要格外注意。\n版本号机制 # 一般是在数据表中加上一个数据版本号 version 字段,表示数据被修改的次数。当数据被修改时,version 值会加一。当线程 A 要更新数据值时,在读取数据的同时也会读取 version 值,在提交更新时,若刚才读取到的 version 值为当前数据库中的 version 值相等时才更新,否则重试更新操作,直到更新成功。\n举一个简单的例子:假设数据库中帐户信息表中有一个 version 字段,当前值为 1 ;而当前帐户余额字段( balance )为 $100 。\n操作员 A 此时将其读出( version=1 ),并从其帐户余额中扣除 $50( $100-$50 )。 在操作员 A 操作的过程中,操作员 B 也读入此用户信息( version=1 ),并从其帐户余额中扣除 $20 ( $100-$20 )。 操作员 A 完成了修改工作,将数据版本号( version=1 ),连同帐户扣除后余额( balance=$50 ),提交至数据库更新,此时由于提交数据版本等于数据库记录当前版本,数据被更新,数据库记录 version 更新为 2 。 操作员 B 完成了操作,也将版本号( version=1 )试图向数据库提交数据( balance=$80 ),但此时比对数据库记录版本时发现,操作员 B 提交的数据版本号为 1 ,数据库记录当前版本也为 2 ,不满足 “ 提交版本必须等于当前版本才能执行更新 “ 的乐观锁策略,因此,操作员 B 的提交被驳回。 这样就避免了操作员 B 用基于 version=1 的旧数据修改的结果覆盖操作员 A 的操作结果的可能。\nCAS 算法 # CAS 的全称是 Compare And Swap(比较与交换) ,用于实现乐观锁,被广泛应用于各大框架中。CAS 的思想很简单,就是用一个预期值和要更新的变量值进行比较,两值相等才会进行更新。\nCAS 是一个原子操作,底层依赖于一条 CPU 的原子指令。\n原子操作 即最小不可拆分的操作,也就是说操作一旦开始,就不能被打断,直到操作完成。\nCAS 涉及到三个操作数:\nV:要更新的变量值(Var) E:预期值(Expected) N:拟写入的新值(New) 当且仅当 V 的值等于 E 时,CAS 通过原子方式用新值 N 来更新 V 的值。如果不等,说明已经有其它线程更新了 V,则当前线程放弃更新。\n举一个简单的例子:线程 A 要修改变量 i 的值为 6,i 原值为 1(V = 1,E=1,N=6,假设不存在 ABA 问题)。\ni 与 1 进行比较,如果相等, 则说明没被其他线程修改,可以被设置为 6 。 i 与 1 进行比较,如果不相等,则说明被其他线程修改,当前线程放弃更新,CAS 操作失败。 当多个线程同时使用 CAS 操作一个变量时,只有一个会胜出,并成功更新,其余均会失败,但失败的线程并不会被挂起,仅是被告知失败,并且允许再次尝试,当然也允许失败的线程放弃操作。\nJava 语言并没有直接实现 CAS,CAS 相关的实现是通过 C++ 内联汇编的形式实现的(JNI 调用)。因此, CAS 的具体实现和操作系统以及 CPU 都有关系。\nsun.misc包下的Unsafe类提供了compareAndSwapObject、compareAndSwapInt、compareAndSwapLong方法来实现的对Object、int、long类型的 CAS 操作\n/** * CAS * @param o 包含要修改field的对象 * @param offset 对象中某field的偏移量 * @param expected 期望值 * @param update 更新值 * @return true | false */ public final native boolean compareAndSwapObject(Object o, long offset, Object expected, Object update); public final native boolean compareAndSwapInt(Object o, long offset, int expected,int update); public final native boolean compareAndSwapLong(Object o, long offset, long expected, long update); 关于 Unsafe 类的详细介绍可以看这篇文章: Java 魔法类 Unsafe 详解 - JavaGuide - 2022 。\nJava 中 CAS 是如何实现的? # 在 Java 中,实现 CAS(Compare-And-Swap, 比较并交换)操作的一个关键类是Unsafe。\nUnsafe类位于sun.misc包下,是一个提供低级别、不安全操作的类。由于其强大的功能和潜在的危险性,它通常用于 JVM 内部或一些需要极高性能和底层访问的库中,而不推荐普通开发者在应用程序中使用。关于 Unsafe类的详细介绍,可以阅读这篇文章:📌 Java 魔法类 Unsafe 详解。\nsun.misc包下的Unsafe类提供了compareAndSwapObject、compareAndSwapInt、compareAndSwapLong方法来实现的对Object、int、long类型的 CAS 操作:\n/** * 以原子方式更新对象字段的值。 * * @param o 要操作的对象 * @param offset 对象字段的内存偏移量 * @param expected 期望的旧值 * @param x 要设置的新值 * @return 如果值被成功更新,则返回 true;否则返回 false */ boolean compareAndSwapObject(Object o, long offset, Object expected, Object x); /** * 以原子方式更新 int 类型的对象字段的值。 */ boolean compareAndSwapInt(Object o, long offset, int expected, int x); /** * 以原子方式更新 long 类型的对象字段的值。 */ boolean compareAndSwapLong(Object o, long offset, long expected, long x); Unsafe类中的 CAS 方法是native方法。native关键字表明这些方法是用本地代码(通常是 C 或 C++)实现的,而不是用 Java 实现的。这些方法直接调用底层的硬件指令来实现原子操作。也就是说,Java 语言并没有直接用 Java 实现 CAS,而是通过 C++ 内联汇编的形式实现的(通过 JNI 调用)。因此,CAS 的具体实现与操作系统以及 CPU 密切相关。\njava.util.concurrent.atomic 包提供了一些用于原子操作的类。这些类利用底层的原子指令,确保在多线程环境下的操作是线程安全的。\n关于这些 Atomic 原子类的介绍和使用,可以阅读这篇文章: Atomic 原子类总结。\nAtomicInteger是 Java 的原子类之一,主要用于对 int 类型的变量进行原子操作,它利用Unsafe类提供的低级别原子操作方法实现无锁的线程安全性。\n下面,我们通过解读AtomicInteger的核心源码(JDK1.8),来说明 Java 如何使用Unsafe类的方法来实现原子操作。\nAtomicInteger核心源码如下:\n// 获取 Unsafe 实例 private static final Unsafe unsafe = Unsafe.getUnsafe(); private static final long valueOffset; static { try { // 获取“value”字段在AtomicInteger类中的内存偏移量 valueOffset = unsafe.objectFieldOffset (AtomicInteger.class.getDeclaredField(\u0026#34;value\u0026#34;)); } catch (Exception ex) { throw new Error(ex); } } // 确保“value”字段的可见性 private volatile int value; // 如果当前值等于预期值,则原子地将值设置为newValue // 使用 Unsafe#compareAndSwapInt 方法进行CAS操作 public final boolean compareAndSet(int expect, int update) { return unsafe.compareAndSwapInt(this, valueOffset, expect, update); } // 原子地将当前值加 delta 并返回旧值 public final int getAndAdd(int delta) { return unsafe.getAndAddInt(this, valueOffset, delta); } // 原子地将当前值加 1 并返回加之前的值(旧值) // 使用 Unsafe#getAndAddInt 方法进行CAS操作。 public final int getAndIncrement() { return unsafe.getAndAddInt(this, valueOffset, 1); } // 原子地将当前值减 1 并返回减之前的值(旧值) public final int getAndDecrement() { return unsafe.getAndAddInt(this, valueOffset, -1); } Unsafe#getAndAddInt源码:\n// 原子地获取并增加整数值 public final int getAndAddInt(Object o, long offset, int delta) { int v; do { // 以 volatile 方式获取对象 o 在内存偏移量 offset 处的整数值 v = getIntVolatile(o, offset); } while (!compareAndSwapInt(o, offset, v, v + delta)); // 返回旧值 return v; } 可以看到,getAndAddInt 使用了 do-while 循环:在compareAndSwapInt操作失败时,会不断重试直到成功。也就是说,getAndAddInt方法会通过 compareAndSwapInt 方法来尝试更新 value 的值,如果更新失败(当前值在此期间被其他线程修改),它会重新获取当前值并再次尝试更新,直到操作成功。\n由于 CAS 操作可能会因为并发冲突而失败,因此通常会与while循环搭配使用,在失败后不断重试,直到操作成功。这就是 自旋锁机制 。\nCAS 算法存在哪些问题? # ABA 问题是 CAS 算法最常见的问题。\nABA 问题 # 如果一个变量 V 初次读取的时候是 A 值,并且在准备赋值的时候检查到它仍然是 A 值,那我们就能说明它的值没有被其他线程修改过了吗?很明显是不能的,因为在这段时间它的值可能被改为其他值,然后又改回 A,那 CAS 操作就会误认为它从来没有被修改过。这个问题被称为 CAS 操作的 \u0026ldquo;ABA\u0026quot;问题。\nABA 问题的解决思路是在变量前面追加上版本号或者时间戳。JDK 1.5 以后的 AtomicStampedReference 类就是用来解决 ABA 问题的,其中的 compareAndSet() 方法就是首先检查当前引用是否等于预期引用,并且当前标志是否等于预期标志,如果全部相等,则以原子方式将该引用和该标志的值设置为给定的更新值。\npublic boolean compareAndSet(V expectedReference, V newReference, int expectedStamp, int newStamp) { Pair\u0026lt;V\u0026gt; current = pair; return expectedReference == current.reference \u0026amp;\u0026amp; expectedStamp == current.stamp \u0026amp;\u0026amp; ((newReference == current.reference \u0026amp;\u0026amp; newStamp == current.stamp) || casPair(current, Pair.of(newReference, newStamp))); } 循环时间长开销大 # CAS 经常会用到自旋操作来进行重试,也就是不成功就一直循环执行直到成功。如果长时间不成功,会给 CPU 带来非常大的执行开销。\n如果 JVM 能够支持处理器提供的pause指令,那么自旋操作的效率将有所提升。pause指令有两个重要作用:\n延迟流水线执行指令:pause指令可以延迟指令的执行,从而减少 CPU 的资源消耗。具体的延迟时间取决于处理器的实现版本,在某些处理器上,延迟时间可能为零。 避免内存顺序冲突:在退出循环时,pause指令可以避免由于内存顺序冲突而导致的 CPU 流水线被清空,从而提高 CPU 的执行效率。 只能保证一个共享变量的原子操作 # CAS 操作仅能对单个共享变量有效。当需要操作多个共享变量时,CAS 就显得无能为力。不过,从 JDK 1.5 开始,Java 提供了AtomicReference类,这使得我们能够保证引用对象之间的原子性。通过将多个变量封装在一个对象中,我们可以使用AtomicReference来执行 CAS 操作。\n除了 AtomicReference 这种方式之外,还可以利用加锁来保证。\nsynchronized 关键字 # synchronized 是什么?有什么用? # synchronized 是 Java 中的一个关键字,翻译成中文是同步的意思,主要解决的是多个线程之间访问资源的同步性,可以保证被它修饰的方法或者代码块在任意时刻只能有一个线程执行。\n在 Java 早期版本中,synchronized 属于 重量级锁,效率低下。这是因为监视器锁(monitor)是依赖于底层的操作系统的 Mutex Lock 来实现的,Java 的线程是映射到操作系统的原生线程之上的。如果要挂起或者唤醒一个线程,都需要操作系统帮忙完成,而操作系统实现线程之间的切换时需要从用户态转换到内核态,这个状态之间的转换需要相对比较长的时间,时间成本相对较高。\n不过,在 Java 6 之后, synchronized 引入了大量的优化如自旋锁、适应性自旋锁、锁消除、锁粗化、偏向锁、轻量级锁等技术来减少锁操作的开销,这些优化让 synchronized 锁的效率提升了很多。因此, synchronized 还是可以在实际项目中使用的,像 JDK 源码、很多开源框架都大量使用了 synchronized 。\n关于偏向锁多补充一点:由于偏向锁增加了 JVM 的复杂性,同时也并没有为所有应用都带来性能提升。因此,在 JDK15 中,偏向锁被默认关闭(仍然可以使用 -XX:+UseBiasedLocking 启用偏向锁),在 JDK18 中,偏向锁已经被彻底废弃(无法通过命令行打开)。\n如何使用 synchronized? # synchronized 关键字的使用方式主要有下面 3 种:\n修饰实例方法 修饰静态方法 修饰代码块 1、修饰实例方法 (锁当前对象实例)\n给当前对象实例加锁,进入同步代码前要获得 当前对象实例的锁 。\nsynchronized void method() { //业务代码 } 2、修饰静态方法 (锁当前类)\n给当前类加锁,会作用于类的所有对象实例 ,进入同步代码前要获得 当前 class 的锁。\n这是因为静态成员不属于任何一个实例对象,归整个类所有,不依赖于类的特定实例,被类的所有实例共享。\nsynchronized static void method() { //业务代码 } 静态 synchronized 方法和非静态 synchronized 方法之间的调用互斥么?不互斥!如果一个线程 A 调用一个实例对象的非静态 synchronized 方法,而线程 B 需要调用这个实例对象所属类的静态 synchronized 方法,是允许的,不会发生互斥现象,因为访问静态 synchronized 方法占用的锁是当前类的锁,而访问非静态 synchronized 方法占用的锁是当前实例对象锁。\n3、修饰代码块 (锁指定对象/类)\n对括号里指定的对象/类加锁:\nsynchronized(object) 表示进入同步代码库前要获得 给定对象的锁。 synchronized(类.class) 表示进入同步代码前要获得 给定 Class 的锁 synchronized(this) { //业务代码 } 总结:\nsynchronized 关键字加到 static 静态方法和 synchronized(class) 代码块上都是是给 Class 类上锁; synchronized 关键字加到实例方法上是给对象实例上锁; 尽量不要使用 synchronized(String a) 因为 JVM 中,字符串常量池具有缓存功能。 构造方法可以用 synchronized 修饰么? # 构造方法不能使用 synchronized 关键字修饰。不过,可以在构造方法内部使用 synchronized 代码块。\n另外,构造方法本身是线程安全的,但如果在构造方法中涉及到共享资源的操作,就需要采取适当的同步措施来保证整个构造过程的线程安全。\n⭐️synchronized 底层原理了解吗? # synchronized 关键字底层原理属于 JVM 层面的东西。\nsynchronized 同步语句块的情况 # public class SynchronizedDemo { public void method() { synchronized (this) { System.out.println(\u0026#34;synchronized 代码块\u0026#34;); } } } 通过 JDK 自带的 javap 命令查看 SynchronizedDemo 类的相关字节码信息:首先切换到类的对应目录执行 javac SynchronizedDemo.java 命令生成编译后的 .class 文件,然后执行javap -c -s -v -l SynchronizedDemo.class。\n从上面我们可以看出:synchronized 同步语句块的实现使用的是 monitorenter 和 monitorexit 指令,其中 monitorenter 指令指向同步代码块的开始位置,monitorexit 指令则指明同步代码块的结束位置。\n上面的字节码中包含一个 monitorenter 指令以及两个 monitorexit 指令,这是为了保证锁在同步代码块代码正常执行以及出现异常的这两种情况下都能被正确释放。\n当执行 monitorenter 指令时,线程试图获取锁也就是获取 对象监视器 monitor 的持有权。\n在 Java 虚拟机(HotSpot)中,Monitor 是基于 C++实现的,由 ObjectMonitor实现的。每个对象中都内置了一个 ObjectMonitor对象。\n另外,wait/notify等方法也依赖于monitor对象,这就是为什么只有在同步的块或者方法中才能调用wait/notify等方法,否则会抛出java.lang.IllegalMonitorStateException的异常的原因。\n在执行monitorenter时,会尝试获取对象的锁,如果锁的计数器为 0 则表示锁可以被获取,获取后将锁计数器设为 1 也就是加 1。\n对象锁的的拥有者线程才可以执行 monitorexit 指令来释放锁。在执行 monitorexit 指令后,将锁计数器设为 0,表明锁被释放,其他线程可以尝试获取锁。\n如果获取对象锁失败,那当前线程就要阻塞等待,直到锁被另外一个线程释放为止。\nsynchronized 修饰方法的的情况 # public class SynchronizedDemo2 { public synchronized void method() { System.out.println(\u0026#34;synchronized 方法\u0026#34;); } } synchronized 修饰的方法并没有 monitorenter 指令和 monitorexit 指令,取而代之的是 ACC_SYNCHRONIZED 标识,该标识指明了该方法是一个同步方法。JVM 通过该 ACC_SYNCHRONIZED 访问标志来辨别一个方法是否声明为同步方法,从而执行相应的同步调用。\n如果是实例方法,JVM 会尝试获取实例对象的锁。如果是静态方法,JVM 会尝试获取当前 class 的锁。\n总结 # synchronized 同步语句块的实现使用的是 monitorenter 和 monitorexit 指令,其中 monitorenter 指令指向同步代码块的开始位置,monitorexit 指令则指明同步代码块的结束位置。\nsynchronized 修饰的方法并没有 monitorenter 指令和 monitorexit 指令,取而代之的是 ACC_SYNCHRONIZED 标识,该标识指明了该方法是一个同步方法。\n不过,两者的本质都是对对象监视器 monitor 的获取。\n相关推荐: Java 锁与线程的那些事 - 有赞技术团队 。\n🧗🏻 进阶一下:学有余力的小伙伴可以抽时间详细研究一下对象监视器 monitor。\nJDK1.6 之后的 synchronized 底层做了哪些优化?锁升级原理了解吗? # 在 Java 6 之后, synchronized 引入了大量的优化如自旋锁、适应性自旋锁、锁消除、锁粗化、偏向锁、轻量级锁等技术来减少锁操作的开销,这些优化让 synchronized 锁的效率提升了很多(JDK18 中,偏向锁已经被彻底废弃,前面已经提到过了)。\n锁主要存在四种状态,依次是:无锁状态、偏向锁状态、轻量级锁状态、重量级锁状态,他们会随着竞争的激烈而逐渐升级。注意锁可以升级不可降级,这种策略是为了提高获得锁和释放锁的效率。\nsynchronized 锁升级是一个比较复杂的过程,面试也很少问到,如果你想要详细了解的话,可以看看这篇文章: 浅析 synchronized 锁升级的原理与实现。\nsynchronized 的偏向锁为什么被废弃了? # Open JDK 官方声明: JEP 374: Deprecate and Disable Biased Locking\n在 JDK15 中,偏向锁被默认关闭(仍然可以使用 -XX:+UseBiasedLocking 启用偏向锁),在 JDK18 中,偏向锁已经被彻底废弃(无法通过命令行打开)。\n在官方声明中,主要原因有两个方面:\n性能收益不明显: 偏向锁是 HotSpot 虚拟机的一项优化技术,可以提升单线程对同步代码块的访问性能。\n受益于偏向锁的应用程序通常使用了早期的 Java 集合 API,例如 HashTable、Vector,在这些集合类中通过 synchronized 来控制同步,这样在单线程频繁访问时,通过偏向锁会减少同步开销。\n随着 JDK 的发展,出现了 ConcurrentHashMap 高性能的集合类,在集合类内部进行了许多性能优化,此时偏向锁带来的性能收益就不明显了。\n偏向锁仅仅在单线程访问同步代码块的场景中可以获得性能收益。\n如果存在多线程竞争,就需要 撤销偏向锁 ,这个操作的性能开销是比较昂贵的。偏向锁的撤销需要等待进入到全局安全点(safe point),该状态下所有线程都是暂停的,此时去检查线程状态并进行偏向锁的撤销。\nJVM 内部代码维护成本太高: 偏向锁将许多复杂代码引入到同步子系统,并且对其他的 HotSpot 组件也具有侵入性。这种复杂性为理解代码、系统重构带来了困难,因此, OpenJDK 官方希望禁用、废弃并删除偏向锁。\n⭐️synchronized 和 volatile 有什么区别? # synchronized 关键字和 volatile 关键字是两个互补的存在,而不是对立的存在!\nvolatile 关键字是线程同步的轻量级实现,所以 volatile性能肯定比synchronized关键字要好 。但是 volatile 关键字只能用于变量而 synchronized 关键字可以修饰方法以及代码块 。 volatile 关键字能保证数据的可见性,但不能保证数据的原子性。synchronized 关键字两者都能保证。 volatile关键字主要用于解决变量在多个线程之间的可见性,而 synchronized 关键字解决的是多个线程之间访问资源的同步性。 ReentrantLock # ReentrantLock 是什么? # ReentrantLock 实现了 Lock 接口,是一个可重入且独占式的锁,和 synchronized 关键字类似。不过,ReentrantLock 更灵活、更强大,增加了轮询、超时、中断、公平锁和非公平锁等高级功能。\npublic class ReentrantLock implements Lock, java.io.Serializable {} ReentrantLock 里面有一个内部类 Sync,Sync 继承 AQS(AbstractQueuedSynchronizer),添加锁和释放锁的大部分操作实际上都是在 Sync 中实现的。Sync 有公平锁 FairSync 和非公平锁 NonfairSync 两个子类。\nReentrantLock 默认使用非公平锁,也可以通过构造器来显式的指定使用公平锁。\n// 传入一个 boolean 值,true 时为公平锁,false 时为非公平锁 public ReentrantLock(boolean fair) { sync = fair ? new FairSync() : new NonfairSync(); } 从上面的内容可以看出, ReentrantLock 的底层就是由 AQS 来实现的。关于 AQS 的相关内容推荐阅读 AQS 详解 这篇文章。\n公平锁和非公平锁有什么区别? # 公平锁 : 锁被释放之后,先申请的线程先得到锁。性能较差一些,因为公平锁为了保证时间上的绝对顺序,上下文切换更频繁。 非公平锁:锁被释放之后,后申请的线程可能会先获取到锁,是随机或者按照其他优先级排序的。性能更好,但可能会导致某些线程永远无法获取到锁。 ⭐️synchronized 和 ReentrantLock 有什么区别? # 两者都是可重入锁 # 可重入锁 也叫递归锁,指的是线程可以再次获取自己的内部锁。比如一个线程获得了某个对象的锁,此时这个对象锁还没有释放,当其再次想要获取这个对象的锁的时候还是可以获取的,如果是不可重入锁的话,就会造成死锁。\nJDK 提供的所有现成的 Lock 实现类,包括 synchronized 关键字锁都是可重入的。\n在下面的代码中,method1() 和 method2()都被 synchronized 关键字修饰,method1()调用了method2()。\npublic class SynchronizedDemo { public synchronized void method1() { System.out.println(\u0026#34;方法1\u0026#34;); method2(); } public synchronized void method2() { System.out.println(\u0026#34;方法2\u0026#34;); } } 由于 synchronized锁是可重入的,同一个线程在调用method1() 时可以直接获得当前对象的锁,执行 method2() 的时候可以再次获取这个对象的锁,不会产生死锁问题。假如synchronized是不可重入锁的话,由于该对象的锁已被当前线程所持有且无法释放,这就导致线程在执行 method2()时获取锁失败,会出现死锁问题。\nsynchronized 依赖于 JVM 而 ReentrantLock 依赖于 API # synchronized 是依赖于 JVM 实现的,前面我们也讲到了 虚拟机团队在 JDK1.6 为 synchronized 关键字进行了很多优化,但是这些优化都是在虚拟机层面实现的,并没有直接暴露给我们。\nReentrantLock 是 JDK 层面实现的(也就是 API 层面,需要 lock() 和 unlock() 方法配合 try/finally 语句块来完成),所以我们可以通过查看它的源代码,来看它是如何实现的。\nReentrantLock 比 synchronized 增加了一些高级功能 # 相比synchronized,ReentrantLock增加了一些高级功能。主要来说主要有三点:\n等待可中断 : ReentrantLock提供了一种能够中断等待锁的线程的机制,通过 lock.lockInterruptibly() 来实现这个机制。也就是说当前线程在等待获取锁的过程中,如果其他线程中断当前线程「 interrupt() 」,当前线程就会抛出 InterruptedException 异常,可以捕捉该异常进行相应处理。 可实现公平锁 : ReentrantLock可以指定是公平锁还是非公平锁。而synchronized只能是非公平锁。所谓的公平锁就是先等待的线程先获得锁。ReentrantLock默认情况是非公平的,可以通过 ReentrantLock类的ReentrantLock(boolean fair)构造方法来指定是否是公平的。 可实现选择性通知(锁可以绑定多个条件): synchronized关键字与wait()和notify()/notifyAll()方法相结合可以实现等待/通知机制。ReentrantLock类当然也可以实现,但是需要借助于Condition接口与newCondition()方法。 支持超时 :ReentrantLock 提供了 tryLock(timeout) 的方法,可以指定等待获取锁的最长等待时间,如果超过了等待时间,就会获取锁失败,不会一直等待。 如果你想使用上述功能,那么选择 ReentrantLock 是一个不错的选择。\n关于 Condition接口的补充:\nCondition是 JDK1.5 之后才有的,它具有很好的灵活性,比如可以实现多路通知功能也就是在一个Lock对象中可以创建多个Condition实例(即对象监视器),线程对象可以注册在指定的Condition中,从而可以有选择性的进行线程通知,在调度线程上更加灵活。 在使用notify()/notifyAll()方法进行通知时,被通知的线程是由 JVM 选择的,用ReentrantLock类结合Condition实例可以实现“选择性通知” ,这个功能非常重要,而且是 Condition 接口默认提供的。而synchronized关键字就相当于整个 Lock 对象中只有一个Condition实例,所有的线程都注册在它一个身上。如果执行notifyAll()方法的话就会通知所有处于等待状态的线程,这样会造成很大的效率问题。而Condition实例的signalAll()方法,只会唤醒注册在该Condition实例中的所有等待线程。\n关于 等待可中断 的补充:\nlockInterruptibly() 会让获取锁的线程在阻塞等待的过程中可以响应中断,即当前线程在获取锁的时候,发现锁被其他线程持有,就会阻塞等待。\n在阻塞等待的过程中,如果其他线程中断当前线程 interrupt() ,就会抛出 InterruptedException 异常,可以捕获该异常,做一些处理操作。\n为了更好理解这个方法,借用 Stack Overflow 上的一个案例,可以更好地理解 lockInterruptibly() 可以响应中断:\npublic class MyRentrantlock { Thread t = new Thread() { @Override public void run() { ReentrantLock r = new ReentrantLock(); // 1.1、第一次尝试获取锁,可以获取成功 r.lock(); // 1.2、此时锁的重入次数为 1 System.out.println(\u0026#34;lock() : lock count :\u0026#34; + r.getHoldCount()); // 2、中断当前线程,通过 Thread.currentThread().isInterrupted() 可以看到当前线程的中断状态为 true interrupt(); System.out.println(\u0026#34;Current thread is intrupted\u0026#34;); // 3.1、尝试获取锁,可以成功获取 r.tryLock(); // 3.2、此时锁的重入次数为 2 System.out.println(\u0026#34;tryLock() on intrupted thread lock count :\u0026#34; + r.getHoldCount()); try { // 4、打印线程的中断状态为 true,那么调用 lockInterruptibly() 方法就会抛出 InterruptedException 异常 System.out.println(\u0026#34;Current Thread isInterrupted:\u0026#34; + Thread.currentThread().isInterrupted()); r.lockInterruptibly(); System.out.println(\u0026#34;lockInterruptibly() --NOt executable statement\u0026#34; + r.getHoldCount()); } catch (InterruptedException e) { r.lock(); System.out.println(\u0026#34;Error\u0026#34;); } finally { r.unlock(); } // 5、打印锁的重入次数,可以发现 lockInterruptibly() 方法并没有成功获取到锁 System.out.println(\u0026#34;lockInterruptibly() not able to Acqurie lock: lock count :\u0026#34; + r.getHoldCount()); r.unlock(); System.out.println(\u0026#34;lock count :\u0026#34; + r.getHoldCount()); r.unlock(); System.out.println(\u0026#34;lock count :\u0026#34; + r.getHoldCount()); } }; public static void main(String str[]) { MyRentrantlock m = new MyRentrantlock(); m.t.start(); } } 输出:\nlock() : lock count :1 Current thread is intrupted tryLock() on intrupted thread lock count :2 Current Thread isInterrupted:true Error lockInterruptibly() not able to Acqurie lock: lock count :2 lock count :1 lock count :0 关于 支持超时 的补充:\n为什么需要 tryLock(timeout) 这个功能呢?\ntryLock(timeout) 方法尝试在指定的超时时间内获取锁。如果成功获取锁,则返回 true;如果在锁可用之前超时,则返回 false。此功能在以下几种场景中非常有用:\n防止死锁: 在复杂的锁场景中,tryLock(timeout) 可以通过允许线程在合理的时间内放弃并重试来帮助防止死锁。 提高响应速度: 防止线程无限期阻塞。 处理时间敏感的操作: 对于具有严格时间限制的操作,tryLock(timeout) 允许线程在无法及时获取锁时继续执行替代操作。 可中断锁和不可中断锁有什么区别? # 可中断锁:获取锁的过程中可以被中断,不需要一直等到获取锁之后 才能进行其他逻辑处理。ReentrantLock 就属于是可中断锁。 不可中断锁:一旦线程申请了锁,就只能等到拿到锁以后才能进行其他的逻辑处理。 synchronized 就属于是不可中断锁。 ReentrantReadWriteLock # ReentrantReadWriteLock 在实际项目中使用的并不多,面试中也问的比较少,简单了解即可。JDK 1.8 引入了性能更好的读写锁 StampedLock 。\nReentrantReadWriteLock 是什么? # ReentrantReadWriteLock 实现了 ReadWriteLock ,是一个可重入的读写锁,既可以保证多个线程同时读的效率,同时又可以保证有写入操作时的线程安全。\npublic class ReentrantReadWriteLock implements ReadWriteLock, java.io.Serializable{ } public interface ReadWriteLock { Lock readLock(); Lock writeLock(); } 一般锁进行并发控制的规则:读读互斥、读写互斥、写写互斥。 读写锁进行并发控制的规则:读读不互斥、读写互斥、写写互斥(只有读读不互斥)。 ReentrantReadWriteLock 其实是两把锁,一把是 WriteLock (写锁),一把是 ReadLock(读锁) 。读锁是共享锁,写锁是独占锁。读锁可以被同时读,可以同时被多个线程持有,而写锁最多只能同时被一个线程持有。\n和 ReentrantLock 一样,ReentrantReadWriteLock 底层也是基于 AQS 实现的。\nReentrantReadWriteLock 也支持公平锁和非公平锁,默认使用非公平锁,可以通过构造器来显示的指定。\n// 传入一个 boolean 值,true 时为公平锁,false 时为非公平锁 public ReentrantReadWriteLock(boolean fair) { sync = fair ? new FairSync() : new NonfairSync(); readerLock = new ReadLock(this); writerLock = new WriteLock(this); } ReentrantReadWriteLock 适合什么场景? # 由于 ReentrantReadWriteLock 既可以保证多个线程同时读的效率,同时又可以保证有写入操作时的线程安全。因此,在读多写少的情况下,使用 ReentrantReadWriteLock 能够明显提升系统性能。\n共享锁和独占锁有什么区别? # 共享锁:一把锁可以被多个线程同时获得。 独占锁:一把锁只能被一个线程获得。 线程持有读锁还能获取写锁吗? # 在线程持有读锁的情况下,该线程不能取得写锁(因为获取写锁的时候,如果发现当前的读锁被占用,就马上获取失败,不管读锁是不是被当前线程持有)。 在线程持有写锁的情况下,该线程可以继续获取读锁(获取读锁时如果发现写锁被占用,只有写锁没有被当前线程占用的情况才会获取失败)。 读写锁的源码分析,推荐阅读 聊聊 Java 的几把 JVM 级锁 - 阿里巴巴中间件 这篇文章,写的很不错。\n读锁为什么不能升级为写锁? # 写锁可以降级为读锁,但是读锁却不能升级为写锁。这是因为读锁升级为写锁会引起线程的争夺,毕竟写锁属于是独占锁,这样的话,会影响性能。\n另外,还可能会有死锁问题发生。举个例子:假设两个线程的读锁都想升级写锁,则需要对方都释放自己锁,而双方都不释放,就会产生死锁。\nStampedLock # StampedLock 面试中问的比较少,不是很重要,简单了解即可。\nStampedLock 是什么? # StampedLock 是 JDK 1.8 引入的性能更好的读写锁,不可重入且不支持条件变量 Condition。\n不同于一般的 Lock 类,StampedLock 并不是直接实现 Lock或 ReadWriteLock接口,而是基于 CLH 锁 独立实现的(AQS 也是基于这玩意)。\npublic class StampedLock implements java.io.Serializable { } StampedLock 提供了三种模式的读写控制模式:读锁、写锁和乐观读。\n写锁:独占锁,一把锁只能被一个线程获得。当一个线程获取写锁后,其他请求读锁和写锁的线程必须等待。类似于 ReentrantReadWriteLock 的写锁,不过这里的写锁是不可重入的。 读锁 (悲观读):共享锁,没有线程获取写锁的情况下,多个线程可以同时持有读锁。如果己经有线程持有写锁,则其他线程请求获取该读锁会被阻塞。类似于 ReentrantReadWriteLock 的读锁,不过这里的读锁是不可重入的。 乐观读:允许多个线程获取乐观读以及读锁。同时允许一个写线程获取写锁。 另外,StampedLock 还支持这三种锁在一定条件下进行相互转换 。\nlong tryConvertToWriteLock(long stamp){} long tryConvertToReadLock(long stamp){} long tryConvertToOptimisticRead(long stamp){} StampedLock 在获取锁的时候会返回一个 long 型的数据戳,该数据戳用于稍后的锁释放参数,如果返回的数据戳为 0 则表示锁获取失败。当前线程持有了锁再次获取锁还是会返回一个新的数据戳,这也是StampedLock不可重入的原因。\n// 写锁 public long writeLock() { long s, next; // bypass acquireWrite in fully unlocked case only return ((((s = state) \u0026amp; ABITS) == 0L \u0026amp;\u0026amp; U.compareAndSwapLong(this, STATE, s, next = s + WBIT)) ? next : acquireWrite(false, 0L)); } // 读锁 public long readLock() { long s = state, next; // bypass acquireRead on common uncontended case return ((whead == wtail \u0026amp;\u0026amp; (s \u0026amp; ABITS) \u0026lt; RFULL \u0026amp;\u0026amp; U.compareAndSwapLong(this, STATE, s, next = s + RUNIT)) ? next : acquireRead(false, 0L)); } // 乐观读 public long tryOptimisticRead() { long s; return (((s = state) \u0026amp; WBIT) == 0L) ? (s \u0026amp; SBITS) : 0L; } StampedLock 的性能为什么更好? # 相比于传统读写锁多出来的乐观读是StampedLock比 ReadWriteLock 性能更好的关键原因。StampedLock 的乐观读允许一个写线程获取写锁,所以不会导致所有写线程阻塞,也就是当读多写少的时候,写线程有机会获取写锁,减少了线程饥饿的问题,吞吐量大大提高。\nStampedLock 适合什么场景? # 和 ReentrantReadWriteLock 一样,StampedLock 同样适合读多写少的业务场景,可以作为 ReentrantReadWriteLock的替代品,性能更好。\n不过,需要注意的是StampedLock不可重入,不支持条件变量 Condition,对中断操作支持也不友好(使用不当容易导致 CPU 飙升)。如果你需要用到 ReentrantLock 的一些高级性能,就不太建议使用 StampedLock 了。\n另外,StampedLock 性能虽好,但使用起来相对比较麻烦,一旦使用不当,就会出现生产问题。强烈建议你在使用StampedLock 之前,看看 StampedLock 官方文档中的案例。\nStampedLock 的底层原理了解吗? # StampedLock 不是直接实现 Lock或 ReadWriteLock接口,而是基于 CLH 锁 实现的(AQS 也是基于这玩意),CLH 锁是对自旋锁的一种改良,是一种隐式的链表队列。StampedLock 通过 CLH 队列进行线程的管理,通过同步状态值 state 来表示锁的状态和类型。\nStampedLock 的原理和 AQS 原理比较类似,这里就不详细介绍了,感兴趣的可以看看下面这两篇文章:\nAQS 详解 StampedLock 底层原理分析 如果你只是准备面试的话,建议多花点精力搞懂 AQS 原理即可,StampedLock 底层原理在面试中遇到的概率非常小。\nAtomic 原子类 # Atomic 原子类部分的内容我单独写了一篇文章来总结: Atomic 原子类总结 。\n参考 # 《深入理解 Java 虚拟机》 《实战 Java 高并发程序设计》 Guide to the Volatile Keyword in Java - Baeldung: https://www.baeldung.com/java-volatile 不可不说的 Java“锁”事 - 美团技术团队: https://tech.meituan.com/2018/11/15/java-lock.html 在 ReadWriteLock 类中读锁为什么不能升级为写锁?: https://cloud.tencent.com/developer/article/1176230 高性能解决线程饥饿的利器 StampedLock: https://mp.weixin.qq.com/s/2Acujjr4BHIhlFsCLGwYSg 理解 Java 中的 ThreadLocal - 技术小黑屋: https://droidyue.com/blog/2016/03/13/learning-threadlocal-in-java/ ThreadLocal (Java Platform SE 8 ) - Oracle Help Center: https://docs.oracle.com/javase/8/docs/api/java/lang/ThreadLocal.html "},{"id":516,"href":"/zh/docs/technology/Interview/java/basis/java-basic-questions-01/","title":"Java基础常见面试题总结(上)","section":"Basis","content":" 基础概念与常识 # Java 语言有哪些特点? # 简单易学(语法简单,上手容易); 面向对象(封装,继承,多态); 平台无关性( Java 虚拟机实现平台无关性); 支持多线程( C++ 语言没有内置的多线程机制,因此必须调用操作系统的多线程功能来进行多线程程序设计,而 Java 语言却提供了多线程支持); 可靠性(具备异常处理和自动内存管理机制); 安全性(Java 语言本身的设计就提供了多重安全防护机制如访问权限修饰符、限制程序直接访问操作系统资源); 高效性(通过 Just In Time 编译器等技术的优化,Java 语言的运行效率还是非常不错的); 支持网络编程并且很方便; 编译与解释并存; …… 🐛 修正(参见: issue#544):C11 开始(2011 年的时候),C就引入了多线程库,在 windows、linux、macos 都可以使用std::thread和std::async来创建线程。参考链接: http://www.cplusplus.com/reference/thread/thread/?kw=thread\n🌈 拓展一下:\n“Write Once, Run Anywhere(一次编写,随处运行)”这句宣传口号,真心经典,流传了好多年!以至于,直到今天,依然有很多人觉得跨平台是 Java 语言最大的优势。实际上,跨平台已经不是 Java 最大的卖点了,各种 JDK 新特性也不是。目前市面上虚拟化技术已经非常成熟,比如你通过 Docker 就很容易实现跨平台了。在我看来,Java 强大的生态才是!\nJava SE vs Java EE # Java SE(Java Platform,Standard Edition): Java 平台标准版,Java 编程语言的基础,它包含了支持 Java 应用程序开发和运行的核心类库以及虚拟机等核心组件。Java SE 可以用于构建桌面应用程序或简单的服务器应用程序。 Java EE(Java Platform, Enterprise Edition ):Java 平台企业版,建立在 Java SE 的基础上,包含了支持企业级应用程序开发和部署的标准和规范(比如 Servlet、JSP、EJB、JDBC、JPA、JTA、JavaMail、JMS)。 Java EE 可以用于构建分布式、可移植、健壮、可伸缩和安全的服务端 Java 应用程序,例如 Web 应用程序。 简单来说,Java SE 是 Java 的基础版本,Java EE 是 Java 的高级版本。Java SE 更适合开发桌面应用程序或简单的服务器应用程序,Java EE 更适合开发复杂的企业级应用程序或 Web 应用程序。\n除了 Java SE 和 Java EE,还有一个 Java ME(Java Platform,Micro Edition)。Java ME 是 Java 的微型版本,主要用于开发嵌入式消费电子设备的应用程序,例如手机、PDA、机顶盒、冰箱、空调等。Java ME 无需重点关注,知道有这个东西就好了,现在已经用不上了。\nJVM vs JDK vs JRE # JVM # Java 虚拟机(Java Virtual Machine, JVM)是运行 Java 字节码的虚拟机。JVM 有针对不同系统的特定实现(Windows,Linux,macOS),目的是使用相同的字节码,它们都会给出相同的结果。字节码和不同系统的 JVM 实现是 Java 语言“一次编译,随处可以运行”的关键所在。\n如下图所示,不同编程语言(Java、Groovy、Kotlin、JRuby、Clojure \u0026hellip;)通过各自的编译器编译成 .class 文件,并最终通过 JVM 在不同平台(Windows、Mac、Linux)上运行。\nJVM 并不是只有一种!只要满足 JVM 规范,每个公司、组织或者个人都可以开发自己的专属 JVM。 也就是说我们平时接触到的 HotSpot VM 仅仅是是 JVM 规范的一种实现而已。\n除了我们平时最常用的 HotSpot VM 外,还有 J9 VM、Zing VM、JRockit VM 等 JVM 。维基百科上就有常见 JVM 的对比: Comparison of Java virtual machines ,感兴趣的可以去看看。并且,你可以在 Java SE Specifications 上找到各个版本的 JDK 对应的 JVM 规范。\nJDK 和 JRE # JDK(Java Development Kit)是一个功能齐全的 Java 开发工具包,供开发者使用,用于创建和编译 Java 程序。它包含了 JRE(Java Runtime Environment),以及编译器 javac 和其他工具,如 javadoc(文档生成器)、jdb(调试器)、jconsole(监控工具)、javap(反编译工具)等。\nJRE 是运行已编译 Java 程序所需的环境,主要包含以下两个部分:\nJVM : 也就是我们上面提到的 Java 虚拟机。 Java 基础类库(Class Library):一组标准的类库,提供常用的功能和 API(如 I/O 操作、网络通信、数据结构等)。 简单来说,JRE 只包含运行 Java 程序所需的环境和类库,而 JDK 不仅包含 JRE,还包括用于开发和调试 Java 程序的工具。\n如果需要编写、编译 Java 程序或使用 Java API 文档,就需要安装 JDK。某些需要 Java 特性的应用程序(如 JSP 转换为 Servlet 或使用反射)也可能需要 JDK 来编译和运行 Java 代码。因此,即使不进行 Java 开发工作,有时也可能需要安装 JDK。\n下图清晰展示了 JDK、JRE 和 JVM 的关系。\n不过,从 JDK 9 开始,就不需要区分 JDK 和 JRE 的关系了,取而代之的是模块系统(JDK 被重新组织成 94 个模块)+ jlink 工具 (随 Java 9 一起发布的新命令行工具,用于生成自定义 Java 运行时映像,该映像仅包含给定应用程序所需的模块) 。并且,从 JDK 11 开始,Oracle 不再提供单独的 JRE 下载。\n在 Java 9 新特性概览这篇文章中,我在介绍模块化系统的时候提到:\n在引入了模块系统之后,JDK 被重新组织成 94 个模块。Java 应用可以通过新增的 jlink 工具,创建出只包含所依赖的 JDK 模块的自定义运行时镜像。这样可以极大的减少 Java 运行时环境的大小。\n也就是说,可以用 jlink 根据自己的需求,创建一个更小的 runtime(运行时),而不是不管什么应用,都是同样的 JRE。\n定制的、模块化的 Java 运行时映像有助于简化 Java 应用的部署和节省内存并增强安全性和可维护性。这对于满足现代应用程序架构的需求,如虚拟化、容器化、微服务和云原生开发,是非常重要的。\n什么是字节码?采用字节码的好处是什么? # 在 Java 中,JVM 可以理解的代码就叫做字节码(即扩展名为 .class 的文件),它不面向任何特定的处理器,只面向虚拟机。Java 语言通过字节码的方式,在一定程度上解决了传统解释型语言执行效率低的问题,同时又保留了解释型语言可移植的特点。所以, Java 程序运行时相对来说还是高效的(不过,和 C、 C++,Rust,Go 等语言还是有一定差距的),而且,由于字节码并不针对一种特定的机器,因此,Java 程序无须重新编译便可在多种不同操作系统的计算机上运行。\nJava 程序从源代码到运行的过程如下图所示:\n我们需要格外注意的是 .class-\u0026gt;机器码 这一步。在这一步 JVM 类加载器首先加载字节码文件,然后通过解释器逐行解释执行,这种方式的执行速度会相对比较慢。而且,有些方法和代码块是经常需要被调用的(也就是所谓的热点代码),所以后面引进了 JIT(Just in Time Compilation) 编译器,而 JIT 属于运行时编译。当 JIT 编译器完成第一次编译后,其会将字节码对应的机器码保存下来,下次可以直接使用。而我们知道,机器码的运行效率肯定是高于 Java 解释器的。这也解释了我们为什么经常会说 Java 是编译与解释共存的语言 。\n🌈 拓展阅读:\n基本功 | Java 即时编译器原理解析及实践 - 美团技术团队 基于静态编译构建微服务应用 - 阿里巴巴中间件 HotSpot 采用了惰性评估(Lazy Evaluation)的做法,根据二八定律,消耗大部分系统资源的只有那一小部分的代码(热点代码),而这也就是 JIT 所需要编译的部分。JVM 会根据代码每次被执行的情况收集信息并相应地做出一些优化,因此执行的次数越多,它的速度就越快。\nJDK、JRE、JVM、JIT 这四者的关系如下图所示。\n下面这张图是 JVM 的大致结构模型。\n为什么说 Java 语言“编译与解释并存”? # 其实这个问题我们讲字节码的时候已经提到过,因为比较重要,所以我们这里再提一下。\n我们可以将高级编程语言按照程序的执行方式分为两种:\n编译型: 编译型语言 会通过 编译器将源代码一次性翻译成可被该平台执行的机器码。一般情况下,编译语言的执行速度比较快,开发效率比较低。常见的编译性语言有 C、C++、Go、Rust 等等。 解释型: 解释型语言会通过 解释器一句一句的将代码解释(interpret)为机器代码后再执行。解释型语言开发效率比较快,执行速度比较慢。常见的解释性语言有 Python、JavaScript、PHP 等等。 根据维基百科介绍:\n为了改善解释语言的效率而发展出的 即时编译技术,已经缩小了这两种语言间的差距。这种技术混合了编译语言与解释型语言的优点,它像编译语言一样,先把程序源代码编译成 字节码。到执行期时,再将字节码直译,之后执行。 Java与 LLVM是这种技术的代表产物。\n相关阅读: 基本功 | Java 即时编译器原理解析及实践\n为什么说 Java 语言“编译与解释并存”?\n这是因为 Java 语言既具有编译型语言的特征,也具有解释型语言的特征。因为 Java 程序要经过先编译,后解释两个步骤,由 Java 编写的程序需要先经过编译步骤,生成字节码(.class 文件),这种字节码必须由 Java 解释器来解释执行。\nAOT 有什么优点?为什么不全部使用 AOT 呢? # JDK 9 引入了一种新的编译模式 AOT(Ahead of Time Compilation) 。和 JIT 不同的是,这种编译模式会在程序被执行前就将其编译成机器码,属于静态编译(C、 C++,Rust,Go 等语言就是静态编译)。AOT 避免了 JIT 预热等各方面的开销,可以提高 Java 程序的启动速度,避免预热时间长。并且,AOT 还能减少内存占用和增强 Java 程序的安全性(AOT 编译后的代码不容易被反编译和修改),特别适合云原生场景。\nJIT 与 AOT 两者的关键指标对比:\n可以看出,AOT 的主要优势在于启动时间、内存占用和打包体积。JIT 的主要优势在于具备更高的极限处理能力,可以降低请求的最大延迟。\n提到 AOT 就不得不提 GraalVM 了!GraalVM 是一种高性能的 JDK(完整的 JDK 发行版本),它可以运行 Java 和其他 JVM 语言,以及 JavaScript、Python 等非 JVM 语言。 GraalVM 不仅能提供 AOT 编译,还能提供 JIT 编译。感兴趣的同学,可以去看看 GraalVM 的官方文档: https://www.graalvm.org/latest/docs/。如果觉得官方文档看着比较难理解的话,也可以找一些文章来看看,比如:\n基于静态编译构建微服务应用 走向 Native 化:Spring\u0026amp;Dubbo AOT 技术示例与原理讲解 既然 AOT 这么多优点,那为什么不全部使用这种编译方式呢?\n我们前面也对比过 JIT 与 AOT,两者各有优点,只能说 AOT 更适合当下的云原生场景,对微服务架构的支持也比较友好。除此之外,AOT 编译无法支持 Java 的一些动态特性,如反射、动态代理、动态加载、JNI(Java Native Interface)等。然而,很多框架和库(如 Spring、CGLIB)都用到了这些特性。如果只使用 AOT 编译,那就没办法使用这些框架和库了,或者说需要针对性地去做适配和优化。举个例子,CGLIB 动态代理使用的是 ASM 技术,而这种技术大致原理是运行时直接在内存中生成并加载修改后的字节码文件也就是 .class 文件,如果全部使用 AOT 提前编译,也就不能使用 ASM 技术了。为了支持类似的动态特性,所以选择使用 JIT 即时编译器。\nOracle JDK vs OpenJDK # 可能在看这个问题之前很多人和我一样并没有接触和使用过 OpenJDK 。那么 Oracle JDK 和 OpenJDK 之间是否存在重大差异?下面我通过收集到的一些资料,为你解答这个被很多人忽视的问题。\n首先,2006 年 SUN 公司将 Java 开源,也就有了 OpenJDK。2009 年 Oracle 收购了 Sun 公司,于是自己在 OpenJDK 的基础上搞了一个 Oracle JDK。Oracle JDK 是不开源的,并且刚开始的几个版本(Java8 ~ Java11)还会相比于 OpenJDK 添加一些特有的功能和工具。\n其次,对于 Java 7 而言,OpenJDK 和 Oracle JDK 是十分接近的。 Oracle JDK 是基于 OpenJDK 7 构建的,只添加了一些小功能,由 Oracle 工程师参与维护。\n下面这段话摘自 Oracle 官方在 2012 年发表的一个博客:\n问:OpenJDK 存储库中的源代码与用于构建 Oracle JDK 的代码之间有什么区别?\n答:非常接近 - 我们的 Oracle JDK 版本构建过程基于 OpenJDK 7 构建,只添加了几个部分,例如部署代码,其中包括 Oracle 的 Java 插件和 Java WebStart 的实现,以及一些闭源的第三方组件,如图形光栅化器,一些开源的第三方组件,如 Rhino,以及一些零碎的东西,如附加文档或第三方字体。展望未来,我们的目的是开源 Oracle JDK 的所有部分,除了我们考虑商业功能的部分。\n最后,简单总结一下 Oracle JDK 和 OpenJDK 的区别:\n是否开源:OpenJDK 是一个参考模型并且是完全开源的,而 Oracle JDK 是基于 OpenJDK 实现的,并不是完全开源的(个人观点:众所周知,JDK 原来是 SUN 公司开发的,后来 SUN 公司又卖给了 Oracle 公司,Oracle 公司以 Oracle 数据库而著名,而 Oracle 数据库又是闭源的,这个时候 Oracle 公司就不想完全开源了,但是原来的 SUN 公司又把 JDK 给开源了,如果这个时候 Oracle 收购回来之后就把他给闭源,必然会引起很多 Java 开发者的不满,导致大家对 Java 失去信心,那 Oracle 公司收购回来不就把 Java 烂在手里了吗!然后,Oracle 公司就想了个骚操作,这样吧,我把一部分核心代码开源出来给你们玩,并且我要和你们自己搞的 JDK 区分下,你们叫 OpenJDK,我叫 Oracle JDK,我发布我的,你们继续玩你们的,要是你们搞出来什么好玩的东西,我后续发布 Oracle JDK 也会拿来用一下,一举两得!)OpenJDK 开源项目: https://github.com/openjdk/jdk 。 是否免费:Oracle JDK 会提供免费版本,但一般有时间限制。JDK17 之后的版本可以免费分发和商用,但是仅有 3 年时间,3 年后无法免费商用。不过,JDK8u221 之前只要不升级可以无限期免费。OpenJDK 是完全免费的。 功能性:Oracle JDK 在 OpenJDK 的基础上添加了一些特有的功能和工具,比如 Java Flight Recorder(JFR,一种监控工具)、Java Mission Control(JMC,一种监控工具)等工具。不过,在 Java 11 之后,OracleJDK 和 OpenJDK 的功能基本一致,之前 OracleJDK 中的私有组件大多数也已经被捐赠给开源组织。 稳定性:OpenJDK 不提供 LTS 服务,而 OracleJDK 大概每三年都会推出一个 LTS 版进行长期支持。不过,很多公司都基于 OpenJDK 提供了对应的和 OracleJDK 周期相同的 LTS 版。因此,两者稳定性其实也是差不多的。 协议:Oracle JDK 使用 BCL/OTN 协议获得许可,而 OpenJDK 根据 GPL v2 许可获得许可。 既然 Oracle JDK 这么好,那为什么还要有 OpenJDK?\n答:\nOpenJDK 是开源的,开源意味着你可以对它根据你自己的需要进行修改、优化,比如 Alibaba 基于 OpenJDK 开发了 Dragonwell8: https://github.com/alibaba/dragonwell8 OpenJDK 是商业免费的(这也是为什么通过 yum 包管理器上默认安装的 JDK 是 OpenJDK 而不是 Oracle JDK)。虽然 Oracle JDK 也是商业免费(比如 JDK 8),但并不是所有版本都是免费的。 OpenJDK 更新频率更快。Oracle JDK 一般是每 6 个月发布一个新版本,而 OpenJDK 一般是每 3 个月发布一个新版本。(现在你知道为啥 Oracle JDK 更稳定了吧,先在 OpenJDK 试试水,把大部分问题都解决掉了才在 Oracle JDK 上发布) 基于以上这些原因,OpenJDK 还是有存在的必要的!\nOracle JDK 和 OpenJDK 如何选择?\n建议选择 OpenJDK 或者基于 OpenJDK 的发行版,比如 AWS 的 Amazon Corretto,阿里巴巴的 Alibaba Dragonwell。\n🌈 拓展一下:\nBCL 协议(Oracle Binary Code License Agreement):可以使用 JDK(支持商用),但是不能进行修改。 OTN 协议(Oracle Technology Network License Agreement):11 及之后新发布的 JDK 用的都是这个协议,可以自己私下用,但是商用需要付费。 Java 和 C++ 的区别? # 我知道很多人没学过 C++,但是面试官就是没事喜欢拿咱们 Java 和 C++ 比呀!没办法!!!就算没学过 C++,也要记下来。\n虽然,Java 和 C++ 都是面向对象的语言,都支持封装、继承和多态,但是,它们还是有挺多不相同的地方:\nJava 不提供指针来直接访问内存,程序内存更加安全 Java 的类是单继承的,C++ 支持多重继承;虽然 Java 的类不可以多继承,但是接口可以多继承。 Java 有自动内存管理垃圾回收机制(GC),不需要程序员手动释放无用内存。 C ++同时支持方法重载和操作符重载,但是 Java 只支持方法重载(操作符重载增加了复杂性,这与 Java 最初的设计思想不符)。 …… 基本语法 # 注释有哪几种形式? # Java 中的注释有三种:\n单行注释:通常用于解释方法内某单行代码的作用。\n多行注释:通常用于解释一段代码的作用。\n文档注释:通常用于生成 Java 开发文档。\n用的比较多的还是单行注释和文档注释,多行注释在实际开发中使用的相对较少。\n在我们编写代码的时候,如果代码量比较少,我们自己或者团队其他成员还可以很轻易地看懂代码,但是当项目结构一旦复杂起来,我们就需要用到注释了。注释并不会执行(编译器在编译代码之前会把代码中的所有注释抹掉,字节码中不保留注释),是我们程序员写给自己看的,注释是你的代码说明书,能够帮助看代码的人快速地理清代码之间的逻辑关系。因此,在写程序的时候随手加上注释是一个非常好的习惯。\n《Clean Code》这本书明确指出:\n代码的注释不是越详细越好。实际上好的代码本身就是注释,我们要尽量规范和美化自己的代码来减少不必要的注释。\n若编程语言足够有表达力,就不需要注释,尽量通过代码来阐述。\n举个例子:\n去掉下面复杂的注释,只需要创建一个与注释所言同一事物的函数即可\n// check to see if the employee is eligible for full benefits if ((employee.flags \u0026amp; HOURLY_FLAG) \u0026amp;\u0026amp; (employee.age \u0026gt; 65)) 应替换为\nif (employee.isEligibleForFullBenefits()) 标识符和关键字的区别是什么? # 在我们编写程序的时候,需要大量地为程序、类、变量、方法等取名字,于是就有了 标识符 。简单来说, 标识符就是一个名字 。\n有一些标识符,Java 语言已经赋予了其特殊的含义,只能用于特定的地方,这些特殊的标识符就是 关键字 。简单来说,关键字是被赋予特殊含义的标识符 。比如,在我们的日常生活中,如果我们想要开一家店,则要给这个店起一个名字,起的这个“名字”就叫标识符。但是我们店的名字不能叫“警察局”,因为“警察局”这个名字已经被赋予了特殊的含义,而“警察局”就是我们日常生活中的关键字。\nJava 语言关键字有哪些? # 分类 关键字 访问控制 private protected public 类,方法和变量修饰符 abstract class extends final implements interface native new static strictfp synchronized transient volatile enum 程序控制 break continue return do while if else for instanceof switch case default assert 错误处理 try catch throw throws finally 包相关 import package 基本类型 boolean byte char double float int long short 变量引用 super this void 保留字 goto const Tips:所有的关键字都是小写的,在 IDE 中会以特殊颜色显示。\ndefault 这个关键字很特殊,既属于程序控制,也属于类,方法和变量修饰符,还属于访问控制。\n在程序控制中,当在 switch 中匹配不到任何情况时,可以使用 default 来编写默认匹配的情况。 在类,方法和变量修饰符中,从 JDK8 开始引入了默认方法,可以使用 default 关键字来定义一个方法的默认实现。 在访问控制中,如果一个方法前没有任何修饰符,则默认会有一个修饰符 default,但是这个修饰符加上了就会报错。 ⚠️ 注意:虽然 true, false, 和 null 看起来像关键字但实际上他们是字面值,同时你也不可以作为标识符来使用。\n官方文档: https://docs.oracle.com/javase/tutorial/java/nutsandbolts/_keywords.html\n自增自减运算符 # 在写代码的过程中,常见的一种情况是需要某个整数类型变量增加 1 或减少 1。Java 提供了自增运算符 (++) 和自减运算符 (--) 来简化这种操作。\n++ 和 -- 运算符可以放在变量之前,也可以放在变量之后:\n前缀形式(例如 ++a 或 --a):先自增/自减变量的值,然后再使用该变量,例如,b = ++a 先将 a 增加 1,然后把增加后的值赋给 b。 后缀形式(例如 a++ 或 a--):先使用变量的当前值,然后再自增/自减变量的值。例如,b = a++ 先将 a 的当前值赋给 b,然后再将 a 增加 1。 为了方便记忆,可以使用下面的口诀:符号在前就先加/减,符号在后就后加/减。\n下面来看一个考察自增自减运算符的高频笔试题:执行下面的代码后,a 、b 、 c 、d和e的值是?\nint a = 9; int b = a++; int c = ++a; int d = c--; int e = --d; 答案:a = 11 、b = 9 、 c = 10 、 d = 10 、 e = 10。\n移位运算符 # 移位运算符是最基本的运算符之一,几乎每种编程语言都包含这一运算符。移位操作中,被操作的数据被视为二进制数,移位就是将其向左或向右移动若干位的运算。\n移位运算符在各种框架以及 JDK 自身的源码中使用还是挺广泛的,HashMap(JDK1.8) 中的 hash 方法的源码就用到了移位运算符:\nstatic final int hash(Object key) { int h; // key.hashCode():返回散列值也就是hashcode // ^:按位异或 // \u0026gt;\u0026gt;\u0026gt;:无符号右移,忽略符号位,空位都以0补齐 return (key == null) ? 0 : (h = key.hashCode()) ^ (h \u0026gt;\u0026gt;\u0026gt; 16); } 使用移位运算符的主要原因:\n高效:移位运算符直接对应于处理器的移位指令。现代处理器具有专门的硬件指令来执行这些移位操作,这些指令通常在一个时钟周期内完成。相比之下,乘法和除法等算术运算在硬件层面上需要更多的时钟周期来完成。 节省内存:通过移位操作,可以使用一个整数(如 int 或 long)来存储多个布尔值或标志位,从而节省内存。 移位运算符最常用于快速乘以或除以 2 的幂次方。除此之外,它还在以下方面发挥着重要作用:\n位字段管理:例如存储和操作多个布尔值。 哈希算法和加密解密:通过移位和与、或等操作来混淆数据。 数据压缩:例如霍夫曼编码通过移位运算符可以快速处理和操作二进制数据,以生成紧凑的压缩格式。 数据校验:例如 CRC(循环冗余校验)通过移位和多项式除法生成和校验数据完整性。。 内存对齐:通过移位操作,可以轻松计算和调整数据的对齐地址。 掌握最基本的移位运算符知识还是很有必要的,这不光可以帮助我们在代码中使用,还可以帮助我们理解源码中涉及到移位运算符的代码。\nJava 中有三种移位运算符:\n\u0026lt;\u0026lt; :左移运算符,向左移若干位,高位丢弃,低位补零。x \u0026lt;\u0026lt; n,相当于 x 乘以 2 的 n 次方(不溢出的情况下)。 \u0026gt;\u0026gt; :带符号右移,向右移若干位,高位补符号位,低位丢弃。正数高位补 0,负数高位补 1。x \u0026gt;\u0026gt; n,相当于 x 除以 2 的 n 次方。 \u0026gt;\u0026gt;\u0026gt; :无符号右移,忽略符号位,空位都以 0 补齐。 虽然移位运算本质上可以分为左移和右移,但在实际应用中,右移操作需要考虑符号位的处理方式。\n由于 double,float 在二进制中的表现比较特殊,因此不能来进行移位操作。\n移位操作符实际上支持的类型只有int和long,编译器在对short、byte、char类型进行移位前,都会将其转换为int类型再操作。\n如果移位的位数超过数值所占有的位数会怎样?\n当 int 类型左移/右移位数大于等于 32 位操作时,会先求余(%)后再进行左移/右移操作。也就是说左移/右移 32 位相当于不进行移位操作(32%32=0),左移/右移 42 位相当于左移/右移 10 位(42%32=10)。当 long 类型进行左移/右移操作时,由于 long 对应的二进制是 64 位,因此求余操作的基数也变成了 64。\n也就是说:x\u0026lt;\u0026lt;42等同于x\u0026lt;\u0026lt;10,x\u0026gt;\u0026gt;42等同于x\u0026gt;\u0026gt;10,x \u0026gt;\u0026gt;\u0026gt;42等同于x \u0026gt;\u0026gt;\u0026gt; 10。\n左移运算符代码示例:\nint i = -1; System.out.println(\u0026#34;初始数据:\u0026#34; + i); System.out.println(\u0026#34;初始数据对应的二进制字符串:\u0026#34; + Integer.toBinaryString(i)); i \u0026lt;\u0026lt;= 10; System.out.println(\u0026#34;左移 10 位后的数据 \u0026#34; + i); System.out.println(\u0026#34;左移 10 位后的数据对应的二进制字符 \u0026#34; + Integer.toBinaryString(i)); 输出:\n初始数据:-1 初始数据对应的二进制字符串:11111111111111111111111111111111 左移 10 位后的数据 -1024 左移 10 位后的数据对应的二进制字符 11111111111111111111110000000000 由于左移位数大于等于 32 位操作时,会先求余(%)后再进行左移操作,所以下面的代码左移 42 位相当于左移 10 位(42%32=10),输出结果和前面的代码一样。\nint i = -1; System.out.println(\u0026#34;初始数据:\u0026#34; + i); System.out.println(\u0026#34;初始数据对应的二进制字符串:\u0026#34; + Integer.toBinaryString(i)); i \u0026lt;\u0026lt;= 42; System.out.println(\u0026#34;左移 10 位后的数据 \u0026#34; + i); System.out.println(\u0026#34;左移 10 位后的数据对应的二进制字符 \u0026#34; + Integer.toBinaryString(i)); 右移运算符使用类似,篇幅问题,这里就不做演示了。\ncontinue、break 和 return 的区别是什么? # 在循环结构中,当循环条件不满足或者循环次数达到要求时,循环会正常结束。但是,有时候可能需要在循环的过程中,当发生了某种条件之后 ,提前终止循环,这就需要用到下面几个关键词:\ncontinue:指跳出当前的这一次循环,继续下一次循环。 break:指跳出整个循环体,继续执行循环下面的语句。 return 用于跳出所在方法,结束该方法的运行。return 一般有两种用法:\nreturn;:直接使用 return 结束方法执行,用于没有返回值函数的方法 return value;:return 一个特定值,用于有返回值函数的方法 思考一下:下列语句的运行结果是什么?\npublic static void main(String[] args) { boolean flag = false; for (int i = 0; i \u0026lt;= 3; i++) { if (i == 0) { System.out.println(\u0026#34;0\u0026#34;); } else if (i == 1) { System.out.println(\u0026#34;1\u0026#34;); continue; } else if (i == 2) { System.out.println(\u0026#34;2\u0026#34;); flag = true; } else if (i == 3) { System.out.println(\u0026#34;3\u0026#34;); break; } else if (i == 4) { System.out.println(\u0026#34;4\u0026#34;); } System.out.println(\u0026#34;xixi\u0026#34;); } if (flag) { System.out.println(\u0026#34;haha\u0026#34;); return; } System.out.println(\u0026#34;heihei\u0026#34;); } 运行结果:\n0 xixi 1 2 xixi 3 haha 基本数据类型 # Java 中的几种基本数据类型了解么? # Java 中有 8 种基本数据类型,分别为:\n6 种数字类型: 4 种整数型:byte、short、int、long 2 种浮点型:float、double 1 种字符类型:char 1 种布尔型:boolean。 这 8 种基本数据类型的默认值以及所占空间的大小如下:\n基本类型 位数 字节 默认值 取值范围 byte 8 1 0 -128 ~ 127 short 16 2 0 -32768(-215) ~ 32767(215 - 1) int 32 4 0 -2147483648 ~ 2147483647 long 64 8 0L -9223372036854775808(-263) ~ 9223372036854775807(263 -1) char 16 2 \u0026lsquo;u0000\u0026rsquo; 0 ~ 65535(2^16 - 1) float 32 4 0f 1.4E-45 ~ 3.4028235E38 double 64 8 0d 4.9E-324 ~ 1.7976931348623157E308 boolean 1 false true、false 可以看到,像 byte、short、int、long能表示的最大正数都减 1 了。这是为什么呢?这是因为在二进制补码表示法中,最高位是用来表示符号的(0 表示正数,1 表示负数),其余位表示数值部分。所以,如果我们要表示最大的正数,我们需要把除了最高位之外的所有位都设为 1。如果我们再加 1,就会导致溢出,变成一个负数。\n对于 boolean,官方文档未明确定义,它依赖于 JVM 厂商的具体实现。逻辑上理解是占用 1 位,但是实际中会考虑计算机高效存储因素。\n另外,Java 的每种基本类型所占存储空间的大小不会像其他大多数语言那样随机器硬件架构的变化而变化。这种所占存储空间大小的不变性是 Java 程序比用其他大多数语言编写的程序更具可移植性的原因之一(《Java 编程思想》2.2 节有提到)。\n注意:\nJava 里使用 long 类型的数据一定要在数值后面加上 L,否则将作为整型解析。 Java 里使用 float 类型的数据一定要在数值后面加上 f 或 F,否则将无法通过编译。 char a = 'h'char :单引号,String a = \u0026quot;hello\u0026quot; :双引号。 这八种基本类型都有对应的包装类分别为:Byte、Short、Integer、Long、Float、Double、Character、Boolean 。\n基本类型和包装类型的区别? # 用途:除了定义一些常量和局部变量之外,我们在其他地方比如方法参数、对象属性中很少会使用基本类型来定义变量。并且,包装类型可用于泛型,而基本类型不可以。 存储方式:基本数据类型的局部变量存放在 Java 虚拟机栈中的局部变量表中,基本数据类型的成员变量(未被 static 修饰 )存放在 Java 虚拟机的堆中。包装类型属于对象类型,我们知道几乎所有对象实例都存在于堆中。 占用空间:相比于包装类型(对象类型), 基本数据类型占用的空间往往非常小。 默认值:成员变量包装类型不赋值就是 null ,而基本类型有默认值且不是 null。 比较方式:对于基本数据类型来说,== 比较的是值。对于包装数据类型来说,== 比较的是对象的内存地址。所有整型包装类对象之间值的比较,全部使用 equals() 方法。 为什么说是几乎所有对象实例都存在于堆中呢? 这是因为 HotSpot 虚拟机引入了 JIT 优化之后,会对对象进行逃逸分析,如果发现某一个对象并没有逃逸到方法外部,那么就可能通过标量替换来实现栈上分配,而避免堆上分配内存\n⚠️ 注意:基本数据类型存放在栈中是一个常见的误区! 基本数据类型的存储位置取决于它们的作用域和声明方式。如果它们是局部变量,那么它们会存放在栈中;如果它们是成员变量,那么它们会存放在堆/方法区/元空间中。\npublic class Test { // 成员变量,存放在堆中 int a = 10; // 被 static 修饰的成员变量,JDK 1.7 及之前位于方法区,1.8 后存放于元空间,均不存放于堆中。 // 变量属于类,不属于对象。 static int b = 20; public void method() { // 局部变量,存放在栈中 int c = 30; static int d = 40; // 编译错误,不能在方法中使用 static 修饰局部变量 } } 包装类型的缓存机制了解么? # Java 基本数据类型的包装类型的大部分都用到了缓存机制来提升性能。\nByte,Short,Integer,Long 这 4 种包装类默认创建了数值 [-128,127] 的相应类型的缓存数据,Character 创建了数值在 [0,127] 范围的缓存数据,Boolean 直接返回 True or False。\nInteger 缓存源码:\npublic static Integer valueOf(int i) { if (i \u0026gt;= IntegerCache.low \u0026amp;\u0026amp; i \u0026lt;= IntegerCache.high) return IntegerCache.cache[i + (-IntegerCache.low)]; return new Integer(i); } private static class IntegerCache { static final int low = -128; static final int high; static { // high value may be configured by property int h = 127; } } Character 缓存源码:\npublic static Character valueOf(char c) { if (c \u0026lt;= 127) { // must cache return CharacterCache.cache[(int)c]; } return new Character(c); } private static class CharacterCache { private CharacterCache(){} static final Character cache[] = new Character[127 + 1]; static { for (int i = 0; i \u0026lt; cache.length; i++) cache[i] = new Character((char)i); } } Boolean 缓存源码:\npublic static Boolean valueOf(boolean b) { return (b ? TRUE : FALSE); } 如果超出对应范围仍然会去创建新的对象,缓存的范围区间的大小只是在性能和资源之间的权衡。\n两种浮点数类型的包装类 Float,Double 并没有实现缓存机制。\nInteger i1 = 33; Integer i2 = 33; System.out.println(i1 == i2);// 输出 true Float i11 = 333f; Float i22 = 333f; System.out.println(i11 == i22);// 输出 false Double i3 = 1.2; Double i4 = 1.2; System.out.println(i3 == i4);// 输出 false 下面我们来看一个问题:下面的代码的输出结果是 true 还是 false 呢?\nInteger i1 = 40; Integer i2 = new Integer(40); System.out.println(i1==i2); Integer i1=40 这一行代码会发生装箱,也就是说这行代码等价于 Integer i1=Integer.valueOf(40) 。因此,i1 直接使用的是缓存中的对象。而Integer i2 = new Integer(40) 会直接创建新的对象。\n因此,答案是 false 。你答对了吗?\n记住:所有整型包装类对象之间值的比较,全部使用 equals 方法比较。\n自动装箱与拆箱了解吗?原理是什么? # 什么是自动拆装箱?\n装箱:将基本类型用它们对应的引用类型包装起来; 拆箱:将包装类型转换为基本数据类型; 举例:\nInteger i = 10; //装箱 int n = i; //拆箱 上面这两行代码对应的字节码为:\nL1 LINENUMBER 8 L1 ALOAD 0 BIPUSH 10 INVOKESTATIC java/lang/Integer.valueOf (I)Ljava/lang/Integer; PUTFIELD AutoBoxTest.i : Ljava/lang/Integer; L2 LINENUMBER 9 L2 ALOAD 0 ALOAD 0 GETFIELD AutoBoxTest.i : Ljava/lang/Integer; INVOKEVIRTUAL java/lang/Integer.intValue ()I PUTFIELD AutoBoxTest.n : I RETURN 从字节码中,我们发现装箱其实就是调用了 包装类的valueOf()方法,拆箱其实就是调用了 xxxValue()方法。\n因此,\nInteger i = 10 等价于 Integer i = Integer.valueOf(10) int n = i 等价于 int n = i.intValue(); 注意:如果频繁拆装箱的话,也会严重影响系统的性能。我们应该尽量避免不必要的拆装箱操作。\nprivate static long sum() { // 应该使用 long 而不是 Long Long sum = 0L; for (long i = 0; i \u0026lt;= Integer.MAX_VALUE; i++) sum += i; return sum; } 为什么浮点数运算的时候会有精度丢失的风险? # 浮点数运算精度丢失代码演示:\nfloat a = 2.0f - 1.9f; float b = 1.8f - 1.7f; System.out.printf(\u0026#34;%.9f\u0026#34;,a);// 0.100000024 System.out.println(b);// 0.099999905 System.out.println(a == b);// false 为什么会出现这个问题呢?\n这个和计算机保存浮点数的机制有很大关系。我们知道计算机是二进制的,而且计算机在表示一个数字时,宽度是有限的,无限循环的小数存储在计算机时,只能被截断,所以就会导致小数精度发生损失的情况。这也就是解释了为什么浮点数没有办法用二进制精确表示。\n就比如说十进制下的 0.2 就没办法精确转换成二进制小数:\n// 0.2 转换为二进制数的过程为,不断乘以 2,直到不存在小数为止, // 在这个计算过程中,得到的整数部分从上到下排列就是二进制的结果。 0.2 * 2 = 0.4 -\u0026gt; 0 0.4 * 2 = 0.8 -\u0026gt; 0 0.8 * 2 = 1.6 -\u0026gt; 1 0.6 * 2 = 1.2 -\u0026gt; 1 0.2 * 2 = 0.4 -\u0026gt; 0(发生循环) ... 关于浮点数的更多内容,建议看一下 计算机系统基础(四)浮点数这篇文章。\n如何解决浮点数运算的精度丢失问题? # BigDecimal 可以实现对浮点数的运算,不会造成精度丢失。通常情况下,大部分需要浮点数精确运算结果的业务场景(比如涉及到钱的场景)都是通过 BigDecimal 来做的。\nBigDecimal a = new BigDecimal(\u0026#34;1.0\u0026#34;); BigDecimal b = new BigDecimal(\u0026#34;1.00\u0026#34;); BigDecimal c = new BigDecimal(\u0026#34;0.8\u0026#34;); BigDecimal x = a.subtract(c); BigDecimal y = b.subtract(c); System.out.println(x); /* 0.2 */ System.out.println(y); /* 0.20 */ // 比较内容,不是比较值 System.out.println(Objects.equals(x, y)); /* false */ // 比较值相等用相等compareTo,相等返回0 System.out.println(0 == x.compareTo(y)); /* true */ 关于 BigDecimal 的详细介绍,可以看看我写的这篇文章: BigDecimal 详解。\n超过 long 整型的数据应该如何表示? # 基本数值类型都有一个表达范围,如果超过这个范围就会有数值溢出的风险。\n在 Java 中,64 位 long 整型是最大的整数类型。\nlong l = Long.MAX_VALUE; System.out.println(l + 1); // -9223372036854775808 System.out.println(l + 1 == Long.MIN_VALUE); // true BigInteger 内部使用 int[] 数组来存储任意大小的整形数据。\n相对于常规整数类型的运算来说,BigInteger 运算的效率会相对较低。\n变量 # 成员变量与局部变量的区别? # 语法形式:从语法形式上看,成员变量是属于类的,而局部变量是在代码块或方法中定义的变量或是方法的参数;成员变量可以被 public,private,static 等修饰符所修饰,而局部变量不能被访问控制修饰符及 static 所修饰;但是,成员变量和局部变量都能被 final 所修饰。 存储方式:从变量在内存中的存储方式来看,如果成员变量是使用 static 修饰的,那么这个成员变量是属于类的,如果没有使用 static 修饰,这个成员变量是属于实例的。而对象存在于堆内存,局部变量则存在于栈内存。 生存时间:从变量在内存中的生存时间上看,成员变量是对象的一部分,它随着对象的创建而存在,而局部变量随着方法的调用而自动生成,随着方法的调用结束而消亡。 默认值:从变量是否有默认值来看,成员变量如果没有被赋初始值,则会自动以类型的默认值而赋值(一种情况例外:被 final 修饰的成员变量也必须显式地赋值),而局部变量则不会自动赋值。 为什么成员变量有默认值?\n先不考虑变量类型,如果没有默认值会怎样?变量存储的是内存地址对应的任意随机值,程序读取该值运行会出现意外。\n默认值有两种设置方式:手动和自动,根据第一点,没有手动赋值一定要自动赋值。成员变量在运行时可借助反射等方法手动赋值,而局部变量不行。\n对于编译器(javac)来说,局部变量没赋值很好判断,可以直接报错。而成员变量可能是运行时赋值,无法判断,误报“没默认值”又会影响用户体验,所以采用自动赋默认值。\n成员变量与局部变量代码示例:\npublic class VariableExample { // 成员变量 private String name; private int age; // 方法中的局部变量 public void method() { int num1 = 10; // 栈中分配的局部变量 String str = \u0026#34;Hello, world!\u0026#34;; // 栈中分配的局部变量 System.out.println(num1); System.out.println(str); } // 带参数的方法中的局部变量 public void method2(int num2) { int sum = num2 + 10; // 栈中分配的局部变量 System.out.println(sum); } // 构造方法中的局部变量 public VariableExample(String name, int age) { this.name = name; // 对成员变量进行赋值 this.age = age; // 对成员变量进行赋值 int num3 = 20; // 栈中分配的局部变量 String str2 = \u0026#34;Hello, \u0026#34; + this.name + \u0026#34;!\u0026#34;; // 栈中分配的局部变量 System.out.println(num3); System.out.println(str2); } } 静态变量有什么作用? # 静态变量也就是被 static 关键字修饰的变量。它可以被类的所有实例共享,无论一个类创建了多少个对象,它们都共享同一份静态变量。也就是说,静态变量只会被分配一次内存,即使创建多个对象,这样可以节省内存。\n静态变量是通过类名来访问的,例如StaticVariableExample.staticVar(如果被 private关键字修饰就无法这样访问了)。\npublic class StaticVariableExample { // 静态变量 public static int staticVar = 0; } 通常情况下,静态变量会被 final 关键字修饰成为常量。\npublic class ConstantVariableExample { // 常量 public static final int constantVar = 0; } 字符型常量和字符串常量的区别? # 形式 : 字符常量是单引号引起的一个字符,字符串常量是双引号引起的 0 个或若干个字符。 含义 : 字符常量相当于一个整型值( ASCII 值),可以参加表达式运算; 字符串常量代表一个地址值(该字符串在内存中存放位置)。 占内存大小:字符常量只占 2 个字节; 字符串常量占若干个字节。 ⚠️ 注意 char 在 Java 中占两个字节。\n字符型常量和字符串常量代码示例:\npublic class StringExample { // 字符型常量 public static final char LETTER_A = \u0026#39;A\u0026#39;; // 字符串常量 public static final String GREETING_MESSAGE = \u0026#34;Hello, world!\u0026#34;; public static void main(String[] args) { System.out.println(\u0026#34;字符型常量占用的字节数为:\u0026#34;+Character.BYTES); System.out.println(\u0026#34;字符串常量占用的字节数为:\u0026#34;+GREETING_MESSAGE.getBytes().length); } } 输出:\n字符型常量占用的字节数为:2 字符串常量占用的字节数为:13 方法 # 什么是方法的返回值?方法有哪几种类型? # 方法的返回值 是指我们获取到的某个方法体中的代码执行后产生的结果!(前提是该方法可能产生结果)。返回值的作用是接收出结果,使得它可以用于其他的操作!\n我们可以按照方法的返回值和参数类型将方法分为下面这几种:\n1、无参数无返回值的方法\npublic void f1() { //...... } // 下面这个方法也没有返回值,虽然用到了 return public void f(int a) { if (...) { // 表示结束方法的执行,下方的输出语句不会执行 return; } System.out.println(a); } 2、有参数无返回值的方法\npublic void f2(Parameter 1, ..., Parameter n) { //...... } 3、有返回值无参数的方法\npublic int f3() { //...... return x; } 4、有返回值有参数的方法\npublic int f4(int a, int b) { return a * b; } 静态方法为什么不能调用非静态成员? # 这个需要结合 JVM 的相关知识,主要原因如下:\n静态方法是属于类的,在类加载的时候就会分配内存,可以通过类名直接访问。而非静态成员属于实例对象,只有在对象实例化之后才存在,需要通过类的实例对象去访问。 在类的非静态成员不存在的时候静态方法就已经存在了,此时调用在内存中还不存在的非静态成员,属于非法操作。 public class Example { // 定义一个字符型常量 public static final char LETTER_A = \u0026#39;A\u0026#39;; // 定义一个字符串常量 public static final String GREETING_MESSAGE = \u0026#34;Hello, world!\u0026#34;; public static void main(String[] args) { // 输出字符型常量的值 System.out.println(\u0026#34;字符型常量的值为:\u0026#34; + LETTER_A); // 输出字符串常量的值 System.out.println(\u0026#34;字符串常量的值为:\u0026#34; + GREETING_MESSAGE); } } 静态方法和实例方法有何不同? # 1、调用方式\n在外部调用静态方法时,可以使用 类名.方法名 的方式,也可以使用 对象.方法名 的方式,而实例方法只有后面这种方式。也就是说,调用静态方法可以无需创建对象 。\n不过,需要注意的是一般不建议使用 对象.方法名 的方式来调用静态方法。这种方式非常容易造成混淆,静态方法不属于类的某个对象而是属于这个类。\n因此,一般建议使用 类名.方法名 的方式来调用静态方法。\npublic class Person { public void method() { //...... } public static void staicMethod(){ //...... } public static void main(String[] args) { Person person = new Person(); // 调用实例方法 person.method(); // 调用静态方法 Person.staicMethod() } } 2、访问类成员是否存在限制\n静态方法在访问本类的成员时,只允许访问静态成员(即静态成员变量和静态方法),不允许访问实例成员(即实例成员变量和实例方法),而实例方法不存在这个限制。\n重载和重写有什么区别? # 重载就是同样的一个方法能够根据输入数据的不同,做出不同的处理\n重写就是当子类继承自父类的相同方法,输入数据一样,但要做出有别于父类的响应时,你就要覆盖父类方法\n重载 # 发生在同一个类中(或者父类和子类之间),方法名必须相同,参数类型不同、个数不同、顺序不同,方法返回值和访问修饰符可以不同。\n《Java 核心技术》这本书是这样介绍重载的:\n如果多个方法(比如 StringBuilder 的构造方法)有相同的名字、不同的参数, 便产生了重载。\nStringBuilder sb = new StringBuilder(); StringBuilder sb2 = new StringBuilder(\u0026#34;HelloWorld\u0026#34;); 编译器必须挑选出具体执行哪个方法,它通过用各个方法给出的参数类型与特定方法调用所使用的值类型进行匹配来挑选出相应的方法。 如果编译器找不到匹配的参数, 就会产生编译时错误, 因为根本不存在匹配, 或者没有一个比其他的更好(这个过程被称为重载解析(overloading resolution))。\nJava 允许重载任何方法, 而不只是构造器方法。\n综上:重载就是同一个类中多个同名方法根据不同的传参来执行不同的逻辑处理。\n重写 # 重写发生在运行期,是子类对父类的允许访问的方法的实现过程进行重新编写。\n方法名、参数列表必须相同,子类方法返回值类型应比父类方法返回值类型更小或相等,抛出的异常范围小于等于父类,访问修饰符范围大于等于父类。 如果父类方法访问修饰符为 private/final/static 则子类就不能重写该方法,但是被 static 修饰的方法能够被再次声明。 构造方法无法被重写 总结 # 综上:重写就是子类对父类方法的重新改造,外部样子不能改变,内部逻辑可以改变。\n区别点 重载方法 重写方法 发生范围 同一个类 子类 参数列表 必须修改 一定不能修改 返回类型 可修改 子类方法返回值类型应比父类方法返回值类型更小或相等 异常 可修改 子类方法声明抛出的异常类应比父类方法声明抛出的异常类更小或相等; 访问修饰符 可修改 一定不能做更严格的限制(可以降低限制) 发生阶段 编译期 运行期 方法的重写要遵循“两同两小一大”(以下内容摘录自《疯狂 Java 讲义》, issue#892 ):\n“两同”即方法名相同、形参列表相同; “两小”指的是子类方法返回值类型应比父类方法返回值类型更小或相等,子类方法声明抛出的异常类应比父类方法声明抛出的异常类更小或相等; “一大”指的是子类方法的访问权限应比父类方法的访问权限更大或相等。 ⭐️ 关于 重写的返回值类型 这里需要额外多说明一下,上面的表述不太清晰准确:如果方法的返回类型是 void 和基本数据类型,则返回值重写时不可修改。但是如果方法的返回值是引用类型,重写时是可以返回该引用类型的子类的。\npublic class Hero { public String name() { return \u0026#34;超级英雄\u0026#34;; } } public class SuperMan extends Hero{ @Override public String name() { return \u0026#34;超人\u0026#34;; } public Hero hero() { return new Hero(); } } public class SuperSuperMan extends SuperMan { @Override public String name() { return \u0026#34;超级超级英雄\u0026#34;; } @Override public SuperMan hero() { return new SuperMan(); } } 什么是可变长参数? # 从 Java5 开始,Java 支持定义可变长参数,所谓可变长参数就是允许在调用方法时传入不定长度的参数。就比如下面这个方法就可以接受 0 个或者多个参数。\npublic static void method1(String... args) { //...... } 另外,可变参数只能作为函数的最后一个参数,但其前面可以有也可以没有任何其他参数。\npublic static void method2(String arg1, String... args) { //...... } 遇到方法重载的情况怎么办呢?会优先匹配固定参数还是可变参数的方法呢?\n答案是会优先匹配固定参数的方法,因为固定参数的方法匹配度更高。\n我们通过下面这个例子来证明一下。\n/** * 微信搜 JavaGuide 回复\u0026#34;面试突击\u0026#34;即可免费领取个人原创的 Java 面试手册 * * @author Guide哥 * @date 2021/12/13 16:52 **/ public class VariableLengthArgument { public static void printVariable(String... args) { for (String s : args) { System.out.println(s); } } public static void printVariable(String arg1, String arg2) { System.out.println(arg1 + arg2); } public static void main(String[] args) { printVariable(\u0026#34;a\u0026#34;, \u0026#34;b\u0026#34;); printVariable(\u0026#34;a\u0026#34;, \u0026#34;b\u0026#34;, \u0026#34;c\u0026#34;, \u0026#34;d\u0026#34;); } } 输出:\nab a b c d 另外,Java 的可变参数编译后实际会被转换成一个数组,我们看编译后生成的 class文件就可以看出来了。\npublic class VariableLengthArgument { public static void printVariable(String... args) { String[] var1 = args; int var2 = args.length; for(int var3 = 0; var3 \u0026lt; var2; ++var3) { String s = var1[var3]; System.out.println(s); } } // ...... } 参考 # What is the difference between JDK and JRE?: https://stackoverflow.com/questions/1906445/what-is-the-difference-between-jdk-and-jre Oracle vs OpenJDK: https://www.educba.com/oracle-vs-openjdk/ Differences between Oracle JDK and OpenJDK: https://stackoverflow.com/questions/22358071/differences-between-oracle-jdk-and-openjdk 彻底弄懂 Java 的移位操作符: https://juejin.cn/post/6844904025880526861 "},{"id":517,"href":"/zh/docs/technology/Interview/java/basis/java-basic-questions-03/","title":"Java基础常见面试题总结(下)","section":"Basis","content":" 异常 # Java 异常类层次结构图概览:\nException 和 Error 有什么区别? # 在 Java 中,所有的异常都有一个共同的祖先 java.lang 包中的 Throwable 类。Throwable 类有两个重要的子类:\nException :程序本身可以处理的异常,可以通过 catch 来进行捕获。Exception 又可以分为 Checked Exception (受检查异常,必须处理) 和 Unchecked Exception (不受检查异常,可以不处理)。 Error:Error 属于程序无法处理的错误 ,我们没办法通过 catch 来进行捕获不建议通过catch捕获 。例如 Java 虚拟机运行错误(Virtual MachineError)、虚拟机内存不够错误(OutOfMemoryError)、类定义错误(NoClassDefFoundError)等 。这些异常发生时,Java 虚拟机(JVM)一般会选择线程终止。 Checked Exception 和 Unchecked Exception 有什么区别? # Checked Exception 即 受检查异常 ,Java 代码在编译过程中,如果受检查异常没有被 catch或者throws 关键字处理的话,就没办法通过编译。\n比如下面这段 IO 操作的代码:\n除了RuntimeException及其子类以外,其他的Exception类及其子类都属于受检查异常 。常见的受检查异常有:IO 相关的异常、ClassNotFoundException、SQLException\u0026hellip;。\nUnchecked Exception 即 不受检查异常 ,Java 代码在编译过程中 ,我们即使不处理不受检查异常也可以正常通过编译。\nRuntimeException 及其子类都统称为非受检查异常,常见的有(建议记下来,日常开发中会经常用到):\nNullPointerException(空指针错误) IllegalArgumentException(参数错误比如方法入参类型错误) NumberFormatException(字符串转换为数字格式错误,IllegalArgumentException的子类) ArrayIndexOutOfBoundsException(数组越界错误) ClassCastException(类型转换错误) ArithmeticException(算术错误) SecurityException (安全错误比如权限不够) UnsupportedOperationException(不支持的操作错误比如重复创建同一用户) …… Throwable 类常用方法有哪些? # String getMessage(): 返回异常发生时的详细信息 String toString(): 返回异常发生时的简要描述 String getLocalizedMessage(): 返回异常对象的本地化信息。使用 Throwable 的子类覆盖这个方法,可以生成本地化信息。如果子类没有覆盖该方法,则该方法返回的信息与 getMessage()返回的结果相同 void printStackTrace(): 在控制台上打印 Throwable 对象封装的异常信息 try-catch-finally 如何使用? # try块:用于捕获异常。其后可接零个或多个 catch 块,如果没有 catch 块,则必须跟一个 finally 块。 catch块:用于处理 try 捕获到的异常。 finally 块:无论是否捕获或处理异常,finally 块里的语句都会被执行。当在 try 块或 catch 块中遇到 return 语句时,finally 语句块将在方法返回之前被执行。 代码示例:\ntry { System.out.println(\u0026#34;Try to do something\u0026#34;); throw new RuntimeException(\u0026#34;RuntimeException\u0026#34;); } catch (Exception e) { System.out.println(\u0026#34;Catch Exception -\u0026gt; \u0026#34; + e.getMessage()); } finally { System.out.println(\u0026#34;Finally\u0026#34;); } 输出:\nTry to do something Catch Exception -\u0026gt; RuntimeException Finally 注意:不要在 finally 语句块中使用 return! 当 try 语句和 finally 语句中都有 return 语句时,try 语句块中的 return 语句会被忽略。这是因为 try 语句中的 return 返回值会先被暂存在一个本地变量中,当执行到 finally 语句中的 return 之后,这个本地变量的值就变为了 finally 语句中的 return 返回值。\njvm 官方文档中有明确提到:\nIf the try clause executes a return, the compiled code does the following:\nSaves the return value (if any) in a local variable. Executes a jsr to the code for the finally clause. Upon return from the finally clause, returns the value saved in the local variable. 代码示例:\npublic static void main(String[] args) { System.out.println(f(2)); } public static int f(int value) { try { return value * value; } finally { if (value == 2) { return 0; } } } 输出:\n0 finally 中的代码一定会执行吗? # 不一定的!在某些情况下,finally 中的代码不会被执行。\n就比如说 finally 之前虚拟机被终止运行的话,finally 中的代码就不会被执行。\ntry { System.out.println(\u0026#34;Try to do something\u0026#34;); throw new RuntimeException(\u0026#34;RuntimeException\u0026#34;); } catch (Exception e) { System.out.println(\u0026#34;Catch Exception -\u0026gt; \u0026#34; + e.getMessage()); // 终止当前正在运行的Java虚拟机 System.exit(1); } finally { System.out.println(\u0026#34;Finally\u0026#34;); } 输出:\nTry to do something Catch Exception -\u0026gt; RuntimeException 另外,在以下 2 种特殊情况下,finally 块的代码也不会被执行:\n程序所在的线程死亡。 关闭 CPU。 相关 issue: https://github.com/Snailclimb/JavaGuide/issues/190。\n🧗🏻 进阶一下:从字节码角度分析try catch finally这个语法糖背后的实现原理。\n如何使用 try-with-resources 代替try-catch-finally? # 适用范围(资源的定义): 任何实现 java.lang.AutoCloseable或者 java.io.Closeable 的对象 关闭资源和 finally 块的执行顺序: 在 try-with-resources 语句中,任何 catch 或 finally 块在声明的资源关闭后运行 《Effective Java》中明确指出:\n面对必须要关闭的资源,我们总是应该优先使用 try-with-resources 而不是try-finally。随之产生的代码更简短,更清晰,产生的异常对我们也更有用。try-with-resources语句让我们更容易编写必须要关闭的资源的代码,若采用try-finally则几乎做不到这点。\nJava 中类似于InputStream、OutputStream、Scanner、PrintWriter等的资源都需要我们调用close()方法来手动关闭,一般情况下我们都是通过try-catch-finally语句来实现这个需求,如下:\n//读取文本文件的内容 Scanner scanner = null; try { scanner = new Scanner(new File(\u0026#34;D://read.txt\u0026#34;)); while (scanner.hasNext()) { System.out.println(scanner.nextLine()); } } catch (FileNotFoundException e) { e.printStackTrace(); } finally { if (scanner != null) { scanner.close(); } } 使用 Java 7 之后的 try-with-resources 语句改造上面的代码:\ntry (Scanner scanner = new Scanner(new File(\u0026#34;test.txt\u0026#34;))) { while (scanner.hasNext()) { System.out.println(scanner.nextLine()); } } catch (FileNotFoundException fnfe) { fnfe.printStackTrace(); } 当然多个资源需要关闭的时候,使用 try-with-resources 实现起来也非常简单,如果你还是用try-catch-finally可能会带来很多问题。\n通过使用分号分隔,可以在try-with-resources块中声明多个资源。\ntry (BufferedInputStream bin = new BufferedInputStream(new FileInputStream(new File(\u0026#34;test.txt\u0026#34;))); BufferedOutputStream bout = new BufferedOutputStream(new FileOutputStream(new File(\u0026#34;out.txt\u0026#34;)))) { int b; while ((b = bin.read()) != -1) { bout.write(b); } } catch (IOException e) { e.printStackTrace(); } 异常使用有哪些需要注意的地方? # 不要把异常定义为静态变量,因为这样会导致异常栈信息错乱。每次手动抛出异常,我们都需要手动 new 一个异常对象抛出。 抛出的异常信息一定要有意义。 建议抛出更加具体的异常比如字符串转换为数字格式错误的时候应该抛出NumberFormatException而不是其父类IllegalArgumentException。 避免重复记录日志:如果在捕获异常的地方已经记录了足够的信息(包括异常类型、错误信息和堆栈跟踪等),那么在业务代码中再次抛出这个异常时,就不应该再次记录相同的错误信息。重复记录日志会使得日志文件膨胀,并且可能会掩盖问题的实际原因,使得问题更难以追踪和解决。 …… 泛型 # 什么是泛型?有什么作用? # Java 泛型(Generics) 是 JDK 5 中引入的一个新特性。使用泛型参数,可以增强代码的可读性以及稳定性。\n编译器可以对泛型参数进行检测,并且通过泛型参数可以指定传入的对象类型。比如 ArrayList\u0026lt;Person\u0026gt; persons = new ArrayList\u0026lt;Person\u0026gt;() 这行代码就指明了该 ArrayList 对象只能传入 Person 对象,如果传入其他类型的对象就会报错。\nArrayList\u0026lt;E\u0026gt; extends AbstractList\u0026lt;E\u0026gt; 并且,原生 List 返回类型是 Object ,需要手动转换类型才能使用,使用泛型后编译器自动转换。\n泛型的使用方式有哪几种? # 泛型一般有三种使用方式:泛型类、泛型接口、泛型方法。\n1.泛型类:\n//此处T可以随便写为任意标识,常见的如T、E、K、V等形式的参数常用于表示泛型 //在实例化泛型类时,必须指定T的具体类型 public class Generic\u0026lt;T\u0026gt;{ private T key; public Generic(T key) { this.key = key; } public T getKey(){ return key; } } 如何实例化泛型类:\nGeneric\u0026lt;Integer\u0026gt; genericInteger = new Generic\u0026lt;Integer\u0026gt;(123456); 2.泛型接口:\npublic interface Generator\u0026lt;T\u0026gt; { public T method(); } 实现泛型接口,不指定类型:\nclass GeneratorImpl\u0026lt;T\u0026gt; implements Generator\u0026lt;T\u0026gt;{ @Override public T method() { return null; } } 实现泛型接口,指定类型:\nclass GeneratorImpl implements Generator\u0026lt;String\u0026gt; { @Override public String method() { return \u0026#34;hello\u0026#34;; } } 3.泛型方法:\npublic static \u0026lt; E \u0026gt; void printArray( E[] inputArray ) { for ( E element : inputArray ){ System.out.printf( \u0026#34;%s \u0026#34;, element ); } System.out.println(); } 使用:\n// 创建不同类型数组:Integer, Double 和 Character Integer[] intArray = { 1, 2, 3 }; String[] stringArray = { \u0026#34;Hello\u0026#34;, \u0026#34;World\u0026#34; }; printArray( intArray ); printArray( stringArray ); 注意: public static \u0026lt; E \u0026gt; void printArray( E[] inputArray ) 一般被称为静态泛型方法;在 java 中泛型只是一个占位符,必须在传递类型后才能使用。类在实例化时才能真正的传递类型参数,由于静态方法的加载先于类的实例化,也就是说类中的泛型还没有传递真正的类型参数,静态的方法的加载就已经完成了,所以静态泛型方法是没有办法使用类上声明的泛型的。只能使用自己声明的 \u0026lt;E\u0026gt;\n项目中哪里用到了泛型? # 自定义接口通用返回结果 CommonResult\u0026lt;T\u0026gt; 通过参数 T 可根据具体的返回类型动态指定结果的数据类型 定义 Excel 处理类 ExcelUtil\u0026lt;T\u0026gt; 用于动态指定 Excel 导出的数据类型 构建集合工具类(参考 Collections 中的 sort, binarySearch 方法)。 …… 反射 # 关于反射的详细解读,请看这篇文章 Java 反射机制详解 。\n何谓反射? # 如果说大家研究过框架的底层原理或者咱们自己写过框架的话,一定对反射这个概念不陌生。反射之所以被称为框架的灵魂,主要是因为它赋予了我们在运行时分析类以及执行类中方法的能力。通过反射你可以获取任意一个类的所有属性和方法,你还可以调用这些方法和属性。\n反射的优缺点? # 反射可以让我们的代码更加灵活、为各种框架提供开箱即用的功能提供了便利。\n不过,反射让我们在运行时有了分析操作类的能力的同时,也增加了安全问题,比如可以无视泛型参数的安全检查(泛型参数的安全检查发生在编译时)。另外,反射的性能也要稍差点,不过,对于框架来说实际是影响不大的。\n相关阅读: Java Reflection: Why is it so slow? 。\n反射的应用场景? # 像咱们平时大部分时候都是在写业务代码,很少会接触到直接使用反射机制的场景。但是!这并不代表反射没有用。相反,正是因为反射,你才能这么轻松地使用各种框架。像 Spring/Spring Boot、MyBatis 等等框架中都大量使用了反射机制。\n这些框架中也大量使用了动态代理,而动态代理的实现也依赖反射。\n比如下面是通过 JDK 实现动态代理的示例代码,其中就使用了反射类 Method 来调用指定的方法。\npublic class DebugInvocationHandler implements InvocationHandler { /** * 代理类中的真实对象 */ private final Object target; public DebugInvocationHandler(Object target) { this.target = target; } public Object invoke(Object proxy, Method method, Object[] args) throws InvocationTargetException, IllegalAccessException { System.out.println(\u0026#34;before method \u0026#34; + method.getName()); Object result = method.invoke(target, args); System.out.println(\u0026#34;after method \u0026#34; + method.getName()); return result; } } 另外,像 Java 中的一大利器 注解 的实现也用到了反射。\n为什么你使用 Spring 的时候 ,一个@Component注解就声明了一个类为 Spring Bean 呢?为什么你通过一个 @Value注解就读取到配置文件中的值呢?究竟是怎么起作用的呢?\n这些都是因为你可以基于反射分析类,然后获取到类/属性/方法/方法的参数上的注解。你获取到注解之后,就可以做进一步的处理。\n注解 # 何谓注解? # Annotation (注解) 是 Java5 开始引入的新特性,可以看作是一种特殊的注释,主要用于修饰类、方法或者变量,提供某些信息供程序在编译或者运行时使用。\n注解本质是一个继承了Annotation 的特殊接口:\n@Target(ElementType.METHOD) @Retention(RetentionPolicy.SOURCE) public @interface Override { } public interface Override extends Annotation{ } JDK 提供了很多内置的注解(比如 @Override、@Deprecated),同时,我们还可以自定义注解。\n注解的解析方法有哪几种? # 注解只有被解析之后才会生效,常见的解析方法有两种:\n编译期直接扫描:编译器在编译 Java 代码的时候扫描对应的注解并处理,比如某个方法使用@Override 注解,编译器在编译的时候就会检测当前的方法是否重写了父类对应的方法。 运行期通过反射处理:像框架中自带的注解(比如 Spring 框架的 @Value、@Component)都是通过反射来进行处理的。 SPI # 关于 SPI 的详细解读,请看这篇文章 Java SPI 机制详解 。\n何谓 SPI? # SPI 即 Service Provider Interface ,字面意思就是:“服务提供者的接口”,我的理解是:专门提供给服务提供者或者扩展框架功能的开发者去使用的一个接口。\nSPI 将服务接口和具体的服务实现分离开来,将服务调用方和服务实现者解耦,能够提升程序的扩展性、可维护性。修改或者替换服务实现并不需要修改调用方。\n很多框架都使用了 Java 的 SPI 机制,比如:Spring 框架、数据库加载驱动、日志接口、以及 Dubbo 的扩展实现等等。\nSPI 和 API 有什么区别? # 那 SPI 和 API 有啥区别?\n说到 SPI 就不得不说一下 API(Application Programming Interface) 了,从广义上来说它们都属于接口,而且很容易混淆。下面先用一张图说明一下:\n一般模块之间都是通过接口进行通讯,因此我们在服务调用方和服务实现方(也称服务提供者)之间引入一个“接口”。\n当实现方提供了接口和实现,我们可以通过调用实现方的接口从而拥有实现方给我们提供的能力,这就是 API。这种情况下,接口和实现都是放在实现方的包中。调用方通过接口调用实现方的功能,而不需要关心具体的实现细节。 当接口存在于调用方这边时,这就是 SPI 。由接口调用方确定接口规则,然后由不同的厂商根据这个规则对这个接口进行实现,从而提供服务。 举个通俗易懂的例子:公司 H 是一家科技公司,新设计了一款芯片,然后现在需要量产了,而市面上有好几家芯片制造业公司,这个时候,只要 H 公司指定好了这芯片生产的标准(定义好了接口标准),那么这些合作的芯片公司(服务提供者)就按照标准交付自家特色的芯片(提供不同方案的实现,但是给出来的结果是一样的)。\nSPI 的优缺点? # 通过 SPI 机制能够大大地提高接口设计的灵活性,但是 SPI 机制也存在一些缺点,比如:\n需要遍历加载所有的实现类,不能做到按需加载,这样效率还是相对较低的。 当多个 ServiceLoader 同时 load 时,会有并发问题。 序列化和反序列化 # 关于序列化和反序列化的详细解读,请看这篇文章 Java 序列化详解 ,里面涉及到的知识点和面试题更全面。\n什么是序列化?什么是反序列化? # 如果我们需要持久化 Java 对象比如将 Java 对象保存在文件中,或者在网络传输 Java 对象,这些场景都需要用到序列化。\n简单来说:\n序列化:将数据结构或对象转换成可以存储或传输的形式,通常是二进制字节流,也可以是 JSON, XML 等文本格式 反序列化:将在序列化过程中所生成的数据转换为原始数据结构或者对象的过程 对于 Java 这种面向对象编程语言来说,我们序列化的都是对象(Object)也就是实例化后的类(Class),但是在 C++这种半面向对象的语言中,struct(结构体)定义的是数据结构类型,而 class 对应的是对象类型。\n下面是序列化和反序列化常见应用场景:\n对象在进行网络传输(比如远程方法调用 RPC 的时候)之前需要先被序列化,接收到序列化的对象之后需要再进行反序列化; 将对象存储到文件之前需要进行序列化,将对象从文件中读取出来需要进行反序列化; 将对象存储到数据库(如 Redis)之前需要用到序列化,将对象从缓存数据库中读取出来需要反序列化; 将对象存储到内存之前需要进行序列化,从内存中读取出来之后需要进行反序列化。 维基百科是如是介绍序列化的:\n序列化(serialization)在计算机科学的数据处理中,是指将数据结构或对象状态转换成可取用格式(例如存成文件,存于缓冲,或经由网络中发送),以留待后续在相同或另一台计算机环境中,能恢复原先状态的过程。依照序列化格式重新获取字节的结果时,可以利用它来产生与原始对象相同语义的副本。对于许多对象,像是使用大量引用的复杂对象,这种序列化重建的过程并不容易。面向对象中的对象序列化,并不概括之前原始对象所关系的函数。这种过程也称为对象编组(marshalling)。从一系列字节提取数据结构的反向操作,是反序列化(也称为解编组、deserialization、unmarshalling)。\n综上:序列化的主要目的是通过网络传输对象或者说是将对象存储到文件系统、数据库、内存中。\nhttps://www.corejavaguru.com/java/serialization/interview-questions-1\n序列化协议对应于 TCP/IP 4 层模型的哪一层?\n我们知道网络通信的双方必须要采用和遵守相同的协议。TCP/IP 四层模型是下面这样的,序列化协议属于哪一层呢?\n应用层 传输层 网络层 网络接口层 如上图所示,OSI 七层协议模型中,表示层做的事情主要就是对应用层的用户数据进行处理转换为二进制流。反过来的话,就是将二进制流转换成应用层的用户数据。这不就对应的是序列化和反序列化么?\n因为,OSI 七层协议模型中的应用层、表示层和会话层对应的都是 TCP/IP 四层模型中的应用层,所以序列化协议属于 TCP/IP 协议应用层的一部分。\n如果有些字段不想进行序列化怎么办? # 对于不想进行序列化的变量,使用 transient 关键字修饰。\ntransient 关键字的作用是:阻止实例中那些用此关键字修饰的的变量序列化;当对象被反序列化时,被 transient 修饰的变量值不会被持久化和恢复。\n关于 transient 还有几点注意:\ntransient 只能修饰变量,不能修饰类和方法。 transient 修饰的变量,在反序列化后变量值将会被置成类型的默认值。例如,如果是修饰 int 类型,那么反序列后结果就是 0。 static 变量因为不属于任何对象(Object),所以无论有没有 transient 关键字修饰,均不会被序列化。 常见序列化协议有哪些? # JDK 自带的序列化方式一般不会用 ,因为序列化效率低并且存在安全问题。比较常用的序列化协议有 Hessian、Kryo、Protobuf、ProtoStuff,这些都是基于二进制的序列化协议。\n像 JSON 和 XML 这种属于文本类序列化方式。虽然可读性比较好,但是性能较差,一般不会选择。\n为什么不推荐使用 JDK 自带的序列化? # 我们很少或者说几乎不会直接使用 JDK 自带的序列化方式,主要原因有下面这些原因:\n不支持跨语言调用 : 如果调用的是其他语言开发的服务的时候就不支持了。 性能差:相比于其他序列化框架性能更低,主要原因是序列化之后的字节数组体积较大,导致传输成本加大。 存在安全问题:序列化和反序列化本身并不存在问题。但当输入的反序列化的数据可被用户控制,那么攻击者即可通过构造恶意输入,让反序列化产生非预期的对象,在此过程中执行构造的任意代码。相关阅读: 应用安全:JAVA 反序列化漏洞之殇 。 I/O # 关于 I/O 的详细解读,请看下面这几篇文章,里面涉及到的知识点和面试题更全面。\nJava IO 基础知识总结 Java IO 设计模式总结 Java IO 模型详解 Java IO 流了解吗? # IO 即 Input/Output,输入和输出。数据输入到计算机内存的过程即输入,反之输出到外部存储(比如数据库,文件,远程主机)的过程即输出。数据传输过程类似于水流,因此称为 IO 流。IO 流在 Java 中分为输入流和输出流,而根据数据的处理方式又分为字节流和字符流。\nJava IO 流的 40 多个类都是从如下 4 个抽象类基类中派生出来的。\nInputStream/Reader: 所有的输入流的基类,前者是字节输入流,后者是字符输入流。 OutputStream/Writer: 所有输出流的基类,前者是字节输出流,后者是字符输出流。 I/O 流为什么要分为字节流和字符流呢? # 问题本质想问:不管是文件读写还是网络发送接收,信息的最小存储单元都是字节,那为什么 I/O 流操作要分为字节流操作和字符流操作呢?\n个人认为主要有两点原因:\n字符流是由 Java 虚拟机将字节转换得到的,这个过程还算是比较耗时; 如果我们不知道编码类型的话,使用字节流的过程中很容易出现乱码问题。 Java IO 中的设计模式有哪些? # 参考答案: Java IO 设计模式总结\nBIO、NIO 和 AIO 的区别? # 参考答案: Java IO 模型详解\n语法糖 # 什么是语法糖? # 语法糖(Syntactic sugar) 代指的是编程语言为了方便程序员开发程序而设计的一种特殊语法,这种语法对编程语言的功能并没有影响。实现相同的功能,基于语法糖写出来的代码往往更简单简洁且更易阅读。\n举个例子,Java 中的 for-each 就是一个常用的语法糖,其原理其实就是基于普通的 for 循环和迭代器。\nString[] strs = {\u0026#34;JavaGuide\u0026#34;, \u0026#34;公众号:JavaGuide\u0026#34;, \u0026#34;博客:https://javaguide.cn/\u0026#34;}; for (String s : strs) { System.out.println(s); } 不过,JVM 其实并不能识别语法糖,Java 语法糖要想被正确执行,需要先通过编译器进行解糖,也就是在程序编译阶段将其转换成 JVM 认识的基本语法。这也侧面说明,Java 中真正支持语法糖的是 Java 编译器而不是 JVM。如果你去看com.sun.tools.javac.main.JavaCompiler的源码,你会发现在compile()中有一个步骤就是调用desugar(),这个方法就是负责解语法糖的实现的。\nJava 中有哪些常见的语法糖? # Java 中最常用的语法糖主要有泛型、自动拆装箱、变长参数、枚举、内部类、增强 for 循环、try-with-resources 语法、lambda 表达式等。\n关于这些语法糖的详细解读,请看这篇文章 Java 语法糖详解 。\n"},{"id":518,"href":"/zh/docs/technology/Interview/java/basis/java-basic-questions-02/","title":"Java基础常见面试题总结(中)","section":"Basis","content":" 面向对象基础 # 面向对象和面向过程的区别 # 面向过程编程(Procedural-Oriented Programming,POP)和面向对象编程(Object-Oriented Programming,OOP)是两种常见的编程范式,两者的主要区别在于解决问题的方式不同:\n面向过程编程(POP):面向过程把解决问题的过程拆成一个个方法,通过一个个方法的执行解决问题。 面向对象编程(OOP):面向对象会先抽象出对象,然后用对象执行方法的方式解决问题。 相比较于 POP,OOP 开发的程序一般具有下面这些优点:\n易维护:由于良好的结构和封装性,OOP 程序通常更容易维护。 易复用:通过继承和多态,OOP 设计使得代码更具复用性,方便扩展功能。 易扩展:模块化设计使得系统扩展变得更加容易和灵活。 POP 的编程方式通常更为简单和直接,适合处理一些较简单的任务。\nPOP 和 OOP 的性能差异主要取决于它们的运行机制,而不仅仅是编程范式本身。因此,简单地比较两者的性能是一个常见的误区(相关 issue : 面向过程:面向过程性能比面向对象高?? )。\n在选择编程范式时,性能并不是唯一的考虑因素。代码的可维护性、可扩展性和开发效率同样重要。\n现代编程语言基本都支持多种编程范式,既可以用来进行面向过程编程,也可以进行面向对象编程。\n下面是一个求圆的面积和周长的示例,简单分别展示了面向对象和面向过程两种不同的解决方案。\n面向对象:\npublic class Circle { // 定义圆的半径 private double radius; // 构造函数 public Circle(double radius) { this.radius = radius; } // 计算圆的面积 public double getArea() { return Math.PI * radius * radius; } // 计算圆的周长 public double getPerimeter() { return 2 * Math.PI * radius; } public static void main(String[] args) { // 创建一个半径为3的圆 Circle circle = new Circle(3.0); // 输出圆的面积和周长 System.out.println(\u0026#34;圆的面积为:\u0026#34; + circle.getArea()); System.out.println(\u0026#34;圆的周长为:\u0026#34; + circle.getPerimeter()); } } 我们定义了一个 Circle 类来表示圆,该类包含了圆的半径属性和计算面积、周长的方法。\n面向过程:\npublic class Main { public static void main(String[] args) { // 定义圆的半径 double radius = 3.0; // 计算圆的面积和周长 double area = Math.PI * radius * radius; double perimeter = 2 * Math.PI * radius; // 输出圆的面积和周长 System.out.println(\u0026#34;圆的面积为:\u0026#34; + area); System.out.println(\u0026#34;圆的周长为:\u0026#34; + perimeter); } } 我们直接定义了圆的半径,并使用该半径直接计算出圆的面积和周长。\n创建一个对象用什么运算符?对象实体与对象引用有何不同? # new 运算符,new 创建对象实例(对象实例在堆内存中),对象引用指向对象实例(对象引用存放在栈内存中)。\n一个对象引用可以指向 0 个或 1 个对象(一根绳子可以不系气球,也可以系一个气球); 一个对象可以有 n 个引用指向它(可以用 n 条绳子系住一个气球)。 对象的相等和引用相等的区别 # 对象的相等一般比较的是内存中存放的内容是否相等。 引用相等一般比较的是他们指向的内存地址是否相等。 这里举一个例子:\nString str1 = \u0026#34;hello\u0026#34;; String str2 = new String(\u0026#34;hello\u0026#34;); String str3 = \u0026#34;hello\u0026#34;; // 使用 == 比较字符串的引用相等 System.out.println(str1 == str2); System.out.println(str1 == str3); // 使用 equals 方法比较字符串的相等 System.out.println(str1.equals(str2)); System.out.println(str1.equals(str3)); 输出结果:\nfalse true true true 从上面的代码输出结果可以看出:\nstr1 和 str2 不相等,而 str1 和 str3 相等。这是因为 == 运算符比较的是字符串的引用是否相等。 str1、 str2、str3 三者的内容都相等。这是因为equals 方法比较的是字符串的内容,即使这些字符串的对象引用不同,只要它们的内容相等,就认为它们是相等的。 如果一个类没有声明构造方法,该程序能正确执行吗? # 构造方法是一种特殊的方法,主要作用是完成对象的初始化工作。\n如果一个类没有声明构造方法,也可以执行!因为一个类即使没有声明构造方法也会有默认的不带参数的构造方法。如果我们自己添加了类的构造方法(无论是否有参),Java 就不会添加默认的无参数的构造方法了。\n我们一直在不知不觉地使用构造方法,这也是为什么我们在创建对象的时候后面要加一个括号(因为要调用无参的构造方法)。如果我们重载了有参的构造方法,记得都要把无参的构造方法也写出来(无论是否用到),因为这可以帮助我们在创建对象的时候少踩坑。\n构造方法有哪些特点?是否可被 override? # 构造方法具有以下特点:\n名称与类名相同:构造方法的名称必须与类名完全一致。 没有返回值:构造方法没有返回类型,且不能使用 void 声明。 自动执行:在生成类的对象时,构造方法会自动执行,无需显式调用。 构造方法不能被重写(override),但可以被重载(overload)。因此,一个类中可以有多个构造方法,这些构造方法可以具有不同的参数列表,以提供不同的对象初始化方式。\n面向对象三大特征 # 封装 # 封装是指把一个对象的状态信息(也就是属性)隐藏在对象内部,不允许外部对象直接访问对象的内部信息。但是可以提供一些可以被外界访问的方法来操作属性。就好像我们看不到挂在墙上的空调的内部的零件信息(也就是属性),但是可以通过遥控器(方法)来控制空调。如果属性不想被外界访问,我们大可不必提供方法给外界访问。但是如果一个类没有提供给外界访问的方法,那么这个类也没有什么意义了。就好像如果没有空调遥控器,那么我们就无法操控空凋制冷,空调本身就没有意义了(当然现在还有很多其他方法 ,这里只是为了举例子)。\npublic class Student { private int id;//id属性私有化 private String name;//name属性私有化 //获取id的方法 public int getId() { return id; } //设置id的方法 public void setId(int id) { this.id = id; } //获取name的方法 public String getName() { return name; } //设置name的方法 public void setName(String name) { this.name = name; } } 继承 # 不同类型的对象,相互之间经常有一定数量的共同点。例如,小明同学、小红同学、小李同学,都共享学生的特性(班级、学号等)。同时,每一个对象还定义了额外的特性使得他们与众不同。例如小明的数学比较好,小红的性格惹人喜爱;小李的力气比较大。继承是使用已存在的类的定义作为基础建立新类的技术,新类的定义可以增加新的数据或新的功能,也可以用父类的功能,但不能选择性地继承父类。通过使用继承,可以快速地创建新的类,可以提高代码的重用,程序的可维护性,节省大量创建新类的时间 ,提高我们的开发效率。\n关于继承如下 3 点请记住:\n子类拥有父类对象所有的属性和方法(包括私有属性和私有方法),但是父类中的私有属性和方法子类是无法访问,只是拥有。 子类可以拥有自己属性和方法,即子类可以对父类进行扩展。 子类可以用自己的方式实现父类的方法。(以后介绍)。 多态 # 多态,顾名思义,表示一个对象具有多种的状态,具体表现为父类的引用指向子类的实例。\n多态的特点:\n对象类型和引用类型之间具有继承(类)/实现(接口)的关系; 引用类型变量发出的方法调用的到底是哪个类中的方法,必须在程序运行期间才能确定; 多态不能调用“只在子类存在但在父类不存在”的方法; 如果子类重写了父类的方法,真正执行的是子类重写的方法,如果子类没有重写父类的方法,执行的是父类的方法。 接口和抽象类有什么共同点和区别? # 接口和抽象类的共同点 # 实例化:接口和抽象类都不能直接实例化,只能被实现(接口)或继承(抽象类)后才能创建具体的对象。 抽象方法:接口和抽象类都可以包含抽象方法。抽象方法没有方法体,必须在子类或实现类中实现。 接口和抽象类的区别 # 设计目的:接口主要用于对类的行为进行约束,你实现了某个接口就具有了对应的行为。抽象类主要用于代码复用,强调的是所属关系。 继承和实现:一个类只能继承一个类(包括抽象类),因为 Java 不支持多继承。但一个类可以实现多个接口,一个接口也可以继承多个其他接口。 成员变量:接口中的成员变量只能是 public static final 类型的,不能被修改且必须有初始值。抽象类的成员变量可以有任何修饰符(private, protected, public),可以在子类中被重新定义或赋值。 方法: Java 8 之前,接口中的方法默认是 public abstract ,也就是只能有方法声明。自 Java 8 起,可以在接口中定义 default(默认) 方法和 static (静态)方法。 自 Java 9 起,接口可以包含 private 方法。 抽象类可以包含抽象方法和非抽象方法。抽象方法没有方法体,必须在子类中实现。非抽象方法有具体实现,可以直接在抽象类中使用或在子类中重写。 在 Java 8 及以上版本中,接口引入了新的方法类型:default 方法、static 方法和 private 方法。这些方法让接口的使用更加灵活。\nJava 8 引入的default 方法用于提供接口方法的默认实现,可以在实现类中被覆盖。这样就可以在不修改实现类的情况下向现有接口添加新功能,从而增强接口的扩展性和向后兼容性。\npublic interface MyInterface { default void defaultMethod() { System.out.println(\u0026#34;This is a default method.\u0026#34;); } } Java 8 引入的static 方法无法在实现类中被覆盖,只能通过接口名直接调用( MyInterface.staticMethod()),类似于类中的静态方法。static 方法通常用于定义一些通用的、与接口相关的工具方法,一般很少用。\npublic interface MyInterface { static void staticMethod() { System.out.println(\u0026#34;This is a static method in the interface.\u0026#34;); } } Java 9 允许在接口中使用 private 方法。private方法可以用于在接口内部共享代码,不对外暴露。\npublic interface MyInterface { // default 方法 default void defaultMethod() { commonMethod(); } // static 方法 static void staticMethod() { commonMethod(); } // 私有静态方法,可以被 static 和 default 方法调用 private static void commonMethod() { System.out.println(\u0026#34;This is a private method used internally.\u0026#34;); } // 实例私有方法,只能被 default 方法调用。 private void instanceCommonMethod() { System.out.println(\u0026#34;This is a private instance method used internally.\u0026#34;); } } 深拷贝和浅拷贝区别了解吗?什么是引用拷贝? # 关于深拷贝和浅拷贝区别,我这里先给结论:\n浅拷贝:浅拷贝会在堆上创建一个新的对象(区别于引用拷贝的一点),不过,如果原对象内部的属性是引用类型的话,浅拷贝会直接复制内部对象的引用地址,也就是说拷贝对象和原对象共用同一个内部对象。 深拷贝:深拷贝会完全复制整个对象,包括这个对象所包含的内部对象。 上面的结论没有完全理解的话也没关系,我们来看一个具体的案例!\n浅拷贝 # 浅拷贝的示例代码如下,我们这里实现了 Cloneable 接口,并重写了 clone() 方法。\nclone() 方法的实现很简单,直接调用的是父类 Object 的 clone() 方法。\npublic class Address implements Cloneable{ private String name; // 省略构造函数、Getter\u0026amp;Setter方法 @Override public Address clone() { try { return (Address) super.clone(); } catch (CloneNotSupportedException e) { throw new AssertionError(); } } } public class Person implements Cloneable { private Address address; // 省略构造函数、Getter\u0026amp;Setter方法 @Override public Person clone() { try { Person person = (Person) super.clone(); return person; } catch (CloneNotSupportedException e) { throw new AssertionError(); } } } 测试:\nPerson person1 = new Person(new Address(\u0026#34;武汉\u0026#34;)); Person person1Copy = person1.clone(); // true System.out.println(person1.getAddress() == person1Copy.getAddress()); 从输出结构就可以看出, person1 的克隆对象和 person1 使用的仍然是同一个 Address 对象。\n深拷贝 # 这里我们简单对 Person 类的 clone() 方法进行修改,连带着要把 Person 对象内部的 Address 对象一起复制。\n@Override public Person clone() { try { Person person = (Person) super.clone(); person.setAddress(person.getAddress().clone()); return person; } catch (CloneNotSupportedException e) { throw new AssertionError(); } } 测试:\nPerson person1 = new Person(new Address(\u0026#34;武汉\u0026#34;)); Person person1Copy = person1.clone(); // false System.out.println(person1.getAddress() == person1Copy.getAddress()); 从输出结构就可以看出,显然 person1 的克隆对象和 person1 包含的 Address 对象已经是不同的了。\n那什么是引用拷贝呢? 简单来说,引用拷贝就是两个不同的引用指向同一个对象。\n我专门画了一张图来描述浅拷贝、深拷贝、引用拷贝:\nObject # Object 类的常见方法有哪些? # Object 类是一个特殊的类,是所有类的父类,主要提供了以下 11 个方法:\n/** * native 方法,用于返回当前运行时对象的 Class 对象,使用了 final 关键字修饰,故不允许子类重写。 */ public final native Class\u0026lt;?\u0026gt; getClass() /** * native 方法,用于返回对象的哈希码,主要使用在哈希表中,比如 JDK 中的HashMap。 */ public native int hashCode() /** * 用于比较 2 个对象的内存地址是否相等,String 类对该方法进行了重写以用于比较字符串的值是否相等。 */ public boolean equals(Object obj) /** * native 方法,用于创建并返回当前对象的一份拷贝。 */ protected native Object clone() throws CloneNotSupportedException /** * 返回类的名字实例的哈希码的 16 进制的字符串。建议 Object 所有的子类都重写这个方法。 */ public String toString() /** * native 方法,并且不能重写。唤醒一个在此对象监视器上等待的线程(监视器相当于就是锁的概念)。如果有多个线程在等待只会任意唤醒一个。 */ public final native void notify() /** * native 方法,并且不能重写。跟 notify 一样,唯一的区别就是会唤醒在此对象监视器上等待的所有线程,而不是一个线程。 */ public final native void notifyAll() /** * native方法,并且不能重写。暂停线程的执行。注意:sleep 方法没有释放锁,而 wait 方法释放了锁 ,timeout 是等待时间。 */ public final native void wait(long timeout) throws InterruptedException /** * 多了 nanos 参数,这个参数表示额外时间(以纳秒为单位,范围是 0-999999)。 所以超时的时间还需要加上 nanos 纳秒。。 */ public final void wait(long timeout, int nanos) throws InterruptedException /** * 跟之前的2个wait方法一样,只不过该方法一直等待,没有超时时间这个概念 */ public final void wait() throws InterruptedException /** * 实例被垃圾回收器回收的时候触发的操作 */ protected void finalize() throws Throwable { } == 和 equals() 的区别 # == 对于基本类型和引用类型的作用效果是不同的:\n对于基本数据类型来说,== 比较的是值。 对于引用数据类型来说,== 比较的是对象的内存地址。 因为 Java 只有值传递,所以,对于 == 来说,不管是比较基本数据类型,还是引用数据类型的变量,其本质比较的都是值,只是引用类型变量存的值是对象的地址。\nequals() 不能用于判断基本数据类型的变量,只能用来判断两个对象是否相等。equals()方法存在于Object类中,而Object类是所有类的直接或间接父类,因此所有的类都有equals()方法。\nObject 类 equals() 方法:\npublic boolean equals(Object obj) { return (this == obj); } equals() 方法存在两种使用情况:\n类没有重写 equals()方法:通过equals()比较该类的两个对象时,等价于通过“==”比较这两个对象,使用的默认是 Object类equals()方法。 类重写了 equals()方法:一般我们都重写 equals()方法来比较两个对象中的属性是否相等;若它们的属性相等,则返回 true(即,认为这两个对象相等)。 举个例子(这里只是为了举例。实际上,你按照下面这种写法的话,像 IDEA 这种比较智能的 IDE 都会提示你将 == 换成 equals() ):\nString a = new String(\u0026#34;ab\u0026#34;); // a 为一个引用 String b = new String(\u0026#34;ab\u0026#34;); // b为另一个引用,对象的内容一样 String aa = \u0026#34;ab\u0026#34;; // 放在常量池中 String bb = \u0026#34;ab\u0026#34;; // 从常量池中查找 System.out.println(aa == bb);// true System.out.println(a == b);// false System.out.println(a.equals(b));// true System.out.println(42 == 42.0);// true String 中的 equals 方法是被重写过的,因为 Object 的 equals 方法是比较的对象的内存地址,而 String 的 equals 方法比较的是对象的值。\n当创建 String 类型的对象时,虚拟机会在常量池中查找有没有已经存在的值和要创建的值相同的对象,如果有就把它赋给当前引用。如果没有就在常量池中重新创建一个 String 对象。\nString类equals()方法:\npublic boolean equals(Object anObject) { if (this == anObject) { return true; } if (anObject instanceof String) { String anotherString = (String)anObject; int n = value.length; if (n == anotherString.value.length) { char v1[] = value; char v2[] = anotherString.value; int i = 0; while (n-- != 0) { if (v1[i] != v2[i]) return false; i++; } return true; } } return false; } hashCode() 有什么用? # hashCode() 的作用是获取哈希码(int 整数),也称为散列码。这个哈希码的作用是确定该对象在哈希表中的索引位置。\nhashCode() 定义在 JDK 的 Object 类中,这就意味着 Java 中的任何类都包含有 hashCode() 函数。另外需要注意的是:Object 的 hashCode() 方法是本地方法,也就是用 C 语言或 C++ 实现的。\n⚠️ 注意:该方法在 Oracle OpenJDK8 中默认是 \u0026ldquo;使用线程局部状态来实现 Marsaglia\u0026rsquo;s xor-shift 随机数生成\u0026rdquo;, 并不是 \u0026ldquo;地址\u0026rdquo; 或者 \u0026ldquo;地址转换而来\u0026rdquo;, 不同 JDK/VM 可能不同。在 Oracle OpenJDK8 中有六种生成方式 (其中第五种是返回地址), 通过添加 VM 参数: -XX:hashCode=4 启用第五种。参考源码:\nhttps://hg.openjdk.org/jdk8u/jdk8u/hotspot/file/87ee5ee27509/src/share/vm/runtime/globals.hpp(1127 行) https://hg.openjdk.org/jdk8u/jdk8u/hotspot/file/87ee5ee27509/src/share/vm/runtime/synchronizer.cpp(537 行开始) public native int hashCode(); 散列表存储的是键值对(key-value),它的特点是:能根据“键”快速的检索出对应的“值”。这其中就利用到了散列码!(可以快速找到所需要的对象)\n为什么要有 hashCode? # 我们以“HashSet 如何检查重复”为例子来说明为什么要有 hashCode?\n下面这段内容摘自我的 Java 启蒙书《Head First Java》:\n当你把对象加入 HashSet 时,HashSet 会先计算对象的 hashCode 值来判断对象加入的位置,同时也会与其他已经加入的对象的 hashCode 值作比较,如果没有相符的 hashCode,HashSet 会假设对象没有重复出现。但是如果发现有相同 hashCode 值的对象,这时会调用 equals() 方法来检查 hashCode 相等的对象是否真的相同。如果两者相同,HashSet 就不会让其加入操作成功。如果不同的话,就会重新散列到其他位置。这样我们就大大减少了 equals 的次数,相应就大大提高了执行速度。\n其实, hashCode() 和 equals()都是用于比较两个对象是否相等。\n那为什么 JDK 还要同时提供这两个方法呢?\n这是因为在一些容器(比如 HashMap、HashSet)中,有了 hashCode() 之后,判断元素是否在对应容器中的效率会更高(参考添加元素进HashSet的过程)!\n我们在前面也提到了添加元素进HashSet的过程,如果 HashSet 在对比的时候,同样的 hashCode 有多个对象,它会继续使用 equals() 来判断是否真的相同。也就是说 hashCode 帮助我们大大缩小了查找成本。\n那为什么不只提供 hashCode() 方法呢?\n这是因为两个对象的hashCode 值相等并不代表两个对象就相等。\n那为什么两个对象有相同的 hashCode 值,它们也不一定是相等的?\n因为 hashCode() 所使用的哈希算法也许刚好会让多个对象传回相同的哈希值。越糟糕的哈希算法越容易碰撞,但这也与数据值域分布的特性有关(所谓哈希碰撞也就是指的是不同的对象得到相同的 hashCode )。\n总结下来就是:\n如果两个对象的hashCode 值相等,那这两个对象不一定相等(哈希碰撞)。 如果两个对象的hashCode 值相等并且equals()方法也返回 true,我们才认为这两个对象相等。 如果两个对象的hashCode 值不相等,我们就可以直接认为这两个对象不相等。 相信大家看了我前面对 hashCode() 和 equals() 的介绍之后,下面这个问题已经难不倒你们了。\n为什么重写 equals() 时必须重写 hashCode() 方法? # 因为两个相等的对象的 hashCode 值必须是相等。也就是说如果 equals 方法判断两个对象是相等的,那这两个对象的 hashCode 值也要相等。\n如果重写 equals() 时没有重写 hashCode() 方法的话就可能会导致 equals 方法判断是相等的两个对象,hashCode 值却不相等。\n思考:重写 equals() 时没有重写 hashCode() 方法的话,使用 HashMap 可能会出现什么问题。\n总结:\nequals 方法判断两个对象是相等的,那这两个对象的 hashCode 值也要相等。 两个对象有相同的 hashCode 值,他们也不一定是相等的(哈希碰撞)。 更多关于 hashCode() 和 equals() 的内容可以查看: Java hashCode() 和 equals()的若干问题解答\nString # String、StringBuffer、StringBuilder 的区别? # 可变性\nString 是不可变的(后面会详细分析原因)。\nStringBuilder 与 StringBuffer 都继承自 AbstractStringBuilder 类,在 AbstractStringBuilder 中也是使用字符数组保存字符串,不过没有使用 final 和 private 关键字修饰,最关键的是这个 AbstractStringBuilder 类还提供了很多修改字符串的方法比如 append 方法。\nabstract class AbstractStringBuilder implements Appendable, CharSequence { char[] value; public AbstractStringBuilder append(String str) { if (str == null) return appendNull(); int len = str.length(); ensureCapacityInternal(count + len); str.getChars(0, len, value, count); count += len; return this; } //... } 线程安全性\nString 中的对象是不可变的,也就可以理解为常量,线程安全。AbstractStringBuilder 是 StringBuilder 与 StringBuffer 的公共父类,定义了一些字符串的基本操作,如 expandCapacity、append、insert、indexOf 等公共方法。StringBuffer 对方法加了同步锁或者对调用的方法加了同步锁,所以是线程安全的。StringBuilder 并没有对方法进行加同步锁,所以是非线程安全的。\n性能\n每次对 String 类型进行改变的时候,都会生成一个新的 String 对象,然后将指针指向新的 String 对象。StringBuffer 每次都会对 StringBuffer 对象本身进行操作,而不是生成新的对象并改变对象引用。相同情况下使用 StringBuilder 相比使用 StringBuffer 仅能获得 10%~15% 左右的性能提升,但却要冒多线程不安全的风险。\n对于三者使用的总结:\n操作少量的数据: 适用 String 单线程操作字符串缓冲区下操作大量数据: 适用 StringBuilder 多线程操作字符串缓冲区下操作大量数据: 适用 StringBuffer String 为什么是不可变的? # String 类中使用 final 关键字修饰字符数组来保存字符串,所以String 对象是不可变的。\npublic final class String implements java.io.Serializable, Comparable\u0026lt;String\u0026gt;, CharSequence { private final char value[]; //... } 🐛 修正:我们知道被 final 关键字修饰的类不能被继承,修饰的方法不能被重写,修饰的变量是基本数据类型则值不能改变,修饰的变量是引用类型则不能再指向其他对象。因此,final 关键字修饰的数组保存字符串并不是 String 不可变的根本原因,因为这个数组保存的字符串是可变的(final 修饰引用类型变量的情况)。\nString 真正不可变有下面几点原因:\n保存字符串的数组被 final 修饰且为私有的,并且String 类没有提供/暴露修改这个字符串的方法。 String 类被 final 修饰导致其不能被继承,进而避免了子类破坏 String 不可变。 相关阅读: 如何理解 String 类型值的不可变? - 知乎提问\n补充(来自 issue 675):在 Java 9 之后,String、StringBuilder 与 StringBuffer 的实现改用 byte 数组存储字符串。\npublic final class String implements java.io.Serializable,Comparable\u0026lt;String\u0026gt;, CharSequence { // @Stable 注解表示变量最多被修改一次,称为“稳定的”。 @Stable private final byte[] value; } abstract class AbstractStringBuilder implements Appendable, CharSequence { byte[] value; } Java 9 为何要将 String 的底层实现由 char[] 改成了 byte[] ?\n新版的 String 其实支持两个编码方案:Latin-1 和 UTF-16。如果字符串中包含的汉字没有超过 Latin-1 可表示范围内的字符,那就会使用 Latin-1 作为编码方案。Latin-1 编码方案下,byte 占一个字节(8 位),char 占用 2 个字节(16),byte 相较 char 节省一半的内存空间。\nJDK 官方就说了绝大部分字符串对象只包含 Latin-1 可表示的字符。\n如果字符串中包含的汉字超过 Latin-1 可表示范围内的字符,byte 和 char 所占用的空间是一样的。\n这是官方的介绍: https://openjdk.java.net/jeps/254 。\n字符串拼接用“+” 还是 StringBuilder? # Java 语言本身并不支持运算符重载,“+”和“+=”是专门为 String 类重载过的运算符,也是 Java 中仅有的两个重载过的运算符。\nString str1 = \u0026#34;he\u0026#34;; String str2 = \u0026#34;llo\u0026#34;; String str3 = \u0026#34;world\u0026#34;; String str4 = str1 + str2 + str3; 上面的代码对应的字节码如下:\n可以看出,字符串对象通过“+”的字符串拼接方式,实际上是通过 StringBuilder 调用 append() 方法实现的,拼接完成之后调用 toString() 得到一个 String 对象 。\n不过,在循环内使用“+”进行字符串的拼接的话,存在比较明显的缺陷:编译器不会创建单个 StringBuilder 以复用,会导致创建过多的 StringBuilder 对象。\nString[] arr = {\u0026#34;he\u0026#34;, \u0026#34;llo\u0026#34;, \u0026#34;world\u0026#34;}; String s = \u0026#34;\u0026#34;; for (int i = 0; i \u0026lt; arr.length; i++) { s += arr[i]; } System.out.println(s); StringBuilder 对象是在循环内部被创建的,这意味着每循环一次就会创建一个 StringBuilder 对象。\n如果直接使用 StringBuilder 对象进行字符串拼接的话,就不会存在这个问题了。\nString[] arr = {\u0026#34;he\u0026#34;, \u0026#34;llo\u0026#34;, \u0026#34;world\u0026#34;}; StringBuilder s = new StringBuilder(); for (String value : arr) { s.append(value); } System.out.println(s); 如果你使用 IDEA 的话,IDEA 自带的代码检查机制也会提示你修改代码。\n在 JDK 9 中,字符串相加“+”改为用动态方法 makeConcatWithConstants() 来实现,通过提前分配空间从而减少了部分临时对象的创建。然而这种优化主要针对简单的字符串拼接,如: a+b+c 。对于循环中的大量拼接操作,仍然会逐个动态分配内存(类似于两个两个 append 的概念),并不如手动使用 StringBuilder 来进行拼接效率高。这个改进是 JDK9 的 JEP 280 提出的,关于这部分改进的详细介绍,推荐阅读这篇文章:还在无脑用 StringBuilder?来重温一下字符串拼接吧 以及参考 issue#2442。\nString#equals() 和 Object#equals() 有何区别? # String 中的 equals 方法是被重写过的,比较的是 String 字符串的值是否相等。 Object 的 equals 方法是比较的对象的内存地址。\n字符串常量池的作用了解吗? # 字符串常量池 是 JVM 为了提升性能和减少内存消耗针对字符串(String 类)专门开辟的一块区域,主要目的是为了避免字符串的重复创建。\n// 在字符串常量池中创建字符串对象 ”ab“ // 将字符串对象 ”ab“ 的引用赋值给 aa String aa = \u0026#34;ab\u0026#34;; // 直接返回字符串常量池中字符串对象 ”ab“,赋值给引用 bb String bb = \u0026#34;ab\u0026#34;; System.out.println(aa==bb); // true 更多关于字符串常量池的介绍可以看一下 Java 内存区域详解 这篇文章。\nString s1 = new String(\u0026ldquo;abc\u0026rdquo;);这句话创建了几个字符串对象? # 先说答案:会创建 1 或 2 个字符串对象。\n字符串常量池中不存在 \u0026ldquo;abc\u0026rdquo;:会创建 2 个 字符串对象。一个在字符串常量池中,由 ldc 指令触发创建。一个在堆中,由 new String() 创建,并使用常量池中的 \u0026ldquo;abc\u0026rdquo; 进行初始化。 字符串常量池中已存在 \u0026ldquo;abc\u0026rdquo;:会创建 1 个 字符串对象。该对象在堆中,由 new String() 创建,并使用常量池中的 \u0026ldquo;abc\u0026rdquo; 进行初始化。 下面开始详细分析。\n1、如果字符串常量池中不存在字符串对象 “abc”,那么它首先会在字符串常量池中创建字符串对象 \u0026ldquo;abc\u0026rdquo;,然后在堆内存中再创建其中一个字符串对象 \u0026ldquo;abc\u0026rdquo;。\n示例代码(JDK 1.8):\nString s1 = new String(\u0026#34;abc\u0026#34;); 对应的字节码:\n// 在堆内存中分配一个尚未初始化的 String 对象。 // #2 是常量池中的一个符号引用,指向 java/lang/String 类。 // 在类加载的解析阶段,这个符号引用会被解析成直接引用,即指向实际的 java/lang/String 类。 0 new #2 \u0026lt;java/lang/String\u0026gt; // 复制栈顶的 String 对象引用,为后续的构造函数调用做准备。 // 此时操作数栈中有两个相同的对象引用:一个用于传递给构造函数,另一个用于保持对新对象的引用,后续将其存储到局部变量表。 3 dup // JVM 先检查字符串常量池中是否存在 \u0026#34;abc\u0026#34;。 // 如果常量池中已存在 \u0026#34;abc\u0026#34;,则直接返回该字符串的引用; // 如果常量池中不存在 \u0026#34;abc\u0026#34;,则 JVM 会在常量池中创建该字符串字面量并返回它的引用。 // 这个引用被压入操作数栈,用作构造函数的参数。 4 ldc #3 \u0026lt;abc\u0026gt; // 调用构造方法,使用从常量池中加载的 \u0026#34;abc\u0026#34; 初始化堆中的 String 对象 // 新的 String 对象将包含与常量池中的 \u0026#34;abc\u0026#34; 相同的内容,但它是一个独立的对象,存储于堆中。 6 invokespecial #4 \u0026lt;java/lang/String.\u0026lt;init\u0026gt; : (Ljava/lang/String;)V\u0026gt; // 将堆中的 String 对象引用存储到局部变量表 9 astore_1 // 返回,结束方法 10 return ldc (load constant) 指令的确是从常量池中加载各种类型的常量,包括字符串常量、整数常量、浮点数常量,甚至类引用等。对于字符串常量,ldc 指令的行为如下:\n从常量池加载字符串:ldc 首先检查字符串常量池中是否已经有内容相同的字符串对象。 复用已有字符串对象:如果字符串常量池中已经存在内容相同的字符串对象,ldc 会将该对象的引用加载到操作数栈上。 没有则创建新对象并加入常量池:如果字符串常量池中没有相同内容的字符串对象,JVM 会在常量池中创建一个新的字符串对象,并将其引用加载到操作数栈中。 2、如果字符串常量池中已存在字符串对象“abc”,则只会在堆中创建 1 个字符串对象“abc”。\n示例代码(JDK 1.8):\n// 字符串常量池中已存在字符串对象“abc” String s1 = \u0026#34;abc\u0026#34;; // 下面这段代码只会在堆中创建 1 个字符串对象“abc” String s2 = new String(\u0026#34;abc\u0026#34;); 对应的字节码:\n0 ldc #2 \u0026lt;abc\u0026gt; 2 astore_1 3 new #3 \u0026lt;java/lang/String\u0026gt; 6 dup 7 ldc #2 \u0026lt;abc\u0026gt; 9 invokespecial #4 \u0026lt;java/lang/String.\u0026lt;init\u0026gt; : (Ljava/lang/String;)V\u0026gt; 12 astore_2 13 return 这里就不对上面的字节码进行详细注释了,7 这个位置的 ldc 命令不会在堆中创建新的字符串对象“abc”,这是因为 0 这个位置已经执行了一次 ldc 命令,已经在堆中创建过一次字符串对象“abc”了。7 这个位置执行 ldc 命令会直接返回字符串常量池中字符串对象“abc”对应的引用。\nString#intern 方法有什么作用? # String.intern() 是一个 native (本地) 方法,用来处理字符串常量池中的字符串对象引用。它的工作流程可以概括为以下两种情况:\n常量池中已有相同内容的字符串对象:如果字符串常量池中已经有一个与调用 intern() 方法的字符串内容相同的 String 对象,intern() 方法会直接返回常量池中该对象的引用。 常量池中没有相同内容的字符串对象:如果字符串常量池中还没有一个与调用 intern() 方法的字符串内容相同的对象,intern() 方法会将当前字符串对象的引用添加到字符串常量池中,并返回该引用。 总结:\nintern() 方法的主要作用是确保字符串引用在常量池中的唯一性。 当调用 intern() 时,如果常量池中已经存在相同内容的字符串,则返回常量池中已有对象的引用;否则,将该字符串添加到常量池并返回其引用。 示例代码(JDK 1.8) :\n// s1 指向字符串常量池中的 \u0026#34;Java\u0026#34; 对象 String s1 = \u0026#34;Java\u0026#34;; // s2 也指向字符串常量池中的 \u0026#34;Java\u0026#34; 对象,和 s1 是同一个对象 String s2 = s1.intern(); // 在堆中创建一个新的 \u0026#34;Java\u0026#34; 对象,s3 指向它 String s3 = new String(\u0026#34;Java\u0026#34;); // s4 指向字符串常量池中的 \u0026#34;Java\u0026#34; 对象,和 s1 是同一个对象 String s4 = s3.intern(); // s1 和 s2 指向的是同一个常量池中的对象 System.out.println(s1 == s2); // true // s3 指向堆中的对象,s4 指向常量池中的对象,所以不同 System.out.println(s3 == s4); // false // s1 和 s4 都指向常量池中的同一个对象 System.out.println(s1 == s4); // true String 类型的变量和常量做“+”运算时发生了什么? # 先来看字符串不加 final 关键字拼接的情况(JDK1.8):\nString str1 = \u0026#34;str\u0026#34;; String str2 = \u0026#34;ing\u0026#34;; String str3 = \u0026#34;str\u0026#34; + \u0026#34;ing\u0026#34;; String str4 = str1 + str2; String str5 = \u0026#34;string\u0026#34;; System.out.println(str3 == str4);//false System.out.println(str3 == str5);//true System.out.println(str4 == str5);//false 注意:比较 String 字符串的值是否相等,可以使用 equals() 方法。 String 中的 equals 方法是被重写过的。 Object 的 equals 方法是比较的对象的内存地址,而 String 的 equals 方法比较的是字符串的值是否相等。如果你使用 == 比较两个字符串是否相等的话,IDEA 还是提示你使用 equals() 方法替换。\n对于编译期可以确定值的字符串,也就是常量字符串 ,jvm 会将其存入字符串常量池。并且,字符串常量拼接得到的字符串常量在编译阶段就已经被存放字符串常量池,这个得益于编译器的优化。\n在编译过程中,Javac 编译器(下文中统称为编译器)会进行一个叫做 常量折叠(Constant Folding) 的代码优化。《深入理解 Java 虚拟机》中是也有介绍到:\n常量折叠会把常量表达式的值求出来作为常量嵌在最终生成的代码中,这是 Javac 编译器会对源代码做的极少量优化措施之一(代码优化几乎都在即时编译器中进行)。\n对于 String str3 = \u0026quot;str\u0026quot; + \u0026quot;ing\u0026quot;; 编译器会给你优化成 String str3 = \u0026quot;string\u0026quot;; 。\n并不是所有的常量都会进行折叠,只有编译器在程序编译期就可以确定值的常量才可以:\n基本数据类型( byte、boolean、short、char、int、float、long、double)以及字符串常量。 final 修饰的基本数据类型和字符串变量 字符串通过 “+”拼接得到的字符串、基本数据类型之间算数运算(加减乘除)、基本数据类型的位运算(\u0026laquo;、\u0026gt;\u0026gt;、\u0026gt;\u0026raquo; ) 引用的值在程序编译期是无法确定的,编译器无法对其进行优化。\n对象引用和“+”的字符串拼接方式,实际上是通过 StringBuilder 调用 append() 方法实现的,拼接完成之后调用 toString() 得到一个 String 对象 。\nString str4 = new StringBuilder().append(str1).append(str2).toString(); 我们在平时写代码的时候,尽量避免多个字符串对象拼接,因为这样会重新创建对象。如果需要改变字符串的话,可以使用 StringBuilder 或者 StringBuffer。\n不过,字符串使用 final 关键字声明之后,可以让编译器当做常量来处理。\n示例代码:\nfinal String str1 = \u0026#34;str\u0026#34;; final String str2 = \u0026#34;ing\u0026#34;; // 下面两个表达式其实是等价的 String c = \u0026#34;str\u0026#34; + \u0026#34;ing\u0026#34;;// 常量池中的对象 String d = str1 + str2; // 常量池中的对象 System.out.println(c == d);// true 被 final 关键字修饰之后的 String 会被编译器当做常量来处理,编译器在程序编译期就可以确定它的值,其效果就相当于访问常量。\n如果 ,编译器在运行时才能知道其确切值的话,就无法对其优化。\n示例代码(str2 在运行时才能确定其值):\nfinal String str1 = \u0026#34;str\u0026#34;; final String str2 = getStr(); String c = \u0026#34;str\u0026#34; + \u0026#34;ing\u0026#34;;// 常量池中的对象 String d = str1 + str2; // 在堆上创建的新的对象 System.out.println(c == d);// false public static String getStr() { return \u0026#34;ing\u0026#34;; } 参考 # 深入解析 String#intern: https://tech.meituan.com/2014/03/06/in-depth-understanding-string-intern.html Java String 源码解读: http://keaper.cn/2020/09/08/java-string-mian-mian-guan/ R 大(RednaxelaFX)关于常量折叠的回答: https://www.zhihu.com/question/55976094/answer/147302764 "},{"id":519,"href":"/zh/docs/technology/Interview/java/collection/java-collection-questions-01/","title":"Java集合常见面试题总结(上)","section":"Collection","content":" 集合概述 # Java 集合概览 # Java 集合,也叫作容器,主要是由两大接口派生而来:一个是 Collection接口,主要用于存放单一元素;另一个是 Map 接口,主要用于存放键值对。对于Collection 接口,下面又有三个主要的子接口:List、Set 、 Queue。\nJava 集合框架如下图所示:\n注:图中只列举了主要的继承派生关系,并没有列举所有关系。比方省略了AbstractList, NavigableSet等抽象类以及其他的一些辅助类,如想深入了解,可自行查看源码。\n说说 List, Set, Queue, Map 四者的区别? # List(对付顺序的好帮手): 存储的元素是有序的、可重复的。 Set(注重独一无二的性质): 存储的元素不可重复的。 Queue(实现排队功能的叫号机): 按特定的排队规则来确定先后顺序,存储的元素是有序的、可重复的。 Map(用 key 来搜索的专家): 使用键值对(key-value)存储,类似于数学上的函数 y=f(x),\u0026ldquo;x\u0026rdquo; 代表 key,\u0026ldquo;y\u0026rdquo; 代表 value,key 是无序的、不可重复的,value 是无序的、可重复的,每个键最多映射到一个值。 集合框架底层数据结构总结 # 先来看一下 Collection 接口下面的集合。\nList # ArrayList:Object[] 数组。详细可以查看: ArrayList 源码分析。 Vector:Object[] 数组。 LinkedList:双向链表(JDK1.6 之前为循环链表,JDK1.7 取消了循环)。详细可以查看: LinkedList 源码分析。 Set # HashSet(无序,唯一): 基于 HashMap 实现的,底层采用 HashMap 来保存元素。 LinkedHashSet: LinkedHashSet 是 HashSet 的子类,并且其内部是通过 LinkedHashMap 来实现的。 TreeSet(有序,唯一): 红黑树(自平衡的排序二叉树)。 Queue # PriorityQueue: Object[] 数组来实现小顶堆。详细可以查看: PriorityQueue 源码分析。 DelayQueue:PriorityQueue。详细可以查看: DelayQueue 源码分析。 ArrayDeque: 可扩容动态双向数组。 再来看看 Map 接口下面的集合。\nMap # HashMap:JDK1.8 之前 HashMap 由数组+链表组成的,数组是 HashMap 的主体,链表则是主要为了解决哈希冲突而存在的(“拉链法”解决冲突)。JDK1.8 以后在解决哈希冲突时有了较大的变化,当链表长度大于阈值(默认为 8)(将链表转换成红黑树前会判断,如果当前数组的长度小于 64,那么会选择先进行数组扩容,而不是转换为红黑树)时,将链表转化为红黑树,以减少搜索时间。详细可以查看: HashMap 源码分析。 LinkedHashMap:LinkedHashMap 继承自 HashMap,所以它的底层仍然是基于拉链式散列结构即由数组和链表或红黑树组成。另外,LinkedHashMap 在上面结构的基础上,增加了一条双向链表,使得上面的结构可以保持键值对的插入顺序。同时通过对链表进行相应的操作,实现了访问顺序相关逻辑。详细可以查看: LinkedHashMap 源码分析 Hashtable:数组+链表组成的,数组是 Hashtable 的主体,链表则是主要为了解决哈希冲突而存在的。 TreeMap:红黑树(自平衡的排序二叉树)。 如何选用集合? # 我们主要根据集合的特点来选择合适的集合。比如:\n我们需要根据键值获取到元素值时就选用 Map 接口下的集合,需要排序时选择 TreeMap,不需要排序时就选择 HashMap,需要保证线程安全就选用 ConcurrentHashMap。 我们只需要存放元素值时,就选择实现Collection 接口的集合,需要保证元素唯一时选择实现 Set 接口的集合比如 TreeSet 或 HashSet,不需要就选择实现 List 接口的比如 ArrayList 或 LinkedList,然后再根据实现这些接口的集合的特点来选用。 为什么要使用集合? # 当我们需要存储一组类型相同的数据时,数组是最常用且最基本的容器之一。但是,使用数组存储对象存在一些不足之处,因为在实际开发中,存储的数据类型多种多样且数量不确定。这时,Java 集合就派上用场了。与数组相比,Java 集合提供了更灵活、更有效的方法来存储多个数据对象。Java 集合框架中的各种集合类和接口可以存储不同类型和数量的对象,同时还具有多样化的操作方式。相较于数组,Java 集合的优势在于它们的大小可变、支持泛型、具有内建算法等。总的来说,Java 集合提高了数据的存储和处理灵活性,可以更好地适应现代软件开发中多样化的数据需求,并支持高质量的代码编写。\nList # ArrayList 和 Array(数组)的区别? # ArrayList 内部基于动态数组实现,比 Array(静态数组) 使用起来更加灵活:\nArrayList会根据实际存储的元素动态地扩容或缩容,而 Array 被创建之后就不能改变它的长度了。 ArrayList 允许你使用泛型来确保类型安全,Array 则不可以。 ArrayList 中只能存储对象。对于基本类型数据,需要使用其对应的包装类(如 Integer、Double 等)。Array 可以直接存储基本类型数据,也可以存储对象。 ArrayList 支持插入、删除、遍历等常见操作,并且提供了丰富的 API 操作方法,比如 add()、remove()等。Array 只是一个固定长度的数组,只能按照下标访问其中的元素,不具备动态添加、删除元素的能力。 ArrayList创建时不需要指定大小,而Array创建时必须指定大小。 下面是二者使用的简单对比:\nArray:\n// 初始化一个 String 类型的数组 String[] stringArr = new String[]{\u0026#34;hello\u0026#34;, \u0026#34;world\u0026#34;, \u0026#34;!\u0026#34;}; // 修改数组元素的值 stringArr[0] = \u0026#34;goodbye\u0026#34;; System.out.println(Arrays.toString(stringArr));// [goodbye, world, !] // 删除数组中的元素,需要手动移动后面的元素 for (int i = 0; i \u0026lt; stringArr.length - 1; i++) { stringArr[i] = stringArr[i + 1]; } stringArr[stringArr.length - 1] = null; System.out.println(Arrays.toString(stringArr));// [world, !, null] ArrayList :\n// 初始化一个 String 类型的 ArrayList ArrayList\u0026lt;String\u0026gt; stringList = new ArrayList\u0026lt;\u0026gt;(Arrays.asList(\u0026#34;hello\u0026#34;, \u0026#34;world\u0026#34;, \u0026#34;!\u0026#34;)); // 添加元素到 ArrayList 中 stringList.add(\u0026#34;goodbye\u0026#34;); System.out.println(stringList);// [hello, world, !, goodbye] // 修改 ArrayList 中的元素 stringList.set(0, \u0026#34;hi\u0026#34;); System.out.println(stringList);// [hi, world, !, goodbye] // 删除 ArrayList 中的元素 stringList.remove(0); System.out.println(stringList); // [world, !, goodbye] ArrayList 和 Vector 的区别?(了解即可) # ArrayList 是 List 的主要实现类,底层使用 Object[]存储,适用于频繁的查找工作,线程不安全 。 Vector 是 List 的古老实现类,底层使用Object[] 存储,线程安全。 Vector 和 Stack 的区别?(了解即可) # Vector 和 Stack 两者都是线程安全的,都是使用 synchronized 关键字进行同步处理。 Stack 继承自 Vector,是一个后进先出的栈,而 Vector 是一个列表。 随着 Java 并发编程的发展,Vector 和 Stack 已经被淘汰,推荐使用并发集合类(例如 ConcurrentHashMap、CopyOnWriteArrayList 等)或者手动实现线程安全的方法来提供安全的多线程操作支持。\nArrayList 可以添加 null 值吗? # ArrayList 中可以存储任何类型的对象,包括 null 值。不过,不建议向ArrayList 中添加 null 值, null 值无意义,会让代码难以维护比如忘记做判空处理就会导致空指针异常。\n示例代码:\nArrayList\u0026lt;String\u0026gt; listOfStrings = new ArrayList\u0026lt;\u0026gt;(); listOfStrings.add(null); listOfStrings.add(\u0026#34;java\u0026#34;); System.out.println(listOfStrings); 输出:\n[null, java] ArrayList 插入和删除元素的时间复杂度? # 对于插入:\n头部插入:由于需要将所有元素都依次向后移动一个位置,因此时间复杂度是 O(n)。 尾部插入:当 ArrayList 的容量未达到极限时,往列表末尾插入元素的时间复杂度是 O(1),因为它只需要在数组末尾添加一个元素即可;当容量已达到极限并且需要扩容时,则需要执行一次 O(n) 的操作将原数组复制到新的更大的数组中,然后再执行 O(1) 的操作添加元素。 指定位置插入:需要将目标位置之后的所有元素都向后移动一个位置,然后再把新元素放入指定位置。这个过程需要移动平均 n/2 个元素,因此时间复杂度为 O(n)。 对于删除:\n头部删除:由于需要将所有元素依次向前移动一个位置,因此时间复杂度是 O(n)。 尾部删除:当删除的元素位于列表末尾时,时间复杂度为 O(1)。 指定位置删除:需要将目标元素之后的所有元素向前移动一个位置以填补被删除的空白位置,因此需要移动平均 n/2 个元素,时间复杂度为 O(n)。 这里简单列举一个例子:\n// ArrayList的底层数组大小为10,此时存储了7个元素 +---+---+---+---+---+---+---+---+---+---+ | 1 | 2 | 3 | 4 | 5 | 6 | 7 | | | | +---+---+---+---+---+---+---+---+---+---+ 0 1 2 3 4 5 6 7 8 9 // 在索引为1的位置插入一个元素8,该元素后面的所有元素都要向右移动一位 +---+---+---+---+---+---+---+---+---+---+ | 1 | 8 | 2 | 3 | 4 | 5 | 6 | 7 | | | +---+---+---+---+---+---+---+---+---+---+ 0 1 2 3 4 5 6 7 8 9 // 删除索引为1的位置的元素,该元素后面的所有元素都要向左移动一位 +---+---+---+---+---+---+---+---+---+---+ | 1 | 2 | 3 | 4 | 5 | 6 | 7 | | | | +---+---+---+---+---+---+---+---+---+---+ 0 1 2 3 4 5 6 7 8 9 LinkedList 插入和删除元素的时间复杂度? # 头部插入/删除:只需要修改头结点的指针即可完成插入/删除操作,因此时间复杂度为 O(1)。 尾部插入/删除:只需要修改尾结点的指针即可完成插入/删除操作,因此时间复杂度为 O(1)。 指定位置插入/删除:需要先移动到指定位置,再修改指定节点的指针完成插入/删除,不过由于有头尾指针,可以从较近的指针出发,因此需要遍历平均 n/4 个元素,时间复杂度为 O(n)。 这里简单列举一个例子:假如我们要删除节点 9 的话,需要先遍历链表找到该节点。然后,再执行相应节点指针指向的更改,具体的源码可以参考: LinkedList 源码分析 。\nLinkedList 为什么不能实现 RandomAccess 接口? # RandomAccess 是一个标记接口,用来表明实现该接口的类支持随机访问(即可以通过索引快速访问元素)。由于 LinkedList 底层数据结构是链表,内存地址不连续,只能通过指针来定位,不支持随机快速访问,所以不能实现 RandomAccess 接口。\nArrayList 与 LinkedList 区别? # 是否保证线程安全: ArrayList 和 LinkedList 都是不同步的,也就是不保证线程安全; 底层数据结构: ArrayList 底层使用的是 Object 数组;LinkedList 底层使用的是 双向链表 数据结构(JDK1.6 之前为循环链表,JDK1.7 取消了循环。注意双向链表和双向循环链表的区别,下面有介绍到!) 插入和删除是否受元素位置的影响: ArrayList 采用数组存储,所以插入和删除元素的时间复杂度受元素位置的影响。 比如:执行add(E e)方法的时候, ArrayList 会默认在将指定的元素追加到此列表的末尾,这种情况时间复杂度就是 O(1)。但是如果要在指定位置 i 插入和删除元素的话(add(int index, E element)),时间复杂度就为 O(n)。因为在进行上述操作的时候集合中第 i 和第 i 个元素之后的(n-i)个元素都要执行向后位/向前移一位的操作。 LinkedList 采用链表存储,所以在头尾插入或者删除元素不受元素位置的影响(add(E e)、addFirst(E e)、addLast(E e)、removeFirst()、 removeLast()),时间复杂度为 O(1),如果是要在指定位置 i 插入和删除元素的话(add(int index, E element),remove(Object o),remove(int index)), 时间复杂度为 O(n) ,因为需要先移动到指定位置再插入和删除。 是否支持快速随机访问: LinkedList 不支持高效的随机元素访问,而 ArrayList(实现了 RandomAccess 接口) 支持。快速随机访问就是通过元素的序号快速获取元素对象(对应于get(int index)方法)。 内存空间占用: ArrayList 的空间浪费主要体现在在 list 列表的结尾会预留一定的容量空间,而 LinkedList 的空间花费则体现在它的每一个元素都需要消耗比 ArrayList 更多的空间(因为要存放直接后继和直接前驱以及数据)。 我们在项目中一般是不会使用到 LinkedList 的,需要用到 LinkedList 的场景几乎都可以使用 ArrayList 来代替,并且,性能通常会更好!就连 LinkedList 的作者约书亚 · 布洛克(Josh Bloch)自己都说从来不会使用 LinkedList 。\n另外,不要下意识地认为 LinkedList 作为链表就最适合元素增删的场景。我在上面也说了,LinkedList 仅仅在头尾插入或者删除元素的时候时间复杂度近似 O(1),其他情况增删元素的平均时间复杂度都是 O(n) 。\n补充内容: 双向链表和双向循环链表 # 双向链表: 包含两个指针,一个 prev 指向前一个节点,一个 next 指向后一个节点。\n双向循环链表: 最后一个节点的 next 指向 head,而 head 的 prev 指向最后一个节点,构成一个环。\n补充内容:RandomAccess 接口 # public interface RandomAccess { } 查看源码我们发现实际上 RandomAccess 接口中什么都没有定义。所以,在我看来 RandomAccess 接口不过是一个标识罢了。标识什么? 标识实现这个接口的类具有随机访问功能。\n在 binarySearch() 方法中,它要判断传入的 list 是否 RandomAccess 的实例,如果是,调用indexedBinarySearch()方法,如果不是,那么调用iteratorBinarySearch()方法\npublic static \u0026lt;T\u0026gt; int binarySearch(List\u0026lt;? extends Comparable\u0026lt;? super T\u0026gt;\u0026gt; list, T key) { if (list instanceof RandomAccess || list.size()\u0026lt;BINARYSEARCH_THRESHOLD) return Collections.indexedBinarySearch(list, key); else return Collections.iteratorBinarySearch(list, key); } ArrayList 实现了 RandomAccess 接口, 而 LinkedList 没有实现。为什么呢?我觉得还是和底层数据结构有关!ArrayList 底层是数组,而 LinkedList 底层是链表。数组天然支持随机访问,时间复杂度为 O(1),所以称为快速随机访问。链表需要遍历到特定位置才能访问特定位置的元素,时间复杂度为 O(n),所以不支持快速随机访问。ArrayList 实现了 RandomAccess 接口,就表明了他具有快速随机访问功能。 RandomAccess 接口只是标识,并不是说 ArrayList 实现 RandomAccess 接口才具有快速随机访问功能的!\n说一说 ArrayList 的扩容机制吧 # 详见笔主的这篇文章: ArrayList 扩容机制分析。\n说说集合中的 fail-fast 和 fail-safe 是什么 # 关于fail-fast引用medium中一篇文章关于fail-fast和fail-safe的说法:\nFail-fast systems are designed to immediately stop functioning upon encountering an unexpected condition. This immediate failure helps to catch errors early, making debugging more straightforward.\n快速失败的思想即针对可能发生的异常进行提前表明故障并停止运行,通过尽早的发现和停止错误,降低故障系统级联的风险。\n在java.util包下的大部分集合是不支持线程安全的,为了能够提前发现并发操作导致线程安全风险,提出通过维护一个modCount记录修改的次数,迭代期间通过比对预期修改次数expectedModCount和modCount是否一致来判断是否存在并发操作,从而实现快速失败,由此保证在避免在异常时执行非必要的复杂代码。\n对应的我们给出下面这样一段在示例,我们首先插入100个操作元素,一个线程迭代元素,一个线程删除元素,最终输出结果如愿抛出ConcurrentModificationException:\n// 使用线程安全的 CopyOnWriteArrayList 避免 ConcurrentModificationException List\u0026lt;Integer\u0026gt; list = new CopyOnWriteArrayList\u0026lt;\u0026gt;(); CountDownLatch countDownLatch = new CountDownLatch(2); // 添加元素 for (int i = 0; i \u0026lt; 100; i++) { list.add(i); } Thread t1 = new Thread(() -\u0026gt; { // 迭代元素 (注意:Integer 是不可变的,这里的 i++ 不会修改 list 中的值) for (Integer i : list) { i++; // 这行代码实际上没有修改list中的元素 } countDownLatch.countDown(); }); Thread t2 = new Thread(() -\u0026gt; { System.out.println(\u0026#34;删除元素1\u0026#34;); list.remove(Integer.valueOf(1)); // 使用 Integer.valueOf(1) 删除指定值的对象 countDownLatch.countDown(); }); t1.start(); t2.start(); countDownLatch.await(); 我们在初始化时插入了100个元素,此时对应的修改modCount次数为100,随后线程 2 在线程 1 迭代期间进行元素删除操作,此时对应的modCount就变为101。 线程 1 在随后foreach第 2 轮循环发现modCount 为101,与预期的expectedModCount(值为100因为初始化插入了元素100个)不等,判定为并发操作异常,于是便快速失败,抛出ConcurrentModificationException:\n对此我们也给出for循环底层迭代器获取下一个元素时的next方法,可以看到其内部的checkForComodification具有针对修改次数比对的逻辑:\npublic E next() { //检查是否存在并发修改 checkForComodification(); //...... //返回下一个元素 return (E) elementData[lastRet = i]; } final void checkForComodification() { //当前循环遍历次数和预期修改次数不一致时,就会抛出ConcurrentModificationException if (modCount != expectedModCount) throw new ConcurrentModificationException(); } 而fail-safe也就是安全失败的含义,它旨在即使面对意外情况也能恢复并继续运行,这使得它特别适用于不确定或者不稳定的环境:\nFail-safe systems take a different approach, aiming to recover and continue even in the face of unexpected conditions. This makes them particularly suited for uncertain or volatile environments.\n该思想常运用于并发容器,最经典的实现就是CopyOnWriteArrayList的实现,通过写时复制的思想保证在进行修改操作时复制出一份快照,基于这份快照完成添加或者删除操作后,将CopyOnWriteArrayList底层的数组引用指向这个新的数组空间,由此避免迭代时被并发修改所干扰所导致并发操作安全问题,当然这种做法也存缺点即进行遍历操作时无法获得实时结果:\n对应我们也给出CopyOnWriteArrayList实现fail-safe的核心代码,可以看到它的实现就是通过getArray获取数组引用然后通过Arrays.copyOf得到一个数组的快照,基于这个快照完成添加操作后,修改底层array变量指向的引用地址由此完成写时复制:\npublic 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; //array指向新的数组 setArray(newElements); return true; } finally { lock.unlock(); } } Set # Comparable 和 Comparator 的区别 # Comparable 接口和 Comparator 接口都是 Java 中用于排序的接口,它们在实现类对象之间比较大小、排序等方面发挥了重要作用:\nComparable 接口实际上是出自java.lang包 它有一个 compareTo(Object obj)方法用来排序 Comparator接口实际上是出自 java.util 包它有一个compare(Object obj1, Object obj2)方法用来排序 一般我们需要对一个集合使用自定义排序时,我们就要重写compareTo()方法或compare()方法,当我们需要对某一个集合实现两种排序方式,比如一个 song 对象中的歌名和歌手名分别采用一种排序方法的话,我们可以重写compareTo()方法和使用自制的Comparator方法或者以两个 Comparator 来实现歌名排序和歌星名排序,第二种代表我们只能使用两个参数版的 Collections.sort().\nComparator 定制排序 # ArrayList\u0026lt;Integer\u0026gt; arrayList = new ArrayList\u0026lt;Integer\u0026gt;(); arrayList.add(-1); arrayList.add(3); arrayList.add(3); arrayList.add(-5); arrayList.add(7); arrayList.add(4); arrayList.add(-9); arrayList.add(-7); System.out.println(\u0026#34;原始数组:\u0026#34;); System.out.println(arrayList); // void reverse(List list):反转 Collections.reverse(arrayList); System.out.println(\u0026#34;Collections.reverse(arrayList):\u0026#34;); System.out.println(arrayList); // void sort(List list),按自然排序的升序排序 Collections.sort(arrayList); System.out.println(\u0026#34;Collections.sort(arrayList):\u0026#34;); System.out.println(arrayList); // 定制排序的用法 Collections.sort(arrayList, new Comparator\u0026lt;Integer\u0026gt;() { @Override public int compare(Integer o1, Integer o2) { return o2.compareTo(o1); } }); System.out.println(\u0026#34;定制排序后:\u0026#34;); System.out.println(arrayList); Output:\n原始数组: [-1, 3, 3, -5, 7, 4, -9, -7] Collections.reverse(arrayList): [-7, -9, 4, 7, -5, 3, 3, -1] Collections.sort(arrayList): [-9, -7, -5, -1, 3, 3, 4, 7] 定制排序后: [7, 4, 3, 3, -1, -5, -7, -9] 重写 compareTo 方法实现按年龄来排序 # // person对象没有实现Comparable接口,所以必须实现,这样才不会出错,才可以使treemap中的数据按顺序排列 // 前面一个例子的String类已经默认实现了Comparable接口,详细可以查看String类的API文档,另外其他 // 像Integer类等都已经实现了Comparable接口,所以不需要另外实现了 public class Person implements Comparable\u0026lt;Person\u0026gt; { private String name; private int age; public Person(String name, int age) { super(); this.name = name; this.age = age; } public String getName() { return name; } public void setName(String name) { this.name = name; } public int getAge() { return age; } public void setAge(int age) { this.age = age; } /** * T重写compareTo方法实现按年龄来排序 */ @Override public int compareTo(Person o) { if (this.age \u0026gt; o.getAge()) { return 1; } if (this.age \u0026lt; o.getAge()) { return -1; } return 0; } } public static void main(String[] args) { TreeMap\u0026lt;Person, String\u0026gt; pdata = new TreeMap\u0026lt;Person, String\u0026gt;(); pdata.put(new Person(\u0026#34;张三\u0026#34;, 30), \u0026#34;zhangsan\u0026#34;); pdata.put(new Person(\u0026#34;李四\u0026#34;, 20), \u0026#34;lisi\u0026#34;); pdata.put(new Person(\u0026#34;王五\u0026#34;, 10), \u0026#34;wangwu\u0026#34;); pdata.put(new Person(\u0026#34;小红\u0026#34;, 5), \u0026#34;xiaohong\u0026#34;); // 得到key的值的同时得到key所对应的值 Set\u0026lt;Person\u0026gt; keys = pdata.keySet(); for (Person key : keys) { System.out.println(key.getAge() + \u0026#34;-\u0026#34; + key.getName()); } } Output:\n5-小红 10-王五 20-李四 30-张三 无序性和不可重复性的含义是什么 # 无序性不等于随机性 ,无序性是指存储的数据在底层数组中并非按照数组索引的顺序添加 ,而是根据数据的哈希值决定的。 不可重复性是指添加的元素按照 equals() 判断时 ,返回 false,需要同时重写 equals() 方法和 hashCode() 方法。 比较 HashSet、LinkedHashSet 和 TreeSet 三者的异同 # HashSet、LinkedHashSet 和 TreeSet 都是 Set 接口的实现类,都能保证元素唯一,并且都不是线程安全的。 HashSet、LinkedHashSet 和 TreeSet 的主要区别在于底层数据结构不同。HashSet 的底层数据结构是哈希表(基于 HashMap 实现)。LinkedHashSet 的底层数据结构是链表和哈希表,元素的插入和取出顺序满足 FIFO。TreeSet 底层数据结构是红黑树,元素是有序的,排序的方式有自然排序和定制排序。 底层数据结构不同又导致这三者的应用场景不同。HashSet 用于不需要保证元素插入和取出顺序的场景,LinkedHashSet 用于保证元素的插入和取出顺序满足 FIFO 的场景,TreeSet 用于支持对元素自定义排序规则的场景。 Queue # Queue 与 Deque 的区别 # Queue 是单端队列,只能从一端插入元素,另一端删除元素,实现上一般遵循 先进先出(FIFO) 规则。\nQueue 扩展了 Collection 的接口,根据 因为容量问题而导致操作失败后处理方式的不同 可以分为两类方法: 一种在操作失败后会抛出异常,另一种则会返回特殊值。\nQueue 接口 抛出异常 返回特殊值 插入队尾 add(E e) offer(E e) 删除队首 remove() poll() 查询队首元素 element() peek() Deque 是双端队列,在队列的两端均可以插入或删除元素。\nDeque 扩展了 Queue 的接口, 增加了在队首和队尾进行插入和删除的方法,同样根据失败后处理方式的不同分为两类:\nDeque 接口 抛出异常 返回特殊值 插入队首 addFirst(E e) offerFirst(E e) 插入队尾 addLast(E e) offerLast(E e) 删除队首 removeFirst() pollFirst() 删除队尾 removeLast() pollLast() 查询队首元素 getFirst() peekFirst() 查询队尾元素 getLast() peekLast() 事实上,Deque 还提供有 push() 和 pop() 等其他方法,可用于模拟栈。\nArrayDeque 与 LinkedList 的区别 # ArrayDeque 和 LinkedList 都实现了 Deque 接口,两者都具有队列的功能,但两者有什么区别呢?\nArrayDeque 是基于可变长的数组和双指针来实现,而 LinkedList 则通过链表来实现。\nArrayDeque 不支持存储 NULL 数据,但 LinkedList 支持。\nArrayDeque 是在 JDK1.6 才被引入的,而LinkedList 早在 JDK1.2 时就已经存在。\nArrayDeque 插入时可能存在扩容过程, 不过均摊后的插入操作依然为 O(1)。虽然 LinkedList 不需要扩容,但是每次插入数据时均需要申请新的堆空间,均摊性能相比更慢。\n从性能的角度上,选用 ArrayDeque 来实现队列要比 LinkedList 更好。此外,ArrayDeque 也可以用于实现栈。\n说一说 PriorityQueue # PriorityQueue 是在 JDK1.5 中被引入的, 其与 Queue 的区别在于元素出队顺序是与优先级相关的,即总是优先级最高的元素先出队。\n这里列举其相关的一些要点:\nPriorityQueue 利用了二叉堆的数据结构来实现的,底层使用可变长的数组来存储数据 PriorityQueue 通过堆元素的上浮和下沉,实现了在 O(logn) 的时间复杂度内插入元素和删除堆顶元素。 PriorityQueue 是非线程安全的,且不支持存储 NULL 和 non-comparable 的对象。 PriorityQueue 默认是小顶堆,但可以接收一个 Comparator 作为构造参数,从而来自定义元素优先级的先后。 PriorityQueue 在面试中可能更多的会出现在手撕算法的时候,典型例题包括堆排序、求第 K 大的数、带权图的遍历等,所以需要会熟练使用才行。\n什么是 BlockingQueue? # BlockingQueue (阻塞队列)是一个接口,继承自 Queue。BlockingQueue阻塞的原因是其支持当队列没有元素时一直阻塞,直到有元素;还支持如果队列已满,一直等到队列可以放入新元素时再放入。\npublic interface BlockingQueue\u0026lt;E\u0026gt; extends Queue\u0026lt;E\u0026gt; { // ... } BlockingQueue 常用于生产者-消费者模型中,生产者线程会向队列中添加数据,而消费者线程会从队列中取出数据进行处理。\nBlockingQueue 的实现类有哪些? # Java 中常用的阻塞队列实现类有以下几种:\nArrayBlockingQueue:使用数组实现的有界阻塞队列。在创建时需要指定容量大小,并支持公平和非公平两种方式的锁访问机制。 LinkedBlockingQueue:使用单向链表实现的可选有界阻塞队列。在创建时可以指定容量大小,如果不指定则默认为Integer.MAX_VALUE。和ArrayBlockingQueue不同的是, 它仅支持非公平的锁访问机制。 PriorityBlockingQueue:支持优先级排序的无界阻塞队列。元素必须实现Comparable接口或者在构造函数中传入Comparator对象,并且不能插入 null 元素。 SynchronousQueue:同步队列,是一种不存储元素的阻塞队列。每个插入操作都必须等待对应的删除操作,反之删除操作也必须等待插入操作。因此,SynchronousQueue通常用于线程之间的直接传递数据。 DelayQueue:延迟队列,其中的元素只有到了其指定的延迟时间,才能够从队列中出队。 …… 日常开发中,这些队列使用的其实都不多,了解即可。\nArrayBlockingQueue 和 LinkedBlockingQueue 有什么区别? # ArrayBlockingQueue 和 LinkedBlockingQueue 是 Java 并发包中常用的两种阻塞队列实现,它们都是线程安全的。不过,不过它们之间也存在下面这些区别:\n底层实现:ArrayBlockingQueue 基于数组实现,而 LinkedBlockingQueue 基于链表实现。 是否有界:ArrayBlockingQueue 是有界队列,必须在创建时指定容量大小。LinkedBlockingQueue 创建时可以不指定容量大小,默认是Integer.MAX_VALUE,也就是无界的。但也可以指定队列大小,从而成为有界的。 锁是否分离: ArrayBlockingQueue中的锁是没有分离的,即生产和消费用的是同一个锁;LinkedBlockingQueue中的锁是分离的,即生产用的是putLock,消费是takeLock,这样可以防止生产者和消费者线程之间的锁争夺。 内存占用:ArrayBlockingQueue 需要提前分配数组内存,而 LinkedBlockingQueue 则是动态分配链表节点内存。这意味着,ArrayBlockingQueue 在创建时就会占用一定的内存空间,且往往申请的内存比实际所用的内存更大,而LinkedBlockingQueue 则是根据元素的增加而逐渐占用内存空间。 "},{"id":520,"href":"/zh/docs/technology/Interview/java/collection/java-collection-questions-02/","title":"Java集合常见面试题总结(下)","section":"Collection","content":" Map(重要) # HashMap 和 Hashtable 的区别 # 线程是否安全: HashMap 是非线程安全的,Hashtable 是线程安全的,因为 Hashtable 内部的方法基本都经过synchronized 修饰。(如果你要保证线程安全的话就使用 ConcurrentHashMap 吧!); 效率: 因为线程安全的问题,HashMap 要比 Hashtable 效率高一点。另外,Hashtable 基本被淘汰,不要在代码中使用它; 对 Null key 和 Null value 的支持: HashMap 可以存储 null 的 key 和 value,但 null 作为键只能有一个,null 作为值可以有多个;Hashtable 不允许有 null 键和 null 值,否则会抛出 NullPointerException。 初始容量大小和每次扩充容量大小的不同: ① 创建时如果不指定容量初始值,Hashtable 默认的初始大小为 11,之后每次扩充,容量变为原来的 2n+1。HashMap 默认的初始化大小为 16。之后每次扩充,容量变为原来的 2 倍。② 创建时如果给定了容量初始值,那么 Hashtable 会直接使用你给定的大小,而 HashMap 会将其扩充为 2 的幂次方大小(HashMap 中的tableSizeFor()方法保证,下面给出了源代码)。也就是说 HashMap 总是使用 2 的幂作为哈希表的大小,后面会介绍到为什么是 2 的幂次方。 底层数据结构: JDK1.8 以后的 HashMap 在解决哈希冲突时有了较大的变化,当链表长度大于阈值(默认为 8)时,将链表转化为红黑树(将链表转换成红黑树前会判断,如果当前数组的长度小于 64,那么会选择先进行数组扩容,而不是转换为红黑树),以减少搜索时间(后文中我会结合源码对这一过程进行分析)。Hashtable 没有这样的机制。 哈希函数的实现:HashMap 对哈希值进行了高位和低位的混合扰动处理以减少冲突,而 Hashtable 直接使用键的 hashCode() 值。 HashMap 中带有初始容量的构造函数:\npublic HashMap(int initialCapacity, float loadFactor) { if (initialCapacity \u0026lt; 0) throw new IllegalArgumentException(\u0026#34;Illegal initial capacity: \u0026#34; + initialCapacity); if (initialCapacity \u0026gt; MAXIMUM_CAPACITY) initialCapacity = MAXIMUM_CAPACITY; if (loadFactor \u0026lt;= 0 || Float.isNaN(loadFactor)) throw new IllegalArgumentException(\u0026#34;Illegal load factor: \u0026#34; + loadFactor); this.loadFactor = loadFactor; this.threshold = tableSizeFor(initialCapacity); } public HashMap(int initialCapacity) { this(initialCapacity, DEFAULT_LOAD_FACTOR); } 下面这个方法保证了 HashMap 总是使用 2 的幂作为哈希表的大小。\n/** * Returns a power of two size for the given target capacity. */ static final int tableSizeFor(int cap) { int n = cap - 1; n |= n \u0026gt;\u0026gt;\u0026gt; 1; n |= n \u0026gt;\u0026gt;\u0026gt; 2; n |= n \u0026gt;\u0026gt;\u0026gt; 4; n |= n \u0026gt;\u0026gt;\u0026gt; 8; n |= n \u0026gt;\u0026gt;\u0026gt; 16; return (n \u0026lt; 0) ? 1 : (n \u0026gt;= MAXIMUM_CAPACITY) ? MAXIMUM_CAPACITY : n + 1; } HashMap 和 HashSet 区别 # 如果你看过 HashSet 源码的话就应该知道:HashSet 底层就是基于 HashMap 实现的。(HashSet 的源码非常非常少,因为除了 clone()、writeObject()、readObject()是 HashSet 自己不得不实现之外,其他方法都是直接调用 HashMap 中的方法。\nHashMap HashSet 实现了 Map 接口 实现 Set 接口 存储键值对 仅存储对象 调用 put()向 map 中添加元素 调用 add()方法向 Set 中添加元素 HashMap 使用键(Key)计算 hashcode HashSet 使用成员对象来计算 hashcode 值,对于两个对象来说 hashcode 可能相同,所以equals()方法用来判断对象的相等性 HashMap 和 TreeMap 区别 # TreeMap 和HashMap 都继承自AbstractMap ,但是需要注意的是TreeMap它还实现了NavigableMap接口和SortedMap 接口。\n实现 NavigableMap 接口让 TreeMap 有了对集合内元素的搜索的能力。\nNavigableMap 接口提供了丰富的方法来探索和操作键值对:\n定向搜索: ceilingEntry(), floorEntry(), higherEntry()和 lowerEntry() 等方法可以用于定位大于等于、小于等于、严格大于、严格小于给定键的最接近的键值对。 子集操作: subMap(), headMap()和 tailMap() 方法可以高效地创建原集合的子集视图,而无需复制整个集合。 逆序视图:descendingMap() 方法返回一个逆序的 NavigableMap 视图,使得可以反向迭代整个 TreeMap。 边界操作: firstEntry(), lastEntry(), pollFirstEntry()和 pollLastEntry() 等方法可以方便地访问和移除元素。 这些方法都是基于红黑树数据结构的属性实现的,红黑树保持平衡状态,从而保证了搜索操作的时间复杂度为 O(log n),这让 TreeMap 成为了处理有序集合搜索问题的强大工具。\n实现SortedMap接口让 TreeMap 有了对集合中的元素根据键排序的能力。默认是按 key 的升序排序,不过我们也可以指定排序的比较器。示例代码如下:\n/** * @author shuang.kou * @createTime 2020年06月15日 17:02:00 */ public class Person { private Integer age; public Person(Integer age) { this.age = age; } public Integer getAge() { return age; } public static void main(String[] args) { TreeMap\u0026lt;Person, String\u0026gt; treeMap = new TreeMap\u0026lt;\u0026gt;(new Comparator\u0026lt;Person\u0026gt;() { @Override public int compare(Person person1, Person person2) { int num = person1.getAge() - person2.getAge(); return Integer.compare(num, 0); } }); treeMap.put(new Person(3), \u0026#34;person1\u0026#34;); treeMap.put(new Person(18), \u0026#34;person2\u0026#34;); treeMap.put(new Person(35), \u0026#34;person3\u0026#34;); treeMap.put(new Person(16), \u0026#34;person4\u0026#34;); treeMap.entrySet().stream().forEach(personStringEntry -\u0026gt; { System.out.println(personStringEntry.getValue()); }); } } 输出:\nperson1 person4 person2 person3 可以看出,TreeMap 中的元素已经是按照 Person 的 age 字段的升序来排列了。\n上面,我们是通过传入匿名内部类的方式实现的,你可以将代码替换成 Lambda 表达式实现的方式:\nTreeMap\u0026lt;Person, String\u0026gt; treeMap = new TreeMap\u0026lt;\u0026gt;((person1, person2) -\u0026gt; { int num = person1.getAge() - person2.getAge(); return Integer.compare(num, 0); }); 综上,相比于HashMap来说, TreeMap 主要多了对集合中的元素根据键排序的能力以及对集合内元素的搜索的能力。\nHashSet 如何检查重复? # 以下内容摘自我的 Java 启蒙书《Head first java》第二版:\n当你把对象加入HashSet时,HashSet 会先计算对象的hashcode值来判断对象加入的位置,同时也会与其他加入的对象的 hashcode 值作比较,如果没有相符的 hashcode,HashSet 会假设对象没有重复出现。但是如果发现有相同 hashcode 值的对象,这时会调用equals()方法来检查 hashcode 相等的对象是否真的相同。如果两者相同,HashSet 就不会让加入操作成功。\n在 JDK1.8 中,HashSet的add()方法只是简单的调用了HashMap的put()方法,并且判断了一下返回值以确保是否有重复元素。直接看一下HashSet中的源码:\n// Returns: true if this set did not already contain the specified element // 返回值:当 set 中没有包含 add 的元素时返回真 public boolean add(E e) { return map.put(e, PRESENT)==null; } 而在HashMap的putVal()方法中也能看到如下说明:\n// Returns : previous value, or null if none // 返回值:如果插入位置没有元素返回null,否则返回上一个元素 final V putVal(int hash, K key, V value, boolean onlyIfAbsent, boolean evict) { ... } 也就是说,在 JDK1.8 中,实际上无论HashSet中是否已经存在了某元素,HashSet都会直接插入,只是会在add()方法的返回值处告诉我们插入前是否存在相同元素。\nHashMap 的底层实现 # JDK1.8 之前 # JDK1.8 之前 HashMap 底层是 数组和链表 结合在一起使用也就是 链表散列。HashMap 通过 key 的 hashcode 经过扰动函数处理过后得到 hash 值,然后通过 (n - 1) \u0026amp; hash 判断当前元素存放的位置(这里的 n 指的是数组的长度),如果当前位置存在元素的话,就判断该元素与要存入的元素的 hash 值以及 key 是否相同,如果相同的话,直接覆盖,不相同就通过拉链法解决冲突。\nHashMap 中的扰动函数(hash 方法)是用来优化哈希值的分布。通过对原始的 hashCode() 进行额外处理,扰动函数可以减小由于糟糕的 hashCode() 实现导致的碰撞,从而提高数据的分布均匀性。\nJDK 1.8 HashMap 的 hash 方法源码:\nJDK 1.8 的 hash 方法 相比于 JDK 1.7 hash 方法更加简化,但是原理不变。\nstatic final int hash(Object key) { int h; // key.hashCode():返回散列值也就是hashcode // ^:按位异或 // \u0026gt;\u0026gt;\u0026gt;:无符号右移,忽略符号位,空位都以0补齐 return (key == null) ? 0 : (h = key.hashCode()) ^ (h \u0026gt;\u0026gt;\u0026gt; 16); } 对比一下 JDK1.7 的 HashMap 的 hash 方法源码.\nstatic int hash(int h) { // This function ensures that hashCodes that differ only by // constant multiples at each bit position have a bounded // number of collisions (approximately 8 at default load factor). h ^= (h \u0026gt;\u0026gt;\u0026gt; 20) ^ (h \u0026gt;\u0026gt;\u0026gt; 12); return h ^ (h \u0026gt;\u0026gt;\u0026gt; 7) ^ (h \u0026gt;\u0026gt;\u0026gt; 4); } 相比于 JDK1.8 的 hash 方法 ,JDK 1.7 的 hash 方法的性能会稍差一点点,因为毕竟扰动了 4 次。\n所谓 “拉链法” 就是:将链表和数组相结合。也就是说创建一个链表数组,数组中每一格就是一个链表。若遇到哈希冲突,则将冲突的值加到链表中即可。\nJDK1.8 之后 # 相比于之前的版本, JDK1.8 之后在解决哈希冲突时有了较大的变化,当链表长度大于阈值(默认为 8)(将链表转换成红黑树前会判断,如果当前数组的长度小于 64,那么会选择先进行数组扩容,而不是转换为红黑树)时,将链表转化为红黑树。\n这样做的目的是减少搜索时间:链表的查询效率为 O(n)(n 是链表的长度),红黑树是一种自平衡二叉搜索树,其查询效率为 O(log n)。当链表较短时,O(n) 和 O(log n) 的性能差异不明显。但当链表变长时,查询性能会显著下降。\n为什么优先扩容而非直接转为红黑树?\n数组扩容能减少哈希冲突的发生概率(即将元素重新分散到新的、更大的数组中),这在多数情况下比直接转换为红黑树更高效。\n红黑树需要保持自平衡,维护成本较高。并且,过早引入红黑树反而会增加复杂度。\n为什么选择阈值 8 和 64?\n泊松分布表明,链表长度达到 8 的概率极低(小于千万分之一)。在绝大多数情况下,链表长度都不会超过 8。阈值设置为 8,可以保证性能和空间效率的平衡。 数组长度阈值 64 同样是经过实践验证的经验值。在小数组中扩容成本低,优先扩容可以避免过早引入红黑树。数组大小达到 64 时,冲突概率较高,此时红黑树的性能优势开始显现。 TreeMap、TreeSet 以及 JDK1.8 之后的 HashMap 底层都用到了红黑树。红黑树就是为了解决二叉查找树的缺陷,因为二叉查找树在某些情况下会退化成一个线性结构。\n我们来结合源码分析一下 HashMap 链表到红黑树的转换。\n1、 putVal 方法中执行链表转红黑树的判断逻辑。\n链表的长度大于 8 的时候,就执行 treeifyBin (转换红黑树)的逻辑。\n// 遍历链表 for (int binCount = 0; ; ++binCount) { // 遍历到链表最后一个节点 if ((e = p.next) == null) { p.next = newNode(hash, key, value, null); // 如果链表元素个数大于TREEIFY_THRESHOLD(8) if (binCount \u0026gt;= TREEIFY_THRESHOLD - 1) // -1 for 1st // 红黑树转换(并不会直接转换成红黑树) treeifyBin(tab, hash); break; } if (e.hash == hash \u0026amp;\u0026amp; ((k = e.key) == key || (key != null \u0026amp;\u0026amp; key.equals(k)))) break; p = e; } 2、treeifyBin 方法中判断是否真的转换为红黑树。\nfinal void treeifyBin(Node\u0026lt;K,V\u0026gt;[] tab, int hash) { int n, index; Node\u0026lt;K,V\u0026gt; e; // 判断当前数组的长度是否小于 64 if (tab == null || (n = tab.length) \u0026lt; MIN_TREEIFY_CAPACITY) // 如果当前数组的长度小于 64,那么会选择先进行数组扩容 resize(); else if ((e = tab[index = (n - 1) \u0026amp; hash]) != null) { // 否则才将列表转换为红黑树 TreeNode\u0026lt;K,V\u0026gt; hd = null, tl = null; do { TreeNode\u0026lt;K,V\u0026gt; p = replacementTreeNode(e, null); if (tl == null) hd = p; else { p.prev = tl; tl.next = p; } tl = p; } while ((e = e.next) != null); if ((tab[index] = hd) != null) hd.treeify(tab); } } 将链表转换成红黑树前会判断,如果当前数组的长度小于 64,那么会选择先进行数组扩容,而不是转换为红黑树。\nHashMap 的长度为什么是 2 的幂次方 # 为了让 HashMap 存取高效并减少碰撞,我们需要确保数据尽量均匀分布。哈希值在 Java 中通常使用 int 表示,其范围是 -2147483648 ~ 2147483647前后加起来大概 40 亿的映射空间,只要哈希函数映射得比较均匀松散,一般应用是很难出现碰撞的。但是,问题是一个 40 亿长度的数组,内存是放不下的。所以,这个散列值是不能直接拿来用的。用之前还要先做对数组的长度取模运算,得到的余数才能用来要存放的位置也就是对应的数组下标。\n这个算法应该如何设计呢?\n我们首先可能会想到采用 % 取余的操作来实现。但是,重点来了:“取余(%)操作中如果除数是 2 的幂次则等价于与其除数减一的与(\u0026amp;)操作(也就是说 hash%length==hash\u0026amp;(length-1) 的前提是 length 是 2 的 n 次方)。” 并且,采用二进制位操作 \u0026amp; 相对于 % 能够提高运算效率。\n除了上面所说的位运算比取余效率高之外,我觉得更重要的一个原因是:长度是 2 的幂次方,可以让 HashMap 在扩容的时候更均匀。例如:\nlength = 8 时,length - 1 = 7 的二进制位0111 length = 16 时,length - 1 = 15 的二进制位1111 这时候原本存在 HashMap 中的元素计算新的数组位置时 hash\u0026amp;(length-1),取决 hash 的第四个二进制位(从右数),会出现两种情况:\n第四个二进制位为 0,数组位置不变,也就是说当前元素在新数组和旧数组的位置相同。 第四个二进制位为 1,数组位置在新数组扩容之后的那一部分。 这里列举一个例子:\n假设有一个元素的哈希值为 10101100 旧数组元素位置计算: hash = 10101100 length - 1 = 00000111 \u0026amp; ----------------- index = 00000100 (4) 新数组元素位置计算: hash = 10101100 length - 1 = 00001111 \u0026amp; ----------------- index = 00001100 (12) 看第四位(从右数): 1.高位为 0:位置不变。 2.高位为 1:移动到新位置(原索引位置+原容量)。 ⚠️注意:这里列举的场景看的是第四个二进制位,更准确点来说看的是高位(从右数),例如 length = 32 时,length - 1 = 31,二进制为 11111,这里看的就是第五个二进制位。\n也就是说扩容之后,在旧数组元素 hash 值比较均匀(至于 hash 值均不均匀,取决于前面讲的对象的 hashcode() 方法和扰动函数)的情况下,新数组元素也会被分配的比较均匀,最好的情况是会有一半在新数组的前半部分,一半在新数组后半部分。\n这样也使得扩容机制变得简单和高效,扩容后只需检查哈希值高位的变化来决定元素的新位置,要么位置不变(高位为 0),要么就是移动到新位置(高位为 1,原索引位置+原容量)。\n最后,简单总结一下 HashMap 的长度是 2 的幂次方的原因:\n位运算效率更高:位运算(\u0026amp;)比取余运算(%)更高效。当长度为 2 的幂次方时,hash % length 等价于 hash \u0026amp; (length - 1)。 可以更好地保证哈希值的均匀分布:扩容之后,在旧数组元素 hash 值比较均匀的情况下,新数组元素也会被分配的比较均匀,最好的情况是会有一半在新数组的前半部分,一半在新数组后半部分。 扩容机制变得简单和高效:扩容后只需检查哈希值高位的变化来决定元素的新位置,要么位置不变(高位为 0),要么就是移动到新位置(高位为 1,原索引位置+原容量)。 HashMap 多线程操作导致死循环问题 # JDK1.7 及之前版本的 HashMap 在多线程环境下扩容操作可能存在死循环问题,这是由于当一个桶位中有多个元素需要进行扩容时,多个线程同时对链表进行操作,头插法可能会导致链表中的节点指向错误的位置,从而形成一个环形链表,进而使得查询元素的操作陷入死循环无法结束。\n为了解决这个问题,JDK1.8 版本的 HashMap 采用了尾插法而不是头插法来避免链表倒置,使得插入的节点永远都是放在链表的末尾,避免了链表中的环形结构。但是还是不建议在多线程下使用 HashMap,因为多线程下使用 HashMap 还是会存在数据覆盖的问题。并发环境下,推荐使用 ConcurrentHashMap 。\n一般面试中这样介绍就差不多,不需要记各种细节,个人觉得也没必要记。如果想要详细了解 HashMap 扩容导致死循环问题,可以看看耗子叔的这篇文章: Java HashMap 的死循环。\nHashMap 为什么线程不安全? # JDK1.7 及之前版本,在多线程环境下,HashMap 扩容时会造成死循环和数据丢失的问题。\n数据丢失这个在 JDK1.7 和 JDK 1.8 中都存在,这里以 JDK 1.8 为例进行介绍。\nJDK 1.8 后,在 HashMap 中,多个键值对可能会被分配到同一个桶(bucket),并以链表或红黑树的形式存储。多个线程对 HashMap 的 put 操作会导致线程不安全,具体来说会有数据覆盖的风险。\n举个例子:\n两个线程 1,2 同时进行 put 操作,并且发生了哈希冲突(hash 函数计算出的插入下标是相同的)。 不同的线程可能在不同的时间片获得 CPU 执行的机会,当前线程 1 执行完哈希冲突判断后,由于时间片耗尽挂起。线程 2 先完成了插入操作。 随后,线程 1 获得时间片,由于之前已经进行过 hash 碰撞的判断,所有此时会直接进行插入,这就导致线程 2 插入的数据被线程 1 覆盖了。 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) { // ... // 判断是否出现 hash 碰撞 // (n - 1) \u0026amp; hash 确定元素存放在哪个桶中,桶为空,新生成结点放入桶中(此时,这个结点是放在数组中) if ((p = tab[i = (n - 1) \u0026amp; hash]) == null) tab[i] = newNode(hash, key, value, null); // 桶中已经存在元素(处理hash冲突) else { // ... } 还有一种情况是这两个线程同时 put 操作导致 size 的值不正确,进而导致数据覆盖的问题:\n线程 1 执行 if(++size \u0026gt; threshold) 判断时,假设获得 size 的值为 10,由于时间片耗尽挂起。 线程 2 也执行 if(++size \u0026gt; threshold) 判断,获得 size 的值也为 10,并将元素插入到该桶位中,并将 size 的值更新为 11。 随后,线程 1 获得时间片,它也将元素放入桶位中,并将 size 的值更新为 11。 线程 1、2 都执行了一次 put 操作,但是 size 的值只增加了 1,也就导致实际上只有一个元素被添加到了 HashMap 中。 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) { // ... // 实际大小大于阈值则扩容 if (++size \u0026gt; threshold) resize(); // 插入后回调 afterNodeInsertion(evict); return null; } HashMap 常见的遍历方式? # HashMap 的 7 种遍历方式与性能分析!\n🐛 修正(参见: issue#1411):\n这篇文章对于 parallelStream 遍历方式的性能分析有误,先说结论:存在阻塞时 parallelStream 性能最高, 非阻塞时 parallelStream 性能最低 。\n当遍历不存在阻塞时, parallelStream 的性能是最低的:\nBenchmark Mode Cnt Score Error Units Test.entrySet avgt 5 288.651 ± 10.536 ns/op Test.keySet avgt 5 584.594 ± 21.431 ns/op Test.lambda avgt 5 221.791 ± 10.198 ns/op Test.parallelStream avgt 5 6919.163 ± 1116.139 ns/op 加入阻塞代码Thread.sleep(10)后, parallelStream 的性能才是最高的:\nBenchmark Mode Cnt Score Error Units Test.entrySet avgt 5 1554828440.000 ± 23657748.653 ns/op Test.keySet avgt 5 1550612500.000 ± 6474562.858 ns/op Test.lambda avgt 5 1551065180.000 ± 19164407.426 ns/op Test.parallelStream avgt 5 186345456.667 ± 3210435.590 ns/op ConcurrentHashMap 和 Hashtable 的区别 # ConcurrentHashMap 和 Hashtable 的区别主要体现在实现线程安全的方式上不同。\n底层数据结构: JDK1.7 的 ConcurrentHashMap 底层采用 分段的数组+链表 实现,JDK1.8 采用的数据结构跟 HashMap1.8 的结构一样,数组+链表/红黑二叉树。Hashtable 和 JDK1.8 之前的 HashMap 的底层数据结构类似都是采用 数组+链表 的形式,数组是 HashMap 的主体,链表则是主要为了解决哈希冲突而存在的; 实现线程安全的方式(重要): 在 JDK1.7 的时候,ConcurrentHashMap 对整个桶数组进行了分割分段(Segment,分段锁),每一把锁只锁容器其中一部分数据(下面有示意图),多线程访问容器里不同数据段的数据,就不会存在锁竞争,提高并发访问率。 到了 JDK1.8 的时候,ConcurrentHashMap 已经摒弃了 Segment 的概念,而是直接用 Node 数组+链表+红黑树的数据结构来实现,并发控制使用 synchronized 和 CAS 来操作。(JDK1.6 以后 synchronized 锁做了很多优化) 整个看起来就像是优化过且线程安全的 HashMap,虽然在 JDK1.8 中还能看到 Segment 的数据结构,但是已经简化了属性,只是为了兼容旧版本; Hashtable(同一把锁) :使用 synchronized 来保证线程安全,效率非常低下。当一个线程访问同步方法时,其他线程也访问同步方法,可能会进入阻塞或轮询状态,如使用 put 添加元素,另一个线程不能使用 put 添加元素,也不能使用 get,竞争会越来越激烈效率越低。 下面,我们再来看看两者底层数据结构的对比图。\nHashtable :\nhttps://www.cnblogs.com/chengxiao/p/6842045.html\u003e\nJDK1.7 的 ConcurrentHashMap:\nConcurrentHashMap 是由 Segment 数组结构和 HashEntry 数组结构组成。\nSegment 数组中的每个元素包含一个 HashEntry 数组,每个 HashEntry 数组属于链表结构。\nJDK1.8 的 ConcurrentHashMap:\nJDK1.8 的 ConcurrentHashMap 不再是 Segment 数组 + HashEntry 数组 + 链表,而是 Node 数组 + 链表 / 红黑树。不过,Node 只能用于链表的情况,红黑树的情况需要使用 TreeNode。当冲突链表达到一定长度时,链表会转换成红黑树。\nTreeNode是存储红黑树节点,被TreeBin包装。TreeBin通过root属性维护红黑树的根结点,因为红黑树在旋转的时候,根结点可能会被它原来的子节点替换掉,在这个时间点,如果有其他线程要写这棵红黑树就会发生线程不安全问题,所以在 ConcurrentHashMap 中TreeBin通过waiter属性维护当前使用这棵红黑树的线程,来防止其他线程的进入。\nstatic final class TreeBin\u0026lt;K,V\u0026gt; extends Node\u0026lt;K,V\u0026gt; { TreeNode\u0026lt;K,V\u0026gt; root; volatile TreeNode\u0026lt;K,V\u0026gt; first; volatile Thread waiter; volatile int lockState; // values for lockState static final int WRITER = 1; // set while holding write lock static final int WAITER = 2; // set when waiting for write lock static final int READER = 4; // increment value for setting read lock ... } ConcurrentHashMap 线程安全的具体实现方式/底层具体实现 # JDK1.8 之前 # 首先将数据分为一段一段(这个“段”就是 Segment)的存储,然后给每一段数据配一把锁,当一个线程占用锁访问其中一个段数据时,其他段的数据也能被其他线程访问。\nConcurrentHashMap 是由 Segment 数组结构和 HashEntry 数组结构组成。\nSegment 继承了 ReentrantLock,所以 Segment 是一种可重入锁,扮演锁的角色。HashEntry 用于存储键值对数据。\nstatic class Segment\u0026lt;K,V\u0026gt; extends ReentrantLock implements Serializable { } 一个 ConcurrentHashMap 里包含一个 Segment 数组,Segment 的个数一旦初始化就不能改变。 Segment 数组的大小默认是 16,也就是说默认可以同时支持 16 个线程并发写。\nSegment 的结构和 HashMap 类似,是一种数组和链表结构,一个 Segment 包含一个 HashEntry 数组,每个 HashEntry 是一个链表结构的元素,每个 Segment 守护着一个 HashEntry 数组里的元素,当对 HashEntry 数组的数据进行修改时,必须首先获得对应的 Segment 的锁。也就是说,对同一 Segment 的并发写入会被阻塞,不同 Segment 的写入是可以并发执行的。\nJDK1.8 之后 # Java 8 几乎完全重写了 ConcurrentHashMap,代码量从原来 Java 7 中的 1000 多行,变成了现在的 6000 多行。\nConcurrentHashMap 取消了 Segment 分段锁,采用 Node + CAS + synchronized 来保证并发安全。数据结构跟 HashMap 1.8 的结构类似,数组+链表/红黑二叉树。Java 8 在链表长度超过一定阈值(8)时将链表(寻址时间复杂度为 O(N))转换为红黑树(寻址时间复杂度为 O(log(N)))。\nJava 8 中,锁粒度更细,synchronized 只锁定当前链表或红黑二叉树的首节点,这样只要 hash 不冲突,就不会产生并发,就不会影响其他 Node 的读写,效率大幅提升。\nJDK 1.7 和 JDK 1.8 的 ConcurrentHashMap 实现有什么不同? # 线程安全实现方式:JDK 1.7 采用 Segment 分段锁来保证安全, Segment 是继承自 ReentrantLock。JDK1.8 放弃了 Segment 分段锁的设计,采用 Node + CAS + synchronized 保证线程安全,锁粒度更细,synchronized 只锁定当前链表或红黑二叉树的首节点。 Hash 碰撞解决方法 : JDK 1.7 采用拉链法,JDK1.8 采用拉链法结合红黑树(链表长度超过一定阈值时,将链表转换为红黑树)。 并发度:JDK 1.7 最大并发度是 Segment 的个数,默认是 16。JDK 1.8 最大并发度是 Node 数组的大小,并发度更大。 ConcurrentHashMap 为什么 key 和 value 不能为 null? # ConcurrentHashMap 的 key 和 value 不能为 null 主要是为了避免二义性。null 是一个特殊的值,表示没有对象或没有引用。如果你用 null 作为键,那么你就无法区分这个键是否存在于 ConcurrentHashMap 中,还是根本没有这个键。同样,如果你用 null 作为值,那么你就无法区分这个值是否是真正存储在 ConcurrentHashMap 中的,还是因为找不到对应的键而返回的。\n拿 get 方法取值来说,返回的结果为 null 存在两种情况:\n值没有在集合中 ; 值本身就是 null。 这也就是二义性的由来。\n具体可以参考 ConcurrentHashMap 源码分析 。\n多线程环境下,存在一个线程操作该 ConcurrentHashMap 时,其他的线程将该 ConcurrentHashMap 修改的情况,所以无法通过 containsKey(key) 来判断否存在这个键值对,也就没办法解决二义性问题了。\n与此形成对比的是,HashMap 可以存储 null 的 key 和 value,但 null 作为键只能有一个,null 作为值可以有多个。如果传入 null 作为参数,就会返回 hash 值为 0 的位置的值。单线程环境下,不存在一个线程操作该 HashMap 时,其他的线程将该 HashMap 修改的情况,所以可以通过 contains(key)来做判断是否存在这个键值对,从而做相应的处理,也就不存在二义性问题。\n也就是说,多线程下无法正确判定键值对是否存在(存在其他线程修改的情况),单线程是可以的(不存在其他线程修改的情况)。\n如果你确实需要在 ConcurrentHashMap 中使用 null 的话,可以使用一个特殊的静态空对象来代替 null。\npublic static final Object NULL = new Object(); 最后,再分享一下 ConcurrentHashMap 作者本人 (Doug Lea)对于这个问题的回答:\nThe main reason that nulls aren\u0026rsquo;t allowed in ConcurrentMaps (ConcurrentHashMaps, ConcurrentSkipListMaps) is that ambiguities that may be just barely tolerable in non-concurrent maps can\u0026rsquo;t be accommodated. The main one is that if map.get(key) returns null, you can\u0026rsquo;t detect whether the key explicitly maps to null vs the key isn\u0026rsquo;t mapped. In a non-concurrent map, you can check this via map.contains(key), but in a concurrent one, the map might have changed between calls.\n翻译过来之后的,大致意思还是单线程下可以容忍歧义,而多线程下无法容忍。\nConcurrentHashMap 能保证复合操作的原子性吗? # ConcurrentHashMap 是线程安全的,意味着它可以保证多个线程同时对它进行读写操作时,不会出现数据不一致的情况,也不会导致 JDK1.7 及之前版本的 HashMap 多线程操作导致死循环问题。但是,这并不意味着它可以保证所有的复合操作都是原子性的,一定不要搞混了!\n复合操作是指由多个基本操作(如put、get、remove、containsKey等)组成的操作,例如先判断某个键是否存在containsKey(key),然后根据结果进行插入或更新put(key, value)。这种操作在执行过程中可能会被其他线程打断,导致结果不符合预期。\n例如,有两个线程 A 和 B 同时对 ConcurrentHashMap 进行复合操作,如下:\n// 线程 A if (!map.containsKey(key)) { map.put(key, value); } // 线程 B if (!map.containsKey(key)) { map.put(key, anotherValue); } 如果线程 A 和 B 的执行顺序是这样:\n线程 A 判断 map 中不存在 key 线程 B 判断 map 中不存在 key 线程 B 将 (key, anotherValue) 插入 map 线程 A 将 (key, value) 插入 map 那么最终的结果是 (key, value),而不是预期的 (key, anotherValue)。这就是复合操作的非原子性导致的问题。\n那如何保证 ConcurrentHashMap 复合操作的原子性呢?\nConcurrentHashMap 提供了一些原子性的复合操作,如 putIfAbsent、compute、computeIfAbsent 、computeIfPresent、merge等。这些方法都可以接受一个函数作为参数,根据给定的 key 和 value 来计算一个新的 value,并且将其更新到 map 中。\n上面的代码可以改写为:\n// 线程 A map.putIfAbsent(key, value); // 线程 B map.putIfAbsent(key, anotherValue); 或者:\n// 线程 A map.computeIfAbsent(key, k -\u0026gt; value); // 线程 B map.computeIfAbsent(key, k -\u0026gt; anotherValue); 很多同学可能会说了,这种情况也能加锁同步呀!确实可以,但不建议使用加锁的同步机制,违背了使用 ConcurrentHashMap 的初衷。在使用 ConcurrentHashMap 的时候,尽量使用这些原子性的复合操作方法来保证原子性。\nCollections 工具类(不重要) # Collections 工具类常用方法:\n排序 查找,替换操作 同步控制(不推荐,需要线程安全的集合类型时请考虑使用 JUC 包下的并发集合) 排序操作 # void reverse(List list)//反转 void shuffle(List list)//随机排序 void sort(List list)//按自然排序的升序排序 void sort(List list, Comparator c)//定制排序,由Comparator控制排序逻辑 void swap(List list, int i , int j)//交换两个索引位置的元素 void rotate(List list, int distance)//旋转。当distance为正数时,将list后distance个元素整体移到前面。当distance为负数时,将 list的前distance个元素整体移到后面 查找,替换操作 # int binarySearch(List list, Object key)//对List进行二分查找,返回索引,注意List必须是有序的 int max(Collection coll)//根据元素的自然顺序,返回最大的元素。 类比int min(Collection coll) int max(Collection coll, Comparator c)//根据定制排序,返回最大元素,排序规则由Comparatator类控制。类比int min(Collection coll, Comparator c) void fill(List list, Object obj)//用指定的元素代替指定list中的所有元素 int frequency(Collection c, Object o)//统计元素出现次数 int indexOfSubList(List list, List target)//统计target在list中第一次出现的索引,找不到则返回-1,类比int lastIndexOfSubList(List source, list target) boolean replaceAll(List list, Object oldVal, Object newVal)//用新元素替换旧元素 同步控制 # Collections 提供了多个synchronizedXxx()方法·,该方法可以将指定集合包装成线程同步的集合,从而解决多线程并发访问集合时的线程安全问题。\n我们知道 HashSet,TreeSet,ArrayList,LinkedList,HashMap,TreeMap 都是线程不安全的。Collections 提供了多个静态方法可以把他们包装成线程同步的集合。\n最好不要用下面这些方法,效率非常低,需要线程安全的集合类型时请考虑使用 JUC 包下的并发集合。\n方法如下:\nsynchronizedCollection(Collection\u0026lt;T\u0026gt; c) //返回指定 collection 支持的同步(线程安全的)collection。 synchronizedList(List\u0026lt;T\u0026gt; list)//返回指定列表支持的同步(线程安全的)List。 synchronizedMap(Map\u0026lt;K,V\u0026gt; m) //返回由指定映射支持的同步(线程安全的)Map。 synchronizedSet(Set\u0026lt;T\u0026gt; s) //返回指定 set 支持的同步(线程安全的)set。 "},{"id":521,"href":"/zh/docs/technology/Interview/java/collection/java-collection-precautions-for-use/","title":"Java集合使用注意事项总结","section":"Collection","content":"这篇文章我根据《阿里巴巴 Java 开发手册》总结了关于集合使用常见的注意事项以及其具体原理。\n强烈建议小伙伴们多多阅读几遍,避免自己写代码的时候出现这些低级的问题。\n集合判空 # 《阿里巴巴 Java 开发手册》的描述如下:\n判断所有集合内部的元素是否为空,使用 isEmpty() 方法,而不是 size()==0 的方式。\n这是因为 isEmpty() 方法的可读性更好,并且时间复杂度为 O(1)。\n绝大部分我们使用的集合的 size() 方法的时间复杂度也是 O(1),不过,也有很多复杂度不是 O(1) 的,比如 java.util.concurrent 包下的 ConcurrentLinkedQueue。ConcurrentLinkedQueue 的 isEmpty() 方法通过 first() 方法进行判断,其中 first() 方法返回的是队列中第一个值不为 null 的节点(节点值为null的原因是在迭代器中使用的逻辑删除)\npublic boolean isEmpty() { return first() == null; } Node\u0026lt;E\u0026gt; first() { restartFromHead: for (;;) { for (Node\u0026lt;E\u0026gt; h = head, p = h, q;;) { boolean hasItem = (p.item != null); if (hasItem || (q = p.next) == null) { // 当前节点值不为空 或 到达队尾 updateHead(h, p); // 将head设置为p return hasItem ? p : null; } else if (p == q) continue restartFromHead; else p = q; // p = p.next } } } 由于在插入与删除元素时,都会执行updateHead(h, p)方法,所以该方法的执行的时间复杂度可以近似为O(1)。而 size() 方法需要遍历整个链表,时间复杂度为O(n)\npublic int size() { int count = 0; for (Node\u0026lt;E\u0026gt; p = first(); p != null; p = succ(p)) if (p.item != null) if (++count == Integer.MAX_VALUE) break; return count; } 此外,在ConcurrentHashMap 1.7 中 size() 方法和 isEmpty() 方法的时间复杂度也不太一样。ConcurrentHashMap 1.7 将元素数量存储在每个Segment 中,size() 方法需要统计每个 Segment 的数量,而 isEmpty() 只需要找到第一个不为空的 Segment 即可。但是在ConcurrentHashMap 1.8 中的 size() 方法和 isEmpty() 都需要调用 sumCount() 方法,其时间复杂度与 Node 数组的大小有关。下面是 sumCount() 方法的源码:\nfinal long sumCount() { CounterCell[] as = counterCells; CounterCell a; long sum = baseCount; if (as != null) for (int i = 0; i \u0026lt; as.length; ++i) if ((a = as[i]) != null) sum += a.value; return sum; } 这是因为在并发的环境下,ConcurrentHashMap 将每个 Node 中节点的数量存储在 CounterCell[] 数组中。在 ConcurrentHashMap 1.7 中,将元素数量存储在每个Segment 中,size() 方法需要统计每个 Segment 的数量,而 isEmpty() 只需要找到第一个不为空的 Segment 即可。\n集合转 Map # 《阿里巴巴 Java 开发手册》的描述如下:\n在使用 java.util.stream.Collectors 类的 toMap() 方法转为 Map 集合时,一定要注意当 value 为 null 时会抛 NPE 异常。\nclass Person { private String name; private String phoneNumber; // getters and setters } List\u0026lt;Person\u0026gt; bookList = new ArrayList\u0026lt;\u0026gt;(); bookList.add(new Person(\u0026#34;jack\u0026#34;,\u0026#34;18163138123\u0026#34;)); bookList.add(new Person(\u0026#34;martin\u0026#34;,null)); // 空指针异常 bookList.stream().collect(Collectors.toMap(Person::getName, Person::getPhoneNumber)); 下面我们来解释一下原因。\n首先,我们来看 java.util.stream.Collectors 类的 toMap() 方法 ,可以看到其内部调用了 Map 接口的 merge() 方法。\npublic static \u0026lt;T, K, U, M extends Map\u0026lt;K, U\u0026gt;\u0026gt; Collector\u0026lt;T, ?, M\u0026gt; toMap(Function\u0026lt;? super T, ? extends K\u0026gt; keyMapper, Function\u0026lt;? super T, ? extends U\u0026gt; valueMapper, BinaryOperator\u0026lt;U\u0026gt; mergeFunction, Supplier\u0026lt;M\u0026gt; mapSupplier) { BiConsumer\u0026lt;M, T\u0026gt; accumulator = (map, element) -\u0026gt; map.merge(keyMapper.apply(element), valueMapper.apply(element), mergeFunction); return new CollectorImpl\u0026lt;\u0026gt;(mapSupplier, accumulator, mapMerger(mergeFunction), CH_ID); } Map 接口的 merge() 方法如下,这个方法是接口中的默认实现。\n如果你还不了解 Java 8 新特性的话,请看这篇文章: 《Java8 新特性总结》 。\ndefault V merge(K key, V value, BiFunction\u0026lt;? super V, ? super V, ? extends V\u0026gt; remappingFunction) { Objects.requireNonNull(remappingFunction); Objects.requireNonNull(value); V oldValue = get(key); V newValue = (oldValue == null) ? value : remappingFunction.apply(oldValue, value); if(newValue == null) { remove(key); } else { put(key, newValue); } return newValue; } merge() 方法会先调用 Objects.requireNonNull() 方法判断 value 是否为空。\npublic static \u0026lt;T\u0026gt; T requireNonNull(T obj) { if (obj == null) throw new NullPointerException(); return obj; } 集合遍历 # 《阿里巴巴 Java 开发手册》的描述如下:\n不要在 foreach 循环里进行元素的 remove/add 操作。remove 元素请使用 Iterator 方式,如果并发操作,需要对 Iterator 对象加锁。\n通过反编译你会发现 foreach 语法底层其实还是依赖 Iterator 。不过, remove/add 操作直接调用的是集合自己的方法,而不是 Iterator 的 remove/add方法\n这就导致 Iterator 莫名其妙地发现自己有元素被 remove/add ,然后,它就会抛出一个 ConcurrentModificationException 来提示用户发生了并发修改异常。这就是单线程状态下产生的 fail-fast 机制。\nfail-fast 机制:多个线程对 fail-fast 集合进行修改的时候,可能会抛出ConcurrentModificationException。 即使是单线程下也有可能会出现这种情况,上面已经提到过。\n相关阅读: 什么是 fail-fast 。\nJava8 开始,可以使用 Collection#removeIf()方法删除满足特定条件的元素,如\nList\u0026lt;Integer\u0026gt; list = new ArrayList\u0026lt;\u0026gt;(); for (int i = 1; i \u0026lt;= 10; ++i) { list.add(i); } list.removeIf(filter -\u0026gt; filter % 2 == 0); /* 删除list中的所有偶数 */ System.out.println(list); /* [1, 3, 5, 7, 9] */ 除了上面介绍的直接使用 Iterator 进行遍历操作之外,你还可以:\n使用普通的 for 循环 使用 fail-safe 的集合类。java.util包下面的所有的集合类都是 fail-fast 的,而java.util.concurrent包下面的所有的类都是 fail-safe 的。 …… 集合去重 # 《阿里巴巴 Java 开发手册》的描述如下:\n可以利用 Set 元素唯一的特性,可以快速对一个集合进行去重操作,避免使用 List 的 contains() 进行遍历去重或者判断包含操作。\n这里我们以 HashSet 和 ArrayList 为例说明。\n// Set 去重代码示例 public static \u0026lt;T\u0026gt; Set\u0026lt;T\u0026gt; removeDuplicateBySet(List\u0026lt;T\u0026gt; data) { if (CollectionUtils.isEmpty(data)) { return new HashSet\u0026lt;\u0026gt;(); } return new HashSet\u0026lt;\u0026gt;(data); } // List 去重代码示例 public static \u0026lt;T\u0026gt; List\u0026lt;T\u0026gt; removeDuplicateByList(List\u0026lt;T\u0026gt; data) { if (CollectionUtils.isEmpty(data)) { return new ArrayList\u0026lt;\u0026gt;(); } List\u0026lt;T\u0026gt; result = new ArrayList\u0026lt;\u0026gt;(data.size()); for (T current : data) { if (!result.contains(current)) { result.add(current); } } return result; } 两者的核心差别在于 contains() 方法的实现。\nHashSet 的 contains() 方法底部依赖的 HashMap 的 containsKey() 方法,时间复杂度接近于 O(1)(没有出现哈希冲突的时候为 O(1))。\nprivate transient HashMap\u0026lt;E,Object\u0026gt; map; public boolean contains(Object o) { return map.containsKey(o); } 我们有 N 个元素插入进 Set 中,那时间复杂度就接近是 O (n)。\nArrayList 的 contains() 方法是通过遍历所有元素的方法来做的,时间复杂度接近是 O(n)。\npublic boolean contains(Object o) { return indexOf(o) \u0026gt;= 0; } public int indexOf(Object o) { if (o == null) { for (int i = 0; i \u0026lt; size; i++) if (elementData[i]==null) return i; } else { for (int i = 0; i \u0026lt; size; i++) if (o.equals(elementData[i])) return i; } return -1; } 集合转数组 # 《阿里巴巴 Java 开发手册》的描述如下:\n使用集合转数组的方法,必须使用集合的 toArray(T[] array),传入的是类型完全一致、长度为 0 的空数组。\ntoArray(T[] array) 方法的参数是一个泛型数组,如果 toArray 方法中没有传递任何参数的话返回的是 Object类 型数组。\nString [] s= new String[]{ \u0026#34;dog\u0026#34;, \u0026#34;lazy\u0026#34;, \u0026#34;a\u0026#34;, \u0026#34;over\u0026#34;, \u0026#34;jumps\u0026#34;, \u0026#34;fox\u0026#34;, \u0026#34;brown\u0026#34;, \u0026#34;quick\u0026#34;, \u0026#34;A\u0026#34; }; List\u0026lt;String\u0026gt; list = Arrays.asList(s); Collections.reverse(list); //没有指定类型的话会报错 s=list.toArray(new String[0]); 由于 JVM 优化,new String[0]作为Collection.toArray()方法的参数现在使用更好,new String[0]就是起一个模板的作用,指定了返回数组的类型,0 是为了节省空间,因为它只是为了说明返回的类型。详见: https://shipilev.net/blog/2016/arrays-wisdom-ancients/\n数组转集合 # 《阿里巴巴 Java 开发手册》的描述如下:\n使用工具类 Arrays.asList() 把数组转换成集合时,不能使用其修改集合相关的方法, 它的 add/remove/clear 方法会抛出 UnsupportedOperationException 异常。\n我在之前的一个项目中就遇到一个类似的坑。\nArrays.asList()在平时开发中还是比较常见的,我们可以使用它将一个数组转换为一个 List 集合。\nString[] myArray = {\u0026#34;Apple\u0026#34;, \u0026#34;Banana\u0026#34;, \u0026#34;Orange\u0026#34;}; List\u0026lt;String\u0026gt; myList = Arrays.asList(myArray); //上面两个语句等价于下面一条语句 List\u0026lt;String\u0026gt; myList = Arrays.asList(\u0026#34;Apple\u0026#34;,\u0026#34;Banana\u0026#34;, \u0026#34;Orange\u0026#34;); JDK 源码对于这个方法的说明:\n/** *返回由指定数组支持的固定大小的列表。此方法作为基于数组和基于集合的API之间的桥梁, * 与 Collection.toArray()结合使用。返回的List是可序列化并实现RandomAccess接口。 */ public static \u0026lt;T\u0026gt; List\u0026lt;T\u0026gt; asList(T... a) { return new ArrayList\u0026lt;\u0026gt;(a); } 下面我们来总结一下使用注意事项。\n1、Arrays.asList()是泛型方法,传递的数组必须是对象数组,而不是基本类型。\nint[] myArray = {1, 2, 3}; List myList = Arrays.asList(myArray); System.out.println(myList.size());//1 System.out.println(myList.get(0));//数组地址值 System.out.println(myList.get(1));//报错:ArrayIndexOutOfBoundsException int[] array = (int[]) myList.get(0); System.out.println(array[0]);//1 当传入一个原生数据类型数组时,Arrays.asList() 的真正得到的参数就不是数组中的元素,而是数组对象本身!此时 List 的唯一元素就是这个数组,这也就解释了上面的代码。\n我们使用包装类型数组就可以解决这个问题。\nInteger[] myArray = {1, 2, 3}; 2、使用集合的修改方法: add()、remove()、clear()会抛出异常。\nList myList = Arrays.asList(1, 2, 3); myList.add(4);//运行时报错:UnsupportedOperationException myList.remove(1);//运行时报错:UnsupportedOperationException myList.clear();//运行时报错:UnsupportedOperationException Arrays.asList() 方法返回的并不是 java.util.ArrayList ,而是 java.util.Arrays 的一个内部类,这个内部类并没有实现集合的修改方法或者说并没有重写这些方法。\nList myList = Arrays.asList(1, 2, 3); System.out.println(myList.getClass());//class java.util.Arrays$ArrayList 下图是 java.util.Arrays$ArrayList 的简易源码,我们可以看到这个类重写的方法有哪些。\nprivate static class ArrayList\u0026lt;E\u0026gt; extends AbstractList\u0026lt;E\u0026gt; implements RandomAccess, java.io.Serializable { ... @Override public E get(int index) { ... } @Override public E set(int index, E element) { ... } @Override public int indexOf(Object o) { ... } @Override public boolean contains(Object o) { ... } @Override public void forEach(Consumer\u0026lt;? super E\u0026gt; action) { ... } @Override public void replaceAll(UnaryOperator\u0026lt;E\u0026gt; operator) { ... } @Override public void sort(Comparator\u0026lt;? super E\u0026gt; c) { ... } } 我们再看一下java.util.AbstractList的 add/remove/clear 方法就知道为什么会抛出 UnsupportedOperationException 了。\npublic E remove(int index) { throw new UnsupportedOperationException(); } public boolean add(E e) { add(size(), e); return true; } public void add(int index, E element) { throw new UnsupportedOperationException(); } public void clear() { removeRange(0, size()); } protected void removeRange(int fromIndex, int toIndex) { ListIterator\u0026lt;E\u0026gt; it = listIterator(fromIndex); for (int i=0, n=toIndex-fromIndex; i\u0026lt;n; i++) { it.next(); it.remove(); } } 那我们如何正确的将数组转换为 ArrayList ?\n1、手动实现工具类\n//JDK1.5+ static \u0026lt;T\u0026gt; List\u0026lt;T\u0026gt; arrayToList(final T[] array) { final List\u0026lt;T\u0026gt; l = new ArrayList\u0026lt;T\u0026gt;(array.length); for (final T s : array) { l.add(s); } return l; } Integer [] myArray = { 1, 2, 3 }; System.out.println(arrayToList(myArray).getClass());//class java.util.ArrayList 2、最简便的方法\nList list = new ArrayList\u0026lt;\u0026gt;(Arrays.asList(\u0026#34;a\u0026#34;, \u0026#34;b\u0026#34;, \u0026#34;c\u0026#34;)) 3、使用 Java8 的 Stream(推荐)\nInteger [] myArray = { 1, 2, 3 }; List myList = Arrays.stream(myArray).collect(Collectors.toList()); //基本类型也可以实现转换(依赖boxed的装箱操作) int [] myArray2 = { 1, 2, 3 }; List myList = Arrays.stream(myArray2).boxed().collect(Collectors.toList()); 4、使用 Guava\n对于不可变集合,你可以使用 ImmutableList类及其 of()与 copyOf()工厂方法:(参数不能为空)\nList\u0026lt;String\u0026gt; il = ImmutableList.of(\u0026#34;string\u0026#34;, \u0026#34;elements\u0026#34;); // from varargs List\u0026lt;String\u0026gt; il = ImmutableList.copyOf(aStringArray); // from array 对于可变集合,你可以使用 Lists类及其 newArrayList()工厂方法:\nList\u0026lt;String\u0026gt; l1 = Lists.newArrayList(anotherListOrCollection); // from collection List\u0026lt;String\u0026gt; l2 = Lists.newArrayList(aStringArray); // from array List\u0026lt;String\u0026gt; l3 = Lists.newArrayList(\u0026#34;or\u0026#34;, \u0026#34;string\u0026#34;, \u0026#34;elements\u0026#34;); // from varargs 5、使用 Apache Commons Collections\nList\u0026lt;String\u0026gt; list = new ArrayList\u0026lt;String\u0026gt;(); CollectionUtils.addAll(list, str); 6、 使用 Java9 的 List.of()方法\nInteger[] array = {1, 2, 3}; List\u0026lt;Integer\u0026gt; list = List.of(array); "},{"id":522,"href":"/zh/docs/technology/Interview/java/jvm/memory-area/","title":"Java内存区域详解(重点)","section":"Jvm","content":" 如果没有特殊说明,都是针对的是 HotSpot 虚拟机。\n本文基于《深入理解 Java 虚拟机:JVM 高级特性与最佳实践》进行总结补充。\n常见面试题:\n介绍下 Java 内存区域(运行时数据区) Java 对象的创建过程(五步,建议能默写出来并且要知道每一步虚拟机做了什么) 对象的访问定位的两种方式(句柄和直接指针两种方式) 前言 # 对于 Java 程序员来说,在虚拟机自动内存管理机制下,不再需要像 C/C++程序开发程序员这样为每一个 new 操作去写对应的 delete/free 操作,不容易出现内存泄漏和内存溢出问题。正是因为 Java 程序员把内存控制权利交给 Java 虚拟机,一旦出现内存泄漏和溢出方面的问题,如果不了解虚拟机是怎样使用内存的,那么排查错误将会是一个非常艰巨的任务。\n运行时数据区域 # Java 虚拟机在执行 Java 程序的过程中会把它管理的内存划分成若干个不同的数据区域。\nJDK 1.8 和之前的版本略有不同,我们这里以 JDK 1.7 和 JDK 1.8 这两个版本为例介绍。\nJDK 1.7:\nJDK 1.8:\n线程私有的:\n程序计数器 虚拟机栈 本地方法栈 线程共享的:\n堆 方法区 直接内存 (非运行时数据区的一部分) Java 虚拟机规范对于运行时数据区域的规定是相当宽松的。以堆为例:堆可以是连续空间,也可以不连续。堆的大小可以固定,也可以在运行时按需扩展 。虚拟机实现者可以使用任何垃圾回收算法管理堆,甚至完全不进行垃圾收集也是可以的。\n程序计数器 # 程序计数器是一块较小的内存空间,可以看作是当前线程所执行的字节码的行号指示器。字节码解释器工作时通过改变这个计数器的值来选取下一条需要执行的字节码指令,分支、循环、跳转、异常处理、线程恢复等功能都需要依赖这个计数器来完成。\n另外,为了线程切换后能恢复到正确的执行位置,每条线程都需要有一个独立的程序计数器,各线程之间计数器互不影响,独立存储,我们称这类内存区域为“线程私有”的内存。\n从上面的介绍中我们知道了程序计数器主要有两个作用:\n字节码解释器通过改变程序计数器来依次读取指令,从而实现代码的流程控制,如:顺序执行、选择、循环、异常处理。 在多线程的情况下,程序计数器用于记录当前线程执行的位置,从而当线程被切换回来的时候能够知道该线程上次运行到哪儿了。 ⚠️ 注意:程序计数器是唯一一个不会出现 OutOfMemoryError 的内存区域,它的生命周期随着线程的创建而创建,随着线程的结束而死亡。\nJava 虚拟机栈 # 与程序计数器一样,Java 虚拟机栈(后文简称栈)也是线程私有的,它的生命周期和线程相同,随着线程的创建而创建,随着线程的死亡而死亡。\n栈绝对算的上是 JVM 运行时数据区域的一个核心,除了一些 Native 方法调用是通过本地方法栈实现的(后面会提到),其他所有的 Java 方法调用都是通过栈来实现的(也需要和其他运行时数据区域比如程序计数器配合)。\n方法调用的数据需要通过栈进行传递,每一次方法调用都会有一个对应的栈帧被压入栈中,每一个方法调用结束后,都会有一个栈帧被弹出。\n栈由一个个栈帧组成,而每个栈帧中都拥有:局部变量表、操作数栈、动态链接、方法返回地址。和数据结构上的栈类似,两者都是先进后出的数据结构,只支持出栈和入栈两种操作。\n局部变量表 主要存放了编译期可知的各种数据类型(boolean、byte、char、short、int、float、long、double)、对象引用(reference 类型,它不同于对象本身,可能是一个指向对象起始地址的引用指针,也可能是指向一个代表对象的句柄或其他与此对象相关的位置)。\n操作数栈 主要作为方法调用的中转站使用,用于存放方法执行过程中产生的中间计算结果。另外,计算过程中产生的临时变量也会放在操作数栈中。\n动态链接 主要服务一个方法需要调用其他方法的场景。Class 文件的常量池里保存有大量的符号引用比如方法引用的符号引用。当一个方法要调用其他方法,需要将常量池中指向方法的符号引用转化为其在内存地址中的直接引用。动态链接的作用就是为了将符号引用转换为调用方法的直接引用,这个过程也被称为 动态连接 。\n栈空间虽然不是无限的,但一般正常调用的情况下是不会出现问题的。不过,如果函数调用陷入无限循环的话,就会导致栈中被压入太多栈帧而占用太多空间,导致栈空间过深。那么当线程请求栈的深度超过当前 Java 虚拟机栈的最大深度的时候,就抛出 StackOverFlowError 错误。\nJava 方法有两种返回方式,一种是 return 语句正常返回,一种是抛出异常。不管哪种返回方式,都会导致栈帧被弹出。也就是说, 栈帧随着方法调用而创建,随着方法结束而销毁。无论方法正常完成还是异常完成都算作方法结束。\n除了 StackOverFlowError 错误之外,栈还可能会出现OutOfMemoryError错误,这是因为如果栈的内存大小可以动态扩展, 如果虚拟机在动态扩展栈时无法申请到足够的内存空间,则抛出OutOfMemoryError异常。\n简单总结一下程序运行中栈可能会出现两种错误:\nStackOverFlowError: 若栈的内存大小不允许动态扩展,那么当线程请求栈的深度超过当前 Java 虚拟机栈的最大深度的时候,就抛出 StackOverFlowError 错误。 OutOfMemoryError: 如果栈的内存大小可以动态扩展, 如果虚拟机在动态扩展栈时无法申请到足够的内存空间,则抛出OutOfMemoryError异常。 本地方法栈 # 和虚拟机栈所发挥的作用非常相似,区别是:虚拟机栈为虚拟机执行 Java 方法 (也就是字节码)服务,而本地方法栈则为虚拟机使用到的 Native 方法服务。 在 HotSpot 虚拟机中和 Java 虚拟机栈合二为一。\n本地方法被执行的时候,在本地方法栈也会创建一个栈帧,用于存放该本地方法的局部变量表、操作数栈、动态链接、出口信息。\n方法执行完毕后相应的栈帧也会出栈并释放内存空间,也会出现 StackOverFlowError 和 OutOfMemoryError 两种错误。\n堆 # Java 虚拟机所管理的内存中最大的一块,Java 堆是所有线程共享的一块内存区域,在虚拟机启动时创建。此内存区域的唯一目的就是存放对象实例,几乎所有的对象实例以及数组都在这里分配内存。\nJava 世界中“几乎”所有的对象都在堆中分配,但是,随着 JIT 编译器的发展与逃逸分析技术逐渐成熟,栈上分配、标量替换优化技术将会导致一些微妙的变化,所有的对象都分配到堆上也渐渐变得不那么“绝对”了。从 JDK 1.7 开始已经默认开启逃逸分析,如果某些方法中的对象引用没有被返回或者未被外面使用(也就是未逃逸出去),那么对象可以直接在栈上分配内存。\nJava 堆是垃圾收集器管理的主要区域,因此也被称作 GC 堆(Garbage Collected Heap)。从垃圾回收的角度,由于现在收集器基本都采用分代垃圾收集算法,所以 Java 堆还可以细分为:新生代和老年代;再细致一点有:Eden、Survivor、Old 等空间。进一步划分的目的是更好地回收内存,或者更快地分配内存。\n在 JDK 7 版本及 JDK 7 版本之前,堆内存被通常分为下面三部分:\n新生代内存(Young Generation) 老生代(Old Generation) 永久代(Permanent Generation) 下图所示的 Eden 区、两个 Survivor 区 S0 和 S1 都属于新生代,中间一层属于老年代,最下面一层属于永久代。\nJDK 8 版本之后 PermGen(永久代) 已被 Metaspace(元空间) 取代,元空间使用的是本地内存。 (我会在方法区这部分内容详细介绍到)。\n大部分情况,对象都会首先在 Eden 区域分配,在一次新生代垃圾回收后,如果对象还存活,则会进入 S0 或者 S1,并且对象的年龄还会加 1(Eden 区-\u0026gt;Survivor 区后对象的初始年龄变为 1),当它的年龄增加到一定程度(默认为 15 岁),就会被晋升到老年代中。对象晋升到老年代的年龄阈值,可以通过参数 -XX:MaxTenuringThreshold 来设置。不过,设置的值应该在 0-15,否则会爆出以下错误:\nMaxTenuringThreshold of 20 is invalid; must be between 0 and 15 为什么年龄只能是 0-15?\n因为记录年龄的区域在对象头中,这个区域的大小通常是 4 位。这 4 位可以表示的最大二进制数字是 1111,即十进制的 15。因此,对象的年龄被限制为 0 到 15。\n这里我们简单结合对象布局来详细介绍一下。\n在 HotSpot 虚拟机中,对象在内存中存储的布局可以分为 3 块区域:对象头(Header)、实例数据(Instance Data)和对齐填充(Padding)。其中,对象头包括两部分:标记字段(Mark Word)和类型指针(Klass Word)。关于对象内存布局的详细介绍,后文会介绍到,这里就不重复提了。\n这个年龄信息就是在标记字段中存放的(标记字段还存放了对象自身的其他信息比如哈希码、锁状态信息等等)。markOop.hpp定义了标记字(mark word)的结构:\n可以看到对象年龄占用的大小确实是 4 位。\n🐛 修正(参见: issue552):“Hotspot 遍历所有对象时,按照年龄从小到大对其所占用的大小进行累加,当累加到某个年龄时,所累加的大小超过了 Survivor 区的一半,则取这个年龄和 MaxTenuringThreshold 中更小的一个值,作为新的晋升年龄阈值”。\n动态年龄计算的代码如下\nuint ageTable::compute_tenuring_threshold(size_t survivor_capacity) { //survivor_capacity是survivor空间的大小 size_t desired_survivor_size = (size_t)((((double) survivor_capacity)*TargetSurvivorRatio)/100);//TargetSurvivorRatio 为50 size_t total = 0; uint age = 1; while (age \u0026lt; table_size) { total += sizes[age];//sizes数组是每个年龄段对象大小 if (total \u0026gt; desired_survivor_size) break; age++; } uint result = age \u0026lt; MaxTenuringThreshold ? age : MaxTenuringThreshold; ... } 堆这里最容易出现的就是 OutOfMemoryError 错误,并且出现这种错误之后的表现形式还会有几种,比如:\njava.lang.OutOfMemoryError: GC Overhead Limit Exceeded:当 JVM 花太多时间执行垃圾回收并且只能回收很少的堆空间时,就会发生此错误。 java.lang.OutOfMemoryError: Java heap space :假如在创建新的对象时, 堆内存中的空间不足以存放新创建的对象, 就会引发此错误。(和配置的最大堆内存有关,且受制于物理内存大小。最大堆内存可通过-Xmx参数配置,若没有特别配置,将会使用默认值,详见: Default Java 8 max heap size) …… 方法区 # 方法区属于是 JVM 运行时数据区域的一块逻辑区域,是各个线程共享的内存区域。\n《Java 虚拟机规范》只是规定了有方法区这么个概念和它的作用,方法区到底要如何实现那就是虚拟机自己要考虑的事情了。也就是说,在不同的虚拟机实现上,方法区的实现是不同的。\n当虚拟机要使用一个类时,它需要读取并解析 Class 文件获取相关信息,再将信息存入到方法区。方法区会存储已被虚拟机加载的 类信息、字段信息、方法信息、常量、静态变量、即时编译器编译后的代码缓存等数据。\n方法区和永久代以及元空间是什么关系呢? 方法区和永久代以及元空间的关系很像 Java 中接口和类的关系,类实现了接口,这里的类就可以看作是永久代和元空间,接口可以看作是方法区,也就是说永久代以及元空间是 HotSpot 虚拟机对虚拟机规范中方法区的两种实现方式。并且,永久代是 JDK 1.8 之前的方法区实现,JDK 1.8 及以后方法区的实现变成了元空间。\n为什么要将永久代 (PermGen) 替换为元空间 (MetaSpace) 呢?\n下图来自《深入理解 Java 虚拟机》第 3 版 2.2.5\n1、整个永久代有一个 JVM 本身设置的固定大小上限,无法进行调整(也就是受到 JVM 内存的限制),而元空间使用的是本地内存,受本机可用内存的限制,虽然元空间仍旧可能溢出,但是比原来出现的几率会更小。\n当元空间溢出时会得到如下错误:java.lang.OutOfMemoryError: MetaSpace\n你可以使用 -XX:MaxMetaspaceSize 标志设置最大元空间大小,默认值为 unlimited,这意味着它只受系统内存的限制。-XX:MetaspaceSize 调整标志定义元空间的初始大小如果未指定此标志,则 Metaspace 将根据运行时的应用程序需求动态地重新调整大小。\n2、元空间里面存放的是类的元数据,这样加载多少类的元数据就不由 MaxPermSize 控制了, 而由系统的实际可用空间来控制,这样能加载的类就更多了。\n3、在 JDK8,合并 HotSpot 和 JRockit 的代码时, JRockit 从来没有一个叫永久代的东西, 合并之后就没有必要额外的设置这么一个永久代的地方了。\n4、永久代会为 GC 带来不必要的复杂度,并且回收效率偏低。\n方法区常用参数有哪些?\nJDK 1.8 之前永久代还没被彻底移除的时候通常通过下面这些参数来调节方法区大小。\n-XX:PermSize=N //方法区 (永久代) 初始大小 -XX:MaxPermSize=N //方法区 (永久代) 最大大小,超过这个值将会抛出 OutOfMemoryError 异常:java.lang.OutOfMemoryError: PermGen 相对而言,垃圾收集行为在这个区域是比较少出现的,但并非数据进入方法区后就“永久存在”了。\nJDK 1.8 的时候,方法区(HotSpot 的永久代)被彻底移除了(JDK1.7 就已经开始了),取而代之是元空间,元空间使用的是本地内存。下面是一些常用参数:\n-XX:MetaspaceSize=N //设置 Metaspace 的初始(和最小大小) -XX:MaxMetaspaceSize=N //设置 Metaspace 的最大大小 与永久代很大的不同就是,如果不指定大小的话,随着更多类的创建,虚拟机会耗尽所有可用的系统内存。\n运行时常量池 # Class 文件中除了有类的版本、字段、方法、接口等描述信息外,还有用于存放编译期生成的各种字面量(Literal)和符号引用(Symbolic Reference)的 常量池表(Constant Pool Table) 。\n字面量是源代码中的固定值的表示法,即通过字面我们就能知道其值的含义。字面量包括整数、浮点数和字符串字面量。常见的符号引用包括类符号引用、字段符号引用、方法符号引用、接口方法符号。\n《深入理解 Java 虚拟机》7.34 节第三版对符号引用和直接引用的解释如下:\n常量池表会在类加载后存放到方法区的运行时常量池中。\n运行时常量池的功能类似于传统编程语言的符号表,尽管它包含了比典型符号表更广泛的数据。\n既然运行时常量池是方法区的一部分,自然受到方法区内存的限制,当常量池无法再申请到内存时会抛出 OutOfMemoryError 错误。\n字符串常量池 # 字符串常量池 是 JVM 为了提升性能和减少内存消耗针对字符串(String 类)专门开辟的一块区域,主要目的是为了避免字符串的重复创建。\n// 在字符串常量池中创建字符串对象 ”ab“ // 将字符串对象 ”ab“ 的引用赋值给给 aa String aa = \u0026#34;ab\u0026#34;; // 直接返回字符串常量池中字符串对象 ”ab“,赋值给引用 bb String bb = \u0026#34;ab\u0026#34;; System.out.println(aa==bb); // true HotSpot 虚拟机中字符串常量池的实现是 src/hotspot/share/classfile/stringTable.cpp ,StringTable 可以简单理解为一个固定大小的HashTable ,容量为 StringTableSize(可以通过 -XX:StringTableSize 参数来设置),保存的是字符串(key)和 字符串对象的引用(value)的映射关系,字符串对象的引用指向堆中的字符串对象。\nJDK1.7 之前,字符串常量池存放在永久代。JDK1.7 字符串常量池和静态变量从永久代移动到了 Java 堆中。\nJDK 1.7 为什么要将字符串常量池移动到堆中?\n主要是因为永久代(方法区实现)的 GC 回收效率太低,只有在整堆收集 (Full GC)的时候才会被执行 GC。Java 程序中通常会有大量的被创建的字符串等待回收,将字符串常量池放到堆中,能够更高效及时地回收字符串内存。\n相关问题: JVM 常量池中存储的是对象还是引用呢? - RednaxelaFX - 知乎\n最后再来分享一段周志明老师在 《深入理解 Java 虚拟机(第 3 版)》样例代码\u0026amp;勘误 GitHub 仓库的 issue#112 中说过的话:\n运行时常量池、方法区、字符串常量池这些都是不随虚拟机实现而改变的逻辑概念,是公共且抽象的,Metaspace、Heap 是与具体某种虚拟机实现相关的物理概念,是私有且具体的。\n直接内存 # 直接内存是一种特殊的内存缓冲区,并不在 Java 堆或方法区中分配的,而是通过 JNI 的方式在本地内存上分配的。\n直接内存并不是虚拟机运行时数据区的一部分,也不是虚拟机规范中定义的内存区域,但是这部分内存也被频繁地使用。而且也可能导致 OutOfMemoryError 错误出现。\nJDK1.4 中新加入的 NIO(Non-Blocking I/O,也被称为 New I/O),引入了一种基于通道(Channel)与缓存区(Buffer)的 I/O 方式,它可以直接使用 Native 函数库直接分配堆外内存,然后通过一个存储在 Java 堆中的 DirectByteBuffer 对象作为这块内存的引用进行操作。这样就能在一些场景中显著提高性能,因为避免了在 Java 堆和 Native 堆之间来回复制数据。\n直接内存的分配不会受到 Java 堆的限制,但是,既然是内存就会受到本机总内存大小以及处理器寻址空间的限制。\n类似的概念还有 堆外内存 。在一些文章中将直接内存等价于堆外内存,个人觉得不是特别准确。\n堆外内存就是把内存对象分配在堆外的内存,这些内存直接受操作系统管理(而不是虚拟机),这样做的结果就是能够在一定程度上减少垃圾回收对应用程序造成的影响。\nHotSpot 虚拟机对象探秘 # 通过上面的介绍我们大概知道了虚拟机的内存情况,下面我们来详细的了解一下 HotSpot 虚拟机在 Java 堆中对象分配、布局和访问的全过程。\n对象的创建 # Java 对象的创建过程我建议最好是能默写出来,并且要掌握每一步在做什么。\nStep1:类加载检查 # 虚拟机遇到一条 new 指令时,首先将去检查这个指令的参数是否能在常量池中定位到这个类的符号引用,并且检查这个符号引用代表的类是否已被加载过、解析和初始化过。如果没有,那必须先执行相应的类加载过程。\nStep2:分配内存 # 在类加载检查通过后,接下来虚拟机将为新生对象分配内存。对象所需的内存大小在类加载完成后便可确定,为对象分配空间的任务等同于把一块确定大小的内存从 Java 堆中划分出来。分配方式有 “指针碰撞” 和 “空闲列表” 两种,选择哪种分配方式由 Java 堆是否规整决定,而 Java 堆是否规整又由所采用的垃圾收集器是否带有压缩整理功能决定。\n内存分配的两种方式 (补充内容,需要掌握):\n指针碰撞: 适用场合:堆内存规整(即没有内存碎片)的情况下。 原理:用过的内存全部整合到一边,没有用过的内存放在另一边,中间有一个分界指针,只需要向着没用过的内存方向将该指针移动对象内存大小位置即可。 使用该分配方式的 GC 收集器:Serial, ParNew 空闲列表: 适用场合:堆内存不规整的情况下。 原理:虚拟机会维护一个列表,该列表中会记录哪些内存块是可用的,在分配的时候,找一块儿足够大的内存块儿来划分给对象实例,最后更新列表记录。 使用该分配方式的 GC 收集器:CMS 选择以上两种方式中的哪一种,取决于 Java 堆内存是否规整。而 Java 堆内存是否规整,取决于 GC 收集器的算法是\u0026quot;标记-清除\u0026quot;,还是\u0026quot;标记-整理\u0026quot;(也称作\u0026quot;标记-压缩\u0026quot;),值得注意的是,复制算法内存也是规整的。\n内存分配并发问题(补充内容,需要掌握)\n在创建对象的时候有一个很重要的问题,就是线程安全,因为在实际开发过程中,创建对象是很频繁的事情,作为虚拟机来说,必须要保证线程是安全的,通常来讲,虚拟机采用两种方式来保证线程安全:\nCAS+失败重试: CAS 是乐观锁的一种实现方式。所谓乐观锁就是,每次不加锁而是假设没有冲突而去完成某项操作,如果因为冲突失败就重试,直到成功为止。虚拟机采用 CAS 配上失败重试的方式保证更新操作的原子性。 TLAB: 为每一个线程预先在 Eden 区分配一块儿内存,JVM 在给线程中的对象分配内存时,首先在 TLAB 分配,当对象大于 TLAB 中的剩余内存或 TLAB 的内存已用尽时,再采用上述的 CAS 进行内存分配 Step3:初始化零值 # 内存分配完成后,虚拟机需要将分配到的内存空间都初始化为零值(不包括对象头),这一步操作保证了对象的实例字段在 Java 代码中可以不赋初始值就直接使用,程序能访问到这些字段的数据类型所对应的零值。\nStep4:设置对象头 # 初始化零值完成之后,虚拟机要对对象进行必要的设置,例如这个对象是哪个类的实例、如何才能找到类的元数据信息、对象的哈希码、对象的 GC 分代年龄等信息。 这些信息存放在对象头中。 另外,根据虚拟机当前运行状态的不同,如是否启用偏向锁等,对象头会有不同的设置方式。\nStep5:执行 init 方法 # 在上面工作都完成之后,从虚拟机的视角来看,一个新的对象已经产生了,但从 Java 程序的视角来看,对象创建才刚开始,\u0026lt;init\u0026gt; 方法还没有执行,所有的字段都还为零。所以一般来说,执行 new 指令之后会接着执行 \u0026lt;init\u0026gt; 方法,把对象按照程序员的意愿进行初始化,这样一个真正可用的对象才算完全产生出来。\n对象的内存布局 # 在 Hotspot 虚拟机中,对象在内存中的布局可以分为 3 块区域:对象头(Header)、实例数据(Instance Data)和对齐填充(Padding)。\n对象头包括两部分信息:\n标记字段(Mark Word):用于存储对象自身的运行时数据, 如哈希码(HashCode)、GC 分代年龄、锁状态标志、线程持有的锁、偏向线程 ID、偏向时间戳等等。 类型指针(Klass pointer):对象指向它的类元数据的指针,虚拟机通过这个指针来确定这个对象是哪个类的实例。 实例数据部分是对象真正存储的有效信息,也是在程序中所定义的各种类型的字段内容。\n对齐填充部分不是必然存在的,也没有什么特别的含义,仅仅起占位作用。 因为 Hotspot 虚拟机的自动内存管理系统要求对象起始地址必须是 8 字节的整数倍,换句话说就是对象的大小必须是 8 字节的整数倍。而对象头部分正好是 8 字节的倍数(1 倍或 2 倍),因此,当对象实例数据部分没有对齐时,就需要通过对齐填充来补全。\n对象的访问定位 # 建立对象就是为了使用对象,我们的 Java 程序通过栈上的 reference 数据来操作堆上的具体对象。对象的访问方式由虚拟机实现而定,目前主流的访问方式有:使用句柄、直接指针。\n句柄 # 如果使用句柄的话,那么 Java 堆中将会划分出一块内存来作为句柄池,reference 中存储的就是对象的句柄地址,而句柄中包含了对象实例数据与对象类型数据各自的具体地址信息。\n直接指针 # 如果使用直接指针访问,reference 中存储的直接就是对象的地址。\n这两种对象访问方式各有优势。使用句柄来访问的最大好处是 reference 中存储的是稳定的句柄地址,在对象被移动时只会改变句柄中的实例数据指针,而 reference 本身不需要修改。使用直接指针访问方式最大的好处就是速度快,它节省了一次指针定位的时间开销。\nHotSpot 虚拟机主要使用的就是这种方式来进行对象访问。\n参考 # 《深入理解 Java 虚拟机:JVM 高级特性与最佳实践(第二版》 《自己动手写 Java 虚拟机》 Chapter 2. The Structure of the Java Virtual Machine: https://docs.oracle.com/javase/specs/jvms/se8/html/jvms-2.html JVM 栈帧内部结构-动态链接: https://chenxitag.com/archives/368 Java 中 new String(\u0026ldquo;字面量\u0026rdquo;) 中 \u0026ldquo;字面量\u0026rdquo; 是何时进入字符串常量池的? - 木女孩的回答 - 知乎: https://www.zhihu.com/question/55994121/answer/147296098 JVM 常量池中存储的是对象还是引用呢? - RednaxelaFX 的回答 - 知乎: https://www.zhihu.com/question/57109429/answer/151717241 http://www.pointsoftware.ch/en/under-the-hood-runtime-data-areas-javas-memory-model/ https://dzone.com/articles/jvm-permgen-%E2%80%93-where-art-thou https://stackoverflow.com/questions/9095748/method-area-and-permgen "},{"id":523,"href":"/zh/docs/technology/Interview/java/jvm/jdk-monitoring-and-troubleshooting-tools/","title":"JDK监控和故障处理工具总结","section":"Jvm","content":" JDK 命令行工具 # 这些命令在 JDK 安装目录下的 bin 目录下:\njps (JVM Process Status): 类似 UNIX 的 ps 命令。用于查看所有 Java 进程的启动类、传入参数和 Java 虚拟机参数等信息; jstat(JVM Statistics Monitoring Tool): 用于收集 HotSpot 虚拟机各方面的运行数据; jinfo (Configuration Info for Java) : Configuration Info for Java,显示虚拟机配置信息; jmap (Memory Map for Java) : 生成堆转储快照; jhat (JVM Heap Dump Browser) : 用于分析 heapdump 文件,它会建立一个 HTTP/HTML 服务器,让用户可以在浏览器上查看分析结果。JDK9 移除了 jhat; jstack (Stack Trace for Java) : 生成虚拟机当前时刻的线程快照,线程快照就是当前虚拟机内每一条线程正在执行的方法堆栈的集合。 jps:查看所有 Java 进程 # jps(JVM Process Status) 命令类似 UNIX 的 ps 命令。\njps:显示虚拟机执行主类名称以及这些进程的本地虚拟机唯一 ID(Local Virtual Machine Identifier,LVMID)。jps -q:只输出进程的本地虚拟机唯一 ID。\nC:\\Users\\SnailClimb\u0026gt;jps 7360 NettyClient2 17396 7972 Launcher 16504 Jps 17340 NettyServer jps -l:输出主类的全名,如果进程执行的是 Jar 包,输出 Jar 路径。\nC:\\Users\\SnailClimb\u0026gt;jps -l 7360 firstNettyDemo.NettyClient2 17396 7972 org.jetbrains.jps.cmdline.Launcher 16492 sun.tools.jps.Jps 17340 firstNettyDemo.NettyServer jps -v:输出虚拟机进程启动时 JVM 参数。\njps -m:输出传递给 Java 进程 main() 函数的参数。\njstat: 监视虚拟机各种运行状态信息 # jstat(JVM Statistics Monitoring Tool) 使用于监视虚拟机各种运行状态信息的命令行工具。 它可以显示本地或者远程(需要远程主机提供 RMI 支持)虚拟机进程中的类信息、内存、垃圾收集、JIT 编译等运行数据,在没有 GUI,只提供了纯文本控制台环境的服务器上,它将是运行期间定位虚拟机性能问题的首选工具。\njstat 命令使用格式:\njstat -\u0026lt;option\u0026gt; [-t] [-h\u0026lt;lines\u0026gt;] \u0026lt;vmid\u0026gt; [\u0026lt;interval\u0026gt; [\u0026lt;count\u0026gt;]] 比如 jstat -gc -h3 31736 1000 10表示分析进程 id 为 31736 的 gc 情况,每隔 1000ms 打印一次记录,打印 10 次停止,每 3 行后打印指标头部。\n常见的 option 如下:\njstat -class vmid:显示 ClassLoader 的相关信息; jstat -compiler vmid:显示 JIT 编译的相关信息; jstat -gc vmid:显示与 GC 相关的堆信息; jstat -gccapacity vmid:显示各个代的容量及使用情况; jstat -gcnew vmid:显示新生代信息; jstat -gcnewcapcacity vmid:显示新生代大小与使用情况; jstat -gcold vmid:显示老年代和永久代的行为统计,从 jdk1.8 开始,该选项仅表示老年代,因为永久代被移除了; jstat -gcoldcapacity vmid:显示老年代的大小; jstat -gcpermcapacity vmid:显示永久代大小,从 jdk1.8 开始,该选项不存在了,因为永久代被移除了; jstat -gcutil vmid:显示垃圾收集信息; 另外,加上 -t参数可以在输出信息上加一个 Timestamp 列,显示程序的运行时间。\njinfo: 实时地查看和调整虚拟机各项参数 # jinfo vmid :输出当前 jvm 进程的全部参数和系统属性 (第一部分是系统的属性,第二部分是 JVM 的参数)。\njinfo -flag name vmid :输出对应名称的参数的具体值。比如输出 MaxHeapSize、查看当前 jvm 进程是否开启打印 GC 日志 ( -XX:PrintGCDetails :详细 GC 日志模式,这两个都是默认关闭的)。\nC:\\Users\\SnailClimb\u0026gt;jinfo -flag MaxHeapSize 17340 -XX:MaxHeapSize=2124414976 C:\\Users\\SnailClimb\u0026gt;jinfo -flag PrintGC 17340 -XX:-PrintGC 使用 jinfo 可以在不重启虚拟机的情况下,可以动态的修改 jvm 的参数。尤其在线上的环境特别有用,请看下面的例子:\njinfo -flag [+|-]name vmid 开启或者关闭对应名称的参数。\nC:\\Users\\SnailClimb\u0026gt;jinfo -flag PrintGC 17340 -XX:-PrintGC C:\\Users\\SnailClimb\u0026gt;jinfo -flag +PrintGC 17340 C:\\Users\\SnailClimb\u0026gt;jinfo -flag PrintGC 17340 -XX:+PrintGC jmap:生成堆转储快照 # jmap(Memory Map for Java)命令用于生成堆转储快照。 如果不使用 jmap 命令,要想获取 Java 堆转储,可以使用 “-XX:+HeapDumpOnOutOfMemoryError” 参数,可以让虚拟机在 OOM 异常出现之后自动生成 dump 文件,Linux 命令下可以通过 kill -3 发送进程退出信号也能拿到 dump 文件。\njmap 的作用并不仅仅是为了获取 dump 文件,它还可以查询 finalizer 执行队列、Java 堆和永久代的详细信息,如空间使用率、当前使用的是哪种收集器等。和jinfo一样,jmap有不少功能在 Windows 平台下也是受限制的。\n示例:将指定应用程序的堆快照输出到桌面。后面,可以通过 jhat、Visual VM 等工具分析该堆文件。\nC:\\Users\\SnailClimb\u0026gt;jmap -dump:format=b,file=C:\\Users\\SnailClimb\\Desktop\\heap.hprof 17340 Dumping heap to C:\\Users\\SnailClimb\\Desktop\\heap.hprof ... Heap dump file created jhat: 分析 heapdump 文件 # jhat 用于分析 heapdump 文件,它会建立一个 HTTP/HTML 服务器,让用户可以在浏览器上查看分析结果。\nC:\\Users\\SnailClimb\u0026gt;jhat C:\\Users\\SnailClimb\\Desktop\\heap.hprof Reading from C:\\Users\\SnailClimb\\Desktop\\heap.hprof... Dump file created Sat May 04 12:30:31 CST 2019 Snapshot read, resolving... Resolving 131419 objects... Chasing references, expect 26 dots.......................... Eliminating duplicate references.......................... Snapshot resolved. Started HTTP server on port 7000 Server is ready. 访问 http://localhost:7000/\n注意⚠️:JDK9 移除了 jhat( JEP 241: Remove the jhat Tool),你可以使用其替代品 Eclipse Memory Analyzer Tool (MAT) 和 VisualVM,这也是官方所推荐的。\njstack :生成虚拟机当前时刻的线程快照 # jstack(Stack Trace for Java)命令用于生成虚拟机当前时刻的线程快照。线程快照就是当前虚拟机内每一条线程正在执行的方法堆栈的集合.\n生成线程快照的目的主要是定位线程长时间出现停顿的原因,如线程间死锁、死循环、请求外部资源导致的长时间等待等都是导致线程长时间停顿的原因。线程出现停顿的时候通过jstack来查看各个线程的调用堆栈,就可以知道没有响应的线程到底在后台做些什么事情,或者在等待些什么资源。\n下面是一个线程死锁的代码。我们下面会通过 jstack 命令进行死锁检查,输出死锁信息,找到发生死锁的线程。\npublic class DeadLockDemo { private static Object resource1 = new Object();//资源 1 private static Object resource2 = new Object();//资源 2 public static void main(String[] args) { new Thread(() -\u0026gt; { synchronized (resource1) { System.out.println(Thread.currentThread() + \u0026#34;get resource1\u0026#34;); try { Thread.sleep(1000); } catch (InterruptedException e) { e.printStackTrace(); } System.out.println(Thread.currentThread() + \u0026#34;waiting get resource2\u0026#34;); synchronized (resource2) { System.out.println(Thread.currentThread() + \u0026#34;get resource2\u0026#34;); } } }, \u0026#34;线程 1\u0026#34;).start(); new Thread(() -\u0026gt; { synchronized (resource2) { System.out.println(Thread.currentThread() + \u0026#34;get resource2\u0026#34;); try { Thread.sleep(1000); } catch (InterruptedException e) { e.printStackTrace(); } System.out.println(Thread.currentThread() + \u0026#34;waiting get resource1\u0026#34;); synchronized (resource1) { System.out.println(Thread.currentThread() + \u0026#34;get resource1\u0026#34;); } } }, \u0026#34;线程 2\u0026#34;).start(); } } Output\nThread[线程 1,5,main]get resource1 Thread[线程 2,5,main]get resource2 Thread[线程 1,5,main]waiting get resource2 Thread[线程 2,5,main]waiting get resource1 线程 A 通过 synchronized (resource1) 获得 resource1 的监视器锁,然后通过Thread.sleep(1000);让线程 A 休眠 1s 为的是让线程 B 得到执行然后获取到 resource2 的监视器锁。线程 A 和线程 B 休眠结束了都开始企图请求获取对方的资源,然后这两个线程就会陷入互相等待的状态,这也就产生了死锁。\n通过 jstack 命令分析:\nC:\\Users\\SnailClimb\u0026gt;jps 13792 KotlinCompileDaemon 7360 NettyClient2 17396 7972 Launcher 8932 Launcher 9256 DeadLockDemo 10764 Jps 17340 NettyServer C:\\Users\\SnailClimb\u0026gt;jstack 9256 输出的部分内容如下:\nFound one Java-level deadlock: ============================= \u0026#34;线程 2\u0026#34;: waiting to lock monitor 0x000000000333e668 (object 0x00000000d5efe1c0, a java.lang.Object), which is held by \u0026#34;线程 1\u0026#34; \u0026#34;线程 1\u0026#34;: waiting to lock monitor 0x000000000333be88 (object 0x00000000d5efe1d0, a java.lang.Object), which is held by \u0026#34;线程 2\u0026#34; Java stack information for the threads listed above: =================================================== \u0026#34;线程 2\u0026#34;: at DeadLockDemo.lambda$main$1(DeadLockDemo.java:31) - waiting to lock \u0026lt;0x00000000d5efe1c0\u0026gt; (a java.lang.Object) - locked \u0026lt;0x00000000d5efe1d0\u0026gt; (a java.lang.Object) at DeadLockDemo$$Lambda$2/1078694789.run(Unknown Source) at java.lang.Thread.run(Thread.java:748) \u0026#34;线程 1\u0026#34;: at DeadLockDemo.lambda$main$0(DeadLockDemo.java:16) - waiting to lock \u0026lt;0x00000000d5efe1d0\u0026gt; (a java.lang.Object) - locked \u0026lt;0x00000000d5efe1c0\u0026gt; (a java.lang.Object) at DeadLockDemo$$Lambda$1/1324119927.run(Unknown Source) at java.lang.Thread.run(Thread.java:748) Found 1 deadlock. 可以看到 jstack 命令已经帮我们找到发生死锁的线程的具体信息。\nJDK 可视化分析工具 # JConsole:Java 监视与管理控制台 # JConsole 是基于 JMX 的可视化监视、管理工具。可以很方便的监视本地及远程服务器的 java 进程的内存使用情况。你可以在控制台输入jconsole命令启动或者在 JDK 目录下的 bin 目录找到jconsole.exe然后双击启动。\n连接 Jconsole # 如果需要使用 JConsole 连接远程进程,可以在远程 Java 程序启动时加上下面这些参数:\n-Djava.rmi.server.hostname=外网访问 ip 地址 -Dcom.sun.management.jmxremote.port=60001 //监控的端口号 -Dcom.sun.management.jmxremote.authenticate=false //关闭认证 -Dcom.sun.management.jmxremote.ssl=false 在使用 JConsole 连接时,远程进程地址如下:\n外网访问 ip 地址:60001 查看 Java 程序概况 # 内存监控 # JConsole 可以显示当前内存的详细信息。不仅包括堆内存/非堆内存的整体信息,还可以细化到 eden 区、survivor 区等的使用情况,如下图所示。\n点击右边的“执行 GC(G)”按钮可以强制应用程序执行一个 Full GC。\n新生代 GC(Minor GC):指发生新生代的的垃圾收集动作,Minor GC 非常频繁,回收速度一般也比较快。 老年代 GC(Major GC/Full GC):指发生在老年代的 GC,出现了 Major GC 经常会伴随至少一次的 Minor GC(并非绝对),Major GC 的速度一般会比 Minor GC 的慢 10 倍以上。 线程监控 # 类似我们前面讲的 jstack 命令,不过这个是可视化的。\n最下面有一个\u0026quot;检测死锁 (D)\u0026ldquo;按钮,点击这个按钮可以自动为你找到发生死锁的线程以及它们的详细信息 。\nVisual VM:多合一故障处理工具 # VisualVM 提供在 Java 虚拟机 (Java Virtual Machine, JVM) 上运行的 Java 应用程序的详细信息。在 VisualVM 的图形用户界面中,您可以方便、快捷地查看多个 Java 应用程序的相关信息。Visual VM 官网: https://visualvm.github.io/ 。Visual VM 中文文档: https://visualvm.github.io/documentation.html。\n下面这段话摘自《深入理解 Java 虚拟机》。\nVisualVM(All-in-One Java Troubleshooting Tool)是到目前为止随 JDK 发布的功能最强大的运行监视和故障处理程序,官方在 VisualVM 的软件说明中写上了“All-in-One”的描述字样,预示着他除了运行监视、故障处理外,还提供了很多其他方面的功能,如性能分析(Profiling)。VisualVM 的性能分析功能甚至比起 JProfiler、YourKit 等专业且收费的 Profiling 工具都不会逊色多少,而且 VisualVM 还有一个很大的优点:不需要被监视的程序基于特殊 Agent 运行,因此他对应用程序的实际性能的影响很小,使得他可以直接应用在生产环境中。这个优点是 JProfiler、YourKit 等工具无法与之媲美的。\nVisualVM 基于 NetBeans 平台开发,因此他一开始就具备了插件扩展功能的特性,通过插件扩展支持,VisualVM 可以做到:\n显示虚拟机进程以及进程的配置、环境信息(jps、jinfo)。 监视应用程序的 CPU、GC、堆、方法区以及线程的信息(jstat、jstack)。 dump 以及分析堆转储快照(jmap、jhat)。 方法级的程序运行性能分析,找到被调用最多、运行时间最长的方法。 离线程序快照:收集程序的运行时配置、线程 dump、内存 dump 等信息建立一个快照,可以将快照发送开发者处进行 Bug 反馈。 其他 plugins 的无限的可能性…… 这里就不具体介绍 VisualVM 的使用,如果想了解的话可以看:\nhttps://visualvm.github.io/documentation.html https://www.ibm.com/developerworks/cn/java/j-lo-visualvm/index.html MAT:内存分析器工具 # MAT(Memory Analyzer Tool)是一款快速便捷且功能强大丰富的 JVM 堆内存离线分析工具。其通过展现 JVM 异常时所记录的运行时堆转储快照(Heap dump)状态(正常运行时也可以做堆转储分析),帮助定位内存泄漏问题或优化大内存消耗逻辑。\n在遇到 OOM 和 GC 问题的时候,我一般会首选使用 MAT 分析 dump 文件在,这也是该工具应用最多的一个场景。\n关于 MAT 的详细介绍推荐下面这两篇文章,写的很不错:\nJVM 内存分析工具 MAT 的深度讲解与实践—入门篇 JVM 内存分析工具 MAT 的深度讲解与实践—进阶篇 "},{"id":524,"href":"/zh/docs/technology/Interview/java/concurrent/jmm/","title":"JMM(Java 内存模型)详解","section":"Concurrent","content":"JMM(Java 内存模型)主要定义了对于一个共享变量,当另一个线程对这个共享变量执行写操作后,这个线程对这个共享变量的可见性。\n要想理解透彻 JMM(Java 内存模型),我们先要从 CPU 缓存模型和指令重排序 说起!\n从 CPU 缓存模型说起 # 为什么要弄一个 CPU 高速缓存呢? 类比我们开发网站后台系统使用的缓存(比如 Redis)是为了解决程序处理速度和访问常规关系型数据库速度不对等的问题。 CPU 缓存则是为了解决 CPU 处理速度和内存处理速度不对等的问题。\n我们甚至可以把 内存看作外存的高速缓存,程序运行的时候我们把外存的数据复制到内存,由于内存的处理速度远远高于外存,这样提高了处理速度。\n总结:CPU Cache 缓存的是内存数据用于解决 CPU 处理速度和内存不匹配的问题,内存缓存的是硬盘数据用于解决硬盘访问速度过慢的问题。\n为了更好地理解,我画了一个简单的 CPU Cache 示意图如下所示。\n🐛 修正(参见: issue#1848):对 CPU 缓存模型绘图不严谨的地方进行完善。\n现代的 CPU Cache 通常分为三层,分别叫 L1,L2,L3 Cache。有些 CPU 可能还有 L4 Cache,这里不做讨论,并不常见\nCPU Cache 的工作方式: 先复制一份数据到 CPU Cache 中,当 CPU 需要用到的时候就可以直接从 CPU Cache 中读取数据,当运算完成后,再将运算得到的数据写回 Main Memory 中。但是,这样存在 内存缓存不一致性的问题 !比如我执行一个 i++ 操作的话,如果两个线程同时执行的话,假设两个线程从 CPU Cache 中读取的 i=1,两个线程做了 i++ 运算完之后再写回 Main Memory 之后 i=2,而正确结果应该是 i=3。\nCPU 为了解决内存缓存不一致性问题可以通过制定缓存一致协议(比如 MESI 协议)或者其他手段来解决。 这个缓存一致性协议指的是在 CPU 高速缓存与主内存交互的时候需要遵守的原则和规范。不同的 CPU 中,使用的缓存一致性协议通常也会有所不同。\n我们的程序运行在操作系统之上,操作系统屏蔽了底层硬件的操作细节,将各种硬件资源虚拟化。于是,操作系统也就同样需要解决内存缓存不一致性问题。\n操作系统通过 内存模型(Memory Model) 定义一系列规范来解决这个问题。无论是 Windows 系统,还是 Linux 系统,它们都有特定的内存模型。\n指令重排序 # 说完了 CPU 缓存模型,我们再来看看另外一个比较重要的概念 指令重排序 。\n为了提升执行速度/性能,计算机在执行程序代码的时候,会对指令进行重排序。\n什么是指令重排序? 简单来说就是系统在执行代码的时候并不一定是按照你写的代码的顺序依次执行。\n常见的指令重排序有下面 2 种情况:\n编译器优化重排:编译器(包括 JVM、JIT 编译器等)在不改变单线程程序语义的前提下,重新安排语句的执行顺序。 指令并行重排:现代处理器采用了指令级并行技术(Instruction-Level Parallelism,ILP)来将多条指令重叠执行。如果不存在数据依赖性,处理器可以改变语句对应机器指令的执行顺序。 另外,内存系统也会有“重排序”,但又不是真正意义上的重排序。在 JMM 里表现为主存和本地内存的内容可能不一致,进而导致程序在多线程下执行可能出现问题。\nJava 源代码会经历 编译器优化重排 —\u0026gt; 指令并行重排 —\u0026gt; 内存系统重排 的过程,最终才变成操作系统可执行的指令序列。\n指令重排序可以保证串行语义一致,但是没有义务保证多线程间的语义也一致 ,所以在多线程下,指令重排序可能会导致一些问题。\n对于编译器优化重排和处理器的指令重排序(指令并行重排和内存系统重排都属于是处理器级别的指令重排序),处理该问题的方式不一样。\n对于编译器,通过禁止特定类型的编译器重排序的方式来禁止重排序。\n对于处理器,通过插入内存屏障(Memory Barrier,或有时叫做内存栅栏,Memory Fence)的方式来禁止特定类型的处理器重排序。\n内存屏障(Memory Barrier,或有时叫做内存栅栏,Memory Fence)是一种 CPU 指令,用来禁止处理器指令发生重排序(像屏障一样),从而保障指令执行的有序性。另外,为了达到屏障的效果,它也会使处理器写入、读取值之前,将主内存的值写入高速缓存,清空无效队列,从而保障变量的可见性。\nJMM(Java Memory Model) # 什么是 JMM?为什么需要 JMM? # Java 是最早尝试提供内存模型的编程语言。由于早期内存模型存在一些缺陷(比如非常容易削弱编译器的优化能力),从 Java5 开始,Java 开始使用新的内存模型 《JSR-133:Java Memory Model and Thread Specification》 。\n一般来说,编程语言也可以直接复用操作系统层面的内存模型。不过,不同的操作系统内存模型不同。如果直接复用操作系统层面的内存模型,就可能会导致同样一套代码换了一个操作系统就无法执行了。Java 语言是跨平台的,它需要自己提供一套内存模型以屏蔽系统差异。\n这只是 JMM 存在的其中一个原因。实际上,对于 Java 来说,你可以把 JMM 看作是 Java 定义的并发编程相关的一组规范,除了抽象了线程和主内存之间的关系之外,其还规定了从 Java 源代码到 CPU 可执行指令的这个转化过程要遵守哪些和并发相关的原则和规范,其主要目的是为了简化多线程编程,增强程序可移植性的。\n为什么要遵守这些并发相关的原则和规范呢? 这是因为并发编程下,像 CPU 多级缓存和指令重排这类设计可能会导致程序运行出现一些问题。就比如说我们上面提到的指令重排序就可能会让多线程程序的执行出现问题,为此,JMM 抽象了 happens-before 原则(后文会详细介绍到)来解决这个指令重排序问题。\nJMM 说白了就是定义了一些规范来解决这些问题,开发者可以利用这些规范更方便地开发多线程程序。对于 Java 开发者说,你不需要了解底层原理,直接使用并发相关的一些关键字和类(比如 volatile、synchronized、各种 Lock)即可开发出并发安全的程序。\nJMM 是如何抽象线程和主内存之间的关系? # Java 内存模型(JMM) 抽象了线程和主内存之间的关系,就比如说线程之间的共享变量必须存储在主内存中。\n在 JDK1.2 之前,Java 的内存模型实现总是从 主存 (即共享内存)读取变量,是不需要进行特别的注意的。而在当前的 Java 内存模型下,线程可以把变量保存 本地内存 (比如机器的寄存器)中,而不是直接在主存中进行读写。这就可能造成一个线程在主存中修改了一个变量的值,而另外一个线程还继续使用它在寄存器中的变量值的拷贝,造成数据的不一致。\n这和我们上面讲到的 CPU 缓存模型非常相似。\n什么是主内存?什么是本地内存?\n主内存:所有线程创建的实例对象都存放在主内存中,不管该实例对象是成员变量,还是局部变量,类信息、常量、静态变量都是放在主内存中。为了获取更好的运行速度,虚拟机及硬件系统可能会让工作内存优先存储于寄存器和高速缓存中。 本地内存:每个线程都有一个私有的本地内存,本地内存存储了该线程以读 / 写共享变量的副本。每个线程只能操作自己本地内存中的变量,无法直接访问其他线程的本地内存。如果线程间需要通信,必须通过主内存来进行。本地内存是 JMM 抽象出来的一个概念,并不真实存在,它涵盖了缓存、写缓冲区、寄存器以及其他的硬件和编译器优化。 Java 内存模型的抽象示意图如下:\n从上图来看,线程 1 与线程 2 之间如果要进行通信的话,必须要经历下面 2 个步骤:\n线程 1 把本地内存中修改过的共享变量副本的值同步到主内存中去。 线程 2 到主存中读取对应的共享变量的值。 也就是说,JMM 为共享变量提供了可见性的保障。\n不过,多线程下,对主内存中的一个共享变量进行操作有可能诱发线程安全问题。举个例子:\n线程 1 和线程 2 分别对同一个共享变量进行操作,一个执行修改,一个执行读取。 线程 2 读取到的是线程 1 修改之前的值还是修改后的值并不确定,都有可能,因为线程 1 和线程 2 都是先将共享变量从主内存拷贝到对应线程的工作内存中。 关于主内存与工作内存直接的具体交互协议,即一个变量如何从主内存拷贝到工作内存,如何从工作内存同步到主内存之间的实现细节,Java 内存模型定义来以下八种同步操作(了解即可,无需死记硬背):\n锁定(lock): 作用于主内存中的变量,将他标记为一个线程独享变量。 解锁(unlock): 作用于主内存中的变量,解除变量的锁定状态,被解除锁定状态的变量才能被其他线程锁定。 read(读取):作用于主内存的变量,它把一个变量的值从主内存传输到线程的工作内存中,以便随后的 load 动作使用。 load(载入):把 read 操作从主内存中得到的变量值放入工作内存的变量的副本中。 use(使用):把工作内存中的一个变量的值传给执行引擎,每当虚拟机遇到一个使用到变量的指令时都会使用该指令。 assign(赋值):作用于工作内存的变量,它把一个从执行引擎接收到的值赋给工作内存的变量,每当虚拟机遇到一个给变量赋值的字节码指令时执行这个操作。 store(存储):作用于工作内存的变量,它把工作内存中一个变量的值传送到主内存中,以便随后的 write 操作使用。 write(写入):作用于主内存的变量,它把 store 操作从工作内存中得到的变量的值放入主内存的变量中。 除了这 8 种同步操作之外,还规定了下面这些同步规则来保证这些同步操作的正确执行(了解即可,无需死记硬背):\n不允许一个线程无原因地(没有发生过任何 assign 操作)把数据从线程的工作内存同步回主内存中。 一个新的变量只能在主内存中 “诞生”,不允许在工作内存中直接使用一个未被初始化(load 或 assign)的变量,换句话说就是对一个变量实施 use 和 store 操作之前,必须先执行过了 assign 和 load 操作。 一个变量在同一个时刻只允许一条线程对其进行 lock 操作,但 lock 操作可以被同一条线程重复执行多次,多次执行 lock 后,只有执行相同次数的 unlock 操作,变量才会被解锁。 如果对一个变量执行 lock 操作,将会清空工作内存中此变量的值,在执行引擎使用这个变量前,需要重新执行 load 或 assign 操作初始化变量的值。 如果一个变量事先没有被 lock 操作锁定,则不允许对它执行 unlock 操作,也不允许去 unlock 一个被其他线程锁定住的变量。 …… Java 内存区域和 JMM 有何区别? # 这是一个比较常见的问题,很多初学者非常容易搞混。 Java 内存区域和内存模型是完全不一样的两个东西:\nJVM 内存结构和 Java 虚拟机的运行时区域相关,定义了 JVM 在运行时如何分区存储程序数据,就比如说堆主要用于存放对象实例。 Java 内存模型和 Java 的并发编程相关,抽象了线程和主内存之间的关系就比如说线程之间的共享变量必须存储在主内存中,规定了从 Java 源代码到 CPU 可执行指令的这个转化过程要遵守哪些和并发相关的原则和规范,其主要目的是为了简化多线程编程,增强程序可移植性的。 happens-before 原则是什么? # happens-before 这个概念最早诞生于 Leslie Lamport 于 1978 年发表的论文 《Time,Clocks and the Ordering of Events in a Distributed System》。在这篇论文中,Leslie Lamport 提出了 逻辑时钟的概念,这也成了第一个逻辑时钟算法 。在分布式环境中,通过一系列规则来定义逻辑时钟的变化,从而能通过逻辑时钟来对分布式系统中的事件的先后顺序进行判断。逻辑时钟并不度量时间本身,仅区分事件发生的前后顺序,其本质就是定义了一种 happens-before 关系。\n上面提到的 happens-before 这个概念诞生的背景并不是重点,简单了解即可。\nJSR 133 引入了 happens-before 这个概念来描述两个操作之间的内存可见性。\n为什么需要 happens-before 原则? happens-before 原则的诞生是为了程序员和编译器、处理器之间的平衡。程序员追求的是易于理解和编程的强内存模型,遵守既定规则编码即可。编译器和处理器追求的是较少约束的弱内存模型,让它们尽己所能地去优化性能,让性能最大化。happens-before 原则的设计思想其实非常简单:\n为了对编译器和处理器的约束尽可能少,只要不改变程序的执行结果(单线程程序和正确执行的多线程程序),编译器和处理器怎么进行重排序优化都行。 对于会改变程序执行结果的重排序,JMM 要求编译器和处理器必须禁止这种重排序。 下面这张是 《Java 并发编程的艺术》这本书中的一张 JMM 设计思想的示意图,非常清晰。\n了解了 happens-before 原则的设计思想,我们再来看看 JSR-133 对 happens-before 原则的定义:\n如果一个操作 happens-before 另一个操作,那么第一个操作的执行结果将对第二个操作可见,并且第一个操作的执行顺序排在第二个操作之前。 两个操作之间存在 happens-before 关系,并不意味着 Java 平台的具体实现必须要按照 happens-before 关系指定的顺序来执行。如果重排序之后的执行结果,与按 happens-before 关系来执行的结果一致,那么 JMM 也允许这样的重排序。 我们看下面这段代码:\nint userNum = getUserNum(); // 1 int teacherNum = getTeacherNum(); // 2 int totalNum = userNum + teacherNum; // 3 1 happens-before 2 2 happens-before 3 1 happens-before 3 虽然 1 happens-before 2,但对 1 和 2 进行重排序不会影响代码的执行结果,所以 JMM 是允许编译器和处理器执行这种重排序的。但 1 和 2 必须是在 3 执行之前,也就是说 1,2 happens-before 3 。\nhappens-before 原则表达的意义其实并不是一个操作发生在另外一个操作的前面,虽然这从程序员的角度上来说也并无大碍。更准确地来说,它更想表达的意义是前一个操作的结果对于后一个操作是可见的,无论这两个操作是否在同一个线程里。\n举个例子:操作 1 happens-before 操作 2,即使操作 1 和操作 2 不在同一个线程内,JMM 也会保证操作 1 的结果对操作 2 是可见的。\nhappens-before 常见规则有哪些?谈谈你的理解? # happens-before 的规则就 8 条,说多不多,重点了解下面列举的 5 条即可。全记是不可能的,很快就忘记了,意义不大,随时查阅即可。\n程序顺序规则:一个线程内,按照代码顺序,书写在前面的操作 happens-before 于书写在后面的操作; 解锁规则:解锁 happens-before 于加锁; volatile 变量规则:对一个 volatile 变量的写操作 happens-before 于后面对这个 volatile 变量的读操作。说白了就是对 volatile 变量的写操作的结果对于发生于其后的任何操作都是可见的。 传递规则:如果 A happens-before B,且 B happens-before C,那么 A happens-before C; 线程启动规则:Thread 对象的 start()方法 happens-before 于此线程的每一个动作。 如果两个操作不满足上述任意一个 happens-before 规则,那么这两个操作就没有顺序的保障,JVM 可以对这两个操作进行重排序。\nhappens-before 和 JMM 什么关系? # happens-before 与 JMM 的关系用《Java 并发编程的艺术》这本书中的一张图就可以非常好的解释清楚。\n再看并发编程三个重要特性 # 原子性 # 一次操作或者多次操作,要么所有的操作全部都得到执行并且不会受到任何因素的干扰而中断,要么都不执行。\n在 Java 中,可以借助synchronized、各种 Lock 以及各种原子类实现原子性。\nsynchronized 和各种 Lock 可以保证任一时刻只有一个线程访问该代码块,因此可以保障原子性。各种原子类是利用 CAS (compare and swap) 操作(可能也会用到 volatile或者final关键字)来保证原子操作。\n可见性 # 当一个线程对共享变量进行了修改,那么另外的线程都是立即可以看到修改后的最新值。\n在 Java 中,可以借助synchronized、volatile 以及各种 Lock 实现可见性。\n如果我们将变量声明为 volatile ,这就指示 JVM,这个变量是共享且不稳定的,每次使用它都到主存中进行读取。\n有序性 # 由于指令重排序问题,代码的执行顺序未必就是编写代码时候的顺序。\n我们上面讲重排序的时候也提到过:\n指令重排序可以保证串行语义一致,但是没有义务保证多线程间的语义也一致 ,所以在多线程下,指令重排序可能会导致一些问题。\n在 Java 中,volatile 关键字可以禁止指令进行重排序优化。\n总结 # Java 是最早尝试提供内存模型的语言,其主要目的是为了简化多线程编程,增强程序可移植性的。 CPU 可以通过制定缓存一致协议(比如 MESI 协议)来解决内存缓存不一致性问题。 为了提升执行速度/性能,计算机在执行程序代码的时候,会对指令进行重排序。 简单来说就是系统在执行代码的时候并不一定是按照你写的代码的顺序依次执行。指令重排序可以保证串行语义一致,但是没有义务保证多线程间的语义也一致 ,所以在多线程下,指令重排序可能会导致一些问题。 你可以把 JMM 看作是 Java 定义的并发编程相关的一组规范,除了抽象了线程和主内存之间的关系之外,其还规定了从 Java 源代码到 CPU 可执行指令的这个转化过程要遵守哪些和并发相关的原则和规范,其主要目的是为了简化多线程编程,增强程序可移植性的。 JSR 133 引入了 happens-before 这个概念来描述两个操作之间的内存可见性。 参考 # 《Java 并发编程的艺术》第三章 Java 内存模型 《深入浅出 Java 多线程》: http://concurrent.redspider.group/RedSpider.html Java 内存访问重排序的研究: https://tech.meituan.com/2014/09/23/java-memory-reordering.html 嘿,同学,你要的 Java 内存模型 (JMM) 来了: https://xie.infoq.cn/article/739920a92d0d27e2053174ef2 JSR 133 (Java Memory Model) FAQ: https://www.cs.umd.edu/~pugh/java/memoryModel/jsr-133-faq.html "},{"id":525,"href":"/zh/docs/technology/Interview/java/jvm/jvm-garbage-collection/","title":"JVM垃圾回收详解(重点)","section":"Jvm","content":" 如果没有特殊说明,都是针对的是 HotSpot 虚拟机。\n本文基于《深入理解 Java 虚拟机:JVM 高级特性与最佳实践》进行总结补充。\n常见面试题:\n如何判断对象是否死亡(两种方法)。 简单的介绍一下强引用、软引用、弱引用、虚引用(虚引用与软引用和弱引用的区别、使用软引用能带来的好处)。 如何判断一个常量是废弃常量 如何判断一个类是无用的类 垃圾收集有哪些算法,各自的特点? HotSpot 为什么要分为新生代和老年代? 常见的垃圾回收器有哪些? 介绍一下 CMS,G1 收集器。 Minor Gc 和 Full GC 有什么不同呢? 前言 # 当需要排查各种内存溢出问题、当垃圾收集成为系统达到更高并发的瓶颈时,我们就需要对这些“自动化”的技术实施必要的监控和调节。\n堆空间的基本结构 # Java 的自动内存管理主要是针对对象内存的回收和对象内存的分配。同时,Java 自动内存管理最核心的功能是 堆 内存中对象的分配与回收。\nJava 堆是垃圾收集器管理的主要区域,因此也被称作 GC 堆(Garbage Collected Heap)。\n从垃圾回收的角度来说,由于现在收集器基本都采用分代垃圾收集算法,所以 Java 堆被划分为了几个不同的区域,这样我们就可以根据各个区域的特点选择合适的垃圾收集算法。\n在 JDK 7 版本及 JDK 7 版本之前,堆内存被通常分为下面三部分:\n新生代内存(Young Generation) 老生代(Old Generation) 永久代(Permanent Generation) 下图所示的 Eden 区、两个 Survivor 区 S0 和 S1 都属于新生代,中间一层属于老年代,最下面一层属于永久代。\nJDK 8 版本之后 PermGen(永久) 已被 Metaspace(元空间) 取代,元空间使用的是直接内存 。\n关于堆空间结构更详细的介绍,可以回过头看看 Java 内存区域详解 这篇文章。\n内存分配和回收原则 # 对象优先在 Eden 区分配 # 大多数情况下,对象在新生代中 Eden 区分配。当 Eden 区没有足够空间进行分配时,虚拟机将发起一次 Minor GC。下面我们来进行实际测试一下。\n测试代码:\npublic class GCTest { public static void main(String[] args) { byte[] allocation1, allocation2; allocation1 = new byte[30900*1024]; } } 通过以下方式运行: 添加的参数:-XX:+PrintGCDetails 运行结果 (红色字体描述有误,应该是对应于 JDK1.7 的永久代):\n从上图我们可以看出 Eden 区内存几乎已经被分配完全(即使程序什么也不做,新生代也会使用 2000 多 k 内存)。\n假如我们再为 allocation2 分配内存会出现什么情况呢?\nallocation2 = new byte[900*1024]; 给 allocation2 分配内存的时候 Eden 区内存几乎已经被分配完了\n当 Eden 区没有足够空间进行分配时,虚拟机将发起一次 Minor GC。GC 期间虚拟机又发现 allocation1 无法存入 Survivor 空间,所以只好通过 分配担保机制 把新生代的对象提前转移到老年代中去,老年代上的空间足够存放 allocation1,所以不会出现 Full GC。执行 Minor GC 后,后面分配的对象如果能够存在 Eden 区的话,还是会在 Eden 区分配内存。可以执行如下代码验证:\npublic class GCTest { public static void main(String[] args) { byte[] allocation1, allocation2,allocation3,allocation4,allocation5; allocation1 = new byte[32000*1024]; allocation2 = new byte[1000*1024]; allocation3 = new byte[1000*1024]; allocation4 = new byte[1000*1024]; allocation5 = new byte[1000*1024]; } } 大对象直接进入老年代 # 大对象就是需要大量连续内存空间的对象(比如:字符串、数组)。\n大对象直接进入老年代的行为是由虚拟机动态决定的,它与具体使用的垃圾回收器和相关参数有关。大对象直接进入老年代是一种优化策略,旨在避免将大对象放入新生代,从而减少新生代的垃圾回收频率和成本。\nG1 垃圾回收器会根据 -XX:G1HeapRegionSize 参数设置的堆区域大小和 -XX:G1MixedGCLiveThresholdPercent 参数设置的阈值,来决定哪些对象会直接进入老年代。 Parallel Scavenge 垃圾回收器中,默认情况下,并没有一个固定的阈值(XX:ThresholdTolerance是动态调整的)来决定何时直接在老年代分配大对象。而是由虚拟机根据当前的堆内存情况和历史数据动态决定。 长期存活的对象将进入老年代 # 既然虚拟机采用了分代收集的思想来管理内存,那么内存回收时就必须能识别哪些对象应放在新生代,哪些对象应放在老年代中。为了做到这一点,虚拟机给每个对象一个对象年龄(Age)计数器。\n大部分情况,对象都会首先在 Eden 区域分配。如果对象在 Eden 出生并经过第一次 Minor GC 后仍然能够存活,并且能被 Survivor 容纳的话,将被移动到 Survivor 空间(s0 或者 s1)中,并将对象年龄设为 1(Eden 区-\u0026gt;Survivor 区后对象的初始年龄变为 1)。\n对象在 Survivor 中每熬过一次 MinorGC,年龄就增加 1 岁,当它的年龄增加到一定程度(默认为 15 岁),就会被晋升到老年代中。对象晋升到老年代的年龄阈值,可以通过参数 -XX:MaxTenuringThreshold 来设置。\n修正( issue552):“Hotspot 遍历所有对象时,按照年龄从小到大对其所占用的大小进行累积,当累积的某个年龄大小超过了 survivor 区的 50% 时(默认值是 50%,可以通过 -XX:TargetSurvivorRatio=percent 来设置,参见 issue1199 ),取这个年龄和 MaxTenuringThreshold 中更小的一个值,作为新的晋升年龄阈值”。\njdk8 官方文档引用: https://docs.oracle.com/javase/8/docs/technotes/tools/unix/java.html。\n动态年龄计算的代码如下:\nuint ageTable::compute_tenuring_threshold(size_t survivor_capacity) { //survivor_capacity是survivor空间的大小 size_t desired_survivor_size = (size_t)((((double)survivor_capacity)*TargetSurvivorRatio)/100); size_t total = 0; uint age = 1; while (age \u0026lt; table_size) { //sizes数组是每个年龄段对象大小 total += sizes[age]; if (total \u0026gt; desired_survivor_size) { break; } age++; } uint result = age \u0026lt; MaxTenuringThreshold ? age : MaxTenuringThreshold; ... } 额外补充说明( issue672):关于默认的晋升年龄是 15,这个说法的来源大部分都是《深入理解 Java 虚拟机》这本书。 如果你去 Oracle 的官网阅读 相关的虚拟机参数,你会发现-XX:MaxTenuringThreshold=threshold这里有个说明\nSets the maximum tenuring threshold for use in adaptive GC sizing. The largest value is 15. The default value is 15 for the parallel (throughput) collector, and 6 for the CMS collector.默认晋升年龄并不都是 15,这个是要区分垃圾收集器的,CMS 就是 6.\n主要进行 gc 的区域 # 周志明先生在《深入理解 Java 虚拟机》第二版中 P92 如是写道:\n“老年代 GC(Major GC/Full GC),指发生在老年代的 GC……”\n上面的说法已经在《深入理解 Java 虚拟机》第三版中被改正过来了。感谢 R 大的回答:\n总结:\n针对 HotSpot VM 的实现,它里面的 GC 其实准确分类只有两大种:\n部分收集 (Partial GC):\n新生代收集(Minor GC / Young GC):只对新生代进行垃圾收集; 老年代收集(Major GC / Old GC):只对老年代进行垃圾收集。需要注意的是 Major GC 在有的语境中也用于指代整堆收集; 混合收集(Mixed GC):对整个新生代和部分老年代进行垃圾收集。 整堆收集 (Full GC):收集整个 Java 堆和方法区。\n空间分配担保 # 空间分配担保是为了确保在 Minor GC 之前老年代本身还有容纳新生代所有对象的剩余空间。\n《深入理解 Java 虚拟机》第三章对于空间分配担保的描述如下:\nJDK 6 Update 24 之前,在发生 Minor GC 之前,虚拟机必须先检查老年代最大可用的连续空间是否大于新生代所有对象总空间,如果这个条件成立,那这一次 Minor GC 可以确保是安全的。如果不成立,则虚拟机会先查看 -XX:HandlePromotionFailure 参数的设置值是否允许担保失败(Handle Promotion Failure);如果允许,那会继续检查老年代最大可用的连续空间是否大于历次晋升到老年代对象的平均大小,如果大于,将尝试进行一次 Minor GC,尽管这次 Minor GC 是有风险的;如果小于,或者 -XX: HandlePromotionFailure 设置不允许冒险,那这时就要改为进行一次 Full GC。\nJDK 6 Update 24 之后的规则变为只要老年代的连续空间大于新生代对象总大小或者历次晋升的平均大小,就会进行 Minor GC,否则将进行 Full GC。\n死亡对象判断方法 # 堆中几乎放着所有的对象实例,对堆垃圾回收前的第一步就是要判断哪些对象已经死亡(即不能再被任何途径使用的对象)。\n引用计数法 # 给对象中添加一个引用计数器:\n每当有一个地方引用它,计数器就加 1; 当引用失效,计数器就减 1; 任何时候计数器为 0 的对象就是不可能再被使用的。 这个方法实现简单,效率高,但是目前主流的虚拟机中并没有选择这个算法来管理内存,其最主要的原因是它很难解决对象之间循环引用的问题。\n所谓对象之间的相互引用问题,如下面代码所示:除了对象 objA 和 objB 相互引用着对方之外,这两个对象之间再无任何引用。但是他们因为互相引用对方,导致它们的引用计数器都不为 0,于是引用计数算法无法通知 GC 回收器回收他们。\npublic class ReferenceCountingGc { Object instance = null; public static void main(String[] args) { ReferenceCountingGc objA = new ReferenceCountingGc(); ReferenceCountingGc objB = new ReferenceCountingGc(); objA.instance = objB; objB.instance = objA; objA = null; objB = null; } } 可达性分析算法 # 这个算法的基本思想就是通过一系列的称为 “GC Roots” 的对象作为起点,从这些节点开始向下搜索,节点所走过的路径称为引用链,当一个对象到 GC Roots 没有任何引用链相连的话,则证明此对象是不可用的,需要被回收。\n下图中的 Object 6 ~ Object 10 之间虽有引用关系,但它们到 GC Roots 不可达,因此为需要被回收的对象。\n哪些对象可以作为 GC Roots 呢?\n虚拟机栈(栈帧中的局部变量表)中引用的对象 本地方法栈(Native 方法)中引用的对象 方法区中类静态属性引用的对象 方法区中常量引用的对象 所有被同步锁持有的对象 JNI(Java Native Interface)引用的对象 对象可以被回收,就代表一定会被回收吗?\n即使在可达性分析法中不可达的对象,也并非是“非死不可”的,这时候它们暂时处于“缓刑阶段”,要真正宣告一个对象死亡,至少要经历两次标记过程;可达性分析法中不可达的对象被第一次标记并且进行一次筛选,筛选的条件是此对象是否有必要执行 finalize 方法。当对象没有覆盖 finalize 方法,或 finalize 方法已经被虚拟机调用过时,虚拟机将这两种情况视为没有必要执行。\n被判定为需要执行的对象将会被放在一个队列中进行第二次标记,除非这个对象与引用链上的任何一个对象建立关联,否则就会被真的回收。\nObject 类中的 finalize 方法一直被认为是一个糟糕的设计,成为了 Java 语言的负担,影响了 Java 语言的安全和 GC 的性能。JDK9 版本及后续版本中各个类中的 finalize 方法会被逐渐弃用移除。忘掉它的存在吧!\n参考:\nJEP 421: Deprecate Finalization for Removal 是时候忘掉 finalize 方法了 引用类型总结 # 无论是通过引用计数法判断对象引用数量,还是通过可达性分析法判断对象的引用链是否可达,判定对象的存活都与“引用”有关。\nJDK1.2 之前,Java 中引用的定义很传统:如果 reference 类型的数据存储的数值代表的是另一块内存的起始地址,就称这块内存代表一个引用。\nJDK1.2 以后,Java 对引用的概念进行了扩充,将引用分为强引用、软引用、弱引用、虚引用四种(引用强度逐渐减弱)\n1.强引用(StrongReference)\n以前我们使用的大部分引用实际上都是强引用,这是使用最普遍的引用。如果一个对象具有强引用,那就类似于必不可少的生活用品,垃圾回收器绝不会回收它。当内存空间不足,Java 虚拟机宁愿抛出 OutOfMemoryError 错误,使程序异常终止,也不会靠随意回收具有强引用的对象来解决内存不足问题。\n2.软引用(SoftReference)\n如果一个对象只具有软引用,那就类似于可有可无的生活用品。如果内存空间足够,垃圾回收器就不会回收它,如果内存空间不足了,就会回收这些对象的内存。只要垃圾回收器没有回收它,该对象就可以被程序使用。软引用可用来实现内存敏感的高速缓存。\n软引用可以和一个引用队列(ReferenceQueue)联合使用,如果软引用所引用的对象被垃圾回收,JAVA 虚拟机就会把这个软引用加入到与之关联的引用队列中。\n3.弱引用(WeakReference)\n如果一个对象只具有弱引用,那就类似于可有可无的生活用品。弱引用与软引用的区别在于:只具有弱引用的对象拥有更短暂的生命周期。在垃圾回收器线程扫描它所管辖的内存区域的过程中,一旦发现了只具有弱引用的对象,不管当前内存空间足够与否,都会回收它的内存。不过,由于垃圾回收器是一个优先级很低的线程, 因此不一定会很快发现那些只具有弱引用的对象。\n弱引用可以和一个引用队列(ReferenceQueue)联合使用,如果弱引用所引用的对象被垃圾回收,Java 虚拟机就会把这个弱引用加入到与之关联的引用队列中。\n4.虚引用(PhantomReference)\n\u0026ldquo;虚引用\u0026quot;顾名思义,就是形同虚设,与其他几种引用都不同,虚引用并不会决定对象的生命周期。如果一个对象仅持有虚引用,那么它就和没有任何引用一样,在任何时候都可能被垃圾回收。\n虚引用主要用来跟踪对象被垃圾回收的活动。\n虚引用与软引用和弱引用的一个区别在于: 虚引用必须和引用队列(ReferenceQueue)联合使用。当垃圾回收器准备回收一个对象时,如果发现它还有虚引用,就会在回收对象的内存之前,把这个虚引用加入到与之关联的引用队列中。程序可以通过判断引用队列中是否已经加入了虚引用,来了解被引用的对象是否将要被垃圾回收。程序如果发现某个虚引用已经被加入到引用队列,那么就可以在所引用的对象的内存被回收之前采取必要的行动。\n特别注意,在程序设计中一般很少使用弱引用与虚引用,使用软引用的情况较多,这是因为软引用可以加速 JVM 对垃圾内存的回收速度,可以维护系统的运行安全,防止内存溢出(OutOfMemory)等问题的产生。\n如何判断一个常量是废弃常量? # 运行时常量池主要回收的是废弃的常量。那么,我们如何判断一个常量是废弃常量呢?\nJDK1.7 及之后版本的 JVM 已经将运行时常量池从方法区中移了出来,在 Java 堆(Heap)中开辟了一块区域存放运行时常量池。\n🐛 修正(参见: issue747, reference):\nJDK1.7 之前运行时常量池逻辑包含字符串常量池存放在方法区, 此时 hotspot 虚拟机对方法区的实现为永久代 JDK1.7 字符串常量池被从方法区拿到了堆中, 这里没有提到运行时常量池,也就是说字符串常量池被单独拿到堆,运行时常量池剩下的东西还在方法区, 也就是 hotspot 中的永久代 。 JDK1.8 hotspot 移除了永久代用元空间(Metaspace)取而代之, 这时候字符串常量池还在堆, 运行时常量池还在方法区, 只不过方法区的实现从永久代变成了元空间(Metaspace) 假如在字符串常量池中存在字符串 \u0026ldquo;abc\u0026rdquo;,如果当前没有任何 String 对象引用该字符串常量的话,就说明常量 \u0026ldquo;abc\u0026rdquo; 就是废弃常量,如果这时发生内存回收的话而且有必要的话,\u0026ldquo;abc\u0026rdquo; 就会被系统清理出常量池了。\n如何判断一个类是无用的类? # 方法区主要回收的是无用的类,那么如何判断一个类是无用的类的呢?\n判定一个常量是否是“废弃常量”比较简单,而要判定一个类是否是“无用的类”的条件则相对苛刻许多。类需要同时满足下面 3 个条件才能算是 “无用的类”:\n该类所有的实例都已经被回收,也就是 Java 堆中不存在该类的任何实例。 加载该类的 ClassLoader 已经被回收。 该类对应的 java.lang.Class 对象没有在任何地方被引用,无法在任何地方通过反射访问该类的方法。 虚拟机可以对满足上述 3 个条件的无用类进行回收,这里说的仅仅是“可以”,而并不是和对象一样不使用了就会必然被回收。\n垃圾收集算法 # 标记-清除算法 # 标记-清除(Mark-and-Sweep)算法分为“标记(Mark)”和“清除(Sweep)”阶段:首先标记出所有不需要回收的对象,在标记完成后统一回收掉所有没有被标记的对象。\n它是最基础的收集算法,后续的算法都是对其不足进行改进得到。这种垃圾收集算法会带来两个明显的问题:\n效率问题:标记和清除两个过程效率都不高。 空间问题:标记清除后会产生大量不连续的内存碎片。 关于具体是标记可回收对象(不可达对象)还是不可回收对象(可达对象),众说纷纭,两种说法其实都没问题,我个人更倾向于是后者。\n如果按照前者的理解,整个标记-清除过程大致是这样的:\n当一个对象被创建时,给一个标记位,假设为 0 (false); 在标记阶段,我们将所有可达对象(或用户可以引用的对象)的标记位设置为 1 (true); 扫描阶段清除的就是标记位为 0 (false)的对象。 复制算法 # 为了解决标记-清除算法的效率和内存碎片问题,复制(Copying)收集算法出现了。它可以将内存分为大小相同的两块,每次使用其中的一块。当这一块的内存使用完后,就将还存活的对象复制到另一块去,然后再把使用的空间一次清理掉。这样就使每次的内存回收都是对内存区间的一半进行回收。\n虽然改进了标记-清除算法,但依然存在下面这些问题:\n可用内存变小:可用内存缩小为原来的一半。 不适合老年代:如果存活对象数量比较大,复制性能会变得很差。 标记-整理算法 # 标记-整理(Mark-and-Compact)算法是根据老年代的特点提出的一种标记算法,标记过程仍然与“标记-清除”算法一样,但后续步骤不是直接对可回收对象回收,而是让所有存活的对象向一端移动,然后直接清理掉端边界以外的内存。\n由于多了整理这一步,因此效率也不高,适合老年代这种垃圾回收频率不是很高的场景。\n分代收集算法 # 当前虚拟机的垃圾收集都采用分代收集算法,这种算法没有什么新的思想,只是根据对象存活周期的不同将内存分为几块。一般将 Java 堆分为新生代和老年代,这样我们就可以根据各个年代的特点选择合适的垃圾收集算法。\n比如在新生代中,每次收集都会有大量对象死去,所以可以选择“复制”算法,只需要付出少量对象的复制成本就可以完成每次垃圾收集。而老年代的对象存活几率是比较高的,而且没有额外的空间对它进行分配担保,所以我们必须选择“标记-清除”或“标记-整理”算法进行垃圾收集。\n延伸面试问题: HotSpot 为什么要分为新生代和老年代?\n根据上面的对分代收集算法的介绍回答。\n垃圾收集器 # 如果说收集算法是内存回收的方法论,那么垃圾收集器就是内存回收的具体实现。\n虽然我们对各个收集器进行比较,但并非要挑选出一个最好的收集器。因为直到现在为止还没有最好的垃圾收集器出现,更加没有万能的垃圾收集器,我们能做的就是根据具体应用场景选择适合自己的垃圾收集器。试想一下:如果有一种四海之内、任何场景下都适用的完美收集器存在,那么我们的 HotSpot 虚拟机就不会实现那么多不同的垃圾收集器了。\nJDK 默认垃圾收集器(使用 java -XX:+PrintCommandLineFlags -version 命令查看):\nJDK 8: Parallel Scavenge(新生代)+ Parallel Old(老年代) JDK 9 ~ JDK22: G1 Serial 收集器 # Serial(串行)收集器是最基本、历史最悠久的垃圾收集器了。大家看名字就知道这个收集器是一个单线程收集器了。它的 “单线程” 的意义不仅仅意味着它只会使用一条垃圾收集线程去完成垃圾收集工作,更重要的是它在进行垃圾收集工作的时候必须暂停其他所有的工作线程( \u0026ldquo;Stop The World\u0026rdquo; ),直到它收集结束。\n新生代采用标记-复制算法,老年代采用标记-整理算法。\n虚拟机的设计者们当然知道 Stop The World 带来的不良用户体验,所以在后续的垃圾收集器设计中停顿时间在不断缩短(仍然还有停顿,寻找最优秀的垃圾收集器的过程仍然在继续)。\n但是 Serial 收集器有没有优于其他垃圾收集器的地方呢?当然有,它简单而高效(与其他收集器的单线程相比)。Serial 收集器由于没有线程交互的开销,自然可以获得很高的单线程收集效率。Serial 收集器对于运行在 Client 模式下的虚拟机来说是个不错的选择。\nParNew 收集器 # ParNew 收集器其实就是 Serial 收集器的多线程版本,除了使用多线程进行垃圾收集外,其余行为(控制参数、收集算法、回收策略等等)和 Serial 收集器完全一样。\n新生代采用标记-复制算法,老年代采用标记-整理算法。\n它是许多运行在 Server 模式下的虚拟机的首要选择,除了 Serial 收集器外,只有它能与 CMS 收集器(真正意义上的并发收集器,后面会介绍到)配合工作。\n并行和并发概念补充:\n并行(Parallel):指多条垃圾收集线程并行工作,但此时用户线程仍然处于等待状态。\n并发(Concurrent):指用户线程与垃圾收集线程同时执行(但不一定是并行,可能会交替执行),用户程序在继续运行,而垃圾收集器运行在另一个 CPU 上。\nParallel Scavenge 收集器 # Parallel Scavenge 收集器也是使用标记-复制算法的多线程收集器,它看上去几乎和 ParNew 都一样。 那么它有什么特别之处呢?\n-XX:+UseParallelGC 使用 Parallel 收集器+ 老年代串行 -XX:+UseParallelOldGC 使用 Parallel 收集器+ 老年代并行 Parallel Scavenge 收集器关注点是吞吐量(高效率的利用 CPU)。CMS 等垃圾收集器的关注点更多的是用户线程的停顿时间(提高用户体验)。所谓吞吐量就是 CPU 中用于运行用户代码的时间与 CPU 总消耗时间的比值。 Parallel Scavenge 收集器提供了很多参数供用户找到最合适的停顿时间或最大吞吐量,如果对于收集器运作不太了解,手工优化存在困难的时候,使用 Parallel Scavenge 收集器配合自适应调节策略,把内存管理优化交给虚拟机去完成也是一个不错的选择。\n新生代采用标记-复制算法,老年代采用标记-整理算法。\n这是 JDK1.8 默认收集器\n使用 java -XX:+PrintCommandLineFlags -version 命令查看\n-XX:InitialHeapSize=262921408 -XX:MaxHeapSize=4206742528 -XX:+PrintCommandLineFlags -XX:+UseCompressedClassPointers -XX:+UseCompressedOops -XX:+UseParallelGC java version \u0026#34;1.8.0_211\u0026#34; Java(TM) SE Runtime Environment (build 1.8.0_211-b12) Java HotSpot(TM) 64-Bit Server VM (build 25.211-b12, mixed mode) JDK1.8 默认使用的是 Parallel Scavenge + Parallel Old,如果指定了-XX:+UseParallelGC 参数,则默认指定了-XX:+UseParallelOldGC,可以使用-XX:-UseParallelOldGC 来禁用该功能\nSerial Old 收集器 # Serial 收集器的老年代版本,它同样是一个单线程收集器。它主要有两大用途:一种用途是在 JDK1.5 以及以前的版本中与 Parallel Scavenge 收集器搭配使用,另一种用途是作为 CMS 收集器的后备方案。\nParallel Old 收集器 # Parallel Scavenge 收集器的老年代版本。使用多线程和“标记-整理”算法。在注重吞吐量以及 CPU 资源的场合,都可以优先考虑 Parallel Scavenge 收集器和 Parallel Old 收集器。\nCMS 收集器 # CMS(Concurrent Mark Sweep)收集器是一种以获取最短回收停顿时间为目标的收集器。它非常符合在注重用户体验的应用上使用。\nCMS(Concurrent Mark Sweep)收集器是 HotSpot 虚拟机第一款真正意义上的并发收集器,它第一次实现了让垃圾收集线程与用户线程(基本上)同时工作。\n从名字中的Mark Sweep这两个词可以看出,CMS 收集器是一种 “标记-清除”算法实现的,它的运作过程相比于前面几种垃圾收集器来说更加复杂一些。整个过程分为四个步骤:\n初始标记: 短暂停顿,标记直接与 root 相连的对象(根对象); 并发标记: 同时开启 GC 和用户线程,用一个闭包结构去记录可达对象。但在这个阶段结束,这个闭包结构并不能保证包含当前所有的可达对象。因为用户线程可能会不断的更新引用域,所以 GC 线程无法保证可达性分析的实时性。所以这个算法里会跟踪记录这些发生引用更新的地方。 重新标记: 重新标记阶段就是为了修正并发标记期间因为用户程序继续运行而导致标记产生变动的那一部分对象的标记记录,这个阶段的停顿时间一般会比初始标记阶段的时间稍长,远远比并发标记阶段时间短 并发清除: 开启用户线程,同时 GC 线程开始对未标记的区域做清扫。 从它的名字就可以看出它是一款优秀的垃圾收集器,主要优点:并发收集、低停顿。但是它有下面三个明显的缺点:\n对 CPU 资源敏感; 无法处理浮动垃圾; 它使用的回收算法-“标记-清除”算法会导致收集结束时会有大量空间碎片产生。 CMS 垃圾回收器在 Java 9 中已经被标记为过时(deprecated),并在 Java 14 中被移除。\nG1 收集器 # G1 (Garbage-First) 是一款面向服务器的垃圾收集器,主要针对配备多颗处理器及大容量内存的机器. 以极高概率满足 GC 停顿时间要求的同时,还具备高吞吐量性能特征。\n被视为 JDK1.7 中 HotSpot 虚拟机的一个重要进化特征。它具备以下特点:\n并行与并发:G1 能充分利用 CPU、多核环境下的硬件优势,使用多个 CPU(CPU 或者 CPU 核心)来缩短 Stop-The-World 停顿时间。部分其他收集器原本需要停顿 Java 线程执行的 GC 动作,G1 收集器仍然可以通过并发的方式让 java 程序继续执行。 分代收集:虽然 G1 可以不需要其他收集器配合就能独立管理整个 GC 堆,但是还是保留了分代的概念。 空间整合:与 CMS 的“标记-清除”算法不同,G1 从整体来看是基于“标记-整理”算法实现的收集器;从局部上来看是基于“标记-复制”算法实现的。 可预测的停顿:这是 G1 相对于 CMS 的另一个大优势,降低停顿时间是 G1 和 CMS 共同的关注点,但 G1 除了追求低停顿外,还能建立可预测的停顿时间模型,能让使用者明确指定在一个长度为 M 毫秒的时间片段内,消耗在垃圾收集上的时间不得超过 N 毫秒。 G1 收集器的运作大致分为以下几个步骤:\n初始标记: 短暂停顿(Stop-The-World,STW),标记从 GC Roots 可直接引用的对象,即标记所有直接可达的活跃对象 并发标记:与应用并发运行,标记所有可达对象。 这一阶段可能持续较长时间,取决于堆的大小和对象的数量。 最终标记: 短暂停顿(STW),处理并发标记阶段结束后残留的少量未处理的引用变更。 筛选回收:根据标记结果,选择回收价值高的区域,复制存活对象到新区域,回收旧区域内存。这一阶段包含一个或多个停顿(STW),具体取决于回收的复杂度。 G1 收集器在后台维护了一个优先列表,每次根据允许的收集时间,优先选择回收价值最大的 Region(这也就是它的名字 Garbage-First 的由来) 。这种使用 Region 划分内存空间以及有优先级的区域回收方式,保证了 G1 收集器在有限时间内可以尽可能高的收集效率(把内存化整为零)。\n从 JDK9 开始,G1 垃圾收集器成为了默认的垃圾收集器。\nZGC 收集器 # 与 CMS 中的 ParNew 和 G1 类似,ZGC 也采用标记-复制算法,不过 ZGC 对该算法做了重大改进。\nZGC 可以将暂停时间控制在几毫秒以内,且暂停时间不受堆内存大小的影响,出现 Stop The World 的情况会更少,但代价是牺牲了一些吞吐量。ZGC 最大支持 16TB 的堆内存。\nZGC 在 Java11 中引入,处于试验阶段。经过多个版本的迭代,不断的完善和修复问题,ZGC 在 Java15 已经可以正式使用了。\n不过,默认的垃圾回收器依然是 G1。你可以通过下面的参数启用 ZGC:\njava -XX:+UseZGC className 在 Java21 中,引入了分代 ZGC,暂停时间可以缩短到 1 毫秒以内。\n你可以通过下面的参数启用分代 ZGC:\njava -XX:+UseZGC -XX:+ZGenerational className 关于 ZGC 收集器的详细介绍推荐看看这几篇文章:\n从历代 GC 算法角度剖析 ZGC - 京东技术 新一代垃圾回收器 ZGC 的探索与实践 - 美团技术团队 极致八股文之 JVM 垃圾回收器 G1\u0026amp;ZGC 详解 - 阿里云开发者 参考 # 《深入理解 Java 虚拟机:JVM 高级特性与最佳实践(第二版》 The Java® Virtual Machine Specification - Java SE 8 Edition: https://docs.oracle.com/javase/specs/jvms/se8/html/index.html "},{"id":526,"href":"/zh/docs/technology/Interview/java/jvm/jvm-in-action/","title":"JVM线上问题排查和性能调优案例","section":"Jvm","content":"JVM 线上问题排查和性能调优也是面试常问的一个问题,尤其是社招中大厂的面试。\n这篇文章,我会分享一些我看到的相关的案例。\n下面是正文。\n一次线上 OOM 问题分析 - 艾小仙 - 2023\n现象:线上某个服务有接口非常慢,通过监控链路查看发现,中间的 GAP 时间非常大,实际接口并没有消耗很多时间,并且在那段时间里有很多这样的请求。 分析:使用 JDK 自带的jvisualvm分析 dump 文件(MAT 也能分析)。 建议:对于 SQL 语句,如果监测到没有where条件的全表查询应该默认增加一个合适的limit作为限制,防止这种问题拖垮整个系统 资料: 实战案例:记一次 dump 文件分析历程转载 - HeapDump - 2022。 生产事故-记一次特殊的 OOM 排查 - 程语有云 - 2023\n现象:网络没有问题的情况下,系统某开放接口从 2023 年 3 月 10 日 14 时许开始无法访问和使用。 临时解决办法:紧急回滚至上一稳定版本。 分析:使用 MAT (Memory Analyzer Tool)工具分析 dump 文件。 建议:正常情况下,-Xmn参数(控制 Young 区的大小)总是应当小于-Xmx参数(控制堆内存的最大大小),否则就会触发 OOM 错误。 资料: 最重要的 JVM 参数总结 - JavaGuide - 2023 一次大量 JVM Native 内存泄露的排查分析(64M 问题) - 掘金 - 2022\n现象:线上项目刚启动完使用 top 命令查看 RES 占用了超过 1.5G。 分析:整个分析流程用到了较多工作,可以跟着作者思路一步一步来,值得学习借鉴。 建议:远离 Hibernate。 资料: Linux top 命令里的内存相关字段(VIRT, RES, SHR, CODE, DATA) YGC 问题排查,又让我涨姿势了! - IT 人的职场进阶 - 2021\n现象:广告服务在新版本上线后,收到了大量的服务超时告警。 分析:使用 MAT (Memory Analyzer Tool) 工具分析 dump 文件。 建议:学会 YGC(Young GC) 问题的排查思路,掌握 YGC 的相关知识点。 听说 JVM 性能优化很难?今天我小试了一把! - 陈树义 - 2021\n通过观察 GC 频率和停顿时间,来进行 JVM 内存空间调整,使其达到最合理的状态。调整过程记得小步快跑,避免内存剧烈波动影响线上服务。 这其实是最为简单的一种 JVM 性能调优方式了,可以算是粗调吧。\n你们要的线上 GC 问题案例来啦 - 编了个程 - 2021\n案例 1:使用 guava cache 的时候,没有设置最大缓存数量和弱引用,导致频繁触发 Young GC 案例 2: 对于一个查询和排序分页的 SQL,同时这个 SQL 需要 join 多张表,在分库分表下,直接调用 SQL 性能很差。于是,查单表,再在内存排序分页,用了一个 List 来保存数据,而有些数据量大,造成了这个现象。 Java 中 9 种常见的 CMS GC 问题分析与解决 - 美团技术团 - 2020\n这篇文章共 2w+ 字,详细介绍了 GC 基础,总结了 CMS GC 的一些常见问题分析与解决办法。\n给祖传系统做了点 GC 调优,暂停时间降低了 90% - 京东云技术团队 - 2023\n这篇文章提到了一个在规则引擎系统中遇到的 GC(垃圾回收)问题,主要表现为系统在启动后发生了一次较长的 Young GC(年轻代垃圾回收)导致性能下降。经过分析,问题的核心在于动态对象年龄判定机制,它导致了过早的对象晋升,引起了长时间的垃圾回收。\n"},{"id":527,"href":"/zh/docs/technology/Interview/system-design/security/jwt-intro/","title":"JWT 基础概念详解","section":"Security","content":" 什么是 JWT? # JWT (JSON Web Token) 是目前最流行的跨域认证解决方案,是一种基于 Token 的认证授权机制。 从 JWT 的全称可以看出,JWT 本身也是 Token,一种规范化之后的 JSON 结构的 Token。\nJWT 自身包含了身份验证所需要的所有信息,因此,我们的服务器不需要存储 Session 信息。这显然增加了系统的可用性和伸缩性,大大减轻了服务端的压力。\n可以看出,JWT 更符合设计 RESTful API 时的「Stateless(无状态)」原则 。\n并且, 使用 JWT 认证可以有效避免 CSRF 攻击,因为 JWT 一般是存在在 localStorage 中,使用 JWT 进行身份验证的过程中是不会涉及到 Cookie 的。\n我在 JWT 优缺点分析这篇文章中有详细介绍到使用 JWT 做身份认证的优势和劣势。\n下面是 RFC 7519 对 JWT 做的较为正式的定义。\nJSON Web Token (JWT) is a compact, URL-safe means of representing claims to be transferred between two parties. The claims in a JWT are encoded as a JSON object that is used as the payload of a JSON Web Signature (JWS) structure or as the plaintext of a JSON Web Encryption (JWE) structure, enabling the claims to be digitally signed or integrity protected with a Message Authentication Code (MAC) and/or encrypted. —— JSON Web Token (JWT)\nJWT 由哪些部分组成? # JWT 本质上就是一组字串,通过(.)切分成三个为 Base64 编码的部分:\nHeader(头部) : 描述 JWT 的元数据,定义了生成签名的算法以及 Token 的类型。Header 被 Base64Url 编码后成为 JWT 的第一部分。 Payload(载荷) : 用来存放实际需要传递的数据,包含声明(Claims),如sub(subject,主题)、jti(JWT ID)。Payload 被 Base64Url 编码后成为 JWT 的第二部分。 Signature(签名):服务器通过 Payload、Header 和一个密钥(Secret)使用 Header 里面指定的签名算法(默认是 HMAC SHA256)生成。生成的签名会成为 JWT 的第三部分。 JWT 通常是这样的:xxxxx.yyyyy.zzzzz。\n示例:\neyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9. eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiaWF0IjoxNTE2MjM5MDIyfQ. SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c 你可以在 jwt.io 这个网站上对其 JWT 进行解码,解码之后得到的就是 Header、Payload、Signature 这三部分。\nHeader 和 Payload 都是 JSON 格式的数据,Signature 由 Payload、Header 和 Secret(密钥)通过特定的计算公式和加密算法得到。\nHeader # Header 通常由两部分组成:\ntyp(Type):令牌类型,也就是 JWT。 alg(Algorithm):签名算法,比如 HS256。 示例:\n{ \u0026#34;alg\u0026#34;: \u0026#34;HS256\u0026#34;, \u0026#34;typ\u0026#34;: \u0026#34;JWT\u0026#34; } JSON 形式的 Header 被转换成 Base64 编码,成为 JWT 的第一部分。\nPayload # Payload 也是 JSON 格式数据,其中包含了 Claims(声明,包含 JWT 的相关信息)。\nClaims 分为三种类型:\nRegistered Claims(注册声明):预定义的一些声明,建议使用,但不是强制性的。 Public Claims(公有声明):JWT 签发方可以自定义的声明,但是为了避免冲突,应该在 IANA JSON Web Token Registry 中定义它们。 Private Claims(私有声明):JWT 签发方因为项目需要而自定义的声明,更符合实际项目场景使用。 下面是一些常见的注册声明:\niss(issuer):JWT 签发方。 iat(issued at time):JWT 签发时间。 sub(subject):JWT 主题。 aud(audience):JWT 接收方。 exp(expiration time):JWT 的过期时间。 nbf(not before time):JWT 生效时间,早于该定义的时间的 JWT 不能被接受处理。 jti(JWT ID):JWT 唯一标识。 示例:\n{ \u0026#34;uid\u0026#34;: \u0026#34;ff1212f5-d8d1-4496-bf41-d2dda73de19a\u0026#34;, \u0026#34;sub\u0026#34;: \u0026#34;1234567890\u0026#34;, \u0026#34;name\u0026#34;: \u0026#34;John Doe\u0026#34;, \u0026#34;exp\u0026#34;: 15323232, \u0026#34;iat\u0026#34;: 1516239022, \u0026#34;scope\u0026#34;: [\u0026#34;admin\u0026#34;, \u0026#34;user\u0026#34;] } Payload 部分默认是不加密的,一定不要将隐私信息存放在 Payload 当中!!!\nJSON 形式的 Payload 被转换成 Base64 编码,成为 JWT 的第二部分。\nSignature # Signature 部分是对前两部分的签名,作用是防止 JWT(主要是 payload) 被篡改。\n这个签名的生成需要用到:\nHeader + Payload。 存放在服务端的密钥(一定不要泄露出去)。 签名算法。 签名的计算公式如下:\nHMACSHA256( base64UrlEncode(header) + \u0026#34;.\u0026#34; + base64UrlEncode(payload), secret) 算出签名以后,把 Header、Payload、Signature 三个部分拼成一个字符串,每个部分之间用\u0026quot;点\u0026quot;(.)分隔,这个字符串就是 JWT 。\n如何基于 JWT 进行身份验证? # 在基于 JWT 进行身份验证的的应用程序中,服务器通过 Payload、Header 和 Secret(密钥)创建 JWT 并将 JWT 发送给客户端。客户端接收到 JWT 之后,会将其保存在 Cookie 或者 localStorage 里面,以后客户端发出的所有请求都会携带这个令牌。\n简化后的步骤如下:\n用户向服务器发送用户名、密码以及验证码用于登陆系统。 如果用户用户名、密码以及验证码校验正确的话,服务端会返回已经签名的 Token,也就是 JWT。 用户以后每次向后端发请求都在 Header 中带上这个 JWT 。 服务端检查 JWT 并从中获取用户相关信息。 两点建议:\n建议将 JWT 存放在 localStorage 中,放在 Cookie 中会有 CSRF 风险。 请求服务端并携带 JWT 的常见做法是将其放在 HTTP Header 的 Authorization 字段中(Authorization: Bearer Token)。 spring-security-jwt-guide 就是一个基于 JWT 来做身份认证的简单案例,感兴趣的可以看看。\n如何防止 JWT 被篡改? # 有了签名之后,即使 JWT 被泄露或者截获,黑客也没办法同时篡改 Signature、Header、Payload。\n这是为什么呢?因为服务端拿到 JWT 之后,会解析出其中包含的 Header、Payload 以及 Signature 。服务端会根据 Header、Payload、密钥再次生成一个 Signature。拿新生成的 Signature 和 JWT 中的 Signature 作对比,如果一样就说明 Header 和 Payload 没有被修改。\n不过,如果服务端的秘钥也被泄露的话,黑客就可以同时篡改 Signature、Header、Payload 了。黑客直接修改了 Header 和 Payload 之后,再重新生成一个 Signature 就可以了。\n密钥一定保管好,一定不要泄露出去。JWT 安全的核心在于签名,签名安全的核心在密钥。\n如何加强 JWT 的安全性? # 使用安全系数高的加密算法。 使用成熟的开源库,没必要造轮子。 JWT 存放在 localStorage 中而不是 Cookie 中,避免 CSRF 风险。 一定不要将隐私信息存放在 Payload 当中。 密钥一定保管好,一定不要泄露出去。JWT 安全的核心在于签名,签名安全的核心在密钥。 Payload 要加入 exp (JWT 的过期时间),永久有效的 JWT 不合理。并且,JWT 的过期时间不宜过长。 …… "},{"id":528,"href":"/zh/docs/technology/Interview/system-design/security/advantages-and-disadvantages-of-jwt/","title":"JWT 身份认证优缺点分析","section":"Security","content":"校招面试中,遇到大部分的候选者认证登录这块用的都是 JWT。提问 JWT 的概念性问题以及使用 JWT 的原因,基本都能回答一些,但当问到 JWT 存在的一些问题和解决方案时,只有一小部分候选者回答的还可以。\nJWT 不是银弹,也有很多缺陷,很多时候并不是最优的选择。这篇文章,我们一起探讨一下 JWT 身份认证的优缺点以及常见问题的解决办法,来看看为什么很多人不再推荐使用 JWT 了。\n关于 JWT 的基本概念介绍请看我写的这篇文章: JWT 基本概念详解。\nJWT 的优势 # 相比于 Session 认证的方式来说,使用 JWT 进行身份认证主要有下面 4 个优势。\n无状态 # JWT 自身包含了身份验证所需要的所有信息,因此,我们的服务器不需要存储 JWT 信息。这显然增加了系统的可用性和伸缩性,大大减轻了服务端的压力。\n不过,也正是由于 JWT 的无状态,也导致了它最大的缺点:不可控!\n就比如说,我们想要在 JWT 有效期内废弃一个 JWT 或者更改它的权限的话,并不会立即生效,通常需要等到有效期过后才可以。再比如说,当用户 Logout 的话,JWT 也还有效。除非,我们在后端增加额外的处理逻辑比如将失效的 JWT 存储起来,后端先验证 JWT 是否有效再进行处理。具体的解决办法,我们会在后面的内容中详细介绍到,这里只是简单提一下。\n有效避免了 CSRF 攻击 # CSRF(Cross Site Request Forgery) 一般被翻译为 跨站请求伪造,属于网络攻击领域范围。相比于 SQL 脚本注入、XSS 等安全攻击方式,CSRF 的知名度并没有它们高。但是,它的确是我们开发系统时必须要考虑的安全隐患。就连业内技术标杆 Google 的产品 Gmail 也曾在 2007 年的时候爆出过 CSRF 漏洞,这给 Gmail 的用户造成了很大的损失。\n那么究竟什么是跨站请求伪造呢? 简单来说就是用你的身份去做一些不好的事情(发送一些对你不友好的请求比如恶意转账)。\n举个简单的例子:小壮登录了某网上银行,他来到了网上银行的帖子区,看到一个帖子下面有一个链接写着“科学理财,年盈利率过万”,小壮好奇的点开了这个链接,结果发现自己的账户少了 10000 元。这是这么回事呢?原来黑客在链接中藏了一个请求,这个请求直接利用小壮的身份给银行发送了一个转账请求,也就是通过你的 Cookie 向银行发出请求。\n\u0026lt;a src=\u0026#34;http://www.mybank.com/Transfer?bankId=11\u0026amp;money=10000\u0026#34; \u0026gt;科学理财,年盈利率过万\u0026lt;/a \u0026gt; CSRF 攻击需要依赖 Cookie ,Session 认证中 Cookie 中的 SessionID 是由浏览器发送到服务端的,只要发出请求,Cookie 就会被携带。借助这个特性,即使黑客无法获取你的 SessionID,只要让你误点攻击链接,就可以达到攻击效果。\n另外,并不是必须点击链接才可以达到攻击效果,很多时候,只要你打开了某个页面,CSRF 攻击就会发生。\n那为什么 JWT 不会存在这种问题呢?\n一般情况下我们使用 JWT 的话,在我们登录成功获得 JWT 之后,一般会选择存放在 localStorage 中。前端的每一个请求后续都会附带上这个 JWT,整个过程压根不会涉及到 Cookie。因此,即使你点击了非法链接发送了请求到服务端,这个非法请求也是不会携带 JWT 的,所以这个请求将是非法的。\n总结来说就一句话:使用 JWT 进行身份验证不需要依赖 Cookie ,因此可以避免 CSRF 攻击。\n不过,这样也会存在 XSS 攻击的风险。为了避免 XSS 攻击,你可以选择将 JWT 存储在标记为httpOnly 的 Cookie 中。但是,这样又导致了你必须自己提供 CSRF 保护,因此,实际项目中我们通常也不会这么做。\n常见的避免 XSS 攻击的方式是过滤掉请求中存在 XSS 攻击风险的可疑字符串。\n在 Spring 项目中,我们一般是通过创建 XSS 过滤器来实现的。\n@Component @Order(Ordered.HIGHEST_PRECEDENCE) public class XSSFilter implements Filter { @Override public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain) throws IOException, ServletException { XSSRequestWrapper wrappedRequest = new XSSRequestWrapper((HttpServletRequest) request); chain.doFilter(wrappedRequest, response); } // other methods } 适合移动端应用 # 使用 Session 进行身份认证的话,需要保存一份信息在服务器端,而且这种方式会依赖到 Cookie(需要 Cookie 保存 SessionId),所以不适合移动端。\n但是,使用 JWT 进行身份认证就不会存在这种问题,因为只要 JWT 可以被客户端存储就能够使用,而且 JWT 还可以跨语言使用。\n为什么使用 Session 进行身份认证的话不适合移动端 ?\n状态管理: Session 基于服务器端的状态管理,而移动端应用通常是无状态的。移动设备的连接可能不稳定或中断,因此难以维护长期的会话状态。如果使用 Session 进行身份认证,移动应用需要频繁地与服务器进行会话维护,增加了网络开销和复杂性; 兼容性: 移动端应用通常会面向多个平台,如 iOS、Android 和 Web。每个平台对于 Session 的管理和存储方式可能不同,可能导致跨平台兼容性的问题; 安全性: 移动设备通常处于不受信任的网络环境,存在数据泄露和攻击的风险。将敏感的会话信息存储在移动设备上增加了被攻击的潜在风险。 单点登录友好 # 使用 Session 进行身份认证的话,实现单点登录,需要我们把用户的 Session 信息保存在一台电脑上,并且还会遇到常见的 Cookie 跨域的问题。但是,使用 JWT 进行认证的话, JWT 被保存在客户端,不会存在这些问题。\nJWT 身份认证常见问题及解决办法 # 注销登录等场景下 JWT 还有效 # 与之类似的具体相关场景有:\n退出登录; 修改密码; 服务端修改了某个用户具有的权限或者角色; 用户的帐户被封禁/删除; 用户被服务端强制注销; 用户被踢下线; …… 这个问题不存在于 Session 认证方式中,因为在 Session 认证方式中,遇到这种情况的话服务端删除对应的 Session 记录即可。但是,使用 JWT 认证的方式就不好解决了。我们也说过了,JWT 一旦派发出去,如果后端不增加其他逻辑的话,它在失效之前都是有效的。\n那我们如何解决这个问题呢?查阅了很多资料,我简单总结了下面 4 种方案:\n1、将 JWT 存入数据库\n将有效的 JWT 存入数据库中,更建议使用内存数据库比如 Redis。如果需要让某个 JWT 失效就直接从 Redis 中删除这个 JWT 即可。但是,这样会导致每次使用 JWT 都要先从 Redis 中查询 JWT 是否存在的步骤,而且违背了 JWT 的无状态原则。\n2、黑名单机制\n和上面的方式类似,使用内存数据库比如 Redis 维护一个黑名单,如果想让某个 JWT 失效的话就直接将这个 JWT 加入到 黑名单 即可。然后,每次使用 JWT 进行请求的话都会先判断这个 JWT 是否存在于黑名单中。\n前两种方案的核心在于将有效的 JWT 存储起来或者将指定的 JWT 拉入黑名单。\n虽然这两种方案都违背了 JWT 的无状态原则,但是一般实际项目中我们通常还是会使用这两种方案。\n3、修改密钥 (Secret) :\n我们为每个用户都创建一个专属密钥,如果我们想让某个 JWT 失效,我们直接修改对应用户的密钥即可。但是,这样相比于前两种引入内存数据库带来了危害更大:\n如果服务是分布式的,则每次发出新的 JWT 时都必须在多台机器同步密钥。为此,你需要将密钥存储在数据库或其他外部服务中,这样和 Session 认证就没太大区别了。 如果用户同时在两个浏览器打开系统,或者在手机端也打开了系统,如果它从一个地方将账号退出,那么其他地方都要重新进行登录,这是不可取的。 4、保持令牌的有效期限短并经常轮换\n很简单的一种方式。但是,会导致用户登录状态不会被持久记录,而且需要用户经常登录。\n另外,对于修改密码后 JWT 还有效问题的解决还是比较容易的。说一种我觉得比较好的方式:使用用户的密码的哈希值对 JWT 进行签名。因此,如果密码更改,则任何先前的令牌将自动无法验证。\nJWT 的续签问题 # JWT 有效期一般都建议设置的不太长,那么 JWT 过期后如何认证,如何实现动态刷新 JWT,避免用户经常需要重新登录?\n我们先来看看在 Session 认证中一般的做法:假如 Session 的有效期 30 分钟,如果 30 分钟内用户有访问,就把 Session 有效期延长 30 分钟。\nJWT 认证的话,我们应该如何解决续签问题呢?查阅了很多资料,我简单总结了下面 4 种方案:\n1、类似于 Session 认证中的做法(不推荐)\n这种方案满足于大部分场景。假设服务端给的 JWT 有效期设置为 30 分钟,服务端每次进行校验时,如果发现 JWT 的有效期马上快过期了,服务端就重新生成 JWT 给客户端。客户端每次请求都检查新旧 JWT,如果不一致,则更新本地的 JWT。这种做法的问题是仅仅在快过期的时候请求才会更新 JWT ,对客户端不是很友好。\n2、每次请求都返回新 JWT(不推荐)\n这种方案的的思路很简单,但是,开销会比较大,尤其是在服务端要存储维护 JWT 的情况下。\n3、JWT 有效期设置到半夜(不推荐)\n这种方案是一种折衷的方案,保证了大部分用户白天可以正常登录,适用于对安全性要求不高的系统。\n4、用户登录返回两个 JWT(推荐)\n第一个是 accessJWT ,它的过期时间 JWT 本身的过期时间比如半个小时,另外一个是 refreshJWT 它的过期时间更长一点比如为 1 天。refreshJWT 只用来获取 accessJWT,不容易被泄露。\n客户端登录后,将 accessJWT 和 refreshJWT 保存在本地,每次访问将 accessJWT 传给服务端。服务端校验 accessJWT 的有效性,如果过期的话,就将 refreshJWT 传给服务端。如果有效,服务端就生成新的 accessJWT 给客户端。否则,客户端就重新登录即可。\n这种方案的不足是:\n需要客户端来配合; 用户注销的时候需要同时保证两个 JWT 都无效; 重新请求获取 JWT 的过程中会有短暂 JWT 不可用的情况(可以通过在客户端设置定时器,当 accessJWT 快过期的时候,提前去通过 refreshJWT 获取新的 accessJWT); 存在安全问题,只要拿到了未过期的 refreshJWT 就一直可以获取到 accessJWT。不过,由于 refreshJWT 只用来获取 accessJWT,不容易被泄露。 JWT 体积太大 # JWT 结构复杂(Header、Payload 和 Signature),包含了更多额外的信息,还需要进行 Base64Url 编码,这会使得 JWT 体积较大,增加了网络传输的开销。\nJWT 组成:\nJWT 示例:\neyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9. eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiaWF0IjoxNTE2MjM5MDIyfQ. SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c 解决办法:\n尽量减少 JWT Payload(载荷)中的信息,只保留必要的用户和权限信息。 在传输 JWT 之前,使用压缩算法(如 GZIP)对 JWT 进行压缩以减少体积。 在某些情况下,使用传统的 Token 可能更合适。传统的 Token 通常只是一个唯一标识符,对应的信息(例如用户 ID、Token 过期时间、权限信息)存储在服务端,通常会通过 Redis 保存。 总结 # JWT 其中一个很重要的优势是无状态,但实际上,我们想要在实际项目中合理使用 JWT 做认证登录的话,也还是需要保存 JWT 信息。\nJWT 也不是银弹,也有很多缺陷,具体是选择 JWT 还是 Session 方案还是要看项目的具体需求。万万不可尬吹 JWT,而看不起其他身份认证方案。\n另外,不用 JWT 直接使用普通的 Token(随机生成的 ID,不包含具体的信息) 结合 Redis 来做身份认证也是可以的。\n参考 # JWT 超详细分析: https://learnku.com/articles/17883 How to log out when using JWT: https://medium.com/devgorilla/how-to-log-out-when-using-jwt-a8c7823e8a6 CSRF protection with JSON Web JWTs: https://medium.com/@agungsantoso/csrf-protection-with-json-web-JWTs-83e0f2fcbcc Invalidating JSON Web JWTs: https://stackoverflow.com/questions/21978658/invalidating-json-web-JWTs "},{"id":529,"href":"/zh/docs/technology/Interview/high-performance/message-queue/kafka-questions-01/","title":"Kafka常见问题总结","section":"High Performance","content":" Kafka 基础 # Kafka 是什么?主要应用场景有哪些? # Kafka 是一个分布式流式处理平台。这到底是什么意思呢?\n流平台具有三个关键功能:\n消息队列:发布和订阅消息流,这个功能类似于消息队列,这也是 Kafka 也被归类为消息队列的原因。 容错的持久方式存储记录消息流:Kafka 会把消息持久化到磁盘,有效避免了消息丢失的风险。 流式处理平台: 在消息发布的时候进行处理,Kafka 提供了一个完整的流式处理类库。 Kafka 主要有两大应用场景:\n消息队列:建立实时流数据管道,以可靠地在系统或应用程序之间获取数据。 数据处理: 构建实时的流数据处理程序来转换或处理数据流。 和其他消息队列相比,Kafka 的优势在哪里? # 我们现在经常提到 Kafka 的时候就已经默认它是一个非常优秀的消息队列了,我们也会经常拿它跟 RocketMQ、RabbitMQ 对比。我觉得 Kafka 相比其他消息队列主要的优势如下:\n极致的性能:基于 Scala 和 Java 语言开发,设计中大量使用了批量处理和异步的思想,最高可以每秒处理千万级别的消息。 生态系统兼容性无可匹敌:Kafka 与周边生态系统的兼容性是最好的没有之一,尤其在大数据和流计算领域。 实际上在早期的时候 Kafka 并不是一个合格的消息队列,早期的 Kafka 在消息队列领域就像是一个衣衫褴褛的孩子一样,功能不完备并且有一些小问题比如丢失消息、不保证消息可靠性等等。当然,这也和 LinkedIn 最早开发 Kafka 用于处理海量的日志有很大关系,哈哈哈,人家本来最开始就不是为了作为消息队列滴,谁知道后面误打误撞在消息队列领域占据了一席之地。\n随着后续的发展,这些短板都被 Kafka 逐步修复完善。所以,Kafka 作为消息队列不可靠这个说法已经过时!\n队列模型了解吗?Kafka 的消息模型知道吗? # 题外话:早期的 JMS 和 AMQP 属于消息服务领域权威组织所做的相关的标准,我在 JavaGuide的 《消息队列其实很简单》这篇文章中介绍过。但是,这些标准的进化跟不上消息队列的演进速度,这些标准实际上已经属于废弃状态。所以,可能存在的情况是:不同的消息队列都有自己的一套消息模型。\n队列模型:早期的消息模型 # 使用队列(Queue)作为消息通信载体,满足生产者与消费者模式,一条消息只能被一个消费者使用,未被消费的消息在队列中保留直到被消费或超时。 比如:我们生产者发送 100 条消息的话,两个消费者来消费一般情况下两个消费者会按照消息发送的顺序各自消费一半(也就是你一个我一个的消费。)\n队列模型存在的问题:\n假如我们存在这样一种情况:我们需要将生产者产生的消息分发给多个消费者,并且每个消费者都能接收到完整的消息内容。\n这种情况,队列模型就不好解决了。很多比较杠精的人就说:我们可以为每个消费者创建一个单独的队列,让生产者发送多份。这是一种非常愚蠢的做法,浪费资源不说,还违背了使用消息队列的目的。\n发布-订阅模型:Kafka 消息模型 # 发布-订阅模型主要是为了解决队列模型存在的问题。\n发布订阅模型(Pub-Sub) 使用主题(Topic) 作为消息通信载体,类似于广播模式;发布者发布一条消息,该消息通过主题传递给所有的订阅者,在一条消息广播之后才订阅的用户则是收不到该条消息的。\n在发布 - 订阅模型中,如果只有一个订阅者,那它和队列模型就基本是一样的了。所以说,发布 - 订阅模型在功能层面上是可以兼容队列模型的。\nKafka 采用的就是发布 - 订阅模型。\nRocketMQ 的消息模型和 Kafka 基本是完全一样的。唯一的区别是 Kafka 中没有队列这个概念,与之对应的是 Partition(分区)。\nKafka 核心概念 # 什么是 Producer、Consumer、Broker、Topic、Partition? # Kafka 将生产者发布的消息发送到 Topic(主题) 中,需要这些消息的消费者可以订阅这些 Topic(主题),如下图所示:\n上面这张图也为我们引出了,Kafka 比较重要的几个概念:\nProducer(生产者) : 产生消息的一方。 Consumer(消费者) : 消费消息的一方。 Broker(代理) : 可以看作是一个独立的 Kafka 实例。多个 Kafka Broker 组成一个 Kafka Cluster。 同时,你一定也注意到每个 Broker 中又包含了 Topic 以及 Partition 这两个重要的概念:\nTopic(主题) : Producer 将消息发送到特定的主题,Consumer 通过订阅特定的 Topic(主题) 来消费消息。 Partition(分区) : Partition 属于 Topic 的一部分。一个 Topic 可以有多个 Partition ,并且同一 Topic 下的 Partition 可以分布在不同的 Broker 上,这也就表明一个 Topic 可以横跨多个 Broker 。这正如我上面所画的图一样。 划重点:Kafka 中的 Partition(分区) 实际上可以对应成为消息队列中的队列。这样是不是更好理解一点?\nKafka 的多副本机制了解吗?带来了什么好处? # 还有一点我觉得比较重要的是 Kafka 为分区(Partition)引入了多副本(Replica)机制。分区(Partition)中的多个副本之间会有一个叫做 leader 的家伙,其他副本称为 follower。我们发送的消息会被发送到 leader 副本,然后 follower 副本才能从 leader 副本中拉取消息进行同步。\n生产者和消费者只与 leader 副本交互。你可以理解为其他副本只是 leader 副本的拷贝,它们的存在只是为了保证消息存储的安全性。当 leader 副本发生故障时会从 follower 中选举出一个 leader,但是 follower 中如果有和 leader 同步程度达不到要求的参加不了 leader 的竞选。\nKafka 的多分区(Partition)以及多副本(Replica)机制有什么好处呢?\nKafka 通过给特定 Topic 指定多个 Partition, 而各个 Partition 可以分布在不同的 Broker 上, 这样便能提供比较好的并发能力(负载均衡)。 Partition 可以指定对应的 Replica 数, 这也极大地提高了消息存储的安全性, 提高了容灾能力,不过也相应的增加了所需要的存储空间。 Zookeeper 和 Kafka # Zookeeper 在 Kafka 中的作用是什么? # 要想搞懂 zookeeper 在 Kafka 中的作用 一定要自己搭建一个 Kafka 环境然后自己进 zookeeper 去看一下有哪些文件夹和 Kafka 有关,每个节点又保存了什么信息。 一定不要光看不实践,这样学来的也终会忘记!这部分内容参考和借鉴了这篇文章: https://www.jianshu.com/p/a036405f989c 。\n下图就是我的本地 Zookeeper ,它成功和我本地的 Kafka 关联上(以下文件夹结构借助 idea 插件 Zookeeper tool 实现)。\nZooKeeper 主要为 Kafka 提供元数据的管理的功能。\n从图中我们可以看出,Zookeeper 主要为 Kafka 做了下面这些事情:\nBroker 注册:在 Zookeeper 上会有一个专门用来进行 Broker 服务器列表记录的节点。每个 Broker 在启动时,都会到 Zookeeper 上进行注册,即到 /brokers/ids 下创建属于自己的节点。每个 Broker 就会将自己的 IP 地址和端口等信息记录到该节点中去 Topic 注册:在 Kafka 中,同一个Topic 的消息会被分成多个分区并将其分布在多个 Broker 上,这些分区信息及与 Broker 的对应关系也都是由 Zookeeper 在维护。比如我创建了一个名字为 my-topic 的主题并且它有两个分区,对应到 zookeeper 中会创建这些文件夹:/brokers/topics/my-topic/Partitions/0、/brokers/topics/my-topic/Partitions/1 负载均衡:上面也说过了 Kafka 通过给特定 Topic 指定多个 Partition, 而各个 Partition 可以分布在不同的 Broker 上, 这样便能提供比较好的并发能力。 对于同一个 Topic 的不同 Partition,Kafka 会尽力将这些 Partition 分布到不同的 Broker 服务器上。当生产者产生消息后也会尽量投递到不同 Broker 的 Partition 里面。当 Consumer 消费的时候,Zookeeper 可以根据当前的 Partition 数量以及 Consumer 数量来实现动态负载均衡。 …… 使用 Kafka 能否不引入 Zookeeper? # 在 Kafka 2.8 之前,Kafka 最被大家诟病的就是其重度依赖于 Zookeeper。在 Kafka 2.8 之后,引入了基于 Raft 协议的 KRaft 模式,不再依赖 Zookeeper,大大简化了 Kafka 的架构,让你可以以一种轻量级的方式来使用 Kafka。\n不过,要提示一下:如果要使用 KRaft 模式的话,建议选择较高版本的 Kafka,因为这个功能还在持续完善优化中。Kafka 3.3.1 版本是第一个将 KRaft(Kafka Raft)共识协议标记为生产就绪的版本。\nKafka 消费顺序、消息丢失和重复消费 # Kafka 如何保证消息的消费顺序? # 我们在使用消息队列的过程中经常有业务场景需要严格保证消息的消费顺序,比如我们同时发了 2 个消息,这 2 个消息对应的操作分别对应的数据库操作是:\n更改用户会员等级。 根据会员等级计算订单价格。 假如这两条消息的消费顺序不一样造成的最终结果就会截然不同。\n我们知道 Kafka 中 Partition(分区)是真正保存消息的地方,我们发送的消息都被放在了这里。而我们的 Partition(分区) 又存在于 Topic(主题) 这个概念中,并且我们可以给特定 Topic 指定多个 Partition。\n每次添加消息到 Partition(分区) 的时候都会采用尾加法,如上图所示。 Kafka 只能为我们保证 Partition(分区) 中的消息有序。\n消息在被追加到 Partition(分区)的时候都会分配一个特定的偏移量(offset)。Kafka 通过偏移量(offset)来保证消息在分区内的顺序性。\n所以,我们就有一种很简单的保证消息消费顺序的方法:1 个 Topic 只对应一个 Partition。这样当然可以解决问题,但是破坏了 Kafka 的设计初衷。\nKafka 中发送 1 条消息的时候,可以指定 topic, partition, key,data(数据) 4 个参数。如果你发送消息的时候指定了 Partition 的话,所有消息都会被发送到指定的 Partition。并且,同一个 key 的消息可以保证只发送到同一个 partition,这个我们可以采用表/对象的 id 来作为 key 。\n总结一下,对于如何保证 Kafka 中消息消费的顺序,有了下面两种方法:\n1 个 Topic 只对应一个 Partition。 (推荐)发送消息的时候指定 key/Partition。 当然不仅仅只有上面两种方法,上面两种方法是我觉得比较好理解的,\nKafka 如何保证消息不丢失? # 生产者丢失消息的情况 # 生产者(Producer) 调用send方法发送消息之后,消息可能因为网络问题并没有发送过去。\n所以,我们不能默认在调用send方法发送消息之后消息发送成功了。为了确定消息是发送成功,我们要判断消息发送的结果。但是要注意的是 Kafka 生产者(Producer) 使用 send 方法发送消息实际上是异步的操作,我们可以通过 get()方法获取调用结果,但是这样也让它变为了同步操作,示例代码如下:\n详细代码见我的这篇文章: Kafka 系列第三篇!10 分钟学会如何在 Spring Boot 程序中使用 Kafka 作为消息队列?\nSendResult\u0026lt;String, Object\u0026gt; sendResult = kafkaTemplate.send(topic, o).get(); if (sendResult.getRecordMetadata() != null) { logger.info(\u0026#34;生产者成功发送消息到\u0026#34; + sendResult.getProducerRecord().topic() + \u0026#34;-\u0026gt; \u0026#34; + sendRe sult.getProducerRecord().value().toString()); } 但是一般不推荐这么做!可以采用为其添加回调函数的形式,示例代码如下:\nListenableFuture\u0026lt;SendResult\u0026lt;String, Object\u0026gt;\u0026gt; future = kafkaTemplate.send(topic, o); future.addCallback(result -\u0026gt; logger.info(\u0026#34;生产者成功发送消息到topic:{} partition:{}的消息\u0026#34;, result.getRecordMetadata().topic(), result.getRecordMetadata().partition()), ex -\u0026gt; logger.error(\u0026#34;生产者发送消失败,原因:{}\u0026#34;, ex.getMessage())); 如果消息发送失败的话,我们检查失败的原因之后重新发送即可!\n另外,这里推荐为 Producer 的retries(重试次数)设置一个比较合理的值,一般是 3 ,但是为了保证消息不丢失的话一般会设置比较大一点。设置完成之后,当出现网络问题之后能够自动重试消息发送,避免消息丢失。另外,建议还要设置重试间隔,因为间隔太小的话重试的效果就不明显了,网络波动一次你 3 次一下子就重试完了。\n消费者丢失消息的情况 # 我们知道消息在被追加到 Partition(分区)的时候都会分配一个特定的偏移量(offset)。偏移量(offset)表示 Consumer 当前消费到的 Partition(分区)的所在的位置。Kafka 通过偏移量(offset)可以保证消息在分区内的顺序性。\n当消费者拉取到了分区的某个消息之后,消费者会自动提交了 offset。自动提交的话会有一个问题,试想一下,当消费者刚拿到这个消息准备进行真正消费的时候,突然挂掉了,消息实际上并没有被消费,但是 offset 却被自动提交了。\n解决办法也比较粗暴,我们手动关闭自动提交 offset,每次在真正消费完消息之后再自己手动提交 offset 。 但是,细心的朋友一定会发现,这样会带来消息被重新消费的问题。比如你刚刚消费完消息之后,还没提交 offset,结果自己挂掉了,那么这个消息理论上就会被消费两次。\nKafka 弄丢了消息 # 我们知道 Kafka 为分区(Partition)引入了多副本(Replica)机制。分区(Partition)中的多个副本之间会有一个叫做 leader 的家伙,其他副本称为 follower。我们发送的消息会被发送到 leader 副本,然后 follower 副本才能从 leader 副本中拉取消息进行同步。生产者和消费者只与 leader 副本交互。你可以理解为其他副本只是 leader 副本的拷贝,它们的存在只是为了保证消息存储的安全性。\n试想一种情况:假如 leader 副本所在的 broker 突然挂掉,那么就要从 follower 副本重新选出一个 leader ,但是 leader 的数据还有一些没有被 follower 副本的同步的话,就会造成消息丢失。\n设置 acks = all\n解决办法就是我们设置 acks = all。acks 是 Kafka 生产者(Producer) 很重要的一个参数。\nacks 的默认值即为 1,代表我们的消息被 leader 副本接收之后就算被成功发送。当我们配置 acks = all 表示只有所有 ISR 列表的副本全部收到消息时,生产者才会接收到来自服务器的响应. 这种模式是最高级别的,也是最安全的,可以确保不止一个 Broker 接收到了消息. 该模式的延迟会很高.\n设置 replication.factor \u0026gt;= 3\n为了保证 leader 副本能有 follower 副本能同步消息,我们一般会为 topic 设置 replication.factor \u0026gt;= 3。这样就可以保证每个 分区(partition) 至少有 3 个副本。虽然造成了数据冗余,但是带来了数据的安全性。\n设置 min.insync.replicas \u0026gt; 1\n一般情况下我们还需要设置 min.insync.replicas\u0026gt; 1 ,这样配置代表消息至少要被写入到 2 个副本才算是被成功发送。min.insync.replicas 的默认值为 1 ,在实际生产中应尽量避免默认值 1。\n但是,为了保证整个 Kafka 服务的高可用性,你需要确保 replication.factor \u0026gt; min.insync.replicas 。为什么呢?设想一下假如两者相等的话,只要是有一个副本挂掉,整个分区就无法正常工作了。这明显违反高可用性!一般推荐设置成 replication.factor = min.insync.replicas + 1。\n设置 unclean.leader.election.enable = false\nKafka 0.11.0.0 版本开始 unclean.leader.election.enable 参数的默认值由原来的 true 改为 false\n我们最开始也说了我们发送的消息会被发送到 leader 副本,然后 follower 副本才能从 leader 副本中拉取消息进行同步。多个 follower 副本之间的消息同步情况不一样,当我们配置了 unclean.leader.election.enable = false 的话,当 leader 副本发生故障时就不会从 follower 副本中和 leader 同步程度达不到要求的副本中选择出 leader ,这样降低了消息丢失的可能性。\nKafka 如何保证消息不重复消费? # kafka 出现消息重复消费的原因:\n服务端侧已经消费的数据没有成功提交 offset(根本原因)。 Kafka 侧 由于服务端处理业务时间长或者网络链接等等原因让 Kafka 认为服务假死,触发了分区 rebalance。 解决方案:\n消费消息服务做幂等校验,比如 Redis 的 set、MySQL 的主键等天然的幂等功能。这种方法最有效。 将 enable.auto.commit 参数设置为 false,关闭自动提交,开发者在代码中手动提交 offset。那么这里会有个问题:什么时候提交 offset 合适? 处理完消息再提交:依旧有消息重复消费的风险,和自动提交一样 拉取到消息即提交:会有消息丢失的风险。允许消息延时的场景,一般会采用这种方式。然后,通过定时任务在业务不繁忙(比如凌晨)的时候做数据兜底。 Kafka 重试机制 # 在 Kafka 如何保证消息不丢失这里,我们提到了 Kafka 的重试机制。由于这部分内容较为重要,我们这里再来详细介绍一下。\n网上关于 Spring Kafka 的默认重试机制文章很多,但大多都是过时的,和实际运行结果完全不一样。以下是根据 spring-kafka-2.9.3 源码重新梳理一下。\n消费失败会怎么样? # 在消费过程中,当其中一个消息消费异常时,会不会卡住后续队列消息的消费?这样业务岂不是卡住了?\n生产者代码:\nfor (int i = 0; i \u0026lt; 10; i++) { kafkaTemplate.send(KafkaConst.TEST_TOPIC, String.valueOf(i)) } 消费者消代码:\n@KafkaListener(topics = {KafkaConst.TEST_TOPIC},groupId = \u0026#34;apple\u0026#34;) private void customer(String message) throws InterruptedException { log.info(\u0026#34;kafka customer:{}\u0026#34;,message); Integer n = Integer.parseInt(message); if (n%5==0){ throw new RuntimeException(); } } 在默认配置下,当消费异常会进行重试,重试多次后会跳过当前消息,继续进行后续消息的消费,不会一直卡在当前消息。下面是一段消费的日志,可以看出当 test-0@95 重试多次后会被跳过。\n2023-08-10 12:03:32.918 DEBUG 9700 --- [ntainer#0-0-C-1] o.s.kafka.listener.DefaultErrorHandler : Skipping seek of: test-0@95 2023-08-10 12:03:32.918 TRACE 9700 --- [ntainer#0-0-C-1] o.s.kafka.listener.DefaultErrorHandler : Seeking: test-0 to: 96 2023-08-10 12:03:32.918 INFO 9700 --- [ntainer#0-0-C-1] o.a.k.clients.consumer.KafkaConsumer : [Consumer clientId=consumer-apple-1, groupId=apple] Seeking to offset 96 for partition test-0 因此,即使某个消息消费异常,Kafka 消费者仍然能够继续消费后续的消息,不会一直卡在当前消息,保证了业务的正常进行。\n默认会重试多少次? # 默认配置下,消费异常会进行重试,重试次数是多少, 重试是否有时间间隔?\n看源码 FailedRecordTracker 类有个 recovered 函数,返回 Boolean 值判断是否要进行重试,下面是这个函数中判断是否重试的逻辑:\n@Override public boolean recovered(ConsumerRecord \u0026lt;\u0026lt; ? , ? \u0026gt; record, Exception exception, @Nullable MessageListenerContainer container, @Nullable Consumer \u0026lt;\u0026lt; ? , ? \u0026gt; consumer) throws InterruptedException { if (this.noRetries) { // 不支持重试 attemptRecovery(record, exception, null, consumer); return true; } // 取已经失败的消费记录集合 Map \u0026lt; TopicPartition, FailedRecord \u0026gt; map = this.failures.get(); if (map == null) { this.failures.set(new HashMap \u0026lt; \u0026gt; ()); map = this.failures.get(); } // 获取消费记录所在的Topic和Partition TopicPartition topicPartition = new TopicPartition(record.topic(), record.partition()); FailedRecord failedRecord = getFailedRecordInstance(record, exception, map, topicPartition); // 通知注册的重试监听器,消息投递失败 this.retryListeners.forEach(rl - \u0026gt; rl.failedDelivery(record, exception, failedRecord.getDeliveryAttempts().get())); // 获取下一次重试的时间间隔 long nextBackOff = failedRecord.getBackOffExecution().nextBackOff(); if (nextBackOff != BackOffExecution.STOP) { this.backOffHandler.onNextBackOff(container, exception, nextBackOff); return false; } else { attemptRecovery(record, exception, topicPartition, consumer); map.remove(topicPartition); if (map.isEmpty()) { this.failures.remove(); } return true; } } 其中, BackOffExecution.STOP 的值为 -1。\n@FunctionalInterface public interface BackOffExecution { long STOP = -1; long nextBackOff(); } nextBackOff 的值调用 BackOff 类的 nextBackOff() 函数。如果当前执行次数大于最大执行次数则返回 STOP,既超过这个最大执行次数后才会停止重试。\npublic long nextBackOff() { this.currentAttempts++; if (this.currentAttempts \u0026lt;= getMaxAttempts()) { return getInterval(); } else { return STOP; } } 那么这个 getMaxAttempts 的值又是多少呢?回到最开始,当执行出错会进入 DefaultErrorHandler 。DefaultErrorHandler 默认的构造函数是:\npublic DefaultErrorHandler() { this(null, SeekUtils.DEFAULT_BACK_OFF); } SeekUtils.DEFAULT_BACK_OFF 定义的是:\npublic static final int DEFAULT_MAX_FAILURES = 10; public static final FixedBackOff DEFAULT_BACK_OFF = new FixedBackOff(0, DEFAULT_MAX_FAILURES - 1); DEFAULT_MAX_FAILURES 的值是 10,currentAttempts 从 0 到 9,所以总共会执行 10 次,每次重试的时间间隔为 0。\n最后,简单总结一下:Kafka 消费者在默认配置下会进行最多 10 次 的重试,每次重试的时间间隔为 0,即立即进行重试。如果在 10 次重试后仍然无法成功消费消息,则不再进行重试,消息将被视为消费失败。\n如何自定义重试次数以及时间间隔? # 从上面的代码可以知道,默认错误处理器的重试次数以及时间间隔是由 FixedBackOff 控制的,FixedBackOff 是 DefaultErrorHandler 初始化时默认的。所以自定义重试次数以及时间间隔,只需要在 DefaultErrorHandler 初始化的时候传入自定义的 FixedBackOff 即可。重新实现一个 KafkaListenerContainerFactory ,调用 setCommonErrorHandler 设置新的自定义的错误处理器就可以实现。\n@Bean public KafkaListenerContainerFactory kafkaListenerContainerFactory(ConsumerFactory\u0026lt;String, String\u0026gt; consumerFactory) { ConcurrentKafkaListenerContainerFactory factory = new ConcurrentKafkaListenerContainerFactory(); // 自定义重试时间间隔以及次数 FixedBackOff fixedBackOff = new FixedBackOff(1000, 5); factory.setCommonErrorHandler(new DefaultErrorHandler(fixedBackOff)); factory.setConsumerFactory(consumerFactory); return factory; } 如何在重试失败后进行告警? # 自定义重试失败后逻辑,需要手动实现,以下是一个简单的例子,重写 DefaultErrorHandler 的 handleRemaining 函数,加上自定义的告警等操作。\n@Slf4j public class DelErrorHandler extends DefaultErrorHandler { public DelErrorHandler(FixedBackOff backOff) { super(null,backOff); } @Override public void handleRemaining(Exception thrownException, List\u0026lt;ConsumerRecord\u0026lt;?, ?\u0026gt;\u0026gt; records, Consumer\u0026lt;?, ?\u0026gt; consumer, MessageListenerContainer container) { super.handleRemaining(thrownException, records, consumer, container); log.info(\u0026#34;重试多次失败\u0026#34;); // 自定义操作 } } DefaultErrorHandler 只是默认的一个错误处理器,Spring Kafka 还提供了 CommonErrorHandler 接口。手动实现 CommonErrorHandler 就可以实现更多的自定义操作,有很高的灵活性。例如根据不同的错误类型,实现不同的重试逻辑以及业务逻辑等。\n重试失败后的数据如何再次处理? # 当达到最大重试次数后,数据会直接被跳过,继续向后进行。当代码修复后,如何重新消费这些重试失败的数据呢?\n死信队列(Dead Letter Queue,简称 DLQ) 是消息中间件中的一种特殊队列。它主要用于处理无法被消费者正确处理的消息,通常是因为消息格式错误、处理失败、消费超时等情况导致的消息被\u0026quot;丢弃\u0026quot;或\u0026quot;死亡\u0026quot;的情况。当消息进入队列后,消费者会尝试处理它。如果处理失败,或者超过一定的重试次数仍无法被成功处理,消息可以发送到死信队列中,而不是被永久性地丢弃。在死信队列中,可以进一步分析、处理这些无法正常消费的消息,以便定位问题、修复错误,并采取适当的措施。\n@RetryableTopic 是 Spring Kafka 中的一个注解,它用于配置某个 Topic 支持消息重试,更推荐使用这个注解来完成重试。\n// 重试 5 次,重试间隔 100 毫秒,最大间隔 1 秒 @RetryableTopic( attempts = \u0026#34;5\u0026#34;, backoff = @Backoff(delay = 100, maxDelay = 1000) ) @KafkaListener(topics = {KafkaConst.TEST_TOPIC}, groupId = \u0026#34;apple\u0026#34;) private void customer(String message) { log.info(\u0026#34;kafka customer:{}\u0026#34;, message); Integer n = Integer.parseInt(message); if (n % 5 == 0) { throw new RuntimeException(); } System.out.println(n); } 当达到最大重试次数后,如果仍然无法成功处理消息,消息会被发送到对应的死信队列中。对于死信队列的处理,既可以用 @DltHandler 处理,也可以使用 @KafkaListener 重新消费。\n参考 # Kafka 官方文档: https://kafka.apache.org/documentation/ 极客时间—《Kafka 核心技术与实战》第 11 节:无消息丢失配置怎么实现? "},{"id":530,"href":"/zh/docs/technology/Interview/java/collection/linkedhashmap-source-code/","title":"LinkedHashMap 源码分析","section":"Collection","content":" LinkedHashMap 简介 # LinkedHashMap 是 Java 提供的一个集合类,它继承自 HashMap,并在 HashMap 基础上维护一条双向链表,使得具备如下特性:\n支持遍历时会按照插入顺序有序进行迭代。 支持按照元素访问顺序排序,适用于封装 LRU 缓存工具。 因为内部使用双向链表维护各个节点,所以遍历时的效率和元素个数成正比,相较于和容量成正比的 HashMap 来说,迭代效率会高很多。 LinkedHashMap 逻辑结构如下图所示,它是在 HashMap 基础上在各个节点之间维护一条双向链表,使得原本散列在不同 bucket 上的节点、链表、红黑树有序关联起来。\nLinkedHashMap 使用示例 # 插入顺序遍历 # 如下所示,我们按照顺序往 LinkedHashMap 添加元素然后进行遍历。\nHashMap \u0026lt; String, String \u0026gt; map = new LinkedHashMap \u0026lt; \u0026gt; (); map.put(\u0026#34;a\u0026#34;, \u0026#34;2\u0026#34;); map.put(\u0026#34;g\u0026#34;, \u0026#34;3\u0026#34;); map.put(\u0026#34;r\u0026#34;, \u0026#34;1\u0026#34;); map.put(\u0026#34;e\u0026#34;, \u0026#34;23\u0026#34;); for (Map.Entry \u0026lt; String, String \u0026gt; entry: map.entrySet()) { System.out.println(entry.getKey() + \u0026#34;:\u0026#34; + entry.getValue()); } 输出:\na:2 g:3 r:1 e:23 可以看出,LinkedHashMap 的迭代顺序是和插入顺序一致的,这一点是 HashMap 所不具备的。\n访问顺序遍历 # LinkedHashMap 定义了排序模式 accessOrder(boolean 类型,默认为 false),访问顺序则为 true,插入顺序则为 false。\n为了实现访问顺序遍历,我们可以使用传入 accessOrder 属性的 LinkedHashMap 构造方法,并将 accessOrder 设置为 true,表示其具备访问有序性。\nLinkedHashMap\u0026lt;Integer, String\u0026gt; map = new LinkedHashMap\u0026lt;\u0026gt;(16, 0.75f, true); map.put(1, \u0026#34;one\u0026#34;); map.put(2, \u0026#34;two\u0026#34;); map.put(3, \u0026#34;three\u0026#34;); map.put(4, \u0026#34;four\u0026#34;); map.put(5, \u0026#34;five\u0026#34;); //访问元素2,该元素会被移动至链表末端 map.get(2); //访问元素3,该元素会被移动至链表末端 map.get(3); for (Map.Entry\u0026lt;Integer, String\u0026gt; entry : map.entrySet()) { System.out.println(entry.getKey() + \u0026#34; : \u0026#34; + entry.getValue()); } 输出:\n1 : one 4 : four 5 : five 2 : two 3 : three 可以看出,LinkedHashMap 的迭代顺序是和访问顺序一致的。\nLRU 缓存 # 从上一个我们可以了解到通过 LinkedHashMap 我们可以封装一个简易版的 LRU(Least Recently Used,最近最少使用) 缓存,确保当存放的元素超过容器容量时,将最近最少访问的元素移除。\n具体实现思路如下:\n继承 LinkedHashMap; 构造方法中指定 accessOrder 为 true ,这样在访问元素时就会把该元素移动到链表尾部,链表首元素就是最近最少被访问的元素; 重写removeEldestEntry 方法,该方法会返回一个 boolean 值,告知 LinkedHashMap 是否需要移除链表首元素(缓存容量有限)。 public class LRUCache\u0026lt;K, V\u0026gt; extends LinkedHashMap\u0026lt;K, V\u0026gt; { private final int capacity; public LRUCache(int capacity) { super(capacity, 0.75f, true); this.capacity = capacity; } /** * 判断size超过容量时返回true,告知LinkedHashMap移除最老的缓存项(即链表的第一个元素) */ @Override protected boolean removeEldestEntry(Map.Entry\u0026lt;K, V\u0026gt; eldest) { return size() \u0026gt; capacity; } } 测试代码如下,笔者初始化缓存容量为 3,然后按照次序先后添加 4 个元素。\nLRUCache\u0026lt;Integer, String\u0026gt; cache = new LRUCache\u0026lt;\u0026gt;(3); cache.put(1, \u0026#34;one\u0026#34;); cache.put(2, \u0026#34;two\u0026#34;); cache.put(3, \u0026#34;three\u0026#34;); cache.put(4, \u0026#34;four\u0026#34;); cache.put(5, \u0026#34;five\u0026#34;); for (int i = 1; i \u0026lt;= 5; i++) { System.out.println(cache.get(i)); } 输出:\nnull null three four five 从输出结果来看,由于缓存容量为 3 ,因此,添加第 4 个元素时,第 1 个元素会被删除。添加第 5 个元素时,第 2 个元素会被删除。\nLinkedHashMap 源码解析 # Node 的设计 # 在正式讨论 LinkedHashMap 前,我们先来聊聊 LinkedHashMap 节点 Entry 的设计,我们都知道 HashMap 的 bucket 上的因为冲突转为链表的节点会在符合以下两个条件时会将链表转为红黑树:\n链表上的节点个数达到树化的阈值 7,即TREEIFY_THRESHOLD - 1。 bucket 的容量达到最小的树化容量即MIN_TREEIFY_CAPACITY。 🐛 修正(参见: issue#2147):\n链表上的节点个数达到树化的阈值是 8 而非 7。因为源码的判断是从链表初始元素开始遍历,下标是从 0 开始的,所以判断条件设置为 8-1=7,其实是迭代到尾部元素时再判断整个链表长度大于等于 8 才进行树化操作。\n而 LinkedHashMap 是在 HashMap 的基础上为 bucket 上的每一个节点建立一条双向链表,这就使得转为红黑树的树节点也需要具备双向链表节点的特性,即每一个树节点都需要拥有两个引用存储前驱节点和后继节点的地址,所以对于树节点类 TreeNode 的设计就是一个比较棘手的问题。\n对此我们不妨来看看两者之间节点类的类图,可以看到:\nLinkedHashMap 的节点内部类 Entry 基于 HashMap 的基础上,增加 before 和 after 指针使节点具备双向链表的特性。 HashMap 的树节点 TreeNode 继承了具备双向链表特性的 LinkedHashMap 的 Entry。 很多读者此时就会有这样一个疑问,为什么 HashMap 的树节点 TreeNode 要通过 LinkedHashMap 获取双向链表的特性呢?为什么不直接在 Node 上实现前驱和后继指针呢?\n先来回答第一个问题,我们都知道 LinkedHashMap 是在 HashMap 基础上对节点增加双向指针实现双向链表的特性,所以 LinkedHashMap 内部链表转红黑树时,对应的节点会转为树节点 TreeNode,为了保证使用 LinkedHashMap 时树节点具备双向链表的特性,所以树节点 TreeNode 需要继承 LinkedHashMap 的 Entry。\n再来说说第二个问题,我们直接在 HashMap 的节点 Node 上直接实现前驱和后继指针,然后 TreeNode 直接继承 Node 获取双向链表的特性为什么不行呢?其实这样做也是可以的。只不过这种做法会使得使用 HashMap 时存储键值对的节点类 Node 多了两个没有必要的引用,占用没必要的内存空间。\n所以,为了保证 HashMap 底层的节点类 Node 没有多余的引用,又要保证 LinkedHashMap 的节点类 Entry 拥有存储链表的引用,设计者就让 LinkedHashMap 的节点 Entry 去继承 Node 并增加存储前驱后继节点的引用 before、after,让需要用到链表特性的节点去实现需要的逻辑。然后树节点 TreeNode 再通过继承 Entry 获取 before、after 两个指针。\nstatic class Entry\u0026lt;K,V\u0026gt; extends HashMap.Node\u0026lt;K,V\u0026gt; { Entry\u0026lt;K,V\u0026gt; before, after; Entry(int hash, K key, V value, Node\u0026lt;K,V\u0026gt; next) { super(hash, key, value, next); } } 但是这样做,不也使得使用 HashMap 时的 TreeNode 多了两个没有必要的引用吗?这不也是一种空间的浪费吗?\nstatic final class TreeNode\u0026lt;K,V\u0026gt; extends LinkedHashMap.Entry\u0026lt;K,V\u0026gt; { //略 } 对于这个问题,引用作者的一段注释,作者们认为在良好的 hashCode 算法时,HashMap 转红黑树的概率不大。就算转为红黑树变为树节点,也可能会因为移除或者扩容将 TreeNode 变为 Node,所以 TreeNode 的使用概率不算很大,对于这一点资源空间的浪费是可以接受的。\nBecause TreeNodes are about twice the size of regular nodes, we use them only when bins contain enough nodes to warrant use (see TREEIFY_THRESHOLD). And when they become too small (due to removal or resizing) they are converted back to plain bins. In usages with well-distributed user hashCodes, tree bins are rarely used. Ideally, under random hashCodes, the frequency of nodes in bins follows a Poisson distribution 构造方法 # LinkedHashMap 构造方法有 4 个实现也比较简单,直接调用父类即 HashMap 的构造方法完成初始化。\npublic LinkedHashMap() { super(); accessOrder = false; } public LinkedHashMap(int initialCapacity) { super(initialCapacity); accessOrder = false; } public LinkedHashMap(int initialCapacity, float loadFactor) { super(initialCapacity, loadFactor); accessOrder = false; } public LinkedHashMap(int initialCapacity, float loadFactor, boolean accessOrder) { super(initialCapacity, loadFactor); this.accessOrder = accessOrder; } 我们上面也提到了,默认情况下 accessOrder 为 false,如果我们要让 LinkedHashMap 实现键值对按照访问顺序排序(即将最近未访问的元素排在链表首部、最近访问的元素移动到链表尾部),需要调用第 4 个构造方法将 accessOrder 设置为 true。\nget 方法 # get 方法是 LinkedHashMap 增删改查操作中唯一一个重写的方法, accessOrder 为 true 的情况下, 它会在元素查询完成之后,将当前访问的元素移到链表的末尾。\npublic V get(Object key) { Node \u0026lt; K, V \u0026gt; e; //获取key的键值对,若为空直接返回 if ((e = getNode(hash(key), key)) == null) return null; //若accessOrder为true,则调用afterNodeAccess将当前元素移到链表末尾 if (accessOrder) afterNodeAccess(e); //返回键值对的值 return e.value; } 从源码可以看出,get 的执行步骤非常简单:\n调用父类即 HashMap 的 getNode 获取键值对,若为空则直接返回。 判断 accessOrder 是否为 true,若为 true 则说明需要保证 LinkedHashMap 的链表访问有序性,执行步骤 3。 调用 LinkedHashMap 重写的 afterNodeAccess 将当前元素添加到链表末尾。 关键点在于 afterNodeAccess 方法的实现,这个方法负责将元素移动到链表末尾。\nvoid afterNodeAccess(Node \u0026lt; K, V \u0026gt; e) { // move node to last LinkedHashMap.Entry \u0026lt; K, V \u0026gt; last; //如果accessOrder 且当前节点不为链表尾节点 if (accessOrder \u0026amp;\u0026amp; (last = tail) != e) { //获取当前节点、以及前驱节点和后继节点 LinkedHashMap.Entry \u0026lt; K, V \u0026gt; p = (LinkedHashMap.Entry \u0026lt; K, V \u0026gt; ) e, b = p.before, a = p.after; //将当前节点的后继节点指针指向空,使其和后继节点断开联系 p.after = null; //如果前驱节点为空,则说明当前节点是链表的首节点,故将后继节点设置为首节点 if (b == null) head = a; else //如果前驱节点不为空,则让前驱节点指向后继节点 b.after = a; //如果后继节点不为空,则让后继节点指向前驱节点 if (a != null) a.before = b; else //如果后继节点为空,则说明当前节点在链表最末尾,直接让last 指向前驱节点,这个 else其实 没有意义,因为最开头if已经确保了p不是尾结点了,自然after不会是null last = b; //如果last为空,则说明当前链表只有一个节点p,则将head指向p if (last == null) head = p; else { //反之让p的前驱指针指向尾节点,再让尾节点的前驱指针指向p p.before = last; last.after = p; } //tail指向p,自此将节点p移动到链表末尾 tail = p; ++modCount; } } 从源码可以看出, afterNodeAccess 方法完成了下面这些操作:\n如果 accessOrder 为 true 且链表尾部不为当前节点 p,我们则需要将当前节点移到链表尾部。 获取当前节点 p、以及它的前驱节点 b 和后继节点 a。 将当前节点 p 的后继指针设置为 null,使其和后继节点 p 断开联系。 尝试将前驱节点指向后继节点,若前驱节点为空,则说明当前节点 p 就是链表首节点,故直接将后继节点 a 设置为首节点,随后我们再将 p 追加到 a 的末尾。 再尝试让后继节点 a 指向前驱节点 b。 上述操作让前驱节点和后继节点完成关联,并将当前节点 p 独立出来,这一步则是将当前节点 p 追加到链表末端,如果链表末端为空,则说明当前链表只有一个节点 p,所以直接让 head 指向 p 即可。 上述操作已经将 p 成功到达链表末端,最后我们将 tail 指针即指向链表末端的指针指向 p 即可。 可以结合这张图理解,展示了 key 为 13 的元素被移动到了链表尾部。\n看不太懂也没关系,知道这个方法的作用就够了,后续有时间再慢慢消化。\nremove 方法后置操作——afterNodeRemoval # LinkedHashMap 并没有对 remove 方法进行重写,而是直接继承 HashMap 的 remove 方法,为了保证键值对移除后双向链表中的节点也会同步被移除,LinkedHashMap 重写了 HashMap 的空实现方法 afterNodeRemoval。\nfinal Node\u0026lt;K,V\u0026gt; removeNode(int hash, Object key, Object value, boolean matchValue, boolean movable) { //略 if (node != null \u0026amp;\u0026amp; (!matchValue || (v = node.value) == value || (value != null \u0026amp;\u0026amp; value.equals(v)))) { if (node instanceof TreeNode) ((TreeNode\u0026lt;K,V\u0026gt;)node).removeTreeNode(this, tab, movable); else if (node == p) tab[index] = node.next; else p.next = node.next; ++modCount; --size; //HashMap的removeNode完成元素移除后会调用afterNodeRemoval进行移除后置操作 afterNodeRemoval(node); return node; } } return null; } //空实现 void afterNodeRemoval(Node\u0026lt;K,V\u0026gt; p) { } 我们可以看到从 HashMap 继承来的 remove 方法内部调用的 removeNode 方法将节点从 bucket 删除后,调用了 afterNodeRemoval。\nvoid afterNodeRemoval(Node\u0026lt;K,V\u0026gt; e) { // unlink //获取当前节点p、以及e的前驱节点b和后继节点a LinkedHashMap.Entry\u0026lt;K,V\u0026gt; p = (LinkedHashMap.Entry\u0026lt;K,V\u0026gt;)e, b = p.before, a = p.after; //将p的前驱和后继指针都设置为null,使其和前驱、后继节点断开联系 p.before = p.after = null; //如果前驱节点为空,则说明当前节点p是链表首节点,让head指针指向后继节点a即可 if (b == null) head = a; else //如果前驱节点b不为空,则让b直接指向后继节点a b.after = a; //如果后继节点为空,则说明当前节点p在链表末端,所以直接让tail指针指向前驱节点a即可 if (a == null) tail = b; else //反之后继节点的前驱指针直接指向前驱节点 a.before = b; } 从源码可以看出, afterNodeRemoval 方法的整体操作就是让当前节点 p 和前驱节点、后继节点断开联系,等待 gc 回收,整体步骤为:\n获取当前节点 p、以及 p 的前驱节点 b 和后继节点 a。 让当前节点 p 和其前驱、后继节点断开联系。 尝试让前驱节点 b 指向后继节点 a,若 b 为空则说明当前节点 p 在链表首部,我们直接将 head 指向后继节点 a 即可。 尝试让后继节点 a 指向前驱节点 b,若 a 为空则说明当前节点 p 在链表末端,所以直接让 tail 指针指向前驱节点 b 即可。 可以结合这张图理解,展示了 key 为 13 的元素被删除,也就是从链表中移除了这个元素。\n看不太懂也没关系,知道这个方法的作用就够了,后续有时间再慢慢消化。\nput 方法后置操作——afterNodeInsertion # 同样的 LinkedHashMap 并没有实现插入方法,而是直接继承 HashMap 的所有插入方法交由用户使用,但为了维护双向链表访问的有序性,它做了这样两件事:\n重写 afterNodeAccess(上文提到过),如果当前被插入的 key 已存在与 map 中,因为 LinkedHashMap 的插入操作会将新节点追加至链表末尾,所以对于存在的 key 则调用 afterNodeAccess 将其放到链表末端。 重写了 HashMap 的 afterNodeInsertion 方法,当 removeEldestEntry 返回 true 时,会将链表首节点移除。 这一点我们可以在 HashMap 的插入操作核心方法 putVal 中看到。\nfinal V putVal(int hash, K key, V value, boolean onlyIfAbsent, boolean evict) { //略 if (e != null) { // existing mapping for key V oldValue = e.value; if (!onlyIfAbsent || oldValue == null) e.value = value; //如果当前的key在map中存在,则调用afterNodeAccess afterNodeAccess(e); return oldValue; } } ++modCount; if (++size \u0026gt; threshold) resize(); //调用插入后置方法,该方法被LinkedHashMap重写 afterNodeInsertion(evict); return null; } 上述步骤的源码上文已经解释过了,所以这里我们着重了解一下 afterNodeInsertion 的工作流程,假设我们的重写了 removeEldestEntry,当链表 size 超过 capacity 时,就返回 true。\n/** * 判断size超过容量时返回true,告知LinkedHashMap移除最老的缓存项(即链表的第一个元素) */ protected boolean removeEldestEntry(Map.Entry \u0026lt; K, V \u0026gt; eldest) { return size() \u0026gt; capacity; } 以下图为例,假设笔者最后新插入了一个不存在的节点 19,假设 capacity 为 4,所以 removeEldestEntry 返回 true,我们要将链表首节点移除。\n移除的步骤很简单,查看链表首节点是否存在,若存在则断开首节点和后继节点的关系,并让首节点指针指向下一节点,所以 head 指针指向了 12,节点 10 成为没有任何引用指向的空对象,等待 GC。\nvoid afterNodeInsertion(boolean evict) { // possibly remove eldest LinkedHashMap.Entry\u0026lt;K,V\u0026gt; first; //如果evict为true且队首元素不为空以及removeEldestEntry返回true,则说明我们需要最老的元素(即在链表首部的元素)移除。 if (evict \u0026amp;\u0026amp; (first = head) != null \u0026amp;\u0026amp; removeEldestEntry(first)) { //获取链表首部的键值对的key K key = first.key; //调用removeNode将元素从HashMap的bucket中移除,并和LinkedHashMap的双向链表断开,等待gc回收 removeNode(hash(key), key, null, false, true); } } 从源码可以看出, afterNodeInsertion 方法完成了下面这些操作:\n判断 eldest 是否为 true,只有为 true 才能说明可能需要将最年长的键值对(即链表首部的元素)进行移除,具体是否具体要进行移除,还得确定链表是否为空((first = head) != null),以及 removeEldestEntry 方法是否返回 true,只有这两个方法返回 true 才能确定当前链表不为空,且链表需要进行移除操作了。 获取链表第一个元素的 key。 调用 HashMap 的 removeNode 方法,该方法我们上文提到过,它会将节点从 HashMap 的 bucket 中移除,并且 LinkedHashMap 还重写了 removeNode 中的 afterNodeRemoval 方法,所以这一步将通过调用 removeNode 将元素从 HashMap 的 bucket 中移除,并和 LinkedHashMap 的双向链表断开,等待 gc 回收。 LinkedHashMap 和 HashMap 遍历性能比较 # LinkedHashMap 维护了一个双向链表来记录数据插入的顺序,因此在迭代遍历生成的迭代器的时候,是按照双向链表的路径进行遍历的。这一点相比于 HashMap 那种遍历整个 bucket 的方式来说,高效许多。\n这一点我们可以从两者的迭代器中得以印证,先来看看 HashMap 的迭代器,可以看到 HashMap 迭代键值对时会用到一个 nextNode 方法,该方法会返回 next 指向的下一个元素,并会从 next 开始遍历 bucket 找到下一个 bucket 中不为空的元素 Node。\nfinal class EntryIterator extends HashIterator implements Iterator \u0026lt; Map.Entry \u0026lt; K, V \u0026gt;\u0026gt; { public final Map.Entry \u0026lt; K, V \u0026gt; next() { return nextNode(); } } //获取下一个Node final Node \u0026lt; K, V \u0026gt; nextNode() { Node \u0026lt; K, V \u0026gt; [] t; //获取下一个元素next Node \u0026lt; K, V \u0026gt; e = next; if (modCount != expectedModCount) throw new ConcurrentModificationException(); if (e == null) throw new NoSuchElementException(); //将next指向bucket中下一个不为空的Node if ((next = (current = e).next) == null \u0026amp;\u0026amp; (t = table) != null) { do {} while (index \u0026lt; t.length \u0026amp;\u0026amp; (next = t[index++]) == null); } return e; } 相比之下 LinkedHashMap 的迭代器则是直接使用通过 after 指针快速定位到当前节点的后继节点,简洁高效许多。\nfinal class LinkedEntryIterator extends LinkedHashIterator implements Iterator \u0026lt; Map.Entry \u0026lt; K, V \u0026gt;\u0026gt; { public final Map.Entry \u0026lt; K, V \u0026gt; next() { return nextNode(); } } //获取下一个Node final LinkedHashMap.Entry \u0026lt; K, V \u0026gt; nextNode() { //获取下一个节点next LinkedHashMap.Entry \u0026lt; K, V \u0026gt; e = next; if (modCount != expectedModCount) throw new ConcurrentModificationException(); if (e == null) throw new NoSuchElementException(); //current 指针指向当前节点 current = e; //next直接当前节点的after指针快速定位到下一个节点 next = e.after; return e; } 为了验证笔者所说的观点,笔者对这两个容器进行了压测,测试插入 1000w 和迭代 1000w 条数据的耗时,代码如下:\nint count = 1000_0000; Map\u0026lt;Integer, Integer\u0026gt; hashMap = new HashMap\u0026lt;\u0026gt;(); Map\u0026lt;Integer, Integer\u0026gt; linkedHashMap = new LinkedHashMap\u0026lt;\u0026gt;(); long start, end; start = System.currentTimeMillis(); for (int i = 0; i \u0026lt; count; i++) { hashMap.put(ThreadLocalRandom.current().nextInt(1, count), ThreadLocalRandom.current().nextInt(0, count)); } end = System.currentTimeMillis(); System.out.println(\u0026#34;map time putVal: \u0026#34; + (end - start)); start = System.currentTimeMillis(); for (int i = 0; i \u0026lt; count; i++) { linkedHashMap.put(ThreadLocalRandom.current().nextInt(1, count), ThreadLocalRandom.current().nextInt(0, count)); } end = System.currentTimeMillis(); System.out.println(\u0026#34;linkedHashMap putVal time: \u0026#34; + (end - start)); start = System.currentTimeMillis(); long num = 0; for (Integer v : hashMap.values()) { num = num + v; } end = System.currentTimeMillis(); System.out.println(\u0026#34;map get time: \u0026#34; + (end - start)); start = System.currentTimeMillis(); for (Integer v : linkedHashMap.values()) { num = num + v; } end = System.currentTimeMillis(); System.out.println(\u0026#34;linkedHashMap get time: \u0026#34; + (end - start)); System.out.println(num); 从输出结果来看,因为 LinkedHashMap 需要维护双向链表的缘故,插入元素相较于 HashMap 会更耗时,但是有了双向链表明确的前后节点关系,迭代效率相对于前者高效了许多。不过,总体来说却别不大,毕竟数据量这么庞大。\nmap time putVal: 5880 linkedHashMap putVal time: 7567 map get time: 143 linkedHashMap get time: 67 63208969074998 LinkedHashMap 常见面试题 # 什么是 LinkedHashMap? # LinkedHashMap 是 Java 集合框架中 HashMap 的一个子类,它继承了 HashMap 的所有属性和方法,并且在 HashMap 的基础重写了 afterNodeRemoval、afterNodeInsertion、afterNodeAccess 方法。使之拥有顺序插入和访问有序的特性。\nLinkedHashMap 如何按照插入顺序迭代元素? # LinkedHashMap 按照插入顺序迭代元素是它的默认行为。LinkedHashMap 内部维护了一个双向链表,用于记录元素的插入顺序。因此,当使用迭代器迭代元素时,元素的顺序与它们最初插入的顺序相同。\nLinkedHashMap 如何按照访问顺序迭代元素? # LinkedHashMap 可以通过构造函数中的 accessOrder 参数指定按照访问顺序迭代元素。当 accessOrder 为 true 时,每次访问一个元素时,该元素会被移动到链表的末尾,因此下次访问该元素时,它就会成为链表中的最后一个元素,从而实现按照访问顺序迭代元素。\nLinkedHashMap 如何实现 LRU 缓存? # 将 accessOrder 设置为 true 并重写 removeEldestEntry 方法当链表大小超过容量时返回 true,使得每次访问一个元素时,该元素会被移动到链表的末尾。一旦插入操作让 removeEldestEntry 返回 true 时,视为缓存已满,LinkedHashMap 就会将链表首元素移除,由此我们就能实现一个 LRU 缓存。\nLinkedHashMap 和 HashMap 有什么区别? # LinkedHashMap 和 HashMap 都是 Java 集合框架中的 Map 接口的实现类。它们的最大区别在于迭代元素的顺序。HashMap 迭代元素的顺序是不确定的,而 LinkedHashMap 提供了按照插入顺序或访问顺序迭代元素的功能。此外,LinkedHashMap 内部维护了一个双向链表,用于记录元素的插入顺序或访问顺序,而 HashMap 则没有这个链表。因此,LinkedHashMap 的插入性能可能会比 HashMap 略低,但它提供了更多的功能并且迭代效率相较于 HashMap 更加高效。\n参考文献 # LinkedHashMap 源码详细分析(JDK1.8): https://www.imooc.com/article/22931 HashMap 与 LinkedHashMap: https://www.cnblogs.com/Spground/p/8536148.html 源于 LinkedHashMap 源码: https://leetcode.cn/problems/lru-cache/solution/yuan-yu-linkedhashmapyuan-ma-by-jeromememory/ "},{"id":531,"href":"/zh/docs/technology/Interview/java/collection/linkedlist-source-code/","title":"LinkedList 源码分析","section":"Collection","content":" LinkedList 简介 # LinkedList 是一个基于双向链表实现的集合类,经常被拿来和 ArrayList 做比较。关于 LinkedList 和ArrayList的详细对比,我们 Java 集合常见面试题总结(上)有详细介绍到。\n不过,我们在项目中一般是不会使用到 LinkedList 的,需要用到 LinkedList 的场景几乎都可以使用 ArrayList 来代替,并且,性能通常会更好!就连 LinkedList 的作者约书亚 · 布洛克(Josh Bloch)自己都说从来不会使用 LinkedList 。\n另外,不要下意识地认为 LinkedList 作为链表就最适合元素增删的场景。我在上面也说了,LinkedList 仅仅在头尾插入或者删除元素的时候时间复杂度近似 O(1),其他情况增删元素的平均时间复杂度都是 O(n) 。\nLinkedList 插入和删除元素的时间复杂度? # 头部插入/删除:只需要修改头结点的指针即可完成插入/删除操作,因此时间复杂度为 O(1)。 尾部插入/删除:只需要修改尾结点的指针即可完成插入/删除操作,因此时间复杂度为 O(1)。 指定位置插入/删除:需要先移动到指定位置,再修改指定节点的指针完成插入/删除,不过由于有头尾指针,可以从较近的指针出发,因此需要遍历平均 n/4 个元素,时间复杂度为 O(n)。 LinkedList 为什么不能实现 RandomAccess 接口? # RandomAccess 是一个标记接口,用来表明实现该接口的类支持随机访问(即可以通过索引快速访问元素)。由于 LinkedList 底层数据结构是链表,内存地址不连续,只能通过指针来定位,不支持随机快速访问,所以不能实现 RandomAccess 接口。\nLinkedList 源码分析 # 这里以 JDK1.8 为例,分析一下 LinkedList 的底层核心源码。\nLinkedList 的类定义如下:\npublic class LinkedList\u0026lt;E\u0026gt; extends AbstractSequentialList\u0026lt;E\u0026gt; implements List\u0026lt;E\u0026gt;, Deque\u0026lt;E\u0026gt;, Cloneable, java.io.Serializable { //... } LinkedList 继承了 AbstractSequentialList ,而 AbstractSequentialList 又继承于 AbstractList 。\n阅读过 ArrayList 的源码我们就知道,ArrayList 同样继承了 AbstractList , 所以 LinkedList 会有大部分方法和 ArrayList 相似。\nLinkedList 实现了以下接口:\nList : 表明它是一个列表,支持添加、删除、查找等操作,并且可以通过下标进行访问。 Deque :继承自 Queue 接口,具有双端队列的特性,支持从两端插入和删除元素,方便实现栈和队列等数据结构。需要注意,Deque 的发音为 \u0026ldquo;deck\u0026rdquo; [dɛk],这个大部分人都会读错。 Cloneable :表明它具有拷贝能力,可以进行深拷贝或浅拷贝操作。 Serializable : 表明它可以进行序列化操作,也就是可以将对象转换为字节流进行持久化存储或网络传输,非常方便。 LinkedList 中的元素是通过 Node 定义的:\nprivate static class Node\u0026lt;E\u0026gt; { E item;// 节点值 Node\u0026lt;E\u0026gt; next; // 指向的下一个节点(后继节点) Node\u0026lt;E\u0026gt; prev; // 指向的前一个节点(前驱结点) // 初始化参数顺序分别是:前驱结点、本身节点值、后继节点 Node(Node\u0026lt;E\u0026gt; prev, E element, Node\u0026lt;E\u0026gt; next) { this.item = element; this.next = next; this.prev = prev; } } 初始化 # LinkedList 中有一个无参构造函数和一个有参构造函数。\n// 创建一个空的链表对象 public LinkedList() { } // 接收一个集合类型作为参数,会创建一个与传入集合相同元素的链表对象 public LinkedList(Collection\u0026lt;? extends E\u0026gt; c) { this(); addAll(c); } 插入元素 # LinkedList 除了实现了 List 接口相关方法,还实现了 Deque 接口的很多方法,所以我们有很多种方式插入元素。\n我们这里以 List 接口中相关的插入方法为例进行源码讲解,对应的是add() 方法。\nadd() 方法有两个版本:\nadd(E e):用于在 LinkedList 的尾部插入元素,即将新元素作为链表的最后一个元素,时间复杂度为 O(1)。 add(int index, E element):用于在指定位置插入元素。这种插入方式需要先移动到指定位置,再修改指定节点的指针完成插入/删除,因此需要移动平均 n/4 个元素,时间复杂度为 O(n)。 // 在链表尾部插入元素 public boolean add(E e) { linkLast(e); return true; } // 在链表指定位置插入元素 public void add(int index, E element) { // 下标越界检查 checkPositionIndex(index); // 判断 index 是不是链表尾部位置 if (index == size) // 如果是就直接调用 linkLast 方法将元素节点插入链表尾部即可 linkLast(element); else // 如果不是则调用 linkBefore 方法将其插入指定元素之前 linkBefore(element, node(index)); } // 将元素节点插入到链表尾部 void linkLast(E e) { // 将最后一个元素赋值(引用传递)给节点 l final Node\u0026lt;E\u0026gt; l = last; // 创建节点,并指定节点前驱为链表尾节点 last,后继引用为空 final Node\u0026lt;E\u0026gt; newNode = new Node\u0026lt;\u0026gt;(l, e, null); // 将 last 引用指向新节点 last = newNode; // 判断尾节点是否为空 // 如果 l 是null 意味着这是第一次添加元素 if (l == null) // 如果是第一次添加,将first赋值为新节点,此时链表只有一个元素 first = newNode; else // 如果不是第一次添加,将新节点赋值给l(添加前的最后一个元素)的next l.next = newNode; size++; modCount++; } // 在指定元素之前插入元素 void linkBefore(E e, Node\u0026lt;E\u0026gt; succ) { // assert succ != null;断言 succ不为 null // 定义一个节点元素保存 succ 的 prev 引用,也就是它的前一节点信息 final Node\u0026lt;E\u0026gt; pred = succ.prev; // 初始化节点,并指明前驱和后继节点 final Node\u0026lt;E\u0026gt; newNode = new Node\u0026lt;\u0026gt;(pred, e, succ); // 将 succ 节点前驱引用 prev 指向新节点 succ.prev = newNode; // 判断前驱节点是否为空,为空表示 succ 是第一个节点 if (pred == null) // 新节点成为第一个节点 first = newNode; else // succ 节点前驱的后继引用指向新节点 pred.next = newNode; size++; modCount++; } 获取元素 # LinkedList获取元素相关的方法一共有 3 个:\ngetFirst():获取链表的第一个元素。 getLast():获取链表的最后一个元素。 get(int index):获取链表指定位置的元素。 // 获取链表的第一个元素 public E getFirst() { final Node\u0026lt;E\u0026gt; f = first; if (f == null) throw new NoSuchElementException(); return f.item; } // 获取链表的最后一个元素 public E getLast() { final Node\u0026lt;E\u0026gt; l = last; if (l == null) throw new NoSuchElementException(); return l.item; } // 获取链表指定位置的元素 public E get(int index) { // 下标越界检查,如果越界就抛异常 checkElementIndex(index); // 返回链表中对应下标的元素 return node(index).item; } 这里的核心在于 node(int index) 这个方法:\n// 返回指定下标的非空节点 Node\u0026lt;E\u0026gt; node(int index) { // 断言下标未越界 // assert isElementIndex(index); // 如果index小于size的二分之一 从前开始查找(向后查找) 反之向前查找 if (index \u0026lt; (size \u0026gt;\u0026gt; 1)) { Node\u0026lt;E\u0026gt; x = first; // 遍历,循环向后查找,直至 i == index for (int i = 0; i \u0026lt; index; i++) x = x.next; return x; } else { Node\u0026lt;E\u0026gt; x = last; for (int i = size - 1; i \u0026gt; index; i--) x = x.prev; return x; } } get(int index) 或 remove(int index) 等方法内部都调用了该方法来获取对应的节点。\n从这个方法的源码可以看出,该方法通过比较索引值与链表 size 的一半大小来确定从链表头还是尾开始遍历。如果索引值小于 size 的一半,就从链表头开始遍历,反之从链表尾开始遍历。这样可以在较短的时间内找到目标节点,充分利用了双向链表的特性来提高效率。\n删除元素 # LinkedList删除元素相关的方法一共有 5 个:\nremoveFirst():删除并返回链表的第一个元素。 removeLast():删除并返回链表的最后一个元素。 remove(E e):删除链表中首次出现的指定元素,如果不存在该元素则返回 false。 remove(int index):删除指定索引处的元素,并返回该元素的值。 void clear():移除此链表中的所有元素。 // 删除并返回链表的第一个元素 public E removeFirst() { final Node\u0026lt;E\u0026gt; f = first; if (f == null) throw new NoSuchElementException(); return unlinkFirst(f); } // 删除并返回链表的最后一个元素 public E removeLast() { final Node\u0026lt;E\u0026gt; l = last; if (l == null) throw new NoSuchElementException(); return unlinkLast(l); } // 删除链表中首次出现的指定元素,如果不存在该元素则返回 false public boolean remove(Object o) { // 如果指定元素为 null,遍历链表找到第一个为 null 的元素进行删除 if (o == null) { for (Node\u0026lt;E\u0026gt; x = first; x != null; x = x.next) { if (x.item == null) { unlink(x); return true; } } } else { // 如果不为 null ,遍历链表找到要删除的节点 for (Node\u0026lt;E\u0026gt; x = first; x != null; x = x.next) { if (o.equals(x.item)) { unlink(x); return true; } } } return false; } // 删除链表指定位置的元素 public E remove(int index) { // 下标越界检查,如果越界就抛异常 checkElementIndex(index); return unlink(node(index)); } 这里的核心在于 unlink(Node\u0026lt;E\u0026gt; x) 这个方法:\nE unlink(Node\u0026lt;E\u0026gt; x) { // 断言 x 不为 null // assert x != null; // 获取当前节点(也就是待删除节点)的元素 final E element = x.item; // 获取当前节点的下一个节点 final Node\u0026lt;E\u0026gt; next = x.next; // 获取当前节点的前一个节点 final Node\u0026lt;E\u0026gt; prev = x.prev; // 如果前一个节点为空,则说明当前节点是头节点 if (prev == null) { // 直接让链表头指向当前节点的下一个节点 first = next; } else { // 如果前一个节点不为空 // 将前一个节点的 next 指针指向当前节点的下一个节点 prev.next = next; // 将当前节点的 prev 指针置为 null,,方便 GC 回收 x.prev = null; } // 如果下一个节点为空,则说明当前节点是尾节点 if (next == null) { // 直接让链表尾指向当前节点的前一个节点 last = prev; } else { // 如果下一个节点不为空 // 将下一个节点的 prev 指针指向当前节点的前一个节点 next.prev = prev; // 将当前节点的 next 指针置为 null,方便 GC 回收 x.next = null; } // 将当前节点元素置为 null,方便 GC 回收 x.item = null; size--; modCount++; return element; } unlink() 方法的逻辑如下:\n首先获取待删除节点 x 的前驱和后继节点; 判断待删除节点是否为头节点或尾节点: 如果 x 是头节点,则将 first 指向 x 的后继节点 next 如果 x 是尾节点,则将 last 指向 x 的前驱节点 prev 如果 x 不是头节点也不是尾节点,执行下一步操作 将待删除节点 x 的前驱的后继指向待删除节点的后继 next,断开 x 和 x.prev 之间的链接; 将待删除节点 x 的后继的前驱指向待删除节点的前驱 prev,断开 x 和 x.next 之间的链接; 将待删除节点 x 的元素置空,修改链表长度。 可以参考下图理解(图源: LinkedList 源码分析(JDK 1.8)):\n遍历链表 # 推荐使用for-each 循环来遍历 LinkedList 中的元素, for-each 循环最终会转换成迭代器形式。\nLinkedList\u0026lt;String\u0026gt; list = new LinkedList\u0026lt;\u0026gt;(); list.add(\u0026#34;apple\u0026#34;); list.add(\u0026#34;banana\u0026#34;); list.add(\u0026#34;pear\u0026#34;); for (String fruit : list) { System.out.println(fruit); } LinkedList 的遍历的核心就是它的迭代器的实现。\n// 双向迭代器 private class ListItr implements ListIterator\u0026lt;E\u0026gt; { // 表示上一次调用 next() 或 previous() 方法时经过的节点; private Node\u0026lt;E\u0026gt; lastReturned; // 表示下一个要遍历的节点; private Node\u0026lt;E\u0026gt; next; // 表示下一个要遍历的节点的下标,也就是当前节点的后继节点的下标; private int nextIndex; // 表示当前遍历期望的修改计数值,用于和 LinkedList 的 modCount 比较,判断链表是否被其他线程修改过。 private int expectedModCount = modCount; ………… } 下面我们对迭代器 ListItr 中的核心方法进行详细介绍。\n我们先来看下从头到尾方向的迭代:\n// 判断还有没有下一个节点 public boolean hasNext() { // 判断下一个节点的下标是否小于链表的大小,如果是则表示还有下一个元素可以遍历 return nextIndex \u0026lt; size; } // 获取下一个节点 public E next() { // 检查在迭代过程中链表是否被修改过 checkForComodification(); // 判断是否还有下一个节点可以遍历,如果没有则抛出 NoSuchElementException 异常 if (!hasNext()) throw new NoSuchElementException(); // 将 lastReturned 指向当前节点 lastReturned = next; // 将 next 指向下一个节点 next = next.next; nextIndex++; return lastReturned.item; } 再来看一下从尾到头方向的迭代:\n// 判断是否还有前一个节点 public boolean hasPrevious() { return nextIndex \u0026gt; 0; } // 获取前一个节点 public E previous() { // 检查是否在迭代过程中链表被修改 checkForComodification(); // 如果没有前一个节点,则抛出异常 if (!hasPrevious()) throw new NoSuchElementException(); // 将 lastReturned 和 next 指针指向上一个节点 lastReturned = next = (next == null) ? last : next.prev; nextIndex--; return lastReturned.item; } 如果需要删除或插入元素,也可以使用迭代器进行操作。\nLinkedList\u0026lt;String\u0026gt; list = new LinkedList\u0026lt;\u0026gt;(); list.add(\u0026#34;apple\u0026#34;); list.add(null); list.add(\u0026#34;banana\u0026#34;); // Collection 接口的 removeIf 方法底层依然是基于迭代器 list.removeIf(Objects::isNull); for (String fruit : list) { System.out.println(fruit); } 迭代器对应的移除元素的方法如下:\n// 从列表中删除上次被返回的元素 public void remove() { // 检查是否在迭代过程中链表被修改 checkForComodification(); // 如果上次返回的节点为空,则抛出异常 if (lastReturned == null) throw new IllegalStateException(); // 获取当前节点的下一个节点 Node\u0026lt;E\u0026gt; lastNext = lastReturned.next; // 从链表中删除上次返回的节点 unlink(lastReturned); // 修改指针 if (next == lastReturned) next = lastNext; else nextIndex--; // 将上次返回的节点引用置为 null,方便 GC 回收 lastReturned = null; expectedModCount++; } LinkedList 常用方法测试 # 代码:\n// 创建 LinkedList 对象 LinkedList\u0026lt;String\u0026gt; list = new LinkedList\u0026lt;\u0026gt;(); // 添加元素到链表末尾 list.add(\u0026#34;apple\u0026#34;); list.add(\u0026#34;banana\u0026#34;); list.add(\u0026#34;pear\u0026#34;); System.out.println(\u0026#34;链表内容:\u0026#34; + list); // 在指定位置插入元素 list.add(1, \u0026#34;orange\u0026#34;); System.out.println(\u0026#34;链表内容:\u0026#34; + list); // 获取指定位置的元素 String fruit = list.get(2); System.out.println(\u0026#34;索引为 2 的元素:\u0026#34; + fruit); // 修改指定位置的元素 list.set(3, \u0026#34;grape\u0026#34;); System.out.println(\u0026#34;链表内容:\u0026#34; + list); // 删除指定位置的元素 list.remove(0); System.out.println(\u0026#34;链表内容:\u0026#34; + list); // 删除第一个出现的指定元素 list.remove(\u0026#34;banana\u0026#34;); System.out.println(\u0026#34;链表内容:\u0026#34; + list); // 获取链表的长度 int size = list.size(); System.out.println(\u0026#34;链表长度:\u0026#34; + size); // 清空链表 list.clear(); System.out.println(\u0026#34;清空后的链表:\u0026#34; + list); 输出:\n索引为 2 的元素:banana 链表内容:[apple, orange, banana, grape] 链表内容:[orange, banana, grape] 链表内容:[orange, grape] 链表长度:2 清空后的链表:[] "},{"id":532,"href":"/zh/docs/technology/Interview/cs-basics/operating-system/linux-intro/","title":"Linux 基础知识总结","section":"Operating System","content":"简单介绍一下 Java 程序员必知的 Linux 的一些概念以及常见命令。\n初探 Linux # Linux 简介 # 通过以下三点可以概括 Linux 到底是什么:\n类 Unix 系统:Linux 是一种自由、开放源码的类似 Unix 的操作系统 Linux 本质是指 Linux 内核:严格来讲,Linux 这个词本身只表示 Linux 内核,单独的 Linux 内核并不能成为一个可以正常工作的操作系统。所以,就有了各种 Linux 发行版。 Linux 之父(林纳斯·本纳第克特·托瓦兹 Linus Benedict Torvalds):一个编程领域的传奇式人物,真大佬!我辈崇拜敬仰之楷模。他是 Linux 内核 的最早作者,随后发起了这个开源项目,担任 Linux 内核的首要架构师。他还发起了 Git 这个开源项目,并为主要的开发者。 Linux 诞生 # 1989 年,Linus Torvalds 进入芬兰陆军新地区旅,服 11 个月的国家义务兵役,军衔为少尉,主要服务于计算机部门,任务是弹道计算。服役期间,购买了安德鲁·斯图尔特·塔能鲍姆所著的教科书及 minix 源代码,开始研究操作系统。1990 年,他退伍后回到大学,开始接触 Unix。\nMinix 是一个迷你版本的类 Unix 操作系统,由塔能鲍姆教授为了教学之用而创作,采用微核心设计。它启发了 Linux 内核的创作。\n1991 年,Linus Torvalds 开源了 Linux 内核。Linux 以一只可爱的企鹅作为标志,象征着敢作敢为、热爱生活。\n常见的 Linux 发行版本 # Linus Torvalds 开源的只是 Linux 内核,我们上面也提到了操作系统内核的作用。一些组织或厂商将 Linux 内核与各种软件和文档包装起来,并提供系统安装界面和系统配置、设定与管理工具,就构成了 Linux 的发行版本。\n内核主要负责系统的内存管理,硬件设备的管理,文件系统的管理以及应用程序的管理。\nLinux 的发行版本可以大体分为两类:\n商业公司维护的发行版本:比如 Red Hat 公司维护支持的 Red Hat Enterprise Linux (RHEL)。 社区组织维护的发行版本:比如基于 Red Hat Enterprise Linux(RHEL)的 CentOS、基于 Debian 的 Ubuntu。 对于初学者学习 Linux ,推荐选择 CentOS,原因如下:\nCentOS 免费且开放源代码; CentOS 基于 RHEL,功能与 RHEL 高度一致,安全稳定、性能优秀。 Linux 文件系统 # Linux 文件系统简介 # 在 Linux 操作系统中,一切被操作系统管理的资源,如网络接口卡、磁盘驱动器、打印机、输入输出设备、普通文件或目录等,都被视为文件。这是 Linux 系统中一个重要的概念,即\u0026quot;一切都是文件\u0026quot;。\n这种概念源自 UNIX 哲学,即将所有资源都抽象为文件的方式来进行管理和访问。Linux 的文件系统也借鉴了 UNIX 文件系统的设计理念。这种设计使得 Linux 系统可以通过统一的文件接口来管理和操作不同类型的资源,从而实现了一种统一的文件操作方式。例如,可以使用类似于读写文件的方式来对待网络接口、磁盘驱动器、设备文件等,使得操作和管理这些资源更加统一和简便。\n这种文件为中心的设计理念为 Linux 系统带来了灵活性和可扩展性,使得 Linux 成为一种强大的操作系统。同时,这也是 Linux 系统的一大特点,深受广大用户和开发者的喜欢和推崇。\ninode 介绍 # inode 是 Linux/Unix 文件系统的基础。那 inode 到是什么?有什么作用呢?\n通过以下五点可以概括 inode 到底是什么:\n硬盘以扇区 (Sector) 为最小物理存储单位,而操作系统和文件系统以块 (Block) 为单位进行读写,块由多个扇区组成。文件数据存储在这些块中。现代硬盘扇区通常为 4KB,与一些常见块大小相同,但操作系统也支持更大的块大小,以提升大文件读写性能。文件元信息(例如权限、大小、修改时间以及数据块位置)存储在 inode(索引节点)中。每个文件都有唯一的 inode。inode 本身不存储文件数据,而是存储指向数据块的指针,操作系统通过这些指针找到并读取文件数据。 固态硬盘 (SSD) 虽然没有物理扇区,但使用逻辑块,其概念与传统硬盘的块类似。 inode 是一种固定大小的数据结构,其大小在文件系统创建时就确定了,并且在文件的生命周期内保持不变。 inode 的访问速度非常快,因为系统可以直接通过 inode 号码定位到文件的元数据信息,无需遍历整个文件系统。 inode 的数量是有限的,每个文件系统只能包含固定数量的 inode。这意味着当文件系统中的 inode 用完时,无法再创建新的文件或目录,即使磁盘上还有可用空间。因此,在创建文件系统时,需要根据文件和目录的预期数量来合理分配 inode 的数量。 可以使用 stat 命令可以查看文件的 inode 信息,包括文件的 inode 号、文件类型、权限、所有者、文件大小、修改时间。 简单来说:inode 就是用来维护某个文件被分成几块、每一块在的地址、文件拥有者,创建时间,权限,大小等信息。\n再总结一下 inode 和 block:\ninode:记录文件的属性信息,可以使用 stat 命令查看 inode 信息。 block:实际文件的内容,如果一个文件大于一个块时候,那么将占用多个 block,但是一个块只能存放一个文件。(因为数据是由 inode 指向的,如果有两个文件的数据存放在同一个块中,就会乱套了) 可以看出,Linux/Unix 操作系统使用 inode 区分不同的文件。这样做的好处是,即使文件名被修改或删除,文件的 inode 号码不会改变,从而可以避免一些因文件重命名、移动或删除导致的错误。同时,inode 也可以提供更高的文件系统性能,因为 inode 的访问速度非常快,可以直接通过 inode 号码定位到文件的元数据信息,无需遍历整个文件系统。\n不过,使用 inode 号码也使得文件系统在用户和应用程序层面更加抽象和复杂,需要通过系统命令或文件系统接口来访问和管理文件的 inode 信息。\n硬链接和软链接 # 在 Linux/类 Unix 系统上,文件链接(File Link)是一种特殊的文件类型,可以在文件系统中指向另一个文件。常见的文件链接类型有两种:\n1、硬链接(Hard Link)\n在 Linux/类 Unix 文件系统中,每个文件和目录都有一个唯一的索引节点(inode)号,用来标识该文件或目录。硬链接通过 inode 节点号建立连接,硬链接和源文件的 inode 节点号相同,两者对文件系统来说是完全平等的(可以看作是互为硬链接,源头是同一份文件),删除其中任何一个对另外一个没有影响,可以通过给文件设置硬链接文件来防止重要文件被误删。 只有删除了源文件和所有对应的硬链接文件,该文件才会被真正删除。 硬链接具有一些限制,不能对目录以及不存在的文件创建硬链接,并且,硬链接也不能跨越文件系统。 ln 命令用于创建硬链接。 2、软链接(Symbolic Link 或 Symlink)\n软链接和源文件的 inode 节点号不同,而是指向一个文件路径。 源文件删除后,软链接依然存在,但是指向的是一个无效的文件路径。 软连接类似于 Windows 系统中的快捷方式。 不同于硬链接,可以对目录或者不存在的文件创建软链接,并且,软链接可以跨越文件系统。 ln -s 命令用于创建软链接。 硬链接为什么不能跨文件系统?\n我们之前提到过,硬链接是通过 inode 节点号建立连接的,而硬链接和源文件共享相同的 inode 节点号。\n然而,每个文件系统都有自己的独立 inode 表,且每个 inode 表只维护该文件系统内的 inode。如果在不同的文件系统之间创建硬链接,可能会导致 inode 节点号冲突的问题,即目标文件的 inode 节点号已经在该文件系统中被使用。\nLinux 文件类型 # Linux 支持很多文件类型,其中非常重要的文件类型有: 普通文件,目录文件,链接文件,设备文件,管道文件,Socket 套接字文件 等。\n普通文件(-):用于存储信息和数据, Linux 用户可以根据访问权限对普通文件进行查看、更改和删除。比如:图片、声音、PDF、text、视频、源代码等等。 目录文件(d,directory file):目录也是文件的一种,用于表示和管理系统中的文件,目录文件中包含一些文件名和子目录名。打开目录事实上就是打开目录文件。 符号链接文件(l,symbolic link):保留了指向文件的地址而不是文件本身。 字符设备(c,char):用来访问字符设备比如键盘。 设备文件(b,block):用来访问块设备比如硬盘、软盘。 管道文件(p,pipe) : 一种特殊类型的文件,用于进程之间的通信。 套接字文件(s,socket):用于进程间的网络通信,也可以用于本机之间的非网络通信。 每种文件类型都有不同的用途和属性,可以通过命令如ls、file等来查看文件的类型信息。\n# 普通文件(-) -rw-r--r-- 1 user group 1024 Apr 14 10:00 file.txt # 目录文件(d,directory file)* drwxr-xr-x 2 user group 4096 Apr 14 10:00 directory/ # 套接字文件(s,socket) srwxrwxrwx 1 user group 0 Apr 14 10:00 socket Linux 目录树 # Linux 使用一种称为目录树的层次结构来组织文件和目录。目录树由根目录(/)作为起始点,向下延伸,形成一系列的目录和子目录。每个目录可以包含文件和其他子目录。结构层次鲜明,就像一棵倒立的树。 常见目录说明:\n/bin: 存放二进制可执行文件(ls、cat、mkdir 等),常用命令一般都在这里; /etc: 存放系统管理和配置文件; /home: 存放所有用户文件的根目录,是用户主目录的基点,比如用户 user 的主目录就是/home/user,可以用~user 表示; /usr: 用于存放系统应用程序; /opt: 额外安装的可选应用程序包所放置的位置。一般情况下,我们可以把 tomcat 等都安装到这里; /proc: 虚拟文件系统目录,是系统内存的映射。可直接访问这个目录来获取系统信息; /root: 超级用户(系统管理员)的主目录(特权阶级o); /sbin: 存放二进制可执行文件,只有 root 才能访问。这里存放的是系统管理员使用的系统级别的管理命令和程序。如 ifconfig 等; /dev: 用于存放设备文件; /mnt: 系统管理员安装临时文件系统的安装点,系统提供这个目录是让用户临时挂载其他的文件系统; /boot: 存放用于系统引导时使用的各种文件; /lib 和/lib64: 存放着和系统运行相关的库文件 ; /tmp: 用于存放各种临时文件,是公用的临时文件存储点; /var: 用于存放运行时需要改变数据的文件,也是某些大文件的溢出区,比方说各种服务的日志文件(系统启动日志等。)等; /lost+found: 这个目录平时是空的,系统非正常关机而留下“无家可归”的文件(windows 下叫什么.chk)就在这里。 Linux 常用命令 # 下面只是给出了一些比较常用的命令。\n推荐一个 Linux 命令快查网站,非常不错,大家如果遗忘某些命令或者对某些命令不理解都可以在这里得到解决。Linux 命令在线速查手册: https://wangchujiang.com/linux-command/ 。\n另外, shell.how 这个网站可以用来解释常见命令的意思,对你学习 Linux 基本命令以及其他常用命令(如 Git、NPM)。\n目录切换 # cd usr:切换到该目录下 usr 目录 cd ..(或cd../):切换到上一层目录 cd /:切换到系统根目录 cd ~:切换到用户主目录 cd -: 切换到上一个操作所在目录 目录操作 # ls:显示目录中的文件和子目录的列表。例如:ls /home,显示 /home 目录下的文件和子目录列表。 ll:ll 是 ls -l 的别名,ll 命令可以看到该目录下的所有目录和文件的详细信息 mkdir [选项] 目录名:创建新目录(增)。例如:mkdir -m 755 my_directory,创建一个名为 my_directory 的新目录,并将其权限设置为 755,即所有用户对该目录有读、写和执行的权限。 find [路径] [表达式]:在指定目录及其子目录中搜索文件或目录(查),非常强大灵活。例如:① 列出当前目录及子目录下所有文件和文件夹: find .;② 在/home目录下查找以 .txt 结尾的文件名:find /home -name \u0026quot;*.txt\u0026quot; ,忽略大小写: find /home -i name \u0026quot;*.txt\u0026quot; ;③ 当前目录及子目录下查找所有以 .txt 和 .pdf 结尾的文件:find . \\( -name \u0026quot;*.txt\u0026quot; -o -name \u0026quot;*.pdf\u0026quot; \\)或find . -name \u0026quot;*.txt\u0026quot; -o -name \u0026quot;*.pdf\u0026quot;。 pwd:显示当前工作目录的路径。 rmdir [选项] 目录名:删除空目录(删)。例如:rmdir -p my_directory,删除名为 my_directory 的空目录,并且会递归删除my_directory的空父目录,直到遇到非空目录或根目录。 rm [选项] 文件或目录名:删除文件/目录(删)。例如:rm -r my_directory,删除名为 my_directory 的目录,-r(recursive,递归) 表示会递归删除指定目录及其所有子目录和文件。 cp [选项] 源文件/目录 目标文件/目录:复制文件或目录(移)。例如:cp file.txt /home/file.txt,将 file.txt 文件复制到 /home 目录下,并重命名为 file.txt。cp -r source destination,将 source 目录及其下的所有子目录和文件复制到 destination 目录下,并保留源文件的属性和目录结构。 mv [选项] 源文件/目录 目标文件/目录:移动文件或目录(移),也可以用于重命名文件或目录。例如:mv file.txt /home/file.txt,将 file.txt 文件移动到 /home 目录下,并重命名为 file.txt。mv 与 cp 的结果不同,mv 好像文件“搬家”,文件个数并未增加。而 cp 对文件进行复制,文件个数增加了。 文件操作 # 像 mv、cp、rm 等文件和目录都适用的命令,这里就不重复列举了。\ntouch [选项] 文件名..:创建新文件或更新已存在文件(增)。例如:touch file1.txt file2.txt file3.txt ,创建 3 个文件。 ln [选项] \u0026lt;源文件\u0026gt; \u0026lt;硬链接/软链接文件\u0026gt;:创建硬链接/软链接。例如:ln -s file.txt file_link,创建名为 file_link 的软链接,指向 file.txt 文件。-s 选项代表的就是创建软链接,s 即 symbolic(软链接又名符号链接) 。 cat/more/less/tail 文件名:文件的查看(查) 。命令 tail -f 文件 可以对某个文件进行动态监控,例如 Tomcat 的日志文件, 会随着程序的运行,日志会变化,可以使用 tail -f catalina-2016-11-11.log 监控 文 件的变化 。 vim 文件名:修改文件的内容(改)。vim 编辑器是 Linux 中的强大组件,是 vi 编辑器的加强版,vim 编辑器的命令和快捷方式有很多,但此处不一一阐述,大家也无需研究的很透彻,使用 vim 编辑修改文件的方式基本会使用就可以了。在实际开发中,使用 vim 编辑器主要作用就是修改配置文件,下面是一般步骤:vim 文件------\u0026gt;进入文件-----\u0026gt;命令模式------\u0026gt;按i进入编辑模式-----\u0026gt;编辑文件 -------\u0026gt;按Esc进入底行模式-----\u0026gt;输入:wq/q! (输入 wq 代表写入内容并退出,即保存;输入 q!代表强制退出不保存)。 文件压缩 # 1)打包并压缩文件:\nLinux 中的打包文件一般是以 .tar 结尾的,压缩的命令一般是以 .gz 结尾的。而一般情况下打包和压缩是一起进行的,打包并压缩后的文件的后缀名一般 .tar.gz。\n命令:tar -zcvf 打包压缩后的文件名 要打包压缩的文件 ,其中:\nz:调用 gzip 压缩命令进行压缩 c:打包文件 v:显示运行过程 f:指定文件名 比如:假如 test 目录下有三个文件分别是:aaa.txt、 bbb.txt、ccc.txt,如果我们要打包 test 目录并指定压缩后的压缩包名称为 test.tar.gz 可以使用命令:tar -zcvf test.tar.gz aaa.txt bbb.txt ccc.txt 或 tar -zcvf test.tar.gz /test/ 。\n2)解压压缩包:\n命令:tar [-xvf] 压缩文件\n其中 x 代表解压\n示例:\n将 /test 下的 test.tar.gz 解压到当前目录下可以使用命令:tar -xvf test.tar.gz 将 /test 下的 test.tar.gz 解压到根目录/usr 下:tar -xvf test.tar.gz -C /usr(-C 代表指定解压的位置) 文件传输 # scp [选项] 源文件 远程文件 (scp 即 secure copy,安全复制):用于通过 SSH 协议进行安全的文件传输,可以实现从本地到远程主机的上传和从远程主机到本地的下载。例如:scp -r my_directory user@remote:/home/user ,将本地目录my_directory上传到远程服务器 /home/user 目录下。scp -r user@remote:/home/user/my_directory ,将远程服务器的 /home/user 目录下的my_directory目录下载到本地。需要注意的是,scp 命令需要在本地和远程系统之间建立 SSH 连接进行文件传输,因此需要确保远程服务器已经配置了 SSH 服务,并且具有正确的权限和认证方式。 rsync [选项] 源文件 远程文件 : 可以在本地和远程系统之间高效地进行文件复制,并且能够智能地处理增量复制,节省带宽和时间。例如:rsync -r my_directory user@remote:/home/user,将本地目录my_directory上传到远程服务器 /home/user 目录下。 ftp (File Transfer Protocol):提供了一种简单的方式来连接到远程 FTP 服务器并进行文件上传、下载、删除等操作。使用之前需要先连接登录远程 FTP 服务器,进入 FTP 命令行界面后,可以使用 put 命令将本地文件上传到远程主机,可以使用get命令将远程主机的文件下载到本地,可以使用 delete 命令删除远程主机的文件。这里就不进行演示了。 文件权限 # 操作系统中每个文件都拥有特定的权限、所属用户和所属组。权限是操作系统用来限制资源访问的机制,在 Linux 中权限一般分为读(readable)、写(writable)和执行(executable),分为三组。分别对应文件的属主(owner),属组(group)和其他用户(other),通过这样的机制来限制哪些用户、哪些组可以对特定的文件进行什么样的操作。\n通过 ls -l 命令我们可以 查看某个目录下的文件或目录的权限\n示例:在随意某个目录下ls -l\n第一列的内容的信息解释如下:\n下面将详细讲解文件的类型、Linux 中权限以及文件有所有者、所在组、其它组具体是什么?\n文件的类型:\nd:代表目录 -:代表文件 l:代表软链接(可以认为是 window 中的快捷方式) Linux 中权限分为以下几种:\nr:代表权限是可读,r 也可以用数字 4 表示 w:代表权限是可写,w 也可以用数字 2 表示 x:代表权限是可执行,x 也可以用数字 1 表示 文件和目录权限的区别:\n对文件和目录而言,读写执行表示不同的意义。\n对于文件:\n权限名称 可执行操作 r 可以使用 cat 查看文件的内容 w 可以修改文件的内容 x 可以将其运行为二进制文件 对于目录:\n权限名称 可执行操作 r 可以查看目录下列表 w 可以创建和删除目录下文件 x 可以使用 cd 进入目录 需要注意的是:超级用户可以无视普通用户的权限,即使文件目录权限是 000,依旧可以访问。\n在 linux 中的每个用户必须属于一个组,不能独立于组外。在 linux 中每个文件有所有者、所在组、其它组的概念。\n所有者(u):一般为文件的创建者,谁创建了该文件,就天然的成为该文件的所有者,用 ls ‐ahl 命令可以看到文件的所有者 也可以使用 chown 用户名 文件名来修改文件的所有者 。 文件所在组(g):当某个用户创建了一个文件后,这个文件的所在组就是该用户所在的组用 ls ‐ahl命令可以看到文件的所有组也可以使用 chgrp 组名 文件名来修改文件所在的组。 其它组(o):除开文件的所有者和所在组的用户外,系统的其它用户都是文件的其它组。 我们再来看看如何修改文件/目录的权限。\n修改文件/目录的权限的命令:chmod\n示例:修改/test 下的 aaa.txt 的权限为文件所有者有全部权限,文件所有者所在的组有读写权限,其他用户只有读的权限。\nchmod u=rwx,g=rw,o=r aaa.txt 或者 chmod 764 aaa.txt\n补充一个比较常用的东西:\n假如我们装了一个 zookeeper,我们每次开机到要求其自动启动该怎么办?\n新建一个脚本 zookeeper 为新建的脚本 zookeeper 添加可执行权限,命令是:chmod +x zookeeper 把 zookeeper 这个脚本添加到开机启动项里面,命令是:chkconfig --add zookeeper 如果想看看是否添加成功,命令是:chkconfig --list 用户管理 # Linux 系统是一个多用户多任务的分时操作系统,任何一个要使用系统资源的用户,都必须首先向系统管理员申请一个账号,然后以这个账号的身份进入系统。\n用户的账号一方面可以帮助系统管理员对使用系统的用户进行跟踪,并控制他们对系统资源的访问;另一方面也可以帮助用户组织文件,并为用户提供安全性保护。\nLinux 用户管理相关命令:\nuseradd [选项] 用户名:创建用户账号。使用useradd指令所建立的帐号,实际上是保存在 /etc/passwd文本文件中。 userdel [选项] 用户名:删除用户帐号。 usermod [选项] 用户名:修改用户账号的属性和配置比如用户名、用户 ID、家目录。 passwd [选项] 用户名: 设置用户的认证信息,包括用户密码、密码过期时间等。。例如:passwd -S 用户名 ,显示用户账号密码信息。passwd -d 用户名: 清除用户密码,会导致用户无法登录。passwd 用户名,修改用户密码,随后系统会提示输入新密码并确认密码。 su [选项] 用户名(su 即 Switch User,切换用户):在当前登录的用户和其他用户之间切换身份。 用户组管理 # 每个用户都有一个用户组,系统可以对一个用户组中的所有用户进行集中管理。不同 Linux 系统对用户组的规定有所不同,如 Linux 下的用户属于与它同名的用户组,这个用户组在创建用户时同时创建。\n用户组的管理涉及用户组的添加、删除和修改。组的增加、删除和修改实际上就是对/etc/group文件的更新。\nLinux 系统用户组的管理相关命令:\ngroupadd [选项] 用户组 :增加一个新的用户组。 groupdel 用户组:要删除一个已有的用户组。 groupmod [选项] 用户组 : 修改用户组的属性。 系统状态 # top [选项]:用于实时查看系统的 CPU 使用率、内存使用率、进程信息等。 htop [选项]:类似于 top,但提供了更加交互式和友好的界面,可让用户交互式操作,支持颜色主题,可横向或纵向滚动浏览进程列表,并支持鼠标操作。 uptime [选项]:用于查看系统总共运行了多长时间、系统的平均负载等信息。 vmstat [间隔时间] [重复次数]:vmstat (Virtual Memory Statistics) 的含义为显示虚拟内存状态,但是它可以报告关于进程、内存、I/O 等系统整体运行状态。 free [选项]:用于查看系统的内存使用情况,包括已用内存、可用内存、缓冲区和缓存等。 df [选项] [文件系统]:用于查看系统的磁盘空间使用情况,包括磁盘空间的总量、已使用量和可用量等,可以指定文件系统上。例如:df -a,查看全部文件系统。 du [选项] [文件]:用于查看指定目录或文件的磁盘空间使用情况,可以指定不同的选项来控制输出格式和单位。 sar [选项] [时间间隔] [重复次数]:用于收集、报告和分析系统的性能统计信息,包括系统的 CPU 使用、内存使用、磁盘 I/O、网络活动等详细信息。它的特点是可以连续对系统取样,获得大量的取样数据。取样数据和分析的结果都可以存入文件,使用它时消耗的系统资源很小。 ps [选项]:用于查看系统中的进程信息,包括进程的 ID、状态、资源使用情况等。ps -ef/ps -aux:这两个命令都是查看当前系统正在运行进程,两者的区别是展示格式不同。如果想要查看特定的进程可以使用这样的格式:ps aux|grep redis (查看包括 redis 字符串的进程),也可使用 pgrep redis -a。 systemctl [命令] [服务名称]:用于管理系统的服务和单元,可以查看系统服务的状态、启动、停止、重启等。 网络通信 # ping [选项] 目标主机:测试与目标主机的网络连接。 ifconfig 或 ip:用于查看系统的网络接口信息,包括网络接口的 IP 地址、MAC 地址、状态等。 netstat [选项]:用于查看系统的网络连接状态和网络统计信息,可以查看当前的网络连接情况、监听端口、网络协议等。 ss [选项]:比 netstat 更好用,提供了更快速、更详细的网络连接信息。 其他 # sudo + 其他命令:以系统管理者的身份执行指令,也就是说,经由 sudo 所执行的指令就好像是 root 亲自执行。 grep 要搜索的字符串 要搜索的文件 --color:搜索命令,\u0026ndash;color 代表高亮显示。 kill -9 进程的pid:杀死进程(-9 表示强制终止)先用 ps 查找进程,然后用 kill 杀掉。 shutdown:shutdown -h now:指定现在立即关机;shutdown +5 \u0026quot;System will shutdown after 5 minutes\u0026quot;:指定 5 分钟后关机,同时送出警告信息给登入用户。 reboot:reboot:重开机。reboot -w:做个重开机的模拟(只有纪录并不会真的重开机)。 Linux 环境变量 # 在 Linux 系统中,环境变量是用来定义系统运行环境的一些参数,比如每个用户不同的主目录(HOME)。\n环境变量分类 # 按照作用域来分,环境变量可以简单的分成:\n用户级别环境变量 : ~/.bashrc、~/.bash_profile。 系统级别环境变量 : /etc/bashrc、/etc/environment、/etc/profile、/etc/profile.d。 上述配置文件执行先后顺序为:/etc/environment –\u0026gt; /etc/profile –\u0026gt; /etc/profile.d –\u0026gt; ~/.bash_profile –\u0026gt; /etc/bashrc –\u0026gt; ~/.bashrc\n如果要修改系统级别环境变量文件,需要管理员具备对该文件的写入权限。\n建议用户级别环境变量在 ~/.bash_profile中配置,系统级别环境变量在 /etc/profile.d 中配置。\n按照生命周期来分,环境变量可以简单的分成:\n永久的:需要用户修改相关的配置文件,变量永久生效。 临时的:用户利用 export 命令,在当前终端下声明环境变量,关闭 shell 终端失效。 读取环境变量 # 通过 export 命令可以输出当前系统定义的所有环境变量。\n# 列出当前的环境变量值 export -p 除了 export 命令之外, env 命令也可以列出所有环境变量。\necho 命令可以输出指定环境变量的值。\n# 输出当前的PATH环境变量的值 echo $PATH # 输出当前的HOME环境变量的值 echo $HOME 环境变量修改 # 通过 export命令可以修改指定的环境变量。不过,这种方式修改环境变量仅仅对当前 shell 终端生效,关闭 shell 终端就会失效。修改完成之后,立即生效。\nexport CLASSPATH=./JAVA_HOME/lib;$JAVA_HOME/jre/lib 通过 vim 命令修改环境变量配置文件。这种方式修改环境变量永久有效。\nvim ~/.bash_profile 如果修改的是系统级别环境变量则对所有用户生效,如果修改的是用户级别环境变量则仅对当前用户生效。\n修改完成之后,需要 source 命令让其生效或者关闭 shell 终端重新登录。\nsource /etc/profile "},{"id":533,"href":"/zh/docs/technology/Interview/tools/maven/maven-core-concepts/","title":"Maven核心概念总结","section":"Maven","content":" 这部分内容主要根据 Maven 官方文档整理,做了对应的删减,主要保留比较重要的部分,不涉及实战,主要是一些重要概念的介绍。\nMaven 介绍 # Maven 官方文档是这样介绍的 Maven 的:\nApache Maven is a software project management and comprehension tool. Based on the concept of a project object model (POM), Maven can manage a project\u0026rsquo;s build, reporting and documentation from a central piece of information.\nApache Maven 的本质是一个软件项目管理和理解工具。基于项目对象模型 (Project Object Model,POM) 的概念,Maven 可以从一条中心信息管理项目的构建、报告和文档。\n什么是 POM? 每一个 Maven 工程都有一个 pom.xml 文件,位于根目录中,包含项目构建生命周期的详细信息。通过 pom.xml 文件,我们可以定义项目的坐标、项目依赖、项目信息、插件信息等等配置。\n对于开发者来说,Maven 的主要作用主要有 3 个:\n项目构建:提供标准的、跨平台的自动化项目构建方式。 依赖管理:方便快捷的管理项目依赖的资源(jar 包),避免资源间的版本冲突问题。 统一开发结构:提供标准的、统一的项目结构。 关于 Maven 的基本使用这里就不介绍了,建议看看官网的 5 分钟上手 Maven 的教程: Maven in 5 Minutes 。\nMaven 坐标 # 项目中依赖的第三方库以及插件可统称为构件。每一个构件都可以使用 Maven 坐标唯一标识,坐标元素包括:\ngroupId(必须): 定义了当前 Maven 项目隶属的组织或公司。groupId 一般分为多段,通常情况下,第一段为域,第二段为公司名称。域又分为 org、com、cn 等,其中 org 为非营利组织,com 为商业组织,cn 表示中国。以 apache 开源社区的 tomcat 项目为例,这个项目的 groupId 是 org.apache,它的域是 org(因为 tomcat 是非营利项目),公司名称是 apache,artifactId 是 tomcat。 artifactId(必须):定义了当前 Maven 项目的名称,项目的唯一的标识符,对应项目根目录的名称。 version(必须):定义了 Maven 项目当前所处版本。 packaging(可选):定义了 Maven 项目的打包方式(比如 jar,war\u0026hellip;),默认使用 jar。 classifier(可选):常用于区分从同一 POM 构建的具有不同内容的构件,可以是任意的字符串,附加在版本号之后。 只要你提供正确的坐标,就能从 Maven 仓库中找到相应的构件供我们使用。\n举个例子(引入阿里巴巴开源的 EasyExcel):\n\u0026lt;dependency\u0026gt; \u0026lt;groupId\u0026gt;com.alibaba\u0026lt;/groupId\u0026gt; \u0026lt;artifactId\u0026gt;easyexcel\u0026lt;/artifactId\u0026gt; \u0026lt;version\u0026gt;3.1.1\u0026lt;/version\u0026gt; \u0026lt;/dependency\u0026gt; 你可以在 https://mvnrepository.com/ 这个网站上找到几乎所有可用的构件,如果你的项目使用的是 Maven 作为构建工具,那这个网站你一定会经常接触。\nMaven 依赖 # 如果使用 Maven 构建产生的构件(例如 Jar 文件)被其他的项目引用,那么该构件就是其他项目的依赖。\n依赖配置 # 配置信息示例:\n\u0026lt;project\u0026gt; \u0026lt;dependencies\u0026gt; \u0026lt;dependency\u0026gt; \u0026lt;groupId\u0026gt;\u0026lt;/groupId\u0026gt; \u0026lt;artifactId\u0026gt;\u0026lt;/artifactId\u0026gt; \u0026lt;version\u0026gt;\u0026lt;/version\u0026gt; \u0026lt;type\u0026gt;...\u0026lt;/type\u0026gt; \u0026lt;scope\u0026gt;...\u0026lt;/scope\u0026gt; \u0026lt;optional\u0026gt;...\u0026lt;/optional\u0026gt; \u0026lt;exclusions\u0026gt; \u0026lt;exclusion\u0026gt; \u0026lt;groupId\u0026gt;...\u0026lt;/groupId\u0026gt; \u0026lt;artifactId\u0026gt;...\u0026lt;/artifactId\u0026gt; \u0026lt;/exclusion\u0026gt; \u0026lt;/exclusions\u0026gt; \u0026lt;/dependency\u0026gt; \u0026lt;/dependencies\u0026gt; \u0026lt;/project\u0026gt; 配置说明:\ndependencies:一个 pom.xml 文件中只能存在一个这样的标签,是用来管理依赖的总标签。 dependency:包含在 dependencies 标签中,可以有多个,每一个表示项目的一个依赖。 groupId,artifactId,version(必要):依赖的基本坐标,对于任何一个依赖来说,基本坐标是最重要的,Maven 根据坐标才能找到需要的依赖。我们在上面解释过这些元素的具体意思,这里就不重复提了。 type(可选):依赖的类型,对应于项目坐标定义的 packaging。大部分情况下,该元素不必声明,其默认值是 jar。 scope(可选):依赖的范围,默认值是 compile。 optional(可选):标记依赖是否可选 exclusions(可选):用来排除传递性依赖,例如 jar 包冲突 依赖范围 # classpath 用于指定 .class 文件存放的位置,类加载器会从该路径中加载所需的 .class 文件到内存中。\nMaven 在编译、执行测试、实际运行有着三套不同的 classpath:\n编译 classpath:编译主代码有效 测试 classpath:编译、运行测试代码有效 运行 classpath:项目运行时有效 Maven 的依赖范围如下:\ncompile:编译依赖范围(默认),使用此依赖范围对于编译、测试、运行三种都有效,即在编译、测试和运行的时候都要使用该依赖 Jar 包。 test:测试依赖范围,从字面意思就可以知道此依赖范围只能用于测试,而在编译和运行项目时无法使用此类依赖,典型的是 JUnit,它只用于编译测试代码和运行测试代码的时候才需要。 provided:此依赖范围,对于编译和测试有效,而对运行时无效。比如 servlet-api.jar 在 Tomcat 中已经提供了,我们只需要的是编译期提供而已。 runtime:运行时依赖范围,对于测试和运行有效,但是在编译主代码时无效,典型的就是 JDBC 驱动实现。 system:系统依赖范围,使用 system 范围的依赖时必须通过 systemPath 元素显示地指定依赖文件的路径,不依赖 Maven 仓库解析,所以可能会造成建构的不可移植。 传递依赖性 # 依赖冲突 # 1、对于 Maven 而言,同一个 groupId 同一个 artifactId 下,只能使用一个 version。\n\u0026lt;dependency\u0026gt; \u0026lt;groupId\u0026gt;in.hocg.boot\u0026lt;/groupId\u0026gt; \u0026lt;artifactId\u0026gt;mybatis-plus-spring-boot-starter\u0026lt;/artifactId\u0026gt; \u0026lt;version\u0026gt;1.0.48\u0026lt;/version\u0026gt; \u0026lt;/dependency\u0026gt; \u0026lt;!-- 只会使用 1.0.49 这个版本的依赖 --\u0026gt; \u0026lt;dependency\u0026gt; \u0026lt;groupId\u0026gt;in.hocg.boot\u0026lt;/groupId\u0026gt; \u0026lt;artifactId\u0026gt;mybatis-plus-spring-boot-starter\u0026lt;/artifactId\u0026gt; \u0026lt;version\u0026gt;1.0.49\u0026lt;/version\u0026gt; \u0026lt;/dependency\u0026gt; 若相同类型但版本不同的依赖存在于同一个 pom 文件,只会引入后一个声明的依赖。\n2、项目的两个依赖同时引入了某个依赖。\n举个例子,项目存在下面这样的依赖关系:\n依赖链路一:A -\u0026gt; B -\u0026gt; C -\u0026gt; X(1.0) 依赖链路二:A -\u0026gt; D -\u0026gt; X(2.0) 这两条依赖路径上有两个版本的 X,为了避免依赖重复,Maven 只会选择其中的一个进行解析。\n哪个版本的 X 会被 Maven 解析使用呢?\nMaven 在遇到这种问题的时候,会遵循 路径最短优先 和 声明顺序优先 两大原则。解决这个问题的过程也被称为 Maven 依赖调解 。\n路径最短优先\n依赖链路一:A -\u0026gt; B -\u0026gt; C -\u0026gt; X(1.0) // dist = 3 依赖链路二:A -\u0026gt; D -\u0026gt; X(2.0) // dist = 2 依赖链路二的路径最短,因此,X(2.0)会被解析使用。\n不过,你也可以发现。路径最短优先原则并不是通用的,像下面这种路径长度相等的情况就不能单单通过其解决了:\n依赖链路一:A -\u0026gt; B -\u0026gt; X(1.0) // dist = 2 依赖链路二:A -\u0026gt; D -\u0026gt; X(2.0) // dist = 2 因此,Maven 又定义了声明顺序优先原则。\n依赖调解第一原则不能解决所有问题,比如这样的依赖关系:A-\u0026gt;B-\u0026gt;Y(1.0)、A-\u0026gt; C-\u0026gt;Y(2.0),Y(1.0)和 Y(2.0)的依赖路径长度是一样的,都为 2。Maven 定义了依赖调解的第二原则:\n声明顺序优先\n在依赖路径长度相等的前提下,在 pom.xml 中依赖声明的顺序决定了谁会被解析使用,顺序最前的那个依赖优胜。该例中,如果 B 的依赖声明在 D 之前,那么 X (1.0)就会被解析使用。\n\u0026lt;!-- A pom.xml --\u0026gt; \u0026lt;dependencies\u0026gt; ... dependency B ... dependency D \u0026lt;/dependencies\u0026gt; 排除依赖 # 单纯依赖 Maven 来进行依赖调解,在很多情况下是不适用的,需要我们手动排除依赖。\n举个例子,当前项目存在下面这样的依赖关系:\n依赖链路一:A -\u0026gt; B -\u0026gt; C -\u0026gt; X(1.5) // dist = 3 依赖链路二:A -\u0026gt; D -\u0026gt; X(1.0) // dist = 2 根据路径最短优先原则,X(1.0) 会被解析使用,也就是说实际用的是 1.0 版本的 X。\n但是!!!这会一些问题:如果 C 依赖用到了 1.5 版本的 X 中才有的一个类,运行项目就会报NoClassDefFoundError错误。如果 C 依赖用到了 1.5 版本的 X 中才有的一个方法,运行项目就会报NoSuchMethodError错误。\n现在知道为什么你的 Maven 项目总是会报NoClassDefFoundError和NoSuchMethodError错误了吧?\n如何解决呢? 我们可以通过exclusion标签手动将 X(1.0) 给排除。\n\u0026lt;dependency\u0026gt; ...... \u0026lt;exclusions\u0026gt; \u0026lt;exclusion\u0026gt; \u0026lt;artifactId\u0026gt;x\u0026lt;/artifactId\u0026gt; \u0026lt;groupId\u0026gt;org.apache.x\u0026lt;/groupId\u0026gt; \u0026lt;/exclusion\u0026gt; \u0026lt;/exclusions\u0026gt; \u0026lt;/dependency\u0026gt; 一般我们在解决依赖冲突的时候,都会优先保留版本较高的。这是因为大部分 jar 在升级的时候都会做到向下兼容。\n如果高版本修改了低版本的一些类或者方法的话,这个时候就不能直接保留高版本了,而是应该考虑优化上层依赖,比如升级上层依赖的版本。\n还是上面的例子:\n依赖链路一:A -\u0026gt; B -\u0026gt; C -\u0026gt; X(1.5) // dist = 3 依赖链路二:A -\u0026gt; D -\u0026gt; X(1.0) // dist = 2 我们保留了 1.5 版本的 X,但是这个版本的 X 删除了 1.0 版本中的某些类。这个时候,我们可以考虑升级 D 的版本到一个 X 兼容的版本。\nMaven 仓库 # 在 Maven 世界中,任何一个依赖、插件或者项目构建的输出,都可以称为 构件 。\n坐标和依赖是构件在 Maven 世界中的逻辑表示方式,构件的物理表示方式是文件,Maven 通过仓库来统一管理这些文件。 任何一个构件都有一组坐标唯一标识。有了仓库之后,无需手动引入构件,我们直接给定构件的坐标即可在 Maven 仓库中找到该构件。\nMaven 仓库分为:\n本地仓库:运行 Maven 的计算机上的一个目录,它缓存远程下载的构件并包含尚未发布的临时构件。settings.xml 文件中可以看到 Maven 的本地仓库路径配置,默认本地仓库路径是在 ${user.home}/.m2/repository。 远程仓库:官方或者其他组织维护的 Maven 仓库。 Maven 远程仓库可以分为:\n中央仓库:这个仓库是由 Maven 社区来维护的,里面存放了绝大多数开源软件的包,并且是作为 Maven 的默认配置,不需要开发者额外配置。另外为了方便查询,还提供了一个 查询地址,开发者可以通过这个地址更快的搜索需要构件的坐标。 私服:私服是一种特殊的远程 Maven 仓库,它是架设在局域网内的仓库服务,私服一般被配置为互联网远程仓库的镜像,供局域网内的 Maven 用户使用。 其他的公共仓库:有一些公共仓库是为了加速访问(比如阿里云 Maven 镜像仓库)或者部分构件不存在于中央仓库中。 Maven 依赖包寻找顺序:\n先去本地仓库找寻,有的话,直接使用。 本地仓库没有找到的话,会去远程仓库找寻,下载包到本地仓库。 远程仓库没有找到的话,会报错。 Maven 生命周期 # Maven 的生命周期就是为了对所有的构建过程进行抽象和统一,包含了项目的清理、初始化、编译、测试、打包、集成测试、验证、部署和站点生成等几乎所有构建步骤。\nMaven 定义了 3 个生命周期META-INF/plexus/components.xml:\ndefault 生命周期 clean生命周期 site生命周期 这些生命周期是相互独立的,每个生命周期包含多个阶段(phase)。并且,这些阶段是有序的,也就是说,后面的阶段依赖于前面的阶段。当执行某个阶段的时候,会先执行它前面的阶段。\n执行 Maven 生命周期的命令格式如下:\nmvn 阶段 [阶段2] ...[阶段n] default 生命周期 # default生命周期是在没有任何关联插件的情况下定义的,是 Maven 的主要生命周期,用于构建应用程序,共包含 23 个阶段。\n\u0026lt;phases\u0026gt; \u0026lt;!-- 验证项目是否正确,并且所有必要的信息可用于完成构建过程 --\u0026gt; \u0026lt;phase\u0026gt;validate\u0026lt;/phase\u0026gt; \u0026lt;!-- 建立初始化状态,例如设置属性 --\u0026gt; \u0026lt;phase\u0026gt;initialize\u0026lt;/phase\u0026gt; \u0026lt;!-- 生成要包含在编译阶段的源代码 --\u0026gt; \u0026lt;phase\u0026gt;generate-sources\u0026lt;/phase\u0026gt; \u0026lt;!-- 处理源代码 --\u0026gt; \u0026lt;phase\u0026gt;process-sources\u0026lt;/phase\u0026gt; \u0026lt;!-- 生成要包含在包中的资源 --\u0026gt; \u0026lt;phase\u0026gt;generate-resources\u0026lt;/phase\u0026gt; \u0026lt;!-- 将资源复制并处理到目标目录中,为打包阶段做好准备。 --\u0026gt; \u0026lt;phase\u0026gt;process-resources\u0026lt;/phase\u0026gt; \u0026lt;!-- 编译项目的源代码 --\u0026gt; \u0026lt;phase\u0026gt;compile\u0026lt;/phase\u0026gt; \u0026lt;!-- 对编译生成的文件进行后处理,例如对 Java 类进行字节码增强/优化 --\u0026gt; \u0026lt;phase\u0026gt;process-classes\u0026lt;/phase\u0026gt; \u0026lt;!-- 生成要包含在编译阶段的任何测试源代码 --\u0026gt; \u0026lt;phase\u0026gt;generate-test-sources\u0026lt;/phase\u0026gt; \u0026lt;!-- 处理测试源代码 --\u0026gt; \u0026lt;phase\u0026gt;process-test-sources\u0026lt;/phase\u0026gt; \u0026lt;!-- 生成要包含在编译阶段的测试源代码 --\u0026gt; \u0026lt;phase\u0026gt;generate-test-resources\u0026lt;/phase\u0026gt; \u0026lt;!-- 处理从测试代码文件编译生成的文件 --\u0026gt; \u0026lt;phase\u0026gt;process-test-resources\u0026lt;/phase\u0026gt; \u0026lt;!-- 编译测试源代码 --\u0026gt; \u0026lt;phase\u0026gt;test-compile\u0026lt;/phase\u0026gt; \u0026lt;!-- 处理从测试代码文件编译生成的文件 --\u0026gt; \u0026lt;phase\u0026gt;process-test-classes\u0026lt;/phase\u0026gt; \u0026lt;!-- 使用合适的单元测试框架(Junit 就是其中之一)运行测试 --\u0026gt; \u0026lt;phase\u0026gt;test\u0026lt;/phase\u0026gt; \u0026lt;!-- 在实际打包之前,执行任何的必要的操作为打包做准备 --\u0026gt; \u0026lt;phase\u0026gt;prepare-package\u0026lt;/phase\u0026gt; \u0026lt;!-- 获取已编译的代码并将其打包成可分发的格式,例如 JAR、WAR 或 EAR 文件 --\u0026gt; \u0026lt;phase\u0026gt;package\u0026lt;/phase\u0026gt; \u0026lt;!-- 在执行集成测试之前执行所需的操作。 例如,设置所需的环境 --\u0026gt; \u0026lt;phase\u0026gt;pre-integration-test\u0026lt;/phase\u0026gt; \u0026lt;!-- 处理并在必要时部署软件包到集成测试可以运行的环境 --\u0026gt; \u0026lt;phase\u0026gt;integration-test\u0026lt;/phase\u0026gt; \u0026lt;!-- 执行集成测试后执行所需的操作。 例如,清理环境 --\u0026gt; \u0026lt;phase\u0026gt;post-integration-test\u0026lt;/phase\u0026gt; \u0026lt;!-- 运行任何检查以验证打的包是否有效并符合质量标准。 --\u0026gt; \u0026lt;phase\u0026gt;verify\u0026lt;/phase\u0026gt; \u0026lt;!-- 将包安装到本地仓库中,可以作为本地其他项目的依赖 --\u0026gt; \u0026lt;phase\u0026gt;install\u0026lt;/phase\u0026gt; \u0026lt;!-- 将最终的项目包复制到远程仓库中与其他开发者和项目共享 --\u0026gt; \u0026lt;phase\u0026gt;deploy\u0026lt;/phase\u0026gt; \u0026lt;/phases\u0026gt; 根据前面提到的阶段间依赖关系理论,当我们执行 mvn test命令的时候,会执行从 validate 到 test 的所有阶段,这也就解释了为什么执行测试的时候,项目的代码能够自动编译。\nclean 生命周期 # clean 生命周期的目的是清理项目,共包含 3 个阶段:\npre-clean clean post-clean \u0026lt;phases\u0026gt; \u0026lt;!-- 执行一些需要在clean之前完成的工作 --\u0026gt; \u0026lt;phase\u0026gt;pre-clean\u0026lt;/phase\u0026gt; \u0026lt;!-- 移除所有上一次构建生成的文件 --\u0026gt; \u0026lt;phase\u0026gt;clean\u0026lt;/phase\u0026gt; \u0026lt;!-- 执行一些需要在clean之后立刻完成的工作 --\u0026gt; \u0026lt;phase\u0026gt;post-clean\u0026lt;/phase\u0026gt; \u0026lt;/phases\u0026gt; \u0026lt;default-phases\u0026gt; \u0026lt;clean\u0026gt; org.apache.maven.plugins:maven-clean-plugin:2.5:clean \u0026lt;/clean\u0026gt; \u0026lt;/default-phases\u0026gt; 根据前面提到的阶段间依赖关系理论,当我们执行 mvn clean 的时候,会执行 clean 生命周期中的 pre-clean 和 clean 阶段。\nsite 生命周期 # site 生命周期的目的是建立和发布项目站点,共包含 4 个阶段:\npre-site site post-site site-deploy \u0026lt;phases\u0026gt; \u0026lt;!-- 执行一些需要在生成站点文档之前完成的工作 --\u0026gt; \u0026lt;phase\u0026gt;pre-site\u0026lt;/phase\u0026gt; \u0026lt;!-- 生成项目的站点文档作 --\u0026gt; \u0026lt;phase\u0026gt;site\u0026lt;/phase\u0026gt; \u0026lt;!-- 执行一些需要在生成站点文档之后完成的工作,并且为部署做准备 --\u0026gt; \u0026lt;phase\u0026gt;post-site\u0026lt;/phase\u0026gt; \u0026lt;!-- 将生成的站点文档部署到特定的服务器上 --\u0026gt; \u0026lt;phase\u0026gt;site-deploy\u0026lt;/phase\u0026gt; \u0026lt;/phases\u0026gt; \u0026lt;default-phases\u0026gt; \u0026lt;site\u0026gt; org.apache.maven.plugins:maven-site-plugin:3.3:site \u0026lt;/site\u0026gt; \u0026lt;site-deploy\u0026gt; org.apache.maven.plugins:maven-site-plugin:3.3:deploy \u0026lt;/site-deploy\u0026gt; \u0026lt;/default-phases\u0026gt; Maven 能够基于 pom.xml 所包含的信息,自动生成一个友好的站点,方便团队交流和发布项目信息。\nMaven 插件 # Maven 本质上是一个插件执行框架,所有的执行过程,都是由一个一个插件独立完成的。像咱们日常使用到的 install、clean、deploy 等命令,其实底层都是一个一个的 Maven 插件。关于 Maven 的核心插件可以参考官方的这篇文档: https://maven.apache.org/plugins/index.html 。\n本地默认插件路径: ${user.home}/.m2/repository/org/apache/maven/plugins\n除了 Maven 自带的插件之外,还有一些三方提供的插件比如单测覆盖率插件 jacoco-maven-plugin、帮助开发检测代码中不合规范的地方的插件 maven-checkstyle-plugin、分析代码质量的 sonar-maven-plugin。并且,我们还可以自定义插件来满足自己的需求。\njacoco-maven-plugin 使用示例:\n\u0026lt;build\u0026gt; \u0026lt;plugins\u0026gt; \u0026lt;plugin\u0026gt; \u0026lt;groupId\u0026gt;org.jacoco\u0026lt;/groupId\u0026gt; \u0026lt;artifactId\u0026gt;jacoco-maven-plugin\u0026lt;/artifactId\u0026gt; \u0026lt;version\u0026gt;0.8.8\u0026lt;/version\u0026gt; \u0026lt;executions\u0026gt; \u0026lt;execution\u0026gt; \u0026lt;goals\u0026gt; \u0026lt;goal\u0026gt;prepare-agent\u0026lt;/goal\u0026gt; \u0026lt;/goals\u0026gt; \u0026lt;/execution\u0026gt; \u0026lt;execution\u0026gt; \u0026lt;id\u0026gt;generate-code-coverage-report\u0026lt;/id\u0026gt; \u0026lt;phase\u0026gt;test\u0026lt;/phase\u0026gt; \u0026lt;goals\u0026gt; \u0026lt;goal\u0026gt;report\u0026lt;/goal\u0026gt; \u0026lt;/goals\u0026gt; \u0026lt;/execution\u0026gt; \u0026lt;/executions\u0026gt; \u0026lt;/plugin\u0026gt; \u0026lt;/plugins\u0026gt; \u0026lt;/build\u0026gt; 你可以将 Maven 插件理解为一组任务的集合,用户可以通过命令行直接运行指定插件的任务,也可以将插件任务挂载到构建生命周期,随着生命周期运行。\nMaven 插件被分为下面两种类型:\nBuild plugins:在构建时执行。 Reporting plugins:在网站生成过程中执行。 Maven 多模块管理 # 多模块管理简单地来说就是将一个项目分为多个模块,每个模块只负责单一的功能实现。直观的表现就是一个 Maven 项目中不止有一个 pom.xml 文件,会在不同的目录中有多个 pom.xml 文件,进而实现多模块管理。\n多模块管理除了可以更加便于项目开发和管理,还有如下好处:\n降低代码之间的耦合性(从类级别的耦合提升到 jar 包级别的耦合); 减少重复,提升复用性; 每个模块都可以是自解释的(通过模块名或者模块文档); 模块还规范了代码边界的划分,开发者很容易通过模块确定自己所负责的内容。 多模块管理下,会有一个父模块,其他的都是子模块。父模块通常只有一个 pom.xml,没有其他内容。父模块的 pom.xml 一般只定义了各个依赖的版本号、包含哪些子模块以及插件有哪些。不过,要注意的是,如果依赖只在某个子项目中使用,则可以在子项目的 pom.xml 中直接引入,防止父 pom 的过于臃肿。\n如下图所示,Dubbo 项目就被分成了多个子模块比如 dubbo-common(公共逻辑模块)、dubbo-remoting(远程通讯模块)、dubbo-rpc(远程调用模块)。\n文章推荐 # 安全同学讲 Maven 间接依赖场景的仲裁机制 - 阿里开发者 - 2022 高效使用 Java 构建工具| Maven 篇 - 阿里开发者 - 2022 安全同学讲 Maven 重打包的故事 - 阿里开发者 - 2022 参考 # 《Maven 实战》 Introduction to Repositories - Maven 官方文档: https://maven.apache.org/guides/introduction/introduction-to-repositories.html Introduction to the Build Lifecycle - Maven 官方文档: https://maven.apache.org/guides/introduction/introduction-to-the-lifecycle.html#Lifecycle_Reference Maven 依赖范围: http://www.mvnbook.com/maven-dependency.html 解决 maven 依赖冲突,这篇就够了!: https://www.cnblogs.com/qdhxhz/p/16363532.html Multi-Module Project with Maven: https://www.baeldung.com/maven-multi-module "},{"id":534,"href":"/zh/docs/technology/Interview/tools/maven/maven-best-practices/","title":"Maven最佳实践","section":"Maven","content":" 本文由 JavaGuide 翻译并完善,原文地址: https://medium.com/@AlexanderObregon/maven-best-practices-tips-and-tricks-for-java-developers-438eca03f72b 。\nMaven 是一种广泛使用的 Java 项目构建自动化工具。它简化了构建过程并帮助管理依赖关系,使开发人员的工作更轻松。Maven 详细介绍可以参考我写的这篇 Maven 核心概念总结 。\n这篇文章不会涉及到 Maven 概念的介绍,主要讨论一些最佳实践、建议和技巧,以优化我们在项目中对 Maven 的使用并改善我们的开发体验。\nMaven 标准目录结构 # Maven 遵循标准目录结构来保持项目之间的一致性。遵循这种结构可以让其他开发人员更轻松地理解我们的项目。\nMaven 项目的标准目录结构如下:\nsrc/ main/ java/ resources/ test/ java/ resources/ pom.xml src/main/java:源代码目录 src/main/resources:资源文件目录 src/test/java:测试代码目录 src/test/resources:测试资源文件目录 这只是一个最简单的 Maven 项目目录示例。实际项目中,我们还会根据项目规范去做进一步的细分。\n指定 Maven 编译器插件 # 默认情况下,Maven 使用 Java5 编译我们的项目。要使用不同的 JDK 版本,请在 pom.xml 文件中配置 Maven 编译器插件。\n例如,如果你想要使用 Java8 来编译你的项目,你可以在\u0026lt;build\u0026gt;标签下添加以下的代码片段:\n\u0026lt;build\u0026gt; \u0026lt;plugins\u0026gt; \u0026lt;plugin\u0026gt; \u0026lt;groupId\u0026gt;org.apache.maven.plugins\u0026lt;/groupId\u0026gt; \u0026lt;artifactId\u0026gt;maven-compiler-plugin\u0026lt;/artifactId\u0026gt; \u0026lt;version\u0026gt;3.8.1\u0026lt;/version\u0026gt; \u0026lt;configuration\u0026gt; \u0026lt;source\u0026gt;1.8\u0026lt;/source\u0026gt; \u0026lt;target\u0026gt;1.8\u0026lt;/target\u0026gt; \u0026lt;/configuration\u0026gt; \u0026lt;/plugin\u0026gt; \u0026lt;/plugins\u0026gt; \u0026lt;/build\u0026gt; 这样,Maven 就会使用 Java8 的编译器来编译你的项目。如果你想要使用其他版本的 JDK,你只需要修改\u0026lt;source\u0026gt;和\u0026lt;target\u0026gt;标签的值即可。例如,如果你想要使用 Java11,你可以将它们的值改为 11。\n有效管理依赖关系 # Maven 的依赖管理系统是其最强大的功能之一。在顶层 pom 文件中,通过标签 dependencyManagement 定义公共的依赖关系,这有助于避免冲突并确保所有模块使用相同版本的依赖项。\n例如,假设我们有一个父模块和两个子模块 A 和 B,我们想要在所有模块中使用 JUnit 5.7.2 作为测试框架。我们可以在父模块的pom.xml文件中使用\u0026lt;dependencyManagement\u0026gt;标签来定义 JUnit 的版本:\n\u0026lt;dependencyManagement\u0026gt; \u0026lt;dependencies\u0026gt; \u0026lt;dependency\u0026gt; \u0026lt;groupId\u0026gt;org.junit.jupiter\u0026lt;/groupId\u0026gt; \u0026lt;artifactId\u0026gt;junit-jupiter\u0026lt;/artifactId\u0026gt; \u0026lt;version\u0026gt;5.7.2\u0026lt;/version\u0026gt; \u0026lt;scope\u0026gt;test\u0026lt;/scope\u0026gt; \u0026lt;/dependency\u0026gt; \u0026lt;/dependencies\u0026gt; \u0026lt;/dependencyManagement\u0026gt; 在子模块 A 和 B 的 pom.xml 文件中,我们只需要引用 JUnit 的 groupId 和 artifactId 即可:\n\u0026lt;dependencies\u0026gt; \u0026lt;dependency\u0026gt; \u0026lt;groupId\u0026gt;org.junit.jupiter\u0026lt;/groupId\u0026gt; \u0026lt;artifactId\u0026gt;junit-jupiter\u0026lt;/artifactId\u0026gt; \u0026lt;/dependency\u0026gt; \u0026lt;/dependencies\u0026gt; 针对不同环境使用配置文件 # Maven 配置文件允许我们配置不同环境的构建设置,例如开发、测试和生产。在 pom.xml 文件中定义配置文件并使用命令行参数激活它们:\n\u0026lt;profiles\u0026gt; \u0026lt;profile\u0026gt; \u0026lt;id\u0026gt;development\u0026lt;/id\u0026gt; \u0026lt;activation\u0026gt; \u0026lt;activeByDefault\u0026gt;true\u0026lt;/activeByDefault\u0026gt; \u0026lt;/activation\u0026gt; \u0026lt;properties\u0026gt; \u0026lt;environment\u0026gt;dev\u0026lt;/environment\u0026gt; \u0026lt;/properties\u0026gt; \u0026lt;/profile\u0026gt; \u0026lt;profile\u0026gt; \u0026lt;id\u0026gt;production\u0026lt;/id\u0026gt; \u0026lt;properties\u0026gt; \u0026lt;environment\u0026gt;prod\u0026lt;/environment\u0026gt; \u0026lt;/properties\u0026gt; \u0026lt;/profile\u0026gt; \u0026lt;/profiles\u0026gt; 使用命令行激活配置文件:\nmvn clean install -P production 保持 pom.xml 干净且井然有序 # 组织良好的 pom.xml 文件更易于维护和理解。以下是维护干净的 pom.xml 的一些技巧:\n将相似的依赖项和插件组合在一起。 使用注释来描述特定依赖项或插件的用途。 将插件和依赖项的版本号保留在 \u0026lt;properties\u0026gt; 标签内以便于管理。 \u0026lt;properties\u0026gt; \u0026lt;junit.version\u0026gt;5.7.0\u0026lt;/junit.version\u0026gt; \u0026lt;mockito.version\u0026gt;3.9.0\u0026lt;/mockito.version\u0026gt; \u0026lt;/properties\u0026gt; 使用 Maven Wrapper # Maven Wrapper 是一个用于管理和使用 Maven 的工具,它允许在没有预先安装 Maven 的情况下运行和构建 Maven 项目。\nMaven 官方文档是这样介绍 Maven Wrapper 的:\nThe Maven Wrapper is an easy way to ensure a user of your Maven build has everything necessary to run your Maven build.\nMaven Wrapper 是一种简单的方法,可以确保 Maven 构建的用户拥有运行 Maven 构建所需的一切。\nMaven Wrapper 可以确保构建过程使用正确的 Maven 版本,非常方便。要使用 Maven Wrapper,请在项目目录中运行以下命令:\nmvn wrapper:wrapper 此命令会在我们的项目中生成 Maven Wrapper 文件。现在我们可以使用 ./mvnw (或 Windows 上的 ./mvnw.cmd)而不是 mvn 来执行 Maven 命令。\n通过持续集成实现构建自动化 # 将 Maven 项目与持续集成 (CI) 系统(例如 Jenkins 或 GitHub Actions)集成,可确保自动构建、测试和部署我们的代码。CI 有助于及早发现问题并在整个团队中提供一致的构建流程。以下是 Maven 项目的简单 GitHub Actions 工作流程示例:\nname: Java CI with Maven on: [push] jobs: build: runs-on: ubuntu-latest steps: - name: Checkout code uses: actions/checkout@v2 - name: Set up JDK 11 uses: actions/setup-java@v2 with: java-version: \u0026#39;11\u0026#39; distribution: \u0026#39;adopt\u0026#39; - name: Build with Maven run: ./mvnw clean install 利用 Maven 插件获得附加功能 # 有许多 Maven 插件可用于扩展 Maven 的功能。一些流行的插件包括(前三个是 Maven 自带的插件,后三个是第三方提供的插件):\nmaven-surefire-plugin:配置并执行单元测试。 maven-failsafe-plugin:配置并执行集成测试。 maven-javadoc-plugin:生成 Javadoc 格式的项目文档。 maven-checkstyle-plugin:强制执行编码标准和最佳实践。 jacoco-maven-plugin: 单测覆盖率。 sonar-maven-plugin:分析代码质量。 …… jacoco-maven-plugin 使用示例:\n\u0026lt;build\u0026gt; \u0026lt;plugins\u0026gt; \u0026lt;plugin\u0026gt; \u0026lt;groupId\u0026gt;org.jacoco\u0026lt;/groupId\u0026gt; \u0026lt;artifactId\u0026gt;jacoco-maven-plugin\u0026lt;/artifactId\u0026gt; \u0026lt;version\u0026gt;0.8.8\u0026lt;/version\u0026gt; \u0026lt;executions\u0026gt; \u0026lt;execution\u0026gt; \u0026lt;goals\u0026gt; \u0026lt;goal\u0026gt;prepare-agent\u0026lt;/goal\u0026gt; \u0026lt;/goals\u0026gt; \u0026lt;/execution\u0026gt; \u0026lt;execution\u0026gt; \u0026lt;id\u0026gt;generate-code-coverage-report\u0026lt;/id\u0026gt; \u0026lt;phase\u0026gt;test\u0026lt;/phase\u0026gt; \u0026lt;goals\u0026gt; \u0026lt;goal\u0026gt;report\u0026lt;/goal\u0026gt; \u0026lt;/goals\u0026gt; \u0026lt;/execution\u0026gt; \u0026lt;/executions\u0026gt; \u0026lt;/plugin\u0026gt; \u0026lt;/plugins\u0026gt; \u0026lt;/build\u0026gt; 如果这些已有的插件无法满足我们的需求,我们还可以自定义插件。\n探索可用的插件并在 pom.xml 文件中配置它们以增强我们的开发过程。\n总结 # Maven 是一个强大的工具,可以简化 Java 项目的构建过程和依赖关系管理。通过遵循这些最佳实践和技巧,我们可以优化 Maven 的使用并改善我们的 Java 开发体验。请记住使用标准目录结构,有效管理依赖关系,利用不同环境的配置文件,并将项目与持续集成系统集成,以确保构建一致。\n"},{"id":535,"href":"/zh/docs/technology/Interview/database/mongodb/mongodb-questions-01/","title":"MongoDB常见面试题总结(上)","section":"Mongodb","content":" 少部分内容参考了 MongoDB 官方文档的描述,在此说明一下。\nMongoDB 基础 # MongoDB 是什么? # MongoDB 是一个基于 分布式文件存储 的开源 NoSQL 数据库系统,由 C++ 编写的。MongoDB 提供了 面向文档 的存储方式,操作起来比较简单和容易,支持“无模式”的数据建模,可以存储比较复杂的数据类型,是一款非常流行的 文档类型数据库 。\n在高负载的情况下,MongoDB 天然支持水平扩展和高可用,可以很方便地添加更多的节点/实例,以保证服务性能和可用性。在许多场景下,MongoDB 可以用于代替传统的关系型数据库或键/值存储方式,皆在为 Web 应用提供可扩展的高可用高性能数据存储解决方案。\nMongoDB 的存储结构是什么? # MongoDB 的存储结构区别于传统的关系型数据库,主要由如下三个单元组成:\n文档(Document):MongoDB 中最基本的单元,由 BSON 键值对(key-value)组成,类似于关系型数据库中的行(Row)。 集合(Collection):一个集合可以包含多个文档,类似于关系型数据库中的表(Table)。 数据库(Database):一个数据库中可以包含多个集合,可以在 MongoDB 中创建多个数据库,类似于关系型数据库中的数据库(Database)。 也就是说,MongoDB 将数据记录存储为文档 (更具体来说是 BSON 文档),这些文档在集合中聚集在一起,数据库中存储一个或多个文档集合。\nSQL 与 MongoDB 常见术语对比:\nSQL MongoDB 表(Table) 集合(Collection) 行(Row) 文档(Document) 列(Col) 字段(Field) 主键(Primary Key) 对象 ID(Objectid) 索引(Index) 索引(Index) 嵌套表(Embedded Table) 嵌入式文档(Embedded Document) 数组(Array) 数组(Array) 文档 # MongoDB 中的记录就是一个 BSON 文档,它是由键值对组成的数据结构,类似于 JSON 对象,是 MongoDB 中的基本数据单元。字段的值可能包括其他文档、数组和文档数组。\n文档的键是字符串。除了少数例外情况,键可以使用任意 UTF-8 字符。\n键不能含有 \\0(空字符)。这个字符用来表示键的结尾。 . 和 $ 有特别的意义,只有在特定环境下才能使用。 以下划线_开头的键是保留的(不是严格要求的)。 BSON [bee·sahn] 是 Binary JSON的简称,是 JSON 文档的二进制表示,支持将文档和数组嵌入到其他文档和数组中,还包含允许表示不属于 JSON 规范的数据类型的扩展。有关 BSON 规范的内容,可以参考 bsonspec.org,另见 BSON 类型。\n根据维基百科对 BJSON 的介绍,BJSON 的遍历速度优于 JSON,这也是 MongoDB 选择 BSON 的主要原因,但 BJSON 需要更多的存储空间。\n与 JSON 相比,BSON 着眼于提高存储和扫描效率。BSON 文档中的大型元素以长度字段为前缀以便于扫描。在某些情况下,由于长度前缀和显式数组索引的存在,BSON 使用的空间会多于 JSON。\n集合 # MongoDB 集合存在于数据库中,没有固定的结构,也就是 无模式 的,这意味着可以往集合插入不同格式和类型的数据。不过,通常情况下,插入集合中的数据都会有一定的关联性。\n集合不需要事先创建,当第一个文档插入或者第一个索引创建时,如果该集合不存在,则会创建一个新的集合。\n集合名可以是满足下列条件的任意 UTF-8 字符串:\n集合名不能是空字符串\u0026quot;\u0026quot;。 集合名不能含有 \\0 (空字符),这个字符表示集合名的结尾。 集合名不能以\u0026quot;system.\u0026ldquo;开头,这是为系统集合保留的前缀。例如 system.users 这个集合保存着数据库的用户信息,system.namespaces 集合保存着所有数据库集合的信息。 集合名必须以下划线或者字母符号开始,并且不能包含 $。 数据库 # 数据库用于存储所有集合,而集合又用于存储所有文档。一个 MongoDB 中可以创建多个数据库,每一个数据库都有自己的集合和权限。\nMongoDB 预留了几个特殊的数据库。\nadmin : admin 数据库主要是保存 root 用户和角色。例如,system.users 表存储用户,system.roles 表存储角色。一般不建议用户直接操作这个数据库。将一个用户添加到这个数据库,且使它拥有 admin 库上的名为 dbAdminAnyDatabase 的角色权限,这个用户自动继承所有数据库的权限。一些特定的服务器端命令也只能从这个数据库运行,比如关闭服务器。 local : local 数据库是不会被复制到其他分片的,因此可以用来存储本地单台服务器的任意 collection。一般不建议用户直接使用 local 库存储任何数据,也不建议进行 CRUD 操作,因为数据无法被正常备份与恢复。 config : 当 MongoDB 使用分片设置时,config 数据库可用来保存分片的相关信息。 test : 默认创建的测试库,连接 mongod 服务时,如果不指定连接的具体数据库,默认就会连接到 test 数据库。 数据库名可以是满足以下条件的任意 UTF-8 字符串:\n不能是空字符串\u0026quot;\u0026quot;。 不得含有' '(空格)、.、$、/、\\和 \\0 (空字符)。 应全部小写。 最多 64 字节。 数据库名最终会变成文件系统里的文件,这也就是有如此多限制的原因。\nMongoDB 有什么特点? # 数据记录被存储为文档:MongoDB 中的记录就是一个 BSON 文档,它是由键值对组成的数据结构,类似于 JSON 对象,是 MongoDB 中的基本数据单元。 模式自由:集合的概念类似 MySQL 里的表,但它不需要定义任何模式,能够用更少的数据对象表现复杂的领域模型对象。 支持多种查询方式:MongoDB 查询 API 支持读写操作 (CRUD)以及数据聚合、文本搜索和地理空间查询。 支持 ACID 事务:NoSQL 数据库通常不支持事务,为了可扩展和高性能进行了权衡。不过,也有例外,MongoDB 就支持事务。与关系型数据库一样,MongoDB 事务同样具有 ACID 特性。MongoDB 单文档原生支持原子性,也具备事务的特性。MongoDB 4.0 加入了对多文档事务的支持,但只支持复制集部署模式下的事务,也就是说事务的作用域限制为一个副本集内。MongoDB 4.2 引入了分布式事务,增加了对分片集群上多文档事务的支持,并合并了对副本集上多文档事务的现有支持。 高效的二进制存储:存储在集合中的文档,是以键值对的形式存在的。键用于唯一标识一个文档,一般是 ObjectId 类型,值是以 BSON 形式存在的。BSON = Binary JSON, 是在 JSON 基础上加了一些类型及元数据描述的格式。 自带数据压缩功能:存储同样的数据所需的资源更少。 支持 mapreduce:通过分治的方式完成复杂的聚合任务。不过,从 MongoDB 5.0 开始,map-reduce 已经不被官方推荐使用了,替代方案是 聚合管道。聚合管道提供比 map-reduce 更好的性能和可用性。 支持多种类型的索引:MongoDB 支持多种类型的索引,包括单字段索引、复合索引、多键索引、哈希索引、文本索引、 地理位置索引等,每种类型的索引有不同的使用场合。 支持 failover:提供自动故障恢复的功能,主节点发生故障时,自动从从节点中选举出一个新的主节点,确保集群的正常使用,这对于客户端来说是无感知的。 支持分片集群:MongoDB 支持集群自动切分数据,让集群存储更多的数据,具备更强的性能。在数据插入和更新时,能够自动路由和存储。 支持存储大文件:MongoDB 的单文档存储空间要求不超过 16MB。对于超过 16MB 的大文件,MongoDB 提供了 GridFS 来进行存储,通过 GridFS,可以将大型数据进行分块处理,然后将这些切分后的小文档保存在数据库中。 MongoDB 适合什么应用场景? # MongoDB 的优势在于其数据模型和存储引擎的灵活性、架构的可扩展性以及对强大的索引支持。\n选用 MongoDB 应该充分考虑 MongoDB 的优势,结合实际项目的需求来决定:\n随着项目的发展,使用类 JSON 格式(BSON)保存数据是否满足项目需求?MongoDB 中的记录就是一个 BSON 文档,它是由键值对组成的数据结构,类似于 JSON 对象,是 MongoDB 中的基本数据单元。 是否需要大数据量的存储?是否需要快速水平扩展?MongoDB 支持分片集群,可以很方便地添加更多的节点(实例),让集群存储更多的数据,具备更强的性能。 是否需要更多类型索引来满足更多应用场景?MongoDB 支持多种类型的索引,包括单字段索引、复合索引、多键索引、哈希索引、文本索引、 地理位置索引等,每种类型的索引有不同的使用场合。 …… MongoDB 存储引擎 # MongoDB 支持哪些存储引擎? # 存储引擎(Storage Engine)是数据库的核心组件,负责管理数据在内存和磁盘中的存储方式。\n与 MySQL 一样,MongoDB 采用的也是 插件式的存储引擎架构 ,支持不同类型的存储引擎,不同的存储引擎解决不同场景的问题。在创建数据库或集合时,可以指定存储引擎。\n插件式的存储引擎架构可以实现 Server 层和存储引擎层的解耦,可以支持多种存储引擎,如 MySQL 既可以支持 B-Tree 结构的 InnoDB 存储引擎,还可以支持 LSM 结构的 RocksDB 存储引擎。\n在存储引擎刚出来的时候,默认是使用 MMAPV1 存储引擎,MongoDB4.x 版本不再支持 MMAPv1 存储引擎。\n现在主要有下面这两种存储引擎:\nWiredTiger 存储引擎:自 MongoDB 3.2 以后,默认的存储引擎为 WiredTiger 存储引擎 。非常适合大多数工作负载,建议用于新部署。WiredTiger 提供文档级并发模型、检查点和数据压缩(后文会介绍到)等功能。 In-Memory 存储引擎: In-Memory 存储引擎在 MongoDB Enterprise 中可用。它不是将文档存储在磁盘上,而是将它们保留在内存中以获得更可预测的数据延迟。 此外,MongoDB 3.0 提供了 可插拔的存储引擎 API ,允许第三方为 MongoDB 开发存储引擎,这点和 MySQL 也比较类似。\nWiredTiger 基于 LSM Tree 还是 B+ Tree? # 目前绝大部分流行的数据库存储引擎都是基于 B/B+ Tree 或者 LSM(Log Structured Merge) Tree 来实现的。对于 NoSQL 数据库来说,绝大部分(比如 HBase、Cassandra、RocksDB)都是基于 LSM 树,MongoDB 不太一样。\n上面也说了,自 MongoDB 3.2 以后,默认的存储引擎为 WiredTiger 存储引擎。在 WiredTiger 引擎官网上,我们发现 WiredTiger 使用的是 B+ 树作为其存储结构:\nWiredTiger maintains a table\u0026#39;s data in memory using a data structure called a B-Tree ( B+ Tree to be specific), referring to the nodes of a B-Tree as pages. Internal pages carry only keys. The leaf pages store both keys and values. 此外,WiredTiger 还支持 LSM(Log Structured Merge) 树作为存储结构,MongoDB 在使用 WiredTiger 作为存储引擎时,默认使用的是 B+ 树。\n如果想要了解 MongoDB 使用 B+ 树的原因,可以看看这篇文章: 【驳斥八股文系列】别瞎分析了,MongoDB 使用的是 B+ 树,不是你们以为的 B 树。\n使用 B+ 树时,WiredTiger 以 page 为基本单位往磁盘读写数据。B+ 树的每个节点为一个 page,共有三种类型的 page:\nroot page(根节点):B+ 树的根节点。 internal page(内部节点):不实际存储数据的中间索引节点。 leaf page(叶子节点):真正存储数据的叶子节点,包含一个页头(page header)、块头(block header)和真正的数据(key/value),其中页头定义了页的类型、页中实际载荷数据的大小、页中记录条数等信息;块头定义了此页的 checksum、块在磁盘上的寻址位置等信息。 其整体结构如下图所示:\n如果想要深入研究学习 WiredTiger 存储引擎,推荐阅读 MongoDB 中文社区的 WiredTiger 存储引擎系列。\nMongoDB 聚合 # MongoDB 聚合有什么用? # 实际项目中,我们经常需要将多个文档甚至是多个集合汇总到一起计算分析(比如求和、取最大值)并返回计算后的结果,这个过程被称为 聚合操作 。\n根据官方文档介绍,我们可以使用聚合操作来:\n将来自多个文档的值组合在一起。 对集合中的数据进行的一系列运算。 分析数据随时间的变化。 MongoDB 提供了哪几种执行聚合的方法? # MongoDB 提供了两种执行聚合的方法:\n聚合管道(Aggregation Pipeline):执行聚合操作的首选方法。 单一目的聚合方法(Single purpose aggregation methods):也就是单一作用的聚合函数比如 count()、distinct()、estimatedDocumentCount()。 绝大部分文章中还提到了 map-reduce 这种聚合方法。不过,从 MongoDB 5.0 开始,map-reduce 已经不被官方推荐使用了,替代方案是 聚合管道。聚合管道提供比 map-reduce 更好的性能和可用性。\nMongoDB 聚合管道由多个阶段组成,每个阶段在文档通过管道时转换文档。每个阶段接收前一个阶段的输出,进一步处理数据,并将其作为输入数据发送到下一个阶段。\n每个管道的工作流程是:\n接受一系列原始数据文档 对这些文档进行一系列运算 结果文档输出给下一个阶段 常用阶段操作符:\n操作符 简述 $match 匹配操作符,用于对文档集合进行筛选 $project 投射操作符,用于重构每一个文档的字段,可以提取字段,重命名字段,甚至可以对原有字段进行操作后新增字段 $sort 排序操作符,用于根据一个或多个字段对文档进行排序 $limit 限制操作符,用于限制返回文档的数量 $skip 跳过操作符,用于跳过指定数量的文档 $count 统计操作符,用于统计文档的数量 $group 分组操作符,用于对文档集合进行分组 $unwind 拆分操作符,用于将数组中的每一个值拆分为单独的文档 $lookup 连接操作符,用于连接同一个数据库中另一个集合,并获取指定的文档,类似于 populate 更多操作符介绍详见官方文档: https://docs.mongodb.com/manual/reference/operator/aggregation/\n阶段操作符用于 db.collection.aggregate 方法里面,数组参数中的第一层。\ndb.collection.aggregate( [ { 阶段操作符:表述 }, { 阶段操作符:表述 }, ... ] ) 下面是 MongoDB 官方文档中的一个例子:\ndb.orders.aggregate([ # 第一阶段:$match阶段按status字段过滤文档,并将status等于\u0026#34;A\u0026#34;的文档传递到下一阶段。 { $match: { status: \u0026#34;A\u0026#34; } }, # 第二阶段:$group阶段按cust_id字段将文档分组,以计算每个cust_id唯一值的金额总和。 { $group: { _id: \u0026#34;$cust_id\u0026#34;, total: { $sum: \u0026#34;$amount\u0026#34; } } } ]) MongoDB 事务 # MongoDB 事务想要搞懂原理还是比较花费时间的,我自己也没有搞太明白。因此,我这里只是简单介绍一下 MongoDB 事务,想要了解原理的小伙伴,可以自行搜索查阅相关资料。\n这里推荐几篇文章,供大家参考:\n技术干货| MongoDB 事务原理 MongoDB 一致性模型设计与实现 MongoDB 官方文档对事务的介绍 我们在介绍 NoSQL 数据的时候也说过,NoSQL 数据库通常不支持事务,为了可扩展和高性能进行了权衡。不过,也有例外,MongoDB 就支持事务。\n与关系型数据库一样,MongoDB 事务同样具有 ACID 特性:\n原子性(Atomicity):事务是最小的执行单位,不允许分割。事务的原子性确保动作要么全部完成,要么完全不起作用; 一致性(Consistency):执行事务前后,数据保持一致,例如转账业务中,无论事务是否成功,转账者和收款人的总额应该是不变的; 隔离性(Isolation):并发访问数据库时,一个用户的事务不被其他事务所干扰,各并发事务之间数据库是独立的。WiredTiger 存储引擎支持读未提交( read-uncommitted )、读已提交( read-committed )和快照( snapshot )隔离,MongoDB 启动时默认选快照隔离。在不同隔离级别下,一个事务的生命周期内,可能出现脏读、不可重复读、幻读等现象。 持久性(Durability):一个事务被提交之后。它对数据库中数据的改变是持久的,即使数据库发生故障也不应该对其有任何影响。 关于事务的详细介绍这篇文章就不多说了,感兴趣的可以看看我写的 MySQL 常见面试题总结这篇文章,里面有详细介绍到。\nMongoDB 单文档原生支持原子性,也具备事务的特性。当谈论 MongoDB 事务的时候,通常指的是 多文档 。MongoDB 4.0 加入了对多文档 ACID 事务的支持,但只支持复制集部署模式下的 ACID 事务,也就是说事务的作用域限制为一个副本集内。MongoDB 4.2 引入了 分布式事务 ,增加了对分片集群上多文档事务的支持,并合并了对副本集上多文档事务的现有支持。\n根据官方文档介绍:\n从 MongoDB 4.2 开始,分布式事务和多文档事务在 MongoDB 中是一个意思。分布式事务是指分片集群和副本集上的多文档事务。从 MongoDB 4.2 开始,多文档事务(无论是在分片集群还是副本集上)也称为分布式事务。\n在大多数情况下,多文档事务比单文档写入会产生更大的性能成本。对于大部分场景来说, 非规范化数据模型(嵌入式文档和数组) 依然是最佳选择。也就是说,适当地对数据进行建模可以最大限度地减少对多文档事务的需求。\n注意:\n从 MongoDB 4.2 开始,多文档事务支持副本集和分片集群,其中:主节点使用 WiredTiger 存储引擎,同时从节点使用 WiredTiger 存储引擎或 In-Memory 存储引擎。在 MongoDB 4.0 中,只有使用 WiredTiger 存储引擎的副本集支持事务。 在 MongoDB 4.2 及更早版本中,你无法在事务中创建集合。从 MongoDB 4.4 开始,您可以在事务中创建集合和索引。有关详细信息,请参阅 在事务中创建集合和索引。 MongoDB 数据压缩 # 借助 WiredTiger 存储引擎( MongoDB 3.2 后的默认存储引擎),MongoDB 支持对所有集合和索引进行压缩。压缩以额外的 CPU 为代价最大限度地减少存储使用。\n默认情况下,WiredTiger 使用 Snappy 压缩算法(谷歌开源,旨在实现非常高的速度和合理的压缩,压缩比 3 ~ 5 倍)对所有集合使用块压缩,对所有索引使用前缀压缩。\n除了 Snappy 之外,对于集合还有下面这些压缩算法:\nzlib:高度压缩算法,压缩比 5 ~ 7 倍 Zstandard(简称 zstd):Facebook 开源的一种快速无损压缩算法,针对 zlib 级别的实时压缩场景和更好的压缩比,提供更高的压缩率和更低的 CPU 使用率,MongoDB 4.2 开始可用。 WiredTiger 日志也会被压缩,默认使用的也是 Snappy 压缩算法。如果日志记录小于或等于 128 字节,WiredTiger 不会压缩该记录。\nAmazon Document 与 MongoDB 的差异 # Amazon DocumentDB(与 MongoDB 兼容) 是一种快速、可靠、完全托管的数据库服务。Amazon DocumentDB 可在云中轻松设置、操作和扩展与 MongoDB 兼容的数据库。\n$vectorSearch 运算符 # Amazon DocumentDB 不支持$vectorSearch作为独立运营商。相反,我们在$search运营商vectorSearch内部支持。有关更多信息,请参阅 向量搜索 Amazon DocumentDB。\nOpCountersCommand # Amazon DocumentDB 的OpCountersCommand行为偏离于 MongoDB 的opcounters.command 如下:\nMongoDB 的opcounters.command 计入除插入、更新和删除之外的所有命令,而 Amazon DocumentDB 的 OpCountersCommand 也排除 find 命令。 Amazon DocumentDB 将内部命令(例如getCloudWatchMetricsV2)对 OpCountersCommand 计入。 管理数据库和集合 # Amazon DocumentDB 不支持管理或本地数据库,MongoDB system.* 或 startup_log 集合也不支持。\ncursormaxTimeMS # 在 Amazon DocumentDB 中,cursor.maxTimeMS 重置每个请求的计数器。getMore因此,如果指定了 3000MS maxTimeMS,则该查询耗时 2800MS,而每个后续getMore请求耗时 300MS,则游标不会超时。游标仅在单个操作(无论是查询还是单个getMore请求)耗时超过指定值时才将超时maxTimeMS。此外,检查游标执行时间的扫描器以五 (5) 分钟间隔尺寸运行。\nexplain() # Amazon DocumentDB 在利用分布式、容错、自修复的存储系统的专用数据库引擎上模拟 MongoDB 4.0 API。因此,查询计划和explain() 的输出在 Amazon DocumentDB 和 MongoDB 之间可能有所不同。希望控制其查询计划的客户可以使用 $hint 运算符强制选择首选索引。\n字段名称限制 # Amazon DocumentDB 不支持点“。” 例如,文档字段名称中 db.foo.insert({‘x.1’:1})。\nAmazon DocumentDB 也不支持字段名称中的 $ 前缀。\n例如,在 Amazon DocumentDB 或 MongoDB 中尝试以下命令:\nrs0:PRIMARY\u0026lt; db.foo.insert({\u0026#34;a\u0026#34;:{\u0026#34;$a\u0026#34;:1}}) MongoDB 将返回以下内容:\nWriteResult({ \u0026#34;nInserted\u0026#34; : 1 }) Amazon DocumentDB 将返回一个错误:\nWriteResult({ \u0026#34;nInserted\u0026#34; : 0, \u0026#34;writeError\u0026#34; : { \u0026#34;code\u0026#34; : 2, \u0026#34;errmsg\u0026#34; : \u0026#34;Document can\u0026#39;t have $ prefix field names: $a\u0026#34; } }) 参考 # MongoDB 官方文档(主要参考资料,以官方文档为准): https://www.mongodb.com/docs/manual/ 《MongoDB 权威指南》 技术干货| MongoDB 事务原理 - MongoDB 中文社区: https://mongoing.com/archives/82187 Transactions - MongoDB 官方文档: https://www.mongodb.com/docs/manual/core/transactions/ WiredTiger Storage Engine - MongoDB 官方文档: https://www.mongodb.com/docs/manual/core/wiredtiger/ WiredTiger 存储引擎之一:基础数据结构分析: https://mongoing.com/topic/archives-35143 "},{"id":536,"href":"/zh/docs/technology/Interview/database/mongodb/mongodb-questions-02/","title":"MongoDB常见面试题总结(下)","section":"Mongodb","content":" MongoDB 索引 # MongoDB 索引有什么用? # 和关系型数据库类似,MongoDB 中也有索引。索引的目的主要是用来提高查询效率,如果没有索引的话,MongoDB 必须执行 集合扫描 ,即扫描集合中的每个文档,以选择与查询语句匹配的文档。如果查询存在合适的索引,MongoDB 可以使用该索引来限制它必须检查的文档数量。并且,MongoDB 可以使用索引中的排序返回排序后的结果。\n虽然索引可以显著缩短查询时间,但是使用索引、维护索引是有代价的。在执行写入操作时,除了要更新文档之外,还必须更新索引,这必然会影响写入的性能。因此,当有大量写操作而读操作少时,或者不考虑读操作的性能时,都不推荐建立索引。\nMongoDB 支持哪些类型的索引? # MongoDB 支持多种类型的索引,包括单字段索引、复合索引、多键索引、哈希索引、文本索引、 地理位置索引等,每种类型的索引有不同的使用场合。\n单字段索引: 建立在单个字段上的索引,索引创建的排序顺序无所谓,MongoDB 可以头/尾开始遍历。 复合索引: 建立在多个字段上的索引,也可以称之为组合索引、联合索引。 多键索引:MongoDB 的一个字段可能是数组,在对这种字段创建索引时,就是多键索引。MongoDB 会为数组的每个值创建索引。就是说你可以按照数组里面的值做条件来查询,这个时候依然会走索引。 哈希索引:按数据的哈希值索引,用在哈希分片集群上。 文本索引: 支持对字符串内容的文本搜索查询。文本索引可以包含任何值为字符串或字符串元素数组的字段。一个集合只能有一个文本搜索索引,但该索引可以覆盖多个字段。MongoDB 虽然支持全文索引,但是性能低下,暂时不建议使用。 地理位置索引: 基于经纬度的索引,适合 2D 和 3D 的位置查询。 唯一索引:确保索引字段不会存储重复值。如果集合已经存在了违反索引的唯一约束的文档,则后台创建唯一索引会失败。 TTL 索引:TTL 索引提供了一个过期机制,允许为每一个文档设置一个过期时间,当一个文档达到预设的过期时间之后就会被删除。 …… 复合索引中字段的顺序有影响吗? # 复合索引中字段的顺序非常重要,例如下图中的复合索引由{userid:1, score:-1}组成,则该复合索引首先按照userid升序排序;然后再每个userid的值内,再按照score降序排序。\n在复合索引中,按照何种方式排序,决定了该索引在查询中是否能被应用到。\n走复合索引的排序:\ndb.s2.find().sort({\u0026#34;userid\u0026#34;: 1, \u0026#34;score\u0026#34;: -1}) db.s2.find().sort({\u0026#34;userid\u0026#34;: -1, \u0026#34;score\u0026#34;: 1}) 不走复合索引的排序:\ndb.s2.find().sort({\u0026#34;userid\u0026#34;: 1, \u0026#34;score\u0026#34;: 1}) db.s2.find().sort({\u0026#34;userid\u0026#34;: -1, \u0026#34;score\u0026#34;: -1}) db.s2.find().sort({\u0026#34;score\u0026#34;: 1, \u0026#34;userid\u0026#34;: -1}) db.s2.find().sort({\u0026#34;score\u0026#34;: 1, \u0026#34;userid\u0026#34;: 1}) db.s2.find().sort({\u0026#34;score\u0026#34;: -1, \u0026#34;userid\u0026#34;: -1}) db.s2.find().sort({\u0026#34;score\u0026#34;: -1, \u0026#34;userid\u0026#34;: 1}) 我们可以通过 explain 进行分析:\ndb.s2.find().sort({\u0026#34;score\u0026#34;: -1, \u0026#34;userid\u0026#34;: 1}).explain() 复合索引遵循左前缀原则吗? # MongoDB 的复合索引遵循左前缀原则:拥有多个键的索引,可以同时得到所有这些键的前缀组成的索引,但不包括除左前缀之外的其他子集。比如说,有一个类似 {a: 1, b: 1, c: 1, ..., z: 1} 这样的索引,那么实际上也等于有了 {a: 1}、{a: 1, b: 1}、{a: 1, b: 1, c: 1} 等一系列索引,但是不会有 {b: 1} 这样的非左前缀的索引。\n什么是 TTL 索引? # TTL 索引提供了一个过期机制,允许为每一个文档设置一个过期时间 expireAfterSeconds ,当一个文档达到预设的过期时间之后就会被删除。TTL 索引除了有 expireAfterSeconds 属性外,和普通索引一样。\n数据过期对于某些类型的信息很有用,比如机器生成的事件数据、日志和会话信息,这些信息只需要在数据库中保存有限的时间。\nTTL 索引运行原理:\nMongoDB 会开启一个后台线程读取该 TTL 索引的值来判断文档是否过期,但不会保证已过期的数据会立马被删除,因后台线程每 60 秒触发一次删除任务,且如果删除的数据量较大,会存在上一次的删除未完成,而下一次的任务已经开启的情况,导致过期的数据也会出现超过了数据保留时间 60 秒以上的现象。 对于副本集而言,TTL 索引的后台进程只会在 Primary 节点开启,在从节点会始终处于空闲状态,从节点的数据删除是由主库删除后产生的 oplog 来做同步。 TTL 索引限制:\nTTL 索引是单字段索引。复合索引不支持 TTL _id字段不支持 TTL 索引。 无法在上限集合(Capped Collection)上创建 TTL 索引,因为 MongoDB 无法从上限集合中删除文档。 如果某个字段已经存在非 TTL 索引,那么在该字段上无法再创建 TTL 索引。 什么是覆盖索引查询? # 根据官方文档介绍,覆盖查询是以下的查询:\n所有的查询字段是索引的一部分。 结果中返回的所有字段都在同一索引中。 查询中没有字段等于null。 由于所有出现在查询中的字段是索引的一部分, MongoDB 无需在整个数据文档中检索匹配查询条件和返回使用相同索引的查询结果。因为索引存在于内存中,从索引中获取数据比通过扫描文档读取数据要快得多。\n举个例子:我们有如下 users 集合:\n{ \u0026#34;_id\u0026#34;: ObjectId(\u0026#34;53402597d852426020000002\u0026#34;), \u0026#34;contact\u0026#34;: \u0026#34;987654321\u0026#34;, \u0026#34;dob\u0026#34;: \u0026#34;01-01-1991\u0026#34;, \u0026#34;gender\u0026#34;: \u0026#34;M\u0026#34;, \u0026#34;name\u0026#34;: \u0026#34;Tom Benzamin\u0026#34;, \u0026#34;user_name\u0026#34;: \u0026#34;tombenzamin\u0026#34; } 我们在 users 集合中创建联合索引,字段为 gender 和 user_name :\ndb.users.ensureIndex({gender:1,user_name:1}) 现在,该索引会覆盖以下查询:\ndb.users.find({gender:\u0026#34;M\u0026#34;},{user_name:1,_id:0}) 为了让指定的索引覆盖查询,必须显式地指定 _id: 0 来从结果中排除 _id 字段,因为索引不包括 _id 字段。\nMongoDB 高可用 # 复制集群 # 什么是复制集群? # MongoDB 的复制集群又称为副本集群,是一组维护相同数据集合的 mongod 进程。\n客户端连接到整个 Mongodb 复制集群,主节点机负责整个复制集群的写,从节点可以进行读操作,但默认还是主节点负责整个复制集群的读。主节点发生故障时,自动从从节点中选举出一个新的主节点,确保集群的正常使用,这对于客户端来说是无感知的。\n通常来说,一个复制集群包含 1 个主节点(Primary),多个从节点(Secondary)以及零个或 1 个仲裁节点(Arbiter)。\n主节点:整个集群的写操作入口,接收所有的写操作,并将集合所有的变化记录到操作日志中,即 oplog。主节点挂掉之后会自动选出新的主节点。 从节点:从主节点同步数据,在主节点挂掉之后选举新节点。不过,从节点可以配置成 0 优先级,阻止它在选举中成为主节点。 仲裁节点:这个是为了节约资源或者多机房容灾用,只负责主节点选举时投票不存数据,保证能有节点获得多数赞成票。 下图是一个典型的三成员副本集群:\n主节点与备节点之间是通过 oplog(操作日志) 来同步数据的。oplog 是 local 库下的一个特殊的 上限集合(Capped Collection) ,用来保存写操作所产生的增量日志,类似于 MySQL 中 的 Binlog。\n上限集合类似于定长的循环队列,数据顺序追加到集合的尾部,当集合空间达到上限时,它会覆盖集合中最旧的文档。上限集合的数据将会被顺序写入到磁盘的固定空间内,所以,I/O 速度非常快,如果不建立索引,性能更好。\n当主节点上的一个写操作完成后,会向 oplog 集合写入一条对应的日志,而从节点则通过这个 oplog 不断拉取到新的日志,在本地进行回放以达到数据同步的目的。\n副本集最多有一个主节点。 如果当前主节点不可用,一个选举会抉择出新的主节点。MongoDB 的节点选举规则能够保证在 Primary 挂掉之后选取的新节点一定是集群中数据最全的一个。\n为什么要用复制集群? # 实现 failover:提供自动故障恢复的功能,主节点发生故障时,自动从从节点中选举出一个新的主节点,确保集群的正常使用,这对于客户端来说是无感知的。 实现读写分离:我们可以设置从节点上可以读取数据,主节点负责写入数据,这样的话就实现了读写分离,减轻了主节点读写压力过大的问题。MongoDB 4.0 之前版本如果主库压力不大,不建议读写分离,因为写会阻塞读,除非业务对响应时间不是非常关注以及读取历史数据接受一定时间延迟。 分片集群 # 什么是分片集群? # 分片集群是 MongoDB 的分布式版本,相较副本集,分片集群数据被均衡的分布在不同分片中, 不仅大幅提升了整个集群的数据容量上限,也将读写的压力分散到不同分片,以解决副本集性能瓶颈的难题。\nMongoDB 的分片集群由如下三个部分组成(下图来源于 官方文档对分片集群的介绍):\nConfig Servers:配置服务器,本质上是一个 MongoDB 的副本集,负责存储集群的各种元数据和配置,如分片地址、Chunks 等 Mongos:路由服务,不存具体数据,从 Config 获取集群配置讲请求转发到特定的分片,并且整合分片结果返回给客户端。 Shard:每个分片是整体数据的一部分子集,从 MongoDB3.6 版本开始,每个 Shard 必须部署为副本集(replica set)架构 为什么要用分片集群? # 随着系统数据量以及吞吐量的增长,常见的解决办法有两种:垂直扩展和水平扩展。\n垂直扩展通过增加单个服务器的能力来实现,比如磁盘空间、内存容量、CPU 数量等;水平扩展则通过将数据存储到多个服务器上来实现,根据需要添加额外的服务器以增加容量。\n类似于 Redis Cluster,MongoDB 也可以通过分片实现 水平扩展 。水平扩展这种方式更灵活,可以满足更大数据量的存储需求,支持更高吞吐量。并且,水平扩展所需的整体成本更低,仅仅需要相对较低配置的单机服务器即可,代价是增加了部署的基础设施和维护的复杂性。\n也就是说当你遇到如下问题时,可以使用分片集群解决:\n存储容量受单机限制,即磁盘资源遭遇瓶颈。 读写能力受单机限制,可能是 CPU、内存或者网卡等资源遭遇瓶颈,导致读写能力无法扩展。 什么是分片键? # 分片键(Shard Key) 是数据分区的前提, 从而实现数据分发到不同服务器上,减轻服务器的负担。也就是说,分片键决定了集合内的文档如何在集群的多个分片间的分布状况。\n分片键就是文档里面的一个字段,但是这个字段不是普通的字段,有一定的要求:\n它必须在所有文档中都出现。 它必须是集合的一个索引,可以是单索引或复合索引的前缀索引,不能是多索引、文本索引或地理空间位置索引。 MongoDB 4.2 之前的版本,文档的分片键字段值不可变。MongoDB 4.2 版本开始,除非分片键字段是不可变的 _id 字段,否则您可以更新文档的分片键值。MongoDB 5.0 版本开始,实现了实时重新分片(live resharding),可以实现分片键的完全重新选择。 它的大小不能超过 512 字节。 如何选择分片键? # 选择合适的片键对 sharding 效率影响很大,主要基于如下四个因素(摘自 分片集群使用注意事项 - - 腾讯云文档):\n取值基数 取值基数建议尽可能大,如果用小基数的片键,因为备选值有限,那么块的总数量就有限,随着数据增多,块的大小会越来越大,导致水平扩展时移动块会非常困难。 例如:选择年龄做一个基数,范围最多只有 100 个,随着数据量增多,同一个值分布过多时,导致 chunck 的增长超出 chuncksize 的范围,引起 jumbo chunk,从而无法迁移,导致数据分布不均匀,性能瓶颈。 取值分布 取值分布建议尽量均匀,分布不均匀的片键会造成某些块的数据量非常大,同样有上面数据分布不均匀,性能瓶颈的问题。 查询带分片 查询时建议带上分片,使用分片键进行条件查询时,mongos 可以直接定位到具体分片,否则 mongos 需要将查询分发到所有分片,再等待响应返回。 避免单调递增或递减 单调递增的 sharding key,数据文件挪动小,但写入会集中,导致最后一篇的数据量持续增大,不断发生迁移,递减同理。 综上,在选择片键时要考虑以上 4 个条件,尽可能满足更多的条件,才能降低 MoveChunks 对性能的影响,从而获得最优的性能体验。\n分片策略有哪些? # MongoDB 支持两种分片算法来满足不同的查询需求(摘自 MongoDB 分片集群介绍 - 阿里云文档):\n1、基于范围的分片:\nMongoDB 按照分片键(Shard Key)的值的范围将数据拆分为不同的块(Chunk),每个块包含了一段范围内的数据。当分片键的基数大、频率低且值非单调变更时,范围分片更高效。\n优点:Mongos 可以快速定位请求需要的数据,并将请求转发到相应的 Shard 节点中。 缺点:可能导致数据在 Shard 节点上分布不均衡,容易造成读写热点,且不具备写分散性。 适用场景:分片键的值不是单调递增或单调递减、分片键的值基数大且重复的频率低、需要范围查询等业务场景。 2、基于 Hash 值的分片\nMongoDB 计算单个字段的哈希值作为索引值,并以哈希值的范围将数据拆分为不同的块(Chunk)。\n优点:可以将数据更加均衡地分布在各 Shard 节点中,具备写分散性。 缺点:不适合进行范围查询,进行范围查询时,需要将读请求分发到所有的 Shard 节点。 适用场景:分片键的值存在单调递增或递减、片键的值基数大且重复的频率低、需要写入的数据随机分发、数据读取随机性较大等业务场景。 除了上述两种分片策略,您还可以配置 复合片键 ,例如由一个低基数的键和一个单调递增的键组成。\n分片数据如何存储? # Chunk(块) 是 MongoDB 分片集群的一个核心概念,其本质上就是由一组 Document 组成的逻辑数据单元。每个 Chunk 包含一定范围片键的数据,互不相交且并集为全部数据,即离散数学中划分的概念。\n分片集群不会记录每条数据在哪个分片上,而是记录 Chunk 在哪个分片上一级这个 Chunk 包含哪些数据。\n默认情况下,一个 Chunk 的最大值默认为 64MB(可调整,取值范围为 1~1024 MB。如无特殊需求,建议保持默认值),进行数据插入、更新、删除时,如果此时 Mongos 感知到了目标 Chunk 的大小或者其中的数据量超过上限,则会触发 Chunk 分裂。\n数据的增长会让 Chunk 分裂得越来越多。这个时候,各个分片上的 Chunk 数量可能会不平衡。Mongos 中的 均衡器(Balancer) 组件就会执行自动平衡,尝试使各个 Shard 上 Chunk 的数量保持均衡,这个过程就是 再平衡(Rebalance)。默认情况下,数据库和集合的 Rebalance 是开启的。\n如下图所示,随着数据插入,导致 Chunk 分裂,让 AB 两个分片有 3 个 Chunk,C 分片只有一个,这个时候就会把 B 分配的迁移一个到 C 分片实现集群数据均衡。\nBalancer 是 MongoDB 的一个运行在 Config Server 的 Primary 节点上(自 MongoDB 3.4 版本起)的后台进程,它监控每个分片上 Chunk 数量,并在某个分片上 Chunk 数量达到阈值进行迁移。\nChunk 只会分裂,不会合并,即使 chunkSize 的值变大。\nRebalance 操作是比较耗费系统资源的,我们可以通过在业务低峰期执行、预分片或者设置 Rebalance 时间窗等方式来减少其对 MongoDB 正常使用所带来的影响。\nChunk 迁移原理是什么? # 关于 Chunk 迁移原理的详细介绍,推荐阅读 MongoDB 中文社区的 一文读懂 MongoDB chunk 迁移这篇文章。\n学习资料推荐 # MongoDB 中文手册|官方文档中文版(推荐):基于 4.2 版本,不断与官方最新版保持同步。 MongoDB 初学者教程——7 天学习 MongoDB:快速入门。 SpringBoot 整合 MongoDB 实战 - 2022:很不错的一篇 MongoDB 入门文章,主要围绕 MongoDB 的 Java 客户端使用进行基本的增删改查操作介绍。 参考 # MongoDB 官方文档(主要参考资料,以官方文档为准): https://www.mongodb.com/docs/manual/ 《MongoDB 权威指南》 Indexes - MongoDB 官方文档: https://www.mongodb.com/docs/manual/indexes/ MongoDB - 索引知识 - 程序员翔仔 - 2022: https://fatedeity.cn/posts/database/mongodb-index-knowledge.html MongoDB - 索引: https://www.cnblogs.com/Neeo/articles/14325130.html Sharding - MongoDB 官方文档: https://www.mongodb.com/docs/manual/sharding/ MongoDB 分片集群介绍 - 阿里云文档: https://help.aliyun.com/document_detail/64561.html 分片集群使用注意事项 - - 腾讯云文档: https://cloud.tencent.com/document/product/240/44611 "},{"id":537,"href":"/zh/docs/technology/Interview/system-design/framework/mybatis/mybatis-interview/","title":"MyBatis常见面试题总结","section":"Framework","content":" 本篇文章由 JavaGuide 收集自网络,原出处不明。\n比起这些枯燥的面试题,我更建议你看看文末推荐的 MyBatis 优质好文。\n#{} 和 ${} 的区别是什么? # 注:这道题是面试官面试我同事的。\n答:\n${}是 Properties 文件中的变量占位符,它可以用于标签属性值和 sql 内部,属于原样文本替换,可以替换任意内容,比如${driver}会被原样替换为com.mysql.jdbc. Driver。 一个示例:根据参数按任意字段排序:\nselect * from users order by ${orderCols} orderCols可以是 name、name desc、name,sex asc等,实现灵活的排序。\n#{}是 sql 的参数占位符,MyBatis 会将 sql 中的#{}替换为? 号,在 sql 执行前会使用 PreparedStatement 的参数设置方法,按序给 sql 的? 号占位符设置参数值,比如 ps.setInt(0, parameterValue),#{item.name} 的取值方式为使用反射从参数对象中获取 item 对象的 name 属性值,相当于 param.getItem().getName()。 xml 映射文件中,除了常见的 select、insert、update、delete 标签之外,还有哪些标签? # 注:这道题是京东面试官面试我时问的。\n答:还有很多其他的标签, \u0026lt;resultMap\u0026gt;、 \u0026lt;parameterMap\u0026gt;、 \u0026lt;sql\u0026gt;、 \u0026lt;include\u0026gt;、 \u0026lt;selectKey\u0026gt; ,加上动态 sql 的 9 个标签, trim|where|set|foreach|if|choose|when|otherwise|bind 等,其中 \u0026lt;sql\u0026gt; 为 sql 片段标签,通过 \u0026lt;include\u0026gt; 标签引入 sql 片段, \u0026lt;selectKey\u0026gt; 为不支持自增的主键生成策略标签。\nDao 接口的工作原理是什么?Dao 接口里的方法,参数不同时,方法能重载吗? # 注:这道题也是京东面试官面试我被问的。\n答:最佳实践中,通常一个 xml 映射文件,都会写一个 Dao 接口与之对应。Dao 接口就是人们常说的 Mapper 接口,接口的全限名,就是映射文件中的 namespace 的值,接口的方法名,就是映射文件中 MappedStatement 的 id 值,接口方法内的参数,就是传递给 sql 的参数。 Mapper 接口是没有实现类的,当调用接口方法时,接口全限名+方法名拼接字符串作为 key 值,可唯一定位一个 MappedStatement ,举例:com.mybatis3.mappers. StudentDao.findStudentById ,可以唯一找到 namespace 为 com.mybatis3.mappers. StudentDao 下面 id = findStudentById 的 MappedStatement 。在 MyBatis 中,每一个 \u0026lt;select\u0026gt;、 \u0026lt;insert\u0026gt;、 \u0026lt;update\u0026gt;、 \u0026lt;delete\u0026gt; 标签,都会被解析为一个 MappedStatement 对象。\nDao 接口里的方法,是不能重载的,因为是全限名+方法名的保存和寻找策略。\nDao 接口里的方法可以重载,但是 Mybatis 的 xml 里面的 ID 不允许重复。\nMybatis 版本 3.3.0,亲测如下:\n/** * Mapper接口里面方法重载 */ public interface StuMapper { List\u0026lt;Student\u0026gt; getAllStu(); List\u0026lt;Student\u0026gt; getAllStu(@Param(\u0026#34;id\u0026#34;) Integer id); } 然后在 StuMapper.xml 中利用 Mybatis 的动态 sql 就可以实现。\n\u0026lt;select id=\u0026#34;getAllStu\u0026#34; resultType=\u0026#34;com.pojo.Student\u0026#34;\u0026gt; select * from student \u0026lt;where\u0026gt; \u0026lt;if test=\u0026#34;id != null\u0026#34;\u0026gt; id = #{id} \u0026lt;/if\u0026gt; \u0026lt;/where\u0026gt; \u0026lt;/select\u0026gt; 能正常运行,并能得到相应的结果,这样就实现了在 Dao 接口中写重载方法。\nMybatis 的 Dao 接口可以有多个重载方法,但是多个接口对应的映射必须只有一个,否则启动会报错。\n相关 issue: 更正:Dao 接口里的方法可以重载,但是 Mybatis 的 xml 里面的 ID 不允许重复!。\nDao 接口的工作原理是 JDK 动态代理,MyBatis 运行时会使用 JDK 动态代理为 Dao 接口生成代理 proxy 对象,代理对象 proxy 会拦截接口方法,转而执行 MappedStatement 所代表的 sql,然后将 sql 执行结果返回。\n补充:\nDao 接口方法可以重载,但是需要满足以下条件:\n仅有一个无参方法和一个有参方法 多个有参方法时,参数数量必须一致。且使用相同的 @Param ,或者使用 param1 这种 测试如下:\nPersonDao.java\nPerson queryById(); Person queryById(@Param(\u0026#34;id\u0026#34;) Long id); Person queryById(@Param(\u0026#34;id\u0026#34;) Long id, @Param(\u0026#34;name\u0026#34;) String name); PersonMapper.xml\n\u0026lt;select id=\u0026#34;queryById\u0026#34; resultMap=\u0026#34;PersonMap\u0026#34;\u0026gt; select id, name, age, address from person \u0026lt;where\u0026gt; \u0026lt;if test=\u0026#34;id != null\u0026#34;\u0026gt; id = #{id} \u0026lt;/if\u0026gt; \u0026lt;if test=\u0026#34;name != null and name != \u0026#39;\u0026#39;\u0026#34;\u0026gt; name = #{name} \u0026lt;/if\u0026gt; \u0026lt;/where\u0026gt; limit 1 \u0026lt;/select\u0026gt; org.apache.ibatis.scripting.xmltags. DynamicContext. ContextAccessor#getProperty 方法用于获取 \u0026lt;if\u0026gt; 标签中的条件值\npublic Object getProperty(Map context, Object target, Object name) { Map map = (Map) target; Object result = map.get(name); if (map.containsKey(name) || result != null) { return result; } Object parameterObject = map.get(PARAMETER_OBJECT_KEY); if (parameterObject instanceof Map) { return ((Map)parameterObject).get(name); } return null; } parameterObject 为 map,存放的是 Dao 接口中参数相关信息。\n((Map)parameterObject).get(name) 方法如下\npublic V get(Object key) { if (!super.containsKey(key)) { throw new BindingException(\u0026#34;Parameter \u0026#39;\u0026#34; + key + \u0026#34;\u0026#39; not found. Available parameters are \u0026#34; + keySet()); } return super.get(key); } queryById()方法执行时,parameterObject为 null,getProperty方法返回 null 值,\u0026lt;if\u0026gt;标签获取的所有条件值都为 null,所有条件不成立,动态 sql 可以正常执行。 queryById(1L)方法执行时,parameterObject为 map,包含了id和param1两个 key 值。当获取\u0026lt;if\u0026gt;标签中name的属性值时,进入((Map)parameterObject).get(name)方法中,map 中 key 不包含name,所以抛出异常。 queryById(1L,\u0026quot;1\u0026quot;)方法执行时,parameterObject中包含id,param1,name,param2四个 key 值,id和name属性都可以获取到,动态 sql 正常执行。 MyBatis 是如何进行分页的?分页插件的原理是什么? # 注:我出的。\n答:(1) MyBatis 使用 RowBounds 对象进行分页,它是针对 ResultSet 结果集执行的内存分页,而非物理分页;(2) 可以在 sql 内直接书写带有物理分页的参数来完成物理分页功能,(3) 也可以使用分页插件来完成物理分页。\n分页插件的基本原理是使用 MyBatis 提供的插件接口,实现自定义插件,在插件的拦截方法内拦截待执行的 sql,然后重写 sql,根据 dialect 方言,添加对应的物理分页语句和物理分页参数。\n举例:select _ from student ,拦截 sql 后重写为:select t._ from (select \\* from student)t limit 0,10\n简述 MyBatis 的插件运行原理,以及如何编写一个插件 # 注:我出的。\n答:MyBatis 仅可以编写针对 ParameterHandler、 ResultSetHandler、 StatementHandler、 Executor 这 4 种接口的插件,MyBatis 使用 JDK 的动态代理,为需要拦截的接口生成代理对象以实现接口方法拦截功能,每当执行这 4 种接口对象的方法时,就会进入拦截方法,具体就是 InvocationHandler 的 invoke() 方法,当然,只会拦截那些你指定需要拦截的方法。\n实现 MyBatis 的 Interceptor 接口并复写 intercept() 方法,然后在给插件编写注解,指定要拦截哪一个接口的哪些方法即可,记住,别忘了在配置文件中配置你编写的插件。\nMyBatis 执行批量插入,能返回数据库主键列表吗? # 注:我出的。\n答:能,JDBC 都能,MyBatis 当然也能。\nMyBatis 动态 sql 是做什么的?都有哪些动态 sql?能简述一下动态 sql 的执行原理不? # 注:我出的。\n答:MyBatis 动态 sql 可以让我们在 xml 映射文件内,以标签的形式编写动态 sql,完成逻辑判断和动态拼接 sql 的功能。其执行原理为,使用 OGNL 从 sql 参数对象中计算表达式的值,根据表达式的值动态拼接 sql,以此来完成动态 sql 的功能。\nMyBatis 提供了 9 种动态 sql 标签:\n\u0026lt;if\u0026gt;\u0026lt;/if\u0026gt; \u0026lt;where\u0026gt;\u0026lt;/where\u0026gt;(trim,set) \u0026lt;choose\u0026gt;\u0026lt;/choose\u0026gt;(when, otherwise) \u0026lt;foreach\u0026gt;\u0026lt;/foreach\u0026gt; \u0026lt;bind/\u0026gt; 关于 MyBatis 动态 SQL 的详细介绍,请看这篇文章: Mybatis 系列全解(八):Mybatis 的 9 大动态 SQL 标签你知道几个? 。\n关于这些动态 SQL 的具体使用方法,请看这篇文章: Mybatis【13】\u0026ndash; Mybatis 动态 sql 标签怎么使用?\nMyBatis 是如何将 sql 执行结果封装为目标对象并返回的?都有哪些映射形式? # 注:我出的。\n答:第一种是使用 \u0026lt;resultMap\u0026gt; 标签,逐一定义列名和对象属性名之间的映射关系。第二种是使用 sql 列的别名功能,将列别名书写为对象属性名,比如 T_NAME AS NAME,对象属性名一般是 name,小写,但是列名不区分大小写,MyBatis 会忽略列名大小写,智能找到与之对应对象属性名,你甚至可以写成 T_NAME AS NaMe,MyBatis 一样可以正常工作。\n有了列名与属性名的映射关系后,MyBatis 通过反射创建对象,同时使用反射给对象的属性逐一赋值并返回,那些找不到映射关系的属性,是无法完成赋值的。\nMyBatis 能执行一对一、一对多的关联查询吗?都有哪些实现方式,以及它们之间的区别 # 注:我出的。\n答:能,MyBatis 不仅可以执行一对一、一对多的关联查询,还可以执行多对一,多对多的关联查询,多对一查询,其实就是一对一查询,只需要把 selectOne() 修改为 selectList() 即可;多对多查询,其实就是一对多查询,只需要把 selectOne() 修改为 selectList() 即可。\n关联对象查询,有两种实现方式,一种是单独发送一个 sql 去查询关联对象,赋给主对象,然后返回主对象。另一种是使用嵌套查询,嵌套查询的含义为使用 join 查询,一部分列是 A 对象的属性值,另外一部分列是关联对象 B 的属性值,好处是只发一个 sql 查询,就可以把主对象和其关联对象查出来。\n那么问题来了,join 查询出来 100 条记录,如何确定主对象是 5 个,而不是 100 个?其去重复的原理是 \u0026lt;resultMap\u0026gt; 标签内的 \u0026lt;id\u0026gt; 子标签,指定了唯一确定一条记录的 id 列,MyBatis 根据 \u0026lt;id\u0026gt; 列值来完成 100 条记录的去重复功能, \u0026lt;id\u0026gt; 可以有多个,代表了联合主键的语意。\n同样主对象的关联对象,也是根据这个原理去重复的,尽管一般情况下,只有主对象会有重复记录,关联对象一般不会重复。\n举例:下面 join 查询出来 6 条记录,一、二列是 Teacher 对象列,第三列为 Student 对象列,MyBatis 去重复处理后,结果为 1 个老师 6 个学生,而不是 6 个老师 6 个学生。\nt_id t_name s_id 1 teacher 38 1 teacher 39 1 teacher 40 1 teacher 41 1 teacher 42 1 teacher 43 MyBatis 是否支持延迟加载?如果支持,它的实现原理是什么? # 注:我出的。\n答:MyBatis 仅支持 association 关联对象和 collection 关联集合对象的延迟加载,association 指的就是一对一,collection 指的就是一对多查询。在 MyBatis 配置文件中,可以配置是否启用延迟加载 lazyLoadingEnabled=true|false。\n它的原理是,使用 CGLIB 创建目标对象的代理对象,当调用目标方法时,进入拦截器方法,比如调用 a.getB().getName() ,拦截器 invoke() 方法发现 a.getB() 是 null 值,那么就会单独发送事先保存好的查询关联 B 对象的 sql,把 B 查询上来,然后调用 a.setB(b),于是 a 的对象 b 属性就有值了,接着完成 a.getB().getName() 方法的调用。这就是延迟加载的基本原理。\n当然了,不光是 MyBatis,几乎所有的包括 Hibernate,支持延迟加载的原理都是一样的。\nMyBatis 的 xml 映射文件中,不同的 xml 映射文件,id 是否可以重复? # 注:我出的。\n答:不同的 xml 映射文件,如果配置了 namespace,那么 id 可以重复;如果没有配置 namespace,那么 id 不能重复;毕竟 namespace 不是必须的,只是最佳实践而已。\n原因就是 namespace+id 是作为 Map\u0026lt;String, MappedStatement\u0026gt; 的 key 使用的,如果没有 namespace,就剩下 id,那么,id 重复会导致数据互相覆盖。有了 namespace,自然 id 就可以重复,namespace 不同,namespace+id 自然也就不同。\nMyBatis 中如何执行批处理? # 注:我出的。\n答:使用 BatchExecutor 完成批处理。\nMyBatis 都有哪些 Executor 执行器?它们之间的区别是什么? # 注:我出的\n答:MyBatis 有三种基本的 Executor 执行器:\nSimpleExecutor: 每执行一次 update 或 select,就开启一个 Statement 对象,用完立刻关闭 Statement 对象。 ReuseExecutor: 执行 update 或 select,以 sql 作为 key 查找 Statement 对象,存在就使用,不存在就创建,用完后,不关闭 Statement 对象,而是放置于 Map\u0026lt;String, Statement\u0026gt;内,供下一次使用。简言之,就是重复使用 Statement 对象。 BatchExecutor:执行 update(没有 select,JDBC 批处理不支持 select),将所有 sql 都添加到批处理中(addBatch()),等待统一执行(executeBatch()),它缓存了多个 Statement 对象,每个 Statement 对象都是 addBatch()完毕后,等待逐一执行 executeBatch()批处理。与 JDBC 批处理相同。 作用范围:Executor 的这些特点,都严格限制在 SqlSession 生命周期范围内。\nMyBatis 中如何指定使用哪一种 Executor 执行器? # 注:我出的\n答:在 MyBatis 配置文件中,可以指定默认的 ExecutorType 执行器类型,也可以手动给 DefaultSqlSessionFactory 的创建 SqlSession 的方法传递 ExecutorType 类型参数。\nMyBatis 是否可以映射 Enum 枚举类? # 注:我出的\n答:MyBatis 可以映射枚举类,不单可以映射枚举类,MyBatis 可以映射任何对象到表的一列上。映射方式为自定义一个 TypeHandler ,实现 TypeHandler 的 setParameter() 和 getResult() 接口方法。 TypeHandler 有两个作用:\n一是完成从 javaType 至 jdbcType 的转换; 二是完成 jdbcType 至 javaType 的转换,体现为 setParameter() 和 getResult() 两个方法,分别代表设置 sql 问号占位符参数和获取列查询结果。 MyBatis 映射文件中,如果 A 标签通过 include 引用了 B 标签的内容,请问,B 标签能否定义在 A 标签的后面,还是说必须定义在 A 标签的前面? # 注:我出的\n答:虽然 MyBatis 解析 xml 映射文件是按照顺序解析的,但是,被引用的 B 标签依然可以定义在任何地方,MyBatis 都可以正确识别。\n原理是,MyBatis 解析 A 标签,发现 A 标签引用了 B 标签,但是 B 标签尚未解析到,尚不存在,此时,MyBatis 会将 A 标签标记为未解析状态,然后继续解析余下的标签,包含 B 标签,待所有标签解析完毕,MyBatis 会重新解析那些被标记为未解析的标签,此时再解析 A 标签时,B 标签已经存在,A 标签也就可以正常解析完成了。\n简述 MyBatis 的 xml 映射文件和 MyBatis 内部数据结构之间的映射关系? # 注:我出的\n答:MyBatis 将所有 xml 配置信息都封装到 All-In-One 重量级对象 Configuration 内部。在 xml 映射文件中, \u0026lt;parameterMap\u0026gt; 标签会被解析为 ParameterMap 对象,其每个子元素会被解析为 ParameterMapping 对象。 \u0026lt;resultMap\u0026gt; 标签会被解析为 ResultMap 对象,其每个子元素会被解析为 ResultMapping 对象。每一个 \u0026lt;select\u0026gt;、\u0026lt;insert\u0026gt;、\u0026lt;update\u0026gt;、\u0026lt;delete\u0026gt; 标签均会被解析为 MappedStatement 对象,标签内的 sql 会被解析为 BoundSql 对象。\n为什么说 MyBatis 是半自动 ORM 映射工具?它与全自动的区别在哪里? # 注:我出的\n答:Hibernate 属于全自动 ORM 映射工具,使用 Hibernate 查询关联对象或者关联集合对象时,可以根据对象关系模型直接获取,所以它是全自动的。而 MyBatis 在查询关联对象或关联集合对象时,需要手动编写 sql 来完成,所以,称之为半自动 ORM 映射工具。\n面试题看似都很简单,但是想要能正确回答上来,必定是研究过源码且深入的人,而不是仅会使用的人或者用的很熟的人,以上所有面试题及其答案所涉及的内容,在我的 MyBatis 系列博客中都有详细讲解和原理分析。\n文章推荐 # 2W 字全面剖析 Mybatis 中的 9 种设计模式 从零开始实现一个 MyBatis 加解密插件 MyBatis 最全使用指南 脑洞打开!第一次看到这样使用 MyBatis 的,看得我一愣一愣的。 MyBatis 居然也有并发问题 "},{"id":538,"href":"/zh/docs/technology/Interview/database/mysql/mysql-query-cache/","title":"MySQL查询缓存详解","section":"Mysql","content":"缓存是一个有效且实用的系统性能优化的手段,不论是操作系统还是各种软件和网站或多或少都用到了缓存。\n然而,有经验的 DBA 都建议生产环境中把 MySQL 自带的 Query Cache(查询缓存)给关掉。而且,从 MySQL 5.7.20 开始,就已经默认弃用查询缓存了。在 MySQL 8.0 及之后,更是直接删除了查询缓存的功能。\n这又是为什么呢?查询缓存真就这么鸡肋么?\n带着如下几个问题,我们正式进入本文。\nMySQL 查询缓存是什么?适用范围? MySQL 缓存规则是什么? MySQL 缓存的优缺点是什么? MySQL 缓存对性能有什么影响? MySQL 查询缓存介绍 # MySQL 体系架构如下图所示:\n为了提高完全相同的查询语句的响应速度,MySQL Server 会对查询语句进行 Hash 计算得到一个 Hash 值。MySQL Server 不会对 SQL 做任何处理,SQL 必须完全一致 Hash 值才会一样。得到 Hash 值之后,通过该 Hash 值到查询缓存中匹配该查询的结果。\n如果匹配(命中),则将查询的结果集直接返回给客户端,不必再解析、执行查询。 如果没有匹配(未命中),则将 Hash 值和结果集保存在查询缓存中,以便以后使用。 也就是说,一个查询语句(select)到了 MySQL Server 之后,会先到查询缓存看看,如果曾经执行过的话,就直接返回结果集给客户端。\nMySQL 查询缓存管理和配置 # 通过 show variables like '%query_cache%'命令可以查看查询缓存相关的信息。\n8.0 版本之前的话,打印的信息可能是下面这样的:\nmysql\u0026gt; show variables like \u0026#39;%query_cache%\u0026#39;; +------------------------------+---------+ | Variable_name | Value | +------------------------------+---------+ | have_query_cache | YES | | query_cache_limit | 1048576 | | query_cache_min_res_unit | 4096 | | query_cache_size | 599040 | | query_cache_type | ON | | query_cache_wlock_invalidate | OFF | +------------------------------+---------+ 6 rows in set (0.02 sec) 8.0 以及之后版本之后,打印的信息是下面这样的:\nmysql\u0026gt; show variables like \u0026#39;%query_cache%\u0026#39;; +------------------+-------+ | Variable_name | Value | +------------------+-------+ | have_query_cache | NO | +------------------+-------+ 1 row in set (0.01 sec) 我们这里对 8.0 版本之前show variables like '%query_cache%';命令打印出来的信息进行解释。\nhave_query_cache: 该 MySQL Server 是否支持查询缓存,如果是 YES 表示支持,否则则是不支持。 query_cache_limit: MySQL 查询缓存的最大查询结果,查询结果大于该值时不会被缓存。 query_cache_min_res_unit: 查询缓存分配的最小块的大小(字节)。当查询进行的时候,MySQL 把查询结果保存在查询缓存中,但如果要保存的结果比较大,超过 query_cache_min_res_unit 的值 ,这时候 MySQL 将一边检索结果,一边进行保存结果,也就是说,有可能在一次查询中,MySQL 要进行多次内存分配的操作。适当的调节 query_cache_min_res_unit 可以优化内存。 query_cache_size: 为缓存查询结果分配的内存的数量,单位是字节,且数值必须是 1024 的整数倍。默认值是 0,即禁用查询缓存。 query_cache_type: 设置查询缓存类型,默认为 ON。设置 GLOBAL 值可以设置后面的所有客户端连接的类型。客户端可以设置 SESSION 值以影响他们自己对查询缓存的使用。 query_cache_wlock_invalidate:如果某个表被锁住,是否返回缓存中的数据,默认关闭,也是建议的。 query_cache_type 可能的值(修改 query_cache_type 需要重启 MySQL Server):\n0 或 OFF:关闭查询功能。 1 或 ON:开启查询缓存功能,但不缓存 Select SQL_NO_CACHE 开头的查询。 2 或 DEMAND:开启查询缓存功能,但仅缓存 Select SQL_CACHE 开头的查询。 建议:\nquery_cache_size不建议设置的过大。过大的空间不但挤占实例其他内存结构的空间,而且会增加在缓存中搜索的开销。建议根据实例规格,初始值设置为 10MB 到 100MB 之间的值,而后根据运行使用情况调整。\n建议通过调整 query_cache_size 的值来开启、关闭查询缓存,因为修改query_cache_type 参数需要重启 MySQL Server 生效。\n8.0 版本之前,my.cnf 加入以下配置,重启 MySQL 开启查询缓存\nquery_cache_type=1 query_cache_size=600000 或者,MySQL 执行以下命令也可以开启查询缓存\nset global query_cache_type=1; set global query_cache_size=600000; 手动清理缓存可以使用下面三个 SQL:\nflush query cache;:清理查询缓存内存碎片。 reset query cache;:从查询缓存中移除所有查询。 flush tables; 关闭所有打开的表,同时该操作会清空查询缓存中的内容。 MySQL 缓存机制 # 缓存规则 # 查询缓存会将查询语句和结果集保存到内存(一般是 key-value 的形式,key 是查询语句,value 是查询的结果集),下次再查直接从内存中取。 缓存的结果是通过 sessions 共享的,所以一个 client 查询的缓存结果,另一个 client 也可以使用。 SQL 必须完全一致才会导致查询缓存命中(大小写、空格、使用的数据库、协议版本、字符集等必须一致)。检查查询缓存时,MySQL Server 不会对 SQL 做任何处理,它精确的使用客户端传来的查询。 不缓存查询中的子查询结果集,仅缓存查询最终结果集。 不确定的函数将永远不会被缓存, 比如 now()、curdate()、last_insert_id()、rand() 等。 不缓存产生告警(Warnings)的查询。 太大的结果集不会被缓存 (\u0026lt; query_cache_limit)。 如果查询中包含任何用户自定义函数、存储函数、用户变量、临时表、MySQL 库中的系统表,其查询结果也不会被缓存。 缓存建立之后,MySQL 的查询缓存系统会跟踪查询中涉及的每张表,如果这些表(数据或结构)发生变化,那么和这张表相关的所有缓存数据都将失效。 MySQL 缓存在分库分表环境下是不起作用的。 不缓存使用 SQL_NO_CACHE 的查询。 …… 查询缓存 SELECT 选项示例:\nSELECT SQL_CACHE id, name FROM customer;# 会缓存 SELECT SQL_NO_CACHE id, name FROM customer;# 不会缓存 缓存机制中的内存管理 # 查询缓存是完全存储在内存中的,所以在配置和使用它之前,我们需要先了解它是如何使用内存的。\nMySQL 查询缓存使用内存池技术,自己管理内存释放和分配,而不是通过操作系统。内存池使用的基本单位是变长的 block, 用来存储类型、大小、数据等信息。一个结果集的缓存通过链表把这些 block 串起来。block 最短长度为 query_cache_min_res_unit。\n当服务器启动的时候,会初始化缓存需要的内存,是一个完整的空闲块。当查询结果需要缓存的时候,先从空闲块中申请一个数据块为参数 query_cache_min_res_unit 配置的空间,即使缓存数据很小,申请数据块也是这个,因为查询开始返回结果的时候就分配空间,此时无法预知结果多大。\n分配内存块需要先锁住空间块,所以操作很慢,MySQL 会尽量避免这个操作,选择尽可能小的内存块,如果不够,继续申请,如果存储完时有空余则释放多余的。\n但是如果并发的操作,余下的需要回收的空间很小,小于 query_cache_min_res_unit,不能再次被使用,就会产生碎片。\nMySQL 查询缓存的优缺点 # 优点:\n查询缓存的查询,发生在 MySQL 接收到客户端的查询请求、查询权限验证之后和查询 SQL 解析之前。也就是说,当 MySQL 接收到客户端的查询 SQL 之后,仅仅只需要对其进行相应的权限验证之后,就会通过查询缓存来查找结果,甚至都不需要经过 Optimizer 模块进行执行计划的分析优化,更不需要发生任何存储引擎的交互。 由于查询缓存是基于内存的,直接从内存中返回相应的查询结果,因此减少了大量的磁盘 I/O 和 CPU 计算,导致效率非常高。 缺点:\nMySQL 会对每条接收到的 SELECT 类型的查询进行 Hash 计算,然后查找这个查询的缓存结果是否存在。虽然 Hash 计算和查找的效率已经足够高了,一条查询语句所带来的开销可以忽略,但一旦涉及到高并发,有成千上万条查询语句时,hash 计算和查找所带来的开销就必须重视了。 查询缓存的失效问题。如果表的变更比较频繁,则会造成查询缓存的失效率非常高。表的变更不仅仅指表中的数据发生变化,还包括表结构或者索引的任何变化。 查询语句不同,但查询结果相同的查询都会被缓存,这样便会造成内存资源的过度消耗。查询语句的字符大小写、空格或者注释的不同,查询缓存都会认为是不同的查询(因为他们的 Hash 值会不同)。 相关系统变量设置不合理会造成大量的内存碎片,这样便会导致查询缓存频繁清理内存。 MySQL 查询缓存对性能的影响 # 在 MySQL Server 中打开查询缓存对数据库的读和写都会带来额外的消耗:\n读查询开始之前必须检查是否命中缓存。 如果读查询可以缓存,那么执行完查询操作后,会查询结果和查询语句写入缓存。 当向某个表写入数据的时候,必须将这个表所有的缓存设置为失效,如果缓存空间很大,则消耗也会很大,可能使系统僵死一段时间,因为这个操作是靠全局锁操作来保护的。 对 InnoDB 表,当修改一个表时,设置了缓存失效,但是多版本特性会暂时将这修改对其他事务屏蔽,在这个事务提交之前,所有查询都无法使用缓存,直到这个事务被提交,所以长时间的事务,会大大降低查询缓存的命中。 总结 # MySQL 中的查询缓存虽然能够提升数据库的查询性能,但是查询同时也带来了额外的开销,每次查询后都要做一次缓存操作,失效后还要销毁。\n查询缓存是一个适用较少情况的缓存机制。如果你的应用对数据库的更新很少,那么查询缓存将会作用显著。比较典型的如博客系统,一般博客更新相对较慢,数据表相对稳定不变,这时候查询缓存的作用会比较明显。\n简单总结一下查询缓存的适用场景:\n表数据修改不频繁、数据较静态。 查询(Select)重复度高。 查询结果集小于 1 MB。 对于一个更新频繁的系统来说,查询缓存缓存的作用是很微小的,在某些情况下开启查询缓存会带来性能的下降。\n简单总结一下查询缓存不适用的场景:\n表中的数据、表结构或者索引变动频繁 重复的查询很少 查询的结果集很大 《高性能 MySQL》这样写到:\n根据我们的经验,在高并发压力环境中查询缓存会导致系统性能的下降,甚至僵死。如果你一 定要使用查询缓存,那么不要设置太大内存,而且只有在明确收益的时候才使用(数据库内容修改次数较少)。\n确实是这样的!实际项目中,更建议使用本地缓存(比如 Caffeine)或者分布式缓存(比如 Redis) ,性能更好,更通用一些。\n参考 # 《高性能 MySQL》 MySQL 缓存机制: https://zhuanlan.zhihu.com/p/55947158 RDS MySQL 查询缓存(Query Cache)的设置和使用 - 阿里元云数据库 RDS 文档: https://help.aliyun.com/document_detail/41717.html 8.10.3 The MySQL Query Cache - MySQL 官方文档: https://dev.mysql.com/doc/refman/5.7/en/query-cache.html "},{"id":539,"href":"/zh/docs/technology/Interview/database/mysql/mysql-questions-01/","title":"MySQL常见面试题总结","section":"Mysql","content":" MySQL 基础 # 什么是关系型数据库? # 顾名思义,关系型数据库(RDB,Relational Database)就是一种建立在关系模型的基础上的数据库。关系模型表明了数据库中所存储的数据之间的联系(一对一、一对多、多对多)。\n关系型数据库中,我们的数据都被存放在了各种表中(比如用户表),表中的每一行就存放着一条数据(比如一个用户的信息)。\n大部分关系型数据库都使用 SQL 来操作数据库中的数据。并且,大部分关系型数据库都支持事务的四大特性(ACID)。\n有哪些常见的关系型数据库呢?\nMySQL、PostgreSQL、Oracle、SQL Server、SQLite(微信本地的聊天记录的存储就是用的 SQLite) ……。\n什么是 SQL? # SQL 是一种结构化查询语言(Structured Query Language),专门用来与数据库打交道,目的是提供一种从数据库中读写数据的简单有效的方法。\n几乎所有的主流关系数据库都支持 SQL ,适用性非常强。并且,一些非关系型数据库也兼容 SQL 或者使用的是类似于 SQL 的查询语言。\nSQL 可以帮助我们:\n新建数据库、数据表、字段; 在数据库中增加,删除,修改,查询数据; 新建视图、函数、存储过程; 对数据库中的数据进行简单的数据分析; 搭配 Hive,Spark SQL 做大数据; 搭配 SQLFlow 做机器学习; …… 什么是 MySQL? # MySQL 是一种关系型数据库,主要用于持久化存储我们的系统中的一些数据比如用户信息。\n由于 MySQL 是开源免费并且比较成熟的数据库,因此,MySQL 被大量使用在各种系统中。任何人都可以在 GPL(General Public License) 的许可下下载并根据个性化的需要对其进行修改。MySQL 的默认端口号是3306。\nMySQL 有什么优点? # 这个问题本质上是在问 MySQL 如此流行的原因。\nMySQL 主要具有下面这些优点:\n成熟稳定,功能完善。 开源免费。 文档丰富,既有详细的官方文档,又有非常多优质文章可供参考学习。 开箱即用,操作简单,维护成本低。 兼容性好,支持常见的操作系统,支持多种开发语言。 社区活跃,生态完善。 事务支持优秀, InnoDB 存储引擎默认使用 REPEATABLE-READ 并不会有任何性能损失,并且,InnoDB 实现的 REPEATABLE-READ 隔离级别其实是可以解决幻读问题发生的。 支持分库分表、读写分离、高可用。 MySQL 字段类型 # MySQL 字段类型可以简单分为三大类:\n数值类型:整型(TINYINT、SMALLINT、MEDIUMINT、INT 和 BIGINT)、浮点型(FLOAT 和 DOUBLE)、定点型(DECIMAL) 字符串类型:CHAR、VARCHAR、TINYTEXT、TEXT、MEDIUMTEXT、LONGTEXT、TINYBLOB、BLOB、MEDIUMBLOB 和 LONGBLOB 等,最常用的是 CHAR 和 VARCHAR。 日期时间类型:YEAR、TIME、DATE、DATETIME 和 TIMESTAMP 等。 下面这张图不是我画的,忘记是从哪里保存下来的了,总结的还蛮不错的。\nMySQL 字段类型比较多,我这里会挑选一些日常开发使用很频繁且面试常问的字段类型,以面试问题的形式来详细介绍。如无特殊说明,针对的都是 InnoDB 存储引擎。\n另外,推荐阅读一下《高性能 MySQL(第三版)》的第四章,有详细介绍 MySQL 字段类型优化。\n整数类型的 UNSIGNED 属性有什么用? # MySQL 中的整数类型可以使用可选的 UNSIGNED 属性来表示不允许负值的无符号整数。使用 UNSIGNED 属性可以将正整数的上限提高一倍,因为它不需要存储负数值。\n例如, TINYINT UNSIGNED 类型的取值范围是 0 ~ 255,而普通的 TINYINT 类型的值范围是 -128 ~ 127。INT UNSIGNED 类型的取值范围是 0 ~ 4,294,967,295,而普通的 INT 类型的值范围是 -2,147,483,648 ~ 2,147,483,647。\n对于从 0 开始递增的 ID 列,使用 UNSIGNED 属性可以非常适合,因为不允许负值并且可以拥有更大的上限范围,提供了更多的 ID 值可用。\nCHAR 和 VARCHAR 的区别是什么? # CHAR 和 VARCHAR 是最常用到的字符串类型,两者的主要区别在于:CHAR 是定长字符串,VARCHAR 是变长字符串。\nCHAR 在存储时会在右边填充空格以达到指定的长度,检索时会去掉空格;VARCHAR 在存储时需要使用 1 或 2 个额外字节记录字符串的长度,检索时不需要处理。\nCHAR 更适合存储长度较短或者长度都差不多的字符串,例如 Bcrypt 算法、MD5 算法加密后的密码、身份证号码。VARCHAR 类型适合存储长度不确定或者差异较大的字符串,例如用户昵称、文章标题等。\nCHAR(M) 和 VARCHAR(M) 的 M 都代表能够保存的字符数的最大值,无论是字母、数字还是中文,每个都只占用一个字符。\nVARCHAR(100)和 VARCHAR(10)的区别是什么? # VARCHAR(100)和 VARCHAR(10)都是变长类型,表示能存储最多 100 个字符和 10 个字符。因此,VARCHAR (100) 可以满足更大范围的字符存储需求,有更好的业务拓展性。而 VARCHAR(10)存储超过 10 个字符时,就需要修改表结构才可以。\n虽说 VARCHAR(100)和 VARCHAR(10)能存储的字符范围不同,但二者存储相同的字符串,所占用磁盘的存储空间其实是一样的,这也是很多人容易误解的一点。\n不过,VARCHAR(100) 会消耗更多的内存。这是因为 VARCHAR 类型在内存中操作时,通常会分配固定大小的内存块来保存值,即使用字符类型中定义的长度。例如在进行排序的时候,VARCHAR(100)是按照 100 这个长度来进行的,也就会消耗更多内存。\nDECIMAL 和 FLOAT/DOUBLE 的区别是什么? # DECIMAL 和 FLOAT 的区别是:DECIMAL 是定点数,FLOAT/DOUBLE 是浮点数。DECIMAL 可以存储精确的小数值,FLOAT/DOUBLE 只能存储近似的小数值。\nDECIMAL 用于存储具有精度要求的小数,例如与货币相关的数据,可以避免浮点数带来的精度损失。\n在 Java 中,MySQL 的 DECIMAL 类型对应的是 Java 类 java.math.BigDecimal。\n为什么不推荐使用 TEXT 和 BLOB? # TEXT 类型类似于 CHAR(0-255 字节)和 VARCHAR(0-65,535 字节),但可以存储更长的字符串,即长文本数据,例如博客内容。\n类型 可存储大小 用途 TINYTEXT 0-255 字节 一般文本字符串 TEXT 0-65,535 字节 长文本字符串 MEDIUMTEXT 0-16,772,150 字节 较大文本数据 LONGTEXT 0-4,294,967,295 字节 极大文本数据 BLOB 类型主要用于存储二进制大对象,例如图片、音视频等文件。\n类型 可存储大小 用途 TINYBLOB 0-255 字节 短文本二进制字符串 BLOB 0-65KB 二进制字符串 MEDIUMBLOB 0-16MB 二进制形式的长文本数据 LONGBLOB 0-4GB 二进制形式的极大文本数据 在日常开发中,很少使用 TEXT 类型,但偶尔会用到,而 BLOB 类型则基本不常用。如果预期长度范围可以通过 VARCHAR 来满足,建议避免使用 TEXT。\n数据库规范通常不推荐使用 BLOB 和 TEXT 类型,这两种类型具有一些缺点和限制,例如:\n不能有默认值。 在使用临时表时无法使用内存临时表,只能在磁盘上创建临时表(《高性能 MySQL》书中有提到)。 检索效率较低。 不能直接创建索引,需要指定前缀长度。 可能会消耗大量的网络和 IO 带宽。 可能导致表上的 DML 操作变慢。 …… DATETIME 和 TIMESTAMP 的区别是什么? # DATETIME 类型没有时区信息,TIMESTAMP 和时区有关。\nTIMESTAMP 只需要使用 4 个字节的存储空间,但是 DATETIME 需要耗费 8 个字节的存储空间。但是,这样同样造成了一个问题,Timestamp 表示的时间范围更小。\nDATETIME:1000-01-01 00:00:00 ~ 9999-12-31 23:59:59 Timestamp:1970-01-01 00:00:01 ~ 2037-12-31 23:59:59 关于两者的详细对比,请参考我写的 MySQL 时间类型数据存储建议。\nNULL 和 \u0026rsquo;\u0026rsquo; 的区别是什么? # NULL 跟 ''(空字符串)是两个完全不一样的值,区别如下:\nNULL 代表一个不确定的值,就算是两个 NULL,它俩也不一定相等。例如,SELECT NULL=NULL的结果为 false,但是在我们使用DISTINCT,GROUP BY,ORDER BY时,NULL又被认为是相等的。 ''的长度是 0,是不占用空间的,而NULL 是需要占用空间的。 NULL 会影响聚合函数的结果。例如,SUM、AVG、MIN、MAX 等聚合函数会忽略 NULL 值。 COUNT 的处理方式取决于参数的类型。如果参数是 *(COUNT(*)),则会统计所有的记录数,包括 NULL 值;如果参数是某个字段名(COUNT(列名)),则会忽略 NULL 值,只统计非空值的个数。 查询 NULL 值时,必须使用 IS NULL 或 IS NOT NULLl 来判断,而不能使用 =、!=、 \u0026lt;、\u0026gt; 之类的比较运算符。而''是可以使用这些比较运算符的。 看了上面的介绍之后,相信你对另外一个高频面试题:“为什么 MySQL 不建议使用 NULL 作为列默认值?”也有了答案。\nBoolean 类型如何表示? # MySQL 中没有专门的布尔类型,而是用 TINYINT(1) 类型来表示布尔值。TINYINT(1) 类型可以存储 0 或 1,分别对应 false 或 true。\nMySQL 基础架构 # 建议配合 SQL 语句在 MySQL 中的执行过程 这篇文章来理解 MySQL 基础架构。另外,“一个 SQL 语句在 MySQL 中的执行流程”也是面试中比较常问的一个问题。\n下图是 MySQL 的一个简要架构图,从下图你可以很清晰的看到客户端的一条 SQL 语句在 MySQL 内部是如何执行的。\n从上图可以看出, MySQL 主要由下面几部分构成:\n连接器: 身份认证和权限相关(登录 MySQL 的时候)。 查询缓存: 执行查询语句的时候,会先查询缓存(MySQL 8.0 版本后移除,因为这个功能不太实用)。 分析器: 没有命中缓存的话,SQL 语句就会经过分析器,分析器说白了就是要先看你的 SQL 语句要干嘛,再检查你的 SQL 语句语法是否正确。 优化器: 按照 MySQL 认为最优的方案去执行。 执行器: 执行语句,然后从存储引擎返回数据。 执行语句之前会先判断是否有权限,如果没有权限的话,就会报错。 插件式存储引擎:主要负责数据的存储和读取,采用的是插件式架构,支持 InnoDB、MyISAM、Memory 等多种存储引擎。InnoDB 是 MySQL 的默认存储引擎,绝大部分场景使用 InnoDB 就是最好的选择。 MySQL 存储引擎 # MySQL 核心在于存储引擎,想要深入学习 MySQL,必定要深入研究 MySQL 存储引擎。\nMySQL 支持哪些存储引擎?默认使用哪个? # MySQL 支持多种存储引擎,你可以通过 SHOW ENGINES 命令来查看 MySQL 支持的所有存储引擎。\n从上图我们可以查看出, MySQL 当前默认的存储引擎是 InnoDB。并且,所有的存储引擎中只有 InnoDB 是事务性存储引擎,也就是说只有 InnoDB 支持事务。\n我这里使用的 MySQL 版本是 8.x,不同的 MySQL 版本之间可能会有差别。\nMySQL 5.5.5 之前,MyISAM 是 MySQL 的默认存储引擎。5.5.5 版本之后,InnoDB 是 MySQL 的默认存储引擎。\n你可以通过 SELECT VERSION() 命令查看你的 MySQL 版本。\nmysql\u0026gt; SELECT VERSION(); +-----------+ | VERSION() | +-----------+ | 8.0.27 | +-----------+ 1 row in set (0.00 sec) 你也可以通过 SHOW VARIABLES LIKE '%storage_engine%' 命令直接查看 MySQL 当前默认的存储引擎。\nmysql\u0026gt; SHOW VARIABLES LIKE \u0026#39;%storage_engine%\u0026#39;; +---------------------------------+-----------+ | Variable_name | Value | +---------------------------------+-----------+ | default_storage_engine | InnoDB | | default_tmp_storage_engine | InnoDB | | disabled_storage_engines | | | internal_tmp_mem_storage_engine | TempTable | +---------------------------------+-----------+ 4 rows in set (0.00 sec) 如果你想要深入了解每个存储引擎以及它们之间的区别,推荐你去阅读以下 MySQL 官方文档对应的介绍(面试不会问这么细,了解即可):\nInnoDB 存储引擎详细介绍: https://dev.mysql.com/doc/refman/8.0/en/innodb-storage-engine.html 。 其他存储引擎详细介绍: https://dev.mysql.com/doc/refman/8.0/en/storage-engines.html 。 MySQL 存储引擎架构了解吗? # MySQL 存储引擎采用的是 插件式架构 ,支持多种存储引擎,我们甚至可以为不同的数据库表设置不同的存储引擎以适应不同场景的需要。存储引擎是基于表的,而不是数据库。\n下图展示了具有可插拔存储引擎的 MySQL 架构():\n你还可以根据 MySQL 定义的存储引擎实现标准接口来编写一个属于自己的存储引擎。这些非官方提供的存储引擎可以称为第三方存储引擎,区别于官方存储引擎。像目前最常用的 InnoDB 其实刚开始就是一个第三方存储引擎,后面由于过于优秀,其被 Oracle 直接收购了。\nMySQL 官方文档也有介绍到如何编写一个自定义存储引擎,地址: https://dev.mysql.com/doc/internals/en/custom-engine.html 。\nMyISAM 和 InnoDB 有什么区别? # MySQL 5.5 之前,MyISAM 引擎是 MySQL 的默认存储引擎,可谓是风光一时。\n虽然,MyISAM 的性能还行,各种特性也还不错(比如全文索引、压缩、空间函数等)。但是,MyISAM 不支持事务和行级锁,而且最大的缺陷就是崩溃后无法安全恢复。\nMySQL 5.5 版本之后,InnoDB 是 MySQL 的默认存储引擎。\n言归正传!咱们下面还是来简单对比一下两者:\n1、是否支持行级锁\nMyISAM 只有表级锁(table-level locking),而 InnoDB 支持行级锁(row-level locking)和表级锁,默认为行级锁。\n也就说,MyISAM 一锁就是锁住了整张表,这在并发写的情况下是多么滴憨憨啊!这也是为什么 InnoDB 在并发写的时候,性能更牛皮了!\n2、是否支持事务\nMyISAM 不提供事务支持。\nInnoDB 提供事务支持,实现了 SQL 标准定义了四个隔离级别,具有提交(commit)和回滚(rollback)事务的能力。并且,InnoDB 默认使用的 REPEATABLE-READ(可重读)隔离级别是可以解决幻读问题发生的(基于 MVCC 和 Next-Key Lock)。\n关于 MySQL 事务的详细介绍,可以看看我写的这篇文章: MySQL 事务隔离级别详解。\n3、是否支持外键\nMyISAM 不支持,而 InnoDB 支持。\n外键对于维护数据一致性非常有帮助,但是对性能有一定的损耗。因此,通常情况下,我们是不建议在实际生产项目中使用外键的,在业务代码中进行约束即可!\n阿里的《Java 开发手册》也是明确规定禁止使用外键的。\n不过,在代码中进行约束的话,对程序员的能力要求更高,具体是否要采用外键还是要根据你的项目实际情况而定。\n总结:一般我们也是不建议在数据库层面使用外键的,应用层面可以解决。不过,这样会对数据的一致性造成威胁。具体要不要使用外键还是要根据你的项目来决定。\n4、是否支持数据库异常崩溃后的安全恢复\nMyISAM 不支持,而 InnoDB 支持。\n使用 InnoDB 的数据库在异常崩溃后,数据库重新启动的时候会保证数据库恢复到崩溃前的状态。这个恢复的过程依赖于 redo log 。\n5、是否支持 MVCC\nMyISAM 不支持,而 InnoDB 支持。\n讲真,这个对比有点废话,毕竟 MyISAM 连行级锁都不支持。MVCC 可以看作是行级锁的一个升级,可以有效减少加锁操作,提高性能。\n6、索引实现不一样。\n虽然 MyISAM 引擎和 InnoDB 引擎都是使用 B+Tree 作为索引结构,但是两者的实现方式不太一样。\nInnoDB 引擎中,其数据文件本身就是索引文件。相比 MyISAM,索引文件和数据文件是分离的,其表数据文件本身就是按 B+Tree 组织的一个索引结构,树的叶节点 data 域保存了完整的数据记录。\n详细区别,推荐你看看我写的这篇文章: MySQL 索引详解。\n7、性能有差别。\nInnoDB 的性能比 MyISAM 更强大,不管是在读写混合模式下还是只读模式下,随着 CPU 核数的增加,InnoDB 的读写能力呈线性增长。MyISAM 因为读写不能并发,它的处理能力跟核数没关系。\n8、数据缓存策略和机制实现不同。\nInnoDB 使用缓冲池(Buffer Pool)缓存数据页和索引页,MyISAM 使用键缓存(Key Cache)仅缓存索引页而不缓存数据页。\n总结:\nInnoDB 支持行级别的锁粒度,MyISAM 不支持,只支持表级别的锁粒度。 MyISAM 不提供事务支持。InnoDB 提供事务支持,实现了 SQL 标准定义了四个隔离级别。 MyISAM 不支持外键,而 InnoDB 支持。 MyISAM 不支持 MVCC,而 InnoDB 支持。 虽然 MyISAM 引擎和 InnoDB 引擎都是使用 B+Tree 作为索引结构,但是两者的实现方式不太一样。 MyISAM 不支持数据库异常崩溃后的安全恢复,而 InnoDB 支持。 InnoDB 的性能比 MyISAM 更强大。 最后,再分享一张图片给你,这张图片详细对比了常见的几种 MySQL 存储引擎。\nMyISAM 和 InnoDB 如何选择? # 大多数时候我们使用的都是 InnoDB 存储引擎,在某些读密集的情况下,使用 MyISAM 也是合适的。不过,前提是你的项目不介意 MyISAM 不支持事务、崩溃恢复等缺点(可是~我们一般都会介意啊)。\n《MySQL 高性能》上面有一句话这样写到:\n不要轻易相信“MyISAM 比 InnoDB 快”之类的经验之谈,这个结论往往不是绝对的。在很多我们已知场景中,InnoDB 的速度都可以让 MyISAM 望尘莫及,尤其是用到了聚簇索引,或者需要访问的数据都可以放入内存的应用。\n因此,对于咱们日常开发的业务系统来说,你几乎找不到什么理由使用 MyISAM 了,老老实实用默认的 InnoDB 就可以了!\nMySQL 索引 # MySQL 索引相关的问题比较多,对于面试和工作都比较重要,于是,我单独抽了一篇文章专门来总结 MySQL 索引相关的知识点和问题: MySQL 索引详解 。\nMySQL 查询缓存 # MySQL 查询缓存是查询结果缓存。执行查询语句的时候,会先查询缓存,如果缓存中有对应的查询结果,就会直接返回。\nmy.cnf 加入以下配置,重启 MySQL 开启查询缓存\nquery_cache_type=1 query_cache_size=600000 MySQL 执行以下命令也可以开启查询缓存\nset global query_cache_type=1; set global query_cache_size=600000; 查询缓存会在同样的查询条件和数据情况下,直接返回缓存中的结果。但需要注意的是,查询缓存的匹配条件非常严格,任何细微的差异都会导致缓存无法命中。这里的查询条件包括查询语句本身、当前使用的数据库、以及其他可能影响结果的因素,如客户端协议版本号等。\n查询缓存不命中的情况:\n任何两个查询在任何字符上的不同都会导致缓存不命中。 如果查询中包含任何用户自定义函数、存储函数、用户变量、临时表、MySQL 库中的系统表,其查询结果也不会被缓存。 缓存建立之后,MySQL 的查询缓存系统会跟踪查询中涉及的每张表,如果这些表(数据或结构)发生变化,那么和这张表相关的所有缓存数据都将失效。 缓存虽然能够提升数据库的查询性能,但是缓存同时也带来了额外的开销,每次查询后都要做一次缓存操作,失效后还要销毁。 因此,开启查询缓存要谨慎,尤其对于写密集的应用来说更是如此。如果开启,要注意合理控制缓存空间大小,一般来说其大小设置为几十 MB 比较合适。此外,还可以通过 sql_cache 和 sql_no_cache 来控制某个查询语句是否需要缓存:\nSELECT sql_no_cache COUNT(*) FROM usr; MySQL 5.6 开始,查询缓存已默认禁用。MySQL 8.0 开始,已经不再支持查询缓存了(具体可以参考这篇文章: MySQL 8.0: Retiring Support for the Query Cache)。\nMySQL 日志 # MySQL 日志常见的面试题有:\nMySQL 中常见的日志有哪些? 慢查询日志有什么用? binlog 主要记录了什么? redo log 如何保证事务的持久性? 页修改之后为什么不直接刷盘呢? binlog 和 redolog 有什么区别? undo log 如何保证事务的原子性? …… 上诉问题的答案可以在 《Java 面试指北》(付费) 的 「技术面试题篇」 中找到。\nMySQL 事务 # 何谓事务? # 我们设想一个场景,这个场景中我们需要插入多条相关联的数据到数据库,不幸的是,这个过程可能会遇到下面这些问题:\n数据库中途突然因为某些原因挂掉了。 客户端突然因为网络原因连接不上数据库了。 并发访问数据库时,多个线程同时写入数据库,覆盖了彼此的更改。 …… 上面的任何一个问题都可能会导致数据的不一致性。为了保证数据的一致性,系统必须能够处理这些问题。事务就是我们抽象出来简化这些问题的首选机制。事务的概念起源于数据库,目前,已经成为一个比较广泛的概念。\n何为事务? 一言蔽之,事务是逻辑上的一组操作,要么都执行,要么都不执行。\n事务最经典也经常被拿出来说例子就是转账了。假如小明要给小红转账 1000 元,这个转账会涉及到两个关键操作,这两个操作必须都成功或者都失败。\n将小明的余额减少 1000 元 将小红的余额增加 1000 元。 事务会把这两个操作就可以看成逻辑上的一个整体,这个整体包含的操作要么都成功,要么都要失败。这样就不会出现小明余额减少而小红的余额却并没有增加的情况。\n何谓数据库事务? # 大多数情况下,我们在谈论事务的时候,如果没有特指分布式事务,往往指的就是数据库事务。\n数据库事务在我们日常开发中接触的最多了。如果你的项目属于单体架构的话,你接触到的往往就是数据库事务了。\n那数据库事务有什么作用呢?\n简单来说,数据库事务可以保证多个对数据库的操作(也就是 SQL 语句)构成一个逻辑上的整体。构成这个逻辑上的整体的这些数据库操作遵循:要么全部执行成功,要么全部不执行 。\n# 开启一个事务 START TRANSACTION; # 多条 SQL 语句 SQL1,SQL2... ## 提交事务 COMMIT; 另外,关系型数据库(例如:MySQL、SQL Server、Oracle 等)事务都有 ACID 特性:\n原子性(Atomicity):事务是最小的执行单位,不允许分割。事务的原子性确保动作要么全部完成,要么完全不起作用; 一致性(Consistency):执行事务前后,数据保持一致,例如转账业务中,无论事务是否成功,转账者和收款人的总额应该是不变的; 隔离性(Isolation):并发访问数据库时,一个用户的事务不被其他事务所干扰,各并发事务之间数据库是独立的; 持久性(Durability):一个事务被提交之后。它对数据库中数据的改变是持久的,即使数据库发生故障也不应该对其有任何影响。 🌈 这里要额外补充一点:只有保证了事务的持久性、原子性、隔离性之后,一致性才能得到保障。也就是说 A、I、D 是手段,C 是目的! 想必大家也和我一样,被 ACID 这个概念被误导了很久! 我也是看周志明老师的公开课 《周志明的软件架构课》才搞清楚的(多看好书!!!)。\n另外,DDIA 也就是 《Designing Data-Intensive Application(数据密集型应用系统设计)》 的作者在他的这本书中如是说:\nAtomicity, isolation, and durability are properties of the database, whereas consis‐ tency (in the ACID sense) is a property of the application. The application may rely on the database’s atomicity and isolation properties in order to achieve consistency, but it’s not up to the database alone.\n翻译过来的意思是:原子性,隔离性和持久性是数据库的属性,而一致性(在 ACID 意义上)是应用程序的属性。应用可能依赖数据库的原子性和隔离属性来实现一致性,但这并不仅取决于数据库。因此,字母 C 不属于 ACID 。\n《Designing Data-Intensive Application(数据密集型应用系统设计)》这本书强推一波,值得读很多遍!豆瓣有接近 90% 的人看了这本书之后给了五星好评。另外,中文翻译版本已经在 GitHub 开源,地址: https://github.com/Vonng/ddia 。\n并发事务带来了哪些问题? # 在典型的应用程序中,多个事务并发运行,经常会操作相同的数据来完成各自的任务(多个用户对同一数据进行操作)。并发虽然是必须的,但可能会导致以下的问题。\n脏读(Dirty read) # 一个事务读取数据并且对数据进行了修改,这个修改对其他事务来说是可见的,即使当前事务没有提交。这时另外一个事务读取了这个还未提交的数据,但第一个事务突然回滚,导致数据并没有被提交到数据库,那第二个事务读取到的就是脏数据,这也就是脏读的由来。\n例如:事务 1 读取某表中的数据 A=20,事务 1 修改 A=A-1,事务 2 读取到 A = 19,事务 1 回滚导致对 A 的修改并未提交到数据库, A 的值还是 20。\n丢失修改(Lost to modify) # 在一个事务读取一个数据时,另外一个事务也访问了该数据,那么在第一个事务中修改了这个数据后,第二个事务也修改了这个数据。这样第一个事务内的修改结果就被丢失,因此称为丢失修改。\n例如:事务 1 读取某表中的数据 A=20,事务 2 也读取 A=20,事务 1 先修改 A=A-1,事务 2 后来也修改 A=A-1,最终结果 A=19,事务 1 的修改被丢失。\n不可重复读(Unrepeatable read) # 指在一个事务内多次读同一数据。在这个事务还没有结束时,另一个事务也访问该数据。那么,在第一个事务中的两次读数据之间,由于第二个事务的修改导致第一个事务两次读取的数据可能不太一样。这就发生了在一个事务内两次读到的数据是不一样的情况,因此称为不可重复读。\n例如:事务 1 读取某表中的数据 A=20,事务 2 也读取 A=20,事务 1 修改 A=A-1,事务 2 再次读取 A =19,此时读取的结果和第一次读取的结果不同。\n幻读(Phantom read) # 幻读与不可重复读类似。它发生在一个事务读取了几行数据,接着另一个并发事务插入了一些数据时。在随后的查询中,第一个事务就会发现多了一些原本不存在的记录,就好像发生了幻觉一样,所以称为幻读。\n例如:事务 2 读取某个范围的数据,事务 1 在这个范围插入了新的数据,事务 2 再次读取这个范围的数据发现相比于第一次读取的结果多了新的数据。\n不可重复读和幻读有什么区别? # 不可重复读的重点是内容修改或者记录减少比如多次读取一条记录发现其中某些记录的值被修改; 幻读的重点在于记录新增比如多次执行同一条查询语句(DQL)时,发现查到的记录增加了。 幻读其实可以看作是不可重复读的一种特殊情况,单独把幻读区分出来的原因主要是解决幻读和不可重复读的方案不一样。\n举个例子:执行 delete 和 update 操作的时候,可以直接对记录加锁,保证事务安全。而执行 insert 操作的时候,由于记录锁(Record Lock)只能锁住已经存在的记录,为了避免插入新记录,需要依赖间隙锁(Gap Lock)。也就是说执行 insert 操作的时候需要依赖 Next-Key Lock(Record Lock+Gap Lock) 进行加锁来保证不出现幻读。\n并发事务的控制方式有哪些? # MySQL 中并发事务的控制方式无非就两种:锁 和 MVCC。锁可以看作是悲观控制的模式,多版本并发控制(MVCC,Multiversion concurrency control)可以看作是乐观控制的模式。\n锁 控制方式下会通过锁来显式控制共享资源而不是通过调度手段,MySQL 中主要是通过 读写锁 来实现并发控制。\n共享锁(S 锁):又称读锁,事务在读取记录的时候获取共享锁,允许多个事务同时获取(锁兼容)。 排他锁(X 锁):又称写锁/独占锁,事务在修改记录的时候获取排他锁,不允许多个事务同时获取。如果一个记录已经被加了排他锁,那其他事务不能再对这条记录加任何类型的锁(锁不兼容)。 读写锁可以做到读读并行,但是无法做到写读、写写并行。另外,根据根据锁粒度的不同,又被分为 表级锁(table-level locking) 和 行级锁(row-level locking) 。InnoDB 不光支持表级锁,还支持行级锁,默认为行级锁。行级锁的粒度更小,仅对相关的记录上锁即可(对一行或者多行记录加锁),所以对于并发写入操作来说, InnoDB 的性能更高。不论是表级锁还是行级锁,都存在共享锁(Share Lock,S 锁)和排他锁(Exclusive Lock,X 锁)这两类。\nMVCC 是多版本并发控制方法,即对一份数据会存储多个版本,通过事务的可见性来保证事务能看到自己应该看到的版本。通常会有一个全局的版本分配器来为每一行数据设置版本号,版本号是唯一的。\nMVCC 在 MySQL 中实现所依赖的手段主要是: 隐藏字段、read view、undo log。\nundo log : undo log 用于记录某行数据的多个版本的数据。 read view 和 隐藏字段 : 用来判断当前版本数据的可见性。 关于 InnoDB 对 MVCC 的具体实现可以看这篇文章: InnoDB 存储引擎对 MVCC 的实现 。\nSQL 标准定义了哪些事务隔离级别? # SQL 标准定义了四个隔离级别:\nREAD-UNCOMMITTED(读取未提交) :最低的隔离级别,允许读取尚未提交的数据变更,可能会导致脏读、幻读或不可重复读。 READ-COMMITTED(读取已提交) :允许读取并发事务已经提交的数据,可以阻止脏读,但是幻读或不可重复读仍有可能发生。 REPEATABLE-READ(可重复读) :对同一字段的多次读取结果都是一致的,除非数据是被本身事务自己所修改,可以阻止脏读和不可重复读,但幻读仍有可能发生。 SERIALIZABLE(可串行化) :最高的隔离级别,完全服从 ACID 的隔离级别。所有的事务依次逐个执行,这样事务之间就完全不可能产生干扰,也就是说,该级别可以防止脏读、不可重复读以及幻读。 隔离级别 脏读 不可重复读 幻读 READ-UNCOMMITTED √ √ √ READ-COMMITTED × √ √ REPEATABLE-READ × × √ SERIALIZABLE × × × MySQL 的隔离级别是基于锁实现的吗? # MySQL 的隔离级别基于锁和 MVCC 机制共同实现的。\nSERIALIZABLE 隔离级别是通过锁来实现的,READ-COMMITTED 和 REPEATABLE-READ 隔离级别是基于 MVCC 实现的。不过, SERIALIZABLE 之外的其他隔离级别可能也需要用到锁机制,就比如 REPEATABLE-READ 在当前读情况下需要使用加锁读来保证不会出现幻读。\nMySQL 的默认隔离级别是什么? # MySQL InnoDB 存储引擎的默认支持的隔离级别是 REPEATABLE-READ(可重读)。我们可以通过SELECT @@tx_isolation;命令来查看,MySQL 8.0 该命令改为SELECT @@transaction_isolation;\nmysql\u0026gt; SELECT @@tx_isolation; +-----------------+ | @@tx_isolation | +-----------------+ | REPEATABLE-READ | +-----------------+ 关于 MySQL 事务隔离级别的详细介绍,可以看看我写的这篇文章: MySQL 事务隔离级别详解。\nMySQL 锁 # 锁是一种常见的并发事务的控制方式。\n表级锁和行级锁了解吗?有什么区别? # MyISAM 仅仅支持表级锁(table-level locking),一锁就锁整张表,这在并发写的情况下性非常差。InnoDB 不光支持表级锁(table-level locking),还支持行级锁(row-level locking),默认为行级锁。\n行级锁的粒度更小,仅对相关的记录上锁即可(对一行或者多行记录加锁),所以对于并发写入操作来说, InnoDB 的性能更高。\n表级锁和行级锁对比:\n表级锁: MySQL 中锁定粒度最大的一种锁(全局锁除外),是针对非索引字段加的锁,对当前操作的整张表加锁,实现简单,资源消耗也比较少,加锁快,不会出现死锁。不过,触发锁冲突的概率最高,高并发下效率极低。表级锁和存储引擎无关,MyISAM 和 InnoDB 引擎都支持表级锁。 行级锁: MySQL 中锁定粒度最小的一种锁,是 针对索引字段加的锁 ,只针对当前操作的行记录进行加锁。 行级锁能大大减少数据库操作的冲突。其加锁粒度最小,并发度高,但加锁的开销也最大,加锁慢,会出现死锁。行级锁和存储引擎有关,是在存储引擎层面实现的。 行级锁的使用有什么注意事项? # InnoDB 的行锁是针对索引字段加的锁,表级锁是针对非索引字段加的锁。当我们执行 UPDATE、DELETE 语句时,如果 WHERE条件中字段没有命中唯一索引或者索引失效的话,就会导致扫描全表对表中的所有行记录进行加锁。这个在我们日常工作开发中经常会遇到,一定要多多注意!!!\n不过,很多时候即使用了索引也有可能会走全表扫描,这是因为 MySQL 优化器的原因。\nInnoDB 有哪几类行锁? # InnoDB 行锁是通过对索引数据页上的记录加锁实现的,MySQL InnoDB 支持三种行锁定方式:\n记录锁(Record Lock):属于单个行记录上的锁。 间隙锁(Gap Lock):锁定一个范围,不包括记录本身。 临键锁(Next-Key Lock):Record Lock+Gap Lock,锁定一个范围,包含记录本身,主要目的是为了解决幻读问题(MySQL 事务部分提到过)。记录锁只能锁住已经存在的记录,为了避免插入新记录,需要依赖间隙锁。 在 InnoDB 默认的隔离级别 REPEATABLE-READ 下,行锁默认使用的是 Next-Key Lock。但是,如果操作的索引是唯一索引或主键,InnoDB 会对 Next-Key Lock 进行优化,将其降级为 Record Lock,即仅锁住索引本身,而不是范围。\n一些大厂面试中可能会问到 Next-Key Lock 的加锁范围,这里推荐一篇文章: MySQL next-key lock 加锁范围是什么? - 程序员小航 - 2021 。\n共享锁和排他锁呢? # 不论是表级锁还是行级锁,都存在共享锁(Share Lock,S 锁)和排他锁(Exclusive Lock,X 锁)这两类:\n共享锁(S 锁):又称读锁,事务在读取记录的时候获取共享锁,允许多个事务同时获取(锁兼容)。 排他锁(X 锁):又称写锁/独占锁,事务在修改记录的时候获取排他锁,不允许多个事务同时获取。如果一个记录已经被加了排他锁,那其他事务不能再对这条事务加任何类型的锁(锁不兼容)。 排他锁与任何的锁都不兼容,共享锁仅和共享锁兼容。\nS 锁 X 锁 S 锁 不冲突 冲突 X 锁 冲突 冲突 由于 MVCC 的存在,对于一般的 SELECT 语句,InnoDB 不会加任何锁。不过, 你可以通过以下语句显式加共享锁或排他锁。\n# 共享锁 可以在 MySQL 5.7 和 MySQL 8.0 中使用 SELECT ... LOCK IN SHARE MODE; # 共享锁 可以在 MySQL 8.0 中使用 SELECT ... FOR SHARE; # 排他锁 SELECT ... FOR UPDATE; 意向锁有什么作用? # 如果需要用到表锁的话,如何判断表中的记录没有行锁呢,一行一行遍历肯定是不行,性能太差。我们需要用到一个叫做意向锁的东东来快速判断是否可以对某个表使用表锁。\n意向锁是表级锁,共有两种:\n意向共享锁(Intention Shared Lock,IS 锁):事务有意向对表中的某些记录加共享锁(S 锁),加共享锁前必须先取得该表的 IS 锁。 意向排他锁(Intention Exclusive Lock,IX 锁):事务有意向对表中的某些记录加排他锁(X 锁),加排他锁之前必须先取得该表的 IX 锁。 意向锁是由数据引擎自己维护的,用户无法手动操作意向锁,在为数据行加共享/排他锁之前,InnoDB 会先获取该数据行所在在数据表的对应意向锁。\n意向锁之间是互相兼容的。\nIS 锁 IX 锁 IS 锁 兼容 兼容 IX 锁 兼容 兼容 意向锁和共享锁和排它锁互斥(这里指的是表级别的共享锁和排他锁,意向锁不会与行级的共享锁和排他锁互斥)。\nIS 锁 IX 锁 S 锁 兼容 互斥 X 锁 互斥 互斥 《MySQL 技术内幕 InnoDB 存储引擎》这本书对应的描述应该是笔误了。\n当前读和快照读有什么区别? # 快照读(一致性非锁定读)就是单纯的 SELECT 语句,但不包括下面这两类 SELECT 语句:\nSELECT ... FOR UPDATE # 共享锁 可以在 MySQL 5.7 和 MySQL 8.0 中使用 SELECT ... LOCK IN SHARE MODE; # 共享锁 可以在 MySQL 8.0 中使用 SELECT ... FOR SHARE; 快照即记录的历史版本,每行记录可能存在多个历史版本(多版本技术)。\n快照读的情况下,如果读取的记录正在执行 UPDATE/DELETE 操作,读取操作不会因此去等待记录上 X 锁的释放,而是会去读取行的一个快照。\n只有在事务隔离级别 RC(读取已提交) 和 RR(可重读)下,InnoDB 才会使用一致性非锁定读:\n在 RC 级别下,对于快照数据,一致性非锁定读总是读取被锁定行的最新一份快照数据。 在 RR 级别下,对于快照数据,一致性非锁定读总是读取本事务开始时的行数据版本。 快照读比较适合对于数据一致性要求不是特别高且追求极致性能的业务场景。\n当前读 (一致性锁定读)就是给行记录加 X 锁或 S 锁。\n当前读的一些常见 SQL 语句类型如下:\n# 对读的记录加一个X锁 SELECT...FOR UPDATE # 对读的记录加一个S锁 SELECT...LOCK IN SHARE MODE # 对读的记录加一个S锁 SELECT...FOR SHARE # 对修改的记录加一个X锁 INSERT... UPDATE... DELETE... 自增锁有了解吗? # 不太重要的一个知识点,简单了解即可。\n关系型数据库设计表的时候,通常会有一列作为自增主键。InnoDB 中的自增主键会涉及一种比较特殊的表级锁— 自增锁(AUTO-INC Locks) 。\nCREATE TABLE `sequence_id` ( `id` BIGINT(20) UNSIGNED NOT NULL AUTO_INCREMENT, `stub` CHAR(10) NOT NULL DEFAULT \u0026#39;\u0026#39;, PRIMARY KEY (`id`), UNIQUE KEY `stub` (`stub`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; 更准确点来说,不仅仅是自增主键,AUTO_INCREMENT的列都会涉及到自增锁,毕竟非主键也可以设置自增长。\n如果一个事务正在插入数据到有自增列的表时,会先获取自增锁,拿不到就可能会被阻塞住。这里的阻塞行为只是自增锁行为的其中一种,可以理解为自增锁就是一个接口,其具体的实现有多种。具体的配置项为 innodb_autoinc_lock_mode (MySQL 5.1.22 引入),可以选择的值如下:\ninnodb_autoinc_lock_mode 介绍 0 传统模式 1 连续模式(MySQL 8.0 之前默认) 2 交错模式(MySQL 8.0 之后默认) 交错模式下,所有的“INSERT-LIKE”语句(所有的插入语句,包括:INSERT、REPLACE、INSERT…SELECT、REPLACE…SELECT、LOAD DATA等)都不使用表级锁,使用的是轻量级互斥锁实现,多条插入语句可以并发执行,速度更快,扩展性也更好。\n不过,如果你的 MySQL 数据库有主从同步需求并且 Binlog 存储格式为 Statement 的话,不要将 InnoDB 自增锁模式设置为交叉模式,不然会有数据不一致性问题。这是因为并发情况下插入语句的执行顺序就无法得到保障。\n如果 MySQL 采用的格式为 Statement ,那么 MySQL 的主从同步实际上同步的就是一条一条的 SQL 语句。\n最后,再推荐一篇文章: 为什么 MySQL 的自增主键不单调也不连续 。\nMySQL 性能优化 # 关于 MySQL 性能优化的建议总结,请看这篇文章: MySQL 高性能优化规范建议总结 。\n能用 MySQL 直接存储文件(比如图片)吗? # 可以是可以,直接存储文件对应的二进制数据即可。不过,还是建议不要在数据库中存储文件,会严重影响数据库性能,消耗过多存储空间。\n可以选择使用云服务厂商提供的开箱即用的文件存储服务,成熟稳定,价格也比较低。\n也可以选择自建文件存储服务,实现起来也不难,基于 FastDFS、MinIO(推荐) 等开源项目就可以实现分布式文件服务。\n数据库只存储文件地址信息,文件由文件存储服务负责存储。\n相关阅读: Spring Boot 整合 MinIO 实现分布式文件服务 。\nMySQL 如何存储 IP 地址? # 可以将 IP 地址转换成整形数据存储,性能更好,占用空间也更小。\nMySQL 提供了两个方法来处理 ip 地址\nINET_ATON():把 ip 转为无符号整型 (4-8 位) INET_NTOA() :把整型的 ip 转为地址 插入数据前,先用 INET_ATON() 把 ip 地址转为整型,显示数据时,使用 INET_NTOA() 把整型的 ip 地址转为地址显示即可。\n有哪些常见的 SQL 优化手段? # 《Java 面试指北》(付费) 的 「技术面试题篇」 有一篇文章详细介绍了常见的 SQL 优化手段,非常全面,清晰易懂!\n如何分析 SQL 的性能? # 我们可以使用 EXPLAIN 命令来分析 SQL 的 执行计划 。执行计划是指一条 SQL 语句在经过 MySQL 查询优化器的优化会后,具体的执行方式。\nEXPLAIN 并不会真的去执行相关的语句,而是通过 查询优化器 对语句进行分析,找出最优的查询方案,并显示对应的信息。\nEXPLAIN 适用于 SELECT, DELETE, INSERT, REPLACE, 和 UPDATE语句,我们一般分析 SELECT 查询较多。\n我们这里简单来演示一下 EXPLAIN 的使用。\nEXPLAIN 的输出格式如下:\nmysql\u0026gt; EXPLAIN SELECT `score`,`name` FROM `cus_order` ORDER BY `score` DESC; +----+-------------+-----------+------------+------+---------------+------+---------+------+--------+----------+----------------+ | id | select_type | table | partitions | type | possible_keys | key | key_len | ref | rows | filtered | Extra | +----+-------------+-----------+------------+------+---------------+------+---------+------+--------+----------+----------------+ | 1 | SIMPLE | cus_order | NULL | ALL | NULL | NULL | NULL | NULL | 997572 | 100.00 | Using filesort | +----+-------------+-----------+------------+------+---------------+------+---------+------+--------+----------+----------------+ 1 row in set, 1 warning (0.00 sec) 各个字段的含义如下:\n列名 含义 id SELECT 查询的序列标识符 select_type SELECT 关键字对应的查询类型 table 用到的表名 partitions 匹配的分区,对于未分区的表,值为 NULL type 表的访问方法 possible_keys 可能用到的索引 key 实际用到的索引 key_len 所选索引的长度 ref 当使用索引等值查询时,与索引作比较的列或常量 rows 预计要读取的行数 filtered 按表条件过滤后,留存的记录数的百分比 Extra 附加信息 篇幅问题,我这里只是简单介绍了一下 MySQL 执行计划,详细介绍请看: SQL 的执行计划这篇文章。\n读写分离和分库分表了解吗? # 读写分离和分库分表相关的问题比较多,于是,我单独写了一篇文章来介绍: 读写分离和分库分表详解。\n深度分页如何优化? # 深度分页介绍及优化建议\n数据冷热分离如何做? # 数据冷热分离详解\nMySQL 性能怎么优化? # MySQL 性能优化是一个系统性工程,涉及多个方面,在面试中不可能面面俱到。因此,建议按照“点-线-面”的思路展开,从核心问题入手,再逐步扩展,展示出你对问题的思考深度和解决能力。\n1. 抓住核心:慢 SQL 定位与分析\n性能优化的第一步永远是找到瓶颈。面试时,建议先从 慢 SQL 定位和分析 入手,这不仅能展示你解决问题的思路,还能体现你对数据库性能监控的熟练掌握:\n监控工具: 介绍常用的慢 SQL 监控工具,如 MySQL 慢查询日志、Performance Schema 等,说明你对这些工具的熟悉程度以及如何通过它们定位问题。 EXPLAIN 命令: 详细说明 EXPLAIN 命令的使用,分析查询计划、索引使用情况,可以结合实际案例展示如何解读分析结果,比如执行顺序、索引使用情况、全表扫描等。 2. 由点及面:索引、表结构和 SQL 优化\n定位到慢 SQL 后,接下来就要针对具体问题进行优化。 这里可以重点介绍索引、表结构和 SQL 编写规范等方面的优化技巧:\n索引优化: 这是 MySQL 性能优化的重点,可以介绍索引的创建原则、覆盖索引、最左前缀匹配原则等。如果能结合你项目的实际应用来说明如何选择合适的索引,会更加分一些。 表结构优化: 优化表结构设计,包括选择合适的字段类型、避免冗余字段、合理使用范式和反范式设计等等。 SQL 优化: 避免使用 SELECT *、尽量使用具体字段、使用连接查询代替子查询、合理使用分页查询、批量操作等,都是 SQL 编写过程中需要注意的细节。 3. 进阶方案:架构优化\n当面试官对基础优化知识比较满意时,可能会深入探讨一些架构层面的优化方案。以下是一些常见的架构优化策略:\n读写分离: 将读操作和写操作分离到不同的数据库实例,提升数据库的并发处理能力。 分库分表: 将数据分散到多个数据库实例或数据表中,降低单表数据量,提升查询效率。但要权衡其带来的复杂性和维护成本,谨慎使用。 数据冷热分离:根据数据的访问频率和业务重要性,将数据分为冷数据和热数据,冷数据一般存储在存储在低成本、低性能的介质中,热数据高性能存储介质中。 缓存机制: 使用 Redis 等缓存中间件,将热点数据缓存到内存中,减轻数据库压力。这个非常常用,提升效果非常明显,性价比极高! 4. 其他优化手段\n除了慢 SQL 定位、索引优化和架构优化,还可以提及一些其他优化手段,展示你对 MySQL 性能调优的全面理解:\n连接池配置: 配置合理的数据库连接池(如 连接池大小、超时时间 等),能够有效提升数据库连接的效率,避免频繁的连接开销。 硬件配置: 提升硬件性能也是优化的重要手段之一。使用高性能服务器、增加内存、使用 SSD 硬盘等硬件升级,都可以有效提升数据库的整体性能。 5.总结\n在面试中,建议按优先级依次介绍慢 SQL 定位、 索引优化、表结构设计和 SQL 优化等内容。架构层面的优化,如 读写分离和分库分表、 数据冷热分离 应作为最后的手段,除非在特定场景下有明显的性能瓶颈,否则不应轻易使用,因其引入的复杂性会带来额外的维护成本。\nMySQL 学习资料推荐 # 书籍推荐 。\n文章推荐 :\n一树一溪的 MySQL 系列教程 Yes 的 MySQL 系列教程 写完这篇 我的 SQL 优化能力直接进入新层次 - 变成派大星 - 2022 两万字详解!InnoDB 锁专题! - 捡田螺的小男孩 - 2022 MySQL 的自增主键一定是连续的吗? - 飞天小牛肉 - 2022 深入理解 MySQL 索引底层原理 - 腾讯技术工程 - 2020 参考 # 《高性能 MySQL》第 7 章 MySQL 高级特性 《MySQL 技术内幕 InnoDB 存储引擎》第 6 章 锁 Relational Database: https://www.omnisci.com/technical-glossary/relational-database 一篇文章看懂 mysql 中 varchar 能存多少汉字、数字,以及 varchar(100)和 varchar(10)的区别: https://www.cnblogs.com/zhuyeshen/p/11642211.html 技术分享 | 隔离级别:正确理解幻读: https://opensource.actionsky.com/20210818-mysql/ MySQL Server Logs - MySQL 5.7 Reference Manual: https://dev.mysql.com/doc/refman/5.7/en/server-logs.html Redo Log - MySQL 5.7 Reference Manual: https://dev.mysql.com/doc/refman/5.7/en/innodb-redo-log.html Locking Reads - MySQL 5.7 Reference Manual: https://dev.mysql.com/doc/refman/5.7/en/innodb-locking-reads.html 深入理解数据库行锁与表锁 https://zhuanlan.zhihu.com/p/52678870 详解 MySQL InnoDB 中意向锁的作用: https://juejin.cn/post/6844903666332368909 深入剖析 MySQL 自增锁: https://juejin.cn/post/6968420054287253540 在数据库中不可重复读和幻读到底应该怎么分?: https://www.zhihu.com/question/392569386 "},{"id":540,"href":"/zh/docs/technology/Interview/database/mysql/mysql-high-performance-optimization-specification-recommendations/","title":"MySQL高性能优化规范建议总结","section":"Mysql","content":" 作者: 听风 原文地址: https://www.cnblogs.com/huchong/p/10219318.html。\nJavaGuide 已获得作者授权,并对原文内容进行了完善补充。\n数据库命名规范 # 所有数据库对象名称必须使用小写字母并用下划线分割 所有数据库对象名称禁止使用 MySQL 保留关键字(如果表名中包含关键字查询时,需要将其用单引号括起来) 数据库对象的命名要能做到见名识意,并且最后不要超过 32 个字符 临时库表必须以 tmp_ 为前缀并以日期为后缀,备份表必须以 bak_ 为前缀并以日期 (时间戳) 为后缀 所有存储相同数据的列名和列类型必须一致(一般作为关联列,如果查询时关联列类型不一致会自动进行数据类型隐式转换,会造成列上的索引失效,导致查询效率降低) 数据库基本设计规范 # 所有表必须使用 InnoDB 存储引擎 # 没有特殊要求(即 InnoDB 无法满足的功能如:列存储,存储空间数据等)的情况下,所有表必须使用 InnoDB 存储引擎(MySQL5.5 之前默认使用 Myisam,5.6 以后默认的为 InnoDB)。\nInnoDB 支持事务,支持行级锁,更好的恢复性,高并发下性能更好。\n数据库和表的字符集统一使用 UTF8 # 兼容性更好,统一字符集可以避免由于字符集转换产生的乱码,不同的字符集进行比较前需要进行转换会造成索引失效,如果数据库中有存储 emoji 表情的需要,字符集需要采用 utf8mb4 字符集。\n推荐阅读一下我写的这篇文章: MySQL 字符集详解 。\n所有表和字段都需要添加注释 # 使用 comment 从句添加表和列的备注,从一开始就进行数据字典的维护\n尽量控制单表数据量的大小,建议控制在 500 万以内 # 500 万并不是 MySQL 数据库的限制,过大会造成修改表结构,备份,恢复都会有很大的问题。\n可以用历史数据归档(应用于日志数据),分库分表(应用于业务数据)等手段来控制数据量大小\n谨慎使用 MySQL 分区表 # 分区表在物理上表现为多个文件,在逻辑上表现为一个表;\n谨慎选择分区键,跨分区查询效率可能更低;\n建议采用物理分表的方式管理大数据。\n经常一起使用的列放到一个表中 # 避免更多的关联操作。\n禁止在表中建立预留字段 # 预留字段的命名很难做到见名识义。 预留字段无法确认存储的数据类型,所以无法选择合适的类型。 对预留字段类型的修改,会对表进行锁定。 禁止在数据库中存储文件(比如图片)这类大的二进制数据 # 在数据库中存储文件会严重影响数据库性能,消耗过多存储空间。\n文件(比如图片)这类大的二进制数据通常存储于文件服务器,数据库只存储文件地址信息。\n不要被数据库范式所束缚 # 一般来说,设计关系数据库时需要满足第三范式,但为了满足第三范式,我们可能会拆分出多张表。而在进行查询时需要对多张表进行关联查询,有时为了提高查询效率,会降低范式的要求,在表中保存一定的冗余信息,也叫做反范式。但要注意反范式一定要适度。\n禁止在线上做数据库压力测试 # 禁止从开发环境,测试环境直接连接生产环境数据库 # 安全隐患极大,要对生产环境抱有敬畏之心!\n数据库字段设计规范 # 优先选择符合存储需要的最小的数据类型 # 存储字节越小,占用也就空间越小,性能也越好。\na.某些字符串可以转换成数字类型存储比如可以将 IP 地址转换成整型数据。\n数字是连续的,性能更好,占用空间也更小。\nMySQL 提供了两个方法来处理 ip 地址\nINET_ATON():把 ip 转为无符号整型 (4-8 位) INET_NTOA() :把整型的 ip 转为地址 插入数据前,先用 INET_ATON() 把 ip 地址转为整型,显示数据时,使用 INET_NTOA() 把整型的 ip 地址转为地址显示即可。\nb.对于非负型的数据 (如自增 ID,整型 IP,年龄) 来说,要优先使用无符号整型来存储。\n无符号相对于有符号可以多出一倍的存储空间\nSIGNED INT -2147483648~2147483647 UNSIGNED INT 0~4294967295 c.小数值类型(比如年龄、状态表示如 0/1)优先使用 TINYINT 类型。\n避免使用 TEXT,BLOB 数据类型,最常见的 TEXT 类型可以存储 64k 的数据 # a. 建议把 BLOB 或是 TEXT 列分离到单独的扩展表中。\nMySQL 内存临时表不支持 TEXT、BLOB 这样的大数据类型,如果查询中包含这样的数据,在排序等操作时,就不能使用内存临时表,必须使用磁盘临时表进行。而且对于这种数据,MySQL 还是要进行二次查询,会使 sql 性能变得很差,但是不是说一定不能使用这样的数据类型。\n如果一定要使用,建议把 BLOB 或是 TEXT 列分离到单独的扩展表中,查询时一定不要使用 select *而只需要取出必要的列,不需要 TEXT 列的数据时不要对该列进行查询。\n2、TEXT 或 BLOB 类型只能使用前缀索引\n因为 MySQL 对索引字段长度是有限制的,所以 TEXT 类型只能使用前缀索引,并且 TEXT 列上是不能有默认值的\n避免使用 ENUM 类型 # 修改 ENUM 值需要使用 ALTER 语句; ENUM 类型的 ORDER BY 操作效率低,需要额外操作; ENUM 数据类型存在一些限制比如建议不要使用数值作为 ENUM 的枚举值。 相关阅读: 是否推荐使用 MySQL 的 enum 类型? - 架构文摘 - 知乎 。\n尽可能把所有列定义为 NOT NULL # 除非有特别的原因使用 NULL 值,应该总是让字段保持 NOT NULL。\n索引 NULL 列需要额外的空间来保存,所以要占用更多的空间; 进行比较和计算时要对 NULL 值做特别的处理。 相关阅读: 技术分享 | MySQL 默认值选型(是空,还是 NULL) 。\n一定不要用字符串存储日期 # 对于日期类型来说, 一定不要用字符串存储日期。可以考虑 DATETIME、TIMESTAMP 和 数值型时间戳。\n这三种种方式都有各自的优势,根据实际场景选择最合适的才是王道。下面再对这三种方式做一个简单的对比,以供大家实际开发中选择正确的存放时间的数据类型:\n类型 存储空间 日期格式 日期范围 是否带时区信息 DATETIME 5~8 字节 YYYY-MM-DD hh:mm:ss[.fraction] 1000-01-01 00:00:00[.000000] ~ 9999-12-31 23:59:59[.999999] 否 TIMESTAMP 4~7 字节 YYYY-MM-DD hh:mm:ss[.fraction] 1970-01-01 00:00:01[.000000] ~ 2038-01-19 03:14:07[.999999] 是 数值型时间戳 4 字节 全数字如 1578707612 1970-01-01 00:00:01 之后的时间 否 MySQL 时间类型选择的详细介绍请看这篇: MySQL 时间类型数据存储建议。\n同财务相关的金额类数据必须使用 decimal 类型 # 非精准浮点:float,double 精准浮点:decimal decimal 类型为精准浮点数,在计算时不会丢失精度。占用空间由定义的宽度决定,每 4 个字节可以存储 9 位数字,并且小数点要占用一个字节。并且,decimal 可用于存储比 bigint 更大的整型数据\n不过, 由于 decimal 需要额外的空间和计算开销,应该尽量只在需要对数据进行精确计算时才使用 decimal 。\n单表不要包含过多字段 # 如果一个表包含过多字段的话,可以考虑将其分解成多个表,必要时增加中间表进行关联。\n索引设计规范 # 限制每张表上的索引数量,建议单张表索引不超过 5 个 # 索引并不是越多越好!索引可以提高效率同样可以降低效率。\n索引可以增加查询效率,但同样也会降低插入和更新的效率,甚至有些情况下会降低查询效率。\n因为 MySQL 优化器在选择如何优化查询时,会根据统一信息,对每一个可以用到的索引来进行评估,以生成出一个最好的执行计划,如果同时有很多个索引都可以用于查询,就会增加 MySQL 优化器生成执行计划的时间,同样会降低查询性能。\n禁止使用全文索引 # 全文索引不适用于 OLTP 场景。\n禁止给表中的每一列都建立单独的索引 # 5.6 版本之前,一个 sql 只能使用到一个表中的一个索引,5.6 以后,虽然有了合并索引的优化方式,但是还是远远没有使用一个联合索引的查询方式好。\n每个 InnoDB 表必须有个主键 # InnoDB 是一种索引组织表:数据的存储的逻辑顺序和索引的顺序是相同的。每个表都可以有多个索引,但是表的存储顺序只能有一种。\nInnoDB 是按照主键索引的顺序来组织表的\n不要使用更新频繁的列作为主键,不使用多列主键(相当于联合索引) 不要使用 UUID,MD5,HASH,字符串列作为主键(无法保证数据的顺序增长) 主键建议使用自增 ID 值 常见索引列建议 # 出现在 SELECT、UPDATE、DELETE 语句的 WHERE 从句中的列 包含在 ORDER BY、GROUP BY、DISTINCT 中的字段 并不要将符合 1 和 2 中的字段的列都建立一个索引, 通常将 1、2 中的字段建立联合索引效果更好 多表 join 的关联列 如何选择索引列的顺序 # 建立索引的目的是:希望通过索引进行数据查找,减少随机 IO,增加查询性能 ,索引能过滤出越少的数据,则从磁盘中读入的数据也就越少。\n区分度最高的列放在联合索引的最左侧: 这是最重要的原则。区分度越高,通过索引筛选出的数据就越少,I/O 操作也就越少。计算区分度的方法是 count(distinct column) / count(*)。 最频繁使用的列放在联合索引的左侧: 这符合最左前缀匹配原则。将最常用的查询条件列放在最左侧,可以最大程度地利用索引。 字段长度: 字段长度对联合索引非叶子节点的影响很小,因为它存储了所有联合索引字段的值。字段长度主要影响主键和包含在其他索引中的字段的存储空间,以及这些索引的叶子节点的大小。因此,在选择联合索引列的顺序时,字段长度的优先级最低。 对于主键和包含在其他索引中的字段,选择较短的字段长度可以节省存储空间和提高 I/O 性能。 避免建立冗余索引和重复索引(增加了查询优化器生成执行计划的时间) # 重复索引示例:primary key(id)、index(id)、unique index(id) 冗余索引示例:index(a,b,c)、index(a,b)、index(a) 对于频繁的查询优先考虑使用覆盖索引 # 覆盖索引:就是包含了所有查询字段 (where,select,order by,group by 包含的字段) 的索引\n覆盖索引的好处:\n避免 InnoDB 表进行索引的二次查询,也就是回表操作: InnoDB 是以聚集索引的顺序来存储的,对于 InnoDB 来说,二级索引在叶子节点中所保存的是行的主键信息,如果是用二级索引查询数据的话,在查找到相应的键值后,还要通过主键进行二次查询才能获取我们真实所需要的数据。而在覆盖索引中,二级索引的键值中可以获取所有的数据,避免了对主键的二次查询(回表),减少了 IO 操作,提升了查询效率。 可以把随机 IO 变成顺序 IO 加快查询效率: 由于覆盖索引是按键值的顺序存储的,对于 IO 密集型的范围查找来说,对比随机从磁盘读取每一行的数据 IO 要少的多,因此利用覆盖索引在访问时也可以把磁盘的随机读取的 IO 转变成索引查找的顺序 IO。 索引 SET 规范 # 尽量避免使用外键约束\n不建议使用外键约束(foreign key),但一定要在表与表之间的关联键上建立索引 外键可用于保证数据的参照完整性,但建议在业务端实现 外键会影响父表和子表的写操作从而降低性能 数据库 SQL 开发规范 # 尽量不在数据库做运算,复杂运算需移到业务应用里完成 # 尽量不在数据库做运算,复杂运算需移到业务应用里完成。这样可以避免数据库的负担过重,影响数据库的性能和稳定性。数据库的主要作用是存储和管理数据,而不是处理数据。\n优化对性能影响较大的 SQL 语句 # 要找到最需要优化的 SQL 语句。要么是使用最频繁的语句,要么是优化后提高最明显的语句,可以通过查询 MySQL 的慢查询日志来发现需要进行优化的 SQL 语句。\n充分利用表上已经存在的索引 # 避免使用双%号的查询条件。如:a like '%123%',(如果无前置%,只有后置%,是可以用到列上的索引的)\n一个 SQL 只能利用到复合索引中的一列进行范围查询。如:有 a,b,c 列的联合索引,在查询条件中有 a 列的范围查询,则在 b,c 列上的索引将不会被用到。\n在定义联合索引时,如果 a 列要用到范围查找的话,就要把 a 列放到联合索引的右侧,使用 left join 或 not exists 来优化 not in 操作,因为 not in 也通常会使用索引失效。\n禁止使用 SELECT * 必须使用 SELECT \u0026lt;字段列表\u0026gt; 查询 # SELECT * 会消耗更多的 CPU。 SELECT * 无用字段增加网络带宽资源消耗,增加数据传输时间,尤其是大字段(如 varchar、blob、text)。 SELECT * 无法使用 MySQL 优化器覆盖索引的优化(基于 MySQL 优化器的“覆盖索引”策略又是速度极快,效率极高,业界极为推荐的查询优化方式) SELECT \u0026lt;字段列表\u0026gt; 可减少表结构变更带来的影响、 禁止使用不含字段列表的 INSERT 语句 # 如:\ninsert into t values (\u0026#39;a\u0026#39;,\u0026#39;b\u0026#39;,\u0026#39;c\u0026#39;); 应使用:\ninsert into t(c1,c2,c3) values (\u0026#39;a\u0026#39;,\u0026#39;b\u0026#39;,\u0026#39;c\u0026#39;); 建议使用预编译语句进行数据库操作 # 预编译语句可以重复使用这些计划,减少 SQL 编译所需要的时间,还可以解决动态 SQL 所带来的 SQL 注入的问题。 只传参数,比传递 SQL 语句更高效。 相同语句可以一次解析,多次使用,提高处理效率。 避免数据类型的隐式转换 # 隐式转换会导致索引失效如:\nselect name,phone from customer where id = \u0026#39;111\u0026#39;; 详细解读可以看: MySQL 中的隐式转换造成的索引失效 这篇文章。\n避免使用子查询,可以把子查询优化为 join 操作 # 通常子查询在 in 子句中,且子查询中为简单 SQL(不包含 union、group by、order by、limit 从句) 时,才可以把子查询转化为关联查询进行优化。\n子查询性能差的原因: 子查询的结果集无法使用索引,通常子查询的结果集会被存储到临时表中,不论是内存临时表还是磁盘临时表都不会存在索引,所以查询性能会受到一定的影响。特别是对于返回结果集比较大的子查询,其对查询性能的影响也就越大。由于子查询会产生大量的临时表也没有索引,所以会消耗过多的 CPU 和 IO 资源,产生大量的慢查询。\n避免使用 JOIN 关联太多的表 # 对于 MySQL 来说,是存在关联缓存的,缓存的大小可以由 join_buffer_size 参数进行设置。\n在 MySQL 中,对于同一个 SQL 多关联(join)一个表,就会多分配一个关联缓存,如果在一个 SQL 中关联的表越多,所占用的内存也就越大。\n如果程序中大量的使用了多表关联的操作,同时 join_buffer_size 设置的也不合理的情况下,就容易造成服务器内存溢出的情况,就会影响到服务器数据库性能的稳定性。\n同时对于关联操作来说,会产生临时表操作,影响查询效率,MySQL 最多允许关联 61 个表,建议不超过 5 个。\n减少同数据库的交互次数 # 数据库更适合处理批量操作,合并多个相同的操作到一起,可以提高处理效率。\n对应同一列进行 or 判断时,使用 in 代替 or # in 的值不要超过 500 个,in 操作可以更有效的利用索引,or 大多数情况下很少能利用到索引。\n禁止使用 order by rand() 进行随机排序 # order by rand() 会把表中所有符合条件的数据装载到内存中,然后在内存中对所有数据根据随机生成的值进行排序,并且可能会对每一行都生成一个随机值,如果满足条件的数据集非常大,就会消耗大量的 CPU 和 IO 及内存资源。\n推荐在程序中获取一个随机值,然后从数据库中获取数据的方式。\nWHERE 从句中禁止对列进行函数转换和计算 # 对列进行函数转换或计算时会导致无法使用索引\n不推荐:\nwhere date(create_time)=\u0026#39;20190101\u0026#39; 推荐:\nwhere create_time \u0026gt;= \u0026#39;20190101\u0026#39; and create_time \u0026lt; \u0026#39;20190102\u0026#39; 在明显不会有重复值时使用 UNION ALL 而不是 UNION # UNION 会把两个结果集的所有数据放到临时表中后再进行去重操作 UNION ALL 不会再对结果集进行去重操作 拆分复杂的大 SQL 为多个小 SQL # 大 SQL 逻辑上比较复杂,需要占用大量 CPU 进行计算的 SQL MySQL 中,一个 SQL 只能使用一个 CPU 进行计算 SQL 拆分后可以通过并行执行来提高处理效率 程序连接不同的数据库使用不同的账号,禁止跨库查询 # 为数据库迁移和分库分表留出余地 降低业务耦合度 避免权限过大而产生的安全风险 数据库操作行为规范 # 超 100 万行的批量写 (UPDATE,DELETE,INSERT) 操作,要分批多次进行操作 # 大批量操作可能会造成严重的主从延迟\n主从环境中,大批量操作可能会造成严重的主从延迟,大批量的写操作一般都需要执行一定长的时间,而只有当主库上执行完成后,才会在其他从库上执行,所以会造成主库与从库长时间的延迟情况\nbinlog 日志为 row 格式时会产生大量的日志\n大批量写操作会产生大量日志,特别是对于 row 格式二进制数据而言,由于在 row 格式中会记录每一行数据的修改,我们一次修改的数据越多,产生的日志量也就会越多,日志的传输和恢复所需要的时间也就越长,这也是造成主从延迟的一个原因\n避免产生大事务操作\n大批量修改数据,一定是在一个事务中进行的,这就会造成表中大批量数据进行锁定,从而导致大量的阻塞,阻塞会对 MySQL 的性能产生非常大的影响。\n特别是长时间的阻塞会占满所有数据库的可用连接,这会使生产环境中的其他应用无法连接到数据库,因此一定要注意大批量写操作要进行分批\n对于大表使用 pt-online-schema-change 修改表结构 # 避免大表修改产生的主从延迟 避免在对表字段进行修改时进行锁表 对大表数据结构的修改一定要谨慎,会造成严重的锁表操作,尤其是生产环境,是不能容忍的。\npt-online-schema-change 它会首先建立一个与原表结构相同的新表,并且在新表上进行表结构的修改,然后再把原表中的数据复制到新表中,并在原表中增加一些触发器。把原表中新增的数据也复制到新表中,在行所有数据复制完成之后,把新表命名成原表,并把原来的表删除掉。把原来一个 DDL 操作,分解成多个小的批次进行。\n禁止为程序使用的账号赋予 super 权限 # 当达到最大连接数限制时,还运行 1 个有 super 权限的用户连接 super 权限只能留给 DBA 处理问题的账号使用 对于程序连接数据库账号,遵循权限最小原则 # 程序使用数据库账号只能在一个 DB 下使用,不准跨库 程序使用的账号原则上不准有 drop 权限 推荐阅读 # 技术同学必会的 MySQL 设计规约,都是惨痛的教训 - 阿里开发者 聊聊数据库建表的 15 个小技巧 "},{"id":541,"href":"/zh/docs/technology/Interview/database/mysql/some-thoughts-on-database-storage-time/","title":"MySQL日期类型选择建议","section":"Mysql","content":"我们平时开发中不可避免的就是要存储时间,比如我们要记录操作表中这条记录的时间、记录转账的交易时间、记录出发时间、用户下单时间等等。你会发现时间这个东西与我们开发的联系还是非常紧密的,用的好与不好会给我们的业务甚至功能带来很大的影响。所以,我们有必要重新出发,好好认识一下这个东西。\n不要用字符串存储日期 # 和绝大部分对数据库不太了解的新手一样,我在大学的时候就这样干过,甚至认为这样是一个不错的表示日期的方法。毕竟简单直白,容易上手。\n但是,这是不正确的做法,主要会有下面两个问题:\n字符串占用的空间更大! 字符串存储的日期效率比较低(逐个字符进行比对),无法用日期相关的 API 进行计算和比较。 Datetime 和 Timestamp 之间的抉择 # Datetime 和 Timestamp 是 MySQL 提供的两种比较相似的保存时间的数据类型,可以精确到秒。他们两者究竟该如何选择呢?\n下面我们来简单对比一下二者。\n时区信息 # DateTime 类型是没有时区信息的(时区无关) ,DateTime 类型保存的时间都是当前会话所设置的时区对应的时间。这样就会有什么问题呢?当你的时区更换之后,比如你的服务器更换地址或者更换客户端连接时区设置的话,就会导致你从数据库中读出的时间错误。\nTimestamp 和时区有关。Timestamp 类型字段的值会随着服务器时区的变化而变化,自动换算成相应的时间,说简单点就是在不同时区,查询到同一个条记录此字段的值会不一样。\n下面实际演示一下!\n建表 SQL 语句:\nCREATE TABLE `time_zone_test` ( `id` bigint(20) NOT NULL AUTO_INCREMENT, `date_time` datetime DEFAULT NULL, `time_stamp` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, PRIMARY KEY (`id`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8; 插入数据:\nINSERT INTO time_zone_test(date_time,time_stamp) VALUES(NOW(),NOW()); 查看数据:\nselect date_time,time_stamp from time_zone_test; 结果:\n+---------------------+---------------------+ | date_time | time_stamp | +---------------------+---------------------+ | 2020-01-11 09:53:32 | 2020-01-11 09:53:32 | +---------------------+---------------------+ 现在我们运行\n修改当前会话的时区:\nset time_zone=\u0026#39;+8:00\u0026#39;; 再次查看数据:\n+---------------------+---------------------+ | date_time | time_stamp | +---------------------+---------------------+ | 2020-01-11 09:53:32 | 2020-01-11 17:53:32 | +---------------------+---------------------+ 扩展:一些关于 MySQL 时区设置的一个常用 sql 命令\n# 查看当前会话时区 SELECT @@session.time_zone; # 设置当前会话时区 SET time_zone = \u0026#39;Europe/Helsinki\u0026#39;; SET time_zone = \u0026#34;+00:00\u0026#34;; # 数据库全局时区设置 SELECT @@global.time_zone; # 设置全局时区 SET GLOBAL time_zone = \u0026#39;+8:00\u0026#39;; SET GLOBAL time_zone = \u0026#39;Europe/Helsinki\u0026#39;; 占用空间 # 下图是 MySQL 日期类型所占的存储空间(官方文档传送门: https://dev.mysql.com/doc/refman/8.0/en/storage-requirements.html):\n在 MySQL 5.6.4 之前,DateTime 和 Timestamp 的存储空间是固定的,分别为 8 字节和 4 字节。但是从 MySQL 5.6.4 开始,它们的存储空间会根据毫秒精度的不同而变化,DateTime 的范围是 58 字节,Timestamp 的范围是 47 字节。\n表示范围 # Timestamp 表示的时间范围更小,只能到 2038 年:\nDateTime:1000-01-01 00:00:00.000000 ~ 9999-12-31 23:59:59.499999 Timestamp:1970-01-01 00:00:01.000000 ~ 2038-01-19 03:14:07.499999 性能 # 由于 TIMESTAMP 需要根据时区进行转换,所以从毫秒数转换到 TIMESTAMP 时,不仅要调用一个简单的函数,还要调用操作系统底层的系统函数。这个系统函数为了保证操作系统时区的一致性,需要进行加锁操作,这就降低了效率。\nDATETIME 不涉及时区转换,所以不会有这个问题。\n为了避免 TIMESTAMP 的时区转换问题,建议使用指定的时区,而不是依赖于操作系统时区。\n数值时间戳是更好的选择吗? # 很多时候,我们也会使用 int 或者 bigint 类型的数值也就是数值时间戳来表示时间。\n这种存储方式的具有 Timestamp 类型的所具有一些优点,并且使用它的进行日期排序以及对比等操作的效率会更高,跨系统也很方便,毕竟只是存放的数值。缺点也很明显,就是数据的可读性太差了,你无法直观的看到具体时间。\n时间戳的定义如下:\n时间戳的定义是从一个基准时间开始算起,这个基准时间是「1970-1-1 00:00:00 +0:00」,从这个时间开始,用整数表示,以秒计时,随着时间的流逝这个时间整数不断增加。这样一来,我只需要一个数值,就可以完美地表示时间了,而且这个数值是一个绝对数值,即无论的身处地球的任何角落,这个表示时间的时间戳,都是一样的,生成的数值都是一样的,并且没有时区的概念,所以在系统的中时间的传输中,都不需要进行额外的转换了,只有在显示给用户的时候,才转换为字符串格式的本地时间。\n数据库中实际操作:\nmysql\u0026gt; select UNIX_TIMESTAMP(\u0026#39;2020-01-11 09:53:32\u0026#39;); +---------------------------------------+ | UNIX_TIMESTAMP(\u0026#39;2020-01-11 09:53:32\u0026#39;) | +---------------------------------------+ | 1578707612 | +---------------------------------------+ 1 row in set (0.00 sec) mysql\u0026gt; select FROM_UNIXTIME(1578707612); +---------------------------+ | FROM_UNIXTIME(1578707612) | +---------------------------+ | 2020-01-11 09:53:32 | +---------------------------+ 1 row in set (0.01 sec) 总结 # MySQL 中时间到底怎么存储才好?Datetime?Timestamp?还是数值时间戳?\n并没有一个银弹,很多程序员会觉得数值型时间戳是真的好,效率又高还各种兼容,但是很多人又觉得它表现的不够直观。\n《高性能 MySQL 》这本神书的作者就是推荐 Timestamp,原因是数值表示时间不够直观。下面是原文:\n每种方式都有各自的优势,根据实际场景选择最合适的才是王道。下面再对这三种方式做一个简单的对比,以供大家实际开发中选择正确的存放时间的数据类型:\n类型 存储空间 日期格式 日期范围 是否带时区信息 DATETIME 5~8 字节 YYYY-MM-DD hh:mm:ss[.fraction] 1000-01-01 00:00:00[.000000] ~ 9999-12-31 23:59:59[.999999] 否 TIMESTAMP 4~7 字节 YYYY-MM-DD hh:mm:ss[.fraction] 1970-01-01 00:00:01[.000000] ~ 2038-01-19 03:14:07[.999999] 是 数值型时间戳 4 字节 全数字如 1578707612 1970-01-01 00:00:01 之后的时间 否 "},{"id":542,"href":"/zh/docs/technology/Interview/database/mysql/mysql-logs/","title":"MySQL三大日志(binlog、redo log和undo log)详解","section":"Mysql","content":" 本文来自公号程序猿阿星投稿,JavaGuide 对其做了补充完善。\n前言 # MySQL 日志 主要包括错误日志、查询日志、慢查询日志、事务日志、二进制日志几大类。其中,比较重要的还要属二进制日志 binlog(归档日志)和事务日志 redo log(重做日志)和 undo log(回滚日志)。\n今天就来聊聊 redo log(重做日志)、binlog(归档日志)、两阶段提交、undo log(回滚日志)。\nredo log # redo log(重做日志)是 InnoDB 存储引擎独有的,它让 MySQL 拥有了崩溃恢复能力。\n比如 MySQL 实例挂了或宕机了,重启时,InnoDB 存储引擎会使用 redo log 恢复数据,保证数据的持久性与完整性。\nMySQL 中数据是以页为单位,你查询一条记录,会从硬盘把一页的数据加载出来,加载出来的数据叫数据页,会放入到 Buffer Pool 中。\n后续的查询都是先从 Buffer Pool 中找,没有命中再去硬盘加载,减少硬盘 IO 开销,提升性能。\n更新表数据的时候,也是如此,发现 Buffer Pool 里存在要更新的数据,就直接在 Buffer Pool 里更新。\n然后会把“在某个数据页上做了什么修改”记录到重做日志缓存(redo log buffer)里,接着刷盘到 redo log 文件里。\n图片笔误提示:第 4 步 “清空 redo log buffe 刷盘到 redo 日志中”这句话中的 buffe 应该是 buffer。\n理想情况,事务一提交就会进行刷盘操作,但实际上,刷盘的时机是根据策略来进行的。\n小贴士:每条 redo 记录由“表空间号+数据页号+偏移量+修改数据长度+具体修改的数据”组成\n刷盘时机 # InnoDB 刷新重做日志的时机有几种情况:\nInnoDB 将 redo log 刷到磁盘上有几种情况:\n事务提交:当事务提交时,log buffer 里的 redo log 会被刷新到磁盘(可以通过innodb_flush_log_at_trx_commit参数控制,后文会提到)。 log buffer 空间不足时:log buffer 中缓存的 redo log 已经占满了 log buffer 总容量的大约一半左右,就需要把这些日志刷新到磁盘上。 事务日志缓冲区满:InnoDB 使用一个事务日志缓冲区(transaction log buffer)来暂时存储事务的重做日志条目。当缓冲区满时,会触发日志的刷新,将日志写入磁盘。 Checkpoint(检查点):InnoDB 定期会执行检查点操作,将内存中的脏数据(已修改但尚未写入磁盘的数据)刷新到磁盘,并且会将相应的重做日志一同刷新,以确保数据的一致性。 后台刷新线程:InnoDB 启动了一个后台线程,负责周期性(每隔 1 秒)地将脏页(已修改但尚未写入磁盘的数据页)刷新到磁盘,并将相关的重做日志一同刷新。 正常关闭服务器:MySQL 关闭的时候,redo log 都会刷入到磁盘里去。 总之,InnoDB 在多种情况下会刷新重做日志,以保证数据的持久性和一致性。\n我们要注意设置正确的刷盘策略innodb_flush_log_at_trx_commit 。根据 MySQL 配置的刷盘策略的不同,MySQL 宕机之后可能会存在轻微的数据丢失问题。\ninnodb_flush_log_at_trx_commit 的值有 3 种,也就是共有 3 种刷盘策略:\n0:设置为 0 的时候,表示每次事务提交时不进行刷盘操作。这种方式性能最高,但是也最不安全,因为如果 MySQL 挂了或宕机了,可能会丢失最近 1 秒内的事务。 1:设置为 1 的时候,表示每次事务提交时都将进行刷盘操作。这种方式性能最低,但是也最安全,因为只要事务提交成功,redo log 记录就一定在磁盘里,不会有任何数据丢失。 2:设置为 2 的时候,表示每次事务提交时都只把 log buffer 里的 redo log 内容写入 page cache(文件系统缓存)。page cache 是专门用来缓存文件的,这里被缓存的文件就是 redo log 文件。这种方式的性能和安全性都介于前两者中间。 刷盘策略innodb_flush_log_at_trx_commit 的默认值为 1,设置为 1 的时候才不会丢失任何数据。为了保证事务的持久性,我们必须将其设置为 1。\n另外,InnoDB 存储引擎有一个后台线程,每隔1 秒,就会把 redo log buffer 中的内容写到文件系统缓存(page cache),然后调用 fsync 刷盘。\n也就是说,一个没有提交事务的 redo log 记录,也可能会刷盘。\n为什么呢?\n因为在事务执行过程 redo log 记录是会写入redo log buffer 中,这些 redo log 记录会被后台线程刷盘。\n除了后台线程每秒1次的轮询操作,还有一种情况,当 redo log buffer 占用的空间即将达到 innodb_log_buffer_size 一半的时候,后台线程会主动刷盘。\n下面是不同刷盘策略的流程图。\ninnodb_flush_log_at_trx_commit=0 # 为0时,如果 MySQL 挂了或宕机可能会有1秒数据的丢失。\ninnodb_flush_log_at_trx_commit=1 # 为1时, 只要事务提交成功,redo log 记录就一定在硬盘里,不会有任何数据丢失。\n如果事务执行期间 MySQL 挂了或宕机,这部分日志丢了,但是事务并没有提交,所以日志丢了也不会有损失。\ninnodb_flush_log_at_trx_commit=2 # 为2时, 只要事务提交成功,redo log buffer中的内容只写入文件系统缓存(page cache)。\n如果仅仅只是 MySQL 挂了不会有任何数据丢失,但是宕机可能会有1秒数据的丢失。\n日志文件组 # 硬盘上存储的 redo log 日志文件不只一个,而是以一个日志文件组的形式出现的,每个的redo日志文件大小都是一样的。\n比如可以配置为一组4个文件,每个文件的大小是 1GB,整个 redo log 日志文件组可以记录4G的内容。\n它采用的是环形数组形式,从头开始写,写到末尾又回到头循环写,如下图所示。\n在这个日志文件组中还有两个重要的属性,分别是 write pos、checkpoint\nwrite pos 是当前记录的位置,一边写一边后移 checkpoint 是当前要擦除的位置,也是往后推移 每次刷盘 redo log 记录到日志文件组中,write pos 位置就会后移更新。\n每次 MySQL 加载日志文件组恢复数据时,会清空加载过的 redo log 记录,并把 checkpoint 后移更新。\nwrite pos 和 checkpoint 之间的还空着的部分可以用来写入新的 redo log 记录。\n如果 write pos 追上 checkpoint ,表示日志文件组满了,这时候不能再写入新的 redo log 记录,MySQL 得停下来,清空一些记录,把 checkpoint 推进一下。\n注意从 MySQL 8.0.30 开始,日志文件组有了些许变化:\nThe innodb_redo_log_capacity variable supersedes the innodb_log_files_in_group and innodb_log_file_size variables, which are deprecated. When the innodb_redo_log_capacity setting is defined, the innodb_log_files_in_group and innodb_log_file_size settings are ignored; otherwise, these settings are used to compute the innodb_redo_log_capacity setting (innodb_log_files_in_group * innodb_log_file_size = innodb_redo_log_capacity). If none of those variables are set, redo log capacity is set to the innodb_redo_log_capacity default value, which is 104857600 bytes (100MB). The maximum redo log capacity is 128GB.\nRedo log files reside in the #innodb_redo directory in the data directory unless a different directory was specified by the innodb_log_group_home_dir variable. If innodb_log_group_home_dir was defined, the redo log files reside in the #innodb_redo directory in that directory. There are two types of redo log files, ordinary and spare. Ordinary redo log files are those being used. Spare redo log files are those waiting to be used. InnoDB tries to maintain 32 redo log files in total, with each file equal in size to 1/32 * innodb_redo_log_capacity; however, file sizes may differ for a time after modifying the innodb_redo_log_capacity setting.\n意思是在 MySQL 8.0.30 之前可以通过 innodb_log_files_in_group 和 innodb_log_file_size 配置日志文件组的文件数和文件大小,但在 MySQL 8.0.30 及之后的版本中,这两个变量已被废弃,即使被指定也是用来计算 innodb_redo_log_capacity 的值。而日志文件组的文件数则固定为 32,文件大小则为 innodb_redo_log_capacity / 32 。\n关于这一点变化,我们可以验证一下。\n首先创建一个配置文件,里面配置一下 innodb_log_files_in_group 和 innodb_log_file_size 的值:\n[mysqld] innodb_log_file_size = 10485760 innodb_log_files_in_group = 64 docker 启动一个 MySQL 8.0.32 的容器:\ndocker run -d -p 3312:3309 -e MYSQL_ROOT_PASSWORD=your-password -v /path/to/your/conf:/etc/mysql/conf.d --name MySQL830 mysql:8.0.32 现在我们来看一下启动日志:\n2023-08-03T02:05:11.720357Z 0 [Warning] [MY-013907] [InnoDB] Deprecated configuration parameters innodb_log_file_size and/or innodb_log_files_in_group have been used to compute innodb_redo_log_capacity=671088640. Please use innodb_redo_log_capacity instead. 这里也表明了 innodb_log_files_in_group 和 innodb_log_file_size 这两个变量是用来计算 innodb_redo_log_capacity ,且已经被废弃。\n我们再看下日志文件组的文件数是多少:\n可以看到刚好是 32 个,并且每个日志文件的大小是 671088640 / 32 = 20971520\n所以在使用 MySQL 8.0.30 及之后的版本时,推荐使用 innodb_redo_log_capacity 变量配置日志文件组\nredo log 小结 # 相信大家都知道 redo log 的作用和它的刷盘时机、存储形式。\n现在我们来思考一个问题:只要每次把修改后的数据页直接刷盘不就好了,还有 redo log 什么事?\n它们不都是刷盘么?差别在哪里?\n1 Byte = 8bit 1 KB = 1024 Byte 1 MB = 1024 KB 1 GB = 1024 MB 1 TB = 1024 GB 实际上,数据页大小是16KB,刷盘比较耗时,可能就修改了数据页里的几 Byte 数据,有必要把完整的数据页刷盘吗?\n而且数据页刷盘是随机写,因为一个数据页对应的位置可能在硬盘文件的随机位置,所以性能是很差。\n如果是写 redo log,一行记录可能就占几十 Byte,只包含表空间号、数据页号、磁盘文件偏移 量、更新值,再加上是顺序写,所以刷盘速度很快。\n所以用 redo log 形式记录修改内容,性能会远远超过刷数据页的方式,这也让数据库的并发能力更强。\n其实内存的数据页在一定时机也会刷盘,我们把这称为页合并,讲 Buffer Pool的时候会对这块细说\nbinlog # redo log 它是物理日志,记录内容是“在某个数据页上做了什么修改”,属于 InnoDB 存储引擎。\n而 binlog 是逻辑日志,记录内容是语句的原始逻辑,类似于“给 ID=2 这一行的 c 字段加 1”,属于MySQL Server 层。\n不管用什么存储引擎,只要发生了表数据更新,都会产生 binlog 日志。\n那 binlog 到底是用来干嘛的?\n可以说 MySQL 数据库的数据备份、主备、主主、主从都离不开 binlog,需要依靠 binlog 来同步数据,保证数据一致性。\nbinlog 会记录所有涉及更新数据的逻辑操作,并且是顺序写。\n记录格式 # binlog 日志有三种格式,可以通过binlog_format参数指定。\nstatement row mixed 指定statement,记录的内容是SQL语句原文,比如执行一条update T set update_time=now() where id=1,记录的内容如下。\n同步数据时,会执行记录的SQL语句,但是有个问题,update_time=now()这里会获取当前系统时间,直接执行会导致与原库的数据不一致。\n为了解决这种问题,我们需要指定为row,记录的内容不再是简单的SQL语句了,还包含操作的具体数据,记录内容如下。\nrow格式记录的内容看不到详细信息,要通过mysqlbinlog工具解析出来。\nupdate_time=now()变成了具体的时间update_time=1627112756247,条件后面的@1、@2、@3 都是该行数据第 1 个~3 个字段的原始值(假设这张表只有 3 个字段)。\n这样就能保证同步数据的一致性,通常情况下都是指定为row,这样可以为数据库的恢复与同步带来更好的可靠性。\n但是这种格式,需要更大的容量来记录,比较占用空间,恢复与同步时会更消耗 IO 资源,影响执行速度。\n所以就有了一种折中的方案,指定为mixed,记录的内容是前两者的混合。\nMySQL 会判断这条SQL语句是否可能引起数据不一致,如果是,就用row格式,否则就用statement格式。\n写入机制 # binlog 的写入时机也非常简单,事务执行过程中,先把日志写到binlog cache,事务提交的时候,再把binlog cache写到 binlog 文件中。\n因为一个事务的 binlog 不能被拆开,无论这个事务多大,也要确保一次性写入,所以系统会给每个线程分配一个块内存作为binlog cache。\n我们可以通过binlog_cache_size参数控制单个线程 binlog cache 大小,如果存储内容超过了这个参数,就要暂存到磁盘(Swap)。\nbinlog 日志刷盘流程如下\n上图的 write,是指把日志写入到文件系统的 page cache,并没有把数据持久化到磁盘,所以速度比较快 上图的 fsync,才是将数据持久化到磁盘的操作 write和fsync的时机,可以由参数sync_binlog控制,默认是1。\n为0的时候,表示每次提交事务都只write,由系统自行判断什么时候执行fsync。\n虽然性能得到提升,但是机器宕机,page cache里面的 binlog 会丢失。\n为了安全起见,可以设置为1,表示每次提交事务都会执行fsync,就如同 redo log 日志刷盘流程 一样。\n最后还有一种折中方式,可以设置为N(N\u0026gt;1),表示每次提交事务都write,但累积N个事务后才fsync。\n在出现 IO 瓶颈的场景里,将sync_binlog设置成一个比较大的值,可以提升性能。\n同样的,如果机器宕机,会丢失最近N个事务的 binlog 日志。\n两阶段提交 # redo log(重做日志)让 InnoDB 存储引擎拥有了崩溃恢复能力。\nbinlog(归档日志)保证了 MySQL 集群架构的数据一致性。\n虽然它们都属于持久化的保证,但是侧重点不同。\n在执行更新语句过程,会记录 redo log 与 binlog 两块日志,以基本的事务为单位,redo log 在事务执行过程中可以不断写入,而 binlog 只有在提交事务时才写入,所以 redo log 与 binlog 的写入时机不一样。\n回到正题,redo log 与 binlog 两份日志之间的逻辑不一致,会出现什么问题?\n我们以update语句为例,假设id=2的记录,字段c值是0,把字段c值更新成1,SQL语句为update T set c=1 where id=2。\n假设执行过程中写完 redo log 日志后,binlog 日志写期间发生了异常,会出现什么情况呢?\n由于 binlog 没写完就异常,这时候 binlog 里面没有对应的修改记录。因此,之后用 binlog 日志恢复数据时,就会少这一次更新,恢复出来的这一行c值是0,而原库因为 redo log 日志恢复,这一行c值是1,最终数据不一致。\n为了解决两份日志之间的逻辑一致问题,InnoDB 存储引擎使用两阶段提交方案。\n原理很简单,将 redo log 的写入拆成了两个步骤prepare和commit,这就是两阶段提交。\n使用两阶段提交后,写入 binlog 时发生异常也不会有影响,因为 MySQL 根据 redo log 日志恢复数据时,发现 redo log 还处于prepare阶段,并且没有对应 binlog 日志,就会回滚该事务。\n再看一个场景,redo log 设置commit阶段发生异常,那会不会回滚事务呢?\n并不会回滚事务,它会执行上图框住的逻辑,虽然 redo log 是处于prepare阶段,但是能通过事务id找到对应的 binlog 日志,所以 MySQL 认为是完整的,就会提交事务恢复数据。\nundo log # 这部分内容为 JavaGuide 的补充:\n每一个事务对数据的修改都会被记录到 undo log ,当执行事务过程中出现错误或者需要执行回滚操作的话,MySQL 可以利用 undo log 将数据恢复到事务开始之前的状态。\nundo log 属于逻辑日志,记录的是 SQL 语句,比如说事务执行一条 DELETE 语句,那 undo log 就会记录一条相对应的 INSERT 语句。同时,undo log 的信息也会被记录到 redo log 中,因为 undo log 也要实现持久性保护。并且,undo-log 本身是会被删除清理的,例如 INSERT 操作,在事务提交之后就可以清除掉了;UPDATE/DELETE 操作在事务提交不会立即删除,会加入 history list,由后台线程 purge 进行清理。\nundo log 是采用 segment(段)的方式来记录的,每个 undo 操作在记录的时候占用一个 undo log segment(undo 日志段),undo log segment 包含在 rollback segment(回滚段)中。事务开始时,需要为其分配一个 rollback segment。每个 rollback segment 有 1024 个 undo log segment,这有助于管理多个并发事务的回滚需求。\n通常情况下, rollback segment header(通常在回滚段的第一个页)负责管理 rollback segment。rollback segment header 是 rollback segment 的一部分,通常在回滚段的第一个页。history list 是 rollback segment header 的一部分,它的主要作用是记录所有已经提交但还没有被清理(purge)的事务的 undo log。这个列表使得 purge 线程能够找到并清理那些不再需要的 undo log 记录。\n另外,MVCC 的实现依赖于:隐藏字段、Read View、undo log。在内部实现中,InnoDB 通过数据行的 DB_TRX_ID 和 Read View 来判断数据的可见性,如不可见,则通过数据行的 DB_ROLL_PTR 找到 undo log 中的历史版本。每个事务读到的数据版本可能是不一样的,在同一个事务中,用户只能看到该事务创建 Read View 之前已经提交的修改和该事务本身做的修改\n总结 # 这部分内容为 JavaGuide 的补充:\nMySQL InnoDB 引擎使用 redo log(重做日志) 保证事务的持久性,使用 undo log(回滚日志) 来保证事务的原子性。\nMySQL 数据库的数据备份、主备、主主、主从都离不开 binlog,需要依靠 binlog 来同步数据,保证数据一致性。\n参考 # 《MySQL 实战 45 讲》 《从零开始带你成为 MySQL 实战优化高手》 《MySQL 是怎样运行的:从根儿上理解 MySQL》 《MySQL 技术 Innodb 存储引擎》 "},{"id":543,"href":"/zh/docs/technology/Interview/database/mysql/transaction-isolation-level/","title":"MySQL事务隔离级别详解","section":"Mysql","content":" 本文由 SnailClimb 和 guang19 共同完成。\n关于事务基本概览的介绍,请看这篇文章的介绍: MySQL 常见知识点\u0026amp;面试题总结\n事务隔离级别总结 # SQL 标准定义了四个隔离级别:\nREAD-UNCOMMITTED(读取未提交) :最低的隔离级别,允许读取尚未提交的数据变更,可能会导致脏读、幻读或不可重复读。 READ-COMMITTED(读取已提交) :允许读取并发事务已经提交的数据,可以阻止脏读,但是幻读或不可重复读仍有可能发生。 REPEATABLE-READ(可重复读) :对同一字段的多次读取结果都是一致的,除非数据是被本身事务自己所修改,可以阻止脏读和不可重复读,但幻读仍有可能发生。 SERIALIZABLE(可串行化) :最高的隔离级别,完全服从 ACID 的隔离级别。所有的事务依次逐个执行,这样事务之间就完全不可能产生干扰,也就是说,该级别可以防止脏读、不可重复读以及幻读。 隔离级别 脏读 不可重复读 幻读 READ-UNCOMMITTED √ √ √ READ-COMMITTED × √ √ REPEATABLE-READ × × √ SERIALIZABLE × × × MySQL InnoDB 存储引擎的默认支持的隔离级别是 REPEATABLE-READ(可重读)。我们可以通过SELECT @@tx_isolation;命令来查看,MySQL 8.0 该命令改为SELECT @@transaction_isolation;\nMySQL\u0026gt; SELECT @@tx_isolation; +-----------------+ | @@tx_isolation | +-----------------+ | REPEATABLE-READ | +-----------------+ 从上面对 SQL 标准定义了四个隔离级别的介绍可以看出,标准的 SQL 隔离级别定义里,REPEATABLE-READ(可重复读)是不可以防止幻读的。\n但是!InnoDB 实现的 REPEATABLE-READ 隔离级别其实是可以解决幻读问题发生的,主要有下面两种情况:\n快照读:由 MVCC 机制来保证不出现幻读。 当前读:使用 Next-Key Lock 进行加锁来保证不出现幻读,Next-Key Lock 是行锁(Record Lock)和间隙锁(Gap Lock)的结合,行锁只能锁住已经存在的行,为了避免插入新行,需要依赖间隙锁。 因为隔离级别越低,事务请求的锁越少,所以大部分数据库系统的隔离级别都是 READ-COMMITTED ,但是你要知道的是 InnoDB 存储引擎默认使用 REPEATABLE-READ 并不会有任何性能损失。\nInnoDB 存储引擎在分布式事务的情况下一般会用到 SERIALIZABLE 隔离级别。\n《MySQL 技术内幕:InnoDB 存储引擎(第 2 版)》7.7 章这样写到:\nInnoDB 存储引擎提供了对 XA 事务的支持,并通过 XA 事务来支持分布式事务的实现。分布式事务指的是允许多个独立的事务资源(transactional resources)参与到一个全局的事务中。事务资源通常是关系型数据库系统,但也可以是其他类型的资源。全局事务要求在其中的所有参与的事务要么都提交,要么都回滚,这对于事务原有的 ACID 要求又有了提高。另外,在使用分布式事务时,InnoDB 存储引擎的事务隔离级别必须设置为 SERIALIZABLE。\n实际情况演示 # 在下面我会使用 2 个命令行 MySQL ,模拟多线程(多事务)对同一份数据的脏读问题。\nMySQL 命令行的默认配置中事务都是自动提交的,即执行 SQL 语句后就会马上执行 COMMIT 操作。如果要显式地开启一个事务需要使用命令:START TRANSACTION。\n我们可以通过下面的命令来设置隔离级别。\nSET [SESSION|GLOBAL] TRANSACTION ISOLATION LEVEL [READ UNCOMMITTED|READ COMMITTED|REPEATABLE READ|SERIALIZABLE] 我们再来看一下我们在下面实际操作中使用到的一些并发控制语句:\nSTART TRANSACTION |BEGIN:显式地开启一个事务。 COMMIT:提交事务,使得对数据库做的所有修改成为永久性。 ROLLBACK:回滚会结束用户的事务,并撤销正在进行的所有未提交的修改。 脏读(读未提交) # 避免脏读(读已提交) # 不可重复读 # 还是刚才上面的读已提交的图,虽然避免了读未提交,但是却出现了,一个事务还没有结束,就发生了 不可重复读问题。\n可重复读 # 幻读 # 演示幻读出现的情况 # SQL 脚本 1 在第一次查询工资为 500 的记录时只有一条,SQL 脚本 2 插入了一条工资为 500 的记录,提交之后;SQL 脚本 1 在同一个事务中再次使用当前读查询发现出现了两条工资为 500 的记录这种就是幻读。\n解决幻读的方法 # 解决幻读的方式有很多,但是它们的核心思想就是一个事务在操作某张表数据的时候,另外一个事务不允许新增或者删除这张表中的数据了。解决幻读的方式主要有以下几种:\n将事务隔离级别调整为 SERIALIZABLE 。 在可重复读的事务级别下,给事务操作的这张表添加表锁。 在可重复读的事务级别下,给事务操作的这张表添加 Next-key Lock(Record Lock+Gap Lock)。 参考 # 《MySQL 技术内幕:InnoDB 存储引擎》 https://dev.MySQL.com/doc/refman/5.7/en/ Mysql 锁:灵魂七拷问 Innodb 中的事务隔离级别和锁的关系 "},{"id":544,"href":"/zh/docs/technology/Interview/database/mysql/mysql-index/","title":"MySQL索引详解","section":"Mysql","content":" 感谢 WT-AHA对本文的完善,相关 PR: https://github.com/Snailclimb/JavaGuide/pull/1648 。\n但凡经历过几场面试的小伙伴,应该都清楚,数据库索引这个知识点在面试中出现的频率高到离谱。\n除了对于准备面试来说非常重要之外,善用索引对 SQL 的性能提升非常明显,是一个性价比较高的 SQL 优化手段。\n索引介绍 # 索引是一种用于快速查询和检索数据的数据结构,其本质可以看成是一种排序好的数据结构。\n索引的作用就相当于书的目录。打个比方: 我们在查字典的时候,如果没有目录,那我们就只能一页一页的去找我们需要查的那个字,速度很慢。如果有目录了,我们只需要先去目录里查找字的位置,然后直接翻到那一页就行了。\n索引底层数据结构存在很多种类型,常见的索引结构有: B 树, B+树 和 Hash、红黑树。在 MySQL 中,无论是 Innodb 还是 MyIsam,都使用了 B+树作为索引结构。\n索引的优缺点 # 优点:\n使用索引可以大大加快数据的检索速度(大大减少检索的数据量), 减少 IO 次数,这也是创建索引的最主要的原因。 通过创建唯一性索引,可以保证数据库表中每一行数据的唯一性。 缺点:\n创建索引和维护索引需要耗费许多时间。当对表中的数据进行增删改的时候,如果数据有索引,那么索引也需要动态的修改,会降低 SQL 执行效率。 索引需要使用物理文件存储,也会耗费一定空间。 但是,使用索引一定能提高查询性能吗?\n大多数情况下,索引查询都是比全表扫描要快的。但是如果数据库的数据量不大,那么使用索引也不一定能够带来很大提升。\n索引底层数据结构选型 # Hash 表 # 哈希表是键值对的集合,通过键(key)即可快速取出对应的值(value),因此哈希表可以快速检索数据(接近 O(1))。\n为何能够通过 key 快速取出 value 呢? 原因在于 哈希算法(也叫散列算法)。通过哈希算法,我们可以快速找到 key 对应的 index,找到了 index 也就找到了对应的 value。\nhash = hashfunc(key) index = hash % array_size 但是!哈希算法有个 Hash 冲突 问题,也就是说多个不同的 key 最后得到的 index 相同。通常情况下,我们常用的解决办法是 链地址法。链地址法就是将哈希冲突数据存放在链表中。就比如 JDK1.8 之前 HashMap 就是通过链地址法来解决哈希冲突的。不过,JDK1.8 以后HashMap为了减少链表过长的时候搜索时间过长引入了红黑树。\n为了减少 Hash 冲突的发生,一个好的哈希函数应该“均匀地”将数据分布在整个可能的哈希值集合中。\nMySQL 的 InnoDB 存储引擎不直接支持常规的哈希索引,但是,InnoDB 存储引擎中存在一种特殊的“自适应哈希索引”(Adaptive Hash Index),自适应哈希索引并不是传统意义上的纯哈希索引,而是结合了 B+Tree 和哈希索引的特点,以便更好地适应实际应用中的数据访问模式和性能需求。自适应哈希索引的每个哈希桶实际上是一个小型的 B+Tree 结构。这个 B+Tree 结构可以存储多个键值对,而不仅仅是一个键。这有助于减少哈希冲突链的长度,提高了索引的效率。关于 Adaptive Hash Index 的详细介绍,可以查看 MySQL 各种“Buffer”之 Adaptive Hash Index 这篇文章。\n既然哈希表这么快,为什么 MySQL 没有使用其作为索引的数据结构呢? 主要是因为 Hash 索引不支持顺序和范围查询。假如我们要对表中的数据进行排序或者进行范围查询,那 Hash 索引可就不行了。并且,每次 IO 只能取一个。\n试想一种情况:\nSELECT * FROM tb1 WHERE id \u0026lt; 500; 在这种范围查询中,优势非常大,直接遍历比 500 小的叶子节点就够了。而 Hash 索引是根据 hash 算法来定位的,难不成还要把 1 - 499 的数据,每个都进行一次 hash 计算来定位吗?这就是 Hash 最大的缺点了。\n二叉查找树(BST) # 二叉查找树(Binary Search Tree)是一种基于二叉树的数据结构,它具有以下特点:\n左子树所有节点的值均小于根节点的值。 右子树所有节点的值均大于根节点的值。 左右子树也分别为二叉查找树。 当二叉查找树是平衡的时候,也就是树的每个节点的左右子树深度相差不超过 1 的时候,查询的时间复杂度为 O(log2(N)),具有比较高的效率。然而,当二叉查找树不平衡时,例如在最坏情况下(有序插入节点),树会退化成线性链表(也被称为斜树),导致查询效率急剧下降,时间复杂退化为 O(N)。\n也就是说,二叉查找树的性能非常依赖于它的平衡程度,这就导致其不适合作为 MySQL 底层索引的数据结构。\n为了解决这个问题,并提高查询效率,人们发明了多种在二叉查找树基础上的改进型数据结构,如平衡二叉树、B-Tree、B+Tree 等。\nAVL 树 # AVL 树是计算机科学中最早被发明的自平衡二叉查找树,它的名称来自于发明者 G.M. Adelson-Velsky 和 E.M. Landis 的名字缩写。AVL 树的特点是保证任何节点的左右子树高度之差不超过 1,因此也被称为高度平衡二叉树,它的查找、插入和删除在平均和最坏情况下的时间复杂度都是 O(logn)。\nAVL 树采用了旋转操作来保持平衡。主要有四种旋转操作:LL 旋转、RR 旋转、LR 旋转和 RL 旋转。其中 LL 旋转和 RR 旋转分别用于处理左左和右右失衡,而 LR 旋转和 RL 旋转则用于处理左右和右左失衡。\n由于 AVL 树需要频繁地进行旋转操作来保持平衡,因此会有较大的计算开销进而降低了数据库写操作的性能。并且, 在使用 AVL 树时,每个树节点仅存储一个数据,而每次进行磁盘 IO 时只能读取一个节点的数据,如果需要查询的数据分布在多个节点上,那么就需要进行多次磁盘 IO。 磁盘 IO 是一项耗时的操作,在设计数据库索引时,我们需要优先考虑如何最大限度地减少磁盘 IO 操作的次数。\n实际应用中,AVL 树使用的并不多。\n红黑树 # 红黑树是一种自平衡二叉查找树,通过在插入和删除节点时进行颜色变换和旋转操作,使得树始终保持平衡状态,它具有以下特点:\n每个节点非红即黑; 根节点总是黑色的; 每个叶子节点都是黑色的空节点(NIL 节点); 如果节点是红色的,则它的子节点必须是黑色的(反之不一定); 从任意节点到它的叶子节点或空子节点的每条路径,必须包含相同数目的黑色节点(即相同的黑色高度)。 和 AVL 树不同的是,红黑树并不追求严格的平衡,而是大致的平衡。正因如此,红黑树的查询效率稍有下降,因为红黑树的平衡性相对较弱,可能会导致树的高度较高,这可能会导致一些数据需要进行多次磁盘 IO 操作才能查询到,这也是 MySQL 没有选择红黑树的主要原因。也正因如此,红黑树的插入和删除操作效率大大提高了,因为红黑树在插入和删除节点时只需进行 O(1) 次数的旋转和变色操作,即可保持基本平衡状态,而不需要像 AVL 树一样进行 O(logn) 次数的旋转操作。\n红黑树的应用还是比较广泛的,TreeMap、TreeSet 以及 JDK1.8 的 HashMap 底层都用到了红黑树。对于数据在内存中的这种情况来说,红黑树的表现是非常优异的。\nB 树\u0026amp; B+树 # B 树也称 B-树,全称为 多路平衡查找树 ,B+ 树是 B 树的一种变体。B 树和 B+树中的 B 是 Balanced (平衡)的意思。\n目前大部分数据库系统及文件系统都采用 B-Tree 或其变种 B+Tree 作为索引结构。\nB 树\u0026amp; B+树两者有何异同呢?\nB 树的所有节点既存放键(key) 也存放数据(data),而 B+树只有叶子节点存放 key 和 data,其他内节点只存放 key。 B 树的叶子节点都是独立的;B+树的叶子节点有一条引用链指向与它相邻的叶子节点。 B 树的检索的过程相当于对范围内的每个节点的关键字做二分查找,可能还没有到达叶子节点,检索就结束了。而 B+树的检索效率就很稳定了,任何查找都是从根节点到叶子节点的过程,叶子节点的顺序检索很明显。 在 B 树中进行范围查询时,首先找到要查找的下限,然后对 B 树进行中序遍历,直到找到查找的上限;而 B+树的范围查询,只需要对链表进行遍历即可。 综上,B+树与 B 树相比,具备更少的 IO 次数、更稳定的查询效率和更适于范围查询这些优势。\n在 MySQL 中,MyISAM 引擎和 InnoDB 引擎都是使用 B+Tree 作为索引结构,但是,两者的实现方式不太一样。(下面的内容整理自《Java 工程师修炼之道》)\nMyISAM 引擎中,B+Tree 叶节点的 data 域存放的是数据记录的地址。在索引检索的时候,首先按照 B+Tree 搜索算法搜索索引,如果指定的 Key 存在,则取出其 data 域的值,然后以 data 域的值为地址读取相应的数据记录。这被称为“非聚簇索引(非聚集索引)”。\nInnoDB 引擎中,其数据文件本身就是索引文件。相比 MyISAM,索引文件和数据文件是分离的,其表数据文件本身就是按 B+Tree 组织的一个索引结构,树的叶节点 data 域保存了完整的数据记录。这个索引的 key 是数据表的主键,因此 InnoDB 表数据文件本身就是主索引。这被称为“聚簇索引(聚集索引)”,而其余的索引都作为 辅助索引 ,辅助索引的 data 域存储相应记录主键的值而不是地址,这也是和 MyISAM 不同的地方。在根据主索引搜索时,直接找到 key 所在的节点即可取出数据;在根据辅助索引查找时,则需要先取出主键的值,再走一遍主索引。 因此,在设计表的时候,不建议使用过长的字段作为主键,也不建议使用非单调的字段作为主键,这样会造成主索引频繁分裂。\n索引类型总结 # 按照数据结构维度划分:\nBTree 索引:MySQL 里默认和最常用的索引类型。只有叶子节点存储 value,非叶子节点只有指针和 key。存储引擎 MyISAM 和 InnoDB 实现 BTree 索引都是使用 B+Tree,但二者实现方式不一样(前面已经介绍了)。 哈希索引:类似键值对的形式,一次即可定位。 RTree 索引:一般不会使用,仅支持 geometry 数据类型,优势在于范围查找,效率较低,通常使用搜索引擎如 ElasticSearch 代替。 全文索引:对文本的内容进行分词,进行搜索。目前只有 CHAR、VARCHAR ,TEXT 列上可以创建全文索引。一般不会使用,效率较低,通常使用搜索引擎如 ElasticSearch 代替。 按照底层存储方式角度划分:\n聚簇索引(聚集索引):索引结构和数据一起存放的索引,InnoDB 中的主键索引就属于聚簇索引。 非聚簇索引(非聚集索引):索引结构和数据分开存放的索引,二级索引(辅助索引)就属于非聚簇索引。MySQL 的 MyISAM 引擎,不管主键还是非主键,使用的都是非聚簇索引。 按照应用维度划分:\n主键索引:加速查询 + 列值唯一(不可以有 NULL)+ 表中只有一个。 普通索引:仅加速查询。 唯一索引:加速查询 + 列值唯一(可以有 NULL)。 覆盖索引:一个索引包含(或者说覆盖)所有需要查询的字段的值。 联合索引:多列值组成一个索引,专门用于组合搜索,其效率大于索引合并。 全文索引:对文本的内容进行分词,进行搜索。目前只有 CHAR、VARCHAR ,TEXT 列上可以创建全文索引。一般不会使用,效率较低,通常使用搜索引擎如 ElasticSearch 代替。 前缀索引:对文本的前几个字符创建索引,相比普通索引建立的数据更小,因为只取前几个字符。 MySQL 8.x 中实现的索引新特性:\n隐藏索引:也称为不可见索引,不会被优化器使用,但是仍然需要维护,通常会软删除和灰度发布的场景中使用。主键不能设置为隐藏(包括显式设置或隐式设置)。 降序索引:之前的版本就支持通过 desc 来指定索引为降序,但实际上创建的仍然是常规的升序索引。直到 MySQL 8.x 版本才开始真正支持降序索引。另外,在 MySQL 8.x 版本中,不再对 GROUP BY 语句进行隐式排序。 函数索引:从 MySQL 8.0.13 版本开始支持在索引中使用函数或者表达式的值,也就是在索引中可以包含函数或者表达式。 主键索引(Primary Key) # 数据表的主键列使用的就是主键索引。\n一张数据表有只能有一个主键,并且主键不能为 null,不能重复。\n在 MySQL 的 InnoDB 的表中,当没有显示的指定表的主键时,InnoDB 会自动先检查表中是否有唯一索引且不允许存在 null 值的字段,如果有,则选择该字段为默认的主键,否则 InnoDB 将会自动创建一个 6Byte 的自增主键。\n二级索引 # 二级索引(Secondary Index)的叶子节点存储的数据是主键的值,也就是说,通过二级索引可以定位主键的位置,二级索引又称为辅助索引/非主键索引。\n唯一索引,普通索引,前缀索引等索引都属于二级索引。\nPS: 不懂的同学可以暂存疑,慢慢往下看,后面会有答案的,也可以自行搜索。\n唯一索引(Unique Key):唯一索引也是一种约束。唯一索引的属性列不能出现重复的数据,但是允许数据为 NULL,一张表允许创建多个唯一索引。 建立唯一索引的目的大部分时候都是为了该属性列的数据的唯一性,而不是为了查询效率。 普通索引(Index):普通索引的唯一作用就是为了快速查询数据,一张表允许创建多个普通索引,并允许数据重复和 NULL。 前缀索引(Prefix):前缀索引只适用于字符串类型的数据。前缀索引是对文本的前几个字符创建索引,相比普通索引建立的数据更小,因为只取前几个字符。 全文索引(Full Text):全文索引主要是为了检索大文本数据中的关键字的信息,是目前搜索引擎数据库使用的一种技术。Mysql5.6 之前只有 MYISAM 引擎支持全文索引,5.6 之后 InnoDB 也支持了全文索引。 二级索引:\n聚簇索引与非聚簇索引 # 聚簇索引(聚集索引) # 聚簇索引介绍 # 聚簇索引(Clustered Index)即索引结构和数据一起存放的索引,并不是一种单独的索引类型。InnoDB 中的主键索引就属于聚簇索引。\n在 MySQL 中,InnoDB 引擎的表的 .ibd文件就包含了该表的索引和数据,对于 InnoDB 引擎表来说,该表的索引(B+树)的每个非叶子节点存储索引,叶子节点存储索引和索引对应的数据。\n聚簇索引的优缺点 # 优点:\n查询速度非常快:聚簇索引的查询速度非常的快,因为整个 B+树本身就是一颗多叉平衡树,叶子节点也都是有序的,定位到索引的节点,就相当于定位到了数据。相比于非聚簇索引, 聚簇索引少了一次读取数据的 IO 操作。 对排序查找和范围查找优化:聚簇索引对于主键的排序查找和范围查找速度非常快。 缺点:\n依赖于有序的数据:因为 B+树是多路平衡树,如果索引的数据不是有序的,那么就需要在插入时排序,如果数据是整型还好,否则类似于字符串或 UUID 这种又长又难比较的数据,插入或查找的速度肯定比较慢。 更新代价大:如果对索引列的数据被修改时,那么对应的索引也将会被修改,而且聚簇索引的叶子节点还存放着数据,修改代价肯定是较大的,所以对于主键索引来说,主键一般都是不可被修改的。 非聚簇索引(非聚集索引) # 非聚簇索引介绍 # 非聚簇索引(Non-Clustered Index)即索引结构和数据分开存放的索引,并不是一种单独的索引类型。二级索引(辅助索引)就属于非聚簇索引。MySQL 的 MyISAM 引擎,不管主键还是非主键,使用的都是非聚簇索引。\n非聚簇索引的叶子节点并不一定存放数据的指针,因为二级索引的叶子节点就存放的是主键,根据主键再回表查数据。\n非聚簇索引的优缺点 # 优点:\n更新代价比聚簇索引要小 。非聚簇索引的更新代价就没有聚簇索引那么大了,非聚簇索引的叶子节点是不存放数据的。\n缺点:\n依赖于有序的数据:跟聚簇索引一样,非聚簇索引也依赖于有序的数据 可能会二次查询(回表):这应该是非聚簇索引最大的缺点了。 当查到索引对应的指针或主键后,可能还需要根据指针或主键再到数据文件或表中查询。 这是 MySQL 的表的文件截图:\n聚簇索引和非聚簇索引:\n非聚簇索引一定回表查询吗(覆盖索引)? # 非聚簇索引不一定回表查询。\n试想一种情况,用户准备使用 SQL 查询用户名,而用户名字段正好建立了索引。\nSELECT name FROM table WHERE name=\u0026#39;guang19\u0026#39;; 那么这个索引的 key 本身就是 name,查到对应的 name 直接返回就行了,无需回表查询。\n即使是 MYISAM 也是这样,虽然 MYISAM 的主键索引确实需要回表,因为它的主键索引的叶子节点存放的是指针。但是!如果 SQL 查的就是主键呢?\nSELECT id FROM table WHERE id=1; 主键索引本身的 key 就是主键,查到返回就行了。这种情况就称之为覆盖索引了。\n覆盖索引和联合索引 # 覆盖索引 # 如果一个索引包含(或者说覆盖)所有需要查询的字段的值,我们就称之为 覆盖索引(Covering Index) 。\n在 InnoDB 存储引擎中,非主键索引的叶子节点包含的是主键的值。这意味着,当使用非主键索引进行查询时,数据库会先找到对应的主键值,然后再通过主键索引来定位和检索完整的行数据。这个过程被称为“回表”。\n覆盖索引即需要查询的字段正好是索引的字段,那么直接根据该索引,就可以查到数据了,而无需回表查询。\n如主键索引,如果一条 SQL 需要查询主键,那么正好根据主键索引就可以查到主键。再如普通索引,如果一条 SQL 需要查询 name,name 字段正好有索引, 那么直接根据这个索引就可以查到数据,也无需回表。\n我们这里简单演示一下覆盖索引的效果。\n1、创建一个名为 cus_order 的表,来实际测试一下这种排序方式。为了测试方便, cus_order 这张表只有 id、score、name这 3 个字段。\nCREATE TABLE `cus_order` ( `id` int(11) unsigned NOT NULL AUTO_INCREMENT, `score` int(11) NOT NULL, `name` varchar(11) NOT NULL DEFAULT \u0026#39;\u0026#39;, PRIMARY KEY (`id`) ) ENGINE=InnoDB AUTO_INCREMENT=100000 DEFAULT CHARSET=utf8mb4; 2、定义一个简单的存储过程(PROCEDURE)来插入 100w 测试数据。\nDELIMITER ;; CREATE DEFINER=`root`@`%` PROCEDURE `BatchinsertDataToCusOder`(IN start_num INT,IN max_num INT) BEGIN DECLARE i INT default start_num; WHILE i \u0026lt; max_num DO insert into `cus_order`(`id`, `score`, `name`) values (i,RAND() * 1000000,CONCAT(\u0026#39;user\u0026#39;, i)); SET i = i + 1; END WHILE; END;; DELIMITER ; 存储过程定义完成之后,我们执行存储过程即可!\nCALL BatchinsertDataToCusOder(1, 1000000); # 插入100w+的随机数据 等待一会,100w 的测试数据就插入完成了!\n3、创建覆盖索引并使用 EXPLAIN 命令分析。\n为了能够对这 100w 数据按照 score 进行排序,我们需要执行下面的 SQL 语句。\n#降序排序 SELECT `score`,`name` FROM `cus_order` ORDER BY `score` DESC; 使用 EXPLAIN 命令分析这条 SQL 语句,通过 Extra 这一列的 Using filesort ,我们发现是没有用到覆盖索引的。\n不过这也是理所应当,毕竟我们现在还没有创建索引呢!\n我们这里以 score 和 name 两个字段建立联合索引:\nALTER TABLE `cus_order` ADD INDEX id_score_name(score, name); 创建完成之后,再用 EXPLAIN 命令分析再次分析这条 SQL 语句。\n通过 Extra 这一列的 Using index ,说明这条 SQL 语句成功使用了覆盖索引。\n关于 EXPLAIN 命令的详细介绍请看: MySQL 执行计划分析这篇文章。\n联合索引 # 使用表中的多个字段创建索引,就是 联合索引,也叫 组合索引 或 复合索引。\n以 score 和 name 两个字段建立联合索引:\nALTER TABLE `cus_order` ADD INDEX id_score_name(score, name); 最左前缀匹配原则 # 最左前缀匹配原则指的是在使用联合索引时,MySQL 会根据索引中的字段顺序,从左到右依次匹配查询条件中的字段。如果查询条件与索引中的最左侧字段相匹配,那么 MySQL 就会使用索引来过滤数据,这样可以提高查询效率。\n最左匹配原则会一直向右匹配,直到遇到范围查询(如 \u0026gt;、\u0026lt;)为止。对于 \u0026gt;=、\u0026lt;=、BETWEEN 以及前缀匹配 LIKE 的范围查询,不会停止匹配(相关阅读: 联合索引的最左匹配原则全网都在说的一个错误结论)。\n假设有一个联合索引(column1, column2, column3),其从左到右的所有前缀为(column1)、(column1, column2)、(column1, column2, column3)(创建 1 个联合索引相当于创建了 3 个索引),包含这些列的所有查询都会走索引而不会全表扫描。\n我们在使用联合索引时,可以将区分度高的字段放在最左边,这也可以过滤更多数据。\n我们这里简单演示一下最左前缀匹配的效果。\n1、创建一个名为 student 的表,这张表只有 id、name、class这 3 个字段。\nCREATE TABLE `student` ( `id` int NOT NULL, `name` varchar(100) DEFAULT NULL, `class` varchar(100) DEFAULT NULL, PRIMARY KEY (`id`), KEY `name_class_idx` (`name`,`class`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; 2、下面我们分别测试三条不同的 SQL 语句。\n# 可以命中索引 SELECT * FROM student WHERE name = \u0026#39;Anne Henry\u0026#39;; EXPLAIN SELECT * FROM student WHERE name = \u0026#39;Anne Henry\u0026#39; AND class = \u0026#39;lIrm08RYVk\u0026#39;; # 无法命中索引 SELECT * FROM student WHERE class = \u0026#39;lIrm08RYVk\u0026#39;; 再来看一个常见的面试题:如果有索引 联合索引(a,b,c),查询 a=1 AND c=1会走索引么?c=1 呢?b=1 AND c=1呢?\n先不要往下看答案,给自己 3 分钟时间想一想。\n查询 a=1 AND c=1:根据最左前缀匹配原则,查询可以使用索引的前缀部分。因此,该查询仅在 a=1 上使用索引,然后对结果进行 c=1 的过滤。 查询 c=1 :由于查询中不包含最左列 a,根据最左前缀匹配原则,整个索引都无法被使用。 查询b=1 AND c=1:和第二种一样的情况,整个索引都不会使用。 MySQL 8.0.13 版本引入了索引跳跃扫描(Index Skip Scan,简称 ISS),它可以在某些索引查询场景下提高查询效率。在没有 ISS 之前,不满足最左前缀匹配原则的联合索引查询中会执行全表扫描。而 ISS 允许 MySQL 在某些情况下避免全表扫描,即使查询条件不符合最左前缀。不过,这个功能比较鸡肋, 和 Oracle 中的没法比,MySQL 8.0.31 还报告了一个 bug: Bug #109145 Using index for skip scan cause incorrect result(后续版本已经修复)。个人建议知道有这个东西就好,不需要深究,实际项目也不一定能用上。\n索引下推 # 索引下推(Index Condition Pushdown,简称 ICP) 是 MySQL 5.6 版本中提供的一项索引优化功能,它允许存储引擎在索引遍历过程中,执行部分 WHERE字句的判断条件,直接过滤掉不满足条件的记录,从而减少回表次数,提高查询效率。\n假设我们有一个名为 user 的表,其中包含 id, username, zipcode和 birthdate 4 个字段,创建了联合索引(zipcode, birthdate)。\nCREATE TABLE `user` ( `id` int NOT NULL AUTO_INCREMENT, `username` varchar(20) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NOT NULL, `zipcode` varchar(20) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NOT NULL, `birthdate` date NOT NULL, PRIMARY KEY (`id`), KEY `idx_username_birthdate` (`zipcode`,`birthdate`) ) ENGINE=InnoDB AUTO_INCREMENT=1001 DEFAULT CHARSET=utf8mb4; # 查询 zipcode 为 431200 且生日在 3 月的用户 # birthdate 字段使用函数索引失效 SELECT * FROM user WHERE zipcode = \u0026#39;431200\u0026#39; AND MONTH(birthdate) = 3; 没有索引下推之前,即使 zipcode 字段利用索引可以帮助我们快速定位到 zipcode = '431200' 的用户,但我们仍然需要对每一个找到的用户进行回表操作,获取完整的用户数据,再去判断 MONTH(birthdate) = 3。 有了索引下推之后,存储引擎会在使用zipcode 字段索引查找zipcode = '431200' 的用户时,同时判断MONTH(birthdate) = 3。这样,只有同时满足条件的记录才会被返回,减少了回表次数。 再来讲讲索引下推的具体原理,先看下面这张 MySQL 简要架构图。\nMySQL 可以简单分为 Server 层和存储引擎层这两层。Server 层处理查询解析、分析、优化、缓存以及与客户端的交互等操作,而存储引擎层负责数据的存储和读取,MySQL 支持 InnoDB、MyISAM、Memory 等多种存储引擎。\n索引下推的下推其实就是指将部分上层(Server 层)负责的事情,交给了下层(存储引擎层)去处理。\n我们这里结合索引下推原理再对上面提到的例子进行解释。\n没有索引下推之前:\n存储引擎层先根据 zipcode 索引字段找到所有 zipcode = '431200' 的用户的主键 ID,然后二次回表查询,获取完整的用户数据; 存储引擎层把所有 zipcode = '431200' 的用户数据全部交给 Server 层,Server 层根据MONTH(birthdate) = 3这一条件再进一步做筛选。 有了索引下推之后:\n存储引擎层先根据 zipcode 索引字段找到所有 zipcode = '431200' 的用户,然后直接判断 MONTH(birthdate) = 3,筛选出符合条件的主键 ID; 二次回表查询,根据符合条件的主键 ID 去获取完整的用户数据; 存储引擎层把符合条件的用户数据全部交给 Server 层。 可以看出,除了可以减少回表次数之外,索引下推还可以减少存储引擎层和 Server 层的数据传输量。\n最后,总结一下索引下推应用范围:\n适用于 InnoDB 引擎和 MyISAM 引擎的查询。 适用于执行计划是 range, ref, eq_ref, ref_or_null 的范围查询。 对于 InnoDB 表,仅用于非聚簇索引。索引下推的目标是减少全行读取次数,从而减少 I/O 操作。对于 InnoDB 聚集索引,完整的记录已经读入 InnoDB 缓冲区。在这种情况下使用索引下推 不会减少 I/O。 子查询不能使用索引下推,因为子查询通常会创建临时表来处理结果,而这些临时表是没有索引的。 存储过程不能使用索引下推,因为存储引擎无法调用存储函数。 正确使用索引的一些建议 # 选择合适的字段创建索引 # 不为 NULL 的字段:索引字段的数据应该尽量不为 NULL,因为对于数据为 NULL 的字段,数据库较难优化。如果字段频繁被查询,但又避免不了为 NULL,建议使用 0,1,true,false 这样语义较为清晰的短值或短字符作为替代。 被频繁查询的字段:我们创建索引的字段应该是查询操作非常频繁的字段。 被作为条件查询的字段:被作为 WHERE 条件查询的字段,应该被考虑建立索引。 频繁需要排序的字段:索引已经排序,这样查询可以利用索引的排序,加快排序查询时间。 被经常频繁用于连接的字段:经常用于连接的字段可能是一些外键列,对于外键列并不一定要建立外键,只是说该列涉及到表与表的关系。对于频繁被连接查询的字段,可以考虑建立索引,提高多表连接查询的效率。 被频繁更新的字段应该慎重建立索引 # 虽然索引能带来查询上的效率,但是维护索引的成本也是不小的。 如果一个字段不被经常查询,反而被经常修改,那么就更不应该在这种字段上建立索引了。\n限制每张表上的索引数量 # 索引并不是越多越好,建议单张表索引不超过 5 个!索引可以提高效率同样可以降低效率。\n索引可以增加查询效率,但同样也会降低插入和更新的效率,甚至有些情况下会降低查询效率。\n因为 MySQL 优化器在选择如何优化查询时,会根据统一信息,对每一个可以用到的索引来进行评估,以生成出一个最好的执行计划,如果同时有很多个索引都可以用于查询,就会增加 MySQL 优化器生成执行计划的时间,同样会降低查询性能。\n尽可能的考虑建立联合索引而不是单列索引 # 因为索引是需要占用磁盘空间的,可以简单理解为每个索引都对应着一颗 B+树。如果一个表的字段过多,索引过多,那么当这个表的数据达到一个体量后,索引占用的空间也是很多的,且修改索引时,耗费的时间也是较多的。如果是联合索引,多个字段在一个索引上,那么将会节约很大磁盘空间,且修改数据的操作效率也会提升。\n注意避免冗余索引 # 冗余索引指的是索引的功能相同,能够命中索引(a, b)就肯定能命中索引(a) ,那么索引(a)就是冗余索引。如(name,city )和(name )这两个索引就是冗余索引,能够命中前者的查询肯定是能够命中后者的 在大多数情况下,都应该尽量扩展已有的索引而不是创建新索引。\n字符串类型的字段使用前缀索引代替普通索引 # 前缀索引仅限于字符串类型,较普通索引会占用更小的空间,所以可以考虑使用前缀索引带替普通索引。\n避免索引失效 # 索引失效也是慢查询的主要原因之一,常见的导致索引失效的情况有下面这些:\n使用 SELECT * 进行查询; SELECT * 不会直接导致索引失效(如果不走索引大概率是因为 where 查询范围过大导致的),但它可能会带来一些其他的性能问题比如造成网络传输和数据处理的浪费、无法使用索引覆盖; 创建了组合索引,但查询条件未遵守最左匹配原则; 在索引列上进行计算、函数、类型转换等操作; 以 % 开头的 LIKE 查询比如 LIKE '%abc';; 查询条件中使用 OR,且 OR 的前后条件中有一个列没有索引,涉及的索引都不会被使用到; IN 的取值范围较大时会导致索引失效,走全表扫描(NOT IN 和 IN 的失效场景相同); 发生 隐式转换; …… 推荐阅读这篇文章: 美团暑期实习一面:MySQl 索引失效的场景有哪些?。\n删除长期未使用的索引 # 删除长期未使用的索引,不用的索引的存在会造成不必要的性能损耗。\nMySQL 5.7 可以通过查询 sys 库的 schema_unused_indexes 视图来查询哪些索引从未被使用。\n知道如何分析 SQL 语句是否走索引查询 # 我们可以使用 EXPLAIN 命令来分析 SQL 的 执行计划 ,这样就知道语句是否命中索引了。执行计划是指一条 SQL 语句在经过 MySQL 查询优化器的优化会后,具体的执行方式。\nEXPLAIN 并不会真的去执行相关的语句,而是通过 查询优化器 对语句进行分析,找出最优的查询方案,并显示对应的信息。\nEXPLAIN 的输出格式如下:\nmysql\u0026gt; EXPLAIN SELECT `score`,`name` FROM `cus_order` ORDER BY `score` DESC; +----+-------------+-----------+------------+------+---------------+------+---------+------+--------+----------+----------------+ | id | select_type | table | partitions | type | possible_keys | key | key_len | ref | rows | filtered | Extra | +----+-------------+-----------+------------+------+---------------+------+---------+------+--------+----------+----------------+ | 1 | SIMPLE | cus_order | NULL | ALL | NULL | NULL | NULL | NULL | 997572 | 100.00 | Using filesort | +----+-------------+-----------+------------+------+---------------+------+---------+------+--------+----------+----------------+ 1 row in set, 1 warning (0.00 sec) 各个字段的含义如下:\n列名 含义 id SELECT 查询的序列标识符 select_type SELECT 关键字对应的查询类型 table 用到的表名 partitions 匹配的分区,对于未分区的表,值为 NULL type 表的访问方法 possible_keys 可能用到的索引 key 实际用到的索引 key_len 所选索引的长度 ref 当使用索引等值查询时,与索引作比较的列或常量 rows 预计要读取的行数 filtered 按表条件过滤后,留存的记录数的百分比 Extra 附加信息 篇幅问题,我这里只是简单介绍了一下 MySQL 执行计划,详细介绍请看: MySQL 执行计划分析这篇文章。\n"},{"id":545,"href":"/zh/docs/technology/Interview/database/mysql/index-invalidation-caused-by-implicit-conversion/","title":"MySQL隐式转换造成索引失效","section":"Mysql","content":" 本次测试使用的 MySQL 版本是 5.7.26,随着 MySQL 版本的更新某些特性可能会发生改变,本文不代表所述观点和结论于 MySQL 所有版本均准确无误,版本差异请自行甄别。\n原文: https://www.guitu18.com/post/2019/11/24/61.html\n前言 # 数据库优化是一个任重而道远的任务,想要做优化必须深入理解数据库的各种特性。在开发过程中我们经常会遇到一些原因很简单但造成的后果却很严重的疑难杂症,这类问题往往还不容易定位,排查费时费力最后发现是一个很小的疏忽造成的,又或者是因为不了解某个技术特性产生的。\n于数据库层面,最常见的恐怕就是索引失效了,且一开始因为数据量小还不易被发现。但随着业务的拓展数据量的提升,性能问题慢慢的就体现出来了,处理不及时还很容易造成雪球效应,最终导致数据库卡死甚至瘫痪。造成索引失效的原因可能有很多种,相关技术博客已经有太多了,今天我要记录的是隐式转换造成的索引失效。\n数据准备 # 首先使用存储过程生成 1000 万条测试数据, 测试表一共建立了 7 个字段(包括主键),num1和num2保存的是和ID一样的顺序数字,其中num2是字符串类型。 type1和type2保存的都是主键对 5 的取模,目的是模拟实际应用中常用类似 type 类型的数据,但是type2是没有建立索引的。 str1和str2都是保存了一个 20 位长度的随机字符串,str1不能为NULL,str2允许为NULL,相应的生成测试数据的时候我也会在str2字段生产少量NULL值(每 100 条数据产生一个NULL值)。\n-- 创建测试数据表 DROP TABLE IF EXISTS test1; CREATE TABLE `test1` ( `id` int(11) NOT NULL, `num1` int(11) NOT NULL DEFAULT \u0026#39;0\u0026#39;, `num2` varchar(11) NOT NULL DEFAULT \u0026#39;\u0026#39;, `type1` int(4) NOT NULL DEFAULT \u0026#39;0\u0026#39;, `type2` int(4) NOT NULL DEFAULT \u0026#39;0\u0026#39;, `str1` varchar(100) NOT NULL DEFAULT \u0026#39;\u0026#39;, `str2` varchar(100) DEFAULT NULL, PRIMARY KEY (`id`), KEY `num1` (`num1`), KEY `num2` (`num2`), KEY `type1` (`type1`), KEY `str1` (`str1`), KEY `str2` (`str2`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8; -- 创建存储过程 DROP PROCEDURE IF EXISTS pre_test1; DELIMITER // CREATE PROCEDURE `pre_test1`() BEGIN DECLARE i INT DEFAULT 0; SET autocommit = 0; WHILE i \u0026lt; 10000000 DO SET i = i + 1; SET @str1 = SUBSTRING(MD5(RAND()),1,20); -- 每100条数据str2产生一个null值 IF i % 100 = 0 THEN SET @str2 = NULL; ELSE SET @str2 = @str1; END IF; INSERT INTO test1 (`id`, `num1`, `num2`, `type1`, `type2`, `str1`, `str2`) VALUES (CONCAT(\u0026#39;\u0026#39;, i), CONCAT(\u0026#39;\u0026#39;, i), CONCAT(\u0026#39;\u0026#39;, i), i%5, i%5, @str1, @str2); -- 事务优化,每一万条数据提交一次事务 IF i % 10000 = 0 THEN COMMIT; END IF; END WHILE; END; // DELIMITER ; -- 执行存储过程 CALL pre_test1(); 数据量比较大,还涉及使用MD5生成随机字符串,所以速度有点慢,稍安勿躁,耐心等待即可。\n1000 万条数据,我用了 33 分钟才跑完(实际时间跟你电脑硬件配置有关)。这里贴几条生成的数据,大致长这样。\nSQL 测试 # 先来看这组 SQL,一共四条,我们的测试数据表num1是int类型,num2是varchar类型,但是存储的数据都是跟主键id一样的顺序数字,两个字段都建立有索引。\n1: SELECT * FROM `test1` WHERE num1 = 10000; 2: SELECT * FROM `test1` WHERE num1 = \u0026#39;10000\u0026#39;; 3: SELECT * FROM `test1` WHERE num2 = 10000; 4: SELECT * FROM `test1` WHERE num2 = \u0026#39;10000\u0026#39;; 这四条 SQL 都是有针对性写的,12 查询的字段是 int 类型,34 查询的字段是varchar类型。12 或 34 查询的字段虽然都相同,但是一个条件是数字,一个条件是用引号引起来的字符串。这样做有什么区别呢?先不看下边的测试结果你能猜出这四条 SQL 的效率顺序吗?\n经测试这四条 SQL 最后的执行结果却相差很大,其中 124 三条 SQL 基本都是瞬间出结果,大概在 0.0010.005 秒,在千万级的数据量下这样的结果可以判定这三条 SQL 性能基本没差别了。但是第三条 SQL,多次测试耗时基本在 4.54.8 秒之间。\n为什么 34 两条 SQL 效率相差那么大,但是同样做对比的 12 两条 SQL 却没什么差别呢?查看一下执行计划,下边分别 1234 条 SQL 的执行计划数据:\n可以看到,124 三条 SQL 都能使用到索引,连接类型都为ref,扫描行数都为 1,所以效率非常高。再看看第三条 SQL,没有用上索引,所以为全表扫描,rows直接到达 1000 万了,所以性能差别才那么大。\n仔细观察你会发现,34 两条 SQL 查询的字段num2是varchar类型的,查询条件等号右边加引号的第 4 条 SQL 是用到索引的,那么是查询的数据类型和字段数据类型不一致造成的吗?如果是这样那 12 两条 SQL 查询的字段num1是int类型,但是第 2 条 SQL 查询条件右边加了引号为什么还能用上索引呢。\n查阅 MySQL 相关文档发现是隐式转换造成的,看一下官方的描述:\n官方文档: 12.2 Type Conversion in Expression Evaluation\n当操作符与不同类型的操作数一起使用时,会发生类型转换以使操作数兼容。某些转换是隐式发生的。例如,MySQL 会根据需要自动将字符串转换为数字,反之亦然。以下规则描述了比较操作的转换方式:\n两个参数至少有一个是NULL时,比较的结果也是NULL,特殊的情况是使用\u0026lt;=\u0026gt;对两个NULL做比较时会返回1,这两种情况都不需要做类型转换 两个参数都是字符串,会按照字符串来比较,不做类型转换 两个参数都是整数,按照整数来比较,不做类型转换 十六进制的值和非数字做比较时,会被当做二进制串 有一个参数是TIMESTAMP或DATETIME,并且另外一个参数是常量,常量会被转换为timestamp 有一个参数是decimal类型,如果另外一个参数是decimal或者整数,会将整数转换为decimal后进行比较,如果另外一个参数是浮点数,则会把decimal转换为浮点数进行比较 所有其他情况下,两个参数都会被转换为浮点数再进行比较 根据官方文档的描述,我们的第 23 两条 SQL 都发生了隐式转换,第 2 条 SQL 的查询条件num1 = '10000',左边是int类型右边是字符串,第 3 条 SQL 相反,那么根据官方转换规则第 7 条,左右两边都会转换为浮点数再进行比较。\n先看第 2 条 SQL:SELECT * FROM `test1` WHERE num1 = '10000'; 左边为 int 类型10000,转换为浮点数还是10000,右边字符串类型'10000',转换为浮点数也是10000。两边的转换结果都是唯一确定的,所以不影响使用索引。\n第 3 条 SQL:SELECT * FROM `test1` WHERE num2 = 10000; 左边是字符串类型'10000',转浮点数为 10000 是唯一的,右边int类型10000转换结果也是唯一的。但是,因为左边是检索条件,'10000'转到10000虽然是唯一,但是其他字符串也可以转换为10000,比如'10000a','010000','10000'等等都能转为浮点数10000,这样的情况下,是不能用到索引的。\n关于这个隐式转换我们可以通过查询测试验证一下,先插入几条数据,其中num2='10000a'、'010000'和'10000':\nINSERT INTO `test1` (`id`, `num1`, `num2`, `type1`, `type2`, `str1`, `str2`) VALUES (\u0026#39;10000001\u0026#39;, \u0026#39;10000\u0026#39;, \u0026#39;10000a\u0026#39;, \u0026#39;0\u0026#39;, \u0026#39;0\u0026#39;, \u0026#39;2df3d9465ty2e4hd523\u0026#39;, \u0026#39;2df3d9465ty2e4hd523\u0026#39;); INSERT INTO `test1` (`id`, `num1`, `num2`, `type1`, `type2`, `str1`, `str2`) VALUES (\u0026#39;10000002\u0026#39;, \u0026#39;10000\u0026#39;, \u0026#39;010000\u0026#39;, \u0026#39;0\u0026#39;, \u0026#39;0\u0026#39;, \u0026#39;2df3d9465ty2e4hd523\u0026#39;, \u0026#39;2df3d9465ty2e4hd523\u0026#39;); INSERT INTO `test1` (`id`, `num1`, `num2`, `type1`, `type2`, `str1`, `str2`) VALUES (\u0026#39;10000003\u0026#39;, \u0026#39;10000\u0026#39;, \u0026#39; 10000\u0026#39;, \u0026#39;0\u0026#39;, \u0026#39;0\u0026#39;, \u0026#39;2df3d9465ty2e4hd523\u0026#39;, \u0026#39;2df3d9465ty2e4hd523\u0026#39;); 然后使用第三条 SQL 语句SELECT * FROM `test1` WHERE num2 = 10000;进行查询:\n从结果可以看到,后面插入的三条数据也都匹配上了。那么这个字符串隐式转换的规则是什么呢?为什么num2='10000a'、'010000'和'10000'这三种情形都能匹配上呢?查阅相关资料发现规则如下:\n不以数字开头的字符串都将转换为0。如'abc'、'a123bc'、'abc123'都会转化为0; 以数字开头的字符串转换时会进行截取,从第一个字符截取到第一个非数字内容为止。比如'123abc'会转换为123,'012abc'会转换为012也就是12,'5.3a66b78c'会转换为5.3,其他同理。 现对以上规则做如下测试验证:\n如此也就印证了之前的查询结果了。\n再次写一条 SQL 查询 str1 字段:SELECT * FROM `test1` WHERE str1 = 1234;\n分析和总结 # 通过上面的测试我们发现 MySQL 使用操作符的一些特性:\n当操作符左右两边的数据类型不一致时,会发生隐式转换。 当 where 查询操作符左边为数值类型时发生了隐式转换,那么对效率影响不大,但还是不推荐这么做。 当 where 查询操作符左边为字符类型时发生了隐式转换,那么会导致索引失效,造成全表扫描效率极低。 字符串转换为数值类型时,非数字开头的字符串会转化为0,以数字开头的字符串会截取从第一个字符到第一个非数字内容为止的值为转化结果。 所以,我们在写 SQL 时一定要养成良好的习惯,查询的字段是什么类型,等号右边的条件就写成对应的类型。特别当查询的字段是字符串时,等号右边的条件一定要用引号引起来标明这是一个字符串,否则会造成索引失效触发全表扫描。\n"},{"id":546,"href":"/zh/docs/technology/Interview/database/mysql/mysql-query-execution-plan/","title":"MySQL执行计划分析","section":"Mysql","content":" 本文来自公号 MySQL 技术,JavaGuide 对其做了补充完善。原文地址: https://mp.weixin.qq.com/s/d5OowNLtXBGEAbT31sSH4g\n优化 SQL 的第一步应该是读懂 SQL 的执行计划。本篇文章,我们一起来学习下 MySQL EXPLAIN 执行计划相关知识。\n什么是执行计划? # 执行计划 是指一条 SQL 语句在经过 MySQL 查询优化器 的优化后,具体的执行方式。\n执行计划通常用于 SQL 性能分析、优化等场景。通过 EXPLAIN 的结果,可以了解到如数据表的查询顺序、数据查询操作的操作类型、哪些索引可以被命中、哪些索引实际会命中、每个数据表有多少行记录被查询等信息。\n如何获取执行计划? # MySQL 为我们提供了 EXPLAIN 命令,来获取执行计划的相关信息。\n需要注意的是,EXPLAIN 语句并不会真的去执行相关的语句,而是通过查询优化器对语句进行分析,找出最优的查询方案,并显示对应的信息。\nEXPLAIN 执行计划支持 SELECT、DELETE、INSERT、REPLACE 以及 UPDATE 语句。我们一般多用于分析 SELECT 查询语句,使用起来非常简单,语法如下:\nEXPLAIN + SELECT 查询语句; 我们简单来看下一条查询语句的执行计划:\nmysql\u0026gt; explain SELECT * FROM dept_emp WHERE emp_no IN (SELECT emp_no FROM dept_emp GROUP BY emp_no HAVING COUNT(emp_no)\u0026gt;1); +----+-------------+----------+------------+-------+-----------------+---------+---------+------+--------+----------+-------------+ | id | select_type | table | partitions | type | possible_keys | key | key_len | ref | rows | filtered | Extra | +----+-------------+----------+------------+-------+-----------------+---------+---------+------+--------+----------+-------------+ | 1 | PRIMARY | dept_emp | NULL | ALL | NULL | NULL | NULL | NULL | 331143 | 100.00 | Using where | | 2 | SUBQUERY | dept_emp | NULL | index | PRIMARY,dept_no | PRIMARY | 16 | NULL | 331143 | 100.00 | Using index | +----+-------------+----------+------------+-------+-----------------+---------+---------+------+--------+----------+-------------+ 可以看到,执行计划结果中共有 12 列,各列代表的含义总结如下表:\n列名 含义 id SELECT 查询的序列标识符 select_type SELECT 关键字对应的查询类型 table 用到的表名 partitions 匹配的分区,对于未分区的表,值为 NULL type 表的访问方法 possible_keys 可能用到的索引 key 实际用到的索引 key_len 所选索引的长度 ref 当使用索引等值查询时,与索引作比较的列或常量 rows 预计要读取的行数 filtered 按表条件过滤后,留存的记录数的百分比 Extra 附加信息 如何分析 EXPLAIN 结果? # 为了分析 EXPLAIN 语句的执行结果,我们需要搞懂执行计划中的重要字段。\nid # SELECT 标识符,用于标识每个 SELECT 语句的执行顺序。\nid 如果相同,从上往下依次执行。id 不同,id 值越大,执行优先级越高,如果行引用其他行的并集结果,则该值可以为 NULL。\nselect_type # 查询的类型,主要用于区分普通查询、联合查询、子查询等复杂的查询,常见的值有:\nSIMPLE:简单查询,不包含 UNION 或者子查询。 PRIMARY:查询中如果包含子查询或其他部分,外层的 SELECT 将被标记为 PRIMARY。 SUBQUERY:子查询中的第一个 SELECT。 UNION:在 UNION 语句中,UNION 之后出现的 SELECT。 DERIVED:在 FROM 中出现的子查询将被标记为 DERIVED。 UNION RESULT:UNION 查询的结果。 table # 查询用到的表名,每行都有对应的表名,表名除了正常的表之外,也可能是以下列出的值:\n\u0026lt;unionM,N\u0026gt; : 本行引用了 id 为 M 和 N 的行的 UNION 结果; \u0026lt;derivedN\u0026gt; : 本行引用了 id 为 N 的表所产生的的派生表结果。派生表有可能产生自 FROM 语句中的子查询。 \u0026lt;subqueryN\u0026gt; : 本行引用了 id 为 N 的表所产生的的物化子查询结果。 type(重要) # 查询执行的类型,描述了查询是如何执行的。所有值的顺序从最优到最差排序为:\nsystem \u0026gt; const \u0026gt; eq_ref \u0026gt; ref \u0026gt; fulltext \u0026gt; ref_or_null \u0026gt; index_merge \u0026gt; unique_subquery \u0026gt; index_subquery \u0026gt; range \u0026gt; index \u0026gt; ALL\n常见的几种类型具体含义如下:\nsystem:如果表使用的引擎对于表行数统计是精确的(如:MyISAM),且表中只有一行记录的情况下,访问方法是 system ,是 const 的一种特例。 const:表中最多只有一行匹配的记录,一次查询就可以找到,常用于使用主键或唯一索引的所有字段作为查询条件。 eq_ref:当连表查询时,前一张表的行在当前这张表中只有一行与之对应。是除了 system 与 const 之外最好的 join 方式,常用于使用主键或唯一索引的所有字段作为连表条件。 ref:使用普通索引作为查询条件,查询结果可能找到多个符合条件的行。 index_merge:当查询条件使用了多个索引时,表示开启了 Index Merge 优化,此时执行计划中的 key 列列出了使用到的索引。 range:对索引列进行范围查询,执行计划中的 key 列表示哪个索引被使用了。 index:查询遍历了整棵索引树,与 ALL 类似,只不过扫描的是索引,而索引一般在内存中,速度更快。 ALL:全表扫描。 possible_keys # possible_keys 列表示 MySQL 执行查询时可能用到的索引。如果这一列为 NULL ,则表示没有可能用到的索引;这种情况下,需要检查 WHERE 语句中所使用的的列,看是否可以通过给这些列中某个或多个添加索引的方法来提高查询性能。\nkey(重要) # key 列表示 MySQL 实际使用到的索引。如果为 NULL,则表示未用到索引。\nkey_len # key_len 列表示 MySQL 实际使用的索引的最大长度;当使用到联合索引时,有可能是多个列的长度和。在满足需求的前提下越短越好。如果 key 列显示 NULL ,则 key_len 列也显示 NULL 。\nrows # rows 列表示根据表统计信息及选用情况,大致估算出找到所需的记录或所需读取的行数,数值越小越好。\nExtra(重要) # 这列包含了 MySQL 解析查询的额外信息,通过这些信息,可以更准确的理解 MySQL 到底是如何执行查询的。常见的值如下:\nUsing filesort:在排序时使用了外部的索引排序,没有用到表内索引进行排序。 Using temporary:MySQL 需要创建临时表来存储查询的结果,常见于 ORDER BY 和 GROUP BY。 Using index:表明查询使用了覆盖索引,不用回表,查询效率非常高。 Using index condition:表示查询优化器选择使用了索引条件下推这个特性。 Using where:表明查询使用了 WHERE 子句进行条件过滤。一般在没有使用到索引的时候会出现。 Using join buffer (Block Nested Loop):连表查询的方式,表示当被驱动表的没有使用索引的时候,MySQL 会先将驱动表读出来放到 join buffer 中,再遍历被驱动表与驱动表进行查询。 这里提醒下,当 Extra 列包含 Using filesort 或 Using temporary 时,MySQL 的性能可能会存在问题,需要尽可能避免。\n参考 # https://dev.mysql.com/doc/refman/5.7/en/explain-output.html https://juejin.cn/post/6953444668973514789 "},{"id":547,"href":"/zh/docs/technology/Interview/database/mysql/mysql-auto-increment-primary-key-continuous/","title":"MySQL自增主键一定是连续的吗","section":"Mysql","content":" 作者:飞天小牛肉\n原文: https://mp.weixin.qq.com/s/qci10h9rJx_COZbHV3aygQ\n众所周知,自增主键可以让聚集索引尽量地保持递增顺序插入,避免了随机查询,从而提高了查询效率。\n但实际上,MySQL 的自增主键并不能保证一定是连续递增的。\n下面举个例子来看下,如下所示创建一张表:\n自增值保存在哪里? # 使用 insert into test_pk values(null, 1, 1) 插入一行数据,再执行 show create table 命令来看一下表的结构定义:\n上述表的结构定义存放在后缀名为 .frm 的本地文件中,在 MySQL 安装目录下的 data 文件夹下可以找到这个 .frm 文件:\n从上述表结构可以看到,表定义里面出现了一个 AUTO_INCREMENT=2,表示下一次插入数据时,如果需要自动生成自增值,会生成 id = 2。\n但需要注意的是,自增值并不会保存在这个表结构也就是 .frm 文件中,不同的引擎对于自增值的保存策略不同:\n1)MyISAM 引擎的自增值保存在数据文件中\n2)InnoDB 引擎的自增值,其实是保存在了内存里,并没有持久化。第一次打开表的时候,都会去找自增值的最大值 max(id),然后将 max(id)+1 作为这个表当前的自增值。\n举个例子:我们现在表里当前数据行里最大的 id 是 1,AUTO_INCREMENT=2,对吧。这时候,我们删除 id=1 的行,AUTO_INCREMENT 还是 2。\n但如果马上重启 MySQL 实例,重启后这个表的 AUTO_INCREMENT 就会变成 1。 也就是说,MySQL 重启可能会修改一个表的 AUTO_INCREMENT 的值。\n以上,是在我本地 MySQL 5.x 版本的实验,实际上,到了 MySQL 8.0 版本后,自增值的变更记录被放在了 redo log 中,提供了自增值持久化的能力 ,也就是实现了“如果发生重启,表的自增值可以根据 redo log 恢复为 MySQL 重启前的值”\n也就是说对于上面这个例子来说,重启实例后这个表的 AUTO_INCREMENT 仍然是 2。\n理解了 MySQL 自增值到底保存在哪里以后,我们再来看看自增值的修改机制,并以此引出第一种自增值不连续的场景。\n自增值不连续的场景 # 自增值不连续场景 1 # 在 MySQL 里面,如果字段 id 被定义为 AUTO_INCREMENT,在插入一行数据的时候,自增值的行为如下:\n如果插入数据时 id 字段指定为 0、null 或未指定值,那么就把这个表当前的 AUTO_INCREMENT 值填到自增字段; 如果插入数据时 id 字段指定了具体的值,就直接使用语句里指定的值。 根据要插入的值和当前自增值的大小关系,自增值的变更结果也会有所不同。假设某次要插入的值是 insert_num,当前的自增值是 autoIncrement_num:\n如果 insert_num \u0026lt; autoIncrement_num,那么这个表的自增值不变 如果 insert_num \u0026gt;= autoIncrement_num,就需要把当前自增值修改为新的自增值 也就是说,如果插入的 id 是 100,当前的自增值是 90,insert_num \u0026gt;= autoIncrement_num,那么自增值就会被修改为新的自增值即 101\n一定是这样吗?\n非也~\n了解过分布式 id 的小伙伴一定知道,为了避免两个库生成的主键发生冲突,我们可以让一个库的自增 id 都是奇数,另一个库的自增 id 都是偶数\n这个奇数偶数其实是通过 auto_increment_offset 和 auto_increment_increment 这两个参数来决定的,这俩分别用来表示自增的初始值和步长,默认值都是 1。\n所以,上面的例子中生成新的自增值的步骤实际是这样的:从 auto_increment_offset 开始,以 auto_increment_increment 为步长,持续叠加,直到找到第一个大于 100 的值,作为新的自增值。\n所以,这种情况下,自增值可能会是 102,103 等等之类的,就会导致不连续的主键 id。\n更遗憾的是,即使在自增初始值和步长这两个参数都设置为 1 的时候,自增主键 id 也不一定能保证主键是连续的\n自增值不连续场景 2 # 举个例子,我们现在往表里插入一条 (null,1,1) 的记录,生成的主键是 1,AUTO_INCREMENT= 2,对吧\n这时我再执行一条插入 (null,1,1) 的命令,很显然会报错 Duplicate entry,因为我们设置了一个唯一索引字段 a:\n但是,你会惊奇的发现,虽然插入失败了,但自增值仍然从 2 增加到了 3!\n这是为啥?\n我们来分析下这个 insert 语句的执行流程:\n执行器调用 InnoDB 引擎接口准备插入一行记录 (null,1,1); InnoDB 发现用户没有指定自增 id 的值,则获取表 test_pk 当前的自增值 2; 将传入的记录改成 (2,1,1); 将表的自增值改成 3; 继续执行插入数据操作,由于已经存在 a=1 的记录,所以报 Duplicate key error,语句返回 可以看到,自增值修改的这个操作,是在真正执行插入数据的操作之前。\n这个语句真正执行的时候,因为碰到唯一键 a 冲突,所以 id = 2 这一行并没有插入成功,但也没有将自增值再改回去。所以,在这之后,再插入新的数据行时,拿到的自增 id 就是 3。也就是说,出现了自增主键不连续的情况。\n至此,我们已经罗列了两种自增主键不连续的情况:\n自增初始值和自增步长设置不为 1 唯一键冲突 除此之外,事务回滚也会导致这种情况\n自增值不连续场景 3 # 我们现在表里有一行 (1,1,1) 的记录,AUTO_INCREMENT = 3:\n我们先插入一行数据 (null, 2, 2),也就是 (3, 2, 2) 嘛,并且 AUTO_INCREMENT 变为 4:\n再去执行这样一段 SQL:\n虽然我们插入了一条 (null, 3, 3) 记录,但是使用 rollback 进行回滚了,所以数据库中是没有这条记录的:\n在这种事务回滚的情况下,自增值并没有同样发生回滚!如下图所示,自增值仍然固执地从 4 增加到了 5:\n所以这时候我们再去插入一条数据(null, 3, 3)的时候,主键 id 就会被自动赋为 5 了:\n那么,为什么在出现唯一键冲突或者回滚的时候,MySQL 没有把表的自增值改回去呢?回退回去的话不就不会发生自增 id 不连续了吗?\n事实上,这么做的主要原因是为了提高性能。\n我们直接用反证法来验证:假设 MySQL 在事务回滚的时候会把自增值改回去,会发生什么?\n现在有两个并行执行的事务 A 和 B,在申请自增值的时候,为了避免两个事务申请到相同的自增 id,肯定要加锁,然后顺序申请,对吧。\n假设事务 A 申请到了 id = 1, 事务 B 申请到 id=2,那么这时候表 t 的自增值是 3,之后继续执行。 事务 B 正确提交了,但事务 A 出现了唯一键冲突,也就是 id = 1 的那行记录插入失败了,那如果允许事务 A 把自增 id 回退,也就是把表的当前自增值改回 1,那么就会出现这样的情况:表里面已经有 id = 2 的行,而当前的自增 id 值是 1。 接下来,继续执行的其他事务就会申请到 id=2。这时,就会出现插入语句报错“主键冲突”。 而为了解决这个主键冲突,有两种方法:\n每次申请 id 之前,先判断表里面是否已经存在这个 id,如果存在,就跳过这个 id 把自增 id 的锁范围扩大,必须等到一个事务执行完成并提交,下一个事务才能再申请自增 id 很显然,上述两个方法的成本都比较高,会导致性能问题。而究其原因呢,是我们假设的这个 “允许自增 id 回退”。\n因此,InnoDB 放弃了这个设计,语句执行失败也不回退自增 id。也正是因为这样,所以才只保证了自增 id 是递增的,但不保证是连续的。\n综上,已经分析了三种自增值不连续的场景,还有第四种场景:批量插入数据。\n自增值不连续场景 4 # 对于批量插入数据的语句,MySQL 有一个批量申请自增 id 的策略:\n语句执行过程中,第一次申请自增 id,会分配 1 个; 1 个用完以后,这个语句第二次申请自增 id,会分配 2 个; 2 个用完以后,还是这个语句,第三次申请自增 id,会分配 4 个; 依此类推,同一个语句去申请自增 id,每次申请到的自增 id 个数都是上一次的两倍。 注意,这里说的批量插入数据,不是在普通的 insert 语句里面包含多个 value 值!!!,因为这类语句在申请自增 id 的时候,是可以精确计算出需要多少个 id 的,然后一次性申请,申请完成后锁就可以释放了。\n而对于 insert … select、replace …… select 和 load data 这种类型的语句来说,MySQL 并不知道到底需要申请多少 id,所以就采用了这种批量申请的策略,毕竟一个一个申请的话实在太慢了。\n举个例子,假设我们现在这个表有下面这些数据:\n我们创建一个和当前表 test_pk 有相同结构定义的表 test_pk2:\n然后使用 insert...select 往 teset_pk2 表中批量插入数据:\n可以看到,成功导入了数据。\n再来看下 test_pk2 的自增值是多少:\n如上分析,是 8 而不是 6\n具体来说,insert……select 实际上往表中插入了 5 行数据 (1 1)(2 2)(3 3)(4 4)(5 5)。但是,这五行数据是分三次申请的自增 id,结合批量申请策略,每次申请到的自增 id 个数都是上一次的两倍,所以:\n第一次申请到了一个 id:id=1 第二次被分配了两个 id:id=2 和 id=3 第三次被分配到了 4 个 id:id=4、id = 5、id = 6、id=7 由于这条语句实际只用上了 5 个 id,所以 id=6 和 id=7 就被浪费掉了。之后,再执行 insert into test_pk2 values(null,6,6),实际上插入的数据就是(8,6,6):\n小结 # 本文总结下自增值不连续的 4 个场景:\n自增初始值和自增步长设置不为 1 唯一键冲突 事务回滚 批量插入(如 insert...select 语句) "},{"id":548,"href":"/zh/docs/technology/Interview/cs-basics/network/nat/","title":"NAT 协议详解(网络层)","section":"Network","content":" 应用场景 # NAT 协议(Network Address Translation) 的应用场景如同它的名称——网络地址转换,应用于内部网到外部网的地址转换过程中。具体地说,在一个小的子网(局域网,Local Area Network,LAN)内,各主机使用的是同一个 LAN 下的 IP 地址,但在该 LAN 以外,在广域网(Wide Area Network,WAN)中,需要一个统一的 IP 地址来标识该 LAN 在整个 Internet 上的位置。\n这个场景其实不难理解。随着一个个小型办公室、家庭办公室(Small Office, Home Office, SOHO)的出现,为了管理这些 SOHO,一个个子网被设计出来,从而在整个 Internet 中的主机数量将非常庞大。如果每个主机都有一个“绝对唯一”的 IP 地址,那么 IPv4 地址的表达能力可能很快达到上限($2^{32}$)。因此,实际上,SOHO 子网中的 IP 地址是“相对的”,这在一定程度上也缓解了 IPv4 地址的分配压力。\nSOHO 子网的“代理人”,也就是和外界的窗口,通常由路由器扮演。路由器的 LAN 一侧管理着一个小子网,而它的 WAN 接口才是真正参与到 Internet 中的接口,也就有一个“绝对唯一的地址”。NAT 协议,正是在 LAN 中的主机在与 LAN 外界通信时,起到了地址转换的关键作用。\n细节 # 假设当前场景如上图。中间是一个路由器,它的右侧组织了一个 LAN,网络号为10.0.0/24。LAN 侧接口的 IP 地址为10.0.0.4,并且该子网内有至少三台主机,分别是10.0.0.1,10.0.0.2和10.0.0.3。路由器的左侧连接的是 WAN,WAN 侧接口的 IP 地址为138.76.29.7。\n首先,针对以上信息,我们有如下事实需要说明:\n路由器的右侧子网的网络号为10.0.0/24,主机号为10.0.0/8,三台主机地址,以及路由器的 LAN 侧接口地址,均由 DHCP 协议规定。而且,该 DHCP 运行在路由器内部(路由器自维护一个小 DHCP 服务器),从而为子网内提供 DHCP 服务。 路由器的 WAN 侧接口地址同样由 DHCP 协议规定,但该地址是路由器从 ISP(网络服务提供商)处获得,也就是该 DHCP 通常运行在路由器所在区域的 DHCP 服务器上。 现在,路由器内部还运行着 NAT 协议,从而为 LAN-WAN 间通信提供地址转换服务。为此,一个很重要的结构是 NAT 转换表。为了说明 NAT 的运行细节,假设有以下请求发生:\n主机10.0.0.1向 IP 地址为128.119.40.186的 Web 服务器(端口 80)发送了 HTTP 请求(如请求页面)。此时,主机10.0.0.1将随机指派一个端口,如3345,作为本次请求的源端口号,将该请求发送到路由器中(目的地址将是128.119.40.186,但会先到达10.0.0.4)。 10.0.0.4即路由器的 LAN 接口收到10.0.0.1的请求。路由器将为该请求指派一个新的源端口号,如5001,并将请求报文发送给 WAN 接口138.76.29.7。同时,在 NAT 转换表中记录一条转换记录138.76.29.7:5001——10.0.0.1:3345。 请求报文到达 WAN 接口,继续向目的主机128.119.40.186发送。 之后,将会有如下响应发生:\n主机128.119.40.186收到请求,构造响应报文,并将其发送给目的地138.76.29.7:5001。 响应报文到达路由器的 WAN 接口。路由器查询 NAT 转换表,发现138.76.29.7:5001在转换表中有记录,从而将其目的地址和目的端口转换成为10.0.0.1:3345,再发送到10.0.0.4上。 被转换的响应报文到达路由器的 LAN 接口,继而被转发至目的地10.0.0.1。 🐛 修正(参见: issue#2009):上图第四步的 Dest 值应该为 10.0.0.1:3345 而不是~~138.76.29.7:5001~~,这里笔误了。\n划重点 # 针对以上过程,有以下几个重点需要强调:\n当请求报文到达路由器,并被指定了新端口号时,由于端口号有 16 位,因此,通常来说,一个路由器管理的 LAN 中的最大主机数 $≈65500$($2^{16}$ 的地址空间),但通常 SOHO 子网内不会有如此多的主机数量。 对于目的服务器来说,从来不知道“到底是哪个主机给我发送的请求”,它只知道是来自138.76.29.7:5001的路由器转发的请求。因此,可以说,==路由器在 WAN 和 LAN 之间起到了屏蔽作用,==所有内部主机发送到外部的报文,都具有同一个 IP 地址(不同的端口号),所有外部发送到内部的报文,也都只有一个目的地(不同端口号),是经过了 NAT 转换后,外部报文才得以正确地送达内部主机。 在报文穿过路由器,发生 NAT 转换时,如果 LAN 主机 IP 已经在 NAT 转换表中注册过了,则不需要路由器新指派端口,而是直接按照转换记录穿过路由器。同理,外部报文发送至内部时也如此。 总结 NAT 协议的特点,有以下几点:\nNAT 协议通过对 WAN 屏蔽 LAN,有效地缓解了 IPv4 地址分配压力。 LAN 主机 IP 地址的变更,无需通告 WAN。 WAN 的 ISP 变更接口地址时,无需通告 LAN 内主机。 LAN 主机对 WAN 不可见,不可直接寻址,可以保证一定程度的安全性。 然而,NAT 协议由于其独特性,存在着一些争议。比如,可能你已经注意到了,==NAT 协议在 LAN 以外,标识一个内部主机时,使用的是端口号,因为 IP 地址都是相同的。==这种将端口号作为主机寻址的行为,可能会引发一些误会。此外,路由器作为网络层的设备,修改了传输层的分组内容(修改了源 IP 地址和端口号),同样是不规范的行为。但是,尽管如此,NAT 协议作为 IPv4 时代的产物,极大地方便了一些本来棘手的问题,一直被沿用至今。\n"},{"id":549,"href":"/zh/docs/technology/Interview/system-design/framework/netty/","title":"Netty常见面试题总结(付费)","section":"Framework","content":"Netty 相关的面试题为我的 知识星球(点击链接即可查看详细介绍以及加入方法)专属内容,已经整理到了 《Java 面试指北》中。\n"},{"id":550,"href":"/zh/docs/technology/Interview/database/nosql/","title":"NoSQL基础知识总结","section":"Database","content":" NoSQL 是什么? # NoSQL(Not Only SQL 的缩写)泛指非关系型的数据库,主要针对的是键值、文档以及图形类型数据存储。并且,NoSQL 数据库天生支持分布式,数据冗余和数据分片等特性,旨在提供可扩展的高可用高性能数据存储解决方案。\n一个常见的误解是 NoSQL 数据库或非关系型数据库不能很好地存储关系型数据。NoSQL 数据库可以存储关系型数据—它们与关系型数据库的存储方式不同。\nNoSQL 数据库代表:HBase、Cassandra、MongoDB、Redis。\nSQL 和 NoSQL 有什么区别? # SQL 数据库 NoSQL 数据库 数据存储模型 结构化存储,具有固定行和列的表格 非结构化存储。文档:JSON 文档,键值:键值对,宽列:包含行和动态列的表,图:节点和边 发展历程 开发于 1970 年代,重点是减少数据重复 开发于 2000 年代后期,重点是提升可扩展性,减少大规模数据的存储成本 例子 Oracle、MySQL、Microsoft SQL Server、PostgreSQL 文档:MongoDB、CouchDB,键值:Redis、DynamoDB,宽列:Cassandra、 HBase,图表:Neo4j、 Amazon Neptune、Giraph ACID 属性 提供原子性、一致性、隔离性和持久性 (ACID) 属性 通常不支持 ACID 事务,为了可扩展、高性能进行了权衡,少部分支持比如 MongoDB 。不过,MongoDB 对 ACID 事务 的支持和 MySQL 还是有所区别的。 性能 性能通常取决于磁盘子系统。要获得最佳性能,通常需要优化查询、索引和表结构。 性能通常由底层硬件集群大小、网络延迟以及调用应用程序来决定。 扩展 垂直(使用性能更强大的服务器进行扩展)、读写分离、分库分表 横向(增加服务器的方式横向扩展,通常是基于分片机制) 用途 普通企业级的项目的数据存储 用途广泛比如图数据库支持分析和遍历连接数据之间的关系、键值数据库可以处理大量数据扩展和极高的状态变化 查询语法 结构化查询语言 (SQL) 数据访问语法可能因数据库而异 NoSQL 数据库有什么优势? # NoSQL 数据库非常适合许多现代应用程序,例如移动、Web 和游戏等应用程序,它们需要灵活、可扩展、高性能和功能强大的数据库以提供卓越的用户体验。\n灵活性: NoSQL 数据库通常提供灵活的架构,以实现更快速、更多的迭代开发。灵活的数据模型使 NoSQL 数据库成为半结构化和非结构化数据的理想之选。 可扩展性: NoSQL 数据库通常被设计为通过使用分布式硬件集群来横向扩展,而不是通过添加昂贵和强大的服务器来纵向扩展。 高性能: NoSQL 数据库针对特定的数据模型和访问模式进行了优化,这与尝试使用关系数据库完成类似功能相比可实现更高的性能。 强大的功能: NoSQL 数据库提供功能强大的 API 和数据类型,专门针对其各自的数据模型而构建。 NoSQL 数据库有哪些类型? # NoSQL 数据库主要可以分为下面四种类型:\n键值:键值数据库是一种较简单的数据库,其中每个项目都包含键和值。这是极为灵活的 NoSQL 数据库类型,因为应用可以完全控制 value 字段中存储的内容,没有任何限制。Redis 和 DynanoDB 是两款非常流行的键值数据库。 文档:文档数据库中的数据被存储在类似于 JSON(JavaScript 对象表示法)对象的文档中,非常清晰直观。每个文档包含成对的字段和值。这些值通常可以是各种类型,包括字符串、数字、布尔值、数组或对象等,并且它们的结构通常与开发者在代码中使用的对象保持一致。MongoDB 就是一款非常流行的文档数据库。 图形:图形数据库旨在轻松构建和运行与高度连接的数据集一起使用的应用程序。图形数据库的典型使用案例包括社交网络、推荐引擎、欺诈检测和知识图形。Neo4j 和 Giraph 是两款非常流行的图形数据库。 宽列:宽列存储数据库非常适合需要存储大量的数据。Cassandra 和 HBase 是两款非常流行的宽列存储数据库。 下面这张图片来源于 微软的官方文档 | 关系数据与 NoSQL 数据。\n参考 # NoSQL 是什么?- MongoDB 官方文档: https://www.mongodb.com/zh-cn/nosql-explained 什么是 NoSQL? - AWS: https://aws.amazon.com/cn/nosql/ NoSQL vs. SQL Databases - MongoDB 官方文档: https://www.mongodb.com/zh-cn/nosql-explained/nosql-vs-sql "},{"id":551,"href":"/zh/docs/technology/Interview/cs-basics/network/osi-and-tcp-ip-model/","title":"OSI 和 TCP/IP 网络分层模型详解(基础)","section":"Network","content":" OSI 七层模型 # OSI 七层模型 是国际标准化组织提出一个网络分层模型,其大体结构以及每一层提供的功能如下图所示:\n每一层都专注做一件事情,并且每一层都需要使用下一层提供的功能比如传输层需要使用网络层提供的路由和寻址功能,这样传输层才知道把数据传输到哪里去。\nOSI 的七层体系结构概念清楚,理论也很完整,但是它比较复杂而且不实用,而且有些功能在多个层中重复出现。\n上面这种图可能比较抽象,再来一个比较生动的图片。下面这个图片是我在国外的一个网站上看到的,非常赞!\n既然 OSI 七层模型这么厉害,为什么干不过 TCP/IP 四 层模型呢?\n的确,OSI 七层模型当时一直被一些大公司甚至一些国家政府支持。这样的背景下,为什么会失败呢?我觉得主要有下面几方面原因:\nOSI 的专家缺乏实际经验,他们在完成 OSI 标准时缺乏商业驱动力 OSI 的协议实现起来过分复杂,而且运行效率很低 OSI 制定标准的周期太长,因而使得按 OSI 标准生产的设备无法及时进入市场(20 世纪 90 年代初期,虽然整套的 OSI 国际标准都已经制定出来,但基于 TCP/IP 的互联网已经抢先在全球相当大的范围成功运行了) OSI 的层次划分不太合理,有些功能在多个层次中重复出现。 OSI 七层模型虽然失败了,但是却提供了很多不错的理论基础。为了更好地去了解网络分层,OSI 七层模型还是非常有必要学习的。\n最后再分享一个关于 OSI 七层模型非常不错的总结图片!\nTCP/IP 四层模型 # TCP/IP 四层模型 是目前被广泛采用的一种模型,我们可以将 TCP / IP 模型看作是 OSI 七层模型的精简版本,由以下 4 层组成:\n应用层 传输层 网络层 网络接口层 需要注意的是,我们并不能将 TCP/IP 四层模型 和 OSI 七层模型完全精确地匹配起来,不过可以简单将两者对应起来,如下图所示:\n应用层(Application layer) # 应用层位于传输层之上,主要提供两个终端设备上的应用程序之间信息交换的服务,它定义了信息交换的格式,消息会交给下一层传输层来传输。 我们把应用层交互的数据单元称为报文。\n应用层协议定义了网络通信规则,对于不同的网络应用需要不同的应用层协议。在互联网中应用层协议很多,如支持 Web 应用的 HTTP 协议,支持电子邮件的 SMTP 协议等等。\n应用层常见协议:\nHTTP(Hypertext Transfer Protocol,超文本传输协议):基于 TCP 协议,是一种用于传输超文本和多媒体内容的协议,主要是为 Web 浏览器与 Web 服务器之间的通信而设计的。当我们使用浏览器浏览网页的时候,我们网页就是通过 HTTP 请求进行加载的。 SMTP(Simple Mail Transfer Protocol,简单邮件发送协议):基于 TCP 协议,是一种用于发送电子邮件的协议。注意 ⚠️:SMTP 协议只负责邮件的发送,而不是接收。要从邮件服务器接收邮件,需要使用 POP3 或 IMAP 协议。 POP3/IMAP(邮件接收协议):基于 TCP 协议,两者都是负责邮件接收的协议。IMAP 协议是比 POP3 更新的协议,它在功能和性能上都更加强大。IMAP 支持邮件搜索、标记、分类、归档等高级功能,而且可以在多个设备之间同步邮件状态。几乎所有现代电子邮件客户端和服务器都支持 IMAP。 FTP(File Transfer Protocol,文件传输协议) : 基于 TCP 协议,是一种用于在计算机之间传输文件的协议,可以屏蔽操作系统和文件存储方式。注意 ⚠️:FTP 是一种不安全的协议,因为它在传输过程中不会对数据进行加密。建议在传输敏感数据时使用更安全的协议,如 SFTP。 Telnet(远程登陆协议):基于 TCP 协议,用于通过一个终端登陆到其他服务器。Telnet 协议的最大缺点之一是所有数据(包括用户名和密码)均以明文形式发送,这有潜在的安全风险。这就是为什么如今很少使用 Telnet,而是使用一种称为 SSH 的非常安全的网络传输协议的主要原因。 SSH(Secure Shell Protocol,安全的网络传输协议):基于 TCP 协议,通过加密和认证机制实现安全的访问和文件传输等业务 RTP(Real-time Transport Protocol,实时传输协议):通常基于 UDP 协议,但也支持 TCP 协议。它提供了端到端的实时传输数据的功能,但不包含资源预留存、不保证实时传输质量,这些功能由 WebRTC 实现。 DNS(Domain Name System,域名管理系统): 基于 UDP 协议,用于解决域名和 IP 地址的映射问题。 关于这些协议的详细介绍请看 应用层常见协议总结(应用层) 这篇文章。\n传输层(Transport layer) # 传输层的主要任务就是负责向两台终端设备进程之间的通信提供通用的数据传输服务。 应用进程利用该服务传送应用层报文。“通用的”是指并不针对某一个特定的网络应用,而是多种应用可以使用同一个运输层服务。\n传输层常见协议:\nTCP(Transmission Control Protocol,传输控制协议 ):提供 面向连接 的,可靠 的数据传输服务。 UDP(User Datagram Protocol,用户数据协议):提供 无连接 的,尽最大努力 的数据传输服务(不保证数据传输的可靠性),简单高效。 网络层(Network layer) # 网络层负责为分组交换网上的不同主机提供通信服务。 在发送数据时,网络层把运输层产生的报文段或用户数据报封装成分组和包进行传送。在 TCP/IP 体系结构中,由于网络层使用 IP 协议,因此分组也叫 IP 数据报,简称数据报。\n⚠️ 注意:不要把运输层的“用户数据报 UDP”和网络层的“IP 数据报”弄混。\n网络层的还有一个任务就是选择合适的路由,使源主机运输层所传下来的分组,能通过网络层中的路由器找到目的主机。\n这里强调指出,网络层中的“网络”二字已经不是我们通常谈到的具体网络,而是指计算机网络体系结构模型中第三层的名称。\n互联网是由大量的异构(heterogeneous)网络通过路由器(router)相互连接起来的。互联网使用的网络层协议是无连接的网际协议(Internet Protocol)和许多路由选择协议,因此互联网的网络层也叫做 网际层 或 IP 层。\n网络层常见协议:\nIP(Internet Protocol,网际协议):TCP/IP 协议中最重要的协议之一,主要作用是定义数据包的格式、对数据包进行路由和寻址,以便它们可以跨网络传播并到达正确的目的地。目前 IP 协议主要分为两种,一种是过去的 IPv4,另一种是较新的 IPv6,目前这两种协议都在使用,但后者已经被提议来取代前者。 ARP(Address Resolution Protocol,地址解析协议):ARP 协议解决的是网络层地址和链路层地址之间的转换问题。因为一个 IP 数据报在物理上传输的过程中,总是需要知道下一跳(物理上的下一个目的地)该去往何处,但 IP 地址属于逻辑地址,而 MAC 地址才是物理地址,ARP 协议解决了 IP 地址转 MAC 地址的一些问题。 ICMP(Internet Control Message Protocol,互联网控制报文协议):一种用于传输网络状态和错误消息的协议,常用于网络诊断和故障排除。例如,Ping 工具就使用了 ICMP 协议来测试网络连通性。 NAT(Network Address Translation,网络地址转换协议):NAT 协议的应用场景如同它的名称——网络地址转换,应用于内部网到外部网的地址转换过程中。具体地说,在一个小的子网(局域网,LAN)内,各主机使用的是同一个 LAN 下的 IP 地址,但在该 LAN 以外,在广域网(WAN)中,需要一个统一的 IP 地址来标识该 LAN 在整个 Internet 上的位置。 OSPF(Open Shortest Path First,开放式最短路径优先) ):一种内部网关协议(Interior Gateway Protocol,IGP),也是广泛使用的一种动态路由协议,基于链路状态算法,考虑了链路的带宽、延迟等因素来选择最佳路径。 RIP(Routing Information Protocol,路由信息协议):一种内部网关协议(Interior Gateway Protocol,IGP),也是一种动态路由协议,基于距离向量算法,使用固定的跳数作为度量标准,选择跳数最少的路径作为最佳路径。 BGP(Border Gateway Protocol,边界网关协议):一种用来在路由选择域之间交换网络层可达性信息(Network Layer Reachability Information,NLRI)的路由选择协议,具有高度的灵活性和可扩展性。 网络接口层(Network interface layer) # 我们可以把网络接口层看作是数据链路层和物理层的合体。\n数据链路层(data link layer)通常简称为链路层( 两台主机之间的数据传输,总是在一段一段的链路上传送的)。数据链路层的作用是将网络层交下来的 IP 数据报组装成帧,在两个相邻节点间的链路上传送帧。每一帧包括数据和必要的控制信息(如同步信息,地址信息,差错控制等)。 物理层的作用是实现相邻计算机节点之间比特流的透明传送,尽可能屏蔽掉具体传输介质和物理设备的差异 网络接口层重要功能和协议如下图所示:\n总结 # 简单总结一下每一层包含的协议和核心技术:\n应用层协议 :\nHTTP(Hypertext Transfer Protocol,超文本传输协议) SMTP(Simple Mail Transfer Protocol,简单邮件发送协议) POP3/IMAP(邮件接收协议) FTP(File Transfer Protocol,文件传输协议) Telnet(远程登陆协议) SSH(Secure Shell Protocol,安全的网络传输协议) RTP(Real-time Transport Protocol,实时传输协议) DNS(Domain Name System,域名管理系统) …… 传输层协议 :\nTCP 协议 报文段结构 可靠数据传输 流量控制 拥塞控制 UDP 协议 报文段结构 RDT(可靠数据传输协议) 网络层协议 :\nIP(Internet Protocol,网际协议) ARP(Address Resolution Protocol,地址解析协议) ICMP 协议(控制报文协议,用于发送控制消息) NAT(Network Address Translation,网络地址转换协议) OSPF(Open Shortest Path First,开放式最短路径优先) RIP(Routing Information Protocol,路由信息协议) BGP(Border Gateway Protocol,边界网关协议) …… 网络接口层 :\n差错检测技术 多路访问协议(信道复用技术) CSMA/CD 协议 MAC 协议 以太网技术 …… 网络分层的原因 # 在这篇文章的最后,我想聊聊:“为什么网络要分层?”。\n说到分层,我们先从我们平时使用框架开发一个后台程序来说,我们往往会按照每一层做不同的事情的原则将系统分为三层(复杂的系统分层会更多):\nRepository(数据库操作) Service(业务操作) Controller(前后端数据交互) 复杂的系统需要分层,因为每一层都需要专注于一类事情。网络分层的原因也是一样,每一层只专注于做一类事情。\n好了,再来说回:“为什么网络要分层?”。我觉得主要有 3 方面的原因:\n各层之间相互独立:各层之间相互独立,各层之间不需要关心其他层是如何实现的,只需要知道自己如何调用下层提供好的功能就可以了(可以简单理解为接口调用)。这个和我们对开发时系统进行分层是一个道理。 提高了整体灵活性:每一层都可以使用最适合的技术来实现,你只需要保证你提供的功能以及暴露的接口的规则没有改变就行了。这个和我们平时开发系统的时候要求的高内聚、低耦合的原则也是可以对应上的。 大问题化小:分层可以将复杂的网络问题分解为许多比较小的、界线比较清晰简单的小问题来处理和解决。这样使得复杂的计算机网络系统变得易于设计,实现和标准化。 这个和我们平时开发的时候,一般会将系统功能分解,然后将复杂的问题分解为容易理解的更小的问题是相对应的,这些较小的问题具有更好的边界(目标和接口)定义。 我想到了计算机世界非常非常有名的一句话,这里分享一下:\n计算机科学领域的任何问题都可以通过增加一个间接的中间层来解决,计算机整个体系从上到下都是按照严格的层次结构设计的。\n参考 # TCP/IP model vs OSI model: https://fiberbit.com.tw/tcpip-model-vs-osi-model/ Data Encapsulation and the TCP/IP Protocol Stack: https://docs.oracle.com/cd/E19683-01/806-4075/ipov-32/index.html "},{"id":552,"href":"/zh/docs/technology/Interview/distributed-system/protocol/paxos-algorithm/","title":"Paxos 算法详解","section":"Protocol","content":" 背景 # Paxos 算法是 Leslie Lamport( 莱斯利·兰伯特)在 1990 年提出了一种分布式系统 共识 算法。这也是第一个被证明完备的共识算法(前提是不存在拜占庭将军问题,也就是没有恶意节点)。\n为了介绍 Paxos 算法,兰伯特专门写了一篇幽默风趣的论文。在这篇论文中,他虚拟了一个叫做 Paxos 的希腊城邦来更形象化地介绍 Paxos 算法。\n不过,审稿人并不认可这篇论文的幽默。于是,他们就给兰伯特说:“如果你想要成功发表这篇论文的话,必须删除所有 Paxos 相关的故事背景”。兰伯特一听就不开心了:“我凭什么修改啊,你们这些审稿人就是缺乏幽默细胞,发不了就不发了呗!”。\n于是乎,提出 Paxos 算法的那篇论文在当时并没有被成功发表。\n直到 1998 年,系统研究中心 (Systems Research Center,SRC)的两个技术研究员需要找一些合适的分布式算法来服务他们正在构建的分布式系统,Paxos 算法刚好可以解决他们的部分需求。因此,兰伯特就把论文发给了他们。在看了论文之后,这俩大佬觉得论文还是挺不错的。于是,兰伯特在 1998 年重新发表论文 《The Part-Time Parliament》。\n论文发表之后,各路学者直呼看不懂,言语中还略显调侃之意。这谁忍得了,在 2001 年的时候,兰伯特专门又写了一篇 《Paxos Made Simple》 的论文来简化对 Paxos 的介绍,主要讲述两阶段共识协议部分,顺便还不忘嘲讽一下这群学者。\n《Paxos Made Simple》这篇论文就 14 页,相比于 《The Part-Time Parliament》的 33 页精简了不少。最关键的是这篇论文的摘要就一句话:\nThe Paxos algorithm, when presented in plain English, is very simple.\n翻译过来的意思大概就是:当我用无修饰的英文来描述时,Paxos 算法真心简单!\n有没有感觉到来自兰伯特大佬满满地嘲讽的味道?\n介绍 # Paxos 算法是第一个被证明完备的分布式系统共识算法。共识算法的作用是让分布式系统中的多个节点之间对某个提案(Proposal)达成一致的看法。提案的含义在分布式系统中十分宽泛,像哪一个节点是 Leader 节点、多个事件发生的顺序等等都可以是一个提案。\n兰伯特当时提出的 Paxos 算法主要包含 2 个部分:\nBasic Paxos 算法:描述的是多节点之间如何就某个值(提案 Value)达成共识。 Multi-Paxos 思想:描述的是执行多个 Basic Paxos 实例,就一系列值达成共识。Multi-Paxos 说白了就是执行多次 Basic Paxos ,核心还是 Basic Paxos 。 由于 Paxos 算法在国际上被公认的非常难以理解和实现,因此不断有人尝试简化这一算法。到了 2013 年才诞生了一个比 Paxos 算法更易理解和实现的共识算法— Raft 算法 。更具体点来说,Raft 是 Multi-Paxos 的一个变种,其简化了 Multi-Paxos 的思想,变得更容易被理解以及工程实现。\n针对没有恶意节点的情况,除了 Raft 算法之外,当前最常用的一些共识算法比如 ZAB 协议、 Fast Paxos 算法都是基于 Paxos 算法改进的。\n针对存在恶意节点的情况,一般使用的是 工作量证明(POW,Proof-of-Work)、 权益证明(PoS,Proof-of-Stake ) 等共识算法。这类共识算法最典型的应用就是区块链,就比如说前段时间以太坊官方宣布其共识机制正在从工作量证明(PoW)转变为权益证明(PoS)。\n区块链系统使用的共识算法需要解决的核心问题是 拜占庭将军问题 ,这和我们日常接触到的 ZooKeeper、Etcd、Consul 等分布式中间件不太一样。\n下面我们来对 Paxos 算法的定义做一个总结:\nPaxos 算法是兰伯特在 1990 年提出了一种分布式系统共识算法。 兰伯特当时提出的 Paxos 算法主要包含 2 个部分: Basic Paxos 算法和 Multi-Paxos 思想。 Raft 算法、ZAB 协议、 Fast Paxos 算法都是基于 Paxos 算法改进而来。 Basic Paxos 算法 # Basic Paxos 中存在 3 个重要的角色:\n提议者(Proposer):也可以叫做协调者(coordinator),提议者负责接受客户端的请求并发起提案。提案信息通常包括提案编号 (Proposal ID) 和提议的值 (Value)。 接受者(Acceptor):也可以叫做投票员(voter),负责对提议者的提案进行投票,同时需要记住自己的投票历史; 学习者(Learner):如果有超过半数接受者就某个提议达成了共识,那么学习者就需要接受这个提议,并就该提议作出运算,然后将运算结果返回给客户端。 为了减少实现该算法所需的节点数,一个节点可以身兼多个角色。并且,一个提案被选定需要被半数以上的 Acceptor 接受。这样的话,Basic Paxos 算法还具备容错性,在少于一半的节点出现故障时,集群仍能正常工作。\nMulti Paxos 思想 # Basic Paxos 算法的仅能就单个值达成共识,为了能够对一系列的值达成共识,我们需要用到 Multi Paxos 思想。\n⚠️注意:Multi-Paxos 只是一种思想,这种思想的核心就是通过多个 Basic Paxos 实例就一系列值达成共识。也就是说,Basic Paxos 是 Multi-Paxos 思想的核心,Multi-Paxos 就是多执行几次 Basic Paxos。\n由于兰伯特提到的 Multi-Paxos 思想缺少代码实现的必要细节(比如怎么选举领导者),所以在理解和实现上比较困难。\n不过,也不需要担心,我们并不需要自己实现基于 Multi-Paxos 思想的共识算法,业界已经有了比较出名的实现。像 Raft 算法就是 Multi-Paxos 的一个变种,其简化了 Multi-Paxos 的思想,变得更容易被理解以及工程实现,实际项目中可以优先考虑 Raft 算法。\n参考 # https://zh.wikipedia.org/wiki/Paxos 分布式系统中的一致性与共识算法: http://www.xuyasong.com/?p=1970 "},{"id":553,"href":"/zh/docs/technology/Interview/java/collection/priorityqueue-source-code/","title":"PriorityQueue 源码分析(付费)","section":"Collection","content":"PriorityQueue 源码分析 为我的 知识星球(点击链接即可查看详细介绍以及加入方法)专属内容,已经整理到了 《Java 必读源码系列》中。\n"},{"id":554,"href":"/zh/docs/technology/Interview/high-performance/message-queue/rabbitmq-questions/","title":"RabbitMQ常见问题总结","section":"High Performance","content":" 本篇文章由 JavaGuide 收集自网络,原出处不明。\nRabbitMQ 是什么? # RabbitMQ 是一个在 AMQP(Advanced Message Queuing Protocol )基础上实现的,可复用的企业消息系统。它可以用于大型软件系统各个模块之间的高效通信,支持高并发,支持可扩展。它支持多种客户端如:Python、Ruby、.NET、Java、JMS、C、PHP、ActionScript、XMPP、STOMP 等,支持 AJAX,持久化,用于在分布式系统中存储转发消息,在易用性、扩展性、高可用性等方面表现不俗。\nRabbitMQ 是使用 Erlang 编写的一个开源的消息队列,本身支持很多的协议:AMQP,XMPP, SMTP, STOMP,也正是如此,使的它变的非常重量级,更适合于企业级的开发。它同时实现了一个 Broker 构架,这意味着消息在发送给客户端时先在中心队列排队,对路由(Routing)、负载均衡(Load balance)或者数据持久化都有很好的支持。\nPS:也可能直接问什么是消息队列?消息队列就是一个使用队列来通信的组件。\nRabbitMQ 特点? # 可靠性: RabbitMQ 使用一些机制来保证可靠性, 如持久化、传输确认及发布确认等。 灵活的路由 : 在消息进入队列之前,通过交换器来路由消息。对于典型的路由功能, RabbitMQ 己经提供了一些内置的交换器来实现。针对更复杂的路由功能,可以将多个交换器绑定在一起, 也可以通过插件机制来实现自己的交换器。 扩展性: 多个 RabbitMQ 节点可以组成一个集群,也可以根据实际业务情况动态地扩展 集群中节点。 高可用性 : 队列可以在集群中的机器上设置镜像,使得在部分节点出现问题的情况下队 列仍然可用。 多种协议: RabbitMQ 除了原生支持 AMQP 协议,还支持 STOMP, MQTT 等多种消息 中间件协议。 多语言客户端 :RabbitMQ 几乎支持所有常用语言,比如 Java、 Python、 Ruby、 PHP、 C#、 JavaScript 等。 管理界面 : RabbitMQ 提供了一个易用的用户界面,使得用户可以监控和管理消息、集 群中的节点等。 插件机制 : RabbitMQ 提供了许多插件 , 以实现从多方面进行扩展,当然也可以编写自 己的插件。 RabbitMQ 核心概念? # RabbitMQ 整体上是一个生产者与消费者模型,主要负责接收、存储和转发消息。可以把消息传递的过程想象成:当你将一个包裹送到邮局,邮局会暂存并最终将邮件通过邮递员送到收件人的手上,RabbitMQ 就好比由邮局、邮箱和邮递员组成的一个系统。从计算机术语层面来说,RabbitMQ 模型更像是一种交换机模型。\nRabbitMQ 的整体模型架构如下:\n下面我会一一介绍上图中的一些概念。\nProducer(生产者) 和 Consumer(消费者) # Producer(生产者) :生产消息的一方(邮件投递者) Consumer(消费者) :消费消息的一方(邮件收件人) 消息一般由 2 部分组成:消息头(或者说是标签 Label)和 消息体。消息体也可以称为 payLoad ,消息体是不透明的,而消息头则由一系列的可选属性组成,这些属性包括 routing-key(路由键)、priority(相对于其他消息的优先权)、delivery-mode(指出该消息可能需要持久性存储)等。生产者把消息交由 RabbitMQ 后,RabbitMQ 会根据消息头把消息发送给感兴趣的 Consumer(消费者)。\nExchange(交换器) # 在 RabbitMQ 中,消息并不是直接被投递到 Queue(消息队列) 中的,中间还必须经过 Exchange(交换器) 这一层,Exchange(交换器) 会把我们的消息分配到对应的 Queue(消息队列) 中。\nExchange(交换器) 用来接收生产者发送的消息并将这些消息路由给服务器中的队列中,如果路由不到,或许会返回给 Producer(生产者) ,或许会被直接丢弃掉 。这里可以将 RabbitMQ 中的交换器看作一个简单的实体。\nRabbitMQ 的 Exchange(交换器) 有 4 种类型,不同的类型对应着不同的路由策略:direct(默认),fanout, topic, 和 headers,不同类型的 Exchange 转发消息的策略有所区别。这个会在介绍 Exchange Types(交换器类型) 的时候介绍到。\nExchange(交换器) 示意图如下:\n生产者将消息发给交换器的时候,一般会指定一个 RoutingKey(路由键),用来指定这个消息的路由规则,而这个 RoutingKey 需要与交换器类型和绑定键(BindingKey)联合使用才能最终生效。\nRabbitMQ 中通过 Binding(绑定) 将 Exchange(交换器) 与 Queue(消息队列) 关联起来,在绑定的时候一般会指定一个 BindingKey(绑定建) ,这样 RabbitMQ 就知道如何正确将消息路由到队列了,如下图所示。一个绑定就是基于路由键将交换器和消息队列连接起来的路由规则,所以可以将交换器理解成一个由绑定构成的路由表。Exchange 和 Queue 的绑定可以是多对多的关系。\nBinding(绑定) 示意图:\n生产者将消息发送给交换器时,需要一个 RoutingKey,当 BindingKey 和 RoutingKey 相匹配时,消息会被路由到对应的队列中。在绑定多个队列到同一个交换器的时候,这些绑定允许使用相同的 BindingKey。BindingKey 并不是在所有的情况下都生效,它依赖于交换器类型,比如 fanout 类型的交换器就会无视,而是将消息路由到所有绑定到该交换器的队列中。\nQueue(消息队列) # Queue(消息队列) 用来保存消息直到发送给消费者。它是消息的容器,也是消息的终点。一个消息可投入一个或多个队列。消息一直在队列里面,等待消费者连接到这个队列将其取走。\nRabbitMQ 中消息只能存储在 队列 中,这一点和 Kafka 这种消息中间件相反。Kafka 将消息存储在 topic(主题) 这个逻辑层面,而相对应的队列逻辑只是 topic 实际存储文件中的位移标识。 RabbitMQ 的生产者生产消息并最终投递到队列中,消费者可以从队列中获取消息并消费。\n多个消费者可以订阅同一个队列,这时队列中的消息会被平均分摊(Round-Robin,即轮询)给多个消费者进行处理,而不是每个消费者都收到所有的消息并处理,这样避免消息被重复消费。\nRabbitMQ 不支持队列层面的广播消费,如果有广播消费的需求,需要在其上进行二次开发,这样会很麻烦,不建议这样做。\nBroker(消息中间件的服务节点) # 对于 RabbitMQ 来说,一个 RabbitMQ Broker 可以简单地看作一个 RabbitMQ 服务节点,或者 RabbitMQ 服务实例。大多数情况下也可以将一个 RabbitMQ Broker 看作一台 RabbitMQ 服务器。\n下图展示了生产者将消息存入 RabbitMQ Broker,以及消费者从 Broker 中消费数据的整个流程。\n这样图 1 中的一些关于 RabbitMQ 的基本概念我们就介绍完毕了,下面再来介绍一下 Exchange Types(交换器类型) 。\nExchange Types(交换器类型) # RabbitMQ 常用的 Exchange Type 有 fanout、direct、topic、headers 这四种(AMQP 规范里还提到两种 Exchange Type,分别为 system 与 自定义,这里不予以描述)。\n1、fanout\nfanout 类型的 Exchange 路由规则非常简单,它会把所有发送到该 Exchange 的消息路由到所有与它绑定的 Queue 中,不需要做任何判断操作,所以 fanout 类型是所有的交换机类型里面速度最快的。fanout 类型常用来广播消息。\n2、direct\ndirect 类型的 Exchange 路由规则也很简单,它会把消息路由到那些 Bindingkey 与 RoutingKey 完全匹配的 Queue 中。\n以上图为例,如果发送消息的时候设置路由键为“warning”,那么消息会路由到 Queue1 和 Queue2。如果在发送消息的时候设置路由键为\u0026quot;Info”或者\u0026quot;debug”,消息只会路由到 Queue2。如果以其他的路由键发送消息,则消息不会路由到这两个队列中。\ndirect 类型常用在处理有优先级的任务,根据任务的优先级把消息发送到对应的队列,这样可以指派更多的资源去处理高优先级的队列。\n3、topic\n前面讲到 direct 类型的交换器路由规则是完全匹配 BindingKey 和 RoutingKey ,但是这种严格的匹配方式在很多情况下不能满足实际业务的需求。topic 类型的交换器在匹配规则上进行了扩展,它与 direct 类型的交换器相似,也是将消息路由到 BindingKey 和 RoutingKey 相匹配的队列中,但这里的匹配规则有些不同,它约定:\nRoutingKey 为一个点号“.”分隔的字符串(被点号“.”分隔开的每一段独立的字符串称为一个单词),如 “com.rabbitmq.client”、“java.util.concurrent”、“com.hidden.client”; BindingKey 和 RoutingKey 一样也是点号“.”分隔的字符串; BindingKey 中可以存在两种特殊字符串“*”和“#”,用于做模糊匹配,其中“*”用于匹配一个单词,“#”用于匹配多个单词(可以是零个)。 以上图为例:\n路由键为 “com.rabbitmq.client” 的消息会同时路由到 Queue1 和 Queue2; 路由键为 “com.hidden.client” 的消息只会路由到 Queue2 中; 路由键为 “com.hidden.demo” 的消息只会路由到 Queue2 中; 路由键为 “java.rabbitmq.demo” 的消息只会路由到 Queue1 中; 路由键为 “java.util.concurrent” 的消息将会被丢弃或者返回给生产者(需要设置 mandatory 参数),因为它没有匹配任何路由键。 4、headers(不推荐)\nheaders 类型的交换器不依赖于路由键的匹配规则来路由消息,而是根据发送的消息内容中的 headers 属性进行匹配。在绑定队列和交换器时指定一组键值对,当发送消息到交换器时,RabbitMQ 会获取到该消息的 headers(也是一个键值对的形式),对比其中的键值对是否完全匹配队列和交换器绑定时指定的键值对,如果完全匹配则消息会路由到该队列,否则不会路由到该队列。headers 类型的交换器性能会很差,而且也不实用,基本上不会看到它的存在。\nAMQP 是什么? # RabbitMQ 就是 AMQP 协议的 Erlang 的实现(当然 RabbitMQ 还支持 STOMP2、 MQTT3 等协议 ) AMQP 的模型架构 和 RabbitMQ 的模型架构是一样的,生产者将消息发送给交换器,交换器和队列绑定 。\nRabbitMQ 中的交换器、交换器类型、队列、绑定、路由键等都是遵循的 AMQP 协议中相 应的概念。目前 RabbitMQ 最新版本默认支持的是 AMQP 0-9-1。\nAMQP 协议的三层:\nModule Layer:协议最高层,主要定义了一些客户端调用的命令,客户端可以用这些命令实现自己的业务逻辑。 Session Layer:中间层,主要负责客户端命令发送给服务器,再将服务端应答返回客户端,提供可靠性同步机制和错误处理。 TransportLayer:最底层,主要传输二进制数据流,提供帧的处理、信道复用、错误检测和数据表示等。 AMQP 模型的三大组件:\n交换器 (Exchange):消息代理服务器中用于把消息路由到队列的组件。 队列 (Queue):用来存储消息的数据结构,位于硬盘或内存中。 绑定 (Binding):一套规则,告知交换器消息应该将消息投递给哪个队列。 说说生产者 Producer 和消费者 Consumer? # 生产者 :\n消息生产者,就是投递消息的一方。 消息一般包含两个部分:消息体(payload)和标签(Label)。 消费者:\n消费消息,也就是接收消息的一方。 消费者连接到 RabbitMQ 服务器,并订阅到队列上。消费消息时只消费消息体,丢弃标签。 说说 Broker 服务节点、Queue 队列、Exchange 交换器? # Broker:可以看做 RabbitMQ 的服务节点。一般情况下一个 Broker 可以看做一个 RabbitMQ 服务器。 Queue:RabbitMQ 的内部对象,用于存储消息。多个消费者可以订阅同一队列,这时队列中的消息会被平摊(轮询)给多个消费者进行处理。 Exchange:生产者将消息发送到交换器,由交换器将消息路由到一个或者多个队列中。当路由不到时,或返回给生产者或直接丢弃。 什么是死信队列?如何导致的? # DLX,全称为 Dead-Letter-Exchange,死信交换器,死信邮箱。当消息在一个队列中变成死信 (dead message) 之后,它能被重新发送到另一个交换器中,这个交换器就是 DLX,绑定 DLX 的队列就称之为死信队列。\n导致的死信的几种原因:\n消息被拒(Basic.Reject /Basic.Nack) 且 requeue = false。 消息 TTL 过期。 队列满了,无法再添加。 什么是延迟队列?RabbitMQ 怎么实现延迟队列? # 延迟队列指的是存储对应的延迟消息,消息被发送以后,并不想让消费者立刻拿到消息,而是等待特定时间后,消费者才能拿到这个消息进行消费。\nRabbitMQ 本身是没有延迟队列的,要实现延迟消息,一般有两种方式:\n通过 RabbitMQ 本身队列的特性来实现,需要使用 RabbitMQ 的死信交换机(Exchange)和消息的存活时间 TTL(Time To Live)。 在 RabbitMQ 3.5.7 及以上的版本提供了一个插件(rabbitmq-delayed-message-exchange)来实现延迟队列功能。同时,插件依赖 Erlang/OPT 18.0 及以上。 也就是说,AMQP 协议以及 RabbitMQ 本身没有直接支持延迟队列的功能,但是可以通过 TTL 和 DLX 模拟出延迟队列的功能。\n什么是优先级队列? # RabbitMQ 自 V3.5.0 有优先级队列实现,优先级高的队列会先被消费。\n可以通过x-max-priority参数来实现优先级队列。不过,当消费速度大于生产速度且 Broker 没有堆积的情况下,优先级显得没有意义。\nRabbitMQ 有哪些工作模式? # 简单模式 work 工作模式 pub/sub 发布订阅模式 Routing 路由模式 Topic 主题模式 RabbitMQ 消息怎么传输? # 由于 TCP 链接的创建和销毁开销较大,且并发数受系统资源限制,会造成性能瓶颈,所以 RabbitMQ 使用信道的方式来传输数据。信道(Channel)是生产者、消费者与 RabbitMQ 通信的渠道,信道是建立在 TCP 链接上的虚拟链接,且每条 TCP 链接上的信道数量没有限制。就是说 RabbitMQ 在一条 TCP 链接上建立成百上千个信道来达到多个线程处理,这个 TCP 被多个线程共享,每个信道在 RabbitMQ 都有唯一的 ID,保证了信道私有性,每个信道对应一个线程使用。\n如何保证消息的可靠性? # 消息到 MQ 的过程中搞丢,MQ 自己搞丢,MQ 到消费过程中搞丢。\n生产者到 RabbitMQ:事务机制和 Confirm 机制,注意:事务机制和 Confirm 机制是互斥的,两者不能共存,会导致 RabbitMQ 报错。 RabbitMQ 自身:持久化、集群、普通模式、镜像模式。 RabbitMQ 到消费者:basicAck 机制、死信队列、消息补偿机制。 如何保证 RabbitMQ 消息的顺序性? # 拆分多个 queue(消息队列),每个 queue(消息队列) 一个 consumer(消费者),就是多一些 queue (消息队列)而已,确实是麻烦点; 或者就一个 queue (消息队列)但是对应一个 consumer(消费者),然后这个 consumer(消费者)内部用内存队列做排队,然后分发给底层不同的 worker 来处理。 如何保证 RabbitMQ 高可用的? # RabbitMQ 是比较有代表性的,因为是基于主从(非分布式)做高可用性的,我们就以 RabbitMQ 为例子讲解第一种 MQ 的高可用性怎么实现。RabbitMQ 有三种模式:单机模式、普通集群模式、镜像集群模式。\n单机模式\nDemo 级别的,一般就是你本地启动了玩玩儿的?,没人生产用单机模式。\n普通集群模式\n意思就是在多台机器上启动多个 RabbitMQ 实例,每个机器启动一个。你创建的 queue,只会放在一个 RabbitMQ 实例上,但是每个实例都同步 queue 的元数据(元数据可以认为是 queue 的一些配置信息,通过元数据,可以找到 queue 所在实例)。\n你消费的时候,实际上如果连接到了另外一个实例,那么那个实例会从 queue 所在实例上拉取数据过来。这方案主要是提高吞吐量的,就是说让集群中多个节点来服务某个 queue 的读写操作。\n镜像集群模式\n这种模式,才是所谓的 RabbitMQ 的高可用模式。跟普通集群模式不一样的是,在镜像集群模式下,你创建的 queue,无论元数据还是 queue 里的消息都会存在于多个实例上,就是说,每个 RabbitMQ 节点都有这个 queue 的一个完整镜像,包含 queue 的全部数据的意思。然后每次你写消息到 queue 的时候,都会自动把消息同步到多个实例的 queue 上。RabbitMQ 有很好的管理控制台,就是在后台新增一个策略,这个策略是镜像集群模式的策略,指定的时候是可以要求数据同步到所有节点的,也可以要求同步到指定数量的节点,再次创建 queue 的时候,应用这个策略,就会自动将数据同步到其他的节点上去了。\n这样的好处在于,你任何一个机器宕机了,没事儿,其它机器(节点)还包含了这个 queue 的完整数据,别的 consumer 都可以到其它节点上去消费数据。坏处在于,第一,这个性能开销也太大了吧,消息需要同步到所有机器上,导致网络带宽压力和消耗很重!RabbitMQ 一个 queue 的数据都是放在一个节点里的,镜像集群下,也是每个节点都放这个 queue 的完整数据。\n如何解决消息队列的延时以及过期失效问题? # RabbtiMQ 是可以设置过期时间的,也就是 TTL。如果消息在 queue 中积压超过一定的时间就会被 RabbitMQ 给清理掉,这个数据就没了。那这就是第二个坑了。这就不是说数据会大量积压在 mq 里,而是大量的数据会直接搞丢。我们可以采取一个方案,就是批量重导,这个我们之前线上也有类似的场景干过。就是大量积压的时候,我们当时就直接丢弃数据了,然后等过了高峰期以后,比如大家一起喝咖啡熬夜到晚上 12 点以后,用户都睡觉了。这个时候我们就开始写程序,将丢失的那批数据,写个临时程序,一点一点的查出来,然后重新灌入 mq 里面去,把白天丢的数据给他补回来。也只能是这样了。假设 1 万个订单积压在 mq 里面,没有处理,其中 1000 个订单都丢了,你只能手动写程序把那 1000 个订单给查出来,手动发到 mq 里去再补一次。\n"},{"id":555,"href":"/zh/docs/technology/Interview/distributed-system/protocol/raft-algorithm/","title":"Raft 算法详解","section":"Protocol","content":" 本文由 SnailClimb 和 Xieqijun 共同完成。\n1 背景 # 当今的数据中心和应用程序在高度动态的环境中运行,为了应对高度动态的环境,它们通过额外的服务器进行横向扩展,并且根据需求进行扩展和收缩。同时,服务器和网络故障也很常见。\n因此,系统必须在正常操作期间处理服务器的上下线。它们必须对变故做出反应并在几秒钟内自动适应;对客户来说的话,明显的中断通常是不可接受的。\n幸运的是,分布式共识可以帮助应对这些挑战。\n1.1 拜占庭将军 # 在介绍共识算法之前,先介绍一个简化版拜占庭将军的例子来帮助理解共识算法。\n假设多位拜占庭将军中没有叛军,信使的信息可靠但有可能被暗杀的情况下,将军们如何达成是否要进攻的一致性决定?\n解决方案大致可以理解成:先在所有的将军中选出一个大将军,用来做出所有的决定。\n举例如下:假如现在一共有 3 个将军 A,B 和 C,每个将军都有一个随机时间的倒计时器,倒计时一结束,这个将军就把自己当成大将军候选人,然后派信使传递选举投票的信息给将军 B 和 C,如果将军 B 和 C 还没有把自己当作候选人(自己的倒计时还没有结束),并且没有把选举票投给其他人,它们就会把票投给将军 A,信使回到将军 A 时,将军 A 知道自己收到了足够的票数,成为大将军。在有了大将军之后,是否需要进攻就由大将军 A 决定,然后再去派信使通知另外两个将军,自己已经成为了大将军。如果一段时间还没收到将军 B 和 C 的回复(信使可能会被暗杀),那就再重派一个信使,直到收到回复。\n1.2 共识算法 # 共识是可容错系统中的一个基本问题:即使面对故障,服务器也可以在共享状态上达成一致。\n共识算法允许一组节点像一个整体一样一起工作,即使其中的一些节点出现故障也能够继续工作下去,其正确性主要是源于复制状态机的性质:一组Server的状态机计算相同状态的副本,即使有一部分的Server宕机了它们仍然能够继续运行。\n图-1 复制状态机架构\n一般通过使用复制日志来实现复制状态机。每个Server存储着一份包括命令序列的日志文件,状态机会按顺序执行这些命令。因为每个日志包含相同的命令,并且顺序也相同,所以每个状态机处理相同的命令序列。由于状态机是确定性的,所以处理相同的状态,得到相同的输出。\n因此共识算法的工作就是保持复制日志的一致性。服务器上的共识模块从客户端接收命令并将它们添加到日志中。它与其他服务器上的共识模块通信,以确保即使某些服务器发生故障。每个日志最终包含相同顺序的请求。一旦命令被正确地复制,它们就被称为已提交。每个服务器的状态机按照日志顺序处理已提交的命令,并将输出返回给客户端,因此,这些服务器形成了一个单一的、高度可靠的状态机。\n适用于实际系统的共识算法通常具有以下特性:\n安全。确保在非拜占庭条件(也就是上文中提到的简易版拜占庭)下的安全性,包括网络延迟、分区、包丢失、复制和重新排序。\n高可用。只要大多数服务器都是可操作的,并且可以相互通信,也可以与客户端进行通信,那么这些服务器就可以看作完全功能可用的。因此,一个典型的由五台服务器组成的集群可以容忍任何两台服务器端故障。假设服务器因停止而发生故障;它们稍后可能会从稳定存储上的状态中恢复并重新加入集群。\n一致性不依赖时序。错误的时钟和极端的消息延迟,在最坏的情况下也只会造成可用性问题,而不会产生一致性问题。\n在集群中大多数服务器响应,命令就可以完成,不会被少数运行缓慢的服务器来影响整体系统性能。\n2 基础 # 2.1 节点类型 # 一个 Raft 集群包括若干服务器,以典型的 5 服务器集群举例。在任意的时间,每个服务器一定会处于以下三个状态中的一个:\nLeader:负责发起心跳,响应客户端,创建日志,同步日志。 Candidate:Leader 选举过程中的临时角色,由 Follower 转化而来,发起投票参与竞选。 Follower:接受 Leader 的心跳和日志同步数据,投票给 Candidate。 在正常的情况下,只有一个服务器是 Leader,剩下的服务器是 Follower。Follower 是被动的,它们不会发送任何请求,只是响应来自 Leader 和 Candidate 的请求。\n图-2:服务器的状态\n2.2 任期 # 图-3:任期\n如图 3 所示,raft 算法将时间划分为任意长度的任期(term),任期用连续的数字表示,看作当前 term 号。每一个任期的开始都是一次选举,在选举开始时,一个或多个 Candidate 会尝试成为 Leader。如果一个 Candidate 赢得了选举,它就会在该任期内担任 Leader。如果没有选出 Leader,将会开启另一个任期,并立刻开始下一次选举。raft 算法保证在给定的一个任期最少要有一个 Leader。\n每个节点都会存储当前的 term 号,当服务器之间进行通信时会交换当前的 term 号;如果有服务器发现自己的 term 号比其他人小,那么他会更新到较大的 term 值。如果一个 Candidate 或者 Leader 发现自己的 term 过期了,他会立即退回成 Follower。如果一台服务器收到的请求的 term 号是过期的,那么它会拒绝此次请求。\n2.3 日志 # entry:每一个事件成为 entry,只有 Leader 可以创建 entry。entry 的内容为\u0026lt;term,index,cmd\u0026gt;其中 cmd 是可以应用到状态机的操作。 log:由 entry 构成的数组,每一个 entry 都有一个表明自己在 log 中的 index。只有 Leader 才可以改变其他节点的 log。entry 总是先被 Leader 添加到自己的 log 数组中,然后再发起共识请求,获得同意后才会被 Leader 提交给状态机。Follower 只能从 Leader 获取新日志和当前的 commitIndex,然后把对应的 entry 应用到自己的状态机中。 3 领导人选举 # raft 使用心跳机制来触发 Leader 的选举。\n如果一台服务器能够收到来自 Leader 或者 Candidate 的有效信息,那么它会一直保持为 Follower 状态,并且刷新自己的 electionElapsed,重新计时。\nLeader 会向所有的 Follower 周期性发送心跳来保证自己的 Leader 地位。如果一个 Follower 在一个周期内没有收到心跳信息,就叫做选举超时,然后它就会认为此时没有可用的 Leader,并且开始进行一次选举以选出一个新的 Leader。\n为了开始新的选举,Follower 会自增自己的 term 号并且转换状态为 Candidate。然后他会向所有节点发起 RequestVoteRPC 请求, Candidate 的状态会持续到以下情况发生:\n赢得选举 其他节点赢得选举 一轮选举结束,无人胜出 赢得选举的条件是:一个 Candidate 在一个任期内收到了来自集群内的多数选票(N/2+1),就可以成为 Leader。\n在 Candidate 等待选票的时候,它可能收到其他节点声明自己是 Leader 的心跳,此时有两种情况:\n该 Leader 的 term 号大于等于自己的 term 号,说明对方已经成为 Leader,则自己回退为 Follower。 该 Leader 的 term 号小于自己的 term 号,那么会拒绝该请求并让该节点更新 term。 由于可能同一时刻出现多个 Candidate,导致没有 Candidate 获得大多数选票,如果没有其他手段来重新分配选票的话,那么可能会无限重复下去。\nraft 使用了随机的选举超时时间来避免上述情况。每一个 Candidate 在发起选举后,都会随机化一个新的选举超时时间,这种机制使得各个服务器能够分散开来,在大多数情况下只有一个服务器会率先超时;它会在其他服务器超时之前赢得选举。\n4 日志复制 # 一旦选出了 Leader,它就开始接受客户端的请求。每一个客户端的请求都包含一条需要被复制状态机(Replicated State Machine)执行的命令。\nLeader 收到客户端请求后,会生成一个 entry,包含\u0026lt;index,term,cmd\u0026gt;,再将这个 entry 添加到自己的日志末尾后,向所有的节点广播该 entry,要求其他服务器复制这条 entry。\n如果 Follower 接受该 entry,则会将 entry 添加到自己的日志后面,同时返回给 Leader 同意。\n如果 Leader 收到了多数的成功响应,Leader 会将这个 entry 应用到自己的状态机中,之后可以称这个 entry 是 committed 的,并且向客户端返回执行结果。\nraft 保证以下两个性质:\n在两个日志里,有两个 entry 拥有相同的 index 和 term,那么它们一定有相同的 cmd 在两个日志里,有两个 entry 拥有相同的 index 和 term,那么它们前面的 entry 也一定相同 通过“仅有 Leader 可以生成 entry”来保证第一个性质,第二个性质需要一致性检查来进行保证。\n一般情况下,Leader 和 Follower 的日志保持一致,然后,Leader 的崩溃会导致日志不一样,这样一致性检查会产生失败。Leader 通过强制 Follower 复制自己的日志来处理日志的不一致。这就意味着,在 Follower 上的冲突日志会被领导者的日志覆盖。\n为了使得 Follower 的日志和自己的日志一致,Leader 需要找到 Follower 与它日志一致的地方,然后删除 Follower 在该位置之后的日志,接着把这之后的日志发送给 Follower。\nLeader 给每一个Follower 维护了一个 nextIndex,它表示 Leader 将要发送给该追随者的下一条日志条目的索引。当一个 Leader 开始掌权时,它会将 nextIndex 初始化为它的最新的日志条目索引数+1。如果一个 Follower 的日志和 Leader 的不一致,AppendEntries 一致性检查会在下一次 AppendEntries RPC 时返回失败。在失败之后,Leader 会将 nextIndex 递减然后重试 AppendEntries RPC。最终 nextIndex 会达到一个 Leader 和 Follower 日志一致的地方。这时,AppendEntries 会返回成功,Follower 中冲突的日志条目都被移除了,并且添加所缺少的上了 Leader 的日志条目。一旦 AppendEntries 返回成功,Follower 和 Leader 的日志就一致了,这样的状态会保持到该任期结束。\n5 安全性 # 5.1 选举限制 # Leader 需要保证自己存储全部已经提交的日志条目。这样才可以使日志条目只有一个流向:从 Leader 流向 Follower,Leader 永远不会覆盖已经存在的日志条目。\n每个 Candidate 发送 RequestVoteRPC 时,都会带上最后一个 entry 的信息。所有节点收到投票信息时,会对该 entry 进行比较,如果发现自己的更新,则拒绝投票给该 Candidate。\n判断日志新旧的方式:如果两个日志的 term 不同,term 大的更新;如果 term 相同,更长的 index 更新。\n5.2 节点崩溃 # 如果 Leader 崩溃,集群中的节点在 electionTimeout 时间内没有收到 Leader 的心跳信息就会触发新一轮的选主,在选主期间整个集群对外是不可用的。\n如果 Follower 和 Candidate 崩溃,处理方式会简单很多。之后发送给它的 RequestVoteRPC 和 AppendEntriesRPC 会失败。由于 raft 的所有请求都是幂等的,所以失败的话会无限的重试。如果崩溃恢复后,就可以收到新的请求,然后选择追加或者拒绝 entry。\n5.3 时间与可用性 # raft 的要求之一就是安全性不依赖于时间:系统不能仅仅因为一些事件发生的比预想的快一些或者慢一些就产生错误。为了保证上述要求,最好能满足以下的时间条件:\nbroadcastTime \u0026lt;\u0026lt; electionTimeout \u0026lt;\u0026lt; MTBF\nbroadcastTime:向其他节点并发发送消息的平均响应时间; electionTimeout:选举超时时间; MTBF(mean time between failures):单台机器的平均健康时间; broadcastTime应该比electionTimeout小一个数量级,为的是使Leader能够持续发送心跳信息(heartbeat)来阻止Follower开始选举;\nelectionTimeout也要比MTBF小几个数量级,为的是使得系统稳定运行。当Leader崩溃时,大约会在整个electionTimeout的时间内不可用;我们希望这种情况仅占全部时间的很小一部分。\n由于broadcastTime和MTBF是由系统决定的属性,因此需要决定electionTimeout的时间。\n一般来说,broadcastTime 一般为 0.5~20ms,electionTimeout 可以设置为 10~500ms,MTBF 一般为一两个月。\n6 参考 # https://tanxinyu.work/raft/ https://github.com/OneSizeFitsQuorum/raft-thesis-zh_cn/blob/master/raft-thesis-zh_cn.md https://github.com/ongardie/dissertation/blob/master/stanford.pdf https://knowledge-sharing.gitbooks.io/raft/content/chapter5.html "},{"id":556,"href":"/zh/docs/technology/Interview/database/redis/redis-data-structures-02/","title":"Redis 3 种特殊数据类型详解","section":"Redis","content":"除了 5 种基本的数据类型之外,Redis 还支持 3 种特殊的数据类型:Bitmap、HyperLogLog、GEO。\nBitmap (位图) # 介绍 # 根据官网介绍:\nBitmaps are not an actual data type, but a set of bit-oriented operations defined on the String type which is treated like a bit vector. Since strings are binary safe blobs and their maximum length is 512 MB, they are suitable to set up to 2^32 different bits.\nBitmap 不是 Redis 中的实际数据类型,而是在 String 类型上定义的一组面向位的操作,将其视为位向量。由于字符串是二进制安全的块,且最大长度为 512 MB,它们适合用于设置最多 2^32 个不同的位。\nBitmap 存储的是连续的二进制数字(0 和 1),通过 Bitmap, 只需要一个 bit 位来表示某个元素对应的值或者状态,key 就是对应元素本身 。我们知道 8 个 bit 可以组成一个 byte,所以 Bitmap 本身会极大的节省储存空间。\n你可以将 Bitmap 看作是一个存储二进制数字(0 和 1)的数组,数组中每个元素的下标叫做 offset(偏移量)。\n常用命令 # 命令 介绍 SETBIT key offset value 设置指定 offset 位置的值 GETBIT key offset 获取指定 offset 位置的值 BITCOUNT key start end 获取 start 和 end 之间值为 1 的元素个数 BITOP operation destkey key1 key2 \u0026hellip; 对一个或多个 Bitmap 进行运算,可用运算符有 AND, OR, XOR 以及 NOT Bitmap 基本操作演示:\n# SETBIT 会返回之前位的值(默认是 0)这里会生成 7 个位 \u0026gt; SETBIT mykey 7 1 (integer) 0 \u0026gt; SETBIT mykey 7 0 (integer) 1 \u0026gt; GETBIT mykey 7 (integer) 0 \u0026gt; SETBIT mykey 6 1 (integer) 0 \u0026gt; SETBIT mykey 8 1 (integer) 0 # 通过 bitcount 统计被被设置为 1 的位的数量。 \u0026gt; BITCOUNT mykey (integer) 2 应用场景 # 需要保存状态信息(0/1 即可表示)的场景\n举例:用户签到情况、活跃用户情况、用户行为统计(比如是否点赞过某个视频)。 相关命令:SETBIT、GETBIT、BITCOUNT、BITOP。 HyperLogLog(基数统计) # 介绍 # HyperLogLog 是一种有名的基数计数概率算法 ,基于 LogLog Counting(LLC)优化改进得来,并不是 Redis 特有的,Redis 只是实现了这个算法并提供了一些开箱即用的 API。\nRedis 提供的 HyperLogLog 占用空间非常非常小,只需要 12k 的空间就能存储接近2^64个不同元素。这是真的厉害,这就是数学的魅力么!并且,Redis 对 HyperLogLog 的存储结构做了优化,采用两种方式计数:\n稀疏矩阵:计数较少的时候,占用空间很小。 稠密矩阵:计数达到某个阈值的时候,占用 12k 的空间。 Redis 官方文档中有对应的详细说明:\n基数计数概率算法为了节省内存并不会直接存储元数据,而是通过一定的概率统计方法预估基数值(集合中包含元素的个数)。因此, HyperLogLog 的计数结果并不是一个精确值,存在一定的误差(标准误差为 0.81% )。\nHyperLogLog 的使用非常简单,但原理非常复杂。HyperLogLog 的原理以及在 Redis 中的实现可以看这篇文章: HyperLogLog 算法的原理讲解以及 Redis 是如何应用它的 。\n再推荐一个可以帮助理解 HyperLogLog 原理的工具: Sketch of the Day: HyperLogLog — Cornerstone of a Big Data Infrastructure 。\n除了 HyperLogLog 之外,Redis 还提供了其他的概率数据结构,对应的官方文档地址: https://redis.io/docs/data-types/probabilistic/ 。\n常用命令 # HyperLogLog 相关的命令非常少,最常用的也就 3 个。\n命令 介绍 PFADD key element1 element2 \u0026hellip; 添加一个或多个元素到 HyperLogLog 中 PFCOUNT key1 key2 获取一个或者多个 HyperLogLog 的唯一计数。 PFMERGE destkey sourcekey1 sourcekey2 \u0026hellip; 将多个 HyperLogLog 合并到 destkey 中,destkey 会结合多个源,算出对应的唯一计数。 HyperLogLog 基本操作演示:\n\u0026gt; PFADD hll foo bar zap (integer) 1 \u0026gt; PFADD hll zap zap zap (integer) 0 \u0026gt; PFADD hll foo bar (integer) 0 \u0026gt; PFCOUNT hll (integer) 3 \u0026gt; PFADD some-other-hll 1 2 3 (integer) 1 \u0026gt; PFCOUNT hll some-other-hll (integer) 6 \u0026gt; PFMERGE desthll hll some-other-hll \u0026#34;OK\u0026#34; \u0026gt; PFCOUNT desthll (integer) 6 应用场景 # 数量巨大(百万、千万级别以上)的计数场景\n举例:热门网站每日/每周/每月访问 ip 数统计、热门帖子 uv 统计。 相关命令:PFADD、PFCOUNT 。 Geospatial (地理位置) # 介绍 # Geospatial index(地理空间索引,简称 GEO) 主要用于存储地理位置信息,基于 Sorted Set 实现。\n通过 GEO 我们可以轻松实现两个位置距离的计算、获取指定位置附近的元素等功能。\n常用命令 # 命令 介绍 GEOADD key longitude1 latitude1 member1 \u0026hellip; 添加一个或多个元素对应的经纬度信息到 GEO 中 GEOPOS key member1 member2 \u0026hellip; 返回给定元素的经纬度信息 GEODIST key member1 member2 M/KM/FT/MI 返回两个给定元素之间的距离 GEORADIUS key longitude latitude radius distance 获取指定位置附近 distance 范围内的其他元素,支持 ASC(由近到远)、DESC(由远到近)、Count(数量) 等参数 GEORADIUSBYMEMBER key member radius distance 类似于 GEORADIUS 命令,只是参照的中心点是 GEO 中的元素 基本操作:\n\u0026gt; GEOADD personLocation 116.33 39.89 user1 116.34 39.90 user2 116.35 39.88 user3 3 \u0026gt; GEOPOS personLocation user1 116.3299986720085144 39.89000061669732844 \u0026gt; GEODIST personLocation user1 user2 km 1.4018 通过 Redis 可视化工具查看 personLocation ,果不其然,底层就是 Sorted Set。\nGEO 中存储的地理位置信息的经纬度数据通过 GeoHash 算法转换成了一个整数,这个整数作为 Sorted Set 的 score(权重参数)使用。\n获取指定位置范围内的其他元素:\n\u0026gt; GEORADIUS personLocation 116.33 39.87 3 km user3 user1 \u0026gt; GEORADIUS personLocation 116.33 39.87 2 km \u0026gt; GEORADIUS personLocation 116.33 39.87 5 km user3 user1 user2 \u0026gt; GEORADIUSBYMEMBER personLocation user1 5 km user3 user1 user2 \u0026gt; GEORADIUSBYMEMBER personLocation user1 2 km user1 user2 GEORADIUS 命令的底层原理解析可以看看阿里的这篇文章: Redis 到底是怎么实现“附近的人”这个功能的呢? 。\n移除元素:\nGEO 底层是 Sorted Set ,你可以对 GEO 使用 Sorted Set 相关的命令。\n\u0026gt; ZREM personLocation user1 1 \u0026gt; ZRANGE personLocation 0 -1 user3 user2 \u0026gt; ZSCORE personLocation user2 4069879562983946 应用场景 # 需要管理使用地理空间数据的场景\n举例:附近的人。 相关命令: GEOADD、GEORADIUS、GEORADIUSBYMEMBER 。 总结 # 数据类型 说明 Bitmap 你可以将 Bitmap 看作是一个存储二进制数字(0 和 1)的数组,数组中每个元素的下标叫做 offset(偏移量)。通过 Bitmap, 只需要一个 bit 位来表示某个元素对应的值或者状态,key 就是对应元素本身 。我们知道 8 个 bit 可以组成一个 byte,所以 Bitmap 本身会极大的节省储存空间。 HyperLogLog Redis 提供的 HyperLogLog 占用空间非常非常小,只需要 12k 的空间就能存储接近2^64个不同元素。不过,HyperLogLog 的计数结果并不是一个精确值,存在一定的误差(标准误差为 0.81% )。 Geospatial index Geospatial index(地理空间索引,简称 GEO) 主要用于存储地理位置信息,基于 Sorted Set 实现。 参考 # Redis Data Structures: https://redis.com/redis-enterprise/data-structures/ 。 《Redis 深度历险:核心原理与应用实践》1.6 四两拨千斤——HyperLogLog 布隆过滤器,位图,HyperLogLog: https://hogwartsrico.github.io/2020/06/08/BloomFilter-HyperLogLog-BitMap/index.html "},{"id":557,"href":"/zh/docs/technology/Interview/database/redis/redis-data-structures-01/","title":"Redis 5 种基本数据类型详解","section":"Redis","content":"Redis 共有 5 种基本数据类型:String(字符串)、List(列表)、Set(集合)、Hash(散列)、Zset(有序集合)。\n这 5 种数据类型是直接提供给用户使用的,是数据的保存形式,其底层实现主要依赖这 8 种数据结构:简单动态字符串(SDS)、LinkedList(双向链表)、Dict(哈希表/字典)、SkipList(跳跃表)、Intset(整数集合)、ZipList(压缩列表)、QuickList(快速列表)。\nRedis 5 种基本数据类型对应的底层数据结构实现如下表所示:\nString List Hash Set Zset SDS LinkedList/ZipList/QuickList Dict、ZipList Dict、Intset ZipList、SkipList Redis 3.2 之前,List 底层实现是 LinkedList 或者 ZipList。 Redis 3.2 之后,引入了 LinkedList 和 ZipList 的结合 QuickList,List 的底层实现变为 QuickList。从 Redis 7.0 开始, ZipList 被 ListPack 取代。\n你可以在 Redis 官网上找到 Redis 数据类型/结构非常详细的介绍:\nRedis Data Structures Redis Data types tutorial 未来随着 Redis 新版本的发布,可能会有新的数据结构出现,通过查阅 Redis 官网对应的介绍,你总能获取到最靠谱的信息。\nString(字符串) # 介绍 # String 是 Redis 中最简单同时也是最常用的一个数据类型。\nString 是一种二进制安全的数据类型,可以用来存储任何类型的数据比如字符串、整数、浮点数、图片(图片的 base64 编码或者解码或者图片的路径)、序列化后的对象。\n虽然 Redis 是用 C 语言写的,但是 Redis 并没有使用 C 的字符串表示,而是自己构建了一种 简单动态字符串(Simple Dynamic String,SDS)。相比于 C 的原生字符串,Redis 的 SDS 不光可以保存文本数据还可以保存二进制数据,并且获取字符串长度复杂度为 O(1)(C 字符串为 O(N)),除此之外,Redis 的 SDS API 是安全的,不会造成缓冲区溢出。\n常用命令 # 命令 介绍 SET key value 设置指定 key 的值 SETNX key value 只有在 key 不存在时设置 key 的值 GET key 获取指定 key 的值 MSET key1 value1 key2 value2 …… 设置一个或多个指定 key 的值 MGET key1 key2 \u0026hellip; 获取一个或多个指定 key 的值 STRLEN key 返回 key 所储存的字符串值的长度 INCR key 将 key 中储存的数字值增一 DECR key 将 key 中储存的数字值减一 EXISTS key 判断指定 key 是否存在 DEL key(通用) 删除指定的 key EXPIRE key seconds(通用) 给指定 key 设置过期时间 更多 Redis String 命令以及详细使用指南,请查看 Redis 官网对应的介绍: https://redis.io/commands/?group=string 。\n基本操作:\n\u0026gt; SET key value OK \u0026gt; GET key \u0026#34;value\u0026#34; \u0026gt; EXISTS key (integer) 1 \u0026gt; STRLEN key (integer) 5 \u0026gt; DEL key (integer) 1 \u0026gt; GET key (nil) 批量设置:\n\u0026gt; MSET key1 value1 key2 value2 OK \u0026gt; MGET key1 key2 # 批量获取多个 key 对应的 value 1) \u0026#34;value1\u0026#34; 2) \u0026#34;value2\u0026#34; 计数器(字符串的内容为整数的时候可以使用):\n\u0026gt; SET number 1 OK \u0026gt; INCR number # 将 key 中储存的数字值增一 (integer) 2 \u0026gt; GET number \u0026#34;2\u0026#34; \u0026gt; DECR number # 将 key 中储存的数字值减一 (integer) 1 \u0026gt; GET number \u0026#34;1\u0026#34; 设置过期时间(默认为永不过期):\n\u0026gt; EXPIRE key 60 (integer) 1 \u0026gt; SETEX key 60 value # 设置值并设置过期时间 OK \u0026gt; TTL key (integer) 56 应用场景 # 需要存储常规数据的场景\n举例:缓存 Session、Token、图片地址、序列化后的对象(相比较于 Hash 存储更节省内存)。 相关命令:SET、GET。 需要计数的场景\n举例:用户单位时间的请求数(简单限流可以用到)、页面单位时间的访问数。 相关命令:SET、GET、 INCR、DECR 。 分布式锁\n利用 SETNX key value 命令可以实现一个最简易的分布式锁(存在一些缺陷,通常不建议这样实现分布式锁)。\nList(列表) # 介绍 # Redis 中的 List 其实就是链表数据结构的实现。我在 线性数据结构 :数组、链表、栈、队列 这篇文章中详细介绍了链表这种数据结构,我这里就不多做介绍了。\n许多高级编程语言都内置了链表的实现比如 Java 中的 LinkedList,但是 C 语言并没有实现链表,所以 Redis 实现了自己的链表数据结构。Redis 的 List 的实现为一个 双向链表,即可以支持反向查找和遍历,更方便操作,不过带来了部分额外的内存开销。\n常用命令 # 命令 介绍 RPUSH key value1 value2 \u0026hellip; 在指定列表的尾部(右边)添加一个或多个元素 LPUSH key value1 value2 \u0026hellip; 在指定列表的头部(左边)添加一个或多个元素 LSET key index value 将指定列表索引 index 位置的值设置为 value LPOP key 移除并获取指定列表的第一个元素(最左边) RPOP key 移除并获取指定列表的最后一个元素(最右边) LLEN key 获取列表元素数量 LRANGE key start end 获取列表 start 和 end 之间 的元素 更多 Redis List 命令以及详细使用指南,请查看 Redis 官网对应的介绍: https://redis.io/commands/?group=list 。\n通过 RPUSH/LPOP 或者 LPUSH/RPOP实现队列:\n\u0026gt; RPUSH myList value1 (integer) 1 \u0026gt; RPUSH myList value2 value3 (integer) 3 \u0026gt; LPOP myList \u0026#34;value1\u0026#34; \u0026gt; LRANGE myList 0 1 1) \u0026#34;value2\u0026#34; 2) \u0026#34;value3\u0026#34; \u0026gt; LRANGE myList 0 -1 1) \u0026#34;value2\u0026#34; 2) \u0026#34;value3\u0026#34; 通过 RPUSH/RPOP或者LPUSH/LPOP 实现栈:\n\u0026gt; RPUSH myList2 value1 value2 value3 (integer) 3 \u0026gt; RPOP myList2 # 将 list的最右边的元素取出 \u0026#34;value3\u0026#34; 我专门画了一个图方便大家理解 RPUSH , LPOP , lpush , RPOP 命令:\n通过 LRANGE 查看对应下标范围的列表元素:\n\u0026gt; RPUSH myList value1 value2 value3 (integer) 3 \u0026gt; LRANGE myList 0 1 1) \u0026#34;value1\u0026#34; 2) \u0026#34;value2\u0026#34; \u0026gt; LRANGE myList 0 -1 1) \u0026#34;value1\u0026#34; 2) \u0026#34;value2\u0026#34; 3) \u0026#34;value3\u0026#34; 通过 LRANGE 命令,你可以基于 List 实现分页查询,性能非常高!\n通过 LLEN 查看链表长度:\n\u0026gt; LLEN myList (integer) 3 应用场景 # 信息流展示\n举例:最新文章、最新动态。 相关命令:LPUSH、LRANGE。 消息队列\nList 可以用来做消息队列,只是功能过于简单且存在很多缺陷,不建议这样做。\n相对来说,Redis 5.0 新增加的一个数据结构 Stream 更适合做消息队列一些,只是功能依然非常简陋。和专业的消息队列相比,还是有很多欠缺的地方比如消息丢失和堆积问题不好解决。\nHash(哈希) # 介绍 # Redis 中的 Hash 是一个 String 类型的 field-value(键值对) 的映射表,特别适合用于存储对象,后续操作的时候,你可以直接修改这个对象中的某些字段的值。\nHash 类似于 JDK1.8 前的 HashMap,内部实现也差不多(数组 + 链表)。不过,Redis 的 Hash 做了更多优化。\n常用命令 # 命令 介绍 HSET key field value 设置指定哈希表中指定字段的值 HSETNX key field value 只有指定字段不存在时设置指定字段的值 HMSET key field1 value1 field2 value2 \u0026hellip; 同时将一个或多个 field-value (域-值)对设置到指定哈希表中 HGET key field 获取指定哈希表中指定字段的值 HMGET key field1 field2 \u0026hellip; 获取指定哈希表中一个或者多个指定字段的值 HGETALL key 获取指定哈希表中所有的键值对 HEXISTS key field 查看指定哈希表中指定的字段是否存在 HDEL key field1 field2 \u0026hellip; 删除一个或多个哈希表字段 HLEN key 获取指定哈希表中字段的数量 HINCRBY key field increment 对指定哈希中的指定字段做运算操作(正数为加,负数为减) 更多 Redis Hash 命令以及详细使用指南,请查看 Redis 官网对应的介绍: https://redis.io/commands/?group=hash 。\n模拟对象数据存储:\n\u0026gt; HMSET userInfoKey name \u0026#34;guide\u0026#34; description \u0026#34;dev\u0026#34; age 24 OK \u0026gt; HEXISTS userInfoKey name # 查看 key 对应的 value中指定的字段是否存在。 (integer) 1 \u0026gt; HGET userInfoKey name # 获取存储在哈希表中指定字段的值。 \u0026#34;guide\u0026#34; \u0026gt; HGET userInfoKey age \u0026#34;24\u0026#34; \u0026gt; HGETALL userInfoKey # 获取在哈希表中指定 key 的所有字段和值 1) \u0026#34;name\u0026#34; 2) \u0026#34;guide\u0026#34; 3) \u0026#34;description\u0026#34; 4) \u0026#34;dev\u0026#34; 5) \u0026#34;age\u0026#34; 6) \u0026#34;24\u0026#34; \u0026gt; HSET userInfoKey name \u0026#34;GuideGeGe\u0026#34; \u0026gt; HGET userInfoKey name \u0026#34;GuideGeGe\u0026#34; \u0026gt; HINCRBY userInfoKey age 2 (integer) 26 应用场景 # 对象数据存储场景\n举例:用户信息、商品信息、文章信息、购物车信息。 相关命令:HSET (设置单个字段的值)、HMSET(设置多个字段的值)、HGET(获取单个字段的值)、HMGET(获取多个字段的值)。 Set(集合) # 介绍 # Redis 中的 Set 类型是一种无序集合,集合中的元素没有先后顺序但都唯一,有点类似于 Java 中的 HashSet 。当你需要存储一个列表数据,又不希望出现重复数据时,Set 是一个很好的选择,并且 Set 提供了判断某个元素是否在一个 Set 集合内的重要接口,这个也是 List 所不能提供的。\n你可以基于 Set 轻易实现交集、并集、差集的操作,比如你可以将一个用户所有的关注人存在一个集合中,将其所有粉丝存在一个集合。这样的话,Set 可以非常方便的实现如共同关注、共同粉丝、共同喜好等功能。这个过程也就是求交集的过程。\n常用命令 # 命令 介绍 SADD key member1 member2 \u0026hellip; 向指定集合添加一个或多个元素 SMEMBERS key 获取指定集合中的所有元素 SCARD key 获取指定集合的元素数量 SISMEMBER key member 判断指定元素是否在指定集合中 SINTER key1 key2 \u0026hellip; 获取给定所有集合的交集 SINTERSTORE destination key1 key2 \u0026hellip; 将给定所有集合的交集存储在 destination 中 SUNION key1 key2 \u0026hellip; 获取给定所有集合的并集 SUNIONSTORE destination key1 key2 \u0026hellip; 将给定所有集合的并集存储在 destination 中 SDIFF key1 key2 \u0026hellip; 获取给定所有集合的差集 SDIFFSTORE destination key1 key2 \u0026hellip; 将给定所有集合的差集存储在 destination 中 SPOP key count 随机移除并获取指定集合中一个或多个元素 SRANDMEMBER key count 随机获取指定集合中指定数量的元素 更多 Redis Set 命令以及详细使用指南,请查看 Redis 官网对应的介绍: https://redis.io/commands/?group=set 。\n基本操作:\n\u0026gt; SADD mySet value1 value2 (integer) 2 \u0026gt; SADD mySet value1 # 不允许有重复元素,因此添加失败 (integer) 0 \u0026gt; SMEMBERS mySet 1) \u0026#34;value1\u0026#34; 2) \u0026#34;value2\u0026#34; \u0026gt; SCARD mySet (integer) 2 \u0026gt; SISMEMBER mySet value1 (integer) 1 \u0026gt; SADD mySet2 value2 value3 (integer) 2 mySet : value1、value2 。 mySet2:value2、value3 。 求交集:\n\u0026gt; SINTERSTORE mySet3 mySet mySet2 (integer) 1 \u0026gt; SMEMBERS mySet3 1) \u0026#34;value2\u0026#34; 求并集:\n\u0026gt; SUNION mySet mySet2 1) \u0026#34;value3\u0026#34; 2) \u0026#34;value2\u0026#34; 3) \u0026#34;value1\u0026#34; 求差集:\n\u0026gt; SDIFF mySet mySet2 # 差集是由所有属于 mySet 但不属于 A 的元素组成的集合 1) \u0026#34;value1\u0026#34; 应用场景 # 需要存放的数据不能重复的场景\n举例:网站 UV 统计(数据量巨大的场景还是 HyperLogLog更适合一些)、文章点赞、动态点赞等场景。 相关命令:SCARD(获取集合数量) 。 需要获取多个数据源交集、并集和差集的场景\n举例:共同好友(交集)、共同粉丝(交集)、共同关注(交集)、好友推荐(差集)、音乐推荐(差集)、订阅号推荐(差集+交集) 等场景。 相关命令:SINTER(交集)、SINTERSTORE (交集)、SUNION (并集)、SUNIONSTORE(并集)、SDIFF(差集)、SDIFFSTORE (差集)。 需要随机获取数据源中的元素的场景\n举例:抽奖系统、随机点名等场景。 相关命令:SPOP(随机获取集合中的元素并移除,适合不允许重复中奖的场景)、SRANDMEMBER(随机获取集合中的元素,适合允许重复中奖的场景)。 Sorted Set(有序集合) # 介绍 # Sorted Set 类似于 Set,但和 Set 相比,Sorted Set 增加了一个权重参数 score,使得集合中的元素能够按 score 进行有序排列,还可以通过 score 的范围来获取元素的列表。有点像是 Java 中 HashMap 和 TreeSet 的结合体。\n常用命令 # 命令 介绍 ZADD key score1 member1 score2 member2 \u0026hellip; 向指定有序集合添加一个或多个元素 ZCARD KEY 获取指定有序集合的元素数量 ZSCORE key member 获取指定有序集合中指定元素的 score 值 ZINTERSTORE destination numkeys key1 key2 \u0026hellip; 将给定所有有序集合的交集存储在 destination 中,对相同元素对应的 score 值进行 SUM 聚合操作,numkeys 为集合数量 ZUNIONSTORE destination numkeys key1 key2 \u0026hellip; 求并集,其它和 ZINTERSTORE 类似 ZDIFFSTORE destination numkeys key1 key2 \u0026hellip; 求差集,其它和 ZINTERSTORE 类似 ZRANGE key start end 获取指定有序集合 start 和 end 之间的元素(score 从低到高) ZREVRANGE key start end 获取指定有序集合 start 和 end 之间的元素(score 从高到底) ZREVRANK key member 获取指定有序集合中指定元素的排名(score 从大到小排序) 更多 Redis Sorted Set 命令以及详细使用指南,请查看 Redis 官网对应的介绍: https://redis.io/commands/?group=sorted-set 。\n基本操作:\n\u0026gt; ZADD myZset 2.0 value1 1.0 value2 (integer) 2 \u0026gt; ZCARD myZset 2 \u0026gt; ZSCORE myZset value1 2.0 \u0026gt; ZRANGE myZset 0 1 1) \u0026#34;value2\u0026#34; 2) \u0026#34;value1\u0026#34; \u0026gt; ZREVRANGE myZset 0 1 1) \u0026#34;value1\u0026#34; 2) \u0026#34;value2\u0026#34; \u0026gt; ZADD myZset2 4.0 value2 3.0 value3 (integer) 2 myZset : value1(2.0)、value2(1.0) 。 myZset2:value2 (4.0)、value3(3.0) 。 获取指定元素的排名:\n\u0026gt; ZREVRANK myZset value1 0 \u0026gt; ZREVRANK myZset value2 1 求交集:\n\u0026gt; ZINTERSTORE myZset3 2 myZset myZset2 1 \u0026gt; ZRANGE myZset3 0 1 WITHSCORES value2 5 求并集:\n\u0026gt; ZUNIONSTORE myZset4 2 myZset myZset2 3 \u0026gt; ZRANGE myZset4 0 2 WITHSCORES value1 2 value3 3 value2 5 求差集:\n\u0026gt; ZDIFF 2 myZset myZset2 WITHSCORES value1 2 应用场景 # 需要随机获取数据源中的元素根据某个权重进行排序的场景\n举例:各种排行榜比如直播间送礼物的排行榜、朋友圈的微信步数排行榜、王者荣耀中的段位排行榜、话题热度排行榜等等。 相关命令:ZRANGE (从小到大排序)、 ZREVRANGE (从大到小排序)、ZREVRANK (指定元素排名)。 《Java 面试指北》 的「技术面试题篇」就有一篇文章详细介绍如何使用 Sorted Set 来设计制作一个排行榜。\n需要存储的数据有优先级或者重要程度的场景 比如优先级任务队列。\n举例:优先级任务队列。 相关命令:ZRANGE (从小到大排序)、 ZREVRANGE (从大到小排序)、ZREVRANK (指定元素排名)。 总结 # 数据类型 说明 String 一种二进制安全的数据类型,可以用来存储任何类型的数据比如字符串、整数、浮点数、图片(图片的 base64 编码或者解码或者图片的路径)、序列化后的对象。 List Redis 的 List 的实现为一个双向链表,即可以支持反向查找和遍历,更方便操作,不过带来了部分额外的内存开销。 Hash 一个 String 类型的 field-value(键值对) 的映射表,特别适合用于存储对象,后续操作的时候,你可以直接修改这个对象中的某些字段的值。 Set 无序集合,集合中的元素没有先后顺序但都唯一,有点类似于 Java 中的 HashSet 。 Zset 和 Set 相比,Sorted Set 增加了一个权重参数 score,使得集合中的元素能够按 score 进行有序排列,还可以通过 score 的范围来获取元素的列表。有点像是 Java 中 HashMap 和 TreeSet 的结合体。 参考 # Redis Data Structures: https://redis.com/redis-enterprise/data-structures/ 。 Redis Commands: https://redis.io/commands/ 。 Redis Data types tutorial: https://redis.io/docs/manual/data-types/data-types-tutorial/ 。 Redis 存储对象信息是用 Hash 还是 String : https://segmentfault.com/a/1190000040032006 "},{"id":558,"href":"/zh/docs/technology/Interview/database/redis/redis-questions-01/","title":"Redis常见面试题总结(上)","section":"Redis","content":" Redis 基础 # 什么是 Redis? # Redis (REmote DIctionary Server)是一个基于 C 语言开发的开源 NoSQL 数据库(BSD 许可)。与传统数据库不同的是,Redis 的数据是保存在内存中的(内存数据库,支持持久化),因此读写速度非常快,被广泛应用于分布式缓存方向。并且,Redis 存储的是 KV 键值对数据。\n为了满足不同的业务场景,Redis 内置了多种数据类型实现(比如 String、Hash、Sorted Set、Bitmap、HyperLogLog、GEO)。并且,Redis 还支持事务、持久化、Lua 脚本、发布订阅模型、多种开箱即用的集群方案(Redis Sentinel、Redis Cluster)。\nRedis 没有外部依赖,Linux 和 OS X 是 Redis 开发和测试最多的两个操作系统,官方推荐生产环境使用 Linux 部署 Redis。\n个人学习的话,你可以自己本机安装 Redis 或者通过 Redis 官网提供的 在线 Redis 环境(少部分命令无法使用)来实际体验 Redis。\n全世界有非常多的网站使用到了 Redis , techstacks.io 专门维护了一个 使用 Redis 的热门站点列表 ,感兴趣的话可以看看。\nRedis 为什么这么快? # Redis 内部做了非常多的性能优化,比较重要的有下面 3 点:\nRedis 基于内存,内存的访问速度比磁盘快很多; Redis 基于 Reactor 模式设计开发了一套高效的事件处理模型,主要是单线程事件循环和 IO 多路复用(Redis 线程模式后面会详细介绍到); Redis 内置了多种优化过后的数据类型/结构实现,性能非常高。 Redis 通信协议实现简单且解析高效。 下面这张图片总结的挺不错的,分享一下,出自 Why is Redis so fast? 。\n那既然都这么快了,为什么不直接用 Redis 当主数据库呢?主要是因为内存成本太高且 Redis 提供的数据持久化仍然有数据丢失的风险。\n除了 Redis,你还知道其他分布式缓存方案吗? # 如果面试中被问到这个问题的话,面试官主要想看看:\n你在选择 Redis 作为分布式缓存方案时,是否是经过严谨的调研和思考,还是只是因为 Redis 是当前的“热门”技术。 你在分布式缓存方向的技术广度。 如果你了解其他方案,并且能解释为什么最终选择了 Redis(更进一步!),这会对你面试表现加分不少!\n下面简单聊聊常见的分布式缓存技术选型。\n分布式缓存的话,比较老牌同时也是使用的比较多的还是 Memcached 和 Redis。不过,现在基本没有看过还有项目使用 Memcached 来做缓存,都是直接用 Redis。\nMemcached 是分布式缓存最开始兴起的那会,比较常用的。后来,随着 Redis 的发展,大家慢慢都转而使用更加强大的 Redis 了。\n有一些大厂也开源了类似于 Redis 的分布式高性能 KV 存储数据库,例如,腾讯开源的 Tendis 。Tendis 基于知名开源项目 RocksDB 作为存储引擎 ,100% 兼容 Redis 协议和 Redis4.0 所有数据模型。关于 Redis 和 Tendis 的对比,腾讯官方曾经发过一篇文章: Redis vs Tendis:冷热混合存储版架构揭秘 ,可以简单参考一下。\n不过,从 Tendis 这个项目的 Github 提交记录可以看出,Tendis 开源版几乎已经没有被维护更新了,加上其关注度并不高,使用的公司也比较少。因此,不建议你使用 Tendis 来实现分布式缓存。\n目前,比较业界认可的 Redis 替代品还是下面这两个开源分布式缓存(都是通过碰瓷 Redis 火的):\nDragonfly:一种针对现代应用程序负荷需求而构建的内存数据库,完全兼容 Redis 和 Memcached 的 API,迁移时无需修改任何代码,号称全世界最快的内存数据库。 KeyDB: Redis 的一个高性能分支,专注于多线程、内存效率和高吞吐量。 不过,个人还是建议分布式缓存首选 Redis ,毕竟经过这么多年的生考验,生态也这么优秀,资料也很全面!\nPS:篇幅问题,我这并没有对上面提到的分布式缓存选型做详细介绍和对比,感兴趣的话,可以自行研究一下。\n说一下 Redis 和 Memcached 的区别和共同点 # 现在公司一般都是用 Redis 来实现缓存,而且 Redis 自身也越来越强大了!不过,了解 Redis 和 Memcached 的区别和共同点,有助于我们在做相应的技术选型的时候,能够做到有理有据!\n共同点:\n都是基于内存的数据库,一般都用来当做缓存使用。 都有过期策略。 两者的性能都非常高。 区别:\n数据类型:Redis 支持更丰富的数据类型(支持更复杂的应用场景)。Redis 不仅仅支持简单的 k/v 类型的数据,同时还提供 list,set,zset,hash 等数据结构的存储。Memcached 只支持最简单的 k/v 数据类型。 数据持久化:Redis 支持数据的持久化,可以将内存中的数据保持在磁盘中,重启的时候可以再次加载进行使用,而 Memcached 把数据全部存在内存之中。也就是说,Redis 有灾难恢复机制而 Memcached 没有。 集群模式支持:Memcached 没有原生的集群模式,需要依靠客户端来实现往集群中分片写入数据;但是 Redis 自 3.0 版本起是原生支持集群模式的。 线程模型:Memcached 是多线程,非阻塞 IO 复用的网络模型;Redis 使用单线程的多路 IO 复用模型。 (Redis 6.0 针对网络数据的读写引入了多线程) 特性支持:Redis 支持发布订阅模型、Lua 脚本、事务等功能,而 Memcached 不支持。并且,Redis 支持更多的编程语言。 过期数据删除:Memcached 过期数据的删除策略只用了惰性删除,而 Redis 同时使用了惰性删除与定期删除。 相信看了上面的对比之后,我们已经没有什么理由可以选择使用 Memcached 来作为自己项目的分布式缓存了。\n为什么要用 Redis? # 1、访问速度更快\n传统数据库数据保存在磁盘,而 Redis 基于内存,内存的访问速度比磁盘快很多。引入 Redis 之后,我们可以把一些高频访问的数据放到 Redis 中,这样下次就可以直接从内存中读取,速度可以提升几十倍甚至上百倍。\n2、高并发\n一般像 MySQL 这类的数据库的 QPS 大概都在 4k 左右(4 核 8g) ,但是使用 Redis 缓存之后很容易达到 5w+,甚至能达到 10w+(就单机 Redis 的情况,Redis 集群的话会更高)。\nQPS(Query Per Second):服务器每秒可以执行的查询次数;\n由此可见,直接操作缓存能够承受的数据库请求数量是远远大于直接访问数据库的,所以我们可以考虑把数据库中的部分数据转移到缓存中去,这样用户的一部分请求会直接到缓存这里而不用经过数据库。进而,我们也就提高了系统整体的并发。\n3、功能全面\nRedis 除了可以用作缓存之外,还可以用于分布式锁、限流、消息队列、延时队列等场景,功能强大!\n常见的缓存读写策略有哪些? # 关于常见的缓存读写策略的详细介绍,可以看我写的这篇文章: 3 种常用的缓存读写策略详解 。\n什么是 Redis Module?有什么用? # Redis 从 4.0 版本开始,支持通过 Module 来扩展其功能以满足特殊的需求。这些 Module 以动态链接库(so 文件)的形式被加载到 Redis 中,这是一种非常灵活的动态扩展功能的实现方式,值得借鉴学习!\n我们每个人都可以基于 Redis 去定制化开发自己的 Module,比如实现搜索引擎功能、自定义分布式锁和分布式限流。\n目前,被 Redis 官方推荐的 Module 有:\nRediSearch:用于实现搜索引擎的模块。 RedisJSON:用于处理 JSON 数据的模块。 RedisGraph:用于实现图形数据库的模块。 RedisTimeSeries:用于处理时间序列数据的模块。 RedisBloom:用于实现布隆过滤器的模块。 RedisAI:用于执行深度学习/机器学习模型并管理其数据的模块。 RedisCell:用于实现分布式限流的模块。 …… 关于 Redis 模块的详细介绍,可以查看官方文档: https://redis.io/modules。\nRedis 应用 # Redis 除了做缓存,还能做什么? # 分布式锁:通过 Redis 来做分布式锁是一种比较常见的方式。通常情况下,我们都是基于 Redisson 来实现分布式锁。关于 Redis 实现分布式锁的详细介绍,可以看我写的这篇文章: 分布式锁详解 。 限流:一般是通过 Redis + Lua 脚本的方式来实现限流。如果不想自己写 Lua 脚本的话,也可以直接利用 Redisson 中的 RRateLimiter 来实现分布式限流,其底层实现就是基于 Lua 代码+令牌桶算法。 消息队列:Redis 自带的 List 数据结构可以作为一个简单的队列使用。Redis 5.0 中增加的 Stream 类型的数据结构更加适合用来做消息队列。它比较类似于 Kafka,有主题和消费组的概念,支持消息持久化以及 ACK 机制。 延时队列:Redisson 内置了延时队列(基于 Sorted Set 实现的)。 分布式 Session :利用 String 或者 Hash 数据类型保存 Session 数据,所有的服务器都可以访问。 复杂业务场景:通过 Redis 以及 Redis 扩展(比如 Redisson)提供的数据结构,我们可以很方便地完成很多复杂的业务场景比如通过 Bitmap 统计活跃用户、通过 Sorted Set 维护排行榜、通过 HyperLogLog 统计网站 UV 和 PV。 …… 如何基于 Redis 实现分布式锁? # 关于 Redis 实现分布式锁的详细介绍,可以看我写的这篇文章: 分布式锁详解 。\nRedis 可以做消息队列么? # 实际项目中使用 Redis 来做消息队列的非常少,毕竟有更成熟的消息队列中间件可以用。\n先说结论:可以是可以,但不建议使用 Redis 来做消息队列。和专业的消息队列相比,还是有很多欠缺的地方。\nRedis 2.0 之前,如果想要使用 Redis 来做消息队列的话,只能通过 List 来实现。\n通过 RPUSH/LPOP 或者 LPUSH/RPOP即可实现简易版消息队列:\n# 生产者生产消息 \u0026gt; RPUSH myList msg1 msg2 (integer) 2 \u0026gt; RPUSH myList msg3 (integer) 3 # 消费者消费消息 \u0026gt; LPOP myList \u0026#34;msg1\u0026#34; 不过,通过 RPUSH/LPOP 或者 LPUSH/RPOP这样的方式存在性能问题,我们需要不断轮询去调用 RPOP 或 LPOP 来消费消息。当 List 为空时,大部分的轮询的请求都是无效请求,这种方式大量浪费了系统资源。\n因此,Redis 还提供了 BLPOP、BRPOP 这种阻塞式读取的命令(带 B-Blocking 的都是阻塞式),并且还支持一个超时参数。如果 List 为空,Redis 服务端不会立刻返回结果,它会等待 List 中有新数据后再返回或者是等待最多一个超时时间后返回空。如果将超时时间设置为 0 时,即可无限等待,直到弹出消息\n# 超时时间为 10s # 如果有数据立刻返回,否则最多等待10秒 \u0026gt; BRPOP myList 10 null List 实现消息队列功能太简单,像消息确认机制等功能还需要我们自己实现,最要命的是没有广播机制,消息也只能被消费一次。\nRedis 2.0 引入了发布订阅 (pub/sub) 功能,解决了 List 实现消息队列没有广播机制的问题。\npub/sub 中引入了一个概念叫 channel(频道),发布订阅机制的实现就是基于这个 channel 来做的。\npub/sub 涉及发布者(Publisher)和订阅者(Subscriber,也叫消费者)两个角色:\n发布者通过 PUBLISH 投递消息给指定 channel。 订阅者通过SUBSCRIBE订阅它关心的 channel。并且,订阅者可以订阅一个或者多个 channel。 我们这里启动 3 个 Redis 客户端来简单演示一下:\npub/sub 既能单播又能广播,还支持 channel 的简单正则匹配。不过,消息丢失(客户端断开连接或者 Redis 宕机都会导致消息丢失)、消息堆积(发布者发布消息的时候不会管消费者的具体消费能力如何)等问题依然没有一个比较好的解决办法。\n为此,Redis 5.0 新增加的一个数据结构 Stream 来做消息队列。Stream 支持:\n发布 / 订阅模式 按照消费者组进行消费(借鉴了 Kafka 消费者组的概念) 消息持久化( RDB 和 AOF) ACK 机制(通过确认机制来告知已经成功处理了消息) 阻塞式获取消息 Stream 的结构如下:\n这是一个有序的消息链表,每个消息都有一个唯一的 ID 和对应的内容。ID 是一个时间戳和序列号的组合,用来保证消息的唯一性和递增性。内容是一个或多个键值对(类似 Hash 基本数据类型),用来存储消息的数据。\n这里再对图中涉及到的一些概念,进行简单解释:\nConsumer Group:消费者组用于组织和管理多个消费者。消费者组本身不处理消息,而是再将消息分发给消费者,由消费者进行真正的消费 last_delivered_id:标识消费者组当前消费位置的游标,消费者组中任意一个消费者读取了消息都会使 last_delivered_id 往前移动。 pending_ids:记录已经被客户端消费但没有 ack 的消息的 ID。 下面是Stream 用作消息队列时常用的命令:\nXADD:向流中添加新的消息。 XREAD:从流中读取消息。 XREADGROUP:从消费组中读取消息。 XRANGE:根据消息 ID 范围读取流中的消息。 XREVRANGE:与 XRANGE 类似,但以相反顺序返回结果。 XDEL:从流中删除消息。 XTRIM:修剪流的长度,可以指定修建策略(MAXLEN/MINID)。 XLEN:获取流的长度。 XGROUP CREATE:创建消费者组。 XGROUP DESTROY : 删除消费者组 XGROUP DELCONSUMER:从消费者组中删除一个消费者。 XGROUP SETID:为消费者组设置新的最后递送消息 ID XACK:确认消费组中的消息已被处理。 XPENDING:查询消费组中挂起(未确认)的消息。 XCLAIM:将挂起的消息从一个消费者转移到另一个消费者。 XINFO:获取流(XINFO STREAM)、消费组(XINFO GROUPS)或消费者(XINFO CONSUMERS)的详细信息。 Stream 使用起来相对要麻烦一些,这里就不演示了。\n总的来说,Stream 已经可以满足一个消息队列的基本要求了。不过,Stream 在实际使用中依然会有一些小问题不太好解决比如在 Redis 发生故障恢复后不能保证消息至少被消费一次。\n综上,和专业的消息队列相比,使用 Redis 来实现消息队列还是有很多欠缺的地方比如消息丢失和堆积问题不好解决。因此,我们通常建议不要使用 Redis 来做消息队列,你完全可以选择市面上比较成熟的一些消息队列比如 RocketMQ、Kafka。不过,如果你就是想要用 Redis 来做消息队列的话,那我建议你优先考虑 Stream,这是目前相对最优的 Redis 消息队列实现。\n相关阅读: Redis 消息队列发展历程 - 阿里开发者 - 2022。\nRedis 可以做搜索引擎么? # Redis 是可以实现全文搜索引擎功能的,需要借助 RediSearch ,这是一个基于 Redis 的搜索引擎模块。\nRediSearch 支持中文分词、聚合统计、停用词、同义词、拼写检查、标签查询、向量相似度查询、多关键词搜索、分页搜索等功能,算是一个功能比较完善的全文搜索引擎了。\n相比较于 Elasticsearch 来说,RediSearch 主要在下面两点上表现更优异一些:\n性能更优秀:依赖 Redis 自身的高性能,基于内存操作(Elasticsearch 基于磁盘)。 较低内存占用实现快速索引:RediSearch 内部使用压缩的倒排索引,所以可以用较低的内存占用来实现索引的快速构建。 对于小型项目的简单搜索场景来说,使用 RediSearch 来作为搜索引擎还是没有问题的(搭配 RedisJSON 使用)。\n对于比较复杂或者数据规模较大的搜索场景还是不太建议使用 RediSearch 来作为搜索引擎,主要是因为下面这些限制和问题:\n数据量限制:Elasticsearch 可以支持 PB 级别的数据量,可以轻松扩展到多个节点,利用分片机制提高可用性和性能。RedisSearch 是基于 Redis 实现的,其能存储的数据量受限于 Redis 的内存容量,不太适合存储大规模的数据(内存昂贵,扩展能力较差)。 分布式能力较差:Elasticsearch 是为分布式环境设计的,可以轻松扩展到多个节点。虽然 RedisSearch 支持分布式部署,但在实际应用中可能会面临一些挑战,如数据分片、节点间通信、数据一致性等问题。 聚合功能较弱:Elasticsearch 提供了丰富的聚合功能,而 RediSearch 的聚合功能相对较弱,只支持简单的聚合操作。 生态较差:Elasticsearch 可以轻松和常见的一些系统/软件集成比如 Hadoop、Spark、Kibana,而 RedisSearch 则不具备该优势。 Elasticsearch 适用于全文搜索、复杂查询、实时数据分析和聚合的场景,而 RediSearch 适用于快速数据存储、缓存和简单查询的场景。\n如何基于 Redis 实现延时任务? # 类似的问题:\n订单在 10 分钟后未支付就失效,如何用 Redis 实现? 红包 24 小时未被查收自动退还,如何用 Redis 实现? 基于 Redis 实现延时任务的功能无非就下面两种方案:\nRedis 过期事件监听 Redisson 内置的延时队列 Redis 过期事件监听的存在时效性较差、丢消息、多服务实例下消息重复消费等问题,不被推荐使用。\nRedisson 内置的延时队列具备下面这些优势:\n减少了丢消息的可能:DelayedQueue 中的消息会被持久化,即使 Redis 宕机了,根据持久化机制,也只可能丢失一点消息,影响不大。当然了,你也可以使用扫描数据库的方法作为补偿机制。 消息不存在重复消费问题:每个客户端都是从同一个目标队列中获取任务的,不存在重复消费的问题。 关于 Redis 实现延时任务的详细介绍,可以看我写的这篇文章: 如何基于 Redis 实现延时任务?。\nRedis 数据类型 # 关于 Redis 5 种基础数据类型和 3 种特殊数据类型的详细介绍请看下面这两篇文章以及 Redis 官方文档 :\nRedis 5 种基本数据类型详解 Redis 3 种特殊数据类型详解 Redis 常用的数据类型有哪些? # Redis 中比较常见的数据类型有下面这些:\n5 种基础数据类型:String(字符串)、List(列表)、Set(集合)、Hash(散列)、Zset(有序集合)。 3 种特殊数据类型:HyperLogLog(基数统计)、Bitmap (位图)、Geospatial (地理位置)。 除了上面提到的之外,还有一些其他的比如 Bloom filter(布隆过滤器)、Bitfield(位域)。\nString 的应用场景有哪些? # String 是 Redis 中最简单同时也是最常用的一个数据类型。它是一种二进制安全的数据类型,可以用来存储任何类型的数据比如字符串、整数、浮点数、图片(图片的 base64 编码或者解码或者图片的路径)、序列化后的对象。\nString 的常见应用场景如下:\n常规数据(比如 Session、Token、序列化后的对象、图片的路径)的缓存; 计数比如用户单位时间的请求数(简单限流可以用到)、页面单位时间的访问数; 分布式锁(利用 SETNX key value 命令可以实现一个最简易的分布式锁); …… 关于 String 的详细介绍请看这篇文章: Redis 5 种基本数据类型详解。\nString 还是 Hash 存储对象数据更好呢? # 简单对比一下二者:\n对象存储方式:String 存储的是序列化后的对象数据,存放的是整个对象,操作简单直接。Hash 是对对象的每个字段单独存储,可以获取部分字段的信息,也可以修改或者添加部分字段,节省网络流量。如果对象中某些字段需要经常变动或者经常需要单独查询对象中的个别字段信息,Hash 就非常适合。 内存消耗:Hash 通常比 String 更节省内存,特别是在字段较多且字段长度较短时。Redis 对小型 Hash 进行优化(如使用 ziplist 存储),进一步降低内存占用。 复杂对象存储:String 在处理多层嵌套或复杂结构的对象时更方便,因为无需处理每个字段的独立存储和操作。 性能:String 的操作通常具有 O(1) 的时间复杂度,因为它存储的是整个对象,操作简单直接,整体读写的性能较好。Hash 由于需要处理多个字段的增删改查操作,在字段较多且经常变动的情况下,可能会带来额外的性能开销。 总结:\n在绝大多数情况下,String 更适合存储对象数据,尤其是当对象结构简单且整体读写是主要操作时。 如果你需要频繁操作对象的部分字段或节省内存,Hash 可能是更好的选择。 String 的底层实现是什么? # Redis 是基于 C 语言编写的,但 Redis 的 String 类型的底层实现并不是 C 语言中的字符串(即以空字符 \\0 结尾的字符数组),而是自己编写了 SDS(Simple Dynamic String,简单动态字符串) 来作为底层实现。\nSDS 最早是 Redis 作者为日常 C 语言开发而设计的 C 字符串,后来被应用到了 Redis 上,并经过了大量的修改完善以适合高性能操作。\nRedis7.0 的 SDS 的部分源码如下( https://github.com/redis/redis/blob/7.0/src/sds.h):\n/* Note: sdshdr5 is never used, we just access the flags byte directly. * However is here to document the layout of type 5 SDS strings. */ struct __attribute__ ((__packed__)) sdshdr5 { unsigned char flags; /* 3 lsb of type, and 5 msb of string length */ char buf[]; }; struct __attribute__ ((__packed__)) sdshdr8 { uint8_t len; /* used */ uint8_t alloc; /* excluding the header and null terminator */ unsigned char flags; /* 3 lsb of type, 5 unused bits */ char buf[]; }; struct __attribute__ ((__packed__)) sdshdr16 { uint16_t len; /* used */ uint16_t alloc; /* excluding the header and null terminator */ unsigned char flags; /* 3 lsb of type, 5 unused bits */ char buf[]; }; struct __attribute__ ((__packed__)) sdshdr32 { uint32_t len; /* used */ uint32_t alloc; /* excluding the header and null terminator */ unsigned char flags; /* 3 lsb of type, 5 unused bits */ char buf[]; }; struct __attribute__ ((__packed__)) sdshdr64 { uint64_t len; /* used */ uint64_t alloc; /* excluding the header and null terminator */ unsigned char flags; /* 3 lsb of type, 5 unused bits */ char buf[]; }; 通过源码可以看出,SDS 共有五种实现方式 SDS_TYPE_5(并未用到)、SDS_TYPE_8、SDS_TYPE_16、SDS_TYPE_32、SDS_TYPE_64,其中只有后四种实际用到。Redis 会根据初始化的长度决定使用哪种类型,从而减少内存的使用。\n类型 字节 位 sdshdr5 \u0026lt; 1 \u0026lt;8 sdshdr8 1 8 sdshdr16 2 16 sdshdr32 4 32 sdshdr64 8 64 对于后四种实现都包含了下面这 4 个属性:\nlen:字符串的长度也就是已经使用的字节数 alloc:总共可用的字符空间大小,alloc-len 就是 SDS 剩余的空间大小 buf[]:实际存储字符串的数组 flags:低三位保存类型标志 SDS 相比于 C 语言中的字符串有如下提升:\n可以避免缓冲区溢出:C 语言中的字符串被修改(比如拼接)时,一旦没有分配足够长度的内存空间,就会造成缓冲区溢出。SDS 被修改时,会先根据 len 属性检查空间大小是否满足要求,如果不满足,则先扩展至所需大小再进行修改操作。 获取字符串长度的复杂度较低:C 语言中的字符串的长度通常是经过遍历计数来实现的,时间复杂度为 O(n)。SDS 的长度获取直接读取 len 属性即可,时间复杂度为 O(1)。 减少内存分配次数:为了避免修改(增加/减少)字符串时,每次都需要重新分配内存(C 语言的字符串是这样的),SDS 实现了空间预分配和惰性空间释放两种优化策略。当 SDS 需要增加字符串时,Redis 会为 SDS 分配好内存,并且根据特定的算法分配多余的内存,这样可以减少连续执行字符串增长操作所需的内存重分配次数。当 SDS 需要减少字符串时,这部分内存不会立即被回收,会被记录下来,等待后续使用(支持手动释放,有对应的 API)。 二进制安全:C 语言中的字符串以空字符 \\0 作为字符串结束的标识,这存在一些问题,像一些二进制文件(比如图片、视频、音频)就可能包括空字符,C 字符串无法正确保存。SDS 使用 len 属性判断字符串是否结束,不存在这个问题。 🤐 多提一嘴,很多文章里 SDS 的定义是下面这样的:\nstruct sdshdr { unsigned int len; unsigned int free; char buf[]; }; 这个也没错,Redis 3.2 之前就是这样定义的。后来,由于这种方式的定义存在问题,len 和 free 的定义用了 4 个字节,造成了浪费。Redis 3.2 之后,Redis 改进了 SDS 的定义,将其划分为了现在的 5 种类型。\n购物车信息用 String 还是 Hash 存储更好呢? # 由于购物车中的商品频繁修改和变动,购物车信息建议使用 Hash 存储:\n用户 id 为 key 商品 id 为 field,商品数量为 value 那用户购物车信息的维护具体应该怎么操作呢?\n用户添加商品就是往 Hash 里面增加新的 field 与 value; 查询购物车信息就是遍历对应的 Hash; 更改商品数量直接修改对应的 value 值(直接 set 或者做运算皆可); 删除商品就是删除 Hash 中对应的 field; 清空购物车直接删除对应的 key 即可。 这里只是以业务比较简单的购物车场景举例,实际电商场景下,field 只保存一个商品 id 是没办法满足需求的。\n使用 Redis 实现一个排行榜怎么做? # Redis 中有一个叫做 Sorted Set (有序集合)的数据类型经常被用在各种排行榜的场景,比如直播间送礼物的排行榜、朋友圈的微信步数排行榜、王者荣耀中的段位排行榜、话题热度排行榜等等。\n相关的一些 Redis 命令: ZRANGE (从小到大排序)、 ZREVRANGE (从大到小排序)、ZREVRANK (指定元素排名)。\n《Java 面试指北》 的「技术面试题篇」就有一篇文章详细介绍如何使用 Sorted Set 来设计制作一个排行榜,感兴趣的小伙伴可以看看。\nRedis 的有序集合底层为什么要用跳表,而不用平衡树、红黑树或者 B+树? # 这道面试题很多大厂比较喜欢问,难度还是有点大的。\n平衡树 vs 跳表:平衡树的插入、删除和查询的时间复杂度和跳表一样都是 O(log n)。对于范围查询来说,平衡树也可以通过中序遍历的方式达到和跳表一样的效果。但是它的每一次插入或者删除操作都需要保证整颗树左右节点的绝对平衡,只要不平衡就要通过旋转操作来保持平衡,这个过程是比较耗时的。跳表诞生的初衷就是为了克服平衡树的一些缺点。跳表使用概率平衡而不是严格强制的平衡,因此,跳表中的插入和删除算法比平衡树的等效算法简单得多,速度也快得多。 红黑树 vs 跳表:相比较于红黑树来说,跳表的实现也更简单一些,不需要通过旋转和染色(红黑变换)来保证黑平衡。并且,按照区间来查找数据这个操作,红黑树的效率没有跳表高。 B+树 vs 跳表:B+树更适合作为数据库和文件系统中常用的索引结构之一,它的核心思想是通过可能少的 IO 定位到尽可能多的索引来获得查询数据。对于 Redis 这种内存数据库来说,它对这些并不感冒,因为 Redis 作为内存数据库它不可能存储大量的数据,所以对于索引不需要通过 B+树这种方式进行维护,只需按照概率进行随机维护即可,节约内存。而且使用跳表实现 zset 时相较前者来说更简单一些,在进行插入时只需通过索引将数据插入到链表中合适的位置再随机维护一定高度的索引即可,也不需要像 B+树那样插入时发现失衡时还需要对节点分裂与合并。 另外,我还单独写了一篇文章从有序集合的基本使用到跳表的源码分析和实现,让你会对 Redis 的有序集合底层实现的跳表有着更深刻的理解和掌握 : Redis 为什么用跳表实现有序集合。\nSet 的应用场景是什么? # Redis 中 Set 是一种无序集合,集合中的元素没有先后顺序但都唯一,有点类似于 Java 中的 HashSet 。\nSet 的常见应用场景如下:\n存放的数据不能重复的场景:网站 UV 统计(数据量巨大的场景还是 HyperLogLog更适合一些)、文章点赞、动态点赞等等。 需要获取多个数据源交集、并集和差集的场景:共同好友(交集)、共同粉丝(交集)、共同关注(交集)、好友推荐(差集)、音乐推荐(差集)、订阅号推荐(差集+交集) 等等。 需要随机获取数据源中的元素的场景:抽奖系统、随机点名等等。 使用 Set 实现抽奖系统怎么做? # 如果想要使用 Set 实现一个简单的抽奖系统的话,直接使用下面这几个命令就可以了:\nSADD key member1 member2 ...:向指定集合添加一个或多个元素。 SPOP key count:随机移除并获取指定集合中一个或多个元素,适合不允许重复中奖的场景。 SRANDMEMBER key count : 随机获取指定集合中指定数量的元素,适合允许重复中奖的场景。 使用 Bitmap 统计活跃用户怎么做? # Bitmap 存储的是连续的二进制数字(0 和 1),通过 Bitmap, 只需要一个 bit 位来表示某个元素对应的值或者状态,key 就是对应元素本身 。我们知道 8 个 bit 可以组成一个 byte,所以 Bitmap 本身会极大的节省储存空间。\n你可以将 Bitmap 看作是一个存储二进制数字(0 和 1)的数组,数组中每个元素的下标叫做 offset(偏移量)。\n如果想要使用 Bitmap 统计活跃用户的话,可以使用日期(精确到天)作为 key,然后用户 ID 为 offset,如果当日活跃过就设置为 1。\n初始化数据:\n\u0026gt; SETBIT 20210308 1 1 (integer) 0 \u0026gt; SETBIT 20210308 2 1 (integer) 0 \u0026gt; SETBIT 20210309 1 1 (integer) 0 统计 20210308~20210309 总活跃用户数:\n\u0026gt; BITOP and desk1 20210308 20210309 (integer) 1 \u0026gt; BITCOUNT desk1 (integer) 1 统计 20210308~20210309 在线活跃用户数:\n\u0026gt; BITOP or desk2 20210308 20210309 (integer) 1 \u0026gt; BITCOUNT desk2 (integer) 2 使用 HyperLogLog 统计页面 UV 怎么做? # 使用 HyperLogLog 统计页面 UV 主要需要用到下面这两个命令:\nPFADD key element1 element2 ...:添加一个或多个元素到 HyperLogLog 中。 PFCOUNT key1 key2:获取一个或者多个 HyperLogLog 的唯一计数。 1、将访问指定页面的每个用户 ID 添加到 HyperLogLog 中。\nPFADD PAGE_1:UV USER1 USER2 ...... USERn 2、统计指定页面的 UV。\nPFCOUNT PAGE_1:UV Redis 持久化机制(重要) # Redis 持久化机制(RDB 持久化、AOF 持久化、RDB 和 AOF 的混合持久化) 相关的问题比较多,也比较重要,于是我单独抽了一篇文章来总结 Redis 持久化机制相关的知识点和问题: Redis 持久化机制详解 。\nRedis 线程模型(重要) # 对于读写命令来说,Redis 一直是单线程模型。不过,在 Redis 4.0 版本之后引入了多线程来执行一些大键值对的异步删除操作, Redis 6.0 版本之后引入了多线程来处理网络请求(提高网络 IO 读写性能)。\nRedis 单线程模型了解吗? # Redis 基于 Reactor 模式设计开发了一套高效的事件处理模型 (Netty 的线程模型也基于 Reactor 模式,Reactor 模式不愧是高性能 IO 的基石),这套事件处理模型对应的是 Redis 中的文件事件处理器(file event handler)。由于文件事件处理器(file event handler)是单线程方式运行的,所以我们一般都说 Redis 是单线程模型。\n《Redis 设计与实现》有一段话是这样介绍文件事件处理器的,我觉得写得挺不错。\nRedis 基于 Reactor 模式开发了自己的网络事件处理器:这个处理器被称为文件事件处理器(file event handler)。\n文件事件处理器使用 I/O 多路复用(multiplexing)程序来同时监听多个套接字,并根据套接字目前执行的任务来为套接字关联不同的事件处理器。 当被监听的套接字准备好执行连接应答(accept)、读取(read)、写入(write)、关 闭(close)等操作时,与操作相对应的文件事件就会产生,这时文件事件处理器就会调用套接字之前关联好的事件处理器来处理这些事件。 虽然文件事件处理器以单线程方式运行,但通过使用 I/O 多路复用程序来监听多个套接字,文件事件处理器既实现了高性能的网络通信模型,又可以很好地与 Redis 服务器中其他同样以单线程方式运行的模块进行对接,这保持了 Redis 内部单线程设计的简单性。\n既然是单线程,那怎么监听大量的客户端连接呢?\nRedis 通过 IO 多路复用程序 来监听来自客户端的大量连接(或者说是监听多个 socket),它会将感兴趣的事件及类型(读、写)注册到内核中并监听每个事件是否发生。\n这样的好处非常明显:I/O 多路复用技术的使用让 Redis 不需要额外创建多余的线程来监听客户端的大量连接,降低了资源的消耗(和 NIO 中的 Selector 组件很像)。\n文件事件处理器(file event handler)主要是包含 4 个部分:\n多个 socket(客户端连接) IO 多路复用程序(支持多个客户端连接的关键) 文件事件分派器(将 socket 关联到相应的事件处理器) 事件处理器(连接应答处理器、命令请求处理器、命令回复处理器) 相关阅读: Redis 事件机制详解 。\nRedis6.0 之前为什么不使用多线程? # 虽然说 Redis 是单线程模型,但实际上,Redis 在 4.0 之后的版本中就已经加入了对多线程的支持。\n不过,Redis 4.0 增加的多线程主要是针对一些大键值对的删除操作的命令,使用这些命令就会使用主线程之外的其他线程来“异步处理”,从而减少对主线程的影响。\n为此,Redis 4.0 之后新增了几个异步命令:\nUNLINK:可以看作是 DEL 命令的异步版本。 FLUSHALL ASYNC:用于清空所有数据库的所有键,不限于当前 SELECT 的数据库。 FLUSHDB ASYNC:用于清空当前 SELECT 数据库中的所有键。 总的来说,直到 Redis 6.0 之前,Redis 的主要操作仍然是单线程处理的。\n那 Redis6.0 之前为什么不使用多线程? 我觉得主要原因有 3 点:\n单线程编程容易并且更容易维护; Redis 的性能瓶颈不在 CPU ,主要在内存和网络; 多线程就会存在死锁、线程上下文切换等问题,甚至会影响性能。 相关阅读: 为什么 Redis 选择单线程模型? 。\nRedis6.0 之后为何引入了多线程? # Redis6.0 引入多线程主要是为了提高网络 IO 读写性能,因为这个算是 Redis 中的一个性能瓶颈(Redis 的瓶颈主要受限于内存和网络)。\n虽然,Redis6.0 引入了多线程,但是 Redis 的多线程只是在网络数据的读写这类耗时操作上使用了,执行命令仍然是单线程顺序执行。因此,你也不需要担心线程安全问题。\nRedis6.0 的多线程默认是禁用的,只使用主线程。如需开启需要设置 IO 线程数 \u0026gt; 1,需要修改 redis 配置文件 redis.conf:\nio-threads 4 #设置1的话只会开启主线程,官网建议4核的机器建议设置为2或3个线程,8核的建议设置为6个线程 另外:\nio-threads 的个数一旦设置,不能通过 config 动态设置。 当设置 ssl 后,io-threads 将不工作。 开启多线程后,默认只会使用多线程进行 IO 写入 writes,即发送数据给客户端,如果需要开启多线程 IO 读取 reads,同样需要修改 redis 配置文件 redis.conf :\nio-threads-do-reads yes 但是官网描述开启多线程读并不能有太大提升,因此一般情况下并不建议开启\n相关阅读:\nRedis 6.0 新特性-多线程连环 13 问! Redis 多线程网络模型全面揭秘(推荐) Redis 后台线程了解吗? # 我们虽然经常说 Redis 是单线程模型(主要逻辑是单线程完成的),但实际还有一些后台线程用于执行一些比较耗时的操作:\n通过 bio_close_file 后台线程来释放 AOF / RDB 等过程中产生的临时文件资源。 通过 bio_aof_fsync 后台线程调用 fsync 函数将系统内核缓冲区还未同步到到磁盘的数据强制刷到磁盘( AOF 文件)。 通过 bio_lazy_free后台线程释放大对象(已删除)占用的内存空间. 在bio.h 文件中有定义(Redis 6.0 版本,源码地址: https://github.com/redis/redis/blob/6.0/src/bio.h):\n#ifndef __BIO_H #define __BIO_H /* Exported API */ void bioInit(void); void bioCreateBackgroundJob(int type, void *arg1, void *arg2, void *arg3); unsigned long long bioPendingJobsOfType(int type); unsigned long long bioWaitStepOfType(int type); time_t bioOlderJobOfType(int type); void bioKillThreads(void); /* Background job opcodes */ #define BIO_CLOSE_FILE 0 /* Deferred close(2) syscall. */ #define BIO_AOF_FSYNC 1 /* Deferred AOF fsync. */ #define BIO_LAZY_FREE 2 /* Deferred objects freeing. */ #define BIO_NUM_OPS 3 #endif 关于 Redis 后台线程的详细介绍可以查看 Redis 6.0 后台线程有哪些? 这篇就文章。\nRedis 内存管理 # Redis 给缓存数据设置过期时间有什么用? # 一般情况下,我们设置保存的缓存数据的时候都会设置一个过期时间。为什么呢?\n内存是有限且珍贵的,如果不对缓存数据设置过期时间,那内存占用就会一直增长,最终可能会导致 OOM 问题。通过设置合理的过期时间,Redis 会自动删除暂时不需要的数据,为新的缓存数据腾出空间。\nRedis 自带了给缓存数据设置过期时间的功能,比如:\n127.0.0.1:6379\u0026gt; expire key 60 # 数据在 60s 后过期 (integer) 1 127.0.0.1:6379\u0026gt; setex key 60 value # 数据在 60s 后过期 (setex:[set] + [ex]pire) OK 127.0.0.1:6379\u0026gt; ttl key # 查看数据还有多久过期 (integer) 56 注意 ⚠️:Redis 中除了字符串类型有自己独有设置过期时间的命令 setex 外,其他方法都需要依靠 expire 命令来设置过期时间 。另外, persist 命令可以移除一个键的过期时间。\n过期时间除了有助于缓解内存的消耗,还有什么其他用么?\n很多时候,我们的业务场景就是需要某个数据只在某一时间段内存在,比如我们的短信验证码可能只在 1 分钟内有效,用户登录的 Token 可能只在 1 天内有效。\n如果使用传统的数据库来处理的话,一般都是自己判断过期,这样更麻烦并且性能要差很多。\nRedis 是如何判断数据是否过期的呢? # Redis 通过一个叫做过期字典(可以看作是 hash 表)来保存数据过期的时间。过期字典的键指向 Redis 数据库中的某个 key(键),过期字典的值是一个 long long 类型的整数,这个整数保存了 key 所指向的数据库键的过期时间(毫秒精度的 UNIX 时间戳)。\n过期字典是存储在 redisDb 这个结构里的:\ntypedef struct redisDb { ... dict *dict; //数据库键空间,保存着数据库中所有键值对 dict *expires // 过期字典,保存着键的过期时间 ... } redisDb; 在查询一个 key 的时候,Redis 首先检查该 key 是否存在于过期字典中(时间复杂度为 O(1)),如果不在就直接返回,在的话需要判断一下这个 key 是否过期,过期直接删除 key 然后返回 null。\nRedis 过期 key 删除策略了解么? # 如果假设你设置了一批 key 只能存活 1 分钟,那么 1 分钟后,Redis 是怎么对这批 key 进行删除的呢?\n常用的过期数据的删除策略就下面这几种:\n惰性删除:只会在取出/查询 key 的时候才对数据进行过期检查。这种方式对 CPU 最友好,但是可能会造成太多过期 key 没有被删除。 定期删除:周期性地随机从设置了过期时间的 key 中抽查一批,然后逐个检查这些 key 是否过期,过期就删除 key。相比于惰性删除,定期删除对内存更友好,对 CPU 不太友好。 延迟队列:把设置过期时间的 key 放到一个延迟队列里,到期之后就删除 key。这种方式可以保证每个过期 key 都能被删除,但维护延迟队列太麻烦,队列本身也要占用资源。 定时删除:每个设置了过期时间的 key 都会在设置的时间到达时立即被删除。这种方法可以确保内存中不会有过期的键,但是它对 CPU 的压力最大,因为它需要为每个键都设置一个定时器。 Redis 采用的那种删除策略呢?\nRedis 采用的是 定期删除+惰性/懒汉式删除 结合的策略,这也是大部分缓存框架的选择。定期删除对内存更加友好,惰性删除对 CPU 更加友好。两者各有千秋,结合起来使用既能兼顾 CPU 友好,又能兼顾内存友好。\n下面是我们详细介绍一下 Redis 中的定期删除具体是如何做的。\nRedis 的定期删除过程是随机的(周期性地随机从设置了过期时间的 key 中抽查一批),所以并不保证所有过期键都会被立即删除。这也就解释了为什么有的 key 过期了,并没有被删除。并且,Redis 底层会通过限制删除操作执行的时长和频率来减少删除操作对 CPU 时间的影响。\n另外,定期删除还会受到执行时间和过期 key 的比例的影响:\n执行时间已经超过了阈值,那么就中断这一次定期删除循环,以避免使用过多的 CPU 时间。 如果这一批过期的 key 比例超过一个比例,就会重复执行此删除流程,以更积极地清理过期 key。相应地,如果过期的 key 比例低于这个比例,就会中断这一次定期删除循环,避免做过多的工作而获得很少的内存回收。 Redis 7.2 版本的执行时间阈值是 25ms,过期 key 比例设定值是 10%。\n#define ACTIVE_EXPIRE_CYCLE_FAST_DURATION 1000 /* Microseconds. */ #define ACTIVE_EXPIRE_CYCLE_SLOW_TIME_PERC 25 /* Max % of CPU to use. */ #define ACTIVE_EXPIRE_CYCLE_ACCEPTABLE_STALE 10 /* % of stale keys after which we do extra efforts. */ 每次随机抽查数量是多少?\nexpire.c中定义了每次随机抽查的数量,Redis 7.2 版本为 20 ,也就是说每次会随机选择 20 个设置了过期时间的 key 判断是否过期。\n#define ACTIVE_EXPIRE_CYCLE_KEYS_PER_LOOP 20 /* Keys for each DB loop. */ 如何控制定期删除的执行频率?\n在 Redis 中,定期删除的频率是由 hz 参数控制的。hz 默认为 10,代表每秒执行 10 次,也就是每秒钟进行 10 次尝试来查找并删除过期的 key。\nhz 的取值范围为 1~500。增大 hz 参数的值会提升定期删除的频率。如果你想要更频繁地执行定期删除任务,可以适当增加 hz 的值,但这会加 CPU 的使用率。根据 Redis 官方建议,hz 的值不建议超过 100,对于大部分用户使用默认的 10 就足够了。\n下面是 hz 参数的官方注释,我翻译了其中的重要信息(Redis 7.2 版本)。\n类似的参数还有一个 dynamic-hz,这个参数开启之后 Redis 就会在 hz 的基础上动态计算一个值。Redis 提供并默认启用了使用自适应 hz 值的能力,\n这两个参数都在 Redis 配置文件 redis.conf中:\n# 默认为 10 hz 10 # 默认开启 dynamic-hz yes 多提一嘴,除了定期删除过期 key 这个定期任务之外,还有一些其他定期任务例如关闭超时的客户端连接、更新统计信息,这些定期任务的执行频率也是通过 hz 参数决定。\n为什么定期删除不是把所有过期 key 都删除呢?\n这样会对性能造成太大的影响。如果我们 key 数量非常庞大的话,挨个遍历检查是非常耗时的,会严重影响性能。Redis 设计这种策略的目的是为了平衡内存和性能。\n为什么 key 过期之后不立马把它删掉呢?这样不是会浪费很多内存空间吗?\n因为不太好办到,或者说这种删除方式的成本太高了。假如我们使用延迟队列作为删除策略,这样存在下面这些问题:\n队列本身的开销可能很大:key 多的情况下,一个延迟队列可能无法容纳。 维护延迟队列太麻烦:修改 key 的过期时间就需要调整期在延迟队列中的位置,并且,还需要引入并发控制。 大量 key 集中过期怎么办? # 当 Redis 中存在大量 key 在同一时间点集中过期时,可能会导致以下问题:\n请求延迟增加: Redis 在处理过期 key 时需要消耗 CPU 资源,如果过期 key 数量庞大,会导致 Redis 实例的 CPU 占用率升高,进而影响其他请求的处理速度,造成延迟增加。 内存占用过高: 过期的 key 虽然已经失效,但在 Redis 真正删除它们之前,仍然会占用内存空间。如果过期 key 没有及时清理,可能会导致内存占用过高,甚至引发内存溢出。 为了避免这些问题,可以采取以下方案:\n尽量避免 key 集中过期: 在设置键的过期时间时尽量随机一点。 开启 lazy free 机制: 修改 redis.conf 配置文件,将 lazyfree-lazy-expire 参数设置为 yes,即可开启 lazy free 机制。开启 lazy free 机制后,Redis 会在后台异步删除过期的 key,不会阻塞主线程的运行,从而降低对 Redis 性能的影响。 Redis 内存淘汰策略了解么? # 相关问题:MySQL 里有 2000w 数据,Redis 中只存 20w 的数据,如何保证 Redis 中的数据都是热点数据?\nRedis 的内存淘汰策略只有在运行内存达到了配置的最大内存阈值时才会触发,这个阈值是通过redis.conf的maxmemory参数来定义的。64 位操作系统下,maxmemory 默认为 0 ,表示不限制内存大小。32 位操作系统下,默认的最大内存值是 3GB。\n你可以使用命令 config get maxmemory 来查看 maxmemory的值。\n\u0026gt; config get maxmemory maxmemory 0 Redis 提供了 6 种内存淘汰策略:\nvolatile-lru(least recently used):从已设置过期时间的数据集(server.db[i].expires)中挑选最近最少使用的数据淘汰。 volatile-ttl:从已设置过期时间的数据集(server.db[i].expires)中挑选将要过期的数据淘汰。 volatile-random:从已设置过期时间的数据集(server.db[i].expires)中任意选择数据淘汰。 allkeys-lru(least recently used):从数据集(server.db[i].dict)中移除最近最少使用的数据淘汰。 allkeys-random:从数据集(server.db[i].dict)中任意选择数据淘汰。 no-eviction(默认内存淘汰策略):禁止驱逐数据,当内存不足以容纳新写入数据时,新写入操作会报错。 4.0 版本后增加以下两种:\nvolatile-lfu(least frequently used):从已设置过期时间的数据集(server.db[i].expires)中挑选最不经常使用的数据淘汰。 allkeys-lfu(least frequently used):从数据集(server.db[i].dict)中移除最不经常使用的数据淘汰。 allkeys-xxx 表示从所有的键值中淘汰数据,而 volatile-xxx 表示从设置了过期时间的键值中淘汰数据。\nconfig.c中定义了内存淘汰策略的枚举数组:\nconfigEnum maxmemory_policy_enum[] = { {\u0026#34;volatile-lru\u0026#34;, MAXMEMORY_VOLATILE_LRU}, {\u0026#34;volatile-lfu\u0026#34;, MAXMEMORY_VOLATILE_LFU}, {\u0026#34;volatile-random\u0026#34;,MAXMEMORY_VOLATILE_RANDOM}, {\u0026#34;volatile-ttl\u0026#34;,MAXMEMORY_VOLATILE_TTL}, {\u0026#34;allkeys-lru\u0026#34;,MAXMEMORY_ALLKEYS_LRU}, {\u0026#34;allkeys-lfu\u0026#34;,MAXMEMORY_ALLKEYS_LFU}, {\u0026#34;allkeys-random\u0026#34;,MAXMEMORY_ALLKEYS_RANDOM}, {\u0026#34;noeviction\u0026#34;,MAXMEMORY_NO_EVICTION}, {NULL, 0} }; 你可以使用 config get maxmemory-policy 命令来查看当前 Redis 的内存淘汰策略。\n\u0026gt; config get maxmemory-policy maxmemory-policy noeviction 可以通过config set maxmemory-policy 内存淘汰策略 命令修改内存淘汰策略,立即生效,但这种方式重启 Redis 之后就失效了。修改 redis.conf 中的 maxmemory-policy 参数不会因为重启而失效,不过,需要重启之后修改才能生效。\nmaxmemory-policy noeviction 关于淘汰策略的详细说明可以参考 Redis 官方文档: https://redis.io/docs/reference/eviction/。\n参考 # 《Redis 开发与运维》 《Redis 设计与实现》 《Redis 核心原理与实战》 Redis 命令手册: https://www.redis.com.cn/commands.html RedisSearch 终极使用指南,你值得拥有!: https://mp.weixin.qq.com/s/FA4XVAXJksTOHUXMsayy2g WHY Redis choose single thread (vs multi threads): https://medium.com/@jychen7/sharing-redis-single-thread-vs-multi-threads-5870bd44d153 "},{"id":559,"href":"/zh/docs/technology/Interview/database/redis/redis-questions-02/","title":"Redis常见面试题总结(下)","section":"Redis","content":" Redis 事务 # 什么是 Redis 事务? # 你可以将 Redis 中的事务理解为:Redis 事务提供了一种将多个命令请求打包的功能。然后,再按顺序执行打包的所有命令,并且不会被中途打断。\nRedis 事务实际开发中使用的非常少,功能比较鸡肋,不要将其和我们平时理解的关系型数据库的事务混淆了。\n除了不满足原子性和持久性之外,事务中的每条命令都会与 Redis 服务器进行网络交互,这是比较浪费资源的行为。明明一次批量执行多个命令就可以了,这种操作实在是看不懂。\n因此,Redis 事务是不建议在日常开发中使用的。\n如何使用 Redis 事务? # Redis 可以通过 MULTI,EXEC,DISCARD 和 WATCH 等命令来实现事务(Transaction)功能。\n\u0026gt; MULTI OK \u0026gt; SET PROJECT \u0026#34;JavaGuide\u0026#34; QUEUED \u0026gt; GET PROJECT QUEUED \u0026gt; EXEC 1) OK 2) \u0026#34;JavaGuide\u0026#34; MULTI 命令后可以输入多个命令,Redis 不会立即执行这些命令,而是将它们放到队列,当调用了 EXEC 命令后,再执行所有的命令。\n这个过程是这样的:\n开始事务(MULTI); 命令入队(批量操作 Redis 的命令,先进先出(FIFO)的顺序执行); 执行事务(EXEC)。 你也可以通过 DISCARD 命令取消一个事务,它会清空事务队列中保存的所有命令。\n\u0026gt; MULTI OK \u0026gt; SET PROJECT \u0026#34;JavaGuide\u0026#34; QUEUED \u0026gt; GET PROJECT QUEUED \u0026gt; DISCARD OK 你可以通过 WATCH 命令监听指定的 Key,当调用 EXEC 命令执行事务时,如果一个被 WATCH 命令监视的 Key 被 其他客户端/Session 修改的话,整个事务都不会被执行。\n# 客户端 1 \u0026gt; SET PROJECT \u0026#34;RustGuide\u0026#34; OK \u0026gt; WATCH PROJECT OK \u0026gt; MULTI OK \u0026gt; SET PROJECT \u0026#34;JavaGuide\u0026#34; QUEUED # 客户端 2 # 在客户端 1 执行 EXEC 命令提交事务之前修改 PROJECT 的值 \u0026gt; SET PROJECT \u0026#34;GoGuide\u0026#34; # 客户端 1 # 修改失败,因为 PROJECT 的值被客户端2修改了 \u0026gt; EXEC (nil) \u0026gt; GET PROJECT \u0026#34;GoGuide\u0026#34; 不过,如果 WATCH 与 事务 在同一个 Session 里,并且被 WATCH 监视的 Key 被修改的操作发生在事务内部,这个事务是可以被执行成功的(相关 issue: WATCH 命令碰到 MULTI 命令时的不同效果)。\n事务内部修改 WATCH 监视的 Key:\n\u0026gt; SET PROJECT \u0026#34;JavaGuide\u0026#34; OK \u0026gt; WATCH PROJECT OK \u0026gt; MULTI OK \u0026gt; SET PROJECT \u0026#34;JavaGuide1\u0026#34; QUEUED \u0026gt; SET PROJECT \u0026#34;JavaGuide2\u0026#34; QUEUED \u0026gt; SET PROJECT \u0026#34;JavaGuide3\u0026#34; QUEUED \u0026gt; EXEC 1) OK 2) OK 3) OK 127.0.0.1:6379\u0026gt; GET PROJECT \u0026#34;JavaGuide3\u0026#34; 事务外部修改 WATCH 监视的 Key:\n\u0026gt; SET PROJECT \u0026#34;JavaGuide\u0026#34; OK \u0026gt; WATCH PROJECT OK \u0026gt; SET PROJECT \u0026#34;JavaGuide2\u0026#34; OK \u0026gt; MULTI OK \u0026gt; GET USER QUEUED \u0026gt; EXEC (nil) Redis 官网相关介绍 https://redis.io/topics/transactions 如下:\nRedis 事务支持原子性吗? # Redis 的事务和我们平时理解的关系型数据库的事务不同。我们知道事务具有四大特性:1. 原子性,2. 隔离性,3. 持久性,4. 一致性。\n原子性(Atomicity): 事务是最小的执行单位,不允许分割。事务的原子性确保动作要么全部完成,要么完全不起作用; 隔离性(Isolation): 并发访问数据库时,一个用户的事务不被其他事务所干扰,各并发事务之间数据库是独立的; 持久性(Durability): 一个事务被提交之后。它对数据库中数据的改变是持久的,即使数据库发生故障也不应该对其有任何影响。 一致性(Consistency): 执行事务前后,数据保持一致,多个事务对同一个数据读取的结果是相同的; Redis 事务在运行错误的情况下,除了执行过程中出现错误的命令外,其他命令都能正常执行。并且,Redis 事务是不支持回滚(roll back)操作的。因此,Redis 事务其实是不满足原子性的。\nRedis 官网也解释了自己为啥不支持回滚。简单来说就是 Redis 开发者们觉得没必要支持回滚,这样更简单便捷并且性能更好。Redis 开发者觉得即使命令执行错误也应该在开发过程中就被发现而不是生产过程中。\n相关 issue :\nissue#452: 关于 Redis 事务不满足原子性的问题 。 Issue#491:关于 Redis 没有事务回滚? Redis 事务支持持久性吗? # Redis 不同于 Memcached 的很重要一点就是,Redis 支持持久化,而且支持 3 种持久化方式:\n快照(snapshotting,RDB) 只追加文件(append-only file, AOF) RDB 和 AOF 的混合持久化(Redis 4.0 新增) 与 RDB 持久化相比,AOF 持久化的实时性更好。在 Redis 的配置文件中存在三种不同的 AOF 持久化方式( fsync策略),它们分别是:\nappendfsync always #每次有数据修改发生时都会调用fsync函数同步AOF文件,fsync完成后线程返回,这样会严重降低Redis的速度 appendfsync everysec #每秒钟调用fsync函数同步一次AOF文件 appendfsync no #让操作系统决定何时进行同步,一般为30秒一次 AOF 持久化的fsync策略为 no、everysec 时都会存在数据丢失的情况 。always 下可以基本是可以满足持久性要求的,但性能太差,实际开发过程中不会使用。\n因此,Redis 事务的持久性也是没办法保证的。\n如何解决 Redis 事务的缺陷? # Redis 从 2.6 版本开始支持执行 Lua 脚本,它的功能和事务非常类似。我们可以利用 Lua 脚本来批量执行多条 Redis 命令,这些 Redis 命令会被提交到 Redis 服务器一次性执行完成,大幅减小了网络开销。\n一段 Lua 脚本可以视作一条命令执行,一段 Lua 脚本执行过程中不会有其他脚本或 Redis 命令同时执行,保证了操作不会被其他指令插入或打扰。\n不过,如果 Lua 脚本运行时出错并中途结束,出错之后的命令是不会被执行的。并且,出错之前执行的命令是无法被撤销的,无法实现类似关系型数据库执行失败可以回滚的那种原子性效果。因此, 严格来说的话,通过 Lua 脚本来批量执行 Redis 命令实际也是不完全满足原子性的。\n如果想要让 Lua 脚本中的命令全部执行,必须保证语句语法和命令都是对的。\n另外,Redis 7.0 新增了 Redis functions 特性,你可以将 Redis functions 看作是比 Lua 更强大的脚本。\nRedis 性能优化(重要) # 除了下面介绍的内容之外,再推荐两篇不错的文章:\n你的 Redis 真的变慢了吗?性能优化如何做 - 阿里开发者 Redis 常见阻塞原因总结 - JavaGuide 使用批量操作减少网络传输 # 一个 Redis 命令的执行可以简化为以下 4 步:\n发送命令 命令排队 命令执行 返回结果 其中,第 1 步和第 4 步耗费时间之和称为 Round Trip Time (RTT,往返时间) ,也就是数据在网络上传输的时间。\n使用批量操作可以减少网络传输次数,进而有效减小网络开销,大幅减少 RTT。\n另外,除了能减少 RTT 之外,发送一次命令的 socket I/O 成本也比较高(涉及上下文切换,存在read()和write()系统调用),批量操作还可以减少 socket I/O 成本。这个在官方对 pipeline 的介绍中有提到: https://redis.io/docs/manual/pipelining/ 。\n原生批量操作命令 # Redis 中有一些原生支持批量操作的命令,比如:\nMGET(获取一个或多个指定 key 的值)、MSET(设置一个或多个指定 key 的值)、 HMGET(获取指定哈希表中一个或者多个指定字段的值)、HMSET(同时将一个或多个 field-value 对设置到指定哈希表中)、 SADD(向指定集合添加一个或多个元素) …… 不过,在 Redis 官方提供的分片集群解决方案 Redis Cluster 下,使用这些原生批量操作命令可能会存在一些小问题需要解决。就比如说 MGET 无法保证所有的 key 都在同一个 hash slot(哈希槽)上,MGET可能还是需要多次网络传输,原子操作也无法保证了。不过,相较于非批量操作,还是可以节省不少网络传输次数。\n整个步骤的简化版如下(通常由 Redis 客户端实现,无需我们自己再手动实现):\n找到 key 对应的所有 hash slot; 分别向对应的 Redis 节点发起 MGET 请求获取数据; 等待所有请求执行结束,重新组装结果数据,保持跟入参 key 的顺序一致,然后返回结果。 如果想要解决这个多次网络传输的问题,比较常用的办法是自己维护 key 与 slot 的关系。不过这样不太灵活,虽然带来了性能提升,但同样让系统复杂性提升。\nRedis Cluster 并没有使用一致性哈希,采用的是 哈希槽分区 ,每一个键值对都属于一个 hash slot(哈希槽) 。当客户端发送命令请求的时候,需要先根据 key 通过上面的计算公示找到的对应的哈希槽,然后再查询哈希槽和节点的映射关系,即可找到目标 Redis 节点。\n我在 Redis 集群详解(付费) 这篇文章中详细介绍了 Redis Cluster 这部分的内容,感兴趣地可以看看。\npipeline # 对于不支持批量操作的命令,我们可以利用 pipeline(流水线) 将一批 Redis 命令封装成一组,这些 Redis 命令会被一次性提交到 Redis 服务器,只需要一次网络传输。不过,需要注意控制一次批量操作的 元素个数(例如 500 以内,实际也和元素字节数有关),避免网络传输的数据量过大。\n与MGET、MSET等原生批量操作命令一样,pipeline 同样在 Redis Cluster 上使用会存在一些小问题。原因类似,无法保证所有的 key 都在同一个 hash slot(哈希槽)上。如果想要使用的话,客户端需要自己维护 key 与 slot 的关系。\n原生批量操作命令和 pipeline 的是有区别的,使用的时候需要注意:\n原生批量操作命令是原子操作,pipeline 是非原子操作。 pipeline 可以打包不同的命令,原生批量操作命令不可以。 原生批量操作命令是 Redis 服务端支持实现的,而 pipeline 需要服务端和客户端的共同实现。 顺带补充一下 pipeline 和 Redis 事务的对比:\n事务是原子操作,pipeline 是非原子操作。两个不同的事务不会同时运行,而 pipeline 可以同时以交错方式执行。 Redis 事务中每个命令都需要发送到服务端,而 Pipeline 只需要发送一次,请求次数更少。 事务可以看作是一个原子操作,但其实并不满足原子性。当我们提到 Redis 中的原子操作时,主要指的是这个操作(比如事务、Lua 脚本)不会被其他操作(比如其他事务、Lua 脚本)打扰,并不能完全保证这个操作中的所有写命令要么都执行要么都不执行。这主要也是因为 Redis 是不支持回滚操作。\n另外,pipeline 不适用于执行顺序有依赖关系的一批命令。就比如说,你需要将前一个命令的结果给后续的命令使用,pipeline 就没办法满足你的需求了。对于这种需求,我们可以使用 Lua 脚本 。\nLua 脚本 # Lua 脚本同样支持批量操作多条命令。一段 Lua 脚本可以视作一条命令执行,可以看作是 原子操作 。也就是说,一段 Lua 脚本执行过程中不会有其他脚本或 Redis 命令同时执行,保证了操作不会被其他指令插入或打扰,这是 pipeline 所不具备的。\n并且,Lua 脚本中支持一些简单的逻辑处理比如使用命令读取值并在 Lua 脚本中进行处理,这同样是 pipeline 所不具备的。\n不过, Lua 脚本依然存在下面这些缺陷:\n如果 Lua 脚本运行时出错并中途结束,之后的操作不会进行,但是之前已经发生的写操作不会撤销,所以即使使用了 Lua 脚本,也不能实现类似数据库回滚的原子性。 Redis Cluster 下 Lua 脚本的原子操作也无法保证了,原因同样是无法保证所有的 key 都在同一个 hash slot(哈希槽)上。 大量 key 集中过期问题 # 我在前面提到过:对于过期 key,Redis 采用的是 定期删除+惰性/懒汉式删除 策略。\n定期删除执行过程中,如果突然遇到大量过期 key 的话,客户端请求必须等待定期清理过期 key 任务线程执行完成,因为这个这个定期任务线程是在 Redis 主线程中执行的。这就导致客户端请求没办法被及时处理,响应速度会比较慢。\n如何解决呢? 下面是两种常见的方法:\n给 key 设置随机过期时间。 开启 lazy-free(惰性删除/延迟释放) 。lazy-free 特性是 Redis 4.0 开始引入的,指的是让 Redis 采用异步方式延迟释放 key 使用的内存,将该操作交给单独的子线程处理,避免阻塞主线程。 个人建议不管是否开启 lazy-free,我们都尽量给 key 设置随机过期时间。\nRedis bigkey(大 Key) # 什么是 bigkey? # 简单来说,如果一个 key 对应的 value 所占用的内存比较大,那这个 key 就可以看作是 bigkey。具体多大才算大呢?有一个不是特别精确的参考标准:\nString 类型的 value 超过 1MB 复合类型(List、Hash、Set、Sorted Set 等)的 value 包含的元素超过 5000 个(不过,对于复合类型的 value 来说,不一定包含的元素越多,占用的内存就越多)。 bigkey 是怎么产生的?有什么危害? # bigkey 通常是由于下面这些原因产生的:\n程序设计不当,比如直接使用 String 类型存储较大的文件对应的二进制数据。 对于业务的数据规模考虑不周到,比如使用集合类型的时候没有考虑到数据量的快速增长。 未及时清理垃圾数据,比如哈希中冗余了大量的无用键值对。 bigkey 除了会消耗更多的内存空间和带宽,还会对性能造成比较大的影响。\n在 Redis 常见阻塞原因总结这篇文章中我们提到:大 key 还会造成阻塞问题。具体来说,主要体现在下面三个方面:\n客户端超时阻塞:由于 Redis 执行命令是单线程处理,然后在操作大 key 时会比较耗时,那么就会阻塞 Redis,从客户端这一视角看,就是很久很久都没有响应。 网络阻塞:每次获取大 key 产生的网络流量较大,如果一个 key 的大小是 1 MB,每秒访问量为 1000,那么每秒会产生 1000MB 的流量,这对于普通千兆网卡的服务器来说是灾难性的。 工作线程阻塞:如果使用 del 删除大 key 时,会阻塞工作线程,这样就没办法处理后续的命令。 大 key 造成的阻塞问题还会进一步影响到主从同步和集群扩容。\n综上,大 key 带来的潜在问题是非常多的,我们应该尽量避免 Redis 中存在 bigkey。\n如何发现 bigkey? # 1、使用 Redis 自带的 --bigkeys 参数来查找。\n# redis-cli -p 6379 --bigkeys # Scanning the entire keyspace to find biggest keys as well as # average sizes per key type. You can use -i 0.1 to sleep 0.1 sec # per 100 SCAN commands (not usually needed). [00.00%] Biggest string found so far \u0026#39;\u0026#34;ballcat:oauth:refresh_auth:f6cdb384-9a9d-4f2f-af01-dc3f28057c20\u0026#34;\u0026#39; with 4437 bytes [00.00%] Biggest list found so far \u0026#39;\u0026#34;my-list\u0026#34;\u0026#39; with 17 items -------- summary ------- Sampled 5 keys in the keyspace! Total key length in bytes is 264 (avg len 52.80) Biggest list found \u0026#39;\u0026#34;my-list\u0026#34;\u0026#39; has 17 items Biggest string found \u0026#39;\u0026#34;ballcat:oauth:refresh_auth:f6cdb384-9a9d-4f2f-af01-dc3f28057c20\u0026#34;\u0026#39; has 4437 bytes 1 lists with 17 items (20.00% of keys, avg size 17.00) 0 hashs with 0 fields (00.00% of keys, avg size 0.00) 4 strings with 4831 bytes (80.00% of keys, avg size 1207.75) 0 streams with 0 entries (00.00% of keys, avg size 0.00) 0 sets with 0 members (00.00% of keys, avg size 0.00) 0 zsets with 0 members (00.00% of keys, avg size 0.00 从这个命令的运行结果,我们可以看出:这个命令会扫描(Scan) Redis 中的所有 key ,会对 Redis 的性能有一点影响。并且,这种方式只能找出每种数据结构 top 1 bigkey(占用内存最大的 String 数据类型,包含元素最多的复合数据类型)。然而,一个 key 的元素多并不代表占用内存也多,需要我们根据具体的业务情况来进一步判断。\n在线上执行该命令时,为了降低对 Redis 的影响,需要指定 -i 参数控制扫描的频率。redis-cli -p 6379 --bigkeys -i 3 表示扫描过程中每次扫描后休息的时间间隔为 3 秒。\n2、使用 Redis 自带的 SCAN 命令\nSCAN 命令可以按照一定的模式和数量返回匹配的 key。获取了 key 之后,可以利用 STRLEN、HLEN、LLEN等命令返回其长度或成员数量。\n数据结构 命令 复杂度 结果(对应 key) String STRLEN O(1) 字符串值的长度 Hash HLEN O(1) 哈希表中字段的数量 List LLEN O(1) 列表元素数量 Set SCARD O(1) 集合元素数量 Sorted Set ZCARD O(1) 有序集合的元素数量 对于集合类型还可以使用 MEMORY USAGE 命令(Redis 4.0+),这个命令会返回键值对占用的内存空间。\n3、借助开源工具分析 RDB 文件。\n通过分析 RDB 文件来找出 big key。这种方案的前提是你的 Redis 采用的是 RDB 持久化。\n网上有现成的代码/工具可以直接拿来使用:\nredis-rdb-tools:Python 语言写的用来分析 Redis 的 RDB 快照文件用的工具 rdb_bigkeys : Go 语言写的用来分析 Redis 的 RDB 快照文件用的工具,性能更好。 4、借助公有云的 Redis 分析服务。\n如果你用的是公有云的 Redis 服务的话,可以看看其是否提供了 key 分析功能(一般都提供了)。\n这里以阿里云 Redis 为例说明,它支持 bigkey 实时分析、发现,文档地址: https://www.alibabacloud.com/help/zh/apsaradb-for-redis/latest/use-the-real-time-key-statistics-feature 。\n如何处理 bigkey? # bigkey 的常见处理以及优化办法如下(这些方法可以配合起来使用):\n分割 bigkey:将一个 bigkey 分割为多个小 key。例如,将一个含有上万字段数量的 Hash 按照一定策略(比如二次哈希)拆分为多个 Hash。 手动清理:Redis 4.0+ 可以使用 UNLINK 命令来异步删除一个或多个指定的 key。Redis 4.0 以下可以考虑使用 SCAN 命令结合 DEL 命令来分批次删除。 采用合适的数据结构:例如,文件二进制数据不使用 String 保存、使用 HyperLogLog 统计页面 UV、Bitmap 保存状态信息(0/1)。 开启 lazy-free(惰性删除/延迟释放) :lazy-free 特性是 Redis 4.0 开始引入的,指的是让 Redis 采用异步方式延迟释放 key 使用的内存,将该操作交给单独的子线程处理,避免阻塞主线程。 Redis hotkey(热 Key) # 什么是 hotkey? # 如果一个 key 的访问次数比较多且明显多于其他 key 的话,那这个 key 就可以看作是 hotkey(热 Key)。例如在 Redis 实例的每秒处理请求达到 5000 次,而其中某个 key 的每秒访问量就高达 2000 次,那这个 key 就可以看作是 hotkey。\nhotkey 出现的原因主要是某个热点数据访问量暴增,如重大的热搜事件、参与秒杀的商品。\nhotkey 有什么危害? # 处理 hotkey 会占用大量的 CPU 和带宽,可能会影响 Redis 实例对其他请求的正常处理。此外,如果突然访问 hotkey 的请求超出了 Redis 的处理能力,Redis 就会直接宕机。这种情况下,大量请求将落到后面的数据库上,可能会导致数据库崩溃。\n因此,hotkey 很可能成为系统性能的瓶颈点,需要单独对其进行优化,以确保系统的高可用性和稳定性。\n如何发现 hotkey? # 1、使用 Redis 自带的 --hotkeys 参数来查找。\nRedis 4.0.3 版本中新增了 hotkeys 参数,该参数能够返回所有 key 的被访问次数。\n使用该方案的前提条件是 Redis Server 的 maxmemory-policy 参数设置为 LFU 算法,不然就会出现如下所示的错误。\n# redis-cli -p 6379 --hotkeys # Scanning the entire keyspace to find hot keys as well as # average sizes per key type. You can use -i 0.1 to sleep 0.1 sec # per 100 SCAN commands (not usually needed). Error: ERR An LFU maxmemory policy is not selected, access frequency not tracked. Please note that when switching between policies at runtime LRU and LFU data will take some time to adjust. Redis 中有两种 LFU 算法:\nvolatile-lfu(least frequently used):从已设置过期时间的数据集(server.db[i].expires)中挑选最不经常使用的数据淘汰。 allkeys-lfu(least frequently used):当内存不足以容纳新写入数据时,在键空间中,移除最不经常使用的 key。 以下是配置文件 redis.conf 中的示例:\n# 使用 volatile-lfu 策略 maxmemory-policy volatile-lfu # 或者使用 allkeys-lfu 策略 maxmemory-policy allkeys-lfu 需要注意的是,hotkeys 参数命令也会增加 Redis 实例的 CPU 和内存消耗(全局扫描),因此需要谨慎使用。\n2、使用MONITOR 命令。\nMONITOR 命令是 Redis 提供的一种实时查看 Redis 的所有操作的方式,可以用于临时监控 Redis 实例的操作情况,包括读写、删除等操作。\n由于该命令对 Redis 性能的影响比较大,因此禁止长时间开启 MONITOR(生产环境中建议谨慎使用该命令)。\n# redis-cli 127.0.0.1:6379\u0026gt; MONITOR OK 1683638260.637378 [0 172.17.0.1:61516] \u0026#34;ping\u0026#34; 1683638267.144236 [0 172.17.0.1:61518] \u0026#34;smembers\u0026#34; \u0026#34;mySet\u0026#34; 1683638268.941863 [0 172.17.0.1:61518] \u0026#34;smembers\u0026#34; \u0026#34;mySet\u0026#34; 1683638269.551671 [0 172.17.0.1:61518] \u0026#34;smembers\u0026#34; \u0026#34;mySet\u0026#34; 1683638270.646256 [0 172.17.0.1:61516] \u0026#34;ping\u0026#34; 1683638270.849551 [0 172.17.0.1:61518] \u0026#34;smembers\u0026#34; \u0026#34;mySet\u0026#34; 1683638271.926945 [0 172.17.0.1:61518] \u0026#34;smembers\u0026#34; \u0026#34;mySet\u0026#34; 1683638274.276599 [0 172.17.0.1:61518] \u0026#34;smembers\u0026#34; \u0026#34;mySet2\u0026#34; 1683638276.327234 [0 172.17.0.1:61518] \u0026#34;smembers\u0026#34; \u0026#34;mySet\u0026#34; 在发生紧急情况时,我们可以选择在合适的时机短暂执行 MONITOR 命令并将输出重定向至文件,在关闭 MONITOR 命令后通过对文件中请求进行归类分析即可找出这段时间中的 hotkey。\n3、借助开源项目。\n京东零售的 hotkey 这个项目不光支持 hotkey 的发现,还支持 hotkey 的处理。\n4、根据业务情况提前预估。\n可以根据业务情况来预估一些 hotkey,比如参与秒杀活动的商品数据等。不过,我们无法预估所有 hotkey 的出现,比如突发的热点新闻事件等。\n5、业务代码中记录分析。\n在业务代码中添加相应的逻辑对 key 的访问情况进行记录分析。不过,这种方式会让业务代码的复杂性增加,一般也不会采用。\n6、借助公有云的 Redis 分析服务。\n如果你用的是公有云的 Redis 服务的话,可以看看其是否提供了 key 分析功能(一般都提供了)。\n这里以阿里云 Redis 为例说明,它支持 hotkey 实时分析、发现,文档地址: https://www.alibabacloud.com/help/zh/apsaradb-for-redis/latest/use-the-real-time-key-statistics-feature 。\n如何解决 hotkey? # hotkey 的常见处理以及优化办法如下(这些方法可以配合起来使用):\n读写分离:主节点处理写请求,从节点处理读请求。 使用 Redis Cluster:将热点数据分散存储在多个 Redis 节点上。 二级缓存:hotkey 采用二级缓存的方式进行处理,将 hotkey 存放一份到 JVM 本地内存中(可以用 Caffeine)。 除了这些方法之外,如果你使用的公有云的 Redis 服务话,还可以留意其提供的开箱即用的解决方案。\n这里以阿里云 Redis 为例说明,它支持通过代理查询缓存功能(Proxy Query Cache)优化热点 Key 问题。\n慢查询命令 # 为什么会有慢查询命令? # 我们知道一个 Redis 命令的执行可以简化为以下 4 步:\n发送命令 命令排队 命令执行 返回结果 Redis 慢查询统计的是命令执行这一步骤的耗时,慢查询命令也就是那些命令执行时间较长的命令。\nRedis 为什么会有慢查询命令呢?\nRedis 中的大部分命令都是 O(1)时间复杂度,但也有少部分 O(n) 时间复杂度的命令,例如:\nKEYS *:会返回所有符合规则的 key。 HGETALL:会返回一个 Hash 中所有的键值对。 LRANGE:会返回 List 中指定范围内的元素。 SMEMBERS:返回 Set 中的所有元素。 SINTER/SUNION/SDIFF:计算多个 Set 的交集/并集/差集。 …… 由于这些命令时间复杂度是 O(n),有时候也会全表扫描,随着 n 的增大,执行耗时也会越长。不过, 这些命令并不是一定不能使用,但是需要明确 N 的值。另外,有遍历的需求可以使用 HSCAN、SSCAN、ZSCAN 代替。\n除了这些 O(n)时间复杂度的命令可能会导致慢查询之外, 还有一些时间复杂度可能在 O(N) 以上的命令,例如:\nZRANGE/ZREVRANGE:返回指定 Sorted Set 中指定排名范围内的所有元素。时间复杂度为 O(log(n)+m),n 为所有元素的数量, m 为返回的元素数量,当 m 和 n 相当大时,O(n) 的时间复杂度更小。 ZREMRANGEBYRANK/ZREMRANGEBYSCORE:移除 Sorted Set 中指定排名范围/指定 score 范围内的所有元素。时间复杂度为 O(log(n)+m),n 为所有元素的数量, m 被删除元素的数量,当 m 和 n 相当大时,O(n) 的时间复杂度更小。 …… 如何找到慢查询命令? # 在 redis.conf 文件中,我们可以使用 slowlog-log-slower-than 参数设置耗时命令的阈值,并使用 slowlog-max-len 参数设置耗时命令的最大记录条数。\n当 Redis 服务器检测到执行时间超过 slowlog-log-slower-than阈值的命令时,就会将该命令记录在慢查询日志(slow log) 中,这点和 MySQL 记录慢查询语句类似。当慢查询日志超过设定的最大记录条数之后,Redis 会把最早的执行命令依次舍弃。\n⚠️注意:由于慢查询日志会占用一定内存空间,如果设置最大记录条数过大,可能会导致内存占用过高的问题。\nslowlog-log-slower-than和slowlog-max-len的默认配置如下(可以自行修改):\n# The following time is expressed in microseconds, so 1000000 is equivalent # to one second. Note that a negative number disables the slow log, while # a value of zero forces the logging of every command. slowlog-log-slower-than 10000 # There is no limit to this length. Just be aware that it will consume memory. # You can reclaim memory used by the slow log with SLOWLOG RESET. slowlog-max-len 128 除了修改配置文件之外,你也可以直接通过 CONFIG 命令直接设置:\n# 命令执行耗时超过 10000 微妙(即10毫秒)就会被记录 CONFIG SET slowlog-log-slower-than 10000 # 只保留最近 128 条耗时命令 CONFIG SET slowlog-max-len 128 获取慢查询日志的内容很简单,直接使用SLOWLOG GET 命令即可。\n127.0.0.1:6379\u0026gt; SLOWLOG GET #慢日志查询 1) 1) (integer) 5 2) (integer) 1684326682 3) (integer) 12000 4) 1) \u0026#34;KEYS\u0026#34; 2) \u0026#34;*\u0026#34; 5) \u0026#34;172.17.0.1:61152\u0026#34; 6) \u0026#34;\u0026#34; // ... 慢查询日志中的每个条目都由以下六个值组成:\n唯一渐进的日志标识符。 处理记录命令的 Unix 时间戳。 执行所需的时间量,以微秒为单位。 组成命令参数的数组。 客户端 IP 地址和端口。 客户端名称。 SLOWLOG GET 命令默认返回最近 10 条的的慢查询命令,你也自己可以指定返回的慢查询命令的数量 SLOWLOG GET N。\n下面是其他比较常用的慢查询相关的命令:\n# 返回慢查询命令的数量 127.0.0.1:6379\u0026gt; SLOWLOG LEN (integer) 128 # 清空慢查询命令 127.0.0.1:6379\u0026gt; SLOWLOG RESET OK Redis 内存碎片 # 相关问题:\n什么是内存碎片?为什么会有 Redis 内存碎片? 如何清理 Redis 内存碎片? 参考答案: Redis 内存碎片详解。\nRedis 生产问题(重要) # 缓存穿透 # 什么是缓存穿透? # 缓存穿透说简单点就是大量请求的 key 是不合理的,根本不存在于缓存中,也不存在于数据库中 。这就导致这些请求直接到了数据库上,根本没有经过缓存这一层,对数据库造成了巨大的压力,可能直接就被这么多请求弄宕机了。\n举个例子:某个黑客故意制造一些非法的 key 发起大量请求,导致大量请求落到数据库,结果数据库上也没有查到对应的数据。也就是说这些请求最终都落到了数据库上,对数据库造成了巨大的压力。\n有哪些解决办法? # 最基本的就是首先做好参数校验,一些不合法的参数请求直接抛出异常信息返回给客户端。比如查询的数据库 id 不能小于 0、传入的邮箱格式不对的时候直接返回错误消息给客户端等等。\n1)缓存无效 key\n如果缓存和数据库都查不到某个 key 的数据就写一个到 Redis 中去并设置过期时间,具体命令如下:SET key value EX 10086 。这种方式可以解决请求的 key 变化不频繁的情况,如果黑客恶意攻击,每次构建不同的请求 key,会导致 Redis 中缓存大量无效的 key 。很明显,这种方案并不能从根本上解决此问题。如果非要用这种方式来解决穿透问题的话,尽量将无效的 key 的过期时间设置短一点比如 1 分钟。\n另外,这里多说一嘴,一般情况下我们是这样设计 key 的:表名:列名:主键名:主键值 。\n如果用 Java 代码展示的话,差不多是下面这样的:\npublic Object getObjectInclNullById(Integer id) { // 从缓存中获取数据 Object cacheValue = cache.get(id); // 缓存为空 if (cacheValue == null) { // 从数据库中获取 Object storageValue = storage.get(key); // 缓存空对象 cache.set(key, storageValue); // 如果存储数据为空,需要设置一个过期时间(300秒) if (storageValue == null) { // 必须设置过期时间,否则有被攻击的风险 cache.expire(key, 60 * 5); } return storageValue; } return cacheValue; } 2)布隆过滤器\n布隆过滤器是一个非常神奇的数据结构,通过它我们可以非常方便地判断一个给定数据是否存在于海量数据中。我们可以把它看作由二进制向量(或者说位数组)和一系列随机映射函数(哈希函数)两部分组成的数据结构。相比于我们平时常用的 List、Map、Set 等数据结构,它占用空间更少并且效率更高,但是缺点是其返回的结果是概率性的,而不是非常准确的。理论情况下添加到集合中的元素越多,误报的可能性就越大。并且,存放在布隆过滤器的数据不容易删除。\nBloom Filter 会使用一个较大的 bit 数组来保存所有的数据,数组中的每个元素都只占用 1 bit ,并且每个元素只能是 0 或者 1(代表 false 或者 true),这也是 Bloom Filter 节省内存的核心所在。这样来算的话,申请一个 100w 个元素的位数组只占用 1000000Bit / 8 = 125000 Byte = 125000/1024 KB ≈ 122KB 的空间。\n具体是这样做的:把所有可能存在的请求的值都存放在布隆过滤器中,当用户请求过来,先判断用户发来的请求的值是否存在于布隆过滤器中。不存在的话,直接返回请求参数错误信息给客户端,存在的话才会走下面的流程。\n加入布隆过滤器之后的缓存处理流程图如下。\n更多关于布隆过滤器的详细介绍可以看看我的这篇原创: 不了解布隆过滤器?一文给你整的明明白白! ,强烈推荐。\n3)接口限流\n根据用户或者 IP 对接口进行限流,对于异常频繁的访问行为,还可以采取黑名单机制,例如将异常 IP 列入黑名单。\n后面提到的缓存击穿和雪崩都可以配合接口限流来解决,毕竟这些问题的关键都是有很多请求落到了数据库上造成数据库压力过大。\n限流的具体方案可以参考这篇文章: 服务限流详解。\n缓存击穿 # 什么是缓存击穿? # 缓存击穿中,请求的 key 对应的是 热点数据 ,该数据 存在于数据库中,但不存在于缓存中(通常是因为缓存中的那份数据已经过期) 。这就可能会导致瞬时大量的请求直接打到了数据库上,对数据库造成了巨大的压力,可能直接就被这么多请求弄宕机了。\n举个例子:秒杀进行过程中,缓存中的某个秒杀商品的数据突然过期,这就导致瞬时大量对该商品的请求直接落到数据库上,对数据库造成了巨大的压力。\n有哪些解决办法? # 永不过期(不推荐):设置热点数据永不过期或者过期时间比较长。 提前预热(推荐):针对热点数据提前预热,将其存入缓存中并设置合理的过期时间比如秒杀场景下的数据在秒杀结束之前不过期。 加锁(看情况):在缓存失效后,通过设置互斥锁确保只有一个请求去查询数据库并更新缓存。 缓存穿透和缓存击穿有什么区别? # 缓存穿透中,请求的 key 既不存在于缓存中,也不存在于数据库中。\n缓存击穿中,请求的 key 对应的是 热点数据 ,该数据 存在于数据库中,但不存在于缓存中(通常是因为缓存中的那份数据已经过期) 。\n缓存雪崩 # 什么是缓存雪崩? # 我发现缓存雪崩这名字起的有点意思,哈哈。\n实际上,缓存雪崩描述的就是这样一个简单的场景:缓存在同一时间大面积的失效,导致大量的请求都直接落到了数据库上,对数据库造成了巨大的压力。 这就好比雪崩一样,摧枯拉朽之势,数据库的压力可想而知,可能直接就被这么多请求弄宕机了。\n另外,缓存服务宕机也会导致缓存雪崩现象,导致所有的请求都落到了数据库上。\n举个例子:数据库中的大量数据在同一时间过期,这个时候突然有大量的请求需要访问这些过期的数据。这就导致大量的请求直接落到数据库上,对数据库造成了巨大的压力。\n有哪些解决办法? # 针对 Redis 服务不可用的情况:\nRedis 集群:采用 Redis 集群,避免单机出现问题整个缓存服务都没办法使用。Redis Cluster 和 Redis Sentinel 是两种最常用的 Redis 集群实现方案,详细介绍可以参考: Redis 集群详解(付费)。 多级缓存:设置多级缓存,例如本地缓存+Redis 缓存的二级缓存组合,当 Redis 缓存出现问题时,还可以从本地缓存中获取到部分数据。 针对大量缓存同时失效的情况:\n设置随机失效时间(可选):为缓存设置随机的失效时间,例如在固定过期时间的基础上加上一个随机值,这样可以避免大量缓存同时到期,从而减少缓存雪崩的风险。 提前预热(推荐):针对热点数据提前预热,将其存入缓存中并设置合理的过期时间比如秒杀场景下的数据在秒杀结束之前不过期。 持久缓存策略(看情况):虽然一般不推荐设置缓存永不过期,但对于某些关键性和变化不频繁的数据,可以考虑这种策略。 缓存预热如何实现? # 常见的缓存预热方式有两种:\n使用定时任务,比如 xxl-job,来定时触发缓存预热的逻辑,将数据库中的热点数据查询出来并存入缓存中。 使用消息队列,比如 Kafka,来异步地进行缓存预热,将数据库中的热点数据的主键或者 ID 发送到消息队列中,然后由缓存服务消费消息队列中的数据,根据主键或者 ID 查询数据库并更新缓存。 缓存雪崩和缓存击穿有什么区别? # 缓存雪崩和缓存击穿比较像,但缓存雪崩导致的原因是缓存中的大量或者所有数据失效,缓存击穿导致的原因主要是某个热点数据不存在与缓存中(通常是因为缓存中的那份数据已经过期)。\n如何保证缓存和数据库数据的一致性? # 细说的话可以扯很多,但是我觉得其实没太大必要(小声 BB:很多解决方案我也没太弄明白)。我个人觉得引入缓存之后,如果为了短时间的不一致性问题,选择让系统设计变得更加复杂的话,完全没必要。\n下面单独对 Cache Aside Pattern(旁路缓存模式) 来聊聊。\nCache Aside Pattern 中遇到写请求是这样的:更新数据库,然后直接删除缓存 。\n如果更新数据库成功,而删除缓存这一步失败的情况的话,简单说有两个解决方案:\n缓存失效时间变短(不推荐,治标不治本):我们让缓存数据的过期时间变短,这样的话缓存就会从数据库中加载数据。另外,这种解决办法对于先操作缓存后操作数据库的场景不适用。 增加缓存更新重试机制(常用):如果缓存服务当前不可用导致缓存删除失败的话,我们就隔一段时间进行重试,重试次数可以自己定。不过,这里更适合引入消息队列实现异步重试,将删除缓存重试的消息投递到消息队列,然后由专门的消费者来重试,直到成功。虽然说多引入了一个消息队列,但其整体带来的收益还是要更高一些。 相关文章推荐: 缓存和数据库一致性问题,看这篇就够了 - 水滴与银弹。\n哪些情况可能会导致 Redis 阻塞? # 单独抽了一篇文章来总结可能会导致 Redis 阻塞的情况: Redis 常见阻塞原因总结。\nRedis 集群 # Redis Sentinel:\n什么是 Sentinel? 有什么用? Sentinel 如何检测节点是否下线?主观下线与客观下线的区别? Sentinel 是如何实现故障转移的? 为什么建议部署多个 sentinel 节点(哨兵集群)? Sentinel 如何选择出新的 master(选举机制)? 如何从 Sentinel 集群中选择出 Leader ? Sentinel 可以防止脑裂吗? Redis Cluster:\n为什么需要 Redis Cluster?解决了什么问题?有什么优势? Redis Cluster 是如何分片的? 为什么 Redis Cluster 的哈希槽是 16384 个? 如何确定给定 key 的应该分布到哪个哈希槽中? Redis Cluster 支持重新分配哈希槽吗? Redis Cluster 扩容缩容期间可以提供服务吗? Redis Cluster 中的节点是怎么进行通信的? 参考答案: Redis 集群详解(付费)。\nRedis 使用规范 # 实际使用 Redis 的过程中,我们尽量要准守一些常见的规范,比如:\n使用连接池:避免频繁创建关闭客户端连接。 尽量不使用 O(n)指令,使用 O(n) 命令时要关注 n 的数量:像 KEYS *、HGETALL、LRANGE、SMEMBERS、SINTER/SUNION/SDIFF等 O(n) 命令并非不能使用,但是需要明确 n 的值。另外,有遍历的需求可以使用 HSCAN、SSCAN、ZSCAN 代替。 使用批量操作减少网络传输:原生批量操作命令(比如 MGET、MSET等等)、pipeline、Lua 脚本。 尽量不适用 Redis 事务:Redis 事务实现的功能比较鸡肋,可以使用 Lua 脚本代替。 禁止长时间开启 monitor:对性能影响比较大。 控制 key 的生命周期:避免 Redis 中存放了太多不经常被访问的数据。 …… 相关文章推荐: 阿里云 Redis 开发规范 。\n参考 # 《Redis 开发与运维》 《Redis 设计与实现》 Redis Transactions : https://redis.io/docs/manual/transactions/ What is Redis Pipeline: https://buildatscale.tech/what-is-redis-pipeline/ 一文详解 Redis 中 BigKey、HotKey 的发现与处理: https://mp.weixin.qq.com/s/FPYE1B839_8Yk1-YSiW-1Q Bigkey 问题的解决思路与方式探索: https://mp.weixin.qq.com/s/Sej7D9TpdAobcCmdYdMIyA Redis 延迟问题全面排障指南: https://mp.weixin.qq.com/s/mIc6a9mfEGdaNDD3MmfFsg "},{"id":560,"href":"/zh/docs/technology/Interview/database/redis/redis-common-blocking-problems-summary/","title":"Redis常见阻塞原因总结","section":"Redis","content":" 本文整理完善自: https://mp.weixin.qq.com/s/0Nqfq_eQrUb12QH6eBbHXA ,作者:阿 Q 说代码\n这篇文章会详细总结一下可能导致 Redis 阻塞的情况,这些情况也是影响 Redis 性能的关键因素,使用 Redis 的时候应该格外注意!\nO(n) 命令 # Redis 中的大部分命令都是 O(1)时间复杂度,但也有少部分 O(n) 时间复杂度的命令,例如:\nKEYS *:会返回所有符合规则的 key。 HGETALL:会返回一个 Hash 中所有的键值对。 LRANGE:会返回 List 中指定范围内的元素。 SMEMBERS:返回 Set 中的所有元素。 SINTER/SUNION/SDIFF:计算多个 Set 的交集/并集/差集。 …… 由于这些命令时间复杂度是 O(n),有时候也会全表扫描,随着 n 的增大,执行耗时也会越长,从而导致客户端阻塞。不过, 这些命令并不是一定不能使用,但是需要明确 N 的值。另外,有遍历的需求可以使用 HSCAN、SSCAN、ZSCAN 代替。\n除了这些 O(n)时间复杂度的命令可能会导致阻塞之外, 还有一些时间复杂度可能在 O(N) 以上的命令,例如:\nZRANGE/ZREVRANGE:返回指定 Sorted Set 中指定排名范围内的所有元素。时间复杂度为 O(log(n)+m),n 为所有元素的数量, m 为返回的元素数量,当 m 和 n 相当大时,O(n) 的时间复杂度更小。 ZREMRANGEBYRANK/ZREMRANGEBYSCORE:移除 Sorted Set 中指定排名范围/指定 score 范围内的所有元素。时间复杂度为 O(log(n)+m),n 为所有元素的数量, m 被删除元素的数量,当 m 和 n 相当大时,O(n) 的时间复杂度更小。 …… SAVE 创建 RDB 快照 # Redis 提供了两个命令来生成 RDB 快照文件:\nsave : 同步保存操作,会阻塞 Redis 主线程; bgsave : fork 出一个子进程,子进程执行,不会阻塞 Redis 主线程,默认选项。 默认情况下,Redis 默认配置会使用 bgsave 命令。如果手动使用 save 命令生成 RDB 快照文件的话,就会阻塞主线程。\nAOF # AOF 日志记录阻塞 # Redis AOF 持久化机制是在执行完命令之后再记录日志,这和关系型数据库(如 MySQL)通常都是执行命令之前记录日志(方便故障恢复)不同。\n为什么是在执行完命令之后记录日志呢?\n避免额外的检查开销,AOF 记录日志不会对命令进行语法检查; 在命令执行完之后再记录,不会阻塞当前的命令执行。 这样也带来了风险(我在前面介绍 AOF 持久化的时候也提到过):\n如果刚执行完命令 Redis 就宕机会导致对应的修改丢失; 可能会阻塞后续其他命令的执行(AOF 记录日志是在 Redis 主线程中进行的)。 AOF 刷盘阻塞 # 开启 AOF 持久化后每执行一条会更改 Redis 中的数据的命令,Redis 就会将该命令写入到 AOF 缓冲区 server.aof_buf 中,然后再根据 appendfsync 配置来决定何时将其同步到硬盘中的 AOF 文件。\n在 Redis 的配置文件中存在三种不同的 AOF 持久化方式( fsync策略),它们分别是:\nappendfsync always:主线程调用 write 执行写操作后,后台线程( aof_fsync 线程)立即会调用 fsync 函数同步 AOF 文件(刷盘),fsync 完成后线程返回,这样会严重降低 Redis 的性能(write + fsync)。 appendfsync everysec:主线程调用 write 执行写操作后立即返回,由后台线程( aof_fsync 线程)每秒钟调用 fsync 函数(系统调用)同步一次 AOF 文件(write+fsync,fsync间隔为 1 秒) appendfsync no:主线程调用 write 执行写操作后立即返回,让操作系统决定何时进行同步,Linux 下一般为 30 秒一次(write但不fsync,fsync 的时机由操作系统决定)。 当后台线程( aof_fsync 线程)调用 fsync 函数同步 AOF 文件时,需要等待,直到写入完成。当磁盘压力太大的时候,会导致 fsync 操作发生阻塞,主线程调用 write 函数时也会被阻塞。fsync 完成后,主线程执行 write 才能成功返回。\n关于 AOF 工作流程的详细介绍可以查看: Redis 持久化机制详解,有助于理解 AOF 刷盘阻塞。\nAOF 重写阻塞 # fork 出一条子线程来将文件重写,在执行 BGREWRITEAOF 命令时,Redis 服务器会维护一个 AOF 重写缓冲区,该缓冲区会在子线程创建新 AOF 文件期间,记录服务器执行的所有写命令。 当子线程完成创建新 AOF 文件的工作之后,服务器会将重写缓冲区中的所有内容追加到新 AOF 文件的末尾,使得新的 AOF 文件保存的数据库状态与现有的数据库状态一致。 最后,服务器用新的 AOF 文件替换旧的 AOF 文件,以此来完成 AOF 文件重写操作。 阻塞就是出现在第 2 步的过程中,将缓冲区中新数据写到新文件的过程中会产生阻塞。\n相关阅读: Redis AOF 重写阻塞问题分析。\n大 Key # 如果一个 key 对应的 value 所占用的内存比较大,那这个 key 就可以看作是 bigkey。具体多大才算大呢?有一个不是特别精确的参考标准:\nstring 类型的 value 超过 1MB 复合类型(列表、哈希、集合、有序集合等)的 value 包含的元素超过 5000 个(对于复合类型的 value 来说,不一定包含的元素越多,占用的内存就越多)。 大 key 造成的阻塞问题如下:\n客户端超时阻塞:由于 Redis 执行命令是单线程处理,然后在操作大 key 时会比较耗时,那么就会阻塞 Redis,从客户端这一视角看,就是很久很久都没有响应。 引发网络阻塞:每次获取大 key 产生的网络流量较大,如果一个 key 的大小是 1 MB,每秒访问量为 1000,那么每秒会产生 1000MB 的流量,这对于普通千兆网卡的服务器来说是灾难性的。 阻塞工作线程:如果使用 del 删除大 key 时,会阻塞工作线程,这样就没办法处理后续的命令。 查找大 key # 当我们在使用 Redis 自带的 --bigkeys 参数查找大 key 时,最好选择在从节点上执行该命令,因为主节点上执行时,会阻塞主节点。\n我们还可以使用 SCAN 命令来查找大 key;\n通过分析 RDB 文件来找出 big key,这种方案的前提是 Redis 采用的是 RDB 持久化。网上有现成的工具:\nredis-rdb-tools:Python 语言写的用来分析 Redis 的 RDB 快照文件用的工具 rdb_bigkeys:Go 语言写的用来分析 Redis 的 RDB 快照文件用的工具,性能更好。 删除大 key # 删除操作的本质是要释放键值对占用的内存空间。\n释放内存只是第一步,为了更加高效地管理内存空间,在应用程序释放内存时,操作系统需要把释放掉的内存块插入一个空闲内存块的链表,以便后续进行管理和再分配。这个过程本身需要一定时间,而且会阻塞当前释放内存的应用程序。\n所以,如果一下子释放了大量内存,空闲内存块链表操作时间就会增加,相应地就会造成 Redis 主线程的阻塞,如果主线程发生了阻塞,其他所有请求可能都会超时,超时越来越多,会造成 Redis 连接耗尽,产生各种异常。\n删除大 key 时建议采用分批次删除和异步删除的方式进行。\n清空数据库 # 清空数据库和上面 bigkey 删除也是同样道理,flushdb、flushall 也涉及到删除和释放所有的键值对,也是 Redis 的阻塞点。\n集群扩容 # Redis 集群可以进行节点的动态扩容缩容,这一过程目前还处于半自动状态,需要人工介入。\n在扩缩容的时候,需要进行数据迁移。而 Redis 为了保证迁移的一致性,迁移所有操作都是同步操作。\n执行迁移时,两端的 Redis 均会进入时长不等的阻塞状态,对于小 Key,该时间可以忽略不计,但如果一旦 Key 的内存使用过大,严重的时候会触发集群内的故障转移,造成不必要的切换。\nSwap(内存交换) # 什么是 Swap? Swap 直译过来是交换的意思,Linux 中的 Swap 常被称为内存交换或者交换分区。类似于 Windows 中的虚拟内存,就是当内存不足的时候,把一部分硬盘空间虚拟成内存使用,从而解决内存容量不足的情况。因此,Swap 分区的作用就是牺牲硬盘,增加内存,解决 VPS 内存不够用或者爆满的问题。\nSwap 对于 Redis 来说是非常致命的,Redis 保证高性能的一个重要前提是所有的数据在内存中。如果操作系统把 Redis 使用的部分内存换出硬盘,由于内存与硬盘的读写速度差几个数量级,会导致发生交换后的 Redis 性能急剧下降。\n识别 Redis 发生 Swap 的检查方法如下:\n1、查询 Redis 进程号\nredis-cli -p 6383 info server | grep process_id process_id: 4476 2、根据进程号查询内存交换信息\ncat /proc/4476/smaps | grep Swap Swap: 0kB Swap: 0kB Swap: 4kB Swap: 0kB Swap: 0kB ..... 如果交换量都是 0KB 或者个别的是 4KB,则正常。\n预防内存交换的方法:\n保证机器充足的可用内存 确保所有 Redis 实例设置最大可用内存(maxmemory),防止极端情况 Redis 内存不可控的增长 降低系统使用 swap 优先级,如echo 10 \u0026gt; /proc/sys/vm/swappiness CPU 竞争 # Redis 是典型的 CPU 密集型应用,不建议和其他多核 CPU 密集型服务部署在一起。当其他进程过度消耗 CPU 时,将严重影响 Redis 的吞吐量。\n可以通过redis-cli --stat获取当前 Redis 使用情况。通过top命令获取进程对 CPU 的利用率等信息 通过info commandstats统计信息分析出命令不合理开销时间,查看是否是因为高算法复杂度或者过度的内存优化问题。\n网络问题 # 连接拒绝、网络延迟,网卡软中断等网络问题也可能会导致 Redis 阻塞。\n参考 # Redis 阻塞的 6 大类场景分析与总结: https://mp.weixin.qq.com/s/eaZCEtTjTuEmXfUubVHjew Redis 开发与运维笔记-Redis 的噩梦-阻塞: https://mp.weixin.qq.com/s/TDbpz9oLH6ifVv6ewqgSgA "},{"id":561,"href":"/zh/docs/technology/Interview/database/redis/redis-persistence/","title":"Redis持久化机制详解","section":"Redis","content":"使用缓存的时候,我们经常需要对内存中的数据进行持久化也就是将内存中的数据写入到硬盘中。大部分原因是为了之后重用数据(比如重启机器、机器故障之后恢复数据),或者是为了做数据同步(比如 Redis 集群的主从节点通过 RDB 文件同步数据)。\nRedis 不同于 Memcached 的很重要一点就是,Redis 支持持久化,而且支持 3 种持久化方式:\n快照(snapshotting,RDB) 只追加文件(append-only file, AOF) RDB 和 AOF 的混合持久化(Redis 4.0 新增) 官方文档地址: https://redis.io/topics/persistence 。\nRDB 持久化 # 什么是 RDB 持久化? # Redis 可以通过创建快照来获得存储在内存里面的数据在 某个时间点 上的副本。Redis 创建快照之后,可以对快照进行备份,可以将快照复制到其他服务器从而创建具有相同数据的服务器副本(Redis 主从结构,主要用来提高 Redis 性能),还可以将快照留在原地以便重启服务器的时候使用。\n快照持久化是 Redis 默认采用的持久化方式,在 redis.conf 配置文件中默认有此下配置:\nsave 900 1 #在900秒(15分钟)之后,如果至少有1个key发生变化,Redis就会自动触发bgsave命令创建快照。 save 300 10 #在300秒(5分钟)之后,如果至少有10个key发生变化,Redis就会自动触发bgsave命令创建快照。 save 60 10000 #在60秒(1分钟)之后,如果至少有10000个key发生变化,Redis就会自动触发bgsave命令创建快照。 RDB 创建快照时会阻塞主线程吗? # Redis 提供了两个命令来生成 RDB 快照文件:\nsave : 同步保存操作,会阻塞 Redis 主线程; bgsave : fork 出一个子进程,子进程执行,不会阻塞 Redis 主线程,默认选项。 这里说 Redis 主线程而不是主进程的主要是因为 Redis 启动之后主要是通过单线程的方式完成主要的工作。如果你想将其描述为 Redis 主进程,也没毛病。\nAOF 持久化 # 什么是 AOF 持久化? # 与快照持久化相比,AOF 持久化的实时性更好。默认情况下 Redis 没有开启 AOF(append only file)方式的持久化(Redis 6.0 之后已经默认是开启了),可以通过 appendonly 参数开启:\nappendonly yes 开启 AOF 持久化后每执行一条会更改 Redis 中的数据的命令,Redis 就会将该命令写入到 AOF 缓冲区 server.aof_buf 中,然后再写入到 AOF 文件中(此时还在系统内核缓存区未同步到磁盘),最后再根据持久化方式( fsync策略)的配置来决定何时将系统内核缓存区的数据同步到硬盘中的。\n只有同步到磁盘中才算持久化保存了,否则依然存在数据丢失的风险,比如说:系统内核缓存区的数据还未同步,磁盘机器就宕机了,那这部分数据就算丢失了。\nAOF 文件的保存位置和 RDB 文件的位置相同,都是通过 dir 参数设置的,默认的文件名是 appendonly.aof。\nAOF 工作基本流程是怎样的? # AOF 持久化功能的实现可以简单分为 5 步:\n命令追加(append):所有的写命令会追加到 AOF 缓冲区中。 文件写入(write):将 AOF 缓冲区的数据写入到 AOF 文件中。这一步需要调用write函数(系统调用),write将数据写入到了系统内核缓冲区之后直接返回了(延迟写)。注意!!!此时并没有同步到磁盘。 文件同步(fsync):AOF 缓冲区根据对应的持久化方式( fsync 策略)向硬盘做同步操作。这一步需要调用 fsync 函数(系统调用), fsync 针对单个文件操作,对其进行强制硬盘同步,fsync 将阻塞直到写入磁盘完成后返回,保证了数据持久化。 文件重写(rewrite):随着 AOF 文件越来越大,需要定期对 AOF 文件进行重写,达到压缩的目的。 重启加载(load):当 Redis 重启时,可以加载 AOF 文件进行数据恢复。 Linux 系统直接提供了一些函数用于对文件和设备进行访问和控制,这些函数被称为 系统调用(syscall)。\n这里对上面提到的一些 Linux 系统调用再做一遍解释:\nwrite:写入系统内核缓冲区之后直接返回(仅仅是写到缓冲区),不会立即同步到硬盘。虽然提高了效率,但也带来了数据丢失的风险。同步硬盘操作通常依赖于系统调度机制,Linux 内核通常为 30s 同步一次,具体值取决于写出的数据量和 I/O 缓冲区的状态。 fsync:fsync用于强制刷新系统内核缓冲区(同步到到磁盘),确保写磁盘操作结束才会返回。 AOF 工作流程图如下:\nAOF 持久化方式有哪些? # 在 Redis 的配置文件中存在三种不同的 AOF 持久化方式( fsync策略),它们分别是:\nappendfsync always:主线程调用 write 执行写操作后,后台线程( aof_fsync 线程)立即会调用 fsync 函数同步 AOF 文件(刷盘),fsync 完成后线程返回,这样会严重降低 Redis 的性能(write + fsync)。 appendfsync everysec:主线程调用 write 执行写操作后立即返回,由后台线程( aof_fsync 线程)每秒钟调用 fsync 函数(系统调用)同步一次 AOF 文件(write+fsync,fsync间隔为 1 秒) appendfsync no:主线程调用 write 执行写操作后立即返回,让操作系统决定何时进行同步,Linux 下一般为 30 秒一次(write但不fsync,fsync 的时机由操作系统决定)。 可以看出:这 3 种持久化方式的主要区别在于 fsync 同步 AOF 文件的时机(刷盘)。\n为了兼顾数据和写入性能,可以考虑 appendfsync everysec 选项 ,让 Redis 每秒同步一次 AOF 文件,Redis 性能受到的影响较小。而且这样即使出现系统崩溃,用户最多只会丢失一秒之内产生的数据。当硬盘忙于执行写入操作的时候,Redis 还会优雅的放慢自己的速度以便适应硬盘的最大写入速度。\n从 Redis 7.0.0 开始,Redis 使用了 Multi Part AOF 机制。顾名思义,Multi Part AOF 就是将原来的单个 AOF 文件拆分成多个 AOF 文件。在 Multi Part AOF 中,AOF 文件被分为三种类型,分别为:\nBASE:表示基础 AOF 文件,它一般由子进程通过重写产生,该文件最多只有一个。 INCR:表示增量 AOF 文件,它一般会在 AOFRW 开始执行时被创建,该文件可能存在多个。 HISTORY:表示历史 AOF 文件,它由 BASE 和 INCR AOF 变化而来,每次 AOFRW 成功完成时,本次 AOFRW 之前对应的 BASE 和 INCR AOF 都将变为 HISTORY,HISTORY 类型的 AOF 会被 Redis 自动删除。 Multi Part AOF 不是重点,了解即可,详细介绍可以看看阿里开发者的 Redis 7.0 Multi Part AOF 的设计和实现 这篇文章。\n相关 issue: Redis 的 AOF 方式 #783。\nAOF 为什么是在执行完命令之后记录日志? # 关系型数据库(如 MySQL)通常都是执行命令之前记录日志(方便故障恢复),而 Redis AOF 持久化机制是在执行完命令之后再记录日志。\n为什么是在执行完命令之后记录日志呢?\n避免额外的检查开销,AOF 记录日志不会对命令进行语法检查; 在命令执行完之后再记录,不会阻塞当前的命令执行。 这样也带来了风险(我在前面介绍 AOF 持久化的时候也提到过):\n如果刚执行完命令 Redis 就宕机会导致对应的修改丢失; 可能会阻塞后续其他命令的执行(AOF 记录日志是在 Redis 主线程中进行的)。 AOF 重写了解吗? # 当 AOF 变得太大时,Redis 能够在后台自动重写 AOF 产生一个新的 AOF 文件,这个新的 AOF 文件和原有的 AOF 文件所保存的数据库状态一样,但体积更小。\nAOF 重写(rewrite) 是一个有歧义的名字,该功能是通过读取数据库中的键值对来实现的,程序无须对现有 AOF 文件进行任何读入、分析或者写入操作。\n由于 AOF 重写会进行大量的写入操作,为了避免对 Redis 正常处理命令请求造成影响,Redis 将 AOF 重写程序放到子进程里执行。\nAOF 文件重写期间,Redis 还会维护一个 AOF 重写缓冲区,该缓冲区会在子进程创建新 AOF 文件期间,记录服务器执行的所有写命令。当子进程完成创建新 AOF 文件的工作之后,服务器会将重写缓冲区中的所有内容追加到新 AOF 文件的末尾,使得新的 AOF 文件保存的数据库状态与现有的数据库状态一致。最后,服务器用新的 AOF 文件替换旧的 AOF 文件,以此来完成 AOF 文件重写操作。\n开启 AOF 重写功能,可以调用 BGREWRITEAOF 命令手动执行,也可以设置下面两个配置项,让程序自动决定触发时机:\nauto-aof-rewrite-min-size:如果 AOF 文件大小小于该值,则不会触发 AOF 重写。默认值为 64 MB; auto-aof-rewrite-percentage:执行 AOF 重写时,当前 AOF 大小(aof_current_size)和上一次重写时 AOF 大小(aof_base_size)的比值。如果当前 AOF 文件大小增加了这个百分比值,将触发 AOF 重写。将此值设置为 0 将禁用自动 AOF 重写。默认值为 100。 Redis 7.0 版本之前,如果在重写期间有写入命令,AOF 可能会使用大量内存,重写期间到达的所有写入命令都会写入磁盘两次。\nRedis 7.0 版本之后,AOF 重写机制得到了优化改进。下面这段内容摘自阿里开发者的 从 Redis7.0 发布看 Redis 的过去与未来 这篇文章。\nAOF 重写期间的增量数据如何处理一直是个问题,在过去写期间的增量数据需要在内存中保留,写结束后再把这部分增量数据写入新的 AOF 文件中以保证数据完整性。可以看出来 AOF 写会额外消耗内存和磁盘 IO,这也是 Redis AOF 写的痛点,虽然之前也进行过多次改进但是资源消耗的本质问题一直没有解决。\n阿里云的 Redis 企业版在最初也遇到了这个问题,在内部经过多次迭代开发,实现了 Multi-part AOF 机制来解决,同时也贡献给了社区并随此次 7.0 发布。具体方法是采用 base(全量数据)+inc(增量数据)独立文件存储的方式,彻底解决内存和 IO 资源的浪费,同时也支持对历史 AOF 文件的保存管理,结合 AOF 文件中的时间信息还可以实现 PITR 按时间点恢复(阿里云企业版 Tair 已支持),这进一步增强了 Redis 的数据可靠性,满足用户数据回档等需求。\n相关 issue: Redis AOF 重写描述不准确 #1439。\nAOF 校验机制了解吗? # AOF 校验机制是 Redis 在启动时对 AOF 文件进行检查,以判断文件是否完整,是否有损坏或者丢失的数据。这个机制的原理其实非常简单,就是通过使用一种叫做 校验和(checksum) 的数字来验证 AOF 文件。这个校验和是通过对整个 AOF 文件内容进行 CRC64 算法计算得出的数字。如果文件内容发生了变化,那么校验和也会随之改变。因此,Redis 在启动时会比较计算出的校验和与文件末尾保存的校验和(计算的时候会把最后一行保存校验和的内容给忽略点),从而判断 AOF 文件是否完整。如果发现文件有问题,Redis 就会拒绝启动并提供相应的错误信息。AOF 校验机制十分简单有效,可以提高 Redis 数据的可靠性。\n类似地,RDB 文件也有类似的校验机制来保证 RDB 文件的正确性,这里就不重复进行介绍了。\nRedis 4.0 对于持久化机制做了什么优化? # 由于 RDB 和 AOF 各有优势,于是,Redis 4.0 开始支持 RDB 和 AOF 的混合持久化(默认关闭,可以通过配置项 aof-use-rdb-preamble 开启)。\n如果把混合持久化打开,AOF 重写的时候就直接把 RDB 的内容写到 AOF 文件开头。这样做的好处是可以结合 RDB 和 AOF 的优点, 快速加载同时避免丢失过多的数据。当然缺点也是有的, AOF 里面的 RDB 部分是压缩格式不再是 AOF 格式,可读性较差。\n官方文档地址: https://redis.io/topics/persistence\n如何选择 RDB 和 AOF? # 关于 RDB 和 AOF 的优缺点,官网上面也给了比较详细的说明 Redis persistence,这里结合自己的理解简单总结一下。\nRDB 比 AOF 优秀的地方:\nRDB 文件存储的内容是经过压缩的二进制数据, 保存着某个时间点的数据集,文件很小,适合做数据的备份,灾难恢复。AOF 文件存储的是每一次写命令,类似于 MySQL 的 binlog 日志,通常会比 RDB 文件大很多。当 AOF 变得太大时,Redis 能够在后台自动重写 AOF。新的 AOF 文件和原有的 AOF 文件所保存的数据库状态一样,但体积更小。不过, Redis 7.0 版本之前,如果在重写期间有写入命令,AOF 可能会使用大量内存,重写期间到达的所有写入命令都会写入磁盘两次。 使用 RDB 文件恢复数据,直接解析还原数据即可,不需要一条一条地执行命令,速度非常快。而 AOF 则需要依次执行每个写命令,速度非常慢。也就是说,与 AOF 相比,恢复大数据集的时候,RDB 速度更快。 AOF 比 RDB 优秀的地方:\nRDB 的数据安全性不如 AOF,没有办法实时或者秒级持久化数据。生成 RDB 文件的过程是比较繁重的, 虽然 BGSAVE 子进程写入 RDB 文件的工作不会阻塞主线程,但会对机器的 CPU 资源和内存资源产生影响,严重的情况下甚至会直接把 Redis 服务干宕机。AOF 支持秒级数据丢失(取决 fsync 策略,如果是 everysec,最多丢失 1 秒的数据),仅仅是追加命令到 AOF 文件,操作轻量。 RDB 文件是以特定的二进制格式保存的,并且在 Redis 版本演进中有多个版本的 RDB,所以存在老版本的 Redis 服务不兼容新版本的 RDB 格式的问题。 AOF 以一种易于理解和解析的格式包含所有操作的日志。你可以轻松地导出 AOF 文件进行分析,你也可以直接操作 AOF 文件来解决一些问题。比如,如果执行FLUSHALL命令意外地刷新了所有内容后,只要 AOF 文件没有被重写,删除最新命令并重启即可恢复之前的状态。 综上:\nRedis 保存的数据丢失一些也没什么影响的话,可以选择使用 RDB。 不建议单独使用 AOF,因为时不时地创建一个 RDB 快照可以进行数据库备份、更快的重启以及解决 AOF 引擎错误。 如果保存的数据要求安全性比较高的话,建议同时开启 RDB 和 AOF 持久化或者开启 RDB 和 AOF 混合持久化。 参考 # 《Redis 设计与实现》 Redis persistence - Redis 官方文档: https://redis.io/docs/management/persistence/ The difference between AOF and RDB persistence: https://www.sobyte.net/post/2022-04/redis-rdb-and-aof/ Redis AOF 持久化详解 - 程序员历小冰: http://remcarpediem.net/article/376c55d8/ Redis RDB 与 AOF 持久化 · Analyze: https://wingsxdu.com/posts/database/redis/rdb-and-aof/ "},{"id":562,"href":"/zh/docs/technology/Interview/database/redis/redis-cluster/","title":"Redis集群详解(付费)","section":"Redis","content":"Redis 集群 相关的面试题为我的 知识星球(点击链接即可查看详细介绍以及加入方法)专属内容,已经整理到了 《Java 面试指北》中。\n"},{"id":563,"href":"/zh/docs/technology/Interview/database/redis/redis-memory-fragmentation/","title":"Redis内存碎片详解","section":"Redis","content":" 什么是内存碎片? # 你可以将内存碎片简单地理解为那些不可用的空闲内存。\n举个例子:操作系统为你分配了 32 字节的连续内存空间,而你存储数据实际只需要使用 24 字节内存空间,那这多余出来的 8 字节内存空间如果后续没办法再被分配存储其他数据的话,就可以被称为内存碎片。\nRedis 内存碎片虽然不会影响 Redis 性能,但是会增加内存消耗。\n为什么会有 Redis 内存碎片? # Redis 内存碎片产生比较常见的 2 个原因:\n1、Redis 存储数据的时候向操作系统申请的内存空间可能会大于数据实际需要的存储空间。\n以下是这段 Redis 官方的原话:\nTo store user keys, Redis allocates at most as much memory as the maxmemory setting enables (however there are small extra allocations possible).\nRedis 使用 zmalloc 方法(Redis 自己实现的内存分配方法)进行内存分配的时候,除了要分配 size 大小的内存之外,还会多分配 PREFIX_SIZE 大小的内存。\nzmalloc 方法源码如下(源码地址: https://github.com/antirez/redis-tools/blob/master/zmalloc.c):\nvoid *zmalloc(size_t size) { // 分配指定大小的内存 void *ptr = malloc(size+PREFIX_SIZE); if (!ptr) zmalloc_oom_handler(size); #ifdef HAVE_MALLOC_SIZE update_zmalloc_stat_alloc(zmalloc_size(ptr)); return ptr; #else *((size_t*)ptr) = size; update_zmalloc_stat_alloc(size+PREFIX_SIZE); return (char*)ptr+PREFIX_SIZE; #endif } 另外,Redis 可以使用多种内存分配器来分配内存( libc、jemalloc、tcmalloc),默认使用 jemalloc,而 jemalloc 按照一系列固定的大小(8 字节、16 字节、32 字节……)来分配内存的。jemalloc 划分的内存单元如下图所示:\n当程序申请的内存最接近某个固定值时,jemalloc 会给它分配相应大小的空间,就比如说程序需要申请 17 字节的内存,jemalloc 会直接给它分配 32 字节的内存,这样会导致有 15 字节内存的浪费。不过,jemalloc 专门针对内存碎片问题做了优化,一般不会存在过度碎片化的问题。\n2、频繁修改 Redis 中的数据也会产生内存碎片。\n当 Redis 中的某个数据删除时,Redis 通常不会轻易释放内存给操作系统。\n这个在 Redis 官方文档中也有对应的原话:\n文档地址: https://redis.io/topics/memory-optimization 。\n如何查看 Redis 内存碎片的信息? # 使用 info memory 命令即可查看 Redis 内存相关的信息。下图中每个参数具体的含义,Redis 官方文档有详细的介绍: https://redis.io/commands/INFO 。\nRedis 内存碎片率的计算公式:mem_fragmentation_ratio (内存碎片率)= used_memory_rss (操作系统实际分配给 Redis 的物理内存空间大小)/ used_memory(Redis 内存分配器为了存储数据实际申请使用的内存空间大小)\n也就是说,mem_fragmentation_ratio (内存碎片率)的值越大代表内存碎片率越严重。\n一定不要误认为used_memory_rss 减去 used_memory值就是内存碎片的大小!!!这不仅包括内存碎片,还包括其他进程开销,以及共享库、堆栈等的开销。\n很多小伙伴可能要问了:“多大的内存碎片率才是需要清理呢?”。\n通常情况下,我们认为 mem_fragmentation_ratio \u0026gt; 1.5 的话才需要清理内存碎片。 mem_fragmentation_ratio \u0026gt; 1.5 意味着你使用 Redis 存储实际大小 2G 的数据需要使用大于 3G 的内存。\n如果想要快速查看内存碎片率的话,你还可以通过下面这个命令:\n\u0026gt; redis-cli -p 6379 info | grep mem_fragmentation_ratio 另外,内存碎片率可能存在小于 1 的情况。这种情况我在日常使用中还没有遇到过,感兴趣的小伙伴可以看看这篇文章 故障分析 | Redis 内存碎片率太低该怎么办?- 爱可生开源社区 。\n如何清理 Redis 内存碎片? # Redis4.0-RC3 版本以后自带了内存整理,可以避免内存碎片率过大的问题。\n直接通过 config set 命令将 activedefrag 配置项设置为 yes 即可。\nconfig set activedefrag yes 具体什么时候清理需要通过下面两个参数控制:\n# 内存碎片占用空间达到 500mb 的时候开始清理 config set active-defrag-ignore-bytes 500mb # 内存碎片率大于 1.5 的时候开始清理 config set active-defrag-threshold-lower 50 通过 Redis 自动内存碎片清理机制可能会对 Redis 的性能产生影响,我们可以通过下面两个参数来减少对 Redis 性能的影响:\n# 内存碎片清理所占用 CPU 时间的比例不低于 20% config set active-defrag-cycle-min 20 # 内存碎片清理所占用 CPU 时间的比例不高于 50% config set active-defrag-cycle-max 50 另外,重启节点可以做到内存碎片重新整理。如果你采用的是高可用架构的 Redis 集群的话,你可以将碎片率过高的主节点转换为从节点,以便进行安全重启。\n参考 # Redis 官方文档: https://redis.io/topics/memory-optimization Redis 核心技术与实战 - 极客时间 - 删除数据后,为什么内存占用率还是很高?: https://time.geekbang.org/column/article/289140 Redis 源码解析——内存分配:\u0026laquo; https://shinerio.cc/2020/05/17/redis/Redis\u003e 源码解析——内存管理\u0026gt; "},{"id":564,"href":"/zh/docs/technology/Interview/database/redis/redis-skiplist/","title":"Redis为什么用跳表实现有序集合","section":"Redis","content":" 前言 # 近几年针对 Redis 面试时会涉及常见数据结构的底层设计,其中就有这么一道比较有意思的面试题:“Redis 的有序集合底层为什么要用跳表,而不用平衡树、红黑树或者 B+树?”。\n本文就以这道大厂常问的面试题为切入点,带大家详细了解一下跳表这个数据结构。\n本文整体脉络如下图所示,笔者会从有序集合的基本使用到跳表的源码分析和实现,让你会对 Redis 的有序集合底层实现的跳表有着更深刻的理解和掌握。\n跳表在 Redis 中的运用 # 这里我们需要先了解一下 Redis 用到跳表的数据结构有序集合的使用,Redis 有个比较常用的数据结构叫有序集合(sorted set,简称 zset),正如其名它是一个可以保证有序且元素唯一的集合,所以它经常用于排行榜等需要进行统计排列的场景。\n这里我们通过命令行的形式演示一下排行榜的实现,可以看到笔者分别输入 3 名用户:xiaoming、xiaohong、xiaowang,它们的score分别是 60、80、60,最终按照成绩升级降序排列。\n127.0.0.1:6379\u0026gt; zadd rankList 60 xiaoming (integer) 1 127.0.0.1:6379\u0026gt; zadd rankList 80 xiaohong (integer) 1 127.0.0.1:6379\u0026gt; zadd rankList 60 xiaowang (integer) 1 # 返回有序集中指定区间内的成员,通过索引,分数从高到低 127.0.0.1:6379\u0026gt; ZREVRANGE rankList 0 100 WITHSCORES 1) \u0026#34;xiaohong\u0026#34; 2) \u0026#34;80\u0026#34; 3) \u0026#34;xiaowang\u0026#34; 4) \u0026#34;60\u0026#34; 5) \u0026#34;xiaoming\u0026#34; 6) \u0026#34;60\u0026#34; 此时我们通过 object 指令查看 zset 的数据结构,可以看到当前有序集合存储的还是ziplist(压缩列表)。\n127.0.0.1:6379\u0026gt; object encoding rankList \u0026#34;ziplist\u0026#34; 因为设计者考虑到 Redis 数据存放于内存,为了节约宝贵的内存空间,在有序集合元素小于 64 字节且个数小于 128 的时候,会使用 ziplist,而这个阈值的默认值的设置就来自下面这两个配置项。\nzset-max-ziplist-value 64 zset-max-ziplist-entries 128 一旦有序集合中的某个元素超出这两个其中的一个阈值它就会转为 skiplist(实际是 dict+skiplist,还会借用字典来提高获取指定元素的效率)。\n我们不妨在添加一个大于 64 字节的元素,可以看到有序集合的底层存储转为 skiplist。\n127.0.0.1:6379\u0026gt; zadd rankList 90 yigemingzihuichaoguo64zijiedeyonghumingchengyongyuceshitiaobiaodeshijiyunyong (integer) 1 # 超过阈值,转为跳表 127.0.0.1:6379\u0026gt; object encoding rankList \u0026#34;skiplist\u0026#34; 也就是说,ZSet 有两种不同的实现,分别是 ziplist 和 skiplist,具体使用哪种结构进行存储的规则如下:\n当有序集合对象同时满足以下两个条件时,使用 ziplist: ZSet 保存的键值对数量少于 128 个; 每个元素的长度小于 64 字节。 如果不满足上述两个条件,那么使用 skiplist 。 手写一个跳表 # 为了更好的回答上述问题以及更好的理解和掌握跳表,这里可以通过手写一个简单的跳表的形式来帮助读者理解跳表这个数据结构。\n我们都知道有序链表在添加、查询、删除的平均时间复杂都都是 O(n) 即线性增长,所以一旦节点数量达到一定体量后其性能表现就会非常差劲。而跳表我们完全可以理解为在原始链表基础上,建立多级索引,通过多级索引检索定位将增删改查的时间复杂度变为 O(log n) 。\n可能这里说的有些抽象,我们举个例子,以下图跳表为例,其原始链表存储按序存储 1-10,有 2 级索引,每级索引的索引个数都是基于下层元素个数的一半。\n假如我们需要查询元素 6,其工作流程如下:\n从 2 级索引开始,先来到节点 4。 查看 4 的后继节点,是 8 的 2 级索引,这个值大于 6,说明 2 级索引后续的索引都是大于 6 的,没有再往后搜寻的必要,我们索引向下查找。 来到 4 的 1 级索引,比对其后继节点为 6,查找结束。 相较于原始有序链表需要 6 次,我们的跳表通过建立多级索引,我们只需两次就直接定位到了目标元素,其查寻的复杂度被直接优化为O(log n)。\n对应的添加也是一个道理,假如我们需要在这个有序集合中添加一个元素 7,那么我们就需要通过跳表找到小于元素 7 的最大值,也就是下图元素 6 的位置,将其插入到元素 6 的后面,让元素 6 的索引指向新插入的节点 7,其工作流程如下:\n从 2 级索引开始定位到了元素 4 的索引。 查看索引 4 的后继索引为 8,索引向下推进。 来到 1 级索引,发现索引 4 后继索引为 6,小于插入元素 7,指针推进到索引 6 位置。 继续比较 6 的后继节点为索引 8,大于元素 7,索引继续向下。 最终我们来到 6 的原始节点,发现其后继节点为 7,指针没有继续向下的空间,自此我们可知元素 6 就是小于插入元素 7 的最大值,于是便将元素 7 插入。 这里我们又面临一个问题,我们是否需要为元素 7 建立索引,索引多高合适?\n我们上文提到,理想情况是每一层索引是下一层元素个数的二分之一,假设我们的总共有 16 个元素,对应各级索引元素个数应该是:\n1. 一级索引:16/2=8 2. 二级索引:8/2 =4 3. 三级索引:4/2=2 由此我们用数学归纳法可知:\n1. 一级索引:16/2=16/2^1=8 2. 二级索引:8/2 =\u0026gt; 16/2^2 =4 3. 三级索引:4/2=\u0026gt;16/2^3=2 假设元素个数为 n,那么对应 k 层索引的元素个数 r 计算公式为:\nr=n/2^k 同理我们再来推断以下索引的最大高度,一般来说最高级索引的元素个数为 2,我们设元素总个数为 n,索引高度为 h,代入上述公式可得:\n2= n/2^h =\u0026gt; 2*2^h=n =\u0026gt; 2^(h+1)=n =\u0026gt; h+1=log2^n =\u0026gt; h=log2^n -1 而 Redis 又是内存数据库,我们假设元素最大个数是65536,我们把65536代入上述公式可知最大高度为 16。所以我们建议添加一个元素后为其建立的索引高度不超过 16。\n因为我们要求尽可能保证每一个上级索引都是下级索引的一半,在实现高度生成算法时,我们可以这样设计:\n跳表的高度计算从原始链表开始,即默认情况下插入的元素的高度为 1,代表没有索引,只有元素节点。 设计一个为插入元素生成节点索引高度 level 的方法。 进行一次随机运算,随机数值范围为 0-1 之间。 如果随机数大于 0.5 则为当前元素添加一级索引,自此我们保证生成一级索引的概率为 50% ,这也就保证了 1 级索引理想情况下只有一半的元素会生成索引。 同理后续每次随机算法得到的值大于 0.5 时,我们的索引高度就加 1,这样就可以保证节点生成的 2 级索引概率为 25% ,3 级索引为 12.5% …… 我们回过头,上述插入 7 之后,我们通过随机算法得到 2,即要为其建立 1 级索引:\n最后我们再来说说删除,假设我们这里要删除元素 10,我们必须定位到当前跳表各层元素小于 10 的最大值,索引执行步骤为:\n2 级索引 4 的后继节点为 8,指针推进。 索引 8 无后继节点,该层无要删除的元素,指针直接向下。 1 级索引 8 后继节点为 10,说明 1 级索引 8 在进行删除时需要将自己的指针和 1 级索引 10 断开联系,将 10 删除。 1 级索引完成定位后,指针向下,后继节点为 9,指针推进。 9 的后继节点为 10,同理需要让其指向 null,将 10 删除。 模板定义 # 有了整体的思路之后,我们可以开始实现一个跳表了,首先定义一下跳表中的节点Node,从上文的演示中可以看出每一个Node它都包含以下几个元素:\n存储的value值。 后继节点的地址。 多级索引。 为了更方便统一管理Node后继节点地址和多级索引指向的元素地址,笔者在Node中设置了一个forwards数组,用于记录原始链表节点的后继节点和多级索引的后继节点指向。\n以下图为例,我们forwards数组长度为 5,其中索引 0记录的是原始链表节点的后继节点地址,而其余自底向上表示从 1 级索引到 4 级索引的后继节点指向。\n于是我们的就有了这样一个代码定义,可以看出笔者对于数组的长度设置为固定的 16==(上文的推算最大高度建议是 16),默认data为-1,节点最大高度maxLevel初始化为 1,注意这个maxLevel==的值代表原始链表加上索引的总高度。\n/** * 跳表索引最大高度为16 */ private static final int MAX_LEVEL = 16; class Node { private int data = -1; private Node[] forwards = new Node[MAX_LEVEL]; private int maxLevel = 0; } 元素添加 # 定义好节点之后,我们先实现以下元素的添加,添加元素时首先自然是设置data这一步我们直接根据将传入的value设置到data上即可。\n然后就是高度maxLevel的设置 ,我们在上文也已经给出了思路,默认高度为 1,即只有一个原始链表节点,通过随机算法每次大于 0.5 索引高度加 1,由此我们得出高度计算的算法randomLevel():\n/** * 理论来讲,一级索引中元素个数应该占原始数据的 50%,二级索引中元素个数占 25%,三级索引12.5% ,一直到最顶层。 * 因为这里每一层的晋升概率是 50%。对于每一个新插入的节点,都需要调用 randomLevel 生成一个合理的层数。 * 该 randomLevel 方法会随机生成 1~MAX_LEVEL 之间的数,且 : * 50%的概率返回 1 * 25%的概率返回 2 * 12.5%的概率返回 3 ... * @return */ private int randomLevel() { int level = 1; while (Math.random() \u0026gt; PROB \u0026amp;\u0026amp; level \u0026lt; MAX_LEVEL) { ++level; } return level; } 然后再设置当前要插入的Node和Node索引的后继节点地址,这一步稍微复杂一点,我们假设当前节点的高度为 4,即 1 个节点加 3 个索引,所以我们创建一个长度为 4 的数组maxOfMinArr ,遍历各级索引节点中小于当前value的最大值。\n假设我们要插入的value为 5,我们的数组查找结果当前节点的前驱节点和 1 级索引、2 级索引的前驱节点都为 4,三级索引为空。\n然后我们基于这个数组maxOfMinArr 定位到各级的后继节点,让插入的元素 5 指向这些后继节点,而maxOfMinArr指向 5,结果如下图:\n转化成代码就是下面这个形式,是不是很简单呢?我们继续:\n/** * 默认情况下的高度为1,即只有自己一个节点 */ private int levelCount = 1; /** * 跳表最底层的节点,即头节点 */ private Node h = new Node(); public void add(int value) { //随机生成高度 int level = randomLevel(); Node newNode = new Node(); newNode.data = value; newNode.maxLevel = level; //创建一个node数组,用于记录小于当前value的最大值 Node[] maxOfMinArr = new Node[level]; //默认情况下指向头节点 for (int i = 0; i \u0026lt; level; i++) { maxOfMinArr[i] = h; } //基于上述结果拿到当前节点的后继节点 Node p = h; for (int i = level - 1; i \u0026gt;= 0; i--) { while (p.forwards[i] != null \u0026amp;\u0026amp; p.forwards[i].data \u0026lt; value) { p = p.forwards[i]; } maxOfMinArr[i] = p; } //更新前驱节点的后继节点为当前节点newNode for (int i = 0; i \u0026lt; level; i++) { newNode.forwards[i] = maxOfMinArr[i].forwards[i]; maxOfMinArr[i].forwards[i] = newNode; } //如果当前newNode高度大于跳表最高高度则更新levelCount if (levelCount \u0026lt; level) { levelCount = level; } } 元素查询 # 查询逻辑比较简单,从跳表最高级的索引开始定位找到小于要查的 value 的最大值,以下图为例,我们希望查找到节点 8:\n跳表的 3 级索引首先找找到 5 的索引,5 的 3 级索引 forwards[3] 指向空,索引直接向下。 来到 5 的 2 级索引,其后继 forwards[2] 指向 8,继续向下。 5 的 1 级索引 forwards[1] 指向索引 6,继续向前。 索引 6 的 forwards[1] 指向索引 8,继续向下。 我们在原始节点向前找到节点 7。 节点 7 后续就是节点 8,继续向前为节点 8,无法继续向下,结束搜寻。 判断 7 的前驱,等于 8,查找结束。 所以我们的代码实现也很上述步骤差不多,从最高级索引开始向前查找,如果不为空且小于要查找的值,则继续向前搜寻,遇到不小于的节点则继续向下,如此往复,直到得到当前跳表中小于查找值的最大节点,查看其前驱是否等于要查找的值:\npublic Node get(int value) { Node p = h; //找到小于value的最大值 for (int i = levelCount - 1; i \u0026gt;= 0; i--) { while (p.forwards[i] != null \u0026amp;\u0026amp; p.forwards[i].data \u0026lt; value) { p = p.forwards[i]; } } //如果p的前驱节点等于value则直接返回 if (p.forwards[0] != null \u0026amp;\u0026amp; p.forwards[0].data == value) { return p.forwards[0]; } return null; } 元素删除 # 最后是删除逻辑,需要查找各层级小于要删除节点的最大值,假设我们要删除 10:\n3 级索引得到小于 10 的最大值为 5,继续向下。 2 级索引从索引 5 开始查找,发现小于 10 的最大值为 8,继续向下。 同理 1 级索引得到 8,继续向下。 原始节点找到 9。 从最高级索引开始,查看每个小于 10 的节点后继节点是否为 10,如果等于 10,则让这个节点指向 10 的后继节点,将节点 10 及其索引交由 GC 回收。 /** * 删除 * * @param value */ public void delete(int value) { Node p = h; //找到各级节点小于value的最大值 Node[] updateArr = new Node[levelCount]; for (int i = levelCount - 1; i \u0026gt;= 0; i--) { while (p.forwards[i] != null \u0026amp;\u0026amp; p.forwards[i].data \u0026lt; value) { p = p.forwards[i]; } updateArr[i] = p; } //查看原始层节点前驱是否等于value,若等于则说明存在要删除的值 if (p.forwards[0] != null \u0026amp;\u0026amp; p.forwards[0].data == value) { //从最高级索引开始查看其前驱是否等于value,若等于则将当前节点指向value节点的后继节点 for (int i = levelCount - 1; i \u0026gt;= 0; i--) { if (updateArr[i].forwards[i] != null \u0026amp;\u0026amp; updateArr[i].forwards[i].data == value) { updateArr[i].forwards[i] = updateArr[i].forwards[i].forwards[i]; } } } //从最高级开始查看是否有一级索引为空,若为空则层级减1 while (levelCount \u0026gt; 1 \u0026amp;\u0026amp; h.forwards[levelCount - 1] == null) { levelCount--; } } 完整代码以及测试 # 完整代码如下,读者可自行参阅:\npublic class SkipList { /** * 跳表索引最大高度为16 */ private static final int MAX_LEVEL = 16; /** * 每个节点添加一层索引高度的概率为二分之一 */ private static final float PROB = 0.5 f; /** * 默认情况下的高度为1,即只有自己一个节点 */ private int levelCount = 1; /** * 跳表最底层的节点,即头节点 */ private Node h = new Node(); public SkipList() {} public class Node { private int data = -1; /** * */ private Node[] forwards = new Node[MAX_LEVEL]; private int maxLevel = 0; @Override public String toString() { return \u0026#34;Node{\u0026#34; + \u0026#34;data=\u0026#34; + data + \u0026#34;, maxLevel=\u0026#34; + maxLevel + \u0026#39;}\u0026#39;; } } public void add(int value) { //随机生成高度 int level = randomLevel(); Node newNode = new Node(); newNode.data = value; newNode.maxLevel = level; //创建一个node数组,用于记录小于当前value的最大值 Node[] maxOfMinArr = new Node[level]; //默认情况下指向头节点 for (int i = 0; i \u0026lt; level; i++) { maxOfMinArr[i] = h; } //基于上述结果拿到当前节点的后继节点 Node p = h; for (int i = level - 1; i \u0026gt;= 0; i--) { while (p.forwards[i] != null \u0026amp;\u0026amp; p.forwards[i].data \u0026lt; value) { p = p.forwards[i]; } maxOfMinArr[i] = p; } //更新前驱节点的后继节点为当前节点newNode for (int i = 0; i \u0026lt; level; i++) { newNode.forwards[i] = maxOfMinArr[i].forwards[i]; maxOfMinArr[i].forwards[i] = newNode; } //如果当前newNode高度大于跳表最高高度则更新levelCount if (levelCount \u0026lt; level) { levelCount = level; } } /** * 理论来讲,一级索引中元素个数应该占原始数据的 50%,二级索引中元素个数占 25%,三级索引12.5% ,一直到最顶层。 * 因为这里每一层的晋升概率是 50%。对于每一个新插入的节点,都需要调用 randomLevel 生成一个合理的层数。 * 该 randomLevel 方法会随机生成 1~MAX_LEVEL 之间的数,且 : * 50%的概率返回 1 * 25%的概率返回 2 * 12.5%的概率返回 3 ... * @return */ private int randomLevel() { int level = 1; while (Math.random() \u0026gt; PROB \u0026amp;\u0026amp; level \u0026lt; MAX_LEVEL) { ++level; } return level; } public Node get(int value) { Node p = h; //找到小于value的最大值 for (int i = levelCount - 1; i \u0026gt;= 0; i--) { while (p.forwards[i] != null \u0026amp;\u0026amp; p.forwards[i].data \u0026lt; value) { p = p.forwards[i]; } } //如果p的前驱节点等于value则直接返回 if (p.forwards[0] != null \u0026amp;\u0026amp; p.forwards[0].data == value) { return p.forwards[0]; } return null; } /** * 删除 * * @param value */ public void delete(int value) { Node p = h; //找到各级节点小于value的最大值 Node[] updateArr = new Node[levelCount]; for (int i = levelCount - 1; i \u0026gt;= 0; i--) { while (p.forwards[i] != null \u0026amp;\u0026amp; p.forwards[i].data \u0026lt; value) { p = p.forwards[i]; } updateArr[i] = p; } //查看原始层节点前驱是否等于value,若等于则说明存在要删除的值 if (p.forwards[0] != null \u0026amp;\u0026amp; p.forwards[0].data == value) { //从最高级索引开始查看其前驱是否等于value,若等于则将当前节点指向value节点的后继节点 for (int i = levelCount - 1; i \u0026gt;= 0; i--) { if (updateArr[i].forwards[i] != null \u0026amp;\u0026amp; updateArr[i].forwards[i].data == value) { updateArr[i].forwards[i] = updateArr[i].forwards[i].forwards[i]; } } } //从最高级开始查看是否有一级索引为空,若为空则层级减1 while (levelCount \u0026gt; 1 \u0026amp;\u0026amp; h.forwards[levelCount - 1] == null) { levelCount--; } } public void printAll() { Node p = h; //基于最底层的非索引层进行遍历,只要后继节点不为空,则速速出当前节点,并移动到后继节点 while (p.forwards[0] != null) { System.out.println(p.forwards[0]); p = p.forwards[0]; } } } 对应测试代码和输出结果如下:\npublic static void main(String[] args) { SkipList skipList = new SkipList(); for (int i = 0; i \u0026lt; 24; i++) { skipList.add(i); } System.out.println(\u0026#34;==========输出添加结果==========\u0026#34;); skipList.printAll(); SkipList.Node node = skipList.get(22); System.out.println(\u0026#34;==========查询结果:\u0026#34; + node+\u0026#34; ==========\u0026#34;); skipList.delete(22); System.out.println(\u0026#34;==========删除结果==========\u0026#34;); skipList.printAll(); } 输出结果:\n==========输出添加结果========== Node{data=0, maxLevel=2} Node{data=1, maxLevel=3} Node{data=2, maxLevel=1} Node{data=3, maxLevel=1} Node{data=4, maxLevel=2} Node{data=5, maxLevel=2} Node{data=6, maxLevel=2} Node{data=7, maxLevel=2} Node{data=8, maxLevel=4} Node{data=9, maxLevel=1} Node{data=10, maxLevel=1} Node{data=11, maxLevel=1} Node{data=12, maxLevel=1} Node{data=13, maxLevel=1} Node{data=14, maxLevel=1} Node{data=15, maxLevel=3} Node{data=16, maxLevel=4} Node{data=17, maxLevel=2} Node{data=18, maxLevel=1} Node{data=19, maxLevel=1} Node{data=20, maxLevel=1} Node{data=21, maxLevel=3} Node{data=22, maxLevel=1} Node{data=23, maxLevel=1} ==========查询结果:Node{data=22, maxLevel=1} ========== ==========删除结果========== Node{data=0, maxLevel=2} Node{data=1, maxLevel=3} Node{data=2, maxLevel=1} Node{data=3, maxLevel=1} Node{data=4, maxLevel=2} Node{data=5, maxLevel=2} Node{data=6, maxLevel=2} Node{data=7, maxLevel=2} Node{data=8, maxLevel=4} Node{data=9, maxLevel=1} Node{data=10, maxLevel=1} Node{data=11, maxLevel=1} Node{data=12, maxLevel=1} Node{data=13, maxLevel=1} Node{data=14, maxLevel=1} Node{data=15, maxLevel=3} Node{data=16, maxLevel=4} Node{data=17, maxLevel=2} Node{data=18, maxLevel=1} Node{data=19, maxLevel=1} Node{data=20, maxLevel=1} Node{data=21, maxLevel=3} Node{data=23, maxLevel=1} Redis 跳表的特点:\n采用双向链表,不同于上面的示例,存在一个回退指针。主要用于简化操作,例如删除某个元素时,还需要找到该元素的前驱节点,使用回退指针会非常方便。 score 值可以重复,如果 score 值一样,则按照 ele(节点存储的值,为 sds)字典排序 Redis 跳跃表默认允许最大的层数是 32,被源码中 ZSKIPLIST_MAXLEVEL 定义。 和其余三种数据结构的比较 # 最后,我们再来回答一下文章开头的那道面试题: “Redis 的有序集合底层为什么要用跳表,而不用平衡树、红黑树或者 B+树?”。\n平衡树 vs 跳表 # 先来说说它和平衡树的比较,平衡树我们又会称之为 AVL 树,是一个严格的平衡二叉树,平衡条件必须满足(所有节点的左右子树高度差不超过 1,即平衡因子为范围为 [-1,1])。平衡树的插入、删除和查询的时间复杂度和跳表一样都是 O(log n) 。\n对于范围查询来说,它也可以通过中序遍历的方式达到和跳表一样的效果。但是它的每一次插入或者删除操作都需要保证整颗树左右节点的绝对平衡,只要不平衡就要通过旋转操作来保持平衡,这个过程是比较耗时的。\n跳表诞生的初衷就是为了克服平衡树的一些缺点,跳表的发明者在论文 《Skip lists: a probabilistic alternative to balanced trees》中有详细提到:\nSkip lists are a data structure that can be used in place of balanced trees. Skip lists use probabilistic balancing rather than strictly enforced balancing and as a result the algorithms for insertion and deletion in skip lists are much simpler and significantly faster than equivalent algorithms for balanced trees.\n跳表是一种可以用来代替平衡树的数据结构。跳表使用概率平衡而不是严格强制的平衡,因此,跳表中的插入和删除算法比平衡树的等效算法简单得多,速度也快得多。\n笔者这里也贴出了 AVL 树插入操作的核心代码,可以看出每一次添加操作都需要进行一次递归定位插入位置,然后还需要根据回溯到根节点检查沿途的各层节点是否失衡,再通过旋转节点的方式进行调整。\n// 向二分搜索树中添加新的元素(key, value) public void add(K key, V value) { root = add(root, key, value); } // 向以node为根的二分搜索树中插入元素(key, value),递归算法 // 返回插入新节点后二分搜索树的根 private Node add(Node node, K key, V value) { if (node == null) { size++; return new Node(key, value); } if (key.compareTo(node.key) \u0026lt; 0) node.left = add(node.left, key, value); else if (key.compareTo(node.key) \u0026gt; 0) node.right = add(node.right, key, value); else // key.compareTo(node.key) == 0 node.value = value; node.height = 1 + Math.max(getHeight(node.left), getHeight(node.right)); int balanceFactor = getBalanceFactor(node); // LL型需要右旋 if (balanceFactor \u0026gt; 1 \u0026amp;\u0026amp; getBalanceFactor(node.left) \u0026gt;= 0) { return rightRotate(node); } //RR型失衡需要左旋 if (balanceFactor \u0026lt; -1 \u0026amp;\u0026amp; getBalanceFactor(node.right) \u0026lt;= 0) { return leftRotate(node); } //LR需要先左旋成LL型,然后再右旋 if (balanceFactor \u0026gt; 1 \u0026amp;\u0026amp; getBalanceFactor(node.left) \u0026lt; 0) { node.left = leftRotate(node.left); return rightRotate(node); } //RL if (balanceFactor \u0026lt; -1 \u0026amp;\u0026amp; getBalanceFactor(node.right) \u0026gt; 0) { node.right = rightRotate(node.right); return leftRotate(node); } return node; } 红黑树 vs 跳表 # 红黑树(Red Black Tree)也是一种自平衡二叉查找树,它的查询性能略微逊色于 AVL 树,但插入和删除效率更高。红黑树的插入、删除和查询的时间复杂度和跳表一样都是 O(log n) 。\n红黑树是一个黑平衡树,即从任意节点到另外一个叶子叶子节点,它所经过的黑节点是一样的。当对它进行插入操作时,需要通过旋转和染色(红黑变换)来保证黑平衡。不过,相较于 AVL 树为了维持平衡的开销要小一些。关于红黑树的详细介绍,可以查看这篇文章: 红黑树。\n相比较于红黑树来说,跳表的实现也更简单一些。并且,按照区间来查找数据这个操作,红黑树的效率没有跳表高。\n对应红黑树添加的核心代码如下,读者可自行参阅理解:\nprivate Node \u0026lt; K, V \u0026gt; add(Node \u0026lt; K, V \u0026gt; node, K key, V val) { if (node == null) { size++; return new Node(key, val); } if (key.compareTo(node.key) \u0026lt; 0) { node.left = add(node.left, key, val); } else if (key.compareTo(node.key) \u0026gt; 0) { node.right = add(node.right, key, val); } else { node.val = val; } //左节点不为红,右节点为红,左旋 if (isRed(node.right) \u0026amp;\u0026amp; !isRed(node.left)) { node = leftRotate(node); } //左链右旋 if (isRed(node.left) \u0026amp;\u0026amp; isRed(node.left.left)) { node = rightRotate(node); } //颜色翻转 if (isRed(node.left) \u0026amp;\u0026amp; isRed(node.right)) { flipColors(node); } return node; } B+树 vs 跳表 # 想必使用 MySQL 的读者都知道 B+树这个数据结构,B+树是一种常用的数据结构,具有以下特点:\n多叉树结构:它是一棵多叉树,每个节点可以包含多个子节点,减小了树的高度,查询效率高。 存储效率高:其中非叶子节点存储多个 key,叶子节点存储 value,使得每个节点更够存储更多的键,根据索引进行范围查询时查询效率更高。- 平衡性:它是绝对的平衡,即树的各个分支高度相差不大,确保查询和插入时间复杂度为 O(log n) 。 顺序访问:叶子节点间通过链表指针相连,范围查询表现出色。 数据均匀分布:B+树插入时可能会导致数据重新分布,使得数据在整棵树分布更加均匀,保证范围查询和删除效率。 所以,B+树更适合作为数据库和文件系统中常用的索引结构之一,它的核心思想是通过可能少的 IO 定位到尽可能多的索引来获得查询数据。对于 Redis 这种内存数据库来说,它对这些并不感冒,因为 Redis 作为内存数据库它不可能存储大量的数据,所以对于索引不需要通过 B+树这种方式进行维护,只需按照概率进行随机维护即可,节约内存。而且使用跳表实现 zset 时相较前者来说更简单一些,在进行插入时只需通过索引将数据插入到链表中合适的位置再随机维护一定高度的索引即可,也不需要像 B+树那样插入时发现失衡时还需要对节点分裂与合并。\nRedis 作者给出的理由 # 当然我们也可以通过 Redis 的作者自己给出的理由:\nThere are a few reasons: 1、They are not very memory intensive. It\u0026rsquo;s up to you basically. Changing parameters about the probability of a node to have a given number of levels will make then less memory intensive than btrees. 2、A sorted set is often target of many ZRANGE or ZREVRANGE operations, that is, traversing the skip list as a linked list. With this operation the cache locality of skip lists is at least as good as with other kind of balanced trees. 3、They are simpler to implement, debug, and so forth. For instance thanks to the skip list simplicity I received a patch (already in Redis master) with augmented skip lists implementing ZRANK in O(log(N)). It required little changes to the code.\n翻译过来的意思就是:\n有几个原因:\n1、它们不是很占用内存。这主要取决于你。改变节点拥有给定层数的概率的参数,会使它们比 B 树更节省内存。\n2、有序集合经常是许多 ZRANGE 或 ZREVRANGE 操作的目标,也就是说,以链表的方式遍历跳表。通过这种操作,跳表的缓存局部性至少和其他类型的平衡树一样好。\n3、它们更容易实现、调试等等。例如,由于跳表的简单性,我收到了一个补丁(已经在 Redis 主分支中),用增强的跳表实现了 O(log(N))的 ZRANK。它只需要对代码做很少的修改。\n小结 # 本文通过大量篇幅介绍跳表的工作原理和实现,帮助读者更进一步的熟悉跳表这一数据结构的优劣,最后再结合各个数据结构操作的特点进行比对,从而帮助读者更好的理解这道面试题,建议读者实现理解跳表时,尽可能配合执笔模拟来了解跳表的增删改查详细过程。\n参考 # 为啥 redis 使用跳表(skiplist)而不是使用 red-black?: https://www.zhihu.com/question/20202931/answer/16086538 Skip List\u0026ndash;跳表(全网最详细的跳表文章没有之一): https://www.jianshu.com/p/9d8296562806 Redis 对象与底层数据结构详解: https://blog.csdn.net/shark_chili3007/article/details/104171986 Redis 有序集合(sorted set): https://www.runoob.com/redis/redis-sorted-sets.html 红黑树和跳表比较: https://zhuanlan.zhihu.com/p/576984787 为什么 redis 的 zset 用跳跃表而不用 b+ tree?: https://blog.csdn.net/f80407515/article/details/129136998 "},{"id":565,"href":"/zh/docs/technology/Interview/system-design/basis/RESTfulAPI/","title":"RestFul API 简明教程","section":"Basis","content":"\n这篇文章简单聊聊后端程序员必备的 RESTful API 相关的知识。\n开始正式介绍 RESTful API 之前,我们需要首先搞清:API 到底是什么?\n何为 API? # API(Application Programming Interface) 翻译过来是应用程序编程接口的意思。\n我们在进行后端开发的时候,主要的工作就是为前端或者其他后端服务提供 API 比如查询用户数据的 API 。\n但是, API 不仅仅代表后端系统暴露的接口,像框架中提供的方法也属于 API 的范畴。\n为了方便大家理解,我再列举几个例子 🌰:\n你通过某电商网站搜索某某商品,电商网站的前端就调用了后端提供了搜索商品相关的 API。 你使用 JDK 开发 Java 程序,想要读取用户的输入的话,你就需要使用 JDK 提供的 IO 相关的 API。 …… 你可以把 API 理解为程序与程序之间通信的桥梁,其本质就是一个函数而已。另外,API 的使用也不是没有章法的,它的规则由(比如数据输入和输出的格式)API 提供方制定。\n何为 RESTful API? # RESTful API 经常也被叫做 REST API,它是基于 REST 构建的 API。这个 REST 到底是什么,我们后文在讲,涉及到的概念比较多。\n如果你看 RESTful API 相关的文章的话一般都比较晦涩难懂,主要是因为 REST 涉及到的一些概念比较难以理解。但是,实际上,我们平时开发用到的 RESTful API 的知识非常简单也很容易概括!\n举个例子,如果我给你下面两个 API 你是不是立马能知道它们是干什么用的!这就是 RESTful API 的强大之处!\nGET /classes:列出所有班级 POST /classes:新建一个班级 RESTful API 可以让你看到 URL+Http Method 就知道这个 URL 是干什么的,让你看到了 HTTP 状态码(status code)就知道请求结果如何。\n像咱们在开发过程中设计 API 的时候也应该至少要满足 RESTful API 的最基本的要求(比如接口中尽量使用名词,使用 POST 请求创建资源,DELETE 请求删除资源等等,示例:GET /notes/id:获取某个指定 id 的笔记的信息)。\n解读 REST # REST 是 REpresentational State Transfer 的缩写。这个词组的翻译过来就是“表现层状态转化”。\n这样理解起来甚是晦涩,实际上 REST 的全称是 Resource Representational State Transfer ,直白地翻译过来就是 “资源”在网络传输中以某种“表现形式”进行“状态转移” 。如果还是不能继续理解,请继续往下看,相信下面的讲解一定能让你理解到底啥是 REST 。\n我们分别对上面涉及到的概念进行解读,以便加深理解,实际上你不需要搞懂下面这些概念,也能看懂我下一部分要介绍到的内容。不过,为了更好地能跟别人扯扯 “RESTful API”我建议你还是要好好理解一下!\n资源(Resource):我们可以把真实的对象数据称为资源。一个资源既可以是一个集合,也可以是单个个体。比如我们的班级 classes 是代表一个集合形式的资源,而特定的 class 代表单个个体资源。每一种资源都有特定的 URI(统一资源标识符)与之对应,如果我们需要获取这个资源,访问这个 URI 就可以了,比如获取特定的班级:/class/12。另外,资源也可以包含子资源,比如 /classes/classId/teachers:列出某个指定班级的所有老师的信息 表现形式(Representational):\u0026ldquo;资源\u0026quot;是一种信息实体,它可以有多种外在表现形式。我们把\u0026quot;资源\u0026quot;具体呈现出来的形式比如 json,xml,image,txt 等等叫做它的\u0026quot;表现层/表现形式\u0026rdquo;。 状态转移(State Transfer):大家第一眼看到这个词语一定会很懵逼?内心 BB:这尼玛是啥啊? 大白话来说 REST 中的状态转移更多地描述的服务器端资源的状态,比如你通过增删改查(通过 HTTP 动词实现)引起资源状态的改变。ps:互联网通信协议 HTTP 协议,是一个无状态协议,所有的资源状态都保存在服务器端。 综合上面的解释,我们总结一下什么是 RESTful 架构:\n每一个 URI 代表一种资源; 客户端和服务器之间,传递这种资源的某种表现形式比如 json,xml,image,txt 等等; 客户端通过特定的 HTTP 动词,对服务器端资源进行操作,实现\u0026quot;表现层状态转化\u0026quot;。 RESTful API 规范 # 动作 # GET:请求从服务器获取特定资源。举个例子:GET /classes(获取所有班级) POST:在服务器上创建一个新的资源。举个例子:POST /classes(创建班级) PUT:更新服务器上的资源(客户端提供更新后的整个资源)。举个例子:PUT /classes/12(更新编号为 12 的班级) DELETE:从服务器删除特定的资源。举个例子:DELETE /classes/12(删除编号为 12 的班级) PATCH:更新服务器上的资源(客户端提供更改的属性,可以看做作是部分更新),使用的比较少,这里就不举例子了。 路径(接口命名) # 路径又称\u0026quot;终点\u0026quot;(endpoint),表示 API 的具体网址。实际开发中常见的规范如下:\n网址中不能有动词,只能有名词,API 中的名词也应该使用复数。 因为 REST 中的资源往往和数据库中的表对应,而数据库中的表都是同种记录的\u0026quot;集合\u0026quot;(collection)。如果 API 调用并不涉及资源(如计算,翻译等操作)的话,可以用动词。比如:GET /calculate?param1=11\u0026amp;param2=33 。 不用大写字母,建议用中杠 - 不用下杠 _ 。比如邀请码写成 invitation-code而不是 invitation_code 。 善用版本化 API。当我们的 API 发生了重大改变而不兼容前期版本的时候,我们可以通过 URL 来实现版本化,比如 http://api.example.com/v1、http://apiv1.example.com 。版本不必非要是数字,只是数字用的最多,日期、季节都可以作为版本标识符,项目团队达成共识就可。 接口尽量使用名词,避免使用动词。 RESTful API 操作(HTTP Method)的是资源(名词)而不是动作(动词)。 Talk is cheap!来举个实际的例子来说明一下吧!现在有这样一个 API 提供班级(class)的信息,还包括班级中的学生和教师的信息,则它的路径应该设计成下面这样。\nGET /classes:列出所有班级 POST /classes:新建一个班级 GET /classes/{classId}:获取某个指定班级的信息 PUT /classes/{classId}:更新某个指定班级的信息(一般倾向整体更新) PATCH /classes/{classId}:更新某个指定班级的信息(一般倾向部分更新) DELETE /classes/{classId}:删除某个班级 GET /classes/{classId}/teachers:列出某个指定班级的所有老师的信息 GET /classes/{classId}/students:列出某个指定班级的所有学生的信息 DELETE /classes/{classId}/teachers/{ID}:删除某个指定班级下的指定的老师的信息 反例:\n/getAllclasses /createNewclass /deleteAllActiveclasses 理清资源的层次结构,比如业务针对的范围是学校,那么学校会是一级资源:/schools,老师: /schools/teachers,学生: /schools/students 就是二级资源。\n过滤信息(Filtering) # 如果我们在查询的时候需要添加特定条件的话,建议使用 url 参数的形式。比如我们要查询 state 状态为 active 并且 name 为 guidegege 的班级:\nGET /classes?state=active\u0026amp;name=guidegege 比如我们要实现分页查询:\nGET /classes?page=1\u0026amp;size=10 //指定第1页,每页10个数据 状态码(Status Codes) # 状态码范围:\n2xx:成功 3xx:重定向 4xx:客户端错误 5xx:服务器错误 200 成功 301 永久重定向 400 错误请求 500 服务器错误 201 创建 304 资源未修改 401 未授权 502 网关错误 403 禁止访问 504 网关超时 404 未找到 405 请求方法不对 RESTful 的极致 HATEOAS # RESTful 的极致是 hateoas ,但是这个基本不会在实际项目中用到。\n上面是 RESTful API 最基本的东西,也是我们平时开发过程中最容易实践到的。实际上,RESTful API 最好做到 Hypermedia,即返回结果中提供链接,连向其他 API 方法,使得用户不查文档,也知道下一步应该做什么。\n比如,当用户向 api.example.com 的根目录发出请求,会得到这样一个返回结果\n{\u0026#34;link\u0026#34;: { \u0026#34;rel\u0026#34;: \u0026#34;collection https://www.example.com/classes\u0026#34;, \u0026#34;href\u0026#34;: \u0026#34;https://api.example.com/classes\u0026#34;, \u0026#34;title\u0026#34;: \u0026#34;List of classes\u0026#34;, \u0026#34;type\u0026#34;: \u0026#34;application/vnd.yourformat+json\u0026#34; }} 上面代码表示,文档中有一个 link 属性,用户读取这个属性就知道下一步该调用什么 API 了。rel 表示这个 API 与当前网址的关系(collection 关系,并给出该 collection 的网址),href 表示 API 的路径,title 表示 API 的标题,type 表示返回类型 Hypermedia API 的设计被称为 HATEOAS。\n在 Spring 中有一个叫做 HATEOAS 的 API 库,通过它我们可以更轻松的创建出符合 HATEOAS 设计的 API。相关文章:\n在 Spring Boot 中使用 HATEOAS Building REST services with Spring (Spring 官网 ) An Intro to Spring HATEOAS spring-hateoas-examples Spring HATEOAS (Spring 官网 ) 参考 # https://RESTfulapi.net/\nhttps://www.ruanyifeng.com/blog/2014/05/restful_api.html\nhttps://juejin.im/entry/59e460c951882542f578f2f0\nhttps://phauer.com/2016/testing-RESTful-services-java-best-practices/\nhttps://www.seobility.net/en/wiki/REST_API\nhttps://dev.to/duomly/rest-api-vs-graphql-comparison-3j6g\n"},{"id":566,"href":"/zh/docs/technology/Interview/high-performance/message-queue/rocketmq-questions/","title":"RocketMQ常见问题总结","section":"High Performance","content":" 本文由 FrancisQ 投稿! 相比原文主要进行了下面这些完善:\n分析了 RocketMQ 高性能读写的原因和顺序消费的具体实现 增加了消息类型、消费者类型、消费者组和生产者组的介绍 消息队列扫盲 # 消息队列顾名思义就是存放消息的队列,队列我就不解释了,别告诉我你连队列都不知道是啥吧?\n所以问题并不是消息队列是什么,而是 消息队列为什么会出现?消息队列能用来干什么?用它来干这些事会带来什么好处?消息队列会带来副作用吗?\n消息队列为什么会出现? # 消息队``列算是作为后端程序员的一个必备技能吧,因为分布式应用必定涉及到各个系统之间的通信问题,这个时候消息队列也应运而生了。可以说分布式的产生是消息队列的基础,而分布式怕是一个很古老的概念了吧,所以消息队列也是一个很古老的中间件了。\n消息队列能用来干什么? # 异步 # 你可能会反驳我,应用之间的通信又不是只能由消息队列解决,好好的通信为什么中间非要插一个消息队列呢?我不能直接进行通信吗?\n很好 👍,你又提出了一个概念,同步通信。就比如现在业界使用比较多的 Dubbo 就是一个适用于各个系统之间同步通信的 RPC 框架。\n我来举个 🌰 吧,比如我们有一个购票系统,需求是用户在购买完之后能接收到购买完成的短信。\n我们省略中间的网络通信时间消耗,假如购票系统处理需要 150ms ,短信系统处理需要 200ms ,那么整个处理流程的时间消耗就是 150ms + 200ms = 350ms。\n当然,乍看没什么问题。可是仔细一想你就感觉有点问题,我用户购票在购票系统的时候其实就已经完成了购买,而我现在通过同步调用非要让整个请求拉长时间,而短信系统这玩意又不是很有必要,它仅仅是一个辅助功能增强用户体验感而已。我现在整个调用流程就有点 头重脚轻 的感觉了,购票是一个不太耗时的流程,而我现在因为同步调用,非要等待发送短信这个比较耗时的操作才返回结果。那我如果再加一个发送邮件呢?\n这样整个系统的调用链又变长了,整个时间就变成了 550ms。\n当我们在学生时代需要在食堂排队的时候,我们和食堂大妈就是一个同步的模型。\n我们需要告诉食堂大妈:“姐姐,给我加个鸡腿,再加个酸辣土豆丝,帮我浇点汁上去,多打点饭哦 😋😋😋” 咦~~~ 为了多吃点,真恶心。\n然后大妈帮我们打饭配菜,我们看着大妈那颤抖的手和掉落的土豆丝不禁咽了咽口水。\n最终我们从大妈手中接过饭菜然后去寻找座位了\u0026hellip;\n回想一下,我们在给大妈发送需要的信息之后我们是 同步等待大妈给我配好饭菜 的,上面我们只是加了鸡腿和土豆丝,万一我再加一个番茄牛腩,韭菜鸡蛋,这样是不是大妈打饭配菜的流程就会变长,我们等待的时间也会相应的变长。\n那后来,我们工作赚钱了有钱去饭店吃饭了,我们告诉服务员来一碗牛肉面加个荷包蛋 (传达一个消息) ,然后我们就可以在饭桌上安心的玩手机了 (干自己其他事情) ,等到我们的牛肉面上了我们就可以吃了。这其中我们也就传达了一个消息,然后我们又转过头干其他事情了。这其中虽然做面的时间没有变短,但是我们只需要传达一个消息就可以干其他事情了,这是一个 异步 的概念。\n所以,为了解决这一个问题,聪明的程序员在中间也加了个类似于服务员的中间件——消息队列。这个时候我们就可以把模型给改造了。\n这样,我们在将消息存入消息队列之后我们就可以直接返回了(我们告诉服务员我们要吃什么然后玩手机),所以整个耗时只是 150ms + 10ms = 160ms。\n但是你需要注意的是,整个流程的时长是没变的,就像你仅仅告诉服务员要吃什么是不会影响到做面的速度的。\n解耦 # 回到最初同步调用的过程,我们写个伪代码简单概括一下。\n那么第二步,我们又添加了一个发送邮件,我们就得重新去修改代码,如果我们又加一个需求:用户购买完还需要给他加积分,这个时候我们是不是又得改代码?\n如果你觉得还行,那么我这个时候不要发邮件这个服务了呢,我是不是又得改代码,又得重启应用?\n这样改来改去是不是很麻烦,那么 此时我们就用一个消息队列在中间进行解耦 。你需要注意的是,我们后面的发送短信、发送邮件、添加积分等一些操作都依赖于上面的 result ,这东西抽象出来就是购票的处理结果呀,比如订单号,用户账号等等,也就是说我们后面的一系列服务都是需要同样的消息来进行处理。既然这样,我们是不是可以通过 “广播消息” 来实现。\n我上面所讲的“广播”并不是真正的广播,而是接下来的系统作为消费者去 订阅 特定的主题。比如我们这里的主题就可以叫做 订票 ,我们购买系统作为一个生产者去生产这条消息放入消息队列,然后消费者订阅了这个主题,会从消息队列中拉取消息并消费。就比如我们刚刚画的那张图,你会发现,在生产者这边我们只需要关注 生产消息到指定主题中 ,而 消费者只需要关注从指定主题中拉取消息 就行了。\n如果没有消息队列,每当一个新的业务接入,我们都要在主系统调用新接口、或者当我们取消某些业务,我们也得在主系统删除某些接口调用。有了消息队列,我们只需要关心消息是否送达了队列,至于谁希望订阅,接下来收到消息如何处理,是下游的事情,无疑极大地减少了开发和联调的工作量。\n削峰 # 我们再次回到一开始我们使用同步调用系统的情况,并且思考一下,如果此时有大量用户请求购票整个系统会变成什么样?\n如果,此时有一万的请求进入购票系统,我们知道运行我们主业务的服务器配置一般会比较好,所以这里我们假设购票系统能承受这一万的用户请求,那么也就意味着我们同时也会出现一万调用发短信服务的请求。而对于短信系统来说并不是我们的主要业务,所以我们配备的硬件资源并不会太高,那么你觉得现在这个短信系统能承受这一万的峰值么,且不说能不能承受,系统会不会 直接崩溃 了?\n短信业务又不是我们的主业务,我们能不能 折中处理 呢?如果我们把购买完成的信息发送到消息队列中,而短信系统 尽自己所能地去消息队列中取消息和消费消息 ,即使处理速度慢一点也无所谓,只要我们的系统没有崩溃就行了。\n留得江山在,还怕没柴烧?你敢说每次发送验证码的时候是一发你就收到了的么?\n消息队列能带来什么好处? # 其实上面我已经说了。异步、解耦、削峰。 哪怕你上面的都没看懂也千万要记住这六个字,因为他不仅是消息队列的精华,更是编程和架构的精华。\n消息队列会带来副作用吗? # 没有哪一门技术是“银弹”,消息队列也有它的副作用。\n比如,本来好好的两个系统之间的调用,我中间加了个消息队列,如果消息队列挂了怎么办呢?是不是 降低了系统的可用性 ?\n那这样是不是要保证 HA(高可用)?是不是要搞集群?那么我 整个系统的复杂度是不是上升了 ?\n抛开上面的问题不讲,万一我发送方发送失败了,然后执行重试,这样就可能产生重复的消息。\n或者我消费端处理失败了,请求重发,这样也会产生重复的消息。\n对于一些微服务来说,消费重复消息会带来更大的麻烦,比如增加积分,这个时候我加了多次是不是对其他用户不公平?\n那么,又 如何解决重复消费消息的问题 呢?\n如果我们此时的消息需要保证严格的顺序性怎么办呢?比如生产者生产了一系列的有序消息(对一个 id 为 1 的记录进行删除增加修改),但是我们知道在发布订阅模型中,对于主题是无顺序的,那么这个时候就会导致对于消费者消费消息的时候没有按照生产者的发送顺序消费,比如这个时候我们消费的顺序为修改删除增加,如果该记录涉及到金额的话是不是会出大事情?\n那么,又 如何解决消息的顺序消费问题 呢?\n就拿我们上面所讲的分布式系统来说,用户购票完成之后是不是需要增加账户积分?在同一个系统中我们一般会使用事务来进行解决,如果用 Spring 的话我们在上面伪代码中加入 @Transactional 注解就好了。但是在不同系统中如何保证事务呢?总不能这个系统我扣钱成功了你那积分系统积分没加吧?或者说我这扣钱明明失败了,你那积分系统给我加了积分。\n那么,又如何 解决分布式事务问题 呢?\n我们刚刚说了,消息队列可以进行削峰操作,那如果我的消费者如果消费很慢或者生产者生产消息很快,这样是不是会将消息堆积在消息队列中?\n那么,又如何 解决消息堆积的问题 呢?\n可用性降低,复杂度上升,又带来一系列的重复消费,顺序消费,分布式事务,消息堆积的问题,这消息队列还怎么用啊 😵?\n别急,办法总是有的。\nRocketMQ 是什么? # 哇,你个混蛋!上面给我抛出那么多问题,你现在又讲 RocketMQ ,还让不让人活了?!🤬\n别急别急,话说你现在清楚 MQ 的构造吗,我还没讲呢,我们先搞明白 MQ 的内部构造,再来看看如何解决上面的一系列问题吧,不过你最好带着问题去阅读和了解喔。\nRocketMQ 是一个 队列模型 的消息中间件,具有高性能、高可靠、高实时、分布式 的特点。它是一个采用 Java 语言开发的分布式的消息系统,由阿里巴巴团队开发,在 2016 年底贡献给 Apache,成为了 Apache 的一个顶级项目。 在阿里内部,RocketMQ 很好地服务了集团大大小小上千个应用,在每年的双十一当天,更有不可思议的万亿级消息通过 RocketMQ 流转。\n废话不多说,想要了解 RocketMQ 历史的同学可以自己去搜寻资料。听完上面的介绍,你只要知道 RocketMQ 很快、很牛、而且经历过双十一的实践就行了!\n队列模型和主题模型是什么? # 在谈 RocketMQ 的技术架构之前,我们先来了解一下两个名词概念——队列模型 和 主题模型 。\n首先我问一个问题,消息队列为什么要叫消息队列?\n你可能觉得很弱智,这玩意不就是存放消息的队列嘛?不叫消息队列叫什么?\n的确,早期的消息中间件是通过 队列 这一模型来实现的,可能是历史原因,我们都习惯把消息中间件成为消息队列。\n但是,如今例如 RocketMQ、Kafka 这些优秀的消息中间件不仅仅是通过一个 队列 来实现消息存储的。\n队列模型 # 就像我们理解队列一样,消息中间件的队列模型就真的只是一个队列。。。我画一张图给大家理解。\n在一开始我跟你提到了一个 “广播” 的概念,也就是说如果我们此时我们需要将一个消息发送给多个消费者(比如此时我需要将信息发送给短信系统和邮件系统),这个时候单个队列即不能满足需求了。\n当然你可以让 Producer 生产消息放入多个队列中,然后每个队列去对应每一个消费者。问题是可以解决,创建多个队列并且复制多份消息是会很影响资源和性能的。而且,这样子就会导致生产者需要知道具体消费者个数然后去复制对应数量的消息队列,这就违背我们消息中间件的 解耦 这一原则。\n主题模型 # 那么有没有好的方法去解决这一个问题呢?有,那就是 主题模型 或者可以称为 发布订阅模型 。\n感兴趣的同学可以去了解一下设计模式里面的观察者模式并且手动实现一下,我相信你会有所收获的。\n在主题模型中,消息的生产者称为 发布者(Publisher) ,消息的消费者称为 订阅者(Subscriber) ,存放消息的容器称为 主题(Topic) 。\n其中,发布者将消息发送到指定主题中,订阅者需要 提前订阅主题 才能接受特定主题的消息。\nRocketMQ 中的消息模型 # RocketMQ 中的消息模型就是按照 主题模型 所实现的。你可能会好奇这个 主题 到底是怎么实现的呢?你上面也没有讲到呀!\n其实对于主题模型的实现来说每个消息中间件的底层设计都是不一样的,就比如 Kafka 中的 分区 ,RocketMQ 中的 队列 ,RabbitMQ 中的 Exchange 。我们可以理解为 主题模型/发布订阅模型 就是一个标准,那些中间件只不过照着这个标准去实现而已。\n所以,RocketMQ 中的 主题模型 到底是如何实现的呢?首先我画一张图,大家尝试着去理解一下。\n我们可以看到在整个图中有 Producer Group、Topic、Consumer Group 三个角色,我来分别介绍一下他们。\nProducer Group 生产者组:代表某一类的生产者,比如我们有多个秒杀系统作为生产者,这多个合在一起就是一个 Producer Group 生产者组,它们一般生产相同的消息。 Consumer Group 消费者组:代表某一类的消费者,比如我们有多个短信系统作为消费者,这多个合在一起就是一个 Consumer Group 消费者组,它们一般消费相同的消息。 Topic 主题:代表一类消息,比如订单消息,物流消息等等。 你可以看到图中生产者组中的生产者会向主题发送消息,而 主题中存在多个队列,生产者每次生产消息之后是指定主题中的某个队列发送消息的。\n每个主题中都有多个队列(分布在不同的 Broker中,如果是集群的话,Broker又分布在不同的服务器中),集群消费模式下,一个消费者集群多台机器共同消费一个 topic 的多个队列,一个队列只会被一个消费者消费。如果某个消费者挂掉,分组内其它消费者会接替挂掉的消费者继续消费。就像上图中 Consumer1 和 Consumer2 分别对应着两个队列,而 Consumer3 是没有队列对应的,所以一般来讲要控制 消费者组中的消费者个数和主题中队列个数相同 。\n当然也可以消费者个数小于队列个数,只不过不太建议。如下图。\n每个消费组在每个队列上维护一个消费位置 ,为什么呢?\n因为我们刚刚画的仅仅是一个消费者组,我们知道在发布订阅模式中一般会涉及到多个消费者组,而每个消费者组在每个队列中的消费位置都是不同的。如果此时有多个消费者组,那么消息被一个消费者组消费完之后是不会删除的(因为其它消费者组也需要呀),它仅仅是为每个消费者组维护一个 消费位移(offset) ,每次消费者组消费完会返回一个成功的响应,然后队列再把维护的消费位移加一,这样就不会出现刚刚消费过的消息再一次被消费了。\n可能你还有一个问题,为什么一个主题中需要维护多个队列 ?\n答案是 提高并发能力 。的确,每个主题中只存在一个队列也是可行的。你想一下,如果每个主题中只存在一个队列,这个队列中也维护着每个消费者组的消费位置,这样也可以做到 发布订阅模式 。如下图。\n但是,这样我生产者是不是只能向一个队列发送消息?又因为需要维护消费位置所以一个队列只能对应一个消费者组中的消费者,这样是不是其他的 Consumer 就没有用武之地了?从这两个角度来讲,并发度一下子就小了很多。\n所以总结来说,RocketMQ 通过使用在一个 Topic 中配置多个队列并且每个队列维护每个消费者组的消费位置 实现了 主题模式/发布订阅模式 。\nRocketMQ 的架构图 # 讲完了消息模型,我们理解起 RocketMQ 的技术架构起来就容易多了。\nRocketMQ 技术架构中有四大角色 NameServer、Broker、Producer、Consumer 。我来向大家分别解释一下这四个角色是干啥的。\nBroker:主要负责消息的存储、投递和查询以及服务高可用保证。说白了就是消息队列服务器嘛,生产者生产消息到 Broker ,消费者从 Broker 拉取消息并消费。\n这里,我还得普及一下关于 Broker、Topic 和 队列的关系。上面我讲解了 Topic 和队列的关系——一个 Topic 中存在多个队列,那么这个 Topic 和队列存放在哪呢?\n一个 Topic 分布在多个 Broker上,一个 Broker 可以配置多个 Topic ,它们是多对多的关系。\n如果某个 Topic 消息量很大,应该给它多配置几个队列(上文中提到了提高并发能力),并且 尽量多分布在不同 Broker 上,以减轻某个 Broker 的压力 。\nTopic 消息量都比较均匀的情况下,如果某个 broker 上的队列越多,则该 broker 压力越大。\n所以说我们需要配置多个 Broker。\nNameServer:不知道你们有没有接触过 ZooKeeper 和 Spring Cloud 中的 Eureka ,它其实也是一个 注册中心 ,主要提供两个功能:Broker 管理 和 路由信息管理 。说白了就是 Broker 会将自己的信息注册到 NameServer 中,此时 NameServer 就存放了很多 Broker 的信息(Broker 的路由表),消费者和生产者就从 NameServer 中获取路由表然后照着路由表的信息和对应的 Broker 进行通信(生产者和消费者定期会向 NameServer 去查询相关的 Broker 的信息)。\nProducer:消息发布的角色,支持分布式集群方式部署。说白了就是生产者。\nConsumer:消息消费的角色,支持分布式集群方式部署。支持以 push 推,pull 拉两种模式对消息进行消费。同时也支持集群方式和广播方式的消费,它提供实时消息订阅机制。说白了就是消费者。\n听完了上面的解释你可能会觉得,这玩意好简单。不就是这样的么?\n嗯?你可能会发现一个问题,这老家伙 NameServer 干啥用的,这不多余吗?直接 Producer、Consumer 和 Broker 直接进行生产消息,消费消息不就好了么?\n但是,我们上文提到过 Broker 是需要保证高可用的,如果整个系统仅仅靠着一个 Broker 来维持的话,那么这个 Broker 的压力会不会很大?所以我们需要使用多个 Broker 来保证 负载均衡 。\n如果说,我们的消费者和生产者直接和多个 Broker 相连,那么当 Broker 修改的时候必定会牵连着每个生产者和消费者,这样就会产生耦合问题,而 NameServer 注册中心就是用来解决这个问题的。\n如果还不是很理解的话,可以去看我介绍 Spring Cloud 的那篇文章,其中介绍了 Eureka 注册中心。\n当然,RocketMQ 中的技术架构肯定不止前面那么简单,因为上面图中的四个角色都是需要做集群的。我给出一张官网的架构图,大家尝试理解一下。\n其实和我们最开始画的那张乞丐版的架构图也没什么区别,主要是一些细节上的差别。听我细细道来 🤨。\n第一、我们的 Broker 做了集群并且还进行了主从部署 ,由于消息分布在各个 Broker 上,一旦某个 Broker 宕机,则该Broker 上的消息读写都会受到影响。所以 Rocketmq 提供了 master/slave 的结构,salve 定时从 master 同步数据(同步刷盘或者异步刷盘),如果 master 宕机,则 slave 提供消费服务,但是不能写入消息 (后面我还会提到哦)。\n第二、为了保证 HA ,我们的 NameServer 也做了集群部署,但是请注意它是 去中心化 的。也就意味着它没有主节点,你可以很明显地看出 NameServer 的所有节点是没有进行 Info Replicate 的,在 RocketMQ 中是通过 单个 Broker 和所有 NameServer 保持长连接 ,并且在每隔 30 秒 Broker 会向所有 Nameserver 发送心跳,心跳包含了自身的 Topic 配置信息,这个步骤就对应这上面的 Routing Info 。\n第三、在生产者需要向 Broker 发送消息的时候,需要先从 NameServer 获取关于 Broker 的路由信息,然后通过 轮询 的方法去向每个队列中生产数据以达到 负载均衡 的效果。\n第四、消费者通过 NameServer 获取所有 Broker 的路由信息后,向 Broker 发送 Pull 请求来获取消息数据。Consumer 可以以两种模式启动—— 广播(Broadcast)和集群(Cluster)。广播模式下,一条消息会发送给 同一个消费组中的所有消费者 ,集群模式下消息只会发送给一个消费者。\nRocketMQ 功能特性 # 消息 # 普通消息 # 普通消息一般应用于微服务解耦、事件驱动、数据集成等场景,这些场景大多数要求数据传输通道具有可靠传输的能力,且对消息的处理时机、处理顺序没有特别要求。以在线的电商交易场景为例,上游订单系统将用户下单支付这一业务事件封装成独立的普通消息并发送至 RocketMQ 服务端,下游按需从服务端订阅消息并按照本地消费逻辑处理下游任务。每个消息之间都是相互独立的,且不需要产生关联。另外还有日志系统,以离线的日志收集场景为例,通过埋点组件收集前端应用的相关操作日志,并转发到 RocketMQ 。\n普通消息生命周期\n初始化:消息被生产者构建并完成初始化,待发送到服务端的状态。 待消费:消息被发送到服务端,对消费者可见,等待消费者消费的状态。 消费中:消息被消费者获取,并按照消费者本地的业务逻辑进行处理的过程。 此时服务端会等待消费者完成消费并提交消费结果,如果一定时间后没有收到消费者的响应,RocketMQ 会对消息进行重试处理。 消费提交:消费者完成消费处理,并向服务端提交消费结果,服务端标记当前消息已经被处理(包括消费成功和失败)。RocketMQ 默认支持保留所有消息,此时消息数据并不会立即被删除,只是逻辑标记已消费。消息在保存时间到期或存储空间不足被删除前,消费者仍然可以回溯消息重新消费。 消息删除:RocketMQ 按照消息保存机制滚动清理最早的消息数据,将消息从物理文件中删除。 定时消息 # 在分布式定时调度触发、任务超时处理等场景,需要实现精准、可靠的定时事件触发。使用 RocketMQ 的定时消息可以简化定时调度任务的开发逻辑,实现高性能、可扩展、高可靠的定时触发能力。定时消息仅支持在 MessageType 为 Delay 的主题内使用,即定时消息只能发送至类型为定时消息的主题中,发送的消息的类型必须和主题的类型一致。在 4.x 版本中,只支持延时消息,默认分为 18 个等级分别为:1s 5s 10s 30s 1m 2m 3m 4m 5m 6m 7m 8m 9m 10m 20m 30m 1h 2h,也可以在配置文件中增加自定义的延时等级和时长。在 5.x 版本中,开始支持定时消息,在构造消息时提供了 3 个 API 来指定延迟时间或定时时间。\n基于定时消息的超时任务处理具备如下优势:\n精度高、开发门槛低:基于消息通知方式不存在定时阶梯间隔。可以轻松实现任意精度事件触发,无需业务去重。 高性能可扩展:传统的数据库扫描方式较为复杂,需要频繁调用接口扫描,容易产生性能瓶颈。RocketMQ 的定时消息具有高并发和水平扩展的能力。 定时消息生命周期\n初始化:消息被生产者构建并完成初始化,待发送到服务端的状态。 定时中:消息被发送到服务端,和普通消息不同的是,服务端不会直接构建消息索引,而是会将定时消息单独存储在定时存储系统中,等待定时时刻到达。 待消费:定时时刻到达后,服务端将消息重新写入普通存储引擎,对下游消费者可见,等待消费者消费的状态。 消费中:消息被消费者获取,并按照消费者本地的业务逻辑进行处理的过程。 此时服务端会等待消费者完成消费并提交消费结果,如果一定时间后没有收到消费者的响应,RocketMQ 会对消息进行重试处理。 消费提交:消费者完成消费处理,并向服务端提交消费结果,服务端标记当前消息已经被处理(包括消费成功和失败)。RocketMQ 默认支持保留所有消息,此时消息数据并不会立即被删除,只是逻辑标记已消费。消息在保存时间到期或存储空间不足被删除前,消费者仍然可以回溯消息重新消费。 消息删除:Apache RocketMQ 按照消息保存机制滚动清理最早的消息数据,将消息从物理文件中删除。 定时消息的实现逻辑需要先经过定时存储等待触发,定时时间到达后才会被投递给消费者。因此,如果将大量定时消息的定时时间设置为同一时刻,则到达该时刻后会有大量消息同时需要被处理,会造成系统压力过大,导致消息分发延迟,影响定时精度。\n顺序消息 # 顺序消息仅支持使用 MessageType 为 FIFO 的主题,即顺序消息只能发送至类型为顺序消息的主题中,发送的消息的类型必须和主题的类型一致。和普通消息发送相比,顺序消息发送必须要设置消息组。(推荐实现 MessageQueueSelector 的方式,见下文)。要保证消息的顺序性需要单一生产者串行发送。\n单线程使用 MessageListenerConcurrently 可以顺序消费,多线程环境下使用 MessageListenerOrderly 才能顺序消费。\n事务消息 # 事务消息是 Apache RocketMQ 提供的一种高级消息类型,支持在分布式场景下保障消息生产和本地事务的最终一致性。简单来讲,就是将本地事务(数据库的 DML 操作)与发送消息合并在同一个事务中。例如,新增一个订单。在事务未提交之前,不发送订阅的消息。发送消息的动作随着事务的成功提交而发送,随着事务的回滚而取消。当然真正地处理过程不止这么简单,包含了半消息、事务监听和事务回查等概念,下面有更详细的说明。\n关于发送消息 # 不建议单一进程创建大量生产者 # Apache RocketMQ 的生产者和主题是多对多的关系,支持同一个生产者向多个主题发送消息。对于生产者的创建和初始化,建议遵循够用即可、最大化复用原则,如果有需要发送消息到多个主题的场景,无需为每个主题都创建一个生产者。\n不建议频繁创建和销毁生产者 # Apache RocketMQ 的生产者是可以重复利用的底层资源,类似数据库的连接池。因此不需要在每次发送消息时动态创建生产者,且在发送结束后销毁生产者。这样频繁的创建销毁会在服务端产生大量短连接请求,严重影响系统性能。\n正确示例:\nProducer p = ProducerBuilder.build(); for (int i =0;i\u0026lt;n;i++){ Message m= MessageBuilder.build(); p.send(m); } p.shutdown(); 消费者分类 # PushConsumer # 高度封装的消费者类型,消费消息仅仅通过消费监听器监听并返回结果。消息的获取、消费状态提交以及消费重试都通过 RocketMQ 的客户端 SDK 完成。\nPushConsumer 的消费监听器执行结果分为以下三种情况:\n返回消费成功:以 Java SDK 为例,返回ConsumeResult.SUCCESS,表示该消息处理成功,服务端按照消费结果更新消费进度。 返回消费失败:以 Java SDK 为例,返回ConsumeResult.FAILURE,表示该消息处理失败,需要根据消费重试逻辑判断是否进行重试消费。 出现非预期失败:例如抛异常等行为,该结果按照消费失败处理,需要根据消费重试逻辑判断是否进行重试消费。 具体实现可以参见这篇文章 RocketMQ 对 pull 和 push 的实现。\n使用 PushConsumer 消费者消费时,不允许使用以下方式处理消息,否则 RocketMQ 无法保证消息的可靠性。\n错误方式一:消息还未处理完成,就提前返回消费成功结果。此时如果消息消费失败,RocketMQ 服务端是无法感知的,因此不会进行消费重试。 错误方式二:在消费监听器内将消息再次分发到自定义的其他线程,消费监听器提前返回消费结果。此时如果消息消费失败,RocketMQ 服务端同样无法感知,因此也不会进行消费重试。 PushConsumer 严格限制了消息同步处理及每条消息的处理超时时间,适用于以下场景: 消息处理时间可预估:如果不确定消息处理耗时,经常有预期之外的长时间耗时的消息,PushConsumer 的可靠性保证会频繁触发消息重试机制造成大量重复消息。 无异步化、高级定制场景:PushConsumer 限制了消费逻辑的线程模型,由客户端 SDK 内部按最大吞吐量触发消息处理。该模型开发逻辑简单,但是不允许使用异步化和自定义处理流程。 SimpleConsumer # SimpleConsumer 是一种接口原子型的消费者类型,消息的获取、消费状态提交以及消费重试都是通过消费者业务逻辑主动发起调用完成。\n一个来自官网的例子:\n// 消费示例:使用 SimpleConsumer 消费普通消息,主动获取消息处理并提交。 ClientServiceProvider provider = ClientServiceProvider.loadService(); String topic = \u0026#34;YourTopic\u0026#34;; FilterExpression filterExpression = new FilterExpression(\u0026#34;YourFilterTag\u0026#34;, FilterExpressionType.TAG); SimpleConsumer simpleConsumer = provider.newSimpleConsumerBuilder() // 设置消费者分组。 .setConsumerGroup(\u0026#34;YourConsumerGroup\u0026#34;) // 设置接入点。 .setClientConfiguration(ClientConfiguration.newBuilder().setEndpoints(\u0026#34;YourEndpoint\u0026#34;).build()) // 设置预绑定的订阅关系。 .setSubscriptionExpressions(Collections.singletonMap(topic, filterExpression)) // 设置从服务端接受消息的最大等待时间 .setAwaitDuration(Duration.ofSeconds(1)) .build(); try { // SimpleConsumer 需要主动获取消息,并处理。 List\u0026lt;MessageView\u0026gt; messageViewList = simpleConsumer.receive(10, Duration.ofSeconds(30)); messageViewList.forEach(messageView -\u0026gt; { System.out.println(messageView); // 消费处理完成后,需要主动调用 ACK 提交消费结果。 try { simpleConsumer.ack(messageView); } catch (ClientException e) { logger.error(\u0026#34;Failed to ack message, messageId={}\u0026#34;, messageView.getMessageId(), e); } }); } catch (ClientException e) { // 如果遇到系统流控等原因造成拉取失败,需要重新发起获取消息请求。 logger.error(\u0026#34;Failed to receive message\u0026#34;, e); } SimpleConsumer 适用于以下场景:\n消息处理时长不可控:如果消息处理时长无法预估,经常有长时间耗时的消息处理情况。建议使用 SimpleConsumer 消费类型,可以在消费时自定义消息的预估处理时长,若实际业务中预估的消息处理时长不符合预期,也可以通过接口提前修改。 需要异步化、批量消费等高级定制场景:SimpleConsumer 在 SDK 内部没有复杂的线程封装,完全由业务逻辑自由定制,可以实现异步分发、批量消费等高级定制场景。 需要自定义消费速率:SimpleConsumer 是由业务逻辑主动调用接口获取消息,因此可以自由调整获取消息的频率,自定义控制消费速率。 PullConsumer # 施工中。。。\n消费者分组和生产者分组 # 生产者分组 # RocketMQ 服务端 5.x 版本开始,生产者是匿名的,无需管理生产者分组(ProducerGroup);对于历史版本服务端 3.x 和 4.x 版本,已经使用的生产者分组可以废弃无需再设置,且不会对当前业务产生影响。\n消费者分组 # 消费者分组是多个消费行为一致的消费者的负载均衡分组。消费者分组不是具体实体而是一个逻辑资源。通过消费者分组实现消费性能的水平扩展以及高可用容灾。\n消费者分组中的订阅关系、投递顺序性、消费重试策略是一致的。\n订阅关系:Apache RocketMQ 以消费者分组的粒度管理订阅关系,实现订阅关系的管理和追溯。 投递顺序性:Apache RocketMQ 的服务端将消息投递给消费者消费时,支持顺序投递和并发投递,投递方式在消费者分组中统一配置。 消费重试策略: 消费者消费消息失败时的重试策略,包括重试次数、死信队列设置等。 RocketMQ 服务端 5.x 版本:上述消费者的消费行为从关联的消费者分组中统一获取,因此,同一分组内所有消费者的消费行为必然是一致的,客户端无需关注。\nRocketMQ 服务端 3.x/4.x 历史版本:上述消费逻辑由消费者客户端接口定义,因此,您需要自己在消费者客户端设置时保证同一分组下的消费者的消费行为一致。(来自官方网站)\n如何解决顺序消费和重复消费? # 其实,这些东西都是我在介绍消息队列带来的一些副作用的时候提到的,也就是说,这些问题不仅仅挂钩于 RocketMQ ,而是应该每个消息中间件都需要去解决的。\n在上面我介绍 RocketMQ 的技术架构的时候我已经向你展示了 它是如何保证高可用的 ,这里不涉及运维方面的搭建,如果你感兴趣可以自己去官网上照着例子搭建属于你自己的 RocketMQ 集群。\n其实 Kafka 的架构基本和 RocketMQ 类似,只是它注册中心使用了 Zookeeper、它的 分区 就相当于 RocketMQ 中的 队列 。还有一些小细节不同会在后面提到。\n顺序消费 # 在上面的技术架构介绍中,我们已经知道了 RocketMQ 在主题上是无序的、它只有在队列层面才是保证有序 的。\n这又扯到两个概念——普通顺序 和 严格顺序 。\n所谓普通顺序是指 消费者通过 同一个消费队列收到的消息是有顺序的 ,不同消息队列收到的消息则可能是无顺序的。普通顺序消息在 Broker 重启情况下不会保证消息顺序性 (短暂时间) 。\n所谓严格顺序是指 消费者收到的 所有消息 均是有顺序的。严格顺序消息 即使在异常情况下也会保证消息的顺序性 。\n但是,严格顺序看起来虽好,实现它可会付出巨大的代价。如果你使用严格顺序模式,Broker 集群中只要有一台机器不可用,则整个集群都不可用。你还用啥?现在主要场景也就在 binlog 同步。\n一般而言,我们的 MQ 都是能容忍短暂的乱序,所以推荐使用普通顺序模式。\n那么,我们现在使用了 普通顺序模式 ,我们从上面学习知道了在 Producer 生产消息的时候会进行轮询(取决你的负载均衡策略)来向同一主题的不同消息队列发送消息。那么如果此时我有几个消息分别是同一个订单的创建、支付、发货,在轮询的策略下这 三个消息会被发送到不同队列 ,因为在不同的队列此时就无法使用 RocketMQ 带来的队列有序特性来保证消息有序性了。\n那么,怎么解决呢?\n其实很简单,我们需要处理的仅仅是将同一语义下的消息放入同一个队列(比如这里是同一个订单),那我们就可以使用 Hash 取模法 来保证同一个订单在同一个队列中就行了。\nRocketMQ 实现了两种队列选择算法,也可以自己实现\n轮询算法\n轮询算法就是向消息指定的 topic 所在队列中依次发送消息,保证消息均匀分布 是 RocketMQ 默认队列选择算法 最小投递延迟算法\n每次消息投递的时候统计消息投递的延迟,选择队列时优先选择消息延时小的队列,导致消息分布不均匀,按照如下设置即可。\nproducer.setSendLatencyFaultEnable(true); 继承 MessageQueueSelector 实现\nSendResult sendResult = producer.send(msg, new MessageQueueSelector() { @Override public MessageQueue select(List\u0026lt;MessageQueue\u0026gt; mqs, Message msg, Object arg) { //从mqs中选择一个队列,可以根据msg特点选择 return null; } }, new Object()); 特殊情况处理 # 发送异常 # 选择队列后会与 Broker 建立连接,通过网络请求将消息发送到 Broker 上,如果 Broker 挂了或者网络波动发送消息超时此时 RocketMQ 会进行重试。\n重新选择其他 Broker 中的消息队列进行发送,默认重试两次,可以手动设置。\nproducer.setRetryTimesWhenSendFailed(5); 消息过大 # 消息超过 4k 时 RocketMQ 会将消息压缩后在发送到 Broker 上,减少网络资源的占用。\n重复消费 # emmm,就两个字—— 幂等 。在编程中一个幂等 操作的特点是其任意多次执行所产生的影响均与一次执行的影响相同。比如说,这个时候我们有一个订单的处理积分的系统,每当来一个消息的时候它就负责为创建这个订单的用户的积分加上相应的数值。可是有一次,消息队列发送给订单系统 FrancisQ 的订单信息,其要求是给 FrancisQ 的积分加上 500。但是积分系统在收到 FrancisQ 的订单信息处理完成之后返回给消息队列处理成功的信息的时候出现了网络波动(当然还有很多种情况,比如 Broker 意外重启等等),这条回应没有发送成功。\n那么,消息队列没收到积分系统的回应会不会尝试重发这个消息?问题就来了,我再发这个消息,万一它又给 FrancisQ 的账户加上 500 积分怎么办呢?\n所以我们需要给我们的消费者实现 幂等 ,也就是对同一个消息的处理结果,执行多少次都不变。\n那么如何给业务实现幂等呢?这个还是需要结合具体的业务的。你可以使用 写入 Redis 来保证,因为 Redis 的 key 和 value 就是天然支持幂等的。当然还有使用 数据库插入法 ,基于数据库的唯一键来保证重复数据不会被插入多条。\n不过最主要的还是需要 根据特定场景使用特定的解决方案 ,你要知道你的消息消费是否是完全不可重复消费还是可以忍受重复消费的,然后再选择强校验和弱校验的方式。毕竟在 CS 领域还是很少有技术银弹的说法。\n而在整个互联网领域,幂等不仅仅适用于消息队列的重复消费问题,这些实现幂等的方法,也同样适用于,在其他场景中来解决重复请求或者重复调用的问题 。比如将 HTTP 服务设计成幂等的,解决前端或者 APP 重复提交表单数据的问题 ,也可以将一个微服务设计成幂等的,解决 RPC 框架自动重试导致的 重复调用问题 。\nRocketMQ 如何实现分布式事务? # 如何解释分布式事务呢?事务大家都知道吧?要么都执行要么都不执行 。在同一个系统中我们可以轻松地实现事务,但是在分布式架构中,我们有很多服务是部署在不同系统之间的,而不同服务之间又需要进行调用。比如此时我下订单然后增加积分,如果保证不了分布式事务的话,就会出现 A 系统下了订单,但是 B 系统增加积分失败或者 A 系统没有下订单,B 系统却增加了积分。前者对用户不友好,后者对运营商不利,这是我们都不愿意见到的。\n那么,如何去解决这个问题呢?\n如今比较常见的分布式事务实现有 2PC、TCC 和事务消息(half 半消息机制)。每一种实现都有其特定的使用场景,但是也有各自的问题,都不是完美的解决方案。\n在 RocketMQ 中使用的是 事务消息加上事务反查机制 来解决分布式事务问题的。我画了张图,大家可以对照着图进行理解。\n在第一步发送的 half 消息 ,它的意思是 在事务提交之前,对于消费者来说,这个消息是不可见的 。\n那么,如何做到写入消息但是对用户不可见呢?RocketMQ 事务消息的做法是:如果消息是 half 消息,将备份原消息的主题与消息消费队列,然后 改变主题 为 RMQ_SYS_TRANS_HALF_TOPIC。由于消费组未订阅该主题,故消费端无法消费 half 类型的消息,然后 RocketMQ 会开启一个定时任务,从 Topic 为 RMQ_SYS_TRANS_HALF_TOPIC 中拉取消息进行消费,根据生产者组获取一个服务提供者发送回查事务状态请求,根据事务状态来决定是提交或回滚消息。\n你可以试想一下,如果没有从第 5 步开始的 事务反查机制 ,如果出现网路波动第 4 步没有发送成功,这样就会产生 MQ 不知道是不是需要给消费者消费的问题,他就像一个无头苍蝇一样。在 RocketMQ 中就是使用的上述的事务反查来解决的,而在 Kafka 中通常是直接抛出一个异常让用户来自行解决。\n你还需要注意的是,在 MQ Server 指向系统 B 的操作已经和系统 A 不相关了,也就是说在消息队列中的分布式事务是——本地事务和存储消息到消息队列才是同一个事务。这样也就产生了事务的最终一致性,因为整个过程是异步的,每个系统只要保证它自己那一部分的事务就行了。\n实践中会遇到的问题:事务消息需要一个事务监听器来监听本地事务是否成功,并且事务监听器接口只允许被实现一次。那就意味着需要把各种事务消息的本地事务都写在一个接口方法里面,必将会产生大量的耦合和类型判断。采用函数 Function 接口来包装整个业务过程,作为一个参数传递到监听器的接口方法中。再调用 Function 的 apply() 方法来执行业务,事务也会在 apply() 方法中执行。让监听器与业务之间实现解耦,使之具备了真实生产环境中的可行性。\n1.模拟一个添加用户浏览记录的需求\n@PostMapping(\u0026#34;/add\u0026#34;) @ApiOperation(\u0026#34;添加用户浏览记录\u0026#34;) public Result\u0026lt;TransactionSendResult\u0026gt; add(Long userId, Long forecastLogId) { // 函数式编程:浏览记录入库 Function\u0026lt;String, Boolean\u0026gt; function = transactionId -\u0026gt; viewHistoryHandler.addViewHistory(transactionId, userId, forecastLogId); Map\u0026lt;String, Long\u0026gt; hashMap = new HashMap\u0026lt;\u0026gt;(); hashMap.put(\u0026#34;userId\u0026#34;, userId); hashMap.put(\u0026#34;forecastLogId\u0026#34;, forecastLogId); String jsonString = JSON.toJSONString(hashMap); // 发送事务消息;将本地的事务操作,用函数Function接口接收,作为一个参数传入到方法中 TransactionSendResult transactionSendResult = mqProducerService.sendTransactionMessage(jsonString, MQDestination.TAG_ADD_VIEW_HISTORY, function); return Result.success(transactionSendResult); } 2.发送事务消息的方法\n/** * 发送事务消息 * * @param msgBody * @param tag * @param function * @return */ public TransactionSendResult sendTransactionMessage(String msgBody, String tag, Function\u0026lt;String, Boolean\u0026gt; function) { // 构建消息体 Message\u0026lt;String\u0026gt; message = buildMessage(msgBody); // 构建消息投递信息 String destination = buildDestination(tag); TransactionSendResult result = rocketMQTemplate.sendMessageInTransaction(destination, message, function); return result; } 3.生产者消息监听器,只允许一个类去实现该监听器\n@Slf4j @RocketMQTransactionListener public class TransactionMsgListener implements RocketMQLocalTransactionListener { @Autowired private RedisService redisService; /** * 执行本地事务(在发送消息成功时执行) * * @param message * @param o * @return commit or rollback or unknown */ @Override public RocketMQLocalTransactionState executeLocalTransaction(Message message, Object o) { // 1、获取事务ID String transactionId = null; try { transactionId = message.getHeaders().get(\u0026#34;rocketmq_TRANSACTION_ID\u0026#34;).toString(); // 2、判断传入函数对象是否为空,如果为空代表没有要执行的业务直接抛弃消息 if (o == null) { //返回ROLLBACK状态的消息会被丢弃 log.info(\u0026#34;事务消息回滚,没有需要处理的业务 transactionId={}\u0026#34;, transactionId); return RocketMQLocalTransactionState.ROLLBACK; } // 将Object o转换成Function对象 Function\u0026lt;String, Boolean\u0026gt; function = (Function\u0026lt;String, Boolean\u0026gt;) o; // 执行业务 事务也会在function.apply中执行 Boolean apply = function.apply(transactionId); if (apply) { log.info(\u0026#34;事务提交,消息正常处理 transactionId={}\u0026#34;, transactionId); //返回COMMIT状态的消息会立即被消费者消费到 return RocketMQLocalTransactionState.COMMIT; } } catch (Exception e) { log.info(\u0026#34;出现异常 返回ROLLBACK transactionId={}\u0026#34;, transactionId); return RocketMQLocalTransactionState.ROLLBACK; } return RocketMQLocalTransactionState.ROLLBACK; } /** * 事务回查机制,检查本地事务的状态 * * @param message * @return */ @Override public RocketMQLocalTransactionState checkLocalTransaction(Message message) { String transactionId = message.getHeaders().get(\u0026#34;rocketmq_TRANSACTION_ID\u0026#34;).toString(); // 查redis MqTransaction mqTransaction = redisService.getCacheObject(\u0026#34;mqTransaction:\u0026#34; + transactionId); if (Objects.isNull(mqTransaction)) { return RocketMQLocalTransactionState.ROLLBACK; } return RocketMQLocalTransactionState.COMMIT; } } 4.模拟的业务场景,这里的方法必须提取出来,放在别的类里面.如果调用方与被调用方在同一个类中,会发生事务失效的问题.\n@Component public class ViewHistoryHandler { @Autowired private IViewHistoryService viewHistoryService; @Autowired private IMqTransactionService mqTransactionService; @Autowired private RedisService redisService; /** * 浏览记录入库 * * @param transactionId * @param userId * @param forecastLogId * @return */ @Transactional public Boolean addViewHistory(String transactionId, Long userId, Long forecastLogId) { // 构建浏览记录 ViewHistory viewHistory = new ViewHistory(); viewHistory.setUserId(userId); viewHistory.setForecastLogId(forecastLogId); viewHistory.setCreateTime(LocalDateTime.now()); boolean save = viewHistoryService.save(viewHistory); // 本地事务信息 MqTransaction mqTransaction = new MqTransaction(); mqTransaction.setTransactionId(transactionId); mqTransaction.setCreateTime(new Date()); mqTransaction.setStatus(MqTransaction.StatusEnum.VALID.getStatus()); // 1.可以把事务信息存数据库 mqTransactionService.save(mqTransaction); // 2.也可以选择存redis,4个小时有效期,\u0026#39;4个小时\u0026#39;是RocketMQ内置的最大回查超时时长,过期未确认将强制回滚 redisService.setCacheObject(\u0026#34;mqTransaction:\u0026#34; + transactionId, mqTransaction, 4L, TimeUnit.HOURS); // 放开注释,模拟异常,事务回滚 // int i = 10 / 0; return save; } } 5.消费消息,以及幂等处理\n@Service @RocketMQMessageListener(topic = MQDestination.TOPIC, selectorExpression = MQDestination.TAG_ADD_VIEW_HISTORY, consumerGroup = MQDestination.TAG_ADD_VIEW_HISTORY) public class ConsumerAddViewHistory implements RocketMQListener\u0026lt;Message\u0026gt; { // 监听到消息就会执行此方法 @Override public void onMessage(Message message) { // 幂等校验 String transactionId = message.getTransactionId(); // 查redis MqTransaction mqTransaction = redisService.getCacheObject(\u0026#34;mqTransaction:\u0026#34; + transactionId); // 不存在事务记录 if (Objects.isNull(mqTransaction)) { return; } // 已消费 if (Objects.equals(mqTransaction.getStatus(), MqTransaction.StatusEnum.CONSUMED.getStatus())) { return; } String msg = new String(message.getBody()); Map\u0026lt;String, Long\u0026gt; map = JSON.parseObject(msg, new TypeReference\u0026lt;HashMap\u0026lt;String, Long\u0026gt;\u0026gt;() { }); Long userId = map.get(\u0026#34;userId\u0026#34;); Long forecastLogId = map.get(\u0026#34;forecastLogId\u0026#34;); // 下游的业务处理 // TODO 记录用户喜好,更新用户画像 // TODO 更新\u0026#39;证券预测文章\u0026#39;的浏览量,重新计算文章的曝光排序 // 更新状态为已消费 mqTransaction.setUpdateTime(new Date()); mqTransaction.setStatus(MqTransaction.StatusEnum.CONSUMED.getStatus()); redisService.setCacheObject(\u0026#34;mqTransaction:\u0026#34; + transactionId, mqTransaction, 4L, TimeUnit.HOURS); log.info(\u0026#34;监听到消息:msg={}\u0026#34;, JSON.toJSONString(map)); } } 如何解决消息堆积问题? # 在上面我们提到了消息队列一个很重要的功能——削峰 。那么如果这个峰值太大了导致消息堆积在队列中怎么办呢?\n其实这个问题可以将它广义化,因为产生消息堆积的根源其实就只有两个——生产者生产太快或者消费者消费太慢。\n我们可以从多个角度去思考解决这个问题,当流量到峰值的时候是因为生产者生产太快,我们可以使用一些 限流降级 的方法,当然你也可以增加多个消费者实例去水平扩展增加消费能力来匹配生产的激增。如果消费者消费过慢的话,我们可以先检查 是否是消费者出现了大量的消费错误 ,或者打印一下日志查看是否是哪一个线程卡死,出现了锁资源不释放等等的问题。\n当然,最快速解决消息堆积问题的方法还是增加消费者实例,不过 同时你还需要增加每个主题的队列数量 。\n别忘了在 RocketMQ 中,一个队列只会被一个消费者消费 ,如果你仅仅是增加消费者实例就会出现我一开始给你画架构图的那种情况。\n什么是回溯消费? # 回溯消费是指 Consumer 已经消费成功的消息,由于业务上需求需要重新消费,在RocketMQ 中, Broker 在向Consumer 投递成功消息后,消息仍然需要保留 。并且重新消费一般是按照时间维度,例如由于 Consumer 系统故障,恢复后需要重新消费 1 小时前的数据,那么 Broker 要提供一种机制,可以按照时间维度来回退消费进度。RocketMQ 支持按照时间回溯消费,时间维度精确到毫秒。\n这是官方文档的解释,我直接照搬过来就当科普了 😁😁😁。\nRocketMQ 如何保证高性能读写 # 传统 IO 方式 # 传统的 IO 读写其实就是 read + write 的操作,整个过程会分为如下几步\n用户调用 read()方法,开始读取数据,此时发生一次上下文从用户态到内核态的切换,也就是图示的切换 1 将磁盘数据通过 DMA 拷贝到内核缓存区 将内核缓存区的数据拷贝到用户缓冲区,这样用户,也就是我们写的代码就能拿到文件的数据 read()方法返回,此时就会从内核态切换到用户态,也就是图示的切换 2 当我们拿到数据之后,就可以调用 write()方法,此时上下文会从用户态切换到内核态,即图示切换 3 CPU 将用户缓冲区的数据拷贝到 Socket 缓冲区 将 Socket 缓冲区数据拷贝至网卡 write()方法返回,上下文重新从内核态切换到用户态,即图示切换 4 整个过程发生了 4 次上下文切换和 4 次数据的拷贝,这在高并发场景下肯定会严重影响读写性能故引入了零拷贝技术\n零拷贝技术 # mmap # mmap(memory map)是一种内存映射文件的方法,即将一个文件或者其它对象映射到进程的地址空间,实现文件磁盘地址和进程虚拟地址空间中一段虚拟地址的一一对映关系。\n简单地说就是内核缓冲区和应用缓冲区共享,从而减少了从读缓冲区到用户缓冲区的一次 CPU 拷贝。基于此上述架构图可变为:\n基于 mmap IO 读写其实就变成 mmap + write 的操作,也就是用 mmap 替代传统 IO 中的 read 操作。\n当用户发起 mmap 调用的时候会发生上下文切换 1,进行内存映射,然后数据被拷贝到内核缓冲区,mmap 返回,发生上下文切换 2;随后用户调用 write,发生上下文切换 3,将内核缓冲区的数据拷贝到 Socket 缓冲区,write 返回,发生上下文切换 4。\n发生 4 次上下文切换和 3 次 IO 拷贝操作,在 Java 中的实现:\nFileChannel fileChannel = new RandomAccessFile(\u0026#34;test.txt\u0026#34;, \u0026#34;rw\u0026#34;).getChannel(); MappedByteBuffer mappedByteBuffer = fileChannel.map(FileChannel.MapMode.READ_WRITE, 0, fileChannel.size()); sendfile # sendfile()跟 mmap()一样,也会减少一次 CPU 拷贝,但是它同时也会减少两次上下文切换。\n如图,用户在发起 sendfile()调用时会发生切换 1,之后数据通过 DMA 拷贝到内核缓冲区,之后再将内核缓冲区的数据 CPU 拷贝到 Socket 缓冲区,最后拷贝到网卡,sendfile()返回,发生切换 2。发生了 3 次拷贝和两次切换。Java 也提供了相应 api:\nFileChannel channel = FileChannel.open(Paths.get(\u0026#34;./test.txt\u0026#34;), StandardOpenOption.WRITE, StandardOpenOption.CREATE); //调用transferTo方法向目标数据传输 channel.transferTo(position, len, target); 在如上代码中,并没有文件的读写操作,而是直接将文件的数据传输到 target 目标缓冲区,也就是说,sendfile 是无法知道文件的具体的数据的;但是 mmap 不一样,他是可以修改内核缓冲区的数据的。假设如果需要对文件的内容进行修改之后再传输,只有 mmap 可以满足。\n通过上面的一些介绍,结论是基于零拷贝技术,可以减少 CPU 的拷贝次数和上下文切换次数,从而可以实现文件高效的读写操作。\nRocketMQ 内部主要是使用基于 mmap 实现的零拷贝(其实就是调用上述提到的 api),用来读写文件,这也是 RocketMQ 为什么快的一个很重要原因。\nRocketMQ 的刷盘机制 # 上面我讲了那么多的 RocketMQ 的架构和设计原理,你有没有好奇\n在 Topic 中的 队列是以什么样的形式存在的?\n队列中的消息又是如何进行存储持久化的呢?\n我在上文中提到的 同步刷盘 和 异步刷盘 又是什么呢?它们会给持久化带来什么样的影响呢?\n下面我将给你们一一解释。\n同步刷盘和异步刷盘 # 如上图所示,在同步刷盘中需要等待一个刷盘成功的 ACK ,同步刷盘对 MQ 消息可靠性来说是一种不错的保障,但是 性能上会有较大影响 ,一般地适用于金融等特定业务场景。\n而异步刷盘往往是开启一个线程去异步地执行刷盘操作。消息刷盘采用后台异步线程提交的方式进行, 降低了读写延迟 ,提高了 MQ 的性能和吞吐量,一般适用于如发验证码等对于消息保证要求不太高的业务场景。\n一般地,异步刷盘只有在 Broker 意外宕机的时候会丢失部分数据,你可以设置 Broker 的参数 FlushDiskType 来调整你的刷盘策略(ASYNC_FLUSH 或者 SYNC_FLUSH)。\n同步复制和异步复制 # 上面的同步刷盘和异步刷盘是在单个结点层面的,而同步复制和异步复制主要是指的 Borker 主从模式下,主节点返回消息给客户端的时候是否需要同步从节点。\n同步复制:也叫 “同步双写”,也就是说,只有消息同步双写到主从节点上时才返回写入成功 。 异步复制:消息写入主节点之后就直接返回写入成功 。 然而,很多事情是没有完美的方案的,就比如我们进行消息写入的节点越多就更能保证消息的可靠性,但是随之的性能也会下降,所以需要程序员根据特定业务场景去选择适应的主从复制方案。\n那么,异步复制会不会也像异步刷盘那样影响消息的可靠性呢?\n答案是不会的,因为两者就是不同的概念,对于消息可靠性是通过不同的刷盘策略保证的,而像异步同步复制策略仅仅是影响到了 可用性 。为什么呢?其主要原因是 RocketMQ 是不支持自动主从切换的,当主节点挂掉之后,生产者就不能再给这个主节点生产消息了。\n比如这个时候采用异步复制的方式,在主节点还未发送完需要同步的消息的时候主节点挂掉了,这个时候从节点就少了一部分消息。但是此时生产者无法再给主节点生产消息了,消费者可以自动切换到从节点进行消费(仅仅是消费),所以在主节点挂掉的时间只会产生主从结点短暂的消息不一致的情况,降低了可用性,而当主节点重启之后,从节点那部分未来得及复制的消息还会继续复制。\n在单主从架构中,如果一个主节点挂掉了,那么也就意味着整个系统不能再生产了。那么这个可用性的问题能否解决呢?一个主从不行那就多个主从的呗,别忘了在我们最初的架构图中,每个 Topic 是分布在不同 Broker 中的。\n但是这种复制方式同样也会带来一个问题,那就是无法保证 严格顺序 。在上文中我们提到了如何保证的消息顺序性是通过将一个语义的消息发送在同一个队列中,使用 Topic 下的队列来保证顺序性的。如果此时我们主节点 A 负责的是订单 A 的一系列语义消息,然后它挂了,这样其他节点是无法代替主节点 A 的,如果我们任意节点都可以存入任何消息,那就没有顺序性可言了。\n而在 RocketMQ 中采用了 Dledger 解决这个问题。他要求在写入消息的时候,要求至少消息复制到半数以上的节点之后,才给客⼾端返回写⼊成功,并且它是⽀持通过选举来动态切换主节点的。这里我就不展开说明了,读者可以自己去了解。\n也不是说 Dledger 是个完美的方案,至少在 Dledger 选举过程中是无法提供服务的,而且他必须要使用三个节点或以上,如果多数节点同时挂掉他也是无法保证可用性的,而且要求消息复制半数以上节点的效率和直接异步复制还是有一定的差距的。\n存储机制 # 还记得上面我们一开始的三个问题吗?到这里第三个问题已经解决了。\n但是,在 Topic 中的 队列是以什么样的形式存在的?队列中的消息又是如何进行存储持久化的呢? 还未解决,其实这里涉及到了 RocketMQ 是如何设计它的存储结构了。我首先想大家介绍 RocketMQ 消息存储架构中的三大角色——CommitLog、ConsumeQueue 和 IndexFile 。\nCommitLog:消息主体以及元数据的存储主体,存储 Producer 端写入的消息主体内容,消息内容不是定长的。单个文件大小默认 1G ,文件名长度为 20 位,左边补零,剩余为起始偏移量,比如 00000000000000000000 代表了第一个文件,起始偏移量为 0,文件大小为 1G=1073741824;当第一个文件写满了,第二个文件为 00000000001073741824,起始偏移量为 1073741824,以此类推。消息主要是顺序写入日志文件,当文件满了,写入下一个文件。 ConsumeQueue:消息消费队列,引入的目的主要是提高消息消费的性能(我们再前面也讲了),由于RocketMQ 是基于主题 Topic 的订阅模式,消息消费是针对主题进行的,如果要遍历 commitlog 文件中根据 Topic 检索消息是非常低效的。Consumer 即可根据 ConsumeQueue 来查找待消费的消息。其中,ConsumeQueue(逻辑消费队列)作为消费消息的索引,保存了指定 Topic 下的队列消息在 CommitLog 中的起始物理偏移量 offset ,消息大小 size 和消息 Tag 的 HashCode 值。consumequeue 文件可以看成是基于 topic 的 commitlog 索引文件,故 consumequeue 文件夹的组织方式如下:topic/queue/file 三层组织结构,具体存储路径为:$HOME/store/consumequeue/{topic}/{queueId}/{fileName}。同样 consumequeue 文件采取定长设计,每一个条目共 20 个字节,分别为 8 字节的 commitlog 物理偏移量、4 字节的消息长度、8 字节 tag hashcode,单个文件由 30W 个条目组成,可以像数组一样随机访问每一个条目,每个 ConsumeQueue文件大小约 5.72M; IndexFile:IndexFile(索引文件)提供了一种可以通过 key 或时间区间来查询消息的方法。这里只做科普不做详细介绍。 总结来说,整个消息存储的结构,最主要的就是 CommitLoq 和 ConsumeQueue 。而 ConsumeQueue 你可以大概理解为 Topic 中的队列。\nRocketMQ 采用的是 混合型的存储结构 ,即为 Broker 单个实例下所有的队列共用一个日志数据文件来存储消息。有意思的是在同样高并发的 Kafka 中会为每个 Topic 分配一个存储文件。这就有点类似于我们有一大堆书需要装上书架,RocketMQ 是不分书的种类直接成批的塞上去的,而 Kafka 是将书本放入指定的分类区域的。\n而 RocketMQ 为什么要这么做呢?原因是 提高数据的写入效率 ,不分 Topic 意味着我们有更大的几率获取 成批 的消息进行数据写入,但也会带来一个麻烦就是读取消息的时候需要遍历整个大文件,这是非常耗时的。\n所以,在 RocketMQ 中又使用了 ConsumeQueue 作为每个队列的索引文件来 提升读取消息的效率。我们可以直接根据队列的消息序号,计算出索引的全局位置(索引序号*索引固定⻓度 20),然后直接读取这条索引,再根据索引中记录的消息的全局位置,找到消息。\n讲到这里,你可能对 RocketMQ 的存储架构还有些模糊,没事,我们结合着图来理解一下。\nemmm,是不是有一点复杂 🤣,看英文图片和英文文档的时候就不要怂,硬着头皮往下看就行。\n如果上面没看懂的读者一定要认真看下面的流程分析!\n首先,在最上面的那一块就是我刚刚讲的你现在可以直接 把 ConsumerQueue 理解为 Queue。\n在图中最左边说明了红色方块代表被写入的消息,虚线方块代表等待被写入的。左边的生产者发送消息会指定 Topic、QueueId 和具体消息内容,而在 Broker 中管你是哪门子消息,他直接 全部顺序存储到了 CommitLog。而根据生产者指定的 Topic 和 QueueId 将这条消息本身在 CommitLog 的偏移(offset),消息本身大小,和 tag 的 hash 值存入对应的 ConsumeQueue 索引文件中。而在每个队列中都保存了 ConsumeOffset 即每个消费者组的消费位置(我在架构那里提到了,忘了的同学可以回去看一下),而消费者拉取消息进行消费的时候只需要根据 ConsumeOffset 获取下一个未被消费的消息就行了。\n上述就是我对于整个消息存储架构的大概理解(这里不涉及到一些细节讨论,比如稀疏索引等等问题),希望对你有帮助。\n因为有一个知识点因为写嗨了忘讲了,想想在哪里加也不好,所以我留给大家去思考 🤔🤔 一下吧。\n为什么 CommitLog 文件要设计成固定大小的长度呢?提醒:内存映射机制。\n总结 # 总算把这篇博客写完了。我讲的你们还记得吗 😅?\n这篇文章中我主要想大家介绍了\n消息队列出现的原因 消息队列的作用(异步,解耦,削峰) 消息队列带来的一系列问题(消息堆积、重复消费、顺序消费、分布式事务等等) 消息队列的两种消息模型——队列和主题模式 分析了 RocketMQ 的技术架构(NameServer、Broker、Producer、Consumer) 结合 RocketMQ 回答了消息队列副作用的解决方案 介绍了 RocketMQ 的存储机制和刷盘策略。 等等。。。\n"},{"id":567,"href":"/zh/docs/technology/Interview/distributed-system/rpc/rpc-intro/","title":"RPC基础知识总结","section":"Rpc","content":"这篇文章会简单介绍一下 RPC 相关的基础概念。\nRPC 是什么? # RPC(Remote Procedure Call) 即远程过程调用,通过名字我们就能看出 RPC 关注的是远程调用而非本地调用。\n为什么要 RPC ? 因为,两个不同的服务器上的服务提供的方法不在一个内存空间,所以,需要通过网络编程才能传递方法调用所需要的参数。并且,方法调用的结果也需要通过网络编程来接收。但是,如果我们自己手动网络编程来实现这个调用过程的话工作量是非常大的,因为,我们需要考虑底层传输方式(TCP 还是 UDP)、序列化方式等等方面。\nRPC 能帮助我们做什么呢? 简单来说,通过 RPC 可以帮助我们调用远程计算机上某个服务的方法,这个过程就像调用本地方法一样简单。并且!我们不需要了解底层网络编程的具体细节。\n举个例子:两个不同的服务 A、B 部署在两台不同的机器上,服务 A 如果想要调用服务 B 中的某个方法的话就可以通过 RPC 来做。\n一言蔽之:RPC 的出现就是为了让你调用远程方法像调用本地方法一样简单。\nRPC 的原理是什么? # 为了能够帮助小伙伴们理解 RPC 原理,我们可以将整个 RPC 的 核心功能看作是下面 👇 5 个部分实现的:\n客户端(服务消费端):调用远程方法的一端。 客户端 Stub(桩):这其实就是一代理类。代理类主要做的事情很简单,就是把你调用方法、类、方法参数等信息传递到服务端。 网络传输:网络传输就是你要把你调用的方法的信息比如说参数啊这些东西传输到服务端,然后服务端执行完之后再把返回结果通过网络传输给你传输回来。网络传输的实现方式有很多种比如最基本的 Socket 或者性能以及封装更加优秀的 Netty(推荐)。 服务端 Stub(桩):这个桩就不是代理类了。我觉得理解为桩实际不太好,大家注意一下就好。这里的服务端 Stub 实际指的就是接收到客户端执行方法的请求后,去执行对应的方法然后返回结果给客户端的类。 服务端(服务提供端):提供远程方法的一端。 具体原理图如下,后面我会串起来将整个 RPC 的过程给大家说一下。\n服务消费端(client)以本地调用的方式调用远程服务; 客户端 Stub(client stub) 接收到调用后负责将方法、参数等组装成能够进行网络传输的消息体(序列化):RpcRequest; 客户端 Stub(client stub) 找到远程服务的地址,并将消息发送到服务提供端; 服务端 Stub(桩)收到消息将消息反序列化为 Java 对象: RpcRequest; 服务端 Stub(桩)根据RpcRequest中的类、方法、方法参数等信息调用本地的方法; 服务端 Stub(桩)得到方法执行结果并将组装成能够进行网络传输的消息体:RpcResponse(序列化)发送至消费方; 客户端 Stub(client stub)接收到消息并将消息反序列化为 Java 对象:RpcResponse ,这样也就得到了最终结果。over! 相信小伙伴们看完上面的讲解之后,已经了解了 RPC 的原理。\n虽然篇幅不多,但是基本把 RPC 框架的核心原理讲清楚了!另外,对于上面的技术细节,我会在后面的章节介绍到。\n最后,对于 RPC 的原理,希望小伙伴不单单要理解,还要能够自己画出来并且能够给别人讲出来。因为,在面试中这个问题在面试官问到 RPC 相关内容的时候基本都会碰到。\n有哪些常见的 RPC 框架? # 我们这里说的 RPC 框架指的是可以让客户端直接调用服务端方法,就像调用本地方法一样简单的框架,比如我下面介绍的 Dubbo、Motan、gRPC 这些。 如果需要和 HTTP 协议打交道,解析和封装 HTTP 请求和响应。这类框架并不能算是“RPC 框架”,比如 Feign。\nDubbo # Apache Dubbo 是一款微服务框架,为大规模微服务实践提供高性能 RPC 通信、流量治理、可观测性等解决方案, 涵盖 Java、Golang 等多种语言 SDK 实现。\nDubbo 提供了从服务定义、服务发现、服务通信到流量管控等几乎所有的服务治理能力,支持 Triple 协议(基于 HTTP/2 之上定义的下一代 RPC 通信协议)、应用级服务发现、Dubbo Mesh (Dubbo3 赋予了很多云原生友好的新特性)等特性。\nDubbo 是由阿里开源,后来加入了 Apache 。正是由于 Dubbo 的出现,才使得越来越多的公司开始使用以及接受分布式架构。\nDubbo 算的是比较优秀的国产开源项目了,它的源码也是非常值得学习和阅读的!\nGitHub: https://github.com/apache/incubator-dubbo 官网: https://dubbo.apache.org/zh/ Motan # Motan 是新浪微博开源的一款 RPC 框架,据说在新浪微博正支撑着千亿次调用。不过笔者倒是很少看到有公司使用,而且网上的资料也比较少。\n很多人喜欢拿 Motan 和 Dubbo 作比较,毕竟都是国内大公司开源的。笔者在查阅了很多资料,以及简单查看了其源码之后发现:Motan 更像是一个精简版的 Dubbo,可能是借鉴了 Dubbo 的思想,Motan 的设计更加精简,功能更加纯粹。\n不过,我不推荐你在实际项目中使用 Motan。如果你要是公司实际使用的话,还是推荐 Dubbo ,其社区活跃度以及生态都要好很多。\n从 Motan 看 RPC 框架设计: http://kriszhang.com/motan-rpc-impl/ Motan 中文文档: https://github.com/weibocom/motan/wiki/zh_overview gRPC # gRPC 是 Google 开源的一个高性能、通用的开源 RPC 框架。其由主要面向移动应用开发并基于 HTTP/2 协议标准而设计(支持双向流、消息头压缩等功能,更加节省带宽),基于 ProtoBuf 序列化协议开发,并且支持众多开发语言。\n何谓 ProtoBuf? ProtoBuf( Protocol Buffer) 是一种更加灵活、高效的数据格式,可用于通讯协议、数据存储等领域,基本支持所有主流编程语言且与平台无关。不过,通过 ProtoBuf 定义接口和数据类型还挺繁琐的,这是一个小问题。\n不得不说,gRPC 的通信层的设计还是非常优秀的, Dubbo-go 3.0 的通信层改进主要借鉴了 gRPC。\n不过,gRPC 的设计导致其几乎没有服务治理能力。如果你想要解决这个问题的话,就需要依赖其他组件比如腾讯的 PolarisMesh(北极星)了。\nGitHub: https://github.com/grpc/grpc 官网: https://grpc.io/ Thrift # Apache Thrift 是 Facebook 开源的跨语言的 RPC 通信框架,目前已经捐献给 Apache 基金会管理,由于其跨语言特性和出色的性能,在很多互联网公司得到应用,有能力的公司甚至会基于 thrift 研发一套分布式服务框架,增加诸如服务注册、服务发现等功能。\nThrift支持多种不同的编程语言,包括C++、Java、Python、PHP、Ruby等(相比于 gRPC 支持的语言更多 )。\n官网: https://thrift.apache.org/ Thrift 简单介绍: https://www.jianshu.com/p/8f25d057a5a9 总结 # gRPC 和 Thrift 虽然支持跨语言的 RPC 调用,但是它们只提供了最基本的 RPC 框架功能,缺乏一系列配套的服务化组件和服务治理功能的支撑。\nDubbo 不论是从功能完善程度、生态系统还是社区活跃度来说都是最优秀的。而且,Dubbo 在国内有很多成功的案例比如当当网、滴滴等等,是一款经得起生产考验的成熟稳定的 RPC 框架。最重要的是你还能找到非常多的 Dubbo 参考资料,学习成本相对也较低。\n下图展示了 Dubbo 的生态系统。\nDubbo 也是 Spring Cloud Alibaba 里面的一个组件。\n但是,Dubbo 和 Motan 主要是给 Java 语言使用。虽然,Dubbo 和 Motan 目前也能兼容部分语言,但是不太推荐。如果需要跨多种语言调用的话,可以考虑使用 gRPC。\n综上,如果是 Java 后端技术栈,并且你在纠结选择哪一种 RPC 框架的话,我推荐你考虑一下 Dubbo。\n如何设计并实现一个 RPC 框架? # 《手写 RPC 框架》 是我的 知识星球的一个内部小册,我写了 12 篇文章来讲解如何从零开始基于 Netty+Kyro+Zookeeper 实现一个简易的 RPC 框架。\n麻雀虽小五脏俱全,项目代码注释详细,结构清晰,并且集成了 Check Style 规范代码结构,非常适合阅读和学习。\n内容概览:\n既然有了 HTTP 协议,为什么还要有 RPC ? # 关于这个问题的详细答案,请看这篇文章: 有了 HTTP 协议,为什么还要有 RPC ? 。\n"},{"id":568,"href":"/zh/docs/technology/Interview/cs-basics/operating-system/shell-intro/","title":"Shell 编程基础知识总结","section":"Operating System","content":"Shell 编程在我们的日常开发工作中非常实用,目前 Linux 系统下最流行的运维自动化语言就是 Shell 和 Python 了。\n这篇文章我会简单总结一下 Shell 编程基础知识,带你入门 Shell 编程!\n走进 Shell 编程的大门 # 为什么要学 Shell? # 学一个东西,我们大部分情况都是往实用性方向着想。从工作角度来讲,学习 Shell 是为了提高我们自己工作效率,提高产出,让我们在更少的时间完成更多的事情。\n很多人会说 Shell 编程属于运维方面的知识了,应该是运维人员来做,我们做后端开发的没必要学。我觉得这种说法大错特错,相比于专门做 Linux 运维的人员来说,我们对 Shell 编程掌握程度的要求要比他们低,但是 Shell 编程也是我们必须要掌握的!\n目前 Linux 系统下最流行的运维自动化语言就是 Shell 和 Python 了。\n两者之间,Shell 几乎是 IT 企业必须使用的运维自动化编程语言,特别是在运维工作中的服务监控、业务快速部署、服务启动停止、数据备份及处理、日志分析等环节里,shell 是不可缺的。Python 更适合处理复杂的业务逻辑,以及开发复杂的运维软件工具,实现通过 web 访问等。Shell 是一个命令解释器,解释执行用户所输入的命令和程序。一输入命令,就立即回应的交互的对话方式。\n另外,了解 shell 编程也是大部分互联网公司招聘后端开发人员的要求。下图是我截取的一些知名互联网公司对于 Shell 编程的要求。\n什么是 Shell? # 简单来说“Shell 编程就是对一堆 Linux 命令的逻辑化处理”。\nW3Cschool 上的一篇文章是这样介绍 Shell 的,如下图所示。 Shell 编程的 Hello World # 学习任何一门编程语言第一件事就是输出 HelloWorld 了!下面我会从新建文件到 shell 代码编写来说下 Shell 编程如何输出 Hello World。\n(1)新建一个文件 helloworld.sh :touch helloworld.sh,扩展名为 sh(sh 代表 Shell)(扩展名并不影响脚本执行,见名知意就好,如果你用 php 写 shell 脚本,扩展名就用 php 好了)\n(2) 使脚本具有执行权限:chmod +x helloworld.sh\n(3) 使用 vim 命令修改 helloworld.sh 文件:vim helloworld.sh(vim 文件\u0026mdash;\u0026mdash;\u0026gt;进入文件\u0026mdash;\u0026ndash;\u0026gt;命令模式\u0026mdash;\u0026mdash;\u0026gt;按 i 进入编辑模式\u0026mdash;\u0026ndash;\u0026gt;编辑文件 \u0026mdash;\u0026mdash;-\u0026gt;按 Esc 进入底行模式\u0026mdash;\u0026ndash;\u0026gt;输入:wq/q! (输入 wq 代表写入内容并退出,即保存;输入 q!代表强制退出不保存。))\nhelloworld.sh 内容如下:\n#!/bin/bash #第一个shell小程序,echo 是linux中的输出命令。 echo \u0026#34;helloworld!\u0026#34; shell 中 # 符号表示注释。shell 的第一行比较特殊,一般都会以#!开始来指定使用的 shell 类型。在 linux 中,除了 bash shell 以外,还有很多版本的 shell, 例如 zsh、dash 等等\u0026hellip;不过 bash shell 还是我们使用最多的。\n(4) 运行脚本:./helloworld.sh 。(注意,一定要写成 ./helloworld.sh ,而不是 helloworld.sh ,运行其它二进制的程序也一样,直接写 helloworld.sh ,linux 系统会去 PATH 里寻找有没有叫 helloworld.sh 的,而只有 /bin, /sbin, /usr/bin,/usr/sbin 等在 PATH 里,你的当前目录通常不在 PATH 里,所以写成 helloworld.sh 是会找不到命令的,要用./helloworld.sh 告诉系统说,就在当前目录找。)\nShell 变量 # Shell 编程中的变量介绍 # Shell 编程中一般分为三种变量:\n我们自己定义的变量(自定义变量): 仅在当前 Shell 实例中有效,其他 Shell 启动的程序不能访问局部变量。 Linux 已定义的环境变量(环境变量, 例如:PATH, ​HOME 等\u0026hellip;, 这类变量我们可以直接使用),使用 env 命令可以查看所有的环境变量,而 set 命令既可以查看环境变量也可以查看自定义变量。 Shell 变量:Shell 变量是由 Shell 程序设置的特殊变量。Shell 变量中有一部分是环境变量,有一部分是局部变量,这些变量保证了 Shell 的正常运行 常用的环境变量:\nPATH 决定了 shell 将到哪些目录中寻找命令或程序\nHOME 当前用户主目录\nHISTSIZE 历史记录数\nLOGNAME 当前用户的登录名\nHOSTNAME 指主机的名称\nSHELL 当前用户 Shell 类型\nLANGUAGE 语言相关的环境变量,多语言可以修改此环境变量\nMAIL 当前用户的邮件存放目录\nPS1 基本提示符,对于 root 用户是#,对于普通用户是$\n使用 Linux 已定义的环境变量:\n比如我们要看当前用户目录可以使用:echo $HOME命令;如果我们要看当前用户 Shell 类型 可以使用echo $SHELL命令。可以看出,使用方法非常简单。\n使用自己定义的变量:\n#!/bin/bash #自定义变量hello hello=\u0026#34;hello world\u0026#34; echo $hello echo \u0026#34;helloworld!\u0026#34; Shell 编程中的变量名的命名的注意事项:\n命名只能使用英文字母,数字和下划线,首个字符不能以数字开头,但是可以使用下划线(_)开头。 中间不能有空格,可以使用下划线(_)。 不能使用标点符号。 不能使用 bash 里的关键字(可用 help 命令查看保留关键字)。 Shell 字符串入门 # 字符串是 shell 编程中最常用最有用的数据类型(除了数字和字符串,也没啥其它类型好用了),字符串可以用单引号,也可以用双引号。这点和 Java 中有所不同。\n在单引号中所有的特殊符号,如$和反引号都没有特殊含义。在双引号中,除了\u0026quot;$\u0026quot;、\u0026quot;\\\u0026quot;、反引号和感叹号(需开启 history expansion),其他的字符没有特殊含义。\n单引号字符串:\n#!/bin/bash name=\u0026#39;SnailClimb\u0026#39; hello=\u0026#39;Hello, I am $name!\u0026#39; echo $hello 输出内容:\nHello, I am $name! 双引号字符串:\n#!/bin/bash name=\u0026#39;SnailClimb\u0026#39; hello=\u0026#34;Hello, I am $name!\u0026#34; echo $hello 输出内容:\nHello, I am SnailClimb! Shell 字符串常见操作 # 拼接字符串:\n#!/bin/bash name=\u0026#34;SnailClimb\u0026#34; # 使用双引号拼接 greeting=\u0026#34;hello, \u0026#34;$name\u0026#34; !\u0026#34; greeting_1=\u0026#34;hello, ${name} !\u0026#34; echo $greeting $greeting_1 # 使用单引号拼接 greeting_2=\u0026#39;hello, \u0026#39;$name\u0026#39; !\u0026#39; greeting_3=\u0026#39;hello, ${name} !\u0026#39; echo $greeting_2 $greeting_3 输出结果:\n获取字符串长度:\n#!/bin/bash #获取字符串长度 name=\u0026#34;SnailClimb\u0026#34; # 第一种方式 echo ${#name} #输出 10 # 第二种方式 expr length \u0026#34;$name\u0026#34;; 输出结果:\n10 10 使用 expr 命令时,表达式中的运算符左右必须包含空格,如果不包含空格,将会输出表达式本身:\nexpr 5+6 // 直接输出 5+6 expr 5 + 6 // 输出 11 对于某些运算符,还需要我们使用符号\\进行转义,否则就会提示语法错误。\nexpr 5 * 6 // 输出错误 expr 5 \\* 6 // 输出30 截取子字符串:\n简单的字符串截取:\n#从字符串第 1 个字符开始往后截取 10 个字符 str=\u0026#34;SnailClimb is a great man\u0026#34; echo ${str:0:10} #输出:SnailClimb 根据表达式截取:\n#!bin/bash #author:amau var=\u0026#34;https://www.runoob.com/linux/linux-shell-variable.html\u0026#34; # %表示删除从后匹配, 最短结果 # %%表示删除从后匹配, 最长匹配结果 # #表示删除从头匹配, 最短结果 # ##表示删除从头匹配, 最长匹配结果 # 注: *为通配符, 意为匹配任意数量的任意字符 s1=${var%%t*} #h s2=${var%t*} #https://www.runoob.com/linux/linux-shell-variable.h s3=${var%%.*} #https://www s4=${var#*/} #/www.runoob.com/linux/linux-shell-variable.html s5=${var##*/} #linux-shell-variable.html Shell 数组 # bash 支持一维数组(不支持多维数组),并且没有限定数组的大小。我下面给了大家一个关于数组操作的 Shell 代码示例,通过该示例大家可以知道如何创建数组、获取数组长度、获取/删除特定位置的数组元素、删除整个数组以及遍历数组。\n#!/bin/bash array=(1 2 3 4 5); # 获取数组长度 length=${#array[@]} # 或者 length2=${#array[*]} #输出数组长度 echo $length #输出:5 echo $length2 #输出:5 # 输出数组第三个元素 echo ${array[2]} #输出:3 unset array[1]# 删除下标为1的元素也就是删除第二个元素 for i in ${array[@]};do echo $i ;done # 遍历数组,输出:1 3 4 5 unset array; # 删除数组中的所有元素 for i in ${array[@]};do echo $i ;done # 遍历数组,数组元素为空,没有任何输出内容 Shell 基本运算符 # 说明:图片来自《菜鸟教程》\nShell 编程支持下面几种运算符\n算数运算符 关系运算符 布尔运算符 字符串运算符 文件测试运算符 算数运算符 # 我以加法运算符做一个简单的示例(注意:不是单引号,是反引号):\n#!/bin/bash a=3;b=3; val=`expr $a + $b` #输出:Total value : 6 echo \u0026#34;Total value : $val\u0026#34; 关系运算符 # 关系运算符只支持数字,不支持字符串,除非字符串的值是数字。\n通过一个简单的示例演示关系运算符的使用,下面 shell 程序的作用是当 score=100 的时候输出 A 否则输出 B。\n#!/bin/bash score=90; maxscore=100; if [ $score -eq $maxscore ] then echo \u0026#34;A\u0026#34; else echo \u0026#34;B\u0026#34; fi 输出结果:\nB 逻辑运算符 # 示例:\n#!/bin/bash a=$(( 1 \u0026amp;\u0026amp; 0)) # 输出:0;逻辑与运算只有相与的两边都是1,与的结果才是1;否则与的结果是0 echo $a; 布尔运算符 # 这里就不做演示了,应该挺简单的。\n字符串运算符 # 简单示例:\n#!/bin/bash a=\u0026#34;abc\u0026#34;; b=\u0026#34;efg\u0026#34;; if [ $a = $b ] then echo \u0026#34;a 等于 b\u0026#34; else echo \u0026#34;a 不等于 b\u0026#34; fi 输出:\na 不等于 b 文件相关运算符 # 使用方式很简单,比如我们定义好了一个文件路径file=\u0026quot;/usr/learnshell/test.sh\u0026quot; 如果我们想判断这个文件是否可读,可以这样if [ -r $file ] 如果想判断这个文件是否可写,可以这样-w $file,是不是很简单。\nShell 流程控制 # if 条件语句 # 简单的 if else-if else 的条件语句示例\n#!/bin/bash a=3; b=9; if [ $a -eq $b ] then echo \u0026#34;a 等于 b\u0026#34; elif [ $a -gt $b ] then echo \u0026#34;a 大于 b\u0026#34; else echo \u0026#34;a 小于 b\u0026#34; fi 输出结果:\na 小于 b 相信大家通过上面的示例就已经掌握了 shell 编程中的 if 条件语句。不过,还要提到的一点是,不同于我们常见的 Java 以及 PHP 中的 if 条件语句,shell if 条件语句中不能包含空语句也就是什么都不做的语句。\nfor 循环语句 # 通过下面三个简单的示例认识 for 循环语句最基本的使用,实际上 for 循环语句的功能比下面你看到的示例展现的要大得多。\n输出当前列表中的数据:\nfor loop in 1 2 3 4 5 do echo \u0026#34;The value is: $loop\u0026#34; done 产生 10 个随机数:\n#!/bin/bash for i in {0..9}; do echo $RANDOM; done 输出 1 到 5:\n通常情况下 shell 变量调用需要加 $,但是 for 的 (()) 中不需要,下面来看一个例子:\n#!/bin/bash length=5 for((i=1;i\u0026lt;=length;i++));do echo $i; done; while 语句 # 基本的 while 循环语句:\n#!/bin/bash int=1 while(( $int\u0026lt;=5 )) do echo $int let \u0026#34;int++\u0026#34; done while 循环可用于读取键盘信息:\necho \u0026#39;按下 \u0026lt;CTRL-D\u0026gt; 退出\u0026#39; echo -n \u0026#39;输入你最喜欢的电影: \u0026#39; while read FILM do echo \u0026#34;是的!$FILM 是一个好电影\u0026#34; done 输出内容:\n按下 \u0026lt;CTRL-D\u0026gt; 退出 输入你最喜欢的电影: 变形金刚 是的!变形金刚 是一个好电影 无限循环:\nwhile true do command done Shell 函数 # 不带参数没有返回值的函数 # #!/bin/bash hello(){ echo \u0026#34;这是我的第一个 shell 函数!\u0026#34; } echo \u0026#34;-----函数开始执行-----\u0026#34; hello echo \u0026#34;-----函数执行完毕-----\u0026#34; 输出结果:\n-----函数开始执行----- 这是我的第一个 shell 函数! -----函数执行完毕----- 有返回值的函数 # 输入两个数字之后相加并返回结果:\n#!/bin/bash funWithReturn(){ echo \u0026#34;输入第一个数字: \u0026#34; read aNum echo \u0026#34;输入第二个数字: \u0026#34; read anotherNum echo \u0026#34;两个数字分别为 $aNum 和 $anotherNum !\u0026#34; return $(($aNum+$anotherNum)) } funWithReturn echo \u0026#34;输入的两个数字之和为 $?\u0026#34; 输出结果:\n输入第一个数字: 1 输入第二个数字: 2 两个数字分别为 1 和 2 ! 输入的两个数字之和为 3 带参数的函数 # #!/bin/bash funWithParam(){ echo \u0026#34;第一个参数为 $1 !\u0026#34; echo \u0026#34;第二个参数为 $2 !\u0026#34; echo \u0026#34;第十个参数为 $10 !\u0026#34; echo \u0026#34;第十个参数为 ${10} !\u0026#34; echo \u0026#34;第十一个参数为 ${11} !\u0026#34; echo \u0026#34;参数总数有 $# 个!\u0026#34; echo \u0026#34;作为一个字符串输出所有参数 $* !\u0026#34; } funWithParam 1 2 3 4 5 6 7 8 9 34 73 输出结果:\n第一个参数为 1 ! 第二个参数为 2 ! 第十个参数为 10 ! 第十个参数为 34 ! 第十一个参数为 73 ! 参数总数有 11 个! 作为一个字符串输出所有参数 1 2 3 4 5 6 7 8 9 34 73 ! "},{"id":569,"href":"/zh/docs/technology/Interview/system-design/framework/spring/springboot-source-code/","title":"Spring Boot核心源码解读(付费)","section":"Framework","content":"Spring Boot 核心源码解读 为我的 知识星球(点击链接即可查看详细介绍以及加入方法)专属内容,已经整理到了 《Java 必读源码系列》中。\n"},{"id":570,"href":"/zh/docs/technology/Interview/distributed-system/spring-cloud-gateway-questions/","title":"Spring Cloud Gateway常见问题总结","section":"Distributed System","content":" 本文重构完善自 6000 字 | 16 图 | 深入理解 Spring Cloud Gateway 的原理 - 悟空聊架构这篇文章。\n什么是 Spring Cloud Gateway? # Spring Cloud Gateway 属于 Spring Cloud 生态系统中的网关,其诞生的目标是为了替代老牌网关 Zuul。准确点来说,应该是 Zuul 1.x。Spring Cloud Gateway 起步要比 Zuul 2.x 更早。\n为了提升网关的性能,Spring Cloud Gateway 基于 Spring WebFlux 。Spring WebFlux 使用 Reactor 库来实现响应式编程模型,底层基于 Netty 实现同步非阻塞的 I/O。\nSpring Cloud Gateway 不仅提供统一的路由方式,并且基于 Filter 链的方式提供了网关基本的功能,例如:安全,监控/指标,限流。\nSpring Cloud Gateway 和 Zuul 2.x 的差别不大,也是通过过滤器来处理请求。不过,目前更加推荐使用 Spring Cloud Gateway 而非 Zuul,Spring Cloud 生态对其支持更加友好。\nGitHub 地址: https://github.com/spring-cloud/spring-cloud-gateway 官网: https://spring.io/projects/spring-cloud-gateway Spring Cloud Gateway 的工作流程? # Spring Cloud Gateway 的工作流程如下图所示:\n这是 Spring 官方博客中的一张图,原文地址: https://spring.io/blog/2022/08/26/creating-a-custom-spring-cloud-gateway-filter。\n具体的流程分析:\n路由判断:客户端的请求到达网关后,先经过 Gateway Handler Mapping 处理,这里面会做断言(Predicate)判断,看下符合哪个路由规则,这个路由映射后端的某个服务。 请求过滤:然后请求到达 Gateway Web Handler,这里面有很多过滤器,组成过滤器链(Filter Chain),这些过滤器可以对请求进行拦截和修改,比如添加请求头、参数校验等等,有点像净化污水。然后将请求转发到实际的后端服务。这些过滤器逻辑上可以称作 Pre-Filters,Pre 可以理解为“在\u0026hellip;之前”。 服务处理:后端服务会对请求进行处理。 响应过滤:后端处理完结果后,返回给 Gateway 的过滤器再次做处理,逻辑上可以称作 Post-Filters,Post 可以理解为“在\u0026hellip;之后”。 响应返回:响应经过过滤处理后,返回给客户端。 总结:客户端的请求先通过匹配规则找到合适的路由,就能映射到具体的服务。然后请求经过过滤器处理后转发给具体的服务,服务处理后,再次经过过滤器处理,最后返回给客户端。\nSpring Cloud Gateway 的断言是什么? # 断言(Predicate)这个词听起来极其深奥,它是一种编程术语,我们生活中根本就不会用它。说白了它就是对一个表达式进行 if 判断,结果为真或假,如果为真则做这件事,否则做那件事。\n在 Gateway 中,如果客户端发送的请求满足了断言的条件,则映射到指定的路由器,就能转发到指定的服务上进行处理。\n断言配置的示例如下,配置了两个路由规则,有一个 predicates 断言配置,当请求 url 中包含 api/thirdparty,就匹配到了第一个路由 route_thirdparty。\n常见的路由断言规则如下图所示:\nSpring Cloud Gateway 的路由和断言是什么关系? # Route 路由和 Predicate 断言的对应关系如下::\n一对多:一个路由规则可以包含多个断言。如上图中路由 Route1 配置了三个断言 Predicate。 同时满足:如果一个路由规则中有多个断言,则需要同时满足才能匹配。如上图中路由 Route2 配置了两个断言,客户端发送的请求必须同时满足这两个断言,才能匹配路由 Route2。 第一个匹配成功:如果一个请求可以匹配多个路由,则映射第一个匹配成功的路由。如上图所示,客户端发送的请求满足 Route3 和 Route4 的断言,但是 Route3 的配置在配置文件中靠前,所以只会匹配 Route3。 Spring Cloud Gateway 如何实现动态路由? # 在使用 Spring Cloud Gateway 的时候,官方文档提供的方案总是基于配置文件或代码配置的方式。\nSpring Cloud Gateway 作为微服务的入口,需要尽量避免重启,而现在配置更改需要重启服务不能满足实际生产过程中的动态刷新、实时变更的业务需求,所以我们需要在 Spring Cloud Gateway 运行时动态配置网关。\n实现动态路由的方式有很多种,其中一种推荐的方式是基于 Nacos 注册中心来做。 Spring Cloud Gateway 可以从注册中心获取服务的元数据(例如服务名称、路径等),然后根据这些信息自动生成路由规则。这样,当你添加、移除或更新服务实例时,网关会自动感知并相应地调整路由规则,无需手动维护路由配置。\n其实这些复杂的步骤并不需要我们手动实现,通过 Nacos Server 和 Spring Cloud Alibaba Nacos Config 即可实现配置的动态变更,官方文档地址: https://github.com/alibaba/spring-cloud-alibaba/wiki/Nacos-config 。\nSpring Cloud Gateway 的过滤器有哪些? # 过滤器 Filter 按照请求和响应可以分为两种:\nPre 类型:在请求被转发到微服务之前,对请求进行拦截和修改,例如参数校验、权限校验、流量监控、日志输出以及协议转换等操作。 Post 类型:微服务处理完请求后,返回响应给网关,网关可以再次进行处理,例如修改响应内容或响应头、日志输出、流量监控等。 另外一种分类是按照过滤器 Filter 作用的范围进行划分:\nGatewayFilter:局部过滤器,应用在单个路由或一组路由上的过滤器。标红色表示比较常用的过滤器。 GlobalFilter:全局过滤器,应用在所有路由上的过滤器。 局部过滤器 # 常见的局部过滤器如下图所示:\n具体怎么用呢?这里有个示例,如果 URL 匹配成功,则去掉 URL 中的 “api”。\nfilters: #过滤器 - RewritePath=/api/(?\u0026lt;segment\u0026gt;.*),/$\\{segment} # 将跳转路径中包含的 “api” 替换成空 当然我们也可以自定义过滤器,本篇不做展开。\n全局过滤器 # 常见的全局过滤器如下图所示:\n全局过滤器最常见的用法是进行负载均衡。配置如下所示:\nspring: cloud: gateway: routes: - id: route_member # 第三方微服务路由规则 uri: lb://passjava-member # 负载均衡,将请求转发到注册中心注册的 passjava-member 服务 predicates: # 断言 - Path=/api/member/** # 如果前端请求路径包含 api/member,则应用这条路由规则 filters: #过滤器 - RewritePath=/api/(?\u0026lt;segment\u0026gt;.*),/$\\{segment} # 将跳转路径中包含的api替换成空 这里有个关键字 lb,用到了全局过滤器 LoadBalancerClientFilter,当匹配到这个路由后,会将请求转发到 passjava-member 服务,且支持负载均衡转发,也就是先将 passjava-member 解析成实际的微服务的 host 和 port,然后再转发给实际的微服务。\nSpring Cloud Gateway 支持限流吗? # Spring Cloud Gateway 自带了限流过滤器,对应的接口是 RateLimiter,RateLimiter 接口只有一个实现类 RedisRateLimiter (基于 Redis + Lua 实现的限流),提供的限流功能比较简易且不易使用。\n从 Sentinel 1.6.0 版本开始,Sentinel 引入了 Spring Cloud Gateway 的适配模块,可以提供两种资源维度的限流:route 维度和自定义 API 维度。也就是说,Spring Cloud Gateway 可以结合 Sentinel 实现更强大的网关流量控制。\nSpring Cloud Gateway 如何自定义全局异常处理? # 在 SpringBoot 项目中,我们捕获全局异常只需要在项目中配置 @RestControllerAdvice和 @ExceptionHandler就可以了。不过,这种方式在 Spring Cloud Gateway 下不适用。\nSpring Cloud Gateway 提供了多种全局处理的方式,比较常用的一种是实现ErrorWebExceptionHandler并重写其中的handle方法。\n@Order(-1) @Component @RequiredArgsConstructor public class GlobalErrorWebExceptionHandler implements ErrorWebExceptionHandler { private final ObjectMapper objectMapper; @Override public Mono\u0026lt;Void\u0026gt; handle(ServerWebExchange exchange, Throwable ex) { // ... } } 参考 # Spring Cloud Gateway 官方文档: https://cloud.spring.io/spring-cloud-gateway/reference/html/ Creating a custom Spring Cloud Gateway Filter: https://spring.io/blog/2022/08/26/creating-a-custom-spring-cloud-gateway-filter 全局异常处理: https://zhuanlan.zhihu.com/p/347028665 "},{"id":571,"href":"/zh/docs/technology/Interview/system-design/framework/spring/spring-transaction/","title":"Spring 事务详解","section":"Framework","content":"前段时间答应读者的 Spring 事务 分析总结终于来了。这部分内容比较重要,不论是对于工作还是面试,但是网上比较好的参考资料比较少。\n什么是事务? # 事务是逻辑上的一组操作,要么都执行,要么都不执行。\n相信大家应该都能背上面这句话了,下面我结合我们日常的真实开发来谈一谈。\n我们系统的每个业务方法可能包括了多个原子性的数据库操作,比如下面的 savePerson() 方法中就有两个原子性的数据库操作。这些原子性的数据库操作是有依赖的,它们要么都执行,要不就都不执行。\npublic void savePerson() { personDao.save(person); personDetailDao.save(personDetail); } 另外,需要格外注意的是:事务能否生效数据库引擎是否支持事务是关键。比如常用的 MySQL 数据库默认使用支持事务的 innodb引擎。但是,如果把数据库引擎变为 myisam,那么程序也就不再支持事务了!\n事务最经典也经常被拿出来说例子就是转账了。假如小明要给小红转账 1000 元,这个转账会涉及到两个关键操作就是:\n将小明的余额减少 1000 元。 将小红的余额增加 1000 元。 万一在这两个操作之间突然出现错误比如银行系统崩溃或者网络故障,导致小明余额减少而小红的余额没有增加,这样就不对了。事务就是保证这两个关键操作要么都成功,要么都要失败。\npublic class OrdersService { private AccountDao accountDao; public void setOrdersDao(AccountDao accountDao) { this.accountDao = accountDao; } @Transactional(propagation = Propagation.REQUIRED, isolation = Isolation.DEFAULT, readOnly = false, timeout = -1) public void accountMoney() { //小红账户多1000 accountDao.addMoney(1000,xiaohong); //模拟突然出现的异常,比如银行中可能为突然停电等等 //如果没有配置事务管理的话会造成,小红账户多了1000而小明账户没有少钱 int i = 10 / 0; //小王账户少1000 accountDao.reduceMoney(1000,xiaoming); } } 另外,数据库事务的 ACID 四大特性是事务的基础,下面简单来了解一下。\n事务的特性(ACID)了解么? # 原子性(Atomicity):事务是最小的执行单位,不允许分割。事务的原子性确保动作要么全部完成,要么完全不起作用; 一致性(Consistency):执行事务前后,数据保持一致,例如转账业务中,无论事务是否成功,转账者和收款人的总额应该是不变的; 隔离性(Isolation):并发访问数据库时,一个用户的事务不被其他事务所干扰,各并发事务之间数据库是独立的; 持久性(Durability):一个事务被提交之后。它对数据库中数据的改变是持久的,即使数据库发生故障也不应该对其有任何影响。 🌈 这里要额外补充一点:只有保证了事务的持久性、原子性、隔离性之后,一致性才能得到保障。也就是说 A、I、D 是手段,C 是目的! 想必大家也和我一样,被 ACID 这个概念被误导了很久! 我也是看周志明老师的公开课 《周志明的软件架构课》才搞清楚的(多看好书!!!)。\n另外,DDIA 也就是 《Designing Data-Intensive Application(数据密集型应用系统设计)》 的作者在他的这本书中如是说:\nAtomicity, isolation, and durability are properties of the database, whereas consis‐ tency (in the ACID sense) is a property of the application. The application may rely on the database’s atomicity and isolation properties in order to achieve consistency, but it’s not up to the database alone.\n翻译过来的意思是:原子性,隔离性和持久性是数据库的属性,而一致性(在 ACID 意义上)是应用程序的属性。应用可能依赖数据库的原子性和隔离属性来实现一致性,但这并不仅取决于数据库。因此,字母 C 不属于 ACID 。\n《Designing Data-Intensive Application(数据密集型应用系统设计)》这本书强推一波,值得读很多遍!豆瓣有接近 90% 的人看了这本书之后给了五星好评。另外,中文翻译版本已经在 GitHub 开源,地址: https://github.com/Vonng/ddia 。\n详谈 Spring 对事务的支持 # ⚠️ 再提醒一次:你的程序是否支持事务首先取决于数据库 ,比如使用 MySQL 的话,如果你选择的是 innodb 引擎,那么恭喜你,是可以支持事务的。但是,如果你的 MySQL 数据库使用的是 myisam 引擎的话,那不好意思,从根上就是不支持事务的。\n这里再多提一下一个非常重要的知识点:MySQL 怎么保证原子性的?\n我们知道如果想要保证事务的原子性,就需要在异常发生时,对已经执行的操作进行回滚,在 MySQL 中,恢复机制是通过 回滚日志(undo log) 实现的,所有事务进行的修改都会先记录到这个回滚日志中,然后再执行相关的操作。如果执行过程中遇到异常的话,我们直接利用 回滚日志 中的信息将数据回滚到修改之前的样子即可!并且,回滚日志会先于数据持久化到磁盘上。这样就保证了即使遇到数据库突然宕机等情况,当用户再次启动数据库的时候,数据库还能够通过查询回滚日志来回滚之前未完成的事务。\nSpring 支持两种方式的事务管理 # 编程式事务管理 # 通过 TransactionTemplate或者TransactionManager手动管理事务,实际应用中很少使用,但是对于你理解 Spring 事务管理原理有帮助。\n使用TransactionTemplate 进行编程式事务管理的示例代码如下:\n@Autowired private TransactionTemplate transactionTemplate; public void testTransaction() { transactionTemplate.execute(new TransactionCallbackWithoutResult() { @Override protected void doInTransactionWithoutResult(TransactionStatus transactionStatus) { try { // .... 业务代码 } catch (Exception e){ //回滚 transactionStatus.setRollbackOnly(); } } }); } 使用 TransactionManager 进行编程式事务管理的示例代码如下:\n@Autowired private PlatformTransactionManager transactionManager; public void testTransaction() { TransactionStatus status = transactionManager.getTransaction(new DefaultTransactionDefinition()); try { // .... 业务代码 transactionManager.commit(status); } catch (Exception e) { transactionManager.rollback(status); } } 声明式事务管理 # 推荐使用(代码侵入性最小),实际是通过 AOP 实现(基于@Transactional 的全注解方式使用最多)。\n使用 @Transactional注解进行事务管理的示例代码如下:\n@Transactional(propagation = Propagation.REQUIRED) public void aMethod { //do something B b = new B(); C c = new C(); b.bMethod(); c.cMethod(); } Spring 事务管理接口介绍 # Spring 框架中,事务管理相关最重要的 3 个接口如下:\nPlatformTransactionManager:(平台)事务管理器,Spring 事务策略的核心。 TransactionDefinition:事务定义信息(事务隔离级别、传播行为、超时、只读、回滚规则)。 TransactionStatus:事务运行状态。 我们可以把 PlatformTransactionManager 接口可以被看作是事务上层的管理者,而 TransactionDefinition 和 TransactionStatus 这两个接口可以看作是事务的描述。\nPlatformTransactionManager 会根据 TransactionDefinition 的定义比如事务超时时间、隔离级别、传播行为等来进行事务管理 ,而 TransactionStatus 接口则提供了一些方法来获取事务相应的状态比如是否新事务、是否可以回滚等等。\nPlatformTransactionManager:事务管理接口 # Spring 并不直接管理事务,而是提供了多种事务管理器 。Spring 事务管理器的接口是:PlatformTransactionManager 。\n通过这个接口,Spring 为各个平台如:JDBC(DataSourceTransactionManager)、Hibernate(HibernateTransactionManager)、JPA(JpaTransactionManager)等都提供了对应的事务管理器,但是具体的实现就是各个平台自己的事情了。\nPlatformTransactionManager 接口的具体实现如下:\nPlatformTransactionManager接口中定义了三个方法:\npackage org.springframework.transaction; import org.springframework.lang.Nullable; public interface PlatformTransactionManager { //获得事务 TransactionStatus getTransaction(@Nullable TransactionDefinition var1) throws TransactionException; //提交事务 void commit(TransactionStatus var1) throws TransactionException; //回滚事务 void rollback(TransactionStatus var1) throws TransactionException; } 这里多插一嘴。为什么要定义或者说抽象出来PlatformTransactionManager这个接口呢?\n主要是因为要将事务管理行为抽象出来,然后不同的平台去实现它,这样我们可以保证提供给外部的行为不变,方便我们扩展。\n我前段时间在我的 知识星球分享过:“为什么我们要用接口?” 。\n《设计模式》(GOF 那本)这本书在很多年前都提到过说要基于接口而非实现编程,你真的知道为什么要基于接口编程么?\n纵观开源框架和项目的源码,接口是它们不可或缺的重要组成部分。要理解为什么要用接口,首先要搞懂接口提供了什么功能。我们可以把接口理解为提供了一系列功能列表的约定,接口本身不提供功能,它只定义行为。但是谁要用,就要先实现我,遵守我的约定,然后再自己去实现我定义的要实现的功能。\n举个例子,我上个项目有发送短信的需求,为此,我们定了一个接口,接口只有两个方法:\n1.发送短信 2.处理发送结果的方法。\n刚开始我们用的是阿里云短信服务,然后我们实现这个接口完成了一个阿里云短信的服务。后来,我们突然又换到了别的短信服务平台,我们这个时候只需要再实现这个接口即可。这样保证了我们提供给外部的行为不变。几乎不需要改变什么代码,我们就轻松完成了需求的转变,提高了代码的灵活性和可扩展性。\n什么时候用接口?当你要实现的功能模块设计抽象行为的时候,比如发送短信的服务,图床的存储服务等等。\nTransactionDefinition:事务属性 # 事务管理器接口 PlatformTransactionManager 通过 getTransaction(TransactionDefinition definition) 方法来得到一个事务,这个方法里面的参数是 TransactionDefinition 类 ,这个类就定义了一些基本的事务属性。\n什么是事务属性呢? 事务属性可以理解成事务的一些基本配置,描述了事务策略如何应用到方法上。\n事务属性包含了 5 个方面:\n隔离级别 传播行为 回滚规则 是否只读 事务超时 TransactionDefinition 接口中定义了 5 个方法以及一些表示事务属性的常量比如隔离级别、传播行为等等。\npackage org.springframework.transaction; import org.springframework.lang.Nullable; public interface TransactionDefinition { int PROPAGATION_REQUIRED = 0; int PROPAGATION_SUPPORTS = 1; int PROPAGATION_MANDATORY = 2; int PROPAGATION_REQUIRES_NEW = 3; int PROPAGATION_NOT_SUPPORTED = 4; int PROPAGATION_NEVER = 5; int PROPAGATION_NESTED = 6; int ISOLATION_DEFAULT = -1; int ISOLATION_READ_UNCOMMITTED = 1; int ISOLATION_READ_COMMITTED = 2; int ISOLATION_REPEATABLE_READ = 4; int ISOLATION_SERIALIZABLE = 8; int TIMEOUT_DEFAULT = -1; // 返回事务的传播行为,默认值为 REQUIRED。 int getPropagationBehavior(); //返回事务的隔离级别,默认值是 DEFAULT int getIsolationLevel(); // 返回事务的超时时间,默认值为-1。如果超过该时间限制但事务还没有完成,则自动回滚事务。 int getTimeout(); // 返回是否为只读事务,默认值为 false boolean isReadOnly(); @Nullable String getName(); } TransactionStatus:事务状态 # TransactionStatus接口用来记录事务的状态 该接口定义了一组方法,用来获取或判断事务的相应状态信息。\nPlatformTransactionManager.getTransaction(…)方法返回一个 TransactionStatus 对象。\nTransactionStatus 接口内容如下:\npublic interface TransactionStatus{ boolean isNewTransaction(); // 是否是新的事务 boolean hasSavepoint(); // 是否有恢复点 void setRollbackOnly(); // 设置为只回滚 boolean isRollbackOnly(); // 是否为只回滚 boolean isCompleted; // 是否已完成 } 事务属性详解 # 实际业务开发中,大家一般都是使用 @Transactional 注解来开启事务,很多人并不清楚这个注解里面的参数是什么意思,有什么用。为了更好的在项目中使用事务管理,强烈推荐好好阅读一下下面的内容。\n事务传播行为 # 事务传播行为是为了解决业务层方法之间互相调用的事务问题。\n当事务方法被另一个事务方法调用时,必须指定事务应该如何传播。例如:方法可能继续在现有事务中运行,也可能开启一个新事务,并在自己的事务中运行。\n举个例子:我们在 A 类的aMethod()方法中调用了 B 类的 bMethod() 方法。这个时候就涉及到业务层方法之间互相调用的事务问题。如果我们的 bMethod()如果发生异常需要回滚,如何配置事务传播行为才能让 aMethod()也跟着回滚呢?这个时候就需要事务传播行为的知识了,如果你不知道的话一定要好好看一下。\n@Service Class A { @Autowired B b; @Transactional(propagation = Propagation.xxx) public void aMethod { //do something b.bMethod(); } } @Service Class B { @Transactional(propagation = Propagation.xxx) public void bMethod { //do something } } 在TransactionDefinition定义中包括了如下几个表示传播行为的常量:\npublic interface TransactionDefinition { int PROPAGATION_REQUIRED = 0; int PROPAGATION_SUPPORTS = 1; int PROPAGATION_MANDATORY = 2; int PROPAGATION_REQUIRES_NEW = 3; int PROPAGATION_NOT_SUPPORTED = 4; int PROPAGATION_NEVER = 5; int PROPAGATION_NESTED = 6; ...... } 不过,为了方便使用,Spring 相应地定义了一个枚举类:Propagation\npackage org.springframework.transaction.annotation; import org.springframework.transaction.TransactionDefinition; public enum Propagation { REQUIRED(TransactionDefinition.PROPAGATION_REQUIRED), SUPPORTS(TransactionDefinition.PROPAGATION_SUPPORTS), MANDATORY(TransactionDefinition.PROPAGATION_MANDATORY), REQUIRES_NEW(TransactionDefinition.PROPAGATION_REQUIRES_NEW), NOT_SUPPORTED(TransactionDefinition.PROPAGATION_NOT_SUPPORTED), NEVER(TransactionDefinition.PROPAGATION_NEVER), NESTED(TransactionDefinition.PROPAGATION_NESTED); private final int value; Propagation(int value) { this.value = value; } public int value() { return this.value; } } 正确的事务传播行为可能的值如下:\n1.TransactionDefinition.PROPAGATION_REQUIRED\n使用的最多的一个事务传播行为,我们平时经常使用的@Transactional注解默认使用就是这个事务传播行为。如果当前存在事务,则加入该事务;如果当前没有事务,则创建一个新的事务。也就是说:\n如果外部方法没有开启事务的话,Propagation.REQUIRED修饰的内部方法会新开启自己的事务,且开启的事务相互独立,互不干扰。 如果外部方法开启事务并且被Propagation.REQUIRED的话,所有Propagation.REQUIRED修饰的内部方法和外部方法均属于同一事务 ,只要一个方法回滚,整个事务均回滚。 举个例子:如果我们上面的aMethod()和bMethod()使用的都是PROPAGATION_REQUIRED传播行为的话,两者使用的就是同一个事务,只要其中一个方法回滚,整个事务均回滚。\n@Service Class A { @Autowired B b; @Transactional(propagation = Propagation.REQUIRED) public void aMethod { //do something b.bMethod(); } } @Service Class B { @Transactional(propagation = Propagation.REQUIRED) public void bMethod { //do something } } 2.TransactionDefinition.PROPAGATION_REQUIRES_NEW\n创建一个新的事务,如果当前存在事务,则把当前事务挂起。也就是说不管外部方法是否开启事务,Propagation.REQUIRES_NEW修饰的内部方法会新开启自己的事务,且开启的事务相互独立,互不干扰。\n举个例子:如果我们上面的bMethod()使用PROPAGATION_REQUIRES_NEW事务传播行为修饰,aMethod还是用PROPAGATION_REQUIRED修饰的话。如果aMethod()发生异常回滚,bMethod()不会跟着回滚,因为 bMethod()开启了独立的事务。但是,如果 bMethod()抛出了未被捕获的异常并且这个异常满足事务回滚规则的话,aMethod()同样也会回滚,因为这个异常被 aMethod()的事务管理机制检测到了。\n@Service Class A { @Autowired B b; @Transactional(propagation = Propagation.REQUIRED) public void aMethod { //do something b.bMethod(); } } @Service Class B { @Transactional(propagation = Propagation.REQUIRES_NEW) public void bMethod { //do something } } 3.TransactionDefinition.PROPAGATION_NESTED:\n如果当前存在事务,就在嵌套事务内执行;如果当前没有事务,就执行与TransactionDefinition.PROPAGATION_REQUIRED类似的操作。也就是说:\n在外部方法开启事务的情况下,在内部开启一个新的事务,作为嵌套事务存在。 如果外部方法无事务,则单独开启一个事务,与 PROPAGATION_REQUIRED 类似。 这里还是简单举个例子:如果 bMethod() 回滚的话,aMethod()不会回滚。如果 aMethod() 回滚的话,bMethod()会回滚。\n@Service Class A { @Autowired B b; @Transactional(propagation = Propagation.REQUIRED) public void aMethod { //do something b.bMethod(); } } @Service Class B { @Transactional(propagation = Propagation.NESTED) public void bMethod { //do something } } 4.TransactionDefinition.PROPAGATION_MANDATORY\n如果当前存在事务,则加入该事务;如果当前没有事务,则抛出异常。(mandatory:强制性)\n这个使用的很少,就不举例子来说了。\n若是错误的配置以下 3 种事务传播行为,事务将不会发生回滚,这里不对照案例讲解了,使用的很少。\nTransactionDefinition.PROPAGATION_SUPPORTS: 如果当前存在事务,则加入该事务;如果当前没有事务,则以非事务的方式继续运行。 TransactionDefinition.PROPAGATION_NOT_SUPPORTED: 以非事务方式运行,如果当前存在事务,则把当前事务挂起。 TransactionDefinition.PROPAGATION_NEVER: 以非事务方式运行,如果当前存在事务,则抛出异常。 更多关于事务传播行为的内容请看这篇文章: 《太难了~面试官让我结合案例讲讲自己对 Spring 事务传播行为的理解。》\n事务隔离级别 # TransactionDefinition 接口中定义了五个表示隔离级别的常量:\npublic interface TransactionDefinition { ...... int ISOLATION_DEFAULT = -1; int ISOLATION_READ_UNCOMMITTED = 1; int ISOLATION_READ_COMMITTED = 2; int ISOLATION_REPEATABLE_READ = 4; int ISOLATION_SERIALIZABLE = 8; ...... } 和事务传播行为那块一样,为了方便使用,Spring 也相应地定义了一个枚举类:Isolation\npublic enum Isolation { DEFAULT(TransactionDefinition.ISOLATION_DEFAULT), READ_UNCOMMITTED(TransactionDefinition.ISOLATION_READ_UNCOMMITTED), READ_COMMITTED(TransactionDefinition.ISOLATION_READ_COMMITTED), REPEATABLE_READ(TransactionDefinition.ISOLATION_REPEATABLE_READ), SERIALIZABLE(TransactionDefinition.ISOLATION_SERIALIZABLE); private final int value; Isolation(int value) { this.value = value; } public int value() { return this.value; } } 下面我依次对每一种事务隔离级别进行介绍:\nTransactionDefinition.ISOLATION_DEFAULT :使用后端数据库默认的隔离级别,MySQL 默认采用的 REPEATABLE_READ 隔离级别 Oracle 默认采用的 READ_COMMITTED 隔离级别. TransactionDefinition.ISOLATION_READ_UNCOMMITTED :最低的隔离级别,使用这个隔离级别很少,因为它允许读取尚未提交的数据变更,可能会导致脏读、幻读或不可重复读 TransactionDefinition.ISOLATION_READ_COMMITTED : 允许读取并发事务已经提交的数据,可以阻止脏读,但是幻读或不可重复读仍有可能发生 TransactionDefinition.ISOLATION_REPEATABLE_READ : 对同一字段的多次读取结果都是一致的,除非数据是被本身事务自己所修改,可以阻止脏读和不可重复读,但幻读仍有可能发生。 TransactionDefinition.ISOLATION_SERIALIZABLE : 最高的隔离级别,完全服从 ACID 的隔离级别。所有的事务依次逐个执行,这样事务之间就完全不可能产生干扰,也就是说,该级别可以防止脏读、不可重复读以及幻读。但是这将严重影响程序的性能。通常情况下也不会用到该级别。 相关阅读: MySQL 事务隔离级别详解。\n事务超时属性 # 所谓事务超时,就是指一个事务所允许执行的最长时间,如果超过该时间限制但事务还没有完成,则自动回滚事务。在 TransactionDefinition 中以 int 的值来表示超时时间,其单位是秒,默认值为-1,这表示事务的超时时间取决于底层事务系统或者没有超时时间。\n事务只读属性 # package org.springframework.transaction; import org.springframework.lang.Nullable; public interface TransactionDefinition { ...... // 返回是否为只读事务,默认值为 false boolean isReadOnly(); } 对于只有读取数据查询的事务,可以指定事务类型为 readonly,即只读事务。只读事务不涉及数据的修改,数据库会提供一些优化手段,适合用在有多条数据库查询操作的方法中。\n很多人就会疑问了,为什么我一个数据查询操作还要启用事务支持呢?\n拿 MySQL 的 innodb 举例子,根据官网 https://dev.mysql.com/doc/refman/5.7/en/innodb-autocommit-commit-rollback.html 描述:\nMySQL 默认对每一个新建立的连接都启用了autocommit模式。在该模式下,每一个发送到 MySQL 服务器的sql语句都会在一个单独的事务中进行处理,执行结束后会自动提交事务,并开启一个新的事务。\n但是,如果你给方法加上了Transactional注解的话,这个方法执行的所有sql会被放在一个事务中。如果声明了只读事务的话,数据库就会去优化它的执行,并不会带来其他的什么收益。\n如果不加Transactional,每条sql会开启一个单独的事务,中间被其它事务改了数据,都会实时读取到最新值。\n分享一下关于事务只读属性,其他人的解答:\n如果你一次执行单条查询语句,则没有必要启用事务支持,数据库默认支持 SQL 执行期间的读一致性; 如果你一次执行多条查询语句,例如统计查询,报表查询,在这种场景下,多条查询 SQL 必须保证整体的读一致性,否则,在前条 SQL 查询之后,后条 SQL 查询之前,数据被其他用户改变,则该次整体的统计查询将会出现读数据不一致的状态,此时,应该启用事务支持 事务回滚规则 # 这些规则定义了哪些异常会导致事务回滚而哪些不会。默认情况下,事务只有遇到运行期异常(RuntimeException 的子类)时才会回滚,Error 也会导致事务回滚,但是,在遇到检查型(Checked)异常时不会回滚。\n如果你想要回滚你定义的特定的异常类型的话,可以这样:\n@Transactional(rollbackFor= MyException.class) @Transactional 注解使用详解 # @Transactional 的作用范围 # 方法:推荐将注解使用于方法上,不过需要注意的是:该注解只能应用到 public 方法上,否则不生效。 类:如果这个注解使用在类上的话,表明该注解对该类中所有的 public 方法都生效。 接口:不推荐在接口上使用。 @Transactional 的常用配置参数 # @Transactional注解源码如下,里面包含了基本事务属性的配置:\n@Target({ElementType.TYPE, ElementType.METHOD}) @Retention(RetentionPolicy.RUNTIME) @Inherited @Documented public @interface Transactional { @AliasFor(\u0026#34;transactionManager\u0026#34;) String value() default \u0026#34;\u0026#34;; @AliasFor(\u0026#34;value\u0026#34;) String transactionManager() default \u0026#34;\u0026#34;; Propagation propagation() default Propagation.REQUIRED; Isolation isolation() default Isolation.DEFAULT; int timeout() default TransactionDefinition.TIMEOUT_DEFAULT; boolean readOnly() default false; Class\u0026lt;? extends Throwable\u0026gt;[] rollbackFor() default {}; String[] rollbackForClassName() default {}; Class\u0026lt;? extends Throwable\u0026gt;[] noRollbackFor() default {}; String[] noRollbackForClassName() default {}; } @Transactional 的常用配置参数总结(只列出了 5 个我平时比较常用的):\n属性名 说明 propagation 事务的传播行为,默认值为 REQUIRED,可选的值在上面介绍过 isolation 事务的隔离级别,默认值采用 DEFAULT,可选的值在上面介绍过 timeout 事务的超时时间,默认值为-1(不会超时)。如果超过该时间限制但事务还没有完成,则自动回滚事务。 readOnly 指定事务是否为只读事务,默认值为 false。 rollbackFor 用于指定能够触发事务回滚的异常类型,并且可以指定多个异常类型。 @Transactional 事务注解原理 # 面试中在问 AOP 的时候可能会被问到的一个问题。简单说下吧!\n我们知道,@Transactional 的工作机制是基于 AOP 实现的,AOP 又是使用动态代理实现的。如果目标对象实现了接口,默认情况下会采用 JDK 的动态代理,如果目标对象没有实现了接口,会使用 CGLIB 动态代理。\n🤐 多提一嘴:createAopProxy() 方法 决定了是使用 JDK 还是 Cglib 来做动态代理,源码如下:\npublic class DefaultAopProxyFactory implements AopProxyFactory, Serializable { @Override public AopProxy createAopProxy(AdvisedSupport config) throws AopConfigException { if (config.isOptimize() || config.isProxyTargetClass() || hasNoUserSuppliedProxyInterfaces(config)) { Class\u0026lt;?\u0026gt; targetClass = config.getTargetClass(); if (targetClass == null) { throw new AopConfigException(\u0026#34;TargetSource cannot determine target class: \u0026#34; + \u0026#34;Either an interface or a target is required for proxy creation.\u0026#34;); } if (targetClass.isInterface() || Proxy.isProxyClass(targetClass)) { return new JdkDynamicAopProxy(config); } return new ObjenesisCglibAopProxy(config); } else { return new JdkDynamicAopProxy(config); } } ....... } 如果一个类或者一个类中的 public 方法上被标注@Transactional 注解的话,Spring 容器就会在启动的时候为其创建一个代理类,在调用被@Transactional 注解的 public 方法的时候,实际调用的是,TransactionInterceptor 类中的 invoke()方法。这个方法的作用就是在目标方法之前开启事务,方法执行过程中如果遇到异常的时候回滚事务,方法调用完成之后提交事务。\nTransactionInterceptor 类中的 invoke()方法内部实际调用的是 TransactionAspectSupport 类的 invokeWithinTransaction()方法。由于新版本的 Spring 对这部分重写很大,而且用到了很多响应式编程的知识,这里就不列源码了。\nSpring AOP 自调用问题 # 当一个方法被标记了@Transactional 注解的时候,Spring 事务管理器只会在被其他类方法调用的时候生效,而不会在一个类中方法调用生效。\n这是因为 Spring AOP 工作原理决定的。因为 Spring AOP 使用动态代理来实现事务的管理,它会在运行的时候为带有 @Transactional 注解的方法生成代理对象,并在方法调用的前后应用事物逻辑。如果该方法被其他类调用我们的代理对象就会拦截方法调用并处理事务。但是在一个类中的其他方法内部调用的时候,我们代理对象就无法拦截到这个内部调用,因此事务也就失效了。\nMyService 类中的method1()调用method2()就会导致method2()的事务失效。\n@Service public class MyService { private void method1() { method2(); //...... } @Transactional public void method2() { //...... } } 解决办法就是避免同一类中自调用或者使用 AspectJ 取代 Spring AOP 代理。\nissue #2091补充了一个例子:\n@Service public class MyService { private void method1() { ((MyService)AopContext.currentProxy()).method2(); // 先获取该类的代理对象,然后通过代理对象调用method2。 //...... } @Transactional public void method2() { //...... } } 上面的代码确实可以在自调用的时候开启事务,但是这是因为使用了 AopContext.currentProxy() 方法来获取当前类的代理对象,然后通过代理对象调用 method2()。这样就相当于从外部调用了 method2(),所以事务注解才会生效。我们一般也不会在代码中这么写,所以可以忽略这个特殊的例子。\n@Transactional 的使用注意事项总结 # @Transactional 注解只有作用到 public 方法上事务才生效,不推荐在接口上使用; 避免同一个类中调用 @Transactional 注解的方法,这样会导致事务失效; 正确的设置 @Transactional 的 rollbackFor 和 propagation 属性,否则事务可能会回滚失败; 被 @Transactional 注解的方法所在的类必须被 Spring 管理,否则不生效; 底层使用的数据库必须支持事务机制,否则不生效; …… 参考 # [总结]Spring 事务管理中@Transactional 的参数: http://www.mobabel.net/spring 事务管理中 transactional 的参数/ Spring 官方文档: https://docs.spring.io/spring/docs/4.2.x/spring-framework-reference/html/transaction.html 《Spring5 高级编程》 透彻的掌握 Spring 中@transactional 的使用: https://www.ibm.com/developerworks/cn/java/j-master-spring-transactional-use/index.html Spring 事务的传播特性: https://github.com/love-somnus/Spring/wiki/Spring 事务的传播特性 Spring 事务传播行为详解: https://segmentfault.com/a/1190000013341344 全面分析 Spring 的编程式事务管理及声明式事务管理: https://www.ibm.com/developerworks/cn/education/opensource/os-cn-spring-trans/index.html "},{"id":572,"href":"/zh/docs/technology/Interview/system-design/framework/spring/spring-design-patterns-summary/","title":"Spring 中的设计模式详解","section":"Framework","content":"“JDK 中用到了哪些设计模式? Spring 中用到了哪些设计模式? ”这两个问题,在面试中比较常见。\n我在网上搜索了一下关于 Spring 中设计模式的讲解几乎都是千篇一律,而且大部分都年代久远。所以,花了几天时间自己总结了一下。\n由于我的个人能力有限,文中如有任何错误各位都可以指出。另外,文章篇幅有限,对于设计模式以及一些源码的解读我只是一笔带过,这篇文章的主要目的是回顾一下 Spring 中的设计模式。\n控制反转(IoC)和依赖注入(DI) # IoC(Inversion of Control,控制反转) 是 Spring 中一个非常非常重要的概念,它不是什么技术,而是一种解耦的设计思想。IoC 的主要目的是借助于“第三方”(Spring 中的 IoC 容器) 实现具有依赖关系的对象之间的解耦(IOC 容器管理对象,你只管使用即可),从而降低代码之间的耦合度。\nIoC 是一个原则,而不是一个模式,以下模式(但不限于)实现了 IoC 原则。\nSpring IoC 容器就像是一个工厂一样,当我们需要创建一个对象的时候,只需要配置好配置文件/注解即可,完全不用考虑对象是如何被创建出来的。 IoC 容器负责创建对象,将对象连接在一起,配置这些对象,并从创建中处理这些对象的整个生命周期,直到它们被完全销毁。\n在实际项目中一个 Service 类如果有几百甚至上千个类作为它的底层,我们需要实例化这个 Service,你可能要每次都要搞清这个 Service 所有底层类的构造函数,这可能会把人逼疯。如果利用 IOC 的话,你只需要配置好,然后在需要的地方引用就行了,这大大增加了项目的可维护性且降低了开发难度。\n关于 Spring IOC 的理解,推荐看这一下知乎的一个回答: https://www.zhihu.com/question/23277575/answer/169698662 ,非常不错。\n控制反转怎么理解呢? 举个例子:\u0026ldquo;对象 a 依赖了对象 b,当对象 a 需要使用 对象 b 的时候必须自己去创建。但是当系统引入了 IOC 容器后, 对象 a 和对象 b 之间就失去了直接的联系。这个时候,当对象 a 需要使用 对象 b 的时候, 我们可以指定 IOC 容器去创建一个对象 b 注入到对象 a 中\u0026rdquo;。 对象 a 获得依赖对象 b 的过程,由主动行为变为了被动行为,控制权反转,这就是控制反转名字的由来。\nDI(Dependency Inject,依赖注入)是实现控制反转的一种设计模式,依赖注入就是将实例变量传入到一个对象中去。\n工厂设计模式 # Spring 使用工厂模式可以通过 BeanFactory 或 ApplicationContext 创建 bean 对象。\n两者对比:\nBeanFactory:延迟注入(使用到某个 bean 的时候才会注入),相比于ApplicationContext 来说会占用更少的内存,程序启动速度更快。 ApplicationContext:容器启动的时候,不管你用没用到,一次性创建所有 bean 。BeanFactory 仅提供了最基本的依赖注入支持,ApplicationContext 扩展了 BeanFactory ,除了有BeanFactory的功能还有额外更多功能,所以一般开发人员使用ApplicationContext会更多。 ApplicationContext 的三个实现类:\nClassPathXmlApplication:把上下文文件当成类路径资源。 FileSystemXmlApplication:从文件系统中的 XML 文件载入上下文定义信息。 XmlWebApplicationContext:从 Web 系统中的 XML 文件载入上下文定义信息。 Example:\nimport org.springframework.context.ApplicationContext; import org.springframework.context.support.FileSystemXmlApplicationContext; public class App { public static void main(String[] args) { ApplicationContext context = new FileSystemXmlApplicationContext( \u0026#34;C:/work/IOC Containers/springframework.applicationcontext/src/main/resources/bean-factory-config.xml\u0026#34;); HelloApplicationContext obj = (HelloApplicationContext) context.getBean(\u0026#34;helloApplicationContext\u0026#34;); obj.getMsg(); } } 单例设计模式 # 在我们的系统中,有一些对象其实我们只需要一个,比如说:线程池、缓存、对话框、注册表、日志对象、充当打印机、显卡等设备驱动程序的对象。事实上,这一类对象只能有一个实例,如果制造出多个实例就可能会导致一些问题的产生,比如:程序的行为异常、资源使用过量、或者不一致性的结果。\n使用单例模式的好处 :\n对于频繁使用的对象,可以省略创建对象所花费的时间,这对于那些重量级对象而言,是非常可观的一笔系统开销; 由于 new 操作的次数减少,因而对系统内存的使用频率也会降低,这将减轻 GC 压力,缩短 GC 停顿时间。 Spring 中 bean 的默认作用域就是 singleton(单例)的。 除了 singleton 作用域,Spring 中 bean 还有下面几种作用域:\nprototype : 每次获取都会创建一个新的 bean 实例。也就是说,连续 getBean() 两次,得到的是不同的 Bean 实例。 request (仅 Web 应用可用): 每一次 HTTP 请求都会产生一个新的 bean(请求 bean),该 bean 仅在当前 HTTP request 内有效。 session (仅 Web 应用可用) : 每一次来自新 session 的 HTTP 请求都会产生一个新的 bean(会话 bean),该 bean 仅在当前 HTTP session 内有效。 application/global-session (仅 Web 应用可用):每个 Web 应用在启动时创建一个 Bean(应用 Bean),,该 bean 仅在当前应用启动时间内有效。 websocket (仅 Web 应用可用):每一次 WebSocket 会话产生一个新的 bean。 Spring 通过 ConcurrentHashMap 实现单例注册表的特殊方式实现单例模式。\nSpring 实现单例的核心代码如下:\n// 通过 ConcurrentHashMap(线程安全) 实现单例注册表 private final Map\u0026lt;String, Object\u0026gt; singletonObjects = new ConcurrentHashMap\u0026lt;String, Object\u0026gt;(64); public Object getSingleton(String beanName, ObjectFactory\u0026lt;?\u0026gt; singletonFactory) { Assert.notNull(beanName, \u0026#34;\u0026#39;beanName\u0026#39; must not be null\u0026#34;); synchronized (this.singletonObjects) { // 检查缓存中是否存在实例 Object singletonObject = this.singletonObjects.get(beanName); if (singletonObject == null) { //...省略了很多代码 try { singletonObject = singletonFactory.getObject(); } //...省略了很多代码 // 如果实例对象在不存在,我们注册到单例注册表中。 addSingleton(beanName, singletonObject); } return (singletonObject != NULL_OBJECT ? singletonObject : null); } } //将对象添加到单例注册表 protected void addSingleton(String beanName, Object singletonObject) { synchronized (this.singletonObjects) { this.singletonObjects.put(beanName, (singletonObject != null ? singletonObject : NULL_OBJECT)); } } } 单例 Bean 存在线程安全问题吗?\n大部分时候我们并没有在项目中使用多线程,所以很少有人会关注这个问题。单例 Bean 存在线程问题,主要是因为当多个线程操作同一个对象的时候是存在资源竞争的。\n常见的有两种解决办法:\n在 Bean 中尽量避免定义可变的成员变量。 在类中定义一个 ThreadLocal 成员变量,将需要的可变成员变量保存在 ThreadLocal 中(推荐的一种方式)。 不过,大部分 Bean 实际都是无状态(没有实例变量)的(比如 Dao、Service),这种情况下, Bean 是线程安全的。\n代理设计模式 # 代理模式在 AOP 中的应用 # AOP(Aspect-Oriented Programming,面向切面编程) 能够将那些与业务无关,却为业务模块所共同调用的逻辑或责任(例如事务处理、日志管理、权限控制等)封装起来,便于减少系统的重复代码,降低模块间的耦合度,并有利于未来的可拓展性和可维护性。\nSpring AOP 就是基于动态代理的,如果要代理的对象,实现了某个接口,那么 Spring AOP 会使用 JDK Proxy 去创建代理对象,而对于没有实现接口的对象,就无法使用 JDK Proxy 去进行代理了,这时候 Spring AOP 会使用 Cglib 生成一个被代理对象的子类来作为代理,如下图所示:\n当然,你也可以使用 AspectJ ,Spring AOP 已经集成了 AspectJ ,AspectJ 应该算的上是 Java 生态系统中最完整的 AOP 框架了。\n使用 AOP 之后我们可以把一些通用功能抽象出来,在需要用到的地方直接使用即可,这样大大简化了代码量。我们需要增加新功能时也方便,这样也提高了系统扩展性。日志功能、事务管理等等场景都用到了 AOP 。\nSpring AOP 和 AspectJ AOP 有什么区别? # Spring AOP 属于运行时增强,而 AspectJ 是编译时增强。 Spring AOP 基于代理(Proxying),而 AspectJ 基于字节码操作(Bytecode Manipulation)。\nSpring AOP 已经集成了 AspectJ ,AspectJ 应该算的上是 Java 生态系统中最完整的 AOP 框架了。AspectJ 相比于 Spring AOP 功能更加强大,但是 Spring AOP 相对来说更简单,\n如果我们的切面比较少,那么两者性能差异不大。但是,当切面太多的话,最好选择 AspectJ ,它比 Spring AOP 快很多。\n模板方法 # 模板方法模式是一种行为设计模式,它定义一个操作中的算法的骨架,而将一些步骤延迟到子类中。 模板方法使得子类可以不改变一个算法的结构即可重定义该算法的某些特定步骤的实现方式。\npublic abstract class Template { //这是我们的模板方法 public final void TemplateMethod(){ PrimitiveOperation1(); PrimitiveOperation2(); PrimitiveOperation3(); } protected void PrimitiveOperation1(){ //当前类实现 } //被子类实现的方法 protected abstract void PrimitiveOperation2(); protected abstract void PrimitiveOperation3(); } public class TemplateImpl extends Template { @Override public void PrimitiveOperation2() { //当前类实现 } @Override public void PrimitiveOperation3() { //当前类实现 } } Spring 中 JdbcTemplate、HibernateTemplate 等以 Template 结尾的对数据库操作的类,它们就使用到了模板模式。一般情况下,我们都是使用继承的方式来实现模板模式,但是 Spring 并没有使用这种方式,而是使用 Callback 模式与模板方法模式配合,既达到了代码复用的效果,同时增加了灵活性。\n观察者模式 # 观察者模式是一种对象行为型模式。它表示的是一种对象与对象之间具有依赖关系,当一个对象发生改变的时候,依赖这个对象的所有对象也会做出反应。Spring 事件驱动模型就是观察者模式很经典的一个应用。Spring 事件驱动模型非常有用,在很多场景都可以解耦我们的代码。比如我们每次添加商品的时候都需要重新更新商品索引,这个时候就可以利用观察者模式来解决这个问题。\nSpring 事件驱动模型中的三种角色 # 事件角色 # ApplicationEvent (org.springframework.context包下)充当事件的角色,这是一个抽象类,它继承了java.util.EventObject并实现了 java.io.Serializable接口。\nSpring 中默认存在以下事件,他们都是对 ApplicationContextEvent 的实现(继承自ApplicationContextEvent):\nContextStartedEvent:ApplicationContext 启动后触发的事件; ContextStoppedEvent:ApplicationContext 停止后触发的事件; ContextRefreshedEvent:ApplicationContext 初始化或刷新完成后触发的事件; ContextClosedEvent:ApplicationContext 关闭后触发的事件。 事件监听者角色 # ApplicationListener 充当了事件监听者角色,它是一个接口,里面只定义了一个 onApplicationEvent()方法来处理ApplicationEvent。ApplicationListener接口类源码如下,可以看出接口定义看出接口中的事件只要实现了 ApplicationEvent就可以了。所以,在 Spring 中我们只要实现 ApplicationListener 接口的 onApplicationEvent() 方法即可完成监听事件\npackage org.springframework.context; import java.util.EventListener; @FunctionalInterface public interface ApplicationListener\u0026lt;E extends ApplicationEvent\u0026gt; extends EventListener { void onApplicationEvent(E var1); } 事件发布者角色 # ApplicationEventPublisher 充当了事件的发布者,它也是一个接口。\n@FunctionalInterface public interface ApplicationEventPublisher { default void publishEvent(ApplicationEvent event) { this.publishEvent((Object)event); } void publishEvent(Object var1); } ApplicationEventPublisher 接口的publishEvent()这个方法在AbstractApplicationContext类中被实现,阅读这个方法的实现,你会发现实际上事件真正是通过ApplicationEventMulticaster来广播出去的。具体内容过多,就不在这里分析了,后面可能会单独写一篇文章提到。\nSpring 的事件流程总结 # 定义一个事件: 实现一个继承自 ApplicationEvent,并且写相应的构造函数; 定义一个事件监听者:实现 ApplicationListener 接口,重写 onApplicationEvent() 方法; 使用事件发布者发布消息: 可以通过 ApplicationEventPublisher 的 publishEvent() 方法发布消息。 Example:\n// 定义一个事件,继承自ApplicationEvent并且写相应的构造函数 public class DemoEvent extends ApplicationEvent{ private static final long serialVersionUID = 1L; private String message; public DemoEvent(Object source,String message){ super(source); this.message = message; } public String getMessage() { return message; } // 定义一个事件监听者,实现ApplicationListener接口,重写 onApplicationEvent() 方法; @Component public class DemoListener implements ApplicationListener\u0026lt;DemoEvent\u0026gt;{ //使用onApplicationEvent接收消息 @Override public void onApplicationEvent(DemoEvent event) { String msg = event.getMessage(); System.out.println(\u0026#34;接收到的信息是:\u0026#34;+msg); } } // 发布事件,可以通过ApplicationEventPublisher 的 publishEvent() 方法发布消息。 @Component public class DemoPublisher { @Autowired ApplicationContext applicationContext; public void publish(String message){ //发布事件 applicationContext.publishEvent(new DemoEvent(this, message)); } } 当调用 DemoPublisher 的 publish() 方法的时候,比如 demoPublisher.publish(\u0026quot;你好\u0026quot;) ,控制台就会打印出:接收到的信息是:你好 。\n适配器模式 # 适配器模式(Adapter Pattern) 将一个接口转换成客户希望的另一个接口,适配器模式使接口不兼容的那些类可以一起工作。\nSpring AOP 中的适配器模式 # 我们知道 Spring AOP 的实现是基于代理模式,但是 Spring AOP 的增强或通知(Advice)使用到了适配器模式,与之相关的接口是AdvisorAdapter 。\nAdvice 常用的类型有:BeforeAdvice(目标方法调用前,前置通知)、AfterAdvice(目标方法调用后,后置通知)、AfterReturningAdvice(目标方法执行结束后,return 之前)等等。每个类型 Advice(通知)都有对应的拦截器:MethodBeforeAdviceInterceptor、AfterReturningAdviceInterceptor、ThrowsAdviceInterceptor 等等。\nSpring 预定义的通知要通过对应的适配器,适配成 MethodInterceptor 接口(方法拦截器)类型的对象(如:MethodBeforeAdviceAdapter 通过调用 getInterceptor 方法,将 MethodBeforeAdvice 适配成 MethodBeforeAdviceInterceptor )。\nSpring MVC 中的适配器模式 # 在 Spring MVC 中,DispatcherServlet 根据请求信息调用 HandlerMapping,解析请求对应的 Handler。解析到对应的 Handler(也就是我们平常说的 Controller 控制器)后,开始由HandlerAdapter 适配器处理。HandlerAdapter 作为期望接口,具体的适配器实现类用于对目标类进行适配,Controller 作为需要适配的类。\n为什么要在 Spring MVC 中使用适配器模式?\nSpring MVC 中的 Controller 种类众多,不同类型的 Controller 通过不同的方法来对请求进行处理。如果不利用适配器模式的话,DispatcherServlet 直接获取对应类型的 Controller,需要的自行来判断,像下面这段代码一样:\nif(mappedHandler.getHandler() instanceof MultiActionController){ ((MultiActionController)mappedHandler.getHandler()).xxx }else if(mappedHandler.getHandler() instanceof XXX){ ... }else if(...){ ... } 假如我们再增加一个 Controller类型就要在上面代码中再加入一行 判断语句,这种形式就使得程序难以维护,也违反了设计模式中的开闭原则 – 对扩展开放,对修改关闭。\n装饰者模式 # 装饰者模式可以动态地给对象添加一些额外的属性或行为。相比于使用继承,装饰者模式更加灵活。简单点儿说就是当我们需要修改原有的功能,但我们又不愿直接去修改原有的代码时,设计一个 Decorator 套在原有代码外面。其实在 JDK 中就有很多地方用到了装饰者模式,比如 InputStream家族,InputStream 类下有 FileInputStream (读取文件)、BufferedInputStream (增加缓存,使读取文件速度大大提升)等子类都在不修改InputStream 代码的情况下扩展了它的功能。\nSpring 中配置 DataSource 的时候,DataSource 可能是不同的数据库和数据源。我们能否根据客户的需求在少修改原有类的代码下动态切换不同的数据源?这个时候就要用到装饰者模式(这一点我自己还没太理解具体原理)。Spring 中用到的包装器模式在类名上含有 Wrapper或者 Decorator。这些类基本上都是动态地给一个对象添加一些额外的职责\n总结 # Spring 框架中用到了哪些设计模式?\n工厂设计模式 : Spring 使用工厂模式通过 BeanFactory、ApplicationContext 创建 bean 对象。 代理设计模式 : Spring AOP 功能的实现。 单例设计模式 : Spring 中的 Bean 默认都是单例的。 模板方法模式 : Spring 中 jdbcTemplate、hibernateTemplate 等以 Template 结尾的对数据库操作的类,它们就使用到了模板模式。 包装器设计模式 : 我们的项目需要连接多个数据库,而且不同的客户在每次访问中根据需要会去访问不同的数据库。这种模式让我们可以根据客户的需求能够动态切换不同的数据源。 观察者模式: Spring 事件驱动模型就是观察者模式很经典的一个应用。 适配器模式 :Spring AOP 的增强或通知(Advice)使用到了适配器模式、spring MVC 中也是用到了适配器模式适配Controller。 …… 参考 # 《Spring 技术内幕》 https://blog.eduonix.com/java-programming-2/learn-design-patterns-used-spring-framework/ https://www.tutorialsteacher.com/ioc/inversion-of-control https://design-patterns.readthedocs.io/zh_CN/latest/behavioral_patterns/observer.html https://juejin.im/post/5a8eb261f265da4e9e307230 https://juejin.im/post/5ba28986f265da0abc2b6084 "},{"id":573,"href":"/zh/docs/technology/Interview/system-design/framework/spring/spring-common-annotations/","title":"Spring\u0026SpringBoot常用注解总结","section":"Framework","content":" 0.前言 # 可以毫不夸张地说,这篇文章介绍的 Spring/SpringBoot 常用注解基本已经涵盖你工作中遇到的大部分常用的场景。对于每一个注解我都说了具体用法,掌握搞懂,使用 SpringBoot 来开发项目基本没啥大问题了!\n为什么要写这篇文章?\n最近看到网上有一篇关于 SpringBoot 常用注解的文章被转载的比较多,我看了文章内容之后属实觉得质量有点低,并且有点会误导没有太多实际使用经验的人(这些人又占据了大多数)。所以,自己索性花了大概 两天时间简单总结一下了。\n因为我个人的能力和精力有限,如果有任何不对或者需要完善的地方,请帮忙指出!Guide 感激不尽!\n1. @SpringBootApplication # 这里先单独拎出@SpringBootApplication 注解说一下,虽然我们一般不会主动去使用它。\nGuide:这个注解是 Spring Boot 项目的基石,创建 SpringBoot 项目之后会默认在主类加上。\n@SpringBootApplication public class SpringSecurityJwtGuideApplication { public static void main(java.lang.String[] args) { SpringApplication.run(SpringSecurityJwtGuideApplication.class, args); } } 我们可以把 @SpringBootApplication看作是 @Configuration、@EnableAutoConfiguration、@ComponentScan 注解的集合。\npackage org.springframework.boot.autoconfigure; @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 { ...... } package org.springframework.boot; @Target(ElementType.TYPE) @Retention(RetentionPolicy.RUNTIME) @Documented @Configuration public @interface SpringBootConfiguration { } 根据 SpringBoot 官网,这三个注解的作用分别是:\n@EnableAutoConfiguration:启用 SpringBoot 的自动配置机制 @ComponentScan:扫描被@Component (@Repository,@Service,@Controller)注解的 bean,注解默认会扫描该类所在的包下所有的类。 @Configuration:允许在 Spring 上下文中注册额外的 bean 或导入其他配置类 2. Spring Bean 相关 # 2.1. @Autowired # 自动导入对象到类中,被注入进的类同样要被 Spring 容器管理比如:Service 类注入到 Controller 类中。\n@Service public class UserService { ...... } @RestController @RequestMapping(\u0026#34;/users\u0026#34;) public class UserController { @Autowired private UserService userService; ...... } 2.2. @Component,@Repository,@Service, @Controller # 我们一般使用 @Autowired 注解让 Spring 容器帮我们自动装配 bean。要想把类标识成可用于 @Autowired 注解自动装配的 bean 的类,可以采用以下注解实现:\n@Component:通用的注解,可标注任意类为 Spring 组件。如果一个 Bean 不知道属于哪个层,可以使用@Component 注解标注。 @Repository : 对应持久层即 Dao 层,主要用于数据库相关操作。 @Service : 对应服务层,主要涉及一些复杂的逻辑,需要用到 Dao 层。 @Controller : 对应 Spring MVC 控制层,主要用于接受用户请求并调用 Service 层返回数据给前端页面。 2.3. @RestController # @RestController注解是@Controller和@ResponseBody的合集,表示这是个控制器 bean,并且是将函数的返回值直接填入 HTTP 响应体中,是 REST 风格的控制器。\nGuide:现在都是前后端分离,说实话我已经很久没有用过@Controller。如果你的项目太老了的话,就当我没说。\n单独使用 @Controller 不加 @ResponseBody的话一般是用在要返回一个视图的情况,这种情况属于比较传统的 Spring MVC 的应用,对应于前后端不分离的情况。@Controller +@ResponseBody 返回 JSON 或 XML 形式数据\n关于@RestController 和 @Controller的对比,请看这篇文章: @RestController vs @Controller。\n2.4. @Scope # 声明 Spring Bean 的作用域,使用方法:\n@Bean @Scope(\u0026#34;singleton\u0026#34;) public Person personSingleton() { return new Person(); } 四种常见的 Spring Bean 的作用域:\nsingleton : 唯一 bean 实例,Spring 中的 bean 默认都是单例的。 prototype : 每次请求都会创建一个新的 bean 实例。 request : 每一次 HTTP 请求都会产生一个新的 bean,该 bean 仅在当前 HTTP request 内有效。 session : 每一个 HTTP Session 会产生一个新的 bean,该 bean 仅在当前 HTTP session 内有效。 2.5. @Configuration # 一般用来声明配置类,可以使用 @Component注解替代,不过使用@Configuration注解声明配置类更加语义化。\n@Configuration public class AppConfig { @Bean public TransferService transferService() { return new TransferServiceImpl(); } } 3. 处理常见的 HTTP 请求类型 # 5 种常见的请求类型:\nGET:请求从服务器获取特定资源。举个例子:GET /users(获取所有学生) POST:在服务器上创建一个新的资源。举个例子:POST /users(创建学生) PUT:更新服务器上的资源(客户端提供更新后的整个资源)。举个例子:PUT /users/12(更新编号为 12 的学生) DELETE:从服务器删除特定的资源。举个例子:DELETE /users/12(删除编号为 12 的学生) PATCH:更新服务器上的资源(客户端提供更改的属性,可以看做作是部分更新),使用的比较少,这里就不举例子了。 3.1. GET 请求 # @GetMapping(\u0026quot;users\u0026quot;) 等价于@RequestMapping(value=\u0026quot;/users\u0026quot;,method=RequestMethod.GET)\n@GetMapping(\u0026#34;/users\u0026#34;) public ResponseEntity\u0026lt;List\u0026lt;User\u0026gt;\u0026gt; getAllUsers() { return userRepository.findAll(); } 3.2. POST 请求 # @PostMapping(\u0026quot;users\u0026quot;) 等价于@RequestMapping(value=\u0026quot;/users\u0026quot;,method=RequestMethod.POST)\n关于@RequestBody注解的使用,在下面的“前后端传值”这块会讲到。\n@PostMapping(\u0026#34;/users\u0026#34;) public ResponseEntity\u0026lt;User\u0026gt; createUser(@Valid @RequestBody UserCreateRequest userCreateRequest) { return userRepository.save(userCreateRequest); } 3.3. PUT 请求 # @PutMapping(\u0026quot;/users/{userId}\u0026quot;) 等价于@RequestMapping(value=\u0026quot;/users/{userId}\u0026quot;,method=RequestMethod.PUT)\n@PutMapping(\u0026#34;/users/{userId}\u0026#34;) public ResponseEntity\u0026lt;User\u0026gt; updateUser(@PathVariable(value = \u0026#34;userId\u0026#34;) Long userId, @Valid @RequestBody UserUpdateRequest userUpdateRequest) { ...... } 3.4. DELETE 请求 # @DeleteMapping(\u0026quot;/users/{userId}\u0026quot;)等价于@RequestMapping(value=\u0026quot;/users/{userId}\u0026quot;,method=RequestMethod.DELETE)\n@DeleteMapping(\u0026#34;/users/{userId}\u0026#34;) public ResponseEntity deleteUser(@PathVariable(value = \u0026#34;userId\u0026#34;) Long userId){ ...... } 3.5. PATCH 请求 # 一般实际项目中,我们都是 PUT 不够用了之后才用 PATCH 请求去更新数据。\n@PatchMapping(\u0026#34;/profile\u0026#34;) public ResponseEntity updateStudent(@RequestBody StudentUpdateRequest studentUpdateRequest) { studentRepository.updateDetail(studentUpdateRequest); return ResponseEntity.ok().build(); } 4. 前后端传值 # 掌握前后端传值的正确姿势,是你开始 CRUD 的第一步!\n4.1. @PathVariable 和 @RequestParam # @PathVariable用于获取路径参数,@RequestParam用于获取查询参数。\n举个简单的例子:\n@GetMapping(\u0026#34;/klasses/{klassId}/teachers\u0026#34;) public List\u0026lt;Teacher\u0026gt; getKlassRelatedTeachers( @PathVariable(\u0026#34;klassId\u0026#34;) Long klassId, @RequestParam(value = \u0026#34;type\u0026#34;, required = false) String type ) { ... } 如果我们请求的 url 是:/klasses/123456/teachers?type=web\n那么我们服务获取到的数据就是:klassId=123456,type=web。\n4.2. @RequestBody # 用于读取 Request 请求(可能是 POST,PUT,DELETE,GET 请求)的 body 部分并且Content-Type 为 application/json 格式的数据,接收到数据之后会自动将数据绑定到 Java 对象上去。系统会使用HttpMessageConverter或者自定义的HttpMessageConverter将请求的 body 中的 json 字符串转换为 java 对象。\n我用一个简单的例子来给演示一下基本使用!\n我们有一个注册的接口:\n@PostMapping(\u0026#34;/sign-up\u0026#34;) public ResponseEntity signUp(@RequestBody @Valid UserRegisterRequest userRegisterRequest) { userService.save(userRegisterRequest); return ResponseEntity.ok().build(); } UserRegisterRequest对象:\n@Data @AllArgsConstructor @NoArgsConstructor public class UserRegisterRequest { @NotBlank private String userName; @NotBlank private String password; @NotBlank private String fullName; } 我们发送 post 请求到这个接口,并且 body 携带 JSON 数据:\n{ \u0026#34;userName\u0026#34;: \u0026#34;coder\u0026#34;, \u0026#34;fullName\u0026#34;: \u0026#34;shuangkou\u0026#34;, \u0026#34;password\u0026#34;: \u0026#34;123456\u0026#34; } 这样我们的后端就可以直接把 json 格式的数据映射到我们的 UserRegisterRequest 类上。\n👉 需要注意的是:一个请求方法只可以有一个@RequestBody,但是可以有多个@RequestParam和@PathVariable。 如果你的方法必须要用两个 @RequestBody来接受数据的话,大概率是你的数据库设计或者系统设计出问题了!\n5. 读取配置信息 # 很多时候我们需要将一些常用的配置信息比如阿里云 oss、发送短信、微信认证的相关配置信息等等放到配置文件中。\n下面我们来看一下 Spring 为我们提供了哪些方式帮助我们从配置文件中读取这些配置信息。\n我们的数据源application.yml内容如下:\nwuhan2020: 2020年初武汉爆发了新型冠状病毒,疫情严重,但是,我相信一切都会过去!武汉加油!中国加油! my-profile: name: Guide哥 email: koushuangbwcx@163.com library: location: 湖北武汉加油中国加油 books: - name: 天才基本法 description: 二十二岁的林朝夕在父亲确诊阿尔茨海默病这天,得知自己暗恋多年的校园男神裴之即将出国深造的消息——对方考取的学校,恰是父亲当年为她放弃的那所。 - name: 时间的秩序 description: 为什么我们记得过去,而非未来?时间“流逝”意味着什么?是我们存在于时间之内,还是时间存在于我们之中?卡洛·罗韦利用诗意的文字,邀请我们思考这一亘古难题——时间的本质。 - name: 了不起的我 description: 如何养成一个新习惯?如何让心智变得更成熟?如何拥有高质量的关系? 如何走出人生的艰难时刻? 5.1. @Value(常用) # 使用 @Value(\u0026quot;${property}\u0026quot;) 读取比较简单的配置信息:\n@Value(\u0026#34;${wuhan2020}\u0026#34;) String wuhan2020; 5.2. @ConfigurationProperties(常用) # 通过@ConfigurationProperties读取配置信息并与 bean 绑定。\n@Component @ConfigurationProperties(prefix = \u0026#34;library\u0026#34;) class LibraryProperties { @NotEmpty private String location; private List\u0026lt;Book\u0026gt; books; @Setter @Getter @ToString static class Book { String name; String description; } 省略getter/setter ...... } 你可以像使用普通的 Spring bean 一样,将其注入到类中使用。\n5.3. @PropertySource(不常用) # @PropertySource读取指定 properties 文件\n@Component @PropertySource(\u0026#34;classpath:website.properties\u0026#34;) class WebSite { @Value(\u0026#34;${url}\u0026#34;) private String url; 省略getter/setter ...... } 更多内容请查看我的这篇文章: 《10 分钟搞定 SpringBoot 如何优雅读取配置文件?》 。\n6. 参数校验 # 数据的校验的重要性就不用说了,即使在前端对数据进行校验的情况下,我们还是要对传入后端的数据再进行一遍校验,避免用户绕过浏览器直接通过一些 HTTP 工具直接向后端请求一些违法数据。\nBean Validation 是一套定义 JavaBean 参数校验标准的规范 (JSR 303, 349, 380),它提供了一系列注解,可以直接用于 JavaBean 的属性上,从而实现便捷的参数校验。\nJSR 303 (Bean Validation 1.0): 奠定了基础,引入了核心校验注解(如 @NotNull、@Size、@Min、@Max 等),定义了如何通过注解的方式对 JavaBean 的属性进行校验,并支持嵌套对象校验和自定义校验器。 JSR 349 (Bean Validation 1.1): 在 1.0 基础上进行扩展,例如引入了对方法参数和返回值校验的支持、增强了对分组校验(Group Validation)的处理。 JSR 380 (Bean Validation 2.0): 拥抱 Java 8 的新特性,并进行了一些改进,例如支持 java.time 包中的日期和时间类型、引入了一些新的校验注解(如 @NotEmpty, @NotBlank等)。 校验的时候我们实际用的是 Hibernate Validator 框架。Hibernate Validator 是 Hibernate 团队最初的数据校验框架,Hibernate Validator 4.x 是 Bean Validation 1.0(JSR 303)的参考实现,Hibernate Validator 5.x 是 Bean Validation 1.1(JSR 349)的参考实现,目前最新版的 Hibernate Validator 6.x 是 Bean Validation 2.0(JSR 380)的参考实现。\nSpringBoot 项目的 spring-boot-starter-web 依赖中已经有 hibernate-validator 包,不需要引用相关依赖。如下图所示(通过 idea 插件—Maven Helper 生成):\n注:更新版本的 spring-boot-starter-web 依赖中不再有 hibernate-validator 包(如 2.3.11.RELEASE),需要自己引入 spring-boot-starter-validation 依赖。\n非 SpringBoot 项目需要自行引入相关依赖包,这里不多做讲解,具体可以查看我的这篇文章:《 如何在 Spring/Spring Boot 中做参数校验?你需要了解的都在这里!》。\n👉 需要注意的是:所有的注解,推荐使用 JSR 注解,即javax.validation.constraints,而不是org.hibernate.validator.constraints\n6.1. 一些常用的字段验证的注解 # @NotEmpty 被注释的字符串的不能为 null 也不能为空 @NotBlank 被注释的字符串非 null,并且必须包含一个非空白字符 @Null 被注释的元素必须为 null @NotNull 被注释的元素必须不为 null @AssertTrue 被注释的元素必须为 true @AssertFalse 被注释的元素必须为 false @Pattern(regex=,flag=)被注释的元素必须符合指定的正则表达式 @Email 被注释的元素必须是 Email 格式。 @Min(value)被注释的元素必须是一个数字,其值必须大于等于指定的最小值 @Max(value)被注释的元素必须是一个数字,其值必须小于等于指定的最大值 @DecimalMin(value)被注释的元素必须是一个数字,其值必须大于等于指定的最小值 @DecimalMax(value) 被注释的元素必须是一个数字,其值必须小于等于指定的最大值 @Size(max=, min=)被注释的元素的大小必须在指定的范围内 @Digits(integer, fraction)被注释的元素必须是一个数字,其值必须在可接受的范围内 @Past被注释的元素必须是一个过去的日期 @Future 被注释的元素必须是一个将来的日期 …… 6.2. 验证请求体(RequestBody) # @Data @AllArgsConstructor @NoArgsConstructor public class Person { @NotNull(message = \u0026#34;classId 不能为空\u0026#34;) private String classId; @Size(max = 33) @NotNull(message = \u0026#34;name 不能为空\u0026#34;) private String name; @Pattern(regexp = \u0026#34;((^Man$|^Woman$|^UGM$))\u0026#34;, message = \u0026#34;sex 值不在可选范围\u0026#34;) @NotNull(message = \u0026#34;sex 不能为空\u0026#34;) private String sex; @Email(message = \u0026#34;email 格式不正确\u0026#34;) @NotNull(message = \u0026#34;email 不能为空\u0026#34;) private String email; } 我们在需要验证的参数上加上了@Valid注解,如果验证失败,它将抛出MethodArgumentNotValidException。\n@RestController @RequestMapping(\u0026#34;/api\u0026#34;) public class PersonController { @PostMapping(\u0026#34;/person\u0026#34;) public ResponseEntity\u0026lt;Person\u0026gt; getPerson(@RequestBody @Valid Person person) { return ResponseEntity.ok().body(person); } } 6.3. 验证请求参数(Path Variables 和 Request Parameters) # 一定一定不要忘记在类上加上 @Validated 注解了,这个参数可以告诉 Spring 去校验方法参数。\n@RestController @RequestMapping(\u0026#34;/api\u0026#34;) @Validated public class PersonController { @GetMapping(\u0026#34;/person/{id}\u0026#34;) public ResponseEntity\u0026lt;Integer\u0026gt; getPersonByID(@Valid @PathVariable(\u0026#34;id\u0026#34;) @Max(value = 5,message = \u0026#34;超过 id 的范围了\u0026#34;) Integer id) { return ResponseEntity.ok().body(id); } } 更多关于如何在 Spring 项目中进行参数校验的内容,请看《 如何在 Spring/Spring Boot 中做参数校验?你需要了解的都在这里!》这篇文章。\n7. 全局处理 Controller 层异常 # 介绍一下我们 Spring 项目必备的全局处理 Controller 层异常。\n相关注解:\n@ControllerAdvice :注解定义全局异常处理类 @ExceptionHandler :注解声明异常处理方法 如何使用呢?拿我们在第 5 节参数校验这块来举例子。如果方法参数不对的话就会抛出MethodArgumentNotValidException,我们来处理这个异常。\n@ControllerAdvice @ResponseBody public class GlobalExceptionHandler { /** * 请求参数异常处理 */ @ExceptionHandler(MethodArgumentNotValidException.class) public ResponseEntity\u0026lt;?\u0026gt; handleMethodArgumentNotValidException(MethodArgumentNotValidException ex, HttpServletRequest request) { ...... } } 更多关于 Spring Boot 异常处理的内容,请看我的这两篇文章:\nSpringBoot 处理异常的几种常见姿势 使用枚举简单封装一个优雅的 Spring Boot 全局异常处理! 8. JPA 相关 # 8.1. 创建表 # @Entity声明一个类对应一个数据库实体。\n@Table 设置表名\n@Entity @Table(name = \u0026#34;role\u0026#34;) public class Role { @Id @GeneratedValue(strategy = GenerationType.IDENTITY) private Long id; private String name; private String description; 省略getter/setter...... } 8.2. 创建主键 # @Id:声明一个字段为主键。\n使用@Id声明之后,我们还需要定义主键的生成策略。我们可以使用 @GeneratedValue 指定主键生成策略。\n1.通过 @GeneratedValue直接使用 JPA 内置提供的四种主键生成策略来指定主键生成策略。\n@Id @GeneratedValue(strategy = GenerationType.IDENTITY) private Long id; JPA 使用枚举定义了 4 种常见的主键生成策略,如下:\nGuide:枚举替代常量的一种用法\npublic enum GenerationType { /** * 使用一个特定的数据库表格来保存主键 * 持久化引擎通过关系数据库的一张特定的表格来生成主键, */ TABLE, /** *在某些数据库中,不支持主键自增长,比如Oracle、PostgreSQL其提供了一种叫做\u0026#34;序列(sequence)\u0026#34;的机制生成主键 */ SEQUENCE, /** * 主键自增长 */ IDENTITY, /** *把主键生成策略交给持久化引擎(persistence engine), *持久化引擎会根据数据库在以上三种主键生成 策略中选择其中一种 */ AUTO } @GeneratedValue注解默认使用的策略是GenerationType.AUTO\npublic @interface GeneratedValue { GenerationType strategy() default AUTO; String generator() default \u0026#34;\u0026#34;; } 一般使用 MySQL 数据库的话,使用GenerationType.IDENTITY策略比较普遍一点(分布式系统的话需要另外考虑使用分布式 ID)。\n2.通过 @GenericGenerator声明一个主键策略,然后 @GeneratedValue使用这个策略\n@Id @GeneratedValue(generator = \u0026#34;IdentityIdGenerator\u0026#34;) @GenericGenerator(name = \u0026#34;IdentityIdGenerator\u0026#34;, strategy = \u0026#34;identity\u0026#34;) private Long id; 等价于:\n@Id @GeneratedValue(strategy = GenerationType.IDENTITY) private Long id; jpa 提供的主键生成策略有如下几种:\npublic class DefaultIdentifierGeneratorFactory implements MutableIdentifierGeneratorFactory, Serializable, ServiceRegistryAwareService { @SuppressWarnings(\u0026#34;deprecation\u0026#34;) public DefaultIdentifierGeneratorFactory() { register( \u0026#34;uuid2\u0026#34;, UUIDGenerator.class ); register( \u0026#34;guid\u0026#34;, GUIDGenerator.class ); // can be done with UUIDGenerator + strategy register( \u0026#34;uuid\u0026#34;, UUIDHexGenerator.class ); // \u0026#34;deprecated\u0026#34; for new use register( \u0026#34;uuid.hex\u0026#34;, UUIDHexGenerator.class ); // uuid.hex is deprecated register( \u0026#34;assigned\u0026#34;, Assigned.class ); register( \u0026#34;identity\u0026#34;, IdentityGenerator.class ); register( \u0026#34;select\u0026#34;, SelectGenerator.class ); register( \u0026#34;sequence\u0026#34;, SequenceStyleGenerator.class ); register( \u0026#34;seqhilo\u0026#34;, SequenceHiLoGenerator.class ); register( \u0026#34;increment\u0026#34;, IncrementGenerator.class ); register( \u0026#34;foreign\u0026#34;, ForeignGenerator.class ); register( \u0026#34;sequence-identity\u0026#34;, SequenceIdentityGenerator.class ); register( \u0026#34;enhanced-sequence\u0026#34;, SequenceStyleGenerator.class ); register( \u0026#34;enhanced-table\u0026#34;, TableGenerator.class ); } public void register(String strategy, Class generatorClass) { LOG.debugf( \u0026#34;Registering IdentifierGenerator strategy [%s] -\u0026gt; [%s]\u0026#34;, strategy, generatorClass.getName() ); final Class previous = generatorStrategyToClassNameMap.put( strategy, generatorClass ); if ( previous != null ) { LOG.debugf( \u0026#34; - overriding [%s]\u0026#34;, previous.getName() ); } } } 8.3. 设置字段类型 # @Column 声明字段。\n示例:\n设置属性 userName 对应的数据库字段名为 user_name,长度为 32,非空\n@Column(name = \u0026#34;user_name\u0026#34;, nullable = false, length=32) private String userName; 设置字段类型并且加默认值,这个还是挺常用的。\n@Column(columnDefinition = \u0026#34;tinyint(1) default 1\u0026#34;) private Boolean enabled; 8.4. 指定不持久化特定字段 # @Transient:声明不需要与数据库映射的字段,在保存的时候不需要保存进数据库 。\n如果我们想让secrect 这个字段不被持久化,可以使用 @Transient关键字声明。\n@Entity(name=\u0026#34;USER\u0026#34;) public class User { ...... @Transient private String secrect; // not persistent because of @Transient } 除了 @Transient关键字声明, 还可以采用下面几种方法:\nstatic String secrect; // not persistent because of static final String secrect = \u0026#34;Satish\u0026#34;; // not persistent because of final transient String secrect; // not persistent because of transient 一般使用注解的方式比较多。\n8.5. 声明大字段 # @Lob:声明某个字段为大字段。\n@Lob private String content; 更详细的声明:\n@Lob //指定 Lob 类型数据的获取策略, FetchType.EAGER 表示非延迟加载,而 FetchType.LAZY 表示延迟加载 ; @Basic(fetch = FetchType.EAGER) //columnDefinition 属性指定数据表对应的 Lob 字段类型 @Column(name = \u0026#34;content\u0026#34;, columnDefinition = \u0026#34;LONGTEXT NOT NULL\u0026#34;) private String content; 8.6. 创建枚举类型的字段 # 可以使用枚举类型的字段,不过枚举字段要用@Enumerated注解修饰。\npublic enum Gender { MALE(\u0026#34;男性\u0026#34;), FEMALE(\u0026#34;女性\u0026#34;); private String value; Gender(String str){ value=str; } } @Entity @Table(name = \u0026#34;role\u0026#34;) public class Role { @Id @GeneratedValue(strategy = GenerationType.IDENTITY) private Long id; private String name; private String description; @Enumerated(EnumType.STRING) private Gender gender; 省略getter/setter...... } 数据库里面对应存储的是 MALE/FEMALE。\n8.7. 增加审计功能 # 只要继承了 AbstractAuditBase的类都会默认加上下面四个字段。\n@Data @AllArgsConstructor @NoArgsConstructor @MappedSuperclass @EntityListeners(value = AuditingEntityListener.class) public abstract class AbstractAuditBase { @CreatedDate @Column(updatable = false) @JsonIgnore private Instant createdAt; @LastModifiedDate @JsonIgnore private Instant updatedAt; @CreatedBy @Column(updatable = false) @JsonIgnore private String createdBy; @LastModifiedBy @JsonIgnore private String updatedBy; } 我们对应的审计功能对应地配置类可能是下面这样的(Spring Security 项目):\n@Configuration @EnableJpaAuditing public class AuditSecurityConfiguration { @Bean AuditorAware\u0026lt;String\u0026gt; auditorAware() { return () -\u0026gt; Optional.ofNullable(SecurityContextHolder.getContext()) .map(SecurityContext::getAuthentication) .filter(Authentication::isAuthenticated) .map(Authentication::getName); } } 简单介绍一下上面涉及到的一些注解:\n@CreatedDate: 表示该字段为创建时间字段,在这个实体被 insert 的时候,会设置值\n@CreatedBy :表示该字段为创建人,在这个实体被 insert 的时候,会设置值\n@LastModifiedDate、@LastModifiedBy同理。\n@EnableJpaAuditing:开启 JPA 审计功能。\n8.8. 删除/修改数据 # @Modifying 注解提示 JPA 该操作是修改操作,注意还要配合@Transactional注解使用。\n@Repository public interface UserRepository extends JpaRepository\u0026lt;User, Integer\u0026gt; { @Modifying @Transactional(rollbackFor = Exception.class) void deleteByUserName(String userName); } 8.9. 关联关系 # @OneToOne 声明一对一关系 @OneToMany 声明一对多关系 @ManyToOne 声明多对一关系 @ManyToMany 声明多对多关系 更多关于 Spring Boot JPA 的文章请看我的这篇文章: 一文搞懂如何在 Spring Boot 正确中使用 JPA 。\n9. 事务 @Transactional # 在要开启事务的方法上使用@Transactional注解即可!\n@Transactional(rollbackFor = Exception.class) public void save() { ...... } 我们知道 Exception 分为运行时异常 RuntimeException 和非运行时异常。在@Transactional注解中如果不配置rollbackFor属性,那么事务只会在遇到RuntimeException的时候才会回滚,加上rollbackFor=Exception.class,可以让事务在遇到非运行时异常时也回滚。\n@Transactional 注解一般可以作用在类或者方法上。\n作用于类:当把@Transactional 注解放在类上时,表示所有该类的 public 方法都配置相同的事务属性信息。 作用于方法:当类配置了@Transactional,方法也配置了@Transactional,方法的事务会覆盖类的事务配置信息。 更多关于 Spring 事务的内容请查看我的这篇文章: 可能是最漂亮的 Spring 事务管理详解 。\n10. json 数据处理 # 10.1. 过滤 json 数据 # @JsonIgnoreProperties 作用在类上用于过滤掉特定字段不返回或者不解析。\n//生成json时将userRoles属性过滤 @JsonIgnoreProperties({\u0026#34;userRoles\u0026#34;}) public class User { private String userName; private String fullName; private String password; private List\u0026lt;UserRole\u0026gt; userRoles = new ArrayList\u0026lt;\u0026gt;(); } @JsonIgnore一般用于类的属性上,作用和上面的@JsonIgnoreProperties 一样。\npublic class User { private String userName; private String fullName; private String password; //生成json时将userRoles属性过滤 @JsonIgnore private List\u0026lt;UserRole\u0026gt; userRoles = new ArrayList\u0026lt;\u0026gt;(); } 10.2. 格式化 json 数据 # @JsonFormat一般用来格式化 json 数据。\n比如:\n@JsonFormat(shape=JsonFormat.Shape.STRING, pattern=\u0026#34;yyyy-MM-dd\u0026#39;T\u0026#39;HH:mm:ss.SSS\u0026#39;Z\u0026#39;\u0026#34;, timezone=\u0026#34;GMT\u0026#34;) private Date date; 10.3. 扁平化对象 # @Getter @Setter @ToString public class Account { private Location location; private PersonInfo personInfo; @Getter @Setter @ToString public static class Location { private String provinceName; private String countyName; } @Getter @Setter @ToString public static class PersonInfo { private String userName; private String fullName; } } 未扁平化之前:\n{ \u0026#34;location\u0026#34;: { \u0026#34;provinceName\u0026#34;: \u0026#34;湖北\u0026#34;, \u0026#34;countyName\u0026#34;: \u0026#34;武汉\u0026#34; }, \u0026#34;personInfo\u0026#34;: { \u0026#34;userName\u0026#34;: \u0026#34;coder1234\u0026#34;, \u0026#34;fullName\u0026#34;: \u0026#34;shaungkou\u0026#34; } } 使用@JsonUnwrapped 扁平对象之后:\n@Getter @Setter @ToString public class Account { @JsonUnwrapped private Location location; @JsonUnwrapped private PersonInfo personInfo; ...... } { \u0026#34;provinceName\u0026#34;: \u0026#34;湖北\u0026#34;, \u0026#34;countyName\u0026#34;: \u0026#34;武汉\u0026#34;, \u0026#34;userName\u0026#34;: \u0026#34;coder1234\u0026#34;, \u0026#34;fullName\u0026#34;: \u0026#34;shaungkou\u0026#34; } 11. 测试相关 # @ActiveProfiles一般作用于测试类上, 用于声明生效的 Spring 配置文件。\n@SpringBootTest(webEnvironment = RANDOM_PORT) @ActiveProfiles(\u0026#34;test\u0026#34;) @Slf4j public abstract class TestBase { ...... } @Test声明一个方法为测试方法\n@Transactional被声明的测试方法的数据会回滚,避免污染测试数据。\n@WithMockUser Spring Security 提供的,用来模拟一个真实用户,并且可以赋予权限。\n@Test @Transactional @WithMockUser(username = \u0026#34;user-id-18163138155\u0026#34;, authorities = \u0026#34;ROLE_TEACHER\u0026#34;) void should_import_student_success() throws Exception { ...... } 暂时总结到这里吧!虽然花了挺长时间才写完,不过可能还是会一些常用的注解的被漏掉,所以,我将文章也同步到了 Github 上去,Github 地址: 欢迎完善!\n本文已经收录进我的 75K Star 的 Java 开源项目 JavaGuide: https://github.com/Snailclimb/JavaGuide。\n"},{"id":574,"href":"/zh/docs/technology/Interview/system-design/framework/spring/spring-boot-auto-assembly-principles/","title":"SpringBoot 自动装配原理详解","section":"Framework","content":" 作者: Miki-byte-1024 \u0026amp; Snailclimb\n每次问到 Spring Boot, 面试官非常喜欢问这个问题:“讲述一下 SpringBoot 自动装配原理?”。\n我觉得我们可以从以下几个方面回答:\n什么是 SpringBoot 自动装配? SpringBoot 是如何实现自动装配的?如何实现按需加载? 如何实现一个 Starter? 篇幅问题,这篇文章并没有深入,小伙伴们也可以直接使用 debug 的方式去看看 SpringBoot 自动装配部分的源代码。\n前言 # 使用过 Spring 的小伙伴,一定有被 XML 配置统治的恐惧。即使 Spring 后面引入了基于注解的配置,我们在开启某些 Spring 特性或者引入第三方依赖的时候,还是需要用 XML 或 Java 进行显式配置。\n举个例子。没有 Spring Boot 的时候,我们写一个 RestFul Web 服务,还首先需要进行如下配置。\n@Configuration public class RESTConfiguration { @Bean public View jsonTemplate() { MappingJackson2JsonView view = new MappingJackson2JsonView(); view.setPrettyPrint(true); return view; } @Bean public ViewResolver viewResolver() { return new BeanNameViewResolver(); } } spring-servlet.xml\n\u0026lt;beans xmlns=\u0026#34;http://www.springframework.org/schema/beans\u0026#34; xmlns:xsi=\u0026#34;http://www.w3.org/2001/XMLSchema-instance\u0026#34; xmlns:context=\u0026#34;http://www.springframework.org/schema/context\u0026#34; xmlns:mvc=\u0026#34;http://www.springframework.org/schema/mvc\u0026#34; xsi:schemaLocation=\u0026#34;http://www.springframework.org/schema/beans http://www.springframework.org/schema/beans/spring-beans.xsd http://www.springframework.org/schema/context/ http://www.springframework.org/schema/context/spring-context.xsd http://www.springframework.org/schema/mvc/ http://www.springframework.org/schema/mvc/spring-mvc.xsd\u0026#34;\u0026gt; \u0026lt;context:component-scan base-package=\u0026#34;com.howtodoinjava.demo\u0026#34; /\u0026gt; \u0026lt;mvc:annotation-driven /\u0026gt; \u0026lt;!-- JSON Support --\u0026gt; \u0026lt;bean name=\u0026#34;viewResolver\u0026#34; class=\u0026#34;org.springframework.web.servlet.view.BeanNameViewResolver\u0026#34;/\u0026gt; \u0026lt;bean name=\u0026#34;jsonTemplate\u0026#34; class=\u0026#34;org.springframework.web.servlet.view.json.MappingJackson2JsonView\u0026#34;/\u0026gt; \u0026lt;/beans\u0026gt; 但是,Spring Boot 项目,我们只需要添加相关依赖,无需配置,通过启动下面的 main 方法即可。\n@SpringBootApplication public class DemoApplication { public static void main(String[] args) { SpringApplication.run(DemoApplication.class, args); } } 并且,我们通过 Spring Boot 的全局配置文件 application.properties或application.yml即可对项目进行设置比如更换端口号,配置 JPA 属性等等。\n为什么 Spring Boot 使用起来这么酸爽呢? 这得益于其自动装配。自动装配可以说是 Spring Boot 的核心,那究竟什么是自动装配呢?\n什么是 SpringBoot 自动装配? # 我们现在提到自动装配的时候,一般会和 Spring Boot 联系在一起。但是,实际上 Spring Framework 早就实现了这个功能。Spring Boot 只是在其基础上,通过 SPI 的方式,做了进一步优化。\nSpringBoot 定义了一套接口规范,这套规范规定:SpringBoot 在启动时会扫描外部引用 jar 包中的META-INF/spring.factories文件,将文件中配置的类型信息加载到 Spring 容器(此处涉及到 JVM 类加载机制与 Spring 的容器知识),并执行类中定义的各种操作。对于外部 jar 来说,只需要按照 SpringBoot 定义的标准,就能将自己的功能装置进 SpringBoot。 自 Spring Boot 3.0 开始,自动配置包的路径从META-INF/spring.factories 修改为 META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports。\n没有 Spring Boot 的情况下,如果我们需要引入第三方依赖,需要手动配置,非常麻烦。但是,Spring Boot 中,我们直接引入一个 starter 即可。比如你想要在项目中使用 redis 的话,直接在项目中引入对应的 starter 即可。\n\u0026lt;dependency\u0026gt; \u0026lt;groupId\u0026gt;org.springframework.boot\u0026lt;/groupId\u0026gt; \u0026lt;artifactId\u0026gt;spring-boot-starter-data-redis\u0026lt;/artifactId\u0026gt; \u0026lt;/dependency\u0026gt; 引入 starter 之后,我们通过少量注解和一些简单的配置就能使用第三方组件提供的功能了。\n在我看来,自动装配可以简单理解为:通过注解或者一些简单的配置就能在 Spring Boot 的帮助下实现某块功能。\nSpringBoot 是如何实现自动装配的? # 我们先看一下 SpringBoot 的核心注解 SpringBootApplication 。\n@Target({ElementType.TYPE}) @Retention(RetentionPolicy.RUNTIME) @Documented @Inherited \u0026lt;1.\u0026gt;@SpringBootConfiguration \u0026lt;2.\u0026gt;@ComponentScan \u0026lt;3.\u0026gt;@EnableAutoConfiguration public @interface SpringBootApplication { } @Target({ElementType.TYPE}) @Retention(RetentionPolicy.RUNTIME) @Documented @Configuration //实际上它也是一个配置类 public @interface SpringBootConfiguration { } 大概可以把 @SpringBootApplication看作是 @Configuration、@EnableAutoConfiguration、@ComponentScan 注解的集合。根据 SpringBoot 官网,这三个注解的作用分别是:\n@EnableAutoConfiguration:启用 SpringBoot 的自动配置机制 @Configuration:允许在上下文中注册额外的 bean 或导入其他配置类 @ComponentScan:扫描被@Component (@Service,@Controller)注解的 bean,注解默认会扫描启动类所在的包下所有的类 ,可以自定义不扫描某些 bean。如下图所示,容器中将排除TypeExcludeFilter和AutoConfigurationExcludeFilter。 @EnableAutoConfiguration 是实现自动装配的重要注解,我们以这个注解入手。\n@EnableAutoConfiguration:实现自动装配的核心注解 # EnableAutoConfiguration 只是一个简单地注解,自动装配核心功能的实现实际是通过 AutoConfigurationImportSelector类。\n@Target({ElementType.TYPE}) @Retention(RetentionPolicy.RUNTIME) @Documented @Inherited @AutoConfigurationPackage //作用:将main包下的所有组件注册到容器中 @Import({AutoConfigurationImportSelector.class}) //加载自动装配类 xxxAutoconfiguration public @interface EnableAutoConfiguration { String ENABLED_OVERRIDE_PROPERTY = \u0026#34;spring.boot.enableautoconfiguration\u0026#34;; Class\u0026lt;?\u0026gt;[] exclude() default {}; String[] excludeName() default {}; } 我们现在重点分析下AutoConfigurationImportSelector 类到底做了什么?\nAutoConfigurationImportSelector:加载自动装配类 # AutoConfigurationImportSelector类的继承体系如下:\npublic class AutoConfigurationImportSelector implements DeferredImportSelector, BeanClassLoaderAware, ResourceLoaderAware, BeanFactoryAware, EnvironmentAware, Ordered { } public interface DeferredImportSelector extends ImportSelector { } public interface ImportSelector { String[] selectImports(AnnotationMetadata var1); } 可以看出,AutoConfigurationImportSelector 类实现了 ImportSelector接口,也就实现了这个接口中的 selectImports方法,该方法主要用于获取所有符合条件的类的全限定类名,这些类需要被加载到 IoC 容器中。\nprivate static final String[] NO_IMPORTS = new String[0]; public String[] selectImports(AnnotationMetadata annotationMetadata) { // \u0026lt;1\u0026gt;.判断自动装配开关是否打开 if (!this.isEnabled(annotationMetadata)) { return NO_IMPORTS; } else { //\u0026lt;2\u0026gt;.获取所有需要装配的bean AutoConfigurationMetadata autoConfigurationMetadata = AutoConfigurationMetadataLoader.loadMetadata(this.beanClassLoader); AutoConfigurationImportSelector.AutoConfigurationEntry autoConfigurationEntry = this.getAutoConfigurationEntry(autoConfigurationMetadata, annotationMetadata); return StringUtils.toStringArray(autoConfigurationEntry.getConfigurations()); } } 这里我们需要重点关注一下getAutoConfigurationEntry()方法,这个方法主要负责加载自动配置类的。\n该方法调用链如下:\n现在我们结合getAutoConfigurationEntry()的源码来详细分析一下:\nprivate static final AutoConfigurationEntry EMPTY_ENTRY = new AutoConfigurationEntry(); AutoConfigurationEntry getAutoConfigurationEntry(AutoConfigurationMetadata autoConfigurationMetadata, AnnotationMetadata annotationMetadata) { //\u0026lt;1\u0026gt;. if (!this.isEnabled(annotationMetadata)) { return EMPTY_ENTRY; } else { //\u0026lt;2\u0026gt;. AnnotationAttributes attributes = this.getAttributes(annotationMetadata); //\u0026lt;3\u0026gt;. List\u0026lt;String\u0026gt; configurations = this.getCandidateConfigurations(annotationMetadata, attributes); //\u0026lt;4\u0026gt;. configurations = this.removeDuplicates(configurations); Set\u0026lt;String\u0026gt; exclusions = this.getExclusions(annotationMetadata, attributes); this.checkExcludedClasses(configurations, exclusions); configurations.removeAll(exclusions); configurations = this.filter(configurations, autoConfigurationMetadata); this.fireAutoConfigurationImportEvents(configurations, exclusions); return new AutoConfigurationImportSelector.AutoConfigurationEntry(configurations, exclusions); } } 第 1 步:\n判断自动装配开关是否打开。默认spring.boot.enableautoconfiguration=true,可在 application.properties 或 application.yml 中设置\n第 2 步:\n用于获取EnableAutoConfiguration注解中的 exclude 和 excludeName。\n第 3 步\n获取需要自动装配的所有配置类,读取META-INF/spring.factories\nspring-boot/spring-boot-project/spring-boot-autoconfigure/src/main/resources/META-INF/spring.factories 从下图可以看到这个文件的配置内容都被我们读取到了。XXXAutoConfiguration的作用就是按需加载组件。\n不光是这个依赖下的META-INF/spring.factories被读取到,所有 Spring Boot Starter 下的META-INF/spring.factories都会被读取到。\n所以,你可以清楚滴看到, druid 数据库连接池的 Spring Boot Starter 就创建了META-INF/spring.factories文件。\n如果,我们自己要创建一个 Spring Boot Starter,这一步是必不可少的。\n第 4 步:\n到这里可能面试官会问你:“spring.factories中这么多配置,每次启动都要全部加载么?”。\n很明显,这是不现实的。我们 debug 到后面你会发现,configurations 的值变小了。\n因为,这一步有经历了一遍筛选,@ConditionalOnXXX 中的所有条件都满足,该类才会生效。\n@Configuration // 检查相关的类:RabbitTemplate 和 Channel是否存在 // 存在才会加载 @ConditionalOnClass({ RabbitTemplate.class, Channel.class }) @EnableConfigurationProperties(RabbitProperties.class) @Import(RabbitAnnotationDrivenConfiguration.class) public class RabbitAutoConfiguration { } 有兴趣的童鞋可以详细了解下 Spring Boot 提供的条件注解\n@ConditionalOnBean:当容器里有指定 Bean 的条件下 @ConditionalOnMissingBean:当容器里没有指定 Bean 的情况下 @ConditionalOnSingleCandidate:当指定 Bean 在容器中只有一个,或者虽然有多个但是指定首选 Bean @ConditionalOnClass:当类路径下有指定类的条件下 @ConditionalOnMissingClass:当类路径下没有指定类的条件下 @ConditionalOnProperty:指定的属性是否有指定的值 @ConditionalOnResource:类路径是否有指定的值 @ConditionalOnExpression:基于 SpEL 表达式作为判断条件 @ConditionalOnJava:基于 Java 版本作为判断条件 @ConditionalOnJndi:在 JNDI 存在的条件下差在指定的位置 @ConditionalOnNotWebApplication:当前项目不是 Web 项目的条件下 @ConditionalOnWebApplication:当前项目是 Web 项 目的条件下 如何实现一个 Starter # 光说不练假把式,现在就来撸一个 starter,实现自定义线程池\n第一步,创建threadpool-spring-boot-starter工程\n第二步,引入 Spring Boot 相关依赖\n第三步,创建ThreadPoolAutoConfiguration\n第四步,在threadpool-spring-boot-starter工程的 resources 包下创建META-INF/spring.factories文件\n最后新建工程引入threadpool-spring-boot-starter\n测试通过!!!\n总结 # Spring Boot 通过@EnableAutoConfiguration开启自动装配,通过 SpringFactoriesLoader 最终加载META-INF/spring.factories中的自动配置类实现自动装配,自动配置类其实就是通过@Conditional按需加载的配置类,想要其生效必须引入spring-boot-starter-xxx包实现起步依赖\n"},{"id":575,"href":"/zh/docs/technology/Interview/system-design/framework/spring/springboot-knowledge-and-questions-summary/","title":"SpringBoot常见面试题总结(付费)","section":"Framework","content":"Spring Boot 相关的面试题为我的 知识星球(点击链接即可查看详细介绍以及加入方法)专属内容,已经整理到了 《Java 面试指北》中。\n"},{"id":576,"href":"/zh/docs/technology/Interview/system-design/framework/spring/spring-knowledge-and-questions-summary/","title":"Spring常见面试题总结","section":"Framework","content":"这篇文章主要是想通过一些问题,加深大家对于 Spring 的理解,所以不会涉及太多的代码!\n下面的很多问题我自己在使用 Spring 的过程中也并没有注意,自己也是临时查阅了很多资料和书籍补上的。网上也有一些很多关于 Spring 常见问题/面试题整理的文章,我感觉大部分都是互相 copy,而且很多问题也不是很好,有些回答也存在问题。所以,自己花了一周的业余时间整理了一下,希望对大家有帮助。\nSpring 基础 # 什么是 Spring 框架? # Spring 是一款开源的轻量级 Java 开发框架,旨在提高开发人员的开发效率以及系统的可维护性。\n我们一般说 Spring 框架指的都是 Spring Framework,它是很多模块的集合,使用这些模块可以很方便地协助我们进行开发,比如说 Spring 支持 IoC(Inversion of Control:控制反转) 和 AOP(Aspect-Oriented Programming:面向切面编程)、可以很方便地对数据库进行访问、可以很方便地集成第三方组件(电子邮件,任务,调度,缓存等等)、对单元测试支持比较好、支持 RESTful Java 应用程序的开发。\nSpring 最核心的思想就是不重新造轮子,开箱即用,提高开发效率。\nSpring 翻译过来就是春天的意思,可见其目标和使命就是为 Java 程序员带来春天啊!感动!\n🤐 多提一嘴:语言的流行通常需要一个杀手级的应用,Spring 就是 Java 生态的一个杀手级的应用框架。\nSpring 提供的核心功能主要是 IoC 和 AOP。学习 Spring ,一定要把 IoC 和 AOP 的核心思想搞懂!\nSpring 官网: https://spring.io/ GitHub 地址: https://github.com/spring-projects/spring-framework Spring 包含的模块有哪些? # Spring4.x 版本:\nSpring5.x 版本:\nSpring5.x 版本中 Web 模块的 Portlet 组件已经被废弃掉,同时增加了用于异步响应式处理的 WebFlux 组件。\nSpring 各个模块的依赖关系如下:\nCore Container # Spring 框架的核心模块,也可以说是基础模块,主要提供 IoC 依赖注入功能的支持。Spring 其他所有的功能基本都需要依赖于该模块,我们从上面那张 Spring 各个模块的依赖关系图就可以看出来。\nspring-core:Spring 框架基本的核心工具类。 spring-beans:提供对 bean 的创建、配置和管理等功能的支持。 spring-context:提供对国际化、事件传播、资源加载等功能的支持。 spring-expression:提供对表达式语言(Spring Expression Language) SpEL 的支持,只依赖于 core 模块,不依赖于其他模块,可以单独使用。 AOP # spring-aspects:该模块为与 AspectJ 的集成提供支持。 spring-aop:提供了面向切面的编程实现。 spring-instrument:提供了为 JVM 添加代理(agent)的功能。 具体来讲,它为 Tomcat 提供了一个织入代理,能够为 Tomcat 传递类文 件,就像这些文件是被类加载器加载的一样。没有理解也没关系,这个模块的使用场景非常有限。 Data Access/Integration # spring-jdbc:提供了对数据库访问的抽象 JDBC。不同的数据库都有自己独立的 API 用于操作数据库,而 Java 程序只需要和 JDBC API 交互,这样就屏蔽了数据库的影响。 spring-tx:提供对事务的支持。 spring-orm:提供对 Hibernate、JPA、iBatis 等 ORM 框架的支持。 spring-oxm:提供一个抽象层支撑 OXM(Object-to-XML-Mapping),例如:JAXB、Castor、XMLBeans、JiBX 和 XStream 等。 spring-jms : 消息服务。自 Spring Framework 4.1 以后,它还提供了对 spring-messaging 模块的继承。 Spring Web # spring-web:对 Web 功能的实现提供一些最基础的支持。 spring-webmvc:提供对 Spring MVC 的实现。 spring-websocket:提供了对 WebSocket 的支持,WebSocket 可以让客户端和服务端进行双向通信。 spring-webflux:提供对 WebFlux 的支持。WebFlux 是 Spring Framework 5.0 中引入的新的响应式框架。与 Spring MVC 不同,它不需要 Servlet API,是完全异步。 Messaging # spring-messaging 是从 Spring4.0 开始新加入的一个模块,主要职责是为 Spring 框架集成一些基础的报文传送应用。\nSpring Test # Spring 团队提倡测试驱动开发(TDD)。有了控制反转 (IoC)的帮助,单元测试和集成测试变得更简单。\nSpring 的测试模块对 JUnit(单元测试框架)、TestNG(类似 JUnit)、Mockito(主要用来 Mock 对象)、PowerMock(解决 Mockito 的问题比如无法模拟 final, static, private 方法)等等常用的测试框架支持的都比较好。\nSpring,Spring MVC,Spring Boot 之间什么关系? # 很多人对 Spring,Spring MVC,Spring Boot 这三者傻傻分不清楚!这里简单介绍一下这三者,其实很简单,没有什么高深的东西。\nSpring 包含了多个功能模块(上面刚刚提到过),其中最重要的是 Spring-Core(主要提供 IoC 依赖注入功能的支持) 模块, Spring 中的其他模块(比如 Spring MVC)的功能实现基本都需要依赖于该模块。\n下图对应的是 Spring4.x 版本。目前最新的 5.x 版本中 Web 模块的 Portlet 组件已经被废弃掉,同时增加了用于异步响应式处理的 WebFlux 组件。\nSpring MVC 是 Spring 中的一个很重要的模块,主要赋予 Spring 快速构建 MVC 架构的 Web 程序的能力。MVC 是模型(Model)、视图(View)、控制器(Controller)的简写,其核心思想是通过将业务逻辑、数据、显示分离来组织代码。\n使用 Spring 进行开发各种配置过于麻烦比如开启某些 Spring 特性时,需要用 XML 或 Java 进行显式配置。于是,Spring Boot 诞生了!\nSpring 旨在简化 J2EE 企业应用程序开发。Spring Boot 旨在简化 Spring 开发(减少配置文件,开箱即用!)。\nSpring Boot 只是简化了配置,如果你需要构建 MVC 架构的 Web 程序,你还是需要使用 Spring MVC 作为 MVC 框架,只是说 Spring Boot 帮你简化了 Spring MVC 的很多配置,真正做到开箱即用!\nSpring IoC # 谈谈自己对于 Spring IoC 的了解 # IoC(Inversion of Control:控制反转) 是一种设计思想,而不是一个具体的技术实现。IoC 的思想就是将原本在程序中手动创建对象的控制权,交由 Spring 框架来管理。不过, IoC 并非 Spring 特有,在其他语言中也有应用。\n为什么叫控制反转?\n控制:指的是对象创建(实例化、管理)的权力 反转:控制权交给外部环境(Spring 框架、IoC 容器) 将对象之间的相互依赖关系交给 IoC 容器来管理,并由 IoC 容器完成对象的注入。这样可以很大程度上简化应用的开发,把应用从复杂的依赖关系中解放出来。 IoC 容器就像是一个工厂一样,当我们需要创建一个对象的时候,只需要配置好配置文件/注解即可,完全不用考虑对象是如何被创建出来的。\n在实际项目中一个 Service 类可能依赖了很多其他的类,假如我们需要实例化这个 Service,你可能要每次都要搞清这个 Service 所有底层类的构造函数,这可能会把人逼疯。如果利用 IoC 的话,你只需要配置好,然后在需要的地方引用就行了,这大大增加了项目的可维护性且降低了开发难度。\n在 Spring 中, IoC 容器是 Spring 用来实现 IoC 的载体, IoC 容器实际上就是个 Map(key,value),Map 中存放的是各种对象。\nSpring 时代我们一般通过 XML 文件来配置 Bean,后来开发人员觉得 XML 文件来配置不太好,于是 SpringBoot 注解配置就慢慢开始流行起来。\n相关阅读:\nIoC 源码阅读 IoC \u0026amp; AOP 详解(快速搞懂) 什么是 Spring Bean? # 简单来说,Bean 代指的就是那些被 IoC 容器所管理的对象。\n我们需要告诉 IoC 容器帮助我们管理哪些对象,这个是通过配置元数据来定义的。配置元数据可以是 XML 文件、注解或者 Java 配置类。\n\u0026lt;!-- Constructor-arg with \u0026#39;value\u0026#39; attribute --\u0026gt; \u0026lt;bean id=\u0026#34;...\u0026#34; class=\u0026#34;...\u0026#34;\u0026gt; \u0026lt;constructor-arg value=\u0026#34;...\u0026#34;/\u0026gt; \u0026lt;/bean\u0026gt; 下图简单地展示了 IoC 容器如何使用配置元数据来管理对象。\norg.springframework.beans和 org.springframework.context 这两个包是 IoC 实现的基础,如果想要研究 IoC 相关的源码的话,可以去看看\n将一个类声明为 Bean 的注解有哪些? # @Component:通用的注解,可标注任意类为 Spring 组件。如果一个 Bean 不知道属于哪个层,可以使用@Component 注解标注。 @Repository : 对应持久层即 Dao 层,主要用于数据库相关操作。 @Service : 对应服务层,主要涉及一些复杂的逻辑,需要用到 Dao 层。 @Controller : 对应 Spring MVC 控制层,主要用于接受用户请求并调用 Service 层返回数据给前端页面。 @Component 和 @Bean 的区别是什么? # @Component 注解作用于类,而@Bean注解作用于方法。 @Component通常是通过类路径扫描来自动侦测以及自动装配到 Spring 容器中(我们可以使用 @ComponentScan 注解定义要扫描的路径从中找出标识了需要装配的类自动装配到 Spring 的 bean 容器中)。@Bean 注解通常是我们在标有该注解的方法中定义产生这个 bean,@Bean告诉了 Spring 这是某个类的实例,当我需要用它的时候还给我。 @Bean 注解比 @Component 注解的自定义性更强,而且很多地方我们只能通过 @Bean 注解来注册 bean。比如当我们引用第三方库中的类需要装配到 Spring容器时,则只能通过 @Bean来实现。 @Bean注解使用示例:\n@Configuration public class AppConfig { @Bean public TransferService transferService() { return new TransferServiceImpl(); } } 上面的代码相当于下面的 xml 配置\n\u0026lt;beans\u0026gt; \u0026lt;bean id=\u0026#34;transferService\u0026#34; class=\u0026#34;com.acme.TransferServiceImpl\u0026#34;/\u0026gt; \u0026lt;/beans\u0026gt; 下面这个例子是通过 @Component 无法实现的。\n@Bean public OneService getService(status) { case (status) { when 1: return new serviceImpl1(); when 2: return new serviceImpl2(); when 3: return new serviceImpl3(); } } 注入 Bean 的注解有哪些? # Spring 内置的 @Autowired 以及 JDK 内置的 @Resource 和 @Inject 都可以用于注入 Bean。\nAnnotation Package Source @Autowired org.springframework.bean.factory Spring 2.5+ @Resource javax.annotation Java JSR-250 @Inject javax.inject Java JSR-330 @Autowired 和@Resource使用的比较多一些。\n@Autowired 和 @Resource 的区别是什么? # Autowired 属于 Spring 内置的注解,默认的注入方式为byType(根据类型进行匹配),也就是说会优先根据接口类型去匹配并注入 Bean (接口的实现类)。\n这会有什么问题呢? 当一个接口存在多个实现类的话,byType这种方式就无法正确注入对象了,因为这个时候 Spring 会同时找到多个满足条件的选择,默认情况下它自己不知道选择哪一个。\n这种情况下,注入方式会变为 byName(根据名称进行匹配),这个名称通常就是类名(首字母小写)。就比如说下面代码中的 smsService 就是我这里所说的名称,这样应该比较好理解了吧。\n// smsService 就是我们上面所说的名称 @Autowired private SmsService smsService; 举个例子,SmsService 接口有两个实现类: SmsServiceImpl1和 SmsServiceImpl2,且它们都已经被 Spring 容器所管理。\n// 报错,byName 和 byType 都无法匹配到 bean @Autowired private SmsService smsService; // 正确注入 SmsServiceImpl1 对象对应的 bean @Autowired private SmsService smsServiceImpl1; // 正确注入 SmsServiceImpl1 对象对应的 bean // smsServiceImpl1 就是我们上面所说的名称 @Autowired @Qualifier(value = \u0026#34;smsServiceImpl1\u0026#34;) private SmsService smsService; 我们还是建议通过 @Qualifier 注解来显式指定名称而不是依赖变量的名称。\n@Resource属于 JDK 提供的注解,默认注入方式为 byName。如果无法通过名称匹配到对应的 Bean 的话,注入方式会变为byType。\n@Resource 有两个比较重要且日常开发常用的属性:name(名称)、type(类型)。\npublic @interface Resource { String name() default \u0026#34;\u0026#34;; Class\u0026lt;?\u0026gt; type() default Object.class; } 如果仅指定 name 属性则注入方式为byName,如果仅指定type属性则注入方式为byType,如果同时指定name 和type属性(不建议这么做)则注入方式为byType+byName。\n// 报错,byName 和 byType 都无法匹配到 bean @Resource private SmsService smsService; // 正确注入 SmsServiceImpl1 对象对应的 bean @Resource private SmsService smsServiceImpl1; // 正确注入 SmsServiceImpl1 对象对应的 bean(比较推荐这种方式) @Resource(name = \u0026#34;smsServiceImpl1\u0026#34;) private SmsService smsService; 简单总结一下:\n@Autowired 是 Spring 提供的注解,@Resource 是 JDK 提供的注解。 Autowired 默认的注入方式为byType(根据类型进行匹配),@Resource默认注入方式为 byName(根据名称进行匹配)。 当一个接口存在多个实现类的情况下,@Autowired 和@Resource都需要通过名称才能正确匹配到对应的 Bean。Autowired 可以通过 @Qualifier 注解来显式指定名称,@Resource可以通过 name 属性来显式指定名称。 @Autowired 支持在构造函数、方法、字段和参数上使用。@Resource 主要用于字段和方法上的注入,不支持在构造函数或参数上使用。 注入 Bean 的方式有哪些? # 依赖注入 (Dependency Injection, DI) 的常见方式:\n构造函数注入:通过类的构造函数来注入依赖项。 Setter 注入:通过类的 Setter 方法来注入依赖项。 Field(字段) 注入:直接在类的字段上使用注解(如 @Autowired 或 @Resource)来注入依赖项。 构造函数注入示例:\n@Service public class UserService { private final UserRepository userRepository; public UserService(UserRepository userRepository) { this.userRepository = userRepository; } //... } Setter 注入示例:\n@Service public class UserService { private UserRepository userRepository; // 在 Spring 4.3 及以后的版本,特定情况下 @Autowired 可以省略不写 @Autowired public void setUserRepository(UserRepository userRepository) { this.userRepository = userRepository; } //... } Field 注入示例:\n@Service public class UserService { @Autowired private UserRepository userRepository; //... } 构造函数注入还是 Setter 注入? # Spring 官方有对这个问题的回答: https://docs.spring.io/spring-framework/reference/core/beans/dependencies/factory-collaborators.html#beans-setter-injection。\n我这里主要提取总结完善一下 Spring 官方的建议。\nSpring 官方推荐构造函数注入,这种注入方式的优势如下:\n依赖完整性:确保所有必需依赖在对象创建时就被注入,避免了空指针异常的风险。 不可变性:有助于创建不可变对象,提高了线程安全性。 初始化保证:组件在使用前已完全初始化,减少了潜在的错误。 测试便利性:在单元测试中,可以直接通过构造函数传入模拟的依赖项,而不必依赖 Spring 容器进行注入。 构造函数注入适合处理必需的依赖项,而 Setter 注入 则更适合可选的依赖项,这些依赖项可以有默认值或在对象生命周期中动态设置。虽然 @Autowired 可以用于 Setter 方法来处理必需的依赖项,但构造函数注入仍然是更好的选择。\n在某些情况下(例如第三方类不提供 Setter 方法),构造函数注入可能是唯一的选择。\nBean 的作用域有哪些? # Spring 中 Bean 的作用域通常有下面几种:\nsingleton : IoC 容器中只有唯一的 bean 实例。Spring 中的 bean 默认都是单例的,是对单例设计模式的应用。 prototype : 每次获取都会创建一个新的 bean 实例。也就是说,连续 getBean() 两次,得到的是不同的 Bean 实例。 request (仅 Web 应用可用): 每一次 HTTP 请求都会产生一个新的 bean(请求 bean),该 bean 仅在当前 HTTP request 内有效。 session (仅 Web 应用可用) : 每一次来自新 session 的 HTTP 请求都会产生一个新的 bean(会话 bean),该 bean 仅在当前 HTTP session 内有效。 application/global-session (仅 Web 应用可用):每个 Web 应用在启动时创建一个 Bean(应用 Bean),该 bean 仅在当前应用启动时间内有效。 websocket (仅 Web 应用可用):每一次 WebSocket 会话产生一个新的 bean。 如何配置 bean 的作用域呢?\nxml 方式:\n\u0026lt;bean id=\u0026#34;...\u0026#34; class=\u0026#34;...\u0026#34; scope=\u0026#34;singleton\u0026#34;\u0026gt;\u0026lt;/bean\u0026gt; 注解方式:\n@Bean @Scope(value = ConfigurableBeanFactory.SCOPE_PROTOTYPE) public Person personPrototype() { return new Person(); } Bean 是线程安全的吗? # Spring 框架中的 Bean 是否线程安全,取决于其作用域和状态。\n我们这里以最常用的两种作用域 prototype 和 singleton 为例介绍。几乎所有场景的 Bean 作用域都是使用默认的 singleton ,重点关注 singleton 作用域即可。\nprototype 作用域下,每次获取都会创建一个新的 bean 实例,不存在资源竞争问题,所以不存在线程安全问题。singleton 作用域下,IoC 容器中只有唯一的 bean 实例,可能会存在资源竞争问题(取决于 Bean 是否有状态)。如果这个 bean 是有状态的话,那就存在线程安全问题(有状态 Bean 是指包含可变的成员变量的对象)。\n有状态 Bean 示例:\n// 定义了一个购物车类,其中包含一个保存用户的购物车里商品的 List @Component public class ShoppingCart { private List\u0026lt;String\u0026gt; items = new ArrayList\u0026lt;\u0026gt;(); public void addItem(String item) { items.add(item); } public List\u0026lt;String\u0026gt; getItems() { return items; } } 不过,大部分 Bean 实际都是无状态(没有定义可变的成员变量)的(比如 Dao、Service),这种情况下, Bean 是线程安全的。\n无状态 Bean 示例:\n// 定义了一个用户服务,它仅包含业务逻辑而不保存任何状态。 @Component public class UserService { public User findUserById(Long id) { //... } //... } 对于有状态单例 Bean 的线程安全问题,常见的三种解决办法是:\n避免可变成员变量: 尽量设计 Bean 为无状态。 使用ThreadLocal: 将可变成员变量保存在 ThreadLocal 中,确保线程独立。 使用同步机制: 利用 synchronized 或 ReentrantLock 来进行同步控制,确保线程安全。 这里以 ThreadLocal为例,演示一下ThreadLocal 保存用户登录信息的场景:\npublic class UserThreadLocal { private UserThreadLocal() {} private static final ThreadLocal\u0026lt;SysUser\u0026gt; LOCAL = ThreadLocal.withInitial(() -\u0026gt; null); public static void put(SysUser sysUser) { LOCAL.set(sysUser); } public static SysUser get() { return LOCAL.get(); } public static void remove() { LOCAL.remove(); } } Bean 的生命周期了解么? # 创建 Bean 的实例:Bean 容器首先会找到配置文件中的 Bean 定义,然后使用 Java 反射 API 来创建 Bean 的实例。 Bean 属性赋值/填充:为 Bean 设置相关属性和依赖,例如@Autowired 等注解注入的对象、@Value 注入的值、setter方法或构造函数注入依赖和值、@Resource注入的各种资源。 Bean 初始化: 如果 Bean 实现了 BeanNameAware 接口,调用 setBeanName()方法,传入 Bean 的名字。 如果 Bean 实现了 BeanClassLoaderAware 接口,调用 setBeanClassLoader()方法,传入 ClassLoader对象的实例。 如果 Bean 实现了 BeanFactoryAware 接口,调用 setBeanFactory()方法,传入 BeanFactory对象的实例。 与上面的类似,如果实现了其他 *.Aware接口,就调用相应的方法。 如果有和加载这个 Bean 的 Spring 容器相关的 BeanPostProcessor 对象,执行postProcessBeforeInitialization() 方法 如果 Bean 实现了InitializingBean接口,执行afterPropertiesSet()方法。 如果 Bean 在配置文件中的定义包含 init-method 属性,执行指定的方法。 如果有和加载这个 Bean 的 Spring 容器相关的 BeanPostProcessor 对象,执行postProcessAfterInitialization() 方法。 销毁 Bean:销毁并不是说要立马把 Bean 给销毁掉,而是把 Bean 的销毁方法先记录下来,将来需要销毁 Bean 或者销毁容器的时候,就调用这些方法去释放 Bean 所持有的资源。 如果 Bean 实现了 DisposableBean 接口,执行 destroy() 方法。 如果 Bean 在配置文件中的定义包含 destroy-method 属性,执行指定的 Bean 销毁方法。或者,也可以直接通过@PreDestroy 注解标记 Bean 销毁之前执行的方法。 AbstractAutowireCapableBeanFactory 的 doCreateBean() 方法中能看到依次执行了这 4 个阶段:\nprotected Object doCreateBean(final String beanName, final RootBeanDefinition mbd, final @Nullable Object[] args) throws BeanCreationException { // 1. 创建 Bean 的实例 BeanWrapper instanceWrapper = null; if (instanceWrapper == null) { instanceWrapper = createBeanInstance(beanName, mbd, args); } Object exposedObject = bean; try { // 2. Bean 属性赋值/填充 populateBean(beanName, mbd, instanceWrapper); // 3. Bean 初始化 exposedObject = initializeBean(beanName, exposedObject, mbd); } // 4. 销毁 Bean-注册回调接口 try { registerDisposableBeanIfNecessary(beanName, bean, mbd); } return exposedObject; } Aware 接口能让 Bean 能拿到 Spring 容器资源。\nSpring 中提供的 Aware 接口主要有:\nBeanNameAware:注入当前 bean 对应 beanName; BeanClassLoaderAware:注入加载当前 bean 的 ClassLoader; BeanFactoryAware:注入当前 BeanFactory 容器的引用。 BeanPostProcessor 接口是 Spring 为修改 Bean 提供的强大扩展点。\npublic interface BeanPostProcessor { // 初始化前置处理 default Object postProcessBeforeInitialization(Object bean, String beanName) throws BeansException { return bean; } // 初始化后置处理 default Object postProcessAfterInitialization(Object bean, String beanName) throws BeansException { return bean; } } postProcessBeforeInitialization:Bean 实例化、属性注入完成后,InitializingBean#afterPropertiesSet方法以及自定义的 init-method 方法之前执行; postProcessAfterInitialization:类似于上面,不过是在 InitializingBean#afterPropertiesSet方法以及自定义的 init-method 方法之后执行。 InitializingBean 和 init-method 是 Spring 为 Bean 初始化提供的扩展点。\npublic interface InitializingBean { // 初始化逻辑 void afterPropertiesSet() throws Exception; } 指定 init-method 方法,指定初始化方法:\n\u0026lt;?xml version=\u0026#34;1.0\u0026#34; encoding=\u0026#34;UTF-8\u0026#34;?\u0026gt; \u0026lt;beans xmlns=\u0026#34;http://www.springframework.org/schema/beans\u0026#34; xmlns:xsi=\u0026#34;http://www.w3.org/2001/XMLSchema-instance\u0026#34; xsi:schemaLocation=\u0026#34;http://www.springframework.org/schema/beans http://www.springframework.org/schema/beans/spring-beans.xsd\u0026#34;\u0026gt; \u0026lt;bean id=\u0026#34;demo\u0026#34; class=\u0026#34;com.chaycao.Demo\u0026#34; init-method=\u0026#34;init()\u0026#34;/\u0026gt; \u0026lt;/beans\u0026gt; 如何记忆呢?\n整体上可以简单分为四步:实例化 —\u0026gt; 属性赋值 —\u0026gt; 初始化 —\u0026gt; 销毁。 初始化这一步涉及到的步骤比较多,包含 Aware 接口的依赖注入、BeanPostProcessor 在初始化前后的处理以及 InitializingBean 和 init-method 的初始化操作。 销毁这一步会注册相关销毁回调接口,最后通过DisposableBean 和 destory-method 进行销毁。 最后,再分享一张清晰的图解(图源: 如何记忆 Spring Bean 的生命周期)。\nSpring AOP # 谈谈自己对于 AOP 的了解 # AOP(Aspect-Oriented Programming:面向切面编程)能够将那些与业务无关,却为业务模块所共同调用的逻辑或责任(例如事务处理、日志管理、权限控制等)封装起来,便于减少系统的重复代码,降低模块间的耦合度,并有利于未来的可拓展性和可维护性。\nSpring AOP 就是基于动态代理的,如果要代理的对象,实现了某个接口,那么 Spring AOP 会使用 JDK Proxy,去创建代理对象,而对于没有实现接口的对象,就无法使用 JDK Proxy 去进行代理了,这时候 Spring AOP 会使用 Cglib 生成一个被代理对象的子类来作为代理,如下图所示:\n当然你也可以使用 AspectJ !Spring AOP 已经集成了 AspectJ ,AspectJ 应该算的上是 Java 生态系统中最完整的 AOP 框架了。\nAOP 切面编程涉及到的一些专业术语:\n术语 含义 目标(Target) 被通知的对象 代理(Proxy) 向目标对象应用通知之后创建的代理对象 连接点(JoinPoint) 目标对象的所属类中,定义的所有方法均为连接点 切入点(Pointcut) 被切面拦截 / 增强的连接点(切入点一定是连接点,连接点不一定是切入点) 通知(Advice) 增强的逻辑 / 代码,也即拦截到目标对象的连接点之后要做的事情 切面(Aspect) 切入点(Pointcut)+通知(Advice) Weaving(织入) 将通知应用到目标对象,进而生成代理对象的过程动作 Spring AOP 和 AspectJ AOP 有什么区别? # Spring AOP 属于运行时增强,而 AspectJ 是编译时增强。 Spring AOP 基于代理(Proxying),而 AspectJ 基于字节码操作(Bytecode Manipulation)。\nSpring AOP 已经集成了 AspectJ ,AspectJ 应该算的上是 Java 生态系统中最完整的 AOP 框架了。AspectJ 相比于 Spring AOP 功能更加强大,但是 Spring AOP 相对来说更简单,\n如果我们的切面比较少,那么两者性能差异不大。但是,当切面太多的话,最好选择 AspectJ ,它比 Spring AOP 快很多。\nAOP 常见的通知类型有哪些? # Before(前置通知):目标对象的方法调用之前触发 After (后置通知):目标对象的方法调用之后触发 AfterReturning(返回通知):目标对象的方法调用完成,在返回结果值之后触发 AfterThrowing(异常通知):目标对象的方法运行中抛出 / 触发异常后触发。AfterReturning 和 AfterThrowing 两者互斥。如果方法调用成功无异常,则会有返回值;如果方法抛出了异常,则不会有返回值。 Around (环绕通知):编程式控制目标对象的方法调用。环绕通知是所有通知类型中可操作范围最大的一种,因为它可以直接拿到目标对象,以及要执行的方法,所以环绕通知可以任意的在目标对象的方法调用前后搞事,甚至不调用目标对象的方法 多个切面的执行顺序如何控制? # 1、通常使用@Order 注解直接定义切面顺序\n// 值越小优先级越高 @Order(3) @Component @Aspect public class LoggingAspect implements Ordered { 2、实现Ordered 接口重写 getOrder 方法。\n@Component @Aspect public class LoggingAspect implements Ordered { // .... @Override public int getOrder() { // 返回值越小优先级越高 return 1; } } Spring MVC # 说说自己对于 Spring MVC 了解? # MVC 是模型(Model)、视图(View)、控制器(Controller)的简写,其核心思想是通过将业务逻辑、数据、显示分离来组织代码。\n网上有很多人说 MVC 不是设计模式,只是软件设计规范,我个人更倾向于 MVC 同样是众多设计模式中的一种。 java-design-patterns 项目中就有关于 MVC 的相关介绍。\n想要真正理解 Spring MVC,我们先来看看 Model 1 和 Model 2 这两个没有 Spring MVC 的时代。\nModel 1 时代\n很多学 Java 后端比较晚的朋友可能并没有接触过 Model 1 时代下的 JavaWeb 应用开发。在 Model1 模式下,整个 Web 应用几乎全部用 JSP 页面组成,只用少量的 JavaBean 来处理数据库连接、访问等操作。\n这个模式下 JSP 即是控制层(Controller)又是表现层(View)。显而易见,这种模式存在很多问题。比如控制逻辑和表现逻辑混杂在一起,导致代码重用率极低;再比如前端和后端相互依赖,难以进行测试维护并且开发效率极低。\nModel 2 时代\n学过 Servlet 并做过相关 Demo 的朋友应该了解“Java Bean(Model)+ JSP(View)+Servlet(Controller) ”这种开发模式,这就是早期的 JavaWeb MVC 开发模式。\nModel:系统涉及的数据,也就是 dao 和 bean。 View:展示模型中的数据,只是用来展示。 Controller:接受用户请求,并将请求发送至 Model,最后返回数据给 JSP 并展示给用户 Model2 模式下还存在很多问题,Model2 的抽象和封装程度还远远不够,使用 Model2 进行开发时不可避免地会重复造轮子,这就大大降低了程序的可维护性和复用性。\n于是,很多 JavaWeb 开发相关的 MVC 框架应运而生比如 Struts2,但是 Struts2 比较笨重。\nSpring MVC 时代\n随着 Spring 轻量级开发框架的流行,Spring 生态圈出现了 Spring MVC 框架, Spring MVC 是当前最优秀的 MVC 框架。相比于 Struts2 , Spring MVC 使用更加简单和方便,开发效率更高,并且 Spring MVC 运行速度更快。\nMVC 是一种设计模式,Spring MVC 是一款很优秀的 MVC 框架。Spring MVC 可以帮助我们进行更简洁的 Web 层的开发,并且它天生与 Spring 框架集成。Spring MVC 下我们一般把后端项目分为 Service 层(处理业务)、Dao 层(数据库操作)、Entity 层(实体类)、Controller 层(控制层,返回数据给前台页面)。\nSpring MVC 的核心组件有哪些? # 记住了下面这些组件,也就记住了 SpringMVC 的工作原理。\nDispatcherServlet:核心的中央处理器,负责接收请求、分发,并给予客户端响应。 HandlerMapping:处理器映射器,根据 URL 去匹配查找能处理的 Handler ,并会将请求涉及到的拦截器和 Handler 一起封装。 HandlerAdapter:处理器适配器,根据 HandlerMapping 找到的 Handler ,适配执行对应的 Handler; Handler:请求处理器,处理实际请求的处理器。 ViewResolver:视图解析器,根据 Handler 返回的逻辑视图 / 视图,解析并渲染真正的视图,并传递给 DispatcherServlet 响应客户端 SpringMVC 工作原理了解吗? # Spring MVC 原理如下图所示:\nSpringMVC 工作原理的图解我没有自己画,直接图省事在网上找了一个非常清晰直观的,原出处不明。\n流程说明(重要):\n客户端(浏览器)发送请求, DispatcherServlet拦截请求。 DispatcherServlet 根据请求信息调用 HandlerMapping 。HandlerMapping 根据 URL 去匹配查找能处理的 Handler(也就是我们平常说的 Controller 控制器) ,并会将请求涉及到的拦截器和 Handler 一起封装。 DispatcherServlet 调用 HandlerAdapter适配器执行 Handler 。 Handler 完成对用户请求的处理后,会返回一个 ModelAndView 对象给DispatcherServlet,ModelAndView 顾名思义,包含了数据模型以及相应的视图的信息。Model 是返回的数据对象,View 是个逻辑上的 View。 ViewResolver 会根据逻辑 View 查找实际的 View。 DispaterServlet 把返回的 Model 传给 View(视图渲染)。 把 View 返回给请求者(浏览器) 上述流程是传统开发模式(JSP,Thymeleaf 等)的工作原理。然而现在主流的开发方式是前后端分离,这种情况下 Spring MVC 的 View 概念发生了一些变化。由于 View 通常由前端框架(Vue, React 等)来处理,后端不再负责渲染页面,而是只负责提供数据,因此:\n前后端分离时,后端通常不再返回具体的视图,而是返回纯数据(通常是 JSON 格式),由前端负责渲染和展示。 View 的部分在前后端分离的场景下往往不需要设置,Spring MVC 的控制器方法只需要返回数据,不再返回 ModelAndView,而是直接返回数据,Spring 会自动将其转换为 JSON 格式。相应的,ViewResolver 也将不再被使用。 怎么做到呢?\n使用 @RestController 注解代替传统的 @Controller 注解,这样所有方法默认会返回 JSON 格式的数据,而不是试图解析视图。 如果你使用的是 @Controller,可以结合 @ResponseBody 注解来返回 JSON。 统一异常处理怎么做? # 推荐使用注解的方式统一异常处理,具体会使用到 @ControllerAdvice + @ExceptionHandler 这两个注解 。\n@ControllerAdvice @ResponseBody public class GlobalExceptionHandler { @ExceptionHandler(BaseException.class) public ResponseEntity\u0026lt;?\u0026gt; handleAppException(BaseException ex, HttpServletRequest request) { //...... } @ExceptionHandler(value = ResourceNotFoundException.class) public ResponseEntity\u0026lt;ErrorReponse\u0026gt; handleResourceNotFoundException(ResourceNotFoundException ex, HttpServletRequest request) { //...... } } 这种异常处理方式下,会给所有或者指定的 Controller 织入异常处理的逻辑(AOP),当 Controller 中的方法抛出异常的时候,由被@ExceptionHandler 注解修饰的方法进行处理。\nExceptionHandlerMethodResolver 中 getMappedMethod 方法决定了异常具体被哪个被 @ExceptionHandler 注解修饰的方法处理异常。\n@Nullable private Method getMappedMethod(Class\u0026lt;? extends Throwable\u0026gt; exceptionType) { List\u0026lt;Class\u0026lt;? extends Throwable\u0026gt;\u0026gt; matches = new ArrayList\u0026lt;\u0026gt;(); //找到可以处理的所有异常信息。mappedMethods 中存放了异常和处理异常的方法的对应关系 for (Class\u0026lt;? extends Throwable\u0026gt; mappedException : this.mappedMethods.keySet()) { if (mappedException.isAssignableFrom(exceptionType)) { matches.add(mappedException); } } // 不为空说明有方法处理异常 if (!matches.isEmpty()) { // 按照匹配程度从小到大排序 matches.sort(new ExceptionDepthComparator(exceptionType)); // 返回处理异常的方法 return this.mappedMethods.get(matches.get(0)); } else { return null; } } 从源代码看出:getMappedMethod()会首先找到可以匹配处理异常的所有方法信息,然后对其进行从小到大的排序,最后取最小的那一个匹配的方法(即匹配度最高的那个)。\nSpring 框架中用到了哪些设计模式? # 关于下面这些设计模式的详细介绍,可以看我写的 Spring 中的设计模式详解 这篇文章。\n工厂设计模式 : Spring 使用工厂模式通过 BeanFactory、ApplicationContext 创建 bean 对象。 代理设计模式 : Spring AOP 功能的实现。 单例设计模式 : Spring 中的 Bean 默认都是单例的。 模板方法模式 : Spring 中 jdbcTemplate、hibernateTemplate 等以 Template 结尾的对数据库操作的类,它们就使用到了模板模式。 包装器设计模式 : 我们的项目需要连接多个数据库,而且不同的客户在每次访问中根据需要会去访问不同的数据库。这种模式让我们可以根据客户的需求能够动态切换不同的数据源。 观察者模式: Spring 事件驱动模型就是观察者模式很经典的一个应用。 适配器模式 : Spring AOP 的增强或通知(Advice)使用到了适配器模式、spring MVC 中也是用到了适配器模式适配Controller。 …… Spring 的循环依赖 # Spring 循环依赖了解吗,怎么解决? # 循环依赖是指 Bean 对象循环引用,是两个或多个 Bean 之间相互持有对方的引用,例如 CircularDependencyA → CircularDependencyB → CircularDependencyA。\n@Component public class CircularDependencyA { @Autowired private CircularDependencyB circB; } @Component public class CircularDependencyB { @Autowired private CircularDependencyA circA; } 单个对象的自我依赖也会出现循环依赖,但这种概率极低,属于是代码编写错误。\n@Component public class CircularDependencyA { @Autowired private CircularDependencyA circA; } Spring 框架通过使用三级缓存来解决这个问题,确保即使在循环依赖的情况下也能正确创建 Bean。\nSpring 中的三级缓存其实就是三个 Map,如下:\n// 一级缓存 /** Cache of singleton objects: bean name to bean instance. */ private final Map\u0026lt;String, Object\u0026gt; singletonObjects = new ConcurrentHashMap\u0026lt;\u0026gt;(256); // 二级缓存 /** Cache of early singleton objects: bean name to bean instance. */ private final Map\u0026lt;String, Object\u0026gt; earlySingletonObjects = new HashMap\u0026lt;\u0026gt;(16); // 三级缓存 /** Cache of singleton factories: bean name to ObjectFactory. */ private final Map\u0026lt;String, ObjectFactory\u0026lt;?\u0026gt;\u0026gt; singletonFactories = new HashMap\u0026lt;\u0026gt;(16); 简单来说,Spring 的三级缓存包括:\n一级缓存(singletonObjects):存放最终形态的 Bean(已经实例化、属性填充、初始化),单例池,为“Spring 的单例属性”⽽⽣。一般情况我们获取 Bean 都是从这里获取的,但是并不是所有的 Bean 都在单例池里面,例如原型 Bean 就不在里面。 二级缓存(earlySingletonObjects):存放过渡 Bean(半成品,尚未属性填充),也就是三级缓存中ObjectFactory产生的对象,与三级缓存配合使用的,可以防止 AOP 的情况下,每次调用ObjectFactory#getObject()都是会产生新的代理对象的。 三级缓存(singletonFactories):存放ObjectFactory,ObjectFactory的getObject()方法(最终调用的是getEarlyBeanReference()方法)可以生成原始 Bean 对象或者代理对象(如果 Bean 被 AOP 切面代理)。三级缓存只会对单例 Bean 生效。 接下来说一下 Spring 创建 Bean 的流程:\n先去 一级缓存 singletonObjects 中获取,存在就返回; 如果不存在或者对象正在创建中,于是去 二级缓存 earlySingletonObjects 中获取; 如果还没有获取到,就去 三级缓存 singletonFactories 中获取,通过执行 ObjectFacotry 的 getObject() 就可以获取该对象,获取成功之后,从三级缓存移除,并将该对象加入到二级缓存中。 在三级缓存中存储的是 ObjectFacoty :\npublic interface ObjectFactory\u0026lt;T\u0026gt; { T getObject() throws BeansException; } Spring 在创建 Bean 的时候,如果允许循环依赖的话,Spring 就会将刚刚实例化完成,但是属性还没有初始化完的 Bean 对象给提前暴露出去,这里通过 addSingletonFactory 方法,向三级缓存中添加一个 ObjectFactory 对象:\n// AbstractAutowireCapableBeanFactory # doCreateBean # public abstract class AbstractAutowireCapableBeanFactory ... { protected Object doCreateBean(...) { //... // 支撑循环依赖:将 ()-\u0026gt;getEarlyBeanReference 作为一个 ObjectFactory 对象的 getObject() 方法加入到三级缓存中 addSingletonFactory(beanName, () -\u0026gt; getEarlyBeanReference(beanName, mbd, bean)); } } 那么上边在说 Spring 创建 Bean 的流程时说了,如果一级缓存、二级缓存都取不到对象时,会去三级缓存中通过 ObjectFactory 的 getObject 方法获取对象。\nclass A { // 使用了 B private B b; } class B { // 使用了 A private A a; } 以上面的循环依赖代码为例,整个解决循环依赖的流程如下:\n当 Spring 创建 A 之后,发现 A 依赖了 B ,又去创建 B,B 依赖了 A ,又去创建 A; 在 B 创建 A 的时候,那么此时 A 就发生了循环依赖,由于 A 此时还没有初始化完成,因此在 一二级缓存 中肯定没有 A; 那么此时就去三级缓存中调用 getObject() 方法去获取 A 的 前期暴露的对象 ,也就是调用上边加入的 getEarlyBeanReference() 方法,生成一个 A 的 前期暴露对象; 然后就将这个 ObjectFactory 从三级缓存中移除,并且将前期暴露对象放入到二级缓存中,那么 B 就将这个前期暴露对象注入到依赖,来支持循环依赖。 只用两级缓存够吗? 在没有 AOP 的情况下,确实可以只使用一级和三级缓存来解决循环依赖问题。但是,当涉及到 AOP 时,二级缓存就显得非常重要了,因为它确保了即使在 Bean 的创建过程中有多次对早期引用的请求,也始终只返回同一个代理对象,从而避免了同一个 Bean 有多个代理对象的问题。\n最后总结一下 Spring 如何解决三级缓存:\n在三级缓存这一块,主要记一下 Spring 是如何支持循环依赖的即可,也就是如果发生循环依赖的话,就去 三级缓存 singletonFactories 中拿到三级缓存中存储的 ObjectFactory 并调用它的 getObject() 方法来获取这个循环依赖对象的前期暴露对象(虽然还没初始化完成,但是可以拿到该对象在堆中的存储地址了),并且将这个前期暴露对象放到二级缓存中,这样在循环依赖时,就不会重复初始化了!\n不过,这种机制也有一些缺点,比如增加了内存开销(需要维护三级缓存,也就是三个 Map),降低了性能(需要进行多次检查和转换)。并且,还有少部分情况是不支持循环依赖的,比如非单例的 bean 和@Async注解的 bean 无法支持循环依赖。\n@Lazy 能解决循环依赖吗? # @Lazy 用来标识类是否需要懒加载/延迟加载,可以作用在类上、方法上、构造器上、方法参数上、成员变量中。\nSpring Boot 2.2 新增了全局懒加载属性,开启后全局 bean 被设置为懒加载,需要时再去创建。\n配置文件配置全局懒加载:\n#默认false spring.main.lazy-initialization=true 编码的方式设置全局懒加载:\nSpringApplication springApplication=new SpringApplication(Start.class); springApplication.setLazyInitialization(false); springApplication.run(args); 如非必要,尽量不要用全局懒加载。全局懒加载会让 Bean 第一次使用的时候加载会变慢,并且它会延迟应用程序问题的发现(当 Bean 被初始化时,问题才会出现)。\n如果一个 Bean 没有被标记为懒加载,那么它会在 Spring IoC 容器启动的过程中被创建和初始化。如果一个 Bean 被标记为懒加载,那么它不会在 Spring IoC 容器启动时立即实例化,而是在第一次被请求时才创建。这可以帮助减少应用启动时的初始化时间,也可以用来解决循环依赖问题。\n循环依赖问题是如何通过@Lazy 解决的呢?这里举一个例子,比如说有两个 Bean,A 和 B,他们之间发生了循环依赖,那么 A 的构造器上添加 @Lazy 注解之后(延迟 Bean B 的实例化),加载的流程如下:\n首先 Spring 会去创建 A 的 Bean,创建时需要注入 B 的属性; 由于在 A 上标注了 @Lazy 注解,因此 Spring 会去创建一个 B 的代理对象,将这个代理对象注入到 A 中的 B 属性; 之后开始执行 B 的实例化、初始化,在注入 B 中的 A 属性时,此时 A 已经创建完毕了,就可以将 A 给注入进去。 从上面的加载流程可以看出: @Lazy 解决循环依赖的关键点在于代理对象的使用。\n没有 @Lazy 的情况下:在 Spring 容器初始化 A 时会立即尝试创建 B,而在创建 B 的过程中又会尝试创建 A,最终导致循环依赖(即无限递归,最终抛出异常)。 使用 @Lazy 的情况下:Spring 不会立即创建 B,而是会注入一个 B 的代理对象。由于此时 B 仍未被真正初始化,A 的初始化可以顺利完成。等到 A 实例实际调用 B 的方法时,代理对象才会触发 B 的真正初始化。 @Lazy 能够在一定程度上打破循环依赖链,允许 Spring 容器顺利地完成 Bean 的创建和注入。但这并不是一个根本性的解决方案,尤其是在构造函数注入、复杂的多级依赖等场景中,@Lazy 无法有效地解决问题。因此,最佳实践仍然是尽量避免设计上的循环依赖。\nSpringBoot 允许循环依赖发生么? # SpringBoot 2.6.x 以前是默认允许循环依赖的,也就是说你的代码出现了循环依赖问题,一般情况下也不会报错。SpringBoot 2.6.x 以后官方不再推荐编写存在循环依赖的代码,建议开发者自己写代码的时候去减少不必要的互相依赖。这其实也是我们最应该去做的,循环依赖本身就是一种设计缺陷,我们不应该过度依赖 Spring 而忽视了编码的规范和质量,说不定未来某个 SpringBoot 版本就彻底禁止循环依赖的代码了。\nSpringBoot 2.6.x 以后,如果你不想重构循环依赖的代码的话,也可以采用下面这些方法:\n在全局配置文件中设置允许循环依赖存在:spring.main.allow-circular-references=true。最简单粗暴的方式,不太推荐。 在导致循环依赖的 Bean 上添加 @Lazy 注解,这是一种比较推荐的方式。@Lazy 用来标识类是否需要懒加载/延迟加载,可以作用在类上、方法上、构造器上、方法参数上、成员变量中。 …… Spring 事务 # 关于 Spring 事务的详细介绍,可以看我写的 Spring 事务详解 这篇文章。\nSpring 管理事务的方式有几种? # 编程式事务:在代码中硬编码(在分布式系统中推荐使用) : 通过 TransactionTemplate或者 TransactionManager 手动管理事务,事务范围过大会出现事务未提交导致超时,因此事务要比锁的粒度更小。 声明式事务:在 XML 配置文件中配置或者直接基于注解(单体应用或者简单业务系统推荐使用) : 实际是通过 AOP 实现(基于@Transactional 的全注解方式使用最多) Spring 事务中哪几种事务传播行为? # 事务传播行为是为了解决业务层方法之间互相调用的事务问题。\n当事务方法被另一个事务方法调用时,必须指定事务应该如何传播。例如:方法可能继续在现有事务中运行,也可能开启一个新事务,并在自己的事务中运行。\n正确的事务传播行为可能的值如下:\n1.TransactionDefinition.PROPAGATION_REQUIRED\n使用的最多的一个事务传播行为,我们平时经常使用的@Transactional注解默认使用就是这个事务传播行为。如果当前存在事务,则加入该事务;如果当前没有事务,则创建一个新的事务。\n2.TransactionDefinition.PROPAGATION_REQUIRES_NEW\n创建一个新的事务,如果当前存在事务,则把当前事务挂起。也就是说不管外部方法是否开启事务,Propagation.REQUIRES_NEW修饰的内部方法会新开启自己的事务,且开启的事务相互独立,互不干扰。\n3.TransactionDefinition.PROPAGATION_NESTED\n如果当前存在事务,则创建一个事务作为当前事务的嵌套事务来运行;如果当前没有事务,则该取值等价于TransactionDefinition.PROPAGATION_REQUIRED。\n4.TransactionDefinition.PROPAGATION_MANDATORY\n如果当前存在事务,则加入该事务;如果当前没有事务,则抛出异常。(mandatory:强制性)\n这个使用的很少。\n若是错误的配置以下 3 种事务传播行为,事务将不会发生回滚:\nTransactionDefinition.PROPAGATION_SUPPORTS: 如果当前存在事务,则加入该事务;如果当前没有事务,则以非事务的方式继续运行。 TransactionDefinition.PROPAGATION_NOT_SUPPORTED: 以非事务方式运行,如果当前存在事务,则把当前事务挂起。 TransactionDefinition.PROPAGATION_NEVER: 以非事务方式运行,如果当前存在事务,则抛出异常。 Spring 事务中的隔离级别有哪几种? # 和事务传播行为这块一样,为了方便使用,Spring 也相应地定义了一个枚举类:Isolation\npublic enum Isolation { DEFAULT(TransactionDefinition.ISOLATION_DEFAULT), READ_UNCOMMITTED(TransactionDefinition.ISOLATION_READ_UNCOMMITTED), READ_COMMITTED(TransactionDefinition.ISOLATION_READ_COMMITTED), REPEATABLE_READ(TransactionDefinition.ISOLATION_REPEATABLE_READ), SERIALIZABLE(TransactionDefinition.ISOLATION_SERIALIZABLE); private final int value; Isolation(int value) { this.value = value; } public int value() { return this.value; } } 下面我依次对每一种事务隔离级别进行介绍:\nTransactionDefinition.ISOLATION_DEFAULT :使用后端数据库默认的隔离级别,MySQL 默认采用的 REPEATABLE_READ 隔离级别 Oracle 默认采用的 READ_COMMITTED 隔离级别. TransactionDefinition.ISOLATION_READ_UNCOMMITTED :最低的隔离级别,使用这个隔离级别很少,因为它允许读取尚未提交的数据变更,可能会导致脏读、幻读或不可重复读 TransactionDefinition.ISOLATION_READ_COMMITTED : 允许读取并发事务已经提交的数据,可以阻止脏读,但是幻读或不可重复读仍有可能发生 TransactionDefinition.ISOLATION_REPEATABLE_READ : 对同一字段的多次读取结果都是一致的,除非数据是被本身事务自己所修改,可以阻止脏读和不可重复读,但幻读仍有可能发生。 TransactionDefinition.ISOLATION_SERIALIZABLE : 最高的隔离级别,完全服从 ACID 的隔离级别。所有的事务依次逐个执行,这样事务之间就完全不可能产生干扰,也就是说,该级别可以防止脏读、不可重复读以及幻读。但是这将严重影响程序的性能。通常情况下也不会用到该级别。 @Transactional(rollbackFor = Exception.class)注解了解吗? # Exception 分为运行时异常 RuntimeException 和非运行时异常。事务管理对于企业应用来说是至关重要的,即使出现异常情况,它也可以保证数据的一致性。\n当 @Transactional 注解作用于类上时,该类的所有 public 方法将都具有该类型的事务属性,同时,我们也可以在方法级别使用该标注来覆盖类级别的定义。\n@Transactional 注解默认回滚策略是只有在遇到RuntimeException(运行时异常) 或者 Error 时才会回滚事务,而不会回滚 Checked Exception(受检查异常)。这是因为 Spring 认为RuntimeException和 Error 是不可预期的错误,而受检异常是可预期的错误,可以通过业务逻辑来处理。\n如果想要修改默认的回滚策略,可以使用 @Transactional 注解的 rollbackFor 和 noRollbackFor 属性来指定哪些异常需要回滚,哪些异常不需要回滚。例如,如果想要让所有的异常都回滚事务,可以使用如下的注解:\n@Transactional(rollbackFor = Exception.class) public void someMethod() { // some business logic } 如果想要让某些特定的异常不回滚事务,可以使用如下的注解:\n@Transactional(noRollbackFor = CustomException.class) public void someMethod() { // some business logic } Spring Data JPA # JPA 重要的是实战,这里仅对小部分知识点进行总结。\n如何使用 JPA 在数据库中非持久化一个字段? # 假如我们有下面一个类:\n@Entity(name=\u0026#34;USER\u0026#34;) public class User { @Id @GeneratedValue(strategy = GenerationType.AUTO) @Column(name = \u0026#34;ID\u0026#34;) private Long id; @Column(name=\u0026#34;USER_NAME\u0026#34;) private String userName; @Column(name=\u0026#34;PASSWORD\u0026#34;) private String password; private String secrect; } 如果我们想让secrect 这个字段不被持久化,也就是不被数据库存储怎么办?我们可以采用下面几种方法:\nstatic String transient1; // not persistent because of static final String transient2 = \u0026#34;Satish\u0026#34;; // not persistent because of final transient String transient3; // not persistent because of transient @Transient String transient4; // not persistent because of @Transient 一般使用后面两种方式比较多,我个人使用注解的方式比较多。\nJPA 的审计功能是做什么的?有什么用? # 审计功能主要是帮助我们记录数据库操作的具体行为比如某条记录是谁创建的、什么时间创建的、最后修改人是谁、最后修改时间是什么时候。\n@Data @AllArgsConstructor @NoArgsConstructor @MappedSuperclass @EntityListeners(value = AuditingEntityListener.class) public abstract class AbstractAuditBase { @CreatedDate @Column(updatable = false) @JsonIgnore private Instant createdAt; @LastModifiedDate @JsonIgnore private Instant updatedAt; @CreatedBy @Column(updatable = false) @JsonIgnore private String createdBy; @LastModifiedBy @JsonIgnore private String updatedBy; } @CreatedDate: 表示该字段为创建时间字段,在这个实体被 insert 的时候,会设置值\n@CreatedBy :表示该字段为创建人,在这个实体被 insert 的时候,会设置值\n@LastModifiedDate、@LastModifiedBy同理。\n实体之间的关联关系注解有哪些? # @OneToOne : 一对一。 @ManyToMany:多对多。 @OneToMany : 一对多。 @ManyToOne:多对一。 利用 @ManyToOne 和 @OneToMany 也可以表达多对多的关联关系。\nSpring Security # Spring Security 重要的是实战,这里仅对小部分知识点进行总结。\n有哪些控制请求访问权限的方法? # permitAll():无条件允许任何形式访问,不管你登录还是没有登录。 anonymous():允许匿名访问,也就是没有登录才可以访问。 denyAll():无条件决绝任何形式的访问。 authenticated():只允许已认证的用户访问。 fullyAuthenticated():只允许已经登录或者通过 remember-me 登录的用户访问。 hasRole(String) : 只允许指定的角色访问。 hasAnyRole(String) : 指定一个或者多个角色,满足其一的用户即可访问。 hasAuthority(String):只允许具有指定权限的用户访问 hasAnyAuthority(String):指定一个或者多个权限,满足其一的用户即可访问。 hasIpAddress(String) : 只允许指定 ip 的用户访问。 hasRole 和 hasAuthority 有区别吗? # 可以看看松哥的这篇文章: Spring Security 中的 hasRole 和 hasAuthority 有区别吗?,介绍的比较详细。\n如何对密码进行加密? # 如果我们需要保存密码这类敏感数据到数据库的话,需要先加密再保存。\nSpring Security 提供了多种加密算法的实现,开箱即用,非常方便。这些加密算法实现类的接口是 PasswordEncoder ,如果你想要自己实现一个加密算法的话,也需要实现 PasswordEncoder 接口。\nPasswordEncoder 接口一共也就 3 个必须实现的方法。\npublic interface PasswordEncoder { // 加密也就是对原始密码进行编码 String encode(CharSequence var1); // 比对原始密码和数据库中保存的密码 boolean matches(CharSequence var1, String var2); // 判断加密密码是否需要再次进行加密,默认返回 false default boolean upgradeEncoding(String encodedPassword) { return false; } } 官方推荐使用基于 bcrypt 强哈希函数的加密算法实现类。\n如何优雅更换系统使用的加密算法? # 如果我们在开发过程中,突然发现现有的加密算法无法满足我们的需求,需要更换成另外一个加密算法,这个时候应该怎么办呢?\n推荐的做法是通过 DelegatingPasswordEncoder 兼容多种不同的密码加密方案,以适应不同的业务需求。\n从名字也能看出来,DelegatingPasswordEncoder 其实就是一个代理类,并非是一种全新的加密算法,它做的事情就是代理上面提到的加密算法实现类。在 Spring Security 5.0 之后,默认就是基于 DelegatingPasswordEncoder 进行密码加密的。\n参考 # 《Spring 技术内幕》 《从零开始深入学习 Spring》: https://juejin.cn/book/6857911863016390663 http://www.cnblogs.com/wmyskxz/p/8820371.html https://www.journaldev.com/2696/spring-interview-questions-and-answers https://www.edureka.co/blog/interview-questions/spring-interview-questions/ https://www.cnblogs.com/clwydjgs/p/9317849.html https://howtodoinjava.com/interview-questions/top-spring-interview-questions-with-answers/ http://www.tomaszezula.com/2014/02/09/spring-series-part-5-component-vs-bean/ https://stackoverflow.com/questions/34172888/difference-between-bean-and-autowired "},{"id":577,"href":"/zh/docs/technology/Interview/database/sql/sql-questions-01/","title":"SQL常见面试题总结(1)","section":"SQL","content":" 题目来源于: 牛客题霸 - SQL 必知必会\n检索数据 # SELECT 用于从数据库中查询数据。\n从 Customers 表中检索所有的 ID # 现有表 Customers 如下:\ncust_id A B C 编写 SQL 语句,从 Customers 表中检索所有的 cust_id。\n答案:\nSELECT cust_id FROM Customers 检索并列出已订购产品的清单 # 表 OrderItems 含有非空的列 prod_id 代表商品 id,包含了所有已订购的商品(有些已被订购多次)。\nprod_id a1 a2 a3 a4 a5 a6 a7 编写 SQL 语句,检索并列出所有已订购商品(prod_id)的去重后的清单。\n答案:\nSELECT DISTINCT prod_id FROM OrderItems 知识点:DISTINCT 用于返回列中的唯一不同值。\n检索所有列 # 现在有 Customers 表(表中含有列 cust_id 代表客户 id,cust_name 代表客户姓名)\ncust_id cust_name a1 andy a2 ben a3 tony a4 tom a5 an a6 lee a7 hex 需要编写 SQL 语句,检索所有列。\n答案:\nSELECT cust_id, cust_name FROM Customers 排序检索数据 # ORDER BY 用于对结果集按照一个列或者多个列进行排序。默认按照升序对记录进行排序,如果需要按照降序对记录进行排序,可以使用 DESC 关键字。\n检索顾客名称并且排序 # 有表 Customers,cust_id 代表客户 id,cust_name 代表客户姓名。\ncust_id cust_name a1 andy a2 ben a3 tony a4 tom a5 an a6 lee a7 hex 从 Customers 中检索所有的顾客名称(cust_name),并按从 Z 到 A 的顺序显示结果。\n答案:\nSELECT cust_name FROM Customers ORDER BY cust_name DESC 对顾客 ID 和日期排序 # 有 Orders 表:\ncust_id order_num order_date andy aaaa 2021-01-01 00:00:00 andy bbbb 2021-01-01 12:00:00 bob cccc 2021-01-10 12:00:00 dick dddd 2021-01-11 00:00:00 编写 SQL 语句,从 Orders 表中检索顾客 ID(cust_id)和订单号(order_num),并先按顾客 ID 对结果进行排序,再按订单日期倒序排列。\n答案:\n# 根据列名排序 # 注意:是 order_date 降序,而不是 order_num SELECT cust_id, order_num FROM Orders ORDER BY cust_id,order_date DESC 知识点:order by 对多列排序的时候,先排序的列放前面,后排序的列放后面。并且,不同的列可以有不同的排序规则。\n按照数量和价格排序 # 假设有一个 OrderItems 表:\nquantity item_price 1 100 10 1003 2 500 编写 SQL 语句,显示 OrderItems 表中的数量(quantity)和价格(item_price),并按数量由多到少、价格由高到低排序。\n答案:\nSELECT quantity, item_price FROM OrderItems ORDER BY quantity DESC,item_price DESC 检查 SQL 语句 # 有 Vendors 表:\nvend_name 海底捞 小龙坎 大龙燚 下面的 SQL 语句有问题吗?尝试将它改正确,使之能够正确运行,并且返回结果根据vend_name 逆序排列。\nSELECT vend_name, FROM Vendors ORDER vend_name DESC 改正后:\nSELECT vend_name FROM Vendors ORDER BY vend_name DESC 知识点:\n逗号作用是用来隔开列与列之间的。 ORDER BY 是有 BY 的,需要撰写完整,且位置正确。 过滤数据 # WHERE 可以过滤返回的数据。\n下面的运算符可以在 WHERE 子句中使用:\n运算符 描述 = 等于 \u0026lt;\u0026gt; 不等于。 注释: 在 SQL 的一些版本中,该操作符可被写成 != \u0026gt; 大于 \u0026lt; 小于 \u0026gt;= 大于等于 \u0026lt;= 小于等于 BETWEEN 在某个范围内 LIKE 搜索某种模式 IN 指定针对某个列的多个可能值 返回固定价格的产品 # 有表 Products:\nprod_id prod_name prod_price a0018 sockets 9.49 a0019 iphone13 600 b0018 gucci t-shirts 1000 【问题】从 Products 表中检索产品 ID(prod_id)和产品名称(prod_name),只返回价格为 9.49 美元的产品。\n答案:\nSELECT prod_id, prod_name FROM Products WHERE prod_price = 9.49 返回更高价格的产品 # 有表 Products:\nprod_id prod_name prod_price a0018 sockets 9.49 a0019 iphone13 600 b0019 gucci t-shirts 1000 【问题】编写 SQL 语句,从 Products 表中检索产品 ID(prod_id)和产品名称(prod_name),只返回价格为 9 美元或更高的产品。\n答案:\nSELECT prod_id, prod_name FROM Products WHERE prod_price \u0026gt;= 9 返回产品并且按照价格排序 # 有表 Products:\nprod_id prod_name prod_price a0011 egg 3 a0019 sockets 4 b0019 coffee 15 【问题】编写 SQL 语句,返回 Products 表中所有价格在 3 美元到 6 美元之间的产品的名称(prod_name)和价格(prod_price),然后按价格对结果进行排序。\n答案:\nSELECT prod_name, prod_price FROM Products WHERE prod_price BETWEEN 3 AND 6 ORDER BY prod_price # 或者 SELECT prod_name, prod_price FROM Products WHERE prod_price \u0026gt;= 3 AND prod_price \u0026lt;= 6 ORDER BY prod_price 返回更多的产品 # OrderItems 表含有:订单号 order_num,quantity产品数量\norder_num quantity a1 105 a2 1100 a2 200 a4 1121 a5 10 a2 19 a7 5 【问题】从 OrderItems 表中检索出所有不同且不重复的订单号(order_num),其中每个订单都要包含 100 个或更多的产品。\n答案:\nSELECT order_num FROM OrderItems GROUP BY order_num HAVING SUM(quantity) \u0026gt;= 100 高级数据过滤 # AND 和 OR 运算符用于基于一个以上的条件对记录进行过滤,两者可以结合使用。AND 必须 2 个条件都成立,OR只要 2 个条件中的一个成立即可。\n检索供应商名称 # Vendors 表有字段供应商名称(vend_name)、供应商国家(vend_country)、供应商州(vend_state)\nvend_name vend_country vend_state apple USA CA vivo CNA shenzhen huawei CNA xian 【问题】编写 SQL 语句,从 Vendors 表中检索供应商名称(vend_name),仅返回加利福尼亚州的供应商(这需要按国家[USA]和州[CA]进行过滤,没准其他国家也存在一个 CA)\n答案:\nSELECT vend_name FROM Vendors WHERE vend_country = \u0026#39;USA\u0026#39; AND vend_state = \u0026#39;CA\u0026#39; 检索并列出已订购产品的清单 # OrderItems 表包含了所有已订购的产品(有些已被订购多次)。\nprod_id order_num quantity BR01 a1 105 BR02 a2 1100 BR02 a2 200 BR03 a4 1121 BR017 a5 10 BR02 a2 19 BR017 a7 5 【问题】编写 SQL 语句,查找所有订购了数量至少 100 个的 BR01、BR02 或 BR03 的订单。你需要返回 OrderItems 表的订单号(order_num)、产品 ID(prod_id)和数量(quantity),并按产品 ID 和数量进行过滤。\n答案:\nSELECT order_num, prod_id, quantity FROM OrderItems WHERE prod_id IN (\u0026#39;BR01\u0026#39;, \u0026#39;BR02\u0026#39;, \u0026#39;BR03\u0026#39;) AND quantity \u0026gt;= 100 返回所有价格在 3 美元到 6 美元之间的产品的名称和价格 # 有表 Products:\nprod_id prod_name prod_price a0011 egg 3 a0019 sockets 4 b0019 coffee 15 【问题】编写 SQL 语句,返回所有价格在 3 美元到 6 美元之间的产品的名称(prod_name)和价格(prod_price),使用 AND 操作符,然后按价格对结果进行升序排序。\n答案:\nSELECT prod_name, prod_price FROM Products WHERE prod_price \u0026gt;= 3 and prod_price \u0026lt;= 6 ORDER BY prod_price 检查 SQL 语句 # 供应商表 Vendors 有字段供应商名称 vend_name、供应商国家 vend_country、供应商省份 vend_state\nvend_name vend_country vend_state apple USA CA vivo CNA shenzhen huawei CNA xian 【问题】修改正确下面 sql,使之正确返回。\nSELECT vend_name FROM Vendors ORDER BY vend_name WHERE vend_country = \u0026#39;USA\u0026#39; AND vend_state = \u0026#39;CA\u0026#39;; 修改后:\nSELECT vend_name FROM Vendors WHERE vend_country = \u0026#39;USA\u0026#39; AND vend_state = \u0026#39;CA\u0026#39; ORDER BY vend_name ORDER BY 语句必须放在 WHERE 之后。\n用通配符进行过滤 # SQL 通配符必须与 LIKE 运算符一起使用\n在 SQL 中,可使用以下通配符:\n通配符 描述 % 代表零个或多个字符 _ 仅替代一个字符 [charlist] 字符列中的任何单一字符 [^charlist] 或者 [!charlist] 不在字符列中的任何单一字符 检索产品名称和描述(一) # Products 表如下:\nprod_name prod_desc a0011 usb a0019 iphone13 b0019 gucci t-shirts c0019 gucci toy d0019 lego toy 【问题】编写 SQL 语句,从 Products 表中检索产品名称(prod_name)和描述(prod_desc),仅返回描述中包含 toy 一词的产品名称。\n答案:\nSELECT prod_name, prod_desc FROM Products WHERE prod_desc LIKE \u0026#39;%toy%\u0026#39; 检索产品名称和描述(二) # Products 表如下:\nprod_name prod_desc a0011 usb a0019 iphone13 b0019 gucci t-shirts c0019 gucci toy d0019 lego toy 【问题】编写 SQL 语句,从 Products 表中检索产品名称(prod_name)和描述(prod_desc),仅返回描述中未出现 toy 一词的产品,最后按”产品名称“对结果进行排序。\n答案:\nSELECT prod_name, prod_desc FROM Products WHERE prod_desc NOT LIKE \u0026#39;%toy%\u0026#39; ORDER BY prod_name 检索产品名称和描述(三) # Products 表如下:\nprod_name prod_desc a0011 usb a0019 iphone13 b0019 gucci t-shirts c0019 gucci toy d0019 lego carrots toy 【问题】编写 SQL 语句,从 Products 表中检索产品名称(prod_name)和描述(prod_desc),仅返回描述中同时出现 toy 和 carrots 的产品。有好几种方法可以执行此操作,但对于这个挑战题,请使用 AND 和两个 LIKE 比较。\n答案:\nSELECT prod_name, prod_desc FROM Products WHERE prod_desc LIKE \u0026#39;%toy%\u0026#39; AND prod_desc LIKE \u0026#34;%carrots%\u0026#34; 检索产品名称和描述(四) # Products 表如下:\nprod_name prod_desc a0011 usb a0019 iphone13 b0019 gucci t-shirts c0019 gucci toy d0019 lego toy carrots 【问题】编写 SQL 语句,从 Products 表中检索产品名称(prod_name)和描述(prod_desc),仅返回在描述中以先后顺序同时出现 toy 和 carrots 的产品。提示:只需要用带有三个 % 符号的 LIKE 即可。\n答案:\nSELECT prod_name, prod_desc FROM Products WHERE prod_desc LIKE \u0026#39;%toy%carrots%\u0026#39; 创建计算字段 # 别名 # 别名的常见用法是在检索出的结果中重命名表的列字段(为了符合特定的报表要求或客户需求)。有表 Vendors 代表供应商信息,vend_id 供应商 id、vend_name 供应商名称、vend_address 供应商地址、vend_city 供应商城市。\nvend_id vend_name vend_address vend_city a001 tencent cloud address1 shenzhen a002 huawei cloud address2 dongguan a003 aliyun cloud address3 hangzhou a003 netease cloud address4 guangzhou 【问题】编写 SQL 语句,从 Vendors 表中检索 vend_id、vend_name、vend_address 和 vend_city,将 vend_name 重命名为 vname,将 vend_city 重命名为 vcity,将 vend_address 重命名为 vaddress,按供应商名称对结果进行升序排序。\n答案:\nSELECT vend_id, vend_name AS vname, vend_address AS vaddress, vend_city AS vcity FROM Vendors ORDER BY vname # as 可以省略 SELECT vend_id, vend_name vname, vend_address vaddress, vend_city vcity FROM Vendors ORDER BY vname 打折 # 我们的示例商店正在进行打折促销,所有产品均降价 10%。Products 表包含 prod_id 产品 id、prod_price 产品价格。\n【问题】编写 SQL 语句,从 Products 表中返回 prod_id、prod_price 和 sale_price。sale_price 是一个包含促销价格的计算字段。提示:可以乘以 0.9,得到原价的 90%(即 10%的折扣)。\n答案:\nSELECT prod_id, prod_price, prod_price * 0.9 AS sale_price FROM Products 注意:sale_price 是对计算结果的命名,而不是原有的列名。\n使用函数处理数据 # 顾客登录名 # 我们的商店已经上线了,正在创建顾客账户。所有用户都需要登录名,默认登录名是其名称和所在城市的组合。\n给出 Customers 表 如下:\ncust_id cust_name cust_contact cust_city a1 Andy Li Andy Li Oak Park a2 Ben Liu Ben Liu Oak Park a3 Tony Dai Tony Dai Oak Park a4 Tom Chen Tom Chen Oak Park a5 An Li An Li Oak Park a6 Lee Chen Lee Chen Oak Park a7 Hex Liu Hex Liu Oak Park 【问题】编写 SQL 语句,返回顾客 ID(cust_id)、顾客名称(cust_name)和登录名(user_login),其中登录名全部为大写字母,并由顾客联系人的前两个字符(cust_contact)和其所在城市的前三个字符(cust_city)组成。提示:需要使用函数、拼接和别名。\n答案:\nSELECT cust_id, cust_name, UPPER(CONCAT(SUBSTRING(cust_contact, 1, 2), SUBSTRING(cust_city, 1, 3))) AS user_login FROM Customers 知识点:\n截取函数SUBSTRING():截取字符串,substring(str ,n ,m)(n 表示起始截取位置,m 表示要截取的字符个数)表示返回字符串 str 从第 n 个字符开始截取 m 个字符;\n拼接函数CONCAT():将两个或多个字符串连接成一个字符串,select concat(A,B):连接字符串 A 和 B。\n大写函数 UPPER():将指定字符串转换为大写。\n返回 2020 年 1 月的所有订单的订单号和订单日期 # Orders 订单表如下:\norder_num order_date a0001 2020-01-01 00:00:00 a0002 2020-01-02 00:00:00 a0003 2020-01-01 12:00:00 a0004 2020-02-01 00:00:00 a0005 2020-03-01 00:00:00 【问题】编写 SQL 语句,返回 2020 年 1 月的所有订单的订单号(order_num)和订单日期(order_date),并按订单日期升序排序\n答案:\nSELECT order_num, order_date FROM Orders WHERE month(order_date) = \u0026#39;01\u0026#39; AND YEAR(order_date) = \u0026#39;2020\u0026#39; ORDER BY order_date 也可以用通配符来做:\nSELECT order_num, order_date FROM Orders WHERE order_date LIKE \u0026#39;2020-01%\u0026#39; ORDER BY order_date 知识点:\n日期格式:YYYY-MM-DD 时间格式:HH:MM:SS 日期和时间处理相关的常用函数:\n函 数 说 明 ADDDATE() 增加一个日期(天、周等) ADDTIME() 增加一个时间(时、分等) CURDATE() 返回当前日期 CURTIME() 返回当前时间 DATE() 返回日期时间的日期部分 DATEDIFF 计算两个日期之差 DATE_FORMAT() 返回一个格式化的日期或时间串 DAY() 返回一个日期的天数部分 DAYOFWEEK() 对于一个日期,返回对应的星期几 HOUR() 返回一个时间的小时部分 MINUTE() 返回一个时间的分钟部分 MONTH() 返回一个日期的月份部分 NOW() 返回当前日期和时间 SECOND() 返回一个时间的秒部分 TIME() 返回一个日期时间的时间部分 YEAR() 返回一个日期的年份部分 汇总数据 # 汇总数据相关的函数:\n函 数 说 明 AVG() 返回某列的平均值 COUNT() 返回某列的行数 MAX() 返回某列的最大值 MIN() 返回某列的最小值 SUM() 返回某列值之和 确定已售出产品的总数 # OrderItems 表代表售出的产品,quantity 代表售出商品数量。\nquantity 10 100 1000 10001 2 15 【问题】编写 SQL 语句,确定已售出产品的总数。\n答案:\nSELECT Sum(quantity) AS items_ordered FROM OrderItems 确定已售出产品项 BR01 的总数 # OrderItems 表代表售出的产品,quantity 代表售出商品数量,产品项为 prod_id。\nquantity prod_id 10 AR01 100 AR10 1000 BR01 10001 BR010 【问题】修改创建的语句,确定已售出产品项(prod_id)为\u0026quot;BR01\u0026quot;的总数。\n答案:\nSELECT Sum(quantity) AS items_ordered FROM OrderItems WHERE prod_id = \u0026#39;BR01\u0026#39; 确定 Products 表中价格不超过 10 美元的最贵产品的价格 # Products 表如下,prod_price 代表商品的价格。\nprod_price 9.49 600 1000 【问题】编写 SQL 语句,确定 Products 表中价格不超过 10 美元的最贵产品的价格(prod_price)。将计算所得的字段命名为 max_price。\n答案:\nSELECT Max(prod_price) AS max_price FROM Products WHERE prod_price \u0026lt;= 10 分组数据 # GROUP BY:\nGROUP BY 子句将记录分组到汇总行中。 GROUP BY 为每个组返回一个记录。 GROUP BY 通常还涉及聚合COUNT,MAX,SUM,AVG 等。 GROUP BY 可以按一列或多列进行分组。 GROUP BY 按分组字段进行排序后,ORDER BY 可以以汇总字段来进行排序。 HAVING:\nHAVING 用于对汇总的 GROUP BY 结果进行过滤。 HAVING 必须要与 GROUP BY 连用。 WHERE 和 HAVING 可以在相同的查询中。 HAVING vs WHERE:\nWHERE:过滤指定的行,后面不能加聚合函数(分组函数)。 HAVING:过滤分组,必须要与 GROUP BY 连用,不能单独使用。 返回每个订单号各有多少行数 # OrderItems 表包含每个订单的每个产品\norder_num a002 a002 a002 a004 a007 【问题】编写 SQL 语句,返回每个订单号(order_num)各有多少行数(order_lines),并按 order_lines 对结果进行升序排序。\n答案:\nSELECT order_num, Count(order_num) AS order_lines FROM OrderItems GROUP BY order_num ORDER BY order_lines 知识点:\ncount(*),count(列名)都可以,区别在于,count(列名)是统计非 NULL 的行数; order by 最后执行,所以可以使用列别名; 分组聚合一定不要忘记加上 group by ,不然只会有一行结果。 每个供应商成本最低的产品 # 有 Products 表,含有字段 prod_price 代表产品价格,vend_id 代表供应商 id\nvend_id prod_price a0011 100 a0019 0.1 b0019 1000 b0019 6980 b0019 20 【问题】编写 SQL 语句,返回名为 cheapest_item 的字段,该字段包含每个供应商成本最低的产品(使用 Products 表中的 prod_price),然后从最低成本到最高成本对结果进行升序排序。\n答案:\nSELECT vend_id, Min(prod_price) AS cheapest_item FROM Products GROUP BY vend_id ORDER BY cheapest_item 返回订单数量总和不小于 100 的所有订单的订单号 # OrderItems 代表订单商品表,包括:订单号 order_num 和订单数量 quantity。\norder_num quantity a1 105 a2 1100 a2 200 a4 1121 a5 10 a2 19 a7 5 【问题】请编写 SQL 语句,返回订单数量总和不小于 100 的所有订单号,最后结果按照订单号升序排序。\n答案:\n# 直接聚合 SELECT order_num FROM OrderItems GROUP BY order_num HAVING Sum(quantity) \u0026gt;= 100 ORDER BY order_num # 子查询 SELECT a.order_num FROM (SELECT order_num, Sum(quantity) AS sum_num FROM OrderItems GROUP BY order_num HAVING sum_num \u0026gt;= 100) a ORDER BY a.order_num 知识点:\nwhere:过滤过滤指定的行,后面不能加聚合函数(分组函数)。 having:过滤分组,与 group by 连用,不能单独使用。 计算总和 # OrderItems 表代表订单信息,包括字段:订单号 order_num 和 item_price 商品售出价格、quantity 商品数量。\norder_num item_price quantity a1 10 105 a2 1 1100 a2 1 200 a4 2 1121 a5 5 10 a2 1 19 a7 7 5 【问题】编写 SQL 语句,根据订单号聚合,返回订单总价不小于 1000 的所有订单号,最后的结果按订单号进行升序排序。\n提示:总价 = item_price 乘以 quantity\n答案:\nSELECT order_num, Sum(item_price * quantity) AS total_price FROM OrderItems GROUP BY order_num HAVING total_price \u0026gt;= 1000 ORDER BY order_num 检查 SQL 语句 # OrderItems 表含有 order_num 订单号\norder_num a002 a002 a002 a004 a007 【问题】将下面代码修改正确后执行\nSELECT order_num, COUNT(*) AS items FROM OrderItems GROUP BY items HAVING COUNT(*) \u0026gt;= 3 ORDER BY items, order_num; 修改后:\nSELECT order_num, COUNT(*) AS items FROM OrderItems GROUP BY order_num HAVING items \u0026gt;= 3 ORDER BY items, order_num; 使用子查询 # 子查询是嵌套在较大查询中的 SQL 查询,也称内部查询或内部选择,包含子查询的语句也称为外部查询或外部选择。简单来说,子查询就是指将一个 SELECT 查询(子查询)的结果作为另一个 SQL 语句(主查询)的数据来源或者判断条件。\n子查询可以嵌入 SELECT、INSERT、UPDATE 和 DELETE 语句中,也可以和 =、\u0026lt;、\u0026gt;、IN、BETWEEN、EXISTS 等运算符一起使用。\n子查询常用在 WHERE 子句和 FROM 子句后边:\n当用于 WHERE 子句时,根据不同的运算符,子查询可以返回单行单列、多行单列、单行多列数据。子查询就是要返回能够作为 WHERE 子句查询条件的值。 当用于 FROM 子句时,一般返回多行多列数据,相当于返回一张临时表,这样才符合 FROM 后面是表的规则。这种做法能够实现多表联合查询。 注意:MySQL 数据库从 4.1 版本才开始支持子查询,早期版本是不支持的。\n用于 WHERE 子句的子查询的基本语法如下:\nSELECT column_name [, column_name ] FROM table1 [, table2 ] WHERE column_name operator (SELECT column_name [, column_name ] FROM table1 [, table2 ] [WHERE]) 子查询需要放在括号( )内。 operator 表示用于 WHERE 子句的运算符,可以是比较运算符(如 =, \u0026lt;, \u0026gt;, \u0026lt;\u0026gt; 等)或逻辑运算符(如 IN, NOT IN, EXISTS, NOT EXISTS 等),具体根据需求来确定。 用于 FROM 子句的子查询的基本语法如下:\nSELECT column_name [, column_name ] FROM (SELECT column_name [, column_name ] FROM table1 [, table2 ] [WHERE]) AS temp_table_name [, ...] [JOIN type JOIN table_name ON condition] WHERE condition; 用于 FROM 的子查询返回的结果相当于一张临时表,所以需要使用 AS 关键字为该临时表起一个名字。 子查询需要放在括号 ( ) 内。 可以指定多个临时表名,并使用 JOIN 语句连接这些表。 返回购买价格为 10 美元或以上产品的顾客列表 # OrderItems 表示订单商品表,含有字段订单号:order_num、订单价格:item_price;Orders 表代表订单信息表,含有顾客 id:cust_id 和订单号:order_num\nOrderItems 表:\norder_num item_price a1 10 a2 1 a2 1 a4 2 a5 5 a2 1 a7 7 Orders 表:\norder_num cust_id a1 cust10 a2 cust1 a2 cust1 a4 cust2 a5 cust5 a2 cust1 a7 cust7 【问题】使用子查询,返回购买价格为 10 美元或以上产品的顾客列表,结果无需排序。\n答案:\nSELECT cust_id FROM Orders WHERE order_num IN (SELECT DISTINCT order_num FROM OrderItems where item_price \u0026gt;= 10) 确定哪些订单购买了 prod_id 为 BR01 的产品(一) # 表 OrderItems 代表订单商品信息表,prod_id 为产品 id;Orders 表代表订单表有 cust_id 代表顾客 id 和订单日期 order_date\nOrderItems 表:\nprod_id order_num BR01 a0001 BR01 a0002 BR02 a0003 BR02 a0013 Orders 表:\norder_num cust_id order_date a0001 cust10 2022-01-01 00:00:00 a0002 cust1 2022-01-01 00:01:00 a0003 cust1 2022-01-02 00:00:00 a0013 cust2 2022-01-01 00:20:00 【问题】\n编写 SQL 语句,使用子查询来确定哪些订单(在 OrderItems 中)购买了 prod_id 为 \u0026ldquo;BR01\u0026rdquo; 的产品,然后从 Orders 表中返回每个产品对应的顾客 ID(cust_id)和订单日期(order_date),按订购日期对结果进行升序排序。\n答案:\n# 写法 1:子查询 SELECT cust_id,order_date FROM Orders WHERE order_num IN (SELECT order_num FROM OrderItems WHERE prod_id = \u0026#39;BR01\u0026#39; ) ORDER BY order_date; # 写法 2: 连接表 SELECT b.cust_id, b.order_date FROM OrderItems a,Orders b WHERE a.order_num = b.order_num AND a.prod_id = \u0026#39;BR01\u0026#39; ORDER BY order_date 返回购买 prod_id 为 BR01 的产品的所有顾客的电子邮件(一) # 你想知道订购 BR01 产品的日期,有表 OrderItems 代表订单商品信息表,prod_id 为产品 id;Orders 表代表订单表有 cust_id 代表顾客 id 和订单日期 order_date;Customers 表含有 cust_email 顾客邮件和 cust_id 顾客 id\nOrderItems 表:\nprod_id order_num BR01 a0001 BR01 a0002 BR02 a0003 BR02 a0013 Orders 表:\norder_num cust_id order_date a0001 cust10 2022-01-01 00:00:00 a0002 cust1 2022-01-01 00:01:00 a0003 cust1 2022-01-02 00:00:00 a0013 cust2 2022-01-01 00:20:00 Customers 表代表顾客信息,cust_id 为顾客 id,cust_email 为顾客 email\ncust_id cust_email cust10 cust10@cust.com cust1 cust1@cust.com cust2 cust2@cust.com 【问题】返回购买 prod_id 为 BR01 的产品的所有顾客的电子邮件(Customers 表中的 cust_email),结果无需排序。\n提示:这涉及 SELECT 语句,最内层的从 OrderItems 表返回 order_num,中间的从 Customers 表返回 cust_id。\n答案:\n# 写法 1:子查询 SELECT cust_email FROM Customers WHERE cust_id IN (SELECT cust_id FROM Orders WHERE order_num IN (SELECT order_num FROM OrderItems WHERE prod_id = \u0026#39;BR01\u0026#39;)) # 写法 2: 连接表(inner join) SELECT c.cust_email FROM OrderItems a,Orders b,Customers c WHERE a.order_num = b.order_num AND b.cust_id = c.cust_id AND a.prod_id = \u0026#39;BR01\u0026#39; # 写法 3:连接表(left join) SELECT c.cust_email FROM Orders a LEFT JOIN OrderItems b ON a.order_num = b.order_num LEFT JOIN Customers c ON a.cust_id = c.cust_id WHERE b.prod_id = \u0026#39;BR01\u0026#39; 返回每个顾客不同订单的总金额 # 我们需要一个顾客 ID 列表,其中包含他们已订购的总金额。\nOrderItems 表代表订单信息,OrderItems 表有订单号:order_num 和商品售出价格:item_price、商品数量:quantity。\norder_num item_price quantity a0001 10 105 a0002 1 1100 a0002 1 200 a0013 2 1121 a0003 5 10 a0003 1 19 a0003 7 5 Orders 表订单号:order_num、顾客 id:cust_id\norder_num cust_id a0001 cust10 a0002 cust1 a0003 cust1 a0013 cust2 【问题】\n编写 SQL 语句,返回顾客 ID(Orders 表中的 cust_id),并使用子查询返回 total_ordered 以便返回每个顾客的订单总数,将结果按金额从大到小排序。\n答案:\n# 写法 1:子查询 SELECT o.cust_id, SUM(tb.total_ordered) AS `total_ordered` FROM (SELECT order_num, SUM(item_price * quantity) AS total_ordered FROM OrderItems GROUP BY order_num) AS tb, Orders o WHERE tb.order_num = o.order_num GROUP BY o.cust_id ORDER BY total_ordered DESC; # 写法 2:连接表 SELECT b.cust_id, Sum(a.quantity * a.item_price) AS total_ordered FROM OrderItems a,Orders b WHERE a.order_num = b.order_num GROUP BY cust_id ORDER BY total_ordered DESC 关于写法一详细介绍可以参考: issue#2402:写法 1 存在的错误以及修改方法。\n从 Products 表中检索所有的产品名称以及对应的销售总数 # Products 表中检索所有的产品名称:prod_name、产品 id:prod_id\nprod_id prod_name a0001 egg a0002 sockets a0013 coffee a0003 cola OrderItems 代表订单商品表,订单产品:prod_id、售出数量:quantity\nprod_id quantity a0001 105 a0002 1100 a0002 200 a0013 1121 a0003 10 a0003 19 a0003 5 【问题】\n编写 SQL 语句,从 Products 表中检索所有的产品名称(prod_name),以及名为 quant_sold 的计算列,其中包含所售产品的总数(在 OrderItems 表上使用子查询和 SUM(quantity) 检索)。\n答案:\n# 写法 1:子查询 SELECT p.prod_name, tb.quant_sold FROM (SELECT prod_id, Sum(quantity) AS quant_sold FROM OrderItems GROUP BY prod_id) AS tb, Products p WHERE tb.prod_id = p.prod_id # 写法 2:连接表 SELECT p.prod_name, Sum(o.quantity) AS quant_sold FROM Products p, OrderItems o WHERE p.prod_id = o.prod_id GROUP BY p.prod_name(这里不能用 p.prod_id,会报错) 连接表 # JOIN 是“连接”的意思,顾名思义,SQL JOIN 子句用于将两个或者多个表联合起来进行查询。\n连接表时需要在每个表中选择一个字段,并对这些字段的值进行比较,值相同的两条记录将合并为一条。连接表的本质就是将不同表的记录合并起来,形成一张新表。当然,这张新表只是临时的,它仅存在于本次查询期间。\n使用 JOIN 连接两个表的基本语法如下:\nSELECT table1.column1, table2.column2... FROM table1 JOIN table2 ON table1.common_column1 = table2.common_column2; table1.common_column1 = table2.common_column2 是连接条件,只有满足此条件的记录才会合并为一行。您可以使用多个运算符来连接表,例如 =、\u0026gt;、\u0026lt;、\u0026lt;\u0026gt;、\u0026lt;=、\u0026gt;=、!=、between、like 或者 not,但是最常见的是使用 =。\n当两个表中有同名的字段时,为了帮助数据库引擎区分是哪个表的字段,在书写同名字段名时需要加上表名。当然,如果书写的字段名在两个表中是唯一的,也可以不使用以上格式,只写字段名即可。\n另外,如果两张表的关联字段名相同,也可以使用 USING子句来代替 ON,举个例子:\n# join....on SELECT c.cust_name, o.order_num FROM Customers c INNER JOIN Orders o ON c.cust_id = o.cust_id ORDER BY c.cust_name # 如果两张表的关联字段名相同,也可以使用USING子句:JOIN....USING() SELECT c.cust_name, o.order_num FROM Customers c INNER JOIN Orders o USING(cust_id) ORDER BY c.cust_name ON 和 WHERE 的区别:\n连接表时,SQL 会根据连接条件生成一张新的临时表。ON 就是连接条件,它决定临时表的生成。 WHERE 是在临时表生成以后,再对临时表中的数据进行过滤,生成最终的结果集,这个时候已经没有 JOIN-ON 了。 所以总结来说就是:SQL 先根据 ON 生成一张临时表,然后再根据 WHERE 对临时表进行筛选。\nSQL 允许在 JOIN 左边加上一些修饰性的关键词,从而形成不同类型的连接,如下表所示:\n连接类型 说明 INNER JOIN 内连接 (默认连接方式)只有当两个表都存在满足条件的记录时才会返回行。 LEFT JOIN / LEFT OUTER JOIN 左(外)连接 返回左表中的所有行,即使右表中没有满足条件的行也是如此。 RIGHT JOIN / RIGHT OUTER JOIN 右(外)连接 返回右表中的所有行,即使左表中没有满足条件的行也是如此。 FULL JOIN / FULL OUTER JOIN 全(外)连接 只要其中有一个表存在满足条件的记录,就返回行。 SELF JOIN 将一个表连接到自身,就像该表是两个表一样。为了区分两个表,在 SQL 语句中需要至少重命名一个表。 CROSS JOIN 交叉连接,从两个或者多个连接表中返回记录集的笛卡尔积。 下图展示了 LEFT JOIN、RIGHT JOIN、INNER JOIN、OUTER JOIN 相关的 7 种用法。\n如果不加任何修饰词,只写 JOIN,那么默认为 INNER JOIN\n对于 INNER JOIN 来说,还有一种隐式的写法,称为 “隐式内连接”,也就是没有 INNER JOIN 关键字,使用 WHERE 语句实现内连接的功能\n# 隐式内连接 SELECT c.cust_name, o.order_num FROM Customers c,Orders o WHERE c.cust_id = o.cust_id ORDER BY c.cust_name # 显式内连接 SELECT c.cust_name, o.order_num FROM Customers c INNER JOIN Orders o USING(cust_id) ORDER BY c.cust_name; 返回顾客名称和相关订单号 # Customers 表有字段顾客名称 cust_name、顾客 id cust_id\ncust_id cust_name cust10 andy cust1 ben cust2 tony cust22 tom cust221 an cust2217 hex Orders 订单信息表,含有字段 order_num 订单号、cust_id 顾客 id\norder_num cust_id a1 cust10 a2 cust1 a3 cust2 a4 cust22 a5 cust221 a7 cust2217 【问题】编写 SQL 语句,返回 Customers 表中的顾客名称(cust_name)和 Orders 表中的相关订单号(order_num),并按顾客名称再按订单号对结果进行升序排序。你可以尝试用两个不同的写法,一个使用简单的等连接语法,另外一个使用 INNER JOIN。\n答案:\n# 隐式内连接 SELECT c.cust_name, o.order_num FROM Customers c,Orders o WHERE c.cust_id = o.cust_id ORDER BY c.cust_name,o.order_num # 显式内连接 SELECT c.cust_name, o.order_num FROM Customers c INNER JOIN Orders o USING(cust_id) ORDER BY c.cust_name,o.order_num; 返回顾客名称和相关订单号以及每个订单的总价 # Customers 表有字段,顾客名称:cust_name、顾客 id:cust_id\ncust_id cust_name cust10 andy cust1 ben cust2 tony cust22 tom cust221 an cust2217 hex Orders 订单信息表,含有字段,订单号:order_num、顾客 id:cust_id\norder_num cust_id a1 cust10 a2 cust1 a3 cust2 a4 cust22 a5 cust221 a7 cust2217 OrderItems 表有字段,商品订单号:order_num、商品数量:quantity、商品价格:item_price\norder_num quantity item_price a1 1000 10 a2 200 10 a3 10 15 a4 25 50 a5 15 25 a7 7 7 【问题】除了返回顾客名称和订单号,返回 Customers 表中的顾客名称(cust_name)和 Orders 表中的相关订单号(order_num),添加第三列 OrderTotal,其中包含每个订单的总价,并按顾客名称再按订单号对结果进行升序排序。\n# 简单的等连接语法 SELECT c.cust_name, o.order_num, SUM(quantity * item_price) AS OrderTotal FROM Customers c,Orders o,OrderItems oi WHERE c.cust_id = o.cust_id AND o.order_num = oi.order_num GROUP BY c.cust_name, o.order_num ORDER BY c.cust_name, o.order_num 注意,可能有小伙伴会这样写:\nSELECT c.cust_name, o.order_num, SUM(quantity * item_price) AS OrderTotal FROM Customers c,Orders o,OrderItems oi WHERE c.cust_id = o.cust_id AND o.order_num = oi.order_num GROUP BY c.cust_name ORDER BY c.cust_name,o.order_num 这是错误的!只对 cust_name 进行聚类确实符合题意,但是不符合 GROUP BY 的语法。\nselect 语句中,如果没有 GROUP BY 语句,那么 cust_name、order_num 会返回若干个值,而 sum(quantity * item_price) 只返回一个值,通过 group by cust_name 可以让 cust_name 和 sum(quantity * item_price) 一一对应起来,或者说聚类,所以同样的,也要对 order_num 进行聚类。\n一句话,select 中的字段要么都聚类,要么都不聚类\n确定哪些订单购买了 prod_id 为 BR01 的产品(二) # 表 OrderItems 代表订单商品信息表,prod_id 为产品 id;Orders 表代表订单表有 cust_id 代表顾客 id 和订单日期 order_date\nOrderItems 表:\nprod_id order_num BR01 a0001 BR01 a0002 BR02 a0003 BR02 a0013 Orders 表:\norder_num cust_id order_date a0001 cust10 2022-01-01 00:00:00 a0002 cust1 2022-01-01 00:01:00 a0003 cust1 2022-01-02 00:00:00 a0013 cust2 2022-01-01 00:20:00 【问题】\n编写 SQL 语句,使用子查询来确定哪些订单(在 OrderItems 中)购买了 prod_id 为 \u0026ldquo;BR01\u0026rdquo; 的产品,然后从 Orders 表中返回每个产品对应的顾客 ID(cust_id)和订单日期(order_date),按订购日期对结果进行升序排序。\n提示:这一次使用连接和简单的等连接语法。\n# 写法 1:子查询 SELECT cust_id, order_date FROM Orders WHERE order_num IN (SELECT order_num FROM OrderItems WHERE prod_id = \u0026#39;BR01\u0026#39;) ORDER BY order_date # 写法 2:连接表 inner join SELECT cust_id, order_date FROM Orders o INNER JOIN (SELECT order_num FROM OrderItems WHERE prod_id = \u0026#39;BR01\u0026#39;) tb ON o.order_num = tb.order_num ORDER BY order_date # 写法 3:写法 2 的简化版 SELECT cust_id, order_date FROM Orders INNER JOIN OrderItems USING(order_num) WHERE OrderItems.prod_id = \u0026#39;BR01\u0026#39; ORDER BY order_date 返回购买 prod_id 为 BR01 的产品的所有顾客的电子邮件(二) # 有表 OrderItems 代表订单商品信息表,prod_id 为产品 id;Orders 表代表订单表有 cust_id 代表顾客 id 和订单日期 order_date;Customers 表含有 cust_email 顾客邮件和 cust_id 顾客 id\nOrderItems 表:\nprod_id order_num BR01 a0001 BR01 a0002 BR02 a0003 BR02 a0013 Orders 表:\norder_num cust_id order_date a0001 cust10 2022-01-01 00:00:00 a0002 cust1 2022-01-01 00:01:00 a0003 cust1 2022-01-02 00:00:00 a0013 cust2 2022-01-01 00:20:00 Customers 表代表顾客信息,cust_id 为顾客 id,cust_email 为顾客 email\ncust_id cust_email cust10 cust10@cust.com cust1 cust1@cust.com cust2 cust2@cust.com 【问题】返回购买 prod_id 为 BR01 的产品的所有顾客的电子邮件(Customers 表中的 cust_email),结果无需排序。\n提示:涉及到 SELECT 语句,最内层的从 OrderItems 表返回 order_num,中间的从 Customers 表返回 cust_id,但是必须使用 INNER JOIN 语法。\nSELECT cust_email FROM Customers INNER JOIN Orders using(cust_id) INNER JOIN OrderItems using(order_num) WHERE OrderItems.prod_id = \u0026#39;BR01\u0026#39; 确定最佳顾客的另一种方式(二) # OrderItems 表代表订单信息,确定最佳顾客的另一种方式是看他们花了多少钱,OrderItems 表有订单号 order_num 和 item_price 商品售出价格、quantity 商品数量\norder_num item_price quantity a1 10 105 a2 1 1100 a2 1 200 a4 2 1121 a5 5 10 a2 1 19 a7 7 5 Orders 表含有字段 order_num 订单号、cust_id 顾客 id\norder_num cust_id a1 cust10 a2 cust1 a3 cust2 a4 cust22 a5 cust221 a7 cust2217 顾客表 Customers 有字段 cust_id 客户 id、cust_name 客户姓名\ncust_id cust_name cust10 andy cust1 ben cust2 tony cust22 tom cust221 an cust2217 hex 【问题】编写 SQL 语句,返回订单总价不小于 1000 的客户名称和总额(OrderItems 表中的 order_num)。\n提示:需要计算总和(item_price 乘以 quantity)。按总额对结果进行排序,请使用 INNER JOIN语法。\nSELECT cust_name, SUM(item_price * quantity) AS total_price FROM Customers INNER JOIN Orders USING(cust_id) INNER JOIN OrderItems USING(order_num) GROUP BY cust_name HAVING total_price \u0026gt;= 1000 ORDER BY total_price 创建高级连接 # 检索每个顾客的名称和所有的订单号(一) # Customers 表代表顾客信息含有顾客 id cust_id 和 顾客名称 cust_name\ncust_id cust_name cust10 andy cust1 ben cust2 tony cust22 tom cust221 an cust2217 hex Orders 表代表订单信息含有订单号 order_num 和顾客 id cust_id\norder_num cust_id a1 cust10 a2 cust1 a3 cust2 a4 cust22 a5 cust221 a7 cust2217 【问题】使用 INNER JOIN 编写 SQL 语句,检索每个顾客的名称(Customers 表中的 cust_name)和所有的订单号(Orders 表中的 order_num),最后根据顾客姓名 cust_name 升序返回。\nSELECT cust_name, order_num FROM Customers INNER JOIN Orders USING(cust_id) ORDER BY cust_name 检索每个顾客的名称和所有的订单号(二) # Orders 表代表订单信息含有订单号 order_num 和顾客 id cust_id\norder_num cust_id a1 cust10 a2 cust1 a3 cust2 a4 cust22 a5 cust221 a7 cust2217 Customers 表代表顾客信息含有顾客 id cust_id 和 顾客名称 cust_name\ncust_id cust_name cust10 andy cust1 ben cust2 tony cust22 tom cust221 an cust2217 hex cust40 ace 【问题】检索每个顾客的名称(Customers 表中的 cust_name)和所有的订单号(Orders 表中的 order_num),列出所有的顾客,即使他们没有下过订单。最后根据顾客姓名 cust_name 升序返回。\nSELECT cust_name, order_num FROM Customers LEFT JOIN Orders USING(cust_id) ORDER BY cust_name 返回产品名称和与之相关的订单号 # Products 表为产品信息表含有字段 prod_id 产品 id、prod_name 产品名称\nprod_id prod_name a0001 egg a0002 sockets a0013 coffee a0003 cola a0023 soda OrderItems 表为订单信息表含有字段 order_num 订单号和产品 id prod_id\nprod_id order_num a0001 a105 a0002 a1100 a0002 a200 a0013 a1121 a0003 a10 a0003 a19 a0003 a5 【问题】使用外连接(left join、 right join、full join)联结 Products 表和 OrderItems 表,返回产品名称(prod_name)和与之相关的订单号(order_num)的列表,并按照产品名称升序排序。\nSELECT prod_name, order_num FROM Products LEFT JOIN OrderItems USING(prod_id) ORDER BY prod_name 返回产品名称和每一项产品的总订单数 # Products 表为产品信息表含有字段 prod_id 产品 id、prod_name 产品名称\nprod_id prod_name a0001 egg a0002 sockets a0013 coffee a0003 cola a0023 soda OrderItems 表为订单信息表含有字段 order_num 订单号和产品 id prod_id\nprod_id order_num a0001 a105 a0002 a1100 a0002 a200 a0013 a1121 a0003 a10 a0003 a19 a0003 a5 【问题】\n使用 OUTER JOIN 联结 Products 表和 OrderItems 表,返回产品名称(prod_name)和每一项产品的总订单数(不是订单号),并按产品名称升序排序。\nSELECT prod_name, COUNT(order_num) AS orders FROM Products LEFT JOIN OrderItems USING(prod_id) GROUP BY prod_name ORDER BY prod_name 列出供应商及其可供产品的数量 # 有 Vendors 表含有 vend_id (供应商 id)\nvend_id a0002 a0013 a0003 a0010 有 Products 表含有 vend_id(供应商 id)和 prod_id(供应产品 id)\nvend_id prod_id a0001 egg a0002 prod_id_iphone a00113 prod_id_tea a0003 prod_id_vivo phone a0010 prod_id_huawei phone 【问题】列出供应商(Vendors 表中的 vend_id)及其可供产品的数量,包括没有产品的供应商。你需要使用 OUTER JOIN 和 COUNT()聚合函数来计算 Products 表中每种产品的数量,最后根据 vend_id 升序排序。\n注意:vend_id 列会显示在多个表中,因此在每次引用它时都需要完全限定它。\nSELECT v.vend_id, COUNT(prod_id) AS prod_id FROM Vendors v LEFT JOIN Products p USING(vend_id) GROUP BY v.vend_id ORDER BY v.vend_id 组合查询 # UNION 运算符将两个或更多查询的结果组合起来,并生成一个结果集,其中包含来自 UNION 中参与查询的提取行。\nUNION 基本规则:\n所有查询的列数和列顺序必须相同。 每个查询中涉及表的列的数据类型必须相同或兼容。 通常返回的列名取自第一个查询。 默认地,UNION 操作符选取不同的值。如果允许重复的值,请使用 UNION ALL。\nSELECT column_name(s) FROM table1 UNION ALL SELECT column_name(s) FROM table2; UNION 结果集中的列名总是等于 UNION 中第一个 SELECT 语句中的列名。\nJOIN vs UNION:\nJOIN 中连接表的列可能不同,但在 UNION 中,所有查询的列数和列顺序必须相同。 UNION 将查询之后的行放在一起(垂直放置),但 JOIN 将查询之后的列放在一起(水平放置),即它构成一个笛卡尔积。 将两个 SELECT 语句结合起来(一) # 表 OrderItems 包含订单产品信息,字段 prod_id 代表产品 id、quantity 代表产品数量\nprod_id quantity a0001 105 a0002 100 a0002 200 a0013 1121 a0003 10 a0003 19 a0003 5 BNBG 10002 【问题】将两个 SELECT 语句结合起来,以便从 OrderItems 表中检索产品 id(prod_id)和 quantity。其中,一个 SELECT 语句过滤数量为 100 的行,另一个 SELECT 语句过滤 id 以 BNBG 开头的产品,最后按产品 id 对结果进行升序排序。\nSELECT prod_id, quantity FROM OrderItems WHERE quantity = 100 UNION SELECT prod_id, quantity FROM OrderItems WHERE prod_id LIKE \u0026#39;BNBG%\u0026#39; 将两个 SELECT 语句结合起来(二) # 表 OrderItems 包含订单产品信息,字段 prod_id 代表产品 id、quantity 代表产品数量。\nprod_id quantity a0001 105 a0002 100 a0002 200 a0013 1121 a0003 10 a0003 19 a0003 5 BNBG 10002 【问题】将两个 SELECT 语句结合起来,以便从 OrderItems 表中检索产品 id(prod_id)和 quantity。其中,一个 SELECT 语句过滤数量为 100 的行,另一个 SELECT 语句过滤 id 以 BNBG 开头的产品,最后按产品 id 对结果进行升序排序。 注意:这次仅使用单个 SELECT 语句。\n答案:\n要求只用一条 select 语句,那就用 or 不用 union 了。\nSELECT prod_id, quantity FROM OrderItems WHERE quantity = 100 OR prod_id LIKE \u0026#39;BNBG%\u0026#39; 组合 Products 表中的产品名称和 Customers 表中的顾客名称 # Products 表含有字段 prod_name 代表产品名称\nprod_name flower rice ring umbrella Customers 表代表顾客信息,cust_name 代表顾客名称\ncust_name andy ben tony tom an lee hex 【问题】编写 SQL 语句,组合 Products 表中的产品名称(prod_name)和 Customers 表中的顾客名称(cust_name)并返回,然后按产品名称对结果进行升序排序。\n# UNION 结果集中的列名总是等于 UNION 中第一个 SELECT 语句中的列名。 SELECT prod_name FROM Products UNION SELECT cust_name FROM Customers ORDER BY prod_name 检查 SQL 语句 # 表 Customers 含有字段 cust_name 顾客名、cust_contact 顾客联系方式、cust_state 顾客州、cust_email 顾客 email\ncust_name cust_contact cust_state cust_email cust10 8695192 MI cust10@cust.com cust1 8695193 MI cust1@cust.com cust2 8695194 IL cust2@cust.com 【问题】修正下面错误的 SQL\nSELECT cust_name, cust_contact, cust_email FROM Customers WHERE cust_state = \u0026#39;MI\u0026#39; ORDER BY cust_name; UNION SELECT cust_name, cust_contact, cust_email FROM Customers WHERE cust_state = \u0026#39;IL\u0026#39;ORDER BY cust_name; 修正后:\nSELECT cust_name, cust_contact, cust_email FROM Customers WHERE cust_state = \u0026#39;MI\u0026#39; UNION SELECT cust_name, cust_contact, cust_email FROM Customers WHERE cust_state = \u0026#39;IL\u0026#39; ORDER BY cust_name; 使用 union 组合查询时,只能使用一条 order by 字句,他必须位于最后一条 select 语句之后\n或者直接用 or 来做:\nSELECT cust_name, cust_contact, cust_email FROM Customers WHERE cust_state = \u0026#39;MI\u0026#39; or cust_state = \u0026#39;IL\u0026#39; ORDER BY cust_name; "},{"id":578,"href":"/zh/docs/technology/Interview/database/sql/sql-questions-02/","title":"SQL常见面试题总结(2)","section":"SQL","content":" 题目来源于: 牛客题霸 - SQL 进阶挑战\n增删改操作 # SQL 插入记录的方式汇总:\n普通插入(全字段) :INSERT INTO table_name VALUES (value1, value2, ...) 普通插入(限定字段) :INSERT INTO table_name (column1, column2, ...) VALUES (value1, value2, ...) 多条一次性插入 :INSERT INTO table_name (column1, column2, ...) VALUES (value1_1, value1_2, ...), (value2_1, value2_2, ...), ... 从另一个表导入 :INSERT INTO table_name SELECT * FROM table_name2 [WHERE key=value] 带更新的插入 :REPLACE INTO table_name VALUES (value1, value2, ...)(注意这种原理是检测到主键或唯一性索引键重复就删除原记录后重新插入) 插入记录(一) # 描述:牛客后台会记录每个用户的试卷作答记录到 exam_record 表,现在有两个用户的作答记录详情如下:\n用户 1001 在 2021 年 9 月 1 日晚上 10 点 11 分 12 秒开始作答试卷 9001,并在 50 分钟后提交,得了 90 分; 用户 1002 在 2021 年 9 月 4 日上午 7 点 1 分 2 秒开始作答试卷 9002,并在 10 分钟后退出了平台。 试卷作答记录表exam_record中,表已建好,其结构如下,请用一条语句将这两条记录插入表中。\nFiled Type Null Key Extra Default Comment id int(11) NO PRI auto_increment (NULL) 自增 ID uid int(11) NO (NULL) 用户 ID exam_id int(11) NO (NULL) 试卷 ID start_time datetime NO (NULL) 开始时间 submit_time datetime YES (NULL) 提交时间 score tinyint(4) YES (NULL) 得分 答案:\n// 存在自增主键,无需手动赋值 INSERT INTO exam_record (uid, exam_id, start_time, submit_time, score) VALUES (1001, 9001, \u0026#39;2021-09-01 22:11:12\u0026#39;, \u0026#39;2021-09-01 23:01:12\u0026#39;, 90), (1002, 9002, \u0026#39;2021-09-04 07:01:02\u0026#39;, NULL, NULL); 插入记录(二) # 描述:现有一张试卷作答记录表exam_record,结构如下表,其中包含多年来的用户作答试卷记录,由于数据越来越多,维护难度越来越大,需要对数据表内容做精简,历史数据做备份。\n表exam_record:\nFiled Type Null Key Extra Default Comment id int(11) NO PRI auto_increment (NULL) 自增 ID uid int(11) NO (NULL) 用户 ID exam_id int(11) NO (NULL) 试卷 ID start_time datetime NO (NULL) 开始时间 submit_time datetime YES (NULL) 提交时间 score tinyint(4) YES (NULL) 得分 我们已经创建了一张新表exam_record_before_2021用来备份 2021 年之前的试题作答记录,结构和exam_record表一致,请将 2021 年之前的已完成了的试题作答纪录导入到该表。\n答案:\nINSERT INTO exam_record_before_2021 (uid, exam_id, start_time, submit_time, score) SELECT uid,exam_id,start_time,submit_time,score FROM exam_record WHERE YEAR(submit_time) \u0026lt; 2021; 插入记录(三) # 描述:现在有一套 ID 为 9003 的高难度 SQL 试卷,时长为一个半小时,请你将 2021-01-01 00:00:00 作为发布时间插入到试题信息表examination_info,不管该 ID 试卷是否存在,都要插入成功,请尝试插入它。\n试题信息表examination_info:\nFiled Type Null Key Extra Default Comment id int(11) NO PRI auto_increment (NULL) 自增 ID exam_id int(11) NO UNI (NULL) 试卷 ID tag varchar(32) YES (NULL) 类别标签 difficulty varchar(8) YES (NULL) 难度 duration int(11) NO (NULL) 时长(分钟数) release_time datetime YES (NULL) 发布时间 答案:\nREPLACE INTO examination_info VALUES (NULL, 9003, \u0026#34;SQL\u0026#34;, \u0026#34;hard\u0026#34;, 90, \u0026#34;2021-01-01 00:00:00\u0026#34;); 更新记录(一) # 描述:现在有一张试卷信息表 examination_info, 表结构如下图所示:\nFiled Type Null Key Extra Default Comment id int(11) NO PRI auto_increment (NULL) 自增 ID exam_id int(11) NO UNI (NULL) 试卷 ID tag char(32) YES (NULL) 类别标签 difficulty char(8) YES (NULL) 难度 duration int(11) NO (NULL) 时长 release_time datetime YES (NULL) 发布时间 请把examination_info表中tag为PYTHON的tag字段全部修改为Python。\n思路:这题有两种解题思路,最容易想到的是直接update + where来指定条件更新,第二种就是根据要修改的字段进行查找替换\n答案一:\nUPDATE examination_info SET tag = \u0026#39;Python\u0026#39; WHERE tag=\u0026#39;PYTHON\u0026#39; 答案二:\nUPDATE examination_info SET tag = REPLACE(tag,\u0026#39;PYTHON\u0026#39;,\u0026#39;Python\u0026#39;) # REPLACE (目标字段,\u0026#34;查找内容\u0026#34;,\u0026#34;替换内容\u0026#34;) 更新记录(二) # 描述:现有一张试卷作答记录表 exam_record,其中包含多年来的用户作答试卷记录,结构如下表:作答记录表 exam_record: submit_time 为 完成时间 (注意这句话)\nFiled Type Null Key Extra Default Comment id int(11) NO PRI auto_increment (NULL) 自增 ID uid int(11) NO (NULL) 用户 ID exam_id int(11) NO (NULL) 试卷 ID start_time datetime NO (NULL) 开始时间 submit_time datetime YES (NULL) 提交时间 score tinyint(4) YES (NULL) 得分 题目要求:请把 exam_record 表中 2021 年 9 月 1 日之前开始作答的未完成记录全部改为被动完成,即:将完成时间改为'2099-01-01 00:00:00\u0026rsquo;,分数改为 0。\n思路:注意题干中的关键字(已经高亮) \u0026quot; xxx 时间 \u0026quot;之前这个条件, 那么这里马上就要想到要进行时间的比较 可以直接 xxx_time \u0026lt; \u0026quot;2021-09-01 00:00:00\u0026quot;, 也可以采用date()函数来进行比较;第二个条件就是 \u0026quot;未完成\u0026quot;, 即完成时间为 NULL,也就是题目中的提交时间 \u0026mdash;\u0026ndash; submit_time 为 NULL。\n答案:\nUPDATE exam_record SET submit_time = \u0026#39;2099-01-01 00:00:00\u0026#39;, score = 0 WHERE DATE(start_time) \u0026lt; \u0026#34;2021-09-01\u0026#34; AND submit_time IS null 删除记录(一) # 描述:现有一张试卷作答记录表 exam_record,其中包含多年来的用户作答试卷记录,结构如下表:\n作答记录表exam_record: start_time 是试卷开始时间submit_time 是交卷,即结束时间。\nFiled Type Null Key Extra Default Comment id int(11) NO PRI auto_increment (NULL) 自增 ID uid int(11) NO (NULL) 用户 ID exam_id int(11) NO (NULL) 试卷 ID start_time datetime NO (NULL) 开始时间 submit_time datetime YES (NULL) 提交时间 score tinyint(4) YES (NULL) 得分 要求:请删除exam_record表中作答时间小于 5 分钟整且分数不及格(及格线为 60 分)的记录;\n思路:这一题虽然是练习删除,仔细看确是考察对时间函数的用法,这里提及的分钟数比较,常用的函数有 TIMEDIFF和TIMESTAMPDIFF ,两者用法稍有区别,后者更为灵活,这都是看个人习惯。\n1. TIMEDIFF:两个时间之间的差值\nTIMEDIFF(time1, time2) 两者参数都是必须的,都是一个时间或者日期时间表达式。如果指定的参数不合法或者是 NULL,那么函数将返回 NULL。\n对于这题而言,可以用在 minute 函数里面,因为 TIMEDIFF 计算出来的是时间的差值,在外面套一个 MINUTE 函数,计算出来的就是分钟数。\nTIMESTAMPDIFF:用于计算两个日期的时间差 TIMESTAMPDIFF(unit,datetime_expr1,datetime_expr2) # 参数说明 #unit: 日期比较返回的时间差单位,常用可选值如下: SECOND:秒 MINUTE:分钟 HOUR:小时 DAY:天 WEEK:星期 MONTH:月 QUARTER:季度 YEAR:年 # TIMESTAMPDIFF函数返回datetime_expr2 - datetime_expr1的结果(人话: 后面的 - 前面的 即2-1),其中datetime_expr1和datetime_expr2可以是DATE或DATETIME类型值(人话:可以是“2023-01-01”, 也可以是“2023-01-01- 00:00:00”) 这题需要进行分钟的比较,那么就是 TIMESTAMPDIFF(MINUTE, 开始时间, 结束时间) \u0026lt; 5\n答案:\nDELETE FROM exam_record WHERE MINUTE (TIMEDIFF(submit_time , start_time)) \u0026lt; 5 AND score \u0026lt; 60 DELETE FROM exam_record WHERE TIMESTAMPDIFF(MINUTE, start_time, submit_time) \u0026lt; 5 AND score \u0026lt; 60 删除记录(二) # 描述:现有一张试卷作答记录表exam_record,其中包含多年来的用户作答试卷记录,结构如下表:\n作答记录表exam_record:start_time 是试卷开始时间,submit_time 是交卷时间,即结束时间,如果未完成的话,则为空。\nFiled Type Null Key Extra Default Comment id int(11) NO PRI auto_increment (NULL) 自增 ID uid int(11) NO (NULL) 用户 ID exam_id int(11) NO (NULL) 试卷 ID start_time datetime NO (NULL) 开始时间 submit_time datetime YES (NULL) 提交时间 score tinyint(4) YES (NULL) 分数 要求:请删除exam_record表中未完成作答或作答时间小于 5 分钟整的记录中,开始作答时间最早的 3 条记录。\n思路:这题比较简单,但是要注意题干中给出的信息,结束时间,如果未完成的话,则为空,这个其实就是一个条件\n还有一个条件就是小于 5 分钟,跟上题类似,但是这里是或,即两个条件满足一个就行;另外就是稍微考察到了排序和 limit 的用法。\n答案:\nDELETE FROM exam_record WHERE submit_time IS null OR TIMESTAMPDIFF(MINUTE, start_time, submit_time) \u0026lt; 5 ORDER BY start_time LIMIT 3 # 默认就是asc, desc是降序排列 删除记录(三) # 描述:现有一张试卷作答记录表 exam_record,其中包含多年来的用户作答试卷记录,结构如下表:\nFiled Type Null Key Extra Default Comment id int(11) NO PRI auto_increment (NULL) 自增 ID uid int(11) NO (NULL) 用户 ID exam_id int(11) NO (NULL) 试卷 ID start_time datetime NO (NULL) 开始时间 submit_time datetime YES (NULL) 提交时间 score tinyint(4) YES (NULL) 分数 要求:请删除exam_record表中所有记录,并重置自增主键\n思路:这题考察对三种删除语句的区别,注意高亮部分,要求重置主键;\nDROP: 清空表,删除表结构,不可逆 TRUNCATE: 格式化表,不删除表结构,不可逆 DELETE:删除数据,可逆 这里选用TRUNCATE的原因是:TRUNCATE 只能作用于表;TRUNCATE会清空表中的所有行,但表结构及其约束、索引等保持不变;TRUNCATE会重置表的自增值;使用TRUNCATE后会使表和索引所占用的空间会恢复到初始大小。\n这题也可以采用DELETE来做,但是在删除后,还需要手动ALTER表结构来设置主键初始值;\n同理也可以采用DROP来做,直接删除整张表,包括表结构,然后再新建表即可。\n答案:\nTRUNCATE exam_record; 表与索引操作 # 创建一张新表 # 描述:现有一张用户信息表,其中包含多年来在平台注册过的用户信息,随着牛客平台的不断壮大,用户量飞速增长,为了高效地为高活跃用户提供服务,现需要将部分用户拆分出一张新表。\n原来的用户信息表:\nFiled Type Null Key Default Extra Comment id int(11) NO PRI (NULL) auto_increment 自增 ID uid int(11) NO UNI (NULL) 用户 ID nick_name varchar(64) YES (NULL) 昵称 achievement int(11) YES 0 成就值 level int(11) YES (NULL) 用户等级 job varchar(32) YES (NULL) 职业方向 register_time datetime YES CURRENT_TIMESTAMP 注册时间 作为数据分析师,请创建一张优质用户信息表 user_info_vip,表结构和用户信息表一致。\n你应该返回的输出如下表格所示,请写出建表语句将表格中所有限制和说明记录到表里。\nFiled Type Null Key Default Extra Comment id int(11) NO PRI (NULL) auto_increment 自增 ID uid int(11) NO UNI (NULL) 用户 ID nick_name varchar(64) YES (NULL) 昵称 achievement int(11) YES 0 成就值 level int(11) YES (NULL) 用户等级 job varchar(32) YES (NULL) 职业方向 register_time datetime YES CURRENT_TIMESTAMP 注册时间 思路:如果这题给出了旧表的名称,可直接create table 新表 as select * from 旧表; 但是这题并没有给出旧表名称,所以需要自己创建,注意默认值和键的创建即可,比较简单。(注意:如果是在牛客网上面执行,请注意 comment 中要和题目中的 comment 保持一致,包括大小写,否则不通过,还有字符也要设置)\n答案:\nCREATE TABLE IF NOT EXISTS user_info_vip( id INT(11) PRIMARY KEY AUTO_INCREMENT COMMENT\u0026#39;自增ID\u0026#39;, uid INT(11) UNIQUE NOT NULL COMMENT \u0026#39;用户ID\u0026#39;, nick_name VARCHAR(64) COMMENT\u0026#39;昵称\u0026#39;, achievement INT(11) DEFAULT 0 COMMENT \u0026#39;成就值\u0026#39;, `level` INT(11) COMMENT \u0026#39;用户等级\u0026#39;, job VARCHAR(32) COMMENT \u0026#39;职业方向\u0026#39;, register_time DATETIME DEFAULT CURRENT_TIMESTAMP COMMENT \u0026#39;注册时间\u0026#39; )CHARACTER SET UTF8 修改表 # 描述: 现有一张用户信息表user_info,其中包含多年来在平台注册过的用户信息。\n用户信息表 user_info:\nFiled Type Null Key Default Extra Comment id int(11) NO PRI (NULL) auto_increment 自增 ID uid int(11) NO UNI (NULL) 用户 ID nick_name varchar(64) YES (NULL) 昵称 achievement int(11) YES 0 成就值 level int(11) YES (NULL) 用户等级 job varchar(32) YES (NULL) 职业方向 register_time datetime YES CURRENT_TIMESTAMP 注册时间 ==要求:==请在用户信息表,字段 level 的后面增加一列最多可保存 15 个汉字的字段 school;并将表中 job 列名改为 profession,同时 varchar 字段长度变为 10;achievement 的默认值设置为 0。\n思路:首先做这题之前,需要了解 ALTER 语句的基本用法:\n添加一列:ALTER TABLE 表名 ADD COLUMN 列名 类型 【first | after 字段名】;(first : 在某列之前添加,after 反之) 修改列的类型或约束:ALTER TABLE 表名 MODIFY COLUMN 列名 新类型 【新约束】; 修改列名:ALTER TABLE 表名 change COLUMN 旧列名 新列名 类型; 删除列:ALTER TABLE 表名 drop COLUMN 列名; 修改表名:ALTER TABLE 表名 rename 【to】 新表名; 将某一列放到第一列:ALTER TABLE 表名 MODIFY COLUMN 列名 类型 first; COLUMN 关键字其实可以省略不写,这里基于规范还是罗列出来了。\n在修改时,如果有多个修改项,可以写到一起,但要注意格式\n答案:\nALTER TABLE user_info ADD school VARCHAR(15) AFTER level, CHANGE job profession VARCHAR(10), MODIFY achievement INT(11) DEFAULT 0; 删除表 # 描述:现有一张试卷作答记录表 exam_record,其中包含多年来的用户作答试卷记录。一般每年都会为 exam_record 表建立一张备份表 exam_record_{YEAR},{YEAR} 为对应年份。\n现在随着数据越来越多,存储告急,请你把很久前的(2011 到 2014 年)备份表都删掉(如果存在的话)。\n思路:这题很简单,直接删就行,如果嫌麻烦,可以将要删除的表用逗号隔开,写到一行;这里肯定会有小伙伴问:如果要删除很多张表呢?放心,如果要删除很多张表,可以写脚本来进行删除。\n答案:\nDROP TABLE IF EXISTS exam_record_2011; DROP TABLE IF EXISTS exam_record_2012; DROP TABLE IF EXISTS exam_record_2013; DROP TABLE IF EXISTS exam_record_2014; 创建索引 # 描述:现有一张试卷信息表 examination_info,其中包含各种类型试卷的信息。为了对表更方便快捷地查询,需要在 examination_info 表创建以下索引,\n规则如下:在 duration 列创建普通索引 idx_duration、在 exam_id 列创建唯一性索引 uniq_idx_exam_id、在 tag 列创建全文索引 full_idx_tag。\n根据题意,将返回如下结果:\nexamination_info 0 PRIMARY 1 id A 0 BTREE examination_info 0 uniq_idx_exam_id 1 exam_id A 0 YES BTREE examination_info 1 idx_duration 1 duration A 0 BTREE examination_info 1 full_idx_tag 1 tag 0 YES FULLTEXT 备注:后台会通过 SHOW INDEX FROM examination_info 语句来对比输出结果\n思路:做这题首先需要了解常见的索引类型:\nB-Tree 索引:B-Tree(或称为平衡树)索引是最常见和默认的索引类型。它适用于各种查询条件,可以快速定位到符合条件的数据。B-Tree 索引适用于普通的查找操作,支持等值查询、范围查询和排序。 唯一索引:唯一索引与普通的 B-Tree 索引类似,不同之处在于它要求被索引的列的值是唯一的。这意味着在插入或更新数据时,MySQL 会验证索引列的唯一性。 主键索引:主键索引是一种特殊的唯一索引,它用于唯一标识表中的每一行数据。每个表只能有一个主键索引,它可以帮助提高数据的访问速度和数据完整性。 全文索引:全文索引用于在文本数据中进行全文搜索。它支持在文本字段中进行关键字搜索,而不仅仅是简单的等值或范围查找。全文索引适用于需要进行全文搜索的应用场景。 -- 示例: -- 添加B-Tree索引: CREATE INDEX idx_name(索引名) ON 表名 (字段名); -- idx_name为索引名,以下都是 -- 创建唯一索引: CREATE UNIQUE INDEX idx_name ON 表名 (字段名); -- 创建一个主键索引: ALTER TABLE 表名 ADD PRIMARY KEY (字段名); -- 创建一个全文索引 ALTER TABLE 表名 ADD FULLTEXT INDEX idx_name (字段名); -- 通过以上示例,可以看出create 和 alter 都可以添加索引 有了以上的基础知识之后,该题答案也就浮出水面了。\n答案:\nALTER TABLE examination_info ADD INDEX idx_duration(duration), ADD UNIQUE INDEX uniq_idx_exam_id(exam_id), ADD FULLTEXT INDEX full_idx_tag(tag); 删除索引 # 描述:请删除examination_info表上的唯一索引 uniq_idx_exam_id 和全文索引 full_idx_tag。\n思路:该题考察删除索引的基本语法:\n-- 使用 DROP INDEX 删除索引 DROP INDEX idx_name ON 表名; -- 使用 ALTER TABLE 删除索引 ALTER TABLE employees DROP INDEX idx_email; 这里需要注意的是:在 MySQL 中,一次删除多个索引的操作是不支持的。每次删除索引时,只能指定一个索引名称进行删除。\n而且 DROP 命令需要慎用!!!\n答案:\nDROP INDEX uniq_idx_exam_id ON examination_info; DROP INDEX full_idx_tag ON examination_info; "},{"id":579,"href":"/zh/docs/technology/Interview/database/sql/sql-questions-03/","title":"SQL常见面试题总结(3)","section":"SQL","content":" 题目来源于: 牛客题霸 - SQL 进阶挑战\n较难或者困难的题目可以根据自身实际情况和面试需要来决定是否要跳过。\n聚合函数 # SQL 类别高难度试卷得分的截断平均值(较难) # 描述: 牛客的运营同学想要查看大家在 SQL 类别中高难度试卷的得分情况。\n请你帮她从exam_record数据表中计算所有用户完成 SQL 类别高难度试卷得分的截断平均值(去掉一个最大值和一个最小值后的平均值)。\n示例数据:examination_info(exam_id 试卷 ID, tag 试卷类别, difficulty 试卷难度, duration 考试时长, release_time 发布时间)\nid exam_id tag difficulty duration release_time 1 9001 SQL hard 60 2020-01-01 10:00:00 2 9002 算法 medium 80 2020-08-02 10:00:00 示例数据:exam_record(uid 用户 ID, exam_id 试卷 ID, start_time 开始作答时间, submit_time 交卷时间, score 得分)\nid uid exam_id start_time submit_time score 1 1001 9001 2020-01-02 09:01:01 2020-01-02 09:21:01 80 2 1001 9001 2021-05-02 10:01:01 2021-05-02 10:30:01 81 3 1001 9001 2021-06-02 19:01:01 2021-06-02 19:31:01 84 4 1001 9002 2021-09-05 19:01:01 2021-09-05 19:40:01 89 5 1001 9001 2021-09-02 12:01:01 (NULL) (NULL) 6 1001 9002 2021-09-01 12:01:01 (NULL) (NULL) 7 1002 9002 2021-02-02 19:01:01 2021-02-02 19:30:01 87 8 1002 9001 2021-05-05 18:01:01 2021-05-05 18:59:02 90 9 1003 9001 2021-09-07 12:01:01 2021-09-07 10:31:01 50 10 1004 9001 2021-09-06 10:01:01 (NULL) (NULL) 根据输入你的查询结果如下:\ntag difficulty clip_avg_score SQL hard 81.7 从examination_info表可知,试卷 9001 为高难度 SQL 试卷,该试卷被作答的得分有[80,81,84,90,50],去除最高分和最低分后为[80,81,84],平均分为 81.6666667,保留一位小数后为 81.7\n输入描述:\n输入数据中至少有 3 个有效分数\n思路一: 要找出高难度 sql 试卷,肯定需要联 examination_info 这张表,然后找出高难度的课程,由 examination_info 得知,高难度 sql 的 exam_id 为 9001,那么等下就以 exam_id = 9001 作为条件去查询;\n先找出 9001 号考试 select * from exam_record where exam_id = 9001\n然后,找出最高分 select max(score) 最高分 from exam_record where exam_id = 9001\n接着,找出最低分 select min(score) 最低分 from exam_record where exam_id = 9001\n在查询出来的分数结果集当中,去掉最高分和最低分,最直观能想到的就是 NOT IN 或者 用 NOT EXISTS 也行,这里以 NOT IN 来做\n首先将主体写出来select tag, difficulty, round(avg(score), 1) clip_avg_score from examination_info info INNER JOIN exam_record record\n小 tips : MYSQL 的 ROUND() 函数 ,ROUND(X)返回参数 X 最近似的整数 ROUND(X,D)返回 X ,其值保留到小数点后 D 位,第 D 位的保留方式为四舍五入。\n再将上面的 \u0026ldquo;碎片\u0026rdquo; 语句拼凑起来即可, 注意在 NOT IN 中两个子查询用 UNION ALL 来关联,用 union 把 max 和 min 的结果集中在一行当中,这样形成一列多行的效果。\n答案一:\nSELECT tag, difficulty, ROUND(AVG(score), 1) clip_avg_score FROM examination_info info INNER JOIN exam_record record WHERE info.exam_id = record.exam_id AND record.exam_id = 9001 AND record.score NOT IN( SELECT MAX(score) FROM exam_record WHERE exam_id = 9001 UNION ALL SELECT MIN(score) FROM exam_record WHERE exam_id = 9001 ) 这是最直观,也是最容易想到的解法,但是还有待改进,这算是投机取巧过关,其实严格按照题目要求应该这么写:\nSELECT tag, difficulty, ROUND(AVG(score), 1) clip_avg_score FROM examination_info info INNER JOIN exam_record record WHERE info.exam_id = record.exam_id AND record.exam_id = (SELECT examination_info.exam_id FROM examination_info WHERE tag = \u0026#39;SQL\u0026#39; AND difficulty = \u0026#39;hard\u0026#39; ) AND record.score NOT IN (SELECT MAX(score) FROM exam_record WHERE exam_id = (SELECT examination_info.exam_id FROM examination_info WHERE tag = \u0026#39;SQL\u0026#39; AND difficulty = \u0026#39;hard\u0026#39; ) UNION ALL SELECT MIN(score) FROM exam_record WHERE exam_id = (SELECT examination_info.exam_id FROM examination_info WHERE tag = \u0026#39;SQL\u0026#39; AND difficulty = \u0026#39;hard\u0026#39; ) ) 然而你会发现,重复的语句非常多,所以可以利用WITH来抽取公共部分\nWITH 子句介绍:\nWITH 子句,也称为公共表表达式(Common Table Expression,CTE),是在 SQL 查询中定义临时表的方式。它可以让我们在查询中创建一个临时命名的结果集,并且可以在同一查询中引用该结果集。\n基本用法:\nWITH cte_name (column1, column2, ..., columnN) AS ( -- 查询体 SELECT ... FROM ... WHERE ... ) -- 主查询 SELECT ... FROM cte_name WHERE ... WITH 子句由以下几个部分组成:\ncte_name: 给临时表起一个名称,可以在主查询中引用。 (column1, column2, ..., columnN): 可选,指定临时表的列名。 AS: 必需,表示开始定义临时表。 CTE 查询体: 实际的查询语句,用于定义临时表中的数据。 WITH 子句的主要用途之一是增强查询的可读性和可维护性,尤其在涉及多个嵌套子查询或需要重复使用相同的查询逻辑时。通过将这些逻辑放在一个命名的临时表中,我们可以更清晰地组织查询,并消除重复代码。\n此外,WITH 子句还可以在复杂的查询中实现递归查询。递归查询允许我们在单个查询中执行对同一表的多次迭代,逐步构建结果集。这在处理层次结构数据、组织结构和树状结构等场景中非常有用。\n小细节:MySQL 5.7 版本以及之前的版本不支持在 WITH 子句中直接使用别名。\n下面是改进后的答案:\nWITH t1 AS (SELECT record.*, info.tag, info.difficulty FROM exam_record record INNER JOIN examination_info info ON record.exam_id = info.exam_id WHERE info.tag = \u0026#34;SQL\u0026#34; AND info.difficulty = \u0026#34;hard\u0026#34; ) SELECT tag, difficulty, ROUND(AVG(score), 1) FROM t1 WHERE score NOT IN (SELECT max(score) FROM t1 UNION SELECT min(score) FROM t1) 思路二:\n筛选 SQL 高难度试卷:where tag=\u0026quot;SQL\u0026quot; and difficulty=\u0026quot;hard\u0026quot; 计算截断平均值:(和-最大值-最小值) / (总个数-2): (sum(score) - max(score) - min(score)) / (count(score) - 2) 有一个缺点就是,如果最大值和最小值有多个,这个方法就很难筛选出来, 但是题目中说了\u0026mdash;\u0026ndash;\u0026gt;去掉一个最大值和一个最小值后的平均值, 所以这里可以用这个公式。 答案二:\nSELECT info.tag, info.difficulty, ROUND((SUM(record.score)- MIN(record.score)- MAX(record.score)) / (COUNT(record.score)- 2), 1) AS clip_avg_score FROM examination_info info, exam_record record WHERE info.exam_id = record.exam_id AND info.tag = \u0026#34;SQL\u0026#34; AND info.difficulty = \u0026#34;hard\u0026#34;; 统计作答次数 # 有一个试卷作答记录表 exam_record,请从中统计出总作答次数 total_pv、试卷已完成作答数 complete_pv、已完成的试卷数 complete_exam_cnt。\n示例数据 exam_record 表(uid 用户 ID, exam_id 试卷 ID, start_time 开始作答时间, submit_time 交卷时间, score 得分):\nid uid exam_id start_time submit_time score 1 1001 9001 2020-01-02 09:01:01 2020-01-02 09:21:01 80 2 1001 9001 2021-05-02 10:01:01 2021-05-02 10:30:01 81 3 1001 9001 2021-06-02 19:01:01 2021-06-02 19:31:01 84 4 1001 9002 2021-09-05 19:01:01 2021-09-05 19:40:01 89 5 1001 9001 2021-09-02 12:01:01 (NULL) (NULL) 6 1001 9002 2021-09-01 12:01:01 (NULL) (NULL) 7 1002 9002 2021-02-02 19:01:01 2021-02-02 19:30:01 87 8 1002 9001 2021-05-05 18:01:01 2021-05-05 18:59:02 90 9 1003 9001 2021-09-07 12:01:01 2021-09-07 10:31:01 50 10 1004 9001 2021-09-06 10:01:01 (NULL) (NULL) 示例输出:\ntotal_pv complete_pv complete_exam_cnt 10 7 2 解释:表示截止当前,有 10 次试卷作答记录,已完成的作答次数为 7 次(中途退出的为未完成状态,其交卷时间和份数为 NULL),已完成的试卷有 9001 和 9002 两份。\n思路: 这题一看到统计次数,肯定第一时间就要想到用COUNT这个函数来解决,问题是要统计不同的记录,该怎么来写?使用子查询就能解决这个题目(这题用 case when 也能写出来,解法类似,逻辑不同而已);首先在做这个题之前,让我们先来了解一下COUNT的基本用法;\nCOUNT() 函数的基本语法如下所示:\nCOUNT(expression) 其中,expression 可以是列名、表达式、常量或通配符。下面是一些常见的用法示例:\n计算表中所有行的数量: SELECT COUNT(*) FROM table_name; 计算特定列非空(不为 NULL)值的数量: SELECT COUNT(column_name) FROM table_name; 计算满足条件的行数: SELECT COUNT(*) FROM table_name WHERE condition; 结合 GROUP BY 使用,计算分组后每个组的行数: SELECT column_name, COUNT(*) FROM table_name GROUP BY column_name; 计算不同列组合的唯一组合数: SELECT COUNT(DISTINCT column_name1, column_name2) FROM table_name; 在使用 COUNT() 函数时,如果不指定任何参数或者使用 COUNT(*),将会计算所有行的数量。而如果使用列名,则只会计算该列非空值的数量。\n另外,COUNT() 函数的结果是一个整数值。即使结果是零,也不会返回 NULL,这点需要谨记。\n答案:\nSELECT count(*) total_pv, ( SELECT count(*) FROM exam_record WHERE submit_time IS NOT NULL ) complete_pv, ( SELECT COUNT( DISTINCT exam_id, score IS NOT NULL OR NULL ) FROM exam_record ) complete_exam_cnt FROM exam_record 这里着重说一下COUNT( DISTINCT exam_id, score IS NOT NULL OR NULL )这一句,判断 score 是否为 null ,如果是即为真,如果不是返回 null;注意这里如果不加 or null 在不是 null 的情况下只会返回 false 也就是返回 0;\nCOUNT本身是不可以对多列求行数的,distinct的加入是的多列成为一个整体,可以求出现的行数了;count distinct在计算时只返回非 null 的行, 这个也要注意;\n另外通过本题 get 到了\u0026mdash;\u0026mdash;\u0026gt;count 加条件常用句式count( 列判断 or null)\n得分不小于平均分的最低分 # 描述: 请从试卷作答记录表中找到 SQL 试卷得分不小于该类试卷平均得分的用户最低得分。\n示例数据 exam_record 表(uid 用户 ID, exam_id 试卷 ID, start_time 开始作答时间, submit_time 交卷时间, score 得分):\nid uid exam_id start_time submit_time score 1 1001 9001 2020-01-02 09:01:01 2020-01-02 09:21:01 80 2 1002 9001 2021-09-05 19:01:01 2021-09-05 19:40:01 89 3 1002 9002 2021-09-02 12:01:01 (NULL) (NULL) 4 1002 9003 2021-09-01 12:01:01 (NULL) (NULL) 5 1002 9001 2021-02-02 19:01:01 2021-02-02 19:30:01 87 6 1002 9002 2021-05-05 18:01:01 2021-05-05 18:59:02 90 7 1003 9002 2021-02-06 12:01:01 (NULL) (NULL) 8 1003 9003 2021-09-07 10:01:01 2021-09-07 10:31:01 86 9 1004 9003 2021-09-06 12:01:01 (NULL) (NULL) examination_info 表(exam_id 试卷 ID, tag 试卷类别, difficulty 试卷难度, duration 考试时长, release_time 发布时间)\nid exam_id tag difficulty duration release_time 1 9001 SQL hard 60 2020-01-01 10:00:00 2 9002 SQL easy 60 2020-02-01 10:00:00 3 9003 算法 medium 80 2020-08-02 10:00:00 示例输出数据:\nmin_score_over_avg 87 解释:试卷 9001 和 9002 为 SQL 类别,作答这两份试卷的得分有[80,89,87,90],平均分为 86.5,不小于平均分的最小分数为 87\n思路:这类题目第一眼看确实很复杂, 因为不知道从哪入手,但是当我们仔细读题审题后,要学会抓住题干中的关键信息。以本题为例:请从试卷作答记录表中找到SQL试卷得分不小于该类试卷平均得分的用户最低得分。你能一眼从中提取哪些有效信息来作为解题思路?\n第一条:找到SQL试卷得分\n第二条:该类试卷平均得分\n第三条:该类试卷的用户最低得分\n然后中间的 “桥梁” 就是不小于\n将条件拆分后,先逐步完成\n-- 找出tag为‘SQL’的得分 【80, 89,87,90】 -- 再算出这一组的平均得分 select ROUND(AVG(score), 1) from examination_info info INNER JOIN exam_record record where info.exam_id = record.exam_id and tag= \u0026#39;SQL\u0026#39; 然后再找出该类试卷的最低得分,接着将结果集【80, 89,87,90】 去和平均分数作比较,方可得出最终答案。\n答案:\nSELECT MIN(score) AS min_score_over_avg FROM examination_info info INNER JOIN exam_record record WHERE info.exam_id = record.exam_id AND tag= \u0026#39;SQL\u0026#39; AND score \u0026gt;= (SELECT ROUND(AVG(score), 1) FROM examination_info info INNER JOIN exam_record record WHERE info.exam_id = record.exam_id AND tag= \u0026#39;SQL\u0026#39; ) 其实这类题目给出的要求看似很 “绕”,但其实仔细梳理一遍,将大条件拆分成小条件,逐个拆分完以后,最后将所有条件拼凑起来。反正只要记住:抓主干,理分支,问题便迎刃而解。\n分组查询 # 平均活跃天数和月活人数 # 描述:用户在牛客试卷作答区作答记录存储在表 exam_record 中,内容如下:\nexam_record 表(uid 用户 ID, exam_id 试卷 ID, start_time 开始作答时间, submit_time 交卷时间, score 得分)\nid uid exam_id start_time submit_time score 1 1001 9001 2021-07-02 09:01:01 2021-07-02 09:21:01 80 2 1002 9001 2021-09-05 19:01:01 2021-09-05 19:40:01 81 3 1002 9002 2021-09-02 12:01:01 (NULL) (NULL) 4 1002 9003 2021-09-01 12:01:01 (NULL) (NULL) 5 1002 9001 2021-07-02 19:01:01 2021-07-02 19:30:01 82 6 1002 9002 2021-07-05 18:01:01 2021-07-05 18:59:02 90 7 1003 9002 2021-07-06 12:01:01 (NULL) (NULL) 8 1003 9003 2021-09-07 10:01:01 2021-09-07 10:31:01 86 9 1004 9003 2021-09-06 12:01:01 (NULL) (NULL) 10 1002 9003 2021-09-01 12:01:01 2021-09-01 12:31:01 81 11 1005 9001 2021-09-01 12:01:01 2021-09-01 12:31:01 88 12 1006 9002 2021-09-02 12:11:01 2021-09-02 12:31:01 89 13 1007 9002 2020-09-02 12:11:01 2020-09-02 12:31:01 89 请计算 2021 年每个月里试卷作答区用户平均月活跃天数 avg_active_days 和月度活跃人数 mau,上面数据的示例输出如下:\nmonth avg_active_days mau 202107 1.50 2 202109 1.25 4 解释:2021 年 7 月有 2 人活跃,共活跃了 3 天(1001 活跃 1 天,1002 活跃 2 天),平均活跃天数 1.5;2021 年 9 月有 4 人活跃,共活跃了 5 天,平均活跃天数 1.25,结果保留 2 位小数。\n注:此处活跃指有交卷行为。\n思路:读完题先注意高亮部分;一般求天数和月活跃人数马上就要想到相关的日期函数;这一题我们同样来进行拆分,把问题细化再解决;首先求活跃人数,肯定要用到COUNT(),那这里首先就有一个坑,不知道大家注意了没有?用户 1002 在 9 月份做了两种不同的试卷,所以这里要注意去重,不然在统计的时候,活跃人数是错的;第二个就是要知道日期的格式化,如上表,题目要求以202107这种日期格式展现,要用到DATE_FORMAT来进行格式化。\n基本用法:\nDATE_FORMAT(date_value, format)\ndate_value 参数是待格式化的日期或时间值。 format 参数是指定的日期或时间格式(这个和 Java 里面的日期格式一样)。 答案:\nSELECT DATE_FORMAT(submit_time, \u0026#39;%Y%m\u0026#39;) MONTH, round(count(DISTINCT UID, DATE_FORMAT(submit_time, \u0026#39;%Y%m%d\u0026#39;)) / count(DISTINCT UID), 2) avg_active_days, COUNT(DISTINCT UID) mau FROM exam_record WHERE YEAR (submit_time) = 2021 GROUP BY MONTH 这里多说一句, 使用COUNT(DISTINCT uid, DATE_FORMAT(submit_time, '%Y%m%d')) 可以统计在 uid 列和 submit_time 列按照年份、月份和日期进行格式化后的组合值的数量。\n月总刷题数和日均刷题数 # 描述:现有一张题目练习记录表 practice_record,示例内容如下:\nid uid question_id submit_time score 1 1001 8001 2021-08-02 11:41:01 60 2 1002 8001 2021-09-02 19:30:01 50 3 1002 8001 2021-09-02 19:20:01 70 4 1002 8002 2021-09-02 19:38:01 70 5 1003 8002 2021-08-01 19:38:01 80 请从中统计出 2021 年每个月里用户的月总刷题数 month_q_cnt 和日均刷题数 avg_day_q_cnt(按月份升序排序)以及该年的总体情况,示例数据输出如下:\nsubmit_month month_q_cnt avg_day_q_cnt 202108 2 0.065 202109 3 0.100 2021 汇总 5 0.161 解释:2021 年 8 月共有 2 次刷题记录,日均刷题数为 2/31=0.065(保留 3 位小数);2021 年 9 月共有 3 次刷题记录,日均刷题数为 3/30=0.100;2021 年共有 5 次刷题记录(年度汇总平均无实际意义,这里我们按照 31 天来算 5/31=0.161)\n牛客已经采用最新的 Mysql 版本,如果您运行结果出现错误:ONLY_FULL_GROUP_BY,意思是:对于 GROUP BY 聚合操作,如果在 SELECT 中的列,没有在 GROUP BY 中出现,那么这个 SQL 是不合法的,因为列不在 GROUP BY 从句中,也就是说查出来的列必须在 group by 后面出现否则就会报错,或者这个字段出现在聚合函数里面。\n思路:\n看到实例数据就要马上联想到相关的函数,比如submit_month就要用到DATE_FORMAT来格式化日期。然后查出每月的刷题数量。\n每月的刷题数量\nSELECT MONTH ( submit_time ), COUNT( question_id ) FROM practice_record GROUP BY MONTH (submit_time) 接着第三列这里要用到DAY(LAST_DAY(date_value))函数来查找给定日期的月份中的天数。\n示例代码如下:\nSELECT DAY(LAST_DAY(\u0026#39;2023-07-08\u0026#39;)) AS days_in_month; -- 输出:31 SELECT DAY(LAST_DAY(\u0026#39;2023-02-01\u0026#39;)) AS days_in_month; -- 输出:28 (闰年中的二月份) SELECT DAY(LAST_DAY(NOW())) AS days_in_current_month; -- 输出:31 (当前月份的天数) 使用 LAST_DAY() 函数获取给定日期的当月最后一天,然后使用 DAY() 函数提取该日期的天数。这样就能获得指定月份的天数。\n需要注意的是,LAST_DAY() 函数返回的是日期值,而 DAY() 函数用于提取日期值中的天数部分。\n有了上述的分析之后,即可马上写出答案,这题复杂就复杂在处理日期上,其中的逻辑并不难。\n答案:\nSELECT DATE_FORMAT(submit_time, \u0026#39;%Y%m\u0026#39;) submit_month, count(question_id) month_q_cnt, ROUND(COUNT(question_id) / DAY (LAST_DAY(submit_time)), 3) avg_day_q_cnt FROM practice_record WHERE DATE_FORMAT(submit_time, \u0026#39;%Y\u0026#39;) = \u0026#39;2021\u0026#39; GROUP BY submit_month UNION ALL SELECT \u0026#39;2021汇总\u0026#39; AS submit_month, count(question_id) month_q_cnt, ROUND(COUNT(question_id) / 31, 3) avg_day_q_cnt FROM practice_record WHERE DATE_FORMAT(submit_time, \u0026#39;%Y\u0026#39;) = \u0026#39;2021\u0026#39; ORDER BY submit_month 在实例数据输出中因为最后一行需要得出汇总数据,所以这里要 UNION ALL加到结果集中;别忘了最后要排序!\n未完成试卷数大于 1 的有效用户(较难) # 描述:现有试卷作答记录表 exam_record(uid 用户 ID, exam_id 试卷 ID, start_time 开始作答时间, submit_time 交卷时间, score 得分),示例数据如下:\nid uid exam_id start_time submit_time score 1 1001 9001 2021-07-02 09:01:01 2021-07-02 09:21:01 80 2 1002 9001 2021-09-05 19:01:01 2021-09-05 19:40:01 81 3 1002 9002 2021-09-02 12:01:01 (NULL) (NULL) 4 1002 9003 2021-09-01 12:01:01 (NULL) (NULL) 5 1002 9001 2021-07-02 19:01:01 2021-07-02 19:30:01 82 6 1002 9002 2021-07-05 18:01:01 2021-07-05 18:59:02 90 7 1003 9002 2021-07-06 12:01:01 (NULL) (NULL) 8 1003 9003 2021-09-07 10:01:01 2021-09-07 10:31:01 86 9 1004 9003 2021-09-06 12:01:01 (NULL) (NULL) 10 1002 9003 2021-09-01 12:01:01 2021-09-01 12:31:01 81 11 1005 9001 2021-09-01 12:01:01 2021-09-01 12:31:01 88 12 1006 9002 2021-09-02 12:11:01 2021-09-02 12:31:01 89 13 1007 9002 2020-09-02 12:11:01 2020-09-02 12:31:01 89 还有一张试卷信息表 examination_info(exam_id 试卷 ID, tag 试卷类别, difficulty 试卷难度, duration 考试时长, release_time 发布时间),示例数据如下:\nid exam_id tag difficulty duration release_time 1 9001 SQL hard 60 2020-01-01 10:00:00 2 9002 SQL easy 60 2020-02-01 10:00:00 3 9003 算法 medium 80 2020-08-02 10:00:00 请统计 2021 年每个未完成试卷作答数大于 1 的有效用户的数据(有效用户指完成试卷作答数至少为 1 且未完成数小于 5),输出用户 ID、未完成试卷作答数、完成试卷作答数、作答过的试卷 tag 集合,按未完成试卷数量由多到少排序。示例数据的输出结果如下:\nuid incomplete_cnt complete_cnt detail 1002 2 4 2021-09-01:算法;2021-07-02:SQL;2021-09-02:SQL;2021-09-05:SQL;2021-07-05:SQL 解释:2021 年的作答记录中,除了 1004,其他用户均满足有效用户定义,但只有 1002 未完成试卷数大于 1,因此只输出 1002,detail 中是 1002 作答过的试卷{日期:tag}集合,日期和 tag 间用 : 连接,多元素间用 ; 连接。\n思路:\n仔细读题后,分析出:首先要联表,因为后面要输出tag;\n筛选出 2021 年的数据\nSELECT * FROM exam_record er LEFT JOIN examination_info ei ON er.exam_id = ei.exam_id WHERE YEAR (er.start_time)= 2021 根据 uid 进行分组,然后对每个用户进行条件进行判断,题目中要求完成试卷数至少为1,未完成试卷数要大于1,小于5\n那么等会儿写 sql 的时候条件应该是:未完成 \u0026gt; 1 and 已完成 \u0026gt;=1 and 未完成 \u0026lt; 5\n因为最后要用到字符串的拼接,而且还要组合拼接,这个可以用GROUP_CONCAT函数,下面简单介绍一下该函数的用法:\n基本格式:\nGROUP_CONCAT([DISTINCT] expr [ORDER BY {unsigned_integer | col_name | expr} [ASC | DESC] [, ...]] [SEPARATOR sep]) expr:要连接的列或表达式。 DISTINCT:可选参数,用于去重。当指定了 DISTINCT,相同的值只会出现一次。 ORDER BY:可选参数,用于排序连接后的值。可以选择升序 (ASC) 或降序 (DESC) 排序。 SEPARATOR sep:可选参数,用于设置连接后的值的分隔符。(本题要用这个参数设置 ; 号 ) GROUP_CONCAT() 函数常用于 GROUP BY 子句中,将一组行的值连接为一个字符串,并在结果集中以聚合的形式返回。\n答案:\nSELECT a.uid, SUM(CASE WHEN a.submit_time IS NULL THEN 1 END) AS incomplete_cnt, SUM(CASE WHEN a.submit_time IS NOT NULL THEN 1 END) AS complete_cnt, GROUP_CONCAT(DISTINCT CONCAT(DATE_FORMAT(a.start_time, \u0026#39;%Y-%m-%d\u0026#39;), \u0026#39;:\u0026#39;, b.tag) ORDER BY start_time SEPARATOR \u0026#34;;\u0026#34;) AS detail FROM exam_record a LEFT JOIN examination_info b ON a.exam_id = b.exam_id WHERE YEAR (a.start_time)= 2021 GROUP BY a.uid HAVING incomplete_cnt \u0026gt; 1 AND complete_cnt \u0026gt;= 1 AND incomplete_cnt \u0026lt; 5 ORDER BY incomplete_cnt DESC SUM(CASE WHEN a.submit_time IS NULL THEN 1 END) 统计了每个用户未完成的记录数量。 SUM(CASE WHEN a.submit_time IS NOT NULL THEN 1 END) 统计了每个用户已完成的记录数量。 GROUP_CONCAT(DISTINCT CONCAT(DATE_FORMAT(a.start_time, '%Y-%m-%d'), ':', b.tag) ORDER BY a.start_time SEPARATOR ';') 将每个用户的考试日期和标签以逗号分隔的形式连接成一个字符串,并按考试开始时间进行排序。 嵌套子查询 # 月均完成试卷数不小于 3 的用户爱作答的类别(较难) # 描述:现有试卷作答记录表 exam_record(uid:用户 ID, exam_id:试卷 ID, start_time:开始作答时间, submit_time:交卷时间,没提交的话为 NULL, score:得分),示例数据如下:\nid uid exam_id start_time submit_time score 1 1001 9001 2021-07-02 09:01:01 (NULL) (NULL) 2 1002 9003 2021-09-01 12:01:01 2021-09-01 12:21:01 60 3 1002 9002 2021-09-02 12:01:01 2021-09-02 12:31:01 70 4 1002 9001 2021-09-05 19:01:01 2021-09-05 19:40:01 81 5 1002 9002 2021-07-06 12:01:01 (NULL) (NULL) 6 1003 9003 2021-09-07 10:01:01 2021-09-07 10:31:01 86 7 1003 9003 2021-09-08 12:01:01 2021-09-08 12:11:01 40 8 1003 9001 2021-09-08 13:01:01 (NULL) (NULL) 9 1003 9002 2021-09-08 14:01:01 (NULL) (NULL) 10 1003 9003 2021-09-08 15:01:01 (NULL) (NULL) 11 1005 9001 2021-09-01 12:01:01 2021-09-01 12:31:01 88 12 1005 9002 2021-09-01 12:01:01 2021-09-01 12:31:01 88 13 1005 9002 2021-09-02 12:11:01 2021-09-02 12:31:01 89 试卷信息表 examination_info(exam_id:试卷 ID, tag:试卷类别, difficulty:试卷难度, duration:考试时长, release_time:发布时间),示例数据如下:\nid exam_id tag difficulty duration release_time 1 9001 SQL hard 60 2020-01-01 10:00:00 2 9002 C++ easy 60 2020-02-01 10:00:00 3 9003 算法 medium 80 2020-08-02 10:00:00 请从表中统计出 “当月均完成试卷数”不小于 3 的用户们爱作答的类别及作答次数,按次数降序输出,示例输出如下:\ntag tag_cnt C++ 4 SQL 2 算法 1 解释:用户 1002 和 1005 在 2021 年 09 月的完成试卷数目均为 3,其他用户均小于 3;然后用户 1002 和 1005 作答过的试卷 tag 分布结果按作答次数降序排序依次为 C++、SQL、算法。\n思路:这题考察联合子查询,重点在于月均回答\u0026gt;=3, 但是个人认为这里没有表述清楚,应该直接说查 9 月的就容易理解多了;这里不是每个月都要\u0026gt;=3 或者是所有答题次数/答题月份。不要理解错误了。\n先查询出哪些用户月均答题大于三次\nSELECT UID FROM exam_record record GROUP BY UID, MONTH (start_time) HAVING count(submit_time) \u0026gt;= 3 有了这一步之后再进行深入,只要能理解上一步(我的意思是不被题目中的月均所困扰),然后再套一个子查询,查哪些用户包含其中,然后查出题目中所需的列即可。记得排序!!\nSELECT tag, count(start_time) AS tag_cnt FROM exam_record record INNER JOIN examination_info info ON record.exam_id = info.exam_id WHERE UID IN (SELECT UID FROM exam_record record GROUP BY UID, MONTH (start_time) HAVING count(submit_time) \u0026gt;= 3) GROUP BY tag ORDER BY tag_cnt DESC 试卷发布当天作答人数和平均分 # 描述:现有用户信息表 user_info(uid 用户 ID,nick_name 昵称, achievement 成就值, level 等级, job 职业方向, register_time 注册时间),示例数据如下:\nid uid nick_name achievement level job register_time 1 1001 牛客 1 号 3100 7 算法 2020-01-01 10:00:00 2 1002 牛客 2 号 2100 6 算法 2020-01-01 10:00:00 3 1003 牛客 3 号 1500 5 算法 2020-01-01 10:00:00 4 1004 牛客 4 号 1100 4 算法 2020-01-01 10:00:00 5 1005 牛客 5 号 1600 6 C++ 2020-01-01 10:00:00 6 1006 牛客 6 号 3000 6 C++ 2020-01-01 10:00:00 释义:用户 1001 昵称为牛客 1 号,成就值为 3100,用户等级是 7 级,职业方向为算法,注册时间 2020-01-01 10:00:00\n试卷信息表 examination_info(exam_id 试卷 ID, tag 试卷类别, difficulty 试卷难度, duration 考试时长, release_time 发布时间) 示例数据如下:\nid exam_id tag difficulty duration release_time 1 9001 SQL hard 60 2021-09-01 06:00:00 2 9002 C++ easy 60 2020-02-01 10:00:00 3 9003 算法 medium 80 2020-08-02 10:00:00 试卷作答记录表 exam_record(uid 用户 ID, exam_id 试卷 ID, start_time 开始作答时间, submit_time 交卷时间, score 得分) 示例数据如下:\nid uid exam_id start_time submit_time score 1 1001 9001 2021-07-02 09:01:01 2021-09-01 09:41:01 70 2 1002 9003 2021-09-01 12:01:01 2021-09-01 12:21:01 60 3 1002 9002 2021-09-02 12:01:01 2021-09-02 12:31:01 70 4 1002 9001 2021-09-01 19:01:01 2021-09-01 19:40:01 80 5 1002 9003 2021-08-01 12:01:01 2021-08-01 12:21:01 60 6 1002 9002 2021-08-02 12:01:01 2021-08-02 12:31:01 70 7 1002 9001 2021-09-01 19:01:01 2021-09-01 19:40:01 85 8 1002 9002 2021-07-06 12:01:01 (NULL) (NULL) 9 1003 9002 2021-09-07 10:01:01 2021-09-07 10:31:01 86 10 1003 9003 2021-09-08 12:01:01 2021-09-08 12:11:01 40 11 1003 9003 2021-09-01 13:01:01 2021-09-01 13:41:01 70 12 1003 9001 2021-09-08 14:01:01 (NULL) (NULL) 13 1003 9002 2021-09-08 15:01:01 (NULL) (NULL) 14 1005 9001 2021-09-01 12:01:01 2021-09-01 12:31:01 90 15 1005 9002 2021-09-01 12:01:01 2021-09-01 12:31:01 88 16 1005 9002 2021-09-02 12:11:01 2021-09-02 12:31:01 89 请计算每张 SQL 类别试卷发布后,当天 5 级以上的用户作答的人数 uv 和平均分 avg_score,按人数降序,相同人数的按平均分升序,示例数据结果输出如下:\nexam_id uv avg_score 9001 3 81.3 解释:只有一张 SQL 类别的试卷,试卷 ID 为 9001,发布当天(2021-09-01)有 1001、1002、1003、1005 作答过,但是 1003 是 5 级用户,其他 3 位为 5 级以上,他们三的得分有[70,80,85,90],平均分为 81.3(保留 1 位小数)。\n思路:这题看似很复杂,但是先逐步将“外边”条件拆分,然后合拢到一起,答案就出来,多表查询反正记住:由外向里,抽丝剥茧。\n先把三种表连起来,同时给定一些条件,比如题目中要求等级\u0026gt; 5的用户,那么可以先查出来\nSELECT DISTINCT u_info.uid FROM examination_info e_info INNER JOIN exam_record record INNER JOIN user_info u_info WHERE e_info.exam_id = record.exam_id AND u_info.uid = record.uid AND u_info.LEVEL \u0026gt; 5 接着注意题目中要求:每张sql类别试卷发布后,当天作答用户,注意其中的当天,那我们马上就要想到要用到时间的比较。\n对试卷发布日期和开始考试日期进行比较:DATE(e_info.release_time) = DATE(record.start_time);不用担心submit_time 为 null 的问题,后续在 where 中会给过滤掉。\n答案:\nSELECT record.exam_id AS exam_id, COUNT(DISTINCT u_info.uid) AS uv, ROUND(SUM(record.score) / COUNT(u_info.uid), 1) AS avg_score FROM examination_info e_info INNER JOIN exam_record record INNER JOIN user_info u_info WHERE e_info.exam_id = record.exam_id AND u_info.uid = record.uid AND DATE (e_info.release_time) = DATE (record.start_time) AND submit_time IS NOT NULL AND tag = \u0026#39;SQL\u0026#39; AND u_info.LEVEL \u0026gt; 5 GROUP BY record.exam_id ORDER BY uv DESC, avg_score ASC 注意最后的分组排序!先按人数排,若一致,按平均分排。\n作答试卷得分大于过 80 的人的用户等级分布 # 描述:\n现有用户信息表 user_info(uid 用户 ID,nick_name 昵称, achievement 成就值, level 等级, job 职业方向, register_time 注册时间):\nid uid nick_name achievement level job register_time 1 1001 牛客 1 号 3100 7 算法 2020-01-01 10:00:00 2 1002 牛客 2 号 2100 6 算法 2020-01-01 10:00:00 3 1003 牛客 3 号 1500 5 算法 2020-01-01 10:00:00 4 1004 牛客 4 号 1100 4 算法 2020-01-01 10:00:00 5 1005 牛客 5 号 1600 6 C++ 2020-01-01 10:00:00 6 1006 牛客 6 号 3000 6 C++ 2020-01-01 10:00:00 试卷信息表 examination_info(exam_id 试卷 ID, tag 试卷类别, difficulty 试卷难度, duration 考试时长, release_time 发布时间):\nid exam_id tag difficulty duration release_time 1 9001 SQL hard 60 2021-09-01 06:00:00 2 9002 C++ easy 60 2021-09-01 06:00:00 3 9003 算法 medium 80 2021-09-01 10:00:00 试卷作答信息表 exam_record(uid 用户 ID, exam_id 试卷 ID, start_time 开始作答时间, submit_time 交卷时间, score 得分):\nid uid exam_id start_time submit_time score 1 1001 9001 2021-09-01 09:01:01 2021-09-01 09:41:01 79 2 1002 9003 2021-09-01 12:01:01 2021-09-01 12:21:01 60 3 1002 9002 2021-09-01 12:01:01 2021-09-01 12:31:01 70 4 1002 9001 2021-09-01 19:01:01 2021-09-01 19:40:01 80 5 1002 9003 2021-08-01 12:01:01 2021-08-01 12:21:01 60 6 1002 9002 2021-09-01 12:01:01 2021-09-01 12:31:01 70 7 1002 9001 2021-09-01 19:01:01 2021-09-01 19:40:01 85 8 1002 9002 2021-09-01 12:01:01 (NULL) (NULL) 9 1003 9002 2021-09-07 10:01:01 2021-09-07 10:31:01 86 10 1003 9003 2021-09-08 12:01:01 2021-09-08 12:11:01 40 11 1003 9003 2021-09-01 13:01:01 2021-09-01 13:41:01 81 12 1003 9001 2021-09-01 14:01:01 (NULL) (NULL) 13 1003 9002 2021-09-08 15:01:01 (NULL) (NULL) 14 1005 9001 2021-09-01 12:01:01 2021-09-01 12:31:01 90 15 1005 9002 2021-09-01 12:01:01 2021-09-01 12:31:01 88 16 1005 9002 2021-09-02 12:11:01 2021-09-02 12:31:01 89 统计作答 SQL 类别的试卷得分大于过 80 的人的用户等级分布,按数量降序排序(保证数量都不同)。示例数据结果输出如下:\nlevel level_cnt 6 2 5 1 解释:9001 为 SQL 类试卷,作答该试卷大于 80 分的人有 1002、1003、1005 共 3 人,6 级两人,5 级一人。\n==思路:==这题和上一题都是一样的数据,只是查询条件改变了而已,上一题理解了,这题分分钟做出来。\n答案:\nSELECT u_info.LEVEL AS LEVEL, count(u_info.uid) AS level_cnt FROM examination_info e_info INNER JOIN exam_record record INNER JOIN user_info u_info WHERE e_info.exam_id = record.exam_id AND u_info.uid = record.uid AND record.score \u0026gt; 80 AND submit_time IS NOT NULL AND tag = \u0026#39;SQL\u0026#39; GROUP BY LEVEL ORDER BY level_cnt DESC 合并查询 # 每个题目和每份试卷被作答的人数和次数 # 描述:\n现有试卷作答记录表 exam_record(uid 用户 ID, exam_id 试卷 ID, start_time 开始作答时间, submit_time 交卷时间, score 得分):\nid uid exam_id start_time submit_time score 1 1001 9001 2021-09-01 09:01:01 2021-09-01 09:41:01 81 2 1002 9002 2021-09-01 12:01:01 2021-09-01 12:31:01 70 3 1002 9001 2021-09-01 19:01:01 2021-09-01 19:40:01 80 4 1002 9002 2021-09-01 12:01:01 2021-09-01 12:31:01 70 5 1004 9001 2021-09-01 19:01:01 2021-09-01 19:40:01 85 6 1002 9002 2021-09-01 12:01:01 (NULL) (NULL) 题目练习表 practice_record(uid 用户 ID, question_id 题目 ID, submit_time 提交时间, score 得分):\nid uid question_id submit_time score 1 1001 8001 2021-08-02 11:41:01 60 2 1002 8001 2021-09-02 19:30:01 50 3 1002 8001 2021-09-02 19:20:01 70 4 1002 8002 2021-09-02 19:38:01 70 5 1003 8001 2021-08-02 19:38:01 70 6 1003 8001 2021-08-02 19:48:01 90 7 1003 8002 2021-08-01 19:38:01 80 请统计每个题目和每份试卷被作答的人数和次数,分别按照\u0026quot;试卷\u0026quot;和\u0026quot;题目\u0026quot;的 uv \u0026amp; pv 降序显示,示例数据结果输出如下:\ntid uv pv 9001 3 3 9002 1 3 8001 3 5 8002 2 2 解释:“试卷”有 3 人共练习 3 次试卷 9001,1 人作答 3 次 9002;“刷题”有 3 人刷 5 次 8001,有 2 人刷 2 次 8002\n思路:这题的难点和易错点在于UNION和ORDER BY 同时使用的问题\n有以下几种情况:使用union和多个order by不加括号,报错!\norder by在union连接的子句中不起作用;\n比如不加括号:\nSELECT exam_id AS tid, COUNT(DISTINCT UID) AS uv, COUNT(UID) AS pv FROM exam_record GROUP BY exam_id ORDER BY uv DESC, pv DESC UNION SELECT question_id AS tid, COUNT(DISTINCT UID) AS uv, COUNT(UID) AS pv FROM practice_record GROUP BY question_id ORDER BY uv DESC, pv DESC 直接报语法错误,如果没有括号,只能有一个order by\n还有一种order by不起作用的情况,但是能在子句的子句中起作用,这里的解决方案就是在外面再套一层查询。\n答案:\nSELECT * FROM (SELECT exam_id AS tid, COUNT(DISTINCT exam_record.uid) uv, COUNT(*) pv FROM exam_record GROUP BY exam_id ORDER BY uv DESC, pv DESC) t1 UNION SELECT * FROM (SELECT question_id AS tid, COUNT(DISTINCT practice_record.uid) uv, COUNT(*) pv FROM practice_record GROUP BY question_id ORDER BY uv DESC, pv DESC) t2; 分别满足两个活动的人 # 描述: 为了促进更多用户在牛客平台学习和刷题进步,我们会经常给一些既活跃又表现不错的用户发放福利。假使以前我们有两拨运营活动,分别给每次试卷得分都能到 85 分的人(activity1)、至少有一次用了一半时间就完成高难度试卷且分数大于 80 的人(activity2)发了福利券。\n现在,需要你一次性将这两个活动满足的人筛选出来,交给运营同学。请写出一个 SQL 实现:输出 2021 年里,所有每次试卷得分都能到 85 分的人以及至少有一次用了一半时间就完成高难度试卷且分数大于 80 的人的 id 和活动号,按用户 ID 排序输出。\n现有试卷信息表 examination_info(exam_id 试卷 ID, tag 试卷类别, difficulty 试卷难度, duration 考试时长, release_time 发布时间):\nid exam_id tag difficulty duration release_time 1 9001 SQL hard 60 2021-09-01 06:00:00 2 9002 C++ easy 60 2021-09-01 06:00:00 3 9003 算法 medium 80 2021-09-01 10:00:00 试卷作答记录表 exam_record(uid 用户 ID, exam_id 试卷 ID, start_time 开始作答时间, submit_time 交卷时间, score 得分):\nid uid exam_id start_time submit_time score 1 1001 9001 2021-09-01 09:01:01 2021-09-01 09:31:00 81 2 1002 9002 2021-09-01 12:01:01 2021-09-01 12:31:01 70 3 1003 9001 2021-09-01 19:01:01 2021-09-01 19:40:01 86 4 1003 9002 2021-09-01 12:01:01 2021-09-01 12:31:01 89 5 1004 9001 2021-09-01 19:01:01 2021-09-01 19:30:01 85 示例数据输出结果:\nuid activity 1001 activity2 1003 activity1 1004 activity1 1004 activity2 解释:用户 1001 最小分数 81 不满足活动 1,但 29 分 59 秒完成了 60 分钟长的试卷得分 81,满足活动 2;1003 最小分数 86 满足活动 1,完成时长都大于试卷时长的一半,不满足活动 2;用户 1004 刚好用了一半时间(30 分钟整)完成了试卷得分 85,满足活动 1 和活动 2。\n思路: 这一题需要涉及到时间的减法,需要用到 TIMESTAMPDIFF() 函数计算两个时间戳之间的分钟差值。\n下面我们来看一下基本用法\n示例:\nTIMESTAMPDIFF(MINUTE, start_time, end_time) TIMESTAMPDIFF() 函数的第一个参数是时间单位,这里我们选择 MINUTE 表示返回分钟差值。第二个参数是较早的时间戳,第三个参数是较晚的时间戳。函数会返回它们之间的分钟差值\n了解了这个函数的用法之后,我们再回过头来看activity1的要求,求分数大于 85 即可,那我们还是先把这个写出来,后续思路就会清晰很多\nSELECT DISTINCT UID FROM exam_record WHERE score \u0026gt;= 85 AND YEAR (start_time) = \u0026#39;2021\u0026#39; 根据条件 2,接着写出在一半时间内完成高难度试卷且分数大于80的人\nSELECT UID FROM examination_info info INNER JOIN exam_record record WHERE info.exam_id = record.exam_id AND (TIMESTAMPDIFF(MINUTE, start_time, submit_time)) \u0026lt; (info.duration / 2) AND difficulty = \u0026#39;hard\u0026#39; AND score \u0026gt;= 80 然后再把两者UNION 起来即可。(这里特别要注意括号问题和order by位置,具体用法在上一篇中已提及)\n答案:\nSELECT DISTINCT UID UID, \u0026#39;activity1\u0026#39; activity FROM exam_record WHERE UID not in (SELECT UID FROM exam_record WHERE score\u0026lt;85 AND YEAR(submit_time) = 2021 ) UNION SELECT DISTINCT UID UID, \u0026#39;activity2\u0026#39; activity FROM exam_record e_r LEFT JOIN examination_info e_i ON e_r.exam_id = e_i.exam_id WHERE YEAR(submit_time) = 2021 AND difficulty = \u0026#39;hard\u0026#39; AND TIMESTAMPDIFF(SECOND, start_time, submit_time) \u0026lt;= duration *30 AND score\u0026gt;80 ORDER BY UID 连接查询 # 满足条件的用户的试卷完成数和题目练习数(困难) # 描述:\n现有用户信息表 user_info(uid 用户 ID,nick_name 昵称, achievement 成就值, level 等级, job 职业方向, register_time 注册时间):\nid uid nick_name achievement level job register_time 1 1001 牛客 1 号 3100 7 算法 2020-01-01 10:00:00 2 1002 牛客 2 号 2300 7 算法 2020-01-01 10:00:00 3 1003 牛客 3 号 2500 7 算法 2020-01-01 10:00:00 4 1004 牛客 4 号 1200 5 算法 2020-01-01 10:00:00 5 1005 牛客 5 号 1600 6 C++ 2020-01-01 10:00:00 6 1006 牛客 6 号 2000 6 C++ 2020-01-01 10:00:00 试卷信息表 examination_info(exam_id 试卷 ID, tag 试卷类别, difficulty 试卷难度, duration 考试时长, release_time 发布时间):\nid exam_id tag difficulty duration release_time 1 9001 SQL hard 60 2021-09-01 06:00:00 2 9002 C++ hard 60 2021-09-01 06:00:00 3 9003 算法 medium 80 2021-09-01 10:00:00 试卷作答记录表 exam_record(uid 用户 ID, exam_id 试卷 ID, start_time 开始作答时间, submit_time 交卷时间, score 得分):\nid uid exam_id start_time submit_time score 1 1001 9001 2021-09-01 09:01:01 2021-09-01 09:31:00 81 2 1002 9002 2021-09-01 12:01:01 2021-09-01 12:31:01 81 3 1003 9001 2021-09-01 19:01:01 2021-09-01 19:40:01 86 4 1003 9002 2021-09-01 12:01:01 2021-09-01 12:31:51 89 5 1004 9001 2021-09-01 19:01:01 2021-09-01 19:30:01 85 6 1005 9002 2021-09-01 12:01:01 2021-09-01 12:31:02 85 7 1006 9003 2021-09-07 10:01:01 2021-09-07 10:21:01 84 8 1006 9001 2021-09-07 10:01:01 2021-09-07 10:21:01 80 题目练习记录表 practice_record(uid 用户 ID, question_id 题目 ID, submit_time 提交时间, score 得分):\nid uid question_id submit_time score 1 1001 8001 2021-08-02 11:41:01 60 2 1002 8001 2021-09-02 19:30:01 50 3 1002 8001 2021-09-02 19:20:01 70 4 1002 8002 2021-09-02 19:38:01 70 5 1004 8001 2021-08-02 19:38:01 70 6 1004 8002 2021-08-02 19:48:01 90 7 1001 8002 2021-08-02 19:38:01 70 8 1004 8002 2021-08-02 19:48:01 90 9 1004 8002 2021-08-02 19:58:01 94 10 1004 8003 2021-08-02 19:38:01 70 11 1004 8003 2021-08-02 19:48:01 90 12 1004 8003 2021-08-01 19:38:01 80 请你找到高难度 SQL 试卷得分平均值大于 80 并且是 7 级的红名大佬,统计他们的 2021 年试卷总完成次数和题目总练习次数,只保留 2021 年有试卷完成记录的用户。结果按试卷完成数升序,按题目练习数降序。\n示例数据输出如下:\nuid exam_cnt question_cnt 1001 1 2 1003 2 0 解释:用户 1001、1003、1004、1006 满足高难度 SQL 试卷得分平均值大于 80,但只有 1001、1003 是 7 级红名大佬;1001 完成了 1 次试卷 1001,练习了 2 次题目;1003 完成了 2 次试卷 9001、9002,未练习题目(因此计数为 0)\n思路:\n先将条件进行初步筛选,比如先查出做过高难度 sql 试卷的用户\nSELECT record.uid FROM exam_record record INNER JOIN examination_info e_info ON record.exam_id = e_info.exam_id JOIN user_info u_info ON record.uid = u_info.uid WHERE e_info.tag = \u0026#39;SQL\u0026#39; AND e_info.difficulty = \u0026#39;hard\u0026#39; 然后根据题目要求,接着再往里叠条件即可;\n但是这里又要注意:\n第一:不能YEAR(submit_time)= 2021这个条件放到最后,要在ON条件里,因为左连接存在返回左表全部行,右表为 null 的情形,放在 JOIN条件的 ON 子句中的目的是为了确保在连接两个表时,只有满足年份条件的记录会进行连接。这样可以避免其他年份的记录被包含在结果中。即 1001 做过 2021 年的试卷,但没有练习过,如果把条件放到最后,就会排除掉这种情况。\n第二,必须是COUNT(distinct er.exam_id) exam_cnt, COUNT(distinct pr.id) question_cnt,要加 distinct,因为有左连接产生很多重复值。\n答案:\nSELECT er.uid AS UID, count(DISTINCT er.exam_id) AS exam_cnt, count(DISTINCT pr.id) AS question_cnt FROM exam_record er LEFT JOIN practice_record pr ON er.uid = pr.uid AND YEAR (er.submit_time)= 2021 AND YEAR (pr.submit_time)= 2021 WHERE er.uid IN (SELECT er.uid FROM exam_record er LEFT JOIN examination_info ei ON er.exam_id = ei.exam_id LEFT JOIN user_info ui ON er.uid = ui.uid WHERE tag = \u0026#39;SQL\u0026#39; AND difficulty = \u0026#39;hard\u0026#39; AND LEVEL = 7 GROUP BY er.uid HAVING avg(score) \u0026gt; 80) GROUP BY er.uid ORDER BY exam_cnt, question_cnt DESC 可能细心的小伙伴会发现,为什么明明将条件限制了tag = 'SQL' AND difficulty = 'hard',但是用户 1003 仍然能查出两条考试记录,其中一条的考试tag为 C++; 这是由于LEFT JOIN的特性,即使没有与右表匹配的行,左表的所有记录仍然会被保留。\n每个 6/7 级用户活跃情况(困难) # 描述:\n现有用户信息表 user_info(uid 用户 ID,nick_name 昵称, achievement 成就值, level 等级, job 职业方向, register_time 注册时间):\nid uid nick_name achievement level job register_time 1 1001 牛客 1 号 3100 7 算法 2020-01-01 10:00:00 2 1002 牛客 2 号 2300 7 算法 2020-01-01 10:00:00 3 1003 牛客 3 号 2500 7 算法 2020-01-01 10:00:00 4 1004 牛客 4 号 1200 5 算法 2020-01-01 10:00:00 5 1005 牛客 5 号 1600 6 C++ 2020-01-01 10:00:00 6 1006 牛客 6 号 2600 7 C++ 2020-01-01 10:00:00 试卷信息表 examination_info(exam_id 试卷 ID, tag 试卷类别, difficulty 试卷难度, duration 考试时长, release_time 发布时间):\nid exam_id tag difficulty duration release_time 1 9001 SQL hard 60 2021-09-01 06:00:00 2 9002 C++ easy 60 2021-09-01 06:00:00 3 9003 算法 medium 80 2021-09-01 10:00:00 试卷作答记录表 exam_record(uid 用户 ID, exam_id 试卷 ID, start_time 开始作答时间, submit_time 交卷时间, score 得分):\nuid exam_id start_time submit_time score 1001 9001 2021-09-01 09:01:01 2021-09-01 09:31:00 78 1001 9001 2021-09-01 09:01:01 2021-09-01 09:31:00 81 1005 9001 2021-09-01 19:01:01 2021-09-01 19:30:01 85 1005 9002 2021-09-01 12:01:01 2021-09-01 12:31:02 85 1006 9003 2021-09-07 10:01:01 2021-09-07 10:21:59 84 1006 9001 2021-09-07 10:01:01 2021-09-07 10:21:01 81 1002 9001 2020-09-01 13:01:01 2020-09-01 13:41:01 81 1005 9001 2021-09-01 14:01:01 (NULL) (NULL) 题目练习记录表 practice_record(uid 用户 ID, question_id 题目 ID, submit_time 提交时间, score 得分):\nuid question_id submit_time score 1001 8001 2021-08-02 11:41:01 60 1004 8001 2021-08-02 19:38:01 70 1004 8002 2021-08-02 19:48:01 90 1001 8002 2021-08-02 19:38:01 70 1004 8002 2021-08-02 19:48:01 90 1006 8002 2021-08-04 19:58:01 94 1006 8003 2021-08-03 19:38:01 70 1006 8003 2021-08-02 19:48:01 90 1006 8003 2020-08-01 19:38:01 80 请统计每个 6/7 级用户总活跃月份数、2021 年活跃天数、2021 年试卷作答活跃天数、2021 年答题活跃天数,按照总活跃月份数、2021 年活跃天数降序排序。由示例数据结果输出如下:\nuid act_month_total act_days_2021 act_days_2021_exam 1006 3 4 1 1001 2 2 1 1005 1 1 1 1002 1 0 0 1003 0 0 0 解释:6/7 级用户共有 5 个,其中 1006 在 202109、202108、202008 共 3 个月活跃过,2021 年活跃的日期有 20210907、20210804、20210803、20210802 共 4 天,2021 年在试卷作答区 20210907 活跃 1 天,在题目练习区活跃了 3 天。\n思路:\n这题的关键在于CASE WHEN THEN的使用,不然要写很多的left join 因为会产生很多的结果集。\nCASE WHEN THEN语句是一种条件表达式,用于在 SQL 中根据条件执行不同的操作或返回不同的结果。\n语法结构如下:\nCASE WHEN condition1 THEN result1 WHEN condition2 THEN result2 ... ELSE result END 在这个结构中,可以根据需要添加多个WHEN子句,每个WHEN子句后面跟着一个条件(condition)和一个结果(result)。条件可以是任何逻辑表达式,如果满足条件,将返回对应的结果。\n最后的ELSE子句是可选的,用于指定当所有前面的条件都不满足时的默认返回结果。如果没有提供ELSE子句,则默认返回NULL。\n例如:\nSELECT score, CASE WHEN score \u0026gt;= 90 THEN \u0026#39;优秀\u0026#39; WHEN score \u0026gt;= 80 THEN \u0026#39;良好\u0026#39; WHEN score \u0026gt;= 60 THEN \u0026#39;及格\u0026#39; ELSE \u0026#39;不及格\u0026#39; END AS grade FROM student_scores; 在上述示例中,根据学生成绩(score)的不同范围,使用 CASE WHEN THEN 语句返回相应的等级(grade)。如果成绩大于等于 90,则返回\u0026quot;优秀\u0026quot;;如果成绩大于等于 80,则返回\u0026quot;良好\u0026quot;;如果成绩大于等于 60,则返回\u0026quot;及格\u0026quot;;否则返回\u0026quot;不及格\u0026quot;。\n那了解到了上述的用法之后,回过头看看该题,要求列出不同的活跃天数。\ncount(distinct act_month) as act_month_total, count(distinct case when year(act_time)=\u0026#39;2021\u0026#39;then act_day end) as act_days_2021, count(distinct case when year(act_time)=\u0026#39;2021\u0026#39; and tag=\u0026#39;exam\u0026#39; then act_day end) as act_days_2021_exam, count(distinct case when year(act_time)=\u0026#39;2021\u0026#39; and tag=\u0026#39;question\u0026#39;then act_day end) as act_days_2021_question 这里的 tag 是先给标记,方便对查询进行区分,将考试和答题分开。\n找出试卷作答区的用户\nSELECT uid, exam_id AS ans_id, start_time AS act_time, date_format( start_time, \u0026#39;%Y%m\u0026#39; ) AS act_month, date_format( start_time, \u0026#39;%Y%m%d\u0026#39; ) AS act_day, \u0026#39;exam\u0026#39; AS tag FROM exam_record 紧接着就是答题作答区的用户\nSELECT uid, question_id AS ans_id, submit_time AS act_time, date_format( submit_time, \u0026#39;%Y%m\u0026#39; ) AS act_month, date_format( submit_time, \u0026#39;%Y%m%d\u0026#39; ) AS act_day, \u0026#39;question\u0026#39; AS tag FROM practice_record 最后将两个结果进行UNION 最后别忘了将结果进行排序 (这题有点类似于分治法的思想)\n答案:\nSELECT user_info.uid, count(DISTINCT act_month) AS act_month_total, count(DISTINCT CASE WHEN YEAR (act_time)= \u0026#39;2021\u0026#39; THEN act_day END) AS act_days_2021, count(DISTINCT CASE WHEN YEAR (act_time)= \u0026#39;2021\u0026#39; AND tag = \u0026#39;exam\u0026#39; THEN act_day END) AS act_days_2021_exam, count(DISTINCT CASE WHEN YEAR (act_time)= \u0026#39;2021\u0026#39; AND tag = \u0026#39;question\u0026#39; THEN act_day END) AS act_days_2021_question FROM (SELECT UID, exam_id AS ans_id, start_time AS act_time, date_format(start_time, \u0026#39;%Y%m\u0026#39;) AS act_month, date_format(start_time, \u0026#39;%Y%m%d\u0026#39;) AS act_day, \u0026#39;exam\u0026#39; AS tag FROM exam_record UNION ALL SELECT UID, question_id AS ans_id, submit_time AS act_time, date_format(submit_time, \u0026#39;%Y%m\u0026#39;) AS act_month, date_format(submit_time, \u0026#39;%Y%m%d\u0026#39;) AS act_day, \u0026#39;question\u0026#39; AS tag FROM practice_record) total RIGHT JOIN user_info ON total.uid = user_info.uid WHERE user_info.LEVEL IN (6, 7) GROUP BY user_info.uid ORDER BY act_month_total DESC, act_days_2021 DESC "},{"id":580,"href":"/zh/docs/technology/Interview/database/sql/sql-questions-04/","title":"SQL常见面试题总结(4)","section":"SQL","content":" 题目来源于: 牛客题霸 - SQL 进阶挑战\n较难或者困难的题目可以根据自身实际情况和面试需要来决定是否要跳过。\n专用窗口函数 # MySQL 8.0 版本引入了窗口函数的支持,下面是 MySQL 中常见的窗口函数及其用法:\nROW_NUMBER(): 为查询结果集中的每一行分配一个唯一的整数值。 SELECT col1, col2, ROW_NUMBER() OVER (ORDER BY col1) AS row_num FROM table; RANK(): 计算每一行在排序结果中的排名。 SELECT col1, col2, RANK() OVER (ORDER BY col1 DESC) AS ranking FROM table; DENSE_RANK(): 计算每一行在排序结果中的排名,保留相同的排名。 SELECT col1, col2, DENSE_RANK() OVER (ORDER BY col1 DESC) AS ranking FROM table; NTILE(n): 将结果分成 n 个基本均匀的桶,并为每个桶分配一个标识号。 SELECT col1, col2, NTILE(4) OVER (ORDER BY col1) AS bucket FROM table; SUM(), AVG(),COUNT(), MIN(), MAX(): 这些聚合函数也可以与窗口函数结合使用,计算窗口内指定列的汇总、平均值、计数、最小值和最大值。 SELECT col1, col2, SUM(col1) OVER () AS sum_col FROM table; LEAD() 和 LAG(): LEAD 函数用于获取当前行之后的某个偏移量的行的值,而 LAG 函数用于获取当前行之前的某个偏移量的行的值。 SELECT col1, col2, LEAD(col1, 1) OVER (ORDER BY col1) AS next_col1, LAG(col1, 1) OVER (ORDER BY col1) AS prev_col1 FROM table; FIRST_VALUE() 和 LAST_VALUE(): FIRST_VALUE 函数用于获取窗口内指定列的第一个值,LAST_VALUE 函数用于获取窗口内指定列的最后一个值。 SELECT col1, col2, FIRST_VALUE(col2) OVER (PARTITION BY col1 ORDER BY col2) AS first_val, LAST_VALUE(col2) OVER (PARTITION BY col1 ORDER BY col2) AS last_val FROM table; 窗口函数通常需要配合 OVER 子句一起使用,用于定义窗口的大小、排序规则和分组方式。\n每类试卷得分前三名 # 描述:\n现有试卷信息表 examination_info(exam_id 试卷 ID, tag 试卷类别, difficulty 试卷难度, duration 考试时长, release_time 发布时间):\nid exam_id tag difficulty duration release_time 1 9001 SQL hard 60 2021-09-01 06:00:00 2 9002 SQL hard 60 2021-09-01 06:00:00 3 9003 算法 medium 80 2021-09-01 10:00:00 试卷作答记录表 exam_record(uid 用户 ID, exam_id 试卷 ID, start_time 开始作答时间, submit_time 交卷时间, score 得分):\nid uid exam_id start_time submit_time score 1 1001 9001 2021-09-01 09:01:01 2021-09-01 09:31:00 78 2 1002 9001 2021-09-01 09:01:01 2021-09-01 09:31:00 81 3 1002 9002 2021-09-01 12:01:01 2021-09-01 12:31:01 81 4 1003 9001 2021-09-01 19:01:01 2021-09-01 19:40:01 86 5 1003 9002 2021-09-01 12:01:01 2021-09-01 12:31:51 89 6 1004 9001 2021-09-01 19:01:01 2021-09-01 19:30:01 85 7 1005 9003 2021-09-01 12:01:01 2021-09-01 12:31:02 85 8 1006 9003 2021-09-07 10:01:01 2021-09-07 10:21:01 84 9 1003 9003 2021-09-08 12:01:01 2021-09-08 12:11:01 40 10 1003 9002 2021-09-01 14:01:01 (NULL) (NULL) 找到每类试卷得分的前 3 名,如果两人最大分数相同,选择最小分数大者,如果还相同,选择 uid 大者。由示例数据结果输出如下:\ntid uid ranking SQL 1003 1 SQL 1004 2 SQL 1002 3 算法 1005 1 算法 1006 2 算法 1003 3 解释:有作答得分记录的试卷 tag 有 SQL 和算法,SQL 试卷用户 1001、1002、1003、1004 有作答得分,最高得分分别为 81、81、89、85,最低得分分别为 78、81、86、40,因此先按最高得分排名再按最低得分排名取前三为 1003、1004、1002。\n答案:\nSELECT tag, UID, ranking FROM (SELECT b.tag AS tag, a.uid AS UID, ROW_NUMBER() OVER (PARTITION BY b.tag ORDER BY b.tag, max(a.score) DESC, min(a.score) DESC, a.uid DESC) AS ranking FROM exam_record a LEFT JOIN examination_info b ON a.exam_id = b.exam_id GROUP BY b.tag, a.uid) t WHERE ranking \u0026lt;= 3 第二快/慢用时之差大于试卷时长一半的试卷(较难) # 描述:\n现有试卷信息表 examination_info(exam_id 试卷 ID, tag 试卷类别, difficulty 试卷难度, duration 考试时长, release_time 发布时间):\nid exam_id tag difficulty duration release_time 1 9001 SQL hard 60 2021-09-01 06:00:00 2 9002 C++ hard 60 2021-09-01 06:00:00 3 9003 算法 medium 80 2021-09-01 10:00:00 试卷作答记录表 exam_record(uid 用户 ID, exam_id 试卷 ID, start_time 开始作答时间, submit_time 交卷时间, score 得分):\nid uid exam_id start_time submit_time score 1 1001 9001 2021-09-01 09:01:01 2021-09-01 09:51:01 78 2 1001 9002 2021-09-01 09:01:01 2021-09-01 09:31:00 81 3 1002 9002 2021-09-01 12:01:01 2021-09-01 12:31:01 81 4 1003 9001 2021-09-01 19:01:01 2021-09-01 19:59:01 86 5 1003 9002 2021-09-01 12:01:01 2021-09-01 12:31:51 89 6 1004 9002 2021-09-01 19:01:01 2021-09-01 19:30:01 85 7 1005 9001 2021-09-01 12:01:01 2021-09-01 12:31:02 85 8 1006 9001 2021-09-07 10:02:01 2021-09-07 10:21:01 84 9 1003 9001 2021-09-08 12:01:01 2021-09-08 12:11:01 40 10 1003 9002 2021-09-01 14:01:01 (NULL) (NULL) 11 1005 9001 2021-09-01 14:01:01 (NULL) (NULL) 12 1003 9003 2021-09-08 15:01:01 (NULL) (NULL) 找到第二快和第二慢用时之差大于试卷时长的一半的试卷信息,按试卷 ID 降序排序。由示例数据结果输出如下:\nexam_id duration release_time 9001 60 2021-09-01 06:00:00 解释:试卷 9001 被作答用时有 50 分钟、58 分钟、30 分 1 秒、19 分钟、10 分钟,第二快和第二慢用时之差为 50 分钟-19 分钟=31 分钟,试卷时长为 60 分钟,因此满足大于试卷时长一半的条件,输出试卷 ID、时长、发布时间。\n思路:\n第一步,找到每张试卷完成时间的顺序排名和倒序排名 也就是表 a;\n第二步,与通过试卷信息表 b 建立内连接,并根据试卷 id 分组,利用having筛选排名为第二个数据,将秒转化为分钟并进行比较,最后再根据试卷 id 倒序排序就行\n答案:\nSELECT a.exam_id, b.duration, b.release_time FROM (SELECT exam_id, row_number() OVER (PARTITION BY exam_id ORDER BY timestampdiff(SECOND, start_time, submit_time) DESC) rn1, row_number() OVER (PARTITION BY exam_id ORDER BY timestampdiff(SECOND, start_time, submit_time) ASC) rn2, timestampdiff(SECOND, start_time, submit_time) timex FROM exam_record WHERE score IS NOT NULL ) a INNER JOIN examination_info b ON a.exam_id = b.exam_id GROUP BY a.exam_id HAVING (max(IF (rn1 = 2, a.timex, 0))- max(IF (rn2 = 2, a.timex, 0)))/ 60 \u0026gt; b.duration / 2 ORDER BY a.exam_id DESC 连续两次作答试卷的最大时间窗(较难) # 描述\n现有试卷作答记录表 exam_record(uid 用户 ID, exam_id 试卷 ID, start_time 开始作答时间, submit_time 交卷时间, score 得分):\nid uid exam_id start_time submit_time score 1 1006 9003 2021-09-07 10:01:01 2021-09-07 10:21:02 84 2 1006 9001 2021-09-01 12:11:01 2021-09-01 12:31:01 89 3 1006 9002 2021-09-06 10:01:01 2021-09-06 10:21:01 81 4 1005 9002 2021-09-05 10:01:01 2021-09-05 10:21:01 81 5 1005 9001 2021-09-05 10:31:01 2021-09-05 10:51:01 81 请计算在 2021 年至少有两天作答过试卷的人中,计算该年连续两次作答试卷的最大时间窗 days_window,那么根据该年的历史规律他在 days_window 天里平均会做多少套试卷,按最大时间窗和平均做答试卷套数倒序排序。由示例数据结果输出如下:\nuid days_window avg_exam_cnt 1006 6 2.57 解释:用户 1006 分别在 20210901、20210906、20210907 作答过 3 次试卷,连续两次作答最大时间窗为 6 天(1 号到 6 号),他 1 号到 7 号这 7 天里共做了 3 张试卷,平均每天 3/7=0.428571 张,那么 6 天里平均会做 0.428571*6=2.57 张试卷(保留两位小数);用户 1005 在 20210905 做了两张试卷,但是只有一天的作答记录,过滤掉。\n思路:\n上面这个解释中提示要对作答记录去重,千万别被骗了,不要去重!去重就通不过测试用例。注意限制时间是 2021 年;\n而且要注意时间差要+1 天;还要注意没交卷也算在内!!!! (反正感觉这题描述不清,出的不是很好)\n答案:\nSELECT UID, max(datediff(next_time, start_time)) + 1 AS days_window, round(count(start_time)/(datediff(max(start_time), min(start_time))+ 1) * (max(datediff(next_time, start_time))+ 1), 2) AS avg_exam_cnt FROM (SELECT UID, start_time, lead(start_time, 1) OVER (PARTITION BY UID ORDER BY start_time) AS next_time FROM exam_record WHERE YEAR (start_time) = \u0026#39;2021\u0026#39; ) a GROUP BY UID HAVING count(DISTINCT date(start_time)) \u0026gt; 1 ORDER BY days_window DESC, avg_exam_cnt DESC 近三个月未完成为 0 的用户完成情况 # 描述:\n现有试卷作答记录表 exam_record(uid:用户 ID, exam_id:试卷 ID, start_time:开始作答时间, submit_time:交卷时间,为空的话则代表未完成, score:得分):\nid uid exam_id start_time submit_time score 1 1006 9003 2021-09-06 10:01:01 2021-09-06 10:21:02 84 2 1006 9001 2021-08-02 12:11:01 2021-08-02 12:31:01 89 3 1006 9002 2021-06-06 10:01:01 2021-06-06 10:21:01 81 4 1006 9002 2021-05-06 10:01:01 2021-05-06 10:21:01 81 5 1006 9001 2021-05-01 12:01:01 (NULL) (NULL) 6 1001 9001 2021-09-05 10:31:01 2021-09-05 10:51:01 81 7 1001 9003 2021-08-01 09:01:01 2021-08-01 09:51:11 78 8 1001 9002 2021-07-01 09:01:01 2021-07-01 09:31:00 81 9 1001 9002 2021-07-01 12:01:01 2021-07-01 12:31:01 81 10 1001 9002 2021-07-01 12:01:01 (NULL) (NULL) 找到每个人近三个有试卷作答记录的月份中没有试卷是未完成状态的用户的试卷作答完成数,按试卷完成数和用户 ID 降序排名。由示例数据结果输出如下:\nuid exam_complete_cnt 1006 3 解释:用户 1006 近三个有作答试卷的月份为 202109、202108、202106,作答试卷数为 3,全部完成;用户 1001 近三个有作答试卷的月份为 202109、202108、202107,作答试卷数为 5,完成试卷数为 4,因为有未完成试卷,故过滤掉。\n思路:\n找到每个人近三个有试卷作答记录的月份中没有试卷是未完成状态的用户的试卷作答完成数首先看这句话,肯定要先根据人进行分组 最近三个月,可以采用连续重复排名,倒序排列,排名\u0026lt;=3 统计作答数 拼装剩余条件 排序 答案:\nSELECT UID, count(score) exam_complete_cnt FROM (SELECT *, DENSE_RANK() OVER (PARTITION BY UID ORDER BY date_format(start_time, \u0026#39;%Y%m\u0026#39;) DESC) dr FROM exam_record) t1 WHERE dr \u0026lt;= 3 GROUP BY UID HAVING count(dr)= count(score) ORDER BY exam_complete_cnt DESC, UID DESC 未完成率较高的 50%用户近三个月答卷情况(困难) # 描述:\n现有用户信息表 user_info(uid 用户 ID,nick_name 昵称, achievement 成就值, level 等级, job 职业方向, register_time 注册时间):\nid uid nick_name achievement level job register_time 1 1001 牛客 1 号 3200 7 算法 2020-01-01 10:00:00 2 1002 牛客 2 号 2500 6 算法 2020-01-01 10:00:00 3 1003 牛客 3 号 ♂ 2200 5 算法 2020-01-01 10:00:00 试卷信息表 examination_info(exam_id 试卷 ID, tag 试卷类别, difficulty 试卷难度, duration 考试时长, release_time 发布时间):\nid exam_id tag difficulty duration release_time 1 9001 SQL hard 60 2020-01-01 10:00:00 2 9002 SQL hard 80 2020-01-01 10:00:00 3 9003 算法 hard 80 2020-01-01 10:00:00 4 9004 PYTHON medium 70 2020-01-01 10:00:00 试卷作答记录表 exam_record(uid 用户 ID, exam_id 试卷 ID, start_time 开始作答时间, submit_time 交卷时间, score 得分):\nid uid exam_id start_time submit_time score 1 1001 9001 2020-01-01 09:01:01 2020-01-01 09:21:59 90 15 1002 9001 2020-01-01 18:01:01 2020-01-01 18:59:02 90 13 1001 9001 2020-01-02 10:01:01 2020-01-02 10:31:01 89 2 1002 9001 2020-01-20 10:01:01 3 1002 9001 2020-02-01 12:11:01 5 1001 9001 2020-03-01 12:01:01 6 1002 9001 2020-03-01 12:01:01 2020-03-01 12:41:01 90 4 1003 9001 2020-03-01 19:01:01 7 1002 9001 2020-05-02 19:01:01 2020-05-02 19:32:00 90 14 1001 9002 2020-01-01 12:11:01 8 1001 9002 2020-01-02 19:01:01 2020-01-02 19:59:01 69 9 1001 9002 2020-02-02 12:01:01 2020-02-02 12:20:01 99 10 1002 9002 2020-02-02 12:01:01 11 1002 9002 2020-02-02 12:01:01 2020-02-02 12:43:01 81 12 1002 9002 2020-03-02 12:11:01 17 1001 9002 2020-05-05 18:01:01 16 1002 9003 2020-05-06 12:01:01 请统计 SQL 试卷上未完成率较高的 50%用户中,6 级和 7 级用户在有试卷作答记录的近三个月中,每个月的答卷数目和完成数目。按用户 ID、月份升序排序。\n由示例数据结果输出如下:\nuid start_month total_cnt complete_cnt 1002 202002 3 1 1002 202003 2 1 1002 202005 2 1 解释:各个用户对 SQL 试卷的未完成数、作答总数、未完成率如下:\nuid incomplete_cnt total_cnt incomplete_rate 1001 3 7 0.4286 1002 4 8 0.5000 1003 1 1 1.0000 1001、1002、1003 分别排在 1.0、0.5、0.0 的位置,因此较高的 50%用户(排位\u0026lt;=0.5)为 1002、1003;\n1003 不是 6 级或 7 级;\n有试卷作答记录的近三个月为 202005、202003、202002;\n这三个月里 1002 的作答题数分别为 3、2、2,完成数目分别为 1、1、1。\n思路:\n注意点:这题注意求的是所有的答题次数和完成次数,而 sql 类别的试卷是限制未完成率排名,6, 7 级用户限制的是做题记录。\n先求出未完成率的排名\nSELECT UID, count(submit_time IS NULL OR NULL)/ count(start_time) AS num, PERCENT_RANK() OVER ( ORDER BY count(submit_time IS NULL OR NULL)/ count(start_time)) AS ranking FROM exam_record LEFT JOIN examination_info USING (exam_id) WHERE tag = \u0026#39;SQL\u0026#39; GROUP BY UID 再求出最近三个月的练习记录\nSELECT UID, date_format(start_time, \u0026#39;%Y%m\u0026#39;) AS month_d, submit_time, exam_id, dense_rank() OVER (PARTITION BY UID ORDER BY date_format(start_time, \u0026#39;%Y%m\u0026#39;) DESC) AS ranking FROM exam_record LEFT JOIN user_info USING (UID) WHERE LEVEL IN (6,7) 答案:\nSELECT t1.uid, t1.month_d, count(*) AS total_cnt, count(t1.submit_time) AS complete_cnt FROM-- 先求出未完成率的排名 (SELECT UID, count(submit_time IS NULL OR NULL)/ count(start_time) AS num, PERCENT_RANK() OVER ( ORDER BY count(submit_time IS NULL OR NULL)/ count(start_time)) AS ranking FROM exam_record LEFT JOIN examination_info USING (exam_id) WHERE tag = \u0026#39;SQL\u0026#39; GROUP BY UID) t INNER JOIN (-- 再求出近三个月的练习记录 SELECT UID, date_format(start_time, \u0026#39;%Y%m\u0026#39;) AS month_d, submit_time, exam_id, dense_rank() OVER (PARTITION BY UID ORDER BY date_format(start_time, \u0026#39;%Y%m\u0026#39;) DESC) AS ranking FROM exam_record LEFT JOIN user_info USING (UID) WHERE LEVEL IN (6,7) ) t1 USING (UID) WHERE t1.ranking \u0026lt;= 3 AND t.ranking \u0026gt;= 0.5 -- 使用限制找到符合条件的记录 GROUP BY t1.uid, t1.month_d ORDER BY t1.uid, t1.month_d 试卷完成数同比 2020 年的增长率及排名变化(困难) # 描述:\n现有试卷信息表 examination_info(exam_id 试卷 ID, tag 试卷类别, difficulty 试卷难度, duration 考试时长, release_time 发布时间):\nid exam_id tag difficulty duration release_time 1 9001 SQL hard 60 2021-01-01 10:00:00 2 9002 C++ hard 80 2021-01-01 10:00:00 3 9003 算法 hard 80 2021-01-01 10:00:00 4 9004 PYTHON medium 70 2021-01-01 10:00:00 试卷作答记录表 exam_record(uid 用户 ID, exam_id 试卷 ID, start_time 开始作答时间, submit_time 交卷时间, score 得分):\nid uid exam_id start_time submit_time score 1 1001 9001 2020-08-02 10:01:01 2020-08-02 10:31:01 89 2 1002 9001 2020-04-01 18:01:01 2020-04-01 18:59:02 90 3 1001 9001 2020-04-01 09:01:01 2020-04-01 09:21:59 80 5 1002 9001 2021-03-02 19:01:01 2021-03-02 19:32:00 20 8 1003 9001 2021-05-02 12:01:01 2021-05-02 12:31:01 98 13 1003 9001 2020-01-02 10:01:01 2020-01-02 10:31:01 89 9 1001 9002 2020-02-02 12:01:01 2020-02-02 12:20:01 99 10 1002 9002 2021-02-02 12:01:01 2020-02-02 12:43:01 81 11 1001 9002 2020-01-02 19:01:01 2020-01-02 19:59:01 69 16 1002 9002 2020-02-02 12:01:01 17 1002 9002 2020-03-02 12:11:01 18 1001 9002 2021-05-05 18:01:01 4 1002 9003 2021-01-20 10:01:01 2021-01-20 10:10:01 81 6 1001 9003 2021-04-02 19:01:01 2021-04-02 19:40:01 89 15 1002 9003 2021-01-01 18:01:01 2021-01-01 18:59:02 90 7 1004 9004 2020-05-02 12:01:01 2020-05-02 12:20:01 99 12 1001 9004 2021-09-02 12:11:01 14 1002 9004 2020-01-01 12:11:01 2020-01-01 12:31:01 83 请计算 2021 年上半年各类试卷的做完次数相比 2020 年上半年同期的增长率(百分比格式,保留 1 位小数),以及做完次数排名变化,按增长率和 21 年排名降序输出。\n由示例数据结果输出如下:\ntag exam_cnt_20 exam_cnt_21 growth_rate exam_cnt_rank_20 exam_cnt_rank_21 rank_delta SQL 3 2 -33.3% 1 2 1 解释:2020 年上半年有 3 个 tag 有作答完成的记录,分别是 C++、SQL、PYTHON,它们被做完的次数分别是 3、3、2,做完次数排名为 1、1(并列)、3;\n2021 年上半年有 2 个 tag 有作答完成的记录,分别是算法、SQL,它们被做完的次数分别是 3、2,做完次数排名为 1、2;具体如下:\ntag start_year exam_cnt exam_cnt_rank C++ 2020 3 1 SQL 2020 3 1 PYTHON 2020 2 3 算法 2021 3 1 SQL 2021 2 2 因此能输出同比结果的 tag 只有 SQL,从 2020 到 2021 年,做完次数 3=\u0026gt;2,减少 33.3%(保留 1 位小数);排名 1=\u0026gt;2,后退 1 名。\n思路:\n本题难点在于长整型的数据类型要求不能有负号产生,用 cast 函数转换数据类型为 signed。\n以及用到的增长率计算公式:(exam_cnt_21-exam_cnt_20)/exam_cnt_20\n做完次数排名变化(2021 年和 2020 年比排名升了或者降了多少)\n计算公式:exam_cnt_rank_21 - exam_cnt_rank_20\n在 MySQL 中,CAST() 函数用于将一个表达式的数据类型转换为另一个数据类型。它的基本语法如下:\nCAST(expression AS data_type) -- 将一个字符串转换成整数 SELECT CAST(\u0026#39;123\u0026#39; AS INT); 示例就不一一举例了,这个函数很简单\n答案:\nSELECT tag, exam_cnt_20, exam_cnt_21, concat( round( 100 * (exam_cnt_21 - exam_cnt_20) / exam_cnt_20, 1 ), \u0026#39;%\u0026#39; ) AS growth_rate, exam_cnt_rank_20, exam_cnt_rank_21, cast(exam_cnt_rank_21 AS signed) - cast(exam_cnt_rank_20 AS signed) AS rank_delta FROM ( #2020年、2021年上半年各类试卷的做完次数和做完次数排名 SELECT tag, count( IF ( date_format(start_time, \u0026#39;%Y%m%d\u0026#39;) BETWEEN \u0026#39;20200101\u0026#39; AND \u0026#39;20200630\u0026#39;, start_time, NULL ) ) AS exam_cnt_20, count( IF ( substring(start_time, 1, 10) BETWEEN \u0026#39;2021-01-01\u0026#39; AND \u0026#39;2021-06-30\u0026#39;, start_time, NULL ) ) AS exam_cnt_21, rank() over ( ORDER BY count( IF ( date_format(start_time, \u0026#39;%Y%m%d\u0026#39;) BETWEEN \u0026#39;20200101\u0026#39; AND \u0026#39;20200630\u0026#39;, start_time, NULL ) ) DESC ) AS exam_cnt_rank_20, rank() over ( ORDER BY count( IF ( substring(start_time, 1, 10) BETWEEN \u0026#39;2021-01-01\u0026#39; AND \u0026#39;2021-06-30\u0026#39;, start_time, NULL ) ) DESC ) AS exam_cnt_rank_21 FROM examination_info JOIN exam_record USING (exam_id) WHERE submit_time IS NOT NULL GROUP BY tag ) main WHERE exam_cnt_21 * exam_cnt_20 \u0026lt;\u0026gt; 0 ORDER BY growth_rate DESC, exam_cnt_rank_21 DESC 聚合窗口函数 # 对试卷得分做 min-max 归一化 # 描述:\n现有试卷信息表 examination_info(exam_id 试卷 ID, tag 试卷类别, difficulty 试卷难度, duration 考试时长, release_time 发布时间):\nid exam_id tag difficulty duration release_time 1 9001 SQL hard 60 2020-01-01 10:00:00 2 9002 C++ hard 80 2020-01-01 10:00:00 3 9003 算法 hard 80 2020-01-01 10:00:00 4 9004 PYTHON medium 70 2020-01-01 10:00:00 试卷作答记录表 exam_record(uid 用户 ID, exam_id 试卷 ID, start_time 开始作答时间, submit_time 交卷时间, score 得分):\nid uid exam_id start_time submit_time score 6 1003 9001 2020-01-02 12:01:01 2020-01-02 12:31:01 68 9 1001 9001 2020-01-02 10:01:01 2020-01-02 10:31:01 89 1 1001 9001 2020-01-01 09:01:01 2020-01-01 09:21:59 90 12 1002 9002 2021-05-05 18:01:01 (NULL) (NULL) 3 1004 9002 2020-01-01 12:01:01 2020-01-01 12:11:01 60 2 1003 9002 2020-01-01 19:01:01 2020-01-01 19:30:01 75 7 1001 9002 2020-01-02 12:01:01 2020-01-02 12:43:01 81 10 1002 9002 2020-01-01 12:11:01 2020-01-01 12:31:01 83 4 1003 9002 2020-01-01 12:01:01 2020-01-01 12:41:01 90 5 1002 9002 2020-01-02 19:01:01 2020-01-02 19:32:00 90 11 1002 9004 2021-09-06 12:01:01 (NULL) (NULL) 8 1001 9005 2020-01-02 12:11:01 (NULL) (NULL) 在物理学及统计学数据计算时,有个概念叫 min-max 标准化,也被称为离差标准化,是对原始数据的线性变换,使结果值映射到[0 - 1]之间。\n转换函数为:\n请你将用户作答高难度试卷的得分在每份试卷作答记录内执行 min-max 归一化后缩放到[0,100]区间,并输出用户 ID、试卷 ID、归一化后分数平均值;最后按照试卷 ID 升序、归一化分数降序输出。(注:得分区间默认为[0,100],如果某个试卷作答记录中只有一个得分,那么无需使用公式,归一化并缩放后分数仍为原分数)。\n由示例数据结果输出如下:\nuid exam_id avg_new_score 1001 9001 98 1003 9001 0 1002 9002 88 1003 9002 75 1001 9002 70 1004 9002 0 解释:高难度试卷有 9001、9002、9003;\n作答了 9001 的记录有 3 条,分数分别为 68、89、90,按给定公式归一化后分数为:0、95、100,而后两个得分都是用户 1001 作答的,因此用户 1001 对试卷 9001 的新得分为(95+100)/2≈98(只保留整数部分),用户 1003 对于试卷 9001 的新得分为 0。最后结果按照试卷 ID 升序、归一化分数降序输出。\n思路:\n注意点:\n将高难度的试卷,按每类试卷的得分,利用 max/min (col) over()窗口函数求得各组内最大最小值,然后进行归一化公式计算,缩放区间为[0,100],即 min_max*100 若某类试卷只有一个得分,则无需使用归一化公式,因只有一个分 max_score=min_score,score,公式后结果可能会变成 0。 最后结果按 uid、exam_id 分组求归一化后均值,score 为 NULL 的要过滤掉。 最后就是仔细看上面公式 (说实话,这题看起来就很绕)\n答案:\nSELECT uid, exam_id, round(sum(min_max) / count(score), 0) AS avg_new_score FROM ( SELECT *, IF ( max_score = min_score, score, (score - min_score) / (max_score - min_score) * 100 ) AS min_max FROM ( SELECT uid, a.exam_id, score, max(score) over (PARTITION BY a.exam_id) AS max_score, min(score) over (PARTITION BY a.exam_id) AS min_score FROM exam_record a LEFT JOIN examination_info b USING (exam_id) WHERE difficulty = \u0026#39;hard\u0026#39; ) t WHERE score IS NOT NULL ) t1 GROUP BY uid, exam_id ORDER BY exam_id ASC, avg_new_score DESC; 每份试卷每月作答数和截止当月的作答总数 # 描述:\n现有试卷作答记录表 exam_record(uid 用户 ID, exam_id 试卷 ID, start_time 开始作答时间, submit_time 交卷时间, score 得分):\nid uid exam_id start_time submit_time score 1 1001 9001 2020-01-01 09:01:01 2020-01-01 09:21:59 90 2 1002 9001 2020-01-20 10:01:01 2020-01-20 10:10:01 89 3 1002 9001 2020-02-01 12:11:01 2020-02-01 12:31:01 83 4 1003 9001 2020-03-01 19:01:01 2020-03-01 19:30:01 75 5 1004 9001 2020-03-01 12:01:01 2020-03-01 12:11:01 60 6 1003 9001 2020-03-01 12:01:01 2020-03-01 12:41:01 90 7 1002 9001 2020-05-02 19:01:01 2020-05-02 19:32:00 90 8 1001 9002 2020-01-02 19:01:01 2020-01-02 19:59:01 69 9 1004 9002 2020-02-02 12:01:01 2020-02-02 12:20:01 99 10 1003 9002 2020-02-02 12:01:01 2020-02-02 12:31:01 68 11 1001 9002 2020-02-02 12:01:01 2020-02-02 12:43:01 81 12 1001 9002 2020-03-02 12:11:01 (NULL) (NULL) 请输出每份试卷每月作答数和截止当月的作答总数。 由示例数据结果输出如下:\nexam_id start_month month_cnt cum_exam_cnt 9001 202001 2 2 9001 202002 1 3 9001 202003 3 6 9001 202005 1 7 9002 202001 1 1 9002 202002 3 4 9002 202003 1 5 解释:试卷 9001 在 202001、202002、202003、202005 共 4 个月有被作答记录,每个月被作答数分别为 2、1、3、1,截止当月累积作答总数为 2、3、6、7。\n思路:\n这题就两个关键点:统计截止当月的作答总数、输出每份试卷每月作答数和截止当月的作答总数\n这个是关键==sum(count(*)) over(partition by exam_id order by date_format(start_time,'%Y%m'))==\n答案:\nSELECT exam_id, date_format(start_time, \u0026#39;%Y%m\u0026#39;) AS start_month, count(*) AS month_cnt, sum(count(*)) OVER (PARTITION BY exam_id ORDER BY date_format(start_time, \u0026#39;%Y%m\u0026#39;)) AS cum_exam_cnt FROM exam_record GROUP BY exam_id, start_month 每月及截止当月的答题情况(较难) # 描述:现有试卷作答记录表 exam_record(uid 用户 ID, exam_id 试卷 ID, start_time 开始作答时间, submit_time 交卷时间, score 得分):\nid uid exam_id start_time submit_time score 1 1001 9001 2020-01-01 09:01:01 2020-01-01 09:21:59 90 2 1002 9001 2020-01-20 10:01:01 2020-01-20 10:10:01 89 3 1002 9001 2020-02-01 12:11:01 2020-02-01 12:31:01 83 4 1003 9001 2020-03-01 19:01:01 2020-03-01 19:30:01 75 5 1004 9001 2020-03-01 12:01:01 2020-03-01 12:11:01 60 6 1003 9001 2020-03-01 12:01:01 2020-03-01 12:41:01 90 7 1002 9001 2020-05-02 19:01:01 2020-05-02 19:32:00 90 8 1001 9002 2020-01-02 19:01:01 2020-01-02 19:59:01 69 9 1004 9002 2020-02-02 12:01:01 2020-02-02 12:20:01 99 10 1003 9002 2020-02-02 12:01:01 2020-02-02 12:31:01 68 11 1001 9002 2020-01-02 19:01:01 2020-02-02 12:43:01 81 12 1001 9002 2020-03-02 12:11:01 (NULL) (NULL) 请输出自从有用户作答记录以来,每月的试卷作答记录中月活用户数、新增用户数、截止当月的单月最大新增用户数、截止当月的累积用户数。结果按月份升序输出。\n由示例数据结果输出如下:\nstart_month mau month_add_uv max_month_add_uv cum_sum_uv 202001 2 2 2 2 202002 4 2 2 4 202003 3 0 2 4 202005 1 0 2 4 month 1001 1002 1003 1004 202001 1 1 202002 1 1 1 1 202003 1 1 1 202005 1 由上述矩阵可以看出,2020 年 1 月有 2 个用户活跃(mau=2),当月新增用户数为 2;\n2020 年 2 月有 4 个用户活跃,当月新增用户数为 2,最大单月新增用户数为 2,当前累积用户数为 4。\n思路:\n难点:\n1.如何求每月新增用户\n2.截至当月的答题情况\n大致流程:\n(1)统计每个人的首次登陆月份 min()\n(2)统计每月的月活和新增用户数:先得到每个人的首次登陆月份,再对首次登陆月份分组求和是该月份的新增人数\n(3)统计截止当月的单月最大新增用户数、截止当月的累积用户数 ,最终按照按月份升序输出\n答案:\n-- 截止当月的单月最大新增用户数、截止当月的累积用户数,按月份升序输出 SELECT start_month, mau, month_add_uv, max( month_add_uv ) over ( ORDER BY start_month ), sum( month_add_uv ) over ( ORDER BY start_month ) FROM ( -- 统计每月的月活和新增用户数 SELECT date_format( a.start_time, \u0026#39;%Y%m\u0026#39; ) AS start_month, count( DISTINCT a.uid ) AS mau, count( DISTINCT b.uid ) AS month_add_uv FROM exam_record a LEFT JOIN ( -- 统计每个人的首次登陆月份 SELECT uid, min( date_format( start_time, \u0026#39;%Y%m\u0026#39; )) AS first_month FROM exam_record GROUP BY uid ) b ON date_format( a.start_time, \u0026#39;%Y%m\u0026#39; ) = b.first_month GROUP BY start_month ) main ORDER BY start_month "},{"id":581,"href":"/zh/docs/technology/Interview/database/sql/sql-questions-05/","title":"SQL常见面试题总结(5)","section":"SQL","content":" 题目来源于: 牛客题霸 - SQL 进阶挑战\n较难或者困难的题目可以根据自身实际情况和面试需要来决定是否要跳过。\n空值处理 # 统计有未完成状态的试卷的未完成数和未完成率 # 描述:\n现有试卷作答记录表 exam_record(uid 用户 ID, exam_id 试卷 ID, start_time 开始作答时间, submit_time 交卷时间, score 得分),数据如下:\nid uid exam_id start_time submit_time score 1 1001 9001 2020-01-02 09:01:01 2020-01-02 09:21:01 80 2 1001 9001 2021-05-02 10:01:01 2021-05-02 10:30:01 81 3 1001 9001 2021-09-02 12:01:01 (NULL) (NULL) 请统计有未完成状态的试卷的未完成数 incomplete_cnt 和未完成率 incomplete_rate。由示例数据结果输出如下:\nexam_id incomplete_cnt complete_rate 9001 1 0.333 解释:试卷 9001 有 3 次被作答的记录,其中两次完成,1 次未完成,因此未完成数为 1,未完成率为 0.333(保留 3 位小数)\n思路:\n这题只需要注意一个是有条件限制,一个是没条件限制的;要么分别查询条件,然后合并;要么直接在 select 里面进行条件判断。\n答案:\n写法 1:\nSELECT exam_id, count(submit_time IS NULL OR NULL) incomplete_cnt, ROUND(count(submit_time IS NULL OR NULL) / count(*), 3) complete_rate FROM exam_record GROUP BY exam_id HAVING incomplete_cnt \u0026lt;\u0026gt; 0 写法 2:\nSELECT exam_id, count(submit_time IS NULL OR NULL) incomplete_cnt, ROUND(count(submit_time IS NULL OR NULL) / count(*), 3) complete_rate FROM exam_record GROUP BY exam_id HAVING incomplete_cnt \u0026lt;\u0026gt; 0 两种写法都可以,只有中间的写法不一样,一个是对符合条件的才COUNT,一个是直接上IF,后者更为直观,最后这个having解释一下, 无论是 complete_rate 还是 incomplete_cnt,只要不为 0 即可,不为 0 就意味着有未完成的。\n0 级用户高难度试卷的平均用时和平均得分 # 描述:\n现有用户信息表 user_info(uid 用户 ID,nick_name 昵称, achievement 成就值, level 等级, job 职业方向, register_time 注册时间),数据如下:\nid uid nick_name achievement level job register_time 1 1001 牛客 1 号 10 0 算法 2020-01-01 10:00:00 2 1002 牛客 2 号 2100 6 算法 2020-01-01 10:00:00 试卷信息表 examination_info(exam_id 试卷 ID, tag 试卷类别, difficulty 试卷难度, duration 考试时长, release_time 发布时间),数据如下:\nid exam_id tag difficulty duration release_time 1 9001 SQL hard 60 2020-01-01 10:00:00 2 9002 SQL easy 60 2020-01-01 10:00:00 3 9004 算法 medium 80 2020-01-01 10:00:00 试卷作答记录表 exam_record(uid 用户 ID, exam_id 试卷 ID, start_time 开始作答时间, submit_time 交卷时间, score 得分),数据如下:\nid uid exam_id start_time submit_time score 1 1001 9001 2020-01-02 09:01:01 2020-01-02 09:21:59 80 2 1001 9001 2021-05-02 10:01:01 (NULL) (NULL) 3 1001 9002 2021-02-02 19:01:01 2021-02-02 19:30:01 87 4 1001 9001 2021-06-02 19:01:01 2021-06-02 19:32:00 20 5 1001 9002 2021-09-05 19:01:01 2021-09-05 19:40:01 89 6 1001 9002 2021-09-01 12:01:01 (NULL) (NULL) 7 1002 9002 2021-05-05 18:01:01 2021-05-05 18:59:02 90 请输出每个 0 级用户所有的高难度试卷考试平均用时和平均得分,未完成的默认试卷最大考试时长和 0 分处理。由示例数据结果输出如下:\nuid avg_score avg_time_took 1001 33 36.7 解释:0 级用户有 1001,高难度试卷有 9001,1001 作答 9001 的记录有 3 条,分别用时 20 分钟、未完成(试卷时长 60 分钟)、30 分钟(未满 31 分钟),分别得分为 80 分、未完成(0 分处理)、20 分。因此他的平均用时为 110/3=36.7(保留一位小数),平均得分为 33 分(取整)\n思路:这题用IF是判断的最方便的,因为涉及到 NULL 值的判断。当然 case when也可以,大同小异。这题的难点就在于空值的处理,其他的这些查询条件什么的,我相信难不倒大家。\n答案:\nSELECT UID, round(avg(new_socre)) AS avg_score, round(avg(time_diff), 1) AS avg_time_took FROM (SELECT er.uid, IF (er.submit_time IS NOT NULL, TIMESTAMPDIFF(MINUTE, start_time, submit_time), ef.duration) AS time_diff, IF (er.submit_time IS NOT NULL,er.score,0) AS new_socre FROM exam_record er LEFT JOIN user_info uf ON er.uid = uf.uid LEFT JOIN examination_info ef ON er.exam_id = ef.exam_id WHERE uf.LEVEL = 0 AND ef.difficulty = \u0026#39;hard\u0026#39; ) t GROUP BY UID ORDER BY UID 高级条件语句 # 筛选限定昵称成就值活跃日期的用户(较难) # 描述:\n现有用户信息表 user_info(uid 用户 ID,nick_name 昵称, achievement 成就值, level 等级, job 职业方向, register_time 注册时间):\nid uid nick_name achievement level job register_time 1 1001 牛客 1 号 1000 2 算法 2020-01-01 10:00:00 2 1002 牛客 2 号 1200 3 算法 2020-01-01 10:00:00 3 1003 进击的 3 号 2200 5 算法 2020-01-01 10:00:00 4 1004 牛客 4 号 2500 6 算法 2020-01-01 10:00:00 5 1005 牛客 5 号 3000 7 C++ 2020-01-01 10:00:00 试卷作答记录表 exam_record(uid 用户 ID, exam_id 试卷 ID, start_time 开始作答时间, submit_time 交卷时间, score 得分):\nid uid exam_id start_time submit_time score 1 1001 9001 2020-01-02 09:01:01 2020-01-02 09:21:59 80 3 1001 9002 2021-02-02 19:01:01 2021-02-02 19:30:01 87 2 1001 9001 2021-05-02 10:01:01 (NULL) (NULL) 4 1001 9001 2021-06-02 19:01:01 2021-06-02 19:32:00 20 6 1001 9002 2021-09-01 12:01:01 (NULL) (NULL) 5 1001 9002 2021-09-05 19:01:01 2021-09-05 19:40:01 89 11 1002 9001 2020-01-01 12:01:01 2020-01-01 12:31:01 81 12 1002 9002 2020-02-01 12:01:01 2020-02-01 12:31:01 82 13 1002 9002 2020-02-02 12:11:01 2020-02-02 12:31:01 83 7 1002 9002 2021-05-05 18:01:01 2021-05-05 18:59:02 90 16 1002 9001 2021-09-06 12:01:01 2021-09-06 12:21:01 80 17 1002 9001 2021-09-06 12:01:01 (NULL) (NULL) 18 1002 9001 2021-09-07 12:01:01 (NULL) (NULL) 8 1003 9003 2021-02-06 12:01:01 (NULL) (NULL) 9 1003 9001 2021-09-07 10:01:01 2021-09-07 10:31:01 89 10 1004 9002 2021-08-06 12:01:01 (NULL) (NULL) 14 1005 9001 2021-02-01 11:01:01 2021-02-01 11:31:01 84 15 1006 9001 2021-02-01 11:01:01 2021-02-01 11:31:01 84 题目练习记录表 practice_record(uid 用户 ID, question_id 题目 ID, submit_time 提交时间, score 得分):\nid uid question_id submit_time score 1 1001 8001 2021-08-02 11:41:01 60 2 1002 8001 2021-09-02 19:30:01 50 3 1002 8001 2021-09-02 19:20:01 70 4 1002 8002 2021-09-02 19:38:01 70 5 1003 8002 2021-09-01 19:38:01 80 请找到昵称以『牛客』开头『号』结尾、成就值在 1200~2500 之间,且最近一次活跃(答题或作答试卷)在 2021 年 9 月的用户信息。\n由示例数据结果输出如下:\nuid nick_name achievement 1002 牛客 2 号 1200 解释:昵称以『牛客』开头『号』结尾且成就值在 1200~2500 之间的有 1002、1004;\n1002 最近一次试卷区活跃为 2021 年 9 月,最近一次题目区活跃为 2021 年 9 月;1004 最近一次试卷区活跃为 2021 年 8 月,题目区未活跃。\n因此最终满足条件的只有 1002。\n思路:\n先根据条件列出主要查询语句\n昵称以『牛客』开头『号』结尾: nick_name LIKE \u0026quot;牛客%号\u0026quot;\n成就值在 1200~2500 之间:achievement BETWEEN 1200 AND 2500\n第三个条件因为限定了为 9 月,所以直接写就行:( date_format( record.submit_time, '%Y%m' )= 202109 OR date_format( pr.submit_time, '%Y%m' )= 202109 )\n答案:\nSELECT DISTINCT u_info.uid, u_info.nick_name, u_info.achievement FROM user_info u_info LEFT JOIN exam_record record ON record.uid = u_info.uid LEFT JOIN practice_record pr ON u_info.uid = pr.uid WHERE u_info.nick_name LIKE \u0026#34;牛客%号\u0026#34; AND u_info.achievement BETWEEN 1200 AND 2500 AND (date_format(record.submit_time, \u0026#39;%Y%m\u0026#39;)= 202109 OR date_format(pr.submit_time, \u0026#39;%Y%m\u0026#39;)= 202109) GROUP BY u_info.uid 筛选昵称规则和试卷规则的作答记录(较难) # 描述:\n现有用户信息表 user_info(uid 用户 ID,nick_name 昵称, achievement 成就值, level 等级, job 职业方向, register_time 注册时间):\nid uid nick_name achievement level job register_time 1 1001 牛客 1 号 1900 2 算法 2020-01-01 10:00:00 2 1002 牛客 2 号 1200 3 算法 2020-01-01 10:00:00 3 1003 牛客 3 号 ♂ 2200 5 算法 2020-01-01 10:00:00 4 1004 牛客 4 号 2500 6 算法 2020-01-01 10:00:00 5 1005 牛客 555 号 2000 7 C++ 2020-01-01 10:00:00 6 1006 666666 3000 6 C++ 2020-01-01 10:00:00 试卷信息表 examination_info(exam_id 试卷 ID, tag 试卷类别, difficulty 试卷难度, duration 考试时长, release_time 发布时间):\nid exam_id tag difficulty duration release_time 1 9001 C++ hard 60 2020-01-01 10:00:00 2 9002 c# hard 80 2020-01-01 10:00:00 3 9003 SQL medium 70 2020-01-01 10:00:00 试卷作答记录表 exam_record(uid 用户 ID, exam_id 试卷 ID, start_time 开始作答时间, submit_time 交卷时间, score 得分):\nid uid exam_id start_time submit_time score 1 1001 9001 2020-01-02 09:01:01 2020-01-02 09:21:59 80 2 1001 9001 2021-05-02 10:01:01 (NULL) (NULL) 4 1001 9001 2021-06-02 19:01:01 2021-06-02 19:32:00 20 3 1001 9002 2021-02-02 19:01:01 2021-02-02 19:30:01 87 5 1001 9002 2021-09-05 19:01:01 2021-09-05 19:40:01 89 6 1001 9002 2021-09-01 12:01:01 (NULL) (NULL) 11 1002 9001 2020-01-01 12:01:01 2020-01-01 12:31:01 81 16 1002 9001 2021-09-06 12:01:01 2021-09-06 12:21:01 80 17 1002 9001 2021-09-06 12:01:01 (NULL) (NULL) 18 1002 9001 2021-09-07 12:01:01 (NULL) (NULL) 7 1002 9002 2021-05-05 18:01:01 2021-05-05 18:59:02 90 12 1002 9002 2020-02-01 12:01:01 2020-02-01 12:31:01 82 13 1002 9002 2020-02-02 12:11:01 2020-02-02 12:31:01 83 9 1003 9001 2021-09-07 10:01:01 2021-09-07 10:31:01 89 8 1003 9003 2021-02-06 12:01:01 (NULL) (NULL) 10 1004 9002 2021-08-06 12:01:01 (NULL) (NULL) 14 1005 9001 2021-02-01 11:01:01 2021-02-01 11:31:01 84 15 1006 9001 2021-02-01 11:01:01 2021-09-01 11:31:01 84 找到昵称以\u0026quot;牛客\u0026quot;+纯数字+\u0026ldquo;号\u0026quot;或者纯数字组成的用户对于字母 c 开头的试卷类别(如 C,C++,c#等)的已完成的试卷 ID 和平均得分,按用户 ID、平均分升序排序。由示例数据结果输出如下:\nuid exam_id avg_score 1002 9001 81 1002 9002 85 1005 9001 84 1006 9001 84 解释:昵称满足条件的用户有 1002、1004、1005、1006;\nc 开头的试卷有 9001、9002;\n满足上述条件的作答记录中,1002 完成 9001 的得分有 81、80,平均分为 81(80.5 取整四舍五入得 81);\n1002 完成 9002 的得分有 90、82、83,平均分为 85;\n思路:\n还是老样子,既然给出了条件,就先把各个条件先写出来\n找到昵称以\u0026quot;牛客\u0026rdquo;+纯数字+\u0026ldquo;号\u0026quot;或者纯数字组成的用户: 我最开始是这么写的:nick_name LIKE '牛客%号' OR nick_name REGEXP '^[0-9]+$',如果表中有个 “牛客 H 号” ,那也能通过。\n所以这里还得用正则: nick_name LIKE '^牛客[0-9]+号'\n对于字母 c 开头的试卷类别: e_info.tag LIKE 'c%' 或者 tag regexp '^c|^C' 第一个也能匹配到大写 C\n答案:\nSELECT UID, exam_id, ROUND(AVG(score), 0) avg_score FROM exam_record WHERE UID IN (SELECT UID FROM user_info WHERE nick_name RLIKE \u0026#34;^牛客[0-9]+号 $\u0026#34; OR nick_name RLIKE \u0026#34;^[0-9]+$\u0026#34;) AND exam_id IN (SELECT exam_id FROM examination_info WHERE tag RLIKE \u0026#34;^[cC]\u0026#34;) AND score IS NOT NULL GROUP BY UID,exam_id ORDER BY UID,avg_score; 根据指定记录是否存在输出不同情况(困难) # 描述:\n现有用户信息表 user_info(uid 用户 ID,nick_name 昵称, achievement 成就值, level 等级, job 职业方向, register_time 注册时间):\nid uid nick_name achievement level job register_time 1 1001 牛客 1 号 19 0 算法 2020-01-01 10:00:00 2 1002 牛客 2 号 1200 3 算法 2020-01-01 10:00:00 3 1003 进击的 3 号 22 0 算法 2020-01-01 10:00:00 4 1004 牛客 4 号 25 0 算法 2020-01-01 10:00:00 5 1005 牛客 555 号 2000 7 C++ 2020-01-01 10:00:00 6 1006 666666 3000 6 C++ 2020-01-01 10:00:00 试卷作答记录表 exam_record(uid 用户 ID, exam_id 试卷 ID, start_time 开始作答时间, submit_time 交卷时间, score 得分):\nid uid exam_id start_time submit_time score 1 1001 9001 2020-01-02 09:01:01 2020-01-02 09:21:59 80 2 1001 9001 2021-05-02 10:01:01 (NULL) (NULL) 3 1001 9002 2021-02-02 19:01:01 2021-02-02 19:30:01 87 4 1001 9002 2021-09-01 12:01:01 (NULL) (NULL) 5 1001 9003 2021-09-02 12:01:01 (NULL) (NULL) 6 1001 9004 2021-09-03 12:01:01 (NULL) (NULL) 7 1002 9001 2020-01-01 12:01:01 2020-01-01 12:31:01 99 8 1002 9003 2020-02-01 12:01:01 2020-02-01 12:31:01 82 9 1002 9003 2020-02-02 12:11:01 (NULL) (NULL) 10 1002 9002 2021-05-05 18:01:01 (NULL) (NULL) 11 1002 9001 2021-09-06 12:01:01 (NULL) (NULL) 12 1003 9003 2021-02-06 12:01:01 (NULL) (NULL) 13 1003 9001 2021-09-07 10:01:01 2021-09-07 10:31:01 89 请你筛选表中的数据,当有任意一个 0 级用户未完成试卷数大于 2 时,输出每个 0 级用户的试卷未完成数和未完成率(保留 3 位小数);若不存在这样的用户,则输出所有有作答记录的用户的这两个指标。结果按未完成率升序排序。\n由示例数据结果输出如下:\nuid incomplete_cnt incomplete_rate 1004 0 0.000 1003 1 0.500 1001 4 0.667 解释:0 级用户有 1001、1003、1004;他们作答试卷数和未完成数分别为:6:4、2:1、0:0;\n存在 1001 这个 0 级用户未完成试卷数大于 2,因此输出这三个用户的未完成数和未完成率(1004 未作答过试卷,未完成率默认填 0,保留 3 位小数后是 0.000);\n结果按照未完成率升序排序。\n附:如果 1001 不满足『未完成试卷数大于 2』,则需要输出 1001、1002、1003 的这两个指标,因为试卷作答记录表里只有这三个用户的作答记录。\n思路:\n先把可能满足条件==“0 级用户未完成试卷数大于 2”==的 SQL 写出来\nSELECT ui.uid UID FROM user_info ui LEFT JOIN exam_record er ON ui.uid = er.uid WHERE ui.uid IN (SELECT ui.uid FROM user_info ui LEFT JOIN exam_record er ON ui.uid = er.uid WHERE er.submit_time IS NULL AND ui.LEVEL = 0 ) GROUP BY ui.uid HAVING sum(IF(er.submit_time IS NULL, 1, 0)) \u0026gt; 2 然后再分别写出两种情况的 SQL 查询语句:\n情况 1. 查询存在条件要求的 0 级用户的试卷未完成率\nSELECT tmp1.uid uid, sum( IF ( er.submit_time IS NULL AND er.start_time IS NOT NULL, 1, 0 )) incomplete_cnt, round( sum( IF ( er.submit_time IS NULL AND er.start_time IS NOT NULL, 1, 0 ))/ count( tmp1.uid ), 3 ) incomplete_rate FROM ( SELECT DISTINCT ui.uid FROM user_info ui LEFT JOIN exam_record er ON ui.uid = er.uid WHERE er.submit_time IS NULL AND ui.LEVEL = 0 ) tmp1 LEFT JOIN exam_record er ON tmp1.uid = er.uid GROUP BY tmp1.uid ORDER BY incomplete_rate 情况 2. 查询不存在条件要求时所有有作答记录的 yong 用户的试卷未完成率\nSELECT ui.uid uid, sum( CASE WHEN er.submit_time IS NULL AND er.start_time IS NOT NULL THEN 1 ELSE 0 END ) incomplete_cnt, round( sum( IF ( er.submit_time IS NULL AND er.start_time IS NOT NULL, 1, 0 ))/ count( ui.uid ), 3 ) incomplete_rate FROM user_info ui JOIN exam_record er ON ui.uid = er.uid GROUP BY ui.uid ORDER BY incomplete_rate 拼在一起,就是答案\nWITH host_user AS (SELECT ui.uid UID FROM user_info ui LEFT JOIN exam_record er ON ui.uid = er.uid WHERE ui.uid IN (SELECT ui.uid FROM user_info ui LEFT JOIN exam_record er ON ui.uid = er.uid WHERE er.submit_time IS NULL AND ui.LEVEL = 0 ) GROUP BY ui.uid HAVING sum(IF (er.submit_time IS NULL, 1, 0))\u0026gt; 2), tt1 AS (SELECT tmp1.uid UID, sum(IF (er.submit_time IS NULL AND er.start_time IS NOT NULL, 1, 0)) incomplete_cnt, round(sum(IF (er.submit_time IS NULL AND er.start_time IS NOT NULL, 1, 0))/ count(tmp1.uid), 3) incomplete_rate FROM (SELECT DISTINCT ui.uid FROM user_info ui LEFT JOIN exam_record er ON ui.uid = er.uid WHERE er.submit_time IS NULL AND ui.LEVEL = 0 ) tmp1 LEFT JOIN exam_record er ON tmp1.uid = er.uid GROUP BY tmp1.uid ORDER BY incomplete_rate), tt2 AS (SELECT ui.uid UID, sum(CASE WHEN er.submit_time IS NULL AND er.start_time IS NOT NULL THEN 1 ELSE 0 END) incomplete_cnt, round(sum(IF (er.submit_time IS NULL AND er.start_time IS NOT NULL, 1, 0))/ count(ui.uid), 3) incomplete_rate FROM user_info ui JOIN exam_record er ON ui.uid = er.uid GROUP BY ui.uid ORDER BY incomplete_rate) (SELECT tt1.* FROM tt1 LEFT JOIN (SELECT UID FROM host_user) t1 ON 1 = 1 WHERE t1.uid IS NOT NULL ) UNION ALL (SELECT tt2.* FROM tt2 LEFT JOIN (SELECT UID FROM host_user) t2 ON 1 = 1 WHERE t2.uid IS NULL) V2 版本(根据上面做出的改进,答案缩短了,逻辑更强):\nSELECT ui.uid, SUM( IF ( start_time IS NOT NULL AND score IS NULL, 1, 0 )) AS incomplete_cnt,#3.试卷未完成数 ROUND( AVG( IF ( start_time IS NOT NULL AND score IS NULL, 1, 0 )), 3 ) AS incomplete_rate #4.未完成率 FROM user_info ui LEFT JOIN exam_record USING ( uid ) WHERE CASE WHEN (#1.当有任意一个0级用户未完成试卷数大于2时 SELECT MAX( lv0_incom_cnt ) FROM ( SELECT SUM( IF ( score IS NULL, 1, 0 )) AS lv0_incom_cnt FROM user_info JOIN exam_record USING ( uid ) WHERE LEVEL = 0 GROUP BY uid ) table1 )\u0026gt; 2 THEN uid IN ( #1.1找出每个0级用户 SELECT uid FROM user_info WHERE LEVEL = 0 ) ELSE uid IN ( #2.若不存在这样的用户,找出有作答记录的用户 SELECT DISTINCT uid FROM exam_record ) END GROUP BY ui.uid ORDER BY incomplete_rate #5.结果按未完成率升序排序 各用户等级的不同得分表现占比(较难) # 描述:\n现有用户信息表 user_info(uid 用户 ID,nick_name 昵称, achievement 成就值, level 等级, job 职业方向, register_time 注册时间):\nid uid nick_name achievement level job register_time 1 1001 牛客 1 号 19 0 算法 2020-01-01 10:00:00 2 1002 牛客 2 号 1200 3 算法 2020-01-01 10:00:00 3 1003 牛客 3 号 ♂ 22 0 算法 2020-01-01 10:00:00 4 1004 牛客 4 号 25 0 算法 2020-01-01 10:00:00 5 1005 牛客 555 号 2000 7 C++ 2020-01-01 10:00:00 6 1006 666666 3000 6 C++ 2020-01-01 10:00:00 试卷作答记录表 exam_record(uid 用户 ID, exam_id 试卷 ID, start_time 开始作答时间, submit_time 交卷时间, score 得分):\nid uid exam_id start_time submit_time score 1 1001 9001 2020-01-02 09:01:01 2020-01-02 09:21:59 80 2 1001 9001 2021-05-02 10:01:01 (NULL) (NULL) 3 1001 9002 2021-02-02 19:01:01 2021-02-02 19:30:01 75 4 1001 9002 2021-09-01 12:01:01 2021-09-01 12:11:01 60 5 1001 9003 2021-09-02 12:01:01 2021-09-02 12:41:01 90 6 1001 9001 2021-06-02 19:01:01 2021-06-02 19:32:00 20 7 1001 9002 2021-09-05 19:01:01 2021-09-05 19:40:01 89 8 1001 9004 2021-09-03 12:01:01 (NULL) (NULL) 9 1002 9001 2020-01-01 12:01:01 2020-01-01 12:31:01 99 10 1002 9003 2020-02-01 12:01:01 2020-02-01 12:31:01 82 11 1002 9003 2020-02-02 12:11:01 2020-02-02 12:41:01 76 为了得到用户试卷作答的定性表现,我们将试卷得分按分界点[90,75,60]分为优良中差四个得分等级(分界点划分到左区间),请统计不同用户等级的人在完成过的试卷中各得分等级占比(结果保留 3 位小数),未完成过试卷的用户无需输出,结果按用户等级降序、占比降序排序。\n由示例数据结果输出如下:\nlevel score_grade ratio 3 良 0.667 3 优 0.333 0 良 0.500 0 中 0.167 0 优 0.167 0 差 0.167 解释:完成过试卷的用户有 1001、1002;完成了的试卷对应的用户等级和分数等级如下:\nuid exam_id score level score_grade 1001 9001 80 0 良 1001 9002 75 0 良 1001 9002 60 0 中 1001 9003 90 0 优 1001 9001 20 0 差 1001 9002 89 0 良 1002 9001 99 3 优 1002 9003 82 3 良 1002 9003 76 3 良 因此 0 级用户(只有 1001)的各分数等级比例为:优 1/6,良 1/6,中 1/6,差 3/6;3 级用户(只有 1002)各分数等级比例为:优 1/3,良 2/3。结果保留 3 位小数。\n思路:\n先把 ==“将试卷得分按分界点[90,75,60]分为优良中差四个得分等级”==这个条件写出来,这里可以用到case when\nCASE WHEN a.score \u0026gt;= 90 THEN \u0026#39;优\u0026#39; WHEN a.score \u0026lt; 90 AND a.score \u0026gt;= 75 THEN \u0026#39;良\u0026#39; WHEN a.score \u0026lt; 75 AND a.score \u0026gt;= 60 THEN \u0026#39;中\u0026#39; ELSE \u0026#39;差\u0026#39; END 这题的关键点就在于这,其他剩下的就是条件拼接了\n答案:\nSELECT a.LEVEL, a.score_grade, ROUND(a.cur_count / b.total_num, 3) AS ratio FROM (SELECT b.LEVEL AS LEVEL, (CASE WHEN a.score \u0026gt;= 90 THEN \u0026#39;优\u0026#39; WHEN a.score \u0026lt; 90 AND a.score \u0026gt;= 75 THEN \u0026#39;良\u0026#39; WHEN a.score \u0026lt; 75 AND a.score \u0026gt;= 60 THEN \u0026#39;中\u0026#39; ELSE \u0026#39;差\u0026#39; END) AS score_grade, count(1) AS cur_count FROM exam_record a LEFT JOIN user_info b ON a.uid = b.uid WHERE a.submit_time IS NOT NULL GROUP BY b.LEVEL, score_grade) a LEFT JOIN (SELECT b.LEVEL AS LEVEL, count(b.LEVEL) AS total_num FROM exam_record a LEFT JOIN user_info b ON a.uid = b.uid WHERE a.submit_time IS NOT NULL GROUP BY b.LEVEL) b ON a.LEVEL = b.LEVEL ORDER BY a.LEVEL DESC, ratio DESC 限量查询 # 注册时间最早的三个人 # 描述:\n现有用户信息表 user_info(uid 用户 ID,nick_name 昵称, achievement 成就值, level 等级, job 职业方向, register_time 注册时间):\nid uid nick_name achievement level job register_time 1 1001 牛客 1 号 19 0 算法 2020-01-01 10:00:00 2 1002 牛客 2 号 1200 3 算法 2020-02-01 10:00:00 3 1003 牛客 3 号 ♂ 22 0 算法 2020-01-02 10:00:00 4 1004 牛客 4 号 25 0 算法 2020-01-02 11:00:00 5 1005 牛客 555 号 4000 7 C++ 2020-01-11 10:00:00 6 1006 666666 3000 6 C++ 2020-11-01 10:00:00 请从中找到注册时间最早的 3 个人。由示例数据结果输出如下:\nuid nick_name register_time 1001 牛客 1 2020-01-01 10:00:00 1003 牛客 3 号 ♂ 2020-01-02 10:00:00 1004 牛客 4 号 2020-01-02 11:00:00 解释:按注册时间排序后选取前三名,输出其用户 ID、昵称、注册时间。\n答案:\nSELECT uid, nick_name, register_time FROM user_info ORDER BY register_time LIMIT 3 注册当天就完成了试卷的名单第三页(较难) # 描述:现有用户信息表 user_info(uid 用户 ID,nick_name 昵称, achievement 成就值, level 等级, job 职业方向, register_time 注册时间):\nid uid nick_name achievement level job register_time 1 1001 牛客 1 19 0 算法 2020-01-01 10:00:00 2 1002 牛客 2 号 1200 3 算法 2020-01-01 10:00:00 3 1003 牛客 3 号 ♂ 22 0 算法 2020-01-01 10:00:00 4 1004 牛客 4 号 25 0 算法 2020-01-01 10:00:00 5 1005 牛客 555 号 4000 7 算法 2020-01-11 10:00:00 6 1006 牛客 6 号 25 0 算法 2020-01-02 11:00:00 7 1007 牛客 7 号 25 0 算法 2020-01-02 11:00:00 8 1008 牛客 8 号 25 0 算法 2020-01-02 11:00:00 9 1009 牛客 9 号 25 0 算法 2020-01-02 11:00:00 10 1010 牛客 10 号 25 0 算法 2020-01-02 11:00:00 11 1011 666666 3000 6 C++ 2020-01-02 10:00:00 试卷信息表 examination_info(exam_id 试卷 ID, tag 试卷类别, difficulty 试卷难度, duration 考试时长, release_time 发布时间):\nid exam_id tag difficulty duration release_time 1 9001 算法 hard 60 2020-01-01 10:00:00 2 9002 算法 hard 80 2020-01-01 10:00:00 3 9003 SQL medium 70 2020-01-01 10:00:00 试卷作答记录表 exam_record(uid 用户 ID, exam_id 试卷 ID, start_time 开始作答时间, submit_time 交卷时间, score 得分):\nid uid exam_id start_time submit_time score 1 1001 9001 2020-01-02 09:01:01 2020-01-02 09:21:59 80 2 1002 9003 2020-01-20 10:01:01 2020-01-20 10:10:01 81 3 1002 9002 2020-01-01 12:11:01 2020-01-01 12:31:01 83 4 1003 9002 2020-01-01 19:01:01 2020-01-01 19:30:01 75 5 1004 9002 2020-01-01 12:01:01 2020-01-01 12:11:01 60 6 1005 9002 2020-01-01 12:01:01 2020-01-01 12:41:01 90 7 1006 9001 2020-01-02 19:01:01 2020-01-02 19:32:00 20 8 1007 9002 2020-01-02 19:01:01 2020-01-02 19:40:01 89 9 1008 9003 2020-01-02 12:01:01 2020-01-02 12:20:01 99 10 1008 9001 2020-01-02 12:01:01 2020-01-02 12:31:01 98 11 1009 9002 2020-01-02 12:01:01 2020-01-02 12:31:01 82 12 1010 9002 2020-01-02 12:11:01 2020-01-02 12:41:01 76 13 1011 9001 2020-01-02 10:01:01 2020-01-02 10:31:01 89 找到求职方向为算法工程师,且注册当天就完成了算法类试卷的人,按参加过的所有考试最高得分排名。排名榜很长,我们将采用分页展示,每页 3 条,现在需要你取出第 3 页(页码从 1 开始)的人的信息。\n由示例数据结果输出如下:\nuid level register_time max_score 1010 0 2020-01-02 11:00:00 76 1003 0 2020-01-01 10:00:00 75 1004 0 2020-01-01 11:00:00 60 解释:除了 1011 其他用户的求职方向都为算法工程师;算法类试卷有 9001 和 9002,11 个用户注册当天都完成了算法类试卷;计算他们的所有考试最大分时,只有 1002 和 1008 完成了两次考试,其他人只完成了一场考试,1002 两场考试最高分为 81,1008 最高分为 99。\n按最高分排名如下:\nuid level register_time max_score 1008 0 2020-01-02 11:00:00 99 1005 7 2020-01-01 10:00:00 90 1007 0 2020-01-02 11:00:00 89 1002 3 2020-01-01 10:00:00 83 1009 0 2020-01-02 11:00:00 82 1001 0 2020-01-01 10:00:00 80 1010 0 2020-01-02 11:00:00 76 1003 0 2020-01-01 10:00:00 75 1004 0 2020-01-01 11:00:00 60 1006 0 2020-01-02 11:00:00 20 每页 3 条,第三页也就是第 7~9 条,返回 1010、1003、1004 的行记录即可。\n思路:\n每页三条,即需要取出第三页的人的信息,要用到limit\n统计求职方向为算法工程师且注册当天就完成了算法类试卷的人的信息和每次记录的得分,先求满足条件的用户,后用 left join 做连接查找信息和每次记录的得分\n答案:\nSELECT t1.uid, LEVEL, register_time, max(score) AS max_score FROM exam_record t JOIN examination_info USING (exam_id) JOIN user_info t1 ON t.uid = t1.uid AND date(t.submit_time) = date(t1.register_time) WHERE job = \u0026#39;算法\u0026#39; AND tag = \u0026#39;算法\u0026#39; GROUP BY t1.uid, LEVEL, register_time ORDER BY max_score DESC LIMIT 6,3 文本转换函数 # 修复串列了的记录 # 描述:现有试卷信息表 examination_info(exam_id 试卷 ID, tag 试卷类别, difficulty 试卷难度, duration 考试时长, release_time 发布时间):\nid exam_id tag difficulty duration release_time 1 9001 算法 hard 60 2021-01-01 10:00:00 2 9002 算法 hard 80 2021-01-01 10:00:00 3 9003 SQL medium 70 2021-01-01 10:00:00 4 9004 算法,medium,80 0 2021-01-01 10:00:00 录题同学有一次手误将部分记录的试题类别 tag、难度、时长同时录入到了 tag 字段,请帮忙找出这些录错了的记录,并拆分后按正确的列类型输出。\n由示例数据结果输出如下:\nexam_id tag difficulty duration 9004 算法 medium 80 思路:\n先来学习下本题要用到的函数\nSUBSTRING_INDEX 函数用于提取字符串中指定分隔符的部分。它接受三个参数:原始字符串、分隔符和指定要返回的部分的数量。\n以下是 SUBSTRING_INDEX 函数的语法:\nSUBSTRING_INDEX(str, delimiter, count) str:要进行分割的原始字符串。 delimiter:用作分割的字符串或字符。 count:指定要返回的部分的数量。 如果 count 大于 0,则返回从左边开始的前 count 个部分(以分隔符为界)。 如果 count 小于 0,则返回从右边开始的前 count 个部分(以分隔符为界),即从右侧向左计数。 下面是一些示例,演示了 SUBSTRING_INDEX 函数的使用:\n提取字符串中的第一个部分:\nSELECT SUBSTRING_INDEX(\u0026#39;apple,banana,cherry\u0026#39;, \u0026#39;,\u0026#39;, 1); -- 输出结果:\u0026#39;apple\u0026#39; 提取字符串中的最后一个部分:\nSELECT SUBSTRING_INDEX(\u0026#39;apple,banana,cherry\u0026#39;, \u0026#39;,\u0026#39;, -1); -- 输出结果:\u0026#39;cherry\u0026#39; 提取字符串中的前两个部分:\nSELECT SUBSTRING_INDEX(\u0026#39;apple,banana,cherry\u0026#39;, \u0026#39;,\u0026#39;, 2); -- 输出结果:\u0026#39;apple,banana\u0026#39; 提取字符串中的最后两个部分:\nSELECT SUBSTRING_INDEX(\u0026#39;apple,banana,cherry\u0026#39;, \u0026#39;,\u0026#39;, -2); -- 输出结果:\u0026#39;banana,cherry\u0026#39; 答案:\nSELECT exam_id, substring_index( tag, \u0026#39;,\u0026#39;, 1 ) tag, substring_index( substring_index( tag, \u0026#39;,\u0026#39;, 2 ), \u0026#39;,\u0026#39;,- 1 ) difficulty, substring_index( tag, \u0026#39;,\u0026#39;,- 1 ) duration FROM examination_info WHERE difficulty = \u0026#39;\u0026#39; 对过长的昵称截取处理 # 描述:现有用户信息表 user_info(uid 用户 ID,nick_name 昵称, achievement 成就值, level 等级, job 职业方向, register_time 注册时间):\nid uid nick_name achievement level job register_time 1 1001 牛客 1 19 0 算法 2020-01-01 10:00:00 2 1002 牛客 2 号 1200 3 算法 2020-01-01 10:00:00 3 1003 牛客 3 号 ♂ 22 0 算法 2020-01-01 10:00:00 4 1004 牛客 4 号 25 0 算法 2020-01-01 11:00:00 5 1005 牛客 5678901234 号 4000 7 算法 2020-01-11 10:00:00 6 1006 牛客 67890123456789 号 25 0 算法 2020-01-02 11:00:00 有的用户的昵称特别长,在一些展示场景会导致样式混乱,因此需要将特别长的昵称转换一下再输出,请输出字符数大于 10 的用户信息,对于字符数大于 13 的用户输出前 10 个字符然后加上三个点号:『\u0026hellip;』。\n由示例数据结果输出如下:\nuid nick_name 1005 牛客 5678901234 号 1006 牛客 67890123\u0026hellip; 解释:字符数大于 10 的用户有 1005 和 1006,长度分别为 13、17;因此需要对 1006 的昵称截断输出。\n思路:\n这题涉及到字符的计算,要计算字符串的字符数(即字符串的长度),可以使用 LENGTH 函数或 CHAR_LENGTH 函数。这两个函数的区别在于对待多字节字符的方式。\nLENGTH 函数:它返回给定字符串的字节数。对于包含多字节字符的字符串,每个字符都会被当作一个字节来计算。 示例:\nSELECT LENGTH(\u0026#39;你好\u0026#39;); -- 输出结果:6,因为 \u0026#39;你好\u0026#39; 中的每个汉字每个占3个字节 CHAR_LENGTH 函数:它返回给定字符串的字符数。对于包含多字节字符的字符串,每个字符会被当作一个字符来计算。 示例:\nSELECT CHAR_LENGTH(\u0026#39;你好\u0026#39;); -- 输出结果:2,因为 \u0026#39;你好\u0026#39; 中有两个字符,即两个汉字 答案:\nSELECT uid, CASE WHEN CHAR_LENGTH( nick_name ) \u0026gt; 13 THEN CONCAT( SUBSTR( nick_name, 1, 10 ), \u0026#39;...\u0026#39; ) ELSE nick_name END AS nick_name FROM user_info WHERE CHAR_LENGTH( nick_name ) \u0026gt; 10 GROUP BY uid; 大小写混乱时的筛选统计(较难) # 描述:\n现有试卷信息表 examination_info(exam_id 试卷 ID, tag 试卷类别, difficulty 试卷难度, duration 考试时长, release_time 发布时间):\nid exam_id tag difficulty duration release_time 1 9001 算法 hard 60 2021-01-01 10:00:00 2 9002 C++ hard 80 2021-01-01 10:00:00 3 9003 C++ hard 80 2021-01-01 10:00:00 4 9004 sql medium 70 2021-01-01 10:00:00 5 9005 C++ hard 80 2021-01-01 10:00:00 6 9006 C++ hard 80 2021-01-01 10:00:00 7 9007 C++ hard 80 2021-01-01 10:00:00 8 9008 SQL medium 70 2021-01-01 10:00:00 9 9009 SQL medium 70 2021-01-01 10:00:00 10 9010 SQL medium 70 2021-01-01 10:00:00 试卷作答信息表 exam_record(uid 用户 ID, exam_id 试卷 ID, start_time 开始作答时间, submit_time 交卷时间, score 得分):\nid uid exam_id start_time submit_time score 1 1001 9001 2020-01-01 09:01:01 2020-01-01 09:21:59 80 2 1002 9003 2020-01-20 10:01:01 2020-01-20 10:10:01 81 3 1002 9002 2020-02-01 12:11:01 2020-02-01 12:31:01 83 4 1003 9002 2020-03-01 19:01:01 2020-03-01 19:30:01 75 5 1004 9002 2020-03-01 12:01:01 2020-03-01 12:11:01 60 6 1005 9002 2020-03-01 12:01:01 2020-03-01 12:41:01 90 7 1006 9001 2020-05-02 19:01:01 2020-05-02 19:32:00 20 8 1007 9003 2020-01-02 19:01:01 2020-01-02 19:40:01 89 9 1008 9004 2020-02-02 12:01:01 2020-02-02 12:20:01 99 10 1008 9001 2020-02-02 12:01:01 2020-02-02 12:31:01 98 11 1009 9002 2020-02-02 12:01:01 2020-01-02 12:43:01 81 12 1010 9001 2020-01-02 12:11:01 (NULL) (NULL) 13 1010 9001 2020-02-02 12:01:01 2020-01-02 10:31:01 89 试卷的类别 tag 可能出现大小写混乱的情况,请先筛选出试卷作答数小于 3 的类别 tag,统计将其转换为大写后对应的原本试卷作答数。\n如果转换后 tag 并没有发生变化,不输出该条结果。\n由示例数据结果输出如下:\ntag answer_cnt C++ 6 解释:被作答过的试卷有 9001、9002、9003、9004,他们的 tag 和被作答次数如下:\nexam_id tag answer_cnt 9001 算法 4 9002 C++ 6 9003 c++ 2 9004 sql 2 作答次数小于 3 的 tag 有 c和 sql,而转为大写后只有 C本来就有作答数,于是输出 c++转化大写后的作答次数为 6。\n思路:\n首先,这题有点混乱,9004 根据示例数据查出来只有 1 次,这里显示有 2 次。\n先看一下大小写转换函数:\n1.UPPER(s)或UCASE(s)函数可以将字符串 s 中的字母字符全部转换成大写字母;\n2.LOWER(s)或者LCASE(s)函数可以将字符串 s 中的字母字符全部转换成小写字母。\n难点在于相同表做连接要查询不同的值\n答案:\nWITH a AS (SELECT tag, COUNT(start_time) AS answer_cnt FROM exam_record er JOIN examination_info ei ON er.exam_id = ei.exam_id GROUP BY tag) SELECT a.tag, b.answer_cnt FROM a INNER JOIN a AS b ON UPPER(a.tag)= b.tag #a小写 b大写 AND a.tag != b.tag WHERE a.answer_cnt \u0026lt; 3; "},{"id":582,"href":"/zh/docs/technology/Interview/database/sql/sql-syntax-summary/","title":"SQL语法基础知识总结","section":"SQL","content":" 本文整理完善自下面这两份资料:\nSQL 语法速成手册 MySQL 超全教程 基本概念 # 数据库术语 # 数据库(database) - 保存有组织的数据的容器(通常是一个文件或一组文件)。 数据表(table) - 某种特定类型数据的结构化清单。 模式(schema) - 关于数据库和表的布局及特性的信息。模式定义了数据在表中如何存储,包含存储什么样的数据,数据如何分解,各部分信息如何命名等信息。数据库和表都有模式。 列(column) - 表中的一个字段。所有表都是由一个或多个列组成的。 行(row) - 表中的一个记录。 主键(primary key) - 一列(或一组列),其值能够唯一标识表中每一行。 SQL 语法 # SQL(Structured Query Language),标准 SQL 由 ANSI 标准委员会管理,从而称为 ANSI SQL。各个 DBMS 都有自己的实现,如 PL/SQL、Transact-SQL 等。\nSQL 语法结构 # SQL 语法结构包括:\n子句 - 是语句和查询的组成成分。(在某些情况下,这些都是可选的。) 表达式 - 可以产生任何标量值,或由列和行的数据库表 谓词 - 给需要评估的 SQL 三值逻辑(3VL)(true/false/unknown)或布尔真值指定条件,并限制语句和查询的效果,或改变程序流程。 查询 - 基于特定条件检索数据。这是 SQL 的一个重要组成部分。 语句 - 可以持久地影响纲要和数据,也可以控制数据库事务、程序流程、连接、会话或诊断。 SQL 语法要点 # SQL 语句不区分大小写,但是数据库表名、列名和值是否区分,依赖于具体的 DBMS 以及配置。例如:SELECT 与 select、Select 是相同的。 多条 SQL 语句必须以分号(;)分隔。 处理 SQL 语句时,所有空格都被忽略。 SQL 语句可以写成一行,也可以分写为多行。\n-- 一行 SQL 语句 UPDATE user SET username=\u0026#39;robot\u0026#39;, password=\u0026#39;robot\u0026#39; WHERE username = \u0026#39;root\u0026#39;; -- 多行 SQL 语句 UPDATE user SET username=\u0026#39;robot\u0026#39;, password=\u0026#39;robot\u0026#39; WHERE username = \u0026#39;root\u0026#39;; SQL 支持三种注释:\n## 注释1 -- 注释2 /* 注释3 */ SQL 分类 # 数据定义语言(DDL) # 数据定义语言(Data Definition Language,DDL)是 SQL 语言集中负责数据结构定义与数据库对象定义的语言。\nDDL 的主要功能是定义数据库对象。\nDDL 的核心指令是 CREATE、ALTER、DROP。\n数据操纵语言(DML) # 数据操纵语言(Data Manipulation Language, DML)是用于数据库操作,对数据库其中的对象和数据运行访问工作的编程语句。\nDML 的主要功能是 访问数据,因此其语法都是以读写数据库为主。\nDML 的核心指令是 INSERT、UPDATE、DELETE、SELECT。这四个指令合称 CRUD(Create, Read, Update, Delete),即增删改查。\n事务控制语言(TCL) # 事务控制语言 (Transaction Control Language, TCL) 用于管理数据库中的事务。这些用于管理由 DML 语句所做的更改。它还允许将语句分组为逻辑事务。\nTCL 的核心指令是 COMMIT、ROLLBACK。\n数据控制语言(DCL) # 数据控制语言 (Data Control Language, DCL) 是一种可对数据访问权进行控制的指令,它可以控制特定用户账户对数据表、查看表、预存程序、用户自定义函数等数据库对象的控制权。\nDCL 的核心指令是 GRANT、REVOKE。\nDCL 以控制用户的访问权限为主,因此其指令作法并不复杂,可利用 DCL 控制的权限有:CONNECT、SELECT、INSERT、UPDATE、DELETE、EXECUTE、USAGE、REFERENCES。\n根据不同的 DBMS 以及不同的安全性实体,其支持的权限控制也有所不同。\n我们先来介绍 DML 语句用法。 DML 的主要功能是读写数据库实现增删改查。\n增删改查 # 增删改查,又称为 CRUD,数据库基本操作中的基本操作。\n插入数据 # INSERT INTO 语句用于向表中插入新记录。\n插入完整的行\n# 插入一行 INSERT INTO user VALUES (10, \u0026#39;root\u0026#39;, \u0026#39;root\u0026#39;, \u0026#39;xxxx@163.com\u0026#39;); # 插入多行 INSERT INTO user VALUES (10, \u0026#39;root\u0026#39;, \u0026#39;root\u0026#39;, \u0026#39;xxxx@163.com\u0026#39;), (12, \u0026#39;user1\u0026#39;, \u0026#39;user1\u0026#39;, \u0026#39;xxxx@163.com\u0026#39;), (18, \u0026#39;user2\u0026#39;, \u0026#39;user2\u0026#39;, \u0026#39;xxxx@163.com\u0026#39;); 插入行的一部分\nINSERT INTO user(username, password, email) VALUES (\u0026#39;admin\u0026#39;, \u0026#39;admin\u0026#39;, \u0026#39;xxxx@163.com\u0026#39;); 插入查询出来的数据\nINSERT INTO user(username) SELECT name FROM account; 更新数据 # UPDATE 语句用于更新表中的记录。\nUPDATE user SET username=\u0026#39;robot\u0026#39;, password=\u0026#39;robot\u0026#39; WHERE username = \u0026#39;root\u0026#39;; 删除数据 # DELETE 语句用于删除表中的记录。 TRUNCATE TABLE 可以清空表,也就是删除所有行。说明:TRUNCATE 语句不属于 DML 语法而是 DDL 语法。 删除表中的指定数据\nDELETE FROM user WHERE username = \u0026#39;robot\u0026#39;; 清空表中的数据\nTRUNCATE TABLE user; 查询数据 # SELECT 语句用于从数据库中查询数据。\nDISTINCT 用于返回唯一不同的值。它作用于所有列,也就是说所有列的值都相同才算相同。\nLIMIT 限制返回的行数。可以有两个参数,第一个参数为起始行,从 0 开始;第二个参数为返回的总行数。\nASC:升序(默认) DESC:降序 查询单列\nSELECT prod_name FROM products; 查询多列\nSELECT prod_id, prod_name, prod_price FROM products; 查询所有列\nSELECT * FROM products; 查询不同的值\nSELECT DISTINCT vend_id FROM products; 限制查询结果\n-- 返回前 5 行 SELECT * FROM mytable LIMIT 5; SELECT * FROM mytable LIMIT 0, 5; -- 返回第 3 ~ 5 行 SELECT * FROM mytable LIMIT 2, 3; 排序 # order by 用于对结果集按照一个列或者多个列进行排序。默认按照升序对记录进行排序,如果需要按照降序对记录进行排序,可以使用 desc 关键字。\norder by 对多列排序的时候,先排序的列放前面,后排序的列放后面。并且,不同的列可以有不同的排序规则。\nSELECT * FROM products ORDER BY prod_price DESC, prod_name ASC; 分组 # group by:\ngroup by 子句将记录分组到汇总行中。 group by 为每个组返回一个记录。 group by 通常还涉及聚合count,max,sum,avg 等。 group by 可以按一列或多列进行分组。 group by 按分组字段进行排序后,order by 可以以汇总字段来进行排序。 分组\nSELECT cust_name, COUNT(cust_address) AS addr_num FROM Customers GROUP BY cust_name; 分组后排序\nSELECT cust_name, COUNT(cust_address) AS addr_num FROM Customers GROUP BY cust_name ORDER BY cust_name DESC; having:\nhaving 用于对汇总的 group by 结果进行过滤。 having 一般都是和 group by 连用。 where 和 having 可以在相同的查询中。 使用 WHERE 和 HAVING 过滤数据\nSELECT cust_name, COUNT(*) AS NumberOfOrders FROM Customers WHERE cust_email IS NOT NULL GROUP BY cust_name HAVING COUNT(*) \u0026gt; 1; having vs where:\nwhere:过滤过滤指定的行,后面不能加聚合函数(分组函数)。where 在group by 前。 having:过滤分组,一般都是和 group by 连用,不能单独使用。having 在 group by 之后。 子查询 # 子查询是嵌套在较大查询中的 SQL 查询,也称内部查询或内部选择,包含子查询的语句也称为外部查询或外部选择。简单来说,子查询就是指将一个 select 查询(子查询)的结果作为另一个 SQL 语句(主查询)的数据来源或者判断条件。\n子查询可以嵌入 SELECT、INSERT、UPDATE 和 DELETE 语句中,也可以和 =、\u0026lt;、\u0026gt;、IN、BETWEEN、EXISTS 等运算符一起使用。\n子查询常用在 WHERE 子句和 FROM 子句后边:\n当用于 WHERE 子句时,根据不同的运算符,子查询可以返回单行单列、多行单列、单行多列数据。子查询就是要返回能够作为 WHERE 子句查询条件的值。 当用于 FROM 子句时,一般返回多行多列数据,相当于返回一张临时表,这样才符合 FROM 后面是表的规则。这种做法能够实现多表联合查询。 注意:MYSQL 数据库从 4.1 版本才开始支持子查询,早期版本是不支持的。\n用于 WHERE 子句的子查询的基本语法如下:\nselect column_name [, column_name ] from table1 [, table2 ] where column_name operator (select column_name [, column_name ] from table1 [, table2 ] [where]) 子查询需要放在括号( )内。 operator 表示用于 where 子句的运算符。 用于 FROM 子句的子查询的基本语法如下:\nselect column_name [, column_name ] from (select column_name [, column_name ] from table1 [, table2 ] [where]) as temp_table_name where condition 用于 FROM 的子查询返回的结果相当于一张临时表,所以需要使用 AS 关键字为该临时表起一个名字。\n子查询的子查询\nSELECT cust_name, cust_contact FROM customers WHERE cust_id IN (SELECT cust_id FROM orders WHERE order_num IN (SELECT order_num FROM orderitems WHERE prod_id = \u0026#39;RGAN01\u0026#39;)); 内部查询首先在其父查询之前执行,以便可以将内部查询的结果传递给外部查询。执行过程可以参考下图:\nWHERE # WHERE 子句用于过滤记录,即缩小访问数据的范围。 WHERE 后跟一个返回 true 或 false 的条件。 WHERE 可以与 SELECT,UPDATE 和 DELETE 一起使用。 可以在 WHERE 子句中使用的操作符。 运算符 描述 = 等于 \u0026lt;\u0026gt; 不等于。注释:在 SQL 的一些版本中,该操作符可被写成 != \u0026gt; 大于 \u0026lt; 小于 \u0026gt;= 大于等于 \u0026lt;= 小于等于 BETWEEN 在某个范围内 LIKE 搜索某种模式 IN 指定针对某个列的多个可能值 SELECT 语句中的 WHERE 子句\nSELECT * FROM Customers WHERE cust_name = \u0026#39;Kids Place\u0026#39;; UPDATE 语句中的 WHERE 子句\nUPDATE Customers SET cust_name = \u0026#39;Jack Jones\u0026#39; WHERE cust_name = \u0026#39;Kids Place\u0026#39;; DELETE 语句中的 WHERE 子句\nDELETE FROM Customers WHERE cust_name = \u0026#39;Kids Place\u0026#39;; IN 和 BETWEEN # IN 操作符在 WHERE 子句中使用,作用是在指定的几个特定值中任选一个值。 BETWEEN 操作符在 WHERE 子句中使用,作用是选取介于某个范围内的值。 IN 示例\nSELECT * FROM products WHERE vend_id IN (\u0026#39;DLL01\u0026#39;, \u0026#39;BRS01\u0026#39;); BETWEEN 示例\nSELECT * FROM products WHERE prod_price BETWEEN 3 AND 5; AND、OR、NOT # AND、OR、NOT 是用于对过滤条件的逻辑处理指令。 AND 优先级高于 OR,为了明确处理顺序,可以使用 ()。 AND 操作符表示左右条件都要满足。 OR 操作符表示左右条件满足任意一个即可。 NOT 操作符用于否定一个条件。 AND 示例\nSELECT prod_id, prod_name, prod_price FROM products WHERE vend_id = \u0026#39;DLL01\u0026#39; AND prod_price \u0026lt;= 4; OR 示例\nSELECT prod_id, prod_name, prod_price FROM products WHERE vend_id = \u0026#39;DLL01\u0026#39; OR vend_id = \u0026#39;BRS01\u0026#39;; NOT 示例\nSELECT * FROM products WHERE prod_price NOT BETWEEN 3 AND 5; LIKE # LIKE 操作符在 WHERE 子句中使用,作用是确定字符串是否匹配模式。 只有字段是文本值时才使用 LIKE。 LIKE 支持两个通配符匹配选项:% 和 _。 不要滥用通配符,通配符位于开头处匹配会非常慢。 % 表示任何字符出现任意次数。 _ 表示任何字符出现一次。 % 示例\nSELECT prod_id, prod_name, prod_price FROM products WHERE prod_name LIKE \u0026#39;%bean bag%\u0026#39;; _ 示例\nSELECT prod_id, prod_name, prod_price FROM products WHERE prod_name LIKE \u0026#39;__ inch teddy bear\u0026#39;; 连接 # JOIN 是“连接”的意思,顾名思义,SQL JOIN 子句用于将两个或者多个表联合起来进行查询。\n连接表时需要在每个表中选择一个字段,并对这些字段的值进行比较,值相同的两条记录将合并为一条。连接表的本质就是将不同表的记录合并起来,形成一张新表。当然,这张新表只是临时的,它仅存在于本次查询期间。\n使用 JOIN 连接两个表的基本语法如下:\nselect table1.column1, table2.column2... from table1 join table2 on table1.common_column1 = table2.common_column2; table1.common_column1 = table2.common_column2 是连接条件,只有满足此条件的记录才会合并为一行。您可以使用多个运算符来连接表,例如 =、\u0026gt;、\u0026lt;、\u0026lt;\u0026gt;、\u0026lt;=、\u0026gt;=、!=、between、like 或者 not,但是最常见的是使用 =。\n当两个表中有同名的字段时,为了帮助数据库引擎区分是哪个表的字段,在书写同名字段名时需要加上表名。当然,如果书写的字段名在两个表中是唯一的,也可以不使用以上格式,只写字段名即可。\n另外,如果两张表的关联字段名相同,也可以使用 USING子句来代替 ON,举个例子:\n# join....on select c.cust_name, o.order_num from Customers c inner join Orders o on c.cust_id = o.cust_id order by c.cust_name; # 如果两张表的关联字段名相同,也可以使用USING子句:join....using() select c.cust_name, o.order_num from Customers c inner join Orders o using(cust_id) order by c.cust_name; ON 和 WHERE 的区别:\n连接表时,SQL 会根据连接条件生成一张新的临时表。ON 就是连接条件,它决定临时表的生成。 WHERE 是在临时表生成以后,再对临时表中的数据进行过滤,生成最终的结果集,这个时候已经没有 JOIN-ON 了。 所以总结来说就是:SQL 先根据 ON 生成一张临时表,然后再根据 WHERE 对临时表进行筛选。\nSQL 允许在 JOIN 左边加上一些修饰性的关键词,从而形成不同类型的连接,如下表所示:\n连接类型 说明 INNER JOIN 内连接 (默认连接方式)只有当两个表都存在满足条件的记录时才会返回行。 LEFT JOIN / LEFT OUTER JOIN 左(外)连接 返回左表中的所有行,即使右表中没有满足条件的行也是如此。 RIGHT JOIN / RIGHT OUTER JOIN 右(外)连接 返回右表中的所有行,即使左表中没有满足条件的行也是如此。 FULL JOIN / FULL OUTER JOIN 全(外)连接 只要其中有一个表存在满足条件的记录,就返回行。 SELF JOIN 将一个表连接到自身,就像该表是两个表一样。为了区分两个表,在 SQL 语句中需要至少重命名一个表。 CROSS JOIN 交叉连接,从两个或者多个连接表中返回记录集的笛卡尔积。 下图展示了 LEFT JOIN、RIGHT JOIN、INNER JOIN、OUTER JOIN 相关的 7 种用法。\n如果不加任何修饰词,只写 JOIN,那么默认为 INNER JOIN\n对于 INNER JOIN 来说,还有一种隐式的写法,称为 “隐式内连接”,也就是没有 INNER JOIN 关键字,使用 WHERE 语句实现内连接的功能\n# 隐式内连接 select c.cust_name, o.order_num from Customers c, Orders o where c.cust_id = o.cust_id order by c.cust_name; # 显式内连接 select c.cust_name, o.order_num from Customers c inner join Orders o using(cust_id) order by c.cust_name; 组合 # UNION 运算符将两个或更多查询的结果组合起来,并生成一个结果集,其中包含来自 UNION 中参与查询的提取行。\nUNION 基本规则:\n所有查询的列数和列顺序必须相同。 每个查询中涉及表的列的数据类型必须相同或兼容。 通常返回的列名取自第一个查询。 默认地,UNION 操作符选取不同的值。如果允许重复的值,请使用 UNION ALL。\nSELECT column_name(s) FROM table1 UNION ALL SELECT column_name(s) FROM table2; UNION 结果集中的列名总是等于 UNION 中第一个 SELECT 语句中的列名。\nJOIN vs UNION:\nJOIN 中连接表的列可能不同,但在 UNION 中,所有查询的列数和列顺序必须相同。 UNION 将查询之后的行放在一起(垂直放置),但 JOIN 将查询之后的列放在一起(水平放置),即它构成一个笛卡尔积。 函数 # 不同数据库的函数往往各不相同,因此不可移植。本节主要以 MySQL 的函数为例。\n文本处理 # 函数 说明 LEFT()、RIGHT() 左边或者右边的字符 LOWER()、UPPER() 转换为小写或者大写 LTRIM()、RTRIM() 去除左边或者右边的空格 LENGTH() 长度,以字节为单位 SOUNDEX() 转换为语音值 其中, SOUNDEX() 可以将一个字符串转换为描述其语音表示的字母数字模式。\nSELECT * FROM mytable WHERE SOUNDEX(col1) = SOUNDEX(\u0026#39;apple\u0026#39;) 日期和时间处理 # 日期格式:YYYY-MM-DD 时间格式:HH:MM:SS 函 数 说 明 AddDate() 增加一个日期(天、周等) AddTime() 增加一个时间(时、分等) CurDate() 返回当前日期 CurTime() 返回当前时间 Date() 返回日期时间的日期部分 DateDiff() 计算两个日期之差 Date_Add() 高度灵活的日期运算函数 Date_Format() 返回一个格式化的日期或时间串 Day() 返回一个日期的天数部分 DayOfWeek() 对于一个日期,返回对应的星期几 Hour() 返回一个时间的小时部分 Minute() 返回一个时间的分钟部分 Month() 返回一个日期的月份部分 Now() 返回当前日期和时间 Second() 返回一个时间的秒部分 Time() 返回一个日期时间的时间部分 Year() 返回一个日期的年份部分 数值处理 # 函数 说明 SIN() 正弦 COS() 余弦 TAN() 正切 ABS() 绝对值 SQRT() 平方根 MOD() 余数 EXP() 指数 PI() 圆周率 RAND() 随机数 汇总 # 函 数 说 明 AVG() 返回某列的平均值 COUNT() 返回某列的行数 MAX() 返回某列的最大值 MIN() 返回某列的最小值 SUM() 返回某列值之和 AVG() 会忽略 NULL 行。\n使用 DISTINCT 可以让汇总函数值汇总不同的值。\nSELECT AVG(DISTINCT col1) AS avg_col FROM mytable 接下来,我们来介绍 DDL 语句用法。DDL 的主要功能是定义数据库对象(如:数据库、数据表、视图、索引等)\n数据定义 # 数据库(DATABASE) # 创建数据库 # CREATE DATABASE test; 删除数据库 # DROP DATABASE test; 选择数据库 # USE test; 数据表(TABLE) # 创建数据表 # 普通创建\nCREATE TABLE user ( id int(10) unsigned NOT NULL COMMENT \u0026#39;Id\u0026#39;, username varchar(64) NOT NULL DEFAULT \u0026#39;default\u0026#39; COMMENT \u0026#39;用户名\u0026#39;, password varchar(64) NOT NULL DEFAULT \u0026#39;default\u0026#39; COMMENT \u0026#39;密码\u0026#39;, email varchar(64) NOT NULL DEFAULT \u0026#39;default\u0026#39; COMMENT \u0026#39;邮箱\u0026#39; ) COMMENT=\u0026#39;用户表\u0026#39;; 根据已有的表创建新表\nCREATE TABLE vip_user AS SELECT * FROM user; 删除数据表 # DROP TABLE user; 修改数据表 # 添加列\nALTER TABLE user ADD age int(3); 删除列\nALTER TABLE user DROP COLUMN age; 修改列\nALTER TABLE `user` MODIFY COLUMN age tinyint; 添加主键\nALTER TABLE user ADD PRIMARY KEY (id); 删除主键\nALTER TABLE user DROP PRIMARY KEY; 视图(VIEW) # 定义:\n视图是基于 SQL 语句的结果集的可视化的表。 视图是虚拟的表,本身不包含数据,也就不能对其进行索引操作。对视图的操作和对普通表的操作一样。 作用:\n简化复杂的 SQL 操作,比如复杂的联结; 只使用实际表的一部分数据; 通过只给用户访问视图的权限,保证数据的安全性; 更改数据格式和表示。 创建视图 # CREATE VIEW top_10_user_view AS SELECT id, username FROM user WHERE id \u0026lt; 10; 删除视图 # DROP VIEW top_10_user_view; 索引(INDEX) # 索引是一种用于快速查询和检索数据的数据结构,其本质可以看成是一种排序好的数据结构。\n索引的作用就相当于书的目录。打个比方: 我们在查字典的时候,如果没有目录,那我们就只能一页一页的去找我们需要查的那个字,速度很慢。如果有目录了,我们只需要先去目录里查找字的位置,然后直接翻到那一页就行了。\n优点:\n使用索引可以大大加快 数据的检索速度(大大减少检索的数据量), 这也是创建索引的最主要的原因。 通过创建唯一性索引,可以保证数据库表中每一行数据的唯一性。 缺点:\n创建索引和维护索引需要耗费许多时间。当对表中的数据进行增删改的时候,如果数据有索引,那么索引也需要动态的修改,会降低 SQL 执行效率。 索引需要使用物理文件存储,也会耗费一定空间。 但是,使用索引一定能提高查询性能吗?\n大多数情况下,索引查询都是比全表扫描要快的。但是如果数据库的数据量不大,那么使用索引也不一定能够带来很大提升。\n关于索引的详细介绍,请看我写的 MySQL 索引详解 这篇文章。\n创建索引 # CREATE INDEX user_index ON user (id); 添加索引 # ALTER table user ADD INDEX user_index(id) 创建唯一索引 # CREATE UNIQUE INDEX user_index ON user (id); 删除索引 # ALTER TABLE user DROP INDEX user_index; 约束 # SQL 约束用于规定表中的数据规则。\n如果存在违反约束的数据行为,行为会被约束终止。\n约束可以在创建表时规定(通过 CREATE TABLE 语句),或者在表创建之后规定(通过 ALTER TABLE 语句)。\n约束类型:\nNOT NULL - 指示某列不能存储 NULL 值。 UNIQUE - 保证某列的每行必须有唯一的值。 PRIMARY KEY - NOT NULL 和 UNIQUE 的结合。确保某列(或两个列多个列的结合)有唯一标识,有助于更容易更快速地找到表中的一个特定的记录。 FOREIGN KEY - 保证一个表中的数据匹配另一个表中的值的参照完整性。 CHECK - 保证列中的值符合指定的条件。 DEFAULT - 规定没有给列赋值时的默认值。 创建表时使用约束条件:\nCREATE TABLE Users ( Id INT(10) UNSIGNED NOT NULL AUTO_INCREMENT COMMENT \u0026#39;自增Id\u0026#39;, Username VARCHAR(64) NOT NULL UNIQUE DEFAULT \u0026#39;default\u0026#39; COMMENT \u0026#39;用户名\u0026#39;, Password VARCHAR(64) NOT NULL DEFAULT \u0026#39;default\u0026#39; COMMENT \u0026#39;密码\u0026#39;, Email VARCHAR(64) NOT NULL DEFAULT \u0026#39;default\u0026#39; COMMENT \u0026#39;邮箱地址\u0026#39;, Enabled TINYINT(4) DEFAULT NULL COMMENT \u0026#39;是否有效\u0026#39;, PRIMARY KEY (Id) ) ENGINE=InnoDB AUTO_INCREMENT=2 DEFAULT CHARSET=utf8mb4 COMMENT=\u0026#39;用户表\u0026#39;; 接下来,我们来介绍 TCL 语句用法。TCL 的主要功能是管理数据库中的事务。\n事务处理 # 不能回退 SELECT 语句,回退 SELECT 语句也没意义;也不能回退 CREATE 和 DROP 语句。\nMySQL 默认是隐式提交,每执行一条语句就把这条语句当成一个事务然后进行提交。当出现 START TRANSACTION 语句时,会关闭隐式提交;当 COMMIT 或 ROLLBACK 语句执行后,事务会自动关闭,重新恢复隐式提交。\n通过 set autocommit=0 可以取消自动提交,直到 set autocommit=1 才会提交;autocommit 标记是针对每个连接而不是针对服务器的。\n指令:\nSTART TRANSACTION - 指令用于标记事务的起始点。 SAVEPOINT - 指令用于创建保留点。 ROLLBACK TO - 指令用于回滚到指定的保留点;如果没有设置保留点,则回退到 START TRANSACTION 语句处。 COMMIT - 提交事务。 -- 开始事务 START TRANSACTION; -- 插入操作 A INSERT INTO `user` VALUES (1, \u0026#39;root1\u0026#39;, \u0026#39;root1\u0026#39;, \u0026#39;xxxx@163.com\u0026#39;); -- 创建保留点 updateA SAVEPOINT updateA; -- 插入操作 B INSERT INTO `user` VALUES (2, \u0026#39;root2\u0026#39;, \u0026#39;root2\u0026#39;, \u0026#39;xxxx@163.com\u0026#39;); -- 回滚到保留点 updateA ROLLBACK TO updateA; -- 提交事务,只有操作 A 生效 COMMIT; 接下来,我们来介绍 DCL 语句用法。DCL 的主要功能是控制用户的访问权限。\n权限控制 # 要授予用户帐户权限,可以用GRANT命令。要撤销用户的权限,可以用REVOKE命令。这里以 MySQL 为例,介绍权限控制实际应用。\nGRANT授予权限语法:\nGRANT privilege,[privilege],.. ON privilege_level TO user [IDENTIFIED BY password] [REQUIRE tsl_option] [WITH [GRANT_OPTION | resource_option]]; 简单解释一下:\n在GRANT关键字后指定一个或多个权限。如果授予用户多个权限,则每个权限由逗号分隔。 ON privilege_level 确定权限应用级别。MySQL 支持 global(*.*),database(database.*),table(database.table)和列级别。如果使用列权限级别,则必须在每个权限之后指定一个或逗号分隔列的列表。 user 是要授予权限的用户。如果用户已存在,则GRANT语句将修改其权限。否则,GRANT语句将创建一个新用户。可选子句IDENTIFIED BY允许您为用户设置新的密码。 REQUIRE tsl_option指定用户是否必须通过 SSL,X059 等安全连接连接到数据库服务器。 可选 WITH GRANT OPTION 子句允许您授予其他用户或从其他用户中删除您拥有的权限。此外,您可以使用WITH子句分配 MySQL 数据库服务器的资源,例如,设置用户每小时可以使用的连接数或语句数。这在 MySQL 共享托管等共享环境中非常有用。 REVOKE 撤销权限语法:\nREVOKE privilege_type [(column_list)] [, priv_type [(column_list)]]... ON [object_type] privilege_level FROM user [, user]... 简单解释一下:\n在 REVOKE 关键字后面指定要从用户撤消的权限列表。您需要用逗号分隔权限。 指定在 ON 子句中撤销特权的特权级别。 指定要撤消 FROM 子句中的权限的用户帐户。 GRANT 和 REVOKE 可在几个层次上控制访问权限:\n整个服务器,使用 GRANT ALL 和 REVOKE ALL; 整个数据库,使用 ON database.*; 特定的表,使用 ON database.table; 特定的列; 特定的存储过程。 新创建的账户没有任何权限。账户用 username@host 的形式定义,username@% 使用的是默认主机名。MySQL 的账户信息保存在 mysql 这个数据库中。\nUSE mysql; SELECT user FROM user; 下表说明了可用于GRANT和REVOKE语句的所有允许权限:\n特权 说明 级别 全局 数据库 表 列 程序 代理 ALL [PRIVILEGES] 授予除 GRANT OPTION 之外的指定访问级别的所有权限 ALTER 允许用户使用 ALTER TABLE 语句 X X X ALTER ROUTINE 允许用户更改或删除存储的例程 X X X CREATE 允许用户创建数据库和表 X X X CREATE ROUTINE 允许用户创建存储的例程 X X CREATE TABLESPACE 允许用户创建,更改或删除表空间和日志文件组 X CREATE TEMPORARY TABLES 允许用户使用 CREATE TEMPORARY TABLE 创建临时表 X X CREATE USER 允许用户使用 CREATE USER,DROP USER,RENAME USER 和 REVOKE ALL PRIVILEGES 语句。 X CREATE VIEW 允许用户创建或修改视图。 X X X DELETE 允许用户使用 DELETE X X X DROP 允许用户删除数据库,表和视图 X X X EVENT 启用事件计划程序的事件使用。 X X EXECUTE 允许用户执行存储的例程 X X X FILE 允许用户读取数据库目录中的任何文件。 X GRANT OPTION 允许用户拥有授予或撤消其他帐户权限的权限。 X X X X X INDEX 允许用户创建或删除索引。 X X X INSERT 允许用户使用 INSERT 语句 X X X X LOCK TABLES 允许用户对具有 SELECT 权限的表使用 LOCK TABLES X X PROCESS 允许用户使用 SHOW PROCESSLIST 语句查看所有进程。 X PROXY 启用用户代理。 REFERENCES 允许用户创建外键 X X X X RELOAD 允许用户使用 FLUSH 操作 X REPLICATION CLIENT 允许用户查询以查看主服务器或从属服务器的位置 X REPLICATION SLAVE 允许用户使用复制从属从主服务器读取二进制日志事件。 X SELECT 允许用户使用 SELECT 语句 X X X X SHOW DATABASES 允许用户显示所有数据库 X SHOW VIEW 允许用户使用 SHOW CREATE VIEW 语句 X X X SHUTDOWN 允许用户使用 mysqladmin shutdown 命令 X SUPER 允许用户使用其他管理操作,例如 CHANGE MASTER TO,KILL,PURGE BINARY LOGS,SET GLOBAL 和 mysqladmin 命令 X TRIGGER 允许用户使用 TRIGGER 操作。 X X X UPDATE 允许用户使用 UPDATE 语句 X X X X USAGE 相当于“没有特权” 创建账户 # CREATE USER myuser IDENTIFIED BY \u0026#39;mypassword\u0026#39;; 修改账户名 # UPDATE user SET user=\u0026#39;newuser\u0026#39; WHERE user=\u0026#39;myuser\u0026#39;; FLUSH PRIVILEGES; 删除账户 # DROP USER myuser; 查看权限 # SHOW GRANTS FOR myuser; 授予权限 # GRANT SELECT, INSERT ON *.* TO myuser; 删除权限 # REVOKE SELECT, INSERT ON *.* FROM myuser; 更改密码 # SET PASSWORD FOR myuser = \u0026#39;mypass\u0026#39;; 存储过程 # 存储过程可以看成是对一系列 SQL 操作的批处理。存储过程可以由触发器,其他存储过程以及 Java, Python,PHP 等应用程序调用。\n使用存储过程的好处:\n代码封装,保证了一定的安全性; 代码复用; 由于是预先编译,因此具有很高的性能。 创建存储过程:\n命令行中创建存储过程需要自定义分隔符,因为命令行是以 ; 为结束符,而存储过程中也包含了分号,因此会错误把这部分分号当成是结束符,造成语法错误。 包含 in、out 和 inout 三种参数。 给变量赋值都需要用 select into 语句。 每次只能给一个变量赋值,不支持集合的操作。 需要注意的是:阿里巴巴《Java 开发手册》强制禁止使用存储过程。因为存储过程难以调试和扩展,更没有移植性。\n至于到底要不要在项目中使用,还是要看项目实际需求,权衡好利弊即可!\n创建存储过程 # DROP PROCEDURE IF EXISTS `proc_adder`; DELIMITER ;; CREATE DEFINER=`root`@`localhost` PROCEDURE `proc_adder`(IN a int, IN b int, OUT sum int) BEGIN DECLARE c int; if a is null then set a = 0; end if; if b is null then set b = 0; end if; set sum = a + b; END ;; DELIMITER ; 使用存储过程 # set @b=5; call proc_adder(2,@b,@s); select @s as sum; 游标 # 游标(cursor)是一个存储在 DBMS 服务器上的数据库查询,它不是一条 SELECT 语句,而是被该语句检索出来的结果集。\n在存储过程中使用游标可以对一个结果集进行移动遍历。\n游标主要用于交互式应用,其中用户需要滚动屏幕上的数据,并对数据进行浏览或做出更改。\n使用游标的几个明确步骤:\n在使用游标前,必须声明(定义)它。这个过程实际上没有检索数据, 它只是定义要使用的 SELECT 语句和游标选项。\n一旦声明,就必须打开游标以供使用。这个过程用前面定义的 SELECT 语句把数据实际检索出来。\n对于填有数据的游标,根据需要取出(检索)各行。\n在结束游标使用时,必须关闭游标,可能的话,释放游标(有赖于具\n体的 DBMS)。\nDELIMITER $ CREATE PROCEDURE getTotal() BEGIN DECLARE total INT; -- 创建接收游标数据的变量 DECLARE sid INT; DECLARE sname VARCHAR(10); -- 创建总数变量 DECLARE sage INT; -- 创建结束标志变量 DECLARE done INT DEFAULT false; -- 创建游标 DECLARE cur CURSOR FOR SELECT id,name,age from cursor_table where age\u0026gt;30; -- 指定游标循环结束时的返回值 DECLARE CONTINUE HANDLER FOR NOT FOUND SET done = true; SET total = 0; OPEN cur; FETCH cur INTO sid, sname, sage; WHILE(NOT done) DO SET total = total + 1; FETCH cur INTO sid, sname, sage; END WHILE; CLOSE cur; SELECT total; END $ DELIMITER ; -- 调用存储过程 call getTotal(); 触发器 # 触发器是一种与表操作有关的数据库对象,当触发器所在表上出现指定事件时,将调用该对象,即表的操作事件触发表上的触发器的执行。\n我们可以使用触发器来进行审计跟踪,把修改记录到另外一张表中。\n使用触发器的优点:\nSQL 触发器提供了另一种检查数据完整性的方法。 SQL 触发器可以捕获数据库层中业务逻辑中的错误。 SQL 触发器提供了另一种运行计划任务的方法。通过使用 SQL 触发器,您不必等待运行计划任务,因为在对表中的数据进行更改之前或之后会自动调用触发器。 SQL 触发器对于审计表中数据的更改非常有用。 使用触发器的缺点:\nSQL 触发器只能提供扩展验证,并且不能替换所有验证。必须在应用程序层中完成一些简单的验证。例如,您可以使用 JavaScript 在客户端验证用户的输入,或者使用服务器端脚本语言(如 JSP,PHP,ASP.NET,Perl)在服务器端验证用户的输入。 从客户端应用程序调用和执行 SQL 触发器是不可见的,因此很难弄清楚数据库层中发生了什么。 SQL 触发器可能会增加数据库服务器的开销。 MySQL 不允许在触发器中使用 CALL 语句 ,也就是不能调用存储过程。\n注意:在 MySQL 中,分号 ; 是语句结束的标识符,遇到分号表示该段语句已经结束,MySQL 可以开始执行了。因此,解释器遇到触发器执行动作中的分号后就开始执行,然后会报错,因为没有找到和 BEGIN 匹配的 END。\n这时就会用到 DELIMITER 命令(DELIMITER 是定界符,分隔符的意思)。它是一条命令,不需要语句结束标识,语法为:DELIMITER new_delimiter。new_delimiter 可以设为 1 个或多个长度的符号,默认的是分号 ;,我们可以把它修改为其他符号,如 $ - DELIMITER $ 。在这之后的语句,以分号结束,解释器不会有什么反应,只有遇到了 $,才认为是语句结束。注意,使用完之后,我们还应该记得把它给修改回来。\n在 MySQL 5.7.2 版之前,可以为每个表定义最多六个触发器。\nBEFORE INSERT - 在将数据插入表格之前激活。 AFTER INSERT - 将数据插入表格后激活。 BEFORE UPDATE - 在更新表中的数据之前激活。 AFTER UPDATE - 更新表中的数据后激活。 BEFORE DELETE - 在从表中删除数据之前激活。 AFTER DELETE - 从表中删除数据后激活。 但是,从 MySQL 版本 5.7.2+开始,可以为同一触发事件和操作时间定义多个触发器。\nNEW 和 OLD:\nMySQL 中定义了 NEW 和 OLD 关键字,用来表示触发器的所在表中,触发了触发器的那一行数据。 在 INSERT 型触发器中,NEW 用来表示将要(BEFORE)或已经(AFTER)插入的新数据; 在 UPDATE 型触发器中,OLD 用来表示将要或已经被修改的原数据,NEW 用来表示将要或已经修改为的新数据; 在 DELETE 型触发器中,OLD 用来表示将要或已经被删除的原数据; 使用方法:NEW.columnName (columnName 为相应数据表某一列名) 创建触发器 # 提示:为了理解触发器的要点,有必要先了解一下创建触发器的指令。\nCREATE TRIGGER 指令用于创建触发器。\n语法:\nCREATE TRIGGER trigger_name trigger_time trigger_event ON table_name FOR EACH ROW BEGIN trigger_statements END; 说明:\ntrigger_name:触发器名 trigger_time : 触发器的触发时机。取值为 BEFORE 或 AFTER。 trigger_event : 触发器的监听事件。取值为 INSERT、UPDATE 或 DELETE。 table_name : 触发器的监听目标。指定在哪张表上建立触发器。 FOR EACH ROW: 行级监视,Mysql 固定写法,其他 DBMS 不同。 trigger_statements: 触发器执行动作。是一条或多条 SQL 语句的列表,列表内的每条语句都必须用分号 ; 来结尾。 当触发器的触发条件满足时,将会执行 BEGIN 和 END 之间的触发器执行动作。\n示例:\nDELIMITER $ CREATE TRIGGER `trigger_insert_user` AFTER INSERT ON `user` FOR EACH ROW BEGIN INSERT INTO `user_history`(user_id, operate_type, operate_time) VALUES (NEW.id, \u0026#39;add a user\u0026#39;, now()); END $ DELIMITER ; 查看触发器 # SHOW TRIGGERS; 删除触发器 # DROP TRIGGER IF EXISTS trigger_insert_user; 文章推荐 # 后端程序员必备:SQL 高性能优化指南!35+条优化建议立马 GET! 后端程序员必备:书写高质量 SQL 的 30 条建议 "},{"id":583,"href":"/zh/docs/technology/Interview/database/mysql/how-sql-executed-in-mysql/","title":"SQL语句在MySQL中的执行过程","section":"Mysql","content":" 本文来自 木木匠投稿。\n本篇文章会分析下一个 SQL 语句在 MySQL 中的执行流程,包括 SQL 的查询在 MySQL 内部会怎么流转,SQL 语句的更新是怎么完成的。\n在分析之前我会先带着你看看 MySQL 的基础架构,知道了 MySQL 由那些组件组成以及这些组件的作用是什么,可以帮助我们理解和解决这些问题。\n一 MySQL 基础架构分析 # 1.1 MySQL 基本架构概览 # 下图是 MySQL 的一个简要架构图,从下图你可以很清晰的看到用户的 SQL 语句在 MySQL 内部是如何执行的。\n先简单介绍一下下图涉及的一些组件的基本作用帮助大家理解这幅图,在 1.2 节中会详细介绍到这些组件的作用。\n连接器: 身份认证和权限相关(登录 MySQL 的时候)。 查询缓存: 执行查询语句的时候,会先查询缓存(MySQL 8.0 版本后移除,因为这个功能不太实用)。 分析器: 没有命中缓存的话,SQL 语句就会经过分析器,分析器说白了就是要先看你的 SQL 语句要干嘛,再检查你的 SQL 语句语法是否正确。 优化器: 按照 MySQL 认为最优的方案去执行。 执行器: 执行语句,然后从存储引擎返回数据。 - 简单来说 MySQL 主要分为 Server 层和存储引擎层:\nServer 层:主要包括连接器、查询缓存、分析器、优化器、执行器等,所有跨存储引擎的功能都在这一层实现,比如存储过程、触发器、视图,函数等,还有一个通用的日志模块 binlog 日志模块。 存储引擎:主要负责数据的存储和读取,采用可以替换的插件式架构,支持 InnoDB、MyISAM、Memory 等多个存储引擎,其中 InnoDB 引擎有自有的日志模块 redolog 模块。现在最常用的存储引擎是 InnoDB,它从 MySQL 5.5 版本开始就被当做默认存储引擎了。 1.2 Server 层基本组件介绍 # 1) 连接器 # 连接器主要和身份认证和权限相关的功能相关,就好比一个级别很高的门卫一样。\n主要负责用户登录数据库,进行用户的身份认证,包括校验账户密码,权限等操作,如果用户账户密码已通过,连接器会到权限表中查询该用户的所有权限,之后在这个连接里的权限逻辑判断都是会依赖此时读取到的权限数据,也就是说,后续只要这个连接不断开,即使管理员修改了该用户的权限,该用户也是不受影响的。\n2) 查询缓存(MySQL 8.0 版本后移除) # 查询缓存主要用来缓存我们所执行的 SELECT 语句以及该语句的结果集。\n连接建立后,执行查询语句的时候,会先查询缓存,MySQL 会先校验这个 SQL 是否执行过,以 Key-Value 的形式缓存在内存中,Key 是查询语句,Value 是结果集。如果缓存 key 被命中,就会直接返回给客户端,如果没有命中,就会执行后续的操作,完成后也会把结果缓存起来,方便下一次调用。当然在真正执行缓存查询的时候还是会校验用户的权限,是否有该表的查询条件。\nMySQL 查询不建议使用缓存,因为查询缓存失效在实际业务场景中可能会非常频繁,假如你对一个表更新的话,这个表上的所有的查询缓存都会被清空。对于不经常更新的数据来说,使用缓存还是可以的。\n所以,一般在大多数情况下我们都是不推荐去使用查询缓存的。\nMySQL 8.0 版本后删除了缓存的功能,官方也是认为该功能在实际的应用场景比较少,所以干脆直接删掉了。\n3) 分析器 # MySQL 没有命中缓存,那么就会进入分析器,分析器主要是用来分析 SQL 语句是来干嘛的,分析器也会分为几步:\n第一步,词法分析,一条 SQL 语句有多个字符串组成,首先要提取关键字,比如 select,提出查询的表,提出字段名,提出查询条件等等。做完这些操作后,就会进入第二步。\n第二步,语法分析,主要就是判断你输入的 SQL 是否正确,是否符合 MySQL 的语法。\n完成这 2 步之后,MySQL 就准备开始执行了,但是如何执行,怎么执行是最好的结果呢?这个时候就需要优化器上场了。\n4) 优化器 # 优化器的作用就是它认为的最优的执行方案去执行(有时候可能也不是最优,这篇文章涉及对这部分知识的深入讲解),比如多个索引的时候该如何选择索引,多表查询的时候如何选择关联顺序等。\n可以说,经过了优化器之后可以说这个语句具体该如何执行就已经定下来。\n5) 执行器 # 当选择了执行方案后,MySQL 就准备开始执行了,首先执行前会校验该用户有没有权限,如果没有权限,就会返回错误信息,如果有权限,就会去调用引擎的接口,返回接口执行的结果。\n二 语句分析 # 2.1 查询语句 # 说了以上这么多,那么究竟一条 SQL 语句是如何执行的呢?其实我们的 SQL 可以分为两种,一种是查询,一种是更新(增加,修改,删除)。我们先分析下查询语句,语句如下:\nselect * from tb_student A where A.age=\u0026#39;18\u0026#39; and A.name=\u0026#39; 张三 \u0026#39;; 结合上面的说明,我们分析下这个语句的执行流程:\n先检查该语句是否有权限,如果没有权限,直接返回错误信息,如果有权限,在 MySQL8.0 版本以前,会先查询缓存,以这条 SQL 语句为 key 在内存中查询是否有结果,如果有直接缓存,如果没有,执行下一步。\n通过分析器进行词法分析,提取 SQL 语句的关键元素,比如提取上面这个语句是查询 select,提取需要查询的表名为 tb_student,需要查询所有的列,查询条件是这个表的 id=\u0026lsquo;1\u0026rsquo;。然后判断这个 SQL 语句是否有语法错误,比如关键词是否正确等等,如果检查没问题就执行下一步。\n接下来就是优化器进行确定执行方案,上面的 SQL 语句,可以有两种执行方案:a.先查询学生表中姓名为“张三”的学生,然后判断是否年龄是 18。b.先找出学生中年龄 18 岁的学生,然后再查询姓名为“张三”的学生。那么优化器根据自己的优化算法进行选择执行效率最好的一个方案(优化器认为,有时候不一定最好)。那么确认了执行计划后就准备开始执行了。\n进行权限校验,如果没有权限就会返回错误信息,如果有权限就会调用数据库引擎接口,返回引擎的执行结果。\n2.2 更新语句 # 以上就是一条查询 SQL 的执行流程,那么接下来我们看看一条更新语句如何执行的呢?SQL 语句如下:\nupdate tb_student A set A.age=\u0026#39;19\u0026#39; where A.name=\u0026#39; 张三 \u0026#39;; 我们来给张三修改下年龄,在实际数据库肯定不会设置年龄这个字段的,不然要被技术负责人打的。其实这条语句也基本上会沿着上一个查询的流程走,只不过执行更新的时候肯定要记录日志啦,这就会引入日志模块了,MySQL 自带的日志模块是 binlog(归档日志) ,所有的存储引擎都可以使用,我们常用的 InnoDB 引擎还自带了一个日志模块 redo log(重做日志),我们就以 InnoDB 模式下来探讨这个语句的执行流程。流程如下:\n先查询到张三这一条数据,不会走查询缓存,因为更新语句会导致与该表相关的查询缓存失效。 然后拿到查询的语句,把 age 改为 19,然后调用引擎 API 接口,写入这一行数据,InnoDB 引擎把数据保存在内存中,同时记录 redo log,此时 redo log 进入 prepare 状态,然后告诉执行器,执行完成了,随时可以提交。 执行器收到通知后记录 binlog,然后调用引擎接口,提交 redo log 为提交状态。 更新完成。 这里肯定有同学会问,为什么要用两个日志模块,用一个日志模块不行吗?\n这是因为最开始 MySQL 并没有 InnoDB 引擎(InnoDB 引擎是其他公司以插件形式插入 MySQL 的),MySQL 自带的引擎是 MyISAM,但是我们知道 redo log 是 InnoDB 引擎特有的,其他存储引擎都没有,这就导致会没有 crash-safe 的能力(crash-safe 的能力即使数据库发生异常重启,之前提交的记录都不会丢失),binlog 日志只能用来归档。\n并不是说只用一个日志模块不可以,只是 InnoDB 引擎就是通过 redo log 来支持事务的。那么,又会有同学问,我用两个日志模块,但是不要这么复杂行不行,为什么 redo log 要引入 prepare 预提交状态?这里我们用反证法来说明下为什么要这么做?\n先写 redo log 直接提交,然后写 binlog,假设写完 redo log 后,机器挂了,binlog 日志没有被写入,那么机器重启后,这台机器会通过 redo log 恢复数据,但是这个时候 binlog 并没有记录该数据,后续进行机器备份的时候,就会丢失这一条数据,同时主从同步也会丢失这一条数据。 先写 binlog,然后写 redo log,假设写完了 binlog,机器异常重启了,由于没有 redo log,本机是无法恢复这一条记录的,但是 binlog 又有记录,那么和上面同样的道理,就会产生数据不一致的情况。 如果采用 redo log 两阶段提交的方式就不一样了,写完 binlog 后,然后再提交 redo log 就会防止出现上述的问题,从而保证了数据的一致性。那么问题来了,有没有一个极端的情况呢?假设 redo log 处于预提交状态,binlog 也已经写完了,这个时候发生了异常重启会怎么样呢? 这个就要依赖于 MySQL 的处理机制了,MySQL 的处理过程如下:\n判断 redo log 是否完整,如果判断是完整的,就立即提交。 如果 redo log 只是预提交但不是 commit 状态,这个时候就会去判断 binlog 是否完整,如果完整就提交 redo log, 不完整就回滚事务。 这样就解决了数据一致性的问题。\n三 总结 # MySQL 主要分为 Server 层和引擎层,Server 层主要包括连接器、查询缓存、分析器、优化器、执行器,同时还有一个日志模块(binlog),这个日志模块所有执行引擎都可以共用,redolog 只有 InnoDB 有。 引擎层是插件式的,目前主要包括,MyISAM,InnoDB,Memory 等。 查询语句的执行流程如下:权限校验(如果命中缓存)\u0026mdash;\u0026gt;查询缓存\u0026mdash;\u0026gt;分析器\u0026mdash;\u0026gt;优化器\u0026mdash;\u0026gt;权限校验\u0026mdash;\u0026gt;执行器\u0026mdash;\u0026gt;引擎 更新语句执行流程如下:分析器\u0026mdash;-\u0026gt;权限校验\u0026mdash;-\u0026gt;执行器\u0026mdash;\u0026gt;引擎\u0026mdash;redo log(prepare 状态)\u0026mdash;\u0026gt;binlog\u0026mdash;\u0026gt;redo log(commit 状态) 四 参考 # 《MySQL 实战 45 讲》 MySQL 5.6 参考手册: https://dev.MySQL.com/doc/refman/5.6/en/ "},{"id":584,"href":"/zh/docs/technology/Interview/system-design/security/sso-intro/","title":"SSO 单点登录详解","section":"Security","content":" 本文授权转载自: https://ken.io/note/sso-design-implement 作者:ken.io\nSSO 介绍 # 什么是 SSO? # SSO 英文全称 Single Sign On,单点登录。SSO 是在多个应用系统中,用户只需要登录一次就可以访问所有相互信任的应用系统。\n例如你登录网易账号中心( https://reg.163.com/ )之后访问以下站点都是登录状态。\n网易直播 https://v.163.com 网易博客 https://blog.163.com 网易花田 https://love.163.com 网易考拉 https://www.kaola.com 网易 Lofter http://www.lofter.com SSO 有什么好处? # 用户角度 :用户能够做到一次登录多次使用,无需记录多套用户名和密码,省心。 系统管理员角度 : 管理员只需维护好一个统一的账号中心就可以了,方便。 新系统开发角度: 新系统开发时只需直接对接统一的账号中心即可,简化开发流程,省时。 SSO 设计与实现 # 本篇文章也主要是为了探讨如何设计\u0026amp;实现一个 SSO 系统\n以下为需要实现的核心功能:\n单点登录 单点登出 支持跨域单点登录 支持跨域单点登出 核心应用与依赖 # 应用/模块/对象 说明 前台站点 需要登录的站点 SSO 站点-登录 提供登录的页面 SSO 站点-登出 提供注销登录的入口 SSO 服务-登录 提供登录服务 SSO 服务-登录状态 提供登录状态校验/登录信息查询的服务 SSO 服务-登出 提供用户注销登录的服务 数据库 存储用户账户信息 缓存 存储用户的登录信息,通常使用 Redis 用户登录状态的存储与校验 # 常见的 Web 框架对于 Session 的实现都是生成一个 SessionId 存储在浏览器 Cookie 中。然后将 Session 内容存储在服务器端内存中,这个 ken.io 在之前 Session 工作原理中也提到过。整体也是借鉴这个思路。\n用户登录成功之后,生成 AuthToken 交给客户端保存。如果是浏览器,就保存在 Cookie 中。如果是手机 App 就保存在 App 本地缓存中。本篇主要探讨基于 Web 站点的 SSO。\n用户在浏览需要登录的页面时,客户端将 AuthToken 提交给 SSO 服务校验登录状态/获取用户登录信息\n对于登录信息的存储,建议采用 Redis,使用 Redis 集群来存储登录信息,既可以保证高可用,又可以线性扩充。同时也可以让 SSO 服务满足负载均衡/可伸缩的需求。\n对象 说明 AuthToken 直接使用 UUID/GUID 即可,如果有验证 AuthToken 合法性需求,可以将 UserName+时间戳加密生成,服务端解密之后验证合法性 登录信息 通常是将 UserId,UserName 缓存起来 用户登录/登录校验 # 登录时序图\n按照上图,用户登录后 AuthToken 保存在 Cookie 中。 domain=test.com 浏览器会将 domain 设置成 .test.com,\n这样访问所有 *.test.com 的 web 站点,都会将 AuthToken 携带到服务器端。 然后通过 SSO 服务,完成对用户状态的校验/用户登录信息的获取\n登录信息获取/登录状态校验\n用户登出 # 用户登出时要做的事情很简单:\n服务端清除缓存(Redis)中的登录状态 客户端清除存储的 AuthToken 登出时序图\n跨域登录、登出 # 前面提到过,核心思路是客户端存储 AuthToken,服务器端通过 Redis 存储登录信息。由于客户端是将 AuthToken 存储在 Cookie 中的。所以跨域要解决的问题,就是如何解决 Cookie 的跨域读写问题。\n解决跨域的核心思路就是:\n登录完成之后通过回调的方式,将 AuthToken 传递给主域名之外的站点,该站点自行将 AuthToken 保存在当前域下的 Cookie 中。 登出完成之后通过回调的方式,调用非主域名站点的登出页面,完成设置 Cookie 中的 AuthToken 过期的操作。 跨域登录(主域名已登录)\n跨域登录(主域名未登录)\n跨域登出\n说明 # 关于方案:这次设计方案更多是提供实现思路。如果涉及到 APP 用户登录等情况,在访问 SSO 服务时,增加对 APP 的签名验证就好了。当然,如果有无线网关,验证签名不是问题。 关于时序图:时序图中并没有包含所有场景,只列举了核心/主要场景,另外对于一些不影响理解思路的消息能省就省了。 "},{"id":585,"href":"/zh/docs/technology/Interview/cs-basics/network/tcp-reliability-guarantee/","title":"TCP 传输可靠性保障(传输层)","section":"Network","content":" TCP 如何保证传输的可靠性? # 基于数据块传输:应用数据被分割成 TCP 认为最适合发送的数据块,再传输给网络层,数据块被称为报文段或段。 对失序数据包重新排序以及去重:TCP 为了保证不发生丢包,就给每个包一个序列号,有了序列号能够将接收到的数据根据序列号排序,并且去掉重复序列号的数据就可以实现数据包去重。 校验和 : TCP 将保持它首部和数据的检验和。这是一个端到端的检验和,目的是检测数据在传输过程中的任何变化。如果收到段的检验和有差错,TCP 将丢弃这个报文段和不确认收到此报文段。 重传机制 : 在数据包丢失或延迟的情况下,重新发送数据包,直到收到对方的确认应答(ACK)。TCP 重传机制主要有:基于计时器的重传(也就是超时重传)、快速重传(基于接收端的反馈信息来引发重传)、SACK(在快速重传的基础上,返回最近收到的报文段的序列号范围,这样客户端就知道,哪些数据包已经到达服务器了)、D-SACK(重复 SACK,在 SACK 的基础上,额外携带信息,告知发送方有哪些数据包自己重复接收了)。关于重传机制的详细介绍,可以查看 详解 TCP 超时与重传机制这篇文章。 流量控制 : TCP 连接的每一方都有固定大小的缓冲空间,TCP 的接收端只允许发送端发送接收端缓冲区能接纳的数据。当接收方来不及处理发送方的数据,能提示发送方降低发送的速率,防止包丢失。TCP 使用的流量控制协议是可变大小的滑动窗口协议(TCP 利用滑动窗口实现流量控制)。 拥塞控制 : 当网络拥塞时,减少数据的发送。TCP 在发送数据的时候,需要考虑两个因素:一是接收方的接收能力,二是网络的拥塞程度。接收方的接收能力由滑动窗口表示,表示接收方还有多少缓冲区可以用来接收数据。网络的拥塞程度由拥塞窗口表示,它是发送方根据网络状况自己维护的一个值,表示发送方认为可以在网络中传输的数据量。发送方发送数据的大小是滑动窗口和拥塞窗口的最小值,这样可以保证发送方既不会超过接收方的接收能力,也不会造成网络的过度拥塞。 TCP 如何实现流量控制? # TCP 利用滑动窗口实现流量控制。流量控制是为了控制发送方发送速率,保证接收方来得及接收。 接收方发送的确认报文中的窗口字段可以用来控制发送方窗口大小,从而影响发送方的发送速率。将窗口字段设置为 0,则发送方不能发送数据。\n为什么需要流量控制? 这是因为双方在通信的时候,发送方的速率与接收方的速率是不一定相等,如果发送方的发送速率太快,会导致接收方处理不过来。如果接收方处理不过来的话,就只能把处理不过来的数据存在 接收缓冲区(Receiving Buffers) 里(失序的数据包也会被存放在缓存区里)。如果缓存区满了发送方还在狂发数据的话,接收方只能把收到的数据包丢掉。出现丢包问题的同时又疯狂浪费着珍贵的网络资源。因此,我们需要控制发送方的发送速率,让接收方与发送方处于一种动态平衡才好。\n这里需要注意的是(常见误区):\n发送端不等同于客户端 接收端不等同于服务端 TCP 为全双工(Full-Duplex, FDX)通信,双方可以进行双向通信,客户端和服务端既可能是发送端又可能是服务端。因此,两端各有一个发送缓冲区与接收缓冲区,两端都各自维护一个发送窗口和一个接收窗口。接收窗口大小取决于应用、系统、硬件的限制(TCP 传输速率不能大于应用的数据处理速率)。通信双方的发送窗口和接收窗口的要求相同\nTCP 发送窗口可以划分成四个部分:\n已经发送并且确认的 TCP 段(已经发送并确认); 已经发送但是没有确认的 TCP 段(已经发送未确认); 未发送但是接收方准备接收的 TCP 段(可以发送); 未发送并且接收方也并未准备接受的 TCP 段(不可发送)。 TCP 发送窗口结构图示:\nSND.WND:发送窗口。 SND.UNA:Send Unacknowledged 指针,指向发送窗口的第一个字节。 SND.NXT:Send Next 指针,指向可用窗口的第一个字节。 可用窗口大小 = SND.UNA + SND.WND - SND.NXT 。\nTCP 接收窗口可以划分成三个部分:\n已经接收并且已经确认的 TCP 段(已经接收并确认); 等待接收且允许发送方发送 TCP 段(可以接收未确认); 不可接收且不允许发送方发送 TCP 段(不可接收)。 TCP 接收窗口结构图示:\n接收窗口的大小是根据接收端处理数据的速度动态调整的。 如果接收端读取数据快,接收窗口可能会扩大。 否则,它可能会缩小。\n另外,这里的滑动窗口大小只是为了演示使用,实际窗口大小通常会远远大于这个值。\nTCP 的拥塞控制是怎么实现的? # 在某段时间,若对网络中某一资源的需求超过了该资源所能提供的可用部分,网络的性能就要变坏。这种情况就叫拥塞。拥塞控制就是为了防止过多的数据注入到网络中,这样就可以使网络中的路由器或链路不致过载。拥塞控制所要做的都有一个前提,就是网络能够承受现有的网络负荷。拥塞控制是一个全局性的过程,涉及到所有的主机,所有的路由器,以及与降低网络传输性能有关的所有因素。相反,流量控制往往是点对点通信量的控制,是个端到端的问题。流量控制所要做到的就是抑制发送端发送数据的速率,以便使接收端来得及接收。\n为了进行拥塞控制,TCP 发送方要维持一个 拥塞窗口(cwnd) 的状态变量。拥塞控制窗口的大小取决于网络的拥塞程度,并且动态变化。发送方让自己的发送窗口取为拥塞窗口和接收方的接受窗口中较小的一个。\nTCP 的拥塞控制采用了四种算法,即 慢开始、 拥塞避免、快重传 和 快恢复。在网络层也可以使路由器采用适当的分组丢弃策略(如主动队列管理 AQM),以减少网络拥塞的发生。\n慢开始: 慢开始算法的思路是当主机开始发送数据时,如果立即把大量数据字节注入到网络,那么可能会引起网络阻塞,因为现在还不知道网络的符合情况。经验表明,较好的方法是先探测一下,即由小到大逐渐增大发送窗口,也就是由小到大逐渐增大拥塞窗口数值。cwnd 初始值为 1,每经过一个传播轮次,cwnd 加倍。 拥塞避免: 拥塞避免算法的思路是让拥塞窗口 cwnd 缓慢增大,即每经过一个往返时间 RTT 就把发送方的 cwnd 加 1. 快重传与快恢复: 在 TCP/IP 中,快速重传和恢复(fast retransmit and recovery,FRR)是一种拥塞控制算法,它能快速恢复丢失的数据包。没有 FRR,如果数据包丢失了,TCP 将会使用定时器来要求传输暂停。在暂停的这段时间内,没有新的或复制的数据包被发送。有了 FRR,如果接收机接收到一个不按顺序的数据段,它会立即给发送机发送一个重复确认。如果发送机接收到三个重复确认,它会假定确认件指出的数据段丢失了,并立即重传这些丢失的数据段。有了 FRR,就不会因为重传时要求的暂停被耽误。 当有单独的数据包丢失时,快速重传和恢复(FRR)能最有效地工作。当有多个数据信息包在某一段很短的时间内丢失时,它则不能很有效地工作。 ARQ 协议了解吗? # 自动重传请求(Automatic Repeat-reQuest,ARQ)是 OSI 模型中数据链路层和传输层的错误纠正协议之一。它通过使用确认和超时这两个机制,在不可靠服务的基础上实现可靠的信息传输。如果发送方在发送后一段时间之内没有收到确认信息(Acknowledgements,就是我们常说的 ACK),它通常会重新发送,直到收到确认或者重试超过一定的次数。\nARQ 包括停止等待 ARQ 协议和连续 ARQ 协议。\n停止等待 ARQ 协议 # 停止等待协议是为了实现可靠传输的,它的基本原理就是每发完一个分组就停止发送,等待对方确认(回复 ACK)。如果过了一段时间(超时时间后),还是没有收到 ACK 确认,说明没有发送成功,需要重新发送,直到收到确认后再发下一个分组;\n在停止等待协议中,若接收方收到重复分组,就丢弃该分组,但同时还要发送确认。\n1) 无差错情况:\n发送方发送分组,接收方在规定时间内收到,并且回复确认.发送方再次发送。\n2) 出现差错情况(超时重传):\n停止等待协议中超时重传是指只要超过一段时间仍然没有收到确认,就重传前面发送过的分组(认为刚才发送过的分组丢失了)。因此每发送完一个分组需要设置一个超时计时器,其重传时间应比数据在分组传输的平均往返时间更长一些。这种自动重传方式常称为 自动重传请求 ARQ 。另外在停止等待协议中若收到重复分组,就丢弃该分组,但同时还要发送确认。\n3) 确认丢失和确认迟到\n确认丢失:确认消息在传输过程丢失。当 A 发送 M1 消息,B 收到后,B 向 A 发送了一个 M1 确认消息,但却在传输过程中丢失。而 A 并不知道,在超时计时过后,A 重传 M1 消息,B 再次收到该消息后采取以下两点措施:1. 丢弃这个重复的 M1 消息,不向上层交付。 2. 向 A 发送确认消息。(不会认为已经发送过了,就不再发送。A 能重传,就证明 B 的确认消息丢失)。 确认迟到:确认消息在传输过程中迟到。A 发送 M1 消息,B 收到并发送确认。在超时时间内没有收到确认消息,A 重传 M1 消息,B 仍然收到并继续发送确认消息(B 收到了 2 份 M1)。此时 A 收到了 B 第二次发送的确认消息。接着发送其他数据。过了一会,A 收到了 B 第一次发送的对 M1 的确认消息(A 也收到了 2 份确认消息)。处理如下:1. A 收到重复的确认后,直接丢弃。2. B 收到重复的 M1 后,也直接丢弃重复的 M1。 连续 ARQ 协议 # 连续 ARQ 协议可提高信道利用率。发送方维持一个发送窗口,凡位于发送窗口内的分组可以连续发送出去,而不需要等待对方确认。接收方一般采用累计确认,对按序到达的最后一个分组发送确认,表明到这个分组为止的所有分组都已经正确收到了。\n优点: 信道利用率高,容易实现,即使确认丢失,也不必重传。 缺点: 不能向发送方反映出接收方已经正确收到的所有分组的信息。 比如:发送方发送了 5 条 消息,中间第三条丢失(3 号),这时接收方只能对前两个发送确认。发送方无法知道后三个分组的下落,而只好把后三个全部重传一次。这也叫 Go-Back-N(回退 N),表示需要退回来重传已经发送过的 N 个消息。 超时重传如何实现?超时重传时间怎么确定? # 当发送方发送数据之后,它启动一个定时器,等待目的端确认收到这个报文段。接收端实体对已成功收到的包发回一个相应的确认信息(ACK)。如果发送端实体在合理的往返时延(RTT)内未收到确认消息,那么对应的数据包就被假设为 已丢失并进行重传。\nRTT(Round Trip Time):往返时间,也就是数据包从发出去到收到对应 ACK 的时间。 RTO(Retransmission Time Out):重传超时时间,即从数据发送时刻算起,超过这个时间便执行重传。 RTO 的确定是一个关键问题,因为它直接影响到 TCP 的性能和效率。如果 RTO 设置得太小,会导致不必要的重传,增加网络负担;如果 RTO 设置得太大,会导致数据传输的延迟,降低吞吐量。因此,RTO 应该根据网络的实际状况,动态地进行调整。\nRTT 的值会随着网络的波动而变化,所以 TCP 不能直接使用 RTT 作为 RTO。为了动态地调整 RTO,TCP 协议采用了一些算法,如加权移动平均(EWMA)算法,Karn 算法,Jacobson 算法等,这些算法都是根据往返时延(RTT)的测量和变化来估计 RTO 的值。\n参考 # 《计算机网络(第 7 版)》 《图解 HTTP》 https://www.9tut.com/tcp-and-udp-tutorial https://github.com/wolverinn/Waking-Up/blob/master/Computer%20Network.md TCP Flow Control— https://www.brianstorti.com/tcp-flow-control/ TCP 流量控制(Flow Control): https://notfalse.net/24/tcp-flow-control TCP 之滑动窗口原理 : https://cloud.tencent.com/developer/article/1857363 "},{"id":586,"href":"/zh/docs/technology/Interview/cs-basics/network/tcp-connection-and-disconnection/","title":"TCP 三次握手和四次挥手(传输层)","section":"Network","content":"为了准确无误地把数据送达目标处,TCP 协议采用了三次握手策略。\n建立连接-TCP 三次握手 # 建立一个 TCP 连接需要“三次握手”,缺一不可:\n一次握手:客户端发送带有 SYN(SEQ=x) 标志的数据包 -\u0026gt; 服务端,然后客户端进入 SYN_SEND 状态,等待服务端的确认; 二次握手:服务端发送带有 SYN+ACK(SEQ=y,ACK=x+1) 标志的数据包 –\u0026gt; 客户端,然后服务端进入 SYN_RECV 状态; 三次握手:客户端发送带有 ACK(ACK=y+1) 标志的数据包 –\u0026gt; 服务端,然后客户端和服务端都进入ESTABLISHED 状态,完成 TCP 三次握手。 当建立了 3 次握手之后,客户端和服务端就可以传输数据啦!\n什么是半连接队列和全连接队列? # 在 TCP 三次握手过程中,Linux 内核会维护两个队列来管理连接请求:\n半连接队列(也称 SYN Queue):当服务端收到客户端的 SYN 请求时,此时双方还没有完全建立连接,它会把半连接状态的连接放在半连接队列。 全连接队列(也称 Accept Queue):当服务端收到客户端对 ACK 响应时,意味着三次握手成功完成,服务端会将该连接从半连接队列移动到全连接队列。如果未收到客户端的 ACK 响应,会进行重传,重传的等待时间通常是指数增长的。如果重传次数超过系统规定的最大重传次数,系统将从半连接队列中删除该连接信息。 这两个队列的存在是为了处理并发连接请求,确保服务端能够有效地管理新的连接请求。另外,新的连接请求被拒绝或忽略除了和每个队列的大小限制有关系之外,还和很多其他因素有关系,这里就不详细介绍了,整体逻辑比较复杂。\n为什么要三次握手? # 三次握手的目的是建立可靠的通信信道,说到通讯,简单来说就是数据的发送与接收,而三次握手最主要的目的就是双方确认自己与对方的发送与接收是正常的。\n第一次握手:Client 什么都不能确认;Server 确认了对方发送正常,自己接收正常 第二次握手:Client 确认了:自己发送、接收正常,对方发送、接收正常;Server 确认了:对方发送正常,自己接收正常 第三次握手:Client 确认了:自己发送、接收正常,对方发送、接收正常;Server 确认了:自己发送、接收正常,对方发送、接收正常 三次握手就能确认双方收发功能都正常,缺一不可。\n更详细的解答可以看这个: TCP 为什么是三次握手,而不是两次或四次? - 车小胖的回答 - 知乎 。\n第 2 次握手传回了 ACK,为什么还要传回 SYN? # 服务端传回发送端所发送的 ACK 是为了告诉客户端:“我接收到的信息确实就是你所发送的信号了”,这表明从客户端到服务端的通信是正常的。回传 SYN 则是为了建立并确认从服务端到客户端的通信。\nSYN 同步序列编号(Synchronize Sequence Numbers) 是 TCP/IP 建立连接时使用的握手信号。在客户机和服务端之间建立正常的 TCP 网络连接时,客户机首先发出一个 SYN 消息,服务端使用 SYN-ACK 应答表示接收到了这个消息,最后客户机再以 ACK(Acknowledgement)消息响应。这样在客户机和服务端之间才能建立起可靠的 TCP 连接,数据才可以在客户机和服务端之间传递。\n三次握手过程中可以携带数据吗? # 在 TCP 三次握手过程中,第三次握手是可以携带数据的(客户端发送完 ACK 确认包之后就进入 ESTABLISHED 状态了),这一点在 RFC 793 文档中有提到。也就是说,一旦完成了前两次握手,TCP 协议允许数据在第三次握手时开始传输。\n如果第三次握手的 ACK 确认包丢失,但是客户端已经开始发送携带数据的包,那么服务端在收到这个携带数据的包时,如果该包中包含了 ACK 标记,服务端会将其视为有效的第三次握手确认。这样,连接就被认为是建立的,服务端会处理该数据包,并继续正常的数据传输流程。\n断开连接-TCP 四次挥手 # 断开一个 TCP 连接则需要“四次挥手”,缺一不可:\n第一次挥手:客户端发送一个 FIN(SEQ=x) 标志的数据包-\u0026gt;服务端,用来关闭客户端到服务端的数据传送。然后客户端进入 FIN-WAIT-1 状态。 第二次挥手:服务端收到这个 FIN(SEQ=X) 标志的数据包,它发送一个 ACK (ACK=x+1)标志的数据包-\u0026gt;客户端 。然后服务端进入 CLOSE-WAIT 状态,客户端进入 FIN-WAIT-2 状态。 第三次挥手:服务端发送一个 FIN (SEQ=y)标志的数据包-\u0026gt;客户端,请求关闭连接,然后服务端进入 LAST-ACK 状态。 第四次挥手:客户端发送 ACK (ACK=y+1)标志的数据包-\u0026gt;服务端,然后客户端进入TIME-WAIT状态,服务端在收到 ACK (ACK=y+1)标志的数据包后进入 CLOSE 状态。此时如果客户端等待 2MSL 后依然没有收到回复,就证明服务端已正常关闭,随后客户端也可以关闭连接了。 只要四次挥手没有结束,客户端和服务端就可以继续传输数据!\n为什么要四次挥手? # TCP 是全双工通信,可以双向传输数据。任何一方都可以在数据传送结束后发出连接释放的通知,待对方确认后进入半关闭状态。当另一方也没有数据再发送的时候,则发出连接释放通知,对方确认后就完全关闭了 TCP 连接。\n举个例子:A 和 B 打电话,通话即将结束后。\n第一次挥手:A 说“我没啥要说的了” 第二次挥手:B 回答“我知道了”,但是 B 可能还会有要说的话,A 不能要求 B 跟着自己的节奏结束通话 第三次挥手:于是 B 可能又巴拉巴拉说了一通,最后 B 说“我说完了” 第四次挥手:A 回答“知道了”,这样通话才算结束。 为什么不能把服务端发送的 ACK 和 FIN 合并起来,变成三次挥手? # 因为服务端收到客户端断开连接的请求时,可能还有一些数据没有发完,这时先回复 ACK,表示接收到了断开连接的请求。等到数据发完之后再发 FIN,断开服务端到客户端的数据传送。\n如果第二次挥手时服务端的 ACK 没有送达客户端,会怎样? # 客户端没有收到 ACK 确认,会重新发送 FIN 请求。\n为什么第四次挥手客户端需要等待 2*MSL(报文段最长寿命)时间后才进入 CLOSED 状态? # 第四次挥手时,客户端发送给服务端的 ACK 有可能丢失,如果服务端因为某些原因而没有收到 ACK 的话,服务端就会重发 FIN,如果客户端在 2*MSL 的时间内收到了 FIN,就会重新发送 ACK 并再次等待 2MSL,防止 Server 没有收到 ACK 而不断重发 FIN。\nMSL(Maximum Segment Lifetime) : 一个片段在网络中最大的存活时间,2MSL 就是一个发送和一个回复所需的最大时间。如果直到 2MSL,Client 都没有再次收到 FIN,那么 Client 推断 ACK 已经被成功接收,则结束 TCP 连接。\n参考 # 《计算机网络(第 7 版)》\n《图解 HTTP》\nTCP and UDP Tutorial: https://www.9tut.com/tcp-and-udp-tutorial\n从一次线上问题说起,详解 TCP 半连接队列、全连接队列: https://mp.weixin.qq.com/s/YpSlU1yaowTs-pF6R43hMw\n"},{"id":587,"href":"/zh/docs/technology/Interview/java/concurrent/threadlocal/","title":"ThreadLocal 详解","section":"Concurrent","content":" 本文来自一枝花算不算浪漫投稿, 原文地址: https://juejin.cn/post/6844904151567040519。\n前言 # 全文共 10000+字,31 张图,这篇文章同样耗费了不少的时间和精力才创作完成,原创不易,请大家点点关注+在看,感谢。\n对于ThreadLocal,大家的第一反应可能是很简单呀,线程的变量副本,每个线程隔离。那这里有几个问题大家可以思考一下:\nThreadLocal的 key 是弱引用,那么在 ThreadLocal.get()的时候,发生GC之后,key 是否为null? ThreadLocal中ThreadLocalMap的数据结构? ThreadLocalMap的Hash 算法? ThreadLocalMap中Hash 冲突如何解决? ThreadLocalMap的扩容机制? ThreadLocalMap中过期 key 的清理机制?探测式清理和启发式清理流程? ThreadLocalMap.set()方法实现原理? ThreadLocalMap.get()方法实现原理? 项目中ThreadLocal使用情况?遇到的坑? …… 上述的一些问题你是否都已经掌握的很清楚了呢?本文将围绕这些问题使用图文方式来剖析ThreadLocal的点点滴滴。\n目录 # 注明: 本文源码基于JDK 1.8\nThreadLocal代码演示 # 我们先看下ThreadLocal使用示例:\npublic class ThreadLocalTest { private List\u0026lt;String\u0026gt; messages = Lists.newArrayList(); public static final ThreadLocal\u0026lt;ThreadLocalTest\u0026gt; holder = ThreadLocal.withInitial(ThreadLocalTest::new); public static void add(String message) { holder.get().messages.add(message); } public static List\u0026lt;String\u0026gt; clear() { List\u0026lt;String\u0026gt; messages = holder.get().messages; holder.remove(); System.out.println(\u0026#34;size: \u0026#34; + holder.get().messages.size()); return messages; } public static void main(String[] args) { ThreadLocalTest.add(\u0026#34;一枝花算不算浪漫\u0026#34;); System.out.println(holder.get().messages); ThreadLocalTest.clear(); } } 打印结果:\n[一枝花算不算浪漫] size: 0 ThreadLocal对象可以提供线程局部变量,每个线程Thread拥有一份自己的副本变量,多个线程互不干扰。\nThreadLocal的数据结构 # Thread类有一个类型为ThreadLocal.ThreadLocalMap的实例变量threadLocals,也就是说每个线程有一个自己的ThreadLocalMap。\nThreadLocalMap有自己的独立实现,可以简单地将它的key视作ThreadLocal,value为代码中放入的值(实际上key并不是ThreadLocal本身,而是它的一个弱引用)。\n每个线程在往ThreadLocal里放值的时候,都会往自己的ThreadLocalMap里存,读也是以ThreadLocal作为引用,在自己的map里找对应的key,从而实现了线程隔离。\nThreadLocalMap有点类似HashMap的结构,只是HashMap是由数组+链表实现的,而ThreadLocalMap中并没有链表结构。\n我们还要注意Entry, 它的key是ThreadLocal\u0026lt;?\u0026gt; k ,继承自WeakReference, 也就是我们常说的弱引用类型。\nGC 之后 key 是否为 null? # 回应开头的那个问题, ThreadLocal 的key是弱引用,那么在ThreadLocal.get()的时候,发生GC之后,key是否是null?\n为了搞清楚这个问题,我们需要搞清楚Java的四种引用类型:\n强引用:我们常常 new 出来的对象就是强引用类型,只要强引用存在,垃圾回收器将永远不会回收被引用的对象,哪怕内存不足的时候 软引用:使用 SoftReference 修饰的对象被称为软引用,软引用指向的对象在内存要溢出的时候被回收 弱引用:使用 WeakReference 修饰的对象被称为弱引用,只要发生垃圾回收,若这个对象只被弱引用指向,那么就会被回收 虚引用:虚引用是最弱的引用,在 Java 中使用 PhantomReference 进行定义。虚引用中唯一的作用就是用队列接收对象即将死亡的通知 接着再来看下代码,我们使用反射的方式来看看GC后ThreadLocal中的数据情况:(下面代码来源自: https://blog.csdn.net/thewindkee/article/details/103726942 本地运行演示 GC 回收场景)\npublic class ThreadLocalDemo { public static void main(String[] args) throws NoSuchFieldException, IllegalAccessException, InterruptedException { Thread t = new Thread(()-\u0026gt;test(\u0026#34;abc\u0026#34;,false)); t.start(); t.join(); System.out.println(\u0026#34;--gc后--\u0026#34;); Thread t2 = new Thread(() -\u0026gt; test(\u0026#34;def\u0026#34;, true)); t2.start(); t2.join(); } private static void test(String s,boolean isGC) { try { new ThreadLocal\u0026lt;\u0026gt;().set(s); if (isGC) { System.gc(); } Thread t = Thread.currentThread(); Class\u0026lt;? extends Thread\u0026gt; clz = t.getClass(); Field field = clz.getDeclaredField(\u0026#34;threadLocals\u0026#34;); field.setAccessible(true); Object ThreadLocalMap = field.get(t); Class\u0026lt;?\u0026gt; tlmClass = ThreadLocalMap.getClass(); Field tableField = tlmClass.getDeclaredField(\u0026#34;table\u0026#34;); tableField.setAccessible(true); Object[] arr = (Object[]) tableField.get(ThreadLocalMap); for (Object o : arr) { if (o != null) { Class\u0026lt;?\u0026gt; entryClass = o.getClass(); Field valueField = entryClass.getDeclaredField(\u0026#34;value\u0026#34;); Field referenceField = entryClass.getSuperclass().getSuperclass().getDeclaredField(\u0026#34;referent\u0026#34;); valueField.setAccessible(true); referenceField.setAccessible(true); System.out.println(String.format(\u0026#34;弱引用key:%s,值:%s\u0026#34;, referenceField.get(o), valueField.get(o))); } } } catch (Exception e) { e.printStackTrace(); } } } 结果如下:\n弱引用key:java.lang.ThreadLocal@433619b6,值:abc 弱引用key:java.lang.ThreadLocal@418a15e3,值:java.lang.ref.SoftReference@bf97a12 --gc后-- 弱引用key:null,值:def 如图所示,因为这里创建的ThreadLocal并没有指向任何值,也就是没有任何引用:\nnew ThreadLocal\u0026lt;\u0026gt;().set(s); 所以这里在GC之后,key就会被回收,我们看到上面debug中的referent=null, 如果改动一下代码:\n这个问题刚开始看,如果没有过多思考,弱引用,还有垃圾回收,那么肯定会觉得是null。\n其实是不对的,因为题目说的是在做 ThreadLocal.get() 操作,证明其实还是有强引用存在的,所以 key 并不为 null,如下图所示,ThreadLocal的强引用仍然是存在的。\n如果我们的强引用不存在的话,那么 key 就会被回收,也就是会出现我们 value 没被回收,key 被回收,导致 value 永远存在,出现内存泄漏。\nThreadLocal.set()方法源码详解 # ThreadLocal中的set方法原理如上图所示,很简单,主要是判断ThreadLocalMap是否存在,然后使用ThreadLocal中的set方法进行数据处理。\n代码如下:\npublic void set(T value) { Thread t = Thread.currentThread(); ThreadLocalMap map = getMap(t); if (map != null) map.set(this, value); else createMap(t, value); } void createMap(Thread t, T firstValue) { t.threadLocals = new ThreadLocalMap(this, firstValue); } 主要的核心逻辑还是在ThreadLocalMap中的,一步步往下看,后面还有更详细的剖析。\nThreadLocalMap Hash 算法 # 既然是Map结构,那么ThreadLocalMap当然也要实现自己的hash算法来解决散列表数组冲突问题。\nint i = key.threadLocalHashCode \u0026amp; (len-1); ThreadLocalMap中hash算法很简单,这里i就是当前 key 在散列表中对应的数组下标位置。\n这里最关键的就是threadLocalHashCode值的计算,ThreadLocal中有一个属性为HASH_INCREMENT = 0x61c88647\npublic class ThreadLocal\u0026lt;T\u0026gt; { private final int threadLocalHashCode = nextHashCode(); private static AtomicInteger nextHashCode = new AtomicInteger(); private static final int HASH_INCREMENT = 0x61c88647; private static int nextHashCode() { return nextHashCode.getAndAdd(HASH_INCREMENT); } static class ThreadLocalMap { ThreadLocalMap(ThreadLocal\u0026lt;?\u0026gt; firstKey, Object firstValue) { table = new Entry[INITIAL_CAPACITY]; int i = firstKey.threadLocalHashCode \u0026amp; (INITIAL_CAPACITY - 1); table[i] = new Entry(firstKey, firstValue); size = 1; setThreshold(INITIAL_CAPACITY); } } } 每当创建一个ThreadLocal对象,这个ThreadLocal.nextHashCode 这个值就会增长 0x61c88647 。\n这个值很特殊,它是斐波那契数 也叫 黄金分割数。hash增量为 这个数字,带来的好处就是 hash 分布非常均匀。\n我们自己可以尝试下:\n可以看到产生的哈希码分布很均匀,这里不去细纠斐波那契具体算法,感兴趣的可以自行查阅相关资料。\nThreadLocalMap Hash 冲突 # 注明: 下面所有示例图中,绿色块Entry代表正常数据,灰色块代表Entry的key值为null,已被垃圾回收。白色块表示Entry为null。\n虽然ThreadLocalMap中使用了黄金分割数来作为hash计算因子,大大减少了Hash冲突的概率,但是仍然会存在冲突。\nHashMap中解决冲突的方法是在数组上构造一个链表结构,冲突的数据挂载到链表上,如果链表长度超过一定数量则会转化成红黑树。\n而 ThreadLocalMap 中并没有链表结构,所以这里不能使用 HashMap 解决冲突的方式了。\n如上图所示,如果我们插入一个value=27的数据,通过 hash 计算后应该落入槽位 4 中,而槽位 4 已经有了 Entry 数据。\n此时就会线性向后查找,一直找到 Entry 为 null 的槽位才会停止查找,将当前元素放入此槽位中。当然迭代过程中还有其他的情况,比如遇到了 Entry 不为 null 且 key 值相等的情况,还有 Entry 中的 key 值为 null 的情况等等都会有不同的处理,后面会一一详细讲解。\n这里还画了一个Entry中的key为null的数据(Entry=2 的灰色块数据),因为key值是弱引用类型,所以会有这种数据存在。在set过程中,如果遇到了key过期的Entry数据,实际上是会进行一轮探测式清理操作的,具体操作方式后面会讲到。\nThreadLocalMap.set()详解 # ThreadLocalMap.set()原理图解 # 看完了ThreadLocal hash 算法后,我们再来看set是如何实现的。\n往ThreadLocalMap中set数据(新增或者更新数据)分为好几种情况,针对不同的情况我们画图来说明。\n第一种情况: 通过hash计算后的槽位对应的Entry数据为空:\n这里直接将数据放到该槽位即可。\n第二种情况: 槽位数据不为空,key值与当前ThreadLocal通过hash计算获取的key值一致:\n这里直接更新该槽位的数据。\n第三种情况: 槽位数据不为空,往后遍历过程中,在找到Entry为null的槽位之前,没有遇到key过期的Entry:\n遍历散列数组,线性往后查找,如果找到Entry为null的槽位,则将数据放入该槽位中,或者往后遍历过程中,遇到了key 值相等的数据,直接更新即可。\n第四种情况: 槽位数据不为空,往后遍历过程中,在找到Entry为null的槽位之前,遇到key过期的Entry,如下图,往后遍历过程中,遇到了index=7的槽位数据Entry的key=null:\n散列数组下标为 7 位置对应的Entry数据key为null,表明此数据key值已经被垃圾回收掉了,此时就会执行replaceStaleEntry()方法,该方法含义是替换过期数据的逻辑,以index=7位起点开始遍历,进行探测式数据清理工作。\n初始化探测式清理过期数据扫描的开始位置:slotToExpunge = staleSlot = 7\n以当前staleSlot开始 向前迭代查找,找其他过期的数据,然后更新过期数据起始扫描下标slotToExpunge。for循环迭代,直到碰到Entry为null结束。\n如果找到了过期的数据,继续向前迭代,直到遇到Entry=null的槽位才停止迭代,如下图所示,slotToExpunge 被更新为 0:\n以当前节点(index=7)向前迭代,检测是否有过期的Entry数据,如果有则更新slotToExpunge值。碰到null则结束探测。以上图为例slotToExpunge被更新为 0。\n上面向前迭代的操作是为了更新探测清理过期数据的起始下标slotToExpunge的值,这个值在后面会讲解,它是用来判断当前过期槽位staleSlot之前是否还有过期元素。\n接着开始以staleSlot位置(index=7)向后迭代,如果找到了相同 key 值的 Entry 数据:\n从当前节点staleSlot向后查找key值相等的Entry元素,找到后更新Entry的值并交换staleSlot元素的位置(staleSlot位置为过期元素),更新Entry数据,然后开始进行过期Entry的清理工作,如下图所示:\n向后遍历过程中,如果没有找到相同 key 值的 Entry 数据:\n从当前节点staleSlot向后查找key值相等的Entry元素,直到Entry为null则停止寻找。通过上图可知,此时table中没有key值相同的Entry。\n创建新的Entry,替换table[stableSlot]位置:\n替换完成后也是进行过期元素清理工作,清理工作主要是有两个方法:expungeStaleEntry()和cleanSomeSlots(),具体细节后面会讲到,请继续往后看。\nThreadLocalMap.set()源码详解 # 上面已经用图的方式解析了set()实现的原理,其实已经很清晰了,我们接着再看下源码:\njava.lang.ThreadLocal.ThreadLocalMap.set():\nprivate void set(ThreadLocal\u0026lt;?\u0026gt; key, Object value) { Entry[] tab = table; int len = tab.length; int i = key.threadLocalHashCode \u0026amp; (len-1); for (Entry e = tab[i]; e != null; e = tab[i = nextIndex(i, len)]) { ThreadLocal\u0026lt;?\u0026gt; k = e.get(); if (k == key) { e.value = value; return; } if (k == null) { replaceStaleEntry(key, value, i); return; } } tab[i] = new Entry(key, value); int sz = ++size; if (!cleanSomeSlots(i, sz) \u0026amp;\u0026amp; sz \u0026gt;= threshold) rehash(); } 这里会通过key来计算在散列表中的对应位置,然后以当前key对应的桶的位置向后查找,找到可以使用的桶。\nEntry[] tab = table; int len = tab.length; int i = key.threadLocalHashCode \u0026amp; (len-1); 什么情况下桶才是可以使用的呢?\nk = key 说明是替换操作,可以使用 碰到一个过期的桶,执行替换逻辑,占用过期桶 查找过程中,碰到桶中Entry=null的情况,直接使用 接着就是执行for循环遍历,向后查找,我们先看下nextIndex()、prevIndex()方法实现:\nprivate static int nextIndex(int i, int len) { return ((i + 1 \u0026lt; len) ? i + 1 : 0); } private static int prevIndex(int i, int len) { return ((i - 1 \u0026gt;= 0) ? i - 1 : len - 1); } 接着看剩下for循环中的逻辑:\n遍历当前key值对应的桶中Entry数据为空,这说明散列数组这里没有数据冲突,跳出for循环,直接set数据到对应的桶中 如果key值对应的桶中Entry数据不为空\n2.1 如果k = key,说明当前set操作是一个替换操作,做替换逻辑,直接返回\n2.2 如果key = null,说明当前桶位置的Entry是过期数据,执行replaceStaleEntry()方法(核心方法),然后返回 for循环执行完毕,继续往下执行说明向后迭代的过程中遇到了entry为null的情况\n3.1 在Entry为null的桶中创建一个新的Entry对象\n3.2 执行++size操作 调用cleanSomeSlots()做一次启发式清理工作,清理散列数组中Entry的key过期的数据\n4.1 如果清理工作完成后,未清理到任何数据,且size超过了阈值(数组长度的 2/3),进行rehash()操作\n4.2 rehash()中会先进行一轮探测式清理,清理过期key,清理完成后如果size \u0026gt;= threshold - threshold / 4,就会执行真正的扩容逻辑(扩容逻辑往后看) 接着重点看下replaceStaleEntry()方法,replaceStaleEntry()方法提供替换过期数据的功能,我们可以对应上面第四种情况的原理图来再回顾下,具体代码如下:\njava.lang.ThreadLocal.ThreadLocalMap.replaceStaleEntry():\nprivate void replaceStaleEntry(ThreadLocal\u0026lt;?\u0026gt; key, Object value, int staleSlot) { Entry[] tab = table; int len = tab.length; Entry e; int slotToExpunge = staleSlot; for (int i = prevIndex(staleSlot, len); (e = tab[i]) != null; i = prevIndex(i, len)) if (e.get() == null) slotToExpunge = i; for (int i = nextIndex(staleSlot, len); (e = tab[i]) != null; i = nextIndex(i, len)) { ThreadLocal\u0026lt;?\u0026gt; k = e.get(); if (k == key) { e.value = value; tab[i] = tab[staleSlot]; tab[staleSlot] = e; if (slotToExpunge == staleSlot) slotToExpunge = i; cleanSomeSlots(expungeStaleEntry(slotToExpunge), len); return; } if (k == null \u0026amp;\u0026amp; slotToExpunge == staleSlot) slotToExpunge = i; } tab[staleSlot].value = null; tab[staleSlot] = new Entry(key, value); if (slotToExpunge != staleSlot) cleanSomeSlots(expungeStaleEntry(slotToExpunge), len); } slotToExpunge表示开始探测式清理过期数据的开始下标,默认从当前的staleSlot开始。以当前的staleSlot开始,向前迭代查找,找到没有过期的数据,for循环一直碰到Entry为null才会结束。如果向前找到了过期数据,更新探测清理过期数据的开始下标为 i,即slotToExpunge=i\nfor (int i = prevIndex(staleSlot, len); (e = tab[i]) != null; i = prevIndex(i, len)){ if (e.get() == null){ slotToExpunge = i; } } 接着开始从staleSlot向后查找,也是碰到Entry为null的桶结束。 如果迭代过程中,碰到 k == key,这说明这里是替换逻辑,替换新数据并且交换当前staleSlot位置。如果slotToExpunge == staleSlot,这说明replaceStaleEntry()一开始向前查找过期数据时并未找到过期的Entry数据,接着向后查找过程中也未发现过期数据,修改开始探测式清理过期数据的下标为当前循环的 index,即slotToExpunge = i。最后调用cleanSomeSlots(expungeStaleEntry(slotToExpunge), len);进行启发式过期数据清理。\nif (k == key) { e.value = value; tab[i] = tab[staleSlot]; tab[staleSlot] = e; if (slotToExpunge == staleSlot) slotToExpunge = i; cleanSomeSlots(expungeStaleEntry(slotToExpunge), len); return; } cleanSomeSlots()和expungeStaleEntry()方法后面都会细讲,这两个是和清理相关的方法,一个是过期key相关Entry的启发式清理(Heuristically scan),另一个是过期key相关Entry的探测式清理。\n如果 k != key则会接着往下走,k == null说明当前遍历的Entry是一个过期数据,slotToExpunge == staleSlot说明,一开始的向前查找数据并未找到过期的Entry。如果条件成立,则更新slotToExpunge 为当前位置,这个前提是前驱节点扫描时未发现过期数据。\nif (k == null \u0026amp;\u0026amp; slotToExpunge == staleSlot) slotToExpunge = i; 往后迭代的过程中如果没有找到k == key的数据,且碰到Entry为null的数据,则结束当前的迭代操作。此时说明这里是一个添加的逻辑,将新的数据添加到table[staleSlot] 对应的slot中。\ntab[staleSlot].value = null; tab[staleSlot] = new Entry(key, value); 最后判断除了staleSlot以外,还发现了其他过期的slot数据,就要开启清理数据的逻辑:\nif (slotToExpunge != staleSlot) cleanSomeSlots(expungeStaleEntry(slotToExpunge), len); ThreadLocalMap过期 key 的探测式清理流程 # 上面我们有提及ThreadLocalMap的两种过期key数据清理方式:探测式清理和启发式清理。\n我们先讲下探测式清理,也就是expungeStaleEntry方法,遍历散列数组,从开始位置向后探测清理过期数据,将过期数据的Entry设置为null,沿途中碰到未过期的数据则将此数据rehash后重新在table数组中定位,如果定位的位置已经有了数据,则会将未过期的数据放到最靠近此位置的Entry=null的桶中,使rehash后的Entry数据距离正确的桶的位置更近一些。操作逻辑如下:\n如上图,set(27) 经过 hash 计算后应该落到index=4的桶中,由于index=4桶已经有了数据,所以往后迭代最终数据放入到index=7的桶中,放入后一段时间后index=5中的Entry数据key变为了null\n如果再有其他数据set到map中,就会触发探测式清理操作。\n如上图,执行探测式清理后,index=5的数据被清理掉,继续往后迭代,到index=7的元素时,经过rehash后发现该元素正确的index=4,而此位置已经有了数据,往后查找离index=4最近的Entry=null的节点(刚被探测式清理掉的数据:index=5),找到后移动index= 7的数据到index=5中,此时桶的位置离正确的位置index=4更近了。\n经过一轮探测式清理后,key过期的数据会被清理掉,没过期的数据经过rehash重定位后所处的桶位置理论上更接近i= key.hashCode \u0026amp; (tab.len - 1)的位置。这种优化会提高整个散列表查询性能。\n接着看下expungeStaleEntry()具体流程,我们还是以先原理图后源码讲解的方式来一步步梳理:\n我们假设expungeStaleEntry(3) 来调用此方法,如上图所示,我们可以看到ThreadLocalMap中table的数据情况,接着执行清理操作:\n第一步是清空当前staleSlot位置的数据,index=3位置的Entry变成了null。然后接着往后探测:\n执行完第二步后,index=4 的元素挪到 index=3 的槽位中。\n继续往后迭代检查,碰到正常数据,计算该数据位置是否偏移,如果被偏移,则重新计算slot位置,目的是让正常数据尽可能存放在正确位置或离正确位置更近的位置\n在往后迭代的过程中碰到空的槽位,终止探测,这样一轮探测式清理工作就完成了,接着我们继续看看具体实现源代码:\nprivate int expungeStaleEntry(int staleSlot) { Entry[] tab = table; int len = tab.length; tab[staleSlot].value = null; tab[staleSlot] = null; size--; Entry e; int i; for (i = nextIndex(staleSlot, len); (e = tab[i]) != null; i = nextIndex(i, len)) { ThreadLocal\u0026lt;?\u0026gt; k = e.get(); if (k == null) { e.value = null; tab[i] = null; size--; } else { int h = k.threadLocalHashCode \u0026amp; (len - 1); if (h != i) { tab[i] = null; while (tab[h] != null) h = nextIndex(h, len); tab[h] = e; } } } return i; } 这里我们还是以staleSlot=3 来做示例说明,首先是将tab[staleSlot]槽位的数据清空,然后设置size-- 接着以staleSlot位置往后迭代,如果遇到k==null的过期数据,也是清空该槽位数据,然后size--\nThreadLocal\u0026lt;?\u0026gt; k = e.get(); if (k == null) { e.value = null; tab[i] = null; size--; } 如果key没有过期,重新计算当前key的下标位置是不是当前槽位下标位置,如果不是,那么说明产生了hash冲突,此时以新计算出来正确的槽位位置往后迭代,找到最近一个可以存放entry的位置。\nint h = k.threadLocalHashCode \u0026amp; (len - 1); if (h != i) { tab[i] = null; while (tab[h] != null) h = nextIndex(h, len); tab[h] = e; } 这里是处理正常的产生Hash冲突的数据,经过迭代后,有过Hash冲突数据的Entry位置会更靠近正确位置,这样的话,查询的时候 效率才会更高。\nThreadLocalMap扩容机制 # 在ThreadLocalMap.set()方法的最后,如果执行完启发式清理工作后,未清理到任何数据,且当前散列数组中Entry的数量已经达到了列表的扩容阈值(len*2/3),就开始执行rehash()逻辑:\nif (!cleanSomeSlots(i, sz) \u0026amp;\u0026amp; sz \u0026gt;= threshold) rehash(); 接着看下rehash()具体实现:\nprivate void rehash() { expungeStaleEntries(); if (size \u0026gt;= threshold - threshold / 4) resize(); } private void expungeStaleEntries() { Entry[] tab = table; int len = tab.length; for (int j = 0; j \u0026lt; len; j++) { Entry e = tab[j]; if (e != null \u0026amp;\u0026amp; e.get() == null) expungeStaleEntry(j); } } 这里首先是会进行探测式清理工作,从table的起始位置往后清理,上面有分析清理的详细流程。清理完成之后,table中可能有一些key为null的Entry数据被清理掉,所以此时通过判断size \u0026gt;= threshold - threshold / 4 也就是size \u0026gt;= threshold * 3/4 来决定是否扩容。\n我们还记得上面进行rehash()的阈值是size \u0026gt;= threshold,所以当面试官套路我们ThreadLocalMap扩容机制的时候 我们一定要说清楚这两个步骤:\n接着看看具体的resize()方法,为了方便演示,我们以oldTab.len=8来举例:\n扩容后的tab的大小为oldLen * 2,然后遍历老的散列表,重新计算hash位置,然后放到新的tab数组中,如果出现hash冲突则往后寻找最近的entry为null的槽位,遍历完成之后,oldTab中所有的entry数据都已经放入到新的tab中了。重新计算tab下次扩容的阈值,具体代码如下:\nprivate void resize() { Entry[] oldTab = table; int oldLen = oldTab.length; int newLen = oldLen * 2; Entry[] newTab = new Entry[newLen]; int count = 0; for (int j = 0; j \u0026lt; oldLen; ++j) { Entry e = oldTab[j]; if (e != null) { ThreadLocal\u0026lt;?\u0026gt; k = e.get(); if (k == null) { e.value = null; } else { int h = k.threadLocalHashCode \u0026amp; (newLen - 1); while (newTab[h] != null) h = nextIndex(h, newLen); newTab[h] = e; count++; } } } setThreshold(newLen); size = count; table = newTab; } ThreadLocalMap.get()详解 # 上面已经看完了set()方法的源码,其中包括set数据、清理数据、优化数据桶的位置等操作,接着看看get()操作的原理。\nThreadLocalMap.get()图解 # 第一种情况: 通过查找key值计算出散列表中slot位置,然后该slot位置中的Entry.key和查找的key一致,则直接返回:\n第二种情况: slot位置中的Entry.key和要查找的key不一致:\n我们以get(ThreadLocal1)为例,通过hash计算后,正确的slot位置应该是 4,而index=4的槽位已经有了数据,且key值不等于ThreadLocal1,所以需要继续往后迭代查找。\n迭代到index=5的数据时,此时Entry.key=null,触发一次探测式数据回收操作,执行expungeStaleEntry()方法,执行完后,index 5,8的数据都会被回收,而index 6,7的数据都会前移。index 6,7前移之后,继续从 index=5 往后迭代,于是就在 index=6 找到了key值相等的Entry数据,如下图所示:\nThreadLocalMap.get()源码详解 # java.lang.ThreadLocal.ThreadLocalMap.getEntry():\nprivate Entry getEntry(ThreadLocal\u0026lt;?\u0026gt; key) { int i = key.threadLocalHashCode \u0026amp; (table.length - 1); Entry e = table[i]; if (e != null \u0026amp;\u0026amp; e.get() == key) return e; else return getEntryAfterMiss(key, i, e); } private Entry getEntryAfterMiss(ThreadLocal\u0026lt;?\u0026gt; key, int i, Entry e) { Entry[] tab = table; int len = tab.length; while (e != null) { ThreadLocal\u0026lt;?\u0026gt; k = e.get(); if (k == key) return e; if (k == null) expungeStaleEntry(i); else i = nextIndex(i, len); e = tab[i]; } return null; } ThreadLocalMap过期 key 的启发式清理流程 # 上面多次提及到ThreadLocalMap过期 key 的两种清理方式:探测式清理(expungeStaleEntry())、启发式清理(cleanSomeSlots())\n探测式清理是以当前Entry 往后清理,遇到值为null则结束清理,属于线性探测清理。\n而启发式清理被作者定义为:Heuristically scan some cells looking for stale entries.\n具体代码如下:\nprivate boolean cleanSomeSlots(int i, int n) { boolean removed = false; Entry[] tab = table; int len = tab.length; do { i = nextIndex(i, len); Entry e = tab[i]; if (e != null \u0026amp;\u0026amp; e.get() == null) { n = len; removed = true; i = expungeStaleEntry(i); } } while ( (n \u0026gt;\u0026gt;\u0026gt;= 1) != 0); return removed; } InheritableThreadLocal # 我们使用ThreadLocal的时候,在异步场景下是无法给子线程共享父线程中创建的线程副本数据的。\n为了解决这个问题,JDK 中还有一个InheritableThreadLocal类,我们来看一个例子:\npublic class InheritableThreadLocalDemo { public static void main(String[] args) { ThreadLocal\u0026lt;String\u0026gt; ThreadLocal = new ThreadLocal\u0026lt;\u0026gt;(); ThreadLocal\u0026lt;String\u0026gt; inheritableThreadLocal = new InheritableThreadLocal\u0026lt;\u0026gt;(); ThreadLocal.set(\u0026#34;父类数据:threadLocal\u0026#34;); inheritableThreadLocal.set(\u0026#34;父类数据:inheritableThreadLocal\u0026#34;); new Thread(new Runnable() { @Override public void run() { System.out.println(\u0026#34;子线程获取父类ThreadLocal数据:\u0026#34; + ThreadLocal.get()); System.out.println(\u0026#34;子线程获取父类inheritableThreadLocal数据:\u0026#34; + inheritableThreadLocal.get()); } }).start(); } } 打印结果:\n子线程获取父类ThreadLocal数据:null 子线程获取父类inheritableThreadLocal数据:父类数据:inheritableThreadLocal 实现原理是子线程是通过在父线程中通过调用new Thread()方法来创建子线程,Thread#init方法在Thread的构造方法中被调用。在init方法中拷贝父线程数据到子线程中:\nprivate void init(ThreadGroup g, Runnable target, String name, long stackSize, AccessControlContext acc, boolean inheritThreadLocals) { if (name == null) { throw new NullPointerException(\u0026#34;name cannot be null\u0026#34;); } if (inheritThreadLocals \u0026amp;\u0026amp; parent.inheritableThreadLocals != null) this.inheritableThreadLocals = ThreadLocal.createInheritedMap(parent.inheritableThreadLocals); this.stackSize = stackSize; tid = nextThreadID(); } 但InheritableThreadLocal仍然有缺陷,一般我们做异步化处理都是使用的线程池,而InheritableThreadLocal是在new Thread中的init()方法给赋值的,而线程池是线程复用的逻辑,所以这里会存在问题。\n当然,有问题出现就会有解决问题的方案,阿里巴巴开源了一个TransmittableThreadLocal组件就可以解决这个问题,这里就不再延伸,感兴趣的可自行查阅资料。\nThreadLocal项目中使用实战 # ThreadLocal使用场景 # 我们现在项目中日志记录用的是ELK+Logstash,最后在Kibana中进行展示和检索。\n现在都是分布式系统统一对外提供服务,项目间调用的关系可以通过 traceId 来关联,但是不同项目之间如何传递 traceId 呢?\n这里我们使用 org.slf4j.MDC 来实现此功能,内部就是通过 ThreadLocal 来实现的,具体实现如下:\n当前端发送请求到服务 A时,服务 A会生成一个类似UUID的traceId字符串,将此字符串放入当前线程的ThreadLocal中,在调用服务 B的时候,将traceId写入到请求的Header中,服务 B在接收请求时会先判断请求的Header中是否有traceId,如果存在则写入自己线程的ThreadLocal中。\n图中的requestId即为我们各个系统链路关联的traceId,系统间互相调用,通过这个requestId即可找到对应链路,这里还有会有一些其他场景:\n针对于这些场景,我们都可以有相应的解决方案,如下所示\nFeign 远程调用解决方案 # 服务发送请求:\n@Component @Slf4j public class FeignInvokeInterceptor implements RequestInterceptor { @Override public void apply(RequestTemplate template) { String requestId = MDC.get(\u0026#34;requestId\u0026#34;); if (StringUtils.isNotBlank(requestId)) { template.header(\u0026#34;requestId\u0026#34;, requestId); } } } 服务接收请求:\n@Slf4j @Component public class LogInterceptor extends HandlerInterceptorAdapter { @Override public void afterCompletion(HttpServletRequest arg0, HttpServletResponse arg1, Object arg2, Exception arg3) { MDC.remove(\u0026#34;requestId\u0026#34;); } @Override public void postHandle(HttpServletRequest arg0, HttpServletResponse arg1, Object arg2, ModelAndView arg3) { } @Override public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception { String requestId = request.getHeader(BaseConstant.REQUEST_ID_KEY); if (StringUtils.isBlank(requestId)) { requestId = UUID.randomUUID().toString().replace(\u0026#34;-\u0026#34;, \u0026#34;\u0026#34;); } MDC.put(\u0026#34;requestId\u0026#34;, requestId); return true; } } 线程池异步调用,requestId 传递 # 因为MDC是基于ThreadLocal去实现的,异步过程中,子线程并没有办法获取到父线程ThreadLocal存储的数据,所以这里可以自定义线程池执行器,修改其中的run()方法:\npublic class MyThreadPoolTaskExecutor extends ThreadPoolTaskExecutor { @Override public void execute(Runnable runnable) { Map\u0026lt;String, String\u0026gt; context = MDC.getCopyOfContextMap(); super.execute(() -\u0026gt; run(runnable, context)); } @Override private void run(Runnable runnable, Map\u0026lt;String, String\u0026gt; context) { if (context != null) { MDC.setContextMap(context); } try { runnable.run(); } finally { MDC.remove(); } } } 使用 MQ 发送消息给第三方系统 # 在 MQ 发送的消息体中自定义属性requestId,接收方消费消息后,自己解析requestId使用即可。\n"},{"id":588,"href":"/zh/docs/technology/Interview/system-design/web-real-time-message-push/","title":"Web 实时消息推送详解","section":"System Design","content":" 原文地址: https://juejin.cn/post/7122014462181113887,JavaGuide 对本文进行了完善总结。\n我有一个朋友做了一个小破站,现在要实现一个站内信 Web 消息推送的功能,对,就是下图这个小红点,一个很常用的功能。\n不过他还没想好用什么方式做,这里我帮他整理了一下几种方案,并简单做了实现。\n什么是消息推送? # 推送的场景比较多,比如有人关注我的公众号,这时我就会收到一条推送消息,以此来吸引我点击打开应用。\n消息推送通常是指网站的运营工作等人员,通过某种工具对用户当前网页或移动设备 APP 进行的主动消息推送。\n消息推送一般又分为 Web 端消息推送和移动端消息推送。\n移动端消息推送示例:\nWeb 端消息推送示例:\n在具体实现之前,咱们再来分析一下前边的需求,其实功能很简单,只要触发某个事件(主动分享了资源或者后台主动推送消息),Web 页面的通知小红点就会实时的 +1 就可以了。\n通常在服务端会有若干张消息推送表,用来记录用户触发不同事件所推送不同类型的消息,前端主动查询(拉)或者被动接收(推)用户所有未读的消息数。\n消息推送无非是推(push)和拉(pull)两种形式,下边我们逐个了解下。\n消息推送常见方案 # 短轮询 # 轮询(polling) 应该是实现消息推送方案中最简单的一种,这里我们暂且将轮询分为短轮询和长轮询。\n短轮询很好理解,指定的时间间隔,由浏览器向服务器发出 HTTP 请求,服务器实时返回未读消息数据给客户端,浏览器再做渲染显示。\n一个简单的 JS 定时器就可以搞定,每秒钟请求一次未读消息数接口,返回的数据展示即可。\nsetInterval(() =\u0026gt; { // 方法请求 messageCount().then((res) =\u0026gt; { if (res.code === 200) { this.messageCount = res.data; } }); }, 1000); 效果还是可以的,短轮询实现固然简单,缺点也是显而易见,由于推送数据并不会频繁变更,无论后端此时是否有新的消息产生,客户端都会进行请求,势必会对服务端造成很大压力,浪费带宽和服务器资源。\n长轮询 # 长轮询是对上边短轮询的一种改进版本,在尽可能减少对服务器资源浪费的同时,保证消息的相对实时性。长轮询在中间件中应用的很广泛,比如 Nacos 和 Apollo 配置中心,消息队列 Kafka、RocketMQ 中都有用到长轮询。\nNacos 配置中心交互模型是 push 还是 pull?一文中我详细介绍过 Nacos 长轮询的实现原理,感兴趣的小伙伴可以瞅瞅。\n长轮询其实原理跟轮询差不多,都是采用轮询的方式。不过,如果服务端的数据没有发生变更,会 一直 hold 住请求,直到服务端的数据发生变化,或者等待一定时间超时才会返回。返回后,客户端又会立即再次发起下一次长轮询。\n这次我使用 Apollo 配置中心实现长轮询的方式,应用了一个类DeferredResult,它是在 Servlet3.0 后经过 Spring 封装提供的一种异步请求机制,直意就是延迟结果。\nDeferredResult可以允许容器线程快速释放占用的资源,不阻塞请求线程,以此接受更多的请求提升系统的吞吐量,然后启动异步工作线程处理真正的业务逻辑,处理完成调用DeferredResult.setResult(200)提交响应结果。\n下边我们用长轮询来实现消息推送。\n因为一个 ID 可能会被多个长轮询请求监听,所以我采用了 Guava 包提供的Multimap结构存放长轮询,一个 key 可以对应多个 value。一旦监听到 key 发生变化,对应的所有长轮询都会响应。前端得到非请求超时的状态码,知晓数据变更,主动查询未读消息数接口,更新页面数据。\n@Controller @RequestMapping(\u0026#34;/polling\u0026#34;) public class PollingController { // 存放监听某个Id的长轮询集合 // 线程同步结构 public static Multimap\u0026lt;String, DeferredResult\u0026lt;String\u0026gt;\u0026gt; watchRequests = Multimaps.synchronizedMultimap(HashMultimap.create()); /** * 设置监听 */ @GetMapping(path = \u0026#34;watch/{id}\u0026#34;) @ResponseBody public DeferredResult\u0026lt;String\u0026gt; watch(@PathVariable String id) { // 延迟对象设置超时时间 DeferredResult\u0026lt;String\u0026gt; deferredResult = new DeferredResult\u0026lt;\u0026gt;(TIME_OUT); // 异步请求完成时移除 key,防止内存溢出 deferredResult.onCompletion(() -\u0026gt; { watchRequests.remove(id, deferredResult); }); // 注册长轮询请求 watchRequests.put(id, deferredResult); return deferredResult; } /** * 变更数据 */ @GetMapping(path = \u0026#34;publish/{id}\u0026#34;) @ResponseBody public String publish(@PathVariable String id) { // 数据变更 取出监听ID的所有长轮询请求,并一一响应处理 if (watchRequests.containsKey(id)) { Collection\u0026lt;DeferredResult\u0026lt;String\u0026gt;\u0026gt; deferredResults = watchRequests.get(id); for (DeferredResult\u0026lt;String\u0026gt; deferredResult : deferredResults) { deferredResult.setResult(\u0026#34;我更新了\u0026#34; + new Date()); } } return \u0026#34;success\u0026#34;; } 当请求超过设置的超时时间,会抛出AsyncRequestTimeoutException异常,这里直接用@ControllerAdvice全局捕获统一返回即可,前端获取约定好的状态码后再次发起长轮询请求,如此往复调用。\n@ControllerAdvice public class AsyncRequestTimeoutHandler { @ResponseStatus(HttpStatus.NOT_MODIFIED) @ResponseBody @ExceptionHandler(AsyncRequestTimeoutException.class) public String asyncRequestTimeoutHandler(AsyncRequestTimeoutException e) { System.out.println(\u0026#34;异步请求超时\u0026#34;); return \u0026#34;304\u0026#34;; } } 我们来测试一下,首先页面发起长轮询请求/polling/watch/10086监听消息更变,请求被挂起,不变更数据直至超时,再次发起了长轮询请求;紧接着手动变更数据/polling/publish/10086,长轮询得到响应,前端处理业务逻辑完成后再次发起请求,如此循环往复。\n长轮询相比于短轮询在性能上提升了很多,但依然会产生较多的请求,这是它的一点不完美的地方。\niframe 流 # iframe 流就是在页面中插入一个隐藏的\u0026lt;iframe\u0026gt;标签,通过在src中请求消息数量 API 接口,由此在服务端和客户端之间创建一条长连接,服务端持续向iframe传输数据。\n传输的数据通常是 HTML、或是内嵌的 JavaScript 脚本,来达到实时更新页面的效果。\n这种方式实现简单,前端只要一个\u0026lt;iframe\u0026gt;标签搞定了\n\u0026lt;iframe src=\u0026#34;/iframe/message\u0026#34; style=\u0026#34;display:none\u0026#34;\u0026gt;\u0026lt;/iframe\u0026gt; 服务端直接组装 HTML、JS 脚本数据向 response 写入就行了\n@Controller @RequestMapping(\u0026#34;/iframe\u0026#34;) public class IframeController { @GetMapping(path = \u0026#34;message\u0026#34;) public void message(HttpServletResponse response) throws IOException, InterruptedException { while (true) { response.setHeader(\u0026#34;Pragma\u0026#34;, \u0026#34;no-cache\u0026#34;); response.setDateHeader(\u0026#34;Expires\u0026#34;, 0); response.setHeader(\u0026#34;Cache-Control\u0026#34;, \u0026#34;no-cache,no-store\u0026#34;); response.setStatus(HttpServletResponse.SC_OK); response.getWriter().print(\u0026#34; \u0026lt;script type=\\\u0026#34;text/javascript\\\u0026#34;\u0026gt;\\n\u0026#34; + \u0026#34;parent.document.getElementById(\u0026#39;clock\u0026#39;).innerHTML = \\\u0026#34;\u0026#34; + count.get() + \u0026#34;\\\u0026#34;;\u0026#34; + \u0026#34;parent.document.getElementById(\u0026#39;count\u0026#39;).innerHTML = \\\u0026#34;\u0026#34; + count.get() + \u0026#34;\\\u0026#34;;\u0026#34; + \u0026#34;\u0026lt;/script\u0026gt;\u0026#34;); } } } iframe 流的服务器开销很大,而且 IE、Chrome 等浏览器一直会处于 loading 状态,图标会不停旋转,简直是强迫症杀手。\niframe 流非常不友好,强烈不推荐。\nSSE (推荐) # 很多人可能不知道,服务端向客户端推送消息,其实除了可以用WebSocket这种耳熟能详的机制外,还有一种服务器发送事件(Server-Sent Events),简称 SSE。这是一种服务器端到客户端(浏览器)的单向消息推送。\n大名鼎鼎的 ChatGPT 就是采用的 SSE。对于需要长时间等待响应的对话场景,ChatGPT 采用了一种巧妙的策略:它会将已经计算出的数据“推送”给用户,并利用 SSE 技术在计算过程中持续返回数据。这样做的好处是可以避免用户因等待时间过长而选择关闭页面。\nSSE 基于 HTTP 协议的,我们知道一般意义上的 HTTP 协议是无法做到服务端主动向客户端推送消息的,但 SSE 是个例外,它变换了一种思路。\nSSE 在服务器和客户端之间打开一个单向通道,服务端响应的不再是一次性的数据包而是text/event-stream类型的数据流信息,在有数据变更时从服务器流式传输到客户端。\n整体的实现思路有点类似于在线视频播放,视频流会连续不断的推送到浏览器,你也可以理解成,客户端在完成一次用时很长(网络不畅)的下载。\nSSE 与 WebSocket 作用相似,都可以建立服务端与浏览器之间的通信,实现服务端向客户端推送消息,但还是有些许不同:\nSSE 是基于 HTTP 协议的,它们不需要特殊的协议或服务器实现即可工作;WebSocket 需单独服务器来处理协议。 SSE 单向通信,只能由服务端向客户端单向通信;WebSocket 全双工通信,即通信的双方可以同时发送和接受信息。 SSE 实现简单开发成本低,无需引入其他组件;WebSocket 传输数据需做二次解析,开发门槛高一些。 SSE 默认支持断线重连;WebSocket 则需要自己实现。 SSE 只能传送文本消息,二进制数据需要经过编码后传送;WebSocket 默认支持传送二进制数据。 SSE 与 WebSocket 该如何选择?\n技术并没有好坏之分,只有哪个更合适\nSSE 好像一直不被大家所熟知,一部分原因是出现了 WebSocket,这个提供了更丰富的协议来执行双向、全双工通信。对于游戏、即时通信以及需要双向近乎实时更新的场景,拥有双向通道更具吸引力。\n但是,在某些情况下,不需要从客户端发送数据。而你只需要一些服务器操作的更新。比如:站内信、未读消息数、状态更新、股票行情、监控数量等场景,SEE 不管是从实现的难易和成本上都更加有优势。此外,SSE 具有 WebSocket 在设计上缺乏的多种功能,例如:自动重新连接、事件 ID 和发送任意事件的能力。\n前端只需进行一次 HTTP 请求,带上唯一 ID,打开事件流,监听服务端推送的事件就可以了\n\u0026lt;script\u0026gt; let source = null; let userId = 7777 if (window.EventSource) { // 建立连接 source = new EventSource(\u0026#39;http://localhost:7777/sse/sub/\u0026#39;+userId); setMessageInnerHTML(\u0026#34;连接用户=\u0026#34; + userId); /** * 连接一旦建立,就会触发open事件 * 另一种写法:source.onopen = function (event) {} */ source.addEventListener(\u0026#39;open\u0026#39;, function (e) { setMessageInnerHTML(\u0026#34;建立连接。。。\u0026#34;); }, false); /** * 客户端收到服务器发来的数据 * 另一种写法:source.onmessage = function (event) {} */ source.addEventListener(\u0026#39;message\u0026#39;, function (e) { setMessageInnerHTML(e.data); }); } else { setMessageInnerHTML(\u0026#34;你的浏览器不支持SSE\u0026#34;); } \u0026lt;/script\u0026gt; 服务端的实现更简单,创建一个SseEmitter对象放入sseEmitterMap进行管理\nprivate static Map\u0026lt;String, SseEmitter\u0026gt; sseEmitterMap = new ConcurrentHashMap\u0026lt;\u0026gt;(); /** * 创建连接 */ public static SseEmitter connect(String userId) { try { // 设置超时时间,0表示不过期。默认30秒 SseEmitter sseEmitter = new SseEmitter(0L); // 注册回调 sseEmitter.onCompletion(completionCallBack(userId)); sseEmitter.onError(errorCallBack(userId)); sseEmitter.onTimeout(timeoutCallBack(userId)); sseEmitterMap.put(userId, sseEmitter); count.getAndIncrement(); return sseEmitter; } catch (Exception e) { log.info(\u0026#34;创建新的sse连接异常,当前用户:{}\u0026#34;, userId); } return null; } /** * 给指定用户发送消息 */ public static void sendMessage(String userId, String message) { if (sseEmitterMap.containsKey(userId)) { try { sseEmitterMap.get(userId).send(message); } catch (IOException e) { log.error(\u0026#34;用户[{}]推送异常:{}\u0026#34;, userId, e.getMessage()); removeUser(userId); } } } 注意: SSE 不支持 IE 浏览器,对其他主流浏览器兼容性做的还不错。\nWebsocket # Websocket 应该是大家都比较熟悉的一种实现消息推送的方式,上边我们在讲 SSE 的时候也和 Websocket 进行过比较。\n这是一种在 TCP 连接上进行全双工通信的协议,建立客户端和服务器之间的通信渠道。浏览器和服务器仅需一次握手,两者之间就直接可以创建持久性的连接,并进行双向数据传输。\nWebSocket 的工作过程可以分为以下几个步骤:\n客户端向服务器发送一个 HTTP 请求,请求头中包含 Upgrade: websocket 和 Sec-WebSocket-Key 等字段,表示要求升级协议为 WebSocket; 服务器收到这个请求后,会进行升级协议的操作,如果支持 WebSocket,它将回复一个 HTTP 101 状态码,响应头中包含 ,Connection: Upgrade和 Sec-WebSocket-Accept: xxx 等字段、表示成功升级到 WebSocket 协议。 客户端和服务器之间建立了一个 WebSocket 连接,可以进行双向的数据传输。数据以帧(frames)的形式进行传送,而不是传统的 HTTP 请求和响应。WebSocket 的每条消息可能会被切分成多个数据帧(最小单位)。发送端会将消息切割成多个帧发送给接收端,接收端接收消息帧,并将关联的帧重新组装成完整的消息。 客户端或服务器可以主动发送一个关闭帧,表示要断开连接。另一方收到后,也会回复一个关闭帧,然后双方关闭 TCP 连接。 另外,建立 WebSocket 连接之后,通过心跳机制来保持 WebSocket 连接的稳定性和活跃性。\nSpringBoot 整合 WebSocket,先引入 WebSocket 相关的工具包,和 SSE 相比有额外的开发成本。\n\u0026lt;!-- 引入websocket --\u0026gt; \u0026lt;dependency\u0026gt; \u0026lt;groupId\u0026gt;org.springframework.boot\u0026lt;/groupId\u0026gt; \u0026lt;artifactId\u0026gt;spring-boot-starter-websocket\u0026lt;/artifactId\u0026gt; \u0026lt;/dependency\u0026gt; 服务端使用@ServerEndpoint注解标注当前类为一个 WebSocket 服务器,客户端可以通过ws://localhost:7777/webSocket/10086来连接到 WebSocket 服务器端。\n@Component @Slf4j @ServerEndpoint(\u0026#34;/websocket/{userId}\u0026#34;) public class WebSocketServer { //与某个客户端的连接会话,需要通过它来给客户端发送数据 private Session session; private static final CopyOnWriteArraySet\u0026lt;WebSocketServer\u0026gt; webSockets = new CopyOnWriteArraySet\u0026lt;\u0026gt;(); // 用来存在线连接数 private static final Map\u0026lt;String, Session\u0026gt; sessionPool = new HashMap\u0026lt;String, Session\u0026gt;(); /** * 链接成功调用的方法 */ @OnOpen public void onOpen(Session session, @PathParam(value = \u0026#34;userId\u0026#34;) String userId) { try { this.session = session; webSockets.add(this); sessionPool.put(userId, session); log.info(\u0026#34;websocket消息: 有新的连接,总数为:\u0026#34; + webSockets.size()); } catch (Exception e) { } } /** * 收到客户端消息后调用的方法 */ @OnMessage public void onMessage(String message) { log.info(\u0026#34;websocket消息: 收到客户端消息:\u0026#34; + message); } /** * 此为单点消息 */ public void sendOneMessage(String userId, String message) { Session session = sessionPool.get(userId); if (session != null \u0026amp;\u0026amp; session.isOpen()) { try { log.info(\u0026#34;websocket消: 单点消息:\u0026#34; + message); session.getAsyncRemote().sendText(message); } catch (Exception e) { e.printStackTrace(); } } } } 服务端还需要注入ServerEndpointerExporter,这个 Bean 就会自动注册使用了@ServerEndpoint注解的 WebSocket 服务器。\n@Configuration public class WebSocketConfiguration { /** * 用于注册使用了 @ServerEndpoint 注解的 WebSocket 服务器 */ @Bean public ServerEndpointExporter serverEndpointExporter() { return new ServerEndpointExporter(); } } 前端初始化打开 WebSocket 连接,并监听连接状态,接收服务端数据或向服务端发送数据。\n\u0026lt;script\u0026gt; var ws = new WebSocket(\u0026#39;ws://localhost:7777/webSocket/10086\u0026#39;); // 获取连接状态 console.log(\u0026#39;ws连接状态:\u0026#39; + ws.readyState); //监听是否连接成功 ws.onopen = function () { console.log(\u0026#39;ws连接状态:\u0026#39; + ws.readyState); //连接成功则发送一个数据 ws.send(\u0026#39;test1\u0026#39;); } // 接听服务器发回的信息并处理展示 ws.onmessage = function (data) { console.log(\u0026#39;接收到来自服务器的消息:\u0026#39;); console.log(data); //完成通信后关闭WebSocket连接 ws.close(); } // 监听连接关闭事件 ws.onclose = function () { // 监听整个过程中websocket的状态 console.log(\u0026#39;ws连接状态:\u0026#39; + ws.readyState); } // 监听并处理error事件 ws.onerror = function (error) { console.log(error); } function sendMessage() { var content = $(\u0026#34;#message\u0026#34;).val(); $.ajax({ url: \u0026#39;/socket/publish?userId=10086\u0026amp;message=\u0026#39; + content, type: \u0026#39;GET\u0026#39;, data: { \u0026#34;id\u0026#34;: \u0026#34;7777\u0026#34;, \u0026#34;content\u0026#34;: content }, success: function (data) { console.log(data) } }) } \u0026lt;/script\u0026gt; 页面初始化建立 WebSocket 连接,之后就可以进行双向通信了,效果还不错。\nMQTT # 什么是 MQTT 协议?\nMQTT (Message Queue Telemetry Transport)是一种基于发布/订阅(publish/subscribe)模式的轻量级通讯协议,通过订阅相应的主题来获取消息,是物联网(Internet of Thing)中的一个标准传输协议。\n该协议将消息的发布者(publisher)与订阅者(subscriber)进行分离,因此可以在不可靠的网络环境中,为远程连接的设备提供可靠的消息服务,使用方式与传统的 MQ 有点类似。\nTCP 协议位于传输层,MQTT 协议位于应用层,MQTT 协议构建于 TCP/IP 协议上,也就是说只要支持 TCP/IP 协议栈的地方,都可以使用 MQTT 协议。\n为什么要用 MQTT 协议?\nMQTT 协议为什么在物联网(IOT)中如此受偏爱?而不是其它协议,比如我们更为熟悉的 HTTP 协议呢?\n首先 HTTP 协议它是一种同步协议,客户端请求后需要等待服务器的响应。而在物联网(IOT)环境中,设备会很受制于环境的影响,比如带宽低、网络延迟高、网络通信不稳定等,显然异步消息协议更为适合 IOT 应用程序。 HTTP 是单向的,如果要获取消息客户端必须发起连接,而在物联网(IOT)应用程序中,设备或传感器往往都是客户端,这意味着它们无法被动地接收来自网络的命令。 通常需要将一条命令或者消息,发送到网络上的所有设备上。HTTP 要实现这样的功能不但很困难,而且成本极高。 具体的 MQTT 协议介绍和实践,这里我就不再赘述了,大家可以参考我之前的两篇文章,里边写的也都很详细了。\nMQTT 协议的介绍: 我也没想到 SpringBoot + RabbitMQ 做智能家居,会这么简单 MQTT 实现消息推送: 未读消息(小红点),前端 与 RabbitMQ 实时消息推送实践,贼简单~ 总结 # 以下内容为 JavaGuide 补充\n介绍 优点 缺点 短轮询 客户端定时向服务端发送请求,服务端直接返回响应数据(即使没有数据更新) 简单、易理解、易实现 实时性太差,无效请求太多,频繁建立连接太耗费资源 长轮询 与短轮询不同是,长轮询接收到客户端请求之后等到有数据更新才返回请求 减少了无效请求 挂起请求会导致资源浪费 iframe 流 服务端和客户端之间创建一条长连接,服务端持续向iframe传输数据。 简单、易理解、易实现 维护一个长连接会增加开销,效果太差(图标会不停旋转) SSE 一种服务器端到客户端(浏览器)的单向消息推送。 简单、易实现,功能丰富 不支持双向通信 WebSocket 除了最初建立连接时用 HTTP 协议,其他时候都是直接基于 TCP 协议进行通信的,可以实现客户端和服务端的全双工通信。 性能高、开销小 对开发人员要求更高,实现相对复杂一些 MQTT 基于发布/订阅(publish/subscribe)模式的轻量级通讯协议,通过订阅相应的主题来获取消息。 成熟稳定,轻量级 对开发人员要求更高,实现相对复杂一些 "},{"id":589,"href":"/zh/docs/technology/Interview/distributed-system/distributed-process-coordination/zookeeper/zookeeper-in-action/","title":"ZooKeeper 实战","section":"Distributed Process Coordination","content":"这篇文章简单给演示一下 ZooKeeper 常见命令的使用以及 ZooKeeper Java 客户端 Curator 的基本使用。介绍到的内容都是最基本的操作,能满足日常工作的基本需要。\n如果文章有任何需要改善和完善的地方,欢迎在评论区指出,共同进步!\nZooKeeper 安装 # 使用 Docker 安装 zookeeper # a.使用 Docker 下载 ZooKeeper\ndocker pull zookeeper:3.5.8 b.运行 ZooKeeper\ndocker run -d --name zookeeper -p 2181:2181 zookeeper:3.5.8 连接 ZooKeeper 服务 # a.进入 ZooKeeper 容器中\n先使用 docker ps 查看 ZooKeeper 的 ContainerID,然后使用 docker exec -it ContainerID /bin/bash 命令进入容器中。\nb.先进入 bin 目录,然后通过 ./zkCli.sh -server 127.0.0.1:2181命令连接 ZooKeeper 服务\nroot@eaf70fc620cb:/apache-zookeeper-3.5.8-bin# cd bin 如果你看到控制台成功打印出如下信息的话,说明你已经成功连接 ZooKeeper 服务。\nZooKeeper 常用命令演示 # 查看常用命令(help 命令) # 通过 help 命令查看 ZooKeeper 常用命令\n创建节点(create 命令) # 通过 create 命令在根目录创建了 node1 节点,与它关联的字符串是\u0026quot;node1\u0026quot;\n[zk: 127.0.0.1:2181(CONNECTED) 34] create /node1 “node1” 通过 create 命令在根目录创建了 node1 节点,与它关联的内容是数字 123\n[zk: 127.0.0.1:2181(CONNECTED) 1] create /node1/node1.1 123 Created /node1/node1.1 更新节点数据内容(set 命令) # [zk: 127.0.0.1:2181(CONNECTED) 11] set /node1 \u0026#34;set node1\u0026#34; 获取节点的数据(get 命令) # get 命令可以获取指定节点的数据内容和节点的状态,可以看出我们通过 set 命令已经将节点数据内容改为 \u0026ldquo;set node1\u0026rdquo;。\n[zk: zookeeper(CONNECTED) 12] get -s /node1 set node1 cZxid = 0x47 ctime = Sun Jan 20 10:22:59 CST 2019 mZxid = 0x4b mtime = Sun Jan 20 10:41:10 CST 2019 pZxid = 0x4a cversion = 1 dataVersion = 1 aclVersion = 0 ephemeralOwner = 0x0 dataLength = 9 numChildren = 1 查看某个目录下的子节点(ls 命令) # 通过 ls 命令查看根目录下的节点\n[zk: 127.0.0.1:2181(CONNECTED) 37] ls / [dubbo, ZooKeeper, node1] 通过 ls 命令查看 node1 目录下的节点\n[zk: 127.0.0.1:2181(CONNECTED) 5] ls /node1 [node1.1] ZooKeeper 中的 ls 命令和 linux 命令中的 ls 类似, 这个命令将列出绝对路径 path 下的所有子节点信息(列出 1 级,并不递归)\n查看节点状态(stat 命令) # 通过 stat 命令查看节点状态\n[zk: 127.0.0.1:2181(CONNECTED) 10] stat /node1 cZxid = 0x47 ctime = Sun Jan 20 10:22:59 CST 2019 mZxid = 0x47 mtime = Sun Jan 20 10:22:59 CST 2019 pZxid = 0x4a cversion = 1 dataVersion = 0 aclVersion = 0 ephemeralOwner = 0x0 dataLength = 11 numChildren = 1 上面显示的一些信息比如 cversion、aclVersion、numChildren 等等,我在上面 “ ZooKeeper 相关概念总结(入门)” 这篇文章中已经介绍到。\n查看节点信息和状态(ls2 命令) # ls2 命令更像是 ls 命令和 stat 命令的结合。 ls2 命令返回的信息包括 2 部分:\n子节点列表 当前节点的 stat 信息。 [zk: 127.0.0.1:2181(CONNECTED) 7] ls2 /node1 [node1.1] cZxid = 0x47 ctime = Sun Jan 20 10:22:59 CST 2019 mZxid = 0x47 mtime = Sun Jan 20 10:22:59 CST 2019 pZxid = 0x4a cversion = 1 dataVersion = 0 aclVersion = 0 ephemeralOwner = 0x0 dataLength = 11 numChildren = 1 删除节点(delete 命令) # 这个命令很简单,但是需要注意的一点是如果你要删除某一个节点,那么这个节点必须无子节点才行。\n[zk: 127.0.0.1:2181(CONNECTED) 3] delete /node1/node1.1 在后面我会介绍到 Java 客户端 API 的使用以及开源 ZooKeeper 客户端 ZkClient 和 Curator 的使用。\nZooKeeper Java 客户端 Curator 简单使用 # Curator 是 Netflix 公司开源的一套 ZooKeeper Java 客户端框架,相比于 Zookeeper 自带的客户端 zookeeper 来说,Curator 的封装更加完善,各种 API 都可以比较方便地使用。\n下面我们就来简单地演示一下 Curator 的使用吧!\nCurator4.0+版本对 ZooKeeper 3.5.x 支持比较好。开始之前,请先将下面的依赖添加进你的项目。\n\u0026lt;dependency\u0026gt; \u0026lt;groupId\u0026gt;org.apache.curator\u0026lt;/groupId\u0026gt; \u0026lt;artifactId\u0026gt;curator-framework\u0026lt;/artifactId\u0026gt; \u0026lt;version\u0026gt;4.2.0\u0026lt;/version\u0026gt; \u0026lt;/dependency\u0026gt; \u0026lt;dependency\u0026gt; \u0026lt;groupId\u0026gt;org.apache.curator\u0026lt;/groupId\u0026gt; \u0026lt;artifactId\u0026gt;curator-recipes\u0026lt;/artifactId\u0026gt; \u0026lt;version\u0026gt;4.2.0\u0026lt;/version\u0026gt; \u0026lt;/dependency\u0026gt; 连接 ZooKeeper 客户端 # 通过 CuratorFrameworkFactory 创建 CuratorFramework 对象,然后再调用 CuratorFramework 对象的 start() 方法即可!\nprivate static final int BASE_SLEEP_TIME = 1000; private static final int MAX_RETRIES = 3; // Retry strategy. Retry 3 times, and will increase the sleep time between retries. RetryPolicy retryPolicy = new ExponentialBackoffRetry(BASE_SLEEP_TIME, MAX_RETRIES); CuratorFramework zkClient = CuratorFrameworkFactory.builder() // the server to connect to (can be a server list) .connectString(\u0026#34;127.0.0.1:2181\u0026#34;) .retryPolicy(retryPolicy) .build(); zkClient.start(); 对于一些基本参数的说明:\nbaseSleepTimeMs:重试之间等待的初始时间 maxRetries:最大重试次数 connectString:要连接的服务器列表 retryPolicy:重试策略 数据节点的增删改查 # 创建节点 # 我们在 ZooKeeper 常见概念解读 中介绍到,我们通常是将 znode 分为 4 大类:\n持久(PERSISTENT)节点:一旦创建就一直存在即使 ZooKeeper 集群宕机,直到将其删除。 临时(EPHEMERAL)节点:临时节点的生命周期是与 客户端会话(session) 绑定的,会话消失则节点消失 。并且,临时节点 只能做叶子节点 ,不能创建子节点。 持久顺序(PERSISTENT_SEQUENTIAL)节点:除了具有持久(PERSISTENT)节点的特性之外, 子节点的名称还具有顺序性。比如 /node1/app0000000001、/node1/app0000000002 。 临时顺序(EPHEMERAL_SEQUENTIAL)节点:除了具备临时(EPHEMERAL)节点的特性之外,子节点的名称还具有顺序性。 你在使用的 ZooKeeper 的时候,会发现 CreateMode 类中实际有 7 种 znode 类型 ,但是用的最多的还是上面介绍的 4 种。\na.创建持久化节点\n你可以通过下面两种方式创建持久化的节点。\n//注意:下面的代码会报错,下文说了具体原因 zkClient.create().forPath(\u0026#34;/node1/00001\u0026#34;); zkClient.create().withMode(CreateMode.PERSISTENT).forPath(\u0026#34;/node1/00002\u0026#34;); 但是,你运行上面的代码会报错,这是因为的父节点node1还未创建。\n你可以先创建父节点 node1 ,然后再执行上面的代码就不会报错了。\nzkClient.create().forPath(\u0026#34;/node1\u0026#34;); 更推荐的方式是通过下面这行代码, creatingParentsIfNeeded() 可以保证父节点不存在的时候自动创建父节点,这是非常有用的。\nzkClient.create().creatingParentsIfNeeded().withMode(CreateMode.PERSISTENT).forPath(\u0026#34;/node1/00001\u0026#34;); b.创建临时节点\nzkClient.create().creatingParentsIfNeeded().withMode(CreateMode.EPHEMERAL).forPath(\u0026#34;/node1/00001\u0026#34;); c.创建节点并指定数据内容\nzkClient.create().creatingParentsIfNeeded().withMode(CreateMode.EPHEMERAL).forPath(\u0026#34;/node1/00001\u0026#34;,\u0026#34;java\u0026#34;.getBytes()); zkClient.getData().forPath(\u0026#34;/node1/00001\u0026#34;);//获取节点的数据内容,获取到的是 byte数组 d.检测节点是否创建成功\nzkClient.checkExists().forPath(\u0026#34;/node1/00001\u0026#34;);//不为null的话,说明节点创建成功 删除节点 # a.删除一个子节点\nzkClient.delete().forPath(\u0026#34;/node1/00001\u0026#34;); b.删除一个节点以及其下的所有子节点\nzkClient.delete().deletingChildrenIfNeeded().forPath(\u0026#34;/node1\u0026#34;); 获取/更新节点数据内容 # zkClient.create().creatingParentsIfNeeded().withMode(CreateMode.EPHEMERAL).forPath(\u0026#34;/node1/00001\u0026#34;,\u0026#34;java\u0026#34;.getBytes()); zkClient.getData().forPath(\u0026#34;/node1/00001\u0026#34;);//获取节点的数据内容 zkClient.setData().forPath(\u0026#34;/node1/00001\u0026#34;,\u0026#34;c++\u0026#34;.getBytes());//更新节点数据内容 获取某个节点的所有子节点路径 # List\u0026lt;String\u0026gt; childrenPaths = zkClient.getChildren().forPath(\u0026#34;/node1\u0026#34;); "},{"id":590,"href":"/zh/docs/technology/Interview/distributed-system/distributed-process-coordination/zookeeper/zookeeper-plus/","title":"ZooKeeper相关概念总结(进阶)","section":"Distributed Process Coordination","content":" FrancisQ 投稿。\n什么是 ZooKeeper # ZooKeeper 由 Yahoo 开发,后来捐赠给了 Apache ,现已成为 Apache 顶级项目。ZooKeeper 是一个开源的分布式应用程序协调服务器,其为分布式系统提供一致性服务。其一致性是通过基于 Paxos 算法的 ZAB 协议完成的。其主要功能包括:配置维护、分布式同步、集群管理等。\n简单来说, ZooKeeper 是一个 分布式协调服务框架 。分布式?协调服务?这啥玩意?🤔🤔\n其实解释到分布式这个概念的时候,我发现有些同学并不是能把 分布式和集群 这两个概念很好的理解透。前段时间有同学和我探讨起分布式的东西,他说分布式不就是加机器吗?一台机器不够用再加一台抗压呗。当然加机器这种说法也无可厚非,你一个分布式系统必定涉及到多个机器,但是你别忘了,计算机学科中还有一个相似的概念—— Cluster ,集群不也是加机器吗?但是 集群 和 分布式 其实就是两个完全不同的概念。\n比如,我现在有一个秒杀服务,并发量太大单机系统承受不住,那我加几台服务器也 一样 提供秒杀服务,这个时候就是 Cluster 集群 。\n但是,我现在换一种方式,我将一个秒杀服务 拆分成多个子服务 ,比如创建订单服务,增加积分服务,扣优惠券服务等等,然后我将这些子服务都部署在不同的服务器上 ,这个时候就是 Distributed 分布式 。\n而我为什么反驳同学所说的分布式就是加机器呢?因为我认为加机器更加适用于构建集群,因为它真是只有加机器。而对于分布式来说,你首先需要将业务进行拆分,然后再加机器(不仅仅是加机器那么简单),同时你还要去解决分布式带来的一系列问题。\n比如各个分布式组件如何协调起来,如何减少各个系统之间的耦合度,分布式事务的处理,如何去配置整个分布式系统等等。ZooKeeper 主要就是解决这些问题的。\n一致性问题 # 设计一个分布式系统必定会遇到一个问题—— 因为分区容忍性(partition tolerance)的存在,就必定要求我们需要在系统可用性(availability)和数据一致性(consistency)中做出权衡 。这就是著名的 CAP 定理。\n理解起来其实很简单,比如说把一个班级作为整个系统,而学生是系统中的一个个独立的子系统。这个时候班里的小红小明偷偷谈恋爱被班里的大嘴巴小花发现了,小花欣喜若狂告诉了周围的人,然后小红小明谈恋爱的消息在班级里传播起来了。当在消息的传播(散布)过程中,你抓到一个同学问他们的情况,如果回答你不知道,那么说明整个班级系统出现了数据不一致的问题(因为小花已经知道这个消息了)。而如果他直接不回答你,因为整个班级有消息在进行传播(为了保证一致性,需要所有人都知道才可提供服务),这个时候就出现了系统的可用性问题。\n而上述前者就是 Eureka 的处理方式,它保证了 AP(可用性),后者就是我们今天所要讲的 ZooKeeper 的处理方式,它保证了 CP(数据一致性)。\n一致性协议和算法 # 而为了解决数据一致性问题,在科学家和程序员的不断探索中,就出现了很多的一致性协议和算法。比如 2PC(两阶段提交),3PC(三阶段提交),Paxos 算法等等。\n这时候请你思考一个问题,同学之间如果采用传纸条的方式去传播消息,那么就会出现一个问题——我咋知道我的小纸条有没有传到我想要传递的那个人手中呢?万一被哪个小家伙给劫持篡改了呢,对吧?\n这个时候就引申出一个概念—— 拜占庭将军问题 。它意指 在不可靠信道上试图通过消息传递的方式达到一致性是不可能的, 所以所有的一致性算法的 必要前提 就是安全可靠的消息通道。\n而为什么要去解决数据一致性的问题?你想想,如果一个秒杀系统将服务拆分成了下订单和加积分服务,这两个服务部署在不同的机器上了,万一在消息的传播过程中积分系统宕机了,总不能你这边下了订单却没加积分吧?你总得保证两边的数据需要一致吧?\n2PC(两阶段提交) # 两阶段提交是一种保证分布式系统数据一致性的协议,现在很多数据库都是采用的两阶段提交协议来完成 分布式事务 的处理。\n在介绍 2PC 之前,我们先来想想分布式事务到底有什么问题呢?\n还拿秒杀系统的下订单和加积分两个系统来举例吧(我想你们可能都吐了 🤮🤮🤮),我们此时下完订单会发个消息给积分系统告诉它下面该增加积分了。如果我们仅仅是发送一个消息也不收回复,那么我们的订单系统怎么能知道积分系统的收到消息的情况呢?如果我们增加一个收回复的过程,那么当积分系统收到消息后返回给订单系统一个 Response ,但在中间出现了网络波动,那个回复消息没有发送成功,订单系统是不是以为积分系统消息接收失败了?它是不是会回滚事务?但此时积分系统是成功收到消息的,它就会去处理消息然后给用户增加积分,这个时候就会出现积分加了但是订单没下成功。\n所以我们所需要解决的是在分布式系统中,整个调用链中,我们所有服务的数据处理要么都成功要么都失败,即所有服务的 原子性问题 。\n在两阶段提交中,主要涉及到两个角色,分别是协调者和参与者。\n第一阶段:当要执行一个分布式事务的时候,事务发起者首先向协调者发起事务请求,然后协调者会给所有参与者发送 prepare 请求(其中包括事务内容)告诉参与者你们需要执行事务了,如果能执行我发的事务内容那么就先执行但不提交,执行后请给我回复。然后参与者收到 prepare 消息后,他们会开始执行事务(但不提交),并将 Undo 和 Redo 信息记入事务日志中,之后参与者就向协调者反馈是否准备好了。\n第二阶段:第二阶段主要是协调者根据参与者反馈的情况来决定接下来是否可以进行事务的提交操作,即提交事务或者回滚事务。\n比如这个时候 所有的参与者 都返回了准备好了的消息,这个时候就进行事务的提交,协调者此时会给所有的参与者发送 Commit 请求 ,当参与者收到 Commit 请求的时候会执行前面执行的事务的 提交操作 ,提交完毕之后将给协调者发送提交成功的响应。\n而如果在第一阶段并不是所有参与者都返回了准备好了的消息,那么此时协调者将会给所有参与者发送 回滚事务的 rollback 请求,参与者收到之后将会 回滚它在第一阶段所做的事务处理 ,然后再将处理情况返回给协调者,最终协调者收到响应后便给事务发起者返回处理失败的结果。\n个人觉得 2PC 实现得还是比较鸡肋的,因为事实上它只解决了各个事务的原子性问题,随之也带来了很多的问题。\n单点故障问题,如果协调者挂了那么整个系统都处于不可用的状态了。 阻塞问题,即当协调者发送 prepare 请求,参与者收到之后如果能处理那么它将会进行事务的处理但并不提交,这个时候会一直占用着资源不释放,如果此时协调者挂了,那么这些资源都不会再释放了,这会极大影响性能。 数据不一致问题,比如当第二阶段,协调者只发送了一部分的 commit 请求就挂了,那么也就意味着,收到消息的参与者会进行事务的提交,而后面没收到的则不会进行事务提交,那么这时候就会产生数据不一致性问题。 3PC(三阶段提交) # 因为 2PC 存在的一系列问题,比如单点,容错机制缺陷等等,从而产生了 3PC(三阶段提交) 。那么这三阶段又分别是什么呢?\n千万不要吧 PC 理解成个人电脑了,其实他们是 phase-commit 的缩写,即阶段提交。\nCanCommit 阶段:协调者向所有参与者发送 CanCommit 请求,参与者收到请求后会根据自身情况查看是否能执行事务,如果可以则返回 YES 响应并进入预备状态,否则返回 NO 。 PreCommit 阶段:协调者根据参与者返回的响应来决定是否可以进行下面的 PreCommit 操作。如果上面参与者返回的都是 YES,那么协调者将向所有参与者发送 PreCommit 预提交请求,参与者收到预提交请求后,会进行事务的执行操作,并将 Undo 和 Redo 信息写入事务日志中 ,最后如果参与者顺利执行了事务则给协调者返回成功的响应。如果在第一阶段协调者收到了 任何一个 NO 的信息,或者 在一定时间内 并没有收到全部的参与者的响应,那么就会中断事务,它会向所有参与者发送中断请求(abort),参与者收到中断请求之后会立即中断事务,或者在一定时间内没有收到协调者的请求,它也会中断事务。 DoCommit 阶段:这个阶段其实和 2PC 的第二阶段差不多,如果协调者收到了所有参与者在 PreCommit 阶段的 YES 响应,那么协调者将会给所有参与者发送 DoCommit 请求,参与者收到 DoCommit 请求后则会进行事务的提交工作,完成后则会给协调者返回响应,协调者收到所有参与者返回的事务提交成功的响应之后则完成事务。若协调者在 PreCommit 阶段 收到了任何一个 NO 或者在一定时间内没有收到所有参与者的响应 ,那么就会进行中断请求的发送,参与者收到中断请求后则会 通过上面记录的回滚日志 来进行事务的回滚操作,并向协调者反馈回滚状况,协调者收到参与者返回的消息后,中断事务。 这里是 3PC 在成功的环境下的流程图,你可以看到 3PC 在很多地方进行了超时中断的处理,比如协调者在指定时间内未收到全部的确认消息则进行事务中断的处理,这样能 减少同步阻塞的时间 。还有需要注意的是,3PC 在 DoCommit 阶段参与者如未收到协调者发送的提交事务的请求,它会在一定时间内进行事务的提交。为什么这么做呢?是因为这个时候我们肯定保证了在第一阶段所有的协调者全部返回了可以执行事务的响应,这个时候我们有理由相信其他系统都能进行事务的执行和提交,所以不管协调者有没有发消息给参与者,进入第三阶段参与者都会进行事务的提交操作。\n总之,3PC 通过一系列的超时机制很好的缓解了阻塞问题,但是最重要的一致性并没有得到根本的解决,比如在 DoCommit 阶段,当一个参与者收到了请求之后其他参与者和协调者挂了或者出现了网络分区,这个时候收到消息的参与者都会进行事务提交,这就会出现数据不一致性问题。\n所以,要解决一致性问题还需要靠 Paxos 算法 ⭐️ ⭐️ ⭐️ 。\nPaxos 算法 # Paxos 算法是基于消息传递且具有高度容错特性的一致性算法,是目前公认的解决分布式一致性问题最有效的算法之一,其解决的问题就是在分布式系统中如何就某个值(决议)达成一致 。\n在 Paxos 中主要有三个角色,分别为 Proposer提案者、Acceptor表决者、Learner学习者。Paxos 算法和 2PC 一样,也有两个阶段,分别为 Prepare 和 accept 阶段。\nprepare 阶段 # Proposer提案者:负责提出 proposal,每个提案者在提出提案时都会首先获取到一个 具有全局唯一性的、递增的提案编号 N,即在整个集群中是唯一的编号 N,然后将该编号赋予其要提出的提案,在第一阶段是只将提案编号发送给所有的表决者。 Acceptor表决者:每个表决者在 accept 某提案后,会将该提案编号 N 记录在本地,这样每个表决者中保存的已经被 accept 的提案中会存在一个编号最大的提案,其编号假设为 maxN。每个表决者仅会 accept 编号大于自己本地 maxN 的提案,在批准提案时表决者会将以前接受过的最大编号的提案作为响应反馈给 Proposer 。 下面是 prepare 阶段的流程图,你可以对照着参考一下。\naccept 阶段 # 当一个提案被 Proposer 提出后,如果 Proposer 收到了超过半数的 Acceptor 的批准(Proposer 本身同意),那么此时 Proposer 会给所有的 Acceptor 发送真正的提案(你可以理解为第一阶段为试探),这个时候 Proposer 就会发送提案的内容和提案编号。\n表决者收到提案请求后会再次比较本身已经批准过的最大提案编号和该提案编号,如果该提案编号 大于等于 已经批准过的最大提案编号,那么就 accept 该提案(此时执行提案内容但不提交),随后将情况返回给 Proposer 。如果不满足则不回应或者返回 NO 。\n当 Proposer 收到超过半数的 accept ,那么它这个时候会向所有的 acceptor 发送提案的提交请求。需要注意的是,因为上述仅仅是超过半数的 acceptor 批准执行了该提案内容,其他没有批准的并没有执行该提案内容,所以这个时候需要向未批准的 acceptor 发送提案内容和提案编号并让它无条件执行和提交,而对于前面已经批准过该提案的 acceptor 来说 仅仅需要发送该提案的编号 ,让 acceptor 执行提交就行了。\n而如果 Proposer 如果没有收到超过半数的 accept 那么它将会将 递增 该 Proposal 的编号,然后 重新进入 Prepare 阶段 。\n对于 Learner 来说如何去学习 Acceptor 批准的提案内容,这有很多方式,读者可以自己去了解一下,这里不做过多解释。\npaxos 算法的死循环问题 # 其实就有点类似于两个人吵架,小明说我是对的,小红说我才是对的,两个人据理力争的谁也不让谁 🤬🤬。\n比如说,此时提案者 P1 提出一个方案 M1,完成了 Prepare 阶段的工作,这个时候 acceptor 则批准了 M1,但是此时提案者 P2 同时也提出了一个方案 M2,它也完成了 Prepare 阶段的工作。然后 P1 的方案已经不能在第二阶段被批准了(因为 acceptor 已经批准了比 M1 更大的 M2),所以 P1 自增方案变为 M3 重新进入 Prepare 阶段,然后 acceptor ,又批准了新的 M3 方案,它又不能批准 M2 了,这个时候 M2 又自增进入 Prepare 阶段。。。\n就这样无休无止的永远提案下去,这就是 paxos 算法的死循环问题。\n那么如何解决呢?很简单,人多了容易吵架,我现在 就允许一个能提案 就行了。\n引出 ZAB # Zookeeper 架构 # 作为一个优秀高效且可靠的分布式协调框架,ZooKeeper 在解决分布式数据一致性问题时并没有直接使用 Paxos ,而是专门定制了一致性协议叫做 ZAB(ZooKeeper Atomic Broadcast) 原子广播协议,该协议能够很好地支持 崩溃恢复 。\nZAB 中的三个角色 # 和介绍 Paxos 一样,在介绍 ZAB 协议之前,我们首先来了解一下在 ZAB 中三个主要的角色,Leader 领导者、Follower跟随者、Observer观察者 。\nLeader:集群中 唯一的写请求处理者 ,能够发起投票(投票也是为了进行写请求)。 Follower:能够接收客户端的请求,如果是读请求则可以自己处理,如果是写请求则要转发给 Leader 。在选举过程中会参与投票,有选举权和被选举权 。 Observer:就是没有选举权和被选举权的 Follower 。 在 ZAB 协议中对 zkServer(即上面我们说的三个角色的总称) 还有两种模式的定义,分别是 消息广播 和 崩溃恢复 。\n消息广播模式 # 说白了就是 ZAB 协议是如何处理写请求的,上面我们不是说只有 Leader 能处理写请求嘛?那么我们的 Follower 和 Observer 是不是也需要 同步更新数据 呢?总不能数据只在 Leader 中更新了,其他角色都没有得到更新吧?\n不就是 在整个集群中保持数据的一致性 嘛?如果是你,你会怎么做呢?\n废话,第一步肯定需要 Leader 将写请求 广播 出去呀,让 Leader 问问 Followers 是否同意更新,如果超过半数以上的同意那么就进行 Follower 和 Observer 的更新(和 Paxos 一样)。当然这么说有点虚,画张图理解一下。\n嗯。。。看起来很简单,貌似懂了 🤥🤥🤥。这两个 Queue 哪冒出来的?答案是 ZAB 需要让 Follower 和 Observer 保证顺序性 。何为顺序性,比如我现在有一个写请求 A,此时 Leader 将请求 A 广播出去,因为只需要半数同意就行,所以可能这个时候有一个 Follower F1 因为网络原因没有收到,而 Leader 又广播了一个请求 B,因为网络原因,F1 竟然先收到了请求 B 然后才收到了请求 A,这个时候请求处理的顺序不同就会导致数据的不同,从而 产生数据不一致问题 。\n所以在 Leader 这端,它为每个其他的 zkServer 准备了一个 队列 ,采用先进先出的方式发送消息。由于协议是 通过 TCP 来进行网络通信的,保证了消息的发送顺序性,接受顺序性也得到了保证。\n除此之外,在 ZAB 中还定义了一个 全局单调递增的事务 ID ZXID ,它是一个 64 位 long 型,其中高 32 位表示 epoch 年代,低 32 位表示事务 id。epoch 是会根据 Leader 的变化而变化的,当一个 Leader 挂了,新的 Leader 上位的时候,年代(epoch)就变了。而低 32 位可以简单理解为递增的事务 id。\n定义这个的原因也是为了顺序性,每个 proposal 在 Leader 中生成后需要 通过其 ZXID 来进行排序 ,才能得到处理。\n崩溃恢复模式 # 说到崩溃恢复我们首先要提到 ZAB 中的 Leader 选举算法,当系统出现崩溃影响最大应该是 Leader 的崩溃,因为我们只有一个 Leader ,所以当 Leader 出现问题的时候我们势必需要重新选举 Leader 。\nLeader 选举可以分为两个不同的阶段,第一个是我们提到的 Leader 宕机需要重新选举,第二则是当 Zookeeper 启动时需要进行系统的 Leader 初始化选举。下面我先来介绍一下 ZAB 是如何进行初始化选举的。\n假设我们集群中有 3 台机器,那也就意味着我们需要两台以上同意(超过半数)。比如这个时候我们启动了 server1 ,它会首先 投票给自己 ,投票内容为服务器的 myid 和 ZXID ,因为初始化所以 ZXID 都为 0,此时 server1 发出的投票为 (1,0)。但此时 server1 的投票仅为 1,所以不能作为 Leader ,此时还在选举阶段所以整个集群处于 Looking 状态。\n接着 server2 启动了,它首先也会将投票选给自己(2,0),并将投票信息广播出去(server1也会,只是它那时没有其他的服务器了),server1 在收到 server2 的投票信息后会将投票信息与自己的作比较。首先它会比较 ZXID ,ZXID 大的优先为 Leader,如果相同则比较 myid,myid 大的优先作为 Leader。所以此时server1 发现 server2 更适合做 Leader,它就会将自己的投票信息更改为(2,0)然后再广播出去,之后server2 收到之后发现和自己的一样无需做更改,并且自己的 投票已经超过半数 ,则 确定 server2 为 Leader,server1 也会将自己服务器设置为 Following 变为 Follower。整个服务器就从 Looking 变为了正常状态。\n当 server3 启动发现集群没有处于 Looking 状态时,它会直接以 Follower 的身份加入集群。\n还是前面三个 server 的例子,如果在整个集群运行的过程中 server2 挂了,那么整个集群会如何重新选举 Leader 呢?其实和初始化选举差不多。\n首先毫无疑问的是剩下的两个 Follower 会将自己的状态 从 Following 变为 Looking 状态 ,然后每个 server 会向初始化投票一样首先给自己投票(这不过这里的 zxid 可能不是 0 了,这里为了方便随便取个数字)。\n假设 server1 给自己投票为(1,99),然后广播给其他 server,server3 首先也会给自己投票(3,95),然后也广播给其他 server。server1 和 server3 此时会收到彼此的投票信息,和一开始选举一样,他们也会比较自己的投票和收到的投票(zxid 大的优先,如果相同那么就 myid 大的优先)。这个时候 server1 收到了 server3 的投票发现没自己的合适故不变,server3 收到 server1 的投票结果后发现比自己的合适于是更改投票为(1,99)然后广播出去,最后 server1 收到了发现自己的投票已经超过半数就把自己设为 Leader,server3 也随之变为 Follower。\n请注意 ZooKeeper 为什么要设置奇数个结点?比如这里我们是三个,挂了一个我们还能正常工作,挂了两个我们就不能正常工作了(已经没有超过半数的节点数了,所以无法进行投票等操作了)。而假设我们现在有四个,挂了一个也能工作,但是挂了两个也不能正常工作了,这是和三个一样的,而三个比四个还少一个,带来的效益是一样的,所以 Zookeeper 推荐奇数个 server 。\n那么说完了 ZAB 中的 Leader 选举方式之后我们再来了解一下 崩溃恢复 是什么玩意?\n其实主要就是 当集群中有机器挂了,我们整个集群如何保证数据一致性?\n如果只是 Follower 挂了,而且挂的没超过半数的时候,因为我们一开始讲了在 Leader 中会维护队列,所以不用担心后面的数据没接收到导致数据不一致性。\n如果 Leader 挂了那就麻烦了,我们肯定需要先暂停服务变为 Looking 状态然后进行 Leader 的重新选举(上面我讲过了),但这个就要分为两种情况了,分别是 确保已经被 Leader 提交的提案最终能够被所有的 Follower 提交 和 跳过那些已经被丢弃的提案 。\n确保已经被 Leader 提交的提案最终能够被所有的 Follower 提交是什么意思呢?\n假设 Leader (server2) 发送 commit 请求(忘了请看上面的消息广播模式),他发送给了 server3,然后要发给 server1 的时候突然挂了。这个时候重新选举的时候我们如果把 server1 作为 Leader 的话,那么肯定会产生数据不一致性,因为 server3 肯定会提交刚刚 server2 发送的 commit 请求的提案,而 server1 根本没收到所以会丢弃。\n那怎么解决呢?\n聪明的同学肯定会质疑,这个时候 server1 已经不可能成为 Leader 了,因为 server1 和 server3 进行投票选举的时候会比较 ZXID ,而此时 server3 的 ZXID 肯定比 server1 的大了。(不理解可以看前面的选举算法)\n那么跳过那些已经被丢弃的提案又是什么意思呢?\n假设 Leader (server2) 此时同意了提案 N1,自身提交了这个事务并且要发送给所有 Follower 要 commit 的请求,却在这个时候挂了,此时肯定要重新进行 Leader 的选举,比如说此时选 server1 为 Leader (这无所谓)。但是过了一会,这个 挂掉的 Leader 又重新恢复了 ,此时它肯定会作为 Follower 的身份进入集群中,需要注意的是刚刚 server2 已经同意提交了提案 N1,但其他 server 并没有收到它的 commit 信息,所以其他 server 不可能再提交这个提案 N1 了,这样就会出现数据不一致性问题了,所以 该提案 N1 最终需要被抛弃掉 。\nZookeeper 的几个理论知识 # 了解了 ZAB 协议还不够,它仅仅是 Zookeeper 内部实现的一种方式,而我们如何通过 Zookeeper 去做一些典型的应用场景呢?比如说集群管理,分布式锁,Master 选举等等。\n这就涉及到如何使用 Zookeeper 了,但在使用之前我们还需要掌握几个概念。比如 Zookeeper 的 数据模型、会话机制、ACL、Watcher 机制 等等。\n数据模型 # zookeeper 数据存储结构与标准的 Unix 文件系统非常相似,都是在根节点下挂很多子节点(树型)。但是 zookeeper 中没有文件系统中目录与文件的概念,而是 使用了 znode 作为数据节点 。znode 是 zookeeper 中的最小数据单元,每个 znode 上都可以保存数据,同时还可以挂载子节点,形成一个树形化命名空间。\n每个 znode 都有自己所属的 节点类型 和 节点状态。\n其中节点类型可以分为 持久节点、持久顺序节点、临时节点 和 临时顺序节点。\n持久节点:一旦创建就一直存在,直到将其删除。 持久顺序节点:一个父节点可以为其子节点 维护一个创建的先后顺序 ,这个顺序体现在 节点名称 上,是节点名称后自动添加一个由 10 位数字组成的数字串,从 0 开始计数。 临时节点:临时节点的生命周期是与 客户端会话 绑定的,会话消失则节点消失 。临时节点 只能做叶子节点 ,不能创建子节点。 临时顺序节点:父节点可以创建一个维持了顺序的临时节点(和前面的持久顺序性节点一样)。 节点状态中包含了很多节点的属性比如 czxid、mzxid 等等,在 zookeeper 中是使用 Stat 这个类来维护的。下面我列举一些属性解释。\nczxid:Created ZXID,该数据节点被 创建 时的事务 ID。 mzxid:Modified ZXID,节点 最后一次被更新时 的事务 ID。 ctime:Created Time,该节点被创建的时间。 mtime:Modified Time,该节点最后一次被修改的时间。 version:节点的版本号。 cversion:子节点 的版本号。 aversion:节点的 ACL 版本号。 ephemeralOwner:创建该节点的会话的 sessionID ,如果该节点为持久节点,该值为 0。 dataLength:节点数据内容的长度。 numChildre:该节点的子节点个数,如果为临时节点为 0。 pzxid:该节点子节点列表最后一次被修改时的事务 ID,注意是子节点的 列表 ,不是内容。 会话 # 我想这个对于后端开发的朋友肯定不陌生,不就是 session 吗?只不过 zk 客户端和服务端是通过 TCP 长连接 维持的会话机制,其实对于会话来说你可以理解为 保持连接状态 。\n在 zookeeper 中,会话还有对应的事件,比如 CONNECTION_LOSS 连接丢失事件、SESSION_MOVED 会话转移事件、SESSION_EXPIRED 会话超时失效事件 。\nACL # ACL 为 Access Control Lists ,它是一种权限控制。在 zookeeper 中定义了 5 种权限,它们分别为:\nCREATE:创建子节点的权限。 READ:获取节点数据和子节点列表的权限。 WRITE:更新节点数据的权限。 DELETE:删除子节点的权限。 ADMIN:设置节点 ACL 的权限。 Watcher 机制 # Watcher 为事件监听器,是 zk 非常重要的一个特性,很多功能都依赖于它,它有点类似于订阅的方式,即客户端向服务端 注册 指定的 watcher ,当服务端符合了 watcher 的某些事件或要求则会 向客户端发送事件通知 ,客户端收到通知后找到自己定义的 Watcher 然后 执行相应的回调方法 。\nZookeeper 的几个典型应用场景 # 前面说了这么多的理论知识,你可能听得一头雾水,这些玩意有啥用?能干啥事?别急,听我慢慢道来。\n选主 # 还记得上面我们的所说的临时节点吗?因为 Zookeeper 的强一致性,能够很好地在保证 在高并发的情况下保证节点创建的全局唯一性 (即无法重复创建同样的节点)。\n利用这个特性,我们可以 让多个客户端创建一个指定的节点 ,创建成功的就是 master。\n但是,如果这个 master 挂了怎么办???\n你想想为什么我们要创建临时节点?还记得临时节点的生命周期吗?master 挂了是不是代表会话断了?会话断了是不是意味着这个节点没了?还记得 watcher 吗?我们是不是可以 让其他不是 master 的节点监听节点的状态 ,比如说我们监听这个临时节点的父节点,如果子节点个数变了就代表 master 挂了,这个时候我们 触发回调函数进行重新选举 ,或者我们直接监听节点的状态,我们可以通过节点是否已经失去连接来判断 master 是否挂了等等。\n总的来说,我们可以完全 利用 临时节点、节点状态 和 watcher 来实现选主的功能,临时节点主要用来选举,节点状态和watcher 可以用来判断 master 的活性和进行重新选举。\n数据发布/订阅 # 还记得 Zookeeper 的 Watcher 机制吗? Zookeeper 通过这种推拉相结合的方式实现客户端与服务端的交互:客户端向服务端注册节点,一旦相应节点的数据变更,服务端就会向“监听”该节点的客户端发送 Watcher 事件通知,客户端接收到通知后需要 主动 到服务端获取最新的数据。基于这种方式,Zookeeper 实现了 数据发布/订阅 功能。\n一个典型的应用场景为 全局配置信息的集中管理。 客户端在启动时会主动到 Zookeeper 服务端获取配置信息,同时 在指定节点注册一个 Watcher 监听。当配置信息发生变更,服务端通知所有订阅的客户端重新获取配置信息,实现配置信息的实时更新。\n上面所提到的全局配置信息通常包括机器列表信息、运行时的开关配置、数据库配置信息等。需要注意的是,这类全局配置信息通常具备以下特性:\n数据量较小 数据内容在运行时动态变化 集群中机器共享一致配置 负载均衡 # 可以通过 Zookeeper 的 临时节点 实现负载均衡。回顾一下临时节点的特性:当创建节点的客户端与服务端之间断开连接,即客户端会话(session)消失时,对应节点也会自动消失。因此,我们可以使用临时节点来维护 Server 的地址列表,从而保证请求不会被分配到已停机的服务上。\n具体地,我们需要在集群的每一个 Server 中都使用 Zookeeper 客户端连接 Zookeeper 服务端,同时用 Server 自身的地址信息在服务端指定目录下创建临时节点。当客户端请求调用集群服务时,首先通过 Zookeeper 获取该目录下的节点列表 (即所有可用的 Server),随后根据不同的负载均衡策略将请求转发到某一具体的 Server。\n分布式锁 # 分布式锁的实现方式有很多种,比如 Redis、数据库、zookeeper 等。个人认为 zookeeper 在实现分布式锁这方面是非常非常简单的。\n上面我们已经提到过了 zk 在高并发的情况下保证节点创建的全局唯一性,这玩意一看就知道能干啥了。实现互斥锁呗,又因为能在分布式的情况下,所以能实现分布式锁呗。\n如何实现呢?这玩意其实跟选主基本一样,我们也可以利用临时节点的创建来实现。\n首先肯定是如何获取锁,因为创建节点的唯一性,我们可以让多个客户端同时创建一个临时节点,创建成功的就说明获取到了锁 。然后没有获取到锁的客户端也像上面选主的非主节点创建一个 watcher 进行节点状态的监听,如果这个互斥锁被释放了(可能获取锁的客户端宕机了,或者那个客户端主动释放了锁)可以调用回调函数重新获得锁。\nzk 中不需要向 redis 那样考虑锁得不到释放的问题了,因为当客户端挂了,节点也挂了,锁也释放了。是不是很简单?\n那能不能使用 zookeeper 同时实现 共享锁和独占锁 呢?答案是可以的,不过稍微有点复杂而已。\n还记得 有序的节点 吗?\n这个时候我规定所有创建节点必须有序,当你是读请求(要获取共享锁)的话,如果 没有比自己更小的节点,或比自己小的节点都是读请求 ,则可以获取到读锁,然后就可以开始读了。若比自己小的节点中有写请求 ,则当前客户端无法获取到读锁,只能等待前面的写请求完成。\n如果你是写请求(获取独占锁),若 没有比自己更小的节点 ,则表示当前客户端可以直接获取到写锁,对数据进行修改。若发现 有比自己更小的节点,无论是读操作还是写操作,当前客户端都无法获取到写锁 ,等待所有前面的操作完成。\n这就很好地同时实现了共享锁和独占锁,当然还有优化的地方,比如当一个锁得到释放它会通知所有等待的客户端从而造成 羊群效应 。此时你可以通过让等待的节点只监听他们前面的节点。\n具体怎么做呢?其实也很简单,你可以让 读请求监听比自己小的最后一个写请求节点,写请求只监听比自己小的最后一个节点 ,感兴趣的小伙伴可以自己去研究一下。\n命名服务 # 如何给一个对象设置 ID,大家可能都会想到 UUID,但是 UUID 最大的问题就在于它太长了。。。(太长不一定是好事,嘿嘿嘿)。那么在条件允许的情况下,我们能不能使用 zookeeper 来实现呢?\n我们之前提到过 zookeeper 是通过 树形结构 来存储数据节点的,那也就是说,对于每个节点的 全路径,它必定是唯一的,我们可以使用节点的全路径作为命名方式了。而且更重要的是,路径是我们可以自己定义的,这对于我们对有些有语意的对象的 ID 设置可以更加便于理解。\n集群管理和注册中心 # 看到这里是不是觉得 zookeeper 实在是太强大了,它怎么能这么能干!\n别急,它能干的事情还很多呢。可能我们会有这样的需求,我们需要了解整个集群中有多少机器在工作,我们想对集群中的每台机器的运行时状态进行数据采集,对集群中机器进行上下线操作等等。\n而 zookeeper 天然支持的 watcher 和 临时节点能很好的实现这些需求。我们可以为每条机器创建临时节点,并监控其父节点,如果子节点列表有变动(我们可能创建删除了临时节点),那么我们可以使用在其父节点绑定的 watcher 进行状态监控和回调。\n至于注册中心也很简单,我们同样也是让 服务提供者 在 zookeeper 中创建一个临时节点并且将自己的 ip、port、调用方式 写入节点,当 服务消费者 需要进行调用的时候会 通过注册中心找到相应的服务的地址列表(IP 端口什么的) ,并缓存到本地(方便以后调用),当消费者调用服务时,不会再去请求注册中心,而是直接通过负载均衡算法从地址列表中取一个服务提供者的服务器调用服务。\n当服务提供者的某台服务器宕机或下线时,相应的地址会从服务提供者地址列表中移除。同时,注册中心会将新的服务地址列表发送给服务消费者的机器并缓存在消费者本机(当然你可以让消费者进行节点监听,我记得 Eureka 会先试错,然后再更新)。\n总结 # 看到这里的同学实在是太有耐心了 👍👍👍 不知道大家是否还记得我讲了什么 😒。\n这篇文章中我带大家入门了 zookeeper 这个强大的分布式协调框架。现在我们来简单梳理一下整篇文章的内容。\n分布式与集群的区别\n2PC、3PC 以及 paxos 算法这些一致性框架的原理和实现。\nzookeeper 专门的一致性算法 ZAB 原子广播协议的内容(Leader 选举、崩溃恢复、消息广播)。\nzookeeper 中的一些基本概念,比如 ACL,数据节点,会话,watcher机制等等。\nzookeeper 的典型应用场景,比如选主,注册中心等等。\n如果忘了可以回去看看再次理解一下,如果有疑问和建议欢迎提出 🤝🤝🤝。\n"},{"id":591,"href":"/zh/docs/technology/Interview/distributed-system/distributed-process-coordination/zookeeper/zookeeper-intro/","title":"ZooKeeper相关概念总结(入门)","section":"Distributed Process Coordination","content":"相信大家对 ZooKeeper 应该不算陌生。但是你真的了解 ZooKeeper 到底有啥用不?如果别人/面试官让你给他讲讲对于 ZooKeeper 的认识,你能回答到什么地步呢?\n拿我自己来说吧!我本人在大学曾经使用 Dubbo 来做分布式项目的时候,使用了 ZooKeeper 作为注册中心。为了保证分布式系统能够同步访问某个资源,我还使用 ZooKeeper 做过分布式锁。另外,我在学习 Kafka 的时候,知道 Kafka 很多功能的实现依赖了 ZooKeeper。\n前几天,总结项目经验的时候,我突然问自己 ZooKeeper 到底是个什么东西?想了半天,脑海中只是简单的能浮现出几句话:\nZooKeeper 可以被用作注册中心、分布式锁; ZooKeeper 是 Hadoop 生态系统的一员; 构建 ZooKeeper 集群的时候,使用的服务器最好是奇数台。 由此可见,我对于 ZooKeeper 的理解仅仅是停留在了表面。\n所以,通过本文,希望带大家稍微详细的了解一下 ZooKeeper 。如果没有学过 ZooKeeper ,那么本文将会是你进入 ZooKeeper 大门的垫脚砖。如果你已经接触过 ZooKeeper ,那么本文将带你回顾一下 ZooKeeper 的一些基础概念。\n另外,本文不光会涉及到 ZooKeeper 的一些概念,后面的文章会介绍到 ZooKeeper 常见命令的使用以及使用 Apache Curator 作为 ZooKeeper 的客户端。\n如果文章有任何需要改善和完善的地方,欢迎在评论区指出,共同进步!\nZooKeeper 介绍 # ZooKeeper 由来 # 正式介绍 ZooKeeper 之前,我们先来看看 ZooKeeper 的由来,还挺有意思的。\n下面这段内容摘自《从 Paxos 到 ZooKeeper》第四章第一节,推荐大家阅读一下:\nZooKeeper 最早起源于雅虎研究院的一个研究小组。在当时,研究人员发现,在雅虎内部很多大型系统基本都需要依赖一个类似的系统来进行分布式协调,但是这些系统往往都存在分布式单点问题。所以,雅虎的开发人员就试图开发一个通用的无单点问题的分布式协调框架,以便让开发人员将精力集中在处理业务逻辑上。\n关于“ZooKeeper”这个项目的名字,其实也有一段趣闻。在立项初期,考虑到之前内部很多项目都是使用动物的名字来命名的(例如著名的 Pig 项目),雅虎的工程师希望给这个项目也取一个动物的名字。时任研究院的首席科学家 RaghuRamakrishnan 开玩笑地说:“在这样下去,我们这儿就变成动物园了!”此话一出,大家纷纷表示就叫动物园管理员吧一一一因为各个以动物命名的分布式组件放在一起,雅虎的整个分布式系统看上去就像一个大型的动物园了,而 ZooKeeper 正好要用来进行分布式环境的协调一一于是,ZooKeeper 的名字也就由此诞生了。\nZooKeeper 概览 # ZooKeeper 是一个开源的分布式协调服务,它的设计目标是将那些复杂且容易出错的分布式一致性服务封装起来,构成一个高效可靠的原语集,并以一系列简单易用的接口提供给用户使用。\n原语: 操作系统或计算机网络用语范畴。是由若干条指令组成的,用于完成一定功能的一个过程。具有不可分割性,即原语的执行必须是连续的,在执行过程中不允许被中断。\nZooKeeper 为我们提供了高可用、高性能、稳定的分布式数据一致性解决方案,通常被用于实现诸如数据发布/订阅、负载均衡、命名服务、分布式协调/通知、集群管理、Master 选举、分布式锁和分布式队列等功能。这些功能的实现主要依赖于 ZooKeeper 提供的 数据存储+事件监听 功能(后文会详细介绍到) 。\nZooKeeper 将数据保存在内存中,性能是不错的。 在“读”多于“写”的应用程序中尤其地高性能,因为“写”会导致所有的服务器间同步状态。(“读”多于“写”是协调服务的典型场景)。\n另外,很多顶级的开源项目都用到了 ZooKeeper,比如:\nKafka : ZooKeeper 主要为 Kafka 提供 Broker 和 Topic 的注册以及多个 Partition 的负载均衡等功能。不过,在 Kafka 2.8 之后,引入了基于 Raft 协议的 KRaft 模式,不再依赖 Zookeeper,大大简化了 Kafka 的架构。 Hbase : ZooKeeper 为 Hbase 提供确保整个集群只有一个 Master 以及保存和提供 regionserver 状态信息(是否在线)等功能。 Hadoop : ZooKeeper 为 Namenode 提供高可用支持。 ZooKeeper 特点 # 顺序一致性: 从同一客户端发起的事务请求,最终将会严格地按照顺序被应用到 ZooKeeper 中去。 原子性: 所有事务请求的处理结果在整个集群中所有机器上的应用情况是一致的,也就是说,要么整个集群中所有的机器都成功应用了某一个事务,要么都没有应用。 单一系统映像: 无论客户端连到哪一个 ZooKeeper 服务器上,其看到的服务端数据模型都是一致的。 可靠性: 一旦一次更改请求被应用,更改的结果就会被持久化,直到被下一次更改覆盖。 实时性: 一旦数据发生变更,其他节点会实时感知到。每个客户端的系统视图都是最新的。 集群部署:3~5 台(最好奇数台)机器就可以组成一个集群,每台机器都在内存保存了 ZooKeeper 的全部数据,机器之间互相通信同步数据,客户端连接任何一台机器都可以。 ==高可用:==如果某台机器宕机,会保证数据不丢失。集群中挂掉不超过一半的机器,都能保证集群可用。比如 3 台机器可以挂 1 台,5 台机器可以挂 2 台。 ZooKeeper 应用场景 # ZooKeeper 概览中,我们介绍到使用其通常被用于实现诸如数据发布/订阅、负载均衡、命名服务、分布式协调/通知、集群管理、Master 选举、分布式锁和分布式队列等功能。\n下面选 3 个典型的应用场景来专门说说:\n命名服务:可以通过 ZooKeeper 的顺序节点生成全局唯一 ID。 数据发布/订阅:通过 Watcher 机制 可以很方便地实现数据发布/订阅。当你将数据发布到 ZooKeeper 被监听的节点上,其他机器可通过监听 ZooKeeper 上节点的变化来实现配置的动态更新。 分布式锁:通过创建唯一节点获得分布式锁,当获得锁的一方执行完相关代码或者是挂掉之后就释放锁。分布式锁的实现也需要用到 Watcher 机制 ,我在 分布式锁详解 这篇文章中有详细介绍到如何基于 ZooKeeper 实现分布式锁。 实际上,这些功能的实现基本都得益于 ZooKeeper 可以保存数据的功能,但是 ZooKeeper 不适合保存大量数据,这一点需要注意。\nZooKeeper 重要概念 # 破音:拿出小本本,下面的内容非常重要哦!\nData model(数据模型) # ZooKeeper 数据模型采用层次化的多叉树形结构,每个节点上都可以存储数据,这些数据可以是数字、字符串或者是二进制序列。并且。每个节点还可以拥有 N 个子节点,最上层是根节点以“/”来代表。每个数据节点在 ZooKeeper 中被称为 znode,它是 ZooKeeper 中数据的最小单元。并且,每个 znode 都有一个唯一的路径标识。\n强调一句:ZooKeeper 主要是用来协调服务的,而不是用来存储业务数据的,所以不要放比较大的数据在 znode 上,ZooKeeper 给出的每个节点的数据大小上限是 1M 。\n从下图可以更直观地看出:ZooKeeper 节点路径标识方式和 Unix 文件系统路径非常相似,都是由一系列使用斜杠\u0026quot;/\u0026ldquo;进行分割的路径表示,开发人员可以向这个节点中写入数据,也可以在节点下面创建子节点。这些操作我们后面都会介绍到。\nznode(数据节点) # 介绍了 ZooKeeper 树形数据模型之后,我们知道每个数据节点在 ZooKeeper 中被称为 znode,它是 ZooKeeper 中数据的最小单元。你要存放的数据就放在上面,是你使用 ZooKeeper 过程中经常需要接触到的一个概念。\n我们通常是将 znode 分为 4 大类:\n持久(PERSISTENT)节点:一旦创建就一直存在即使 ZooKeeper 集群宕机,直到将其删除。 临时(EPHEMERAL)节点:临时节点的生命周期是与 客户端会话(session) 绑定的,会话消失则节点消失。并且,临时节点只能做叶子节点 ,不能创建子节点。 持久顺序(PERSISTENT_SEQUENTIAL)节点:除了具有持久(PERSISTENT)节点的特性之外, 子节点的名称还具有顺序性。比如 /node1/app0000000001、/node1/app0000000002 。 临时顺序(EPHEMERAL_SEQUENTIAL)节点:除了具备临时(EPHEMERAL)节点的特性之外,子节点的名称还具有顺序性 每个 znode 由 2 部分组成:\nstat:状态信息 data:节点存放的数据的具体内容 如下所示,我通过 get 命令来获取 根目录下的 dubbo 节点的内容。(get 命令在下面会介绍到)。\n[zk: 127.0.0.1:2181(CONNECTED) 6] get /dubbo # 该数据节点关联的数据内容为空 null # 下面是该数据节点的一些状态信息,其实就是 Stat 对象的格式化输出 cZxid = 0x2 ctime = Tue Nov 27 11:05:34 CST 2018 mZxid = 0x2 mtime = Tue Nov 27 11:05:34 CST 2018 pZxid = 0x3 cversion = 1 dataVersion = 0 aclVersion = 0 ephemeralOwner = 0x0 dataLength = 0 numChildren = 1 Stat 类中包含了一个数据节点的所有状态信息的字段,包括事务 ID(cZxid)、节点创建时间(ctime) 和子节点个数(numChildren) 等等。\n下面我们来看一下每个 znode 状态信息究竟代表的是什么吧!(下面的内容来源于《从 Paxos 到 ZooKeeper 分布式一致性原理与实践》,因为 Guide 确实也不是特别清楚,要学会参考资料的嘛! ):\nznode 状态信息 解释 cZxid create ZXID,即该数据节点被创建时的事务 id ctime create time,即该节点的创建时间 mZxid modified ZXID,即该节点最终一次更新时的事务 id mtime modified time,即该节点最后一次的更新时间 pZxid 该节点的子节点列表最后一次修改时的事务 id,只有子节点列表变更才会更新 pZxid,子节点内容变更不会更新 cversion 子节点版本号,当前节点的子节点每次变化时值增加 1 dataVersion 数据节点内容版本号,节点创建时为 0,每更新一次节点内容(不管内容有无变化)该版本号的值增加 1 aclVersion 节点的 ACL 版本号,表示该节点 ACL 信息变更次数 ephemeralOwner 创建该临时节点的会话的 sessionId;如果当前节点为持久节点,则 ephemeralOwner=0 dataLength 数据节点内容长度 numChildren 当前节点的子节点个数 版本(version) # 在前面我们已经提到,对应于每个 znode,ZooKeeper 都会为其维护一个叫作 Stat 的数据结构,Stat 中记录了这个 znode 的三个相关的版本:\ndataVersion:当前 znode 节点的版本号 cversion:当前 znode 子节点的版本 aclVersion:当前 znode 的 ACL 的版本。 ACL(权限控制) # ZooKeeper 采用 ACL(AccessControlLists)策略来进行权限控制,类似于 UNIX 文件系统的权限控制。\n对于 znode 操作的权限,ZooKeeper 提供了以下 5 种:\nCREATE : 能创建子节点 READ:能获取节点数据和列出其子节点 WRITE : 能设置/更新节点数据 DELETE : 能删除子节点 ADMIN : 能设置节点 ACL 的权限 其中尤其需要注意的是,CREATE 和 DELETE 这两种权限都是针对 子节点 的权限控制。\n对于身份认证,提供了以下几种方式:\nworld:默认方式,所有用户都可无条件访问。 auth :不使用任何 id,代表任何已认证的用户。 digest :用户名:密码认证方式:username:password 。 ip : 对指定 ip 进行限制。 Watcher(事件监听器) # Watcher(事件监听器),是 ZooKeeper 中的一个很重要的特性。ZooKeeper 允许用户在指定节点上注册一些 Watcher,并且在一些特定事件触发的时候,ZooKeeper 服务端会将事件通知到感兴趣的客户端上去,该机制是 ZooKeeper 实现分布式协调服务的重要特性。\n破音:非常有用的一个特性,都拿出小本本记好了,后面用到 ZooKeeper 基本离不开 Watcher(事件监听器)机制。\n会话(Session) # Session 可以看作是 ZooKeeper 服务器与客户端的之间的一个 TCP 长连接,通过这个连接,客户端能够通过心跳检测与服务器保持有效的会话,也能够向 ZooKeeper 服务器发送请求并接受响应,同时还能够通过该连接接收来自服务器的 Watcher 事件通知。\nSession 有一个属性叫做:sessionTimeout ,sessionTimeout 代表会话的超时时间。当由于服务器压力太大、网络故障或是客户端主动断开连接等各种原因导致客户端连接断开时,只要在sessionTimeout规定的时间内能够重新连接上集群中任意一台服务器,那么之前创建的会话仍然有效。\n另外,在为客户端创建会话之前,服务端首先会为每个客户端都分配一个 sessionID。由于 sessionID是 ZooKeeper 会话的一个重要标识,许多与会话相关的运行机制都是基于这个 sessionID 的,因此,无论是哪台服务器为客户端分配的 sessionID,都务必保证全局唯一。\nZooKeeper 集群 # 为了保证高可用,最好是以集群形态来部署 ZooKeeper,这样只要集群中大部分机器是可用的(能够容忍一定的机器故障),那么 ZooKeeper 本身仍然是可用的。通常 3 台服务器就可以构成一个 ZooKeeper 集群了。ZooKeeper 官方提供的架构图就是一个 ZooKeeper 集群整体对外提供服务。\n上图中每一个 Server 代表一个安装 ZooKeeper 服务的服务器。组成 ZooKeeper 服务的服务器都会在内存中维护当前的服务器状态,并且每台服务器之间都互相保持着通信。集群间通过 ZAB 协议(ZooKeeper Atomic Broadcast)来保持数据的一致性。\n最典型集群模式:Master/Slave 模式(主备模式)。在这种模式中,通常 Master 服务器作为主服务器提供写服务,其他的 Slave 服务器从服务器通过异步复制的方式获取 Master 服务器最新的数据提供读服务。\nZooKeeper 集群角色 # 但是,在 ZooKeeper 中没有选择传统的 Master/Slave 概念,而是引入了 Leader、Follower 和 Observer 三种角色。如下图所示\nZooKeeper 集群中的所有机器通过一个 Leader 选举过程 来选定一台称为 “Leader” 的机器,Leader 既可以为客户端提供写服务又能提供读服务。除了 Leader 外,Follower 和 Observer 都只能提供读服务。Follower 和 Observer 唯一的区别在于 Observer 机器不参与 Leader 的选举过程,也不参与写操作的“过半写成功”策略,因此 Observer 机器可以在不影响写性能的情况下提升集群的读性能。\n角色 说明 Leader 为客户端提供读和写的服务,负责投票的发起和决议,更新系统状态。 Follower 为客户端提供读服务,如果是写服务则转发给 Leader。参与选举过程中的投票。 Observer 为客户端提供读服务,如果是写服务则转发给 Leader。不参与选举过程中的投票,也不参与“过半写成功”策略。在不影响写性能的情况下提升集群的读性能。此角色于 ZooKeeper3.3 系列新增的角色。 ZooKeeper 集群 Leader 选举过程 # 当 Leader 服务器出现网络中断、崩溃退出与重启等异常情况时,就会进入 Leader 选举过程,这个过程会选举产生新的 Leader 服务器。\n这个过程大致是这样的:\nLeader election(选举阶段):节点在一开始都处于选举阶段,只要有一个节点得到超半数节点的票数,它就可以当选准 leader。 Discovery(发现阶段):在这个阶段,followers 跟准 leader 进行通信,同步 followers 最近接收的事务提议。 Synchronization(同步阶段):同步阶段主要是利用 leader 前一阶段获得的最新提议历史,同步集群中所有的副本。同步完成之后准 leader 才会成为真正的 leader。 Broadcast(广播阶段):到了这个阶段,ZooKeeper 集群才能正式对外提供事务服务,并且 leader 可以进行消息广播。同时如果有新的节点加入,还需要对新节点进行同步。 ZooKeeper 集群中的服务器状态有下面几种:\nLOOKING:寻找 Leader。 LEADING:Leader 状态,对应的节点为 Leader。 FOLLOWING:Follower 状态,对应的节点为 Follower。 OBSERVING:Observer 状态,对应节点为 Observer,该节点不参与 Leader 选举。 ZooKeeper 集群为啥最好奇数台? # ZooKeeper 集群在宕掉几个 ZooKeeper 服务器之后,如果剩下的 ZooKeeper 服务器个数大于宕掉的个数的话整个 ZooKeeper 才依然可用。假如我们的集群中有 n 台 ZooKeeper 服务器,那么也就是剩下的服务数必须大于 n/2。先说一下结论,2n 和 2n-1 的容忍度是一样的,都是 n-1,大家可以先自己仔细想一想,这应该是一个很简单的数学问题了。\n比如假如我们有 3 台,那么最大允许宕掉 1 台 ZooKeeper 服务器,如果我们有 4 台的的时候也同样只允许宕掉 1 台。 假如我们有 5 台,那么最大允许宕掉 2 台 ZooKeeper 服务器,如果我们有 6 台的的时候也同样只允许宕掉 2 台。\n综上,何必增加那一个不必要的 ZooKeeper 呢?\nZooKeeper 选举的过半机制防止脑裂 # 何为集群脑裂?\n对于一个集群,通常多台机器会部署在不同机房,来提高这个集群的可用性。保证可用性的同时,会发生一种机房间网络线路故障,导致机房间网络不通,而集群被割裂成几个小集群。这时候子集群各自选主导致“脑裂”的情况。\n举例说明:比如现在有一个由 6 台服务器所组成的一个集群,部署在了 2 个机房,每个机房 3 台。正常情况下只有 1 个 leader,但是当两个机房中间网络断开的时候,每个机房的 3 台服务器都会认为另一个机房的 3 台服务器下线,而选出自己的 leader 并对外提供服务。若没有过半机制,当网络恢复的时候会发现有 2 个 leader。仿佛是 1 个大脑(leader)分散成了 2 个大脑,这就发生了脑裂现象。脑裂期间 2 个大脑都可能对外提供了服务,这将会带来数据一致性等问题。\n过半机制是如何防止脑裂现象产生的?\nZooKeeper 的过半机制导致不可能产生 2 个 leader,因为少于等于一半是不可能产生 leader 的,这就使得不论机房的机器如何分配都不可能发生脑裂。\nZAB 协议和 Paxos 算法 # Paxos 算法应该可以说是 ZooKeeper 的灵魂了。但是,ZooKeeper 并没有完全采用 Paxos 算法 ,而是使用 ZAB 协议作为其保证数据一致性的核心算法。另外,在 ZooKeeper 的官方文档中也指出,ZAB 协议并不像 Paxos 算法那样,是一种通用的分布式一致性算法,它是一种特别为 Zookeeper 设计的崩溃可恢复的原子消息广播算法。\nZAB 协议介绍 # ZAB(ZooKeeper Atomic Broadcast,原子广播) 协议是为分布式协调服务 ZooKeeper 专门设计的一种支持崩溃恢复的原子广播协议。 在 ZooKeeper 中,主要依赖 ZAB 协议来实现分布式数据一致性,基于该协议,ZooKeeper 实现了一种主备模式的系统架构来保持集群中各个副本之间的数据一致性。\nZAB 协议两种基本的模式:崩溃恢复和消息广播 # ZAB 协议包括两种基本的模式,分别是\n崩溃恢复:当整个服务框架在启动过程中,或是当 Leader 服务器出现网络中断、崩溃退出与重启等异常情况时,ZAB 协议就会进入恢复模式并选举产生新的 Leader 服务器。当选举产生了新的 Leader 服务器,同时集群中已经有过半的机器与该 Leader 服务器完成了状态同步之后,ZAB 协议就会退出恢复模式。其中,所谓的状态同步是指数据同步,用来保证集群中存在过半的机器能够和 Leader 服务器的数据状态保持一致。 消息广播:当集群中已经有过半的 Follower 服务器完成了和 Leader 服务器的状态同步,那么整个服务框架就可以进入消息广播模式了。 当一台同样遵守 ZAB 协议的服务器启动后加入到集群中时,如果此时集群中已经存在一个 Leader 服务器在负责进行消息广播,那么新加入的服务器就会自觉地进入数据恢复模式:找到 Leader 所在的服务器,并与其进行数据同步,然后一起参与到消息广播流程中去。 ZAB 协议\u0026amp;Paxos 算法文章推荐 # 关于 ZAB 协议\u0026amp;Paxos 算法 需要讲和理解的东西太多了,具体可以看下面这几篇文章:\nPaxos 算法详解 ZooKeeper 与 Zab 协议 · Analyze Raft 算法详解 ZooKeeper VS ETCD # ETCD 是一种强一致性的分布式键值存储,它提供了一种可靠的方式来存储需要由分布式系统或机器集群访问的数据。ETCD 内部采用 Raft 算法作为一致性算法,基于 Go 语言实现。\n与 ZooKeeper 类似,ETCD 也可用于数据发布/订阅、负载均衡、命名服务、分布式协调/通知、分布式锁等场景。那二者如何选择呢?\n得物技术的 浅析如何基于 ZooKeeper 实现高可用架构这篇文章给出了如下的对比表格(我进一步做了优化),可以作为参考:\nZooKeeper ETCD 语言 Java Go 协议 TCP Grpc 接口调用 必须要使用自己的 client 进行调用 可通过 HTTP 传输,即可通过 CURL 等命令实现调用 一致性算法 Zab 协议 Raft 算法 Watcher 机制 较局限,一次性触发器 一次 Watch 可以监听所有的事件 数据模型 基于目录的层次模式 参考了 zk 的数据模型,是个扁平的 kv 模型 存储 kv 存储,使用的是 ConcurrentHashMap,内存存储,一般不建议存储较多数据 kv 存储,使用 bbolt 存储引擎,可以处理几个 GB 的数据。 MVCC 不支持 支持,通过两个 B+ Tree 进行版本控制 全局 Session 存在缺陷 实现更灵活,避免了安全性问题 权限校验 ACL RBAC 事务能力 提供了简易的事务能力 只提供了版本号的检查能力 部署维护 复杂 简单 ZooKeeper 在存储性能、全局 Session、Watcher 机制等方面存在一定局限性,越来越多的开源项目在替换 ZooKeeper 为 Raft 实现或其它分布式协调服务,例如: Kafka Needs No Keeper - Removing ZooKeeper Dependency (confluent.io)、 Moving Toward a ZooKeeper-Less Apache Pulsar (streamnative.io)。\nETCD 相对来说更优秀一些,提供了更稳定的高负载读写能力,对 ZooKeeper 暴露的许多问题进行了改进优化。并且,ETCD 基本能够覆盖 ZooKeeper 的所有应用场景,实现对其的替代。\n总结 # ZooKeeper 本身就是一个分布式程序(只要半数以上节点存活,ZooKeeper 就能正常服务)。 为了保证高可用,最好是以集群形态来部署 ZooKeeper,这样只要集群中大部分机器是可用的(能够容忍一定的机器故障),那么 ZooKeeper 本身仍然是可用的。 ZooKeeper 将数据保存在内存中,这也就保证了 高吞吐量和低延迟(但是内存限制了能够存储的容量不太大,此限制也是保持 znode 中存储的数据量较小的进一步原因)。 ZooKeeper 是高性能的。 在“读”多于“写”的应用程序中尤其地明显,因为“写”会导致所有的服务器间同步状态。(“读”多于“写”是协调服务的典型场景。) ZooKeeper 有临时节点的概念。 当创建临时节点的客户端会话一直保持活动,瞬时节点就一直存在。而当会话终结时,瞬时节点被删除。持久节点是指一旦这个 znode 被创建了,除非主动进行 znode 的移除操作,否则这个 znode 将一直保存在 ZooKeeper 上。 ZooKeeper 底层其实只提供了两个功能:① 管理(存储、读取)用户程序提交的数据;② 为用户程序提供数据节点监听服务。 参考 # 《从 Paxos 到 ZooKeeper 分布式一致性原理与实践》 谈谈 ZooKeeper 的局限性: https://wingsxdu.com/posts/database/zookeeper-limitations/ "},{"id":592,"href":"/zh/docs/technology/Interview/high-quality-technical-articles/interview/some-secrets-about-alibaba-interview/","title":"阿里技术面试的一些秘密","section":"Interview","content":" 推荐语:详细介绍了求职者在面试中应该具备哪些能力才会有更大概率脱颖而出。\n原文地址: https://mp.weixin.qq.com/s/M2M808PwQ2JcMqfLQfXQMw\n最近我的工作稍微轻松些,就被安排去校招面试了\n当时还是有些激动的,以前都是被面试的,现在我自己也成为一个面试别人的面试官\n接下来就谈谈我的面试心得(谈谈阿里面试的秘籍)\n我是怎么筛选简历的? # 面试之前都是要筛选简历,这个大家应该知道\n阿里对待招聘非常负责任,面试官必须对每位同学的简历进行查看和筛选,如果不合适还需要写清楚理由\n对于校招生来说,第一份工作非常重要,而且校招的面试机会也只有一次,一旦收到大家的简历意味着大家非常认可和喜爱阿里这家公司\n所以我们对每份简历都会认真看,大家可以非常放心,不会无缘无故挂掉大家的简历\n尽管我们报以非常负责任的态度,但有些同学们的简历实在是难以下看\n关于如何写简历,我之前写过类似的文章,这里就把之前的文章放这里让大家看看 一份好的简历应该有哪些内容\n在筛选简历的时候会有以下信息非常重要,大家一定要认真写\n项目经历,具体写法可以看上面提到的文章 个人含金量比较高的奖项,比如 ACM 奖牌、计算机竞赛等 个人技能 这块会看,但是大多数简历写法都差不多,尽量写得言简意赅 重要期刊论文发表、开源项目 加分项 这些信息非常重要,我筛选简历的时候这些信息占整份简历的比重 4/5 左右\n面试的时候我会注重哪些方面? # 表达要清楚 # 这点是硬伤,在面试的时候有些同学半天说不清楚自己做的项目,我都在替你着急\n描述项目有个简单的方法论,我自己总结的 大家看看适不适合自己\n最好言简意赅的描述一下你的项目背景,让面试官很快知道项目干了啥(让面试官很快对项目感兴趣) 说下项目用了哪些技术,做技术的用了哪些技术得说清楚,面试官会对你的技术比较感兴趣 解决了什么问题,做项目肯定是为了解决问题,总不能为了做项目而做项目吧(解决问题的能力非常重要) 遇到哪些难题,如何突破这些难题,项目遇到困难问题很正常,突破困难才是一次好的成长 项目还有哪些完善的地方,不可能设计出完美的执行方案,有待改进说明你对项目认识深刻,思考深入 一场面试时间一般 60—80 分钟,好的表达有助于彼此之间了解更多的问题\n基础知识要扎实 # 校招非常注重基础知识,所以这块问的问题比较多,我一般会结合你项目去问,看看同学对技术是停留在用的阶段还是有自己的深入思考\n每个方向对基础知识要求不同,但有些基础知识是通用的\n比如数据结构与算法、操作系统、计算机网络 等\n这些基础技术知识一定要掌握扎实,技术岗位都会或多或少去问这些基础\n动手能力很重要 # action,action,action ,重要的事情说三遍,做技术的不可能光靠一张嘴,能落地才是最重要的\n面试官除了问你基础知识和项目还会去考考你的动手能力,面试时间一般不会太长,根据岗位的不同一般会让同学们写一些算法题目\n阿里面试,不会给你出非常变态的算法题目\n主要还是考察大家的动手能力、思考问题的能力、数据结构的应用能力\n在写代码的过程中,我也总结了自己的方法论:\n上来不要先写,审题、问清楚题目意图,不要自以为是的去理解思路,工作中 沟通需求、明确需求、提出质疑和建议是非常好的习惯 接下来说思路 思路错了写到一半再去改会非常浪费时间 描述清楚之后,先写代码思路的步骤注释,一边写注释,脑子里迭代一遍自己的思路是否正确,是否是最优解 最后,代码规范 除了上面这些常规的方面 # 其实,现在面试已经非常卷了,上面说的这些很多都是 八股文\n有些学生会拿到很多面试题目和答案,反复的去记忆,面试官问问题他就开始在脑子里面检索答案\n我一般问几个问题就知道该学生是不是在背八股文了。\n对于背八股文的同学,我真的非常难过。\n尽管你背的很好,但不能给你过啊,得对得起自己职责,得对公司负责啊!\n背的在好,不如理解一个知识点,理解一个知识点会有助于你去理解很多其他的知识点,很多知识点连起来就是一个知识体系。\n当面试官问你体系中的任何一个问题,都可以把这个体系讲给他听,不是背诵 。\n深入理解问题,我会比较关注。\n我在面试过程中,会通过一个问题去问一串问题,慢慢就把整体体系串起来。\n你的比赛和论文是你的亮点,这些东西是非常重要的加分项。\n我也会在面试中穿插一些开放性题目,都是思考题 考验一个同学思考问题的方式。\n最后 # 作为一个面试官,我很想对大家说,每个企业都非常渴望人才,都希望找到最适合企业发展的人\n面试的时候面试官会尽量去挖掘你的价值。\n但是,面试时间有限,同学们一定要在有限的时间里展现出自己的能力和无限的潜力 。\n最后,祝愿优秀的你能找到自己理想的工作!\n"},{"id":593,"href":"/zh/docs/technology/Interview/cs-basics/data-structure/bloom-filter/","title":"布隆过滤器","section":"Data Structure","content":"布隆过滤器相信大家没用过的话,也已经听过了。\n布隆过滤器主要是为了解决海量数据的存在性问题。对于海量数据中判定某个数据是否存在且容忍轻微误差这一场景(比如缓存穿透、海量数据去重)来说,非常适合。\n文章内容概览:\n什么是布隆过滤器? 布隆过滤器的原理介绍。 布隆过滤器使用场景。 通过 Java 编程手动实现布隆过滤器。 利用 Google 开源的 Guava 中自带的布隆过滤器。 Redis 中的布隆过滤器。 什么是布隆过滤器? # 首先,我们需要了解布隆过滤器的概念。\n布隆过滤器(Bloom Filter,BF)是一个叫做 Bloom 的老哥于 1970 年提出的。我们可以把它看作由二进制向量(或者说位数组)和一系列随机映射函数(哈希函数)两部分组成的数据结构。相比于我们平时常用的 List、Map、Set 等数据结构,它占用空间更少并且效率更高,但是缺点是其返回的结果是概率性的,而不是非常准确的。理论情况下添加到集合中的元素越多,误报的可能性就越大。并且,存放在布隆过滤器的数据不容易删除。\nBloom Filter 会使用一个较大的 bit 数组来保存所有的数据,数组中的每个元素都只占用 1 bit ,并且每个元素只能是 0 或者 1(代表 false 或者 true),这也是 Bloom Filter 节省内存的核心所在。这样来算的话,申请一个 100w 个元素的位数组只占用 1000000Bit / 8 = 125000 Byte = 125000/1024 KB ≈ 122KB 的空间。\n总结:一个名叫 Bloom 的人提出了一种来检索元素是否在给定大集合中的数据结构,这种数据结构是高效且性能很好的,但缺点是具有一定的错误识别率和删除难度。并且,理论情况下,添加到集合中的元素越多,误报的可能性就越大。\n布隆过滤器的原理介绍 # 当一个元素加入布隆过滤器中的时候,会进行如下操作:\n使用布隆过滤器中的哈希函数对元素值进行计算,得到哈希值(有几个哈希函数得到几个哈希值)。 根据得到的哈希值,在位数组中把对应下标的值置为 1。 当我们需要判断一个元素是否存在于布隆过滤器的时候,会进行如下操作:\n对给定元素再次进行相同的哈希计算; 得到值之后判断位数组中的每个元素是否都为 1,如果值都为 1,那么说明这个值在布隆过滤器中,如果存在一个值不为 1,说明该元素不在布隆过滤器中。 Bloom Filter 的简单原理图如下:\n如图所示,当字符串存储要加入到布隆过滤器中时,该字符串首先由多个哈希函数生成不同的哈希值,然后将对应的位数组的下标设置为 1(当位数组初始化时,所有位置均为 0)。当第二次存储相同字符串时,因为先前的对应位置已设置为 1,所以很容易知道此值已经存在(去重非常方便)。\n如果我们需要判断某个字符串是否在布隆过滤器中时,只需要对给定字符串再次进行相同的哈希计算,得到值之后判断位数组中的每个元素是否都为 1,如果值都为 1,那么说明这个值在布隆过滤器中,如果存在一个值不为 1,说明该元素不在布隆过滤器中。\n不同的字符串可能哈希出来的位置相同,这种情况我们可以适当增加位数组大小或者调整我们的哈希函数。\n综上,我们可以得出:布隆过滤器说某个元素存在,小概率会误判。布隆过滤器说某个元素不在,那么这个元素一定不在。\n布隆过滤器使用场景 # 判断给定数据是否存在:比如判断一个数字是否存在于包含大量数字的数字集中(数字集很大,上亿)、 防止缓存穿透(判断请求的数据是否有效避免直接绕过缓存请求数据库)等等、邮箱的垃圾邮件过滤(判断一个邮件地址是否在垃圾邮件列表中)、黑名单功能(判断一个 IP 地址或手机号码是否在黑名单中)等等。 去重:比如爬给定网址的时候对已经爬取过的 URL 去重、对巨量的 QQ 号/订单号去重。 去重场景也需要用到判断给定数据是否存在,因此布隆过滤器主要是为了解决海量数据的存在性问题。\n编码实战 # 通过 Java 编程手动实现布隆过滤器 # 我们上面已经说了布隆过滤器的原理,知道了布隆过滤器的原理之后就可以自己手动实现一个了。\n如果你想要手动实现一个的话,你需要:\n一个合适大小的位数组保存数据 几个不同的哈希函数 添加元素到位数组(布隆过滤器)的方法实现 判断给定元素是否存在于位数组(布隆过滤器)的方法实现。 下面给出一个我觉得写的还算不错的代码(参考网上已有代码改进得到,对于所有类型对象皆适用):\nimport java.util.BitSet; public class MyBloomFilter { /** * 位数组的大小 */ private static final int DEFAULT_SIZE = 2 \u0026lt;\u0026lt; 24; /** * 通过这个数组可以创建 6 个不同的哈希函数 */ private static final int[] SEEDS = new int[]{3, 13, 46, 71, 91, 134}; /** * 位数组。数组中的元素只能是 0 或者 1 */ private BitSet bits = new BitSet(DEFAULT_SIZE); /** * 存放包含 hash 函数的类的数组 */ private SimpleHash[] func = new SimpleHash[SEEDS.length]; /** * 初始化多个包含 hash 函数的类的数组,每个类中的 hash 函数都不一样 */ public MyBloomFilter() { // 初始化多个不同的 Hash 函数 for (int i = 0; i \u0026lt; SEEDS.length; i++) { func[i] = new SimpleHash(DEFAULT_SIZE, SEEDS[i]); } } /** * 添加元素到位数组 */ public void add(Object value) { for (SimpleHash f : func) { bits.set(f.hash(value), true); } } /** * 判断指定元素是否存在于位数组 */ public boolean contains(Object value) { boolean ret = true; for (SimpleHash f : func) { ret = ret \u0026amp;\u0026amp; bits.get(f.hash(value)); } return ret; } /** * 静态内部类。用于 hash 操作! */ public static class SimpleHash { private int cap; private int seed; public SimpleHash(int cap, int seed) { this.cap = cap; this.seed = seed; } /** * 计算 hash 值 */ public int hash(Object value) { int h; return (value == null) ? 0 : Math.abs((cap - 1) \u0026amp; seed * ((h = value.hashCode()) ^ (h \u0026gt;\u0026gt;\u0026gt; 16))); } } } 测试:\nString value1 = \u0026#34;https://javaguide.cn/\u0026#34;; String value2 = \u0026#34;https://github.com/Snailclimb\u0026#34;; MyBloomFilter filter = new MyBloomFilter(); System.out.println(filter.contains(value1)); System.out.println(filter.contains(value2)); filter.add(value1); filter.add(value2); System.out.println(filter.contains(value1)); System.out.println(filter.contains(value2)); Output:\nfalse false true true 测试:\nInteger value1 = 13423; Integer value2 = 22131; MyBloomFilter filter = new MyBloomFilter(); System.out.println(filter.contains(value1)); System.out.println(filter.contains(value2)); filter.add(value1); filter.add(value2); System.out.println(filter.contains(value1)); System.out.println(filter.contains(value2)); Output:\nfalse false true true 利用 Google 开源的 Guava 中自带的布隆过滤器 # 自己实现的目的主要是为了让自己搞懂布隆过滤器的原理,Guava 中布隆过滤器的实现算是比较权威的,所以实际项目中我们不需要手动实现一个布隆过滤器。\n首先我们需要在项目中引入 Guava 的依赖:\n\u0026lt;dependency\u0026gt; \u0026lt;groupId\u0026gt;com.google.guava\u0026lt;/groupId\u0026gt; \u0026lt;artifactId\u0026gt;guava\u0026lt;/artifactId\u0026gt; \u0026lt;version\u0026gt;28.0-jre\u0026lt;/version\u0026gt; \u0026lt;/dependency\u0026gt; 实际使用如下:\n我们创建了一个最多存放 最多 1500 个整数的布隆过滤器,并且我们可以容忍误判的概率为百分之(0.01)\n// 创建布隆过滤器对象 BloomFilter\u0026lt;Integer\u0026gt; filter = BloomFilter.create( Funnels.integerFunnel(), 1500, 0.01); // 判断指定元素是否存在 System.out.println(filter.mightContain(1)); System.out.println(filter.mightContain(2)); // 将元素添加进布隆过滤器 filter.put(1); filter.put(2); System.out.println(filter.mightContain(1)); System.out.println(filter.mightContain(2)); 在我们的示例中,当 mightContain() 方法返回 true 时,我们可以 99%确定该元素在过滤器中,当过滤器返回 false 时,我们可以 100%确定该元素不存在于过滤器中。\nGuava 提供的布隆过滤器的实现还是很不错的(想要详细了解的可以看一下它的源码实现),但是它有一个重大的缺陷就是只能单机使用(另外,容量扩展也不容易),而现在互联网一般都是分布式的场景。为了解决这个问题,我们就需要用到 Redis 中的布隆过滤器了。\nRedis 中的布隆过滤器 # 介绍 # Redis v4.0 之后有了 Module(模块/插件) 功能,Redis Modules 让 Redis 可以使用外部模块扩展其功能 。布隆过滤器就是其中的 Module。详情可以查看 Redis 官方对 Redis Modules 的介绍: https://redis.io/modules\n另外,官网推荐了一个 RedisBloom 作为 Redis 布隆过滤器的 Module,地址: https://github.com/RedisBloom/RedisBloom 其他还有:\nredis-lua-scaling-bloom-filter(lua 脚本实现): https://github.com/erikdubbelboer/redis-lua-scaling-bloom-filter pyreBloom(Python 中的快速 Redis 布隆过滤器): https://github.com/seomoz/pyreBloom …… RedisBloom 提供了多种语言的客户端支持,包括:Python、Java、JavaScript 和 PHP。\n使用 Docker 安装 # 如果我们需要体验 Redis 中的布隆过滤器非常简单,通过 Docker 就可以了!我们直接在 Google 搜索 docker redis bloomfilter 然后在排除广告的第一条搜素结果就找到了我们想要的答案(这是我平常解决问题的一种方式,分享一下),具体地址: https://hub.docker.com/r/redislabs/rebloom/ (介绍的很详细 )。\n具体操作如下:\n➜ ~ docker run -p 6379:6379 --name redis-redisbloom redislabs/rebloom:latest ➜ ~ docker exec -it redis-redisbloom bash root@21396d02c252:/data# redis-cli 127.0.0.1:6379\u0026gt; 注意:当前 rebloom 镜像已经被废弃,官方推荐使用 redis-stack\n常用命令一览 # 注意:key : 布隆过滤器的名称,item : 添加的元素。\nBF.ADD:将元素添加到布隆过滤器中,如果该过滤器尚不存在,则创建该过滤器。格式:BF.ADD {key} {item}。 BF.MADD : 将一个或多个元素添加到“布隆过滤器”中,并创建一个尚不存在的过滤器。该命令的操作方式BF.ADD与之相同,只不过它允许多个输入并返回多个值。格式:BF.MADD {key} {item} [item ...] 。 BF.EXISTS : 确定元素是否在布隆过滤器中存在。格式:BF.EXISTS {key} {item}。 BF.MEXISTS:确定一个或者多个元素是否在布隆过滤器中存在格式:BF.MEXISTS {key} {item} [item ...]。 另外, BF.RESERVE 命令需要单独介绍一下:\n这个命令的格式如下:\nBF.RESERVE {key} {error_rate} {capacity} [EXPANSION expansion] 。\n下面简单介绍一下每个参数的具体含义:\nkey:布隆过滤器的名称 error_rate : 期望的误报率。该值必须介于 0 到 1 之间。例如,对于期望的误报率 0.1%(1000 中为 1),error_rate 应该设置为 0.001。该数字越接近零,则每个项目的内存消耗越大,并且每个操作的 CPU 使用率越高。 capacity: 过滤器的容量。当实际存储的元素个数超过这个值之后,性能将开始下降。实际的降级将取决于超出限制的程度。随着过滤器元素数量呈指数增长,性能将线性下降。 可选参数:\nexpansion:如果创建了一个新的子过滤器,则其大小将是当前过滤器的大小乘以expansion。默认扩展值为 2。这意味着每个后续子过滤器将是前一个子过滤器的两倍。 实际使用 # 127.0.0.1:6379\u0026gt; BF.ADD myFilter java (integer) 1 127.0.0.1:6379\u0026gt; BF.ADD myFilter javaguide (integer) 1 127.0.0.1:6379\u0026gt; BF.EXISTS myFilter java (integer) 1 127.0.0.1:6379\u0026gt; BF.EXISTS myFilter javaguide (integer) 1 127.0.0.1:6379\u0026gt; BF.EXISTS myFilter github (integer) 0 "},{"id":594,"href":"/zh/docs/technology/Interview/cs-basics/operating-system/operating-system-basic-questions-01/","title":"操作系统常见面试题总结(上)","section":"Operating System","content":"很多读者抱怨计算操作系统的知识点比较繁杂,自己也没有多少耐心去看,但是面试的时候又经常会遇到。所以,我带着我整理好的操作系统的常见问题来啦!这篇文章总结了一些我觉得比较重要的操作系统相关的问题比如 用户态和内核态、系统调用、进程和线程、死锁、内存管理、虚拟内存、文件系统等等。\n这篇文章只是对一些操作系统比较重要概念的一个概览,深入学习的话,建议大家还是老老实实地去看书。另外, 这篇文章的很多内容参考了《现代操作系统》第三版这本书,非常感谢。\n开始本文的内容之前,我们先聊聊为什么要学习操作系统。\n从对个人能力方面提升来说:操作系统中的很多思想、很多经典的算法,你都可以在我们日常开发使用的各种工具或者框架中找到它们的影子。比如说我们开发的系统使用的缓存(比如 Redis)和操作系统的高速缓存就很像。CPU 中的高速缓存有很多种,不过大部分都是为了解决 CPU 处理速度和内存处理速度不对等的问题。我们还可以把内存看作外存的高速缓存,程序运行的时候我们把外存的数据复制到内存,由于内存的处理速度远远高于外存,这样提高了处理速度。同样地,我们使用的 Redis 缓存就是为了解决程序处理速度和访问常规关系型数据库速度不对等的问题。高速缓存一般会按照局部性原理(2-8 原则)根据相应的淘汰算法保证缓存中的数据是经常会被访问的。我们平常使用的 Redis 缓存很多时候也会按照 2-8 原则去做,很多淘汰算法都和操作系统中的类似。既说了 2-8 原则,那就不得不提命中率了,这是所有缓存概念都通用的。简单来说也就是你要访问的数据有多少能直接在缓存中直接找到。命中率高的话,一般表明你的缓存设计比较合理,系统处理速度也相对较快。 从面试角度来说:尤其是校招,对于操作系统方面知识的考察是非常非常多的。 简单来说,学习操作系统能够提高自己思考的深度以及对技术的理解力,并且,操作系统方面的知识也是面试必备。\n操作系统基础 # 什么是操作系统? # 通过以下四点可以概括操作系统到底是什么:\n操作系统(Operating System,简称 OS)是管理计算机硬件与软件资源的程序,是计算机的基石。 操作系统本质上是一个运行在计算机上的软件程序 ,主要用于管理计算机硬件和软件资源。 举例:运行在你电脑上的所有应用程序都通过操作系统来调用系统内存以及磁盘等等硬件。 操作系统存在屏蔽了硬件层的复杂性。 操作系统就像是硬件使用的负责人,统筹着各种相关事项。 操作系统的内核(Kernel)是操作系统的核心部分,它负责系统的内存管理,硬件设备的管理,文件系统的管理以及应用程序的管理。 内核是连接应用程序和硬件的桥梁,决定着系统的性能和稳定性。 很多人容易把操作系统的内核(Kernel)和中央处理器(CPU,Central Processing Unit)弄混。你可以简单从下面两点来区别:\n操作系统的内核(Kernel)属于操作系统层面,而 CPU 属于硬件。 CPU 主要提供运算,处理各种指令的能力。内核(Kernel)主要负责系统管理比如内存管理,它屏蔽了对硬件的操作。 下图清晰说明了应用程序、内核、CPU 这三者的关系。\n操作系统主要有哪些功能? # 从资源管理的角度来看,操作系统有 6 大功能:\n进程和线程的管理:进程的创建、撤销、阻塞、唤醒,进程间的通信等。 存储管理:内存的分配和管理、外存(磁盘等)的分配和管理等。 文件管理:文件的读、写、创建及删除等。 设备管理:完成设备(输入输出设备和外部存储设备等)的请求或释放,以及设备启动等功能。 网络管理:操作系统负责管理计算机网络的使用。网络是计算机系统中连接不同计算机的方式,操作系统需要管理计算机网络的配置、连接、通信和安全等,以提供高效可靠的网络服务。 安全管理:用户的身份认证、访问控制、文件加密等,以防止非法用户对系统资源的访问和操作。 常见的操作系统有哪些? # Windows # 目前最流行的个人桌面操作系统 ,不做多的介绍,大家都清楚。界面简单易操作,软件生态非常好。\n玩玩电脑游戏还是必须要有 Windows 的,所以我现在是一台 Windows 用于玩游戏,一台 Mac 用于平时日常开发和学习使用。\nUnix # 最早的多用户、多任务操作系统 。后面崛起的 Linux 在很多方面都参考了 Unix。\n目前这款操作系统已经逐渐逐渐退出操作系统的舞台。\nLinux # Linux 是一套免费使用、开源的类 Unix 操作系统。 Linux 存在着许多不同的发行版本,但它们都使用了 Linux 内核 。\n严格来讲,Linux 这个词本身只表示 Linux 内核,在 GNU/Linux 系统中,Linux 实际就是 Linux 内核,而该系统的其余部分主要是由 GNU 工程编写和提供的程序组成。单独的 Linux 内核并不能成为一个可以正常工作的操作系统。\n很多人更倾向使用 “GNU/Linux” 一词来表达人们通常所说的 “Linux”。\nMac OS # 苹果自家的操作系统,编程体验和 Linux 相当,但是界面、软件生态以及用户体验各方面都要比 Linux 操作系统更好。\n用户态和内核态 # 什么是用户态和内核态? # 根据进程访问资源的特点,我们可以把进程在系统上的运行分为两个级别:\n用户态(User Mode) : 用户态运行的进程可以直接读取用户程序的数据,拥有较低的权限。当应用程序需要执行某些需要特殊权限的操作,例如读写磁盘、网络通信等,就需要向操作系统发起系统调用请求,进入内核态。 内核态(Kernel Mode):内核态运行的进程几乎可以访问计算机的任何资源包括系统的内存空间、设备、驱动程序等,不受限制,拥有非常高的权限。当操作系统接收到进程的系统调用请求时,就会从用户态切换到内核态,执行相应的系统调用,并将结果返回给进程,最后再从内核态切换回用户态。 内核态相比用户态拥有更高的特权级别,因此能够执行更底层、更敏感的操作。不过,由于进入内核态需要付出较高的开销(需要进行一系列的上下文切换和权限检查),应该尽量减少进入内核态的次数,以提高系统的性能和稳定性。\n为什么要有用户态和内核态?只有一个内核态不行么? # 在 CPU 的所有指令中,有一些指令是比较危险的比如内存分配、设置时钟、IO 处理等,如果所有的程序都能使用这些指令的话,会对系统的正常运行造成灾难性地影响。因此,我们需要限制这些危险指令只能内核态运行。这些只能由操作系统内核态执行的指令也被叫做 特权指令 。 如果计算机系统中只有一个内核态,那么所有程序或进程都必须共享系统资源,例如内存、CPU、硬盘等,这将导致系统资源的竞争和冲突,从而影响系统性能和效率。并且,这样也会让系统的安全性降低,毕竟所有程序或进程都具有相同的特权级别和访问权限。 因此,同时具有用户态和内核态主要是为了保证计算机系统的安全性、稳定性和性能。\n用户态和内核态是如何切换的? # 用户态切换到内核态的 3 种方式:\n系统调用(Trap):用户态进程 主动 要求切换到内核态的一种方式,主要是为了使用内核态才能做的事情比如读取磁盘资源。系统调用的机制其核心还是使用了操作系统为用户特别开放的一个中断来实现。 中断(Interrupt):当外围设备完成用户请求的操作后,会向 CPU 发出相应的中断信号,这时 CPU 会暂停执行下一条即将要执行的指令转而去执行与中断信号对应的处理程序,如果先前执行的指令是用户态下的程序,那么这个转换的过程自然也就发生了由用户态到内核态的切换。比如硬盘读写操作完成,系统会切换到硬盘读写的中断处理程序中执行后续操作等。 异常(Exception):当 CPU 在执行运行在用户态下的程序时,发生了某些事先不可知的异常,这时会触发由当前运行进程切换到处理此异常的内核相关程序中,也就转到了内核态,比如缺页异常。 在系统的处理上,中断和异常类似,都是通过中断向量表来找到相应的处理程序进行处理。区别在于,中断来自处理器外部,不是由任何一条专门的指令造成,而异常是执行当前指令的结果。\n系统调用 # 什么是系统调用? # 我们运行的程序基本都是运行在用户态,如果我们调用操作系统提供的内核态级别的子功能咋办呢?那就需要系统调用了!\n也就是说在我们运行的用户程序中,凡是与系统态级别的资源有关的操作(如文件管理、进程控制、内存管理等),都必须通过系统调用方式向操作系统提出服务请求,并由操作系统代为完成。\n这些系统调用按功能大致可分为如下几类:\n设备管理:完成设备(输入输出设备和外部存储设备等)的请求或释放,以及设备启动等功能。 文件管理:完成文件的读、写、创建及删除等功能。 进程管理:进程的创建、撤销、阻塞、唤醒,进程间的通信等功能。 内存管理:完成内存的分配、回收以及获取作业占用内存区大小及地址等功能。 系统调用和普通库函数调用非常相似,只是系统调用由操作系统内核提供,运行于内核态,而普通的库函数调用由函数库或用户自己提供,运行于用户态。\n总结:系统调用是应用程序与操作系统之间进行交互的一种方式,通过系统调用,应用程序可以访问操作系统底层资源例如文件、设备、网络等。\n系统调用的过程了解吗? # 系统调用的过程可以简单分为以下几个步骤:\n用户态的程序发起系统调用,因为系统调用中涉及一些特权指令(只能由操作系统内核态执行的指令),用户态程序权限不足,因此会中断执行,也就是 Trap(Trap 是一种中断)。 发生中断后,当前 CPU 执行的程序会中断,跳转到中断处理程序。内核程序开始执行,也就是开始处理系统调用。 内核处理完成后,主动触发 Trap,这样会再次发生中断,切换回用户态工作。 进程和线程 # 什么是进程和线程? # 进程(Process) 是指计算机中正在运行的一个程序实例。举例:你打开的微信就是一个进程。 线程(Thread) 也被称为轻量级进程,更加轻量。多个线程可以在同一个进程中同时执行,并且共享进程的资源比如内存空间、文件句柄、网络连接等。举例:你打开的微信里就有一个线程专门用来拉取别人发你的最新的消息。 进程和线程的区别是什么? # 下图是 Java 内存区域,我们从 JVM 的角度来说一下线程和进程之间的关系吧!\n从上图可以看出:一个进程中可以有多个线程,多个线程共享进程的堆和方法区 (JDK1.8 之后的元空间)资源,但是每个线程有自己的程序计数器、虚拟机栈 和 本地方法栈。\n总结:\n线程是进程划分成的更小的运行单位,一个进程在其执行的过程中可以产生多个线程。 线程和进程最大的不同在于基本上各进程是独立的,而各线程则不一定,因为同一进程中的线程极有可能会相互影响。 线程执行开销小,但不利于资源的管理和保护;而进程正相反。 有了进程为什么还需要线程? # 进程切换是一个开销很大的操作,线程切换的成本较低。 线程更轻量,一个进程可以创建多个线程。 多个线程可以并发处理不同的任务,更有效地利用了多处理器和多核计算机。而进程只能在一个时间干一件事,如果在执行过程中遇到阻塞问题比如 IO 阻塞就会挂起直到结果返回。 同一进程内的线程共享内存和文件,因此它们之间相互通信无须调用内核。 为什么要使用多线程? # 先从总体上来说:\n从计算机底层来说: 线程可以比作是轻量级的进程,是程序执行的最小单位,线程间的切换和调度的成本远远小于进程。另外,多核 CPU 时代意味着多个线程可以同时运行,这减少了线程上下文切换的开销。 从当代互联网发展趋势来说: 现在的系统动不动就要求百万级甚至千万级的并发量,而多线程并发编程正是开发高并发系统的基础,利用好多线程机制可以大大提高系统整体的并发能力以及性能。 再深入到计算机底层来探讨:\n单核时代:在单核时代多线程主要是为了提高单进程利用 CPU 和 IO 系统的效率。 假设只运行了一个 Java 进程的情况,当我们请求 IO 的时候,如果 Java 进程中只有一个线程,此线程被 IO 阻塞则整个进程被阻塞。CPU 和 IO 设备只有一个在运行,那么可以简单地说系统整体效率只有 50%。当使用多线程的时候,一个线程被 IO 阻塞,其他线程还可以继续使用 CPU。从而提高了 Java 进程利用系统资源的整体效率。 多核时代: 多核时代多线程主要是为了提高进程利用多核 CPU 的能力。举个例子:假如我们要计算一个复杂的任务,我们只用一个线程的话,不论系统有几个 CPU 核心,都只会有一个 CPU 核心被利用到。而创建多个线程,这些线程可以被映射到底层多个 CPU 上执行,在任务中的多个线程没有资源竞争的情况下,任务执行的效率会有显著性的提高,约等于(单核时执行时间/CPU 核心数)。 线程间的同步的方式有哪些? # 线程同步是两个或多个共享关键资源的线程的并发执行。应该同步线程以避免关键的资源使用冲突。\n下面是几种常见的线程同步的方式:\n互斥锁(Mutex) :采用互斥对象机制,只有拥有互斥对象的线程才有访问公共资源的权限。因为互斥对象只有一个,所以可以保证公共资源不会被多个线程同时访问。比如 Java 中的 synchronized 关键词和各种 Lock 都是这种机制。 读写锁(Read-Write Lock) :允许多个线程同时读取共享资源,但只有一个线程可以对共享资源进行写操作。 信号量(Semaphore) :它允许同一时刻多个线程访问同一资源,但是需要控制同一时刻访问此资源的最大线程数量。 屏障(Barrier) :屏障是一种同步原语,用于等待多个线程到达某个点再一起继续执行。当一个线程到达屏障时,它会停止执行并等待其他线程到达屏障,直到所有线程都到达屏障后,它们才会一起继续执行。比如 Java 中的 CyclicBarrier 是这种机制。 事件(Event) :Wait/Notify:通过通知操作的方式来保持多线程同步,还可以方便的实现多线程优先级的比较操作。 PCB 是什么?包含哪些信息? # PCB(Process Control Block) 即进程控制块,是操作系统中用来管理和跟踪进程的数据结构,每个进程都对应着一个独立的 PCB。你可以将 PCB 视为进程的大脑。\n当操作系统创建一个新进程时,会为该进程分配一个唯一的进程 ID,并且为该进程创建一个对应的进程控制块。当进程执行时,PCB 中的信息会不断变化,操作系统会根据这些信息来管理和调度进程。\nPCB 主要包含下面几部分的内容:\n进程的描述信息,包括进程的名称、标识符等等; 进程的调度信息,包括进程阻塞原因、进程状态(就绪、运行、阻塞等)、进程优先级(标识进程的重要程度)等等; 进程对资源的需求情况,包括 CPU 时间、内存空间、I/O 设备等等。 进程打开的文件信息,包括文件描述符、文件类型、打开模式等等。 处理机的状态信息(由处理机的各种寄存器中的内容组成的),包括通用寄存器、指令计数器、程序状态字 PSW、用户栈指针。 …… 进程有哪几种状态? # 我们一般把进程大致分为 5 种状态,这一点和线程很像!\n创建状态(new):进程正在被创建,尚未到就绪状态。 就绪状态(ready):进程已处于准备运行状态,即进程获得了除了处理器之外的一切所需资源,一旦得到处理器资源(处理器分配的时间片)即可运行。 运行状态(running):进程正在处理器上运行(单核 CPU 下任意时刻只有一个进程处于运行状态)。 阻塞状态(waiting):又称为等待状态,进程正在等待某一事件而暂停运行如等待某资源为可用或等待 IO 操作完成。即使处理器空闲,该进程也不能运行。 结束状态(terminated):进程正在从系统中消失。可能是进程正常结束或其他原因中断退出运行。 进程间的通信方式有哪些? # 下面这部分总结参考了: 《进程间通信 IPC (InterProcess Communication)》 这篇文章,推荐阅读,总结的非常不错。\n管道/匿名管道(Pipes) :用于具有亲缘关系的父子进程间或者兄弟进程之间的通信。 有名管道(Named Pipes) : 匿名管道由于没有名字,只能用于亲缘关系的进程间通信。为了克服这个缺点,提出了有名管道。有名管道严格遵循 先进先出(First In First Out) 。有名管道以磁盘文件的方式存在,可以实现本机任意两个进程通信。 信号(Signal) :信号是一种比较复杂的通信方式,用于通知接收进程某个事件已经发生; 消息队列(Message Queuing) :消息队列是消息的链表,具有特定的格式,存放在内存中并由消息队列标识符标识。管道和消息队列的通信数据都是先进先出的原则。与管道(无名管道:只存在于内存中的文件;命名管道:存在于实际的磁盘介质或者文件系统)不同的是消息队列存放在内核中,只有在内核重启(即,操作系统重启)或者显式地删除一个消息队列时,该消息队列才会被真正的删除。消息队列可以实现消息的随机查询,消息不一定要以先进先出的次序读取,也可以按消息的类型读取.比 FIFO 更有优势。消息队列克服了信号承载信息量少,管道只能承载无格式字 节流以及缓冲区大小受限等缺点。 信号量(Semaphores) :信号量是一个计数器,用于多进程对共享数据的访问,信号量的意图在于进程间同步。这种通信方式主要用于解决与同步相关的问题并避免竞争条件。 共享内存(Shared memory) :使得多个进程可以访问同一块内存空间,不同进程可以及时看到对方进程中对共享内存中数据的更新。这种方式需要依靠某种同步操作,如互斥锁和信号量等。可以说这是最有用的进程间通信方式。 套接字(Sockets) : 此方法主要用于在客户端和服务器之间通过网络进行通信。套接字是支持 TCP/IP 的网络通信的基本操作单元,可以看做是不同主机之间的进程进行双向通信的端点,简单的说就是通信的两方的一种约定,用套接字中的相关函数来完成通信过程。 进程的调度算法有哪些? # 这是一个很重要的知识点!为了确定首先执行哪个进程以及最后执行哪个进程以实现最大 CPU 利用率,计算机科学家已经定义了一些算法,它们是:\n先到先服务调度算法(FCFS,First Come, First Served) : 从就绪队列中选择一个最先进入该队列的进程为之分配资源,使它立即执行并一直执行到完成或发生某事件而被阻塞放弃占用 CPU 时再重新调度。 短作业优先的调度算法(SJF,Shortest Job First) : 从就绪队列中选出一个估计运行时间最短的进程为之分配资源,使它立即执行并一直执行到完成或发生某事件而被阻塞放弃占用 CPU 时再重新调度。 时间片轮转调度算法(RR,Round-Robin) : 时间片轮转调度是一种最古老,最简单,最公平且使用最广的算法。每个进程被分配一个时间段,称作它的时间片,即该进程允许运行的时间。 多级反馈队列调度算法(MFQ,Multi-level Feedback Queue):前面介绍的几种进程调度的算法都有一定的局限性。如短进程优先的调度算法,仅照顾了短进程而忽略了长进程 。多级反馈队列调度算法既能使高优先级的作业得到响应又能使短作业(进程)迅速完成,因而它是目前被公认的一种较好的进程调度算法,UNIX 操作系统采取的便是这种调度算法。 优先级调度算法(Priority):为每个流程分配优先级,首先执行具有最高优先级的进程,依此类推。具有相同优先级的进程以 FCFS 方式执行。可以根据内存要求,时间要求或任何其他资源要求来确定优先级。 什么是僵尸进程和孤儿进程? # 在 Unix/Linux 系统中,子进程通常是通过 fork()系统调用创建的,该调用会创建一个新的进程,该进程是原有进程的一个副本。子进程和父进程的运行是相互独立的,它们各自拥有自己的 PCB,即使父进程结束了,子进程仍然可以继续运行。\n当一个进程调用 exit()系统调用结束自己的生命时,内核会释放该进程的所有资源,包括打开的文件、占用的内存等,但是该进程对应的 PCB 依然存在于系统中。这些信息只有在父进程调用 wait()或 waitpid()系统调用时才会被释放,以便让父进程得到子进程的状态信息。\n这样的设计可以让父进程在子进程结束时得到子进程的状态信息,并且可以防止出现“僵尸进程”(即子进程结束后 PCB 仍然存在但父进程无法得到状态信息的情况)。\n僵尸进程:子进程已经终止,但是其父进程仍在运行,且父进程没有调用 wait()或 waitpid()等系统调用来获取子进程的状态信息,释放子进程占用的资源,导致子进程的 PCB 依然存在于系统中,但无法被进一步使用。这种情况下,子进程被称为“僵尸进程”。避免僵尸进程的产生,父进程需要及时调用 wait()或 waitpid()系统调用来回收子进程。 孤儿进程:一个进程的父进程已经终止或者不存在,但是该进程仍在运行。这种情况下,该进程就是孤儿进程。孤儿进程通常是由于父进程意外终止或未及时调用 wait()或 waitpid()等系统调用来回收子进程导致的。为了避免孤儿进程占用系统资源,操作系统会将孤儿进程的父进程设置为 init 进程(进程号为 1),由 init 进程来回收孤儿进程的资源。 如何查看是否有僵尸进程? # Linux 下可以使用 Top 命令查找,zombie 值表示僵尸进程的数量,为 0 则代表没有僵尸进程。\n下面这个命令可以定位僵尸进程以及该僵尸进程的父进程:\nps -A -ostat,ppid,pid,cmd |grep -e \u0026#39;^[Zz]\u0026#39; 死锁 # 什么是死锁? # 死锁(Deadlock)描述的是这样一种情况:多个进程/线程同时被阻塞,它们中的一个或者全部都在等待某个资源被释放。由于进程/线程被无限期地阻塞,因此程序不可能正常终止。\n能列举一个操作系统发生死锁的例子吗? # 假设有两个进程 A 和 B,以及两个资源 X 和 Y,它们的分配情况如下:\n进程 占用资源 需求资源 A X Y B Y X 此时,进程 A 占用资源 X 并且请求资源 Y,而进程 B 已经占用了资源 Y 并请求资源 X。两个进程都在等待对方释放资源,无法继续执行,陷入了死锁状态。\n产生死锁的四个必要条件是什么? # 互斥:资源必须处于非共享模式,即一次只有一个进程可以使用。如果另一进程申请该资源,那么必须等待直到该资源被释放为止。 占有并等待:一个进程至少应该占有一个资源,并等待另一资源,而该资源被其他进程所占有。 非抢占:资源不能被抢占。只能在持有资源的进程完成任务后,该资源才会被释放。 循环等待:有一组等待进程 {P0, P1,..., Pn}, P0 等待的资源被 P1 占有,P1 等待的资源被 P2 占有,……,Pn-1 等待的资源被 Pn 占有,Pn 等待的资源被 P0 占有。 注意 ⚠️:这四个条件是产生死锁的 必要条件 ,也就是说只要系统发生死锁,这些条件必然成立,而只要上述条件之一不满足,就不会发生死锁。\n下面是百度百科对必要条件的解释:\n如果没有事物情况 A,则必然没有事物情况 B,也就是说如果有事物情况 B 则一定有事物情况 A,那么 A 就是 B 的必要条件。从逻辑学上看,B 能推导出 A,A 就是 B 的必要条件,等价于 B 是 A 的充分条件。\n能写一个模拟产生死锁的代码吗? # 下面通过一个实际的例子来模拟下图展示的线程死锁:\npublic class DeadLockDemo { private static Object resource1 = new Object();//资源 1 private static Object resource2 = new Object();//资源 2 public static void main(String[] args) { new Thread(() -\u0026gt; { synchronized (resource1) { System.out.println(Thread.currentThread() + \u0026#34;get resource1\u0026#34;); try { Thread.sleep(1000); } catch (InterruptedException e) { e.printStackTrace(); } System.out.println(Thread.currentThread() + \u0026#34;waiting get resource2\u0026#34;); synchronized (resource2) { System.out.println(Thread.currentThread() + \u0026#34;get resource2\u0026#34;); } } }, \u0026#34;线程 1\u0026#34;).start(); new Thread(() -\u0026gt; { synchronized (resource2) { System.out.println(Thread.currentThread() + \u0026#34;get resource2\u0026#34;); try { Thread.sleep(1000); } catch (InterruptedException e) { e.printStackTrace(); } System.out.println(Thread.currentThread() + \u0026#34;waiting get resource1\u0026#34;); synchronized (resource1) { System.out.println(Thread.currentThread() + \u0026#34;get resource1\u0026#34;); } } }, \u0026#34;线程 2\u0026#34;).start(); } } Output\nThread[线程 1,5,main]get resource1 Thread[线程 2,5,main]get resource2 Thread[线程 1,5,main]waiting get resource2 Thread[线程 2,5,main]waiting get resource1 线程 A 通过 synchronized (resource1) 获得 resource1 的监视器锁,然后通过Thread.sleep(1000);让线程 A 休眠 1s 为的是让线程 B 得到执行然后获取到 resource2 的监视器锁。线程 A 和线程 B 休眠结束了都开始企图请求获取对方的资源,然后这两个线程就会陷入互相等待的状态,这也就产生了死锁。\n解决死锁的方法 # 解决死锁的方法可以从多个角度去分析,一般的情况下,有预防,避免,检测和解除四种。\n预防 是采用某种策略,限制并发进程对资源的请求,从而使得死锁的必要条件在系统执行的任何时间上都不满足。\n避免则是系统在分配资源时,根据资源的使用情况提前做出预测,从而避免死锁的发生\n检测是指系统设有专门的机构,当死锁发生时,该机构能够检测死锁的发生,并精确地确定与死锁有关的进程和资源。\n解除 是与检测相配套的一种措施,用于将进程从死锁状态下解脱出来。\n死锁的预防 # 死锁四大必要条件上面都已经列出来了,很显然,只要破坏四个必要条件中的任何一个就能够预防死锁的发生。\n破坏第一个条件 互斥条件:使得资源是可以同时访问的,这是种简单的方法,磁盘就可以用这种方法管理,但是我们要知道,有很多资源 往往是不能同时访问的 ,所以这种做法在大多数的场合是行不通的。\n破坏第三个条件 非抢占:也就是说可以采用 剥夺式调度算法,但剥夺式调度方法目前一般仅适用于 主存资源 和 处理器资源 的分配,并不适用于所有的资源,会导致 资源利用率下降。\n所以一般比较实用的 预防死锁的方法,是通过考虑破坏第二个条件和第四个条件。\n1、静态分配策略\n静态分配策略可以破坏死锁产生的第二个条件(占有并等待)。所谓静态分配策略,就是指一个进程必须在执行前就申请到它所需要的全部资源,并且知道它所要的资源都得到满足之后才开始执行。进程要么占有所有的资源然后开始执行,要么不占有资源,不会出现占有一些资源等待一些资源的情况。\n静态分配策略逻辑简单,实现也很容易,但这种策略 严重地降低了资源利用率,因为在每个进程所占有的资源中,有些资源是在比较靠后的执行时间里采用的,甚至有些资源是在额外的情况下才使用的,这样就可能造成一个进程占有了一些 几乎不用的资源而使其他需要该资源的进程产生等待 的情况。\n2、层次分配策略\n层次分配策略破坏了产生死锁的第四个条件(循环等待)。在层次分配策略下,所有的资源被分成了多个层次,一个进程得到某一次的一个资源后,它只能再申请较高一层的资源;当一个进程要释放某层的一个资源时,必须先释放所占用的较高层的资源,按这种策略,是不可能出现循环等待链的,因为那样的话,就出现了已经申请了较高层的资源,反而去申请了较低层的资源,不符合层次分配策略,证明略。\n死锁的避免 # 上面提到的 破坏 死锁产生的四个必要条件之一就可以成功 预防系统发生死锁 ,但是会导致 低效的进程运行 和 资源使用率 。而死锁的避免相反,它的角度是允许系统中同时存在四个必要条件 ,只要掌握并发进程中与每个进程有关的资源动态申请情况,做出 明智和合理的选择 ,仍然可以避免死锁,因为四大条件仅仅是产生死锁的必要条件。\n我们将系统的状态分为 安全状态 和 不安全状态 ,每当在为申请者分配资源前先测试系统状态,若把系统资源分配给申请者会产生死锁,则拒绝分配,否则接受申请,并为它分配资源。\n如果操作系统能够保证所有的进程在有限的时间内得到需要的全部资源,则称系统处于安全状态,否则说系统是不安全的。很显然,系统处于安全状态则不会发生死锁,系统若处于不安全状态则可能发生死锁。\n那么如何保证系统保持在安全状态呢?通过算法,其中最具有代表性的 避免死锁算法 就是 Dijkstra 的银行家算法,银行家算法用一句话表达就是:当一个进程申请使用资源的时候,银行家算法 通过先 试探 分配给该进程资源,然后通过 安全性算法 判断分配后系统是否处于安全状态,若不安全则试探分配作废,让该进程继续等待,若能够进入到安全的状态,则就 真的分配资源给该进程。\n银行家算法详情可见: 《一句话+一张图说清楚——银行家算法》 。\n操作系统教程书中讲述的银行家算法也比较清晰,可以一看.\n死锁的避免(银行家算法)改善了 资源使用率低的问题 ,但是它要不断地检测每个进程对各类资源的占用和申请情况,以及做 安全性检查 ,需要花费较多的时间。\n死锁的检测 # 对资源的分配加以限制可以 预防和避免 死锁的发生,但是都不利于各进程对系统资源的充分共享。解决死锁问题的另一条途径是 死锁检测和解除 (这里突然联想到了乐观锁和悲观锁,感觉死锁的检测和解除就像是 乐观锁 ,分配资源时不去提前管会不会发生死锁了,等到真的死锁出现了再来解决嘛,而 死锁的预防和避免 更像是悲观锁,总是觉得死锁会出现,所以在分配资源的时候就很谨慎)。\n这种方法对资源的分配不加以任何限制,也不采取死锁避免措施,但系统 定时地运行一个 “死锁检测” 的程序,判断系统内是否出现死锁,如果检测到系统发生了死锁,再采取措施去解除它。\n进程-资源分配图 # 操作系统中的每一刻时刻的系统状态都可以用进程-资源分配图来表示,进程-资源分配图是描述进程和资源申请及分配关系的一种有向图,可用于检测系统是否处于死锁状态。\n用一个方框表示每一个资源类,方框中的黑点表示该资源类中的各个资源,用一个圆圈表示每一个进程,用 有向边 来表示进程申请资源和资源被分配的情况。\n图中 2-21 是进程-资源分配图的一个例子,其中共有三个资源类,每个进程的资源占有和申请情况已清楚地表示在图中。在这个例子中,由于存在 占有和等待资源的环路 ,导致一组进程永远处于等待资源的状态,发生了 死锁。\n进程-资源分配图中存在环路并不一定是发生了死锁。因为循环等待资源仅仅是死锁发生的必要条件,而不是充分条件。图 2-22 便是一个有环路而无死锁的例子。虽然进程 P1 和进程 P3 分别占用了一个资源 R1 和一个资源 R2,并且因为等待另一个资源 R2 和另一个资源 R1 形成了环路,但进程 P2 和进程 P4 分别占有了一个资源 R1 和一个资源 R2,它们申请的资源得到了满足,在有限的时间里会归还资源,于是进程 P1 或 P3 都能获得另一个所需的资源,环路自动解除,系统也就不存在死锁状态了。\n死锁检测步骤 # 知道了死锁检测的原理,我们可以利用下列步骤编写一个 死锁检测 程序,检测系统是否产生了死锁。\n如果进程-资源分配图中无环路,则此时系统没有发生死锁 如果进程-资源分配图中有环路,且每个资源类仅有一个资源,则系统中已经发生了死锁。 如果进程-资源分配图中有环路,且涉及到的资源类有多个资源,此时系统未必会发生死锁。如果能在进程-资源分配图中找出一个 既不阻塞又非独立的进程 ,该进程能够在有限的时间内归还占有的资源,也就是把边给消除掉了,重复此过程,直到能在有限的时间内 消除所有的边 ,则不会发生死锁,否则会发生死锁。(消除边的过程类似于 拓扑排序) 死锁的解除 # 当死锁检测程序检测到存在死锁发生时,应设法让其解除,让系统从死锁状态中恢复过来,常用的解除死锁的方法有以下四种:\n立即结束所有进程的执行,重新启动操作系统:这种方法简单,但以前所在的工作全部作废,损失很大。 撤销涉及死锁的所有进程,解除死锁后继续运行:这种方法能彻底打破死锁的循环等待条件,但将付出很大代价,例如有些进程可能已经计算了很长时间,由于被撤销而使产生的部分结果也被消除了,再重新执行时还要再次进行计算。 逐个撤销涉及死锁的进程,回收其资源直至死锁解除。 抢占资源:从涉及死锁的一个或几个进程中抢占资源,把夺得的资源再分配给涉及死锁的进程直至死锁解除。 参考 # 《计算机操作系统—汤小丹》第四版 《深入理解计算机系统》 《重学操作系统》 操作系统为什么要分用户态和内核态: https://blog.csdn.net/chen134225/article/details/81783980 从根上理解用户态与内核态: https://juejin.cn/post/6923863670132850701 什么是僵尸进程与孤儿进程: https://blog.csdn.net/a745233700/article/details/120715371 "},{"id":595,"href":"/zh/docs/technology/Interview/cs-basics/operating-system/operating-system-basic-questions-02/","title":"操作系统常见面试题总结(下)","section":"Operating System","content":" 内存管理 # 内存管理主要做了什么? # 操作系统的内存管理非常重要,主要负责下面这些事情:\n内存的分配与回收:对进程所需的内存进行分配和释放,malloc 函数:申请内存,free 函数:释放内存。 地址转换:将程序中的虚拟地址转换成内存中的物理地址。 内存扩充:当系统没有足够的内存时,利用虚拟内存技术或自动覆盖技术,从逻辑上扩充内存。 内存映射:将一个文件直接映射到进程的进程空间中,这样可以通过内存指针用读写内存的办法直接存取文件内容,速度更快。 内存优化:通过调整内存分配策略和回收算法来优化内存使用效率。 内存安全:保证进程之间使用内存互不干扰,避免一些恶意程序通过修改内存来破坏系统的安全性。 …… 什么是内存碎片? # 内存碎片是由内存的申请和释放产生的,通常分为下面两种:\n内部内存碎片(Internal Memory Fragmentation,简称为内存碎片):已经分配给进程使用但未被使用的内存。导致内部内存碎片的主要原因是,当采用固定比例比如 2 的幂次方进行内存分配时,进程所分配的内存可能会比其实际所需要的大。举个例子,一个进程只需要 65 字节的内存,但为其分配了 128(2^7) 大小的内存,那 63 字节的内存就成为了内部内存碎片。 外部内存碎片(External Memory Fragmentation,简称为外部碎片):由于未分配的连续内存区域太小,以至于不能满足任意进程所需要的内存分配请求,这些小片段且不连续的内存空间被称为外部碎片。也就是说,外部内存碎片指的是那些并未分配给进程但又不能使用的内存。我们后面介绍的分段机制就会导致外部内存碎片。 内存碎片会导致内存利用率下降,如何减少内存碎片是内存管理要非常重视的一件事情。\n常见的内存管理方式有哪些? # 内存管理方式可以简单分为下面两种:\n连续内存管理:为一个用户程序分配一个连续的内存空间,内存利用率一般不高。 非连续内存管理:允许一个程序使用的内存分布在离散或者说不相邻的内存中,相对更加灵活一些。 连续内存管理 # 块式管理 是早期计算机操作系统的一种连续内存管理方式,存在严重的内存碎片问题。块式管理会将内存分为几个固定大小的块,每个块中只包含一个进程。如果程序运行需要内存的话,操作系统就分配给它一块,如果程序运行只需要很小的空间的话,分配的这块内存很大一部分几乎被浪费了。这些在每个块中未被利用的空间,我们称之为内部内存碎片。除了内部内存碎片之外,由于两个内存块之间可能还会有外部内存碎片,这些不连续的外部内存碎片由于太小了无法再进行分配。\n在 Linux 系统中,连续内存管理采用了 伙伴系统(Buddy System)算法 来实现,这是一种经典的连续内存分配算法,可以有效解决外部内存碎片的问题。伙伴系统的主要思想是将内存按 2 的幂次划分(每一块内存大小都是 2 的幂次比如 2^6=64 KB),并将相邻的内存块组合成一对伙伴(注意:必须是相邻的才是伙伴)。\n当进行内存分配时,伙伴系统会尝试找到大小最合适的内存块。如果找到的内存块过大,就将其一分为二,分成两个大小相等的伙伴块。如果还是大的话,就继续切分,直到到达合适的大小为止。\n假设两块相邻的内存块都被释放,系统会将这两个内存块合并,进而形成一个更大的内存块,以便后续的内存分配。这样就可以减少内存碎片的问题,提高内存利用率。\n虽然解决了外部内存碎片的问题,但伙伴系统仍然存在内存利用率不高的问题(内部内存碎片)。这主要是因为伙伴系统只能分配大小为 2n 的内存块,因此当需要分配的内存大小不是 2n 的整数倍时,会浪费一定的内存空间。举个例子:如果要分配 65 大小的内存快,依然需要分配 2^7=128 大小的内存块。\n对于内部内存碎片的问题,Linux 采用 SLAB 进行解决。由于这部分内容不是本篇文章的重点,这里就不详细介绍了。\n非连续内存管理 # 非连续内存管理存在下面 3 种方式:\n段式管理:以段(一段连续的物理内存)的形式管理/分配物理内存。应用程序的虚拟地址空间被分为大小不等的段,段是有实际意义的,每个段定义了一组逻辑信息,例如有主程序段 MAIN、子程序段 X、数据段 D 及栈段 S 等。 页式管理:把物理内存分为连续等长的物理页,应用程序的虚拟地址空间也被划分为连续等长的虚拟页,是现代操作系统广泛使用的一种内存管理方式。 段页式管理机制:结合了段式管理和页式管理的一种内存管理机制,把物理内存先分成若干段,每个段又继续分成若干大小相等的页。 虚拟内存 # 什么是虚拟内存?有什么用? # 虚拟内存(Virtual Memory) 是计算机系统内存管理非常重要的一个技术,本质上来说它只是逻辑存在的,是一个假想出来的内存空间,主要作用是作为进程访问主存(物理内存)的桥梁并简化内存管理。\n总结来说,虚拟内存主要提供了下面这些能力:\n隔离进程:物理内存通过虚拟地址空间访问,虚拟地址空间与进程一一对应。每个进程都认为自己拥有了整个物理内存,进程之间彼此隔离,一个进程中的代码无法更改正在由另一进程或操作系统使用的物理内存。 提升物理内存利用率:有了虚拟地址空间后,操作系统只需要将进程当前正在使用的部分数据或指令加载入物理内存。 简化内存管理:进程都有一个一致且私有的虚拟地址空间,程序员不用和真正的物理内存打交道,而是借助虚拟地址空间访问物理内存,从而简化了内存管理。 多个进程共享物理内存:进程在运行过程中,会加载许多操作系统的动态库。这些库对于每个进程而言都是公用的,它们在内存中实际只会加载一份,这部分称为共享内存。 提高内存使用安全性:控制进程对物理内存的访问,隔离不同进程的访问权限,提高系统的安全性。 提供更大的可使用内存空间:可以让程序拥有超过系统物理内存大小的可用内存空间。这是因为当物理内存不够用时,可以利用磁盘充当,将物理内存页(通常大小为 4 KB)保存到磁盘文件(会影响读写速度),数据或代码页会根据需要在物理内存与磁盘之间移动。 没有虚拟内存有什么问题? # 如果没有虚拟内存的话,程序直接访问和操作的都是物理内存,看似少了一层中介,但多了很多问题。\n具体有什么问题呢? 这里举几个例子说明(参考虚拟内存提供的能力回答这个问题):\n用户程序可以访问任意物理内存,可能会不小心操作到系统运行必需的内存,进而造成操作系统崩溃,严重影响系统的安全。 同时运行多个程序容易崩溃。比如你想同时运行一个微信和一个 QQ 音乐,微信在运行的时候给内存地址 1xxx 赋值后,QQ 音乐也同样给内存地址 1xxx 赋值,那么 QQ 音乐对内存的赋值就会覆盖微信之前所赋的值,这就可能会造成微信这个程序会崩溃。 程序运行过程中使用的所有数据或指令都要载入物理内存,根据局部性原理,其中很大一部分可能都不会用到,白白占用了宝贵的物理内存资源。 …… 什么是虚拟地址和物理地址? # 物理地址(Physical Address) 是真正的物理内存中地址,更具体点来说是内存地址寄存器中的地址。程序中访问的内存地址不是物理地址,而是 虚拟地址(Virtual Address) 。\n也就是说,我们编程开发的时候实际就是在和虚拟地址打交道。比如在 C 语言中,指针里面存储的数值就可以理解成为内存里的一个地址,这个地址也就是我们说的虚拟地址。\n操作系统一般通过 CPU 芯片中的一个重要组件 MMU(Memory Management Unit,内存管理单元) 将虚拟地址转换为物理地址,这个过程被称为 地址翻译/地址转换(Address Translation) 。\n通过 MMU 将虚拟地址转换为物理地址后,再通过总线传到物理内存设备,进而完成相应的物理内存读写请求。\nMMU 将虚拟地址翻译为物理地址的主要机制有两种: 分段机制 和 分页机制 。\n什么是虚拟地址空间和物理地址空间? # 虚拟地址空间是虚拟地址的集合,是虚拟内存的范围。每一个进程都有一个一致且私有的虚拟地址空间。 物理地址空间是物理地址的集合,是物理内存的范围。 虚拟地址与物理内存地址是如何映射的? # MMU 将虚拟地址翻译为物理地址的主要机制有 3 种:\n分段机制 分页机制 段页机制 其中,现代操作系统广泛采用分页机制,需要重点关注!\n分段机制 # 分段机制(Segmentation) 以段(一段 连续 的物理内存)的形式管理/分配物理内存。应用程序的虚拟地址空间被分为大小不等的段,段是有实际意义的,每个段定义了一组逻辑信息,例如有主程序段 MAIN、子程序段 X、数据段 D 及栈段 S 等。\n段表有什么用?地址翻译过程是怎样的? # 分段管理通过 段表(Segment Table) 映射虚拟地址和物理地址。\n分段机制下的虚拟地址由两部分组成:\n段号:标识着该虚拟地址属于整个虚拟地址空间中的哪一个段。 段内偏移量:相对于该段起始地址的偏移量。 具体的地址翻译过程如下:\nMMU 首先解析得到虚拟地址中的段号; 通过段号去该应用程序的段表中取出对应的段信息(找到对应的段表项); 从段信息中取出该段的起始地址(物理地址)加上虚拟地址中的段内偏移量得到最终的物理地址。 段表中还存有诸如段长(可用于检查虚拟地址是否超出合法范围)、段类型(该段的类型,例如代码段、数据段等)等信息。\n通过段号一定要找到对应的段表项吗?得到最终的物理地址后对应的物理内存一定存在吗?\n不一定。段表项可能并不存在:\n段表项被删除:软件错误、软件恶意行为等情况可能会导致段表项被删除。 段表项还未创建:如果系统内存不足或者无法分配到连续的物理内存块就会导致段表项无法被创建。 分段机制为什么会导致内存外部碎片? # 分段机制容易出现外部内存碎片,即在段与段之间留下碎片空间(不足以映射给虚拟地址空间中的段)。从而造成物理内存资源利用率的降低。\n举个例子:假设可用物理内存为 5G 的系统使用分段机制分配内存。现在有 4 个进程,每个进程的内存占用情况如下:\n进程 1:0~1G(第 1 段) 进程 2:1~3G(第 2 段) 进程 3:3~4.5G(第 3 段) 进程 4:4.5~5G(第 4 段) 此时,我们关闭了进程 1 和进程 4,则第 1 段和第 4 段的内存会被释放,空闲物理内存还有 1.5G。由于这 1.5G 物理内存并不是连续的,导致没办法将空闲的物理内存分配给一个需要 1.5G 物理内存的进程。\n分页机制 # 分页机制(Paging) 把主存(物理内存)分为连续等长的物理页,应用程序的虚拟地址空间划也被分为连续等长的虚拟页。现代操作系统广泛采用分页机制。\n注意:这里的页是连续等长的,不同于分段机制下不同长度的段。\n在分页机制下,应用程序虚拟地址空间中的任意虚拟页可以被映射到物理内存中的任意物理页上,因此可以实现物理内存资源的离散分配。分页机制按照固定页大小分配物理内存,使得物理内存资源易于管理,可有效避免分段机制中外部内存碎片的问题。\n页表有什么用?地址翻译过程是怎样的? # 分页管理通过 页表(Page Table) 映射虚拟地址和物理地址。我这里画了一张基于单级页表进行地址翻译的示意图。\n在分页机制下,每个进程都会有一个对应的页表。\n分页机制下的虚拟地址由两部分组成:\n页号:通过虚拟页号可以从页表中取出对应的物理页号; 页内偏移量:物理页起始地址+页内偏移量=物理内存地址。 具体的地址翻译过程如下:\nMMU 首先解析得到虚拟地址中的虚拟页号; 通过虚拟页号去该应用程序的页表中取出对应的物理页号(找到对应的页表项); 用该物理页号对应的物理页起始地址(物理地址)加上虚拟地址中的页内偏移量得到最终的物理地址。 页表中还存有诸如访问标志(标识该页面有没有被访问过)、脏数据标识位等信息。\n通过虚拟页号一定要找到对应的物理页号吗?找到了物理页号得到最终的物理地址后对应的物理页一定存在吗?\n不一定!可能会存在 页缺失 。也就是说,物理内存中没有对应的物理页或者物理内存中有对应的物理页但虚拟页还未和物理页建立映射(对应的页表项不存在)。关于页缺失的内容,后面会详细介绍到。\n单级页表有什么问题?为什么需要多级页表? # 以 32 位的环境为例,虚拟地址空间范围共有 232(4G)。假设 一个页的大小是 212(4KB),那页表项共有 4G / 4K = 2^20 个。每个页表项为一个地址,占用 4 字节,2^20 * 2^2 / 1024 * 1024= 4MB。也就是说一个程序啥都不干,页表大小就得占用 4M。\n系统运行的应用程序多起来的话,页表的开销还是非常大的。而且,绝大部分应用程序可能只能用到页表中的几项,其他的白白浪费了。\n为了解决这个问题,操作系统引入了 多级页表 ,多级页表对应多个页表,每个页表与前一个页表相关联。32 位系统一般为二级页表,64 位系统一般为四级页表。\n这里以二级页表为例进行介绍:二级列表分为一级页表和二级页表。一级页表共有 1024 个页表项,一级页表又关联二级页表,二级页表同样共有 1024 个页表项。二级页表中的一级页表项是一对多的关系,二级页表按需加载(只会用到很少一部分二级页表),进而节省空间占用。\n假设只需要 2 个二级页表,那两级页表的内存占用情况为: 4KB(一级页表占用) + 4KB * 2(二级页表占用) = 12 KB。\n多级页表属于时间换空间的典型场景,利用增加页表查询的次数减少页表占用的空间。\nTLB 有什么用?使用 TLB 之后的地址翻译流程是怎样的? # 为了提高虚拟地址到物理地址的转换速度,操作系统在 页表方案 基础之上引入了 转址旁路缓存(Translation Lookaside Buffer,TLB,也被称为快表) 。\n在主流的 AArch64 和 x86-64 体系结构下,TLB 属于 (Memory Management Unit,内存管理单元) 内部的单元,本质上就是一块高速缓存(Cache),缓存了虚拟页号到物理页号的映射关系,你可以将其简单看作是存储着键(虚拟页号)值(物理页号)对的哈希表。\n使用 TLB 之后的地址翻译流程是这样的:\n用虚拟地址中的虚拟页号作为 key 去 TLB 中查询; 如果能查到对应的物理页的话,就不用再查询页表了,这种情况称为 TLB 命中(TLB hit)。 如果不能查到对应的物理页的话,还是需要去查询主存中的页表,同时将页表中的该映射表项添加到 TLB 中,这种情况称为 TLB 未命中(TLB miss)。 当 TLB 填满后,又要登记新页时,就按照一定的淘汰策略淘汰掉快表中的一个页。 由于页表也在主存中,因此在没有 TLB 之前,每次读写内存数据时 CPU 要访问两次主存。有了 TLB 之后,对于存在于 TLB 中的页表数据只需要访问一次主存即可。\nTLB 的设计思想非常简单,但命中率往往非常高,效果很好。这就是因为被频繁访问的页就是其中的很小一部分。\n看完了之后你会发现快表和我们平时经常在开发系统中使用的缓存(比如 Redis)很像,的确是这样的,操作系统中的很多思想、很多经典的算法,你都可以在我们日常开发使用的各种工具或者框架中找到它们的影子。\n换页机制有什么用? # 换页机制的思想是当物理内存不够用的时候,操作系统选择将一些物理页的内容放到磁盘上去,等要用到的时候再将它们读取到物理内存中。也就是说,换页机制利用磁盘这种较低廉的存储设备扩展的物理内存。\n这也就解释了一个日常使用电脑常见的问题:为什么操作系统中所有进程运行所需的物理内存即使比真实的物理内存要大一些,这些进程也是可以正常运行的,只是运行速度会变慢。\n这同样是一种时间换空间的策略,你用 CPU 的计算时间,页的调入调出花费的时间,换来了一个虚拟的更大的物理内存空间来支持程序的运行。\n什么是页缺失? # 根据维基百科:\n页缺失(Page Fault,又名硬错误、硬中断、分页错误、寻页缺失、缺页中断、页故障等)指的是当软件试图访问已映射在虚拟地址空间中,但是目前并未被加载在物理内存中的一个分页时,由 MMU 所发出的中断。\n常见的页缺失有下面这两种:\n硬性页缺失(Hard Page Fault):物理内存中没有对应的物理页。于是,Page Fault Handler 会指示 CPU 从已经打开的磁盘文件中读取相应的内容到物理内存,而后交由 MMU 建立相应的虚拟页和物理页的映射关系。 软性页缺失(Soft Page Fault):物理内存中有对应的物理页,但虚拟页还未和物理页建立映射。于是,Page Fault Handler 会指示 MMU 建立相应的虚拟页和物理页的映射关系。 发生上面这两种缺页错误的时候,应用程序访问的是有效的物理内存,只是出现了物理页缺失或者虚拟页和物理页的映射关系未建立的问题。如果应用程序访问的是无效的物理内存的话,还会出现 无效缺页错误(Invalid Page Fault) 。\n常见的页面置换算法有哪些? # 当发生硬性页缺失时,如果物理内存中没有空闲的物理页面可用的话。操作系统就必须将物理内存中的一个物理页淘汰出去,这样就可以腾出空间来加载新的页面了。\n用来选择淘汰哪一个物理页的规则叫做 页面置换算法 ,我们可以把页面置换算法看成是淘汰物物理页的规则。\n页缺失太频繁的发生会非常影响性能,一个好的页面置换算法应该是可以减少页缺失出现的次数。\n常见的页面置换算法有下面这 5 种(其他还有很多页面置换算法都是基于这些算法改进得来的):\n最佳页面置换算法(OPT,Optimal):优先选择淘汰的页面是以后永不使用的,或者是在最长时间内不再被访问的页面,这样可以保证获得最低的缺页率。但由于人们目前无法预知进程在内存下的若干页面中哪个是未来最长时间内不再被访问的,因而该算法无法实现,只是理论最优的页面置换算法,可以作为衡量其他置换算法优劣的标准。 先进先出页面置换算法(FIFO,First In First Out) : 最简单的一种页面置换算法,总是淘汰最先进入内存的页面,即选择在内存中驻留时间最久的页面进行淘汰。该算法易于实现和理解,一般只需要通过一个 FIFO 队列即可满足需求。不过,它的性能并不是很好。 最近最久未使用页面置换算法(LRU ,Least Recently Used):LRU 算法赋予每个页面一个访问字段,用来记录一个页面自上次被访问以来所经历的时间 T,当须淘汰一个页面时,选择现有页面中其 T 值最大的,即最近最久未使用的页面予以淘汰。LRU 算法是根据各页之前的访问情况来实现,因此是易于实现的。OPT 算法是根据各页未来的访问情况来实现,因此是不可实现的。 最少使用页面置换算法(LFU,Least Frequently Used) : 和 LRU 算法比较像,不过该置换算法选择的是之前一段时间内使用最少的页面作为淘汰页。 时钟页面置换算法(Clock):可以认为是一种最近未使用算法,即逐出的页面都是最近没有使用的那个。 FIFO 页面置换算法性能为何不好?\n主要原因主要有二:\n经常访问或者需要长期存在的页面会被频繁调入调出:较早调入的页往往是经常被访问或者需要长期存在的页,这些页会被反复调入和调出。 存在 Belady 现象:被置换的页面并不是进程不会访问的,有时就会出现分配的页面数增多但缺页率反而提高的异常现象。出现该异常的原因是因为 FIFO 算法只考虑了页面进入内存的顺序,而没有考虑页面访问的频率和紧迫性。 哪一种页面置换算法实际用的比较多?\nLRU 算法是实际使用中应用的比较多,也被认为是最接近 OPT 的页面置换算法。\n不过,需要注意的是,实际应用中这些算法会被做一些改进,就比如 InnoDB Buffer Pool( InnoDB 缓冲池,MySQL 数据库中用于管理缓存页面的机制)就改进了传统的 LRU 算法,使用了一种称为\u0026quot;Adaptive LRU\u0026quot;的算法(同时结合了 LRU 和 LFU 算法的思想)。\n分页机制和分段机制有哪些共同点和区别? # 共同点:\n都是非连续内存管理的方式。 都采用了地址映射的方法,将虚拟地址映射到物理地址,以实现对内存的管理和保护。 区别:\n分页机制以页面为单位进行内存管理,而分段机制以段为单位进行内存管理。页的大小是固定的,由操作系统决定,通常为 2 的幂次方。而段的大小不固定,取决于我们当前运行的程序。 页是物理单位,即操作系统将物理内存划分成固定大小的页面,每个页面的大小通常是 2 的幂次方,例如 4KB、8KB 等等。而段则是逻辑单位,是为了满足程序对内存空间的逻辑需求而设计的,通常根据程序中数据和代码的逻辑结构来划分。 分段机制容易出现外部内存碎片,即在段与段之间留下碎片空间(不足以映射给虚拟地址空间中的段)。分页机制解决了外部内存碎片的问题,但仍然可能会出现内部内存碎片。 分页机制采用了页表来完成虚拟地址到物理地址的映射,页表通过一级页表和二级页表来实现多级映射;而分段机制则采用了段表来完成虚拟地址到物理地址的映射,每个段表项中记录了该段的起始地址和长度信息。 分页机制对程序没有任何要求,程序只需要按照虚拟地址进行访问即可;而分段机制需要程序员将程序分为多个段,并且显式地使用段寄存器来访问不同的段。 段页机制 # 结合了段式管理和页式管理的一种内存管理机制,把物理内存先分成若干段,每个段又继续分成若干大小相等的页。\n在段页式机制下,地址翻译的过程分为两个步骤:\n段式地址映射。 页式地址映射。 局部性原理 # 要想更好地理解虚拟内存技术,必须要知道计算机中著名的 局部性原理(Locality Principle)。另外,局部性原理既适用于程序结构,也适用于数据结构,是非常重要的一个概念。\n局部性原理是指在程序执行过程中,数据和指令的访问存在一定的空间和时间上的局部性特点。其中,时间局部性是指一个数据项或指令在一段时间内被反复使用的特点,空间局部性是指一个数据项或指令在一段时间内与其相邻的数据项或指令被反复使用的特点。\n在分页机制中,页表的作用是将虚拟地址转换为物理地址,从而完成内存访问。在这个过程中,局部性原理的作用体现在两个方面:\n时间局部性:由于程序中存在一定的循环或者重复操作,因此会反复访问同一个页或一些特定的页,这就体现了时间局部性的特点。为了利用时间局部性,分页机制中通常采用缓存机制来提高页面的命中率,即将最近访问过的一些页放入缓存中,如果下一次访问的页已经在缓存中,就不需要再次访问内存,而是直接从缓存中读取。 空间局部性:由于程序中数据和指令的访问通常是具有一定的空间连续性的,因此当访问某个页时,往往会顺带访问其相邻的一些页。为了利用空间局部性,分页机制中通常采用预取技术来预先将相邻的一些页读入内存缓存中,以便在未来访问时能够直接使用,从而提高访问速度。 总之,局部性原理是计算机体系结构设计的重要原则之一,也是许多优化算法的基础。在分页机制中,利用时间局部性和空间局部性,采用缓存和预取技术,可以提高页面的命中率,从而提高内存访问效率\n文件系统 # 文件系统主要做了什么? # 文件系统主要负责管理和组织计算机存储设备上的文件和目录,其功能包括以下几个方面:\n存储管理:将文件数据存储到物理存储介质中,并且管理空间分配,以确保每个文件都有足够的空间存储,并避免文件之间发生冲突。 文件管理:文件的创建、删除、移动、重命名、压缩、加密、共享等等。 目录管理:目录的创建、删除、移动、重命名等等。 文件访问控制:管理不同用户或进程对文件的访问权限,以确保用户只能访问其被授权访问的文件,以保证文件的安全性和保密性。 硬链接和软链接有什么区别? # 在 Linux/类 Unix 系统上,文件链接(File Link)是一种特殊的文件类型,可以在文件系统中指向另一个文件。常见的文件链接类型有两种:\n1、硬链接(Hard Link)\n在 Linux/类 Unix 文件系统中,每个文件和目录都有一个唯一的索引节点(inode)号,用来标识该文件或目录。硬链接通过 inode 节点号建立连接,硬链接和源文件的 inode 节点号相同,两者对文件系统来说是完全平等的(可以看作是互为硬链接,源头是同一份文件),删除其中任何一个对另外一个没有影响,可以通过给文件设置硬链接文件来防止重要文件被误删。 只有删除了源文件和所有对应的硬链接文件,该文件才会被真正删除。 硬链接具有一些限制,不能对目录以及不存在的文件创建硬链接,并且,硬链接也不能跨越文件系统。 ln 命令用于创建硬链接。 2、软链接(Symbolic Link 或 Symlink)\n软链接和源文件的 inode 节点号不同,而是指向一个文件路径。 源文件删除后,软链接依然存在,但是指向的是一个无效的文件路径。 软连接类似于 Windows 系统中的快捷方式。 不同于硬链接,可以对目录或者不存在的文件创建软链接,并且,软链接可以跨越文件系统。 ln -s 命令用于创建软链接。 硬链接为什么不能跨文件系统? # 我们之前提到过,硬链接是通过 inode 节点号建立连接的,而硬链接和源文件共享相同的 inode 节点号。\n然而,每个文件系统都有自己的独立 inode 表,且每个 inode 表只维护该文件系统内的 inode。如果在不同的文件系统之间创建硬链接,可能会导致 inode 节点号冲突的问题,即目标文件的 inode 节点号已经在该文件系统中被使用。\n提高文件系统性能的方式有哪些? # 优化硬件:使用高速硬件设备(如 SSD、NVMe)替代传统的机械硬盘,使用 RAID(Redundant Array of Inexpensive Disks)等技术提高磁盘性能。 选择合适的文件系统选型:不同的文件系统具有不同的特性,对于不同的应用场景选择合适的文件系统可以提高系统性能。 运用缓存:访问磁盘的效率比较低,可以运用缓存来减少磁盘的访问次数。不过,需要注意缓存命中率,缓存命中率过低的话,效果太差。 避免磁盘过度使用:注意磁盘的使用率,避免将磁盘用满,尽量留一些剩余空间,以免对文件系统的性能产生负面影响。 对磁盘进行合理的分区:合理的磁盘分区方案,能够使文件系统在不同的区域存储文件,从而减少文件碎片,提高文件读写性能。 常见的磁盘调度算法有哪些? # 磁盘调度算法是操作系统中对磁盘访问请求进行排序和调度的算法,其目的是提高磁盘的访问效率。\n一次磁盘读写操作的时间由磁盘寻道/寻找时间、延迟时间和传输时间决定。磁盘调度算法可以通过改变到达磁盘请求的处理顺序,减少磁盘寻道时间和延迟时间。\n常见的磁盘调度算法有下面这 6 种(其他还有很多磁盘调度算法都是基于这些算法改进得来的):\n先来先服务算法(First-Come First-Served,FCFS):按照请求到达磁盘调度器的顺序进行处理,先到达的请求的先被服务。FCFS 算法实现起来比较简单,不存在算法开销。不过,由于没有考虑磁头移动的路径和方向,平均寻道时间较长。同时,该算法容易出现饥饿问题,即一些后到的磁盘请求可能需要等待很长时间才能得到服务。 最短寻道时间优先算法(Shortest Seek Time First,SSTF):也被称为最佳服务优先(Shortest Service Time First,SSTF)算法,优先选择距离当前磁头位置最近的请求进行服务。SSTF 算法能够最小化磁头的寻道时间,但容易出现饥饿问题,即磁头附近的请求不断被服务,远离磁头的请求长时间得不到响应。实际应用中,需要优化一下该算法的实现,避免出现饥饿问题。 扫描算法(SCAN):也被称为电梯(Elevator)算法,基本思想和电梯非常类似。磁头沿着一个方向扫描磁盘,如果经过的磁道有请求就处理,直到到达磁盘的边界,然后改变移动方向,依此往复。SCAN 算法能够保证所有的请求得到服务,解决了饥饿问题。但是,如果磁头从一个方向刚扫描完,请求才到的话。这个请求就需要等到磁头从相反方向过来之后才能得到处理。 循环扫描算法(Circular Scan,C-SCAN):SCAN 算法的变体,只在磁盘的一侧进行扫描,并且只按照一个方向扫描,直到到达磁盘边界,然后回到磁盘起点,重新开始循环。 边扫描边观察算法(LOOK):SCAN 算法中磁头到了磁盘的边界才改变移动方向,这样可能会做很多无用功,因为磁头移动方向上可能已经没有请求需要处理了。LOOK 算法对 SCAN 算法进行了改进,如果磁头移动方向上已经没有别的请求,就可以立即改变磁头移动方向,依此往复。也就是边扫描边观察指定方向上还有无请求,因此叫 LOOK。 均衡循环扫描算法(C-LOOK):C-SCAN 只有到达磁盘边界时才能改变磁头移动方向,并且磁头返回时也需要返回到磁盘起点,这样可能会做很多无用功。C-LOOK 算法对 C-SCAN 算法进行了改进,如果磁头移动的方向上已经没有磁道访问请求了,就可以立即让磁头返回,并且磁头只需要返回到有磁道访问请求的位置即可。 参考 # 《计算机操作系统—汤小丹》第四版 《深入理解计算机系统》 《重学操作系统》 《现代操作系统原理与实现》 王道考研操作系统知识点整理: https://wizardforcel.gitbooks.io/wangdaokaoyan-os/content/13.html 内存管理之伙伴系统与 SLAB: https://blog.csdn.net/qq_44272681/article/details/124199068 为什么 Linux 需要虚拟内存: https://draveness.me/whys-the-design-os-virtual-memory/ 程序员的自我修养(七):内存缺页错误: https://liam.page/2017/09/01/page-fault/ 虚拟内存的那点事儿: https://juejin.cn/post/6844903507594575886 "},{"id":596,"href":"/zh/docs/technology/Interview/high-performance/sql-optimization/","title":"常见SQL优化手段总结(付费)","section":"High Performance","content":"常见 SQL 优化手段总结 相关的内容为我的 知识星球(点击链接即可查看详细介绍以及加入方法)专属内容,已经整理到了 《Java 面试指北》中。\n"},{"id":597,"href":"/zh/docs/technology/Interview/system-design/security/encryption-algorithms/","title":"常见加密算法总结","section":"Security","content":"加密算法是一种用数学方法对数据进行变换的技术,目的是保护数据的安全,防止被未经授权的人读取或修改。加密算法可以分为三大类:对称加密算法、非对称加密算法和哈希算法(也叫摘要算法)。\n日常开发中常见的需要用到加密算法的场景:\n保存在数据库中的密码需要加盐之后使用哈希算法(比如 BCrypt)进行加密。 保存在数据库中的银行卡号、身份号这类敏感数据需要使用对称加密算法(比如 AES)保存。 网络传输的敏感数据比如银行卡号、身份号需要用 HTTPS + 非对称加密算法(如 RSA)来保证传输数据的安全性。 …… ps: 严格上来说,哈希算法其实不属于加密算法,只是可以用到某些加密场景中(例如密码加密),两者可以看作是并列关系。加密算法通常指的是可以将明文转换为密文,并且能够通过某种方式(如密钥)再将密文还原为明文的算法。而哈希算法是一种单向过程,它将输入信息转换成一个固定长度的、看似随机的哈希值,但这个过程是不可逆的,也就是说,不能从哈希值还原出原始信息。\n哈希算法 # 哈希算法也叫散列函数或摘要算法,它的作用是对任意长度的数据生成一个固定长度的唯一标识,也叫哈希值、散列值或消息摘要(后文统称为哈希值)。\n哈希算法的是不可逆的,你无法通过哈希之后的值再得到原值。\n哈希值的作用是可以用来验证数据的完整性和一致性。\n举两个实际的例子:\n保存密码到数据库时使用哈希算法进行加密,可以通过比较用户输入密码的哈希值和数据库保存的哈希值是否一致,来判断密码是否正确。 我们下载一个文件时,可以通过比较文件的哈希值和官方提供的哈希值是否一致,来判断文件是否被篡改或损坏; 这种算法的特点是不可逆:\n不能从哈希值还原出原始数据。 原始数据的任何改变都会导致哈希值的巨大变化。 哈希算法可以简单分为两类:\n加密哈希算法:安全性较高的哈希算法,它可以提供一定的数据完整性保护和数据防篡改能力,能够抵御一定的攻击手段,安全性相对较高,但性能较差,适用于对安全性要求较高的场景。例如 SHA2、SHA3、SM3、RIPEMD-160、BLAKE2、SipHash 等等。 非加密哈希算法:安全性相对较低的哈希算法,易受到暴力破解、冲突攻击等攻击手段的影响,但性能较高,适用于对安全性没有要求的业务场景。例如 CRC32、MurMurHash3、SipHash 等等。 除了这两种之外,还有一些特殊的哈希算法,例如安全性更高的慢哈希算法。\n常见的哈希算法有:\nMD(Message Digest,消息摘要算法):MD2、MD4、MD5 等,已经不被推荐使用。 SHA(Secure Hash Algorithm,安全哈希算法):SHA-1 系列安全性低,SHA2,SHA3 系列安全性较高。 国密算法:例如 SM2、SM3、SM4,其中 SM2 为非对称加密算法,SM4 为对称加密算法,SM3 为哈希算法(安全性及效率和 SHA-256 相当,但更适合国内的应用环境)。 Bcrypt(密码哈希算法):基于 Blowfish 加密算法的密码哈希算法,专门为密码加密而设计,安全性高,属于慢哈希算法。 MAC(Message Authentication Code,消息认证码算法):HMAC 是一种基于哈希的 MAC,可以与任何安全的哈希算法结合使用,例如 SHA-256。 CRC:(Cyclic Redundancy Check,循环冗余校验):CRC32 是一种 CRC 算法,它的特点是生成 32 位的校验值,通常用于数据完整性校验、文件校验等场景。 SipHash:加密哈希算法,它的设计目的是在速度和安全性之间达到一个平衡,用于防御 哈希泛洪 DoS 攻击。Rust 默认使用 SipHash 作为哈希算法,从 Redis4.0 开始,哈希算法被替换为 SipHash。 MurMurHash:经典快速的非加密哈希算法,目前最新的版本是 MurMurHash3,可以生成 32 位或者 128 位哈希值; …… 哈希算法一般是不需要密钥的,但也存在部分特殊哈希算法需要密钥。例如,MAC 和 SipHash 就是一种基于密钥的哈希算法,它在哈希算法的基础上增加了一个密钥,使得只有知道密钥的人才能验证数据的完整性和来源。\nMD # MD 算法有多个版本,包括 MD2、MD4、MD5 等,其中 MD5 是最常用的版本,它可以生成一个 128 位(16 字节)的哈希值。从安全性上说:MD5 \u0026gt; MD4 \u0026gt; MD2。除了这些版本,还有一些基于 MD4 或 MD5 改进的算法,如 RIPEMD、HAVAL 等。\n即使是最安全 MD 算法 MD5 也存在被破解的风险,攻击者可以通过暴力破解或彩虹表攻击等方式,找到与原始数据相同的哈希值,从而破解数据。\n为了增加破解难度,通常可以选择加盐。盐(Salt)在密码学中,是指通过在密码任意固定位置插入特定的字符串,让哈希后的结果和使用原始密码的哈希结果不相符,这种过程称之为“加盐”。\n加盐之后就安全了吗?并不一定,这只是增加了破解难度,不代表无法破解。而且,MD5 算法本身就存在弱碰撞(Collision)问题,即多个不同的输入产生相同的 MD5 值。\n因此,MD 算法已经不被推荐使用,建议使用更安全的哈希算法比如 SHA-2、Bcrypt。\nJava 提供了对 MD 算法系列的支持,包括 MD2、MD5。\nMD5 代码示例(未加盐):\nString originalString = \u0026#34;Java学习 + 面试指南:javaguide.cn\u0026#34;; // 创建MD5摘要对象 MessageDigest messageDigest = MessageDigest.getInstance(\u0026#34;MD5\u0026#34;); messageDigest.update(originalString.getBytes(StandardCharsets.UTF_8)); // 计算哈希值 byte[] result = messageDigest.digest(); // 将哈希值转换为十六进制字符串 String hexString = new HexBinaryAdapter().marshal(result); System.out.println(\u0026#34;Original String: \u0026#34; + originalString); System.out.println(\u0026#34;MD5 Hash: \u0026#34; + hexString.toLowerCase()); 输出:\nOriginal String: Java学习 + 面试指南:javaguide.cn MD5 Hash: fb246796f5b1b60d4d0268c817c608fa SHA # SHA(Secure Hash Algorithm)系列算法是一组密码哈希算法,用于将任意长度的数据映射为固定长度的哈希值。SHA 系列算法由美国国家安全局(NSA)于 1993 年设计,目前共有 SHA-1、SHA-2、SHA-3 三种版本。\nSHA-1 算法将任意长度的数据映射为 160 位的哈希值。然而,SHA-1 算法存在一些严重的缺陷,比如安全性低,容易受到碰撞攻击和长度扩展攻击。因此,SHA-1 算法已经不再被推荐使用。 SHA-2 家族(如 SHA-256、SHA-384、SHA-512 等)和 SHA-3 系列是 SHA-1 算法的替代方案,它们都提供了更高的安全性和更长的哈希值长度。\nSHA-2 家族是在 SHA-1 算法的基础上改进而来的,它们采用了更复杂的运算过程和更多的轮次,使得攻击者更难以通过预计算或巧合找到碰撞。\n为了寻找一种更安全和更先进的密码哈希算法,美国国家标准与技术研究院(National Institute of Standards and Technology,简称 NIST)在 2007 年公开征集 SHA-3 的候选算法。NIST 一共收到了 64 个算法方案,经过多轮的评估和筛选,最终在 2012 年宣布 Keccak 算法胜出,成为 SHA-3 的标准算法(SHA-3 与 SHA-2 算法没有直接的关系)。 Keccak 算法具有与 MD 和 SHA-1/2 完全不同的设计思路,即海绵结构(Sponge Construction),使得传统攻击方法无法直接应用于 SHA-3 的攻击中(能够抵抗目前已知的所有攻击方式包括碰撞攻击、长度扩展攻击、差分攻击等)。\n由于 SHA-2 算法还没有出现重大的安全漏洞,而且在软件中的效率更高,所以大多数人还是倾向于使用 SHA-2 算法。\n相比 MD5 算法,SHA-2 算法之所以更强,主要有两个原因:\n哈希值长度更长:例如 SHA-256 算法的哈希值长度为 256 位,而 MD5 算法的哈希值长度为 128 位,这就提高了攻击者暴力破解或者彩虹表攻击的难度。 更强的碰撞抗性:SHA 算法采用了更复杂的运算过程和更多的轮次,使得攻击者更难以通过预计算或巧合找到碰撞。目前还没有找到任何两个不同的数据,它们的 SHA-256 哈希值相同。 当然,SHA-2 也不是绝对安全的,也有被暴力破解或者彩虹表攻击的风险,所以,在实际的应用中,加盐还是必不可少的。\nJava 提供了对 SHA 算法系列的支持,包括 SHA-1、SHA-256、SHA-384 和 SHA-512。\nSHA-256 代码示例(未加盐):\nString originalString = \u0026#34;Java学习 + 面试指南:javaguide.cn\u0026#34;; // 创建SHA-256摘要对象 MessageDigest messageDigest = MessageDigest.getInstance(\u0026#34;SHA-256\u0026#34;); messageDigest.update(originalString.getBytes()); // 计算哈希值 byte[] result = messageDigest.digest(); // 将哈希值转换为十六进制字符串 String hexString = new HexBinaryAdapter().marshal(result); System.out.println(\u0026#34;Original String: \u0026#34; + originalString); System.out.println(\u0026#34;SHA-256 Hash: \u0026#34; + hexString.toLowerCase()); 输出:\nOriginal String: Java学习 + 面试指南:javaguide.cn SHA-256 Hash: 184eb7e1d7fb002444098c9bde3403c6f6722c93ecfac242c0e35cd9ed3b41cd Bcrypt # Bcrypt 算法是一种基于 Blowfish 加密算法的密码哈希算法,专门为密码加密而设计,安全性高。\n由于 Bcrypt 采用了 salt(盐) 和 cost(成本) 两种机制,它可以有效地防止彩虹表攻击和暴力破解攻击,从而保证密码的安全性。salt 是一个随机生成的字符串,用于和密码混合,增加密码的复杂度和唯一性。cost 是一个数值参数,用于控制 Bcrypt 算法的迭代次数,增加密码哈希的计算时间和资源消耗。\nBcrypt 算法可以根据实际情况进行调整加密的复杂度,可以设置不同的 cost 值和 salt 值,从而满足不同的安全需求,灵活性很高。\nJava 应用程序的安全框架 Spring Security 支持多种密码编码器,其中 BCryptPasswordEncoder 是官方推荐的一种,它使用 BCrypt 算法对用户的密码进行加密存储。\n@Bean public PasswordEncoder passwordEncoder(){ return new BCryptPasswordEncoder(); } 对称加密 # 对称加密算法是指加密和解密使用同一个密钥的算法,也叫共享密钥加密算法。\n常见的对称加密算法有 DES、3DES、AES 等。\nDES 和 3DES # DES(Data Encryption Standard)使用 64 位的密钥(有效秘钥长度为 56 位,8 位奇偶校验位)和 64 位的明文进行加密。\n虽然 DES 一次只能加密 64 位,但我们只需要把明文划分成 64 位一组的块,就可以实现任意长度明文的加密。如果明文长度不是 64 位的倍数,必须进行填充,常用的模式有 PKCS5Padding, PKCS7Padding, NOPADDING。\nDES 加密算法的基本思想是将 64 位的明文分成两半,然后对每一半进行多轮的变换,最后再合并成 64 位的密文。这些变换包括置换、异或、选择、移位等操作,每一轮都使用了一个子密钥,而这些子密钥都是由同一个 56 位的主密钥生成的。DES 加密算法总共进行了 16 轮变换,最后再进行一次逆置换,得到最终的密文。\n这是一个经典的对称加密算法,但也有明显的缺陷,即 56 位的密钥安全性不足,已被证实可以在短时间内破解。\n为了提高 DES 算法的安全性,人们提出了一些变种或者替代方案,例如 3DES(Triple DES)。\n3DES(Triple DES)是 DES 向 AES 过渡的加密算法,它使用 2 个或者 3 个 56 位的密钥对数据进行三次加密。3DES 相当于是对每个数据块应用三次 DES 的对称加密算法。\n为了兼容普通的 DES,3DES 并没有直接使用 加密-\u0026gt;加密-\u0026gt;加密 的方式,而是采用了加密-\u0026gt;解密-\u0026gt;加密 的方式。当三种密钥均相同时,前两步相互抵消,相当于仅实现了一次加密,因此可实现对普通 DES 加密算法的兼容。3DES 比 DES 更为安全,但其处理速度不高。\nAES # AES(Advanced Encryption Standard)算法是一种更先进的对称密钥加密算法,它使用 128 位、192 位或 256 位的密钥对数据进行加密或解密,密钥越长,安全性越高。\nAES 也是一种分组(或者叫块)密码,分组长度只能是 128 位,也就是说,每个分组为 16 个字节。AES 加密算法有多种工作模式(mode of operation),如:ECB、CBC、OFB、CFB、CTR、XTS、OCB、GCM(目前使用最广泛的模式)。不同的模式参数和加密流程不同,但是核心仍然是 AES 算法。\n和 DES 类似,对于不是 128 位倍数的明文需要进行填充,常用的填充模式有 PKCS5Padding, PKCS7Padding, NOPADDING。不过,AES-GCM 是流加密算法,可以对任意长度的明文进行加密,所以对应的填充模式为 NoPadding,即无需填充。\nAES 的速度比 3DES 快,而且更安全。\nDES 算法和 AES 算法简单对比(图片来自于: RSA vs. AES Encryption: Key Differences Explained):\n基于 Java 实现 AES 算法代码示例:\nprivate static final String AES_ALGORITHM = \u0026#34;AES\u0026#34;; // AES密钥 private static final String AES_SECRET_KEY = \u0026#34;4128D9CDAC7E2F82951CBAF7FDFE675B\u0026#34;; // AES加密模式为GCM,填充方式为NoPadding // AES-GCM 是流加密(Stream cipher)算法,所以对应的填充模式为 NoPadding,即无需填充。 private static final String AES_TRANSFORMATION = \u0026#34;AES/GCM/NoPadding\u0026#34;; // 加密器 private static Cipher encryptionCipher; // 解密器 private static Cipher decryptionCipher; /** * 完成一些初始化工作 */ public static void init() throws Exception { // 将AES密钥转换为SecretKeySpec对象 SecretKeySpec secretKeySpec = new SecretKeySpec(AES_SECRET_KEY.getBytes(), AES_ALGORITHM); // 使用指定的AES加密模式和填充方式获取对应的加密器并初始化 encryptionCipher = Cipher.getInstance(AES_TRANSFORMATION); encryptionCipher.init(Cipher.ENCRYPT_MODE, secretKeySpec); // 使用指定的AES加密模式和填充方式获取对应的解密器并初始化 decryptionCipher = Cipher.getInstance(AES_TRANSFORMATION); decryptionCipher.init(Cipher.DECRYPT_MODE, secretKeySpec, new GCMParameterSpec(128, encryptionCipher.getIV())); } /** * 加密 */ public static String encrypt(String data) throws Exception { byte[] dataInBytes = data.getBytes(); // 加密数据 byte[] encryptedBytes = encryptionCipher.doFinal(dataInBytes); return Base64.getEncoder().encodeToString(encryptedBytes); } /** * 解密 */ public static String decrypt(String encryptedData) throws Exception { byte[] dataInBytes = Base64.getDecoder().decode(encryptedData); // 解密数据 byte[] decryptedBytes = decryptionCipher.doFinal(dataInBytes); return new String(decryptedBytes, StandardCharsets.UTF_8); } public static void main(String[] args) throws Exception { String originalString = \u0026#34;Java学习 + 面试指南:javaguide.cn\u0026#34;; init(); String encryptedData = encrypt(originalString); String decryptedData = decrypt(encryptedData); System.out.println(\u0026#34;Original String: \u0026#34; + originalString); System.out.println(\u0026#34;AES Encrypted Data : \u0026#34; + encryptedData); System.out.println(\u0026#34;AES Decrypted Data : \u0026#34; + decryptedData); } 输出:\nOriginal String: Java学习 + 面试指南:javaguide.cn AES Encrypted Data : E1qTkK91suBqToag7WCyoFP9uK5hR1nSfM6p+oBlYj71bFiIVnk5TsQRT+zpjv8stha7oyKi3jQ= AES Decrypted Data : Java学习 + 面试指南:javaguide.cn 非对称加密 # 非对称加密算法是指加密和解密使用不同的密钥的算法,也叫公开密钥加密算法。这两个密钥互不相同,一个称为公钥,另一个称为私钥。公钥可以公开给任何人使用,私钥则要保密。\n如果用公钥加密数据,只能用对应的私钥解密(加密);如果用私钥加密数据,只能用对应的公钥解密(签名)。这样就可以实现数据的安全传输和身份认证。\n常见的非对称加密算法有 RSA、DSA、ECC 等。\nRSA # RSA(Rivest–Shamir–Adleman algorithm)算法是一种基于大数分解的困难性的非对称加密算法,它需要选择两个大素数作为私钥的一部分,然后计算出它们的乘积作为公钥的一部分(寻求两个大素数比较简单,而将它们的乘积进行因式分解却极其困难)。RSA 算法原理的详细介绍,可以参考这篇文章: 你真的了解 RSA 加密算法吗? - 小傅哥。\nRSA 算法的安全性依赖于大数分解的难度,目前已经有 512 位和 768 位的 RSA 公钥被成功分解,因此建议使用 2048 位或以上的密钥长度。\nRSA 算法的优点是简单易用,可以用于数据加密和数字签名;缺点是运算速度慢,不适合大量数据的加密。\nRSA 算法是是目前应用最广泛的非对称加密算法,像 SSL/TLS、SSH 等协议中就用到了 RSA 算法。\n基于 Java 实现 RSA 算法代码示例:\nprivate static final String RSA_ALGORITHM = \u0026#34;RSA\u0026#34;; /** * 生成RSA密钥对 */ public static KeyPair generateKeyPair() throws NoSuchAlgorithmException { KeyPairGenerator keyPairGenerator = KeyPairGenerator.getInstance(RSA_ALGORITHM); // 密钥大小为2048位 keyPairGenerator.initialize(2048); return keyPairGenerator.generateKeyPair(); } /** * 使用公钥加密数据 */ public static String encrypt(String data, PublicKey publicKey) throws Exception { Cipher cipher = Cipher.getInstance(RSA_ALGORITHM); cipher.init(Cipher.ENCRYPT_MODE, publicKey); byte[] encryptedData = cipher.doFinal(data.getBytes(StandardCharsets.UTF_8)); return Base64.getEncoder().encodeToString(encryptedData); } /** * 使用私钥解密数据 */ public static String decrypt(String encryptedData, PrivateKey privateKey) throws Exception { byte[] decodedData = Base64.getDecoder().decode(encryptedData); Cipher cipher = Cipher.getInstance(RSA_ALGORITHM); cipher.init(Cipher.DECRYPT_MODE, privateKey); byte[] decryptedData = cipher.doFinal(decodedData); return new String(decryptedData, StandardCharsets.UTF_8); } public static void main(String[] args) throws Exception { KeyPair keyPair = generateKeyPair(); PublicKey publicKey = keyPair.getPublic(); PrivateKey privateKey = keyPair.getPrivate(); String originalString = \u0026#34;Java学习 + 面试指南:javaguide.cn\u0026#34;; String encryptedData = encrypt(originalString, publicKey); String decryptedData = decrypt(encryptedData, privateKey); System.out.println(\u0026#34;Original String: \u0026#34; + originalString); System.out.println(\u0026#34;RSA Encrypted Data : \u0026#34; + encryptedData); System.out.println(\u0026#34;RSA Decrypted Data : \u0026#34; + decryptedData); } 输出:\nOriginal String: Java学习 + 面试指南:javaguide.cn RSA Encrypted Data : T9ey/CEPUAhZm4UJjuVNIg8RPd1fQ32S9w6+rvOKxmuMumkJY2daFfWuCn8A73Mk5bL6TigOJI0GHfKOt/W2x968qLM3pBGCcPX17n4pR43f32IIIz9iPdgF/INOqDxP5ZAtCDvTiuzcSgDHXqiBSK5TDjtj7xoGjfudYAXICa8pWitnqDgJYoo2J0F8mKzxoi8D8eLE455MEx8ZT1s7FUD/z7/H8CfShLRbO9zq/zFI06TXn123ufg+F4lDaq/5jaIxGVEUB/NFeX4N6OZCFHtAV32mw71BYUadzI9TgvkkUr1rSKmQ0icNhnRdKedJokGUh8g9QQ768KERu92Ibg== RSA Decrypted Data : Java学习 + 面试指南:javaguide.cn DSA # DSA(Digital Signature Algorithm)算法是一种基于离散对数的困难性的非对称加密算法,它需要选择一个素数 q 和一个 q 的倍数 p 作为私钥的一部分,然后计算出一个模 p 的原根 g 和一个模 q 的整数 y 作为公钥的一部分。DSA 算法的安全性依赖于离散对数的难度,目前已经有 1024 位的 DSA 公钥被成功破解,因此建议使用 2048 位或以上的密钥长度。\nDSA 算法的优点是数字签名速度快,适合生成数字证书;缺点是不能用于数据加密,且签名过程需要随机数。\nDSA 算法签名过程:\n使用消息摘要算法对要发送的数据进行加密,生成一个信息摘要,也就是一个短的、唯一的、不可逆的数据表示。 发送方用自己的 DSA 私钥对信息摘要再进行加密,形成一个数字签名,也就是一个可以证明数据来源和完整性的数据附加。 将原始数据和数字签名一起通过互联网传送给接收方。 接收方用发送方的公钥对数字签名进行解密,得到信息摘要。同时,接收方也用消息摘要算法对收到的原始数据进行加密,得到另一个信息摘要。接收方将两个信息摘要进行比较,如果两者一致,则说明在传送过程中数据没有被篡改或损坏;否则,则说明数据已经失去了安全性和保密性。 总结 # 这篇文章介绍了三种加密算法:哈希算法、对称加密算法和非对称加密算法。\n哈希算法是一种用数学方法对数据生成一个固定长度的唯一标识的技术,可以用来验证数据的完整性和一致性,常见的哈希算法有 MD、SHA、MAC 等。 对称加密算法是一种加密和解密使用同一个密钥的算法,可以用来保护数据的安全性和保密性,常见的对称加密算法有 DES、3DES、AES 等。 非对称加密算法是一种加密和解密使用不同的密钥的算法,可以用来实现数据的安全传输和身份认证,常见的非对称加密算法有 RSA、DSA、ECC 等。 参考 # 深入理解完美哈希 - 腾讯技术工程: https://mp.weixin.qq.com/s/M8Wcj8sZ7UF1CMr887Puog 写给开发人员的实用密码学(二)—— 哈希函数: https://thiscute.world/posts/practical-cryptography-basics-2-hash/ 奇妙的安全旅行之 DSA 算法: https://zhuanlan.zhihu.com/p/347025157 AES-GCM 加密简介: https://juejin.cn/post/6844904122676690951 Java AES 256 GCM Encryption and Decryption Example | JCE Unlimited Strength: https://www.javainterviewpoint.com/java-aes-256-gcm-encryption-and-decryption/ "},{"id":598,"href":"/zh/docs/technology/Interview/cs-basics/algorithms/common-data-structures-leetcode-recommendations/","title":"常见数据结构经典LeetCode题目推荐","section":"Algorithms","content":" 数组 # 704.二分查找: https://leetcode.cn/problems/binary-search/\n80.删除有序数组中的重复项 II: https://leetcode.cn/problems/remove-duplicates-from-sorted-array-ii\n977.有序数组的平方: https://leetcode.cn/problems/squares-of-a-sorted-array/\n链表 # 707.设计链表: https://leetcode.cn/problems/design-linked-list/\n206.反转链表: https://leetcode.cn/problems/reverse-linked-list/\n92.反转链表 II: https://leetcode.cn/problems/reverse-linked-list-ii/\n61.旋转链表: https://leetcode.cn/problems/rotate-list/\n栈与队列 # 232.用栈实现队列: https://leetcode.cn/problems/implement-queue-using-stacks/\n225.用队列实现栈: https://leetcode.cn/problems/implement-stack-using-queues/\n347.前 K 个高频元素: https://leetcode.cn/problems/top-k-frequent-elements/\n239.滑动窗口最大值: https://leetcode.cn/problems/sliding-window-maximum/\n二叉树 # 105.从前序与中序遍历构造二叉树: https://leetcode.cn/problems/construct-binary-tree-from-preorder-and-inorder-traversal/\n117.填充每个节点的下一个右侧节点指针 II: https://leetcode.cn/problems/populating-next-right-pointers-in-each-node-ii\n236.二叉树的最近公共祖先: https://leetcode.cn/problems/lowest-common-ancestor-of-a-binary-tree/\n129.求根节点到叶节点数字之和: https://leetcode.cn/problems/sum-root-to-leaf-numbers/\n102.二叉树的层序遍历: https://leetcode.cn/problems/binary-tree-level-order-traversal/\n530.二叉搜索树的最小绝对差: https://leetcode.cn/problems/minimum-absolute-difference-in-bst/\n图 # 200.岛屿数量: https://leetcode.cn/problems/number-of-islands/\n207.课程表: https://leetcode.cn/problems/course-schedule/\n210.课程表 II: https://leetcode.cn/problems/course-schedule-ii/\n堆 # 215.数组中的第 K 个最大元素: https://leetcode.cn/problems/kth-largest-element-in-an-array/\n216.数据流的中位数: https://leetcode.cn/problems/find-median-from-data-stream/\n217.前 K 个高频元素: https://leetcode.cn/problems/top-k-frequent-elements/\n"},{"id":599,"href":"/zh/docs/technology/Interview/high-availability/timeout-and-retry/","title":"超时\u0026重试详解","section":"High Availability","content":"由于网络问题、系统或者服务内部的 Bug、服务器宕机、操作系统崩溃等问题的不确定性,我们的系统或者服务永远不可能保证时刻都是可用的状态。\n为了最大限度的减小系统或者服务出现故障之后带来的影响,我们需要用到的 超时(Timeout) 和 重试(Retry) 机制。\n想要把超时和重试机制讲清楚其实很简单,因为它俩本身就不是什么高深的概念。\n虽然超时和重试机制的思想很简单,但是它俩是真的非常实用。你平时接触到的绝大部分涉及到远程调用的系统或者服务都会应用超时和重试机制。尤其是对于微服务系统来说,正确设置超时和重试非常重要。单体服务通常只涉及数据库、缓存、第三方 API、中间件等的网络调用,而微服务系统内部各个服务之间还存在着网络调用。\n超时机制 # 什么是超时机制? # 超时机制说的是当一个请求超过指定的时间(比如 1s)还没有被处理的话,这个请求就会直接被取消并抛出指定的异常或者错误(比如 504 Gateway Timeout)。\n我们平时接触到的超时可以简单分为下面 2 种:\n连接超时(ConnectTimeout):客户端与服务端建立连接的最长等待时间。 读取超时(ReadTimeout):客户端和服务端已经建立连接,客户端等待服务端处理完请求的最长时间。实际项目中,我们关注比较多的还是读取超时。 一些连接池客户端框架中可能还会有获取连接超时和空闲连接清理超时。\n如果没有设置超时的话,就可能会导致服务端连接数爆炸和大量请求堆积的问题。\n这些堆积的连接和请求会消耗系统资源,影响新收到的请求的处理。严重的情况下,甚至会拖垮整个系统或者服务。\n我之前在实际项目就遇到过类似的问题,整个网站无法正常处理请求,服务器负载直接快被拉满。后面发现原因是项目超时设置错误加上客户端请求处理异常,导致服务端连接数直接接近 40w+,这么多堆积的连接直接把系统干趴了。\n超时时间应该如何设置? # 超时到底设置多长时间是一个难题!超时值设置太高或者太低都有风险。如果设置太高的话,会降低超时机制的有效性,比如你设置超时为 10s 的话,那设置超时就没啥意义了,系统依然可能会出现大量慢请求堆积的问题。如果设置太低的话,就可能会导致在系统或者服务在某些处理请求速度变慢的情况下(比如请求突然增多),大量请求重试(超时通常会结合重试)继续加重系统或者服务的压力,进而导致整个系统或者服务被拖垮的问题。\n通常情况下,我们建议读取超时设置为 1500ms ,这是一个比较普适的值。如果你的系统或者服务对于延迟比较敏感的话,那读取超时值可以适当在 1500ms 的基础上进行缩短。反之,读取超时值也可以在 1500ms 的基础上进行加长,不过,尽量还是不要超过 1500ms 。连接超时可以适当设置长一些,建议在 1000ms ~ 5000ms 之内。\n没有银弹!超时值具体该设置多大,还是要根据实际项目的需求和情况慢慢调整优化得到。\n更上一层,参考 美团的 Java 线程池参数动态配置思想,我们也可以将超时弄成可配置化的参数而不是固定的,比较简单的一种办法就是将超时的值放在配置中心中。这样的话,我们就可以根据系统或者服务的状态动态调整超时值了。\n重试机制 # 什么是重试机制? # 重试机制一般配合超时机制一起使用,指的是多次发送相同的请求来避免瞬态故障和偶然性故障。\n瞬态故障可以简单理解为某一瞬间系统偶然出现的故障,并不会持久。偶然性故障可以理解为哪些在某些情况下偶尔出现的故障,频率通常较低。\n重试的核心思想是通过消耗服务器的资源来尽可能获得请求更大概率被成功处理。由于瞬态故障和偶然性故障是很少发生的,因此,重试对于服务器的资源消耗几乎是可以被忽略的。\n常见的重试策略有哪些? # 常见的重试策略有两种:\n固定间隔时间重试:每次重试之间都使用相同的时间间隔,比如每隔 1.5 秒进行一次重试。这种重试策略的优点是实现起来比较简单,不需要考虑重试次数和时间的关系,也不需要维护额外的状态信息。但是这种重试策略的缺点是可能会导致重试过于频繁或过于稀疏,从而影响系统的性能和效率。如果重试间隔太短,可能会对目标系统造成过大的压力,导致雪崩效应;如果重试间隔太长,可能会导致用户等待时间过长,影响用户体验。 梯度间隔重试:根据重试次数的增加去延长下次重试时间,比如第一次重试间隔为 1 秒,第二次为 2 秒,第三次为 4 秒,以此类推。这种重试策略的优点是能够有效提高重试成功的几率(随着重试次数增加,但是重试依然不成功,说明目标系统恢复时间比较长,因此可以根据重试次数延长下次重试时间),也能通过柔性化的重试避免对下游系统造成更大压力。但是这种重试策略的缺点是实现起来比较复杂,需要考虑重试次数和时间的关系,以及设置合理的上限和下限值。另外,这种重试策略也可能会导致用户等待时间过长,影响用户体验。 这两种适合的场景各不相同。固定间隔时间重试适用于目标系统恢复时间比较稳定和可预测的场景,比如网络波动或服务重启。梯度间隔重试适用于目标系统恢复时间比较长或不可预测的场景,比如网络故障和服务故障。\n重试的次数如何设置? # 重试的次数不宜过多,否则依然会对系统负载造成比较大的压力。\n重试的次数通常建议设为 3 次。大部分情况下,我们还是更建议使用梯度间隔重试策略,比如说我们要重试 3 次的话,第 1 次请求失败后,等待 1 秒再进行重试,第 2 次请求失败后,等待 2 秒再进行重试,第 3 次请求失败后,等待 3 秒再进行重试。\n什么是重试幂等? # 超时和重试机制在实际项目中使用的话,需要注意保证同一个请求没有被多次执行。\n什么情况下会出现一个请求被多次执行呢?客户端等待服务端完成请求完成超时但此时服务端已经执行了请求,只是由于短暂的网络波动导致响应在发送给客户端的过程中延迟了。\n举个例子:用户支付购买某个课程,结果用户支付的请求由于重试的问题导致用户购买同一门课程支付了两次。对于这种情况,我们在执行用户购买课程的请求的时候需要判断一下用户是否已经购买过。这样的话,就不会因为重试的问题导致重复购买了。\nJava 中如何实现重试? # 如果要手动编写代码实现重试逻辑的话,可以通过循环(例如 while 或 for 循环)或者递归实现。不过,一般不建议自己动手实现,有很多第三方开源库提供了更完善的重试机制实现,例如 Spring Retry、Resilience4j、Guava Retrying。\n参考 # 微服务之间调用超时的设置治理: https://www.infoq.cn/article/eyrslar53l6hjm5yjgyx 超时、重试和抖动回退: https://aws.amazon.com/cn/builders-library/timeouts-retries-and-backoff-with-jitter/ "},{"id":600,"href":"/zh/docs/technology/Interview/high-quality-technical-articles/advanced-programmer/the-growth-strategy-of-the-technological-giant/","title":"程序员的技术成长战略","section":"Advanced Programmer","content":" 推荐语:波波老师的一篇文章,写的非常好,不光是对技术成长有帮助,其他领域也是同样适用的!建议反复阅读,形成一套自己的技术成长策略。\n原文地址: https://mp.weixin.qq.com/s/YrN8T67s801-MRo01lCHXA\n1. 前言 # 在波波的微信技术交流群里头,经常有学员问关于技术人该如何学习成长的问题,虽然是微信交流,但我依然可以感受到小伙伴们焦虑的心情。\n技术人为啥焦虑? 恕我直言,说白了是胆识不足格局太小。胆就是胆量,焦虑的人一般对未来的不确定性怀有恐惧。识就是见识,焦虑的人一般看不清楚周围世界,也看不清自己和适合自己的道路。格局也称志向,容易焦虑的人通常视野窄志向小。如果从战略和管理的视角来看,就是对自己和周围世界的认知不足,没有一个清晰和长期的学习成长战略,也没有可执行的阶段性目标计划+严格的执行。\n因为问此类问题的学员很多,让我感觉有点烦了,为了避免重复回答,所以我专门总结梳理了这篇长文,试图统一来回答这类问题。如果后面还有学员问类似问题,我会引导他们来读这篇文章,然后让他们用三个月、一年甚至更长的时间,去思考和回答这样一个问题:你的技术成长战略究竟是什么? 如果你想清楚了这个问题,有清晰和可落地的答案,那么恭喜你,你只需按部就班执行就好,根本无需焦虑,你实现自己的战略目标并做出成就只是一个时间问题;否则,你仍然需要通过不断磨炼+思考,务必去搞清楚这个人生的大问题!!!\n下面我们来看一些行业技术大牛是怎么做的。\n二. 跟技术大牛学成长战略 # 我们知道软件设计是有设计模式(Design Pattern)的,其实技术人的成长也是有成长模式(Growth Pattern)的。波波经常在 Linkedin 上看一些技术大牛的成长履历,探究其中的成长模式,从而启发制定自己的技术成长战略。\n当然,很少有技术大牛会清晰地告诉你他们的技术成长战略,以及每一年的细分落地计划。但是,这并不妨碍我们通过他们的过往履历和产出成果,去溯源他们的技术成长战略。实际上, 越是牛逼的技术人,他们的技术成长战略和路径越是清晰,我们越容易从中探究出一些成功的模式。\n2.1 系统性能专家案例 # 国内的开发者大都热衷于系统性能优化,有些人甚至三句话离不开高性能/高并发,但真正能深入这个领域,做到专家级水平的却寥寥无几。\n我这边要特别介绍的这个技术大牛叫 Brendan Gregg ,他是系统性能领域经典书《System Performance: Enterprise and the Cloud》(中文版 《性能之巅:洞悉系统、企业和云计算》)的作者,也是著名的 性能分析利器火焰图(Flame Graph)的作者。\nBrendan Gregg 之前是 Netflix 公司的高级性能架构师,在 Netflix 工作近 7 年。2022 年 4 月,他离开了 Netflix 去了 Intel,担任院士职位。\n总体上,他已经在系统性能领域深耕超过 10 年, Brendan Gregg 的过往履历可以在 linkedin 上看到。在这 10 年间,除了书籍以外,Brendan Gregg 还产出了超过上百份和系统性能相关的技术文档,演讲视频/ppt,还有各种工具软件,相关内容都整整齐齐地分享在 他的技术博客上,可以说他是一个非常高产的技术大牛。\n上图来自 Brendan Gregg 的新书《BPF Performance Tools: Linux System and Application Observability》。从这个图可以看出,Brendan Gregg 对系统性能领域的掌握程度,已经深挖到了硬件、操作系统和应用的每一个角落,可以说是 360 度无死角,整个计算机系统对他来说几乎都是透明的。波波认为,Brendan Gregg 是名副其实的,世界级的,系统性能领域的大神级人物。\n2.2 从开源到企业案例 # 我要分享的第二个技术大牛是 Jay Kreps,他是知名的开源消息中间件 Kafka 的创始人/架构师,也是 Confluent 公司的联合创始人和 CEO,Confluent 公司是围绕 Kafka 开发企业级产品和服务的技术公司。\n从 Jay Kreps 的 Linkedin 的履历上我们可以看出,Jay Kreps 之前在 Linkedin 工作了 7 年多(2007.6 ~ 2014. 9),从高级工程师、工程主管,一直做到首席资深工程师。Kafka 大致是在 2010 年,Jay Kreps 在 Linkedin 发起的一个项目,解决 Linkedin 内部的大数据采集、存储和消费问题。之后,他和他的团队一直专注 Kafka 的打磨,开源(2011 年初)和社区生态的建设。\n到 2014 年底,Kafka 在社区已经非常成功,有了一个比较大的用户群,于是 Jay Kreps 就和几个早期作者一起离开了 Linkedin,成立了 Confluent 公司,开始了 Kafka 和周边产品的企业化服务道路。今年(2020.4 月),Confluent 公司已经获得 E 轮 2.5 亿美金融资,公司估值达到 45 亿美金。从 Kafka 诞生到现在,Jay Kreps 差不多在这个产品和公司上投入了整整 10 年。\n上图是 Confluent 创始人三人组,一个非常有意思的组合,一个中国人(左),一个印度人(右),中间的 Jay Kreps 是美国人。\n我之所以对 Kafka 和 Jay Kreps 的印象特别深刻,是因为在 2012 年下半年,我在携程框架部也是专门搞大数据采集的,我还开发过一套功能类似 Kafka 的 Log Collector + Agent 产品。我记得同时期有不止 4 个同类型的开源产品:Facebook Scribe、Apache Chukwa、Apache Flume 和 Apache Kafka。现在回头看,只有 Kafka 走到现在发展得最好,这个和创始人的专注和持续投入是分不开的,当然背后和几个创始人的技术大格局也是分不开的。\n当年我对战略性思维几乎没有概念,还处在什么技术都想学、认为各种项目做得越多越牛的阶段。搞了半年的数据采集以后,我就掉头搞其它“更有趣的”项目去了(从这个事情的侧面,也可以看出我当年的技术格局是很小的)。中间我陆续关注过 Jay 的一些创业动向,但是没想到他能把 Confluent 公司发展到目前这个规模。现在回想,其实在十年前,Jay Kreps 对自己的技术成长就有比较明确的战略性思考,也具有大的技术格局和成事的一些必要特质。Jay Kreps 和 Kafka 给我上了一堂生动的技术战略和实践课。\n2.3 技术媒体大 V 案例 # 介绍到这里,有些同学可能会反驳说:波波你讲的这些大牛都是学历背景好,功底扎实起点高,所以他们才更能成功。其实不然,这里我再要介绍一位技术媒体界的大 V 叫 Brad Traversy,大家可以看 他的 Linkedin 简历,背景很一般,学历差不多是一个非正规的社区大学(相当于大专),没有正规大厂工作经历,有限几份工作一直是在做网站外包。\n但是!!!Brad Traversy 目前是技术媒体领域的一个大 V,当前 他在 Youtube 上的频道有 138 万多的订阅量,10 年累计输出 Web 开发和编程相关教学视频超过 800 个。Brad Traversy 也是 Udemy 上的一个成功讲师,目前已经在 Udemy 上累计输出课程 19 门,购课学生数量近 42 万。\nBrad Traversy 目前是自由职业者,他的 Youtube 广告+Udemy 课程的收入相当不错。\n就是这样一位技术媒体大 V,你很难想象,在年轻的时候,贴在他身上的标签是:不良少年,酗酒,抽烟,吸毒,纹身,进监狱。。。直\n到结婚后的第一个孩子诞生,他才开始担起责任做出改变,然后凭借对技术的一腔热情,开始在 Youtube 平台上持续输出免费课程。从此他找到了适合自己的战略目标,然后人生开始发生各种积极的变化。。。如果大家对 Brad Traversy 的过往经历感兴趣,推荐观看他在 Youtube 上的自述视频 《My Struggles \u0026amp; Success》。\n我粗略浏览了 Brad Traversy 在 Youtube 上的所有视频,10 年总计输出 800+视频,平均每年 80+。第一个视频提交于 2010 年 8 月,刚开始几年几乎没有订阅量,2017 年 1 月订阅量才到 50k,这中间差不多隔了 6 年。2017.10 月订阅量猛增到 200k,2018 年 3 月订阅量到 300k。当前 2021.1 月,订阅量达到 138 万。可以认为从 2017 开始,也就是在积累了 6 ~ 7 年后,他的订阅量开始出现拐点。如果把这些数据画出来,将会是一条非常漂亮的复利曲线。\n2.4 案例小结 # Brendan Gregg,Jay Kreps 和 Brad Traversy 三个人走的技术路线各不相同,但是他们的成功具有共性或者说模式:\n1、找到了适合自己的长期战略目标。\nBrendan Gregg: 成为系统性能领域顶级专家 Jay Kreps:开创基于 Kafka 开源消息队列的企业服务公司,并将公司做到上市 Brad Traversy: 成为技术媒体领域大 V 和课程讲师,并以此作为自己的职业 2、专注深耕一个(或有限几个相关的)细分领域(Niche),保持定力,不随便切换领域。\nBrendan Gregg:系统性能领域 Jay Kreps: 消息中间件/实时计算领域+创业 Brad Traversy: 技术媒体/教学领域,方向 Web 开发 + 编程语言 3、长期投入,三人都持续投入了 10 年。\n4、年度细分计划+持续可量化的价值产出(Persistent \u0026amp; Measurable Value Output)。\nBrendan Gregg:除公司日常工作产出以外,每年有超过 10 份以上的技术文档和演讲视频产出,平均每年有 2.5 个开源工具产出。十年共产出书籍 2 本,其中《System Performance》已经更新到第二版。 Jay Kreps:总体有开源产品+公司产出,1 本书产出,每年有 Kafka 和周边产品发版若干。 Brad Traversy: 每年有 Youtube 免费视频产出(平均每年 80+)+Udemy 收费视频课产出(平均每年 1.5 门)。 5、以终为始是牛人和普通人的一大区别。\n普通人通常走一步算一步,很少长远规划。牛人通 常是先有远大目标,然后采用倒推法,将大目标细化到每年/月/周的详细落地计划。Brendan Gregg,Jay Kreps 和 Brad Traversy 三人都是以终为始的典型。\n上面总结了几位技术大牛的成长模式,其中一个重点就是:这些大牛的成长都是通过 持续有价值产出(Persistent Valuable Output) 来驱动的。持续产出为啥如此重要,这个还要从下面的学习金字塔说起。\n三、学习金字塔和刻意训练 # 学习金字塔是美国缅因州国家训练实验室的研究成果,它认为:\n我们平时上课听讲之后,学习内容平均留存率大致只有 5%左右; 书本阅读的平均留存率大致只有 10%左右; 学习配上视听效果的课程,平均留存率大致在 20%左右; 老师实际动手做实验演示后的平均留存率大致在 30%左右; 小组讨论(尤其是辩论后)的平均留存率可以达到 50%左右; 在实践中实际应用所学之后,平均留存率可以达到 75%左右; 在实践的基础上,再把所学梳理出来,转而再传授给他人后,平均留存率可以达到 90%左右。 上面列出的 7 种学习方法,前四种称为 被动学习 ,后三种称为 主动学习。\n拿学游泳做个类比,被动学习相当于你看别人游泳,而主动学习则是你自己要下水去游。我们知道游泳或者跑步之类的运动是要燃烧身体卡路里的,这样才能达到锻炼身体和长肌肉的效果(肌肉是卡路里燃烧的结果)。如果你只是看别人游泳,自己不实际去游,是不会长肌肉的。同样的,主动学习也是要燃烧脑部卡路里的,这样才能达到训练大脑和长脑部“肌肉”的效果。\n我们也知道,燃烧身体的卡路里,通常会让人感觉不舒适,如果燃烧身体卡路里会让人感觉舒适的话,估计这个世界上应该不会有胖子这类人。同样,燃烧脑部卡路里也会让人感觉不适、紧张、出汗或语无伦次,如果燃烧脑部卡路里会让人感觉舒适的话,估计这个世界上人人都很聪明,人人都能发挥最大潜能。当然,这些不舒适是短期的,长期会使你更健康和聪明。波波一直认为, 人与人之间的先天身体其实都差不多,但是后天身体素质和能力有差异,这些差异,很大程度是由后天对身体和大脑的训练质量、频度和强度所造成的。\n明白这个道理之后,心智成熟和自律的人就会对自己进行持续地 刻意训练 。这个刻意训练包括对身体的训练,比如波波现在每天坚持跑步 3km,走 3km,每天做 60 个仰卧起坐,5 分钟平板撑等等,每天保持让身体燃烧一定量的卡路里。刻意训练也包括对大脑的训练,比如波波现在每天做项目写代码 coding(训练脑+手),平均每天在 B 站上输出十分钟免费视频(训练脑+口头表达),另外有定期总结输出公众号文章(训练脑+文字表达),还有每天打半小时左右的平衡球(下图)或古墓丽影游戏(训练小脑+手),每天保持让大脑燃烧一定量的卡路里,并保持一定强度(适度不适感)。\n关于刻意训练的专业原理和方法论,推荐看书籍《刻意练习》。\n注意,如果你平时从来不做举重锻炼的,那么某天突然做举重会很不适应甚至受伤。脑部训练也是一样的,如果你从来没有做过视频输出,那么刚开始做会很不适应,做出来的视频质量会很差。不过没有关系,任何训练都是一个循序渐进,不断强化的过程。等大脑相关区域的\u0026quot;肌肉\u0026quot;长出来以后,会逐步进入正循环,后面会越来越顺畅,相关\u0026quot;肌肉\u0026quot;会越来越发达。所以,和健身一样,健脑也不能遇到困难就放弃,需要循序渐进(Incremental)+持续地(Persistent)刻意训练。\n理解了学习金字塔和刻意训练以后,现在再来看 Brendan Gregg,Jay Kreps 和 Brad Traversy 这些大牛的做法,他们的学习成长都是建立在持续有价值产出的基础上的,这些产出都是刻意训练+燃烧脑部卡路里的成果。他们的产出要么是建立在实践基础上的产出,例如 Jay Kreps 的 Kafka 开源项目和 Confluent 公司;要么是在实践的基础上,再整理传授给其他人的产出,例如,Brendan Greeg 的技术演讲 ppt/视频,书籍,还有 Brad Traversy 的教学视频等等。换句话说,他们一直在学习金字塔的 5 ~ 7 层主动和高效地学习。并且,他们的学习产出还可以获得用户使用,有客户价值(Customer Value),有用户就有反馈和度量。记住,有反馈和度量的学习,也称闭环学习,它是能够不断改进提升的;反之,没有反馈和度量的学习,无法改进提升。\n现在,你也应该明白,晒个书单秀个技能图谱很简单,读个书上个课也不难。但是要你给出 5 ~ 10 年的总体技术成长战略,再基于这个战略给出每年的细分落地计划(尤其是产出计划),然后再严格按计划执行,这的确是很难的事情。这需要大量的实践训练+深度思考,要燃烧大量的脑部卡路里!但这是上天设置的进化法则,成长为真正的技术大牛如同成长为一流的运动员,是需要通过燃烧与之相匹配量的卡路里来交换的。成长为真正的技术大牛,也是需要通过产出与之匹配的社会价值来交换的,只有这样社会才能正常进化。你推进了社会进化,社会才会回馈你。如果不是这样,社会就无法正常进化。\n四、战略思维的诞生 # 一般毕业生刚进入企业工作的时候,思考大都是以天/星期/月为单位的,基本上都是今天学个什么技术,明天学个什么语言,很少会去思考一年甚至更长的目标。这是个眼前漆黑看不到的懵懂时期,捕捉到机会点的能力和概率都非常小。\n工作了三年以后,悟性好的人通常会以一年为思考周期,制定和实施一些年度计划。这个时期是相信天赋和比拼能力的阶段,可以捕捉到一些小机会。\n工作了五年以后,一些悟性好的人会产生出一定的胆识和眼光,他们会以 3 ~ 5 年为周期来制定和实施计划,开始主动布局去捕捉一些中型机会点。\n工作了十年以后,悟性高的人会看到模式和规则变化,例如看出行业发展模式,还有人才的成长模式等,于是开始诞生出战略性思维。然后他们会以 5 ~ 10 年为周期来制定和实施自己的战略计划,开始主动布局去捕捉一些中大机会点。Brendan Gregg,Jay Kreps 和 Brad Traversy 都是属于这个阶段的人。\n当然还有很少一些更牛的时代精英,他们能够看透时代和人性,他们的思考是以一生甚至更长时间为单位的,这些超人不在本文讨论范围内。\n五、建议 # 1、以 5 ~ 10 年为周期去布局谋划你的战略。\n现在大学生毕业的年龄一般在 22 ~ 23 岁,那么在工作了十年后,也就是在你 32 ~ 33 岁的时候,你也差不多看了十年了,应该对自己和周围的世界(你的行业和领域)有一个比较深刻的领悟了。如果你到这个年纪还懵懵懂懂,今天抓东明天抓西,那么只能说你的胆识格局是相当的低。在当前 IT 行业竞争这么激烈的情况下,到 35 岁被下岗可能就在眼前了。\n有了战略性思考,你应该以 5 ~ 10 年为周期去布局谋划你的战略。以 Brendan Gregg,Jay Kreps 和 Brad Traversy 这些大牛为例,人生若真的要干点成就出来,投入周期一般都要十年的。从 33 岁开始,你大致有 3 个十年,因为到 60 岁以后,一般人都老眼昏花干不了大事了。如果你悟性差一点,到 40 岁才开始规划,那么你大致还有 2 个十年。如果你规划好了,这 2 ~ 3 个十年可以成就不小的事业。否则,你很可能一生都成就不了什么事业,或者一直在帮助别人成就别人的事业。\n2、专注自己的精力。\n考虑到人生能干事业的时间也就是 2 ~ 3 个十年,你会发现人生其实很短暂,这时候你会把精力都投入到实现你的十年战略上去,没有时间再浪费在比如网上的闲聊和扯皮争论上去。\n3、细分落地计划尤其是产出计划。\n有了十年战略方向,下一步是每年的细分落地计划,尤其是产出计划。这些计划主要应该工作在学习金字塔的 5/6/7 层。产出应该是刻意训练+燃烧卡路里的结果,每天让身体和大脑都保持燃烧一定量的卡路里。\n4、产出有价值的东西形成正反馈。\n产出应该有客户价值,自己能学习(自己成长进化),对别人还有用(推动社会成长进化),这样可以得到用户回馈和度量,形成一个闭环,可以持续改进和提升你的学习。\n5、少即是多。\n深耕一个(或有限几个相关的)领域。所有细分计划应该紧密围绕你的战略展开。克制内心欲望,不要贪多和分心,不要被喧嚣的世界所迷惑。\n6、战略方向+细分计划都要写下来,定期 review 优化。\n7、要有定力,持续努力。\n曲则全、枉则直,战略实现是不可能直线的。战略方向和细分计划通常要按需调整,尤其在早期,但是最终要收敛。如果老是变不收敛,就是缺乏战略定力,是个必须思考和解决的大问题。\n别人的成长战略可以参考,但是不要刻意去模仿,你有你自己的颜色,你应该成为独一无二的你。\n战略方向和细分计划明确了,接下来就是按部就班执行,十年如一日铁打不动。\n8、慢就是快。\n战略目标的实现也和种树一样是生长出来的,需要时间耐心栽培,记住==慢就是快。==焦虑纠结的时候,像念经一样默念王阳明《传习录》中的教诲:\n立志用功,如种树然。方其根芽,犹未有干;及其有干,尚未有枝;枝而后叶,叶而后花实。初种根时,只管栽培灌溉。勿作枝想,勿作花想,勿作实想。悬想何益?但不忘栽培之功,怕没有枝叶花实?\n译文:\n实现战略目标,就像种树一样。刚开始只是一个小根芽,树干还没有长出来;树干长出来了,枝叶才能慢慢长出来;树枝长出来,然后才能开花和结果。刚开始种树的时候,只管栽培灌溉,别老是纠结枝什么时候长出来,花什么时候开,果实什么时候结出来。纠结有什么好处呢?只要你坚持投入栽培,还怕没有枝叶花实吗?\n"},{"id":601,"href":"/zh/docs/technology/Interview/high-quality-technical-articles/programmer/efficient-book-publishing-and-practice-guide/","title":"程序员高效出书避坑和实践指南","section":"Programmer","content":" 推荐语:详细介绍了程序员出书的一些常见问题,强烈建议有出书想法的朋友看看这篇文章。\n原文地址: https://www.cnblogs.com/JavaArchitect/p/14128202.html\n古有三不朽, 所谓立德、立功、立言。程序员出一本属于自己的书,如果说是立言,可能过于高大上,但终究也算一件雅事。\n出书其实不挣钱,而且从写作到最终拿钱的周期也不短。但程序员如果有一本属于自己的技术书,那至少在面试中能很好地证明自己,也能渐渐地在业内积累自己的名气,面试和做其它事情时也能有不少底气。在本文里,本人就将结合自己的经验和自己踩过的坑,和大家聊聊程序员出书的那些事。\n1.出书的稿酬收益和所需要的时间 # 先说下出书的收益和需要付出的代价,这里姑且先不谈“出书带来的无形资产”,先谈下真金白银的稿酬。\n如果直接和出版社联系,一般稿酬是版税,是书价格的 8%乘以印刷数(或者实际销售数),如果你是大牛的话,还可以往上加,不过一般版税估计也就 10%到 12%。请注意这里的价格是书的全价,不是打折后的价格。\n比如一本书全价是 70 块,在京东等地打 7 折销售,那么版税是 70 块的 8%,也就是说卖出一本作者能有 5.6 的收益,当然真实拿到手以后还再要扣税。\n同时也请注意合同的约定是支付稿酬的方式是印刷数还是实际销售数,我和出版社谈的,一般是印刷数量,这有什么差别呢?现在计算机类的图书一般是首印 2500 册,那么实际拿到手的钱数是 70*8%*2500,当然还要扣税。但如果是按实际销售数量算的话,如果首印才销了 1800 本的话,那么就得按这个数量算钱了。\n现在一本 300 页的书,定价一般在 70 左右,按版税 8%和 2500 册算的话,税前收益是 14000,税后估计是 12000 左右,对新手作者的话,300 的书至少要写 8 个月,由此大家可以算下平均每个月的收益,算下来其实每月也就 1500 的收益,真不多。\n别人的情况我不敢说,但我出书以后,除了稿酬,还有哪些其它的收益呢?\n在当下和之前的公司面试时,告诉面试官我在相关方面出过书以后,面试官就直接会认为我很资深,帮我省了不少事情。 我还在做线下的培训,我就直接拿我最近出的 Python 书做教材了,省得我再备课了。 和别人谈项目,能用我的书证明自己的技术实力,如果是第一次和别人打交道,那么这种证明能立杆见效。 尤其是第一点,其实对一些小公司或者是一些外派开发岗而言,如果候选人在这个方面出过书,甚至都有可能免面试直接录取,本人之前面试过一个大公司的外派岗,就得到过这种待遇。\n2.支付稿酬的时间点和加印后的收益 # 我是和出版社直接联系出书,支付稿酬的时间点一般是在首印后的 3 个月内拿到首印部分稿酬的一部分(具体是 50%到 90%),然后在图书出版后的一年后再拿到其它部分的稿酬。当下有不少书,能销掉首印的册数就不错了,不过也有不少书能加印,甚至出第二和第三版,一般加印册数的版税会在加印后的半年到一年内结清。\n从支付稿酬的时间点上来,对作者确实会有延迟,外加上稿酬也不算高,相对于作者的辛勤劳动,所以出书真不是挣钱的事,而且拿钱的周期还长。如果个别图书公司工作人员一方面在出书阶段对作者没什么帮助, 另一方面还要在中间再挣个差价,那么真有些作践作者的辛勤劳动了。\n3.同图书公司打交道的所见所闻 # 在和出版社编辑沟通前,我也和图书公司的工作人员交流过,不少工作人员对我也是比较尊重,交流虽然不算深入,但也算客气。不过最终对比出版社给出的稿酬等条件,我还是没有通过图书公司出书,这也是比较可惜的事情。下面我给出些具体的经历。\n我经常在博客园等地收到一些图书公司工作人员的留言,问要不要出书,一般我不问,他们不会说自己是出版社编辑还是图书公司的工作人员。有个别图书公司的工作人员,会向作者,尤其是新手作者,说些“出版社编辑一般不会直接和作者联系”,以及“出书一般是通过图书公司”等的话。其实这些话不能算错,比如你不联系出版社编辑,那么对方自然不会直接联系你,但相反如果作者直接和出版社编辑联系,第一没难度,第二可能更直接。 我和出版社编辑交流大纲时,即使大纲有不足,他们也能直接给出具体的修改意见,比如某个章节该写什么,某个小节的大纲该怎么写。而我和个别图书公司的工作人员交流过大纲时,得到的反馈大多是“要重写”,怎么个重写法?这些工作人员可能只能给出抽象的意见,什么都要我自己琢磨。在我之前的博文 程序员怎样出版一本技术书里,我就给出过具体的经历。 由于交流不深,所以我没有和图书公司签订过出书协议,但我知道,只有出版社能出书。由于没有经历过,所以我也不知道图书公司在合同里是否有避规风险等条款,但我见过一位图书公司人员人员给出的一些退稿案例,并隐约流露出对作者的责备之意。细思感觉不妥,对接的工作人员第一不能在出问题的第一时间及时发现并向作者反馈,第二在出问题之后不能对应协调最终导致退稿,第三在退稿之后,作者在付出劳动的情况下图书公司不仅不用承担任何风险,并还能指摘作者。对此,退稿固然有作者的因素,但同是作者的我未免有兔死狐悲之谈。而我在出版社出书时,编辑有时候甚至会主动关心,主动给素材,哪怕有问题也会第一时间修改,所以甚至大范围修改稿件的情况都基本没有出现。 再说下图书公司给作者的稿酬。我见过按页给钱,比如一页 30 到 50 块,并卖断版权,即书重印后作者也无法再得到稿酬,如果是按版税给钱,我也见过给 6%,至于图书公司能否给到 8 个点甚至更高,我没见到过,所以不知道,也不敢擅拟。 我交流过的图书公司工作人员不多,交流也不深,因为我现在主要是和出版社的编辑交流。所以以上只是我对个别图书公司编辑的感受,我无意以偏概全,而和我交流的一些图书公司工作人员至少态度上对我很尊重。所以大家也可以对比尝试下和图书公司以及出版社合作的不同方式。不管怎样,你在写书甚至在签出书协议前,你需要问清楚如下的事项,并且对方有义务让你了解如下的事实。\n你得问清楚,对方的身份是出版社编辑还是图书公司工作人员,这其实应当是对方主动告之。 你的书在哪个出版社出版?这点需要在出书协议里明确给出,不能是先完稿再定出版社。而且,最终能出版书的,一定是出版社,而不是图书公司。 稿酬的支付方式,哪怕图书公司中间可能挣差价,但至少你得了解出版社能给到的稿酬。如果你是通过图书公司出的书,不管图书公司怎么和你谈的,但出版社给图书公司的钱一分不会少,中间部分应该就是图书公司的盈利。 最终和你签订出书合同的,是图书公司还是出版社,这一定得在你签字前搞明白,哪怕你最终是和图书公司签协议,但至少得知道你还能直接和出版社签协议。 你不能存有“在图书公司出书要求低”的想法,更不应该存有“我能力一般,所以只能在图书公司出书”的想法。图书公司自己是没有资格出书的,所以他们也是会把稿件交给出版社,所以该有的要求一点也不会低。你的大纲在出版社编辑那边通不过,那么在图书公司的工作人员那边同样通不过,哪怕你索要的稿酬少,图书公司方面对应的要求一定也不会降低。 如果你明知“图书公司和出版社的差别”,并还是和图书公司合作,这个是两厢情愿的事情。但如果对方“不主动告知”,而你在不了解两者差异的基础上同图书公司合作,那么对方也无可指摘。不过兼听则明,大家如果要出书,不妨和出版社和图书公司都去打打交道对比下。\n4.如何直接同国内计算机图书的知名出版社编辑联系 # 我在清华大学出版社、机械工业出版社、北京大学出版社和电子工业出版社出过书,出书流程也比较顺畅,和编辑打交道也比较愉快。我个人无意把国内出版社划分成三六九等,但计算机行业,比较知名的出版社有清华、机工、电子工业和人邮这四家,当然其它出版社在计算机方面也出版过精品书。\n如何同这些知名出版社的编辑直接打交道?\n直接到官网,一般官网上都直接有联系方式。 你在博客园等地发表文章,会有人找你出书,其中除了图书公司的工作人员外,也有出版社编辑,一般出版社的编辑会直接说明身份,比如我是 xx 出版社的编辑 xx。 本人也和些出版社的编辑联系过,大家如果要,我可以给。 那怎么去找图书公司的工作人员?一般不用主动找,你发表若干博文后,他们会主动找你。如果你细问,“您是出版社编辑还是图书公司的编辑”,他们会表明身份,如果你再细问,那么他们可能会站在图书公司的立场上解释出版社和图书公司的差异。\n从中大家可以看到,不管你最终是否写成书,但去找知名出版社的编辑,并不难。并且,你找到后,他们还会进一步和你交流选题。\n5.定选题和出书的流程 # 这里给出我和出版社编辑交流合作,最终出书的流程。\n第一,联系上出版社编辑后,先讨论选题,你可以选择一个你比较熟悉的方向,或者你愿意专攻的方向,这个方向可以是 java 分布式组件,Spring cloud 全家桶,微服务,或者是 Python 数据分析,机器学习或深度学习等。这方面你如果有扎实的项目经验那最好,如果你当下虽然不熟悉,但你有毅力经过短时间的系统学习确保你写的内容能成系统或者能帮到别人,那么你也可以在这方面出书。\n第二,定好选题方向后,你可以先列出大纲,比如以 Python 数据分析为例,你可以定 12 个章节,第一章讲语法,第二章讲 numpy 类等等,以此类推,你定大纲的时候,可以参考别人书的目录,从而制定你的写作内容。定好大纲以后,你可以和编辑交流,当编辑也认可这个大纲以后,就可以定出版协议。\n对一般作者而言,出版协议其实差不多,稿酬一般是 8 个点,写作周期是和出版社协商,支付周期可能也大同小异,然后出版社会买断这本书的电子以及各种文字的版权。但如果作者是大牛,那么这些细节都可以和出版社协商。\n然后是写书,这是很枯燥的,尤其是写最后几章的时候。我一般是工作日每天用半小时,两天周末周末用 4,5 个小时写,这样一般半年能写完一本 300 页的书,关于高效写书的技巧,后文会详细提及。\n在写书时,一般建议每写好一个章节就交给编辑审阅,这样就不会导致太大问题的出现,而且如果是新手作者,刚开始的措辞和写作技巧都需要积累,这样出版社的编辑在开始阶段也能及时帮到作者。\n当你写完把稿件交到编辑以后,可能会有三校三审的事情,在其中同我合作的编辑会帮助我修改语法和错别字等问题,然后会形成一个修改意见让我确认和修改。我了解下来,如果在图书公司出书,退稿的风险一般就发生在这个阶段,因为图书公司可能是会一次性地把稿件提交给出版社。但由于我会把每个章节都直接提交给出版社编辑审阅,所以即使有大问题,那么在写开始几个章节时都已经暴露并修改,所以最后的修改意见一般不会太长。也就是说,如果是直接和出版社沟通,在三校三审阶段,工作量可能未必大,我一般是在提交一本书以后,由编辑做这个事情,然后我就继续策划并开始写后一本书。\n最后就是拿稿酬,之前已经说了,作者其实不应该对稿酬有太大的期望,也就是聊胜于无。但如果一不小心写了本销量在 5000 乃至 10000 本左右的畅销书,那么可能在一年内也能有 5 万左右的额外收益,并能在业内积累些名气。\n6.出案例书比出经验书要快 # 对一些作者而言,尤其是新手作者,出书不容易,往往是开始几个章节干劲十足,后面发现问题越积越多,外加工作一忙,就不了了之了,或者用 1 年以上的时间才能完成一本书。对此,我的感受是,一本 300 到 400 书的写作周期最长是 8 个月。为了能在这个时间段里完成一本书,我对应给出的建议是,新手作者可以写案例书,别先写介绍经验类的书。\n什么叫案例书?比如一本书里用一个大案例贯穿,系统介绍一个知识点,比如小程序开发,或者全栈开发等。或者一本书一个章节放一个案例,在一本书里给出 10 个左右 Python 深度学习方面的案例。什么叫经验类书呢?比如介绍面试经验的书就属于这这种,或者一些技术大牛写的介绍分布式高并发开发经验的书也算经验类书。\n请注意这里并没有区分两类书的差异,只是对新手作者而言,案例书好写。因为在其中,更多的是看图说话,先给出案例(比如 Python 深度学习里的图像识别案例),然后通过案例介绍 API 的用法(比如 Python 对应库的用法),以及技术的综合要点(比如如何用 Python 库综合实现图像识别功能)。并且案例书里需要作者主观发挥的点比较少,作者无需用自己的话整理相关的经验。对新手作者而言,在组织文字介绍经验时,可能会有自己明白但说不上来的感觉,这样一方面就无法达到预期的效果,另一方面还有可能因为无法有效表述而导致进度的延迟。\n但相反对于案例书,第一案例一般可以借鉴别人的,第二介绍现存的技术总比介绍自己的经验要容易,第三一般还有同类的书可以供作者参考,所以作者不大需要斟酌措辞,新手作者用半年到八个月的时间也有可能写完一本。当作者通过写几本书积累一定经验后,再去挑战经验类书,在这种情况下,写出来的经验类书就有可能畅销了。\n那么具体而言,怎么高效出一本案例书呢?\n对整本书而言,先用少量章节介绍搭建环境和通用基本语法的内容。 在写每个章节案例时,用到总分总的结构,先总体介绍下你这个案例的需求功能,以及要用的技术点,再分开介绍每个功能点的代码实现,最后再总结下这些功能点的使用要点。 在介绍案例中具体代码时,也可以用到总分总的结构,即先总体介绍下这段代码的结构,再分别给出关键代码的说明,最后再给出运行效果并综述其中技术的实现要点。 这样的话,刚开始可以是 1 个月一个章节,写到后面熟练以后估计一个月能写两个章节,这样 8 个月完成一本书,也就不是不可能了。\n7.如何在参考现有内容的基础上避免版权问题 # 写书时,一般多少都需要参考现有的代码和现有的书,但这绝不是重复劳动。比如某位作者整合了不同网站上多个案例,然后系统地讲述了 Python 数据分析,这样虽然现成资料都有,但对读者来说,就能一站式学习。同样地,比如在 Python 神经网络方面,现有 2,3 本书分别给出了若干人脸识别等若干案例,但如果你有效整合到一起,并加他人的基础上加上你的功能,那对读者来说也是有价值的。\n这里就涉及到版权问题,先要说明,作者不能抱有任何幻想,如果出了版权问题,书没出版还好,如果已经出版了,作者不仅要赔钱,而且在业内就会有不好的名声,可谓身败名裂。但其实要避免版权问题一点也不难。\n不能抄袭网上现有的内容,哪怕一句也不行。对此,作者可以在理解人家语句含义的基础上改写。不能抄袭人家书上现有的目录,更不能抄袭人家书上的话,同样一句也不行,对应的解决方法同样是在理解的基础上改写。 不能抄袭 GitHub 上或者任何地方别人的代码,哪怕这个代码是开源的。对此,你可以在理解对方代码的基础上,先运行通,然后一定得自己新建一个项目,在你的项目里参考别人的代码实现你的功能,在这个过程中不能有大段的复制粘贴操作。也就是说,你的代码和别人的代码,在注释,变量命名,类名和方法名上不能有雷同的地方,当然你还可以额外加上你自己的功能。 至于在写技术和案例介绍时,你就可以用你自己的话来说,这样也不会出现版权问题。 用了上述办法以后,作者就可以在参考现有资料的基础上,充分加上属于你的功能,写上你独到的理解,从而高效地出版属于你自己的书。\n8.新手作者需要着着重避免的问题 # 在上文里详细给出了出书的流程,并通过案例书,给出了具体的习作方法,这里就特别针对新手作者,给出些需要注意的实践要点。\n技术书不同于文艺书,在其中首先要确保把技能知识点讲清楚,然后再此基础上可以适当加上些风趣生动的措辞。所以对新手作者而言,甚至可以直接用朴素的文字介绍案例技术,而无需过多考虑文字上的生动性。 内容需要针对初学者,在介绍技术时,从最基本的零基础讲起,别讲太深的。这里以 Python 机器学习为例,可以从什么是机器学习以及 Python 如何实现机器学习讲起,但如果首先就讲机器学习里的实践经验,就未必能确保初学者能学会。 新手作者恨不得把自己知道的都写出来。这种态度非常好,但需要考虑读者的客观接受水平所以需要在写书前设置个预期效果,比如零基础的 Python 开发人员读了我的书以后至少能干活。这个预期效果别不可行,比如不能是“零基础的 Python 开发人员读了我书以后能达到 3 年开发的水准”。这样就可以根据预先制定的效果,制定写作内容,从在你的书就能更着重讲基础知识,这样读者就能有真正有收获。 不过话说回来,如果新手作者直接和出版社编辑联系,找个热门点的方向,并根据案例仔细讲解技术,甚至都有可能写出销量过万的畅销书。\n9.总结:在国内知名出版社出书,其实是个体力活 # 可能当下,写公众号和录视频等的方式,挣钱收益要高于出书,不过话可以这样说,经营公众号和录制视频也是个长期的事情,在短时间里可能未必有收益,如果不是系统地发表内容的话,可能甚至不会有收益。所以出书可能是个非常好的前期准备工作,你靠出书系统积累了素材,靠出书整合了你的知识体系,那么在此基础上,靠公众号或者录视频挣钱可能就会事半功倍。\n从上文里大家可以看到,在出书前期,联系出版社编辑和定选题并不难,如果要写案例书,那么在参考别人内容的基础上,要写完一般书可能也不是高不可攀的事情。甚至可以这样说,出书是个体力活,只要坚持,要出本书并不难,只是你愿不愿意坚持下去的问题。但一旦你有了属于自己的技术书,那么在找工作时,你就能自信地和面试官说你是这方面的专家,在你的视频、公众号和文字里,你也能正大光明地说,你是计算机图书的作者。更为重要的是,和名校、大厂经历一样,属于你的技术书同样是证明程序员能力的重要证据,当你通过出书有效整合了相关方面的知识体系后,那么在这方面,不管是找工作,或者是干私活,或者是接项目做,你都能理直气壮地和别人说:我能行!\n"},{"id":602,"href":"/zh/docs/technology/Interview/high-quality-technical-articles/advanced-programmer/programmer-quickly-learn-new-technology/","title":"程序员如何快速学习新技术","section":"Advanced Programmer","content":" 推荐语:这是 《Java 面试指北》练级攻略篇中的一篇文章,分享了我对于如何快速学习一门新技术的看法。\n很多时候,我们因为工作原因需要快速学习某项技术,进而在项目中应用。或者说,我们想要去面试的公司要求的某项技术我们之前没有接触过,为了应对面试需要,我们需要快速掌握这项技术。\n作为一个人纯自学出生的程序员,这篇文章简单聊聊自己对于如何快速学习某项技术的看法。\n学习任何一门技术的时候,一定要先搞清楚这个技术是为了解决什么问题的。深入学习这个技术的之前,一定先从全局的角度来了解这个技术,思考一下它是由哪些模块构成的,提供了哪些功能,和同类的技术想必它有什么优势。\n比如说我们在学习 Spring 的时候,通过 Spring 官方文档你就可以知道 Spring 最新的技术动态,Spring 包含哪些模块 以及 Spring 可以帮你解决什么问题。\n再比如说我在学习消息队列的时候,我会先去了解这个消息队列一般在系统中有什么作用,帮助我们解决了什么问题。消息队列的种类很多,具体学习研究某个消息队列的时候,我会将其和自己已经学习过的消息队列作比较。像我自己在学习 RocketMQ 的时候,就会先将其和自己曾经学习过的第 1 个消息队列 ActiveMQ 进行比较,思考 RocketMQ 相对于 ActiveMQ 有了哪些提升,解决了 ActiveMQ 的哪些痛点,两者有哪些相似的地方,又有哪些不同的地方。\n学习一个技术最有效最快的办法就是将这个技术和自己之前学到的技术建立连接,形成一个网络。\n然后,我建议你先去看看官方文档的教程,运行一下相关的 Demo ,做一些小项目。\n不过,官方文档通常是英文的,通常只有国产项目以及少部分国外的项目提供了中文文档。并且,官方文档介绍的往往也比较粗糙,不太适合初学者作为学习资料。\n如果你看不太懂官网的文档,你也可以搜索相关的关键词找一些高质量的博客或者视频来看。 一定不要一上来就想着要搞懂这个技术的原理。\n就比如说我们在学习 Spring 框架的时候,我建议你在搞懂 Spring 框架所解决的问题之后,不是直接去开始研究 Spring 框架的原理或者源码,而是先实际去体验一下 Spring 框架提供的核心功能 IoC(Inverse of Control:控制反转) 和 AOP(Aspect-Oriented Programming:面向切面编程),使用 Spring 框架写一些 Demo,甚至是使用 Spring 框架做一些小项目。\n一言以蔽之, 在研究这个技术的原理之前,先要搞懂这个技术是怎么使用的。\n这样的循序渐进的学习过程,可以逐渐帮你建立学习的快感,获得即时的成就感,避免直接研究原理性的知识而被劝退。\n研究某个技术原理的时候,为了避免内容过于抽象,我们同样可以动手实践。\n比如说我们学习 Tomcat 原理的时候,我们发现 Tomcat 的自定义线程池挺有意思,那我们自己也可以手写一个定制版的线程池。再比如我们学习 Dubbo 原理的时候,可以自己动手造一个简易版的 RPC 框架。\n另外,学习项目中需要用到的技术和面试中需要用到的技术其实还是有一些差别的。\n如果你学习某一项技术是为了在实际项目中使用的话,那你的侧重点就是学习这项技术的使用以及最佳实践,了解这项技术在使用过程中可能会遇到的问题。你的最终目标就是这项技术为项目带来了实际的效果,并且,这个效果是正面的。\n如果你学习某一项技术仅仅是为了面试的话,那你的侧重点就应该放在这项技术在面试中最常见的一些问题上,也就是我们常说的八股文。\n很多人一提到八股文,就是一脸不屑。在我看来,如果你不是死记硬背八股文,而是去所思考这些面试题的本质。那你在准备八股文的过程中,同样也能让你加深对这项技术的了解。\n最后,最重要同时也是最难的还是 知行合一!知行合一!知行合一! 不论是编程还是其他领域,最重要不是你知道的有多少,而是要尽量做到知行合一。\n"},{"id":603,"href":"/zh/docs/technology/Interview/high-quality-technical-articles/programmer/how-do-programmers-publish-a-technical-book/","title":"程序员怎样出版一本技术书","section":"Programmer","content":" 推荐语:详细介绍了程序员应该如何从头开始出一本自己的书籍。\n原文地址: https://www.cnblogs.com/JavaArchitect/p/12195219.html\n在面试或联系副业的时候,如果能令人信服地证明自己的实力,那么很有可能事半功倍。如何证明自己的实力?最有信服力的是大公司职位背景背书,没有之一,比如在 BAT 担任资深架构,那么其它话甚至都不用讲了。\n不过,不是每个人入职后马上就是大公司架构师,在上进的路上,还可以通过公众号,专栏博文,GitHub 代码量和出书出视频等方式来证明自己。和其它方式相比,属于自己的技术图书由于经过了国家级出版社的加持,相对更能让别人认可自己的实力,而对于一些小公司而言,一本属于自己的书甚至可以说是免面试的通行证。所以在本文里,就将和广大程序员朋友聊聊出版技术书的那些事。\n1.不是有能力了再出书,而是在出书过程中升能力 # 我知道的不少朋友,是在工作 3 年内出了第一本书,有些优秀的,甚至在校阶段就出书了。\n与之相比还有另外一种态度,不少同学可能想,要等到技术积累到一定程度再写。其实这或许就不怎么积极了,边写书,边升技术,而且写出的书对人还有帮助,这绝对可以做到的。\n比如有同学向深入了解最近比较热门的 Python 数据分析和机器学习,那么就可以在系统性的学习之后,整理之前学习到的爬虫,数据分析和机器学习的案例,根据自己的理解,用适合于初学者的方式整理一下,然后就能出书了。这种书,对资深的人帮助未必大,但由于包含案例,对入门级的读者绝对有帮助,因为这属于现身说法。而且话说回来,如果没有出书这个动力,或者学习过程也就是浅尝辄止,或者未必能全身心地投入,有了出书这个目标,更能保证学习的效果。\n2.适合初级开发,高级开发和架构师写的书 # 之前也提到了,初级开发适合写案例书,就拿 Python 爬虫数据分析机器学习题材为例,可以先找几本这方面现成的书,这些书里,或者章节内容不同,但一起集成看的话,应该可以包含这方面的内容。然后就参考别人书的思路,比如一章写爬虫,一章写 pandas,一章写 matplotlib 等等,整合起来,就可以用 若干个章节构成一本书了。总之,别人书里包含什么内容,你别照抄,但可以参考别人写哪些技术点。\n定好章节后,再定下每个章节的小节,比如第三章讲爬虫案例,那么可以定 3.1 讲爬虫概念,3.2 讲如何搭建 Scrapy 库,3.3 讲如何开发 Scrapy 爬虫案例,通过先章再节的次序,就可以定好一本书的框架。由于是案例书,所以是先给运行通的代码,再用这些代码案例教别人入门,所以案例未必很深,但需要让初学者看了就能懂,而且按照你给出的知识体系逐步学习之后,能理解这个主题的内容。并且,能在看完你这本书以后,能通过调通你给出的爬虫,机器学习等的案例,掌握这一领域的知识,并能从事这方面的基本开发。这个目标,对初级开发而言,稍微用点心,费点时间,应该不难达到。\n而对于高级开发和架构师而言,除了写存粹案例书以外,还可以在书里给出你在大公司里总结出来的开发经验,也就是所谓踩过的坑,比如 Python 在用 matplotlib 会图例时,在设置坐标轴方面有哪些技巧,设置时会遇到哪些常见问题,如果在书里大量包含这种经验,你的书含金量更高。\n此外,高级开发和架构师还可以写一些技术含量更高的书,比如就讲高并发场景下的实践经验,或者 k8s+docker 应对高并发的经验,这种书里,可以给出代码,更可以给出实施方案和架构实施技巧,比如就讲高并发场景里,缓存该如何选型,如何避免击穿,雪崩等场景,如何排查线上 redis 问题,如何设计故障应对预案。除了这条路之外,还可以深入细节,比如通过讲 dubbo 底层代码,告诉大家如何高效配置 dubbo,出了问题该如何排查。如果架构师或高级开发有这类书作为背书,外带大厂工作经验,那么就更可以打出自己的知名度。\n3.可以直接找出版社,也可以找出版公司 # 在我的这篇博文里, 程序员副业那些事:聊聊出书和录视频,给出了通过出版社出书和图书公司出书的差别,供大家参考,大家看了以后可以自行决定出书方式。\n不过不管怎么选,在出书前你得搞明白一些事,或许个别图书出版公司的工作人员不会主动说,这需要你自己问清楚。\n你的合作方是谁?图书出版公司还是出版社? 你的书将在哪个出版社出版?国内比较有名的是清华,人邮,电子和机械,同时其它出版社不能说不好,但业内比较认这四个。 和你沟通的人,是最终有决定权的图书编辑吗?还是图书公司里的工作人员?再啰嗦下,最后能决定书能否出版,以及确定修改意见的,是出版社的编辑。 通过对比出版社和图书出版公司,在搞清楚诸多细节后,大家可以自己斟酌考虑合作的方式。而且,出版社和图书公司的联系方式,在官网上都有,大家可以自行通过邮件等方式联系。\n4.如果别人拿你做试错对象,或有不尊重,赶紧止损 # 我之前看到有图书出版公司招募面向 Java 初学者图书的作者,并且也主动联系过相关人员,得到的反馈大多是:“要重写”。\n比如我列了大纲发过去,反馈是“要重写”,原因是对方没学过 Java,但作为零基础的人看了我的大纲,发现学不会。至于要重写成什么样子 ,对方也说不上来,总之让我再给个大纲,再给一版后,同样没过,这次好些,给了我几本其它类似书的大纲,让我自行看别人有什么好的点。总之不提(或者说提不出)具体的改进点,要我自行尝试各种改进点,试到对方感觉可以为止。\n相比我和几位出版社专业的编辑沟通时,哪怕大纲或稿件有问题,对方会指明到点,并给出具体的修改意见。我不知道图书出版公司里的组织结构,但出版社里,计算机图书有专门的部门,专门的编辑,对方提出的意见都是比较专业,且修改起来很有操作性。\n另外,我在各种渠道,时不时看到有图书出版公司的人员,晒出别人交付的稿件,在众目睽睽之下,说其中有什么问题,意思让大家引以为戒。姑且不论这样做的动机,并且这位工作人员也涂掉了能表面作者身份的信息。但作者出于信任把稿件交到你手上,在不征得作者同意就公开稿件,说“不把作者当回事”,这并不为过。不然,完全可以用私信的方式和作者交流,而不是把作者无心之过公示于众。\n我在和出版社合作时,这类事绝没发生过,而且我认识的出版社编辑,都对各位作者保持着足够的尊重。而且我和我的朋友和多位图书出版公司的朋友交流时,也能得到尊重和礼遇。所以,如果大家在写书时,尤其在写第一本书时,如果遇到被试错,或者从言辞等方面感觉对方不把你当会事,那么可以当即止损。其实也没有什么“损失”,你把当前的大纲和稿件再和出版社编辑交流时,或许你的收益还能提升。\n5.如何写好 30 页篇幅的章节? # 在和出版社定好写作合同后,就可以创作了。书是由章节构成,这里讲下如何构思并创作一个章节。\n比如写爬虫章节,大概 30 页,先定节和目,比如 3.1 搭建爬虫环境是小节,3.1.1 下载 Python Scrapy 包,则是目。先定要写的内容,具体到爬虫小节,可以写 3.1 搭建环境,3.2 Scrapy 的重要模块,3.3 如何开发 Scrapy 爬虫,3.4 开发好以后如何运行,3.5 如何把爬到的信息放入数据库,这些都是小节。\n再具体到目,比如 3.5 里,3.5.1 里写如何搭建数据库环境 3.5.2 里写如何在 Scrapy 里连接数据库 3.5.3 里给出实际案例 3.5.4 里给出运行步骤和示例效果。\n这样可以搭建好一个章的框架,在每个小节里,先给出可以运行通的,而且能说明问题的代码,再给出对代码的说明,再写下代码如何配置,开发时该注意哪些问题,必要时用表格和图来说明,用这样的条理,最多 3 个星期可以完成一个章节,快的话一周半就一个章节。\n以此类推,一本书大概有 12 个章节,第一章可以讲如何安装环境,以及基础语法,后面就可以由浅入深,一个章节一个主题,比如讲 Python 爬虫,第二章可以是讲基础语法,第三章讲 http 协议以及爬虫知识点,以此深入,讲全爬虫,数据分析,数据展示和机器学习等技能。\n按这样算,如果出第一本书,平均下来一个月 2 个章节,大概半年到八个月可以完成一本书,思路就是先搭建书的知识体系,写每个章节时再搭建某个知识点的框架,在小节和目里,用代码结合说明的方式,这样从简到难,大家就可以完成第一本属于自己的书了。\n6.如何写出一本销量过 5 千的书 # 目前纸质书一般一次印刷在 2500 册,大多数书一般就一次印刷,买完为止。如果能销调 5000 本,就属于受欢迎了,如果销量过万,就可以说是大神级书的。这里先不论大神级书,就说下如何写一本过 5000 的畅销书。\n1 最好贴近热点,比如当前热点是全栈开发和机器学习等,如何找热点,就到京东等处去看热销书的关键字。具体操作起来,多和出版社编辑沟通,或许作者更多是从技术角度分析,但出版社的编辑是从市场角度来考虑问题。\n2 如果你的书能被培训机构用作教材,那想不热都不行。培训机构一般用哪些教材呢?第一面向初学者,第二代码全面,第三在这个领域里涵盖知识点全。如果要达成这点,大家可以和出版社的编辑直接沟通,问下相关细节。\n3 可以文字生动,但不能用过于花哨的文字来掩盖书的内涵不足,也就是说畅销书一定要有干货,能解决初学者实际问题,比如 Python 机器学习方向,就写一本用案例涵盖目前常用的机器学习算法,一个章节一种算法,并且案例中有可视化,数据分析,爬虫等要素,可视化的效果如果再吸引人,这本书畅销的可能性也很大。\n4 一定不能心存敷衍,代码调通不算,更力求简洁,说明文字多面向读者,内容上,确保读者一看就会,而且看了有收获,或许这点说起来很抽象,但我写了几本书以后切身体会,要做到这很难,同时做到了,书哪怕不畅想,但至少不误人子弟。\n7.总结,出书仅是一个里程碑,程序员在上进路上应永不停息 # 出书不简单,因为不是每个人都愿意在半年到八个月里,每个晚上每个周末都费时费力写书。但出书也不难,毕竟时间用上去了,出书也只是调试代码加写文字的活,最多再外加些和人沟通的成本。\n其实出书收益并不高,算下来月入大概能在 3k 左右,如果是和图书出版公司合作,估计更少,但这好歹能证明自己的实力。不过在出书后不能止步于此,因为在大厂里有太多的牛人,甚至不用靠出书来证明自己的实力。\n那么如何让出书带来的利益最大化呢?第一可以靠这进大厂,面试时有自己的书绝对是加分项。第二可以用这个去各大网站开专栏,录视频,或者开公众号,毕竟有出版社的背书,能更让别人信服你的能力。第三更得用写书时积累的学习方法和上进的态势继续专研更高深技术,技术有了,不仅能到大厂挣更多的钱,还能通过企业培训等方式更高效地挣钱。\n"},{"id":604,"href":"/zh/docs/technology/Interview/high-quality-technical-articles/programmer/high-value-certifications-for-programmers/","title":"程序员最该拿的几种高含金量证书","section":"Programmer","content":"证书是能有效证明自己能力的好东西,它就是你实力的象征。在短短的面试时间内,证书可以为你加不少分。通过考证来提升自己,是一种性价比很高的办法。不过,相比金融、建筑、医疗等行业,IT 行业的职业资格证书并没有那么多。\n下面我总结了一下程序员可以考的一些常见证书。\n软考 # 全国计算机技术与软件专业技术资格(水平)考试,简称“软考”,是国内认可度较高的一项计算机技术资格认证。尽管一些人吐槽其实际价值,但在特定领域和情况下,它还是非常有用的,例如软考证书在国企和事业单位中具有较高的认可度、在某些城市软考证书可以用于积分落户、可用于个税补贴。\n软考有初、中、高三个级别,建议直接考高级。相比于 PMP(项目管理专业人士认证),软考高项的难度更大,特别是论文部分,绝大部分人都挂在了论文部分。过了软考高项,在一些单位可以内部挂证,每个月多拿几百。\n官网地址: https://www.ruankao.org.cn/。\n备考建议: 2024 年上半年,一次通过软考高级架构师考试的备考秘诀 - 阿里云开发者\nPAT # 攀拓计算机能力测评(PAT)是一个专注于考察算法能力的测评体系,由浙江大学主办。该测评分为四个级别:基础级、乙级、甲级和顶级。\n通过 PAT 测评并达到联盟企业规定的相应评级和分数,可以跳过学历门槛,免除筛选简历和笔试环节,直接获得面试机会。具体有哪些公司可以去官网看看: https://www.patest.cn/company 。\n对于考研浙江大学的同学来说,PAT(甲级)成绩在一年内可以作为硕士研究生招生考试上机复试成绩。\nPMP # PMP(Project Management Professional)认证由美国项目管理协会(PMI)提供,是全球范围内认可度最高的项目管理专业人士资格认证。PMP 认证旨在提升项目管理专业人士的知识和技能,确保项目顺利完成。\nPMP 是“一证在手,全球通用”的资格认证,对项目管理人士来说,PMP 证书含金量还是比较高的。放眼全球,很多成功企业都会将 PMP 认证作为项目经理的入职标准。\n但是!真正有价值的不是 PMP 证书,而是《PMBOK》 那套项目管理体系,在《PMBOK》(PMP 考试指定用书)中也包含了非常多商业活动、实业项目、组织规划、建筑行业等各个领域的项目案例。\n另外,PMP 证书不是一个高大上的证书,而是一个基础的证书。\nACP # ACP(Agile Certified Practitioner)认证同样由美国项目管理协会(PMI)提供,是项目管理领域的另一个重要认证。与 PMP(Project Management Professional)注重传统的瀑布方法论不同,ACP 专注于敏捷项目管理方法论,如 Scrum、Kanban、Lean、Extreme Programming(XP)等。\nOCP # Oracle Certified Professional(OCP)是 Oracle 公司提供的一项专业认证,专注于 Oracle 数据库及相关技术。这个认证旨在验证和认证个人在使用和管理 Oracle 数据库方面的专业知识和技能。\n下图展示了 Oracle 认证的不同路径和相应的认证级别,分别是核心路径(Core Track)和专业路径(Speciality Track)。\n阿里云认证 # 阿里云(Alibaba Cloud)提供的专业认证,认证方向包括云计算、大数据、人工智能、Devops 等。职业认证分为 ACA、ACP、ACE 三个等级,除了职业认证之外,还有一个开发者 Clouder 认证,这是专门为开发者设立的专项技能认证。\n官网地址: https://edu.aliyun.com/certification/。\n华为认证 # 华为认证是由华为技术有限公司提供的面向 ICT(信息与通信技术)领域的专业认证,认证方向包括网络、存储、云计算、大数据、人工智能等,非常庞大的认证体系。\nAWS 认证 # AWS 云认证考试是 AWS 云计算服务的官方认证考试,旨在验证 IT 专业人士在设计、部署和管理 AWS 基础架构方面的技能。\nAWS 认证分为多个级别,包括基础级、从业者级、助理级、专业级和专家级(Specialty),涵盖多个角色和技能:\n基础级别:AWS Certified Cloud Practitioner,适合初学者,验证对 AWS 基础知识的理解,是最简单的入门认证。 助理级别:包括 AWS Certified Solutions Architect – Associate、AWS Certified Developer – Associate 和 AWS Certified SysOps Administrator – Associate,适合中级专业人士,验证其设计、开发和管理 AWS 应用的能力。 专业级别:包括 AWS Certified Solutions Architect – Professional 和 AWS Certified DevOps Engineer – Professional,适合高级专业人士,验证其在复杂和大规模 AWS 环境中的能力。 专家级别:包括 AWS Certified Advanced Networking – Specialty、AWS Certified Big Data – Specialty 等,专注于特定技术领域的深度知识和技能。 备考建议: 小白入门云计算的最佳方式,是去考一张 AWS 的证书(附备考经验)\nGoogle Cloud 认证 # 与 AWS 认证不同,Google Cloud 认证只有一门助理级认证(Associate Cloud Engineer),其他大部分为专业级(专家级)认证。\n备考建议: 如何备考谷歌云认证\n官网地址: https://cloud.google.com/certification\n微软认证 # 微软的认证体系主要针对其 Azure 云平台,分为基础级别、助理级别和专家级别,认证方向包括云计算、数据管理、开发、生产力工具等。\nElastic 认证 # Elastic 认证是由 Elastic 公司提供的一系列专业认证,旨在验证个人在使用 Elastic Stack(包括 Elasticsearch、Logstash、Kibana 、Beats 等)方面的技能和知识。\n如果你在日常开发核心工作是负责 ElasticSearch 相关业务的话,还是比较建议考的,含金量挺高。\n目前 Elastic 认证证书分为四类:Elastic Certified Engineer、Elastic Certified Analyst、Elastic Certified Observability Engineer、Elastic Certified SIEM Specialist。\n比较建议考 Elastic Certified Engineer,这个是 Elastic Stack 的基础认证,考察安装、配置、管理和维护 Elasticsearch 集群等核心技能。\n其他 # PostgreSQL 认证:国内的 PostgreSQL 认证分为专员级(PCA)、专家级(PCP)和大师级(PCM),主要考查 PostgreSQL 数据库管理和优化,价格略贵,不是很推荐。 Kubernetes 认证:Cloud Native Computing Foundation (CNCF) 提供了几个官方认证,例如 Certified Kubernetes Administrator (CKA)、Certified Kubernetes Application Developer (CKAD),主要考察 Kubernetes 方面的技能和知识。 "},{"id":605,"href":"/zh/docs/technology/Interview/java/concurrent/reentrantlock/","title":"从ReentrantLock的实现看AQS的原理及应用","section":"Concurrent","content":" 本文转载自: https://tech.meituan.com/2019/12/05/aqs-theory-and-apply.html\n作者:美团技术团队\nJava 中的大部分同步类(Semaphore、ReentrantLock 等)都是基于 AbstractQueuedSynchronizer(简称为 AQS)实现的。AQS 是一种提供了原子式管理同步状态、阻塞和唤醒线程功能以及队列模型的简单框架。\n本文会从应用层逐渐深入到原理层,并通过 ReentrantLock 的基本特性和 ReentrantLock 与 AQS 的关联,来深入解读 AQS 相关独占锁的知识点,同时采取问答的模式来帮助大家理解 AQS。由于篇幅原因,本篇文章主要阐述 AQS 中独占锁的逻辑和 Sync Queue,不讲述包含共享锁和 Condition Queue 的部分(本篇文章核心为 AQS 原理剖析,只是简单介绍了 ReentrantLock,感兴趣同学可以阅读一下 ReentrantLock 的源码)。\n1 ReentrantLock # 1.1 ReentrantLock 特性概览 # ReentrantLock 意思为可重入锁,指的是一个线程能够对一个临界资源重复加锁。为了帮助大家更好地理解 ReentrantLock 的特性,我们先将 ReentrantLock 跟常用的 Synchronized 进行比较,其特性如下(蓝色部分为本篇文章主要剖析的点):\n下面通过伪代码,进行更加直观的比较:\n// ==========================Synchronized的使用方式========================== // 1.用于代码块 synchronized (this) {} // 2.用于对象 synchronized (object) {} // 3.用于方法 public synchronized void test () {} // 4.可重入 for (int i = 0; i \u0026lt; 100; i++) { synchronized (this) {} } // ==========================ReentrantLock的使用方式========================== public void test () throw Exception { // 1.初始化选择公平锁、非公平锁 ReentrantLock lock = new ReentrantLock(true); // 2.可用于代码块 lock.lock(); try { try { // 3.支持多种加锁方式,比较灵活; 具有可重入特性 if(lock.tryLock(100, TimeUnit.MILLISECONDS)){ } } finally { // 4.手动释放锁 lock.unlock() } } finally { lock.unlock(); } } 1.2 ReentrantLock 与 AQS 的关联 # 通过上文我们已经了解,ReentrantLock 支持公平锁和非公平锁(关于公平锁和非公平锁的原理分析,可参考《 不可不说的 Java“锁”事》),并且 ReentrantLock 的底层就是由 AQS 来实现的。那么 ReentrantLock 是如何通过公平锁和非公平锁与 AQS 关联起来呢? 我们着重从这两者的加锁过程来理解一下它们与 AQS 之间的关系(加锁过程中与 AQS 的关联比较明显,解锁流程后续会介绍)。\n非公平锁源码中的加锁流程如下:\n// java.util.concurrent.locks.ReentrantLock#NonfairSync // 非公平锁 static final class NonfairSync extends Sync { ... final void lock() { if (compareAndSetState(0, 1)) setExclusiveOwnerThread(Thread.currentThread()); else acquire(1); } ... } 这块代码的含义为:\n若通过 CAS 设置变量 State(同步状态)成功,也就是获取锁成功,则将当前线程设置为独占线程。 若通过 CAS 设置变量 State(同步状态)失败,也就是获取锁失败,则进入 Acquire 方法进行后续处理。 第一步很好理解,但第二步获取锁失败后,后续的处理策略是怎么样的呢?这块可能会有以下思考:\n某个线程获取锁失败的后续流程是什么呢?有以下两种可能: (1) 将当前线程获锁结果设置为失败,获取锁流程结束。这种设计会极大降低系统的并发度,并不满足我们实际的需求。所以就需要下面这种流程,也就是 AQS 框架的处理流程。\n(2) 存在某种排队等候机制,线程继续等待,仍然保留获取锁的可能,获取锁流程仍在继续。\n对于问题 1 的第二种情况,既然说到了排队等候机制,那么就一定会有某种队列形成,这样的队列是什么数据结构呢? 处于排队等候机制中的线程,什么时候可以有机会获取锁呢? 如果处于排队等候机制中的线程一直无法获取锁,还是需要一直等待吗,还是有别的策略来解决这一问题? 带着非公平锁的这些问题,再看下公平锁源码中获锁的方式:\n// java.util.concurrent.locks.ReentrantLock#FairSync static final class FairSync extends Sync { ... final void lock() { acquire(1); } ... } 看到这块代码,我们可能会存在这种疑问:Lock 函数通过 Acquire 方法进行加锁,但是具体是如何加锁的呢?\n结合公平锁和非公平锁的加锁流程,虽然流程上有一定的不同,但是都调用了 Acquire 方法,而 Acquire 方法是 FairSync 和 UnfairSync 的父类 AQS 中的核心方法。\n对于上边提到的问题,其实在 ReentrantLock 类源码中都无法解答,而这些问题的答案,都是位于 Acquire 方法所在的类 AbstractQueuedSynchronizer 中,也就是本文的核心——AQS。下面我们会对 AQS 以及 ReentrantLock 和 AQS 的关联做详细介绍(相关问题答案会在 2.3.5 小节中解答)。\n2 AQS # 首先,我们通过下面的架构图来整体了解一下 AQS 框架:\n上图中有颜色的为 Method,无颜色的为 Attribution。 总的来说,AQS 框架共分为五层,自上而下由浅入深,从 AQS 对外暴露的 API 到底层基础数据。 当有自定义同步器接入时,只需重写第一层所需要的部分方法即可,不需要关注底层具体的实现流程。当自定义同步器进行加锁或者解锁操作时,先经过第一层的 API 进入 AQS 内部方法,然后经过第二层进行锁的获取,接着对于获取锁失败的流程,进入第三层和第四层的等待队列处理,而这些处理方式均依赖于第五层的基础数据提供层。 下面我们会从整体到细节,从流程到方法逐一剖析 AQS 框架,主要分析过程如下:\n2.1 原理概览 # AQS 核心思想是,如果被请求的共享资源空闲,那么就将当前请求资源的线程设置为有效的工作线程,将共享资源设置为锁定状态;如果共享资源被占用,就需要一定的阻塞等待唤醒机制来保证锁分配。这个机制主要用的是 CLH 队列的变体实现的,将暂时获取不到锁的线程加入到队列中。\nCLH:Craig、Landin and Hagersten 队列,是单向链表,AQS 中的队列是 CLH 变体的虚拟双向队列(FIFO),AQS 是通过将每条请求共享资源的线程封装成一个节点来实现锁的分配。\n主要原理图如下:\nAQS 使用一个 Volatile 的 int 类型的成员变量来表示同步状态,通过内置的 FIFO 队列来完成资源获取的排队工作,通过 CAS 完成对 State 值的修改。\n2.1.1 AQS 数据结构 # 先来看下 AQS 中最基本的数据结构——Node,Node 即为上面 CLH 变体队列中的节点。\n解释一下几个方法和属性值的含义:\n方法和属性值 含义 waitStatus 当前节点在队列中的状态 thread 表示处于该节点的线程 prev 前驱指针 predecessor 返回前驱节点,没有的话抛出 npe nextWaiter 指向下一个处于 CONDITION 状态的节点(由于本篇文章不讲述 Condition Queue 队列,这个指针不多介绍) next 后继指针 线程两种锁的模式:\n模式 含义 SHARED 表示线程以共享的模式等待锁 EXCLUSIVE 表示线程正在以独占的方式等待锁 waitStatus 有下面几个枚举值:\n枚举 含义 0 当一个 Node 被初始化的时候的默认值 CANCELLED 为 1,表示线程获取锁的请求已经取消了 CONDITION 为-2,表示节点在等待队列中,节点线程等待唤醒 PROPAGATE 为-3,当前线程处在 SHARED 情况下,该字段才会使用 SIGNAL 为-1,表示线程已经准备好了,就等资源释放了 2.1.2 同步状态 State # 在了解数据结构后,接下来了解一下 AQS 的同步状态——State。AQS 中维护了一个名为 state 的字段,意为同步状态,是由 Volatile 修饰的,用于展示当前临界资源的获锁情况。\n// java.util.concurrent.locks.AbstractQueuedSynchronizer private volatile int state; 下面提供了几个访问这个字段的方法:\n方法名 描述 protected final int getState() 获取 State 的值 protected final void setState(int newState) 设置 State 的值 protected final boolean compareAndSetState(int expect, int update) 使用 CAS 方式更新 State 这几个方法都是 Final 修饰的,说明子类中无法重写它们。我们可以通过修改 State 字段表示的同步状态来实现多线程的独占模式和共享模式(加锁过程)。\n对于我们自定义的同步工具,需要自定义获取同步状态和释放状态的方式,也就是 AQS 架构图中的第一层:API 层。\n2.2 AQS 重要方法与 ReentrantLock 的关联 # 从架构图中可以得知,AQS 提供了大量用于自定义同步器实现的 Protected 方法。自定义同步器实现的相关方法也只是为了通过修改 State 字段来实现多线程的独占模式或者共享模式。自定义同步器需要实现以下方法(ReentrantLock 需要实现的方法如下,并不是全部):\n方法名 描述 protected boolean isHeldExclusively() 该线程是否正在独占资源。只有用到 Condition 才需要去实现它。 protected boolean tryAcquire(int arg) 独占方式。arg 为获取锁的次数,尝试获取资源,成功则返回 True,失败则返回 False。 protected boolean tryRelease(int arg) 独占方式。arg 为释放锁的次数,尝试释放资源,成功则返回 True,失败则返回 False。 protected int tryAcquireShared(int arg) 共享方式。arg 为获取锁的次数,尝试获取资源。负数表示失败;0 表示成功,但没有剩余可用资源;正数表示成功,且有剩余资源。 protected boolean tryReleaseShared(int arg) 共享方式。arg 为释放锁的次数,尝试释放资源,如果释放后允许唤醒后续等待结点返回 True,否则返回 False。 一般来说,自定义同步器要么是独占方式,要么是共享方式,它们也只需实现 tryAcquire-tryRelease、tryAcquireShared-tryReleaseShared 中的一种即可。AQS 也支持自定义同步器同时实现独占和共享两种方式,如 ReentrantReadWriteLock。ReentrantLock 是独占锁,所以实现了 tryAcquire-tryRelease。\n以非公平锁为例,这里主要阐述一下非公平锁与 AQS 之间方法的关联之处,具体每一处核心方法的作用会在文章后面详细进行阐述。\n🐛 修正(参见: issue#1761): 图中的一处小错误,(AQS)CAS 修改共享资源 State 成功之后应该是获取锁成功(非公平锁)。\n对应的源码如下:\nfinal boolean nonfairTryAcquire(int acquires) { final Thread current = Thread.currentThread();//获取当前线程 int c = getState(); if (c == 0) { if (compareAndSetState(0, acquires)) {//CAS抢锁 setExclusiveOwnerThread(current);//设置当前线程为独占线程 return true;//抢锁成功 } } else if (current == getExclusiveOwnerThread()) { int nextc = c + acquires; if (nextc \u0026lt; 0) // overflow throw new Error(\u0026#34;Maximum lock count exceeded\u0026#34;); setState(nextc); return true; } return false; } 为了帮助大家理解 ReentrantLock 和 AQS 之间方法的交互过程,以非公平锁为例,我们将加锁和解锁的交互流程单独拎出来强调一下,以便于对后续内容的理解。\n加锁:\n通过 ReentrantLock 的加锁方法 Lock 进行加锁操作。 会调用到内部类 Sync 的 Lock 方法,由于 Sync#lock 是抽象方法,根据 ReentrantLock 初始化选择的公平锁和非公平锁,执行相关内部类的 Lock 方法,本质上都会执行 AQS 的 Acquire 方法。 AQS 的 Acquire 方法会执行 tryAcquire 方法,但是由于 tryAcquire 需要自定义同步器实现,因此执行了 ReentrantLock 中的 tryAcquire 方法,由于 ReentrantLock 是通过公平锁和非公平锁内部类实现的 tryAcquire 方法,因此会根据锁类型不同,执行不同的 tryAcquire。 tryAcquire 是获取锁逻辑,获取失败后,会执行框架 AQS 的后续逻辑,跟 ReentrantLock 自定义同步器无关。 解锁:\n通过 ReentrantLock 的解锁方法 Unlock 进行解锁。 Unlock 会调用内部类 Sync 的 Release 方法,该方法继承于 AQS。 Release 中会调用 tryRelease 方法,tryRelease 需要自定义同步器实现,tryRelease 只在 ReentrantLock 中的 Sync 实现,因此可以看出,释放锁的过程,并不区分是否为公平锁。 释放成功后,所有处理由 AQS 框架完成,与自定义同步器无关。 通过上面的描述,大概可以总结出 ReentrantLock 加锁解锁时 API 层核心方法的映射关系。\n3 通过 ReentrantLock 理解 AQS # ReentrantLock 中公平锁和非公平锁在底层是相同的,这里以非公平锁为例进行分析。\n在非公平锁中,有一段这样的代码:\n// java.util.concurrent.locks.ReentrantLock static final class NonfairSync extends Sync { ... final void lock() { if (compareAndSetState(0, 1)) setExclusiveOwnerThread(Thread.currentThread()); else acquire(1); } ... } 看一下这个 Acquire 是怎么写的:\n// java.util.concurrent.locks.AbstractQueuedSynchronizer public final void acquire(int arg) { if (!tryAcquire(arg) \u0026amp;\u0026amp; acquireQueued(addWaiter(Node.EXCLUSIVE), arg)) selfInterrupt(); } 再看一下 tryAcquire 方法:\n// java.util.concurrent.locks.AbstractQueuedSynchronizer protected boolean tryAcquire(int arg) { throw new UnsupportedOperationException(); } 可以看出,这里只是 AQS 的简单实现,具体获取锁的实现方法是由各自的公平锁和非公平锁单独实现的(以 ReentrantLock 为例)。如果该方法返回了 True,则说明当前线程获取锁成功,就不用往后执行了;如果获取失败,就需要加入到等待队列中。下面会详细解释线程是何时以及怎样被加入进等待队列中的。\n3.1 线程加入等待队列 # 3.1.1 加入队列的时机 # 当执行 Acquire(1)时,会通过 tryAcquire 获取锁。在这种情况下,如果获取锁失败,就会调用 addWaiter 加入到等待队列中去。\n3.1.2 如何加入队列 # 获取锁失败后,会执行 addWaiter(Node.EXCLUSIVE)加入等待队列,具体实现方法如下:\n// java.util.concurrent.locks.AbstractQueuedSynchronizer private Node addWaiter(Node mode) { Node node = new Node(Thread.currentThread(), mode); // Try the fast path of enq; backup to full enq on failure Node pred = tail; if (pred != null) { node.prev = pred; if (compareAndSetTail(pred, node)) { pred.next = node; return node; } } enq(node); return node; } private final boolean compareAndSetTail(Node expect, Node update) { return unsafe.compareAndSwapObject(this, tailOffset, expect, update); } 主要的流程如下:\n通过当前的线程和锁模式新建一个节点。 Pred 指针指向尾节点 Tail。 将 New 中 Node 的 Prev 指针指向 Pred。 通过 compareAndSetTail 方法,完成尾节点的设置。这个方法主要是对 tailOffset 和 Expect 进行比较,如果 tailOffset 的 Node 和 Expect 的 Node 地址是相同的,那么设置 Tail 的值为 Update 的值。 // java.util.concurrent.locks.AbstractQueuedSynchronizer static { try { stateOffset = unsafe.objectFieldOffset(AbstractQueuedSynchronizer.class.getDeclaredField(\u0026#34;state\u0026#34;)); headOffset = unsafe.objectFieldOffset(AbstractQueuedSynchronizer.class.getDeclaredField(\u0026#34;head\u0026#34;)); tailOffset = unsafe.objectFieldOffset(AbstractQueuedSynchronizer.class.getDeclaredField(\u0026#34;tail\u0026#34;)); waitStatusOffset = unsafe.objectFieldOffset(Node.class.getDeclaredField(\u0026#34;waitStatus\u0026#34;)); nextOffset = unsafe.objectFieldOffset(Node.class.getDeclaredField(\u0026#34;next\u0026#34;)); } catch (Exception ex) { throw new Error(ex); } } 从 AQS 的静态代码块可以看出,都是获取一个对象的属性相对于该对象在内存当中的偏移量,这样我们就可以根据这个偏移量在对象内存当中找到这个属性。tailOffset 指的是 tail 对应的偏移量,所以这个时候会将 new 出来的 Node 置为当前队列的尾节点。同时,由于是双向链表,也需要将前一个节点指向尾节点。\n如果 Pred 指针是 Null(说明等待队列中没有元素),或者当前 Pred 指针和 Tail 指向的位置不同(说明被别的线程已经修改),就需要看一下 Enq 的方法。 // java.util.concurrent.locks.AbstractQueuedSynchronizer private Node enq(final Node node) { for (;;) { Node t = tail; if (t == null) { // Must initialize if (compareAndSetHead(new Node())) tail = head; } else { node.prev = t; if (compareAndSetTail(t, node)) { t.next = node; return t; } } } } 如果没有被初始化,需要进行初始化一个头结点出来。但请注意,初始化的头结点并不是当前线程节点,而是调用了无参构造函数的节点。如果经历了初始化或者并发导致队列中有元素,则与之前的方法相同。其实,addWaiter 就是一个在双端链表添加尾节点的操作,需要注意的是,双端链表的头结点是一个无参构造函数的头结点。\n总结一下,线程获取锁的时候,过程大体如下:\n1、当没有线程获取到锁时,线程 1 获取锁成功。\n2、线程 2 申请锁,但是锁被线程 1 占有。\n3、如果再有线程要获取锁,依次在队列中往后排队即可。\n回到上边的代码,hasQueuedPredecessors 是公平锁加锁时判断等待队列中是否存在有效节点的方法。如果返回 False,说明当前线程可以争取共享资源;如果返回 True,说明队列中存在有效节点,当前线程必须加入到等待队列中。\n// java.util.concurrent.locks.ReentrantLock public final boolean hasQueuedPredecessors() { // The correctness of this depends on head being initialized // before tail and on head.next being accurate if the current // thread is first in queue. Node t = tail; // Read fields in reverse initialization order Node h = head; Node s; return h != t \u0026amp;\u0026amp; ((s = h.next) == null || s.thread != Thread.currentThread()); } 看到这里,我们理解一下 h != t \u0026amp;\u0026amp; ((s = h.next) == null || s.thread != Thread.currentThread());为什么要判断的头结点的下一个节点?第一个节点储存的数据是什么?\n双向链表中,第一个节点为虚节点,其实并不存储任何信息,只是占位。真正的第一个有数据的节点,是在第二个节点开始的。当 h != t 时:如果(s = h.next) == null,等待队列正在有线程进行初始化,但只是进行到了 Tail 指向 Head,没有将 Head 指向 Tail,此时队列中有元素,需要返回 True(这块具体见下边代码分析)。 如果(s = h.next) != null,说明此时队列中至少有一个有效节点。如果此时 s.thread == Thread.currentThread(),说明等待队列的第一个有效节点中的线程与当前线程相同,那么当前线程是可以获取资源的;如果 s.thread != Thread.currentThread(),说明等待队列的第一个有效节点线程与当前线程不同,当前线程必须加入进等待队列。\n// java.util.concurrent.locks.AbstractQueuedSynchronizer#enq if (t == null) { // Must initialize if (compareAndSetHead(new Node())) tail = head; } else { node.prev = t; if (compareAndSetTail(t, node)) { t.next = node; return t; } } 节点入队不是原子操作,所以会出现短暂的 head != tail,此时 Tail 指向最后一个节点,而且 Tail 指向 Head。如果 Head 没有指向 Tail(可见 5、6、7 行),这种情况下也需要将相关线程加入队列中。所以这块代码是为了解决极端情况下的并发问题。\n3.1.3 等待队列中线程出队列时机 # 回到最初的源码:\n// java.util.concurrent.locks.AbstractQueuedSynchronizer public final void acquire(int arg) { if (!tryAcquire(arg) \u0026amp;\u0026amp; acquireQueued(addWaiter(Node.EXCLUSIVE), arg)) selfInterrupt(); } 上文解释了 addWaiter 方法,这个方法其实就是把对应的线程以 Node 的数据结构形式加入到双端队列里,返回的是一个包含该线程的 Node。而这个 Node 会作为参数,进入到 acquireQueued 方法中。acquireQueued 方法可以对排队中的线程进行“获锁”操作。\n总的来说,一个线程获取锁失败了,被放入等待队列,acquireQueued 会把放入队列中的线程不断去获取锁,直到获取成功或者不再需要获取(中断)。\n下面我们从“何时出队列?”和“如何出队列?”两个方向来分析一下 acquireQueued 源码:\n// java.util.concurrent.locks.AbstractQueuedSynchronizer final boolean acquireQueued(final Node node, int arg) { // 标记是否成功拿到资源 boolean failed = true; try { // 标记等待过程中是否中断过 boolean interrupted = false; // 开始自旋,要么获取锁,要么中断 for (;;) { // 获取当前节点的前驱节点 final Node p = node.predecessor(); // 如果p是头结点,说明当前节点在真实数据队列的首部,就尝试获取锁(别忘了头结点是虚节点) if (p == head \u0026amp;\u0026amp; tryAcquire(arg)) { // 获取锁成功,头指针移动到当前node setHead(node); p.next = null; // help GC failed = false; return interrupted; } // 说明p为头节点且当前没有获取到锁(可能是非公平锁被抢占了)或者是p不为头结点,这个时候就要判断当前node是否要被阻塞(被阻塞条件:前驱节点的waitStatus为-1),防止无限循环浪费资源。具体两个方法下面细细分析 if (shouldParkAfterFailedAcquire(p, node) \u0026amp;\u0026amp; parkAndCheckInterrupt()) interrupted = true; } } finally { if (failed) cancelAcquire(node); } } 注:setHead 方法是把当前节点置为虚节点,但并没有修改 waitStatus,因为它是一直需要用的数据。\n// java.util.concurrent.locks.AbstractQueuedSynchronizer private void setHead(Node node) { head = node; node.thread = null; node.prev = null; } // java.util.concurrent.locks.AbstractQueuedSynchronizer // 靠前驱节点判断当前线程是否应该被阻塞 private static boolean shouldParkAfterFailedAcquire(Node pred, Node node) { // 获取头结点的节点状态 int ws = pred.waitStatus; // 说明头结点处于唤醒状态 if (ws == Node.SIGNAL) return true; // 通过枚举值我们知道waitStatus\u0026gt;0是取消状态 if (ws \u0026gt; 0) { do { // 循环向前查找取消节点,把取消节点从队列中剔除 node.prev = pred = pred.prev; } while (pred.waitStatus \u0026gt; 0); pred.next = node; } else { // 设置前任节点等待状态为SIGNAL compareAndSetWaitStatus(pred, ws, Node.SIGNAL); } return false; } parkAndCheckInterrupt 主要用于挂起当前线程,阻塞调用栈,返回当前线程的中断状态。\n// java.util.concurrent.locks.AbstractQueuedSynchronizer private final boolean parkAndCheckInterrupt() { LockSupport.park(this); return Thread.interrupted(); } 上述方法的流程图如下:\n从上图可以看出,跳出当前循环的条件是当“前置节点是头结点,且当前线程获取锁成功”。为了防止因死循环导致 CPU 资源被浪费,我们会判断前置节点的状态来决定是否要将当前线程挂起,具体挂起流程用流程图表示如下(shouldParkAfterFailedAcquire 流程):\n从队列中释放节点的疑虑打消了,那么又有新问题了:\nshouldParkAfterFailedAcquire 中取消节点是怎么生成的呢?什么时候会把一个节点的 waitStatus 设置为-1? 是在什么时间释放节点通知到被挂起的线程呢? 3.2 CANCELLED 状态节点生成 # acquireQueued 方法中的 Finally 代码:\n// java.util.concurrent.locks.AbstractQueuedSynchronizer final boolean acquireQueued(final Node node, int arg) { boolean failed = true; try { ... for (;;) { final Node p = node.predecessor(); if (p == head \u0026amp;\u0026amp; tryAcquire(arg)) { ... failed = false; ... } ... } finally { if (failed) cancelAcquire(node); } } 通过 cancelAcquire 方法,将 Node 的状态标记为 CANCELLED。接下来,我们逐行来分析这个方法的原理:\n// java.util.concurrent.locks.AbstractQueuedSynchronizer private void cancelAcquire(Node node) { // 将无效节点过滤 if (node == null) return; // 设置该节点不关联任何线程,也就是虚节点 node.thread = null; Node pred = node.prev; // 通过前驱节点,跳过取消状态的node while (pred.waitStatus \u0026gt; 0) node.prev = pred = pred.prev; // 获取过滤后的前驱节点的后继节点 Node predNext = pred.next; // 把当前node的状态设置为CANCELLED node.waitStatus = Node.CANCELLED; // 如果当前节点是尾节点,将从后往前的第一个非取消状态的节点设置为尾节点 // 更新失败的话,则进入else,如果更新成功,将tail的后继节点设置为null if (node == tail \u0026amp;\u0026amp; compareAndSetTail(node, pred)) { compareAndSetNext(pred, predNext, null); } else { int ws; // 如果当前节点不是head的后继节点,1:判断当前节点前驱节点的是否为SIGNAL,2:如果不是,则把前驱节点设置为SIGNAL看是否成功 // 如果1和2中有一个为true,再判断当前节点的线程是否为null // 如果上述条件都满足,把当前节点的前驱节点的后继指针指向当前节点的后继节点 if (pred != head \u0026amp;\u0026amp; ((ws = pred.waitStatus) == Node.SIGNAL || (ws \u0026lt;= 0 \u0026amp;\u0026amp; compareAndSetWaitStatus(pred, ws, Node.SIGNAL))) \u0026amp;\u0026amp; pred.thread != null) { Node next = node.next; if (next != null \u0026amp;\u0026amp; next.waitStatus \u0026lt;= 0) compareAndSetNext(pred, predNext, next); } else { // 如果当前节点是head的后继节点,或者上述条件不满足,那就唤醒当前节点的后继节点 unparkSuccessor(node); } node.next = node; // help GC } } 当前的流程:\n获取当前节点的前驱节点,如果前驱节点的状态是 CANCELLED,那就一直往前遍历,找到第一个 waitStatus \u0026lt;= 0 的节点,将找到的 Pred 节点和当前 Node 关联,将当前 Node 设置为 CANCELLED。 根据当前节点的位置,考虑以下三种情况: (1) 当前节点是尾节点。\n(2) 当前节点是 Head 的后继节点。\n(3) 当前节点不是 Head 的后继节点,也不是尾节点。\n根据上述第二条,我们来分析每一种情况的流程。\n当前节点是尾节点。\n当前节点是 Head 的后继节点。\n当前节点不是 Head 的后继节点,也不是尾节点。\n通过上面的流程,我们对于 CANCELLED 节点状态的产生和变化已经有了大致的了解,但是为什么所有的变化都是对 Next 指针进行了操作,而没有对 Prev 指针进行操作呢?什么情况下会对 Prev 指针进行操作?\n执行 cancelAcquire 的时候,当前节点的前置节点可能已经从队列中出去了(已经执行过 Try 代码块中的 shouldParkAfterFailedAcquire 方法了),如果此时修改 Prev 指针,有可能会导致 Prev 指向另一个已经移除队列的 Node,因此这块变化 Prev 指针不安全。 shouldParkAfterFailedAcquire 方法中,会执行下面的代码,其实就是在处理 Prev 指针。shouldParkAfterFailedAcquire 是获取锁失败的情况下才会执行,进入该方法后,说明共享资源已被获取,当前节点之前的节点都不会出现变化,因此这个时候变更 Prev 指针比较安全。\ndo { node.prev = pred = pred.prev; } while (pred.waitStatus \u0026gt; 0); 3.3 如何解锁 # 我们已经剖析了加锁过程中的基本流程,接下来再对解锁的基本流程进行分析。由于 ReentrantLock 在解锁的时候,并不区分公平锁和非公平锁,所以我们直接看解锁的源码:\n// java.util.concurrent.locks.ReentrantLock public void unlock() { sync.release(1); } 可以看到,本质释放锁的地方,是通过框架来完成的。\n// java.util.concurrent.locks.AbstractQueuedSynchronizer public final boolean release(int arg) { if (tryRelease(arg)) { Node h = head; if (h != null \u0026amp;\u0026amp; h.waitStatus != 0) unparkSuccessor(h); return true; } return false; } 在 ReentrantLock 里面的公平锁和非公平锁的父类 Sync 定义了可重入锁的释放锁机制。\n// java.util.concurrent.locks.ReentrantLock.Sync // 方法返回当前锁是不是没有被线程持有 protected final boolean tryRelease(int releases) { // 减少可重入次数 int c = getState() - releases; // 当前线程不是持有锁的线程,抛出异常 if (Thread.currentThread() != getExclusiveOwnerThread()) throw new IllegalMonitorStateException(); boolean free = false; // 如果持有线程全部释放,将当前独占锁所有线程设置为null,并更新state if (c == 0) { free = true; setExclusiveOwnerThread(null); } setState(c); return free; } 我们来解释下述源码:\n// java.util.concurrent.locks.AbstractQueuedSynchronizer public final boolean release(int arg) { // 上边自定义的tryRelease如果返回true,说明该锁没有被任何线程持有 if (tryRelease(arg)) { // 获取头结点 Node h = head; // 头结点不为空并且头结点的waitStatus不是初始化节点情况,解除线程挂起状态 if (h != null \u0026amp;\u0026amp; h.waitStatus != 0) unparkSuccessor(h); return true; } return false; } 这里的判断条件为什么是 h != null \u0026amp;\u0026amp; h.waitStatus != 0?\nh == null Head 还没初始化。初始情况下,head == null,第一个节点入队,Head 会被初始化一个虚拟节点。所以说,这里如果还没来得及入队,就会出现 head == null 的情况。\nh != null \u0026amp;\u0026amp; waitStatus == 0 表明后继节点对应的线程仍在运行中,不需要唤醒。\nh != null \u0026amp;\u0026amp; waitStatus \u0026lt; 0 表明后继节点可能被阻塞了,需要唤醒。\n再看一下 unparkSuccessor 方法:\n// java.util.concurrent.locks.AbstractQueuedSynchronizer private void unparkSuccessor(Node node) { // 获取头结点waitStatus int ws = node.waitStatus; if (ws \u0026lt; 0) compareAndSetWaitStatus(node, ws, 0); // 获取当前节点的下一个节点 Node s = node.next; // 如果下个节点是null或者下个节点被cancelled,就找到队列最开始的非cancelled的节点 if (s == null || s.waitStatus \u0026gt; 0) { s = null; // 就从尾部节点开始找,到队首,找到队列第一个waitStatus\u0026lt;0的节点。 for (Node t = tail; t != null \u0026amp;\u0026amp; t != node; t = t.prev) if (t.waitStatus \u0026lt;= 0) s = t; } // 如果当前节点的下个节点不为空,而且状态\u0026lt;=0,就把当前节点unpark if (s != null) LockSupport.unpark(s.thread); } 为什么要从后往前找第一个非 Cancelled 的节点呢?原因如下。\n之前的 addWaiter 方法:\n// java.util.concurrent.locks.AbstractQueuedSynchronizer private Node addWaiter(Node mode) { Node node = new Node(Thread.currentThread(), mode); // Try the fast path of enq; backup to full enq on failure Node pred = tail; if (pred != null) { node.prev = pred; if (compareAndSetTail(pred, node)) { pred.next = node; return node; } } enq(node); return node; } 我们从这里可以看到,节点入队并不是原子操作,也就是说,node.prev = pred; compareAndSetTail(pred, node) 这两个地方可以看作 Tail 入队的原子操作,但是此时 pred.next = node;还没执行,如果这个时候执行了 unparkSuccessor 方法,就没办法从前往后找了,所以需要从后往前找。还有一点原因,在产生 CANCELLED 状态节点的时候,先断开的是 Next 指针,Prev 指针并未断开,因此也是必须要从后往前遍历才能够遍历完全部的 Node。\n综上所述,如果是从前往后找,由于极端情况下入队的非原子操作和 CANCELLED 节点产生过程中断开 Next 指针的操作,可能会导致无法遍历所有的节点。所以,唤醒对应的线程后,对应的线程就会继续往下执行。继续执行 acquireQueued 方法以后,中断如何处理?\n3.4 中断恢复后的执行流程 # 唤醒后,会执行 return Thread.interrupted();,这个函数返回的是当前执行线程的中断状态,并清除。\n// java.util.concurrent.locks.AbstractQueuedSynchronizer private final boolean parkAndCheckInterrupt() { LockSupport.park(this); return Thread.interrupted(); } 再回到 acquireQueued 代码,当 parkAndCheckInterrupt 返回 True 或者 False 的时候,interrupted 的值不同,但都会执行下次循环。如果这个时候获取锁成功,就会把当前 interrupted 返回。\n// java.util.concurrent.locks.AbstractQueuedSynchronizer final boolean acquireQueued(final Node node, int arg) { boolean failed = true; try { boolean interrupted = false; for (;;) { final Node p = node.predecessor(); if (p == head \u0026amp;\u0026amp; tryAcquire(arg)) { setHead(node); p.next = null; // help GC failed = false; return interrupted; } if (shouldParkAfterFailedAcquire(p, node) \u0026amp;\u0026amp; parkAndCheckInterrupt()) interrupted = true; } } finally { if (failed) cancelAcquire(node); } } 如果 acquireQueued 为 True,就会执行 selfInterrupt 方法。\n// java.util.concurrent.locks.AbstractQueuedSynchronizer static void selfInterrupt() { Thread.currentThread().interrupt(); } 该方法其实是为了中断线程。但为什么获取了锁以后还要中断线程呢?这部分属于 Java 提供的协作式中断知识内容,感兴趣同学可以查阅一下。这里简单介绍一下:\n当中断线程被唤醒时,并不知道被唤醒的原因,可能是当前线程在等待中被中断,也可能是释放了锁以后被唤醒。因此我们通过 Thread.interrupted()方法检查中断标记(该方法返回了当前线程的中断状态,并将当前线程的中断标识设置为 False),并记录下来,如果发现该线程被中断过,就再中断一次。 线程在等待资源的过程中被唤醒,唤醒后还是会不断地去尝试获取锁,直到抢到锁为止。也就是说,在整个流程中,并不响应中断,只是记录中断记录。最后抢到锁返回了,那么如果被中断过的话,就需要补充一次中断。 这里的处理方式主要是运用线程池中基本运作单元 Worder 中的 runWorker,通过 Thread.interrupted()进行额外的判断处理,感兴趣的同学可以看下 ThreadPoolExecutor 源码。\n3.5 小结 # 我们在 1.3 小节中提出了一些问题,现在来回答一下。\nQ:某个线程获取锁失败的后续流程是什么呢?\nA:存在某种排队等候机制,线程继续等待,仍然保留获取锁的可能,获取锁流程仍在继续。\nQ:既然说到了排队等候机制,那么就一定会有某种队列形成,这样的队列是什么数据结构呢?\nA:是 CLH 变体的 FIFO 双端队列。\nQ:处于排队等候机制中的线程,什么时候可以有机会获取锁呢?\nA:可以详细看下 2.3.1.3 小节。\nQ:如果处于排队等候机制中的线程一直无法获取锁,需要一直等待么?还是有别的策略来解决这一问题?\nA:线程所在节点的状态会变成取消状态,取消状态的节点会从队列中释放,具体可见 2.3.2 小节。\nQ:Lock 函数通过 Acquire 方法进行加锁,但是具体是如何加锁的呢?\nA:AQS 的 Acquire 会调用 tryAcquire 方法,tryAcquire 由各个自定义同步器实现,通过 tryAcquire 完成加锁过程。\n4 AQS 应用 # 4.1 ReentrantLock 的可重入应用 # ReentrantLock 的可重入性是 AQS 很好的应用之一,在了解完上述知识点以后,我们很容易得知 ReentrantLock 实现可重入的方法。在 ReentrantLock 里面,不管是公平锁还是非公平锁,都有一段逻辑。\n公平锁:\n// java.util.concurrent.locks.ReentrantLock.FairSync#tryAcquire if (c == 0) { if (!hasQueuedPredecessors() \u0026amp;\u0026amp; compareAndSetState(0, acquires)) { setExclusiveOwnerThread(current); return true; } } else if (current == getExclusiveOwnerThread()) { int nextc = c + acquires; if (nextc \u0026lt; 0) throw new Error(\u0026#34;Maximum lock count exceeded\u0026#34;); setState(nextc); return true; } 非公平锁:\n// java.util.concurrent.locks.ReentrantLock.Sync#nonfairTryAcquire if (c == 0) { if (compareAndSetState(0, acquires)){ setExclusiveOwnerThread(current); return true; } } else if (current == getExclusiveOwnerThread()) { int nextc = c + acquires; if (nextc \u0026lt; 0) // overflow throw new Error(\u0026#34;Maximum lock count exceeded\u0026#34;); setState(nextc); return true; } 从上面这两段都可以看到,有一个同步状态 State 来控制整体可重入的情况。State 是 Volatile 修饰的,用于保证一定的可见性和有序性。\n// java.util.concurrent.locks.AbstractQueuedSynchronizer private volatile int state; 接下来看 State 这个字段主要的过程:\nState 初始化的时候为 0,表示没有任何线程持有锁。 当有线程持有该锁时,值就会在原来的基础上+1,同一个线程多次获得锁是,就会多次+1,这里就是可重入的概念。 解锁也是对这个字段-1,一直到 0,此线程对锁释放。 4.2 JUC 中的应用场景 # 除了上边 ReentrantLock 的可重入性的应用,AQS 作为并发编程的框架,为很多其他同步工具提供了良好的解决方案。下面列出了 JUC 中的几种同步工具,大体介绍一下 AQS 的应用场景:\n同步工具 同步工具与 AQS 的关联 ReentrantLock 使用 AQS 保存锁重复持有的次数。当一个线程获取锁时,ReentrantLock 记录当前获得锁的线程标识,用于检测是否重复获取,以及错误线程试图解锁操作时异常情况的处理。 Semaphore 使用 AQS 同步状态来保存信号量的当前计数。tryRelease 会增加计数,acquireShared 会减少计数。 CountDownLatch 使用 AQS 同步状态来表示计数。计数为 0 时,所有的 Acquire 操作(CountDownLatch 的 await 方法)才可以通过。 ReentrantReadWriteLock 使用 AQS 同步状态中的 16 位保存写锁持有的次数,剩下的 16 位用于保存读锁的持有次数。 ThreadPoolExecutor Worker 利用 AQS 同步状态实现对独占线程变量的设置(tryAcquire 和 tryRelease)。 4.3 自定义同步工具 # 了解 AQS 基本原理以后,按照上面所说的 AQS 知识点,自己实现一个同步工具。\npublic class LeeLock { private static class Sync extends AbstractQueuedSynchronizer { @Override protected boolean tryAcquire (int arg) { return compareAndSetState(0, 1); } @Override protected boolean tryRelease (int arg) { setState(0); return true; } @Override protected boolean isHeldExclusively () { return getState() == 1; } } private Sync sync = new Sync(); public void lock () { sync.acquire(1); } public void unlock () { sync.release(1); } } 通过我们自己定义的 Lock 完成一定的同步功能。\npublic class LeeMain { static int count = 0; static LeeLock leeLock = new LeeLock(); public static void main (String[] args) throws InterruptedException { Runnable runnable = new Runnable() { @Override public void run () { try { leeLock.lock(); for (int i = 0; i \u0026lt; 10000; i++) { count++; } } catch (Exception e) { e.printStackTrace(); } finally { leeLock.unlock(); } } }; Thread thread1 = new Thread(runnable); Thread thread2 = new Thread(runnable); thread1.start(); thread2.start(); thread1.join(); thread2.join(); System.out.println(count); } } 上述代码每次运行结果都会是 20000。通过简单的几行代码就能实现同步功能,这就是 AQS 的强大之处。\n5 总结 # 我们日常开发中使用并发的场景太多,但是对并发内部的基本框架原理了解的人却不多。由于篇幅原因,本文仅介绍了可重入锁 ReentrantLock 的原理和 AQS 原理,希望能够成为大家了解 AQS 和 ReentrantLock 等同步器的“敲门砖”。\n参考资料 # Lea D. The java. util. concurrent synchronizer framework[J]. Science of Computer Programming, 2005, 58(3): 293-309. 《Java 并发编程实战》 不可不说的 Java“锁”事 "},{"id":606,"href":"/zh/docs/technology/Interview/high-quality-technical-articles/interview/technical-preliminary-preparation/","title":"从面试官和候选者的角度谈如何准备技术初试","section":"Interview","content":" 推荐语:从面试官和面试者两个角度探讨了技术面试!非常不错!\n内容概览:\n通过技术基础考察候选者,才能考察到候选者的真实技术实力:技术深度和广度。 实战与理论结合。比如,候选人叙述 JVM 内存模型布局之后,可以接着问:有哪些原因可能会导致 OOM , 有哪些预防措施? 你是否遇到过内存泄露的问题? 如何排查和解决这类问题? 项目经历考察不宜超过两个。因为要深入考察一个项目的详情,所占用的时间还是比较大的。一般来说,会让候选人挑选一个他或她觉得最有收获的/最有挑战的/印象最深刻的/自己觉得特有意思的项目。然后围绕这个项目进行发问。通常是从项目背景出发,考察项目的技术栈、项目模块及交互的整体理解、项目中遇到的有挑战性的技术问题及解决方案、排查和解决问题、代码可维护性问题、工程质量保障等。 多问少说,让候选者多表现。根据候选者的回答适当地引导或递进或横向移动。 原文地址: https://www.cnblogs.com/lovesqcc/p/15169365.html\n考察目标和思路 # 首先明确,技术初试的考察目标:\n候选人的技术基础; 候选人解决问题的思路和能力。 技术基础是基石(冰山之下的东西),占七分, 解决问题的思路和能力是落地(冰山之上露出的部分),占三分。 业务和技术基础考察,三七开。\n技术基础考察 # 为什么要考察技术基础? # 程序员最重要的两种技术思维能力,是逻辑思维能力和抽象设计能力。逻辑思维能力是基础,抽象设计能力是高阶。 考察技术基础,正好可以同时考察这两种思维能力。能不能理解基础技术概念及关联,是考察逻辑思维能力;能不能把业务问题抽象成技术问题并合理的组织映射,是考察抽象设计能力。\n绝大部分业务问题,都可以抽象成技术问题。在某种意义上,业务问题只是技术问题的领域化表述。\n因此,通过技术基础考察候选者,才能考察到候选者的真实技术实力:技术深度和广度。\n技术基础怎么考察? # 技术基础怎么考察?通过有效的多角度的发问模式来考察。\n是什么-为什么 # 是什么考察对概念的基本理解,为什么考察对概念的实现原理。\n比如:索引是什么? 索引是如何实现的?\n引导-横向发问-深入发问 # 引导性,比如 “你对 Java 同步工具熟悉吗?” 作个试探,得到肯定答复后,可以进一步问:“你熟悉哪些同步工具类?” 了解候选者的广度;\n获取候选者的回答后,可以进一步问:“ 谈谈 ConcurrentHashMap 或 AQS 的实现原理?”\n一个人在多大程度上把技术原理能讲得清晰,包括思路和细节,说明他对技术的掌握能力有多强。\n跳跃式/交叉式发问 # 比如:讲到哈希高效查找,可以谈谈哈希一致性算法 。 两者既有关联又有很多不同点。也是一种技术广度的考察方法。\n总结性发问 # 比如:你在做 XXX 中,获得了哪些可以分享的经验? 考察候选人的归纳总结能力。\n实战与理论结合 # 比如,候选人叙述 JVM 内存模型布局之后,可以接着问:有哪些原因可能会导致 OOM , 有哪些预防措施? 你是否遇到过内存泄露的问题? 如何排查和解决这类问题?\n比如,候选人有谈到 SQL 优化和索引优化,那就正好谈谈索引的实现原理,如何建立最佳索引?\n再比如,候选人有谈到事务,那就正好谈谈事务实现原理,隔离级别,快照实现等;\n熟悉与不熟悉结合 # 针对候选人简历上写的熟悉的部分,和没有写出的都问下。比如候选人简历上写着:熟悉 JVM 内存模型, 那我就考察下内存管理相关(熟悉部分),再考察下 Java 并发工具类(不确定是否熟悉部分)。\n死知识与活知识结合 # 比如,查找算法有哪些?顺序查找、二分查找、哈希查找。这些大家通常能说出来,也是“死知识”。\n这些查找算法各适用于什么场景?在你工作中,有哪些场景用到了哪些查找算法?为什么? 这些是“活知识”。\n学习或工作中遇到的 # 有时,在学习和工作中遇到的问题,也可以作为面试题。\n比如,最近在学习《操作系统导论》并发部分,有一章节是如何使数据结构成为线程安全的。这里就有一些可以提问的地方:如何实现一个锁?如何实现一个线程安全的计数器?如何实现一个线程安全的链表?如何实现一个线程安全的 Map ?如何提升并发的性能?\n工作中遇到的问题,也可以抽象提炼出来,作为技术基础面试题。\n技术栈适配度发问 # 如果候选人(简历上所写的)使用的某些技术与本公司的技术栈比较契合,则可以针对这些技术点进行深入提问,考察候选人在这些技术点的掌握程度。如果掌握程度比较好,则技术适配度相对更高一些。\n当然,这一点并不能作为筛掉那些没有使用该技术栈的候选人的依据。比如本公司使用 MongoDB 和 MySQL, 而一个候选人没有用过 Mongodb, 但使用过 MySQL, Redis, ES, HBase 等多种存储系统,那么适配度并不比仅使用过 MySQL 和 MongoDB 的候选人逊色,因为他所涉及的技术广度更大,可以推断出他有足够能力掌握 Mongodb。\n创造有个性的面试题库 # 每个技术面试官都会有一个面试题库。持续积累面试题库,日常中突然想到的问题,就随手记录下来。\n业务维度考察 # 为什么要考察业务维度? # 技术基础考察,容易错过的地方是,候选人的非技术能力特质,比如沟通组织能力、带项目能力、抗压能力、解决实际问题的能力、团队影响力、其它性格特质等。\n为什么不能单考察业务维度? # 因为业务方面通常比较熟悉,可能就直接按照现有方案说出来了,很难考察到候选人的深入理解、横向拓展和归纳总结能力。\n这一点,建议有针对性地考察下候选人的归纳总结能力:比如, 微服务搭建或开发或维护/保证系统稳定性或性能方面的过程中,你收获了哪些可以分享的经验?\n解决问题能力考察 # 仅仅只是技术基础还不够,通常最好结合实际业务,针对他项目里的业务,抽象出技术问题进行考察。\n解决思路重在层层递进。这一点对于面试官的要求也比较高,兼具良好的倾听能力、技术深度和业务经验。首先要仔细倾听候选人的阐述,找到适当的技术切入点,然后进行发问。如果进不去,那就容易考察失败。\n设计问题 # 比如多个机器间共享大量业务对象,这些业务对象之间有些联合字段是重复的,如何去重? 如果瞬时有大量请求涌入,如何保证服务器的稳定性? 项目经历 # 项目经历考察不宜超过两个。因为要深入考察一个项目的详情,所占用的时间还是比较大的。\n一般来说,会让候选人挑选一个他或她觉得最有收获的/最有挑战的/印象最深刻的/自己觉得特有意思的项目。然后围绕这个项目进行发问。通常是从项目背景出发,考察项目的技术栈、项目模块及交互的整体理解、项目中遇到的有挑战性的技术问题及解决方案、排查和解决问题、代码可维护性问题、工程质量保障等。\n面试官如何做好一场面试? # 预先准备 # 面试官也需要做一些准备。比如熟悉候选者的技能优势、工作经历等,做一个面试设计。\n在面试将要开始时,做好面试准备。此外,面试官也需要对公司的一些基本情况有所了解,尤其是公司所使用技术栈、业务全景及方向、工作内容、晋升制度等,这一点技术型候选人问得比较多。\n面试启动 # 一般以候选人自我介绍启动,不过候选人往往会谈得比较散,因此,我会直接提问:谈谈你有哪些优势以及自己觉得可以改进的地方?\n然后以一个相对简单的基础题作为技术提问的开始:你熟悉哪些查找算法?大多数人是能答上顺序查找、二分查找、哈希查找的。\n问题设计 # 提前阅读候选人简历,从简历中筛选出关键词,根据这些关键词进行有针对性地问题设计。\n比如候选人简历里提到 MVVM ,可以问 MVVM 与 MVC 的区别; 提到了观察者模式,可以谈谈观察者模式,顺便问问他还熟悉哪些设计模式。\n宽松氛围 # 即使问的问题比较多比较难,也要注意保持宽松氛围。\n在面试前,根据候选人基本信息适当调侃一下,比如一位候选人叫汪奎,那我就说:之前我们团队有位叫袁奎,我们都喊他奎爷。\n在面试过程中,适当提示,或者给出少量自己的看法,也能缓解候选人的紧张情绪。\n学会倾听 # 多问少说,让候选者多表现。根据候选者的回答适当地引导或递进或横向移动。\n引导候选人表现他最优势的一面,让他或她感觉好一些:毕竟一场面试双方都付出了时间和精力,不应该是面试官 Diss 候选人的场合,而应该让彼此有更好的交流。很大可能,你也能从候选人那里学到不少东西。\n面试这件事,只不过双方的角色和立场有所不同,但并不代表面试官的水平就一定高于候选人。\n记录重点 # 认真客观地记录候选人的回答,尽可能避免任何主观评价,亦不作任何加工(比如自己给总结一下,总结能力也是候选人的一个特质)。\n作出判断 # 面试过程是一种铺垫,关键的是作出判断。\n作出判断最容易陷入误区的是:贪深求全。总希望候选人技术又深入又全面。实际上,这是一种奢望。如果候选人的技术能力又深入又全面,很可能也会面临两种情况:\n候选人有更好的选择; 候选人在其它方面可能存在不足,比如团队协作方面。 一个比较合适的尺度是:\n他或她的技术水平能否胜任当前工作; 他或她的技术水平与同组团队成员水平如何; 他或她的技术水平是否与年限相对匹配,是否有潜力胜任更复杂的任务。 不同年龄看重的东西不一样。\n对于三年以下的工程师,应当更看重其技术基础,因为这代表着他的未来潜能;同时也考察下他在实际开发中的体现,比如团队协作、业务经验、抗压能力、主动学习的热情和能力等。\n对于三年以上的工程师,应当更看重其业务经验、解决问题能力,看看他或她是如何分析具体问题,在业务范畴内考察其技术基础的深度和广度。\n如何判断一个候选人的真实技术水平及是否适合所需,这方面,我也在学习中。\n给候选人的话 # 关注技术基础 # 一个常见的疑惑是:开发业务系统的大多数时候,基本不涉及数据结构与算法的设计与实现,为什么要考察 HashMap 的实现原理?为什么要学好数据结构与算法、操作系统、网络通信这些基础课程?\n现在我可以给出一个答案了:\n正如上面所述,绝大多数的业务问题,实际上最终都会映射到基础技术问题上:数据结构与算法的实现、内存管理、并发控制、网络通信等;这些是理解现代互联网大规模程序以及解决程序疑难问题的基石,—— 除非能祝福自己永远都不会遇到疑难问题,永远都只满足于编写 CRUD; 这些技术基础正是程序世界里最有趣最激动人心的地方。如果对这些不感兴趣,就很难在这个领域里深入进去,不如及早转行从事其它职业,非技术的世界一直都很精彩广阔(有时我也想多出去走走,不想局限于技术世界); 技术基础是程序员的内功,而具体技术则是招式。徒有招式而内功不深,遇到高手(优秀同行从业者的竞争及疑难杂症)容易不堪一击; 具备扎实的专业技术基础,能达到的上限更高,未来更有可能胜任复杂的技术问题求解,或者在同样的问题上能够做到更好的方案; 人们喜欢跟与自己相似的人合作,牛人倾向于与牛人合作能得到更好的效果;如果一个团队大部分人技术基础比较好,那么进来一个技术基础比较薄弱的人,协作成本会变高;如果你想和牛人一起合作拿到更好的结果,那就要让自己至少在技术基础上能够与牛人搭配的上; 在 CRUD 的基础上拓展其它才能也不失为一种好的选择,但这不会是一个真正的程序员的姿态,顶多是有技术基础的产品经理、项目经理、HR、运营、客满等其它岗位人才。这是职业选择的问题,已经超出了考察程序员的范畴。 不要在意某个问题回答不上来 # 如果面试官问你很多问题,而有些没有回答上来,不要在意。面试官很可能只是在测试你的技术深度和广度,然后判断你是否达到某个水位线。\n重点是:有些问题你答得很有深度,也体现了你的深度思考能力。\n这一点是我当了技术面试官才领会到的。当然,并不是每位技术面试官都是这么想的,但我觉得这应该是个更合适的方式。\n"},{"id":607,"href":"/zh/docs/technology/Interview/high-quality-technical-articles/personal-experience/four-year-work-in-tencent-summary/","title":"从校招入职腾讯的四年工作总结","section":"Personal Experience","content":"程序员是一个流动性很大的职业,经常会有新面孔的到来,也经常会有老面孔的离开,有主动离开的,也有被动离职的。\n再加上这几年卷得厉害,做的事更多了,拿到的却更少了,互联网好像也没有那么香了。\n人来人往,变动无常的状态,其实也早已习惯。\n打工人的唯一出路,无外乎精进自己的专业技能,提升自己的核心竞争力,这样无论有什么变动,走到哪里,都能有口饭吃。\n今天分享一位博主,校招入职腾讯,工作四年后,离开的故事。\n至于为什么离开,我也不清楚,可能是有其他更好的选择,或者是觉得当前的工作对自己的提升有限。\n下文中的“我”,指这位作者本人。\n原文地址: https://zhuanlan.zhihu.com/p/602517682\n研究生毕业后, 一直在腾讯工作,不知不觉就过了四年。个人本身没有刻意总结的习惯,以前只顾着往前奔跑了,忘了停下来思考总结。记得看过一个职业规划文档,说的三年一个阶段,五年一个阶段的说法,现在恰巧是四年,同时又从腾讯离开,该做一个总结了。\n先对自己这四年做一个简单的评价吧:个人认为,没有完全的浪费和辜负这四年的光阴。为何要这么说了?因为我发现和别人对比,好像意义不大,比我混的好的人很多;比我混的差的人也不少。说到底,我只是一个普普通通的人,才不惊人,技不压众,接受自己的平凡,然后看自己做的,是否让自己满意就好。\n下面具体谈几点吧,我主要想聊下工作,绩效,EPC,嫡系看法,最后再谈下收获。\n工作情况 # 我在腾讯内部没有转过岗,但是做过的项目也还是比较丰富的,包括:BUGLY、分布式调用链(Huskie)、众包系统(SOHO),EPC 度量系统。其中一些是对外的,一些是内部系统,可能有些大家不知道。还是比较感谢这些项目经历,既有纯业务的系统,也有偏框架的系统,让我学到了不少知识。\n接下来,简单介绍一下每个项目吧,毕竟每一个项目都付出了很多心血的:\nBUGLY,这是一个终端 Crash 联网上报的系统,很多 APP 都接入了。Huskie,这是一个基于 zipkin 搭建的分布式调用链跟踪项目。SOHO,这是一个众包系统,主要是将数据标准和语音采集任务众包出去,让人家做。EPC 度量系统,这是研发效能度量系统,主要是度量研发效能情况的。这里我谈一下对于业务开发的理解和认识,很多人可能都跟我最开始一样,有一个疑惑,整天做业务开发如何成长?换句话说,就是说整天做 CRUD,如何成长?我开始也有这样的疑惑,后来我转变了观念。\n我觉得对于系统的复杂度,可以粗略的分为技术复杂度和业务复杂度,对于业务系统,就是业务复杂度高一些,对于框架系统就是技术复杂度偏高一些。解决这两种复杂度,都具有很大的挑战。\n此前做过的众包系统,就是各种业务逻辑,搞过去,搞过来,其实这就是业务复杂度高。为了解决这个问题,我们开始探索和实践领域驱动(DDD),确实带来了一些帮助,不至于系统那么混乱了。同时,我觉得这个过程中,自己对于 DDD 的感悟,对于我后来的项目系统划分和设计以及开发都带来了帮助。\n当然 DDD 不是银弹,我也不是吹嘘它有多好,只是了解了它后,有时候设计和开发时,能换一种思路。\n可以发现,其实平时咱们做业务,想做好,其实也没那么容易,如果可以多探索多实践,将一些好的方法或思想或架构引入进来,与个人和业务都会有有帮助。\n绩效情况 # 我在腾讯工作四年,腾讯半年考核一次,一共考核八次,回想了下,四年来的绩效情况为:三星,三星,五星,三星,五星,四星,四星,三星。统计一下, 四五星占比刚好一半。\nPS:还好以前有奖杯,不然一点念想都没了。(现在腾讯似乎不发了)\n印象比较深的是两次五星获得经历。第一次五星是工作的第二年,那一年是在做众包项目,因为项目本身难度不大,因此我把一些精力投入到了团队的基础建设中,帮团队搭建了 java 以及 golang 的项目脚手架,又做了几次中心技术分享,最终 Leader 觉得我表现比较突出,因此给了我五星。看来,主动一些,与个人与团队都是有好处的,最终也能获得一些回报。\n第二次五星,就是与 EPC 有关了。说一个搞笑的事,我也是后来才知道的,项目初期,总监去汇报时,给老板演示系统,加载了很久指标才刷出来,总监很不好意思的说正在优化;过了一段时间,又去汇报演示,结果又很尴尬的刷了很久才出来,总监无赖表示还是在优化。没想到,自己曾经让总监这么丢脸,哈哈。好吧,说一下结果,最终,我自己写了一个查询引擎替换了 Mondrian,之后再也没有出现那种尴尬的情况了。随之而来,也给了好绩效鼓励。做 EPC 度量项目,我觉得自己成长很大,比如抗压能力,当你从零到一搭建一个系统时,会有一个先扛住再优化的过程,此外如果你的项目很重要,尤其是数据相关,那么任何一点问题,都可能让你神经紧绷,得想尽办法降低风险和故障。此外,另一个不同的感受就是,以前得项目,我大多是开发者,而这个系统,我是 Owner 负责人,当你 Owner 一个系统时,你得时刻负责,同时还需要思考系统的规划和方向,此外还需要分配好需求和把控进度,角色体验跟以前完全不一样。\n谈谈 EPC # 很多人都骂 EPC,或者笑 EPC,作为度量平台核心开发者之一,我来谈谈客观的看法。\n其实 EPC 初衷是好的,希望通过全方位多维度的研效指标,来度量研发效能各环节的质量,进而反推业务,提升研发效能。然而,最终在实践的过程中,才发现,客观条件并不支持(工具还没建设好);此外,一味的追求指标数据,使得下面的人想方设法让指标好看,最终违背了初衷。\n为什么,说 EPC 好了,其实如果你仔细了解下 EPC,你就会发现,他是一套相当完善且比较先进的指标度量体系。覆盖了需求,代码,缺陷,测试,持续集成,运营部署各个环节。\n此外,这个过程中,虽然一些人和一些业务做弊,但绝大多数业务还是做出了改变的,比如微视那边的人反馈是,以前的代码写的跟屎一样,当有了 EPC 后,代码质量好了很多。虽然最后微视还是亡了,但是大厦将倾,EPC 是救不了的,亡了也更不能怪 EPC。\n谈谈嫡系 # 大家都说腾讯,嫡系文化盛行。但其实我觉得在那个公司都一样吧。这也符合事物的基本规律,人们只相信自己信任并熟悉的人。作为领导,你难道会把把重要的事情交给自己不熟悉的人吗?\n其实我也不知道我算不算嫡系,脉脉上有人问过”怎么知道自己算不算嫡系”,下面有一个回答,我觉得很妙:如果你不知道你是不是嫡系,那你就不是。哈哈,这么说来,我可能不是。\n但另一方面,后来我负责了团队内很重要的事情,应该是中心内都算很重要的事,我独自负责一个方向,直接向总监汇报,似乎又有点像。\n网上也有其他说法,一针见血,是不是嫡系,就看钱到不到位,这么说也有道理。我在 7 级时,就发了股票,自我感觉,还是不错的。我当时以为不出意外的话,我以后的钱途和发展是不是就会一帆风顺。不出意外就出了意外,第二年,EPC 不达预期,部门总经理和总监都被换了,中心来了一个新的的总监。\n好吧,又要重新建立信任了。再到后来,是不是嫡系已经不重要了,因为大环境不好,又加上裁员,大家主动的被动的差不多都走了。\n总结一下,嫡系的存在,其实情有可原。怎么样成为嫡系了?其实我也不知道。不过,我觉得,与其思考怎么成为嫡系,不如思考怎么展现自己的价值和能力,当别人发现你的价值和能力了,那自然更多的机会就会给予你,有了机会,只要把握住了,那就有更多的福利了。\n再谈收获 # 收获,什么叫做收获了?个人觉得无论是外在的物质,技能,职级;还是内在的感悟,认识,都算收获。\n先说一些可量化的吧,我觉得有:\n级别上,升上了九级,高级工程师。虽然大家都在说腾讯职级缩水,但是有没有高工的能力自己其实是知道的,我个人感觉,通过我这几年的努力,我算是达到了我当时认为的我需要在高工时达到的状态; 绩效上,自我评价,个人不是一个特别卷的人,或者说不会为了卷而卷。但是,如果我认定我应该把它做好得,我的 Owner 意识,以及负责态度,我觉得还是可以的。最终在腾讯四年的绩效也还算过的去。再谈一些其他软技能方面: 1、文档能力\n作为程序员,文档能力其实是一项很重要的能力。其实我也没觉得自己文档能力有多好,但是前后两任总监,都说我的文档不错,那看来,我可能在平均水准之上。\n2、明确方向\n最后,说一个更虚的,但是我觉得最有价值的收获: 我逐渐明确了,或者确定了以后的方向和路,那就是走数据开发。\n其实,找到并确定一个目标很难,身边有清晰目标和方向的人很少,大多数是迷茫的。\n前一段时间,跟人聊天,谈到职业规划,说是可以从两个角度思考:\n选一个业务方向,比如电商,广告,不断地积累业务领域知识和业务相关技能,随着经验的不断积累,最终你就是这个领域的专家。 深入一个技术方向,不断钻研底层技术知识,这样就有希望成为此技术专家。坦白来说,虽然我深入研究并实践过领域驱动设计,也用来建模和解决了一些复杂业务问题,但是发自内心的,我其实更喜欢钻研技术,同时,我又对大数据很感兴趣。因此,我决定了,以后的方向,就做数据相关的工作。 腾讯的四年,是我的第一份工作经历,认识了很多厉害的人,学到了很多。最后自己主动离开,也算走的体面(即使损失了大礼包),还是感谢腾讯。\n"},{"id":608,"href":"/zh/docs/technology/Interview/java/jvm/jvm-intro/","title":"大白话带你认识 JVM","section":"Jvm","content":" 来自 说出你的愿望吧丷投稿,原文地址: https://juejin.im/post/5e1505d0f265da5d5d744050。\n前言 # 如果在文中用词或者理解方面出现问题,欢迎指出。此文旨在提及而不深究,但会尽量效率地把知识点都抛出来\n一、JVM 的基本介绍 # JVM 是 Java Virtual Machine 的缩写,它是一个虚构出来的计算机,一种规范。通过在实际的计算机上仿真模拟各类计算机功能实现···\n好,其实抛开这么专业的句子不说,就知道 JVM 其实就类似于一台小电脑运行在 windows 或者 linux 这些操作系统环境下即可。它直接和操作系统进行交互,与硬件不直接交互,而操作系统可以帮我们完成和硬件进行交互的工作。\n1.1 Java 文件是如何被运行的 # 比如我们现在写了一个 HelloWorld.java 好了,那这个 HelloWorld.java 抛开所有东西不谈,那是不是就类似于一个文本文件,只是这个文本文件它写的都是英文,而且有一定的缩进而已。\n那我们的 JVM 是不认识文本文件的,所以它需要一个 编译 ,让其成为一个它会读二进制文件的 HelloWorld.class\n① 类加载器 # 如果 JVM 想要执行这个 .class 文件,我们需要将其装进一个 类加载器 中,它就像一个搬运工一样,会把所有的 .class 文件全部搬进 JVM 里面来。\n② 方法区 # 方法区 是用于存放类似于元数据信息方面的数据的,比如类信息,常量,静态变量,编译后代码···等\n类加载器将 .class 文件搬过来就是先丢到这一块上\n③ 堆 # 堆 主要放了一些存储的数据,比如对象实例,数组···等,它和方法区都同属于 线程共享区域 。也就是说它们都是 线程不安全 的\n④ 栈 # 栈 这是我们的代码运行空间。我们编写的每一个方法都会放到 栈 里面运行。\n我们会听说过 本地方法栈 或者 本地方法接口 这两个名词,不过我们基本不会涉及这两块的内容,它俩底层是使用 C 来进行工作的,和 Java 没有太大的关系。\n⑤ 程序计数器 # 主要就是完成一个加载工作,类似于一个指针一样的,指向下一行我们需要执行的代码。和栈一样,都是 线程独享 的,就是说每一个线程都会有自己对应的一块区域而不会存在并发和多线程的问题。\n小总结 # Java 文件经过编译后变成 .class 字节码文件 字节码文件通过类加载器被搬运到 JVM 虚拟机中 虚拟机主要的 5 大块:方法区,堆都为线程共享区域,有线程安全问题,栈和本地方法栈和计数器都是独享区域,不存在线程安全问题,而 JVM 的调优主要就是围绕堆,栈两大块进行 1.2 简单的代码例子 # 一个简单的学生类\n一个 main 方法\n执行 main 方法的步骤如下:\n编译好 App.java 后得到 App.class 后,执行 App.class,系统会启动一个 JVM 进程,从 classpath 路径中找到一个名为 App.class 的二进制文件,将 App 的类信息加载到运行时数据区的方法区内,这个过程叫做 App 类的加载 JVM 找到 App 的主程序入口,执行 main 方法 这个 main 中的第一条语句为 Student student = new Student(\u0026ldquo;tellUrDream\u0026rdquo;) ,就是让 JVM 创建一个 Student 对象,但是这个时候方法区中是没有 Student 类的信息的,所以 JVM 马上加载 Student 类,把 Student 类的信息放到方法区中 加载完 Student 类后,JVM 在堆中为一个新的 Student 实例分配内存,然后调用构造函数初始化 Student 实例,这个 Student 实例持有 指向方法区中的 Student 类的类型信息 的引用 执行 student.sayName();时,JVM 根据 student 的引用找到 student 对象,然后根据 student 对象持有的引用定位到方法区中 student 类的类型信息的方法表,获得 sayName() 的字节码地址。 执行 sayName() 其实也不用管太多,只需要知道对象实例初始化时会去方法区中找类信息,完成后再到栈那里去运行方法。找方法就在方法表中找。\n二、类加载器的介绍 # 之前也提到了它是负责加载.class 文件的,它们在文件开头会有特定的文件标示,将 class 文件字节码内容加载到内存中,并将这些内容转换成方法区中的运行时数据结构,并且 ClassLoader 只负责 class 文件的加载,而是否能够运行则由 Execution Engine 来决定\n2.1 类加载器的流程 # 从类被加载到虚拟机内存中开始,到释放内存总共有 7 个步骤:加载,验证,准备,解析,初始化,使用,卸载。其中验证,准备,解析三个部分统称为连接\n2.1.1 加载 # 将 class 文件加载到内存 将静态数据结构转化成方法区中运行时的数据结构 在堆中生成一个代表这个类的 java.lang.Class 对象作为数据访问的入口 2.1.2 链接 # 验证:确保加载的类符合 JVM 规范和安全,保证被校验类的方法在运行时不会做出危害虚拟机的事件,其实就是一个安全检查 准备:为 static 变量在方法区中分配内存空间,设置变量的初始值,例如 static int a = 3 (注意:准备阶段只设置类中的静态变量(方法区中),不包括实例变量(堆内存中),实例变量是对象初始化时赋值的) 解析:虚拟机将常量池内的符号引用替换为直接引用的过程(符号引用比如我现在 import java.util.ArrayList 这就算符号引用,直接引用就是指针或者对象地址,注意引用对象一定是在内存进行) 2.1.3 初始化 # 初始化其实就是执行类构造器方法的\u0026lt;clinit\u0026gt;()的过程,而且要保证执行前父类的\u0026lt;clinit\u0026gt;()方法执行完毕。这个方法由编译器收集,顺序执行所有类变量(static 修饰的成员变量)显式初始化和静态代码块中语句。此时准备阶段时的那个 static int a 由默认初始化的 0 变成了显式初始化的 3。 由于执行顺序缘故,初始化阶段类变量如果在静态代码块中又进行了更改,会覆盖类变量的显式初始化,最终值会为静态代码块中的赋值。\n注意:字节码文件中初始化方法有两种,非静态资源初始化的\u0026lt;init\u0026gt;和静态资源初始化的\u0026lt;clinit\u0026gt;,类构造器方法\u0026lt;clinit\u0026gt;()不同于类的构造器,这些方法都是字节码文件中只能给 JVM 识别的特殊方法。\n2.1.4 卸载 # GC 将无用对象从内存中卸载\n2.2 类加载器的加载顺序 # 加载一个 Class 类的顺序也是有优先级的,类加载器从最底层开始往上的顺序是这样的\nBootStrap ClassLoader:rt.jar Extension ClassLoader: 加载扩展的 jar 包 App ClassLoader:指定的 classpath 下面的 jar 包 Custom ClassLoader:自定义的类加载器 2.3 双亲委派机制 # 当一个类收到了加载请求时,它是不会先自己去尝试加载的,而是委派给父类去完成,比如我现在要 new 一个 Person,这个 Person 是我们自定义的类,如果我们要加载它,就会先委派 App ClassLoader ,只有当父类加载器都反馈自己无法完成这个请求(也就是父类加载器都没有找到加载所需的 Class)时,子类加载器才会自行尝试加载。\n这样做的好处是,加载位于 rt.jar 包中的类时不管是哪个加载器加载,最终都会委托到 BootStrap ClassLoader 进行加载,这样保证了使用不同的类加载器得到的都是同一个结果。\n其实这个也是一个隔离的作用,避免了我们的代码影响了 JDK 的代码,比如我现在自己定义一个 java.lang.String:\npackage java.lang; public class String { public static void main(String[] args) { System.out.println(); } } 尝试运行当前类的 main 函数的时候,我们的代码肯定会报错。这是因为在加载的时候其实是找到了 rt.jar 中的java.lang.String,然而发现这个里面并没有 main 方法。\n三、运行时数据区 # 3.1 本地方法栈和程序计数器 # 比如说我们现在点开 Thread 类的源码,会看到它的 start0 方法带有一个 native 关键字修饰,而且不存在方法体,这种用 native 修饰的方法就是本地方法,这是使用 C 来实现的,然后一般这些方法都会放到一个叫做本地方法栈的区域。\n程序计数器其实就是一个指针,它指向了我们程序中下一句需要执行的指令,它也是内存区域中唯一一个不会出现 OutOfMemoryError 的区域,而且占用内存空间小到基本可以忽略不计。这个内存仅代表当前线程所执行的字节码的行号指示器,字节码解析器通过改变这个计数器的值选取下一条需要执行的字节码指令。\n如果执行的是 native 方法,那这个指针就不工作了。\n3.2 方法区 # 方法区主要的作用是存放类的元数据信息,常量和静态变量···等。当它存储的信息过大时,会在无法满足内存分配时报错。\n3.3 虚拟机栈和虚拟机堆 # 一句话便是:栈管运行,堆管存储。则虚拟机栈负责运行代码,而虚拟机堆负责存储数据。\n3.3.1 虚拟机栈的概念 # 它是 Java 方法执行的内存模型。里面会对局部变量,动态链表,方法出口,栈的操作(入栈和出栈)进行存储,且线程独享。同时如果我们听到局部变量表,那也是在说虚拟机栈\npublic class Person{ int a = 1; public void doSomething(){ int b = 2; } } 3.3.2 虚拟机栈存在的异常 # 如果线程请求的栈的深度大于虚拟机栈的最大深度,就会报 StackOverflowError (这种错误经常出现在递归中)。Java 虚拟机也可以动态扩展,但随着扩展会不断地申请内存,当无法申请足够内存时就会报错 OutOfMemoryError。\n3.3.3 虚拟机栈的生命周期 # 对于栈来说,不存在垃圾回收。只要程序运行结束,栈的空间自然就会释放了。栈的生命周期和所处的线程是一致的。\n这里补充一句:8 种基本类型的变量+对象的引用变量+实例方法都是在栈里面分配内存。\n3.3.4 虚拟机栈的执行 # 我们经常说的栈帧数据,说白了在 JVM 中叫栈帧,放到 Java 中其实就是方法,它也是存放在栈中的。\n栈中的数据都是以栈帧的格式存在,它是一个关于方法和运行期数据的数据集。比如我们执行一个方法 a,就会对应产生一个栈帧 A1,然后 A1 会被压入栈中。同理方法 b 会有一个 B1,方法 c 会有一个 C1,等到这个线程执行完毕后,栈会先弹出 C1,后 B1,A1。它是一个先进后出,后进先出原则。\n3.3.5 局部变量的复用 # 局部变量表用于存放方法参数和方法内部所定义的局部变量。它的容量是以 Slot 为最小单位,一个 slot 可以存放 32 位以内的数据类型。\n虚拟机通过索引定位的方式使用局部变量表,范围为 [0,局部变量表的 slot 的数量]。方法中的参数就会按一定顺序排列在这个局部变量表中,至于怎么排的我们可以先不关心。而为了节省栈帧空间,这些 slot 是可以复用的,当方法执行位置超过了某个变量,那么这个变量的 slot 可以被其它变量复用。当然如果需要复用,那我们的垃圾回收自然就不会去动这些内存。\n3.3.6 虚拟机堆的概念 # JVM 内存会划分为堆内存和非堆内存,堆内存中也会划分为年轻代和老年代,而非堆内存则为永久代。年轻代又会分为Eden和Survivor区。Survivor 也会分为FromPlace和ToPlace,toPlace 的 survivor 区域是空的。Eden,FromPlace 和 ToPlace 的默认占比为 8:1:1。当然这个东西其实也可以通过一个 -XX:+UsePSAdaptiveSurvivorSizePolicy 参数来根据生成对象的速率动态调整\n堆内存中存放的是对象,垃圾收集就是收集这些对象然后交给 GC 算法进行回收。非堆内存其实我们已经说过了,就是方法区。在 1.8 中已经移除永久代,替代品是一个元空间(MetaSpace),最大区别是 metaSpace 是不存在于 JVM 中的,它使用的是本地内存。并有两个参数\nMetaspaceSize:初始化元空间大小,控制发生GC MaxMetaspaceSize:限制元空间大小上限,防止占用过多物理内存。 移除的原因可以大致了解一下:融合 HotSpot JVM 和 JRockit VM 而做出的改变,因为 JRockit 是没有永久代的,不过这也间接性地解决了永久代的 OOM 问题。\n3.3.7 Eden 年轻代的介绍 # 当我们 new 一个对象后,会先放到 Eden 划分出来的一块作为存储空间的内存,但是我们知道对堆内存是线程共享的,所以有可能会出现两个对象共用一个内存的情况。这里 JVM 的处理是为每个线程都预先申请好一块连续的内存空间并规定了对象存放的位置,而如果空间不足会再申请多块内存空间。这个操作我们会称作 TLAB,有兴趣可以了解一下。\n当 Eden 空间满了之后,会触发一个叫做 Minor GC(就是一个发生在年轻代的 GC)的操作,存活下来的对象移动到 Survivor0 区。Survivor0 区满后触发 Minor GC,就会将存活对象移动到 Survivor1 区,此时还会把 from 和 to 两个指针交换,这样保证了一段时间内总有一个 survivor 区为空且 to 所指向的 survivor 区为空。经过多次的 Minor GC 后仍然存活的对象(这里的存活判断是 15 次,对应到虚拟机参数为 -XX:MaxTenuringThreshold 。为什么是 15,因为 HotSpot 会在对象头中的标记字段里记录年龄,分配到的空间仅有 4 位,所以最多只能记录到 15)会移动到老年代。\n🐛 修正:当 Eden 区内存空间满了的时候,就会触发 Minor GC,Survivor0 区满不会触发 Minor GC 。\n那 Survivor0 区 的对象什么时候垃圾回收呢?\n假设 Survivor0 区现在是满的,此时又触发了 Minor GC ,发现 Survivor0 区依旧是满的,存不下,此时会将 S0 区与 Eden 区的对象一起进行可达性分析,找出活跃的对象,将它复制到 S1 区并且将 S0 区域和 Eden 区的对象给清空,这样那些不可达的对象进行清除,并且将 S0 区 和 S1 区交换。\n老年代是存储长期存活的对象的,占满时就会触发我们最常听说的 Full GC,期间会停止所有线程等待 GC 的完成。所以对于响应要求高的应用应该尽量去减少发生 Full GC 从而避免响应超时的问题。\n而且当老年区执行了 full gc 之后仍然无法进行对象保存的操作,就会产生 OOM,这时候就是虚拟机中的堆内存不足,原因可能会是堆内存设置的大小过小,这个可以通过参数-Xms、-Xmx 来调整。也可能是代码中创建的对象大且多,而且它们一直在被引用从而长时间垃圾收集无法收集它们。\n补充说明:关于-XX:TargetSurvivorRatio 参数的问题。其实也不一定是要满足-XX:MaxTenuringThreshold 才移动到老年代。可以举个例子:如对象年龄 5 的占 30%,年龄 6 的占 36%,年龄 7 的占 34%,加入某个年龄段(如例子中的年龄 6)后,总占用超过 Survivor 空间*TargetSurvivorRatio 的时候,从该年龄段开始及大于的年龄对象就要进入老年代(即例子中的年龄 6 对象,就是年龄 6 和年龄 7 晋升到老年代),这时候无需等到 MaxTenuringThreshold 中要求的 15\n3.3.8 如何判断一个对象需要被干掉 # 图中程序计数器、虚拟机栈、本地方法栈,3 个区域随着线程的生存而生存的。内存分配和回收都是确定的。随着线程的结束内存自然就被回收了,因此不需要考虑垃圾回收的问题。而 Java 堆和方法区则不一样,各线程共享,内存的分配和回收都是动态的。因此垃圾收集器所关注的都是堆和方法这部分内存。\n在进行回收前就要判断哪些对象还存活,哪些已经死去。下面介绍两个基础的计算方法\n1.引用计数器计算:给对象添加一个引用计数器,每次引用这个对象时计数器加一,引用失效时减一,计数器等于 0 时就是不会再次使用的。不过这个方法有一种情况就是出现对象的循环引用时 GC 没法回收。\n2.可达性分析计算:这是一种类似于二叉树的实现,将一系列的 GC ROOTS 作为起始的存活对象集,从这个节点往下搜索,搜索所走过的路径成为引用链,把能被该集合引用到的对象加入到集合中。搜索当一个对象到 GC Roots 没有使用任何引用链时,则说明该对象是不可用的。主流的商用程序语言,例如 Java,C#等都是靠这招去判定对象是否存活的。\n(了解一下即可)在 Java 语言汇总能作为 GC Roots 的对象分为以下几种:\n虚拟机栈(栈帧中的本地方法表)中引用的对象(局部变量) 方法区中静态变量所引用的对象(静态变量) 方法区中常量引用的对象 本地方法栈(即 native 修饰的方法)中 JNI 引用的对象(JNI 是 Java 虚拟机调用对应的 C 函数的方式,通过 JNI 函数也可以创建新的 Java 对象。且 JNI 对于对象的局部引用或者全局引用都会把它们指向的对象都标记为不可回收) 已启动的且未终止的 Java 线程 这种方法的优点是能够解决循环引用的问题,可它的实现需要耗费大量资源和时间,也需要 GC(它的分析过程引用关系不能发生变化,所以需要停止所有进程)\n3.3.9 如何宣告一个对象的真正死亡 # 首先必须要提到的是一个名叫 finalize() 的方法\nfinalize()是 Object 类的一个方法、一个对象的 finalize()方法只会被系统自动调用一次,经过 finalize()方法逃脱死亡的对象,第二次不会再调用。\n补充一句:并不提倡在程序中调用 finalize()来进行自救。建议忘掉 Java 程序中该方法的存在。因为它执行的时间不确定,甚至是否被执行也不确定(Java 程序的不正常退出),而且运行代价高昂,无法保证各个对象的调用顺序(甚至有不同线程中调用)。在 Java9 中已经被标记为 deprecated ,且 java.lang.ref.Cleaner(也就是强、软、弱、幻象引用的那一套)中已经逐步替换掉它,会比 finalize 来的更加的轻量及可靠。\n判断一个对象的死亡至少需要两次标记\n如果对象进行可达性分析之后没发现与 GC Roots 相连的引用链,那它将会第一次标记并且进行一次筛选。判断的条件是决定这个对象是否有必要执行 finalize()方法。如果对象有必要执行 finalize()方法,则被放入 F-Queue 队列中。 GC 对 F-Queue 队列中的对象进行二次标记。如果对象在 finalize()方法中重新与引用链上的任何一个对象建立了关联,那么二次标记时则会将它移出“即将回收”集合。如果此时对象还没成功逃脱,那么只能被回收了。 如果确定对象已经死亡,我们又该如何回收这些垃圾呢\n3.4 垃圾回收算法 # 关于常见垃圾回收算法的详细介绍,建议阅读这篇: JVM 垃圾回收详解(重点)。\n3.5 (了解)各种各样的垃圾回收器 # HotSpot VM 中的垃圾回收器,以及适用场景\n到 jdk8 为止,默认的垃圾收集器是 Parallel Scavenge 和 Parallel Old\n从 jdk9 开始,G1 收集器成为默认的垃圾收集器 目前来看,G1 回收器停顿时间最短而且没有明显缺点,非常适合 Web 应用。在 jdk8 中测试 Web 应用,堆内存 6G,新生代 4.5G 的情况下,Parallel Scavenge 回收新生代停顿长达 1.5 秒。G1 回收器回收同样大小的新生代只停顿 0.2 秒。\n3.6 (了解)JVM 的常用参数 # JVM 的参数非常之多,这里只列举比较重要的几个,通过各种各样的搜索引擎也可以得知这些信息。\n参数名称 含义 默认值 说明 -Xms 初始堆大小 物理内存的 1/64(\u0026lt;1GB) 默认(MinHeapFreeRatio 参数可以调整)空余堆内存小于 40%时,JVM 就会增大堆直到-Xmx 的最大限制. -Xmx 最大堆大小 物理内存的 1/4(\u0026lt;1GB) 默认(MaxHeapFreeRatio 参数可以调整)空余堆内存大于 70%时,JVM 会减少堆直到 -Xms 的最小限制 -Xmn 年轻代大小(1.4or later) 注意:此处的大小是(eden+ 2 survivor space).与 jmap -heap 中显示的 New gen 是不同的。整个堆大小=年轻代大小 + 老年代大小 + 持久代(永久代)大小.增大年轻代后,将会减小年老代大小.此值对系统性能影响较大,Sun 官方推荐配置为整个堆的 3/8 -XX:NewSize 设置年轻代大小(for 1.3/1.4) -XX:MaxNewSize 年轻代最大值(for 1.3/1.4) -XX:PermSize 设置持久代(perm gen)初始值 物理内存的 1/64 -XX:MaxPermSize 设置持久代最大值 物理内存的 1/4 -Xss 每个线程的堆栈大小 JDK5.0 以后每个线程堆栈大小为 1M,以前每个线程堆栈大小为 256K.根据应用的线程所需内存大小进行 调整.在相同物理内存下,减小这个值能生成更多的线程.但是操作系统对一个进程内的线程数还是有限制的,不能无限生成,经验值在 3000~5000 左右一般小的应用, 如果栈不是很深, 应该是 128k 够用的 大的应用建议使用 256k。这个选项对性能影响比较大,需要严格的测试。(校长)和 threadstacksize 选项解释很类似,官方文档似乎没有解释,在论坛中有这样一句话:-Xss is translated in a VM flag named ThreadStackSize”一般设置这个值就可以了 -XX:NewRatio 年轻代(包括 Eden 和两个 Survivor 区)与年老代的比值(除去持久代) -XX:NewRatio=4 表示年轻代与年老代所占比值为 1:4,年轻代占整个堆栈的 1/5Xms=Xmx 并且设置了 Xmn 的情况下,该参数不需要进行设置。 -XX:SurvivorRatio Eden 区与 Survivor 区的大小比值 设置为 8,则两个 Survivor 区与一个 Eden 区的比值为 2:8,一个 Survivor 区占整个年轻代的 1/10 -XX:+DisableExplicitGC 关闭 System.gc() 这个参数需要严格的测试 -XX:PretenureSizeThreshold 对象超过多大是直接在旧生代分配 0 单位字节 新生代采用 Parallel ScavengeGC 时无效另一种直接在旧生代分配的情况是大的数组对象,且数组中无外部引用对象. -XX:ParallelGCThreads 并行收集器的线程数 此值最好配置与处理器数目相等 同样适用于 CMS -XX:MaxGCPauseMillis 每次年轻代垃圾回收的最长时间(最大暂停时间) 如果无法满足此时间,JVM 会自动调整年轻代大小,以满足此值. 其实还有一些打印及 CMS 方面的参数,这里就不以一一列举了\n四、关于 JVM 调优的一些方面 # 根据刚刚涉及的 jvm 的知识点,我们可以尝试对 JVM 进行调优,主要就是堆内存那块\n所有线程共享数据区大小=新生代大小 + 年老代大小 + 持久代大小。持久代一般固定大小为 64m。所以 java 堆中增大年轻代后,将会减小年老代大小(因为老年代的清理是使用 fullgc,所以老年代过小的话反而是会增多 fullgc 的)。此值对系统性能影响较大,Sun 官方推荐配置为 java 堆的 3/8。\n4.1 调整最大堆内存和最小堆内存 # -Xmx –Xms:指定 java 堆最大值(默认值是物理内存的 1/4(\u0026lt;1GB))和初始 java 堆最小值(默认值是物理内存的 1/64(\u0026lt;1GB))\n默认(MinHeapFreeRatio 参数可以调整)空余堆内存小于 40%时,JVM 就会增大堆直到-Xmx 的最大限制.,默认(MaxHeapFreeRatio 参数可以调整)空余堆内存大于 70%时,JVM 会减少堆直到 -Xms 的最小限制。简单点来说,你不停地往堆内存里面丢数据,等它剩余大小小于 40%了,JVM 就会动态申请内存空间不过会小于-Xmx,如果剩余大小大于 70%,又会动态缩小不过不会小于–Xms。就这么简单\n开发过程中,通常会将 -Xms 与 -Xmx 两个参数配置成相同的值,其目的是为了能够在 java 垃圾回收机制清理完堆区后不需要重新分隔计算堆区的大小而浪费资源。\n我们执行下面的代码\nSystem.out.println(\u0026#34;Xmx=\u0026#34; + Runtime.getRuntime().maxMemory() / 1024.0 / 1024 + \u0026#34;M\u0026#34;); //系统的最大空间 System.out.println(\u0026#34;free mem=\u0026#34; + Runtime.getRuntime().freeMemory() / 1024.0 / 1024 + \u0026#34;M\u0026#34;); //系统的空闲空间 System.out.println(\u0026#34;total mem=\u0026#34; + Runtime.getRuntime().totalMemory() / 1024.0 / 1024 + \u0026#34;M\u0026#34;); //当前可用的总空间 注意:此处设置的是 Java 堆大小,也就是新生代大小 + 老年代大小\n设置一个 VM options 的参数\n-Xmx20m -Xms5m -XX:+PrintGCDetails 再次启动 main 方法\n这里 GC 弹出了一个 Allocation Failure 分配失败,这个事情发生在 PSYoungGen,也就是年轻代中\n这时候申请到的内存为 18M,空闲内存为 4.214195251464844M\n我们此时创建一个字节数组看看,执行下面的代码\nbyte[] b = new byte[1 * 1024 * 1024]; System.out.println(\u0026#34;分配了1M空间给数组\u0026#34;); System.out.println(\u0026#34;Xmx=\u0026#34; + Runtime.getRuntime().maxMemory() / 1024.0 / 1024 + \u0026#34;M\u0026#34;); //系统的最大空间 System.out.println(\u0026#34;free mem=\u0026#34; + Runtime.getRuntime().freeMemory() / 1024.0 / 1024 + \u0026#34;M\u0026#34;); //系统的空闲空间 System.out.println(\u0026#34;total mem=\u0026#34; + Runtime.getRuntime().totalMemory() / 1024.0 / 1024 + \u0026#34;M\u0026#34;); 此时 free memory 就又缩水了,不过 total memory 是没有变化的。Java 会尽可能将 total mem 的值维持在最小堆内存大小\nbyte[] b = new byte[10 * 1024 * 1024]; System.out.println(\u0026#34;分配了10M空间给数组\u0026#34;); System.out.println(\u0026#34;Xmx=\u0026#34; + Runtime.getRuntime().maxMemory() / 1024.0 / 1024 + \u0026#34;M\u0026#34;); //系统的最大空间 System.out.println(\u0026#34;free mem=\u0026#34; + Runtime.getRuntime().freeMemory() / 1024.0 / 1024 + \u0026#34;M\u0026#34;); //系统的空闲空间 System.out.println(\u0026#34;total mem=\u0026#34; + Runtime.getRuntime().totalMemory() / 1024.0 / 1024 + \u0026#34;M\u0026#34;); //当前可用的总空间 这时候我们创建了一个 10M 的字节数据,这时候最小堆内存是顶不住的。我们会发现现在的 total memory 已经变成了 15M,这就是已经申请了一次内存的结果。\n此时我们再跑一下这个代码\nSystem.gc(); System.out.println(\u0026#34;Xmx=\u0026#34; + Runtime.getRuntime().maxMemory() / 1024.0 / 1024 + \u0026#34;M\u0026#34;); //系统的最大空间 System.out.println(\u0026#34;free mem=\u0026#34; + Runtime.getRuntime().freeMemory() / 1024.0 / 1024 + \u0026#34;M\u0026#34;); //系统的空闲空间 System.out.println(\u0026#34;total mem=\u0026#34; + Runtime.getRuntime().totalMemory() / 1024.0 / 1024 + \u0026#34;M\u0026#34;); //当前可用的总空间 此时我们手动执行了一次 fullgc,此时 total memory 的内存空间又变回 5.5M 了,此时又是把申请的内存释放掉的结果。\n4.2 调整新生代和老年代的比值 # -XX:NewRatio --- 新生代(eden+2\\*Survivor)和老年代(不包含永久区)的比值 例如:-XX:NewRatio=4,表示新生代:老年代=1:4,即新生代占整个堆的 1/5。在 Xms=Xmx 并且设置了 Xmn 的情况下,该参数不需要进行设置。 4.3 调整 Survivor 区和 Eden 区的比值 # -XX:SurvivorRatio(幸存代)--- 设置两个 Survivor 区和 eden 的比值 例如:8,表示两个 Survivor:eden=2:8,即一个 Survivor 占年轻代的 1/10 4.4 设置年轻代和老年代的大小 # -XX:NewSize --- 设置年轻代大小 -XX:MaxNewSize --- 设置年轻代最大值 可以通过设置不同参数来测试不同的情况,反正最优解当然就是官方的 Eden 和 Survivor 的占比为 8:1:1,然后在刚刚介绍这些参数的时候都已经附带了一些说明,感兴趣的也可以看看。反正最大堆内存和最小堆内存如果数值不同会导致多次的 gc,需要注意。\n4.5 小总结 # 根据实际事情调整新生代和幸存代的大小,官方推荐新生代占 java 堆的 3/8,幸存代占新生代的 1/10\n在 OOM 时,记得 Dump 出堆,确保可以排查现场问题,通过下面命令你可以输出一个.dump 文件,这个文件可以使用 VisualVM 或者 Java 自带的 Java VisualVM 工具。\n-Xmx20m -Xms5m -XX:+HeapDumpOnOutOfMemoryError -XX:HeapDumpPath=你要输出的日志路径 一般我们也可以通过编写脚本的方式来让 OOM 出现时给我们报个信,可以通过发送邮件或者重启程序等来解决。\n4.6 永久区的设置 # -XX:PermSize -XX:MaxPermSize 初始空间(默认为物理内存的 1/64)和最大空间(默认为物理内存的 1/4)。也就是说,jvm 启动时,永久区一开始就占用了 PermSize 大小的空间,如果空间还不够,可以继续扩展,但是不能超过 MaxPermSize,否则会 OOM。\ntips:如果堆空间没有用完也抛出了 OOM,有可能是永久区导致的。堆空间实际占用非常少,但是永久区溢出 一样抛出 OOM。\n4.7 JVM 的栈参数调优 # 4.7.1 调整每个线程栈空间的大小 # 可以通过-Xss:调整每个线程栈空间的大小\nJDK5.0 以后每个线程堆栈大小为 1M,以前每个线程堆栈大小为 256K。在相同物理内存下,减小这个值能生成更多的线程。但是操作系统对一个进程内的线程数还是有限制的,不能无限生成,经验值在 3000~5000 左右\n4.7.2 设置线程栈的大小 # -XXThreadStackSize: 设置线程栈的大小(0 means use default stack size) 这些参数都是可以通过自己编写程序去简单测试的,这里碍于篇幅问题就不再提供 demo 了\n4.8 (可以直接跳过了)JVM 其他参数介绍 # 形形色色的参数很多,就不会说把所有都扯个遍了,因为大家其实也不会说一定要去深究到底。\n4.8.1 设置内存页的大小 # -XXThreadStackSize: 设置内存页的大小,不可设置过大,会影响Perm的大小 4.8.2 设置原始类型的快速优化 # -XX:+UseFastAccessorMethods: 设置原始类型的快速优化 4.8.3 设置关闭手动 GC # -XX:+DisableExplicitGC: 设置关闭System.gc()(这个参数需要严格的测试) 4.8.4 设置垃圾最大年龄 # -XX:MaxTenuringThreshold 设置垃圾最大年龄。如果设置为0的话,则年轻代对象不经过Survivor区,直接进入年老代.对于年老代比较多的应用,可以提高效率。如果将此值设置为一个较大值,则年轻代对象会在Survivor区进行多次复制,这样可以增加对象再年轻代的存活时间,加在年轻代即被回收的概率。该参数只有在串行GC时才有效. 4.8.5 加快编译速度 # -XX:+AggressiveOpts 加快编译速度 4.8.6 改善锁机制性能 # -XX:+UseBiasedLocking 4.8.7 禁用垃圾回收 # -Xnoclassgc 4.8.8 设置堆空间存活时间 # -XX:SoftRefLRUPolicyMSPerMB 设置每兆堆空闲空间中SoftReference的存活时间,默认值是1s。 4.8.9 设置对象直接分配在老年代 # -XX:PretenureSizeThreshold 设置对象超过多大时直接在老年代分配,默认值是0。 4.8.10 设置 TLAB 占 eden 区的比例 # -XX:TLABWasteTargetPercent 设置TLAB占eden区的百分比,默认值是1% 。 4.8.11 设置是否优先 YGC # -XX:+CollectGen0First 设置FullGC时是否先YGC,默认值是false。 finally # 真的扯了很久这东西,参考了多方的资料,有极客时间的《深入拆解虚拟机》和《Java 核心技术面试精讲》,也有百度,也有自己在学习的一些线上课程的总结。希望对你有所帮助,谢谢。\n"},{"id":609,"href":"/zh/docs/technology/Interview/system-design/basis/naming/","title":"代码命名指南","section":"Basis","content":"我还记得我刚工作那一段时间, 项目 Code Review 的时候,我经常因为变量命名不规范而被 “diss”!\n究其原因还是自己那会经验不足,而且,大学那会写项目的时候不太注意这些问题,想着只要把功能实现出来就行了。\n但是,工作中就不一样,为了代码的可读性、可维护性,项目组对于代码质量的要求还是很高的!\n前段时间,项目组新来的一个实习生也经常在 Code Review 因为变量命名不规范而被 “diss”,这让我想到自己刚到公司写代码那会的日子。\n于是,我就简单写了这篇关于变量命名规范的文章,希望能对同样有此困扰的小伙伴提供一些帮助。\n确实,编程过程中,有太多太多让我们头疼的事情了,比如命名、维护其他人的代码、写测试、与其他人沟通交流等等。\n据说之前在 Quora 网站,由接近 5000 名程序员票选出来的最难的事情就是“命名”。\n大名鼎鼎的《重构》的作者老马(Martin Fowler)曾经在 TwoHardThings这篇文章中提到过 CS 领域有两大最难的事情:一是 缓存失效 ,一是 程序命名 。\n这个句话实际上也是老马引用别人的,类似的表达还有很多。比如分布式系统领域有两大最难的事情:一是 保证消息顺序 ,一是 严格一次传递 。\n今天咱们就单独拎出 “命名” 来聊聊!\n这篇文章配合我之前发的 《编码 5 分钟,命名 2 小时?史上最全的 Java 命名规范参考!》 这篇文章阅读效果更佳哦!\n为什么需要重视命名? # 咱们需要先搞懂为什么要重视编程中的命名这一行为,它对于我们的编码工作有着什么意义。\n为什么命名很重要呢? 这是因为 好的命名即是注释,别人一看到你的命名就知道你的变量、方法或者类是做什么的!\n简单来说就是 别人根据你的命名就能知道你的代码要表达的意思 (不过,前提这个人也要有基本的英语知识,对于一些编程中常见的单词比较熟悉)。\n简单举个例子说明一下命名的重要性。\n《Clean Code》这本书明确指出:\n好的代码本身就是注释,我们要尽量规范和美化自己的代码来减少不必要的注释。\n若编程语言足够有表达力,就不需要注释,尽量通过代码来阐述。\n举个例子:\n去掉下面复杂的注释,只需要创建一个与注释所言同一事物的函数即可\n// check to see if the employee is eligible for full benefits if ((employee.flags \u0026amp; HOURLY_FLAG) \u0026amp;\u0026amp; (employee.age \u0026gt; 65)) 应替换为\nif (employee.isEligibleForFullBenefits()) 常见命名规则以及适用场景 # 这里只介绍 3 种最常见的命名规范。\n驼峰命名法(CamelCase) # 驼峰命名法应该我们最常见的一个,这种命名方式使用大小写混合的格式来区别各个单词,并且单词之间不使用空格隔开或者连接字符连接的命名方式\n大驼峰命名法(UpperCamelCase) # 类名需要使用大驼峰命名法(UpperCamelCase)\n正例:\nServiceDiscovery、ServiceInstance、LruCacheFactory 反例:\nserviceDiscovery、Serviceinstance、LRUCacheFactory 小驼峰命名法(lowerCamelCase) # 方法名、参数名、成员变量、局部变量需要使用小驼峰命名法(lowerCamelCase)。\n正例:\ngetUserInfo() createCustomThreadPool() setNameFormat(String nameFormat) Uservice userService; 反例:\nGetUserInfo()、CreateCustomThreadPool()、setNameFormat(String NameFormat) Uservice user_service 蛇形命名法(snake_case) # 测试方法名、常量、枚举名称需要使用蛇形命名法(snake_case)\n在蛇形命名法中,各个单词之间通过下划线“_”连接,比如should_get_200_status_code_when_request_is_valid、CLIENT_CONNECT_SERVER_FAILURE。\n蛇形命名法的优势是命名所需要的单词比较多的时候,比如我把上面的命名通过小驼峰命名法给大家看一下:“shouldGet200StatusCodeWhenRequestIsValid”。\n感觉如何? 相比于使用蛇形命名法(snake_case)来说是不是不那么易读?\n正例:\n@Test void should_get_200_status_code_when_request_is_valid() { ...... } 反例:\n@Test void shouldGet200StatusCodeWhenRequestIsValid() { ...... } 串式命名法(kebab-case) # 在串式命名法中,各个单词之间通过连接符“-”连接,比如dubbo-registry。\n建议项目文件夹名称使用串式命名法(kebab-case),比如 dubbo 项目的各个模块的命名是下面这样的。\n常见命名规范 # Java 语言基本命名规范 # 1、类名需要使用大驼峰命名法(UpperCamelCase)风格。方法名、参数名、成员变量、局部变量需要使用小驼峰命名法(lowerCamelCase)。\n2、测试方法名、常量、枚举名称需要使用蛇形命名法(snake_case),比如should_get_200_status_code_when_request_is_valid、CLIENT_CONNECT_SERVER_FAILURE。并且,测试方法名称要求全部小写,常量以及枚举名称需要全部大写。\n3、项目文件夹名称使用串式命名法(kebab-case),比如dubbo-registry。\n4、包名统一使用小写,尽量使用单个名词作为包名,各个单词通过 \u0026ldquo;.\u0026rdquo; 分隔符连接,并且各个单词必须为单数。\n正例:org.apache.dubbo.common.threadlocal\n反例:org.apache_dubbo.Common.threadLocals\n5、抽象类命名使用 Abstract 开头。\n//为远程传输部分抽象出来的一个抽象类(出处:Dubbo源码) public abstract class AbstractClient extends AbstractEndpoint implements Client { } 6、异常类命名使用 Exception 结尾。\n//自定义的 NoSuchMethodException(出处:Dubbo源码) public class NoSuchMethodException extends RuntimeException { private static final long serialVersionUID = -2725364246023268766L; public NoSuchMethodException() { super(); } public NoSuchMethodException(String msg) { super(msg); } } 7、测试类命名以它要测试的类的名称开始,以 Test 结尾。\n//为 AnnotationUtils 类写的测试类(出处:Dubbo源码) public class AnnotationUtilsTest { ...... } POJO 类中布尔类型的变量,都不要加 is 前缀,否则部分框架解析会引起序列化错误。\n如果模块、接口、类、方法使用了设计模式,在命名时需体现出具体模式。\n命名易读性规范 # 1、为了能让命名更加易懂和易读,尽量不要缩写/简写单词,除非这些单词已经被公认可以被这样缩写/简写。比如 CustomThreadFactory 不可以被写成 ~~CustomTF 。\n2、命名不像函数一样要尽量追求短,可读性强的名字优先于简短的名字,虽然可读性强的名字会比较长一点。 这个对应我们上面说的第 1 点。\n3、避免无意义的命名,你起的每一个名字都要能表明意思。\n正例:UserService userService; int userCount;\n反例: UserService service int count\n4、避免命名过长(50 个字符以内最好),过长的命名难以阅读并且丑陋。\n5、不要使用拼音,更不要使用中文。 不过像 alibaba、wuhan、taobao 这种国际通用名词可以当做英文来看待。\n正例:discount\n反例:dazhe\nCodelf:变量命名神器? # 这是一个由国人开发的网站,网上有很多人称其为变量命名神器, 我在实际使用了几天之后感觉没那么好用。小伙伴们可以自行体验一下,然后再给出自己的判断。\nCodelf 提供了在线网站版本,网址: https://unbug.github.io/codelf/,具体使用情况如下:\n我选择了 Java 编程语言,然后搜索了“序列化”这个关键词,然后它就返回了很多关于序列化的命名。\n并且,Codelf 还提供了 VS code 插件,看这个评价,看来大家还是很喜欢这款命名工具的。\n相关阅读推荐 # 《阿里巴巴 Java 开发手册》 《Clean Code》 Google Java 代码指南: https://google.github.io/styleguide/javaguide.html 告别编码 5 分钟,命名 2 小时!史上最全的 Java 命名规范参考: https://www.cnblogs.com/liqiangchn/p/12000361.html 总结 # 作为一个合格的程序员,小伙伴们应该都知道代码表义的重要性。想要写出高质量代码,好的命名就是第一步!\n好的命名对于其他人(包括你自己)理解你的代码有着很大的帮助!你的代码越容易被理解,可维护性就越强,侧面也就说明你的代码设计的也就越好!\n在日常编码过程中,我们需要谨记常见命名规范比如类名需要使用大驼峰命名法、不要使用拼音,更不要使用中文……。\n另外,国人开发的一个叫做 Codelf 的网站被很多人称为“变量命名神器”,当你为命名而头疼的时候,你可以去参考一下上面提供的一些命名示例。\n最后,祝愿大家都不用再为命名而困扰!\n"},{"id":610,"href":"/zh/docs/technology/Interview/system-design/basis/refactoring/","title":"代码重构指南","section":"Basis","content":"前段时间重读了 《重构:改善代码既有设计》,收货颇多。于是,简单写了一篇文章来聊聊我对重构的看法。\n何谓重构? # 学习重构必看的一本神书《重构:改善代码既有设计》从两个角度给出了重构的定义:\n重构(名词):对软件内部结构的一种调整,目的是在不改变软件可观察行为的前提下,提高其可理解性,降低其修改成本。 重构(动词):使用一系列重构手法,在不改变软件可观察行为的前提下,调整其结构。 用更贴近工程师的语言来说:重构就是利用设计模式(如组合模式、策略模式、责任链模式)、软件设计原则(如 SOLID 原则、YAGNI 原则、KISS 原则)和重构手段(如封装、继承、构建测试体系)来让代码更容易理解,更易于修改。\n软件设计原则指导着我们组织和规范代码,同时,重构也是为了能够尽量设计出尽量满足软件设计原则的软件。\n正确重构的核心在于 步子一定要小,每一步的重构都不会影响软件的正常运行,可以随时停止重构。\n常见的设计模式如下:\n更全面的设计模式总结,可以看 java-design-patterns 这个开源项目。\n常见的软件设计原则如下:\n更全面的设计原则总结,可以看 java-design-patterns 和 hacker-laws-zh 这两个开源项目。\n为什么要重构? # 在上面介绍重构定义的时候,我从比较抽象的角度介绍了重构的好处:重构的主要目的主要是提升代码\u0026amp;架构的灵活性/可扩展性以及复用性。\n如果对应到一个真实的项目,重构具体能为我们带来什么好处呢?\n让代码更容易理解:通过添加注释、命名规范、逻辑优化等手段可以让我们的代码更容易被理解; 避免代码腐化:通过重构干掉坏味道代码; 加深对代码的理解:重构代码的过程会加深你对某部分代码的理解; 发现潜在 bug:是这样的,很多潜在的 bug ,都是我们在重构的过程中发现的; …… 看了上面介绍的关于重构带来的好处之后,你会发现重构的最终目标是 提高软件开发速度和质量 。\n重构并不会减慢软件开发速度,相反,如果代码质量和软件设计较差,当我们想要添加新功能的话,开发速度会越来越慢。到了最后,甚至都有想要重写整个系统的冲动。\n《重构:改善代码既有设计》这本书中这样说:\n重构的唯一目的就是让我们开发更快,用更少的工作量创造更大的价值。\n性能优化就是重构吗? # 重构的目的是提高代码的可读性、可维护性和灵活性,它关注的是代码的内部结构——如何让开发者更容易理解代码,如何让后续的功能开发和维护更加高效。而性能优化则是为了让代码运行得更快、占用更少的资源,它关注的是程序的外部表现——如何减少响应时间、降低资源消耗、提升系统吞吐量。这两者看似对立,但实际上它们的目标是统一的,都是为了提高软件的整体质量。\n在实际开发中,理想的做法是首先确保代码的可读性和可维护性,然后根据实际需求选择合适的性能优化手段。优秀的软件设计不是一味追求性能最大化,而是要在可维护性和性能之间找到平衡。通过这种方式,我们可以打造既易于管理又具有良好性能的软件系统。\n何时进行重构? # 重构在是开发过程中随时可以进行的,见机行事即可,并不需要单独分配一两天的时间专门用来重构。\n提交代码之前 # 《重构:改善代码既有设计》这本书介绍了一个 营地法则 的概念:\n编程时,需要遵循营地法则:保证你离开时的代码库一定比来时更健康。\n这个概念表达的核心思想其实很简单:在你提交代码的之前,花一会时间想一想,我这次的提交是让项目代码变得更健康了,还是更腐化了,或者说没什么变化?\n项目团队的每一个人只有保证自己的提交没有让项目代码变得更腐化,项目代码才会朝着健康的方向发展。\n当我们离开营地(项目代码)的时候,请不要留下垃圾(代码坏味道)!尽量确保营地变得更干净了!\n开发一个新功能之后\u0026amp;之前 # 在开发一个新功能之后,我们应该回过头看看是不是有可以改进的地方。在添加一个新功能之前,我们可以思考一下自己是否可以重构代码以让新功能的开发更容易。\n一个新功能的开发不应该仅仅只有功能验证通过那么简单,我们还应该尽量保证代码质量。\n有一个两顶帽子的比喻:在我开发新功能之前,我发现重构可以让新功能的开发更容易,于是我戴上了重构的帽子。重构之后,我换回原来的帽子,继续开发新能功能。新功能开发完成之后,我又发现自己的代码难以理解,于是我又戴上了重构帽子。比较好的开发状态就是就是这样在重构和开发新功能之间来回切换。\nCode Review 之后 # Code Review 可以非常有效提高代码的整体质量,它会帮助我们发现代码中的坏味道以及可能存在问题的地方。并且, Code Review 可以帮助项目团队其他程序员理解你负责的业务模块,有效避免人员方面的单点风险。\n经历一次 Code Review ,你的代码可能会收到很多改进建议。\n捡垃圾式重构 # 当我们发现坏味道代码(垃圾)的时候,如果我们不想停下手头自己正在做的工作,但又不想放着垃圾不管,我们可以这样做:\n如果这个垃圾很容易重构的话,我们可以立即重构它。 如果这个垃圾不太容易重构的话,我们可以先记录下来,当完成当下的任务再回来重构它。 阅读理解代码的时候 # 搞开发的小伙伴应该非常有体会:我们经常需要阅读项目团队中其他人写的代码,也经常需要阅读自己过去写的代码。阅读代码的时候,通常要比我们写代码的时间还要多很多。\n我们在阅读理解代码的时候,如果发现一些坏味道的话,我们就可以对其进行重构。\n就比如说你在阅读张三写的某段代码的时候,你发现这段代码逻辑过于复杂难以理解,你有更好的写法,那你就可以对张三的这段代码逻辑进行重构。\n重构有哪些注意事项? # 单元测试是重构的保护网 # 单元测试可以为重构提供信心,降低重构的成本。我们要像重视生产代码那样,重视单元测试。\n另外,多提一句:持续集成也要依赖单元测试,当持续集成服务自动构建新代码之后,会自动运行单元测试来发现代码错误。\n怎样才能算单元测试呢? 网上的定义很多,很抽象,很容易把人给看迷糊了。我觉得对于单元测试的定义主要取决于你的项目,一个函数甚至是一个类都可以看作是一个单元。就比如说我们写了一个计算个人股票收益率的方法,我们为了验证它的正确性专门为它写了一个单元测试。再比如说我们代码有一个类专门负责数据脱敏,我们为了验证脱敏是否符合预期专门为这个类写了一个单元测试。\n单元测试也是需要重构或者修改的。 《代码整洁之道:敏捷软件开发手册》这本书这样写到:\n测试代码需要随着生产代码的演进而修改,如果测试不能保持整洁,只会越来越难修改。\n不要为了重构而重构 # 重构一定是要为项目带来价值的! 某些情况下我们不应该进行重构:\n学习了某个设计模式/工程实践之后,不顾项目实际情况,刻意使用在项目上(避免货物崇拜编程); 项目进展比较急的时候,重构项目调用的某个 API 的底层代码(重构之后对项目调用这个 API 并没有带来什么价值); 重写比重构更容易更省事; …… 遵循方法 # 《重构:改善代码既有设计》这本书中列举除了代码常见的一些坏味道(比如重复代码、过长函数)和重构手段(如提炼函数、提炼变量、提炼类)。我们应该花时间去学习这些重构相关的理论知识,并在代码中去实践这些重构理论。\n如何练习重构? # 除了可以在重构项目代码的过程中练习精进重构之外,你还可以有下面这些手段:\n当我重构时,我在想些什么:转转技术的这篇文章总结了常见的重构场景和重构方式。 重构实战练习:通过几个小案例一步一步带你学习重构! 设计模式+重构学习网站:免费在线学习代码重构、 设计模式、 SOLID 原则 (单一职责、 开闭原则、 里氏替换、 接口隔离以及依赖反转) 。 IDEA 官方文档的代码重构教程:教你如何使用 IDEA 进行重构。 参考 # 再读《重构》- ThoughtWorks 洞见 - 2020:详细介绍了重构的要点比如小步重构、捡垃圾式的重构,主要是重构概念相关的介绍。 常见代码重构技巧 - VectorJin - 2021:从软件设计原则、设计模式、代码分层、命名规范等角度介绍了如何进行重构,比较偏实战。 "},{"id":611,"href":"/zh/docs/technology/Interview/system-design/basis/unit-test/","title":"单元测试到底是什么?应该怎么做?","section":"Basis","content":" 本文重构完善自 谈谈为什么写单元测试 - 键盘男 - 2016这篇文章。\n何谓单元测试? # 维基百科是这样介绍单元测试的:\n在计算机编程中,单元测试(Unit Testing)是针对程序模块(软件设计的最小单位)进行的正确性检验测试工作。\n程序单元是应用的 最小可测试部件 。在过程化编程中,一个单元就是单个程序、函数、过程等;对于面向对象编程,最小单元就是方法,包括基类(超类)、抽象类、或者派生类(子类)中的方法。\n由于每个单元有独立的逻辑,在做单元测试时,为了隔离外部依赖,确保这些依赖不影响验证逻辑,我们经常会用到 Fake、Stub 与 Mock 。\n关于 Fake、Mock 与 Stub 这几个概念的解读,可以看看这篇文章: 测试中 Fakes、Mocks 以及 Stubs 概念明晰 - 王下邀月熊 - 2018 。\n为什么需要单元测试? # 为重构保驾护航 # 我在 重构这篇文章中这样写到:\n单元测试可以为重构提供信心,降低重构的成本。我们要像重视生产代码那样,重视单元测试。\n每个开发者都会经历重构,重构后把代码改坏了的情况并不少见,很可能你只是修改了一个很简单的方法就导致系统出现了一个比较严重的错误。\n如果有了单元测试的话,就不会存在这个隐患了。写完一个类,把单元测试写了,确保这个类逻辑正确;写第二个类,单元测试……写 100 个类,道理一样,每个类做到第一点“保证逻辑正确性”,100 个类拼在一起肯定不出问题。你大可以放心一边重构,一边运行 APP;而不是整体重构完,提心吊胆地 run。\n提高代码质量 # 由于每个单元有独立的逻辑,做单元测试时需要隔离外部依赖,确保这些依赖不影响验证逻辑。因为要把各种依赖分离,单元测试会促进工程进行组件拆分,整理工程依赖关系,更大程度减少代码耦合。这样写出来的代码,更好维护,更好扩展,从而提高代码质量。\n减少 bug # 一个机器,由各种细小的零件组成,如果其中某件零件坏了,机器运行故障。必须保证每个零件都按设计图要求的规格,机器才能正常运行。\n一个可单元测试的工程,会把业务、功能分割成规模更小、有独立的逻辑部件,称为单元。单元测试的目标,就是保证各个单元的逻辑正确性。单元测试保障工程各个“零件”按“规格”(需求)执行,从而保证整个“机器”(项目)运行正确,最大限度减少 bug。\n快速定位 bug # 如果程序有 bug,我们运行一次全部单元测试,找到不通过的测试,可以很快地定位对应的执行代码。修复代码后,运行对应的单元测试;如还不通过,继续修改,运行测试……直到测试通过。\n持续集成依赖单元测试 # 持续集成需要依赖单元测试,当持续集成服务自动构建新代码之后,会自动运行单元测试来发现代码错误。\n谁逼你写单元测试? # 领导要求 # 有些经验丰富的领导,或多或少都会要求团队写单元测试。对于有一定工作经验的队友,这要求挺合理;对于经验尚浅的、毕业生,恐怕要死要活了,连代码都写不好,还要写单元测试,are you kidding me?\n培训新人单元测试用法,是一项艰巨的任务。新人代码风格未形成,也不知道单元测试多重要,强制单元测试会让他们感到困惑,没办法按自己思路写代码。\n大牛都写单元测试 # 国外很多家喻户晓的开源项目,都有大量单元测试。例如, retrofit、 okhttp、 butterknife…… 国外大牛都写单元测试,我们也写吧!\n很多读者都有这种想法,一开始满腔热血。当真要对自己项目单元测试时,便困难重重,很大原因是项目对单元测试不友好。最后只能对一些不痛不痒的工具类做单元测试,久而久之,当初美好愿望也不了了之。\n保住面子 # 都是有些许年经验的老鸟,还天天被测试同学追 bug,好意思么?花多一点时间写单元测试,确保没低级 bug,还能彰显大牛风范,何乐而不为?\n心虚 # 笔者也是个不太相信自己代码的人,总觉得哪里会突然冒出莫名其妙的 bug,也怕别人不小心改了自己的代码(被害妄想症),新版本上线提心吊胆……花点时间写单元测试,有事没事跑一下测试,确保原逻辑没问题,至少能睡安稳一点。\nTDD 测试驱动开发 # 何谓 TDD? # TDD 即 Test-Driven Development( 测试驱动开发),这是敏捷开发的一项核心实践和技术,也是一种设计方法论。\nTDD 原理是开发功能代码之前,先编写测试用例代码,然后针对测试用例编写功能代码,使其能够通过。\nTDD 的节奏:“红 - 绿 - 重构”。\n由于 TDD 对开发人员要求非常高,跟传统开发思维不一样,因此实施起来相当困难。\nTDD 在很多人眼中是不实用的,一来他们并不理解测试“驱动”开发的含义,但更重要的是,他们很少会做任务分解。而任务分解是做好 TDD 的关键点。只有把任务分解到可以测试的地步,才能够有针对性地写测试。\nTDD 优缺点分析 # 测试驱动开发有好处也有坏处。因为每个测试用例都是根据需求来的,或者说把一个大需求分解成若干小需求编写测试用例,所以测试用例写出来后,开发者写的执行代码,必须满足测试用例。如果测试不通过,则修改执行代码,直到测试用例通过。\n优点:\n帮你整理需求,梳理思路; 帮你设计出更合理的接口(空想的话很容易设计出屎); 减小代码出现 bug 的概率; 提高开发效率(前提是正确且熟练使用 TDD)。 缺点:\n能用好 TDD 的人非常少,看似简单,实则门槛很高; 投入开发资源(时间和精力)通常会更多; 由于测试用例在未进行代码设计前写;很有可能限制开发者对代码整体设计; 可能引起开发人员不满情绪,我觉得这点很严重,毕竟不是人人都喜欢单元测试,尽管单元测试会带给我们相当多的好处。 相关阅读: 如何用正确的姿势打开 TDD? - 陈天 - 2017 。\n单测框架如何选择? # 对于单测来说,目前常用的单测框架有:JUnit、Mockito、Spock、PowerMock、JMockit、TestableMock 等等。\nJUnit 几乎是默认选择,但是其不支持 Mock,因此我们还需要选择一个 Mock 工具。Mockito 和 Spock 是最主流的两款 Mock 工具,一般都是在这两者中选择。\n究竟是选择 Mockito 还是 Spock 呢?我这里做了一些简单的对比分析:\nSpock 没办法 Mock 静态方法和私有方法 ,Mockito 3.4.0 以后,支持静态方法的 Mock,具体可以看这个 issue: https://github.com/mockito/mockito/issues/1013,具体教程可以看这篇文章: https://www.baeldung.com/mockito-mock-static-methods。 Spock 基于 Groovy,写出来的测试代码更清晰易读,比较规范(自带 given-when-then 的常用测试结构规范)。Mockito 没有具体的结构规范,需要项目组自己约定一个或者遵守比较好的测试代码实践。通常来说,同样的测试用例,Spock 的代码要更简洁。 Mockito 使用的人群更广泛,稳定可靠。并且,Mockito 是 SpringBoot Test 默认集成的 Mock 工具。 Mockito 和 Spock 都是非常不错的 Mock 工具,相对来说,Mockito 的适用性更强一些。\n总结 # 单元测试确实会带给你相当多的好处,但不是立刻体验出来。正如买重疾保险,交了很多保费,没病没痛,十几年甚至几十年都用不上,最好就是一辈子用不上理赔,身体健康最重要。单元测试也一样,写了可以买个放心,对代码的一种保障,有 bug 尽快测出来,没 bug 就最好,总不能说“写那么多单元测试,结果测不出 bug,浪费时间”吧?\n以下是个人对单元测试一些建议:\n越重要的代码,越要写单元测试; 代码做不到单元测试,多思考如何改进,而不是放弃; 边写业务代码,边写单元测试,而不是完成整个新功能后再写; 多思考如何改进、简化测试代码。 测试代码需要随着生产代码的演进而重构或者修改,如果测试不能保持整洁,只会越来越难修改。 作为一名经验丰富的程序员,写单元测试更多的是对自己的代码负责。有测试用例的代码,别人更容易看懂,以后别人接手你的代码时,也可能放心做改动。\n多敲代码实践,多跟有单元测试经验的工程师交流,你会发现写单元测试获得的收益会更多。\n"},{"id":612,"href":"/zh/docs/technology/Interview/high-quality-technical-articles/personal-experience/two-years-of-back-end-develop--experience-in-didi-and-toutiao/","title":"滴滴和头条两年后端工作经验分享","section":"Personal Experience","content":" 推荐语:很实用的工作经验分享,看完之后十分受用!\n内容概览:\n要学会深入思考,总结沉淀,这是我觉得最重要也是最有意义的一件事。 积极学习,保持技术热情。如果我们积极学习,保持技术能力、知识储备与工作年限成正比,这到了 35 岁哪还有什么焦虑呢,这样的大牛我觉得应该也是各大公司抢着要吧? 在能为公司办成事,创造价值这一点上,我觉得最重要的两个字就是主动,主动承担任务,主动沟通交流,主动推动项目进展,主动协调资源,主动向上反馈,主动创造影响力等等。 脸皮要厚一点,多找人聊,快速融入,最忌讳有问题也不说,自己把自己孤立起来。 想舔就舔,不想舔也没必要酸别人,Respect Greatness。 时刻准备着,技术在手就没什么可怕的,哪天干得不爽了直接跳槽。 平时积极总结沉淀,多跟别人交流,形成方法论。 …… 原文地址: https://www.nowcoder.com/discuss/351805\n先简单交代一下背景吧,某不知名 985 的本硕,17 年毕业加入滴滴,当时找工作时候也是在牛客这里跟大家一起奋战的。今年下半年跳槽到了头条,一直从事后端研发相关的工作。之前没有实习经历,算是两年半的工作经验吧。这两年半之间完成了一次晋升,换了一家公司,有过开心满足的时光,也有过迷茫挣扎的日子,不过还算顺利地从一只职场小菜鸟转变为了一名资深划水员。在这个过程中,总结出了一些还算实用的划水经验,有些是自己领悟到的,有些是跟别人交流学到的,在这里跟大家分享一下。\n学会深入思考,总结沉淀 # 我想说的第一条就是要学会深入思考,总结沉淀,这是我觉得最重要也是最有意义的一件事。\n先来说深入思考。 在程序员这个圈子里,常能听到一些言论:“我这个工作一点技术含量都没有,每天就 CRUD,再写写 if-else,这 TM 能让我学到什么东西?”\n抛开一部分调侃和戏谑的论调不谈,这可能确实是一部分同学的真实想法,至少曾经的我,就这么认为过。后来随着工作经验的积累,加上和一些高 level 的同学交流探讨之后,我发现这个想法其实是非常错误的。之所以出现没什么可学的这样的看法,基本上是思维懒惰的结果。任何一件看起来很不起眼的小事,只要进行深入思考,稍微纵向挖深或者横向拓宽一下,都是足以让人沉溺的知识海洋。\n举一个例子。某次有个同学跟我说,这周有个服务 OOM 了,查了一周发现有个地方 defer 写的有问题,改了几行代码上线修复了,周报都没法写。可能大家也遇到过这样的场景,还算是有一定的代表性。其实就查 bug 这件事来说,是一个发现问题,排查问题,解决问题的过程,包含了触发、定位、复现、根因、修复、复盘等诸多步骤,花了一周来做这件事,一定有不断尝试与纠错的过程,这里面其实就有很多思考的空间。比如说定位,如何缩小范围的?走了哪些弯路?用了哪些分析工具?比如说根因,可以研究的点起码有 linux 的 OOM,k8s 的 OOM,go 的内存管理,defer 机制,函数闭包的原理等等。如果这些真的都不涉及,仍然花了一周时间做这件事,那复盘应该会有很多思考,提出来几十个 WHY 没问题吧\u0026hellip;\n再来说下总结沉淀。 这个我觉得也是大多数程序员比较欠缺的地方,只顾埋头干活,可以把一件事做的很好。但是几乎从来不做抽象总结,以至于工作好几年了,所掌握的知识还是零星的几点,不成体系,不仅容易遗忘,而且造成自己视野比较窄,看问题比较局限。适时地做一些总结沉淀是很重要的,这是一个从术到道的过程,会让自己看问题的角度更广,层次更高。遇到同类型的问题,可以按照总结好的方法论,系统化、层次化地推进和解决。\n还是举一个例子。做后台服务,今天优化了 1G 内存,明天优化了 50%的读写耗时,是不是可以做一下性能优化的总结?比如说在应用层,可以管理服务对接的应用方,梳理他们访问的合理性;在架构层,可以做缓存、预处理、读写分离、异步、并行等等;在代码层,可以做的事情更多了,资源池化、对象复用、无锁化设计、大 key 拆分、延迟处理、编码压缩、gc 调优还有各种语言相关的高性能实践\u0026hellip;等下次再遇到需要性能优化的场景,一整套思路立马就能套用过来了,剩下的就是工具和实操的事儿了。\n还有的同学说了,我就每天跟 PM 撕撕逼,做做需求,也不做性能优化啊。先不讨论是否可以搞性能优化,单就做业务需求来讲,也有可以总结的地方。比如说,如何做系统建设?系统核心能力,系统边界,系统瓶颈,服务分层拆分,服务治理这些问题有思考过吗?每天跟 PM 讨论需求,那作为技术同学该如何培养产品思维,引导产品走向,如何做到架构先行于业务,这些问题也是可以思考和总结的吧。就想一下,连接手维护别人烂代码这种蛋疼的事情,都能让 Martin Fowler 整出来一套重构理论,还显得那么高大上,我们确实也没啥必要对自己的工作妄自菲薄\u0026hellip;\n所以说:学习和成长是一个自驱的过程,如果觉得没什么可学的,大概率并不是真的没什么可学的,而是因为自己太懒了,不仅是行动上太懒了,思维上也太懒了。可以多写技术文章,多分享,强迫自己去思考和总结,毕竟如果文章深度不够,大家也不好意思公开分享。\n积极学习,保持技术热情 # 最近两年在互联网圈里广泛传播的一种焦虑论叫做 35 岁程序员现象,大意是说程序员这个行业干到 35 岁就基本等着被裁员了。不可否认,互联网行业在这一点上确实不如公务员等体制内职业。但是,这个问题里 35 岁程序员并不是绝对生理意义上的 35 岁,应该是指那些工作十几年和工作两三年没什么太大区别的程序员。后面的工作基本是在吃老本,没有主动学习与充电,35 岁和 25 岁差不多,而且没有了 25 岁时对学习成长的渴望,反而添了家庭生活的诸多琐事,薪资要求往往也较高,在企业看来这确实是没什么竞争力。\n如果我们积极学习,保持技术能力、知识储备与工作年限成正比,这到了 35 岁哪还有什么焦虑呢,这样的大牛我觉得应该也是各大公司抢着要吧? 但是,学习这件事,其实是一个反人类的过程,这就需要我们强迫自己跳出自己的安逸区,主动学习,保持技术热情。 在滴滴时有一句话大概是,主动跳出自己的舒适区,感到挣扎与压力的时候,往往是黎明前的黑暗,那才是成长最快的时候。相反如果感觉自己每天都过得很安逸,工作只是在混时长,那可能真的是温水煮青蛙了。\n刚毕业的这段时间,往往空闲时间还比较多,正是努力学习技术的好时候。借助这段时间夯实基础,培养出良好的学习习惯,保持积极的学习态度,应该是受益终身的。至于如何高效率学习,网上有很多大牛写这样的帖子,到了公司后内网也能找到很多这样的分享,我就不多谈了。\n可以加入学习小组和技术社区,公司内和公司外的都可以,关注前沿技术。\n主动承担,及时交流反馈 # 前两条还是从个人的角度出发来说的,希望大家可以提升个人能力,保持核心竞争力,但从公司角度来讲,公司招聘员工入职,最重要的是让员工创造出业务价值,为公司服务。虽然对于校招生一般都会有一定的培养体系,但实际上公司确实没有帮助我们成长的义务。\n在能为公司办成事,创造价值这一点上,我觉得最重要的两个字就是主动,主动承担任务,主动沟通交流,主动推动项目进展,主动协调资源,主动向上反馈,主动创造影响力等等。\n我当初刚入职的时候,基本就是 leader 给分配什么任务就把本职工作做好,然后就干自己的事了,几乎从来不主动去跟别人交流或者主动去思考些能帮助项目发展的点子。自以为把本职工作保质保量完成就行了,后来发现这么做其实是非常不够的,这只是最基本的要求。而有些同学的做法则是 leader 只需要同步一下最近要做什么方向,下面的一系列事情基本不需要 leader 操心了 ,这样的同学我是 leader 我也喜欢啊。入职后经常会听到的一个词叫 owner 意识,大概就是这个意思吧。\n在这个过程中,另外很重要的一点就是及时向上沟通反馈。项目进展不顺利,遇到什么问题,及时跟 leader 同步,技术方案拿捏不准可以跟 leader 探讨,一些资源协调不了可以找 leader 帮忙,不要有太多顾忌,认为这些会太麻烦,leader 其实就是干这个事的。。如果项目进展比较顺利,确实也不需要 leader 介入,那也需要及时把项目的进度,取得的收益及时反馈,自己有什么想法也提出来探讨,问问 leader 对当前进展的建议,还有哪些地方需要改进,消除信息误差。做这些事一方面是合理利用 leader 的各种资源,另一方面也可以让 leader 了解到自己的工作量,对项目整体有所把控,毕竟 leader 也有 leader,也是要汇报的。可能算是大家比较反感的向上管理吧,有内味了,这个其实我也做得不好。但是最基本的一点,不要接了一个任务闷着头干活甚至与世隔绝了,一个月了也没跟 leader 同步过,想着憋个大招之类的,那基本凉凉。\n一定要主动,可以先从强迫自己在各种公开场合发言开始,有问题或想法及时 one-one。\n除了以上几点,还有一些小点我觉得也是比较重要的,列在下面:\n第一件事建立信任 # 无论是校招还是社招,刚入职的第一件事是非常重要的,直接决定了 leader 和同事对自己的第一印象。入职后要做的第一件事一定要做好,最起码的要顺利完成而且不能出线上事故。这件事的目的就是为了建立信任,让团队觉得自己起码是靠谱的。如果这件事做得比较好,后面一路都会比较顺利。如果这件事就搞杂了,可能有的 leader 还会给第二次机会,再搞不好,后面就很难了,这一条对于社招来说更为重要。\n而刚入职,公司技术栈不熟练,业务繁杂很难理清什么头绪,压力确实比较大。这时候一方面需要自己投入更多的精力,另一方面要多跟组内的同学交流,不懂就问。最有效率的学习方式,我觉得不是什么看书啊学习视频啊,而是直接去找对应的人聊,让别人讲一遍自己基本就全懂了,这效率比看文档看代码快多了,不仅省去了过滤无用信息的过程,还了解到了业务的演变历史。当然,这需要一定的沟通技巧,毕竟同事们也都很忙。\n脸皮要厚一点,多找人聊,快速融入,最忌讳有问题也不说,自己把自己孤立起来。\n超出预期 # 超出预期这个词的外延范围很广,比如 leader 让去做个值周,解答用户群里大家的问题,结果不仅解答了大家的问题,还收集了这些问题进行分类,进而做了一个智能问答机器人解放了值周的人力,这可以算超出预期。比如 leader 让给运营做一个小工具,结果建设了一系列的工具甚至发展成了一个平台,成为了一个完整的项目,这也算超出预期。超出预期要求我们有把事情做大的能力,也就是想到了 leader 没想到的地方,并且创造了实际价值,拿到了业务收益。这个能力其实也比较重要,在工作中发现,有的人能把一个小盘子越做越大,而有的人恰好反之,那么那些有创新能力,经常超出预期的同学发展空间显然就更大一点。\n这块其实比较看个人能力,暂时没想到什么太好的捷径,多想一步吧。\n体系化思考,系统化建设 # 这句话是晋升时候总结出来的,大意就是做系统建设要有全局视野,不要局限于某一个小点,应该有良好的规划能力和清晰的演进蓝图。比如,今天加了一个监控,明天加一个报警,这些事不应该成为一个个孤岛,而是属于稳定性建设一期其中的一小步。这一期稳定性建设要做的工作是报警配置和监控梳理,包括机器监控、系统监控、业务监控、数据监控等,预期能拿到 XXX 的收益。这个工作还有后续的 roadmap,稳定性建设二期要做容量规划,接入压测,三期要做降级演练,多活容灾,四期要做\u0026hellip;给人的感觉就是这个人思考非常全面,办事有体系有规划。\n平时积极总结沉淀,多跟别人交流,形成方法论。\n提升自己的软素质能力 # 这里的软素质能力其实想说的就是 PPT、沟通、表达、时间管理、设计、文档等方面的能力。说实话,我觉得我当时能晋升就是因为 PPT 做的好了一点\u0026hellip;可能大家平时对这些能力都不怎么关注,以前我也不重视,觉得比较简单,用时候直接上就行了,但事实可能并不像想象得那样简单。比如晋升时候 PPT+演讲+答辩这个工作,其实有很多细节的思考在里面,内容如何选取,排版怎么设计,怎样引导听众的情绪,如何回答评委的问题等等。晋升时候我见过很多同学 PPT 内容编排杂乱无章,演讲过程也不流畅自然,虽然确实做了很多实际工作,但在表达上欠缺了很多,属于会做不会说,如果再遇到不了解实际情况的外部门评委,吃亏是可以预见的。\n公司内网一般都会有一些软素质培训课程,可以找一些场合刻意训练。\n以上都是这些分享还都算比较伟光正,但是社会吧也不全是那么美好的。。下面这些内容有负能量倾向,三观特别正的同学以及观感不适者建议跳过。\n拍马屁是真的香 # 拍马屁这东西入职前我是很反感的,我最初想加入互联网公司的原因就是觉得互联网公司的人情世故没那么多,事实证明,我错了\u0026hellip;入职前几天,部门群里大 leader 发了一条消息,后面几十条带着大拇指的消息立马跟上,学习了,点赞,真不错,优秀,那场面,说是红旗招展锣鼓喧天鞭炮齐鸣一点也不过分。除了惊叹大家超强的信息接收能力和处理速度外,更进一步我还发现,连拍马屁都是有队形的,一级部门 leader 发消息,几个二级部门 leader 跟上,后面各组长跟上,最后是大家的狂欢,让我一度怀疑拍马屁的速度就决定了职业生涯的发展前景(没错,现在我已经不怀疑了)。\n坦诚地说,我到现在也没习惯在群里拍马屁,但也不反感了,可以说把这个事当成一乐了。倒不是说我没有那个口才和能力(事实上也不需要什么口才,大家都简单直接),在某些场合,为活跃气氛的需要,我也能小嘴儿抹了蜜,甚至能把古诗文彩虹屁给 leader 安排上。而是我发现我的直属 leader 也不怎么在群里拍马屁,所以我表面上不公开拍马屁其实属于暗地里事实上迎合了 leader 的喜好\u0026hellip;\n但是拍马屁这个事只要掌握好度,整体来说还是香的,最多是没用,至少不会有什么坏处嘛。大家能力都差不多,每一次在群里拍马屁的机会就是一次露脸的机会,按某个同事的说法,这就叫打造个人技术影响力\u0026hellip;\n想舔就舔,不想舔也没必要酸别人,Respect Greatness。\n永不缺席的撕逼甩锅实战 # 有人的地方,就有江湖。虽然搞技术的大多城府也不深,但撕逼甩锅邀功抢活这些闹心的事儿基本也不会缺席,甚至我还见到过公开群发邮件撕逼的\u0026hellip;这部分话题涉及到一些敏感信息就不多说了,而且我们低职级的遇到这些事儿的机会也不会太多。只是给大家提个醒,在工作的时候迟早都会吃到这方面的瓜,到时候留个心眼。\n稍微注意一下,咱不会去欺负别人,但也不能轻易让别人给欺负了。\n不要被画饼蒙蔽了双眼 # 说实话,我个人是比较反感灌鸡汤、打鸡血、谈梦想、讲奋斗这一类行为的,9102 年都快过完了,这一套***治还在大行其道,真不知道是该可笑还是可悲。当然,这些词本身并没有什么问题,但是这些东西应该是自驱的,而不应该成为外界的一种强 push。『我必须努力奋斗』这个句式我觉得是正常的,但是『你必须努力奋斗』这种话多少感觉有点诡异,努力奋斗所以让公司的股东们发家致富?尤其在钱没给够的情况下,这些行为无异于耍流氓。我们需要对 leader 的这些画饼操作保持清醒的认知,理性分析,作出决策。比如感觉钱没给够(或者职级太低,同理)的时候,可能有以下几种情况:\nleader 并没有注意到你薪资较低这一事实 leader 知道这个事实,但是不知道你有多强烈的涨薪需求 leader 知道你有涨薪的需求,但他觉得你能力还不够 leader 知道你有涨薪的需求,能力也够,但是他不想给你涨 leader 想给你涨,也向上反馈和争取了,但是没有资源 这时候我们需要做的是向上反馈,跟 leader 沟通确认。如果是 1 和 2,那么通过沟通可以消除信息误差。如果是 3,需要分情况讨论。如果是 4 和 5,已经可以考虑撤退了。对于这些事儿,也没必要抱怨,抱怨解决不了任何问题。我们要做的就是努力提升好个人能力,保持个人竞争力,等一个合适的时机,跳槽就完事了。\n时刻准备着,技术在手就没什么可怕的,哪天干得不爽了直接跳槽。\n学会包装 # 这一条说白了就是,要会吹。忘了从哪儿看到的了,能说、会写、善做是对职场人的三大要求。能说是很重要的,能说才能要来项目,拉来资源,招来人。同样一件事,不同的人能说出来完全不一样的效果。比如我做了个小工具上线了,我就只能说出来基本事实,而让 leader 描述一下,这就成了,打造了 XXX 的工具抓手,改进了 XXX 的完整生态,形成了 XXX 的业务闭环。老哥,我服了,硬币全给你还不行嘛。据我的观察,每个互联网公司都有这么几个词,抓手、生态、闭环、拉齐、梳理、迭代、owner 意识等等等等,我们需要做的就是熟读并背诵全文,啊不,是牢记并熟练使用。\n这是对事情的包装,对人的包装也是一样的,尤其是在晋升和面试这样的应试型场合,特点是流程短一锤子买卖,包装显得尤为重要。晋升和面试这里就不展开说了,这里面的道和术太多了。。下面的场景提炼自面试过程中和某公司面试官的谈话,大家可以感受一下:\n我们背后是一个四五百亿美金的市场\u0026hellip; 我负责过每天千亿级别访问量的系统\u0026hellip; 工作两年能达到这个程度挺不错的\u0026hellip; 贵司技术氛围挺好的,业务发展前景也很广阔\u0026hellip; 啊,彼此彼此\u0026hellip; 嗯,久仰久仰\u0026hellip; 人生如戏,全靠演技\n可以多看 leader 的 PPT,多听老板的向上汇报和宣讲会。\n选择和努力哪个更重要? # 这还用问么,当然是选择。在完美的选择面前,努力显得一文不值,我有个多年没联系的高中同学今年已经在时代广场敲钟了\u0026hellip;但是这样的案例太少了,做出完美选择的随机成本太高,不确定性太大。对于大多数刚毕业的同学,对行业的判断力还不够成熟,对自身能力和创业难度把握得也不够精准,此时拉几个人去创业,显得风险太高。我觉得更为稳妥的一条路是,先加入规模稍大一点的公司,找一个好 leader,抱好大腿,提升自己的个人能力。好平台加上大腿,再加上个人努力,这个起飞速度已经可以了。等后面积累了一定人脉和资金,深刻理解了市场和需求,对自己有信心了,可以再去考虑创业的事。\n后记 # 本来还想分享一些生活方面的故事,发现已经这么长了,那就先这样叭。上面写的一些总结和建议我自己做的也不是很好,还需要继续加油,和大家共勉。另外,其中某些观点,由于个人视角的局限性也不保证是普适和正确的,可能再工作几年这些观点也会发生改变,欢迎大家跟我交流~(甩锅成功)\n最后祝大家都能找到心仪的工作,快乐工作,幸福生活,广阔天地,大有作为。\n"},{"id":613,"href":"/zh/docs/technology/Interview/high-performance/read-and-write-separation-and-library-subtable/","title":"读写分离和分库分表详解","section":"High Performance","content":" 读写分离 # 什么是读写分离? # 见名思意,根据读写分离的名字,我们就可以知道:读写分离主要是为了将对数据库的读写操作分散到不同的数据库节点上。 这样的话,就能够小幅提升写性能,大幅提升读性能。\n我简单画了一张图来帮助不太清楚读写分离的小伙伴理解。\n一般情况下,我们都会选择一主多从,也就是一台主数据库负责写,其他的从数据库负责读。主库和从库之间会进行数据同步,以保证从库中数据的准确性。这样的架构实现起来比较简单,并且也符合系统的写少读多的特点。\n如何实现读写分离? # 不论是使用哪一种读写分离具体的实现方案,想要实现读写分离一般包含如下几步:\n部署多台数据库,选择其中的一台作为主数据库,其他的一台或者多台作为从数据库。 保证主数据库和从数据库之间的数据是实时同步的,这个过程也就是我们常说的主从复制。 系统将写请求交给主数据库处理,读请求交给从数据库处理。 落实到项目本身的话,常用的方式有两种:\n1. 代理方式\n我们可以在应用和数据中间加了一个代理层。应用程序所有的数据请求都交给代理层处理,代理层负责分离读写请求,将它们路由到对应的数据库中。\n提供类似功能的中间件有 MySQL Router(官方, MySQL Proxy 的替代方案)、Atlas(基于 MySQL Proxy)、MaxScale、MyCat。\n关于 MySQL Router 多提一点:在 MySQL 8.2 的版本中,MySQL Router 能自动分辨对数据库读写/操作并把这些操作路由到正确的实例上。这是一项有价值的功能,可以优化数据库性能和可扩展性,而无需在应用程序中进行任何更改。具体介绍可以参考官方博客: MySQL 8.2 – transparent read/write splitting。\n2. 组件方式\n在这种方式中,我们可以通过引入第三方组件来帮助我们读写请求。\n这也是我比较推荐的一种方式。这种方式目前在各种互联网公司中用的最多的,相关的实际的案例也非常多。如果你要采用这种方式的话,推荐使用 sharding-jdbc ,直接引入 jar 包即可使用,非常方便。同时,也节省了很多运维的成本。\n你可以在 shardingsphere 官方找到 sharding-jdbc 关于读写分离的操作。\n主从复制原理是什么? # MySQL binlog(binary log 即二进制日志文件) 主要记录了 MySQL 数据库中数据的所有变化(数据库执行的所有 DDL 和 DML 语句)。因此,我们根据主库的 MySQL binlog 日志就能够将主库的数据同步到从库中。\n更具体和详细的过程是这个样子的(图片来自于: 《MySQL Master-Slave Replication on the Same Machine》):\n主库将数据库中数据的变化写入到 binlog 从库连接主库 从库会创建一个 I/O 线程向主库请求更新的 binlog 主库会创建一个 binlog dump 线程来发送 binlog ,从库中的 I/O 线程负责接收 从库的 I/O 线程将接收的 binlog 写入到 relay log 中。 从库的 SQL 线程读取 relay log 同步数据到本地(也就是再执行一遍 SQL )。 怎么样?看了我对主从复制这个过程的讲解,你应该搞明白了吧!\n你一般看到 binlog 就要想到主从复制。当然,除了主从复制之外,binlog 还能帮助我们实现数据恢复。\n🌈 拓展一下:\n不知道大家有没有使用过阿里开源的一个叫做 canal 的工具。这个工具可以帮助我们实现 MySQL 和其他数据源比如 Elasticsearch 或者另外一台 MySQL 数据库之间的数据同步。很显然,这个工具的底层原理肯定也是依赖 binlog。canal 的原理就是模拟 MySQL 主从复制的过程,解析 binlog 将数据同步到其他的数据源。\n另外,像咱们常用的分布式缓存组件 Redis 也是通过主从复制实现的读写分离。\n🌕 简单总结一下:\nMySQL 主从复制是依赖于 binlog 。另外,常见的一些同步 MySQL 数据到其他数据源的工具(比如 canal)的底层一般也是依赖 binlog 。\n如何避免主从延迟? # 读写分离对于提升数据库的并发非常有效,但是,同时也会引来一个问题:主库和从库的数据存在延迟,比如你写完主库之后,主库的数据同步到从库是需要时间的,这个时间差就导致了主库和从库的数据不一致性问题。这也就是我们经常说的 主从同步延迟 。\n如果我们的业务场景无法容忍主从同步延迟的话,应该如何避免呢(注意:我这里说的是避免而不是减少延迟)?\n这里提供两种我知道的方案(能力有限,欢迎补充),你可以根据自己的业务场景参考一下。\n强制将读请求路由到主库处理 # 既然你从库的数据过期了,那我就直接从主库读取嘛!这种方案虽然会增加主库的压力,但是,实现起来比较简单,也是我了解到的使用最多的一种方式。\n比如 Sharding-JDBC 就是采用的这种方案。通过使用 Sharding-JDBC 的 HintManager 分片键值管理器,我们可以强制使用主库。\nHintManager hintManager = HintManager.getInstance(); hintManager.setMasterRouteOnly(); // 继续JDBC操作 对于这种方案,你可以将那些必须获取最新数据的读请求都交给主库处理。\n延迟读取 # 还有一些朋友肯定会想既然主从同步存在延迟,那我就在延迟之后读取啊,比如主从同步延迟 0.5s,那我就 1s 之后再读取数据。这样多方便啊!方便是方便,但是也很扯淡。\n不过,如果你是这样设计业务流程就会好很多:对于一些对数据比较敏感的场景,你可以在完成写请求之后,避免立即进行请求操作。比如你支付成功之后,跳转到一个支付成功的页面,当你点击返回之后才返回自己的账户。\n总结 # 关于如何避免主从延迟,我们这里介绍了两种方案。实际上,延迟读取这种方案没办法完全避免主从延迟,只能说可以减少出现延迟的概率而已,实际项目中一般不会使用。\n总的来说,要想不出现延迟问题,一般还是要强制将那些必须获取最新数据的读请求都交给主库处理。如果你的项目的大部分业务场景对数据准确性要求不是那么高的话,这种方案还是可以选择的。\n什么情况下会出现主从延迟?如何尽量减少延迟? # 我们在上面的内容中也提到了主从延迟以及避免主从延迟的方法,这里我们再来详细分析一下主从延迟出现的原因以及应该如何尽量减少主从延迟。\n要搞懂什么情况下会出现主从延迟,我们需要先搞懂什么是主从延迟。\nMySQL 主从同步延时是指从库的数据落后于主库的数据,这种情况可能由以下两个原因造成:\n从库 I/O 线程接收 binlog 的速度跟不上主库写入 binlog 的速度,导致从库 relay log 的数据滞后于主库 binlog 的数据; 从库 SQL 线程执行 relay log 的速度跟不上从库 I/O 线程接收 binlog 的速度,导致从库的数据滞后于从库 relay log 的数据。 与主从同步有关的时间点主要有 3 个:\n主库执行完一个事务,写入 binlog,将这个时刻记为 T1; 从库 I/O 线程接收到 binlog 并写入 relay log 的时刻记为 T2; 从库 SQL 线程读取 relay log 同步数据本地的时刻记为 T3。 结合我们上面讲到的主从复制原理,可以得出:\nT2 和 T1 的差值反映了从库 I/O 线程的性能和网络传输的效率,这个差值越小说明从库 I/O 线程的性能和网络传输效率越高。 T3 和 T2 的差值反映了从库 SQL 线程执行的速度,这个差值越小,说明从库 SQL 线程执行速度越快。 那什么情况下会出现出从延迟呢?这里列举几种常见的情况:\n从库机器性能比主库差:从库接收 binlog 并写入 relay log 以及执行 SQL 语句的速度会比较慢(也就是 T2-T1 和 T3-T2 的值会较大),进而导致延迟。解决方法是选择与主库一样规格或更高规格的机器作为从库,或者对从库进行性能优化,比如调整参数、增加缓存、使用 SSD 等。 从库处理的读请求过多:从库需要执行主库的所有写操作,同时还要响应读请求,如果读请求过多,会占用从库的 CPU、内存、网络等资源,影响从库的复制效率(也就是 T2-T1 和 T3-T2 的值会较大,和前一种情况类似)。解决方法是引入缓存(推荐)、使用一主多从的架构,将读请求分散到不同的从库,或者使用其他系统来提供查询的能力,比如将 binlog 接入到 Hadoop、Elasticsearch 等系统中。 大事务:运行时间比较长,长时间未提交的事务就可以称为大事务。由于大事务执行时间长,并且从库上的大事务会比主库上的大事务花费更多的时间和资源,因此非常容易造成主从延迟。解决办法是避免大批量修改数据,尽量分批进行。类似的情况还有执行时间较长的慢 SQL ,实际项目遇到慢 SQL 应该进行优化。 从库太多:主库需要将 binlog 同步到所有的从库,如果从库数量太多,会增加同步的时间和开销(也就是 T2-T1 的值会比较大,但这里是因为主库同步压力大导致的)。解决方案是减少从库的数量,或者将从库分为不同的层级,让上层的从库再同步给下层的从库,减少主库的压力。 网络延迟:如果主从之间的网络传输速度慢,或者出现丢包、抖动等问题,那么就会影响 binlog 的传输效率,导致从库延迟。解决方法是优化网络环境,比如提升带宽、降低延迟、增加稳定性等。 单线程复制:MySQL5.5 及之前,只支持单线程复制。为了优化复制性能,MySQL 5.6 引入了 多线程复制,MySQL 5.7 还进一步完善了多线程复制。 复制模式:MySQL 默认的复制是异步的,必然会存在延迟问题。全同步复制不存在延迟问题,但性能太差了。半同步复制是一种折中方案,相对于异步复制,半同步复制提高了数据的安全性,减少了主从延迟(还是有一定程度的延迟)。MySQL 5.5 开始,MySQL 以插件的形式支持 semi-sync 半同步复制。并且,MySQL 5.7 引入了 增强半同步复制 。 …… 《MySQL 实战 45 讲》这个专栏中的 读写分离有哪些坑?这篇文章也有对主从延迟解决方案这一话题进行探讨,感兴趣的可以阅读学习一下。\n分库分表 # 读写分离主要应对的是数据库读并发,没有解决数据库存储问题。试想一下:如果 MySQL 一张表的数据量过大怎么办?\n换言之,我们该如何解决 MySQL 的存储压力呢?\n答案之一就是 分库分表。\n什么是分库? # 分库 就是将数据库中的数据分散到不同的数据库上,可以垂直分库,也可以水平分库。\n垂直分库 就是把单一数据库按照业务进行划分,不同的业务使用不同的数据库,进而将一个数据库的压力分担到多个数据库。\n举个例子:说你将数据库中的用户表、订单表和商品表分别单独拆分为用户数据库、订单数据库和商品数据库。\n水平分库 是把同一个表按一定规则拆分到不同的数据库中,每个库可以位于不同的服务器上,这样就实现了水平扩展,解决了单表的存储和性能瓶颈的问题。\n举个例子:订单表数据量太大,你对订单表进行了水平切分(水平分表),然后将切分后的 2 张订单表分别放在两个不同的数据库。\n什么是分表? # 分表 就是对单表的数据进行拆分,可以是垂直拆分,也可以是水平拆分。\n垂直分表 是对数据表列的拆分,把一张列比较多的表拆分为多张表。\n举个例子:我们可以将用户信息表中的一些列单独抽出来作为一个表。\n水平分表 是对数据表行的拆分,把一张行比较多的表拆分为多张表,可以解决单一表数据量过大的问题。\n举个例子:我们可以将用户信息表拆分成多个用户信息表,这样就可以避免单一表数据量过大对性能造成影响。\n水平拆分只能解决单表数据量大的问题,为了提升性能,我们通常会选择将拆分后的多张表放在不同的数据库中。也就是说,水平分表通常和水平分库同时出现。\n什么情况下需要分库分表? # 遇到下面几种场景可以考虑分库分表:\n单表的数据达到千万级别以上,数据库读写速度比较缓慢。 数据库中的数据占用的空间越来越大,备份时间越来越长。 应用的并发量太大(应该优先考虑其他性能优化方法,而非分库分表)。 不过,分库分表的成本太高,如非必要尽量不要采用。而且,并不一定是单表千万级数据量就要分表,毕竟每张表包含的字段不同,它们在不错的性能下能够存放的数据量也不同,还是要具体情况具体分析。\n之前看过一篇文章分析 “ InnoDB 中高度为 3 的 B+ 树最多可以存多少数据”,写的挺不错,感兴趣的可以看看。\n常见的分片算法有哪些? # 分片算法主要解决了数据被水平分片之后,数据究竟该存放在哪个表的问题。\n常见的分片算法有:\n哈希分片:求指定分片键的哈希,然后根据哈希值确定数据应被放置在哪个表中。哈希分片比较适合随机读写的场景,不太适合经常需要范围查询的场景。哈希分片可以使每个表的数据分布相对均匀,但对动态伸缩(例如新增一个表或者库)不友好。 范围分片:按照特定的范围区间(比如时间区间、ID 区间)来分配数据,比如 将 id 为 1~299999 的记录分到第一个表, 300000~599999 的分到第二个表。范围分片适合需要经常进行范围查找且数据分布均匀的场景,不太适合随机读写的场景(数据未被分散,容易出现热点数据的问题)。 映射表分片:使用一个单独的表(称为映射表)来存储分片键和分片位置的对应关系。映射表分片策略可以支持任何类型的分片算法,如哈希分片、范围分片等。映射表分片策略是可以灵活地调整分片规则,不需要修改应用程序代码或重新分布数据。不过,这种方式需要维护额外的表,还增加了查询的开销和复杂度。 一致性哈希分片:将哈希空间组织成一个环形结构,将分片键和节点(数据库或表)都映射到这个环上,然后根据顺时针的规则确定数据或请求应该分配到哪个节点上,解决了传统哈希对动态伸缩不友好的问题。 地理位置分片:很多 NewSQL 数据库都支持地理位置分片算法,也就是根据地理位置(如城市、地域)来分配数据。 融合算法分片:灵活组合多种分片算法,比如将哈希分片和范围分片组合。 …… 分片键如何选择? # 分片键(Sharding Key)是数据分片的关键字段。分片键的选择非常重要,它关系着数据的分布和查询效率。一般来说,分片键应该具备以下特点:\n具有共性,即能够覆盖绝大多数的查询场景,尽量减少单次查询所涉及的分片数量,降低数据库压力; 具有离散性,即能够将数据均匀地分散到各个分片上,避免数据倾斜和热点问题; 具有稳定性,即分片键的值不会发生变化,避免数据迁移和一致性问题; 具有扩展性,即能够支持分片的动态增加和减少,避免数据重新分片的开销。 实际项目中,分片键很难满足上面提到的所有特点,需要权衡一下。并且,分片键可以是表中多个字段的组合,例如取用户 ID 后四位作为订单 ID 后缀。\n分库分表会带来什么问题呢? # 记住,你在公司做的任何技术决策,不光是要考虑这个技术能不能满足我们的要求,是否适合当前业务场景,还要重点考虑其带来的成本。\n引入分库分表之后,会给系统带来什么挑战呢?\njoin 操作:同一个数据库中的表分布在了不同的数据库中,导致无法使用 join 操作。这样就导致我们需要手动进行数据的封装,比如你在一个数据库中查询到一个数据之后,再根据这个数据去另外一个数据库中找对应的数据。不过,很多大厂的资深 DBA 都是建议尽量不要使用 join 操作。因为 join 的效率低,并且会对分库分表造成影响。对于需要用到 join 操作的地方,可以采用多次查询业务层进行数据组装的方法。不过,这种方法需要考虑业务上多次查询的事务性的容忍度。 事务问题:同一个数据库中的表分布在了不同的数据库中,如果单个操作涉及到多个数据库,那么数据库自带的事务就无法满足我们的要求了。这个时候,我们就需要引入分布式事务了。关于分布式事务常见解决方案总结,网站上也有对应的总结: https://javaguide.cn/distributed-system/distributed-transaction.html 。 分布式 ID:分库之后, 数据遍布在不同服务器上的数据库,数据库的自增主键已经没办法满足生成的主键唯一了。我们如何为不同的数据节点生成全局唯一主键呢?这个时候,我们就需要为我们的系统引入分布式 ID 了。关于分布式 ID 的详细介绍\u0026amp;实现方案总结,可以看我写的这篇文章: 分布式 ID 介绍\u0026amp;实现方案总结。 跨库聚合查询问题:分库分表会导致常规聚合查询操作,如 group by,order by 等变得异常复杂。这是因为这些操作需要在多个分片上进行数据汇总和排序,而不是在单个数据库上进行。为了实现这些操作,需要编写复杂的业务代码,或者使用中间件来协调分片间的通信和数据传输。这样会增加开发和维护的成本,以及影响查询的性能和可扩展性。 …… 另外,引入分库分表之后,一般需要 DBA 的参与,同时还需要更多的数据库服务器,这些都属于成本。\n分库分表有没有什么比较推荐的方案? # Apache ShardingSphere 是一款分布式的数据库生态系统, 可以将任意数据库转换为分布式数据库,并通过数据分片、弹性伸缩、加密等能力对原有数据库进行增强。\nShardingSphere 项目(包括 Sharding-JDBC、Sharding-Proxy 和 Sharding-Sidecar)是当当捐入 Apache 的,目前主要由京东数科的一些巨佬维护。\nShardingSphere 绝对可以说是当前分库分表的首选!ShardingSphere 的功能完善,除了支持读写分离和分库分表,还提供分布式事务、数据库治理、影子库、数据加密和脱敏等功能。\nShardingSphere 提供的功能如下:\nShardingSphere 的优势如下(摘自 ShardingSphere 官方文档: https://shardingsphere.apache.org/document/current/cn/overview/):\n极致性能:驱动程序端历经长年打磨,效率接近原生 JDBC,性能极致。 生态兼容:代理端支持任何通过 MySQL/PostgreSQL 协议的应用访问,驱动程序端可对接任意实现 JDBC 规范的数据库。 业务零侵入:面对数据库替换场景,ShardingSphere 可满足业务无需改造,实现平滑业务迁移。 运维低成本:在保留原技术栈不变前提下,对 DBA 学习、管理成本低,交互友好。 安全稳定:基于成熟数据库底座之上提供增量能力,兼顾安全性及稳定性。 弹性扩展:具备计算、存储平滑在线扩展能力,可满足业务多变的需求。 开放生态:通过多层次(内核、功能、生态)插件化能力,为用户提供可定制满足自身特殊需求的独有系统。 另外,ShardingSphere 的生态体系完善,社区活跃,文档完善,更新和发布比较频繁。\n不过,还是要多提一句:现在很多公司都是用的类似于 TiDB 这种分布式关系型数据库,不需要我们手动进行分库分表(数据库层面已经帮我们做了),也不需要解决手动分库分表引入的各种问题,直接一步到位,内置很多实用的功能(如无感扩容和缩容、冷热存储分离)!如果公司条件允许的话,个人也是比较推荐这种方式!\n分库分表后,数据怎么迁移呢? # 分库分表之后,我们如何将老库(单库单表)的数据迁移到新库(分库分表后的数据库系统)呢?\n比较简单同时也是非常常用的方案就是停机迁移,写个脚本老库的数据写到新库中。比如你在凌晨 2 点,系统使用的人数非常少的时候,挂一个公告说系统要维护升级预计 1 小时。然后,你写一个脚本将老库的数据都同步到新库中。\n如果你不想停机迁移数据的话,也可以考虑双写方案。双写方案是针对那种不能停机迁移的场景,实现起来要稍微麻烦一些。具体原理是这样的:\n我们对老库的更新操作(增删改),同时也要写入新库(双写)。如果操作的数据不存在于新库的话,需要插入到新库中。 这样就能保证,咱们新库里的数据是最新的。 在迁移过程,双写只会让被更新操作过的老库中的数据同步到新库,我们还需要自己写脚本将老库中的数据和新库的数据做比对。如果新库中没有,那咱们就把数据插入到新库。如果新库有,旧库没有,就把新库对应的数据删除(冗余数据清理)。 重复上一步的操作,直到老库和新库的数据一致为止。 想要在项目中实施双写还是比较麻烦的,很容易会出现问题。我们可以借助上面提到的数据库同步工具 Canal 做增量数据迁移(还是依赖 binlog,开发和维护成本较低)。\n总结 # 读写分离主要是为了将对数据库的读写操作分散到不同的数据库节点上。 这样的话,就能够小幅提升写性能,大幅提升读性能。 读写分离基于主从复制,MySQL 主从复制是依赖于 binlog 。 分库 就是将数据库中的数据分散到不同的数据库上。分表 就是对单表的数据进行拆分,可以是垂直拆分,也可以是水平拆分。 引入分库分表之后,需要系统解决事务、分布式 id、无法 join 操作问题。 现在很多公司都是用的类似于 TiDB 这种分布式关系型数据库,不需要我们手动进行分库分表(数据库层面已经帮我们做了),也不需要解决手动分库分表引入的各种问题,直接一步到位,内置很多实用的功能(如无感扩容和缩容、冷热存储分离)!如果公司条件允许的话,个人也是比较推荐这种方式! 如果必须要手动分库分表的话,ShardingSphere 是首选!ShardingSphere 的功能完善,除了支持读写分离和分库分表,还提供分布式事务、数据库治理等功能。另外,ShardingSphere 的生态体系完善,社区活跃,文档完善,更新和发布比较频繁。 "},{"id":614,"href":"/zh/docs/technology/Interview/java/basis/generics-and-wildcards/","title":"泛型\u0026通配符详解","section":"Basis","content":"泛型\u0026amp;通配符 相关的面试题为我的 知识星球(点击链接即可查看详细介绍以及加入方法)专属内容,已经整理到了 《Java 面试指北》(点击链接即可查看详细介绍以及获取方法)中。\n《Java 面试指北》 的部分内容展示如下,你可以将其看作是 JavaGuide 的补充完善,两者可以配合使用。\n《Java 面试指北》只是星球内部众多资料中的一个,星球还有很多其他优质资料比如 专属专栏、Java 编程视频、PDF 资料。\n"},{"id":615,"href":"/zh/docs/technology/Interview/cs-basics/network/the-whole-process-of-accessing-web-pages/","title":"访问网页的全过程(知识串联)","section":"Network","content":"开发岗中总是会考很多计算机网络的知识点,但如果让面试官只考一道题,便涵盖最多的计网知识点,那可能就是 网页浏览的全过程 了。本篇文章将带大家从头到尾过一遍这道被考烂的面试题,必会!!!\n总的来说,网络通信模型可以用下图来表示,也就是大家只要熟记网络结构五层模型,按照这个体系,很多知识点都能顺出来了。访问网页的过程也是如此。\n开始之前,我们先简单过一遍完整流程:\n在浏览器中输入指定网页的 URL。 浏览器通过 DNS 协议,获取域名对应的 IP 地址。 浏览器根据 IP 地址和端口号,向目标服务器发起一个 TCP 连接请求。 浏览器在 TCP 连接上,向服务器发送一个 HTTP 请求报文,请求获取网页的内容。 服务器收到 HTTP 请求报文后,处理请求,并返回 HTTP 响应报文给浏览器。 浏览器收到 HTTP 响应报文后,解析响应体中的 HTML 代码,渲染网页的结构和样式,同时根据 HTML 中的其他资源的 URL(如图片、CSS、JS 等),再次发起 HTTP 请求,获取这些资源的内容,直到网页完全加载显示。 浏览器在不需要和服务器通信时,可以主动关闭 TCP 连接,或者等待服务器的关闭请求。 应用层 # 一切的开始——打开浏览器,在地址栏输入 URL,回车确认。那么,什么是 URL?访问 URL 有什么用?\nURL # URL(Uniform Resource Locators),即统一资源定位器。网络上的所有资源都靠 URL 来定位,每一个文件就对应着一个 URL,就像是路径地址。理论上,文件资源和 URL 一一对应。实际上也有例外,比如某些 URL 指向的文件已经被重定位到另一个位置,这样就有多个 URL 指向同一个文件。\nURL 的组成结构 # 协议。URL 的前缀通常表示了该网址采用了何种应用层协议,通常有两种——HTTP 和 HTTPS。当然也有一些不太常见的前缀头,比如文件传输时用到的ftp:。 域名。域名便是访问网址的通用名,这里也有可能是网址的 IP 地址,域名可以理解为 IP 地址的可读版本,毕竟绝大部分人都不会选择记住一个网址的 IP 地址。 端口。如果指明了访问网址的端口的话,端口会紧跟在域名后面,并用一个冒号隔开。 资源路径。域名(端口)后紧跟的就是资源路径,从第一个/开始,表示从服务器上根目录开始进行索引到的文件路径,上图中要访问的文件就是服务器根目录下/path/to/myfile.html。早先的设计是该文件通常物理存储于服务器主机上,但现在随着网络技术的进步,该文件不一定会物理存储在服务器主机上,有可能存放在云上,而文件路径也有可能是虚拟的(遵循某种规则)。 参数。参数是浏览器在向服务器提交请求时,在 URL 中附带的参数。服务器解析请求时,会提取这些参数。参数采用键值对的形式key=value,每一个键值对使用\u0026amp;隔开。参数的具体含义和请求操作的具体方法有关。 锚点。锚点顾名思义,是在要访问的页面上的一个锚。要访问的页面大部分都多于一页,如果指定了锚点,那么在客户端显示该网页是就会定位到锚点处,相当于一个小书签。值得一提的是,在 URL 中,锚点以#开头,并且不会作为请求的一部分发送给服务端。 DNS # 键入了 URL 之后,第一个重头戏登场——DNS 服务器解析。DNS(Domain Name System)域名系统,要解决的是 域名和 IP 地址的映射问题 。毕竟,域名只是一个网址便于记住的名字,而网址真正存在的地址其实是 IP 地址。\n传送门: DNS 域名系统详解(应用层)\nHTTP/HTTPS # 利用 DNS 拿到了目标主机的 IP 地址之后,浏览器便可以向目标 IP 地址发送 HTTP 报文,请求需要的资源了。在这里,根据目标网站的不同,请求报文可能是 HTTP 协议或安全性增强的 HTTPS 协议。\n传送门:\nHTTP vs HTTPS(应用层) HTTP 1.0 vs HTTP 1.1(应用层) HTTP 常见状态码总结(应用层) 传输层 # 由于 HTTP 协议是基于 TCP 协议的,在应用层的数据封装好以后,要交给传输层,经 TCP 协议继续封装。\nTCP 协议保证了数据传输的可靠性,是数据包传输的主力协议。\n传送门:\nTCP 三次握手和四次挥手(传输层) TCP 传输可靠性保障(传输层) 网络层 # 终于,来到网络层,此时我们的主机不再是和另一台主机进行交互了,而是在和中间系统进行交互。也就是说,应用层和传输层都是端到端的协议,而网络层及以下都是中间件的协议了。\n网络层的的核心功能——转发与路由,必会!!!如果面试官问到了网络层,而你恰好又什么都不会的话,最最起码要说出这五个字——转发与路由。\n转发:将分组从路由器的输入端口转移到合适的输出端口。 路由:确定分组从源到目的经过的路径。 所以到目前为止,我们的数据包经过了应用层、传输层的封装,来到了网络层,终于开始准备在物理层面传输了,第一个要解决的问题就是——往哪里传输?或者说,要把数据包发到哪个路由器上? 这便是 BGP 协议要解决的问题。\n"},{"id":616,"href":"/zh/docs/technology/Interview/distributed-system/distributed-id/","title":"分布式ID介绍\u0026实现方案总结","section":"Distributed System","content":" 分布式 ID 介绍 # 什么是 ID? # 日常开发中,我们需要对系统中的各种数据使用 ID 唯一表示,比如用户 ID 对应且仅对应一个人,商品 ID 对应且仅对应一件商品,订单 ID 对应且仅对应一个订单。\n我们现实生活中也有各种 ID,比如身份证 ID 对应且仅对应一个人、地址 ID 对应且仅对应一个地址。\n简单来说,ID 就是数据的唯一标识。\n什么是分布式 ID? # 分布式 ID 是分布式系统下的 ID。分布式 ID 不存在与现实生活中,属于计算机系统中的一个概念。\n我简单举一个分库分表的例子。\n我司的一个项目,使用的是单机 MySQL 。但是,没想到的是,项目上线一个月之后,随着使用人数越来越多,整个系统的数据量将越来越大。单机 MySQL 已经没办法支撑了,需要进行分库分表(推荐 Sharding-JDBC)。\n在分库之后, 数据遍布在不同服务器上的数据库,数据库的自增主键已经没办法满足生成的主键唯一了。我们如何为不同的数据节点生成全局唯一主键呢?\n这个时候就需要生成分布式 ID了。\n分布式 ID 需要满足哪些要求? # 分布式 ID 作为分布式系统中必不可少的一环,很多地方都要用到分布式 ID。\n一个最基本的分布式 ID 需要满足下面这些要求:\n全局唯一:ID 的全局唯一性肯定是首先要满足的! 高性能:分布式 ID 的生成速度要快,对本地资源消耗要小。 高可用:生成分布式 ID 的服务要保证可用性无限接近于 100%。 方便易用:拿来即用,使用方便,快速接入! 除了这些之外,一个比较好的分布式 ID 还应保证:\n安全:ID 中不包含敏感信息。 有序递增:如果要把 ID 存放在数据库的话,ID 的有序性可以提升数据库写入速度。并且,很多时候 ,我们还很有可能会直接通过 ID 来进行排序。 有具体的业务含义:生成的 ID 如果能有具体的业务含义,可以让定位问题以及开发更透明化(通过 ID 就能确定是哪个业务)。 独立部署:也就是分布式系统单独有一个发号器服务,专门用来生成分布式 ID。这样就生成 ID 的服务可以和业务相关的服务解耦。不过,这样同样带来了网络调用消耗增加的问题。总的来说,如果需要用到分布式 ID 的场景比较多的话,独立部署的发号器服务还是很有必要的。 分布式 ID 常见解决方案 # 数据库 # 数据库主键自增 # 这种方式就比较简单直白了,就是通过关系型数据库的自增主键产生来唯一的 ID。\n以 MySQL 举例,我们通过下面的方式即可。\n1.创建一个数据库表。\nCREATE TABLE `sequence_id` ( `id` bigint(20) unsigned NOT NULL AUTO_INCREMENT, `stub` char(10) NOT NULL DEFAULT \u0026#39;\u0026#39;, PRIMARY KEY (`id`), UNIQUE KEY `stub` (`stub`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; stub 字段无意义,只是为了占位,便于我们插入或者修改数据。并且,给 stub 字段创建了唯一索引,保证其唯一性。\n2.通过 replace into 来插入数据。\nBEGIN; REPLACE INTO sequence_id (stub) VALUES (\u0026#39;stub\u0026#39;); SELECT LAST_INSERT_ID(); COMMIT; 插入数据这里,我们没有使用 insert into 而是使用 replace into 来插入数据,具体步骤是这样的:\n第一步:尝试把数据插入到表中。\n第二步:如果主键或唯一索引字段出现重复数据错误而插入失败时,先从表中删除含有重复关键字值的冲突行,然后再次尝试把数据插入到表中。\n这种方式的优缺点也比较明显:\n优点:实现起来比较简单、ID 有序递增、存储消耗空间小 缺点:支持的并发量不大、存在数据库单点问题(可以使用数据库集群解决,不过增加了复杂度)、ID 没有具体业务含义、安全问题(比如根据订单 ID 的递增规律就能推算出每天的订单量,商业机密啊! )、每次获取 ID 都要访问一次数据库(增加了对数据库的压力,获取速度也慢) 数据库号段模式 # 数据库主键自增这种模式,每次获取 ID 都要访问一次数据库,ID 需求比较大的时候,肯定是不行的。\n如果我们可以批量获取,然后存在在内存里面,需要用到的时候,直接从内存里面拿就舒服了!这也就是我们说的 基于数据库的号段模式来生成分布式 ID。\n数据库的号段模式也是目前比较主流的一种分布式 ID 生成方式。像滴滴开源的 Tinyid 就是基于这种方式来做的。不过,TinyId 使用了双号段缓存、增加多 db 支持等方式来进一步优化。\n以 MySQL 举例,我们通过下面的方式即可。\n1. 创建一个数据库表。\nCREATE TABLE `sequence_id_generator` ( `id` int(10) NOT NULL, `current_max_id` bigint(20) NOT NULL COMMENT \u0026#39;当前最大id\u0026#39;, `step` int(10) NOT NULL COMMENT \u0026#39;号段的长度\u0026#39;, `version` int(20) NOT NULL COMMENT \u0026#39;版本号\u0026#39;, `biz_type` int(20) NOT NULL COMMENT \u0026#39;业务类型\u0026#39;, PRIMARY KEY (`id`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; current_max_id 字段和step字段主要用于获取批量 ID,获取的批量 id 为:current_max_id ~ current_max_id+step。\nversion 字段主要用于解决并发问题(乐观锁),biz_type 主要用于表示业务类型。\n2. 先插入一行数据。\nINSERT INTO `sequence_id_generator` (`id`, `current_max_id`, `step`, `version`, `biz_type`) VALUES (1, 0, 100, 0, 101); 3. 通过 SELECT 获取指定业务下的批量唯一 ID\nSELECT `current_max_id`, `step`,`version` FROM `sequence_id_generator` where `biz_type` = 101 结果:\nid current_max_id step version biz_type 1 0 100 0 101 4. 不够用的话,更新之后重新 SELECT 即可。\nUPDATE sequence_id_generator SET current_max_id = 0+100, version=version+1 WHERE version = 0 AND `biz_type` = 101 SELECT `current_max_id`, `step`,`version` FROM `sequence_id_generator` where `biz_type` = 101 结果:\nid current_max_id step version biz_type 1 100 100 1 101 相比于数据库主键自增的方式,数据库的号段模式对于数据库的访问次数更少,数据库压力更小。\n另外,为了避免单点问题,你可以从使用主从模式来提高可用性。\n数据库号段模式的优缺点:\n优点:ID 有序递增、存储消耗空间小 缺点:存在数据库单点问题(可以使用数据库集群解决,不过增加了复杂度)、ID 没有具体业务含义、安全问题(比如根据订单 ID 的递增规律就能推算出每天的订单量,商业机密啊! ) NoSQL # 一般情况下,NoSQL 方案使用 Redis 多一些。我们通过 Redis 的 incr 命令即可实现对 id 原子顺序递增。\n127.0.0.1:6379\u0026gt; set sequence_id_biz_type 1 OK 127.0.0.1:6379\u0026gt; incr sequence_id_biz_type (integer) 2 127.0.0.1:6379\u0026gt; get sequence_id_biz_type \u0026#34;2\u0026#34; 为了提高可用性和并发,我们可以使用 Redis Cluster。Redis Cluster 是 Redis 官方提供的 Redis 集群解决方案(3.0+版本)。\n除了 Redis Cluster 之外,你也可以使用开源的 Redis 集群方案 Codis (大规模集群比如上百个节点的时候比较推荐)。\n除了高可用和并发之外,我们知道 Redis 基于内存,我们需要持久化数据,避免重启机器或者机器故障后数据丢失。Redis 支持两种不同的持久化方式:快照(snapshotting,RDB)、只追加文件(append-only file, AOF)。 并且,Redis 4.0 开始支持 RDB 和 AOF 的混合持久化(默认关闭,可以通过配置项 aof-use-rdb-preamble 开启)。\n关于 Redis 持久化,我这里就不过多介绍。不了解这部分内容的小伙伴,可以看看 Redis 持久化机制详解这篇文章。\nRedis 方案的优缺点:\n优点:性能不错并且生成的 ID 是有序递增的 缺点:和数据库主键自增方案的缺点类似 除了 Redis 之外,MongoDB ObjectId 经常也会被拿来当做分布式 ID 的解决方案。\nMongoDB ObjectId 一共需要 12 个字节存储:\n0~3:时间戳 3~6:代表机器 ID 7~8:机器进程 ID 9~11:自增值 MongoDB 方案的优缺点:\n优点:性能不错并且生成的 ID 是有序递增的 缺点:需要解决重复 ID 问题(当机器时间不对的情况下,可能导致会产生重复 ID)、有安全性问题(ID 生成有规律性) 算法 # UUID # UUID 是 Universally Unique Identifier(通用唯一标识符) 的缩写。UUID 包含 32 个 16 进制数字(8-4-4-4-12)。\nJDK 就提供了现成的生成 UUID 的方法,一行代码就行了。\n//输出示例:cb4a9ede-fa5e-4585-b9bb-d60bce986eaa UUID.randomUUID() RFC 4122 中关于 UUID 的示例是这样的:\n我们这里重点关注一下这个 Version(版本),不同的版本对应的 UUID 的生成规则是不同的。\n5 种不同的 Version(版本)值分别对应的含义(参考 维基百科对于 UUID 的介绍):\n版本 1 : UUID 是根据时间和节点 ID(通常是 MAC 地址)生成; 版本 2 : UUID 是根据标识符(通常是组或用户 ID)、时间和节点 ID 生成; 版本 3、版本 5 : 版本 5 - 确定性 UUID 通过散列(hashing)名字空间(namespace)标识符和名称生成; 版本 4 : UUID 使用 随机性或 伪随机性生成。 下面是 Version 1 版本下生成的 UUID 的示例:\nJDK 中通过 UUID 的 randomUUID() 方法生成的 UUID 的版本默认为 4。\nUUID uuid = UUID.randomUUID(); int version = uuid.version();// 4 另外,Variant(变体)也有 4 种不同的值,这种值分别对应不同的含义。这里就不介绍了,貌似平时也不怎么需要关注。\n需要用到的时候,去看看维基百科对于 UUID 的 Variant(变体) 相关的介绍即可。\n从上面的介绍中可以看出,UUID 可以保证唯一性,因为其生成规则包括 MAC 地址、时间戳、名字空间(Namespace)、随机或伪随机数、时序等元素,计算机基于这些规则生成的 UUID 是肯定不会重复的。\n虽然,UUID 可以做到全局唯一性,但是,我们一般很少会使用它。\n比如使用 UUID 作为 MySQL 数据库主键的时候就非常不合适:\n数据库主键要尽量越短越好,而 UUID 的消耗的存储空间比较大(32 个字符串,128 位)。 UUID 是无顺序的,InnoDB 引擎下,数据库主键的无序性会严重影响数据库性能。 最后,我们再简单分析一下 UUID 的优缺点 (面试的时候可能会被问到的哦!) :\n优点:生成速度比较快、简单易用 缺点:存储消耗空间大(32 个字符串,128 位)、 不安全(基于 MAC 地址生成 UUID 的算法会造成 MAC 地址泄露)、无序(非自增)、没有具体业务含义、需要解决重复 ID 问题(当机器时间不对的情况下,可能导致会产生重复 ID) Snowflake(雪花算法) # Snowflake 是 Twitter 开源的分布式 ID 生成算法。Snowflake 由 64 bit 的二进制数字组成,这 64bit 的二进制被分成了几部分,每一部分存储的数据都有特定的含义:\nsign(1bit):符号位(标识正负),始终为 0,代表生成的 ID 为正数。 timestamp (41 bits):一共 41 位,用来表示时间戳,单位是毫秒,可以支撑 2 ^41 毫秒(约 69 年) datacenter id + worker id (10 bits):一般来说,前 5 位表示机房 ID,后 5 位表示机器 ID(实际项目中可以根据实际情况调整)。这样就可以区分不同集群/机房的节点。 sequence (12 bits):一共 12 位,用来表示序列号。 序列号为自增值,代表单台机器每毫秒能够产生的最大 ID 数(2^12 = 4096),也就是说单台机器每毫秒最多可以生成 4096 个 唯一 ID。 在实际项目中,我们一般也会对 Snowflake 算法进行改造,最常见的就是在 Snowflake 算法生成的 ID 中加入业务类型信息。\n我们再来看看 Snowflake 算法的优缺点:\n优点:生成速度比较快、生成的 ID 有序递增、比较灵活(可以对 Snowflake 算法进行简单的改造比如加入业务 ID) 缺点:需要解决重复 ID 问题(ID 生成依赖时间,在获取时间的时候,可能会出现时间回拨的问题,也就是服务器上的时间突然倒退到之前的时间,进而导致会产生重复 ID)、依赖机器 ID 对分布式环境不友好(当需要自动启停或增减机器时,固定的机器 ID 可能不够灵活)。 如果你想要使用 Snowflake 算法的话,一般不需要你自己再造轮子。有很多基于 Snowflake 算法的开源实现比如美团 的 Leaf、百度的 UidGenerator(后面会提到),并且这些开源实现对原有的 Snowflake 算法进行了优化,性能更优秀,还解决了 Snowflake 算法的时间回拨问题和依赖机器 ID 的问题。\n并且,Seata 还提出了“改良版雪花算法”,针对原版雪花算法进行了一定的优化改良,解决了时间回拨问题,大幅提高的 QPS。具体介绍和改进原理,可以参考下面这两篇文章:\nSeata 基于改良版雪花算法的分布式 UUID 生成器分析 在开源项目中看到一个改良版的雪花算法,现在它是你的了。 开源框架 # UidGenerator(百度) # UidGenerator 是百度开源的一款基于 Snowflake(雪花算法)的唯一 ID 生成器。\n不过,UidGenerator 对 Snowflake(雪花算法)进行了改进,生成的唯一 ID 组成如下:\nsign(1bit):符号位(标识正负),始终为 0,代表生成的 ID 为正数。 delta seconds (28 bits):当前时间,相对于时间基点\u0026quot;2016-05-20\u0026quot;的增量值,单位:秒,最多可支持约 8.7 年 worker id (22 bits):机器 id,最多可支持约 420w 次机器启动。内置实现为在启动时由数据库分配,默认分配策略为用后即弃,后续可提供复用策略。 sequence (13 bits):每秒下的并发序列,13 bits 可支持每秒 8192 个并发。 可以看出,和原始 Snowflake(雪花算法)生成的唯一 ID 的组成不太一样。并且,上面这些参数我们都可以自定义。\nUidGenerator 官方文档中的介绍如下:\n自 18 年后,UidGenerator 就基本没有再维护了,我这里也不过多介绍。想要进一步了解的朋友,可以看看 UidGenerator 的官方介绍。\nLeaf(美团) # Leaf 是美团开源的一个分布式 ID 解决方案 。这个项目的名字 Leaf(树叶) 起源于德国哲学家、数学家莱布尼茨的一句话:“There are no two identical leaves in the world”(世界上没有两片相同的树叶) 。这名字起得真心挺不错的,有点文艺青年那味了!\nLeaf 提供了 号段模式 和 Snowflake(雪花算法) 这两种模式来生成分布式 ID。并且,它支持双号段,还解决了雪花 ID 系统时钟回拨问题。不过,时钟问题的解决需要弱依赖于 Zookeeper(使用 Zookeeper 作为注册中心,通过在特定路径下读取和创建子节点来管理 workId) 。\nLeaf 的诞生主要是为了解决美团各个业务线生成分布式 ID 的方法多种多样以及不可靠的问题。\nLeaf 对原有的号段模式进行改进,比如它这里增加了双号段避免获取 DB 在获取号段的时候阻塞请求获取 ID 的线程。简单来说,就是我一个号段还没用完之前,我自己就主动提前去获取下一个号段(图片来自于美团官方文章: 《Leaf——美团点评分布式 ID 生成系统》)。\n根据项目 README 介绍,在 4C8G VM 基础上,通过公司 RPC 方式调用,QPS 压测结果近 5w/s,TP999 1ms。\nTinyid(滴滴) # Tinyid 是滴滴开源的一款基于数据库号段模式的唯一 ID 生成器。\n数据库号段模式的原理我们在上面已经介绍过了。Tinyid 有哪些亮点呢?\n为了搞清楚这个问题,我们先来看看基于数据库号段模式的简单架构方案。(图片来自于 Tinyid 的官方 wiki: 《Tinyid 原理介绍》)\n在这种架构模式下,我们通过 HTTP 请求向发号器服务申请唯一 ID。负载均衡 router 会把我们的请求送往其中的一台 tinyid-server。\n这种方案有什么问题呢?在我看来(Tinyid 官方 wiki 也有介绍到),主要由下面这 2 个问题:\n获取新号段的情况下,程序获取唯一 ID 的速度比较慢。 需要保证 DB 高可用,这个是比较麻烦且耗费资源的。 除此之外,HTTP 调用也存在网络开销。\nTinyid 的原理比较简单,其架构如下图所示:\n相比于基于数据库号段模式的简单架构方案,Tinyid 方案主要做了下面这些优化:\n双号段缓存:为了避免在获取新号段的情况下,程序获取唯一 ID 的速度比较慢。 Tinyid 中的号段在用到一定程度的时候,就会去异步加载下一个号段,保证内存中始终有可用号段。 增加多 db 支持:支持多个 DB,并且,每个 DB 都能生成唯一 ID,提高了可用性。 增加 tinyid-client:纯本地操作,无 HTTP 请求消耗,性能和可用性都有很大提升。 Tinyid 的优缺点这里就不分析了,结合数据库号段模式的优缺点和 Tinyid 的原理就能知道。\nIdGenerator(个人) # 和 UidGenerator、Leaf 一样, IdGenerator 也是一款基于 Snowflake(雪花算法)的唯一 ID 生成器。\nIdGenerator 有如下特点:\n生成的唯一 ID 更短; 兼容所有雪花算法(号段模式或经典模式,大厂或小厂); 原生支持 C#/Java/Go/C/Rust/Python/Node.js/PHP(C 扩展)/SQL/ 等语言,并提供多线程安全调用动态库(FFI); 解决了时间回拨问题,支持手工插入新 ID(当业务需要在历史时间生成新 ID 时,用本算法的预留位能生成 5000 个每秒); 不依赖外部存储系统; 默认配置下,ID 可用 71000 年不重复。 IdGenerator 生成的唯一 ID 组成如下:\ntimestamp (位数不固定):时间差,是生成 ID 时的系统时间减去 BaseTime(基础时间,也称基点时间、原点时间、纪元时间,默认值为 2020 年) 的总时间差(毫秒单位)。初始为 5bits,随着运行时间而增加。如果觉得默认值太老,你可以重新设置,不过要注意,这个值以后最好不变。 worker id (默认 6 bits):机器 id,机器码,最重要参数,是区分不同机器或不同应用的唯一 ID,最大值由 WorkerIdBitLength(默认 6)限定。如果一台服务器部署多个独立服务,需要为每个服务指定不同的 WorkerId。 sequence (默认 6 bits):序列数,是每毫秒下的序列数,由参数中的 SeqBitLength(默认 6)限定。增加 SeqBitLength 会让性能更高,但生成的 ID 也会更长。 Java 语言使用示例: https://github.com/yitter/idgenerator/tree/master/Java。\n总结 # 通过这篇文章,我基本上已经把最常见的分布式 ID 生成方案都总结了一波。\n除了上面介绍的方式之外,像 ZooKeeper 这类中间件也可以帮助我们生成唯一 ID。没有银弹,一定要结合实际项目来选择最适合自己的方案。\n不过,本文主要介绍的是分布式 ID 的理论知识。在实际的面试中,面试官可能会结合具体的业务场景来考察你对分布式 ID 的设计,你可以参考这篇文章: 分布式 ID 设计指南(对于实际工作中分布式 ID 的设计也非常有帮助)。\n"},{"id":617,"href":"/zh/docs/technology/Interview/distributed-system/distributed-id-design/","title":"分布式ID设计指南","section":"Distributed System","content":"::: tip\n看到百度 Geek 说的一篇结合具体场景聊分布式 ID 设计的文章,感觉挺不错的。于是,我将这篇文章的部分内容整理到了这里。原文传送门: 分布式 ID 生成服务的技术原理和项目实战 。\n:::\n网上绝大多数的分布式 ID 生成服务,一般着重于技术原理剖析,很少见到根据具体的业务场景去选型 ID 生成服务的文章。\n本文结合一些使用场景,进一步探讨业务场景中对 ID 有哪些具体的要求。\n场景一:订单系统 # 我们在商场买东西一码付二维码,下单生成的订单号,使用到的优惠券码,联合商品兑换券码,这些是在网上购物经常使用到的单号,那么为什么有些单号那么长,有些只有几位数?有些单号一看就知道年月日的信息,有些却看不出任何意义?下面展开分析下订单系统中不同场景的 id 服务的具体实现。\n1、一码付 # 我们常见的一码付,指的是一个二维码可以使用支付宝或者微信进行扫码支付。\n二维码的本质是一个字符串。聚合码的本质就是一个链接地址。用户使用支付宝微信直接扫一个码付钱,不用担心拿支付宝扫了微信的收款码或者用微信扫了支付宝的收款码,这极大减少了用户扫码支付的时间。\n实现原理是当客户用 APP 扫码后,网站后台就会判断客户的扫码环境。(微信、支付宝、QQ 钱包、京东支付、云闪付等)。\n判断扫码环境的原理就是根据打开链接浏览器的 HTTP header。任何浏览器打开 http 链接时,请求的 header 都会有 User-Agent(UA、用户代理)信息。\nUA 是一个特殊字符串头,服务器依次可以识别出客户使用的操作系统及版本、CPU 类型、浏览器及版本、浏览器渲染引擎、浏览器语言、浏览器插件等很多信息。\n各渠道对应支付产品的名称不一样,一定要仔细看各支付产品的 API 介绍。\n微信支付:JSAPI 支付支付 支付宝:手机网站支付 QQ 钱包:公众号支付 其本质均为在 APP 内置浏览器中实现 HTML5 支付。\n文库的研发同学在这个思路上,做了优化迭代。动态生成一码付的二维码预先绑定用户所选的商品信息和价格,根据用户所选的商品动态更新。这样不仅支持一码多平台调起支付,而且不用用户选择商品输入金额,即可完成订单支付的功能,很丝滑。用户在真正扫码后,服务端才通过前端获取用户 UID,结合二维码绑定的商品信息,真正的生成订单,发送支付信息到第三方(qq、微信、支付宝),第三方生成支付订单推给用户设备,从而调起支付。\n区别于固定的一码付,在文库的应用中,使用到了动态二维码,二维码本质是一个短网址,ID 服务提供短网址的唯一标志参数。唯一的短网址映射的 ID 绑定了商品的订单信息,技术和业务的深度结合,缩短了支付流程,提升用户的支付体验。\n2、订单号 # 订单号在实际的业务过程中作为一个订单的唯一标识码存在,一般实现以下业务场景:\n用户订单遇到问题,需要找客服进行协助; 对订单进行操作,如线下收款,订单核销; 下单,改单,成单,退单,售后等系统内部的订单流程处理和跟进。 很多时候搜索订单相关信息的时候都是以订单 ID 作为唯一标识符,这是由于订单号的生成规则的唯一性决定的。从技术角度看,除了 ID 服务必要的特性之外,在订单号的设计上需要体现几个特性:\n(1)信息安全\n编号不能透露公司的运营情况,比如日销、公司流水号等信息,以及商业信息和用户手机号,身份证等隐私信息。并且不能有明显的整体规律(可以有局部规律),任意修改一个字符就能查询到另一个订单信息,这也是不允许的。\n类比于我们高考时候的考生编号的生成规则,一定不能是连号的,否则只需要根据顺序往下查询就能搜索到别的考生的成绩,这是绝对不可允许。\n(2)部分可读\n位数要便于操作,因此要求订单号的位数适中,且局部有规律。这样可以方便在订单异常,或者退货时客服查询。\n过长的订单号或易读性差的订单号会导致客服输入困难且易错率较高,影响用户体验的售后体验。因此在实际的业务场景中,订单号的设计通常都会适当携带一些允许公开的对使用场景有帮助的信息,如时间,星期,类型等等,这个主要根据所涉及的编号对应的使用场景来。\n而且像时间、星期这些自增长的属于作为订单号的设计的一部分元素,有助于解决业务累积而导致的订单号重复的问题。\n(3)查询效率\n常见的电商平台订单号大多是纯数字组成,兼具可读性的同时,int 类型相对 varchar 类型的查询效率更高,对在线业务更加友好。\n3、优惠券和兑换券 # 优惠券、兑换券是运营推广最常用的促销工具之一,合理使用它们,可以让买家得到实惠,商家提升商品销量。常见场景有:\n在文库购买【文库 VIP+QQ 音乐年卡】联合商品,支付成功后会得到 QQ 音乐年卡的兑换码,可以去 QQ 音乐 App 兑换音乐会员年卡; 疫情期间,部分地方政府发放的消费券; 瓶装饮料经常会出现输入优惠编码兑换奖品。 从技术角度看,有些场景适合 ID 即时生成,比如电商平台购物领取的优惠券,只需要在用户领取时分配优惠券信息即可。有些线上线下结合的场景,比如疫情优惠券,瓶盖开奖,京东卡,超市卡这种,则需要预先生成,预先生成的券码具备以下特性:\n1.预先生成,在活动正式开始前提供出来进行活动预热;\n2.优惠券体量大,以万为单位,通常在 10 万级别以上;\n3.不可破解、仿制券码;\n4.支持用后核销;\n5.优惠券、兑换券属于广撒网的策略,所以利用率低,也就不适合使用数据库进行存储 (占空间,有效的数据又少)。\n设计思路上,需要设计一种有效的兑换码生成策略,支持预先生成,支持校验,内容简洁,生成的兑换码都具有唯一性,那么这种策略就是一种特殊的编解码策略,按照约定的编解码规则支撑上述需求。\n既然是一种编解码规则,那么需要约定编码空间(也就是用户看到的组成兑换码的字符),编码空间由字符 a-z,A-Z,数字 0-9 组成,为了增强兑换码的可识别度,剔除大写字母 O 以及 I,可用字符如下所示,共 60 个字符:\nabcdefghijklmnopqrstuvwxyzABCDEFGHJKLMNPQRSTUVWXZY0123456789\n之前说过,兑换码要求尽可能简洁,那么设计时就需要考虑兑换码的字符数,假设上限为 12 位,而字符空间有 60 位,那么可以表示的空间范围为 60^12=130606940160000000000000(也就是可以 12 位的兑换码可以生成天量,应该够运营同学挥霍了),转换成 2 进制:\n1001000100000000101110011001101101110011000000000000000000000(61 位)\n兑换码组成成分分析\n兑换码可以预先生成,并且不需要额外的存储空间保存这些信息,每一个优惠方案都有独立的一组兑换码(指运营同学组织的每一场运营活动都有不同的兑换码,不能混合使用, 例如双 11 兑换码不能使用在双 12 活动上),每个兑换码有自己的编号,防止重复,为了保证兑换码的有效性,对兑换码的数据需要进行校验,当前兑换码的数据组成如下所示:\n优惠方案 ID + 兑换码序列号 i + 校验码\n编码方案\n兑换码序列号 i,代表当前兑换码是当前活动中第 i 个兑换码,兑换码序列号的空间范围决定了优惠活动可以发行的兑换码数目,当前采用 30 位 bit 位表示,可表示范围:1073741824(10 亿个券码)。 优惠方案 ID, 代表当前优惠方案的 ID 号,优惠方案的空间范围决定了可以组织的优惠活动次数,当前采用 15 位表示,可以表示范围:32768(考虑到运营活动的频率,以及 ID 的初始值 10000,15 位足够,365 天每天有运营活动,可以使用 54 年)。 校验码,校验兑换码是否有效,主要为了快捷的校验兑换码信息的是否正确,其次可以起到填充数据的目的,增强数据的散列性,使用 13 位表示校验位,其中分为两部分,前 6 位和后 7 位。 深耕业务还会有区分通用券和单独券的情况,分别具备以下特点,技术实现需要因地制宜地思考。\n通用券:多个玩家都可以输入兑换,然后有总量限制,期限限制。 单独券:运营同学可以在后台设置兑换码的奖励物品、期限、个数,然后由后台生成兑换码的列表,兑换之后核销。 场景二:Tracing # 1、日志跟踪 # 在分布式服务架构下,一个 Web 请求从网关流入,有可能会调用多个服务对请求进行处理,拿到最终结果。这个过程中每个服务之间的通信又是单独的网络请求,无论请求经过的哪个服务出了故障或者处理过慢都会对前端造成影响。\n处理一个 Web 请求要调用的多个服务,为了能更方便的查询哪个环节的服务出现了问题,现在常用的解决方案是为整个系统引入分布式链路跟踪。\n在分布式链路跟踪中有两个重要的概念:跟踪(trace)和 跨度( span)。trace 是请求在分布式系统中的整个链路视图,span 则代表整个链路中不同服务内部的视图,span 组合在一起就是整个 trace 的视图。\n在整个请求的调用链中,请求会一直携带 traceid 往下游服务传递,每个服务内部也会生成自己的 spanid 用于生成自己的内部调用视图,并和 traceid 一起传递给下游服务。\n2、TraceId 生成规则 # 这种场景下,生成的 ID 除了要求唯一之外,还要求生成的效率高、吞吐量大。traceid 需要具备接入层的服务器实例自主生成的能力,如果每个 trace 中的 ID 都需要请求公共的 ID 服务生成,纯纯的浪费网络带宽资源。且会阻塞用户请求向下游传递,响应耗时上升,增加了没必要的风险。所以需要服务器实例最好可以自行计算 tracid,spanid,避免依赖外部服务。\n产生规则:服务器 IP + ID 产生的时间 + 自增序列 + 当前进程号 ,比如:\n0ad1348f1403169275002100356696\n前 8 位 0ad1348f 即产生 TraceId 的机器的 IP,这是一个十六进制的数字,每两位代表 IP 中的一段,我们把这个数字,按每两位转成 10 进制即可得到常见的 IP 地址表示方式 10.209.52.143,您也可以根据这个规律来查找到请求经过的第一个服务器。\n后面的 13 位 1403169275002 是产生 TraceId 的时间。之后的 4 位 1003 是一个自增的序列,从 1000 涨到 9000,到达 9000 后回到 1000 再开始往上涨。最后的 5 位 56696 是当前的进程 ID,为了防止单机多进程出现 TraceId 冲突的情况,所以在 TraceId 末尾添加了当前的进程 ID。\n3、SpanId 生成规则 # span 是层的意思,比如在第一个实例算是第一层, 请求代理或者分流到下一个实例处理,就是第二层,以此类推。通过层,SpanId 代表本次调用在整个调用链路树中的位置。\n假设一个 服务器实例 A 接收了一次用户请求,代表是整个调用的根节点,那么 A 层处理这次请求产生的非服务调用日志记录 spanid 的值都是 0,A 层需要通过 RPC 依次调用 B、C、D 三个服务器实例,那么在 A 的日志中,SpanId 分别是 0.1,0.2 和 0.3,在 B、C、D 中,SpanId 也分别是 0.1,0.2 和 0.3;如果 C 系统在处理请求的时候又调用了 E,F 两个服务器实例,那么 C 系统中对应的 spanid 是 0.2.1 和 0.2.2,E、F 两个系统对应的日志也是 0.2.1 和 0.2.2。\n根据上面的描述可以知道,如果把一次调用中所有的 SpanId 收集起来,可以组成一棵完整的链路树。\nspanid 的生成本质:在跨层传递透传的同时,控制大小版本号的自增来实现的。\n场景三:短网址 # 短网址主要功能包括网址缩短与还原两大功能。相对于长网址,短网址可以更方便地在电子邮件,社交网络,微博和手机上传播,例如原来很长的网址通过短网址服务即可生成相应的短网址,避免折行或超出字符限制。\n常用的 ID 生成服务比如:MySQL ID 自增、 Redis 键自增、号段模式,生成的 ID 都是一串数字。短网址服务把客户的长网址转换成短网址,\n实际是在 dwz.cn 域名后面拼接新产生的数字类型 ID,直接用数字 ID,网址长度也有些长,服务可以通过数字 ID 转更高进制的方式压缩长度。这种算法在短网址的技术实现上越来越多了起来,它可以进一步压缩网址长度。转进制的压缩算法在生活中有广泛的应用场景,举例:\n客户的长网址: https://wenku.baidu.com/ndbusiness/browse/wenkuvipcashier?cashier_code=PCoperatebanner ID 映射的短网址: https://dwz.cn/2047601319t66 (演示使用,可能无法正确打开) 转进制后的短网址: https://dwz.cn/2ezwDJ0 (演示使用,可能无法正确打开) "},{"id":618,"href":"/zh/docs/technology/Interview/distributed-system/distributed-configuration-center/","title":"分布式配置中心常见问题总结(付费)","section":"Distributed System","content":"分布式配置中心 相关的面试题为我的 知识星球(点击链接即可查看详细介绍以及加入方法)专属内容,已经整理到了《Java 面试指北》中。\n"},{"id":619,"href":"/zh/docs/technology/Interview/distributed-system/distributed-transaction/","title":"分布式事务常见解决方案总结(付费)","section":"Distributed System","content":"分布式事务 相关的面试题为我的 知识星球(点击链接即可查看详细介绍以及加入方法)专属内容,已经整理到了《Java 面试指北》中。\n"},{"id":620,"href":"/zh/docs/technology/Interview/distributed-system/distributed-lock-implementations/","title":"分布式锁常见实现方案总结","section":"Distributed System","content":"通常情况下,我们一般会选择基于 Redis 或者 ZooKeeper 实现分布式锁,Redis 用的要更多一点,我这里也先以 Redis 为例介绍分布式锁的实现。\n基于 Redis 实现分布式锁 # 如何基于 Redis 实现一个最简易的分布式锁? # 不论是本地锁还是分布式锁,核心都在于“互斥”。\n在 Redis 中, SETNX 命令是可以帮助我们实现互斥。SETNX 即 SET if Not eXists (对应 Java 中的 setIfAbsent 方法),如果 key 不存在的话,才会设置 key 的值。如果 key 已经存在, SETNX 啥也不做。\n\u0026gt; SETNX lockKey uniqueValue (integer) 1 \u0026gt; SETNX lockKey uniqueValue (integer) 0 释放锁的话,直接通过 DEL 命令删除对应的 key 即可。\n\u0026gt; DEL lockKey (integer) 1 为了防止误删到其他的锁,这里我们建议使用 Lua 脚本通过 key 对应的 value(唯一值)来判断。\n选用 Lua 脚本是为了保证解锁操作的原子性。因为 Redis 在执行 Lua 脚本时,可以以原子性的方式执行,从而保证了锁释放操作的原子性。\n// 释放锁时,先比较锁对应的 value 值是否相等,避免锁的误释放 if redis.call(\u0026#34;get\u0026#34;,KEYS[1]) == ARGV[1] then return redis.call(\u0026#34;del\u0026#34;,KEYS[1]) else return 0 end 这是一种最简易的 Redis 分布式锁实现,实现方式比较简单,性能也很高效。不过,这种方式实现分布式锁存在一些问题。就比如应用程序遇到一些问题比如释放锁的逻辑突然挂掉,可能会导致锁无法被释放,进而造成共享资源无法再被其他线程/进程访问。\n为什么要给锁设置一个过期时间? # 为了避免锁无法被释放,我们可以想到的一个解决办法就是:给这个 key(也就是锁) 设置一个过期时间 。\n127.0.0.1:6379\u0026gt; SET lockKey uniqueValue EX 3 NX OK lockKey:加锁的锁名; uniqueValue:能够唯一标识锁的随机字符串; NX:只有当 lockKey 对应的 key 值不存在的时候才能 SET 成功; EX:过期时间设置(秒为单位)EX 3 标示这个锁有一个 3 秒的自动过期时间。与 EX 对应的是 PX(毫秒为单位),这两个都是过期时间设置。 一定要保证设置指定 key 的值和过期时间是一个原子操作!!! 不然的话,依然可能会出现锁无法被释放的问题。\n这样确实可以解决问题,不过,这种解决办法同样存在漏洞:如果操作共享资源的时间大于过期时间,就会出现锁提前过期的问题,进而导致分布式锁直接失效。如果锁的超时时间设置过长,又会影响到性能。\n你或许在想:如果操作共享资源的操作还未完成,锁过期时间能够自己续期就好了!\n如何实现锁的优雅续期? # 对于 Java 开发的小伙伴来说,已经有了现成的解决方案: Redisson 。其他语言的解决方案,可以在 Redis 官方文档中找到,地址: https://redis.io/topics/distlock 。\nRedisson 是一个开源的 Java 语言 Redis 客户端,提供了很多开箱即用的功能,不仅仅包括多种分布式锁的实现。并且,Redisson 还支持 Redis 单机、Redis Sentinel、Redis Cluster 等多种部署架构。\nRedisson 中的分布式锁自带自动续期机制,使用起来非常简单,原理也比较简单,其提供了一个专门用来监控和续期锁的 Watch Dog( 看门狗),如果操作共享资源的线程还未执行完成的话,Watch Dog 会不断地延长锁的过期时间,进而保证锁不会因为超时而被释放。\n看门狗名字的由来于 getLockWatchdogTimeout() 方法,这个方法返回的是看门狗给锁续期的过期时间,默认为 30 秒( redisson-3.17.6)。\n//默认 30秒,支持修改 private long lockWatchdogTimeout = 30 * 1000; public Config setLockWatchdogTimeout(long lockWatchdogTimeout) { this.lockWatchdogTimeout = lockWatchdogTimeout; return this; } public long getLockWatchdogTimeout() { return lockWatchdogTimeout; } renewExpiration() 方法包含了看门狗的主要逻辑:\nprivate void renewExpiration() { //...... Timeout task = commandExecutor.getConnectionManager().newTimeout(new TimerTask() { @Override public void run(Timeout timeout) throws Exception { //...... // 异步续期,基于 Lua 脚本 CompletionStage\u0026lt;Boolean\u0026gt; future = renewExpirationAsync(threadId); future.whenComplete((res, e) -\u0026gt; { if (e != null) { // 无法续期 log.error(\u0026#34;Can\u0026#39;t update lock \u0026#34; + getRawName() + \u0026#34; expiration\u0026#34;, e); EXPIRATION_RENEWAL_MAP.remove(getEntryName()); return; } if (res) { // 递归调用实现续期 renewExpiration(); } else { // 取消续期 cancelExpirationRenewal(null); } }); } // 延迟 internalLockLeaseTime/3(默认 10s,也就是 30/3) 再调用 }, internalLockLeaseTime / 3, TimeUnit.MILLISECONDS); ee.setTimeout(task); } 默认情况下,每过 10 秒,看门狗就会执行续期操作,将锁的超时时间设置为 30 秒。看门狗续期前也会先判断是否需要执行续期操作,需要才会执行续期,否则取消续期操作。\nWatch Dog 通过调用 renewExpirationAsync() 方法实现锁的异步续期:\nprotected CompletionStage\u0026lt;Boolean\u0026gt; renewExpirationAsync(long threadId) { return evalWriteAsync(getRawName(), LongCodec.INSTANCE, RedisCommands.EVAL_BOOLEAN, // 判断是否为持锁线程,如果是就执行续期操作,就锁的过期时间设置为 30s(默认) \u0026#34;if (redis.call(\u0026#39;hexists\u0026#39;, KEYS[1], ARGV[2]) == 1) then \u0026#34; + \u0026#34;redis.call(\u0026#39;pexpire\u0026#39;, KEYS[1], ARGV[1]); \u0026#34; + \u0026#34;return 1; \u0026#34; + \u0026#34;end; \u0026#34; + \u0026#34;return 0;\u0026#34;, Collections.singletonList(getRawName()), internalLockLeaseTime, getLockName(threadId)); } 可以看出, renewExpirationAsync 方法其实是调用 Lua 脚本实现的续期,这样做主要是为了保证续期操作的原子性。\n我这里以 Redisson 的分布式可重入锁 RLock 为例来说明如何使用 Redisson 实现分布式锁:\n// 1.获取指定的分布式锁对象 RLock lock = redisson.getLock(\u0026#34;lock\u0026#34;); // 2.拿锁且不设置锁超时时间,具备 Watch Dog 自动续期机制 lock.lock(); // 3.执行业务 ... // 4.释放锁 lock.unlock(); 只有未指定锁超时时间,才会使用到 Watch Dog 自动续期机制。\n// 手动给锁设置过期时间,不具备 Watch Dog 自动续期机制 lock.lock(10, TimeUnit.SECONDS); 如果使用 Redis 来实现分布式锁的话,还是比较推荐直接基于 Redisson 来做的。\n如何实现可重入锁? # 所谓可重入锁指的是在一个线程中可以多次获取同一把锁,比如一个线程在执行一个带锁的方法,该方法中又调用了另一个需要相同锁的方法,则该线程可以直接执行调用的方法即可重入 ,而无需重新获得锁。像 Java 中的 synchronized 和 ReentrantLock 都属于可重入锁。\n不可重入的分布式锁基本可以满足绝大部分业务场景了,一些特殊的场景可能会需要使用可重入的分布式锁。\n可重入分布式锁的实现核心思路是线程在获取锁的时候判断是否为自己的锁,如果是的话,就不用再重新获取了。为此,我们可以为每个锁关联一个可重入计数器和一个占有它的线程。当可重入计数器大于 0 时,则锁被占有,需要判断占有该锁的线程和请求获取锁的线程是否为同一个。\n实际项目中,我们不需要自己手动实现,推荐使用我们上面提到的 Redisson ,其内置了多种类型的锁比如可重入锁(Reentrant Lock)、自旋锁(Spin Lock)、公平锁(Fair Lock)、多重锁(MultiLock)、 红锁(RedLock)、 读写锁(ReadWriteLock)。\nRedis 如何解决集群情况下分布式锁的可靠性? # 为了避免单点故障,生产环境下的 Redis 服务通常是集群化部署的。\nRedis 集群下,上面介绍到的分布式锁的实现会存在一些问题。由于 Redis 集群数据同步到各个节点时是异步的,如果在 Redis 主节点获取到锁后,在没有同步到其他节点时,Redis 主节点宕机了,此时新的 Redis 主节点依然可以获取锁,所以多个应用服务就可以同时获取到锁。\n针对这个问题,Redis 之父 antirez 设计了 Redlock 算法 来解决。\nRedlock 算法的思想是让客户端向 Redis 集群中的多个独立的 Redis 实例依次请求申请加锁,如果客户端能够和半数以上的实例成功地完成加锁操作,那么我们就认为,客户端成功地获得分布式锁,否则加锁失败。\n即使部分 Redis 节点出现问题,只要保证 Redis 集群中有半数以上的 Redis 节点可用,分布式锁服务就是正常的。\nRedlock 是直接操作 Redis 节点的,并不是通过 Redis 集群操作的,这样才可以避免 Redis 集群主从切换导致的锁丢失问题。\nRedlock 实现比较复杂,性能比较差,发生时钟变迁的情况下还存在安全性隐患。《数据密集型应用系统设计》一书的作者 Martin Kleppmann 曾经专门发文( How to do distributed locking - Martin Kleppmann - 2016)怼过 Redlock,他认为这是一个很差的分布式锁实现。感兴趣的朋友可以看看 Redis 锁从面试连环炮聊到神仙打架这篇文章,有详细介绍到 antirez 和 Martin Kleppmann 关于 Redlock 的激烈辩论。\n实际项目中不建议使用 Redlock 算法,成本和收益不成正比,可以考虑基于 Redis 主从复制+哨兵模式实现分布式锁。\n基于 ZooKeeper 实现分布式锁 # ZooKeeper 相比于 Redis 实现分布式锁,除了提供相对更高的可靠性之外,在功能层面还有一个非常有用的特性:Watch 机制。这个机制可以用来实现公平的分布式锁。不过,使用 ZooKeeper 实现的分布式锁在性能方面相对较差,因此如果对性能要求比较高的话,ZooKeeper 可能就不太适合了。\n如何基于 ZooKeeper 实现分布式锁? # ZooKeeper 分布式锁是基于 临时顺序节点 和 Watcher(事件监听器) 实现的。\n获取锁:\n首先我们要有一个持久节点/locks,客户端获取锁就是在locks下创建临时顺序节点。 假设客户端 1 创建了/locks/lock1节点,创建成功之后,会判断 lock1是否是 /locks 下最小的子节点。 如果 lock1是最小的子节点,则获取锁成功。否则,获取锁失败。 如果获取锁失败,则说明有其他的客户端已经成功获取锁。客户端 1 并不会不停地循环去尝试加锁,而是在前一个节点比如/locks/lock0上注册一个事件监听器。这个监听器的作用是当前一个节点释放锁之后通知客户端 1(避免无效自旋),这样客户端 1 就加锁成功了。 释放锁:\n成功获取锁的客户端在执行完业务流程之后,会将对应的子节点删除。 成功获取锁的客户端在出现故障之后,对应的子节点由于是临时顺序节点,也会被自动删除,避免了锁无法被释放。 我们前面说的事件监听器其实监听的就是这个子节点删除事件,子节点删除就意味着锁被释放。 实际项目中,推荐使用 Curator 来实现 ZooKeeper 分布式锁。Curator 是 Netflix 公司开源的一套 ZooKeeper Java 客户端框架,相比于 ZooKeeper 自带的客户端 zookeeper 来说,Curator 的封装更加完善,各种 API 都可以比较方便地使用。\nCurator主要实现了下面四种锁:\nInterProcessMutex:分布式可重入排它锁 InterProcessSemaphoreMutex:分布式不可重入排它锁 InterProcessReadWriteLock:分布式读写锁 InterProcessMultiLock:将多个锁作为单个实体管理的容器,获取锁的时候获取所有锁,释放锁也会释放所有锁资源(忽略释放失败的锁)。 CuratorFramework client = ZKUtils.getClient(); client.start(); // 分布式可重入排它锁 InterProcessLock lock1 = new InterProcessMutex(client, lockPath1); // 分布式不可重入排它锁 InterProcessLock lock2 = new InterProcessSemaphoreMutex(client, lockPath2); // 将多个锁作为一个整体 InterProcessMultiLock lock = new InterProcessMultiLock(Arrays.asList(lock1, lock2)); if (!lock.acquire(10, TimeUnit.SECONDS)) { throw new IllegalStateException(\u0026#34;不能获取多锁\u0026#34;); } System.out.println(\u0026#34;已获取多锁\u0026#34;); System.out.println(\u0026#34;是否有第一个锁: \u0026#34; + lock1.isAcquiredInThisProcess()); System.out.println(\u0026#34;是否有第二个锁: \u0026#34; + lock2.isAcquiredInThisProcess()); try { // 资源操作 resource.use(); } finally { System.out.println(\u0026#34;释放多个锁\u0026#34;); lock.release(); } System.out.println(\u0026#34;是否有第一个锁: \u0026#34; + lock1.isAcquiredInThisProcess()); System.out.println(\u0026#34;是否有第二个锁: \u0026#34; + lock2.isAcquiredInThisProcess()); client.close(); 为什么要用临时顺序节点? # 每个数据节点在 ZooKeeper 中被称为 znode,它是 ZooKeeper 中数据的最小单元。\n我们通常是将 znode 分为 4 大类:\n持久(PERSISTENT)节点:一旦创建就一直存在即使 ZooKeeper 集群宕机,直到将其删除。 临时(EPHEMERAL)节点:临时节点的生命周期是与 客户端会话(session) 绑定的,会话消失则节点消失 。并且,临时节点只能做叶子节点 ,不能创建子节点。 持久顺序(PERSISTENT_SEQUENTIAL)节点:除了具有持久(PERSISTENT)节点的特性之外, 子节点的名称还具有顺序性。比如 /node1/app0000000001、/node1/app0000000002 。 临时顺序(EPHEMERAL_SEQUENTIAL)节点:除了具备临时(EPHEMERAL)节点的特性之外,子节点的名称还具有顺序性。 可以看出,临时节点相比持久节点,最主要的是对会话失效的情况处理不一样,临时节点会话消失则对应的节点消失。这样的话,如果客户端发生异常导致没来得及释放锁也没关系,会话失效节点自动被删除,不会发生死锁的问题。\n使用 Redis 实现分布式锁的时候,我们是通过过期时间来避免锁无法被释放导致死锁问题的,而 ZooKeeper 直接利用临时节点的特性即可。\n假设不使用顺序节点的话,所有尝试获取锁的客户端都会对持有锁的子节点加监听器。当该锁被释放之后,势必会造成所有尝试获取锁的客户端来争夺锁,这样对性能不友好。使用顺序节点之后,只需要监听前一个节点就好了,对性能更友好。\n为什么要设置对前一个节点的监听? # Watcher(事件监听器),是 ZooKeeper 中的一个很重要的特性。ZooKeeper 允许用户在指定节点上注册一些 Watcher,并且在一些特定事件触发的时候,ZooKeeper 服务端会将事件通知到感兴趣的客户端上去,该机制是 ZooKeeper 实现分布式协调服务的重要特性。\n同一时间段内,可能会有很多客户端同时获取锁,但只有一个可以获取成功。如果获取锁失败,则说明有其他的客户端已经成功获取锁。获取锁失败的客户端并不会不停地循环去尝试加锁,而是在前一个节点注册一个事件监听器。\n这个事件监听器的作用是:当前一个节点对应的客户端释放锁之后(也就是前一个节点被删除之后,监听的是删除事件),通知获取锁失败的客户端(唤醒等待的线程,Java 中的 wait/notifyAll ),让它尝试去获取锁,然后就成功获取锁了。\n如何实现可重入锁? # 这里以 Curator 的 InterProcessMutex 对可重入锁的实现来介绍(源码地址: InterProcessMutex.java)。\n当我们调用 InterProcessMutex#acquire方法获取锁的时候,会调用InterProcessMutex#internalLock方法。\n// 获取可重入互斥锁,直到获取成功为止 @Override public void acquire() throws Exception { if (!internalLock(-1, null)) { throw new IOException(\u0026#34;Lost connection while trying to acquire lock: \u0026#34; + basePath); } } internalLock 方法会先获取当前请求锁的线程,然后从 threadData( ConcurrentMap\u0026lt;Thread, LockData\u0026gt; 类型)中获取当前线程对应的 lockData 。 lockData 包含锁的信息和加锁的次数,是实现可重入锁的关键。\n第一次获取锁的时候,lockData为 null。获取锁成功之后,会将当前线程和对应的 lockData 放到 threadData 中\nprivate boolean internalLock(long time, TimeUnit unit) throws Exception { // 获取当前请求锁的线程 Thread currentThread = Thread.currentThread(); // 拿对应的 lockData LockData lockData = threadData.get(currentThread); // 第一次获取锁的话,lockData 为 null if (lockData != null) { // 当前线程获取过一次锁之后 // 因为当前线程的锁存在, lockCount 自增后返回,实现锁重入. lockData.lockCount.incrementAndGet(); return true; } // 尝试获取锁 String lockPath = internals.attemptLock(time, unit, getLockNodeBytes()); if (lockPath != null) { LockData newLockData = new LockData(currentThread, lockPath); // 获取锁成功之后,将当前线程和对应的 lockData 放到 threadData 中 threadData.put(currentThread, newLockData); return true; } return false; } LockData是 InterProcessMutex中的一个静态内部类。\nprivate final ConcurrentMap\u0026lt;Thread, LockData\u0026gt; threadData = Maps.newConcurrentMap(); private static class LockData { // 当前持有锁的线程 final Thread owningThread; // 锁对应的子节点 final String lockPath; // 加锁的次数 final AtomicInteger lockCount = new AtomicInteger(1); private LockData(Thread owningThread, String lockPath) { this.owningThread = owningThread; this.lockPath = lockPath; } } 如果已经获取过一次锁,后面再来获取锁的话,直接就会在 if (lockData != null) 这里被拦下了,然后就会执行lockData.lockCount.incrementAndGet(); 将加锁次数加 1。\n整个可重入锁的实现逻辑非常简单,直接在客户端判断当前线程有没有获取锁,有的话直接将加锁次数加 1 就可以了。\n总结 # 在这篇文章中,我介绍了实现分布式锁的两种常见方式:Redis 和 ZooKeeper。至于具体选择 Redis 还是 ZooKeeper 来实现分布式锁,还是要根据业务的具体需求来决定。\n如果对性能要求比较高的话,建议使用 Redis 实现分布式锁。推荐优先选择 Redisson 提供的现成分布式锁,而不是自己实现。实际项目中不建议使用 Redlock 算法,成本和收益不成正比,可以考虑基于 Redis 主从复制+哨兵模式实现分布式锁。 如果对可靠性要求比较高,建议使用 ZooKeeper 实现分布式锁,推荐基于 Curator 框架来实现。不过,现在很多项目都不会用到 ZooKeeper,如果单纯是因为分布式锁而引入 ZooKeeper 的话,那是不太可取的,不建议这样做,为了一个小小的功能增加了系统的复杂度。 需要注意的是,无论选择哪种方式实现分布式锁,包括 Redis、ZooKeeper 或 Etcd(本文没介绍,但也经常用来实现分布式锁),都无法保证 100% 的安全性,特别是在遇到进程垃圾回收(GC)、网络延迟等异常情况下。\n为了进一步提高系统的可靠性,建议引入一个兜底机制。例如,可以通过 版本号(Fencing Token)机制 来避免并发冲突。\n最后,再分享几篇我觉得写的还不错的文章:\n分布式锁实现原理与最佳实践 - 阿里云开发者 聊聊分布式锁 - 字节跳动技术团队 Redis、ZooKeeper、Etcd,谁有最好用的分布式锁? - 腾讯云开发者 "},{"id":621,"href":"/zh/docs/technology/Interview/distributed-system/distributed-lock/","title":"分布式锁介绍","section":"Distributed System","content":"网上有很多分布式锁相关的文章,写了一个相对简洁易懂的版本,针对面试和工作应该够用了。\n这篇文章我们先介绍一下分布式锁的基本概念。\n为什么需要分布式锁? # 在多线程环境中,如果多个线程同时访问共享资源(例如商品库存、外卖订单),会发生数据竞争,可能会导致出现脏数据或者系统问题,威胁到程序的正常运行。\n举个例子,假设现在有 100 个用户参与某个限时秒杀活动,每位用户限购 1 件商品,且商品的数量只有 3 个。如果不对共享资源进行互斥访问,就可能出现以下情况:\n线程 1、2、3 等多个线程同时进入抢购方法,每一个线程对应一个用户。 线程 1 查询用户已经抢购的数量,发现当前用户尚未抢购且商品库存还有 1 个,因此认为可以继续执行抢购流程。 线程 2 也执行查询用户已经抢购的数量,发现当前用户尚未抢购且商品库存还有 1 个,因此认为可以继续执行抢购流程。 线程 1 继续执行,将库存数量减少 1 个,然后返回成功。 线程 2 继续执行,将库存数量减少 1 个,然后返回成功。 此时就发生了超卖问题,导致商品被多卖了一份。 为了保证共享资源被安全地访问,我们需要使用互斥操作对共享资源进行保护,即同一时刻只允许一个线程访问共享资源,其他线程需要等待当前线程释放后才能访问。这样可以避免数据竞争和脏数据问题,保证程序的正确性和稳定性。\n如何才能实现共享资源的互斥访问呢? 锁是一个比较通用的解决方案,更准确点来说是悲观锁。\n悲观锁总是假设最坏的情况,认为共享资源每次被访问的时候就会出现问题(比如共享数据被修改),所以每次在获取资源操作的时候都会上锁,这样其他线程想拿到这个资源就会阻塞直到锁被上一个持有者释放。也就是说,共享资源每次只给一个线程使用,其它线程阻塞,用完后再把资源转让给其它线程。\n对于单机多线程来说,在 Java 中,我们通常使用 ReentrantLock 类、synchronized 关键字这类 JDK 自带的 本地锁 来控制一个 JVM 进程内的多个线程对本地共享资源的访问。\n下面是我对本地锁画的一张示意图。\n从图中可以看出,这些线程访问共享资源是互斥的,同一时刻只有一个线程可以获取到本地锁访问共享资源。\n分布式系统下,不同的服务/客户端通常运行在独立的 JVM 进程上。如果多个 JVM 进程共享同一份资源的话,使用本地锁就没办法实现资源的互斥访问了。于是,分布式锁 就诞生了。\n举个例子:系统的订单服务一共部署了 3 份,都对外提供服务。用户下订单之前需要检查库存,为了防止超卖,这里需要加锁以实现对检查库存操作的同步访问。由于订单服务位于不同的 JVM 进程中,本地锁在这种情况下就没办法正常工作了。我们需要用到分布式锁,这样的话,即使多个线程不在同一个 JVM 进程中也能获取到同一把锁,进而实现共享资源的互斥访问。\n下面是我对分布式锁画的一张示意图。\n从图中可以看出,这些独立的进程中的线程访问共享资源是互斥的,同一时刻只有一个线程可以获取到分布式锁访问共享资源。\n分布式锁应该具备哪些条件? # 一个最基本的分布式锁需要满足:\n互斥:任意一个时刻,锁只能被一个线程持有。 高可用:锁服务是高可用的,当一个锁服务出现问题,能够自动切换到另外一个锁服务。并且,即使客户端的释放锁的代码逻辑出现问题,锁最终一定还是会被释放,不会影响其他线程对共享资源的访问。这一般是通过超时机制实现的。 可重入:一个节点获取了锁之后,还可以再次获取锁。 除了上面这三个基本条件之外,一个好的分布式锁还需要满足下面这些条件:\n高性能:获取和释放锁的操作应该快速完成,并且不应该对整个系统的性能造成过大影响。 非阻塞:如果获取不到锁,不能无限期等待,避免对系统正常运行造成影响。 分布式锁的常见实现方式有哪些? # 常见分布式锁实现方案如下:\n基于关系型数据库比如 MySQL 实现分布式锁。 基于分布式协调服务 ZooKeeper 实现分布式锁。 基于分布式键值存储系统比如 Redis 、Etcd 实现分布式锁。 关系型数据库的方式一般是通过唯一索引或者排他锁实现。不过,一般不会使用这种方式,问题太多比如性能太差、不具备锁失效机制。\n基于 ZooKeeper 或者 Redis 实现分布式锁这两种实现方式要用的更多一些,我专门写了一篇文章来详细介绍这两种方案: 分布式锁常见实现方案总结。\n总结 # 这篇文章我们主要介绍了:\n分布式锁的用途:分布式系统下,不同的服务/客户端通常运行在独立的 JVM 进程上。如果多个 JVM 进程共享同一份资源的话,使用本地锁就没办法实现资源的互斥访问了。 分布式锁的应该具备的条件:互斥、高可用、可重入、高性能、非阻塞。 分布式锁的常见实现方式:关系型数据库比如 MySQL、分布式协调服务 ZooKeeper、分布式键值存储系统比如 Redis 、Etcd 。 "},{"id":622,"href":"/zh/docs/technology/Interview/high-availability/limit-request/","title":"服务限流详解","section":"High Availability","content":"针对软件系统来说,限流就是对请求的速率进行限制,避免瞬时的大量请求击垮软件系统。毕竟,软件系统的处理能力是有限的。如果说超过了其处理能力的范围,软件系统可能直接就挂掉了。\n限流可能会导致用户的请求无法被正确处理或者无法立即被处理,不过,这往往也是权衡了软件系统的稳定性之后得到的最优解。\n现实生活中,处处都有限流的实际应用,就比如排队买票是为了避免大量用户涌入购票而导致售票员无法处理。\n常见限流算法有哪些? # 简单介绍 4 种非常好理解并且容易实现的限流算法!\n图片来源于 InfoQ 的一篇文章 《分布式服务限流实战,已经为你排好坑了》。\n固定窗口计数器算法 # 固定窗口其实就是时间窗口,其原理是将时间划分为固定大小的窗口,在每个窗口内限制请求的数量或速率,即固定窗口计数器算法规定了系统单位时间处理的请求数量。\n假如我们规定系统中某个接口 1 分钟只能被访问 33 次的话,使用固定窗口计数器算法的实现思路如下:\n将时间划分固定大小窗口,这里是 1 分钟一个窗口。 给定一个变量 counter 来记录当前接口处理的请求数量,初始值为 0(代表接口当前 1 分钟内还未处理请求)。 1 分钟之内每处理一个请求之后就将 counter+1 ,当 counter=33 之后(也就是说在这 1 分钟内接口已经被访问 33 次的话),后续的请求就会被全部拒绝。 等到 1 分钟结束后,将 counter 重置 0,重新开始计数。 优点:实现简单,易于理解。\n缺点:\n限流不够平滑。例如,我们限制某个接口每分钟只能访问 30 次,假设前 30 秒就有 30 个请求到达的话,那后续 30 秒将无法处理请求,这是不可取的,用户体验极差! 无法保证限流速率,因而无法应对突然激增的流量。例如,我们限制某个接口 1 分钟只能访问 1000 次,该接口的 QPS 为 500,前 55s 这个接口 1 个请求没有接收,后 1s 突然接收了 1000 个请求。然后,在当前场景下,这 1000 个请求在 1s 内是没办法被处理的,系统直接就被瞬时的大量请求给击垮了。 滑动窗口计数器算法 # 滑动窗口计数器算法 算的上是固定窗口计数器算法的升级版,限流的颗粒度更小。\n滑动窗口计数器算法相比于固定窗口计数器算法的优化在于:它把时间以一定比例分片 。\n例如我们的接口限流每分钟处理 60 个请求,我们可以把 1 分钟分为 60 个窗口。每隔 1 秒移动一次,每个窗口一秒只能处理不大于 60(请求数)/60(窗口数) 的请求, 如果当前窗口的请求计数总和超过了限制的数量的话就不再处理其他请求。\n很显然, 当滑动窗口的格子划分的越多,滑动窗口的滚动就越平滑,限流的统计就会越精确。\n优点:\n相比于固定窗口算法,滑动窗口计数器算法可以应对突然激增的流量。 相比于固定窗口算法,滑动窗口计数器算法的颗粒度更小,可以提供更精确的限流控制。 缺点:\n与固定窗口计数器算法类似,滑动窗口计数器算法依然存在限流不够平滑的问题。 相比较于固定窗口计数器算法,滑动窗口计数器算法实现和理解起来更复杂一些。 漏桶算法 # 我们可以把发请求的动作比作成注水到桶中,我们处理请求的过程可以比喻为漏桶漏水。我们往桶中以任意速率流入水,以一定速率流出水。当水超过桶流量则丢弃,因为桶容量是不变的,保证了整体的速率。\n如果想要实现这个算法的话也很简单,准备一个队列用来保存请求,然后我们定期从队列中拿请求来执行就好了(和消息队列削峰/限流的思想是一样的)。\n优点:\n实现简单,易于理解。 可以控制限流速率,避免网络拥塞和系统过载。 缺点:\n无法应对突然激增的流量,因为只能以固定的速率处理请求,对系统资源利用不够友好。 桶流入水(发请求)的速率如果一直大于桶流出水(处理请求)的速率的话,那么桶会一直是满的,一部分新的请求会被丢弃,导致服务质量下降。 实际业务场景中,基本不会使用漏桶算法。\n令牌桶算法 # 令牌桶算法也比较简单。和漏桶算法算法一样,我们的主角还是桶(这限流算法和桶过不去啊)。不过现在桶里装的是令牌了,请求在被处理之前需要拿到一个令牌,请求处理完毕之后将这个令牌丢弃(删除)。我们根据限流大小,按照一定的速率往桶里添加令牌。如果桶装满了,就不能继续往里面继续添加令牌了。\n优点:\n可以限制平均速率和应对突然激增的流量。 可以动态调整生成令牌的速率。 缺点:\n如果令牌产生速率和桶的容量设置不合理,可能会出现问题比如大量的请求被丢弃、系统过载。 相比于其他限流算法,实现和理解起来更复杂一些。 针对什么来进行限流? # 实际项目中,还需要确定限流对象,也就是针对什么来进行限流。常见的限流对象如下:\nIP :针对 IP 进行限流,适用面较广,简单粗暴。 业务 ID:挑选唯一的业务 ID 以实现更针对性地限流。例如,基于用户 ID 进行限流。 个性化:根据用户的属性或行为,进行不同的限流策略。例如, VIP 用户不限流,而普通用户限流。根据系统的运行指标(如 QPS、并发调用数、系统负载等),动态调整限流策略。例如,当系统负载较高的时候,控制每秒通过的请求减少。 针对 IP 进行限流是目前比较常用的一个方案。不过,实际应用中需要注意用户真实 IP 地址的正确获取。常用的真实 IP 获取方法有 X-Forwarded-For 和 TCP Options 字段承载真实源 IP 信息。虽然 X-Forwarded-For 字段可能会被伪造,但因为其实现简单方便,很多项目还是直接用的这种方法。\n除了我上面介绍到的限流对象之外,还有一些其他较为复杂的限流对象策略,比如阿里的 Sentinel 还支持 基于调用关系的限流(包括基于调用方限流、基于调用链入口限流、关联流量限流等)以及更细维度的 热点参数限流(实时的统计热点参数并针对热点参数的资源调用进行流量控制)。\n另外,一个项目可以根据具体的业务需求选择多种不同的限流对象搭配使用。\n单机限流怎么做? # 单机限流针对的是单体架构应用。\n单机限流可以直接使用 Google Guava 自带的限流工具类 RateLimiter 。 RateLimiter 基于令牌桶算法,可以应对突发流量。\nGuava 地址: https://github.com/google/guava\n除了最基本的令牌桶算法(平滑突发限流)实现之外,Guava 的RateLimiter还提供了 平滑预热限流 的算法实现。\n平滑突发限流就是按照指定的速率放令牌到桶里,而平滑预热限流会有一段预热时间,预热时间之内,速率会逐渐提升到配置的速率。\n我们下面通过两个简单的小例子来详细了解吧!\n我们直接在项目中引入 Guava 相关的依赖即可使用。\n\u0026lt;dependency\u0026gt; \u0026lt;groupId\u0026gt;com.google.guava\u0026lt;/groupId\u0026gt; \u0026lt;artifactId\u0026gt;guava\u0026lt;/artifactId\u0026gt; \u0026lt;version\u0026gt;31.0.1-jre\u0026lt;/version\u0026gt; \u0026lt;/dependency\u0026gt; 下面是一个简单的 Guava 平滑突发限流的 Demo。\nimport com.google.common.util.concurrent.RateLimiter; /** * 微信搜 JavaGuide 回复\u0026#34;面试突击\u0026#34;即可免费领取个人原创的 Java 面试手册 * * @author Guide哥 * @date 2021/10/08 19:12 **/ public class RateLimiterDemo { public static void main(String[] args) { // 1s 放 5 个令牌到桶里也就是 0.2s 放 1个令牌到桶里 RateLimiter rateLimiter = RateLimiter.create(5); for (int i = 0; i \u0026lt; 10; i++) { double sleepingTime = rateLimiter.acquire(1); System.out.printf(\u0026#34;get 1 tokens: %ss%n\u0026#34;, sleepingTime); } } } 输出:\nget 1 tokens: 0.0s get 1 tokens: 0.188413s get 1 tokens: 0.197811s get 1 tokens: 0.198316s get 1 tokens: 0.19864s get 1 tokens: 0.199363s get 1 tokens: 0.193997s get 1 tokens: 0.199623s get 1 tokens: 0.199357s get 1 tokens: 0.195676s 下面是一个简单的 Guava 平滑预热限流的 Demo。\nimport com.google.common.util.concurrent.RateLimiter; import java.util.concurrent.TimeUnit; /** * 微信搜 JavaGuide 回复\u0026#34;面试突击\u0026#34;即可免费领取个人原创的 Java 面试手册 * * @author Guide哥 * @date 2021/10/08 19:12 **/ public class RateLimiterDemo { public static void main(String[] args) { // 1s 放 5 个令牌到桶里也就是 0.2s 放 1个令牌到桶里 // 预热时间为3s,也就说刚开始的 3s 内发牌速率会逐渐提升到 0.2s 放 1 个令牌到桶里 RateLimiter rateLimiter = RateLimiter.create(5, 3, TimeUnit.SECONDS); for (int i = 0; i \u0026lt; 20; i++) { double sleepingTime = rateLimiter.acquire(1); System.out.printf(\u0026#34;get 1 tokens: %sds%n\u0026#34;, sleepingTime); } } } 输出:\nget 1 tokens: 0.0s get 1 tokens: 0.561919s get 1 tokens: 0.516931s get 1 tokens: 0.463798s get 1 tokens: 0.41286s get 1 tokens: 0.356172s get 1 tokens: 0.300489s get 1 tokens: 0.252545s get 1 tokens: 0.203996s get 1 tokens: 0.198359s 另外,Bucket4j 是一个非常不错的基于令牌/漏桶算法的限流库。\nBucket4j 地址: https://github.com/vladimir-bukhtoyarov/bucket4j\n相对于,Guava 的限流工具类来说,Bucket4j 提供的限流功能更加全面。不仅支持单机限流和分布式限流,还可以集成监控,搭配 Prometheus 和 Grafana 使用。\n不过,毕竟 Guava 也只是一个功能全面的工具类库,其提供的开箱即用的限流功能在很多单机场景下还是比较实用的。\nSpring Cloud Gateway 中自带的单机限流的早期版本就是基于 Bucket4j 实现的。后来,替换成了 Resilience4j。\nResilience4j 是一个轻量级的容错组件,其灵感来自于 Hystrix。自 Netflix 宣布不再积极开发 Hystrix 之后,Spring 官方和 Netflix 都更推荐使用 Resilience4j 来做限流熔断。\nResilience4j 地址: https://github.com/resilience4j/resilience4j\n一般情况下,为了保证系统的高可用,项目的限流和熔断都是要一起做的。\nResilience4j 不仅提供限流,还提供了熔断、负载保护、自动重试等保障系统高可用开箱即用的功能。并且,Resilience4j 的生态也更好,很多网关都使用 Resilience4j 来做限流熔断的。\n因此,在绝大部分场景下 Resilience4j 或许会是更好的选择。如果是一些比较简单的限流场景的话,Guava 或者 Bucket4j 也是不错的选择。\n分布式限流怎么做? # 分布式限流针对的分布式/微服务应用架构应用,在这种架构下,单机限流就不适用了,因为会存在多种服务,并且一种服务也可能会被部署多份。\n分布式限流常见的方案:\n借助中间件限流:可以借助 Sentinel 或者使用 Redis 来自己实现对应的限流逻辑。 网关层限流:比较常用的一种方案,直接在网关层把限流给安排上了。不过,通常网关层限流通常也需要借助到中间件/框架。就比如 Spring Cloud Gateway 的分布式限流实现RedisRateLimiter就是基于 Redis+Lua 来实现的,再比如 Spring Cloud Gateway 还可以整合 Sentinel 来做限流。 如果你要基于 Redis 来手动实现限流逻辑的话,建议配合 Lua 脚本来做。\n为什么建议 Redis+Lua 的方式? 主要有两点原因:\n减少了网络开销:我们可以利用 Lua 脚本来批量执行多条 Redis 命令,这些 Redis 命令会被提交到 Redis 服务器一次性执行完成,大幅减小了网络开销。 原子性:一段 Lua 脚本可以视作一条命令执行,一段 Lua 脚本执行过程中不会有其他脚本或 Redis 命令同时执行,保证了操作不会被其他指令插入或打扰。 我这里就不放具体的限流脚本代码了,网上也有很多现成的优秀的限流脚本供你参考,就比如 Apache 网关项目 ShenYu 的 RateLimiter 限流插件就基于 Redis + Lua 实现了令牌桶算法/并发令牌桶算法、漏桶算法、滑动窗口算法。\nShenYu 地址: https://github.com/apache/incubator-shenyu\n另外,如果不想自己写 Lua 脚本的话,也可以直接利用 Redisson 中的 RRateLimiter 来实现分布式限流,其底层实现就是基于 Lua 代码+令牌桶算法。\nRedisson 是一个开源的 Java 语言 Redis 客户端,提供了很多开箱即用的功能,比如 Java 中常用的数据结构实现、分布式锁、延迟队列等等。并且,Redisson 还支持 Redis 单机、Redis Sentinel、Redis Cluster 等多种部署架构。\nRRateLimiter 的使用方式非常简单。我们首先需要获取一个RRateLimiter对象,直接通过 Redisson 客户端获取即可。然后,设置限流规则就好。\n// 创建一个 Redisson 客户端实例 RedissonClient redissonClient = Redisson.create(); // 获取一个名为 \u0026#34;javaguide.limiter\u0026#34; 的限流器对象 RRateLimiter rateLimiter = redissonClient.getRateLimiter(\u0026#34;javaguide.limiter\u0026#34;); // 尝试设置限流器的速率为每小时 100 次 // RateType 有两种,OVERALL是全局限流,ER_CLIENT是单Client限流(可以认为就是单机限流) rateLimiter.trySetRate(RateType.OVERALL, 100, 1, RateIntervalUnit.HOURS); 接下来我们调用acquire()方法或tryAcquire()方法即可获取许可。\n// 获取一个许可,如果超过限流器的速率则会等待 // acquire()是同步方法,对应的异步方法:acquireAsync() rateLimiter.acquire(1); // 尝试在 5 秒内获取一个许可,如果成功则返回 true,否则返回 false // tryAcquire()是同步方法,对应的异步方法:tryAcquireAsync() boolean res = rateLimiter.tryAcquire(1, 5, TimeUnit.SECONDS); 总结 # 这篇文章主要介绍了常见的限流算法、限流对象的选择以及单机限流和分布式限流分别应该怎么做。\n参考 # 服务治理之轻量级熔断框架 Resilience4j: https://xie.infoq.cn/article/14786e571c1a4143ad1ef8f19 超详细的 Guava RateLimiter 限流原理解析: https://cloud.tencent.com/developer/article/1408819 实战 Spring Cloud Gateway 之限流篇 👍: https://www.aneasystone.com/archives/2020/08/spring-cloud-gateway-current-limiting.html 详解 Redisson 分布式限流的实现原理: https://juejin.cn/post/7199882882138898489 一文详解 Java 限流接口实现 - 阿里云开发者: https://mp.weixin.qq.com/s/A5VYjstIDeVvizNK2HkrTQ 分布式限流方案的探索与实践 - 腾讯云开发者: https://mp.weixin.qq.com/s/MJbEQROGlThrHSwCjYB_4Q "},{"id":623,"href":"/zh/docs/technology/Interview/high-performance/load-balancing/","title":"负载均衡原理及算法详解","section":"High Performance","content":" 什么是负载均衡? # 负载均衡 指的是将用户请求分摊到不同的服务器上处理,以提高系统整体的并发处理能力以及可靠性。负载均衡服务可以有由专门的软件或者硬件来完成,一般情况下,硬件的性能更好,软件的价格更便宜(后文会详细介绍到)。\n下图是 《Java 面试指北》 「高并发篇」中的一篇文章的配图,从图中可以看出,系统的商品服务部署了多份在不同的服务器上,为了实现访问商品服务请求的分流,我们用到了负载均衡。\n负载均衡是一种比较常用且实施起来较为简单的提高系统并发能力和可靠性的手段,不论是单体架构的系统还是微服务架构的系统几乎都会用到。\n负载均衡分为哪几种? # 负载均衡可以简单分为 服务端负载均衡 和 客户端负载均衡 这两种。\n服务端负载均衡涉及到的知识点更多,工作中遇到的也比较多,因此,我会花更多时间来介绍。\n服务端负载均衡 # 服务端负载均衡 主要应用在 系统外部请求 和 网关层 之间,可以使用 软件 或者 硬件 实现。\n下图是我画的一个简单的基于 Nginx 的服务端负载均衡示意图:\n硬件负载均衡 通过专门的硬件设备(比如 F5、A10、Array )实现负载均衡功能。\n硬件负载均衡的优势是性能很强且稳定,缺点就是实在是太贵了。像基础款的 F5 最低也要 20 多万,绝大部分公司是根本负担不起的,业务量不大的话,真没必要非要去弄个硬件来做负载均衡,用软件负载均衡就足够了!\n在我们日常开发中,一般很难接触到硬件负载均衡,接触的比较多的还是 软件负载均衡 。软件负载均衡通过软件(比如 LVS、Nginx、HAproxy )实现负载均衡功能,性能虽然差一些,但价格便宜啊!像基础款的 Linux 服务器也就几千,性能好一点的 2~3 万的就很不错了。\n根据 OSI 模型,服务端负载均衡还可以分为:\n二层负载均衡 三层负载均衡 四层负载均衡 七层负载均衡 最常见的是四层和七层负载均衡,因此,本文也是重点介绍这两种负载均衡。\nNginx 官网对四层负载和七层负载均衡均衡做了详细介绍,感兴趣的可以看看。\nWhat Is Layer 4 Load Balancing? What Is Layer 7 Load Balancing? 四层负载均衡 工作在 OSI 模型第四层,也就是传输层,这一层的主要协议是 TCP/UDP,负载均衡器在这一层能够看到数据包里的源端口地址以及目的端口地址,会基于这些信息通过一定的负载均衡算法将数据包转发到后端真实服务器。也就是说,四层负载均衡的核心就是 IP+端口层面的负载均衡,不涉及具体的报文内容。 七层负载均衡 工作在 OSI 模型第七层,也就是应用层,这一层的主要协议是 HTTP 。这一层的负载均衡比四层负载均衡路由网络请求的方式更加复杂,它会读取报文的数据部分(比如说我们的 HTTP 部分的报文),然后根据读取到的数据内容(如 URL、Cookie)做出负载均衡决策。也就是说,七层负载均衡器的核心是报文内容(如 URL、Cookie)层面的负载均衡,执行第七层负载均衡的设备通常被称为 反向代理服务器 。 七层负载均衡比四层负载均衡会消耗更多的性能,不过,也相对更加灵活,能够更加智能地路由网络请求,比如说你可以根据请求的内容进行优化如缓存、压缩、加密。\n简单来说,四层负载均衡性能很强,七层负载均衡功能更强! 不过,对于绝大部分业务场景来说,四层负载均衡和七层负载均衡的性能差异基本可以忽略不计的。\n下面这段话摘自 Nginx 官网的 What Is Layer 4 Load Balancing? 这篇文章。\nLayer 4 load balancing was a popular architectural approach to traffic handling when commodity hardware was not as powerful as it is now, and the interaction between clients and application servers was much less complex. It requires less computation than more sophisticated load balancing methods (such as Layer 7), but CPU and memory are now sufficiently fast and cheap that the performance advantage for Layer 4 load balancing has become negligible or irrelevant in most situations.\n第 4 层负载平衡是一种流行的流量处理体系结构方法,当时商用硬件没有现在这么强大,客户端和应用程序服务器之间的交互也不那么复杂。它比更复杂的负载平衡方法(如第 7 层)需要更少的计算量,但是 CPU 和内存现在足够快和便宜,在大多数情况下,第 4 层负载平衡的性能优势已经变得微不足道或无关紧要。\n在工作中,我们通常会使用 Nginx 来做七层负载均衡,LVS(Linux Virtual Server 虚拟服务器, Linux 内核的 4 层负载均衡)来做四层负载均衡。\n关于 Nginx 的常见知识点总结, 《Java 面试指北》 中「技术面试题篇」中已经有对应的内容了,感兴趣的小伙伴可以去看看。\n不过,LVS 这个绝大部分公司真用不上,像阿里、百度、腾讯、eBay 等大厂才会使用到,用的最多的还是 Nginx。\n客户端负载均衡 # 客户端负载均衡 主要应用于系统内部的不同的服务之间,可以使用现成的负载均衡组件来实现。\n在客户端负载均衡中,客户端会自己维护一份服务器的地址列表,发送请求之前,客户端会根据对应的负载均衡算法来选择具体某一台服务器处理请求。\n客户端负载均衡器和服务运行在同一个进程或者说 Java 程序里,不存在额外的网络开销。不过,客户端负载均衡的实现会受到编程语言的限制,比如说 Spring Cloud Load Balancer 就只能用于 Java 语言。\nJava 领域主流的微服务框架 Dubbo、Spring Cloud 等都内置了开箱即用的客户端负载均衡实现。Dubbo 属于是默认自带了负载均衡功能,Spring Cloud 是通过组件的形式实现的负载均衡,属于可选项,比较常用的是 Spring Cloud Load Balancer(官方,推荐) 和 Ribbon(Netflix,已被弃用)。\n下图是我画的一个简单的基于 Spring Cloud Load Balancer(Ribbon 也类似) 的客户端负载均衡示意图:\n负载均衡常见的算法有哪些? # 随机法 # 随机法 是最简单粗暴的负载均衡算法。\n如果没有配置权重的话,所有的服务器被访问到的概率都是相同的。如果配置权重的话,权重越高的服务器被访问的概率就越大。\n未加权重的随机算法适合于服务器性能相近的集群,其中每个服务器承载相同的负载。加权随机算法适合于服务器性能不等的集群,权重的存在可以使请求分配更加合理化。\n不过,随机算法有一个比较明显的缺陷:部分机器在一段时间之内无法被随机到,毕竟是概率算法,就算是大家权重一样, 也可能会出现这种情况。\n于是,轮询法 来了!\n轮询法 # 轮询法是挨个轮询服务器处理,也可以设置权重。\n如果没有配置权重的话,每个请求按时间顺序逐一分配到不同的服务器处理。如果配置权重的话,权重越高的服务器被访问的次数就越多。\n未加权重的轮询算法适合于服务器性能相近的集群,其中每个服务器承载相同的负载。加权轮询算法适合于服务器性能不等的集群,权重的存在可以使请求分配更加合理化。\n在加权轮询的基础上,还有进一步改进得到的负载均衡算法,比如平滑的加权轮训算法。\n平滑的加权轮训算法最早是在 Nginx 中被实现,可以参考这个 commit: https://github.com/phusion/nginx/commit/27e94984486058d73157038f7950a0a36ecc6e35。如果你认真学习过 Dubbo 负载均衡策略的话,就会发现 Dubbo 的加权轮询就借鉴了该算法实现并进一步做了优化。\n两次随机法 # 两次随机法在随机法的基础上多增加了一次随机,多选出一个服务器。随后再根据两台服务器的负载等情况,从其中选择出一个最合适的服务器。\n两次随机法的好处是可以动态地调节后端节点的负载,使其更加均衡。如果只使用一次随机法,可能会导致某些服务器过载,而某些服务器空闲。\n哈希法 # 将请求的参数信息通过哈希函数转换成一个哈希值,然后根据哈希值来决定请求被哪一台服务器处理。\n在服务器数量不变的情况下,相同参数的请求总是发到同一台服务器处理,比如同个 IP 的请求、同一个用户的请求。\n一致性 Hash 法 # 和哈希法类似,一致性 Hash 法也可以让相同参数的请求总是发到同一台服务器处理。不过,它解决了哈希法存在的一些问题。\n常规哈希法在服务器数量变化时,哈希值会重新落在不同的服务器上,这明显违背了使用哈希法的本意。而一致性哈希法的核心思想是将数据和节点都映射到一个哈希环上,然后根据哈希值的顺序来确定数据属于哪个节点。当服务器增加或删除时,只影响该服务器的哈希,而不会导致整个服务集群的哈希键值重新分布。\n最小连接法 # 当有新的请求出现时,遍历服务器节点列表并选取其中连接数最小的一台服务器来响应当前请求。相同连接的情况下,可以进行加权随机。\n最少连接数基于一个服务器连接数越多,负载就越高这一理想假设。然而, 实际情况是连接数并不能代表服务器的实际负载,有些连接耗费系统资源更多,有些连接不怎么耗费系统资源。\n最少活跃法 # 最少活跃法和最小连接法类似,但要更科学一些。最少活跃法以活动连接数为标准,活动连接数可以理解为当前正在处理的请求数。活跃数越低,说明处理能力越强,这样就可以使处理能力强的服务器处理更多请求。相同活跃数的情况下,可以进行加权随机。\n最快响应时间法 # 不同于最小连接法和最少活跃法,最快响应时间法以响应时间为标准来选择具体是哪一台服务器处理。客户端会维持每个服务器的响应时间,每次请求挑选响应时间最短的。相同响应时间的情况下,可以进行加权随机。\n这种算法可以使得请求被更快处理,但可能会造成流量过于集中于高性能服务器的问题。\n七层负载均衡可以怎么做? # 简单介绍两种项目中常用的七层负载均衡解决方案:DNS 解析和反向代理。\n除了我介绍的这两种解决方案之外,HTTP 重定向等手段也可以用来实现负载均衡,不过,相对来说,还是 DNS 解析和反向代理用的更多一些,也更推荐一些。\nDNS 解析 # DNS 解析是比较早期的七层负载均衡实现方式,非常简单。\nDNS 解析实现负载均衡的原理是这样的:在 DNS 服务器中为同一个主机记录配置多个 IP 地址,这些 IP 地址对应不同的服务器。当用户请求域名的时候,DNS 服务器采用轮询算法返回 IP 地址,这样就实现了轮询版负载均衡。\n现在的 DNS 解析几乎都支持 IP 地址的权重配置,这样的话,在服务器性能不等的集群中请求分配会更加合理化。像我自己目前正在用的阿里云 DNS 就支持权重配置。\n反向代理 # 客户端将请求发送到反向代理服务器,由反向代理服务器去选择目标服务器,获取数据后再返回给客户端。对外暴露的是反向代理服务器地址,隐藏了真实服务器 IP 地址。反向代理“代理”的是目标服务器,这一个过程对于客户端而言是透明的。\nNginx 就是最常用的反向代理服务器,它可以将接收到的客户端请求以一定的规则(负载均衡策略)均匀地分配到这个服务器集群中所有的服务器上。\n反向代理负载均衡同样属于七层负载均衡。\n客户端负载均衡通常是怎么做的? # 我们上面也说了,客户端负载均衡可以使用现成的负载均衡组件来实现。\nNetflix Ribbon 和 Spring Cloud Load Balancer 就是目前 Java 生态最流行的两个负载均衡组件。\nRibbon 是老牌负载均衡组件,由 Netflix 开发,功能比较全面,支持的负载均衡策略也比较多。 Spring Cloud Load Balancer 是 Spring 官方为了取代 Ribbon 而推出的,功能相对更简单一些,支持的负载均衡也少一些。\nRibbon 支持的 7 种负载均衡策略:\nRandomRule:随机策略。 RoundRobinRule(默认):轮询策略 WeightedResponseTimeRule:权重(根据响应时间决定权重)策略 BestAvailableRule:最小连接数策略 RetryRule:重试策略(按照轮询策略来获取服务,如果获取的服务实例为 null 或已经失效,则在指定的时间之内不断地进行重试来获取服务,如果超过指定时间依然没获取到服务实例则返回 null) AvailabilityFilteringRule:可用敏感性策略(先过滤掉非健康的服务实例,然后再选择连接数较小的服务实例) ZoneAvoidanceRule:区域敏感性策略(根据服务所在区域的性能和服务的可用性来选择服务实例) Spring Cloud Load Balancer 支持的 2 种负载均衡策略:\nRandomLoadBalancer:随机策略 RoundRobinLoadBalancer(默认):轮询策略 public class CustomLoadBalancerConfiguration { @Bean ReactorLoadBalancer\u0026lt;ServiceInstance\u0026gt; randomLoadBalancer(Environment environment, LoadBalancerClientFactory loadBalancerClientFactory) { String name = environment.getProperty(LoadBalancerClientFactory.PROPERTY_NAME); return new RandomLoadBalancer(loadBalancerClientFactory .getLazyProvider(name, ServiceInstanceListSupplier.class), name); } } 不过,Spring Cloud Load Balancer 支持的负载均衡策略其实不止这两种,ServiceInstanceListSupplier 的实现类同样可以让其支持类似于 Ribbon 的负载均衡策略。这个应该是后续慢慢完善引入的,不看官方文档还真发现不了,所以说阅读官方文档真的很重要!\n这里举两个官方的例子:\nZonePreferenceServiceInstanceListSupplier:实现基于区域的负载平衡 HintBasedServiceInstanceListSupplier:实现基于 hint 提示的负载均衡 public class CustomLoadBalancerConfiguration { // 使用基于区域的负载平衡方法 @Bean public ServiceInstanceListSupplier discoveryClientServiceInstanceListSupplier( ConfigurableApplicationContext context) { return ServiceInstanceListSupplier.builder() .withDiscoveryClient() .withZonePreference() .withCaching() .build(context); } } 关于 Spring Cloud Load Balancer 更详细更新的介绍,推荐大家看看官方文档: https://docs.spring.io/spring-cloud-commons/docs/current/reference/html/#spring-cloud-loadbalancer ,一切以官方文档为主。\n轮询策略基本可以满足绝大部分项目的需求,我们的实际项目中如果没有特殊需求的话,通常使用的就是默认的轮询策略。并且,Ribbon 和 Spring Cloud Load Balancer 都支持自定义负载均衡策略。\n个人建议如非必需 Ribbon 某个特有的功能或者负载均衡策略的话,就优先选择 Spring 官方提供的 Spring Cloud Load Balancer。\n最后再说说为什么我不太推荐使用 Ribbon 。\nSpring Cloud 2020.0.0 版本移除了 Netflix 除 Eureka 外的所有组件。Spring Cloud Hoxton.M2 是第一个支持 Spring Cloud Load Balancer 来替代 Netfix Ribbon 的版本。\n我们早期学习微服务,肯定接触过 Netflix 公司开源的 Feign、Ribbon、Zuul、Hystrix、Eureka 等知名的微服务系统构建所必须的组件,直到现在依然有非常非常多的公司在使用这些组件。不夸张地说,Netflix 公司引领了 Java 技术栈下的微服务发展。\n那为什么 Spring Cloud 这么急着移除 Netflix 的组件呢? 主要是因为在 2018 年的时候,Netflix 宣布其开源的核心组件 Hystrix、Ribbon、Zuul、Eureka 等进入维护状态,不再进行新特性开发,只修 BUG。于是,Spring 官方不得不考虑移除 Netflix 的组件。\nSpring Cloud Alibaba 是一个不错的选择,尤其是对于国内的公司和个人开发者来说。\n参考 # 干货 | eBay 的 4 层软件负载均衡实现: https://mp.weixin.qq.com/s/bZMxLTECOK3mjdgiLbHj-g HTTP Load Balancing(Nginx 官方文档): https://docs.nginx.com/nginx/admin-guide/load-balancer/http-load-balancer/ 深入浅出负载均衡 - vivo 互联网技术: https://www.cnblogs.com/vivotech/p/14859041.html "},{"id":624,"href":"/zh/docs/technology/Interview/high-availability/high-availability-system-design/","title":"高可用系统设计指南","section":"High Availability","content":" 什么是高可用?可用性的判断标准是啥? # 高可用描述的是一个系统在大部分时间都是可用的,可以为我们提供服务的。高可用代表系统即使在发生硬件故障或者系统升级的时候,服务仍然是可用的。\n一般情况下,我们使用多少个 9 来评判一个系统的可用性,比如 99.9999% 就是代表该系统在所有的运行时间中只有 0.0001% 的时间是不可用的,这样的系统就是非常非常高可用的了!当然,也会有系统如果可用性不太好的话,可能连 9 都上不了。\n除此之外,系统的可用性还可以用某功能的失败次数与总的请求次数之比来衡量,比如对网站请求 1000 次,其中有 10 次请求失败,那么可用性就是 99%。\n哪些情况会导致系统不可用? # 黑客攻击; 硬件故障,比如服务器坏掉。 并发量/用户请求量激增导致整个服务宕掉或者部分服务不可用。 代码中的坏味道导致内存泄漏或者其他问题导致程序挂掉。 网站架构某个重要的角色比如 Nginx 或者数据库突然不可用。 自然灾害或者人为破坏。 …… 有哪些提高系统可用性的方法? # 注重代码质量,测试严格把关 # 我觉得这个是最最最重要的,代码质量有问题比如比较常见的内存泄漏、循环依赖都是对系统可用性极大的损害。大家都喜欢谈限流、降级、熔断,但是我觉得从代码质量这个源头把关是首先要做好的一件很重要的事情。如何提高代码质量?比较实际可用的就是 CodeReview,不要在乎每天多花的那 1 个小时左右的时间,作用可大着呢!\n另外,安利几个对提高代码质量有实际效果的神器:\nSonarqube; Alibaba 开源的 Java 诊断工具 Arthas; 阿里巴巴 Java 代码规范(Alibaba Java Code Guidelines); IDEA 自带的代码分析等工具。 使用集群,减少单点故障 # 先拿常用的 Redis 举个例子!我们如何保证我们的 Redis 缓存高可用呢?答案就是使用集群,避免单点故障。当我们使用一个 Redis 实例作为缓存的时候,这个 Redis 实例挂了之后,整个缓存服务可能就挂了。使用了集群之后,即使一台 Redis 实例挂了,不到一秒就会有另外一台 Redis 实例顶上。\n限流 # 流量控制(flow control),其原理是监控应用流量的 QPS 或并发线程数等指标,当达到指定的阈值时对流量进行控制,以避免被瞬时的流量高峰冲垮,从而保障应用的高可用性。——来自 alibaba-Sentinel 的 wiki。\n超时和重试机制设置 # 一旦用户请求超过某个时间的得不到响应,就抛出异常。这个是非常重要的,很多线上系统故障都是因为没有进行超时设置或者超时设置的方式不对导致的。我们在读取第三方服务的时候,尤其适合设置超时和重试机制。一般我们使用一些 RPC 框架的时候,这些框架都自带的超时重试的配置。如果不进行超时设置可能会导致请求响应速度慢,甚至导致请求堆积进而让系统无法再处理请求。重试的次数一般设为 3 次,再多次的重试没有好处,反而会加重服务器压力(部分场景使用失败重试机制会不太适合)。\n熔断机制 # 超时和重试机制设置之外,熔断机制也是很重要的。 熔断机制说的是系统自动收集所依赖服务的资源使用情况和性能指标,当所依赖的服务恶化或者调用失败次数达到某个阈值的时候就迅速失败,让当前系统立即切换依赖其他备用服务。 比较常用的流量控制和熔断降级框架是 Netflix 的 Hystrix 和 alibaba 的 Sentinel。\n异步调用 # 异步调用的话我们不需要关心最后的结果,这样我们就可以用户请求完成之后就立即返回结果,具体处理我们可以后续再做,秒杀场景用这个还是蛮多的。但是,使用异步之后我们可能需要 适当修改业务流程进行配合,比如用户在提交订单之后,不能立即返回用户订单提交成功,需要在消息队列的订单消费者进程真正处理完该订单之后,甚至出库后,再通过电子邮件或短信通知用户订单成功。除了可以在程序中实现异步之外,我们常常还使用消息队列,消息队列可以通过异步处理提高系统性能(削峰、减少响应所需时间)并且可以降低系统耦合性。\n使用缓存 # 如果我们的系统属于并发量比较高的话,如果我们单纯使用数据库的话,当大量请求直接落到数据库可能数据库就会直接挂掉。使用缓存缓存热点数据,因为缓存存储在内存中,所以速度相当地快!\n其他 # 核心应用和服务优先使用更好的硬件 监控系统资源使用情况增加报警设置。 注意备份,必要时候回滚。 灰度发布: 将服务器集群分成若干部分,每天只发布一部分机器,观察运行稳定没有故障,第二天继续发布一部分机器,持续几天才把整个集群全部发布完毕,期间如果发现问题,只需要回滚已发布的一部分服务器即可 定期检查/更换硬件: 如果不是购买的云服务的话,定期还是需要对硬件进行一波检查的,对于一些需要更换或者升级的硬件,要及时更换或者升级。 …… "},{"id":625,"href":"/zh/docs/technology/Interview/high-quality-technical-articles/advanced-programmer/seven-tips-for-becoming-an-advanced-programmer/","title":"给想成长为高级别开发同学的七条建议","section":"Advanced Programmer","content":" 推荐语:普通程序员要想成长为高级程序员甚至是专家等更高级别,应该注意在哪些方面注意加强?开发内功修炼号主飞哥在这篇文章中就给出了七条实用的建议。\n内容概览:\n刻意加强需求评审能力 主动思考效率 加强内功能力 思考性能 重视线上 关注全局 归纳总结能力 原文地址: https://mp.weixin.qq.com/s/8lMGzBzXine-NAsqEaIE4g\n建议 1:刻意加强需求评审能力 # 先从需求评审开始说。在互联网公司,需求评审是开发工作的主要入口。\n对于普通程序员来说,一般就是根据产品经理提的需求细节,开始设想这个功能要怎么实现,开发成本大概需要多长时间。把自己当成了需求到代码之间的翻译官。很少去思考需求的合理性,对于自己做的事情有多大价值,不管也不问。\n而对于高级别的程序员来说,并不会一开始就陷入细节,而是会更多地会从产品本身出发,询问产品经理为啥要做这个细节,目的是啥。换个说法,就是会先考虑这个需求是不是合理。\n如果需求高级不合理就进行 PK ,要么对需求进行调整,要么就砍掉。不过要注意的是 PK 和调整需求不仅仅砍需求,还有另外一个方向,那就是对需求进行加强。\n产品同学由于缺乏技术背景,很可能想的并不够充分,这个时候如果你有更好的想法,也完全可以提出来,加到需求里,让这个需求变得更有价值。\n总之,高级程序员并不会一五一十地按产品经理的需求文档来进行后面的开发,而是一切从有利于业务的角度出发思考,对产品经理的需求进行删、改、增。\n这样的工作表面看似和开发无关,但是只有这样才能保证后续所有开发同学都是有价值的,而不是做一堆无用功。无用功做的多了会极大的挫伤开发的成就感。\n所以,普通程序员要想成长为更高级别的开发,一定要加强需求评审能力的培养。\n建议 2:主动思考效率 # 普通的程序员,按部就班的去写代码,有活儿来我就干,没活儿的时候我就呆着。很少去深度思考现有的这些代码为什么要这么写,这么写的好处是啥,有哪些地方存在瓶颈,我是否可以把它优化一些。\n而高级一点程序员,并不会局限于把手头的活儿开发就算完事。他们会主动去琢磨,现在这种开发模式是不是不够的好。那么我是否能做一个什么东西能把这个效率给提升起来。\n举一个小例子,我 6 年前接手一个项目的时候,我发现运营一个月会找我四次,就是找我给她发送一个推送。她说以前的开发都是这么帮他弄的。虽然这个需求处理起来很简单,改两行发布一下就完事。但是烦啊,你想象一下你正专心写代码呢,她又双叒来找你了,思路全被她中断了。而且频繁地操作线上本来就会引入不确定的风险,万一那天手一抽抽搞错了,线上就完蛋了。\n我的做法就是,我专门抽了一周的时间,给她做了一套运营后台。这样以后所有的运营推送她就直接在后台上操作就完事了。我倒出精力去做其它更有价值的事情去了。\n所以,第二个建议就是要主动思考一下现有工作中哪些地方效率有改进的空间,想到了就主动去改进它!\n建议 3:加强内功能力 # 哪些算是内功呢,我想内功修炼的读者们肯定也都很熟悉的了,指的就是大家学校里都学过的操作系统、网络等这些基础。\n普通的程序员会觉得,这些基础知识我都会好么,我大学可是足足学了四年的。工作了以后并不会刻意来回头再来加强自己在这些基础上的深层次的提升。\n高级的程序员,非常清楚自己当年学的那点知识太皮毛了。工作之余也会深入地去研究 Linux、研究网络等方向的底层实现。\n事实上,互联网业界的技术大牛们很大程度是因为对这些基础的理解相当是深厚,具备了深厚的内功以后才促使他们成长为了技术大牛。\n我很难相信一个不理解底层,只会 CURD,只会用别人框架的开发将来能在技术方向成长为大牛。\n所以,还建议多多锻炼底层技术内功能力。如果你不知道怎么练,那就坚持看「开发内功修炼」公众号。\n建议 4:思考性能 # 普通程序员往往就是把需求开发完了就不管了,只要需求实现了,测试通过了就可以交付了。将来流量会有多大,没想过。自己的服务 QPS 能支撑多少,不清楚。\n而高级的程序员往往会关注自己写出来的代码的性能。\n在需求评审的时候,他们一般就会估算大概的请求流量有多大。进而设计阶段就会根据这个量设计符合性能要求的方案。\n在上线之前也会进行性能压测,检验一下在性能上是否符合预期。如果性能存在问题,瓶颈在哪儿,怎么样能进行优化一下。\n所以,第四个建议就是一定要多多主动你所负责业务的性能,并多多进行优化和改进。我想这个建议的重要程度非常之高。但这是需要你具备深厚的内功才可以办的到的,否则如果你连网络是怎么工作的都不清楚,谈何优化!\n建议 5:重视线上 # 普通程序员往往对线上的事情很少去关注,手里记录的服务器就是自己的开发机和发布机,线上机器有几台,流量多大,最近有没有波动这些可能都不清楚。\n而高级的程序员深深的明白,有条件的话,会尽量多多观察自己的线上服务,观察一下代码跑的咋样,有没有啥 error log。请求峰值的时候 CPU、内存的消耗咋样。网络端口消耗的情况咋样,是否需要调节一些参数配置。\n当性能不尽如人意的时候,可能会回头再来思考出性能的改进方案,重新开发和上线。\n你会发现在线上出问题的时候,能紧急扑上前线救火的都是高级一点的程序员。\n所以,飞哥给的第五个建议就是要多多观察线上运行情况。只有多多关注线上,当线上出故障的时候,你才能承担的起快速排出线上问题的重任。\n建议 6:关注全局 # 普通程序员是你分配给我哪个模块,我就干哪个模块,给自己的工作设定了非常小的一个边界,自己所有的眼光都聚集在这个小框框内。\n高级程序员是团队内所有项目模块,哪怕不是他负责的,他也会去熟悉,去了解。具备这种思维的同学无论在技术上,无论是在业务上,成长的也都是最快的。在职级上得到晋升,或者是职位上得到提拔的往往都是这类同学。\n甚至有更高级别的同学,还不止于把目光放在团队内,甚至还会关注公司内其它团队,甚至是业界的业务和技术栈。写到这里我想起了张一鸣说过的,不给自己的工作设边界。\n所以,建议要有大局观,不仅仅是你负责的模块,整个项目其实你都应该去关注。而不是连自己组内同学做的是啥都不知道。\n建议 7:归纳总结能力 # 普通程序员往往是工作的事情做完就拉到,很少回头去对自己的技术,对业务进行归纳和总结。\n而高级的程序员往往都会在一件比较大的事情做完之后总结一下,做个 ppt,写个博客啥的记录下来。这样既对自己的工作是一个归纳,也可以分享给其它同学,促进团队的共同成长。\n"},{"id":626,"href":"/zh/docs/technology/Interview/high-quality-technical-articles/advanced-programmer/thinking-about-technology-and-business-after-five-years-of-work/","title":"工作五年之后,对技术和业务的思考","section":"Advanced Programmer","content":" 推荐语:这是我在两年前看到的一篇对我触动比较深的文章。确实要学会适应变化,并积累能力。积累解决问题的能力,优化思考方式,拓宽自己的认知。\n原文地址: https://mp.weixin.qq.com/s/CTbEdi0F4-qFoJT05kNlXA\n苦海无边,回头无岸。\n01 前言 # 晃晃悠悠的,在互联网行业工作了五年,默然回首,你看哪里像灯火阑珊处?\n初入职场,大部分程序员会觉得苦学技术,以后会顺风顺水升职加薪,这样的想法没有错,但是不算全面,五年后你会不会继续做技术写代码这是核心问题。\n初入职场,会觉得努力加班可以不断提升能力,可以学到技术的公司就算薪水低点也可以接受,但是五年之后会认为加班都是在不断挤压自己的上升空间,薪水低是人生的天花板。\n这里想说的关键问题就是:初入职场的认知和想法大部分不会再适用于五年后的认知。\n工作五年之后面临的最大压力就是选择:职场天花板,技术能力天花板,薪水天花板,三十岁天花板。\n如何面对这些问题,是大部分程序员都在思考和纠结的。做选择的唯一参考点就是:利益最大化,这里可以理解为职场更好的升职加薪,顺风顺水。\n五年,变化最大不是工作经验,能力积累,而是心态,清楚的知道现实和理想之间是存在巨大的差距。\n02 学会适应变化,并积累能力 # 回首自己的职场五年,最认可的一句话就是:学会适应变化,并积累能力。\n变化的就是,五年的时间技术框架更新迭代,开发工具的变迁,公司环境队友的更换,甚至是不同城市的流浪,想着能把肉体和灵魂安放在一处,有句很经典的话就是:唯一不变的就是变化本身。\n要积累的是:解决问题的能力,思考方式,拓宽认知。\n这种很难直白的描述,属于个人认知的范畴,不同的人有不一样的看法,所以只能站在大众化的角度去思考。\n首先聊聊技术,大部分小白级别的,都希望自己的技术能力不断提高,争取做到架构师级别,但是站在当前的互联网环境中,这种想法实现难度还是偏高,这里既不是打击也不是为了抬杠。\n可以观察一下现状,技术团队大的 20-30 人,小的 10-15 人,能有一个架构师去专门管理底层框架都是少有现象。\n这个问题的原因很多,首先架构师的成本过高,环境架构也不是需要经常升级,说的难听点可能框架比项目生命周期更高。\n所以大部分公司的大部分业务,基于现有大部分成熟的开源框架都可以解决,这也就导致架构师这个角色通常由项目主管代替或者级别较高的开发直接负责,这就是现实情况。\n这就导致技术框架的选择思路就是:只选对的。即这方面的人才多,开源解决方案多,以此降低技术方面对公司业务发展的影响。\n那为什么还要不断学习和积累技术能力?如果没有这个能力,程序员岗位可能根本走不了五年之久,需要用技术深度积累不断解决工作中的各种问题,用技术的广度提升自己实现业务需求的认知边界,这是安放肉体的根本保障。\n这就是导致很多五年以后的程序员压力陡然升高的原因,走向管理岗的另一个壁垒就是业务思维和认知。\n03 提高业务能力的积累 # 程序员该不该用心研究业务,这个问题真的没有纠结的必要,只要不是纯技术型的公司,都需要面对业务。\n不管技术、运营、产品、管理层,都是在面向业务工作。\n从自己职场轨迹来看,五年变化最大就是解决业务问题的能力,职场之初面对很多业务场景都不知道如何下手,到几年之后设计业务的解决方案。\n这是大部分程序员在职场前五年跳槽就能涨薪的根本原因,面对业务场景,基于积累的经验和现有的开源工具,能快速给出合理的解决思路和实现过程。\n工作五年可能对技术底层的清晰程度都没有初入职场的小白清楚,但是写的程序却可以避开很多坑坑洼洼,对于业务的审视也是很细节全面。\n解决业务能力的积累,对于技术视野的宽度需求更甚,比如职场初期对于海量数据的处理束手无策,但是在工作几年之后见识数据行业的技术栈,真的就是技术选型的视野问题。\n什么是衡量技术能力的标准?站在一个共识的角度上看:系统的架构与代码设计能适应业务的不断变化和各种需求。\n相对比与技术,业务的变化更加快速频繁,高级工程师或者架构师之所以薪资高,这些角色一方面能适应业务的迭代,并且在工作中具有一定前瞻性,会考虑业务变化的情况下代码复用逻辑,这样的能力是需要一定的技术视野和业务思维的沉淀。\n所以职场中:业务能说的井井有条,代码能写的明明白白,得到机会的可能性更大。\n04 不同的阶段技术和业务的平衡和选择 # 从理性的角度看技术和业务两个方面,能让大部分人职场走的平稳顺利,但是不同的阶段对两者的平衡和选择是不一样的。\n在思考如何选择的时候,可以参考二八原则的逻辑,即在任何一组东西中,最重要的只占其中一小部分,约 20%,其余 80%尽管是多数,却是次要的,因此又称二八定律。\n个人真的非常喜欢这个原则,大部分人都不是天才,所以很难三心二意同时做好几件事情,在同一时间段内应该集中精力做好一件事件。\n但是单纯的二八原则模式可能不适应大部分职场初期的人,因为初期要学习很多内容,如何在职场生存:专业能力,职场关系,为人处世,产品设计等等。\n当然这些东西不是都要用心刻意学习,但是合理安排二二六原则或其他组合是更明智的,首先是专业能力要重点练习,其次可以根据自己的兴趣合理选择一到两个方面去慢慢了解,例如产品,运营,运维,数据等,毕竟三五年以后会不会继续写代码很难说,多给自己留个机会总是有备无患。\n在职场初期,基本都是从技术角度去思考问题,如何快速提升自己的编码能力,在公司能稳定是首要目标,因此大部分时间都是在做基础编码和学习规范,这时可能 90%的心思都是放在基础编码上,另外 10%会学习环境架构。\n最多一到两年,就会开始独立负责模块需求开发,需要自己设计整个代码思路,这里业务就会进入视野,要懂得业务上下游关联关系,学会思考如何设计代码结构,才能在需求变动的情况下代码改动较少,这个时候可能就会放 20%的心思在业务方面,30%学习架构方式。\n三到五年这个时间段,是解决问题能力提升最快的时候,因为这个阶段的程序员基本都是在开发核心业务链路,例如交易、支付、结算、智能商业等模块,需要对业务整体有较清晰的把握能力,不然就是给自己挖坑,这个阶段要对业务流付出大量心血思考。\n越是核心的业务线,越是容易爆发各种问题,如果在日常工作中不花心思处理各种细节问题,半夜异常自动的消息和邮件总是容易让人憔悴。\n所以努力学习技术是提升自己,培养自己的业务认知也同样重要,个人认为这二者的分量平分秋色,只是需要在合适的阶段做出合理的权重划分。\n05 学会在职场做选择和生存 # 基于技术能力和业务思维,学会在职场做选择和生存,这些是职场前五年一路走来的最大体会。\n不管是技术还是业务,这两个概念依旧是个很大的命题,不容易把握,所以学会理清这两个方面能力中的公共模块是关键。\n不管技术还是业务,都不可能从一家公司完全复制到另一家公司,但是可以把一家公司的技术框架,业务解决方案学会,并且带到另一家公司,例如技术领域内的架构、设计、流程、数据管理,业务领域内的思考方式、产品逻辑、分析等,这些是核心能力并且是大部分公司人才招聘的要求,所以这些才是工作中需要重点积累的。\n人的精力是有限的,而且面对三十这个天花板,各种事件也会接连而至,在职场中学会合理安排时间并不断提升核心能力,这样才能保证自己的竞争力。\n职场就像苦海无边,回首望去可能也没有岸边停泊,但是要具有换船的能力或者有个小木筏也就大差不差了。\n"},{"id":627,"href":"/zh/docs/technology/Interview/cs-basics/data-structure/red-black-tree/","title":"红黑树","section":"Data Structure","content":" 红黑树介绍 # 红黑树(Red Black Tree)是一种自平衡二叉查找树。它是在 1972 年由 Rudolf Bayer 发明的,当时被称为平衡二叉 B 树(symmetric binary B-trees)。后来,在 1978 年被 Leo J. Guibas 和 Robert Sedgewick 修改为如今的“红黑树”。\n由于其自平衡的特性,保证了最坏情形下在 O(logn) 时间复杂度内完成查找、增加、删除等操作,性能表现稳定。\n在 JDK 中,TreeMap、TreeSet 以及 JDK1.8 的 HashMap 底层都用到了红黑树。\n为什么需要红黑树? # 红黑树的诞生就是为了解决二叉查找树的缺陷。\n二叉查找树是一种基于比较的数据结构,它的每个节点都有一个键值,而且左子节点的键值小于父节点的键值,右子节点的键值大于父节点的键值。这样的结构可以方便地进行查找、插入和删除操作,因为只需要比较节点的键值就可以确定目标节点的位置。但是,二叉查找树有一个很大的问题,就是它的形状取决于节点插入的顺序。如果节点是按照升序或降序的方式插入的,那么二叉查找树就会退化成一个线性结构,也就是一个链表。这样的情况下,二叉查找树的性能就会大大降低,时间复杂度就会从 O(logn) 变为 O(n)。\n红黑树的诞生就是为了解决二叉查找树的缺陷,因为二叉查找树在某些情况下会退化成一个线性结构。\n红黑树特点 # 每个节点非红即黑。黑色决定平衡,红色不决定平衡。这对应了 2-3 树中一个节点内可以存放 1~2 个节点。 根节点总是黑色的。 每个叶子节点都是黑色的空节点(NIL 节点)。这里指的是红黑树都会有一个空的叶子节点,是红黑树自己的规则。 如果节点是红色的,则它的子节点必须是黑色的(反之不一定)。通常这条规则也叫不会有连续的红色节点。一个节点最多临时会有 3 个子节点,中间是黑色节点,左右是红色节点。 从任意节点到它的叶子节点或空子节点的每条路径,必须包含相同数目的黑色节点(即相同的黑色高度)。每一层都只是有一个节点贡献了树高决定平衡性,也就是对应红黑树中的黑色节点。 正是这些特点才保证了红黑树的平衡,让红黑树的高度不会超过 2log(n+1)。\n红黑树数据结构 # 建立在 BST 二叉搜索树的基础上,AVL、2-3 树、红黑树都是自平衡二叉树(统称 B-树)。但相比于 AVL 树,高度平衡所带来的时间复杂度,红黑树对平衡的控制要宽松一些,红黑树只需要保证黑色节点平衡即可。\n红黑树结构实现 # public class Node { public Class\u0026lt;?\u0026gt; clazz; public Integer value; public Node parent; public Node left; public Node right; // AVL 树所需属性 public int height; // 红黑树所需属性 public Color color = Color.RED; } 1.左倾染色 # 染色时根据当前节点的爷爷节点,找到当前节点的叔叔节点。 再把父节点染黑、叔叔节点染黑,爷爷节点染红。但爷爷节点染红是临时的,当平衡树高操作后会把根节点染黑。 2.右倾染色 # 3.左旋调衡 # 3.1 一次左旋 # 3.2 右旋+左旋 # 4.右旋调衡 # 4.1 一次右旋 # 4.2 左旋+右旋 # 文章推荐 # 《红黑树深入剖析及 Java 实现》 - 美团点评技术团队 漫画:什么是红黑树? - 程序员小灰(也介绍到了二叉查找树,非常推荐) "},{"id":628,"href":"/zh/docs/technology/Interview/high-quality-technical-articles/personal-experience/huawei-od-275-days/","title":"华为 OD 275 天后,我进了腾讯!","section":"Personal Experience","content":" 推荐语:一位朋友的华为 OD 工作经历以及腾讯面试经历分享,内容很不错。\n原文地址: https://www.cnblogs.com/shoufeng/p/14322931.html\n时间线 # 18 年 7 月,毕业于某不知名 985 计科专业; 毕业前,在某马的 JavaEE(后台开发)培训了 6 个月; 第一份工作(18-07 ~ 19-12)接触了大数据,感觉大数据更有前景; 19 年 12 月,入职中国平安产险(去到才发现是做后台开发 😢); 20 年 3 月,从平安辞职,跳去华为 OD 做大数据基础平台; 2021 年 1 月,入职鹅厂 华为 OD 工作经历总结 # 为什么会去华为 OD # 在平安产险(正式员工)只待了 3 个月,就跳去华为 OD,朋友们都是很不理解的 —— 好好的正编不做,去什么外包啊 😂\n但那个时候,我铁了心要去做大数据,不想和没完没了的 CRUD 打交道。刚好面试通过的岗位是华为 Cloud BU 的大数据部门,做的是国内政企中使用率绝对领先的大数据平台…… 平台和工作内容都不错,这么好的机会,说啥也要去啊 💪\n其实有想过在平安内部转岗到大数据的,但是不满足“入职一年以上”这个要求; 「等待就是浪费生命」,在转正流程还没批下来的时候,赶紧溜了 😂\n华为 OD 的工作内容 # 带着无限的期待,火急火燎地去华为报到了。\n和招聘的 HR 说的一样,和华为自有员工一起办公,工作内容和他们完全一样:\n主管根据你的能力水平分配工作,逐渐增加难度,能者多劳; 试用期 6 个月,有导师带你,一般都是高你 2 个 Level 的华为自有员工,基本都是部门大牛。\n所以,不存在外包做的都是基础的、流程性的、没有技术含量的工作 —— 顾虑这个的完全不用担心,你只需要打听清楚要去的部门/小组具体做什么,能接受就再考虑其他的。\n感触很深的一点是:华为是有着近 20 万员工的巨头,内部有很多流程和制度。好处是:能接触到大公司的产品从开发、测试,到发布、运维等一系列的流程,比如提交代码的时候,会由经验资深、经过内部认证的大牛给你 Review,在拉会检视的时候,可以学习他们考虑问题的角度,还有对整个产品全局的把控。\n但同时,个人觉得这也有不好的地方:流程繁琐会导致工作效率变低,比如改动几行代码,就需要跑完整个 CI(有些耗时比较久),还要提供自验和 VT 的报告。\nOD 与华为自有员工的对比 # 什么是 OD?Outstanding Dispatcher,人员派遣,官方强调说,OD 和常说的“外包”是不一样的。\n说说我了解的 OD:\n参考华为的薪酬框架,OD 人员的薪酬体系有一定的市场竞争力 —— 的确是这样,貌似会稍微倒挂同级别的自有员工; 可以参与华为主力产品的研发 —— 是的,这也是和某软等“供应商”的兄弟们不一样的地方; 外网权限也可以申请打开(对,就是梯子),部门内部的大多数文档都是可以看的; 工号是单独的 300 号段,其他供应商员工的工号是 8 开头,或着 WX 开头; 工卡带是红色的,和自有员工一样,但是工卡内容不同,OD 的明确标注:办公区通行证,并有德科公司的备注: 还听到一些内部的说法:\n没股票,没 TUP,年终奖少,只有工资可能比我司高一点点而已; 不能借针对 HW 的消费贷,也不能买公司提供的优惠保险…… 那,到底要不要去华为 OD? # 我想,搜到我这篇文字的你,心里其实是有偏向的,只是缺最后一片雪花 ❄️,让自己下决心。\n作为过来人之一,我再提供一些参考吧 😃\n1)除了华为 OD,还有没有更好的选择? 综合考虑加班(996、有些是 9106 甚至更多)、薪资、工作内容,以及这份工作经历对你整个职业的加成等等因素;\n2)有看到一些内部的说法,比如:“奇怪 OD 这么棒,为啥大家不自愿转去 OD 啊?”;再比如:“OD 等同华为?这话都说的出口,既然都等同,为啥还要 OD?就是降成本嘛……”\n3)内心够强大吗?虽然没有人会说你是 OD,但总有一些事情会提醒你:你不是华为员工。比如:\na) 内部发文啥的,还有心声平台的大部分内容,都是无权限看的:\nb) 你的考勤是在租赁人员管理系统里考核,绩效管理也是;\nc) 自有员工的工卡具有消费功能(包括刷夜宵),OD 的工卡不能消费,需要办个消费卡,而且夜宵只能通过手机软件领取(自有员工是用工卡领的);\nd) 你的加班一定要提加班申请电子流换 Double 薪资,不然只能换调休,离职时没时间调休也换不来 Double —— 而华为员工即使自己主动离职,也是有 N+1,以及加班时间换成 Double 薪资的;\n网传的 OD 转华为正编,真的假的? # 这个放到单独的一节,是因为它很重要,有很多纠结的同学在关注这个问题。\n答案是:真的。\n据各类非官方渠道(比如知乎上的一些分享),转华为自有是有条件的( https://www.zhihu.com/question/356592219/answer/1562692667):\n1)入职时间:一年以上 2)绩效要求:连续两次绩效 A 3)认证要求:通过可信专业级认证 4)其他条件:根据业务部门的人员需求及指标要求确定\n说说这些条件吧 😃\n条件 2 连续两次绩效 A\n上面链接里的说法:\n绩效 A 大约占整个部门的前 10%,连续两次 A 的意思就是一年里两次考评都排在部门前 10%,能做到这样的在华为属于火车头,这种难得的绩效会舍得分给一个租赁人员吗?\nOD 同学能拿到 A 吗?不知道,我入职晚,都没有经历一个完整的绩效考评。\n(20210605 更新下)一年多了,还留着的 OD 同学告知我:OD 是单独评绩效的,能拿到 A 的比例,大概是 1/5,对应的年终奖就是 4 个月;绩效是 B,年终奖就是 2 个月。\n在我看来,在试用期答辩时,能拿 A,接下来半年的绩效大概率也是拿 A 的。\n但总的来说,这种事既看实力,又看劳动态度(能不能拼命三郎疯狂加班),还要看运气(主管对你是不是认可)……\n条件 3 通过可信专业级认证\n可信专业级认证考试是啥?华为在推动技术人员的可信认证,算是一项安全合规的工作。 专业级有哪些考试呢?共有四门:\n科目一:上级编程,对比力扣 2 道中等、1 道困难; 科目二:编程知识与应用,考察基础的编程语言知识等; 科目三:安全编程、质量、隐私,还有开发者测试等; 科目四:重构知识,包括设计模式、代码重构等。 上面这些,每一门单季度只能考一次(好像有些一年只能考 3 次),每个都要准备,少则 3 天,多则 1 星期,不准备,基本都过不了。 我在 4 个月左右、还没转正的时候,就考过了专业级的科目二、三、四,只剩科目一大半年都没过(算法确实太菜了 😂 但也有同事没准备,连着好几次都没通过。\n条件 4 部门人员需求指标?\n这个听起来都感觉很玄学。还是那句话,实力和运气到了,应该可以的!成功转正员工图镇楼:\n真的感谢 OD,也感谢华为 # 运气很好,在我换工作还不到 3 个月的时候,华为还收我。\n我遇到了很好的主管,起码在工作时间,感觉跟兄长一样指导、帮助我;\n分配给我的导师,是我工作以来认识到技术实力最厉害的人,定位问题思路清晰,编码实力强悍,全局思考问题、制定方案……\n小组、部门的同学都很 nice,9 个多月里,我基本每天都跟打了鸡血一样,现在想想,也不知道当时为什么会那么积极有干劲 😂\n从个人能力上来讲,我是进不去华为的(心里还是有点数的 😂)。正是有了 OD 这个渠道,才有机会切身感受华为的工作氛围,也学到了很多软技能:\n积极主动,勇于承担尝试,好工作要抢过来自己做; 及时同步工作进展,包括已完成、待完成,存在的风险困难等内容,要让领导知道你的工作情况; 勤于总结提炼输出,形成个人 DNA,利人利己; 有不懂的可以随时找人问,脸皮要厚,虚心求教; 不管多忙,所有的会议,不论大小,都要有会议纪要,邮件发给相关人…… 再次感谢,大家都加油,向很牛掰很牛掰前进 💪\n投简历,找面试官求虐 # 20 年 11 月初的一天,在同事们讨论“某某被其他公司高薪挖去了,钱景无限”的消息。\n我忽然惊觉,自己来到华为半年多,除了熟悉内部的系统和流程,好像没有什么成长和进步?\n不禁反思:只有厉害的人才会被挖,现在这个状态的我,在市场上值几个钱?\n刚好想起了之前的一个同事在离职聚会上分享的经验:\n技术人不能闭门造车,要多交流,多看看外面的动态。\n如果感觉自己太安逸了,那就把简历挂出去,去了解其他公司用的是什么技术,他们更关注哪些痛点?面几次你就有方向了。\n这时候起了个念头:找面试官求虐,以此来鞭策自己,进而更好地制定学习方向。\n于是我重新下载了某聘软件,在首页推荐里投了几家公司。\n开始面试 # 11 月 10 号投的简历,当天就有 2 家预约了 11 号下午的线上面试,其中就有鹅厂 🐧\n好巧不巧,10 号晚上要双十一业务保障,一直到第二天凌晨 2 点半才下班。\n熬夜太伤身,还好能申请调休一天,也省去了找借口请假 🙊\n这段时间集中面了 3 家:\n第 1 个是广州的公司,11 号当晚就完成了 2 轮线上面试,开得有点低,就婉拒了; 第 2 个就是本文的重点——鹅厂; 第 3 个是做跨境电商的公司,一面就跪(恭喜它荣升为“在我有限的工作经历中,面试体验最差的 2 家公司之一”🙂️)\n鹅厂,去还是不去? # 一直有一个大厂梦,奈何菜鸟一枚,之前试过好几次,都跪在技术面了。\n所以想了个曲线救国的方法:先在其他单位积累着,有机会了再争取大厂的机会 💪\n很幸运,也很猝不及防,这次竟然通过了鹅厂的所有面试。\n虽然已到年底,但是要是错过这么难得的机会,下次就不知道什么时候才能再通关了。\n所以,年后拿到年终再跳槽 vs 已到手的鹅厂 Offer,我选择了后者 😄\n我的鹅厂面试 # 如本文标题所说,16 天通关五轮面试,第 17 天,我终于收到了期盼已久的鹅厂 Offer。\n做技术的同学,可能会对鹅厂的面试很好奇,他们都会问哪些问题呢?\n我应聘的是大数据开发(Java)岗位,接下来对我的面试做个梳理,也给想来鹅厂的同学们一个参考 😊\n几乎所有问题都能在网络上找到很详细的答案。 篇幅有限,这里只写题目和一些引申的问题。\n技术一面 # Java 语言相关 # 1、对 Java 的类加载器有没有了解?如何自定义类加载器?\n引申:一个类能被加载多次吗?java/javax 包下的类会被加载多次吗?\n2、Java 中要怎么创建一个对象 🐘?\n3、对多线程有了解吗?在什么场景下需要使用多线程?\n引申:对 线程安全 的认识;对线程池的了解,以及各个线程池的适用场景。\n4、对垃圾回收的了解?\n5、对 JVM 分代的了解?\n6、NIO 的了解?用过 RandomAccessFile 吗?\n引申:对 同步、异步,阻塞、非阻塞 的理解?\n多路复用 IO 的优势?\n7、ArrayList 和 LinkedList 的区别?各自的适用场景?\n8、实现一个 Hash 集合,需要考虑哪些因素?\n引申:JDK 对 HashMap 的设计关键点,比如初识容量,扩所容,链表转红黑树,以及 JDK 7 和 JDK 8 的区别等等。\n通用学科相关 # 1、TCP 的三次握手;\n2、Linux 的常用命令,比如:\nps aux / ps -ef、top C df -h、du -sh *、free -g vmstat、mpstat、iostat、netstat 项目框架相关 # 1、Kafka 和其他 MQ 的区别?它的吞吐量为什么高?\n消费者主动 pull 数据,目的是:控制消费节奏,还可以重复消费;\n吞吐量高:各 partition 顺序写 IO,批量刷新到磁盘(OS 的 pageCache 负责刷盘,Kafka 不用管),比随机 IO 快;读取数据基于 sendfile 的 Zero Copy;批量数据压缩……\n2、Hive 和 SparkSQL 的区别?\n3、Ranger 的权限模型、权限对象,鉴权过程,策略如何刷新……\n问题定位方法 # 1、ssh 连接失败,如何定位?\n是否能 ping 通(DNS 是否正确)、对端端口是否开了防火墙、对端服务是否正常……\n2、运行 Java 程序的服务器,CPU 使用率达到 100%,如何定位?\nps aux | grep xxx 或 jps 命令找到 Java 的进程号 pid,\n然后用 top -Hp pid 命令查看其阻塞的线程序号,将其转换为 16 进制;\n再通过 jstack pid 命令跟踪此 Java 进程的堆栈,搜索上述转换来的 16 进制线程号,即可找到对应的线程名及其堆栈信息……\n3、Java 程序发生了内存溢出,如何定位?\njmap 工具查看堆栈信息,看 Eden、Old 区的变化……\n技术二面 # 二面主要是过往项目相关的问题:\n1、Solr 和 Elasticsearch 的区别 / 优劣?\n2、对 Elasticsearch 的优化,它的索引过程,选主过程等问题……\n3、项目中遇到的难题,如何解决的?\nblabla 有少量的基础问题和一面有重复,还有几个和大数据相关的问题,记不太清了 😅\n技术三面 # 这一面是总监面,更多是个人关于职业发展的一些想法,以及在之前公司的成长和收获、对下一份工作的期望等问题。\n但也问了几个技术问题。印象比较深的是这个:\n1 个 1TB 的大文件,每行都只是 1 个数字,无重复,8GB 内存,要怎么对这个文件进行排序?\n首先想到的是 MapReduce 的思路,拆分小文件,分批排序,最后合并。\n此时连环追问来了:\nQ:如何尽可能多的利用内存呢?\nA:用位图法的思路,对数字按顺序映射。(对映射方法要有基本的了解)\nQ:如果在排好序之后,还需要快速查找呢?\nA:可以做索引,类似 Redis 的跳表,通过多级索引提高查找速度。\nQ:索引查找的还是文件。要如何才能更多地利用内存呢?\nA:那就要添加缓存了,把读取过的数字缓存到内存中。\nQ:缓存应该满足什么特点呢?\nA:应该使用 LRU 型的缓存。\n呼。。。总算是追问完了这道题 😂\n还有 GM 面和 HR 面,问题都和个人经历相关,这里就略去不表。\n文末的絮叨 # 入职鹅厂已经 1 月有余。不同的岗位,不同的工作内容,也是不同的挑战。\n感受比较深的是,作为程序员,还是要自我驱动,努力提升个人技术能力,横向纵向都要扩充,这样才能走得长远。\n"},{"id":629,"href":"/zh/docs/technology/Interview/database/redis/cache-basics/","title":"缓存基础常见面试题总结(付费)","section":"Redis","content":"缓存基础 相关的面试题为我的 知识星球(点击链接即可查看详细介绍以及加入方法)专属内容,已经整理到了 《Java 面试指北》中。\n"},{"id":630,"href":"/zh/docs/technology/Interview/cs-basics/algorithms/linkedlist-algorithm-problems/","title":"几道常见的链表算法题","section":"Algorithms","content":" 1. 两数相加 # 题目描述 # Leetcode:给定两个非空链表来表示两个非负整数。位数按照逆序方式存储,它们的每个节点只存储单个数字。将两数相加返回一个新的链表。\n你可以假设除了数字 0 之外,这两个数字都不会以零开头。\n示例:\n输入:(2 -\u0026gt; 4 -\u0026gt; 3) + (5 -\u0026gt; 6 -\u0026gt; 4) 输出:7 -\u0026gt; 0 -\u0026gt; 8 原因:342 + 465 = 807 问题分析 # Leetcode 官方详细解答地址:\nhttps://leetcode-cn.com/problems/add-two-numbers/solution/\n要对头结点进行操作时,考虑创建哑节点 dummy,使用 dummy-\u0026gt;next 表示真正的头节点。这样可以避免处理头节点为空的边界问题。\n我们使用变量来跟踪进位,并从包含最低有效位的表头开始模拟逐 位相加的过程。\nSolution # 我们首先从最低有效位也就是列表 l1 和 l2 的表头开始相加。注意需要考虑到进位的情况!\n/** * Definition for singly-linked list. * public class ListNode { * int val; * ListNode next; * ListNode(int x) { val = x; } * } */ //https://leetcode-cn.com/problems/add-two-numbers/description/ class Solution { public ListNode addTwoNumbers(ListNode l1, ListNode l2) { ListNode dummyHead = new ListNode(0); ListNode p = l1, q = l2, curr = dummyHead; //carry 表示进位数 int carry = 0; while (p != null || q != null) { int x = (p != null) ? p.val : 0; int y = (q != null) ? q.val : 0; int sum = carry + x + y; //进位数 carry = sum / 10; //新节点的数值为sum % 10 curr.next = new ListNode(sum % 10); curr = curr.next; if (p != null) p = p.next; if (q != null) q = q.next; } if (carry \u0026gt; 0) { curr.next = new ListNode(carry); } return dummyHead.next; } } 2. 翻转链表 # 题目描述 # 剑指 offer:输入一个链表,反转链表后,输出链表的所有元素。\n问题分析 # 这道算法题,说直白点就是:如何让后一个节点指向前一个节点!在下面的代码中定义了一个 next 节点,该节点主要是保存要反转到头的那个节点,防止链表 “断裂”。\nSolution # public class ListNode { int val; ListNode next = null; ListNode(int val) { this.val = val; } } /** * * @author Snailclimb * @date 2018年9月19日 * @Description: TODO */ public class Solution { public ListNode ReverseList(ListNode head) { ListNode next = null; ListNode pre = null; while (head != null) { // 保存要反转到头的那个节点 next = head.next; // 要反转的那个节点指向已经反转的上一个节点(备注:第一次反转的时候会指向null) head.next = pre; // 上一个已经反转到头部的节点 pre = head; // 一直向链表尾走 head = next; } return pre; } } 测试方法:\npublic static void main(String[] args) { ListNode a = new ListNode(1); ListNode b = new ListNode(2); ListNode c = new ListNode(3); ListNode d = new ListNode(4); ListNode e = new ListNode(5); a.next = b; b.next = c; c.next = d; d.next = e; new Solution().ReverseList(a); while (e != null) { System.out.println(e.val); e = e.next; } } 输出:\n5 4 3 2 1 3. 链表中倒数第 k 个节点 # 题目描述 # 剑指 offer: 输入一个链表,输出该链表中倒数第 k 个结点。\n问题分析 # 链表中倒数第 k 个节点也就是正数第(L-K+1)个节点,知道了只一点,这一题基本就没问题!\n首先两个节点/指针,一个节点 node1 先开始跑,指针 node1 跑到 k-1 个节点后,另一个节点 node2 开始跑,当 node1 跑到最后时,node2 所指的节点就是倒数第 k 个节点也就是正数第(L-K+1)个节点。\nSolution # /* public class ListNode { int val; ListNode next = null; ListNode(int val) { this.val = val; } }*/ // 时间复杂度O(n),一次遍历即可 // https://www.nowcoder.com/practice/529d3ae5a407492994ad2a246518148a?tpId=13\u0026amp;tqId=11167\u0026amp;tPage=1\u0026amp;rp=1\u0026amp;ru=/ta/coding-interviews\u0026amp;qru=/ta/coding-interviews/question-ranking public class Solution { public ListNode FindKthToTail(ListNode head, int k) { // 如果链表为空或者k小于等于0 if (head == null || k \u0026lt;= 0) { return null; } // 声明两个指向头结点的节点 ListNode node1 = head, node2 = head; // 记录节点的个数 int count = 0; // 记录k值,后面要使用 int index = k; // p指针先跑,并且记录节点数,当node1节点跑了k-1个节点后,node2节点开始跑, // 当node1节点跑到最后时,node2节点所指的节点就是倒数第k个节点 while (node1 != null) { node1 = node1.next; count++; if (k \u0026lt; 1) { node2 = node2.next; } k--; } // 如果节点个数小于所求的倒数第k个节点,则返回空 if (count \u0026lt; index) return null; return node2; } } 4. 删除链表的倒数第 N 个节点 # Leetcode:给定一个链表,删除链表的倒数第 n 个节点,并且返回链表的头结点。\n示例:\n给定一个链表: 1-\u0026gt;2-\u0026gt;3-\u0026gt;4-\u0026gt;5, 和 n = 2. 当删除了倒数第二个节点后,链表变为 1-\u0026gt;2-\u0026gt;3-\u0026gt;5. 说明:\n给定的 n 保证是有效的。\n进阶:\n你能尝试使用一趟扫描实现吗?\n该题在 leetcode 上有详细解答,具体可参考 Leetcode.\n问题分析 # 我们注意到这个问题可以容易地简化成另一个问题:删除从列表开头数起的第 (L - n + 1)个结点,其中 L 是列表的长度。只要我们找到列表的长度 L,这个问题就很容易解决。\nSolution # 两次遍历法\n首先我们将添加一个 哑结点 作为辅助,该结点位于列表头部。哑结点用来简化某些极端情况,例如列表中只含有一个结点,或需要删除列表的头部。在第一次遍历中,我们找出列表的长度 L。然后设置一个指向哑结点的指针,并移动它遍历列表,直至它到达第 (L - n) 个结点那里。我们把第 (L - n)个结点的 next 指针重新链接至第 (L - n + 2)个结点,完成这个算法。\n/** * Definition for singly-linked list. * public class ListNode { * int val; * ListNode next; * ListNode(int x) { val = x; } * } */ // https://leetcode-cn.com/problems/remove-nth-node-from-end-of-list/description/ public class Solution { public ListNode removeNthFromEnd(ListNode head, int n) { // 哑结点,哑结点用来简化某些极端情况,例如列表中只含有一个结点,或需要删除列表的头部 ListNode dummy = new ListNode(0); // 哑结点指向头结点 dummy.next = head; // 保存链表长度 int length = 0; ListNode len = head; while (len != null) { length++; len = len.next; } length = length - n; ListNode target = dummy; // 找到 L-n 位置的节点 while (length \u0026gt; 0) { target = target.next; length--; } // 把第 (L - n)个结点的 next 指针重新链接至第 (L - n + 2)个结点 target.next = target.next.next; return dummy.next; } } 进阶——一次遍历法:\n链表中倒数第 N 个节点也就是正数第(L - n + 1)个节点。\n其实这种方法就和我们上面第四题找“链表中倒数第 k 个节点”所用的思想是一样的。基本思路就是: 定义两个节点 node1、node2;node1 节点先跑,node1 节点 跑到第 n+1 个节点的时候,node2 节点开始跑.当 node1 节点跑到最后一个节点时,node2 节点所在的位置就是第 (L - n ) 个节点(L 代表总链表长度,也就是倒数第 n + 1 个节点)\n/** * Definition for singly-linked list. * public class ListNode { * int val; * ListNode next; * ListNode(int x) { val = x; } * } */ public class Solution { public ListNode removeNthFromEnd(ListNode head, int n) { ListNode dummy = new ListNode(0); dummy.next = head; // 声明两个指向头结点的节点 ListNode node1 = dummy, node2 = dummy; // node1 节点先跑,node1节点 跑到第 n 个节点的时候,node2 节点开始跑 // 当node1 节点跑到最后一个节点时,node2 节点所在的位置就是第 (L-n ) 个节点,也就是倒数第 n+1(L代表总链表长度) while (node1 != null) { node1 = node1.next; if (n \u0026lt; 1 \u0026amp;\u0026amp; node1 != null) { node2 = node2.next; } n--; } node2.next = node2.next.next; return dummy.next; } } 5. 合并两个排序的链表 # 题目描述 # 剑指 offer:输入两个单调递增的链表,输出两个链表合成后的链表,当然我们需要合成后的链表满足单调不减规则。\n问题分析 # 我们可以这样分析:\n假设我们有两个链表 A,B; A 的头节点 A1 的值与 B 的头结点 B1 的值比较,假设 A1 小,则 A1 为头节点; A2 再和 B1 比较,假设 B1 小,则,A1 指向 B1; A2 再和 B2 比较 就这样循环往复就行了,应该还算好理解。 考虑通过递归的方式实现!\nSolution # 递归版本:\n/* public class ListNode { int val; ListNode next = null; ListNode(int val) { this.val = val; } }*/ //https://www.nowcoder.com/practice/d8b6b4358f774294a89de2a6ac4d9337?tpId=13\u0026amp;tqId=11169\u0026amp;tPage=1\u0026amp;rp=1\u0026amp;ru=/ta/coding-interviews\u0026amp;qru=/ta/coding-interviews/question-ranking public class Solution { public ListNode Merge(ListNode list1, ListNode list2) { if (list1 == null) { return list2; } if (list2 == null) { return list1; } if (list1.val \u0026lt;= list2.val) { list1.next = Merge(list1.next, list2); return list1; } else { list2.next = Merge(list1, list2.next); return list2; } } } "},{"id":631,"href":"/zh/docs/technology/Interview/cs-basics/algorithms/string-algorithm-problems/","title":"几道常见的字符串算法题","section":"Algorithms","content":" 作者:wwwxmu\n原文地址: https://www.weiweiblog.cn/13string/\n1. KMP 算法 # 谈到字符串问题,不得不提的就是 KMP 算法,它是用来解决字符串查找的问题,可以在一个字符串(S)中查找一个子串(W)出现的位置。KMP 算法把字符匹配的时间复杂度缩小到 O(m+n) ,而空间复杂度也只有 O(m)。因为“暴力搜索”的方法会反复回溯主串,导致效率低下,而 KMP 算法可以利用已经部分匹配这个有效信息,保持主串上的指针不回溯,通过修改子串的指针,让模式串尽量地移动到有效的位置。\n具体算法细节请参考:\n从头到尾彻底理解 KMP: 如何更好的理解和掌握 KMP 算法? KMP 算法详细解析 图解 KMP 算法 汪都能听懂的 KMP 字符串匹配算法【双语字幕】 KMP 字符串匹配算法 1 除此之外,再来了解一下 BM 算法!\nBM 算法也是一种精确字符串匹配算法,它采用从右向左比较的方法,同时应用到了两种启发式规则,即坏字符规则 和好后缀规则 ,来决定向右跳跃的距离。基本思路就是从右往左进行字符匹配,遇到不匹配的字符后从坏字符表和好后缀表找一个最大的右移值,将模式串右移继续匹配。 《字符串匹配的 KMP 算法》: http://www.ruanyifeng.com/blog/2013/05/Knuth%E2%80%93Morris%E2%80%93Pratt_algorithm.html\n2. 替换空格 # 剑指 offer:请实现一个函数,将一个字符串中的每个空格替换成“%20”。例如,当字符串为 We Are Happy.则经过替换之后的字符串为 We%20Are%20Happy。\n这里我提供了两种方法:① 常规方法;② 利用 API 解决。\n//https://www.weiweiblog.cn/replacespace/ public class Solution { /** * 第一种方法:常规方法。利用String.charAt(i)以及String.valueOf(char).equals(\u0026#34; \u0026#34; * )遍历字符串并判断元素是否为空格。是则替换为\u0026#34;%20\u0026#34;,否则不替换 */ public static String replaceSpace(StringBuffer str) { int length = str.length(); // System.out.println(\u0026#34;length=\u0026#34; + length); StringBuffer result = new StringBuffer(); for (int i = 0; i \u0026lt; length; i++) { char b = str.charAt(i); if (String.valueOf(b).equals(\u0026#34; \u0026#34;)) { result.append(\u0026#34;%20\u0026#34;); } else { result.append(b); } } return result.toString(); } /** * 第二种方法:利用API替换掉所用空格,一行代码解决问题 */ public static String replaceSpace2(StringBuffer str) { return str.toString().replaceAll(\u0026#34;\\\\s\u0026#34;, \u0026#34;%20\u0026#34;); } } 对于替换固定字符(比如空格)的情况,第二种方法其实可以使用 replace 方法替换,性能更好!\nstr.toString().replace(\u0026#34; \u0026#34;,\u0026#34;%20\u0026#34;); 3. 最长公共前缀 # Leetcode: 编写一个函数来查找字符串数组中的最长公共前缀。如果不存在公共前缀,返回空字符串 \u0026ldquo;\u0026quot;。\n示例 1:\n输入: [\u0026#34;flower\u0026#34;,\u0026#34;flow\u0026#34;,\u0026#34;flight\u0026#34;] 输出: \u0026#34;fl\u0026#34; 示例 2:\n输入: [\u0026#34;dog\u0026#34;,\u0026#34;racecar\u0026#34;,\u0026#34;car\u0026#34;] 输出: \u0026#34;\u0026#34; 解释: 输入不存在公共前缀。 思路很简单!先利用 Arrays.sort(strs)为数组排序,再将数组第一个元素和最后一个元素的字符从前往后对比即可!\npublic class Main { public static String replaceSpace(String[] strs) { // 如果检查值不合法及就返回空串 if (!checkStrs(strs)) { return \u0026#34;\u0026#34;; } // 数组长度 int len = strs.length; // 用于保存结果 StringBuilder res = new StringBuilder(); // 给字符串数组的元素按照升序排序(包含数字的话,数字会排在前面) Arrays.sort(strs); int m = strs[0].length(); int n = strs[len - 1].length(); int num = Math.min(m, n); for (int i = 0; i \u0026lt; num; i++) { if (strs[0].charAt(i) == strs[len - 1].charAt(i)) { res.append(strs[0].charAt(i)); } else break; } return res.toString(); } private static boolean checkStrs(String[] strs) { boolean flag = false; if (strs != null) { // 遍历strs检查元素值 for (int i = 0; i \u0026lt; strs.length; i++) { if (strs[i] != null \u0026amp;\u0026amp; strs[i].length() != 0) { flag = true; } else { flag = false; break; } } } return flag; } // 测试 public static void main(String[] args) { String[] strs = { \u0026#34;customer\u0026#34;, \u0026#34;car\u0026#34;, \u0026#34;cat\u0026#34; }; // String[] strs = { \u0026#34;customer\u0026#34;, \u0026#34;car\u0026#34;, null };//空串 // String[] strs = {};//空串 // String[] strs = null;//空串 System.out.println(Main.replaceSpace(strs));// c } } 4. 回文串 # 4.1. 最长回文串 # LeetCode: 给定一个包含大写字母和小写字母的字符串,找到通过这些字母构造成的最长的回文串。在构造过程中,请注意区分大小写。比如\u0026quot;Aa\u0026quot;不能当做一个回文字符串。注 意:假设字符串的长度不会超过 1010。\n回文串:“回文串”是一个正读和反读都一样的字符串,比如“level”或者“noon”等等就是回文串。——百度百科 地址: https://baike.baidu.com/item/%E5%9B%9E%E6%96%87%E4%B8%B2/1274921?fr=aladdin\n示例 1:\n输入: \u0026#34;abccccdd\u0026#34; 输出: 7 解释: 我们可以构造的最长的回文串是\u0026#34;dccaccd\u0026#34;, 它的长度是 7。 我们上面已经知道了什么是回文串?现在我们考虑一下可以构成回文串的两种情况:\n字符出现次数为双数的组合 字符出现次数为偶数的组合+单个字符中出现次数最多且为奇数次的字符 (参见 issue665 ) 统计字符出现的次数即可,双数才能构成回文。因为允许中间一个数单独出现,比如“abcba”,所以如果最后有字母落单,总长度可以加 1。首先将字符串转变为字符数组。然后遍历该数组,判断对应字符是否在 hashset 中,如果不在就加进去,如果在就让 count++,然后移除该字符!这样就能找到出现次数为双数的字符个数。\n//https://leetcode-cn.com/problems/longest-palindrome/description/ class Solution { public int longestPalindrome(String s) { if (s.length() == 0) return 0; // 用于存放字符 HashSet\u0026lt;Character\u0026gt; hashset = new HashSet\u0026lt;Character\u0026gt;(); char[] chars = s.toCharArray(); int count = 0; for (int i = 0; i \u0026lt; chars.length; i++) { if (!hashset.contains(chars[i])) {// 如果hashset没有该字符就保存进去 hashset.add(chars[i]); } else {// 如果有,就让count++(说明找到了一个成对的字符),然后把该字符移除 hashset.remove(chars[i]); count++; } } return hashset.isEmpty() ? count * 2 : count * 2 + 1; } } 4.2. 验证回文串 # LeetCode: 给定一个字符串,验证它是否是回文串,只考虑字母和数字字符,可以忽略字母的大小写。 说明:本题中,我们将空字符串定义为有效的回文串。\n示例 1:\n输入: \u0026#34;A man, a plan, a canal: Panama\u0026#34; 输出: true 示例 2:\n输入: \u0026#34;race a car\u0026#34; 输出: false //https://leetcode-cn.com/problems/valid-palindrome/description/ class Solution { public boolean isPalindrome(String s) { if (s.length() == 0) return true; int l = 0, r = s.length() - 1; while (l \u0026lt; r) { // 从头和尾开始向中间遍历 if (!Character.isLetterOrDigit(s.charAt(l))) {// 字符不是字母和数字的情况 l++; } else if (!Character.isLetterOrDigit(s.charAt(r))) {// 字符不是字母和数字的情况 r--; } else { // 判断二者是否相等 if (Character.toLowerCase(s.charAt(l)) != Character.toLowerCase(s.charAt(r))) return false; l++; r--; } } return true; } } 4.3. 最长回文子串 # Leetcode: LeetCode: 最长回文子串 给定一个字符串 s,找到 s 中最长的回文子串。你可以假设 s 的最大长度为 1000。\n示例 1:\n输入: \u0026#34;babad\u0026#34; 输出: \u0026#34;bab\u0026#34; 注意: \u0026#34;aba\u0026#34;也是一个有效答案。 示例 2:\n输入: \u0026#34;cbbd\u0026#34; 输出: \u0026#34;bb\u0026#34; 以某个元素为中心,分别计算偶数长度的回文最大长度和奇数长度的回文最大长度。\n//https://leetcode-cn.com/problems/longest-palindromic-substring/description/ class Solution { private int index, len; public String longestPalindrome(String s) { if (s.length() \u0026lt; 2) return s; for (int i = 0; i \u0026lt; s.length() - 1; i++) { PalindromeHelper(s, i, i); PalindromeHelper(s, i, i + 1); } return s.substring(index, index + len); } public void PalindromeHelper(String s, int l, int r) { while (l \u0026gt;= 0 \u0026amp;\u0026amp; r \u0026lt; s.length() \u0026amp;\u0026amp; s.charAt(l) == s.charAt(r)) { l--; r++; } if (len \u0026lt; r - l - 1) { index = l + 1; len = r - l - 1; } } } 4.4. 最长回文子序列 # LeetCode: 最长回文子序列 给定一个字符串 s,找到其中最长的回文子序列。可以假设 s 的最大长度为 1000。 最长回文子序列和上一题最长回文子串的区别是,子串是字符串中连续的一个序列,而子序列是字符串中保持相对位置的字符序列,例如,\u0026ldquo;bbbb\u0026quot;可以是字符串\u0026quot;bbbab\u0026quot;的子序列但不是子串。\n给定一个字符串 s,找到其中最长的回文子序列。可以假设 s 的最大长度为 1000。\n示例 1:\n输入: \u0026#34;bbbab\u0026#34; 输出: 4 一个可能的最长回文子序列为 \u0026ldquo;bbbb\u0026rdquo;。\n示例 2:\n输入: \u0026#34;cbbd\u0026#34; 输出: 2 一个可能的最长回文子序列为 \u0026ldquo;bb\u0026rdquo;。\n动态规划: dp[i][j] = dp[i+1][j-1] + 2 if s.charAt(i) == s.charAt(j) otherwise, dp[i][j] = Math.max(dp[i+1][j], dp[i][j-1])\nclass Solution { public int longestPalindromeSubseq(String s) { int len = s.length(); int [][] dp = new int[len][len]; for(int i = len - 1; i\u0026gt;=0; i--){ dp[i][i] = 1; for(int j = i+1; j \u0026lt; len; j++){ if(s.charAt(i) == s.charAt(j)) dp[i][j] = dp[i+1][j-1] + 2; else dp[i][j] = Math.max(dp[i+1][j], dp[i][j-1]); } } return dp[0][len-1]; } } 5. 括号匹配深度 # 爱奇艺 2018 秋招 Java: 一个合法的括号匹配序列有以下定义:\n空串\u0026quot;\u0026ldquo;是一个合法的括号匹配序列 如果\u0026quot;X\u0026quot;和\u0026quot;Y\u0026quot;都是合法的括号匹配序列,\u0026ldquo;XY\u0026quot;也是一个合法的括号匹配序列 如果\u0026quot;X\u0026quot;是一个合法的括号匹配序列,那么\u0026rdquo;(X)\u0026ldquo;也是一个合法的括号匹配序列 每个合法的括号序列都可以由以上规则生成。 例如: \u0026ldquo;\u0026rdquo;,\u0026rdquo;()\u0026rdquo;,\u0026rdquo;()()\u0026rdquo;,\u0026quot;((()))\u0026ldquo;都是合法的括号序列 对于一个合法的括号序列我们又有以下定义它的深度:\n空串\u0026quot;\u0026ldquo;的深度是 0 如果字符串\u0026quot;X\u0026quot;的深度是 x,字符串\u0026quot;Y\u0026quot;的深度是 y,那么字符串\u0026quot;XY\u0026quot;的深度为 max(x,y) 如果\u0026quot;X\u0026quot;的深度是 x,那么字符串\u0026rdquo;(X)\u0026ldquo;的深度是 x+1 例如: \u0026ldquo;()()()\u0026ldquo;的深度是 1,\u0026rdquo;((()))\u0026ldquo;的深度是 3。牛牛现在给你一个合法的括号序列,需要你计算出其深度。\n输入描述: 输入包括一个合法的括号序列s,s长度length(2 ≤ length ≤ 50),序列中只包含\u0026#39;(\u0026#39;和\u0026#39;)\u0026#39;。 输出描述: 输出一个正整数,即这个序列的深度。 示例:\n输入: (()) 输出: 2 代码如下:\nimport java.util.Scanner; /** * https://www.nowcoder.com/test/8246651/summary * * @author Snailclimb * @date 2018年9月6日 * @Description: TODO 求给定合法括号序列的深度 */ public class Main { public static void main(String[] args) { Scanner sc = new Scanner(System.in); String s = sc.nextLine(); int cnt = 0, max = 0, i; for (i = 0; i \u0026lt; s.length(); ++i) { if (s.charAt(i) == \u0026#39;(\u0026#39;) cnt++; else cnt--; max = Math.max(max, cnt); } sc.close(); System.out.println(max); } } 6. 把字符串转换成整数 # 剑指 offer: 将一个字符串转换成一个整数(实现 Integer.valueOf(string)的功能,但是 string 不符合数字要求时返回 0),要求不能使用字符串转换整数的库函数。 数值为 0 或者字符串不是一个合法的数值则返回 0。\n//https://www.weiweiblog.cn/strtoint/ public class Main { public static int StrToInt(String str) { if (str.length() == 0) return 0; char[] chars = str.toCharArray(); // 判断是否存在符号位 int flag = 0; if (chars[0] == \u0026#39;+\u0026#39;) flag = 1; else if (chars[0] == \u0026#39;-\u0026#39;) flag = 2; int start = flag \u0026gt; 0 ? 1 : 0; int res = 0;// 保存结果 for (int i = start; i \u0026lt; chars.length; i++) { if (Character.isDigit(chars[i])) {// 调用Character.isDigit(char)方法判断是否是数字,是返回True,否则False int temp = chars[i] - \u0026#39;0\u0026#39;; res = res * 10 + temp; } else { return 0; } } return flag != 2 ? res : -res; } public static void main(String[] args) { // TODO Auto-generated method stub String s = \u0026#34;-12312312\u0026#34;; System.out.println(\u0026#34;使用库函数转换:\u0026#34; + Integer.valueOf(s)); int res = Main.StrToInt(s); System.out.println(\u0026#34;使用自己写的方法转换:\u0026#34; + res); } } "},{"id":632,"href":"/zh/docs/technology/Interview/cs-basics/network/other-network-questions/","title":"计算机网络常见面试题总结(上)","section":"Network","content":"上篇主要是计算机网络基础和应用层相关的内容。\n计算机网络基础 # 网络分层模型 # OSI 七层模型是什么?每一层的作用是什么? # OSI 七层模型 是国际标准化组织提出的一个网络分层模型,其大体结构以及每一层提供的功能如下图所示:\n每一层都专注做一件事情,并且每一层都需要使用下一层提供的功能比如传输层需要使用网络层提供的路由和寻址功能,这样传输层才知道把数据传输到哪里去。\nOSI 的七层体系结构概念清楚,理论也很完整,但是它比较复杂而且不实用,而且有些功能在多个层中重复出现。\n上面这种图可能比较抽象,再来一个比较生动的图片。下面这个图片是我在国外的一个网站上看到的,非常赞!\nTCP/IP 四层模型是什么?每一层的作用是什么? # TCP/IP 四层模型 是目前被广泛采用的一种模型,我们可以将 TCP / IP 模型看作是 OSI 七层模型的精简版本,由以下 4 层组成:\n应用层 传输层 网络层 网络接口层 需要注意的是,我们并不能将 TCP/IP 四层模型 和 OSI 七层模型完全精确地匹配起来,不过可以简单将两者对应起来,如下图所示:\n关于每一层作用的详细介绍,请看 OSI 和 TCP/IP 网络分层模型详解(基础) 这篇文章。\n为什么网络要分层? # 说到分层,我们先从我们平时使用框架开发一个后台程序来说,我们往往会按照每一层做不同的事情的原则将系统分为三层(复杂的系统分层会更多):\nRepository(数据库操作) Service(业务操作) Controller(前后端数据交互) 复杂的系统需要分层,因为每一层都需要专注于一类事情。网络分层的原因也是一样,每一层只专注于做一类事情。\n好了,再来说回:“为什么网络要分层?”。我觉得主要有 3 方面的原因:\n各层之间相互独立:各层之间相互独立,各层之间不需要关心其他层是如何实现的,只需要知道自己如何调用下层提供好的功能就可以了(可以简单理解为接口调用)。这个和我们对开发时系统进行分层是一个道理。 提高了灵活性和可替换性:每一层都可以使用最适合的技术来实现,你只需要保证你提供的功能以及暴露的接口的规则没有改变就行了。并且,每一层都可以根据需要进行修改或替换,而不会影响到整个网络的结构。这个和我们平时开发系统的时候要求的高内聚、低耦合的原则也是可以对应上的。 大问题化小:分层可以将复杂的网络问题分解为许多比较小的、界线比较清晰简单的小问题来处理和解决。这样使得复杂的计算机网络系统变得易于设计,实现和标准化。 这个和我们平时开发的时候,一般会将系统功能分解,然后将复杂的问题分解为容易理解的更小的问题是相对应的,这些较小的问题具有更好的边界(目标和接口)定义。 我想到了计算机世界非常非常有名的一句话,这里分享一下:\n计算机科学领域的任何问题都可以通过增加一个间接的中间层来解决,计算机整个体系从上到下都是按照严格的层次结构设计的。\n常见网络协议 # 应用层有哪些常见的协议? # HTTP(Hypertext Transfer Protocol,超文本传输协议):基于 TCP 协议,是一种用于传输超文本和多媒体内容的协议,主要是为 Web 浏览器与 Web 服务器之间的通信而设计的。当我们使用浏览器浏览网页的时候,我们网页就是通过 HTTP 请求进行加载的。 SMTP(Simple Mail Transfer Protocol,简单邮件发送协议):基于 TCP 协议,是一种用于发送电子邮件的协议。注意 ⚠️:SMTP 协议只负责邮件的发送,而不是接收。要从邮件服务器接收邮件,需要使用 POP3 或 IMAP 协议。 POP3/IMAP(邮件接收协议):基于 TCP 协议,两者都是负责邮件接收的协议。IMAP 协议是比 POP3 更新的协议,它在功能和性能上都更加强大。IMAP 支持邮件搜索、标记、分类、归档等高级功能,而且可以在多个设备之间同步邮件状态。几乎所有现代电子邮件客户端和服务器都支持 IMAP。 FTP(File Transfer Protocol,文件传输协议) : 基于 TCP 协议,是一种用于在计算机之间传输文件的协议,可以屏蔽操作系统和文件存储方式。注意 ⚠️:FTP 是一种不安全的协议,因为它在传输过程中不会对数据进行加密。建议在传输敏感数据时使用更安全的协议,如 SFTP。 Telnet(远程登陆协议):基于 TCP 协议,用于通过一个终端登陆到其他服务器。Telnet 协议的最大缺点之一是所有数据(包括用户名和密码)均以明文形式发送,这有潜在的安全风险。这就是为什么如今很少使用 Telnet,而是使用一种称为 SSH 的非常安全的网络传输协议的主要原因。 SSH(Secure Shell Protocol,安全的网络传输协议):基于 TCP 协议,通过加密和认证机制实现安全的访问和文件传输等业务 RTP(Real-time Transport Protocol,实时传输协议):通常基于 UDP 协议,但也支持 TCP 协议。它提供了端到端的实时传输数据的功能,但不包含资源预留存、不保证实时传输质量,这些功能由 WebRTC 实现。 DNS(Domain Name System,域名管理系统): 基于 UDP 协议,用于解决域名和 IP 地址的映射问题。 关于这些协议的详细介绍请看 应用层常见协议总结(应用层) 这篇文章。\n传输层有哪些常见的协议? # TCP(Transmission Control Protocol,传输控制协议 ):提供 面向连接 的,可靠 的数据传输服务。 UDP(User Datagram Protocol,用户数据协议):提供 无连接 的,尽最大努力 的数据传输服务(不保证数据传输的可靠性),简单高效。 网络层有哪些常见的协议? # IP(Internet Protocol,网际协议):TCP/IP 协议中最重要的协议之一,属于网络层的协议,主要作用是定义数据包的格式、对数据包进行路由和寻址,以便它们可以跨网络传播并到达正确的目的地。目前 IP 协议主要分为两种,一种是过去的 IPv4,另一种是较新的 IPv6,目前这两种协议都在使用,但后者已经被提议来取代前者。 ARP(Address Resolution Protocol,地址解析协议):ARP 协议解决的是网络层地址和链路层地址之间的转换问题。因为一个 IP 数据报在物理上传输的过程中,总是需要知道下一跳(物理上的下一个目的地)该去往何处,但 IP 地址属于逻辑地址,而 MAC 地址才是物理地址,ARP 协议解决了 IP 地址转 MAC 地址的一些问题。 ICMP(Internet Control Message Protocol,互联网控制报文协议):一种用于传输网络状态和错误消息的协议,常用于网络诊断和故障排除。例如,Ping 工具就使用了 ICMP 协议来测试网络连通性。 NAT(Network Address Translation,网络地址转换协议):NAT 协议的应用场景如同它的名称——网络地址转换,应用于内部网到外部网的地址转换过程中。具体地说,在一个小的子网(局域网,LAN)内,各主机使用的是同一个 LAN 下的 IP 地址,但在该 LAN 以外,在广域网(WAN)中,需要一个统一的 IP 地址来标识该 LAN 在整个 Internet 上的位置。 OSPF(Open Shortest Path First,开放式最短路径优先):一种内部网关协议(Interior Gateway Protocol,IGP),也是广泛使用的一种动态路由协议,基于链路状态算法,考虑了链路的带宽、延迟等因素来选择最佳路径。 RIP(Routing Information Protocol,路由信息协议):一种内部网关协议(Interior Gateway Protocol,IGP),也是一种动态路由协议,基于距离向量算法,使用固定的跳数作为度量标准,选择跳数最少的路径作为最佳路径。 BGP(Border Gateway Protocol,边界网关协议):一种用来在路由选择域之间交换网络层可达性信息(Network Layer Reachability Information,NLRI)的路由选择协议,具有高度的灵活性和可扩展性。 HTTP # 从输入 URL 到页面展示到底发生了什么?(非常重要) # 类似的问题:打开一个网页,整个过程会使用哪些协议?\n先来看一张图(来源于《图解 HTTP》):\n上图有一个错误需要注意:是 OSPF 不是 OPSF。 OSPF(Open Shortest Path First,ospf)开放最短路径优先协议, 是由 Internet 工程任务组开发的路由选择协议\n总体来说分为以下几个步骤:\n在浏览器中输入指定网页的 URL。 浏览器通过 DNS 协议,获取域名对应的 IP 地址。 浏览器根据 IP 地址和端口号,向目标服务器发起一个 TCP 连接请求。 浏览器在 TCP 连接上,向服务器发送一个 HTTP 请求报文,请求获取网页的内容。 服务器收到 HTTP 请求报文后,处理请求,并返回 HTTP 响应报文给浏览器。 浏览器收到 HTTP 响应报文后,解析响应体中的 HTML 代码,渲染网页的结构和样式,同时根据 HTML 中的其他资源的 URL(如图片、CSS、JS 等),再次发起 HTTP 请求,获取这些资源的内容,直到网页完全加载显示。 浏览器在不需要和服务器通信时,可以主动关闭 TCP 连接,或者等待服务器的关闭请求。 详细介绍可以查看这篇文章: 访问网页的全过程(知识串联)(强烈推荐)。\nHTTP 状态码有哪些? # HTTP 状态码用于描述 HTTP 请求的结果,比如 2xx 就代表请求被成功处理。\n关于 HTTP 状态码更详细的总结,可以看我写的这篇文章: HTTP 常见状态码总结(应用层)。\nHTTP Header 中常见的字段有哪些? # 请求头字段名 说明 示例 Accept 能够接受的回应内容类型(Content-Types)。 Accept: text/plain Accept-Charset 能够接受的字符集 Accept-Charset: utf-8 Accept-Datetime 能够接受的按照时间来表示的版本 Accept-Datetime: Thu, 31 May 2007 20:35:00 GMT Accept-Encoding 能够接受的编码方式列表。参考 HTTP 压缩。 Accept-Encoding: gzip, deflate Accept-Language 能够接受的回应内容的自然语言列表。 Accept-Language: en-US Authorization 用于超文本传输协议的认证的认证信息 Authorization: Basic QWxhZGRpbjpvcGVuIHNlc2FtZQ== Cache-Control 用来指定在这次的请求/响应链中的所有缓存机制 都必须 遵守的指令 Cache-Control: no-cache Connection 该浏览器想要优先使用的连接类型 Connection: keep-alive Content-Length 以八位字节数组(8 位的字节)表示的请求体的长度 Content-Length: 348 Content-MD5 请求体的内容的二进制 MD5 散列值,以 Base64 编码的结果 Content-MD5: Q2hlY2sgSW50ZWdyaXR5IQ== Content-Type 请求体的多媒体类型(用于 POST 和 PUT 请求中) Content-Type: application/x-www-form-urlencoded Cookie 之前由服务器通过 Set-Cookie(下文详述)发送的一个超文本传输协议 Cookie Cookie: $Version=1; Skin=new; Date 发送该消息的日期和时间(按照 RFC 7231 中定义的\u0026quot;超文本传输协议日期\u0026quot;格式来发送) Date: Tue, 15 Nov 1994 08:12:31 GMT Expect 表明客户端要求服务器做出特定的行为 Expect: 100-continue From 发起此请求的用户的邮件地址 From: user@example.com Host 服务器的域名(用于虚拟主机),以及服务器所监听的传输控制协议端口号。如果所请求的端口是对应的服务的标准端口,则端口号可被省略。 Host: en.wikipedia.org If-Match 仅当客户端提供的实体与服务器上对应的实体相匹配时,才进行对应的操作。主要作用是用于像 PUT 这样的方法中,仅当从用户上次更新某个资源以来,该资源未被修改的情况下,才更新该资源。 If-Match: \u0026ldquo;737060cd8c284d8af7ad3082f209582d\u0026rdquo; If-Modified-Since 允许服务器在请求的资源自指定的日期以来未被修改的情况下返回 304 Not Modified 状态码 If-Modified-Since: Sat, 29 Oct 1994 19:43:31 GMT If-None-Match 允许服务器在请求的资源的 ETag 未发生变化的情况下返回 304 Not Modified 状态码 If-None-Match: \u0026ldquo;737060cd8c284d8af7ad3082f209582d\u0026rdquo; If-Range 如果该实体未被修改过,则向我发送我所缺少的那一个或多个部分;否则,发送整个新的实体 If-Range: \u0026ldquo;737060cd8c284d8af7ad3082f209582d\u0026rdquo; If-Unmodified-Since 仅当该实体自某个特定时间以来未被修改的情况下,才发送回应。 If-Unmodified-Since: Sat, 29 Oct 1994 19:43:31 GMT Max-Forwards 限制该消息可被代理及网关转发的次数。 Max-Forwards: 10 Origin 发起一个针对跨来源资源共享的请求。 Origin: http://www.example-social-network.com Pragma 与具体的实现相关,这些字段可能在请求/回应链中的任何时候产生多种效果。 Pragma: no-cache Proxy-Authorization 用来向代理进行认证的认证信息。 Proxy-Authorization: Basic QWxhZGRpbjpvcGVuIHNlc2FtZQ== Range 仅请求某个实体的一部分。字节偏移以 0 开始。参见字节服务。 Range: bytes=500-999 Referer 表示浏览器所访问的前一个页面,正是那个页面上的某个链接将浏览器带到了当前所请求的这个页面。 Referer: http://en.wikipedia.org/wiki/Main_Page TE 浏览器预期接受的传输编码方式:可使用回应协议头 Transfer-Encoding 字段中的值; TE: trailers, deflate Upgrade 要求服务器升级到另一个协议。 Upgrade: HTTP/2.0, SHTTP/1.3, IRC/6.9, RTA/x11 User-Agent 浏览器的浏览器身份标识字符串 User-Agent: Mozilla/5.0 (X11; Linux x86_64; rv:12.0) Gecko/20100101 Firefox/21.0 Via 向服务器告知,这个请求是由哪些代理发出的。 Via: 1.0 fred, 1.1 example.com (Apache/1.1) Warning 一个一般性的警告,告知,在实体内容体中可能存在错误。 Warning: 199 Miscellaneous warning HTTP 和 HTTPS 有什么区别?(重要) # 端口号:HTTP 默认是 80,HTTPS 默认是 443。 URL 前缀:HTTP 的 URL 前缀是 http://,HTTPS 的 URL 前缀是 https://。 安全性和资源消耗:HTTP 协议运行在 TCP 之上,所有传输的内容都是明文,客户端和服务器端都无法验证对方的身份。HTTPS 是运行在 SSL/TLS 之上的 HTTP 协议,SSL/TLS 运行在 TCP 之上。所有传输的内容都经过加密,加密采用对称加密,但对称加密的密钥用服务器方的证书进行了非对称加密。所以说,HTTP 安全性没有 HTTPS 高,但是 HTTPS 比 HTTP 耗费更多服务器资源。 SEO(搜索引擎优化):搜索引擎通常会更青睐使用 HTTPS 协议的网站,因为 HTTPS 能够提供更高的安全性和用户隐私保护。使用 HTTPS 协议的网站在搜索结果中可能会被优先显示,从而对 SEO 产生影响。 关于 HTTP 和 HTTPS 更详细的对比总结,可以看我写的这篇文章: HTTP vs HTTPS(应用层) 。\nHTTP/1.0 和 HTTP/1.1 有什么区别? # 连接方式 : HTTP/1.0 为短连接,HTTP/1.1 支持长连接。HTTP 协议的长连接和短连接,实质上是 TCP 协议的长连接和短连接。 状态响应码 : HTTP/1.1 中新加入了大量的状态码,光是错误响应状态码就新增了 24 种。比如说,100 (Continue)——在请求大资源前的预热请求,206 (Partial Content)——范围请求的标识码,409 (Conflict)——请求与当前资源的规定冲突,410 (Gone)——资源已被永久转移,而且没有任何已知的转发地址。 缓存机制 : 在 HTTP/1.0 中主要使用 Header 里的 If-Modified-Since,Expires 来做为缓存判断的标准,HTTP/1.1 则引入了更多的缓存控制策略例如 Entity tag,If-Unmodified-Since, If-Match, If-None-Match 等更多可供选择的缓存头来控制缓存策略。 带宽:HTTP/1.0 中,存在一些浪费带宽的现象,例如客户端只是需要某个对象的一部分,而服务器却将整个对象送过来了,并且不支持断点续传功能,HTTP/1.1 则在请求头引入了 range 头域,它允许只请求资源的某个部分,即返回码是 206(Partial Content),这样就方便了开发者自由的选择以便于充分利用带宽和连接。 Host 头(Host Header)处理 :HTTP/1.1 引入了 Host 头字段,允许在同一 IP 地址上托管多个域名,从而支持虚拟主机的功能。而 HTTP/1.0 没有 Host 头字段,无法实现虚拟主机。 关于 HTTP/1.0 和 HTTP/1.1 更详细的对比总结,可以看我写的这篇文章: HTTP/1.0 vs HTTP/1.1(应用层) 。\nHTTP/1.1 和 HTTP/2.0 有什么区别? # 多路复用(Multiplexing):HTTP/2.0 在同一连接上可以同时传输多个请求和响应(可以看作是 HTTP/1.1 中长链接的升级版本),互不干扰。HTTP/1.1 则使用串行方式,每个请求和响应都需要独立的连接,而浏览器为了控制资源会有 6-8 个 TCP 连接的限制。。这使得 HTTP/2.0 在处理多个请求时更加高效,减少了网络延迟和提高了性能。 二进制帧(Binary Frames):HTTP/2.0 使用二进制帧进行数据传输,而 HTTP/1.1 则使用文本格式的报文。二进制帧更加紧凑和高效,减少了传输的数据量和带宽消耗。 头部压缩(Header Compression):HTTP/1.1 支持Body压缩,Header不支持压缩。HTTP/2.0 支持对Header压缩,使用了专门为Header压缩而设计的 HPACK 算法,减少了网络开销。 服务器推送(Server Push):HTTP/2.0 支持服务器推送,可以在客户端请求一个资源时,将其他相关资源一并推送给客户端,从而减少了客户端的请求次数和延迟。而 HTTP/1.1 需要客户端自己发送请求来获取相关资源。 HTTP/2.0 多路复用效果图(图源: HTTP/2 For Web Developers):\n可以看到,HTTP/2.0 的多路复用使得不同的请求可以共用一个 TCP 连接,避免建立多个连接带来不必要的额外开销,而 HTTP/1.1 中的每个请求都会建立一个单独的连接\nHTTP/2.0 和 HTTP/3.0 有什么区别? # 传输协议:HTTP/2.0 是基于 TCP 协议实现的,HTTP/3.0 新增了 QUIC(Quick UDP Internet Connections) 协议来实现可靠的传输,提供与 TLS/SSL 相当的安全性,具有较低的连接和传输延迟。你可以将 QUIC 看作是 UDP 的升级版本,在其基础上新增了很多功能比如加密、重传等等。HTTP/3.0 之前名为 HTTP-over-QUIC,从这个名字中我们也可以发现,HTTP/3 最大的改造就是使用了 QUIC。 连接建立:HTTP/2.0 需要经过经典的 TCP 三次握手过程(由于安全的 HTTPS 连接建立还需要 TLS 握手,共需要大约 3 个 RTT)。由于 QUIC 协议的特性(TLS 1.3,TLS 1.3 除了支持 1 个 RTT 的握手,还支持 0 个 RTT 的握手)连接建立仅需 0-RTT 或者 1-RTT。这意味着 QUIC 在最佳情况下不需要任何的额外往返时间就可以建立新连接。 头部压缩:HTTP/2.0 使用 HPACK 算法进行头部压缩,而 HTTP/3.0 使用更高效的 QPACK 头压缩算法。 队头阻塞:HTTP/2.0 多请求复用一个 TCP 连接,一旦发生丢包,就会阻塞住所有的 HTTP 请求。由于 QUIC 协议的特性,HTTP/3.0 在一定程度上解决了队头阻塞(Head-of-Line blocking, 简写:HOL blocking)问题,一个连接建立多个不同的数据流,这些数据流之间独立互不影响,某个数据流发生丢包了,其数据流不受影响(本质上是多路复用+轮询)。 连接迁移:HTTP/3.0 支持连接迁移,因为 QUIC 使用 64 位 ID 标识连接,只要 ID 不变就不会中断,网络环境改变时(如从 Wi-Fi 切换到移动数据)也能保持连接。而 TCP 连接是由(源 IP,源端口,目的 IP,目的端口)组成,这个四元组中一旦有一项值发生改变,这个连接也就不能用了。 错误恢复:HTTP/3.0 具有更好的错误恢复机制,当出现丢包、延迟等网络问题时,可以更快地进行恢复和重传。而 HTTP/2.0 则需要依赖于 TCP 的错误恢复机制。 安全性:在 HTTP/2.0 中,TLS 用于加密和认证整个 HTTP 会话,包括所有的 HTTP 头部和数据负载。TLS 的工作是在 TCP 层之上,它加密的是在 TCP 连接中传输的应用层的数据,并不会对 TCP 头部以及 TLS 记录层头部进行加密,所以在传输的过程中 TCP 头部可能会被攻击者篡改来干扰通信。而 HTTP/3.0 的 QUIC 对整个数据包(包括报文头和报文体)进行了加密与认证处理,保障安全性。 HTTP/1.0、HTTP/2.0 和 HTTP/3.0 的协议栈比较:\n下图是一个更详细的 HTTP/2.0 和 HTTP/3.0 对比图:\n从上图可以看出:\nHTTP/2.0:使用 TCP 作为传输协议、使用 HPACK 进行头部压缩、依赖 TLS 进行加密。 HTTP/3.0:使用基于 UDP 的 QUIC 协议、使用更高效的 QPACK 进行头部压缩、在 QUIC 中直接集成了 TLS。QUIC 协议具备连接迁移、拥塞控制与避免、流量控制等特性。 关于 HTTP/1.0 -\u0026gt; HTTP/3.0 更详细的演进介绍,推荐阅读 HTTP1 到 HTTP3 的工程优化。\nHTTP 是不保存状态的协议, 如何保存用户状态? # HTTP 是一种不保存状态,即无状态(stateless)协议。也就是说 HTTP 协议自身不对请求和响应之间的通信状态进行保存。那么我们如何保存用户状态呢?Session 机制的存在就是为了解决这个问题,Session 的主要作用就是通过服务端记录用户的状态。典型的场景是购物车,当你要添加商品到购物车的时候,系统不知道是哪个用户操作的,因为 HTTP 协议是无状态的。服务端给特定的用户创建特定的 Session 之后就可以标识这个用户并且跟踪这个用户了(一般情况下,服务器会在一定时间内保存这个 Session,过了时间限制,就会销毁这个 Session)。\n在服务端保存 Session 的方法很多,最常用的就是内存和数据库(比如是使用内存数据库 redis 保存)。既然 Session 存放在服务器端,那么我们如何实现 Session 跟踪呢?大部分情况下,我们都是通过在 Cookie 中附加一个 Session ID 来方式来跟踪。\nCookie 被禁用怎么办?\n最常用的就是利用 URL 重写把 Session ID 直接附加在 URL 路径的后面。\nURI 和 URL 的区别是什么? # URI(Uniform Resource Identifier) 是统一资源标志符,可以唯一标识一个资源。 URL(Uniform Resource Locator) 是统一资源定位符,可以提供该资源的路径。它是一种具体的 URI,即 URL 可以用来标识一个资源,而且还指明了如何 locate 这个资源。 URI 的作用像身份证号一样,URL 的作用更像家庭住址一样。URL 是一种具体的 URI,它不仅唯一标识资源,而且还提供了定位该资源的信息。\nCookie 和 Session 有什么区别? # 准确点来说,这个问题属于认证授权的范畴,你可以在 认证授权基础概念详解 这篇文章中找到详细的答案。\nGET 和 POST 的区别 # 这个问题在知乎上被讨论的挺火热的,地址: https://www.zhihu.com/question/28586791 。\nGET 和 POST 是 HTTP 协议中两种常用的请求方法,它们在不同的场景和目的下有不同的特点和用法。一般来说,可以从以下几个方面来区分二者(重点搞清两者在语义上的区别即可):\n语义(主要区别):GET 通常用于获取或查询资源,而 POST 通常用于创建或修改资源。 幂等:GET 请求是幂等的,即多次重复执行不会改变资源的状态,而 POST 请求是不幂等的,即每次执行可能会产生不同的结果或影响资源的状态。 格式:GET 请求的参数通常放在 URL 中,形成查询字符串(querystring),而 POST 请求的参数通常放在请求体(body)中,可以有多种编码格式,如 application/x-www-form-urlencoded、multipart/form-data、application/json 等。GET 请求的 URL 长度受到浏览器和服务器的限制,而 POST 请求的 body 大小则没有明确的限制。不过,实际上 GET 请求也可以用 body 传输数据,只是并不推荐这样做,因为这样可能会导致一些兼容性或者语义上的问题。 缓存:由于 GET 请求是幂等的,它可以被浏览器或其他中间节点(如代理、网关)缓存起来,以提高性能和效率。而 POST 请求则不适合被缓存,因为它可能有副作用,每次执行可能需要实时的响应。 安全性:GET 请求和 POST 请求如果使用 HTTP 协议的话,那都不安全,因为 HTTP 协议本身是明文传输的,必须使用 HTTPS 协议来加密传输数据。另外,GET 请求相比 POST 请求更容易泄露敏感数据,因为 GET 请求的参数通常放在 URL 中。 再次提示,重点搞清两者在语义上的区别即可,实际使用过程中,也是通过语义来区分使用 GET 还是 POST。不过,也有一些项目所有的请求都用 POST,这个并不是固定的,项目组达成共识即可。\nWebSocket # 什么是 WebSocket? # WebSocket 是一种基于 TCP 连接的全双工通信协议,即客户端和服务器可以同时发送和接收数据。\nWebSocket 协议在 2008 年诞生,2011 年成为国际标准,几乎所有主流较新版本的浏览器都支持该协议。不过,WebSocket 不只能在基于浏览器的应用程序中使用,很多编程语言、框架和服务器都提供了 WebSocket 支持。\nWebSocket 协议本质上是应用层的协议,用于弥补 HTTP 协议在持久通信能力上的不足。客户端和服务器仅需一次握手,两者之间就直接可以创建持久性的连接,并进行双向数据传输。\n下面是 WebSocket 的常见应用场景:\n视频弹幕 实时消息推送,详见 Web 实时消息推送详解这篇文章 实时游戏对战 多用户协同编辑 社交聊天 …… WebSocket 和 HTTP 有什么区别? # WebSocket 和 HTTP 两者都是基于 TCP 的应用层协议,都可以在网络中传输数据。\n下面是二者的主要区别:\nWebSocket 是一种双向实时通信协议,而 HTTP 是一种单向通信协议。并且,HTTP 协议下的通信只能由客户端发起,服务器无法主动通知客户端。 WebSocket 使用 ws:// 或 wss://(使用 SSL/TLS 加密后的协议,类似于 HTTP 和 HTTPS 的关系) 作为协议前缀,HTTP 使用 http:// 或 https:// 作为协议前缀。 WebSocket 可以支持扩展,用户可以扩展协议,实现部分自定义的子协议,如支持压缩、加密等。 WebSocket 通信数据格式比较轻量,用于协议控制的数据包头部相对较小,网络开销小,而 HTTP 通信每次都要携带完整的头部,网络开销较大(HTTP/2.0 使用二进制帧进行数据传输,还支持头部压缩,减少了网络开销)。 WebSocket 的工作过程是什么样的? # WebSocket 的工作过程可以分为以下几个步骤:\n客户端向服务器发送一个 HTTP 请求,请求头中包含 Upgrade: websocket 和 Sec-WebSocket-Key 等字段,表示要求升级协议为 WebSocket; 服务器收到这个请求后,会进行升级协议的操作,如果支持 WebSocket,它将回复一个 HTTP 101 状态码,响应头中包含 ,Connection: Upgrade和 Sec-WebSocket-Accept: xxx 等字段、表示成功升级到 WebSocket 协议。 客户端和服务器之间建立了一个 WebSocket 连接,可以进行双向的数据传输。数据以帧(frames)的形式进行传送,WebSocket 的每条消息可能会被切分成多个数据帧(最小单位)。发送端会将消息切割成多个帧发送给接收端,接收端接收消息帧,并将关联的帧重新组装成完整的消息。 客户端或服务器可以主动发送一个关闭帧,表示要断开连接。另一方收到后,也会回复一个关闭帧,然后双方关闭 TCP 连接。 另外,建立 WebSocket 连接之后,通过心跳机制来保持 WebSocket 连接的稳定性和活跃性。\nSSE 与 WebSocket 有什么区别? # 摘自 Web 实时消息推送详解。\nSSE 与 WebSocket 作用相似,都可以建立服务端与浏览器之间的通信,实现服务端向客户端推送消息,但还是有些许不同:\nSSE 是基于 HTTP 协议的,它们不需要特殊的协议或服务器实现即可工作;WebSocket 需单独服务器来处理协议。 SSE 单向通信,只能由服务端向客户端单向通信;WebSocket 全双工通信,即通信的双方可以同时发送和接受信息。 SSE 实现简单开发成本低,无需引入其他组件;WebSocket 传输数据需做二次解析,开发门槛高一些。 SSE 默认支持断线重连;WebSocket 则需要自己实现。 SSE 只能传送文本消息,二进制数据需要经过编码后传送;WebSocket 默认支持传送二进制数据。 SSE 与 WebSocket 该如何选择?\nSSE 好像一直不被大家所熟知,一部分原因是出现了 WebSocket,这个提供了更丰富的协议来执行双向、全双工通信。对于游戏、即时通信以及需要双向近乎实时更新的场景,拥有双向通道更具吸引力。\n但是,在某些情况下,不需要从客户端发送数据。而你只需要一些服务器操作的更新。比如:站内信、未读消息数、状态更新、股票行情、监控数量等场景,SSE 不管是从实现的难易和成本上都更加有优势。此外,SSE 具有 WebSocket 在设计上缺乏的多种功能,例如:自动重新连接、事件 ID 和发送任意事件的能力。\nPING # PING 命令的作用是什么? # PING 命令是一种常用的网络诊断工具,经常用来测试网络中主机之间的连通性和网络延迟。\n这里简单举一个例子,我们来 PING 一下百度。\n# 发送4个PING请求数据包到 www.baidu.com ❯ ping -c 4 www.baidu.com PING www.a.shifen.com (14.119.104.189): 56 data bytes 64 bytes from 14.119.104.189: icmp_seq=0 ttl=54 time=27.867 ms 64 bytes from 14.119.104.189: icmp_seq=1 ttl=54 time=28.732 ms 64 bytes from 14.119.104.189: icmp_seq=2 ttl=54 time=27.571 ms 64 bytes from 14.119.104.189: icmp_seq=3 ttl=54 time=27.581 ms --- www.a.shifen.com ping statistics --- 4 packets transmitted, 4 packets received, 0.0% packet loss round-trip min/avg/max/stddev = 27.571/27.938/28.732/0.474 ms PING 命令的输出结果通常包括以下几部分信息:\nICMP Echo Request(请求报文)信息:序列号、TTL(Time to Live)值。 目标主机的域名或 IP 地址:输出结果的第一行。 往返时间(RTT,Round-Trip Time):从发送 ICMP Echo Request(请求报文)到接收到 ICMP Echo Reply(响应报文)的总时间,用来衡量网络连接的延迟。 统计结果(Statistics):包括发送的 ICMP 请求数据包数量、接收到的 ICMP 响应数据包数量、丢包率、往返时间(RTT)的最小、平均、最大和标准偏差值。 如果 PING 对应的目标主机无法得到正确的响应,则表明这两个主机之间的连通性存在问题(有些主机或网络管理员可能禁用了对 ICMP 请求的回复,这样也会导致无法得到正确的响应)。如果往返时间(RTT)过高,则表明网络延迟过高。\nPING 命令的工作原理是什么? # PING 基于网络层的 ICMP(Internet Control Message Protocol,互联网控制报文协议),其主要原理就是通过在网络上发送和接收 ICMP 报文实现的。\nICMP 报文中包含了类型字段,用于标识 ICMP 报文类型。ICMP 报文的类型有很多种,但大致可以分为两类:\n查询报文类型:向目标主机发送请求并期望得到响应。 差错报文类型:向源主机发送错误信息,用于报告网络中的错误情况。 PING 用到的 ICMP Echo Request(类型为 8 ) 和 ICMP Echo Reply(类型为 0) 属于查询报文类型 。\nPING 命令会向目标主机发送 ICMP Echo Request。 如果两个主机的连通性正常,目标主机会返回一个对应的 ICMP Echo Reply。 DNS # DNS 的作用是什么? # DNS(Domain Name System)域名管理系统,是当用户使用浏览器访问网址之后,使用的第一个重要协议。DNS 要解决的是域名和 IP 地址的映射问题。\n在一台电脑上,可能存在浏览器 DNS 缓存,操作系统 DNS 缓存,路由器 DNS 缓存。如果以上缓存都查询不到,那么 DNS 就闪亮登场了。\n目前 DNS 的设计采用的是分布式、层次数据库结构,DNS 是应用层协议,它可以在 UDP 或 TCP 协议之上运行,端口为 53 。\nDNS 服务器有哪些?根服务器有多少个? # DNS 服务器自底向上可以依次分为以下几个层级(所有 DNS 服务器都属于以下四个类别之一):\n根 DNS 服务器。根 DNS 服务器提供 TLD 服务器的 IP 地址。目前世界上只有 13 组根服务器,我国境内目前仍没有根服务器。 顶级域 DNS 服务器(TLD 服务器)。顶级域是指域名的后缀,如com、org、net和edu等。国家也有自己的顶级域,如uk、fr和ca。TLD 服务器提供了权威 DNS 服务器的 IP 地址。 权威 DNS 服务器。在因特网上具有公共可访问主机的每个组织机构必须提供公共可访问的 DNS 记录,这些记录将这些主机的名字映射为 IP 地址。 本地 DNS 服务器。每个 ISP(互联网服务提供商)都有一个自己的本地 DNS 服务器。当主机发出 DNS 请求时,该请求被发往本地 DNS 服务器,它起着代理的作用,并将该请求转发到 DNS 层次结构中。严格说来,不属于 DNS 层级结构 世界上并不是只有 13 台根服务器,这是很多人普遍的误解,网上很多文章也是这么写的。实际上,现在根服务器数量远远超过这个数量。最初确实是为 DNS 根服务器分配了 13 个 IP 地址,每个 IP 地址对应一个不同的根 DNS 服务器。然而,由于互联网的快速发展和增长,这个原始的架构变得不太适应当前的需求。为了提高 DNS 的可靠性、安全性和性能,目前这 13 个 IP 地址中的每一个都有多个服务器,截止到 2023 年底,所有根服务器之和达到了 1700 多台,未来还会继续增加。\nDNS 解析的过程是什么样的? # 整个过程的步骤比较多,我单独写了一篇文章详细介绍: DNS 域名系统详解(应用层) 。\nDNS 劫持了解吗?如何应对? # DNS 劫持是一种网络攻击,它通过修改 DNS 服务器的解析结果,使用户访问的域名指向错误的 IP 地址,从而导致用户无法访问正常的网站,或者被引导到恶意的网站。DNS 劫持有时也被称为 DNS 重定向、DNS 欺骗或 DNS 污染。\n参考 # 《图解 HTTP》 《计算机网络自顶向下方法》(第七版) 详解 HTTP/2.0 及 HTTPS 协议: https://juejin.cn/post/7034668672262242318 HTTP 请求头字段大全| HTTP Request Headers: https://www.flysnow.org/tools/table/http-request-headers/ HTTP1、HTTP2、HTTP3: https://juejin.cn/post/6855470356657307662 如何看待 HTTP/3 ? - 车小胖的回答 - 知乎: https://www.zhihu.com/question/302412059/answer/533223530 "},{"id":633,"href":"/zh/docs/technology/Interview/cs-basics/network/other-network-questions2/","title":"计算机网络常见面试题总结(下)","section":"Network","content":"下篇主要是传输层和网络层相关的内容。\nTCP 与 UDP # TCP 与 UDP 的区别(重要) # 是否面向连接:UDP 在传送数据之前不需要先建立连接。而 TCP 提供面向连接的服务,在传送数据之前必须先建立连接,数据传送结束后要释放连接。 是否是可靠传输:远地主机在收到 UDP 报文后,不需要给出任何确认,并且不保证数据不丢失,不保证是否顺序到达。TCP 提供可靠的传输服务,TCP 在传递数据之前,会有三次握手来建立连接,而且在数据传递时,有确认、窗口、重传、拥塞控制机制。通过 TCP 连接传输的数据,无差错、不丢失、不重复、并且按序到达。 是否有状态:这个和上面的“是否可靠传输”相对应。TCP 传输是有状态的,这个有状态说的是 TCP 会去记录自己发送消息的状态比如消息是否发送了、是否被接收了等等。为此 ,TCP 需要维持复杂的连接状态表。而 UDP 是无状态服务,简单来说就是不管发出去之后的事情了(这很渣男!)。 传输效率:由于使用 TCP 进行传输的时候多了连接、确认、重传等机制,所以 TCP 的传输效率要比 UDP 低很多。 传输形式:TCP 是面向字节流的,UDP 是面向报文的。 首部开销:TCP 首部开销(20 ~ 60 字节)比 UDP 首部开销(8 字节)要大。 是否提供广播或多播服务:TCP 只支持点对点通信,UDP 支持一对一、一对多、多对一、多对多; …… 我把上面总结的内容通过表格形式展示出来了!确定不点个赞嘛?\nTCP UDP 是否面向连接 是 否 是否可靠 是 否 是否有状态 是 否 传输效率 较慢 较快 传输形式 字节流 数据报文段 首部开销 20 ~ 60 bytes 8 bytes 是否提供广播或多播服务 否 是 什么时候选择 TCP,什么时候选 UDP? # UDP 一般用于即时通信,比如:语音、 视频、直播等等。这些场景对传输数据的准确性要求不是特别高,比如你看视频即使少个一两帧,实际给人的感觉区别也不大。 TCP 用于对传输准确性要求特别高的场景,比如文件传输、发送和接收邮件、远程登录等等。 HTTP 基于 TCP 还是 UDP? # HTTP 协议是基于 TCP 协议的,所以发送 HTTP 请求之前首先要建立 TCP 连接也就是要经历 3 次握手。\n🐛 修正(参见 issue#1915):\nHTTP/3.0 之前是基于 TCP 协议的,而 HTTP/3.0 将弃用 TCP,改用 基于 UDP 的 QUIC 协议 。\n此变化解决了 HTTP/2 中存在的队头阻塞问题。队头阻塞是指在 HTTP/2.0 中,多个 HTTP 请求和响应共享一个 TCP 连接,如果其中一个请求或响应因为网络拥塞或丢包而被阻塞,那么后续的请求或响应也无法发送,导致整个连接的效率降低。这是由于 HTTP/2.0 在单个 TCP 连接上使用了多路复用,受到 TCP 拥塞控制的影响,少量的丢包就可能导致整个 TCP 连接上的所有流被阻塞。HTTP/3.0 在一定程度上解决了队头阻塞问题,一个连接建立多个不同的数据流,这些数据流之间独立互不影响,某个数据流发生丢包了,其数据流不受影响(本质上是多路复用+轮询)。\n除了解决队头阻塞问题,HTTP/3.0 还可以减少握手过程的延迟。在 HTTP/2.0 中,如果要建立一个安全的 HTTPS 连接,需要经过 TCP 三次握手和 TLS 握手:\nTCP 三次握手:客户端和服务器交换 SYN 和 ACK 包,建立一个 TCP 连接。这个过程需要 1.5 个 RTT(round-trip time),即一个数据包从发送到接收的时间。 TLS 握手:客户端和服务器交换密钥和证书,建立一个 TLS 加密层。这个过程需要至少 1 个 RTT(TLS 1.3)或者 2 个 RTT(TLS 1.2)。 所以,HTTP/2.0 的连接建立就至少需要 2.5 个 RTT(TLS 1.3)或者 3.5 个 RTT(TLS 1.2)。而在 HTTP/3.0 中,使用的 QUIC 协议(TLS 1.3,TLS 1.3 除了支持 1 个 RTT 的握手,还支持 0 个 RTT 的握手)连接建立仅需 0-RTT 或者 1-RTT。这意味着 QUIC 在最佳情况下不需要任何的额外往返时间就可以建立新连接。\n相关证明可以参考下面这两个链接:\nhttps://zh.wikipedia.org/zh/HTTP/3 https://datatracker.ietf.org/doc/rfc9114/ 使用 TCP 的协议有哪些?使用 UDP 的协议有哪些? # 运行于 TCP 协议之上的协议:\nHTTP 协议(HTTP/3.0 之前):超文本传输协议(HTTP,HyperText Transfer Protocol)是一种用于传输超文本和多媒体内容的协议,主要是为 Web 浏览器与 Web 服务器之间的通信而设计的。当我们使用浏览器浏览网页的时候,我们网页就是通过 HTTP 请求进行加载的。 HTTPS 协议:更安全的超文本传输协议(HTTPS,Hypertext Transfer Protocol Secure),身披 SSL 外衣的 HTTP 协议 FTP 协议:文件传输协议 FTP(File Transfer Protocol)是一种用于在计算机之间传输文件的协议,可以屏蔽操作系统和文件存储方式。注意 ⚠️:FTP 是一种不安全的协议,因为它在传输过程中不会对数据进行加密。建议在传输敏感数据时使用更安全的协议,如 SFTP。 SMTP 协议:简单邮件传输协议(SMTP,Simple Mail Transfer Protocol)的缩写,是一种用于发送电子邮件的协议。注意 ⚠️:SMTP 协议只负责邮件的发送,而不是接收。要从邮件服务器接收邮件,需要使用 POP3 或 IMAP 协议。 POP3/IMAP 协议:两者都是负责邮件接收的协议。IMAP 协议是比 POP3 更新的协议,它在功能和性能上都更加强大。IMAP 支持邮件搜索、标记、分类、归档等高级功能,而且可以在多个设备之间同步邮件状态。几乎所有现代电子邮件客户端和服务器都支持 IMAP。 Telnet 协议:用于通过一个终端登陆到其他服务器。Telnet 协议的最大缺点之一是所有数据(包括用户名和密码)均以明文形式发送,这有潜在的安全风险。这就是为什么如今很少使用 Telnet,而是使用一种称为 SSH 的非常安全的网络传输协议的主要原因。 SSH 协议 : SSH( Secure Shell)是目前较可靠,专为远程登录会话和其他网络服务提供安全性的协议。利用 SSH 协议可以有效防止远程管理过程中的信息泄露问题。SSH 建立在可靠的传输协议 TCP 之上。 …… 运行于 UDP 协议之上的协议:\nHTTP 协议(HTTP/3.0 ): HTTP/3.0 弃用 TCP,改用基于 UDP 的 QUIC 协议 。 DHCP 协议:动态主机配置协议,动态配置 IP 地址 DNS:域名系统(DNS,Domain Name System)将人类可读的域名 (例如,www.baidu.com) 转换为机器可读的 IP 地址 (例如,220.181.38.148)。 我们可以将其理解为专为互联网设计的电话薄。实际上,DNS 同时支持 UDP 和 TCP 协议。 …… TCP 三次握手和四次挥手(非常重要) # 相关面试题:\n为什么要三次握手? 第 2 次握手传回了 ACK,为什么还要传回 SYN? 为什么要四次挥手? 为什么不能把服务器发送的 ACK 和 FIN 合并起来,变成三次挥手? 如果第二次挥手时服务器的 ACK 没有送达客户端,会怎样? 为什么第四次挥手客户端需要等待 2*MSL(报文段最长寿命)时间后才进入 CLOSED 状态? 参考答案: TCP 三次握手和四次挥手(传输层) 。\nTCP 如何保证传输的可靠性?(重要) # TCP 传输可靠性保障(传输层)\nIP # IP 协议的作用是什么? # IP(Internet Protocol,网际协议) 是 TCP/IP 协议中最重要的协议之一,属于网络层的协议,主要作用是定义数据包的格式、对数据包进行路由和寻址,以便它们可以跨网络传播并到达正确的目的地。\n目前 IP 协议主要分为两种,一种是过去的 IPv4,另一种是较新的 IPv6,目前这两种协议都在使用,但后者已经被提议来取代前者。\n什么是 IP 地址?IP 寻址如何工作? # 每个连入互联网的设备或域(如计算机、服务器、路由器等)都被分配一个 IP 地址(Internet Protocol address),作为唯一标识符。每个 IP 地址都是一个字符序列,如 192.168.1.1(IPv4)、2001:0db8:85a3:0000:0000:8a2e:0370:7334(IPv6) 。\n当网络设备发送 IP 数据包时,数据包中包含了 源 IP 地址 和 目的 IP 地址 。源 IP 地址用于标识数据包的发送方设备或域,而目的 IP 地址则用于标识数据包的接收方设备或域。这类似于一封邮件中同时包含了目的地地址和回邮地址。\n网络设备根据目的 IP 地址来判断数据包的目的地,并将数据包转发到正确的目的地网络或子网络,从而实现了设备间的通信。\n这种基于 IP 地址的寻址方式是互联网通信的基础,它允许数据包在不同的网络之间传递,从而实现了全球范围内的网络互联互通。IP 地址的唯一性和全局性保证了网络中的每个设备都可以通过其独特的 IP 地址进行标识和寻址。\n什么是 IP 地址过滤? # IP 地址过滤(IP Address Filtering) 简单来说就是限制或阻止特定 IP 地址或 IP 地址范围的访问。例如,你有一个图片服务突然被某一个 IP 地址攻击,那我们就可以禁止这个 IP 地址访问图片服务。\nIP 地址过滤是一种简单的网络安全措施,实际应用中一般会结合其他网络安全措施,如认证、授权、加密等一起使用。单独使用 IP 地址过滤并不能完全保证网络的安全。\nIPv4 和 IPv6 有什么区别? # IPv4(Internet Protocol version 4) 是目前广泛使用的 IP 地址版本,其格式是四组由点分隔的数字,例如:123.89.46.72。IPv4 使用 32 位地址作为其 Internet 地址,这意味着共有约 42 亿( 2^32)个可用 IP 地址。\n这么少当然不够用啦!为了解决 IP 地址耗尽的问题,最根本的办法是采用具有更大地址空间的新版本 IP 协议 - IPv6(Internet Protocol version 6)。IPv6 地址使用更复杂的格式,该格式使用由单或双冒号分隔的一组数字和字母,例如:2001:0db8:85a3:0000:0000:8a2e:0370:7334 。IPv6 使用 128 位互联网地址,这意味着越有 2^128(3 开头的 39 位数字,恐怖如斯) 个可用 IP 地址。\n除了更大的地址空间之外,IPv6 的优势还包括:\n无状态地址自动配置(Stateless Address Autoconfiguration,简称 SLAAC):主机可以直接通过根据接口标识和网络前缀生成全局唯一的 IPv6 地址,而无需依赖 DHCP(Dynamic Host Configuration Protocol)服务器,简化了网络配置和管理。 NAT(Network Address Translation,网络地址转换) 成为可选项:IPv6 地址资源充足,可以给全球每个设备一个独立的地址。 对标头结构进行了改进:IPv6 标头结构相较于 IPv4 更加简化和高效,减少了处理开销,提高了网络性能。 可选的扩展头:允许在 IPv6 标头中添加不同的扩展头(Extension Headers),用于实现不同类型的功能和选项。 ICMPv6(Internet Control Message Protocol for IPv6):IPv6 中的 ICMPv6 相较于 IPv4 中的 ICMP 有了一些改进,如邻居发现、路径 MTU 发现等功能的改进,从而提升了网络的可靠性和性能。 …… 如何获取客户端真实 IP? # 获取客户端真实 IP 的方法有多种,主要分为应用层方法、传输层方法和网络层方法。\n应用层方法 :\n通过 X-Forwarded-For 请求头获取,简单方便。不过,这种方法无法保证获取到的是真实 IP,这是因为 X-Forwarded-For 字段可能会被伪造。如果经过多个代理服务器,X-Forwarded-For 字段可能会有多个值(附带了整个请求链中的所有代理服务器 IP 地址)。并且,这种方法只适用于 HTTP 和 SMTP 协议。\n传输层方法:\n利用 TCP Options 字段承载真实源 IP 信息。这种方法适用于任何基于 TCP 的协议,不受应用层的限制。不过,这并非是 TCP 标准所支持的,所以需要通信双方都进行改造。也就是:对于发送方来说,需要有能力把真实源 IP 插入到 TCP Options 里面。对于接收方来说,需要有能力把 TCP Options 里面的 IP 地址读取出来。\n也可以通过 Proxy Protocol 协议来传递客户端 IP 和 Port 信息。这种方法可以利用 Nginx 或者其他支持该协议的反向代理服务器来获取真实 IP 或者在业务服务器解析真实 IP。\n网络层方法:\n隧道 +DSR 模式。这种方法可以适用于任何协议,就是实施起来会比较麻烦,也存在一定限制,实际应用中一般不会使用这种方法。\nNAT 的作用是什么? # NAT(Network Address Translation,网络地址转换) 主要用于在不同网络之间转换 IP 地址。它允许将私有 IP 地址(如在局域网中使用的 IP 地址)映射为公有 IP 地址(在互联网中使用的 IP 地址)或者反向映射,从而实现局域网内的多个设备通过单一公有 IP 地址访问互联网。\nNAT 不光可以缓解 IPv4 地址资源短缺的问题,还可以隐藏内部网络的实际拓扑结构,使得外部网络无法直接访问内部网络中的设备,从而提高了内部网络的安全性。\n相关阅读: NAT 协议详解(网络层)。\nARP # 什么是 Mac 地址? # MAC 地址的全称是 媒体访问控制地址(Media Access Control Address)。如果说,互联网中每一个资源都由 IP 地址唯一标识(IP 协议内容),那么一切网络设备都由 MAC 地址唯一标识。\n可以理解为,MAC 地址是一个网络设备真正的身份证号,IP 地址只是一种不重复的定位方式(比如说住在某省某市某街道的张三,这种逻辑定位是 IP 地址,他的身份证号才是他的 MAC 地址),也可以理解为 MAC 地址是身份证号,IP 地址是邮政地址。MAC 地址也有一些别称,如 LAN 地址、物理地址、以太网地址等。\n还有一点要知道的是,不仅仅是网络资源才有 IP 地址,网络设备也有 IP 地址,比如路由器。但从结构上说,路由器等网络设备的作用是组成一个网络,而且通常是内网,所以它们使用的 IP 地址通常是内网 IP,内网的设备在与内网以外的设备进行通信时,需要用到 NAT 协议。\nMAC 地址的长度为 6 字节(48 比特),地址空间大小有 280 万亿之多( $2^{48}$ ),MAC 地址由 IEEE 统一管理与分配,理论上,一个网络设备中的网卡上的 MAC 地址是永久的。不同的网卡生产商从 IEEE 那里购买自己的 MAC 地址空间(MAC 的前 24 比特),也就是前 24 比特由 IEEE 统一管理,保证不会重复。而后 24 比特,由各家生产商自己管理,同样保证生产的两块网卡的 MAC 地址不会重复。\nMAC 地址具有可携带性、永久性,身份证号永久地标识一个人的身份,不论他到哪里都不会改变。而 IP 地址不具有这些性质,当一台设备更换了网络,它的 IP 地址也就可能发生改变,也就是它在互联网中的定位发生了变化。\n最后,记住,MAC 地址有一个特殊地址:FF-FF-FF-FF-FF-FF(全 1 地址),该地址表示广播地址。\nARP 协议解决了什么问题? # ARP 协议,全称 地址解析协议(Address Resolution Protocol),它解决的是网络层地址和链路层地址之间的转换问题。因为一个 IP 数据报在物理上传输的过程中,总是需要知道下一跳(物理上的下一个目的地)该去往何处,但 IP 地址属于逻辑地址,而 MAC 地址才是物理地址,ARP 协议解决了 IP 地址转 MAC 地址的一些问题。\nARP 协议的工作原理? # ARP 协议详解(网络层)\n复习建议 # 非常推荐大家看一下 《图解 HTTP》 这本书,这本书页数不多,但是内容很是充实,不管是用来系统的掌握网络方面的一些知识还是说纯粹为了应付面试都有很大帮助。下面的一些文章只是参考。大二学习这门课程的时候,我们使用的教材是 《计算机网络第七版》(谢希仁编著),不推荐大家看这本教材,书非常厚而且知识偏理论,不确定大家能不能心平气和的读完。\n参考 # 《图解 HTTP》 《计算机网络自顶向下方法》(第七版) 什么是 Internet 协议(IP)?: https://www.cloudflare.com/zh-cn/learning/network-layer/internet-protocol/ 透传真实源 IP 的各种方法 - 极客时间: https://time.geekbang.org/column/article/497864 What Is NAT and What Are the Benefits of NAT Firewalls?: https://community.fs.com/blog/what-is-nat-and-what-are-the-benefits-of-nat-firewalls.html "},{"id":634,"href":"/zh/docs/technology/Interview/cs-basics/algorithms/the-sword-refers-to-offer/","title":"剑指offer部分编程题","section":"Algorithms","content":" 斐波那契数列 # 题目描述:\n大家都知道斐波那契数列,现在要求输入一个整数 n,请你输出斐波那契数列的第 n 项。 n\u0026lt;=39\n问题分析:\n可以肯定的是这一题通过递归的方式是肯定能做出来,但是这样会有一个很大的问题,那就是递归大量的重复计算会导致内存溢出。另外可以使用迭代法,用 fn1 和 fn2 保存计算过程中的结果,并复用起来。下面我会把两个方法示例代码都给出来并给出两个方法的运行时间对比。\n示例代码:\n采用迭代法:\nint Fibonacci(int number) { if (number \u0026lt;= 0) { return 0; } if (number == 1 || number == 2) { return 1; } int first = 1, second = 1, third = 0; for (int i = 3; i \u0026lt;= number; i++) { third = first + second; first = second; second = third; } return third; } 采用递归:\npublic int Fibonacci(int n) { if (n \u0026lt;= 0) { return 0; } if (n == 1||n==2) { return 1; } return Fibonacci(n - 2) + Fibonacci(n - 1); } 跳台阶问题 # 题目描述:\n一只青蛙一次可以跳上 1 级台阶,也可以跳上 2 级。求该青蛙跳上一个 n 级的台阶总共有多少种跳法。\n问题分析:\n正常分析法:\na.如果两种跳法,1 阶或者 2 阶,那么假定第一次跳的是一阶,那么剩下的是 n-1 个台阶,跳法是 f(n-1); b.假定第一次跳的是 2 阶,那么剩下的是 n-2 个台阶,跳法是 f(n-2) c.由 a,b 假设可以得出总跳法为: f(n) = f(n-1) + f(n-2) d.然后通过实际的情况可以得出:只有一阶的时候 f(1) = 1 ,只有两阶的时候可以有 f(2) = 2\n找规律分析法:\nf(1) = 1, f(2) = 2, f(3) = 3, f(4) = 5, 可以总结出 f(n) = f(n-1) + f(n-2)的规律。但是为什么会出现这样的规律呢?假设现在 6 个台阶,我们可以从第 5 跳一步到 6,这样的话有多少种方案跳到 5 就有多少种方案跳到 6,另外我们也可以从 4 跳两步跳到 6,跳到 4 有多少种方案的话,就有多少种方案跳到 6,其他的不能从 3 跳到 6 什么的啦,所以最后就是 f(6) = f(5) + f(4);这样子也很好理解变态跳台阶的问题了。\n所以这道题其实就是斐波那契数列的问题。\n代码只需要在上一题的代码稍做修改即可。和上一题唯一不同的就是这一题的初始元素变为 1 2 3 5 8……而上一题为 1 1 2 3 5 ……。另外这一题也可以用递归做,但是递归效率太低,所以我这里只给出了迭代方式的代码。\n示例代码:\nint jumpFloor(int number) { if (number \u0026lt;= 0) { return 0; } if (number == 1) { return 1; } if (number == 2) { return 2; } int first = 1, second = 2, third = 0; for (int i = 3; i \u0026lt;= number; i++) { third = first + second; first = second; second = third; } return third; } 变态跳台阶问题 # 题目描述:\n一只青蛙一次可以跳上 1 级台阶,也可以跳上 2 级……它也可以跳上 n 级。求该青蛙跳上一个 n 级的台阶总共有多少种跳法。\n问题分析:\n假设 n\u0026gt;=2,第一步有 n 种跳法:跳 1 级、跳 2 级、到跳 n 级 跳 1 级,剩下 n-1 级,则剩下跳法是 f(n-1) 跳 2 级,剩下 n-2 级,则剩下跳法是 f(n-2) …… 跳 n-1 级,剩下 1 级,则剩下跳法是 f(1) 跳 n 级,剩下 0 级,则剩下跳法是 f(0) 所以在 n\u0026gt;=2 的情况下: f(n)=f(n-1)+f(n-2)+\u0026hellip;+f(1) 因为 f(n-1)=f(n-2)+f(n-3)+\u0026hellip;+f(1) 所以 f(n)=2*f(n-1) 又 f(1)=1,所以可得f(n)=2^(number-1)\n示例代码:\nint JumpFloorII(int number) { return 1 \u0026lt;\u0026lt; --number;//2^(number-1)用位移操作进行,更快 } 补充:\njava 中有三种移位运算符:\n“\u0026laquo;” : 左移运算符,等同于乘 2 的 n 次方 “\u0026raquo;”: 右移运算符,等同于除 2 的 n 次方 “\u0026raquo;\u0026gt;” : 无符号右移运算符,不管移动前最高位是 0 还是 1,右移后左侧产生的空位部分都以 0 来填充。与\u0026raquo;类似。 int a = 16; int b = a \u0026lt;\u0026lt; 2;//左移2,等同于16 * 2的2次方,也就是16 * 4 int c = a \u0026gt;\u0026gt; 2;//右移2,等同于16 / 2的2次方,也就是16 / 4 二维数组查找 # 题目描述:\n在一个二维数组中,每一行都按照从左到右递增的顺序排序,每一列都按照从上到下递增的顺序排序。请完成一个函数,输入这样的一个二维数组和一个整数,判断数组中是否含有该整数。\n问题解析:\n这一道题还是比较简单的,我们需要考虑的是如何做,效率最快。这里有一种很好理解的思路:\n矩阵是有序的,从左下角来看,向上数字递减,向右数字递增, 因此从左下角开始查找,当要查找数字比左下角数字大时。右移 要查找数字比左下角数字小时,上移。这样找的速度最快。\n示例代码:\npublic boolean Find(int target, int [][] array) { //基本思路从左下角开始找,这样速度最快 int row = array.length-1;//行 int column = 0;//列 //当行数大于0,当前列数小于总列数时循环条件成立 while((row \u0026gt;= 0)\u0026amp;\u0026amp; (column\u0026lt; array[0].length)){ if(array[row][column] \u0026gt; target){ row--; }else if(array[row][column] \u0026lt; target){ column++; }else{ return true; } } return false; } 替换空格 # 题目描述:\n请实现一个函数,将一个字符串中的空格替换成“%20”。例如,当字符串为 We Are Happy.则经过替换之后的字符串为 We%20Are%20Happy。\n问题分析:\n这道题不难,我们可以通过循环判断字符串的字符是否为空格,是的话就利用 append()方法添加追加“%20”,否则还是追加原字符。\n或者最简单的方法就是利用:replaceAll(String regex,String replacement)方法了,一行代码就可以解决。\n示例代码:\n常规做法:\npublic String replaceSpace(StringBuffer str) { StringBuffer out = new StringBuffer(); for (int i = 0; i \u0026lt; str.toString().length(); i++) { char b = str.charAt(i); if(String.valueOf(b).equals(\u0026#34; \u0026#34;)){ out.append(\u0026#34;%20\u0026#34;); }else{ out.append(b); } } return out.toString(); } 一行代码解决:\npublic String replaceSpace(StringBuffer str) { //return str.toString().replaceAll(\u0026#34; \u0026#34;, \u0026#34;%20\u0026#34;); //public String replaceAll(String regex,String replacement) //用给定的替换替换与给定的regular expression匹配的此字符串的每个子字符串。 //\\ 转义字符. 如果你要使用 \u0026#34;\\\u0026#34; 本身, 则应该使用 \u0026#34;\\\\\u0026#34;. String类型中的空格用“\\s”表示,所以我这里猜测\u0026#34;\\\\s\u0026#34;就是代表空格的意思 return str.toString().replaceAll(\u0026#34;\\\\s\u0026#34;, \u0026#34;%20\u0026#34;); } 数值的整数次方 # 题目描述:\n给定一个 double 类型的浮点数 base 和 int 类型的整数 exponent。求 base 的 exponent 次方。\n问题解析:\n这道题算是比较麻烦和难一点的一个了。我这里采用的是二分幂思想,当然也可以采用快速幂。 更具剑指 offer 书中细节,该题的解题思路如下:1.当底数为 0 且指数\u0026lt;0 时,会出现对 0 求倒数的情况,需进行错误处理,设置一个全局变量; 2.判断底数是否等于 0,由于 base 为 double 型,所以不能直接用==判断 3.优化求幂函数(二分幂)。 当 n 为偶数,an =(an/2)(an/2); 当 n 为奇数,an = a^[(n-1)/2] a^[(n-1)/2] * a。时间复杂度 O(logn)\n时间复杂度:O(logn)\n示例代码:\npublic class Solution { boolean invalidInput=false; public double Power(double base, int exponent) { //如果底数等于0并且指数小于0 //由于base为double型,不能直接用==判断 if(equal(base,0.0)\u0026amp;\u0026amp;exponent\u0026lt;0){ invalidInput=true; return 0.0; } int absexponent=exponent; //如果指数小于0,将指数转正 if(exponent\u0026lt;0) absexponent=-exponent; //getPower方法求出base的exponent次方。 double res=getPower(base,absexponent); //如果指数小于0,所得结果为上面求的结果的倒数 if(exponent\u0026lt;0) res=1.0/res; return res; } //比较两个double型变量是否相等的方法 boolean equal(double num1,double num2){ if(num1-num2\u0026gt;-0.000001\u0026amp;\u0026amp;num1-num2\u0026lt;0.000001) return true; else return false; } //求出b的e次方的方法 double getPower(double b,int e){ //如果指数为0,返回1 if(e==0) return 1.0; //如果指数为1,返回b if(e==1) return b; //e\u0026gt;\u0026gt;1相等于e/2,这里就是求a^n =(a^n/2)*(a^n/2) double result=getPower(b,e\u0026gt;\u0026gt;1); result*=result; //如果指数n为奇数,则要再乘一次底数base if((e\u0026amp;1)==1) result*=b; return result; } } 当然这一题也可以采用笨方法:累乘。不过这种方法的时间复杂度为 O(n),这样没有前一种方法效率高。\n// 使用累乘 public double powerAnother(double base, int exponent) { double result = 1.0; for (int i = 0; i \u0026lt; Math.abs(exponent); i++) { result *= base; } if (exponent \u0026gt;= 0) return result; else return 1 / result; } 调整数组顺序使奇数位于偶数前面 # 题目描述:\n输入一个整数数组,实现一个函数来调整该数组中数字的顺序,使得所有的奇数位于数组的前半部分,所有的偶数位于位于数组的后半部分,并保证奇数和奇数,偶数和偶数之间的相对位置不变。\n问题解析:\n这道题有挺多种解法的,给大家介绍一种我觉得挺好理解的方法: 我们首先统计奇数的个数假设为 n,然后新建一个等长数组,然后通过循环判断原数组中的元素为偶数还是奇数。如果是则从数组下标 0 的元素开始,把该奇数添加到新数组;如果是偶数则从数组下标为 n 的元素开始把该偶数添加到新数组中。\n示例代码:\n时间复杂度为 O(n),空间复杂度为 O(n)的算法\npublic class Solution { public void reOrderArray(int [] array) { //如果数组长度等于0或者等于1,什么都不做直接返回 if(array.length==0||array.length==1) return; //oddCount:保存奇数个数 //oddBegin:奇数从数组头部开始添加 int oddCount=0,oddBegin=0; //新建一个数组 int[] newArray=new int[array.length]; //计算出(数组中的奇数个数)开始添加元素 for(int i=0;i\u0026lt;array.length;i++){ if((array[i]\u0026amp;1)==1) oddCount++; } for(int i=0;i\u0026lt;array.length;i++){ //如果数为基数新数组从头开始添加元素 //如果为偶数就从oddCount(数组中的奇数个数)开始添加元素 if((array[i]\u0026amp;1)==1) newArray[oddBegin++]=array[i]; else newArray[oddCount++]=array[i]; } for(int i=0;i\u0026lt;array.length;i++){ array[i]=newArray[i]; } } } 链表中倒数第 k 个节点 # 题目描述:\n输入一个链表,输出该链表中倒数第 k 个结点\n问题分析:\n一句话概括: 两个指针一个指针 p1 先开始跑,指针 p1 跑到 k-1 个节点后,另一个节点 p2 开始跑,当 p1 跑到最后时,p2 所指的指针就是倒数第 k 个节点。\n思想的简单理解: 前提假设:链表的结点个数(长度)为 n。 规律一:要找到倒数第 k 个结点,需要向前走多少步呢?比如倒数第一个结点,需要走 n 步,那倒数第二个结点呢?很明显是向前走了 n-1 步,所以可以找到规律是找到倒数第 k 个结点,需要向前走 n-k+1 步。\n算法开始:\n设两个都指向 head 的指针 p1 和 p2,当 p1 走了 k-1 步的时候,停下来。p2 之前一直不动。 p1 的下一步是走第 k 步,这个时候,p2 开始一起动了。至于为什么 p2 这个时候动呢?看下面的分析。 当 p1 走到链表的尾部时,即 p1 走了 n 步。由于我们知道 p2 是在 p1 走了 k-1 步才开始动的,也就是说 p1 和 p2 永远差 k-1 步。所以当 p1 走了 n 步时,p2 走的应该是在 n-(k-1)步。即 p2 走了 n-k+1 步,此时巧妙的是 p2 正好指向的是规律一的倒数第 k 个结点处。 这样是不是很好理解了呢? 考察内容:\n链表+代码的鲁棒性\n示例代码:\n/* //链表类 public class ListNode { int val; ListNode next = null; ListNode(int val) { this.val = val; } }*/ //时间复杂度O(n),一次遍历即可 public class Solution { public ListNode FindKthToTail(ListNode head,int k) { ListNode pre=null,p=null; //两个指针都指向头结点 p=head; pre=head; //记录k值 int a=k; //记录节点的个数 int count=0; //p指针先跑,并且记录节点数,当p指针跑了k-1个节点后,pre指针开始跑, //当p指针跑到最后时,pre所指指针就是倒数第k个节点 while(p!=null){ p=p.next; count++; if(k\u0026lt;1){ pre=pre.next; } k--; } //如果节点个数小于所求的倒数第k个节点,则返回空 if(count\u0026lt;a) return null; return pre; } } 反转链表 # 题目描述:\n输入一个链表,反转链表后,输出链表的所有元素。\n问题分析:\n链表的很常规的一道题,这一道题思路不算难,但自己实现起来真的可能会感觉无从下手,我是参考了别人的代码。 思路就是我们根据链表的特点,前一个节点指向下一个节点的特点,把后面的节点移到前面来。 就比如下图:我们把 1 节点和 2 节点互换位置,然后再将 3 节点指向 2 节点,4 节点指向 3 节点,这样以来下面的链表就被反转了。\n考察内容:\n链表+代码的鲁棒性\n示例代码:\n/* public class ListNode { int val; ListNode next = null; ListNode(int val) { this.val = val; } }*/ public class Solution { public ListNode ReverseList(ListNode head) { ListNode next = null; ListNode pre = null; while (head != null) { //保存要反转到头来的那个节点 next = head.next; //要反转的那个节点指向已经反转的上一个节点 head.next = pre; //上一个已经反转到头部的节点 pre = head; //一直向链表尾走 head = next; } return pre; } } 合并两个排序的链表 # 题目描述:\n输入两个单调递增的链表,输出两个链表合成后的链表,当然我们需要合成后的链表满足单调不减规则。\n问题分析:\n我们可以这样分析:\n假设我们有两个链表 A,B; A 的头节点 A1 的值与 B 的头结点 B1 的值比较,假设 A1 小,则 A1 为头节点; A2 再和 B1 比较,假设 B1 小,则,A1 指向 B1; A2 再和 B2 比较。。。。。。。 就这样循环往复就行了,应该还算好理解。 考察内容:\n链表+代码的鲁棒性\n示例代码:\n非递归版本:\n/* public class ListNode { int val; ListNode next = null; ListNode(int val) { this.val = val; } }*/ public class Solution { public ListNode Merge(ListNode list1,ListNode list2) { //list1为空,直接返回list2 if(list1 == null){ return list2; } //list2为空,直接返回list1 if(list2 == null){ return list1; } ListNode mergeHead = null; ListNode current = null; //当list1和list2不为空时 while(list1!=null \u0026amp;\u0026amp; list2!=null){ //取较小值作头结点 if(list1.val \u0026lt;= list2.val){ if(mergeHead == null){ mergeHead = current = list1; }else{ current.next = list1; //current节点保存list1节点的值因为下一次还要用 current = list1; } //list1指向下一个节点 list1 = list1.next; }else{ if(mergeHead == null){ mergeHead = current = list2; }else{ current.next = list2; //current节点保存list2节点的值因为下一次还要用 current = list2; } //list2指向下一个节点 list2 = list2.next; } } if(list1 == null){ current.next = list2; }else{ current.next = list1; } return mergeHead; } } 递归版本:\npublic ListNode Merge(ListNode list1,ListNode list2) { if(list1 == null){ return list2; } if(list2 == null){ return list1; } if(list1.val \u0026lt;= list2.val){ list1.next = Merge(list1.next, list2); return list1; }else{ list2.next = Merge(list1, list2.next); return list2; } } 用两个栈实现队列 # 题目描述:\n用两个栈来实现一个队列,完成队列的 Push 和 Pop 操作。 队列中的元素为 int 类型。\n问题分析:\n先来回顾一下栈和队列的基本特点: ==栈:==后进先出(LIFO) 队列: 先进先出 很明显我们需要根据 JDK 给我们提供的栈的一些基本方法来实现。先来看一下 Stack 类的一些基本方法:\n既然题目给了我们两个栈,我们可以这样考虑当 push 的时候将元素 push 进 stack1,pop 的时候我们先把 stack1 的元素 pop 到 stack2,然后再对 stack2 执行 pop 操作,这样就可以保证是先进先出的。(负[pop]负[pop]得正[先进先出])\n考察内容:\n队列+栈\n示例代码:\n//左程云的《程序员代码面试指南》的答案 import java.util.Stack; public class Solution { Stack\u0026lt;Integer\u0026gt; stack1 = new Stack\u0026lt;Integer\u0026gt;(); Stack\u0026lt;Integer\u0026gt; stack2 = new Stack\u0026lt;Integer\u0026gt;(); //当执行push操作时,将元素添加到stack1 public void push(int node) { stack1.push(node); } public int pop() { //如果两个队列都为空则抛出异常,说明用户没有push进任何元素 if(stack1.empty()\u0026amp;\u0026amp;stack2.empty()){ throw new RuntimeException(\u0026#34;Queue is empty!\u0026#34;); } //如果stack2不为空直接对stack2执行pop操作, if(stack2.empty()){ while(!stack1.empty()){ //将stack1的元素按后进先出push进stack2里面 stack2.push(stack1.pop()); } } return stack2.pop(); } } 栈的压入,弹出序列 # 题目描述:\n输入两个整数序列,第一个序列表示栈的压入顺序,请判断第二个序列是否为该栈的弹出顺序。假设压入栈的所有数字均不相等。例如序列 1,2,3,4,5 是某栈的压入顺序,序列 4,5,3,2,1 是该压栈序列对应的一个弹出序列,但 4,3,5,1,2 就不可能是该压栈序列的弹出序列。(注意:这两个序列的长度是相等的)\n题目分析:\n这道题想了半天没有思路,参考了 Alias 的答案,他的思路写的也很详细应该很容易看懂。\n【思路】借用一个辅助的栈,遍历压栈顺序,先讲第一个放入栈中,这里是 1,然后判断栈顶元素是不是出栈顺序的第一个元素,这里是 4,很显然 1≠4,所以我们继续压栈,直到相等以后开始出栈,出栈一个元素,则将出栈顺序向后移动一位,直到不相等,这样循环等压栈顺序遍历完成,如果辅助栈还不为空,说明弹出序列不是该栈的弹出顺序。\n举例:\n入栈 1,2,3,4,5\n出栈 4,5,3,2,1\n首先 1 入辅助栈,此时栈顶 1≠4,继续入栈 2\n此时栈顶 2≠4,继续入栈 3\n此时栈顶 3≠4,继续入栈 4\n此时栈顶 4 = 4,出栈 4,弹出序列向后一位,此时为 5,,辅助栈里面是 1,2,3\n此时栈顶 3≠5,继续入栈 5\n此时栈顶 5=5,出栈 5,弹出序列向后一位,此时为 3,,辅助栈里面是 1,2,3\n……. 依次执行,最后辅助栈为空。如果不为空说明弹出序列不是该栈的弹出顺序。\n考察内容:\n栈\n示例代码:\nimport java.util.ArrayList; import java.util.Stack; //这道题没想出来,参考了Alias同学的答案:https://www.nowcoder.com/questionTerminal/d77d11405cc7470d82554cb392585106 public class Solution { public boolean IsPopOrder(int [] pushA,int [] popA) { if(pushA.length == 0 || popA.length == 0) return false; Stack\u0026lt;Integer\u0026gt; s = new Stack\u0026lt;Integer\u0026gt;(); //用于标识弹出序列的位置 int popIndex = 0; for(int i = 0; i\u0026lt; pushA.length;i++){ s.push(pushA[i]); //如果栈不为空,且栈顶元素等于弹出序列 while(!s.empty() \u0026amp;\u0026amp;s.peek() == popA[popIndex]){ //出栈 s.pop(); //弹出序列向后一位 popIndex++; } } return s.empty(); } } "},{"id":635,"href":"/zh/docs/technology/Interview/high-availability/fallback-and-circuit-breaker/","title":"降级\u0026熔断详解(付费)","section":"High Availability","content":"降级\u0026amp;熔断 相关的面试题为我的 知识星球(点击链接即可查看详细介绍以及加入方法)专属内容,已经整理到了 《Java 面试指北》中。\n"},{"id":636,"href":"/zh/docs/technology/Interview/cs-basics/algorithms/classical-algorithm-problems-recommendations/","title":"经典算法思想总结(含LeetCode题目推荐)","section":"Algorithms","content":" 贪心算法 # 算法思想 # 贪心的本质是选择每一阶段的局部最优,从而达到全局最优。\n一般解题步骤 # 将问题分解为若干个子问题 找出适合的贪心策略 求解每一个子问题的最优解 将局部最优解堆叠成全局最优解 LeetCode # 455.分发饼干: https://leetcode.cn/problems/assign-cookies/\n121.买卖股票的最佳时机: https://leetcode.cn/problems/best-time-to-buy-and-sell-stock/\n122.买卖股票的最佳时机 II: https://leetcode.cn/problems/best-time-to-buy-and-sell-stock-ii/\n55.跳跃游戏: https://leetcode.cn/problems/jump-game/\n45.跳跃游戏 II: https://leetcode.cn/problems/jump-game-ii/\n动态规划 # 算法思想 # 动态规划中每一个状态一定是由上一个状态推导出来的,这一点就区分于贪心,贪心没有状态推导,而是从局部直接选最优的。\n经典题目:01 背包、完全背包\n一般解题步骤 # 确定 dp 数组(dp table)以及下标的含义 确定递推公式 dp 数组如何初始化 确定遍历顺序 举例推导 dp 数组 LeetCode # 509.斐波那契数: https://leetcode.cn/problems/fibonacci-number/\n746.使用最小花费爬楼梯: https://leetcode.cn/problems/min-cost-climbing-stairs/\n416.分割等和子集: https://leetcode.cn/problems/partition-equal-subset-sum/\n518.零钱兑换: https://leetcode.cn/problems/coin-change-ii/\n647.回文子串: https://leetcode.cn/problems/palindromic-substrings/\n516.最长回文子序列: https://leetcode.cn/problems/longest-palindromic-subsequence/\n回溯算法 # 算法思想 # 回溯算法实际上一个类似枚举的搜索尝试过程,主要是在搜索尝试过程中寻找问题的解,当发现已不满足求解条\n件时,就“回溯”返回,尝试别的路径。其本质就是穷举。\n经典题目:8 皇后\n一般解题步骤 # 针对所给问题,定义问题的解空间,它至少包含问题的一个(最优)解。 确定易于搜索的解空间结构,使得能用回溯法方便地搜索整个解空间 。 以深度优先的方式搜索解空间,并且在搜索过程中用剪枝函数避免无效搜索。 leetcode # 77.组合: https://leetcode.cn/problems/combinations/\n39.组合总和: https://leetcode.cn/problems/combination-sum/\n40.组合总和 II: https://leetcode.cn/problems/combination-sum-ii/\n78.子集: https://leetcode.cn/problems/subsets/\n90.子集 II: https://leetcode.cn/problems/subsets-ii/\n51.N 皇后: https://leetcode.cn/problems/n-queens/\n分治算法 # 算法思想 # 将一个规模为 N 的问题分解为 K 个规模较小的子问题,这些子问题相互独立且与原问题性质相同。求出子问题的解,就可得到原问题的解。\n经典题目:二分查找、汉诺塔问题\n一般解题步骤 # 将原问题分解为若干个规模较小,相互独立,与原问题形式相同的子问题; 若子问题规模较小而容易被解决则直接解,否则递归地解各个子问题 将各个子问题的解合并为原问题的解。 LeetCode # 108.将有序数组转换成二叉搜索数: https://leetcode.cn/problems/convert-sorted-array-to-binary-search-tree/\n148.排序列表: https://leetcode.cn/problems/sort-list/\n23.合并 k 个升序链表: https://leetcode.cn/problems/merge-k-sorted-lists/\n"},{"id":637,"href":"/zh/docs/technology/Interview/java/concurrent/optimistic-lock-and-pessimistic-lock/","title":"乐观锁和悲观锁详解","section":"Concurrent","content":"如果将悲观锁(Pessimistic Lock)和乐观锁(Optimistic Lock)对应到现实生活中来。悲观锁有点像是一位比较悲观(也可以说是未雨绸缪)的人,总是会假设最坏的情况,避免出现问题。乐观锁有点像是一位比较乐观的人,总是会假设最好的情况,在要出现问题之前快速解决问题。\n什么是悲观锁? # 悲观锁总是假设最坏的情况,认为共享资源每次被访问的时候就会出现问题(比如共享数据被修改),所以每次在获取资源操作的时候都会上锁,这样其他线程想拿到这个资源就会阻塞直到锁被上一个持有者释放。也就是说,共享资源每次只给一个线程使用,其它线程阻塞,用完后再把资源转让给其它线程。\n像 Java 中synchronized和ReentrantLock等独占锁就是悲观锁思想的实现。\npublic void performSynchronisedTask() { synchronized (this) { // 需要同步的操作 } } private Lock lock = new ReentrantLock(); lock.lock(); try { // 需要同步的操作 } finally { lock.unlock(); } 高并发的场景下,激烈的锁竞争会造成线程阻塞,大量阻塞线程会导致系统的上下文切换,增加系统的性能开销。并且,悲观锁还可能会存在死锁问题(线程获得锁的顺序不当时),影响代码的正常运行。\n什么是乐观锁? # 乐观锁总是假设最好的情况,认为共享资源每次被访问的时候不会出现问题,线程可以不停地执行,无需加锁也无需等待,只是在提交修改的时候去验证对应的资源(也就是数据)是否被其它线程修改了(具体方法可以使用版本号机制或 CAS 算法)。\n在 Java 中java.util.concurrent.atomic包下面的原子变量类(比如AtomicInteger、LongAdder)就是使用了乐观锁的一种实现方式 CAS 实现的。 // LongAdder 在高并发场景下会比 AtomicInteger 和 AtomicLong 的性能更好 // 代价就是会消耗更多的内存空间(空间换时间) LongAdder sum = new LongAdder(); sum.increment(); 高并发的场景下,乐观锁相比悲观锁来说,不存在锁竞争造成线程阻塞,也不会有死锁问题,在性能上往往会更胜一筹。但是,如果冲突频繁发生(写占比非常多的情况),会频繁失败并重试,这样同样会非常影响性能,导致 CPU 飙升。\n不过,大量失败重试的问题也是可以解决的,像我们前面提到的 LongAdder以空间换时间的方式就解决了这个问题。\n理论上来说:\n悲观锁通常多用于写比较多的情况(多写场景,竞争激烈),这样可以避免频繁失败和重试影响性能,悲观锁的开销是固定的。不过,如果乐观锁解决了频繁失败和重试这个问题的话(比如LongAdder),也是可以考虑使用乐观锁的,要视实际情况而定。 乐观锁通常多用于写比较少的情况(多读场景,竞争较少),这样可以避免频繁加锁影响性能。不过,乐观锁主要针对的对象是单个共享变量(参考java.util.concurrent.atomic包下面的原子变量类)。 如何实现乐观锁? # 乐观锁一般会使用版本号机制或 CAS 算法实现,CAS 算法相对来说更多一些,这里需要格外注意。\n版本号机制 # 一般是在数据表中加上一个数据版本号 version 字段,表示数据被修改的次数。当数据被修改时,version 值会加一。当线程 A 要更新数据值时,在读取数据的同时也会读取 version 值,在提交更新时,若刚才读取到的 version 值为当前数据库中的 version 值相等时才更新,否则重试更新操作,直到更新成功。\n举一个简单的例子:假设数据库中帐户信息表中有一个 version 字段,当前值为 1 ;而当前帐户余额字段( balance )为 $100 。\n操作员 A 此时将其读出( version=1 ),并从其帐户余额中扣除 $50( $100-$50 )。 在操作员 A 操作的过程中,操作员 B 也读入此用户信息( version=1 ),并从其帐户余额中扣除 $20 ( $100-$20 )。 操作员 A 完成了修改工作,将数据版本号( version=1 ),连同帐户扣除后余额( balance=$50 ),提交至数据库更新,此时由于提交数据版本等于数据库记录当前版本,数据被更新,数据库记录 version 更新为 2 。 操作员 B 完成了操作,也将版本号( version=1 )试图向数据库提交数据( balance=$80 ),但此时比对数据库记录版本时发现,操作员 B 提交的数据版本号为 1 ,数据库记录当前版本也为 2 ,不满足 “ 提交版本必须等于当前版本才能执行更新 “ 的乐观锁策略,因此,操作员 B 的提交被驳回。 这样就避免了操作员 B 用基于 version=1 的旧数据修改的结果覆盖操作员 A 的操作结果的可能。\nCAS 算法 # CAS 的全称是 Compare And Swap(比较与交换) ,用于实现乐观锁,被广泛应用于各大框架中。CAS 的思想很简单,就是用一个预期值和要更新的变量值进行比较,两值相等才会进行更新。\nCAS 是一个原子操作,底层依赖于一条 CPU 的原子指令。\n原子操作 即最小不可拆分的操作,也就是说操作一旦开始,就不能被打断,直到操作完成。\nCAS 涉及到三个操作数:\nV:要更新的变量值(Var) E:预期值(Expected) N:拟写入的新值(New) 当且仅当 V 的值等于 E 时,CAS 通过原子方式用新值 N 来更新 V 的值。如果不等,说明已经有其它线程更新了 V,则当前线程放弃更新。\n举一个简单的例子:线程 A 要修改变量 i 的值为 6,i 原值为 1(V = 1,E=1,N=6,假设不存在 ABA 问题)。\ni 与 1 进行比较,如果相等, 则说明没被其他线程修改,可以被设置为 6 。 i 与 1 进行比较,如果不相等,则说明被其他线程修改,当前线程放弃更新,CAS 操作失败。 当多个线程同时使用 CAS 操作一个变量时,只有一个会胜出,并成功更新,其余均会失败,但失败的线程并不会被挂起,仅是被告知失败,并且允许再次尝试,当然也允许失败的线程放弃操作。\n关于 CAS 的进一步介绍,可以阅读读者写的这篇文章: CAS 详解,其中详细提到了 Java 中 CAS 的实现以及 CAS 存在的一些问题。\n总结 # 本文详细介绍了乐观锁和悲观锁的概念以及乐观锁常见实现方式:\n悲观锁基于悲观的假设,认为共享资源在每次访问时都会发生冲突,因此在每次操作时都会加锁。这种锁机制会导致其他线程阻塞,直到锁被释放。Java 中的 synchronized 和 ReentrantLock 是悲观锁的典型实现方式。虽然悲观锁能有效避免数据竞争,但在高并发场景下会导致线程阻塞、上下文切换频繁,从而影响系统性能,并且还可能引发死锁问题。 乐观锁基于乐观的假设,认为共享资源在每次访问时不会发生冲突,因此无须加锁,只需在提交修改时验证数据是否被其他线程修改。Java 中的 AtomicInteger 和 LongAdder 等类通过 CAS(Compare-And-Swap)算法实现了乐观锁。乐观锁避免了线程阻塞和死锁问题,在读多写少的场景中性能优越。但在写操作频繁的情况下,可能会导致大量重试和失败,从而影响性能。 乐观锁主要通过版本号机制或 CAS 算法实现。版本号机制通过比较版本号确保数据一致性,而 CAS 通过硬件指令实现原子操作,直接比较和交换变量值。 悲观锁和乐观锁各有优缺点,适用于不同的应用场景。在实际开发中,选择合适的锁机制能够有效提升系统的并发性能和稳定性。\n参考 # 《Java 并发编程核心 78 讲》 通俗易懂 悲观锁、乐观锁、可重入锁、自旋锁、偏向锁、轻量/重量级锁、读写锁、各种锁及其 Java 实现!: https://zhuanlan.zhihu.com/p/71156910 "},{"id":638,"href":"/zh/docs/technology/Interview/java/jvm/class-loading-process/","title":"类加载过程详解","section":"Jvm","content":" 类的生命周期 # 类从被加载到虚拟机内存中开始到卸载出内存为止,它的整个生命周期可以简单概括为 7 个阶段:加载(Loading)、验证(Verification)、准备(Preparation)、解析(Resolution)、初始化(Initialization)、使用(Using)和卸载(Unloading)。其中,验证、准备和解析这三个阶段可以统称为连接(Linking)。\n这 7 个阶段的顺序如下图所示:\n类加载过程 # Class 文件需要加载到虚拟机中之后才能运行和使用,那么虚拟机是如何加载这些 Class 文件呢?\n系统加载 Class 类型的文件主要三步:加载-\u0026gt;连接-\u0026gt;初始化。连接过程又可分为三步:验证-\u0026gt;准备-\u0026gt;解析。\n详见 Java Virtual Machine Specification - 5.3. Creation and Loading。\n加载 # 类加载过程的第一步,主要完成下面 3 件事情:\n通过全类名获取定义此类的二进制字节流。 将字节流所代表的静态存储结构转换为方法区的运行时数据结构。 在内存中生成一个代表该类的 Class 对象,作为方法区这些数据的访问入口。 虚拟机规范上面这 3 点并不具体,因此是非常灵活的。比如:\u0026ldquo;通过全类名获取定义此类的二进制字节流\u0026rdquo; 并没有指明具体从哪里获取( ZIP、 JAR、EAR、WAR、网络、动态代理技术运行时动态生成、其他文件生成比如 JSP\u0026hellip;)、怎样获取。\n加载这一步主要是通过我们后面要讲到的 类加载器 完成的。类加载器有很多种,当我们想要加载一个类的时候,具体是哪个类加载器加载由 双亲委派模型 决定(不过,我们也能打破由双亲委派模型)。\n类加载器、双亲委派模型也是非常重要的知识点,这部分内容在 类加载器详解这篇文章中有详细介绍到。阅读本篇文章的时候,大家知道有这么个东西就可以了。\n每个 Java 类都有一个引用指向加载它的 ClassLoader。不过,数组类不是通过 ClassLoader 创建的,而是 JVM 在需要的时候自动创建的,数组类通过getClassLoader()方法获取 ClassLoader 的时候和该数组的元素类型的 ClassLoader 是一致的。\n一个非数组类的加载阶段(加载阶段获取类的二进制字节流的动作)是可控性最强的阶段,这一步我们可以去完成还可以自定义类加载器去控制字节流的获取方式(重写一个类加载器的 loadClass() 方法)。\n加载阶段与连接阶段的部分动作(如一部分字节码文件格式验证动作)是交叉进行的,加载阶段尚未结束,连接阶段可能就已经开始了。\n验证 # 验证是连接阶段的第一步,这一阶段的目的是确保 Class 文件的字节流中包含的信息符合《Java 虚拟机规范》的全部约束要求,保证这些信息被当作代码运行后不会危害虚拟机自身的安全。\n验证阶段这一步在整个类加载过程中耗费的资源还是相对较多的,但很有必要,可以有效防止恶意代码的执行。任何时候,程序安全都是第一位。\n不过,验证阶段也不是必须要执行的阶段。如果程序运行的全部代码(包括自己编写的、第三方包中的、从外部加载的、动态生成的等所有代码)都已经被反复使用和验证过,在生产环境的实施阶段就可以考虑使用 -Xverify:none 参数来关闭大部分的类验证措施,以缩短虚拟机类加载的时间。但是需要注意的是 -Xverify:none 和 -noverify 在 JDK 13 中被标记为 deprecated ,在未来版本的 JDK 中可能会被移除。\n验证阶段主要由四个检验阶段组成:\n文件格式验证(Class 文件格式检查) 元数据验证(字节码语义检查) 字节码验证(程序语义检查) 符号引用验证(类的正确性检查) 文件格式验证这一阶段是基于该类的二进制字节流进行的,主要目的是保证输入的字节流能正确地解析并存储于方法区之内,格式上符合描述一个 Java 类型信息的要求。除了这一阶段之外,其余三个验证阶段都是基于方法区的存储结构上进行的,不会再直接读取、操作字节流了。\n方法区属于是 JVM 运行时数据区域的一块逻辑区域,是各个线程共享的内存区域。当虚拟机要使用一个类时,它需要读取并解析 Class 文件获取相关信息,再将信息存入到方法区。方法区会存储已被虚拟机加载的 类信息、字段信息、方法信息、常量、静态变量、即时编译器编译后的代码缓存等数据。\n关于方法区的详细介绍,推荐阅读 Java 内存区域详解 这篇文章。\n符号引用验证发生在类加载过程中的解析阶段,具体点说是 JVM 将符号引用转化为直接引用的时候(解析阶段会介绍符号引用和直接引用)。\n符号引用验证的主要目的是确保解析阶段能正常执行,如果无法通过符号引用验证,JVM 会抛出异常,比如:\njava.lang.IllegalAccessError:当类试图访问或修改它没有权限访问的字段,或调用它没有权限访问的方法时,抛出该异常。 java.lang.NoSuchFieldError:当类试图访问或修改一个指定的对象字段,而该对象不再包含该字段时,抛出该异常。 java.lang.NoSuchMethodError:当类试图访问一个指定的方法,而该方法不存在时,抛出该异常。 …… 准备 # 准备阶段是正式为类变量分配内存并设置类变量初始值的阶段,这些内存都将在方法区中分配。对于该阶段有以下几点需要注意:\n这时候进行内存分配的仅包括类变量( Class Variables ,即静态变量,被 static 关键字修饰的变量,只与类相关,因此被称为类变量),而不包括实例变量。实例变量会在对象实例化时随着对象一块分配在 Java 堆中。 从概念上讲,类变量所使用的内存都应当在 方法区 中进行分配。不过有一点需要注意的是:JDK 7 之前,HotSpot 使用永久代来实现方法区的时候,实现是完全符合这种逻辑概念的。 而在 JDK 7 及之后,HotSpot 已经把原本放在永久代的字符串常量池、静态变量等移动到堆中,这个时候类变量则会随着 Class 对象一起存放在 Java 堆中。相关阅读: 《深入理解 Java 虚拟机(第 3 版)》勘误#75 这里所设置的初始值\u0026quot;通常情况\u0026quot;下是数据类型默认的零值(如 0、0L、null、false 等),比如我们定义了public static int value=111 ,那么 value 变量在准备阶段的初始值就是 0 而不是 111(初始化阶段才会赋值)。特殊情况:比如给 value 变量加上了 final 关键字public static final int value=111 ,那么准备阶段 value 的值就被赋值为 111。 基本数据类型的零值:(图片来自《深入理解 Java 虚拟机》第 3 版 7.3.3 )\n解析 # 解析阶段是虚拟机将常量池内的符号引用替换为直接引用的过程。 解析动作主要针对类或接口、字段、类方法、接口方法、方法类型、方法句柄和调用限定符 7 类符号引用进行。\n《深入理解 Java 虚拟机》7.3.4 节第三版对符号引用和直接引用的解释如下:\n举个例子:在程序执行方法时,系统需要明确知道这个方法所在的位置。Java 虚拟机为每个类都准备了一张方法表来存放类中所有的方法。当需要调用一个类的方法的时候,只要知道这个方法在方法表中的偏移量就可以直接调用该方法了。通过解析操作符号引用就可以直接转变为目标方法在类中方法表的位置,从而使得方法可以被调用。\n综上,解析阶段是虚拟机将常量池内的符号引用替换为直接引用的过程,也就是得到类或者字段、方法在内存中的指针或者偏移量。\n初始化 # 初始化阶段是执行初始化方法 \u0026lt;clinit\u0026gt; ()方法的过程,是类加载的最后一步,这一步 JVM 才开始真正执行类中定义的 Java 程序代码(字节码)。\n说明:\u0026lt;clinit\u0026gt; ()方法是编译之后自动生成的。\n对于\u0026lt;clinit\u0026gt; () 方法的调用,虚拟机会自己确保其在多线程环境中的安全性。因为 \u0026lt;clinit\u0026gt; () 方法是带锁线程安全,所以在多线程环境下进行类初始化的话可能会引起多个线程阻塞,并且这种阻塞很难被发现。\n对于初始化阶段,虚拟机严格规范了有且只有 6 种情况下,必须对类进行初始化(只有主动去使用类才会初始化类):\n当遇到 new、 getstatic、putstatic 或 invokestatic 这 4 条字节码指令时,比如 new 一个类,读取一个静态字段(未被 final 修饰)、或调用一个类的静态方法时。 当 jvm 执行 new 指令时会初始化类。即当程序创建一个类的实例对象。 当 jvm 执行 getstatic 指令时会初始化类。即程序访问类的静态变量(不是静态常量,常量会被加载到运行时常量池)。 当 jvm 执行 putstatic 指令时会初始化类。即程序给类的静态变量赋值。 当 jvm 执行 invokestatic 指令时会初始化类。即程序调用类的静态方法。 使用 java.lang.reflect 包的方法对类进行反射调用时如 Class.forName(\u0026quot;...\u0026quot;), newInstance() 等等。如果类没初始化,需要触发其初始化。 初始化一个类,如果其父类还未初始化,则先触发该父类的初始化。 当虚拟机启动时,用户需要定义一个要执行的主类 (包含 main 方法的那个类),虚拟机会先初始化这个类。 MethodHandle 和 VarHandle 可以看作是轻量级的反射调用机制,而要想使用这 2 个调用, 就必须先使用 findStaticVarHandle 来初始化要调用的类。 「补充,来自 issue745」 当一个接口中定义了 JDK8 新加入的默认方法(被 default 关键字修饰的接口方法)时,如果有这个接口的实现类发生了初始化,那该接口要在其之前被初始化。 类卸载 # 卸载这部分内容来自 issue#662由 guang19 补充完善。\n卸载类即该类的 Class 对象被 GC。\n卸载类需要满足 3 个要求:\n该类的所有的实例对象都已被 GC,也就是说堆不存在该类的实例对象。 该类没有在其他任何地方被引用 该类的类加载器的实例已被 GC 所以,在 JVM 生命周期内,由 jvm 自带的类加载器加载的类是不会被卸载的。但是由我们自定义的类加载器加载的类是可能被卸载的。\n只要想通一点就好了,JDK 自带的 BootstrapClassLoader, ExtClassLoader, AppClassLoader 负责加载 JDK 提供的类,所以它们(类加载器的实例)肯定不会被回收。而我们自定义的类加载器的实例是可以被回收的,所以使用我们自定义加载器加载的类是可以被卸载掉的。\n参考\n《深入理解 Java 虚拟机》 《实战 Java 虚拟机》 Chapter 5. Loading, Linking, and Initializing - Java Virtual Machine Specification: https://docs.oracle.com/javase/specs/jvms/se8/html/jvms-5.html#jvms-5.4 "},{"id":639,"href":"/zh/docs/technology/Interview/java/jvm/classloader/","title":"类加载器详解(重点)","section":"Jvm","content":" 回顾一下类加载过程 # 开始介绍类加载器和双亲委派模型之前,简单回顾一下类加载过程。\n类加载过程:加载-\u0026gt;连接-\u0026gt;初始化。 连接过程又可分为三步:验证-\u0026gt;准备-\u0026gt;解析。 加载是类加载过程的第一步,主要完成下面 3 件事情:\n通过全类名获取定义此类的二进制字节流 将字节流所代表的静态存储结构转换为方法区的运行时数据结构 在内存中生成一个代表该类的 Class 对象,作为方法区这些数据的访问入口 类加载器 # 类加载器介绍 # 类加载器从 JDK 1.0 就出现了,最初只是为了满足 Java Applet(已经被淘汰) 的需要。后来,慢慢成为 Java 程序中的一个重要组成部分,赋予了 Java 类可以被动态加载到 JVM 中并执行的能力。\n根据官方 API 文档的介绍:\nA class loader is an object that is responsible for loading classes. The class ClassLoader is an abstract class. Given the binary name of a class, a class loader should attempt to locate or generate data that constitutes a definition for the class. A typical strategy is to transform the name into a file name and then read a \u0026ldquo;class file\u0026rdquo; of that name from a file system.\nEvery Class object contains a reference to the ClassLoader that defined it.\nClass objects for array classes are not created by class loaders, but are created automatically as required by the Java runtime. The class loader for an array class, as returned by Class.getClassLoader() is the same as the class loader for its element type; if the element type is a primitive type, then the array class has no class loader.\n翻译过来大概的意思是:\n类加载器是一个负责加载类的对象。ClassLoader 是一个抽象类。给定类的二进制名称,类加载器应尝试定位或生成构成类定义的数据。典型的策略是将名称转换为文件名,然后从文件系统中读取该名称的“类文件”。\n每个 Java 类都有一个引用指向加载它的 ClassLoader。不过,数组类不是通过 ClassLoader 创建的,而是 JVM 在需要的时候自动创建的,数组类通过getClassLoader()方法获取 ClassLoader 的时候和该数组的元素类型的 ClassLoader 是一致的。\n从上面的介绍可以看出:\n类加载器是一个负责加载类的对象,用于实现类加载过程中的加载这一步。 每个 Java 类都有一个引用指向加载它的 ClassLoader。 数组类不是通过 ClassLoader 创建的(数组类没有对应的二进制字节流),是由 JVM 直接生成的。 class Class\u0026lt;T\u0026gt; { ... private final ClassLoader classLoader; @CallerSensitive public ClassLoader getClassLoader() { //... } ... } 简单来说,类加载器的主要作用就是加载 Java 类的字节码( .class 文件)到 JVM 中(在内存中生成一个代表该类的 Class 对象)。 字节码可以是 Java 源程序(.java文件)经过 javac 编译得来,也可以是通过工具动态生成或者通过网络下载得来。\n其实除了加载类之外,类加载器还可以加载 Java 应用所需的资源如文本、图像、配置文件、视频等等文件资源。本文只讨论其核心功能:加载类。\n类加载器加载规则 # JVM 启动的时候,并不会一次性加载所有的类,而是根据需要去动态加载。也就是说,大部分类在具体用到的时候才会去加载,这样对内存更加友好。\n对于已经加载的类会被放在 ClassLoader 中。在类加载的时候,系统会首先判断当前类是否被加载过。已经被加载的类会直接返回,否则才会尝试加载。也就是说,对于一个类加载器来说,相同二进制名称的类只会被加载一次。\npublic abstract class ClassLoader { ... private final ClassLoader parent; // 由这个类加载器加载的类。 private final Vector\u0026lt;Class\u0026lt;?\u0026gt;\u0026gt; classes = new Vector\u0026lt;\u0026gt;(); // 由VM调用,用此类加载器记录每个已加载类。 void addClass(Class\u0026lt;?\u0026gt; c) { classes.addElement(c); } ... } 类加载器总结 # JVM 中内置了三个重要的 ClassLoader:\nBootstrapClassLoader(启动类加载器):最顶层的加载类,由 C++实现,通常表示为 null,并且没有父级,主要用来加载 JDK 内部的核心类库( %JAVA_HOME%/lib目录下的 rt.jar、resources.jar、charsets.jar等 jar 包和类)以及被 -Xbootclasspath参数指定的路径下的所有类。 ExtensionClassLoader(扩展类加载器):主要负责加载 %JRE_HOME%/lib/ext 目录下的 jar 包和类以及被 java.ext.dirs 系统变量所指定的路径下的所有类。 AppClassLoader(应用程序类加载器):面向我们用户的加载器,负责加载当前应用 classpath 下的所有 jar 包和类。 🌈 拓展一下:\nrt.jar:rt 代表“RunTime”,rt.jar是 Java 基础类库,包含 Java doc 里面看到的所有的类的类文件。也就是说,我们常用内置库 java.xxx.*都在里面,比如java.util.*、java.io.*、java.nio.*、java.lang.*、java.sql.*、java.math.*。 Java 9 引入了模块系统,并且略微更改了上述的类加载器。扩展类加载器被改名为平台类加载器(platform class loader)。Java SE 中除了少数几个关键模块,比如说 java.base 是由启动类加载器加载之外,其他的模块均由平台类加载器所加载。 除了这三种类加载器之外,用户还可以加入自定义的类加载器来进行拓展,以满足自己的特殊需求。就比如说,我们可以对 Java 类的字节码( .class 文件)进行加密,加载时再利用自定义的类加载器对其解密。\n除了 BootstrapClassLoader 是 JVM 自身的一部分之外,其他所有的类加载器都是在 JVM 外部实现的,并且全都继承自 ClassLoader抽象类。这样做的好处是用户可以自定义类加载器,以便让应用程序自己决定如何去获取所需的类。\n每个 ClassLoader 可以通过getParent()获取其父 ClassLoader,如果获取到 ClassLoader 为null的话,那么该类是通过 BootstrapClassLoader 加载的。\npublic abstract class ClassLoader { ... // 父加载器 private final ClassLoader parent; @CallerSensitive public final ClassLoader getParent() { //... } ... } 为什么 获取到 ClassLoader 为null就是 BootstrapClassLoader 加载的呢? 这是因为BootstrapClassLoader 由 C++ 实现,由于这个 C++ 实现的类加载器在 Java 中是没有与之对应的类的,所以拿到的结果是 null。\n下面我们来看一个获取 ClassLoader 的小案例:\npublic class PrintClassLoaderTree { public static void main(String[] args) { ClassLoader classLoader = PrintClassLoaderTree.class.getClassLoader(); StringBuilder split = new StringBuilder(\u0026#34;|--\u0026#34;); boolean needContinue = true; while (needContinue){ System.out.println(split.toString() + classLoader); if(classLoader == null){ needContinue = false; }else{ classLoader = classLoader.getParent(); split.insert(0, \u0026#34;\\t\u0026#34;); } } } } 输出结果(JDK 8 ):\n|--sun.misc.Launcher$AppClassLoader@18b4aac2 |--sun.misc.Launcher$ExtClassLoader@53bd815b |--null 从输出结果可以看出:\n我们编写的 Java 类 PrintClassLoaderTree 的 ClassLoader 是AppClassLoader; AppClassLoader的父 ClassLoader 是ExtClassLoader; ExtClassLoader的父ClassLoader是Bootstrap ClassLoader,因此输出结果为 null。 自定义类加载器 # 我们前面也说说了,除了 BootstrapClassLoader 其他类加载器均由 Java 实现且全部继承自java.lang.ClassLoader。如果我们要自定义自己的类加载器,很明显需要继承 ClassLoader抽象类。\nClassLoader 类有两个关键的方法:\nprotected Class loadClass(String name, boolean resolve):加载指定二进制名称的类,实现了双亲委派机制 。name 为类的二进制名称,resolve 如果为 true,在加载时调用 resolveClass(Class\u0026lt;?\u0026gt; c) 方法解析该类。 protected Class findClass(String name):根据类的二进制名称来查找类,默认实现是空方法。 官方 API 文档中写到:\nSubclasses of ClassLoader are encouraged to override findClass(String name), rather than this method.\n建议 ClassLoader的子类重写 findClass(String name)方法而不是loadClass(String name, boolean resolve) 方法。\n如果我们不想打破双亲委派模型,就重写 ClassLoader 类中的 findClass() 方法即可,无法被父类加载器加载的类最终会通过这个方法被加载。但是,如果想打破双亲委派模型则需要重写 loadClass() 方法。\n双亲委派模型 # 双亲委派模型介绍 # 类加载器有很多种,当我们想要加载一个类的时候,具体是哪个类加载器加载呢?这就需要提到双亲委派模型了。\n根据官网介绍:\nThe ClassLoader class uses a delegation model to search for classes and resources. Each instance of ClassLoader has an associated parent class loader. When requested to find a class or resource, a ClassLoader instance will delegate the search for the class or resource to its parent class loader before attempting to find the class or resource itself. The virtual machine\u0026rsquo;s built-in class loader, called the \u0026ldquo;bootstrap class loader\u0026rdquo;, does not itself have a parent but may serve as the parent of a ClassLoader instance.\n翻译过来大概的意思是:\nClassLoader 类使用委托模型来搜索类和资源。每个 ClassLoader 实例都有一个相关的父类加载器。需要查找类或资源时,ClassLoader 实例会在试图亲自查找类或资源之前,将搜索类或资源的任务委托给其父类加载器。 虚拟机中被称为 \u0026ldquo;bootstrap class loader\u0026quot;的内置类加载器本身没有父类加载器,但是可以作为 ClassLoader 实例的父类加载器。\n从上面的介绍可以看出:\nClassLoader 类使用委托模型来搜索类和资源。 双亲委派模型要求除了顶层的启动类加载器外,其余的类加载器都应有自己的父类加载器。 ClassLoader 实例会在试图亲自查找类或资源之前,将搜索类或资源的任务委托给其父类加载器。 下图展示的各种类加载器之间的层次关系被称为类加载器的“双亲委派模型(Parents Delegation Model)”。\n注意 ⚠️:双亲委派模型并不是一种强制性的约束,只是 JDK 官方推荐的一种方式。如果我们因为某些特殊需求想要打破双亲委派模型,也是可以的,后文会介绍具体的方法。\n其实这个双亲翻译的容易让别人误解,我们一般理解的双亲都是父母,这里的双亲更多地表达的是“父母这一辈”的人而已,并不是说真的有一个 MotherClassLoader 和一个FatherClassLoader 。个人觉得翻译成单亲委派模型更好一些,不过,国内既然翻译成了双亲委派模型并流传了,按照这个来也没问题,不要被误解了就好。\n另外,类加载器之间的父子关系一般不是以继承的关系来实现的,而是通常使用组合关系来复用父加载器的代码。\npublic abstract class ClassLoader { ... // 组合 private final ClassLoader parent; protected ClassLoader(ClassLoader parent) { this(checkCreateClassLoader(), parent); } ... } 在面向对象编程中,有一条非常经典的设计原则:组合优于继承,多用组合少用继承。\n双亲委派模型的执行流程 # 双亲委派模型的实现代码非常简单,逻辑非常清晰,都集中在 java.lang.ClassLoader 的 loadClass() 中,相关代码如下所示。\nprotected Class\u0026lt;?\u0026gt; loadClass(String name, boolean resolve) throws ClassNotFoundException { synchronized (getClassLoadingLock(name)) { //首先,检查该类是否已经加载过 Class c = findLoadedClass(name); if (c == null) { //如果 c 为 null,则说明该类没有被加载过 long t0 = System.nanoTime(); try { if (parent != null) { //当父类的加载器不为空,则通过父类的loadClass来加载该类 c = parent.loadClass(name, false); } else { //当父类的加载器为空,则调用启动类加载器来加载该类 c = findBootstrapClassOrNull(name); } } catch (ClassNotFoundException e) { //非空父类的类加载器无法找到相应的类,则抛出异常 } if (c == null) { //当父类加载器无法加载时,则调用findClass方法来加载该类 //用户可通过覆写该方法,来自定义类加载器 long t1 = System.nanoTime(); c = findClass(name); //用于统计类加载器相关的信息 sun.misc.PerfCounter.getParentDelegationTime().addTime(t1 - t0); sun.misc.PerfCounter.getFindClassTime().addElapsedTimeFrom(t1); sun.misc.PerfCounter.getFindClasses().increment(); } } if (resolve) { //对类进行link操作 resolveClass(c); } return c; } } 每当一个类加载器接收到加载请求时,它会先将请求转发给父类加载器。在父类加载器没有找到所请求的类的情况下,该类加载器才会尝试去加载。\n结合上面的源码,简单总结一下双亲委派模型的执行流程:\n在类加载的时候,系统会首先判断当前类是否被加载过。已经被加载的类会直接返回,否则才会尝试加载(每个父类加载器都会走一遍这个流程)。 类加载器在进行类加载的时候,它首先不会自己去尝试加载这个类,而是把这个请求委派给父类加载器去完成(调用父加载器 loadClass()方法来加载类)。这样的话,所有的请求最终都会传送到顶层的启动类加载器 BootstrapClassLoader 中。 只有当父加载器反馈自己无法完成这个加载请求(它的搜索范围中没有找到所需的类)时,子加载器才会尝试自己去加载(调用自己的 findClass() 方法来加载类)。 如果子类加载器也无法加载这个类,那么它会抛出一个 ClassNotFoundException 异常。 🌈 拓展一下:\nJVM 判定两个 Java 类是否相同的具体规则:JVM 不仅要看类的全名是否相同,还要看加载此类的类加载器是否一样。只有两者都相同的情况,才认为两个类是相同的。即使两个类来源于同一个 Class 文件,被同一个虚拟机加载,只要加载它们的类加载器不同,那这两个类就必定不相同。\n双亲委派模型的好处 # 双亲委派模型保证了 Java 程序的稳定运行,可以避免类的重复加载(JVM 区分不同类的方式不仅仅根据类名,相同的类文件被不同的类加载器加载产生的是两个不同的类),也保证了 Java 的核心 API 不被篡改。\n如果没有使用双亲委派模型,而是每个类加载器加载自己的话就会出现一些问题,比如我们编写一个称为 java.lang.Object 类的话,那么程序运行的时候,系统就会出现两个不同的 Object 类。双亲委派模型可以保证加载的是 JRE 里的那个 Object 类,而不是你写的 Object 类。这是因为 AppClassLoader 在加载你的 Object 类时,会委托给 ExtClassLoader 去加载,而 ExtClassLoader 又会委托给 BootstrapClassLoader,BootstrapClassLoader 发现自己已经加载过了 Object 类,会直接返回,不会去加载你写的 Object 类。\n打破双亲委派模型方法 # 为了避免双亲委托机制,我们可以自己定义一个类加载器,然后重写 loadClass() 即可。\n🐛 修正(参见: issue871 ):自定义加载器的话,需要继承 ClassLoader 。如果我们不想打破双亲委派模型,就重写 ClassLoader 类中的 findClass() 方法即可,无法被父类加载器加载的类最终会通过这个方法被加载。但是,如果想打破双亲委派模型则需要重写 loadClass() 方法。\n为什么是重写 loadClass() 方法打破双亲委派模型呢?双亲委派模型的执行流程已经解释了:\n类加载器在进行类加载的时候,它首先不会自己去尝试加载这个类,而是把这个请求委派给父类加载器去完成(调用父加载器 loadClass()方法来加载类)。\n重写 loadClass()方法之后,我们就可以改变传统双亲委派模型的执行流程。例如,子类加载器可以在委派给父类加载器之前,先自己尝试加载这个类,或者在父类加载器返回之后,再尝试从其他地方加载这个类。具体的规则由我们自己实现,根据项目需求定制化。\n我们比较熟悉的 Tomcat 服务器为了能够优先加载 Web 应用目录下的类,然后再加载其他目录下的类,就自定义了类加载器 WebAppClassLoader 来打破双亲委托机制。这也是 Tomcat 下 Web 应用之间的类实现隔离的具体原理。\nTomcat 的类加载器的层次结构如下:\nTomcat 这四个自定义的类加载器对应的目录如下:\nCommonClassLoader对应\u0026lt;Tomcat\u0026gt;/common/* CatalinaClassLoader对应\u0026lt;Tomcat \u0026gt;/server/* SharedClassLoader对应 \u0026lt;Tomcat \u0026gt;/shared/* WebAppClassloader对应 \u0026lt;Tomcat \u0026gt;/webapps/\u0026lt;app\u0026gt;/WEB-INF/* 从图中的委派关系中可以看出:\nCommonClassLoader作为 CatalinaClassLoader 和 SharedClassLoader 的父加载器。CommonClassLoader 能加载的类都可以被 CatalinaClassLoader 和 SharedClassLoader 使用。因此,CommonClassLoader 是为了实现公共类库(可以被所有 Web 应用和 Tomcat 内部组件使用的类库)的共享和隔离。 CatalinaClassLoader 和 SharedClassLoader 能加载的类则与对方相互隔离。CatalinaClassLoader 用于加载 Tomcat 自身的类,为了隔离 Tomcat 本身的类和 Web 应用的类。SharedClassLoader 作为 WebAppClassLoader 的父加载器,专门来加载 Web 应用之间共享的类比如 Spring、Mybatis。 每个 Web 应用都会创建一个单独的 WebAppClassLoader,并在启动 Web 应用的线程里设置线程线程上下文类加载器为 WebAppClassLoader。各个 WebAppClassLoader 实例之间相互隔离,进而实现 Web 应用之间的类隔。 单纯依靠自定义类加载器没办法满足某些场景的要求,例如,有些情况下,高层的类加载器需要加载低层的加载器才能加载的类。\n比如,SPI 中,SPI 的接口(如 java.sql.Driver)是由 Java 核心库提供的,由BootstrapClassLoader 加载。而 SPI 的实现(如com.mysql.cj.jdbc.Driver)是由第三方供应商提供的,它们是由应用程序类加载器或者自定义类加载器来加载的。默认情况下,一个类及其依赖类由同一个类加载器加载。所以,加载 SPI 的接口的类加载器(BootstrapClassLoader)也会用来加载 SPI 的实现。按照双亲委派模型,BootstrapClassLoader 是无法找到 SPI 的实现类的,因为它无法委托给子类加载器去尝试加载。\n再比如,假设我们的项目中有 Spring 的 jar 包,由于其是 Web 应用之间共享的,因此会由 SharedClassLoader 加载(Web 服务器是 Tomcat)。我们项目中有一些用到了 Spring 的业务类,比如实现了 Spring 提供的接口、用到了 Spring 提供的注解。所以,加载 Spring 的类加载器(也就是 SharedClassLoader)也会用来加载这些业务类。但是业务类在 Web 应用目录下,不在 SharedClassLoader 的加载路径下,所以 SharedClassLoader 无法找到业务类,也就无法加载它们。\n如何解决这个问题呢? 这个时候就需要用到 线程上下文类加载器(ThreadContextClassLoader) 了。\n拿 Spring 这个例子来说,当 Spring 需要加载业务类的时候,它不是用自己的类加载器,而是用当前线程的上下文类加载器。还记得我上面说的吗?每个 Web 应用都会创建一个单独的 WebAppClassLoader,并在启动 Web 应用的线程里设置线程线程上下文类加载器为 WebAppClassLoader。这样就可以让高层的类加载器(SharedClassLoader)借助子类加载器( WebAppClassLoader)来加载业务类,破坏了 Java 的类加载委托机制,让应用逆向使用类加载器。\n线程上下文类加载器的原理是将一个类加载器保存在线程私有数据里,跟线程绑定,然后在需要的时候取出来使用。这个类加载器通常是由应用程序或者容器(如 Tomcat)设置的。\nJava.lang.Thread 中的getContextClassLoader()和 setContextClassLoader(ClassLoader cl)分别用来获取和设置线程的上下文类加载器。如果没有通过setContextClassLoader(ClassLoader cl)进行设置的话,线程将继承其父线程的上下文类加载器。\nSpring 获取线程线程上下文类加载器的代码如下:\ncl = Thread.currentThread().getContextClassLoader(); 感兴趣的小伙伴可以自行深入研究一下 Tomcat 打破双亲委派模型的原理,推荐资料: 《深入拆解 Tomcat \u0026amp; Jetty》。\n推荐阅读 # 《深入拆解 Java 虚拟机》 深入分析 Java ClassLoader 原理: https://blog.csdn.net/xyang81/article/details/7292380 Java 类加载器(ClassLoader): http://gityuan.com/2016/01/24/java-classloader/ Class Loaders in Java: https://www.baeldung.com/java-classloaders Class ClassLoader - Oracle 官方文档: https://docs.oracle.com/javase/8/docs/api/java/lang/ClassLoader.html 老大难的 Java ClassLoader 再不理解就老了: https://zhuanlan.zhihu.com/p/51374915 "},{"id":640,"href":"/zh/docs/technology/Interview/java/jvm/class-file-structure/","title":"类文件结构详解","section":"Jvm","content":" 回顾一下字节码 # 在 Java 中,JVM 可以理解的代码就叫做字节码(即扩展名为 .class 的文件),它不面向任何特定的处理器,只面向虚拟机。Java 语言通过字节码的方式,在一定程度上解决了传统解释型语言执行效率低的问题,同时又保留了解释型语言可移植的特点。所以 Java 程序运行时比较高效,而且,由于字节码并不针对一种特定的机器,因此,Java 程序无须重新编译便可在多种不同操作系统的计算机上运行。\nClojure(Lisp 语言的一种方言)、Groovy、Scala、JRuby、Kotlin 等语言都是运行在 Java 虚拟机之上。下图展示了不同的语言被不同的编译器编译成.class文件最终运行在 Java 虚拟机之上。.class文件的二进制格式可以使用 WinHex 查看。\n可以说.class文件是不同的语言在 Java 虚拟机之间的重要桥梁,同时也是支持 Java 跨平台很重要的一个原因。\nClass 文件结构总结 # 根据 Java 虚拟机规范,Class 文件通过 ClassFile 定义,有点类似 C 语言的结构体。\nClassFile 的结构如下:\nClassFile { u4 magic; //Class 文件的标志 u2 minor_version;//Class 的小版本号 u2 major_version;//Class 的大版本号 u2 constant_pool_count;//常量池的数量 cp_info constant_pool[constant_pool_count-1];//常量池 u2 access_flags;//Class 的访问标记 u2 this_class;//当前类 u2 super_class;//父类 u2 interfaces_count;//接口数量 u2 interfaces[interfaces_count];//一个类可以实现多个接口 u2 fields_count;//字段数量 field_info fields[fields_count];//一个类可以有多个字段 u2 methods_count;//方法数量 method_info methods[methods_count];//一个类可以有个多个方法 u2 attributes_count;//此类的属性表中的属性数 attribute_info attributes[attributes_count];//属性表集合 } 通过分析 ClassFile 的内容,我们便可以知道 class 文件的组成。\n下面这张图是通过 IDEA 插件 jclasslib 查看的,你可以更直观看到 Class 文件结构。\n使用 jclasslib 不光可以直观地查看某个类对应的字节码文件,还可以查看类的基本信息、常量池、接口、属性、函数等信息。\n下面详细介绍一下 Class 文件结构涉及到的一些组件。\n魔数(Magic Number) # u4 magic; //Class 文件的标志 每个 Class 文件的头 4 个字节称为魔数(Magic Number),它的唯一作用是确定这个文件是否为一个能被虚拟机接收的 Class 文件。Java 规范规定魔数为固定值:0xCAFEBABE。如果读取的文件不是以这个魔数开头,Java 虚拟机将拒绝加载它。\nClass 文件版本号(Minor\u0026amp;Major Version) # u2 minor_version;//Class 的小版本号 u2 major_version;//Class 的大版本号 紧接着魔数的四个字节存储的是 Class 文件的版本号:第 5 和第 6 个字节是次版本号,第 7 和第 8 个字节是主版本号。\n每当 Java 发布大版本(比如 Java 8,Java9)的时候,主版本号都会加 1。你可以使用 javap -v 命令来快速查看 Class 文件的版本号信息。\n高版本的 Java 虚拟机可以执行低版本编译器生成的 Class 文件,但是低版本的 Java 虚拟机不能执行高版本编译器生成的 Class 文件。所以,我们在实际开发的时候要确保开发的的 JDK 版本和生产环境的 JDK 版本保持一致。\n常量池(Constant Pool) # u2 constant_pool_count;//常量池的数量 cp_info constant_pool[constant_pool_count-1];//常量池 紧接着主次版本号之后的是常量池,常量池的数量是 constant_pool_count-1(常量池计数器是从 1 开始计数的,将第 0 项常量空出来是有特殊考虑的,索引值为 0 代表“不引用任何一个常量池项”)。\n常量池主要存放两大常量:字面量和符号引用。字面量比较接近于 Java 语言层面的的常量概念,如文本字符串、声明为 final 的常量值等。而符号引用则属于编译原理方面的概念。包括下面三类常量:\n类和接口的全限定名 字段的名称和描述符 方法的名称和描述符 常量池中每一项常量都是一个表,这 14 种表有一个共同的特点:开始的第一位是一个 u1 类型的标志位 -tag 来标识常量的类型,代表当前这个常量属于哪种常量类型.\n类型 标志(tag) 描述 CONSTANT_utf8_info 1 UTF-8 编码的字符串 CONSTANT_Integer_info 3 整形字面量 CONSTANT_Float_info 4 浮点型字面量 CONSTANT_Long_info 5 长整型字面量 CONSTANT_Double_info 6 双精度浮点型字面量 CONSTANT_Class_info 7 类或接口的符号引用 CONSTANT_String_info 8 字符串类型字面量 CONSTANT_FieldRef_info 9 字段的符号引用 CONSTANT_MethodRef_info 10 类中方法的符号引用 CONSTANT_InterfaceMethodRef_info 11 接口中方法的符号引用 CONSTANT_NameAndType_info 12 字段或方法的符号引用 CONSTANT_MethodType_info 16 标志方法类型 CONSTANT_MethodHandle_info 15 表示方法句柄 CONSTANT_InvokeDynamic_info 18 表示一个动态方法调用点 .class 文件可以通过javap -v class类名 指令来看一下其常量池中的信息(javap -v class类名-\u0026gt; temp.txt:将结果输出到 temp.txt 文件)。\n访问标志(Access Flags) # u2 access_flags;//Class 的访问标记 在常量池结束之后,紧接着的两个字节代表访问标志,这个标志用于识别一些类或者接口层次的访问信息,包括:这个 Class 是类还是接口,是否为 public 或者 abstract 类型,如果是类的话是否声明为 final 等等。\n类访问和属性修饰符:\n我们定义了一个 Employee 类\npackage top.snailclimb.bean; public class Employee { ... } 通过javap -v class类名 指令来看一下类的访问标志。\n当前类(This Class)、父类(Super Class)、接口(Interfaces)索引集合 # u2 this_class;//当前类 u2 super_class;//父类 u2 interfaces_count;//接口数量 u2 interfaces[interfaces_count];//一个类可以实现多个接口 Java 类的继承关系由类索引、父类索引和接口索引集合三项确定。类索引、父类索引和接口索引集合按照顺序排在访问标志之后,\n类索引用于确定这个类的全限定名,父类索引用于确定这个类的父类的全限定名,由于 Java 语言的单继承,所以父类索引只有一个,除了 java.lang.Object 之外,所有的 Java 类都有父类,因此除了 java.lang.Object 外,所有 Java 类的父类索引都不为 0。\n接口索引集合用来描述这个类实现了哪些接口,这些被实现的接口将按 implements (如果这个类本身是接口的话则是extends) 后的接口顺序从左到右排列在接口索引集合中。\n字段表集合(Fields) # u2 fields_count;//字段数量 field_info fields[fields_count];//一个类会可以有个字段 字段表(field info)用于描述接口或类中声明的变量。字段包括类级变量以及实例变量,但不包括在方法内部声明的局部变量。\nfield info(字段表) 的结构:\naccess_flags: 字段的作用域(public ,private,protected修饰符),是实例变量还是类变量(static修饰符),可否被序列化(transient 修饰符),可变性(final),可见性(volatile 修饰符,是否强制从主内存读写)。 name_index: 对常量池的引用,表示的字段的名称; descriptor_index: 对常量池的引用,表示字段和方法的描述符; attributes_count: 一个字段还会拥有一些额外的属性,attributes_count 存放属性的个数; attributes[attributes_count]: 存放具体属性具体内容。 上述这些信息中,各个修饰符都是布尔值,要么有某个修饰符,要么没有,很适合使用标志位来表示。而字段叫什么名字、字段被定义为什么数据类型这些都是无法固定的,只能引用常量池中常量来描述。\n字段的 access_flag 的取值:\n方法表集合(Methods) # u2 methods_count;//方法数量 method_info methods[methods_count];//一个类可以有个多个方法 methods_count 表示方法的数量,而 method_info 表示方法表。\nClass 文件存储格式中对方法的描述与对字段的描述几乎采用了完全一致的方式。方法表的结构如同字段表一样,依次包括了访问标志、名称索引、描述符索引、属性表集合几项。\nmethod_info(方法表的) 结构:\n方法表的 access_flag 取值:\n注意:因为volatile修饰符和transient修饰符不可以修饰方法,所以方法表的访问标志中没有这两个对应的标志,但是增加了synchronized、native、abstract等关键字修饰方法,所以也就多了这些关键字对应的标志。\n属性表集合(Attributes) # u2 attributes_count;//此类的属性表中的属性数 attribute_info attributes[attributes_count];//属性表集合 在 Class 文件,字段表,方法表中都可以携带自己的属性表集合,以用于描述某些场景专有的信息。与 Class 文件中其它的数据项目要求的顺序、长度和内容不同,属性表集合的限制稍微宽松一些,不再要求各个属性表具有严格的顺序,并且只要不与已有的属性名重复,任何人实现的编译器都可以向属性表中写 入自己定义的属性信息,Java 虚拟机运行时会忽略掉它不认识的属性。\n参考 # 《实战 Java 虚拟机》 Chapter 4. The class File Format - Java Virtual Machine Specification: https://docs.oracle.com/javase/specs/jvms/se8/html/jvms-4.html 实例分析 JAVA CLASS 的文件结构: https://coolshell.cn/articles/9229.html 《Java 虚拟机原理图解》 1.2.2、Class 文件中的常量池详解(上): https://blog.csdn.net/luanlouis/article/details/39960815 "},{"id":641,"href":"/zh/docs/technology/Interview/high-quality-technical-articles/work/employee-performance/","title":"聊聊大厂的绩效考核","section":"Work","content":" 内容概览:\n在大部分公司,绩效跟你的年终奖、职级晋升、薪水涨幅等等福利是直接相关的。 你的上级、上上级对你的绩效拥有绝对的话语权,这是潜规则,放到任何公司都是。成年人的世界,没有绝对的公平,绩效考核尤为明显。 提升绩效的打法: 短期打法:找出 1-2 件事,体现出你的独特价值(抓关键事件)。 长期打法:通过一步步信任的建立,成为团队的核心人员或者是老板的心腹,具备不可替代性。 原文地址: https://mp.weixin.qq.com/s/D1s8p7z8Sp60c-ndGyh2yQ\n在新公司度过了一个完整的 Q3 季度,被打了绩效,也给下属打了绩效,感慨颇深。\n今天就好好聊聊大厂打工人最最关心的「绩效考核」,谈谈它背后的逻辑以及潜规则,摸清楚了它,你在大厂这片丛林里才能更好的生存下去。\n大厂的绩效到底有多重要? # 先从公司角度,谈谈为什么需要绩效考核?\n有一个著名的管理者言论,即:企业战略的上三路和下三路。\n上三路是使命、愿景、价值观,下三路是组织、人才、KPI。下三路需要确保上三路能执行下去,否则便是空谈。那怎么才能达成呢?\n马老板在湖畔大学的课堂上,对底下众多 CEO 学员说,“只能靠 KPI。没有 KPI,一切都是空话,组织和公司是不会进步的”。\n所以,KPI 一般是用来承接企业战略的。身处大厂的打工者们,也能深深感受到:每个季度的 KPI 是如何从大 Boss、到 Boss、再到基层,一层层拆解下来的,最终让所有人朝着一个方向行动,这便是 KPI 对于公司的意义。\n然鹅,并非每个员工都会站在 CEO 的高度去理解 KPI 的价值,大家更关注的是 KPI 对于我个人来说到底有什么意义?\n在互联网大厂,每家公司都会设定一套绩效考核体系,字节用的是 OKR,阿里用的是 KPI,通常都是「271」 制度,即:\n20% 的比例是 A+ 和 A,对应明星员工。\n70% 的比例是 B,对应普通员工。\n10% 的比例是 C 和 C-,对应需要绩效改进或者淘汰的员工。\n有了三六九等,然后才有了利益分配。\n在大厂,绩效结果跟奖金、晋升、薪水涨幅、股票授予是直接相关的。在内卷的今天,甚至可以直接划上等号。\n绩效好的员工,奖金必然多,一年可能调薪两次,晋升答辩时能 PK 掉绩效一般的人,职级低的人甚至可以晋升免试。\n而绩效差的人,有可能一年白干,甚至走人(大厂的末尾淘汰是不成文的规定)。\n总之,你能想到的直接利益都和「绩效」息息相关。所以,在大厂这片高手众多的丛林里,多琢磨下绩效背后的逻辑,既是生存之道,更是一技之长。\n你是怎么看待绩效的? # 凡是用来考核人的规则,大部分人在潜意识里都想去突破它,而不是被束缚。\n至少在我刚工作的前几年,看着身边有些同事因为背个 C 黯然离开的时候,觉得绩效考核就是一个冷血的管理工具。\n尤其遇到自己看不上的领导时,对于他给我打的绩效,其实也是很不屑的。\n到今天,实在见过太多的反面案例了,自己也踩过一些坑,逐渐认识到:当初的想法除了让自己心里爽一点,好像起不到任何作用,甚至会让我的工作方式变形。\n当思维方式变了,也就改变了我对绩效的态度,至少有两点我认为是打工人需要看清的。\n第一,你的上级、上上级对你的绩效拥有绝对的话语权,这是潜规则,放到任何公司都是。\n大家可以去看看身边发展特别好的人,除了有很强的个人能力以外,几乎都是善于利用规则,而不是去挑战规则的人。\n当然,我并不是说你要一味地去跪舔你的领导,而是表达:工作中不要站在领导的对立面去做对抗,如果领导做法很过分,要么直接沟通去影响他,要么选择离开。\n第二,成年人的世界,没有绝对的公平,绩效考核尤为明显。\n我所待过的团队,绩效考核还是相对公平的,虽然也存在受照顾的情况,但都是个例。\n另外就是,技术岗的绩效考核不同于销售或者运营岗,很容易指标化。\n需求吞吐量、BUG 数、线上事故\u0026hellip; 的确有一大堆研发效能指标,但这些指标在绩效考核时是否会被参考?具体又该如何分配比重?本身就是一个扯不清楚的难题。\n最终决定你绩效结果的还是你领导的主观判断。你所见到的 360 环评,以及弄一些指标排序,这些都只是将绩效结果合理化的一种方式,并非关键所在。\n因此,多琢磨如何去影响你的领导?站在他的视角去审视他在绩效考核时到底关注哪些核心点?这才是至关重要的。\n上面讲了一堆潜规则,是不是意味着绩效考核是可以投机取巧,完全不看工作业绩呢,当然不是。\n“你的努力不一定会被看见”、“你的努力应该有的放矢”,大家先记住这两条。\n下面我再展开聊聊,大家最最关心的 A 和 C,它们背后的逻辑。\n绩效被打 A 和 C 的逻辑是什么? # “铆足了劲拿不到 A,一不留神居然拿了个 C”,这是绝大多数打工人最真实的职场现状。\nA 和 C 属于绩效的两个极端,背后的逻辑类似,反着理解即可,下面我详细分析下 C。\n先从我身边人的情况说起,我所看到的案例绝大多数都属于:绩效被打了 C,完全没有任何预感,主管跟他沟通结果时,还是一脸懵逼,“为什么会给我打 C?一定是黑我呀!”。\n前阵子听公司一位大佬分享,用他的话说,这种人就是没有「角色认知」,他不知道他所处的角色和职级该做好哪些事?做成什么样才算「做好了」?被打 C 后自然觉得是在背锅。\n所以,务必确保你对于当前角色是认知到位的,这样才称得上进入了「工作状态」,否则你的一次松懈,一段不太好的表现,很可能导致 C 落在你的头上,岗位越高,摔得越重。\n有了角色认知,再说下对绩效的认知。\n第一,团队很优秀,是不是不用背 C?不是!大厂的 C 都是强制分配的,再优秀的团队也会有 C。所以团队越厉害,竞争越惨烈。\n第二,完成了 KPI,没有工作失误,是不是就万事大吉,不用背 C?不是,绩效是相对的,你必须清楚你在团队所处的位置,你在老板眼中的排序,慢慢练出这种嗅觉。\n懂了上面这些道理,很自然就能知道打 C 的逻辑,C 会集中在两类人上:\n1、工作表现称不上角色要求的人。\n2、在老板眼里排序靠后,就算离开,对团队影响也很小的人。\n要规避 C,有两种打法。\n第 1 种是短期打法:抓关键事件,能不能找出 1-2 件事,体现出你的独特价值(比如本身影响力很大的项目,或者是领导最重视的事),相当于让你的排序有了最基本的保障。\n这种打法,你不能等到评价时再去改变,一定是在前期就抓住机会,承担起最有挑战的任务,然后全力以赴,做好了拿 A,不弄砸也不至于背 C,就怕静水潜流,躺平了去工作。\n第 2 种是长期打法:通过一步步信任的建立,成为团队的核心人员或者是老板的心腹,具备不可替代性。\n上面两种打法都是大的思路,还有很多锦上添花的技巧,比如:加强主动汇报(抹平领导的信息差)、让关键干系人给你点赞(能影响到你领导做出绩效决策的人)。\n写在最后 # 有人的地方就有江湖,有江湖就一定有规则,大厂平面看似平静,其实在绩效考核、晋升等利益点面前,都是一场厮杀。\n当大家攻山头的能力都很强时,==到底做成什么样才算做好了?==当你弄清楚了这个玄机,职场也就看透了。\n如果这篇文章让你有一点启发,来个点赞和在看呀!我是武哥,我们下期见!\n"},{"id":642,"href":"/zh/docs/technology/Interview/high-quality-technical-articles/advanced-programmer/meituan-three-year-summary-lesson-10/","title":"美团三年,总结的10条血泪教训","section":"Advanced Programmer","content":" 推荐语:作者用了很多生动的例子和故事展示了自己在美团的成长和感悟,看了之后受益颇多!\n内容概览:\n本文的作者提出了以下十条建议,希望能对其他职场人有所启发和帮助:\n结构化思考与表达,提高个人影响力 忘掉职级,该怼就怼,推动事情往前走 用好平台资源,结识优秀的人,学习通识课 一切都是争取来的,不要等待机会,要主动寻求 关注商业,升维到老板思维,看清趋势,及时止损 培养数据思维,利用数据了解世界,指导决策 做一个好\u0026quot;销售\u0026quot;,无论是自己还是产品,都要学会展示和说服 少加班多运动,保持身心健康,提高工作效率 有随时可以离开的底气,不要被职场所困,借假修真,提升自己 只是一份工作,不要过分纠结,相信自己,走出去看看 原文地址: https://mp.weixin.qq.com/s/XidSVIwd4oKkDKEICaY1mQ\n在美团的三年多时光,如同一部悠长的交响曲,高高低低,而今离开已有一段时间。闲暇之余,梳理了三年多的收获与感慨,总结成 10 条,既是对过去一段时光的的一个深情回眸,也是对未来之路的一份期许。\n倘若一些感悟能为刚步入职场的年轻人,或是刚在职业生涯中崭露头角的后起之秀,带来一点点启示与帮助,也是莫大的荣幸。\n01 结构化思考与表达 # 美团是一家特别讲究方法论的公司,人人都要熟读四大名著《高效能人士的七个习惯》、《金字塔原理》、《用图表说话》和《学会提问》。\n与结构化思考和表达相关的,是《金字塔原理》,作者是麦肯锡公司第一位女性咨询顾问。这本书告诉我们,思考和表达的过程,就像构建金字塔(或者构建一棵树),先有整体结论,再寻找证据,证据之间要讲究相互独立、而且能穷尽(MECE 原则),论证的过程也要按特定的顺序进行,比如时间顺序、空间顺序、重要性顺序……\n作为大厂社畜,日常很大一部分工作就是写文档、看别人文档。大家做的事,但最后呈现的结果却有很大差异。一篇逻辑清晰、详略得当的文档,给人一种如沐春风的感受,能提炼出重要信息,是好的参考指南。\n结构化思考与表达算是职场最通用的能力,也是打造个人影响力最重要的途径之一。\n02 忘掉职级,该怼就怼 # 在阿里工作时,能看到每个人的 Title,看到江湖地位高(职级高+入职时间早)的同学,即便跟自己没有汇报关系,不自然的会多一层敬畏。推进工作时,会多一层压力,对方未读或已读未回时,不知如何应对。\n美团只能看到每个人的坑位信息,还有 Ta 的上级。工作相关的问题,可以向任何人提问,如果协同方没有及时响应,隔段时间@一次,甚至\u0026quot;怼一怼\u0026quot;,都没啥问题,事情一直往前推进才最重要。除了大象消息直接提问外,还有个大杀器\u0026ndash;TT(公司级问题流转系统),在上面提问时,加上对方主管,如果对方未及时回应,问题会自动升级,每天定时 Push,直到解决为止。\n我见到一些很年轻的同事,他们在推动 OKR、要资源的事上,很有一套,只要能达到自己的目标,不会考虑别人的感受,最终,他们还真能把事办成。\n当然了,段位越高的人,越能用自己的人格魅力、影响力、资源等,去影响和推动事情的进程,而不是靠对他人的 Push。只是在拿结果的事上,不要把自己太当回事,把别人太当回事,大家在一起,也只是为了完成各自的任务,忘掉职级,该怼时还得怼。\n03 用好平台资源 # 没有人能在一家公司待一辈子,公司再牛,跟自己关系不大,重要的是,在有限的时间内,最大化用好平台资源。\n在美团除了认识自己节点的同事外,有幸认识一群特别棒的协作方,还有其他 BU 的同学。\n这些优秀的人身上,有很多共同的特质:谦虚、利他、乐于分享、双赢思维。\n有两位做运营的同学。\n一位是无意中关注他公众号结识上的。他公众号记录了很多职场成长、家庭建造上的思考和收获,还有定期个人复盘。他和太太都是大厂中层管理者,从文章中看到的不是他多厉害,而是非常接地气的故事。我们约饭了两次,有很多共同话题,现在还时不时有一些互动。\n一位职级更高的同学,他在内网发起了一个\u0026quot;请我喝一杯咖啡,和我一起聊聊个人困惑\u0026quot;的活动,我报名参与了一期。和他聊天的过程,特别像是一场教练对话(最近学习教练课程时才感受到的),帮我排除干扰、聚焦目标的同时,也从他分享个人成长蜕变的过程,收获很多动力。(刚好自己最近也学习了教练技术,后面也准备采用类似的方式,去帮助曾经像我一样迷茫的人)\n还有一些协作方同学。他们工作做得超级到位,能感受到,他们在乎他人时间;稍微有点出彩的事儿,不忘记拉上更多人。利他和双赢思维,在他们身上是最好的阐释。\n除了结识优秀的人,向他们学习外,还可以关注各个通道/工种的课程资源。\n在大厂,多数人的角色都是螺丝钉,但千万不要局限于做一颗螺丝钉。多去学习一些通识课,了解商业交付的各个环节,看清商业世界,明白自己的定位,超越自己的定位。\n04 一切都是争取来的 # 工作很多年了,很晚才明白这个道理。\n之前一直认为,只要做好自己该做的,一定会被看见,被赏识,也会得到更多机会。但很多时候,这只是个人的一厢情愿。除了自己,不会有人关心你的权益。\n社会主义初级阶段,我国国内的主要矛盾是人民日益增长的物质文化需要同落后的社会生产之间的矛盾。无论在哪里,资源都是稀缺的,自己在乎的,就得去争取。\n想成长某个技能、想参与哪个模块、想做哪个项目,升职加薪……自己不提,不去争取,不会有人主动给你。\n争不争取是一回事,能不能得到是一回事,只有争取,才有可能得到。争取了,即便没有得到,最终也没失去什么。\n05 关注商业 # 大公司,极度关注效率,大部分岗位,拆解的粒度越细,效率会越高,这些对组织是有利的。但对个人来说,则很容易螺丝钉化。\n做技术的同学,更是这样。\n做前端的同学,不会关注数据是如何落库的;做后端的同学,不会思考页面是否存在兼容性问题;做业务开发的,不用考虑微服务诸多中间件是如何搭建起来的……\n大部分人都想着怎么把自己这摊子事搞好,不会去思考上下游同学在做些什么,更少有人真正关注商业,关心公司的盈利模式,关心每一次产品迭代到底带来哪些业务价值。\n把手头的事做好是应该的,但绝不能停留在此。所有的产品,只有在商业社会产生交付,让客户真正获益,才是有价值的。\n关注商业,能帮我们升维到老板思维,明白投入产出比,抓大放小;也帮助我们,在碰到不好的业务时,及时止损;更重要的是,它帮助我们真正看清趋势,提前做好准备。\n《五分钟商学院》系列,是很好的商业入门级书籍。尽管作者刘润最近存在争议,但不可否认,他比我们大多数人段位还是高很多,他的书值得一读。\n06 培养数据思维 # 当今数字化时代,数据思维显得尤为重要。数据不仅可以帮助我们更好地了解世界,还可以指导我们的决策和行动。\n非常幸运的是,在阿里和美团的两份经历,都是做商业化广告业务,在离钱💰最近的地方,也培养了数据的敏感性。见过商业数据指标的定义、加工、生产和应用全流程,也在不断熏陶下,能看懂大部分指标背后的价值。\n除了直接面向业务的数据,还有研发协作全流程产生的数据。数据被记录和汇总统计后,能直观地看到每个环节的效率和质量。螺丝钉们的工作,也彻彻底底被数字量化,除了积极面对虚拟化、线上化、数字化外,我们别无他法。\n受工作数据化的影响,生活中,我也渐渐变成了一个数据记录狂,日常运动(骑行、跑步、健走等)必须通过智能手表记录下来,没带 Apple Watch,感觉这次白运动了。每天也在很努力地完成三个圆环。\n数据时代,我们沦为了透明人。也得益于数据被记录和分析,我们做任何事,都能快速得到反馈,这也是自我提升的一个重要环节。\n07 做一个好\u0026quot;销售\u0026quot; # 就某种程度来说,所有的工作,本质都是销售。\n这是很多大咖的观点,我也是很晚才明白这个道理。\n我们去一家公司应聘,本质上是在讲一个「我很牛」的故事,销售的是自己;日常工作汇报、季度/年度述职、晋升答辩,是在销售自己;在任何一个场合曝光,也是在销售自己。\n如果我们所服务的组织,对外提供的是一件产品或一项服务,所有上下游协作的同学,唯一在做的事就是,齐心协力把产品/服务卖出去, 我们本质做的还是销售。\n所以, 千万不要看不起任何销售,也不要认为认为销售是一件很丢面子的事。\n真正的大佬,随时随地都在销售。\n08 少加班多运动 # 在职场,大家都认同一个观点,工作是做不完的。\n我们要做的是,用好时间管理四象限法,识别重要程度和优先级,有限时间,聚焦在固定几件事上。\n这要求我们不断提高自己的问题识别能力、拆解能力,还有专注力。\n我们会因为部分项目的需要而加班,但不会长期加班。\n加班时间短一点,就能腾出更多时间运动。\n最近一次线下培训课,认识一位老师 Hubert,Hubert 是一位超级有魅力的中年大叔(可以通过「有意思教练」的课程链接到他),从外企高管的位置离开后,和太太一起创办了一家培训机构。作为公司高层,日常工作非常忙,头发也有些花白了,但一身腱子肉胜过很多健身教练,给人的状态也是很年轻。聊天得知,Hubert 经常 5 点多起来泡健身房~\n我身边还有一些同事,跟我年龄差不多,因为长期加班,发福严重,比实际年龄看起来苍老 10+岁;\n还有同事曾经加班进 ICU,幸好后面身体慢慢恢复过来。\n某某厂员工长期加班猝死的例子,更是屡见不鲜。\n减少加班,增加运动,绝对是一件性价比极高的事。\n09 有随时可以离开的底气 # 当今职场,跟父辈时候完全不一样,职业的多样性和变化性越来越快,很少有人能够在同一份工作或同一个公司待一辈子。除了某些特定的岗位,如公务员、事业单位等,大多数人都会在职业生涯中经历多次的职业变化和调整。\n在商业组织里,个体是弱势群体,但不要做弱者。每一段职场,每一项工作,都是上天给我们的修炼。\n我很喜欢\u0026quot;借假修真\u0026quot;这个词。我们参与的大大小小的项目, 重要吗?对公司来说可能重要,对个人来说,则未必。我们去做,一方面是迫于生计;\n另外一方面,参与每个项目的感悟、心得、体会,是真实存在的,很多的能力,都是在这个过程得到提升。\n明白这一点,就不会被职场所困,会刻意在各样事上提升自己,积累的越多,对事务的本质理解的越深、越广,也越发相信很多底层知识是通用的,内心越平静,也会建立起随时都可以离开的底气。\n10 只是一份工作 # 工作中,我们时常会遇到各种挑战和困难,如发展瓶颈、难以处理的人和事,甚至职场 PUA 等。这些经历可能会让我们感到疲惫、沮丧,甚至怀疑自己的能力和价值。然而,重要的是要明白,困难只是成长道路上的暂时阻碍,而不是我们的定义。\n写总结和复盘是很好的方式,可以帮我们理清思路,找到问题的根源,并学习如何应对类似的情况。但也要注意不要陷入自我怀疑和内耗的陷阱。遇到困难时,应该学会相信自己,积极寻找解决问题的方法,而不是过分纠结于自己的不足和错误。\n内网常有同学匿名分享工作压力过大,常常失眠甚至中度抑郁,每次看到这些话题,非常难过。大环境不好,是不争的事实,但并不代表个体就没有出路。\n我们容易预设困难,容易加很多\u0026quot;可是\u0026quot;,当窗户布满灰尘时,不要试图努力把窗户擦干净,走出去吧,你将看到一片蔚蓝的天空。\n最后 # 写到最后,特别感恩美团三年多的经历。感谢我的 Leader 们,感谢曾经并肩作战过的小伙伴,感谢遇到的每一位和我一样在平凡的岗位,努力想带给身边一片微光的同学。所有的相遇,都是缘分。\n"},{"id":643,"href":"/zh/docs/technology/Interview/system-design/security/sentive-words-filter/","title":"敏感词过滤方案总结","section":"Security","content":"系统需要对用户输入的文本进行敏感词过滤如色情、政治、暴力相关的词汇。\n敏感词过滤用的使用比较多的 Trie 树算法 和 DFA 算法。\n算法实现 # Trie 树 # Trie 树 也称为字典树、单词查找树,哈希树的一种变种,通常被用于字符串匹配,用来解决在一组字符串集合中快速查找某个字符串的问题。像浏览器搜索的关键词提示就可以基于 Trie 树来做的。\n假如我们的敏感词库中有以下敏感词:\n高清视频 高清 CV 东京冷 东京热 我们构造出来的敏感词 Trie 树就是下面这样的:\n当我们要查找对应的字符串“东京热”的话,我们会把这个字符串切割成单个的字符“东”、“京”、“热”,然后我们从 Trie 树的根节点开始匹配。\n可以看出, Trie 树的核心原理其实很简单,就是通过公共前缀来提高字符串匹配效率。\nApache Commons Collections 这个库中就有 Trie 树实现:\nTrie\u0026lt;String, String\u0026gt; trie = new PatriciaTrie\u0026lt;\u0026gt;(); trie.put(\u0026#34;Abigail\u0026#34;, \u0026#34;student\u0026#34;); trie.put(\u0026#34;Abi\u0026#34;, \u0026#34;doctor\u0026#34;); trie.put(\u0026#34;Annabel\u0026#34;, \u0026#34;teacher\u0026#34;); trie.put(\u0026#34;Christina\u0026#34;, \u0026#34;student\u0026#34;); trie.put(\u0026#34;Chris\u0026#34;, \u0026#34;doctor\u0026#34;); Assertions.assertTrue(trie.containsKey(\u0026#34;Abigail\u0026#34;)); assertEquals(\u0026#34;{Abi=doctor, Abigail=student}\u0026#34;, trie.prefixMap(\u0026#34;Abi\u0026#34;).toString()); assertEquals(\u0026#34;{Chris=doctor, Christina=student}\u0026#34;, trie.prefixMap(\u0026#34;Chr\u0026#34;).toString()); Trie 树是一种利用空间换时间的数据结构,占用的内存会比较大。也正是因为这个原因,实际工程项目中都是使用的改进版 Trie 树例如双数组 Trie 树(Double-Array Trie,DAT)。\nDAT 的设计者是日本的 Aoe Jun-ichi,Mori Akira 和 Sato Takuya,他们在 1989 年发表了一篇论文 《An Efficient Implementation of Trie Structures》,详细介绍了 DAT 的构造和应用,原作者写的示例代码地址: https://github.com/komiya-atsushi/darts-java/blob/e2986a55e648296cc0a6244ae4a2e457cd89fb82/src/main/java/darts/DoubleArrayTrie.java。相比较于 Trie 树,DAT 的内存占用极低,可以达到 Trie 树内存的 1%左右。DAT 在中文分词、自然语言处理、信息检索等领域有广泛的应用,是一种非常优秀的数据结构。\nAC 自动机 # Aho-Corasick(AC)自动机是一种建立在 Trie 树上的一种改进算法,是一种多模式匹配算法,由贝尔实验室的研究人员 Alfred V. Aho 和 Margaret J.Corasick 发明。\nAC 自动机算法使用 Trie 树来存放模式串的前缀,通过失败匹配指针(失配指针)来处理匹配失败的跳转。关于 AC 自动机的详细介绍,可以查看这篇文章: 地铁十分钟 | AC 自动机。\n如果使用上面提到的 DAT 来表示 AC 自动机 ,就可以兼顾两者的优点,得到一种高效的多模式匹配算法。Github 上已经有了开源 Java 实现版本: https://github.com/hankcs/AhoCorasickDoubleArrayTrie 。\nDFA # DFA(Deterministic Finite Automata)即确定有穷自动机,与之对应的是 NFA(Non-Deterministic Finite Automata,不确定有穷自动机)。\n关于 DFA 的详细介绍可以看这篇文章: 有穷自动机 DFA\u0026amp;NFA (学习笔记) - 小蜗牛的文章 - 知乎 。\nHutool 提供了 DFA 算法的实现:\nWordTree wordTree = new WordTree(); wordTree.addWord(\u0026#34;大\u0026#34;); wordTree.addWord(\u0026#34;大憨憨\u0026#34;); wordTree.addWord(\u0026#34;憨憨\u0026#34;); String text = \u0026#34;那人真是个大憨憨!\u0026#34;; // 获得第一个匹配的关键字 String matchStr = wordTree.match(text); System.out.println(matchStr); // 标准匹配,匹配到最短关键词,并跳过已经匹配的关键词 List\u0026lt;String\u0026gt; matchStrList = wordTree.matchAll(text, -1, false, false); System.out.println(matchStrList); //匹配到最长关键词,跳过已经匹配的关键词 List\u0026lt;String\u0026gt; matchStrList2 = wordTree.matchAll(text, -1, false, true); System.out.println(matchStrList2); 输出:\n大 [大, 憨憨] [大, 大憨憨] 开源项目 # ToolGood.Words:一款高性能敏感词(非法词/脏字)检测过滤组件,附带繁体简体互换,支持全角半角互换,汉字转拼音,模糊搜索等功能。 sensitive-words-filter:敏感词过滤项目,提供 TTMP、DFA、DAT、hash bucket、Tire 算法支持过滤。可以支持文本的高亮、过滤、判词、替换的接口支持。 论文 # 一种敏感词自动过滤管理系统 一种网络游戏中敏感词过滤方法及系统 "},{"id":644,"href":"/zh/docs/technology/Interview/high-quality-technical-articles/interview/summary-of-spring-recruitment/","title":"普通人的春招总结(阿里、腾讯offer)","section":"Interview","content":" 推荐语:牛客网热帖,写的很全面!暑期实习,投了阿里、腾讯、字节,拿到了阿里和腾讯的 offer。\n原文地址: https://www.nowcoder.com/discuss/640519\n下篇: 十年饮冰,难凉热血——秋招总结\n背景 # 写这篇文章的时候,腾讯 offer 已经下来了,春招也算结束了,这次找暑期实习没有像去年找日常实习一样海投,只投了 BAT 三家,阿里和腾讯收获了 offer,字节没有给面试机会,可能是笔试太拉垮了。\n楼主大三,双非本科,我的春招的起始时间应该是 2 月 20 日到 3 月 23 日收到阿里意向书为止,但是从 3 月 7 日蚂蚁技术终面面完之后就没有面过技术面了,只面过两个 HR 面,剩下的时间都在等 offer。最开始是找朋友内推了字节财经的日常实习,但是到现在还在简历评估,后面又投了财经的暑期实习,笔试之后就一直卡在流程里了。腾讯是一开始被天美捞了,一面挂了之后被 PCG 捞了,最后走完了流程。阿里提前批投了好多部门,蚂蚁最先走完了终面,就录入了系统,最后拿了 offer。这一路走过来真的是酸甜苦辣都经历过,因为学历自卑过,以至于想去考研。总而言之,一定要找一个搭档和你一起复习,比如说 @你怕是个憨批哦,这是我实验室的同学,也是我们实验室的队长,这个人是真的强,阿里核心部门都拿遍了,他在我复习的过程中给了我很多帮助。\n写这个帖子的目的 # 写给自己:总结反思一下大学前三年以及找工作的一些经历与感悟。 写给还在找实习的朋友:希望自己的经历以及面经]能给你们一些启发和帮助。 写给和我一样有着大厂梦的学弟学妹们:你们还有很长的准备时间,无论你之前在干什么,没有目标也好,碌碌无为也好,没找对方向也好,只要从现在开始,找对学习的方向,并且坚持不懈的学上一年两年,一定可以实现你的梦想的。 我的大学经历 # 先简单聊聊一下自己大学的经历。\n本人无论文、无比赛、无 ACM,要啥奖没啥奖,绩点还行,不是很拉垮,也不亮眼。保研肯定保不了,考研估计也考不上。\n大一时候加入了工作室,上学期自学了 C 语言和数据结构,从寒假开始学 Java,当时还不知道 Java 那么卷,我得到的消息是 Java 好找工作,这里就不由得感叹信息差的重要性了,我当时只知道前端、后端和安卓开发,而我确实对后端开发感兴趣,但是因为信息差,我只知道 Java 可以做后端开发,并不知道后端开发其实是一个很局限的概念,后面才慢慢了解到后台开发、服务端开发这些名词,也不知道 C++、Golang 等语言也可以做后台开发,所以就学了 Java。但其实 Java 更适合做业务,C++ 更适合做底层开发、服务端开发,我虽然对业务不反感,但是对 OS、Network 这些更感兴趣一些,当然这些会作为我的一些兴趣,业余时间会自己去研究下。\n学习路线 # 大概学习的路线就是:Java SE 基础 -\u0026gt; MySQL -\u0026gt; Java Web(主要包括 JDBC、Servlet、JSP 等)-\u0026gt; SSM(其实当时 Spring Boot 已经兴起,但是我觉得没有 SSM 基础很难学会 Spring Boot,就先学了 SSM)-\u0026gt; Spring Boot -\u0026gt; Spring Cloud(当时虽然学了 Spring Cloud,但是缺少项目的锤炼,完全不会用,只是了解了分布式的一些概念)-\u0026gt; Redis -\u0026gt; Nginx -\u0026gt; 计算机网络(本来是计算机专业的必修课,可是我们专业要到大三下才学,所以就提前自学了)-\u0026gt; Dubbo -\u0026gt; Zookeeper -\u0026gt; JVM -\u0026gt; JUC -\u0026gt; Netty -\u0026gt; Rabbit MQ -\u0026gt; 操作系统(同计算机网络)-\u0026gt; 计算机组成原理(直接不开这门课)。\n这就是我的一个具体的学习路线,大概是在大二的下学期学完的这些东西,都是通过看视频学的,只会用,并不了解底层原理,达不到面试八股文的水准,把这些东西学完之后,搭建起了知识体系,就开始准备面试了,大概的开始时间是去年的六月份,开始在牛客网上看一些面经,然后会自己总结。准备面试的阶段我觉得最重要的是啃书 + 刷题,八股文只是辅助,我们只是自嘲说面试就背背八股文,但其实像阿里这样的公司,背八股文是完全不能蒙混过关的,除非你有非常亮眼的项目或者实习经历。\n书籍推荐 # 《Thinking in Java》:不多说了,好书,但太厚了,买了没看。 《深入理解 Java 虚拟机》:JVM 的圣经,看了两遍,每一遍都有不同的收获。 《Java 并发编程的艺术》:阿里人写的,基本涵盖了面试会问的并发编程的问题。 《MySQL 技术内幕》:写的很深入,但是对初学者可能不太友好,第一感觉写的比较深而杂,后面单独去看每一章节,觉得收获很大。 《Redis 设计与实现》:书如其名,结合源码深入讲解了 Redis 的实现原理,必看。 《深入理解计算机系统》:大名鼎鼎的 CSAPP,对你面 Java 可能帮助不是很大,但是不得不说这是一本经典,涵盖了计算机系统、体系结构、组成原理、操作系统等知识,我蚂蚁二面的时候就被问了遇到的最大的困难,我就和面试官交流了读这本书中遇到的一些问题,淘系二面的时候也和面试官交流了这本书,我们都觉得这本书还需要二刷。 《TCP/IP 详解卷 1》:我只看了 TCP 相关的章节,但是是有必要通读一遍的,面天美时候和面试官交流了这本书。 《操作系统导论》:颇具盛名的 OSTEP,南大操作系统的课本,看的时候可以结合在 B 站蒋炎岩老师的视频,我会在下面放链接。 这几本书理解透彻了,我相信面试的时候可以面试官面试官聊的很深入了,面试官也会对你印象非常好。但是对于普通人来说,看一遍是肯定记不住的,遗忘是非常正常的现象,我很多也只看了一遍,很多细节也记不清了,最近准备二刷。\n更多书籍推荐建议大家看 JavaGuide 这个网站上的书籍推荐,比较全面。\n教程推荐 # 我上面谈到的学习路线,我建议是跟着视频学,尚硅谷和黑马的教程都可以,一定要手敲一遍。\n2021 南京大学 “操作系统:设计与实现” (蒋炎岩):我不多说了,看评论就知道了。 SpringSecurity-Social-OAuth2 社交登录接口授权鉴权系列课程:字母哥讲的 Spring Security 也很好,Spring Security 或者 Shiro 是做项目必备的,会一个就好,根据实际场景以及个人喜好(笑)来选型。 清华大学邓俊辉数据结构与算法:清华不解释了。 MySQL 实战 45 讲:前 27 讲多看几遍基本可以秒杀面试中遇到的 MySQL 问题了。 Redis 核心技术与实战:讲解了大量的 Redis 在生产上的使用场景,和《Redis 设计与实现》配合着看,也可以秒杀面试中遇到的 Redis 问题了。 JavaGuide:「Java 学习+面试指南」一份涵盖大部分 Java 程序员所需要掌握的核心知识。 《Java 面试指北》:这是一份教你如何更高效地准备面试的小册,涵盖常见八股文(系统设计、常见框架、分布式、高并发 ……)、优质面经等内容。 找工作 # 大概是去年 11 月的时候,牛客上日常实习的面经开始多了起来,我也有了找实习的意识,然后就开始一边复习一边海投,投了很多公司,给面试机会的就那几家,腾讯二面挂了两次,当时心态完全崩了,甚至有了看空春招的想法。很幸运最后收获了一个实习机会,在实习的时候,除了完成日常的工作以外,其余时间也没有松懈,晚上下班后、周末的时间都用来复习,心里也暗暗下定决心,春招一定要卷土重来!\n从二月下旬开始海投阿里提前批,基本都有了面试,开系统那天收到了 16 封内推邮件,具体的面经可以看我以前发的文章。\n从 3.1 到 3.7 那一个周平均每天三场面试,真的非常崩溃,一度想考研,也焦虑过、哭过、笑过,还好结果是好的,最后也去了一直想去的支付宝。\n我主要是想通过自己对面试过程的总结给大家提一些建议,大佬不喜勿喷。\n面试准备 # 要去面试首先要准备一份简历,我个人认为一份好的简历应该有一下三个部分:\n完整的个人信息,这个不多说了吧,个人信息不完整面试官或 HR 都联系不上你,就算学校不好也要写上去,因为听说有些公司没有学校无法进行简历评估,非科班或者说学校不太出名可以将教育信息写在最下面。 项目/实习经历,项目真的很重要,面试大部分时间会围绕着项目来,你项目准备好了可以把控面试的节奏,引导面试官问你擅长的方向,我就是在这方面吃了亏。如果没有项目怎么办,可以去 GitHub 上找一些开源的项目,自己跟着做一遍,加入一些自己的思考和理解。还有做项目不能简单实现功能,还要考虑性能和优化,面试官并不关注你这个功能是怎么实现的,他想知道的是你是如何一步步思考的,一开始的方案是什么,后面选了什么方案,对性能有哪些提升,还能再改进吗? 具备的专业技能,这个可以简单的写一下你学过的专业知识,这样可以让面试官有针对的问一些基础知识,切忌长篇罗列,最擅长的一定要写在上面,依次往下。 简历写好了之后就进入了投递环节,最好找一个靠谱的内推人,因为内推人可以帮你跟进面试的进度,必要时候和 HR 沟通,哪怕挂了也可以告诉你原因,哪些方面表现的不好。现在内推已经不再是门槛,而是最低的入场券,没有认识的人内推也可以在牛客上找一些师兄内推,他们往往也很热情。\n在面试过程中一定不要紧张,因为一面面试官可能比我们大不了几岁,也工作没几年,所以 duck 不必紧张的不会说话,不会就说不会,然后笑一下,会就流利的表达出来,面试并不是一问一答,面试是沟通,是交流,你可以大胆的说出自己的思考,表达沟通能力也是面试的一个衡量指标。\n我个人认为面试和追妹子是差不多的,都是尽快的让对方了解自己,发现你身上的闪光点,只不过面试是让面试官了解你在技术上的造诣。所以,自我介绍环节就变得非常重要,你可以简单介绍完自己的个人信息之后,介绍一下你做过的项目,自我介绍最好长一些,因为在面试前,面试官可能没看过你的简历(逃),你最好留给面试官充足的时间去看你的简历。自我介绍包括项目的介绍可以写成一遍文档,多读几遍,在面试的时候能够背下来,实在不行也可以照着读。\n项目 # 我还是要重点讲一下项目,我以前认为项目是一个不确定性非常大的地方,后来经过面试才知道项目是最容易带面试官节奏的地方。问项目的意义是通过项目来问基础知识,所以就要求你对自己的项目非常熟悉,考虑各种极端情况以及优化方案,熟悉用到的中间件原理,以及这些中间件是如何处理这些情况的,比如说,MQ 的宕机恢复,Redis 集群、哨兵,缓存雪崩、缓存击穿、缓存穿透等。\n优化主要可以从缓存、MQ 解耦、加索引、多线程、异步任务、用 ElasticSearch 做检索等方面考虑,我认为项目优化主要的着手点就是减少数据库的访问量,减少同步调用的次数,比如说加缓存、用 ElasticSearch 做检索就是通过减少数据库的访问来实现的优化,MQ 解耦、异步任务等就是通过减少同步调用的次数来实现的优化。\n项目中还可以学到很多东西,比如下面的这些就是通过项目来学习的:\n权限控制(ABAC、RBAC) JWT 单点登录 分库分表 分片上传/导出 分布式锁 负载均衡 当然还有很多东西,每个人的项目不一样,能学到的东西也天差地别,但是你要相信的是,你接触到的东西,面试官应该是都会的,所以一定要好好准备,不然容易被怼。\n本质上来讲,项目也可以拆解成八股文,可以用准备基础知识的方式来准备项目。\n算法 # 项目的八股文化,会进一步导致无法准确的甄选候选人,所以就到了面试的第三个衡量标准,那就是算法,我曾经在反问阶段问过面试官刷算法对哪些方面有帮助,面试官直截了当的对我说,刷题对你以后找工作有帮助。我的观点是算法其实也是可以通过记忆来提高的,LeetCode 前 200 道题能刷上 3 遍,我不信面试时候还能手撕不了,所以在复习的过程中一定要保持算法的训练。\n面试建议 # 自我介绍尽量丰富一下,项目提前准备好如何介绍。 在面试的时候,遇到不会的问题最好不要直接说不会,然后愣着,等面试官问下一个问题,你可以说自己对这方面不太了解,但是对 XX 有一些了解,然后讲一下,如果面试官感兴趣,你就可以继续说,不感兴趣他就会问下一个问题,面试官一般是不会打断的,这也是让面试官快速了解你的一个小技巧。 尽量向面试官展示你的技术热情,比如说你可以和面试官聊 Java 每个版本的新特性,最近技术圈的一些新闻等等,因为就我所知,技术热情也是阿里面试考察的一方面。 面试是一个双向选择的过程,不要表现的太过去谄媚。 好好把握好反问阶段,问一些有价值的内容,比如说新人培养机制、转正机制等。 经验 # 如果你现在大一,OK,我希望你能多了解一下互联网就业的方向,看看自己的兴趣在哪,先把基础打好,比如说数据结构、操作性、计算机网络、计算机组成原理,因为这四门课既是大部分学校考研的专业课,也是面试中常常会被问到的问题。 如果已经大二了,那就要明确自己的方向,要有自驱力,知道你学习的这个方向都要学哪些知识,学到什么程度能够就业,合理安排好时间,知道自己在什么阶段要达到什么样的水准。 如果你学历比较吃亏,亦或是非科班出身,那么我建议你一定要付出超过常人的努力,因为在我混迹牛客这么多年,我看到的面经一般是学校好一些的问的简单一些,相对差一些的问的难一些,其实也可以理解,毕竟普遍上来说名校出身的综合实力要强一些。 尽量早点实习,如果你现在大二,已经有了能够实习的水平,我建议你早点投简历,尽量找暑期实习,你相信我,如果你这个暑假去实习了,明年一定是乱杀。 接上条,如果找不到实习,尽量要做几个有挑战的项目,并且找到这个项目的抓手。 多刷刷牛客,我在牛客上就认识了很多志同道合的人,他们在我找工作过程中给了我很多帮助。 建议 # 一定要抱团取暖,一起找工作的同学可以拉一个群,无论是自己学校的还是网上认识的,平常多交流复习心得,n 个 1 相加的和一定是大于 n 的。 知识的深度和广度都很重要,平常一定要多了解新技术,而且每学一门技术一定要争取了解它的原理,不然你学的不算是计算机,而是英语系,工作职位也不是研发工程师,而是 API 调用工程师。 运营好自己的 CSDN、掘金等博客平台,我有个学弟大二是 CSDN 博客专家,已经有猎头联系他了,平常写的代码尽量都提交到 GitHub 上,无论是项目也好,实验也好,如果有能力的话最好能录制一些视频发到哔哩哔哩上,因为这是面试官在面试你之前了解你表达能力的一个重要途径。 心态一定要好,面试不顺利,不一定是你的能力问题,也可能是因为他们招人很少,或者说某一些客观条件与他们不匹配,一定要多尝试不同的选择。 多和人沟通交流,不要自己埋头苦干,因为你以后进公司里也需要和别人合作,所以表达和沟通能力是一项基本的技能,要提前培养。 闲聊 # 谈谈信息差 # 我觉得学校的差距并不只是体现在教学水平上,诚然名校的老师讲课水平、实验水平都是高于弱校的,但是信息差才是主要的差距。在 985 学校里面读书,不仅能接触到更多优质企业的校招宣讲、讲座,还能接触到更好的就业氛围,因为名校里面去大厂、去外企的人、甚至出国的人更多,学长学姐的内推只是一方面,另一方面是你可以从他们身上学到技术以外的东西,而双非学校去大厂的人少,他们能影响的只是很少一部分人,这就是信息差。信息差的劣势主要体现在哪些方面呢?比如人家大二已经开始找日常实习了,而你认为找工作是大四的事情,人家大三已经找到暑期实习了,你暑假还需要去参加学校组织的培训,一步步的就这样拉下了。\n好在,互联网的出现让信息更加透明,你可以在网上检索各种各样你想要的信息,比如我就在牛客]上认识了一些志同道合的朋友,他们在找工作的过程中给了我很多帮助。平常可以多刷刷牛客,能够有效的减小信息差。\n谈谈 Java 的内卷 # Java 卷吗?毫无疑问,很卷,我个人认为开发属于没有什么门槛的工作,本科生来干正合适,但是因为算法岗更是神仙打架,导致很多的研究生也转了开发,而且基本都转了 Java 开发。Java 的内卷只是这个原因造成的吗?当然不是,我认为还有一个原因就是培训机构的兴起,让这个行业的门槛进一步降低,你要学什么东西,怎么学,都有人给你安排好了,这是造成内卷的第二个原因。第三个原因就是非科班转码,其它行业的凋落和互联网行业的繁荣形成了鲜明对比,导致很多其它专业的人也自学计算机,找互联网的工作,导致这个行业的人越来越多,蛋糕就那么大,分蛋糕的人却越来越多。\n其实内卷也不一定是个坏现象,这说明阶级上升的通道还没有完全关闭,还是有不少人愿意通过努力来改变现状,这也一定程度上会加快行业的发展,社会的发展。选择权在你自己手上,你可以选择回老家躺平或者进互联网公司内卷,如果选择后者的话,我的建议还是尽早占下坑位,因为唯一不变的是变化,你永远不知道三年后是什么样子。\n祝福 # 惟愿诸君,前程似锦!\n"},{"id":645,"href":"/zh/docs/technology/Interview/system-design/security/design-of-authority-system/","title":"权限系统设计详解","section":"Security","content":" 作者:转转技术团队\n原文: https://mp.weixin.qq.com/s/ONMuELjdHYa0yQceTj01Iw\n老权限系统的问题与现状 # 转转公司在过去并没有一个统一的权限管理系统,权限管理由各业务自行研发或是使用其他业务的权限系统,权限管理的不统一带来了不少问题:\n各业务重复造轮子,维护成本高 各系统只解决部分场景问题,方案不够通用,新项目选型时没有可靠的权限管理方案 缺乏统一的日志管理与审批流程,在授权信息追溯上十分困难 基于上述问题,去年底公司启动建设转转统一权限系统,目标是开发一套灵活、易用、安全的权限管理系统,供各业务使用。\n业界权限系统的设计方式 # 目前业界主流的权限模型有两种,下面分别介绍下:\n基于角色的访问控制(RBAC) 基于属性的访问控制(ABAC) RBAC 模型 # 基于角色的访问控制(Role-Based Access Control,简称 RBAC) 指的是通过用户的角色(Role)授权其相关权限,实现了灵活的访问控制,相比直接授予用户权限,要更加简单、高效、可扩展。\n一个用户可以拥有若干角色,每一个角色又可以被分配若干权限这样,就构造成“用户-角色-权限” 的授权模型。在这种模型中,用户与角色、角色与权限之间构成了多对多的关系。\n用一个图来描述如下:\n当使用 RBAC模型 时,通过分析用户的实际情况,基于共同的职责和需求,授予他们不同角色。这种 用户 -\u0026gt; 角色 -\u0026gt; 权限 间的关系,让我们可以不用再单独管理单个用户权限,用户从授予的角色里面获取所需的权限。\n以一个简单的场景(Gitlab 的权限系统)为例,用户系统中有 Admin、Maintainer、Operator 三种角色,这三种角色分别具备不同的权限,比如只有 Admin 具备创建代码仓库、删除代码仓库的权限,其他的角色都不具备。我们授予某个用户 Admin 这个角色,他就具备了 创建代码仓库 和 删除代码仓库 这两个权限。\n通过 RBAC模型 ,当存在多个用户拥有相同权限时,我们只需要创建好拥有该权限的角色,然后给不同的用户分配不同的角色,后续只需要修改角色的权限,就能自动修改角色内所有用户的权限。\nABAC 模型 # 基于属性的访问控制(Attribute-Based Access Control,简称 ABAC) 是一种比 RBAC模型 更加灵活的授权模型,它的原理是通过各种属性来动态判断一个操作是否可以被允许。这个模型在云系统中使用的比较多,比如 AWS,阿里云等。\n考虑下面这些场景的权限控制:\n授权某个人具体某本书的编辑权限 当一个文档的所属部门跟用户的部门相同时,用户可以访问这个文档 当用户是一个文档的拥有者并且文档的状态是草稿,用户可以编辑这个文档 早上九点前禁止 A 部门的人访问 B 系统 在除了上海以外的地方禁止以管理员身份访问 A 系统 用户对 2022-06-07 之前创建的订单有操作权限 可以发现上述的场景通过 RBAC模型 很难去实现,因为 RBAC模型 仅仅描述了用户可以做什么操作,但是操作的条件,以及操作的数据,RBAC模型 本身是没有这些限制的。但这恰恰是 ABAC模型 的长处,ABAC模型 的思想是基于用户、访问的数据的属性、以及各种环境因素去动态计算用户是否有权限进行操作。\nABAC 模型的原理 # 在 ABAC模型 中,一个操作是否被允许是基于对象、资源、操作和环境信息共同动态计算决定的。\n对象:对象是当前请求访问资源的用户。用户的属性包括 ID,个人资源,角色,部门和组织成员身份等 资源:资源是当前用户要访问的资产或对象,例如文件,数据,服务器,甚至 API 操作:操作是用户试图对资源进行的操作。常见的操作包括“读取”,“写入”,“编辑”,“复制”和“删除” 环境:环境是每个访问请求的上下文。环境属性包含访问的时间和位置,对象的设备,通信协议和加密强度等 在 ABAC模型 的决策语句的执行过程中,决策引擎会根据定义好的决策语句,结合对象、资源、操作、环境等因素动态计算出决策结果。每当发生访问请求时,ABAC模型 决策系统都会分析属性值是否与已建立的策略匹配。如果有匹配的策略,访问请求就会被通过。\n新权限系统的设计思想 # 结合转转的业务现状,RBAC模型 满足了转转绝大部分业务场景,并且开发成本远低于 ABAC模型 的权限系统,所以新权限系统选择了基于 RBAC模型 来实现。对于实在无法满足的业务系统,我们选择了暂时性不支持,这样可以保障新权限系统的快速落地,更快的让业务使用起来。\n标准的 RBAC模型 是完全遵守 用户 -\u0026gt; 角色 -\u0026gt; 权限 这个链路的,也就是用户的权限完全由他所拥有的角色来控制,但是这样会有一个缺点,就是给用户加权限必须新增一个角色,导致实际操作起来效率比较低。所以我们在 RBAC模型 的基础上,新增了给用户直接增加权限的能力,也就是说既可以给用户添加角色,也可以给用户直接添加权限。最终用户的权限是由拥有的角色和权限点组合而成。\n新权限系统的权限模型:用户最终权限 = 用户拥有的角色带来的权限 + 用户独立配置的权限,两者取并集。\n新权限系统方案如下图:\n首先,将集团所有的用户(包括外部用户),通过 统一登录与注册 功能实现了统一管理,同时与公司的组织架构信息模块打通,实现了同一个人员在所有系统中信息的一致,这也为后续基于组织架构进行权限管理提供了可行性。 其次,因为新权限系统需要服务集团所有业务,所以需要支持多系统权限管理。用户进行权限管理前,需要先选择相应的系统,然后配置该系统的 菜单权限 和 数据权限 信息,建立好系统的各个权限点。PS:菜单权限和数据权限的具体说明,下文会详细介绍。 最后,创建该系统下的不同角色,给不同角色配置好权限点。比如店长角色,拥有店员操作权限、本店数据查看权限等,配置好这个角色后,后续只需要给店长增加这个角色,就可以让他拥有对应的权限。 完成上述配置后,就可以进行用户的权限管理了。有两种方式可以给用户加权限:\n先选用户,然后添加权限。该方式可以给用户添加任意角色或是菜单/数据权限点。 先选择角色,然后关联用户。该方式只可给用户添加角色,不能单独添加菜单/数据权限点。 这两种方式的具体设计方案,后文会详细说明。\n权限系统自身的权限管理 # 对于权限系统来说,首先需要设计好系统自身的权限管理,也就是需要管理好 ”谁可以进入权限系统,谁可以管理其他系统的权限“,对于权限系统自身的用户,会分为三类:\n超级管理员:拥有权限系统的全部操作权限,可以进行系统自身的任何操作,也可以管理接入权限的应用系统的管理操作。 权限操作用户:拥有至少一个已接入的应用系统的超级管理员角色的用户。该用户能进行的操作限定在所拥有的应用系统权限范围内。权限操作用户是一种身份,无需分配,而是根据规则自动获得的。 普通用户:普通用户也可以认为是一种身份,除去上述 2 类人,其余的都为普通用户。他们只能申请接入系统以及访问权限申请页面。 权限类型的定义 # 新权限系统中,我们把权限分为两大类,分别是:\n菜单功能权限:包括系统的目录导航、菜单的访问权限,以及按钮和 API 操作的权限 数据权限:包括定义数据的查询范围权限,在不同系统中,通常叫做 “组织”、”站点“等,在新权限系统中,统一称作 ”组织“ 来管理数据权限 默认角色的分类 # 每个系统中设计了三个默认角色,用来满足基本的权限管理需求,分别如下:\n超级管理员:该角色拥有该系统的全部权限,可以修改系统的角色权限等配置,可以给其他用户授权。 系统管理员:该角色拥有给其他用户授权以及修改系统的角色权限等配置能力,但角色本身不具有任何权限。 授权管理员:该角色拥有给其他用户授权的能力。但是授权的范围不超出自己所拥有的权限。 举个栗子:授权管理员 A 可以给 B 用户添加权限,但添加的范围 小于等于 A 用户已拥有的权限。\n经过这么区分,把 拥有权限 和 拥有授权能力 ,这两部分给分隔开来,可以满足所有的权限控制的场景。\n新权限系统的核心模块设计 # 上面介绍了新权限系统的整体设计思想,接下来分别介绍下核心模块的设计\n系统/菜单/数据权限管理 # 把一个新系统接入权限系统有下列步骤:\n创建系统 配置菜单功能权限 配置数据权限(可选) 创建系统的角色 其中,1、2、3 的步骤,都是在系统管理模块完成,具体流程如下图:\n用户可以对系统的基本信息进行增删改查的操作,不同系统之间通过 系统编码 作为唯一区分。同时 系统编码 也会用作于菜单和数据权限编码的前缀,通过这样的设计保证权限编码全局唯一性。\n例如系统的编码为 test_online,那么该系统的菜单编码格式便为 test_online:m_xxx。\n系统管理界面设计如下:\n菜单管理 # 新权限系统首先对菜单进行了分类,分别是 目录、菜单 和 操作,示意如下图\n它们分别代表的含义是:\n目录:指的是应用系统中最顶部的一级目录,通常在系统 Logo 的右边 菜单:指的是应用系统左侧的多层级菜单,通常在系统 Logo 的下面,也是最常用的菜单结构 操作:指页面中的按钮、接口等一系列可以定义为操作或页面元素的部分。 菜单管理界面设计如下:\n菜单权限数据的使用,也提供两种方式:\n动态菜单模式:这种模式下,菜单的增删完全由权限系统接管。也就是说在权限系统增加菜单,应用系统会同步增加。这种模式好处是修改菜单无需项目上线。 静态菜单模式:菜单的增删由应用系统的前端控制,权限系统只控制访问权限。这种模式下,权限系统只能标识出用户是否拥有当前菜单的权限,而具体的显示控制是由前端根据权限数据来决定。 角色与用户管理 # 角色与用户管理都是可以直接改变用户权限的核心模块,整个设计思路如下图:\n这个模块设计重点是需要考虑到批量操作。无论是通过角色关联用户,还是给用户批量增加/删除/重置权限,批量操作的场景都是系统需要设计好的。\n权限申请 # 除了给其他用户添加权限外,新权限系统同时支持了用户自主申请权限。这个模块除了常规的审批流(申请、审批、查看)等,有一个比较特别的功能,就是如何让用户能选对自己要的权限。所以在该模块的设计上,除了直接选择角色外,还支持通过菜单/数据权限点,反向选择角色,如下图:\n操作日志 # 系统操作日志会分为两大类:\n操作流水日志:用户可看、可查的关键操作日志 服务 Log 日志:系统服务运行过程中产生的 Log 日志,其中,服务 Log 日志信息量大于操作流水日志,但是不方便搜索查看。所以权限系统需要提供操作流水日志功能。 在新权限系统中,用户所有的操作可以分为三类,分别为新增、更新、删除。所有的模块也可枚举,例如用户管理、角色管理、菜单管理等。明确这些信息后,那么一条日志就可以抽象为:什么人(Who)在什么时间(When)对哪些人(Target)的哪些模块做了哪些操作。 这样把所有的记录都入库,就可以方便的进行日志的查看和筛选了。\n总结与展望 # 至此,新权限系统的核心设计思路与模块都已介绍完成,新系统在转转内部有大量的业务接入使用,权限管理相比以前方便了许多。权限系统作为每家公司的一个基础系统,灵活且完备的设计可以助力日后业务的发展更加高效。\n后续两篇:\n转转统一权限系统的设计与实现(后端实现篇) 转转统一权限系统的设计与实现(前端实现篇) 参考 # 选择合适的权限模型: https://docs.authing.cn/v2/guides/access-control/choose-the-right-access-control-model.html "},{"id":646,"href":"/zh/docs/technology/Interview/system-design/security/basis-of-authority-certification/","title":"认证授权基础概念详解","section":"Security","content":" 认证 (Authentication) 和授权 (Authorization)的区别是什么? # 这是一个绝大多数人都会混淆的问题。首先先从读音上来认识这两个名词,很多人都会把它俩的读音搞混,所以我建议你先先去查一查这两个单词到底该怎么读,他们的具体含义是什么。\n说简单点就是:\n认证 (Authentication): 你是谁。 授权 (Authorization): 你有权限干什么。 稍微正式点(啰嗦点)的说法就是:\nAuthentication(认证) 是验证您的身份的凭据(例如用户名/用户 ID 和密码),通过这个凭据,系统得以知道你就是你,也就是说系统存在你这个用户。所以,Authentication 被称为身份/用户验证。 Authorization(授权) 发生在 Authentication(认证) 之后。授权嘛,光看意思大家应该就明白,它主要掌管我们访问系统的权限。比如有些特定资源只能具有特定权限的人才能访问比如 admin,有些对系统资源操作比如删除、添加、更新只能特定人才具有。 认证:\n授权:\n这两个一般在我们的系统中被结合在一起使用,目的就是为了保护我们系统的安全性。\nRBAC 模型了解吗? # 系统权限控制最常采用的访问控制模型就是 RBAC 模型 。\n什么是 RBAC 呢? RBAC 即基于角色的权限访问控制(Role-Based Access Control)。这是一种通过角色关联权限,角色同时又关联用户的授权的方式。\n简单地说:一个用户可以拥有若干角色,每一个角色又可以被分配若干权限,这样就构造成“用户-角色-权限” 的授权模型。在这种模型中,用户与角色、角色与权限之间构成了多对多的关系。\n在 RBAC 权限模型中,权限与角色相关联,用户通过成为包含特定角色的成员而得到这些角色的权限,这就极大地简化了权限的管理。\n为了实现 RBAC 权限模型,数据库表的常见设计如下(一共 5 张表,2 张用户建立表之间的联系):\n通过这个权限模型,我们可以创建不同的角色并为不同的角色分配不同的权限范围(菜单)。\n通常来说,如果系统对于权限控制要求比较严格的话,一般都会选择使用 RBAC 模型来做权限控制。\n什么是 Cookie ? Cookie 的作用是什么? # Cookie 和 Session 都是用来跟踪浏览器用户身份的会话方式,但是两者的应用场景不太一样。\n维基百科是这样定义 Cookie 的:\nCookies 是某些网站为了辨别用户身份而储存在用户本地终端上的数据(通常经过加密)。\n简单来说:Cookie 存放在客户端,一般用来保存用户信息。\n下面是 Cookie 的一些应用案例:\n我们在 Cookie 中保存已经登录过的用户信息,下次访问网站的时候页面可以自动帮你登录的一些基本信息给填了。除此之外,Cookie 还能保存用户首选项,主题和其他设置信息。 使用 Cookie 保存 SessionId 或者 Token ,向后端发送请求的时候带上 Cookie,这样后端就能取到 Session 或者 Token 了。这样就能记录用户当前的状态了,因为 HTTP 协议是无状态的。 Cookie 还可以用来记录和分析用户行为。举个简单的例子你在网上购物的时候,因为 HTTP 协议是没有状态的,如果服务器想要获取你在某个页面的停留状态或者看了哪些商品,一种常用的实现方式就是将这些信息存放在 Cookie …… 如何在项目中使用 Cookie 呢? # 我这里以 Spring Boot 项目为例。\n1)设置 Cookie 返回给客户端\n@GetMapping(\u0026#34;/change-username\u0026#34;) public String setCookie(HttpServletResponse response) { // 创建一个 cookie Cookie cookie = new Cookie(\u0026#34;username\u0026#34;, \u0026#34;Jovan\u0026#34;); //设置 cookie过期时间 cookie.setMaxAge(7 * 24 * 60 * 60); // expires in 7 days //添加到 response 中 response.addCookie(cookie); return \u0026#34;Username is changed!\u0026#34;; } 2) 使用 Spring 框架提供的 @CookieValue 注解获取特定的 cookie 的值\n@GetMapping(\u0026#34;/\u0026#34;) public String readCookie(@CookieValue(value = \u0026#34;username\u0026#34;, defaultValue = \u0026#34;Atta\u0026#34;) String username) { return \u0026#34;Hey! My username is \u0026#34; + username; } 3) 读取所有的 Cookie 值\n@GetMapping(\u0026#34;/all-cookies\u0026#34;) public String readAllCookies(HttpServletRequest request) { Cookie[] cookies = request.getCookies(); if (cookies != null) { return Arrays.stream(cookies) .map(c -\u0026gt; c.getName() + \u0026#34;=\u0026#34; + c.getValue()).collect(Collectors.joining(\u0026#34;, \u0026#34;)); } return \u0026#34;No cookies\u0026#34;; } 更多关于如何在 Spring Boot 中使用 Cookie 的内容可以查看这篇文章: How to use cookies in Spring Boot 。\nCookie 和 Session 有什么区别? # Session 的主要作用就是通过服务端记录用户的状态。 典型的场景是购物车,当你要添加商品到购物车的时候,系统不知道是哪个用户操作的,因为 HTTP 协议是无状态的。服务端给特定的用户创建特定的 Session 之后就可以标识这个用户并且跟踪这个用户了。\nCookie 数据保存在客户端(浏览器端),Session 数据保存在服务器端。相对来说 Session 安全性更高。如果使用 Cookie 的一些敏感信息不要写入 Cookie 中,最好能将 Cookie 信息加密然后使用到的时候再去服务器端解密。\n那么,如何使用 Session 进行身份验证?\n如何使用 Session-Cookie 方案进行身份验证? # 很多时候我们都是通过 SessionID 来实现特定的用户,SessionID 一般会选择存放在 Redis 中。举个例子:\n用户成功登陆系统,然后返回给客户端具有 SessionID 的 Cookie 。 当用户向后端发起请求的时候会把 SessionID 带上,这样后端就知道你的身份状态了。 关于这种认证方式更详细的过程如下:\n用户向服务器发送用户名、密码、验证码用于登陆系统。 服务器验证通过后,服务器为用户创建一个 Session,并将 Session 信息存储起来。 服务器向用户返回一个 SessionID,写入用户的 Cookie。 当用户保持登录状态时,Cookie 将与每个后续请求一起被发送出去。 服务器可以将存储在 Cookie 上的 SessionID 与存储在内存中或者数据库中的 Session 信息进行比较,以验证用户的身份,返回给用户客户端响应信息的时候会附带用户当前的状态。 使用 Session 的时候需要注意下面几个点:\n依赖 Session 的关键业务一定要确保客户端开启了 Cookie。 注意 Session 的过期时间。 另外,Spring Session 提供了一种跨多个应用程序或实例管理用户会话信息的机制。如果想详细了解可以查看下面几篇很不错的文章:\nGetting Started with Spring Session Guide to Spring Session Sticky Sessions with Spring Session \u0026amp; Redis 多服务器节点下 Session-Cookie 方案如何做? # Session-Cookie 方案在单体环境是一个非常好的身份认证方案。但是,当服务器水平拓展成多节点时,Session-Cookie 方案就要面临挑战了。\n举个例子:假如我们部署了两份相同的服务 A,B,用户第一次登陆的时候 ,Nginx 通过负载均衡机制将用户请求转发到 A 服务器,此时用户的 Session 信息保存在 A 服务器。结果,用户第二次访问的时候 Nginx 将请求路由到 B 服务器,由于 B 服务器没有保存 用户的 Session 信息,导致用户需要重新进行登陆。\n我们应该如何避免上面这种情况的出现呢?\n有几个方案可供大家参考:\n某个用户的所有请求都通过特性的哈希策略分配给同一个服务器处理。这样的话,每个服务器都保存了一部分用户的 Session 信息。服务器宕机,其保存的所有 Session 信息就完全丢失了。 每一个服务器保存的 Session 信息都是互相同步的,也就是说每一个服务器都保存了全量的 Session 信息。每当一个服务器的 Session 信息发生变化,我们就将其同步到其他服务器。这种方案成本太大,并且,节点越多时,同步成本也越高。 单独使用一个所有服务器都能访问到的数据节点(比如缓存)来存放 Session 信息。为了保证高可用,数据节点尽量要避免是单点。 Spring Session 是一个用于在多个服务器之间管理会话的项目。它可以与多种后端存储(如 Redis、MongoDB 等)集成,从而实现分布式会话管理。通过 Spring Session,可以将会话数据存储在共享的外部存储中,以实现跨服务器的会话同步和共享。 如果没有 Cookie 的话 Session 还能用吗? # 这是一道经典的面试题!\n一般是通过 Cookie 来保存 SessionID ,假如你使用了 Cookie 保存 SessionID 的方案的话, 如果客户端禁用了 Cookie,那么 Session 就无法正常工作。\n但是,并不是没有 Cookie 之后就不能用 Session 了,比如你可以将 SessionID 放在请求的 url 里面https://javaguide.cn/?Session_id=xxx 。这种方案的话可行,但是安全性和用户体验感降低。当然,为了安全你也可以对 SessionID 进行一次加密之后再传入后端。\n为什么 Cookie 无法防止 CSRF 攻击,而 Token 可以? # CSRF(Cross Site Request Forgery) 一般被翻译为 跨站请求伪造 。那么什么是 跨站请求伪造 呢?说简单点,就是用你的身份去发送一些对你不友好的请求。举个简单的例子:\n小壮登录了某网上银行,他来到了网上银行的帖子区,看到一个帖子下面有一个链接写着“科学理财,年盈利率过万”,小壮好奇的点开了这个链接,结果发现自己的账户少了 10000 元。这是这么回事呢?原来黑客在链接中藏了一个请求,这个请求直接利用小壮的身份给银行发送了一个转账请求,也就是通过你的 Cookie 向银行发出请求。\n\u0026lt;a src=http://www.mybank.com/Transfer?bankId=11\u0026amp;money=10000\u0026gt;科学理财,年盈利率过万\u0026lt;/\u0026gt; 上面也提到过,进行 Session 认证的时候,我们一般使用 Cookie 来存储 SessionId,当我们登陆后后端生成一个 SessionId 放在 Cookie 中返回给客户端,服务端通过 Redis 或者其他存储工具记录保存着这个 SessionId,客户端登录以后每次请求都会带上这个 SessionId,服务端通过这个 SessionId 来标示你这个人。如果别人通过 Cookie 拿到了 SessionId 后就可以代替你的身份访问系统了。\nSession 认证中 Cookie 中的 SessionId 是由浏览器发送到服务端的,借助这个特性,攻击者就可以通过让用户误点攻击链接,达到攻击效果。\n但是,我们使用 Token 的话就不会存在这个问题,在我们登录成功获得 Token 之后,一般会选择存放在 localStorage (浏览器本地存储)中。然后我们在前端通过某些方式会给每个发到后端的请求加上这个 Token,这样就不会出现 CSRF 漏洞的问题。因为,即使你点击了非法链接发送了请求到服务端,这个非法请求是不会携带 Token 的,所以这个请求将是非法的。\n需要注意的是:不论是 Cookie 还是 Token 都无法避免 跨站脚本攻击(Cross Site Scripting)XSS 。\n跨站脚本攻击(Cross Site Scripting)缩写为 CSS 但这会与层叠样式表(Cascading Style Sheets,CSS)的缩写混淆。因此,有人将跨站脚本攻击缩写为 XSS。\nXSS 中攻击者会用各种方式将恶意代码注入到其他用户的页面中。就可以通过脚本盗用信息比如 Cookie 。\n推荐阅读: 如何防止 CSRF 攻击?—美团技术团队\n什么是 JWT?JWT 由哪些部分组成? # JWT 基础概念详解\n如何基于 JWT 进行身份验证? 如何防止 JWT 被篡改? # JWT 基础概念详解\n什么是 SSO? # SSO(Single Sign On)即单点登录说的是用户登陆多个子系统的其中一个就有权访问与其相关的其他系统。举个例子我们在登陆了京东金融之后,我们同时也成功登陆京东的京东超市、京东国际、京东生鲜等子系统。\nSSO 有什么好处? # 用户角度 :用户能够做到一次登录多次使用,无需记录多套用户名和密码,省心。 系统管理员角度 : 管理员只需维护好一个统一的账号中心就可以了,方便。 新系统开发角度: 新系统开发时只需直接对接统一的账号中心即可,简化开发流程,省时。 如何设计实现一个 SSO 系统? # SSO 单点登录详解\n什么是 OAuth 2.0? # OAuth 是一个行业的标准授权协议,主要用来授权第三方应用获取有限的权限。而 OAuth 2.0 是对 OAuth 1.0 的完全重新设计,OAuth 2.0 更快,更容易实现,OAuth 1.0 已经被废弃。详情请见: rfc6749。\n实际上它就是一种授权机制,它的最终目的是为第三方应用颁发一个有时效性的令牌 Token,使得第三方应用能够通过该令牌获取相关的资源。\nOAuth 2.0 比较常用的场景就是第三方登录,当你的网站接入了第三方登录的时候一般就是使用的 OAuth 2.0 协议。\n另外,现在 OAuth 2.0 也常见于支付场景(微信支付、支付宝支付)和开发平台(微信开放平台、阿里开放平台等等)。\n下图是 Slack OAuth 2.0 第三方登录的示意图:\n推荐阅读:\nOAuth 2.0 的一个简单解释 10 分钟理解什么是 OAuth 2.0 协议 OAuth 2.0 的四种方式 GitHub OAuth 第三方登录示例教程 参考 # 不要用 JWT 替代 session 管理(上):全面了解 Token,JWT,OAuth,SAML,SSO: https://zhuanlan.zhihu.com/p/38942172 Introduction to JSON Web Tokens: https://jwt.io/introduction JSON Web Token Claims: https://auth0.com/docs/secure/tokens/json-web-tokens/json-web-token-claims "},{"id":647,"href":"/zh/docs/technology/Interview/high-availability/redundancy/","title":"冗余设计详解","section":"High Availability","content":"冗余设计是保证系统和数据高可用的最常的手段。\n对于服务来说,冗余的思想就是相同的服务部署多份,如果正在使用的服务突然挂掉的话,系统可以很快切换到备份服务上,大大减少系统的不可用时间,提高系统的可用性。\n对于数据来说,冗余的思想就是相同的数据备份多份,这样就可以很简单地提高数据的安全性。\n实际上,日常生活中就有非常多的冗余思想的应用。\n拿我自己来说,我对于重要文件的保存方法就是冗余思想的应用。我日常所使用的重要文件都会同步一份在 GitHub 以及个人云盘上,这样就可以保证即使电脑硬盘损坏,我也可以通过 GitHub 或者个人云盘找回自己的重要文件。\n高可用集群(High Availability Cluster,简称 HA Cluster)、同城灾备、异地灾备、同城多活和异地多活是冗余思想在高可用系统设计中最典型的应用。\n高可用集群 : 同一份服务部署两份或者多份,当正在使用的服务突然挂掉的话,可以切换到另外一台服务,从而保证服务的高可用。 同城灾备:一整个集群可以部署在同一个机房,而同城灾备中相同服务部署在同一个城市的不同机房中。并且,备用服务不处理请求。这样可以避免机房出现意外情况比如停电、火灾。 异地灾备:类似于同城灾备,不同的是,相同服务部署在异地(通常距离较远,甚至是在不同的城市或者国家)的不同机房中 同城多活:类似于同城灾备,但备用服务可以处理请求,这样可以充分利用系统资源,提高系统的并发。 异地多活 : 将服务部署在异地的不同机房中,并且,它们可以同时对外提供服务。 高可用集群单纯是服务的冗余,并没有强调地域。同城灾备、异地灾备、同城多活和异地多活实现了地域上的冗余。\n同城和异地的主要区别在于机房之间的距离。异地通常距离较远,甚至是在不同的城市或者国家。\n和传统的灾备设计相比,同城多活和异地多活最明显的改变在于“多活”,即所有站点都是同时在对外提供服务的。异地多活是为了应对突发状况比如火灾、地震等自然或者人为灾害。\n光做好冗余还不够,必须要配合上 故障转移 才可以! 所谓故障转移,简单来说就是实现不可用服务快速且自动地切换到可用服务,整个过程不需要人为干涉。\n举个例子:哨兵模式的 Redis 集群中,如果 Sentinel(哨兵) 检测到 master 节点出现故障的话, 它就会帮助我们实现故障转移,自动将某一台 slave 升级为 master,确保整个 Redis 系统的可用性。整个过程完全自动,不需要人工介入。我在 《Java 面试指北》的「技术面试题篇」中的数据库部分详细介绍了 Redis 集群相关的知识点\u0026amp;面试题,感兴趣的小伙伴可以看看。\n再举个例子:Nginx 可以结合 Keepalived 来实现高可用。如果 Nginx 主服务器宕机的话,Keepalived 可以自动进行故障转移,备用 Nginx 主服务器升级为主服务。并且,这个切换对外是透明的,因为使用的虚拟 IP,虚拟 IP 不会改变。我在 《Java 面试指北》的「技术面试题篇」中的「服务器」部分详细介绍了 Nginx 相关的知识点\u0026amp;面试题,感兴趣的小伙伴可以看看。\n异地多活架构实施起来非常难,需要考虑的因素非常多。本人不才,实际项目中并没有实践过异地多活架构,我对其了解还停留在书本知识。\n如果你想要深入学习异地多活相关的知识,我这里推荐几篇我觉得还不错的文章:\n搞懂异地多活,看这篇就够了- 水滴与银弹 - 2021 四步构建异地多活 《从零开始学架构》— 28 | 业务高可用的保障:异地多活架构 不过,这些文章大多也都是在介绍概念知识。目前,网上还缺少真正介绍具体要如何去实践落地异地多活架构的资料。\n"},{"id":648,"href":"/zh/docs/technology/Interview/database/redis/redis-delayed-task/","title":"如何基于Redis实现延时任务","section":"Redis","content":"基于 Redis 实现延时任务的功能无非就下面两种方案:\nRedis 过期事件监听 Redisson 内置的延时队列 面试的时候,你可以先说自己考虑了这两种方案,但最后发现 Redis 过期事件监听这种方案存在很多问题,因此你最终选择了 Redisson 内置的 DelayedQueue 这种方案。\n这个时候面试官可能会追问你一些相关的问题,我们后面会提到,提前准备就好了。\n另外,除了下面介绍到的这些问题之外,Redis 相关的常见问题建议你都复习一遍,不排除面试官会顺带问你一些 Redis 的其他问题。\nRedis 过期事件监听实现延时任务功能的原理? # Redis 2.0 引入了发布订阅 (pub/sub) 功能。在 pub/sub 中,引入了一个叫做 channel(频道) 的概念,有点类似于消息队列中的 topic(主题)。\npub/sub 涉及发布者(publisher)和订阅者(subscriber,也叫消费者)两个角色:\n发布者通过 PUBLISH 投递消息给指定 channel。 订阅者通过SUBSCRIBE订阅它关心的 channel。并且,订阅者可以订阅一个或者多个 channel。 在 pub/sub 模式下,生产者需要指定消息发送到哪个 channel 中,而消费者则订阅对应的 channel 以获取消息。\nRedis 中有很多默认的 channel,这些 channel 是由 Redis 本身向它们发送消息的,而不是我们自己编写的代码。其中,__keyevent@0__:expired 就是一个默认的 channel,负责监听 key 的过期事件。也就是说,当一个 key 过期之后,Redis 会发布一个 key 过期的事件到__keyevent@\u0026lt;db\u0026gt;__:expired这个 channel 中。\n我们只需要监听这个 channel,就可以拿到过期的 key 的消息,进而实现了延时任务功能。\n这个功能被 Redis 官方称为 keyspace notifications ,作用是实时监控 Redis 键和值的变化。\nRedis 过期事件监听实现延时任务功能有什么缺陷? # 1、时效性差\n官方文档的一段介绍解释了时效性差的原因,地址: https://redis.io/docs/manual/keyspace-notifications/#timing-of-expired-events 。\n这段话的核心是:过期事件消息是在 Redis 服务器删除 key 时发布的,而不是一个 key 过期之后就会就会直接发布。\n我们知道常用的过期数据的删除策略就两个:\n惰性删除:只会在取出 key 的时候才对数据进行过期检查。这样对 CPU 最友好,但是可能会造成太多过期 key 没有被删除。 定期删除:每隔一段时间抽取一批 key 执行删除过期 key 操作。并且,Redis 底层会通过限制删除操作执行的时长和频率来减少删除操作对 CPU 时间的影响。 定期删除对内存更加友好,惰性删除对 CPU 更加友好。两者各有千秋,所以 Redis 采用的是 定期删除+惰性/懒汉式删除 。\n因此,就会存在我设置了 key 的过期时间,但到了指定时间 key 还未被删除,进而没有发布过期事件的情况。\n2、丢消息\nRedis 的 pub/sub 模式中的消息并不支持持久化,这与消息队列不同。在 Redis 的 pub/sub 模式中,发布者将消息发送给指定的频道,订阅者监听相应的频道以接收消息。当没有订阅者时,消息会被直接丢弃,在 Redis 中不会存储该消息。\n3、多服务实例下消息重复消费\nRedis 的 pub/sub 模式目前只有广播模式,这意味着当生产者向特定频道发布一条消息时,所有订阅相关频道的消费者都能够收到该消息。\n这个时候,我们需要注意多个服务实例重复处理消息的问题,这会增加代码开发量和维护难度。\nRedisson 延迟队列原理是什么?有什么优势? # Redisson 是一个开源的 Java 语言 Redis 客户端,提供了很多开箱即用的功能,比如多种分布式锁的实现、延时队列。\n我们可以借助 Redisson 内置的延时队列 RDelayedQueue 来实现延时任务功能。\nRedisson 的延迟队列 RDelayedQueue 是基于 Redis 的 SortedSet 来实现的。SortedSet 是一个有序集合,其中的每个元素都可以设置一个分数,代表该元素的权重。Redisson 利用这一特性,将需要延迟执行的任务插入到 SortedSet 中,并给它们设置相应的过期时间作为分数。\nRedisson 使用 zrangebyscore 命令扫描 SortedSet 中过期的元素,然后将这些过期元素从 SortedSet 中移除,并将它们加入到就绪消息列表中。就绪消息列表是一个阻塞队列,有消息进入就会被监听到。这样做可以避免对整个 SortedSet 进行轮询,提高了执行效率。\n相比于 Redis 过期事件监听实现延时任务功能,这种方式具备下面这些优势:\n减少了丢消息的可能:DelayedQueue 中的消息会被持久化,即使 Redis 宕机了,根据持久化机制,也只可能丢失一点消息,影响不大。当然了,你也可以使用扫描数据库的方法作为补偿机制。 消息不存在重复消费问题:每个客户端都是从同一个目标队列中获取任务的,不存在重复消费的问题。 跟 Redisson 内置的延时队列相比,消息队列可以通过保障消息消费的可靠性、控制消息生产者和消费者的数量等手段来实现更高的吞吐量和更强的可靠性,实际项目中首选使用消息队列的延时消息这种方案。\n"},{"id":649,"href":"/zh/docs/technology/Interview/high-quality-technical-articles/interview/how-to-examine-the-technical-ability-of-programmers-in-the-first-test-of-technology/","title":"如何在技术初试中考察程序员的技术能力","section":"Interview","content":" 推荐语:从面试官和面试者两个角度探讨了技术面试!非常不错!\n内容概览:\n实战与理论结合。比如,候选人叙述 JVM 内存模型布局之后,可以接着问:有哪些原因可能会导致 OOM , 有哪些预防措施? 你是否遇到过内存泄露的问题? 如何排查和解决这类问题? 项目经历考察不宜超过两个。因为要深入考察一个项目的详情,所占用的时间还是比较大的。一般来说,会让候选人挑选一个他或她觉得最有收获的/最有挑战的/印象最深刻的/自己觉得特有意思的项目。然后围绕这个项目进行发问。通常是从项目背景出发,考察项目的技术栈、项目模块及交互的整体理解、项目中遇到的有挑战性的技术问题及解决方案、排查和解决问题、代码可维护性问题、工程质量保障等。 多问少说,让候选者多表现。根据候选者的回答适当地引导或递进或横向移动。 原文地址: https://www.cnblogs.com/lovesqcc/p/15169365.html\n灵魂三连问 # 你觉得人怎么样? 【表达能力、沟通能力、学习能力、总结能力、自省改进能力、抗压能力、情绪管理能力、影响力、团队管理能力】 如果让他独立完成项目的设计和实现,你觉得他能胜任吗? 【系统设计能力、项目管理能力】 他的分析和解决问题的能力,你的评价是啥?【原理理解能力、实战应用能力】 考察目标和思路 # 首先明确,技术初试的考察目标:\n候选人的技术基础; 候选人解决问题的思路和能力。 技术基础是基石(冰山之下的东西),占七分, 解决问题的思路和能力是落地(冰山之上露出的部分),占三分。 业务和技术基础考察,三七开。\n核心考察目标:分析和解决问题的能力。\n技术层面:深度 + 应用能力 + 广度。 对于校招或社招 P6 级别以下,要多注重 深度 + 应用能力,广度是加分项; 在 P6 之上,可增加 广度。\n校招:基础扎实,思维敏捷。 主要考察内容:基础数据结构与算法、进程与并发、内存管理、系统调用与 IO 机制、网络协议、数据库范式与设计、设计模式、设计原则、编程习惯; 社招:经验丰富,里外兼修。 主要考察内容:有一定深度的基础技术机制,比如 Java 内存模型及内存泄露、 JVM 机制、类加载机制、数据库索引及查询优化、缓存、消息中间件、项目、架构设计、工程规范等。 技术基础是什么? # 作为技术初试官,怎么去考察技术基础?究竟什么是技术基础?是知道什么,还是知道如何思考?知识作为现有的成熟原理体系,构成了基础的重要组成部分,而知道如何思考亦尤为重要。俗话说,知其然而知其所以然。知其然,是指熟悉现有知识体系,知其所以然,则是自底向上推导,真正理解知识的来龙去脉,理解为何是这样而不是那样。毕竟,对于本质是逻辑的程序世界而言,并无定法。知道如何思考,并能缜密地设计和开发,深入到细节,这就是技术基础吧。\n为什么要考察技术基础? # 程序员最重要的两种技术思维能力,是逻辑思维能力和抽象设计能力。逻辑思维能力是基础,抽象设计能力是高阶。 考察技术基础,正好可以同时考察这两种思维能力。能不能理解基础技术概念及关联,是考察逻辑思维能力;能不能把业务问题抽象成技术问题并合理的组织映射,是考察抽象设计能力。\n绝大部分业务问题,都可以抽象成技术问题。在某种意义上,业务问题只是技术问题的领域化表述。\n因此,通过技术基础考察候选者,才能考察到候选者的真实技术实力:技术深度和广度。\n为什么不能单考察业务维度? # 因为业务方面通常比较熟悉,可能就直接按照现有方案说出来了,很难考察到候选人的深入理解、横向拓展和归纳总结能力。\n这一点,建议有针对性地考察下候选人的归纳总结能力:比如, 微服务搭建或开发或维护/保证系统稳定性或性能方面的过程中,你收获了哪些可以分享的经验?\n为什么要考察业务维度? # 技术基础考察,容易错过的地方是,候选人的非技术能力特质,比如沟通组织能力、带项目能力、抗压能力、解决实际问题的能力、团队影响力、其它性格特质等。\n考察方法 # 技术基础考察 # 技术基础怎么考察?通过有效的多角度的发问模式来考察。\n是什么-为什么\n是什么考察对概念的基本理解,为什么考察对概念的实现原理。\n比如索引是什么? 索引是如何实现的?\n引导-横向发问-深入发问\n引导性,比如 “你对 java 同步工具熟悉吗?” 作个试探,得到肯定答复后,可以进一步问:“你熟悉哪些同步工具类?” 了解候选者的广度;\n获取候选者的回答后,可以进一步问:“ 谈谈 ConcurrentHashMap 或 AQS 的实现原理?”\n一个人在多大程度上把技术原理能讲得清晰,包括思路和细节,说明他对技术的掌握能力有多强。\n深度有梯度和层次的发问\n设置三个深度层次的发问。每个深度层次可以对应到某个技术深度。\n第一个发问是基本概念层次,考察候选人对概念的理解能力和深度; 第二个发问是原理机制层次,考察候选人对概念的内涵和外延的理解深度; 第三个发问是应用层次,考察候选人的应用能力和思维敏捷程度。 跳跃式/交叉式发问\n比如,讲到哈希高效查找,可以谈谈哈希一致性算法 。 两者既有关联又有很多不同点。也是一种技术广度的考察方法。\n总结性发问\n比如,你在做 XXX 中,获得了哪些可以分享的经验? 考察候选人的归纳总结能力。\n实战与理论结合\n比如,候选人叙述 JVM 内存模型布局之后,可以接着问:有哪些原因可能会导致 OOM , 有哪些预防措施? 你是否遇到过内存泄露的问题? 如何排查和解决这类问题? 比如,候选人有谈到 SQL 优化和索引优化,那就正好谈谈索引的实现原理,如何建立最佳索引? 比如,候选人有谈到事务,那就正好谈谈事务实现原理,隔离级别,快照实现等; 熟悉与不熟悉结合\n针对候选人简历上写的熟悉的部分,和没有写出的都问下。比如候选人简历上写着:熟悉 JVM 内存模型, 那我就考察下内存管理相关(熟悉部分),再考察下 Java 并发工具类(不确定是否熟悉部分)。\n死知识与活知识结合\n比如,查找算法有哪些?顺序查找、二分查找、哈希查找。这些大家通常能说出来,也是“死知识”。\n这些查找算法各适用于什么场景?在你工作中,有哪些场景用到了哪些查找算法?为什么? 这些是“活知识”。\n学习或工作中遇到的\n有时,在学习和工作中遇到的问题,也可以作为面试题。\n比如,最近在学习《操作系统导论》并发部分,有一章节是如何使数据结构成为线程安全的。这里就有一些可以提问的地方:如何实现一个锁?如何实现一个线程安全的计数器?如何实现一个线程安全的链表?如何实现一个线程安全的 Map ?如何提升并发的性能?\n工作中遇到的问题,也可以抽象提炼出来,作为技术基础面试题。\n技术栈适配度发问\n如果候选人(简历上所写的)使用的某些技术与本公司的技术栈比较契合,则可以针对这些技术点进行深入提问,考察候选人在这些技术点的掌握程度。如果掌握程度比较好,则技术适配度相对更高一些。\n当然,这一点并不能作为筛掉那些没有使用该技术栈的候选人的依据。比如本公司使用 MongoDB 和 MySQL, 而一个候选人没有用过 Mongodb, 但使用过 MySQL, Redis, ES, HBase 等多种存储系统,那么适配度并不比仅使用过 MySQL 和 Mongodb 的候选人逊色,因为他所涉及的技术广度更大,可以推断出他有足够能力掌握 Mongodb。\n应对背题式面试\n首先,背题式面试,说明候选人至少是有做准备的。当然,对于招聘的一方来说,更希望找到有能力而不是仅记忆了知识的候选人。\n应对背题式面试,可以通过 “引导-横向发问-深入发问” 的方式,先对候选人关于某个知识点的深度和广度做一个了解,然后出一道实际应用题来考察他是否能灵活使用知识。\n比如 Java 线程同步机制,可以出一道题:线程 A 执行了一段代码,然后创建了一个异步任务在线程 B 中执行,线程 A 需要等待线程 B 执行完成后才能继续执行,请问怎么实现?\n”理论 + 应用题“的模式。敌知我之变,而不知我变之形。变之形,不计其数。\n实用不生僻\n考察工作中频繁用到的知识、技能和能力,不考察冷僻的知识。\n比如我偏向考察数据结构与算法、并发、设计 这三类。因为这三类非常基础非常核心。\n综合串联式发问\n知识之间总是相互联系着的,不要单独考察一个知识点。\n设计一个初始问题,比如说查找算法,然后从这个初始问题出发,串联起各个知识点。比如:\n在每一个技术点上,都可以应用以上发问技巧,导向不同的问题分支。同时考察面试者的深度、广度和应用能力。\n创造有个性的面试题库\n每个技术面试官都会有一个面试题库。持续积累面试题库,日常中突然想到的问题,就随手记录下来。\n解决问题能力考察 # 仅仅只是技术基础还不够,通常最好结合实际业务,针对他项目里的业务,抽象出技术问题进行考察。\n解决思路重在层层递进。这一点对于面试官的要求也比较高,兼具良好的倾听能力、技术深度和业务经验。首先要仔细倾听候选人的阐述,找到适当的技术切入点,然后进行发问。如果进不去,那就容易考察失败。 常见问题:\n性能方面,qps, tps 多少?采用了什么优化措施,达成了什么效果? 如果有大数据量,如何处理?如何保证稳定性? 你觉得这个功能/模块/系统的关键点在哪里?有什么解决方案? 为什么使用 XXX 而不是 YYY ? 长字段如何做索引? 还有哪些方案或思路?各自的利弊? 第三方对接,如何应对外部接口的不稳定性? 第三方对接,对接大量外部系统,代码可维护性? 资损场景?严重故障场景? 线上出现了 CPU 飙高,如何处理? OOM 如何处理? IO 读写尖刺,如何排查? 线上运行过程中,出现过哪些问题?如何解决的? 多个子系统之间的数据一致性问题? 如果需要新增一个 XXX 需求,如何扩展? 重来一遍,你觉得可以在哪些方面改进? 系统可问的关联问题:\n绝大多数系统都有性能相关问题。如果没有性能问题,则说明是小系统,小系统就不值得考察了; 中大型系统通常有技术选型问题; 绝大多数系统都有改进空间; 大多数业务系统都涉及可扩展性问题和可维护性问题; 大多数重要业务系统都经历过比较惨重的线上教训; 大数据量系统必定有稳定性问题; 消费系统必定有时延和堆积问题; 第三方系统对接必定涉及可靠性问题; 分布式系统必定涉及可用性问题; 多个子系统协同必定涉及数据一致性问题; 交易系统有资损和故障场景; 设计问题\n比如多个机器间共享大量业务对象,这些业务对象之间有些联合字段是重复的,如何去重? 如果字段比较长,怎么处理? 如果瞬时有大量请求涌入,如何保证服务器的稳定性? 组件级别:设计一个本地缓存? 设计一个分布式缓存? 模块级别:设计一个任务调度模块?需要考虑什么因素? 系统级别:设计一个内部系统,从各个部门获取销售数据然后统计出报表。复杂性体现在哪里?关键质量属性是哪些?模块划分,模块之间的关联关系?技术选型? 项目经历\n项目经历考察不宜超过两个。因为要深入考察一个项目的详情,所占用的时间还是比较大的。\n一般来说,会让候选人挑选一个他或她觉得最有收获的/最有挑战的/印象最深刻的/自己觉得特有意思/感受到挫折的项目。然后围绕这个项目进行发问。通常是从项目背景出发,考察项目的技术栈、项目模块及交互的整体理解、项目中遇到的有挑战性的技术问题及解决方案、排查和解决问题、代码可维护性问题、工程质量保障、重来一遍可以改进哪些等。\n面试过程 # 预先准备 # 面试官也需要做一些准备。比如熟悉候选者的技能优势、工作经历等,做一个面试设计。\n在面试将要开始时,做好面试准备。此外,面试官也需要对公司的一些基本情况有所了解,尤其是公司所使用技术栈、业务全景及方向、工作内容、晋升制度等,这一点技术型候选人问得比较多。\n面试启动 # 一般以候选人自我介绍启动,不过候选人往往会谈得比较散,因此,我会直接提问:谈谈你有哪些优势以及自己觉得可以改进的地方?\n然后以一个相对简单的基础题作为技术提问的开始:你熟悉哪些查找算法?大多数人是能答上顺序查找、二分查找、哈希查找的。\n问题设计 # 提前阅读候选人简历,从简历中筛选出关键词,根据这些关键词进行有针对性地问题设计。\n比如候选人简历里提到 MVVM ,可以问 MVVM 与 MVC 的区别; 提到了观察者模式,可以谈谈观察者模式,顺便问问他还熟悉哪些设计模式。\n可遵循“优势-标准-随机”原则:\n首先,问他对哪方面技术感兴趣、投入较多(优势部分),根据其优势部分,阐述原理及实战应用; 其次,问若干标准化的问题,看看他的原理理解、实战应用如何; 最后,随机选一个问题,看看他的原理理解、实战应用如何; 对于项目同样可以如此:\n首先,问他最有成就感的项目,技术栈、模块及关联、技术选型、设计关键问题、解决方案、实现细节、改进空间; 其次,问他有挫折感的项目,问题在哪里、做过什么努力、如何改进; 宽松氛围 # 即使问的问题比较多比较难,也要注意保持宽松氛围。\n在面试前,根据候选人基本信息适当调侃一下,比如一位候选人叫汪奎,那我就说:之前我们团队有位叫袁奎,我们都喊他奎爷。\n在面试过程中,适当提示,或者给出少量自己的看法,也能缓解候选人的紧张情绪。\n学会倾听 # 多问少说,让候选者多表现。根据候选者的回答适当地引导或递进或横向移动。\n引导候选人表现他最优势的一面,让他或她感觉好一些:毕竟一场面试双方都付出了时间和精力,不应该是面试官 Diss 候选人的场合,而应该让彼此有更好的交流。很大可能,你也能从候选人那里学到不少东西。\n面试这件事,只不过双方的角色和立场有所不同,但并不代表面试官的水平就一定高于候选人。\n记录重点 # 认真客观地记录候选人的回答,尽可能避免任何主观评价,亦不作任何加工(比如自己给总结一下,总结能力也是候选人的一个特质)。\n多练习 # 模拟面试。\n作出判断 # 面试过程是一种铺垫,关键的是作出判断。\n作出判断最容易陷入误区的是:贪深求全。总希望候选人技术又深入又全面。实际上,这是一种奢望。如果候选人的技术能力又深入又全面,很可能也会面临两种情况:1. 候选人有更好的选择; 2. 候选人在其它方面可能存在不足,比如团队协作方面。\n一个比较合适的尺度是:1. 他或她的技术水平能否胜任当前工作; 2. 他或她的技术水平与同组团队成员水平如何; 3. 他或她的技术水平是否与年限相对匹配,是否有潜力胜任更复杂的任务。\n不同年龄看重的东西不一样 # 对于三年以下的工程师,应当更看重其技术基础,因为这代表着他的未来潜能;同时也考察下他在实际开发中的体现,比如团队协作、业务经验、抗压能力、主动学习的热情和能力等。\n对于三年以上的工程师,应当更看重其业务经验、解决问题能力,看看他或她是如何分析具体问题,在业务范畴内考察其技术基础的深度和广度。\n如何判断一个候选人的真实技术水平及是否适合所需,这方面,我也在学习中。\n面试初上路 # 提前准备好摄像头和音频,可以用耳机测试下。 提前阅读候选人简历,从中筛选关键字,准备几个基本问题。 多问技术基础题,培养下面试感觉。 适当深入问下原理和实现。 如果候选人简历有突出的地方,就先问那个部分;如果没有,就让候选人介绍项目背景,根据项目背景及经验来提问。 小量练习“连问”技巧,直到能够熟悉使用。 着重考察分析和解决问题的能力,必要的话,可以出个编程题。 留出时间给对方问:你有什么想问的?并告知对方三个工作日内回复面试结果。 高效考察 # 当作为技术面试官有一定熟悉度时,就需要提升面试效率。即:在更少的时间内有效考察候选人的技术深度和技术广度。可以准备一些常见的问题,作为标准化测试。\n比如我喜欢考察内存管理及算法、数据库索引、缓存、并发、系统设计、问题分析和思考能力等子主题。\n熟悉哪些用于查找的数据结构和算法? 请任选一种阐述其思想以及你认为有意思的地方。 如果运行到一个 Java 方法,里面创建了一个对象列表,内存是如何分配的?什么时候可能导致栈溢出?什么时候可能导致 OOM ? 导致 OOM 的原因有哪些?如何避免? 线上是否有遇到过 OOM ,怎么解决的? Java 分代垃圾回收算法是怎样的? 项目里选用的垃圾回收器是怎样的?为什么选择这个回收器而不是那个? Java 并发工具有哪些?不同工具适合于什么场景? Atomic 原子类的实现原理 ? ConcurrentHashMap 的实现原理? 如何实现一个可重入锁? 举个项目中的例子,哪些字段使用了索引?为什么是这些字段?你觉得还有什么优化空间?如何建一个好的索引? 缓存的可设置的参数有哪些?分别的影响是什么? Redis 过期策略有哪些? 如何选择 redis 过期策略? 如何实现病毒文件检测任务去重? 熟悉哪些设计模式和设计原则? 从 0 到 1 搭建一个模块/完整系统?你如何着手? 如果候选人答不上,可以问:如果你来设计这样一个 XXX, 你会怎么做?\n时间占比大概为:技术基础(25-30 分钟) + 项目(20-25 分钟) + 候选人提问(5-10 分钟)\n给候选人的话 # 为什么候选人需要关注技术基础\n一个常见的疑惑是:开发业务系统的大多数时候,基本不涉及数据结构与算法的设计与实现,为什么要考察 HashMap 的实现原理?为什么要学好数据结构与算法、操作系统、网络通信这些基础课程?\n现在我可以给出一个答案了:\n正如上面所述,绝大多数的业务问题,实际上最终都会映射到基础技术问题上:数据结构与算法的实现、内存管理、并发控制、网络通信等;这些是理解现代互联网大规模程序以及解决程序疑难问题的基石,—— 除非能祝福自己永远都不会遇到疑难问题,永远都只满足于编写 CRUD; 这些技术基础正是程序世界里最有趣最激动人心的地方。如果对这些不感兴趣,就很难在这个领域里深入进去,不如及早转行从事其它职业,非技术的世界一直都很精彩广阔(有时我也想多出去走走,不想局限于技术世界); 技术基础是程序员的内功,而具体技术则是招式。徒有招式而内功不深,遇到高手(优秀同行从业者的竞争及疑难杂症)容易不堪一击; 具备扎实的专业技术基础,能达到的上限更高,未来更有可能胜任复杂的技术问题求解,或者在同样的问题上能够做到更好的方案; 人们喜欢跟与自己相似的人合作,牛人倾向于与牛人合作能得到更好的效果;如果一个团队大部分人技术基础比较好,那么进来一个技术基础比较薄弱的人,协作成本会变高;如果你想和牛人一起合作拿到更好的结果,那就要让自己至少在技术基础上能够与牛人搭配的上; 在 CRUD 的基础上拓展其它才能也不失为一种好的选择,但这不会是一个真正的程序员的姿态,顶多是有技术基础的产品经理、项目经理、HR、运营、客满等其它岗位人才。这是职业选择的问题,已经超出了考察程序员的范畴。 不要在意某个问题回答不上来\n如果面试官问你很多问题,而有些没有回答上来,不要在意。面试官很可能只是在测试你的技术深度和广度,然后判断你是否达到某个水位线。\n重点是:有些问题你答得很有深度,也体现了你的深度思考能力。\n这一点是我当了技术面试官才领会到的。当然,并不是每位技术面试官都是这么想的,但我觉得这应该是个更合适的方式。\n参考资料 # 技术面试官的 9 大误区 如何当一个好的面试官? "},{"id":650,"href":"/zh/docs/technology/Interview/high-quality-technical-articles/interview/screen-candidates-for-packaging/","title":"如何甄别应聘者的包装程度","section":"Interview","content":" 推荐语:经常听到培训班待过的朋友给我说他们的老师是怎么教他们“包装”自己的,不光是培训班,我认识的很多朋友也都会在面试之前“包装”一下自己,所以这个现象是普遍存在的。但是面试官也不都是傻子,通过下面这篇文章来看看面试官是如何甄别应聘者的包装程度。\n原文地址: https://my.oschina.net/hooker/blog/3014656\n前言 # 上到职场干将下到职场萌新,都会接触到包装简历这个词语。当你简历投到心仪的公司,公司内负责求职的工作人员是如何甄别简历的包装程度的?我根据自己的经验写下了这篇文章,谁都不是天才,包装无可厚非,切勿对号入座!\n正文 # 在互联网极速膨胀的社会背景下,各行各业涌入互联网的 IT 民工日益增大。\n早在 2016 年,我司发布了 Java、Ios 工程师的招聘信息,就 Java 工程师单个岗位而言,日收简历近 200 份,Ios 日收简历近一千份。\n没错,这就是当年培训机构对 Ios 工程师这个岗位发起的市场讨伐。而随着近几年的发展,市场供大于求现象日益严重。人员摸底成为用人单位对人才考核的重大难题。\n笔者初次与求职者以面试的形式进行沟通是 2015 年 6 月。由于当时笔者从业时间短,经验不够丰富,错过了一些优秀的求职者。\n三年后的,今天,笔者再次因公司规模扩大而深入与求职者进行沟通。\n1.初选如何鉴别劣质简历 # 培训机构除了提供技术培训,往往还提供简历编写指导、面试指导。很多潜移默化的东西,我们很难甄别。但培训机构包装的简历,存在千遍一律的特征。\n年龄较小却具备高级文凭\n年龄较小却具备高级文凭,这个或许不能作为一项标准,但是大部分的应聘者,均符合传统文凭的市场情况。个别技术爱好者可能通过自考获得文凭,这种情况需提供独有的技术亮点。\n年龄较大却几乎不具备技术经验\n年龄较大却几乎不具备技术经验,相对前一点,这个问题就比较严重了。大家都知道,一个正常的人,对新事物的接受能力会随着年龄的增长而降低,互联网技术也包括其内。如果一个人年龄较大不具备技术经验,那么只有两种情况:\n中途转行(通过培训、自学等方式强行入行)。 由于能力问题,已有的经验不敢写入简历中(能力与经验/薪资不符)。 项目经验多为管理系统\n项目经验,这一项用来评估应聘者的水平太合适不过了。随着互联网的发展迭代,每一年都会出来很多创新型的互联网公司和新兴行业。笔者最近发布的招聘需求里面。CRM 系统、商城、XX 管理系统、问卷系统、课堂系统占了 90%的份额。试问现在 2019 年,内部管理系统这么火爆么。言归正传,我们对于简历的评估,应当多考虑“确有其事”的项目。比如说该人员当时就职于 XX 公司,该公司当时的背景下确实研发了该项目(外包除外)。\n项目的背景不符合互联网发展背景\n项目背景,每年的市场走向不同,从早些年的电商、彩票风波,到后来的 O2O、夺宝、直播、新零售。每个系列的产品的出现,都符合市场的定义。如果简历中出现 18 年、19 年才刚立项做彩票(15 年政府禁止互联网彩票)、O2O、商城、夺宝(17 年初禁止夺宝类产品)、直播等产品。显然是非常不符合市场需求的。这种情况下需考虑具体情况是否存在理解空间。\n缺乏新意\n不同工作经验下多个项目技术架构或项目结构一致,缺乏新意。一般情况而言,不同的公司技术栈不同,甚至产品的走向和模式完全不同。故此,当一个应聘者多家公司的多个项目中写到的技术千遍一律,业务流程异曲同工。看似整洁,实则更加缺乏说服力。\n技术过于新颖,对旧技术却只字不提\n技术过于新颖,根据互联网技术发展的走向来看,我们在不断向新型技术靠拢。但是任何企业作为资历深厚的 CTO、架构师来说。往往会选择更稳定、更成熟、学习成本更低的已有技术。对新技术的追求不会过于明显。而培训机构则是“哪项技术火我们就教哪项”。故此,出现了很多走入互联网行业的新人对旧技术一窍不通。甚至很多技术都没听过。\n工作经验较丰富,但从事的工作较低级。\n工作经验比较丰富,单从事的工作比较低级,这里存在很大的问题,要么就是原公司没法提供合理的舞台给该人员更好的发展空间,要么就是该人员能力不够,没法完成更高级的工作。当然,还有一种情况就是该人员包装过多的经验导致简历中不和谐。这种情况需要评估公司规模和背景。\n公司背景跨省跨市\n可能很多用人单位和鄙人一样,最近接受到的简历,90%为跨市跳槽的人员。其中武汉占了 60%以上。均为武汉 XX 网络科技有限公司。公司规模均小于 50 人。也有厦门、宁波、南京等等。这个问题笔者就不提了,大家都懂的。跨地区跳槽不好查证。\n缺少业余热情于技术的证明\n有些眼高手低的技术员,做了几个管理系统。用到的技术确是各种分布式、集群、高并发、大数据、消息队列、搜索引擎、镜像容器、多数据库、数据中心等等。期望的薪资也高于行业标准。一个对技术很热情的人,业余时间肯定在技术方面花费过不少时间。那么可以从该人员的博客、git 地址入手。甚至可以通过手机号、邮箱、昵称、马甲。去搜索引擎进行搜集,核实该人员是否在论坛、贴吧、开源组织有过技术背景。\n2. 进入面试阶段,如何甄别对方的水分 # 在甄别对方水分这一块,并没有明确的标准,但是笔者可以提几个点。这也是笔者在实际面试中惯用的做法。\n通过公司规模、团队规模、人员分配是否合理、人员合作方式来判断对方是否具备工作经验\n当招聘初级、初中级 IT 人员的时候,可以询问一些问题,比如公司有多少人、产品团队多少人、产品、技术、后端、前端、客户端、UI、测试各多少人。工作中如何合作的、产品做了多少时间、何时上线的、上线后多长时间迭代一个版本、多长时间迭代一个活动、发展至今多少用户(后端)、多大并发等等(后端)。根据笔者的经验,如果一个人没有任何从业周期,面对这些问题的时候,或多或少答非所问或者给出的答案非常不合理。\n背景公司入职时间、项目立项实现、完工时间、产品技术栈、迭代流程的核实\n很多应聘者对于简历过于包装,只为了追求更高的薪资。当我们问起:你是 xx 年 xx 月入职的该公司?你们项目是 xx 年 xx 月上线的?你们项目使用到 xx 技术?你们每次上线前夕是如何评审的。面对这些问题,应聘者给出的答案经常与简历不符合。这样问题就来了。关于项目使用到的技术,很多项目我们可以通过搜索该项目的地址、APP。通过 HTTP 协议、技术特征、抛出异常特征来大致判别对方使用到的技术。如果应聘者给出的答案明显与之不匹配,嘿嘿。\n通过技术深度,甄别对方的技术水平\n确定对方的技术栈,如:你做过最满意的项目是哪个,为什么?你最喜欢使用的技术是哪些,为什么?\n确定对方项目的发展程度,如:你们产品做了多久,迭代了多久,发布了多少版本,发展到了多少用户,带来多大并发,多少流水?\n确定对方的技术属性,如:平时你会通过什么渠道跟其他技术人形成技术沟通与交流,主要交流过哪些技术?\n笔者最近接待的面试者,很多面试者的简历上,写着层出不穷的各种技术,为了不跨越求职者的技术栈,笔者专门挑应聘者简历写到或用到的技术来进行询问。笔者举几个例子。\n1)某求职者简历上写着熟练使用 Redis。\n介绍一下你使用过 Redis 的哪些数据结构,并描述一下使用的业务场景; 介绍一下你操作 Redis 用到的是什么插件; 介绍一下你们使用的序列化方式; 介绍一下你们使用 Redis 遇到过给你印象较深的问题; 2)某求职者声称熟练 HTTP 协议并编写过爬虫。\n介绍一下你所了解的几个 HTTP head 头并描述其用途; 如果前端提交成功,后端无法接受数据,这时候你将如何排查问题; 描述一下 HTTP 基本报文结构; 如果服务器返回 Cookie,存储在响应内容里面 head 头的字段叫做什么; 当服务端返回 Transfer-Encoding:chunked 代表什么含义 是否了解分段加载并描述下其技术流程。 当然,面向不同的技术,对应的技术深度自然也不一样。\n大体上的套路便是如此:你说你杀过猪。那么你杀过几头猪,分别是啥时候,杀过多大的猪,有啥毛色。事实上对方可能给你的回答是:杀过、十几头、杀过五十斤的、杀过绿色、黄色、红色、蓝色的猪。那么问题就来了。\n然而笔者碰到的问题是:使用 Git 两年却不知道 GitHub、使用 Redis 一年却不知道数据结构也不知道序列化、专业做爬虫却不懂 content-type 含义、使用搜索引擎技术却说不出两个分词插件、使用数据库读写分离却不知道同步延时等等。\n写在最后,笔者认为在招聘途中,并不是不允许求职者包装,但是尽可能满足能筹平衡。虽然这篇文章没有完美的结尾,但是笔者提供了面试失败的各种经验。笔者最终招到了如意的小伙伴。也希望所有技术面试官早日找到符合自己产品发展的 IT 伙伴。\n"},{"id":651,"href":"/zh/docs/technology/Interview/system-design/basis/software-engineering/","title":"软件工程简明教程","section":"Basis","content":"大部分软件开发从业者,都会忽略软件开发中的一些最基础、最底层的一些概念。但是,这些软件开发的概念对于软件开发来说非常重要,就像是软件开发的基石一样。这也是我写这篇文章的原因。\n何为软件工程? # 1968 年 NATO(北大西洋公约组织)提出了软件危机(Software crisis)一词。同年,为了解决软件危机问题,“软件工程”的概念诞生了。一门叫做软件工程的学科也就应运而生。\n随着时间的推移,软件工程这门学科也经历了一轮又一轮的完善,其中的一些核心内容比如软件开发模型越来越丰富实用!\n什么是软件危机呢?\n简单来说,软件危机描述了当时软件开发的一个痛点:我们很难高效地开发出质量高的软件。\nDijkstra(Dijkstra 算法的作者) 在 1972 年图灵奖获奖感言中也提高过软件危机,他是这样说的:“导致软件危机的主要原因是机器变得功能强大了几个数量级!坦率地说:只要没有机器,编程就完全没有问题。当我们有一些弱小的计算机时,编程成为一个温和的问题,而现在我们有了庞大的计算机,编程也同样成为一个巨大的问题”。\n说了这么多,到底什么是软件工程呢?\n工程是为了解决实际的问题将理论应用于实践。软件工程指的就是将工程思想应用于软件开发。\n上面是我对软件工程的定义,我们再来看看比较权威的定义。IEEE 软件工程汇刊给出的定义是这样的: (1)将系统化的、规范的、可量化的方法应用到软件的开发、运行及维护中,即将工程化方法应用于软件。 (2)在(1)中所述方法的研究。\n总之,软件工程的终极目标就是:在更少资源消耗的情况下,创造出更好、更容易维护的软件。\n软件开发过程 # 维基百科是这样定义软件开发过程的:\n软件开发过程(英语:software development process),或软件过程(英语:software process),是软件开发的开发生命周期(software development life cycle),其各个阶段实现了软件的需求定义与分析、设计、实现、测试、交付和维护。软件过程是在开发与构建系统时应遵循的步骤,是软件开发的路线图。\n需求分析:分析用户的需求,建立逻辑模型。 软件设计:根据需求分析的结果对软件架构进行设计。 编码:编写程序运行的源代码。 测试 : 确定测试用例,编写测试报告。 交付:将做好的软件交付给客户。 维护:对软件进行维护比如解决 bug,完善功能。 软件开发过程只是比较笼统的层面上,一定义了一个软件开发可能涉及到的一些流程。\n软件开发模型更具体地定义了软件开发过程,对开发过程提供了强有力的理论支持。\n软件开发模型 # 软件开发模型有很多种,比如瀑布模型(Waterfall Model)、快速原型模型(Rapid Prototype Model)、V 模型(V-model)、W 模型(W-model)、敏捷开发模型。其中最具有代表性的还是 瀑布模型 和 敏捷开发 。\n瀑布模型 定义了一套完成的软件开发周期,完整地展示了一个软件的的生命周期。\n敏捷开发模型 是目前使用的最多的一种软件开发模型。 MBA 智库百科对敏捷开发的描述是这样的:\n敏捷开发 是一种以人为核心、迭代、循序渐进的开发方法。在敏捷开发中,软件项目的构建被切分成多个子项目,各个子项目的成果都经过测试,具备集成和可运行的特征。换言之,就是把一个大项目分为多个相互联系,但也可独立运行的小项目,并分别完成,在此过程中软件一直处于可使用状态。\n像现在比较常见的一些概念比如 持续集成、重构、小版本发布、低文档、站会、结对编程、测试驱动开发 都是敏捷开发的核心。\n软件开发的基本策略 # 软件复用 # 我们在构建一个新的软件的时候,不需要从零开始,通过复用已有的一些轮子(框架、第三方库等)、设计模式、设计原则等等现成的物料,我们可以更快地构建出一个满足要求的软件。\n像我们平时接触的开源项目就是最好的例子。我想,如果不是开源,我们构建出一个满足要求的软件,耗费的精力和时间要比现在多的多!\n分而治之 # 构建软件的过程中,我们会遇到很多问题。我们可以将一些比较复杂的问题拆解为一些小问题,然后,一一攻克。\n我结合现在比较火的软件设计方法—领域驱动设计(Domain Driven Design,简称 DDD)来说说。\n在领域驱动设计中,很重要的一个概念就是领域(Domain),它就是我们要解决的问题。在领域驱动设计中,我们要做的就是把比较大的领域(问题)拆解为若干的小领域(子域)。\n除此之外,分而治之也是一个比较常用的算法思想,对应的就是分治算法。如果你想了解分治算法的话,推荐你看一下北大的 《算法设计与分析 Design and Analysis of Algorithms》。\n逐步演进 # 软件开发是一个逐步演进的过程,我们需要不断进行迭代式增量开发,最终交付符合客户价值的产品。\n这里补充一个在软件开发领域,非常重要的概念:MVP(Minimum Viable Product,最小可行产品)。\n这个最小可行产品,可以理解为刚好能够满足客户需求的产品。下面这张图片把这个思想展示的非常精髓。\n利用最小可行产品,我们可以也可以提早进行市场分析,这对于我们在探索产品不确定性的道路上非常有帮助。可以非常有效地指导我们下一步该往哪里走。\n优化折中 # 软件开发是一个不断优化改进的过程。任何软件都有很多可以优化的点,不可能完美。我们需要不断改进和提升软件的质量。\n但是,也不要陷入这个怪圈。要学会折中,在有限的投入内,以最有效的方式提高现有软件的质量。\n参考 # 软件工程的基本概念-清华大学软件学院 刘强: https://www.xuetangx.com/course/THU08091000367 软件开发过程-维基百科: https://zh.wikipedia.org/wiki/软件开发过程 "},{"id":652,"href":"/zh/docs/technology/Interview/system-design/design-pattern/","title":"设计模式常见面试题总结","section":"System Design","content":"设计模式 相关的面试题已经整理到了 PDF 手册中,你可以在我的公众号“JavaGuide”后台回复“PDF” 获取。\n《设计模式》PDF 电子书内容概览:\n"},{"id":653,"href":"/zh/docs/technology/Interview/high-performance/deep-pagination-optimization/","title":"深度分页介绍及优化建议","section":"High Performance","content":" 深度分页介绍 # 查询偏移量过大的场景我们称为深度分页,这会导致查询性能较低,例如:\n# MySQL 在无法利用索引的情况下跳过1000000条记录后,再获取10条记录 SELECT * FROM t_order ORDER BY id LIMIT 1000000, 10 深度分页问题的原因 # 当查询偏移量过大时,MySQL 的查询优化器可能会选择全表扫描而不是利用索引来优化查询。这是因为扫描索引和跳过大量记录可能比直接全表扫描更耗费资源。\n不同机器上这个查询偏移量过大的临界点可能不同,取决于多个因素,包括硬件配置(如 CPU 性能、磁盘速度)、表的大小、索引的类型和统计信息等。\nMySQL 的查询优化器采用基于成本的策略来选择最优的查询执行计划。它会根据 CPU 和 I/O 的成本来决定是否使用索引扫描或全表扫描。如果优化器认为全表扫描的成本更低,它就会放弃使用索引。不过,即使偏移量很大,如果查询中使用了覆盖索引(covering index),MySQL 仍然可能会使用索引,避免回表操作。\n深度分页优化建议 # 这里以 MySQL 数据库为例介绍一下如何优化深度分页。\n范围查询 # 当可以保证 ID 的连续性时,根据 ID 范围进行分页是比较好的解决方案:\n# 查询指定 ID 范围的数据 SELECT * FROM t_order WHERE id \u0026gt; 100000 AND id \u0026lt;= 100010 ORDER BY id # 也可以通过记录上次查询结果的最后一条记录的ID进行下一页的查询: SELECT * FROM t_order WHERE id \u0026gt; 100000 LIMIT 10 这种基于 ID 范围的深度分页优化方式存在很大限制:\nID 连续性要求高: 实际项目中,数据库自增 ID 往往因为各种原因(例如删除数据、事务回滚等)导致 ID 不连续,难以保证连续性。 排序问题: 如果查询需要按照其他字段(例如创建时间、更新时间等)排序,而不是按照 ID 排序,那么这种方法就不再适用。 并发场景: 在高并发场景下,单纯依赖记录上次查询的最后一条记录的 ID 进行分页,容易出现数据重复或遗漏的问题。 子查询 # 我们先查询出 limit 第一个参数对应的主键值,再根据这个主键值再去过滤并 limit,这样效率会更快一些。\n阿里巴巴《Java 开发手册》中也有对应的描述:\n利用延迟关联或者子查询优化超多分页场景。\n# 通过子查询来获取 id 的起始值,把 limit 1000000 的条件转移到子查询 SELECT * FROM t_order WHERE id \u0026gt;= (SELECT id FROM t_order where id \u0026gt; 1000000 limit 1) LIMIT 10; 工作原理:\n子查询 (SELECT id FROM t_order where id \u0026gt; 1000000 limit 1) 会利用主键索引快速定位到第 1000001 条记录,并返回其 ID 值。 主查询 SELECT * FROM t_order WHERE id \u0026gt;= ... LIMIT 10 将子查询返回的起始 ID 作为过滤条件,使用 id \u0026gt;= 获取从该 ID 开始的后续 10 条记录。 不过,子查询的结果会产生一张新表,会影响性能,应该尽量避免大量使用子查询。并且,这种方法只适用于 ID 是正序的。在复杂分页场景,往往需要通过过滤条件,筛选到符合条件的 ID,此时的 ID 是离散且不连续的。\n当然,我们也可以利用子查询先去获取目标分页的 ID 集合,然后再根据 ID 集合获取内容,但这种写法非常繁琐,不如使用 INNER JOIN 延迟关联。\n延迟关联 # 延迟关联与子查询的优化思路类似,都是通过将 LIMIT 操作转移到主键索引树上,减少回表次数。相比直接使用子查询,延迟关联通过 INNER JOIN 将子查询结果集成到主查询中,避免了子查询可能产生的临时表。在执行 INNER JOIN 时,MySQL 优化器能够利用索引进行高效的连接操作(如索引扫描或其他优化策略),因此在深度分页场景下,性能通常优于直接使用子查询。\n-- 使用 INNER JOIN 进行延迟关联 SELECT t1.* FROM t_order t1 INNER JOIN (SELECT id FROM t_order where id \u0026gt; 1000000 LIMIT 10) t2 ON t1.id = t2.id; 工作原理:\n子查询 (SELECT id FROM t_order where id \u0026gt; 1000000 LIMIT 10) 利用主键索引快速定位目标分页的 10 条记录的 ID。 通过 INNER JOIN 将子查询结果与主表 t_order 关联,获取完整的记录数据。 除了使用 INNER JOIN 之外,还可以使用逗号连接子查询。\n-- 使用逗号进行延迟关联 SELECT t1.* FROM t_order t1, (SELECT id FROM t_order where id \u0026gt; 1000000 LIMIT 10) t2 WHERE t1.id = t2.id; 注意: 虽然逗号连接子查询也能实现类似的效果,但为了代码可读性和可维护性,建议使用更规范的 INNER JOIN 语法。\n覆盖索引 # 索引中已经包含了所有需要获取的字段的查询方式称为覆盖索引。\n覆盖索引的好处:\n避免 InnoDB 表进行索引的二次查询,也就是回表操作: InnoDB 是以聚集索引的顺序来存储的,对于 InnoDB 来说,二级索引在叶子节点中所保存的是行的主键信息,如果是用二级索引查询数据的话,在查找到相应的键值后,还要通过主键进行二次查询才能获取我们真实所需要的数据。而在覆盖索引中,二级索引的键值中可以获取所有的数据,避免了对主键的二次查询(回表),减少了 IO 操作,提升了查询效率。 可以把随机 IO 变成顺序 IO 加快查询效率: 由于覆盖索引是按键值的顺序存储的,对于 IO 密集型的范围查找来说,对比随机从磁盘读取每一行的数据 IO 要少的多,因此利用覆盖索引在访问时也可以把磁盘的随机读取的 IO 转变成索引查找的顺序 IO。 # 如果只需要查询 id, code, type 这三列,可建立 code 和 type 的覆盖索引 SELECT id, code, type FROM t_order ORDER BY code LIMIT 1000000, 10; ⚠️注意:\n当查询的结果集占表的总行数的很大一部分时,MySQL 查询优化器可能选择放弃使用索引,自动转换为全表扫描。 虽然可以使用 FORCE INDEX 强制查询优化器走索引,但这种方式可能会导致查询优化器无法选择更优的执行计划,效果并不总是理想。 总结 # 本文总结了几种常见的深度分页优化方案:\n范围查询: 基于 ID 连续性进行分页,通过记录上一页最后一条记录的 ID 来获取下一页数据。适合 ID 连续且按 ID 查询的场景,但在 ID 不连续或需要按其他字段排序时存在局限。 子查询: 先通过子查询获取分页的起始主键值,再根据主键进行筛选分页。利用主键索引提高效率,但子查询会生成临时表,复杂场景下性能不佳。 延迟关联 (INNER JOIN): 使用 INNER JOIN 将分页操作转移到主键索引上,减少回表次数。相比子查询,延迟关联的性能更优,适合大数据量的分页查询。 覆盖索引: 通过索引直接获取所需字段,避免回表操作,减少 IO 开销,适合查询特定字段的场景。但当结果集较大时,MySQL 可能会选择全表扫描。 参考 # 聊聊如何解决 MySQL 深分页问题 - 捡田螺的小男孩: https://juejin.cn/post/7012016858379321358 数据库深分页介绍及优化方案 - 京东零售技术: https://mp.weixin.qq.com/s/ZEwGKvRCyvAgGlmeseAS7g MySQL 深分页优化 - 得物技术: https://juejin.cn/post/6985478936683610149 "},{"id":654,"href":"/zh/docs/technology/Interview/cs-basics/algorithms/10-classical-sorting-algorithms/","title":"十大经典排序算法总结","section":"Algorithms","content":" 本文转自: http://www.guoyaohua.com/sorting.html,JavaGuide 对其做了补充完善。\n引言 # 所谓排序,就是使一串记录,按照其中的某个或某些关键字的大小,递增或递减的排列起来的操作。排序算法,就是如何使得记录按照要求排列的方法。排序算法在很多领域得到相当地重视,尤其是在大量数据的处理方面。一个优秀的算法可以节省大量的资源。在各个领域中考虑到数据的各种限制和规范,要得到一个符合实际的优秀算法,得经过大量的推理和分析。\n简介 # 排序算法总结 # 常见的内部排序算法有:插入排序、希尔排序、选择排序、冒泡排序、归并排序、快速排序、堆排序、基数排序等,本文只讲解内部排序算法。用一张表格概括:\n排序算法 时间复杂度(平均) 时间复杂度(最差) 时间复杂度(最好) 空间复杂度 排序方式 稳定性 冒泡排序 O(n^2) O(n^2) O(n) O(1) 内部排序 稳定 选择排序 O(n^2) O(n^2) O(n^2) O(1) 内部排序 不稳定 插入排序 O(n^2) O(n^2) O(n) O(1) 内部排序 稳定 希尔排序 O(nlogn) O(n^2) O(nlogn) O(1) 内部排序 不稳定 归并排序 O(nlogn) O(nlogn) O(nlogn) O(n) 外部排序 稳定 快速排序 O(nlogn) O(n^2) O(nlogn) O(logn) 内部排序 不稳定 堆排序 O(nlogn) O(nlogn) O(nlogn) O(1) 内部排序 不稳定 计数排序 O(n+k) O(n+k) O(n+k) O(k) 外部排序 稳定 桶排序 O(n+k) O(n^2) O(n+k) O(n+k) 外部排序 稳定 基数排序 O(n×k) O(n×k) O(n×k) O(n+k) 外部排序 稳定 术语解释:\nn:数据规模,表示待排序的数据量大小。 k:“桶” 的个数,在某些特定的排序算法中(如基数排序、桶排序等),表示分割成的独立的排序区间或类别的数量。 内部排序:所有排序操作都在内存中完成,不需要额外的磁盘或其他存储设备的辅助。这适用于数据量小到足以完全加载到内存中的情况。 外部排序:当数据量过大,不可能全部加载到内存中时使用。外部排序通常涉及到数据的分区处理,部分数据被暂时存储在外部磁盘等存储设备上。 稳定:如果 A 原本在 B 前面,而 $A=B$,排序之后 A 仍然在 B 的前面。 不稳定:如果 A 原本在 B 的前面,而 $A=B$,排序之后 A 可能会出现在 B 的后面。 时间复杂度:定性描述一个算法执行所耗费的时间。 空间复杂度:定性描述一个算法执行所需内存的大小。 排序算法分类 # 十种常见排序算法可以分类两大类别:比较类排序和非比较类排序。\n常见的快速排序、归并排序、堆排序以及冒泡排序等都属于比较类排序算法。比较类排序是通过比较来决定元素间的相对次序,由于其时间复杂度不能突破 O(nlogn),因此也称为非线性时间比较类排序。在冒泡排序之类的排序中,问题规模为 n,又因为需要比较 n 次,所以平均时间复杂度为 O(n²)。在归并排序、快速排序之类的排序中,问题规模通过分治法消减为 logn 次,所以时间复杂度平均 O(nlogn)。\n比较类排序的优势是,适用于各种规模的数据,也不在乎数据的分布,都能进行排序。可以说,比较排序适用于一切需要排序的情况。\n而计数排序、基数排序、桶排序则属于非比较类排序算法。非比较排序不通过比较来决定元素间的相对次序,而是通过确定每个元素之前,应该有多少个元素来排序。由于它可以突破基于比较排序的时间下界,以线性时间运行,因此称为线性时间非比较类排序。 非比较排序只要确定每个元素之前的已有的元素个数即可,所有一次遍历即可解决。算法时间复杂度 $O(n)$。\n非比较排序时间复杂度底,但由于非比较排序需要占用空间来确定唯一位置。所以对数据规模和数据分布有一定的要求。\n冒泡排序 (Bubble Sort) # 冒泡排序是一种简单的排序算法。它重复地遍历要排序的序列,依次比较两个元素,如果它们的顺序错误就把它们交换过来。遍历序列的工作是重复地进行直到没有再需要交换为止,此时说明该序列已经排序完成。这个算法的名字由来是因为越小的元素会经由交换慢慢 “浮” 到数列的顶端。\n算法步骤 # 比较相邻的元素。如果第一个比第二个大,就交换它们两个; 对每一对相邻元素作同样的工作,从开始第一对到结尾的最后一对,这样在最后的元素应该会是最大的数; 针对所有的元素重复以上的步骤,除了最后一个; 重复步骤 1~3,直到排序完成。 图解算法 # 代码实现 # /** * 冒泡排序 * @param arr * @return arr */ public static int[] bubbleSort(int[] arr) { for (int i = 1; i \u0026lt; arr.length; i++) { // Set a flag, if true, that means the loop has not been swapped, // that is, the sequence has been ordered, the sorting has been completed. boolean flag = true; for (int j = 0; j \u0026lt; arr.length - i; j++) { if (arr[j] \u0026gt; arr[j + 1]) { int tmp = arr[j]; arr[j] = arr[j + 1]; arr[j + 1] = tmp; // Change flag flag = false; } } if (flag) { break; } } return arr; } 此处对代码做了一个小优化,加入了 is_sorted Flag,目的是将算法的最佳时间复杂度优化为 O(n),即当原输入序列就是排序好的情况下,该算法的时间复杂度就是 O(n)。\n算法分析 # 稳定性:稳定 时间复杂度:最佳:$O(n)$ ,最差:$O(n2)$, 平均:$O(n2)$ 空间复杂度:$O(1)$ 排序方式:In-place 选择排序 (Selection Sort) # 选择排序是一种简单直观的排序算法,无论什么数据进去都是 $O(n^2)$ 的时间复杂度。所以用到它的时候,数据规模越小越好。唯一的好处可能就是不占用额外的内存空间了吧。它的工作原理:首先在未排序序列中找到最小(大)元素,存放到排序序列的起始位置,然后,再从剩余未排序元素中继续寻找最小(大)元素,然后放到已排序序列的末尾。以此类推,直到所有元素均排序完毕。\n算法步骤 # 首先在未排序序列中找到最小(大)元素,存放到排序序列的起始位置 再从剩余未排序元素中继续寻找最小(大)元素,然后放到已排序序列的末尾。 重复第 2 步,直到所有元素均排序完毕。 图解算法 # 代码实现 # /** * 选择排序 * @param arr * @return arr */ public static int[] selectionSort(int[] arr) { for (int i = 0; i \u0026lt; arr.length - 1; i++) { int minIndex = i; for (int j = i + 1; j \u0026lt; arr.length; j++) { if (arr[j] \u0026lt; arr[minIndex]) { minIndex = j; } } if (minIndex != i) { int tmp = arr[i]; arr[i] = arr[minIndex]; arr[minIndex] = tmp; } } return arr; } 算法分析 # 稳定性:不稳定 时间复杂度:最佳:$O(n2)$ ,最差:$O(n2)$, 平均:$O(n^2)$ 空间复杂度:$O(1)$ 排序方式:In-place 插入排序 (Insertion Sort) # 插入排序是一种简单直观的排序算法。它的工作原理是通过构建有序序列,对于未排序数据,在已排序序列中从后向前扫描,找到相应位置并插入。插入排序在实现上,通常采用 in-place 排序(即只需用到 $O(1)$ 的额外空间的排序),因而在从后向前扫描过程中,需要反复把已排序元素逐步向后挪位,为最新元素提供插入空间。\n插入排序的代码实现虽然没有冒泡排序和选择排序那么简单粗暴,但它的原理应该是最容易理解的了,因为只要打过扑克牌的人都应该能够秒懂。插入排序是一种最简单直观的排序算法,它的工作原理是通过构建有序序列,对于未排序数据,在已排序序列中从后向前扫描,找到相应位置并插入。\n插入排序和冒泡排序一样,也有一种优化算法,叫做拆半插入。\n算法步骤 # 从第一个元素开始,该元素可以认为已经被排序; 取出下一个元素,在已经排序的元素序列中从后向前扫描; 如果该元素(已排序)大于新元素,将该元素移到下一位置; 重复步骤 3,直到找到已排序的元素小于或者等于新元素的位置; 将新元素插入到该位置后; 重复步骤 2~5。 图解算法 # 代码实现 # /** * 插入排序 * @param arr * @return arr */ public static int[] insertionSort(int[] arr) { for (int i = 1; i \u0026lt; arr.length; i++) { int preIndex = i - 1; int current = arr[i]; while (preIndex \u0026gt;= 0 \u0026amp;\u0026amp; current \u0026lt; arr[preIndex]) { arr[preIndex + 1] = arr[preIndex]; preIndex -= 1; } arr[preIndex + 1] = current; } return arr; } 算法分析 # 稳定性:稳定 时间复杂度:最佳:$O(n)$ ,最差:$O(n^2)$, 平均:$O(n2)$ 空间复杂度:O(1)$ 排序方式:In-place 希尔排序 (Shell Sort) # 希尔排序是希尔 (Donald Shell) 于 1959 年提出的一种排序算法。希尔排序也是一种插入排序,它是简单插入排序经过改进之后的一个更高效的版本,也称为递减增量排序算法,同时该算法是冲破 $O(n^2)$ 的第一批算法之一。\n希尔排序的基本思想是:先将整个待排序的记录序列分割成为若干子序列分别进行直接插入排序,待整个序列中的记录 “基本有序” 时,再对全体记录进行依次直接插入排序。\n算法步骤 # 我们来看下希尔排序的基本步骤,在此我们选择增量 $gap=length/2$,缩小增量继续以 $gap = gap/2$ 的方式,这种增量选择我们可以用一个序列来表示,$\\lbrace \\frac{n}{2}, \\frac{(n/2)}{2}, \\dots, 1 \\rbrace$,称为增量序列。希尔排序的增量序列的选择与证明是个数学难题,我们选择的这个增量序列是比较常用的,也是希尔建议的增量,称为希尔增量,但其实这个增量序列不是最优的。此处我们做示例使用希尔增量。\n先将整个待排序的记录序列分割成为若干子序列分别进行直接插入排序,具体算法描述:\n选择一个增量序列 $\\lbrace t_1, t_2, \\dots, t_k \\rbrace$,其中 $t_i \\gt t_j, i \\lt j, t_k = 1$; 按增量序列个数 k,对序列进行 k 趟排序; 每趟排序,根据对应的增量 $t$,将待排序列分割成若干长度为 $m$ 的子序列,分别对各子表进行直接插入排序。仅增量因子为 1 时,整个序列作为一个表来处理,表长度即为整个序列的长度。 图解算法 # 代码实现 # /** * 希尔排序 * * @param arr * @return arr */ public static int[] shellSort(int[] arr) { int n = arr.length; int gap = n / 2; while (gap \u0026gt; 0) { for (int i = gap; i \u0026lt; n; i++) { int current = arr[i]; int preIndex = i - gap; // Insertion sort while (preIndex \u0026gt;= 0 \u0026amp;\u0026amp; arr[preIndex] \u0026gt; current) { arr[preIndex + gap] = arr[preIndex]; preIndex -= gap; } arr[preIndex + gap] = current; } gap /= 2; } return arr; } 算法分析 # 稳定性:不稳定 时间复杂度:最佳:$O(nlogn)$, 最差:$O(n^2)$ 平均:$O(nlogn)$ 空间复杂度:$O(1)$ 归并排序 (Merge Sort) # 归并排序是建立在归并操作上的一种有效的排序算法。该算法是采用分治法 (Divide and Conquer) 的一个非常典型的应用。归并排序是一种稳定的排序方法。将已有序的子序列合并,得到完全有序的序列;即先使每个子序列有序,再使子序列段间有序。若将两个有序表合并成一个有序表,称为 2 - 路归并。\n和选择排序一样,归并排序的性能不受输入数据的影响,但表现比选择排序好的多,因为始终都是 $O(nlogn)$ 的时间复杂度。代价是需要额外的内存空间。\n算法步骤 # 归并排序算法是一个递归过程,边界条件为当输入序列仅有一个元素时,直接返回,具体过程如下:\n如果输入内只有一个元素,则直接返回,否则将长度为 $n$ 的输入序列分成两个长度为 $n/2$ 的子序列; 分别对这两个子序列进行归并排序,使子序列变为有序状态; 设定两个指针,分别指向两个已经排序子序列的起始位置; 比较两个指针所指向的元素,选择相对小的元素放入到合并空间(用于存放排序结果),并移动指针到下一位置; 重复步骤 3 ~ 4 直到某一指针达到序列尾; 将另一序列剩下的所有元素直接复制到合并序列尾。 图解算法 # 代码实现 # /** * 归并排序 * * @param arr * @return arr */ public static int[] mergeSort(int[] arr) { if (arr.length \u0026lt;= 1) { return arr; } int middle = arr.length / 2; int[] arr_1 = Arrays.copyOfRange(arr, 0, middle); int[] arr_2 = Arrays.copyOfRange(arr, middle, arr.length); return merge(mergeSort(arr_1), mergeSort(arr_2)); } /** * Merge two sorted arrays * * @param arr_1 * @param arr_2 * @return sorted_arr */ public static int[] merge(int[] arr_1, int[] arr_2) { int[] sorted_arr = new int[arr_1.length + arr_2.length]; int idx = 0, idx_1 = 0, idx_2 = 0; while (idx_1 \u0026lt; arr_1.length \u0026amp;\u0026amp; idx_2 \u0026lt; arr_2.length) { if (arr_1[idx_1] \u0026lt; arr_2[idx_2]) { sorted_arr[idx] = arr_1[idx_1]; idx_1 += 1; } else { sorted_arr[idx] = arr_2[idx_2]; idx_2 += 1; } idx += 1; } if (idx_1 \u0026lt; arr_1.length) { while (idx_1 \u0026lt; arr_1.length) { sorted_arr[idx] = arr_1[idx_1]; idx_1 += 1; idx += 1; } } else { while (idx_2 \u0026lt; arr_2.length) { sorted_arr[idx] = arr_2[idx_2]; idx_2 += 1; idx += 1; } } return sorted_arr; } 算法分析 # 稳定性:稳定 时间复杂度:最佳:$O(nlogn)$, 最差:$O(nlogn)$, 平均:$O(nlogn)$ 空间复杂度:$O(n)$ 快速排序 (Quick Sort) # 快速排序用到了分治思想,同样的还有归并排序。乍看起来快速排序和归并排序非常相似,都是将问题变小,先排序子串,最后合并。不同的是快速排序在划分子问题的时候经过多一步处理,将划分的两组数据划分为一大一小,这样在最后合并的时候就不必像归并排序那样再进行比较。但也正因为如此,划分的不定性使得快速排序的时间复杂度并不稳定。\n快速排序的基本思想:通过一趟排序将待排序列分隔成独立的两部分,其中一部分记录的元素均比另一部分的元素小,则可分别对这两部分子序列继续进行排序,以达到整个序列有序。\n算法步骤 # 快速排序使用 分治法(Divide and conquer)策略来把一个序列分为较小和较大的 2 个子序列,然后递归地排序两个子序列。具体算法描述如下:\n从序列中随机挑出一个元素,做为 “基准”(pivot); 重新排列序列,将所有比基准值小的元素摆放在基准前面,所有比基准值大的摆在基准的后面(相同的数可以到任一边)。在这个操作结束之后,该基准就处于数列的中间位置。这个称为分区(partition)操作; 递归地把小于基准值元素的子序列和大于基准值元素的子序列进行快速排序。 图解算法 # 代码实现 # 来源: 使用 Java 实现快速排序(详解)\npublic static int partition(int[] array, int low, int high) { int pivot = array[high]; int pointer = low; for (int i = low; i \u0026lt; high; i++) { if (array[i] \u0026lt;= pivot) { int temp = array[i]; array[i] = array[pointer]; array[pointer] = temp; pointer++; } System.out.println(Arrays.toString(array)); } int temp = array[pointer]; array[pointer] = array[high]; array[high] = temp; return pointer; } public static void quickSort(int[] array, int low, int high) { if (low \u0026lt; high) { int position = partition(array, low, high); quickSort(array, low, position - 1); quickSort(array, position + 1, high); } } 算法分析 # 稳定性:不稳定 时间复杂度:最佳:$O(nlogn)$, 最差:$O(n^2)$,平均:$O(nlogn)$ 空间复杂度:$O(logn)$ 堆排序 (Heap Sort) # 堆排序是指利用堆这种数据结构所设计的一种排序算法。堆是一个近似完全二叉树的结构,并同时满足堆的性质:即子结点的值总是小于(或者大于)它的父节点。\n算法步骤 # 将初始待排序列 $(R_1, R_2, \\dots, R_n)$ 构建成大顶堆,此堆为初始的无序区; 将堆顶元素 $R_1$ 与最后一个元素 $R_n$ 交换,此时得到新的无序区 $(R_1, R_2, \\dots, R_{n-1})$ 和新的有序区 $R_n$, 且满足 $R_i \\leqslant R_n (i \\in 1, 2,\\dots, n-1)$; 由于交换后新的堆顶 $R_1$ 可能违反堆的性质,因此需要对当前无序区 $(R_1, R_2, \\dots, R_{n-1})$ 调整为新堆,然后再次将 $R_1$ 与无序区最后一个元素交换,得到新的无序区 $(R_1, R_2, \\dots, R_{n-2})$ 和新的有序区 $(R_{n-1}, R_n)$。不断重复此过程直到有序区的元素个数为 $n-1$,则整个排序过程完成。 图解算法 # 代码实现 # // Global variable that records the length of an array; static int heapLen; /** * Swap the two elements of an array * @param arr * @param i * @param j */ private static void swap(int[] arr, int i, int j) { int tmp = arr[i]; arr[i] = arr[j]; arr[j] = tmp; } /** * Build Max Heap * @param arr */ private static void buildMaxHeap(int[] arr) { for (int i = arr.length / 2 - 1; i \u0026gt;= 0; i--) { heapify(arr, i); } } /** * Adjust it to the maximum heap * @param arr * @param i */ private static void heapify(int[] arr, int i) { int left = 2 * i + 1; int right = 2 * i + 2; int largest = i; if (right \u0026lt; heapLen \u0026amp;\u0026amp; arr[right] \u0026gt; arr[largest]) { largest = right; } if (left \u0026lt; heapLen \u0026amp;\u0026amp; arr[left] \u0026gt; arr[largest]) { largest = left; } if (largest != i) { swap(arr, largest, i); heapify(arr, largest); } } /** * Heap Sort * @param arr * @return */ public static int[] heapSort(int[] arr) { // index at the end of the heap heapLen = arr.length; // build MaxHeap buildMaxHeap(arr); for (int i = arr.length - 1; i \u0026gt; 0; i--) { // Move the top of the heap to the tail of the heap in turn swap(arr, 0, i); heapLen -= 1; heapify(arr, 0); } return arr; } 算法分析 # 稳定性:不稳定 时间复杂度:最佳:$O(nlogn)$, 最差:$O(nlogn)$, 平均:$O(nlogn)$ 空间复杂度:$O(1)$ 计数排序 (Counting Sort) # 计数排序的核心在于将输入的数据值转化为键存储在额外开辟的数组空间中。 作为一种线性时间复杂度的排序,计数排序要求输入的数据必须是有确定范围的整数。\n计数排序 (Counting sort) 是一种稳定的排序算法。计数排序使用一个额外的数组 C,其中第 i 个元素是待排序数组 A 中值等于 i 的元素的个数。然后根据数组 C 来将 A 中的元素排到正确的位置。它只能对整数进行排序。\n算法步骤 # 找出数组中的最大值 max、最小值 min; 创建一个新数组 C,其长度是 max-min+1,其元素默认值都为 0; 遍历原数组 A 中的元素 A[i],以 A[i] - min 作为 C 数组的索引,以 A[i] 的值在 A 中元素出现次数作为 C[A[i] - min] 的值; 对 C 数组变形,新元素的值是该元素与前一个元素值的和,即当 i\u0026gt;1 时 C[i] = C[i] + C[i-1]; 创建结果数组 R,长度和原始数组一样。 从后向前遍历原始数组 A 中的元素 A[i],使用 A[i] 减去最小值 min 作为索引,在计数数组 C 中找到对应的值 C[A[i] - min],C[A[i] - min] - 1 就是 A[i] 在结果数组 R 中的位置,做完上述这些操作,将 count[A[i] - min] 减小 1。 图解算法 # 代码实现 # /** * Gets the maximum and minimum values in the array * * @param arr * @return */ private static int[] getMinAndMax(int[] arr) { int maxValue = arr[0]; int minValue = arr[0]; for (int i = 0; i \u0026lt; arr.length; i++) { if (arr[i] \u0026gt; maxValue) { maxValue = arr[i]; } else if (arr[i] \u0026lt; minValue) { minValue = arr[i]; } } return new int[] { minValue, maxValue }; } /** * Counting Sort * * @param arr * @return */ public static int[] countingSort(int[] arr) { if (arr.length \u0026lt; 2) { return arr; } int[] extremum = getMinAndMax(arr); int minValue = extremum[0]; int maxValue = extremum[1]; int[] countArr = new int[maxValue - minValue + 1]; int[] result = new int[arr.length]; for (int i = 0; i \u0026lt; arr.length; i++) { countArr[arr[i] - minValue] += 1; } for (int i = 1; i \u0026lt; countArr.length; i++) { countArr[i] += countArr[i - 1]; } for (int i = arr.length - 1; i \u0026gt;= 0; i--) { int idx = countArr[arr[i] - minValue] - 1; result[idx] = arr[i]; countArr[arr[i] - minValue] -= 1; } return result; } 算法分析 # 当输入的元素是 n 个 0 到 k 之间的整数时,它的运行时间是 $O(n+k)$。计数排序不是比较排序,排序的速度快于任何比较排序算法。由于用来计数的数组 C 的长度取决于待排序数组中数据的范围(等于待排序数组的最大值与最小值的差加上 1),这使得计数排序对于数据范围很大的数组,需要大量额外内存空间。\n稳定性:稳定 时间复杂度:最佳:$O(n+k)$ 最差:$O(n+k)$ 平均:$O(n+k)$ 空间复杂度:$O(k)$ 桶排序 (Bucket Sort) # 桶排序是计数排序的升级版。它利用了函数的映射关系,高效与否的关键就在于这个映射函数的确定。为了使桶排序更加高效,我们需要做到这两点:\n在额外空间充足的情况下,尽量增大桶的数量 使用的映射函数能够将输入的 N 个数据均匀的分配到 K 个桶中 桶排序的工作的原理:假设输入数据服从均匀分布,将数据分到有限数量的桶里,每个桶再分别排序(有可能再使用别的排序算法或是以递归方式继续使用桶排序进行。\n算法步骤 # 设置一个 BucketSize,作为每个桶所能放置多少个不同数值; 遍历输入数据,并且把数据依次映射到对应的桶里去; 对每个非空的桶进行排序,可以使用其它排序方法,也可以递归使用桶排序; 从非空桶里把排好序的数据拼接起来。 图解算法 # 代码实现 # /** * Gets the maximum and minimum values in the array * @param arr * @return */ private static int[] getMinAndMax(List\u0026lt;Integer\u0026gt; arr) { int maxValue = arr.get(0); int minValue = arr.get(0); for (int i : arr) { if (i \u0026gt; maxValue) { maxValue = i; } else if (i \u0026lt; minValue) { minValue = i; } } return new int[] { minValue, maxValue }; } /** * Bucket Sort * @param arr * @return */ public static List\u0026lt;Integer\u0026gt; bucketSort(List\u0026lt;Integer\u0026gt; arr, int bucket_size) { if (arr.size() \u0026lt; 2 || bucket_size == 0) { return arr; } int[] extremum = getMinAndMax(arr); int minValue = extremum[0]; int maxValue = extremum[1]; int bucket_cnt = (maxValue - minValue) / bucket_size + 1; List\u0026lt;List\u0026lt;Integer\u0026gt;\u0026gt; buckets = new ArrayList\u0026lt;\u0026gt;(); for (int i = 0; i \u0026lt; bucket_cnt; i++) { buckets.add(new ArrayList\u0026lt;Integer\u0026gt;()); } for (int element : arr) { int idx = (element - minValue) / bucket_size; buckets.get(idx).add(element); } for (int i = 0; i \u0026lt; buckets.size(); i++) { if (buckets.get(i).size() \u0026gt; 1) { buckets.set(i, sort(buckets.get(i), bucket_size / 2)); } } ArrayList\u0026lt;Integer\u0026gt; result = new ArrayList\u0026lt;\u0026gt;(); for (List\u0026lt;Integer\u0026gt; bucket : buckets) { for (int element : bucket) { result.add(element); } } return result; } 算法分析 # 稳定性:稳定 时间复杂度:最佳:$O(n+k)$ 最差:$O(n^2)$ 平均:$O(n+k)$ 空间复杂度:$O(n+k)$ 基数排序 (Radix Sort) # 基数排序也是非比较的排序算法,对元素中的每一位数字进行排序,从最低位开始排序,复杂度为 $O(n×k)$,$n$ 为数组长度,$k$ 为数组中元素的最大的位数;\n基数排序是按照低位先排序,然后收集;再按照高位排序,然后再收集;依次类推,直到最高位。有时候有些属性是有优先级顺序的,先按低优先级排序,再按高优先级排序。最后的次序就是高优先级高的在前,高优先级相同的低优先级高的在前。基数排序基于分别排序,分别收集,所以是稳定的。\n算法步骤 # 取得数组中的最大数,并取得位数,即为迭代次数 $N$(例如:数组中最大数值为 1000,则 $N=4$); A 为原始数组,从最低位开始取每个位组成 radix 数组; 对 radix 进行计数排序(利用计数排序适用于小范围数的特点); 将 radix 依次赋值给原数组; 重复 2~4 步骤 $N$ 次 图解算法 # 代码实现 # /** * Radix Sort * * @param arr * @return */ public static int[] radixSort(int[] arr) { if (arr.length \u0026lt; 2) { return arr; } int N = 1; int maxValue = arr[0]; for (int element : arr) { if (element \u0026gt; maxValue) { maxValue = element; } } while (maxValue / 10 != 0) { maxValue = maxValue / 10; N += 1; } for (int i = 0; i \u0026lt; N; i++) { List\u0026lt;List\u0026lt;Integer\u0026gt;\u0026gt; radix = new ArrayList\u0026lt;\u0026gt;(); for (int k = 0; k \u0026lt; 10; k++) { radix.add(new ArrayList\u0026lt;Integer\u0026gt;()); } for (int element : arr) { int idx = (element / (int) Math.pow(10, i)) % 10; radix.get(idx).add(element); } int idx = 0; for (List\u0026lt;Integer\u0026gt; l : radix) { for (int n : l) { arr[idx++] = n; } } } return arr; } 算法分析 # 稳定性:稳定 时间复杂度:最佳:$O(n×k)$ 最差:$O(n×k)$ 平均:$O(n×k)$ 空间复杂度:$O(n+k)$ 基数排序 vs 计数排序 vs 桶排序\n这三种排序算法都利用了桶的概念,但对桶的使用方法上有明显差异:\n基数排序:根据键值的每位数字来分配桶 计数排序:每个桶只存储单一键值 桶排序:每个桶存储一定范围的数值 参考文章 # https://www.cnblogs.com/guoyaohua/p/8600214.html https://en.wikipedia.org/wiki/Sorting_algorithm https://sort.hust.cc/ "},{"id":655,"href":"/zh/docs/technology/Interview/high-quality-technical-articles/advanced-programmer/ten-years-of-dachang-growth-road/","title":"十年大厂成长之路","section":"Advanced Programmer","content":" 推荐语:这篇文章的作者有着丰富的工作经验,曾在大厂工作了 12 年。结合自己走过的弯路和接触过的优秀技术人,他总结出了一些对于个人成长具有普遍指导意义的经验和特质。\n原文地址: https://mp.weixin.qq.com/s/vIIRxznpRr5yd6IVyNUW2w\n最近这段时间,有好几个年轻的同学和我聊到自己的迷茫。其中有关于技术成长的、有关于晋升的、有关于择业的。我很高兴他们愿意听我这个“过来人”分享自己的经验。\n我自己毕业后进入大厂,在大厂工作 12 年,我说的内容都来自于我自己或者身边人的真实情况。尤其,我会把 【我自己走过的弯路】 和 【我看到过的优秀技术人的特质】 相结合来给出建议。\n这些内容我觉得具有普遍的指导意义,所以决定做个整理分享出来。我相信,无论你在大厂还是小厂,如果你相信这些建议,或早或晚他们会帮助到你。\n我自己工作 12 年,走了些弯路,所以我就来讲讲,“在一个技术人 10 年的发展过程中,应该注意些什么”。我们把内容分为两块:\n十年技术路怎么走 一些重要选择 01 十年技术路怎么走 # 【1-2 年】=\u0026gt; 从“菜鸟”到“职业” # 应届生刚进入到工作时,会有各种不适应。比如写好的代码会被反复打回、和团队老司机讨论技术问题会有一堆问号、不敢提问和质疑、碰到问题一个人使劲死磕等等。\n简单来说就是,即使日以继夜地埋头苦干,最后也无法顺利的开展工作。\n这个阶段最重要的几个点:\n【多看多模仿】:比如写代码的时候,不要就像在学校完成书本作业那样只关心功能是否正确,还要关心模块的设计、异常的处理、代码的可读性等等。在你还没有了解这些内容的精髓之前,也要照猫画虎地模仿起来,慢慢地你就会越来越明白真实世界的代码是怎么写的,以及为什么要这么写。\n做技术方案的时候也是同理,技术文档的要求你也许并不理解,但你可以先参考已有文档写起来。\n【脸皮厚一点】:不懂就问,你是新人大家都是理解的。你做的各种方案也可以多找老司机们 review,不要怕被看笑话。\n【关注工作方式】:比如发现需求在计划时间完不成就要尽快报风险、及时做好工作内容的汇报(例如周报)、开会后确定会议结论和 todo 项、承诺时间就要尽力完成、严格遵循公司的要求(例如发布规范、权限规范等)\n一般来说,工作 2 年后,你就应该成为一个职业人。老板可以相信任何工作交到你的手里,不会出现“意外”(例如一个重要需求明天要上线了,突然被告知上不了)。\n【3-4 年】=\u0026gt; 从“职业”到“尖兵” # 工作两年后,对业务以及现有系统的了解已经到达了一定的程度,技术同学会开始承担更有难度的技术挑战。\n例如需要将性能提升到某一个水位、例如需要对某一个重要模块进行重构、例如有个重要的项目需要协同 N 个团队一起完成。\n可见,上述的这些技术问题,难度都已经远远超过一个普通的需求。解决这些问题需要有一定的技术能力,同时也需要具备更高的协同能力。\n这个阶段最重要的几个点:\n【技术能力提升】:无论是公司内还是公司外的技术内容,都要多做主动的学习。基本上这个阶段的技术难题都集中在【性能】【稳定性】和【扩展性】上,而这些内容在业界都是有成型的方法论的。\n【主人翁精神】:技术难题除了技术方案设计及落地外,背后还有一系列的其他工作。例如上线后对效果的观测、重点项目对于上下游改造和风险的了解程度、对于整个技改后续的计划(二期、三期的优化思路)等。\n在工作四年后,基本上你成为了团队的一、二号技术位。很多技术难题即使不是你来落地,也是由你来决定方案。你会做调研、会做方案对比、会考虑整个技改的生命周期。\n【5-7 年】=\u0026gt; 从“尖兵”到“专家” # 技术尖兵重点在于解决某一个具体的技术难题或者重点项目。而下一步的发展方向,就是能够承担起来一整个“业务板块”,也就是“领域技术专家”。\n想要承担一整个“业务板块”需要 【对业务领域有深刻的理解,同时基于这些理解来规划技术的发展方向】 。\n拿支付做个例子。简单的支付功能其实很容易完成,只要处理好和双联(网联和银联)的接口调用(成功、失败、异常)即可。但在很多背景下,支付没有那么简单。\n例如,支付是一个用户敏感型操作,非常强调用户体验,如何能兼顾体验和接口的不稳定?支付接口还需要承担费用,同步和异步的接口费用不同,如何能够降本?支付接口往往还有限额等。这一系列问题的背后涉及到很多技术的设计,包括异步化、补偿设计、资金流设计、最终一致性设计等等。\n这个阶段最重要的几个点:\n【深入理解行业及趋势】:密切关注行业的各种变化(新鲜的玩法、政策的变动、竞对的策略、科技等外在因素的影响等等),和业务同学加强沟通。\n【深入了解行业解决方案】:充分对标已有的国内外技术方案,做深入学习和尝试,评估建设及运维成本,结合业务趋势制定计划。\n【8-10 年】=\u0026gt; 从“专家”到“TL” # 其实很多时候,如果能做到专家,基本也是一个 TL 的角色了,但这并不代表正在执行 TL 的职责。\n专家虽然已经可以做到“为业务发展而规划好技术发展”,但问题是要怎么落地呢?显然,靠一个人的力量是不可能完成建设的。所以,这里的 TL 更多强调的不是“领导”这个职位,而是 【通过聚合一个团队的力量来实施技术规划】 。\n所以,这里的 TL 需要具备【团队技术培养】【合理分配资源】【确认工作优先级】【激励与奖惩】等各种能力。\n这个阶段最重要的几个点:\n【学习管理学】:这里的管理学当然不是指 PUA,而是指如何在每个同学都有各自诉求的现实背景下,让个人目标和团队目标相结合,产生向前发展的动力。\n【始终扎根技术】:很多时候,工作重心偏向管理以后,就会荒废技术。但事实是,一个优秀的领导永远是一个优秀的技术人。参与一起讨论技术方案并给予指导、不断扩展自己的技术宽度、保持对技术的好奇心,这些是让一个技术领导持续拥有向心力的关键。\n02 一些重要选择 # 下面来聊聊在十年间我们可能会碰到的一些重要选择。这些都是真实的血与泪的教训。\n我该不该转岗? # 大厂都有转岗的机制。转岗可以帮助员工寻找自己感兴趣的方向,也可以帮助新型团队招募有即战力的同学。\n转岗看似只是在公司内部变动,但你需要谨慎决定。\n本人转岗过多次。虽然还在同一家公司,但转岗等同于换工作。无论是领域沉淀、工作内容、信任关系、协作关系都是从零开始。\n针对转岗我的建议是:==如果你是想要拓宽自己的技术广度,也就是抱着提升技术能力的想法,我觉得可以转岗。但如果你想要晋升,不建议你转岗。==晋升需要在一个领域的持续积淀和在一个团队信任感的持续建立。\n当然,转岗可能还有其他原因,例如家庭原因、身体原因等,这个不展开讨论了。\n我该不该跳槽? # 跳槽和转岗一样,往往有很多因素造成,不能一概而论,我仅以几个场景来说:\n【晋升失败】:扪心自问,如果你觉得自己确实还不够格,那你就踏踏实实继续努力。如果你觉得评委有失偏颇,你可以尝试去外面面试一下,让市场来给你答案。\n【成长局限】:觉得自己做的事情没有挑战,无法成长。你可以和老板聊一下,有可能是因为你没有看到其中的挑战,也有可能老板没有意识到你的“野心”。\n【氛围不适】:一般来自于新入职或者领导更换,这种情况下不适是正常的。我的建议是,如果一个环境是“对事不对人”的,那就可以留下来,努力去适应,这种不适应只是做事方式不同导致的。但如果这个环境是“对人不对事”的话,走吧。\n跳槽该找怎样的工作? # 我们跳槽的时候往往会同时面试好几家公司。行情好的时候,往往可以收到多家 offer,那么我们要如何选择呢?\n考虑一个 offer 往往有这几点:【公司品牌】【薪资待遇】【职级职称】【技术背景】。每个同学其实都有自己的诉求,所以无论做什么选择都没有对错之分。\n我的一个建议是:你要关注新岗位的空间,这个空间是有希望满足你的期待的。\n比如,你想成为架构师,那新岗位是否有足够的技术挑战来帮助你提升技术能力,而不仅仅是疲于奔命地应付需求?\n比如,你想往技术管理发展,那新岗位是否有带人的机会?是否有足够的问题需要搭建团队来解决?\n比如,你想扎根在某个领域持续发展(例如电商、游戏),那新岗位是不是延续这个领域,并且可以碰到更多这个领域的问题?\n当然,如果薪资实在高到无法拒绝,以上参考可以忽略!\n结语 # 以上就是我对互联网从业技术人员十年成长之路的心得,希望在你困惑和关键选择的时候可以帮助到你。如果我的只言片语能够在未来的某个时间帮助到你哪怕一点,那将是我莫大的荣幸。\n"},{"id":656,"href":"/zh/docs/technology/Interview/cs-basics/data-structure/tree/","title":"树","section":"Data Structure","content":"树就是一种类似现实生活中的树的数据结构(倒置的树)。任何一颗非空树只有一个根节点。\n一棵树具有以下特点:\n一棵树中的任意两个结点有且仅有唯一的一条路径连通。 一棵树如果有 n 个结点,那么它一定恰好有 n-1 条边。 一棵树不包含回路。 下图就是一颗树,并且是一颗二叉树。\n如上图所示,通过上面这张图说明一下树中的常用概念:\n节点:树中的每个元素都可以统称为节点。 根节点:顶层节点或者说没有父节点的节点。上图中 A 节点就是根节点。 父节点:若一个节点含有子节点,则这个节点称为其子节点的父节点。上图中的 B 节点是 D 节点、E 节点的父节点。 子节点:一个节点含有的子树的根节点称为该节点的子节点。上图中 D 节点、E 节点是 B 节点的子节点。 兄弟节点:具有相同父节点的节点互称为兄弟节点。上图中 D 节点、E 节点的共同父节点是 B 节点,故 D 和 E 为兄弟节点。 叶子节点:没有子节点的节点。上图中的 D、F、H、I 都是叶子节点。 节点的高度:该节点到叶子节点的最长路径所包含的边数。 节点的深度:根节点到该节点的路径所包含的边数 节点的层数:节点的深度+1。 树的高度:根节点的高度。 关于树的深度和高度的定义可以看 stackoverflow 上的这个问题: What is the difference between tree depth and height? 。\n二叉树的分类 # 二叉树(Binary tree)是每个节点最多只有两个分支(即不存在分支度大于 2 的节点)的树结构。\n二叉树 的分支通常被称作“左子树”或“右子树”。并且,二叉树 的分支具有左右次序,不能随意颠倒。\n二叉树 的第 i 层至多拥有 2^(i-1) 个节点,深度为 k 的二叉树至多总共有 2^(k+1)-1 个节点(满二叉树的情况),至少有 2^(k) 个节点(关于节点的深度的定义国内争议比较多,我个人比较认可维基百科对 节点深度的定义)。\n满二叉树 # 一个二叉树,如果每一个层的结点数都达到最大值,则这个二叉树就是 满二叉树。也就是说,如果一个二叉树的层数为 K,且结点总数是(2^k) -1 ,则它就是 满二叉树。如下图所示:\n完全二叉树 # 除最后一层外,若其余层都是满的,并且最后一层是满的或者是在右边缺少连续若干节点,则这个二叉树就是 完全二叉树 。\n大家可以想象为一棵树从根结点开始扩展,扩展完左子节点才能开始扩展右子节点,每扩展完一层,才能继续扩展下一层。如下图所示:\n完全二叉树有一个很好的性质:父结点和子节点的序号有着对应关系。\n细心的小伙伴可能发现了,当根节点的值为 1 的情况下,若父结点的序号是 i,那么左子节点的序号就是 2i,右子节点的序号是 2i+1。这个性质使得完全二叉树利用数组存储时可以极大地节省空间,以及利用序号找到某个节点的父结点和子节点,后续二叉树的存储会详细介绍。\n平衡二叉树 # 平衡二叉树 是一棵二叉排序树,且具有以下性质:\n可以是一棵空树 如果不是空树,它的左右两个子树的高度差的绝对值不超过 1,并且左右两个子树都是一棵平衡二叉树。 平衡二叉树的常用实现方法有 红黑树、AVL 树、替罪羊树、加权平衡树、伸展树 等。\n在给大家展示平衡二叉树之前,先给大家看一棵树:\n你管这玩意儿叫树???\n没错,这玩意儿还真叫树,只不过这棵树已经退化为一个链表了,我们管它叫 斜树。\n如果这样,那我为啥不直接用链表呢?\n谁说不是呢?\n二叉树相比于链表,由于父子节点以及兄弟节点之间往往具有某种特殊的关系,这种关系使得我们在树中对数据进行搜索和修改时,相对于链表更加快捷便利。\n但是,如果二叉树退化为一个链表了,那么那么树所具有的优秀性质就难以表现出来,效率也会大打折,为了避免这样的情况,我们希望每个做 “家长”(父结点) 的,都 一碗水端平,分给左儿子和分给右儿子的尽可能一样多,相差最多不超过一层,如下图所示:\n二叉树的存储 # 二叉树的存储主要分为 链式存储 和 顺序存储 两种:\n链式存储 # 和链表类似,二叉树的链式存储依靠指针将各个节点串联起来,不需要连续的存储空间。\n每个节点包括三个属性:\n数据 data。data 不一定是单一的数据,根据不同情况,可以是多个具有不同类型的数据。 左节点指针 left 右节点指针 right。 可是 JAVA 没有指针啊!\n那就直接引用对象呗(别问我对象哪里找)\n顺序存储 # 顺序存储就是利用数组进行存储,数组中的每一个位置仅存储节点的 data,不存储左右子节点的指针,子节点的索引通过数组下标完成。根结点的序号为 1,对于每个节点 Node,假设它存储在数组中下标为 i 的位置,那么它的左子节点就存储在 2i 的位置,它的右子节点存储在下标为 2i+1 的位置。\n一棵完全二叉树的数组顺序存储如下图所示:\n大家可以试着填写一下存储如下二叉树的数组,比较一下和完全二叉树的顺序存储有何区别:\n可以看到,如果我们要存储的二叉树不是完全二叉树,在数组中就会出现空隙,导致内存利用率降低\n二叉树的遍历 # 先序遍历 # 二叉树的先序遍历,就是先输出根结点,再遍历左子树,最后遍历右子树,遍历左子树和右子树的时候,同样遵循先序遍历的规则,也就是说,我们可以递归实现先序遍历。\n代码如下:\npublic void preOrder(TreeNode root){ if(root == null){ return; } system.out.println(root.data); preOrder(root.left); preOrder(root.right); } 中序遍历 # 二叉树的中序遍历,就是先递归中序遍历左子树,再输出根结点的值,再递归中序遍历右子树,大家可以想象成一巴掌把树压扁,父结点被拍到了左子节点和右子节点的中间,如下图所示:\n代码如下:\npublic void inOrder(TreeNode root){ if(root == null){ return; } inOrder(root.left); system.out.println(root.data); inOrder(root.right); } 后序遍历 # 二叉树的后序遍历,就是先递归后序遍历左子树,再递归后序遍历右子树,最后输出根结点的值\n代码如下:\npublic void postOrder(TreeNode root){ if(root == null){ return; } postOrder(root.left); postOrder(root.right); system.out.println(root.data); } "},{"id":657,"href":"/zh/docs/technology/Interview/database/basis/","title":"数据库基础知识总结","section":"Database","content":"数据库知识基础,这部分内容一定要理解记忆。虽然这部分内容只是理论知识,但是非常重要,这是后面学习 MySQL 数据库的基础。PS: 这部分内容由于涉及太多概念性内容,所以参考了维基百科和百度百科相应的介绍。\n什么是数据库, 数据库管理系统, 数据库系统, 数据库管理员? # 数据库 : 数据库(DataBase 简称 DB)就是信息的集合或者说数据库是由数据库管理系统管理的数据的集合。 数据库管理系统 : 数据库管理系统(Database Management System 简称 DBMS)是一种操纵和管理数据库的大型软件,通常用于建立、使用和维护数据库。 数据库系统 : 数据库系统(Data Base System,简称 DBS)通常由软件、数据库和数据管理员(DBA)组成。 数据库管理员 : 数据库管理员(Database Administrator, 简称 DBA)负责全面管理和控制数据库系统。 什么是元组, 码, 候选码, 主码, 外码, 主属性, 非主属性? # 元组:元组(tuple)是关系数据库中的基本概念,关系是一张表,表中的每行(即数据库中的每条记录)就是一个元组,每列就是一个属性。 在二维表里,元组也称为行。 码:码就是能唯一标识实体的属性,对应表中的列。 候选码:若关系中的某一属性或属性组的值能唯一的标识一个元组,而其任何、子集都不能再标识,则称该属性组为候选码。例如:在学生实体中,“学号”是能唯一的区分学生实体的,同时又假设“姓名”、“班级”的属性组合足以区分学生实体,那么{学号}和{姓名,班级}都是候选码。 主码 : 主码也叫主键。主码是从候选码中选出来的。 一个实体集中只能有一个主码,但可以有多个候选码。 外码 : 外码也叫外键。如果一个关系中的一个属性是另外一个关系中的主码则这个属性为外码。 主属性:候选码中出现过的属性称为主属性。比如关系 工人(工号,身份证号,姓名,性别,部门). 显然工号和身份证号都能够唯一标示这个关系,所以都是候选码。工号、身份证号这两个属性就是主属性。如果主码是一个属性组,那么属性组中的属性都是主属性。 非主属性: 不包含在任何一个候选码中的属性称为非主属性。比如在关系——学生(学号,姓名,年龄,性别,班级)中,主码是“学号”,那么其他的“姓名”、“年龄”、“性别”、“班级”就都可以称为非主属性。 什么是 ER 图? # 我们做一个项目的时候一定要试着画 ER 图来捋清数据库设计,这个也是面试官问你项目的时候经常会被问到的。\nER 图 全称是 Entity Relationship Diagram(实体联系图),提供了表示实体类型、属性和联系的方法。\nER 图由下面 3 个要素组成:\n实体:通常是现实世界的业务对象,当然使用一些逻辑对象也可以。比如对于一个校园管理系统,会涉及学生、教师、课程、班级等等实体。在 ER 图中,实体使用矩形框表示。 属性:即某个实体拥有的属性,属性用来描述组成实体的要素,对于产品设计来说可以理解为字段。在 ER 图中,属性使用椭圆形表示。 联系:即实体与实体之间的关系,在 ER 图中用菱形表示,这个关系不仅有业务关联关系,还能通过数字表示实体之间的数量对照关系。例如,一个班级会有多个学生就是一种实体间的联系。 下图是一个学生选课的 ER 图,每个学生可以选若干门课程,同一门课程也可以被若干人选择,所以它们之间的关系是多对多(M: N)。另外,还有其他两种实体之间的关系是:1 对 1(1:1)、1 对多(1: N)。\n数据库范式了解吗? # 数据库范式有 3 种:\n1NF(第一范式):属性不可再分。 2NF(第二范式):1NF 的基础之上,消除了非主属性对于码的部分函数依赖。 3NF(第三范式):3NF 在 2NF 的基础之上,消除了非主属性对于码的传递函数依赖 。 1NF(第一范式) # 属性(对应于表中的字段)不能再被分割,也就是这个字段只能是一个值,不能再分为多个其他的字段了。1NF 是所有关系型数据库的最基本要求 ,也就是说关系型数据库中创建的表一定满足第一范式。\n2NF(第二范式) # 2NF 在 1NF 的基础之上,消除了非主属性对于码的部分函数依赖。如下图所示,展示了第一范式到第二范式的过渡。第二范式在第一范式的基础上增加了一个列,这个列称为主键,非主属性都依赖于主键。\n一些重要的概念:\n函数依赖(functional dependency):若在一张表中,在属性(或属性组)X 的值确定的情况下,必定能确定属性 Y 的值,那么就可以说 Y 函数依赖于 X,写作 X → Y。 部分函数依赖(partial functional dependency):如果 X→Y,并且存在 X 的一个真子集 X0,使得 X0→Y,则称 Y 对 X 部分函数依赖。比如学生基本信息表 R 中(学号,身份证号,姓名)当然学号属性取值是唯一的,在 R 关系中,(学号,身份证号)-\u0026gt;(姓名),(学号)-\u0026gt;(姓名),(身份证号)-\u0026gt;(姓名);所以姓名部分函数依赖于(学号,身份证号); 完全函数依赖(Full functional dependency):在一个关系中,若某个非主属性数据项依赖于全部关键字称之为完全函数依赖。比如学生基本信息表 R(学号,班级,姓名)假设不同的班级学号有相同的,班级内学号不能相同,在 R 关系中,(学号,班级)-\u0026gt;(姓名),但是(学号)-\u0026gt;(姓名)不成立,(班级)-\u0026gt;(姓名)不成立,所以姓名完全函数依赖与(学号,班级); 传递函数依赖:在关系模式 R(U)中,设 X,Y,Z 是 U 的不同的属性子集,如果 X 确定 Y、Y 确定 Z,且有 X 不包含 Y,Y 不确定 X,(X∪Y)∩Z=空集合,则称 Z 传递函数依赖(transitive functional dependency) 于 X。传递函数依赖会导致数据冗余和异常。传递函数依赖的 Y 和 Z 子集往往同属于某一个事物,因此可将其合并放到一个表中。比如在关系 R(学号 , 姓名, 系名,系主任)中,学号 → 系名,系名 → 系主任,所以存在非主属性系主任对于学号的传递函数依赖。 3NF(第三范式) # 3NF 在 2NF 的基础之上,消除了非主属性对于码的传递函数依赖 。符合 3NF 要求的数据库设计,基本上解决了数据冗余过大,插入异常,修改异常,删除异常的问题。比如在关系 R(学号 , 姓名, 系名,系主任)中,学号 → 系名,系名 → 系主任,所以存在非主属性系主任对于学号的传递函数依赖,所以该表的设计,不符合 3NF 的要求。\n主键和外键有什么区别? # 主键(主码):主键用于唯一标识一个元组,不能有重复,不允许为空。一个表只能有一个主键。 外键(外码):外键用来和其他表建立联系用,外键是另一表的主键,外键是可以有重复的,可以是空值。一个表可以有多个外键。 为什么不推荐使用外键与级联? # 对于外键和级联,阿里巴巴开发手册这样说到:\n【强制】不得使用外键与级联,一切外键概念必须在应用层解决。\n说明: 以学生和成绩的关系为例,学生表中的 student_id 是主键,那么成绩表中的 student_id 则为外键。如果更新学生表中的 student_id,同时触发成绩表中的 student_id 更新,即为级联更新。外键与级联更新适用于单机低并发,不适合分布式、高并发集群;级联更新是强阻塞,存在数据库更新风暴的风险;外键影响数据库的插入速度\n为什么不要用外键呢?大部分人可能会这样回答:\n增加了复杂性: a. 每次做 DELETE 或者 UPDATE 都必须考虑外键约束,会导致开发的时候很痛苦, 测试数据极为不方便; b. 外键的主从关系是定的,假如哪天需求有变化,数据库中的这个字段根本不需要和其他表有关联的话就会增加很多麻烦。 增加了额外工作:数据库需要增加维护外键的工作,比如当我们做一些涉及外键字段的增,删,更新操作之后,需要触发相关操作去检查,保证数据的的一致性和正确性,这样会不得不消耗数据库资源。如果在应用层面去维护的话,可以减小数据库压力; 对分库分表不友好:因为分库分表下外键是无法生效的。 …… 我个人觉得上面这种回答不是特别的全面,只是说了外键存在的一个常见的问题。实际上,我们知道外键也是有很多好处的,比如:\n保证了数据库数据的一致性和完整性; 级联操作方便,减轻了程序代码量; …… 所以说,不要一股脑的就抛弃了外键这个概念,既然它存在就有它存在的道理,如果系统不涉及分库分表,并发量不是很高的情况还是可以考虑使用外键的。\n什么是存储过程? # 我们可以把存储过程看成是一些 SQL 语句的集合,中间加了点逻辑控制语句。存储过程在业务比较复杂的时候是非常实用的,比如很多时候我们完成一个操作可能需要写一大串 SQL 语句,这时候我们就可以写有一个存储过程,这样也方便了我们下一次的调用。存储过程一旦调试完成通过后就能稳定运行,另外,使用存储过程比单纯 SQL 语句执行要快,因为存储过程是预编译过的。\n存储过程在互联网公司应用不多,因为存储过程难以调试和扩展,而且没有移植性,还会消耗数据库资源。\n阿里巴巴 Java 开发手册里要求禁止使用存储过程。\ndrop、delete 与 truncate 区别? # 用法不同 # drop(丢弃数据): drop table 表名 ,直接将表都删除掉,在删除表的时候使用。 truncate (清空数据) : truncate table 表名 ,只删除表中的数据,再插入数据的时候自增长 id 又从 1 开始,在清空表中数据的时候使用。 delete(删除数据) : delete from 表名 where 列名=值,删除某一行的数据,如果不加 where 子句和truncate table 表名作用类似。 truncate 和不带 where子句的 delete、以及 drop 都会删除表内的数据,但是 truncate 和 delete 只删除数据不删除表的结构(定义),执行 drop 语句,此表的结构也会删除,也就是执行drop 之后对应的表不复存在。\n属于不同的数据库语言 # truncate 和 drop 属于 DDL(数据定义语言)语句,操作立即生效,原数据不放到 rollback segment 中,不能回滚,操作不触发 trigger。而 delete 语句是 DML (数据库操作语言)语句,这个操作会放到 rollback segment 中,事务提交之后才生效。\nDML 语句和 DDL 语句区别:\nDML 是数据库操作语言(Data Manipulation Language)的缩写,是指对数据库中表记录的操作,主要包括表记录的插入、更新、删除和查询,是开发人员日常使用最频繁的操作。 DDL (Data Definition Language)是数据定义语言的缩写,简单来说,就是对数据库内部的对象进行创建、删除、修改的操作语言。它和 DML 语言的最大区别是 DML 只是对表内部数据的操作,而不涉及到表的定义、结构的修改,更不会涉及到其他对象。DDL 语句更多的被数据库管理员(DBA)所使用,一般的开发人员很少使用。 另外,由于select不会对表进行破坏,所以有的地方也会把select单独区分开叫做数据库查询语言 DQL(Data Query Language)。\n执行速度不同 # 一般来说:drop \u0026gt; truncate \u0026gt; delete(这个我没有实际测试过)。\ndelete命令执行的时候会产生数据库的binlog日志,而日志记录是需要消耗时间的,但是也有个好处方便数据回滚恢复。 truncate命令执行的时候不会产生数据库日志,因此比delete要快。除此之外,还会把表的自增值重置和索引恢复到初始大小等。 drop命令会把表占用的空间全部释放掉。 Tips:你应该更多地关注在使用场景上,而不是执行效率。\n数据库设计通常分为哪几步? # 需求分析 : 分析用户的需求,包括数据、功能和性能需求。 概念结构设计 : 主要采用 E-R 模型进行设计,包括画 E-R 图。 逻辑结构设计 : 通过将 E-R 图转换成表,实现从 E-R 模型到关系模型的转换。 物理结构设计 : 主要是为所设计的数据库选择合适的存储结构和存取路径。 数据库实施 : 包括编程、测试和试运行 数据库的运行和维护 : 系统的运行与数据库的日常维护。 参考 # https://blog.csdn.net/rl529014/article/details/48391465 https://www.zhihu.com/question/24696366/answer/29189700 https://blog.csdn.net/bieleyang/article/details/77149954 "},{"id":658,"href":"/zh/docs/technology/Interview/high-performance/data-cold-hot-separation/","title":"数据冷热分离详解","section":"High Performance","content":" 什么是数据冷热分离? # 数据冷热分离是指根据数据的访问频率和业务重要性,将数据分为冷数据和热数据,冷数据一般存储在存储在低成本、低性能的介质中,热数据高性能存储介质中。\n冷数据和热数据 # 热数据是指经常被访问和修改且需要快速访问的数据,冷数据是指不经常访问,对当前项目价值较低,但需要长期保存的数据。\n冷热数据到底如何区分呢?有两个常见的区分方法:\n时间维度区分:按照数据的创建时间、更新时间、过期时间等,将一定时间段内的数据视为热数据,超过该时间段的数据视为冷数据。例如,订单系统可以将 1 年前的订单数据作为冷数据,1 年内的订单数据作为热数据。这种方法适用于数据的访问频率和时间有较强的相关性的场景。 访问频率区分:将高频访问的数据视为热数据,低频访问的数据视为冷数据。例如,内容系统可以将浏览量非常低的文章作为冷数据,浏览量较高的文章作为热数据。这种方法需要记录数据的访问频率,成本较高,适合访问频率和数据本身有较强的相关性的场景。 几年前的数据并不一定都是冷数据,例如一些优质文章发表几年后依然有很多人访问,大部分普通用户新发表的文章却基本没什么人访问。\n这两种区分冷热数据的方法各有优劣,实际项目中,可以将两者结合使用。\n冷热分离的思想 # 冷热分离的思想非常简单,就是对数据进行分类,然后分开存储。冷热分离的思想可以应用到很多领域和场景中,而不仅仅是数据存储,例如:\n邮件系统中,可以将近期的比较重要的邮件放在收件箱,将比较久远的不太重要的邮件存入归档。 日常生活中,可以将常用的物品放在显眼的位置,不常用的物品放入储藏室或者阁楼。 图书馆中,可以将最受欢迎和最常借阅的图书单独放在一个显眼的区域,将较少借阅的书籍放在不起眼的位置。 …… 数据冷热分离的优缺点 # 优点:热数据的查询性能得到优化(用户的绝大部分操作体验会更好)、节约成本(可以冷热数据的不同存储需求,选择对应的数据库类型和硬件配置,比如将热数据放在 SSD 上,将冷数据放在 HDD 上) 缺点:系统复杂性和风险增加(需要分离冷热数据,数据错误的风险增加)、统计效率低(统计的时候可能需要用到冷库的数据)。 冷数据如何迁移? # 冷数据迁移方案:\n业务层代码实现:当有对数据进行写操作时,触发冷热分离的逻辑,判断数据是冷数据还是热数据,冷数据就入冷库,热数据就入热库。这种方案会影响性能且冷热数据的判断逻辑不太好确定,还需要修改业务层代码,因此一般不会使用。 任务调度:可以利用 xxl-job 或者其他分布式任务调度平台定时去扫描数据库,找出满足冷数据条件的数据,然后批量地将其复制到冷库中,并从热库中删除。这种方法修改的代码非常少,非常适合按照时间区分冷热数据的场景。 监听数据库的变更日志 binlog :将满足冷数据条件的数据从 binlog 中提取出来,然后复制到冷库中,并从热库中删除。这种方法可以不用修改代码,但不适合按照时间维度区分冷热数据的场景。 如果你的公司有 DBA 的话,也可以让 DBA 进行冷数据的人工迁移,一次迁移完成冷数据到冷库。然后,再搭配上面介绍的方案实现后续冷数据的迁移工作。\n冷数据如何存储? # 冷数据的存储要求主要是容量大,成本低,可靠性高,访问速度可以适当牺牲。\n冷数据存储方案:\n中小厂:直接使用 MySQL/PostgreSQL 即可(不改变数据库选型和项目当前使用的数据库保持一致),比如新增一张表来存储某个业务的冷数据或者使用单独的冷库来存放冷数据(涉及跨库查询,增加了系统复杂性和维护难度) 大厂:Hbase(常用)、RocksDB、Doris、Cassandra 如果公司成本预算足的话,也可以直接上 TiDB 这种分布式关系型数据库,直接一步到位。TiDB 6.0 正式支持数据冷热存储分离,可以降低 SSD 使用成本。使用 TiDB 6.0 的数据放置功能,可以在同一个集群实现海量数据的冷热存储,将新的热数据存入 SSD,历史冷数据存入 HDD。\n案例分享 # 如何快速优化几千万数据量的订单表 - 程序员济癫 - 2023 海量数据冷热分离方案与实践 - 字节跳动技术团队 - 2022 "},{"id":659,"href":"/zh/docs/technology/Interview/system-design/security/data-desensitization/","title":"数据脱敏方案总结","section":"Security","content":" 本文转载完善自 Hutool:一行代码搞定数据脱敏 - 京东云开发者。\n什么是数据脱敏 # 数据脱敏的定义 # 数据脱敏百度百科中是这样定义的:\n数据脱敏,指对某些敏感信息通过脱敏规则进行数据的变形,实现敏感隐私数据的可靠保护。这样就可以在开发、测试和其它非生产环境以及外包环境中安全地使用脱敏后的真实数据集。在涉及客户安全数据或者一些商业性敏感数据的情况下,在不违反系统规则条件下,对真实数据进行改造并提供测试使用,如身份证号、手机号、卡号、客户号等个人信息都需要进行数据脱敏。是数据库安全技术之一。\n总的来说,数据脱敏是指对某些敏感信息通过脱敏规则进行数据的变形,实现敏感隐私数据的可靠保护。\n在数据脱敏过程中,通常会采用不同的算法和技术,以根据不同的需求和场景对数据进行处理。例如,对于身份证号码,可以使用掩码算法(masking)将前几位数字保留,其他位用 “X” 或 \u0026ldquo;*\u0026rdquo; 代替;对于姓名,可以使用伪造(pseudonymization)算法,将真实姓名替换成随机生成的假名。\n常用脱敏规则 # 常用脱敏规则是为了保护敏感数据的安全性,在处理和存储敏感数据时对其进行变换或修改。\n下面是几种常见的脱敏规则:\n替换(常用):将敏感数据中的特定字符或字符序列替换为其他字符。例如,将信用卡号中的中间几位数字替换为星号(*)或其他字符。 删除:将敏感数据中的部分内容随机删除。比如,将电话号码的随机 3 位数字进行删除。 重排:将原始数据中的某些字符或字段的顺序打乱。例如,将身份证号码的随机位交错互换。 加噪:在数据中注入一些误差或者噪音,达到对数据脱敏的效果。例如,在敏感数据中添加一些随机生成的字符。 加密(常用):使用加密算法将敏感数据转换为密文。例如,将银行卡号用 MD5 或 SHA-256 等哈希函数进行散列。常见加密算法总结可以参考这篇文章: https://javaguide.cn/system-design/security/encryption-algorithms.html 。 …… 常用脱敏工具 # Hutool # Hutool 一个 Java 基础工具类,对文件、流、加密解密、转码、正则、线程、XML 等 JDK 方法进行封装,组成各种 Util 工具类,同时提供以下组件:\n模块 介绍 hutool-aop JDK 动态代理封装,提供非 IOC 下的切面支持 hutool-bloomFilter 布隆过滤,提供一些 Hash 算法的布隆过滤 hutool-cache 简单缓存实现 hutool-core 核心,包括 Bean 操作、日期、各种 Util 等 hutool-cron 定时任务模块,提供类 Crontab 表达式的定时任务 hutool-crypto 加密解密模块,提供对称、非对称和摘要算法封装 hutool-db JDBC 封装后的数据操作,基于 ActiveRecord 思想 hutool-dfa 基于 DFA 模型的多关键字查找 hutool-extra 扩展模块,对第三方封装(模板引擎、邮件、Servlet、二维码、Emoji、FTP、分词等) hutool-http 基于 HttpUrlConnection 的 Http 客户端封装 hutool-log 自动识别日志实现的日志门面 hutool-script 脚本执行封装,例如 Javascript hutool-setting 功能更强大的 Setting 配置文件和 Properties 封装 hutool-system 系统参数调用封装(JVM 信息等) hutool-json JSON 实现 hutool-captcha 图片验证码实现 hutool-poi 针对 POI 中 Excel 和 Word 的封装 hutool-socket 基于 Java 的 NIO 和 AIO 的 Socket 封装 hutool-jwt JSON Web Token (JWT) 封装实现 可以根据需求对每个模块单独引入,也可以通过引入hutool-all方式引入所有模块,本文所使用的数据脱敏工具就是在 hutool.core 模块。\n现阶段最新版本的 Hutool 支持的脱敏数据类型如下,基本覆盖了常见的敏感信息。\n用户 id 中文姓名 身份证号 座机号 手机号 地址 电子邮件 密码 中国大陆车牌,包含普通车辆、新能源车辆 银行卡 一行代码实现脱敏 # Hutool 提供的脱敏方法如下图所示:\n注意:Hutool 脱敏是通过 * 来代替敏感信息的,具体实现是在 StrUtil.hide 方法中,如果我们想要自定义隐藏符号,则可以把 Hutool 的源码拷出来,重新实现即可。\n这里以手机号、银行卡号、身份证号、密码信息的脱敏为例,下面是对应的测试代码。\nimport cn.hutool.core.util.DesensitizedUtil; import org.junit.Test; import org.springframework.boot.test.context.Spring BootTest; /** * * @description: Hutool实现数据脱敏 */ @Spring BootTest public class HuToolDesensitizationTest { @Test public void testPhoneDesensitization(){ String phone=\u0026#34;13723231234\u0026#34;; System.out.println(DesensitizedUtil.mobilePhone(phone)); //输出:137====1234 } @Test public void testBankCardDesensitization(){ String bankCard=\u0026#34;6217000130008255666\u0026#34;; System.out.println(DesensitizedUtil.bankCard(bankCard)); //输出:6217 ==== ==== *** 5666 } @Test public void testIdCardNumDesensitization(){ String idCardNum=\u0026#34;411021199901102321\u0026#34;; //只显示前4位和后2位 System.out.println(DesensitizedUtil.idCardNum(idCardNum,4,2)); //输出:4110============21 } @Test public void testPasswordDesensitization(){ String password=\u0026#34;www.jd.com_35711\u0026#34;; System.out.println(DesensitizedUtil.password(password)); //输出:================ } } 以上就是使用 Hutool 封装好的工具类实现数据脱敏。\n配合 JackSon 通过注解方式实现脱敏 # 现在有了数据脱敏工具类,如果前端需要显示数据数据的地方比较多,我们不可能在每个地方都调用一个工具类,这样就显得代码太冗余了,那我们如何通过注解的方式优雅的完成数据脱敏呢?\n如果项目是基于 Spring Boot 的 web 项目,则可以利用 Spring Boot 自带的 jackson 自定义序列化实现。它的实现原理其实就是在 json 进行序列化渲染给前端时,进行脱敏。\n第一步:脱敏策略的枚举。\n/** * @author * @description:脱敏策略枚举 */ public enum DesensitizationTypeEnum { //自定义 MY_RULE, //用户id USER_ID, //中文名 CHINESE_NAME, //身份证号 ID_CARD, //座机号 FIXED_PHONE, //手机号 MOBILE_PHONE, //地址 ADDRESS, //电子邮件 EMAIL, //密码 PASSWORD, //中国大陆车牌,包含普通车辆、新能源车辆 CAR_LICENSE, //银行卡 BANK_CARD } 上面表示支持的脱敏类型。\n第二步:定义一个用于脱敏的 Desensitization 注解。\n@Retention (RetentionPolicy.RUNTIME):运行时生效。 @Target (ElementType.FIELD):可用在字段上。 @JacksonAnnotationsInside:此注解可以点进去看一下是一个元注解,主要是用户打包其他注解一起使用。 @JsonSerialize:上面说到过,该注解的作用就是可自定义序列化,可以用在注解上,方法上,字段上,类上,运行时生效等等,根据提供的序列化类里面的重写方法实现自定义序列化。 /** * @author */ @Target(ElementType.FIELD) @Retention(RetentionPolicy.RUNTIME) @JacksonAnnotationsInside @JsonSerialize(using = DesensitizationSerialize.class) public @interface Desensitization { /** * 脱敏数据类型,在MY_RULE的时候,startInclude和endExclude生效 */ DesensitizationTypeEnum type() default DesensitizationTypeEnum.MY_RULE; /** * 脱敏开始位置(包含) */ int startInclude() default 0; /** * 脱敏结束位置(不包含) */ int endExclude() default 0; } 注:只有使用了自定义的脱敏枚举 MY_RULE 的时候,开始位置和结束位置才生效。\n第三步:创建自定的序列化类\n这一步是我们实现数据脱敏的关键。自定义序列化类继承 JsonSerializer,实现 ContextualSerializer 接口,并重写两个方法。\n/** * @author * @description: 自定义序列化类 */ @AllArgsConstructor @NoArgsConstructor public class DesensitizationSerialize extends JsonSerializer\u0026lt;String\u0026gt; implements ContextualSerializer { private DesensitizationTypeEnum type; private Integer startInclude; private Integer endExclude; @Override public void serialize(String str, JsonGenerator jsonGenerator, SerializerProvider serializerProvider) throws IOException { switch (type) { // 自定义类型脱敏 case MY_RULE: jsonGenerator.writeString(CharSequenceUtil.hide(str, startInclude, endExclude)); break; // userId脱敏 case USER_ID: jsonGenerator.writeString(String.valueOf(DesensitizedUtil.userId())); break; // 中文姓名脱敏 case CHINESE_NAME: jsonGenerator.writeString(DesensitizedUtil.chineseName(String.valueOf(str))); break; // 身份证脱敏 case ID_CARD: jsonGenerator.writeString(DesensitizedUtil.idCardNum(String.valueOf(str), 1, 2)); break; // 固定电话脱敏 case FIXED_PHONE: jsonGenerator.writeString(DesensitizedUtil.fixedPhone(String.valueOf(str))); break; // 手机号脱敏 case MOBILE_PHONE: jsonGenerator.writeString(DesensitizedUtil.mobilePhone(String.valueOf(str))); break; // 地址脱敏 case ADDRESS: jsonGenerator.writeString(DesensitizedUtil.address(String.valueOf(str), 8)); break; // 邮箱脱敏 case EMAIL: jsonGenerator.writeString(DesensitizedUtil.email(String.valueOf(str))); break; // 密码脱敏 case PASSWORD: jsonGenerator.writeString(DesensitizedUtil.password(String.valueOf(str))); break; // 中国车牌脱敏 case CAR_LICENSE: jsonGenerator.writeString(DesensitizedUtil.carLicense(String.valueOf(str))); break; // 银行卡脱敏 case BANK_CARD: jsonGenerator.writeString(DesensitizedUtil.bankCard(String.valueOf(str))); break; default: } } @Override public JsonSerializer\u0026lt;?\u0026gt; createContextual(SerializerProvider serializerProvider, BeanProperty beanProperty) throws JsonMappingException { if (beanProperty != null) { // 判断数据类型是否为String类型 if (Objects.equals(beanProperty.getType().getRawClass(), String.class)) { // 获取定义的注解 Desensitization desensitization = beanProperty.getAnnotation(Desensitization.class); // 为null if (desensitization == null) { desensitization = beanProperty.getContextAnnotation(Desensitization.class); } // 不为null if (desensitization != null) { // 创建定义的序列化类的实例并且返回,入参为注解定义的type,开始位置,结束位置。 return new DesensitizationSerialize(desensitization.type(), desensitization.startInclude(), desensitization.endExclude()); } } return serializerProvider.findValueSerializer(beanProperty.getType(), beanProperty); } return serializerProvider.findNullValueSerializer(null); } } 经过上述三步,已经完成了通过注解实现数据脱敏了,下面我们来测试一下。\n首先定义一个要测试的 pojo,对应的字段加入要脱敏的策略。\n/** * * @description: */ @Data @NoArgsConstructor @AllArgsConstructor public class TestPojo { private String userName; @Desensitization(type = DesensitizationTypeEnum.MOBILE_PHONE) private String phone; @Desensitization(type = DesensitizationTypeEnum.PASSWORD) private String password; @Desensitization(type = DesensitizationTypeEnum.MY_RULE, startInclude = 0, endExclude = 2) private String address; } 接下来写一个测试的 controller\n@RestController public class TestController { @RequestMapping(\u0026#34;/test\u0026#34;) public TestPojo testDesensitization(){ TestPojo testPojo = new TestPojo(); testPojo.setUserName(\u0026#34;我是用户名\u0026#34;); testPojo.setAddress(\u0026#34;地球中国-北京市通州区京东总部2号楼\u0026#34;); testPojo.setPhone(\u0026#34;13782946666\u0026#34;); testPojo.setPassword(\u0026#34;sunyangwei123123123.\u0026#34;); System.out.println(testPojo); return testPojo; } } 可以看到我们成功实现了数据脱敏。\nApache ShardingSphere # ShardingSphere 是一套开源的分布式数据库中间件解决方案组成的生态圈,它由 Sharding-JDBC、Sharding-Proxy 和 Sharding-Sidecar(计划中)这 3 款相互独立的产品组成。 他们均提供标准化的数据分片、分布式事务和数据库治理功能 。\nApache ShardingSphere 下面存在一个数据脱敏模块,此模块集成的常用的数据脱敏的功能。其基本原理是对用户输入的 SQL 进行解析拦截,并依靠用户的脱敏配置进行 SQL 的改写,从而实现对原文字段的加密及加密字段的解密。最终实现对用户无感的加解密存储、查询。\n通过 Apache ShardingSphere 可以自动化\u0026amp;透明化数据脱敏过程,用户无需关注脱敏中间实现细节。并且,提供了多种内置、第三方(AKS)的脱敏策略,用户仅需简单配置即可使用。\n官方文档地址: https://shardingsphere.apache.org/document/4.1.1/cn/features/orchestration/encrypt/ 。\nFastJSON # 平时开发 Web 项目的时候,除了默认的 Spring 自带的序列化工具,FastJson 也是一个很常用的 Spring Web Restful 接口序列化的工具。\nFastJSON 实现数据脱敏的方式主要有两种:\n基于注解 @JSONField 实现:需要自定义一个用于脱敏的序列化的类,然后在需要脱敏的字段上通过 @JSONField 中的 serializeUsing 指定为我们自定义的序列化类型即可。 基于序列化过滤器:需要实现 ValueFilter 接口,重写 process 方法完成自定义脱敏,然后在 JSON 转换时使用自定义的转换策略。具体实现可参考这篇文章: https://juejin.cn/post/7067916686141161479。 Mybatis-Mate # 先介绍一下 MyBatis、MyBatis-Plus 和 Mybatis-Mate 这三者的关系:\nMyBatis 是一款优秀的持久层框架,它支持定制化 SQL、存储过程以及高级映射。 MyBatis-Plus 是一个 MyBatis 的增强工具,能够极大地简化持久层的开发工作。 Mybatis-Mate 是为 MyBatis-Plus 提供的企业级模块,旨在更敏捷优雅处理数据。不过,使用之前需要配置授权码(付费)。 Mybatis-Mate 支持敏感词脱敏,内置手机号、邮箱、银行卡号等 9 种常用脱敏规则。\n@FieldSensitive(\u0026#34;testStrategy\u0026#34;) private String username; @Configuration public class SensitiveStrategyConfig { /** * 注入脱敏策略 */ @Bean public ISensitiveStrategy sensitiveStrategy() { // 自定义 testStrategy 类型脱敏处理 return new SensitiveStrategy().addStrategy(\u0026#34;testStrategy\u0026#34;, t -\u0026gt; t + \u0026#34;==*test==*\u0026#34;); } } // 跳过脱密处理,用于编辑场景 RequestDataTransfer.skipSensitive(); MyBatis-Flex # 类似于 MybatisPlus,MyBatis-Flex 也是一个 MyBatis 增强框架。MyBatis-Flex 同样提供了数据脱敏功能,并且是可以免费使用的。\nMyBatis-Flex 提供了 @ColumnMask() 注解,以及内置的 9 种脱敏规则,开箱即用:\n/** * 内置的数据脱敏方式 */ public class Masks { /** * 手机号脱敏 */ public static final String MOBILE = \u0026#34;mobile\u0026#34;; /** * 固定电话脱敏 */ public static final String FIXED_PHONE = \u0026#34;fixed_phone\u0026#34;; /** * 身份证号脱敏 */ public static final String ID_CARD_NUMBER = \u0026#34;id_card_number\u0026#34;; /** * 中文名脱敏 */ public static final String CHINESE_NAME = \u0026#34;chinese_name\u0026#34;; /** * 地址脱敏 */ public static final String ADDRESS = \u0026#34;address\u0026#34;; /** * 邮件脱敏 */ public static final String EMAIL = \u0026#34;email\u0026#34;; /** * 密码脱敏 */ public static final String PASSWORD = \u0026#34;password\u0026#34;; /** * 车牌号脱敏 */ public static final String CAR_LICENSE = \u0026#34;car_license\u0026#34;; /** * 银行卡号脱敏 */ public static final String BANK_CARD_NUMBER = \u0026#34;bank_card_number\u0026#34;; //... } 使用示例:\n@Table(\u0026#34;tb_account\u0026#34;) public class Account { @Id(keyType = KeyType.Auto) private Long id; @ColumnMask(Masks.CHINESE_NAME) private String userName; @ColumnMask(Masks.EMAIL) private String email; } 如果这些内置的脱敏规则不满足你的要求的话,你还可以自定义脱敏规则。\n1、通过 MaskManager 注册新的脱敏规则:\nMaskManager.registerMaskProcessor(\u0026#34;自定义规则名称\u0026#34; , data -\u0026gt; { return data; }) 2、使用自定义的脱敏规则\n@Table(\u0026#34;tb_account\u0026#34;) public class Account { @Id(keyType = KeyType.Auto) private Long id; @ColumnMask(\u0026#34;自定义规则名称\u0026#34;) private String userName; } 并且,对于需要跳过脱密处理的场景,例如进入编辑页面编辑用户数据,MyBatis-Flex 也提供了对应的支持:\nMaskManager#execWithoutMask(推荐):该方法使用了模版方法设计模式,保障跳过脱敏处理并执行相关逻辑后自动恢复脱敏处理。 MaskManager#skipMask:跳过脱敏处理。 MaskManager#restoreMask:恢复脱敏处理,确保后续的操作继续使用脱敏逻辑。 MaskManager#execWithoutMask方法实现如下:\npublic static \u0026lt;T\u0026gt; T execWithoutMask(Supplier\u0026lt;T\u0026gt; supplier) { try { skipMask(); return supplier.get(); } finally { restoreMask(); } } MaskManager 的skipMask和restoreMask方法一般配套使用,推荐try{...}finally{...}模式。\n总结 # 这篇文章主要介绍了:\n数据脱敏的定义:数据脱敏是指对某些敏感信息通过脱敏规则进行数据的变形,实现敏感隐私数据的可靠保护。 常用的脱敏规则:替换、删除、重排、加噪和加密。 常用的脱敏工具:Hutool、Apache ShardingSphere、FastJSON、Mybatis-Mate 和 MyBatis-Flex。 参考 # Hutool 工具官网: https://hutool.cn/docs/#/ 聊聊如何自定义数据脱敏: https://juejin.cn/post/7046567603971719204 FastJSON 实现数据脱敏: https://juejin.cn/post/7067916686141161479 "},{"id":660,"href":"/zh/docs/technology/Interview/cs-basics/data-structure/graph/","title":"图","section":"Data Structure","content":"图是一种较为复杂的非线性结构。 为啥说其较为复杂呢?\n根据前面的内容,我们知道:\n线性数据结构的元素满足唯一的线性关系,每个元素(除第一个和最后一个外)只有一个直接前趋和一个直接后继。 树形数据结构的元素之间有着明显的层次关系。 但是,图形结构的元素之间的关系是任意的。\n何为图呢? 简单来说,图就是由顶点的有穷非空集合和顶点之间的边组成的集合。通常表示为:G(V,E),其中,G 表示一个图,V 表示顶点的集合,E 表示边的集合。\n下图所展示的就是图这种数据结构,并且还是一张有向图。\n图在我们日常生活中的例子很多!比如我们在社交软件上好友关系就可以用图来表示。\n图的基本概念 # 顶点 # 图中的数据元素,我们称之为顶点,图至少有一个顶点(非空有穷集合)\n对应到好友关系图,每一个用户就代表一个顶点。\n边 # 顶点之间的关系用边表示。\n对应到好友关系图,两个用户是好友的话,那两者之间就存在一条边。\n度 # 度表示一个顶点包含多少条边,在有向图中,还分为出度和入度,出度表示从该顶点出去的边的条数,入度表示进入该顶点的边的条数。\n对应到好友关系图,度就代表了某个人的好友数量。\n无向图和有向图 # 边表示的是顶点之间的关系,有的关系是双向的,比如同学关系,A 是 B 的同学,那么 B 也肯定是 A 的同学,那么在表示 A 和 B 的关系时,就不用关注方向,用不带箭头的边表示,这样的图就是无向图。\n有的关系是有方向的,比如父子关系,师生关系,微博的关注关系,A 是 B 的爸爸,但 B 肯定不是 A 的爸爸,A 关注 B,B 不一定关注 A。在这种情况下,我们就用带箭头的边表示二者的关系,这样的图就是有向图。\n无权图和带权图 # 对于一个关系,如果我们只关心关系的有无,而不关心关系有多强,那么就可以用无权图表示二者的关系。\n对于一个关系,如果我们既关心关系的有无,也关心关系的强度,比如描述地图上两个城市的关系,需要用到距离,那么就用带权图来表示,带权图中的每一条边一个数值表示权值,代表关系的强度。\n下图就是一个带权有向图。\n图的存储 # 邻接矩阵存储 # 邻接矩阵将图用二维矩阵存储,是一种较为直观的表示方式。\n如果第 i 个顶点和第 j 个顶点之间有关系,且关系权值为 n,则 A[i][j]=n 。\n在无向图中,我们只关心关系的有无,所以当顶点 i 和顶点 j 有关系时,A[i][j]=1,当顶点 i 和顶点 j 没有关系时,A[i][j]=0。如下图所示:\n值得注意的是:无向图的邻接矩阵是一个对称矩阵,因为在无向图中,顶点 i 和顶点 j 有关系,则顶点 j 和顶点 i 必有关系。\n邻接矩阵存储的方式优点是简单直接(直接使用一个二维数组即可),并且,在获取两个定点之间的关系的时候也非常高效(直接获取指定位置的数组元素的值即可)。但是,这种存储方式的缺点也比较明显,那就是比较浪费空间,\n邻接表存储 # 针对上面邻接矩阵比较浪费内存空间的问题,诞生了图的另外一种存储方法—邻接表 。\n邻接链表使用一个链表来存储某个顶点的所有后继相邻顶点。对于图中每个顶点 Vi,把所有邻接于 Vi 的顶点 Vj 链成一个单链表,这个单链表称为顶点 Vi 的 邻接表。如下图所示:\n大家可以数一数邻接表中所存储的元素的个数以及图中边的条数,你会发现:\n在无向图中,邻接表元素个数等于边的条数的两倍,如左图所示的无向图中,边的条数为 7,邻接表存储的元素个数为 14。 在有向图中,邻接表元素个数等于边的条数,如右图所示的有向图中,边的条数为 8,邻接表存储的元素个数为 8。 图的搜索 # 广度优先搜索 # 广度优先搜索就像水面上的波纹一样一层一层向外扩展,如下图所示:\n广度优先搜索的具体实现方式用到了之前所学过的线性数据结构——队列 。具体过程如下图所示:\n第 1 步:\n第 2 步:\n第 3 步:\n第 4 步:\n第 5 步:\n第 6 步:\n深度优先搜索 # 深度优先搜索就是“一条路走到黑”,从源顶点开始,一直走到没有后继节点,才回溯到上一顶点,然后继续“一条路走到黑”,如下图所示:\n和广度优先搜索类似,深度优先搜索的具体实现用到了另一种线性数据结构——栈 。具体过程如下图所示:\n第 1 步:\n第 2 步:\n第 3 步:\n第 4 步:\n第 5 步:\n第 6 步:\n"},{"id":661,"href":"/zh/docs/technology/Interview/cs-basics/network/network-attack-means/","title":"网络攻击常见手段总结","section":"Network","content":" 本文整理完善自 TCP/IP 常见攻击手段 - 暖蓝笔记 - 2021这篇文章。\n这篇文章的内容主要是介绍 TCP/IP 常见攻击手段,尤其是 DDoS 攻击,也会补充一些其他的常见网络攻击手段。\nIP 欺骗 # IP 是什么? # 在网络中,所有的设备都会分配一个地址。这个地址就仿佛小蓝的家地址「多少号多少室」,这个号就是分配给整个子网的,「室」对应的号码即分配给子网中计算机的,这就是网络中的地址。「号」对应的号码为网络号,「室」对应的号码为主机号,这个地址的整体就是 IP 地址。\n通过 IP 地址我们能知道什么? # 通过 IP 地址,我们就可以知道判断访问对象服务器的位置,从而将消息发送到服务器。一般发送者发出的消息首先经过子网的集线器,转发到最近的路由器,然后根据路由位置访问下一个路由器的位置,直到终点\nIP 头部格式 :\nIP 欺骗技术是什么? # 骗呗,拐骗,诱骗!\nIP 欺骗技术就是伪造某台主机的 IP 地址的技术。通过 IP 地址的伪装使得某台主机能够伪装另外的一台主机,而这台主机往往具有某种特权或者被另外的主机所信任。\n假设现在有一个合法用户 (1.1.1.1) 已经同服务器建立正常的连接,攻击者构造攻击的 TCP 数据,伪装自己的 IP 为 1.1.1.1,并向服务器发送一个带有 RST 位的 TCP 数据段。服务器接收到这样的数据后,认为从 1.1.1.1 发送的连接有错误,就会清空缓冲区中建立好的连接。\n这时,如果合法用户 1.1.1.1 再发送合法数据,服务器就已经没有这样的连接了,该用户就必须从新开始建立连接。攻击时,伪造大量的 IP 地址,向目标发送 RST 数据,使服务器不对合法用户服务。虽然 IP 地址欺骗攻击有着相当难度,但我们应该清醒地意识到,这种攻击非常广泛,入侵往往从这种攻击开始。\n如何缓解 IP 欺骗? # 虽然无法预防 IP 欺骗,但可以采取措施来阻止伪造数据包渗透网络。入口过滤 是防范欺骗的一种极为常见的防御措施,如 BCP38(通用最佳实践文档)所示。入口过滤是一种数据包过滤形式,通常在 网络边缘设备上实施,用于检查传入的 IP 数据包并确定其源标头。如果这些数据包的源标头与其来源不匹配或者看上去很可疑,则拒绝这些数据包。一些网络还实施出口过滤,检查退出网络的 IP 数据包,确保这些数据包具有合法源标头,以防止网络内部用户使用 IP 欺骗技术发起出站恶意攻击。\nSYN Flood(洪水) # SYN Flood 是什么? # SYN Flood 是互联网上最原始、最经典的 DDoS(Distributed Denial of Service,分布式拒绝服务)攻击之一,旨在耗尽可用服务器资源,致使服务器无法传输合法流量\nSYN Flood 利用了 TCP 协议的三次握手机制,攻击者通常利用工具或者控制僵尸主机向服务器发送海量的变源 IP 地址或变源端口的 TCP SYN 报文,服务器响应了这些报文后就会生成大量的半连接,当系统资源被耗尽后,服务器将无法提供正常的服务。 增加服务器性能,提供更多的连接能力对于 SYN Flood 的海量报文来说杯水车薪,防御 SYN Flood 的关键在于判断哪些连接请求来自于真实源,屏蔽非真实源的请求以保障正常的业务请求能得到服务。\nTCP SYN Flood 攻击原理是什么? # TCP SYN Flood 攻击利用的是 TCP 的三次握手(SYN -\u0026gt; SYN/ACK -\u0026gt; ACK),假设连接发起方是 A,连接接受方是 B,即 B 在某个端口(Port)上监听 A 发出的连接请求,过程如下图所示,左边是 A,右边是 B。\nA 首先发送 SYN(Synchronization)消息给 B,要求 B 做好接收数据的准备;B 收到后反馈 SYN-ACK(Synchronization-Acknowledgement) 消息给 A,这个消息的目的有两个:\n向 A 确认已做好接收数据的准备, 同时要求 A 也做好接收数据的准备,此时 B 已向 A 确认好接收状态,并等待 A 的确认,连接处于半开状态(Half-Open),顾名思义只开了一半;A 收到后再次发送 ACK (Acknowledgement) 消息给 B,向 B 确认也做好了接收数据的准备,至此三次握手完成,「连接」就建立了, 大家注意到没有,最关键的一点在于双方是否都按对方的要求进入了可以接收消息的状态。而这个状态的确认主要是双方将要使用的消息序号(SequenceNum),TCP 为保证消息按发送顺序抵达接收方的上层应用,需要用消息序号来标记消息的发送先后顺序的。\nTCP是「双工」(Duplex)连接,同时支持双向通信,也就是双方同时可向对方发送消息,其中 SYN 和 SYN-ACK 消息开启了 A→B 的单向通信通道(B 获知了 A 的消息序号);SYN-ACK 和 ACK 消息开启了 B→A 单向通信通道(A 获知了 B 的消息序号)。\n上面讨论的是双方在诚实守信,正常情况下的通信。\n但实际情况是,网络可能不稳定会丢包,使握手消息不能抵达对方,也可能是对方故意不按规矩来,故意延迟或不发送握手确认消息。\n假设 B 通过某 TCP 端口提供服务,B 在收到 A 的 SYN 消息时,积极的反馈了 SYN-ACK 消息,使连接进入半开状态,因为 B 不确定自己发给 A 的 SYN-ACK 消息或 A 反馈的 ACK 消息是否会丢在半路,所以会给每个待完成的半开连接都设一个Timer,如果超过时间还没有收到 A 的 ACK 消息,则重新发送一次 SYN-ACK 消息给 A,直到重试超过一定次数时才会放弃。\nB 为帮助 A 能顺利连接,需要分配内核资源维护半开连接,那么当 B 面临海量的连接 A 时,如上图所示,SYN Flood 攻击就形成了。攻击方 A 可以控制肉鸡向 B 发送大量 SYN 消息但不响应 ACK 消息,或者干脆伪造 SYN 消息中的 Source IP,使 B 反馈的 SYN-ACK 消息石沉大海,导致 B 被大量注定不能完成的半开连接占据,直到资源耗尽,停止响应正常的连接请求。\nSYN Flood 的常见形式有哪些? # 恶意用户可通过三种不同方式发起 SYN Flood 攻击:\n直接攻击: 不伪造 IP 地址的 SYN 洪水攻击称为直接攻击。在此类攻击中,攻击者完全不屏蔽其 IP 地址。由于攻击者使用具有真实 IP 地址的单一源设备发起攻击,因此很容易发现并清理攻击者。为使目标机器呈现半开状态,黑客将阻止个人机器对服务器的 SYN-ACK 数据包做出响应。为此,通常采用以下两种方式实现:部署防火墙规则,阻止除 SYN 数据包以外的各类传出数据包;或者,对传入的所有 SYN-ACK 数据包进行过滤,防止其到达恶意用户机器。实际上,这种方法很少使用(即便使用过也不多见),因为此类攻击相当容易缓解 – 只需阻止每个恶意系统的 IP 地址。哪怕攻击者使用僵尸网络(如 Mirai 僵尸网络),通常也不会刻意屏蔽受感染设备的 IP。 欺骗攻击: 恶意用户还可以伪造其发送的各个 SYN 数据包的 IP 地址,以便阻止缓解措施并加大身份暴露难度。虽然数据包可能经过伪装,但还是可以通过这些数据包追根溯源。此类检测工作很难开展,但并非不可实现;特别是,如果 Internet 服务提供商 (ISP) 愿意提供帮助,则更容易实现。 分布式攻击(DDoS): 如果使用僵尸网络发起攻击,则追溯攻击源头的可能性很低。随着混淆级别的攀升,攻击者可能还会命令每台分布式设备伪造其发送数据包的 IP 地址。哪怕攻击者使用僵尸网络(如 Mirai 僵尸网络),通常也不会刻意屏蔽受感染设备的 IP。 如何缓解 SYN Flood? # 扩展积压工作队列 # 目标设备安装的每个操作系统都允许具有一定数量的半开连接。若要响应大量 SYN 数据包,一种方法是增加操作系统允许的最大半开连接数目。为成功扩展最大积压工作,系统必须额外预留内存资源以处理各类新请求。如果系统没有足够的内存,无法应对增加的积压工作队列规模,将对系统性能产生负面影响,但仍然好过拒绝服务。\n回收最先创建的 TCP 半开连接 # 另一种缓解策略是在填充积压工作后覆盖最先创建的半开连接。这项策略要求完全建立合法连接的时间低于恶意 SYN 数据包填充积压工作的时间。当攻击量增加或积压工作规模小于实际需求时,这项特定的防御措施将不奏效。\nSYN Cookie # 此策略要求服务器创建 Cookie。为避免在填充积压工作时断开连接,服务器使用 SYN-ACK 数据包响应每一项连接请求,而后从积压工作中删除 SYN 请求,同时从内存中删除请求,保证端口保持打开状态并做好重新建立连接的准备。如果连接是合法请求并且已将最后一个 ACK 数据包从客户端机器发回服务器,服务器将重建(存在一些限制)SYN 积压工作队列条目。虽然这项缓解措施势必会丢失一些 TCP 连接信息,但好过因此导致对合法用户发起拒绝服务攻击。\nUDP Flood(洪水) # UDP Flood 是什么? # UDP Flood 也是一种拒绝服务攻击,将大量的用户数据报协议(UDP)数据包发送到目标服务器,目的是压倒该设备的处理和响应能力。防火墙保护目标服务器也可能因 UDP 泛滥而耗尽,从而导致对合法流量的拒绝服务。\nUDP Flood 攻击原理是什么? # UDP Flood 主要通过利用服务器响应发送到其中一个端口的 UDP 数据包所采取的步骤。在正常情况下,当服务器在特定端口接收到 UDP 数据包时,会经过两个步骤:\n服务器首先检查是否正在运行正在侦听指定端口的请求的程序。 如果没有程序在该端口接收数据包,则服务器使用 ICMP(ping)数据包进行响应,以通知发送方目的地不可达。 举个例子。假设今天要联系酒店的小蓝,酒店客服接到电话后先查看房间的列表来确保小蓝在客房内,随后转接给小蓝。\n首先,接待员接收到呼叫者要求连接到特定房间的电话。接待员然后需要查看所有房间的清单,以确保客人在房间中可用,并愿意接听电话。碰巧的是,此时如果突然间所有的电话线同时亮起来,那么他们就会很快就变得不堪重负了。\n当服务器接收到每个新的 UDP 数据包时,它将通过步骤来处理请求,并利用该过程中的服务器资源。发送 UDP 报文时,每个报文将包含源设备的 IP 地址。在这种类型的 DDoS 攻击期间,攻击者通常不会使用自己的真实 IP 地址,而是会欺骗 UDP 数据包的源 IP 地址,从而阻止攻击者的真实位置被暴露并潜在地饱和来自目标的响应数据包服务器。\n由于目标服务器利用资源检查并响应每个接收到的 UDP 数据包的结果,当接收到大量 UDP 数据包时,目标的资源可能会迅速耗尽,导致对正常流量的拒绝服务。\n如何缓解 UDP Flooding? # 大多数操作系统部分限制了 ICMP 报文的响应速率,以中断需要 ICMP 响应的 DDoS 攻击。这种缓解的一个缺点是在攻击过程中,合法的数据包也可能被过滤。如果 UDP Flood 的容量足够高以使目标服务器的防火墙的状态表饱和,则在服务器级别发生的任何缓解都将不足以应对目标设备上游的瓶颈。\nHTTP Flood(洪水) # HTTP Flood 是什么? # HTTP Flood 是一种大规模的 DDoS(Distributed Denial of Service,分布式拒绝服务)攻击,旨在利用 HTTP 请求使目标服务器不堪重负。目标因请求而达到饱和,且无法响应正常流量后,将出现拒绝服务,拒绝来自实际用户的其他请求。\nHTTP Flood 的攻击原理是什么? # HTTP 洪水攻击是“第 7 层”DDoS 攻击的一种。第 7 层是 OSI 模型的应用程序层,指的是 HTTP 等互联网协议。HTTP 是基于浏览器的互联网请求的基础,通常用于加载网页或通过互联网发送表单内容。缓解应用程序层攻击特别复杂,因为恶意流量和正常流量很难区分。\n为了获得最大效率,恶意行为者通常会利用或创建僵尸网络,以最大程度地扩大攻击的影响。通过利用感染了恶意软件的多台设备,攻击者可以发起大量攻击流量来进行攻击。\nHTTP 洪水攻击有两种:\nHTTP GET 攻击:在这种攻击形式下,多台计算机或其他设备相互协调,向目标服务器发送对图像、文件或其他资产的多个请求。当目标被传入的请求和响应所淹没时,来自正常流量源的其他请求将被拒绝服务。 HTTP POST 攻击:一般而言,在网站上提交表单时,服务器必须处理传入的请求并将数据推送到持久层(通常是数据库)。与发送 POST 请求所需的处理能力和带宽相比,处理表单数据和运行必要数据库命令的过程相对密集。这种攻击利用相对资源消耗的差异,直接向目标服务器发送许多 POST 请求,直到目标服务器的容量饱和并拒绝服务为止。 如何防护 HTTP Flood? # 如前所述,缓解第 7 层攻击非常复杂,而且通常要从多方面进行。一种方法是对发出请求的设备实施质询,以测试它是否是机器人,这与在线创建帐户时常用的 CAPTCHA 测试非常相似。通过提出 JavaScript 计算挑战之类的要求,可以缓解许多攻击。\n其他阻止 HTTP 洪水攻击的途径包括使用 Web 应用程序防火墙 (WAF)、管理 IP 信誉数据库以跟踪和有选择地阻止恶意流量,以及由工程师进行动态分析。Cloudflare 具有超过 2000 万个互联网设备的规模优势,能够分析来自各种来源的流量并通过快速更新的 WAF 规则和其他防护策略来缓解潜在的攻击,从而消除应用程序层 DDoS 流量。\nDNS Flood(洪水) # DNS Flood 是什么? # 域名系统(DNS)服务器是互联网的“电话簿“;互联网设备通过这些服务器来查找特定 Web 服务器以便访问互联网内容。DNS Flood 攻击是一种分布式拒绝服务(DDoS)攻击,攻击者用大量流量淹没某个域的 DNS 服务器,以尝试中断该域的 DNS 解析。如果用户无法找到电话簿,就无法查找到用于调用特定资源的地址。通过中断 DNS 解析,DNS Flood 攻击将破坏网站、API 或 Web 应用程序响应合法流量的能力。很难将 DNS Flood 攻击与正常的大流量区分开来,因为这些大规模流量往往来自多个唯一地址,查询该域的真实记录,模仿合法流量。\nDNS Flood 的攻击原理是什么? # 域名系统的功能是将易于记忆的名称(例如 example.com)转换成难以记住的网站服务器地址(例如 192.168.0.1),因此成功攻击 DNS 基础设施将导致大多数人无法使用互联网。DNS Flood 攻击是一种相对较新的基于 DNS 的攻击,这种攻击是在高带宽 物联网(IoT) 僵尸网络(如 Mirai)兴起后激增的。DNS Flood 攻击使用 IP 摄像头、DVR 盒和其他 IoT 设备的高带宽连接直接淹没主要提供商的 DNS 服务器。来自 IoT 设备的大量请求淹没 DNS 提供商的服务,阻止合法用户访问提供商的 DNS 服务器。\nDNS Flood 攻击不同于 DNS 放大攻击。与 DNS Flood 攻击不同,DNS 放大攻击反射并放大不安全 DNS 服务器的流量,以便隐藏攻击的源头并提高攻击的有效性。DNS 放大攻击使用连接带宽较小的设备向不安全的 DNS 服务器发送无数请求。这些设备对非常大的 DNS 记录发出小型请求,但在发出请求时,攻击者伪造返回地址为目标受害者。这种放大效果让攻击者能借助有限的攻击资源来破坏较大的目标。\n如何防护 DNS Flood? # DNS Flood 对传统上基于放大的攻击方法做出了改变。借助轻易获得的高带宽僵尸网络,攻击者现能针对大型组织发动攻击。除非被破坏的 IoT 设备得以更新或替换,否则抵御这些攻击的唯一方法是使用一个超大型、高度分布式的 DNS 系统,以便实时监测、吸收和阻止攻击流量。\nTCP 重置攻击 # 在 TCP 重置攻击中,攻击者通过向通信的一方或双方发送伪造的消息,告诉它们立即断开连接,从而使通信双方连接中断。正常情况下,如果客户端收发现到达的报文段对于相关连接而言是不正确的,TCP 就会发送一个重置报文段,从而导致 TCP 连接的快速拆卸。\nTCP 重置攻击利用这一机制,通过向通信方发送伪造的重置报文段,欺骗通信双方提前关闭 TCP 连接。如果伪造的重置报文段完全逼真,接收者就会认为它有效,并关闭 TCP 连接,防止连接被用来进一步交换信息。服务端可以创建一个新的 TCP 连接来恢复通信,但仍然可能会被攻击者重置连接。万幸的是,攻击者需要一定的时间来组装和发送伪造的报文,所以一般情况下这种攻击只对长连接有杀伤力,对于短连接而言,你还没攻击呢,人家已经完成了信息交换。\n从某种意义上来说,伪造 TCP 报文段是很容易的,因为 TCP/IP 都没有任何内置的方法来验证服务端的身份。有些特殊的 IP 扩展协议(例如 IPSec)确实可以验证身份,但并没有被广泛使用。客户端只能接收报文段,并在可能的情况下使用更高级别的协议(如 TLS)来验证服务端的身份。但这个方法对 TCP 重置包并不适用,因为 TCP 重置包是 TCP 协议本身的一部分,无法使用更高级别的协议进行验证。\n模拟攻击 # 以下实验是在 OSX 系统中完成的,其他系统请自行测试。\n现在来总结一下伪造一个 TCP 重置报文要做哪些事情:\n嗅探通信双方的交换信息。 截获一个 ACK 标志位置位 1 的报文段,并读取其 ACK 号。 伪造一个 TCP 重置报文段(RST 标志位置为 1),其序列号等于上面截获的报文的 ACK 号。这只是理想情况下的方案,假设信息交换的速度不是很快。大多数情况下为了增加成功率,可以连续发送序列号不同的重置报文。 将伪造的重置报文发送给通信的一方或双方,时其中断连接。 为了实验简单,我们可以使用本地计算机通过 localhost 与自己通信,然后对自己进行 TCP 重置攻击。需要以下几个步骤:\n在两个终端之间建立一个 TCP 连接。 编写一个能嗅探通信双方数据的攻击程序。 修改攻击程序,伪造并发送重置报文。 下面正式开始实验。\n建立 TCP 连接\n可以使用 netcat 工具来建立 TCP 连接,这个工具很多操作系统都预装了。打开第一个终端窗口,运行以下命令:\nnc -nvl 8000 这个命令会启动一个 TCP 服务,监听端口为 8000。接着再打开第二个终端窗口,运行以下命令:\nnc 127.0.0.1 8000 该命令会尝试与上面的服务建立连接,在其中一个窗口输入一些字符,就会通过 TCP 连接发送给另一个窗口并打印出来。\n嗅探流量\n编写一个攻击程序,使用 Python 网络库 scapy 来读取两个终端窗口之间交换的数据,并将其打印到终端上。代码比较长,下面为一部份,完整代码后台回复 TCP 攻击,代码的核心是调用 scapy 的嗅探方法:\n这段代码告诉 scapy 在 lo0 网络接口上嗅探数据包,并记录所有 TCP 连接的详细信息。\niface : 告诉 scapy 在 lo0(localhost)网络接口上进行监听。 lfilter : 这是个过滤器,告诉 scapy 忽略所有不属于指定的 TCP 连接(通信双方皆为 localhost,且端口号为 8000)的数据包。 prn : scapy 通过这个函数来操作所有符合 lfilter 规则的数据包。上面的例子只是将数据包打印到终端,下文将会修改函数来伪造重置报文。 count : scapy 函数返回之前需要嗅探的数据包数量。 发送伪造的重置报文\n下面开始修改程序,发送伪造的 TCP 重置报文来进行 TCP 重置攻击。根据上面的解读,只需要修改 prn 函数就行了,让其检查数据包,提取必要参数,并利用这些参数来伪造 TCP 重置报文并发送。\n例如,假设该程序截获了一个从(src_ip, src_port)发往 (dst_ip, dst_port)的报文段,该报文段的 ACK 标志位已置为 1,ACK 号为 100,000。攻击程序接下来要做的是:\n由于伪造的数据包是对截获的数据包的响应,所以伪造数据包的源 IP/Port 应该是截获数据包的目的 IP/Port,反之亦然。 将伪造数据包的 RST 标志位置为 1,以表示这是一个重置报文。 将伪造数据包的序列号设置为截获数据包的 ACK 号,因为这是发送方期望收到的下一个序列号。 调用 scapy 的 send 方法,将伪造的数据包发送给截获数据包的发送方。 对于我的程序而言,只需将这一行取消注释,并注释这一行的上面一行,就可以全面攻击了。按照步骤 1 的方法设置 TCP 连接,打开第三个窗口运行攻击程序,然后在 TCP 连接的其中一个终端输入一些字符串,你会发现 TCP 连接被中断了!\n进一步实验\n可以继续使用攻击程序进行实验,将伪造数据包的序列号加减 1 看看会发生什么,是不是确实需要和截获数据包的 ACK 号完全相同。 打开 Wireshark,监听 lo0 网络接口,并使用过滤器 ip.src == 127.0.0.1 \u0026amp;\u0026amp; ip.dst == 127.0.0.1 \u0026amp;\u0026amp; tcp.port == 8000 来过滤无关数据。你可以看到 TCP 连接的所有细节。 在连接上更快速地发送数据流,使攻击更难执行。 中间人攻击 # 猪八戒要向小蓝表白,于是写了一封信给小蓝,结果第三者小黑拦截到了这封信,把这封信进行了篡改,于是乎在他们之间进行搞破坏行动。这个马文才就是中间人,实施的就是中间人攻击。好我们继续聊聊什么是中间人攻击。\n什么是中间人? # 攻击中间人攻击英文名叫 Man-in-the-MiddleAttack,简称「MITM 攻击」。指攻击者与通讯的两端分别创建独立的联系,并交换其所收到的数据,使通讯的两端认为他们正在通过一个私密的连接与对方 直接对话,但事实上整个会话都被攻击者完全控制。我们画一张图:\n从这张图可以看到,中间人其实就是攻击者。通过这种原理,有很多实现的用途,比如说,你在手机上浏览不健康网站的时候,手机就会提示你,此网站可能含有病毒,是否继续访问还是做其他的操作等等。\n中间人攻击的原理是什么? # 举个例子,我和公司签了一个一份劳动合同,一人一份合同。不晓得哪个可能改了合同内容,不知道真假了,怎么搞?只好找专业的机构来鉴定,自然就要花钱。\n在安全领域有句话:我们没有办法杜绝网络犯罪,只好想办法提高网络犯罪的成本。既然没法杜绝这种情况,那我们就想办法提高作案的成本,今天我们就简单了解下基本的网络安全知识,也是面试中的高频面试题了。\n为了避免双方说活不算数的情况,双方引入第三家机构,将合同原文给可信任的第三方机构,只要这个机构不监守自盗,合同就相对安全。\n如果第三方机构内部不严格或容易出现纰漏?\n虽然我们将合同原文给第三方机构了,为了防止内部人员的更改,需要采取什么措施呢\n一种可行的办法是引入 摘要算法 。即合同和摘要一起,为了简单的理解摘要。大家可以想象这个摘要为一个函数,这个函数对原文进行了加密,会产生一个唯一的散列值,一旦原文发生一点点变化,那么这个散列值将会变化。\n有哪些常用的摘要算法呢? # 目前比较常用的加密算法有消息摘要算法和安全散列算法(SHA)。MD5 是将任意长度的文章转化为一个 128 位的散列值,可是在 2004 年,MD5 被证实了容易发生碰撞,即两篇原文产生相同的摘要。这样的话相当于直接给黑客一个后门,轻松伪造摘要。\n所以在大部分的情况下都会选择 SHA 算法 。\n出现内鬼了怎么办?\n看似很安全的场面了,理论上来说杜绝了篡改合同的做法。主要某个员工同时具有修改合同和摘要的权利,那搞事儿就是时间的问题了,毕竟没哪个系统可以完全的杜绝员工接触敏感信息,除非敏感信息都不存在。所以能不能考虑将合同和摘要分开存储呢\n那如何确保员工不会修改合同呢?\n这确实蛮难的,不过办法总比困难多。我们将合同放在双方手中,摘要放在第三方机构,篡改难度进一步加大\n那么员工万一和某个用户串通好了呢?\n看来放在第三方的机构还是不好使,同样存在不小风险。所以还需要寻找新的方案,这就出现了 数字签名和证书。\n数字证书和签名有什么用? # 同样的,举个例子。Sum 和 Mike 两个人签合同。Sum 首先用 SHA 算法计算合同的摘要,然后用自己私钥将摘要加密,得到数字签名。Sum 将合同原文、签名,以及公钥三者都交给 Mike\n如果 Sum 想要证明合同是 Mike 的,那么就要使用 Mike 的公钥,将这个签名解密得到摘要 x,然后 Mike 计算原文的 sha 摘要 Y,随后对比 x 和 y,如果两者相等,就认为数据没有被篡改\n在这样的过程中,Mike 是不能更改 Sum 的合同,因为要修改合同不仅仅要修改原文还要修改摘要,修改摘要需要提供 Mike 的私钥,私钥即 Sum 独有的密码,公钥即 Sum 公布给他人使用的密码\n总之,公钥加密的数据只能私钥可以解密。私钥加密的数据只有公钥可以解密,这就是 非对称加密 。\n隐私保护?不是吓唬大家,信息是透明的兄 die,不过尽量去维护个人的隐私吧,今天学习对称加密和非对称加密。\n大家先读读这个字\u0026quot;钥\u0026quot;,是读\u0026quot;yao\u0026quot;,我以前也是,其实读\u0026quot;yue\u0026quot;\n什么是对称加密? # 对称加密,顾名思义,加密方与解密方使用同一钥匙(秘钥)。具体一些就是,发送方通过使用相应的加密算法和秘钥,对将要发送的信息进行加密;对于接收方而言,使用解密算法和相同的秘钥解锁信息,从而有能力阅读信息。\n常见的对称加密算法有哪些? # DES\nDES 使用的密钥表面上是 64 位的,然而只有其中的 56 位被实际用于算法,其余 8 位可以被用于奇偶校验,并在算法中被丢弃。因此,DES 的有效密钥长度为 56 位,通常称 DES 的密钥长度为 56 位。假设秘钥为 56 位,采用暴力破 Jie 的方式,其秘钥个数为 2 的 56 次方,那么每纳秒执行一次解密所需要的时间差不多 1 年的样子。当然,没人这么干。DES 现在已经不是一种安全的加密方法,主要因为它使用的 56 位密钥过短。\nIDEA\n国际数据加密算法(International Data Encryption Algorithm)。秘钥长度 128 位,优点没有专利的限制。\nAES\n当 DES 被破解以后,没过多久推出了 AES 算法,提供了三种长度供选择,128 位、192 位和 256,为了保证性能不受太大的影响,选择 128 即可。\nSM1 和 SM4\n之前几种都是国外的,我们国内自行研究了国密 SM1和 SM4。其中 S 都属于国家标准,算法公开。优点就是国家的大力支持和认可\n总结:\n常见的非对称加密算法有哪些? # 在对称加密中,发送方与接收方使用相同的秘钥。那么在非对称加密中则是发送方与接收方使用的不同的秘钥。其主要解决的问题是防止在秘钥协商的过程中发生泄漏。比如在对称加密中,小蓝将需要发送的消息加密,然后告诉你密码是 123balala,ok,对于其他人而言,很容易就能劫持到密码是 123balala。那么在非对称的情况下,小蓝告诉所有人密码是 123balala,对于中间人而言,拿到也没用,因为没有私钥。所以,非对称密钥其实主要解决了密钥分发的难题。如下图\n其实我们经常都在使用非对称加密,比如使用多台服务器搭建大数据平台 hadoop,为了方便多台机器设置免密登录,是不是就会涉及到秘钥分发。再比如搭建 docker 集群也会使用相关非对称加密算法。\n常见的非对称加密算法:\nRSA(RSA 加密算法,RSA Algorithm):优势是性能比较快,如果想要较高的加密难度,需要很长的秘钥。\nECC:基于椭圆曲线提出。是目前加密强度最高的非对称加密算法\nSM2:同样基于椭圆曲线问题设计。最大优势就是国家认可和大力支持。\n总结:\n常见的散列算法有哪些? # 这个大家应该更加熟悉了,比如我们平常使用的 MD5 校验,在很多时候,我并不是拿来进行加密,而是用来获得唯一性 ID。在做系统的过程中,存储用户的各种密码信息,通常都会通过散列算法,最终存储其散列值。\nMD5(不推荐)\nMD5 可以用来生成一个 128 位的消息摘要,它是目前应用比较普遍的散列算法,具体的应用场景你可以自行  参阅。虽然,因为算法的缺陷,它的唯一性已经被破解了,但是大部分场景下,这并不会构成安全问题。但是,如果不是长度受限(32 个字符),我还是不推荐你继续使用 MD5 的。\nSHA\n安全散列算法。SHA 分为 SHA1 和 SH2 两个版本。该算法的思想是接收一段明文,然后以一种不可逆的方式将它转换成一段(通常更小)密文,也可以简单的理解为取一串输入码(称为预映射或信息),并把它们转化为长度较短、位数固定的输出序列即散列值(也称为信息摘要或信息认证代码)的过程。\nSM3\n国密算法SM3。加密强度和 SHA-256 算法 相差不多。主要是受到了国家的支持。\n总结:\n大部分情况下使用对称加密,具有比较不错的安全性。如果需要分布式进行秘钥分发,考虑非对称。如果不需要可逆计算则散列算法。 因为这段时间有这方面需求,就看了一些这方面的资料,入坑信息安全,就怕以后洗发水都不用买。谢谢大家查看!\n第三方机构和证书机制有什么用? # 问题还有,此时如果 Sum 否认给过 Mike 的公钥和合同,不久 gg 了\n所以需要 Sum 过的话做过的事儿需要足够的信誉,这就引入了 第三方机构和证书机制 。\n证书之所以会有信用,是因为证书的签发方拥有信用。所以如果 Sum 想让 Mike 承认自己的公钥,Sum 不会直接将公钥给 Mike ,而是提供由第三方机构,含有公钥的证书。如果 Mike 也信任这个机构,法律都认可,那 ik,信任关系成立\n如上图所示,Sum 将自己的申请提交给机构,产生证书的原文。机构用自己的私钥签名 Sum 的申请原文(先根据原文内容计算摘要,再用私钥加密),得到带有签名信息的证书。Mike 拿到带签名信息的证书,通过第三方机构的公钥进行解密,获得 Sum 证书的摘要、证书的原文。有了 Sum 证书的摘要和原文,Mike 就可以进行验签。验签通过,Mike 就可以确认 Sum 的证书的确是第三方机构签发的。\n用上面这样一个机制,合同的双方都无法否认合同。这个解决方案的核心在于需要第三方信用服务机构提供信用背书。这里产生了一个最基础的信任链,如果第三方机构的信任崩溃,比如被黑客攻破,那整条信任链条也就断裂了\n为了让这个信任条更加稳固,就需要环环相扣,打造更长的信任链,避免单点信任风险\n上图中,由信誉最好的根证书机构提供根证书,然后根证书机构去签发二级机构的证书;二级机构去签发三级机构的证书;最后有由三级机构去签发 Sum 证书。\n如果要验证 Sum 证书的合法性,就需要用三级机构证书中的公钥去解密 Sum 证书的数字签名。\n如果要验证三级机构证书的合法性,就需要用二级机构的证书去解密三级机构证书的数字签名。\n如果要验证二级结构证书的合法性,就需要用根证书去解密。\n以上,就构成了一个相对长一些的信任链。如果其中一方想要作弊是非常困难的,除非链条中的所有机构同时联合起来,进行欺诈。\n中间人攻击如何避免? # 既然知道了中间人攻击的原理也知道了他的危险,现在我们看看如何避免。相信我们都遇到过下面这种状况:\n出现这个界面的很多情况下,都是遇到了中间人攻击的现象,需要对安全证书进行及时地监测。而且大名鼎鼎的 github 网站,也曾遭遇过中间人攻击:\n想要避免中间人攻击的方法目前主要有两个:\n客户端不要轻易相信证书:因为这些证书极有可能是中间人。 App 可以提前预埋证书在本地:意思是我们本地提前有一些证书,这样其他证书就不能再起作用了。 DDOS # 通过上面的描述,总之即好多种攻击都是 DDOS 攻击,所以简单总结下这个攻击相关内容。\n其实,像全球互联网各大公司,均遭受过大量的 DDoS。\n2018 年,GitHub 在一瞬间遭到高达 1.35Tbps 的带宽攻击。这次 DDoS 攻击几乎可以堪称是互联网有史以来规模最大、威力最大的 DDoS 攻击了。在 GitHub 遭到攻击后,仅仅一周后,DDoS 攻击又开始对 Google、亚马逊甚至 Pornhub 等网站进行了 DDoS 攻击。后续的 DDoS 攻击带宽最高也达到了 1Tbps。\nDDoS 攻击究竟是什么? # DDos 全名 Distributed Denial of Service,翻译成中文就是分布式拒绝服务。指的是处于不同位置的多个攻击者同时向一个或数个目标发动攻击,是一种分布的、协同的大规模攻击方式。单一的 DoS 攻击一般是采用一对一方式的,它利用网络协议和操作系统的一些缺陷,采用欺骗和伪装的策略来进行网络攻击,使网站服务器充斥大量要求回复的信息,消耗网络带宽或系统资源,导致网络或系统不胜负荷以至于瘫痪而停止提供正常的网络服务。\n举个例子\n我开了一家有五十个座位的重庆火锅店,由于用料上等,童叟无欺。平时门庭若市,生意特别红火,而对面二狗家的火锅店却无人问津。二狗为了对付我,想了一个办法,叫了五十个人来我的火锅店坐着却不点菜,让别的客人无法吃饭。\n上面这个例子讲的就是典型的 DDoS 攻击,一般来说是指攻击者利用“肉鸡”对目标网站在较短的时间内发起大量请求,大规模消耗目标网站的主机资源,让它无法正常服务。在线游戏、互联网金融等领域是 DDoS 攻击的高发行业。\n攻击方式很多,比如 ICMP Flood、UDP Flood、NTP Flood、SYN Flood、CC 攻击、DNS Query Flood等等。\n如何应对 DDoS 攻击? # 高防服务器 # 还是拿开的重庆火锅店举例,高防服务器就是我给重庆火锅店增加了两名保安,这两名保安可以让保护店铺不受流氓骚扰,并且还会定期在店铺周围巡逻防止流氓骚扰。\n高防服务器主要是指能独立硬防御 50Gbps 以上的服务器,能够帮助网站拒绝服务攻击,定期扫描网络主节点等,这东西是不错,就是贵~\n黑名单 # 面对火锅店里面的流氓,我一怒之下将他们拍照入档,并禁止他们踏入店铺,但是有的时候遇到长得像的人也会禁止他进入店铺。这个就是设置黑名单,此方法秉承的就是“错杀一千,也不放一百”的原则,会封锁正常流量,影响到正常业务。\nDDoS 清洗 # DDos 清洗,就是我发现客人进店几分钟以后,但是一直不点餐,我就把他踢出店里。\nDDoS 清洗会对用户请求数据进行实时监控,及时发现 DOS 攻击等异常流量,在不影响正常业务开展的情况下清洗掉这些异常流量。\nCDN 加速 # CDN 加速,我们可以这么理解:为了减少流氓骚扰,我干脆将火锅店开到了线上,承接外卖服务,这样流氓找不到店在哪里,也耍不来流氓了。\n在现实中,CDN 服务将网站访问流量分配到了各个节点中,这样一方面隐藏网站的真实 IP,另一方面即使遭遇 DDoS 攻击,也可以将流量分散到各个节点中,防止源站崩溃。\n参考 # HTTP 洪水攻击 - CloudFlare: https://www.cloudflare.com/zh-cn/learning/ddos/http-flood-ddos-attack/ SYN 洪水攻击: https://www.cloudflare.com/zh-cn/learning/ddos/syn-flood-ddos-attack/ 什么是 IP 欺骗?: https://www.cloudflare.com/zh-cn/learning/ddos/glossary/ip-spoofing/ 什么是 DNS 洪水?| DNS 洪水 DDoS 攻击: https://www.cloudflare.com/zh-cn/learning/ddos/dns-flood-ddos-attack/ "},{"id":662,"href":"/zh/docs/technology/Interview/system-design/system-design-questions/","title":"系统设计常见面试题总结(付费)","section":"System Design","content":"系统设计 相关的面试题为我的 知识星球(点击链接即可查看详细介绍以及加入方法)专属内容,已经整理到了 《Java 面试指北》中。\n"},{"id":663,"href":"/zh/docs/technology/Interview/cs-basics/data-structure/linear-data-structure/","title":"线性数据结构","section":"Data Structure","content":" 1. 数组 # 数组(Array) 是一种很常见的数据结构。它由相同类型的元素(element)组成,并且是使用一块连续的内存来存储。\n我们直接可以利用元素的索引(index)可以计算出该元素对应的存储地址。\n数组的特点是:提供随机访问 并且容量有限。\n假如数组的长度为 n。 访问:O(1)//访问特定位置的元素 插入:O(n )//最坏的情况发生在插入发生在数组的首部并需要移动所有元素时 删除:O(n)//最坏的情况发生在删除数组的开头发生并需要移动第一元素后面所有的元素时 2. 链表 # 2.1. 链表简介 # 链表(LinkedList) 虽然是一种线性表,但是并不会按线性的顺序存储数据,使用的不是连续的内存空间来存储数据。\n链表的插入和删除操作的复杂度为 O(1) ,只需要知道目标位置元素的上一个元素即可。但是,在查找一个节点或者访问特定位置的节点的时候复杂度为 O(n) 。\n使用链表结构可以克服数组需要预先知道数据大小的缺点,链表结构可以充分利用计算机内存空间,实现灵活的内存动态管理。但链表不会节省空间,相比于数组会占用更多的空间,因为链表中每个节点存放的还有指向其他节点的指针。除此之外,链表不具有数组随机读取的优点。\n2.2. 链表分类 # 常见链表分类:\n单链表 双向链表 循环链表 双向循环链表 假如链表中有n个元素。 访问:O(n)//访问特定位置的元素 插入删除:O(1)//必须要要知道插入元素的位置 2.2.1. 单链表 # 单链表 单向链表只有一个方向,结点只有一个后继指针 next 指向后面的节点。因此,链表这种数据结构通常在物理内存上是不连续的。我们习惯性地把第一个结点叫作头结点,链表通常有一个不保存任何值的 head 节点(头结点),通过头结点我们可以遍历整个链表。尾结点通常指向 null。\n2.2.2. 循环链表 # 循环链表 其实是一种特殊的单链表,和单链表不同的是循环链表的尾结点不是指向 null,而是指向链表的头结点。\n2.2.3. 双向链表 # 双向链表 包含两个指针,一个 prev 指向前一个节点,一个 next 指向后一个节点。\n2.2.4. 双向循环链表 # 双向循环链表 最后一个节点的 next 指向 head,而 head 的 prev 指向最后一个节点,构成一个环。\n2.3. 应用场景 # 如果需要支持随机访问的话,链表没办法做到。 如果需要存储的数据元素的个数不确定,并且需要经常添加和删除数据的话,使用链表比较合适。 如果需要存储的数据元素的个数确定,并且不需要经常添加和删除数据的话,使用数组比较合适。 2.4. 数组 vs 链表 # 数组支持随机访问,而链表不支持。 数组使用的是连续内存空间对 CPU 的缓存机制友好,链表则相反。 数组的大小固定,而链表则天然支持动态扩容。如果声明的数组过小,需要另外申请一个更大的内存空间存放数组元素,然后将原数组拷贝进去,这个操作是比较耗时的! 3. 栈 # 3.1. 栈简介 # 栈 (Stack) 只允许在有序的线性数据集合的一端(称为栈顶 top)进行加入数据(push)和移除数据(pop)。因而按照 后进先出(LIFO, Last In First Out) 的原理运作。在栈中,push 和 pop 的操作都发生在栈顶。\n栈常用一维数组或链表来实现,用数组实现的栈叫作 顺序栈 ,用链表实现的栈叫作 链式栈 。\n假设堆栈中有n个元素。 访问:O(n)//最坏情况 插入删除:O(1)//顶端插入和删除元素 3.2. 栈的常见应用场景 # 当我们我们要处理的数据只涉及在一端插入和删除数据,并且满足 后进先出(LIFO, Last In First Out) 的特性时,我们就可以使用栈这个数据结构。\n3.2.1. 实现浏览器的回退和前进功能 # 我们只需要使用两个栈(Stack1 和 Stack2)和就能实现这个功能。比如你按顺序查看了 1,2,3,4 这四个页面,我们依次把 1,2,3,4 这四个页面压入 Stack1 中。当你想回头看 2 这个页面的时候,你点击回退按钮,我们依次把 4,3 这两个页面从 Stack1 弹出,然后压入 Stack2 中。假如你又想回到页面 3,你点击前进按钮,我们将 3 页面从 Stack2 弹出,然后压入到 Stack1 中。示例图如下:\n3.2.2. 检查符号是否成对出现 # 给定一个只包括 '(',')','{','}','[',']' 的字符串,判断该字符串是否有效。\n有效字符串需满足:\n左括号必须用相同类型的右括号闭合。 左括号必须以正确的顺序闭合。 比如 \u0026ldquo;()\u0026quot;、\u0026rdquo;()[]{}\u0026quot;、\u0026quot;{[]}\u0026quot; 都是有效字符串,而 \u0026ldquo;(]\u0026quot;、\u0026rdquo;([)]\u0026quot; 则不是。\n这个问题实际是 Leetcode 的一道题目,我们可以利用栈 Stack 来解决这个问题。\n首先我们将括号间的对应规则存放在 Map 中,这一点应该毋容置疑; 创建一个栈。遍历字符串,如果字符是左括号就直接加入stack中,否则将stack 的栈顶元素与这个括号做比较,如果不相等就直接返回 false。遍历结束,如果stack为空,返回 true。 public boolean isValid(String s){ // 括号之间的对应规则 HashMap\u0026lt;Character, Character\u0026gt; mappings = new HashMap\u0026lt;Character, Character\u0026gt;(); mappings.put(\u0026#39;)\u0026#39;, \u0026#39;(\u0026#39;); mappings.put(\u0026#39;}\u0026#39;, \u0026#39;{\u0026#39;); mappings.put(\u0026#39;]\u0026#39;, \u0026#39;[\u0026#39;); Stack\u0026lt;Character\u0026gt; stack = new Stack\u0026lt;Character\u0026gt;(); char[] chars = s.toCharArray(); for (int i = 0; i \u0026lt; chars.length; i++) { if (mappings.containsKey(chars[i])) { char topElement = stack.empty() ? \u0026#39;#\u0026#39; : stack.pop(); if (topElement != mappings.get(chars[i])) { return false; } } else { stack.push(chars[i]); } } return stack.isEmpty(); } 3.2.3. 反转字符串 # 将字符串中的每个字符先入栈再出栈就可以了。\n3.2.4. 维护函数调用 # 最后一个被调用的函数必须先完成执行,符合栈的 后进先出(LIFO, Last In First Out) 特性。\n例如递归函数调用可以通过栈来实现,每次递归调用都会将参数和返回地址压栈。\n3.2.5 深度优先遍历(DFS) # 在深度优先搜索过程中,栈被用来保存搜索路径,以便回溯到上一层。\n3.3. 栈的实现 # 栈既可以通过数组实现,也可以通过链表来实现。不管基于数组还是链表,入栈、出栈的时间复杂度都为 O(1)。\n下面我们使用数组来实现一个栈,并且这个栈具有push()、pop()(返回栈顶元素并出栈)、peek() (返回栈顶元素不出栈)、isEmpty()、size()这些基本的方法。\n提示:每次入栈之前先判断栈的容量是否够用,如果不够用就用Arrays.copyOf()进行扩容;\npublic class MyStack { private int[] storage;//存放栈中元素的数组 private int capacity;//栈的容量 private int count;//栈中元素数量 private static final int GROW_FACTOR = 2; //不带初始容量的构造方法。默认容量为8 public MyStack() { this.capacity = 8; this.storage=new int[8]; this.count = 0; } //带初始容量的构造方法 public MyStack(int initialCapacity) { if (initialCapacity \u0026lt; 1) throw new IllegalArgumentException(\u0026#34;Capacity too small.\u0026#34;); this.capacity = initialCapacity; this.storage = new int[initialCapacity]; this.count = 0; } //入栈 public void push(int value) { if (count == capacity) { ensureCapacity(); } storage[count++] = value; } //确保容量大小 private void ensureCapacity() { int newCapacity = capacity * GROW_FACTOR; storage = Arrays.copyOf(storage, newCapacity); capacity = newCapacity; } //返回栈顶元素并出栈 private int pop() { if (count == 0) throw new IllegalArgumentException(\u0026#34;Stack is empty.\u0026#34;); count--; return storage[count]; } //返回栈顶元素不出栈 private int peek() { if (count == 0){ throw new IllegalArgumentException(\u0026#34;Stack is empty.\u0026#34;); }else { return storage[count-1]; } } //判断栈是否为空 private boolean isEmpty() { return count == 0; } //返回栈中元素的个数 private int size() { return count; } } 验证\nMyStack myStack = new MyStack(3); myStack.push(1); myStack.push(2); myStack.push(3); myStack.push(4); myStack.push(5); myStack.push(6); myStack.push(7); myStack.push(8); System.out.println(myStack.peek());//8 System.out.println(myStack.size());//8 for (int i = 0; i \u0026lt; 8; i++) { System.out.println(myStack.pop()); } System.out.println(myStack.isEmpty());//true myStack.pop();//报错:java.lang.IllegalArgumentException: Stack is empty. 4. 队列 # 4.1. 队列简介 # 队列(Queue) 是 先进先出 (FIFO,First In, First Out) 的线性表。在具体应用中通常用链表或者数组来实现,用数组实现的队列叫作 顺序队列 ,用链表实现的队列叫作 链式队列 。队列只允许在后端(rear)进行插入操作也就是入队 enqueue,在前端(front)进行删除操作也就是出队 dequeue\n队列的操作方式和堆栈类似,唯一的区别在于队列只允许新数据在后端进行添加。\n假设队列中有n个元素。 访问:O(n)//最坏情况 插入删除:O(1)//后端插入前端删除元素 4.2. 队列分类 # 4.2.1. 单队列 # 单队列就是常见的队列, 每次添加元素时,都是添加到队尾。单队列又分为 顺序队列(数组实现) 和 链式队列(链表实现)。\n顺序队列存在“假溢出”的问题也就是明明有位置却不能添加的情况。\n假设下图是一个顺序队列,我们将前两个元素 1,2 出队,并入队两个元素 7,8。当进行入队、出队操作的时候,front 和 rear 都会持续往后移动,当 rear 移动到最后的时候,我们无法再往队列中添加数据,即使数组中还有空余空间,这种现象就是 ”假溢出“ 。除了假溢出问题之外,如下图所示,当添加元素 8 的时候,rear 指针移动到数组之外(越界)。\n为了避免当只有一个元素的时候,队头和队尾重合使处理变得麻烦,所以引入两个指针,front 指针指向对头元素,rear 指针指向队列最后一个元素的下一个位置,这样当 front 等于 rear 时,此队列不是还剩一个元素,而是空队列。——From 《大话数据结构》\n4.2.2. 循环队列 # 循环队列可以解决顺序队列的假溢出和越界问题。解决办法就是:从头开始,这样也就会形成头尾相接的循环,这也就是循环队列名字的由来。\n还是用上面的图,我们将 rear 指针指向数组下标为 0 的位置就不会有越界问题了。当我们再向队列中添加元素的时候, rear 向后移动。\n顺序队列中,我们说 front==rear 的时候队列为空,循环队列中则不一样,也可能为满,如上图所示。解决办法有两种:\n可以设置一个标志变量 flag,当 front==rear 并且 flag=0 的时候队列为空,当front==rear 并且 flag=1 的时候队列为满。 队列为空的时候就是 front==rear ,队列满的时候,我们保证数组还有一个空闲的位置,rear 就指向这个空闲位置,如下图所示,那么现在判断队列是否为满的条件就是:(rear+1) % QueueSize==front 。 4.2.3 双端队列 # 双端队列 (Deque) 是一种在队列的两端都可以进行插入和删除操作的队列,相比单队列来说更加灵活。\n一般来说,我们可以对双端队列进行 addFirst、addLast、removeFirst 和 removeLast 操作。\n4.2.4 优先队列 # 优先队列 (Priority Queue) 从底层结构上来讲并非线性的数据结构,它一般是由堆来实现的。\n在每个元素入队时,优先队列会将新元素其插入堆中并调整堆。 在队头出队时,优先队列会返回堆顶元素并调整堆。 关于堆的具体实现可以看 堆这一节。\n总而言之,不论我们进行什么操作,优先队列都能按照某种排序方式进行一系列堆的相关操作,从而保证整个集合的有序性。\n虽然优先队列的底层并非严格的线性结构,但是在我们使用的过程中,我们是感知不到堆的,从使用者的眼中优先队列可以被认为是一种线性的数据结构:一种会自动排序的线性队列。\n4.3. 队列的常见应用场景 # 当我们需要按照一定顺序来处理数据的时候可以考虑使用队列这个数据结构。\n阻塞队列: 阻塞队列可以看成在队列基础上加了阻塞操作的队列。当队列为空的时候,出队操作阻塞,当队列满的时候,入队操作阻塞。使用阻塞队列我们可以很容易实现“生产者 - 消费者“模型。 线程池中的请求/任务队列: 线程池中没有空闲线程时,新的任务请求线程资源时,线程池该如何处理呢?答案是将这些请求放在队列中,当有空闲线程的时候,会循环中反复从队列中获取任务来执行。队列分为无界队列(基于链表)和有界队列(基于数组)。无界队列的特点就是可以一直入列,除非系统资源耗尽,比如:FixedThreadPool 使用无界队列 LinkedBlockingQueue。但是有界队列就不一样了,当队列满的话后面再有任务/请求就会拒绝,在 Java 中的体现就是会抛出java.util.concurrent.RejectedExecutionException 异常。 栈:双端队列天生便可以实现栈的全部功能(push、pop 和 peek),并且在 Deque 接口中已经实现了相关方法。Stack 类已经和 Vector 一样被遗弃,现在在 Java 中普遍使用双端队列(Deque)来实现栈。 广度优先搜索(BFS),在图的广度优先搜索过程中,队列被用于存储待访问的节点,保证按照层次顺序遍历图的节点。 Linux 内核进程队列(按优先级排队) 现实生活中的派对,播放器上的播放列表; 消息队列 等等…… "},{"id":664,"href":"/zh/docs/technology/Interview/high-performance/message-queue/message-queue/","title":"消息队列基础知识总结","section":"High Performance","content":"::: tip\n这篇文章中的消息队列主要指的是分布式消息队列。\n:::\n“RabbitMQ?”“Kafka?”“RocketMQ?”\u0026hellip;在日常学习与开发过程中,我们常常听到消息队列这个关键词。我也在我的多篇文章中提到了这个概念。可能你是熟练使用消息队列的老手,又或者你是不懂消息队列的新手,不论你了不了解消息队列,本文都将带你搞懂消息队列的一些基本理论。\n如果你是老手,你可能从本文学到你之前不曾注意的一些关于消息队列的重要概念,如果你是新手,相信本文将是你打开消息队列大门的一板砖。\n什么是消息队列? # 我们可以把消息队列看作是一个存放消息的容器,当我们需要使用消息的时候,直接从容器中取出消息供自己使用即可。由于队列 Queue 是一种先进先出的数据结构,所以消费消息时也是按照顺序来消费的。\n参与消息传递的双方称为 生产者 和 消费者 ,生产者负责发送消息,消费者负责处理消息。\n操作系统中的进程通信的一种很重要的方式就是消息队列。我们这里提到的消息队列稍微有点区别,更多指的是各个服务以及系统内部各个组件/模块之前的通信,属于一种 中间件 。\n维基百科是这样介绍中间件的:\n中间件(英语:Middleware),又译中间件、中介层,是一类提供系统软件和应用软件之间连接、便于软件各部件之间的沟通的软件,应用软件可以借助中间件在不同的技术架构之间共享信息与资源。中间件位于客户机服务器的操作系统之上,管理着计算资源和网络通信。\n简单来说:中间件就是一类为应用软件服务的软件,应用软件是为用户服务的,用户不会接触或者使用到中间件。\n除了消息队列之外,常见的中间件还有 RPC 框架、分布式组件、HTTP 服务器、任务调度框架、配置中心、数据库层的分库分表工具和数据迁移工具等等。\n关于中间件比较详细的介绍可以参考阿里巴巴淘系技术的一篇回答: https://www.zhihu.com/question/19730582/answer/1663627873 。\n随着分布式和微服务系统的发展,消息队列在系统设计中有了更大的发挥空间,使用消息队列可以降低系统耦合性、实现任务异步、有效地进行流量削峰,是分布式和微服务系统中重要的组件之一。\n消息队列有什么用? # 通常来说,使用消息队列主要能为我们的系统带来下面三点好处:\n异步处理 削峰/限流 降低系统耦合性 除了这三点之外,消息队列还有其他的一些应用场景,例如实现分布式事务、顺序保证和数据流处理。\n如果在面试的时候你被面试官问到这个问题的话,一般情况是你在你的简历上涉及到消息队列这方面的内容,这个时候推荐你结合你自己的项目来回答。\n异步处理 # 将用户请求中包含的耗时操作,通过消息队列实现异步处理,将对应的消息发送到消息队列之后就立即返回结果,减少响应时间,提高用户体验。随后,系统再对消息进行消费。\n因为用户请求数据写入消息队列之后就立即返回给用户了,但是请求数据在后续的业务校验、写数据库等操作中可能失败。因此,使用消息队列进行异步处理之后,需要适当修改业务流程进行配合,比如用户在提交订单之后,订单数据写入消息队列,不能立即返回用户订单提交成功,需要在消息队列的订单消费者进程真正处理完该订单之后,甚至出库后,再通过电子邮件或短信通知用户订单成功,以免交易纠纷。这就类似我们平时手机订火车票和电影票。\n削峰/限流 # 先将短时间高并发产生的事务消息存储在消息队列中,然后后端服务再慢慢根据自己的能力去消费这些消息,这样就避免直接把后端服务打垮掉。\n举例:在电子商务一些秒杀、促销活动中,合理使用消息队列可以有效抵御促销活动刚开始大量订单涌入对系统的冲击。如下图所示:\n降低系统耦合性 # 使用消息队列还可以降低系统耦合性。如果模块之间不存在直接调用,那么新增模块或者修改模块就对其他模块影响较小,这样系统的可扩展性无疑更好一些。\n生产者(客户端)发送消息到消息队列中去,消费者(服务端)处理消息,需要消费的系统直接去消息队列取消息进行消费即可而不需要和其他系统有耦合,这显然也提高了系统的扩展性。\n消息队列使用发布-订阅模式工作,消息发送者(生产者)发布消息,一个或多个消息接受者(消费者)订阅消息。 从上图可以看到消息发送者(生产者)和消息接受者(消费者)之间没有直接耦合,消息发送者将消息发送至分布式消息队列即结束对消息的处理,消息接受者从分布式消息队列获取该消息后进行后续处理,并不需要知道该消息从何而来。对新增业务,只要对该类消息感兴趣,即可订阅该消息,对原有系统和业务没有任何影响,从而实现网站业务的可扩展性设计。\n例如,我们商城系统分为用户、订单、财务、仓储、消息通知、物流、风控等多个服务。用户在完成下单后,需要调用财务(扣款)、仓储(库存管理)、物流(发货)、消息通知(通知用户发货)、风控(风险评估)等服务。使用消息队列后,下单操作和后续的扣款、发货、通知等操作就解耦了,下单完成发送一个消息到消息队列,需要用到的地方去订阅这个消息进行消息即可。\n另外,为了避免消息队列服务器宕机造成消息丢失,会将成功发送到消息队列的消息存储在消息生产者服务器上,等消息真正被消费者服务器处理后才删除消息。在消息队列服务器宕机后,生产者服务器会选择分布式消息队列服务器集群中的其他服务器发布消息。\n备注: 不要认为消息队列只能利用发布-订阅模式工作,只不过在解耦这个特定业务环境下是使用发布-订阅模式的。除了发布-订阅模式,还有点对点订阅模式(一个消息只有一个消费者),我们比较常用的是发布-订阅模式。另外,这两种消息模型是 JMS 提供的,AMQP 协议还提供了另外 5 种消息模型。\n实现分布式事务 # 分布式事务的解决方案之一就是 MQ 事务。\nRocketMQ、 Kafka、Pulsar、QMQ 都提供了事务相关的功能。事务允许事件流应用将消费,处理,生产消息整个过程定义为一个原子操作。\n详细介绍可以查看 分布式事务详解(付费) 这篇文章。\n顺序保证 # 在很多应用场景中,处理数据的顺序至关重要。消息队列保证数据按照特定的顺序被处理,适用于那些对数据顺序有严格要求的场景。大部分消息队列,例如 RocketMQ、RabbitMQ、Pulsar、Kafka,都支持顺序消息。\n延时/定时处理 # 消息发送后不会立即被消费,而是指定一个时间,到时间后再消费。大部分消息队列,例如 RocketMQ、RabbitMQ、Pulsar、Kafka,都支持定时/延时消息。\n即时通讯 # MQTT(消息队列遥测传输协议)是一种轻量级的通讯协议,采用发布/订阅模式,非常适合于物联网(IoT)等需要在低带宽、高延迟或不可靠网络环境下工作的应用。它支持即时消息传递,即使在网络条件较差的情况下也能保持通信的稳定性。\nRabbitMQ 内置了 MQTT 插件用于实现 MQTT 功能(默认不启用,需要手动开启)。\n数据流处理 # 针对分布式系统产生的海量数据流,如业务日志、监控数据、用户行为等,消息队列可以实时或批量收集这些数据,并将其导入到大数据处理引擎中,实现高效的数据流管理和处理。\n使用消息队列会带来哪些问题? # 系统可用性降低: 系统可用性在某种程度上降低,为什么这样说呢?在加入 MQ 之前,你不用考虑消息丢失或者说 MQ 挂掉等等的情况,但是,引入 MQ 之后你就需要去考虑了! 系统复杂性提高: 加入 MQ 之后,你需要保证消息没有被重复消费、处理消息丢失的情况、保证消息传递的顺序性等等问题! 一致性问题: 我上面讲了消息队列可以实现异步,消息队列带来的异步确实可以提高系统响应速度。但是,万一消息的真正消费者并没有正确消费消息怎么办?这样就会导致数据不一致的情况了! JMS 和 AMQP # JMS 是什么? # JMS(JAVA Message Service,java 消息服务)是 Java 的消息服务,JMS 的客户端之间可以通过 JMS 服务进行异步的消息传输。JMS(JAVA Message Service,Java 消息服务)API 是一个消息服务的标准或者说是规范,允许应用程序组件基于 JavaEE 平台创建、发送、接收和读取消息。它使分布式通信耦合度更低,消息服务更加可靠以及异步性。\nJMS 定义了五种不同的消息正文格式以及调用的消息类型,允许你发送并接收以一些不同形式的数据:\nStreamMessage:Java 原始值的数据流 MapMessage:一套名称-值对 TextMessage:一个字符串对象 ObjectMessage:一个序列化的 Java 对象 BytesMessage:一个字节的数据流 ActiveMQ(已被淘汰) 就是基于 JMS 规范实现的。\nJMS 两种消息模型 # 点到点(P2P)模型 # 使用队列(Queue)作为消息通信载体;满足生产者与消费者模式,一条消息只能被一个消费者使用,未被消费的消息在队列中保留直到被消费或超时。比如:我们生产者发送 100 条消息的话,两个消费者来消费一般情况下两个消费者会按照消息发送的顺序各自消费一半(也就是你一个我一个的消费。)\n发布/订阅(Pub/Sub)模型 # 发布订阅模型(Pub/Sub) 使用主题(Topic)作为消息通信载体,类似于广播模式;发布者发布一条消息,该消息通过主题传递给所有的订阅者。\nAMQP 是什么? # AMQP,即 Advanced Message Queuing Protocol,一个提供统一消息服务的应用层标准 高级消息队列协议(二进制应用层协议),是应用层协议的一个开放标准,为面向消息的中间件设计,兼容 JMS。基于此协议的客户端与消息中间件可传递消息,并不受客户端/中间件同产品,不同的开发语言等条件的限制。\nRabbitMQ 就是基于 AMQP 协议实现的。\nJMS vs AMQP # 对比方向 JMS AMQP 定义 Java API 协议 跨语言 否 是 跨平台 否 是 支持消息类型 提供两种消息模型:①Peer-2-Peer;②Pub/sub 提供了五种消息模型:①direct exchange;②fanout exchange;③topic change;④headers exchange;⑤system exchange。本质来讲,后四种和 JMS 的 pub/sub 模型没有太大差别,仅是在路由机制上做了更详细的划分; 支持消息类型 支持多种消息类型 ,我们在上面提到过 byte[](二进制) 总结:\nAMQP 为消息定义了线路层(wire-level protocol)的协议,而 JMS 所定义的是 API 规范。在 Java 体系中,多个 client 均可以通过 JMS 进行交互,不需要应用修改代码,但是其对跨平台的支持较差。而 AMQP 天然具有跨平台、跨语言特性。 JMS 支持 TextMessage、MapMessage 等复杂的消息类型;而 AMQP 仅支持 byte[] 消息类型(复杂的类型可序列化后发送)。 由于 Exchange 提供的路由算法,AMQP 可以提供多样化的路由方式来传递消息到消息队列,而 JMS 仅支持 队列 和 主题/订阅 方式两种。 RPC 和消息队列的区别 # RPC 和消息队列都是分布式微服务系统中重要的组件之一,下面我们来简单对比一下两者:\n从用途来看:RPC 主要用来解决两个服务的远程通信问题,不需要了解底层网络的通信机制。通过 RPC 可以帮助我们调用远程计算机上某个服务的方法,这个过程就像调用本地方法一样简单。消息队列主要用来降低系统耦合性、实现任务异步、有效地进行流量削峰。 从通信方式来看:RPC 是双向直接网络通讯,消息队列是单向引入中间载体的网络通讯。 从架构上来看:消息队列需要把消息存储起来,RPC 则没有这个要求,因为前面也说了 RPC 是双向直接网络通讯。 从请求处理的时效性来看:通过 RPC 发出的调用一般会立即被处理,存放在消息队列中的消息并不一定会立即被处理。 RPC 和消息队列本质上是网络通讯的两种不同的实现机制,两者的用途不同,万不可将两者混为一谈。\n分布式消息队列技术选型 # 常见的消息队列有哪些? # Kafka # Kafka 是 LinkedIn 开源的一个分布式流式处理平台,已经成为 Apache 顶级项目,早期被用来用于处理海量的日志,后面才慢慢发展成了一款功能全面的高性能消息队列。\n流式处理平台具有三个关键功能:\n消息队列:发布和订阅消息流,这个功能类似于消息队列,这也是 Kafka 也被归类为消息队列的原因。 容错的持久方式存储记录消息流:Kafka 会把消息持久化到磁盘,有效避免了消息丢失的风险。 流式处理平台: 在消息发布的时候进行处理,Kafka 提供了一个完整的流式处理类库。 Kafka 是一个分布式系统,由通过高性能 TCP 网络协议进行通信的服务器和客户端组成,可以部署在在本地和云环境中的裸机硬件、虚拟机和容器上。\n在 Kafka 2.8 之前,Kafka 最被大家诟病的就是其重度依赖于 Zookeeper 做元数据管理和集群的高可用。在 Kafka 2.8 之后,引入了基于 Raft 协议的 KRaft 模式,不再依赖 Zookeeper,大大简化了 Kafka 的架构,让你可以以一种轻量级的方式来使用 Kafka。\n不过,要提示一下:如果要使用 KRaft 模式的话,建议选择较高版本的 Kafka,因为这个功能还在持续完善优化中。Kafka 3.3.1 版本是第一个将 KRaft(Kafka Raft)共识协议标记为生产就绪的版本。\nKafka 官网: http://kafka.apache.org/\nKafka 更新记录(可以直观看到项目是否还在维护): https://kafka.apache.org/downloads\nRocketMQ # RocketMQ 是阿里开源的一款云原生“消息、事件、流”实时数据处理平台,借鉴了 Kafka,已经成为 Apache 顶级项目。\nRocketMQ 的核心特性(摘自 RocketMQ 官网):\n云原生:生与云,长与云,无限弹性扩缩,K8s 友好 高吞吐:万亿级吞吐保证,同时满足微服务与大数据场景。 流处理:提供轻量、高扩展、高性能和丰富功能的流计算引擎。 金融级:金融级的稳定性,广泛用于交易核心链路。 架构极简:零外部依赖,Shared-nothing 架构。 生态友好:无缝对接微服务、实时计算、数据湖等周边生态。 根据官网介绍:\nApache RocketMQ 自诞生以来,因其架构简单、业务功能丰富、具备极强可扩展性等特点被众多企业开发者以及云厂商广泛采用。历经十余年的大规模场景打磨,RocketMQ 已经成为业内共识的金融级可靠业务消息首选方案,被广泛应用于互联网、大数据、移动互联网、物联网等领域的业务场景。\nRocketMQ 官网: https://rocketmq.apache.org/ (文档很详细,推荐阅读)\nRocketMQ 更新记录(可以直观看到项目是否还在维护): https://github.com/apache/rocketmq/releases\nRabbitMQ # RabbitMQ 是采用 Erlang 语言实现 AMQP(Advanced Message Queuing Protocol,高级消息队列协议)的消息中间件,它最初起源于金融系统,用于在分布式系统中存储转发消息。\nRabbitMQ 发展到今天,被越来越多的人认可,这和它在易用性、扩展性、可靠性和高可用性等方面的卓著表现是分不开的。RabbitMQ 的具体特点可以概括为以下几点:\n可靠性: RabbitMQ 使用一些机制来保证消息的可靠性,如持久化、传输确认及发布确认等。 灵活的路由: 在消息进入队列之前,通过交换器来路由消息。对于典型的路由功能,RabbitMQ 己经提供了一些内置的交换器来实现。针对更复杂的路由功能,可以将多个交换器绑定在一起,也可以通过插件机制来实现自己的交换器。这个后面会在我们讲 RabbitMQ 核心概念的时候详细介绍到。 扩展性: 多个 RabbitMQ 节点可以组成一个集群,也可以根据实际业务情况动态地扩展集群中节点。 高可用性: 队列可以在集群中的机器上设置镜像,使得在部分节点出现问题的情况下队列仍然可用。 支持多种协议: RabbitMQ 除了原生支持 AMQP 协议,还支持 STOMP、MQTT 等多种消息中间件协议。 多语言客户端: RabbitMQ 几乎支持所有常用语言,比如 Java、Python、Ruby、PHP、C#、JavaScript 等。 易用的管理界面: RabbitMQ 提供了一个易用的用户界面,使得用户可以监控和管理消息、集群中的节点等。在安装 RabbitMQ 的时候会介绍到,安装好 RabbitMQ 就自带管理界面。 插件机制: RabbitMQ 提供了许多插件,以实现从多方面进行扩展,当然也可以编写自己的插件。感觉这个有点类似 Dubbo 的 SPI 机制 RabbitMQ 官网: https://www.rabbitmq.com/ 。\nRabbitMQ 更新记录(可以直观看到项目是否还在维护): https://www.rabbitmq.com/news.html\nPulsar # Pulsar 是下一代云原生分布式消息流平台,最初由 Yahoo 开发 ,已经成为 Apache 顶级项目。\nPulsar 集消息、存储、轻量化函数式计算为一体,采用计算与存储分离架构设计,支持多租户、持久化存储、多机房跨区域数据复制,具有强一致性、高吞吐、低延时及高可扩展性等流数据存储特性,被看作是云原生时代实时消息流传输、存储和计算最佳解决方案。\nPulsar 的关键特性如下(摘自官网):\n是下一代云原生分布式消息流平台。 Pulsar 的单个实例原生支持多个集群,可跨机房在集群间无缝地完成消息复制。 极低的发布延迟和端到端延迟。 可无缝扩展到超过一百万个 topic。 简单的客户端 API,支持 Java、Go、Python 和 C++。 主题的多种订阅模式(独占、共享和故障转移)。 通过 Apache BookKeeper 提供的持久化消息存储机制保证消息传递 。 由轻量级的 serverless 计算框架 Pulsar Functions 实现流原生的数据处理。 基于 Pulsar Functions 的 serverless connector 框架 Pulsar IO 使得数据更易移入、移出 Apache Pulsar。 分层式存储可在数据陈旧时,将数据从热存储卸载到冷/长期存储(如 S3、GCS)中。 Pulsar 官网: https://pulsar.apache.org/\nPulsar 更新记录(可以直观看到项目是否还在维护): https://github.com/apache/pulsar/releases\nActiveMQ # 目前已经被淘汰,不推荐使用,不建议学习。\n如何选择? # 参考《Java 工程师面试突击第 1 季-中华石杉老师》\n对比方向 概要 吞吐量 万级的 ActiveMQ 和 RabbitMQ 的吞吐量(ActiveMQ 的性能最差)要比十万级甚至是百万级的 RocketMQ 和 Kafka 低一个数量级。 可用性 都可以实现高可用。ActiveMQ 和 RabbitMQ 都是基于主从架构实现高可用性。RocketMQ 基于分布式架构。 Kafka 也是分布式的,一个数据多个副本,少数机器宕机,不会丢失数据,不会导致不可用 时效性 RabbitMQ 基于 Erlang 开发,所以并发能力很强,性能极其好,延时很低,达到微秒级,其他几个都是 ms 级。 功能支持 Pulsar 的功能更全面,支持多租户、多种消费模式和持久性模式等功能,是下一代云原生分布式消息流平台。 消息丢失 ActiveMQ 和 RabbitMQ 丢失的可能性非常低, Kafka、RocketMQ 和 Pulsar 理论上可以做到 0 丢失。 总结:\nActiveMQ 的社区算是比较成熟,但是较目前来说,ActiveMQ 的性能比较差,而且版本迭代很慢,不推荐使用,已经被淘汰了。 RabbitMQ 在吞吐量方面虽然稍逊于 Kafka、RocketMQ 和 Pulsar,但是由于它基于 Erlang 开发,所以并发能力很强,性能极其好,延时很低,达到微秒级。但是也因为 RabbitMQ 基于 Erlang 开发,所以国内很少有公司有实力做 Erlang 源码级别的研究和定制。如果业务场景对并发量要求不是太高(十万级、百万级),那这几种消息队列中,RabbitMQ 或许是你的首选。 RocketMQ 和 Pulsar 支持强一致性,对消息一致性要求比较高的场景可以使用。 RocketMQ 阿里出品,Java 系开源项目,源代码我们可以直接阅读,然后可以定制自己公司的 MQ,并且 RocketMQ 有阿里巴巴的实际业务场景的实战考验。 Kafka 的特点其实很明显,就是仅仅提供较少的核心功能,但是提供超高的吞吐量,ms 级的延迟,极高的可用性以及可靠性,而且分布式可以任意扩展。同时 Kafka 最好是支撑较少的 topic 数量即可,保证其超高吞吐量。Kafka 唯一的一点劣势是有可能消息重复消费,那么对数据准确性会造成极其轻微的影响,在大数据领域中以及日志采集中,这点轻微影响可以忽略这个特性天然适合大数据实时计算以及日志收集。如果是大数据领域的实时计算、日志采集等场景,用 Kafka 是业内标准的,绝对没问题,社区活跃度很高,绝对不会黄,何况几乎是全世界这个领域的事实性规范。 参考 # 《大型网站技术架构 》 KRaft: Apache Kafka Without ZooKeeper: https://developer.confluent.io/learn/kraft/ 消息队列的使用场景是什么样的?: https://mp.weixin.qq.com/s/4V1jI6RylJr7Jr9JsQe73A "},{"id":665,"href":"/zh/docs/technology/Interview/high-quality-technical-articles/interview/my-personal-experience-in-2021/","title":"校招进入飞书的个人经验","section":"Interview","content":" 推荐语:这篇文章的作者校招最终去了飞书做开发。在这篇文章中,他分享了自己的校招经历以及个人经验。\n原文地址: https://www.ihewro.com/archives/1217/\n基本情况 # 我是 C++主要是后台开发的方向。\n2021 春招入职字节飞书客户端,入职字节之前拿到了百度 offer(音视频直播部分) 以及腾讯 PCG (微视、后台开发)的 HR 面试通过(还没有收到录用意向书)。\n不顺利的春招过程 # 春招实习对我来说不太顺利 # 实验室在 1 月份元旦的那天正式可以放假回家,但回家仍然继续“远程工作”,工作并没有减少,每天日复一日的测试,调试我们开发的“流媒体会议系统”。\n在 1 月的倒数第三天,我们开了“年终总结”线上会议。至此,作为研二基本上与实验室的工作开始告别。也正式开始了春招复习的阶段。\n2 月前已经间歇性的开始准备,无非就是在 LeetCode 上面刷刷题目,一天刷不了几道,后面甚至象征性的刷一下每日一题。对我的算法刷题帮助很少。\n2 月份开始,2 月初的时候,LeetCode 才刷了大概 40 多道题目,挤出了几周时间更新了 handsome 主题的 8.x 版本,这又是一个繁忙的几周。直到春节的当天正式发布,春节过后又开始陆陆续续用一些时间修复 bug,发布修复版本。2 月份这样悄悄溜走。\n找实习的过程 # 2021-3 月初\n3 月 初的时候,投了阿里提前批,没想到阿里 3 月 4 号提前批就结束了,那一天约的一面的电话面也被取消了。紧接了开学实验室开会同步进度的时候,发现大家都一面/二面/三面的进度,而我还没有投递的进度。\n2021-3-8\n投递了字节飞书\n2021-4 月初\n字节第一次一面,腾讯第一次一面\n2021-4 中旬\n美团一、二面,腾讯第二次一面和二面,百度三轮面试,通过了。\n2021-4 底\n腾讯第三次一面和字节第二次一面\n2021-5 月初\n腾讯第三次二面和字节第二次二面,后面这两个都通过了\n阿里 # 第一次投了钉钉,没想到因为行测做的不好,在简历筛选给拒绝了。\n第二次阿里妈妈的后端面试,一面电话面试,我感觉面的还可以,最后题目也做出来了。最后反问阶段问对我的面试有什么建议,面试官说投阿里最好还是 Java 的…… 然后电话结束后就给我拒了……\n当时真的心态有点崩,问了这个晚上 7 点半的面试,一直看书晚上都没吃……\n所以春招和阿里就无缘了。\n美团 # 美团一面的面试官真的人很好。也很轻松,因为他们是 Java 岗位,也没问 c++知识,聊了一些基础知识,后面半个小时就是聊非技术问题,比如最喜欢网络上的某位程序员是谁,如何写出优雅的代码,推荐的技术类的书籍之类的。当时回答王垠是比较喜欢的程序员,面试官笑了说他也很喜欢。面试的氛围感觉很好。\n二面的时候全程就问简历上的一个项目,问了大概 90 分钟,感觉他从一开始就有点不太想要我的感觉,很大原因我觉的是我是 c++,转 Java 可能成本还是有一些的。最后问 HR 说结果待定,几天后通知被拒了。\n百度 # 百度一共三轮面试,在一个下午一起进行,真的很刺激。一面就是很基础的一些 c++问题,写了一个题目说一下思路没让运行(真的要运行还不一定能运行起来:))\n二面也是基础,第一个题目合并两个有序数组,第二个题目写归并排序,写的结果不对,又给我换了一个题目,树的 BFS。二面面试官最后问我对今天面试觉得怎么样,我说虽然中间有一个道题目结果不对,但是思路是对的,可能某个小地方写的有问题,但总体的应该还是可以的。二面就给我通过了。\n三面问的技术问题比较少,30 多分钟,也没写题目,问了一些基本情况和基础知识。最后问部门做的什么内容。面试官说后面 hr 会联系我告诉我内容。\n字节飞书 # 第一次一面就凉了,原因应该是笔试题目结果不对……\n第二次一面在 4 月底了,很顺利。二面在五一劳动节后,面试官还让学姐告诉我让我多看看智能指针,面试的时候让我手写 shared_ptr,我之前看了一些实现,但是没有自己写过,导致代码考虑的不够完善,leader 就一直提醒我要怎么改怎么改。\n本来我以为凉了,在 5 月中旬的时候都准备去百度入职了,给我通知说过了,就这样决定去了字节。\n感悟 # 这么多次面试中,让我感悟最深的是面试中的考察题目真的很重要,因为我在基础知识上面也不突出,再加上如果算法题(一般 1 道或者 2 道)如果没做出来,基本就凉了。而面试之前的笔试考试反而没那么重要,也没那么难。基本 4 题写出来 1~2 道题目就有发起面试的机会了。难度也基本就是 LeetCode top 100 上面的那些算法。\n面试中做题,我很容易紧张,头脑就容易一片空白,稍不注意,写错个符号,或者链表赋值错了,很难看出来问题,导出最终结果不对。\n入职字节实习 # 入职字节之前我本来觉得这个岗位可能是我面试的最适合我的了,因为我主 c++,而且飞书用 c++应该挺深的。来之后就觉得我可能不太喜欢做客户端相关,感觉好复杂……也许服务端好一些,现在我仍然不能确定。\n字节的实习福利在这些公司中应该算是比较好的,小问题是工位比较窄,还是工作强度比其他的互联网公司大一些。字节食堂免费而且挺不错的。字节办公大厦很多,我所在的办公地点比较小。\n目前,需要放轻松,仓库代码慢慢看呗,mentor 也让我不急,准备有问题就多问问,不能憋着,浪费时间。拿到转正 offer 后,秋招还是想多试试外企或者国企。强度太大的工作目前很难适应。\n希望过段时间可以分享一下我的感受,以及能够更加适应目前的工作内容。\n求职经验分享 # 一些概念 # 日常实习与正式(暑期)实习有什么区别 # 日常实习如果一个组比较缺人,就很可能一年四季都招实习生,就会有日常实习的机会,只要是在校学生都可以去面试。而正式实习开始时间有一个范围比较固定,比如每年的 3-6 月,也就是暑期实习。 日常实习相对要好进一些,但是有的日常实习没有转正名额,这个要先确认一下。 字节的日常实习和正式实习在转正没什么区别,都是一起申请转正的。 正式实习拿到 offer 之后什么时候可以去实习 # 暑期实习拿到 offer 后就可以立即实习(一般需要走个流程 1 周左右的样子),也可以选择晚一点去实习,时间可以自己去把握,有的公司可以在系统上选择去实习的时间,有的是直接和 hr 沟通一下就可以。\n提前批和正式批的区别 # 以找实习为例:\n先提前批,再正式批,提前批一般是小组直接招人不进系统,没有笔试,流程相对走的快,一般一面过了,很快就是二面。 正式批面试都会有面评,如果上一次失败的面试评价会影响下一次面试,所以还是谨慎一点好 实习 offer 和正式 offer 区别 # 简单来说,实习 offer 只是给你一个实习的机会,如果在实习期间干的不错就可以转正,获得正式 offer。\n签署正式 offer 之后并不是意味着马上去上班,因为我们是校招生,拿到正式 offer 之后,可以继续实习(工资会是正式工资的百分比),也可以请假一段时间等真正毕业的时候再去正式工作。\n时间节点 # 尽早把简历弄出来,最好就是最近一段时间,因为大家对实验室项目现在还很熟悉,现在写起来不是很难,再过几个月写简历就比较痛苦了。\n以去年为例:\n2 月份中旬的时候阿里提前批开始(基本上只有阿里这个时候开了提前批),3 月 8 号阿里提前批结束。腾讯提前批是 3 月多开始的,4 月 15 号结束 3-5 月拿到实习 offer,最好在 4 月份可以拿到比较想去的实习 offer。 4-8 月份实习,7 月初秋招提前批,7 月底或者 8 月初就是秋招正式批,9 月底秋招就少了挺多,但是只是相对来说,还是有机会, 10 月底秋招基本结束,后面还会有秋招补录 怎么找实习机会,个人觉得可以找认识的人内推比较好,内推好处除了可以帮看进度,一般可以直推到组,这样可以排除一些坑的组。提前知道这个组干嘛的。 实习挺重要,最好是实习的时候就找到一个想去的公司,秋招会轻松很多,因为实习转正基本没什么问题,其次实习转正的 offer 一般要比秋招的好(当然如果秋招表现好也是可以拿到很好的 offer)身边不少人正式 offer 都是实习转正的。 控制好实习的时间,因为边实习边准备秋招挺累的,一般实习的时候工作压力也挺大,没什么时间刷题。 面试准备 # 项目经历 # 我觉得我们实验室项目是没问题的,重要是要讲好。\n项目介绍 首先可能让你介绍一下这个项目是什么东西,以及为什么要去做这个项目。\n项目的结果 然后可能会问这个项目的一些数据上最终结果,比如会议系统能够同时多少人使用,或者量化的体验,比如流畅度,或者是一些其他的一些优势。\n项目中的困难 最后都会问过程中有没有遇到什么困难、挑战的,以及怎么解决的。这个过程中主要考察这个项目的技术点是什么。\n困难是指什么,个人觉得主要是花了好几天才解决的问题就是困难。\n举两个例子:\n第一个例子是排查 bug 方面,比如有一个内存泄露的问题花了一周才排查出来,那就算一个困难,那么解决这个困难的过程就是如何去定位这个问题过程,比如我们先根据错误搜索相关资料,肯定没那么容易就直接找到原因,而是我们会在这些资料中找到一些关键词,比如一些工具,那么我们对这个工具的使用就是解决问题的一个过程。\n第二个例子是需求方案的设计,比如某个需求完成,我们实现这个需求可能有多个可行的设计方案。解决这个困难的过程就是我们对最终选择这个方法的原因,以及其他的设计方案的优缺点的思考。\n面试中被问到:你在工作中碰到的最困难的问题是什么?发现问题,解决问题.-CSDN 博客面试中问到工作中遇到困难是怎么解决的\n有人说我解决方法就是通过百度搜索,但实际上细节也是先搜索某个错误或者问题,但是肯定不可能一下子就搜到了代码答案,而是找到一个答案中有某个关键词,接着我们继续找关键词获取其他的信息。\n笔试 # 找实习的笔试我觉得不会太难,一般如果是 4 道题目,做出来 1-2 道题目差不多就有面试的机会了。\n刷题老生常谈的问题,LeetCode Top100。一开始刷题很痛苦,等刷了 40 道题目的时候就有点感觉的,建议从链表、二叉树开始刷,数组类型题目有很多不能通用的技巧。\n::一定要用白版进行训练::,一定要用白板,不仅仅是为了面试记住 API,更重要的是用白板熟练后,写代码会更熟练而且思路更独立和没有依赖。 算法题重中之重,终点不是困难题目,而是简单,中等,常见,高频的题目要熟能生巧,滚瓜烂熟。 面试的笔试过程中,如果出现了问题,一定要第一时间申请使用本地 IDE 进行调试,否则可能很长时间找不到问题,浪费了机会。 面试 # 面试一般 1 场 1 个小时候分为两个部分,前半部分会问一些基础知识或者项目经历,后半部分做题。\n基础知识复习一开始没必要系统的去复习,首先是确保高频问题必会,比如计算机网络、操作系统那几个必问的问题,可以多看看面经就能找到常问题的问题,对于比较偏问题就算没答上来也不是决定性的影响。\n多看面经!!!!!! 不要一直埋头自己学,要看别人问过了哪些常问的问题。 对于实习工作,看的知识点常见的问题一定要全!!!!!,不是那么精问题不大,一定要全,一定要全!!!! 对于自己不会的,尽量多的说!!!! 实在不行,就往别的地方说!!!总之是引导面试官往自己会的地方上说。 面试中的笔试和前面的笔试风格不同,面试笔试题目不太难,但是考察是冷静思考,代码优雅,没有 bug,先思考清楚!!!在写!!! 在描述项目的难点的时候,不要去聊文档调研是难点,回答这部分问题更应该是技术上的难点,最后通过了什么技术解决了这个问题,这部分技术可以让面试官来更多提问以便知道自己的技术能力。 "},{"id":666,"href":"/zh/docs/technology/Interview/high-quality-technical-articles/work/get-into-work-mode-quickly-when-you-join-a-company/","title":"新入职一家公司如何快速进入工作状态","section":"Work","content":" 推荐语:强烈建议每一位即将入职/在职的小伙伴看看这篇文章,看完之后可以帮助你少踩很多坑。整篇文章逻辑清晰,内容全面!\n原文地址: https://www.cnblogs.com/hunternet/p/14675348.html\n一年一度的金三银四跳槽大戏即将落幕,相信很多跳槽的小伙伴们已经找到了心仪的工作,即将或已经有了新的开始。\n相信有过跳槽经验的小伙伴们都知道,每到一个新的公司面临的可能都是新的业务、新的技术、新的团队……这些可能会打破你原来工作思维、编码习惯、合作方式……\n而于公司而言,又不能给你几个月的时间去慢慢的熟悉。这个时候,如何快速进入工作状态,尽快发挥自己的价值是非常重要的。\n有些人可能会很幸运,入职的公司会有完善的流程与机制,通过一带一、各种培训等方式可以在短时间内快速的让新人进入工作状态。有些人可能就没有那么幸运了,就比如我在几年前跳槽进入某厂的时候,当时还没有像我们现在这么完善的带新人融入的机制,又赶上团队最忙的一段时间,刚一入职的当天下午就让给了我几个线上问题去排查,也没有任何的文档和培训。遇到情况,很多人可能会因为难以快速适应,最终承受不起压力而萌生退意。\n那么,我们应该如何去快速的让自己进入工作状态,适应新的工作节奏呢?\n新的工作面对着一堆的代码仓库,很多人常常感觉无从下手。但回顾一下自己过往的工作与项目的经验,我们可以发现它们有着异曲同工之处。当开始一个新的项目,一般会经历几个步骤:需求-\u0026gt;设计-\u0026gt;开发-\u0026gt;测试-\u0026gt;发布,就这么循环往复,我们完成了一个又一个的项目。\n而在这个过程中主要有四个方面的知识那就是业务、技术、项目与团队贯穿始终。新入职一家公司,我们第一阶段的目标就是要具备能够跟着团队做项目的能力,因此我们所应尽快掌握的知识点也要从这四个方面入手。\n业务 # 很多人可能会认为作为一个技术人,最应该了解的不应该是技术吗?于是他们在进入一家公司后,就迫不及待的研究起来了一些技术文档,系统架构,甚至抱起来源代码就开始“啃”,如果你也是这么做的,那就大错特错了!在几乎所有的公司里,技术都是作为一个工具存在的,虽然它很重要,但是它也是为了承载业务所存在的,技术解决了如何做的问题,而业务却告诉我们,做什么,为什么做。一旦脱离了业务,那么技术的存在将毫无意义。\n想要了解业务,有两个非常重要的方式\n一是靠问\n如果你加入的团队,有着完善的业务培训机制,详尽的需求文档,也许你不需要过多的询问就可以了解业务,但这只是理想中的情况,大多数公司是没有这个条件的。因此我们只能靠问。\n这里不得不提的是,作为一个新人一定要有一定的脸皮厚度,不懂就要问。我见过很多新人会因为内向、腼腆,遇到疑问总是不好意思去问,这导致他们很长一段时间都难以融入团队、承担更重要的责任。不怕要怕挨训、怕被怼,而且我相信绝对多数的程序员还是很好沟通的!\n二是靠测试\n我认为测试绝对是一个人快速了解团队业务的方式。通过测试我们可以走一走自己团队所负责项目的整体流程,如果遇到自己走不下去或想不通的地方及时去问,在这个过程中我们自然而然的就可以快速的了解到核心的业务流程。\n在了解业务的过程中,我们应该注意的是不要让自己过多的去追求细节,我们的目的是先能够整体了解业务流程,我们面向哪些用户,提供了哪些服务……\n技术 # 在我们初步了解完业务之后,就该到技术了,也许你已经按捺不住翻开源代码的准备了,但还是要先提醒你一句先不要着急。\n这个时候我们应该先按照自己了解到的业务,结合自己过往的工作经验去思考一下如果是自己去实现这个系统,应该如何去做?这一步很重要,它可以在后面我们具体去了解系统的技术实现的时候去对比一下与自己的实现思路有哪些差异,为什么会有这些差异,哪些更好,哪些不好,对于不好我们可以提出自己的意见,对于更好的我们可以吸收学习为己用!\n接下来,我们就是要了解技术了,但也不是一上来就去翻源代码。 应该按照从宏观到细节,由外而内逐步地对系统进行分析。\n首先,我们应该简单的了解一下 自己团队/项目的所用到的技术栈 ,Java 还是.NET、亦或是多种语言并存,项目是前后端分离还是服务端全包,使用的数据库是 MySQL 还是 PostgreSQL……,这样我们可能会对所用到的技术和框架,以及自己所负责的内容有一定的预期,这一点有的人可能在面试的时候就会简单了解过。\n下一步,我们应该了解的是 系统的宏观业务架构 。自己的团队主要负责哪些系统,每个系统又主要包含哪些模块,又与哪些外部系统进行交互……对于这些,最好可以通过流程图或者思维导图等方式整理出来。\n然后,我们要做的是看一下 自己的团队提供了哪些对外的接口或者服务 。每个接口和服务所提供功能是什么。这一点我们可以继续去测试自己的系统,这个时候我们要看一看主要流程中主要包含了哪些页面,每个页面又调用了后端的哪些接口,每个后端接口又对应着哪个代码仓库。(如果是单纯做后端服务的,可以看一下我们提供了哪些服务,又有哪些上游服务,每个上游服务调用自己团队的哪些服务……),同样我们应该用画图的形式整理出来。\n接着,我们要了解一下 自己的系统或服务又依赖了哪些外部服务 ,也就是说需要哪些外部系统的支持,这些服务也许是团队之外、公司之外,也可能是其他公司提供的。这个时候我们可以简单的进入代码看一下与外部系统的交互是怎么做的,包括通讯框架(REST、RPC)、通讯协议……\n到了代码层面,我们首先应该了解每个模块代码的层次结构,一个模块分了多少层,每个层次的职责是什么,了解了这个就对系统的整个设计有了初步的概念,紧接着就是代码的目录结构、配置文件的位置。\n最后,我们可以寻找一个示例,可以是一个接口,一个页面,让我们的思路跟随者代码的运行的路线,从入参到出参,完整的走一遍来验证一下我们之前的了解。\n到了这里我们对于技术层面的了解就可以先告一段落了,我们的目的知识对系统有一个初步的认知,更细节的东西,后面我们会有大把的时间去了解\n项目与团队 # 上面我们提到,新入职一家公司,第一阶段的目标是有跟着团队做项目的能力,接下来我们要了解的就是项目是如何运作的。\n我们应该把握从需求设计到代码编写入库最终到发布上线的整个过程中的一些关键点。例如项目采用敏捷还是瀑布的模式,一个迭代周期是多长,需求的来源以及展现形式,有没有需求评审,代码的编写规范是什么,编写完成后如何构建,如何入库,有没有提交规范,如何交付测试,发布前的准备是什么,发布工具如何使用……\n关于项目我们只需要观察同事,或者自己亲身经历一个迭代的开发,就能够大概了解清楚。\n在了解项目运作的同时,我们还应该去了解团队,同样我们应该先从外部开始,我们对接了哪些外部团队,比如需求从哪里来,是否对接公司外部的团队,提供服务的上游团队有哪些,依赖的下游团队有哪些,团队之间如何沟通,常用的沟通方式是什么……\n接下来则是团队内部,团队中有哪些角色,每个人的职责是什么,这样遇到问题我们也可以清楚的找到对应的同事寻求帮助。是否有一些定期的活动与会议,例如每日站会、周例会,是否有一些约定俗成的规矩,是否有一些内部评审,分享机制……\n总结 # 新入职一家公司,面临新的工作挑战,能够尽快进入工作状态,实现自己的价值,将会给你带来一个好的开始。\n作为一个程序员,能够尽快进入工作状态,意味着我们首先应该具备跟着团队做项目的能力,这里我站在了一个后端开发的角度上从业务、技术、项目与团队四个方面总结了一些方法和经验。\n关于如何快速进入工作状态,如果你有好的方法与建议,欢迎在评论区留言。\n最后我们用一张思维导图来回顾一下这篇文章的内容。如果你觉得这篇文章对你有所帮助,可以关注文末公众号,我会经常分享一些自己成长过程中的经验与心得,与大家一起学习与进步。\n"},{"id":667,"href":"/zh/docs/technology/Interview/high-availability/performance-test/","title":"性能测试入门","section":"High Availability","content":"性能测试一般情况下都是由测试这个职位去做的,那还需要我们开发学这个干嘛呢?了解性能测试的指标、分类以及工具等知识有助于我们更好地去写出性能更好的程序,另外作为开发这个角色,如果你会性能测试的话,相信也会为你的履历加分不少。\n这篇文章是我会结合自己的实际经历以及在测试这里取的经所得,除此之外,我还借鉴了一些优秀书籍,希望对你有帮助。\n不同角色看网站性能 # 用户 # 当用户打开一个网站的时候,最关注的是什么?当然是网站响应速度的快慢。比如我们点击了淘宝的主页,淘宝需要多久将首页的内容呈现在我的面前,我点击了提交订单按钮需要多久返回结果等等。\n所以,用户在体验我们系统的时候往往根据你的响应速度的快慢来评判你的网站的性能。\n开发人员 # 用户与开发人员都关注速度,这个速度实际上就是我们的系统处理用户请求的速度。\n开发人员一般情况下很难直观的去评判自己网站的性能,我们往往会根据网站当前的架构以及基础设施情况给一个大概的值,比如:\n项目架构是分布式的吗? 用到了缓存和消息队列没有? 高并发的业务有没有特殊处理? 数据库设计是否合理? 系统用到的算法是否还需要优化? 系统是否存在内存泄露的问题? 项目使用的 Redis 缓存多大?服务器性能如何?用的是机械硬盘还是固态硬盘? …… 测试人员 # 测试人员一般会根据性能测试工具来测试,然后一般会做出一个表格。这个表格可能会涵盖下面这些重要的内容:\n响应时间; 请求成功率; 吞吐量; …… 运维人员 # 运维人员会倾向于根据基础设施和资源的利用率来判断网站的性能,比如我们的服务器资源使用是否合理、数据库资源是否存在滥用的情况、当然,这是传统的运维人员,现在 Devops 火起来后,单纯干运维的很少了。我们这里暂且还保留有这个角色。\n性能测试需要注意的点 # 几乎没有文章在讲性能测试的时候提到这个问题,大家都会讲如何去性能测试,有哪些性能测试指标这些东西。\n了解系统的业务场景 # 性能测试之前更需要你了解当前的系统的业务场景。 对系统业务了解的不够深刻,我们很容易犯测试方向偏执的错误,从而导致我们忽略了对系统某些更需要性能测试的地方进行测试。比如我们的系统可以为用户提供发送邮件的功能,用户配置成功邮箱后只需输入相应的邮箱之后就能发送,系统每天大概能处理上万次发邮件的请求。很多人看到这个可能就直接开始使用相关工具测试邮箱发送接口,但是,发送邮件这个场景可能不是当前系统的性能瓶颈,这么多人用我们的系统发邮件, 还可能有很多人一起发邮件,单单这个场景就这么人用,那用户管理可能才是性能瓶颈吧!\n历史数据非常有用 # 当前系统所留下的历史数据非常重要,一般情况下,我们可以通过相应的些历史数据初步判定这个系统哪些接口调用的比较多、哪些服务承受的压力最大,这样的话,我们就可以针对这些地方进行更细致的性能测试与分析。\n另外,这些地方也就像这个系统的一个短板一样,优化好了这些地方会为我们的系统带来质的提升。\n常见性能指标 # 响应时间 # 响应时间 RT(Response-time)就是用户发出请求到用户收到系统处理结果所需要的时间。\nRT 是一个非常重要且直观的指标,RT 数值大小直接反应了系统处理用户请求速度的快慢。\n并发数 # 并发数可以简单理解为系统能够同时供多少人访问使用也就是说系统同时能处理的请求数量。\n并发数反应了系统的负载能力。\nQPS 和 TPS # QPS(Query Per Second) :服务器每秒可以执行的查询次数; TPS(Transaction Per Second) :服务器每秒处理的事务数(这里的一个事务可以理解为客户发出请求到收到服务器的过程); 书中是这样描述 QPS 和 TPS 的区别的。\nQPS vs TPS:QPS 基本类似于 TPS,但是不同的是,对于一个页面的一次访问,形成一个 TPS;但一次页面请求,可能产生多次对服务器的请求,服务器对这些请求,就可计入“QPS”之中。如,访问一个页面会请求服务器 2 次,一次访问,产生一个“T”,产生 2 个“Q”。\n吞吐量 # 吞吐量指的是系统单位时间内系统处理的请求数量。\n一个系统的吞吐量与请求对系统的资源消耗等紧密关联。请求对系统资源消耗越多,系统吞吐能力越低,反之则越高。\nTPS、QPS 都是吞吐量的常用量化指标。\nQPS(TPS) = 并发数/平均响应时间(RT) 并发数 = QPS * 平均响应时间(RT) 系统活跃度指标 # PV(Page View) # 访问量, 即页面浏览量或点击量,衡量网站用户访问的网页数量;在一定统计周期内用户每打开或刷新一个页面就记录 1 次,多次打开或刷新同一页面则浏览量累计。UV 从网页打开的数量/刷新的次数的角度来统计的。\nUV(Unique Visitor) # 独立访客,统计 1 天内访问某站点的用户数。1 天内相同访客多次访问网站,只计算为 1 个独立访客。UV 是从用户个体的角度来统计的。\nDAU(Daily Active User) # 日活跃用户数量。\nMAU(monthly active users) # 月活跃用户人数。\n举例:某网站 DAU 为 1200w, 用户日均使用时长 1 小时,RT 为 0.5s,求并发量和 QPS。\n平均并发量 = DAU(1200w)* 日均使用时长(1 小时,3600 秒) /一天的秒数(86400)=1200w/24 = 50w\n真实并发量(考虑到某些时间段使用人数比较少) = DAU(1200w)* 日均使用时长(1 小时,3600 秒) /一天的秒数-访问量比较小的时间段假设为 8 小时(57600)=1200w/16 = 75w\n峰值并发量 = 平均并发量 * 6 = 300w\nQPS = 真实并发量/RT = 75W/0.5=150w/s\n性能测试分类 # 性能测试 # 性能测试方法是通过测试工具模拟用户请求系统,目的主要是为了测试系统的性能是否满足要求。通俗地说,这种方法就是要在特定的运行条件下验证系统的能力状态。\n性能测试是你在对系统性能已经有了解的前提之后进行的,并且有明确的性能指标。\n负载测试 # 对被测试的系统继续加大请求压力,直到服务器的某个资源已经达到饱和了,比如系统的缓存已经不够用了或者系统的响应时间已经不满足要求了。\n负载测试说白点就是测试系统的上限。\n压力测试 # 不去管系统资源的使用情况,对系统继续加大请求压力,直到服务器崩溃无法再继续提供服务。\n稳定性测试 # 模拟真实场景,给系统一定压力,看看业务是否能稳定运行。\n常用性能测试工具 # 后端常用 # 既然系统设计涉及到系统性能方面的问题,那在面试的时候,面试官就很可能会问:你是如何进行性能测试的?\n推荐 4 个比较常用的性能测试工具:\nJmeter :Apache JMeter 是 JAVA 开发的性能测试工具。 LoadRunner:一款商业的性能测试工具。 Galtling :一款基于 Scala 开发的高性能服务器性能测试工具。 ab :全称为 Apache Bench 。Apache 旗下的一款测试工具,非常实用。 没记错的话,除了 LoadRunner 其他几款性能测试工具都是开源免费的。\n前端常用 # Fiddler:抓包工具,它可以修改请求的数据,甚至可以修改服务器返回的数据,功能非常强大,是 Web 调试的利器。 HttpWatch: 可用于录制 HTTP 请求信息的工具。 常见的性能优化策略 # 性能优化之前我们需要对请求经历的各个环节进行分析,排查出可能出现性能瓶颈的地方,定位问题。\n下面是一些性能优化时,我经常拿来自问的一些问题:\n系统是否需要缓存? 系统架构本身是不是就有问题? 系统是否存在死锁的地方? 系统是否存在内存泄漏?(Java 的自动回收内存虽然很方便,但是,有时候代码写的不好真的会造成内存泄漏) 数据库索引使用是否合理? …… "},{"id":668,"href":"/zh/docs/technology/Interview/java/concurrent/virtual-thread/","title":"虚拟线程常见问题总结","section":"Concurrent","content":" 本文部分内容来自 Lorin 的 PR。\n虚拟线程在 Java 21 正式发布,这是一项重量级的更新。\n什么是虚拟线程? # 虚拟线程(Virtual Thread)是 JDK 而不是 OS 实现的轻量级线程(Lightweight Process,LWP),由 JVM 调度。许多虚拟线程共享同一个操作系统线程,虚拟线程的数量可以远大于操作系统线程的数量。\n虚拟线程和平台线程有什么关系? # 在引入虚拟线程之前,java.lang.Thread 包已经支持所谓的平台线程(Platform Thread),也就是没有虚拟线程之前,我们一直使用的线程。JVM 调度程序通过平台线程(载体线程)来管理虚拟线程,一个平台线程可以在不同的时间执行不同的虚拟线程(多个虚拟线程挂载在一个平台线程上),当虚拟线程被阻塞或等待时,平台线程可以切换到执行另一个虚拟线程。\n虚拟线程、平台线程和系统内核线程的关系图如下所示(图源: How to Use Java 19 Virtual Threads):\n关于平台线程和系统内核线程的对应关系多提一点:在 Windows 和 Linux 等主流操作系统中,Java 线程采用的是一对一的线程模型,也就是一个平台线程对应一个系统内核线程。Solaris 系统是一个特例,HotSpot VM 在 Solaris 上支持多对多和一对一。具体可以参考 R 大的回答: JVM 中的线程模型是用户级的么?。\n虚拟线程有什么优点和缺点? # 优点 # 非常轻量级:可以在单个线程中创建成百上千个虚拟线程而不会导致过多的线程创建和上下文切换。 简化异步编程: 虚拟线程可以简化异步编程,使代码更易于理解和维护。它可以将异步代码编写得更像同步代码,避免了回调地狱(Callback Hell)。 减少资源开销: 由于虚拟线程是由 JVM 实现的,它能够更高效地利用底层资源,例如 CPU 和内存。虚拟线程的上下文切换比平台线程更轻量,因此能够更好地支持高并发场景。 缺点 # 不适用于计算密集型任务: 虚拟线程适用于 I/O 密集型任务,但不适用于计算密集型任务,因为密集型计算始终需要 CPU 资源作为支持。 与某些第三方库不兼容: 虽然虚拟线程设计时考虑了与现有代码的兼容性,但某些依赖平台线程特性的第三方库可能不完全兼容虚拟线程。 如何创建虚拟线程? # 官方提供了以下四种方式创建虚拟线程:\n使用 Thread.startVirtualThread() 创建 使用 Thread.ofVirtual() 创建 使用 ThreadFactory 创建 使用 Executors.newVirtualThreadPerTaskExecutor()创建 1、使用 Thread.startVirtualThread() 创建\npublic class VirtualThreadTest { public static void main(String[] args) { CustomThread customThread = new CustomThread(); Thread.startVirtualThread(customThread); } } static class CustomThread implements Runnable { @Override public void run() { System.out.println(\u0026#34;CustomThread run\u0026#34;); } } 2、使用 Thread.ofVirtual() 创建\npublic class VirtualThreadTest { public static void main(String[] args) { CustomThread customThread = new CustomThread(); // 创建不启动 Thread unStarted = Thread.ofVirtual().unstarted(customThread); unStarted.start(); // 创建直接启动 Thread.ofVirtual().start(customThread); } } static class CustomThread implements Runnable { @Override public void run() { System.out.println(\u0026#34;CustomThread run\u0026#34;); } } 3、使用 ThreadFactory 创建\npublic class VirtualThreadTest { public static void main(String[] args) { CustomThread customThread = new CustomThread(); ThreadFactory factory = Thread.ofVirtual().factory(); Thread thread = factory.newThread(customThread); thread.start(); } } static class CustomThread implements Runnable { @Override public void run() { System.out.println(\u0026#34;CustomThread run\u0026#34;); } } 4、使用Executors.newVirtualThreadPerTaskExecutor()创建\npublic class VirtualThreadTest { public static void main(String[] args) { CustomThread customThread = new CustomThread(); ExecutorService executor = Executors.newVirtualThreadPerTaskExecutor(); executor.submit(customThread); } } static class CustomThread implements Runnable { @Override public void run() { System.out.println(\u0026#34;CustomThread run\u0026#34;); } } 虚拟线程和平台线程性能对比 # 通过多线程和虚拟线程的方式处理相同的任务,对比创建的系统线程数和处理耗时。\n说明:统计创建的系统线程中部分为后台线程(比如 GC 线程),两种场景下都一样,所以并不影响对比。\n测试代码:\npublic class VirtualThreadTest { static List\u0026lt;Integer\u0026gt; list = new ArrayList\u0026lt;\u0026gt;(); public static void main(String[] args) { // 开启线程 统计平台线程数 ScheduledExecutorService scheduledExecutorService = Executors.newScheduledThreadPool(1); scheduledExecutorService.scheduleAtFixedRate(() -\u0026gt; { ThreadMXBean threadBean = ManagementFactory.getThreadMXBean(); ThreadInfo[] threadInfo = threadBean.dumpAllThreads(false, false); updateMaxThreadNum(threadInfo.length); }, 10, 10, TimeUnit.MILLISECONDS); long start = System.currentTimeMillis(); // 虚拟线程 ExecutorService executor = Executors.newVirtualThreadPerTaskExecutor(); // 使用平台线程 // ExecutorService executor = Executors.newFixedThreadPool(200); for (int i = 0; i \u0026lt; 10000; i++) { executor.submit(() -\u0026gt; { try { // 线程睡眠 0.5 s,模拟业务处理 TimeUnit.MILLISECONDS.sleep(500); } catch (InterruptedException ignored) { } }); } executor.close(); System.out.println(\u0026#34;max:\u0026#34; + list.get(0) + \u0026#34; platform thread/os thread\u0026#34;); System.out.printf(\u0026#34;totalMillis:%dms\\n\u0026#34;, System.currentTimeMillis() - start); } // 更新创建的平台最大线程数 private static void updateMaxThreadNum(int num) { if (list.isEmpty()) { list.add(num); } else { Integer integer = list.get(0); if (num \u0026gt; integer) { list.add(0, num); } } } } 请求数 10000 单请求耗时 1s:\n// Virtual Thread max:22 platform thread/os thread totalMillis:1806ms // Platform Thread 线程数200 max:209 platform thread/os thread totalMillis:50578ms // Platform Thread 线程数500 max:509 platform thread/os thread totalMillis:20254ms // Platform Thread 线程数1000 max:1009 platform thread/os thread totalMillis:10214ms // Platform Thread 线程数2000 max:2009 platform thread/os thread totalMillis:5358ms 请求数 10000 单请求耗时 0.5s:\n// Virtual Thread max:22 platform thread/os thread totalMillis:1316ms // Platform Thread 线程数200 max:209 platform thread/os thread totalMillis:25619ms // Platform Thread 线程数500 max:509 platform thread/os thread totalMillis:10277ms // Platform Thread 线程数1000 max:1009 platform thread/os thread totalMillis:5197ms // Platform Thread 线程数2000 max:2009 platform thread/os thread totalMillis:2865ms 可以看到在密集 IO 的场景下,需要创建大量的平台线程异步处理才能达到虚拟线程的处理速度。 因此,在密集 IO 的场景,虚拟线程可以大幅提高线程的执行效率,减少线程资源的创建以及上下文切换。 注意:有段时间 JDK 一直致力于 Reactor 响应式编程来提高 Java 性能,但响应式编程难以理解、调试、使用,最终又回到了同步编程,最终虚拟线程诞生。\n虚拟线程的底层原理是什么? # 如果你想要详细了解虚拟线程实现原理,推荐一篇文章: 虚拟线程 - VirtualThread 源码透视。\n面试一般是不会问到这个问题的,仅供学有余力的同学进一步研究学习。\n"},{"id":669,"href":"/zh/docs/technology/Interview/high-quality-technical-articles/personal-experience/8-years-programmer-work-summary/","title":"一个中科大差生的 8 年程序员工作总结","section":"Personal Experience","content":" 推荐语:这篇文章讲述了一位中科大的朋友 8 年的经历:从 2013 年毕业之后加入上海航天 x 院某卫星研究所,再到入职华为,从华为离职。除了丰富的经历之外,作者在文章还给出了很多自己对于工作/生活的思考。我觉得非常受用!我在这里,向这位作者表达一下衷心的感谢。\n原文地址: https://www.cnblogs.com/scada/p/14259332.html\n前言 # 今年终于从大菊花厂离职了,离职前收入大概 60w 不到吧!在某乎属于比较差的,今天终于有空写一下自己的职场故事,也算是给自己近 8 年的程序员工作做个总结复盘。\n近 8 年有些事情做对了,也有更多事情做错了,在这里记录一下,希望能够给后人一些帮助吧,也欢迎私信交流。文笔不好,见谅,有些细节记不清了,如果有出入,就当是我编的这个故事吧。\nPS:有几个问题先在这里解释一下,评论就不一一回复了\n关于差生,我本人在科大时确实成绩偏下,差生主要讲这一点,没其他意思。 因为买房是我人生中的大事,我认为需要记录和总结一下,本文中会有买房,房价之类的信息出现,您如果对房价,炒房等反感的话,请您停止阅读,并且我再这里为浪费您的时间先道个歉。 2013 年 # 加入上海航天 x 院某卫星研究所 # 本人 86 年生人,13 年从中科大软件相关专业毕业,由于父母均是老师,从小接受的教育就是努力学习,找个稳定的“好工作”,报效国家。\n于是乎,毕业时候头脑一热加入了上海航天 x 院某卫星研究所,没有经过自己认真思考,仅仅听从父母意见,就草率的决定了自己的第一份工作,这也为我 5 年后离职埋下了隐患。这里总结第一条经验:\n如果你的亲人是普通阶层,那对于人生中一些大事来说,他们给的建议往往就是普通阶层的思维,他们的阶层就是他们一生思维决策的结果,如果你的目标是跳出本阶层,那最好只把他们的建议当成参考。\n13 年 4 月份,我坐上火车来到上海,在一路换乘地铁来到了大闵行,出了地铁走路到单位,一路上建筑都比较老旧,我心里想这跟老家也没什么区别嘛,还大上海呢。\n到达单位报道,负责报道的老师很亲切,填写完资料,分配了一间宿舍,还给了大概 3k 左右安家费,当时我心里那个激动啊(乡下孩子没有见过钱啊,见谅),拿了安家费,在附近小超市买好生活用品,这样我就开始了自己航天生涯。\n经过 1 个月集中培训后,我分配到部门,主要负责卫星上嵌入式软件开发。不过说是高大上的卫星软件开发,其实刚开始就是打杂,给实验室、厂房推箱子搬设备,呵呵,说航天是个体力活相信很多航天人都有同感吧。不过当时年轻,心思很单纯,每天搬完设备,晚上主动加班,看文档材料,画软件流程图,编程练习,日子过得很充实。\n记得第一个月到手大概 5k 左右(好少呀),当时很多一起入职的同事抱怨,我没有,我甚至不太愿意和他们比较工资,这里总结第二条经验:\n不要和你的同事比工资,没有意义,比工资总会有人受伤,更多的是负面影响,并且很多时候受伤的会是你。\n工作中暂露头角 # 工作大概一个月的时候,我遇到了一件事情,让我从新员工里面开始暂露头角。事情是这样的当时国家要对军工单位进行 GJB5000A 软件开发等级认证(搞过这个认证的同学应该都知道,过这个认证那是要多酸爽有多酸爽),但是当时一个负责配置管理的同事却提出离职,原因是他考上了公务员,当时我们用的那个软件平台后台的版本控制是 SVN 实现的,恰好我在学校写程序时用过,呵呵,话说回来现在学生自己写软件很少有人会在本地搭版本控制器吧!我记得当时还被同学嘲笑过,这让我想起了乔布斯学习美术字的故事,这里总结一下:\n不要说一项技能没有用,任何你掌握的技能都有价值,但是你要学会找到发挥它的场景。如果有一天你落水了,你可能会很庆幸,自己以前学会了游泳。\n工作中如果要上升,你要勇于承担麻烦的、有挑战的任务,当你推掉麻烦的时候,你也推掉了机遇。\n好了,扯远了,回到前面,当时我主动跟单位认证负责人提出,我可以帮忙负责这方面的工作,我有一定经验。这里要提一下这个负责人,是位女士,她是我非常敬佩的一个前辈,认真,负责,无私,整个人为国家的航天事业奉献了几十年,其实航天领域有非常多这样的老前辈,他们默默奋斗,拿着不高的薪水,为祖国的国防建设做出了巨大的贡献。当时这位负责人,看我平时工作认真积极,思维反应也比较灵活(因为过认证需要和认证专家现场答辩的)就同意了我的请求,接受到这个任务之后,我迅速投入,学习认证流程、体系文件、迅速掌握认证工作要点,一点一点把相关的工作做好,同时周期性对业务进行复盘,总结复盘可能是我自己的一个优点:\n很多人喜欢不停的做事,但不会停下来思考,缺乏总结复盘的能力,其实阶段性总结复盘,不仅能够固化前面的经验,也能梳理后面的方向;把事情做对很重要,但是更重要的是做对的事;另外不要贪快,方向正确慢就是快(后半句是我后来才想明白的,如果早想明白,就不会混成目前这样了)\n1 个月后,当时有惊无险通过了当年的认证,当时负责人主动向单位申请了 2k 特别奖,当时我真的非常高兴,主要是自己的工作产生了价值,得到了认可。后来几个月的日子平淡无奇,有印象的好像只有两件事情。\n一件事情是当年端午,当时我们在单位的宿舍休息,突然楼道上一阵骚动,我打开宿舍门一看,原来是书记来慰问,还给每个人送了一箱消暑饮料,这件事印象比较深刻,是我觉得国企虽然有各种各样的问题,但是论人文关怀,还是国企要好得多。\n错失一次暴富的机会 # 另一件事是当年室友刚买房,然后天天研究生财\u0026amp;之道,一会劝我买房,一会劝我买比\u0026amp;特\u0026amp;币,我当时没有鸟他,为什么呢,因为当时的室友生活习惯不太好,会躺在床上抽烟,还在宿舍内做饭(我们宿舍是那种很老的单位房,通风不好),我有鼻炎,所以不是很喜欢他(嗯,这里要向室友道歉,当年真是太幼稚了)。现在 B\u0026amp;T\u0026amp;C4 万美元了,我当时要是听了室友也能小发一笔了(其实我后来 18 年买了,但是没有拿住这是后话),这里要总结一下:\n不要因为某人的外在,如外貌、习惯、学历等对人贴上标签,去盲目否定别人,对于别人的建议,应该从客观出发,综合分析,从善如流是一项非常难得的品质。\n人很难挣到他认知之外的财富,就算偶然拿到了,也可能很快失去。所以不要羡慕别人投机获得的财富,努力提升你的思维,财商才是正道。\n航天生涯的第一个正式项目 # 转眼到了 9 月份(我 4 月份入职的),我迎来了我航天生涯第一个正式的型号项目(型号,是军工的术语,就相当于某个产品系列比如华为的 mate),当时分配给我的型号正式启动,我终于可以开始写卫星上的代码了。\n当时真的是心无旁骛,一心投身军工码农事业,每天实验室,测试厂房,评审会,日子虽然忙碌,但是也算充实。并且由于我的努力工作,加上还算可以的技术水平,我很快就能独立胜任一些型号基础性的工作了,并且我的努力也受到了型号(产品)线的领导的认可,他们开始计划让我担任型号主管设计师,这是一般工作 1-2 年的员工的岗位,当时还是有的激动的。\n2014 年 # 升任主管设计师后的一次波折 # 转眼间到 2014 年了,大概上半年吧,我正式升任主管设计师,研发工作上也开时独挡一面了,但是没多久产品研发就给了我当头一棒。\n事情是这样的,当时有一个版本软件编写完毕,加载到整星上进行测试,有一天大领导来检查,当时非常巧,领导来时测试主岗按某个岗位的人员要求,发送了一串平时整星没有使用的命令(我在实验室是验证过的),结果我的软件立刻崩溃,无法运行。由于正好领导视察,这个问题立马被上报到质量处,于是我开始了苦逼的技术归零攻关(搞航天的都懂,我就不解释了)。\n期间每天都有 3 个以上领导,询问进度,当时作为新人的我压力可想而知,可是我无论如何都查不出来问题,在实验室我的软件完全正常!后来,某天中午我突然想到,整星上可能有不同的环境,具体过程就不说了。后人查出来是一个负责加载我软件的第三方软件没有受控,非法篡改了我程序的 4 个字节,而这 4 字节正好是那天发送命令才会执行的代码,结果导致我的软件崩溃。最后我花了进一个月完成了所有质量归零报告,技术分析报告,当然负责技术的领导没有责怪,相反的还更加看重我了,后来我每遇到一个质量问题,无论多忙最后定要写一份总结分析报告,这成了我一个技术习惯,也为后来我升任软件开发组长奠定了技术影响基础。\n强烈建议技术团队定期开展质量回溯,需要文档化,还要当面讲解,深入的技术回溯有助于增加团队技术交流活跃度,同时提升团队技术积淀,是提升产品质量,打造优秀团队的有效方法。\n个人的话建议养成写技术总结文章的习惯,这不仅能提升个人技术,同时分享也可以增加你的影响力\n职场软技能的重新认识 # 上半年就在忙碌中度过了,到了年底,发生了一件对我们组影响很大的事情,年底单位开展优秀小组评比, 其实这个很多公司都有,那为什么说对我们组影响很大内,这里我先卖关子。这里先不得不提一个人,是个女孩子,南京大学的,比我晚来一年,她做事积极,反应灵敏,还做得一手不错的 PPT,非常优秀,就是黑了点(希望她看到了不要来找我,呵呵)。\n当时单位开展优秀小组评比,我们当时是新员工,什么都很新鲜,就想参加一下,当时领导说我们每年都参加的,我们问,我们每年做不少东西,怎么没有看到过评比的奖状,领导有点不好意思,说我们没有进过决赛。我们又问,多少名可以进入决赛圈,答曰前 27 名即可(总共好像 50+个组)我们当时心里真是一万个羊驼跑过。。。。\n其实当时我们组每年是做不少事情的,我们觉得我们不应该排名如此之低,于是我们几个年轻人开始策划,先是对我们的办公室彻底改造(因为要现场先打分,然后进决赛),然后好好梳理了我们当年取得的成绩,现场评比时我自告奋勇进行答辩(我沟通表达能力还不错,这也算我一个优势吧),后面在加上前文提到的女孩子做的漂亮 PPT,最后我们组拿到了铜牌班组的好成绩,我也因为这次答辩的优秀表现在领导那里又得到了认可,写了大半段了,再总结一下:\n职场软技能如自我展示很重要,特别是程序员,往往在这方面是个弱项,如果可以的话,可以通过练习,培训强化一下这些软技能,对职场的中后期非常有帮助。\n2015 年 # 时间总是过得很快,一下就到 2015 年了,这一年发生了一件对我影响很大的事情。\n升任小组副组长 # 当时我们小组有 18 个人了,有一天部门开会,主任要求大家匿名投票选副组长(当时部门领导还是很民主的),因为日常事务逐渐增多,老组长精力有限,想把一些事物分担出来,当天选举结果就出来了,我由于前面的技术积累和沟通表达能力的展现,居然升任副组长,当时即有些意外,因为总是有传言说国企没背景一辈子就是最最底层,后来我仔细思考过,下面是我不成熟的想法:\n不要总觉得国企事业单位的人都是拼背景,拼关系,我承认存在关系户,但是不要把关系户和低能力挂钩,背景只是一个放大器,当关系户做出了成绩时它会正面放大影响,当关系户做了不光彩的事情是,它也会让影响更坏。没有背景,你可以作出更大的贡献来达到自己的目标,你奋斗的过程是更大的财富。另外,我遇到的关系户能力都很强,也可能是巧合,也可能是他们的父辈给给他们在经验层次上比我们更优秀的教育。\n学习团队管理技巧 # 升任副组长后,我的工作更加忙碌了,不仅要做自己项目的事情,还要横向管理和协调组内其他项目的事情,有人说要多体谅军工单位的基层班组长,这话真是没错啊。这个时候我开始学习一些管理技巧,如何凝聚团队,如何统一协调资源等等,这段时间我还是在不断成长。不过记得当年还是犯了一个很大的方向性错误,虽然更多的原因可能归结为体制吧,但是当时其实可以在力所能及的范围内做一些事情的。\n具体是这样的,当时管理上有项目线,有行政线,就是很常见的矩阵式管理体系,不过在这个特殊的体制下面出现了一些问题。当时部门一把手被上级强制要求不得挂名某个型号,因为他要负责部门资源调配,而下面的我们每个人都归属 1-2 个型号(项目),在更高层的管理上又有横向的行政线(不归属型号),又有纵向的型号管理线。\n而型号的任务往往是第一线的,因为产品还是第一位的,但是个人的绩效、升迁又归属行政线管理,这种形式在能够高效沟通的民企或者外企一般来说不是问题,但是在沟通效率缓慢,还有其他掣肘因素的国企最终导致组内每个人忙于自身的型号任务,各自单打独斗,无法聚焦,一年忙到头最终却得不到部门认可,我也因为要两面管理疲于应付,后来曾经反思过,其实可以聚焦精力打造通用平台(虽然这在我们行业很难)部分解决这个问题:\n无论个人还是团队,做事情要聚焦,因为个人和团队资源永远都是有限的,如果集中一个事情都做不好,那分散就更难以成功,但是在聚焦之前要深入思考,往什么方向聚焦才是正确的,只有持续做正确的事情才是最重要的。\n2016 年 # 这一年是我人生的关键一年,发生了很多事情。\n升任小组副组长 # 第一件事情是我正式升任组长,由于副组长的工作经验,在组长的岗位上也做得比较顺利,在保证研发工作的同时,继续带领团队连续获得铜牌以上班组奖励,另外各种认证检查都稳稳当通过,但是就在这个时候,因为年轻,我犯下了一个至今非常后悔的错误。\n大概是这样的,我们部门当时有两个大组,一个是我们的软件研发组,一个是负责系统设计的系统分析组。\n当时两个组的工作界面是系统组下发软件任务书给软件组,软件组依照任务书开发,当时由于历史原因,软件组有不少 10 年以上的老员工,而系统组由于新成立由很多员工工作时间不到 2 年,不知道从什么时候起,也不知道是从哪位人员开始,软件组的不少同事认为自己是给系统组打工的。并且,由于系统组同事工作年限较短,实际设计经验不足,任务书中难免出现遗漏,从而导致实际产品出错,两组同事矛盾不断加深。\n最后,出现了一个爆发:当时系统组主推一项新的平台,虽然这个平台得到了行政线的支持,但是由于军工产品迭代严谨,这个新平台当时没有型号愿意使用,同时平台的部分负责人,居然没有完整的型号经验!由于这个新平台的软件需要软件组实现,但是因为已经形成的偏见,软件同事认为这项工作中自己是为利益既得者打工。\n我当时也因为即负责实际软件开发,又负责部分行政事务,并且年轻思想不成熟,也持有类似的思想。过程中的摩擦、冲突就不说了,最后的结果是系统组、软件组多人辞职,系统组组长离职,部门主任离职创业(当然他们辞职不全是这个原因,包括我离职也不全是这个原因,但是我相信这件事情有一定的影响),这件事情我非常后悔,后来反思过其实当时自己应该站出来,协调两组矛盾,全力支持部门技术升级,可能最终就不会有那么多优秀的同事离开了。\n公司战略的转型,技术的升级迭代,一定会伴随着阵痛,作为基层组织者,应该摒弃个人偏见,带领团队配合部门、公司主战略,主战略的成功才是团队成功的前提。\n买房 # 16 年我第二件大事情就是买房,关注过近几年房价的人都可能还记得,16 年一线城市猛涨的情景。其实当时 15 年底,上海市中心和学区房已经开始上涨,我 15 年底听同事开始讨论上涨的房价,我心里开始有了买房的打算,大约 16 春节(2 月份吧,具体记不得了),我回老家探望父母,同时跟他们提出了买房的打算。\n我的父亲是一个“央视新闻爱好者”,爱好看狼咸平,XX 刀,XX 檀的节目,大家懂了吧,父亲说上海房价太高了,都是泡沫,不要买。这个时候我已经不是菜鸟了,我想起我总结的第一条经验(见上文),我开始收集往年的房价数据,中央历年的房价政策,在复盘 15 年的经济政策时我发现,当年有 5 次降息降准,提升公积金贷款额度,放松贷款要求于是我判定房价一定会继续涨,涨到一个幅度各地才会出台各种限购政策,并且房价在城市中是按内环往外涨的于是我开始第一次在人生大事上反对父母,我坚决表态要买房。父亲还是不太同意,他说年底吧,先看看情况(实际是年底母亲的退休公积金可以拿出来大概十几万吧,另外未来丈母娘的公积金也能拿出来了大概比这多些)。我还是不同意,父亲最终拗不过我,终于松口,于是我们拿着双方家庭凑的 50w 现金开始买房,后来上海的房价大家都看到了。这件事也是我做的不多的正确的事情之一。\n但是最可笑的是,我研究房价的同时居然犯下了一个匪夷所思的错误,我居然没有研究买房子最重要的因素是什么,我们当时一心想买一手房(现在想想真是脑子进水),最后买了一套松江区交通不便的房子,这第一套房子的地理位置也为我后来第二次离职埋下了隐患,这个后面会说。\n一线或者准一线城市能买尽量买,不要听信房产崩溃论,如果买不起,那可以在有潜力的城市群里用父母的名义先买一套,毕竟大多数人的财富其实是涨不过通货膨胀的。另外买房最重要的三个要素是,地段,地段,地段。\n买房的那天上午和女朋友领的证,话说当时居然把身份证写错了三次 。。。\n这下我终于算是有个家了,交完首付那个时候身上真的是身无分文了。航天的基层员工的收入真的是不高,我记得我当时作为组长,每月到手大概也就 7k-8k 的样子,另外有少量的奖金,但是总数仍然不高,好在公积金比较多,我日常也没什么消费欲望,房贷到是压力不大。\n买完房子之后,我心里想,这下真的是把双方家庭都掏空了(我们双方家庭都比较普通,我的收入也在知乎垫底,没办法)万一有个意外怎么办,我思来想去,于是在我下一个月发工资之后,做了一个我至今也不知道是对是错的举动,我利用当月的工资,给全家人家人买了保险保险,各种重疾,意外都配好了。但是为什么我至今也不知道对错呢,因为后来老丈人,我母亲都遭遇病魔,但是两次保险公司都拒赔,找出的理由我真是哑口无言,谁叫我近视呢。另外真的是要感谢国家,亲人重病之后,最终还是走了医保,赔偿了部分,不然真的是一笔不小的负担。\n2017 年 # 对我人生重大影响的 2016 年,在历史的长河中终究连浪花都激不起来。历史长河静静流淌到了 2017 年,这一年我参加了中国深空探测项目,当然后面我没有等到天问一号发射就离开了航天,但是有时候仰望星空的时候,想想我的代码正在遥远的星空发挥作用,心里也挺感慨的,我也算是重大历史的参与者了,呵呵。好了不说工作了,平淡无奇的 2017 年,对我来说也发生了两件大事。\n买了第二套房子 # 第一件事是我买了第二套房子,说来可笑,当年第一套房子都是掏空家里,这第二年就买了第二套房子,生活真的是难以捉摸。到 2017 年时,前文说道,我母亲和丈母娘先后退休,公积金提取出来了,然后在双方家里各自办了酒席,酒席之后,双方父母都把所有礼金给了我们,父母对自己的孩子真的是无私之至。当时我们除了月光之外,其实没有什么外债,就是生活简单点。拿到这笔钱后,我们就在想如何使用,一天我在菜市场买菜,有人给我一张 xuanchuan 页,本来对于这样的 xuanchuan 页我一般是直接扔掉的,但是当天鬼死神差我看了一眼,只见上面写着“嘉善高铁房,紧邻上海 1.5w”我当时就石化了,我记得去年我研究上海房价的时候,曾经在网站上看到过嘉善的房价,我清楚的记得是 5-6k,我突然意识到我是不是错过了什么机会,反思一下:\n工作生活中尽量保持好奇心,不要对什么的持怀疑态度,很多机会就隐藏在不起眼的细节中,比如二十年前有人告诉你未来可以在网上购物,有人告诉你未来可以用手机支付,你先别把他直接归为骗子,静下来想一想,凡事要有好奇心,但是要有自己的判断。\n于是我立马飞奔回家,开始分析,大城市周边的房价。我分析了昆山,燕郊,东莞,我发现燕郊极其特殊,几乎没有产业,纯粹是承接大城市人口溢出,因此房价成高度波动。而昆山和东莞,由于自身有产业支撑,又紧邻大城市,因此房价稳定上涨。我和妻子一商量,开始了外地看房之旅,后来我们去了嘉善,觉得没有产业支撑,昆山限购,我们又到嘉兴看房,我发现嘉兴房价也涨了很多,但是这里购房的大多数新房,都是上海购房者,入住率比较低,很多都是打算买给父母住的,但是实际情况是父母几乎不在里面住,我觉得这里买房不妥,存在一个变现的问题。于是我开始继续寻找,一天我看着杭州湾的地图,突然想到,杭州湾北侧不行,那南侧呢?南侧绍兴,宁波经济不是更达吗。于是我们目光投向绍兴,看了一个月后,最后在绍兴紧贴杭州的一个区,购买了一套小房子,后来 17 年房价果然如我预料的那样完成中心城市的上涨之后开始带动三四线城市上涨。后来国家出台了大湾区政策,我对我的小房子更有信心了。这里稍微总结一下我个人不成熟的看法:\n在稳定通胀的时代,负债其实是一种财富。长三角城市群会未来强于珠港澳,因为香港和澳门和深圳存在竞争关系,而长三角城市间更多的是互补,未来我们看澳门可能就跟看一个中等省会城市一样了。\n准备要孩子 # 2017 年的第二件事是,我们终于准备要孩子了,但是妻子怎么也备孕不成功,我们开始频繁的去医院,从 10 元挂号费的普通门诊,看到 200 元,300 元挂号费的专家门诊,看到 600 元的特需门诊,从综合医院看到妇幼医院,从西医看到中医,每个周末不是在医院排队,就是在去医院的路上。最后的诊疗结果是有一定的希望,但是有困难,得到消息时我真的感觉眼前一片黑暗,这种从来在新闻上才能看到了事情居然落到了我们头上,我们甚至开始接触地下 XX 市场。同时越来越高的医疗开销(专家门诊以上就不能报销了)也开始成为了我的负担,前文说了,我收入一直不高,又还贷款,又支付医疗开支渐渐的开始捉襟见肘,我甚至动了卖小房子的打算。\n2018 年 # 前面说到,2017 年开始频繁出入医院,同时项目也越来越忙,我渐渐的开始喘不过气起来,最后医生也给了结论,需要做手术,手术有不小的失败的几率。我和妻子商量后一咬牙做吧,如果失败就走地下的路子,但是可能需要准备一笔钱(手术如果成功倒是花销不会太大),哎,古人说一分钱难倒英雄汉,真是诚不欺我啊,这个时候我已经开始萌生离职的想法了。怎么办呢,生活还是要继续,我想起了经常来单位办理贷款的银行人员,贷款吧,这种事情保险公司肯定不赔的嘛,于是我办理了一笔贷款,准备应急。\n项目结束,离职 # 时间慢慢的时间走到了 8 月份,我的项目已经告一定段落,一颗卫星圆满发射成功,深空项目也通过了初样阶段我的第一份工作也算有始有终了。我开始在网上投递简历,我技术还算可以,沟通交流也不错,面试很顺利,一个月就拿到了 6 个 offer,其中就有大菊花厂的 offer,定级 16A,25k 月薪后来政策改革加了绩效工资 6k(其实我定级和总薪水还是有些偏低了和我是国企,本来总薪水就低有很大关系,话说菊花厂级别后面真的是注水严重,博士入职轻松 17 级)菊花厂的 offer 审批流程是我见过最长,我当时的接口人天天催于流程都走了近 2 个月。我向领导提出了离职,离职的过程很痛苦,有过经历的人估计都知道,这里就不说了。话说我为什么会选择华为呢,一是当时急需钱,二是总觉得搞嵌入式的不到华为看看真的是人生遗憾。现在想想没有认真去理解公司的企业文化就进入一家公司还是太草率了:\n如果你不认同一个公司的企业文化,你大概率干不长,干不到中高层,IT 人你不及时突破到中高层很快你就会面临非常多问题;公司招人主要有两种人,一种是合格的人,一种是合适的人,合格的人是指技能合格,合适的人是指认同文化。企业招人就是先把合格的人找进来,然后通过日日宣讲,潜移默化把不合适的人淘汰掉。\n入职华为 # 经过一阵折腾终于离职成功,开始入职华为。离职我做了一件比较疯狂的事情,当时因为手上有一笔现金了,一直在支付利息,心里就像拿它干点啥。那时由于看病,接触了地下 XX 市场,听说了 B\u0026amp;TC,走之前我心一横买 B\u0026amp;T\u0026amp;C,后来不断波动,最终我还是卖了,挣了一些钱,但是最终没有拿到现在,果然是考验人性啊。\n2019 年 # 成功转正 # 华为的试用期真长,整整 6 个月,每个月还有流程跟踪,交流访谈,终于我转正了,转正答辩我不出意料拿到了 Excellent 评价,涨了点薪水,呵呵还不错。华为的事情我不太想说太多,总之我觉得自己没有资格评判这个公司,从公司看公司的角度华为真正是个伟大的公司,任老爷子也是一个值得敬佩的企业家。\n在华为干了半年后,我发现我终究还是入职的时候太草率了,我当时没有具体的了解这个岗位,这个部门。入职之后我发现,我所在的是硬件部门,我在一个硬件部门的软件组,我真是脑子秀逗了。\n在一个部门,你需要尽力进入到部门主航道里,尽力不要在边缘的航道工作,特别是那些节奏快,考核严格的部门。\n更严峻的是我所在的大组,居然是一个分布在全国 4 地的组,大组长(华为叫 LM)在上海,4 地各有一个本地业务负责人。我立刻意识到,到年终考评时,所有的成果一定会是 4 地分配,并且 4 地的负责人会占去一大部分,这是组织结构形成的优势。我所在的小组到时候会难以突破,资源分配会非常激烈。\n备孕成功 # 先不说这些,在 18 年时妻子做完了手术,手术居然很成功。休息完之后我们 19 年初开始备孕了,这次真的是上天保佑,运气不错,很快就怀上了。这段时间,我虽然每天做地铁 1.5 小时到公司上班,经受高强度的工作,我心里每天还是乐滋滋的。但是,突然有一天,PL(华为小组长)根我说,LM 需要派人去杭研所支持工作,我是最合适人选,让我有个心里准备。当时我是不想去的,这个时候妻子是最需要关怀的时候,我想 LM 表达了我的意愿,并且我也知道如果去了杭州年底绩效考评肯定不高。过程不多说了,反正结果是我去了杭州。\n于是我开始了两头奔波的日子,每个月回上海一趟。这过程中还有个插曲,家里老家城中村改造,分了一点钱,父母执意卖掉了老家学校周边的房子,丈母娘也处理老家的一些房子,然后把钱都给了我们,然后我用这笔家里最后的资产,同时利用华为的现金流在绍、甬不限购地区购买一些房子,我没有炒房的想法,只是防止被通货膨胀侵蚀而已,不过后来结果证明我貌似又蒙对了啊,我自己的看法是:\n杭绍甬在经济层面会连成紧密的一片,在行政区上杭州兼并绍 部分区域的概率其实不大,行政区的扩展应该是先兼并自身的下级代管城市。\n宝宝出生 # 不说房子了,继续工作吧。10 月份干了快一年时候,我华为的师傅(华为有师徒培养体系)偷偷告诉我被定为备选 PL 了,虽然不知道真假,但是我心里还是有点小高兴。不过我心里也慢慢意识到这个公司可能不是我真正想要的公司,这么多年了,愚钝如我慢慢也开始知道自己想干什么了。因为我的宝宝出生了,看着这只四脚吞金兽,我意识到自己已经是一个父亲了。\n2019 年随着美国不断升级的制裁消息,我在华为的日子也走到年底,马上将迎来神奇的 2020 年。\n2020 # 2020 就少写一些了,有些东西真的可能忘却更好。\n在家办公 # 年初就给大家来了一个重击,新冠疫情改变了太多的东西。这个时候真的是看出华为的执行力,居家办公也效率不减多少,并且迅速实现了复工。到了 3-4 月份,华为开始正式评议去年绩效等级,我心里开始有预感,以前的分析大概率会兑现,并且绩效和收入挂钩,华为是个风险意识极强的公司,去年的制裁会导致公司开始风险预备,虽然我日常工作还是受到多数人好评,但是我知道这其实在评议人员那里,没有任何意义。果然绩效评议结果出来了,呵呵,我很不满意。绩效沟通时 LM 破例跟我沟通了很长时间,我直接表达了我的想法。LM 承诺钱不会少,呵呵,我不评价吧。后来一天开始组织调整,成立一个新的小组,LM 给我电话让我当组长,我拒绝了,这件事情我不知道对错,我当时是这样考虑的\n升任新的职位,未必是好事,更高的职位意味着更高的要求,因此对备选人员要么在原岗位已经能力有余,要么时间精力有余;我认为当时我这两个都不满足,呵呵离家有点远,LM 很可以只是因为绩效事情做些补偿。 华为不会垮,这点大家有信心,但未来一定会出现战略收缩,最后这艘大船上还剩下哪些人不清楚,底层士兵有可能是牺牲品。 我 34 岁了 提出离职 # 另外我一直思考未来想做什么,已经有了一丝眉目,就这样,我拿了年终奖约 7 月就提出了离职,后来部门还让我做了最后一次贡献,把我硬留到 10 月份,这样就可以参加上半年考核了,让帮忙背了一个 C 呵呵,这是工作多年,最差绩效吧。\n这里还有一个小插曲,最后这三个月我负责什么工作呢,因为 20 年 3 月开始我就接手了部分部门招聘工作(在华为干过的都知道为什么非 HR 也要帮忙招聘,呵呵大坑啊,就不多解释了),结果最后三个月我这个待离职员工居然继续负责招聘,真的是很搞笑,不过由于我在上一份工作中其实一直也有招聘的工作,所以也算做的轻车熟路,每天看 50 份左右简历(我看得都非常仔细,我害怕自己的疏忽会导致一个优秀的人才错失机会,所以比较慢)其实也蛮有收货,最后好歹对程序员如何写简历有了一些心得。\n总结 # 好了 7 年多,近 8 年的职场讲完了,不管过去如何,未来还是要继续努力,希望看到这篇文章觉得有帮助的朋友,可以帮忙点个推荐,这样可能更多的人看到,也许可以避免更多的人犯我犯的错误。另外欢迎私信或者其他方式交流(某 Xin 号,jingyewandeng),可以讨论职场经验,方向,我也可以帮忙改简历(免费啊),不用怕打扰,能帮助别人是一项很有成绩感的事,并且过程中也会有收获,程序员也不要太腼腆呵呵\n"},{"id":670,"href":"/zh/docs/technology/Interview/database/mysql/a-thousand-lines-of-mysql-study-notes/","title":"一千行 MySQL 学习笔记","section":"Mysql","content":" 原文地址: https://shockerli.net/post/1000-line-mysql-note/ ,JavaGuide 对本文进行了简答排版,新增了目录。\n非常不错的总结,强烈建议保存下来,需要的时候看一看。\n基本操作 # /* Windows服务 */ -- 启动 MySQL net start mysql -- 创建Windows服务 sc create mysql binPath= mysqld_bin_path(注意:等号与值之间有空格) /* 连接与断开服务器 */ -- 连接 MySQL mysql -h 地址 -P 端口 -u 用户名 -p 密码 -- 显示哪些线程正在运行 SHOW PROCESSLIST -- 显示系统变量信息 SHOW VARIABLES 数据库操作 # /* 数据库操作 */ -- 查看当前数据库 SELECT DATABASE(); -- 显示当前时间、用户名、数据库版本 SELECT now(), user(), version(); -- 创建库 CREATE DATABASE[ IF NOT EXISTS] 数据库名 数据库选项 数据库选项: CHARACTER SET charset_name COLLATE collation_name -- 查看已有库 SHOW DATABASES[ LIKE \u0026#39;PATTERN\u0026#39;] -- 查看当前库信息 SHOW CREATE DATABASE 数据库名 -- 修改库的选项信息 ALTER DATABASE 库名 选项信息 -- 删除库 DROP DATABASE[ IF EXISTS] 数据库名 同时删除该数据库相关的目录及其目录内容 表的操作 # /* 表的操作 */ -- 创建表 CREATE [TEMPORARY] TABLE[ IF NOT EXISTS] [库名.]表名 ( 表的结构定义 )[ 表选项] 每个字段必须有数据类型 最后一个字段后不能有逗号 TEMPORARY 临时表,会话结束时表自动消失 对于字段的定义: 字段名 数据类型 [NOT NULL | NULL] [DEFAULT default_value] [AUTO_INCREMENT] [UNIQUE [KEY] | [PRIMARY] KEY] [COMMENT \u0026#39;string\u0026#39;] -- 表选项 -- 字符集 CHARSET = charset_name 如果表没有设定,则使用数据库字符集 -- 存储引擎 ENGINE = engine_name 表在管理数据时采用的不同的数据结构,结构不同会导致处理方式、提供的特性操作等不同 常见的引擎:InnoDB MyISAM Memory/Heap BDB Merge Example CSV MaxDB Archive 不同的引擎在保存表的结构和数据时采用不同的方式 MyISAM表文件含义:.frm表定义,.MYD表数据,.MYI表索引 InnoDB表文件含义:.frm表定义,表空间数据和日志文件 SHOW ENGINES -- 显示存储引擎的状态信息 SHOW ENGINE 引擎名 {LOGS|STATUS} -- 显示存储引擎的日志或状态信息 -- 自增起始数 AUTO_INCREMENT = 行数 -- 数据文件目录 DATA DIRECTORY = \u0026#39;目录\u0026#39; -- 索引文件目录 INDEX DIRECTORY = \u0026#39;目录\u0026#39; -- 表注释 COMMENT = \u0026#39;string\u0026#39; -- 分区选项 PARTITION BY ... (详细见手册) -- 查看所有表 SHOW TABLES[ LIKE \u0026#39;pattern\u0026#39;] SHOW TABLES FROM 库名 -- 查看表结构 SHOW CREATE TABLE 表名 (信息更详细) DESC 表名 / DESCRIBE 表名 / EXPLAIN 表名 / SHOW COLUMNS FROM 表名 [LIKE \u0026#39;PATTERN\u0026#39;] SHOW TABLE STATUS [FROM db_name] [LIKE \u0026#39;pattern\u0026#39;] -- 修改表 -- 修改表本身的选项 ALTER TABLE 表名 表的选项 eg: ALTER TABLE 表名 ENGINE=MYISAM; -- 对表进行重命名 RENAME TABLE 原表名 TO 新表名 RENAME TABLE 原表名 TO 库名.表名 (可将表移动到另一个数据库) -- RENAME可以交换两个表名 -- 修改表的字段机构(13.1.2. ALTER TABLE语法) ALTER TABLE 表名 操作名 -- 操作名 ADD[ COLUMN] 字段定义 -- 增加字段 AFTER 字段名 -- 表示增加在该字段名后面 FIRST -- 表示增加在第一个 ADD PRIMARY KEY(字段名) -- 创建主键 ADD UNIQUE [索引名] (字段名)-- 创建唯一索引 ADD INDEX [索引名] (字段名) -- 创建普通索引 DROP[ COLUMN] 字段名 -- 删除字段 MODIFY[ COLUMN] 字段名 字段属性 -- 支持对字段属性进行修改,不能修改字段名(所有原有属性也需写上) CHANGE[ COLUMN] 原字段名 新字段名 字段属性 -- 支持对字段名修改 DROP PRIMARY KEY -- 删除主键(删除主键前需删除其AUTO_INCREMENT属性) DROP INDEX 索引名 -- 删除索引 DROP FOREIGN KEY 外键 -- 删除外键 -- 删除表 DROP TABLE[ IF EXISTS] 表名 ... -- 清空表数据 TRUNCATE [TABLE] 表名 -- 复制表结构 CREATE TABLE 表名 LIKE 要复制的表名 -- 复制表结构和数据 CREATE TABLE 表名 [AS] SELECT * FROM 要复制的表名 -- 检查表是否有错误 CHECK TABLE tbl_name [, tbl_name] ... [option] ... -- 优化表 OPTIMIZE [LOCAL | NO_WRITE_TO_BINLOG] TABLE tbl_name [, tbl_name] ... -- 修复表 REPAIR [LOCAL | NO_WRITE_TO_BINLOG] TABLE tbl_name [, tbl_name] ... [QUICK] [EXTENDED] [USE_FRM] -- 分析表 ANALYZE [LOCAL | NO_WRITE_TO_BINLOG] TABLE tbl_name [, tbl_name] ... 数据操作 # /* 数据操作 */ ------------------ -- 增 INSERT [INTO] 表名 [(字段列表)] VALUES (值列表)[, (值列表), ...] -- 如果要插入的值列表包含所有字段并且顺序一致,则可以省略字段列表。 -- 可同时插入多条数据记录! REPLACE与INSERT类似,唯一的区别是对于匹配的行,现有行(与主键/唯一键比较)的数据会被替换,如果没有现有行,则插入新行。 INSERT [INTO] 表名 SET 字段名=值[, 字段名=值, ...] -- 查 SELECT 字段列表 FROM 表名[ 其他子句] -- 可来自多个表的多个字段 -- 其他子句可以不使用 -- 字段列表可以用*代替,表示所有字段 -- 删 DELETE FROM 表名[ 删除条件子句] 没有条件子句,则会删除全部 -- 改 UPDATE 表名 SET 字段名=新值[, 字段名=新值] [更新条件] 字符集编码 # /* 字符集编码 */ ------------------ -- MySQL、数据库、表、字段均可设置编码 -- 数据编码与客户端编码不需一致 SHOW VARIABLES LIKE \u0026#39;character_set_%\u0026#39; -- 查看所有字符集编码项 character_set_client 客户端向服务器发送数据时使用的编码 character_set_results 服务器端将结果返回给客户端所使用的编码 character_set_connection 连接层编码 SET 变量名 = 变量值 SET character_set_client = gbk; SET character_set_results = gbk; SET character_set_connection = gbk; SET NAMES GBK; -- 相当于完成以上三个设置 -- 校对集 校对集用以排序 SHOW CHARACTER SET [LIKE \u0026#39;pattern\u0026#39;]/SHOW CHARSET [LIKE \u0026#39;pattern\u0026#39;] 查看所有字符集 SHOW COLLATION [LIKE \u0026#39;pattern\u0026#39;] 查看所有校对集 CHARSET 字符集编码 设置字符集编码 COLLATE 校对集编码 设置校对集编码 数据类型(列类型) # /* 数据类型(列类型) */ ------------------ 1. 数值类型 -- a. 整型 ---------- 类型 字节 范围(有符号位) tinyint 1字节 -128 ~ 127 无符号位:0 ~ 255 smallint 2字节 -32768 ~ 32767 mediumint 3字节 -8388608 ~ 8388607 int 4字节 bigint 8字节 int(M) M表示总位数 - 默认存在符号位,unsigned 属性修改 - 显示宽度,如果某个数不够定义字段时设置的位数,则前面以0补填,zerofill 属性修改 例:int(5) 插入一个数\u0026#39;123\u0026#39;,补填后为\u0026#39;00123\u0026#39; - 在满足要求的情况下,越小越好。 - 1表示bool值真,0表示bool值假。MySQL没有布尔类型,通过整型0和1表示。常用tinyint(1)表示布尔型。 -- b. 浮点型 ---------- 类型 字节 范围 float(单精度) 4字节 double(双精度) 8字节 浮点型既支持符号位 unsigned 属性,也支持显示宽度 zerofill 属性。 不同于整型,前后均会补填0. 定义浮点型时,需指定总位数和小数位数。 float(M, D) double(M, D) M表示总位数,D表示小数位数。 M和D的大小会决定浮点数的范围。不同于整型的固定范围。 M既表示总位数(不包括小数点和正负号),也表示显示宽度(所有显示符号均包括)。 支持科学计数法表示。 浮点数表示近似值。 -- c. 定点数 ---------- decimal -- 可变长度 decimal(M, D) M也表示总位数,D表示小数位数。 保存一个精确的数值,不会发生数据的改变,不同于浮点数的四舍五入。 将浮点数转换为字符串来保存,每9位数字保存为4个字节。 2. 字符串类型 -- a. char, varchar ---------- char 定长字符串,速度快,但浪费空间 varchar 变长字符串,速度慢,但节省空间 M表示能存储的最大长度,此长度是字符数,非字节数。 不同的编码,所占用的空间不同。 char,最多255个字符,与编码无关。 varchar,最多65535字符,与编码有关。 一条有效记录最大不能超过65535个字节。 utf8 最大为21844个字符,gbk 最大为32766个字符,latin1 最大为65532个字符 varchar 是变长的,需要利用存储空间保存 varchar 的长度,如果数据小于255个字节,则采用一个字节来保存长度,反之需要两个字节来保存。 varchar 的最大有效长度由最大行大小和使用的字符集确定。 最大有效长度是65532字节,因为在varchar存字符串时,第一个字节是空的,不存在任何数据,然后还需两个字节来存放字符串的长度,所以有效长度是65535-1-2=65532字节。 例:若一个表定义为 CREATE TABLE tb(c1 int, c2 char(30), c3 varchar(N)) charset=utf8; 问N的最大值是多少? 答:(65535-1-2-4-30*3)/3 -- b. blob, text ---------- blob 二进制字符串(字节字符串) tinyblob, blob, mediumblob, longblob text 非二进制字符串(字符字符串) tinytext, text, mediumtext, longtext text 在定义时,不需要定义长度,也不会计算总长度。 text 类型在定义时,不可给default值 -- c. binary, varbinary ---------- 类似于char和varchar,用于保存二进制字符串,也就是保存字节字符串而非字符字符串。 char, varchar, text 对应 binary, varbinary, blob. 3. 日期时间类型 一般用整型保存时间戳,因为PHP可以很方便的将时间戳进行格式化。 datetime 8字节 日期及时间 1000-01-01 00:00:00 到 9999-12-31 23:59:59 date 3字节 日期 1000-01-01 到 9999-12-31 timestamp 4字节 时间戳 19700101000000 到 2038-01-19 03:14:07 time 3字节 时间 -838:59:59 到 838:59:59 year 1字节 年份 1901 - 2155 datetime YYYY-MM-DD hh:mm:ss timestamp YY-MM-DD hh:mm:ss YYYYMMDDhhmmss YYMMDDhhmmss YYYYMMDDhhmmss YYMMDDhhmmss date YYYY-MM-DD YY-MM-DD YYYYMMDD YYMMDD YYYYMMDD YYMMDD time hh:mm:ss hhmmss hhmmss year YYYY YY YYYY YY 4. 枚举和集合 -- 枚举(enum) ---------- enum(val1, val2, val3...) 在已知的值中进行单选。最大数量为65535. 枚举值在保存时,以2个字节的整型(smallint)保存。每个枚举值,按保存的位置顺序,从1开始逐一递增。 表现为字符串类型,存储却是整型。 NULL值的索引是NULL。 空字符串错误值的索引值是0。 -- 集合(set) ---------- set(val1, val2, val3...) create table tab ( gender set(\u0026#39;男\u0026#39;, \u0026#39;女\u0026#39;, \u0026#39;无\u0026#39;) ); insert into tab values (\u0026#39;男, 女\u0026#39;); 最多可以有64个不同的成员。以bigint存储,共8个字节。采取位运算的形式。 当创建表时,SET成员值的尾部空格将自动被删除。 列属性(列约束) # /* 列属性(列约束) */ ------------------ 1. PRIMARY 主键 - 能唯一标识记录的字段,可以作为主键。 - 一个表只能有一个主键。 - 主键具有唯一性。 - 声明字段时,用 primary key 标识。 也可以在字段列表之后声明 例:create table tab ( id int, stu varchar(10), primary key (id)); - 主键字段的值不能为null。 - 主键可以由多个字段共同组成。此时需要在字段列表后声明的方法。 例:create table tab ( id int, stu varchar(10), age int, primary key (stu, age)); 2. UNIQUE 唯一索引(唯一约束) 使得某字段的值也不能重复。 3. NULL 约束 null不是数据类型,是列的一个属性。 表示当前列是否可以为null,表示什么都没有。 null, 允许为空。默认。 not null, 不允许为空。 insert into tab values (null, \u0026#39;val\u0026#39;); -- 此时表示将第一个字段的值设为null, 取决于该字段是否允许为null 4. DEFAULT 默认值属性 当前字段的默认值。 insert into tab values (default, \u0026#39;val\u0026#39;); -- 此时表示强制使用默认值。 create table tab ( add_time timestamp default current_timestamp ); -- 表示将当前时间的时间戳设为默认值。 current_date, current_time 5. AUTO_INCREMENT 自动增长约束 自动增长必须为索引(主键或unique) 只能存在一个字段为自动增长。 默认为1开始自动增长。可以通过表属性 auto_increment = x进行设置,或 alter table tbl auto_increment = x; 6. COMMENT 注释 例:create table tab ( id int ) comment \u0026#39;注释内容\u0026#39;; 7. FOREIGN KEY 外键约束 用于限制主表与从表数据完整性。 alter table t1 add constraint `t1_t2_fk` foreign key (t1_id) references t2(id); -- 将表t1的t1_id外键关联到表t2的id字段。 -- 每个外键都有一个名字,可以通过 constraint 指定 存在外键的表,称之为从表(子表),外键指向的表,称之为主表(父表)。 作用:保持数据一致性,完整性,主要目的是控制存储在外键表(从表)中的数据。 MySQL中,可以对InnoDB引擎使用外键约束: 语法: foreign key (外键字段) references 主表名 (关联字段) [主表记录删除时的动作] [主表记录更新时的动作] 此时需要检测一个从表的外键需要约束为主表的已存在的值。外键在没有关联的情况下,可以设置为null.前提是该外键列,没有not null。 可以不指定主表记录更改或更新时的动作,那么此时主表的操作被拒绝。 如果指定了 on update 或 on delete:在删除或更新时,有如下几个操作可以选择: 1. cascade,级联操作。主表数据被更新(主键值更新),从表也被更新(外键值更新)。主表记录被删除,从表相关记录也被删除。 2. set null,设置为null。主表数据被更新(主键值更新),从表的外键被设置为null。主表记录被删除,从表相关记录外键被设置成null。但注意,要求该外键列,没有not null属性约束。 3. restrict,拒绝父表删除和更新。 注意,外键只被InnoDB存储引擎所支持。其他引擎是不支持的。 建表规范 # /* 建表规范 */ ------------------ -- Normal Format, NF - 每个表保存一个实体信息 - 每个具有一个ID字段作为主键 - ID主键 + 原子表 -- 1NF, 第一范式 字段不能再分,就满足第一范式。 -- 2NF, 第二范式 满足第一范式的前提下,不能出现部分依赖。 消除复合主键就可以避免部分依赖。增加单列关键字。 -- 3NF, 第三范式 满足第二范式的前提下,不能出现传递依赖。 某个字段依赖于主键,而有其他字段依赖于该字段。这就是传递依赖。 将一个实体信息的数据放在一个表内实现。 SELECT # /* SELECT */ ------------------ SELECT [ALL|DISTINCT] select_expr FROM -\u0026gt; WHERE -\u0026gt; GROUP BY [合计函数] -\u0026gt; HAVING -\u0026gt; ORDER BY -\u0026gt; LIMIT a. select_expr -- 可以用 * 表示所有字段。 select * from tb; -- 可以使用表达式(计算公式、函数调用、字段也是个表达式) select stu, 29+25, now() from tb; -- 可以为每个列使用别名。适用于简化列标识,避免多个列标识符重复。 - 使用 as 关键字,也可省略 as. select stu+10 as add10 from tb; b. FROM 子句 用于标识查询来源。 -- 可以为表起别名。使用as关键字。 SELECT * FROM tb1 AS tt, tb2 AS bb; -- from子句后,可以同时出现多个表。 -- 多个表会横向叠加到一起,而数据会形成一个笛卡尔积。 SELECT * FROM tb1, tb2; -- 向优化符提示如何选择索引 USE INDEX、IGNORE INDEX、FORCE INDEX SELECT * FROM table1 USE INDEX (key1,key2) WHERE key1=1 AND key2=2 AND key3=3; SELECT * FROM table1 IGNORE INDEX (key3) WHERE key1=1 AND key2=2 AND key3=3; c. WHERE 子句 -- 从from获得的数据源中进行筛选。 -- 整型1表示真,0表示假。 -- 表达式由运算符和运算数组成。 -- 运算数:变量(字段)、值、函数返回值 -- 运算符: =, \u0026lt;=\u0026gt;, \u0026lt;\u0026gt;, !=, \u0026lt;=, \u0026lt;, \u0026gt;=, \u0026gt;, !, \u0026amp;\u0026amp;, ||, in (not) null, (not) like, (not) in, (not) between and, is (not), and, or, not, xor is/is not 加上true/false/unknown,检验某个值的真假 \u0026lt;=\u0026gt;与\u0026lt;\u0026gt;功能相同,\u0026lt;=\u0026gt;可用于null比较 d. GROUP BY 子句, 分组子句 GROUP BY 字段/别名 [排序方式] 分组后会进行排序。升序:ASC,降序:DESC 以下[合计函数]需配合 GROUP BY 使用: count 返回不同的非NULL值数目 count(*)、count(字段) sum 求和 max 求最大值 min 求最小值 avg 求平均值 group_concat 返回带有来自一个组的连接的非NULL值的字符串结果。组内字符串连接。 e. HAVING 子句,条件子句 与 where 功能、用法相同,执行时机不同。 where 在开始时执行检测数据,对原数据进行过滤。 having 对筛选出的结果再次进行过滤。 having 字段必须是查询出来的,where 字段必须是数据表存在的。 where 不可以使用字段的别名,having 可以。因为执行WHERE代码时,可能尚未确定列值。 where 不可以使用合计函数。一般需用合计函数才会用 having SQL标准要求HAVING必须引用GROUP BY子句中的列或用于合计函数中的列。 f. ORDER BY 子句,排序子句 order by 排序字段/别名 排序方式 [,排序字段/别名 排序方式]... 升序:ASC,降序:DESC 支持多个字段的排序。 g. LIMIT 子句,限制结果数量子句 仅对处理好的结果进行数量限制。将处理好的结果的看作是一个集合,按照记录出现的顺序,索引从0开始。 limit 起始位置, 获取条数 省略第一个参数,表示从索引0开始。limit 获取条数 h. DISTINCT, ALL 选项 distinct 去除重复记录 默认为 all, 全部记录 UNION # /* UNION */ ------------------ 将多个select查询的结果组合成一个结果集合。 SELECT ... UNION [ALL|DISTINCT] SELECT ... 默认 DISTINCT 方式,即所有返回的行都是唯一的 建议,对每个SELECT查询加上小括号包裹。 ORDER BY 排序时,需加上 LIMIT 进行结合。 需要各select查询的字段数量一样。 每个select查询的字段列表(数量、类型)应一致,因为结果中的字段名以第一条select语句为准。 子查询 # /* 子查询 */ ------------------ - 子查询需用括号包裹。 -- from型 from后要求是一个表,必须给子查询结果取个别名。 - 简化每个查询内的条件。 - from型需将结果生成一个临时表格,可用以原表的锁定的释放。 - 子查询返回一个表,表型子查询。 select * from (select * from tb where id\u0026gt;0) as subfrom where id\u0026gt;1; -- where型 - 子查询返回一个值,标量子查询。 - 不需要给子查询取别名。 - where子查询内的表,不能直接用以更新。 select * from tb where money = (select max(money) from tb); -- 列子查询 如果子查询结果返回的是一列。 使用 in 或 not in 完成查询 exists 和 not exists 条件 如果子查询返回数据,则返回1或0。常用于判断条件。 select column1 from t1 where exists (select * from t2); -- 行子查询 查询条件是一个行。 select * from t1 where (id, gender) in (select id, gender from t2); 行构造符:(col1, col2, ...) 或 ROW(col1, col2, ...) 行构造符通常用于与对能返回两个或两个以上列的子查询进行比较。 -- 特殊运算符 != all() 相当于 not in = some() 相当于 in。any 是 some 的别名 != some() 不等同于 not in,不等于其中某一个。 all, some 可以配合其他运算符一起使用。 连接查询(join) # /* 连接查询(join) */ ------------------ 将多个表的字段进行连接,可以指定连接条件。 -- 内连接(inner join) - 默认就是内连接,可省略inner。 - 只有数据存在时才能发送连接。即连接结果不能出现空行。 on 表示连接条件。其条件表达式与where类似。也可以省略条件(表示条件永远为真) 也可用where表示连接条件。 还有 using, 但需字段名相同。 using(字段名) -- 交叉连接 cross join 即,没有条件的内连接。 select * from tb1 cross join tb2; -- 外连接(outer join) - 如果数据不存在,也会出现在连接结果中。 -- 左外连接 left join 如果数据不存在,左表记录会出现,而右表为null填充 -- 右外连接 right join 如果数据不存在,右表记录会出现,而左表为null填充 -- 自然连接(natural join) 自动判断连接条件完成连接。 相当于省略了using,会自动查找相同字段名。 natural join natural left join natural right join select info.id, info.name, info.stu_num, extra_info.hobby, extra_info.sex from info, extra_info where info.stu_num = extra_info.stu_id; TRUNCATE # /* TRUNCATE */ ------------------ TRUNCATE [TABLE] tbl_name 清空数据 删除重建表 区别: 1,truncate 是删除表再创建,delete 是逐条删除 2,truncate 重置auto_increment的值。而delete不会 3,truncate 不知道删除了几条,而delete知道。 4,当被用于带分区的表时,truncate 会保留分区 备份与还原 # /* 备份与还原 */ ------------------ 备份,将数据的结构与表内数据保存起来。 利用 mysqldump 指令完成。 -- 导出 mysqldump [options] db_name [tables] mysqldump [options] ---database DB1 [DB2 DB3...] mysqldump [options] --all--database 1. 导出一张表 mysqldump -u用户名 -p密码 库名 表名 \u0026gt; 文件名(D:/a.sql) 2. 导出多张表 mysqldump -u用户名 -p密码 库名 表1 表2 表3 \u0026gt; 文件名(D:/a.sql) 3. 导出所有表 mysqldump -u用户名 -p密码 库名 \u0026gt; 文件名(D:/a.sql) 4. 导出一个库 mysqldump -u用户名 -p密码 --lock-all-tables --database 库名 \u0026gt; 文件名(D:/a.sql) 可以-w携带WHERE条件 -- 导入 1. 在登录mysql的情况下: source 备份文件 2. 在不登录的情况下 mysql -u用户名 -p密码 库名 \u0026lt; 备份文件 视图 # 什么是视图: 视图是一个虚拟表,其内容由查询定义。同真实的表一样,视图包含一系列带有名称的列和行数据。但是,视图并不在数据库中以存储的数据值集形式存在。行和列数据来自由定义视图的查询所引用的表,并且在引用视图时动态生成。 视图具有表结构文件,但不存在数据文件。 对其中所引用的基础表来说,视图的作用类似于筛选。定义视图的筛选可以来自当前或其它数据库的一个或多个表,或者其它视图。通过视图进行查询没有任何限制,通过它们进行数据修改时的限制也很少。 视图是存储在数据库中的查询的sql语句,它主要出于两种原因:安全原因,视图可以隐藏一些数据,如:社会保险基金表,可以用视图只显示姓名,地址,而不显示社会保险号和工资数等,另一原因是可使复杂的查询易于理解和使用。 -- 创建视图 CREATE [OR REPLACE] [ALGORITHM = {UNDEFINED | MERGE | TEMPTABLE}] VIEW view_name [(column_list)] AS select_statement - 视图名必须唯一,同时不能与表重名。 - 视图可以使用select语句查询到的列名,也可以自己指定相应的列名。 - 可以指定视图执行的算法,通过ALGORITHM指定。 - column_list如果存在,则数目必须等于SELECT语句检索的列数 -- 查看结构 SHOW CREATE VIEW view_name -- 删除视图 - 删除视图后,数据依然存在。 - 可同时删除多个视图。 DROP VIEW [IF EXISTS] view_name ... -- 修改视图结构 - 一般不修改视图,因为不是所有的更新视图都会映射到表上。 ALTER VIEW view_name [(column_list)] AS select_statement -- 视图作用 1. 简化业务逻辑 2. 对客户端隐藏真实的表结构 -- 视图算法(ALGORITHM) MERGE 合并 将视图的查询语句,与外部查询需要先合并再执行! TEMPTABLE 临时表 将视图执行完毕后,形成临时表,再做外层查询! UNDEFINED 未定义(默认),指的是MySQL自主去选择相应的算法。 事务(transaction) # 事务是指逻辑上的一组操作,组成这组操作的各个单元,要不全成功要不全失败。 - 支持连续SQL的集体成功或集体撤销。 - 事务是数据库在数据完整性方面的一个功能。 - 需要利用 InnoDB 或 BDB 存储引擎,对自动提交的特性支持完成。 - InnoDB被称为事务安全型引擎。 -- 事务开启 START TRANSACTION; 或者 BEGIN; 开启事务后,所有被执行的SQL语句均被认作当前事务内的SQL语句。 -- 事务提交 COMMIT; -- 事务回滚 ROLLBACK; 如果部分操作发生问题,映射到事务开启前。 -- 事务的特性 1. 原子性(Atomicity) 事务是一个不可分割的工作单位,事务中的操作要么都发生,要么都不发生。 2. 一致性(Consistency) 事务前后数据的完整性必须保持一致。 - 事务开始和结束时,外部数据一致 - 在整个事务过程中,操作是连续的 3. 隔离性(Isolation) 多个用户并发访问数据库时,一个用户的事务不能被其它用户的事务所干扰,多个并发事务之间的数据要相互隔离。 4. 持久性(Durability) 一个事务一旦被提交,它对数据库中的数据改变就是永久性的。 -- 事务的实现 1. 要求是事务支持的表类型 2. 执行一组相关的操作前开启事务 3. 整组操作完成后,都成功,则提交;如果存在失败,选择回滚,则会回到事务开始的备份点。 -- 事务的原理 利用InnoDB的自动提交(autocommit)特性完成。 普通的MySQL执行语句后,当前的数据提交操作均可被其他客户端可见。 而事务是暂时关闭“自动提交”机制,需要commit提交持久化数据操作。 -- 注意 1. 数据定义语言(DDL)语句不能被回滚,比如创建或取消数据库的语句,和创建、取消或更改表或存储的子程序的语句。 2. 事务不能被嵌套 -- 保存点 SAVEPOINT 保存点名称 -- 设置一个事务保存点 ROLLBACK TO SAVEPOINT 保存点名称 -- 回滚到保存点 RELEASE SAVEPOINT 保存点名称 -- 删除保存点 -- InnoDB自动提交特性设置 SET autocommit = 0|1; 0表示关闭自动提交,1表示开启自动提交。 - 如果关闭了,那普通操作的结果对其他客户端也不可见,需要commit提交后才能持久化数据操作。 - 也可以关闭自动提交来开启事务。但与START TRANSACTION不同的是, SET autocommit是永久改变服务器的设置,直到下次再次修改该设置。(针对当前连接) 而START TRANSACTION记录开启前的状态,而一旦事务提交或回滚后就需要再次开启事务。(针对当前事务) 锁表 # /* 锁表 */ 表锁定只用于防止其它客户端进行不正当地读取和写入 MyISAM 支持表锁,InnoDB 支持行锁 -- 锁定 LOCK TABLES tbl_name [AS alias] -- 解锁 UNLOCK TABLES 触发器 # /* 触发器 */ ------------------ 触发程序是与表有关的命名数据库对象,当该表出现特定事件时,将激活该对象 监听:记录的增加、修改、删除。 -- 创建触发器 CREATE TRIGGER trigger_name trigger_time trigger_event ON tbl_name FOR EACH ROW trigger_stmt 参数: trigger_time是触发程序的动作时间。它可以是 before 或 after,以指明触发程序是在激活它的语句之前或之后触发。 trigger_event指明了激活触发程序的语句的类型 INSERT:将新行插入表时激活触发程序 UPDATE:更改某一行时激活触发程序 DELETE:从表中删除某一行时激活触发程序 tbl_name:监听的表,必须是永久性的表,不能将触发程序与TEMPORARY表或视图关联起来。 trigger_stmt:当触发程序激活时执行的语句。执行多个语句,可使用BEGIN...END复合语句结构 -- 删除 DROP TRIGGER [schema_name.]trigger_name 可以使用old和new代替旧的和新的数据 更新操作,更新前是old,更新后是new. 删除操作,只有old. 增加操作,只有new. -- 注意 1. 对于具有相同触发程序动作时间和事件的给定表,不能有两个触发程序。 -- 字符连接函数 concat(str1,str2,...]) concat_ws(separator,str1,str2,...) -- 分支语句 if 条件 then 执行语句 elseif 条件 then 执行语句 else 执行语句 end if; -- 修改最外层语句结束符 delimiter 自定义结束符号 SQL语句 自定义结束符号 delimiter ; -- 修改回原来的分号 -- 语句块包裹 begin 语句块 end -- 特殊的执行 1. 只要添加记录,就会触发程序。 2. Insert into on duplicate key update 语法会触发: 如果没有重复记录,会触发 before insert, after insert; 如果有重复记录并更新,会触发 before insert, before update, after update; 如果有重复记录但是没有发生更新,则触发 before insert, before update 3. Replace 语法 如果有记录,则执行 before insert, before delete, after delete, after insert SQL 编程 # /* SQL编程 */ ------------------ --// 局部变量 ---------- -- 变量声明 declare var_name[,...] type [default value] 这个语句被用来声明局部变量。要给变量提供一个默认值,请包含一个default子句。值可以被指定为一个表达式,不需要为一个常数。如果没有default子句,初始值为null。 -- 赋值 使用 set 和 select into 语句为变量赋值。 - 注意:在函数内是可以使用全局变量(用户自定义的变量) --// 全局变量 ---------- -- 定义、赋值 set 语句可以定义并为变量赋值。 set @var = value; 也可以使用select into语句为变量初始化并赋值。这样要求select语句只能返回一行,但是可以是多个字段,就意味着同时为多个变量进行赋值,变量的数量需要与查询的列数一致。 还可以把赋值语句看作一个表达式,通过select执行完成。此时为了避免=被当作关系运算符看待,使用:=代替。(set语句可以使用= 和 :=)。 select @var:=20; select @v1:=id, @v2=name from t1 limit 1; select * from tbl_name where @var:=30; select into 可以将表中查询获得的数据赋给变量。 -| select max(height) into @max_height from tb; -- 自定义变量名 为了避免select语句中,用户自定义的变量与系统标识符(通常是字段名)冲突,用户自定义变量在变量名前使用@作为开始符号。 @var=10; - 变量被定义后,在整个会话周期都有效(登录到退出) --// 控制结构 ---------- -- if语句 if search_condition then statement_list [elseif search_condition then statement_list] ... [else statement_list] end if; -- case语句 CASE value WHEN [compare-value] THEN result [WHEN [compare-value] THEN result ...] [ELSE result] END -- while循环 [begin_label:] while search_condition do statement_list end while [end_label]; - 如果需要在循环内提前终止 while循环,则需要使用标签;标签需要成对出现。 -- 退出循环 退出整个循环 leave 退出当前循环 iterate 通过退出的标签决定退出哪个循环 --// 内置函数 ---------- -- 数值函数 abs(x) -- 绝对值 abs(-10.9) = 10 format(x, d) -- 格式化千分位数值 format(1234567.456, 2) = 1,234,567.46 ceil(x) -- 向上取整 ceil(10.1) = 11 floor(x) -- 向下取整 floor (10.1) = 10 round(x) -- 四舍五入去整 mod(m, n) -- m%n m mod n 求余 10%3=1 pi() -- 获得圆周率 pow(m, n) -- m^n sqrt(x) -- 算术平方根 rand() -- 随机数 truncate(x, d) -- 截取d位小数 -- 时间日期函数 now(), current_timestamp(); -- 当前日期时间 current_date(); -- 当前日期 current_time(); -- 当前时间 date(\u0026#39;yyyy-mm-dd hh:ii:ss\u0026#39;); -- 获取日期部分 time(\u0026#39;yyyy-mm-dd hh:ii:ss\u0026#39;); -- 获取时间部分 date_format(\u0026#39;yyyy-mm-dd hh:ii:ss\u0026#39;, \u0026#39;%d %y %a %d %m %b %j\u0026#39;); -- 格式化时间 unix_timestamp(); -- 获得unix时间戳 from_unixtime(); -- 从时间戳获得时间 -- 字符串函数 length(string) -- string长度,字节 char_length(string) -- string的字符个数 substring(str, position [,length]) -- 从str的position开始,取length个字符 replace(str ,search_str ,replace_str) -- 在str中用replace_str替换search_str instr(string ,substring) -- 返回substring首次在string中出现的位置 concat(string [,...]) -- 连接字串 charset(str) -- 返回字串字符集 lcase(string) -- 转换成小写 left(string, length) -- 从string2中的左边起取length个字符 load_file(file_name) -- 从文件读取内容 locate(substring, string [,start_position]) -- 同instr,但可指定开始位置 lpad(string, length, pad) -- 重复用pad加在string开头,直到字串长度为length ltrim(string) -- 去除前端空格 repeat(string, count) -- 重复count次 rpad(string, length, pad) --在str后用pad补充,直到长度为length rtrim(string) -- 去除后端空格 strcmp(string1 ,string2) -- 逐字符比较两字串大小 -- 流程函数 case when [condition] then result [when [condition] then result ...] [else result] end 多分支 if(expr1,expr2,expr3) 双分支。 -- 聚合函数 count() sum(); max(); min(); avg(); group_concat() -- 其他常用函数 md5(); default(); --// 存储函数,自定义函数 ---------- -- 新建 CREATE FUNCTION function_name (参数列表) RETURNS 返回值类型 函数体 - 函数名,应该合法的标识符,并且不应该与已有的关键字冲突。 - 一个函数应该属于某个数据库,可以使用db_name.function_name的形式执行当前函数所属数据库,否则为当前数据库。 - 参数部分,由\u0026#34;参数名\u0026#34;和\u0026#34;参数类型\u0026#34;组成。多个参数用逗号隔开。 - 函数体由多条可用的mysql语句,流程控制,变量声明等语句构成。 - 多条语句应该使用 begin...end 语句块包含。 - 一定要有 return 返回值语句。 -- 删除 DROP FUNCTION [IF EXISTS] function_name; -- 查看 SHOW FUNCTION STATUS LIKE \u0026#39;partten\u0026#39; SHOW CREATE FUNCTION function_name; -- 修改 ALTER FUNCTION function_name 函数选项 --// 存储过程,自定义功能 ---------- -- 定义 存储存储过程 是一段代码(过程),存储在数据库中的sql组成。 一个存储过程通常用于完成一段业务逻辑,例如报名,交班费,订单入库等。 而一个函数通常专注与某个功能,视为其他程序服务的,需要在其他语句中调用函数才可以,而存储过程不能被其他调用,是自己执行 通过call执行。 -- 创建 CREATE PROCEDURE sp_name (参数列表) 过程体 参数列表:不同于函数的参数列表,需要指明参数类型 IN,表示输入型 OUT,表示输出型 INOUT,表示混合型 注意,没有返回值。 存储过程 # /* 存储过程 */ ------------------ 存储过程是一段可执行性代码的集合。相比函数,更偏向于业务逻辑。 调用:CALL 过程名 -- 注意 - 没有返回值。 - 只能单独调用,不可夹杂在其他语句中 -- 参数 IN|OUT|INOUT 参数名 数据类型 IN 输入:在调用过程中,将数据输入到过程体内部的参数 OUT 输出:在调用过程中,将过程体处理完的结果返回到客户端 INOUT 输入输出:既可输入,也可输出 -- 语法 CREATE PROCEDURE 过程名 (参数列表) BEGIN 过程体 END 用户和权限管理 # /* 用户和权限管理 */ ------------------ -- root密码重置 1. 停止MySQL服务 2. [Linux] /usr/local/mysql/bin/safe_mysqld --skip-grant-tables \u0026amp; [Windows] mysqld --skip-grant-tables 3. use mysql; 4. UPDATE `user` SET PASSWORD=PASSWORD(\u0026#34;密码\u0026#34;) WHERE `user` = \u0026#34;root\u0026#34;; 5. FLUSH PRIVILEGES; 用户信息表:mysql.user -- 刷新权限 FLUSH PRIVILEGES; -- 增加用户 CREATE USER 用户名 IDENTIFIED BY [PASSWORD] 密码(字符串) - 必须拥有mysql数据库的全局CREATE USER权限,或拥有INSERT权限。 - 只能创建用户,不能赋予权限。 - 用户名,注意引号:如 \u0026#39;user_name\u0026#39;@\u0026#39;192.168.1.1\u0026#39; - 密码也需引号,纯数字密码也要加引号 - 要在纯文本中指定密码,需忽略PASSWORD关键词。要把密码指定为由PASSWORD()函数返回的混编值,需包含关键字PASSWORD -- 重命名用户 RENAME USER old_user TO new_user -- 设置密码 SET PASSWORD = PASSWORD(\u0026#39;密码\u0026#39;) -- 为当前用户设置密码 SET PASSWORD FOR 用户名 = PASSWORD(\u0026#39;密码\u0026#39;) -- 为指定用户设置密码 -- 删除用户 DROP USER 用户名 -- 分配权限/添加用户 GRANT 权限列表 ON 表名 TO 用户名 [IDENTIFIED BY [PASSWORD] \u0026#39;password\u0026#39;] - all privileges 表示所有权限 - *.* 表示所有库的所有表 - 库名.表名 表示某库下面的某表 GRANT ALL PRIVILEGES ON `pms`.* TO \u0026#39;pms\u0026#39;@\u0026#39;%\u0026#39; IDENTIFIED BY \u0026#39;pms0817\u0026#39;; -- 查看权限 SHOW GRANTS FOR 用户名 -- 查看当前用户权限 SHOW GRANTS; 或 SHOW GRANTS FOR CURRENT_USER; 或 SHOW GRANTS FOR CURRENT_USER(); -- 撤消权限 REVOKE 权限列表 ON 表名 FROM 用户名 REVOKE ALL PRIVILEGES, GRANT OPTION FROM 用户名 -- 撤销所有权限 -- 权限层级 -- 要使用GRANT或REVOKE,您必须拥有GRANT OPTION权限,并且您必须用于您正在授予或撤销的权限。 全局层级:全局权限适用于一个给定服务器中的所有数据库,mysql.user GRANT ALL ON *.*和 REVOKE ALL ON *.*只授予和撤销全局权限。 数据库层级:数据库权限适用于一个给定数据库中的所有目标,mysql.db, mysql.host GRANT ALL ON db_name.*和REVOKE ALL ON db_name.*只授予和撤销数据库权限。 表层级:表权限适用于一个给定表中的所有列,mysql.talbes_priv GRANT ALL ON db_name.tbl_name和REVOKE ALL ON db_name.tbl_name只授予和撤销表权限。 列层级:列权限适用于一个给定表中的单一列,mysql.columns_priv 当使用REVOKE时,您必须指定与被授权列相同的列。 -- 权限列表 ALL [PRIVILEGES] -- 设置除GRANT OPTION之外的所有简单权限 ALTER -- 允许使用ALTER TABLE ALTER ROUTINE -- 更改或取消已存储的子程序 CREATE -- 允许使用CREATE TABLE CREATE ROUTINE -- 创建已存储的子程序 CREATE TEMPORARY TABLES -- 允许使用CREATE TEMPORARY TABLE CREATE USER -- 允许使用CREATE USER, DROP USER, RENAME USER和REVOKE ALL PRIVILEGES。 CREATE VIEW -- 允许使用CREATE VIEW DELETE -- 允许使用DELETE DROP -- 允许使用DROP TABLE EXECUTE -- 允许用户运行已存储的子程序 FILE -- 允许使用SELECT...INTO OUTFILE和LOAD DATA INFILE INDEX -- 允许使用CREATE INDEX和DROP INDEX INSERT -- 允许使用INSERT LOCK TABLES -- 允许对您拥有SELECT权限的表使用LOCK TABLES PROCESS -- 允许使用SHOW FULL PROCESSLIST REFERENCES -- 未被实施 RELOAD -- 允许使用FLUSH REPLICATION CLIENT -- 允许用户询问从属服务器或主服务器的地址 REPLICATION SLAVE -- 用于复制型从属服务器(从主服务器中读取二进制日志事件) SELECT -- 允许使用SELECT SHOW DATABASES -- 显示所有数据库 SHOW VIEW -- 允许使用SHOW CREATE VIEW SHUTDOWN -- 允许使用mysqladmin shutdown SUPER -- 允许使用CHANGE MASTER, KILL, PURGE MASTER LOGS和SET GLOBAL语句,mysqladmin debug命令;允许您连接(一次),即使已达到max_connections。 UPDATE -- 允许使用UPDATE USAGE -- “无权限”的同义词 GRANT OPTION -- 允许授予权限 表维护 # /* 表维护 */ -- 分析和存储表的关键字分布 ANALYZE [LOCAL | NO_WRITE_TO_BINLOG] TABLE 表名 ... -- 检查一个或多个表是否有错误 CHECK TABLE tbl_name [, tbl_name] ... [option] ... option = {QUICK | FAST | MEDIUM | EXTENDED | CHANGED} -- 整理数据文件的碎片 OPTIMIZE [LOCAL | NO_WRITE_TO_BINLOG] TABLE tbl_name [, tbl_name] ... 杂项 # /* 杂项 */ ------------------ 1. 可用反引号(`)为标识符(库名、表名、字段名、索引、别名)包裹,以避免与关键字重名!中文也可以作为标识符! 2. 每个库目录存在一个保存当前数据库的选项文件db.opt。 3. 注释: 单行注释 # 注释内容 多行注释 /* 注释内容 */ 单行注释 -- 注释内容 (标准SQL注释风格,要求双破折号后加一空格符(空格、TAB、换行等)) 4. 模式通配符: _ 任意单个字符 % 任意多个字符,甚至包括零字符 单引号需要进行转义 \\\u0026#39; 5. CMD命令行内的语句结束符可以为 \u0026#34;;\u0026#34;, \u0026#34;\\G\u0026#34;, \u0026#34;\\g\u0026#34;,仅影响显示结果。其他地方还是用分号结束。delimiter 可修改当前对话的语句结束符。 6. SQL对大小写不敏感 7. 清除已有语句:\\c "},{"id":671,"href":"/zh/docs/technology/Interview/high-quality-technical-articles/interview/the-experience-and-thinking-of-an-interview-experienced-by-an-older-programmer/","title":"一位大龄程序员所经历的面试的历炼和思考","section":"Interview","content":" 推荐语:本文的作者,今年 36 岁,已有 8 年 JAVA 开发经验。在阿里云三年半,有赞四年半,已是标准的大龄程序员了。在这篇文章中,作者给出了一些关于面试和个人能力提升的一些小建议,非常实用!\n内容概览:\n个人介绍,是对自己的一个更为清晰、深入和全面的认识契机。 简历是充分展示自己的浓缩精华,也是重新审视自己和过往经历的契机。不仅仅是简要介绍技能和经验,更要最大程度凸显自己的优势领域(差异化)。 我个人是不赞成海投的,而倾向于定向投。找准方向投,虽然目标更少,但更有效率。 技术探索,一定要先理解原理。原理不懂,就会浮于表层,不能真正掌握它。技术原理探究要掌握到什么程度?数据结构与算法设计、考量因素、技术机制、优化思路。要在脑中回放,直到一切细节而清晰可见。如果能够清晰有条理地表述出来,就更好了。技术原理探究,一定要看源码。看了源码与没看源码是有区别的。没看源码,虽然说得出来,但终是隔了一层纸;看了源码,才捅破了那层纸,有了自己的理解,也就能说得更加有底气了。当然,也可能是我缺乏演戏的本领。 要善于从失败中学习。正是在杭州四个月空档期的持续学习、思考、积累和提炼,以及面试失败的反思、不断调整对策、完善准备、改善原有的短板,采取更为合理的方式,才在回武汉的短短两个周内拿到比较满意的 offer 。 面试是通过沟通来理解双方的过程。面试中的问题,千变万化,但有一些问题是需要提前准备好的。 原文地址: https://www.cnblogs.com/lovesqcc/p/14354921.html\n从每一段经历中学习,在每一件事情中修行。善于从挫折中学习。\n引子 # 我今年 36 岁,已有 8 年 JAVA 开发经验。在阿里云三年半,有赞四年半,已是标准的大龄程序员了。\n在多年的读书、学习和思考中,我的价值观、人生观和世界观也逐步塑造成型。我意识到自己的志趣在于做教育文化方面,因此在半冲动之下,8 月份下旬,裸辞去找工作了。有限理性难以阻挡冲动的个性。不建议裸辞,做事应该有规划、科学合理。\n尽管我最初认为自己“有理想有目标有意愿有能力”,找一份教育开发的工作应该不难,但事实上我还是过于乐观了。现实很快给我泼了一瓢瓢冷水。我屡战屡败,又屡败屡战。惊讶地发现自己还有这个韧性。面试是一项历炼,如果没有被失败击倒,那么从中会生长出一份韧性,这种韧性能让人走得更远。谁没有经历过失败的历练呢?失败是最伟大的导师了,如果你愿意跟他学一学的话。\n在面试的过程中,我很快发现自己的劣势:\n投入精力做业务,技术深度不够,对原理的理解局限于较浅的层次; 视野不够开阔,局限于自己所做的订单业务线,对其它关联业务线(比如商品、营销、支付等)了解不够; 思维不够开阔,大部分时间投入在开发和测试上,对运维、产品、业务、商业层面思考都思考不多; 缺乏管理经验,年龄偏大;这两项劣势我一度低估,但逐渐凸显出来,甚至让我一度不自信,但最终我还是走出来了。 但我也有自己的优势。职业竞争的基本法则是稀缺性和差异化。能够解决大型项目的架构设计和攻克技术难题,精通某个高端技术领域是稀缺性体现;而能够做事能做到缜密周全精细化,有高并发大流量系统开发经验,则是差异性体现。稀缺性是上策,差异化是中策,而降格以求就是下策了。\n我缺乏稀缺性优势,但还有一点差异化优势:\n对每一份工作都很踏实,时间均在 3 年 - 5 年之间,有一点大厂光环,能获得更多面试机会(虽然不一定能面上); 坚持写博客,孜孜不倦地追求软件开发的“道”,时常思考记录开发中遇到的问题及解决方案; 做事认真严谨,能够从整体分析和思考问题,也很注重基础提升; 对工程质量、性能优化、稳定性建设、业务配置化设计有实践经验; 大流量微服务系统的长期开发维护经验。 我投出简历的公司并不多。在不多的面试中,我逐渐意识到网上的“斩获几十家大厂 offer”的说法并不可信。理由如下:\n如果能真斩获大量大厂 offer ,面试的级别很大概率是初级工程师。要知道面试 4 年以上的工程师,面试的深度和广度令人发指,从基础的算法、到各种中间件的原理机制到实际运维架构,无所不包,真个是沉浸在“技术的海洋”,除非一个人的背景和实力非常强大,平时也做了非常深且广的沉淀; 一个背景和实力非常强大的人,是不会有兴趣去投入这么多精力去面各种公司,仅仅是为了吹嘘自己有多能耐;实力越强的人,他会有自己的选择逻辑,投的简历会更定向精准。话说,他为什么不花更多精力投入在那些能够让他有最大化收益的优秀企业呢? 培训机构做的广告。因为他们最清楚新手需要的是信心,哪怕是伪装出来的信心。 好了,闲话不多说了。我讲讲自己在面试中所经受的历练和思考吧。\n准备工作 # 人生或许很长,但面试的时间很短,最长不过一小时或一个半小时。别人如何在短短一小时内能够更清晰地认识长达三十多年的你呢?这就需要你做大量细致的准备工作了。在某种程度上,面试与舞蹈有异曲同工之妙:台上五分钟,台下十年功。\n准备工作主要包括简历准备、个人介绍、公司了解、技术探索、表述能力、常见问题、中高端职位、好的心态。准备工作是对自身和对外部世界的一次全面深入的重新认知。\n初期,我以为自己准备很充分,简历改改就完事了。随着一次次受挫,才发现自己的准备很不充分。在现在的我看来,准备七分,应变三分。准备,就是要知己知彼,知道对方会问哪些问题(通常是系统/项目/技术的深度和广度)、自己应当如何作答;应变,就是当自己遇到不会、不懂、不知道的问题时,如何合理地展示自己的解决思路,以及根据面试中答不上来的问题查漏补缺,夯实基础。\n这个过程,实际上也是学习的过程。持续的反思和提炼、学习新的内容、重新认识自己和过往经历等。\n简历准备 # 最开始,我做得比较简单。把以前的简历拿出来,添加上新的工作经历,略作修改,但整体上模板基本不变。\n在基本面上,我做的是较为细致的,诚实地写上了自己擅长和熟悉的技能和经验经历,排版也尽力做得整洁美观(学过一些 UI 设计)。不浮夸也不故作谦虚。\n在扩展面上,我做的还是不够的。有一天,一位猎头打电话给我,问:“你最大的优势是什么?”。我顿时说不上来。当时也未多加思考。在后续面试屡遭失败之后,一度有些不自信之后,我开始仔细思考自己的优势来。然后将“对工程质量、性能优化、稳定性建设、业务配置化设计有深入思考和实践经验”写在了“技能素养”栏的第一行,因为这确实是我所做过的、最实在且脚踏实地的且具备概括性的。\n有时,简历内容的编排顺序也很重要。之前,我把掌握的语言及技术写在前面,而“项目管理能力和团队影响力”之类的写在后面。但投年糕妈妈之后,未有面试直接被拉到不合适里面,受到了刺激,我意识到或许是对方觉得我管理经验不足。因此,刻意将“项目管理能力和团队影响力”提到了前面,表示自己是重视管理方面的,不过,投过新的简历之后,没有回应。我意识到,这样的编排顺序可能会让人误解我是管理能力偏重的(事实上有一位 HR 问我是不是还在写代码),但实际上管理方面我是欠缺的,最后,我还是调回了原来的顺序,凸出自己“工程师的本色”。后面,我又做了一些语句的编排上的修改。\n随着面试的进展,有时,也会发现自己的简历上写得不够或者以前做得不够的地方。比如,在订单导出这段经历里,我只是写了大幅提升性能和稳定性,显得定性描述化,因此,我添加了一些量化的东西(2w 阻塞 =\u0026gt; 300w+,1w/1min)作为证实;比如,8 月份离职,到 12 月份面试的时候,有一段空档期,有些企业会问到这个。因此,我索性加了一句话,说明这段时间我在干些啥;比如,代表性系统和项目,每一个系统和项目的价值和意义(不一定写在上面,但是心里要有数)。功夫要下足。\n再比如,我很详细地写了有赞的工作经历及经验,但阿里云的那段基本没动。而有些企业对这段经历更感兴趣,我却觉得没太多可说的,留在脑海里的只有少量印象深刻的东西,以及一些博客文章的记录,相比这段工作经历来说显得太单薄。这里实质上不是简历的问题,而是过往经历复盘的问题。建议,在每个项目结束后,都要写个自我复盘。避免时间将这些可贵的经历冲淡。\n每个人其实都有很多可说的东西,但记录下来的又有多少呢?值得谈道的有多少呢?过往不努力,面试徒伤悲。\n简历更新的心得:\n简历是充分展示自己的浓缩精华,也是重新审视自己和过往经历的契机; 不仅仅是简要介绍技能和经验,更要最大程度凸显自己的优势领域(差异化); 增强工作经历的表述,凸显贡献,赢得别人的认可; 复盘并记录每一个项目中的收获,为跳槽和面试打下好的铺垫。 个人介绍 # 面试前通常会要求做个简要的个人介绍。个人介绍通常作为进入面试的前奏曲和缓冲阶段,缓和下紧张气氛。\n我最开始的个人介绍,个性啊业余生活啊工作经历啊志趣啊等等,似乎不知道该说些什么。实际上,个人介绍是一个充分展示自己的主页。主页应当让自己最最核心的优势一目了然(需要挖掘自己的经历并仔细提炼)。我现在的个人介绍一般会包括:个性(比如偏安静)、做事风格(工作认真严谨、注重质量、善于整体思考)、最大优势(owner 意识、执行力、工程把控能力)、工作经历简述(在每个公司的工作负责什么、贡献了什么、收获了什么)。个人介绍简明扼要,无需赘言。\n个人介绍,是对自己的一个更为清晰、深入和全面的认识契机。\n公司了解 # 很多人可能跟我一样,对公司业务了解甚少,就直接投出去了。这样其实是不合理的。首先,我个人是不赞成海投的,而倾向于定向投。找准方向投,虽然目标更少,但更有效率。这跟租房一样,我一般在豆瓣上租房,虽然目标源少,但逮着一个就是好运。\n投一家公司,是因为这家公司符合意向,值得争取,而不是因为这是一家公司。就像找对象,不是为了找一个女人。要确定这家公司是否符合意向,就应当多去了解这家公司:主营业务、未来发展及规划、所在行业及地位、财务状况、业界及网络评价等。\n在面试的过程中适当谈到公司的业务及思考,是可加分项。亦可用于“你有什么想问的?”的提问。\n技术探索 # 技术能力是一个技术人的基本素养。因此,我觉得,无论未来做什么工作,技术能力过硬,总归是最不可或缺的不可忽视的。\n原理和设计思想是软件技术中最为精髓的东西。一般软件技术可以分为两个方面:\n原理:事物如何工作的基本规律和流程; 架构:如何组织大规模逻辑的艺术。 技术探索,一定要先理解原理。原理不懂,就会浮于表层,不能真正掌握它。技术原理探究要掌握到什么程度?数据结构与算法设计、考量因素、技术机制、优化思路。要在脑中回放,直到一切细节而清晰可见。如果能够清晰有条理地表述出来,就更好了。\n技术原理探究,一定要看源码。看了源码与没看源码是有区别的。没看源码,虽然说得出来,但终是隔了一层纸;看了源码,才捅破了那层纸,有了自己的理解,也就能说得更加有底气了。当然,也可能是我缺乏演戏的本领。\n我个人不太赞成刷题式面试。虽然刷题确实是进厂的捷径,但也有缺点:\n它依然是别人的知识体系,而不是自己总结的知识体系; 技术探究是为了未来的工作准备,而不是为了应对一时之需,否则即使进去了还是会处于麻痹状态。 经过系统的整理,我逐步形成了适合自己的技术体系结构: “互联网应用服务端的常用技术思想与机制纲要” 。在这个基础上,再博采众长,看看面试题进行自测和查漏补缺,是更恰当的方式。我会在这个体系上深耕细作。\n表述能力 # 目前,绝大多数企业的主要面试形式是通过口头沟通进行的,少部分企业可能有笔试或机试。口头沟通的形式是有其局限性的。对表述能力的要求比较高,而对专业能力的凸显并不明显。一个人掌握的专业和经验的深度和广度,很难通过几分钟的表述呈现出来。往往深度和广度越大,反而越难表述。而技术人员往往疏于表达。\n我平时写得多说得少,说起来不利索。有时没讲清楚背景,就直接展开,兼之啰嗦、跳跃和回旋往复(这种方式可能更适合写小说),让面试官有时摸不着头脑。表述的条理性和清晰性也是很重要的。不妨自己测试一下:Dubbo 的架构设计是怎样的? Redis 的持久化机制是怎样的?然后自己回答试试看。\n表述能力的基本法则:\n先总后分,先整体后局部; 先说基本思路,然后说优化; 体现互动。先综述,然后向面试官询问要听哪方面,再分述。避免自己一脑瓜子倾倒出来,让面试官猝不及防;系统设计的场景题,多问一些要求,比如时间要求、空间要求、要支持多大数据量或并发量、是否要考虑某些情况等。 常见问题 # 面试是通过沟通来理解双方的过程。面试中的问题,千变万化,但有一些问题是需要提前准备好的。\n比如“灵魂 N 问”:\n你为什么从 XXX 离职? 你的期望薪资是多少? 你有一段空档期,能解释下怎么回事么? 你的职业规划是怎样的? 高频技术问题:\n基础:数据结构与算法、网络; 微服务:技术体系、组件、基础设施等; Dubbo:Dubbo 整体架构、扩展机制、服务暴露、引用、调用、优雅停机等; MySQL:索引与事务的实现原理、SQL 优化、分库分表; Redis : 数据结构、缓存、分布式锁、持久化机制、复制机制; 分布式:分布式事务、一致性问题; 消息中间件:原理、对比; 架构:架构设计方法、架构经验、设计模式; 性能优化:JVM、GC、应用层面的性能优化; 并发基础:ConcurrentHashMap, AQS, CAS,线程池等; 高并发:IO 多路复用;缓存问题及方案; 稳定性:稳定性的思想及经验; 生产问题:工具及排查方法。 中高端职位 # 说起来,我这人可能有点不太自信。我是怀着“踏实做一个工程师”的思想投简历的。\n对于大龄程序员,企业的期望更高。我的每一份“高级工程师”投递,自动被转换为“技术专家”或“架构师”。无力反驳,倍感压力。面试中高端职位,需要更多准备:\n你有带团队经历吗? 在你 X 年的工作经历中,有多少时间用于架构设计? 架构过程是怎样的?你有哪些架构设计思想或方法论? 如果不作准备,就被一下子问懵,乱了阵脚。实际上,我或许还是存着侥幸心理把“技术专家”和“架构师”岗位当做“高工”来面试的,也就无一不遭遇失败了。显然,我把次序弄反了:应当以“技术专家”和“架构师”的规格来面试高级工程师。\n好吧,那就迎难而上吧!我不是惧怕挑战的人。\n此外,“技术专家”和“架构师”职位应当至少留一天的时间来准备。已经有丰富经验的技术专家和架构师可以忽略。\n好的心态 # 保持好的心态也尤为重要。我经历了“乐观-不自信-重拾信心”的心态变化过程。\n很长一段时间,由于“求成心切”,生怕某个技术问题回答不上来搞砸,因此小心谨慎,略显紧张,结果已经梳理好的往往说不清楚或者说得不够有条理。冲着“拿 offer ”的心态去面试,真的很难受,会觉得每场面试都很被动那么难过,甚至有点想要“降格以求”。\n有时,我在想:咋就混成这个样子了呢?按理来说,这个时候我应该有能力去追求自己喜爱的事业了啊!还是平时有点松懈了,视野狭窄,积累不够,导致今天的不利处境。\n我是一个守时的人,也希望对方尽可能守时。杭州的面试官中,基本是守时的,即使迟到也在心理接受范围内,回武汉面试后,节奏就有点被少量企业带偏了。有一两次,我甚至不确定面试官什么时候进入会议。我想,难道这是人才应该受到的“礼待”吗?我有点被轻微冒犯的感觉了。不过我还是“很有涵养地”表示没事。但我始终觉得:面试官迟到,是对人才的不尊重。进入不尊重人才的公司,我是怀有疑虑的。良禽择木而栖,良臣择主而事。难道我能因为此刻的不利处境,而放弃一些基本的原则底线,而屈从于一份不尊重人才的 offer 吗?\n我意识到:一个人应当用其实力去赢得对方的尊重和赏识,以后的合作才会更顺畅。不若,哪怕惜其无缘,亦不可强留。无论别人怎么存疑,心无旁骛地打磨实力,挖掘自己的才干和优势,终会发出自己的光芒。因此,我的心态顿时转变了:应当专注去沟通,与对方充分认识了解,赢得对方心服的认可,而不是拿到一张入门券,成为干活的工具。\n有一个“石头和玉”的小故事,把自己当做人才,并努力去提升自己,才能获得“人才的礼遇”;把自己当石头贱卖,放松努力,也就只能得到“石头的礼遇”。尽管一个人不一定马上就具备人才的能力,但在自己的内心里,就应当从人才的视角去观察待入职的企业,而不仅仅是为了找一份“赚更多钱”的工作。\n此外,焦虑也是不必要的。焦虑的实质是现实与目标的差距。一个人总可以评估目标的合理性及如何达成目标。如果目标过高,则适当调整目标级别;目标可行,则作出合理的决策,并通过持续的努力和恰当的出击来实现目标。决策、努力和出击能力都是可以持续修炼的。\n面试历炼 # 技术人的面试还是更偏重于技术,因此,技术的深度和广度还是要好好准备的。面试官和候选人的处境是不一样的,一个面试官问的只是少量点,但是多个面试官合起来就是一个面。明白这一点,作为面试官的你就不要忘乎所以,以为自己就比候选人厉害。\n我面的企业不多,因为我已经打算从事教育事业,用“志趣和驱动力”这项就直接过滤了很多企业的面试邀请。在杭州面试的基本是教育企业,连阿里华为等抛来的橄榄枝都婉拒了(尽管我也不一定能面上)。虽然做法有点“直男”,但投入最多精力于自己期望从事的行业和事业,才是值得的。\n我所认为的教育事业,并不局限于现在常谈起的在线教育或 K12 教育,而是一个教育体系,任何可以更好滴起到教育效果的事业,包括而不限于教学、阅读、音乐、设计等。\n接力棒科技-高工 # 面的第一家。畅谈一番后,没音讯了。但我也没有太在意。面试官问的比较偏交易业务性的东西,较深的就是如何保证应用的数据一致性了。\n此时的我,就像在路上扔了一颗探路的小石子,尚未意识到自己的处境。\n网易云音乐-高工 # 接着是网易云音乐。大厂就是大厂。一面问的尽是缓存、分布式锁、Dubbo、ZK, MQ 中间件相关的机制。很遗憾,由于我平时关于技术原理的沉淀还是很少,基本是“一问两不知”,挂得很出彩。\n此时,我初步意识到自己的技术底子还很薄弱,也就开始了广阔的技术学习和夯实,自底向上地梳理原理和逻辑,系统地进行整理总结,最终初步形成了自己的互联网服务端技术知识体系结构。\n铭师堂-技术专家 # 架构师面试的。问的相对多了一些,DB, Redis 等。反馈是技术还行,但缺乏管理经验。这是我第一次意识到大龄程序员缺乏管理经验的不利。中小企业的技术专家线招聘中,往往附加了管理经验的需求。应聘时要注意。\n缺乏管理经验,该怎么办呢?思考过一段时间后,我的想法是:\n改变能改变的,不能改变的,学习它。比如技术原理的学习是我能够改变的,但管理经验属于难以一时改变的,那就多了解点管理的基本理论吧。 从经历中挖掘相关经验。虽然我没有正式带团队的实际经验,但是有带项目和带工程师,管控某个业务线的基本管理经验。多多挖掘自己的经历。 字节教育-高工 # 字节教育面试,我给自己挖了不少坑往里跳。\n比如面试官问,讲一个你比较成就感的项目经历。我选择的是近 4 年前的周期购项目。虽然这是我入职有赞的第一个有代表性的项目,但时间太久,又没有详细记录,很多技术细节遗忘不清晰了。我讲到当时印象比较深的“一体化”设计思想,却忘记了当时为什么会有这种思想(未做仔细记录)。\n再比如,一个上课的场景题,我问是用 CS 架构还是 BS 架构?面试官说用 CS 架构吧。这不是给自己挖坑吗?明明自己不熟悉 CS 架构,何必问这个选择呢,不如直接按照 BS 架构来讲解。哎!\n字节教育给我的反馈是:业务 Sense 不错,系统设计能力有待提高。我觉得还是比较中肯的。因此,也开始注重系统设计实战方面的文章阅读和思考训练。\n经验是:\n做项目时,要详细记录每个项目的技术栈、技术决策及原因、技术细节,为面试做好铺垫; 提前准备好印象最深刻的最代表性的系统和项目,避免选择距离当前时间较久的缺乏详细记录的项目; 选择熟悉的项目和架构,至少有好的第一印象,不然给面试官的印象就是你啥都不会。 咪咕数媒-架构师 # 好家伙,一下子 3 位面试官群面。可能我以前经历的太少了吧。似乎国企面试较高端职位,喜欢采取这种形式。兼听则明偏听则暗嘛。问的问题也很广泛,从 ES 的基本原理,到机房的数据迁移。有些技术机制虽然学习过,但不牢固,不清晰,答的也不好。比如 ES 的搜索原理优化,讲过倒排索引后,我对 Term Index 和 Trie 树 讲不清楚。这说明,知道并不代表真正理解了。只有能够清晰有条理地把思路和细节都讲清楚,才算是真正理解了。\n印象深刻的是,有一个问题:你有哪些架构思想?这是第一次被问到架构设计方面的东西,我顿时有点慌乱。虽然平时多有思考,也有写过文章,却没有形成系统精炼的方法论,结果就是答的比较凌乱。\n涂鸦智能-高工 # 应聘涂鸦智能,是因为我觉得这家企业不错。优秀的企业至少应该多沟通一下,说不准以后有合作机会呢!看问题的思维要开阔一些,不能死守在自己想到的那一个事情上。\n涂鸦智能给我的整体观感还是不错的。面试官也很有礼貌有耐心,整体架构、技术和项目都问了很多,问到了我熟悉的地方,答得也还可以。也许我的经验正好是切中他们的需求吧。\n若不是当时想做教育的执念特别强,我很大概率会入职涂鸦智能。物联网在我看来应该是很有趣的领域。\n跟谁学-技术专家 # “跟谁学”基本能答上来。不过反馈是:对于提问抓重点的能力有所欠缺,对于技术的归纳整理也不够。我当时还有点不服气,认为自己写了那么多文章,也算是有不少思考,怎能算是总结不够呢?顶多是有技术盲点。技术犹如海洋,谁能没有盲点?\n不过现在反观,确实距离自己应该有的程度不够。对技术原理机制和生产问题排查的总结不够,不够清晰细致;对设计实践的经验总结也不够,不够系统扎实。这个事情还要持续深入地去做。\n此外,面得越多,越发现自己的表述能力确实有所欠缺。啰嗦、容易就一点展开说个没完、脱离背景直接说方案、跳跃、回旋往复,然后面试官很可能没耐心了。应该遵循“先总后分”、“基本思路-实现-优化”的一些基本逻辑来作答会更好一些。表述能力真的很重要,不可只顾着敲代码。还有每次面教育企业就不免紧张,生怕错过这个机会。\n这是第二家直接告诉我年龄与经验不匹配的企业,加深了我对年龄偏大的忧虑,以致于开始有点不自信了。\n那么我又是怎么重拾信心的呢?有一句老话:“留得青山在,不怕没柴烧”。就算我年龄比较大,如果我的技术能力打磨得足够硬朗,就不信找不到一家能够认可我的企业。大不了我去做开源项目好了。具备好的技术能力,并不一定就局限在企业的范围内去发挥作用,也没必要局限于那些被年龄偏见所蒙蔽的人的认知里。外界的认可固然重要,内在的可贵性却远胜于外在。\n亿童文教-架构师 # 也是采用的 3 人同时面试。主要问的是项目经历,技术方面问得倒不是深入。个人觉得答得还行。面试官也问了架构设计相关的问题,我答得一般。此时,我仍然没有意识到自己在以面“高级工程师”的规格来面试“架构师”岗位。\n面试官比较温和,HR 也在积极联系和沟通,感觉还不错。只是,我没有主动去问反馈意见,也就没有下文了。\n新东方-高工 # 面试新东方,主要是因为切中我做教育的期望,虽然职位需求是做信息管理系统,距离我理想中的业务还有一定距离。经过沟通了解,他们更需要的是对运维方面更熟悉的工程师,不过我正好对运维方面不太熟悉,平时关注不多,因此不太符合他们的真实招聘要求。面试官也是很温和的人,老家在宜昌,是我本科上大学的地方,面试体验不错。\n以后要花些时间学习一些运维相关的东西。作为一名优秀的工程师和合格的架构师,是要广泛学习和熟悉系统所采用的各种组件、中间件、运维部署等的。要有综观能力,不过我醒悟的可能有点迟。Better later than never.\nZOOM-高工 # ZOOM 的一位面试官或许是我见过的所有面试官中最差劲的。共有两位面试官,一位显得很有耐心,另一位则挺着胖胖的肚子,还打着哈欠,一副不怎么关心面试和候选人的样子。我心想,你要不想面,为啥还要来面呢?你以为候选人就低你一等么?换个位置我可以暴打你。不过我还是很有礼貌的,当做什么事也没发生。公司在挑人,候选人也在挑选公司。\n想想,ZOOM 还是疫情期间我们公司用过的远程通信会议软件。印象还不错,有这样的工程师和面试官藏于其中,我也是服了。难倒他是传说中的大大神?据我所知,国外对国内的互联网软件技术设施基本呈碾压态势,中国大部分企业所用的框架、中间件、基础设施等基本是拿国外的来用或者做定制化,真正有自研的很少,有什么好自满的呢?\n阿优文化-高工 # 阿优文化有四轮技术面。其中第一个技术面给我印象比较深刻。看上去,面试官对操作系统的原理机制特别擅长和熟悉。很多问题我都没答上来。本以为挂了,不过又给了扳回一局的机会。第二位面试问的项目经历和技术问题是我很熟悉的。第三位面试官问的比较广泛,有答的上来的,有答不上来的。不过面试官很耐心。第四位是技术总监,也问得很广泛细致。\n整体来说,面试氛围还是很宽松的。不过,阿优当时的招聘需求并不强烈,估计是希望后续有机会时再联系我。可惜我那时准备回武汉了。主要是考虑父母年事已高,希望能多陪陪父母。\n想想,我想问题做决策还是过于简单的,不会做很复杂的计算和权衡。\n小米-专家/架构 # 应聘小米,主要是因为职位与之前在有赞做的很相似,都是做交易中台相关。浏览小米官网之后,觉得他们做的事情很棒,可是与我想做教育文化事业的初衷不太贴合。\n加入小米的意愿不太强烈,面试也就失去了大半动力。我这个性子还是要改一改。\n视觉中国-高工 # 围绕技术、项目和经历来问。总体来说,技术深度并不是太难,项目方面也涉及到了。人力面前辈很温和,我以为会针对自己的经历进行一番“轰炸”,结果是为前辈讲了讲有赞的产品服务和生意模式,然后略略带了下自己的一些经历。\n科大讯飞-架构师 # 一二面,感觉面试官对安排的面试不太感兴趣。架构师,至少是一个对技术和设计能力非常高要求的职位。一面的技术和架构都问了些,二面总围绕我的背景和非技术相关的东西问,似乎对我的外在更关注,而对我自身的技术和设计能力不感兴趣。交流偏浅。\n能力固然有高下之分,但尊重人才的基本礼节却是不变的。尊重人才,是指聚焦人才的能力和才学,而不是一些与才学不甚相关的东西。\n青藤云-高工 # 青藤云的技术面试风格是温和的。感受到坦率交流的味道,被认可的感觉。感受到 HR 求才若渴的心情。和我之前认为的“应当用其实力去赢得对方的尊重和赏识”不谋而合。\n腾讯会议-高工 # 和腾讯面试官是用腾讯会议软件面试腾讯会议的职位。哈哈。由于网络不太稳定,面试过程充满了磕磕碰碰,一句话没说完整就听不清楚了。可想情况如何。但是我们都很有很有很有耐心,最终一起完成了一面。面试是双方智慧与力量的较量,更是双方一起去完成一件事情、发现彼此的合作。这样想来,传统的“单方考验筛选式”的面试观念需要革新。\n由于我已经拿到 offer , 且腾讯会议的事情并不太贴合自己的初衷,因此,我与腾讯方面沟通,停止了二面。\n最终选择 # 当拿到多个 offer 时,如何选择呢?我个人主要看重:\n志趣与驱动力; 薪资待遇; 公司发展前景和个人发展空间; 工作氛围; 小而有战斗力的企业。 在视觉中国与青藤云之间如何选择?作个对比:\n薪资待遇:两者的薪资待遇不相上下,也都是认可我的;视觉中国给出的是 Leader 的职位,而青藤云给出的是核心业务的承诺; 工作氛围:青藤云应该更偏工程师文化氛围,而视觉中国更偏业务化; 挑战性:青藤云的技术挑战更强,而视觉中国的业务挑战性更强; 志趣与驱动力:视觉中国更符合我想做文化的事情,而青藤云安全并不贴合我想做教育文化事业的初衷,而且比较偏技术和底层(我更希望做一些人文性的事情)。但青藤云做的是关于安全的事情,安全是一件很有价值很有意义的事情。而且,以后安全也可以服务于教育行业。有点曲线救国的味道。尤其是创始人张福的理想主义信念“让安全之光照亮互联网的每个角落”及自己的身体力行,让人更有一些触动。最终,我觉得做安全比做图片版权保护稍胜出一小筹。 此外,我觉得做教育,更适合自己的是编程教育,或者是工程师教育。我还想成为一名系统设计师。还需要积累更多生产实践经验。可以多与初中级工程师打交道,在企业内部做培训指导。或者工作之余录制视频,上传到 B 站,服务广大吃瓜群众。将来,我或许还会写一本关于编程设计的书,汇聚毕生所学。\n因此,经过一天慎重的考虑,我决定,加入青藤云安全。当然,做这个选择的同时,也意味着我选择了一个更大的挑战:在安全方面我基本一穷二白,需要学习很多很多的知识和经验,对于我这个大龄程序员来说,是一项不小的挑战。\n小结 # 很多事情都有解决的方法,即使“头疼的”大龄程序员找工作也不例外。确立明确清晰的目标、制定科学合理的决策、持续的努力、掌握基本面、恰当的出击,终能斩获胜利的果实。但要强调一下:功夫在平时。平时要是不累积好,面试的时候就要花更多时间去学习,会受挫、磕磕碰碰、过得也不太舒坦。还是平摊到平时比较好。此外,平时视野也要保持开阔,切忌在面试的时候才“幡然醒悟”。\n一个重要经验是,要善于从失败中学习。正是在杭州四个月空档期的持续学习、思考、积累和提炼,以及面试失败的反思、不断调整对策、完善准备、改善原有的短板,采取更为合理的方式,才在回武汉的短短两个周内拿到比较满意的 offer 。\n此外,值得提及的是,对于技术人员,写博客是一件很有价值的事情。面试通过沟通去了解对方,有其局限性所在。面试未能筛选出符合的人才其实是有比较大概率的:\n面试的时间很短,即使是很有经验的面试官,也会看走眼(根本局限性); 面试官问到的正好是自己不会的(运气问题); 面试官情绪不好,没兴趣(运气问题); 面试官自身的水平。 因此,具备真才实学而被 PASS 掉,并不值得伤心。写博客的意义在于,有更多展示自己思考和平时工作的维度。\n尊重人才的企业,一定是希望从多方面去认识候选人(在优点和缺点之间选择确认是否符合期望),包括博客;不尊重人才的企业,则会倾向于用偷懒的方法,对候选人真实的本领不在意,用一些外在的标准去快速过滤,固然高效,最终对人才的识别能力并不会有多大进步。\n经过这一段面试的历炼,我觉得现在相比离职时的自己,又有了不少进步的。不说脱胎换骨,至少也是蜕了一层皮吧。差距,差距还是有的。起码面试那些知名大厂企业的技术专家和架构师还有差距。这与我平时工作的挑战性、认知视野的局限性及总结不足有关。下一次,我希望积蓄足够实力做到更好,和内心热爱的有价值有意义的事情再近一些些。\n面试,其实也是一段工作经历。\n"},{"id":672,"href":"/zh/docs/technology/Interview/cs-basics/network/application-layer-protocol/","title":"应用层常见协议总结(应用层)","section":"Network","content":" HTTP:超文本传输协议 # 超文本传输协议(HTTP,HyperText Transfer Protocol) 是一种用于传输超文本和多媒体内容的协议,主要是为 Web 浏览器与 Web 服务器之间的通信而设计的。当我们使用浏览器浏览网页的时候,我们网页就是通过 HTTP 请求进行加载的。\nHTTP 使用客户端-服务器模型,客户端向服务器发送 HTTP Request(请求),服务器响应请求并返回 HTTP Response(响应),整个过程如下图所示。\nHTTP 协议基于 TCP 协议,发送 HTTP 请求之前首先要建立 TCP 连接也就是要经历 3 次握手。目前使用的 HTTP 协议大部分都是 1.1。在 1.1 的协议里面,默认是开启了 Keep-Alive 的,这样的话建立的连接就可以在多次请求中被复用了。\n另外, HTTP 协议是”无状态”的协议,它无法记录客户端用户的状态,一般我们都是通过 Session 来记录客户端用户的状态。\nWebsocket:全双工通信协议 # WebSocket 是一种基于 TCP 连接的全双工通信协议,即客户端和服务器可以同时发送和接收数据。\nWebSocket 协议在 2008 年诞生,2011 年成为国际标准,几乎所有主流较新版本的浏览器都支持该协议。不过,WebSocket 不只能在基于浏览器的应用程序中使用,很多编程语言、框架和服务器都提供了 WebSocket 支持。\nWebSocket 协议本质上是应用层的协议,用于弥补 HTTP 协议在持久通信能力上的不足。客户端和服务器仅需一次握手,两者之间就直接可以创建持久性的连接,并进行双向数据传输。\n下面是 WebSocket 的常见应用场景:\n视频弹幕 实时消息推送,详见 Web 实时消息推送详解这篇文章 实时游戏对战 多用户协同编辑 社交聊天 …… WebSocket 的工作过程可以分为以下几个步骤:\n客户端向服务器发送一个 HTTP 请求,请求头中包含 Upgrade: websocket 和 Sec-WebSocket-Key 等字段,表示要求升级协议为 WebSocket; 服务器收到这个请求后,会进行升级协议的操作,如果支持 WebSocket,它将回复一个 HTTP 101 状态码,响应头中包含 ,Connection: Upgrade和 Sec-WebSocket-Accept: xxx 等字段、表示成功升级到 WebSocket 协议。 客户端和服务器之间建立了一个 WebSocket 连接,可以进行双向的数据传输。数据以帧(frames)的形式进行传送,WebSocket 的每条消息可能会被切分成多个数据帧(最小单位)。发送端会将消息切割成多个帧发送给接收端,接收端接收消息帧,并将关联的帧重新组装成完整的消息。 客户端或服务器可以主动发送一个关闭帧,表示要断开连接。另一方收到后,也会回复一个关闭帧,然后双方关闭 TCP 连接。 另外,建立 WebSocket 连接之后,通过心跳机制来保持 WebSocket 连接的稳定性和活跃性。\nSMTP:简单邮件传输(发送)协议 # 简单邮件传输(发送)协议(SMTP,Simple Mail Transfer Protocol) 基于 TCP 协议,是一种用于发送电子邮件的协议\n注意 ⚠️:接受邮件的协议不是 SMTP 而是 POP3 协议。\nSMTP 协议这块涉及的内容比较多,下面这两个问题比较重要:\n电子邮件的发送过程 如何判断邮箱是真正存在的? 电子邮件的发送过程?\n比如我的邮箱是“ dabai@cszhinan.com”,我要向“ xiaoma@qq.com”发送邮件,整个过程可以简单分为下面几步:\n通过 SMTP 协议,我将我写好的邮件交给 163 邮箱服务器(邮局)。 163 邮箱服务器发现我发送的邮箱是 qq 邮箱,然后它使用 SMTP 协议将我的邮件转发到 qq 邮箱服务器。 qq 邮箱服务器接收邮件之后就通知邮箱为“ xiaoma@qq.com”的用户来收邮件,然后用户就通过 POP3/IMAP 协议将邮件取出。 如何判断邮箱是真正存在的?\n很多场景(比如邮件营销)下面我们需要判断我们要发送的邮箱地址是否真的存在,这个时候我们可以利用 SMTP 协议来检测:\n查找邮箱域名对应的 SMTP 服务器地址 尝试与服务器建立连接 连接成功后尝试向需要验证的邮箱发送邮件 根据返回结果判定邮箱地址的真实性 推荐几个在线邮箱是否有效检测工具:\nhttps://verify-email.org/ http://tool.chacuo.net/mailverify https://www.emailcamel.com/ POP3/IMAP:邮件接收的协议 # 这两个协议没必要多做阐述,只需要了解 POP3 和 IMAP 两者都是负责邮件接收的协议 即可(二者也是基于 TCP 协议)。另外,需要注意不要将这两者和 SMTP 协议搞混淆了。SMTP 协议只负责邮件的发送,真正负责接收的协议是 POP3/IMAP。\nIMAP 协议是比 POP3 更新的协议,它在功能和性能上都更加强大。IMAP 支持邮件搜索、标记、分类、归档等高级功能,而且可以在多个设备之间同步邮件状态。几乎所有现代电子邮件客户端和服务器都支持 IMAP。\nFTP:文件传输协议 # FTP 协议 基于 TCP 协议,是一种用于在计算机之间传输文件的协议,可以屏蔽操作系统和文件存储方式。\nFTP 是基于客户—服务器(C/S)模型而设计的,在客户端与 FTP 服务器之间建立两个连接。如果我们要基于 FTP 协议开发一个文件传输的软件的话,首先需要搞清楚 FTP 的原理。关于 FTP 的原理,很多书籍上已经描述的非常详细了:\nFTP 的独特的优势同时也是与其它客户服务器程序最大的不同点就在于它在两台通信的主机之间使用了两条 TCP 连接(其它客户服务器应用程序一般只有一条 TCP 连接):\n控制连接:用于传送控制信息(命令和响应) 数据连接:用于数据传送; 这种将命令和数据分开传送的思想大大提高了 FTP 的效率。\n注意 ⚠️:FTP 是一种不安全的协议,因为它在传输过程中不会对数据进行加密。因此,FTP 传输的文件可能会被窃听或篡改。建议在传输敏感数据时使用更安全的协议,如 SFTP(SSH File Transfer Protocol,一种基于 SSH 协议的安全文件传输协议,用于在网络上安全地传输文件)。\nTelnet:远程登陆协议 # Telnet 协议 基于 TCP 协议,用于通过一个终端登陆到其他服务器。Telnet 协议的最大缺点之一是所有数据(包括用户名和密码)均以明文形式发送,这有潜在的安全风险。这就是为什么如今很少使用 Telnet,而是使用一种称为 SSH 的非常安全的网络传输协议的主要原因。\nSSH:安全的网络传输协议 # SSH(Secure Shell) 基于 TCP 协议,通过加密和认证机制实现安全的访问和文件传输等业务。\nSSH 的经典用途是登录到远程电脑中执行命令。除此之外,SSH 也支持隧道协议、端口映射和 X11 连接(允许用户在本地运行远程服务器上的图形应用程序)。借助 SFTP(SSH File Transfer Protocol) 或 SCP(Secure Copy Protocol) 协议,SSH 还可以安全传输文件。\nSSH 使用客户端-服务器模型,默认端口是 22。SSH 是一个守护进程,负责实时监听客户端请求,并进行处理。大多数现代操作系统都提供了 SSH。\n如下图所示,SSH Client(SSH 客户端)和 SSH Server(SSH 服务器)通过公钥交换生成共享的对称加密密钥,用于后续的加密通信。\nRTP:实时传输协议 # RTP(Real-time Transport Protocol,实时传输协议)通常基于 UDP 协议,但也支持 TCP 协议。它提供了端到端的实时传输数据的功能,但不包含资源预留存、不保证实时传输质量,这些功能由 WebRTC 实现。\nRTP 协议分为两种子协议:\nRTP(Real-time Transport Protocol,实时传输协议):传输具有实时特性的数据。 RTCP(RTP Control Protocol,RTP 控制协议):提供实时传输过程中的统计信息(如网络延迟、丢包率等),WebRTC 正是根据这些信息处理丢包 DNS:域名系统 # DNS(Domain Name System,域名管理系统)基于 UDP 协议,用于解决域名和 IP 地址的映射问题。\n参考 # 《计算机网络自顶向下方法》(第七版) RTP 协议介绍: https://mthli.xyz/rtp-introduction/ "},{"id":673,"href":"/zh/docs/technology/Interview/distributed-system/rpc/httprpc/","title":"有了 HTTP 协议,为什么还要有 RPC ?","section":"Rpc","content":" 本文来自 小白 debug投稿,原文: https://juejin.cn/post/7121882245605883934 。\n我想起了我刚工作的时候,第一次接触 RPC 协议,当时就很懵,我 HTTP 协议用的好好的,为什么还要用 RPC 协议?\n于是就到网上去搜。\n不少解释显得非常官方,我相信大家在各种平台上也都看到过,解释了又好像没解释,都在用一个我们不认识的概念去解释另外一个我们不认识的概念,懂的人不需要看,不懂的人看了还是不懂。\n这种看了,又好像没看的感觉,云里雾里的很难受,我懂。\n为了避免大家有强烈的审丑疲劳,今天我们来尝试重新换个方式讲一讲。\n从 TCP 聊起 # 作为一个程序员,假设我们需要在 A 电脑的进程发一段数据到 B 电脑的进程,我们一般会在代码里使用 socket 进行编程。\n这时候,我们可选项一般也就TCP 和 UDP 二选一。TCP 可靠,UDP 不可靠。 除非是马总这种神级程序员(早期 QQ 大量使用 UDP),否则,只要稍微对可靠性有些要求,普通人一般无脑选 TCP 就对了。\n类似下面这样。\nfd = socket(AF_INET,SOCK_STREAM,0); 其中SOCK_STREAM,是指使用字节流传输数据,说白了就是TCP 协议。\n在定义了 socket 之后,我们就可以愉快的对这个 socket 进行操作,比如用bind()绑定 IP 端口,用connect()发起建连。\n在连接建立之后,我们就可以使用send()发送数据,recv()接收数据。\n光这样一个纯裸的 TCP 连接,就可以做到收发数据了,那是不是就够了?\n不行,这么用会有问题。\n使用纯裸 TCP 会有什么问题 # 八股文常背,TCP 是有三个特点,面向连接、可靠、基于字节流。\n这三个特点真的概括的 非常精辟 ,这个八股文我们没白背。\n每个特点展开都能聊一篇文章,而今天我们需要关注的是 基于字节流 这一点。\n字节流可以理解为一个双向的通道里流淌的二进制数据,也就是 01 串 。纯裸 TCP 收发的这些 01 串之间是 没有任何边界 的,你根本不知道到哪个地方才算一条完整消息。\n正因为这个没有任何边界的特点,所以当我们选择使用 TCP 发送 \u0026ldquo;夏洛\u0026quot;和\u0026quot;特烦恼\u0026rdquo; 的时候,接收端收到的就是 \u0026ldquo;夏洛特烦恼\u0026rdquo; ,这时候接收端没发区分你是想要表达 \u0026ldquo;夏洛\u0026rdquo;+\u0026ldquo;特烦恼\u0026rdquo; 还是 \u0026ldquo;夏洛特\u0026rdquo;+\u0026ldquo;烦恼\u0026rdquo; 。\n这就是所谓的 粘包问题,之前也写过一篇专门的 文章聊过这个问题。\n说这个的目的是为了告诉大家,纯裸 TCP 是不能直接拿来用的,你需要在这个基础上加入一些 自定义的规则 ,用于区分 消息边界 。\n于是我们会把每条要发送的数据都包装一下,比如加入 消息头 ,消息头里写清楚一个完整的包长度是多少,根据这个长度可以继续接收数据,截取出来后它们就是我们真正要传输的 消息体 。\n而这里头提到的 消息头 ,还可以放各种东西,比如消息体是否被压缩过和消息体格式之类的,只要上下游都约定好了,互相都认就可以了,这就是所谓的 协议。\n每个使用 TCP 的项目都可能会定义一套类似这样的协议解析标准,他们可能 有区别,但原理都类似。\n于是基于 TCP,就衍生了非常多的协议,比如 HTTP 和 RPC。\nHTTP 和 RPC # RPC 其实是一种调用方式 # 我们回过头来看网络的分层图。\nTCP 是传输层的协议 ,而基于 TCP 造出来的 HTTP 和各类 RPC 协议,它们都只是定义了不同消息格式的 应用层协议 而已。\nHTTP(Hyper Text Transfer Protocol)协议又叫做 超文本传输协议 。我们用的比较多,平时上网在浏览器上敲个网址就能访问网页,这里用到的就是 HTTP 协议。\n而 RPC(Remote Procedure Call)又叫做 远程过程调用,它本身并不是一个具体的协议,而是一种 调用方式 。\n举个例子,我们平时调用一个 本地方法 就像下面这样。\nres = localFunc(req) 如果现在这不是个本地方法,而是个远端服务器暴露出来的一个方法remoteFunc,如果我们还能像调用本地方法那样去调用它,这样就可以屏蔽掉一些网络细节,用起来更方便,岂不美哉?\nres = remoteFunc(req) 基于这个思路,大佬们造出了非常多款式的 RPC 协议,比如比较有名的gRPC,thrift。\n值得注意的是,虽然大部分 RPC 协议底层使用 TCP,但实际上 它们不一定非得使用 TCP,改用 UDP 或者 HTTP,其实也可以做到类似的功能。\n到这里,我们回到文章标题的问题。\n那既然有 RPC 了,为什么还要有 HTTP 呢? # 其实,TCP 是 70 年 代出来的协议,而 HTTP 是 90 年代 才开始流行的。而直接使用裸 TCP 会有问题,可想而知,这中间这么多年有多少自定义的协议,而这里面就有 80 年代 出来的RPC。\n所以我们该问的不是 既然有 HTTP 协议为什么要有 RPC ,而是 为什么有 RPC 还要有 HTTP 协议?\n现在电脑上装的各种联网软件,比如 xx 管家,xx 卫士,它们都作为客户端(Client) 需要跟服务端(Server) 建立连接收发消息,此时都会用到应用层协议,在这种 Client/Server (C/S) 架构下,它们可以使用自家造的 RPC 协议,因为它只管连自己公司的服务器就 ok 了。\n但有个软件不同,浏览器(Browser) ,不管是 Chrome 还是 IE,它们不仅要能访问自家公司的服务器(Server) ,还需要访问其他公司的网站服务器,因此它们需要有个统一的标准,不然大家没法交流。于是,HTTP 就是那个时代用于统一 Browser/Server (B/S) 的协议。\n也就是说在多年以前,HTTP 主要用于 B/S 架构,而 RPC 更多用于 C/S 架构。但现在其实已经没分那么清了,B/S 和 C/S 在慢慢融合。 很多软件同时支持多端,比如某度云盘,既要支持网页版,还要支持手机端和 PC 端,如果通信协议都用 HTTP 的话,那服务器只用同一套就够了。而 RPC 就开始退居幕后,一般用于公司内部集群里,各个微服务之间的通讯。\n那这么说的话,都用 HTTP 得了,还用什么 RPC?\n仿佛又回到了文章开头的样子,那这就要从它们之间的区别开始说起。\nHTTP 和 RPC 有什么区别 # 我们来看看 RPC 和 HTTP 区别比较明显的几个点。\n服务发现 # 首先要向某个服务器发起请求,你得先建立连接,而建立连接的前提是,你得知道 IP 地址和端口 。这个找到服务对应的 IP 端口的过程,其实就是 服务发现。\n在 HTTP 中,你知道服务的域名,就可以通过 DNS 服务 去解析得到它背后的 IP 地址,默认 80 端口。\n而 RPC 的话,就有些区别,一般会有专门的中间服务去保存服务名和 IP 信息,比如 Consul、Etcd、Nacos、ZooKeeper,甚至是 Redis。想要访问某个服务,就去这些中间服务去获得 IP 和端口信息。由于 DNS 也是服务发现的一种,所以也有基于 DNS 去做服务发现的组件,比如 CoreDNS。\n可以看出服务发现这一块,两者是有些区别,但不太能分高低。\n底层连接形式 # 以主流的 HTTP1.1 协议为例,其默认在建立底层 TCP 连接之后会一直保持这个连接(keep alive),之后的请求和响应都会复用这条连接。\n而 RPC 协议,也跟 HTTP 类似,也是通过建立 TCP 长链接进行数据交互,但不同的地方在于,RPC 协议一般还会再建个 连接池,在请求量大的时候,建立多条连接放在池内,要发数据的时候就从池里取一条连接出来,用完放回去,下次再复用,可以说非常环保。\n由于连接池有利于提升网络请求性能,所以不少编程语言的网络库里都会给 HTTP 加个连接池,比如 Go 就是这么干的。\n可以看出这一块两者也没太大区别,所以也不是关键。\n传输的内容 # 基于 TCP 传输的消息,说到底,无非都是 消息头 Header 和消息体 Body。\nHeader 是用于标记一些特殊信息,其中最重要的是 消息体长度。\nBody 则是放我们真正需要传输的内容,而这些内容只能是二进制 01 串,毕竟计算机只认识这玩意。所以 TCP 传字符串和数字都问题不大,因为字符串可以转成编码再变成 01 串,而数字本身也能直接转为二进制。但结构体呢,我们得想个办法将它也转为二进制 01 串,这样的方案现在也有很多现成的,比如 JSON,Protocol Buffers (Protobuf) 。\n这个将结构体转为二进制数组的过程就叫 序列化 ,反过来将二进制数组复原成结构体的过程叫 反序列化。\n对于主流的 HTTP1.1,虽然它现在叫超文本协议,支持音频视频,但 HTTP 设计 初是用于做网页文本展示的,所以它传的内容以字符串为主。Header 和 Body 都是如此。在 Body 这块,它使用 JSON 来 序列化 结构体数据。\n我们可以随便截个图直观看下。\n可以看到这里面的内容非常多的冗余,显得非常啰嗦。最明显的,像 Header 里的那些信息,其实如果我们约定好头部的第几位是 Content-Type,就不需要每次都真的把 Content-Type 这个字段都传过来,类似的情况其实在 Body 的 JSON 结构里也特别明显。\n而 RPC,因为它定制化程度更高,可以采用体积更小的 Protobuf 或其他序列化协议去保存结构体数据,同时也不需要像 HTTP 那样考虑各种浏览器行为,比如 302 重定向跳转啥的。因此性能也会更好一些,这也是在公司内部微服务中抛弃 HTTP,选择使用 RPC 的最主要原因。\n当然上面说的 HTTP,其实 特指的是现在主流使用的 HTTP1.1,HTTP2在前者的基础上做了很多改进,所以 性能可能比很多 RPC 协议还要好,甚至连gRPC底层都直接用的HTTP2。\n那么问题又来了。\n为什么既然有了 HTTP2,还要有 RPC 协议? # 这个是由于 HTTP2 是 2015 年出来的。那时候很多公司内部的 RPC 协议都已经跑了好些年了,基于历史原因,一般也没必要去换了。\n总结 # 纯裸 TCP 是能收发数据,但它是个无边界的数据流,上层需要定义消息格式用于定义 消息边界 。于是就有了各种协议,HTTP 和各类 RPC 协议就是在 TCP 之上定义的应用层协议。 RPC 本质上不算是协议,而是一种调用方式,而像 gRPC 和 Thrift 这样的具体实现,才是协议,它们是实现了 RPC 调用的协议。目的是希望程序员能像调用本地方法那样去调用远端的服务方法。同时 RPC 有很多种实现方式,不一定非得基于 TCP 协议。 从发展历史来说,HTTP 主要用于 B/S 架构,而 RPC 更多用于 C/S 架构。但现在其实已经没分那么清了,B/S 和 C/S 在慢慢融合。 很多软件同时支持多端,所以对外一般用 HTTP 协议,而内部集群的微服务之间则采用 RPC 协议进行通讯。 RPC 其实比 HTTP 出现的要早,且比目前主流的 HTTP1.1 性能要更好,所以大部分公司内部都还在使用 RPC。 HTTP2.0 在 HTTP1.1 的基础上做了优化,性能可能比很多 RPC 协议都要好,但由于是这几年才出来的,所以也不太可能取代掉 RPC。 "},{"id":674,"href":"/zh/docs/technology/Interview/high-quality-technical-articles/advanced-programmer/20-bad-habits-of-bad-programmers/","title":"糟糕程序员的 20 个坏习惯","section":"Advanced Programmer","content":" 推荐语:Kaito 大佬的一篇文章,很实用的建议!\n原文地址: https://mp.weixin.qq.com/s/6hUU6SZsxGPWAIIByq93Rw\n我想你肯定遇到过这样一类程序员:他们无论是写代码,还是写文档,又或是和别人沟通,都显得特别专业。每次遇到这类人,我都在想,他们到底是怎么做到的?\n随着工作时间的增长,渐渐地我也总结出一些经验,他们身上都保持着一些看似很微小的优秀习惯,但正是因为这些习惯,体现出了一个优秀程序员的基本素养。\n但今天我们来换个角度,来看看一个糟糕程序员有哪些坏习惯?只要我们都能避开这些问题,就可以逐渐向一个优秀程序员靠近。\n1、技术名词拼写不规范 # 无论是个人简历,还是技术文档,我经常看到拼写不规范的技术名词,例如 JAVA、javascript、python、MySql、Hbase、restful。\n正确的拼写应该是 Java、JavaScript、Python、MySQL、HBase、RESTful,不要小看这个问题,很多面试官很有可能因为这一点刷掉你的简历。\n2、写文档,中英文混排不规范 # 中文描述使用英文标点符号,英文和数字使用了全角字符,中文与英文、数字之间没有空格等等。\n其中很多人会忽视中文和英文、数字之间加一个「空格」,这样排版阅读起来会更舒服。之前我的文章排版,都是遵循了这些细节。\n3、重要逻辑不写注释,或写得很拖沓 # 复杂且重要的逻辑代码,很多程序员不写注释,除了自己能看懂代码逻辑,其他人根本看不懂。或者是注释虽然写了,但写得很拖沓,没有逻辑可言。\n重要的逻辑不止要写注释,还要写得简洁、清晰。如果是一眼就能读懂的简单代码,可以不加注释。\n4、写复杂冗长的函数 # 一个函数几百行,一个文件上千行代码,复杂函数不做拆分,导致代码变得越来越难维护,最后谁也不敢动。\n基本的设计模式还是要遵守的,例如单一职责,一个函数只做一件事,开闭原则,对扩展开放,对修改关闭。\n如果函数逻辑确实复杂,也至少要保证主干逻辑足够清晰。\n5、不看官方文档,只看垃圾博客 # 很多人遇到问题不先去看官方文档,而是热衷于去看垃圾博客,这些博客的内容都是互相抄袭,错误百出。\n其实很多软件官方文档写得已经非常好了,常见问题都能找到答案,认真读一读官方文档,比看垃圾博客强一百倍,要养成看官方文档的好习惯。\n6、宣扬内功无用论 # 有些人天天追求日新月异的开源项目和框架,却不肯花时间去啃一啃底层原理,常见问题虽然可以解决,但遇到稍微深一点的问题就束手无策。\n很多高大上的架构设计,思路其实都源于底层。想一想,像计算机体系结构、操作系统、网络协议这些东西,经过多少年演进才变为现在的样子,演进过程中遇到的复杂问题比比皆是,理解了解决这些问题的思路,再看上层技术会变得很简单。\n7、乐于炫技 # 有些人天天把「高大上」的技术名词挂在嘴边,生怕别人不知道自己学了什么高深技术,嘴上乐于炫技,但别人一问他细节就会哑口无言。\n8、不接受质疑 # 自己设计的方案,别人提出疑问时只会回怼,而不是理性分析利弊,抱着学习的心态交流。\n这些人学了点东西就觉得自己很有本事,殊不知只是自己见识太少。\n9、接口协议不规范 # 和别人定 API 协议全靠口头沟通,不给规范的文档说明,甚至到了测试联调时会发现,竟然和协商的还不一样,或者改协议了却不通知对接方,合作体验极差。\n10、遇到问题自己死磕 # 很初级程序员容易犯的问题,遇到问题只会自己死磕,拖到 deadline 也没有产出,领导来问才知道有问题解决不了。\n有问题及时反馈才是对自己负责,对团队负责。\n11、一说就会,一写就废 # 平时技术方案吹得天花乱坠,一让他写代码就废,典型的眼高手低选手。\n12、表达没有逻辑,不站在对方角度看问题 # 讨论问题不交代背景,上来就说自己的方案,别人听得云里雾里,让你从头描述你又讲不明白。\n学会沟通和表达,是合作的基础。\n13、不主动思考,伸手党 # 遇到问题不去 google,不做思考就向别人提问,喜欢做伸手党。\n每个人的时间都很宝贵,大家都更喜欢你带着自己的思考来提问,一来可以规避很多低级问题,二来可以提高交流质量。\n14、经常犯重复的错误 # 出问题后说下次会注意,但下次问题依旧,对自己不负责任,说到底是态度问题。\n15、加功能不考虑扩展性 # 加新功能只关注某一小块业务,不考虑系统整体的扩展性,堆代码行为严重。\n要学会分析需求和未来可能发生的变化,设计更通用的解决方案,降低后期开发成本。\n16、接口不自测,出问题不打日志 # 自己开发的接口不自测就和别人联调,出了问题又说没打日志,协作效率极低。\n17、提交代码不规范 # 很多人提交代码不写描述,或者写的是无意义的描述,尤其是修改很少代码时,这种情况会导致回溯问题成本变高。\n制定代码提交规范,能让你在每一次提交代码时,不会做太随意的代码修改。\n18、手动修改生产环境数据库 # 直连生产环境数据库修改数据,更有 UPDATE / DELETE SQL 忘写 WHERE 条件的情况,产生数据事故。\n修改生产环境数据库一定要谨慎再谨慎,建议操作前先找同事 review 代码再操作。\n19、没理清需求就直接写代码 # 很多程序员接到需求后,不怎么思考就开始写代码,需求和自己理解的有偏差,造成无意义返工。\n多花些时间梳理需求,能规避很多不合理的问题。\n20、重要设计不写文档 # 重要的设计没有文档输出,和别人交接系统时只做口头描述,丢失关键信息。\n有时候理解一个设计方案,一个好的文档要比看几百行代码更高效。\n总结 # 以上这些不良习惯,你命中几个呢?或者你身边有没有碰到这样的人?\n我认为提早规避这些问题,是成为一个优秀程序员必须要做的。这些习惯总结起来大致分为这 4 个方面:\n良好的编程修养 谦虚的学习心态 良好的沟通和表达 注重团队协作 优秀程序员的专业技能,我们可能很难在短时间内学会,但这些基本的职业素养,是可以在短期内做到的。\n希望你我可以有则改之,无则加勉。\n"},{"id":675,"href":"/zh/docs/technology/Interview/high-quality-technical-articles/interview/the-experience-of-get-offer-from-over-20-big-companies/","title":"斩获 20+ 大厂 offer 的面试经验分享","section":"Interview","content":" 推荐语:很实用的面试经验分享!\n原文地址: https://mp.weixin.qq.com/s/HXKg6-H0kGUU2OA1DS43Bw\n突然回想起当年,我也在秋招时也斩获了 20+的互联网各大厂 offer。现在想起来也是有点唏嘘,毕竟拿得再多也只能选择一家。不过许多朋友想让我分享下互联网面试方法,今天就来给大家仔细讲讲打法!\n如今金九银十已经过去,满是硝烟的求职战场上也只留下一处处炮灰。在现在这段日子,又是重新锻炼,时刻准备着明年金三银四的时候。\n对于还没毕业的学生来说,明年三四月是春招补招或者实习招聘的机会;对于职场老油条来说,明年三四月也是拿完年终奖准备提桶跑路的时候。\n所以这段日子,就需要好好准备积累面试方法以及面试经验,明年的冲锋陷阵打下基础。这篇文章将为大家讲讲,程序员应该如何准备好技术面试。\n一般而言,互联网公司技术岗的招聘都会根据需要设置为 3 ~ 4 轮面试,一些 HC 较少的岗位可能还会经历 5 ~ 8 轮面试不等。除此之外,视公司情况,面试之前还可能也会设定相应的笔试环节。\n多轮的面试中包括技术面和 HR 面。相对来说,在整体的招聘流程中,技术面的决定性比较重要,HR 面更多的是确认候选人的基本情况和职业素养。\n不过在某些大厂,HR 也具有一票否决权,所以每一轮面试都该好好准备和应对。技术面试一般可分为五个部分:\n双方自我介绍 项目经历 专业知识考查 编码能力考察 候选人 Q\u0026amp;A 双方自我介绍 # 面试往往是以自我介绍作为开场,很多时候一段条理清晰逻辑明确的开场会决定整场面试的氛围和节奏。\n作为候选人,我们可以在自我介绍中适当的为本次面试提供指向性的信息,以辅助面试官去发掘自己身上的亮点和长处。\n其实自我介绍并不是简单的个人基本情况的条条过目,而是对自己简历的有效性概括。\n什么是有效性概括呢,就是意味着需要对简历中的信息进行核心关键词的提取整合。一段话下来,就能够让面试官对你整体的情况有了了解,从而能够引导面试官的联系提问。\n项目经历 # 项目经历是面试过程中非常重要的一环,特别是在社招的面试中。一般社招的职级越高,往往越看重项目经历。\n而对于一般的校招生而言,几份岗位度匹配度以及项目完整性高的项目经历可以成为面试的亮点,也是决定于拿SP or SSP的关键。\n但是准备好项目经历,并不是一件容易的事情。很多人并不清楚应该怎样去描述自己的项目,更不知道应该在经历中如何去体现自己的优势和亮点。\n这里针对项目经历给大家提几点建议:\n1、高效有条理的描述\n项目经历的一般是简历里篇幅最大的部分,所以在面试时这部分同样重要。在表述时,语言的逻辑和条理一定要清晰,以保证面试官能够在最快的时间抓到你的项目的整体思路。\n相信很多人都听说过写简历的各种原则,比如STAR、SMART等。但实际上这些原则都可以用来规范自己的表达逻辑。\nSTAR原则相对简单,用来在面试过程中规范自己的条理非常有效。所谓STAR,即Situation、Target、Action、Result。这跟写论文写文档的逻辑划分大体一致。\nSituation: 即项目背景,需要将项目提出的原因、现状以及出发点表述清楚。简单来说,就是要将项目提出的来龙去脉描述清晰。比如某某平台建设的原因,是切入用户怎样的痛点之类的。 Target: 即项目目标,这点描述的是项目预期达到或完成的程度。==最好是有可量化的指标和预期结果。==比如性能优化的指标、架构优化所带来的业务收益等等。 Action: 即方法方案,意味着完成项目具体实施的行为。这点在技术面试中最为重要,也是表现候选人能力的基础。==项目的方法或方案可以从技术栈出发,根据采用的不同技术点来具体写明解决了哪些问题。==比如用了什么框架/技术实现了什么架构/优化/设计,解决了项目中什么样的问题。 Result: 即项目获得结果,这点可以在面试中讲讲自己经历过项目后的思考和反思。这样会让面试官感受到你的成长和沉淀,会比直接的结果并动人。 2、充分准备项目亮点\n说实话,大部分人其实都没有十分亮眼的项目,但是并不意味着没有项目经历的亮点。特别是在面试中。\n在面试中,你可以通过充分的准备以及深入的思考来突出你的项目亮点。比如可以从以下几个方向入手:\n充分了解项目的业务逻辑和技术架构 熟悉项目的整体架构和关键设计 明确的知道业务架构或技术方案选型以及决策逻辑 深入掌握项目中涉及的组件以及框架 熟悉项目中的疑难杂症或长期遗留 bug 的解决方案 …… 专业知识考查 # 有经验的面试官往往会在对项目经历刨根问底的同时,从中考察你的专业知识。\n所谓专业知识,对于程序员而言就是意向岗位的计算机知识图谱。对于校招生来说,大部分都是计算机基础;而对于社招而言,很大部分可能是对应岗位的技能树。\n计算机基础主要就是计算机网络、操作系统、编程语言之类的,也就是所谓的八股文。虽然这些东西在实际的工作中可能用处并不多,但是却是面试官评估候选人潜力的标准。\n而对应岗位的技能树就需要根据具体的岗位来划分,比如说客户端岗位可能会问移动操作系统理解、端性能优化、客户端架构以及跨端框架之类的。跟直播视频相关的岗位,还会问音视频处理、通信等相关的知识。\n而后端岗位可能就更偏向于高可用架构、事务理论、分布式中间件以及一些服务化、异步、高可用可扩展的架构设计思想。\n总而言之,工作经验越丰富,岗位技术能的问题也就越深入。\n怎么在面试前去准备这些技术点,在这里我就不过多说了, 因为很多学习路线以及说的很清楚了。\n这里我就讲讲在应对面试的时候,该怎样去更好的表达描述清楚。\n这里针对专业知识考察给大家提几点建议:\n1、提前建立一份技术知识图谱\n在面试之前,可以先将自己比较熟悉的知识点做一个简单的归纳总结,根据不同方向和领域画个简单的草图。这是为了辅助自己在面试时能够进行合理的扩展和延伸。\n面试官一问一答形式的面试总是会给人不太好的面试体验,所以在回答技术要点的过程中,要善于利用自己已有的知识图谱来进行技术广度的扩展和技术深度的钻研。这样一来能够引导面试官往你擅长的方向去提问,二来能够尽可能多的展现自己的亮点。\n2、结合具体经验来总结理解\n技术点本身都是非常死板和冰冷的,但是如果能够将生硬的技术点与具体的案例结合起来描述,会让人眼前一亮。同时也能够表明自己是的的确确理解了该知识点。\n现在网上各种面试素材应有尽有,可能你背背题就能够应付面试官的提问。但是面试官也同样知道这点,所以他能够很清楚的判别出你是否在背题。\n因此,结合具体的经验来解释表达问题是能够防止被误认为背题的有效方法。可能有人会问了,那具体的经验哪里去找呢。\n这就得靠平时的积累了,平时需要多积累沉淀,多看大厂的各类技术输出。经验不一定是自己的,也可以是从别的地方总结而来的。\n此外,也可以结合自己在做项目的过程中的一些技术选型经验以及技术方案更新迭代的过程进行融会贯通,相互结合的来进行表述。\n编码能力考察 # 编码能力考察就是咱们俗称的手撕代码,也是许多同学最害怕的一关。很多人会觉得面试结果就是看手撕代码的表现,但其实并不一定。\n==首先得明确的一点是,编码能力不完全等于算法能力。==很多同学面试时候算法题明明写出来了,但是最终的面试评价却是编码能力一般。还有很多同学面试时算法题死活没通过,但是面试官却觉得他的编码能力还可以。\n所以一定要注意区分这点,编码能力不完全等于算法能力。从公司出发,如果纯粹为了出难度高的算法题来筛选候选人,是没有意义的。因为大家都知道,进了公司可能工作几年都写不了几个算法。\n要记住,做算法题只是一个用来验证编码能力和逻辑思维的手段和方式。\n当然说到底,在准备这一块的面试时,算法题肯定得刷,但是不该盲目追求难度,甚至是死记硬背。\n几点面试时的建议:\n1、数据结构和算法思想是基础\n算法本身实际上是逻辑思考的产物,所以掌握算法思想比会做某一道题要更有意义。数据结构是帮助实现算法的工具,这也很编程的基本能力。所以这二者的熟悉程度是手撕代码的基础。\n2、不要忽视编码规范\n这点就是提醒大家要记住,就算是一段很简单的算法题也能够从中看出你的编码能力。这往往就体现在一些基本的编码规范上。你说你编程经验有 3 年,但是发现连基本的函数封装类型保护都不会,让人怎么相信呢。\n3、沟通很重要\n手撕代码绝对不是一个闭卷考试的过程,而是一个相互沟通的过程。上面也说过,考察算法也是为了考察逻辑思维能力。所以让面试官知道你思考问题的思路以及逻辑比你直接写出答案更重要。\n不仅如此,提前沟通清楚思路,遇到题意不明确的地方及时询问,也是节省大家时间,给面试官留下好印象的机会。\n此外,自己写的代码一定要经得住推敲和质疑,自己能够讲的明白。这也是能够区分「背题」和「真正会做」的地方。\n最后,如果代码实在写不出来,但是也可以适当的表达自己的思路并与面试官交流探讨。毕竟面试也是一个学习的过程。\n候选人 Q\u0026amp;A # 一般正常的话,都会有候选人反问环节。倘若没有,可能是想让你回家等消息。\n反问环节其实也可以是面试中重要的环节,因为这个时候你能够从面试官口中获得关于公司关于岗位更具体真实的信息。\n这些信息可以帮助我们做出更全面更理性的决策,毕竟求职也是一个双向选择的过程。\n加分项 # 最后,给能够坚持看到最后的同学一个福利。我们来谈谈面试中的加分项。\n很多同学会觉得明明面试时候的问题都答上来了,但是最终却没有通过面试,或者面试评价并不高。这很有可能就是面试过程中缺少了亮点,可能你并不差,但是没有打动面试官的地方。\n一般面试官会从下面几个方面去考察候选人的亮点:\n1、沟通\n面试毕竟是问答与表达的艺术,所以你流利的表达,清晰有条理的思路自然能够增加面试官对你的高感度。同时如果还具有举一反三的思维,那也能够从侧面证明你的潜力。\n2、匹配度\n这一点毋庸置疑,但是却很容易被忽视。因为往往大家都会认为,匹配度不高的都在简历筛选阶段被刷掉了。但其实在面试过程中,面试官同样也会评估面试人与岗位的匹配度。\n这个匹配度与工作经历强相关,与之前做过的业务和技术联系很大。特别是某些垂直领域的技术岗位,比如财经、资金、音视频等。\n所以在面试中,如若有跟目标岗位匹配度很高的经历和项目,可以着重详细介绍。\n3、高业绩,有超出岗位的思考\n这点就是可遇不可及,毕竟不是所有人都能够拿着好业绩然后跳槽。但是上一份工作所带来的好业绩,以及在重要项目中的骨干身份会为自己的经历加分。\n同时,如果能在面试中表现出超出岗位本身的能力,更能引起面试官注意。比如具备一定的技术视野,具备良好的规划能力,或者对业务方向有比较深入的见解。这些都能够成为亮点。\n4、技术深度或广度\n相信很多人都听过,职场中最受欢迎的是T型人才。也就是在拥有一定技术广度的基础上,在自己擅长的领域十分拔尖。这样的人才的确很难得,既要求能够胜任自己的在职工作,又能够不设边界的学习和输出其它领域的知识。\n除此之外,比 T 型人才更为难得是所谓 π 型人才,相比于 T 型人才,有了不止一项拔尖的领域。这类人才更是公司会抢占的资源。\n总结 # 面试虽说是考察和筛选优秀人才的过程,但说到底还是人与人沟通并展现自我的方式。所以掌握有效面试的技巧也是帮助自己收获更多的工具。\n这篇文章其实算讲的是方法论,很多我们一看就明白的「道理」实施起来可能会很难。可能会遇到一个不按常理出牌的面试官,也可能也会遇到一个沟通困难的面试官,当然也可能会撞上一个不怎么匹配的岗位。\n总而言之,为了自己想要争取的东西,做好足够的准备总是没有坏处的。祝愿大家能成为π型人才,获得想要的offer!\n"},{"id":676,"href":"/zh/docs/technology/Interview/database/character-set/","title":"字符集详解","section":"Database","content":"MySQL 字符编码集中有两套 UTF-8 编码实现:utf8 和 utf8mb4。\n如果使用 utf8 的话,存储 emoji 符号和一些比较复杂的汉字、繁体字就会出错。\n为什么会这样呢?这篇文章可以从源头给你解答。\n字符集是什么? # 字符是各种文字和符号的统称,包括各个国家文字、标点符号、表情、数字等等。 字符集 就是一系列字符的集合。字符集的种类较多,每个字符集可以表示的字符范围通常不同,就比如说有些字符集是无法表示汉字的。\n计算机只能存储二进制的数据,那英文、汉字、表情等字符应该如何存储呢?\n我们要将这些字符和二进制的数据一一对应起来,比如说字符“a”对应“01100001”,反之,“01100001”对应 “a”。我们将字符对应二进制数据的过程称为\u0026quot;字符编码\u0026quot;,反之,二进制数据解析成字符的过程称为“字符解码”。\n字符编码是什么? # 字符编码是一种将字符集中的字符与计算机中的二进制数据相互转换的方法,可以看作是一种映射规则。也就是说,字符编码的目的是为了让计算机能够存储和传输各种文字信息。\n每种字符集都有自己的字符编码规则,常用的字符集编码规则有 ASCII 编码、 GB2312 编码、GBK 编码、GB18030 编码、Big5 编码、UTF-8 编码、UTF-16 编码等。\n有哪些常见的字符集? # 常见的字符集有:ASCII、GB2312、GB18030、GBK、Unicode……。\n不同的字符集的主要区别在于:\n可以表示的字符范围 编码方式 ASCII # ASCII (American Standard Code for Information Interchange,美国信息交换标准代码) 是一套主要用于现代美国英语的字符集(这也是 ASCII 字符集的局限性所在)。\n为什么 ASCII 字符集没有考虑到中文等其他字符呢? 因为计算机是美国人发明的,当时,计算机的发展还处于比较雏形的时代,还未在其他国家大规模使用。因此,美国发布 ASCII 字符集的时候没有考虑兼容其他国家的语言。\nASCII 字符集至今为止共定义了 128 个字符,其中有 33 个控制字符(比如回车、删除)无法显示。\n一个 ASCII 码长度是一个字节也就是 8 个 bit,比如“a”对应的 ASCII 码是“01100001”。不过,最高位是 0 仅仅作为校验位,其余 7 位使用 0 和 1 进行组合,所以,ASCII 字符集可以定义 128(2^7)个字符。\n由于,ASCII 码可以表示的字符实在是太少了。后来,人们对其进行了扩展得到了 ASCII 扩展字符集 。ASCII 扩展字符集使用 8 位(bits)表示一个字符,所以,ASCII 扩展字符集可以定义 256(2^8)个字符。\nGB2312 # 我们上面说了,ASCII 字符集是一种现代美国英语适用的字符集。因此,很多国家都捣鼓了一个适合自己国家语言的字符集。\nGB2312 字符集是一种对汉字比较友好的字符集,共收录 6700 多个汉字,基本涵盖了绝大部分常用汉字。不过,GB2312 字符集不支持绝大部分的生僻字和繁体字。\n对于英语字符,GB2312 编码和 ASCII 码是相同的,1 字节编码即可。对于非英字符,需要 2 字节编码。\nGBK # GBK 字符集可以看作是 GB2312 字符集的扩展,兼容 GB2312 字符集,共收录了 20000 多个汉字。\nGBK 中 K 是汉语拼音 Kuo Zhan(扩展)中的“Kuo”的首字母。\nGB18030 # GB18030 完全兼容 GB2312 和 GBK 字符集,纳入中国国内少数民族的文字,且收录了日韩汉字,是目前为止最全面的汉字字符集,共收录汉字 70000 多个。\nBIG5 # BIG5 主要针对的是繁体中文,收录了 13000 多个汉字。\nUnicode \u0026amp; UTF-8 # 为了更加适合本国语言,诞生了很多种字符集。\n我们上面也说了不同的字符集可以表示的字符范围以及编码规则存在差异。这就导致了一个非常严重的问题:使用错误的编码方式查看一个包含字符的文件就会产生乱码现象。\n就比如说你使用 UTF-8 编码方式打开 GB2312 编码格式的文件就会出现乱码。示例:“牛”这个汉字 GB2312 编码后的十六进制数值为 “C5A3”,而 “C5A3” 用 UTF-8 解码之后得到的却是 “ţ”。\n你可以通过这个网站在线进行编码和解码: https://www.haomeili.net/HanZi/ZiFuBianMaZhuanHuan\n这样我们就搞懂了乱码的本质:编码和解码时用了不同或者不兼容的字符集 。\n为了解决这个问题,人们就想:“如果我们能够有一种字符集将世界上所有的字符都纳入其中就好了!”。\n然后,Unicode 带着这个使命诞生了。\nUnicode 字符集中包含了世界上几乎所有已知的字符。不过,Unicode 字符集并没有规定如何存储这些字符(也就是如何使用二进制数据表示这些字符)。\n然后,就有了 UTF-8(8-bit Unicode Transformation Format)。类似的还有 UTF-16、 UTF-32。\nUTF-8 使用 1 到 4 个字节为每个字符编码, UTF-16 使用 2 或 4 个字节为每个字符编码,UTF-32 固定位 4 个字节为每个字符编码。\nUTF-8 可以根据不同的符号自动选择编码的长短,像英文字符只需要 1 个字节就够了,这一点 ASCII 字符集一样 。因此,对于英语字符,UTF-8 编码和 ASCII 码是相同的。\nUTF-32 的规则最简单,不过缺陷也比较明显,对于英文字母这类字符消耗的空间是 UTF-8 的 4 倍之多。\nUTF-8 是目前使用最广的一种字符编码。\nMySQL 字符集 # MySQL 支持很多种字符集的方式,比如 GB2312、GBK、BIG5、多种 Unicode 字符集(UTF-8 编码、UTF-16 编码、UCS-2 编码、UTF-32 编码等等)。\n查看支持的字符集 # 你可以通过 SHOW CHARSET 命令来查看,支持 like 和 where 子句。\n默认字符集 # 在 MySQL5.7 中,默认字符集是 latin1 ;在 MySQL8.0 中,默认字符集是 utf8mb4\n字符集的层次级别 # MySQL 中的字符集有以下的层次级别:\nserver(MySQL 实例级别) database(库级别) table(表级别) column(字段级别) 它们的优先级可以简单的认为是从上往下依次增大,也即 column 的优先级会大于 table 等其余层次的。如指定 MySQL 实例级别字符集是utf8mb4,指定某个表字符集是latin1,那么这个表的所有字段如果不指定的话,编码就是latin1。\nserver # 不同版本的 MySQL 其 server 级别的字符集默认值不同,在 MySQL5.7 中,其默认值是 latin1 ;在 MySQL8.0 中,其默认值是 utf8mb4 。\n当然也可以通过在启动 mysqld 时指定 --character-set-server 来设置 server 级别的字符集。\nmysqld mysqld --character-set-server=utf8mb4 mysqld --character-set-server=utf8mb4 \\ --collation-server=utf8mb4_0900_ai_ci 或者如果你是通过源码构建的方式启动的 MySQL,你可以在 cmake 命令中指定选项:\ncmake . -DDEFAULT_CHARSET=latin1 或者 cmake . -DDEFAULT_CHARSET=latin1 \\ -DDEFAULT_COLLATION=latin1_german1_ci 此外,你也可以在运行时改变 character_set_server 的值,从而达到修改 server 级别的字符集的目的。\nserver 级别的字符集是 MySQL 服务器的全局设置,它不仅会作为创建或修改数据库时的默认字符集(如果没有指定其他字符集),还会影响到客户端和服务器之间的连接字符集,具体可以查看 MySQL Connector/J 8.0 - 6.7 Using Character Sets and Unicode。\ndatabase # database 级别的字符集是我们在创建数据库和修改数据库时指定的:\nCREATE DATABASE db_name [[DEFAULT] CHARACTER SET charset_name] [[DEFAULT] COLLATE collation_name] ALTER DATABASE db_name [[DEFAULT] CHARACTER SET charset_name] [[DEFAULT] COLLATE collation_name] 如前面所说,如果在执行上述语句时未指定字符集,那么 MySQL 将会使用 server 级别的字符集。\n可以通过下面的方式查看某个数据库的字符集:\nUSE db_name; SELECT @@character_set_database, @@collation_database; SELECT DEFAULT_CHARACTER_SET_NAME, DEFAULT_COLLATION_NAME FROM INFORMATION_SCHEMA.SCHEMATA WHERE SCHEMA_NAME = \u0026#39;db_name\u0026#39;; table # table 级别的字符集是在创建表和修改表时指定的:\nCREATE TABLE tbl_name (column_list) [[DEFAULT] CHARACTER SET charset_name] [COLLATE collation_name]] ALTER TABLE tbl_name [[DEFAULT] CHARACTER SET charset_name] [COLLATE collation_name] 如果在创建表和修改表时未指定字符集,那么将会使用 database 级别的字符集。\ncolumn # column 级别的字符集同样是在创建表和修改表时指定的,只不过它是定义在列中。下面是个例子:\nCREATE TABLE t1 ( col1 VARCHAR(5) CHARACTER SET latin1 COLLATE latin1_german1_ci ); 如果未指定列级别的字符集,那么将会使用表级别的字符集。\n连接字符集 # 前面说到了字符集的层次级别,它们是和存储相关的。而连接字符集涉及的是和 MySQL 服务器的通信。\n连接字符集与下面这几个变量息息相关:\ncharacter_set_client :描述了客户端发送给服务器的 SQL 语句使用的是什么字符集。 character_set_connection :描述了服务器接收到 SQL 语句时使用什么字符集进行翻译。 character_set_results :描述了服务器返回给客户端的结果使用的是什么字符集。 它们的值可以通过下面的 SQL 语句查询:\nSELECT * FROM performance_schema.session_variables WHERE VARIABLE_NAME IN ( \u0026#39;character_set_client\u0026#39;, \u0026#39;character_set_connection\u0026#39;, \u0026#39;character_set_results\u0026#39;, \u0026#39;collation_connection\u0026#39; ) ORDER BY VARIABLE_NAME; SHOW SESSION VARIABLES LIKE \u0026#39;character\\_set\\_%\u0026#39;; 如果要想修改前面提到的几个变量的值,有以下方式:\n1、修改配置文件\n[mysql] # 只针对MySQL客户端程序 default-character-set=utf8mb4 2、使用 SQL 语句\nset names utf8mb4 # 或者一个个进行修改 # SET character_set_client = utf8mb4; # SET character_set_results = utf8mb4; # SET collation_connection = utf8mb4; JDBC 对连接字符集的影响 # 不知道你们有没有碰到过存储 emoji 表情正常,但是使用类似 Navicat 之类的软件的进行查询的时候,发现 emoji 表情变成了问号的情况。这个问题很有可能就是 JDBC 驱动引起的。\n根据前面的内容,我们知道连接字符集也是会影响我们存储的数据的,而 JDBC 驱动会影响连接字符集。\nmysql-connector-java (JDBC 驱动)主要通过这几个属性影响连接字符集:\ncharacterEncoding characterSetResults 以 DataGrip 2023.1.2 来说,在它配置数据源的高级对话框中,可以看到 characterSetResults 的默认值是 utf8 ,在使用 mysql-connector-java 8.0.25 时,连接字符集最后会被设置成 utf8mb3 。那么这种情况下 emoji 表情就会被显示为问号,并且当前版本驱动还不支持把 characterSetResults 设置为 utf8mb4 ,不过换成 mysql-connector-java driver 8.0.29 却是允许的。\n具体可以看一下 StackOverflow 的 DataGrip MySQL stores emojis correctly but displays them as?这个回答。\nUTF-8 使用 # 通常情况下,我们建议使用 UTF-8 作为默认的字符编码方式。\n不过,这里有一个小坑。\nMySQL 字符编码集中有两套 UTF-8 编码实现:\nutf8:utf8编码只支持1-3个字节 。 在 utf8 编码中,中文是占 3 个字节,其他数字、英文、符号占一个字节。但 emoji 符号占 4 个字节,一些较复杂的文字、繁体字也是 4 个字节。 utf8mb4:UTF-8 的完整实现,正版!最多支持使用 4 个字节表示字符,因此,可以用来存储 emoji 符号。 为什么有两套 UTF-8 编码实现呢? 原因如下:\n因此,如果你需要存储emoji类型的数据或者一些比较复杂的文字、繁体字到 MySQL 数据库的话,数据库的编码一定要指定为utf8mb4 而不是utf8 ,要不然存储的时候就会报错了。\n演示一下吧!(环境:MySQL 5.7+)\n建表语句如下,我们指定数据库 CHARSET 为 utf8 。\nCREATE TABLE `user` ( `id` varchar(66) CHARACTER SET utf8mb3 NOT NULL, `name` varchar(33) CHARACTER SET utf8mb3 NOT NULL, `phone` varchar(33) CHARACTER SET utf8mb3 DEFAULT NULL, `password` varchar(100) CHARACTER SET utf8mb3 DEFAULT NULL ) ENGINE=InnoDB DEFAULT CHARSET=utf8; 当我们执行下面的 insert 语句插入数据到数据库时,果然报错!\nINSERT INTO `user` (`id`, `name`, `phone`, `password`) VALUES (\u0026#39;A00003\u0026#39;, \u0026#39;guide哥😘😘😘\u0026#39;, \u0026#39;181631312312\u0026#39;, \u0026#39;123456\u0026#39;); 报错信息如下:\nIncorrect string value: \u0026#39;\\xF0\\x9F\\x98\\x98\\xF0\\x9F...\u0026#39; for column \u0026#39;name\u0026#39; at row 1 参考 # 字符集和字符编码(Charset \u0026amp; Encoding): https://www.cnblogs.com/skynet/archive/2011/05/03/2035105.html 十分钟搞清字符集和字符编码: http://cenalulu.github.io/linux/character-encoding/ Unicode-维基百科: https://zh.wikipedia.org/wiki/Unicode GB2312-维基百科: https://zh.wikipedia.org/wiki/GB_2312 UTF-8-维基百科: https://zh.wikipedia.org/wiki/UTF-8 GB18030-维基百科: https://zh.wikipedia.org/wiki/GB_18030 MySQL8 文档: https://dev.mysql.com/doc/refman/8.0/en/charset.html MySQL5.7 文档: https://dev.mysql.com/doc/refman/5.7/en/charset.html MySQL Connector/J 文档: https://dev.mysql.com/doc/connector-j/8.0/en/connector-j-reference-charsets.html "},{"id":677,"href":"/zh/docs/technology/Interview/java/jvm/jvm-parameters-intro/","title":"最重要的JVM参数总结","section":"Jvm","content":" 本文由 JavaGuide 翻译自 https://www.baeldung.com/jvm-parameters,并对文章进行了大量的完善补充。\nJDK 版本:1.8\n1.概述 # 在本篇文章中,你将掌握最常用的 JVM 参数配置。\n2.堆内存相关 # Java 虚拟机所管理的内存中最大的一块,Java 堆是所有线程共享的一块内存区域,在虚拟机启动时创建。此内存区域的唯一目的就是存放对象实例,几乎所有的对象实例以及数组都在这里分配内存。\n2.1.显式指定堆内存–Xms和-Xmx # 与性能有关的最常见实践之一是根据应用程序要求初始化堆内存。如果我们需要指定最小和最大堆大小(推荐显示指定大小),以下参数可以帮助你实现:\n-Xms\u0026lt;heap size\u0026gt;[unit] -Xmx\u0026lt;heap size\u0026gt;[unit] heap size 表示要初始化内存的具体大小。 unit 表示要初始化内存的单位。单位为 “ g” (GB)、“ m”(MB)、“ k”(KB)。 举个栗子 🌰,如果我们要为 JVM 分配最小 2 GB 和最大 5 GB 的堆内存大小,我们的参数应该这样来写:\n-Xms2G -Xmx5G 2.2.显式新生代内存(Young Generation) # 根据 Oracle 官方文档,在堆总可用内存配置完成之后,第二大影响因素是为 Young Generation 在堆内存所占的比例。默认情况下,YG 的最小大小为 1310 MB,最大大小为 无限制。\n一共有两种指定 新生代内存(Young Generation)大小的方法:\n1.通过-XX:NewSize和-XX:MaxNewSize指定\n-XX:NewSize=\u0026lt;young size\u0026gt;[unit] -XX:MaxNewSize=\u0026lt;young size\u0026gt;[unit] 举个栗子 🌰,如果我们要为 新生代分配 最小 256m 的内存,最大 1024m 的内存我们的参数应该这样来写:\n-XX:NewSize=256m -XX:MaxNewSize=1024m 2.通过-Xmn\u0026lt;young size\u0026gt;[unit]指定\n举个栗子 🌰,如果我们要为 新生代分配 256m 的内存(NewSize 与 MaxNewSize 设为一致),我们的参数应该这样来写:\n-Xmn256m GC 调优策略中很重要的一条经验总结是这样说的:\n将新对象预留在新生代,由于 Full GC 的成本远高于 Minor GC,因此尽可能将对象分配在新生代是明智的做法,实际项目中根据 GC 日志分析新生代空间大小分配是否合理,适当通过“-Xmn”命令调节新生代大小,最大限度降低新对象直接进入老年代的情况。\n另外,你还可以通过 -XX:NewRatio=\u0026lt;int\u0026gt; 来设置老年代与新生代内存的比值。\n比如下面的参数就是设置老年代与新生代内存的比值为 1。也就是说老年代和新生代所占比值为 1:1,新生代占整个堆栈的 1/2。\n-XX:NewRatio=1 2.3.显式指定永久代/元空间的大小 # 从 Java 8 开始,如果我们没有指定 Metaspace 的大小,随着更多类的创建,虚拟机会耗尽所有可用的系统内存(永久代并不会出现这种情况)。\nJDK 1.8 之前永久代还没被彻底移除的时候通常通过下面这些参数来调节方法区大小\n-XX:PermSize=N #方法区 (永久代) 初始大小 -XX:MaxPermSize=N #方法区 (永久代) 最大大小,超过这个值将会抛出 OutOfMemoryError 异常:java.lang.OutOfMemoryError: PermGen 相对而言,垃圾收集行为在这个区域是比较少出现的,但并非数据进入方法区后就“永久存在”了。\nJDK 1.8 的时候,方法区(HotSpot 的永久代)被彻底移除了(JDK1.7 就已经开始了),取而代之是元空间,元空间使用的是本地内存。\n下面是一些常用参数:\n-XX:MetaspaceSize=N #设置 Metaspace 的初始大小(是一个常见的误区,后面会解释) -XX:MaxMetaspaceSize=N #设置 Metaspace 的最大大小 🐛 修正(参见: issue#1947):\n1、Metaspace 的初始容量并不是 -XX:MetaspaceSize 设置,无论 -XX:MetaspaceSize 配置什么值,对于 64 位 JVM 来说,Metaspace 的初始容量都是 21807104(约 20.8m)。\n可以参考 Oracle 官方文档 Other Considerations 中提到的:\nSpecify a higher value for the option MetaspaceSize to avoid early garbage collections induced for class metadata. The amount of class metadata allocated for an application is application-dependent and general guidelines do not exist for the selection of MetaspaceSize. The default size of MetaspaceSize is platform-dependent and ranges from 12 MB to about 20 MB.\nMetaspaceSize 的默认大小取决于平台,范围从 12 MB 到大约 20 MB。\n另外,还可以看一下这个试验: JVM 参数 MetaspaceSize 的误解。\n2、Metaspace 由于使用不断扩容到-XX:MetaspaceSize参数指定的量,就会发生 FGC,且之后每次 Metaspace 扩容都会发生 Full GC。\n也就是说,MetaspaceSize 表示 Metaspace 使用过程中触发 Full GC 的阈值,只对触发起作用。\n垃圾搜集器内部是根据变量 _capacity_until_GC来判断 Metaspace 区域是否达到阈值的,初始化代码如下所示:\nvoid MetaspaceGC::initialize() { // Set the high-water mark to MaxMetapaceSize during VM initialization since // we can\u0026#39;t do a GC during initialization. _capacity_until_GC = MaxMetaspaceSize; } 相关阅读: issue 更正:MaxMetaspaceSize 如果不指定大小的话,不会耗尽内存 #1204 。\n3.垃圾收集相关 # 3.1.垃圾回收器 # 为了提高应用程序的稳定性,选择正确的 垃圾收集算法至关重要。\nJVM 具有四种类型的 GC 实现:\n串行垃圾收集器 并行垃圾收集器 CMS 垃圾收集器 G1 垃圾收集器 可以使用以下参数声明这些实现:\n-XX:+UseSerialGC -XX:+UseParallelGC -XX:+UseConcMarkSweepGC -XX:+UseG1GC 有关 垃圾回收 实施的更多详细信息,请参见 此处。\n3.2.GC 日志记录 # 生产环境上,或者其他要测试 GC 问题的环境上,一定会配置上打印 GC 日志的参数,便于分析 GC 相关的问题。\n# 必选 # 打印基本 GC 信息 -XX:+PrintGCDetails -XX:+PrintGCDateStamps # 打印对象分布 -XX:+PrintTenuringDistribution # 打印堆数据 -XX:+PrintHeapAtGC # 打印Reference处理信息 # 强引用/弱引用/软引用/虚引用/finalize 相关的方法 -XX:+PrintReferenceGC # 打印STW时间 -XX:+PrintGCApplicationStoppedTime # 可选 # 打印safepoint信息,进入 STW 阶段之前,需要要找到一个合适的 safepoint -XX:+PrintSafepointStatistics -XX:PrintSafepointStatisticsCount=1 # GC日志输出的文件路径 -Xloggc:/path/to/gc-%t.log # 开启日志文件分割 -XX:+UseGCLogFileRotation # 最多分割几个文件,超过之后从头文件开始写 -XX:NumberOfGCLogFiles=14 # 每个文件上限大小,超过就触发分割 -XX:GCLogFileSize=50M 4.处理 OOM # 对于大型应用程序来说,面对内存不足错误是非常常见的,这反过来会导致应用程序崩溃。这是一个非常关键的场景,很难通过复制来解决这个问题。\n这就是为什么 JVM 提供了一些参数,这些参数将堆内存转储到一个物理文件中,以后可以用来查找泄漏:\n-XX:+HeapDumpOnOutOfMemoryError -XX:HeapDumpPath=./java_pid\u0026lt;pid\u0026gt;.hprof -XX:OnOutOfMemoryError=\u0026#34;\u0026lt; cmd args \u0026gt;;\u0026lt; cmd args \u0026gt;\u0026#34; -XX:+UseGCOverheadLimit 这里有几点需要注意:\nHeapDumpOnOutOfMemoryError 指示 JVM 在遇到 OutOfMemoryError 错误时将 heap 转储到物理文件中。 HeapDumpPath 表示要写入文件的路径; 可以给出任何文件名; 但是,如果 JVM 在名称中找到一个 \u0026lt;pid\u0026gt; 标记,则当前进程的进程 id 将附加到文件名中,并使用.hprof格式 OnOutOfMemoryError 用于发出紧急命令,以便在内存不足的情况下执行; 应该在 cmd args 空间中使用适当的命令。例如,如果我们想在内存不足时重启服务器,我们可以设置参数: -XX:OnOutOfMemoryError=\u0026quot;shutdown -r\u0026quot; 。 UseGCOverheadLimit 是一种策略,它限制在抛出 OutOfMemory 错误之前在 GC 中花费的 VM 时间的比例 5.其他 # -server : 启用“ Server Hotspot VM”; 此参数默认用于 64 位 JVM -XX:+UseStringDeduplication : Java 8u20 引入了这个 JVM 参数,通过创建太多相同 String 的实例来减少不必要的内存使用; 这通过将重复 String 值减少为单个全局 char [] 数组来优化堆内存。 -XX:+UseLWPSynchronization: 设置基于 LWP (轻量级进程)的同步策略,而不是基于线程的同步。 -XX:LargePageSizeInBytes: 设置用于 Java 堆的较大页面大小; 它采用 GB/MB/KB 的参数; 页面大小越大,我们可以更好地利用虚拟内存硬件资源; 然而,这可能会导致 PermGen 的空间大小更大,这反过来又会迫使 Java 堆空间的大小减小。 -XX:MaxHeapFreeRatio : 设置 GC 后, 堆空闲的最大百分比,以避免收缩。 -XX:SurvivorRatio : eden/survivor 空间的比例, 例如-XX:SurvivorRatio=6 设置每个 survivor 和 eden 之间的比例为 1:6。 -XX:+UseLargePages : 如果系统支持,则使用大页面内存; 请注意,如果使用这个 JVM 参数,OpenJDK 7 可能会崩溃。 -XX:+UseStringCache : 启用 String 池中可用的常用分配字符串的缓存。 -XX:+UseCompressedStrings : 对 String 对象使用 byte [] 类型,该类型可以用纯 ASCII 格式表示。 -XX:+OptimizeStringConcat : 它尽可能优化字符串串联操作。 文章推荐 # 这里推荐了非常多优质的 JVM 实践相关的文章,推荐阅读,尤其是 JVM 性能优化和问题排查相关的文章。\nJVM 参数配置说明 - 阿里云官方文档 - 2022 JVM 内存配置最佳实践 - 阿里云官方文档 - 2022 求你了,GC 日志打印别再瞎配置了 - 思否 - 2022 一次大量 JVM Native 内存泄露的排查分析(64M 问题) - 掘金 - 2022 一次线上 JVM 调优实践,FullGC40 次/天到 10 天一次的优化过程 - HeapDump - 2021 听说 JVM 性能优化很难?今天我小试了一把! - 陈树义 - 2021 你们要的线上 GC 问题案例来啦 - 编了个程 - 2021 Java 中 9 种常见的 CMS GC 问题分析与解决 - 美团技术团队 - 2020 从实际案例聊聊 Java 应用的 GC 优化-美团技术团队 - 美团技术团队 - 2017 "}] \ No newline at end of file diff --git a/zh.search.min.8fb130b0e1e4aae70c01471fefd03c03e149d55fd9c2033fcec5cf33b6eec5dc.js b/zh.search.min.cc1ede0869cd0fba154732dd9c7a2ae1288d85f94c56a1082680676f0db70c8e.js similarity index 91% rename from zh.search.min.8fb130b0e1e4aae70c01471fefd03c03e149d55fd9c2033fcec5cf33b6eec5dc.js rename to zh.search.min.cc1ede0869cd0fba154732dd9c7a2ae1288d85f94c56a1082680676f0db70c8e.js index b6bb71f7c95..e8e088f70e2 100644 --- a/zh.search.min.8fb130b0e1e4aae70c01471fefd03c03e149d55fd9c2033fcec5cf33b6eec5dc.js +++ b/zh.search.min.cc1ede0869cd0fba154732dd9c7a2ae1288d85f94c56a1082680676f0db70c8e.js @@ -1 +1 @@ -"use strict";(function(){const o="/zh.search-data.min.91e7f22db4c23c8aa5c240e671abe31713d24c3f0f93c5c62c6fd85567973880.json",i=Object.assign({encode:!1,tokenize:function(e){return e.replace(/[\x00-\x7F]/g,"").split("")}},{includeScore:!0,useExtendedSearch:!0,fieldNormWeight:1.5,threshold:.2,ignoreLocation:!0,keys:[{name:"title",weight:.7},{name:"content",weight:.3}]}),e=document.querySelector("#book-search-input"),t=document.querySelector("#book-search-results");if(!e)return;e.addEventListener("focus",n),e.addEventListener("keyup",s),document.addEventListener("keypress",a);function a(t){if(t.target.value!==void 0)return;if(e===document.activeElement)return;const n=String.fromCharCode(t.charCode);if(!r(n))return;e.focus(),t.preventDefault()}function r(t){const n=e.getAttribute("data-hotkeys")||"";return n.indexOf(t)>=0}function n(){e.removeEventListener("focus",n),e.required=!0,fetch(o).then(e=>e.json()).then(e=>{window.bookSearchIndex=new Fuse(e,i)}).then(()=>e.required=!1).then(s)}function s(){for(;t.firstChild;)t.removeChild(t.firstChild);if(!e.value)return;const n=window.bookSearchIndex.search(e.value).slice(0,10);n.forEach(function(e){const n=c("
  • "),s=n.querySelector("a"),o=n.querySelector("small");s.href=e.item.href,s.textContent=e.item.title,o.textContent=e.item.section,t.appendChild(n)})}function c(e){const t=document.createElement("div");return t.innerHTML=e,t.firstChild}})() \ No newline at end of file +"use strict";(function(){const o="/zh.search-data.min.5ae7646166a6fa591581bcf3a1abb5f2964eadd7a4960ad955e166dcdd20567f.json",i=Object.assign({encode:!1,tokenize:function(e){return e.replace(/[\x00-\x7F]/g,"").split("")}},{includeScore:!0,useExtendedSearch:!0,fieldNormWeight:1.5,threshold:.2,ignoreLocation:!0,keys:[{name:"title",weight:.7},{name:"content",weight:.3}]}),e=document.querySelector("#book-search-input"),t=document.querySelector("#book-search-results");if(!e)return;e.addEventListener("focus",n),e.addEventListener("keyup",s),document.addEventListener("keypress",a);function a(t){if(t.target.value!==void 0)return;if(e===document.activeElement)return;const n=String.fromCharCode(t.charCode);if(!r(n))return;e.focus(),t.preventDefault()}function r(t){const n=e.getAttribute("data-hotkeys")||"";return n.indexOf(t)>=0}function n(){e.removeEventListener("focus",n),e.required=!0,fetch(o).then(e=>e.json()).then(e=>{window.bookSearchIndex=new Fuse(e,i)}).then(()=>e.required=!1).then(s)}function s(){for(;t.firstChild;)t.removeChild(t.firstChild);if(!e.value)return;const n=window.bookSearchIndex.search(e.value).slice(0,10);n.forEach(function(e){const n=c("
  • "),s=n.querySelector("a"),o=n.querySelector("small");s.href=e.item.href,s.textContent=e.item.title,o.textContent=e.item.section,t.appendChild(n)})}function c(e){const t=document.createElement("div");return t.innerHTML=e,t.firstChild}})() \ No newline at end of file diff --git a/zh/404.html b/zh/404.html index 546a14dd178..3577ab1060d 100644 --- a/zh/404.html +++ b/zh/404.html @@ -1,2 +1,2 @@ 404 Page not found | 随记 -

    404

    Page Not Found

    随记

    \ No newline at end of file +

    404

    Page Not Found

    随记

    \ No newline at end of file diff --git a/zh/categories/index.html b/zh/categories/index.html index ffc94a6621c..3ef95ab1664 100644 --- a/zh/categories/index.html +++ b/zh/categories/index.html @@ -1,5 +1,5 @@ Categories | 随记 - +